skip to content

How does PyTorch initialize nn.Linear weights by default, and when do you override it?

level: middleimportance: should knowfreq 50%

answer

  1. layers call reset_parameters at construction
  2. fan-in scaled, not activation aware
  3. Xavier for tanh, Kaiming for ReLU
  4. model.apply walks every submodule
  5. matters most without normalization layers

basics

~10 s

nn.Linear initializes itself in reset_parameters: the weight with nn.init.kaiming_uniform_ (a=sqrt(5)) and the bias uniformly within ±1/sqrt(fan_in). Override with torch.nn.init functions applied through model.apply when the default's implied nonlinearity does not match yours.

solid answer

~40 s

Every built-in layer initializes itself in its own `reset_parameters()` at construction, so a freshly built `nn.Linear` already has sensible weights — you never start from zeros or uninitialized memory. For `nn.Linear` the weight comes from `nn.init.kaiming_uniform_(weight, a=math.sqrt(5))` and the bias from a uniform distribution bounded by `1/sqrt(fan_in)`. That default is fan-in scaled and deliberately conservative, but it assumes nothing about your activation function; if you build a deep ReLU stack you generally want `nn.init.kaiming_normal_(w, nonlinearity="relu")`, and for tanh or sigmoid networks `nn.init.xavier_uniform_`. The idiomatic way to apply a scheme across a model is `model.apply(fn)`, which walks every submodule so you can branch on `isinstance(m, nn.Linear)` and zero the biases with `nn.init.zeros_`. Initialization matters most in deep nets without normalization layers, where a bad scale compounds layer by layer into vanishing or exploding activations.

code

python · 14 lines
python
import torch.nn as nn

def init_weights(m):
    if isinstance(m, nn.Linear):
        nn.init.kaiming_normal_(m.weight, nonlinearity="relu")
        if m.bias is not None:
            nn.init.zeros_(m.bias)

model = nn.Sequential(
    nn.Linear(64, 64), nn.ReLU(),
    nn.Linear(64, 10, bias=False),
)
model.apply(init_weights)
print(model[0].bias.abs().max().item())  # 0.0

go deeper

for a junior

Know that built-in layers initialize themselves at construction and that torch.nn.init holds the alternative schemes, all named with a trailing underscore because they act in place.

for a middle

Explain the default for nn.Linear — kaiming_uniform_ on the weight, a fan-in-bounded uniform on the bias — and say why Kaiming suits ReLU while Xavier suits tanh.

for a senior

Show when overriding actually pays: deep stacks without normalization, residual branches zeroed to start as the identity, embeddings rescaled. Diagnose scale drift by measuring activation statistics across depth.

for a principal

Treat initialization as part of a reproducible training recipe: pinned scheme, pinned seed, and a rule that init runs before checkpoint loading so a restored run is never silently reinitialized.

