Robot Learning · Theory · M11
M11-T3 — Reproducibility and Experiment Tracking
A result nobody can regenerate is an anecdote. Reproducibility is mostly bookkeeping, and bookkeeping starts with knowing exactly which inputs a run has.
Lessons M11-T1 and M11-T2 produced numbers: 99 of 100, a seed-level interval of 0.24 to 0.69, a p-value of 0.0028. Every one of them is the output of a function call, and the question of this lesson is what that function needs as input so that you, a colleague or a reviewer can call it again and get the same answer. I treat a run as a function of its code, configuration, seeds, data and environment, test how deterministic the seeded runs in this repository are (the same seed reproduces the same numbers to every printed digit; a different seed changes them; two details can still catch you), compute a configuration hash by hand, and write a run record. Everything is a toy study on the simulated Scout, no real robot data is used, and lesson M11-T4 uses the record to write an experiment report.
Learning objectives
After this lesson, you should be able to:
- Separate re-running exactly (same inputs, same numbers) from reproducing a conclusion (new seeds or a new implementation, same finding), and state which one a claim needs.
- List what a run record must contain: code version, the complete resolved configuration, every seed and the stream it feeds, data identity, environment, results and artifacts.
- Predict when a seeded run stays identical and when it changes, including the pitfalls of two seeds, seed 0, and call order.
- Compute a hash of a canonical configuration and use it as part of a run identifier.
The intuition
Run Scout's PPO training with seed 8 (40 iterations of 10 episodes) twice, in the same process or in different ones. Both runs end with a mean training return of 4.44, score 65 of 100 on the evaluation starts and have first network weight -0.299814589556: the same to the last printed digit. Run it with seed 9 and you get a return of 7.815, 100 of 100 and a weight of -0.257093289996. Nothing in this is magic: a seeded generator is a deterministic function that turns an integer into a stream of numbers, and the whole training run is a deterministic function of that stream.
That determinism is a gift for research, because it lets you re-run exactly, but it is also a trap. If the code, the settings, the seed or the data change in a way that was not written down, the same name produces a different number, and nobody can tell which change did it. In this repository there are two seeds in one call, a seed 0 that is secretly seed 1, and a result that changes if you draw one extra random number first; the example section shows all three. The figure is the structure of the habit that prevents these problems: four inputs recorded, one run, a record with an identifier built from the inputs.
The mathematics
A run is a function
Write the outcome of an experiment as
The run is exactly repeatable if calling F twice with the same five arguments returns the same result. It fails to be when F has a hidden input: the clock, Math.random (the unseeded generator), the order in which a container or a directory is read, the order of a floating-point sum when work is split across threads or devices, a library version, a different GPU. Exact repeatability is the strongest guarantee and the easiest to lose. Weaker, and usually the one that matters for a claim, is reproducing the conclusion: a new run with fresh seeds, or someone else's implementation, reaches the same finding within the statistical noise of lesson M11-T2. Both are called reproducibility in the literature, with the words used in different ways by different authors; in this lesson I call them re-running exactly and reproducing the conclusion.
Seeds and streams
A pseudo-random generator has an internal state a, updated by a fixed rule at every draw; the seed sets the initial state. The k-th number depends on the seed and on k:
So a result depends on how many numbers were drawn, and in what order, before the number you care about. Three consequences: (1) the same seed gives the same stream; (2) inserting one extra draw anywhere earlier shifts every later draw, so adding a print statement that draws a number can change the result; (3) two streams that share one generator are coupled, so give each independent source of randomness (initialisation, exploration noise, training starts, evaluation starts) its own stream, and derive the seeds from a master seed and a name rather than from a counter, so adding a component does not renumber the others.
The configuration hash
A configuration is a set of named settings. To get a short identifier for it, serialise it canonically (keys in sorted order, one fixed number format), feed the bytes to a hash function H and keep a prefix. The same settings in a different key order must give the same identifier, and one changed value must give a different one:
With a 32-bit identifier and N runs, the chance that some two runs share an identifier is about N²/233 (a birthday-problem approximation): about 1 in 8600 for N = 1000. That is fine for a notebook and too weak for a team, where 12 or more hex digits of a cryptographic hash are used. A hash identifies a configuration; it does not describe it, so the full configuration is stored beside it.
- F
- The experiment as a function. If it is not a function of what you recorded, it is not reproducible.
- resolved configuration
- The settings after defaults are filled in. Hashing only the overrides makes two runs with different defaults look identical.
- canonical form
- A unique text for each configuration: sorted keys, fixed formatting, no incidental whitespace.
- H
- A hash function: SHA-256 in practice. FNV-1a (32 bit) is small enough to do by hand and is used below only to show the mechanism.
A worked example
Every number comes from Node scripts against RLCore and SLAMCore.rng (Node v22.22.0, scratch prototypes). The PPO runs are RLCore.trainPolicyGradient({algo: 'ppo', iters: 40, eps: 10, seed: cs}, rand) with rand the next function of SLAMCore.rng(rs); "eval" is the success of the mean policy on RLCore.starts(900, 100, 'all').
Same seed, same numbers; different seed, different numbers
The generator itself: SLAMCore.rng(1) yields 0.627074, 0.002736, 0.527447 as its first three numbers every time; SLAMCore.rng(2) yields 0.734251, 0.324998, 0.285296. The evaluation starts RLCore.starts(900, 3, 'all') are [2.1801, 1.2802], [3.3608, 1.5305], [1.4523, 3.3970] on every call. On the Module 10 toy, transferScore of the tight policy on the floor world with 40 episodes scores 0.675 for seed 1 twice in a row, 0.650 for seed 2 and 0.725 for seed 3. For whole training runs:
| Training seed (config.seed, generator) | Final training return | Eval success | First network weight |
|---|---|---|---|
| 8, 8 (first run) | 4.4400000000 | 65/100 | -0.299814589556 |
| 8, 8 (second run) | 4.4400000000 | 65/100 | -0.299814589556 |
| 9, 9 | 7.8150000000 | 100/100 | -0.257093289996 |
| 8, 9 (the two seeds disagree) | -4.1800000000 | 0/100 | -0.244242597430 |
| 9, 8 (the two seeds disagree) | 1.2400000000 | 95/100 | -0.323074499294 |
| 8, 8 with one extra draw taken from the generator first | -4.0000000000 | 0/100 | not recorded |
The first two rows are identical to every digit, and the 65 of 100 for seed 8 matches a different process run earlier with the same arguments, so this is repeatability across processes on one machine. Row three changes everything: different seed, different final return, success and weight. The last three rows are the traps. (1) Two seeds. trainPolicyGradient takes a seed field in its configuration, which selects the training start positions, and a separate random stream rand supplying the network initialisation and the exploration noise. Seed (8, 9) and seed (9, 8) are different experiments from (8, 8) and (9, 9), and the returned cfg records only the first of the two. A record that lists one number as "the seed" cannot re-run the experiment. (2) Call order. Taking one extra number from the generator before training changed the same nominal run from 65 of 100 to 0 of 100. (3) Seed 0. SLAMCore.rng starts from (seed >>> 0) || 1, so seed 0 is seed 1: rng(0), rng(1), rng(1.5) and rng(4294967297) all give 0.627074, 0.002736, 0.527447. A seed sweep that runs 0, 1, 2, … trains two identical runs and counts them as two independent seeds; start at 1 and use integers.
Notice the independence by design in the other direction: RLCore.starts(seed, n, region) uses its own generator (seeded from the seed argument), so the evaluation starts do not move when training consumes more or fewer random numbers. This is the arrangement to copy: the evaluation set is a function of an evaluation seed alone.
A configuration hash by hand
FNV-1a, 32-bit: start with h = 2166136261 (hex 811C9DC5); for each byte b of the text, set h = (h XOR b) · 16777619 mod 232. The multiplier is 0x01000193 = 224 + 0x193, so multiplying means adding h shifted left by 24 bits (low byte only survives) to h · 0x193, all mod 232. For the text a=1, whose bytes are 0x61, 0x3D, 0x31:
| Step | Byte | h XOR byte | multiply by 16777619, mod 2^32 |
|---|---|---|---|
| 1 | 0x61 (a) | 0x811C9DC5 XOR 0x61 = 0x811C9DA4 | 0xA4000000 + 0x400C292C = 0xE40C292C |
| 2 | 0x3D (=) | 0xE40C292C XOR 0x3D = 0xE40C2911 | 0x1024A5C3 |
| 3 | 0x31 (1) | 0x1024A5C3 XOR 0x31 = 0x1024A5F2 | 0x5BB13BF6 |
Step 1 in detail: h · 0x193 = 0x811C9DA4 · 403 = 872952899884 = 0xCB400C292C, whose low 32 bits are 0x400C292C; h shifted left by 24 bits has the low byte 0xA4 in the top position, giving 0xA4000000; their sum is 0xE40C292C. So the hash of a=1 is 0x5BB13BF6, and of a=2 (last byte 0x32) 0x5AB13A63: changing one character changes the value. I checked both with a three-line program. For real use take SHA-256 of the canonical text and keep a prefix. The PPO configuration {"algo":"ppo",JSON.stringify but 965c58f2 through the canonical form; changing lam from 0.95 to 0.9 gives b4e51f92.
Which configuration? The overrides {"algo":"ppo","eps":10,"iters":40,"seed":8} hash to 9cbd3b07, but the resolved configuration returned in cfg has 15 settings (clip 0.2, entCoef 0, epochs 5, gamma 0.99, hidden 32, initStd 0.5, lam 0.95, lr 0.003, maxGrad 0, region all, vcoef 0.5 and the four I set) and hashes to 45bcb535. If a default changes in the code, the overrides do not change but the resolved configuration, and the behaviour, do. Hash what the run used.
A run record
One line per run is enough, as long as every field is there:
{"id": "45bcb535-s8-g8", "code": "<git rev-parse HEAD> + diff hash if dirty", "config": {15 resolved settings}, "seeds": {"config.seed": 8, "generator": 8, "eval_starts": 900}, "data": "RLCore.starts(900, 100, all)", "env": "node v22.22.0", "results": {"eval_success": 0.65, "final_return": 4.44}, "artifacts": ["policy weights"]}
Five caveats about the experiment itself. First, exact repeatability here is for one machine, one runtime and one code version; I did not test other machines. The ECMAScript standard lets implementations differ slightly in functions such as Math.exp and Math.tanh, so a different engine could in principle produce slightly different weights, which a chaotic training run would amplify into a different outcome. Second, a bit-identical re-run reproduces the number, including a bad one: the seed 8 run is a failure (65 of 100) and re-running it proves nothing about the method (lesson M11-T2). Third, the record above has no commit because the code under test here is the scratch prototype: a real record needs the real hash. Fourth, I did not time-stamp or hash the data files, because the data are generated from seeds in code; real demonstration logs need a content hash. Fifth, none of this concerns real hardware, where the environment itself is not repeatable.
Engineering implications and limitations
What goes in the record
| Item | What to store | Typical failure |
|---|---|---|
| Code version | Commit hash, and a hash of the diff when the working tree is dirty | "It was the main branch" (which moved) |
| Configuration | The resolved settings with defaults, in canonical form, and its hash | Only the command-line overrides were saved |
| Seeds | Every seed and the stream it feeds: initialisation, exploration, training starts, evaluation starts | One seed recorded where there were two; seed 0 used as seed 1 |
| Data | Dataset name, size, a content hash or the generating seed, the split rule | The demonstration file was edited in place |
| Environment | Runtime and library versions, hardware, operating system | An upgrade changed a default |
| Results | One row per seed, with the evaluation protocol, not only the mean | Only the best checkpoint was kept |
| Artifacts | Policy weights, logs, plots, the exact evaluation episodes | The model file was overwritten by the next run |
| Notes | The question, the date, the person, whether the run was a failure | Failed runs deleted, so the table has survivors only |
Habits
- Derive every seed from one master seed and a name (for example the hash of "init" with the master seed) so one number reproduces the experiment and adding a component does not shift the rest.
- Keep the evaluation set a function of its own seed, so changes in training cannot move the test (as
RLCore.startsdoes). - Never use the unseeded generator (
Math.random, the clock) in anything you will report; search for it before a run. - Write the record before the run starts and update it after, so a crashed run leaves a trace. Store failures; they are data (lesson M11-T2 on best-seed reporting).
- Check exact repeatability once: run the same configuration twice, compare the results to the last digit, and write down whether they matched. If they do not, find the hidden input before running anything else.
- Share what a stranger needs: the code at a commit, the configuration file, the command and the record; Pineau et al. propose a checklist of this kind for machine learning papers, covering code, data, hyperparameter search, evaluation and statistics.
What exact repeatability does not give you
It is necessary for debugging (you can bisect a regression) and not sufficient for a claim. A claim is reproduced when a new seed set gives the same finding within noise (lesson M11-T2), when a second implementation agrees, and, for a robot, when the result holds on the floor (lessons M10-T4 and M10-T5). Neither the hash nor the record replaces a held-out test, and a perfectly logged experiment can still be unfair (lesson M11-T1). On real hardware, repeatability is limited by the world: friction, battery state and lighting are not seeded.
Common misconceptions
“I set the seed, so my experiment is reproducible.”
Which seed? trainPolicyGradient has a configuration seed and a separate generator, and the pairs (8, 9) and (9, 8) gave a return of -4.18 and 1.24, against 4.44 for (8, 8). The seed also has to feed every source of randomness in the same order.
“Seeds 0 to 9 give me ten independent runs.”
In this repository seed 0 is seed 1 ((seed >>> 0) || 1), so seeds 0 and 1 are the same run. Use seeds 1 to 10 and check that the first draws differ.
“Saving the command-line arguments is a record of the configuration.”
The overrides {algo, eps, iters, seed} hash to 9cbd3b07; the 15 resolved settings hash to 45bcb535. If a default changes, the overrides do not, but the run does.
“If I can re-run it and get the same number, the result is reproduced.”
That repeats the computation. A bad seed repeats as a bad seed (65 of 100 here). Reproducing the conclusion needs new seeds and the statistics of lesson M11-T2.
Engineering takeaways
- A run is a function of code, resolved configuration, seeds, data and environment; if any input is not recorded, the result cannot be regenerated.
- In this repository seeded runs are exactly repeatable: PPO with seed 8 gave a return of 4.44, 65 of 100 and weight -0.299814589556 on every run, and seed 9 gave 7.815, 100 of 100 and -0.257093289996.
- Three traps: two seeds in one training call (configuration seed and generator), seed 0 equal to seed 1, and any extra draw before training, which changed 65 of 100 to 0 of 100.
- Hash the canonical, resolved configuration (sorted keys, defaults filled in) and build the run identifier from the code version, the configuration hash and the seeds; store the full configuration beside the hash.
- Record every run, failures included, with per-seed results and the evaluation protocol. These are toy runs on the simulated Scout on one machine, no real robot data is used, and repeating a number is not the same as reproducing a finding.
References and further reading
The determinism results, hash values and run record are from my own scratch experiments on this repository's seeded generator and toy Scout world; I did not test other machines or runtimes. The papers below discuss reproducibility in machine learning and reinforcement learning research.
- J. Pineau, P. Vincent-Lamarre, K. Sinha, V. Larivière, A. Beygelzimer, F. d'Alché-Buc, E. Fox and H. Larochelle, “Improving Reproducibility in Machine Learning Research (A Report from the NeurIPS 2019 Reproducibility Program)”, Journal of Machine Learning Research 22(164), 2021.
- P. Henderson, R. Islam, P. Bachman, J. Pineau, D. Precup and D. Meger, “Deep Reinforcement Learning that Matters”, Proceedings of the AAAI Conference on Artificial Intelligence, 2018.
- National Institute of Standards and Technology, “Secure Hash Standard (SHS)”, FIPS PUB 180-4, 2015.
Knowledge check
Three conceptual questions. Write an answer, then reveal the explanation. Your answers stay in your browser.
Question 1
Two PPO runs both use "seed 5" yet give different results. Name three possible causes.
Explanation
Examples: the configuration seed and the generator seed are different numbers in the two runs; an extra random draw (a print statement, a changed loop) happened before training; a hidden input differs (unseeded Math.random, a different default, a different library version or machine).
Question 2
You run seeds 0 to 9 and report ten runs. What is wrong?
Explanation
Seed 0 is treated as seed 1 by SLAMCore.rng, so two of the ten runs are identical and only nine are independent. Use seeds 1 to 10 and check the first draws differ.
Question 3
Why hash the resolved configuration rather than the command-line arguments, and why sort the keys?
Explanation
Defaults are inputs: if one changes, the same arguments run a different experiment, so the resolved settings must be hashed. Sorted keys give one text per configuration, so the same settings in a different order get the same identifier and one changed value changes it.
What’s next
With the comparison designed (lesson M11-T1), the statistics done (lesson M11-T2) and the run recorded, lesson M11-T4 turns them into a document: how to state the question, the setup, the results, the failures and the limits of an experiment report.
Related content
Nothing below is required to finish this lesson.
- BeforeM11-T2: Seeds, Variance and Statistics
Report results over several seeds with intervals, and say when a difference is larger than the noise.
- LabM11-L2: The Learning Capstone
Train, evaluate and diagnose a policy for Scout on hidden scenarios, then write the experiment report.
Get notified when the next lesson and new labs are released.
One email per new lesson or lab.