Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/api/metrics/pyhealth.metrics.fairness.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@

.. currentmodule:: pyhealth.metrics.fairness_utils

Both ``disparate_impact`` and ``statistical_parity_difference`` raise
``ValueError`` if either the protected or unprotected group has zero
instances -- the favorable-outcome rate is undefined for an empty group,
so this is always an error rather than a value (e.g. 0 or NaN) that could
silently poison a downstream average across folds/seeds.

.. autofunction:: disparate_impact

.. autofunction:: statistical_parity_difference
Expand Down
4 changes: 2 additions & 2 deletions examples/tutorials/tutorial_pyhealth_metrics.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@
{
"cell_type": "markdown",
"metadata": {},
"source": "---\n## Part 4: Fairness Metrics\n\nFairness metrics assess whether a model's performance is **equitable across subgroups** defined by sensitive attributes (e.g., race, sex, age group). This is crucial in clinical AI to avoid perpetuating historical health disparities.\n\n```python\nfairness_metrics_fn(\n y_true: np.ndarray, # (n_samples,) true labels\n y_prob: np.ndarray, # (n_samples,) predicted probabilities\n sensitive_attributes: np.ndarray, # (n_samples,) 1=protected group, 0=unprotected\n favorable_outcome: int = 1, # which label value is considered positive\n metrics: Optional[List[str]] = None, # default: both below\n threshold: float = 0.5,\n)\n```\n\n### Supported fairness metrics\n\n| Metric | Formula | Interpretation |\n|--------|---------|----------------|\n| `disparate_impact` | P(\u0177=1 | protected) / P(\u0177=1 | unprotected) | Should be \u2265 0.8 (80% rule). 1.0 = perfect parity |\n| `statistical_parity_difference` | P(\u0177=1 | protected) \u2212 P(\u0177=1 | unprotected) | Should be close to 0. Negative = protected group predicted positive less often |"
"source": "---\n## Part 4: Fairness Metrics\n\nFairness metrics assess whether a model's performance is **equitable across subgroups** defined by sensitive attributes (e.g., race, sex, age group). This is crucial in clinical AI to avoid perpetuating historical health disparities.\n\n```python\nfairness_metrics_fn(\n y_true: np.ndarray, # (n_samples,) true labels\n y_prob: np.ndarray, # (n_samples,) predicted probabilities\n sensitive_attributes: np.ndarray, # (n_samples,) 1=protected group, 0=unprotected\n favorable_outcome: int = 1, # which label value is considered positive\n metrics: Optional[List[str]] = None, # default: both below\n threshold: float = 0.5,\n)\n```\n\n### Supported fairness metrics\n\n| Metric | Formula | Interpretation |\n|--------|---------|----------------|\n| `disparate_impact` | P(\u0177=1 | protected) / P(\u0177=1 | unprotected) | Should be \u2265 0.8 (80% rule). 1.0 = perfect parity |\n| `statistical_parity_difference` | P(\u0177=1 | protected) \u2212 P(\u0177=1 | unprotected) | Should be close to 0. Negative = protected group predicted positive less often |\n\n> **Note:** `disparate_impact` and `statistical_parity_difference` raise `ValueError` if either the protected or unprotected group has zero instances in the batch being evaluated -- the favorable-outcome rate is undefined for an empty group, so this is always an error rather than a silently-returned NaN that could poison an average across folds/seeds."
},
{
"cell_type": "code",
Expand Down Expand Up @@ -176,4 +176,4 @@
},
"nbformat": 4,
"nbformat_minor": 5
}
}
88 changes: 78 additions & 10 deletions pyhealth/metrics/fairness_utils/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,77 @@
- Unprotected group: U
"""


def _favorable_outcome_rate(
group_y_pred: np.ndarray, favorable_outcome: int, group_name: str
) -> float:
"""Computes P(y_pred = favorable_outcome) for a single group's predictions.

Args:
group_y_pred: Predicted target values for one group (already
filtered by the sensitive attribute), of shape (n_group,).
favorable_outcome: Label value which is considered favorable.
group_name: Human-readable group name, used only in the error
message if the group is empty.

Returns:
The favorable-outcome rate for this group. This is a genuine
float, never NaN: an empty group raises ValueError instead of
silently producing a 0/0 NaN that downstream code (or a naive
``== 0`` check) can't detect, since NaN never equals 0.

