Week 8: PCA Interactive Demonstrations

MPS311/439 Machine Learning - Dr. Wei Xing

This notebook contains 5 interactive demonstrations to help you understand Principal Component Analysis.

Instructions: - Run all cells in order - Play with sliders and buttons to see how PCA works - Observe what happens when you change parameters


# Import all required libraries
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import FancyArrowPatch, Ellipse
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from sklearn.datasets import load_digits
from ipywidgets import interact, FloatSlider, IntSlider, Checkbox, Button, Dropdown, VBox, HBox, Output
import ipywidgets as widgets
from IPython.display import display, clear_output

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

# Set matplotlib style
plt.rcParams['figure.figsize'] = (10, 6)
plt.rcParams['font.size'] = 10

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

Demo 1: Interactive 2D PCA - Understanding Variance Directions

Goal: See how principal components align with data spread

What to observe: - PC1 (red arrow) always points in the direction of maximum variance - PC2 (blue arrow) is always perpendicular to PC1 - Arrow lengths represent eigenvalues (variance in each direction)

💡 Try this: 1. Set covariance to 0 → PCs align with X/Y axes 2. Increase covariance to 0.9 → PC1 points diagonally 3. Make variances equal with high covariance → see dramatic PC1, tiny PC2


# Demo 1: Interactive 2D PCA with covariance control

def demo1_pca_2d(var_x=2.0, var_y=1.0, covariance=0.7, n_samples=300):
    """
    Visualize how PCA finds principal components in 2D data.
    """
    # Generate correlated 2D data
    mean = [0, 0]
    cov = [[var_x, covariance * np.sqrt(var_x * var_y)],
           [covariance * np.sqrt(var_x * var_y), var_y]]
    
    data = np.random.multivariate_normal(mean, cov, n_samples)
    
    # Perform PCA
    pca = PCA(n_components=2)
    pca.fit(data)
    
    # Get principal components and eigenvalues
    pc1 = pca.components_[0]
    pc2 = pca.components_[1]
    eigenval1 = pca.explained_variance_[0]
    eigenval2 = pca.explained_variance_[1]
    
    # Create figure
    fig, ax = plt.subplots(figsize=(10, 8))
    
    # Plot data points
    ax.scatter(data[:, 0], data[:, 1], alpha=0.4, s=30, color='steelblue', label='Data points')
    
    # Plot principal components as arrows (scaled by sqrt of eigenvalues)
    scale = 2.5
    arrow1 = FancyArrowPatch((0, 0), (pc1[0] * np.sqrt(eigenval1) * scale, pc1[1] * np.sqrt(eigenval1) * scale),
                            color='red', linewidth=3, arrowstyle='->', mutation_scale=20,
                            label=f'PC1 (λ₁={eigenval1:.2f})')
    arrow2 = FancyArrowPatch((0, 0), (pc2[0] * np.sqrt(eigenval2) * scale, pc2[1] * np.sqrt(eigenval2) * scale),
                            color='blue', linewidth=3, arrowstyle='->', mutation_scale=20,
                            label=f'PC2 (λ₂={eigenval2:.2f})')
    ax.add_patch(arrow1)
    ax.add_patch(arrow2)
    
    # Add grid lines at origin
    ax.axhline(y=0, color='k', linewidth=0.5, alpha=0.3)
    ax.axvline(x=0, color='k', linewidth=0.5, alpha=0.3)
    
    # Set axis properties
    max_range = max(np.abs(data).max(), 5)
    ax.set_xlim(-max_range, max_range)
    ax.set_ylim(-max_range, max_range)
    ax.set_xlabel('Feature 1', fontsize=12)
    ax.set_ylabel('Feature 2', fontsize=12)
    ax.set_title('PCA: Finding Directions of Maximum Variance', fontsize=14, fontweight='bold')
    ax.legend(loc='upper right', fontsize=10)
    ax.grid(True, alpha=0.3)
    ax.set_aspect('equal')
    
    # Add text box with variance explained
    var_ratio1 = pca.explained_variance_ratio_[0]
    var_ratio2 = pca.explained_variance_ratio_[1]
    textstr = f'PC1 explains {var_ratio1:.1%} of variance\nPC2 explains {var_ratio2:.1%} of variance'
    props = dict(boxstyle='round', facecolor='wheat', alpha=0.8)
    ax.text(0.02, 0.98, textstr, transform=ax.transAxes, fontsize=11,
            verticalalignment='top', bbox=props)
    
    plt.tight_layout()
    plt.show()

