Training Well

Knowing backpropagation is not the same as getting a network to learn. This page moves you from NumPy to PyTorch and walks through the craft that decides whether training works: the loop, the optimiser, the learning rate, initialisation, regularisation, normalisation, and a recipe for when it all goes wrong.

Two engineers build the same network, on the same data, with the same code for backpropagation. One gets 97% accuracy in a minute. The other watches the loss sit at 2.30 for an hour, then turn into nan. Nothing in the mathematics separates them. What separates them is a dozen small decisions — how the weights start, how big each step is, when to stop, what to do with a loss that will not move — that textbooks treat as footnotes and practitioners treat as the job. This page is about those decisions.

The training loop in PyTorch

On Neural Networks from Scratch and Backpropagation and Gradient Descent you wrote the forward pass, the gradients and the update by hand in NumPy. That is the right way to understand a network and the wrong way to build one: every new layer means new derivative code, and one sign error silently ruins everything. PyTorch keeps the ideas and removes the bookkeeping. Its arrays are called tensors — NumPy arrays that can also live on a GPU and that remember how they were computed, so PyTorch can run backpropagation for you (this is called autograd).

Here is a complete classifier for scikit-learn’s 8×8 handwritten digits: 1,797 images, ten classes, a small network with one hidden layer of 128 units. It runs in a few seconds on an ordinary laptop CPU.

import torch
from torch import nn
from torch.utils.data import TensorDataset, DataLoader
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split

torch.manual_seed(0)
X, y = load_digits(return_X_y=True)          # 1,797 images, 8x8 = 64 pixels each
X_tr, X_va, y_tr, y_va = train_test_split(X / 16.0, y, test_size=0.25, random_state=42)
X_tr, X_va = torch.tensor(X_tr, dtype=torch.float32), torch.tensor(X_va, dtype=torch.float32)
y_tr, y_va = torch.tensor(y_tr), torch.tensor(y_va)   # int64 class labels
loader = DataLoader(TensorDataset(X_tr, y_tr), batch_size=64, shuffle=True)

model = nn.Sequential(nn.Linear(64, 128), nn.ReLU(), nn.Linear(128, 10))
loss_fn = nn.CrossEntropyLoss()
opt = torch.optim.SGD(model.parameters(), lr=0.1)

for epoch in range(1, 11):
    model.train()
    total = 0.0
    for xb, yb in loader:
        opt.zero_grad()                       # 1. clear last step's gradients
        loss = loss_fn(model(xb), yb)         # 2. forward pass and loss
        loss.backward()                       # 3. backprop fills every .grad
        opt.step()                            # 4. nudge the weights
        total += loss.item() * len(xb)
    model.eval()
    with torch.no_grad():
        logits = model(X_va)
        va_loss = loss_fn(logits, y_va).item()
        va_acc = (logits.argmax(1) == y_va).float().mean().item()
    print(f"epoch {epoch:2d}  train loss {total / len(X_tr):.3f}  "
          f"val loss {va_loss:.3f}  val acc {va_acc:.3f}")
epoch  1  train loss 2.225  val loss 2.101  val acc 0.458
epoch  2  train loss 1.976  val loss 1.806  val acc 0.644
epoch  3  train loss 1.637  val loss 1.426  val acc 0.767
epoch  4  train loss 1.271  val loss 1.145  val acc 0.840
epoch  5  train loss 0.975  val loss 0.889  val acc 0.802
epoch  6  train loss 0.770  val loss 0.727  val acc 0.822
epoch  7  train loss 0.624  val loss 0.594  val acc 0.876
epoch  8  train loss 0.525  val loss 0.498  val acc 0.889
epoch  9  train loss 0.452  val loss 0.425  val acc 0.913
epoch 10  train loss 0.399  val loss 0.472  val acc 0.867

Run with PyTorch 2.14.1 (CPU build) on Python 3.13 As of October 2026. With the same seeds you should see the same numbers on the same versions; other machines, thread counts or PyTorch releases can differ slightly in the last digits.

Read the loop as five ideas:

Look at the output. Validation accuracy climbs from 46% to 91% by epoch 9, then drops to 87% at epoch 10 even though training loss kept falling. That wobble is normal with plain SGD and a fairly large step, and it is exactly the kind of behaviour the rest of this page teaches you to read and control.

