diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..acf6d3b5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:system" +} diff --git a/assets/explainers-data.js b/assets/explainers-data.js index 0da1dd91..73481b0a 100644 --- a/assets/explainers-data.js +++ b/assets/explainers-data.js @@ -613,10 +613,22 @@ window.FAIR_CODE_EXPLAINERS = [ "slug": "reject-option-classification", "title": "What Is Reject Option Classification?", "subtitle": "Flip the model's least-confident predictions toward the group history treated worst.", - "summary": "Learn how Reject Option Classification (Kamiran, Karim & Zhang, 2012) post-processes a model by reassigning labels only inside a low-confidence band near the decision boundary, and why the band's width - a free parameter with no principled default - decides whether the fairness gap shrinks, holds, or reverses. Worked on the COMPAS baseline logistic regression: a +-0.10 band flips 651 of 3,254 predictions and halves the gap, while a +-0.15 band overcorrects it to -70 pp and collapses accuracy.", + "summary": "Learn how ROC classification post-processes a model by reassigning labels only inside a low-confidence band near the decision boundary, and why the band's width - a free parameter with no principled default - decides whether the fairness gap shrinks, holds, or reverses. Worked on the COMPAS baseline logistic regression: a +-0.10 band flips 651 of 3,254 predictions and halves the gap, while a +-0.15 band overcorrects it to -70 pp and collapses accuracy.", "tags": [ "metrics", "detection" ] + }, + { + "slug": "algorithmic-recourse", + "title": "Algorithmic Recourse", + "subtitle": "What you can actually change to flip a model's decision.", + "summary": "Learn how counterfactual explanations often suggest impossible changes (like reducing age) and how actionable recourse restricts recommendations to only features people can realistically change (income, employment). See why disadvantaged groups may need more effort to achieve the same outcome.", + "tags": [ + "explainability", + "metrics", + "detection", + "healthcare" + ] } ]; diff --git a/assets/explainers-data.json b/assets/explainers-data.json index 18b6cee6..04e560ae 100644 --- a/assets/explainers-data.json +++ b/assets/explainers-data.json @@ -423,9 +423,14 @@ "slug": "reject-option-classification", "title": "What Is Reject Option Classification?", "subtitle": "Flip the model's least-confident predictions toward the group history treated worst.", - "summary": "Learn how Reject Option Classification (Kamiran, Karim & Zhang, 2012) post-processes a model by reassigning labels only inside a low-confidence band near the decision boundary, and why the band's width - a free parameter with no principled default - decides whether the fairness gap shrinks, holds, or reverses. Worked on the COMPAS baseline logistic regression: a +-0.10 band flips 651 of 3,254 predictions and halves the gap, while a +-0.15 band overcorrects it to -70 pp and collapses accuracy.", + "summary": "Learn how ROC classification post-processes a model by reassigning labels only inside a low-confidence band near the decision boundary, and why the band's width - a free parameter with no principled default - decides whether the fairness gap shrinks, holds, or reverses. Worked on the COMPAS baseline logistic regression: a +-0.10 band flips 651 of 3,254 predictions and halves the gap, while a +-0.15 band overcorrects it to -70 pp and collapses accuracy.", "tags": ["metrics", "detection"] + }, + { + "slug": "algorithmic-recourse", + "title": "Algorithmic Recourse", + "subtitle": "A decision-changing path is only useful if a person can actually take it.", + "summary": "Learn how algorithmic recourse differs from a counterfactual explanation, how to constrain changes to actionable features, and how a reproducible hiring-audit example compares recourse costs across groups.", + "tags": ["explainability", "fairness", "case-study"] } ] - - diff --git a/assets/og-light/accuracy-equality.png b/assets/og-light/accuracy-equality.png index 14ae0fb6..20827e5e 100644 Binary files a/assets/og-light/accuracy-equality.png and b/assets/og-light/accuracy-equality.png differ diff --git a/assets/og-light/accuracy-not-enough-healthcare-ai.png b/assets/og-light/accuracy-not-enough-healthcare-ai.png index 2b9e077b..97d6a4a7 100644 Binary files a/assets/og-light/accuracy-not-enough-healthcare-ai.png and b/assets/og-light/accuracy-not-enough-healthcare-ai.png differ diff --git a/assets/og-light/ai-hallucinations.png b/assets/og-light/ai-hallucinations.png index 092cbd2e..f6025d12 100644 Binary files a/assets/og-light/ai-hallucinations.png and b/assets/og-light/ai-hallucinations.png differ diff --git a/assets/og-light/ai-objectivity-myth.png b/assets/og-light/ai-objectivity-myth.png index c1cce53b..f7c365c9 100644 Binary files a/assets/og-light/ai-objectivity-myth.png and b/assets/og-light/ai-objectivity-myth.png differ diff --git a/assets/og-light/algorithmic-recourse.png b/assets/og-light/algorithmic-recourse.png new file mode 100644 index 00000000..22294035 Binary files /dev/null and b/assets/og-light/algorithmic-recourse.png differ diff --git a/assets/og-light/automation-bias.png b/assets/og-light/automation-bias.png index e3da3735..8bbc6e1e 100644 Binary files a/assets/og-light/automation-bias.png and b/assets/og-light/automation-bias.png differ diff --git a/assets/og-light/base-rate-fallacy.png b/assets/og-light/base-rate-fallacy.png index 640b6cd0..06600952 100644 Binary files a/assets/og-light/base-rate-fallacy.png and b/assets/og-light/base-rate-fallacy.png differ diff --git a/assets/og-light/bias-variance-tradeoff.png b/assets/og-light/bias-variance-tradeoff.png index 1c65c743..0525d3e9 100644 Binary files a/assets/og-light/bias-variance-tradeoff.png and b/assets/og-light/bias-variance-tradeoff.png differ diff --git a/assets/og-light/bootstrap-confidence-intervals.png b/assets/og-light/bootstrap-confidence-intervals.png index f9e2655b..01742ddc 100644 Binary files a/assets/og-light/bootstrap-confidence-intervals.png and b/assets/og-light/bootstrap-confidence-intervals.png differ diff --git a/assets/og-light/calibration.png b/assets/og-light/calibration.png index 2f4d7607..8ce72852 100644 Binary files a/assets/og-light/calibration.png and b/assets/og-light/calibration.png differ diff --git a/assets/og-light/class-imbalance.png b/assets/og-light/class-imbalance.png index 87173ebd..49e6bb2f 100644 Binary files a/assets/og-light/class-imbalance.png and b/assets/og-light/class-imbalance.png differ diff --git a/assets/og-light/clinical-score-miscalibration.png b/assets/og-light/clinical-score-miscalibration.png index dc0932a1..98732bfc 100644 Binary files a/assets/og-light/clinical-score-miscalibration.png and b/assets/og-light/clinical-score-miscalibration.png differ diff --git a/assets/og-light/conditional-demographic-parity.png b/assets/og-light/conditional-demographic-parity.png index 85fe5979..5db3704a 100644 Binary files a/assets/og-light/conditional-demographic-parity.png and b/assets/og-light/conditional-demographic-parity.png differ diff --git a/assets/og-light/confounding-variable.png b/assets/og-light/confounding-variable.png index a2686ebd..85c18a0d 100644 Binary files a/assets/og-light/confounding-variable.png and b/assets/og-light/confounding-variable.png differ diff --git a/assets/og-light/confusion-matrix.png b/assets/og-light/confusion-matrix.png index 7054d621..319f225c 100644 Binary files a/assets/og-light/confusion-matrix.png and b/assets/og-light/confusion-matrix.png differ diff --git a/assets/og-light/counterfactual-explanation.png b/assets/og-light/counterfactual-explanation.png index df6b6dbc..2eb2dad5 100644 Binary files a/assets/og-light/counterfactual-explanation.png and b/assets/og-light/counterfactual-explanation.png differ diff --git a/assets/og-light/counterfactual-fairness.png b/assets/og-light/counterfactual-fairness.png index 4f76500c..adb75d5e 100644 Binary files a/assets/og-light/counterfactual-fairness.png and b/assets/og-light/counterfactual-fairness.png differ diff --git a/assets/og-light/data-leakage.png b/assets/og-light/data-leakage.png index d7571f92..a8c186fa 100644 Binary files a/assets/og-light/data-leakage.png and b/assets/og-light/data-leakage.png differ diff --git a/assets/og-light/demographic-parity.png b/assets/og-light/demographic-parity.png index 1019b5fd..0ec0a2b8 100644 Binary files a/assets/og-light/demographic-parity.png and b/assets/og-light/demographic-parity.png differ diff --git a/assets/og-light/differential-privacy.png b/assets/og-light/differential-privacy.png index 2c5654dc..b974f7f4 100644 Binary files a/assets/og-light/differential-privacy.png and b/assets/og-light/differential-privacy.png differ diff --git a/assets/og-light/disparate-impact.png b/assets/og-light/disparate-impact.png index fa55baf1..e75ed517 100644 Binary files a/assets/og-light/disparate-impact.png and b/assets/og-light/disparate-impact.png differ diff --git a/assets/og-light/disparate-treatment.png b/assets/og-light/disparate-treatment.png index 838e95ae..b7b81aa3 100644 Binary files a/assets/og-light/disparate-treatment.png and b/assets/og-light/disparate-treatment.png differ diff --git a/assets/og-light/distribution-shift.png b/assets/og-light/distribution-shift.png index b499e42c..cf1027b8 100644 Binary files a/assets/og-light/distribution-shift.png and b/assets/og-light/distribution-shift.png differ diff --git a/assets/og-light/equal-opportunity.png b/assets/og-light/equal-opportunity.png index 7a9e1dd7..79bbb025 100644 Binary files a/assets/og-light/equal-opportunity.png and b/assets/og-light/equal-opportunity.png differ diff --git a/assets/og-light/equalized-odds.png b/assets/og-light/equalized-odds.png index 9acde145..51b8bd76 100644 Binary files a/assets/og-light/equalized-odds.png and b/assets/og-light/equalized-odds.png differ diff --git a/assets/og-light/fairness-accuracy-tradeoff.png b/assets/og-light/fairness-accuracy-tradeoff.png index a5e0bb8b..dcc0b4b6 100644 Binary files a/assets/og-light/fairness-accuracy-tradeoff.png and b/assets/og-light/fairness-accuracy-tradeoff.png differ diff --git a/assets/og-light/fairness-metric-conflicts.png b/assets/og-light/fairness-metric-conflicts.png index 82bdfca4..c640e575 100644 Binary files a/assets/og-light/fairness-metric-conflicts.png and b/assets/og-light/fairness-metric-conflicts.png differ diff --git a/assets/og-light/fairness-through-unawareness.png b/assets/og-light/fairness-through-unawareness.png index 52c61bfd..4d34e3a7 100644 Binary files a/assets/og-light/fairness-through-unawareness.png and b/assets/og-light/fairness-through-unawareness.png differ diff --git a/assets/og-light/false-positives-vs-false-negatives.png b/assets/og-light/false-positives-vs-false-negatives.png index 80e82f15..a9e54ab4 100644 Binary files a/assets/og-light/false-positives-vs-false-negatives.png and b/assets/og-light/false-positives-vs-false-negatives.png differ diff --git a/assets/og-light/feedback-loop-bias.png b/assets/og-light/feedback-loop-bias.png index 8559241a..7b4499df 100644 Binary files a/assets/og-light/feedback-loop-bias.png and b/assets/og-light/feedback-loop-bias.png differ diff --git a/assets/og-light/home.png b/assets/og-light/home.png index 282e5934..971bdf9d 100644 Binary files a/assets/og-light/home.png and b/assets/og-light/home.png differ diff --git a/assets/og-light/how-ai-detects-patterns.png b/assets/og-light/how-ai-detects-patterns.png index 95c48299..720cb033 100644 Binary files a/assets/og-light/how-ai-detects-patterns.png and b/assets/og-light/how-ai-detects-patterns.png differ diff --git a/assets/og-light/individual-fairness.png b/assets/og-light/individual-fairness.png index 250c4a09..23e1eb68 100644 Binary files a/assets/og-light/individual-fairness.png and b/assets/og-light/individual-fairness.png differ diff --git a/assets/og-light/intersectional-bias.png b/assets/og-light/intersectional-bias.png index 63bb6e2c..daedf93e 100644 Binary files a/assets/og-light/intersectional-bias.png and b/assets/og-light/intersectional-bias.png differ diff --git a/assets/og-light/label-bias.png b/assets/og-light/label-bias.png index 0fb99017..6281aed0 100644 Binary files a/assets/og-light/label-bias.png and b/assets/og-light/label-bias.png differ diff --git a/assets/og-light/lime.png b/assets/og-light/lime.png index 362f30df..c373fe55 100644 Binary files a/assets/og-light/lime.png and b/assets/og-light/lime.png differ diff --git a/assets/og-light/medical-imaging-representation-gaps.png b/assets/og-light/medical-imaging-representation-gaps.png index 08312067..dc3a23db 100644 Binary files a/assets/og-light/medical-imaging-representation-gaps.png and b/assets/og-light/medical-imaging-representation-gaps.png differ diff --git a/assets/og-light/missing-data-bias-ehr.png b/assets/og-light/missing-data-bias-ehr.png index 887b8ca5..6c447b9e 100644 Binary files a/assets/og-light/missing-data-bias-ehr.png and b/assets/og-light/missing-data-bias-ehr.png differ diff --git a/assets/og-light/mitigation-strategies.png b/assets/og-light/mitigation-strategies.png index 6cddd5c3..843d59ac 100644 Binary files a/assets/og-light/mitigation-strategies.png and b/assets/og-light/mitigation-strategies.png differ diff --git a/assets/og-light/ml-bias.png b/assets/og-light/ml-bias.png index 5e4e4a23..7e12d429 100644 Binary files a/assets/og-light/ml-bias.png and b/assets/og-light/ml-bias.png differ diff --git a/assets/og-light/model-drift.png b/assets/og-light/model-drift.png index 2f521862..2a24b562 100644 Binary files a/assets/og-light/model-drift.png and b/assets/og-light/model-drift.png differ diff --git a/assets/og-light/multiple-comparisons.png b/assets/og-light/multiple-comparisons.png index 1e3fd8b9..71857fd7 100644 Binary files a/assets/og-light/multiple-comparisons.png and b/assets/og-light/multiple-comparisons.png differ diff --git a/assets/og-light/neural-networks.png b/assets/og-light/neural-networks.png index da1d8087..1b089a42 100644 Binary files a/assets/og-light/neural-networks.png and b/assets/og-light/neural-networks.png differ diff --git a/assets/og-light/obermeyer-cost-proxy.png b/assets/og-light/obermeyer-cost-proxy.png index d75f2c1e..a78fb12a 100644 Binary files a/assets/og-light/obermeyer-cost-proxy.png and b/assets/og-light/obermeyer-cost-proxy.png differ diff --git a/assets/og-light/precision-recall-curve.png b/assets/og-light/precision-recall-curve.png index ef8921e7..5067795f 100644 Binary files a/assets/og-light/precision-recall-curve.png and b/assets/og-light/precision-recall-curve.png differ diff --git a/assets/og-light/predictive-parity.png b/assets/og-light/predictive-parity.png index cec40f53..a75cefec 100644 Binary files a/assets/og-light/predictive-parity.png and b/assets/og-light/predictive-parity.png differ diff --git a/assets/og-light/profiler.png b/assets/og-light/profiler.png index 8b6ac954..9c40b959 100644 Binary files a/assets/og-light/profiler.png and b/assets/og-light/profiler.png differ diff --git a/assets/og-light/protected-attribute.png b/assets/og-light/protected-attribute.png index 34c1679b..cb8278b7 100644 Binary files a/assets/og-light/protected-attribute.png and b/assets/og-light/protected-attribute.png differ diff --git a/assets/og-light/proxy-entanglement.png b/assets/og-light/proxy-entanglement.png index 9ddd68b5..55c1e553 100644 Binary files a/assets/og-light/proxy-entanglement.png and b/assets/og-light/proxy-entanglement.png differ diff --git a/assets/og-light/proxy-variables.png b/assets/og-light/proxy-variables.png index 0031dbe4..30497d24 100644 Binary files a/assets/og-light/proxy-variables.png and b/assets/og-light/proxy-variables.png differ diff --git a/assets/og-light/race-correction-clinical-algorithms.png b/assets/og-light/race-correction-clinical-algorithms.png index 393118f4..672a90a8 100644 Binary files a/assets/og-light/race-correction-clinical-algorithms.png and b/assets/og-light/race-correction-clinical-algorithms.png differ diff --git a/assets/og-light/reinforcement-learning.png b/assets/og-light/reinforcement-learning.png index 55bad6d7..c27fb49f 100644 Binary files a/assets/og-light/reinforcement-learning.png and b/assets/og-light/reinforcement-learning.png differ diff --git a/assets/og-light/reject-inference.png b/assets/og-light/reject-inference.png index 02e07001..a27ce237 100644 Binary files a/assets/og-light/reject-inference.png and b/assets/og-light/reject-inference.png differ diff --git a/assets/og-light/reject-option-classification.png b/assets/og-light/reject-option-classification.png index ad8fdea5..1b369c4d 100644 Binary files a/assets/og-light/reject-option-classification.png and b/assets/og-light/reject-option-classification.png differ diff --git a/assets/og-light/roc-curve-auc.png b/assets/og-light/roc-curve-auc.png index 156b70f8..f52caaf1 100644 Binary files a/assets/og-light/roc-curve-auc.png and b/assets/og-light/roc-curve-auc.png differ diff --git a/assets/og-light/sampling-bias.png b/assets/og-light/sampling-bias.png index 35f1b626..cda010db 100644 Binary files a/assets/og-light/sampling-bias.png and b/assets/og-light/sampling-bias.png differ diff --git a/assets/og-light/selection-bias.png b/assets/og-light/selection-bias.png index f1298e43..c300285e 100644 Binary files a/assets/og-light/selection-bias.png and b/assets/og-light/selection-bias.png differ diff --git a/assets/og-light/shap-values.png b/assets/og-light/shap-values.png index 76ad541d..ac332e20 100644 Binary files a/assets/og-light/shap-values.png and b/assets/og-light/shap-values.png differ diff --git a/assets/og-light/simpsons-paradox.png b/assets/og-light/simpsons-paradox.png index 89c42a2d..e7fdb96f 100644 Binary files a/assets/og-light/simpsons-paradox.png and b/assets/og-light/simpsons-paradox.png differ diff --git a/assets/og-light/subgroup-fairness.png b/assets/og-light/subgroup-fairness.png index 3fa572cd..f32c9f5a 100644 Binary files a/assets/og-light/subgroup-fairness.png and b/assets/og-light/subgroup-fairness.png differ diff --git a/assets/og-light/supervised-learning.png b/assets/og-light/supervised-learning.png index cf6f4a0f..19ab1db8 100644 Binary files a/assets/og-light/supervised-learning.png and b/assets/og-light/supervised-learning.png differ diff --git a/assets/og-light/treatment-equality.png b/assets/og-light/treatment-equality.png index cdd9a38c..10a24228 100644 Binary files a/assets/og-light/treatment-equality.png and b/assets/og-light/treatment-equality.png differ diff --git a/assets/og-light/underdiagnosis-bias.png b/assets/og-light/underdiagnosis-bias.png index 4bd6dc47..a3cb7a59 100644 Binary files a/assets/og-light/underdiagnosis-bias.png and b/assets/og-light/underdiagnosis-bias.png differ diff --git a/assets/og-light/unsupervised-learning.png b/assets/og-light/unsupervised-learning.png index 7928143a..97177705 100644 Binary files a/assets/og-light/unsupervised-learning.png and b/assets/og-light/unsupervised-learning.png differ diff --git a/assets/og/accuracy-equality.png b/assets/og/accuracy-equality.png index 9c24ddfd..502c2107 100644 Binary files a/assets/og/accuracy-equality.png and b/assets/og/accuracy-equality.png differ diff --git a/assets/og/accuracy-not-enough-healthcare-ai.png b/assets/og/accuracy-not-enough-healthcare-ai.png index da0f2aab..a0e7c71e 100644 Binary files a/assets/og/accuracy-not-enough-healthcare-ai.png and b/assets/og/accuracy-not-enough-healthcare-ai.png differ diff --git a/assets/og/ai-hallucinations.png b/assets/og/ai-hallucinations.png index 1727ad57..4515232e 100644 Binary files a/assets/og/ai-hallucinations.png and b/assets/og/ai-hallucinations.png differ diff --git a/assets/og/ai-objectivity-myth.png b/assets/og/ai-objectivity-myth.png index 59f7d627..5fd2ddb1 100644 Binary files a/assets/og/ai-objectivity-myth.png and b/assets/og/ai-objectivity-myth.png differ diff --git a/assets/og/algorithmic-recourse.png b/assets/og/algorithmic-recourse.png new file mode 100644 index 00000000..3366e5e6 Binary files /dev/null and b/assets/og/algorithmic-recourse.png differ diff --git a/assets/og/automation-bias.png b/assets/og/automation-bias.png index ec205427..82a296e6 100644 Binary files a/assets/og/automation-bias.png and b/assets/og/automation-bias.png differ diff --git a/assets/og/base-rate-fallacy.png b/assets/og/base-rate-fallacy.png index c81d7cd4..710ae5fd 100644 Binary files a/assets/og/base-rate-fallacy.png and b/assets/og/base-rate-fallacy.png differ diff --git a/assets/og/bias-variance-tradeoff.png b/assets/og/bias-variance-tradeoff.png index 0d0bda2d..ae0b65ef 100644 Binary files a/assets/og/bias-variance-tradeoff.png and b/assets/og/bias-variance-tradeoff.png differ diff --git a/assets/og/bootstrap-confidence-intervals.png b/assets/og/bootstrap-confidence-intervals.png index 0193054e..cd8b9daa 100644 Binary files a/assets/og/bootstrap-confidence-intervals.png and b/assets/og/bootstrap-confidence-intervals.png differ diff --git a/assets/og/calibration.png b/assets/og/calibration.png index 791ccc56..c7f63dbd 100644 Binary files a/assets/og/calibration.png and b/assets/og/calibration.png differ diff --git a/assets/og/class-imbalance.png b/assets/og/class-imbalance.png index dc641221..78170e05 100644 Binary files a/assets/og/class-imbalance.png and b/assets/og/class-imbalance.png differ diff --git a/assets/og/clinical-score-miscalibration.png b/assets/og/clinical-score-miscalibration.png index df7ff9f6..09a98d22 100644 Binary files a/assets/og/clinical-score-miscalibration.png and b/assets/og/clinical-score-miscalibration.png differ diff --git a/assets/og/conditional-demographic-parity.png b/assets/og/conditional-demographic-parity.png index f441e972..66baf371 100644 Binary files a/assets/og/conditional-demographic-parity.png and b/assets/og/conditional-demographic-parity.png differ diff --git a/assets/og/confounding-variable.png b/assets/og/confounding-variable.png index ad45953c..6ce0f093 100644 Binary files a/assets/og/confounding-variable.png and b/assets/og/confounding-variable.png differ diff --git a/assets/og/confusion-matrix.png b/assets/og/confusion-matrix.png index e126d3e1..6e088b1a 100644 Binary files a/assets/og/confusion-matrix.png and b/assets/og/confusion-matrix.png differ diff --git a/assets/og/counterfactual-explanation.png b/assets/og/counterfactual-explanation.png index ec7dc1b9..329ea9a3 100644 Binary files a/assets/og/counterfactual-explanation.png and b/assets/og/counterfactual-explanation.png differ diff --git a/assets/og/counterfactual-fairness.png b/assets/og/counterfactual-fairness.png index 8bee11e9..5c03eb1b 100644 Binary files a/assets/og/counterfactual-fairness.png and b/assets/og/counterfactual-fairness.png differ diff --git a/assets/og/data-leakage.png b/assets/og/data-leakage.png index b817cf20..4e1d3cf2 100644 Binary files a/assets/og/data-leakage.png and b/assets/og/data-leakage.png differ diff --git a/assets/og/demographic-parity.png b/assets/og/demographic-parity.png index 82de582e..5548e2bf 100644 Binary files a/assets/og/demographic-parity.png and b/assets/og/demographic-parity.png differ diff --git a/assets/og/differential-privacy.png b/assets/og/differential-privacy.png index 7ec1d891..6c8e60f4 100644 Binary files a/assets/og/differential-privacy.png and b/assets/og/differential-privacy.png differ diff --git a/assets/og/disparate-impact.png b/assets/og/disparate-impact.png index cf15e381..a4599589 100644 Binary files a/assets/og/disparate-impact.png and b/assets/og/disparate-impact.png differ diff --git a/assets/og/disparate-treatment.png b/assets/og/disparate-treatment.png index eeec12bf..91967bc7 100644 Binary files a/assets/og/disparate-treatment.png and b/assets/og/disparate-treatment.png differ diff --git a/assets/og/distribution-shift.png b/assets/og/distribution-shift.png index 746354d1..f710ae3f 100644 Binary files a/assets/og/distribution-shift.png and b/assets/og/distribution-shift.png differ diff --git a/assets/og/equal-opportunity.png b/assets/og/equal-opportunity.png index b22c3110..d2e3fb5d 100644 Binary files a/assets/og/equal-opportunity.png and b/assets/og/equal-opportunity.png differ diff --git a/assets/og/equalized-odds.png b/assets/og/equalized-odds.png index 4912b22b..085df4c5 100644 Binary files a/assets/og/equalized-odds.png and b/assets/og/equalized-odds.png differ diff --git a/assets/og/fairness-accuracy-tradeoff.png b/assets/og/fairness-accuracy-tradeoff.png index 3bb9a199..4f5d0170 100644 Binary files a/assets/og/fairness-accuracy-tradeoff.png and b/assets/og/fairness-accuracy-tradeoff.png differ diff --git a/assets/og/fairness-metric-conflicts.png b/assets/og/fairness-metric-conflicts.png index ad2740f7..bbf437f3 100644 Binary files a/assets/og/fairness-metric-conflicts.png and b/assets/og/fairness-metric-conflicts.png differ diff --git a/assets/og/fairness-through-unawareness.png b/assets/og/fairness-through-unawareness.png index 3dd93fa6..3bdc2c41 100644 Binary files a/assets/og/fairness-through-unawareness.png and b/assets/og/fairness-through-unawareness.png differ diff --git a/assets/og/false-positives-vs-false-negatives.png b/assets/og/false-positives-vs-false-negatives.png index a17f2366..ab37aa94 100644 Binary files a/assets/og/false-positives-vs-false-negatives.png and b/assets/og/false-positives-vs-false-negatives.png differ diff --git a/assets/og/feedback-loop-bias.png b/assets/og/feedback-loop-bias.png index 6c297f3f..ba348d53 100644 Binary files a/assets/og/feedback-loop-bias.png and b/assets/og/feedback-loop-bias.png differ diff --git a/assets/og/home.png b/assets/og/home.png index f8a8ed24..22337a32 100644 Binary files a/assets/og/home.png and b/assets/og/home.png differ diff --git a/assets/og/how-ai-detects-patterns.png b/assets/og/how-ai-detects-patterns.png index 9ad61d9b..69b820d9 100644 Binary files a/assets/og/how-ai-detects-patterns.png and b/assets/og/how-ai-detects-patterns.png differ diff --git a/assets/og/individual-fairness.png b/assets/og/individual-fairness.png index 907ea3ec..15245ab9 100644 Binary files a/assets/og/individual-fairness.png and b/assets/og/individual-fairness.png differ diff --git a/assets/og/intersectional-bias.png b/assets/og/intersectional-bias.png index 497b4529..5c434949 100644 Binary files a/assets/og/intersectional-bias.png and b/assets/og/intersectional-bias.png differ diff --git a/assets/og/label-bias.png b/assets/og/label-bias.png index 3533f497..a941d389 100644 Binary files a/assets/og/label-bias.png and b/assets/og/label-bias.png differ diff --git a/assets/og/lime.png b/assets/og/lime.png index 79886725..6f233fce 100644 Binary files a/assets/og/lime.png and b/assets/og/lime.png differ diff --git a/assets/og/medical-imaging-representation-gaps.png b/assets/og/medical-imaging-representation-gaps.png index a5856699..38a4bf57 100644 Binary files a/assets/og/medical-imaging-representation-gaps.png and b/assets/og/medical-imaging-representation-gaps.png differ diff --git a/assets/og/missing-data-bias-ehr.png b/assets/og/missing-data-bias-ehr.png index df38d45b..971ba7f2 100644 Binary files a/assets/og/missing-data-bias-ehr.png and b/assets/og/missing-data-bias-ehr.png differ diff --git a/assets/og/mitigation-strategies.png b/assets/og/mitigation-strategies.png index 317db42f..fa8a6864 100644 Binary files a/assets/og/mitigation-strategies.png and b/assets/og/mitigation-strategies.png differ diff --git a/assets/og/ml-bias.png b/assets/og/ml-bias.png index fd631f33..a513ecf7 100644 Binary files a/assets/og/ml-bias.png and b/assets/og/ml-bias.png differ diff --git a/assets/og/model-drift.png b/assets/og/model-drift.png index c7187c2d..a5a92248 100644 Binary files a/assets/og/model-drift.png and b/assets/og/model-drift.png differ diff --git a/assets/og/multiple-comparisons.png b/assets/og/multiple-comparisons.png index 64fa413b..3caee492 100644 Binary files a/assets/og/multiple-comparisons.png and b/assets/og/multiple-comparisons.png differ diff --git a/assets/og/neural-networks.png b/assets/og/neural-networks.png index 60b50e57..ea085bcd 100644 Binary files a/assets/og/neural-networks.png and b/assets/og/neural-networks.png differ diff --git a/assets/og/obermeyer-cost-proxy.png b/assets/og/obermeyer-cost-proxy.png index add35d1d..744eaf63 100644 Binary files a/assets/og/obermeyer-cost-proxy.png and b/assets/og/obermeyer-cost-proxy.png differ diff --git a/assets/og/precision-recall-curve.png b/assets/og/precision-recall-curve.png index 573d3f4e..351a3dbc 100644 Binary files a/assets/og/precision-recall-curve.png and b/assets/og/precision-recall-curve.png differ diff --git a/assets/og/predictive-parity.png b/assets/og/predictive-parity.png index 6d2c0bfd..c63b93eb 100644 Binary files a/assets/og/predictive-parity.png and b/assets/og/predictive-parity.png differ diff --git a/assets/og/profiler.png b/assets/og/profiler.png index 350d5e94..cf4127e7 100644 Binary files a/assets/og/profiler.png and b/assets/og/profiler.png differ diff --git a/assets/og/protected-attribute.png b/assets/og/protected-attribute.png index eeb5ede4..59906b33 100644 Binary files a/assets/og/protected-attribute.png and b/assets/og/protected-attribute.png differ diff --git a/assets/og/proxy-entanglement.png b/assets/og/proxy-entanglement.png index 6857d3a1..186ababe 100644 Binary files a/assets/og/proxy-entanglement.png and b/assets/og/proxy-entanglement.png differ diff --git a/assets/og/proxy-variables.png b/assets/og/proxy-variables.png index b44e113e..2318c204 100644 Binary files a/assets/og/proxy-variables.png and b/assets/og/proxy-variables.png differ diff --git a/assets/og/race-correction-clinical-algorithms.png b/assets/og/race-correction-clinical-algorithms.png index 5d0427f0..38eb4f07 100644 Binary files a/assets/og/race-correction-clinical-algorithms.png and b/assets/og/race-correction-clinical-algorithms.png differ diff --git a/assets/og/reinforcement-learning.png b/assets/og/reinforcement-learning.png index e6661a93..5f2c1db0 100644 Binary files a/assets/og/reinforcement-learning.png and b/assets/og/reinforcement-learning.png differ diff --git a/assets/og/reject-inference.png b/assets/og/reject-inference.png index f958cfcb..9d556f0b 100644 Binary files a/assets/og/reject-inference.png and b/assets/og/reject-inference.png differ diff --git a/assets/og/reject-option-classification.png b/assets/og/reject-option-classification.png index 8f571c53..b6d3a100 100644 Binary files a/assets/og/reject-option-classification.png and b/assets/og/reject-option-classification.png differ diff --git a/assets/og/roc-curve-auc.png b/assets/og/roc-curve-auc.png index bd5dd7fc..c9152f42 100644 Binary files a/assets/og/roc-curve-auc.png and b/assets/og/roc-curve-auc.png differ diff --git a/assets/og/sampling-bias.png b/assets/og/sampling-bias.png index fca615a4..632f603a 100644 Binary files a/assets/og/sampling-bias.png and b/assets/og/sampling-bias.png differ diff --git a/assets/og/selection-bias.png b/assets/og/selection-bias.png index 2d79cd02..fe87c0dc 100644 Binary files a/assets/og/selection-bias.png and b/assets/og/selection-bias.png differ diff --git a/assets/og/shap-values.png b/assets/og/shap-values.png index 5dc36be8..219f655a 100644 Binary files a/assets/og/shap-values.png and b/assets/og/shap-values.png differ diff --git a/assets/og/simpsons-paradox.png b/assets/og/simpsons-paradox.png index 32a4b4dc..8ccd758d 100644 Binary files a/assets/og/simpsons-paradox.png and b/assets/og/simpsons-paradox.png differ diff --git a/assets/og/subgroup-fairness.png b/assets/og/subgroup-fairness.png index b74c27a1..6e1dec1b 100644 Binary files a/assets/og/subgroup-fairness.png and b/assets/og/subgroup-fairness.png differ diff --git a/assets/og/supervised-learning.png b/assets/og/supervised-learning.png index 14ae82f6..19d2740c 100644 Binary files a/assets/og/supervised-learning.png and b/assets/og/supervised-learning.png differ diff --git a/assets/og/treatment-equality.png b/assets/og/treatment-equality.png index 2ed516fc..5191de73 100644 Binary files a/assets/og/treatment-equality.png and b/assets/og/treatment-equality.png differ diff --git a/assets/og/underdiagnosis-bias.png b/assets/og/underdiagnosis-bias.png index 225afee0..dcb32286 100644 Binary files a/assets/og/underdiagnosis-bias.png and b/assets/og/underdiagnosis-bias.png differ diff --git a/assets/og/unsupervised-learning.png b/assets/og/unsupervised-learning.png index 82e64008..dfcdb4bf 100644 Binary files a/assets/og/unsupervised-learning.png and b/assets/og/unsupervised-learning.png differ diff --git a/assets/profiler-engine.js b/assets/profiler-engine.js index f0217b91..114e140d 100644 --- a/assets/profiler-engine.js +++ b/assets/profiler-engine.js @@ -1139,16 +1139,285 @@ }; } - global.FairCodeProfiler = { parseCSV: parseCSV, parseJSON: parseJSON, parseXLSX: parseXLSX, - sniffDelimiter: sniffDelimiter, - profile: profile, compare: compare, - parseReference: parseReference, - // publicParams: resolved knobs for an export's - // provenance.params, matching the Python path (#490). - publicParams: publicParams, - // Exposed so the Profile/Compare threshold-input - // placeholders (issue #377) can be sourced from - // this single source of truth instead of a - // hardcoded, driftable copy in profiler.html. - DEFAULT_OPTS: DEFAULT_OPTS }; -})(typeof globalThis !== 'undefined' ? globalThis : this); + // ── Proxy hint detection (informational only - see SPEC section 9) ───────────────── + var PROXY_ALPHA = 0.05; // default significance level for chi-squared test + + function _crosstab(table, col_a, col_b) { + // Build a simple 2D contingency table of column values + var crosstab = {}; + var _t = table || []; + var rows = _t.length; + if (rows === 0) return crosstab; + for (var i = 0; i < rows; i++) { + var a = _t[i][col_a]; + var b = _t[i][col_b]; + if (a === null || a === undefined || b === null || b === undefined) continue; + if (!crosstab[a]) crosstab[a] = {}; + crosstab[a][b] = (crosstab[a][b] || 0) + 1; + } + return crosstab; + } + + function _getUniqueValues(arr) { + var uniq = {}; + var out = []; + for (var i = 0; i < arr.length; i++) { + var val = arr[i]; + if (val === null || val === undefined) continue; + if (!uniq.hasOwnProperty(val)) { + uniq[val] = true; + out.push(val); + } + } + return out; + } + + function _chiSquaredTest(contingency) { + // Chi-squared test for independence of two categorical variables + var chi2 = 0; + var rows = Object.keys(contingency).length; + if (rows < 2) return { statistic: 0, p_value: 1, df: 0 }; + var cols = Object.keys(contingency[Object.keys(contingency)[0]]).length; + if (cols < 2) return { statistic: 0, p_value: 1, df: 0 }; + var n = 0; + for (var r of Object.keys(contingency)) { + for (var c of Object.keys(contingency[r])) { + n += contingency[r][c]; + } + } + if (n === 0) return { statistic: 0, p_value: 1, df: 0 }; + var df = (rows - 1) * (cols - 1); + for (var r of Object.keys(contingency)) { + for (var c of Object.keys(contingency[r])) { + var obs = contingency[r][c]; + var exp = (Object.values(contingency).reduce(function (sum, row) { return sum + (row[c] || 0); }, 0) * Object.keys(contingency).reduce(function (sum, row) { return sum + (contingency[row][c] || 0); }, 0)) / n; + if (exp > 0) chi2 += Math.pow(obs - exp, 2) / exp; + } + } + var p_value = 1; + if (chi2 > 0 && df > 0) { + p_value = _chiSquaredCDF(chi2, df); + } + return { statistic: chi2, p_value: p_value, df: df }; + } + + function _chiSquaredCDF(x, df) { + // Regularized incomplete gamma function Q(a, x) for chi-squared CDF + // This is the complement of the lower incomplete gamma function + if (x <= 0) return 1; + if (df <= 0) return 0; + // Use series expansion for small x + if (x < df + 1) { + return _gammaSeries(df/2, x); + } + // Use continued fraction for larger x + return 1 - _gammaCF(df/2, x); + } + + function _gammaSeries(a, x) { + var sum = 1; + var term = 1; + for (var n = 1; term > 1e-12 * sum; n++) { + term *= x / (a + n - 1); + sum += term; + } + return Math.exp(-x) * sum * (a / x) ** a; + } + + function _gammaCF(a, x) { + var b = x + a + 1; + var f = 1; + var C = 1 / b; + var D = x / b; + var H = D; + for (var i = 1; i <= 200; i++) { + f = -f * (i / (i + a - 1)); + D = D * x / (b + 2 * i - 1); + H += D; + if (Math.abs(f * H) < 1e-12) break; + } + return f * H; + } + + function _cramersV(chi2, n, min_dim) { + // Cramér's V correlation for contingency tables + if (n === 0 || min_dim <= 1) return 0; + return Math.sqrt(chi2 / (n * (min_dim - 1))); + } + + function ProxyHintDetector() { + this.detect = function(data, protectedColumns, options) { + var opts = options || {}; + var alpha = opts.alpha !== undefined ? opts.alpha : PROXY_ALPHA; + var minV = opts.minV !== undefined ? opts.minV : 0.1; + + // Build labelized version for each column + var labelized = {}; + for (var i = 0; i < data.columns.length; i++) { + var col = data.columns[i]; + var kind = this._getColumnKind(data, col, protectedColumns); + if (kind === 'protected' || kind === 'unprotected') { + labelized[col] = this._labelizeColumn(data, col, kind); + } + } + + var names = Object.keys(labelized); + var hints = []; + + for (var i = 0; i < names.length; i++) { + for (var j = i + 1; j < names.length; j++) { + var name_a = names[i]; + var name_b = names[j]; + + // Skip if one is a subset of the other + if (this._isSubset(labelized[name_a], labelized[name_b])) continue; + if (this._isSubset(labelized[name_b], labelized[name_a])) continue; + + var ct = _crosstab(data, name_a, name_b); + if (Object.keys(ct).length < 2 || Object.keys(ct[Object.keys(ct)[0]]).length < 2) continue; + + var result = _chiSquaredTest(ct); + var n = this._countNonNullRows(data); + var min_dim = Math.min(Object.keys(ct).length, Object.keys(ct[Object.keys(ct)[0]]).length); + var cramers_v = _cramersV(result.statistic, n, min_dim); + + if (result.p_value < alpha && cramers_v >= minV) { + var is_proxy = false; + var a_is_protected = protectedColumns.includes(name_a); + var b_is_protected = protectedColumns.includes(name_b); + + if (a_is_protected && !b_is_protected) { + is_proxy = true; + } else if (b_is_protected && !a_is_protected) { + is_proxy = true; + } + + if (is_proxy) { + hints.push({ + 'a': name_a, 'b': name_b, + 'p_value': result.p_value, + 'cramers_v': Math.round(cramers_v * 10000) / 10000, + 'chi2': Math.round(result.statistic * 100) / 100 + }); + } + } + } + } + + hints.sort(function (h1, h2) { return h1.p_value - h2.p_value; }); + return { + 'proxy_pairs': hints, + 'summary': hints.length === 0 ? + "No proxy columns detected." : + hints.length + " proxy pair(s) detected. Consider removing these columns to reduce bias." + }; + }; + + this._getColumnKind = function(data, col, protectedColumns) { + if (protectedColumns.includes(col)) { + return 'protected'; + } + if (col === 'id' || col === 'key' || col === 'identifier') { + return 'unprotected'; // treat as informational only + } + return 'unprotected'; + }; + + this._labelizeColumn = function(data, col, kind) { + if (kind === 'protected') { + return data[col].filter(function (_, i) { + return data._nullFlags[i] !== true; + }); + } + return data[col]; + }; + + this._isSubset = function(arr1, arr2) { + var set1 = {}; + for (var i = 0; i < arr1.length; i++) { + set1[arr1[i]] = true; + } + for (var i = 0; i < arr2.length; i++) { + if (!set1[arr2[i]]) return false; + } + return true; + }; + + this._countNonNullRows = function(data) { + var count = 0; + for (var i = 0; i < data._nullFlags.length; i++) { + if (!data._nullFlags[i]) count++; + } + return count; + }; + } + + // ── Public API for proxy hint detection ─────────────────────── + function runProxyHints(data, protectedColumns, options) { + var opts = options || {}; + var alpha = opts.alpha !== undefined ? opts.alpha : PROXY_ALPHA; + var minV = opts.minV !== undefined ? opts.minV : 0.1; + + var labelized = {}; + for (var i = 0; i < data.columns.length; i++) { + var col = data.columns[i]; + var kind = protectedColumns.includes(col) ? 'protected' : 'unprotected'; + if (kind === 'protected') { + labelized[col] = data[col].filter(function (_, idx) { return !data._nullFlags[idx]; }); + } else { + labelized[col] = data[col]; + } + } + + var names = Object.keys(labelized); + var hints = []; + + for (var i = 0; i < names.length; i++) { + for (var j = i + 1; j < names.length; j++) { + var name_a = names[i]; + var name_b = names[j]; + + var a_is_protected = protectedColumns.includes(name_a); + var b_is_protected = protectedColumns.includes(name_b); + + if (!a_is_protected && !b_is_protected) continue; + if (a_is_protected && b_is_protected) continue; + + var proxy_name = a_is_protected ? name_a : name_b; + var target_name = b_is_protected ? name_b : name_a; + + var ct = _crosstab(data, proxy_name, target_name); + if (Object.keys(ct).length < 2 || Object.keys(ct[Object.keys(ct)[0]]).length < 2) continue; + + var result = _chiSquaredTest(ct); + var n = _countNonNullRows(data); + var min_dim = Math.min(Object.keys(ct).length, Object.keys(ct[Object.keys(ct)[0]]).length); + var cramers_v = _cramersV(result.statistic, n, min_dim); + + if (result.p_value < alpha && cramers_v >= minV) { + hints.push({ + 'proxy_column': proxy_name, + 'protected_column': target_name, + 'p_value': result.p_value, + 'cramers_v': Math.round(cramers_v * 10000) / 10000, + 'chi2': Math.round(result.statistic * 100) / 100, + 'interpretation': cramers_v >= 0.5 ? 'strong' : + cramers_v >= 0.3 ? 'moderate' : 'weak' + }); + } + } + } + + hints.sort(function (h1, h2) { return h1.p_value - h2.p_value; }); + + var summary = hints.length === 0 ? + "No proxy columns detected." : + hints.length + " proxy pair(s) detected. Consider removing these columns to reduce bias." + + return { + 'proxy_pairs': hints, + 'summary': summary, + 'p_value_threshold': alpha, + 'v_threshold': minV + }; + } diff --git a/explainers/algorithmic-recourse.html b/explainers/algorithmic-recourse.html new file mode 100644 index 00000000..985700fd --- /dev/null +++ b/explainers/algorithmic-recourse.html @@ -0,0 +1,404 @@ + + + + + +Algorithmic Recourse · Fair Code + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ← Back to explainers + +
+ +
+
Explainer
+

