Week 11: Interactive CNN Demonstrations

MPS311/439 Machine Learning - Dr. Wei Xing

This notebook contains interactive visualizations to help understand Convolutional Neural Networks.

Instructions: 1. Run all cells in order 2. Play with the sliders and buttons 3. Observe how outputs change 4. Try to understand the relationship between parameters and results


# Install required packages (if needed in Colab)
try:
    import google.colab
    IN_COLAB = True
except:
    IN_COLAB = False

# Import libraries
import numpy as np
import matplotlib.pyplot as plt
from matplotlib import patches
from ipywidgets import interact, interactive, fixed, interact_manual
import ipywidgets as widgets
from IPython.display import display, clear_output
from scipy.ndimage import convolve
import warnings
warnings.filterwarnings('ignore')

print("โœ“ All libraries imported successfully!")
print("Ready to explore CNNs!")
โœ“ All libraries imported successfully!
Ready to explore CNNs!

Demo 1: Real-Time Convolution Visualization

Goal: Understand how a filter slides across an image

What to explore: - Move the position slider to see the filter slide - Adjust filter weights to see different patterns - Try the preset filters (vertical edge, horizontal edge, blur) - Change stride to see how it affects output size

# Create a simple test image
def create_test_image():
    img = np.zeros((9, 9))
    # Create a pattern with vertical and horizontal edges
    img[2:7, 3:4] = 1  # Vertical bar
    img[3:4, 2:7] = 1  # Horizontal bar
    img[5:7, 5:7] = 0.5  # Square
    return img

test_image = create_test_image()

# Preset filters
preset_filters = {
    'Custom': np.zeros((3, 3)),
    'Vertical Edge': np.array([[-1, 0, 1], [-1, 0, 1], [-1, 0, 1]]),
    'Horizontal Edge': np.array([[-1, -1, -1], [0, 0, 0], [1, 1, 1]]),
    'Blur': np.ones((3, 3)) / 9,
    'Sharpen': np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]]),
    'Identity': np.array([[0, 0, 0], [0, 1, 0], [0, 0, 0]])
}

def visualize_convolution(position, stride, filter_preset, 
                         w00, w01, w02, w10, w11, w12, w20, w21, w22):
    
    # Get filter
    if filter_preset != 'Custom':
        kernel = preset_filters[filter_preset]
    else:
        kernel = np.array([[w00, w01, w02], [w10, w11, w12], [w20, w21, w22]])
    
    # Calculate output size
    output_size = (test_image.shape[0] - 3) // stride + 1
    
    # Calculate all positions
    positions = []
    for i in range(0, test_image.shape[0] - 2, stride):
        for j in range(0, test_image.shape[1] - 2, stride):
            positions.append((i, j))
    
    if position >= len(positions):
        position = len(positions) - 1
    
    i, j = positions[position]
    
    # Compute full output
    output_full = convolve(test_image, kernel, mode='constant')[::stride, ::stride]
    
    # Extract current region
    region = test_image[i:i+3, j:j+3]
    output_val = np.sum(region * kernel)
    
    # Create visualization
    fig, axes = plt.subplots(1, 4, figsize=(16, 4))
    
    # 1. Input image with highlighted region
    axes[0].imshow(test_image, cmap='gray', vmin=0, vmax=1)
    rect = patches.Rectangle((j-0.5, i-0.5), 3, 3, linewidth=3, 
                            edgecolor='red', facecolor='none')
    axes[0].add_patch(rect)
    axes[0].set_title(f'Input Image\nPosition: ({i}, {j})', fontsize=12, fontweight='bold')
    axes[0].axis('off')
    
    # Add grid
    for x in range(10):
        axes[0].axhline(x-0.5, color='gray', linewidth=0.5, alpha=0.3)
        axes[0].axvline(x-0.5, color='gray', linewidth=0.5, alpha=0.3)
    
    # 2. Filter
    im = axes[1].imshow(kernel, cmap='RdBu', vmin=-2, vmax=2)
    for ii in range(3):
        for jj in range(3):
            axes[1].text(jj, ii, f'{kernel[ii, jj]:.2f}', 
                        ha='center', va='center', fontsize=10, fontweight='bold')
    axes[1].set_title('Filter (Kernel)', fontsize=12, fontweight='bold')
    axes[1].axis('off')
    plt.colorbar(im, ax=axes[1], fraction=0.046)
    
    # 3. Element-wise multiplication
    product = region * kernel
    im = axes[2].imshow(product, cmap='RdBu', vmin=-1, vmax=1)
    for ii in range(3):
        for jj in range(3):
            axes[2].text(jj, ii, f'{product[ii, jj]:.2f}', 
                        ha='center', va='center', fontsize=9)
    axes[2].set_title(f'Element-wise Product\nSum = {output_val:.2f}', 
                     fontsize=12, fontweight='bold')
    axes[2].axis('off')
    plt.colorbar(im, ax=axes[2], fraction=0.046)
    
    # 4. Output feature map
    im = axes[3].imshow(output_full, cmap='viridis')
    # Highlight current output position
    out_i, out_j = position // output_size, position % output_size
    rect = patches.Rectangle((out_j-0.5, out_i-0.5), 1, 1, linewidth=3,
                            edgecolor='red', facecolor='none')
    axes[3].add_patch(rect)
    axes[3].set_title(f'Output Feature Map\n{output_full.shape[0]}ร—{output_full.shape[1]}',
                     fontsize=12, fontweight='bold')
    axes[3].axis('off')
    plt.colorbar(im, ax=axes[3], fraction=0.046)
    
    plt.tight_layout()
    plt.show()

