Model weights are the learned numerical parameters that define how a neural network transforms input into output. During training, these values adjust through backpropagation to minimize a loss function, encoding statistical patterns from the training data into the model’s computational graph. At inference time, weights are fixed constants that determine every prediction the model makes.
How model weights work
A neural network is a composition of differentiable functions. Each layer applies a linear transformation followed by a non-linear activation. The linear transformation is a matrix multiplication (or convolution) where the matrix entries are the weights. Biases are technically weights too — they’re just the parameters connected to a constant input of 1.
Consider a single linear layer:
import torch
import torch.nn as nn
layer = nn.Linear(in_features=768, out_features=768)
x = torch.randn(1, 10, 768) # batch=1, seq_len=10, hidden=768
y = layer(x)
# The weight matrix shape: [out_features, in_features]
print(layer.weight.shape) # torch.Size([768, 768])
print(layer.bias.shape) # torch.Size([768])
The weight matrix W has shape [output_dim, input_dim]. Each row represents the weights connecting all inputs to a single output neuron. During the forward pass, the layer computes y = x @ W.T + b. During backpropagation, gradients flow backward through this operation, and the optimizer updates W and b to reduce loss.
In a transformer, weights appear in several distinct places:
- Token embeddings: A lookup table mapping vocabulary indices to vectors. Shape
[vocab_size, hidden_dim]. These are weights — they’re learned, not fixed. - Position embeddings: Either learned (absolute) or computed (RoPE). Learned position embeddings are weights.
- Attention projections: Four matrices per layer —
W_q,W_k,W_v,W_o. Each mapshidden_dimtohidden_dim(ornum_heads * head_dim). - Feed-forward networks: Two linear layers per transformer block, typically expanding to
4 * hidden_dimthen projecting back. - Layer normalization: Scale (
gamma) and shift (beta) parameters per feature dimension. - Output head: Often tied to the input embedding matrix (weight tying), sometimes separate.
A 7B parameter model like Llama 2 7B has roughly 7 billion scalar values across all these matrices. At 16-bit precision (bfloat16), that’s 14 GB of raw weight data. At 4-bit quantization, roughly 4 GB.
How weights are learned
Training is the process of finding weight values that make the model useful. The standard recipe:
- Initialize weights randomly (or from a pretrained checkpoint). Common schemes: Xavier/Glorot, He/Kaiming, or truncated normal. Proper initialization prevents gradient explosion or vanishing in deep networks.
- Forward pass: Compute predictions on a batch of data.
- Compute loss: Compare predictions to targets (cross-entropy for language modeling).
- Backward pass: Compute gradients of loss with respect to every weight via automatic differentiation.
- Update: Apply an optimizer step (AdamW, SGD with momentum, etc.) to adjust weights in the direction that reduces loss.
- Repeat for millions of steps across terabytes of data.
The loss landscape is non-convex and high-dimensional. There isn’t a single global minimum — there are many equivalent (or near-equivalent) solutions related by symmetries like neuron permutation. What matters is finding a region that generalizes.
Key training dynamics that affect final weights:
- Learning rate schedule: Warmup, cosine decay, or constant-with-cooldown. The final learning rate determines how close weights settle to a local minimum.
- Weight decay: L2 regularization on weights (not biases, typically). Prevents weights from growing arbitrarily large, improves generalization. In AdamW, weight decay is decoupled from the adaptive learning rate.
- Gradient clipping: Prevents occasional large gradient norms from destabilizing training. Standard practice: clip at 1.0.
- Mixed precision: Training in bfloat16 or fp16 with fp32 master weights. Reduces memory, speeds up computation on tensor cores. Requires loss scaling to prevent underflow.
After pretraining, weights may be further adjusted via:
- Supervised fine-tuning (SFT): Continue training on instruction/response pairs.
- Preference optimization (DPO, PPO, KTO): Adjust weights to align with human preferences without explicit reward modeling.
- Continued pretraining: Train on domain-specific corpora (code, legal, medical) to adapt knowledge.
Each phase changes the weights. The final checkpoint is the artifact you deploy.
Why weights matter for engineers
Weights are the model. Architecture defines the capacity and inductive biases; weights determine the actual function computed. This has practical consequences:
Checkpoint size dictates infrastructure. A 70B parameter model at bfloat16 needs ~140 GB VRAM just for weights. At 4-bit quantization, ~40 GB. You need multiple GPUs (tensor parallelism, pipeline parallelism) or CPU offloading. Weight size directly determines your serving hardware budget.
Weight precision affects quality and speed. Quantization (int8, int4, fp8) compresses weights with minimal quality loss — if done carefully. Post-training quantization (PTQ) calibrates on a small dataset. Quantization-aware training (QAT) simulates quantization during training for better recovery. The choice impacts latency, throughput, and perplexity.
Weight sharing enables efficiency. Tying input and output embeddings saves vocab_size * hidden_dim parameters. Grouped-query attention shares key/value projections across heads. Mixture-of-experts (MoE) activates only a subset of weights per token. These architectural choices reduce active parameter count without reducing total weight count.
Weight updates enable adaptation. LoRA (Low-Rank Adaptation) freezes base weights and trains small adapter matrices (A @ B where A: [r, d], B: [d, r], r << d). Only ~0.1-1% of parameters update. The base weights stay unchanged; adapters merge into them for deployment. This makes fine-tuning feasible on consumer GPUs.
Weight inspection reveals behavior. Activation patching, logit lens, and probing classifiers all operate on fixed weights to understand what the model computes. You can’t interpret a model without accessing its weights.
Concrete example: tracing a forward pass
Here’s a minimal GPT-style block showing where weights live and how they transform data:
import torch
import torch.nn as nn
import torch.nn.functional as F
class Attention(nn.Module):
def __init__(self, dim: int, n_heads: int):
super().__init__()
self.n_heads = n_heads
self.head_dim = dim // n_heads
# Weight matrices: 4 * dim * dim parameters
self.wq = nn.Linear(dim, dim, bias=False)
self.wk = nn.Linear(dim, dim, bias=False)
self.wv = nn.Linear(dim, dim, bias=False)
self.wo = nn.Linear(dim, dim, bias=False)
def forward(self, x: torch.Tensor) -> torch.Tensor:
b, t, d = x.shape
# Project to q, k, v - each uses its own weight matrix
q = self.wq(x).view(b, t, self.n_heads, self.head_dim).transpose(1, 2)
k = self.wk(x).view(b, t, self.n_heads, self.head_dim).transpose(1, 2)
v = self.wv(x).view(b, t, self.n_heads, self.head_dim).transpose(1, 2)
# Scaled dot-product attention
attn = (q @ k.transpose(-2, -1)) / (self.head_dim ** 0.5)
attn = F.softmax(attn, dim=-1)
out = attn @ v # [b, n_heads, t, head_dim]
# Merge heads and project out
out = out.transpose(1, 2).contiguous().view(b, t, d)
return self.wo(out)
class FeedForward(nn.Module):
def __init__(self, dim: int, hidden_dim: int):
super().__init__()
# Two linear layers: dim -> hidden_dim -> dim
self.w1 = nn.Linear(dim, hidden_dim, bias=False)
self.w2 = nn.Linear(hidden_dim, dim, bias=False)
self.w3 = nn.Linear(dim, hidden_dim, bias=False) # SwiGLU gate
def forward(self, x: torch.Tensor) -> torch.Tensor:
# SwiGLU: silu(w1(x)) * w3(x) -> w2
return self.w2(F.silu(self.w1(x)) * self.w3(x))
class TransformerBlock(nn.Module):
def __init__(self, dim: int, n_heads: int, hidden_dim: int):
super().__init__()
self.attn = Attention(dim, n_heads)
self.ffn = FeedForward(dim, hidden_dim)
self.norm1 = nn.RMSNorm(dim)
self.norm2 = nn.RMSNorm(dim)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = x + self.attn(self.norm1(x))
x = x + self.ffn(self.norm2(x))
return x
# Count parameters
block = TransformerBlock(dim=4096, n_heads=32, hidden_dim=11008)
total_params = sum(p.numel() for p in block.parameters())
print(f"Parameters in one block: {total_params:,}") # ~140M for Llama-2-7B config
Each nn.Linear wraps a weight matrix. nn.RMSNorm has a single weight vector (scale). No biases in this architecture — modern LLMs often omit them. The total parameter count matches what you’d see in a model card.
When you load a checkpoint:
from safetensors.torch import load_file
state_dict = load_file("model.safetensors")
# Keys look like:
# "model.layers.0.attention.wq.weight" -> [4096, 4096]
# "model.layers.0.feed_forward.w1.weight" -> [11008, 4096]
# "model.norm.weight" -> [4096]
Every key in that state dict is a weight tensor. The values are the learned numbers.
Common misconceptions
Misconception: “Weights are just the embedding matrix.” The token embedding matrix is often the largest single weight tensor (e.g., 32K vocab × 4096 dim = 131M parameters), but it’s only ~2% of a 7B model. The majority of weights live in attention projections and feed-forward layers across dozens of layers. Don’t optimize only embeddings.
Misconception: “Quantization just means lower precision.” Naive quantization (rounding fp16 to int8) destroys quality. Proper quantization requires calibration data to determine per-channel or per-tensor scales, and often per-group scales for 4-bit. The quantization scheme (symmetric vs asymmetric, per-tensor vs per-channel vs group-wise) matters as much as the bit width. GPTQ, AWQ, and HQQ are algorithms that optimize the quantized weights to minimize reconstruction error — they’re not just casting.
Misconception: “Fine-tuning changes all weights equally.” In full fine-tuning, every weight gets a gradient update. But the magnitude of change varies wildly. Lower layers (closer to input) change less; they encode general linguistic features. Upper layers change more; they encode task-specific behavior. LoRA exploits this by only adapting a low-rank subspace. Understanding which weights move helps diagnose overfitting and catastrophic forgetting.
Misconception: “Model weights contain the training data.” Weights are a compressed, lossy representation of statistical patterns in the training data. They don’t store documents verbatim (except in cases of extreme overfitting or data duplication). You cannot “extract” training data from weights reliably — membership inference attacks exist but have low precision. Weights generalize; they don’t memorize in the database sense.
Misconception: “Two models with the same architecture and weights behave identically.” True only if everything else matches: tokenizer, inference dtype, sampling parameters, kernel implementations, and hardware numerics. Different flash attention implementations, different CUDA/cuDNN versions, or CPU vs GPU execution can produce bitwise-different logits from identical weights. Deterministic inference requires controlling the full stack.
Misconception: “Weight count equals model capacity.” Parameter count is a rough proxy. Architecture matters: MoE models have high total parameters but low active parameters per token. Depth vs width tradeoffs affect expressivity. Training compute (FLOPs) correlates better with capability than parameter count alone. Chinchilla scaling laws suggest optimal parameter count for a given compute budget.
Misconception: “You need to understand every weight to use a model.” You don’t. You need to understand the weight artifact: its format (safetensors, pytorch_bin, GGUF), its precision, its size, and how to load it. The internals matter when you’re quantizing, fine-tuning, merging, or debugging. For standard inference, treat weights as an opaque blob that the runtime knows how to execute.
Weight formats and storage
Engineers encounter weights in several serialization formats:
| Format | Description | Use case |
|---|---|---|
safetensors |
Memory-mappable, no pickle, fast load | Standard for Hugging Face Hub, production serving |
pytorch_model.bin |
Pickle-based, legacy | Older checkpoints, some research code |
GGUF |
Quantized, CPU-optimized, single file | llama.cpp, edge deployment |
ONNX |
Graph + weights, cross-framework | Interop, some inference engines |
TensorRT engine |
Compiled plan + weights | NVIDIA GPU max performance |
Safetensors is the de facto standard for distribution. It stores tensors in a flat binary blob with a JSON header describing shapes, dtypes, and offsets. You can memory-map it and read individual tensors without loading the whole file — critical for large models on memory-constrained systems.
from safetensors.torch import load_file, safe_open
# Load entire state dict (loads all into RAM)
state_dict = load_file("model.safetensors")
# Or memory-map and read selectively
with safe_open("model.safetensors", framework="pt", device="cpu") as f:
# Only loads this tensor
wq = f.get_tensor("model.layers.0.attention.wq.weight")
GGUF packs quantized weights with metadata (tokenizer, architecture, quantization scheme) in a single file. It’s designed for mmap + CPU inference with llama.cpp. The quantization type (Q4_K_M, Q8_0, etc.) is encoded in the file — no separate config needed.
Weight merging and arithmetic
Because weights are just tensors, you can do arithmetic on them. This enables practical techniques:
Linear merging (model soups): Average weights from multiple fine-tunes of the same base model.
# Simple average of two fine-tuned checkpoints
merged = {}
for key in state_dict_a:
merged[key] = (state_dict_a[key] + state_dict_b[key]) / 2
Task arithmetic: Add/subtract weight differences to compose behaviors.
# base + (finetune_A - base) + (finetune_B - base) = base + delta_A + delta_B
delta_a = {k: ft_a[k] - base[k] for k in base}
delta_b = {k: ft_b[k] - base[k] for k in base}
composed = {k: base[k] + delta_a[k] + delta_b[k] for k in base}
SLERP (spherical linear interpolation): Interpolate on the hypersphere to preserve weight norm.
import torch
def slerp(t, a, b):
# a, b: tensors of same shape
a_norm = a / a.norm()
b_norm = b / b.norm()
omega = torch.acos((a_norm * b_norm).sum().clamp(-1, 1))
return (torch.sin((1-t)*omega) * a + torch.sin(t*omega) * b) / torch.sin(omega)
These operations only work when models share exact architecture and initialization (same base). Merging across different architectures or tokenizers produces garbage.
Debugging weight issues
When a model behaves strangely, check weights first:
- NaN/Inf weights: Training instability, usually from high learning rate or missing gradient clipping. Check
torch.isnan(param).any(). - Weight norm explosion: Layer norm scale parameters growing without bound. Indicates missing weight decay or numerical issues in mixed precision.
- Dead neurons: Entire output channels with near-zero weights. Can happen with ReLU (not SwiGLU/GELU) and poor initialization.
- Embedding drift: Token embeddings diverging from pretrained values during fine-tuning. Often harmless but can hurt out-of-distribution generalization.
- Quantization artifacts: Quality drop after PTQ usually means outliers in weight distributions. Per-channel or group quantization helps. Check
weight.abs().max(dim=0).
Tooling: torchinfo.summary(model) for parameter counts per module. safetensors.torch.load_file + manual inspection for checkpoint auditing. llama.cpp tools for GGUF analysis.
Summary
Model weights are the learned parameters that define a neural network’s computation. They live in every linear projection, embedding table, and normalization layer. Training finds them; inference uses them; quantization compresses them; fine-tuning adjusts them; merging combines them. For an engineer deploying LLMs, weights are the primary artifact you manage — their size, precision, format, and provenance determine your infrastructure, latency, and quality. Understand what they are, where they live, and how they move through your pipeline. Everything else builds on that foundation.