Repository navigation
Conversation
Simulation.subsample was inherited from policyengine-core and raised TypeError: it rebuilds through build_from_dataset() with no arguments, and the UK builder takes the dataset to load. Making that argument optional would not have been enough. Core's method reads each person's household and benefit unit back from the stored person_household_id and person_benunit_id inputs, which the UK model holds as 32-bit floats, so identifiers above 2**24 would merge households. It also leaves Simulation.dataset as a core Dataset, while UK formulas read dataset.years, and it rebuilds a baseline branch, while a UK baseline is a simulation of its own. The UK method takes households, benefit units and people from the populations, keeps every supplied input for the sampled records, rescales each supplied weight to keep its total, and subsamples the baseline and the dataset with the same draw. Fixes #2254 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A result calculated before subsampling is calculated again on the sample, and the record of an input that a calculated value replaced is not kept. Found by mutating the subsample module: removing the cache invalidation went unnoticed until these assertions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The simulations usage page gains a short section on subsampling, since the method now works for UK simulations. A simulation built from a DataFrame keeps no dataset object, so test that subsampling it leaves dataset as None and still cuts the baseline to the same households. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI formats Python blocks in Markdown with the latest ruff, which the locked ruff does not check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Merge-order note from #2273 (fixes #2267, stores On a local merge of this branch ( assert len(set(stored.tolist())) < len(households)With #2273 the stored values tell the 900-million households apart ( Whichever PR lands second should:
🤖 Generated with Claude Code |
|
UK hub, for this PR's owner: test interaction with #2270. #2270 (DataFrame inputs load as the dataset they describe) is armed to land once its
This PR's last |
Address the independent review of #2265: - Subsampling a clone changed the source simulation's baseline, because policyengine-core's clone shares a separate baseline. Replace each baseline with a subsampled copy instead of changing it in place. - A calculated value cached in memory over an input stored on disk was kept as the input. Read each supplied input from the tier the holder records it in, as Holder._stores_user_input decides. - A trace kept values calculated on the replaced populations. Give each simulation a new tracer, shared as before. - Say that dataset holds the rows of the dataset the simulation was built from, without later set_input values, and that weights are unbiased in the sampling period only. Generate multi-year datasets whose weights change differently by household in the property test. Also adapt to main: a DataFrame-built simulation now has a dataset, and test memberships without assuming how the identifiers are stored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Address the second independent review of #2265: - Copying a baseline with clone copied every cached array before the subsample discarded them, which took a full-size run past a 20 GB memory limit. Announce the copy as get_branch does, so it shares the arrays as views and copies none. - A negative household weight could leave a negative weight in the sample. Refuse negative sampling weights, and negative values in any weight being rescaled. - An input for part of a year, which a DataFrame can give, stays in the simulation and out of the dataset of whole years. Say so, and test it. - Give the property test two explicit cases whose weight shares change from year to year, which the generated cases had not reached. - Qualify the docstring's sampling and unbiasedness claims by quantize_weights, and describe the first-weights fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mutation testing showed the rescaling check alone also caught a negative household weight, so removing the draw's own check went unnoticed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
For coordination: #2308 (fixes #2307) is stacked on this branch and builds on
Nothing in this PR needs to change for #2308. If |
|
CI run cancelled to free runners (UK hub, 2026-10-11 23:30Z). The PolicyEngine org is at GitHub's limit of 60 concurrent jobs, with about 360 queued. Fleet ops asked the hub to let the Budget-critical #2305 take runners first. This PR's queued run had not started, so I cancelled it. Nothing is wrong with the PR. Please don't re-run it or push just to retrigger CI. The hub re-runs this run itself once #2305 has runners. The record is |
Fixes #2254
What was wrong
Simulation.subsample()was inherited from policyengine-core and raisedTypeError: Simulation.build_from_dataset() missing 1 required positional argument: 'dataset'. I reproduced it onmain(2.125.2) with a six-household in-memory dataset, on policyengine-core 3.32.27 (the locked version) and 3.33.1.Why the signature was not the whole problem
I read core's
subsampleand flat-file loader (master8fdf59e9, 3.33.2) against the UK builders, and ran the issue's option 3:build_from_dataset(self, dataset=None)falling back toself.dataset, core'ssubsamplehands the UK builder the flat-fileDatasetit rebuilt withDataset.from_dataframe. The UK builder callsdataset.load_dataset(), which reads only the table, time-period-array and array formats, and raisesUnboundLocalErrorfor a flat file. I ran this on core 3.32.27 and 3.33.2. The UK builder would also rebuild every year from the first one, projected forward, rather than keep each year's inputs.person_household_idandperson_benunit_idinputs. Onmainthe UK model stores both as 32-bit floats, which hold integers exactly only up to 2**24 (16,777,216). In the full-size UK dataset the issue's reproduction used, household identifiers reach about 900 million: its 535,080 distinct household identifiers are only 76,551 distinct values as float32, and its 618,980 benefit unit identifiers are 544,894. A rebuild from those columns would merge households and benefit units. (Store person_household_id and person_benunit_id as integers #2273 proposes storing them as integers. This PR does not depend on it.)branches["baseline"]. A UK simulation's baseline is a separateSimulation, which core's method does not touch, so a reform and its baseline would hold different households.seed, core readsself.dataset.name. UK datasets have noname.weight__. In the UK that would includehousehold_bus_fare_age_weightif it were supplied as an input; it shares out bus fare spending and is not a sampling weight.So this is option 1 in the issue: a UK
subsample. It does not call core's method and does not changebuild_from_dataset. Nothing needs to change in policyengine-core.What the UK method does
Same signature as core's:
subsample(n=None, frac=None, seed=None, time_period=None, quantize_weights=True).nhouseholds with replacement and keeps every household drawn at least once, with its benefit units and people. Only households with a positive weight intime_periodcan be drawn: withquantize_weights=True(the default), each with probability proportional to that weight; without it, each with equal probability. A period before the first one the simulation was given household weights for has none, so the first given weights are used.idsandmembers_entity_id), which are exact, not from the stored identifier inputs.set_input) for the sampled records, for every period. It reads each input from the storage tier (memory or disk) the holder records it in, so a value calculated later and cached over the input is not kept. Calculated values are dropped and recalculated on the sample, and the tracer starts again.household_weight,benunit_weight,person_weight,original_weight,raw_person_weight), and a test fails if a new variable ending inweightis not classified.cloneshares a separate baseline between a simulation and its clone, so subsampling a clone would otherwise cut down the source's baseline. The copy is made asget_branchmakes a branch, so it shares the old baseline's arrays as read-only views instead of copying them, and it rebuilds its own at once. (A plainclonecopied every cached array first, which took the full-size run below past a 20 GB memory limit.) The old baseline stays in memory until nothing holds it.datasetwith the rows of the dataset the simulation was built from for the sampled records, weights rescaled the same way. The original object is not changed (it can be the shared entry of the URL cache). Like the dataset before subsampling, it holds whole years only, and not values set withset_inputafter the build.Differences from core's method, all deliberate:
numpy.random.RandomStateseeded from a SHA-256 digest of the seed, so a seed draws the same households in every process, and the global random state is not touched. A seed therefore does not pick the same households as it would in core.ValueError.Invariants
Stated in
policyengine_uk/tests/test_subsample.pyand tested with Hypothesis for generated datasets of one year (which the model projects) and of several years whose weights change differently from household to household (two explicit examples make sure the shares change), for everyn, seed, sampling period andquantize_weights:nhouseholds are kept, each with a positive weight in the sampling period.n, the multiples sum ton, and the same draws set every year's weights. Withquantize_weights=False, it is proportional to its old weight times a whole number of draws.datasetholds the same records as the populations. For a simulation whose inputs are all in its dataset, a simulation built from that dataset gives the same results as the subsampled simulation. This compares the two implementations of the subsample: the one that cuts the stored inputs and the one that cuts the dataset tables.Simulation._get_recorded_input_array(it skips on 3.32, which has no such method).Intended exceptions, each pinned by a test:
quantize_weights=Falsekeeps the total exactly, which makes the new weights a ratio estimate. I measured the bias: about 6% at 40 draws and under 1% at 400, for a seven-household example. This is the same definition as core's.dataset: one set withset_inputafter the build, or one for part of a year, which a DataFrame can give and a dataset of whole years cannot hold. A weight set withset_inputon the simulation alone is rescaled to its own total there, while the baseline and the dataset keep the dataset's weights.Tests
policyengine_uk/tests/test_subsample.py: 52 tests. They cover the reported call, each failure mode above, each refusal, the findings of two review rounds, and the properties.01f5d051: I made 22 single changes toutils/subsample.pyandutils/supplied_inputs.pyand ran the file against each. 21 were caught. The survivor removes the reset of the input record, which_invalidate_all_cachesrepeats later in the same function, so it changes nothing observable.82e0c499(the branch withmainmerged in), one process each, of the new file plustest_input_records.py,test_simulation_dataset_sources.pyandtest_dataframe_dataset.py:5dd27677(the code has not changed since; later commits add a test and mergemain), on the full-size UK dataset the issue's reproduction used (535,080 households):subsample(n=10_000, seed=0, time_period=2026)took 6.7 seconds and kept 8,660 households. From the weights alone, the expected number of distinct households in such a draw is 8,634. Memberships matched the dataset's 64-bit identifiers exactly, the 1,260 supplied variable-periods were the same as before, the baseline was a new copy with the same records and the old baseline still held every household, every year ofdatasetheld the same records, and each year's household weight total was unchanged to 3 parts in 100 million. The same held forquantize_weights=False(9,864 households, 4.0 seconds).cloneversion exceeded. On a synthetic dataset of 60,000 households with 922 supplied variable-periods, the subsample raised the process's peak resident memory from 2,755 to 2,786 MiB (about 1%).test_dataframe_dataset.py::test_an_exported_frame_loads_back_and_exports_itself, which was failing onmain(Stop deciding a household's UC health element rate by its position #2251 with Load a DataFrame simulation input as the dataset it describes #2270) until Export the identifiers for every year an input frame names, so it round-trips #2349; this branch now includes that fix.Found on the way
Simulation(dataset=<DataFrame>)failed for households of more than one person. Fixed by Load a DataFrame simulation input as the dataset it describes #2270, merged; this branch includes it.person_household_idandperson_benunit_idare stored as 32-bit floats. Store person_household_id and person_benunit_id as integers #2273 is open. This PR reads memberships from the populations and does not depend on it.supplied_inputandsupplied_input_periodsreport a value calculated and cached over an input as supplied. Count only supplied inputs that are still stored in their tier #2308 is open and stacked on this branch: it builds onstored_supplied_valuefrom this PR.axiom: n/a: simulation infrastructure, no policy rule change
🤖 Generated with Claude Code
Reproducer:
policyengine_uk/tests/test_subsample.py::test_subsample_runs_on_a_dataset_simulation,policyengine_uk/tests/test_subsample.py::test_subsample_is_the_uk_method_not_policyengine_cores,policyengine_uk/tests/test_subsample.py::test_identifiers_beyond_float32_precision_keep_their_households,policyengine_uk/tests/test_subsample.py::test_memberships_come_from_the_populations_not_the_stored_identifiers,policyengine_uk/tests/test_subsample.py::test_zero_weight_households_are_never_kept,policyengine_uk/tests/test_subsample.py::test_quantized_weights_count_draws_and_keep_every_years_total