# Create interactive widget
stride_widget = widgets.IntSlider(value=1, min=1, max=2, description='Stride:')
position_widget = widgets.IntSlider(value=0, min=0, max=48, description='Position:')
filter_preset_widget = widgets.Dropdown(
    options=list(preset_filters.keys()),
    value='Vertical Edge',
    description='Preset:'
)

# Filter weight sliders (for custom filter)
weight_sliders = []
for i in range(3):
    for j in range(3):
        slider = widgets.FloatSlider(
            value=0, min=-2, max=2, step=0.1,
            description=f'w[{i}][{j}]:',
            style={'description_width': '60px'},
            layout=widgets.Layout(width='250px')
        )
        weight_sliders.append(slider)

print("๐ŸŽฎ Interactive Convolution Visualizer")
print("="*50)
print("1. Choose a preset filter OR adjust custom weights")
print("2. Move position slider to see filter slide across image")
print("3. Try different strides (1 or 2)")
print("4. Watch how the output feature map is built!")
print("\n")

interact(visualize_convolution,
         position=position_widget,
         stride=stride_widget,
         filter_preset=filter_preset_widget,
         w00=weight_sliders[0], w01=weight_sliders[1], w02=weight_sliders[2],
         w10=weight_sliders[3], w11=weight_sliders[4], w12=weight_sliders[5],
         w20=weight_sliders[6], w21=weight_sliders[7], w22=weight_sliders[8]);
๐ŸŽฎ Interactive Convolution Visualizer
==================================================
1. Choose a preset filter OR adjust custom weights
2. Move position slider to see filter slide across image
3. Try different strides (1 or 2)
4. Watch how the output feature map is built!


Demo 2: Parameter Counting - FC vs CNN

Goal: See the dramatic parameter reduction of CNNs

What to explore: - Increase image size and watch FC parameters explode - Add more filters to CNN and see the modest increase - Compare the parameter counts - Understand why CNNs are more efficient

def parameter_comparison(image_size, channels, fc_neurons, num_filters, filter_size):
    # Calculate parameters
    input_size = image_size * image_size * channels
    
    # Fully-connected
    fc_params = input_size * fc_neurons
    
    # Convolutional
    conv_params = (filter_size * filter_size * channels + 1) * num_filters
    
    # Reduction
    reduction = (1 - conv_params / fc_params) * 100
    
    # Visualization
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))
    
    # Bar chart
    methods = ['Fully-Connected', 'CNN']
    params = [fc_params, conv_params]
    colors = ['#e74c3c', '#2ecc71']
    
    bars = ax1.bar(methods, params, color=colors, alpha=0.7, edgecolor='black', linewidth=2)
    ax1.set_ylabel('Number of Parameters', fontsize=12, fontweight='bold')
    ax1.set_title('Parameter Count Comparison', fontsize=14, fontweight='bold')
    ax1.set_yscale('log')
    ax1.grid(axis='y', alpha=0.3)
    
    # Add value labels
    for bar, param in zip(bars, params):
        height = bar.get_height()
        if param >= 1e6:
            label = f'{param/1e6:.2f}M'
        elif param >= 1e3:
            label = f'{param/1e3:.1f}K'
        else:
            label = f'{int(param)}'
        ax1.text(bar.get_x() + bar.get_width()/2., height * 1.1,
                label, ha='center', va='bottom', fontsize=12, fontweight='bold')
    
    # Calculation breakdown
    ax2.axis('off')
    
    breakdown_text = f"""
    ๐Ÿ“Š CALCULATION BREAKDOWN
    
    Input Specifications:
    โ€ข Image size: {image_size}ร—{image_size}
    โ€ข Channels: {channels}
    โ€ข Total input features: {input_size:,}
    
    โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
    
    Fully-Connected Network:
    โ€ข Hidden neurons: {fc_neurons}
    โ€ข Parameters = {input_size} ร— {fc_neurons}
    โ€ข Total: {fc_params:,} parameters
    
    โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
    
    Convolutional Network:
    โ€ข Filters: {num_filters}
    โ€ข Filter size: {filter_size}ร—{filter_size}
    โ€ข Params per filter: ({filter_size}ร—{filter_size}ร—{channels}+1)
    โ€ข Total: {conv_params:,} parameters
    
    โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
    
    ๐ŸŽฏ PARAMETER REDUCTION: {reduction:.1f}%
    
    CNN uses {fc_params/conv_params:.1f}ร— FEWER parameters!
    """
    
    ax2.text(0.1, 0.95, breakdown_text, transform=ax2.transAxes,
            fontsize=11, verticalalignment='top', family='monospace',
            bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.3))
    
    plt.tight_layout()
    plt.show()

