All posts

Credit risk

Building a Credit Risk Copilot a Regulator Would Accept

Most credit copilots stall at the compliance review. The fix is architectural: keep the scorecard conventional, ground every generated number in a validated fact, and never let the model recommend.

Lucky Rathore 12 min read

Every lending team has now built some version of a credit copilot. Most of them quietly stall in the same place: the model is genuinely useful in a demo, and then someone from risk or compliance asks what happens when it is wrong about a number, and there is no good answer. The fix is architectural, and it starts with being very strict about what the language model is allowed to decide.

The rule that makes everything else possible

The language model does not make the credit decision.

Not "the model makes the decision and a human reviews it." Not "the model makes the decision on thin-file applicants only." The scorecard stays a conventional, explainable model — a monotonic gradient-boosted tree or a regularised logistic regression over a governed feature set. That model is what produces the probability of default, and it is what you validate, monitor, document, and defend.

This is not conservatism for its own sake. Adverse action requirements in most jurisdictions mean that when you decline someone, you must be able to state the principal reasons — specifically, factually, and consistently. Fair-lending review needs you to demonstrate the decision boundary doesn't proxy for a protected class. Model risk governance needs a documented, reproducible artefact. A language model whose reasoning lives in its activations cannot give you any of that in a form a model validation function will sign off.

So what's the copilot for? Everything around the decision, which is where the analyst's time actually goes.

The shape of the system

documents ──► extraction (SLM) ──► structured facts ──► feature store
                                                             │
                                                             ▼
                                                   scorecard (GBM / logistic)
                                                             │
                                    ┌────────────────────────┤
                                    ▼                        ▼
                              PD + reason codes      SHAP attributions
                                    │                        │
                                    └──────────┬─────────────┘
                                               ▼
                                    narrative layer (SLM)
                                               │
                                    ┌──────────┴──────────┐
                                    ▼                     ▼
                            numeric validator      analyst Q&A
                                    │
                                    ▼
                            memo / adverse action

The language model appears twice, and in both places it is working with facts that already exist somewhere auditable.

Stage 1: extraction

Bank statements, salary slips, GST filings, bureau reports. This is genuinely hard — not intellectually, but because the formats are endless and the failure modes are subtle. It is also the highest-volume step and therefore the clearest case for a small, fine-tuned, self-hosted model.

The design rule: extraction returns a value and a provenance pointer, or it returns nothing.

{
  "field": "net_monthly_salary",
  "value": 84500.00,
  "currency": "INR",
  "source": {
    "document_id": "stmt_2026_07",
    "page": 2,
    "bbox": [412, 690, 508, 706],
    "raw_text": "SALARY CREDIT ... 84,500.00"
  },
  "confidence": 0.94
}

Everything downstream depends on this. A number without a pointer back to the pixel it came from cannot be defended, cannot be spot-checked by an analyst, and cannot be debugged when it turns out the model has been reading the overdraft line as income for three weeks.

Stage 2: the scorecard (no language model)

Conventional features over the extracted facts — income stability, obligation ratios, bounce counts, bureau vintage and enquiry velocity, and so on. Monotonic constraints where the direction is known: more bounces should never reduce risk, and if your model says otherwise it has found a data artefact, not a signal.

Out of this you get the probability of default, the reason codes, and per-feature attributions. These are the raw material for everything the copilot says.

Stage 3: the narrative layer

Now the language model earns its place. Given the structured facts and the scorecard's attributions, write the credit memo an analyst would write — and answer their follow-up questions about the file.

The prompt contract is narrow on purpose:

SYSTEM
You write credit memos from structured underwriting data.

Rules:
- Use ONLY facts present in the FACTS block. Never infer a number.
- Every figure you state must appear verbatim in FACTS.
- Explain the score using the ATTRIBUTIONS block, in its ranked order.
- Never state or imply an approve/decline recommendation.
- If a fact needed for a section is missing, write "not available"
  and continue.

FACTS
{structured_facts_json}