Optimisers: SGD, momentum and Adam

An optimiser is the rule that turns gradients into weight changes. Plain stochastic gradient descent (SGD) does the simplest thing: θ ← θ − η · ∇L, where θ stands for all the weights and η (eta) is the learning rate. “Stochastic” because each gradient comes from a random mini-batch, not the whole dataset, so it is a noisy estimate of the true downhill direction.

Noise and awkward landscapes are SGD’s two problems. In a long narrow valley, the gradient points mostly across the valley rather than along it, so SGD zig-zags from wall to wall and crawls forward. Momentum fixes this by keeping a running “velocity” that remembers past gradients:

v ← β · v + ∇L(θ)
θ ← θ − η · vwhere v is the velocity, β (often 0.9) is how much of the old velocity survives each step, η is the learning rate and ∇L(θ) is the gradient of the loss. This is the form PyTorch’s SGD(momentum=…) uses; some texts put η inside the first line instead.

In words: the zig-zag components point in opposite directions on alternate steps and cancel out in the running sum, while the consistent “along the valley” component adds up. With β = 0.9, steps along a steady direction become up to ten times larger. Picture a heavy ball rolling downhill.

One learning rate, no memory. Simple, cheap and well understood. With a carefully tuned learning-rate schedule it still trains many vision models to excellent accuracy, and some practitioners find its solutions generalise slightly better. Its weakness is sensitivity: the right learning rate depends on the model, the batch size and the data, and the same rate is used for every weight.

The same network, the same starting weights, five epochs, different optimisers:

import torch
from torch import nn
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split

X, y = load_digits(return_X_y=True)
X_tr, X_va, y_tr, y_va = train_test_split(X / 16.0, y, test_size=0.25, random_state=42)
X_tr, X_va = torch.tensor(X_tr, dtype=torch.float32), torch.tensor(X_va, dtype=torch.float32)
y_tr, y_va = torch.tensor(y_tr), torch.tensor(y_va)

def run(make_opt, epochs=5):
    torch.manual_seed(0)                      # same starting weights for every optimiser
    model = nn.Sequential(nn.Linear(64, 128), nn.ReLU(), nn.Linear(128, 10))
    opt, loss_fn = make_opt(model.parameters()), nn.CrossEntropyLoss()
    for epoch in range(epochs):
        perm = torch.randperm(len(X_tr))
        for i in range(0, len(X_tr), 64):     # mini-batches of 64, by hand
            idx = perm[i:i + 64]
            opt.zero_grad()
            loss_fn(model(X_tr[idx]), y_tr[idx]).backward()
            opt.step()
    with torch.no_grad():
        return loss_fn(model(X_va), y_va).item(), (model(X_va).argmax(1) == y_va).float().mean().item()

optimisers = {
    "SGD lr=0.01":            lambda p: torch.optim.SGD(p, lr=0.01),
    "SGD lr=0.1":             lambda p: torch.optim.SGD(p, lr=0.1),
    "SGD+momentum 0.9 lr=0.01": lambda p: torch.optim.SGD(p, lr=0.01, momentum=0.9),
    "Adam lr=0.001":          lambda p: torch.optim.Adam(p, lr=0.001),
    "Adam lr=0.01":           lambda p: torch.optim.Adam(p, lr=0.01),
    "SGD lr=5.0":             lambda p: torch.optim.SGD(p, lr=5.0),
}
for name, make in optimisers.items():
    loss, acc = run(make)
    print(f"{name:26s} after 5 epochs: val loss {loss:.3f}  val acc {acc:.3f}")
SGD lr=0.01                after 5 epochs: val loss 2.211  val acc 0.398
SGD lr=0.1                 after 5 epochs: val loss 0.847  val acc 0.856
SGD+momentum 0.9 lr=0.01   after 5 epochs: val loss 0.992  val acc 0.882
Adam lr=0.001              after 5 epochs: val loss 0.729  val acc 0.907
Adam lr=0.01               after 5 epochs: val loss 0.130  val acc 0.964
SGD lr=5.0                 after 5 epochs: val loss 2.555  val acc 0.096

