Responsible AI: Fairness, Bias, and Interpretability Questions
Building ML and AI systems that are fair, explainable, and safe. Covers identifying and mitigating bias, fairness metrics and tradeoffs, model interpretability and explainability techniques, label-bias feedback loops, and responsible and safe development practices for production models. Emphasizes accountability and transparency as first-class design constraints.
Define demographic parity, equalized odds, and calibration (group-wise calibration). For each metric give a formal definition and a loan-approval example of how you would measure it, then state which metric you would prioritize if (a) a regulator requires equal treatment across groups and (b) downstream decisions require well-calibrated risk scores.
Sample Answer
A strong answer opens by naming the three definitions and stating plainly that they generally cannot all hold at once when base rates differ across groups.
Structured elaboration
| Metric | Formal condition | What it controls |
|---|---|---|
| Demographic parity | P(Y^=1∣A=a)=P(Y^=1∣A=b) | Equal selection rate across groups, regardless of outcome |
| Equalized odds | P(Y^=1∣Y=y,A=a)=P(Y^=1∣Y=y,A=b) for both y∈{0,1} | Equal true-positive and false-positive rates across groups |
| Calibration (group-wise) | P(Y=1∣score=s,A=a)=s for every group a | A predicted score of s means the same real-world probability in every group |
Loan example. Say a bank approves loans with a risk score.
- Demographic parity means the same fraction of applicants in each demographic group gets approved, even if the groups have different true default rates.
- Equalized odds means that among applicants who would actually repay, the approval rate is the same across groups (equal TPR), and among those who would default, the rejection rate is the same across groups (equal FPR).
- Calibration means that a 0.2 default-risk score means a genuine 20% default probability whether the applicant is in group A or group B.
Worked example. If group A has a true default rate of 10% and group B has a true default rate of 30%, a single calibrated score function will naturally assign more high scores to group B. Forcing demographic parity on top of that calibration would require either denying good group-A applicants or approving bad group-B applicants purely to match rates, which breaks calibration. This is not a hypothetical: it is the mathematical content of the impossibility result once you fix differing base rates.
Trade-offs and pitfalls. (a) A regulator asking for "equal treatment across groups" usually means demographic parity or equalized odds, not calibration, so lean there. (b) A downstream risk-scoring use case (setting an interest rate, sizing a reserve) needs calibration, because a wrongly-calibrated score misprices risk for an entire group even if selection rates look fair. (c) The most common mistake is treating these three as compatible variations on "fairness" rather than as genuinely conflicting design choices; picking one is a policy decision, not a purely technical one, and should be made with legal and business stakeholders, not unilaterally by the model team.
Design a fairness evaluation and mitigation plan for a hiring-assistant model where sensitive attributes such as gender and race are partially missing and historical selection bias exists. Define metrics that capture intersectional fairness, propose a mitigation strategy, and describe the auditing and governance processes needed for ongoing monitoring.
Sample Answer
Direct answer
Measure fairness at the INTERSECTION of gender and race, not just each dimension separately, since a model can look acceptable on each marginal axis while still failing badly for a specific combination (a real, well-documented pattern, not a hypothetical). Where gender or race is missing, use a soft, probabilistic group-membership estimate rather than dropping those applicants or guessing a hard label, and carry that uncertainty through the fairness calculation itself, including a minimum EFFECTIVE sample size per intersectional cell. Mitigate at two levels together: reweigh training examples toward the group-conditional base rate, AND directly address any resume feature that is itself a proxy for a protected attribute (a referral flag standing in for network access, an employment-gap flag standing in for historical caregiving norms), because reweighing alone will not undo a disparity that is being driven through a specific, still-present proxy feature. Govern this with a standing audit cadence, not a one-time check, since both the applicant pool and the model drift over time.
Structured elaboration
Intersectional fairness metrics. For K protected dimensions with 2 categories each, there are 2K intersectional cells (2 dimensions here: gender x race, 4 cells), and the fairness metric of interest, a disparate-impact-style selection-rate ratio here, has to be computed for EACH cell against a reference (the cell with the highest observed rate, following the same 80 percent rule convention used for single-attribute analysis), not just for gender marginally and race marginally. This matters because marginal fairness is a necessary but not sufficient condition for intersectional fairness: a model can show acceptable gender-only and race-only gaps while its worst-affected group is a specific combination that neither marginal view would surface. Because intersectional cells are smaller by construction (splitting a population four ways instead of two), the SAME minimum-sample-size discipline used for single-attribute audits applies here with more force, and testing many cells at once also raises a real multiple-comparisons concern that a single-attribute test does not face as acutely.
Handling partially missing protected attributes. Applicants who decline to self-report gender or race should not be silently excluded (which biases the audited population toward whoever chose to disclose) or silently assigned a hard guessed label (which fabricates certainty that does not exist). The standard approach is a soft, probabilistic group-membership estimate, conceptually similar to Bayesian Improved Surname Geocoding used in fair-lending analysis, that assigns each applicant a probability distribution over the possible cells rather than a single hard label. Every downstream fairness computation should then use those probabilities as WEIGHTS (an applicant with missing data contributes a FRACTION of a person to each of several cells) rather than collapsing to a point estimate, and the resulting EFFECTIVE sample size per cell (the sum of weights, not a raw row count) is what the minimum-sample-size gate should actually check.
Historical selection bias. A hiring model trained on past hiring OUTCOMES inherits every bias that shaped those outcomes, and two mechanisms are common and distinct: a feature that gives a real but partly UNEARNED boost (a referral flag, where network effects are real but access to the network is itself unevenly distributed across groups for historical reasons unrelated to merit), and a feature that imposes an UNEARNED penalty (an employment-gap flag, where the gap itself may correlate with historical caregiving norms rather than any real capability difference). Neither of these requires the model to see gender or race directly; both function as PROXY features that reintroduce the disparity through the back door, which is exactly why "we don't use the protected attribute as a model input" does not, on its own, guarantee a fair outcome.
Mitigation strategy: two levels, not one. Example-level reweighing (in the style of Kamiran and Calders, adapted here to soft group membership) adjusts each training example's WEIGHT so that, in expectation, each group's outcome rate is pulled toward the overall base rate; this is necessary but, as the worked example below shows directly, it is not sufficient when a specific feature is a strong, still-present proxy for the disparity. Feature-level intervention, auditing which resume features function as proxies and adjusting or removing the ones without a defensible, job-relevant justification (a referral flag is the clearer case here; an employment-gap flag needs more care, since it can also legitimately correlate with a real skills gap and should be handled with a nuanced policy, not a blanket removal), addresses the mechanism reweighing alone cannot reach.
Auditing and governance for ongoing monitoring. A one-time audit is not sufficient because both the applicant pool and the model retrain over time. A durable governance program needs: a fixed re-audit cadence (not just "when someone asks"), drift monitoring on both the model's intersectional selection rates AND the composition of the applicant pool itself (a shift in who applies can move a fairness metric even with an unchanged model), a documented fairness impact assessment updated at every model retrain and reviewed by a named accountable owner, a human-review or appeal channel for rejected candidates, and a pre-agreed rollback trigger and procedure if a monitoring alert fires, so a response plan exists before it is needed under pressure.
Worked example
A synthetic hiring dataset with a real proxy-driven historical bias (referral favoring one intersectional cell, an employment-gap flag disproportionately affecting another), 12 percent of applicants missing self-reported gender and race (handled with soft, Dirichlet-perturbed group-membership probabilities standing in for a real name/geography-based estimate), evaluated across all four intersectional cells under three progressively stronger interventions:
import numpy as np
from sklearn.linear_model import LogisticRegression
rng = np.random.default_rng(6)
n = 3000
# ---- Protected attributes: gender in {M, F} x race in {A, B} -> 4 intersectional
# cells. 12% of applicants have NO self-reported gender/race (a realistic
# non-disclosure rate). ----
gender = rng.choice(["M", "F"], size=n, p=[0.55, 0.45])
race = rng.choice(["A", "B"], size=n, p=[0.65, 0.35])
cells = np.array([f"{g}-{r}" for g, r in zip(gender, race)])
CELL_NAMES = ["M-A", "M-B", "F-A", "F-B"]
missing_mask = rng.uniform(0, 1, n) < 0.12
# For applicants with MISSING self-report, simulate a soft, IMPERFECT proxy-based
# group-membership estimate (standing in for a real technique like Bayesian
# Improved Surname Geocoding): a probability vector over the 4 cells, centered on
# the true cell but with meaningful estimation noise, never a hard guess.
def soft_membership(true_cell_idx, noise_conc=6.0, r=rng):
alpha = np.ones(4) * 0.5
alpha[true_cell_idx] += noise_conc
return r.dirichlet(alpha)
cell_idx_true = np.array([CELL_NAMES.index(c) for c in cells])
membership_prob = np.zeros((n, 4))
for i in range(n):
if missing_mask[i]:
membership_prob[i] = soft_membership(cell_idx_true[i])
else:
membership_prob[i, cell_idx_true[i]] = 1.0 # observed: hard, certain membership
# ---- Resume features. has_gap is a PROXY correlated with gender (F), reflecting
# historical caregiving-driven career gaps; referral is a PROXY correlated with
# the historically dominant network (M-A), reflecting HISTORICAL SELECTION BIAS
# baked into who gets referred in the first place. ----
years_experience = rng.normal(6, 3, n).clip(0, None)
prior_title_seniority = rng.normal(3, 1.2, n).clip(0, None)
has_gap = (rng.uniform(0, 1, n) < np.where(gender == "F", 0.30, 0.10)).astype(float)
referral = (rng.uniform(0, 1, n) < np.where(cells == "M-A", 0.45, 0.15)).astype(float)
true_quality = 0.4 * years_experience + 0.6 * prior_title_seniority + rng.normal(0, 1.5, n)
# ---- HISTORICAL hiring label: the data the current model would be built on.
# Referral gives a real boost (network effects are real) but ALSO an unjustified
# boost beyond what quality alone predicts; a gap imposes an unjustified penalty
# beyond quality alone. Both are the historical selection bias baked into the
# label this exercise has to contend with. ----
hist_logit = (0.35 * true_quality - 3.0) + 1.8 * referral - 1.4 * has_gap
hist_hire = (rng.uniform(0, 1, n) < 1 / (1 + np.exp(-hist_logit))).astype(int)
X_full = np.column_stack([years_experience, prior_title_seniority, has_gap, referral])
X_no_referral = np.column_stack([years_experience, prior_title_seniority, has_gap])
def intersectional_rates(reco, membership_prob, min_effective_n=50):
report = {}
for k, name in enumerate(CELL_NAMES):
w = membership_prob[:, k]
eff_n = w.sum()
rate = np.sum(w * reco) / eff_n if eff_n > 0 else float("nan")
report[name] = {"effective_n": round(eff_n, 1), "reco_rate": round(rate, 4)}
max_rate = max(v["reco_rate"] for v in report.values() if v["effective_n"] >= min_effective_n)
for name, v in report.items():
if v["effective_n"] < min_effective_n:
v["disparate_impact_ratio"], v["flag"] = None, "INSUFFICIENT_EFFECTIVE_SAMPLE"
else:
v["disparate_impact_ratio"] = round(v["reco_rate"] / max_rate, 4)
v["flag"] = "FLAGGED" if v["disparate_impact_ratio"] < 0.8 else "OK"
return report
def threshold_for_rate(scores, target_rate):
return np.quantile(scores, 1 - target_rate)
# ---- Reweighing (Kamiran & Calders style), extended to SOFT group membership:
# each cell's expected (soft-weighted) hire rate is pulled toward the overall
# base rate via a per-cell factor, blended per row by its membership probabilities. ----
overall_hire_rate = hist_hire.mean()
cell_reweight_factor = np.zeros(4)
for k in range(4):
w = membership_prob[:, k]
cell_hire_rate = np.sum(w * hist_hire) / w.sum()
cell_reweight_factor[k] = overall_hire_rate / cell_hire_rate if cell_hire_rate > 0 else 1.0
sample_weight = membership_prob @ cell_reweight_factor
print("Per-cell reweighing factors (soft-membership-weighted):")
for name, factor in zip(CELL_NAMES, cell_reweight_factor):
print(f" {name}: {factor:.4f}")
TARGET_RATE = 0.15 # a fixed overall selection rate, so all three models are
# compared at the SAME hiring volume, not a fixed score cutoff
configs = [
("BASELINE (all features, no reweighing)", X_full, None),
("REWEIGHED (all features)", X_full, sample_weight),
("REWEIGHED + referral proxy feature removed", X_no_referral, sample_weight),
]
for label, Xv, sw in configs:
model = LogisticRegression(max_iter=1000).fit(Xv, hist_hire, sample_weight=sw)
scores = model.predict_proba(Xv)[:, 1]
t = threshold_for_rate(scores, TARGET_RATE)
reco = (scores >= t).astype(int)
print(f"\n=== {label} (overall selection rate={reco.mean():.4f}) ===")
for name, v in intersectional_rates(reco, membership_prob).items():
print(f" {name}: {v}")
Executed output:
Per-cell reweighing factors (soft-membership-weighted):
M-A: 0.7426
M-B: 1.1065
F-A: 1.3238
F-B: 1.2507
=== BASELINE (all features, no reweighing) (overall selection rate=0.1500) ===
M-A: {'effective_n': np.float64(1061.7), 'reco_rate': np.float64(0.2679), 'disparate_impact_ratio': np.float64(1.0), 'flag': 'OK'}
M-B: {'effective_n': np.float64(574.6), 'reco_rate': np.float64(0.1075), 'disparate_impact_ratio': np.float64(0.4013), 'flag': 'FLAGGED'}
F-A: {'effective_n': np.float64(893.8), 'reco_rate': np.float64(0.0698), 'disparate_impact_ratio': np.float64(0.2605), 'flag': 'FLAGGED'}
F-B: {'effective_n': np.float64(469.8), 'reco_rate': np.float64(0.0882), 'disparate_impact_ratio': np.float64(0.3292), 'flag': 'FLAGGED'}
=== REWEIGHED (all features) (overall selection rate=0.1500) ===
M-A: {'effective_n': np.float64(1061.7), 'reco_rate': np.float64(0.2695), 'disparate_impact_ratio': np.float64(1.0), 'flag': 'OK'}
M-B: {'effective_n': np.float64(574.6), 'reco_rate': np.float64(0.108), 'disparate_impact_ratio': np.float64(0.4007), 'flag': 'FLAGGED'}
F-A: {'effective_n': np.float64(893.8), 'reco_rate': np.float64(0.0687), 'disparate_impact_ratio': np.float64(0.2549), 'flag': 'FLAGGED'}
F-B: {'effective_n': np.float64(469.8), 'reco_rate': np.float64(0.0861), 'disparate_impact_ratio': np.float64(0.3195), 'flag': 'FLAGGED'}
=== REWEIGHED + referral proxy feature removed (overall selection rate=0.1500) ===
M-A: {'effective_n': np.float64(1061.7), 'reco_rate': np.float64(0.1687), 'disparate_impact_ratio': np.float64(1.0), 'flag': 'OK'}
M-B: {'effective_n': np.float64(574.6), 'reco_rate': np.float64(0.1642), 'disparate_impact_ratio': np.float64(0.9733), 'flag': 'OK'}
F-A: {'effective_n': np.float64(893.8), 'reco_rate': np.float64(0.1375), 'disparate_impact_ratio': np.float64(0.8151), 'flag': 'OK'}
F-B: {'effective_n': np.float64(469.8), 'reco_rate': np.float64(0.1143), 'disparate_impact_ratio': np.float64(0.6775), 'flag': 'FLAGGED'}
This is the two-level mitigation point made concretely, not asserted. The baseline model, at a fixed 15 percent overall selection rate so every comparison is at the same hiring volume, shows M-A (the historically networked, non-gap-affected cell) far ahead of every other cell, with M-B, F-A, and F-B all clearing FLAGGED territory (ratios of 0.40, 0.26, and 0.33). Reweighing alone barely moves any of these numbers (0.40, 0.25, 0.32), confirming that example-level reweighing cannot undo a disparity that is still being actively driven through a live proxy feature (referral) present in the model's inputs; the model keeps relying on that feature regardless of how the training examples are weighted. Only after ALSO removing the referral proxy feature, on top of reweighing, do M-B (0.9733) and F-A (0.8151) clear the fairness bar, which shows the feature-level intervention was doing the real work the reweighing alone could not. Critically, F-B, the doubly-disadvantaged intersectional cell, remains FLAGGED (0.6775) even after both interventions, which is exactly the finding an intersectional-only-after-marginal-fixes analysis would miss: fixing what looks like the two separate axes of disparity did not fix their intersection, because F-B's disadvantage compounds effects from both the removed referral proxy AND the still-present employment-gap penalty in a way neither single-axis fix fully addresses.
Trade-offs and pitfalls
The clearest pitfall, demonstrated directly above, is declaring victory after fixing what marginal (gender-only, race-only) metrics show, without checking the actual intersectional cells; F-B here would have looked adequately addressed by any analysis that only checked gender and race separately after the two interventions, since both individual axes improved substantially. A second is treating reweighing as a universal fix; it corrects for group-conditional LABEL imbalance but does nothing about a specific feature that is itself a strong, still-present channel for the disparity, and the worked example shows this is not a subtle effect, reweighing alone left every ratio essentially unchanged. A third is over-aggressively removing the employment-gap feature the same way the referral feature was removed here; unlike referral, a gap flag CAN carry some legitimate, job-relevant signal in specific contexts, so a blanket removal policy needs more nuance and its own justification, not a copy-paste of whatever worked for the clearer proxy. Finally, treating the soft group-membership estimate for applicants with missing self-report as if it were ground truth is a risk in the opposite direction: the effective sample sizes above (469.8 to 1061.7) already reflect the dilution from imperfect imputation, and any fairness conclusion drawn from a cell whose effective sample size is small or whose imputation confidence is low should be reported with that uncertainty attached, not presented with the same confidence as a cell built entirely from self-reported, observed data.
Given a credit-scoring or recidivism model showing disparate impact across groups, propose a causal analysis plan to determine whether the disparity is caused by the model itself, historical policy, or socioeconomic confounders. Describe the data you would need, how you would draw the causal graph, which variables to condition on, and when you would advise an intervention versus replacing the model.
Sample Answer
Direct answer
A disparity in a credit-scoring or recidivism model can come from three genuinely different places that call for three different fixes: the model itself adding disparity beyond what its legitimate inputs justify (a modeling defect), a historically-driven policy legacy baked into a feature that looks legitimate but encodes past exclusion, or a present-day socioeconomic confounder that reflects real, current inequality the model did not create. The diagnostic plan is to build a causal graph separating these three channels, collect the specific data each channel needs to be measured (not assumed), condition on the legitimate inputs to isolate the model's own residual contribution, and only then decide whether to intervene on a feature, intervene on policy, or replace the model, since replacing the model fixes only the first of the three.
Structured elaboration
The causal graph to draw. Put the protected attribute A at the root, and trace three distinct paths to the outcome Y:
- A→H→CH→Y: historical policy legacy (H, e.g. redlining-era exclusion from credit access) shapes a feature the model uses (credit-history depth, CH), which the model then scores.
- A→S→Y: current socioeconomic status (S, income, employment stability), a legitimate predictor of repayment capacity that also correlates with A through present-day societal inequality, not through the model or a specific historical policy.
- A→Y directly, bypassing every legitimate feature: the model's own use of an A-correlated proxy (zip code, a device or channel signal) that adds disparity beyond what CH and S already justify.
CH is also driven by true repayment reliability R, a legitimate, largely A-independent signal that must be included in the graph so the historical-policy path is not confused with genuine behavior.
Data needed for each path. Path 1 needs historical documentation independent of the model's own features: historical redlining maps, branch-density or credit-access records by geography and era, anything that lets you estimate how much of today's credit-history depth is explained by past policy rather than present behavior. Path 2 needs direct, current socioeconomic indicators (income, employment duration, asset holdings) collected at the individual level, not inferred from the same features the model already uses, so it can be conditioned on independently. Path 3 needs the model's own feature list and coefficients (or, for a black-box model, its residual behavior after controlling for every legitimate input), since the model-itself channel is defined as whatever disparity is left over once the legitimate paths are accounted for.
Which variables to condition on. To isolate the model-itself channel, condition on every LEGITIMATE input the model is supposed to be using (here, CH and S) and measure what disparity remains in Y; anything left over after conditioning on the full legitimate feature set is either an unmeasured legitimate factor (a gap in the graph, worth investigating before concluding it is the model) or the model's own excess reliance on a proxy. To separate the historical-policy channel from the socioeconomic channel within the legitimate paths, condition on S alone and see how much of CH's remaining correlation with A survives; what is left traces to H (historical policy) rather than to current socioeconomic status, since the two operate through different variables in the graph. Do not condition on CH when trying to isolate H's effect on the FINAL disparity in Y, since CH is the mediator carrying that very effect, and conditioning on a mediator when you want its total effect is a classic identification mistake (it would "explain away" exactly the pathway under investigation).
When to advise an intervention versus replacing the model. This should be a size-and-locus judgment, not a reflexive one:
- If the residual model-itself channel (disparity remaining after conditioning on all legitimate inputs) is a material share of the total, that is direct evidence the model is doing something beyond scoring its stated inputs, and the model needs to be retrained or re-specified (remove the offending proxy, or add an in-processing fairness constraint), because the defect is intrinsic to the model, not to its inputs.
- If most of the disparity traces to the historical-policy channel through a specific feature (credit-history depth encoding redlining-era exclusion), the fix is targeted at that FEATURE, not the whole model: substitute a less contaminated proxy for the same underlying construct (alternative credit-building data, rental/utility payment history), or explicitly adjust the feature for its historical-policy component before it enters any model, current or future.
- If most of the disparity traces to the socioeconomic channel and reflects a real, current difference in repayment capacity that predates and sits outside the model, the model may be statistically accurate and the disparity is a policy question above the model layer (whether and how to price risk that correlates with structural inequality, whether to offer alternative underwriting paths), not a defect to be engineered away inside this model. Advising "replace the model" here would not remove the disparity, since a different, equally accurate model trained on the same socioeconomic reality would show the same gap.
- In practice expect a mix of all three, in which case sequence the response: fix the model-itself channel first (it is usually the cheapest and most defensible fix), then the feature-level historical-policy contamination, and treat the socioeconomic-confounder residual as a business and policy decision to escalate, not silently absorb into "the model is fine."
Worked example
A credit-scoring SCM with all three channels present simultaneously, at a scale that makes each channel's share estimable with negligible noise:
import numpy as np
from sklearn.linear_model import LinearRegression
rng = np.random.default_rng(9)
n = 300_000
A = rng.integers(0, 2, n).astype(float)
H = 0.70 * A + rng.normal(0, 1, n) # historical policy legacy, correlated with A
S = 0.50 * A + rng.normal(0, 1, n) # current socioeconomic status, correlated with A
R = -0.02 * A + rng.normal(0, 1, n) # true repayment reliability: near-zero real gap
CH = 1.0 * R + 0.9 * H + rng.normal(0, 0.5, n) # credit-history depth: true behavior + historical suppression
noise_model = rng.normal(0, 0.5, n)
Y_legit = 0.6 * CH + 0.8 * S + noise_model # a model using ONLY legitimate inputs
Y_actual = 0.6 * CH + 0.8 * S + 0.25 * A + noise_model # the deployed model ALSO uses an A-correlated proxy
gap_actual = Y_actual[A == 1].mean() - Y_actual[A == 0].mean()
gap_legit = Y_legit[A == 1].mean() - Y_legit[A == 0].mean()
model_itself_share = gap_actual - gap_legit
print(f"observed disparity, deployed model: {gap_actual:.4f}")
print(f"disparity with only legitimate inputs (CH, S): {gap_legit:.4f}")
print(f"share of total disparity from the model itself: {model_itself_share/gap_actual*100:.1f}%")
coef_S = 0.8 * 0.50
coef_H = 0.6 * 0.9 * 0.70
coef_R = 0.6 * 1.0 * (-0.02)
implied_total = coef_S + coef_H + coef_R
print(f"share from current socioeconomic status (S): {coef_S/implied_total*100:.1f}%")
print(f"share from historical-policy-contaminated credit history (H via CH): {coef_H/implied_total*100:.1f}%")
print(f"share from true repayment-behavior gap (R via CH): {coef_R/implied_total*100:.1f}%")
X = np.column_stack([A, CH, S])
reg = LinearRegression().fit(X, Y_actual)
print(f"regression of Y_actual on [A, CH, S], recovered coefficient on A: {reg.coef_[0]:.4f}")
Executed output:
observed disparity, deployed model: 1.0150
disparity with only legitimate inputs (CH, S): 0.7650
share of total disparity from the model itself: 24.6%
share from current socioeconomic status (S): 52.2%
share from historical-policy-contaminated credit history (H via CH): 49.3%
share from true repayment-behavior gap (R via CH): -1.6%
regression of Y_actual on [A, CH, S], recovered coefficient on A: 0.2509
Of the total 1.0150-point disparity, 24.6% comes from the model's own excess reliance on an A-correlated proxy (confirmed by the regression recovering the model's true 0.25 proxy coefficient almost exactly, 0.2509, once CH and S are conditioned on). Of the remaining, legitimate-input-driven disparity, 52.2% comes from current socioeconomic status and 49.3% from the historical-policy-contaminated credit-history feature, while the true behavioral gap contributes essentially nothing (-1.6%, statistical noise around zero). This is exactly the mixed case: the model should be fixed first (retrain without the proxy, recovering roughly a quarter of the gap immediately), the credit-history feature needs a separate historical-policy adjustment, and the socioeconomic-status share should go to the business as a policy question rather than be treated as something a model change alone can or should erase.
Trade-offs and pitfalls
The most common wrong turn is collapsing all three channels into "model bias" and responding only by retraining or adding a fairness constraint; the worked example shows that would address only about a quarter of the disparity here and would leave the historical-policy and socioeconomic channels completely untouched, likely triggering a second audit finding shortly after the first is closed. A second pitfall is conditioning on the mediator (CH) when trying to measure the historical-policy channel's total effect on Y, which would statistically absorb the very effect under investigation and produce a false all-clear. A third is assuming a socioeconomic-confounder finding means "nothing to do here": even when the model is statistically accurate given real socioeconomic disparity, the business decision of whether to price that disparity into consumer credit outcomes is a legitimate and often legally scrutinized policy choice, and treating it as automatically out of scope for the audit is itself a judgment call that should be surfaced, not made silently. Finally, all of this depends on getting the graph right in the first place; an unmeasured legitimate factor missing from the graph will masquerade as "model itself" disparity in the conditioning step, so any material model-itself share should trigger a check for missing legitimate variables before concluding the model needs to be replaced.
Explain the formal definition of counterfactual fairness. Describe how you would test for it using observational data and a structural causal model, and discuss the assumptions required and practical limitations when applying this in production.
Sample Answer
Direct answer: counterfactual fairness (Kusner et al., 2017) says a prediction for an individual is fair if it would have been the same in a counterfactual world where that individual's protected attribute (and everything causally downstream of it) had been different, holding everything causally upstream and independent of the protected attribute fixed.
Structured elaboration. This is a CAUSAL definition, in contrast to the purely statistical, population-level definitions like demographic parity or equalized odds. It requires a structural causal model (SCM): a causal graph over the protected attribute A, other observed features X, and the outcome Y, with assumed functional relationships. A prediction Y^ is counterfactually fair if, for every individual, P(Y^A←a=y∣X,A=a)=P(Y^A←a′=y∣X,A=a) for all values a,a′ of the protected attribute; in words, replacing A with a different value in the causal model, while keeping the individual's non-descendant background factors fixed, should not change the predicted outcome.
Testing it, step by step. (1) Specify or assume a causal graph connecting A, X, and Y, including which features are causal descendants of A (and therefore must also be intervened on) versus which are independent background factors. (2) For each individual, generate a counterfactual version of their features under a flipped value of A, propagating the intervention through the causal graph (an individual's education level might causally depend on their historical access, which in turn depended on a protected attribute, so education is a descendant and must change too, not just A itself). (3) Run the model on both the factual and counterfactual feature sets and compare the predictions; a large or systematic gap indicates a counterfactual-fairness violation.
Worked example (small concrete SCM, one individual traced end to end, executed). Use a 3-variable SCM with stated functional forms: protected attribute A∈{0,1}, a mediator E (an "experience" signal that is causally downstream of A, e.g. because historically unequal access shaped how much visible experience the same underlying ability could produce), and the deployed scoring model Y^ itself:
AEY^=UA=α⋅A+UE,α=−2.0=intercept+βA⋅A+βE⋅E,intercept=50, βA=−3.0, βE=4.0alpha = -2.0
intercept = 50.0
beta_A = -3.0
beta_E = 4.0
def E_from(A, U_E):
return alpha * A + U_E
def score(A, E):
return intercept + beta_A * A + beta_E * E
# One individual, observed (factual) values:
A_factual = 1
E_factual = 6.0
# Step 1 (abduction): infer this individual's exogenous noise U_E from what was observed.
# U_E is independent of A by construction, so it is held FIXED under the intervention.
U_E = E_factual - alpha * A_factual
print(f"abduction: U_E = E_factual - alpha*A_factual = {E_factual} - ({alpha})*{A_factual} = {U_E}")
# Step 2 (action): intervene, flipping A.
A_cf = 0 if A_factual == 1 else 1
print(f"action: flip A from {A_factual} to {A_cf}")
# Step 3 (prediction): propagate the flip through the causal graph to get the
# counterfactual E, THEN run the model on the counterfactual feature vector.
E_cf = E_from(A_cf, U_E)
print(f"propagation: E_cf = alpha*A_cf + U_E = ({alpha})*{A_cf} + {U_E} = {E_cf}")
Yhat_factual = score(A_factual, E_factual)
Yhat_cf = score(A_cf, E_cf)
delta = Yhat_cf - Yhat_factual
print(f"factual: A={A_factual}, E={E_factual} -> Yhat = {Yhat_factual}")
print(f"counterfactual: A={A_cf}, E={E_cf} -> Yhat = {Yhat_cf}")
print(f"counterfactual-fairness delta (properly propagated) = {delta}")
# Contrast with the common mistake this answer warns about below: flipping A but
# NOT propagating the intervention through E (holding E fixed at its factual value).
Yhat_cf_naive = score(A_cf, E_factual)
delta_naive = Yhat_cf_naive - Yhat_factual
print(f"counterfactual (naive, E left at factual {E_factual}) -> Yhat = {Yhat_cf_naive}")
print(f"naive delta (E not propagated) = {delta_naive}")
Actual output:
abduction: U_E = E_factual - alpha*A_factual = 6.0 - (-2.0)*1 = 8.0
action: flip A from 1 to 0
propagation: E_cf = alpha*A_cf + U_E = (-2.0)*0 + 8.0 = 8.0
factual: A=1, E=6.0 -> Yhat = 71.0
counterfactual: A=0, E=8.0 -> Yhat = 82.0
counterfactual-fairness delta (properly propagated) = 11.0
counterfactual (naive, E left at factual 6.0) -> Yhat = 74.0
naive delta (E not propagated) = 3.0
This individual's factual prediction is 71.0. Under the properly-propagated counterfactual (flip A, then recompute E from the SAME exogenous noise UE via the stated equation, then re-run the model), the prediction rises to 82.0, an 11.0-point counterfactual-fairness violation: this individual's outcome depends on their protected attribute both directly (βA) and indirectly through the mediator E. Note the naive version that flips A but leaves E at its factual value of 6.0 only shows a 3.0-point gap, understating the true violation by more than 3x, exactly the failure mode described next.
Assumptions and limitations in production. The entire method depends on a causal graph that is usually not directly observable and must be assumed based on domain knowledge, and different plausible graphs can give different fairness verdicts on the same data; sensitivity analysis across a few plausible graphs is standard practice rather than committing to one graph as ground truth. Full counterfactual fairness is also a strong, sometimes overly strict criterion: in most real settings you can only bound rather than exactly compute the counterfactual quantities, since you never observe the same individual under both values of a protected attribute.
Trade-offs and pitfalls. The most common practical mistake is testing "counterfactual fairness" by flipping only the protected attribute value while holding every other feature fixed; if any other feature is causally downstream of the protected attribute (education, income history, address), that test is actually checking something weaker and can pass even when the model would fail a properly-propagated causal test.
Given a structural causal model with variables X, a sensitive attribute A, and outcome Y, sketch pseudocode for a counterfactual-fairness check: test whether the distribution of Y under a counterfactual flip of A is invariant to A for individuals. Provide the sampling and estimation steps.
Sample Answer
Direct answer
Counterfactual fairness (Kusner et al., 2017) asks a per-individual, not per-population, question: for this specific person, would the outcome have been different if only their sensitive attribute A had been different, holding everything about them that is causally independent of A fixed? The check has three structural steps against a structural causal model (SCM) over (A,X,Y): abduction (infer each individual's exogenous noise from what was observed), action (intervene to flip A), and prediction (recompute the downstream variables under the flipped world using the same noise). A predictor is counterfactually fair if its output does not change for any individual under this flip; the estimation step has to compare factual and counterfactual outcomes PAIRED per individual, because a population-level distributional comparison can miss the violation entirely when the flip is symmetric across the population.
Structured elaboration
The SCM. Let A be the sensitive attribute (a root/exogenous variable, e.g. gender), X a mediator that A causally influences (e.g. years of experience shaped by unequal historical opportunity), and Y the outcome (e.g. a hiring score) causally downstream of both:
AXY=UA=fX(A,UX)=fY(A,X,UY)where UA,UX,UY are mutually independent exogenous noise terms and fX,fY are the structural equations. A's effect on Y can travel two ways: directly (A→Y) and indirectly through the mediator (A→X→Y); both are potential sources of unfairness the check needs to catch.
Pseudocode, the three-step abduction/action/prediction procedure applied per individual i:
for each individual i with observed (a_i, x_i, y_i):
# 1. ABDUCTION: infer the exogenous noise this individual actually has,
# given the SCM's structural equations and what was observed.
# (exact if f_X, f_Y are invertible in U; posterior sampling / MCMC
# if they are not, e.g. non-invertible or the SCM has unobserved confounders)
u_x_i = invert_f_X(a_i, x_i)
u_y_i = invert_f_Y(a_i, x_i, y_i) # only needed if testing Y itself,
# not needed if testing a fitted g(A, X)
# 2. ACTION: intervene, do(A := 1 - a_i), the counterfactual flip.
a_cf_i = 1 - a_i
# 3. PREDICTION: recompute downstream variables under the SAME noise,
# now with the counterfactual A.
x_cf_i = f_X(a_cf_i, u_x_i)
y_cf_i = f_Y(a_cf_i, x_cf_i, u_y_i) # or g(a_cf_i, x_cf_i) for a fitted predictor g
store (y_i_or_ghat_i, y_cf_i_or_ghat_cf_i)
Sampling steps (building the dataset the check runs against, distinct from the per-individual counterfactual computation above): draw UA,UX,UY independently from their assumed distributions, push them forward through fX then fY to generate a synthetic observational sample (A,X,Y) of n individuals; on real data, skip this and abduct directly from the observed rows instead of simulating them. When fX or fY is not exactly invertible (nonlinear, or the SCM includes latent confounders), abduction becomes approximate: sample U from its posterior given the observed variables (for example, via Monte Carlo / MCMC, or a variational approximation), and repeat the action-and-prediction steps for several posterior draws to get a distribution of counterfactual outcomes per individual rather than a single point value.
Estimation steps: compute, for every individual, the counterfactual shift Δi=y^icf−y^i. Report:
- The mean and standard deviation of Δi across individuals (an aggregate size-of-violation summary).
- The fraction of individuals whose ∣Δi∣ exceeds a materiality threshold (a practical, business-legible "how many people would actually see a different outcome" number).
- A paired test (for example, a paired t-test or Wilcoxon signed-rank test on Δi, or simply checking whether Δi is materially different from 0 for most individuals), NOT an unpaired two-sample test comparing the pooled factual distribution to the pooled counterfactual distribution. This distinction matters: if the flip sends roughly half the population from A=0 to A=1 and the other half from A=1 to A=0 with a symmetric effect, the pooled factual and counterfactual distributions can look statistically indistinguishable in aggregate even though every single individual's own outcome changed substantially. The question asks for invariance "for individuals," and only a paired, per-individual comparison actually tests that.
Worked example
import numpy as np
from sklearn.linear_model import LinearRegression
from scipy import stats
rng = np.random.default_rng(42)
n = 5000
U_A = rng.integers(0, 2, n)
A = U_A.astype(float)
U_X = rng.normal(0, 1, n)
X = 2.0 * A + U_X # A causally shifts the mediator X
U_Y = rng.normal(0, 1, n)
Y = 3.0 * X + 1.5 * A + U_Y # A has BOTH a direct and an X-mediated effect on Y
naive_model = LinearRegression().fit(np.column_stack([A, X]), Y) # uses A directly: unfair by construction
fair_model = LinearRegression().fit(U_X.reshape(-1, 1), Y) # uses only the A-independent residual
def counterfactual_check(model, use_features):
U_X_hat = X - 2.0 * A # abduction: exact here since f_X is linear and invertible in U_X
A_cf = 1.0 - A # action: do(A := 1-A)
X_cf = 2.0 * A_cf + U_X_hat # prediction: recompute the mediator under the flip
if use_features == "AX":
return model.predict(np.column_stack([A, X])), model.predict(np.column_stack([A_cf, X_cf]))
return model.predict(U_X_hat.reshape(-1, 1)), model.predict(U_X_hat.reshape(-1, 1))
pred_f_naive, pred_cf_naive = counterfactual_check(naive_model, "AX")
pred_f_fair, pred_cf_fair = counterfactual_check(fair_model, "UX")
delta_naive = pred_cf_naive - pred_f_naive
delta_fair = pred_cf_fair - pred_f_fair
print(f"naive: mean shift={delta_naive.mean():.4f}, frac |shift|>0.5={np.mean(np.abs(delta_naive)>0.5):.4f}")
print(f"fair: mean shift={delta_fair.mean():.6f}, frac |shift|>0.5={np.mean(np.abs(delta_fair)>0.5):.4f}")
ks_naive = stats.ks_2samp(pred_f_naive, pred_cf_naive)
print(f"naive UNPAIRED (pooled) KS statistic: {ks_naive.statistic:.4f}, p={ks_naive.pvalue:.2e}")
Executed output:
naive: mean shift=0.0898, frac |shift|>0.5=1.0000
fair: mean shift=0.000000, frac |shift|>0.5=0.0000
naive UNPAIRED (pooled) KS statistic: 0.0234, p=1.29e-01
The naive model, which conditions on A directly, shifts every single individual's (100%) prediction by more than 0.5 when their A is counterfactually flipped, clearly violating counterfactual fairness. The fair model, trained only on the noise term UX that abduction shows is independent of A, shows exactly zero shift for every individual, by construction. But the naive model's pooled (unpaired) factual-versus-counterfactual KS test comes back statistically non-significant (p = 0.129): the population-level distributions look similar because the flip is symmetric (roughly half the population moves up, half moves down, by comparable magnitudes), even though the paired, per-individual check shows every individual is affected. This is exactly the gap the estimation step above is written to avoid.
Trade-offs and pitfalls
The most common wrong turn is running the pooled, unpaired comparison shown above and concluding the model is fair because the aggregate distributions match; the worked example shows this passes even for a model that changes literally every individual's prediction. A second pitfall is assuming abduction is exact: it only inverts cleanly when fX and fY are invertible functions of their noise terms, which almost never holds for a nonlinear model or a neural-network-based structural equation, so real applications need approximate (posterior-sampling) abduction and should report a distribution of counterfactual outcomes per individual, not a single point estimate. Third, the entire exercise inherits the correctness of the assumed causal graph: if the true graph has an unmodeled confounder between A and X, or the mediator is mislabeled as legitimate when it is itself downstream of historical discrimination, the counterfactual-fairness check will faithfully certify the wrong graph as fair. Finally, dropping A-influenced features entirely (as the "fair model" does here) achieves counterfactual invariance by construction, but at the cost of discarding any legitimate predictive signal those features also carried, so in practice the harder and more common task is separating the part of a mediator's variance that is a legitimate signal from the part that is a proxy for historical discrimination, not simply excluding the mediator outright.
Unlock Full Question Bank
Get access to all 7 Responsible AI: Fairness, Bias, and Interpretability interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.