Skip to content

Milestone 4: disparate impact measurement - #18

Merged
edward-sf merged 10 commits into
mainfrom
P4-Dev
Sep 3, 2026
Merged

edward-sf merged 10 commits into
mainfrom
P4-Dev

Conversation

@edward-sf

Copy link
Copy Markdown
Owner

creditboost-train now measures the adverse impact ratio of every model across protected groups, stamps the result into the artifact, and refuses to write one that falls below the four-fifths threshold of 0.80.

Implements docs/superpowers/specs/2026-09-02-creditboost-disparate-impact-design.md task-by-task, test-first, one commit per task.

Why the conventional four-fifths test would have been theatre

Milestone 3 established that no protected attribute is a model feature. That is a statement about inputs, and it is not fairness — neutral features act as proxies, which is why disparate impact is a distinct doctrine from disparate treatment.

The obvious way to measure it does not work here. The four-fifths rule comes from UGESP, written for employment selection where selection rates are moderate. This model approves 97.3% of applicants, and at that rate the arithmetic compresses every ratio toward 1.0. Measured against the shipped model with adverse defined as the high band:

Attribute Adverse-rate disparity AIR Verdict
Age (62+ vs under 62) 9.19× 0.974 passes
Marital status 4.10× 0.971 passes
Sex 1.97× 0.980 passes

An applicant under 62 is denied at more than nine times the rate of one aged 62 or over, and the test returns 0.974. A gate built that way would pass essentially any model — worse than no gate, because it manufactures documented assurance in exactly the artifact a compliance reader would trust.

Defining the adverse outcome as not banded low — the applicant is not auto-approved — restores sensitivity while keeping both the literal rule and its 0.80 threshold. It is grounded rather than convenient: Regulation B defines adverse action to include credit granted "on substantially the terms requested," so a medium band is adverse action.

Results

model-v0.3.0, measured on the 61,503-row validation split:

Attribute AIR Margin over 0.80
CODE_GENDER 0.86841 +0.06841
NAME_FAMILY_STATUS 0.81785 +0.01785
DAYS_BIRTH (62+ vs under 62) 0.80996 +0.00996

roc_auc 0.75307, unchanged. Age clears by one point, which is the design working: a gate that cannot fail proves nothing, and the response to a future failure is investigation, not adjusting the constant.

What changed

  • fairness.py — computes the ratio as min/max over favourable rates. Inverted, a failing model reads as passing and the milestone is decorative, so one test exists solely to catch that direction.
  • Age buckets at the ECOA line of 62, not a quantile, and an unknown age is excluded rather than filed as young — NaN >= 62 is False, which would otherwise report a rate for applicants whose age is not known.
  • ModelMetadata.fairness is required, not optional. Optional would make "every shipped model has measured disparate impact" a hope rather than a guarantee. Enforced at three boundaries: training, artifact.load, and verify_artifact inside the Docker builder.
  • "Not measured" never reads as "passed" — a pydantic validator enforces that exactly one of adverse_impact_ratio and unmeasured_reason is set, and failing_attributes never returns an unmeasured one.
  • CODE_GENDER is now required in training data and still never accepted from a caller. FAIRNESS_ATTRIBUTES is a requirement, PROTECTED_ATTRIBUTES a prohibition; they overlap and are deliberately not merged. FEATURE_ORDER stays at 20, REQUEST_FIELDS at 19.
  • No override flag, for the same reason the AUC floor has none.
  • /metadata exposes the report with no endpoint change.

Also fixes a defect predating this milestone: lockfile, artifact_cli and reasons were never registered in the import-linter contract, because earlier string-replace edits silently no-opped and a narrower contract still passes. M2's spec claims that contract is what guarantees artifact_cli imports cleanly in the Docker builder where scikit-learn is absent — that claim was false until this branch. Verified by injecting a guarded forbidden import and confirming the contract now reports BROKEN.