print("๐Ÿ“Š Parameter Efficiency Explorer")
print("="*50)
print("Adjust the parameters to see how CNNs reduce parameter count!\n")

interact(parameter_comparison,
         image_size=widgets.IntSlider(value=28, min=16, max=256, step=8, 
                                     description='Image Size:', 
                                     style={'description_width': '120px'}),
         channels=widgets.Dropdown(options=[1, 3], value=1, 
                                  description='Channels:',
                                  style={'description_width': '120px'}),
         fc_neurons=widgets.IntSlider(value=100, min=50, max=512, step=50,
                                     description='FC Neurons:',
                                     style={'description_width': '120px'}),
         num_filters=widgets.IntSlider(value=32, min=8, max=128, step=8,
                                      description='CNN Filters:',
                                      style={'description_width': '120px'}),
         filter_size=widgets.Dropdown(options=[3, 5, 7], value=3,
                                     description='Filter Size:',
                                     style={'description_width': '120px'}));
๐Ÿ“Š Parameter Efficiency Explorer
==================================================
Adjust the parameters to see how CNNs reduce parameter count!

Demo 3: Pooling Effect Visualizer

Goal: Understand what pooling does and why itโ€™s useful

What to explore: - Compare Max Pooling vs Average Pooling - Try different pool sizes (2ร—2, 3ร—3, 4ร—4) - Shift the input pattern to see translation invariance - Observe dimension reduction

def create_feature_map(shift_x=0, shift_y=0):
    """Create a sample feature map with a pattern"""
    feature_map = np.random.rand(16, 16) * 0.2  # Background noise
    # Add a strong pattern
    x, y = 6 + shift_x, 6 + shift_y
    feature_map[x:x+4, y:y+4] = np.array([
        [0.3, 0.8, 0.9, 0.4],
        [0.9, 1.0, 0.9, 0.8],
        [0.8, 0.9, 1.0, 0.7],
        [0.3, 0.6, 0.7, 0.3]
    ])
    return feature_map

def apply_pooling(feature_map, pool_size, pool_type):
    """Apply pooling operation"""
    h, w = feature_map.shape
    out_h = h // pool_size
    out_w = w // pool_size
    
    output = np.zeros((out_h, out_w))
    
    for i in range(out_h):
        for j in range(out_w):
            region = feature_map[i*pool_size:(i+1)*pool_size, 
                                j*pool_size:(j+1)*pool_size]
            if pool_type == 'Max':
                output[i, j] = np.max(region)
            else:  # Average
                output[i, j] = np.mean(region)
    
    return output

