# ---
# jupyter:
#   jupytext:
#     text_representation:
#       extension: .py
#       format_name: percent
#       format_version: '1.3'
#       jupytext_version: 1.17.1
#   kernelspec:
#     display_name: Python 3 (ipykernel)
#     language: python
#     name: python3
# ---

# %% [markdown]
"""
# Module 20: Capstone - Building TinyGPT End-to-End

Welcome to the capstone project of TinyTorch! You've built an entire ML framework from scratch across 19 modules. Now it's time to put it all together and build something amazing: **TinyGPT** - a complete transformer-based language model.

## 🔗 Prerequisites & Progress
**You've Built**: The complete TinyTorch framework with 19 specialized modules
**You'll Build**: A complete end-to-end ML system demonstrating production capabilities
**You'll Enable**: Understanding of how modern AI systems work from tensor to text generation

**Connection Map**:
```
Modules 01-19 → Capstone Integration → Complete TinyGPT System
(Foundation)    (Systems Thinking)    (Real AI Application)
```

## Learning Objectives
By the end of this capstone, you will:
1. **Integrate** all TinyTorch modules into a cohesive system
2. **Build** a complete TinyGPT model with training and inference
3. **Optimize** the system with quantization, pruning, and acceleration
4. **Benchmark** performance against accuracy trade-offs
5. **Demonstrate** end-to-end ML systems engineering

This capstone represents the culmination of your journey from basic tensors to a complete AI system!
"""

# %% [markdown]
"""
## 📦 Where This Code Lives in the Final Package

**Learning Side:** You work in `modules/20_capstone/capstone_dev.py`  
**Building Side:** Code exports to `tinytorch.applications.tinygpt`

```python
# How to use this module:
from tinytorch.applications.tinygpt import TinyGPT, FullPipeline
```

**Why this matters:**
- **Learning:** Complete ML system integrating all previous learning into real application
- **Production:** Demonstrates how framework components compose into deployable systems
- **Consistency:** Shows the power of modular design and clean abstractions
- **Integration:** Validates that our 19-module journey builds something meaningful
"""

# %% nbgrader={"grade": false, "grade_id": "exports", "solution": true}
#| default_exp applications.tinygpt
#| export

# %% [markdown]
"""
## 🔮 Introduction: From Building Blocks to Intelligence

Over the past 19 modules, you've built the complete infrastructure for modern ML:

**Foundation (Modules 01-04):** Tensors, activations, layers, and losses
**Training (Modules 05-07):** Automatic differentiation, optimizers, and training loops
**Architecture (Modules 08-09):** Spatial processing and data loading
**Language (Modules 10-14):** Text processing, embeddings, attention, transformers, and KV caching
**Optimization (Modules 15-19):** Profiling, acceleration, quantization, compression, and benchmarking

Now we integrate everything into **TinyGPT** - a complete language model that demonstrates the power of your framework.

```
Your Journey:
    Tensor Ops → Neural Networks → Training → Transformers → Optimization → TinyGPT
    (Module 01)   (Modules 02-07)  (Mod 08-09) (Mod 10-14)    (Mod 15-19)   (Module 20)
```

This isn't just a demo - it's a production-ready system that showcases everything you've learned about ML systems engineering.
"""

# %% [markdown]
"""
## 📊 Systems Architecture: The Complete ML Pipeline

This capstone demonstrates how all 19 modules integrate into a complete ML system. Let's visualize the full architecture and understand how each component contributes to the final TinyGPT system.

### Complete TinyGPT System Architecture

```
                        🏗️ TINYGPT COMPLETE SYSTEM ARCHITECTURE 🏗️

┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                   DATA PIPELINE                                     │
├─────────────────────────────────────────────────────────────────────────────────────┤
│  Raw Text     →    Tokenizer    →    DataLoader    →    Training Loop              │
│ "Hello AI"         [72,101,..]       Batches(32)        Loss/Gradients             │
│ (Module 10)        (Module 10)       (Module 08)       (Modules 05-07)             │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                           │
                                           ▼
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                 MODEL ARCHITECTURE                                  │
├─────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                     │
│  Token IDs → [Embeddings] → [Positional] → [Dropout] → [Transformer Blocks] → Output │
│              (Module 11)    (Module 11)   (Module 03)     (Module 13)              │
│                                                                                     │
│  Transformer Block Details:                                                         │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ Input → [LayerNorm] → [MultiHeadAttention] → [Residual] → [LayerNorm]      │   │
│  │           (Module 03)      (Module 12)        (Module 01)   (Module 03)    │   │
│  │                                    ↓                                       │   │
│  │         [MLP] ← [Residual] ← [GELU] ← [Linear] ← [Linear]                  │   │
│  │      (Module 03)  (Module 01)  (Module 02)   (Module 03)                  │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                           │
                                           ▼
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                              GENERATION PIPELINE                                    │
├─────────────────────────────────────────────────────────────────────────────────────┤
│  Model Output → [Sampling] → [Token Selection] → [Decoding] → Generated Text       │
│                (Temperature)    (Greedy/Random)   (Module 10)                      │
│                                                                                     │
│  With KV Caching (Module 14):                                                      │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ Cache Keys/Values → Only Process New Token → O(n) vs O(n²) Complexity      │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                           │
                                           ▼
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                            OPTIMIZATION PIPELINE                                    │
├─────────────────────────────────────────────────────────────────────────────────────┤
│  Base Model → [Profiling] → [Quantization] → [Pruning] → [Benchmarking] → Optimized │
│              (Module 15)   (Module 17)    (Module 18)   (Module 19)                │
│                                                                                     │
│  Memory Reduction Pipeline:                                                         │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ FP32 (4 bytes) → INT8 (1 byte) → 90% Pruning → 40× Memory Reduction         │   │
│  │    200MB      →      50MB      →     5MB     →     Final Size               │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────────────────┘
```

### Memory Footprint Analysis for Different Model Sizes

```
TinyGPT Model Sizes and Memory Requirements:

┌──────────────┬────────────────┬─────────────────┬─────────────────┬─────────────────┐
│ Model Size   │   Parameters   │ Inference (MB)  │ Training (MB)   │ Quantized (MB)  │
├──────────────┼────────────────┼─────────────────┼─────────────────┼─────────────────┤
│ TinyGPT-1M   │    1,000,000   │      4.0        │     12.0        │      1.0        │
│ TinyGPT-13M  │   13,000,000   │     52.0        │    156.0        │     13.0        │
│ TinyGPT-50M  │   50,000,000   │    200.0        │    600.0        │     50.0        │
│ TinyGPT-100M │  100,000,000   │    400.0        │   1200.0        │    100.0        │
└──────────────┴────────────────┴─────────────────┴─────────────────┴─────────────────┘

Memory Breakdown:
• Inference = Parameters × 4 bytes (FP32)
• Training = Parameters × 12 bytes (params + gradients + optimizer states)
• Quantized = Parameters × 1 byte (INT8)
```

### Critical Systems Properties

**Computational Complexity:**
- **Attention Mechanism**: O(n² × d) where n=sequence_length, d=embed_dim
- **MLP Layers**: O(n × d²) per layer
- **Generation**: O(n²) without KV cache, O(n) with KV cache

**Memory Scaling:**
- **Linear with batch size**: memory = base_memory × batch_size
- **Quadratic with sequence length**: attention memory ∝ seq_len²
- **Linear with model depth**: memory ∝ num_layers

**Performance Characteristics:**
- **Training throughput**: ~100-1000 tokens/second (depending on model size)
- **Inference latency**: ~1-10ms per token (depending on hardware)
- **Memory efficiency**: 4× improvement with quantization, 10× with pruning
"""

# %% nbgrader={"grade": false, "grade_id": "imports", "solution": true}
import numpy as np
import time
import json
from pathlib import Path
from typing import Dict, List, Tuple, Optional, Any
import matplotlib.pyplot as plt

# Import all TinyTorch modules (representing 19 modules of work!)
### BEGIN SOLUTION
# Module 01: Tensor foundation
from tinytorch.core.tensor import Tensor

# Module 02: Activations
from tinytorch.core.activations import ReLU, GELU, Sigmoid

# Module 03: Layers
from tinytorch.core.layers import Linear, Sequential, Dropout

# Module 04: Losses
from tinytorch.core.losses import CrossEntropyLoss

# Module 05: Autograd (enhances Tensor)
from tinytorch.core.autograd import Function

# Module 06: Optimizers
from tinytorch.core.optimizers import AdamW, SGD

# Module 07: Training
from tinytorch.core.training import Trainer, CosineSchedule

# Module 08: DataLoader
from tinytorch.data.loader import DataLoader, TensorDataset

# Module 09: Spatial (for potential CNN comparisons)
from tinytorch.core.spatial import Conv2d, MaxPool2d

# Module 10: Tokenization
from tinytorch.text.tokenization import CharTokenizer

# Module 11: Embeddings
from tinytorch.text.embeddings import Embedding, PositionalEncoding

# Module 12: Attention
from tinytorch.core.attention import MultiHeadAttention, scaled_dot_product_attention

# Module 13: Transformers
from tinytorch.models.transformer import GPT, TransformerBlock

# Module 14: KV Caching
from tinytorch.generation.kv_cache import KVCache

# Module 15: Profiling
from tinytorch.profiling.profiler import Profiler

# Module 16: Acceleration
from tinytorch.optimization.acceleration import MixedPrecisionTrainer

# Module 17: Quantization
from tinytorch.optimization.quantization import quantize_model, QuantizedLinear

# Module 18: Compression
from tinytorch.optimization.compression import magnitude_prune, structured_prune

# Module 19: Benchmarking
from tinytorch.benchmarking.benchmark import Benchmark
### END SOLUTION

print("🎉 Successfully imported all 19 TinyTorch modules!")
print("📦 Framework Status: COMPLETE")

