MPS311/439 Week 3 Interactive Demos

Feature Engineering & Regularization

Course: Machine Learning
Lecturer: Dr. Wei Xing
Week: 3

This notebook contains two interactive demonstrations: 1. Demo 1: Polynomial Features and Overfitting 2. Demo 2: Regularization (Ridge & Lasso)


Setup: Import Libraries

Run this cell first to import all necessary libraries.

# Import necessary libraries
import numpy as np
import matplotlib.pyplot as plt
from sklearn.preprocessing import PolynomialFeatures, StandardScaler
from sklearn.linear_model import LinearRegression, Ridge, Lasso
from sklearn.metrics import r2_score
from ipywidgets import interact, IntSlider, FloatLogSlider, RadioButtons
import warnings
warnings.filterwarnings('ignore')

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

print("✓ All libraries imported successfully!")
print("✓ Ready for interactive demos!")
✓ All libraries imported successfully!
✓ Ready for interactive demos!

Demo 1: Polynomial Features and Overfitting

Objective

Discover how polynomial features can fit curved data, but also lead to overfitting.

Instructions

  1. Run the cell below to generate synthetic house price data
  2. Run the interactive widget cell
  3. Move the slider to explore different polynomial degrees
  4. Watch what happens to training and test R² scores!

What to Look For

  • Degree 1: Straight line, poor fit
  • Degree 2: Perfect! Captures the U-curve
  • Degree 10: Training R² ≈ 1.0, but test R² is terrible (overfitting!)

# Generate synthetic house price data with U-shaped relationship
# True relationship: price = 300 - 5*age + 0.5*age² + noise

np.random.seed(42)

# Training data
X_train = np.linspace(0, 10, 50).reshape(-1, 1)
y_train = 300 - 5*X_train.ravel() + 0.5*X_train.ravel()**2 + np.random.normal(0, 20, 50) * 0.1

# Test data (different from training)
X_test = np.linspace(0, 10, 30).reshape(-1, 1)
y_test = 300 - 5*X_test.ravel() + 0.5*X_test.ravel()**2 + np.random.normal(0, 20, 30) * 0.1

# Ground truth relationship
X_truth = np.linspace(0, 10, 200).reshape(-1, 1)
y_truth = 300 - 5*X_truth.ravel() + 0.5*X_truth.ravel()**2


print(f"✓ Generated training data: {X_train.shape[0]} points")
print(f"✓ Generated test data: {X_test.shape[0]} points")
print(f"✓ True relationship: U-shaped curve (quadratic)")
✓ Generated training data: 50 points
✓ Generated test data: 30 points
✓ True relationship: U-shaped curve (quadratic)
# Demo 1: Interactive Polynomial Degree Explorer

