Credit Decisioning — Verified Adverse-Action Notices

Credit Decisioning application review page showing the model's reasons and the eight notice verification checks

When a lender declines credit, the applicant must be told the specific reasons. This project makes those reasons checkable. A calibrated model scores the application, code, not AI, applies the policy and writes the notice from a reviewed template, and a separate verifier checks every notice before a human decides.

Scope note: a portfolio demonstration on the 1994 UCI German Credit benchmark (1,000 applicants, CC BY 4.0); the review queue holds 60 synthetic applicants derived from it. It is not a lender and is not legal or compliance advice.

Why This Project Exists

In the United States, ECOA and Regulation B require a written notice with the principal reasons for a decline. "Insufficient score" is explicitly not enough, and the CFPB has said the duty is the same when a complex model makes the decision. Two things go wrong in practice:

  • Reasons drift from what the model used after a template edit, a mapping change or a reviewer rewording.
  • Generated text adds things nobody checked — a wrong number, a promise, a prohibited basis such as age or sex.

The harm lands on the applicant, who cannot contest a reason they were never given, and on the lender, who carries the regulatory exposure. The project asks a practical question: how do you prove a regulated document is correct before it is sent?

How It Works

ArtifactProduced byChecked by
Probability of a bad outcomeCalibrated logistic regressionRepeated 5×5 cross-validation, bootstrap intervals, reliability curve
Approve / refer / decline bandCode, from the benchmark's cost matrixUnit tests pin the threshold (derived, not tuned)
Principal reasonsCode, from the model's coefficientsAttributions must sum to the model's log-odds difference
Notice textCode, from a reviewed templateEight deterministic verifier checks
Analyst answersAn LLM agent (read-only)Code checks every number against tool results

A reviewer then sees the model's reasons, the verifier's results and the drafted notice, and records the decision. The model's recommendation is kept on record even when the human overrides it.

The Verifier Is Itself Verified

The verifier is an independent second implementation: it does not call the renderer. It checks that reasons match the derivation, that there are one to four of them, that none rests on a prohibited basis, that each states the applicant's actual value, that the decision is consistent, that every number matches the application, that the legal text matches a pinned hash, and that each sentence is an approved wording. To show these checks earn their place, a mutation suite applies 14 kinds of defect to every genuine notice and requires each defect to fail its intended check, and a meta-test switches each of the eight checks off in turn and requires the gate to notice.

Results and Honest Limits

All figures below come from one generated run (2026-10-29) on the benchmark's 1,000 applicants with out-of-fold predictions.

  • Decision quality: expected cost of 0.541 per applicant (95% interval 0.488–0.598), against 0.70 for declining everyone. Because declining a good applicant costs 1 and approving a bad one costs 5, "decline everyone" beats "approve everyone", so that is the baseline the model has to beat. AUROC is 0.780 (0.751–0.810).
  • Model choice: the boosted-tree challenger's advantage was +0.012 AUROC with an interval that includes zero, so the simpler, explainable model was kept.
  • Verifier: every applicable mutant was caught across 1,006 genuine notices, none of the genuine notices were rejected, and all eight checks were detected when disabled.
  • Fairness: protected attributes are excluded from the model, yet applicants under 25 are approved at 0.57 of the rate of the reference group, below the common 0.8 screen. Dropping a column does not remove its signal. Small groups are labelled "insufficient n" rather than given a number.
  • Analyst agent: 19 of 19 scripted guard-rail cases pass. A separate live run on 15 questions behaved correctly on all 15 after fixes, with one answer flagged for a real transcription error — a smoke test, not a rate.
  • Limits: the data is 1,000 German applicants from the 1970s–90s. Nothing here generalizes to a current lender.

A Lesson Worth Sharing

The first version let an LLM paraphrase each reason. Prose cannot be fully verified by code, so the notice became 100% template and the LLM moved to a read-only analyst, where its numbers can be checked. Moving the model out of the notice made the compliance claim stronger, not weaker. Evaluation also caught a real bug in the analyst's number-checking guard, which is now pinned by tests.

Engineering Decisions Worth Noting

  • No LLM on the decision path, enforced by an import contract rather than by convention.
  • Review workflow as a plain state machine with atomic claims (a double start or stale page returns a conflict) and a verified-hash binding before a notice can be sent.
  • Reproducibility: pinned, hash-checked data, and a command that retrains from scratch and requires the evidence to match.
  • Quality gates: lint, strict typing, import contracts, tests at 90% coverage or better, and an evaluation gate on every pull request.

Stack: Python, calibrated logistic regression, FastAPI with server-sent events, SQLite, LangChain agents, Next.js, and MkDocs.

Who This Is For

For lenders and fintech teams, it shows an approach to regulated, explainable decisions where every outbound document is independently verified. For AI and risk leaders, it is a worked example of using mutation testing to prove that your safety checks actually catch failures. If you are working on a similar problem, I would be glad to discuss it.

Technical Resources

Live App View Source Code Technical Documentation Discuss a Similar Project