Week 5: Linear Discriminant Analysis - Interactive Demonstrations

MPS311/439 Machine Learning

This notebook contains three interactive demonstrations to help you understand: 1. How LDA finds the optimal projection direction 2. How LDA compares to Logistic Regression 3. When to use LDA vs QDA

Instructions: Run each cell and use the sliders to explore different scenarios!

# Import required libraries
import numpy as np
import matplotlib.pyplot as plt
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis, QuadraticDiscriminantAnalysis
from sklearn.linear_model import LogisticRegression
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
import ipywidgets as widgets
from ipywidgets import interact, interactive, fixed
from IPython.display import display
import warnings
warnings.filterwarnings('ignore')

# Set random seed for reproducibility
np.random.seed(42)

# Set plot style
plt.style.use('seaborn-v0_8-darkgrid')
%matplotlib inline

print("✅ All libraries imported successfully!")
print("\n📊 Ready for interactive demonstrations!")

Demo 1: LDA Projection Visualization

Understanding How LDA Finds the Optimal Projection

This demo shows: - Original 2D data with two classes - The projection direction (arrow) found by LDA - The decision boundary (perpendicular to projection) - Projected 1D values as histograms

Play with the sliders to see how different data characteristics affect LDA!

def plot_lda_projection(n_samples=200, class_sep=1.0, cluster_std=1.0, random_state=42):
    """
    Interactive visualization of LDA projection
    
    Parameters:
    - n_samples: Number of samples per class
    - class_sep: Separation between class centers (higher = easier to separate)
    - cluster_std: Standard deviation within each class (higher = more spread)
    - random_state: Random seed for reproducibility
    """
    
    # Generate synthetic 2D data
    X, y = make_classification(
        n_samples=n_samples*2,
        n_features=2,
        n_redundant=0,
        n_informative=2,
        n_clusters_per_class=1,
        class_sep=class_sep,
        flip_y=0,
        random_state=random_state,
        shuffle=True
    )
    
    # Scale the cluster spread
    X = X * cluster_std
    
    # Fit LDA
    lda = LinearDiscriminantAnalysis()
    lda.fit(X, y)
    
    # Get projection direction (normalized)
    w = lda.coef_[0]
    w_normalized = w / np.linalg.norm(w)
    
    # Project data onto LDA direction
    X_projected = X @ w.T
    
    # Create figure with subplots
    fig = plt.figure(figsize=(16, 6))
    
    # Subplot 1: Original 2D data with projection direction
    ax1 = plt.subplot(1, 3, 1)
    
    # Plot data points
    scatter1 = ax1.scatter(X[y==0, 0], X[y==0, 1], c='blue', label='Class 0', 
                          alpha=0.6, edgecolors='k', s=50)
    scatter2 = ax1.scatter(X[y==1, 0], X[y==1, 1], c='red', label='Class 1', 
                          alpha=0.6, edgecolors='k', s=50)
    
    # Plot projection direction as arrow
    center = X.mean(axis=0)
    arrow_scale = 3
    ax1.arrow(center[0], center[1], 
             w_normalized[0]*arrow_scale, w_normalized[1]*arrow_scale,
             head_width=0.3, head_length=0.3, fc='green', ec='green', 
             linewidth=3, label='Projection direction w')
    
    # Plot decision boundary (perpendicular to w)
    x_min, x_max = X[:, 0].min() - 1, X[:, 0].max() + 1
    y_min, y_max = X[:, 1].min() - 1, X[:, 1].max() + 1
    
    xx, yy = np.meshgrid(np.linspace(x_min, x_max, 200),
                         np.linspace(y_min, y_max, 200))
    Z = lda.predict(np.c_[xx.ravel(), yy.ravel()])
    Z = Z.reshape(xx.shape)
    
    ax1.contour(xx, yy, Z, levels=[0.5], colors='black', linewidths=2, 
               linestyles='--', label='Decision boundary')
    ax1.contourf(xx, yy, Z, alpha=0.2, levels=[0, 0.5, 1], colors=['blue', 'red'])
    
    ax1.set_xlabel('Feature 1', fontsize=12)
    ax1.set_ylabel('Feature 2', fontsize=12)
    ax1.set_title('Original 2D Data with LDA Projection', fontsize=14, fontweight='bold')
    ax1.legend(loc='best')
    ax1.grid(True, alpha=0.3)
    ax1.set_xlim(x_min, x_max)
    ax1.set_ylim(y_min, y_max)
    
    # Subplot 2: Projection onto line
    ax2 = plt.subplot(1, 3, 2)
    
    # Project points onto the projection direction line
    projections_on_line = np.outer(X @ w.T, w_normalized)
    
    # Plot the projection line
    line_extent = max(np.abs(X @ w.T)) * 1.2
    line_points = np.array([-line_extent, line_extent])
    ax2.plot(center[0] + line_points * w_normalized[0], 
            center[1] + line_points * w_normalized[1], 
            'g-', linewidth=3, label='Projection line')
    
    # Plot original points (faded)
    ax2.scatter(X[y==0, 0], X[y==0, 1], c='blue', alpha=0.2, s=30)
    ax2.scatter(X[y==1, 0], X[y==1, 1], c='red', alpha=0.2, s=30)
    
    # Plot projected points on the line
    ax2.scatter(center[0] + projections_on_line[y==0, 0], 
               center[1] + projections_on_line[y==0, 1], 
               c='blue', s=50, edgecolors='k', label='Class 0 projected')
    ax2.scatter(center[0] + projections_on_line[y==1, 0], 
               center[1] + projections_on_line[y==1, 1], 
               c='red', s=50, edgecolors='k', label='Class 1 projected')
    
    # Draw lines from points to their projections
    for i in range(0, len(X), max(1, len(X)//20)):  # Draw only subset for clarity
        ax2.plot([X[i, 0], center[0] + projections_on_line[i, 0]], 
                [X[i, 1], center[1] + projections_on_line[i, 1]], 
                'gray', alpha=0.3, linewidth=0.5)
    
    ax2.set_xlabel('Feature 1', fontsize=12)
    ax2.set_ylabel('Feature 2', fontsize=12)
    ax2.set_title('Data Projected onto LDA Direction', fontsize=14, fontweight='bold')
    ax2.legend(loc='best')
    ax2.grid(True, alpha=0.3)
    ax2.set_xlim(x_min, x_max)
    ax2.set_ylim(y_min, y_max)
    
    # Subplot 3: 1D projected values (histogram)
    ax3 = plt.subplot(1, 3, 3)
    
    # Plot histograms of projected values
    ax3.hist(X_projected[y==0], bins=20, color='blue', alpha=0.6, 
            label='Class 0', edgecolor='black')
    ax3.hist(X_projected[y==1], bins=20, color='red', alpha=0.6, 
            label='Class 1', edgecolor='black')
    
    # Mark class means
    mean0 = X_projected[y==0].mean()
    mean1 = X_projected[y==1].mean()
    ax3.axvline(mean0, color='blue', linewidth=3, linestyle='--', label=f'Mean 0: {mean0:.2f}')
    ax3.axvline(mean1, color='red', linewidth=3, linestyle='--', label=f'Mean 1: {mean1:.2f}')
    
    # Mark decision threshold
    threshold = (mean0 + mean1) / 2
    ax3.axvline(threshold, color='black', linewidth=2, linestyle=':', 
               label=f'Threshold: {threshold:.2f}')
    
    ax3.set_xlabel('Projected Value (z = w^T x)', fontsize=12)
    ax3.set_ylabel('Frequency', fontsize=12)
    ax3.set_title('1D Projected Values Distribution', fontsize=14, fontweight='bold')
    ax3.legend(loc='best')
    ax3.grid(True, alpha=0.3)
    
    # Calculate and display metrics
    separation = abs(mean1 - mean0)
    std0 = X_projected[y==0].std()
    std1 = X_projected[y==1].std()
    fisher_ratio = separation / (std0 + std1)
    
    # Add text box with metrics
    textstr = f'Separation: {separation:.2f}\nWithin-class std: {(std0+std1)/2:.2f}\nFisher ratio: {fisher_ratio:.2f}'
    props = dict(boxstyle='round', facecolor='wheat', alpha=0.8)
    ax3.text(0.05, 0.95, textstr, transform=ax3.transAxes, fontsize=10,
            verticalalignment='top', bbox=props)
    
    plt.tight_layout()
    plt.show()
    
    # Print interpretation
    print(f"\n📊 Interpretation:")
    print(f"   • Projection direction w = [{w[0]:.3f}, {w[1]:.3f}]")
    print(f"   • Between-class separation: {separation:.3f}")
    print(f"   • Within-class spread: {(std0+std1)/2:.3f}")
    print(f"   • Fisher's ratio (separation/spread): {fisher_ratio:.3f}")
    print(f"\n💡 Higher Fisher ratio = Better class separation!")

# Create interactive widget
interactive_plot = interactive(
    plot_lda_projection,
    n_samples=widgets.IntSlider(min=50, max=300, step=50, value=150, 
                                description='Samples/class:', style={'description_width': '150px'}),
    class_sep=widgets.FloatSlider(min=0.5, max=3.0, step=0.5, value=1.5, 
                                 description='Class separation:', style={'description_width': '150px'}),
    cluster_std=widgets.FloatSlider(min=0.5, max=2.0, step=0.25, value=1.0, 
                                   description='Cluster spread:', style={'description_width': '150px'}),
    random_state=widgets.IntSlider(min=0, max=100, step=1, value=42, 
                                  description='Random seed:', style={'description_width': '150px'})
)

display(interactive_plot)

🎯 Key Observations from Demo 1:

  1. Projection Direction (Green Arrow): This is the direction \(\mathbf{w}\) that LDA finds
  2. Decision Boundary (Dashed Line): Perpendicular to the projection direction
  3. 1D Histograms: Shows how classes separate after projection
  4. Fisher’s Ratio: Higher values mean better separation!

Try this: - Increase “Class separation” → See Fisher’s ratio increase - Increase “Cluster spread” → See more overlap, Fisher’s ratio decreases - Change “Random seed” → See different random datasets


Demo 2: LDA vs Logistic Regression

Comparing Two Linear Classifiers

Both LDA and Logistic Regression create linear decision boundaries, but they find them differently: - LDA: Finds optimal projection direction (assumes Gaussian classes) - Logistic Regression: Directly optimizes decision boundary

Explore how they compare under different conditions!

def compare_lda_logreg(n_samples=200, class_sep=1.5, cluster_std=1.0, 
                       noise_level=0.0, random_state=42):
    """
    Compare LDA and Logistic Regression decision boundaries
    
    Parameters:
    - n_samples: Number of samples per class
    - class_sep: Separation between classes
    - cluster_std: Within-class standard deviation
    - noise_level: Amount of label noise (0-0.3)
    - random_state: Random seed
    """
    
    # Generate data
    X, y = make_classification(
        n_samples=n_samples*2,
        n_features=2,
        n_redundant=0,
        n_informative=2,
        n_clusters_per_class=1,
        class_sep=class_sep,
        flip_y=noise_level,
        random_state=random_state,
        shuffle=True
    )
    X = X * cluster_std
    
    # Split data
    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.3, random_state=random_state
    )
    
    # Fit models
    lda = LinearDiscriminantAnalysis()
    logreg = LogisticRegression(max_iter=1000)
    
    lda.fit(X_train, y_train)
    logreg.fit(X_train, y_train)
    
    # Make predictions
    lda_pred = lda.predict(X_test)
    logreg_pred = logreg.predict(X_test)
    
    # Calculate accuracies
    lda_acc = accuracy_score(y_test, lda_pred)
    logreg_acc = accuracy_score(y_test, logreg_pred)
    
    # Create mesh for decision boundary
    x_min, x_max = X[:, 0].min() - 1, X[:, 0].max() + 1
    y_min, y_max = X[:, 1].min() - 1, X[:, 1].max() + 1
    xx, yy = np.meshgrid(np.linspace(x_min, x_max, 200),
                         np.linspace(y_min, y_max, 200))
    
    # Create figure
    fig, axes = plt.subplots(1, 3, figsize=(18, 5))
    
    # Plot 1: LDA
    ax1 = axes[0]
    Z_lda = lda.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)
    ax1.contourf(xx, yy, Z_lda, alpha=0.3, levels=[0, 0.5, 1], colors=['blue', 'red'])
    ax1.contour(xx, yy, Z_lda, levels=[0.5], colors='black', linewidths=3, linestyles='-')
    
    ax1.scatter(X_train[y_train==0, 0], X_train[y_train==0, 1], 
               c='blue', marker='o', s=50, alpha=0.6, edgecolors='k', label='Train Class 0')
    ax1.scatter(X_train[y_train==1, 0], X_train[y_train==1, 1], 
               c='red', marker='o', s=50, alpha=0.6, edgecolors='k', label='Train Class 1')
    ax1.scatter(X_test[y_test==0, 0], X_test[y_test==0, 1], 
               c='blue', marker='s', s=80, edgecolors='yellow', linewidths=2, label='Test Class 0')
    ax1.scatter(X_test[y_test==1, 0], X_test[y_test==1, 1], 
               c='red', marker='s', s=80, edgecolors='yellow', linewidths=2, label='Test Class 1')
    
    ax1.set_xlabel('Feature 1', fontsize=12)
    ax1.set_ylabel('Feature 2', fontsize=12)
    ax1.set_title(f'LDA\nAccuracy: {lda_acc:.3f}', fontsize=14, fontweight='bold')
    ax1.legend(loc='best', fontsize=8)
    ax1.grid(True, alpha=0.3)
    ax1.set_xlim(x_min, x_max)
    ax1.set_ylim(y_min, y_max)
    
    # Plot 2: Logistic Regression
    ax2 = axes[1]
    Z_logreg = logreg.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)
    ax2.contourf(xx, yy, Z_logreg, alpha=0.3, levels=[0, 0.5, 1], colors=['blue', 'red'])
    ax2.contour(xx, yy, Z_logreg, levels=[0.5], colors='black', linewidths=3, linestyles='-')
    
    ax2.scatter(X_train[y_train==0, 0], X_train[y_train==0, 1], 
               c='blue', marker='o', s=50, alpha=0.6, edgecolors='k', label='Train Class 0')
    ax2.scatter(X_train[y_train==1, 0], X_train[y_train==1, 1], 
               c='red', marker='o', s=50, alpha=0.6, edgecolors='k', label='Train Class 1')
    ax2.scatter(X_test[y_test==0, 0], X_test[y_test==0, 1], 
               c='blue', marker='s', s=80, edgecolors='yellow', linewidths=2, label='Test Class 0')
    ax2.scatter(X_test[y_test==1, 0], X_test[y_test==1, 1], 
               c='red', marker='s', s=80, edgecolors='yellow', linewidths=2, label='Test Class 1')
    
    ax2.set_xlabel('Feature 1', fontsize=12)
    ax2.set_ylabel('Feature 2', fontsize=12)
    ax2.set_title(f'Logistic Regression\nAccuracy: {logreg_acc:.3f}', fontsize=14, fontweight='bold')
    ax2.legend(loc='best', fontsize=8)
    ax2.grid(True, alpha=0.3)
    ax2.set_xlim(x_min, x_max)
    ax2.set_ylim(y_min, y_max)
    
    # Plot 3: Overlay both boundaries
    ax3 = axes[2]
    
    ax3.scatter(X_train[y_train==0, 0], X_train[y_train==0, 1], 
               c='blue', marker='o', s=50, alpha=0.6, edgecolors='k', label='Train Class 0')
    ax3.scatter(X_train[y_train==1, 0], X_train[y_train==1, 1], 
               c='red', marker='o', s=50, alpha=0.6, edgecolors='k', label='Train Class 1')
    
    # Draw both boundaries
    ax3.contour(xx, yy, Z_lda, levels=[0.5], colors='blue', linewidths=3, 
               linestyles='-', label='LDA boundary')
    ax3.contour(xx, yy, Z_logreg, levels=[0.5], colors='green', linewidths=3, 
               linestyles='--', label='LogReg boundary')
    
    ax3.set_xlabel('Feature 1', fontsize=12)
    ax3.set_ylabel('Feature 2', fontsize=12)
    ax3.set_title('Both Boundaries Overlaid', fontsize=14, fontweight='bold')
    ax3.legend(loc='best')
    ax3.grid(True, alpha=0.3)
    ax3.set_xlim(x_min, x_max)
    ax3.set_ylim(y_min, y_max)
    
    plt.tight_layout()
    plt.show()
    
    # Print comparison
    print(f"\n📊 Model Comparison:")
    print(f"   • LDA Test Accuracy: {lda_acc:.4f}")
    print(f"   • Logistic Regression Test Accuracy: {logreg_acc:.4f}")
    print(f"   • Difference: {abs(lda_acc - logreg_acc):.4f}")
    
    if abs(lda_acc - logreg_acc) < 0.02:
        print(f"\n💡 Both methods perform similarly - boundaries are nearly identical!")
    elif lda_acc > logreg_acc:
        print(f"\n💡 LDA performs better - data likely follows Gaussian assumptions!")
    else:
        print(f"\n💡 LogReg performs better - fewer assumptions help with noisy data!")