# Create interactive widgets
interact(demo1_pca_2d,
         var_x=FloatSlider(value=2.0, min=0.5, max=5.0, step=0.1, description='Var X:', continuous_update=False),
         var_y=FloatSlider(value=1.0, min=0.5, max=5.0, step=0.1, description='Var Y:', continuous_update=False),
         covariance=FloatSlider(value=0.7, min=-0.95, max=0.95, step=0.05, description='Covariance:', continuous_update=False),
         n_samples=IntSlider(value=300, min=100, max=500, step=50, description='N samples:', continuous_update=False));

Demo 2: Component Selection - How Many Components?

Goal: Learn how to choose the number of components

What to observe: - First few components capture most variance - Cumulative variance shows total information kept - 90-95% variance is often sufficient

💡 Try this: 1. Move slider to see how many components needed for 90% variance 2. Look for the “elbow” in the variance plot 3. Notice diminishing returns after ~20 components


# Demo 2: Component selection with scree plot

# Load digits dataset (this loads once)
digits = load_digits()
X_digits = digits.data

# Fit PCA with all components
pca_full = PCA()
pca_full.fit(X_digits)
cumulative_variance = np.cumsum(pca_full.explained_variance_ratio_)

def demo2_component_selection(n_components=20, show_type='Both', show_thresholds=True):
    """
    Visualize variance explained by different numbers of components.
    """
    n_total = len(pca_full.explained_variance_ratio_)
    
    if show_type == 'Both':
        fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
    else:
        fig, ax1 = plt.subplots(figsize=(10, 6))
    
    # Plot 1: Individual variance (bar chart)
    if show_type in ['Individual Variance', 'Both']:
        n_show = min(30, n_total)
        colors = ['red' if i < n_components else 'steelblue' for i in range(n_show)]
        
        if show_type == 'Both':
            ax = ax1
        else:
            ax = ax1
        
        ax.bar(range(1, n_show + 1), pca_full.explained_variance_ratio_[:n_show], 
               color=colors, alpha=0.7, edgecolor='black')
        ax.set_xlabel('Principal Component', fontsize=12)
        ax.set_ylabel('Explained Variance Ratio', fontsize=12)
        ax.set_title('Variance Explained by Each Component', fontsize=13, fontweight='bold')
        ax.grid(True, alpha=0.3, axis='y')
        
        # Add legend
        from matplotlib.patches import Patch
        legend_elements = [Patch(facecolor='red', label='Selected'),
                          Patch(facecolor='steelblue', label='Not selected')]
        ax.legend(handles=legend_elements, loc='upper right')
    
    # Plot 2: Cumulative variance (line plot)
    if show_type in ['Cumulative Variance', 'Both']:
        if show_type == 'Both':
            ax = ax2
        else:
            ax = ax1
        
        ax.plot(range(1, n_total + 1), cumulative_variance, 'b-', linewidth=2.5, label='Cumulative variance')
        
        # Highlight selected number of components
        ax.plot(n_components, cumulative_variance[n_components - 1], 'ro', markersize=10, 
                label=f'k={n_components}')
        
        # Add threshold lines
        if show_thresholds:
            ax.axhline(y=0.90, color='green', linestyle='--', linewidth=1.5, alpha=0.7, label='90% threshold')
            ax.axhline(y=0.95, color='orange', linestyle='--', linewidth=1.5, alpha=0.7, label='95% threshold')
            
            # Find where we cross thresholds
            idx_90 = np.argmax(cumulative_variance >= 0.90) + 1
            idx_95 = np.argmax(cumulative_variance >= 0.95) + 1
            
            ax.plot(idx_90, 0.90, 'go', markersize=8)
            ax.plot(idx_95, 0.95, 'o', color='orange', markersize=8)
        
        ax.set_xlabel('Number of Components', fontsize=12)
        ax.set_ylabel('Cumulative Explained Variance', fontsize=12)
        ax.set_title('Cumulative Variance Explained', fontsize=13, fontweight='bold')
        ax.grid(True, alpha=0.3)
        ax.legend(fontsize=9)
        ax.set_xlim(0, n_total + 1)
        ax.set_ylim(0, 1.05)
    
    # Add info box
    var_kept = cumulative_variance[n_components - 1]
    reduction = (1 - n_components / 64) * 100
    
    info_text = f"Components: {n_components}/64\n"
    info_text += f"Variance kept: {var_kept:.1%}\n"
    info_text += f"Reduction: {reduction:.0f}%"
    
    fig.text(0.5, 0.02, info_text, ha='center', fontsize=11,
             bbox=dict(boxstyle='round', facecolor='lightblue', alpha=0.8))
    
    plt.tight_layout()
    plt.subplots_adjust(bottom=0.15)
    plt.show()

