Repository navigation
Add disclosure control and uncertainty for UK geography outputs - #605
Conversation
Add policyengine.disclosure, a self-contained array-first layer: a floor on distinct survey households, a dominance rule or keyed noise, bootstrap intervals, precision-adaptive rounding, a release check for numbers a reader could recover by subtraction, and a format validator for published files. Add compute_uk_geography_disclosure as the Simulation adapter, keep source_household_id and region on UK household outputs, and deprecate the two unprotected constituency and local-authority functions. Fixes #589 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Close, but not ready to publish from yet: four gaps in the rules, each shown below on synthetic data, then smaller fixes. The structure matches what we agreed on #589, and the tests are strong. Everything below uses synthetic data only. Must1. The dominance rule misses two cases. The rule compares the largest household's
Suggested fix: also test the top household against the signed total, in p% form. Withhold when 2. A matrix with at most one non-zero per record is stored in the label layout. Label data skips the 3. The relation checks skip estimates added with
4. Constituencies and local authorities: subtraction between the two levels is unchecked once acknowledged. This blocks by default, which is right. But Should5. The floors can be lowered, and
6. The adapter quietly checks matrix levels against the nation, not the region. With a weight matrix for 7. The deprecation warning won't reach anyone in a real run.
8. The repr is safe: I checked 9. Docs and code disagree on noise for remainders. The docs, and your note on #589, say noise never protects a remainder. But Nits10. Two rules have no test that fails without them: widening the interval to include the value ( 11. Zero cells. I found no leak from publishing zeros. Two small things, though. A zero change cell gets band 12. Publishing 13. What I checked
What I couldn't check:
🤖 Generated with Claude Code |
Add the p% rule on signed totals to the dominance check. Keep weight-matrix rules for weights given as a matrix. Treat withheld cells of reference estimates as hidden in the package and nesting checks. Check each area against the areas of another level wholly inside it. Validate settings again before use and bound the change tolerance. Require region parents for matrix levels. Send the deprecation message through the log. Make AreaEstimates private and unpicklable. Give zero cells the band of the households measured. Check directions in validate_public. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks Vahid, all 13 are addressed in the next commit. Your probes for 1 to 4 are now tests. Two things need your eye: what the new dominance rule still lets through (1), and which way I went on noise for remainders (9). Must1. Dominance. Added the p% rule on the signed total, as you suggested (
2. 3. 4. Seats and local authorities. Added the containment check. For two label levels on the same records, each area is checked against the public areas of the other level that lie wholly inside it, in both directions. Your L1 = S1 + five households probe is a test, and it blocks whether or not the overlap is acknowledged. Should5. Floors. 6. Matrix levels. A matrix level with no 7. Deprecation. The message now also goes through the 8. 9. Noise and remainders. I picked the code's behaviour and fixed the docs. A remainder gets the rules of a cell: the count floor always applies, and with noise on, noise stands in for the dominance rule, as it does for a cell. What subtraction recovers is the noised sum of the withheld cells. My note on #589 said noise never protects a remainder; what I meant is that it earns no extra credit. Nits10. Both rules now have tests, and I re-ran your two mutations: each fails the suite. 11. A zero change cell now takes the band of the households it was measured on, as a zero share does. The docs say what a zero means. 12. Added to the docs. 13. The things you couldn't check
Also fixed: the |
|
Needs changes before merge: 11 of the 13 are fixed, but the new p% rule still misses signed cases, and one seat and local authority shape is unchecked. Thanks for the careful round. Everything below uses synthetic data only. Round 1, point by point
New findings1. (must) The p% rule only pairs the largest household with the second-largest by size, and only protects the largest. For contributions of one sign, the second-largest is the best attacker, so the usual p% rule is right. With mixed signs, it isn't. Two synthetic cells, each with 20 contributors, pass both rules and are published:
Suggested fix: treat every household i as a possible target. Let R = total − cᵢ. Withhold when |R| < p·|cᵢ|, for any reader, or when |R − cⱼ| < p·|cᵢ| for some other household j, the attacker. With sorted contributions and On the three-household case (3 × +500 and 17 × +2), I agree with leaving it out. p% protects against a single attacker. In that cell, two of the three together get the third to within about 7%, which is a coalition the standard rule doesn't cover, and the docs say so. Whether that is enough for the licence is a licence judgement, not a technical one. 2. (must) An area that lies wholly inside the union of two areas of the other level is not checked. The containment check takes one area and subtracts the areas of the other level that lie wholly inside it. It does not cover the mirror case. Probe on label data: S1 is 197 households of L1 and 3 of L2. S2 is 198 households of L1 and 2 of L2. S3 and S4 split between L2 and L3. No area of either level lies wholly inside an area of the other. With Suggested fix: run the mirror check. For each area X, take the areas of the other level that meet X, and run (their union − X) through 3. (should) With noise on,
On (9), assuming that floor: I think your choice is safe relative to the cell rule. Each household has one factor, so a remainder is the noised sum of the withheld cells. It covers at least 10 households and sits over an un-noised denominator. That is exactly what a noised cell publishes, so a remainder is never weaker than a cell. The point a licence reviewer should see is this: the noise range is 8 to 12%, and p is 10%. So a dominated cell or remainder under noise gives the top household to within 8 to 12%, right at the line p% treats as disclosure. I'd state that next to the noise range in the docs. Two limits still apply. First, an acknowledged 4. (nit) Containment skips a withheld outer area. The nesting check has 5. (nit) What I checked
🤖 Generated with Claude Code |
Apply the p% rule with every household as a target and any reader or any one other household as the attacker. Check each area against the areas of another level that meet it, and flag public areas that add up to a withheld area. Count a withheld parent as recoverable only when the remainder holds no records. Keep noise at least 8% from 1. Say in the changelog that the old functions log a warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks Vahid. All five are addressed in the next commit, and your probes are tests. 1. p% rule. Every household is now a target, and any reader or any one other household is an attacker, as you suggested. It uses 2. Mirror check. Added. For each area, the public areas of the other level that meet it, minus the area, go through On real geography this check matters, as you expected. In a private label run with seats and local authorities released together, the checks blocked many local-authority cells, and resolving them by hand did not converge in a few hundred rounds. In practice that means one of the two levels per dataset and run. A later release of the other level from the same data has the same remainders, so it would need the first one in 3. Noise floor. 4. Withheld outer area. Added: public areas that add up to a withheld area of the other level now give 5. Changelog. It now says the old functions log a warning. |
|
Good to merge once CI is green (it is now). All five are fixed; one docs point (should) and one test nit below. Everything below uses synthetic data only. Round 2, point by point
New findings1. (should) Two seats against two local authorities still leak, behind the acknowledgement. You asked whether a deeper difference can be built. It can, with several areas on both sides. Take S1 and S2 that together cover L1 and L2, with S2 also holding five households of L3. S3 spans L3 and L4, and S4 sits in L4. Both checks pass on every area: each area minus the public areas wholly inside it, and the public areas that meet an area minus that area, all hold 100 or more households. With I wouldn't make this a must. It sits behind an explicit acknowledgement, the docs already say other differences go unchecked, and a full check means searching over sets of areas on both sides. Your private run also points to one level per dataset and run in practice. But "other differences" is too abstract for whoever has to decide on the acknowledgement. I'd name this shape in the docs (two or more areas of each level that cover the same households except a sliver), and say plainly that releasing both levels from one run is not fully covered. A cheap check for later: require every non-empty intersection of a seat and an authority to pass the cell rules. Any such difference is a sum of whole intersections, so this check catches slivers like the one above, at any depth. On real data it would block a lot, so it belongs in a later PR, not this one. 2. (nit) Two cases the tests don't pin down.
What I checked
🤖 Generated with Claude Code |
Fixes #589
Summary
UK results by region, constituency and local authority come from licensed survey microdata. This PR adds one layer that every published geography number goes through, as agreed in #589.
policyengine.disclosure(new package, array-first): the rules, the bootstrap, the rounding, the release check and the validator. It imports nothing from the rest of the library.compute_uk_geography_disclosure(new output): a thin adapter from two completed simulations.source_household_idandregionon the household output.compute_uk_constituency_impactsandcompute_uk_local_authority_impactswarn, through the log too, that their output is not disclosure-controlled. Their behaviour is unchanged.Rules
source_household_id. For a share, both sides pass. The floor can be raised (30), never lowered.reportedorapproximate. On weight-matrix data it is also a gate (10) and it sets the support band. Weights given as a matrix keep the matrix rules whatever they hold.reported,approximate(value and interval kept, tier as the flag) andwithheld(no value, interval, direction or band).prior_public. Reference estimates (publish=False) are checked like the release's own.to_public()refuses before a check and while a blocking finding remains.validate_publicandpython -m policyengine.disclosure validate: a format check that needs no key.Where this differs from the issue text
See my comment on #589. In short:
approximaterows keep a value, zero cells publish as zero, withheld rows show no band, the bootstrap resamples denominators, and a remainder gets the rules of a cell, with no extra credit for noise or rounding.Notes for review
AreaEstimatesis a plain class on purpose. It holds exact values, counts and noise factors. Its data attributes are private, and it cannot be pickled or copied.unchecked_overlap, because other differences are not checked.source_household_idis refused unless the caller passessource_ids.Tests
176 new tests on synthetic data only, plus the two deprecation tests and the passthrough assertions. They cover the cases listed in #589 and the probes from both reviews.
I also ran the layer on a private Microcosm file through policyengine-uk and the array API, as the dashboard will. The runs found defects that the synthetic tests had missed, each now fixed with a test.
Not in this PR
The modelled seat estimator, replicate weights, automatic secondary suppression, and the dashboard wiring (PolicyEngine/uk-autumn-budget-dashboard-2026#24).
🤖 Generated with Claude Code