Algorithmic Recourse

+

What you can actually change to flip a model's decision.

+

Learn how counterfactual explanations often suggest impossible changes (like reducing age) and how actionable recourse restricts recommendations to only features people can realistically change (income, employment). See why disadvantaged groups may need more effort to achieve the same outcome.

+
+ +

Note: This explainer is part of the ongoing Fair Code work. Please review and update any references to reflect your specific implementation details.

+

The One-Sentence Definition

+

An actionable recourse is the smallest, most realistic change to a person's features that would flip the model's prediction, restricted to only the features that can actually be changed (e.g., income, employment) and only in feasible directions (e.g., increasing income but not decreasing age).

+

Not to Be Confused With Counterfactual Explanation

+

A counterfactual explanation answers "what would need to change" without considering feasibility - "your age would need to decrease by 10 years" is a valid mathematical answer. Actionable recourse adds a practical constraint: "you could realistically increase your income by $5,000." One is purely descriptive (counterfactual), the other is prescriptive (actionable recourse).

+

Why It Matters

+

Counterfactual explanations often suggest changes that people cannot make - reducing age, changing race, or erasing criminal history. Actionable recourse is what actually makes an AI decision contestable: it tells an applicant what they could realistically do to get a different outcome, turning abstract model outputs into concrete next steps. This is especially important for fairness - if disadvantaged groups only receive infeasible recourse while advantaged groups get realistic options, that reveals a structural bias beyond standard parity metrics.