Three lessons sit in those six lines. First, the learning rate matters as much as the optimiser: SGD at 0.01 has barely started (40%), SGD at 0.1 is respectable (86%), SGD at 5.0 has blown up and is guessing (9.6%, worse than the 10% you would get by chance). Second, momentum at 0.01 beats plain SGD at ten times that rate, because the velocity multiplies the effective step. Third, Adam at 0.01 is best here (96.4%). Do not over-read a single small run, but this pattern — Adam is forgiving, SGD needs tuning — is typical. A widely used variant, AdamW, changes only how weight decay is applied (see below) and is the default optimiser for training Transformers As of 2026.

Learning-rate schedules

The best learning rate is not constant. Early on, big steps cover ground fast; near the end, small steps settle into a good minimum instead of bouncing around it. A learning-rate schedule changes η as training proceeds. Drag the slider below to feel the trade-off a schedule is trying to manage: small rates crawl, large ones overshoot.

Common schedules, all available in torch.optim.lr_scheduler:

import torch
from torch import nn

model = nn.Linear(4, 2)                       # any model will do
opt = torch.optim.SGD(model.parameters(), lr=0.1)
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=10)

for epoch in range(10):
    lr = opt.param_groups[0]["lr"]
    # ... one epoch of training would go here ...
    opt.step()                                # (normally inside the batch loop)
    sched.step()                              # once per epoch, after the optimiser
    print(f"epoch {epoch}: lr {lr:.4f}")
epoch 0: lr 0.1000
epoch 1: lr 0.0976
epoch 2: lr 0.0905
epoch 3: lr 0.0794
epoch 4: lr 0.0655
epoch 5: lr 0.0500
epoch 6: lr 0.0345
epoch 7: lr 0.0206
epoch 8: lr 0.0095
epoch 9: lr 0.0024

Note the order: opt.step() inside the batch loop, sched.step() once per epoch after it. Calling them the other way round is a common mistake; recent PyTorch versions print a warning.

Initialisation: where the weights start

Gradient descent improves weights; it cannot rescue weights that start in a hopeless place. Each layer multiplies its input by a weight matrix. If the weights are slightly too small, the signal shrinks a little at every layer; slightly too large, and it grows. Over twenty layers, “a little” compounds. Run this:

import torch

torch.manual_seed(0)
x = torch.randn(512, 256)                     # a batch of 512 random inputs
inits = {"std 0.01": lambda n: 0.01,
         "std 1.0":  lambda n: 1.0,
         "He":       lambda n: (2 / n) ** 0.5}
for name, std in inits.items():
    h = x
    for layer in range(1, 21):                # 20 layers, 256 units each, ReLU
        W = torch.randn(256, 256) * std(256)
        h = torch.relu(h @ W)
        if layer in (1, 5, 10, 20):
            print(f"{name:8s} layer {layer:2d}: activation std {h.std().item():.3g}")
std 0.01 layer  1: activation std 0.0933
std 0.01 layer  5: activation std 1.43e-05
std 0.01 layer 10: activation std 2.88e-10
std 0.01 layer 20: activation std 9.05e-20
std 1.0  layer  1: activation std 9.32
std 1.0  layer  5: activation std 1.49e+05
std 1.0  layer 10: activation std 2.39e+10
std 1.0  layer 20: activation std 4.91e+20
He       layer  1: activation std 0.824
He       layer  5: activation std 0.784
He       layer 10: activation std 0.931
He       layer 20: activation std 0.789

With standard deviation 0.01 the activations fall to 10−20 by layer 20: every later layer sees zeros, and the gradients flowing back are just as dead. With standard deviation 1.0 they explode to 1020. The third row stays near 1 all the way down. That is He initialisation (He, Zhang, Ren and Sun, 2015): draw weights with standard deviation √(2 ÷ nin), where nin is the number of inputs to the layer. The 2 compensates for ReLU zeroing out roughly half its inputs. For tanh or sigmoid layers the older Xavier (or Glorot) initialisation (Glorot and Bengio, 2010) uses variance 2 ÷ (nin + nout) instead.

