Code

Dropout, weight decay, early stopping — write it yourself

The split works. Now three measures against overfitting, and one rule without which they lie. This time with fewer hints.

Three measures

Dropout. While training, at every step a random part of the neurons is switched off (say 30 %), and the outputs of the rest are scaled up so the total is preserved. The network cannot rely on one neuron — next time it may not be there.

Weight decay. A penalty for large numbers is added to the loss: the bigger a weight, the more it costs. The network picks more modest, "smoother" functions. For Adam this is the parameter weight_decay.

Early stopping. We watch validation loss: if for patience epochs in a row it did not improve — we stop. And we bring back the best weights, not the ones we stopped at.

One rule: train() and eval()

Dropout has two modes: while training it switches neurons off, while measuring it does not. A model knows this from a single switch:

model.train()   # dropout is active
model.eval()    # dropout is off

What has to happen: fit turns train() on before every epoch, and evaluate turns eval() on before measuring. If neither — no error, no warning, but the numbers are wrong. In step 7 you will see by how much.

Your job

1. model.py — MLP gets a dropout parameter (default 0.0). After each hidden ReLU goes nn.Dropout(dropout); after the last layer — none. The change is small, so you see it finished in the panel; test_model.py checks it.

2. train.py:

  • evaluate — a one-line fix: turn on model.eval(). It is not in the panel.
  • fit — a skeleton with NotImplementedError; in the panel you see only the contract in comments. The change is large, so I do not offer "add two lines". Your old fit from lesson 6 is the starting point: keep the loop, add validation and stopping.
  • build_model and main are already updated (including --dropout, --weight-decay, --patience).

The fit contract (you will also find it in the comment):

for every epoch:
    model.train()
    ... the usual loop over batches ...
    train_loss, train_acc  ← evaluate(model, loader)
    if there is a val_loader:
        val_loss, val_acc  ← evaluate(model, val_loader)
        if val_loss is the lowest so far:
            remember it and a COPY of the weights;  waited ← 0;  row["best"] ← True
        else: waited ← waited + 1
    append the row to the history
    if patience is not given or waited < patience: continue
    else: stop

if patience is given and a copy exists: load the best weights back
return the history

Two hidden details:

  • the weights copy has to be a real copy (copy.deepcopy(model.state_dict())). state_dict() returns references to the live tensors — without deepcopy it simply changes along with the model;
  • the optimizer gets weight_decay: torch.optim.Adam(..., weight_decay=...).

3. The tests have grown. test_model.py got TestDropout, test_train.py got fit with validation and evaluate mode tests. Two changes in the old ones: build_model now takes a third argument (dropout), and the epoch line now says train acc, not train accuracy — so the validation numbers fit as well. The test was changed accordingly.

Run the tests

Before writing — almost everything fails with NotImplementedError, plus one real failure:

FAIL: test_evaluate_switches_dropout_off
AssertionError: ... same model, same data, two different answers

When everything is done: OK, 38 tests, ~1 s.

When it does not pass

  • test_dropout_is_on_while_learning_in_every_epoch fails, the list is ragged with False — you forgot model.train() at the start of the loop. Epoch one still works (a model starts in train() mode), and from epoch two on dropout is off — evaluate switched it off and nobody switched it back on.
  • ... != ... within 4 places (test_patience_puts_the_best_weights_back) — you did not load the best weights back, or you saved a reference instead of a copy. Either way the model stays in the state of the last epoch.
  • not less than ... (test_weight_decay_shrinks_the_weights) — weight_decay did not reach the optimizer.
  • test_patience_stops_early_when_nothing_improves: len(history) is not 4 — check that waited is reset only on an improvement, not every epoch.