Old Boys’ Club Bank: a worked example
This notebook walks through every causal-attribution quantity discussed in the Old Boys’ Club Bank (OBCB) example in the paper, computed analytically on the structural causal model. OBCB is the paper’s running example: a deliberately simple loan-approval model where two applicants are rejected for structurally different reasons, and where most standard attribution methods get at least one of them wrong. We go through the following:
exact closed-form values for Probability of Necessity (PN), Probability of Sufficiency (PS), Probability of Necessity and Sufficiency (PNS), Average Treatment Effect (ATE), Conditional Average Treatment Effect (CATE), Shapley values (SHAP) / Causal SHAP and PCI (Probabilistic Causal Impact, the paper’s method) at both the population and the individual level, for our two applicants Alice and Bob;
a side-by-side comparison table of all six methods, followed by a pass/fail table against the paper’s desiderata (the formal conditions a good attribution score should meet here);
a sense of why the failing methods fail: the main issue comes down to a single mediator,
check_failed, that every intervention propagates through unless something pins it in place.
The narrative follows the paper. We build up the argument in roughly increasing sophistication: PN/PS/PNS first (their successes and blind spots), then ATE/CATE (which share PNS’s blind spot), then PCI (which fixes it via the witness mechanism), then SHAP / Causal SHAP for the feature-attribution comparison, and finally a 3-feature extension that exposes a separate SHAP failure mode. Throughout, the test case to watch is Alice: the correct attribution says her gender, not her credit, caused her rejection, and most methods disagree.
We assume light familiarity with probability and the idea of a causal intervention (\(\mathrm{do}(\cdot)\)), but not with the causal-attribution literature, since each method is motivated in a sentence where it first appears. The notebook is meant to be read at three levels: the prose alone tells the whole story; the markdown tables give the numbers; the code cells are there only if you want to see exactly how a quantity is computed.
*Aside on the paper’s discussion also defines a deterministic version of OBCB used to introduce actual causality. This notebook implements only the stochastic version, since that is the one PN/PS/PNS/ATE/CATE/PCI all need.
Outline
The model
Probability of Necessity (PN)
Probability of Sufficiency (PS)
Probability of Necessity and Sufficiency (PNS)
Treatment effects (ATE, CATE, ITE)
PCI: Probabilistic Causal Impact (the witness mechanism)
SHAP / Causal SHAP: the 2-feature game, plus the six-method comparison table
3-feature extension: a separate SHAP failure mode
Conclusions
1. The model
OBCB is a fictional bank with a discriminatory loan policy. Male applicants are nearly always credit-checked (90%); female applicants almost never are (20%). Unchecked applicants are denied automatically. If the check happens, your credit determines whether you pass, with a small slack at the edges: a man who fails the check is still approved 5% of the time, and a woman who passes is denied 10% of the time.
We follow two applicants throughout:
Alice: female, bad credit, rejected. Most likely she was never checked (since the bank rarely checks women), in which case her credit was never even evaluated.
Bob: male, bad credit, rejected. Almost certainly because he was checked and his credit failed.
The question PN/PS/PNS each get wrong, in different ways, is whether Alice’s rejection belongs to her gender or to her credit.
The DAG below shows the causal structure: the orange node check_failed is the mediator (and the witness PCI will later pin), and the green node loan is the outcome. The structural equations that generate it follow.
[1]:
# Causal DAG for the OBCB loan model.
#
# Rendered with the SAME helper that generates the paper figure
# (scripts/make_example_dags.py): single source of truth, so this notebook and
# the paper never drift. The rendering is colour-blind-safe -- each role is
# encoded by SHAPE (box / hexagon / double box / dashed circle) as well as
# colour, and dashed U-circles mark the exogenous noise on each stochastic
# mechanism.
import pathlib
import sys
import matplotlib.pyplot as plt
_p = pathlib.Path.cwd()
for _ in range(8):
if (_p / "scripts" / "make_example_dags.py").exists():
sys.path.insert(0, str(_p))
break
_p = _p.parent
from scripts.make_example_dags import fig_obcb # noqa: E402
fig, ax = plt.subplots(figsize=(5.6, 4.8))
ax.axis("off")
ax.set_aspect("equal")
fig_obcb(ax)
plt.show()
Notice the following:
Gender affects loan through two routes. Directly via
gender → check → loan(gating whether a check happens at all), and indirectly viagender → loan_if_checked(modulating how strict the check is).check_failedis the mediator. It carries credit’s signal into the loan decision, and it is the witness PCI will use to distinguish Alice’s unchecked branch from Bob’s checked-and-failed branch.
Structural equations
Variable |
Equation |
|---|---|
|
Independent exogenous noise, \(\mathrm{Bern}(0.5)\) each |
|
\(\mathrm{Bern}(0.9)\) if male, \(\mathrm{Bern}(0.2)\) if female |
|
|
|
\(\mathrm{Bern}(p_{g,cf})\), see matrix below |
|
|
Loan probability matrix \(p_{g,cf}\):
passed ( |
failed ( |
|
|---|---|---|
female |
0.90 |
0.00 |
male |
1.00 |
0.05 |
For every quantity below we marginalise over the random check (which is not directly observed) and condition on whatever observables the quantity asks us to.
Desiderata a correct attribution must satisfy
Before computing anything we write down the target: the conditions any good attribution score should satisfy on this example. We follow the paper and grade every method below against the same seven desiderata. They are stated for our two applicants and refer to a generic responsibility score \(R(V \rightsquigarrow \mathrm{loan} \mid \text{person})\), a placeholder for the number a given method assigns to variable \(V\) for the loan outcome (PN, PNS, SHAP, PCI, …). Because some methods produce signed values, the conditions are stated on magnitudes \(\lvert R(\cdot)\rvert\); below we abbreviate \(R(V \rightsquigarrow \mathrm{loan}\mid\cdot)\) as \(R(V\mid\cdot)\).
Name |
In words |
Condition |
|---|---|---|
D-A1 |
gender mattered for Alice |
\(\lvert R(\text{gender}\mid\text{Alice})\rvert > 0\) |
D-A2 |
credit mattered for Alice |
\(\lvert R(\text{credit}\mid\text{Alice})\rvert > 0\) |
D-A-rank |
for Alice, gender outranks credit |
\(\lvert R(\text{gender}\mid\text{Alice})\rvert > \lvert R(\text{credit}\mid\text{Alice})\rvert\) |
D-B1 |
gender mattered for Bob (strictly) |
\(\lvert R(\text{gender}\mid\text{Bob})\rvert > 0\) |
D-B2 |
credit mattered for Bob |
\(\lvert R(\text{credit}\mid\text{Bob})\rvert > 0\) |
D-B-rank |
for Bob, credit outranks gender |
\(\lvert R(\text{credit}\mid\text{Bob})\rvert > \lvert R(\text{gender}\mid\text{Bob})\rvert\) |
D-comp |
gender matters more for Alice than for Bob |
\(\lvert R(\text{gender}\mid\text{Alice})\rvert > \lvert R(\text{gender}\mid\text{Bob})\rvert\) |
The reasoning behind them is just the structural story of the model:
D-A1 / D-A2: Alice has two live causal routes, so neither feature is inert: her gender filtered her out at the checking stage (women are rarely checked), while bad credit would have mattered had she been checked.
D-A-rank is the substantive one. Alice was never credit-checked, so gender’s role is immediate while credit’s is purely hypothetical, and gender should win.
D-B1 / D-B2 / D-B-rank: Bob was checked and failed, so credit is the proximate cause and should outrank gender; but being male opened the check at all, so gender must still come out strictly nonzero.
D-comp is the cross-individual asymmetry, and the hardest for naive methods: gender was sufficient on its own to reject Alice (no evaluation happened), whereas for Bob gender merely opened the door that credit then closed, so gender should carry more responsibility for Alice than for Bob.
These seven are exactly the rows of the pass/fail audit assembled in §7 (the six-method comparison). Watch the D-A-rank row in particular: as we will see, PCI with witnesses is the only method that satisfies all seven.
[2]:
import os
# Smoke-test mode is triggered by ``CI=1``. The OBCB notebook is fully
# analytical (no Monte Carlo, no fitting), so the flag merely keeps the
# convention consistent with the heavier notebooks in this docs/source set.
smoke_test = "CI" in os.environ
import pandas as pd
# ── Model parameters ─────────────────────────────────────────────────────────
# P(check=1 | gender)
p_check = {0: 0.2, 1: 0.9} # 0=female, 1=male
# P(loan_if_checked=1 | gender, check_failed)
# check_failed = check * (1-credit); equals 1 only when check=1 AND credit=bad.
loan_prob = {
(0, 0): 0.9, # female, check_failed=0 (checked+good OR unchecked, but see note)
(0, 1): 0.0, # female, check_failed=1 (checked+bad → always denied)
(1, 0): 1.0, # male, check_failed=0
(1, 1): 0.05, # male, check_failed=1 (checked+bad → 5% approved)
}
def p_loan(gender, credit):
"""P(loan=1 | gender, credit) for the STOCHASTIC model.
Structural equation: loan = loan_if_checked * check
So loan=1 requires check=1. When check=0, loan=0 regardless of credit.
Decomposition:
P(loan=1) = P(check=1) * P(loan_if_checked=1 | check=1, credit)
Given check=1: check_failed = 1 * (1-credit) = 1-credit, so we index
loan_prob by (gender, 1-credit). The check=0 branch contributes 0.
"""
check_failed_given_checked = 1 - credit
return p_check[gender] * loan_prob[(gender, check_failed_given_checked)]
# Sanity check: print all four marginal loan probabilities
print("P(loan=1 | gender, credit):")
for g, gl in [(0, "female"), (1, "male")]:
for c, cl in [(0, "bad"), (1, "good")]:
print(f" {gl:6s}, {cl:4s}: {p_loan(g, c):.4f}")
P(loan=1 | gender, credit):
female, bad : 0.0000
female, good: 0.1800
male , bad : 0.0450
male , good: 0.9000
2. Probability of Necessity (PN)
PN focuses on the question: Had the cause been absent, would the outcome still have occurred?
that is: take an applicant who actually had the cause (\(C = c^\star\)) and was rejected (\(Y = 0\)); PN is the probability that switching the cause to its alternative (\(C = c'\)) would have approved them (\(\mathrm{loan} = 1\)). A high PN means the cause was necessary: remove it and the rejection would most likely not have happened.
PN is the classical necessity measure from the actual-causality literature (Pearl; Halpern & Pearl): it asks whether the cause was needed for the outcome. It is the natural first question when we want to know whether something was “the reason” a decision went the way it did.
We ask this at two levels:
Population level: average over the credit distribution among rejected applicants of the given gender (or vice versa). This asks “how often is the cause necessary in the population?”
Individual level: fix the other factual variable too (so for Alice, condition on gender = female and credit = bad). This asks “how often is the cause necessary for someone like Alice?”
The main mechanical step is averaging over the random check, which is unobserved. We do that with the structural equations directly, exactly the averaging PCI will do later.
[3]:
# ── Helper: posterior on credit within a (gender, loan=0) stratum ─────────────
def p_joint_loan0(gender, credit):
"""P(gender, credit, loan=0) — assuming P(gender)=P(credit)=0.5."""
return 0.5 * 0.5 * (1 - p_loan(gender, credit))
def p_credit_given_gender_loan0(credit, gender):
"""P(credit | gender, loan=0) — posterior on credit for rejected applicants."""
numerator = p_joint_loan0(gender, credit)
denominator = sum(p_joint_loan0(gender, c) for c in [0, 1])
return numerator / denominator
print(
"P(credit | gender, loan=0) — posterior credit distribution among rejected applicants:"
)
for g, gl in [(0, "female"), (1, "male")]:
for c, cl in [(0, "bad"), (1, "good")]:
print(f" {gl:6s}, {cl:4s}: {p_credit_given_gender_loan0(c, g):.4f}")
P(credit | gender, loan=0) — posterior credit distribution among rejected applicants:
female, bad : 0.5495
female, good: 0.4505
male , bad : 0.9052
male , good: 0.0948
[4]:
# ── Population PN: gender ─────────────────────────────────────────────────────
# PN(gender=g, loan=F) = E_{credit ~ posterior(g, loan=0)}[ P(loan=1 | do(gender=g'), credit) ]
def pn_gender(observed_gender, counterfactual_gender):
return sum(
p_credit_given_gender_loan0(c, observed_gender)
* p_loan(counterfactual_gender, c)
for c in [0, 1]
)
pn_male = pn_gender(observed_gender=1, counterfactual_gender=0)
pn_female = pn_gender(observed_gender=0, counterfactual_gender=1)
print(f"PN(gender=male, loan=F) = {pn_male:.4f}")
print(f"PN(gender=female, loan=F) = {pn_female:.4f}")
# ── Population PN: credit ─────────────────────────────────────────────────────
# Symmetric construction: condition on (credit=observed, loan=0) and marginalise over gender.
def p_gender_given_credit_loan0(gender, credit):
"""P(gender | credit, loan=0)."""
numerator = p_joint_loan0(gender, credit)
denominator = sum(p_joint_loan0(g, credit) for g in [0, 1])
return numerator / denominator
def pn_credit(observed_credit, counterfactual_credit):
return sum(
p_gender_given_credit_loan0(g, observed_credit)
* p_loan(g, counterfactual_credit)
for g in [0, 1]
)
pn_credit_bad = pn_credit(observed_credit=0, counterfactual_credit=1)
print(f"PN(credit=bad, loan=F) = {pn_credit_bad:.4f}")
PN(gender=male, loan=F) = 0.0171
PN(gender=female, loan=F) = 0.4302
PN(credit=bad, loan=F) = 0.5317
Gender PN is asymmetric across the two genders. A rejected woman has high PN for her gender (0.43): had she been male, she would quite likely have been approved, because she would have been checked and might have passed. A rejected man has near-zero PN for his gender (0.02): had he been female, she would still have been rejected, since unchecked women never get loans here.
Credit PN is symmetric across genders (0.53 for both). At the population level, credit being “good” tips a rejected applicant’s odds the same way regardless of gender, because the population value averages over a fairly uniform credit-check stratum.
These are reasonable group-level statements but, as we are about to see, they say nothing specific about Alice or Bob.
[5]:
# ── Individual PN: Alice (female, bad credit) ─────────────────────────────────
#
# Context is fixed: (gender=female, credit=bad). No averaging needed.
#
# PN(gender=female | Alice) = P(loan=1 | do(gender=male), credit=bad)
pn_alice_gender = p_loan(gender=1, credit=0) # male, bad credit
# PN(credit=bad | Alice) = P(loan=1 | do(credit=good), gender=female)
pn_alice_credit = p_loan(gender=0, credit=1) # female, good credit
print(f"PN(gender=female | Alice) = {pn_alice_gender:.4f}")
print(f"PN(credit=bad | Alice) = {pn_alice_credit:.4f}")
# ── Individual PN: Bob (male, bad credit) ─────────────────────────────────────
#
# PN(gender=male | Bob) = P(loan=1 | do(gender=female), credit=bad)
# loan_prob[female, check_failed=1] = 0.0, so this is exactly 0.
pn_bob_gender = p_loan(gender=0, credit=0) # female, bad credit
# PN(credit=bad | Bob) = P(loan=1 | do(credit=good), gender=male)
pn_bob_credit = p_loan(gender=1, credit=1) # male, good credit
print(f"\nPN(gender=male | Bob) = {pn_bob_gender:.4f}")
print(f"PN(credit=bad | Bob) = {pn_bob_credit:.4f}")
PN(gender=female | Alice) = 0.0450
PN(credit=bad | Alice) = 0.1800
PN(gender=male | Bob) = 0.0000
PN(credit=bad | Bob) = 0.9000
At the individual level, PN drops noticeably for Alice’s gender, from 0.43 to 0.045. Conditioning on her bad credit means the counterfactual male version of Alice would still most likely fail the credit check, so any small chance of approval now comes only from the 5% slack the bank gives to credit-failed males.
Bob’s gender PN is exactly 0. A female version of Bob, with bad credit, has zero chance of approval under this model: unchecked women always lose, and women who do get checked then fail the bad-credit check (the loan-probability matrix gives 0 for female + check_failed).
There is already something uncomfortable here. PN at the individual level says gender is barely responsible for Alice’s denial (0.045), a number that should be high if the bank’s discriminatory policy caused her rejection. We will return to this.
3. Probability of Sufficiency (PS)
Had the cause been present when it was absent, would the outcome have occurred?
that is: take an applicant who did not have the cause (\(C = c'\)) and was approved (\(Y = 1\)); PS is the probability that introducing the cause (\(C = c^\star\)) would have flipped them to rejected (\(\mathrm{loan} = 0\)). A high PS means the cause is sufficient: add it and the rejection appears.
PS is the sufficiency counterpart to PN, and it is worth computing because a cause we would call responsible should score on both counts, necessary and sufficient. §4 then fuses the two into a single number.
Start from someone who was approved and whose cause was absent, then ask: had the cause been present, would they have been rejected?
As before, we marginalise over the random check using the structural equations.
[6]:
# ── Helper: posterior on credit within a (gender, loan=1) stratum ─────────────
def p_credit_given_gender_loan1(credit, gender):
"""P(credit | gender, loan=1) — posterior credit among approved applicants."""
numerator = 0.5 * p_loan(gender, credit)
denominator = sum(0.5 * p_loan(gender, c) for c in [0, 1])
return numerator / denominator
# ── Population PS: gender ─────────────────────────────────────────────────────
# PS(gender=g, loan=F) = E_{credit ~ posterior(g', loan=1)}[ P(loan=0 | do(gender=g), credit) ]
def ps_gender(factual_gender, counterfactual_gender):
return sum(
p_credit_given_gender_loan1(c, counterfactual_gender)
* (1 - p_loan(factual_gender, c))
for c in [0, 1]
)
ps_male = ps_gender(factual_gender=1, counterfactual_gender=0)
ps_female = ps_gender(factual_gender=0, counterfactual_gender=1)
print(f"PS(gender=male, loan=F) = {ps_male:.4f}")
print(f"PS(gender=female, loan=F) = {ps_female:.4f}")
# ── Population PS: credit ─────────────────────────────────────────────────────
def p_gender_given_credit_loan1(gender, credit):
"""P(gender | credit, loan=1)."""
numerator = 0.5 * p_loan(gender, credit)
denominator = sum(0.5 * p_loan(g, credit) for g in [0, 1])
return numerator / denominator
def ps_credit(factual_credit, counterfactual_credit):
return sum(
p_gender_given_credit_loan1(g, counterfactual_credit)
* (1 - p_loan(g, factual_credit))
for g in [0, 1]
)
ps_credit_bad = ps_credit(factual_credit=0, counterfactual_credit=1)
print(f"PS(credit=bad, loan=F) = {ps_credit_bad:.4f}")
PS(gender=male, loan=F) = 0.1000
PS(gender=female, loan=F) = 0.8286
PS(credit=bad, loan=F) = 0.9625
Gender PS is asymmetric. A randomly chosen approved man, had he been a woman, would almost certainly have been rejected, giving sufficiency 0.83 for gender = female. The reverse is much smaller (0.10): an approved woman made male would mostly still be approved, because male approvals are not that exceptional.
Credit PS is near-certain (0.96). Nearly every approved applicant has good credit, and intervening to give them bad credit nearly always rejects them.
PS gives the “if you put the cause in, would you flip the outcome?” view, complementary to PN’s “if you take the cause out, would you flip the outcome?” view. Neither view on its own is enough for a single rank-able score; for that we need to combine them into PNS.
[7]:
# ── Individual PS: Alice (female, bad credit) ─────────────────────────────────
#
# PS(gender=female | Alice) = P(loan=0 | do(gender=female), credit=bad)
# Conditioning event: (gender=male, credit=bad, loan=1).
# P(loan=1 | male, bad) = 0.045 > 0, so this is well-defined.
# After fixing context (female, bad), P(loan=1) = 0.0 → PS = 1.
ps_alice_gender = 1 - p_loan(gender=0, credit=0) # = 1 - 0 = 1.0
# PS(credit=bad | Alice) = P(loan=0 | do(credit=bad), gender=female)
# Conditioning event: (credit=good, gender=female, loan=1).
# P(loan=1 | female, good) = 0.18 > 0, so this is well-defined.
# After fixing context (female, bad), P(loan=1) = 0.0 → PS = 1.
ps_alice_credit = 1 - p_loan(gender=0, credit=0) # = 1 - 0 = 1.0
print(f"PS(gender=female | Alice) = {ps_alice_gender:.4f}")
print(f"PS(credit=bad | Alice) = {ps_alice_credit:.4f}")
# ── Individual PS: Bob (male, bad credit) ─────────────────────────────────────
#
# PS(gender=male | Bob):
# Conditioning event requires (gender=female, credit=bad, loan=1).
# P(loan=1 | female, bad) = 0 → conditioning event is impossible → UNDEFINED.
ps_bob_gender_conditioning_prob = p_loan(gender=0, credit=0)
print(
f"\nPS(gender=male | Bob): conditioning event P = {ps_bob_gender_conditioning_prob} → UNDEFINED"
)
# PS(credit=bad | Bob) = P(loan=0 | do(credit=bad), gender=male)
# Conditioning event: (credit=good, gender=male, loan=1).
# P(loan=1 | male, good) = 0.9 > 0, so this is well-defined.
# P(loan=1 | male, bad) = 0.9 × 0.05 = 0.045, so P(loan=0 | male, bad) = 0.955.
ps_bob_credit = 1 - p_loan(gender=1, credit=0) # = 1 - 0.045 = 0.955
print(f"PS(credit=bad | Bob) = {ps_bob_credit:.4f}")
PS(gender=female | Alice) = 1.0000
PS(credit=bad | Alice) = 1.0000
PS(gender=male | Bob): conditioning event P = 0.0 → UNDEFINED
PS(credit=bad | Bob) = 0.9550
Both of Alice’s individual PS values are exactly \(1\): starting from an applicant who was approved, forcing in Alice’s factual context (female, bad credit) drives the approval probability to 0, so flipping the outcome is certain. This is the mirror image of her near-zero PN: sufficiency and necessity can disagree sharply at the individual level.
Bob’s gender PS is undefined: it conditions on a female-with-bad-credit applicant being approved, an event with probability 0 under this model (unchecked women never get loans, checked women with bad credit fail). His credit PS is well-defined and near-certain (0.955). The undefined cell is a first hint that PN/PS in isolation are brittle on this example; PNS, which we turn to next, combines both directions into a single score.
4. Probability of Necessity and Sufficiency (PNS)
The question that PNS attempts to answer is about the probability of the outcome occuring under the factual cause but not under the alternative?
that is: the probability that one and the same applicant, the same individual under the same exogenous-noise draw, would be rejected under the factual cause (\(Y_{c^\star} = 0\)) and approved under the alternative (\(Y_{c'} = 1\)). Necessity and sufficiency are required to hold together, in the same counterfactual world.
PNS is a single quantity that’s high only when the cause is both necessary (removing it would flip the outcome) and sufficient (adding it would produce the outcome). It is the closest of the PN/PS family to a “responsibility” score.
PN asked whether removing the cause would have flipped the outcome; PS asked whether adding it would have produced the outcome. PNS asks for both to hold in the same counterfactual world, the same individual and the same noise draw, so it is a joint probability \(P(Y_{c^\star}=0,\,Y_{c'}=1)\) and not the product \(\mathrm{PN}\cdot\mathrm{PS}\): a product would let necessity hold for one hypothetical person and sufficiency for another, whereas responsibility requires them to coincide.
Under the assumption that the exogenous noise variables are independent (which holds in our model, where gender, credit and check noise are independent draws), PNS decomposes into a sum over the noise regions:
that is: split on the exogenous context (here the three check-noise regions); within each context multiply the chance of rejection under the factual cause by the chance of approval under the alternative, then average over contexts weighted by \(P(\text{ctx})\). Noise independence is exactly what lets the joint probability factor into this per-context product.
We just need to enumerate the contexts (the three check-noise regions) and sum.
[8]:
# ── Population PNS: gender ────────────────────────────────────────────────────
# PNS(gender=g, loan=F) = E_{credit}[ P(loan=0 | g, credit) × P(loan=1 | g', credit) ]
def pns_gender(factual_gender, counterfactual_gender):
return sum(
0.5 * (1 - p_loan(factual_gender, c)) * p_loan(counterfactual_gender, c)
for c in [0, 1]
)
pns_male = pns_gender(factual_gender=1, counterfactual_gender=0)
pns_female = pns_gender(factual_gender=0, counterfactual_gender=1)
print(f"PNS(gender=male, loan=F) = {pns_male:.4f}")
print(f"PNS(gender=female, loan=F) = {pns_female:.4f}")
# ── Population PNS: credit ────────────────────────────────────────────────────
# PNS(credit=c, loan=F) = E_{gender}[ P(loan=0 | gender, c) × P(loan=1 | gender, c') ]
def pns_credit(factual_credit, counterfactual_credit):
return sum(
0.5 * (1 - p_loan(g, factual_credit)) * p_loan(g, counterfactual_credit)
for g in [0, 1]
)
pns_credit_bad = pns_credit(factual_credit=0, counterfactual_credit=1)
print(f"PNS(credit=bad, loan=F) = {pns_credit_bad:.4f}")
PNS(gender=male, loan=F) = 0.0090
PNS(gender=female, loan=F) = 0.3915
PNS(credit=bad, loan=F) = 0.5197
PNS for Alice ranks credit (0.18) above gender (0.045). That is the wrong answer if we believe the bank’s policy carried Alice’s denial: she was never even evaluated for credit. PNS has no mechanism to register this: intervening on credit propagates through check_failed (the mediator) and overrides the contextual fact that her check never happened.
PNS for Bob ranks credit (0.86) above gender (0). That is correct: his denial really was caused by the failed credit check.
§3 of the paper introduces the D-comp desideratum because of that asymmetry between Alice and Bob: gender should be strictly more responsible for Alice’s denial than for Bob’s. PNS happens to satisfy D-comp here only because Bob’s gender PNS is exactly zero, not because PNS understands the structural difference between their cases. As we will see next, ATE and CATE share this limitation; PCI then fixes it.
[9]:
# ── Individual PNS: Alice (female, bad credit) ────────────────────────────────
#
# PNS_c(gender=female | credit=bad) = P(loan=0|female,bad) × P(loan=1|male,bad)
pns_alice_gender = (1 - p_loan(0, 0)) * p_loan(1, 0)
# PNS_c(credit=bad | gender=female) = P(loan=0|female,bad) × P(loan=1|female,good)
pns_alice_credit = (1 - p_loan(0, 0)) * p_loan(0, 1)
print(f"PNS_c(gender=female | credit=bad) [Alice] = {pns_alice_gender:.4f}")
print(f"PNS_c(credit=bad | gender=female) [Alice] = {pns_alice_credit:.4f}")
# ── Individual PNS: Bob (male, bad credit) ───────────────────────────────────
#
# PNS_c(gender=male | credit=bad) = P(loan=0|male,bad) × P(loan=1|female,bad)
# P(loan=1 | female, bad) = 0 exactly, so PNS = 0.
pns_bob_gender = (1 - p_loan(1, 0)) * p_loan(0, 0)
# PNS_c(credit=bad | gender=male) = P(loan=0|male,bad) × P(loan=1|male,good)
# = 0.955 × 0.9 = 0.8595 → rounds to 0.86, not 0.85 as in the paper.
pns_bob_credit = (1 - p_loan(1, 0)) * p_loan(1, 1)
print(f"\nPNS_c(gender=male | credit=bad) [Bob] = {pns_bob_gender:.4f} ")
print(f"PNS_c(credit=bad | gender=male) [Bob] = {pns_bob_credit:.4f}")
PNS_c(gender=female | credit=bad) [Alice] = 0.0450
PNS_c(credit=bad | gender=female) [Alice] = 0.1800
PNS_c(gender=male | credit=bad) [Bob] = 0.0000
PNS_c(credit=bad | gender=male) [Bob] = 0.8595
5. Treatment effects (ATE, CATE, ITE)
We now put the classical metrics of the causal-inference literature on the same example: the Average Treatment Effect (ATE), the Conditional Average Treatment Effect (CATE), and the Individual Treatment Effect (ITE).
These are the tools a practitioner trained in causal inference reaches for first, so it is worth seeing exactly where they land here. The ATE is the average change in approval if we flipped the treatment for everyone; the CATE is that same average restricted to a subgroup picked out by covariates \(X\) (e.g. applicants with bad credit); and the ITE (introduced below) is the effect for a single named individual, with their own circumstances held fixed.
ATE and CATE
that is: the average change in approval probability if we forced the treatment on for everyone versus off for everyone, a population-level contrast with nothing held fixed.
that is: the same on-minus-off contrast, but restricted to the subgroup with covariates \(X = x\) (e.g. applicants with bad credit). It conditions on \(X\), yet still averages over everything else, including the hidden check noise.
We compute (i) population ATE for gender and for credit, and (ii) CATE for Alice and Bob conditional on the other covariate.
[10]:
# ── ATE and CATE on the stochastic OBCB model ────────────────────────────────
#
# Closed-form computation: marginalise over check (exogenous noise) and over
# any covariate not fixed by do() or conditioning, using the structural
# equations of the model cell above.
P_LOAN_GIVEN_CHECKED = {
# (gender, check_failed): P(loan_if_checked = 1)
(0, 0): 0.9, # female, passed check
(0, 1): 0.0, # female, failed check
(1, 0): 1.0, # male, passed check
(1, 1): 0.05, # male, failed check
}
def E_loan(do_gender=None, do_credit=None, cond_gender=None, cond_credit=None):
"""E[loan | do(...), cond(...)] under the stochastic OBCB SCM.
``do_*`` sets the variable by intervention.
``cond_*`` conditions on an observed value.
Since gender and credit are independent exogenous Bern(0.5) variables,
conditioning and intervening on them coincide here. (This is not true for
downstream variables like ``check_failed``.)
"""
def grid(do_v, cond_v):
if do_v is not None:
return [(do_v, 1.0)]
if cond_v is not None:
return [(cond_v, 1.0)]
return [(0, 0.5), (1, 0.5)]
total = 0.0
for g, pg in grid(do_gender, cond_gender):
p_check_1 = 0.9 if g == 1 else 0.2
for c, pc in grid(do_credit, cond_credit):
for check, pchk in [(1, p_check_1), (0, 1 - p_check_1)]:
if check == 0:
p_loan = 0.0
else:
cf = 1 - c
p_loan = P_LOAN_GIVEN_CHECKED[(g, cf)]
total += pg * pc * pchk * p_loan
return total
# ── Population ATE ───────────────────────────────────────────────────────────
ate_gender = E_loan(do_gender=1) - E_loan(do_gender=0)
ate_credit = E_loan(do_credit=1) - E_loan(do_credit=0)
# ── CATE conditional on the *other* covariate ────────────────────────────────
# Alice: gender=female (0), credit=bad (0). Bob: gender=male (1), credit=bad (0).
cate_gender_alice = E_loan(do_gender=1, cond_credit=0) - E_loan(
do_gender=0, cond_credit=0
)
cate_credit_alice = E_loan(do_credit=1, cond_gender=0) - E_loan(
do_credit=0, cond_gender=0
)
cate_gender_bob = E_loan(do_gender=1, cond_credit=0) - E_loan(
do_gender=0, cond_credit=0
)
cate_credit_bob = E_loan(do_credit=1, cond_gender=1) - E_loan(
do_credit=0, cond_gender=1
)
print(f"ATE(gender → loan) = {ate_gender:.3f}")
print(f"ATE(credit → loan) = {ate_credit:.3f}")
print()
print(
f"CATE(gender | credit=bad) = {cate_gender_alice:.3f} "
f"(Alice and Bob both, since both have credit=bad)"
)
print(f"CATE(credit | gender=female) = {cate_credit_alice:.3f} (Alice's value)")
print(f"CATE(credit | gender=male) = {cate_credit_bob:.3f} (Bob's value)")
# Sanity check: Alice's CATE values must coincide with her individual PNS.
assert abs(cate_credit_alice - pns_alice_credit) < 1e-9, (
cate_credit_alice,
pns_alice_credit,
)
assert abs(cate_gender_alice - pns_alice_gender) < 1e-9, (
cate_gender_alice,
pns_alice_gender,
)
ATE(gender → loan) = 0.383
ATE(credit → loan) = 0.518
CATE(gender | credit=bad) = 0.045 (Alice and Bob both, since both have credit=bad)
CATE(credit | gender=female) = 0.180 (Alice's value)
CATE(credit | gender=male) = 0.855 (Bob's value)
Population ATE ranks credit (0.52) above gender (0.38), the same direction as population PN. Reasonable at the group level. The problem is that conditional on Alice’s gender, CATE for credit is 0.18 and CATE for gender is 0.045. These numbers are exactly Alice’s individual PNS values from §4. With binary outcomes and PS = 1 in the relevant branches, CATE for a single treatment collapses to PNS\(_c\). CATE inherits the blind spot: the intervention on credit propagates through
check_failed and overrides the contextual fact that Alice was never checked.
Another problem is that CATE conditions on observable covariates but cannot condition on the factual value of the treatment itself. So CATE(gender | credit=bad) = 0.045 is the same number for Alice and Bob, even though we would like gender to be strictly more responsible for Alice’s denial than Bob’s. Alice was denied because the bank refuses to check women; Bob was denied because his credit check failed. CATE conflates them because it integrates over the very check-noise distribution that
distinguishes them. PCI’s witness mechanism (§6 below) breaks the symmetry by pinning check_failed at its factual value: dominantly 0 for Alice (her unchecked branch, where credit is causally disconnected from the loan) and dominantly 1 for Bob (his checked-and-failed branch, where credit is the direct cause).
Individual Treatment Effect (ITE)
ATE and CATE both still average over the hidden check noise. The genuine unit-level quantity is the ITE: we first abduct (infer) the individual’s own exogenous noise from their full factual record, their covariates and the fact that they were denied, then ask the counterfactual “what if we flipped this one feature?”, holding that abducted noise fixed: the factual and counterfactual worlds share the same noise draw \(u\).
It is the most refined of the three treatment effects and the closest classical quantity to PCI, so it is the sharpest test of whether standard counterfactuals already solve the problem, or whether the witness mechanism is needed.
[11]:
# ── Individual Treatment Effect (ITE): the unit-level counterfactual ─────────
#
# ATE marginalises all noise; CATE conditions on the *other* observed covariate
# but still re-marginalises the exogenous noise. The genuine unit-level quantity
# is the ITE: abduct the individual's exogenous noise from their FULL factual
# record — covariates AND the observed outcome (loan=0, denied) — then take the
# counterfactual contrast holding that abducted noise fixed. This is the
# abduction–action–prediction reading already used for PN in §2: factual and
# counterfactual worlds share the same noise draw u.
#
# Two exogenous noises matter:
# U_check ~ Unif[0,1] → check = 1{U_check ≤ p_check[gender]}
# U_loan ~ Unif[0,1] → loan_if_checked = 1{U_loan ≤ loan_prob[(gender,cf)]}
# We discretise U_check into the three regions induced by the two thresholds
# (0.2 for female, 0.9 for male) and handle U_loan analytically.
ite_regions = [
(0.2, "U_check ≤ 0.2"), # both genders checked
(0.7, "0.2 < U_check ≤ 0.9"), # only male checked
(0.1, "U_check > 0.9"), # neither checked
]
def _checked(gender, region_idx):
"""Was the applicant check-evaluated in this U_check region?"""
# female (0): checked only in region 0; male (1): checked in regions 0 and 1
return region_idx == 0 if gender == 0 else region_idx in (0, 1)
def ite(factual_gender, factual_credit, target):
"""E[loan=1 | do(flip target), abducted noise] for a *denied* individual.
The factual outcome is loan=0, so this counterfactual probability is exactly
the unit-level treatment effect on approval: how much flipping ``target``
(gender or credit), with the individual's own noise held fixed, would have
raised the chance of approval.
"""
# ── Abduction: P(region | gender, credit, denied) ∝ prior · P(denied|region) ──
weights = []
for idx, (prior, _) in enumerate(ite_regions):
if _checked(factual_gender, idx):
p_denied = 1 - loan_prob[(factual_gender, 1 - factual_credit)]
else:
p_denied = 1.0 # unchecked ⇒ denied with certainty
weights.append(prior * p_denied)
Z = sum(weights)
posterior = [w / Z for w in weights]
# ── Counterfactual: flip target, hold U_check (region) and U_loan fixed ──────
cf_gender = 1 - factual_gender if target == "gender" else factual_gender
cf_credit = 1 - factual_credit if target == "credit" else factual_credit
effect = 0.0
for idx, (prior, _) in enumerate(ite_regions):
if not _checked(cf_gender, idx):
continue # unchecked under the flip ⇒ loan = 0
q = loan_prob[(cf_gender, 1 - cf_credit)] # cf approval prob given checked
if _checked(factual_gender, idx):
# U_loan abducted from the factual checked-and-denied branch: it lies
# above the factual approval threshold, so it flips to approval only on
# the extra probability mass the counterfactual opens up.
p_app = loan_prob[(factual_gender, 1 - factual_credit)]
p_flip = 0.0 if (q <= p_app or p_app >= 1) else (q - p_app) / (1 - p_app)
else:
# factually unchecked ⇒ U_loan was never used ⇒ a fresh Unif draw
p_flip = q
effect += posterior[idx] * p_flip
return effect
# ── ITE for Alice (female, bad) and Bob (male, bad), both denied ─────────────
ite_gender_alice = ite(0, 0, "gender")
ite_credit_alice = ite(0, 0, "credit")
ite_gender_bob = ite(1, 0, "gender")
ite_credit_bob = ite(1, 0, "credit")
print("Individual Treatment Effect (unit-level counterfactual on approval):")
print(
f" ITE(gender | Alice) = {ite_gender_alice:.4f} ITE(credit | Alice) = {ite_credit_alice:.4f}"
)
print(
f" ITE(gender | Bob) = {ite_gender_bob:.4f} ITE(credit | Bob) = {ite_credit_bob:.4f}"
)
print()
# ── The ATE → CATE → ITE ladder, side by side ────────────────────────────────
ladder = pd.DataFrame(
[
("Alice", "gender", ate_gender, cate_gender_alice, ite_gender_alice),
("Alice", "credit", ate_credit, cate_credit_alice, ite_credit_alice),
("Bob", "gender", ate_gender, cate_gender_bob, ite_gender_bob),
("Bob", "credit", ate_credit, cate_credit_bob, ite_credit_bob),
],
columns=["Person", "Feature", "ATE", "CATE", "ITE"],
)
print(ladder.round(4).to_string(index=False))
print()
# ── What the numbers show ────────────────────────────────────────────────────
print("D-comp on gender — does the method separate Alice from Bob?")
print(
f" CATE: {cate_gender_alice:.3f} vs {cate_gender_bob:.3f} -> "
f"{'SEPARATES' if abs(cate_gender_alice - cate_gender_bob) > 1e-6 else 'cannot separate'}"
)
print(
f" ITE: {ite_gender_alice:.3f} vs {ite_gender_bob:.3f} -> "
f"{'SEPARATES (Alice > Bob)' if ite_gender_alice > ite_gender_bob else 'cannot separate'}"
)
print()
print("D-A-rank for Alice — does the method rank gender above credit?")
print(
f" ITE: gender {ite_gender_alice:.3f} {'>' if ite_gender_alice > ite_credit_alice else '<'} "
f"credit {ite_credit_alice:.3f} -> "
f"{'OK' if ite_gender_alice > ite_credit_alice else 'FAILS (same blind spot as PNS/CATE)'}"
)
# Sanity checks against the values derived by hand / Monte Carlo.
assert abs(ite_gender_alice - 0.045) < 1e-9, ite_gender_alice
assert abs(ite_credit_alice - 0.180) < 1e-9, ite_credit_alice
assert abs(ite_gender_bob - 0.000) < 1e-9, ite_gender_bob
assert abs(ite_credit_bob - 0.855 / 0.955) < 1e-9, ite_credit_bob
# ITE for a denied individual is a genuine counterfactual (abducts the OUTCOME),
# so it parts ways with the *interventional* individual-PN of the earlier cell:
# Bob's credit ITE is 0.895, not the population conditional P(loan=1|male,good)=0.90,
# because conditioning on his denial shifts mass toward the unchecked branch.
assert abs(ite_credit_bob - pn_bob_credit) > 1e-3, (ite_credit_bob, pn_bob_credit)
print(
f"\nAbduction-on-outcome correction: ITE(credit|Bob)={ite_credit_bob:.4f} "
f"vs interventional PN={pn_bob_credit:.4f}"
)
Individual Treatment Effect (unit-level counterfactual on approval):
ITE(gender | Alice) = 0.0450 ITE(credit | Alice) = 0.1800
ITE(gender | Bob) = 0.0000 ITE(credit | Bob) = 0.8953
Person Feature ATE CATE ITE
Alice gender 0.3825 0.045 0.0450
Alice credit 0.5175 0.180 0.1800
Bob gender 0.3825 0.045 0.0000
Bob credit 0.5175 0.855 0.8953
D-comp on gender — does the method separate Alice from Bob?
CATE: 0.045 vs 0.045 -> cannot separate
ITE: 0.045 vs 0.000 -> SEPARATES (Alice > Bob)
D-A-rank for Alice — does the method rank gender above credit?
ITE: gender 0.045 < credit 0.180 -> FAILS (same blind spot as PNS/CATE)
Abduction-on-outcome correction: ITE(credit|Bob)=0.8953 vs interventional PN=0.9000
ATE → CATE → ITE (effect on approval probability; each step conditions on more of the individual’s situation):
Person |
Feature |
ATE |
CATE |
ITE |
|---|---|---|---|---|
Alice |
gender |
0.383 |
0.045 |
0.045 |
Alice |
credit |
0.518 |
0.180 |
0.180 |
Bob |
gender |
0.383 |
0.045 |
0.000 |
Bob |
credit |
0.518 |
0.855 |
0.895 |
Refining ATE → CATE → ITE makes the estimate progressively more individual, and ITE finally recovers the cross-applicant asymmetry D-comp that ATE and CATE both miss. None of the three corrects Alice’s wrong gender-vs-credit ranking (D-A-rank), which needs PCI’s witness mechanism (§6). The rest of this section works through each step.
The ITE is the natural endpoint of the refinement ladder: ATE marginalises everything, CATE conditions on the other covariate, and ITE abducts the individual’s exogenous noise from their full record, including the observed denial, then asks the counterfactual question on that pinned noise.
ITE fixes what CATE could not (D-comp). CATE(gender | credit=bad) = 0.045 is identical for Alice and Bob, so CATE cannot say gender mattered more to one than the other. ITE can: ITE(gender | Alice) = 0.045 but ITE(gender | Bob) = 0. Abduction places Bob in the checked-and-failed branch, where being male did not block him: flipping him to female (still bad credit, still denied) changes nothing. So ITE recovers the cross-individual asymmetry D-comp demands.
ITE does not fix D-A-rank. For Alice, ITE(credit) = 0.18 > ITE(gender) = 0.045, the same wrong ranking as PNS and CATE. Abducting U_check still leaves a 20% branch in which Alice was checked, and in that branch good credit would have flipped the outcome, so credit keeps outranking gender. ITE abducts the noise but not the mechanism. Recovering the correct D-A-rank ordering needs PCI’s witness mechanism, which pins check_failed at its factual value (dominantly 0 for Alice, her
unchecked branch where credit is causally disconnected) rather than merely integrating over its noise.
Because ITE conditions on the outcome, ITE(credit | Bob) = 0.895, not the interventional P(loan=1 | do(credit=good), male) = 0.90 reported as Bob’s individual PN earlier: conditioning on his denial shifts posterior mass slightly toward the unchecked branch. This is the abduction-on-outcome correction that separates a true unit-level counterfactual from a do-query.
ITE is the right kind of object, individual and counterfactual and noise-abducted, and it strictly improves on CATE, yet it inherits PNS’s blind spot on the mediated path. PCI is ITE plus the witness machinery needed to see that path.
6. PCI (Probabilistic Causal Impact)
PCI fixes PNS’s blind spot via the witness mechanism: we hold a set of intermediate variables fixed at their factual values during the counterfactual evaluation, integrating against a distribution \(\Gamma\) over which witness sets to use. For OBCB the natural witness is check_failed: pinning it at its factual value lets us distinguish “Alice’s check never happened” from “Alice’s credit was bad”.
Suspects (the candidate causes): \(\{g\}\), \(\{c\}\), or \(\{g, c\}\), with \(\Gamma_s\) uniform over these three.
Witnesses: \(\emptyset\) or \(\{\text{check\_failed}\}\), with \(\Gamma_w\) uniform over the two.
Alternative values: point mass on the opposite binary value.
Outcome contrast: PNS-style binary, \(y^\star = \text{loan} = 0\).
Since the suspect and witness pools are disjoint, the joint \(\Gamma\) is just the product of \(\Gamma_s\) and \(\Gamma_w\) (no rejection sampling is needed, normaliser \(Z = 1\)).
The check noise splits into three regions. We label the distinct outcomes of the check exogenous noise \(U_{\text{check}}\) (think of drawing \(U \in [0,1]\) uniformly, with check(female) = 1 iff \(U \leq 0.2\) and check(male) = 1 iff \(U \leq 0.9\)):
Region |
Probability |
|
|
|---|---|---|---|
\(u_1\): \(U \leq 0.2\) |
0.2 |
1 |
1 |
\(u_2\): \(0.2 < U \leq 0.9\) |
0.7 |
0 |
1 |
\(u_3\): \(U > 0.9\) |
0.1 |
0 |
0 |
Region \(u_2\) decides Alice’s case. She is unchecked in her factual branch but would have been checked as a male. The witness on check_failed blocks credit’s causal path in \(u_1\) (where Alice was checked-and-failed; pinning check_failed to that factual value would lock credit out of the counterfactual evaluation).
[12]:
# ── U_check regions ──────────────────────────────────────────────────────────
# Each region is (probability, {gender: check_value}).
# These three regions cover all distinct (check_female, check_male) combinations
# given the two thresholds p_check[0]=0.2 and p_check[1]=0.9.
u_regions = {
"u1": (0.2, {0: 1, 1: 1}), # U_check ≤ 0.2: both genders checked
"u2": (0.7, {0: 0, 1: 1}), # 0.2 < U_check ≤ 0.9: only male checked
"u3": (0.1, {0: 0, 1: 0}), # U_check > 0.9: neither checked
}
def p_loan_do(gender_int, credit_int, check_val, witness_cf=None):
"""
P(loan=1) under do(gender=gender_int, credit=credit_int),
with optional witness do(check_failed=witness_cf).
check_val is the realised check for gender_int in the current u-region.
If witness_cf is None, check_failed is computed structurally.
"""
if check_val == 0:
return 0.0
cf = witness_cf if witness_cf is not None else check_val * (1 - credit_int)
return loan_prob[(gender_int, cf)]
def compute_pci(factual_gender, factual_credit, use_witnesses=True):
"""
Compute E[ci_gender] and E[ci_credit] for an individual with the given
factual context, using the PNS binary ci function (y★ = loan = 0).
Γ_s: uniform over {{gender}, {credit}, {gender,credit}} (1/3 each)
Γ_w: uniform over {∅, {check_failed}} (1/2 each, or 1 if no witnesses)
Δ: point mass on the only other binary value
"""
alt_gender = 1 - factual_gender
alt_credit = 1 - factual_credit
# For each suspect subset, store (alt_g, alt_c) — the alternative values
# used in the necessity intervention.
subsets = {
"gender": (alt_gender, factual_credit), # only gender changes
"credit": (factual_gender, alt_credit), # only credit changes
"both": (alt_gender, alt_credit), # both change
}
p_s = 1 / 3
witness_options = [None, "check_failed"] if use_witnesses else [None]
p_w = 1 / len(witness_options)
results = {}
for target in ["gender", "credit"]:
# 2^S_k: subsets of S that contain the target variable
relevant = ["gender", "both"] if target == "gender" else ["credit", "both"]
total = 0.0
for region_prob, check_by_gender in u_regions.values():
factual_check = check_by_gender[factual_gender]
factual_cf = factual_check * (1 - factual_credit)
P_s_meas = 0.0 # sufficiency measure on {loan=0}
P_n_meas = 0.0 # necessity measure on {loan=1}
for subset in relevant:
alt_g, alt_c = subsets[subset]
for witness in witness_options:
w_val = factual_cf if witness == "check_failed" else None
# ── Sufficiency: do(C = factual values, T = factual) ──────
suf_check = check_by_gender[factual_gender]
p_suf = 1.0 - p_loan_do(
factual_gender, factual_credit, suf_check, w_val
)
P_s_meas += p_s * p_w * p_suf
# ── Necessity: do(C = alternative values, T = factual) ────
# If C contains gender, the counterfactual gender is alt_g,
# which changes the check value via the structural equation.
nec_gender_for_check = (
alt_g if subset in ("gender", "both") else factual_gender
)
nec_check = check_by_gender[nec_gender_for_check]
p_nec = p_loan_do(alt_g, alt_c, nec_check, w_val)
P_n_meas += p_s * p_w * p_nec
total += region_prob * P_s_meas * P_n_meas
results[target] = total
return results["gender"], results["credit"]
[13]:
# ── Compute PCI for all four cases ───────────────────────────────────────────
alice_g_w, alice_c_w = compute_pci(
factual_gender=0, factual_credit=0, use_witnesses=True
)
alice_g_nw, alice_c_nw = compute_pci(
factual_gender=0, factual_credit=0, use_witnesses=False
)
bob_g_w, bob_c_w = compute_pci(factual_gender=1, factual_credit=0, use_witnesses=True)
bob_g_nw, bob_c_nw = compute_pci(
factual_gender=1, factual_credit=0, use_witnesses=False
)
# PNS individual values (from earlier cells)
pns_alice_gender = pns_alice_gender # 0.045
pns_alice_credit = pns_alice_credit # 0.18
pns_bob_gender = pns_bob_gender # 0.0
pns_bob_credit = pns_bob_credit # 0.8595
rows: list[tuple[str, ...]] = [
(
"Alice",
"gender",
f"{pns_alice_gender:.4f}",
f"{alice_g_nw:.4f}",
f"{alice_g_w:.4f}",
),
(
"Alice",
"credit",
f"{pns_alice_credit:.4f}",
f"{alice_c_nw:.4f}",
f"{alice_c_w:.4f}",
),
("Bob", "gender", f"{pns_bob_gender:.4f}", f"{bob_g_nw:.4f}", f"{bob_g_w:.4f}"),
("Bob", "credit", f"{pns_bob_credit:.4f}", f"{bob_c_nw:.4f}", f"{bob_c_w:.4f}"),
]
df = pd.DataFrame(
rows,
columns=["Person", "Feature", "PNS", "PCI (no witnesses)", "PCI (with witnesses)"],
)
print(df.to_string(index=False))
print()
# Ranking judgements
print("Rankings (gender vs credit):")
print(
f" PNS — Alice: gender {'>' if pns_alice_gender > pns_alice_credit else '<'} credit (correct: gender > credit) {'✓' if pns_alice_gender > pns_alice_credit else '✗'}"
)
print(
f" PNS — Bob: gender {'>' if pns_bob_gender > pns_bob_credit else '<'} credit (correct: credit > gender) {'✓' if pns_bob_gender < pns_bob_credit else '✗'}"
)
print(
f" PCI w/o witnesses — Alice: gender {'>' if alice_g_nw > alice_c_nw else '<'} credit {'✓' if alice_g_nw > alice_c_nw else '✗'}"
)
print(
f" PCI w/o witnesses — Bob: gender {'>' if bob_g_nw > bob_c_nw else '<'} credit {'✓' if bob_g_nw < bob_c_nw else '✗'}"
)
print(
f" PCI with witnesses — Alice: gender {'>' if alice_g_w > alice_c_w else '<'} credit {'✓' if alice_g_w > alice_c_w else '✗'}"
)
print(
f" PCI with witnesses — Bob: gender {'>' if bob_g_w > bob_c_w else '<'} credit {'✓' if bob_g_w < bob_c_w else '✗'}"
)
Person Feature PNS PCI (no witnesses) PCI (with witnesses)
Alice gender 0.0450 0.2100 0.2628
Alice credit 0.1800 0.2400 0.1989
Bob gender 0.0000 0.0380 0.0190
Bob credit 0.8595 0.2280 0.1187
Rankings (gender vs credit):
PNS — Alice: gender < credit (correct: gender > credit) ✗
PNS — Bob: gender < credit (correct: credit > gender) ✓
PCI w/o witnesses — Alice: gender < credit ✗
PCI w/o witnesses — Bob: gender < credit ✓
PCI with witnesses — Alice: gender > credit ✓
PCI with witnesses — Bob: gender < credit ✓
The PCI verdict (individual level; suspect = the named feature), next to PNS:
Person |
Feature |
PNS |
PCI (no witnesses) |
PCI (with witnesses) |
|---|---|---|---|---|
Alice |
gender |
0.045 |
0.210 |
0.263 |
Alice |
credit |
0.180 |
0.240 |
0.199 |
Bob |
gender |
0.000 |
0.038 |
0.019 |
Bob |
credit |
0.859 |
0.228 |
0.119 |
Compare the two PCI columns against PNS:
Without witnesses, PCI tracks PNS closely and still gets Alice’s ranking wrong (gender 0.210 < credit 0.240). With no mediator pinned, intervening on credit again leaks through
check_failed.With witnesses, the ordering flips for Alice: gender 0.263 > credit 0.199. Holding
check_failedat its factual value (0, since her check never happened) keeps credit causally disconnected from her loan, so the gender intervention is the one that actually moves the outcome. This is the correct ranking, and the only method so far to produce it.
For Bob the witness version still correctly ranks credit above gender, so turning witnesses on fixes Alice without breaking Bob. The next cell makes the no-witness/PNS correspondence precise.
PCI against individual-level PNS
With a single-suspect, no-witness configuration, PCI essentially computes Pearl’s individual-level PNS, \(P(Y_{c'} = 1,\, Y_{c^\star} = 0)\), integrated against the exogenous noise distribution.
There’s a subtle wrinkle for Bob’s credit. PNS, written in factorised form as \(\mathbb{E}_u[P^s] \cdot \mathbb{E}_u[P^n]\), overshoots the true individual-level PNS by a small covariance term, because \(P^s\) and \(P^n\) are negatively correlated through \(U_{\text{check}}\) in Bob’s case. PCI computes \(\mathbb{E}_u[P^s \cdot P^n]\), the correct integrated form, so its no-witness value is slightly lower than the factorised PNS (≈ 0.005 off).
[14]:
def compute_pci_single_suspect(factual_gender, factual_credit, target):
"""
PCI with S = {target}, W = ∅ — the equivalence-claim configuration.
Single (C, T) configuration → no Γ averaging. Reduces to:
∫ P(Y=0 | u, do(target=factual), other=factual)
× P(Y=1 | u, do(target=alt), other=factual) dP_U(u),
integrated over u_check regions.
"""
if target == "gender":
suff_gender, nec_gender = factual_gender, 1 - factual_gender
suff_credit, nec_credit = factual_credit, factual_credit
elif target == "credit":
suff_gender, nec_gender = factual_gender, factual_gender
suff_credit, nec_credit = factual_credit, 1 - factual_credit
else:
raise ValueError(f"unknown target: {target}")
total = 0.0
for region_prob, check_by_gender in u_regions.values():
# Sufficiency world
suff_check = check_by_gender[suff_gender]
if suff_check == 0:
P_suf_y0 = 1.0
else:
cf = suff_check * (1 - suff_credit)
P_suf_y0 = 1.0 - loan_prob[(suff_gender, cf)]
# Necessity world
nec_check = check_by_gender[nec_gender]
if nec_check == 0:
P_nec_y1 = 0.0
else:
cf = nec_check * (1 - nec_credit)
P_nec_y1 = loan_prob[(nec_gender, cf)]
total += region_prob * P_suf_y0 * P_nec_y1
return total
# ── Compute single-suspect PCI for all four cases ────────────────────────────
cases = [
("Alice", 0, 0, "gender", pns_alice_gender),
("Alice", 0, 0, "credit", pns_alice_credit),
("Bob", 1, 0, "gender", pns_bob_gender),
("Bob", 1, 0, "credit", pns_bob_credit),
]
print(
f"{'Person':<6} {'Target':<7} {'PCI(S={X_k}, W=∅)':<20} {'pns_* (notebook)':<18} {'match?'}"
)
print("-" * 72)
for person, fg, fc, target, pns_value in cases:
pci_val = compute_pci_single_suspect(fg, fc, target)
diff = pci_val - pns_value
if abs(diff) < 1e-10:
status = "✓ exact"
else:
status = f"differ by {diff:+.6f}"
print(f"{person:<6} {target:<7} {pci_val:<20.6f} {pns_value:<18.6f} {status}")
Person Target PCI(S={X_k}, W=∅) pns_* (notebook) match?
------------------------------------------------------------------------
Alice gender 0.045000 0.045000 ✓ exact
Alice credit 0.180000 0.180000 ✓ exact
Bob gender 0.000000 0.000000 ✓ exact
Bob credit 0.855000 0.859500 differ by -0.004500
7. SHAP / Causal SHAP, plus the six-method comparison
We now compare against SHAP and Causal SHAP, the most familiar feature-attribution baselines outside the causal-inference literature. To keep things clean we explain the rejection probability \(g(x) = P(\text{loan} = 0 \mid x) = 1 - f(x)\), so positive \(\phi_i\) means feature \(i\) pushes towards rejection.
With \(|N| = 2\) and uniform marginals, the Shapley value collapses to:
where \(v(S) = \mathbb{E}_{X_{\bar S}}[f(x_S, X_{\bar S})]\).
that is: \(v(S)\) is the model’s expected rejection probability when the features in \(S\) are pinned at the applicant’s factual values and the rest are averaged out; \(\phi_i\) is then feature \(i\)’s average marginal contribution to that probability: the amount it adds on joining the coalition, averaged over the two orders in which the two features can enter (first, against \(\emptyset\), or second, completing the full set \(N\)).
[15]:
def shap_rejection(factual_gender, factual_credit):
"""
Exact SHAP values for g(x) = P(loan=0 | x), the rejection probability.
With |N|=2 and uniform P(gender)=P(credit)=0.5 the formula is:
phi_i = 0.5*(v({i}) - v({})) + 0.5*(v(N) - v(N\{i}))
where v(S) = E_{X_bar_S}[f(x_S, X_bar_S)] for f = P(loan=1|.).
Flip sign at the end for g = 1-f.
"""
g, c = factual_gender, factual_credit
# ── characteristic function for f = P(loan=1) ────────────────────────────
v_empty = 0.25 * sum(p_loan(gi, ci) for gi in [0, 1] for ci in [0, 1])
v_gender = 0.5 * p_loan(g, 0) + 0.5 * p_loan(g, 1) # marginalise credit
v_credit = 0.5 * p_loan(0, c) + 0.5 * p_loan(1, c) # marginalise gender
v_both = p_loan(g, c)
phi_f_gender = 0.5 * (v_gender - v_empty) + 0.5 * (v_both - v_credit)
phi_f_credit = 0.5 * (v_credit - v_empty) + 0.5 * (v_both - v_gender)
# Flip for g = 1-f
phi_g_gender = -phi_f_gender
phi_g_credit = -phi_f_credit
return phi_g_gender, phi_g_credit, v_empty, v_gender, v_credit, v_both
# ── Alice (gender=F=0, credit=bad=0) ─────────────────────────────────────────
a_phi_g, a_phi_c, v0, vg_a, vc, vb_a = shap_rejection(0, 0)
print("Alice (gender=F, credit=bad)")
print(f" v({{}}) = {v0:.5f} (baseline: E[P(loan=1)])")
print(f" v({{gender=F}}) = {vg_a:.5f}")
print(f" v({{credit=bad}})= {vc:.5f}")
print(f" v(N) = {vb_a:.5f}")
print(f" phi_g (approval) = {-a_phi_g:.5f} -> phi_g (rejection) = {a_phi_g:.5f}")
print(f" phi_c (approval) = {-a_phi_c:.5f} -> phi_c (rejection) = {a_phi_c:.5f}")
print(f" check efficiency: {a_phi_g + a_phi_c:.5f} == {(1 - vb_a) - (1 - v0):.5f}")
print()
# ── Bob (gender=M=1, credit=bad=0) ───────────────────────────────────────────
b_phi_g, b_phi_c, _, vg_b, _, vb_b = shap_rejection(1, 0)
print("Bob (gender=M, credit=bad)")
print(f" v({{}}) = {v0:.5f} (same baseline)")
print(f" v({{gender=M}}) = {vg_b:.5f}")
print(f" v({{credit=bad}}) = {vc:.5f}")
print(f" v(N) = {vb_b:.5f}")
print(f" phi_g (approval) = {-b_phi_g:.5f} -> phi_g (rejection) = {b_phi_g:.5f}")
print(f" phi_c (approval) = {-b_phi_c:.5f} -> phi_c (rejection) = {b_phi_c:.5f}")
print(f" check efficiency: {b_phi_g + b_phi_c:.5f} == {(1 - vb_b) - (1 - v0):.5f}")
Alice (gender=F, credit=bad)
v({}) = 0.28125 (baseline: E[P(loan=1)])
v({gender=F}) = 0.09000
v({credit=bad})= 0.02250
v(N) = 0.00000
phi_g (approval) = -0.10687 -> phi_g (rejection) = 0.10687
phi_c (approval) = -0.17438 -> phi_c (rejection) = 0.17438
check efficiency: 0.28125 == 0.28125
Bob (gender=M, credit=bad)
v({}) = 0.28125 (same baseline)
v({gender=M}) = 0.47250
v({credit=bad}) = 0.02250
v(N) = 0.04500
phi_g (approval) = 0.10688 -> phi_g (rejection) = -0.10688
phi_c (approval) = -0.34313 -> phi_c (rejection) = 0.34313
check efficiency: 0.23625 == 0.23625
<>:2: SyntaxWarning: invalid escape sequence '\{'
<>:2: SyntaxWarning: invalid escape sequence '\{'
/tmp/ipykernel_61872/2708298125.py:2: SyntaxWarning: invalid escape sequence '\{'
"""
[16]:
# ── Six-method comparison table ──────────────────────────────────────────────
# Columns: PNS, SHAP, PCI (no witnesses), PCI (with witnesses), ATE, CATE.
# ATE is a population quantity, so the same value appears for both individuals
# in each feature row. CATE conditions on the *other* covariate (which is
# credit=bad for the gender row and the individual's gender for the credit row).
rows = [
(
"Alice",
"gender",
f"{pns_alice_gender:.3f}",
f"{a_phi_g:.3f}",
f"{alice_g_nw:.3f}",
f"{alice_g_w:.3f}",
f"{ate_gender:.3f}",
f"{cate_gender_alice:.3f}",
),
(
"Alice",
"credit",
f"{pns_alice_credit:.3f}",
f"{a_phi_c:.3f}",
f"{alice_c_nw:.3f}",
f"{alice_c_w:.3f}",
f"{ate_credit:.3f}",
f"{cate_credit_alice:.3f}",
),
(
"Bob",
"gender",
f"{pns_bob_gender:.3f}",
f"{b_phi_g:.3f}",
f"{bob_g_nw:.3f}",
f"{bob_g_w:.3f}",
f"{ate_gender:.3f}",
f"{cate_gender_bob:.3f}",
),
(
"Bob",
"credit",
f"{pns_bob_credit:.3f}",
f"{b_phi_c:.3f}",
f"{bob_c_nw:.3f}",
f"{bob_c_w:.3f}",
f"{ate_credit:.3f}",
f"{cate_credit_bob:.3f}",
),
]
df4 = pd.DataFrame(
rows,
columns=[
"Person",
"Feature",
"PNS",
"SHAP",
"PCI (no wit.)",
"PCI (wit.)",
"ATE",
"CATE",
],
)
print(df4.to_string(index=False))
# ── Desiderata check (desiderata use |R(·)| magnitudes; D-B1 is strict > 0) ──
print()
checks = [
(
"D-A1 |R(gender)|>0 Alice",
pns_alice_gender > 0,
abs(a_phi_g) > 0,
alice_g_nw > 0,
alice_g_w > 0,
ate_gender > 0,
cate_gender_alice > 0,
),
(
"D-A2 |R(credit)|>0 Alice",
pns_alice_credit > 0,
abs(a_phi_c) > 0,
alice_c_nw > 0,
alice_c_w > 0,
ate_credit > 0,
cate_credit_alice > 0,
),
(
"D-A-rank |gender|>|credit| Alice",
pns_alice_gender > pns_alice_credit,
abs(a_phi_g) > abs(a_phi_c),
alice_g_nw > alice_c_nw,
alice_g_w > alice_c_w,
ate_gender > ate_credit,
cate_gender_alice > cate_credit_alice,
),
(
"D-B1 |R(gender)|>0 Bob (strict)",
pns_bob_gender > 0,
abs(b_phi_g) > 0,
bob_g_nw > 0,
bob_g_w > 0,
ate_gender > 0,
cate_gender_bob > 0,
),
(
"D-B2 |R(credit)|>0 Bob",
pns_bob_credit > 0,
abs(b_phi_c) > 0,
bob_c_nw > 0,
bob_c_w > 0,
ate_credit > 0,
cate_credit_bob > 0,
),
(
"D-B-rank |credit|>|gender| Bob",
pns_bob_credit > pns_bob_gender,
abs(b_phi_c) > abs(b_phi_g),
bob_c_nw > bob_g_nw,
bob_c_w > bob_g_w,
ate_credit > ate_gender,
cate_credit_bob > cate_gender_bob,
),
(
"D-comp |gender Alice|>|gender Bob|",
pns_alice_gender > pns_bob_gender,
abs(a_phi_g) > abs(b_phi_g),
alice_g_nw > bob_g_nw,
alice_g_w > bob_g_w,
ate_gender > ate_gender, # population ATE: equal → fails
cate_gender_alice > cate_gender_bob,
), # same conditioning → fails
]
header = (
f"{'Desideratum':<38} {'PNS':>4} {'SHAP':>5} "
f"{'PCI-nw':>7} {'PCI-w':>6} {'ATE':>5} {'CATE':>5}"
)
print(header)
print("-" * len(header))
tick = lambda b: "✓" if b else "✗"
for label, pns_ok, shap_ok, pci_nw_ok, pci_w_ok, ate_ok, cate_ok in checks:
print(
f"{label:<38} {tick(pns_ok):>4} {tick(shap_ok):>5} "
f"{tick(pci_nw_ok):>7} {tick(pci_w_ok):>6} "
f"{tick(ate_ok):>5} {tick(cate_ok):>5}"
)
Person Feature PNS SHAP PCI (no wit.) PCI (wit.) ATE CATE
Alice gender 0.045 0.107 0.210 0.263 0.383 0.045
Alice credit 0.180 0.174 0.240 0.199 0.518 0.180
Bob gender 0.000 -0.107 0.038 0.019 0.383 0.045
Bob credit 0.859 0.343 0.228 0.119 0.518 0.855
Desideratum PNS SHAP PCI-nw PCI-w ATE CATE
----------------------------------------------------------------------------
D-A1 |R(gender)|>0 Alice ✓ ✓ ✓ ✓ ✓ ✓
D-A2 |R(credit)|>0 Alice ✓ ✓ ✓ ✓ ✓ ✓
D-A-rank |gender|>|credit| Alice ✗ ✗ ✗ ✓ ✗ ✗
D-B1 |R(gender)|>0 Bob (strict) ✗ ✓ ✓ ✓ ✓ ✓
D-B2 |R(credit)|>0 Bob ✓ ✓ ✓ ✓ ✓ ✓
D-B-rank |credit|>|gender| Bob ✓ ✓ ✓ ✓ ✓ ✓
D-comp |gender Alice|>|gender Bob| ✓ ✗ ✓ ✓ ✗ ✗
The six-method comparison and desiderata audit
The six-method comparison (rejection-side magnitudes; SHAP keeps its sign):
Person |
Feature |
PNS |
SHAP |
PCI (no wit.) |
PCI (wit.) |
ATE |
CATE |
|---|---|---|---|---|---|---|---|
Alice |
gender |
0.045 |
0.107 |
0.210 |
0.263 |
0.383 |
0.045 |
Alice |
credit |
0.180 |
0.174 |
0.240 |
0.199 |
0.518 |
0.180 |
Bob |
gender |
0.000 |
−0.107 |
0.038 |
0.019 |
0.383 |
0.045 |
Bob |
credit |
0.859 |
0.343 |
0.228 |
0.119 |
0.518 |
0.855 |
Desiderata audit (✓ = satisfied, ✗ = violated):
Desideratum |
PNS |
SHAP |
PCI-nw |
PCI-w |
ATE |
CATE |
|---|---|---|---|---|---|---|
D-A1 |gender|>0 (Alice) |
✓ |
✓ |
✓ |
✓ |
✓ |
✓ |
D-A2 |credit|>0 (Alice) |
✓ |
✓ |
✓ |
✓ |
✓ |
✓ |
D-A-rank |gender|>|credit| (Alice) |
✗ |
✗ |
✗ |
✓ |
✗ |
✗ |
D-B1 |gender|>0 (Bob, strict) |
✗ |
✓ |
✓ |
✓ |
✓ |
✓ |
D-B2 |credit|>0 (Bob) |
✓ |
✓ |
✓ |
✓ |
✓ |
✓ |
D-B-rank |credit|>|gender| (Bob) |
✓ |
✓ |
✓ |
✓ |
✓ |
✓ |
D-comp |gender|Alice>|gender|Bob |
✓ |
✗ |
✓ |
✓ |
✗ |
✗ |
The first table puts all six methods side by side; the second turns the §3 desiderata into a pass/fail audit (✓ = the method’s numbers satisfy the condition, ✗ = they violate it).
One row decides the comparison: Alice / gender against Alice / credit. The correct attribution says gender should outrank credit (she was rejected for being a woman, not for a credit check that never ran). Only PCI with witnesses gets this: gender 0.263 > credit 0.199. PNS, plain/Causal SHAP, ATE, CATE and even PCI-without-witnesses all put credit on top. That is the D-A-rank failure, an overdetermination failure: gender and credit each look partly responsible for a rejection the structure decided upstream.
Two more rows are worth calling out:
D-comp asks that gender be more responsible for Alice’s denial than for Bob’s. SHAP, ATE and CATE fail it: their gender attribution is symmetric across the two applicants (ATE and CATE return literally the same 0.045 for both). PNS and both PCI variants pass.
D-B1 asks for strictly positive gender attribution for Bob. PNS fails (it returns exactly 0), while SHAP and PCI register the small but non-zero effect of Bob’s gender.
PCI-with-witnesses is the only column with a ✓ in every row. That is the empirical claim §4 of the paper makes, reproduced here exactly.
Causal SHAP: same numbers in the 2-feature game
Because gender and credit are independent exogenous variables in this model, \(\mathrm{do}(\text{gender} = g)\) leaves \(P(\text{credit})\) unchanged and vice versa. The causal value function therefore equals the plain value function for every coalition, and causal SHAP = plain SHAP on this example. (Heskes et al. note this generally: the two coincide whenever the features have no causal arrows between each other in the explanand model.)
[17]:
def causal_shap_rejection(factual_gender, factual_credit):
"""
Causal SHAP (Heskes et al. 2020) for g(x) = P(loan=0 | x).
v_causal(S) = E[f(X) | do(X_S = x_S)]
For OBCB: gender and credit are independent exogenous variables.
do(gender=g) does not change P(credit), and vice versa.
Therefore v_causal(S) = v_plain(S) for all S.
"""
g, c = factual_gender, factual_credit
# ── Causal characteristic function ────────────────────────────────────────
# v_causal({}) = E[f(X)] — baseline, same as plain SHAP
v_empty = 0.25 * sum(p_loan(gi, ci) for gi in [0, 1] for ci in [0, 1])
# v_causal({gender=g}): do(gender=g), credit unaffected (independent)
# P(credit | do(gender=g)) = P(credit) → same as plain SHAP
v_gender_causal = 0.5 * p_loan(g, 0) + 0.5 * p_loan(g, 1)
# v_causal({credit=c}): do(credit=c), gender unaffected (independent)
# P(gender | do(credit=c)) = P(gender) → same as plain SHAP
v_credit_causal = 0.5 * p_loan(0, c) + 0.5 * p_loan(1, c)
# v_causal({gender=g, credit=c}) = f(g, c) — same as plain SHAP
v_both = p_loan(g, c)
phi_f_gender = 0.5 * (v_gender_causal - v_empty) + 0.5 * (v_both - v_credit_causal)
phi_f_credit = 0.5 * (v_credit_causal - v_empty) + 0.5 * (v_both - v_gender_causal)
phi_g_gender = -phi_f_gender # flip for rejection
phi_g_credit = -phi_f_credit
return phi_g_gender, phi_g_credit, v_empty, v_gender_causal, v_credit_causal, v_both
# ── Compute and compare ───────────────────────────────────────────────────────
a_cg, a_cc, *_ = causal_shap_rejection(0, 0)
b_cg, b_cc, *_ = causal_shap_rejection(1, 0)
print("Causal SHAP vs plain SHAP for rejection P(loan=0|x):")
print(
f" Alice gender: plain={a_phi_g:.5f} causal={a_cg:.5f} equal={abs(a_phi_g - a_cg) < 1e-10}"
)
print(
f" Alice credit: plain={a_phi_c:.5f} causal={a_cc:.5f} equal={abs(a_phi_c - a_cc) < 1e-10}"
)
print(
f" Bob gender: plain={b_phi_g:.5f} causal={b_cg:.5f} equal={abs(b_phi_g - b_cg) < 1e-10}"
)
print(
f" Bob credit: plain={b_phi_c:.5f} causal={b_cc:.5f} equal={abs(b_phi_c - b_cc) < 1e-10}"
)
print()
print("Desiderata (|·| magnitudes, D-B1 strict > 0):")
checks_causal = [
("D-A1 |R(gender)|>0 Alice", abs(a_cg) > 0),
("D-A2 |R(credit)|>0 Alice", abs(a_cc) > 0),
("D-A-rank |gender|>|credit| Alice", abs(a_cg) > abs(a_cc)),
("D-B1 |R(gender)|>0 Bob", abs(b_cg) > 0),
("D-B2 |R(credit)|>0 Bob", abs(b_cc) > 0),
("D-B-rank |credit|>|gender| Bob", abs(b_cc) > abs(b_cg)),
("D-comp |gender Alice|>|gender Bob|", abs(a_cg) > abs(b_cg)),
]
for label, ok in checks_causal:
print(f" {label:<38} {'✓' if ok else '✗'}")
Causal SHAP vs plain SHAP for rejection P(loan=0|x):
Alice gender: plain=0.10687 causal=0.10687 equal=True
Alice credit: plain=0.17438 causal=0.17438 equal=True
Bob gender: plain=-0.10688 causal=-0.10688 equal=True
Bob credit: plain=0.34313 causal=0.34313 equal=True
Desiderata (|·| magnitudes, D-B1 strict > 0):
D-A1 |R(gender)|>0 Alice ✓
D-A2 |R(credit)|>0 Alice ✓
D-A-rank |gender|>|credit| Alice ✗
D-B1 |R(gender)|>0 Bob ✓
D-B2 |R(credit)|>0 Bob ✓
D-B-rank |credit|>|gender| Bob ✓
D-comp |gender Alice|>|gender Bob| ✗
8. 3-feature extension
So far SHAP has had only two features (gender, credit). To put SHAP and PCI on equal footing we now extend the SHAP feature set to include check_failed, treating it as an observable model input, the same status the mediator \(M\) has in the signal-with-mediation example of the paper.
Observational model \(\tilde{f}(g, c, cf) = \mathbb{E}[\text{loan} \mid g, c, cf]\):
\(g\) |
\(c\) |
\(cf\) |
\(\tilde{f}\) |
|---|---|---|---|
any |
0 |
0 |
0 |
\(g\) |
0 |
1 |
\(p_{g,1}\) |
\(g\) |
1 |
0 |
\(p_{\text{check}}[g] \cdot p_{g,0}\) |
any |
1 |
1 |
undefined (impossible under the SCM) |
Heskes’ causal value function: \(v_{\text{causal}}(S) = \mathbb{E}[\tilde{f}(X) \mid \mathrm{do}(X_S = x_S^\star)]\). Coalitions that fix \(cf^\star = 1\) and let \(c\) vary marginally query the undefined cell with positive weight.
Causal SHAP is well-defined for Alice (\(cf^\star = 0\)) and undefined for Bob (\(cf^\star = 1\)) without an extrapolation rule. That is a different failure mode from PNS/ATE/CATE: no number at all, where those returned a wrong one. PCI handles this case because the witness mechanism never queries impossible configurations.
[18]:
# ── Extended 3-feature observational model ─────────────────────────────────
#
# Strict observational extension. The cell (c=1, cf=1) has zero observational
# mass under the OBCB SCM and is therefore undefined; we return NaN there.
import math
def f_obs(g, c, cf):
if c == 0 and cf == 0:
return 0.0
if c == 0 and cf == 1:
return loan_prob[(g, 1)]
if c == 1 and cf == 0:
return p_check[g] * loan_prob[(g, 0)]
return math.nan # (c=1, cf=1) is impossible under the SCM
def p_cf_marg(cf):
p1 = sum(0.5 * 0.5 * p_check[g] * (1 - c) for g in [0, 1] for c in [0, 1])
return p1 if cf == 1 else (1 - p1)
# ── Causal SHAP value function (Heskes et al. 2020) ──────────────────────
#
# v_causal(S) = E[f(X) | do(X_S = x_S^*)]. Implementation skips zero-weight
# terms so that NaN cells are only ever poisoned by *positive*-weight queries
# (i.e., when the do-distribution genuinely puts mass on the undefined cell).
def v_causal(S, x_star, fhat):
g_iter = [x_star["g"]] if "g" in S else [0, 1]
c_iter = [x_star["c"]] if "c" in S else [0, 1]
p_g = (lambda g: 1.0) if "g" in S else (lambda g: 0.5)
p_c = (lambda c: 1.0) if "c" in S else (lambda c: 0.5)
total = 0.0
weight_check = 0.0
for g in g_iter:
for c in c_iter:
if "cf" in S:
cf_iter = [x_star["cf"]]
p_cf_local = lambda cf: 1.0
else:
cf_iter = [0, 1]
p1 = p_check[g] * (1 - c)
p_cf_local = (lambda p1=p1: lambda cf: p1 if cf == 1 else (1 - p1))()
for cf in cf_iter:
w = p_g(g) * p_c(c) * p_cf_local(cf)
if w == 0:
continue # skip zero-mass terms (avoid 0 * NaN poisoning)
total += w * fhat(g, c, cf)
weight_check += w
assert abs(weight_check - 1.0) < 1e-9, weight_check
return total
def v_plain(S, x_star, fhat):
g_iter = [x_star["g"]] if "g" in S else [0, 1]
c_iter = [x_star["c"]] if "c" in S else [0, 1]
cf_iter = [x_star["cf"]] if "cf" in S else [0, 1]
p_g = (lambda g: 1.0) if "g" in S else (lambda g: 0.5)
p_c = (lambda c: 1.0) if "c" in S else (lambda c: 0.5)
p_cf = (lambda cf: 1.0) if "cf" in S else p_cf_marg
total = 0.0
for g in g_iter:
for c in c_iter:
for cf in cf_iter:
w = p_g(g) * p_c(c) * p_cf(cf)
if w == 0:
continue
total += w * fhat(g, c, cf)
return total
print("f_obs cells:")
for g in [0, 1]:
for c in [0, 1]:
for cf in [0, 1]:
print(f" f({g},{c},{cf}) = {f_obs(g, c, cf)}")
print()
print(f"P(cf=0) = {p_cf_marg(0):.4f}, P(cf=1) = {p_cf_marg(1):.4f}")
f_obs cells:
f(0,0,0) = 0.0
f(0,0,1) = 0.0
f(0,1,0) = 0.18000000000000002
f(0,1,1) = nan
f(1,0,0) = 0.0
f(1,0,1) = 0.05
f(1,1,0) = 0.9
f(1,1,1) = nan
P(cf=0) = 0.7250, P(cf=1) = 0.2750
[19]:
# ── Shapley computation over the 3-feature game ──────────────────────────────
from itertools import combinations
from math import factorial, isnan
FEATURES = ("g", "c", "cf")
def powerset(iterable):
s = list(iterable)
return [frozenset(c) for r in range(len(s) + 1) for c in combinations(s, r)]
def shapley_with_trace(v_fn, x_star, fhat, target):
n = len(FEATURES)
others = [f for f in FEATURES if f != target]
phi = 0.0
trace = []
for S in powerset(others):
s = len(S)
weight = factorial(s) * factorial(n - s - 1) / factorial(n)
S_set = set(S)
v_with = v_fn(S_set | {target}, x_star, fhat)
v_without = v_fn(S_set, x_star, fhat)
phi += weight * (v_with - v_without)
trace.append(
(tuple(sorted(S_set)), v_without, tuple(sorted(S_set | {target})), v_with)
)
return phi, trace
def shap_table(x_star, label):
print(f"\n=== {label} factual = {x_star} ===")
out = {}
nan_coals_causal = set()
for target in FEATURES:
phi_p, _ = shapley_with_trace(v_plain, x_star, f_obs, target)
phi_c, trace_c = shapley_with_trace(v_causal, x_star, f_obs, target)
out[("plain", target)] = phi_p
out[("causal", target)] = phi_c
for S_a, v_a, S_b, v_b in trace_c:
if isinstance(v_a, float) and isnan(v_a):
nan_coals_causal.add(S_a)
if isinstance(v_b, float) and isnan(v_b):
nan_coals_causal.add(S_b)
if nan_coals_causal:
print(f" NaN-producing coalitions under v_causal: {sorted(nan_coals_causal)}")
full = f_obs(x_star["g"], x_star["c"], x_star["cf"])
base_p = v_plain(set(), x_star, f_obs)
base_c = v_causal(set(), x_star, f_obs)
print(f" full f-hat(x*) = {full}")
print(f" v_plain({{}}) = {base_p}")
print(f" v_causal({{}}) = {base_c}")
print()
print(" Feature phi_plain phi_causal")
for f in FEATURES:
pp = out[("plain", f)]
pc = out[("causal", f)]
pp_s = " NaN " if (isinstance(pp, float) and isnan(pp)) else f"{pp:+.5f}"
pc_s = " NaN " if (isinstance(pc, float) and isnan(pc)) else f"{pc:+.5f}"
print(f" {f:<12} {pp_s} {pc_s}")
return out
alice_x = {"g": 0, "c": 0, "cf": 0} # female, bad, not checked
bob_x = {"g": 1, "c": 0, "cf": 1} # male, bad, checked, check_failed = 1
alice_out = shap_table(alice_x, "Alice")
bob_out = shap_table(bob_x, "Bob")
=== Alice factual = {'g': 0, 'c': 0, 'cf': 0} ===
full f-hat(x*) = 0.0
v_plain({}) = nan
v_causal({}) = 0.28125
Feature phi_plain phi_causal
g NaN -0.09750
c NaN -0.17625
cf NaN -0.00750
=== Bob factual = {'g': 1, 'c': 0, 'cf': 1} ===
NaN-producing coalitions under v_causal: [('cf',), ('cf', 'g')]
full f-hat(x*) = 0.05
v_plain({}) = nan
v_causal({}) = 0.28125
Feature phi_plain phi_causal
g NaN NaN
c NaN NaN
cf NaN NaN
[20]:
# ── Desiderata under the 3-feature game (f_obs strict) ───────────────────
def cmp(a, b):
if (isinstance(a, float) and isnan(a)) or (isinstance(b, float) and isnan(b)):
return "undef"
return "PASS" if a > b else "FAIL"
def gt0(a):
if isinstance(a, float) and isnan(a):
return "undef"
return "PASS" if a > 1e-9 else "FAIL"
def mag(out, m, f):
v = out[(m, f)]
return float("nan") if (isinstance(v, float) and isnan(v)) else abs(v)
def report(method, alice, bob):
A = {f: mag(alice, method, f) for f in FEATURES}
B = {f: mag(bob, method, f) for f in FEATURES}
print(f"\n--- Desiderata under {method} ---")
rows = [
("D-A1 |gender|>0 Alice", gt0(A["g"])),
("D-A2 |credit|>0 Alice", gt0(A["c"])),
("D-A-rank gender>credit Alice", cmp(A["g"], A["c"])),
("D-B1 |gender|>0 Bob", gt0(B["g"])),
("D-B2 |credit|>0 Bob", gt0(B["c"])),
("D-B-rank credit>gender Bob", cmp(B["c"], B["g"])),
("D-comp |gender|_Alice > |gender|_Bob", cmp(A["g"], B["g"])),
]
for label, status in rows:
print(f" {label:<42} {status}")
report("plain", alice_out, bob_out)
report("causal", alice_out, bob_out)
# ── Magnitudes summary table ─────────────────────────────────────────────
import pandas as pd
def fmtm(out, m, f):
v = out[(m, f)]
return "NaN" if (isinstance(v, float) and isnan(v)) else f"{abs(v):.3f}"
rows = []
for method in ("plain", "causal"):
rows.append(
(
method,
fmtm(alice_out, method, "g"),
fmtm(alice_out, method, "c"),
fmtm(alice_out, method, "cf"),
fmtm(bob_out, method, "g"),
fmtm(bob_out, method, "c"),
fmtm(bob_out, method, "cf"),
)
)
df = pd.DataFrame(
rows, columns=["method", "A:|g|", "A:|c|", "A:|cf|", "B:|g|", "B:|c|", "B:|cf|"]
)
print()
print(df.to_string(index=False))
--- Desiderata under plain ---
D-A1 |gender|>0 Alice undef
D-A2 |credit|>0 Alice undef
D-A-rank gender>credit Alice undef
D-B1 |gender|>0 Bob undef
D-B2 |credit|>0 Bob undef
D-B-rank credit>gender Bob undef
D-comp |gender|_Alice > |gender|_Bob undef
--- Desiderata under causal ---
D-A1 |gender|>0 Alice PASS
D-A2 |credit|>0 Alice PASS
D-A-rank gender>credit Alice FAIL
D-B1 |gender|>0 Bob undef
D-B2 |credit|>0 Bob undef
D-B-rank credit>gender Bob undef
D-comp |gender|_Alice > |gender|_Bob undef
method A:|g| A:|c| A:|cf| B:|g| B:|c| B:|cf|
plain NaN NaN NaN NaN NaN NaN
causal 0.097 0.176 0.007 NaN NaN NaN
Both SHAP variants go undefined
method |
A:|g| |
A:|c| |
A:|cf| |
B:|g| |
B:|c| |
B:|cf| |
|---|---|---|---|---|---|---|
plain |
NaN |
NaN |
NaN |
NaN |
NaN |
NaN |
causal |
0.097 |
0.176 |
0.007 |
NaN |
NaN |
NaN |
Once check_failed is promoted to an observable feature, the two SHAP variants fail in a new manner, returning no number at all:
Plain SHAP is undefined for both applicants: with \(cf\) in the feature set the baseline coalition \(v(\emptyset)\) averages \(\tilde f\) over a region that includes the impossible cell (good credit, check failed), so it returns NaN throughout.
Causal SHAP is well-defined for Alice (\(cf^\star = 0\)), and there it still fails D-A-rank: credit (0.176) outranks gender (0.097), the same overdetermination error as the 2-feature game. For Bob (\(cf^\star = 1\)) it is undefined: the coalitions \(\{cf\}\) and \(\{cf, g\}\) query the impossible (good-credit, check-failed) cell, so every one of Bob’s attributions is NaN.
This is a distinct failure mode from PNS/ATE/CATE: those returned a confidently wrong ranking, whereas here Causal SHAP simply cannot answer for Bob. PCI never hits this wall, because its witness mechanism only ever conditions on configurations that actually occurred. The conclusions follow.
9. Conclusions
We ran six attribution methods on two applicants who were both rejected, but for structurally different reasons: Alice was never credit-checked (the bank refuses to check women), while Bob was checked and failed. The right answer is that gender drove Alice’s rejection and credit drove Bob’s. Here is what each method made of that.
PN / PS / PNS give sensible population-level (group-average) numbers, but at the individual level PNS ranks credit (0.18) above gender (0.045) for Alice, the wrong order. The reason is mechanical: intervening on Alice’s credit propagates through the mediator
check_failedand overrides the factual context that her check never happened. PNS satisfies D-comp here only by the accident that Bob’s gender PNS is exactly 0.ATE / CATE inherit exactly this blind spot:
CATE(gender | credit=bad)collapses to Alice’s individual PNS and is identical for Alice and Bob, violating D-comp. Standard treatment effects cannot condition on the factual value of the treatment itself, so they cannot tell the two applicants apart.SHAP and Causal SHAP coincide on this model (because gender and credit are independent exogenous roots, intervening on one does not change the other). Both fail D-A-rank, since Alice’s credit (0.174) outranks her gender (0.107), an overdetermination failure: the two features symmetrically share credit for a rejection the structure decided upstream.
PCI without witnesses behaves like PNS: it still ranks credit above gender for Alice, and we verified it numerically reduces to individual-level PNS in the single-suspect configuration.
PCI with witnesses is the only method that gets Alice right. Pinning
check_failedat its factual value (0 for Alice, since she was never checked) keeps the structural fact that credit was disconnected from her loan decision alive through the counterfactual, so gender (0.263) now outranks credit (0.199). It passes all seven §3 desiderata, including D-A-rank and D-comp.The 3-feature extension exposes a different SHAP failure: once
check_failedis an input, Causal SHAP must query the impossible cell (good credit, check failed) for Bob and returns NaN. PCI never hits this, because the witness mechanism only ever conditions on configurations that actually occurred.
Every method except witness-based PCI lets an intervention overwrite the factual mediator value that records which causal path was actually live. Holding that mediator fixed, the witness mechanism imported from actual causality, lets PCI attribute Alice’s denial to the bank’s policy, where every other method blames a credit check that never ran. §2 to §4 of the paper rest on this.