Skip to main content

🔢 Linear Algebra Basics: The Mathematics of Data Science

Imagine you're a movie recommendation system. You have millions of users and movies, and you need to find patterns in who likes what. That's linear algebra! 🎬 It's the language that lets us talk about high-dimensional spaces, transformations, and the hidden structures in data. From Google's PageRank to neural networks, linear algebra is the mathematical foundation of modern data science.

Vectors and Matrices: The Building Blocks 🏗️

If data science were LEGO, vectors would be the individual bricks and matrices would be the assembled structures. Vectors represent single observations (like one customer's purchases), while matrices represent entire datasets (all customers' purchases). Let's build with these mathematical LEGOs!

graph TB A[Linear Algebra] --> B[Vectors] A --> C[Matrices] A --> D[Operations] A --> E[Decompositions] B --> F[Points in Space] B --> G[Features] B --> H[Directions] C --> I[Datasets] C --> J[Transformations] C --> K[Relationships] D --> L[Multiplication] D --> M[Inversion] D --> N[Eigenvalues] E --> O[SVD] E --> P[PCA] E --> Q[LU/QR] style A fill:#667eea style B fill:#4ecdc4 style C fill:#4ecdc4 style D fill:#ffd93d style E fill:#ff6b6b

The GPS Analogy 🗺️

Think of vectors as GPS coordinates. A 2D vector [latitude, longitude] pinpoints a location. A 3D vector adds altitude. In data science, we work with hundreds of dimensions - each feature is another coordinate in our data space. Matrices? They're like having multiple GPS coordinates at once - tracking an entire fleet of vehicles!

import numpy as np

# Linear Algebra Fundamentals in NumPy 🔢

def vectors_and_basic_operations():
    """
    Vectors: The atoms of linear algebra.
    Everything starts here!
    """
    print("🎯 Vectors and Basic Operations")
    print("=" * 60)
    
    # Creating vectors
    v1 = np.array([3, 4])  # 2D vector
    v2 = np.array([1, 2])
    v3 = np.array([1, 2, 3, 4, 5])  # 5D vector
    
    print("Vector creation:")
    print(f"  v1 = {v1}")
    print(f"  v2 = {v2}")
    print(f"  v3 = {v3}")
    
    # Vector operations
    print("\n📐 Basic Vector Operations:")
    
    # Addition and subtraction
    print(f"  v1 + v2 = {v1 + v2}")
    print(f"  v1 - v2 = {v1 - v2}")
    
    # Scalar multiplication
    print(f"  2 * v1 = {2 * v1}")
    
    # Dot product (inner product)
    dot_product = np.dot(v1, v2)
    print(f"  v1 · v2 = {dot_product}")
    
    # Alternative syntax
    dot_product_alt = v1 @ v2  # Python 3.5+
    print(f"  v1 @ v2 = {dot_product_alt}")
    
    # Vector magnitude (norm)
    magnitude_v1 = np.linalg.norm(v1)
    print(f"  ||v1|| = {magnitude_v1:.3f}")
    
    # Unit vector (normalization)
    unit_v1 = v1 / magnitude_v1
    print(f"  v1_unit = {unit_v1}")
    print(f"  ||v1_unit|| = {np.linalg.norm(unit_v1):.3f}")
    
    # Angle between vectors
    cos_angle = dot_product / (np.linalg.norm(v1) * np.linalg.norm(v2))
    angle_rad = np.arccos(cos_angle)
    angle_deg = np.degrees(angle_rad)
    print(f"\n  Angle between v1 and v2: {angle_deg:.2f}°")
    
    # Cross product (only for 3D vectors)
    a = np.array([1, 0, 0])
    b = np.array([0, 1, 0])
    cross_product = np.cross(a, b)
    print(f"\n  [1,0,0] × [0,1,0] = {cross_product}")