# Create interactive widgets
interact(demo2_component_selection,
         n_components=IntSlider(value=20, min=1, max=64, step=1, description='Components:', continuous_update=False),
         show_type=Dropdown(options=['Individual Variance', 'Cumulative Variance', 'Both'], 
                           value='Both', description='Show:'),
         show_thresholds=Checkbox(value=True, description='Show 90%/95% lines'));

Demo 3: Reconstruction Quality - Information Loss vs. Compression

Goal: See how much information is lost with different numbers of components

What to observe: - With few components (k=1-5): Very blurry, basic shape only - With moderate components (k=10-20): Good reconstruction - With many components (k=30+): Almost identical to original - Difference map shows what’s lost (often just noise/fine details)

💡 Try this: 1. Start at k=1 and slowly increase to see improvement 2. Enable difference map to see what information is discarded 3. Find the sweet spot where reconstruction is “good enough”


# Demo 3: Reconstruction quality visualization

def demo3_reconstruction(n_components=10, digit_idx=0, show_difference=False):
    """
    Visualize reconstruction quality with different numbers of components.
    """
    # Apply PCA with specified number of components
    pca = PCA(n_components=n_components)
    X_reduced = pca.fit_transform(X_digits)
    X_reconstructed = pca.inverse_transform(X_reduced)
    
    # Get original and reconstructed images
    original = X_digits[digit_idx].reshape(8, 8)
    reconstructed = X_reconstructed[digit_idx].reshape(8, 8)
    difference = original - reconstructed
    
    # Calculate metrics
    mse = np.mean((original - reconstructed) ** 2)
    variance_kept = pca.explained_variance_ratio_.sum()
    
    # Create figure
    if show_difference:
        fig, axes = plt.subplots(1, 3, figsize=(12, 4))
    else:
        fig, axes = plt.subplots(1, 2, figsize=(10, 4))
    
    # Original image
    axes[0].imshow(original, cmap='gray')
    axes[0].set_title('Original Digit', fontsize=13, fontweight='bold')
    axes[0].axis('off')
    
    # Reconstructed image
    axes[1].imshow(reconstructed, cmap='gray')
    axes[1].set_title(f'Reconstructed ({n_components} components)', fontsize=13, fontweight='bold')
    axes[1].axis('off')
    
    # Difference map (if enabled)
    if show_difference:
        im = axes[2].imshow(np.abs(difference), cmap='hot')
        axes[2].set_title('Difference (What Was Lost)', fontsize=13, fontweight='bold')
        axes[2].axis('off')
        plt.colorbar(im, ax=axes[2], fraction=0.046)
    
    # Add info text
    info_text = f"Components: {n_components}/64\n"
    info_text += f"Variance kept: {variance_kept:.1%}\n"
    info_text += f"Reconstruction error (MSE): {mse:.4f}\n"
    info_text += f"True label: {digits.target[digit_idx]}"
    
    fig.text(0.5, 0.02, info_text, ha='center', fontsize=11,
             bbox=dict(boxstyle='round', facecolor='lightgreen', alpha=0.8))
    
    plt.tight_layout()
    plt.subplots_adjust(bottom=0.2)
    plt.show()

# Create interactive widgets
interact(demo3_reconstruction,
         n_components=IntSlider(value=10, min=1, max=64, step=1, description='Components:', continuous_update=False),
         digit_idx=IntSlider(value=0, min=0, max=len(X_digits)-1, step=1, description='Image #:', continuous_update=False),
         show_difference=Checkbox(value=False, description='Show difference map'));