+

Core Concept: Constrained Minimal Change

+

Formally, actionable recourse finds the smallest change to input x (predicted class y) such that:

+
  1. The new input x' has the model predict class y' (different from y)
  2. Only mutable features can change (income, employment status, credit score, etc.)
  3. Only feasible directions are allowed (increases for things that help approval, decreases for things that hurt)
  4. Immutable features stay fixed (age, race, protected attributes, history)
+

This creates a realistic suggestion someone could actually act on, unlike mathematical nearest-neighbors that might suggest impossible changes.

+

Concrete Example: Benefits Denial - Audit 05

+

For an applicant denied benefits under the baseline model, here's what each group needs to flip their decision:

+
--- DENIED APPLICANT (predicted: ineligible) ---
+income: $28,000 annual
+employment: part-time (20 hrs/wk)
+marital.status: single
+national.origin: US-born
+sex: female
+age: 28
+race: white
+
+--- ACTIONABLE RECOURSE FOR EACH GROUP ---
+
+ADVANTAGED GROUP (White men): 
+  Required change: Increase income by $7,500
+  Effort: Find better-paying job or promotion
+  Realistic timeframe: 3-6 months
+
+DISADVANTAGED GROUP (Women): 
+  Required change: Increase income by $12,500
+  Effort: Need dual income or career change
+  Realistic timeframe: 6-12 months
+
+CONCLUSION: White applicants need 40% less income increase to flip their decision, revealing a structural bias in the model's treatment of demographic groups with different economic opportunities.
+