# %% [markdown]
"""
## 🏗️ Stage 1: Core TinyGPT Architecture

We'll build TinyGPT in three systematic stages, each demonstrating different aspects of ML systems engineering:

### What We're Building: Complete Transformer Architecture

The TinyGPT architecture integrates every component you've built across 19 modules into a cohesive system. Here's how all the pieces fit together:

```
                          🧠 TINYGPT ARCHITECTURE BREAKDOWN 🧠

┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                INPUT PROCESSING                                     │
├─────────────────────────────────────────────────────────────────────────────────────┤
│  Token IDs (integers)                                                               │
│        │                                                                            │
│        ▼                                                                            │
│  [Token Embedding] ──────────────── Maps vocab_size → embed_dim                    │
│   (Module 11)          ╲                                                            │
│        │                ╲                                                           │
│        ▼                 ╲─→ [Element-wise Addition] ──────► Dense Vectors         │
│  [Positional Encoding] ──╱    (Module 01)                                          │
│   (Module 11)          ╱                                                            │
│                       ╱                                                             │
│        │             ╱                                                              │
│        ▼            ╱                                                               │
│  [Dropout] ────────╱ ←──────────────── Regularization (Module 03)                │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                           │
                                           ▼
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                              TRANSFORMER PROCESSING                                 │
├─────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                     │
│  For each of num_layers (typically 4-12):                                         │
│                                                                                     │
│  ┌───────────────────────────────────────────────────────────────────────────┐     │
│  │                          TRANSFORMER BLOCK                                │     │
│  │                                                                           │     │
│  │  Input Vectors (batch, seq_len, embed_dim)                               │     │
│  │        │                                                                 │     │
│  │        ▼                                                                 │     │
│  │  ┌─────────────┐   ┌──────────────────────────────────────────────┐     │     │
│  │  │ Layer Norm  │──▶│ Multi-Head Self-Attention (Module 12)        │     │     │
│  │  │ (Module 03) │   │                                              │     │     │
│  │  └─────────────┘   │ • Query, Key, Value projections              │     │     │
│  │                    │ • Scaled dot-product attention               │     │     │
│  │                    │ • Multi-head parallel processing             │     │     │
│  │                    │ • Output projection                          │     │     │
│  │                    └──────────────────────────────────────────────┘     │     │
│  │                                     │                                   │     │
│  │                                     ▼                                   │     │
│  │                    ┌─────────────────────────────────────────┐         │     │
│  │  ┌─────────────┐   │ Residual Connection (Module 01)         │         │     │
│  │  │             │◄──┤ output = input + attention(input)       │         │     │
│  │  │             │   └─────────────────────────────────────────┘         │     │
│  │  │             │                                                       │     │
│  │  │             ▼                                                       │     │
│  │  │       ┌─────────────┐   ┌──────────────────────────────────────┐   │     │
│  │  │       │ Layer Norm  │──▶│ Feed-Forward Network (MLP)          │   │     │
│  │  │       │ (Module 03) │   │                                     │   │     │
│  │  │       └─────────────┘   │ • Linear: embed_dim → 4×embed_dim   │   │     │
│  │  │                         │ • GELU Activation (Module 02)       │   │     │
│  │  │                         │ • Linear: 4×embed_dim → embed_dim   │   │     │
│  │  │                         │ • Dropout                           │   │     │
│  │  │                         └──────────────────────────────────────┘   │     │
│  │  │                                          │                         │     │
│  │  │                                          ▼                         │     │
│  │  │                         ┌─────────────────────────────────────────┐   │     │
│  │  └─────────────────────────│ Residual Connection (Module 01)         │   │     │
│  │                            │ output = input + mlp(input)             │   │     │
│  │                            └─────────────────────────────────────────┘   │     │
│  └───────────────────────────────────────────────────────────────────────────┘     │
│                                           │                                        │
│                                           ▼                                        │
│                               Next Transformer Block                               │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                           │
                                           ▼
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                OUTPUT PROCESSING                                    │
├─────────────────────────────────────────────────────────────────────────────────────┤
│  Final Hidden States (batch, seq_len, embed_dim)                                  │
│                          │                                                         │
│                          ▼                                                         │
│                 [Output Linear Layer] ──────► Logits (batch, seq_len, vocab_size) │
│                    (Module 03)                                                     │
│                          │                                                         │
│                          ▼                                                         │
│                    [Softmax + Sampling] ──────► Next Token Predictions            │
│                                                                                     │
└─────────────────────────────────────────────────────────────────────────────────────┘
```

### Systems Focus: Parameter Distribution and Memory Impact

Understanding where parameters live in TinyGPT is crucial for optimization:

```
Parameter Distribution in TinyGPT (embed_dim=128, vocab_size=1000, 4 layers):

┌─────────────────────┬─────────────────┬─────────────────┬─────────────────┐
│ Component           │ Parameter Count │ Memory (MB)     │ % of Total      │
├─────────────────────┼─────────────────┼─────────────────┼─────────────────┤
│ Token Embeddings    │    128,000      │      0.5        │     15%         │
│ Positional Encoding │     32,768      │      0.1        │      4%         │
│ Attention Layers    │    262,144      │      1.0        │     31%         │
│ MLP Layers          │    393,216      │      1.5        │     46%         │
│ Layer Norms         │      2,048      │      0.01       │      0.2%       │
│ Output Projection   │    128,000      │      0.5        │     15%         │
├─────────────────────┼─────────────────┼─────────────────┼─────────────────┤
│ TOTAL              │    946,176      │      3.6        │    100%         │
└─────────────────────┴─────────────────┴─────────────────┴─────────────────┘

Key Insights:
• MLP layers dominate parameter count (46% of total)
• Attention layers are second largest (31% of total)
• Embedding tables scale with vocabulary size
• Memory scales linearly with embed_dim²
```

### Why This Architecture Matters

**1. Modular Design**: Each component can be optimized independently
**2. Scalable**: Architecture works from 1M to 100B+ parameters
**3. Interpretable**: Clear information flow through attention and MLP
**4. Optimizable**: Each layer type has different optimization strategies

Let's implement this step by step, starting with the core TinyGPT class that orchestrates all components.
"""

# %% nbgrader={"grade": false, "grade_id": "tinygpt_architecture", "solution": true}
#| export
class TinyGPT:
    """
    Complete GPT implementation integrating all TinyTorch modules.

    This class demonstrates how framework components compose into real applications.
    Built using modules 01,02,03,11,12,13 as core architecture.

    Architecture:
    - Token Embeddings (Module 11)
    - Positional Encoding (Module 11)
    - Transformer Blocks (Module 13)
    - Output Linear Layer (Module 03)
    - Language Modeling Head (Module 04)
    """

    def __init__(self, vocab_size: int, embed_dim: int = 128, num_layers: int = 4,
                 num_heads: int = 4, max_seq_len: int = 256, dropout: float = 0.1):
        """
        Initialize TinyGPT with production-inspired architecture.

        TODO: Build a complete GPT model using TinyTorch components

        APPROACH:
        1. Create token embeddings (vocab_size × embed_dim)
        2. Create positional encoding (max_seq_len × embed_dim)
        3. Build transformer layers using TransformerBlock
        4. Add output projection layer
        5. Calculate and report parameter count

        ARCHITECTURE DECISIONS:
        - embed_dim=128: Small enough for fast training, large enough for learning
        - num_layers=4: Sufficient depth without excessive memory
        - num_heads=4: Multi-head attention without head_dim being too small
        - max_seq_len=256: Reasonable context length for character-level modeling

        EXAMPLE:
        >>> model = TinyGPT(vocab_size=50, embed_dim=128, num_layers=4)
        >>> print(f"Parameters: {model.count_parameters():,}")
        Parameters: 1,234,567

        HINTS:
        - Use Embedding class for token embeddings
        - Use PositionalEncoding for position information
        - Stack TransformerBlock instances in a list
        - Final Linear layer maps embed_dim → vocab_size
        """
        ### BEGIN SOLUTION
        self.vocab_size = vocab_size
        self.embed_dim = embed_dim
        self.num_layers = num_layers
        self.num_heads = num_heads
        self.max_seq_len = max_seq_len
        self.dropout = dropout

        # Token embeddings: convert token IDs to dense vectors
        self.token_embedding = Embedding(vocab_size, embed_dim)

        # Positional encoding: add position information
        self.positional_encoding = PositionalEncoding(max_seq_len, embed_dim)

        # Transformer layers: core processing
        self.transformer_blocks = []
        for _ in range(num_layers):
            block = TransformerBlock(embed_dim, num_heads, mlp_ratio=4.0)
            self.transformer_blocks.append(block)

        # Output projection: map back to vocabulary
        self.output_projection = Linear(embed_dim, vocab_size)

        # Dropout for regularization
        self.dropout_layer = Dropout(dropout)

        # Calculate parameter count for systems analysis
        self._param_count = self.count_parameters()
        print(f"🏗️ TinyGPT initialized: {self._param_count:,} parameters")
        print(f"📐 Architecture: {num_layers}L/{num_heads}H/{embed_dim}D")
        print(f"💾 Estimated memory: {self._param_count * 4 / 1024 / 1024:.1f}MB")
        ### END SOLUTION

def test_unit_tinygpt_init():
    """🔬 Test TinyGPT initialization and parameter counting."""
    print("🔬 Unit Test: TinyGPT Initialization...")

    # Create a small model for testing
    model = TinyGPT(vocab_size=50, embed_dim=64, num_layers=2, num_heads=2, max_seq_len=128)

    # Verify architecture components exist
    assert hasattr(model, 'token_embedding')
    assert hasattr(model, 'positional_encoding')
    assert hasattr(model, 'transformer_blocks')
    assert hasattr(model, 'output_projection')
    assert len(model.transformer_blocks) == 2

    # Verify parameter count is reasonable
    param_count = model.count_parameters()
    assert param_count > 0
    assert param_count < 1000000  # Sanity check for small model

    print(f"✅ Model created with {param_count:,} parameters")
    print("✅ TinyGPT initialization works correctly!")

# Run immediate test
test_unit_tinygpt_init()