def visualize_pooling(pool_type, pool_size, shift_x, shift_y, show_original):
    # Create feature map
    original_map = create_feature_map(0, 0)
    shifted_map = create_feature_map(shift_x, shift_y)
    
    # Apply pooling
    original_pooled = apply_pooling(original_map, pool_size, pool_type)
    shifted_pooled = apply_pooling(shifted_map, pool_size, pool_type)
    
    # Calculate difference
    difference = np.abs(original_pooled - shifted_pooled)
    avg_diff = np.mean(difference)
    
    # Visualization
    if show_original:
        fig, axes = plt.subplots(2, 3, figsize=(15, 10))
        
        # Original - before pooling
        im1 = axes[0, 0].imshow(original_map, cmap='viridis', vmin=0, vmax=1)
        axes[0, 0].set_title('Original Feature Map\n(No Shift)', fontsize=12, fontweight='bold')
        axes[0, 0].axis('off')
        plt.colorbar(im1, ax=axes[0, 0], fraction=0.046)
        
        # Original - after pooling
        im2 = axes[1, 0].imshow(original_pooled, cmap='viridis', vmin=0, vmax=1)
        axes[1, 0].set_title(f'After {pool_type} Pooling\n{original_pooled.shape[0]}ร—{original_pooled.shape[1]}',
                            fontsize=12, fontweight='bold')
        axes[1, 0].axis('off')
        plt.colorbar(im2, ax=axes[1, 0], fraction=0.046)
        
        # Shifted - before pooling
        im3 = axes[0, 1].imshow(shifted_map, cmap='viridis', vmin=0, vmax=1)
        axes[0, 1].set_title(f'Shifted Feature Map\nShift: ({shift_x}, {shift_y})',
                            fontsize=12, fontweight='bold')
        axes[0, 1].axis('off')
        plt.colorbar(im3, ax=axes[0, 1], fraction=0.046)
        
        # Shifted - after pooling
        im4 = axes[1, 1].imshow(shifted_pooled, cmap='viridis', vmin=0, vmax=1)
        axes[1, 1].set_title(f'After {pool_type} Pooling\n{shifted_pooled.shape[0]}ร—{shifted_pooled.shape[1]}',
                            fontsize=12, fontweight='bold')
        axes[1, 1].axis('off')
        plt.colorbar(im4, ax=axes[1, 1], fraction=0.046)
        
        # Difference map
        im5 = axes[0, 2].imshow(difference, cmap='hot', vmin=0, vmax=0.5)
        axes[0, 2].set_title(f'Absolute Difference\nAvg: {avg_diff:.4f}',
                            fontsize=12, fontweight='bold')
        axes[0, 2].axis('off')
        plt.colorbar(im5, ax=axes[0, 2], fraction=0.046)
        
        # Statistics
        axes[1, 2].axis('off')
        stats_text = f"""
        ๐ŸŽฏ Translation Invariance Test
        
        Input size: 16ร—16
        Pool size: {pool_size}ร—{pool_size}
        Output size: {original_pooled.shape[0]}ร—{original_pooled.shape[1]}
        
        Dimension reduction:
        {100*(1 - original_pooled.size/original_map.size):.1f}%
        
        โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
        
        Pattern shift: ({shift_x}, {shift_y})
        
        Average difference after pooling:
        {avg_diff:.4f}
        
        """
        
        if avg_diff < 0.05:
            stats_text += "\nโœ… Very similar!\nPooling provides\ntranslation invariance!"
            color = 'lightgreen'
        elif avg_diff < 0.15:
            stats_text += "\nโš ๏ธ Some difference\nbut mostly preserved"
            color = 'lightyellow'
        else:
            stats_text += "\nโŒ Significant difference\nLarge shift affects output"
            color = 'lightcoral'
        
        axes[1, 2].text(0.1, 0.9, stats_text, transform=axes[1, 2].transAxes,
                       fontsize=10, verticalalignment='top', family='monospace',
                       bbox=dict(boxstyle='round', facecolor=color, alpha=0.5))
        
    else:
        # Simplified view - just show pooling effect
        fig, axes = plt.subplots(1, 3, figsize=(15, 5))
        
        # Input
        im1 = axes[0].imshow(shifted_map, cmap='viridis', vmin=0, vmax=1)
        axes[0].set_title(f'Input Feature Map\n16ร—16', fontsize=12, fontweight='bold')
        axes[0].axis('off')
        # Draw pooling windows
        for i in range(0, 16, pool_size):
            for j in range(0, 16, pool_size):
                rect = patches.Rectangle((j-0.5, i-0.5), pool_size, pool_size,
                                        linewidth=1.5, edgecolor='red', facecolor='none')
                axes[0].add_patch(rect)
        plt.colorbar(im1, ax=axes[0], fraction=0.046)
        
        # Arrow
        axes[1].text(0.5, 0.5, f'{pool_type}\nPooling\n{pool_size}ร—{pool_size}',
                    transform=axes[1].transAxes, fontsize=16, fontweight='bold',
                    ha='center', va='center',
                    bbox=dict(boxstyle='round', facecolor='lightblue', alpha=0.7))
        axes[1].axis('off')
        
        # Output
        im2 = axes[2].imshow(shifted_pooled, cmap='viridis', vmin=0, vmax=1)
        axes[2].set_title(f'Output Feature Map\n{shifted_pooled.shape[0]}ร—{shifted_pooled.shape[1]}',
                         fontsize=12, fontweight='bold')
        axes[2].axis('off')
        plt.colorbar(im2, ax=axes[2], fraction=0.046)
    
    plt.tight_layout()
    plt.show()

