AI Frameworks
The frameworks that actually get used, grouped by which layer of the stack they occupy. Most confusion here comes from comparing tools that do different jobs: a training helper against a serving engine, or a model library against the tensor framework underneath it.
| Layer | What it handles | Reasonable default in 2026 |
|---|---|---|
| Tensors & autograd | The computation itself | PyTorch |
| Training loop | Boilerplate, devices, checkpointing | Accelerate or Lightning |
| Scaling | Multi-GPU, multi-node, sharding | FSDP, or DeepSpeed at extreme scale |
| Models | Pretrained weights and architectures | Transformers, Diffusers, timm |
| Fine-tuning | Adapting a model cheaply | PEFT, plus TRL for preference work |
| Serving | Throughput, latency, batching | vLLM for LLMs, Triton for the rest |
| Tabular ML | Everything that is not deep learning | scikit-learn and XGBoost |
| Tracking | Experiments, metrics, model registry | Weights & Biases or MLflow |
Click any framework to expand it. PyTorch-centric code for the main ones is collected at the end.
Core frameworks Training & scaling Model libraries Fine-tuning Inference & serving Classical ML Tracking & tuning Code snippets Technical vocabulary
Core frameworks
The tensor and autograd layer. Everything else is built on one of these, so this choice decides your ecosystem more than any other.
PyTorch
Builds the computation graph as the code runs, so a model is ordinary Python. You can step through it with a debugger and inspect it with print. Autograd records what you do to tensors and works out the gradients on the backward pass.
It won research first, then production. torch.compile closed most of the old speed gap by tracing and fusing the graph before it runs, which took away the main argument for static-graph frameworks.
One training step, written out in full — this is the loop every other tool on this page either wraps or replaces.
loss = loss_fn(model(x), y)
opt.zero_grad()
loss.backward() # autograd walks the recorded graph
opt.step()Choose it when: almost always. It is the default for research and for most new production work, and nearly every model library assumes it.
PyTorch versus TensorFlow
The old answer was dynamic graph against static graph. That no longer holds: TF2 runs eagerly by default, and PyTorch can compile ahead of time. Both now do both. What is left is a set of practical differences that still affect daily work.
| Dimension | PyTorch | TensorFlow |
|---|---|---|
| Execution | Eager by default; torch.compile traces and fuses into a graph when you ask for it | Eager by default since TF2; @tf.function traces into a static graph for speed and export |
| Debugging | Ordinary Python — breakpoints, print, and stack traces all work inside the model | Fine in eager mode. Inside a traced tf.function the Python runs only while tracing, which catches people out |
| Tensor layout | NCHW — channels before spatial dimensions | NHWC — channels last |
| Model definition | Subclass nn.Module, write forward; the training loop is yours to write | Keras Sequential/functional/subclassing, with model.fit() supplying the loop |
| Shapes | Inferred at runtime from real tensors, so errors surface on the first forward pass | Often known before the model runs, so some errors show up earlier and others get harder to read |
| Weight format | state_dict — a plain dict of tensors, saved via torch.save or safetensors | SavedModel — graph plus weights plus signatures in one directory, self-describing |
| Deployment | TorchServe, ONNX export, ExecuTorch, TorchScript, plus torch.compile backends | TF Serving, TFLite for mobile, TensorFlow.js for browsers, TFX for full pipelines |
| Accelerators | CUDA and ROCm first class; Apple mps; TPU only through PyTorch/XLA | CUDA plus native, first-class TPU support |
| Distributed | DDP, FSDP, and the DeepSpeed and Accelerate ecosystems | tf.distribute strategies, tightly integrated with TPU pods |
| Ecosystem gravity | Nearly all new research and model releases land here first: Transformers, Diffusers, timm | Large installed base in existing production; fewer new releases target it |
What this means in practice
The layout difference is the one that bites. A convolution weight is [out, in, kH, kW] in PyTorch and [kH, kW, in, out] in TensorFlow. Converting between them means transposing every convolution and dense weight. Get it slightly wrong and the model still runs and still produces plausible output — it is simply incorrect. That is why conversion goes through ONNX or a purpose-built converter rather than by hand.
Tracing is where TensorFlow works differently. Inside a tf.function, Python control flow runs once, while tracing, and is then fixed into the graph. So a print fires once rather than on every call, and a Python if on a tensor value quietly locks in one branch. tf.cond and tf.while_loop exist for exactly this. PyTorch fails differently: torch.compile drops back to eager mode on anything it cannot trace, called a graph break, so you lose speed rather than correctness.
The deployment gap has narrowed but not closed. TFLite and TensorFlow.js are still the more mature paths for mobile and browser, and TPUs are genuinely TensorFlow's home ground. Everywhere else PyTorch has caught up. And with ONNX export, the framework you train in does not have to be the one you serve in.
Keras is not a third option. Keras 3 runs on TensorFlow, PyTorch, or JAX, so "Keras versus PyTorch" is the wrong comparison — you can write Keras and have PyTorch run it.
TensorFlow
Graph-first from the start, which made deployment and cross-platform export strong early on. TF2 made eager execution the default, closing much of the usability gap with PyTorch.
Its remaining strength is where it can run: TFLite for mobile, TensorFlow.js for browsers, TFX for pipelines, and TPU support. It also has a large body of existing production code.
The same idea in TF2: eager by default, then wrapped in tf.function so it is traced into a graph and runs faster.
@tf.function # traced once, then reused
def train_step(x, y):
with tf.GradientTape() as tape:
loss = loss_fn(model(x, training=True), y)
grads = tape.gradient(loss, model.trainable_variables)
opt.apply_gradients(zip(grads, model.trainable_variables))
return lossChoose it when: you are maintaining existing TensorFlow systems, targeting mobile or browser through TFLite/TF.js, or standardised on TFX. Rarely the choice for a greenfield research project.
The point-by-point comparison against PyTorch — execution, layout, weight formats, deployment, and the conversion traps — sits inside the PyTorch entry above.
JAX
NumPy-style functions plus transformations you can stack: grad for derivatives, jit for XLA compilation, vmap for automatic batching, pmap and shard_map for parallelism. Functions must be pure, and state is passed in explicitly rather than held in objects.
That purity rule is the whole trade-off. It is unfamiliar and sometimes awkward, and it is exactly what lets the transformations stack so cleanly and the compiler optimise so aggressively.
Transformations stack: take the gradient, compile it, then batch it over many inputs — each is one wrapper.
def loss_fn(params, x, y):
return jnp.mean((model(params, x) - y) ** 2)
grad_fn = jax.grad(loss_fn) # derivative
fast = jax.jit(grad_fn) # XLA-compiled
batched = jax.vmap(fast, in_axes=(None, 0, 0)) # auto-batchedChoose it when: you are doing research where custom gradients or unusual parallelism matter, running on TPUs, or working in a lab that already uses it. Flax and Optax supply the layers and optimisers.
Keras
A high-level model-building API. Keras 3 is backend-agnostic and runs on TensorFlow, PyTorch, or JAX, so the same model code is portable across all three.
model.fit() hides the training loop completely. That is excellent for standard supervised setups, and limiting the moment you need a custom loop, unusual loss scheduling, or more than one optimiser.
The same model runs on any backend — set an environment variable and nothing else changes.
import os; os.environ["KERAS_BACKEND"] = "torch" # or "tensorflow", "jax"
import keras
model = keras.Sequential([keras.layers.Dense(256, activation="relu"),
keras.layers.Dense(10)])
model.compile(optimizer="adamw", loss="sparse_categorical_crossentropy")
model.fit(train_ds, epochs=5, validation_data=val_ds)Choose it when: the task is standard, you want to write it quickly, or you are teaching. Go past it when the training loop itself becomes the interesting part.
Training and scaling
Removing boilerplate and getting a model across more than one GPU. These sit on top of PyTorch rather than replacing it.
PyTorch Lightning
Reorganises training into a LightningModule. You write training_step, validation_step, and configure_optimizers; the Trainer handles device placement, distributed setup, checkpointing, logging, and early stopping.
The benefit is consistency across a team: every project looks the same, and the parts people usually get subtly wrong are written once. The cost is that you have to learn the framework's control flow before you can override it.
You write the steps; the Trainer handles devices, distribution, checkpoints, and logging.
class LitModel(pl.LightningModule):
def training_step(self, batch, idx):
x, y = batch
loss = F.cross_entropy(self.net(x), y)
self.log("train_loss", loss)
return loss
def configure_optimizers(self):
return torch.optim.AdamW(self.parameters(), lr=3e-4)
pl.Trainer(accelerator="gpu", devices=4, precision="bf16-mixed").fit(LitModel(), dm)Choose it when: you run many experiments and want the structure enforced. Skip it for a one-off training run where the loop is the research.
Hugging Face Accelerate
A thin layer that makes one training script run unchanged on CPU, single GPU, multi-GPU, multi-node, or TPU. You keep your own loop; accelerator.prepare() wraps the model, optimiser, and dataloaders, and accelerator.backward() replaces loss.backward().
Its appeal is how little it takes over. The loop stays yours and stays readable, which usually makes it a better first step than a full framework.
Four changed lines make an existing script run on one GPU, eight GPUs, or several nodes — launched with accelerate launch.
from accelerate import Accelerator
accelerator = Accelerator()
model, opt, dl = accelerator.prepare(model, opt, dl) # <- added
for x, y in dl:
loss = loss_fn(model(x), y)
accelerator.backward(loss) # <- replaces loss.backward()
opt.step(); opt.zero_grad()Choose it when: you want distributed training without restructuring your code or adopting a new abstraction.
FSDP — Fully Sharded Data Parallel
Built into PyTorch. Shards parameters, gradients, and optimiser state across ranks, gathering each layer's parameters only while it is being used and releasing them afterwards.
Plain DDP copies the whole model onto every GPU, so the biggest model you can train is limited by one device's memory. Sharding removes that ceiling, which is why FSDP is the standard way to train models larger than a single accelerator.
Wrapping per transformer block is what lets each layer's parameters be gathered only while that layer runs.
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
from torch.distributed.fsdp.wrap import transformer_auto_wrap_policy
model = FSDP(
model,
auto_wrap_policy=partial(transformer_auto_wrap_policy,
transformer_layer_cls={LlamaDecoderLayer}),
device_id=torch.cuda.current_device(),
)Choose it when: the model no longer fits comfortably on one GPU with its optimiser state, and you want the in-tree PyTorch option.
DeepSpeed
Microsoft's training library, best known for ZeRO: three stages that shard optimiser state, then gradients, then parameters. It also adds CPU and NVMe offload, activation checkpointing, and a fast fused-kernel inference path.
Offload is what sets it apart: parameters can spill to CPU memory or disk, so you can train models that genuinely do not fit in all your GPU memory combined. It costs real throughput.
ZeRO stage 3 shards everything and spills the optimiser to CPU — this config is the whole difference between fitting and not fitting.
// ds_config.json
{"zero_optimization": {"stage": 3,
"offload_optimizer": {"device": "cpu"},
"offload_param": {"device": "cpu"}},
"bf16": {"enabled": true},
"train_micro_batch_size_per_gpu": 1}
$ deepspeed train.py --deepspeed ds_config.jsonChoose it when: you are training at a scale where FSDP is not enough, or you need offload to make the run possible at all.
Megatron-LM
NVIDIA's library for very large language model training, providing tensor parallelism (splitting individual matrix multiplications across GPUs) alongside pipeline and data parallelism.
Combining all three, often called 3-D parallelism, is how frontier-scale training is actually done. It is built for experts and takes a lot of configuration by design.
The three parallelism dimensions are set as launch flags: 8-way tensor, 4-way pipeline, data parallel over the rest.
$ torchrun --nproc_per_node 8 --nnodes 16 pretrain_gpt.py \
--tensor-model-parallel-size 8 \
--pipeline-model-parallel-size 4 \
--num-layers 80 --hidden-size 8192 --num-attention-heads 64 \
--sequence-parallel --use-flash-attnChoose it when: you are pretraining at a scale where tensor parallelism is required. For fine-tuning, this is the wrong tool.
Ray & Ray Train
A general distributed computing framework for Python, with libraries layered on it: Ray Train for distributed training, Ray Tune for hyperparameter search, Ray Serve for deployment, and Ray Data for distributed preprocessing.
What sets it apart is that it runs the whole workload, not just the training step: data loading, training, and tuning all under one cluster.
One cluster runs the search and the distributed training underneath it.
from ray import tune
from ray.train.torch import TorchTrainer
from ray.train import ScalingConfig
trainer = TorchTrainer(train_loop, scaling_config=ScalingConfig(num_workers=8, use_gpu=True))
tuner = tune.Tuner(trainer, param_space={"train_loop_config": {"lr": tune.loguniform(1e-5, 1e-3)}},
tune_config=tune.TuneConfig(num_samples=32))
results = tuner.fit()Choose it when: you need cluster-wide orchestration or large-scale hyperparameter search, not just multi-GPU training.
Model libraries
Pretrained weights and architectures. These are why almost nobody trains from scratch any more.
Hugging Face Transformers
One consistent interface over thousands of pretrained models across text, vision, audio, and multimodal. AutoModel, AutoTokenizer, and AutoProcessor pick the right classes from a model ID.
Its real contribution is standardisation. Swapping architectures becomes a change of one string, and the tools around it — PEFT, TRL, Accelerate, Datasets — all assume the same interfaces.
Changing the model is changing a string — the surrounding code stays identical.
from transformers import AutoModelForCausalLM, AutoTokenizer
model_id = "meta-llama/Llama-3.1-8B-Instruct" # swap this line, nothing else
tok = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(model_id, torch_dtype="auto", device_map="auto")
msgs = [{"role": "user", "content": "Summarise this in one line."}]
ids = tok.apply_chat_template(msgs, add_generation_prompt=True, return_tensors="pt")
print(tok.decode(model.generate(ids.to(model.device), max_new_tokens=64)[0]))Choose it when: you are working with any pretrained model. Effectively unavoidable, and rightly so.
Diffusers
The same idea for diffusion models: pipelines, schedulers, UNet and DiT backbones, VAEs, and ControlNet. The pieces slot together, so you can swap a sampler or attach an adapter without rewriting the pipeline.
Keeping the scheduler separate from the model is the good design decision: the sampling strategy becomes a runtime choice rather than something baked into the weights.
Because the scheduler is separate from the model, changing the sampler is one line and needs no retraining.
from diffusers import StableDiffusionXLPipeline, DPMSolverMultistepScheduler
pipe = StableDiffusionXLPipeline.from_pretrained(
"stabilityai/stable-diffusion-xl-base-1.0", torch_dtype=torch.float16).to("cuda")
pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config) # sampler swap
image = pipe("a lighthouse in fog", num_inference_steps=25, guidance_scale=6.0).images[0]Choose it when: generating or fine-tuning images, video, or audio with diffusion.
timm
Ross Wightman's image model library — hundreds of vision backbones with consistent pretrained weights, plus the training utilities, augmentations, and schedulers that go with them.
It is the reference implementation for vision backbones. timm.create_model(name, pretrained=True, num_classes=n) covers most of what an applied vision project needs on day one.
One call gives you the backbone, the pretrained weights, and the matching preprocessing.
import timm
model = timm.create_model("convnext_base.fb_in22k_ft_in1k", pretrained=True, num_classes=37)
cfg = timm.data.resolve_data_config({}, model=model)
tf = timm.data.create_transform(**cfg) # the exact preprocessing it was trained with
timm.list_models("*vit*", pretrained=True)[:5]Choose it when: you need a vision backbone and want breadth of choice with reliable pretrained weights.
torchvision, torchaudio, torchtext
PyTorch's domain libraries: datasets, transforms, common architectures, and — importantly — operators such as nms, roi_align, and box_iou written as fast, differentiable CUDA kernels.
Even when the models come from elsewhere, torchvision.ops and transforms.v2 are worth using directly rather than rewriting.
These operators are CUDA kernels, so they are both faster and more correct than a hand-rolled version.
from torchvision.ops import nms, box_iou
import torchvision.transforms.v2 as T
keep = nms(boxes, scores, iou_threshold=0.5) # fused CUDA kernel
iou = box_iou(boxes[keep], gt_boxes)
tf = T.Compose([T.RandomResizedCrop(224), T.RandomHorizontalFlip(),
T.ToDtype(torch.float32, scale=True)]) # v2 handles boxes and masks tooChoose it when: you need standard vision or audio primitives that should be fast and correct.
sentence-transformers
Wraps transformer encoders for producing sentence and document embeddings, with pooling, normalisation, similarity search, and training objectives for retrieval built in.
It also supplies cross-encoder rerankers, which give you the two-stage retrieve-then-rerank pattern that most RAG quality improvements come from.
Retrieve broadly with the fast bi-encoder, then rerank the shortlist with the slower, more accurate cross-encoder.
from sentence_transformers import SentenceTransformer, CrossEncoder, util
bi = SentenceTransformer("BAAI/bge-base-en-v1.5")
doc_emb = bi.encode(docs, normalize_embeddings=True)
hits = util.semantic_search(bi.encode([query], normalize_embeddings=True), doc_emb, top_k=50)[0]
ce = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
ranked = sorted(zip(ce.predict([(query, docs[h["corpus_id"]]) for h in hits]), hits), reverse=True)[:5]Choose it when: building semantic search, retrieval, deduplication, or clustering over text.
Fine-tuning
Adapting a pretrained model without the cost of full training.
PEFT
Parameter-efficient fine-tuning: LoRA, QLoRA, prefix tuning, and adapters, integrated with Transformers so a base model becomes trainable with a few lines and a config.
The practical win is size. Adapters are megabytes rather than gigabytes, they combine with each other, and they can be swapped at inference, so one base model can serve many specialisations.
Two lines turn a frozen 8B model into something trainable on one GPU, and the saved adapter is a few megabytes.
from peft import LoraConfig, get_peft_model
cfg = LoraConfig(r=16, lora_alpha=32, lora_dropout=0.05,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"])
model = get_peft_model(model, cfg)
model.print_trainable_parameters() # trainable: 0.12% of all parameters
model.save_pretrained("./adapter") # megabytes, not gigabytesChoose it when: fine-tuning anything large. Full fine-tuning is rarely worth its cost unless you are changing the model's behaviour fundamentally.
TRL
Hugging Face's library for the post-training stack: supervised fine-tuning, reward modelling, PPO, and the simpler preference methods such as DPO and its variants.
DPO in particular removed the need for a separate reward model and an RL loop, which is why a small team can now run preference tuning at all.
DPO needs only preference pairs — no reward model and no RL loop.
from trl import DPOTrainer, DPOConfig
# dataset rows: {"prompt": ..., "chosen": ..., "rejected": ...}
trainer = DPOTrainer(
model=model, ref_model=None, # ref_model=None reuses the frozen base
args=DPOConfig(beta=0.1, learning_rate=5e-6, output_dir="./dpo"),
train_dataset=prefs, processing_class=tok,
)
trainer.train()Choose it when: aligning a model to preferences or instruction data rather than to labels.
Unsloth
Optimised fine-tuning kernels that cut memory use and raise throughput well beyond a plain PEFT setup, while keeping a Transformers-compatible interface.
The practical effect is fitting a fine-tune onto hardware that could not otherwise run it: a single consumer GPU rather than a rented cluster.
Same fine-tune, roughly half the memory — which is what makes a 16 GB consumer GPU enough.
from unsloth import FastLanguageModel
model, tok = FastLanguageModel.from_pretrained(
"unsloth/llama-3.1-8b-bnb-4bit", max_seq_length=2048, load_in_4bit=True)
model = FastLanguageModel.get_peft_model(model, r=16, use_gradient_checkpointing="unsloth")
# then train with the usual SFTTrainerChoose it when: you are fine-tuning on constrained hardware and the speedup decides feasibility.
Axolotl
A configuration-driven fine-tuning tool: a YAML file specifies the model, dataset, method, and hyperparameters, and it orchestrates the run across the underlying libraries.
The value is reproducibility. The config is the experiment, so runs can be diffed, reviewed, and repeated. That matters more than it sounds once you have done thirty of them.
The YAML is the experiment: commit it, diff it against last week's, and the run is reproducible.
# qlora-llama.yml
base_model: meta-llama/Llama-3.1-8B
adapter: qlora
load_in_4bit: true
lora_r: 32
datasets:
- path: ./data/train.jsonl
type: alpaca
sequence_len: 4096
micro_batch_size: 2
$ axolotl train qlora-llama.ymlChoose it when: running many fine-tuning variants and want each one to be a reviewable file rather than an edited script.
Inference and serving
Training frameworks make poor serving engines. This layer exists because throughput, latency, and memory behaviour at inference are a different problem.
vLLM
An LLM serving engine built around PagedAttention, which stores the KV cache in fixed-size blocks like virtual memory pages rather than one continuous block per sequence.
That removes the fragmentation and over-allocation that used to limit concurrency. Together with continuous batching, it gives throughput gains of around ten times over a plain model.generate loop. It also serves an OpenAI-compatible API.
One command gives you an OpenAI-compatible endpoint that any existing client can point at.
$ vllm serve meta-llama/Llama-3.1-8B-Instruct --max-model-len 8192
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="none")
client.chat.completions.create(model="meta-llama/Llama-3.1-8B-Instruct",
messages=[{"role": "user", "content": "hello"}])Choose it when: serving an open-weight LLM to more than one user. The default answer for self-hosted inference.
SGLang
A serving engine with a front-end language for structured LLM programs, notable for RadixAttention — automatic prefix-cache reuse across requests that share a prompt prefix.
That matters for agent and RAG workloads, where every request repeats a long system prompt and the same tool definitions. The shared prefix is computed once rather than on every request.
Every request here repeats the same long system prompt; RadixAttention computes that prefix once and reuses it.
$ python -m sglang.launch_server --model-path meta-llama/Llama-3.1-8B-Instruct
import sglang as sgl
@sgl.function
def classify(s, doc):
s += sgl.system(LONG_SHARED_INSTRUCTIONS) # cached across every call
s += sgl.user(doc)
s += sgl.assistant(sgl.gen("label", choices=["billing", "technical", "other"]))Choose it when: your traffic has heavily shared prefixes, or you need structured generation and complex multi-call control flow.
TensorRT & TensorRT-LLM
NVIDIA's inference compilers. A trained network is converted into an optimised engine — layers fused, precision calibrated to FP8 or INT8, kernels selected for the specific GPU.
It generally produces the fastest inference available on NVIDIA hardware. The cost is a build step per model and per GPU architecture, and an engine that is not portable.
The build step is per model and per GPU, and produces an engine tied to that hardware.
$ trtllm-build --checkpoint_dir ./llama-ckpt \
--gemm_plugin float16 --max_batch_size 64 \
--output_dir ./engine
import torch_tensorrt # the PyTorch-side route
opt = torch_tensorrt.compile(model, inputs=[torch_tensorrt.Input((1, 3, 224, 224))],
enabled_precisions={torch.float16})Choose it when: you are latency-bound on NVIDIA hardware and the deployment target is fixed enough to justify the build pipeline.
ONNX Runtime
Executes models in the ONNX interchange format across CPUs, GPUs, mobile chips, and specialised accelerators, with graph optimisations and quantisation support.
Its value is separation: train in PyTorch, export once, and run anywhere. That matters most for edge and cross-platform deployment, where the training stack cannot follow.
Export once from PyTorch, then run the same file on a CPU server, a phone, or an accelerator.
torch.onnx.export(model, dummy, "model.onnx", opset_version=17,
dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}})
import onnxruntime as ort
sess = ort.InferenceSession("model.onnx", providers=["CPUExecutionProvider"])
out = sess.run(None, {"input": x.numpy()})[0]Choose it when: deploying to CPU, mobile, or heterogeneous hardware, or when the serving environment cannot host PyTorch.
llama.cpp & GGUF
A C/C++ inference implementation with aggressive quantisation, running quantised models on CPUs, Apple silicon, and modest GPUs. GGUF is its single-file model format.
It made local LLM inference genuinely practical on consumer hardware, and most desktop LLM applications are built on it.
A quantised GGUF file runs on a laptop with no GPU and no Python at all.
$ llama-cli -m ./Llama-3.1-8B-Instruct-Q4_K_M.gguf \
-p "Explain quantisation in two sentences." -n 128
$ llama-server -m ./Llama-3.1-8B-Instruct-Q4_K_M.gguf --port 8080
# Q4_K_M: ~4.5 GB on disk instead of ~16 GB at fp16Choose it when: running locally, on-device, or anywhere without a datacentre GPU.
torch.compile & TorchScript
torch.compile traces a model, fuses operations, and generates optimised kernels. It is often a substantial speedup from a single line, with no other change to your code. TorchScript was the older export path and has largely been replaced.
Graph breaks are the thing to watch. Python control flow that depends on data forces the compiler to fall back, quietly reducing the benefit. Compiling also costs time on the first call.
One line, no other change — and a way to check whether anything is silently falling back.
model = torch.compile(model) # first call is slow, then it pays off
# find graph breaks before they cost you the speedup
import torch._dynamo as dynamo
print(dynamo.explain(model)(example_input).break_reasons)Choose it when: you want faster PyTorch without leaving PyTorch. Usually the first optimisation to try.
Triton Inference Server
NVIDIA's general-purpose serving system. It hosts models from several frameworks behind one endpoint, with dynamic batching, concurrent model execution, versioning, and model ensembles.
Not to be confused with OpenAI's Triton, which is a language for writing GPU kernels. The names collide constantly.
Models are directories in a repository; the server exposes all of them behind one endpoint.
model_repository/
detector/
1/model.onnx
config.pbtxt # dynamic_batching { max_queue_delay_microseconds: 100 }
classifier/
1/model.pt
$ tritonserver --model-repository=/model_repository
$ curl localhost:8000/v2/models/detector/infer -d @request.jsonChoose it when: serving several models, possibly from different frameworks, behind one piece of infrastructure. vLLM is the better pick for LLMs specifically.
Classical ML
Still the right answer for most tabular problems, and still where a large share of production value sits.
scikit-learn
Classical algorithms behind one consistent fit/predict interface. Pipelines chain preprocessing and estimation into a single fitted object, and there is cross-validation and model selection alongside.
Pipelines are the underrated part. They apply preprocessing inside each cross-validation fold, which prevents the leakage that quietly inflates scores when you scale or impute before splitting.
Putting the scaler inside the pipeline is what keeps it fitted per fold — do it outside and the score is inflated.
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import cross_val_score
pipe = Pipeline([("scale", StandardScaler()),
("clf", LogisticRegression(max_iter=1000))])
cross_val_score(pipe, X, y, cv=5, scoring="roc_auc") # scaler refits inside each foldChoose it when: the data is tabular, or you need a baseline before anything deeper. Almost every project should start here.
XGBoost
Gradient-boosted trees with regularisation, sparsity handling, and efficient parallel training. The default strong model for structured data.
On tabular problems it routinely beats neural networks with far less tuning and far less data. That is still true, despite regular claims otherwise.
Early stopping on a validation set is the one thing that matters most, and missing values need no imputation.
from xgboost import XGBClassifier
clf = XGBClassifier(n_estimators=2000, max_depth=6, learning_rate=0.05,
subsample=0.8, colsample_bytree=0.8,
scale_pos_weight=neg / pos, # class imbalance
early_stopping_rounds=50, eval_metric="aucpr")
clf.fit(X_train, y_train, eval_set=[(X_valid, y_valid)]) # NaNs handled nativelyChoose it when: you have tabular data and want the strongest model quickly.
LightGBM
Microsoft's boosting implementation. Histogram-based splitting and leaf-wise growth make it train much faster than XGBoost on large datasets, and it handles categorical features natively.
Leaf-wise growth is more accurate per tree and overfits more easily on small data, so num_leaves and min_data_in_leaf matter more here than elsewhere.
Categorical columns go in as-is, and the two leaf-wise parameters are what you actually tune.
import lightgbm as lgb
X["region"] = X["region"].astype("category") # no encoding needed
ds = lgb.Dataset(X_train, y_train, categorical_feature=["region"])
lgb.train({"objective": "binary", "num_leaves": 63, "min_data_in_leaf": 100,
"learning_rate": 0.05, "metric": "auc"},
ds, num_boost_round=3000,
valid_sets=[lgb.Dataset(X_valid, y_valid)],
callbacks=[lgb.early_stopping(100)])Choose it when: datasets are large enough that XGBoost training time is a bottleneck.
CatBoost
Yandex's boosting library. It uses ordered target statistics for categorical features, and ordered boosting to reduce the prediction shift that causes overfitting.
It handles categorical columns with many distinct values natively, and tends to do well on default settings, which makes it a strong first attempt on messy business data.
Name the categorical columns and it handles the encoding, usually well on default settings.
from catboost import CatBoostClassifier, Pool
cats = ["city", "device", "merchant_id"] # high-cardinality is fine
train = Pool(X_train, y_train, cat_features=cats)
model = CatBoostClassifier(iterations=2000, learning_rate=0.05, eval_metric="AUC",
early_stopping_rounds=100, verbose=200)
model.fit(train, eval_set=Pool(X_valid, y_valid, cat_features=cats))Choose it when: the data is heavily categorical and you would rather not hand-engineer encodings.
Tracking and tuning
The infrastructure that makes results reproducible and comparisons meaningful.
Weights & Biases
Experiment tracking with hosted dashboards: metrics, hyperparameters, system usage, artefacts, and sweeps for hyperparameter search, plus report pages for sharing findings.
Its practical value is being able to answer "what exactly produced this checkpoint" months later, and making runs comparable rather than remembered.
Logging the config alongside the metrics is what lets you answer, months later, what produced a checkpoint.
import wandb
wandb.init(project="fraud", config={"lr": 3e-4, "model": "xgb", "features": "v7"})
for epoch in range(epochs):
wandb.log({"epoch": epoch, "val_auc": auc, "val_loss": loss})
wandb.log_artifact("model.pkl", name="fraud-model", type="model")Choose it when: a team runs many experiments and needs shared visibility.
MLflow
Open-source tracking, project packaging, model registry, and deployment. Self-hostable, which is often the deciding factor.
The registry matters most in regulated or audited settings: versioned models with stage transitions and a trail back to the run that produced them.
The registry is the part that matters: a versioned model with a trail back to the exact run.
import mlflow
with mlflow.start_run() as run:
mlflow.log_params({"max_depth": 6, "lr": 0.05})
mlflow.log_metric("val_auc", 0.87)
mlflow.sklearn.log_model(clf, "model")
mlflow.register_model(f"runs:/{run.info.run_id}/model", "readmission-risk")
# then promote that version through Staging -> ProductionChoose it when: you need self-hosting, or a model registry as part of a governed release process.
Optuna
Hyperparameter optimisation with a define-by-run API, meaning the search space is written in ordinary Python control flow. It also does Bayesian sampling and prunes unpromising trials.
Pruning is where the saving comes from. Trials that are clearly losing get stopped early, so the budget goes to candidates that might win.
The search space is ordinary Python, and pruning stops losing trials before they waste the budget.
import optuna
def objective(trial):
params = {"max_depth": trial.suggest_int("max_depth", 3, 10),
"learning_rate": trial.suggest_float("learning_rate", 1e-3, 0.3, log=True)}
for step, auc in enumerate(train_with(params)):
trial.report(auc, step)
if trial.should_prune(): # stop a losing trial early
raise optuna.TrialPruned()
return auc
study = optuna.create_study(direction="maximize", pruner=optuna.pruners.MedianPruner())
study.optimize(objective, n_trials=100)Choose it when: tuning matters enough to automate and grid search would be wasteful.
Code Snippets
The typical style of each framework, side by side. Seeing the same job written four ways usually tells you more than any description of the differences.
Core frameworks — the same model, four ways
PyTorch — explicit loop, you own every step
import torch, torch.nn as nn, torch.nn.functional as F
model = nn.Sequential(nn.Linear(784, 256), nn.ReLU(), nn.Linear(256, 10)).cuda()
opt = torch.optim.AdamW(model.parameters(), lr=3e-4)
for xb, yb in loader:
xb, yb = xb.cuda(), yb.cuda()
opt.zero_grad(set_to_none=True)
loss = F.cross_entropy(model(xb), yb)
loss.backward()
opt.step()Keras — the loop is hidden
import keras
model = keras.Sequential([keras.layers.Dense(256, activation="relu"),
keras.layers.Dense(10)])
model.compile(optimizer="adamw",
loss=keras.losses.SparseCategoricalCrossentropy(from_logits=True),
metrics=["accuracy"])
model.fit(x_train, y_train, epochs=5, validation_split=0.1)JAX — pure functions, explicit state, composed transformations
import jax, jax.numpy as jnp, optax
def loss_fn(params, x, y):
logits = apply_fn(params, x)
return optax.softmax_cross_entropy_with_integer_labels(logits, y).mean()
@jax.jit # compile the whole step
def step(params, opt_state, x, y):
loss, grads = jax.value_and_grad(loss_fn)(params, x, y)
updates, opt_state = optimizer.update(grads, opt_state, params)
return optax.apply_updates(params, updates), opt_state, loss
per_sample_grads = jax.vmap(jax.grad(loss_fn), in_axes=(None, 0, 0)) # free batchingtorch.compile — one line, no code change
model = torch.compile(model) # traces, fuses, generates kernels
# first call pays compilation cost; watch for graph breaks on data-dependent control flowTraining and scaling
Accelerate — your loop, unchanged, now distributed
from accelerate import Accelerator
acc = Accelerator(mixed_precision="bf16", gradient_accumulation_steps=4)
model, opt, loader = acc.prepare(model, opt, loader)
for xb, yb in loader:
with acc.accumulate(model):
loss = F.cross_entropy(model(xb), yb)
acc.backward(loss) # replaces loss.backward()
opt.step(); opt.zero_grad()
# same script runs on 1 GPU or 64 via `accelerate launch`Lightning — structure enforced
import lightning as L
class Lit(L.LightningModule):
def __init__(self, net): super().__init__(); self.net = net
def training_step(self, batch, _):
x, y = batch
loss = F.cross_entropy(self.net(x), y)
self.log("train_loss", loss, prog_bar=True)
return loss
def configure_optimizers(self):
return torch.optim.AdamW(self.parameters(), lr=3e-4)
L.Trainer(max_epochs=10, accelerator="gpu", devices=4, precision="bf16-mixed").fit(Lit(net), loader)FSDP — shard the model across ranks
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
from torch.distributed.fsdp.wrap import transformer_auto_wrap_policy
import functools
model = FSDP(
model,
auto_wrap_policy=functools.partial(transformer_auto_wrap_policy,
transformer_layer_cls={TransformerBlock}),
device_id=torch.cuda.current_device(),
)
# parameters are gathered per layer during use and released afterDeepSpeed ZeRO-3 with offload
{ "zero_optimization": {
"stage": 3,
"offload_optimizer": {"device": "cpu", "pin_memory": true},
"offload_param": {"device": "cpu", "pin_memory": true}},
"bf16": {"enabled": true},
"gradient_accumulation_steps": 8 }Model libraries and fine-tuning
Transformers — load, tokenise, generate
from transformers import AutoTokenizer, AutoModelForCausalLM
import torch
tok = AutoTokenizer.from_pretrained(model_id)
m = AutoModelForCausalLM.from_pretrained(model_id, torch_dtype=torch.bfloat16, device_map="auto")
ids = tok("Explain KV caching in one sentence.", return_tensors="pt").to(m.device)
out = m.generate(**ids, max_new_tokens=120, do_sample=False)
print(tok.decode(out[0], skip_special_tokens=True))timm — a vision backbone in two lines
import timm
model = timm.create_model("convnext_tiny", pretrained=True, num_classes=10)
cfg = timm.data.resolve_model_data_config(model)
tf = timm.data.create_transform(**cfg, is_training=True) # matching preprocessingPEFT — LoRA on a frozen base
from peft import LoraConfig, get_peft_model
cfg = LoraConfig(r=16, lora_alpha=32, lora_dropout=0.05, bias="none",
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
task_type="CAUSAL_LM")
model = get_peft_model(base_model, cfg)
model.print_trainable_parameters() # typically well under 1% of parameters
model.save_pretrained("adapter/") # megabytes, not gigabytesTRL — preference tuning with DPO
from trl import DPOTrainer, DPOConfig
trainer = DPOTrainer(
model=model, ref_model=None, # None -> uses the frozen adapter base
args=DPOConfig(beta=0.1, learning_rate=5e-6, per_device_train_batch_size=2),
train_dataset=pref_ds, # prompt / chosen / rejected
processing_class=tok,
)
trainer.train()Inference and serving
vLLM — offline batch and served endpoint
from vllm import LLM, SamplingParams
llm = LLM(model=model_id, tensor_parallel_size=2, gpu_memory_utilization=0.90)
outs = llm.generate(prompts, SamplingParams(temperature=0.7, max_tokens=256))
# or serve an OpenAI-compatible API:
# vllm serve MODEL_ID --tensor-parallel-size 2 --max-model-len 8192ONNX export and runtime
torch.onnx.export(model, sample_input, "model.onnx",
input_names=["input"], output_names=["logits"],
dynamic_axes={"input": {0: "batch"}, "logits": {0: "batch"}},
opset_version=17)
import onnxruntime as ort
sess = ort.InferenceSession("model.onnx", providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
logits = sess.run(None, {"input": x.numpy()})[0]Quantised local inference
# llama.cpp / GGUF via python bindings
from llama_cpp import Llama
llm = Llama(model_path="model-Q4_K_M.gguf", n_ctx=8192, n_gpu_layers=-1)
print(llm("Summarise this in one line:", max_tokens=64)["choices"][0]["text"])Classical ML and tracking
scikit-learn — pipeline prevents leakage
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.model_selection import cross_val_score
pre = ColumnTransformer([("num", StandardScaler(), num_cols),
("cat", OneHotEncoder(handle_unknown="ignore"), cat_cols)])
pipe = Pipeline([("pre", pre), ("clf", LogisticRegression(max_iter=1000))])
print(cross_val_score(pipe, X, y, cv=5, scoring="roc_auc").mean())
# preprocessing is fitted inside each fold — scaling before the split leaksXGBoost with early stopping
import xgboost as xgb
clf = xgb.XGBClassifier(n_estimators=2000, learning_rate=0.05, max_depth=6,
subsample=0.8, colsample_bytree=0.8,
early_stopping_rounds=50, eval_metric="auc")
clf.fit(X_tr, y_tr, eval_set=[(X_va, y_va)], verbose=100)
print(clf.best_iteration)Tracking a run
import wandb
wandb.init(project="proj", config={"lr": 3e-4, "bs": 32})
wandb.log({"train_loss": loss.item(), "val_auc": auc}, step=global_step)
# or MLflow, self-hosted
import mlflow
with mlflow.start_run():
mlflow.log_params({"lr": 3e-4}); mlflow.log_metric("val_auc", auc)
mlflow.pytorch.log_model(model, "model", registered_model_name="churn")Optuna with pruning
import optuna
def objective(trial):
lr = trial.suggest_float("lr", 1e-5, 1e-2, log=True)
depth = trial.suggest_int("max_depth", 3, 10)
for epoch in range(n_epochs):
score = train_one_epoch(lr, depth)
trial.report(score, epoch)
if trial.should_prune(): raise optuna.TrialPruned() # abandon losers early
return score
study = optuna.create_study(direction="maximize", pruner=optuna.pruners.MedianPruner())
study.optimize(objective, n_trials=50)Technical vocabulary
Every technical term used on this page, defined in one or two sentences, with the maths where it clarifies and a line of code where it makes it concrete. Grouped by where you meet them.
1 · Training basics
An array with any number of dimensions, held on CPU or GPU. A number is 0-D, a vector 1-D, an image 3-D, a batch of images 4-D.
image batch: [N, C, H, W] = [32, 3, 224, 224]
x = torch.randn(32, 3, 224, 224, device="cuda")
x.shape, x.dtype, x.deviceThe numbers the model learns. "Weights" usually means the multiplicative ones, "parameters" means all of them including biases. Model size is quoted in parameter count — 8B means eight billion.
y = Wx + b → W and b are the parameters
sum(p.numel() for p in model.parameters())Running input through the model to get an output. Nothing is learned yet; this is just the prediction.
logits = model(x)A single number saying how wrong the prediction is. Training is the process of making it smaller.
L = f(prediction, target) → lower is better
loss = F.cross_entropy(logits, y)The model's raw output scores, before they are turned into probabilities. They can be any real number, positive or negative.
logits ∈ ℝ vs probabilities ∈ [0, 1] summing to 1
logits = model(x) # e.g. [2.1, -0.4, 0.9]
probs = torch.softmax(logits, -1) # e.g. [0.68, 0.06, 0.21]Turns a vector of logits into probabilities that sum to 1. Exponentiating first means the largest logit dominates, which is why it is called soft max.
softmax(z)ᵢ = e^(zᵢ) / Σⱼ e^(zⱼ)
probs = torch.softmax(logits, dim=-1)Squashes one number into the range 0 to 1. Used for binary problems where each output is an independent yes/no rather than a choice between classes.
σ(z) = 1 / (1 + e^(−z))
p = torch.sigmoid(logit) # single probabilityThe standard classification loss. It measures how far the predicted probability distribution is from the true one. Because the true distribution puts all its mass on the correct class, it reduces to the negative log of the probability the model gave that class.
H(y, ŷ) = −Σᵢ yᵢ · log ŷᵢ → for true class c: −log ŷ_c
Predict 0.9 for the right answer and the loss is 0.105. Predict 0.1 and it is 2.303. Predict near zero and it explodes — which is exactly the intent: confident and wrong should hurt.
loss = F.cross_entropy(logits, targets) # takes LOGITS, applies softmax itself
# equivalent, the long way:
loss = F.nll_loss(torch.log_softmax(logits, -1), targets)The two-class version, applied independently to each output. Used for yes/no problems and multi-label problems where several labels can be true at once.
L = −[ y·log ŷ + (1−y)·log(1−ŷ) ]
loss = F.binary_cross_entropy_with_logits(logit, target) # fuses sigmoid, numerically saferThe standard regression loss: average squared difference between prediction and target. Squaring punishes large errors far more than small ones, which also makes it sensitive to outliers.
MSE = (1/n) Σ (yᵢ − ŷᵢ)²
loss = F.mse_loss(pred, target)
loss = F.l1_loss(pred, target) # mean absolute error - less outlier-sensitiveThe derivative of the loss with respect to each parameter — how much the loss would change if that parameter moved slightly, and in which direction. Training moves every parameter a little way against its gradient.
∇L = [ ∂L/∂w₁, ∂L/∂w₂, … ]
loss.backward()
model.fc.weight.grad # the gradient for that layer's weightsComputing those gradients by applying the chain rule backwards through the network, layer by layer, reusing each layer's result for the one before it.
∂L/∂w = (∂L/∂out) · (∂out/∂w) — the chain rule
loss.backward() # fills .grad on every parameterThe machinery that records every operation performed on a tensor and can therefore differentiate the whole chain automatically. You never write a derivative by hand.
x = torch.tensor(3.0, requires_grad=True)
y = x ** 2
y.backward()
x.grad # tensor(6.) - because dy/dx = 2xThe record of which operations produced which tensors. PyTorch builds it as the code runs and throws it away after each backward pass; graph-mode frameworks build it once and reuse it.
loss.grad_fn # the node that produced this tensorThe rule that turns gradients into parameter updates. Plain SGD steps directly against the gradient; Adam and AdamW keep running averages of the gradient and its square so each parameter gets its own effective step size.
SGD: w ← w − α·∇L (α = learning rate)
opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.01)
opt.step() # apply the update
opt.zero_grad() # clear gradients, or they accumulateHow big a step to take against the gradient. Too high and training diverges; too low and it crawls or gets stuck. The single most important hyperparameter.
typical: 3e-4 for Adam, 1e-5 to 5e-5 for fine-tuning a pretrained model
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs)A batch is the group of examples processed together before one update. An epoch is one full pass over the training set. A step is one parameter update.
steps per epoch = dataset size ÷ batch size
for epoch in range(10):
for batch in dataloader: # one step per batch
...Summing gradients over several small batches before stepping, to simulate a large batch that would not fit in memory.
effective batch = micro-batch × accumulation steps × number of GPUs
(loss / accum).backward()
if (i + 1) % accum == 0:
opt.step(); opt.zero_grad()Overfitting is learning the training data itself rather than the pattern — training loss keeps falling while validation loss rises. Underfitting is the model being too weak to capture the pattern at all.
overfit: train ↓ val ↑ | underfit: both high
Anything that constrains the model so it generalises instead of memorising: weight decay, dropout, data augmentation, early stopping.
L_total = L + λ·‖w‖² (L2 / weight decay)
opt = torch.optim.AdamW(model.parameters(), weight_decay=0.01)
nn.Dropout(p=0.1)Stopping training when validation performance stops improving, and keeping the best checkpoint. Must use a validation set separate from the test set, or the reported score is optimistic.
clf.fit(X_tr, y_tr, eval_set=[(X_val, y_val)]) # xgboost: early_stopping_rounds=50Train fits the parameters. Validation tunes the choices you make by hand — learning rate, when to stop. Test is touched once, at the end. Reusing test for decisions turns it into a validation set and inflates the number you report.
2 · Numbers, memory and precision
How many bits each number uses. Fewer bits means less memory and faster arithmetic, at the cost of range or accuracy. bf16 keeps fp32's exponent range with fewer mantissa bits, which is why it trains stably where fp16 overflows.
fp32: 4 bytes · fp16: 2 · bf16: 2 · fp8: 1 · int8: 1 · int4: 0.5
model.to(torch.bfloat16)
# an 8B model: 32 GB at fp32, 16 GB at bf16, 4 GB at int4Doing most arithmetic in 16-bit while keeping a 32-bit copy of the weights and the loss accumulation, so you get the speed without the numerical instability.
with torch.autocast("cuda", dtype=torch.bfloat16):
loss = loss_fn(model(x), y)
loss.backward()Storing weights and activations as low-precision integers, with a scale factor to map them back. Post-training quantisation converts an already-trained model; quantisation-aware training simulates the rounding during training so the model adapts to it.
q = round(x / s) + z → x ≈ s · (q − z)
model = AutoModelForCausalLM.from_pretrained(mid, load_in_4bit=True)Not storing every intermediate activation for the backward pass, and recomputing them instead. Trades roughly 30% more compute for a large memory saving — often the difference between a model fitting and not.
from torch.utils.checkpoint import checkpoint
out = checkpoint(block, x) # recompute this block's activations laterDuring generation, each new token attends to every previous one. Rather than recomputing their keys and values every step, they are cached. It makes generation fast and it is the main consumer of memory when serving.
size ≈ 2 × layers × heads × head_dim × seq_len × batch × bytes
out = model.generate(ids, use_cache=True) # on by defaultGPU memory holds four things: weights, gradients, optimiser state, and activations. Adam keeps two extra values per parameter, so optimiser state alone is typically twice the model size.
fp32 training ≈ 16 bytes/param: 4 weights + 4 grads + 8 Adam state
3 · Execution and compilation
Operations run the moment you write them, like normal Python. Easy to debug, slower than a compiled graph because each operation is dispatched separately.
y = x + 1 # runs right now, y is a real tensor you can printRunning the function once to record the operations, then executing that recorded graph from then on. Faster, but any Python that depends on actual values is frozen at the value it had while tracing.
@tf.function # TensorFlow
traced = torch.jit.trace(model, example) # PyTorchJust-in-time: compiling at runtime, once the shapes and types are actually known, rather than ahead of time. The first call is slow; every later call is fast.
model = torch.compile(model) # first call compiles, then it pays offA single function that runs on the GPU. One line of Python may launch several. Launching a kernel has fixed overhead, so many tiny kernels waste time.
Combining several operations into one kernel so intermediate results stay in fast registers instead of being written to GPU memory and read back. Memory traffic, not arithmetic, is usually the bottleneck.
unfused: read → add → write → read → relu → write fused: read → add → relu → write
A compiler that takes a whole graph of array operations and emits optimised machine code, fusing aggressively. It is what JAX and TensorFlow use, and how TPUs are targeted.
A point where the compiler cannot trace further — usually Python control flow that depends on a tensor's value — so it falls back to eager execution for that part. Correctness is preserved; speed is quietly lost.
print(torch._dynamo.explain(model)(x).break_reasons)The order dimensions are stored in. PyTorch defaults to channels-first, TensorFlow to channels-last. Same numbers, different memory order — which is why conversion between frameworks means transposing weights.
NCHW: [batch, channels, height, width] · NHWC: [batch, height, width, channels]
x.permute(0, 2, 3, 1) # NCHW → NHWC
x.to(memory_format=torch.channels_last) # same shape, different layoutAn open file format describing a model as a graph of standard operators, so a model trained in one framework can be run by another runtime.
torch.onnx.export(model, dummy, "model.onnx", opset_version=17)A saved snapshot of the parameters. PyTorch's state_dict is a plain dictionary of tensors and carries no code, which is why the model class must be defined to load it.
torch.save(model.state_dict(), "ckpt.pt")
model.load_state_dict(torch.load("ckpt.pt"))4 · Distributed training
Every GPU holds a full copy of the model and processes a different slice of the batch. Gradients are averaged across GPUs after each backward pass so all copies stay identical.
memory per GPU = full model → largest model is capped by one device
model = torch.nn.parallel.DistributedDataParallel(model, device_ids=[rank])The collective operation that sums each GPU's gradients and gives every GPU the result. It is the communication cost that decides how well data parallelism scales.
each rank ends with: g = (g₀ + g₁ + … + g_{n−1}) / n
Splitting something across devices so each holds only a slice, rather than replicating it. What lifts the single-GPU memory ceiling.
Sharding applied to parameters, gradients, and optimiser state. Each layer's parameters are gathered just before use and released straight after. ZeRO's three stages shard progressively more: optimiser state, then gradients, then parameters.
stage 1: optimiser · stage 2: + gradients · stage 3: + parameters
Splitting a single matrix multiplication across GPUs — each computes part of the output, then the parts are combined. Needs fast interconnect, so it is normally kept within one node.
Y = XW → W split by columns: Y = [XW₁ | XW₂]
Putting different layers on different GPUs and passing activations along. The problem is the "bubble" — GPUs idle while waiting — which is reduced by splitting the batch into micro-batches that flow through continuously.
Using data, tensor, and pipeline parallelism together. Standard for frontier-scale pretraining.
total GPUs = data × tensor × pipeline
Moving parameters or optimiser state to CPU RAM or NVMe when not in use, and fetching them back on demand. Makes otherwise impossible runs possible, at a real throughput cost.
World size is the total number of processes; rank is one process's index; local rank is its index on its own machine. A node is one machine, usually with 8 GPUs.
$ torchrun --nproc_per_node 8 --nnodes 4 train.py5 · Models and layers
A matrix multiply plus a bias. The basic building block; most of a transformer's parameters and compute live in these.
y = xWᵀ + b
nn.Linear(in_features=768, out_features=3072)The non-linear step between layers. Without one, stacking linear layers collapses into a single linear layer and depth buys nothing.
ReLU(x) = max(0, x) · GELU(x) ≈ x · Φ(x)
nn.ReLU(), nn.GELU(), nn.SiLU()Sliding a small learned filter across an image and taking a weighted sum at each position. The same filter everywhere means far fewer parameters than a linear layer, and a feature detected in one place is detected anywhere.
out[i,j] = Σ_m Σ_n in[i+m, j+n] · k[m,n]
nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1)Rescaling activations to a stable mean and variance so training does not destabilise. BatchNorm normalises across the batch; LayerNorm and RMSNorm normalise across features, which is what transformers use.
LN(x) = γ · (x − μ) / √(σ² + ε) + β
nn.LayerNorm(768)A lookup table mapping a discrete id — a token, a category — to a learned vector. Similar meanings end up as nearby vectors.
vocab 32000 × dim 4096 → 131M parameters in that one table
emb = nn.Embedding(32000, 4096)
emb(torch.tensor([15, 892])) # [2, 4096]Models read integers, not text. The tokeniser splits text into sub-word pieces and maps each to an id. Roughly 4 characters per token for English.
tok("Hello world")["input_ids"] # [9906, 1917]
tok.decode([9906, 1917]) # "Hello world"Each position produces a query, a key, and a value. It compares its query against every key to get weights, then takes a weighted sum of the values. That is how a token pulls in context from the rest of the sequence.
Attention(Q,K,V) = softmax(QKᵀ / √d_k) · V
The √d_k divisor keeps the dot products from growing with dimension and pushing softmax into a region with vanishing gradients. Cost is quadratic in sequence length, which is why long context is expensive.
out = F.scaled_dot_product_attention(q, k, v, is_causal=True)Running attention several times in parallel on different learned projections, then concatenating. Each head can specialise — one on syntax, another on long-range reference.
head_dim = model_dim / n_heads (e.g. 4096 / 32 = 128)
nn.MultiheadAttention(embed_dim=4096, num_heads=32, batch_first=True)Attention followed by a small feed-forward network, each wrapped in a residual connection and a normalisation. Stack a few dozen of these and you have a modern model.
x ← x + Attn(LN(x)); x ← x + FFN(LN(x))
Adding a layer's input to its output. It gives the gradient a direct path back through the network, which is what makes very deep models trainable.
y = x + F(x)
An encoder reads the whole input at once and produces representations — good for classification and retrieval. A decoder generates one token at a time and may only look backwards — good for generation.
encoder: bidirectional (BERT) · decoder: causal (GPT, Llama)
The backbone is the pretrained feature extractor; the head is the small task-specific layer bolted on top. Transfer learning is keeping the backbone and replacing the head.
model = timm.create_model("resnet50", pretrained=True, num_classes=10) # new headReducing many vectors to one — by mean, by max, or by taking a designated token. How a sequence of token vectors becomes a single sentence embedding.
sentence = token_embeddings.mean(dim=1) # mean pooling6 · Fine-tuning and alignment
Pretraining learns general structure from a very large unlabelled corpus and costs millions. Fine-tuning adapts that model to your task with a small labelled set and costs hours.
Marking parameters as not trainable so gradients are not computed or stored for them. The cheapest way to cut memory when only part of a model needs to change.
for p in model.parameters():
p.requires_grad = FalseInstead of updating a weight matrix, learn a low-rank correction to it. Two thin matrices multiply out to the same shape as the original, so the frozen weight stays untouched and the update is tiny.
W' = W + BA where B: d×r, A: r×k, and r ≪ min(d,k)
With d = k = 4096 and r = 16, that is 33.5M parameters replaced by 131K — about 0.4%.
LoraConfig(r=16, lora_alpha=32, target_modules=["q_proj", "v_proj"])LoRA on top of a base model quantised to 4-bit. The frozen base takes a quarter of the memory, the adapters train in 16-bit, and a 70B fine-tune fits on a single large GPU.
Any small trainable module added to a frozen model. Adapters are megabytes, can be swapped at inference, and several can be composed over one base model.
Training on example prompt-and-answer pairs so the model learns the format and behaviour you want. The first stage of post-training.
Reinforcement learning from human feedback. Humans rank model outputs, a reward model is trained to predict those rankings, and the model is then optimised against that reward — classically with PPO.
SFT → reward model → PPO against the reward, with a KL penalty to the SFT model
Direct preference optimisation. It shows that the RLHF objective can be rewritten as a simple classification loss over preferred and rejected pairs — no separate reward model, no RL loop.
L = −log σ( β · [ log(π/π_ref)(chosen) − log(π/π_ref)(rejected) ] )
Fine-tuning hard on a narrow task degrades the general ability the model had. Lower learning rates, fewer epochs, and adapter methods that leave the base weights frozen all limit it.
7 · Inference and serving
Latency is how long one request takes. Throughput is how many complete per second. Batching improves throughput and worsens latency, so you cannot maximise both.
for LLMs: TTFT (time to first token) and TPOT (time per output token)
Percentiles of the latency distribution. p95 = 200 ms means 95% of requests finished within 200 ms. Report percentiles, not the mean — the mean hides the tail that users actually feel.
torch.tensor(times).quantile(0.95)Static batching fixes the group before it runs. Dynamic batching waits a few milliseconds to gather arriving requests. Continuous batching, used for LLMs, lets a finished sequence leave the batch and a new one join mid-flight rather than waiting for the slowest.
Storing the KV cache in fixed-size blocks like operating-system memory pages, instead of one contiguous allocation per sequence. It removes the wasted space from over-allocating for a maximum length, so far more requests fit at once.
Requests that begin with the same tokens — a shared system prompt, the same tool definitions — reuse the cached KV entries for that prefix instead of recomputing it every time.
Prefill processes the whole prompt in parallel and is compute-bound. Decode generates one token at a time and is memory-bandwidth-bound. They have opposite bottlenecks, which is why serving engines schedule them separately.
The first calls to a model are slow — memory allocation, compilation, caches filling. Always discard them before measuring, or the numbers are meaningless.
for _ in range(20): model(x) # warm up
torch.cuda.synchronize() # GPU work is async - sync before timingA model compiled for one specific GPU architecture, precision, and shape range, as TensorRT produces. Fastest available, and not portable — a new GPU generation means a new build.
8 · Retrieval and embeddings
A fixed-length list of numbers representing a piece of text, an image, or an item, positioned so that similar things are close together.
emb = model.encode(["a sentence"], normalize_embeddings=True) # [1, 768]The angle between two vectors, ignoring their length. 1 means identical direction, 0 unrelated, −1 opposite. On normalised vectors it is just the dot product.
cos(a,b) = (a · b) / (‖a‖ ‖b‖)
sim = F.cosine_similarity(a, b, dim=-1)
sim = a_norm @ b_norm.T # same thing if both are unit lengthA bi-encoder embeds query and document separately, so documents can be indexed in advance and search is fast. A cross-encoder feeds the pair through together, which is far more accurate and far too slow to run over a whole corpus.
bi-encoder: encode once, compare many · cross-encoder: one forward pass per pair
Retrieve a shortlist cheaply, then reorder it with the expensive accurate model. Most practical retrieval quality gains come from this two-stage pattern rather than a better first-stage model.
Approximate nearest neighbour. Exact search compares against every vector; approximate methods trade a little recall for orders-of-magnitude speed, which is what makes million-vector search practical.
Retrieval-augmented generation: fetch relevant documents and put them in the prompt so the model answers from supplied evidence rather than memory.
9 · Vision and diffusion terms
Intersection over union — the overlap between two boxes divided by their combined area. 0 means no overlap, 1 means identical.
IoU = area(A ∩ B) / area(A ∪ B)
from torchvision.ops import box_iou
box_iou(pred_boxes, gt_boxes)Non-maximum suppression. A detector fires several times on the same object; NMS keeps the highest-confidence box and discards any box overlapping it beyond an IoU threshold.
keep = torchvision.ops.nms(boxes, scores, iou_threshold=0.5)Mean average precision — area under the precision-recall curve, averaged over classes. COCO's mAP@[.5:.95] averages over ten IoU thresholds, so it rewards tight boxes and not merely finding the object.
An encoder-decoder with skip connections carrying high-resolution detail across from encoder to decoder. Standard for segmentation, and the standard denoiser inside diffusion models.
An autoencoder that encodes to a distribution rather than a point, giving a continuous latent space you can sample from. In latent diffusion it is the compressor that lets generation run in a small space instead of on pixels.
z = μ + σ · ε, ε ~ N(0, 1) — the reparameterisation trick
Noise is added to an image over many steps, and a network learns to remove it one step at a time. The scheduler decides how much noise each step carries and how sampling walks back; fewer steps means faster and rougher.
x_t = √(ᾱ_t)·x₀ + √(1−ᾱ_t)·ε — jump to any noise level directly
Classifier-free guidance. The model predicts with and without the prompt, and sampling extrapolates away from the unconditional prediction. Higher values follow the prompt more literally and reduce variety.
ε = ε_uncond + w · (ε_cond − ε_uncond) (w ≈ 6–8 typical)
10 · Classical ML
A series of yes/no splits on feature values, ending in a prediction. Individually weak and prone to overfitting, which is why they are used in ensembles.
Building trees one after another, each fitted to the errors the ensemble has made so far. Each tree corrects its predecessors rather than voting independently.
F_m(x) = F_{m−1}(x) + η · h_m(x) where h_m fits the residual
Bagging trains many models independently on resamples and averages them, reducing variance — that is a random forest. Boosting trains them in sequence, reducing bias.
Level-wise grows every node at a depth before going deeper; leaf-wise always splits whichever leaf reduces loss most. Leaf-wise is more accurate per tree and overfits more easily on small data.
{"num_leaves": 63, "min_data_in_leaf": 100} # the two that control itBucketing continuous feature values into a few hundred bins before searching for splits, so the search is over bins rather than every distinct value. The main reason LightGBM trains fast.
Turning categories into numbers. One-hot creates a column per value and explodes on high-cardinality features; target encoding replaces a category with the mean outcome for it and leaks unless computed carefully within folds.
When one class is much rarer than the other, a model that never predicts it can still be 99% accurate. Fix by weighting the loss, not by trusting accuracy.
scale_pos_weight = n_negative / n_positive
Splitting the data into k folds, training on k−1 and validating on the held-out one, k times. Gives a more reliable estimate than a single split, and a spread that tells you how stable it is.
cross_val_score(pipe, X, y, cv=5, scoring="roc_auc")Information in the training features that would not be available at prediction time — including preprocessing fitted on the full dataset before splitting. It inflates offline scores and disappears in production.
Pipeline([("scale", StandardScaler()), ("clf", model)]) # scaler refits per foldROC AUC is the probability the model ranks a random positive above a random negative — 0.5 is chance. PR AUC uses precision and recall instead, and is the more informative of the two when positives are rare.
precision = TP/(TP+FP) · recall = TP/(TP+FN)
11 · Experiment workflow
A setting you choose rather than one the model learns — learning rate, tree depth, LoRA rank, batch size.
Grid tries every combination and wastes effort on parameters that do not matter. Random sampling beats it in practice. Bayesian search builds a model of which regions look promising and samples there.
Stopping a hyperparameter trial early once it is clearly behind, so the budget goes to candidates that might win.
trial.report(auc, step)
if trial.should_prune(): raise optuna.TrialPruned()Recording the config, code version, metrics, and outputs of every run, so a result months later can be traced back to exactly what produced it.
Any file a run produces and you may need again — a checkpoint, a dataset snapshot, a plot — stored with the run rather than beside it on someone's laptop.
A versioned catalogue of trained models with stages such as Staging and Production, and a link back to the run that produced each. The audit trail a governed release process needs.
Being able to rerun and get the same result. Needs the seed, the library versions, the data version, and the config — and on GPU, still not exactly bit-identical unless you force deterministic kernels.
torch.manual_seed(0)
torch.use_deterministic_algorithms(True) # slower, but repeatableRelated: Stacks covers the concepts these frameworks implement, and Vision & Multimodal AI goes deeper on the vision side.
