Skip to content

Calibrate households' SDLT to the OBR total less corporate SDLT - #569

Draft
MaxGhenis wants to merge 1 commit into
mainfrom
sdlt-target-excludes-corporate
Draft

MaxGhenis wants to merge 1 commit into
mainfrom
sdlt-target-excludes-corporate

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Data release. Hold as a draft for the next batch (d833). It lands only inside the next batch release, on Max's go. The batch must also lock a policyengine-uk that includes PolicyEngine/policyengine-uk#2322: full builds refuse an earlier one (see "Guard").

Fixes #564.

What

  • Target. obr/sdlt now targets stamp_duty_land_tax at the OBR's SDLT line less policyengine-uk's corporate SDLT statistic for the same year: gov.hmrc.stamp_duty.statistics.{residential,non_residential}.corporate.revenue, read from the installed policyengine-uk (targets/sources/obr.py: corporate_sdlt_receipts, household_sdlt). The target keeps its name and reference.
  • Guard. A full build (no TESTING=1) stops at its preflight (utils/build_environment.py) if the resolved policyengine-uk holds the corporate statistic at the same value in every OBR year from 2024 to 2030, as versions before #2322 do (£3.13bn, the 2020-21 figure). Target building (and so PR CI's TESTING=1 build) logs a warning in that case instead, so this PR's CI runs on the currently locked policyengine-uk 2.122.2.
  • Tests. tests/test_property_purchased.py compares households' stamp_duty_land_tax with the household-only target, not expected_sdlt against total receipts, and checks that weighted corporate_sdlt equals the statistic subtracted. The new tests/test_obr_sdlt_target.py checks the target identity each year and the invariants below. tests/test_obr_receipts_sheet.py's synthetic workbook now carries £100bn a year so its SDLT row stays above the statistic.

Why

The OBR's SDLT line (EFO March 2026, Table 3.8; it equals Table 3.7's "Total SDLT" every year) covers all buyers in England and Northern Ireland. policyengine-uk adds SDLT paid by companies and other non-natural persons separately as corporate_sdlt: the statistic times each household's shareholding, which divides its corporate-sector wealth by the weighted total, so weighted corporate_sdlt is the statistic whatever the weights. corporate_sdlt is in gov_tax and household_tax (policyengine-uk 2.122.2). Calibrating stamp_duty_land_tax to the whole line therefore counted the corporate share twice.

Measured

Microsimulation runs on the released 1.58.0 enhanced_frs_2024_25.h5 (sha256 5ba399ea…d960b6), which was calibrated to the old target. £bn, PolicyEngine years (2025 = 2025-26).

2025-26 2026-27 2030-31
OBR SDLT (Table 3.8) 15.41 18.15 26.16
corporate statistic, policyengine-uk 2.122.2 (locked) 3.13 3.13 3.13
corporate statistic, policyengine-uk #2322 head cafd3f1d 4.81 5.98 8.87
new target, with 2.122.2 12.28 15.02 23.03
new target, with #2322 10.60 12.17 17.30
households' stamp_duty_land_tax on 1.58.0 16.32 17.31 21.36
weighted corporate_sdlt on 1.58.0, with #2322 4.81 5.98 8.87

With #2322, 1.58.0's household SDLT is 1.54 times the new 2025-26 target, and the new full-build test fails on it as it should. With 2.122.2 the ratio is 1.33. Weighted corporate_sdlt matches the statistic to within £600 under both versions (float32). The household SDLT a rebuilt dataset reaches is pending. This PR's CI runs a reduced TESTING=1 build on 2.122.2, and the full build comes with the batch.