Verification

The final whole-branch review independently reproduced the stamped ratios from the released model.json against the real validation split with its own positional numpy implementation — identical to the last digit. And model.json's sha256 is byte-identical to model-v0.2.0's, so this is provably a metadata refresh rather than a new model.

The load-bearing test was mutation-checked: swapping valid_frame for train_frame in train.py makes test_evaluate_is_called_with_the_aligned_validation_frame_and_predictions fail, and it passes again on restore. That seam matters because the fixture's 40-row validation split leaves every group under the minimum, so nothing else would have caught a misalignment — it would have stamped plausible, wrong ratios with no symptom.

194 tests, ruff, mypy and lint-imports all clean.

Deliberately deferred

  • Adding a fairness-floor check to verify_artifact. Real, but the spec scopes enforcement to training time; expanding the ship boundary belongs in its own milestone.
  • NAME_INCOME_TYPE carries Maternity leave (a sex proxy) and Pensioner (an age proxy) as levels. Recorded since Milestone 3, now measurable but unresolved.
  • Remediation of any disparity found, intersectional analysis, and fairness of the reason codes across groups.

A note on what this does and does not claim

An adverse impact ratio below 0.80 establishes a prima facie case a creditor may rebut with business necessity. It is evidence, not proof, and this gate is a conservative engineering control rather than a legal conclusion. The disparities above are observed outcome differences: some part of the age gap is genuine repayment behaviour, and the model uses no age feature at all — any age effect arrives through proxies, which is exactly what this analysis exists to surface.

🤖 Generated with Claude Code

edward-sf and others added 10 commits September 2, 2026 15:59
Adverse impact ratios per protected attribute, computed on the validation
split inside creditboost-train, stamped into ModelMetadata, and gated at the
four-fifths threshold of 0.80.

A probe against the shipped model drove the central design decision. With
adverse defined as the high band, an applicant under 62 is denied at 9.19x the
rate of one aged 62 or over -- and the four-fifths test returns 0.974, a
comfortable pass. At a 97% approval rate the arithmetic compresses every ratio
toward 1.0, so the conventional test cannot detect anything, and a gate built
on it would manufacture documented assurance it had not established. Defining
adverse as "not banded low" restores sensitivity and keeps both the literal
rule and its 0.80 threshold, grounded in Reg B's definition of adverse action
as covering credit on substantially different terms.

Under that definition the shipped model measures 0.868 on sex, 0.818 on
marital status, and 0.810 on age -- a one-point margin, which is the point: a
gate that cannot fail proves nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six tasks: the fairness schema, the measurement module, making protected
attributes available to training, the metadata field and training gate, the
retrain and release, and docs.

Two things the spec left implicit and the plan pins down. fit() does not return
validation predictions, so main recomputes them rather than changing a
signature tests depend on. And making ModelMetadata.fairness required breaks
five ModelMetadata construction sites across the tests, which take a shared
conftest helper rather than five copies of a literal.

Task 4 leaves docker build red until Task 5 releases model-v0.3.0; that window
is documented up front rather than discovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An attribute is either measured or carries a reason it was not, never both and
never neither: 'not measured' reading as 'passed' is the failure that would
make the whole report worthless.

The ratio is bounded to [0, 1] because min/max over favourable rates cannot
exceed 1 -- a value above it means the ratio was computed upside down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adverse impact ratio per protected attribute, min/max over favourable rates,
with adverse defined as not-banded-low rather than banded-high. On approvals the
four-fifths test cannot discriminate at a 97% approval rate: an applicant under
62 is denied at 9.19x the rate of one aged 62 or over and the test returns
0.974. Reg B treats credit on substantially different terms as adverse action,
so a medium band is adverse.

Age buckets at the ECOA line of 62, and an unknown age is excluded rather than
filed as young -- NaN >= 62 is False, which would report a rate for applicants
whose age is not known.