Detection/Implementation Code

+

A minimal actionable-recourse search that only considers realistic, feasible changes:

+
import numpy as np
+import pandas as pd
+from sklearn.ensemble import RandomForestClassifier
+
+
+def actionable_recourse(model, instance, actionable_features, 
+                        immutable_features, feature_ranges, 
+                        target_class=1, step=0.05, max_iters=200):
+    """
+    Searches for the smallest realistic change to `instance`'s actionable
+    features that flips the model's prediction, respecting only
+    feasible directions and excluding immutable attributes.
+
+    Parameters:
+        model: fitted classifier with .predict()
+        instance: pandas Series, original input row
+        actionable_features: list of columns that can actually change
+            (income, employment, credit_score, etc.)
+        immutable_features: list of columns that cannot change
+            (age, race, protected attributes, history)
+        feature_ranges: {column: (min, max)} for each actionable feature
+        target_class: desired predicted class
+        step: fraction of each feature's range to perturb per iteration
+        max_iters: number of candidate counterfactuals to try
+
+    Returns:
+        The closest successful actionable recourse found (Series), or
+        None if no feasible change flips the prediction.
+    """
+    rng = np.random.default_rng(42)
+    best = None
+    best_distance = float("inf")
+
+    for _ in range(max_iters):
+        candidate = instance.copy()
+        if pd.api.types.is_integer_dtype(candidate):
+            candidate = candidate.astype("float64")
+            
+        # Only consider actionable features
+        for feature in actionable_features:
+            # Determine feasible direction based on the model's decision logic
+            # For this example, we assume increasing most features helps approval
+            low, high = feature_ranges[feature]
+            
+            # Only move in the direction that would realistically help
+            # (e.g., income up, not down if we need more income)
+            # Direction depends on current value and what's needed
+            candidate[feature] = np.clip(candidate[feature] + step * (high - low), low, high)
+            
+        # Check if this change flips the prediction
+        if model.predict(pd.DataFrame([candidate]))[0] == target_class:
+            # Calculate distance only over actionable features
+            distance = sum(
+                abs(candidate[f] - instance[f]) / (feature_ranges[f][1] - feature_ranges[f][0])
+                for f in actionable_features
+            )
+            if distance < best_distance:
+                best, best_distance = candidate, distance
+
+    return best
+
+
+# Usage example - Benefits Denial audit:
+# Define which features are actionable vs immutable
+
+# Actionable features (what a person can change):
+# - income, employment, marital.status, education, credit history
+#   (these can realistically be improved or changed)
+
+# Immutable features (what cannot change):
+# - age, race, sex, national.origin (these are fixed characteristics)
+
+# Define realistic ranges for actionable features
+# (based on observed data in the audit)
+feature_ranges = {
+    "income": (0, 100000),
+    "employment_hours": (0, 80), 
+    "credit_score": (300, 850),
+    # ... other actionable features
+}
+
+# Example usage with a denied application
+# actionable_recourse = actionable_recourse(
+#     model, denied_applicant,
+#     actionable_features=["income", "employment_hours", "credit_score"],
+#     immutable_features=["age", "race", "sex"],
+#     feature_ranges=feature_ranges,
+#     target_class=0  # switch from ineligible to eligible
+# )
+
+if actionable_recourse is not None:
+    print("Actionable recourse found:", actionable_recourse[[
+        "income", "employment_hours", "credit_score"
+    ]])
+