The principle behind both: choose the scale so the variance of the signal is preserved from layer to layer, forwards and backwards. PyTorch’s nn.Linear and nn.Conv2d already start from a sensible fan-in-scaled uniform distribution, so for small networks you rarely need to intervene; torch.nn.init.kaiming_normal_ and xavier_uniform_ are there when you do. Careful initialisation, together with ReLU and the residual connections you will meet on Convolutional Networks, is a large part of why networks of a hundred layers became trainable — the story told on The Deep Learning Revolution.

Regularisation: closing the train–validation gap

A network with enough parameters can memorise its training set — including its mistakes. The gap between training and validation performance is the symptom of overfitting, and regularisation is anything that narrows the gap without simply making the model worse. The three tools every practitioner reaches for:

To make overfitting obvious, the experiment below trains a big network (two hidden layers of 512 units — about 300,000 weights) on only 300 digits, and replaces 30% of the training labels with random ones. Real datasets contain label noise too, though rarely this much. The network can “learn” those wrong labels only by memorising them.

import torch
from torch import nn
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split

X, y = load_digits(return_X_y=True)
X_tr, X_va, y_tr, y_va = train_test_split(X / 16.0, y, train_size=300, random_state=42)
X_tr, X_va = torch.tensor(X_tr, dtype=torch.float32), torch.tensor(X_va, dtype=torch.float32)
y_tr, y_va = torch.tensor(y_tr), torch.tensor(y_va)
g = torch.Generator().manual_seed(1)
noisy = torch.rand(len(y_tr), generator=g) < 0.3          # 30% of training labels...
y_tr[noisy] = torch.randint(0, 10, (int(noisy.sum()),), generator=g)  # ...replaced at random

def run(p_drop, wd, epochs=300):
    torch.manual_seed(0)
    model = nn.Sequential(nn.Linear(64, 512), nn.ReLU(), nn.Dropout(p_drop),
                          nn.Linear(512, 512), nn.ReLU(), nn.Dropout(p_drop),
                          nn.Linear(512, 10))
    opt = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=wd)
    loss_fn = nn.CrossEntropyLoss()
    best_acc, best_epoch = 0.0, 0
    for epoch in range(1, epochs + 1):
        model.train()                         # dropout on while learning
        opt.zero_grad()
        loss_fn(model(X_tr), y_tr).backward() # whole training set as one batch
        opt.step()
        model.eval()                          # dropout off while measuring
        with torch.no_grad():
            va_acc = (model(X_va).argmax(1) == y_va).float().mean().item()
        if va_acc > best_acc:
            best_acc, best_epoch = va_acc, epoch   # what early stopping would keep
    with torch.no_grad():
        tr_acc = (model(X_tr).argmax(1) == y_tr).float().mean().item()
    print(f"dropout {p_drop}, decay {wd}:  train acc {tr_acc:.3f}  val acc {va_acc:.3f}  "
          f"gap {tr_acc - va_acc:+.3f}  | best val {best_acc:.3f} at epoch {best_epoch}")

run(0.0, 0.0)
run(0.5, 0.0)
run(0.5, 1.0)
dropout 0.0, decay 0.0:  train acc 1.000  val acc 0.695  gap +0.305  | best val 0.865 at epoch 22
dropout 0.5, decay 0.0:  train acc 0.983  val acc 0.771  gap +0.212  | best val 0.868 at epoch 30
dropout 0.5, decay 1.0:  train acc 0.977  val acc 0.776  gap +0.201  | best val 0.868 at epoch 30

Read across the rows. Without regularisation the network reaches 100% training accuracy — it has memorised every wrong label — and validation accuracy collapses to 69.5%, a gap of 30 points. Dropout narrows the gap to 21 points and lifts validation accuracy to 77%. Adding strong weight decay helps only a little more here. But the right-hand column is the striking one: at epoch 22, before memorisation set in, the unregularised network was already at 86.5% on validation. Early stopping would have kept those weights and beaten both regularised runs’ final scores. Over the next 278 epochs the network learned the noise.

Key point

Always watch training and validation curves together. Falling training loss tells you the optimiser works; validation tells you whether what it learned is worth having. When they part company, regularise — and keep the best checkpoint, not the last.

Normalisation layers