print("๐ŸŠ Pooling Effect Visualizer")
print("="*50)
print("See how pooling reduces dimensions and provides translation invariance!\n")

interact(visualize_pooling,
         pool_type=widgets.Dropdown(options=['Max', 'Average'], value='Max',
                                   description='Pool Type:',
                                   style={'description_width': '120px'}),
         pool_size=widgets.Dropdown(options=[2, 4], value=2,
                                   description='Pool Size:',
                                   style={'description_width': '120px'}),
         shift_x=widgets.IntSlider(value=0, min=-2, max=2, description='Shift X:',
                                  style={'description_width': '120px'}),
         shift_y=widgets.IntSlider(value=0, min=-2, max=2, description='Shift Y:',
                                  style={'description_width': '120px'}),
         show_original=widgets.Checkbox(value=True, description='Show comparison',
                                       style={'description_width': '120px'}));
๐ŸŠ Pooling Effect Visualizer
==================================================
See how pooling reduces dimensions and provides translation invariance!

Demo 4: CNN Architecture Builder

Goal: Design CNN architectures and understand dimension flow

What to explore: - Add convolutional layers and watch dimensions change - Add pooling layers to reduce size - See how parameters accumulate - Compare to fully-connected equivalent

def build_cnn_architecture(input_size, input_channels,
                          conv1_filters, conv1_size, pool1_size,
                          conv2_filters, conv2_size, pool2_size,
                          dense_units, num_classes):
    
    # Track dimensions and parameters
    layers = []
    total_params = 0
    
    # Input
    h, w, c = input_size, input_size, input_channels
    layers.append({
        'name': 'Input',
        'output': f'{h}ร—{w}ร—{c}',
        'params': 0,
        'type': 'input'
    })
    
    # Conv1
    params_conv1 = (conv1_size * conv1_size * c + 1) * conv1_filters
    total_params += params_conv1
    # Assume 'same' padding
    layers.append({
        'name': f'Conv2D({conv1_filters})',
        'output': f'{h}ร—{w}ร—{conv1_filters}',
        'params': params_conv1,
        'type': 'conv'
    })
    
    # Pool1
    h, w = h // pool1_size, w // pool1_size
    layers.append({
        'name': f'MaxPool({pool1_size}ร—{pool1_size})',
        'output': f'{h}ร—{w}ร—{conv1_filters}',
        'params': 0,
        'type': 'pool'
    })
    c = conv1_filters
    
    # Conv2
    params_conv2 = (conv2_size * conv2_size * c + 1) * conv2_filters
    total_params += params_conv2
    layers.append({
        'name': f'Conv2D({conv2_filters})',
        'output': f'{h}ร—{w}ร—{conv2_filters}',
        'params': params_conv2,
        'type': 'conv'
    })
    
    # Pool2
    h, w = h // pool2_size, w // pool2_size
    layers.append({
        'name': f'MaxPool({pool2_size}ร—{pool2_size})',
        'output': f'{h}ร—{w}ร—{conv2_filters}',
        'params': 0,
        'type': 'pool'
    })
    c = conv2_filters
    
    # Flatten
    flatten_size = h * w * c
    layers.append({
        'name': 'Flatten',
        'output': f'{flatten_size}',
        'params': 0,
        'type': 'flatten'
    })
    
    # Dense
    params_dense1 = (flatten_size + 1) * dense_units
    total_params += params_dense1
    layers.append({
        'name': f'Dense({dense_units})',
        'output': f'{dense_units}',
        'params': params_dense1,
        'type': 'dense'
    })
    
    # Output
    params_output = (dense_units + 1) * num_classes
    total_params += params_output
    layers.append({
        'name': f'Dense({num_classes})',
        'output': f'{num_classes}',
        'params': params_output,
        'type': 'output'
    })
    
    # Calculate equivalent FC network
    fc_total = (input_size * input_size * input_channels) * dense_units + \
               dense_units * num_classes
    
    # Visualization
    fig = plt.figure(figsize=(16, 8))
    gs = fig.add_gridspec(2, 1, height_ratios=[3, 1])
    
    # Architecture diagram
    ax1 = fig.add_subplot(gs[0])
    ax1.set_xlim(0, len(layers) + 1)
    ax1.set_ylim(0, 10)
    ax1.axis('off')
    
    colors = {
        'input': '#95a5a6',
        'conv': '#3498db',
        'pool': '#2ecc71',
        'flatten': '#9b59b6',
        'dense': '#e67e22',
        'output': '#e74c3c'
    }
    
    for i, layer in enumerate(layers):
        x = i + 1
        
        # Draw box
        if layer['type'] in ['conv', 'pool']:
            # 3D-ish box
            rect = patches.FancyBboxPatch((x - 0.3, 4), 0.6, 3,
                                         boxstyle="round,pad=0.05",
                                         linewidth=2, edgecolor='black',
                                         facecolor=colors[layer['type']], alpha=0.7)
        else:
            # 1D bar
            rect = patches.FancyBboxPatch((x - 0.15, 4), 0.3, 3,
                                         boxstyle="round,pad=0.05",
                                         linewidth=2, edgecolor='black',
                                         facecolor=colors[layer['type']], alpha=0.7)
        ax1.add_patch(rect)
        
        # Labels
        ax1.text(x, 7.5, layer['name'], ha='center', va='center',
                fontsize=9, fontweight='bold', wrap=True)
        ax1.text(x, 5.5, layer['output'], ha='center', va='center',
                fontsize=8)
        if layer['params'] > 0:
            param_str = f"{layer['params']:,}" if layer['params'] < 1000 else f"{layer['params']/1000:.1f}K"
            ax1.text(x, 3.5, param_str, ha='center', va='center',
                    fontsize=7, style='italic', color='darkred')
        
        # Arrow
        if i < len(layers) - 1:
            ax1.annotate('', xy=(x + 0.8, 5.5), xytext=(x + 0.3, 5.5),
                        arrowprops=dict(arrowstyle='->', lw=2, color='black'))
    
    ax1.set_title('CNN Architecture', fontsize=14, fontweight='bold', pad=20)
    
    # Statistics panel
    ax2 = fig.add_subplot(gs[1])
    ax2.axis('off')
    
    stats_text = f"""
    ๐Ÿ“Š ARCHITECTURE SUMMARY
    
    Total Layers: {len(layers)}
    Total Parameters: {total_params:,}
    Memory (float32): ~{total_params * 4 / 1024 / 1024:.2f} MB
    
    โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
    
    COMPARISON TO FULLY-CONNECTED:
    
    CNN Parameters:         {total_params:,}
    FC Equivalent:          {fc_total:,}
    Parameter Reduction:    {100*(1 - total_params/fc_total):.1f}%
    
    CNN is {fc_total/total_params:.1f}ร— more efficient!
    """
    
    ax2.text(0.5, 0.5, stats_text, transform=ax2.transAxes,
            fontsize=11, verticalalignment='center', horizontalalignment='center',
            family='monospace',
            bbox=dict(boxstyle='round', facecolor='lightyellow', alpha=0.7))
    
    plt.tight_layout()
    plt.show()