Measurement and enforcement are separate functions so the gate can be tested
against constructed reports without training anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The import-linter contract's source_modules list was missing three
runtime-side modules: creditboost.lockfile, creditboost.artifact_cli, and
creditboost.reasons. Earlier milestones' edits to this list silently
no-opped -- a string replace whose anchor never matched -- and lint-imports
kept passing because a narrower contract is still a valid one to check; the
intended registrations only ever landed on an abandoned branch.

This mattered most for artifact_cli: it runs inside the Docker builder stage,
which installs the base package only and has no scikit-learn, and the
Milestone 2 spec claims this contract is what guarantees the module can be
imported there. That guarantee was false -- an import of data.py or train.py
from artifact_cli would not have been caught until the Docker build crashed.

Verified the gap is closed, not just quieted: a guarded `from .data import
split` temporarily appended to artifact_cli.py made lint-imports report the
contract BROKEN (exit 1, chain: creditboost.artifact_cli -> creditboost.data),
then KEPT again (exit 0) once reverted, with no leftover diff. A check that
has never failed proves nothing, which is exactly how this gap survived two
milestones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CODE_GENDER is required in training data and still never accepted from a
caller. The fixture gains a third column category alongside model features and
monitoring-only request fields: analysis-only columns, present so outcomes can
be measured across groups.

The gender split is deterministic and exactly balanced rather than drawn. 200
rows split evenly gives exactly the default minimum group size, so a random
95/105 would make fairness measurement on the fixture flaky; alternating by
index also consumes no rng, so no existing column moved -- verified against the
previous fixture rather than assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Training measures the adverse impact ratio on the validation split after the
AUC gate and refuses to write a model failing any measured attribute, logging
each group's rate and size so the refusal is actionable. There is no override
flag, for the same reason the AUC floor has none.

ModelMetadata.fairness is required rather than optional: optional would make
'every shipped model has measured disparate impact' a hope rather than a
guarantee. Five test construction sites take a shared conftest helper.

NOTE: docker build fails from this commit until model-v0.3.0 is released. That
is artifact.load correctly refusing a model-v0.2.0 artifact that carries no
fairness report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adverse impact ratios, adverse defined as not-banded-low, all clear of the 0.80
floor: sex 0.86841, marital status 0.81785, age 0.80996. Age clears by 0.00996
-- a one-point margin, which is the point. A gate that cannot fail proves
nothing. roc_auc 0.75307, unchanged.

Nothing affecting training changed this milestone -- same features,
hyperparameters and seeded split -- so this is in substance a metadata refresh.
That is now proven rather than argued: model.json's sha256 is byte-identical to
model-v0.2.0's (019cb131...), and only model_meta.json's digest moved.

Closes the build-red window opened by the previous commit. model-v0.2.0 remains
released but can no longer be loaded by this code, which is the skew gate
working as intended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Documents why the four-fifths rule is applied to not-banded-low rather than to
approvals: at a 97% approval rate the conventional form passes a 9.19x age
disparity at 0.974.

Records the band-policy rule as an explicit documentation-only invariant rather
than leaving the gap implicit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a spy test proving evaluate() receives the aligned validation frame
and its own predictions -- the one property the fixture's small split
lets every other test miss. Corrects the false "enables measurement"
rationale on the fixture's deterministic gender split, warns (without
gating) when an attribute comes back unmeasured, derives the fixture's
analysis-only columns from config.FAIRNESS_ATTRIBUTES instead of a
hardcoded tuple, guards FAIRNESS_ATTRIBUTES against FEATURE_ORDER the
same way PROTECTED_ATTRIBUTES already is, notes size-excluded groups in
the gate's failure log, fixes a stale dict annotation and a one-sided
ratio-bound test, and corrects the stale spec/plan count in CLAUDE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@edward-sf
edward-sf merged commit 3b43269 into main Sep 3, 2026
4 checks passed
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.

1 participant