def matrix_operations():
    """
    Matrices: Where linear algebra gets powerful!
    """
    print("\n🔲 Matrix Operations")
    print("=" * 60)
    
    # Creating matrices
    A = np.array([[1, 2], 
                  [3, 4]])
    
    B = np.array([[5, 6], 
                  [7, 8]])
    
    print("Matrix creation:")
    print(f"A =\n{A}")
    print(f"\nB =\n{B}")
    
    # Basic operations
    print("\n📊 Basic Matrix Operations:")
    
    # Addition and subtraction
    print(f"A + B =\n{A + B}")
    print(f"\nA - B =\n{A - B}")
    
    # Scalar multiplication
    print(f"\n2 * A =\n{2 * A}")
    
    # Element-wise multiplication (Hadamard product)
    print(f"\nA * B (element-wise) =\n{A * B}")
    
    # Matrix multiplication
    print(f"\nA @ B (matrix multiplication) =\n{A @ B}")
    
    # Alternative syntax
    print(f"\nnp.matmul(A, B) =\n{np.matmul(A, B)}")
    
    # Transpose
    print(f"\nA.T (transpose) =\n{A.T}")
    
    # Matrix properties
    print("\n📈 Matrix Properties:")
    
    # Determinant
    det_A = np.linalg.det(A)
    print(f"  det(A) = {det_A:.3f}")
    
    # Trace (sum of diagonal)
    trace_A = np.trace(A)
    print(f"  trace(A) = {trace_A}")
    
    # Rank
    rank_A = np.linalg.matrix_rank(A)
    print(f"  rank(A) = {rank_A}")
    
    # Inverse
    A_inv = np.linalg.inv(A)
    print(f"\nA^(-1) =\n{A_inv}")
    
    # Verify: A @ A^(-1) = I
    identity = A @ A_inv
    print(f"\nA @ A^(-1) =\n{identity.round(10)}")  # Round to avoid floating point errors

# Run basic examples
vectors_and_basic_operations()
matrix_operations()

Matrix Multiplication: The Heart of Transformations ❤️

Matrix multiplication is like a recipe transformer. If you have a recipe (matrix A) and ingredients (matrix B), multiplication gives you the final dish. But here's the magic: the order matters! A×B ≠ B×A, just like adding eggs before or after flour gives different results!

🎯 The Dot Product Dance

Matrix multiplication is really just organized dot products. Each element in the result is the dot product of a row from the first matrix with a column from the second matrix. It's like a perfectly choreographed dance!

C[i,j] = Σ(A[i,k] × B[k,j]) for all k
import numpy as np

