diff --git a/docs/api/metrics/pyhealth.metrics.fairness.rst b/docs/api/metrics/pyhealth.metrics.fairness.rst index a92ee91b2..c54a5fde3 100644 --- a/docs/api/metrics/pyhealth.metrics.fairness.rst +++ b/docs/api/metrics/pyhealth.metrics.fairness.rst @@ -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 diff --git a/examples/tutorials/tutorial_pyhealth_metrics.ipynb b/examples/tutorials/tutorial_pyhealth_metrics.ipynb index 263d3bf34..4ab9d6b87 100644 --- a/examples/tutorials/tutorial_pyhealth_metrics.ipynb +++ b/examples/tutorials/tutorial_pyhealth_metrics.ipynb @@ -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", @@ -176,4 +176,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/pyhealth/metrics/fairness_utils/group.py b/pyhealth/metrics/fairness_utils/group.py index 1a67f4f68..5ed222b45 100644 --- a/pyhealth/metrics/fairness_utils/group.py +++ b/pyhealth/metrics/fairness_utils/group.py @@ -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: @@ -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 - - \ No newline at end of file diff --git a/tests/core/test_fairness.py b/tests/core/test_fairness.py new file mode 100644 index 000000000..ae6617b58 --- /dev/null +++ b/tests/core/test_fairness.py @@ -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()