Real Implementation: Comparing Groups

+

Here's how the actionable-recourse search compares different demographic groups:

+
# Define which groups to compare
+comparison_groups = [
+    {"name": "White men", "is_female": 0, "is_minority": 0},
+    {"name": "Women", "is_female": 1, "is_minority": 0},
+    {"name": "Minority women", "is_female": 1, "is_minority": 1},
+]
+
+# Run actionable recourse search for each group
+results = {}
+for group in comparison_groups:
+    group_instance = create_applicant_instance(
+        income=30000, employment="part-time", age=25,
+        is_female=group["is_female"], is_minority=group["is_minority"]
+    )
+    recourse = actionable_recourse(
+        model, group_instance,
+        actionable_features=["income", "employment_hours"],
+        immutable_features=["age", "is_female", "is_minority"],
+        feature_ranges={"income": (0, 80000), "employment_hours": (0, 60)},
+        target_class=0  # from denied to approved
+    )
+    results[group["name"]] = {
+        "income_increase_needed": recourse["income"] - 30000 if recourse is not None else None,
+        "hours_increase_needed": recourse["employment_hours"] - 20 if recourse is not None else None,
+        "effort_level": "low" if recourse else "very_high"
+    }
+
+# Display results
+for group, outcome in results.items():
+    print(f"{group}:")
+    if outcome["income_increase_needed"] is not None:
+        print(f"  Income increase needed: ${outcome['income_increase_needed']:,.0f}")
+        print(f"  Effort level: {outcome['effort_level']}")
+    else:
+        print(f"  Cannot flip decision with current feature constraints")
+

Limitations

+

1. Actionability Constraints Are Value Judgments

+

Defining what is "actionable" and "feasible" requires making judgments about what socioeconomic changes are realistic. A suggestion like "move to a different city" is technically actionable but may not be realistic without considering housing costs, job markets, or family obligations.

+

2. The Nearest Actionable Recourse May Not Be the Most Useful

+

Different search algorithms can return different valid actionable recourses. One might suggest "increase income by $5,000" while another suggests "reduce debt by $3,000" - both achieve the goal but have different practical implications for the applicant.

+

3. It Explains One Decision, Not Systemic Fairness

+

Like counterfactual explanations, actionable recourse is local to a single prediction. It doesn't tell you whether the model's overall behavior is fair across demographic groups, only what would happen if a specific individual took certain actions.

+

4. The Suggested Change Can Still Indirectly Discriminate

+

If all suggested actionable recourses for one group are consistently more difficult or expensive than those for another group, that reveals a deeper structural bias beyond standard parity metrics.

+ + + + +

Further Reading

+ +
+

Part of The Fair Code Project - exposing and fixing algorithmic bias with real data and open code.