class MatrixMultiplicationMaster:
    """
    Master the art of matrix multiplication.
    The most important operation in linear algebra!
    """
    
    def __init__(self):
        np.random.seed(42)
    
    def multiplication_basics(self):
        """Understanding matrix multiplication step by step"""
        print("🎯 Matrix Multiplication Deep Dive")
        print("=" * 60)
        
        # Simple example
        A = np.array([[1, 2, 3],
                      [4, 5, 6]])  # Shape: (2, 3)
        
        B = np.array([[7, 8],
                      [9, 10],
                      [11, 12]])  # Shape: (3, 2)
        
        print("Matrix A (2×3):")
        print(A)
        print("\nMatrix B (3×2):")
        print(B)
        
        # Manual calculation for understanding
        print("\n📐 Manual Calculation:")
        C = np.zeros((2, 2))
        
        for i in range(2):
            for j in range(2):
                for k in range(3):
                    C[i, j] += A[i, k] * B[k, j]
                print(f"  C[{i},{j}] = {' + '.join([f'{A[i,k]}×{B[k,j]}' for k in range(3)])} = {C[i,j]}")
        
        print(f"\nResult C (2×2):\n{C}")
        
        # NumPy's efficient way
        C_numpy = A @ B
        print(f"\nNumPy result (A @ B):\n{C_numpy}")
        print(f"Results match: {np.allclose(C, C_numpy)}")
        
        # Dimension rules
        print("\n📏 Dimension Rules:")
        print(f"  A shape: {A.shape}")
        print(f"  B shape: {B.shape}")
        print(f"  C shape: {C.shape}")
        print(f"  Rule: (m×n) @ (n×p) = (m×p)")
        print(f"  Inner dimensions must match: 3 == 3 ✓")
    
    def real_world_applications(self):
        """Real-world applications of matrix multiplication"""
        print("\n🌍 Real-World Applications")
        print("=" * 60)
        
        # 1. Linear Transformations
        print("1️⃣ Image Rotation:")
        
        # Rotation matrix for 45 degrees
        theta = np.pi / 4  # 45 degrees
        rotation_matrix = np.array([[np.cos(theta), -np.sin(theta)],
                                   [np.sin(theta), np.cos(theta)]])
        
        # Points of a square
        square = np.array([[0, 1, 1, 0],   # x-coordinates
                          [0, 0, 1, 1]])  # y-coordinates
        
        # Apply rotation
        rotated_square = rotation_matrix @ square
        
        print(f"  Original square corners:")
        for i in range(4):
            print(f"    Point {i+1}: ({square[0,i]:.1f}, {square[1,i]:.1f})")
        
        print(f"  Rotated square corners (45°):")
        for i in range(4):
            print(f"    Point {i+1}: ({rotated_square[0,i]:.2f}, {rotated_square[1,i]:.2f})")
        
        # 2. Neural Network Forward Pass
        print("\n2️⃣ Neural Network Layer:")
        
        # Input batch (4 samples, 3 features)
        X = np.random.randn(4, 3)
        
        # Weights (3 inputs, 2 neurons)
        W = np.random.randn(3, 2)
        
        # Bias
        b = np.array([0.5, -0.5])
        
        # Forward pass: Y = XW + b
        Y = X @ W + b
        
        # Apply activation (ReLU)
        Y_activated = np.maximum(0, Y)
        
        print(f"  Input shape: {X.shape} (batch_size, features)")
        print(f"  Weight shape: {W.shape} (features, neurons)")
        print(f"  Output shape: {Y.shape} (batch_size, neurons)")
        print(f"  Sample output:\n{Y_activated[:2].round(3)}")
        
        # 3. PageRank (Simplified)
        print("\n3️⃣ PageRank Algorithm (Simplified):")
        
        # Adjacency matrix (who links to whom)
        # Pages: A, B, C, D
        links = np.array([[0, 1, 1, 0],  # A links to B, C
                         [1, 0, 0, 1],  # B links to A, D
                         [0, 0, 0, 1],  # C links to D
                         [1, 1, 0, 0]]) # D links to A, B
        
        # Normalize columns (transition matrix)
        column_sums = links.sum(axis=0)
        column_sums[column_sums == 0] = 1  # Avoid division by zero
        transition_matrix = links / column_sums
        
        # Initial page ranks (equal)
        page_rank = np.ones(4) / 4
        
        # Power iteration
        damping = 0.85
        for iteration in range(5):
            page_rank = damping * (transition_matrix @ page_rank) + (1 - damping) / 4
        
        print(f"  Page ranks after 5 iterations:")
        pages = ['A', 'B', 'C', 'D']
        for i, page in enumerate(pages):
            print(f"    Page {page}: {page_rank[i]:.3f}")
        
        print(f"  Most important page: {pages[np.argmax(page_rank)]}")
    
    def matrix_chain_multiplication(self):
        """Optimizing matrix chain multiplication"""
        print("\n⚡ Matrix Chain Multiplication")
        print("=" * 60)
        
        # Three matrices to multiply
        A = np.random.randn(10, 100)
        B = np.random.randn(100, 5)
        C = np.random.randn(5, 50)
        
        print("Matrix dimensions:")
        print(f"  A: {A.shape}")
        print(f"  B: {B.shape}")
        print(f"  C: {C.shape}")
        
        # Two ways to compute A @ B @ C
        import time
        
        # Method 1: (A @ B) @ C
        start = time.perf_counter()
        result1 = (A @ B) @ C
        time1 = time.perf_counter() - start
        
        # Method 2: A @ (B @ C)
        start = time.perf_counter()
        result2 = A @ (B @ C)
        time2 = time.perf_counter() - start
        
        print(f"\n⏱️ Performance Comparison:")
        print(f"  (A @ B) @ C: {time1*1000:.3f} ms")
        print(f"  A @ (B @ C): {time2*1000:.3f} ms")
        print(f"  Speedup: {time1/time2:.2f}x")
        
        # Operations count
        ops1 = 10*100*5 + 10*5*50  # (A@B) then (@C)
        ops2 = 100*5*50 + 10*100*50  # (B@C) then (A@)
        
        print(f"\n🔢 Operations Count:")
        print(f"  (A @ B) @ C: {ops1:,} multiplications")
        print(f"  A @ (B @ C): {ops2:,} multiplications")
        print(f"  Ratio: {ops2/ops1:.2f}x more operations")
        
        print(f"\n💡 Lesson: Parentheses matter in matrix multiplication!")