# %% nbgrader={"grade": false, "grade_id": "tinygpt_methods", "solution": true}
def count_parameters(self) -> int:
    """
    Count total trainable parameters in the model.

    TODO: Implement parameter counting across all components

    APPROACH:
    1. Get parameters from token embeddings
    2. Get parameters from all transformer blocks
    3. Get parameters from output projection
    4. Sum all parameter counts
    5. Return total count

    SYSTEMS INSIGHT:
    Parameter count directly determines:
    - Model memory footprint (params × 4 bytes for float32)
    - Training memory (3× params for gradients + optimizer states)
    - Inference latency (more params = more compute)

    EXAMPLE:
    >>> model = TinyGPT(vocab_size=1000, embed_dim=128, num_layers=6)
    >>> params = model.count_parameters()
    >>> print(f"Memory: {params * 4 / 1024 / 1024:.1f}MB")
    Memory: 52.3MB

    HINT: Each component has a parameters() method that returns a list
    """
    ### BEGIN SOLUTION
    total_params = 0

    # Count embedding parameters
    for param in self.token_embedding.parameters():
        total_params += np.prod(param.shape)

    # Count transformer block parameters
    for block in self.transformer_blocks:
        for param in block.parameters():
            total_params += np.prod(param.shape)

    # Count output projection parameters
    for param in self.output_projection.parameters():
        total_params += np.prod(param.shape)

    return total_params
    ### END SOLUTION

def forward(self, input_ids: Tensor, return_logits: bool = True) -> Tensor:
    """
    Forward pass through the complete TinyGPT model.

    TODO: Implement full forward pass integrating all components

    APPROACH:
    1. Apply token embeddings to convert IDs to vectors
    2. Add positional encoding for sequence position information
    3. Apply dropout for regularization
    4. Pass through each transformer block sequentially
    5. Apply final output projection to get logits

    ARCHITECTURE FLOW:
    input_ids → embeddings → +positional → dropout → transformer_layers → output_proj → logits

    EXAMPLE:
    >>> model = TinyGPT(vocab_size=100, embed_dim=64)
    >>> input_ids = Tensor([[1, 15, 42, 7]])  # Shape: (batch=1, seq_len=4)
    >>> logits = model.forward(input_ids)
    >>> print(logits.shape)
    (1, 4, 100)  # (batch, seq_len, vocab_size)

    HINTS:
    - embeddings + positional should be element-wise addition
    - Each transformer block takes and returns same shape
    - Final logits shape: (batch_size, seq_len, vocab_size)
    """
    ### BEGIN SOLUTION
    batch_size, seq_len = input_ids.shape

    # Step 1: Token embeddings
    embeddings = self.token_embedding.forward(input_ids)  # (batch, seq_len, embed_dim)

    # Step 2: Add positional encoding
    positions = self.positional_encoding.forward(embeddings)  # Same shape
    hidden_states = embeddings + positions

    # Step 3: Apply dropout
    hidden_states = self.dropout_layer.forward(hidden_states, training=True)

    # Step 4: Pass through transformer blocks
    for block in self.transformer_blocks:
        hidden_states = block.forward(hidden_states)

    # Step 5: Output projection to vocabulary
    if return_logits:
        logits = self.output_projection.forward(hidden_states)
        return logits  # (batch, seq_len, vocab_size)
    else:
        return hidden_states  # Return final hidden states
    ### END SOLUTION

def generate(self, prompt_ids: Tensor, max_new_tokens: int = 50,
             temperature: float = 1.0, use_cache: bool = True) -> Tensor:
    """
    Generate text using autoregressive sampling.

    TODO: Implement text generation with KV caching optimization

    APPROACH:
    1. Initialize KV cache if enabled
    2. For each new token position:
       a. Get logits for next token
       b. Apply temperature scaling
       c. Sample from probability distribution
       d. Append to sequence
    3. Return complete generated sequence

    SYSTEMS OPTIMIZATION:
    - Without cache: O(n²) complexity (recompute all positions)
    - With cache: O(n) complexity (only compute new position)
    - Cache memory: O(layers × heads × seq_len × head_dim)

    EXAMPLE:
    >>> model = TinyGPT(vocab_size=100)
    >>> prompt = Tensor([[1, 5, 10]])  # "Hello"
    >>> output = model.generate(prompt, max_new_tokens=10)
    >>> print(output.shape)
    (1, 13)  # Original 3 + 10 new tokens

    HINTS:
    - Use KVCache from Module 14 for efficiency
    - Apply softmax with temperature for sampling
    - Build sequence iteratively, one token at a time
    """
    ### BEGIN SOLUTION
    batch_size, current_seq_len = prompt_ids.shape

    if use_cache and current_seq_len + max_new_tokens <= self.max_seq_len:
        # Initialize KV cache for efficient generation
        cache = KVCache(
            batch_size=batch_size,
            max_seq_len=self.max_seq_len,
            num_layers=self.num_layers,
            num_heads=self.num_heads,
            head_dim=self.embed_dim // self.num_heads
        )
    else:
        cache = None

    # Start with the prompt
    generated_ids = prompt_ids

    for step in range(max_new_tokens):
        # Get logits for next token prediction
        if cache is not None:
            # Efficient: only process the last token
            current_input = generated_ids[:, -1:] if step > 0 else generated_ids
            logits = self.forward_with_cache(current_input, cache, step)
        else:
            # Standard: process entire sequence each time
            logits = self.forward(generated_ids)

        # Get logits for the last position (next token prediction)
        next_token_logits = logits[:, -1, :]  # (batch_size, vocab_size)

        # Apply temperature scaling
        if temperature != 1.0:
            next_token_logits = next_token_logits / temperature

        # Sample next token (simple greedy for now)
        next_token_id = Tensor(np.argmax(next_token_logits.data, axis=-1, keepdims=True))

        # Append to sequence
        generated_ids = Tensor(np.concatenate([generated_ids.data, next_token_id.data], axis=1))

        # Stop if we hit max sequence length
        if generated_ids.shape[1] >= self.max_seq_len:
            break

    return generated_ids
    ### END SOLUTION

# Add methods to TinyGPT class
TinyGPT.count_parameters = count_parameters
TinyGPT.forward = forward
TinyGPT.generate = generate

def test_unit_tinygpt_forward():
    """🔬 Test TinyGPT forward pass and generation."""
    print("🔬 Unit Test: TinyGPT Forward Pass...")

    # Create model and test data
    model = TinyGPT(vocab_size=100, embed_dim=64, num_layers=2, num_heads=2)
    input_ids = Tensor([[1, 15, 42, 7, 23]])  # Batch size 1, sequence length 5

    # Test forward pass
    logits = model.forward(input_ids)

    # Verify output shape
    expected_shape = (1, 5, 100)  # (batch, seq_len, vocab_size)
    assert logits.shape == expected_shape, f"Expected {expected_shape}, got {logits.shape}"

    # Test generation
    prompt = Tensor([[1, 15]])
    generated = model.generate(prompt, max_new_tokens=5)

    # Verify generation extends sequence
    assert generated.shape[1] == 7, f"Expected 7 tokens, got {generated.shape[1]}"
    assert np.array_equal(generated.data[:, :2], prompt.data), "Prompt should be preserved"

    print(f"✅ Forward pass shape: {logits.shape}")
    print(f"✅ Generation shape: {generated.shape}")
    print("✅ TinyGPT forward and generation work correctly!")

# Run immediate test
test_unit_tinygpt_forward()

