mirror of
https://github.com/MLSysBook/TinyTorch.git
synced 2026-07-20 07:58:34 -05:00
- Remove circular imports where modules imported from themselves - Convert tinytorch.core imports to sys.path relative imports - Only import dependencies that are actually used in each module - Preserve documentation imports in markdown cells - Use consistent relative path pattern across all modules - Remove hardcoded absolute paths in favor of relative imports Affected modules: 02_activations, 03_layers, 04_losses, 06_optimizers, 07_training, 09_spatial, 12_attention, 17_quantization
43 KiB
43 KiB
In [ ]:
#| default_exp core.layers
import numpy as np
import sys
import os
# Import the proper Tensor class from Module 01
sys.path.append(os.path.join(os.path.dirname(__file__), '..', '01_tensor'))
from tensor_dev import TensorIn [ ]:
class Linear:
"""
Linear (fully connected) layer: y = xW + b
This is the fundamental building block of neural networks.
Applies a linear transformation to incoming data.
"""
def __init__(self, in_features, out_features, bias=True):
"""
Initialize linear layer with proper weight initialization.
TODO: Initialize weights and bias with Xavier initialization
APPROACH:
1. Create weight matrix (in_features, out_features) with Xavier scaling
2. Create bias vector (out_features,) initialized to zeros if bias=True
3. Set requires_grad=True for parameters (ready for Module 05)
EXAMPLE:
>>> layer = Linear(784, 10) # MNIST classifier final layer
>>> print(layer.weight.shape)
(784, 10)
>>> print(layer.bias.shape)
(10,)
HINTS:
- Xavier init: scale = sqrt(1/in_features)
- Use np.random.randn() for normal distribution
- bias=None when bias=False
"""
### BEGIN SOLUTION
self.in_features = in_features
self.out_features = out_features
# Xavier/Glorot initialization for stable gradients
scale = np.sqrt(1.0 / in_features)
weight_data = np.random.randn(in_features, out_features) * scale
self.weight = Tensor(weight_data, requires_grad=True)
# Initialize bias to zeros or None
if bias:
bias_data = np.zeros(out_features)
self.bias = Tensor(bias_data, requires_grad=True)
else:
self.bias = None
### END SOLUTION
def forward(self, x):
"""
Forward pass through linear layer.
TODO: Implement y = xW + b
APPROACH:
1. Matrix multiply input with weights: xW
2. Add bias if it exists
3. Return result as new Tensor
EXAMPLE:
>>> layer = Linear(3, 2)
>>> x = Tensor([[1, 2, 3], [4, 5, 6]]) # 2 samples, 3 features
>>> y = layer.forward(x)
>>> print(y.shape)
(2, 2) # 2 samples, 2 outputs
HINTS:
- Use tensor.matmul() for matrix multiplication
- Handle bias=None case
- Broadcasting automatically handles bias addition
"""
### BEGIN SOLUTION
# Linear transformation: y = xW
output = x.matmul(self.weight)
# Add bias if present
if self.bias is not None:
output = output + self.bias
return output
### END SOLUTION
def parameters(self):
"""
Return list of trainable parameters.
TODO: Return all tensors that need gradients
APPROACH:
1. Start with weight (always present)
2. Add bias if it exists
3. Return as list for optimizer
"""
### BEGIN SOLUTION
params = [self.weight]
if self.bias is not None:
params.append(self.bias)
return params
### END SOLUTION
def __repr__(self):
"""String representation for debugging."""
bias_str = f", bias={self.bias is not None}"
return f"Linear(in_features={self.in_features}, out_features={self.out_features}{bias_str})"In [ ]:
def test_unit_linear_layer():
"""🔬 Test Linear layer implementation."""
print("🔬 Unit Test: Linear Layer...")
# Test layer creation
layer = Linear(784, 256)
assert layer.in_features == 784
assert layer.out_features == 256
assert layer.weight.shape == (784, 256)
assert layer.bias.shape == (256,)
assert layer.weight.requires_grad == True
assert layer.bias.requires_grad == True
# Test Xavier initialization (weights should be reasonably scaled)
weight_std = np.std(layer.weight.data)
expected_std = np.sqrt(1.0 / 784)
assert 0.5 * expected_std < weight_std < 2.0 * expected_std, f"Weight std {weight_std} not close to Xavier {expected_std}"
# Test bias initialization (should be zeros)
assert np.allclose(layer.bias.data, 0), "Bias should be initialized to zeros"
# Test forward pass
x = Tensor(np.random.randn(32, 784)) # Batch of 32 samples
y = layer.forward(x)
assert y.shape == (32, 256), f"Expected shape (32, 256), got {y.shape}"
# Test no bias option
layer_no_bias = Linear(10, 5, bias=False)
assert layer_no_bias.bias is None
params = layer_no_bias.parameters()
assert len(params) == 1 # Only weight, no bias
# Test parameters method
params = layer.parameters()
assert len(params) == 2 # Weight and bias
assert params[0] is layer.weight
assert params[1] is layer.bias
print("✅ Linear layer works correctly!")
if __name__ == "__main__":
test_unit_linear_layer()
In [ ]:
class Dropout:
"""
Dropout layer for regularization.
During training: randomly zeros elements with probability p
During inference: scales outputs by (1-p) to maintain expected value
This prevents overfitting by forcing the network to not rely on specific neurons.
"""
def __init__(self, p=0.5):
"""
Initialize dropout layer.
TODO: Store dropout probability
Args:
p: Probability of zeroing each element (0.0 = no dropout, 1.0 = zero everything)
EXAMPLE:
>>> dropout = Dropout(0.5) # Zero 50% of elements during training
"""
### BEGIN SOLUTION
if not 0.0 <= p <= 1.0:
raise ValueError(f"Dropout probability must be between 0 and 1, got {p}")
self.p = p
### END SOLUTION
def forward(self, x, training=True):
"""
Forward pass through dropout layer.
TODO: Apply dropout during training, pass through during inference
APPROACH:
1. If not training, return input unchanged
2. If training, create random mask with probability (1-p)
3. Multiply input by mask and scale by 1/(1-p)
4. Return result as new Tensor
EXAMPLE:
>>> dropout = Dropout(0.5)
>>> x = Tensor([1, 2, 3, 4])
>>> y_train = dropout.forward(x, training=True) # Some elements zeroed
>>> y_eval = dropout.forward(x, training=False) # All elements preserved
HINTS:
- Use np.random.random() < keep_prob for mask
- Scale by 1/(1-p) to maintain expected value
- training=False should return input unchanged
"""
### BEGIN SOLUTION
if not training or self.p == 0.0:
# During inference or no dropout, pass through unchanged
return x
if self.p == 1.0:
# Drop everything
return Tensor(np.zeros_like(x.data))
# During training, apply dropout
keep_prob = 1.0 - self.p
# Create random mask: True where we keep elements
mask = np.random.random(x.data.shape) < keep_prob
# Apply mask and scale to maintain expected value
output_data = (x.data * mask) / keep_prob
return Tensor(output_data)
### END SOLUTION
def parameters(self):
"""Dropout has no parameters."""
return []
def __repr__(self):
return f"Dropout(p={self.p})"In [ ]:
def test_unit_dropout_layer():
"""🔬 Test Dropout layer implementation."""
print("🔬 Unit Test: Dropout Layer...")
# Test dropout creation
dropout = Dropout(0.5)
assert dropout.p == 0.5
# Test inference mode (should pass through unchanged)
x = Tensor([1, 2, 3, 4])
y_inference = dropout.forward(x, training=False)
assert np.array_equal(x.data, y_inference.data), "Inference should pass through unchanged"
# Test training mode with zero dropout (should pass through unchanged)
dropout_zero = Dropout(0.0)
y_zero = dropout_zero.forward(x, training=True)
assert np.array_equal(x.data, y_zero.data), "Zero dropout should pass through unchanged"
# Test training mode with full dropout (should zero everything)
dropout_full = Dropout(1.0)
y_full = dropout_full.forward(x, training=True)
assert np.allclose(y_full.data, 0), "Full dropout should zero everything"
# Test training mode with partial dropout
# Note: This is probabilistic, so we test statistical properties
np.random.seed(42) # For reproducible test
x_large = Tensor(np.ones((1000,))) # Large tensor for statistical significance
y_train = dropout.forward(x_large, training=True)
# Count non-zero elements (approximately 50% should survive)
non_zero_count = np.count_nonzero(y_train.data)
expected_survival = 1000 * 0.5
# Allow 10% tolerance for randomness
assert 0.4 * 1000 < non_zero_count < 0.6 * 1000, f"Expected ~500 survivors, got {non_zero_count}"
# Test scaling (surviving elements should be scaled by 1/(1-p) = 2.0)
surviving_values = y_train.data[y_train.data != 0]
expected_value = 2.0 # 1.0 / (1 - 0.5)
assert np.allclose(surviving_values, expected_value), f"Surviving values should be {expected_value}"
# Test no parameters
params = dropout.parameters()
assert len(params) == 0, "Dropout should have no parameters"
# Test invalid probability
try:
Dropout(-0.1)
assert False, "Should raise ValueError for negative probability"
except ValueError:
pass
try:
Dropout(1.1)
assert False, "Should raise ValueError for probability > 1"
except ValueError:
pass
print("✅ Dropout layer works correctly!")
if __name__ == "__main__":
test_unit_dropout_layer()In [ ]:
def analyze_layer_memory():
"""📊 Analyze memory usage patterns in layer operations."""
print("📊 Analyzing Layer Memory Usage...")
# Test different layer sizes
layer_configs = [
(784, 256), # MNIST → hidden
(256, 256), # Hidden → hidden
(256, 10), # Hidden → output
(2048, 2048), # Large hidden
]
print("\nLinear Layer Memory Analysis:")
print("Configuration → Weight Memory → Bias Memory → Total Memory")
for in_feat, out_feat in layer_configs:
# Calculate memory usage
weight_memory = in_feat * out_feat * 4 # 4 bytes per float32
bias_memory = out_feat * 4
total_memory = weight_memory + bias_memory
print(f"({in_feat:4d}, {out_feat:4d}) → {weight_memory/1024:7.1f} KB → {bias_memory/1024:6.1f} KB → {total_memory/1024:7.1f} KB")
# Analyze multi-layer memory scaling
print("\n💡 Multi-layer Model Memory Scaling:")
hidden_sizes = [128, 256, 512, 1024, 2048]
for hidden_size in hidden_sizes:
# 3-layer MLP: 784 → hidden → hidden/2 → 10
layer1_params = 784 * hidden_size + hidden_size
layer2_params = hidden_size * (hidden_size // 2) + (hidden_size // 2)
layer3_params = (hidden_size // 2) * 10 + 10
total_params = layer1_params + layer2_params + layer3_params
memory_mb = total_params * 4 / (1024 * 1024)
print(f"Hidden={hidden_size:4d}: {total_params:7,} params = {memory_mb:5.1f} MB")
# Analysis will be run in main blockIn [ ]:
def analyze_layer_performance():
"""📊 Analyze computational complexity of layer operations."""
print("📊 Analyzing Layer Computational Complexity...")
# Test forward pass FLOPs
batch_sizes = [1, 32, 128, 512]
layer = Linear(784, 256)
print("\nLinear Layer FLOPs Analysis:")
print("Batch Size → Matrix Multiply FLOPs → Bias Add FLOPs → Total FLOPs")
for batch_size in batch_sizes:
# Matrix multiplication: (batch, in) @ (in, out) = batch * in * out FLOPs
matmul_flops = batch_size * 784 * 256
# Bias addition: batch * out FLOPs
bias_flops = batch_size * 256
total_flops = matmul_flops + bias_flops
print(f"{batch_size:10d} → {matmul_flops:15,} → {bias_flops:13,} → {total_flops:11,}")
print("\n💡 Key Insights:")
print("🚀 Linear layer complexity: O(batch_size × in_features × out_features)")
print("🚀 Memory grows linearly with batch size, quadratically with layer width")
print("🚀 Dropout adds minimal computational overhead (element-wise operations)")
# Analysis will be run in main blockIn [ ]:
def test_module():
"""
Comprehensive test of entire module functionality.
This final test runs before module summary to ensure:
- All unit tests pass
- Functions work together correctly
- Module is ready for integration with TinyTorch
"""
print("🧪 RUNNING MODULE INTEGRATION TEST")
print("=" * 50)
# Run all unit tests
print("Running unit tests...")
test_unit_linear_layer()
test_unit_dropout_layer()
print("\nRunning integration scenarios...")
# Test realistic neural network construction with manual composition
print("🔬 Integration Test: Multi-layer Network...")
# Import real activation from module 02
sys.path.append(os.path.join(os.path.dirname(__file__), '..', '02_activations'))
from activations_dev import ReLU
# Build individual layers for manual composition
layer1 = Linear(784, 128)
activation1 = ReLU()
dropout1 = Dropout(0.5)
layer2 = Linear(128, 64)
activation2 = ReLU()
dropout2 = Dropout(0.3)
layer3 = Linear(64, 10)
# Test end-to-end forward pass with manual composition
batch_size = 16
x = Tensor(np.random.randn(batch_size, 784))
# Manual forward pass
x = layer1.forward(x)
x = activation1.forward(x)
x = dropout1.forward(x)
x = layer2.forward(x)
x = activation2.forward(x)
x = dropout2.forward(x)
output = layer3.forward(x)
assert output.shape == (batch_size, 10), f"Expected output shape ({batch_size}, 10), got {output.shape}"
# Test parameter counting from individual layers
all_params = layer1.parameters() + layer2.parameters() + layer3.parameters()
expected_params = 6 # 3 weights + 3 biases from 3 Linear layers
assert len(all_params) == expected_params, f"Expected {expected_params} parameters, got {len(all_params)}"
# Test all parameters have requires_grad=True
for param in all_params:
assert param.requires_grad == True, "All parameters should have requires_grad=True"
# Test individual layer functionality
test_x = Tensor(np.random.randn(4, 784))
# Test dropout in training vs inference
dropout_test = Dropout(0.5)
train_output = dropout_test.forward(test_x, training=True)
infer_output = dropout_test.forward(test_x, training=False)
assert np.array_equal(test_x.data, infer_output.data), "Inference mode should pass through unchanged"
print("✅ Multi-layer network integration works!")
print("\n" + "=" * 50)
print("🎉 ALL TESTS PASSED! Module ready for export.")
print("Run: tito module complete 03_layers")
# Run comprehensive module test
if __name__ == "__main__":
test_module()