# Explore matrix multiplication
mm = MatrixMultiplicationMaster()
mm.multiplication_basics()
mm.real_world_applications()
mm.matrix_chain_multiplication()

Eigenvalues and Eigenvectors: Finding Hidden Patterns 🔍

Eigenvectors are like the "natural directions" of a transformation - directions that don't change, only stretch or shrink. Eigenvalues tell us how much stretching happens. This concept powers Principal Component Analysis (PCA), Google's PageRank, and facial recognition systems!

🎭 The Stretching Analogy

Imagine a rubber sheet with a grid pattern. When you stretch it, most grid lines change direction. But some special lines only get longer or shorter - these are eigenvector directions! The amount of stretching is the eigenvalue.

import numpy as np

class EigenExplorer:
    """
    Exploring eigenvalues and eigenvectors.
    The key to understanding data's hidden structure!
    """
    
    def __init__(self):
        np.random.seed(42)
    
    def eigen_basics(self):
        """Understanding eigenvalues and eigenvectors"""
        print("🔍 Eigenvalues and Eigenvectors")
        print("=" * 60)
        
        # Simple 2x2 matrix
        A = np.array([[3, 1],
                      [1, 3]])
        
        print(f"Matrix A:\n{A}")
        
        # Compute eigenvalues and eigenvectors
        eigenvalues, eigenvectors = np.linalg.eig(A)
        
        print(f"\n📊 Eigenvalues: {eigenvalues}")
        print(f"\n📐 Eigenvectors:")
        for i in range(len(eigenvalues)):
            print(f"  λ{i+1} = {eigenvalues[i]:.3f}")
            print(f"  v{i+1} = {eigenvectors[:, i]}")
        
        # Verification: A @ v = λ * v
        print("\n✅ Verification (A @ v = λ × v):")
        for i in range(len(eigenvalues)):
            v = eigenvectors[:, i]
            λ = eigenvalues[i]
            
            Av = A @ v
            λv = λ * v
            
            print(f"\n  Eigenvector {i+1}:")
            print(f"    A @ v = {Av}")
            print(f"    λ × v = {λv}")
            print(f"    Match? {np.allclose(Av, λv)}")
        
        # Geometric interpretation
        print("\n🎨 Geometric Interpretation:")
        print("  • Eigenvectors = directions that don't change")
        print("  • Eigenvalues = scaling factors in those directions")
        print(f"  • In this case: stretch by {eigenvalues[0]:.1f} in one direction,")
        print(f"    and by {eigenvalues[1]:.1f} in the perpendicular direction")
    
    def pca_with_eigen(self):
        """PCA using eigendecomposition"""
        print("\n📈 Principal Component Analysis (PCA)")
        print("=" * 60)
        
        # Generate correlated 2D data
        mean = [0, 0]
        cov = [[1, 0.8], 
               [0.8, 1]]
        
        data = np.random.multivariate_normal(mean, cov, 1000)
        
        print(f"Dataset shape: {data.shape}")
        print(f"Features: 2, Samples: 1000")
        
        # Step 1: Center the data
        data_centered = data - data.mean(axis=0)
        
        # Step 2: Compute covariance matrix
        cov_matrix = np.cov(data_centered.T)
        print(f"\nCovariance matrix:\n{cov_matrix}")
        
        # Step 3: Eigendecomposition
        eigenvalues, eigenvectors = np.linalg.eig(cov_matrix)
        
        # Sort by eigenvalue (descending)
        idx = eigenvalues.argsort()[::-1]
        eigenvalues = eigenvalues[idx]
        eigenvectors = eigenvectors[:, idx]
        
        print(f"\nEigenvalues (variance explained):")
        total_var = eigenvalues.sum()
        for i, λ in enumerate(eigenvalues):
            var_explained = λ / total_var * 100
            print(f"  PC{i+1}: λ={λ:.3f} ({var_explained:.1f}% variance)")
        
        print(f"\nPrincipal Components (eigenvectors):")
        for i in range(len(eigenvalues)):
            print(f"  PC{i+1}: {eigenvectors[:, i]}")
        
        # Step 4: Transform data
        data_pca = data_centered @ eigenvectors
        
        print(f"\nTransformed data shape: {data_pca.shape}")
        print(f"First principal component captures {eigenvalues[0]/total_var*100:.1f}% of variance!")
        
        # Dimensionality reduction
        # Keep only first component
        data_reduced = data_pca[:, :1] @ eigenvectors[:, :1].T
        
        print(f"\n🔽 Dimensionality Reduction:")
        print(f"  Original dimensions: 2")
        print(f"  Reduced dimensions: 1")
        print(f"  Information retained: {eigenvalues[0]/total_var*100:.1f}%")
    
    def power_iteration(self):
        """Finding dominant eigenvalue using power iteration"""
        print("\n🔄 Power Iteration Method")
        print("=" * 60)
        
        # Create a matrix
        A = np.array([[2, -1, 0],
                      [-1, 2, -1],
                      [0, -1, 2]])
        
        print(f"Matrix A:\n{A}")
        
        # Power iteration
        v = np.random.randn(3)  # Random starting vector
        v = v / np.linalg.norm(v)  # Normalize
        
        print("\nPower Iteration:")
        for iteration in range(10):
            # Multiply by A
            v_new = A @ v
            
            # Estimate eigenvalue (Rayleigh quotient)
            eigenvalue_estimate = (v_new @ v) / (v @ v)
            
            # Normalize
            v = v_new / np.linalg.norm(v_new)
            
            if iteration % 3 == 0:
                print(f"  Iteration {iteration}: λ ≈ {eigenvalue_estimate:.6f}")
        
        print(f"\nFinal estimate:")
        print(f"  Dominant eigenvalue: {eigenvalue_estimate:.6f}")
        print(f"  Corresponding eigenvector: {v}")
        
        # Compare with NumPy
        eigenvalues, eigenvectors = np.linalg.eig(A)
        max_idx = np.argmax(np.abs(eigenvalues))
        
        print(f"\nNumPy result:")
        print(f"  Dominant eigenvalue: {eigenvalues[max_idx]:.6f}")
        print(f"  Match? {np.abs(eigenvalue_estimate - eigenvalues[max_idx]) < 0.001}")
    
    def spectral_clustering_demo(self):
        """Using eigenvalues for clustering"""
        print("\n🎯 Spectral Clustering with Eigenvalues")
        print("=" * 60)
        
        # Create a simple graph (adjacency matrix)
        # Two clusters with weak connection
        adjacency = np.array([
            [0, 1, 1, 0, 0, 0],  # Cluster 1
            [1, 0, 1, 0, 0, 0],
            [1, 1, 0, 0.1, 0, 0],  # Weak link
            [0, 0, 0.1, 0, 1, 1],  # Cluster 2
            [0, 0, 0, 1, 0, 1],
            [0, 0, 0, 1, 1, 0]
        ])
        
        print("Graph adjacency matrix (connection strengths):")
        print(adjacency)
        
        # Compute degree matrix
        degree = np.diag(adjacency.sum(axis=1))
        
        # Laplacian matrix
        laplacian = degree - adjacency
        
        # Eigendecomposition of Laplacian
        eigenvalues, eigenvectors = np.linalg.eig(laplacian)
        
        # Sort by eigenvalue
        idx = eigenvalues.argsort()
        eigenvalues = eigenvalues[idx]
        eigenvectors = eigenvectors[:, idx]
        
        print(f"\n📊 Laplacian Eigenvalues:")
        for i, λ in enumerate(eigenvalues[:4]):
            print(f"  λ{i}: {λ:.3f}")
        
        print(f"\n🔍 Fiedler Vector (2nd smallest eigenvector):")
        fiedler = eigenvectors[:, 1]
        print(f"  {fiedler}")
        
        # Cluster based on sign of Fiedler vector
        cluster1 = np.where(fiedler < 0)[0]
        cluster2 = np.where(fiedler >= 0)[0]
        
        print(f"\n✨ Clustering Result:")
        print(f"  Cluster 1: Nodes {cluster1 + 1}")
        print(f"  Cluster 2: Nodes {cluster2 + 1}")
        print(f"  Perfect separation of the two groups!")