Raises:
ValueError: If the group has no instances (0 samples). The rate
is undefined in that case -- this is different from a
non-empty group whose favorable-outcome rate happens to be
exactly 0, which is a legitimate value.
"""
n = len(group_y_pred)
if n == 0:
raise ValueError(
f"The {group_name} group has no instances (0 samples); "
"the favorable-outcome rate is undefined."
)
return float(np.sum(group_y_pred == favorable_outcome) / n)


def disparate_impact(sensitive_attributes: np.ndarray, y_pred: np.ndarray, favorable_outcome: int = 1, allow_zero_division = False, epsilon: float = 1e-8) -> float:
"""
Computes the disparate impact between the the protected and unprotected group.

disparate_impact = P(y_pred = favorable_outcome | P) / P(y_pred = favorable_outcome | U)

Args:
sensitive_attributes: Sensitive attributes of shape (n_samples,) where 1 is the protected group and 0 is the unprotected group.
y_pred: Predicted target values of shape (n_samples,).
favorable_outcome: Label value which is considered favorable (i.e. "positive").
allow_zero_division: If True, use epsilon instead of 0 in the denominator if the denominator is 0. Otherwise, raise a ValueError.

Returns:
The disparate impact between the protected and unprotected group.

Raises:
ValueError: If either group has no instances at all (this is
always an error, regardless of allow_zero_division -- there
is no meaningful epsilon substitute for a group we have zero
information about), or if the unprotected group's
favorable-outcome rate is exactly 0 and allow_zero_division
is False.

Examples:
>>> import numpy as np
>>> from pyhealth.metrics.fairness_utils import disparate_impact
>>> sensitive_attributes = np.array([0, 0, 1, 1, 1])
>>> y_pred = np.array([1, 0, 1, 1, 0])
>>> disparate_impact(sensitive_attributes, y_pred)
1.3333333333333333
"""

p_fav_unpr = np.sum(y_pred[sensitive_attributes == 0] == favorable_outcome) / len(y_pred[sensitive_attributes == 0])
p_fav_prot = np.sum(y_pred[sensitive_attributes == 1] == favorable_outcome) / len(y_pred[sensitive_attributes == 1])
p_fav_unpr = _favorable_outcome_rate(
y_pred[sensitive_attributes == 0], favorable_outcome, "unprotected"
)
p_fav_prot = _favorable_outcome_rate(
y_pred[sensitive_attributes == 1], favorable_outcome, "protected"
)

if p_fav_unpr == 0:
if allow_zero_division:
Expand All @@ -46,15 +99,30 @@ def statistical_parity_difference(sensitive_attributes: np.ndarray, y_pred: np.n
favorable_outcome: Label value which is considered favorable (i.e. "positive").
Returns:
The statistical parity difference between the protected and unprotected group.

Raises:
ValueError: If either group has no instances at all. Unlike
disparate_impact, a favorable-outcome rate of exactly 0 for
a non-empty group is not an error here (it's a legitimate
value for a difference, e.g. 0 - 0.3 = -0.3).

Examples:
>>> import numpy as np
>>> from pyhealth.metrics.fairness_utils import statistical_parity_difference
>>> sensitive_attributes = np.array([0, 0, 1, 1, 1])
>>> y_pred = np.array([1, 0, 1, 1, 0])
>>> statistical_parity_difference(sensitive_attributes, y_pred)
0.16666666666666663
"""
p_fav_unpr = _favorable_outcome_rate(
y_pred[sensitive_attributes == 0], favorable_outcome, "unprotected"
)
p_fav_prot = _favorable_outcome_rate(
y_pred[sensitive_attributes == 1], favorable_outcome, "protected"
)

p_fav_unpr = np.sum(y_pred[sensitive_attributes == 0] == favorable_outcome) / len(y_pred[sensitive_attributes == 0])
p_fav_prot = np.sum(y_pred[sensitive_attributes == 1] == favorable_outcome) / len(y_pred[sensitive_attributes == 1])

statistical_parity_difference_value = p_fav_prot - p_fav_unpr

return statistical_parity_difference_value




124 changes: 124 additions & 0 deletions tests/core/test_fairness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""Tests for pyhealth.metrics.fairness_utils.group: disparate_impact and
statistical_parity_difference, focused on the empty-subgroup NaN bug.
"""

import unittest

import numpy as np

from pyhealth.metrics.fairness_utils import (
disparate_impact,
statistical_parity_difference,
)


class TestFairnessEmptySubgroup(unittest.TestCase):
"""Regression tests: an empty subgroup must raise ValueError, not
silently return NaN.

