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

19 KiB
Raw Blame History

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".

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

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

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)

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

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

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

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

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
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)

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)

# 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.

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.

# 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
# 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
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

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)

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

# 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.

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

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

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.

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)

# 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

# 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

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