# Create interactive widget
interactive_plot2 = interactive(
    compare_lda_logreg,
    n_samples=widgets.IntSlider(min=50, max=300, step=50, value=150, 
                                description='Samples/class:', style={'description_width': '150px'}),
    class_sep=widgets.FloatSlider(min=0.5, max=3.0, step=0.5, value=1.5, 
                                 description='Class separation:', style={'description_width': '150px'}),
    cluster_std=widgets.FloatSlider(min=0.5, max=2.0, step=0.25, value=1.0, 
                                   description='Cluster spread:', style={'description_width': '150px'}),
    noise_level=widgets.FloatSlider(min=0.0, max=0.3, step=0.05, value=0.0, 
                                   description='Label noise:', style={'description_width': '150px'}),
    random_state=widgets.IntSlider(min=0, max=100, step=1, value=42, 
                                  description='Random seed:', style={'description_width': '150px'})
)

display(interactive_plot2)

🎯 Key Observations from Demo 2:

  1. Similar Performance: For well-separated Gaussian data, both methods work similarly
  2. Different Boundaries: The decision boundaries are usually close but not identical
  3. Noise Sensitivity: Try increasing “Label noise” - see how each method handles it

Try this: - Set “Label noise” = 0.0 → Both methods agree - Increase “Label noise” → See which method is more robust - High “Cluster spread” + Low “Class separation” → More interesting differences