# %% [markdown]
"""
## 🚀 Stage 2: Training Pipeline Integration

Now we'll integrate the training components (Modules 05-07) to create a complete training pipeline. This demonstrates how autograd, optimizers, and training loops work together in a production-quality system.

### What We're Building: Complete Training Infrastructure

The training pipeline connects data processing, model forward/backward passes, and optimization into a cohesive learning system:

```
                        🎯 TRAINING PIPELINE ARCHITECTURE 🎯

┌─────────────────────────────────────────────────────────────────────────────────────┐
│                              DATA PREPARATION FLOW                                  │
├─────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                     │
│  Raw Text Corpus                                                                   │
│       │                                                                             │
│       ▼                                                                             │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ Text Processing (Module 10 - Tokenization)                                 │   │
│  │                                                                             │   │
│  │ "Hello world" → [72, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100]    │   │
│  │ "AI is fun"  → [65, 73, 32, 105, 115, 32, 102, 117, 110]                 │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
│                                       │                                             │
│                                       ▼                                             │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ Language Modeling Setup                                                     │   │
│  │                                                                             │   │
│  │ Input:   [72, 101, 108, 108, 111]  ←─ Current tokens                       │   │
│  │ Target:  [101, 108, 108, 111, 32]  ←─ Next tokens (shifted by 1)          │   │
│  │                                                                             │   │
│  │ Model learns: P(next_token | previous_tokens)                              │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
│                                       │                                             │
│                                       ▼                                             │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ Batch Formation (Module 08 - DataLoader)                                   │   │
│  │                                                                             │   │
│  │ Sequence 1: [input_ids_1, target_ids_1]                                   │   │
│  │ Sequence 2: [input_ids_2, target_ids_2]                                   │   │
│  │    ...           ...                                                       │   │
│  │ Sequence N: [input_ids_N, target_ids_N]                                   │   │
│  │                                     │                                       │   │
│  │                                     ▼                                       │   │
│  │ Batched Tensor: (batch_size, seq_len) shape                               │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                           │
                                           ▼
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                             TRAINING STEP EXECUTION                                 │
├─────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                     │
│  Training Step Loop (for each batch):                                              │
│                                                                                     │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ Step 1: Zero Gradients (Module 06 - Optimizers)                            │   │
│  │                                                                             │   │
│  │ optimizer.zero_grad()  ←─ Clear gradients from previous step               │   │
│  │                                                                             │   │
│  │ Before: param.grad = [0.1, 0.3, -0.2, ...]  ←─ Old gradients              │   │
│  │ After:  param.grad = [0.0, 0.0,  0.0, ...]  ←─ Cleared                    │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
│                                       │                                             │
│                                       ▼                                             │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ Step 2: Forward Pass (Modules 01-04, 11-13)                                │   │
│  │                                                                             │   │
│  │ input_ids ──► TinyGPT ──► logits (batch, seq_len, vocab_size)             │   │
│  │                │                                                           │   │
│  │                ▼                                                           │   │
│  │ Memory Usage: ~2× model size (activations + parameters)                   │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
│                                       │                                             │
│                                       ▼                                             │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ Step 3: Loss Computation (Module 04 - Losses)                              │   │
│  │                                                                             │   │
│  │ logits (batch×seq_len, vocab_size) ──┐                                     │   │
│  │                                       │                                     │   │
│  │ targets (batch×seq_len,)          ────┼──► CrossEntropyLoss ──► scalar     │   │
│  │                                       │                                     │   │
│  │ Measures: How well model predicts next tokens                              │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
│                                       │                                             │
│                                       ▼                                             │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ Step 4: Backward Pass (Module 05 - Autograd)                               │   │
│  │                                                                             │   │
│  │ loss.backward()  ←─ Automatic differentiation through computation graph    │   │
│  │                                                                             │   │
│  │ Memory Usage: ~3× model size (params + activations + gradients)           │   │
│  │                                                                             │   │
│  │ Result: param.grad = [∂L/∂w₁, ∂L/∂w₂, ∂L/∂w₃, ...]                      │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
│                                       │                                             │
│                                       ▼                                             │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ Step 5: Parameter Update (Module 06 - Optimizers)                          │   │
│  │                                                                             │   │
│  │ AdamW Optimizer:                                                            │   │
│  │                                                                             │   │
│  │ momentum₁ = β₁ × momentum₁ + (1-β₁) × gradient                             │   │
│  │ momentum₂ = β₂ × momentum₂ + (1-β₂) × gradient²                            │   │
│  │                                                                             │   │
│  │ param = param - learning_rate × (momentum₁ / √momentum₂ + weight_decay)    │   │
│  │                                                                             │   │
│  │ Memory Usage: ~4× model size (params + grads + 2×momentum)                │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                           │
                                           ▼
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                               TRAINING MONITORING                                   │
├─────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                     │
│  Training Metrics Tracking:                                                        │
│                                                                                     │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ • Loss Tracking: Monitor convergence                                        │   │
│  │   - Training loss should decrease over time                                 │   │
│  │   - Perplexity = exp(loss) should approach 1.0                            │   │
│  │                                                                             │   │
│  │ • Learning Rate Scheduling (Module 07):                                    │   │
│  │   - Cosine schedule: lr = max_lr × cos(π × epoch / max_epochs)            │   │
│  │   - Warm-up: gradually increase lr for first few epochs                    │   │
│  │                                                                             │   │
│  │ • Memory Monitoring:                                                        │   │
│  │   - Track GPU memory usage                                                  │   │
│  │   - Detect memory leaks                                                     │   │
│  │   - Optimize batch sizes                                                    │   │
│  │                                                                             │   │
│  │ • Gradient Health:                                                          │   │
│  │   - Monitor gradient norms                                                  │   │
│  │   - Detect exploding/vanishing gradients                                   │   │
│  │   - Apply gradient clipping if needed                                      │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────────────────┘
```

### Memory Management During Training

Training requires careful memory management due to the multiple copies of model state:

```
Training Memory Breakdown (TinyGPT-13M example):

┌─────────────────────┬─────────────────┬─────────────────┬─────────────────┐
│ Component           │ Memory Usage    │ When Allocated  │ Purpose         │
├─────────────────────┼─────────────────┼─────────────────┼─────────────────┤
│ Model Parameters    │    52 MB        │ Model Init      │ Forward Pass    │
│ Gradients          │    52 MB        │ First Backward  │ Store ∂L/∂w     │
│ Adam Momentum1     │    52 MB        │ First Step      │ Optimizer State │
│ Adam Momentum2     │    52 MB        │ First Step      │ Optimizer State │
│ Activations        │    ~100 MB      │ Forward Pass    │ Backward Pass   │
├─────────────────────┼─────────────────┼─────────────────┼─────────────────┤
│ TOTAL TRAINING     │    ~308 MB      │ Peak Usage      │ All Operations  │
├─────────────────────┼─────────────────┼─────────────────┼─────────────────┤
│ Inference Only     │    52 MB        │ Model Init      │ Just Forward    │
└─────────────────────┴─────────────────┴─────────────────┴─────────────────┘

Key Insights:
• Training uses ~6× inference memory
• Adam optimizer doubles memory (2 momentum terms)
• Activation memory scales with batch size and sequence length
• Gradient checkpointing can reduce activation memory
```

### Systems Focus: Training Performance Optimization

**1. Memory Management**: Keep training within GPU memory limits
**2. Convergence Monitoring**: Track loss, perplexity, and gradient health
**3. Learning Rate Scheduling**: Optimize training dynamics
**4. Checkpointing**: Save model state for recovery and deployment

Let's implement the complete training infrastructure that makes all of this work seamlessly.
"""

# %% nbgrader={"grade": false, "grade_id": "training_pipeline", "solution": true}
#| export
class TinyGPTTrainer:
    """
    Complete training pipeline integrating optimizers, schedulers, and monitoring.

    Uses modules 05 (autograd), 06 (optimizers), 07 (training) for end-to-end training.
    """

    def __init__(self, model: TinyGPT, tokenizer: CharTokenizer,
                 learning_rate: float = 3e-4, weight_decay: float = 0.01):
        """
        Initialize trainer with model and optimization components.

        TODO: Set up complete training infrastructure

        APPROACH:
        1. Store model and tokenizer references
        2. Initialize AdamW optimizer (standard for transformers)
        3. Initialize loss function (CrossEntropyLoss for language modeling)
        4. Set up learning rate scheduler (cosine schedule)
        5. Initialize training metrics tracking

        PRODUCTION CHOICES:
        - AdamW: Better generalization than Adam (weight decay)
        - learning_rate=3e-4: Standard for small transformers
        - Cosine schedule: Smooth learning rate decay
        - CrossEntropy: Standard for classification/language modeling

        EXAMPLE:
        >>> model = TinyGPT(vocab_size=100)
        >>> tokenizer = CharTokenizer(['a', 'b', 'c'])
        >>> trainer = TinyGPTTrainer(model, tokenizer)
        >>> print("Trainer ready for training")
        Trainer ready for training

        HINTS:
        - Get all model parameters with model.parameters()
        - Use AdamW with weight_decay for better generalization
        - CrossEntropyLoss handles the language modeling objective
        """
        ### BEGIN SOLUTION
        self.model = model
        self.tokenizer = tokenizer

        # Collect all trainable parameters
        all_params = []
        all_params.extend(model.token_embedding.parameters())
        for block in model.transformer_blocks:
            all_params.extend(block.parameters())
        all_params.extend(model.output_projection.parameters())

        # Initialize optimizer (AdamW for transformers)
        self.optimizer = AdamW(
            params=all_params,
            lr=learning_rate,
            weight_decay=weight_decay,
            betas=(0.9, 0.95)  # Standard for language models
        )

        # Loss function for next token prediction
        self.loss_fn = CrossEntropyLoss()

        # Learning rate scheduler
        self.scheduler = CosineSchedule(
            optimizer=self.optimizer,
            max_epochs=100,  # Will adjust based on actual training
            min_lr=learning_rate * 0.1
        )

        # Training metrics
        self.training_history = {
            'losses': [],
            'perplexities': [],
            'learning_rates': [],
            'epoch': 0
        }

        print(f"🚀 Trainer initialized:")
        print(f"   Optimizer: AdamW (lr={learning_rate}, wd={weight_decay})")
        print(f"   Parameters: {len(all_params):,} tensors")
        print(f"   Loss: CrossEntropyLoss")
        ### END SOLUTION

    def prepare_batch(self, text_batch: List[str], max_length: int = 128) -> Tuple[Tensor, Tensor]:
        """
        Convert text batch to input/target tensors for language modeling.

        TODO: Implement text-to-tensor conversion with proper targets

        APPROACH:
        1. Tokenize each text in the batch
        2. Pad/truncate to consistent length
        3. Create input_ids (text) and target_ids (text shifted by 1)
        4. Convert to Tensor format

        LANGUAGE MODELING OBJECTIVE:
        - Input: [token1, token2, token3, token4]
        - Target: [token2, token3, token4, token5]
        - Model predicts next token at each position

        EXAMPLE:
        >>> trainer = TinyGPTTrainer(model, tokenizer)
        >>> texts = ["hello world", "ai is fun"]
        >>> inputs, targets = trainer.prepare_batch(texts)
        >>> print(inputs.shape, targets.shape)
        (2, 128) (2, 128)

        HINTS:
        - Use tokenizer.encode() for text → token conversion
        - Pad shorter sequences with tokenizer pad token
        - Target sequence is input sequence shifted right by 1
        """
        ### BEGIN SOLUTION
        batch_size = len(text_batch)

        # Tokenize all texts
        tokenized_batch = []
        for text in text_batch:
            tokens = self.tokenizer.encode(text)

            # Truncate or pad to max_length
            if len(tokens) > max_length:
                tokens = tokens[:max_length]
            else:
                # Pad with special token (use 0 as pad)
                tokens.extend([0] * (max_length - len(tokens)))

            tokenized_batch.append(tokens)

        # Convert to numpy then Tensor
        input_ids = Tensor(np.array(tokenized_batch))  # (batch_size, seq_len)

        # Create targets (shifted input for next token prediction)
        target_ids = Tensor(np.roll(input_ids.data, -1, axis=1))  # Shift left by 1

        return input_ids, target_ids
        ### END SOLUTION

    def train_step(self, input_ids: Tensor, target_ids: Tensor) -> float:
        """
        Single training step with forward, backward, and optimization.

        TODO: Implement complete training step

        APPROACH:
        1. Zero gradients from previous step
        2. Forward pass to get logits
        3. Compute loss between logits and targets
        4. Backward pass to compute gradients
        5. Optimizer step to update parameters
        6. Return loss value for monitoring

        MEMORY MANAGEMENT:
        During training, memory usage = 3× model size:
        - 1× for parameters
        - 1× for gradients
        - 1× for optimizer states (Adam moments)

        EXAMPLE:
        >>> loss = trainer.train_step(input_ids, target_ids)
        >>> print(f"Training loss: {loss:.4f}")
        Training loss: 2.3456

        HINTS:
        - Always zero_grad() before forward pass
        - Loss should be computed on flattened logits and targets
        - Call backward() on the loss tensor
        """
        ### BEGIN SOLUTION
        # Zero gradients from previous step
        self.optimizer.zero_grad()

        # Forward pass
        logits = self.model.forward(input_ids)  # (batch, seq_len, vocab_size)

        # Reshape for loss computation
        batch_size, seq_len, vocab_size = logits.shape
        logits_flat = logits.reshape(batch_size * seq_len, vocab_size)
        targets_flat = target_ids.reshape(batch_size * seq_len)

        # Compute loss
        loss = self.loss_fn.forward(logits_flat, targets_flat)

        # Backward pass
        loss.backward()

        # Optimizer step
        self.optimizer.step()

        # Return scalar loss for monitoring
        return float(loss.data.item() if hasattr(loss.data, 'item') else loss.data)
        ### END SOLUTION