Demo 4: 2D/3D Visualization - Seeing High-Dimensional Data

Goal: Use PCA to visualize 64-dimensional data in 2D/3D

What to observe: - Without colors: Can you see natural clustering? - With colors: Different digits cluster together! - PC1 and PC2 capture meaningful structure (even with ~28% variance) - 3D adds a bit more separation

💡 Try this: 1. Start with “Color by labels” OFF - do you see structure? 2. Turn colors ON - look how digits naturally separate! 3. Try 3D mode for extra perspective


# Demo 4: 2D/3D projection playground

def demo4_projection(projection_type='2D', color_by_label=True):
    """
    Visualize digits dataset in 2D or 3D PCA space.
    """
    if projection_type == '2D':
        # 2D projection
        pca = PCA(n_components=2)
        X_proj = pca.fit_transform(X_digits)
        
        fig, ax = plt.subplots(figsize=(10, 8))
        
        if color_by_label:
            scatter = ax.scatter(X_proj[:, 0], X_proj[:, 1], 
                               c=digits.target, cmap='tab10',
                               alpha=0.6, s=40, edgecolors='k', linewidth=0.3)
            cbar = plt.colorbar(scatter, ax=ax, ticks=range(10))
            cbar.set_label('Digit Class', fontsize=11)
        else:
            ax.scatter(X_proj[:, 0], X_proj[:, 1],
                      alpha=0.5, s=40, color='steelblue', edgecolors='k', linewidth=0.3)
        
        var_pc1 = pca.explained_variance_ratio_[0]
        var_pc2 = pca.explained_variance_ratio_[1]
        total_var = var_pc1 + var_pc2
        
        ax.set_xlabel(f'PC1 ({var_pc1:.1%} variance)', fontsize=12)
        ax.set_ylabel(f'PC2 ({var_pc2:.1%} variance)', fontsize=12)
        ax.set_title(f'Digits in 2D PCA Space (Total: {total_var:.1%} variance)', 
                    fontsize=14, fontweight='bold')
        ax.grid(True, alpha=0.3)
        
    else:
        # 3D projection
        from mpl_toolkits.mplot3d import Axes3D
        
        pca = PCA(n_components=3)
        X_proj = pca.fit_transform(X_digits)
        
        fig = plt.figure(figsize=(10, 8))
        ax = fig.add_subplot(111, projection='3d')
        
        if color_by_label:
            scatter = ax.scatter(X_proj[:, 0], X_proj[:, 1], X_proj[:, 2],
                               c=digits.target, cmap='tab10',
                               alpha=0.6, s=30, edgecolors='k', linewidth=0.2)
            cbar = plt.colorbar(scatter, ax=ax, ticks=range(10), pad=0.1, shrink=0.8)
            cbar.set_label('Digit Class', fontsize=10)
        else:
            ax.scatter(X_proj[:, 0], X_proj[:, 1], X_proj[:, 2],
                      alpha=0.5, s=30, color='steelblue', edgecolors='k', linewidth=0.2)
        
        var_pc1 = pca.explained_variance_ratio_[0]
        var_pc2 = pca.explained_variance_ratio_[1]
        var_pc3 = pca.explained_variance_ratio_[2]
        total_var = var_pc1 + var_pc2 + var_pc3
        
        ax.set_xlabel(f'PC1 ({var_pc1:.1%})', fontsize=11)
        ax.set_ylabel(f'PC2 ({var_pc2:.1%})', fontsize=11)
        ax.set_zlabel(f'PC3 ({var_pc3:.1%})', fontsize=11)
        ax.set_title(f'Digits in 3D PCA Space (Total: {total_var:.1%} variance)',
                    fontsize=13, fontweight='bold')
        ax.grid(True, alpha=0.3)
    
    plt.tight_layout()
    plt.show()

# Create interactive widgets
interact(demo4_projection,
         projection_type=Dropdown(options=['2D', '3D'], value='2D', description='View:'),
         color_by_label=Checkbox(value=True, description='Color by labels'));

Demo 5: Standardization Impact - Why It Matters!

Goal: Understand why feature scaling is critical for PCA