ATTRIBUTIONS
{shap_top_k_json}

"Never state a recommendation" surprises people. It exists because the moment the memo reads as advice, the human reviewer starts anchoring on it, and your human-in-the-loop control quietly stops being a control.

The validator is not optional

Grounding instructions reduce fabrication. They do not eliminate it, and in credit a single invented number is a compliance incident. So the generated text does not go anywhere until a deterministic check has passed:

import re
from decimal import Decimal

NUM = re.compile(r"[-+]?[\d,]*\.?\d+")

def ungrounded_numbers(memo: str, facts: dict, tol=Decimal("0.01")):
    # Return every number in the memo that no fact supports.
    allowed = {Decimal(str(v)) for v in _walk_numeric(facts)}
    bad = []
    for tok in NUM.findall(memo):
        try:
            val = Decimal(tok.replace(",", ""))
        except Exception:
            continue
        if not any(abs(val - a) <= tol for a in allowed):
            bad.append(tok)
    return bad

# in the pipeline
issues = ungrounded_numbers(memo, facts)
if issues:
    metrics.increment("memo.ungrounded", len(issues))
    memo = regenerate_or_escalate(facts, attributions, issues)

It is a crude check and it catches a genuinely useful class of error — transposed digits, totals the model "helpfully" computed, percentages it estimated rather than read. Track the failure rate as a first-class metric: a sudden rise is usually the first sign that an upstream extraction template has drifted.

Evaluating it properly

Text-similarity scores against reference memos are close to worthless here — two memos can be worded completely differently and both be correct, or worded almost identically and differ on the one number that matters. Measure the things that can hurt you:

DimensionHow to measure itBar
Numeric groundingAutomated validator over every generated memoZero tolerance; alert on any
Attribution fidelityDo the drivers cited match the top SHAP features, in order?Automated, high bar
CompletenessRequired sections present and populatedAutomated, high bar
Analyst usefulnessBlind A/B against human-written memos, rated by underwritersSampled weekly
ConsistencyNear-identical files → near-identical memosPaired-file regression suite
Latencyp50 / p95 / p99 end to endWithin the analyst's flow

The consistency test is the one most teams miss and the one a fair-lending reviewer will find first. Construct pairs of applications that differ only in a protected or proxy attribute and confirm the memos don't diverge in tone or emphasis. Keep it in CI.

Fine-tuning: less data than you'd think

If you have historical analyst-written memos alongside the files they describe, you have a supervised dataset. In the low thousands of pairs, a LoRA fine-tune on a small open model is usually enough to pick up your house style, your section structure, and your vocabulary — which is most of what "sounds like us" means.

Two things worth doing that teams routinely skip:

  • Strip the recommendation from every training memo. If your analysts wrote "recommend approval," and you train on it, your model will write it too, and your no-recommendation rule dies in training rather than in review.
  • Include the ugly files. Missing documents, contradictory income evidence, thin bureau history. If the fine-tune only ever sees clean files, it learns to write confidently about incomplete ones — exactly the wrong failure mode.

What to log

Assume you will one day have to reconstruct a specific decision from eighteen months ago. That means, per application: model and prompt version hashes, the exact FACTS and ATTRIBUTIONS blocks passed in, the raw generation, validator results, any regeneration, what the analyst saw, what they changed, and what they decided.

Storage is cheap. Explaining to a regulator that you can't reproduce a memo is not.

What you actually get

Done this way, the copilot doesn't make credit decisions — it removes the document-wrangling and memo-drafting that consumes most of an underwriter's day, and it does so in a form where every claim traces back to a field, every field traces back to a page, and the decision itself remains the property of a model your risk function already knows how to defend.

That's a narrower promise than the demos make. It's also the version that survives contact with a model validation committee.

Want this built on your data?

Writing about this is our day job because building it is. We build and fine-tune custom AI models for fintech teams — fraud, credit risk, document extraction — and hand back models you own and can run yourself. Or build it yourself in a sciFi notebook.