Initialisation sets a good scale at the start, but weights change during training, and the scale of each layer’s inputs drifts with them. Batch normalisation (Ioffe and Szegedy, 2015) re-standardises inside the network: for each feature it subtracts the mini-batch mean and divides by the mini-batch standard deviation, then applies a learned scale γ and shift β so the network can undo the normalisation if that helps. Layer normalisation (Ba, Kiros and Hinton, 2016) does the same calculation across the features of each single example instead of across the batch.

import torch
from torch import nn

torch.manual_seed(0)
h = torch.randn(64, 4) * 3 + 5                # 64 examples x 4 features, mean ~5, std ~3
bn, ln = nn.BatchNorm1d(4), nn.LayerNorm(4)
fmt = lambda t: " ".join(f"{v:+.2f}" for v in t.tolist())

print("raw         mean per feature:", fmt(h.mean(0)))
print("batch norm  mean per feature:", fmt(bn(h).mean(0)))
print("batch norm  std  per feature:", fmt(bn(h).std(0, unbiased=False)))
print("layer norm  mean per example:", fmt(ln(h).mean(1)[:4]), "...")
print("layer norm  std  per example:", fmt(ln(h).std(1, unbiased=False)[:4]), "...")
raw         mean per feature: +4.72 +4.96 +5.50 +5.13
batch norm  mean per feature: +0.00 +0.00 +0.00 -0.00
batch norm  std  per feature: +1.00 +1.00 +1.00 +1.00
layer norm  mean per example: -0.00 +0.00 +0.00 -0.00 ...
layer norm  std  per example: +1.00 +1.00 +1.00 +1.00 ...

Batch norm makes every column (feature) mean 0 and standard deviation 1 across the batch; layer norm makes every row (example) mean 0 and standard deviation 1 across its features. The practical differences follow from that:

Consensus Normalisation layers let you train faster, with higher learning rates and less sensitivity to initialisation. Debated Why they work is less settled.

Other views
  • Internal covariate shift: Ioffe and Szegedy argued batch norm helps by stopping each layer’s input distribution from shifting as earlier layers learn.
  • Smoother landscape: Santurkar and colleagues (2018) found that injecting distribution shift after batch norm barely hurt it, and argued that its real benefit is making the loss surface smoother, so larger steps are safe.
  • Several effects at once: others point to its implicit regularisation (each example is normalised with slightly different batch statistics) and its interaction with weight decay. The mechanisms are probably not exclusive.

A debugging recipe

Neural networks fail quietly. A bug rarely crashes the program; it produces a model that trains to 85% when it should reach 97%, and you never find out. The defence is a fixed order of checks, each cheap, each ruling out a whole family of mistakes. This sequence is adapted from Andrej Karpathy’s widely shared “A Recipe for Training Neural Networks” (2019) and everyday practice. Work through it top to bottom:

  1. Look at the dataPrint a handful of inputs with their labels, and plot a few images. Check the scale (0–1 or 0–16?), the class balance and that labels match inputs after shuffling and splitting.
  2. Check every shapePrint input, output and label shapes for one batch. A label tensor of shape (32, 1) where (32,) is expected can broadcast silently into nonsense.
  3. Check the loss at step zeroWith ten balanced classes and a random network, cross-entropy should start near ln 10 ≈ 2.30. Much higher means your initial outputs are overconfident; exactly 2.30 for ever means nothing is learning.
  4. Overfit one batchTrain on a single small batch until the loss is almost zero. If a network cannot memorise 32 examples, there is a bug in the model, the loss or the update — no amount of data will fix it.
  5. Beat a simple baselineTrain on the full data and compare with logistic regression or a tiny model. A deep network that cannot beat it is suspect.
  6. Watch both curvesPlot training and validation loss every epoch. Diverging to nan: learning rate too high. Flat: too low, or dead units. Training falls, validation rises: overfitting.
  7. Only then tune and regulariseAdjust the learning rate first (it matters most), then add regularisation, then change the architecture — one thing at a time, with notes.

Steps 2 to 4 take ten lines of code:

import torch
from torch import nn
from sklearn.datasets import load_digits

torch.manual_seed(0)
X, y = load_digits(return_X_y=True)
xb = torch.tensor(X[:32] / 16.0, dtype=torch.float32)    # ONE batch of 32
yb = torch.tensor(y[:32])
model = nn.Sequential(nn.Linear(64, 128), nn.ReLU(), nn.Linear(128, 10))
print("input", tuple(xb.shape), "-> logits", tuple(model(xb).shape), "labels", tuple(yb.shape))

