Seeds and reproducibility

Until v0.73.0 there was no way to set a seed at all. Every run trained at 42, the framework default, and the only variation between two runs of one config was row ordering plus whatever the GPU did differently that day.

That matters more than it sounds, because a project that reports "arm A beat arm B by 0.006" needs to know what one arm does against itself. Without a seed knob, the natural spread was missing its largest source, and every measurement quoted against it was weaker than it looked.

The two keys

yaml
training:
  seed: 1234        # weight init of new parameters, data order, dropout
  data_seed: 99     # optional: vary data order while holding init fixed

Both are config keys, not CLI flags. There is no --seed.

  • Both default to unset, not to 42. That is deliberate rather than tidy: an unset seed has to reproduce two different historical defaults, 42 for the trainer and 0 for the multipack sampler, and defaulting the field to 42 would have silently re-ordered every existing multipack run.
  • An unset seed resolves to 42; an unset data_seed stays unset. The fields are optional precisely so the trainer can tell "unset" from "explicitly 42".
  • Bounds are 0 to 2^32 - 1. Zero is legitimate. A YAML true is refused by name rather than coerced to 1.
  • data_seed needs a recent enough accelerate.

What changed in v0.73.1, and what did not

In v0.73.0 the seed reached the supervised trainer and nothing else. A seeded GRPO or DPO run was accepted, trained at 42, and said nothing. Seventeen of eighteen task wrappers ignored it. Replicates that differed only in their seed were the same run.

It now reaches every wrapper, and it is applied before the model loads, because the adapter's own initialisation is drawn before the trainer's seeding used to happen.

The values a run trains at are unchanged. What changed is when they arrive. An unseeded run's adapter and classification-head initialisation used to vary from process to process; it is now deterministic at 42.

What a seed does not buy you

This is the part worth reading before you design an experiment on top of it.

  • It is not a determinism guarantee. For bit-exact reruns you also need PyTorch's deterministic-algorithms switch, and Soup does not set that for you.
  • A resident 4-bit run is still not bit-reproducible from a seed. This is diagnosed and not fixed in the source. Never treat a resident quantised arm as reproducible.
  • A streamed run is bit-reproducible: five runs, one adapter hash, zero movement on every behavioural suite. That asymmetry is real and is why the streamed arm is the control.
  • The mlx backend reads neither field. It now says so, in its existing "MLX backend ignores" line. That is a warning rather than a rejection, because a config that is valid on transformers should not become unloadable by switching backend. Seeding MLX for real needs a separate generator and is separate work.
  • The multipack sampler still takes 0.

Why the ship gate cares

The consequence is concrete, and it is the reason this page sits next to the release notes rather than in a corner.

Three runs of one unchanged resident configuration moved two behavioural suites by 0.375 and 0.269, against a regression threshold of 0.05. Five of seven suites could cross the line on a re-run that changed nothing at all.

Two separate things follow, and they compose:

  1. Set a seed, so a replicate is a replicate.
  2. Measure what your instrument can resolve before you call a delta real. That is soup ship --noise-floor, added in v0.73.2, and it exists because greedy decoding is not deterministic on a GPU either.

Neither one alone is enough. A seed removes a source of variation you control; the noise floor sizes the one you do not.

See also

Soup is free and Apache-2.0. If it saved you a training run, starring the repo costs nothing and helps most. You can also fund the GPU time behind the work a 4 GB laptop cannot reach.