Files
magnus919_agent-skills/data-scientist/references/pytorch-integration.md
T
Magnus Hedemark dc08b67203 feat: add researched code integration references — PyTorch, sklearn, DS workflow
Three researched references validated against current API docs:

- references/pytorch-integration.md: device management, training loops,
  AMP, torch.compile, transfer learning, LoRA, distillation, pruning,
  DDP, debugging (validated against PyTorch 2.12 docs)

- references/sklearn-integration.md: pipelines, ColumnTransformer,
  model selection, ensembles, calibration, imbalanced data, custom
  estimators, feature selection (validated against sklearn 1.8.0 docs)

- references/data-science-coding-workflow.md: project structure,
  config management, experiment logging (MLflow/TensorBoard/WandB),
  result serialization, reproducibility, data versioning, unit testing

66/66 validation tests passing.

Closes #23
2026-05-23 17:10:10 -04:00

617 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PyTorch Integration Reference
**Source validated against:** PyTorch 2.12 documentation (pytorch.org/docs/stable)
**Last reviewed:** 2026-05-23
**When to load:** The campaign protocol (Phase 3-7) or any task requires implementing, training, or deploying a PyTorch model.
---
## Device Management
### Canonical Device Pattern
Always parameterize the device. Never hardcode `"cuda"`.
```python
import torch
device = torch.device(
"cuda" if torch.cuda.is_available()
else "mps" if torch.backends.mps.is_available()
else "cpu"
)
# Usage: model.to(device); tensor.to(device)
```
### Checking Device Properties
```python
if torch.cuda.is_available():
print(f"Device: {torch.cuda.get_device_name(0)}")
print(f"VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} GB")
print(f"CUDA Capability: {torch.cuda.get_device_capability(0)}")
```
**MPS (Apple Silicon):** Available on macOS 12.3+. Not all operations are supported — check `torch.backends.mps.is_available()` and `torch.backends.mps.is_built()`. Some operations fall back to CPU automatically.
---
## Training Loop Patterns
### Basic Supervised Training Loop
```python
model.train()
for epoch in range(num_epochs):
running_loss = 0.0
for batch_x, batch_y in dataloader:
batch_x, batch_y = batch_x.to(device), batch_y.to(device)
optimizer.zero_grad()
outputs = model(batch_x)
loss = criterion(outputs, batch_y)
loss.backward()
# Gradient clipping (essential for stability)
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
running_loss += loss.item()
avg_loss = running_loss / len(dataloader)
print(f"Epoch {epoch}: loss={avg_loss:.4f}")
```
### Gradient Accumulation (for large models on limited VRAM)
```python
accumulation_steps = 4 # Effective batch_size = physical_batch * accumulation
for i, (batch_x, batch_y) in enumerate(dataloader):
outputs = model(batch_x)
loss = criterion(outputs, batch_y) / accumulation_steps
loss.backward()
if (i + 1) % accumulation_steps == 0:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
optimizer.zero_grad()
```
### Validation Loop
```python
model.eval()
val_loss = 0.0
correct = 0
total = 0
with torch.no_grad():
for batch_x, batch_y in val_loader:
batch_x, batch_y = batch_x.to(device), batch_y.to(device)
outputs = model(batch_x)
val_loss += criterion(outputs, batch_y).item()
_, predicted = torch.max(outputs, 1)
total += batch_y.size(0)
correct += (predicted == batch_y).sum().item()
print(f"Val Loss: {val_loss/len(val_loader):.4f}, Acc: {100*correct/total:.2f}%")
```
---
## Dataset & DataLoader
### Custom Dataset Class
```python
from torch.utils.data import Dataset, DataLoader
class CustomDataset(Dataset):
def __init__(self, features, labels, transform=None):
self.features = torch.tensor(features, dtype=torch.float32)
self.labels = torch.tensor(labels, dtype=torch.long)
self.transform = transform
def __len__(self):
return len(self.labels)
def __getitem__(self, idx):
x = self.features[idx]
y = self.labels[idx]
if self.transform:
x = self.transform(x)
return x, y
```
### DataLoader Configuration
```python
dataloader = DataLoader(
dataset,
batch_size=32,
shuffle=True,
num_workers=4, # Set to 0 on Windows if multiprocessing issues
pin_memory=True, # Speeds up GPU transfer (only with CUDA)
persistent_workers=True if num_workers > 0 else False, # PyTorch 2.0+
collate_fn=None, # Custom collation for variable-length data
)
```
**Note on `num_workers`:** On Linux, 4-8 workers is typical. On macOS, keep at 0-2. On Windows, 0 is safest. The optimal value depends on the data loading speed vs GPU speed.
### Collate Function for Variable-Length Data
```python
def collate_fn(batch):
"""Pad sequences in a batch to the same length."""
inputs, labels = zip(*batch)
# Pad to max length in this batch
inputs_padded = torch.nn.utils.rnn.pad_sequence(inputs, batch_first=True)
return inputs_padded, torch.tensor(labels)
```
---
## Model Saving & Loading
### Save/Load State Dict (Recommended)
```python
# Save
torch.save({
"epoch": epoch,
"model_state_dict": model.state_dict(),
"optimizer_state_dict": optimizer.state_dict(),
"loss": loss,
"config": {"n_layers": 4, "hidden_dim": 256}, # metadata
}, "checkpoint.pt")
# Load
checkpoint = torch.load("checkpoint.pt", map_location=device)
model.load_state_dict(checkpoint["model_state_dict"])
optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
start_epoch = checkpoint["epoch"] + 1
```
### Save for Inference (weights only)
```python
torch.save(model.state_dict(), "model_weights.pt")
# Load for inference
model = YourModel()
model.load_state_dict(torch.load("model_weights.pt", map_location="cpu"))
model.eval()
```
### TorchScript / torch.export (for production deployment)
```python
# torch.export (PyTorch 2.x preferred, producesExportedProgram)
exported_program = torch.export.export(model, (example_input,))
torch.export.save(exported_program, "model.pt2")
# TorchScript (legacy, still widely supported)
scripted_model = torch.jit.script(model)
scripted_model.save("model_scripted.pt")
```
---
## Mixed Precision (AMP)
Automatic Mixed Precision trains with `float16` (or `bfloat16`) where safe and `float32` where needed. Typically 1.5-2x faster with minimal accuracy loss.
```python
from torch.amp import autocast, GradScaler
scaler = GradScaler("cuda") # "cuda" or "cpu"
for batch_x, batch_y in dataloader:
batch_x, batch_y = batch_x.to(device), batch_y.to(device)
optimizer.zero_grad()
# Autocast context manager
with autocast(device_type="cuda"): # or "cpu"
outputs = model(batch_x)
loss = criterion(outputs, batch_y)
# Scale loss, backward, unscale, step
scaler.scale(loss).backward()
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
scaler.step(optimizer)
scaler.update()
```
**Key points:**
- `autocast` wraps forward pass + loss computation only
- `GradScaler` prevents underflow in small gradients
- Use `bfloat16` on Ampere+ GPUs (less numerical issues than `float16`)
- AMP is most beneficial for large models (CNNs, Transformers) — small models may not see speedup
---
## torch.compile (PyTorch 2.x JIT Compilation)
`torch.compile` compiles model graphs for faster execution with minimal code changes.
```python
# Basic usage (reduces model — returns a compiled wrapper)
compiled_model = torch.compile(model)
# With options
compiled_model = torch.compile(
model,
mode="reduce-overhead", # "default", "reduce-overhead", "max-autotune"
fullgraph=True, # Fail if graph breaks
dynamic=True, # Support dynamic tensor shapes
)
# Training is identical:
output = compiled_model(batch_x) # First call compiles, subsequent calls are fast
```
**When to use:** Models with large tensor operations (CNNs, Transformers). Not beneficial for very small models or heavy data loading bottlenecks.
**When not to use:** Dynamic control flow, custom CUDA extensions, models compiled for TorchScript export.
**Mode selection:**
- `"default"` — conservative, works for most models
- `"reduce-overhead"` — good for inference, reduces Python overhead
- `"max-autotune"` — benchmarks and picks the best backend (slow first run)
---
## Loss Functions
| Problem Type | Loss Function | Import |
|---|---|---|
| Binary classification | `BCEWithLogitsLoss` | `torch.nn.BCEWithLogitsLoss` |
| Multi-class classification | `CrossEntropyLoss` | `torch.nn.CrossEntropyLoss` |
| Multi-label classification | `BCEWithLogitsLoss` | Combines sigmoid + BCELoss |
| Regression (MSE) | `MSELoss` | `torch.nn.MSELoss` |
| Regression (MAE) | `L1Loss` | `torch.nn.L1Loss` |
| Regression (Huber) | `HuberLoss` | `torch.nn.HuberLoss` (delta parameter) |
| Contrastive / Siamese | `TripletMarginLoss` / `ContrastiveLoss` | `torch.nn.TripletMarginLoss` |
| Imbalanced classes | Weighted `CrossEntropyLoss` | Pass `weight` tensor to constructor |
| Sequence (CTC) | `CTCLoss` | `torch.nn.CTCLoss` |
```python
# Weighted loss for imbalanced data
class_weights = torch.tensor([0.2, 0.8]).to(device) # inverse frequency
criterion = torch.nn.CrossEntropyLoss(weight=class_weights)
```
---
## Optimizers & Schedulers
| Optimizer | When | Learning Rate |
|---|---|---|
| `AdamW` | Default for most models (Transformers, CNNs) | 1e-4 to 3e-4 |
| `Adam` | Legacy; prefer AdamW (proper weight decay) | 1e-4 to 3e-4 |
| `SGD` + momentum | When Adam overfits; vision models | 1e-2 to 1e-1 |
| `AdamW` (LoRA) | Fine-tuning with LoRA | 2e-4 to 5e-4 |
```python
import torch.optim as optim
# AdamW — the default
optimizer = optim.AdamW(model.parameters(), lr=1e-4, weight_decay=0.01)
# SGD with momentum (for vision fine-tuning)
optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4)
```
### Learning Rate Schedulers
```python
from torch.optim.lr_scheduler import (
ReduceLROnPlateau, # Reduce when metric plateaus
CosineAnnealingLR, # Cosine decay
OneCycleLR, # Warmup + cosine ("super-convergence")
)
# Reduce on plateau (works well for most tasks)
scheduler = ReduceLROnPlateau(optimizer, mode="min", patience=5, factor=0.5)
# One-cycle (fast convergence, needs max_lr)
scheduler = OneCycleLR(
optimizer,
max_lr=1e-3,
steps_per_epoch=len(train_loader),
epochs=num_epochs,
)
# Cosine annealing (good for Transformers)
scheduler = CosineAnnealingLR(optimizer, T_max=num_epochs)
# Warmup + cosine (standard for LLM fine-tuning)
# Implement manually:
def get_cosine_schedule_with_warmup(optimizer, warmup_steps, total_steps):
def lr_lambda(current_step):
if current_step < warmup_steps:
return float(current_step) / float(max(1, warmup_steps))
progress = float(current_step - warmup_steps) / float(max(1, total_steps - warmup_steps))
return 0.5 * (1.0 + math.cos(math.pi * progress))
return torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)
```
---
## Transfer Learning
### Feature Extraction (Freeze Backbone)
```python
import torchvision.models as models
model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2)
# Freeze all layers
for param in model.parameters():
param.requires_grad = False
# Replace classifier
num_features = model.fc.in_features
model.fc = torch.nn.Linear(num_features, num_classes)
# Only the new layer's params are trainable
optimizer = optim.AdamW(model.fc.parameters(), lr=1e-3)
```
### Full Fine-Tuning
```python
# Unfreeze all layers
for param in model.parameters():
param.requires_grad = True
# Use lower learning rate
optimizer = optim.AdamW(model.parameters(), lr=2e-5)
# Often helps to freeze early layers, fine-tune later layers:
for name, param in model.named_parameters():
if "layer1" in name or "layer2" in name:
param.requires_grad = False
```
### LoRA for Transformer Models
LoRA (Low-Rank Adaptation) trains small rank-decomposition matrices while keeping the base model frozen. Requires the `peft` library.
```python
from peft import LoraConfig, get_peft_model
lora_config = LoraConfig(
r=8, # Rank — higher = more expressiveness, more params
lora_alpha=32, # Scaling factor
target_modules=["q_proj", "v_proj"], # Which modules to apply LoRA to
lora_dropout=0.1,
bias="none", # Don't train bias terms
task_type="SEQ_CLS", # Task type (SEQ_CLS, CAUSAL_LM, TOKEN_CLS, etc.)
)
model = get_peft_model(base_model, lora_config)
# Train with slightly higher LR
optimizer = optim.AdamW(model.parameters(), lr=3e-4)
# LoRA adds very few params (< 1% of base model)
model.print_trainable_parameters() # e.g., "trainable params: 294,912 || all params: 110,080,512 || %: 0.2679"
```
---
## Knowledge Distillation
```python
import torch.nn.functional as F
def distillation_loss(student_logits, teacher_logits, labels, temperature=4.0, alpha=0.7):
"""
Combined distillation + supervised loss.
Args:
temperature: Higher = softer probability distribution (more information from teacher)
alpha: Weight for distillation loss (vs standard cross-entropy)
"""
# Soft target loss (distillation)
soft_student = F.log_softmax(student_logits / temperature, dim=-1)
soft_teacher = F.softmax(teacher_logits.detach() / temperature, dim=-1)
distill_loss = F.kl_div(soft_student, soft_teacher, reduction="batchmean")
distill_loss *= temperature ** 2 # Scale to keep gradients in right range
# Hard target loss
hard_loss = F.cross_entropy(student_logits, labels)
return alpha * distill_loss + (1 - alpha) * hard_loss
# Training loop with distillation
teacher_model.eval()
for batch_x, batch_y in dataloader:
with torch.no_grad():
teacher_logits = teacher_model(batch_x)
student_logits = student_model(batch_x)
loss = distillation_loss(student_logits, teacher_logits, batch_y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
```
**Temperature tuning:** Start with T=4.0. Higher values produce softer targets (more small-class information). Lower values (T=1.0) collapse to standard cross-entropy.
**Alpha tuning:** α=0.7 (weight on distillation) is a common starting point. Increase α when the teacher is much better than the student.
---
## Model Pruning
```python
import torch.nn.utils.prune as prune
# Apply pruning to specific layers
prune.l1_unstructured(module=model.fc, name="weight", amount=0.3) # Remove 30% of weights
# Make pruning permanent (removes the pruning mask)
prune.remove(module=model.fc, name="weight")
# Structured pruning (removes entire neurons/channels)
prune.ln_structured(module=model.conv1, name="weight", amount=0.2, n=2, dim=0)
# Global pruning (prune all layers together by importance)
parameters_to_prune = [
(model.layer1, "weight"),
(model.layer2, "weight"),
(model.fc, "weight"),
]
prune.global_unstructured(
parameters_to_prune,
pruning_method=prune.L1Unstructured,
amount=0.2, # Remove 20% of weights globally
)
```
**After pruning:** Fine-tune the pruned model. Pruning then fine-tuning almost always recovers accuracy. Pruning without fine-tuning degrades performance significantly.
---
## Distributed Data Parallel (DDP)
For multi-GPU training. DDP wraps the model and handles gradient synchronization.
```python
import torch.distributed as dist
import torch.multiprocessing as mp
from torch.nn.parallel import DistributedDataParallel as DDP
def setup(rank, world_size):
"""Initialize the distributed process group."""
dist.init_process_group(
backend="nccl", # "nccl" for NVIDIA, "gloo" for CPU
init_method="env://", # Use env vars MASTER_ADDR and MASTER_PORT
rank=rank,
world_size=world_size,
)
def cleanup():
dist.destroy_process_group()
def train(rank, world_size):
setup(rank, world_size)
# Model must be on the correct device BEFORE wrapping
model = YourModel().to(rank)
ddp_model = DDP(model, device_ids=[rank])
# DataLoader must use DistributedSampler
sampler = torch.utils.data.distributed.DistributedSampler(
dataset, num_replicas=world_size, rank=rank
)
dataloader = DataLoader(dataset, batch_size=32, sampler=sampler)
# Training loop (same structure, use ddp_model)
for epoch in range(num_epochs):
sampler.set_epoch(epoch) # Shuffle each epoch
for batch_x, batch_y in dataloader:
batch_x, batch_y = batch_x.to(rank), batch_y.to(rank)
outputs = ddp_model(batch_x)
loss = criterion(outputs, batch_y)
loss.backward()
optimizer.step()
cleanup()
# Launch
if __name__ == "__main__":
world_size = torch.cuda.device_count()
mp.spawn(train, args=(world_size,), nprocs=world_size)
```
**When DDP is worth it:** Models that take > 1 hour to train on a single GPU. For short experiments, single-GPU + AMP is often faster due to communication overhead.
---
## Debugging
### Common Failure Patterns
| Symptom | Likely Cause | Fix |
|---|---|---|
| `loss = nan` | Exploding gradients, bad learning rate | Lower LR, add gradient clipping, check for NaN in input data |
| `loss = nan` after AMP | Gradient underflow | Increase `GradScaler` init_scale, or use `bfloat16` |
| Loss doesn't decrease | Wrong LR, wrong loss function | Check LR range, verify loss function matches task |
| CUDA OOM | Batch size too large | Reduce batch size, enable gradient checkpointing, use AMP |
| `Expected all tensors to be on...` | Device mismatch | Always `.to(device)` tensors before model forward |
| `CUDA error: device-side assert` | Wrong label class (out of range) | Check label values are in `[0, num_classes)` |
| Model doesn't overfit 1 batch | Bug in model architecture | Try overfitting on a single batch (batch of 2-4 samples for 100 steps) |
### Overfit on One Batch (Diagnostic)
```python
# If model can't overfit a single batch, something is fundamentally wrong
single_batch = next(iter(dataloader))
for step in range(100):
outputs = model(single_batch[0].to(device))
loss = criterion(outputs, single_batch[1].to(device))
loss.backward()
optimizer.step()
print(f"Loss after 100 steps on 1 batch: {loss.item():.6f}")
# If loss is not near 0, model has a bug or LR is way off.
```
### Gradient Checking
```python
# Check gradient norms during training
total_norm = 0.0
for p in model.parameters():
if p.grad is not None:
param_norm = p.grad.data.norm(2)
total_norm += param_norm.item() ** 2
total_norm = total_norm ** 0.5
if total_norm > 10.0:
print(f"WARNING: Large gradient norm: {total_norm:.4f}")
```
---
## Reproducibility
```python
import random
import numpy as np
import torch
def set_seed(seed: int = 42):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False # Trades off speed for determinism
set_seed(42)
```
**Note on `cudnn.deterministic`:** Setting this to `True` may reduce performance (5-15%). For experiments where exact reproducibility isn't critical, leave `benchmark=True` and accept minor stochastic variation.
---
## See Also
- `references/experimental-campaign-protocol.md` — where this reference fits in the campaign workflow
- `references/sklearn-integration.md` — for non-deep-learning methods
- `scripts/detect-compute.py` — check your hardware before choosing model size
- pytorch.org/docs/stable — official API documentation