## Layers initialize themselves A newly constructed `nn.Linear(64, 10)` is not empty. Its `__init__` allocates the `weight` and `bias` tensors and then calls `self.reset_parameters()`, which fills them. This is why you can build a model and immediately run a forward pass and get finite, reasonably scaled activations. For `nn.Linear` the current implementation uses `init.kaiming_uniform_(self.weight, a=math.sqrt(5))` for the weight, and initializes the bias uniformly in `(-bound, bound)` where `bound = 1 / sqrt(fan_in)`. Convolution layers use the same scheme. The `a=sqrt(5)` choice makes the effective scale equivalent to an older uniform heuristic; it is historical, not a claim that your network uses leaky ReLU with that slope. ## Why initialization scale matters at all Each layer multiplies its input by a weight matrix. If the weights are systematically too large, activation magnitudes grow layer by layer, and in a deep stack you get overflow or a loss that is `nan` on the first step. Too small, and activations shrink toward zero, gradients shrink with them, and the early layers barely learn — the classic vanishing-signal picture. The standard schemes all answer the same question: what variance should the weights have so that the variance of the activations is roughly preserved from layer to layer? Two families dominate: - **Xavier / Glorot** (`nn.init.xavier_uniform_`, `nn.init.xavier_normal_`) balances fan-in and fan-out, derived assuming a symmetric activation with unit derivative near zero — tanh, sigmoid, linear. - **Kaiming / He** (`nn.init.kaiming_uniform_`, `nn.init.kaiming_normal_`) accounts for ReLU zeroing roughly half the activations, so it scales up by a compensating factor. The `nonlinearity="relu"` argument selects the right gain; `mode="fan_in"` (the default) preserves forward-pass variance, `mode="fan_out"` preserves backward-pass variance. `nn.init.calculate_gain(nonlinearity)` exposes the multiplier the library uses for a given activation, which is handy when you write a custom scheme. ## Applying a scheme with apply() `nn.Module.apply(fn)` recursively applies `fn` to every submodule and then to the module itself. That makes it the standard place to put initialization: ``` def init_weights(m): if isinstance(m, nn.Linear): nn.init.kaiming_normal_(m.weight, nonlinearity="relu") if m.bias is not None: nn.init.zeros_(m.bias) model.apply(init_weights) ``` Note the `m.bias is not None` guard: layers built with `bias=False` have `bias` set to `None`, and forgetting the check is a common crash. Note also that all `nn.init` functions end in an underscore — they are in-place, and they are wrapped so they do not record on the autograd graph. ## When the default is fine and when it is not For a shallow MLP or any network with normalization layers between the linear layers, the default is almost always fine — normalization re-centres and re-scales activations at every layer, which washes out moderate initialization differences. The cases where overriding earns its keep: - **Deep nets without normalization.** Here the scale compounds and the difference between Xavier and Kaiming is the difference between training and not training. - **Residual stacks.** A common trick is to zero-initialize the last layer of each residual branch (`nn.init.zeros_`) so that at step zero every block is the identity and the signal passes through unchanged. Training then grows the branches gradually. - **Output layers with known priors.** Initializing a classifier's bias to the log-odds of the class prior makes the first few steps far more stable for very imbalanced data. - **Embedding layers.** `nn.Embedding` defaults to `N(0, 1)`, which is a much larger scale than a fan-in-scaled linear layer; many architectures re-initialize embeddings to a smaller standard deviation with `nn.init.normal_(emb.weight, std=0.02)`. ## Practical cautions Do the initialization **before** you construct the optimizer only if the optimizer holds references — in fact `torch.optim` holds references to the parameter tensors themselves, and `nn.init` functions mutate in place, so the order does not matter. What *does* matter is doing it before loading a checkpoint: `load_state_dict` overwrites everything, so any custom initialization applied afterwards silently destroys the loaded weights. And initialization is a training-stability tool, not a substitute for the rest of the recipe. If a model diverges, initialization is one candidate among several, and the cheapest way to test it is to print the standard deviation of activations at a few depths on one forward pass and see where the scale drifts.

  • Why is nn.Module.apply the idiomatic place to run an init function?
    `apply(fn)` walks the whole module tree recursively — children first, then the module itself — so one function covers every submodule no matter how deeply nested. You branch on `isinstance(m, nn.Linear)` or `nn.Conv2d` inside it. Doing the same by hand means enumerating submodules and re-editing that code whenever the architecture changes.
  • Why does the guard `if m.bias is not None` appear in almost every init function?
    Layers constructed with `bias=False` — common when a normalization layer follows and would cancel the bias anyway — set the `bias` attribute to `None` rather than to an empty tensor. Calling `nn.init.zeros_(m.bias)` on `None` raises. The guard is not defensive noise; it is required for any init function meant to run over a real architecture.
  • Why do teams zero-initialize the final layer of each residual branch?
    With that layer at zero, the whole branch outputs zero at step 0, so the block computes exactly the identity and the signal passes through the depth of the network unchanged. Training then grows the branches from zero rather than fighting an initially noisy deep stack, which measurably improves stability in very deep residual models.
  • Can custom initialization be silently undone?
    Yes — by loading a checkpoint after it. `load_state_dict` overwrites every parameter it has a key for, so if you call `model.apply(init_weights)` after restoring weights, you have thrown away the checkpoint and are training from scratch with a model that looks correctly loaded. Initialize first, load second.

saying these in an interview costs you the question

  • Thinks PyTorch layers start uninitialized or at zero
  • Says Xavier and Kaiming are interchangeable
  • Initializes weights after load_state_dict
  • Calls nn.init.zeros_ on a bias that may be None
  • Believes initialization matters equally with normalization layers

context