# Explore eigenvalues and eigenvectors
eigen = EigenExplorer()
eigen.eigen_basics()
eigen.pca_with_eigen()
eigen.power_iteration()
eigen.spectral_clustering_demo()

Matrix Decompositions: Breaking Down Complexity 🧩

Matrix decompositions are like taking apart a complex machine to understand its components. Just as you might disassemble a clock to see the gears, springs, and wheels, we decompose matrices to reveal their fundamental structures.

📊 LU Decomposition

A = L × U

Breaks matrix into Lower and Upper triangular matrices

Use: Solving linear systems efficiently

🎯 QR Decomposition

A = Q × R

Q is orthogonal, R is upper triangular

Use: Least squares, eigenvalue algorithms

⭐ SVD (Singular Value Decomposition)

A = U × Σ × V^T

The "Swiss Army knife" of linear algebra

Use: PCA, compression, recommendation systems

import numpy as np

class MatrixDecompositions:
    """
    Matrix decompositions: The power tools of linear algebra.
    Breaking down complexity into manageable pieces!
    """
    
    def __init__(self):
        np.random.seed(42)
    
    def lu_decomposition(self):
        """LU Decomposition for solving linear systems"""
        print("📊 LU Decomposition")
        print("=" * 60)
        
        # Matrix to decompose
        A = np.array([[2, 1, 1],
                      [4, -6, 0],
                      [-2, 7, 2]], dtype=float)
        
        print(f"Original matrix A:\n{A}")
        
        # LU decomposition using scipy
        from scipy.linalg import lu
        
        P, L, U = lu(A)
        
        print(f"\nP (Permutation matrix):\n{P}")
        print(f"\nL (Lower triangular):\n{L}")
        print(f"\nU (Upper triangular):\n{U}")
        
        # Verify: P @ A = L @ U
        reconstructed = L @ U
        original_permuted = P @ A
        
        print(f"\nVerification:")
        print(f"  P @ A =\n{original_permuted}")
        print(f"  L @ U =\n{reconstructed}")
        print(f"  Match? {np.allclose(original_permuted, reconstructed)}")
        
        # Application: Solving Ax = b
        b = np.array([1, 2, 3])
        print(f"\n🎯 Solving Ax = b where b = {b}")
        
        # Forward substitution: Ly = Pb
        # Back substitution: Ux = y
        x = np.linalg.solve(A, b)
        
        print(f"  Solution x = {x}")
        print(f"  Verification: A @ x = {A @ x}")
        print(f"  Equals b? {np.allclose(A @ x, b)}")
    
    def qr_decomposition(self):
        """QR Decomposition for orthogonalization"""
        print("\n🎯 QR Decomposition")
        print("=" * 60)
        
        # Create a matrix
        A = np.array([[1, 2],
                      [3, 4],
                      [5, 6]])
        
        print(f"Matrix A (3×2):\n{A}")
        
        # QR decomposition
        Q, R = np.linalg.qr(A)
        
        print(f"\nQ (Orthogonal, 3×3):\n{Q}")
        print(f"\nR (Upper triangular, 3×2):\n{R}")
        
        # Properties of Q
        print(f"\n📐 Properties of Q:")
        print(f"  Q^T @ Q =\n{(Q.T @ Q).round(10)}")
        print(f"  Q is orthogonal? {np.allclose(Q.T @ Q, np.eye(3))}")
        
        # Reconstruction
        reconstructed = Q @ R
        print(f"\nReconstruction Q @ R:\n{reconstructed}")
        print(f"Match with A? {np.allclose(reconstructed, A)}")
        
        # Application: Least squares
        print(f"\n📈 Application: Least Squares Fitting")
        
        # Overdetermined system (more equations than unknowns)
        b = np.array([1, 2, 4])
        
        # Solve using QR
        # A @ x ≈ b  =>  Q @ R @ x = b  =>  R @ x = Q^T @ b
        x = np.linalg.solve(R[:2, :], (Q.T @ b)[:2])
        
        print(f"  Least squares solution: x = {x}")
        print(f"  Residual: ||Ax - b|| = {np.linalg.norm(A @ x - b):.4f}")
    
    def svd_decomposition(self):
        """SVD: The most important decomposition"""
        print("\n⭐ Singular Value Decomposition (SVD)")
        print("=" * 60)
        
        # Create a data matrix (e.g., user-movie ratings)
        # 5 users, 4 movies
        ratings = np.array([[5, 4, 0, 1],
                           [4, 5, 0, 1],
                           [1, 1, 5, 4],
                           [1, 0, 5, 4],
                           [0, 1, 4, 5]], dtype=float)
        
        print("User-Movie Rating Matrix:")
        print("(5 users × 4 movies)")
        print(ratings)
        
        # SVD decomposition
        U, s, Vt = np.linalg.svd(ratings)
        
        print(f"\nSVD Components:")
        print(f"  U shape: {U.shape} (user features)")
        print(f"  Σ values: {s.round(3)} (importance)")
        print(f"  V^T shape: {Vt.shape} (movie features)")
        
        # Interpretation
        print(f"\n📊 Singular Values (importance of each component):")
        total_variance = np.sum(s**2)
        for i, σ in enumerate(s):
            variance_explained = (σ**2 / total_variance) * 100
            print(f"  Component {i+1}: σ={σ:.3f} ({variance_explained:.1f}% variance)")
        
        # Low-rank approximation
        k = 2  # Keep only 2 components
        
        # Truncated SVD
        U_k = U[:, :k]
        s_k = s[:k]
        Vt_k = Vt[:k, :]
        
        # Reconstruct
        Σ_k = np.diag(s_k)
        ratings_approx = U_k @ Σ_k @ Vt_k
        
        print(f"\n🔽 Rank-{k} Approximation:")
        print(ratings_approx.round(2))
        
        print(f"\nApproximation quality:")
        print(f"  Frobenius norm error: {np.linalg.norm(ratings - ratings_approx):.3f}")
        print(f"  Compression ratio: {ratings.size}/{U_k.size + s_k.size + Vt_k.size:.0f} = "
              f"{ratings.size/(U_k.size + s_k.size + Vt_k.size):.1f}x")
        
        # Recommendation system insight
        print(f"\n💡 Recommendation Insights:")
        print(f"  User 1 and 2 are similar (both like movies 1 & 2)")
        print(f"  User 3, 4, 5 form another group (like movies 3 & 4)")
        print(f"  The rank-2 approximation captures these two taste groups!")
    
    def cholesky_decomposition(self):
        """Cholesky decomposition for positive definite matrices"""
        print("\n🔺 Cholesky Decomposition")
        print("=" * 60)
        
        # Create a positive definite matrix (covariance matrix)
        A_temp = np.random.randn(3, 3)
        A = A_temp @ A_temp.T  # Ensure positive definite
        
        print(f"Positive definite matrix A:\n{A}")
        
        # Cholesky decomposition: A = L @ L^T
        L = np.linalg.cholesky(A)
        
        print(f"\nL (Lower triangular):\n{L}")
        
        # Verify
        reconstructed = L @ L.T
        print(f"\nL @ L^T:\n{reconstructed}")
        print(f"Match with A? {np.allclose(reconstructed, A)}")
        
        # Application: Efficient random sampling
        print(f"\n🎲 Application: Multivariate Normal Sampling")
        
        # Generate samples from N(0, A)
        n_samples = 5
        z = np.random.randn(n_samples, 3)  # Standard normal
        samples = z @ L.T  # Transform to N(0, A)
        
        print(f"  Generated {n_samples} samples from N(0, A)")
        print(f"  Sample covariance:\n{np.cov(samples.T).round(2)}")
        print(f"  Should approximate A")

