Adverse action reasons
How lenders can use per-decision explanations to draft the specific reasons an adverse-action notice needs, and what they still have to check themselves.
Not legal advice This page describes how Kaimei's output can support a compliance process. It is not legal advice; your counsel decides what your notices must say.
#What the rules ask
In the United States, the Equal Credit Opportunity Act and Regulation B require a creditor that takes adverse action on a credit application to give the applicant a statement of specific reasons, or to tell them they can ask for one. The Fair Credit Reporting Act adds notice duties when the decision relied on a consumer report.
The Consumer Financial Protection Bureau has made clear that a complex model is no excuse. Creditors must still give specific and accurate reasons (Circular 2022-03), and may not pick a reason from a sample checklist if it does not reflect the factors actually used (Circular 2023-03). The commentary to Regulation B notes that more than four reasons is unlikely to help the applicant.
#How Kaimei helps
- Specific reasons per decision. Each explanation ranks the inputs that pushed this applicant toward decline, with the size of each push.
- Reproducible. KernelSHAP is exact for models with up to 12 inputs, and a fixed seed makes the other methods repeatable, so the same application always gives the same reasons.
- On the record. Each explanation is stored with your application id, the model version, the method and the reference population, for audits and complaints.
- What would have changed the outcome. A counterfactual search, with protected and unchangeable inputs held fixed.
#A worked example
The applicant from Reading an explanation was declined. Compliance keeps a reason table that maps each model input to approved wording, and lists the inputs that must never be stated. The pushes toward decline, strongest first, become draft reasons:
| Input | Value | Push toward decline | Draft reason |
|---|---|---|---|
debt_to_income | 0.38 | +8.0 pts | Excessive obligations in relation to income |
num_delinquencies | 1 | +7.5 pts | Delinquent past or present credit obligations with others |
home_ownership | rent | +4.6 pts | Not in the reason table. Held for review. |
credit_score | 681 | +2.1 pts | Credit score |
age | 34 | +1.3 pts | Never stated. Age is a prohibited basis under the Equal Credit Opportunity Act, with narrow exceptions. Escalate for model review. |
employment_years | 4 | +0.7 pts | Length of employment |
Income, utilisation and the loan amount pushed toward approval, so they are not reasons for this decline. The notice lists up to four approved reasons once review has cleared the two flags; the example wording follows the style of Regulation B's sample forms, and your own table should be written and approved by compliance.
#Doing it in code
For the engineers on your team: the reason table is a plain mapping kept under change control. The explanation's top_features are already ranked, and target_label confirms which way a positive push points.
Every model input should appear either in the reason table or in the never-state list, so an unmapped input is caught before a notice goes out.
REASONS = { # owned by compliance, under change control
"debt_to_income": "Excessive obligations in relation to income",
"num_delinquencies": "Delinquent past or present credit obligations with others",
"credit_score": "Credit score",
"employment_years": "Length of employment",
}
NEVER_STATE = {"age"}
explanation = client.explanations.create(
model="credit-risk-v3",
prediction_id=application_id, # your id for this application
input=application,
method="shap",
options={"seed": 0, "top_k": 20, "counterfactual": True, "frozen_features": ["age"]},
)
assert explanation.output["target_label"] == "decline" # positive pushes point toward decline
reasons, review = [], []
for row in explanation.top_features: # strongest first
if row["attribution"] <= 0:
continue # pushed toward approval
if row["feature"] in NEVER_STATE:
review.append(f'{row["feature"]} pushed toward decline: escalate')
elif row["feature"] in REASONS:
reasons.append(REASONS[row["feature"]])
else:
review.append(f'{row["feature"]} has no approved reason')
print(reasons[:4])
print(review)['Excessive obligations in relation to income', 'Delinquent past or present credit obligations with others', 'Credit score', 'Length of employment']
['home_ownership has no approved reason', 'age pushed toward decline: escalate']#Choices that matter
- The reference population. Reasons are pushes relative to the average applicant in the reference data. Choose that population deliberately, such as recent applications, document it, and refresh it when your policy changes. A different population can reorder the reasons.
- The method. Use KernelSHAP with a fixed seed, so a complaint months later can be answered with the same reasons.
- Held-fixed inputs. In counterfactual searches, hold protected and unchangeable inputs fixed with
frozen_features. - Records. Pass your application id as
prediction_idand keep the explanation as long as your retention policy requires. Kaimei keeps records until you delete them.
#What stays with you
- The actual decision. Reasons must reflect the whole decision, including policy rules applied outside the model, such as cut-offs, overlays and manual reviews, which Kaimei cannot see.
- The reason table. Its wording, and which inputs may be used at all.
- Fair lending testing. Kaimei does not measure disparate impact. Explanations can help investigate a finding but are not a test.
- The notice. Its content, timing and delivery.