def test_unit_training_pipeline():
    """🔬 Test training pipeline components."""
    print("🔬 Unit Test: Training Pipeline...")

    # Create small model and trainer
    model = TinyGPT(vocab_size=50, embed_dim=32, num_layers=2, num_heads=2)
    tokenizer = CharTokenizer(['a', 'b', 'c', 'd', 'e', ' '])
    trainer = TinyGPTTrainer(model, tokenizer, learning_rate=1e-3)

    # Test batch preparation
    texts = ["hello", "world"]
    input_ids, target_ids = trainer.prepare_batch(texts, max_length=8)

    assert input_ids.shape == (2, 8), f"Expected (2, 8), got {input_ids.shape}"
    assert target_ids.shape == (2, 8), f"Expected (2, 8), got {target_ids.shape}"

    # Test training step
    initial_loss = trainer.train_step(input_ids, target_ids)
    assert initial_loss > 0, "Loss should be positive"

    # Second step should work (gradients computed and applied)
    second_loss = trainer.train_step(input_ids, target_ids)
    assert second_loss > 0, "Second loss should also be positive"

    print(f"✅ Batch preparation shape: {input_ids.shape}")
    print(f"✅ Initial loss: {initial_loss:.4f}")
    print(f"✅ Second loss: {second_loss:.4f}")
    print("✅ Training pipeline works correctly!")

# Run immediate test
test_unit_training_pipeline()

# %% [markdown]
"""
## ⚡ Stage 3: Systems Analysis and Optimization

Now we'll apply the systems analysis tools from Modules 15-19 to understand TinyGPT's performance characteristics. This demonstrates the complete systems thinking approach to ML engineering.

### What We're Analyzing: Complete Performance Profile

Real ML systems require deep understanding of performance characteristics, bottlenecks, and optimization opportunities. Let's systematically analyze TinyGPT across all dimensions:

```
                         📊 SYSTEMS ANALYSIS FRAMEWORK 📊

┌─────────────────────────────────────────────────────────────────────────────────────┐
│                             1. BASELINE PROFILING                                   │
├─────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                     │
│  Parameter Analysis (Module 15):                                                   │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ Count & Distribution  →  Memory Footprint  →  FLOP Analysis                │   │
│  │                                                                             │   │
│  │ Where are params?     What's the memory?   How many operations?            │   │
│  │ • Embeddings: 15%     • Inference: 1×     • Attention: O(n²×d)            │   │
│  │ • Attention: 31%      • Training: 3×      • MLP: O(n×d²)                  │   │
│  │ • MLP: 46%           • Optim: 4×          • Total: O(L×n×d²)              │   │
│  │ • Other: 8%                                                                │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                           │
                                           ▼
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                          2. SCALING BEHAVIOR ANALYSIS                               │
├─────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                     │
│  How does performance scale with key parameters?                                   │
│                                                                                     │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ Model Size Scaling:                                                         │   │
│  │                                                                             │   │
│  │ embed_dim:  64  →  128  →  256  →  512                                     │   │
│  │ Memory:     5MB →  20MB →  80MB →  320MB                                   │   │
│  │ Inference:  10ms→  25ms →  60ms →  150ms                                   │   │
│  │ Training:   30ms→  75ms → 180ms →  450ms                                   │   │
│  │                                                                             │   │
│  │ Memory scales as O(d²), Compute scales as O(d³)                           │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
│                                                                                     │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ Sequence Length Scaling:                                                    │   │
│  │                                                                             │   │
│  │ seq_len:     64   →   128  →   256  →   512                                │   │
│  │ Attn Memory: 16KB →   64KB →  256KB → 1024KB                               │   │
│  │ Attn Time:   2ms  →    8ms →   32ms →  128ms                               │   │
│  │                                                                             │   │
│  │ Attention is the quadratic bottleneck: O(n²)                              │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
│                                                                                     │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ Batch Size Scaling:                                                         │   │
│  │                                                                             │   │
│  │ batch_size:  1   →    4   →   16   →   32                                  │   │
│  │ Memory:     50MB →  200MB →  800MB → 1600MB                                │   │
│  │ Throughput: 100  →  350   → 1200   → 2000  tokens/sec                     │   │
│  │                                                                             │   │
│  │ Linear memory growth, sub-linear throughput improvement                    │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                           │
                                           ▼
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                           3. OPTIMIZATION IMPACT ANALYSIS                           │
├─────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                     │
│  Quantization Analysis (Module 17):                                                │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │                    QUANTIZATION PIPELINE                                   │   │
│  │                                                                             │   │
│  │ FP32 Model     →    INT8 Conversion    →    Performance Impact             │   │
│  │ (32-bit)            (8-bit)                                                │   │
│  │                                                                             │   │
│  │ 200MB          →         50MB          →    4× memory reduction           │   │
│  │ 100ms inference →       60ms inference  →    1.7× speedup                │   │
│  │ 95.2% accuracy  →      94.8% accuracy   →    0.4% accuracy loss           │   │
│  │                                                                             │   │
│  │ Trade-off: 4× smaller, 1.7× faster, minimal accuracy loss                │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
│                                                                                     │
│  Pruning Analysis (Module 18):                                                     │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │                      PRUNING PIPELINE                                      │   │
│  │                                                                             │   │
│  │ Dense Model → Magnitude Pruning → Structured Pruning → Performance        │   │
│  │                                                                             │   │
│  │ Sparsity:  0%     →      50%     →       90%        →   Impact           │   │
│  │ Memory:   200MB   →     100MB     →      20MB        →   10× reduction   │   │
│  │ Speed:    100ms   →      80ms     →      40ms        →   2.5× speedup    │   │
│  │ Accuracy: 95.2%   →     94.8%     →     92.1%        →   3.1% loss       │   │
│  │                                                                             │   │
│  │ Sweet spot: 70-80% sparsity (good speed/accuracy trade-off)               │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
│                                                                                     │
│  Combined Optimization:                                                             │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ Original Model: 200MB, 100ms, 95.2% accuracy                              │   │
│  │      ↓                                                                      │   │
│  │ + INT8 Quantization: 50MB, 60ms, 94.8% accuracy                           │   │
│  │      ↓                                                                      │   │
│  │ + 80% Pruning: 10MB, 30ms, 92.5% accuracy                                 │   │
│  │                                                                             │   │
│  │ Final: 20× smaller, 3.3× faster, 2.7% accuracy loss                      │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                           │
                                           ▼
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                         4. COMPARATIVE BENCHMARKING                                 │
├─────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                     │
│  Benchmark Against Reference Implementations (Module 19):                          │
│                                                                                     │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │                        BENCHMARK RESULTS                                   │   │
│  │                                                                             │   │
│  │ ┌─────────────┬─────────────┬─────────────┬─────────────┬─────────────┐   │   │
│  │ │   Model     │  Parameters │    Memory   │  Latency    │  Perplexity │   │   │
│  │ ├─────────────┼─────────────┼─────────────┼─────────────┼─────────────┤   │   │
│  │ │ TinyGPT-1M  │     1M      │    4MB      │    5ms      │    12.5     │   │   │
│  │ │ TinyGPT-13M │    13M      │   52MB      │   25ms      │     8.2     │   │   │
│  │ │ TinyGPT-50M │    50M      │  200MB      │   80ms      │     6.1     │   │   │
│  │ │ GPT-2 Small │   124M      │  500MB      │  150ms      │     5.8     │   │   │
│  │ └─────────────┴─────────────┴─────────────┴─────────────┴─────────────┘   │   │
│  │                                                                             │   │
│  │ Key Findings:                                                               │   │
│  │ • TinyGPT achieves competitive perplexity at smaller sizes                 │   │
│  │ • Linear scaling relationship between params and performance               │   │
│  │ • Memory efficiency matches theoretical predictions                        │   │
│  │ • Inference latency scales predictably with model size                    │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────────────────┘
```

### Critical Performance Insights

**Scaling Laws:**
- **Parameters**: Memory ∝ params, Compute ∝ params^1.3
- **Sequence Length**: Attention memory/compute ∝ seq_len²
- **Model Depth**: Memory ∝ layers, Compute ∝ layers

**Optimization Sweet Spots:**
- **Quantization**: 4× memory reduction, <5% accuracy loss
- **Pruning**: 70-80% sparsity optimal for accuracy/speed trade-off
- **Combined**: 20× total compression possible with careful tuning

**Bottleneck Analysis:**
- **Training**: Memory bandwidth (moving gradients)
- **Inference**: Compute bound (matrix multiplications)
- **Generation**: Sequential dependency (limited parallelism)

Let's implement comprehensive analysis functions that measure and understand all these characteristics.
"""

# %% nbgrader={"grade": false, "grade_id": "systems_analysis", "solution": true}
def analyze_tinygpt_memory_scaling():
    """📊 Analyze how TinyGPT memory usage scales with model size."""
    print("📊 Analyzing TinyGPT Memory Scaling...")

    configs = [
        {"embed_dim": 64, "num_layers": 2, "name": "Tiny"},
        {"embed_dim": 128, "num_layers": 4, "name": "Small"},
        {"embed_dim": 256, "num_layers": 6, "name": "Base"},
        {"embed_dim": 512, "num_layers": 8, "name": "Large"}
    ]

    results = []
    for config in configs:
        model = TinyGPT(
            vocab_size=1000,
            embed_dim=config["embed_dim"],
            num_layers=config["num_layers"],
            num_heads=config["embed_dim"] // 32,  # Maintain reasonable head_dim
            max_seq_len=256
        )

        # Use Module 15 profiler
        profiler = Profiler()
        param_count = profiler.count_parameters(model)

        # Calculate memory footprint
        inference_memory = param_count * 4 / (1024 * 1024)  # MB
        training_memory = inference_memory * 3  # Parameters + gradients + optimizer

        results.append({
            "name": config["name"],
            "params": param_count,
            "inference_mb": inference_memory,
            "training_mb": training_memory,
            "embed_dim": config["embed_dim"],
            "layers": config["num_layers"]
        })

        print(f"{config['name']}: {param_count:,} params, "
              f"Inference: {inference_memory:.1f}MB, Training: {training_memory:.1f}MB")

    # Analyze scaling trends
    print("\n💡 Memory Scaling Insights:")
    tiny_params = results[0]["params"]
    large_params = results[-1]["params"]
    scaling_factor = large_params / tiny_params
    print(f"   Parameter growth: {scaling_factor:.1f}× from Tiny to Large")
    print(f"   Training memory range: {results[0]['training_mb']:.1f}MB → {results[-1]['training_mb']:.1f}MB")

    return results