print("๐Ÿ—๏ธ CNN Architecture Builder")
print("="*50)
print("Design your own CNN and see how dimensions flow!\n")

interact(build_cnn_architecture,
         input_size=widgets.IntSlider(value=28, min=16, max=64, step=4,
                                     description='Input Size:',
                                     style={'description_width': '130px'}),
         input_channels=widgets.Dropdown(options=[1, 3], value=1,
                                        description='Input Channels:',
                                        style={'description_width': '130px'}),
         conv1_filters=widgets.IntSlider(value=32, min=8, max=128, step=8,
                                        description='Conv1 Filters:',
                                        style={'description_width': '130px'}),
         conv1_size=widgets.Dropdown(options=[3, 5], value=3,
                                    description='Conv1 Size:',
                                    style={'description_width': '130px'}),
         pool1_size=widgets.Dropdown(options=[2, 3], value=2,
                                    description='Pool1 Size:',
                                    style={'description_width': '130px'}),
         conv2_filters=widgets.IntSlider(value=64, min=16, max=256, step=16,
                                        description='Conv2 Filters:',
                                        style={'description_width': '130px'}),
         conv2_size=widgets.Dropdown(options=[3, 5], value=3,
                                    description='Conv2 Size:',
                                    style={'description_width': '130px'}),
         pool2_size=widgets.Dropdown(options=[2, 3], value=2,
                                    description='Pool2 Size:',
                                    style={'description_width': '130px'}),
         dense_units=widgets.IntSlider(value=128, min=32, max=512, step=32,
                                      description='Dense Units:',
                                      style={'description_width': '130px'}),
         num_classes=widgets.IntSlider(value=10, min=2, max=100, step=1,
                                      description='Output Classes:',
                                      style={'description_width': '130px'}));
๐Ÿ—๏ธ CNN Architecture Builder
==================================================
Design your own CNN and see how dimensions flow!