What to observe: - Without standardization + high scale: PC1 dominated by large-scale feature - With standardization: Both features get fair weight - The “⚠️ Warning” shows when PCA is biased

💡 Try this: 1. Disable standardization, set scale factor to 1000 - See PC1 ≈ 100% variance (all from Feature 2!) 2. Enable standardization with same settings - See balanced variance between PCs 3. Try different scale factors to see the effect


# Demo 5: Standardization impact demonstration

def demo5_standardization(scale_factor=100, apply_standardization=False, n_samples=300):
    """
    Show the impact of standardization on PCA.
    """
    # Generate data with two features on different scales
    np.random.seed(42)
    feature1 = np.random.randn(n_samples) * 1.0  # Small variance
    feature2 = np.random.randn(n_samples) * scale_factor  # Large variance
    
    # Add some correlation
    feature2 = feature2 + feature1 * (scale_factor * 0.3)
    
    X = np.column_stack([feature1, feature2])
    
    # Create figure with two panels
    fig = plt.figure(figsize=(14, 10))
    
    # Panel 1: Without standardization
    ax1 = plt.subplot(2, 2, 1)
    pca_raw = PCA(n_components=2)
    X_pca_raw = pca_raw.fit_transform(X)
    
    ax1.scatter(X[:, 0], X[:, 1], alpha=0.4, s=30, color='steelblue')
    
    # Draw PCs
    for i, (comp, var) in enumerate(zip(pca_raw.components_, pca_raw.explained_variance_)):
        color = 'red' if i == 0 else 'blue'
        scale = np.sqrt(var) * 0.3
        ax1.arrow(X[:, 0].mean(), X[:, 1].mean(), 
                 comp[0]*scale, comp[1]*scale,
                 head_width=scale*0.1, head_length=scale*0.15, 
                 fc=color, ec=color, linewidth=2, alpha=0.7,
                 label=f'PC{i+1}')
    
    ax1.set_xlabel('Feature 1 (scale=1)', fontsize=11)
    ax1.set_ylabel(f'Feature 2 (scale={scale_factor})', fontsize=11)
    ax1.set_title('Raw Data (No Standardization)', fontsize=12, fontweight='bold')
    ax1.legend()
    ax1.grid(True, alpha=0.3)
    
    # Variance plot for raw
    ax2 = plt.subplot(2, 2, 2)
    ax2.bar([1, 2], pca_raw.explained_variance_ratio_, color=['red', 'blue'], alpha=0.7, edgecolor='black')
    ax2.set_xlabel('Component', fontsize=11)
    ax2.set_ylabel('Explained Variance Ratio', fontsize=11)
    ax2.set_title('Variance Without Standardization', fontsize=12, fontweight='bold')
    ax2.set_xticks([1, 2])
    ax2.set_xticklabels(['PC1', 'PC2'])
    ax2.grid(True, alpha=0.3, axis='y')
    ax2.set_ylim(0, 1.1)
    
    # Add percentage labels
    for i, v in enumerate(pca_raw.explained_variance_ratio_):
        ax2.text(i+1, v + 0.02, f'{v:.1%}', ha='center', fontsize=10, fontweight='bold')
    
    # Panel 2: With standardization
    ax3 = plt.subplot(2, 2, 3)
    scaler = StandardScaler()
    X_scaled = scaler.fit_transform(X)
    
    pca_scaled = PCA(n_components=2)
    X_pca_scaled = pca_scaled.fit_transform(X_scaled)
    
    ax3.scatter(X_scaled[:, 0], X_scaled[:, 1], alpha=0.4, s=30, color='steelblue')
    
    # Draw PCs
    for i, (comp, var) in enumerate(zip(pca_scaled.components_, pca_scaled.explained_variance_)):
        color = 'red' if i == 0 else 'blue'
        scale = np.sqrt(var) * 1.5
        ax3.arrow(0, 0, comp[0]*scale, comp[1]*scale,
                 head_width=0.15, head_length=0.2, 
                 fc=color, ec=color, linewidth=2, alpha=0.7,
                 label=f'PC{i+1}')
    
    ax3.set_xlabel('Feature 1 (standardized)', fontsize=11)
    ax3.set_ylabel('Feature 2 (standardized)', fontsize=11)
    ax3.set_title('Standardized Data', fontsize=12, fontweight='bold')
    ax3.legend()
    ax3.grid(True, alpha=0.3)
    ax3.set_aspect('equal')
    
    # Variance plot for standardized
    ax4 = plt.subplot(2, 2, 4)
    ax4.bar([1, 2], pca_scaled.explained_variance_ratio_, color=['red', 'blue'], alpha=0.7, edgecolor='black')
    ax4.set_xlabel('Component', fontsize=11)
    ax4.set_ylabel('Explained Variance Ratio', fontsize=11)
    ax4.set_title('Variance With Standardization', fontsize=12, fontweight='bold')
    ax4.set_xticks([1, 2])
    ax4.set_xticklabels(['PC1', 'PC2'])
    ax4.grid(True, alpha=0.3, axis='y')
    ax4.set_ylim(0, 1.1)
    
    # Add percentage labels
    for i, v in enumerate(pca_scaled.explained_variance_ratio_):
        ax4.text(i+1, v + 0.02, f'{v:.1%}', ha='center', fontsize=10, fontweight='bold')
    
    # Add warning if not standardized and scale is high
    if not apply_standardization and scale_factor > 50:
        fig.text(0.5, 0.95, '⚠️ WARNING: PC1 dominated by large-scale feature!', 
                ha='center', fontsize=13, fontweight='bold', color='red',
                bbox=dict(boxstyle='round', facecolor='yellow', alpha=0.8))
    
    # Add info box
    info_text = "Comparison:\n"
    info_text += f"Raw PCA - PC1: {pca_raw.explained_variance_ratio_[0]:.1%}, PC2: {pca_raw.explained_variance_ratio_[1]:.1%}\n"
    info_text += f"Standardized PCA - PC1: {pca_scaled.explained_variance_ratio_[0]:.1%}, PC2: {pca_scaled.explained_variance_ratio_[1]:.1%}"
    
    fig.text(0.5, 0.02, info_text, ha='center', fontsize=10,
             bbox=dict(boxstyle='round', facecolor='lightblue', alpha=0.8))
    
    plt.tight_layout()
    plt.subplots_adjust(top=0.92, bottom=0.12)
    plt.show()
    
    # Print recommendation
    if apply_standardization:
        print("✅ Using standardization - both features get fair weight!")
    else:
        if scale_factor > 50:
            print("❌ Without standardization, PC1 is biased toward the large-scale feature.")
            print("   Try enabling standardization to see the difference!")
        else:
            print("ℹ️ Features have similar scales, standardization has less impact.")