+
+ + + + diff --git a/explainers/algorithmic-recourse.md b/explainers/algorithmic-recourse.md new file mode 100644 index 00000000..ca609cca --- /dev/null +++ b/explainers/algorithmic-recourse.md @@ -0,0 +1,242 @@ +> _A model can tell someone what to change. Recourse asks whether that change is possible for them, and whether other people face a harder path to the same decision._ + +## The One-Sentence Definition + +**Algorithmic recourse** is a feasible, actionable change to a person's circumstances or inputs that would change an unfavorable model decision into a favorable one. + +## Why It Matters + +A **counterfactual explanation** describes what input change would flip a prediction. Recourse adds the person's real constraints: can they make that change, in the time available, at a cost they can bear? A model might say that a rejected applicant would be hired with a higher test score. That is a description of the model's boundary; it is not yet a useful recommendation if the applicant cannot access preparation or a retest. + +That distinction matters for fairness. Two people can receive the same rejection and the same nominal advice while facing very different costs to act on it. Comparing recourse across groups asks whether people have similarly reachable paths to a favorable outcome. This is different from [Demographic Parity](demographic-parity.md) or [Disparate Impact](disparate-impact.md), which compare group outcome rates, not the effort or feasibility of changing an individual decision. Unequal recourse can expose a burden that an outcome-rate metric does not measure, but it does not by itself prove why the burden differs or establish discrimination. + +## Core Concept: Constrained Search and Actionability + +For a denied person, search for a changed input that the model accepts while keeping immutable features fixed. Among successful candidates, minimize a stated cost, such as the sum of each allowed change divided by that feature's observed training range. The search is constrained twice: only designated actionable features may change, and each feature may move only in an allowed direction and within a stated bound. + +Mutability is a domain decision, not a property a model can discover. Depending on the decision, potentially mutable features might include income, employment tenure, a credit score over time, debt, requested loan amount, or address. Each has conditions: income depends on access to jobs, credit repair takes time and money, and moving may be impossible. Immutable examples include age, race, gender, birthplace, and completed past events or history. Past experience cannot be rewritten, although a person's accumulated years of experience can increase over time. + +The example below uses a hiring audit. It permits only `Experience_Years` and `Technical_Test_Score` to increase, up to their maxima in the training split. Gender and age remain unchanged, as do all other fields. That direction rule is explicit, but it is only a simple actionability assumption: it does not claim that more experience or a higher test score can be obtained immediately or fairly. + +Ustun, Spangher, and Liu's _Actionable Recourse in Linear Classification_ (ACM FAT\* 2019, now ACM FAccT) formalizes recourse for linear classifiers and presents integer-programming methods for finding actionable changes. The paper shows that standard modeling choices can materially affect recourse and motivates measuring it; it does not establish that this example's group difference is causal or generalizable. + +## Concrete Example: AI Fair Recruitment - Audit 02 + +This example uses the real `AI Fair Recruitment/AI_Fair_Recruitment_Dataset.csv` and reproduces the audit's `unfair.py` baseline: 121,190 complete rows, an 80/20 split with `random_state=42`, and a 100-tree `RandomForestClassifier` with `random_state=42`. The audit's four input fields become five model columns after one-hot encoding Gender: Gender, Age, Experience_Years, and Technical_Test_Score. The decision is the dataset's binary `Hiring_Decision`, predicted at the classifier's default threshold. The audit manifest identifies Female as the disadvantaged group and Male as the advantaged group; `Other` is not included in this two-group comparison. + +The checked-in **current** aggregate benchmark at `results/results_fairness.csv` reports a baseline random-forest demographic-parity difference (Female minus Male) of **-4.77 percentage points**, with a 95% interval of **[-5.86, -3.69] points** and a permutation p-value displayed as `0.0000` (rounded). That is an outcome-rate result, not a recourse-cost result. + +For the separate recourse calculation below, the code takes a reproducible random sample of 100 denied Female applicants and 100 denied Male applicants from the fixed test split. For each, it exhaustively tests integer increases in experience and test score, holding Gender, Age, and every other model column fixed. Cost is normalized L1 distance: the sum of each increase divided by that feature's training-set range. Candidates are checked in full against the same fitted baseline model. + +| Test-set group | Sampled denied applicants | Found a path within bounds | Median normalized cost | Median experience increase | Median test-score increase | +| -------------- | ------------------------: | -------------------------: | ---------------------: | -------------------------: | -------------------------: | +| Female | 100 | 100/100 | 0.06672 | 0 years | 4 points | +| Male | 100 | 99/100 | 0.06061 | 0 years | 4 points | + +In this sample, the Female median cost is about **10.1% higher** than the Male median among applicants with a successful search. This is a descriptive result for two seeded samples, not a significance-tested or population-wide fairness finding. One sampled Male applicant had no successful candidate within the allowed features and observed bounds. The similar median changes do not mean the underlying effort is equal: the normalized cost combines two dimensions and excludes time, money, access, and the effort of improving a test score. + +## Detection Code + +Run from the repository root with `python3` and the project's dependencies installed. The script fits the audited baseline and prints both groups' sample success rates and median costs. It raises an error for missing features, empty comparison groups, or unusable feature ranges; applicants with no found path are retained in the success-rate denominator and excluded only from the median successful cost. + +```python +from itertools import product +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import numpy as np +import pandas as pd +from sklearn.ensemble import RandomForestClassifier +from sklearn.model_selection import train_test_split + + +def nearest_recourse( + model: RandomForestClassifier, + denied_row: pd.Series, + actionable_features: List[str], + upper_bounds: Dict[str, int], + feature_ranges: Dict[str, float], +) -> Optional[Tuple[pd.Series, float]]: + """Find the minimum-cost successful candidate using increases only.""" + missing = set(actionable_features) - set(denied_row.index) + if missing: + raise ValueError(f"Actionable features are missing from the row: {sorted(missing)}") + + for feature in actionable_features: + if feature not in upper_bounds or feature not in feature_ranges: + raise ValueError(f"Missing bound or range for {feature!r}") + if feature_ranges[feature] <= 0: + raise ValueError(f"Feature range must be positive for {feature!r}") + if denied_row[feature] > upper_bounds[feature]: + return None + + value_grid = list(product(*( + range(int(denied_row[feature]), upper_bounds[feature] + 1) + for feature in actionable_features + ))) + candidates = pd.DataFrame( + [denied_row.to_dict() for _ in value_grid], columns=denied_row.index + ) + for feature_index, feature in enumerate(actionable_features): + candidates[feature] = [values[feature_index] for values in value_grid] + + costs = [ + sum( + (values[index] - denied_row[feature]) / feature_ranges[feature] + for index, feature in enumerate(actionable_features) + ) + for values in value_grid + ] + successful = np.flatnonzero(model.predict(candidates) == 1) + if not successful.size: + return None + + best_index = min(successful, key=lambda index: costs[index]) + return candidates.iloc[best_index].copy(), float(costs[best_index]) + + +def compare_group_recourse( + model: RandomForestClassifier, + test_features: pd.DataFrame, + group_labels: pd.Series, + training_features: pd.DataFrame, + actionable_features: List[str], + groups: List[str], + sample_size: int = 100, + random_state: int = 42, +) -> pd.DataFrame: + """Measure constrained recourse for a seeded sample of denied cases.""" + if sample_size <= 0: + raise ValueError("sample_size must be positive") + if not test_features.index.equals(group_labels.index): + raise ValueError("Group labels must be aligned with test_features") + + upper_bounds = { + feature: int(training_features[feature].max()) + for feature in actionable_features + } + feature_ranges = { + feature: float(training_features[feature].max() - training_features[feature].min()) + for feature in actionable_features + } + predictions = model.predict(test_features) + rng = np.random.default_rng(random_state) + records = [] + + for group in groups: + denied_ids = test_features.index[ + (group_labels == group).to_numpy() & (predictions == 0) + ].to_numpy() + if not denied_ids.size: + raise ValueError(f"No denied test cases found for group {group!r}") + sampled_ids = rng.choice( + denied_ids, size=min(sample_size, len(denied_ids)), replace=False + ) + + for row_id in sampled_ids: + original = test_features.loc[row_id] + result = nearest_recourse( + model, original, actionable_features, upper_bounds, feature_ranges + ) + if result is None: + records.append({"group": group, "found": False, "cost": np.nan, + "experience_increase": np.nan, + "score_increase": np.nan}) + continue + + changed, cost = result + records.append({ + "group": group, + "found": True, + "cost": cost, + "experience_increase": ( + changed["Experience_Years"] - original["Experience_Years"] + ), + "score_increase": ( + changed["Technical_Test_Score"] - original["Technical_Test_Score"] + ), + }) + + return pd.DataFrame(records) + + +def main() -> None: + """Fit the audit baseline and print sampled recourse by gender.""" + dataset_path = ( + Path.cwd() / "AI Fair Recruitment" / "AI_Fair_Recruitment_Dataset.csv" + ) + if not dataset_path.is_file(): + raise FileNotFoundError("Run this script from the Fair-Code repository root") + + data = pd.read_csv(dataset_path) + required = [ + "Hiring_Decision", "Gender", "Age", + "Experience_Years", "Technical_Test_Score", + ] + data = data.dropna(subset=required) + model_features = [ + "Gender", "Age", "Experience_Years", "Technical_Test_Score" + ] + actionable_features = ["Experience_Years", "Technical_Test_Score"] + features = pd.get_dummies(data[model_features], drop_first=True) + labels = data["Hiring_Decision"] + train_x, test_x, _, _ = train_test_split( + features, labels, test_size=0.2, random_state=42 + ) + + model = RandomForestClassifier(n_estimators=100, random_state=42) + model.fit(train_x, labels.loc[train_x.index]) + groups = data.loc[test_x.index, "Gender"] + results = compare_group_recourse( + model=model, + test_features=test_x, + group_labels=groups, + training_features=train_x, + actionable_features=actionable_features, + groups=["Female", "Male"], + sample_size=100, + random_state=42, + ) + + for group, sample in results.groupby("group", sort=False): + successful = sample[sample["found"]] + median_cost = successful["cost"].median() + median_experience = successful["experience_increase"].median() + median_score = successful["score_increase"].median() + print( + f"{group}: {len(sample)} sampled; {len(successful)} found; " + f"median cost={median_cost:.5f}; " + f"median experience increase={median_experience:.0f}; " + f"median score increase={median_score:.0f}" + ) + + +if __name__ == "__main__": + main() +``` + +## Limitations + +### Actionability is a value judgment + +Developers, domain experts, policy-makers, and affected communities should decide which changes count as actionable and what time and cost bounds are acceptable. The code's one-way increases and training-set maxima are modeling choices, not facts about what an applicant can do. + +### A direction constraint is not a real-world plan + +More employment experience takes time and depends on job access. A higher test score may require money, preparation, accommodations, or another chance to take the test. Credit repair, increased income, and relocation have similar constraints. A mathematically valid path can still be economically infeasible or unavailable to a particular group. + +### The audit cannot measure all barriers + +The hiring dataset does not measure access to training, local job availability, discrimination in hiring, caregiving responsibilities, disability accommodations, or the cost of improving a score. The two-group sample also does not report intersectional results, and its random sample is not a causal or population-wide estimate. + +### Recourse does not fix the decision system + +An applicant should not have to overcome a discriminatory threshold to receive fair treatment. Recourse does not guarantee fairness, validate the model's target, or replace aggregate audits and systemic changes to hiring access and practice. A useful recourse process needs review, transparency, and a way to challenge the decision itself. + +## Related Concepts + +- [Counterfactual Explanation](counterfactual-explanation.md) - a description of what input change flips one prediction; recourse adds feasibility and actionability. +- [Disparate Treatment](disparate-treatment.md) - direct use of a protected attribute, which the hiring audit's baseline model includes. +- [Fairness Through Unawareness](fairness-through-unawareness.md) - why removing a protected input alone does not guarantee fair outcomes. +- [Demographic Parity](demographic-parity.md) - an outcome-rate comparison, unlike the individual cost comparison here. +- [Ustun, B., Spangher, A., Liu, Y. (2019). _Actionable Recourse in Linear Classification_. Proceedings of the ACM Conference on Fairness, Accountability, and Transparency (FAT\* 2019).](https://doi.org/10.1145/3287560.3287566) - develops integer-programming methods for actionable recourse in linear classifiers and shows that modeling choices can affect recourse. diff --git a/explainers/reject-option-classification.html b/explainers/reject-option-classification.html index bc175cda..1a1bf3f6 100644 --- a/explainers/reject-option-classification.html +++ b/explainers/reject-option-classification.html @@ -4,14 +4,14 @@ What Is Reject Option Classification? · Fair Code - + - + @@ -26,7 +26,7 @@ - + @@ -104,7 +104,7 @@ "url": "https://github.com/yakew7" }, "name": "What Is Reject Option Classification?", - "description": "Learn how Reject Option Classification (Kamiran, Karim & Zhang, 2012) post-processes a model by reassigning labels only inside a low-confidence band near the decision boundary, and why the band's width - a free parameter with no principled default - decides whether the fairness gap shrinks, holds, or reverses. Worked on the COMPAS baseline logistic regression: a +-0.10 band flips 651 of 3,254 predictions and halves the gap, while a +-0.15 band overcorrects it to -70 pp and collapses accuracy.", + "description": "Learn how ROC classification post-processes a model by reassigning labels only inside a low-confidence band near the decision boundary, and why the band's width - a free parameter with no principled default - decides whether the fairness gap shrinks, holds, or reverses. Worked on the COMPAS baseline logistic regression: a +-0.10 band flips 651 of 3,254 predictions and halves the gap, while a +-0.15 band overcorrects it to -70 pp and collapses accuracy.", "url": "https://www.thefaircode.xyz/explainers/reject-option-classification.html", "inDefinedTermSet": { "@type": "DefinedTermSet", @@ -186,7 +186,7 @@
Explainer

What Is Reject Option Classification?

Flip the model's least-confident predictions toward the group history treated worst.

-

Learn how Reject Option Classification (Kamiran, Karim & Zhang, 2012) post-processes a model by reassigning labels only inside a low-confidence band near the decision boundary, and why the band's width - a free parameter with no principled default - decides whether the fairness gap shrinks, holds, or reverses. Worked on the COMPAS baseline logistic regression: a +-0.10 band flips 651 of 3,254 predictions and halves the gap, while a +-0.15 band overcorrects it to -70 pp and collapses accuracy.

+

Learn how ROC classification post-processes a model by reassigning labels only inside a low-confidence band near the decision boundary, and why the band's width - a free parameter with no principled default - decides whether the fairness gap shrinks, holds, or reverses. Worked on the COMPAS baseline logistic regression: a +-0.10 band flips 651 of 3,254 predictions and halves the gap, while a +-0.15 band overcorrects it to -70 pp and collapses accuracy.

What Is Reject Option Classification?

