Table of Contents
PyTorch Basics
Setup & Installation
# Installation
# CPU only
pip install torch torchvision torchaudio
# CUDA 11.8
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
# CUDA 12.1
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
# macOS (MPS backend)
pip install torch torchvision torchaudio
# Verify installation
import torch
print(f"PyTorch version: {torch.__version__}")
print(f"CUDA available: {torch.cuda.is_available()}")
print(f"CUDA version: {torch.version.cuda}")
print(f"Device count: {torch.cuda.device_count()}")
print(f"Current device: {torch.cuda.current_device()}")
print(f"Device name: {torch.cuda.get_device_name(0)}")
# Check MPS (Apple Silicon)
print(f"MPS available: {torch.backends.mps.is_available()}")
# Set device
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"Using device: {device}")
# Or with MPS support
if torch.cuda.is_available():
device = torch.device('cuda')
elif torch.backends.mps.is_available():
device = torch.device('mps')
else:
device = torch.device('cpu')
# Basic imports
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import DataLoader, Dataset
import torchvision
import torchvision.transforms as transforms
import numpy as np
import matplotlib.pyplot as plt
# Set random seed for reproducibility
def set_seed(seed=42):
torch.manual_seed(seed)
torch.cuda.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
np.random.seed(seed)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
set_seed(42)
# Memory management
# Clear GPU cache
torch.cuda.empty_cache()
# Get memory stats
print(f"Allocated: {torch.cuda.memory_allocated() / 1e9:.2f} GB")
print(f"Reserved: {torch.cuda.memory_reserved() / 1e9:.2f} GB")
# Set memory growth (like TensorFlow)
torch.cuda.set_per_process_memory_fraction(0.8, 0)
PyTorch Workflow
┌─────────────────────────────────────────────────────────┐
│ PyTorch Deep Learning Workflow │
└─────────────────────────────────────────────────────────┘
1. Data Preparation
↓
┌─────────────────────────────────────────────┐
│ • Load data │
│ • Create Dataset & DataLoader │
│ • Apply transforms │
│ • Split train/val/test │
└─────────────────────────────────────────────┘
↓
2. Model Definition
↓
┌─────────────────────────────────────────────┐
│ • Define nn.Module │
│ • Implement __init__ and forward() │
│ • Move model to device │
└─────────────────────────────────────────────┘
↓
3. Loss & Optimizer
↓
┌─────────────────────────────────────────────┐
│ • Choose loss function │
│ • Choose optimizer (Adam, SGD, etc.) │
│ • Set learning rate & hyperparameters │
└─────────────────────────────────────────────┘
↓
4. Training Loop
↓
┌─────────────────────────────────────────────┐
│ For each epoch: │
│ For each batch: │
│ 1. Forward pass │
│ 2. Compute loss │
│ 3. Backward pass (compute gradients) │
│ 4. Optimizer step (update weights) │
│ 5. Zero gradients │
└─────────────────────────────────────────────┘
↓
5. Validation
↓
┌─────────────────────────────────────────────┐
│ • Set model to eval mode │
│ • Disable gradient computation │
│ • Compute validation metrics │
└─────────────────────────────────────────────┘
↓
6. Testing & Deployment
↓
┌─────────────────────────────────────────────┐
│ • Evaluate on test set │
│ • Save model │
│ • Export for deployment │
└─────────────────────────────────────────────┘
Complete Example:
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
# 1. Data
X_train = torch.randn(1000, 10)
y_train = torch.randint(0, 2, (1000,))
train_dataset = TensorDataset(X_train, y_train)
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
# 2. Model
class SimpleNN(nn.Module):
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(10, 64)
self.fc2 = nn.Linear(64, 32)
self.fc3 = nn.Linear(32, 2)
def forward(self, x):
x = F.relu(self.fc1(x))
x = F.relu(self.fc2(x))
x = self.fc3(x)
return x
model = SimpleNN().to(device)
# 3. Loss & Optimizer
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
# 4. Training
num_epochs = 10
for epoch in range(num_epochs):
model.train()
for batch_X, batch_y in train_loader:
batch_X, batch_y = batch_X.to(device), batch_y.to(device)
# Forward
outputs = model(batch_X)
loss = criterion(outputs, batch_y)
# Backward
optimizer.zero_grad()
loss.backward()
optimizer.step()
print(f"Epoch {epoch+1}/{num_epochs}, Loss: {loss.item():.4f}")
# 5. Save model
torch.save(model.state_dict(), 'model.pth')
Tensor Operations
Tensor Creation & Manipulation
# Create tensors
# From data
x = torch.tensor([1, 2, 3, 4, 5])
x = torch.tensor([[1, 2], [3, 4]])
# From numpy
import numpy as np
arr = np.array([1, 2, 3])
x = torch.from_numpy(arr)
# Back to numpy
arr = x.numpy()
# Zeros and ones
x = torch.zeros(3, 4)
x = torch.ones(2, 3)
x = torch.zeros_like(existing_tensor)
x = torch.ones_like(existing_tensor)
# Random tensors
x = torch.rand(3, 4) # Uniform [0, 1)
x = torch.randn(3, 4) # Normal distribution
x = torch.randint(0, 10, (3, 4)) # Random integers
# Range
x = torch.arange(0, 10, 2) # [0, 2, 4, 6, 8]
x = torch.linspace(0, 10, 5) # [0, 2.5, 5, 7.5, 10]
# Identity matrix
x = torch.eye(3)
# Tensor properties
x = torch.randn(3, 4)
print(x.shape) # torch.Size([3, 4])
print(x.size()) # torch.Size([3, 4])
print(x.dtype) # torch.float32
print(x.device) # cpu or cuda
print(x.requires_grad) # False
print(x.ndim) # 2
# Change device
x = x.to('cuda')
x = x.to(device)
x = x.cpu()
x = x.cuda()
# Change dtype
x = x.float() # float32
x = x.double() # float64
x = x.int() # int32
x = x.long() # int64
x = x.to(torch.float16)
# Reshaping
x = torch.randn(3, 4)
# Reshape
x = x.view(2, 6)
x = x.reshape(2, 6) # More flexible
# Flatten
x = x.view(-1) # 1D tensor
x = x.flatten()
# Squeeze/unsqueeze (remove/add dimension)
x = torch.randn(1, 3, 1, 4)
x = x.squeeze() # Shape: [3, 4]
x = x.unsqueeze(0) # Shape: [1, 3, 4]
x = x.unsqueeze(-1) # Shape: [1, 3, 4, 1]
# Transpose
x = torch.randn(3, 4)
x = x.t() # 2D transpose
x = x.transpose(0, 1) # Swap dimensions
x = x.permute(1, 0) # Rearrange dimensions
# Indexing & slicing
x = torch.randn(3, 4)
# Basic indexing
x[0] # First row
x[:, 0] # First column
x[0, 1] # Element at (0, 1)
x[1:3] # Rows 1 and 2
# Advanced indexing
indices = torch.tensor([0, 2])
x[indices] # Select rows 0 and 2
# Boolean indexing
mask = x > 0
x[mask] # Elements > 0
# Concatenation
x1 = torch.randn(2, 3)
x2 = torch.randn(2, 3)
# Concatenate along dimension
x = torch.cat([x1, x2], dim=0) # Shape: [4, 3]
x = torch.cat([x1, x2], dim=1) # Shape: [2, 6]
# Stack (adds new dimension)
x = torch.stack([x1, x2], dim=0) # Shape: [2, 2, 3]
# Splitting
x = torch.randn(6, 4)
# Split into chunks
chunks = torch.chunk(x, 3, dim=0) # 3 chunks
# Split with sizes
chunks = torch.split(x, [2, 2, 2], dim=0)
# Mathematical operations
x = torch.randn(3, 4)
y = torch.randn(3, 4)
# Element-wise
z = x + y
z = x - y
z = x * y
z = x / y
z = x ** 2
# In-place operations (end with _)
x.add_(y)
x.mul_(2)
# Matrix multiplication
a = torch.randn(3, 4)
b = torch.randn(4, 5)
c = torch.mm(a, b) # [3, 5]
c = a @ b # Same as mm
# Batch matrix multiplication
a = torch.randn(10, 3, 4)
b = torch.randn(10, 4, 5)
c = torch.bmm(a, b) # [10, 3, 5]
# Dot product
a = torch.tensor([1., 2., 3.])
b = torch.tensor([4., 5., 6.])
result = torch.dot(a, b)
# Reduction operations
x = torch.randn(3, 4)
# Sum
total = x.sum()
col_sum = x.sum(dim=0) # Sum columns
row_sum = x.sum(dim=1) # Sum rows
# Mean
mean = x.mean()
col_mean = x.mean(dim=0)
# Max/Min
max_val = x.max()
max_val, max_idx = x.max(dim=0)
min_val = x.min()
# Argmax/Argmin
idx = x.argmax()
idx = x.argmax(dim=0)
# Standard deviation
std = x.std()
# Comparison operations
x = torch.randn(3, 4)
y = torch.randn(3, 4)
# Element-wise comparison
result = x > y
result = x == y
result = torch.eq(x, y)
# All/Any
all_positive = (x > 0).all()
any_positive = (x > 0).any()
# Advanced operations
# Clamp (clip values)
x = torch.randn(3, 4)
x = x.clamp(min=-1, max=1)
# Where (conditional)
x = torch.randn(3, 4)
y = torch.zeros_like(x)
result = torch.where(x > 0, x, y) # Keep positive, zero negative
# Gather
x = torch.randn(3, 4)
indices = torch.tensor([[0, 1], [2, 3]])
result = torch.gather(x, 1, indices)
Autograd & Gradients
Automatic Differentiation
# Enable gradient tracking
x = torch.tensor([2.0], requires_grad=True)
y = x ** 2
# Compute gradients
y.backward()
# Access gradients
print(x.grad) # dy/dx = 2x = 4
# Multi-variable gradients
x = torch.tensor([2.0], requires_grad=True)
y = torch.tensor([3.0], requires_grad=True)
z = x ** 2 + y ** 3
z.backward()
print(x.grad) # dz/dx = 2x = 4
print(y.grad) # dz/dy = 3y² = 27
# Gradient accumulation
x = torch.tensor([2.0], requires_grad=True)
for i in range(3):
y = x ** 2
y.backward()
print(x.grad) # Accumulates: 4, 8, 12
# Zero gradients
x.grad.zero_()
# No gradient context
x = torch.randn(3, 4, requires_grad=True)
# Temporarily disable gradient
with torch.no_grad():
y = x ** 2 # No gradient tracking
# Evaluation mode (no gradient)
model.eval()
with torch.no_grad():
predictions = model(inputs)
# Detach from computation graph
x = torch.randn(3, 4, requires_grad=True)
y = x ** 2
# Detach (stop gradient flow)
y_detached = y.detach()
# Gradient for non-scalar outputs
x = torch.randn(3, requires_grad=True)
y = x ** 2
# Need gradient argument
gradient = torch.ones_like(y)
y.backward(gradient)
# Custom backward pass
class MyFunction(torch.autograd.Function):
@staticmethod
def forward(ctx, x):
ctx.save_for_backward(x)
return x ** 2
@staticmethod
def backward(ctx, grad_output):
x, = ctx.saved_tensors
return grad_output * 2 * x
# Use custom function
x = torch.tensor([2.0], requires_grad=True)
y = MyFunction.apply(x)
y.backward()
print(x.grad) # 4
# Gradient clipping
# Clip by value
torch.nn.utils.clip_grad_value_(model.parameters(), clip_value=1.0)
# Clip by norm
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
# Higher-order gradients
x = torch.tensor([2.0], requires_grad=True)
y = x ** 3
# First derivative
grad_y = torch.autograd.grad(y, x, create_graph=True)[0]
print(grad_y) # 3x² = 12
# Second derivative
grad2_y = torch.autograd.grad(grad_y, x)[0]
print(grad2_y) # 6x = 12
# Jacobian
def f(x):
return torch.stack([x[0]**2, x[1]**3, x[0]*x[1]])
x = torch.tensor([2.0, 3.0], requires_grad=True)
y = f(x)
jacobian = torch.autograd.functional.jacobian(f, x)
print(jacobian)
# Hessian
def f(x):
return (x ** 2).sum()
x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
hessian = torch.autograd.functional.hessian(f, x)
print(hessian)
Neural Networks
Building Neural Networks
# Basic neural network
import torch.nn as nn
import torch.nn.functional as F
class SimpleNet(nn.Module):
def __init__(self, input_size, hidden_size, output_size):
super(SimpleNet, self).__init__()
self.fc1 = nn.Linear(input_size, hidden_size)
self.fc2 = nn.Linear(hidden_size, hidden_size)
self.fc3 = nn.Linear(hidden_size, output_size)
def forward(self, x):
x = F.relu(self.fc1(x))
x = F.relu(self.fc2(x))
x = self.fc3(x)
return x
# Create model
model = SimpleNet(784, 128, 10)
print(model)
# Move to device
model = model.to(device)
# Count parameters
total_params = sum(p.numel() for p in model.parameters())
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Total parameters: {total_params}")
# Sequential model
model = nn.Sequential(
nn.Linear(784, 256),
nn.ReLU(),
nn.Dropout(0.2),
nn.Linear(256, 128),
nn.ReLU(),
nn.Dropout(0.2),
nn.Linear(128, 10)
)
# ModuleList & ModuleDict
class FlexibleNet(nn.Module):
def __init__(self, layer_sizes):
super().__init__()
# ModuleList for dynamic layers
self.layers = nn.ModuleList([
nn.Linear(layer_sizes[i], layer_sizes[i+1])
for i in range(len(layer_sizes) - 1)
])
# ModuleDict for named layers
self.branches = nn.ModuleDict({
'branch1': nn.Linear(10, 20),
'branch2': nn.Linear(10, 30)
})
def forward(self, x):
for layer in self.layers:
x = F.relu(layer(x))
return x
# Common layers
# Linear (fully connected)
fc = nn.Linear(in_features=100, out_features=50, bias=True)
# Dropout
dropout = nn.Dropout(p=0.5)
# Batch normalization
bn = nn.BatchNorm1d(num_features=100)
bn2d = nn.BatchNorm2d(num_features=64)
# Layer normalization
ln = nn.LayerNorm(normalized_shape=100)
# Embedding
embedding = nn.Embedding(num_embeddings=10000, embedding_dim=300)
# Activation functions
# ReLU
x = F.relu(x)
relu = nn.ReLU()
# LeakyReLU
x = F.leaky_relu(x, negative_slope=0.01)
# GELU
x = F.gelu(x)
# Sigmoid
x = torch.sigmoid(x)
# Tanh
x = torch.tanh(x)
# Softmax
x = F.softmax(x, dim=1)
# LogSoftmax (more numerically stable)
x = F.log_softmax(x, dim=1)
# Custom layer
class CustomLayer(nn.Module):
def __init__(self, in_features, out_features):
super().__init__()
self.weight = nn.Parameter(torch.randn(out_features, in_features))
self.bias = nn.Parameter(torch.zeros(out_features))
def forward(self, x):
return F.linear(x, self.weight, self.bias)
# Residual connections
class ResidualBlock(nn.Module):
def __init__(self, channels):
super().__init__()
self.conv1 = nn.Conv2d(channels, channels, 3, padding=1)
self.bn1 = nn.BatchNorm2d(channels)
self.conv2 = nn.Conv2d(channels, channels, 3, padding=1)
self.bn2 = nn.BatchNorm2d(channels)
def forward(self, x):
residual = x
out = F.relu(self.bn1(self.conv1(x)))
out = self.bn2(self.conv2(out))
out += residual # Skip connection
out = F.relu(out)
return out
# Multi-input/output network
class MultiIONet(nn.Module):
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(10, 64)
self.fc2 = nn.Linear(20, 64)
self.fc_out1 = nn.Linear(128, 5)
self.fc_out2 = nn.Linear(128, 3)
def forward(self, x1, x2):
x1 = F.relu(self.fc1(x1))
x2 = F.relu(self.fc2(x2))
combined = torch.cat([x1, x2], dim=1)
out1 = self.fc_out1(combined)
out2 = self.fc_out2(combined)
return out1, out2
# Initialize weights
def init_weights(m):
if isinstance(m, nn.Linear):
nn.init.xavier_uniform_(m.weight)
nn.init.zeros_(m.bias)
elif isinstance(m, nn.Conv2d):
nn.init.kaiming_normal_(m.weight, mode='fan_out')
model.apply(init_weights)
# Freeze/unfreeze layers
# Freeze all parameters
for param in model.parameters():
param.requires_grad = False
# Unfreeze specific layer
for param in model.fc3.parameters():
param.requires_grad = True
# Check which parameters are trainable
for name, param in model.named_parameters():
print(f"{name}: {param.requires_grad}")
Training Loops
Complete Training Pipeline
# Basic training loop
def train_epoch(model, dataloader, criterion, optimizer, device):
model.train()
running_loss = 0.0
correct = 0
total = 0
for inputs, labels in dataloader:
inputs, labels = inputs.to(device), labels.to(device)
# Zero gradients
optimizer.zero_grad()
# Forward pass
outputs = model(inputs)
loss = criterion(outputs, labels)
# Backward pass
loss.backward()
# Update weights
optimizer.step()
# Statistics
running_loss += loss.item() * inputs.size(0)
_, predicted = outputs.max(1)
total += labels.size(0)
correct += predicted.eq(labels).sum().item()
epoch_loss = running_loss / total
epoch_acc = 100. * correct / total
return epoch_loss, epoch_acc
# Validation loop
def validate(model, dataloader, criterion, device):
model.eval()
running_loss = 0.0
correct = 0
total = 0
with torch.no_grad():
for inputs, labels in dataloader:
inputs, labels = inputs.to(device), labels.to(device)
outputs = model(inputs)
loss = criterion(outputs, labels)
running_loss += loss.item() * inputs.size(0)
_, predicted = outputs.max(1)
total += labels.size(0)
correct += predicted.eq(labels).sum().item()
val_loss = running_loss / total
val_acc = 100. * correct / total
return val_loss, val_acc
# Complete training function
def train_model(model, train_loader, val_loader, criterion, optimizer,
num_epochs, device, scheduler=None):
best_val_acc = 0.0
history = {'train_loss': [], 'train_acc': [],
'val_loss': [], 'val_acc': []}
for epoch in range(num_epochs):
print(f'Epoch {epoch+1}/{num_epochs}')
print('-' * 60)
# Train
train_loss, train_acc = train_epoch(
model, train_loader, criterion, optimizer, device
)
# Validate
val_loss, val_acc = validate(
model, val_loader, criterion, device
)
# Update learning rate
if scheduler:
scheduler.step(val_loss)
# Save history
history['train_loss'].append(train_loss)
history['train_acc'].append(train_acc)
history['val_loss'].append(val_loss)
history['val_acc'].append(val_acc)
print(f'Train Loss: {train_loss:.4f} Acc: {train_acc:.2f}%')
print(f'Val Loss: {val_loss:.4f} Acc: {val_acc:.2f}%')
# Save best model
if val_acc > best_val_acc:
best_val_acc = val_acc
torch.save({
'epoch': epoch,
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'val_acc': val_acc,
}, 'best_model.pth')
print(f'Saved best model with val_acc: {val_acc:.2f}%')
print()
return history
# Training with mixed precision
from torch.cuda.amp import autocast, GradScaler
scaler = GradScaler()
def train_with_amp(model, dataloader, criterion, optimizer, device):
model.train()
running_loss = 0.0
for inputs, labels in dataloader:
inputs, labels = inputs.to(device), labels.to(device)
optimizer.zero_grad()
# Mixed precision forward pass
with autocast():
outputs = model(inputs)
loss = criterion(outputs, labels)
# Scaled backward pass
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
running_loss += loss.item()
return running_loss / len(dataloader)
# Gradient accumulation
accumulation_steps = 4
optimizer.zero_grad()
for i, (inputs, labels) in enumerate(dataloader):
inputs, labels = inputs.to(device), labels.to(device)
outputs = model(inputs)
loss = criterion(outputs, labels)
# Scale loss
loss = loss / accumulation_steps
loss.backward()
# Update every accumulation_steps
if (i + 1) % accumulation_steps == 0:
optimizer.step()
optimizer.zero_grad()
# Early stopping
class EarlyStopping:
def __init__(self, patience=7, min_delta=0):
self.patience = patience
self.min_delta = min_delta
self.counter = 0
self.best_loss = None
self.early_stop = False
def __call__(self, val_loss):
if self.best_loss is None:
self.best_loss = val_loss
elif val_loss > self.best_loss - self.min_delta:
self.counter += 1
if self.counter >= self.patience:
self.early_stop = True
else:
self.best_loss = val_loss
self.counter = 0
# Usage
early_stopping = EarlyStopping(patience=5)
for epoch in range(num_epochs):
train_loss = train_epoch(...)
val_loss = validate(...)
early_stopping(val_loss)
if early_stopping.early_stop:
print("Early stopping triggered")
break
# Learning rate finder
def find_lr(model, dataloader, criterion, optimizer, device,
start_lr=1e-7, end_lr=10, num_iter=100):
model.train()
lr_scheduler = torch.optim.lr_scheduler.ExponentialLR(
optimizer, gamma=(end_lr / start_lr) ** (1 / num_iter)
)
losses = []
lrs = []
for i, (inputs, labels) in enumerate(dataloader):
if i >= num_iter:
break
inputs, labels = inputs.to(device), labels.to(device)
optimizer.zero_grad()
outputs = model(inputs)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
lrs.append(optimizer.param_groups[0]['lr'])
losses.append(loss.item())
lr_scheduler.step()
# Plot
plt.plot(lrs, losses)
plt.xscale('log')
plt.xlabel('Learning Rate')
plt.ylabel('Loss')
plt.show()
return lrs, losses
Resources & Learning Path
Learning Progression
Phase 1: PyTorch Fundamentals (2-3 weeks) □ Tensor operations □ Autograd and gradients □ Basic neural networks □ Training loops □ Data loading Phase 2: Deep Learning Basics (3-4 weeks) □ CNNs for computer vision □ RNNs and LSTMs □ Optimization techniques □ Regularization □ Transfer learning Phase 3: Advanced Architectures (4-6 weeks) □ ResNets and modern CNNs □ Transformers and attention □ GANs □ Advanced optimization □ Model ensembling Phase 4: Production ML (Ongoing) □ Model deployment □ Distributed training □ MLOps practices □ Performance optimization □ Monitoring and maintenance
Related Comprehensive Sheets
Python Ecosystem → Python Advanced (performance, async) → NumPy & Scientific Computing → Data Science with Pandas → Visualization with Matplotlib Machine Learning → Scikit-Learn ML → TensorFlow & Keras → Model Deployment → MLOps Practices Specialized Topics → Computer Vision → Natural Language Processing → Reinforcement Learning → Time Series Forecasting Infrastructure → GPU Computing → Distributed Training → Docker & Kubernetes → Cloud ML Platforms
Pro Tips Summary
Performance ✓ Use GPU when available ✓ Enable mixed precision training ✓ Use DataLoader with num_workers ✓ Profile your code ✓ Batch operations when possible ✓ Use torch.compile() (PyTorch 2.0+) Training ✓ Monitor gradients ✓ Use learning rate scheduling ✓ Implement early stopping ✓ Save checkpoints regularly ✓ Validate frequently ✓ Track metrics with TensorBoard Debugging ✓ Check tensor shapes ✓ Verify gradient flow ✓ Test on small dataset first ✓ Use torch.autograd.detect_anomaly() ✓ Check for NaN/Inf values ✓ Visualize intermediate outputs Best Practices ✓ Set random seeds ✓ Use version control ✓ Document hyperparameters ✓ Split data properly ✓ Normalize inputs ✓ Use proper evaluation metrics ✓ Test on held-out data