ENH: reproducible Monte Carlo via per-simulation-index seeding#1054
ENH: reproducible Monte Carlo via per-simulation-index seeding#1054thc1006 wants to merge 2 commits into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## develop #1054 +/- ##
===========================================
+ Coverage 81.75% 82.31% +0.55%
===========================================
Files 119 119
Lines 15192 15212 +20
===========================================
+ Hits 12420 12521 +101
+ Misses 2772 2691 -81 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Pushed 0d37ed6 to cover the seeding code that the earlier tests only reached through a full (slow) Monte Carlo run.
That takes the patch from 21 uncovered lines down to 4, all in the parallel branch. I looked into whether it is worth covering those too, and I do not think it is:
So un-slowing the parallel test would recover at most the three main-process lines and still not the subprocess one, at the cost of forking workers in the default suite. I kept it |
MonteCarlo seeded the stochastic models per worker in parallel mode (from a fresh, unseeded SeedSequence) and once at construction in serial mode, so the sampled inputs depended on the execution mode and the worker count, and parallel runs were not reproducible run to run. Add a keyword-only random_seed to simulate() (SPEC 7 style: accepts an int, a SeedSequence, or a Generator; None keeps the previous fresh-entropy behavior). Spawn one child seed per simulation index from that root and reseed the stochastic models from child_seeds[i] before simulation i. SeedSequence.spawn is prefix-stable, so index i maps to the same seed regardless of which worker runs it, making the inputs identical across serial, parallel(2) and parallel(N). Each index seed is split three ways so the environment, rocket and flight draw from independent streams rather than sharing one. The serial index field now counts from 0 to match the parallel path. Both changes alter the numbers a fixed seed produces, so stored baselines regenerate. Adds tests/unit/simulation/test_monte_carlo_determinism.py: serial reproducibility, worker invariance (serial == parallel(2) == parallel(4)), and the None-seed path. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The reproducible-seeding change added __root_seed_sequence and __seed_simulation plus the per-index serial and parallel seeding, but the only tests that reached them ran a full Monte Carlo and were marked slow, so the coverage job (which does not pass --runslow) never executed them. Add fast unit tests that drive the two helpers directly: every supported random_seed type normalizes to the same root stream, None draws fresh entropy, existing SeedSequence/Generator/BitGenerator objects are reused rather than copied, and each child seed splits three ways so environment, rocket and flight get independent streams. Move the end-to-end simulate reproducibility tests into tests/integration, next to the existing Monte Carlo simulate test. The serial reproducibility run now lives in the non-slow suite; only the fork-based worker-invariance test stays slow, and it imports multiprocess lazily like the library does. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
0d37ed6 to
761c092
Compare
phmbressan
left a comment
There was a problem hiding this comment.
The implementation is very clear and throughout, nice work.
The explanation on the concepts behind per index seeding (both in the issue and PR description) were rather helpful. I agree having reproducible results was an issue with the parallel per worker seeding.
Regarding the decisions on parameter naming, I agree with most of the decisions taken here. Moreover, the rng attribute is well docstringed, so it shouldn't be a matter of confusion to the user.
@MateusStano could you give your two cents on the changes here before we proceed with a merge?
| rocket=rocket, | ||
| flight=flight, | ||
| ) | ||
| montecarlo.simulate(**simulate_kwargs) |
There was a problem hiding this comment.
Could you double check if after running this test, no files are left dangling on the test file system. If that is the case, using a try-finally block to remove those (as done in a few other MonteCarlo tests) is an option.
There was a problem hiding this comment.
Thanks for the review. On the dangling files: I checked, and nothing gets left behind. The test passes filename=tmp_path / tag into MonteCarlo, so the .inputs.txt, .outputs.txt and .errors.txt all land under pytest's per-test tmp_path and get cleaned up with it. I reran it and confirmed the repo and working directory stay clean, so there's nothing here for a try/finally to remove.
The other MonteCarlo tests need that cleanup because their fixture uses a fixed filename="monte_carlo_test", which writes into the cwd. This one keeps everything inside tmp_path instead. I'm happy to match the try/finally style if you'd prefer consistency across the file, but the tmp_path route seemed cleaner to me. Your call.
Pull request type
Draft / RFC. I'm opening this to make the design in #1053 concrete and get your call on the open questions below before polishing it.
Current behavior
MonteCarlo.simulate()seeds the stochastic models per worker in parallel mode (from a fresh, unseedednp.random.SeedSequence().spawn(n_workers)) and once at construction in serial mode. So the sampled inputs depend on the execution mode and the number of workers, and parallel runs aren't reproducible run to run. This is #1053.New behavior
Adds a keyword-only
random_seedtosimulate(). From that root I spawn one child seed per simulation index and reseed the models fromchild_seeds[i]before simulationi.SeedSequence.spawnis prefix-stable, so indeximaps to the same seed regardless of which worker runs it. The sampled inputs come out identical across serial, parallel(2) and parallel(N), and reproducible from the seed.The design follows Scientific-Python SPEC 7 (a per-call keyword-only seed, normalized to accept an int, a
SeedSequence, or aGenerator, withNone= fresh entropy so existing behavior is unchanged unless you pass a seed) and NumPy's documented parallel idiom (SeedSequence(root).spawn(n)thendefault_rng(child)).tests/unit/simulation/test_monte_carlo_determinism.pycovers it: serial reproducibility, serial == parallel(2) == parallel(4), and the None-seed path.Flightis stubbed, so it's aslowinput-sampling test.Open questions (why this is a draft)
random_seedfor continuity with theStochastic*seed. SPEC 7's direction isrng(SciPy 1.15 renamedseedtorng; sklearn is weighing it in #29315). I can switch torngif you'd rather be forward-looking.simulate()(per run) versus also a stored default onMonteCarlo.__init__(hybrid). I went withsimulate()only, the minimal SPEC-7 form.None= fresh entropy (backward-compatible, not reproducible). A competition or regression context might want a reproducible default instead.random.choice(an unseeded global), so a model with a multi-elementthrust_sourceisn't fully seed-controlled yet. The test uses a single-thrust_sourcerocket to sidestep it; a full fix would seed the stdlibrandomper index too.Breaking change
The exact numbers a seed produces change (per-index seeding, the env/rocket/flight decorrelation, and the serial index now counting from 0 to match parallel), so stored Monte Carlo baselines such as
test_monte_carlo_simulateregenerate. There's no API break for users who don't pin exact samples, andrandom_seedis opt-in.One more heads-up: Python 3.14 flipped the Linux multiprocessing default from fork to forkserver, so workers no longer inherit memory and the seed objects crossing the process boundary must be picklable.
SeedSequenceis, and the test passes under fork.Closes #1053