Demo 5: Training Dynamics - Overfitting Monitor

Goal: See how different regularization techniques affect training

What to explore: - Watch training curves evolve - Increase model capacity and see overfitting - Add dropout to reduce overfitting - Reduce training data and see the effect

Note: This demo simulates training curves for speed

def simulate_training(num_filters, dropout_rate, training_samples, epochs):
    """
    Simulate training curves based on hyperparameters
    In practice, these would come from actual training
    """
    np.random.seed(42)
    
    # Complexity factor (higher = more capacity = more overfitting potential)
    complexity = num_filters / 32.0
    
    # Data sufficiency (more data = less overfitting)
    data_factor = training_samples / 10000.0
    
    # Dropout effect
    regularization = 1.0 - dropout_rate * 0.7
    
    # Generate curves
    epoch_range = np.arange(1, epochs + 1)
    
    # Training accuracy (always improves)
    train_acc = 0.6 + 0.35 * (1 - np.exp(-epoch_range / 3)) + \
                0.05 * complexity * (1 - np.exp(-epoch_range / 2))
    train_acc = np.minimum(train_acc, 0.995)
    
    # Validation accuracy (may plateau or decrease if overfitting)
    val_acc_base = 0.6 + 0.30 * (1 - np.exp(-epoch_range / 4))
    
    # Overfitting effect
    overfit_factor = complexity / data_factor / regularization
    if overfit_factor > 1.5:
        # Starts overfitting
        peak_epoch = max(3, int(8 / overfit_factor))
        overfit_penalty = np.where(epoch_range > peak_epoch,
                                  0.03 * (epoch_range - peak_epoch) * (overfit_factor - 1),
                                  0)
        val_acc = val_acc_base - overfit_penalty
    else:
        val_acc = val_acc_base * regularization + 0.02
    
    val_acc = np.minimum(val_acc, 0.98)
    
    # Add some noise
    train_acc += np.random.randn(epochs) * 0.005
    val_acc += np.random.randn(epochs) * 0.01
    
    # Loss curves (inverse of accuracy, roughly)
    train_loss = 1.5 * np.exp(-epoch_range / 3) + 0.05 + np.random.randn(epochs) * 0.01
    val_loss_base = 1.2 * np.exp(-epoch_range / 4) + 0.1
    
    if overfit_factor > 1.5:
        peak_epoch = max(3, int(8 / overfit_factor))
        overfit_penalty = np.where(epoch_range > peak_epoch,
                                  0.05 * (epoch_range - peak_epoch) * (overfit_factor - 1),
                                  0)
        val_loss = val_loss_base + overfit_penalty
    else:
        val_loss = val_loss_base / regularization
    
    val_loss += np.random.randn(epochs) * 0.02
    
    # Clip values
    train_acc = np.clip(train_acc, 0.5, 1.0)
    val_acc = np.clip(val_acc, 0.5, 1.0)
    train_loss = np.clip(train_loss, 0.01, 2.0)
    val_loss = np.clip(val_loss, 0.01, 2.0)
    
    return epoch_range, train_acc, val_acc, train_loss, val_loss, overfit_factor