Is the new target attainable? This is a feasibility check, not the build pipeline (reweight_experiment.py). Starting from 1.58.0's weights, I reweighted against the 645 national targets only, with #2322's head. The loss was the pipeline's symmetric relative error, run for 512 Adam steps at lr 0.1, once with the old SDLT target (OBR total, £15.41bn) and once with the new one (£10.60bn), all else equal. Both runs hit their SDLT target within 0.02% (£15.41bn and £10.60bn), and the final loss was the same to six decimal places (0.038323). No other target's relative error differs between the runs by more than 0.3 percentage points, and 93.5% of the other targets are within 10% in both. The pipeline also calibrates 650 constituencies jointly, so this shows the target is cheap to meet nationally, not what the full build will reach.

Invariants

For every year the OBR forecasts (2024-25 to 2030-31):

  1. Accounting identity. target + corporate statistic = OBR SDLT. Checked on the committed EFO workbook against the installed policyengine-uk, and as a Hypothesis property over arbitrary totals and statistics.
  2. Bounds. 0 ≤ target ≤ OBR SDLT whenever 0 ≤ statistic ≤ OBR SDLT (Hypothesis). A statistic above the OBR figure raises, naming the year, instead of producing a negative target (Hypothesis). The per-row handler then skips the SDLT target with a warning; the other receipts targets are unaffected.
  3. Differential: subtracted = added. The statistic the target subtracts equals what policyengine-uk's own corporate_sdlt formula adds: a one-household simulation with shareholding = 1 in every year (rel. 1e-6), and weighted corporate_sdlt over the built dataset in 2025.
  4. Same base. The current-receipts SDLT line equals the property-type total (Table 3.7) that #2322 projects the statistic with.

With households on target, model SDLT (stamp_duty_land_tax + corporate_sdlt) is therefore the OBR's figure. Mutation checks: dropping the subtraction fails the identity test. Dropping the non-residential part of the statistic fails the differential test.

Guard and dependency

Tests

  • test_obr_sdlt_target.py, test_obr_receipts_sheet.py, test_build_environment.py, test_obr_efo_fallback.py, test_obr_nics.py, test_obr_nic_signal.py, test_target_registry.py: 80 passed, 5 skipped, under both policyengine-uk 2.122.2 and #2322's head.
  • test_property_purchased.py on 1.58.0: passes with 2.122.2 (full and TESTING=1 bounds). With #2322 it passes under TESTING=1 and fails the full bound (16.32 vs 10.60), as intended.

axiom: n/a: calibration target in the data build, no policy rule changed.

Evidence: ~/reviews/uk-data-sdlt-corporate-564/ (measure.py, measure_2122.json, measure_2322.json, sim1.py, stat.py, reweight_experiment.py/.json/.log).

🤖 Generated with Claude Code

The OBR's SDLT line covers every buyer, but policyengine-uk adds SDLT
from non-natural persons separately as corporate_sdlt, so targeting
stamp_duty_land_tax at the whole line counted the corporate share twice.
Subtract the installed policyengine-uk's corporate statistic for each
year, warn when that statistic is held flat (policyengine-uk before
#2322), and refuse full builds against such a version.

Fixes #564

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Batch dependency with pe-uk #2241 (fixes policyengine-uk#2238) and uk-data #556.

#2241 stops charging a household's other-property stock as an additional-dwelling purchase. additional_residential_property_purchased becomes an input. On 1.58.0 that removes the higher-rates part of household stamp_duty_land_tax: £7.76bn in FY2024-25 on main, £8.7bn of household SDLT in 2025. HMRC's 2024-25 tables (6b, 8a) imply individuals paid at least £4.2bn of HRAD receipts.

If this PR's target (OBR SDLT less corporate SDLT) is built on a policyengine-uk carrying #2241, but without an imputation of additional-dwelling purchases, calibration will push main-home SDLT to absorb the missing higher-rates share. #556 adds that imputation, calibrated to HMRC's HRAD statistics; a draft PR is being built now. So #556 should land in the same d833 batch as this PR, or the target should be held until it does. Max's decision on adopting #556 is d1354.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SDLT calibration target includes corporate SDLT, which policyengine-uk adds separately

1 participant