statspai.fairness¶
fairness ¶
Counterfactual fairness & algorithmic-bias diagnostics (sp.fairness).
Treats fairness as a causal-inference problem rather than a pure statistics one: an algorithm is counterfactually fair if the prediction would be the same had the protected attribute been different, holding the non-protected causal ancestors fixed.
Core tools
- :func:
counterfactual_fairness— Kusner, Loftus, Russell, Silva (2018) Level-2/3 predictor evaluation on a user-supplied SCM. - :func:
orthogonal_to_bias— Chen & Zhu (arXiv:2403.17852v3, 2024) Data pre-processing that removes the part of non-protected features correlated with the protected attribute, via residualization. - :func:
demographic_parity— P(Y_hat=1 | A=a) — A. - :func:
equalized_odds— P(Y_hat=1 | Y=y, A=a) — A. - :func:
fairness_audit— one-shot dashboard combining the metrics above.
References
Kusner, M. J., Loftus, J., Russell, C., & Silva, R. (2018). Counterfactual fairness. NeurIPS.
Hardt, M., Price, E., & Srebro, N. (2016). Equality of opportunity in supervised learning. NeurIPS.
Chen, S., & Zhu, S. (2024). Counterfactual Fairness Through Orthogonal to Bias. arXiv:2403.17852v3.
FairnessResult
dataclass
¶
Single fairness diagnostic.
FairnessAudit
dataclass
¶
One-shot dashboard of fairness diagnostics.
EvidenceWithoutInjusticeResult
dataclass
¶
Bases: FairnessResult
Extended fairness result with bootstrap CI and per-alt statistics.
Inherits the standard metric/value/per_group/threshold/passes/notes
fields from :class:FairnessResult and adds bootstrap-inference
artefacts specific to Kwak-Pleasants 2025.
counterfactual_fairness ¶
counterfactual_fairness(data: DataFrame, predictor: Callable[[DataFrame], ndarray], *, protected: str, scm_intervention: Callable[[DataFrame, Any], DataFrame], alternative_values: Optional[Sequence[Any]] = None, threshold: float = 0.05) -> FairnessResult
Kusner-Loftus-Russell-Silva counterfactual-fairness test.
For each unit, compare the factual prediction with the counterfactual
prediction obtained under a user-supplied SCM intervention that sets
the protected attribute A to alternative values.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Observed covariates (including the protected attribute). |
required |
predictor
|
Callable(DataFrame) -> ndarray
|
Deployed predictor. Must accept a DataFrame and return an array of the same length. |
required |
protected
|
str
|
Protected attribute column — its counterfactual is constructed by
|
required |
scm_intervention
|
Callable(DataFrame, value) -> DataFrame
|
User-supplied causal model. Given the original data and an
alternative value of |
required |
alternative_values
|
sequence
|
Values of |
None
|
threshold
|
float
|
|
0.05
|
Returns:
| Type | Description |
|---|---|
FairnessResult
|
|
Notes
This is a Level-3 (counterfactual) fairness test and is only as credible as the SCM the user supplies. A DAG + structural equations must be specified outside this function — we just wrap the mechanics.
References
Kusner, Loftus, Russell, Silva (2018).
orthogonal_to_bias ¶
orthogonal_to_bias(data: DataFrame, *, features: Sequence[str], protected: str, method: str = 'residualize') -> DataFrame
OB preprocessing — remove the part of features correlated with A.
For each feature X_j, regress X_j ~ A (one-hot A if multi-valued)
and replace the feature by its residual. The residualized features are
by construction uncorrelated with A in-sample. Training a predictor
on the residualized features is a simple relaxation of counterfactual
fairness that requires no explicit SCM.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
features
|
sequence of str
|
Numeric columns to residualize. |
required |
protected
|
str
|
Protected attribute column (numeric or categorical). |
required |
method
|
'residualize'
|
|
'residualize'
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
Copy of |
References
Chen & Zhu (arXiv:2403.17852v3, 2024).
demographic_parity ¶
demographic_parity(data: DataFrame, *, predictions: str, protected: str, threshold: float = 0.1) -> FairnessResult
Demographic-parity gap: max_{a,b} | P(Y_hat=1 | A=a) − P(Y_hat=1 | A=b) |.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
predictions
|
str
|
Column of binary classifier outputs (0/1). |
required |
protected
|
str
|
Column containing the protected attribute |
required |
threshold
|
float
|
A gap below this value counts as a "pass". Follows the 80%-rule of thumb (EEOC disparate-impact guideline). |
0.1
|
Notes
Demographic parity is the weakest fairness criterion — it ignores
ground-truth labels and can be trivially satisfied by a random
classifier. Use together with :func:equalized_odds.
equalized_odds ¶
equalized_odds(data: DataFrame, *, predictions: str, labels: str, protected: str, threshold: float = 0.1) -> FairnessResult
Hardt-Price-Srebro equalized-odds gap.
Returns the larger of the TPR gap and FPR gap across groups:
gap = max( max |TPR_a − TPR_b|, max |FPR_a − FPR_b| )
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
predictions
|
str
|
Binary classifier output. |
required |
labels
|
str
|
Binary ground-truth outcome. |
required |
protected
|
str
|
Protected attribute column. |
required |
threshold
|
float
|
|
0.1
|
fairness_audit ¶
fairness_audit(data: DataFrame, *, predictions: str, protected: str, labels: Optional[str] = None, predictor: Optional[Callable[[DataFrame], ndarray]] = None, scm_intervention: Optional[Callable[[DataFrame, Any], DataFrame]] = None, alternative_values: Optional[Sequence[Any]] = None, threshold: float = 0.1) -> FairnessAudit
Run all applicable fairness diagnostics on a classifier's output.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
predictions
|
str
|
Binary classifier output column. |
required |
protected
|
str
|
|
required |
labels
|
str
|
If present, adds equalized-odds diagnostic. |
None
|
predictor
|
Optional[Callable[[DataFrame], ndarray]]
|
If |
None
|
scm_intervention
|
Optional[Callable[[DataFrame], ndarray]]
|
If |
None
|
alternative_values
|
Optional[Callable[[DataFrame], ndarray]]
|
If |
None
|
evidence_without_injustice ¶
evidence_without_injustice(data: DataFrame, predictor: Callable[[DataFrame], ndarray], *, protected: str, admissible_features: Sequence[str], scm_intervention: Callable[[DataFrame, Any], DataFrame], alternative_values: Optional[Sequence[Any]] = None, threshold: float = 0.05, alpha: float = 0.05, n_boot: int = 500, random_state: Optional[int] = None) -> EvidenceWithoutInjusticeResult
Kwak-Pleasants (2025) evidence-without-injustice fairness test.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Observed covariates — must include |
required |
predictor
|
Callable(DataFrame) -> ndarray
|
Deployed model. Called with (a) the original data and (b) the counterfactual data after intervention + admissibility freezing. |
required |
protected
|
str
|
Name of the protected-attribute column. |
required |
admissible_features
|
sequence of str
|
Feature columns whose factual values are preserved in the
counterfactual world — these encode the "admissible evidence" that
the algorithm may legitimately use even if correlated with |
required |
scm_intervention
|
Callable(DataFrame, value) -> DataFrame
|
User-supplied SCM. Returns a DataFrame of the same shape with
|
required |
alternative_values
|
sequence
|
Values of |
None
|
threshold
|
float
|
Practical-significance threshold on |
0.05
|
alpha
|
float
|
Significance level for the bootstrap CI. |
0.05
|
n_boot
|
int
|
|
500
|
random_state
|
int
|
|
None
|
Returns:
| Type | Description |
|---|---|
EvidenceWithoutInjusticeResult
|
Standard :class: |
Examples:
>>> import numpy as np, pandas as pd, statspai as sp
>>> rng = np.random.default_rng(0)
>>> n = 500
>>> A = rng.integers(0, 2, n)
>>> credit = 600 + 100 * A + rng.normal(0, 30, n) # correlated w/ A
>>> race_noise = 0.0 * A # zero direct effect -> fair
>>> df = pd.DataFrame({'A': A, 'credit': credit, 'noise': rng.normal(size=n)})
>>> def predictor(d): return 1 / (1 + np.exp(-(d['credit'] / 100 - 6)))
>>> def intervene(d, a_alt):
... out = d.copy(); out['A'] = a_alt
... out['credit'] = 600 + 100 * a_alt + (d['credit'] - (600 + 100 * d['A']))
... return out
>>> res = sp.fairness.evidence_without_injustice(
... df, predictor, protected='A',
... admissible_features=['credit'],
... scm_intervention=intervene,
... n_boot=200, random_state=0,
... )
>>> bool(res.passes)
True
Notes
The bootstrap is a paired nonparametric bootstrap over the units: for each replicate we resample rows with replacement and recompute the test statistic. This accounts for predictor-randomness only via plug-in (the predictor is treated as fixed). For full accounting of predictor uncertainty, pass in a Bayesian posterior predictor or wrap the predictor in its own bootstrap at a higher level.