Demo 3: LDA vs QDA vs Logistic Regression

When Classes Have Different Spreads

This demo shows what happens when we violate LDA’s equal covariance assumption: - LDA: Forces linear boundary (may be suboptimal) - QDA: Allows quadratic (curved) boundary - Logistic Regression: Linear boundary (different from LDA)

See when QDA’s flexibility wins!

def compare_lda_qda_logreg(n_samples=150, mean_separation=3.0, 
                          class0_cov_ratio=1.0, class1_cov_ratio=3.0,
                          rotation_angle=45, random_state=42):
    """
    Compare LDA, QDA, and Logistic Regression with different covariances
    
    Parameters:
    - n_samples: Number of samples per class
    - mean_separation: Distance between class centers
    - class0_cov_ratio: Ratio of variances for class 0 (x-var / y-var)
    - class1_cov_ratio: Ratio of variances for class 1 (x-var / y-var)
    - rotation_angle: Rotation angle for covariances (degrees)
    - random_state: Random seed
    """
    
    np.random.seed(random_state)
    
    # Create rotation matrix
    theta = np.radians(rotation_angle)
    rotation = np.array([[np.cos(theta), -np.sin(theta)],
                        [np.sin(theta), np.cos(theta)]])
    
    # Create covariance matrices with different shapes
    cov0_base = np.array([[class0_cov_ratio, 0], [0, 1.0]])
    cov1_base = np.array([[class1_cov_ratio, 0], [0, 1.0]])
    
    # Apply rotation
    cov0 = rotation @ cov0_base @ rotation.T
    cov1 = rotation @ cov1_base @ rotation.T
    
    # Generate data with different covariances
    mean0 = np.array([0, 0])
    mean1 = np.array([mean_separation, 0])
    
    X0 = np.random.multivariate_normal(mean0, cov0, n_samples)
    X1 = np.random.multivariate_normal(mean1, cov1, n_samples)
    
    X = np.vstack([X0, X1])
    y = np.hstack([np.zeros(n_samples), np.ones(n_samples)])
    
    # Shuffle
    shuffle_idx = np.random.permutation(len(X))
    X = X[shuffle_idx]
    y = y[shuffle_idx]
    
    # Split data
    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.3, random_state=random_state
    )
    
    # Fit models
    lda = LinearDiscriminantAnalysis()
    qda = QuadraticDiscriminantAnalysis()
    logreg = LogisticRegression(max_iter=1000)
    
    lda.fit(X_train, y_train)
    qda.fit(X_train, y_train)
    logreg.fit(X_train, y_train)
    
    # Calculate accuracies
    lda_acc = accuracy_score(y_test, lda.predict(X_test))
    qda_acc = accuracy_score(y_test, qda.predict(X_test))
    logreg_acc = accuracy_score(y_test, logreg.predict(X_test))
    
    # Create mesh
    x_min, x_max = X[:, 0].min() - 2, X[:, 0].max() + 2
    y_min, y_max = X[:, 1].min() - 2, X[:, 1].max() + 2
    xx, yy = np.meshgrid(np.linspace(x_min, x_max, 300),
                         np.linspace(y_min, y_max, 300))
    
    # Create figure
    fig, axes = plt.subplots(2, 2, figsize=(16, 14))
    
    models = [('LDA', lda, lda_acc), ('QDA', qda, qda_acc), 
              ('Logistic Regression', logreg, logreg_acc)]
    axes_flat = [axes[0, 0], axes[0, 1], axes[1, 0]]
    
    for idx, (name, model, acc) in enumerate(models):
        ax = axes_flat[idx]
        
        # Predict on mesh
        Z = model.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)
        
        # Plot decision regions
        ax.contourf(xx, yy, Z, alpha=0.3, levels=[0, 0.5, 1], colors=['blue', 'red'])
        
        # Plot decision boundary
        ax.contour(xx, yy, Z, levels=[0.5], colors='black', linewidths=3)
        
        # Plot training data
        ax.scatter(X_train[y_train==0, 0], X_train[y_train==0, 1], 
                  c='blue', marker='o', s=50, alpha=0.6, edgecolors='k', label='Train Class 0')
        ax.scatter(X_train[y_train==1, 0], X_train[y_train==1, 1], 
                  c='red', marker='o', s=50, alpha=0.6, edgecolors='k', label='Train Class 1')
        
        # Plot test data
        ax.scatter(X_test[y_test==0, 0], X_test[y_test==0, 1], 
                  c='blue', marker='s', s=80, edgecolors='yellow', linewidths=2, label='Test Class 0')
        ax.scatter(X_test[y_test==1, 0], X_test[y_test==1, 1], 
                  c='red', marker='s', s=80, edgecolors='yellow', linewidths=2, label='Test Class 1')
        
        ax.set_xlabel('Feature 1', fontsize=12)
        ax.set_ylabel('Feature 2', fontsize=12)
        ax.set_title(f'{name}\nTest Accuracy: {acc:.3f}', fontsize=14, fontweight='bold')
        ax.legend(loc='best', fontsize=9)
        ax.grid(True, alpha=0.3)
        ax.set_xlim(x_min, x_max)
        ax.set_ylim(y_min, y_max)
    
    # Fourth subplot: All boundaries together
    ax4 = axes[1, 1]
    
    # Plot data
    ax4.scatter(X_train[y_train==0, 0], X_train[y_train==0, 1], 
               c='blue', marker='o', s=50, alpha=0.6, edgecolors='k', label='Train Class 0')
    ax4.scatter(X_train[y_train==1, 0], X_train[y_train==1, 1], 
               c='red', marker='o', s=50, alpha=0.6, edgecolors='k', label='Train Class 1')
    
    # Plot all boundaries
    Z_lda = lda.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)
    Z_qda = qda.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)
    Z_logreg = logreg.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)
    
    ax4.contour(xx, yy, Z_lda, levels=[0.5], colors='blue', linewidths=3, 
               linestyles='-', label='LDA')
    ax4.contour(xx, yy, Z_qda, levels=[0.5], colors='green', linewidths=3, 
               linestyles='-', label='QDA (curved!)')
    ax4.contour(xx, yy, Z_logreg, levels=[0.5], colors='orange', linewidths=3, 
               linestyles='--', label='LogReg')
    
    ax4.set_xlabel('Feature 1', fontsize=12)
    ax4.set_ylabel('Feature 2', fontsize=12)
    ax4.set_title('All Decision Boundaries Compared', fontsize=14, fontweight='bold')
    ax4.legend(loc='best', fontsize=10)
    ax4.grid(True, alpha=0.3)
    ax4.set_xlim(x_min, x_max)
    ax4.set_ylim(y_min, y_max)
    
    plt.tight_layout()
    plt.show()
    
    # Print detailed comparison
    print(f"\n📊 Model Performance Comparison:")
    print(f"   • LDA Test Accuracy: {lda_acc:.4f}")
    print(f"   • QDA Test Accuracy: {qda_acc:.4f}")
    print(f"   • Logistic Regression Test Accuracy: {logreg_acc:.4f}")
    
    print(f"\n📐 Covariance Settings:")
    print(f"   • Class 0 covariance ratio: {class0_cov_ratio:.1f}")
    print(f"   • Class 1 covariance ratio: {class1_cov_ratio:.1f}")
    print(f"   • Difference in spreads: {abs(class1_cov_ratio - class0_cov_ratio):.1f}")
    
    best_model = max([(lda_acc, 'LDA'), (qda_acc, 'QDA'), (logreg_acc, 'Logistic Regression')])
    print(f"\n🏆 Best performing model: {best_model[1]} ({best_model[0]:.4f})")
    
    if abs(class1_cov_ratio - class0_cov_ratio) > 1.5:
        if qda_acc > max(lda_acc, logreg_acc) + 0.02:
            print(f"\n💡 QDA wins! The classes have different covariances, so QDA's curved boundary helps!")
        else:
            print(f"\n💡 Despite different covariances, the classes may be too separated for flexibility to matter.")
    else:
        print(f"\n💡 Covariances are similar, so all methods perform comparably.")