def visualize_training(num_filters, dropout_rate, training_samples, epochs):
    # Simulate training
    epoch_range, train_acc, val_acc, train_loss, val_loss, overfit_factor = \
        simulate_training(num_filters, dropout_rate, training_samples, epochs)
    
    # Detect overfitting
    if len(epoch_range) > 5:
        recent_train = train_acc[-3:]
        recent_val = val_acc[-3:]
        gap = np.mean(recent_train) - np.mean(recent_val)
        is_overfitting = gap > 0.05
    else:
        is_overfitting = False
    
    # Visualization
    fig, axes = plt.subplots(1, 3, figsize=(18, 5))
    
    # Accuracy plot
    axes[0].plot(epoch_range, train_acc, 'o-', linewidth=2, markersize=6,
                label='Training', color='#3498db')
    axes[0].plot(epoch_range, val_acc, 's--', linewidth=2, markersize=6,
                label='Validation', color='#e67e22')
    axes[0].set_xlabel('Epoch', fontsize=12, fontweight='bold')
    axes[0].set_ylabel('Accuracy', fontsize=12, fontweight='bold')
    axes[0].set_title('Model Accuracy', fontsize=13, fontweight='bold')
    axes[0].legend(loc='lower right', fontsize=10)
    axes[0].grid(True, alpha=0.3)
    axes[0].set_ylim(0.5, 1.0)
    
    # Loss plot
    axes[1].plot(epoch_range, train_loss, 'o-', linewidth=2, markersize=6,
                label='Training', color='#3498db')
    axes[1].plot(epoch_range, val_loss, 's--', linewidth=2, markersize=6,
                label='Validation', color='#e67e22')
    axes[1].set_xlabel('Epoch', fontsize=12, fontweight='bold')
    axes[1].set_ylabel('Loss', fontsize=12, fontweight='bold')
    axes[1].set_title('Model Loss', fontsize=13, fontweight='bold')
    axes[1].legend(loc='upper right', fontsize=10)
    axes[1].grid(True, alpha=0.3)
    
    # Analysis panel
    axes[2].axis('off')
    
    final_train_acc = train_acc[-1]
    final_val_acc = val_acc[-1]
    gap = final_train_acc - final_val_acc
    
    analysis_text = f"""
    ๐Ÿ“Š TRAINING ANALYSIS
    
    Model Configuration:
    โ€ข Filters: {num_filters}
    โ€ข Dropout: {dropout_rate:.2f}
    โ€ข Training samples: {training_samples:,}
    โ€ข Epochs: {epochs}
    
    โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
    
    Final Results:
    โ€ข Train Acc: {final_train_acc:.3f}
    โ€ข Val Acc: {final_val_acc:.3f}
    โ€ข Gap: {gap:.3f}
    
    โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
    
    """
    
    if is_overfitting:
        analysis_text += """
    โš ๏ธ OVERFITTING DETECTED!
    
    Suggestions:
    โ€ข Increase dropout rate
    โ€ข Add more training data
    โ€ข Reduce model capacity
    โ€ข Use data augmentation
    """
        bgcolor = '#ffcccc'
    elif gap < 0.02:
        analysis_text += """
    โœ… GOOD GENERALIZATION!
    
    The model is learning well
    without overfitting.
    
    You could try:
    โ€ข Increase model capacity
    โ€ข Train for more epochs
    """
        bgcolor = '#ccffcc'
    else:
        analysis_text += """
    โšก REASONABLE PERFORMANCE
    
    Some gap between train/val
    but not severe overfitting.
    
    Consider:
    โ€ข Slight dropout increase
    โ€ข More training data
    """
        bgcolor = '#ffffcc'
    
    axes[2].text(0.1, 0.9, analysis_text, transform=axes[2].transAxes,
                fontsize=10, verticalalignment='top', family='monospace',
                bbox=dict(boxstyle='round', facecolor=bgcolor, alpha=0.7))
    
    plt.tight_layout()
    plt.show()

print("๐Ÿ“ˆ Training Dynamics - Overfitting Monitor")
print("="*50)
print("Explore how hyperparameters affect overfitting!\n")
print("Note: This demo simulates training for demonstration purposes.\n")

interact(visualize_training,
         num_filters=widgets.IntSlider(value=32, min=8, max=128, step=8,
                                      description='Num Filters:',
                                      style={'description_width': '130px'}),
         dropout_rate=widgets.FloatSlider(value=0.0, min=0.0, max=0.7, step=0.1,
                                         description='Dropout Rate:',
                                         style={'description_width': '130px'}),
         training_samples=widgets.IntSlider(value=10000, min=1000, max=50000, step=1000,
                                           description='Train Samples:',
                                           style={'description_width': '130px'}),
         epochs=widgets.IntSlider(value=15, min=5, max=30, step=1,
                                 description='Epochs:',
                                 style={'description_width': '130px'}));
๐Ÿ“ˆ Training Dynamics - Overfitting Monitor
==================================================
Explore how hyperparameters affect overfitting!

Note: This demo simulates training for demonstration purposes.

Summary

๐ŸŽ‰ Congratulations! Youโ€™ve explored the key concepts of CNNs through interactive visualizations.

Key Takeaways:

  1. Convolutions are just local weighted sums that slide across images
  2. Parameter sharing makes CNNs dramatically more efficient than fully-connected networks
  3. Pooling reduces dimensions and provides translation invariance
  4. Architecture design involves balancing model capacity with overfitting
  5. Regularization (dropout, more data) helps prevent overfitting

What to do next:

  • Review the lecture notes for mathematical details
  • Try the actual CNN implementation on MNIST
  • Experiment with different architectures
  • Apply CNNs to your own image datasets
  • Explore transfer learning with pre-trained models

Questions? Ask in class or on the discussion forum!

Want to explore more? Check out: - CNN Explainer: https://poloclub.github.io/cnn-explainer/ - TensorFlow Playground: https://playground.tensorflow.org - Distill.pub for beautiful ML explanations