def explore_polynomial_degree(degree=1):
    """
    Interactive function to explore polynomial regression with different degrees.
    
    Parameters:
    - degree: Polynomial degree (1 to 15)
    """
    # Create polynomial features
    poly = PolynomialFeatures(degree=degree, include_bias=False)
    X_train_poly = poly.fit_transform(X_train)
    X_test_poly = poly.transform(X_test)
    
    # Fit linear regression on polynomial features
    model = LinearRegression()
    model.fit(X_train_poly, y_train)
    
    # Generate smooth curve for visualization
    X_plot = np.linspace(0, 10, 200).reshape(-1, 1)
    X_plot_poly = poly.transform(X_plot)
    y_plot = model.predict(X_plot_poly)
    
    # Calculate R² scores
    train_r2 = r2_score(y_train, model.predict(X_train_poly))
    test_r2 = r2_score(y_test, model.predict(X_test_poly))
    
    # Determine performance category
    if test_r2 > 0.7:
        perf_color = '#06A77D'  # Green
        perf_text = '✓ Good!'
    elif test_r2 > 0.5:
        perf_color = '#F77F00'  # Orange
        perf_text = '⚠ Okay'
    else:
        perf_color = '#E63946'  # Red
        perf_text = '✗ Overfitting!'
    
    # Create the plot
    fig, ax = plt.subplots(figsize=(14, 7))
    
    # Plot training and test data
    ax.scatter(X_train, y_train, alpha=0.6, s=120, label='Training Data', 
               color='#2E86AB', edgecolors='black', linewidth=1)
    ax.scatter(X_test, y_test, alpha=0.6, s=120, label='Test Data', 
               color='#F77F00', marker='^', edgecolors='black', linewidth=1)
    
    # Plot fitted curve
    ax.plot(X_plot, y_plot, 'r-', linewidth=3, label=f'Degree {degree} Fit', zorder=5)
    
    # Plot ground truth relationship
    ax.plot(X_truth, y_truth, 'k-', linewidth=3, label='Ground Truth', zorder=10)
    
    # Styling
    ax.set_xlabel('House Age (years)', fontsize=16, fontweight='bold')
    ax.set_ylabel('House Price (£1000s)', fontsize=16, fontweight='bold')
    ax.set_title(f'Polynomial Degree {degree}', fontsize=20, fontweight='bold', pad=20)
    ax.legend(fontsize=14, loc='upper right', framealpha=0.9)
    ax.grid(True, alpha=0.3, linestyle='--')
    ax.set_xlim(-0.5, 10.5)
    
    # Add metrics text box
    metrics_text = f'Train R² = {train_r2:.3f}\nTest R² = {test_r2:.3f}\nFeatures: {X_train_poly.shape[1]}'
    props = dict(boxstyle='round', facecolor='wheat', alpha=0.9, edgecolor='black', linewidth=2)
    ax.text(0.02, 0.98, metrics_text, transform=ax.transAxes, fontsize=18,
            verticalalignment='top', bbox=props, fontweight='bold', family='monospace')
    
    # Add performance indicator
    ax.text(0.98, 0.98, perf_text, transform=ax.transAxes, fontsize=22,
            verticalalignment='top', horizontalalignment='right',
            color=perf_color, fontweight='bold',
            bbox=dict(boxstyle='round', facecolor='white', alpha=0.9, 
                     edgecolor=perf_color, linewidth=3))
    
    plt.tight_layout()
    plt.show()
    
    # Print analysis
    gap = train_r2 - test_r2
    print(f"\n{'='*60}")
    print(f"Analysis for Degree {degree}:")
    print(f"{'='*60}")
    print(f"Training R²:     {train_r2:.4f}")
    print(f"Test R²:         {test_r2:.4f}")
    print(f"Train-Test Gap:  {gap:.4f}")
    print(f"Number of features: {X_train_poly.shape[1]}")
    
    if gap < 0.1:
        print("\n✓ Good generalization! Small train-test gap.")
    elif gap < 0.3:
        print("\n⚠ Moderate overfitting. Consider regularization.")
    else:
        print("\n✗ Severe overfitting! Model memorizing training data.")
    
    if degree == 2:
        print("\n🎯 This is the SWEET SPOT for this data!")
    elif degree >= 10:
        print("\n⚠️ Very high degree - watch for wild oscillations!")

# Create interactive widget
interact(explore_polynomial_degree, 
         degree=IntSlider(min=1, max=20, step=1, value=1, 
                         description='Polynomial Degree:', 
                         style={'description_width': '150px'},
                         layout={'width': '600px'}));

Demo 2: Regularization (Ridge & Lasso)

Objective

Discover how regularization controls overfitting by penalizing large weights.

Instructions

  1. Run the cell below to prepare high-degree polynomial features
  2. Run the interactive widget cell
  3. Start with Ridge, λ=0.01 (almost no regularization)
  4. Gradually increase λ and watch:
    • Coefficients shrinking
    • Test R² improving
  5. Switch to Lasso and see coefficients become exactly ZERO!

What to Look For

  • λ = 0: Overfitting (huge coefficients, poor test R²)
  • λ = 10: Often optimal (controlled coefficients, good test R²)
  • λ = 1000: Underfitting (all coefficients ≈ 0)
  • Lasso: Watch coefficients drop to exactly zero (sparsity!)

# Prepare data for Demo 2: High-degree polynomial with scaling

# Use same training/test data from Demo 1
# Create degree 10 polynomial features (to start in overfit state)
poly_demo2 = PolynomialFeatures(degree=10, include_bias=False)
X_train_poly_demo2 = poly_demo2.fit_transform(X_train)
X_test_poly_demo2 = poly_demo2.transform(X_test)

# Scale features (CRITICAL for regularization!)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train_poly_demo2)
X_test_scaled = scaler.transform(X_test_poly_demo2)

