Repository navigation
Calibrate the QRF sign gates out of fold, starting with the LCFS chain - #1391
Draft
juaristi22 wants to merge 17 commits into
Draft
juaristi22 wants to merge 17 commits into
juaristi22 wants to merge 17 commits into
Conversation
microcosm#1359, step A1. The chain draws two shares after its twenty targets (the eighteen spend targets and the two heating fuels): tobacco's share of the off-trade alcohol and tobacco that make up P602, and on-trade alcohol's share of restaurants and hotels (P611). The derive step declares them and computes them on the donor from the adult diary lines (LCF 2024-25 Volume F), with refunds as zero, a zero denominator as zero and the drinks past P611's one-decimal rounding held at one. A household's tobacco, off-trade and on-trade alcohol spending is its share of the total it drew, so the parts sum to the totals and no total, the VAT base included, moves; appended last, the shares leave the twenty draws bit-identical (tested against the chain without them). The shares and the spending they give are scratch, on no table and outside the support clip. A report-only excise_shares receipt sets each share on the frame beside the donor's, in bands and withheld shares only. The spec, schema and source_stages.json declare the shares; the coverage manifest's source hash and the H2 fixture tool's synthetic donor follow. The H2 fixture itself is regenerated once, at the end of the PR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ared microcosm#1359, step A2. The step gives each household its part of each national excise quantity in proportion to its spending at prior weights: alcohol volumes by its off- and on-trade alcohol spending (in proportion to the two, or each channel's published share of the volume to that channel's spenders), tobacco clearances by its tobacco spending. Each policyengine-uk input it writes names its published row, unit, share of the row and, for hectolitres of alcohol, the strength (with the engine parameter it mirrors) that turns them into litres of the product. The fiscal year is the one policyengine-uk charges excise over, so no bridge is needed; weekly inputs are the annual quantity over the engine's 52 weeks. Outputs that share a row must take all of it. The one-factor scaling level_road_fuel used is now _scale_to_level, shared by both and unchanged for road fuel. The excise_quantities stage-health check recomputes every level from the vendored rows through the stage's own declaration and holds the frame totals, factors, incidence, basis, share range, share support and the published total's residual beside its lines to the receipt. Nothing declares the step yet: its facts wait for PolicyEngine/chronicle#381, so its tests read synthetic rows (test_support/microcosm_build/uk_excise_facts.py), never publisher ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
microcosm#1359, step A3. The export-surface gate checks a column only against the enhanced-FRS reference, so an engine test holds the excise step to policyengine-uk 2.145.0: every input it can write is a household, yearly, float input with no formula; the engine's 52 weeks are the step's; each declared strength equals the engine parameter it names; and alcohol and tobacco duty are positive, proportional to the quantities and in household tax for 2024, 2025 and 2026. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
microcosm#1359. Chronicle applies the publisher's scale, as its hydrocarbon-oils package does: OBR's thousand hectolitres of alcohol, million sticks and thousand kilograms arrive as litres of pure alcohol, sticks and kilograms, with the published unit kept only as a dimension (PolicyEngine/chronicle#381, branch uk-excise-facts-381). The step declared the published units and scaled by 1e5 and 1e6, which would have put the alcohol volumes 1e5 too high. It now declares the stored units (litre_of_pure_alcohol, litre, stick, kilogram, scaled by one or a thousand) and refuses a row whose own unit is not the declared one, so a value can no longer be read in the wrong scale. The synthetic facts move to the stored units with the same levels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
microcosm#1359. María ruled on 2026-10-23 to keep spending weights for the alcohol volume: no on-trade share of volume can be archived, since MESAS is a dashboard of NielsenIQ and Circana data with no fixed file. The rule sets no level, but a pound buys the same volume in a pub as in a shop, where a unit costs about three times more. The rule's comment records the measured size on the LCFS 2024-25 diary against a quarter of the volume sold on-trade: a pub pound carries about 1.6 times its volume and a shop pound about 0.8 times, about a tenth of the duty sits with other households, and the bottom three income deciles bear about 7 % too little of it and the top decile about 7 % too much. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
microcosm#1359, step C1. A third share follows the two excise shares in the chain: gambling stakes over recreation (P609), from the seven COICOP 09.4.3 stake lines (football pools, bingo, other lotteries, bookmaker and tote betting with gaming machines, the Irish Lottery, National Lottery scratchcards and draws; LCF 2024-25 Volume F, Part 2 rows 1631-1649). Like the others it is computed on the donor from the weekly lines, clipped at one because the tab stores P609 to one decimal place, appended last so no earlier draw moves, and scratch: a household's stakes are its share of the recreation it drew, so the total and the VAT base keep their draw. The synthetic donors and the H2 fixture tool carry stake lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…m#1359) level_excise_quantities now also writes: - beer_draught_share and cider_draught_share, each the clearances under draught relief (alone or with small producer relief) over all of that product's clearances, in litres of pure alcohol; - policyengine-uk's thirteen gambling duty bases (PolicyEngine/policyengine-uk#2590, from 2.151.0). A single-duty base is HMRC's receipts for the duty, or Machine Games Duty's liabilities for the band, over the engine's rate, so the engine charges back the published receipts. General Betting Duty's base, its receipts over the fixed-odds rate, is split over its five inputs by the Gambling Commission's gross gambling yield for Great Britain. Every base goes to households by their drawn gambling stakes at prior weights, the alcohol and tobacco rule. A gambling series may pin dimensions of its own, such as the cash accounting basis. The vendored resource is renamed excise_facts.json: it carries HMRC's clearances, receipts and liabilities and the Commission's yield. The stage-health gate checks the receipt's draught shares against the declared rows. The engine test checks every new input and rate against policyengine-uk, and that gambling duty is linear in the bases and reaches household tax; it needs policyengine-uk 2.151.0 or later. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…microcosm#1359) The feed moves from abf0921 to 200bf6d55, Chronicle main after PolicyEngine/chronicle#384 (1,502,291 rows, up 2,990; the schema digest is unchanged). It brings HMRC's alcohol and tobacco clearances, betting and gaming duty receipts and Machine Games Duty liabilities, the Gambling Commission's industry statistics for Great Britain and OBR's excise lines. It also replaces the June 2026 Consumer Trends edition with September's. - excise_facts.json vendors the 98 FY2024-25 rows the LCFS excise step reads. Every other vendored resource carries the same rows under the new feed identity, except ons_household_expenditure_facts.json, whose 93 rows take the September values. - Five OBR rows join the national contract, held out of the objective: beer and cider, wine and spirits duties accrued, and tobacco and betting and gaming duties cash. Each is measured at FY2024-25 (measurement_period 2024, require_matching_fact_period, a matched_period hold), and diagnose_uk_consumption_taxes.py measures them at that period. The exclusions expire on 2027-01-08 and the hold on 2026-12-11. - The September revisions move three compiled values. Calendar-2024 household road fuel (07.2.2, bound) goes from GBP 37.025bn to GBP 35.460bn; the measured calendar-2025 electricity and gas rows also move. - The local surface is byte-identical but for the membership's feed label. The national references and parity were regenerated from a cut of the pinned artifact without its constituency, MSOA and county rows. The local ones came from a cut to the constituency, local-authority and non-small-area statistical-scope rows. docs/uk-chronicle-feed-repin.md records the entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…icrocosm#1359) The lcfs_consumption stage now declares level_excise_quantities on the vendored excise_facts.json. It reads HMRC's FY2024-25 clearances of beer, cider, wine, spirits and other fermented products at the engine's strengths, the beer and cider draught shares, the four tobacco products and the thirteen gambling duty bases. Each is shared out by the drawn spending at prior weights, with alcohol volume following spending. - The stage writes 24 more household outputs, all nonnegative, after its existing ones and in declared order. The schema, the allowed operation kinds and the graph's typed cells know the step, and the export allow-list (code and gates.json) carries the inputs. - The stage-health gate uk_stage_lcfs_consumption_excise_quantities recomputes every level from the vendored rows. It joins the spine gate scope and the microcosm-data contract mirrors, whose digests are re-pinned. - consumption_drift reports each quantity and base at design and final weights. - The stage notes document the spending rule's lean towards on-trade drinkers and the gambling mapping. The engine checks of the gambling inputs need policyengine-uk 2.151.0 or later. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-pin (microcosm#1359) lcfs_consumption declares 24 more outputs, which flow into the inputs of the stages after it. Every stage that declares a re-vendored resource moves its stage_contract_sha256. The synthetic LCFS donor carries the alcohol, tobacco and gambling diary lines the shares read. The sources are unchanged, and H2 parity passes on policyengine-uk 2.151.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
experiments/1359-excise-quantities-receipts.md gives: - the national figures the step levels to; - a stage-only run on a real spine against microcosm#1383's head; - the re-pin's value moves; - the tests. docs/evidence/uk-excise-quantities-1359/ holds the scripts and the sanitized outputs: aggregates only, with no column digests or donor extremes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…crocosm#1359) Vahid's review: every gambling base followed one stakes total, so National Lottery players carried remote gaming and betting duty. The chain now draws two more shares after the gambling stakes, appended so every earlier draw stays bit-identical: - the National Lottery's share of the stakes (c94316, c94319); - the betting and gaming share of the rest (c94311, c94312, c94314). Lottery Duty's base follows the National Lottery stakes and the other twelve bases follow the betting and gaming stakes. Society lotteries (c94313) and the Irish Lottery (c94315) pay no UK gambling duty and carry no base. Each share rests on 30 or more donors. The stage notes also state the known biases the review asked for: - each duty as a multiple of the spending that shares it out; - incidence against published prevalence, and where the frame loses it; - vaping inside other tobacco; - the national draught shares; - the overstated remote UK horse-racing base. A stage-only run on a real spine leaves 33 of the 46 columns byte-identical to the restacked head. Only the 13 gambling inputs move, and all eight LCFS gates pass. source_stages.json, the coverage manifest and the H2 fixture re-derive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ocosm#1359) Vahid's review: nothing noticed if a rate or strength change moved the duty the levels give. A new engine test runs the committed declaration's national levels through policyengine-uk as one household. Every duty is linear in its inputs, so this is the frame's duty at prior weights. The test pins each OBR row's gap at FY2024-25: - beer and cider +8.1%, wine +2.6%, spirits +2.2%; - tobacco +3.0%; - betting and gaming +0.1%. The proportionality tests also check that household_net_income loses the duties. The five measure exclusions now state those figures. Betting and gaming gets a reason of its own: its bases are receipts over the engine's rates, so the row is an identity, measured for weight drift and not as evidence. Its gap is Machine Games Duty's liabilities against its cash receipts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The receipts answer Vahid's review with aggregates only: - the engine's duty on the national levels against OBR's FY2024-25 rows, with betting and gaming shown to be an identity; - each duty as a multiple of the spending that shares it out, and the share of households carrying it, against ONS and Gambling Commission prevalence; - where the frame loses incidence against the donor: in the share draws, not the parent totals; - the gambling split's stage run, and how it concentrates the betting and gaming duties on the diary's incidence. The changelog names household_net_income, which falls by the three duties (about £24.8bn at FY2024-25), and states the multiples. docs/evidence/uk-excise-quantities-1359/ gains the reconciliation and incidence scripts and their outputs, and the split run with its comparison. Every donor share in them rests on 30 or more donors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sm#1359) A sign gate is a default HistGradientBoostingClassifier calibrated only on its own training rows. It memorises a rare class's training rows, so on every other row, every recipient included, its mean probability of that class falls short of the class's rate. That mean is the share of rows drawn into the class. On held-out LCFS 2024-25 donors, out of fold, the lcfs_consumption chain's gates gave education 0.42 of its rate and solid fuels 0.11. RegimeGatedQRF now takes a gate_calibration, named by GateCalibration. in_sample, the default, is the gate as before. cross_fitted fits a gate on each of five folds of the training rows, stratified by class. One weighted calibration of a slope and per-class offsets (Platt scaling, for two classes) is fitted on the gates' out-of-fold log-probabilities and maps each of them, and a row's probability is the mean of the mapped gates. On the same donors it puts every chain target's out-of-fold mean probability at its rate (education 0.997, solid fuels 1.003) and lowers every target's log loss. Regularising the gate alone, a single calibrated gate, and shifting the mean all left a gap. - The fold assignment and the fold seeds derive from the seed the in-sample gate takes, so the fit RNG advances by the same draw and every forest and draw uniform is unchanged. - A class with fewer rows than folds keeps the in-sample gate. - QRFChainState carries the calibration. It writes it into model_config only when it is cross-fitted, so in-sample checkpoints serialize as before, and a resume under another calibration is refused. qrf.py's bytes move the fit.qrf graph-parity pins, re-pinned with tools/graph_parity_repin.py; their draws are positive-only, so no gate is fitted and they do not move. They also move the spec-loader golden vector. The seed protocol re-pin follows in its own commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…alibration (microcosm#1359) microcosm.fit.qrf is an attested kernel module, and the opt-in cross-fitted gate calibration changes its bytes. So the seed protocol's content digest and the compiled seed stream map digest legitimately move, as they did in 1734b9e. The US spec is unchanged, and the base matched the old pins. - The committed F0 coverage report is regenerated from the re-pinned inventory, and tools/spec_engine_coverage.py --check passes. - The US bundle hash that the multispine pool tool test pins moves with them, to the value the regenerated report carries. Only CI's engine-us and engine-workflow tiers check these here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lcfs_consumption chain declares gate_calibration: cross_fitted on its fit_weighted_qrf_chain, so each gate draws a spending at its donor rate on frame households, which the gates were never fitted to. The in-sample gates drew rare spending there too rarely: on held-out donors, solid fuels about a tenth as often as they occur and education under half as often. The forests and the keyed uniforms are unchanged. A stage-only run on microcosm#1333's test spine (78,440 households, prior weights) passes every LCFS stage-health gate. The share of households with each spending moves as follows, with the LCFS donor's share in brackets: - solid fuels 0.33% -> 2.25% (2.47%); - liquid fuels 3.09% -> 3.88% (4.54%); - education 4.31% -> 6.65% (5.83%); - tobacco 8.16% -> 11.16% (10.34%); - gambling stakes 14.63% -> 16.70% (17.03%); - betting and gaming 1.96% -> 2.63% (3.58%). The near-universal divisions' zeros are now drawn at their rate too, so their incidence moves towards the donor's. The twelve divisions' prior-weighted total falls 1.4%, and the seven divisions held at the shared frame-to-donor ratio fall with it. The excise inputs keep their national levels, but the multiples of spending change. Tobacco duty is about 1.8 times tobacco spending, down from 2.2, and the other gambling duties are 5.5 times betting and gaming stakes, down from 6.4. Lottery Duty is 0.61 times National Lottery stakes, up from 0.56, and alcohol duty 0.45 times alcohol spending, up from 0.41. The stage notes carry these figures and the cross-fitting. The derived files follow the declaration: source_stages.json, the coverage manifest's source digest, the schema's enum on the operation and the charter-H2 fixture. The H2 fixture changes only in the stage's notes and the new parameter. A control with the new qrf.py and no declaration reproduces the stage's earlier run byte for byte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22
force-pushed
the
uk-excise-quantities-1359
branch
from
October 24, 2026 17:17
03427be to
e80a424
Compare
MaxGhenis
reviewed
Oct 24, 2026
MaxGhenis
left a comment
Contributor
There was a problem hiding this comment.
Thanks María. This is a clear diagnosis, and calibrating the gate out of fold is the right fix. I'd land it after the Budget rather than in Monday's cut, since it moves 22 of the 24 excise inputs and the COICOP totals that the VAT coverage is being derived on.
Two changes before merge, and one wording fix:
- Fall back when a fold has too little weight.
_can_cross_fitcounts rows, so a target with zero-weight rows can pass it and still leave a training fold with no weight. sklearn then raisesZeroDivisionError: Weights sum to zero. A reproducer: ten donors, five per class, eight of them at weight zero. The in-sample gate fits; the cross-fitted one crashes. Please check each fold's training weight per class, and keep the in-sample gate when it falls short. The LCFS weights should all be positive, but this matters before other stages opt in. - Check convergence before returning the calibration. When the out-of-fold scores are saturated, the Newton step is huge and the line search hits its 1e-10 floor. The loop then returns the identity map with no warning, so the rate balance fails silently. A reproducer: ten rows, five per class, every logit 29. That gives a positive-class mean of about 1 against a rate of 0.5. Please check the weighted residuals at exit, and fall back to an offsets-only fit or the class rates, or raise. Real gates probably never get there, and your LCFS figures show the balance held, but it should fail loudly.
- Wording. The balance is exact on the pooled out-of-fold donor scores, not on recipients. Also, in the chain, a sign that changes moves later targets' amounts through their predictors, even though the forests and uniforms are the same. The changelogs and the module docstring should say both.
Please also add Hypothesis tests for the invariants:
- the weighted rate balance, including saturated and constant scores;
- the sparse-weight fallback;
- base-versus-head byte identity on the default path.
The two reviews (one by Claude, one by GPT-6.1 Sol) checked these, all on synthetic data:
- The default path is byte-identical to the base, and so are 36 checkpoint byte hashes across all seven regimes.
- Resuming under a different calibration is refused.
- LCFS is the only opt-in.
- The 22 UK manifest re-pins and the US seed-protocol and seed-map pins recompute.
- On held-out data the cross-fitted gate lands at about 1.0 of the true rate with 100 or more positives; the in-sample gate lands at 0.05 to 0.5.
I can send you the reproducers and the proposed property tests.
This was referenced Oct 25, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follows Vahid's review of #1381 (#1359).
Microcosm's regime-gated QRF decides whether each recipient row is zero or positive with a boosted classifier, the sign gate. The gate is calibrated only on the donors it was fitted to, so on every other row it draws rare outcomes too rarely.
This PR adds an opt-in that calibrates each gate out of fold, and turns it on for the LCFS consumption chain only. Every other UK stage and every US fit keep today's gate, and their draws do not move.
The problem
Addressing Vahid's review of #1381 showed that the excise shares the LCFS chain draws lose incidence on the frame. Tobacco sat on 8.2% of frame households against 10.3% of donors, and betting and gaming on 2.0% against 3.6%.
Held-out tests trace it to the gate, not to the frame.
A row is drawn positive when its keyed uniform falls under the gate's probability, so the share of positive draws is the gate's mean probability.
The gate is a default
HistGradientBoostingClassifier, fitted and calibrated on the same donors: 100 rounds, 31 leaves, 20 rows a leaf, and no early stopping below 10,000 rows. It memorises a rare class's donors, so on rows it was not fitted to, its mean probability of that class falls short of the class's rate.Out of fold on the 4,995 LCFS 2024-25 donors, the current gates' mean probability over the true rate is:
For education, solid fuels and gambling stakes, the held-out log loss is worse than a constant at the class rate.
On the frame, rare chain targets lose incidence against the donor. In a stage-only run on #1333's test spine, at prior weights:
How the QRF model changes
An opt-in on
RegimeGatedQRF.RegimeGatedQRF(gate_calibration=...)takes a name from the newGateCalibrationconstants.in_sample, the default, is the gate as before, byte for byte.cross_fittedworks in four steps:What does not change.
predict,predict_from_uniforms, keyedfit_draw_nextandconditional_uniforms.predict_positive_from_uniformsbypasses the gate, as before.QRFChainStaterecords the calibration, but writes it into a checkpoint'smodel_configonly when it is cross-fitted. So in-sample checkpoints are byte-identical, the US banks and PUF chain included. Resuming under a different calibration is refused.Why this form. I compared the candidates out of fold on the LCFS donors, over one five-fold pass. The figure is the mean over the seven rare targets of |log(mean probability / rate)|:
It is written in
qrf.py, not through sklearn'sCalibratedClassifierCV. sklearn's stratified splits, andHistGradientBoostingClassifier's own early stopping, fail on a class with too few rows.Where it runs: only the LCFS chain
lcfs_consumption'sfit_weighted_qrf_chaindeclaresgate_calibration: cross_fitted, which the schema allows as an enum on that operation.impute_lcfs_consumptionpasses it to the model.qrf.pyand no declaration, reproduces Write policyengine-uk's alcohol, tobacco and gambling duty inputs from the LCFS stage #1381's stage run byte for byte.Results on the frame
This is a stage-only run of
lcfs_consumptionon #1333's test spine (78,440 households, policyengine-uk 2.151.0), the control against this head. All eight LCFS stage-health gates pass in both.Rare targets. Share of households with the spending, at prior weights, control → head, with LCFS donors in brackets:
Common targets. The in-sample gates also under-drew the zeros of the near-universal divisions. With those zeros drawn at their rate:
Excise inputs. They keep their national levels; who carries them changes:
The stage notes carry the new figures.
Columns and cost.
Read before reviewing
Re-pins. Any byte edit of
qrf.pymoves these, so they are re-pinned:fit.qrfgraph-parity pins (its draws are positive-only and do not move);Only CI's engine-us and engine-workflow tiers check the US ones.
What remains is the chain's own. The chain conditions each target on its own earlier draws, not on a donor's spending. Out of fold, with the chain's earlier draws as features:
On the frame, tobacco now sits above the donor. No gate calibration reaches that.
Write policyengine-uk's alcohol, tobacco and gambling duty inputs from the LCFS stage #1381's receipts and changelog record the in-sample figures as of Write policyengine-uk's alcohol, tobacco and gambling duty inputs from the LCFS stage #1381; this PR's changelog records the change.
Should the other gated fits move too?
This PR turns the calibration on only where it was measured. Every other gated fit keeps the in-sample gate, and is likely to under-draw its rare classes on recipients in the same way:
predict()fits, among them child support, childcare, disability benefits, retirement contributions and distributions, SCF wealth and auto loans, and the SIPP fits.Do we want to measure each of these with the same held-out harness and opt in the ones that need it, stage by stage? A global change of the default would do more than move every gated draw:
test_spec_engine_seeds.py;qrf.py.Opting in per stage avoids both, because a stage's declaration changes its hash.
Tests
Run locally on the stack, targeted to the touched surfaces, with JUnit counts and no failures or skips:
spec_engine_coverage.py --check;--checkandci_test_plan verify.Not run here, because they need the US engine:
engine_contract/us/test_spec_engine_coverage_tool.pyandengine_workflow/us/test_us_multispine_pool_tool.py. The wheels job did not run either.🤖 Generated with Claude Code