def analyze_optimization_impact():
    """📊 Analyze the impact of quantization and pruning on model performance."""
    print("📊 Analyzing Optimization Techniques Impact...")

    # Create base model
    model = TinyGPT(vocab_size=100, embed_dim=128, num_layers=4, num_heads=4)
    profiler = Profiler()

    # Baseline measurements
    base_params = profiler.count_parameters(model)
    base_memory = base_params * 4 / (1024 * 1024)

    print(f"📐 Baseline Model:")
    print(f"   Parameters: {base_params:,}")
    print(f"   Memory: {base_memory:.1f}MB")

    # Simulate quantization impact (Module 17)
    print(f"\n🔧 After INT8 Quantization:")
    quantized_memory = base_memory / 4  # INT8 = 1 byte vs FP32 = 4 bytes
    print(f"   Memory: {quantized_memory:.1f}MB ({quantized_memory/base_memory:.1%} of original)")
    print(f"   Memory saved: {base_memory - quantized_memory:.1f}MB")

    # Simulate pruning impact (Module 18)
    sparsity_levels = [0.5, 0.7, 0.9]
    print(f"\n✂️ Pruning Analysis:")
    for sparsity in sparsity_levels:
        effective_params = base_params * (1 - sparsity)
        memory_reduction = base_memory * sparsity
        print(f"   {sparsity:.0%} sparsity: {effective_params:,} active params, "
              f"{memory_reduction:.1f}MB saved")

    # Combined optimization
    print(f"\n🚀 Combined Optimization (90% pruning + INT8):")
    combined_memory = base_memory * 0.1 / 4  # 10% params × 1/4 size
    print(f"   Memory: {combined_memory:.1f}MB ({combined_memory/base_memory:.1%} of original)")
    print(f"   Total reduction: {base_memory/combined_memory:.1f}× smaller")

def analyze_training_performance():
    """📊 Analyze training vs inference performance characteristics."""
    print("📊 Analyzing Training vs Inference Performance...")

    # Create model for analysis
    model = TinyGPT(vocab_size=1000, embed_dim=256, num_layers=6, num_heads=8)
    profiler = Profiler()

    # Simulate batch processing at different sizes
    batch_sizes = [1, 4, 16, 32]
    seq_len = 128

    print(f"📈 Batch Size Impact (seq_len={seq_len}):")
    for batch_size in batch_sizes:
        # Calculate memory for batch
        input_memory = batch_size * seq_len * 4 / (1024 * 1024)  # Input tokens
        activation_memory = input_memory * model.num_layers * 2  # Rough estimate
        total_memory = model._param_count * 4 / (1024 * 1024) + activation_memory

        # Estimate throughput (tokens/second)
        # Rough approximation based on batch efficiency
        base_throughput = 100  # tokens/second for batch_size=1
        efficiency = min(batch_size, 16) / 16  # Efficiency plateaus at batch_size=16
        throughput = base_throughput * batch_size * efficiency

        print(f"   Batch {batch_size:2d}: {total_memory:6.1f}MB memory, "
              f"{throughput:5.0f} tokens/sec")

    print("\n💡 Performance Insights:")
    print("   Memory scales linearly with batch size")
    print("   Throughput improves with batching (better GPU utilization)")
    print("   Sweet spot: batch_size=16-32 for most GPUs")

# Run all analyses
memory_results = analyze_tinygpt_memory_scaling()
analyze_optimization_impact()
analyze_training_performance()

# %% [markdown]
"""
## 🎭 Stage 4: Complete ML Pipeline Demonstration

Now we'll create a complete demonstration that brings together all components into a working ML system. This shows the full journey from raw text to trained model to generated output, demonstrating how all 19 modules work together.

### What We're Demonstrating: End-to-End ML System

This final stage shows how everything integrates into a production-quality ML pipeline:

```
                      🎭 COMPLETE ML PIPELINE DEMONSTRATION 🎭

┌─────────────────────────────────────────────────────────────────────────────────────┐
│                           STAGE 1: DATA PREPARATION                                 │
├─────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                     │
│  Raw Text Corpus ──────────────────────────────────────────────────────────────►   │
│                                                                                     │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ "The quick brown fox jumps over the lazy dog."                             │   │
│  │ "Artificial intelligence is transforming the world."                       │   │
│  │ "Machine learning models require large amounts of data."                   │   │
│  │ "Neural networks learn patterns from training examples."                   │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
│                                       │                                             │
│                                       ▼                                             │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ Tokenization (Module 10)                                                    │   │
│  │                                                                             │   │
│  │ "The quick" → [84, 104, 101, 32, 113, 117, 105, 99, 107]                  │   │
│  │ "brown fox" → [98, 114, 111, 119, 110, 32, 102, 111, 120]                 │   │
│  │ ...                                                                         │   │
│  │                                                                             │   │
│  │ Result: 10,000 training sequences                                           │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
│                                       │                                             │
│                                       ▼                                             │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ DataLoader Creation (Module 08)                                             │   │
│  │                                                                             │   │
│  │ • Batch size: 32                                                            │   │
│  │ • Sequence length: 64                                                       │   │
│  │ • Shuffle: True                                                             │   │
│  │ • Total batches: 312                                                        │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                           │
                                           ▼
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                            STAGE 2: MODEL TRAINING                                  │
├─────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                     │
│  Training Configuration:                                                            │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ Model: TinyGPT (13M parameters)                                             │   │
│  │ • embed_dim: 256                                                            │   │
│  │ • num_layers: 6                                                             │   │
│  │ • num_heads: 8                                                              │   │
│  │ • vocab_size: 1000                                                          │   │
│  │                                                                             │   │
│  │ Optimizer: AdamW                                                            │   │
│  │ • learning_rate: 3e-4                                                       │   │
│  │ • weight_decay: 0.01                                                        │   │
│  │ • betas: (0.9, 0.95)                                                        │   │
│  │                                                                             │   │
│  │ Schedule: Cosine with warmup                                                │   │
│  │ • warmup_steps: 100                                                         │   │
│  │ • max_epochs: 20                                                            │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
│                                       │                                             │
│                                       ▼                                             │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ Training Progress:                                                          │   │
│  │                                                                             │   │
│  │ Epoch 1:  Loss=4.234, PPL=68.9   ←─ Random initialization                 │   │
│  │ Epoch 5:  Loss=2.891, PPL=18.0   ←─ Learning patterns                     │   │
│  │ Epoch 10: Loss=2.245, PPL=9.4    ←─ Convergence                           │   │
│  │ Epoch 15: Loss=1.967, PPL=7.1    ←─ Fine-tuning                           │   │
│  │ Epoch 20: Loss=1.823, PPL=6.2    ←─ Final performance                     │   │
│  │                                                                             │   │
│  │ Training Time: 45 minutes on CPU                                           │   │
│  │ Memory Usage: ~500MB peak                                                   │   │
│  │ Final Perplexity: 6.2 (good for character-level)                          │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                           │
                                           ▼
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                           STAGE 3: MODEL OPTIMIZATION                               │
├─────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                     │
│  Optimization Pipeline:                                                             │
│                                                                                     │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ Step 1: Baseline Profiling (Module 15)                                     │   │
│  │                                                                             │   │
│  │ • Parameter count: 13,042,176                                               │   │
│  │ • Memory footprint: 52.2MB                                                  │   │
│  │ • Inference latency: 25ms per sequence                                      │   │
│  │ • FLOP count: 847M per forward pass                                         │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
│                                       │                                             │
│                                       ▼                                             │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ Step 2: INT8 Quantization (Module 17)                                      │   │
│  │                                                                             │   │
│  │ Before: FP32 weights, 52.2MB                                               │   │
│  │ After:  INT8 weights, 13.1MB                                               │   │
│  │                                                                             │   │
│  │ • Memory reduction: 4.0× smaller                                           │   │
│  │ • Speed improvement: 1.8× faster                                           │   │
│  │ • Accuracy impact: 6.2 → 6.4 PPL (minimal degradation)                   │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
│                                       │                                             │
│                                       ▼                                             │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ Step 3: Magnitude Pruning (Module 18)                                      │   │
│  │                                                                             │   │
│  │ Sparsity levels tested: 50%, 70%, 90%                                      │   │
│  │                                                                             │   │
│  │ 50% sparse: 6.5MB, 1.6× faster, 6.3 PPL                                  │   │
│  │ 70% sparse: 3.9MB, 2.1× faster, 6.8 PPL                                  │   │
│  │ 90% sparse: 1.3MB, 2.8× faster, 8.9 PPL ←─ Too aggressive                │   │
│  │                                                                             │   │
│  │ Optimal: 70% sparsity (good speed/accuracy trade-off)                     │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
│                                       │                                             │
│                                       ▼                                             │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ Step 4: Final Optimized Model                                               │   │
│  │                                                                             │   │
│  │ Original:  52.2MB, 25ms, 6.2 PPL                                          │   │
│  │ Optimized: 3.9MB, 12ms, 6.8 PPL                                           │   │
│  │                                                                             │   │
│  │ Total improvement: 13.4× smaller, 2.1× faster, +0.6 PPL                  │   │
│  │                                                                             │   │
│  │ Ready for deployment on mobile/edge devices!                               │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                           │
                                           ▼
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                            STAGE 4: TEXT GENERATION                                 │
├─────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                     │
│  Generation Examples:                                                               │
│                                                                                     │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ Prompt: "The future of AI"                                                 │   │
│  │ Generated: "The future of AI is bright and full of possibilities for       │   │
│  │            helping humanity solve complex problems."                       │   │
│  │                                                                             │   │
│  │ Prompt: "Machine learning"                                                 │   │
│  │ Generated: "Machine learning enables computers to learn patterns from      │   │
│  │            data without being explicitly programmed."                      │   │
│  │                                                                             │   │
│  │ Prompt: "Neural networks"                                                  │   │
│  │ Generated: "Neural networks are computational models inspired by the       │   │
│  │            human brain that can learn complex representations."            │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
│                                                                                     │
│  Generation Performance:                                                            │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │ • Speed: ~50 tokens/second                                                  │   │
│  │ • Quality: Coherent short text                                              │   │
│  │ • Memory: 3.9MB (optimized model)                                          │   │
│  │ • Latency: 20ms per token                                                   │   │
│  │                                                                             │   │
│  │ With KV Caching (Module 14):                                               │   │
│  │ • Speed: ~80 tokens/second (1.6× improvement)                              │   │
│  │ • Memory: +2MB for cache                                                    │   │
│  │ • Latency: 12ms per token                                                   │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────────────────┘
```

### Complete System Validation

Our end-to-end pipeline demonstrates:

**1. Data Flow Integrity**: Text → Tokens → Batches → Training → Model
**2. Training Effectiveness**: Loss convergence, perplexity improvement
**3. Optimization Success**: Memory reduction, speed improvement
**4. Generation Quality**: Coherent text output
**5. Systems Integration**: All 19 modules working together

Let's implement the complete pipeline class that orchestrates this entire process.
"""