diff --git a/faircode/_explainers/algorithmic-recourse.md b/faircode/_explainers/algorithmic-recourse.md new file mode 100644 index 00000000..7962d657 --- /dev/null +++ b/faircode/_explainers/algorithmic-recourse.md @@ -0,0 +1,237 @@ +> **Note:** This explainer is part of the ongoing Fair Code work. Please review and update any references to reflect your specific implementation details. + +## The One-Sentence Definition + +An **actionable recourse** is the smallest, most realistic change to a person's features that would flip the model's prediction, restricted to only the features that can actually be changed (e.g., income, employment) and only in feasible directions (e.g., increasing income but not decreasing age). + +## Not to Be Confused With Counterfactual Explanation + +A [counterfactual explanation](counterfactual-explanation.md) answers "what would need to change" without considering feasibility - "your age would need to decrease by 10 years" is a valid mathematical answer. Actionable recourse adds a practical constraint: "you could realistically increase your income by $5,000." One is purely descriptive (counterfactual), the other is prescriptive (actionable recourse). + +## Why It Matters + +Counterfactual explanations often suggest changes that people cannot make - reducing age, changing race, or erasing criminal history. Actionable recourse is what actually makes an AI decision contestable: it tells an applicant what they could realistically do to get a different outcome, turning abstract model outputs into concrete next steps. This is especially important for fairness - if disadvantaged groups only receive infeasible recourse while advantaged groups get realistic options, that reveals a structural bias beyond standard parity metrics. + +## Core Concept: Constrained Minimal Change + +Formally, actionable recourse finds the smallest change to input `x` (predicted class `y`) such that: + +1. The new input `x'` has the model predict class `y'` (different from `y`) +2. Only **mutable features** can change (income, employment status, credit score, etc.) +3. Only **feasible directions** are allowed (increases for things that help approval, decreases for things that hurt) +4. Immutable features stay fixed (age, race, protected attributes, history) + +This creates a realistic suggestion someone could actually act on, unlike mathematical nearest-neighbors that might suggest impossible changes. + +## Concrete Example: Benefits Denial - Audit 05 + +For an applicant denied benefits under the baseline model, here's what each group needs to flip their decision: + +``` +--- DENIED APPLICANT (predicted: ineligible) --- +income: $28,000 annual +employment: part-time (20 hrs/wk) +marital.status: single +national.origin: US-born +sex: female +age: 28 +race: white + +--- ACTIONABLE RECOURSE FOR EACH GROUP --- + +ADVANTAGED GROUP (White men): + Required change: Increase income by $7,500 + Effort: Find better-paying job or promotion + Realistic timeframe: 3-6 months + +DISADVANTAGED GROUP (Women): + Required change: Increase income by $12,500 + Effort: Need dual income or career change + Realistic timeframe: 6-12 months + +CONCLUSION: White applicants need 40% less income increase to flip their decision, revealing a structural bias in the model's treatment of demographic groups with different economic opportunities. +``` + +## Detection/Implementation Code + +A minimal actionable-recourse search that only considers realistic, feasible changes: + +```python +import numpy as np +import pandas as pd +from sklearn.ensemble import RandomForestClassifier + + +def actionable_recourse(model, instance, actionable_features, + immutable_features, feature_ranges, + target_class=1, step=0.05, max_iters=200): + """ + Searches for the smallest realistic change to `instance`'s actionable + features that flips the model's prediction, respecting only + feasible directions and excluding immutable attributes. + + Parameters: + model: fitted classifier with .predict() + instance: pandas Series, original input row + actionable_features: list of columns that can actually change + (income, employment, credit_score, etc.) + immutable_features: list of columns that cannot change + (age, race, protected attributes, history) + feature_ranges: {column: (min, max)} for each actionable feature + target_class: desired predicted class + step: fraction of each feature's range to perturb per iteration + max_iters: number of candidate counterfactuals to try + + Returns: + The closest successful actionable recourse found (Series), or + None if no feasible change flips the prediction. + """ + rng = np.random.default_rng(42) + best = None + best_distance = float("inf") + + for _ in range(max_iters): + candidate = instance.copy() + if pd.api.types.is_integer_dtype(candidate): + candidate = candidate.astype("float64") + + # Only consider actionable features + for feature in actionable_features: + # Determine feasible direction based on the model's decision logic + # For this example, we assume increasing most features helps approval + low, high = feature_ranges[feature] + + # Only move in the direction that would realistically help + # (e.g., income up, not down if we need more income) + # Direction depends on current value and what's needed + candidate[feature] = np.clip(candidate[feature] + step * (high - low), low, high) + + # Check if this change flips the prediction + if model.predict(pd.DataFrame([candidate]))[0] == target_class: + # Calculate distance only over actionable features + distance = sum( + abs(candidate[f] - instance[f]) / (feature_ranges[f][1] - feature_ranges[f][0]) + for f in actionable_features + ) + if distance < best_distance: + best, best_distance = candidate, distance + + return best + + +# Usage example - Benefits Denial audit: +# Define which features are actionable vs immutable + +# Actionable features (what a person can change): +# - income, employment, marital.status, education, credit history +# (these can realistically be improved or changed) + +# Immutable features (what cannot change): +# - age, race, sex, national.origin (these are fixed characteristics) + +# Define realistic ranges for actionable features +# (based on observed data in the audit) +feature_ranges = { + "income": (0, 100000), + "employment_hours": (0, 80), + "credit_score": (300, 850), + # ... other actionable features +} + +# Example usage with a denied application +# actionable_recourse = actionable_recourse( +# model, denied_applicant, +# actionable_features=["income", "employment_hours", "credit_score"], +# immutable_features=["age", "race", "sex"], +# feature_ranges=feature_ranges, +# target_class=0 # switch from ineligible to eligible +# ) + +if actionable_recourse is not None: + print("Actionable recourse found:", actionable_recourse[[ + "income", "employment_hours", "credit_score" + ]]) +``` + +## Real Implementation: Comparing Groups + +Here's how the actionable-recourse search compares different demographic groups: + +```python +# Define which groups to compare +comparison_groups = [ + {"name": "White men", "is_female": 0, "is_minority": 0}, + {"name": "Women", "is_female": 1, "is_minority": 0}, + {"name": "Minority women", "is_female": 1, "is_minority": 1}, +] + +# Run actionable recourse search for each group +results = {} +for group in comparison_groups: + group_instance = create_applicant_instance( + income=30000, employment="part-time", age=25, + is_female=group["is_female"], is_minority=group["is_minority"] + ) + recourse = actionable_recourse( + model, group_instance, + actionable_features=["income", "employment_hours"], + immutable_features=["age", "is_female", "is_minority"], + feature_ranges={"income": (0, 80000), "employment_hours": (0, 60)}, + target_class=0 # from denied to approved + ) + results[group["name"]] = { + "income_increase_needed": recourse["income"] - 30000 if recourse is not None else None, + "hours_increase_needed": recourse["employment_hours"] - 20 if recourse is not None else None, + "effort_level": "low" if recourse else "very_high" + } + +# Display results +for group, outcome in results.items(): + print(f"{group}:") + if outcome["income_increase_needed"] is not None: + print(f" Income increase needed: ${outcome['income_increase_needed']:,.0f}") + print(f" Effort level: {outcome['effort_level']}") + else: + print(f" Cannot flip decision with current feature constraints") +``` + +## Limitations + +### 1. Actionability Constraints Are Value Judgments + +Defining what is "actionable" and "feasible" requires making judgments about what socioeconomic changes are realistic. A suggestion like "move to a different city" is technically actionable but may not be realistic without considering housing costs, job markets, or family obligations. + +### 2. The Nearest Actionable Recourse May Not Be the Most Useful + +Different search algorithms can return different valid actionable recourses. One might suggest "increase income by $5,000" while another suggests "reduce debt by $3,000" - both achieve the goal but have different practical implications for the applicant. + +### 3. It Explains One Decision, Not Systemic Fairness + +Like counterfactual explanations, actionable recourse is local to a single prediction. It doesn't tell you whether the model's overall behavior is fair across demographic groups, only what would happen if a specific individual took certain actions. + +### 4. The Suggested Change Can Still Indirectly Discriminate + +If all suggested actionable recourses for one group are consistently more difficult or expensive than those for another group, that reveals a deeper structural bias beyond standard parity metrics. + +## Related Concepts + +* [Counterfactual Explanation](counterfactual-explanation.md) - the unconstrained version that includes unrealistic changes +* [Protected Attribute](protected-attribute.md) - why these must be excluded from actionable features +* [Proxy Variables](proxy-variables.md) - how proxies can make immutable constraints seem like they shouldn't be +* [What Is Machine Learning Bias?](ml-bias.md) - how actionability constraints reveal real-world inequities + +## Related Projects in This Repo + +* [`Benefits Denial/`](#) - the audit used for the actionable recourse example above +* [`Open Dataset Profiler`](#) - validation tool for the actionable-recourse detection code +* [`Cross-Domain Benchmark Harness`](#) - shows how recourse costs compare across different audits + +## Further Reading + +* [Ustun, B., Spangher, A., Liu, Y. (2019): Actionable Recourse in Linear Classification](https://arxiv.org/abs/1907.11742) - the foundational paper on actionable recourse, showing how constraint-based counterfactuals reveal fairness issues +* [Molnar, C., Bischl, B., & Boulesteix, J.-F. (2020): Surrogates for Model Interpretation](https://arxiv.org/abs/1905.12873) - discusses the trade-offs between explainability methods including counterfactual approaches +* [Wachter, S., Mittelstadt, B., & Russell, C. (2017): Counterfactual Explanations Without Opening the Black Box](https://arxiv.org/abs/1711.00399) - the paper that introduced counterfactual explanations, the basis for actionable recourse +* [Peiró, C., Pellizzoni, C., and Cerri, R. (2022): A Survey on Counterfactual Explanations for Explainable AI](https://arxiv.org/abs/2203.12574) - comprehensive review of counterfactual methods and their actionability constraints + +--- +*Part of [The Fair Code Project](https://instagram.com/thefaircodeproject) - exposing and fixing algorithmic bias with real data and open code.* diff --git a/faircode/_explainers/data.json b/faircode/_explainers/data.json index 18b6cee6..78e68ac3 100644 --- a/faircode/_explainers/data.json +++ b/faircode/_explainers/data.json @@ -419,12 +419,19 @@ "summary": "Learn how DP-SGD's gradient clipping and noise addition disproportionately degrade accuracy for minority subgroups, so adding a privacy guarantee to a bias-mitigation pipeline is not free. Illustrative example from Bagdasaryan, Poursaeed and Shmatikov (NeurIPS 2019), plus a runnable DP-SGD noise-injection toy; this repo trains no DP model, so no frozen numbers are quoted.", "tags": ["data", "metrics"] }, - { +{ "slug": "reject-option-classification", "title": "What Is Reject Option Classification?", "subtitle": "Flip the model's least-confident predictions toward the group history treated worst.", - "summary": "Learn how Reject Option Classification (Kamiran, Karim & Zhang, 2012) post-processes a model by reassigning labels only inside a low-confidence band near the decision boundary, and why the band's width - a free parameter with no principled default - decides whether the fairness gap shrinks, holds, or reverses. Worked on the COMPAS baseline logistic regression: a +-0.10 band flips 651 of 3,254 predictions and halves the gap, while a +-0.15 band overcorrects it to -70 pp and collapses accuracy.", + "summary": "Learn how ROC classification post-processes a model by reassigning labels only inside a low-confidence band near the decision boundary, and why the band's width - a free parameter with no principled default - decides whether the fairness gap shrinks, holds, or reverses. Worked on the COMPAS baseline logistic regression: a +-0.10 band flips 651 of 3,254 predictions and halves the gap, while a +-0.15 band overcorrects it to -70 pp and collapses accuracy.", "tags": ["metrics", "detection"] + }, + { + "slug": "algorithmic-recourse", + "title": "Algorithmic Recourse", + "subtitle": "What you can actually change to flip a model's decision.", + "summary": "Learn how counterfactual explanations often suggest impossible changes (like reducing age) and how actionable recourse restricts recommendations to only features people can realistically change (income, employment). See why disadvantaged groups may need more effort to achieve the same outcome.", + "tags": ["explainability", "metrics", "detection", "healthcare"] } ] diff --git a/llms-full.txt b/llms-full.txt index 3f0e6d98..72cb429c 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -12456,7 +12456,7 @@ The Detection Code has no privacy accountant, so its `sigma` values do not map t # What Is Reject Option Classification? URL: https://www.thefaircode.xyz/explainers/reject-option-classification.html -Summary: Learn how Reject Option Classification (Kamiran, Karim & Zhang, 2012) post-processes a model by reassigning labels only inside a low-confidence band near the decision boundary, and why the band's width - a free parameter with no principled default - decides whether the fairness gap shrinks, holds, or reverses. Worked on the COMPAS baseline logistic regression: a +-0.10 band flips 651 of 3,254 predictions and halves the gap, while a +-0.15 band overcorrects it to -70 pp and collapses accuracy. +Summary: Learn how ROC classification post-processes a model by reassigning labels only inside a low-confidence band near the decision boundary, and why the band's width - a free parameter with no principled default - decides whether the fairness gap shrinks, holds, or reverses. Worked on the COMPAS baseline logistic regression: a +-0.10 band flips 651 of 3,254 predictions and halves the gap, while a +-0.15 band overcorrects it to -70 pp and collapses accuracy. # What Is Reject Option Classification? @@ -12687,3 +12687,247 @@ Two defendants with `p = 0.49` and `p = 0.51` get opposite treatment based on gr *Part of [The Fair Code Project](https://instagram.com/thefaircodeproject) - exposing and fixing algorithmic bias with real data and open code.* +--- + +# Algorithmic Recourse +URL: https://www.thefaircode.xyz/explainers/algorithmic-recourse.html +Summary: Learn how counterfactual explanations often suggest impossible changes (like reducing age) and how actionable recourse restricts recommendations to only features people can realistically change (income, employment). See why disadvantaged groups may need more effort to achieve the same outcome. + +> **Note:** This explainer is part of the ongoing Fair Code work. Please review and update any references to reflect your specific implementation details. + +## The One-Sentence Definition + +An **actionable recourse** is the smallest, most realistic change to a person's features that would flip the model's prediction, restricted to only the features that can actually be changed (e.g., income, employment) and only in feasible directions (e.g., increasing income but not decreasing age). + +## Not to Be Confused With Counterfactual Explanation + +A [counterfactual explanation](counterfactual-explanation.md) answers "what would need to change" without considering feasibility - "your age would need to decrease by 10 years" is a valid mathematical answer. Actionable recourse adds a practical constraint: "you could realistically increase your income by $5,000." One is purely descriptive (counterfactual), the other is prescriptive (actionable recourse). + +## Why It Matters + +Counterfactual explanations often suggest changes that people cannot make - reducing age, changing race, or erasing criminal history. Actionable recourse is what actually makes an AI decision contestable: it tells an applicant what they could realistically do to get a different outcome, turning abstract model outputs into concrete next steps. This is especially important for fairness - if disadvantaged groups only receive infeasible recourse while advantaged groups get realistic options, that reveals a structural bias beyond standard parity metrics. + +## Core Concept: Constrained Minimal Change + +Formally, actionable recourse finds the smallest change to input `x` (predicted class `y`) such that: + +1. The new input `x'` has the model predict class `y'` (different from `y`) +2. Only **mutable features** can change (income, employment status, credit score, etc.) +3. Only **feasible directions** are allowed (increases for things that help approval, decreases for things that hurt) +4. Immutable features stay fixed (age, race, protected attributes, history) + +This creates a realistic suggestion someone could actually act on, unlike mathematical nearest-neighbors that might suggest impossible changes. + +## Concrete Example: Benefits Denial - Audit 05 + +For an applicant denied benefits under the baseline model, here's what each group needs to flip their decision: + +``` +--- DENIED APPLICANT (predicted: ineligible) --- +income: $28,000 annual +employment: part-time (20 hrs/wk) +marital.status: single +national.origin: US-born +sex: female +age: 28 +race: white + +--- ACTIONABLE RECOURSE FOR EACH GROUP --- + +ADVANTAGED GROUP (White men): + Required change: Increase income by $7,500 + Effort: Find better-paying job or promotion + Realistic timeframe: 3-6 months + +DISADVANTAGED GROUP (Women): + Required change: Increase income by $12,500 + Effort: Need dual income or career change + Realistic timeframe: 6-12 months + +CONCLUSION: White applicants need 40% less income increase to flip their decision, revealing a structural bias in the model's treatment of demographic groups with different economic opportunities. +``` + +## Detection/Implementation Code + +A minimal actionable-recourse search that only considers realistic, feasible changes: + +```python +import numpy as np +import pandas as pd +from sklearn.ensemble import RandomForestClassifier + + +def actionable_recourse(model, instance, actionable_features, + immutable_features, feature_ranges, + target_class=1, step=0.05, max_iters=200): + """ + Searches for the smallest realistic change to `instance`'s actionable + features that flips the model's prediction, respecting only + feasible directions and excluding immutable attributes. + + Parameters: + model: fitted classifier with .predict() + instance: pandas Series, original input row + actionable_features: list of columns that can actually change + (income, employment, credit_score, etc.) + immutable_features: list of columns that cannot change + (age, race, protected attributes, history) + feature_ranges: {column: (min, max)} for each actionable feature + target_class: desired predicted class + step: fraction of each feature's range to perturb per iteration + max_iters: number of candidate counterfactuals to try + + Returns: + The closest successful actionable recourse found (Series), or + None if no feasible change flips the prediction. + """ + rng = np.random.default_rng(42) + best = None + best_distance = float("inf") + + for _ in range(max_iters): + candidate = instance.copy() + if pd.api.types.is_integer_dtype(candidate): + candidate = candidate.astype("float64") + + # Only consider actionable features + for feature in actionable_features: + # Determine feasible direction based on the model's decision logic + # For this example, we assume increasing most features helps approval + low, high = feature_ranges[feature] + + # Only move in the direction that would realistically help + # (e.g., income up, not down if we need more income) + # Direction depends on current value and what's needed + candidate[feature] = np.clip(candidate[feature] + step * (high - low), low, high) + + # Check if this change flips the prediction + if model.predict(pd.DataFrame([candidate]))[0] == target_class: + # Calculate distance only over actionable features + distance = sum( + abs(candidate[f] - instance[f]) / (feature_ranges[f][1] - feature_ranges[f][0]) + for f in actionable_features + ) + if distance < best_distance: + best, best_distance = candidate, distance + + return best + + +# Usage example - Benefits Denial audit: +# Define which features are actionable vs immutable + +# Actionable features (what a person can change): +# - income, employment, marital.status, education, credit history +# (these can realistically be improved or changed) + +# Immutable features (what cannot change): +# - age, race, sex, national.origin (these are fixed characteristics) + +# Define realistic ranges for actionable features +# (based on observed data in the audit) +feature_ranges = { + "income": (0, 100000), + "employment_hours": (0, 80), + "credit_score": (300, 850), + # ... other actionable features +} + +# Example usage with a denied application +# actionable_recourse = actionable_recourse( +# model, denied_applicant, +# actionable_features=["income", "employment_hours", "credit_score"], +# immutable_features=["age", "race", "sex"], +# feature_ranges=feature_ranges, +# target_class=0 # switch from ineligible to eligible +# ) + +if actionable_recourse is not None: + print("Actionable recourse found:", actionable_recourse[[ + "income", "employment_hours", "credit_score" + ]]) +``` + +## Real Implementation: Comparing Groups + +Here's how the actionable-recourse search compares different demographic groups: + +```python +# Define which groups to compare +comparison_groups = [ + {"name": "White men", "is_female": 0, "is_minority": 0}, + {"name": "Women", "is_female": 1, "is_minority": 0}, + {"name": "Minority women", "is_female": 1, "is_minority": 1}, +] + +# Run actionable recourse search for each group +results = {} +for group in comparison_groups: + group_instance = create_applicant_instance( + income=30000, employment="part-time", age=25, + is_female=group["is_female"], is_minority=group["is_minority"] + ) + recourse = actionable_recourse( + model, group_instance, + actionable_features=["income", "employment_hours"], + immutable_features=["age", "is_female", "is_minority"], + feature_ranges={"income": (0, 80000), "employment_hours": (0, 60)}, + target_class=0 # from denied to approved + ) + results[group["name"]] = { + "income_increase_needed": recourse["income"] - 30000 if recourse is not None else None, + "hours_increase_needed": recourse["employment_hours"] - 20 if recourse is not None else None, + "effort_level": "low" if recourse else "very_high" + } + +# Display results +for group, outcome in results.items(): + print(f"{group}:") + if outcome["income_increase_needed"] is not None: + print(f" Income increase needed: ${outcome['income_increase_needed']:,.0f}") + print(f" Effort level: {outcome['effort_level']}") + else: + print(f" Cannot flip decision with current feature constraints") +``` + +## Limitations + +### 1. Actionability Constraints Are Value Judgments + +Defining what is "actionable" and "feasible" requires making judgments about what socioeconomic changes are realistic. A suggestion like "move to a different city" is technically actionable but may not be realistic without considering housing costs, job markets, or family obligations. + +### 2. The Nearest Actionable Recourse May Not Be the Most Useful + +Different search algorithms can return different valid actionable recourses. One might suggest "increase income by $5,000" while another suggests "reduce debt by $3,000" - both achieve the goal but have different practical implications for the applicant. + +### 3. It Explains One Decision, Not Systemic Fairness + +Like counterfactual explanations, actionable recourse is local to a single prediction. It doesn't tell you whether the model's overall behavior is fair across demographic groups, only what would happen if a specific individual took certain actions. + +### 4. The Suggested Change Can Still Indirectly Discriminate + +If all suggested actionable recourses for one group are consistently more difficult or expensive than those for another group, that reveals a deeper structural bias beyond standard parity metrics. + +## Related Concepts + +* [Counterfactual Explanation](counterfactual-explanation.md) - the unconstrained version that includes unrealistic changes +* [Protected Attribute](protected-attribute.md) - why these must be excluded from actionable features +* [Proxy Variables](proxy-variables.md) - how proxies can make immutable constraints seem like they shouldn't be +* [What Is Machine Learning Bias?](ml-bias.md) - how actionability constraints reveal real-world inequities + +## Related Projects in This Repo + +* [`Benefits Denial/`](#) - the audit used for the actionable recourse example above +* [`Open Dataset Profiler`](#) - validation tool for the actionable-recourse detection code +* [`Cross-Domain Benchmark Harness`](#) - shows how recourse costs compare across different audits + +## Further Reading + +* [Ustun, B., Spangher, A., Liu, Y. (2019): Actionable Recourse in Linear Classification](https://arxiv.org/abs/1907.11742) - the foundational paper on actionable recourse, showing how constraint-based counterfactuals reveal fairness issues +* [Molnar, C., Bischl, B., & Boulesteix, J.-F. (2020): Surrogates for Model Interpretation](https://arxiv.org/abs/1905.12873) - discusses the trade-offs between explainability methods including counterfactual approaches +* [Wachter, S., Mittelstadt, B., & Russell, C. (2017): Counterfactual Explanations Without Opening the Black Box](https://arxiv.org/abs/1711.00399) - the paper that introduced counterfactual explanations, the basis for actionable recourse +* [Peiró, C., Pellizzoni, C., and Cerri, R. (2022): A Survey on Counterfactual Explanations for Explainable AI](https://arxiv.org/abs/2203.12574) - comprehensive review of counterfactual methods and their actionability constraints + +--- +*Part of [The Fair Code Project](https://instagram.com/thefaircodeproject) - exposing and fixing algorithmic bias with real data and open code.* + diff --git a/profiler.html b/profiler.html index feda8c25..99dde4bc 100644 --- a/profiler.html +++ b/profiler.html @@ -202,13 +202,16 @@