opt = torch.optim.Adam(model.parameters(), lr=1e-2)
loss_fn = nn.CrossEntropyLoss()
print(f"loss before training {loss_fn(model(xb), yb).item():.3f}  (ln 10 = 2.303)")
for step in range(1, 201):
    opt.zero_grad()
    loss = loss_fn(model(xb), yb)
    loss.backward()
    opt.step()
    if step in (1, 10, 50, 200):
        print(f"step {step:3d}: loss {loss.item():.4f}")
input (32, 64) -> logits (32, 10) labels (32,)
loss before training 2.323  (ln 10 = 2.303)
step   1: loss 2.3230
step  10: loss 0.5432
step  50: loss 0.0005
step 200: loss 0.0001

The shapes are right, the starting loss of 2.323 is close to ln 10 = 2.303, and the loss falls to 0.0001 on the single batch — the machinery works. The bugs this catches most often are: forgetting zero_grad() (gradients accumulate and steps grow ever larger), applying a softmax before CrossEntropyLoss (which then applies its own), labels of the wrong type or shape, and forgetting model.eval() before measuring.

Reproducibility

Training is full of randomness: initial weights, the shuffle order, dropout masks. To get the same result twice, seed every random number generator you use:

import random
import numpy as np
import torch

def set_seed(seed=42):
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)                   # seeds the CPU (and any GPUs)
    torch.use_deterministic_algorithms(True)  # error if an op cannot be deterministic

set_seed(42); a = torch.nn.Linear(3, 1).weight
set_seed(42); b = torch.nn.Linear(3, 1).weight
print(a)
print("identical:", torch.equal(a, b))
Parameter containing:
tensor([[ 0.4414,  0.4792, -0.1353]], requires_grad=True)
identical: True

Two identically seeded layers start with identical weights. On a CPU, seeding is usually enough for repeat runs on the same machine and version; use_deterministic_algorithms(True) makes PyTorch raise an error rather than silently use an operation with no deterministic version. In larger projects, a multi-worker DataLoader needs a seeded generator, and GPUs need extra settings described in the PyTorch reproducibility notes — which are frank that results are not guaranteed to match across releases or platforms, because different CPUs add numbers in different orders.

This guide’s framing Treat a single run as an anecdote. Before you believe that dropout or a new optimiser “helps”, run three or more seeds and look at the spread; on small datasets like these, the difference between seeds can be as large as the effect you are measuring.

Quick check

Your validation accuracy is excellent during training but drops sharply when you test the saved model in a separate script. The model uses dropout and batch norm. What is the likeliest cause?

In training mode, dropout keeps zeroing units and batch norm uses the current batch’s statistics. model.eval() switches both to their inference behaviour.

Twenty ReLU layers, and activations shrink to almost zero by the last layer. Which change addresses the cause most directly?

The signal is being scaled down at every layer. He initialisation chooses the weight scale that keeps the variance roughly constant through ReLU layers.

Your network cannot get the loss on a single batch of 32 examples close to zero. What should you conclude?

Any reasonable network can memorise 32 examples. If it cannot, more data or regularisation will only hide the problem.

In the noisy-label experiment, what would early stopping have done?

Validation accuracy peaked at 86.5% at epoch 22; everything after that was memorisation of noise.

Try this

Take the first training loop on this page and turn it into a proper experiment. Add nn.Dropout(0.2) after the ReLU, switch to torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-2), add a CosineAnnealingLR over 30 epochs, and keep a copy of the weights with the best validation loss (copy.deepcopy(model.state_dict())). Run it with seeds 0, 1 and 2 and report the mean and spread of the best validation accuracy. Then remove one change at a time to see which actually mattered.

// Pause and reflect

  1. In the noisy-label experiment, early stopping beat dropout and weight decay. Would you expect that to hold with clean labels and ten times more data? Why or why not?
  2. Adam adapts a step size for every weight. What might you lose by letting the optimiser make that choice instead of tuning SGD yourself?
  3. Which step of the debugging ladder do you think you would be most tempted to skip — and what bug would that let through?

Your notes are saved privately in this browser.

Key terms on this page

Sources & further reading