# Explore decompositions
decomp = MatrixDecompositions()
decomp.lu_decomposition()
decomp.qr_decomposition()
decomp.svd_decomposition()
decomp.cholesky_decomposition()

Practical Applications: Linear Algebra in Action 🚀

🌟 Where Linear Algebra Powers Data Science

import numpy as np

# Complete Practical Example: Image Compression with SVD

def compress_image_with_svd():
    """
    Compress an image using SVD - real-world linear algebra!
    """
    print("🖼️ Image Compression with SVD")
    print("=" * 60)
    
    # Create a synthetic "image" (normally you'd load a real image)
    # Let's create a pattern
    size = 50
    x = np.linspace(-5, 5, size)
    y = np.linspace(-5, 5, size)
    X, Y = np.meshgrid(x, y)
    
    # Create an interesting pattern
    image = np.sin(np.sqrt(X**2 + Y**2)) + np.cos(2*X) * np.sin(2*Y)
    
    # Normalize to 0-255 range
    image = ((image - image.min()) / (image.max() - image.min()) * 255).astype(np.uint8)
    
    print(f"Original image: {size}×{size} pixels")
    print(f"Original size: {image.size} bytes")
    
    # Perform SVD
    U, s, Vt = np.linalg.svd(image, full_matrices=False)
    
    print(f"\nSingular values (first 10): {s[:10].round(1)}")
    
    # Compress by keeping only k components
    compression_levels = [5, 10, 20, 30]
    
    for k in compression_levels:
        # Reconstruct with k components
        compressed = U[:, :k] @ np.diag(s[:k]) @ Vt[:k, :]
        
        # Calculate compression ratio
        original_size = image.size
        compressed_size = k * (U.shape[0] + Vt.shape[1] + 1)
        ratio = original_size / compressed_size
        
        # Calculate error
        mse = np.mean((image - compressed)**2)
        psnr = 10 * np.log10(255**2 / mse) if mse > 0 else float('inf')
        
        print(f"\nRank-{k} approximation:")
        print(f"  Compression ratio: {ratio:.1f}x")
        print(f"  PSNR: {psnr:.1f} dB")
        print(f"  Storage: {compressed_size} values (vs {original_size})")