print(f"✓ Created degree 10 polynomial features")
print(f"✓ Number of features: {X_train_scaled.shape[1]}")
print(f"✓ Features scaled (mean=0, std=1)")
print(f"\nStarting state: Degree 10 with no regularization = OVERFITTING")
print(f"Let's fix it with regularization...")
✓ Created degree 10 polynomial features
✓ Number of features: 10
✓ Features scaled (mean=0, std=1)

Starting state: Degree 10 with no regularization = OVERFITTING
Let's fix it with regularization...
# Demo 2: Interactive Regularization Explorer

def explore_regularization(lambda_val=0.01, method='Ridge'):
    """
    Interactive function to explore Ridge and Lasso regularization.
    
    Parameters:
    - lambda_val: Regularization strength (λ)
    - method: 'Ridge' or 'Lasso'
    """
    # Choose model based on method
    if method == 'Ridge':
        model = Ridge(alpha=lambda_val)
        color = '#2E86AB'  # Blue
        color_bg = '#E3F2FD'
    else:  # Lasso
        model = Lasso(alpha=lambda_val, max_iter=10000)
        color = '#06A77D'  # Green
        color_bg = '#E8F5E9'
    
    # Fit model
    model.fit(X_train_scaled, y_train)
    
    # Predictions
    X_plot = np.linspace(0, 10, 200).reshape(-1, 1)
    X_plot_poly = poly_demo2.transform(X_plot)
    X_plot_scaled = scaler.transform(X_plot_poly)
    y_plot = model.predict(X_plot_scaled)
    
    # Calculate R² scores
    train_r2 = r2_score(y_train, model.predict(X_train_scaled))
    test_r2 = r2_score(y_test, model.predict(X_test_scaled))
    
    # Count non-zero coefficients (for Lasso)
    non_zero = np.sum(np.abs(model.coef_) > 1e-5)
    max_coef = np.max(np.abs(model.coef_))
    total_coefs = len(model.coef_)
    
    # Create subplots
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(18, 7))
    
    # ========== LEFT PLOT: Fitted Curve ==========
    ax1.scatter(X_train, y_train, alpha=0.6, s=120, label='Training', 
                color='#2E86AB', edgecolors='black', linewidth=1)
    ax1.scatter(X_test, y_test, alpha=0.6, s=120, label='Test', 
                color='#F77F00', marker='^', edgecolors='black', linewidth=1)
    ax1.plot(X_plot, y_plot, '-', linewidth=3, label=f'{method} Fit', color=color)
    
    ax1.set_xlabel('House Age (years)', fontsize=14, fontweight='bold')
    ax1.set_ylabel('House Price (£1000s)', fontsize=14, fontweight='bold')
    ax1.set_title(f'{method} Regression (λ = {lambda_val:.4f})', 
                  fontsize=18, fontweight='bold', pad=15)
    ax1.legend(fontsize=12, loc='upper right', framealpha=0.9)
    ax1.grid(True, alpha=0.3, linestyle='--')
    ax1.set_xlim(-0.5, 10.5)
    
    # Metrics text box
    metrics_text = (f'Train R²: {train_r2:.3f}\n'
                   f'Test R²: {test_r2:.3f}\n'
                   f'Non-zero: {non_zero}/{total_coefs}\n'
                   f'Max |coef|: {max_coef:.1f}')
    props = dict(boxstyle='round', facecolor='wheat', alpha=0.9, 
                edgecolor='black', linewidth=2)
    ax1.text(0.02, 0.98, metrics_text, transform=ax1.transAxes, fontsize=14,
            verticalalignment='top', bbox=props, fontweight='bold', family='monospace')
    
    # ========== RIGHT PLOT: Coefficient Values ==========
    coef_indices = np.arange(len(model.coef_))
    colors_coef = [color if abs(c) > 1e-5 else '#E5E5E5' for c in model.coef_]
    
    ax2.bar(coef_indices, model.coef_, color=colors_coef, alpha=0.8, edgecolor='black', linewidth=0.5)
    ax2.axhline(y=0, color='black', linestyle='-', linewidth=1)
    ax2.set_xlabel('Coefficient Index', fontsize=14, fontweight='bold')
    ax2.set_ylabel('Coefficient Value', fontsize=14, fontweight='bold')
    ax2.set_title(f'Coefficient Values ({method})', fontsize=18, fontweight='bold', pad=15)
    ax2.grid(True, alpha=0.3, axis='y', linestyle='--')
    
    # Add sparsity annotation for Lasso
    if method == 'Lasso' and non_zero < total_coefs:
        zeros_count = total_coefs - non_zero
        sparsity_text = f'{zeros_count} coefficients = 0\n(Automatic feature selection!)'
        ax2.text(0.98, 0.98, sparsity_text, transform=ax2.transAxes, fontsize=14,
                verticalalignment='top', horizontalalignment='right',
                color='#E63946', fontweight='bold',
                bbox=dict(boxstyle='round', facecolor='white', alpha=0.9, 
                         edgecolor='#E63946', linewidth=2))
    
    plt.tight_layout()
    plt.show()
    
    # Print detailed analysis
    gap = train_r2 - test_r2
    print(f"\n{'='*70}")
    print(f"{method} Regression with λ = {lambda_val:.4f}")
    print(f"{'='*70}")
    print(f"Training R²:        {train_r2:.4f}")
    print(f"Test R²:            {test_r2:.4f}")
    print(f"Train-Test Gap:     {gap:.4f}")
    print(f"Non-zero coeffs:    {non_zero} / {total_coefs}")
    print(f"Max |coefficient|:  {max_coef:.2f}")
    
    # Provide guidance
    if lambda_val < 0.01:
        print("\n⚠️ Very low λ - likely overfitting!")
        print("   Try increasing λ to control the coefficients.")
    elif lambda_val > 100:
        print("\n⚠️ Very high λ - likely underfitting!")
        print("   Try decreasing λ to allow more flexibility.")
    elif test_r2 > 0.7 and gap < 0.15:
        print("\n✓ Good balance! Test performance is strong and gap is small.")
    
    if method == 'Lasso' and non_zero < total_coefs * 0.5:
        print(f"\n🎯 Lasso achieved {(1-non_zero/total_coefs)*100:.0f}% sparsity!")
        print(f"   Only {non_zero} features are actually being used.")