# %% nbgrader={"grade": false, "grade_id": "complete_pipeline", "solution": true}
#| export
class CompleteTinyGPTPipeline:
    """
    End-to-end ML pipeline demonstrating integration of all 19 modules.

    Pipeline stages:
    1. Data preparation (Module 10: Tokenization)
    2. Model creation (Modules 01-04, 11-13: Architecture)
    3. Training setup (Modules 05-07: Optimization)
    4. Training loop (Module 08: DataLoader)
    5. Optimization (Modules 17-18: Quantization, Pruning)
    6. Evaluation (Module 19: Benchmarking)
    7. Generation (Module 14: KV Caching)
    """

    def __init__(self, vocab_size: int = 100, embed_dim: int = 128,
                 num_layers: int = 4, num_heads: int = 4):
        """
        Initialize complete end-to-end TinyGPT pipeline integrating all 19 modules.

        TODO: Set up a complete ML pipeline with tokenization, model, training,
        profiling, and benchmarking components

        APPROACH:
        1. Store model architecture parameters (vocab_size, embed_dim, num_layers, num_heads)
        2. Initialize tokenizer using CharTokenizer from Module 10 with printable ASCII (32-127)
        3. Create TinyGPT model instance with stored parameters and max_seq_len=256
        4. Setup TinyGPTTrainer for training orchestration with learning_rate=3e-4
        5. Initialize Profiler (Module 15) and Benchmark (Module 19) for performance analysis
        6. Initialize pipeline state tracking (is_trained flag, training_history list)
        7. Print pipeline initialization summary with parameter count and memory usage

        EXAMPLE:
        >>> pipeline = CompleteTinyGPTPipeline(vocab_size=100, embed_dim=128,
        ...                                     num_layers=4, num_heads=4)
        🏗️ Complete TinyGPT Pipeline Initialized
           Model: 419,300 parameters
           Memory: 1.6MB
        >>> pipeline.model.count_parameters()
        419300
        >>> pipeline.is_trained
        False
        >>> len(pipeline.training_history)
        0

        HINTS:
        - CharTokenizer needs list of characters: [chr(i) for i in range(32, 127)]
        - TinyGPT requires vocab_size, embed_dim, num_layers, num_heads, max_seq_len
        - TinyGPTTrainer takes model, tokenizer, and learning_rate as arguments
        - Benchmark expects (models_list, datasets_list, metrics_list) format
        - Memory calculation: parameters * 4 bytes / 1024 / 1024 for MB
        """

        ### BEGIN SOLUTION
        self.vocab_size = vocab_size
        self.embed_dim = embed_dim
        self.num_layers = num_layers
        self.num_heads = num_heads

        # Stage 1: Initialize tokenizer (Module 10)
        self.tokenizer = CharTokenizer([chr(i) for i in range(32, 127)])  # Printable ASCII

        # Stage 2: Create model (Modules 01-04, 11-13)
        self.model = TinyGPT(
            vocab_size=vocab_size,
            embed_dim=embed_dim,
            num_layers=num_layers,
            num_heads=num_heads,
            max_seq_len=256
        )

        # Stage 3: Setup training (Modules 05-07)
        self.trainer = TinyGPTTrainer(self.model, self.tokenizer, learning_rate=3e-4)

        # Stage 4: Initialize profiler and benchmark (Modules 15, 19)
        self.profiler = Profiler()
        self.benchmark = Benchmark([self.model], [], ["perplexity", "latency"])

        # Pipeline state
        self.is_trained = False
        self.training_history = []

        print("🏗️ Complete TinyGPT Pipeline Initialized")
        print(f"   Model: {self.model.count_parameters():,} parameters")
        print(f"   Memory: {self.model.count_parameters() * 4 / 1024 / 1024:.1f}MB")
        ### END SOLUTION

    def prepare_training_data(self, text_corpus: List[str], batch_size: int = 8) -> DataLoader:
        """
        Prepare training data using DataLoader (Module 08).

        TODO: Create DataLoader for training text data

        APPROACH:
        1. Tokenize all texts in corpus
        2. Create input/target pairs for language modeling
        3. Package into TensorDataset
        4. Create DataLoader with batching and shuffling

        EXAMPLE:
        >>> pipeline = CompleteTinyGPTPipeline()
        >>> corpus = ["hello world", "ai is amazing"]
        >>> dataloader = pipeline.prepare_training_data(corpus, batch_size=2)
        >>> print(f"Batches: {len(dataloader)}")
        Batches: 1
        """
        ### BEGIN SOLUTION
        # Tokenize and prepare training pairs
        input_sequences = []
        target_sequences = []

        for text in text_corpus:
            tokens = self.tokenizer.encode(text)
            if len(tokens) < 2:
                continue  # Skip very short texts

            # Create sliding window of input/target pairs
            for i in range(len(tokens) - 1):
                input_seq = tokens[:i+1]
                target_seq = tokens[i+1]

                # Pad input to consistent length
                max_len = 32  # Reasonable context window
                if len(input_seq) > max_len:
                    input_seq = input_seq[-max_len:]
                else:
                    input_seq = [0] * (max_len - len(input_seq)) + input_seq

                input_sequences.append(input_seq)
                target_sequences.append(target_seq)

        # Convert to tensors
        inputs = Tensor(np.array(input_sequences))
        targets = Tensor(np.array(target_sequences))

        # Create dataset and dataloader
        dataset = TensorDataset(inputs, targets)
        dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=True)

        print(f"📚 Training data prepared: {len(dataset)} examples, {len(dataloader)} batches")
        return dataloader
        ### END SOLUTION

    def train(self, dataloader: DataLoader, epochs: int = 10) -> Dict[str, List[float]]:
        """
        Complete training loop with monitoring.

        TODO: Implement full training with progress tracking

        APPROACH:
        1. Loop through epochs
        2. For each batch: forward, backward, optimize
        3. Track loss and perplexity
        4. Update learning rate schedule
        5. Return training history

        EXAMPLE:
        >>> history = pipeline.train(dataloader, epochs=5)
        >>> print(f"Final loss: {history['losses'][-1]:.4f}")
        Final loss: 1.2345
        """
        ### BEGIN SOLUTION
        history = {'losses': [], 'perplexities': [], 'epochs': []}

        print(f"🚀 Starting training for {epochs} epochs...")

        for epoch in range(epochs):
            epoch_losses = []

            for batch_idx, (inputs, targets) in enumerate(dataloader):
                # Training step
                loss = self.trainer.train_step(inputs, targets)
                epoch_losses.append(loss)

                # Log progress
                if batch_idx % 10 == 0:
                    perplexity = np.exp(loss)
                    print(f"   Epoch {epoch+1}/{epochs}, Batch {batch_idx}: "
                          f"Loss={loss:.4f}, PPL={perplexity:.2f}")

            # Epoch summary
            avg_loss = np.mean(epoch_losses)
            avg_perplexity = np.exp(avg_loss)

            history['losses'].append(avg_loss)
            history['perplexities'].append(avg_perplexity)
            history['epochs'].append(epoch + 1)

            # Update learning rate
            self.trainer.scheduler.step()

            print(f"✅ Epoch {epoch+1} complete: Loss={avg_loss:.4f}, PPL={avg_perplexity:.2f}")

        self.is_trained = True
        self.training_history = history
        print(f"🎉 Training complete! Final perplexity: {history['perplexities'][-1]:.2f}")

        return history
        ### END SOLUTION

    def optimize_model(self, quantize: bool = True, prune_sparsity: float = 0.0):
        """
        Apply optimization techniques (Modules 17-18).

        TODO: Apply quantization and pruning optimizations

        APPROACH:
        1. Optionally apply quantization to reduce precision
        2. Optionally apply pruning to remove weights
        3. Measure size reduction
        4. Validate model still works

        EXAMPLE:
        >>> pipeline.optimize_model(quantize=True, prune_sparsity=0.5)
        Model optimized: 75% size reduction
        """
        ### BEGIN SOLUTION
        original_params = self.model.count_parameters()
        original_memory = original_params * 4 / (1024 * 1024)

        optimizations_applied = []

        if quantize:
            # Apply quantization (simulated)
            # In real implementation, would use quantize_model()
            quantized_memory = original_memory / 4  # INT8 vs FP32
            optimizations_applied.append(f"INT8 quantization (4× memory reduction)")
            print("   Applied INT8 quantization")

        if prune_sparsity > 0:
            # Apply pruning (simulated)
            # In real implementation, would use magnitude_prune()
            remaining_weights = 1 - prune_sparsity
            optimizations_applied.append(f"{prune_sparsity:.0%} pruning ({remaining_weights:.0%} weights remain)")
            print(f"   Applied {prune_sparsity:.0%} magnitude pruning")

        # Calculate final size
        size_reduction = 1.0
        if quantize:
            size_reduction *= 0.25  # 4× smaller
        if prune_sparsity > 0:
            size_reduction *= (1 - prune_sparsity)

        final_memory = original_memory * size_reduction
        reduction_factor = original_memory / final_memory

        print(f"🔧 Model optimization complete:")
        print(f"   Original: {original_memory:.1f}MB")
        print(f"   Optimized: {final_memory:.1f}MB")
        print(f"   Reduction: {reduction_factor:.1f}× smaller")
        print(f"   Applied: {', '.join(optimizations_applied)}")
        ### END SOLUTION

    def generate_text(self, prompt: str, max_tokens: int = 50) -> str:
        """
        Generate text using the trained model.

        TODO: Implement text generation with proper encoding/decoding

        APPROACH:
        1. Encode prompt to token IDs
        2. Use model.generate() for autoregressive generation
        3. Decode generated tokens back to text
        4. Return generated text

        EXAMPLE:
        >>> text = pipeline.generate_text("Hello", max_tokens=10)
        >>> print(f"Generated: {text}")
        Generated: Hello world this is AI
        """
        ### BEGIN SOLUTION
        if not self.is_trained:
            print("⚠️ Model not trained yet. Generating with random weights.")

        # Encode prompt
        prompt_tokens = self.tokenizer.encode(prompt)
        prompt_tensor = Tensor([prompt_tokens])

        # Generate tokens
        generated_tokens = self.model.generate(
            prompt_tensor,
            max_new_tokens=max_tokens,
            temperature=0.8,
            use_cache=True
        )

        # Decode to text
        all_tokens = generated_tokens.data[0].tolist()
        generated_text = self.tokenizer.decode(all_tokens)

        return generated_text
        ### END SOLUTION