compress_image_with_svd()

Summary: Your Linear Algebra Toolkit 🛠️

🎯 Key Takeaways:

# Quick Linear Algebra Reference Card
import numpy as np

# BASIC OPERATIONS
# ----------------
# Vectors
v1 = np.array([1, 2, 3])
v2 = np.array([4, 5, 6])
dot_product = v1 @ v2  # or np.dot(v1, v2)
magnitude = np.linalg.norm(v1)

# Matrices
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])
matrix_mult = A @ B  # or np.matmul(A, B)
transpose = A.T
inverse = np.linalg.inv(A)
determinant = np.linalg.det(A)

# SOLVING SYSTEMS
# ---------------
# Ax = b
x = np.linalg.solve(A, b)  # Direct solve
x = np.linalg.lstsq(A, b, rcond=None)[0]  # Least squares

# EIGENPROBLEMS
# -------------
eigenvalues, eigenvectors = np.linalg.eig(A)
# For symmetric matrices (faster, more stable)
eigenvalues, eigenvectors = np.linalg.eigh(A)

# DECOMPOSITIONS
# --------------
# SVD
U, s, Vt = np.linalg.svd(A)
# QR
Q, R = np.linalg.qr(A)
# Cholesky (for positive definite)
L = np.linalg.cholesky(A)

# COMMON PATTERNS
# ---------------
# PCA
cov_matrix = np.cov(data.T)
eigenvalues, eigenvectors = np.linalg.eig(cov_matrix)
principal_components = data @ eigenvectors

# Distance matrix
diff = points[:, np.newaxis, :] - points[np.newaxis, :, :]
distances = np.linalg.norm(diff, axis=2)

# Regularized regression
w = np.linalg.solve(X.T @ X + lambda * np.eye(n), X.T @ y)

print("🔢 Linear algebra: The foundation of data science!")