# Create interactive widgets
interact(demo5_standardization,
         scale_factor=IntSlider(value=100, min=1, max=1000, step=50, description='Scale Factor:', continuous_update=False),
         apply_standardization=Checkbox(value=False, description='Apply standardization'),
         n_samples=IntSlider(value=300, min=100, max=500, step=50, description='N samples:', continuous_update=False));

Summary: What We Explored

Through these 5 interactive demonstrations, you’ve learned:

  1. Demo 1: How PCA finds directions of maximum variance
    • PC1 always aligns with maximum spread
    • PC2 is perpendicular with next-most variance
    • Eigenvalues tell us importance of each direction
  2. Demo 2: How to choose the number of components
    • 90-95% variance threshold is common
    • Look for “elbow” in scree plot
    • Often only need 20-30% of components
  3. Demo 3: What information is lost/preserved
    • Few components: Basic structure only
    • Moderate components: Good reconstruction
    • Many components: Almost perfect
    • Lost information is often just noise!
  4. Demo 4: How PCA enables visualization
    • Can see 64D data in 2D/3D
    • Natural clusters emerge without labels
    • Structure revealed even with limited variance
  5. Demo 5: Why standardization is critical
    • Different feature scales bias PCA
    • Always standardize when units differ
    • Gives each feature fair weight

Key Takeaway: PCA is a powerful tool for understanding high-dimensional data, but use it wisely!


Practice Exercise

Try applying PCA to your own data: 1. Load your dataset 2. Standardize if features have different scales 3. Apply PCA and examine explained variance 4. Choose appropriate number of components 5. Visualize in 2D or reconstruct data

Good luck with your PCA journey! 🎉