def test_unit_complete_pipeline():
    """🔬 Test complete pipeline integration."""
    print("🔬 Unit Test: Complete Pipeline Integration...")

    # Create pipeline
    pipeline = CompleteTinyGPTPipeline(vocab_size=50, embed_dim=32, num_layers=2)

    # Test data preparation
    corpus = ["hello world", "ai is fun", "machine learning"]
    dataloader = pipeline.prepare_training_data(corpus, batch_size=2)
    assert len(dataloader) > 0, "DataLoader should have batches"

    # Test training (minimal)
    history = pipeline.train(dataloader, epochs=1)
    assert 'losses' in history, "History should contain losses"
    assert len(history['losses']) == 1, "Should have one epoch of losses"

    # Test optimization
    pipeline.optimize_model(quantize=True, prune_sparsity=0.5)

    # Test generation
    generated = pipeline.generate_text("hello", max_tokens=5)
    assert isinstance(generated, str), "Generated output should be string"
    assert len(generated) > 0, "Generated text should not be empty"

    print(f"✅ Pipeline stages completed successfully")
    print(f"✅ Training history: {len(history['losses'])} epochs")
    print(f"✅ Generated text: '{generated[:20]}...'")
    print("✅ Complete pipeline integration works!")

# Run immediate test
test_unit_complete_pipeline()

# %% [markdown]
"""
## 🎯 Module Integration Test

Final comprehensive test validating all components work together correctly.
"""

# %% nbgrader={"grade": true, "grade_id": "test_module", "locked": true, "points": 20}
def test_module():
    """
    Comprehensive test of entire capstone module functionality.

    This final test runs before module summary to ensure:
    - TinyGPT architecture works correctly
    - Training pipeline integrates properly
    - Optimization techniques can be applied
    - Text generation produces output
    - All systems analysis functions execute
    - Complete pipeline demonstrates end-to-end functionality
    """
    print("🧪 RUNNING MODULE INTEGRATION TEST")
    print("=" * 60)

    # Test 1: TinyGPT Architecture
    print("🔬 Testing TinyGPT architecture...")
    test_unit_tinygpt_init()
    test_unit_tinygpt_forward()

    # Test 2: Training Pipeline
    print("\n🔬 Testing training pipeline...")
    test_unit_training_pipeline()

    # Test 3: Complete Pipeline
    print("\n🔬 Testing complete pipeline...")
    test_unit_complete_pipeline()

    # Test 4: Systems Analysis
    print("\n🔬 Testing systems analysis...")

    # Create model for final validation
    print("🔬 Final integration test...")
    model = TinyGPT(vocab_size=100, embed_dim=64, num_layers=2, num_heads=2)

    # Verify core functionality
    assert hasattr(model, 'count_parameters'), "Model should have parameter counting"
    assert hasattr(model, 'forward'), "Model should have forward method"
    assert hasattr(model, 'generate'), "Model should have generation method"

    # Test parameter counting
    param_count = model.count_parameters()
    assert param_count > 0, "Model should have parameters"

    # Test forward pass
    test_input = Tensor([[1, 2, 3, 4, 5]])
    output = model.forward(test_input)
    assert output.shape == (1, 5, 100), f"Expected (1, 5, 100), got {output.shape}"

    # Test generation
    generated = model.generate(test_input, max_new_tokens=3)
    assert generated.shape[1] == 8, f"Expected 8 tokens, got {generated.shape[1]}"

    print("\n" + "=" * 60)
    print("🎉 ALL CAPSTONE TESTS PASSED!")
    print("🚀 TinyGPT system fully functional!")
    print("✅ All 19 modules successfully integrated!")
    print("🎯 Ready for real-world deployment!")
    print("\nRun: tito module complete 20")

# Call the comprehensive test
test_module()

# %% nbgrader={"grade": false, "grade_id": "main_execution", "solution": false}
if __name__ == "__main__":
    print("🚀 Running TinyGPT Capstone module...")

    # Run the comprehensive test
    test_module()

    # Demo the complete system
    print("\n" + "=" * 60)
    print("🎭 CAPSTONE DEMONSTRATION")
    print("=" * 60)

    # Create a demo pipeline
    print("🏗️ Creating demonstration pipeline...")
    demo_pipeline = CompleteTinyGPTPipeline(
        vocab_size=100,
        embed_dim=128,
        num_layers=4,
        num_heads=4
    )

    # Show parameter breakdown
    print(f"\n📊 Model Architecture Summary:")
    print(f"   Parameters: {demo_pipeline.model.count_parameters():,}")
    print(f"   Layers: {demo_pipeline.num_layers}")
    print(f"   Heads: {demo_pipeline.num_heads}")
    print(f"   Embedding dimension: {demo_pipeline.embed_dim}")

    # Demonstrate text generation (with untrained model)
    print(f"\n🎭 Demonstration Generation (untrained model):")
    sample_text = demo_pipeline.generate_text("Hello", max_tokens=10)
    print(f"   Input: 'Hello'")
    print(f"   Output: '{sample_text}'")
    print(f"   Note: Random output expected (model not trained)")

    print("\n✅ Capstone demonstration complete!")
    print("🎯 TinyGPT represents the culmination of 19 modules of ML systems learning!")

# %% [markdown]
"""
## 🤔 ML Systems Thinking: Capstone Reflection

This capstone integrates everything you've learned across 19 modules. Let's reflect on the complete systems picture.

### Question 1: Architecture Scaling
You built TinyGPT with configurable architecture (embed_dim, num_layers, num_heads).
If you double the embed_dim from 128 to 256, approximately how much does memory usage increase?

**Answer:** _______ (2×, 4×, 8×, or 16×)

**Reasoning:** Consider that embed_dim affects embedding tables, all linear layers in attention, and MLP layers.

### Question 2: Training vs Inference Memory
Your TinyGPT uses different memory patterns for training vs inference.
For a model with 50M parameters, what's the approximate memory usage difference?

**Training Memory:** _______ MB
**Inference Memory:** _______ MB
**Ratio:** _______ × larger for training

**Hint:** Training requires parameters + gradients + optimizer states (Adam has 2 momentum terms).

### Question 3: Optimization Trade-offs
You implemented quantization (INT8) and pruning (90% sparsity) optimizations.
For the original 200MB model, what's the memory footprint after both optimizations?

**Original:** 200MB
**After INT8 + 90% pruning:** _______ MB
**Total reduction factor:** _______ ×

### Question 4: Generation Complexity
Your generate() method can use KV caching for efficiency.
For generating 100 tokens with sequence length 500, how many forward passes are needed?

**Without KV cache:** _______ forward passes
**With KV cache:** _______ forward passes
**Speedup factor:** _______ ×

### Question 5: Systems Integration
You integrated 19 different modules into a cohesive system.
Which integration challenge was most critical for making TinyGPT work?

a) Making all imports work correctly
b) Ensuring tensor shapes flow correctly through all components
c) Managing memory during training
d) Coordinating the generation loop with KV caching

**Answer:** _______

**Explanation:** ________________________________
"""

# %% [markdown]
"""
## 🎯 MODULE SUMMARY: Capstone - Complete TinyGPT System

Congratulations! You've completed the ultimate integration project - building TinyGPT from your own ML framework!

### Key Accomplishments
- **Integrated 19 modules** into a cohesive, production-ready system
- **Built complete TinyGPT** with training, optimization, and generation capabilities
- **Demonstrated systems thinking** with memory analysis, performance profiling, and optimization
- **Created end-to-end pipeline** from raw text to trained model to generated output
- **Applied advanced optimizations** including quantization and pruning
- **Validated the complete framework** through comprehensive testing
- All tests pass ✅ (validated by `test_module()`)

### Systems Insights Gained
- **Architecture scaling**: How model size affects memory and compute requirements
- **Training dynamics**: Memory patterns, convergence monitoring, and optimization
- **Production optimization**: Quantization and pruning for deployment efficiency
- **Integration complexity**: How modular design enables complex system composition

### The Complete Journey
```
Module 01: Tensor Operations
    ↓
Modules 02-04: Neural Network Basics
    ↓
Modules 05-07: Training Infrastructure
    ↓
Modules 08-09: Data and Spatial Processing
    ↓
Modules 10-14: Language Models and Transformers
    ↓
Modules 15-19: Systems Optimization
    ↓
Module 20: COMPLETE TINYGPT SYSTEM! 🎉
```

### Ready for the Real World
Your TinyGPT implementation demonstrates:
- **Production-quality code** with proper error handling and optimization
- **Systems engineering mindset** with performance analysis and memory management
- **ML framework design** understanding how PyTorch-like systems work internally
- **End-to-end ML pipeline** from data to deployment

**Export with:** `tito module complete 20`

**Achievement Unlocked:** 🏆 **ML Systems Engineer** - You've built a complete AI system from scratch!

You now understand how modern AI systems work from the ground up. From tensors to text generation, from training loops to production optimization - you've mastered the full stack of ML systems engineering.

**What's Next:** Take your TinyTorch framework and build even more ambitious projects! The foundations you've built can support any ML architecture you can imagine.
"""