Previously, an empty group made the favorable-outcome rate a numpy
0/0 NaN. The guard in disparate_impact checked `rate == 0`, which
NaN never satisfies, so the ValueError never fired and NaN was
returned silently. statistical_parity_difference had no guard at
all. Both cases are fixed by validating group non-emptiness before
computing the rate.
"""

def setUp(self):
# sensitive_attributes: 1 = protected, 0 = unprotected.
self.no_unprotected = np.array([1, 1, 1])
self.no_protected = np.array([0, 0, 0])
self.y_pred = np.array([1, 0, 1])

def test_disparate_impact_raises_when_unprotected_empty(self):
with self.assertRaises(ValueError):
disparate_impact(self.no_unprotected, self.y_pred)

def test_disparate_impact_raises_when_protected_empty(self):
"""The original bug's guard only ever checked the unprotected
group; the protected group being empty was never checked at
all and also produced a silent NaN."""
with self.assertRaises(ValueError):
disparate_impact(self.no_protected, self.y_pred)

def test_disparate_impact_empty_group_raises_even_with_allow_zero_division(self):
"""An empty group is a different failure mode than a non-empty
group with a genuinely-zero rate: allow_zero_division must not
paper over a group we have zero information about."""
with self.assertRaises(ValueError):
disparate_impact(
self.no_unprotected, self.y_pred, allow_zero_division=True
)

def test_statistical_parity_difference_raises_when_unprotected_empty(self):
with self.assertRaises(ValueError):
statistical_parity_difference(self.no_unprotected, self.y_pred)

def test_statistical_parity_difference_raises_when_protected_empty(self):
with self.assertRaises(ValueError):
statistical_parity_difference(self.no_protected, self.y_pred)

def test_no_nan_ever_returned(self):
"""Direct regression test for the core symptom: an empty group
must never let a NaN escape as a return value. Catches the
exception outside the assertion so a hypothetical future
regression that silently returns NaN (instead of raising) would
actually be caught by the isnan check, rather than the test
vacuously passing because nothing after a raise executes."""
for sa in (self.no_unprotected, self.no_protected):
try:
result = disparate_impact(sa, self.y_pred)
except ValueError:
result = None
if result is not None:
self.assertFalse(np.isnan(result), "NaN silently returned instead of raising")

try:
result = statistical_parity_difference(sa, self.y_pred)
except ValueError:
result = None
if result is not None:
self.assertFalse(np.isnan(result), "NaN silently returned instead of raising")


class TestFairnessNormalOperation(unittest.TestCase):
"""Sanity checks that the fix doesn't change behavior for non-empty
groups (the common, legitimate case)."""

def setUp(self):
# unprotected (sa=0): preds [1, 0] -> 1/2 favorable
# protected (sa=1): preds [1, 1, 0] -> 2/3 favorable
self.sensitive_attributes = np.array([0, 0, 1, 1, 1])
self.y_pred = np.array([1, 0, 1, 1, 0])

def test_disparate_impact_normal(self):
result = disparate_impact(self.sensitive_attributes, self.y_pred)
self.assertAlmostEqual(result, (2 / 3) / (1 / 2))

def test_statistical_parity_difference_normal(self):
result = statistical_parity_difference(
self.sensitive_attributes, self.y_pred
)
self.assertAlmostEqual(result, (2 / 3) - (1 / 2))

def test_disparate_impact_zero_rate_non_empty_group_still_raises_by_default(self):
"""A non-empty group with a genuinely-zero favorable rate is NOT
the same failure mode as an empty group, but should still raise
by default (existing, unchanged behavior) unless the caller
opts in via allow_zero_division."""
sa = np.array([0, 0, 1, 1])
yp = np.array([0, 0, 1, 1]) # unprotected group: 0% favorable, but non-empty
with self.assertRaises(ValueError):
disparate_impact(sa, yp)

def test_disparate_impact_allow_zero_division_for_non_empty_zero_rate_group(self):
"""allow_zero_division should still work for its intended case:
a non-empty group whose rate happens to be exactly 0."""
sa = np.array([0, 0, 1, 1])
yp = np.array([0, 0, 1, 1])
result = disparate_impact(sa, yp, allow_zero_division=True, epsilon=1e-8)
self.assertAlmostEqual(result, 1.0 / 1e-8)


if __name__ == "__main__":
unittest.main()
Loading