# Create interactive widget
interactive_plot3 = interactive(
    compare_lda_qda_logreg,
    n_samples=widgets.IntSlider(min=50, max=250, step=50, value=150, 
                               description='Samples/class:', style={'description_width': '150px'}),
    mean_separation=widgets.FloatSlider(min=1.0, max=5.0, step=0.5, value=3.0, 
                                       description='Mean separation:', style={'description_width': '150px'}),
    class0_cov_ratio=widgets.FloatSlider(min=0.5, max=4.0, step=0.5, value=1.0, 
                                        description='Class 0 cov ratio:', style={'description_width': '150px'}),
    class1_cov_ratio=widgets.FloatSlider(min=0.5, max=4.0, step=0.5, value=3.0, 
                                        description='Class 1 cov ratio:', style={'description_width': '150px'}),
    rotation_angle=widgets.IntSlider(min=0, max=90, step=15, value=45, 
                                    description='Rotation angle:', style={'description_width': '150px'}),
    random_state=widgets.IntSlider(min=0, max=100, step=1, value=42, 
                                  description='Random seed:', style={'description_width': '150px'})
)

display(interactive_plot3)

🎯 Key Observations from Demo 3:

  1. QDA’s Curved Boundary: Notice how QDA creates a curved decision boundary!
  2. When QDA Wins: QDA performs best when classes have very different spreads
  3. LDA’s Limitation: LDA forces a straight line even when a curve would be better
  4. Trade-off: QDA needs more data (more parameters to estimate)