# Create interactive widget
interact(explore_regularization,
         lambda_val=FloatLogSlider(
             value=0.01,
             base=10,
             min=-3,  # 10^-3 = 0.001
             max=3,   # 10^3 = 1000
             step=0.1,
             description='λ (lambda):',
             style={'description_width': '150px'},
             layout={'width': '600px'},
             readout_format='.4f'
         ),
         method=RadioButtons(
             options=['Ridge', 'Lasso'],
             description='Method:',
             style={'description_width': '150px'}
         ));

Summary & Key Takeaways

Demo 1: Polynomial Features

  • Degree 2 captured the U-shaped curve perfectly
  • ⚠️ Degree 10+ caused severe overfitting (Train R² → 1.0, Test R² → poor)
  • 📊 Train-test gap is the signature of overfitting

Demo 2: Regularization

  • Increasing λ shrinks coefficients and improves test performance
  • 🎯 Ridge shrinks all coefficients smoothly (never exactly zero)
  • Lasso creates exact zeros (automatic feature selection)
  • ⚖️ Optimal λ balances underfitting and overfitting

The Big Picture

Feature Engineering → Power to fit complex patterns
     +
Regularization → Control to prevent overfitting
     =
Powerful & Reliable Models!

Next Steps

  1. Experiment with different λ values
  2. Try switching between Ridge and Lasso
  3. In the lab: Implement this with real datasets using sklearn
  4. Learn cross-validation to automatically find optimal λ

Questions? Discuss with your neighbor or ask during office hours!

Office Hours: Tuesday 12:00-1:00 PM, Hicks Building I22
Email: w.xing@sheffield.ac.uk


Optional: Quick Experiments

Try these experiments to deepen your understanding!

# Experiment 1: Compare Ridge and Lasso at same λ
# Uncomment and run:

# lambda_test = 1.0
# print("Comparing Ridge vs Lasso at λ = 1.0\n")
# print("RIDGE:")
# explore_regularization(lambda_test, 'Ridge')
# print("\n" + "="*70 + "\n")
# print("LASSO:")
# explore_regularization(lambda_test, 'Lasso')
# Experiment 2: Find the optimal polynomial degree yourself
# Try degrees 1-5 and record test R² for each
# Which degree gives the best test performance?

# Your code here...
# Experiment 3: Explore extreme λ values
# What happens at λ = 0.0001 (almost no regularization)?
# What happens at λ = 10000 (extreme regularization)?

# Your code here...