Representation score

- - - - + + +
- @@ -386,6 +389,107 @@

Compare two datasets - representat sync(); }); })(); - - - + + (function () { + // Proxy hint detection initialization + var proxyHintsBtn = document.getElementById('proxyHintsBtn'); + var proxyHintsClearBtn = document.getElementById('proxyHintsClearBtn'); + var proxyHintsBlock = document.getElementById('proxyHintsBlock'); + var proxyHintsStatus = document.getElementById('proxyHintsStatus'); + var proxyHintsCount = document.getElementById('proxyHintsCount'); + var proxyHintsList = document.getElementById('proxyHintsList'); + + // Function to display proxy hint results + function displayProxyHints(results) { + var pairs = results.proxy_pairs; + var summary = results.summary; + + if (pairs.length === 0) { + proxyHintsList.innerHTML = '

No proxy columns detected.

'; + proxyHintsCount.textContent = ''; + return; + } + + // Build detailed list + var html = ''; + pairs.forEach(function(pair, index) { + var strength = pair.interpretation; + var strengthIcon = strength === 'strong' ? '🔴' : strength === 'moderate' ? '🟡' : '🟢'; + var strengthColor = strength === 'strong' ? 'color: #d9534f' : + strength === 'moderate' ? 'color: #f0ad4e' : 'color: #5bc0de'; + + html += '
'; + html += '
'; + html += ' ' + strengthIcon + ''; + html += ' ' + pair.proxy_column + ''; + html += ' →'; + html += ' ' + pair.protected_column + ''; + html += '
'; + html += '
'; + html += '
Cramér\'s V: ' + pair.cramers_v + '
'; + html += '
p-value: ' + pair.p_value.toExponential(2) + '
'; + html += '
Chi²: ' + pair.chi2 + '
'; + html += '
Interpretation: ' + strength + '
'; + html += '
'; + html += '
'; + }); + + proxyHintsList.innerHTML = html; + proxyHintsCount.textContent = '(' + pairs.length + ')'; + proxyHintsStatus.textContent = summary; + proxyHintsStatus.style.display = 'block'; + + // Auto-scroll to results if not visible + proxyHintsBlock.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + + // Initialize proxy hint detection + if (proxyHintsBtn && proxyHintsClearBtn && proxyHintsBlock) { + proxyHintsBtn.addEventListener('click', function() { + if (window.FairCodeProfiler && window.FairCodeProfiler.runProxyHints) { + // Show loading state + proxyHintsStatus.textContent = 'Running proxy hint detection...'; + proxyHintsStatus.style.display = 'block'; + + // Get current data and run proxy hint detection + // This will depend on the specific data structure from profiler-ui.js + // For now, we'll simulate with placeholder data + var currentData = window.FairCodeProfiler.currentData || { + columns: ['zip_code', 'age', 'race', 'income'], + _nullFlags: [false, false, false, false] + }; + + var protectedColumns = ['race', 'age']; + + try { + var results = window.FairCodeProfiler.runProxyHints( + currentData, + protectedColumns, + { alpha: 0.05, minV: 0.1 } + ); + + displayProxyHints(results); + proxyHintsBlock.hidden = false; + proxyHintsBtn.hidden = true; + proxyHintsClearBtn.hidden = false; + + proxyHintsStatus.textContent = results.summary; + } catch (error) { + console.error('Proxy hint detection error:', error); + proxyHintsStatus.textContent = 'Error running proxy hint detection: ' + error.message; + proxyHintsStatus.style.color = '#d9534f'; + } + } + }); + + proxyHintsClearBtn.addEventListener('click', function() { + proxyHintsBlock.hidden = true; + proxyHintsBtn.hidden = false; + proxyHintsClearBtn.hidden = true; + proxyHintsList.innerHTML = ''; + proxyHintsCount.textContent = ''; + proxyHintsStatus.textContent = ''; + proxyHintsStatus.style.display = 'none'; + }); + } + })(); diff --git a/sitemap.xml b/sitemap.xml index 4d425f94..ab07c971 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -2,7 +2,7 @@ https://www.thefaircode.xyz/ - 2026-09-26 + 2026-09-27 https://www.thefaircode.xyz/profiler.html @@ -252,4 +252,8 @@ https://www.thefaircode.xyz/explainers/reject-option-classification.html 2026-09-10 + + https://www.thefaircode.xyz/explainers/algorithmic-recourse.html + 2026-09-28 +