Try this: - Set both “Class 0 cov ratio” = 1.0 and “Class 1 cov ratio” = 1.0 → All methods agree - Set “Class 1 cov ratio” = 4.0 (very different from Class 0) → QDA should win! - Reduce “Mean separation” → See when flexibility matters most - Change “Rotation angle” → See covariances oriented differently


🎓 Summary and Key Takeaways

What We Learned:

  1. LDA finds optimal projection:
    • Maximizes between-class separation
    • Minimizes within-class spread
    • Reduces dimensionality automatically
  2. LDA vs Logistic Regression:
    • Both create linear boundaries
    • LDA assumes Gaussian classes
    • Usually similar performance, but can differ
  3. QDA for flexibility:
    • Allows different covariances per class
    • Creates curved (quadratic) boundaries
    • Needs more training data
    • Best when classes have different shapes

Decision Guide:

Are classes well-separated and roughly Gaussian?
    └─ Yes → Try LDA first (simple, efficient)
    └─ No → Try Logistic Regression

Do classes have very different spreads/shapes?
    └─ Yes, and you have lots of data → Try QDA
    └─ No or limited data → Stick with LDA

Not sure?
    └─ Try all three and compare using cross-validation!

For Lab This Friday:

You’ll apply these methods to real datasets and practice: - Using sklearn’s LDA and QDA - Visualizing decision boundaries - Comparing model performance - Choosing the right method for your data


📚 Additional Exercises (Optional)

Try modifying the code to: 1. Add a third class and see how LDA handles multi-class problems 2. Create 3D data and project to 2D 3. Compare computational time: LDA vs QDA vs Logistic Regression 4. Implement a simple LDA from scratch using numpy 5. Visualize the confidence/probability contours instead of just boundaries

Questions? Ask in lab or email Dr. Xing!


End of Interactive Demonstrations