diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 052583e..222a815 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -55,7 +55,7 @@ the [Git identity map](#git-identity-map) so `git shortlog -sne` can be reconcil | | Who | Role | |:--|-----|------| -| yakew7 | **Yash Kewlani** - [@yakew7](https://github.com/yakew7) | Creator and maintainer. Author of the seven audits, the `faircode` library and benchmark harness, the Open Dataset Profiler, the website, and the bulk of the 60 explainers. Code owner for `faircode/`, `paper/`, every `audit.yaml`, and project policy (`CLAUDE.md`, `CONTRIBUTING.md`); co-owner of `explainers/`. | +| yakew7 | **Yash Kewlani** - [@yakew7](https://github.com/yakew7) | Creator and maintainer. Author of the seven audits, the `faircode` library and benchmark harness, the Open Dataset Profiler, the website, and the bulk of the 62 explainers. Code owner for `faircode/`, `paper/`, every `audit.yaml`, and project policy (`CLAUDE.md`, `CONTRIBUTING.md`); co-owner of `explainers/`. | Contact: [yashkewlani2020@gmail.com](mailto:yashkewlani2020@gmail.com) · [@thefaircodeproject](https://instagram.com/thefaircodeproject) diff --git a/METRICS.md b/METRICS.md index e135893..6f73793 100644 --- a/METRICS.md +++ b/METRICS.md @@ -6,7 +6,7 @@ ![Contributors](https://img.shields.io/badge/Contributors-29-blue?style=flat-square) ![Forks](https://img.shields.io/badge/Forks-33-orange?style=flat-square) ![Watching](https://img.shields.io/badge/Watching-8-yellow?style=flat-square) -![Explainers](https://img.shields.io/badge/Explainers-60-blueviolet?style=flat-square) +![Explainers](https://img.shields.io/badge/Explainers-62-blueviolet?style=flat-square) ![Countries](https://img.shields.io/badge/Countries-20-informational?style=flat-square) ![Updated](https://img.shields.io/badge/Updated-Weekly-lightgrey?style=flat-square) diff --git a/README.md b/README.md index b0fe3b6..5e92293 100644 --- a/README.md +++ b/README.md @@ -781,10 +781,10 @@ features = [ ## Explainers -60 short, plain-language write-ups of individual fairness concepts, each with runnable detection code. The healthcare-focused ones are called out above in [Healthcare AI Bias Focus](#healthcare-ai-bias-focus). +62 short, plain-language write-ups of individual fairness concepts, each with runnable detection code. The healthcare-focused ones are called out above in [Healthcare AI Bias Focus](#healthcare-ai-bias-focus).
-Show all 60 explainers → +Show all 62 explainers → | Explainer | What it covers | |-----------|----------------| @@ -1205,7 +1205,7 @@ The full public roadmap - with phases, completion status, and content schedule - | Combined Social Reach (Instagram + LinkedIn) | 30K+ | | Countries Reached (Website Visitors) | 20 | | Code Audits Published | 7 | -| Explainers Published | 60 | +| Explainers Published | 62 | Tracked weekly in [METRICS.md](METRICS.md). diff --git a/ROADMAP.md b/ROADMAP.md index 1359c12..4a56f99 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -44,7 +44,7 @@ Fair Code is an open-source responsible AI platform explaining algorithmic bias, ## Phase 1 - Bias Glossary and Beginner Explainers ✅ -**Status: Foundational library complete - 60 explainers published, expanding toward a 60+ library** +**Status: Foundational library complete - 62 explainers published, expanding toward a 60+ library** Build the foundational vocabulary and explain core fairness concepts clearly enough for a non-technical reader. diff --git a/assets/explainers-data.js b/assets/explainers-data.js index 987f97d..cce2d0c 100644 --- a/assets/explainers-data.js +++ b/assets/explainers-data.js @@ -608,5 +608,24 @@ window.FAIR_CODE_EXPLAINERS = [ "data", "metrics" ] + }, + { + "slug": "maxmin-fairness", + "title": "What Is Max-Min (Rawlsian) Fairness?", + "subtitle": "Make the worst-off group's outcome as good as possible, even if the groups end up unequal.", + "summary": "Learn how max-min (Rawlsian) fairness minimizes the maximum group-level loss instead of equalizing a rate across groups, and why a model can move toward it while still failing demographic parity. Worked on the Audit 03 German Credit data: an iterative group-reweighting loop cuts worst-group error from 29.1% to 27.9% and leaves the 12-point selection-rate gap almost untouched.", + "tags": [ + "metrics" + ] + }, + { + "slug": "distributionally-robust-optimization", + "title": "What Is Distributionally Robust Optimization (DRO) for Fairness?", + "subtitle": "Train against the worst subgroup your data might be under-representing, without naming a parity metric.", + "summary": "Learn how DRO trains a model to minimize its worst-case loss over an uncertainty set of subgroup distributions - an in-processing alternative to constraint-based methods like ExponentiatedGradient that needs neither a chosen fairness metric nor group labels at prediction time. Worked on the Audit 03 German Credit data: online group DRO reliably shifts training weight onto the worst group, but on a capacity-limited linear model it buys no worst-group generalization - Sagawa et al.'s point about regularization.", + "tags": [ + "metrics", + "data" + ] } ]; diff --git a/assets/explainers-data.json b/assets/explainers-data.json index fab4ce2..261874d 100644 --- a/assets/explainers-data.json +++ b/assets/explainers-data.json @@ -418,6 +418,20 @@ "subtitle": "The accuracy cost of a privacy guarantee falls hardest on underrepresented groups.", "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": "maxmin-fairness", + "title": "What Is Max-Min (Rawlsian) Fairness?", + "subtitle": "Make the worst-off group's outcome as good as possible, even if the groups end up unequal.", + "summary": "Learn how max-min (Rawlsian) fairness minimizes the maximum group-level loss instead of equalizing a rate across groups, and why a model can move toward it while still failing demographic parity. Worked on the Audit 03 German Credit data: an iterative group-reweighting loop cuts worst-group error from 29.1% to 27.9% and leaves the 12-point selection-rate gap almost untouched.", + "tags": ["metrics"] + }, + { + "slug": "distributionally-robust-optimization", + "title": "What Is Distributionally Robust Optimization (DRO) for Fairness?", + "subtitle": "Train against the worst subgroup your data might be under-representing, without naming a parity metric.", + "summary": "Learn how DRO trains a model to minimize its worst-case loss over an uncertainty set of subgroup distributions - an in-processing alternative to constraint-based methods like ExponentiatedGradient that needs neither a chosen fairness metric nor group labels at prediction time. Worked on the Audit 03 German Credit data: online group DRO reliably shifts training weight onto the worst group, but on a capacity-limited linear model it buys no worst-group generalization - Sagawa et al.'s point about regularization.", + "tags": ["metrics", "data"] } ] diff --git a/assets/og-light/distributionally-robust-optimization.png b/assets/og-light/distributionally-robust-optimization.png new file mode 100644 index 0000000..4b42a30 Binary files /dev/null and b/assets/og-light/distributionally-robust-optimization.png differ diff --git a/assets/og-light/maxmin-fairness.png b/assets/og-light/maxmin-fairness.png new file mode 100644 index 0000000..dd38769 Binary files /dev/null and b/assets/og-light/maxmin-fairness.png differ diff --git a/assets/og/distributionally-robust-optimization.png b/assets/og/distributionally-robust-optimization.png new file mode 100644 index 0000000..bc26e59 Binary files /dev/null and b/assets/og/distributionally-robust-optimization.png differ diff --git a/assets/og/maxmin-fairness.png b/assets/og/maxmin-fairness.png new file mode 100644 index 0000000..5d18e5a Binary files /dev/null and b/assets/og/maxmin-fairness.png differ diff --git a/explainers/distributionally-robust-optimization.html b/explainers/distributionally-robust-optimization.html new file mode 100644 index 0000000..3d1510f --- /dev/null +++ b/explainers/distributionally-robust-optimization.html @@ -0,0 +1,381 @@ + + + + + +What Is Distributionally Robust Optimization (DRO) for Fairness? · Fair Code + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
Explainer
+

What Is Distributionally Robust Optimization (DRO) for Fairness?

+

Train against the worst subgroup your data might be under-representing, without naming a parity metric.

+

Learn how DRO trains a model to minimize its worst-case loss over an uncertainty set of subgroup distributions - an in-processing alternative to constraint-based methods like ExponentiatedGradient that needs neither a chosen fairness metric nor group labels at prediction time. Worked on the Audit 03 German Credit data: online group DRO reliably shifts training weight onto the worst group, but on a capacity-limited linear model it buys no worst-group generalization - Sagawa et al.'s point about regularization.

+
+ +

What Is Distributionally Robust Optimization (DRO) for Fairness?

+

A constraint-based fix needs you to name the fairness metric and the group up front. DRO instead trains against the worst subgroup your data might be under-representing - whichever one that turns out to be.

+
+

The One-Sentence Definition

+

Distributionally Robust Optimization (DRO) is an in-processing fairness technique that trains a model to minimize its worst-case loss over a set of plausible reweightings of the training distribution, rather than minimizing average loss or constraining a specific parity metric.

+
+

Why It Matters

+

This repo's S3 in-processing strategy (mitigation-strategies.md) is Fairlearn's ExponentiatedGradient: optimize accuracy subject to a fairness constraint, such as demographic parity, on a named protected attribute. That works well when you already know which metric matters and which group is at risk. It has two requirements baked in:

+
  • You choose the fairness metric before training. The constraint is demographic parity, or equalized odds, or one specific thing. A gap on a metric you did not constrain is not touched.
  • You choose the group before training. The constraint is defined on race, or age, or a fixed intersection. A subgroup you did not think to check is invisible to it.
+

DRO relaxes both. Instead of a constraint on one metric for one group, it hedges against the worst subgroup the training data might be under-representing - without committing to a parity metric at all, and (in the Hashimoto et al. 2018 form) without needing group labels. If the group actually being harmed is not the one you would have constrained, DRO can still help; ExponentiatedGradient cannot.

+

The cost is a free parameter and no guarantee, both covered below.

+
+

Core Concept: The Min-Max Objective

+

ExponentiatedGradient solves a constrained problem:

+
minimise  average loss L(theta)
+subject to  |demographic_parity_gap(theta)|  <=  epsilon
+

Group DRO solves a min-max problem instead:

+
minimise over theta of   max over Q in U of   E_Q[ loss(theta) ]
+

where U is an uncertainty set: a ball of distributions around the empirical training distribution. E_Q[loss] is the expected loss if the data were reweighted to Q. The model is trained so that even the least favourable reweighting in U still has low loss.

+

When U is "all reweightings that shift mass between predefined groups", this is group DRO (Sagawa et al. 2020), and it has a simple online algorithm:

+

1. Keep a probability vector q over groups. 2. Each step, raise q for whichever group currently has the highest loss. 3. Fit the model on q-reweighted examples. 4. Repeat until q stabilizes.

+

The size of U - how far q is allowed to move from the group base rates - is the robustness knob. Bigger U means the model hedges against more extreme reweightings.

+

This is the same reweighting loop as iterative max-min (Rawlsian) fairness; the difference is framing. Max-min asks "make the worst group's outcome as good as possible." DRO asks "stay robust to a distribution shift that inflates the worst group" - and the uncertainty set U is the explicit statement of which shifts you are hedging against.

+
+

Concrete Example: German Credit Lending - Audit 03

+

Audit 03 (German Credit Lending/) predicts bad credit risk on 1,000 real loan records; the protected attribute is age, with "young" meaning under 30.

+

For reference, this repo's frozen benchmark (paper/results-frozen/results_fairness.csv, the earlier snapshot - see CLAUDE.md) records the S3 in_processing strategy driving the baseline logistic-regression demographic_parity_diff on age from -0.129 (p = 0.05) at S0 to +0.023 (p = 0.71) at S3 - the constraint closes the parity gap it was given. Group DRO does not target that metric at all; it targets worst-group loss.

+

The script below runs an ERM (average-loss) baseline, then online group DRO, sweeping the robustness knob. Features drop age and personal_status; inputs are standardized; the split is 80/20 stratified at random_state=42.

+
import numpy as np
+import pandas as pd
+from sklearn.linear_model import LogisticRegression
+from sklearn.metrics import log_loss
+from sklearn.model_selection import train_test_split
+
+
+def load_german_credit(path="German Credit Lending/credit_customers.csv"):
+    df = pd.read_csv(path)
+    df["y_bad"] = (df["class"] == "bad").astype(int)
+    df["age_group"] = np.where(df["age"] < 30, "young", "older")
+    drop = {"class", "y_bad", "age", "age_group", "personal_status"}
+    feats = [c for c in df.columns if c not in drop]
+    return pd.get_dummies(df[feats], drop_first=True), df["y_bad"].to_numpy(), df["age_group"].to_numpy()
+
+
+def per_group(model, X, y, groups):
+    pred, proba = model.predict(X), model.predict_proba(X)[:, 1]
+    rows = {}
+    for g in ["older", "young"]:
+        m = groups == g
+        rows[g] = {"n": int(m.sum()),
+                   "accuracy": round(float((pred[m] == y[m]).mean()), 4),
+                   "log_loss": round(float(log_loss(y[m], proba[m], labels=[0, 1])), 4)}
+    worst = max(rows["older"]["log_loss"], rows["young"]["log_loss"])
+    return pd.DataFrame(rows).T, round(float(worst), 4), round(float((pred == y).mean()), 4)
+
+
+def fit_group_dro(Xtr, ytr, gtr, robustness=1.0, n_rounds=100):
+    """Online group DRO (Sagawa et al. 2020): keep a distribution q over groups,
+    each round upweight whichever group has the highest loss, and fit the model
+    on q-reweighted examples. `robustness` is the uncertainty-set knob - larger
+    means q chases the worst group harder."""
+    q = {"older": 0.5, "young": 0.5}
+    frac = {g: (gtr == g).mean() for g in q}
+    for _ in range(n_rounds):
+        w = np.array([q[g] / frac[g] for g in gtr], dtype=float)
+        w *= len(w) / w.sum()
+        model = LogisticRegression(max_iter=5000).fit(Xtr, ytr, sample_weight=w)
+        proba = model.predict_proba(Xtr)[:, 1]
+        gl = {g: log_loss(ytr[gtr == g], proba[gtr == g], labels=[0, 1]) for g in q}
+        for g in q:
+            q[g] *= np.exp(robustness * gl[g])
+        total = sum(q.values())
+        q = {g: v / total for g, v in q.items()}
+    return model, q
+
+
+if __name__ == "__main__":
+    X, y, g = load_german_credit()
+    Xtr, Xte, ytr, yte, gtr, gte = train_test_split(
+        X, y, g, test_size=0.2, random_state=42, stratify=y)
+    mean, std = Xtr.mean(), Xtr.std().replace(0, 1)
+    Xtr_s, Xte_s = ((Xtr - mean) / std).to_numpy(), ((Xte - mean) / std).to_numpy()
+
+    erm = LogisticRegression(max_iter=5000).fit(Xtr_s, ytr)
+    tbl, worst, acc = per_group(erm, Xte_s, yte, gte)
+    print("=== ERM baseline (minimise average loss) ===")
+    print(tbl.to_string())
+    print(f"overall accuracy   : {acc:.4f}")
+    print(f"worst-group log_loss: {worst:.4f}\n")
+
+    print("=== Group DRO: sweep the robustness knob ===")
+    print(f"{'robustness':>10} | {'q(young)':>8} | {'worst-group log_loss':>20} | {'overall acc':>11}")
+    for r in [0.0, 0.5, 1.0, 2.0, 5.0]:
+        model, q = fit_group_dro(Xtr_s, ytr, gtr, robustness=r)
+        _, w, a = per_group(model, Xte_s, yte, gte)
+        print(f"{r:>10.1f} | {q['young']:>8.2f} | {w:>20.4f} | {a:>11.4f}")
+

Script Execution Output

+
=== ERM baseline (minimise average loss) ===
+           n  accuracy  log_loss
+older  121.0    0.8099    0.4285
+young   79.0    0.7089    0.5880
+overall accuracy   : 0.7700
+worst-group log_loss: 0.5880
+
+=== Group DRO: sweep the robustness knob ===
+robustness | q(young) | worst-group log_loss | overall acc
+       0.0 |     0.50 |               0.5910 |      0.7700
+       0.5 |     0.62 |               0.5988 |      0.7750
+       1.0 |     0.63 |               0.5991 |      0.7700
+       2.0 |     0.63 |               0.5991 |      0.7700
+       5.0 |     0.63 |               0.5991 |      0.7700
+

(Deterministic with these library versions; a solver's last digits can shift across BLAS backends.)

+

Three things this shows:

+

DRO reliably redirects training weight to the worst group. ERM weights groups by their size. As the robustness knob goes up, q(young) climbs from 0.50 to about 0.63 and then saturates - the min-max objective has found the worst group and is pushing on it. That part works exactly as designed.

+

On this problem, redirecting the weight buys no worst-group generalization. The young group's held-out log_loss is 0.588 under ERM and does not improve under any robustness setting - it drifts slightly up, to ~0.599. Overall accuracy stays flat at 0.77. A linear model on 800 training rows is capacity-limited, not distribution-limited: the young group is genuinely harder to predict here, and reweighting the objective toward it cannot manufacture signal that is not in the features.

+

This is the paper's own headline finding, not a bug in the loop. Sagawa et al. (2020) title their paper "On the Importance of Regularization for Worst-Case Generalization" precisely because plain group DRO increases worst-group training influence without improving worst-group test loss unless the model is strongly regularized (and, in their setting, overparameterized). The robustness knob is a real dial with a real cost - turn it up and q over-hedges toward an implausible worst case, shaving average-case accuracy - but it is not a guarantee of a fairer model.

+
+

Detection and Implementation Code

+

fit_group_dro above is the implementation. The helper below reports whether a DRO run actually moved worst-group loss and what it cost average accuracy - the trade you are actually making.

+
def dro_report(erm_model, dro_model, X, y, groups):
+    """Compare an ERM model against a group-DRO model on held-out data."""
+    def summarise(model):
+        pred, proba = model.predict(X), model.predict_proba(X)[:, 1]
+        per = {}
+        for g in sorted(set(groups)):
+            m = groups == g
+            from sklearn.metrics import log_loss
+            per[g] = {"acc": round(float((pred[m] == y[m]).mean()), 4),
+                      "log_loss": round(float(log_loss(y[m], proba[m], labels=[0, 1])), 4)}
+        return per, round(float((pred == y).mean()), 4)
+
+    erm_per, erm_acc = summarise(erm_model)
+    dro_per, dro_acc = summarise(dro_model)
+    erm_worst = max(v["log_loss"] for v in erm_per.values())
+    dro_worst = max(v["log_loss"] for v in dro_per.values())
+    return {
+        "erm_per_group": erm_per,
+        "dro_per_group": dro_per,
+        "worst_group_log_loss_before": erm_worst,
+        "worst_group_log_loss_after": dro_worst,
+        "worst_group_improved": dro_worst < erm_worst,
+        "overall_accuracy_delta": round(dro_acc - erm_acc, 4),
+    }
+
+

Limitations

+

1. The uncertainty-set size is a free parameter with no data-driven default

+

robustness (equivalently, the radius of U) has to be picked. Too small and DRO collapses to ERM. Too large and the model hedges against reweightings that will never occur, trading real average-case accuracy for robustness to a fantasy worst case. There is no held-out quantity that tells you the "right" size - it encodes how much distribution shift you believe is plausible, which is a judgment call.

+

2. It still needs group labels at training time

+

The group-DRO form used here requires knowing each training example's group to maintain q. It removes the need to pick a parity metric up front, and it does not need group labels at prediction time, but it is not label-free. (Hashimoto et al. 2018's version drops the training-time group labels too, at the cost of hedging against all low-probability subpopulations, not just the ones you care about.)

+

3. Worst-case training loss is not worst-case test loss

+

As the example shows, pushing q toward the worst training group does not by itself improve that group's generalization. Without strong regularization or capacity control, group DRO can overfit the worst group's training set - the central caveat in Sagawa et al. (2020).

+

4. "Worst group" is only defined once you fix the loss and the grouping

+

DRO over groups defined by age says nothing about groups defined by job or by an age x foreign_worker intersection. And the worst group under log-loss, under 0-1 error, and under false-negative rate can differ. The uncertainty set is only as good as the grouping and loss you put into it.

+
+ + +
+ + +
+

Further Reading

+ +
+

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

+
+ + + + diff --git a/explainers/distributionally-robust-optimization.md b/explainers/distributionally-robust-optimization.md new file mode 100644 index 0000000..babcc6b --- /dev/null +++ b/explainers/distributionally-robust-optimization.md @@ -0,0 +1,246 @@ +# What Is Distributionally Robust Optimization (DRO) for Fairness? + +> *A constraint-based fix needs you to name the fairness metric and the group up front. DRO instead trains against the worst subgroup your data might be under-representing - whichever one that turns out to be.* + +--- + +## The One-Sentence Definition + +**Distributionally Robust Optimization (DRO)** is an in-processing fairness technique that trains a model to minimize its *worst-case* loss over a set of plausible reweightings of the training distribution, rather than minimizing average loss or constraining a specific parity metric. + +--- + +## Why It Matters + +This repo's S3 in-processing strategy ([mitigation-strategies.md](mitigation-strategies.md)) is Fairlearn's `ExponentiatedGradient`: optimize accuracy subject to a fairness constraint, such as demographic parity, on a named protected attribute. That works well when you already know which metric matters and which group is at risk. It has two requirements baked in: + +- **You choose the fairness metric before training.** The constraint is demographic parity, or equalized odds, or one specific thing. A gap on a metric you did not constrain is not touched. +- **You choose the group before training.** The constraint is defined on `race`, or `age`, or a fixed intersection. A subgroup you did not think to check is invisible to it. + +DRO relaxes both. Instead of a constraint on one metric for one group, it hedges against the worst subgroup the training data might be under-representing - without committing to a parity metric at all, and (in the Hashimoto et al. 2018 form) without needing group labels. If the group actually being harmed is not the one you would have constrained, DRO can still help; `ExponentiatedGradient` cannot. + +The cost is a free parameter and no guarantee, both covered below. + +--- + +## Core Concept: The Min-Max Objective + +`ExponentiatedGradient` solves a **constrained** problem: + +``` +minimise average loss L(theta) +subject to |demographic_parity_gap(theta)| <= epsilon +``` + +Group DRO solves a **min-max** problem instead: + +``` +minimise over theta of max over Q in U of E_Q[ loss(theta) ] +``` + +where `U` is an *uncertainty set*: a ball of distributions around the empirical training distribution. `E_Q[loss]` is the expected loss if the data were reweighted to `Q`. The model is trained so that even the least favourable reweighting in `U` still has low loss. + +When `U` is "all reweightings that shift mass between predefined groups", this is **group DRO** (Sagawa et al. 2020), and it has a simple online algorithm: + +1. Keep a probability vector `q` over groups. +2. Each step, raise `q` for whichever group currently has the highest loss. +3. Fit the model on `q`-reweighted examples. +4. Repeat until `q` stabilizes. + +The size of `U` - how far `q` is allowed to move from the group base rates - is the **robustness knob**. Bigger `U` means the model hedges against more extreme reweightings. + +This is the same reweighting loop as iterative [max-min (Rawlsian) fairness](maxmin-fairness.md); the difference is framing. Max-min asks "make the worst group's *outcome* as good as possible." DRO asks "stay robust to a *distribution shift* that inflates the worst group" - and the uncertainty set `U` is the explicit statement of which shifts you are hedging against. + +--- + +## Concrete Example: German Credit Lending - Audit 03 + +Audit 03 ([`German Credit Lending/`](../German%20Credit%20Lending/)) predicts bad credit risk on 1,000 real loan records; the protected attribute is `age`, with "young" meaning under 30. + +For reference, this repo's frozen benchmark ([`paper/results-frozen/results_fairness.csv`](../paper/results-frozen/), the earlier snapshot - see [CLAUDE.md](../CLAUDE.md)) records the S3 `in_processing` strategy driving the baseline logistic-regression `demographic_parity_diff` on `age` from **-0.129 (p = 0.05) at S0 to +0.023 (p = 0.71) at S3** - the constraint closes the parity gap it was given. Group DRO does not target that metric at all; it targets worst-group loss. + +The script below runs an ERM (average-loss) baseline, then online group DRO, sweeping the robustness knob. Features drop `age` and `personal_status`; inputs are standardized; the split is 80/20 stratified at `random_state=42`. + +```python +import numpy as np +import pandas as pd +from sklearn.linear_model import LogisticRegression +from sklearn.metrics import log_loss +from sklearn.model_selection import train_test_split + + +def load_german_credit(path="German Credit Lending/credit_customers.csv"): + df = pd.read_csv(path) + df["y_bad"] = (df["class"] == "bad").astype(int) + df["age_group"] = np.where(df["age"] < 30, "young", "older") + drop = {"class", "y_bad", "age", "age_group", "personal_status"} + feats = [c for c in df.columns if c not in drop] + return pd.get_dummies(df[feats], drop_first=True), df["y_bad"].to_numpy(), df["age_group"].to_numpy() + + +def per_group(model, X, y, groups): + pred, proba = model.predict(X), model.predict_proba(X)[:, 1] + rows = {} + for g in ["older", "young"]: + m = groups == g + rows[g] = {"n": int(m.sum()), + "accuracy": round(float((pred[m] == y[m]).mean()), 4), + "log_loss": round(float(log_loss(y[m], proba[m], labels=[0, 1])), 4)} + worst = max(rows["older"]["log_loss"], rows["young"]["log_loss"]) + return pd.DataFrame(rows).T, round(float(worst), 4), round(float((pred == y).mean()), 4) + + +def fit_group_dro(Xtr, ytr, gtr, robustness=1.0, n_rounds=100): + """Online group DRO (Sagawa et al. 2020): keep a distribution q over groups, + each round upweight whichever group has the highest loss, and fit the model + on q-reweighted examples. `robustness` is the uncertainty-set knob - larger + means q chases the worst group harder.""" + q = {"older": 0.5, "young": 0.5} + frac = {g: (gtr == g).mean() for g in q} + for _ in range(n_rounds): + w = np.array([q[g] / frac[g] for g in gtr], dtype=float) + w *= len(w) / w.sum() + model = LogisticRegression(max_iter=5000).fit(Xtr, ytr, sample_weight=w) + proba = model.predict_proba(Xtr)[:, 1] + gl = {g: log_loss(ytr[gtr == g], proba[gtr == g], labels=[0, 1]) for g in q} + for g in q: + q[g] *= np.exp(robustness * gl[g]) + total = sum(q.values()) + q = {g: v / total for g, v in q.items()} + return model, q + + +if __name__ == "__main__": + X, y, g = load_german_credit() + Xtr, Xte, ytr, yte, gtr, gte = train_test_split( + X, y, g, test_size=0.2, random_state=42, stratify=y) + mean, std = Xtr.mean(), Xtr.std().replace(0, 1) + Xtr_s, Xte_s = ((Xtr - mean) / std).to_numpy(), ((Xte - mean) / std).to_numpy() + + erm = LogisticRegression(max_iter=5000).fit(Xtr_s, ytr) + tbl, worst, acc = per_group(erm, Xte_s, yte, gte) + print("=== ERM baseline (minimise average loss) ===") + print(tbl.to_string()) + print(f"overall accuracy : {acc:.4f}") + print(f"worst-group log_loss: {worst:.4f}\n") + + print("=== Group DRO: sweep the robustness knob ===") + print(f"{'robustness':>10} | {'q(young)':>8} | {'worst-group log_loss':>20} | {'overall acc':>11}") + for r in [0.0, 0.5, 1.0, 2.0, 5.0]: + model, q = fit_group_dro(Xtr_s, ytr, gtr, robustness=r) + _, w, a = per_group(model, Xte_s, yte, gte) + print(f"{r:>10.1f} | {q['young']:>8.2f} | {w:>20.4f} | {a:>11.4f}") +``` + +### Script Execution Output + +``` +=== ERM baseline (minimise average loss) === + n accuracy log_loss +older 121.0 0.8099 0.4285 +young 79.0 0.7089 0.5880 +overall accuracy : 0.7700 +worst-group log_loss: 0.5880 + +=== Group DRO: sweep the robustness knob === +robustness | q(young) | worst-group log_loss | overall acc + 0.0 | 0.50 | 0.5910 | 0.7700 + 0.5 | 0.62 | 0.5988 | 0.7750 + 1.0 | 0.63 | 0.5991 | 0.7700 + 2.0 | 0.63 | 0.5991 | 0.7700 + 5.0 | 0.63 | 0.5991 | 0.7700 +``` + +(Deterministic with these library versions; a solver's last digits can shift across BLAS backends.) + +Three things this shows: + +**DRO reliably redirects training weight to the worst group.** ERM weights groups by their size. As the robustness knob goes up, `q(young)` climbs from 0.50 to about 0.63 and then saturates - the min-max objective has found the worst group and is pushing on it. That part works exactly as designed. + +**On this problem, redirecting the weight buys no worst-group generalization.** The young group's held-out `log_loss` is 0.588 under ERM and does not improve under any robustness setting - it drifts slightly *up*, to ~0.599. Overall accuracy stays flat at 0.77. A linear model on 800 training rows is capacity-limited, not distribution-limited: the young group is genuinely harder to predict here, and reweighting the objective toward it cannot manufacture signal that is not in the features. + +**This is the paper's own headline finding, not a bug in the loop.** Sagawa et al. (2020) title their paper *"On the Importance of Regularization for Worst-Case Generalization"* precisely because plain group DRO increases worst-group training influence without improving worst-group test loss unless the model is strongly regularized (and, in their setting, overparameterized). The robustness knob is a real dial with a real cost - turn it up and `q` over-hedges toward an implausible worst case, shaving average-case accuracy - but it is not a guarantee of a fairer model. + +--- + +## Detection and Implementation Code + +`fit_group_dro` above is the implementation. The helper below reports whether a DRO run actually moved worst-group loss and what it cost average accuracy - the trade you are actually making. + +```python +def dro_report(erm_model, dro_model, X, y, groups): + """Compare an ERM model against a group-DRO model on held-out data.""" + def summarise(model): + pred, proba = model.predict(X), model.predict_proba(X)[:, 1] + per = {} + for g in sorted(set(groups)): + m = groups == g + from sklearn.metrics import log_loss + per[g] = {"acc": round(float((pred[m] == y[m]).mean()), 4), + "log_loss": round(float(log_loss(y[m], proba[m], labels=[0, 1])), 4)} + return per, round(float((pred == y).mean()), 4) + + erm_per, erm_acc = summarise(erm_model) + dro_per, dro_acc = summarise(dro_model) + erm_worst = max(v["log_loss"] for v in erm_per.values()) + dro_worst = max(v["log_loss"] for v in dro_per.values()) + return { + "erm_per_group": erm_per, + "dro_per_group": dro_per, + "worst_group_log_loss_before": erm_worst, + "worst_group_log_loss_after": dro_worst, + "worst_group_improved": dro_worst < erm_worst, + "overall_accuracy_delta": round(dro_acc - erm_acc, 4), + } +``` + +--- + +## Limitations + +### 1. The uncertainty-set size is a free parameter with no data-driven default + +`robustness` (equivalently, the radius of `U`) has to be picked. Too small and DRO collapses to ERM. Too large and the model hedges against reweightings that will never occur, trading real average-case accuracy for robustness to a fantasy worst case. There is no held-out quantity that tells you the "right" size - it encodes how much distribution shift you believe is plausible, which is a judgment call. + +### 2. It still needs group labels at training time + +The group-DRO form used here requires knowing each training example's group to maintain `q`. It removes the need to pick a *parity metric* up front, and it does not need group labels at prediction time, but it is not label-free. (Hashimoto et al. 2018's version drops the training-time group labels too, at the cost of hedging against *all* low-probability subpopulations, not just the ones you care about.) + +### 3. Worst-case training loss is not worst-case test loss + +As the example shows, pushing `q` toward the worst training group does not by itself improve that group's generalization. Without strong regularization or capacity control, group DRO can overfit the worst group's training set - the central caveat in Sagawa et al. (2020). + +### 4. "Worst group" is only defined once you fix the loss and the grouping + +DRO over groups defined by `age` says nothing about groups defined by `job` or by an `age x foreign_worker` intersection. And the worst group under log-loss, under 0-1 error, and under false-negative rate can differ. The uncertainty set is only as good as the grouping and loss you put into it. + +--- + +## Related Concepts + +- [Mitigation Strategies](mitigation-strategies.md) - this repo's S3 `ExponentiatedGradient` in-processing strategy, the constraint-based method DRO is contrasted against. +- [What Is Max-Min (Rawlsian) Fairness?](maxmin-fairness.md) - the same reweighting loop, framed as an objective on group outcomes rather than robustness to distribution shift. +- [What Is Distribution Shift?](distribution-shift.md) - the deployment-time phenomenon whose worst case DRO's uncertainty set is meant to bound. +- [What Is the Fairness-Accuracy Trade-off?](fairness-accuracy-tradeoff.md) - the average-case accuracy DRO spends when the robustness knob is turned up. +- [What Is Class Imbalance?](class-imbalance.md) - why average-loss training underserves the smaller, harder group DRO reweights toward. + +--- + +## Related Projects in This Repo + +- [`German Credit Lending/`](../German%20Credit%20Lending/) - Audit 03, the dataset and `age` attribute used above. +- [`faircode/strategies.py`](../faircode/strategies.py) - the S0-S4 implementation, including the S3 `ExponentiatedGradient` strategy DRO is compared with. +- [`paper/results-frozen/results_fairness.csv`](../paper/results-frozen/) - the frozen S0/S3 `demographic_parity_diff` figures cited above. + +--- + +## Further Reading + +- [Sagawa, Koh, Hashimoto & Liang (2020): Distributionally Robust Neural Networks for Group Shifts: On the Importance of Regularization for Worst-Case Generalization, ICLR 2020](https://arxiv.org/abs/1911.08731) - the group-DRO algorithm used here, and the finding that worst-case generalization needs strong regularization. +- [Hashimoto, Srivastava, Namkoong & Liang (2018): Fairness Without Demographics in Repeated Loss Minimization, ICML 2018, PMLR 80:1929-1938](https://proceedings.mlr.press/v80/hashimoto18a.html) - a DRO objective that bounds the minority group's risk without any group labels. +- [Duchi & Namkoong (2021): Learning Models with Uniform Performance via Distributionally Robust Optimization, Annals of Statistics 49(3), 1378-1406](https://doi.org/10.1214/20-AOS2004) - the statistical foundations of the uncertainty-set formulation. +- [Agarwal, Beygelzimer, Dudik, Langford & Wallach (2018): A Reductions Approach to Fair Classification, ICML 2018](https://proceedings.mlr.press/v80/agarwal18a.html) - the `ExponentiatedGradient` constraint-based method this repo uses at S3, for contrast. + +--- + +*Part of [The Fair Code Project](https://instagram.com/thefaircodeproject) - exposing and fixing algorithmic bias with real data and open code.* diff --git a/explainers/maxmin-fairness.html b/explainers/maxmin-fairness.html new file mode 100644 index 0000000..f68fc4d --- /dev/null +++ b/explainers/maxmin-fairness.html @@ -0,0 +1,387 @@ + + + + + +What Is Max-Min (Rawlsian) Fairness? · Fair Code + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
Explainer
+

What Is Max-Min (Rawlsian) Fairness?

+

Make the worst-off group's outcome as good as possible, even if the groups end up unequal.

+

Learn how max-min (Rawlsian) fairness minimizes the maximum group-level loss instead of equalizing a rate across groups, and why a model can move toward it while still failing demographic parity. Worked on the Audit 03 German Credit data: an iterative group-reweighting loop cuts worst-group error from 29.1% to 27.9% and leaves the 12-point selection-rate gap almost untouched.

+
+ +

What Is Max-Min (Rawlsian) Fairness?

+

Parity asks whether the groups are equal to each other. Max-min asks how good we can make the worst-off group's outcome - even if the groups end up unequal.

+
+

The One-Sentence Definition

+

Max-min fairness (also called Rawlsian fairness, after the "difference principle" in John Rawls' A Theory of Justice) is a fairness objective that minimizes the maximum group-level loss - it makes the worst-off group's outcome as good as possible - instead of equalizing a rate across groups the way parity metrics do.

+
+

Why It Matters

+

Every parity-based metric this repo already covers - demographic parity, equalized odds, predictive parity - measures a difference between groups and drives it toward zero. That framing has a well-known escape hatch: you can satisfy a parity constraint by making the better-off group worse rather than making the worse-off group better. "Parity in mediocrity" passes the test.

+

Max-min fairness rejects that move by construction. It does not look at the gap between groups at all. It looks at the single worst group-level outcome and tries to lift it. Concretely:

+
  • A model can satisfy demographic parity while both groups have poor accuracy. Max-min would prefer a model where the worst group's accuracy is higher, even if that widens the between-group gap.
  • Conversely, optimizing only for the worst-off group can widen a parity gap: if you pour modeling capacity into the group with the higher error rate, its predictions improve, but its selection rate can move away from the other group's.
+

So max-min and parity are genuinely different objectives, not two names for the same goal - and a model tuned for one can fail the other. This repo's Fairness Metric Conflicts explainer covers conflicts among parity metrics; the conflict between parity and max-min is a separate axis it does not touch.

+
+

Core Concept: A Different Objective Function

+

Write L_g(theta) for the expected loss (say, error rate) of model theta on group g.

+

Demographic parity constrains a rate to be equal across groups:

+
minimise  L(theta)          [average loss]
+subject to  P(Y_hat = 1 | A = a)  equal for all a
+

Max-min (Rawlsian) fairness changes the thing being minimized:

+
minimise over theta of   max over groups g of   L_g(theta)
+

There is no equality constraint. The objective is a min of a max: push down the largest per-group loss, then whatever the next-largest one is, and so on. If lifting the worst group also happens to help the others, fine; if it leaves a between-group gap, that gap is not penalized.

+

Two standard ways to approximate it without a bespoke solver:

+

1. Iterative group reweighting. Train, measure each group's loss, upweight whichever group is currently worst, retrain. Repeat. The training objective drifts from "average loss" toward "worst-group loss." This is the approach in the code below, and the online form of it is what group distributionally robust optimization does. 2. Distributionally robust optimization (DRO). Minimize the worst-case loss over a set of reweightings of the data. Hashimoto et al. (2018) show a DRO objective controls the minority group's risk without needing group labels, which is why max-min and DRO are usually discussed together.

+
+

Concrete Example: German Credit Lending - Audit 03

+

Audit 03 in this repo (German Credit Lending/) predicts bad credit risk on 1,000 real loan records. The protected attribute is age; here "young" means under 30 (the same cut unfair.py uses).

+

This repo's own frozen benchmark already records the gap max-min targets. In paper/results-frozen/results_fairness.csv (the earlier reference snapshot; see CLAUDE.md), the S0 baseline logistic-regression model on age has an accuracy_equality_diff of -0.153 (p = 0.031) - a statistically significant 15-point per-group accuracy gap. None of this repo's S1-S4 mitigation strategies (mitigation-strategies.md) target that number; they all target demographic parity. Max-min fairness is the objective that goes after accuracy_equality_diff directly.

+

The script below is an independent minimal implementation on the same dataset (not the S0-S4 harness): a plain logistic-regression baseline, then a max-min reweighting loop. Features drop age and personal_status (its proxy); inputs are standardized; the split is 80/20 stratified at random_state=42.

+
import numpy as np
+import pandas as pd
+from sklearn.linear_model import LogisticRegression
+from sklearn.model_selection import train_test_split
+
+
+def load_german_credit(path="German Credit Lending/credit_customers.csv"):
+    df = pd.read_csv(path)
+    df["y_bad"] = (df["class"] == "bad").astype(int)            # 1 = bad credit risk
+    df["age_group"] = np.where(df["age"] < 30, "young", "older")
+    drop = {"class", "y_bad", "age", "age_group", "personal_status"}  # drop age + its proxy
+    feats = [c for c in df.columns if c not in drop]
+    X = pd.get_dummies(df[feats], drop_first=True)
+    return X, df["y_bad"].to_numpy(), df["age_group"].to_numpy()
+
+
+def per_group(y_true, y_pred, groups):
+    out = {}
+    for g in ["older", "young"]:
+        m = groups == g
+        acc = (y_pred[m] == y_true[m]).mean()
+        out[g] = {"n": int(m.sum()), "accuracy": round(float(acc), 4),
+                  "error": round(float(1 - acc), 4),
+                  "selection_rate": round(float(y_pred[m].mean()), 4)}
+    worst = max(out["older"]["error"], out["young"]["error"])
+    dp_gap = out["young"]["selection_rate"] - out["older"]["selection_rate"]
+    return pd.DataFrame(out).T, round(float(worst), 4), round(float(dp_gap), 4)
+
+
+def fit_maxmin(Xtr, ytr, gtr, n_rounds=50, step=0.5):
+    """Rawlsian max-min: iteratively reweight groups toward the worst per-group
+    training error, so the optimizer minimizes the maximum group loss rather
+    than the average loss."""
+    gw = {"older": 1.0, "young": 1.0}
+    for _ in range(n_rounds):
+        w = np.array([gw[g] for g in gtr], dtype=float)
+        w *= len(w) / w.sum()
+        model = LogisticRegression(max_iter=5000).fit(Xtr, ytr, sample_weight=w)
+        err = {g: 1 - (model.predict(Xtr)[gtr == g] == ytr[gtr == g]).mean()
+               for g in gw}
+        for g in gw:                       # multiplicative-weights update
+            gw[g] *= np.exp(step * err[g])
+        total = sum(gw.values())
+        gw = {g: v / total * len(gw) for g, v in gw.items()}
+    return model, gw
+
+
+if __name__ == "__main__":
+    X, y, g = load_german_credit()
+    Xtr, Xte, ytr, yte, gtr, gte = train_test_split(
+        X, y, g, test_size=0.2, random_state=42, stratify=y)
+    mean, std = Xtr.mean(), Xtr.std().replace(0, 1)
+    Xtr_s, Xte_s = ((Xtr - mean) / std).to_numpy(), ((Xte - mean) / std).to_numpy()
+
+    base = LogisticRegression(max_iter=5000).fit(Xtr_s, ytr)
+    b_tbl, b_worst, b_dp = per_group(yte, base.predict(Xte_s), gte)
+    print("=== BASELINE (minimise average loss) ===")
+    print(b_tbl.to_string())
+    print(f"overall accuracy      : {(base.predict(Xte_s) == yte).mean():.4f}")
+    print(f"worst-group error     : {b_worst:.4f}")
+    print(f"demographic-parity gap: {b_dp:.4f}  (young selection rate - older)")
+
+    mm, gw = fit_maxmin(Xtr_s, ytr, gtr)
+    m_tbl, m_worst, m_dp = per_group(yte, mm.predict(Xte_s), gte)
+    print("\n=== MAX-MIN (minimise the worst group's loss) ===")
+    print(m_tbl.to_string())
+    print(f"final group weights   : older {gw['older']:.2f}x, young {gw['young']:.2f}x")
+    print(f"overall accuracy      : {(mm.predict(Xte_s) == yte).mean():.4f}")
+    print(f"worst-group error     : {m_worst:.4f}")
+    print(f"demographic-parity gap: {m_dp:.4f}  (young selection rate - older)")
+

Script Execution Output

+
=== BASELINE (minimise average loss) ===
+           n  accuracy   error  selection_rate
+older  121.0    0.8099  0.1901          0.2066
+young   79.0    0.7089  0.2911          0.3418
+overall accuracy      : 0.7700
+worst-group error     : 0.2911
+demographic-parity gap: 0.1352  (young selection rate - older)
+
+=== MAX-MIN (minimise the worst group's loss) ===
+           n  accuracy   error  selection_rate
+older  121.0    0.8099  0.1901          0.2066
+young   79.0    0.7215  0.2785          0.3291
+final group weights   : older 0.57x, young 1.43x
+overall accuracy      : 0.7750
+worst-group error     : 0.2785
+demographic-parity gap: 0.1225  (young selection rate - older)
+

(Deterministic with these library versions; a solver's last digits can shift across BLAS backends, so the reading below only uses the leading digits.)

+

What the two runs show:

+

The baseline hides a large per-group gap inside a decent average. Overall accuracy is 77%, but that splits into 81% for older applicants and 71% for younger ones - a 10-point accuracy gap, the same disparity the frozen accuracy_equality_diff flags. Average-loss training has no reason to close it: younger applicants are the smaller group (79 of 200 test rows) and carry a higher base rate of bad outcomes, so the optimizer spends its capacity where the rows are.

+

Max-min lifts the worst group, and barely moves the other. Reweighting drives the young group's weight up to 1.43x and the older group's down to 0.57x. Worst-group error falls from 29.1% to 27.9%, the young group's accuracy rises about a point, the older group's predictions are unchanged, and overall accuracy is flat (77.0% to 77.5%). A small effect from a deliberately simple loop - stronger max-min methods (Martinez et al. 2020) push further - but it moves the right number in the right direction.

+

It does not fix demographic parity, and was never trying to. The selection-rate gap goes from 13.5 points to 12.3 points and stays large and in the same direction. A model can move toward max-min fairness while still plainly failing demographic parity: the two objectives are optimizing different things. If you need the selection rates equalized, that is a parity constraint (S3/S4 in mitigation-strategies.md), not a max-min objective.

+
+

Detection and Implementation Code

+

The fit_maxmin function above is the implementation. The check below reports whether a max-min run actually reduced the worst-group loss and what it cost the other groups - the two numbers that decide whether the trade was worth it.

+
def maxmin_report(y_true, base_pred, maxmin_pred, groups):
+    """Compare a baseline model against a max-min run, per group.
+
+    Flags the change in worst-group error and whether any other group got
+    worse (the price of the max-min trade).
+    """
+    rows = {}
+    for g in sorted(set(groups)):
+        m = groups == g
+        base_err = float(1 - (base_pred[m] == y_true[m]).mean())
+        mm_err = float(1 - (maxmin_pred[m] == y_true[m]).mean())
+        rows[g] = {"n": int(m.sum()),
+                   "baseline_error": round(base_err, 4),
+                   "maxmin_error": round(mm_err, 4),
+                   "delta": round(mm_err - base_err, 4)}
+    base_worst = max(r["baseline_error"] for r in rows.values())
+    mm_worst = max(r["maxmin_error"] for r in rows.values())
+    regressed = [g for g, r in rows.items() if r["delta"] > 1e-4]
+    return {
+        "per_group": rows,
+        "worst_group_error_before": round(base_worst, 4),
+        "worst_group_error_after": round(mm_worst, 4),
+        "worst_group_improved": mm_worst < base_worst,
+        "groups_that_got_worse": regressed,
+    }
+
+

Limitations

+

1. A tiny or noisy subgroup can dominate

+

Max-min chases whichever group is currently worst. If one group is small, its measured loss is high-variance, and the loop can pour weight into fitting noise for a handful of rows - hurting everyone else for no real gain. In the example above the "young" test group is only 79 rows; a real deployment should pair max-min with a minimum-group-size floor, the same guard this repo's significance module applies with its small-sample warning. Without that floor, "worst-off group" is not a stable target.

+

2. "Worst-off" depends on a chosen loss function

+

The worst group under 0-1 error, under log-loss, under false-negative rate, and under calibration error can be three different groups. Max-min is only defined once you fix the loss, and that choice is a value judgment, not a technical default - the same point Fairness Metric Conflicts makes about parity metrics.

+

3. It is silent on between-group gaps

+

If your obligation is a legal disparate-impact standard (an 80%-rule selection-rate ratio), max-min does not help you meet it and can move you away from it, as the example shows. Max-min and parity are complementary tools for different requirements, not substitutes.

+

4. Levelling down is technically permitted at the margin

+

Minimizing the maximum loss is usually improved by raising the worst group, but a solver can also lower a better-off group if that reduces the maximum (for instance by shifting a shared threshold). Pareto-efficient formulations (Martinez et al. 2020) rule this out explicitly; a plain reweighting loop does not.

+
+ + +
+ + +
+

Further Reading

+ +
+

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

+
+ + + + diff --git a/explainers/maxmin-fairness.md b/explainers/maxmin-fairness.md new file mode 100644 index 0000000..40498e8 --- /dev/null +++ b/explainers/maxmin-fairness.md @@ -0,0 +1,247 @@ +# What Is Max-Min (Rawlsian) Fairness? + +> *Parity asks whether the groups are equal to each other. Max-min asks how good we can make the worst-off group's outcome - even if the groups end up unequal.* + +--- + +## The One-Sentence Definition + +**Max-min fairness** (also called Rawlsian fairness, after the "difference principle" in John Rawls' *A Theory of Justice*) is a fairness objective that minimizes the maximum group-level loss - it makes the worst-off group's outcome as good as possible - instead of equalizing a rate across groups the way parity metrics do. + +--- + +## Why It Matters + +Every parity-based metric this repo already covers - [demographic parity](demographic-parity.md), [equalized odds](equalized-odds.md), [predictive parity](predictive-parity.md) - measures a *difference between groups* and drives it toward zero. That framing has a well-known escape hatch: you can satisfy a parity constraint by making the better-off group worse rather than making the worse-off group better. "Parity in mediocrity" passes the test. + +Max-min fairness rejects that move by construction. It does not look at the gap between groups at all. It looks at the single worst group-level outcome and tries to lift it. Concretely: + +- A model can satisfy demographic parity while both groups have poor accuracy. Max-min would prefer a model where the worst group's accuracy is higher, even if that widens the between-group gap. +- Conversely, optimizing only for the worst-off group can *widen* a parity gap: if you pour modeling capacity into the group with the higher error rate, its predictions improve, but its selection rate can move away from the other group's. + +So max-min and parity are genuinely different objectives, not two names for the same goal - and a model tuned for one can fail the other. This repo's [Fairness Metric Conflicts](fairness-metric-conflicts.md) explainer covers conflicts *among* parity metrics; the conflict between parity and max-min is a separate axis it does not touch. + +--- + +## Core Concept: A Different Objective Function + +Write `L_g(theta)` for the expected loss (say, error rate) of model `theta` on group `g`. + +**Demographic parity** constrains a rate to be equal across groups: + +``` +minimise L(theta) [average loss] +subject to P(Y_hat = 1 | A = a) equal for all a +``` + +**Max-min (Rawlsian) fairness** changes the thing being minimized: + +``` +minimise over theta of max over groups g of L_g(theta) +``` + +There is no equality constraint. The objective is a `min` of a `max`: push down the largest per-group loss, then whatever the next-largest one is, and so on. If lifting the worst group also happens to help the others, fine; if it leaves a between-group gap, that gap is not penalized. + +Two standard ways to approximate it without a bespoke solver: + +1. **Iterative group reweighting.** Train, measure each group's loss, upweight whichever group is currently worst, retrain. Repeat. The training objective drifts from "average loss" toward "worst-group loss." This is the approach in the code below, and the online form of it is what [group distributionally robust optimization](distributionally-robust-optimization.md) does. +2. **Distributionally robust optimization (DRO).** Minimize the worst-case loss over a set of reweightings of the data. Hashimoto et al. (2018) show a DRO objective controls the minority group's risk *without needing group labels*, which is why max-min and DRO are usually discussed together. + +--- + +## Concrete Example: German Credit Lending - Audit 03 + +Audit 03 in this repo ([`German Credit Lending/`](../German%20Credit%20Lending/)) predicts bad credit risk on 1,000 real loan records. The protected attribute is `age`; here "young" means under 30 (the same cut `unfair.py` uses). + +This repo's own frozen benchmark already records the gap max-min targets. In `paper/results-frozen/results_fairness.csv` (the earlier reference snapshot; see [CLAUDE.md](../CLAUDE.md)), the S0 baseline logistic-regression model on `age` has an **`accuracy_equality_diff` of -0.153** (p = 0.031) - a statistically significant 15-point per-group accuracy gap. None of this repo's S1-S4 mitigation strategies ([mitigation-strategies.md](mitigation-strategies.md)) target that number; they all target demographic parity. Max-min fairness is the objective that goes after `accuracy_equality_diff` directly. + +The script below is an independent minimal implementation on the same dataset (not the S0-S4 harness): a plain logistic-regression baseline, then a max-min reweighting loop. Features drop `age` and `personal_status` (its proxy); inputs are standardized; the split is 80/20 stratified at `random_state=42`. + +```python +import numpy as np +import pandas as pd +from sklearn.linear_model import LogisticRegression +from sklearn.model_selection import train_test_split + + +def load_german_credit(path="German Credit Lending/credit_customers.csv"): + df = pd.read_csv(path) + df["y_bad"] = (df["class"] == "bad").astype(int) # 1 = bad credit risk + df["age_group"] = np.where(df["age"] < 30, "young", "older") + drop = {"class", "y_bad", "age", "age_group", "personal_status"} # drop age + its proxy + feats = [c for c in df.columns if c not in drop] + X = pd.get_dummies(df[feats], drop_first=True) + return X, df["y_bad"].to_numpy(), df["age_group"].to_numpy() + + +def per_group(y_true, y_pred, groups): + out = {} + for g in ["older", "young"]: + m = groups == g + acc = (y_pred[m] == y_true[m]).mean() + out[g] = {"n": int(m.sum()), "accuracy": round(float(acc), 4), + "error": round(float(1 - acc), 4), + "selection_rate": round(float(y_pred[m].mean()), 4)} + worst = max(out["older"]["error"], out["young"]["error"]) + dp_gap = out["young"]["selection_rate"] - out["older"]["selection_rate"] + return pd.DataFrame(out).T, round(float(worst), 4), round(float(dp_gap), 4) + + +def fit_maxmin(Xtr, ytr, gtr, n_rounds=50, step=0.5): + """Rawlsian max-min: iteratively reweight groups toward the worst per-group + training error, so the optimizer minimizes the maximum group loss rather + than the average loss.""" + gw = {"older": 1.0, "young": 1.0} + for _ in range(n_rounds): + w = np.array([gw[g] for g in gtr], dtype=float) + w *= len(w) / w.sum() + model = LogisticRegression(max_iter=5000).fit(Xtr, ytr, sample_weight=w) + err = {g: 1 - (model.predict(Xtr)[gtr == g] == ytr[gtr == g]).mean() + for g in gw} + for g in gw: # multiplicative-weights update + gw[g] *= np.exp(step * err[g]) + total = sum(gw.values()) + gw = {g: v / total * len(gw) for g, v in gw.items()} + return model, gw + + +if __name__ == "__main__": + X, y, g = load_german_credit() + Xtr, Xte, ytr, yte, gtr, gte = train_test_split( + X, y, g, test_size=0.2, random_state=42, stratify=y) + mean, std = Xtr.mean(), Xtr.std().replace(0, 1) + Xtr_s, Xte_s = ((Xtr - mean) / std).to_numpy(), ((Xte - mean) / std).to_numpy() + + base = LogisticRegression(max_iter=5000).fit(Xtr_s, ytr) + b_tbl, b_worst, b_dp = per_group(yte, base.predict(Xte_s), gte) + print("=== BASELINE (minimise average loss) ===") + print(b_tbl.to_string()) + print(f"overall accuracy : {(base.predict(Xte_s) == yte).mean():.4f}") + print(f"worst-group error : {b_worst:.4f}") + print(f"demographic-parity gap: {b_dp:.4f} (young selection rate - older)") + + mm, gw = fit_maxmin(Xtr_s, ytr, gtr) + m_tbl, m_worst, m_dp = per_group(yte, mm.predict(Xte_s), gte) + print("\n=== MAX-MIN (minimise the worst group's loss) ===") + print(m_tbl.to_string()) + print(f"final group weights : older {gw['older']:.2f}x, young {gw['young']:.2f}x") + print(f"overall accuracy : {(mm.predict(Xte_s) == yte).mean():.4f}") + print(f"worst-group error : {m_worst:.4f}") + print(f"demographic-parity gap: {m_dp:.4f} (young selection rate - older)") +``` + +### Script Execution Output + +``` +=== BASELINE (minimise average loss) === + n accuracy error selection_rate +older 121.0 0.8099 0.1901 0.2066 +young 79.0 0.7089 0.2911 0.3418 +overall accuracy : 0.7700 +worst-group error : 0.2911 +demographic-parity gap: 0.1352 (young selection rate - older) + +=== MAX-MIN (minimise the worst group's loss) === + n accuracy error selection_rate +older 121.0 0.8099 0.1901 0.2066 +young 79.0 0.7215 0.2785 0.3291 +final group weights : older 0.57x, young 1.43x +overall accuracy : 0.7750 +worst-group error : 0.2785 +demographic-parity gap: 0.1225 (young selection rate - older) +``` + +(Deterministic with these library versions; a solver's last digits can shift across BLAS backends, so the reading below only uses the leading digits.) + +What the two runs show: + +**The baseline hides a large per-group gap inside a decent average.** Overall accuracy is 77%, but that splits into **81% for older applicants and 71% for younger ones** - a 10-point accuracy gap, the same disparity the frozen `accuracy_equality_diff` flags. Average-loss training has no reason to close it: younger applicants are the smaller group (79 of 200 test rows) and carry a higher base rate of bad outcomes, so the optimizer spends its capacity where the rows are. + +**Max-min lifts the worst group, and barely moves the other.** Reweighting drives the young group's weight up to 1.43x and the older group's down to 0.57x. Worst-group error falls from **29.1% to 27.9%**, the young group's accuracy rises about a point, the older group's predictions are unchanged, and overall accuracy is flat (77.0% to 77.5%). A small effect from a deliberately simple loop - stronger max-min methods (Martinez et al. 2020) push further - but it moves the right number in the right direction. + +**It does not fix demographic parity, and was never trying to.** The selection-rate gap goes from 13.5 points to 12.3 points and stays large and in the same direction. A model can move toward max-min fairness while still plainly failing demographic parity: the two objectives are optimizing different things. If you need the selection rates equalized, that is a parity constraint (S3/S4 in [mitigation-strategies.md](mitigation-strategies.md)), not a max-min objective. + +--- + +## Detection and Implementation Code + +The `fit_maxmin` function above is the implementation. The check below reports whether a max-min run actually reduced the worst-group loss and what it cost the other groups - the two numbers that decide whether the trade was worth it. + +```python +def maxmin_report(y_true, base_pred, maxmin_pred, groups): + """Compare a baseline model against a max-min run, per group. + + Flags the change in worst-group error and whether any other group got + worse (the price of the max-min trade). + """ + rows = {} + for g in sorted(set(groups)): + m = groups == g + base_err = float(1 - (base_pred[m] == y_true[m]).mean()) + mm_err = float(1 - (maxmin_pred[m] == y_true[m]).mean()) + rows[g] = {"n": int(m.sum()), + "baseline_error": round(base_err, 4), + "maxmin_error": round(mm_err, 4), + "delta": round(mm_err - base_err, 4)} + base_worst = max(r["baseline_error"] for r in rows.values()) + mm_worst = max(r["maxmin_error"] for r in rows.values()) + regressed = [g for g, r in rows.items() if r["delta"] > 1e-4] + return { + "per_group": rows, + "worst_group_error_before": round(base_worst, 4), + "worst_group_error_after": round(mm_worst, 4), + "worst_group_improved": mm_worst < base_worst, + "groups_that_got_worse": regressed, + } +``` + +--- + +## Limitations + +### 1. A tiny or noisy subgroup can dominate + +Max-min chases whichever group is currently worst. If one group is small, its measured loss is high-variance, and the loop can pour weight into fitting noise for a handful of rows - hurting everyone else for no real gain. In the example above the "young" test group is only 79 rows; a real deployment should pair max-min with a minimum-group-size floor, the same guard this repo's [significance module](../faircode/significance.py) applies with its small-sample warning. Without that floor, "worst-off group" is not a stable target. + +### 2. "Worst-off" depends on a chosen loss function + +The worst group under 0-1 error, under log-loss, under false-negative rate, and under calibration error can be three different groups. Max-min is only defined once you fix the loss, and that choice is a value judgment, not a technical default - the same point [Fairness Metric Conflicts](fairness-metric-conflicts.md) makes about parity metrics. + +### 3. It is silent on between-group gaps + +If your obligation is a legal disparate-impact standard (an 80%-rule selection-rate ratio), max-min does not help you meet it and can move you away from it, as the example shows. Max-min and parity are complementary tools for different requirements, not substitutes. + +### 4. Levelling down is technically permitted at the margin + +Minimizing the maximum loss is usually improved by *raising* the worst group, but a solver can also lower a better-off group if that reduces the maximum (for instance by shifting a shared threshold). Pareto-efficient formulations (Martinez et al. 2020) rule this out explicitly; a plain reweighting loop does not. + +--- + +## Related Concepts + +- [Why Fairness Metrics Conflict](fairness-metric-conflicts.md) - conflicts among parity metrics; the parity-vs-max-min conflict is a separate axis. +- [What Is Distributionally Robust Optimization (DRO) for Fairness?](distributionally-robust-optimization.md) - the worst-case-loss training method that is the online form of the reweighting loop here. +- [Mitigation Strategies](mitigation-strategies.md) - this repo's S0-S4 ladder, all of which target demographic parity rather than worst-group loss. +- [What Is Class Imbalance?](class-imbalance.md) - why the smaller, higher-base-rate group is the one average-loss training underserves. +- [What Is Demographic Parity?](demographic-parity.md) - the parity objective max-min is being contrasted against. + +--- + +## Related Projects in This Repo + +- [`German Credit Lending/`](../German%20Credit%20Lending/) - Audit 03, the dataset and `age` attribute used above. +- [`paper/results-frozen/results_fairness.csv`](../paper/results-frozen/) - the frozen `accuracy_equality_diff` figure this explainer cites as the repo's own record of the per-group accuracy gap. +- [`faircode/significance.py`](../faircode/significance.py) - the small-sample warning referenced in Limitation 1. + +--- + +## Further Reading + +- [Rawls, J. (1971, rev. 1999): *A Theory of Justice*, Harvard University Press](https://www.hup.harvard.edu/books/9780674000780) - the "difference principle": social and economic inequalities are just only if they benefit the least-advantaged members of society. +- [Hashimoto, Srivastava, Namkoong & Liang (2018): Fairness Without Demographics in Repeated Loss Minimization, ICML 2018, PMLR 80:1929-1938](https://proceedings.mlr.press/v80/hashimoto18a.html) - a DRO objective that bounds the minority group's risk without using group labels. +- [Martinez, Bertran & Sapiro (2020): Minimax Pareto Fairness: A Multi Objective Perspective, ICML 2020, PMLR 119:6755-6764](https://proceedings.mlr.press/v119/martinez20a.html) - treats each group's risk as a separate objective and finds a classifier that is minimax and Pareto-efficient, with no test-time access to the protected attribute. +- [Diana, Gill, Kearns, Kenthapadi & Roth (2021): Minimax Group Fairness: Algorithms and Experiments, AIES 2021](https://doi.org/10.1145/3461702.3462523) - practical algorithms for the min-max group-error objective and how it compares to statistical-parity mitigation. + +--- + +*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 fab4ce2..261874d 100644 --- a/faircode/_explainers/data.json +++ b/faircode/_explainers/data.json @@ -418,6 +418,20 @@ "subtitle": "The accuracy cost of a privacy guarantee falls hardest on underrepresented groups.", "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": "maxmin-fairness", + "title": "What Is Max-Min (Rawlsian) Fairness?", + "subtitle": "Make the worst-off group's outcome as good as possible, even if the groups end up unequal.", + "summary": "Learn how max-min (Rawlsian) fairness minimizes the maximum group-level loss instead of equalizing a rate across groups, and why a model can move toward it while still failing demographic parity. Worked on the Audit 03 German Credit data: an iterative group-reweighting loop cuts worst-group error from 29.1% to 27.9% and leaves the 12-point selection-rate gap almost untouched.", + "tags": ["metrics"] + }, + { + "slug": "distributionally-robust-optimization", + "title": "What Is Distributionally Robust Optimization (DRO) for Fairness?", + "subtitle": "Train against the worst subgroup your data might be under-representing, without naming a parity metric.", + "summary": "Learn how DRO trains a model to minimize its worst-case loss over an uncertainty set of subgroup distributions - an in-processing alternative to constraint-based methods like ExponentiatedGradient that needs neither a chosen fairness metric nor group labels at prediction time. Worked on the Audit 03 German Credit data: online group DRO reliably shifts training weight onto the worst group, but on a capacity-limited linear model it buys no worst-group generalization - Sagawa et al.'s point about regularization.", + "tags": ["metrics", "data"] } ] diff --git a/faircode/_explainers/distributionally-robust-optimization.md b/faircode/_explainers/distributionally-robust-optimization.md new file mode 100644 index 0000000..babcc6b --- /dev/null +++ b/faircode/_explainers/distributionally-robust-optimization.md @@ -0,0 +1,246 @@ +# What Is Distributionally Robust Optimization (DRO) for Fairness? + +> *A constraint-based fix needs you to name the fairness metric and the group up front. DRO instead trains against the worst subgroup your data might be under-representing - whichever one that turns out to be.* + +--- + +## The One-Sentence Definition + +**Distributionally Robust Optimization (DRO)** is an in-processing fairness technique that trains a model to minimize its *worst-case* loss over a set of plausible reweightings of the training distribution, rather than minimizing average loss or constraining a specific parity metric. + +--- + +## Why It Matters + +This repo's S3 in-processing strategy ([mitigation-strategies.md](mitigation-strategies.md)) is Fairlearn's `ExponentiatedGradient`: optimize accuracy subject to a fairness constraint, such as demographic parity, on a named protected attribute. That works well when you already know which metric matters and which group is at risk. It has two requirements baked in: + +- **You choose the fairness metric before training.** The constraint is demographic parity, or equalized odds, or one specific thing. A gap on a metric you did not constrain is not touched. +- **You choose the group before training.** The constraint is defined on `race`, or `age`, or a fixed intersection. A subgroup you did not think to check is invisible to it. + +DRO relaxes both. Instead of a constraint on one metric for one group, it hedges against the worst subgroup the training data might be under-representing - without committing to a parity metric at all, and (in the Hashimoto et al. 2018 form) without needing group labels. If the group actually being harmed is not the one you would have constrained, DRO can still help; `ExponentiatedGradient` cannot. + +The cost is a free parameter and no guarantee, both covered below. + +--- + +## Core Concept: The Min-Max Objective + +`ExponentiatedGradient` solves a **constrained** problem: + +``` +minimise average loss L(theta) +subject to |demographic_parity_gap(theta)| <= epsilon +``` + +Group DRO solves a **min-max** problem instead: + +``` +minimise over theta of max over Q in U of E_Q[ loss(theta) ] +``` + +where `U` is an *uncertainty set*: a ball of distributions around the empirical training distribution. `E_Q[loss]` is the expected loss if the data were reweighted to `Q`. The model is trained so that even the least favourable reweighting in `U` still has low loss. + +When `U` is "all reweightings that shift mass between predefined groups", this is **group DRO** (Sagawa et al. 2020), and it has a simple online algorithm: + +1. Keep a probability vector `q` over groups. +2. Each step, raise `q` for whichever group currently has the highest loss. +3. Fit the model on `q`-reweighted examples. +4. Repeat until `q` stabilizes. + +The size of `U` - how far `q` is allowed to move from the group base rates - is the **robustness knob**. Bigger `U` means the model hedges against more extreme reweightings. + +This is the same reweighting loop as iterative [max-min (Rawlsian) fairness](maxmin-fairness.md); the difference is framing. Max-min asks "make the worst group's *outcome* as good as possible." DRO asks "stay robust to a *distribution shift* that inflates the worst group" - and the uncertainty set `U` is the explicit statement of which shifts you are hedging against. + +--- + +## Concrete Example: German Credit Lending - Audit 03 + +Audit 03 ([`German Credit Lending/`](../German%20Credit%20Lending/)) predicts bad credit risk on 1,000 real loan records; the protected attribute is `age`, with "young" meaning under 30. + +For reference, this repo's frozen benchmark ([`paper/results-frozen/results_fairness.csv`](../paper/results-frozen/), the earlier snapshot - see [CLAUDE.md](../CLAUDE.md)) records the S3 `in_processing` strategy driving the baseline logistic-regression `demographic_parity_diff` on `age` from **-0.129 (p = 0.05) at S0 to +0.023 (p = 0.71) at S3** - the constraint closes the parity gap it was given. Group DRO does not target that metric at all; it targets worst-group loss. + +The script below runs an ERM (average-loss) baseline, then online group DRO, sweeping the robustness knob. Features drop `age` and `personal_status`; inputs are standardized; the split is 80/20 stratified at `random_state=42`. + +```python +import numpy as np +import pandas as pd +from sklearn.linear_model import LogisticRegression +from sklearn.metrics import log_loss +from sklearn.model_selection import train_test_split + + +def load_german_credit(path="German Credit Lending/credit_customers.csv"): + df = pd.read_csv(path) + df["y_bad"] = (df["class"] == "bad").astype(int) + df["age_group"] = np.where(df["age"] < 30, "young", "older") + drop = {"class", "y_bad", "age", "age_group", "personal_status"} + feats = [c for c in df.columns if c not in drop] + return pd.get_dummies(df[feats], drop_first=True), df["y_bad"].to_numpy(), df["age_group"].to_numpy() + + +def per_group(model, X, y, groups): + pred, proba = model.predict(X), model.predict_proba(X)[:, 1] + rows = {} + for g in ["older", "young"]: + m = groups == g + rows[g] = {"n": int(m.sum()), + "accuracy": round(float((pred[m] == y[m]).mean()), 4), + "log_loss": round(float(log_loss(y[m], proba[m], labels=[0, 1])), 4)} + worst = max(rows["older"]["log_loss"], rows["young"]["log_loss"]) + return pd.DataFrame(rows).T, round(float(worst), 4), round(float((pred == y).mean()), 4) + + +def fit_group_dro(Xtr, ytr, gtr, robustness=1.0, n_rounds=100): + """Online group DRO (Sagawa et al. 2020): keep a distribution q over groups, + each round upweight whichever group has the highest loss, and fit the model + on q-reweighted examples. `robustness` is the uncertainty-set knob - larger + means q chases the worst group harder.""" + q = {"older": 0.5, "young": 0.5} + frac = {g: (gtr == g).mean() for g in q} + for _ in range(n_rounds): + w = np.array([q[g] / frac[g] for g in gtr], dtype=float) + w *= len(w) / w.sum() + model = LogisticRegression(max_iter=5000).fit(Xtr, ytr, sample_weight=w) + proba = model.predict_proba(Xtr)[:, 1] + gl = {g: log_loss(ytr[gtr == g], proba[gtr == g], labels=[0, 1]) for g in q} + for g in q: + q[g] *= np.exp(robustness * gl[g]) + total = sum(q.values()) + q = {g: v / total for g, v in q.items()} + return model, q + + +if __name__ == "__main__": + X, y, g = load_german_credit() + Xtr, Xte, ytr, yte, gtr, gte = train_test_split( + X, y, g, test_size=0.2, random_state=42, stratify=y) + mean, std = Xtr.mean(), Xtr.std().replace(0, 1) + Xtr_s, Xte_s = ((Xtr - mean) / std).to_numpy(), ((Xte - mean) / std).to_numpy() + + erm = LogisticRegression(max_iter=5000).fit(Xtr_s, ytr) + tbl, worst, acc = per_group(erm, Xte_s, yte, gte) + print("=== ERM baseline (minimise average loss) ===") + print(tbl.to_string()) + print(f"overall accuracy : {acc:.4f}") + print(f"worst-group log_loss: {worst:.4f}\n") + + print("=== Group DRO: sweep the robustness knob ===") + print(f"{'robustness':>10} | {'q(young)':>8} | {'worst-group log_loss':>20} | {'overall acc':>11}") + for r in [0.0, 0.5, 1.0, 2.0, 5.0]: + model, q = fit_group_dro(Xtr_s, ytr, gtr, robustness=r) + _, w, a = per_group(model, Xte_s, yte, gte) + print(f"{r:>10.1f} | {q['young']:>8.2f} | {w:>20.4f} | {a:>11.4f}") +``` + +### Script Execution Output + +``` +=== ERM baseline (minimise average loss) === + n accuracy log_loss +older 121.0 0.8099 0.4285 +young 79.0 0.7089 0.5880 +overall accuracy : 0.7700 +worst-group log_loss: 0.5880 + +=== Group DRO: sweep the robustness knob === +robustness | q(young) | worst-group log_loss | overall acc + 0.0 | 0.50 | 0.5910 | 0.7700 + 0.5 | 0.62 | 0.5988 | 0.7750 + 1.0 | 0.63 | 0.5991 | 0.7700 + 2.0 | 0.63 | 0.5991 | 0.7700 + 5.0 | 0.63 | 0.5991 | 0.7700 +``` + +(Deterministic with these library versions; a solver's last digits can shift across BLAS backends.) + +Three things this shows: + +**DRO reliably redirects training weight to the worst group.** ERM weights groups by their size. As the robustness knob goes up, `q(young)` climbs from 0.50 to about 0.63 and then saturates - the min-max objective has found the worst group and is pushing on it. That part works exactly as designed. + +**On this problem, redirecting the weight buys no worst-group generalization.** The young group's held-out `log_loss` is 0.588 under ERM and does not improve under any robustness setting - it drifts slightly *up*, to ~0.599. Overall accuracy stays flat at 0.77. A linear model on 800 training rows is capacity-limited, not distribution-limited: the young group is genuinely harder to predict here, and reweighting the objective toward it cannot manufacture signal that is not in the features. + +**This is the paper's own headline finding, not a bug in the loop.** Sagawa et al. (2020) title their paper *"On the Importance of Regularization for Worst-Case Generalization"* precisely because plain group DRO increases worst-group training influence without improving worst-group test loss unless the model is strongly regularized (and, in their setting, overparameterized). The robustness knob is a real dial with a real cost - turn it up and `q` over-hedges toward an implausible worst case, shaving average-case accuracy - but it is not a guarantee of a fairer model. + +--- + +## Detection and Implementation Code + +`fit_group_dro` above is the implementation. The helper below reports whether a DRO run actually moved worst-group loss and what it cost average accuracy - the trade you are actually making. + +```python +def dro_report(erm_model, dro_model, X, y, groups): + """Compare an ERM model against a group-DRO model on held-out data.""" + def summarise(model): + pred, proba = model.predict(X), model.predict_proba(X)[:, 1] + per = {} + for g in sorted(set(groups)): + m = groups == g + from sklearn.metrics import log_loss + per[g] = {"acc": round(float((pred[m] == y[m]).mean()), 4), + "log_loss": round(float(log_loss(y[m], proba[m], labels=[0, 1])), 4)} + return per, round(float((pred == y).mean()), 4) + + erm_per, erm_acc = summarise(erm_model) + dro_per, dro_acc = summarise(dro_model) + erm_worst = max(v["log_loss"] for v in erm_per.values()) + dro_worst = max(v["log_loss"] for v in dro_per.values()) + return { + "erm_per_group": erm_per, + "dro_per_group": dro_per, + "worst_group_log_loss_before": erm_worst, + "worst_group_log_loss_after": dro_worst, + "worst_group_improved": dro_worst < erm_worst, + "overall_accuracy_delta": round(dro_acc - erm_acc, 4), + } +``` + +--- + +## Limitations + +### 1. The uncertainty-set size is a free parameter with no data-driven default + +`robustness` (equivalently, the radius of `U`) has to be picked. Too small and DRO collapses to ERM. Too large and the model hedges against reweightings that will never occur, trading real average-case accuracy for robustness to a fantasy worst case. There is no held-out quantity that tells you the "right" size - it encodes how much distribution shift you believe is plausible, which is a judgment call. + +### 2. It still needs group labels at training time + +The group-DRO form used here requires knowing each training example's group to maintain `q`. It removes the need to pick a *parity metric* up front, and it does not need group labels at prediction time, but it is not label-free. (Hashimoto et al. 2018's version drops the training-time group labels too, at the cost of hedging against *all* low-probability subpopulations, not just the ones you care about.) + +### 3. Worst-case training loss is not worst-case test loss + +As the example shows, pushing `q` toward the worst training group does not by itself improve that group's generalization. Without strong regularization or capacity control, group DRO can overfit the worst group's training set - the central caveat in Sagawa et al. (2020). + +### 4. "Worst group" is only defined once you fix the loss and the grouping + +DRO over groups defined by `age` says nothing about groups defined by `job` or by an `age x foreign_worker` intersection. And the worst group under log-loss, under 0-1 error, and under false-negative rate can differ. The uncertainty set is only as good as the grouping and loss you put into it. + +--- + +## Related Concepts + +- [Mitigation Strategies](mitigation-strategies.md) - this repo's S3 `ExponentiatedGradient` in-processing strategy, the constraint-based method DRO is contrasted against. +- [What Is Max-Min (Rawlsian) Fairness?](maxmin-fairness.md) - the same reweighting loop, framed as an objective on group outcomes rather than robustness to distribution shift. +- [What Is Distribution Shift?](distribution-shift.md) - the deployment-time phenomenon whose worst case DRO's uncertainty set is meant to bound. +- [What Is the Fairness-Accuracy Trade-off?](fairness-accuracy-tradeoff.md) - the average-case accuracy DRO spends when the robustness knob is turned up. +- [What Is Class Imbalance?](class-imbalance.md) - why average-loss training underserves the smaller, harder group DRO reweights toward. + +--- + +## Related Projects in This Repo + +- [`German Credit Lending/`](../German%20Credit%20Lending/) - Audit 03, the dataset and `age` attribute used above. +- [`faircode/strategies.py`](../faircode/strategies.py) - the S0-S4 implementation, including the S3 `ExponentiatedGradient` strategy DRO is compared with. +- [`paper/results-frozen/results_fairness.csv`](../paper/results-frozen/) - the frozen S0/S3 `demographic_parity_diff` figures cited above. + +--- + +## Further Reading + +- [Sagawa, Koh, Hashimoto & Liang (2020): Distributionally Robust Neural Networks for Group Shifts: On the Importance of Regularization for Worst-Case Generalization, ICLR 2020](https://arxiv.org/abs/1911.08731) - the group-DRO algorithm used here, and the finding that worst-case generalization needs strong regularization. +- [Hashimoto, Srivastava, Namkoong & Liang (2018): Fairness Without Demographics in Repeated Loss Minimization, ICML 2018, PMLR 80:1929-1938](https://proceedings.mlr.press/v80/hashimoto18a.html) - a DRO objective that bounds the minority group's risk without any group labels. +- [Duchi & Namkoong (2021): Learning Models with Uniform Performance via Distributionally Robust Optimization, Annals of Statistics 49(3), 1378-1406](https://doi.org/10.1214/20-AOS2004) - the statistical foundations of the uncertainty-set formulation. +- [Agarwal, Beygelzimer, Dudik, Langford & Wallach (2018): A Reductions Approach to Fair Classification, ICML 2018](https://proceedings.mlr.press/v80/agarwal18a.html) - the `ExponentiatedGradient` constraint-based method this repo uses at S3, for contrast. + +--- + +*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/maxmin-fairness.md b/faircode/_explainers/maxmin-fairness.md new file mode 100644 index 0000000..40498e8 --- /dev/null +++ b/faircode/_explainers/maxmin-fairness.md @@ -0,0 +1,247 @@ +# What Is Max-Min (Rawlsian) Fairness? + +> *Parity asks whether the groups are equal to each other. Max-min asks how good we can make the worst-off group's outcome - even if the groups end up unequal.* + +--- + +## The One-Sentence Definition + +**Max-min fairness** (also called Rawlsian fairness, after the "difference principle" in John Rawls' *A Theory of Justice*) is a fairness objective that minimizes the maximum group-level loss - it makes the worst-off group's outcome as good as possible - instead of equalizing a rate across groups the way parity metrics do. + +--- + +## Why It Matters + +Every parity-based metric this repo already covers - [demographic parity](demographic-parity.md), [equalized odds](equalized-odds.md), [predictive parity](predictive-parity.md) - measures a *difference between groups* and drives it toward zero. That framing has a well-known escape hatch: you can satisfy a parity constraint by making the better-off group worse rather than making the worse-off group better. "Parity in mediocrity" passes the test. + +Max-min fairness rejects that move by construction. It does not look at the gap between groups at all. It looks at the single worst group-level outcome and tries to lift it. Concretely: + +- A model can satisfy demographic parity while both groups have poor accuracy. Max-min would prefer a model where the worst group's accuracy is higher, even if that widens the between-group gap. +- Conversely, optimizing only for the worst-off group can *widen* a parity gap: if you pour modeling capacity into the group with the higher error rate, its predictions improve, but its selection rate can move away from the other group's. + +So max-min and parity are genuinely different objectives, not two names for the same goal - and a model tuned for one can fail the other. This repo's [Fairness Metric Conflicts](fairness-metric-conflicts.md) explainer covers conflicts *among* parity metrics; the conflict between parity and max-min is a separate axis it does not touch. + +--- + +## Core Concept: A Different Objective Function + +Write `L_g(theta)` for the expected loss (say, error rate) of model `theta` on group `g`. + +**Demographic parity** constrains a rate to be equal across groups: + +``` +minimise L(theta) [average loss] +subject to P(Y_hat = 1 | A = a) equal for all a +``` + +**Max-min (Rawlsian) fairness** changes the thing being minimized: + +``` +minimise over theta of max over groups g of L_g(theta) +``` + +There is no equality constraint. The objective is a `min` of a `max`: push down the largest per-group loss, then whatever the next-largest one is, and so on. If lifting the worst group also happens to help the others, fine; if it leaves a between-group gap, that gap is not penalized. + +Two standard ways to approximate it without a bespoke solver: + +1. **Iterative group reweighting.** Train, measure each group's loss, upweight whichever group is currently worst, retrain. Repeat. The training objective drifts from "average loss" toward "worst-group loss." This is the approach in the code below, and the online form of it is what [group distributionally robust optimization](distributionally-robust-optimization.md) does. +2. **Distributionally robust optimization (DRO).** Minimize the worst-case loss over a set of reweightings of the data. Hashimoto et al. (2018) show a DRO objective controls the minority group's risk *without needing group labels*, which is why max-min and DRO are usually discussed together. + +--- + +## Concrete Example: German Credit Lending - Audit 03 + +Audit 03 in this repo ([`German Credit Lending/`](../German%20Credit%20Lending/)) predicts bad credit risk on 1,000 real loan records. The protected attribute is `age`; here "young" means under 30 (the same cut `unfair.py` uses). + +This repo's own frozen benchmark already records the gap max-min targets. In `paper/results-frozen/results_fairness.csv` (the earlier reference snapshot; see [CLAUDE.md](../CLAUDE.md)), the S0 baseline logistic-regression model on `age` has an **`accuracy_equality_diff` of -0.153** (p = 0.031) - a statistically significant 15-point per-group accuracy gap. None of this repo's S1-S4 mitigation strategies ([mitigation-strategies.md](mitigation-strategies.md)) target that number; they all target demographic parity. Max-min fairness is the objective that goes after `accuracy_equality_diff` directly. + +The script below is an independent minimal implementation on the same dataset (not the S0-S4 harness): a plain logistic-regression baseline, then a max-min reweighting loop. Features drop `age` and `personal_status` (its proxy); inputs are standardized; the split is 80/20 stratified at `random_state=42`. + +```python +import numpy as np +import pandas as pd +from sklearn.linear_model import LogisticRegression +from sklearn.model_selection import train_test_split + + +def load_german_credit(path="German Credit Lending/credit_customers.csv"): + df = pd.read_csv(path) + df["y_bad"] = (df["class"] == "bad").astype(int) # 1 = bad credit risk + df["age_group"] = np.where(df["age"] < 30, "young", "older") + drop = {"class", "y_bad", "age", "age_group", "personal_status"} # drop age + its proxy + feats = [c for c in df.columns if c not in drop] + X = pd.get_dummies(df[feats], drop_first=True) + return X, df["y_bad"].to_numpy(), df["age_group"].to_numpy() + + +def per_group(y_true, y_pred, groups): + out = {} + for g in ["older", "young"]: + m = groups == g + acc = (y_pred[m] == y_true[m]).mean() + out[g] = {"n": int(m.sum()), "accuracy": round(float(acc), 4), + "error": round(float(1 - acc), 4), + "selection_rate": round(float(y_pred[m].mean()), 4)} + worst = max(out["older"]["error"], out["young"]["error"]) + dp_gap = out["young"]["selection_rate"] - out["older"]["selection_rate"] + return pd.DataFrame(out).T, round(float(worst), 4), round(float(dp_gap), 4) + + +def fit_maxmin(Xtr, ytr, gtr, n_rounds=50, step=0.5): + """Rawlsian max-min: iteratively reweight groups toward the worst per-group + training error, so the optimizer minimizes the maximum group loss rather + than the average loss.""" + gw = {"older": 1.0, "young": 1.0} + for _ in range(n_rounds): + w = np.array([gw[g] for g in gtr], dtype=float) + w *= len(w) / w.sum() + model = LogisticRegression(max_iter=5000).fit(Xtr, ytr, sample_weight=w) + err = {g: 1 - (model.predict(Xtr)[gtr == g] == ytr[gtr == g]).mean() + for g in gw} + for g in gw: # multiplicative-weights update + gw[g] *= np.exp(step * err[g]) + total = sum(gw.values()) + gw = {g: v / total * len(gw) for g, v in gw.items()} + return model, gw + + +if __name__ == "__main__": + X, y, g = load_german_credit() + Xtr, Xte, ytr, yte, gtr, gte = train_test_split( + X, y, g, test_size=0.2, random_state=42, stratify=y) + mean, std = Xtr.mean(), Xtr.std().replace(0, 1) + Xtr_s, Xte_s = ((Xtr - mean) / std).to_numpy(), ((Xte - mean) / std).to_numpy() + + base = LogisticRegression(max_iter=5000).fit(Xtr_s, ytr) + b_tbl, b_worst, b_dp = per_group(yte, base.predict(Xte_s), gte) + print("=== BASELINE (minimise average loss) ===") + print(b_tbl.to_string()) + print(f"overall accuracy : {(base.predict(Xte_s) == yte).mean():.4f}") + print(f"worst-group error : {b_worst:.4f}") + print(f"demographic-parity gap: {b_dp:.4f} (young selection rate - older)") + + mm, gw = fit_maxmin(Xtr_s, ytr, gtr) + m_tbl, m_worst, m_dp = per_group(yte, mm.predict(Xte_s), gte) + print("\n=== MAX-MIN (minimise the worst group's loss) ===") + print(m_tbl.to_string()) + print(f"final group weights : older {gw['older']:.2f}x, young {gw['young']:.2f}x") + print(f"overall accuracy : {(mm.predict(Xte_s) == yte).mean():.4f}") + print(f"worst-group error : {m_worst:.4f}") + print(f"demographic-parity gap: {m_dp:.4f} (young selection rate - older)") +``` + +### Script Execution Output + +``` +=== BASELINE (minimise average loss) === + n accuracy error selection_rate +older 121.0 0.8099 0.1901 0.2066 +young 79.0 0.7089 0.2911 0.3418 +overall accuracy : 0.7700 +worst-group error : 0.2911 +demographic-parity gap: 0.1352 (young selection rate - older) + +=== MAX-MIN (minimise the worst group's loss) === + n accuracy error selection_rate +older 121.0 0.8099 0.1901 0.2066 +young 79.0 0.7215 0.2785 0.3291 +final group weights : older 0.57x, young 1.43x +overall accuracy : 0.7750 +worst-group error : 0.2785 +demographic-parity gap: 0.1225 (young selection rate - older) +``` + +(Deterministic with these library versions; a solver's last digits can shift across BLAS backends, so the reading below only uses the leading digits.) + +What the two runs show: + +**The baseline hides a large per-group gap inside a decent average.** Overall accuracy is 77%, but that splits into **81% for older applicants and 71% for younger ones** - a 10-point accuracy gap, the same disparity the frozen `accuracy_equality_diff` flags. Average-loss training has no reason to close it: younger applicants are the smaller group (79 of 200 test rows) and carry a higher base rate of bad outcomes, so the optimizer spends its capacity where the rows are. + +**Max-min lifts the worst group, and barely moves the other.** Reweighting drives the young group's weight up to 1.43x and the older group's down to 0.57x. Worst-group error falls from **29.1% to 27.9%**, the young group's accuracy rises about a point, the older group's predictions are unchanged, and overall accuracy is flat (77.0% to 77.5%). A small effect from a deliberately simple loop - stronger max-min methods (Martinez et al. 2020) push further - but it moves the right number in the right direction. + +**It does not fix demographic parity, and was never trying to.** The selection-rate gap goes from 13.5 points to 12.3 points and stays large and in the same direction. A model can move toward max-min fairness while still plainly failing demographic parity: the two objectives are optimizing different things. If you need the selection rates equalized, that is a parity constraint (S3/S4 in [mitigation-strategies.md](mitigation-strategies.md)), not a max-min objective. + +--- + +## Detection and Implementation Code + +The `fit_maxmin` function above is the implementation. The check below reports whether a max-min run actually reduced the worst-group loss and what it cost the other groups - the two numbers that decide whether the trade was worth it. + +```python +def maxmin_report(y_true, base_pred, maxmin_pred, groups): + """Compare a baseline model against a max-min run, per group. + + Flags the change in worst-group error and whether any other group got + worse (the price of the max-min trade). + """ + rows = {} + for g in sorted(set(groups)): + m = groups == g + base_err = float(1 - (base_pred[m] == y_true[m]).mean()) + mm_err = float(1 - (maxmin_pred[m] == y_true[m]).mean()) + rows[g] = {"n": int(m.sum()), + "baseline_error": round(base_err, 4), + "maxmin_error": round(mm_err, 4), + "delta": round(mm_err - base_err, 4)} + base_worst = max(r["baseline_error"] for r in rows.values()) + mm_worst = max(r["maxmin_error"] for r in rows.values()) + regressed = [g for g, r in rows.items() if r["delta"] > 1e-4] + return { + "per_group": rows, + "worst_group_error_before": round(base_worst, 4), + "worst_group_error_after": round(mm_worst, 4), + "worst_group_improved": mm_worst < base_worst, + "groups_that_got_worse": regressed, + } +``` + +--- + +## Limitations + +### 1. A tiny or noisy subgroup can dominate + +Max-min chases whichever group is currently worst. If one group is small, its measured loss is high-variance, and the loop can pour weight into fitting noise for a handful of rows - hurting everyone else for no real gain. In the example above the "young" test group is only 79 rows; a real deployment should pair max-min with a minimum-group-size floor, the same guard this repo's [significance module](../faircode/significance.py) applies with its small-sample warning. Without that floor, "worst-off group" is not a stable target. + +### 2. "Worst-off" depends on a chosen loss function + +The worst group under 0-1 error, under log-loss, under false-negative rate, and under calibration error can be three different groups. Max-min is only defined once you fix the loss, and that choice is a value judgment, not a technical default - the same point [Fairness Metric Conflicts](fairness-metric-conflicts.md) makes about parity metrics. + +### 3. It is silent on between-group gaps + +If your obligation is a legal disparate-impact standard (an 80%-rule selection-rate ratio), max-min does not help you meet it and can move you away from it, as the example shows. Max-min and parity are complementary tools for different requirements, not substitutes. + +### 4. Levelling down is technically permitted at the margin + +Minimizing the maximum loss is usually improved by *raising* the worst group, but a solver can also lower a better-off group if that reduces the maximum (for instance by shifting a shared threshold). Pareto-efficient formulations (Martinez et al. 2020) rule this out explicitly; a plain reweighting loop does not. + +--- + +## Related Concepts + +- [Why Fairness Metrics Conflict](fairness-metric-conflicts.md) - conflicts among parity metrics; the parity-vs-max-min conflict is a separate axis. +- [What Is Distributionally Robust Optimization (DRO) for Fairness?](distributionally-robust-optimization.md) - the worst-case-loss training method that is the online form of the reweighting loop here. +- [Mitigation Strategies](mitigation-strategies.md) - this repo's S0-S4 ladder, all of which target demographic parity rather than worst-group loss. +- [What Is Class Imbalance?](class-imbalance.md) - why the smaller, higher-base-rate group is the one average-loss training underserves. +- [What Is Demographic Parity?](demographic-parity.md) - the parity objective max-min is being contrasted against. + +--- + +## Related Projects in This Repo + +- [`German Credit Lending/`](../German%20Credit%20Lending/) - Audit 03, the dataset and `age` attribute used above. +- [`paper/results-frozen/results_fairness.csv`](../paper/results-frozen/) - the frozen `accuracy_equality_diff` figure this explainer cites as the repo's own record of the per-group accuracy gap. +- [`faircode/significance.py`](../faircode/significance.py) - the small-sample warning referenced in Limitation 1. + +--- + +## Further Reading + +- [Rawls, J. (1971, rev. 1999): *A Theory of Justice*, Harvard University Press](https://www.hup.harvard.edu/books/9780674000780) - the "difference principle": social and economic inequalities are just only if they benefit the least-advantaged members of society. +- [Hashimoto, Srivastava, Namkoong & Liang (2018): Fairness Without Demographics in Repeated Loss Minimization, ICML 2018, PMLR 80:1929-1938](https://proceedings.mlr.press/v80/hashimoto18a.html) - a DRO objective that bounds the minority group's risk without using group labels. +- [Martinez, Bertran & Sapiro (2020): Minimax Pareto Fairness: A Multi Objective Perspective, ICML 2020, PMLR 119:6755-6764](https://proceedings.mlr.press/v119/martinez20a.html) - treats each group's risk as a separate objective and finds a classifier that is minimax and Pareto-efficient, with no test-time access to the protected attribute. +- [Diana, Gill, Kearns, Kenthapadi & Roth (2021): Minimax Group Fairness: Algorithms and Experiments, AIES 2021](https://doi.org/10.1145/3461702.3462523) - practical algorithms for the min-max group-error objective and how it compares to statistical-parity mitigation. + +--- + +*Part of [The Fair Code Project](https://instagram.com/thefaircodeproject) - exposing and fixing algorithmic bias with real data and open code.* diff --git a/llms-full.txt b/llms-full.txt index dd42aae..341f0f4 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -12242,3 +12242,510 @@ The Detection Code has no privacy accountant, so its `sigma` values do not map t *Part of [The Fair Code Project](https://instagram.com/thefaircodeproject) - exposing and fixing algorithmic bias with real data and open code.* +--- + +# What Is Max-Min (Rawlsian) Fairness? +URL: https://www.thefaircode.xyz/explainers/maxmin-fairness.html +Summary: Learn how max-min (Rawlsian) fairness minimizes the maximum group-level loss instead of equalizing a rate across groups, and why a model can move toward it while still failing demographic parity. Worked on the Audit 03 German Credit data: an iterative group-reweighting loop cuts worst-group error from 29.1% to 27.9% and leaves the 12-point selection-rate gap almost untouched. + +# What Is Max-Min (Rawlsian) Fairness? + +> *Parity asks whether the groups are equal to each other. Max-min asks how good we can make the worst-off group's outcome - even if the groups end up unequal.* + +--- + +## The One-Sentence Definition + +**Max-min fairness** (also called Rawlsian fairness, after the "difference principle" in John Rawls' *A Theory of Justice*) is a fairness objective that minimizes the maximum group-level loss - it makes the worst-off group's outcome as good as possible - instead of equalizing a rate across groups the way parity metrics do. + +--- + +## Why It Matters + +Every parity-based metric this repo already covers - [demographic parity](demographic-parity.md), [equalized odds](equalized-odds.md), [predictive parity](predictive-parity.md) - measures a *difference between groups* and drives it toward zero. That framing has a well-known escape hatch: you can satisfy a parity constraint by making the better-off group worse rather than making the worse-off group better. "Parity in mediocrity" passes the test. + +Max-min fairness rejects that move by construction. It does not look at the gap between groups at all. It looks at the single worst group-level outcome and tries to lift it. Concretely: + +- A model can satisfy demographic parity while both groups have poor accuracy. Max-min would prefer a model where the worst group's accuracy is higher, even if that widens the between-group gap. +- Conversely, optimizing only for the worst-off group can *widen* a parity gap: if you pour modeling capacity into the group with the higher error rate, its predictions improve, but its selection rate can move away from the other group's. + +So max-min and parity are genuinely different objectives, not two names for the same goal - and a model tuned for one can fail the other. This repo's [Fairness Metric Conflicts](fairness-metric-conflicts.md) explainer covers conflicts *among* parity metrics; the conflict between parity and max-min is a separate axis it does not touch. + +--- + +## Core Concept: A Different Objective Function + +Write `L_g(theta)` for the expected loss (say, error rate) of model `theta` on group `g`. + +**Demographic parity** constrains a rate to be equal across groups: + +``` +minimise L(theta) [average loss] +subject to P(Y_hat = 1 | A = a) equal for all a +``` + +**Max-min (Rawlsian) fairness** changes the thing being minimized: + +``` +minimise over theta of max over groups g of L_g(theta) +``` + +There is no equality constraint. The objective is a `min` of a `max`: push down the largest per-group loss, then whatever the next-largest one is, and so on. If lifting the worst group also happens to help the others, fine; if it leaves a between-group gap, that gap is not penalized. + +Two standard ways to approximate it without a bespoke solver: + +1. **Iterative group reweighting.** Train, measure each group's loss, upweight whichever group is currently worst, retrain. Repeat. The training objective drifts from "average loss" toward "worst-group loss." This is the approach in the code below, and the online form of it is what [group distributionally robust optimization](distributionally-robust-optimization.md) does. +2. **Distributionally robust optimization (DRO).** Minimize the worst-case loss over a set of reweightings of the data. Hashimoto et al. (2018) show a DRO objective controls the minority group's risk *without needing group labels*, which is why max-min and DRO are usually discussed together. + +--- + +## Concrete Example: German Credit Lending - Audit 03 + +Audit 03 in this repo ([`German Credit Lending/`](../German%20Credit%20Lending/)) predicts bad credit risk on 1,000 real loan records. The protected attribute is `age`; here "young" means under 30 (the same cut `unfair.py` uses). + +This repo's own frozen benchmark already records the gap max-min targets. In `paper/results-frozen/results_fairness.csv` (the earlier reference snapshot; see [CLAUDE.md](../CLAUDE.md)), the S0 baseline logistic-regression model on `age` has an **`accuracy_equality_diff` of -0.153** (p = 0.031) - a statistically significant 15-point per-group accuracy gap. None of this repo's S1-S4 mitigation strategies ([mitigation-strategies.md](mitigation-strategies.md)) target that number; they all target demographic parity. Max-min fairness is the objective that goes after `accuracy_equality_diff` directly. + +The script below is an independent minimal implementation on the same dataset (not the S0-S4 harness): a plain logistic-regression baseline, then a max-min reweighting loop. Features drop `age` and `personal_status` (its proxy); inputs are standardized; the split is 80/20 stratified at `random_state=42`. + +```python +import numpy as np +import pandas as pd +from sklearn.linear_model import LogisticRegression +from sklearn.model_selection import train_test_split + + +def load_german_credit(path="German Credit Lending/credit_customers.csv"): + df = pd.read_csv(path) + df["y_bad"] = (df["class"] == "bad").astype(int) # 1 = bad credit risk + df["age_group"] = np.where(df["age"] < 30, "young", "older") + drop = {"class", "y_bad", "age", "age_group", "personal_status"} # drop age + its proxy + feats = [c for c in df.columns if c not in drop] + X = pd.get_dummies(df[feats], drop_first=True) + return X, df["y_bad"].to_numpy(), df["age_group"].to_numpy() + + +def per_group(y_true, y_pred, groups): + out = {} + for g in ["older", "young"]: + m = groups == g + acc = (y_pred[m] == y_true[m]).mean() + out[g] = {"n": int(m.sum()), "accuracy": round(float(acc), 4), + "error": round(float(1 - acc), 4), + "selection_rate": round(float(y_pred[m].mean()), 4)} + worst = max(out["older"]["error"], out["young"]["error"]) + dp_gap = out["young"]["selection_rate"] - out["older"]["selection_rate"] + return pd.DataFrame(out).T, round(float(worst), 4), round(float(dp_gap), 4) + + +def fit_maxmin(Xtr, ytr, gtr, n_rounds=50, step=0.5): + """Rawlsian max-min: iteratively reweight groups toward the worst per-group + training error, so the optimizer minimizes the maximum group loss rather + than the average loss.""" + gw = {"older": 1.0, "young": 1.0} + for _ in range(n_rounds): + w = np.array([gw[g] for g in gtr], dtype=float) + w *= len(w) / w.sum() + model = LogisticRegression(max_iter=5000).fit(Xtr, ytr, sample_weight=w) + err = {g: 1 - (model.predict(Xtr)[gtr == g] == ytr[gtr == g]).mean() + for g in gw} + for g in gw: # multiplicative-weights update + gw[g] *= np.exp(step * err[g]) + total = sum(gw.values()) + gw = {g: v / total * len(gw) for g, v in gw.items()} + return model, gw + + +if __name__ == "__main__": + X, y, g = load_german_credit() + Xtr, Xte, ytr, yte, gtr, gte = train_test_split( + X, y, g, test_size=0.2, random_state=42, stratify=y) + mean, std = Xtr.mean(), Xtr.std().replace(0, 1) + Xtr_s, Xte_s = ((Xtr - mean) / std).to_numpy(), ((Xte - mean) / std).to_numpy() + + base = LogisticRegression(max_iter=5000).fit(Xtr_s, ytr) + b_tbl, b_worst, b_dp = per_group(yte, base.predict(Xte_s), gte) + print("=== BASELINE (minimise average loss) ===") + print(b_tbl.to_string()) + print(f"overall accuracy : {(base.predict(Xte_s) == yte).mean():.4f}") + print(f"worst-group error : {b_worst:.4f}") + print(f"demographic-parity gap: {b_dp:.4f} (young selection rate - older)") + + mm, gw = fit_maxmin(Xtr_s, ytr, gtr) + m_tbl, m_worst, m_dp = per_group(yte, mm.predict(Xte_s), gte) + print("\n=== MAX-MIN (minimise the worst group's loss) ===") + print(m_tbl.to_string()) + print(f"final group weights : older {gw['older']:.2f}x, young {gw['young']:.2f}x") + print(f"overall accuracy : {(mm.predict(Xte_s) == yte).mean():.4f}") + print(f"worst-group error : {m_worst:.4f}") + print(f"demographic-parity gap: {m_dp:.4f} (young selection rate - older)") +``` + +### Script Execution Output + +``` +=== BASELINE (minimise average loss) === + n accuracy error selection_rate +older 121.0 0.8099 0.1901 0.2066 +young 79.0 0.7089 0.2911 0.3418 +overall accuracy : 0.7700 +worst-group error : 0.2911 +demographic-parity gap: 0.1352 (young selection rate - older) + +=== MAX-MIN (minimise the worst group's loss) === + n accuracy error selection_rate +older 121.0 0.8099 0.1901 0.2066 +young 79.0 0.7215 0.2785 0.3291 +final group weights : older 0.57x, young 1.43x +overall accuracy : 0.7750 +worst-group error : 0.2785 +demographic-parity gap: 0.1225 (young selection rate - older) +``` + +(Deterministic with these library versions; a solver's last digits can shift across BLAS backends, so the reading below only uses the leading digits.) + +What the two runs show: + +**The baseline hides a large per-group gap inside a decent average.** Overall accuracy is 77%, but that splits into **81% for older applicants and 71% for younger ones** - a 10-point accuracy gap, the same disparity the frozen `accuracy_equality_diff` flags. Average-loss training has no reason to close it: younger applicants are the smaller group (79 of 200 test rows) and carry a higher base rate of bad outcomes, so the optimizer spends its capacity where the rows are. + +**Max-min lifts the worst group, and barely moves the other.** Reweighting drives the young group's weight up to 1.43x and the older group's down to 0.57x. Worst-group error falls from **29.1% to 27.9%**, the young group's accuracy rises about a point, the older group's predictions are unchanged, and overall accuracy is flat (77.0% to 77.5%). A small effect from a deliberately simple loop - stronger max-min methods (Martinez et al. 2020) push further - but it moves the right number in the right direction. + +**It does not fix demographic parity, and was never trying to.** The selection-rate gap goes from 13.5 points to 12.3 points and stays large and in the same direction. A model can move toward max-min fairness while still plainly failing demographic parity: the two objectives are optimizing different things. If you need the selection rates equalized, that is a parity constraint (S3/S4 in [mitigation-strategies.md](mitigation-strategies.md)), not a max-min objective. + +--- + +## Detection and Implementation Code + +The `fit_maxmin` function above is the implementation. The check below reports whether a max-min run actually reduced the worst-group loss and what it cost the other groups - the two numbers that decide whether the trade was worth it. + +```python +def maxmin_report(y_true, base_pred, maxmin_pred, groups): + """Compare a baseline model against a max-min run, per group. + + Flags the change in worst-group error and whether any other group got + worse (the price of the max-min trade). + """ + rows = {} + for g in sorted(set(groups)): + m = groups == g + base_err = float(1 - (base_pred[m] == y_true[m]).mean()) + mm_err = float(1 - (maxmin_pred[m] == y_true[m]).mean()) + rows[g] = {"n": int(m.sum()), + "baseline_error": round(base_err, 4), + "maxmin_error": round(mm_err, 4), + "delta": round(mm_err - base_err, 4)} + base_worst = max(r["baseline_error"] for r in rows.values()) + mm_worst = max(r["maxmin_error"] for r in rows.values()) + regressed = [g for g, r in rows.items() if r["delta"] > 1e-4] + return { + "per_group": rows, + "worst_group_error_before": round(base_worst, 4), + "worst_group_error_after": round(mm_worst, 4), + "worst_group_improved": mm_worst < base_worst, + "groups_that_got_worse": regressed, + } +``` + +--- + +## Limitations + +### 1. A tiny or noisy subgroup can dominate + +Max-min chases whichever group is currently worst. If one group is small, its measured loss is high-variance, and the loop can pour weight into fitting noise for a handful of rows - hurting everyone else for no real gain. In the example above the "young" test group is only 79 rows; a real deployment should pair max-min with a minimum-group-size floor, the same guard this repo's [significance module](../faircode/significance.py) applies with its small-sample warning. Without that floor, "worst-off group" is not a stable target. + +### 2. "Worst-off" depends on a chosen loss function + +The worst group under 0-1 error, under log-loss, under false-negative rate, and under calibration error can be three different groups. Max-min is only defined once you fix the loss, and that choice is a value judgment, not a technical default - the same point [Fairness Metric Conflicts](fairness-metric-conflicts.md) makes about parity metrics. + +### 3. It is silent on between-group gaps + +If your obligation is a legal disparate-impact standard (an 80%-rule selection-rate ratio), max-min does not help you meet it and can move you away from it, as the example shows. Max-min and parity are complementary tools for different requirements, not substitutes. + +### 4. Levelling down is technically permitted at the margin + +Minimizing the maximum loss is usually improved by *raising* the worst group, but a solver can also lower a better-off group if that reduces the maximum (for instance by shifting a shared threshold). Pareto-efficient formulations (Martinez et al. 2020) rule this out explicitly; a plain reweighting loop does not. + +--- + +## Related Concepts + +- [Why Fairness Metrics Conflict](fairness-metric-conflicts.md) - conflicts among parity metrics; the parity-vs-max-min conflict is a separate axis. +- [What Is Distributionally Robust Optimization (DRO) for Fairness?](distributionally-robust-optimization.md) - the worst-case-loss training method that is the online form of the reweighting loop here. +- [Mitigation Strategies](mitigation-strategies.md) - this repo's S0-S4 ladder, all of which target demographic parity rather than worst-group loss. +- [What Is Class Imbalance?](class-imbalance.md) - why the smaller, higher-base-rate group is the one average-loss training underserves. +- [What Is Demographic Parity?](demographic-parity.md) - the parity objective max-min is being contrasted against. + +--- + +## Related Projects in This Repo + +- [`German Credit Lending/`](../German%20Credit%20Lending/) - Audit 03, the dataset and `age` attribute used above. +- [`paper/results-frozen/results_fairness.csv`](../paper/results-frozen/) - the frozen `accuracy_equality_diff` figure this explainer cites as the repo's own record of the per-group accuracy gap. +- [`faircode/significance.py`](../faircode/significance.py) - the small-sample warning referenced in Limitation 1. + +--- + +## Further Reading + +- [Rawls, J. (1971, rev. 1999): *A Theory of Justice*, Harvard University Press](https://www.hup.harvard.edu/books/9780674000780) - the "difference principle": social and economic inequalities are just only if they benefit the least-advantaged members of society. +- [Hashimoto, Srivastava, Namkoong & Liang (2018): Fairness Without Demographics in Repeated Loss Minimization, ICML 2018, PMLR 80:1929-1938](https://proceedings.mlr.press/v80/hashimoto18a.html) - a DRO objective that bounds the minority group's risk without using group labels. +- [Martinez, Bertran & Sapiro (2020): Minimax Pareto Fairness: A Multi Objective Perspective, ICML 2020, PMLR 119:6755-6764](https://proceedings.mlr.press/v119/martinez20a.html) - treats each group's risk as a separate objective and finds a classifier that is minimax and Pareto-efficient, with no test-time access to the protected attribute. +- [Diana, Gill, Kearns, Kenthapadi & Roth (2021): Minimax Group Fairness: Algorithms and Experiments, AIES 2021](https://doi.org/10.1145/3461702.3462523) - practical algorithms for the min-max group-error objective and how it compares to statistical-parity mitigation. + +--- + +*Part of [The Fair Code Project](https://instagram.com/thefaircodeproject) - exposing and fixing algorithmic bias with real data and open code.* + +--- + +# What Is Distributionally Robust Optimization (DRO) for Fairness? +URL: https://www.thefaircode.xyz/explainers/distributionally-robust-optimization.html +Summary: Learn how DRO trains a model to minimize its worst-case loss over an uncertainty set of subgroup distributions - an in-processing alternative to constraint-based methods like ExponentiatedGradient that needs neither a chosen fairness metric nor group labels at prediction time. Worked on the Audit 03 German Credit data: online group DRO reliably shifts training weight onto the worst group, but on a capacity-limited linear model it buys no worst-group generalization - Sagawa et al.'s point about regularization. + +# What Is Distributionally Robust Optimization (DRO) for Fairness? + +> *A constraint-based fix needs you to name the fairness metric and the group up front. DRO instead trains against the worst subgroup your data might be under-representing - whichever one that turns out to be.* + +--- + +## The One-Sentence Definition + +**Distributionally Robust Optimization (DRO)** is an in-processing fairness technique that trains a model to minimize its *worst-case* loss over a set of plausible reweightings of the training distribution, rather than minimizing average loss or constraining a specific parity metric. + +--- + +## Why It Matters + +This repo's S3 in-processing strategy ([mitigation-strategies.md](mitigation-strategies.md)) is Fairlearn's `ExponentiatedGradient`: optimize accuracy subject to a fairness constraint, such as demographic parity, on a named protected attribute. That works well when you already know which metric matters and which group is at risk. It has two requirements baked in: + +- **You choose the fairness metric before training.** The constraint is demographic parity, or equalized odds, or one specific thing. A gap on a metric you did not constrain is not touched. +- **You choose the group before training.** The constraint is defined on `race`, or `age`, or a fixed intersection. A subgroup you did not think to check is invisible to it. + +DRO relaxes both. Instead of a constraint on one metric for one group, it hedges against the worst subgroup the training data might be under-representing - without committing to a parity metric at all, and (in the Hashimoto et al. 2018 form) without needing group labels. If the group actually being harmed is not the one you would have constrained, DRO can still help; `ExponentiatedGradient` cannot. + +The cost is a free parameter and no guarantee, both covered below. + +--- + +## Core Concept: The Min-Max Objective + +`ExponentiatedGradient` solves a **constrained** problem: + +``` +minimise average loss L(theta) +subject to |demographic_parity_gap(theta)| <= epsilon +``` + +Group DRO solves a **min-max** problem instead: + +``` +minimise over theta of max over Q in U of E_Q[ loss(theta) ] +``` + +where `U` is an *uncertainty set*: a ball of distributions around the empirical training distribution. `E_Q[loss]` is the expected loss if the data were reweighted to `Q`. The model is trained so that even the least favourable reweighting in `U` still has low loss. + +When `U` is "all reweightings that shift mass between predefined groups", this is **group DRO** (Sagawa et al. 2020), and it has a simple online algorithm: + +1. Keep a probability vector `q` over groups. +2. Each step, raise `q` for whichever group currently has the highest loss. +3. Fit the model on `q`-reweighted examples. +4. Repeat until `q` stabilizes. + +The size of `U` - how far `q` is allowed to move from the group base rates - is the **robustness knob**. Bigger `U` means the model hedges against more extreme reweightings. + +This is the same reweighting loop as iterative [max-min (Rawlsian) fairness](maxmin-fairness.md); the difference is framing. Max-min asks "make the worst group's *outcome* as good as possible." DRO asks "stay robust to a *distribution shift* that inflates the worst group" - and the uncertainty set `U` is the explicit statement of which shifts you are hedging against. + +--- + +## Concrete Example: German Credit Lending - Audit 03 + +Audit 03 ([`German Credit Lending/`](../German%20Credit%20Lending/)) predicts bad credit risk on 1,000 real loan records; the protected attribute is `age`, with "young" meaning under 30. + +For reference, this repo's frozen benchmark ([`paper/results-frozen/results_fairness.csv`](../paper/results-frozen/), the earlier snapshot - see [CLAUDE.md](../CLAUDE.md)) records the S3 `in_processing` strategy driving the baseline logistic-regression `demographic_parity_diff` on `age` from **-0.129 (p = 0.05) at S0 to +0.023 (p = 0.71) at S3** - the constraint closes the parity gap it was given. Group DRO does not target that metric at all; it targets worst-group loss. + +The script below runs an ERM (average-loss) baseline, then online group DRO, sweeping the robustness knob. Features drop `age` and `personal_status`; inputs are standardized; the split is 80/20 stratified at `random_state=42`. + +```python +import numpy as np +import pandas as pd +from sklearn.linear_model import LogisticRegression +from sklearn.metrics import log_loss +from sklearn.model_selection import train_test_split + + +def load_german_credit(path="German Credit Lending/credit_customers.csv"): + df = pd.read_csv(path) + df["y_bad"] = (df["class"] == "bad").astype(int) + df["age_group"] = np.where(df["age"] < 30, "young", "older") + drop = {"class", "y_bad", "age", "age_group", "personal_status"} + feats = [c for c in df.columns if c not in drop] + return pd.get_dummies(df[feats], drop_first=True), df["y_bad"].to_numpy(), df["age_group"].to_numpy() + + +def per_group(model, X, y, groups): + pred, proba = model.predict(X), model.predict_proba(X)[:, 1] + rows = {} + for g in ["older", "young"]: + m = groups == g + rows[g] = {"n": int(m.sum()), + "accuracy": round(float((pred[m] == y[m]).mean()), 4), + "log_loss": round(float(log_loss(y[m], proba[m], labels=[0, 1])), 4)} + worst = max(rows["older"]["log_loss"], rows["young"]["log_loss"]) + return pd.DataFrame(rows).T, round(float(worst), 4), round(float((pred == y).mean()), 4) + + +def fit_group_dro(Xtr, ytr, gtr, robustness=1.0, n_rounds=100): + """Online group DRO (Sagawa et al. 2020): keep a distribution q over groups, + each round upweight whichever group has the highest loss, and fit the model + on q-reweighted examples. `robustness` is the uncertainty-set knob - larger + means q chases the worst group harder.""" + q = {"older": 0.5, "young": 0.5} + frac = {g: (gtr == g).mean() for g in q} + for _ in range(n_rounds): + w = np.array([q[g] / frac[g] for g in gtr], dtype=float) + w *= len(w) / w.sum() + model = LogisticRegression(max_iter=5000).fit(Xtr, ytr, sample_weight=w) + proba = model.predict_proba(Xtr)[:, 1] + gl = {g: log_loss(ytr[gtr == g], proba[gtr == g], labels=[0, 1]) for g in q} + for g in q: + q[g] *= np.exp(robustness * gl[g]) + total = sum(q.values()) + q = {g: v / total for g, v in q.items()} + return model, q + + +if __name__ == "__main__": + X, y, g = load_german_credit() + Xtr, Xte, ytr, yte, gtr, gte = train_test_split( + X, y, g, test_size=0.2, random_state=42, stratify=y) + mean, std = Xtr.mean(), Xtr.std().replace(0, 1) + Xtr_s, Xte_s = ((Xtr - mean) / std).to_numpy(), ((Xte - mean) / std).to_numpy() + + erm = LogisticRegression(max_iter=5000).fit(Xtr_s, ytr) + tbl, worst, acc = per_group(erm, Xte_s, yte, gte) + print("=== ERM baseline (minimise average loss) ===") + print(tbl.to_string()) + print(f"overall accuracy : {acc:.4f}") + print(f"worst-group log_loss: {worst:.4f}\n") + + print("=== Group DRO: sweep the robustness knob ===") + print(f"{'robustness':>10} | {'q(young)':>8} | {'worst-group log_loss':>20} | {'overall acc':>11}") + for r in [0.0, 0.5, 1.0, 2.0, 5.0]: + model, q = fit_group_dro(Xtr_s, ytr, gtr, robustness=r) + _, w, a = per_group(model, Xte_s, yte, gte) + print(f"{r:>10.1f} | {q['young']:>8.2f} | {w:>20.4f} | {a:>11.4f}") +``` + +### Script Execution Output + +``` +=== ERM baseline (minimise average loss) === + n accuracy log_loss +older 121.0 0.8099 0.4285 +young 79.0 0.7089 0.5880 +overall accuracy : 0.7700 +worst-group log_loss: 0.5880 + +=== Group DRO: sweep the robustness knob === +robustness | q(young) | worst-group log_loss | overall acc + 0.0 | 0.50 | 0.5910 | 0.7700 + 0.5 | 0.62 | 0.5988 | 0.7750 + 1.0 | 0.63 | 0.5991 | 0.7700 + 2.0 | 0.63 | 0.5991 | 0.7700 + 5.0 | 0.63 | 0.5991 | 0.7700 +``` + +(Deterministic with these library versions; a solver's last digits can shift across BLAS backends.) + +Three things this shows: + +**DRO reliably redirects training weight to the worst group.** ERM weights groups by their size. As the robustness knob goes up, `q(young)` climbs from 0.50 to about 0.63 and then saturates - the min-max objective has found the worst group and is pushing on it. That part works exactly as designed. + +**On this problem, redirecting the weight buys no worst-group generalization.** The young group's held-out `log_loss` is 0.588 under ERM and does not improve under any robustness setting - it drifts slightly *up*, to ~0.599. Overall accuracy stays flat at 0.77. A linear model on 800 training rows is capacity-limited, not distribution-limited: the young group is genuinely harder to predict here, and reweighting the objective toward it cannot manufacture signal that is not in the features. + +**This is the paper's own headline finding, not a bug in the loop.** Sagawa et al. (2020) title their paper *"On the Importance of Regularization for Worst-Case Generalization"* precisely because plain group DRO increases worst-group training influence without improving worst-group test loss unless the model is strongly regularized (and, in their setting, overparameterized). The robustness knob is a real dial with a real cost - turn it up and `q` over-hedges toward an implausible worst case, shaving average-case accuracy - but it is not a guarantee of a fairer model. + +--- + +## Detection and Implementation Code + +`fit_group_dro` above is the implementation. The helper below reports whether a DRO run actually moved worst-group loss and what it cost average accuracy - the trade you are actually making. + +```python +def dro_report(erm_model, dro_model, X, y, groups): + """Compare an ERM model against a group-DRO model on held-out data.""" + def summarise(model): + pred, proba = model.predict(X), model.predict_proba(X)[:, 1] + per = {} + for g in sorted(set(groups)): + m = groups == g + from sklearn.metrics import log_loss + per[g] = {"acc": round(float((pred[m] == y[m]).mean()), 4), + "log_loss": round(float(log_loss(y[m], proba[m], labels=[0, 1])), 4)} + return per, round(float((pred == y).mean()), 4) + + erm_per, erm_acc = summarise(erm_model) + dro_per, dro_acc = summarise(dro_model) + erm_worst = max(v["log_loss"] for v in erm_per.values()) + dro_worst = max(v["log_loss"] for v in dro_per.values()) + return { + "erm_per_group": erm_per, + "dro_per_group": dro_per, + "worst_group_log_loss_before": erm_worst, + "worst_group_log_loss_after": dro_worst, + "worst_group_improved": dro_worst < erm_worst, + "overall_accuracy_delta": round(dro_acc - erm_acc, 4), + } +``` + +--- + +## Limitations + +### 1. The uncertainty-set size is a free parameter with no data-driven default + +`robustness` (equivalently, the radius of `U`) has to be picked. Too small and DRO collapses to ERM. Too large and the model hedges against reweightings that will never occur, trading real average-case accuracy for robustness to a fantasy worst case. There is no held-out quantity that tells you the "right" size - it encodes how much distribution shift you believe is plausible, which is a judgment call. + +### 2. It still needs group labels at training time + +The group-DRO form used here requires knowing each training example's group to maintain `q`. It removes the need to pick a *parity metric* up front, and it does not need group labels at prediction time, but it is not label-free. (Hashimoto et al. 2018's version drops the training-time group labels too, at the cost of hedging against *all* low-probability subpopulations, not just the ones you care about.) + +### 3. Worst-case training loss is not worst-case test loss + +As the example shows, pushing `q` toward the worst training group does not by itself improve that group's generalization. Without strong regularization or capacity control, group DRO can overfit the worst group's training set - the central caveat in Sagawa et al. (2020). + +### 4. "Worst group" is only defined once you fix the loss and the grouping + +DRO over groups defined by `age` says nothing about groups defined by `job` or by an `age x foreign_worker` intersection. And the worst group under log-loss, under 0-1 error, and under false-negative rate can differ. The uncertainty set is only as good as the grouping and loss you put into it. + +--- + +## Related Concepts + +- [Mitigation Strategies](mitigation-strategies.md) - this repo's S3 `ExponentiatedGradient` in-processing strategy, the constraint-based method DRO is contrasted against. +- [What Is Max-Min (Rawlsian) Fairness?](maxmin-fairness.md) - the same reweighting loop, framed as an objective on group outcomes rather than robustness to distribution shift. +- [What Is Distribution Shift?](distribution-shift.md) - the deployment-time phenomenon whose worst case DRO's uncertainty set is meant to bound. +- [What Is the Fairness-Accuracy Trade-off?](fairness-accuracy-tradeoff.md) - the average-case accuracy DRO spends when the robustness knob is turned up. +- [What Is Class Imbalance?](class-imbalance.md) - why average-loss training underserves the smaller, harder group DRO reweights toward. + +--- + +## Related Projects in This Repo + +- [`German Credit Lending/`](../German%20Credit%20Lending/) - Audit 03, the dataset and `age` attribute used above. +- [`faircode/strategies.py`](../faircode/strategies.py) - the S0-S4 implementation, including the S3 `ExponentiatedGradient` strategy DRO is compared with. +- [`paper/results-frozen/results_fairness.csv`](../paper/results-frozen/) - the frozen S0/S3 `demographic_parity_diff` figures cited above. + +--- + +## Further Reading + +- [Sagawa, Koh, Hashimoto & Liang (2020): Distributionally Robust Neural Networks for Group Shifts: On the Importance of Regularization for Worst-Case Generalization, ICLR 2020](https://arxiv.org/abs/1911.08731) - the group-DRO algorithm used here, and the finding that worst-case generalization needs strong regularization. +- [Hashimoto, Srivastava, Namkoong & Liang (2018): Fairness Without Demographics in Repeated Loss Minimization, ICML 2018, PMLR 80:1929-1938](https://proceedings.mlr.press/v80/hashimoto18a.html) - a DRO objective that bounds the minority group's risk without any group labels. +- [Duchi & Namkoong (2021): Learning Models with Uniform Performance via Distributionally Robust Optimization, Annals of Statistics 49(3), 1378-1406](https://doi.org/10.1214/20-AOS2004) - the statistical foundations of the uncertainty-set formulation. +- [Agarwal, Beygelzimer, Dudik, Langford & Wallach (2018): A Reductions Approach to Fair Classification, ICML 2018](https://proceedings.mlr.press/v80/agarwal18a.html) - the `ExponentiatedGradient` constraint-based method this repo uses at S3, for contrast. + +--- + +*Part of [The Fair Code Project](https://instagram.com/thefaircodeproject) - exposing and fixing algorithmic bias with real data and open code.* + diff --git a/sitemap.xml b/sitemap.xml index 15b23d8..bde2c28 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -248,4 +248,12 @@ https://www.thefaircode.xyz/explainers/differential-privacy.html 2026-09-09 + + https://www.thefaircode.xyz/explainers/maxmin-fairness.html + 2026-09-10 + + + https://www.thefaircode.xyz/explainers/distributionally-robust-optimization.html + 2026-09-10 +