From 227505f5342f9995f192a0eda425869210afc74d Mon Sep 17 00:00:00 2001 From: Shaurya Singh <298017155+shaurya416@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:17:17 -0700 Subject: [PATCH] fix(fairness): count a masked 0/inf ratio as exceeded in fairness_check calculate_ratio() masks a subgroup ratio that is exactly 0 or inf to NaN, so calculate_parity_loss()'s log stays finite. universal_fairness_check() reused that same masked frame for the pass/fail comparison, and NaN compares False both ways in numpy, so a masked ratio was never counted as exceeded -- even though 0 or inf is the most extreme disparity a metric can show. A model that never predicts the positive class for one subgroup could be reported as fair. universal_fairness_check() now recomputes the ratio from the raw scores already stored on GroupFairnessClassification (self.metric_scores, unmasked) to tell apart why a ratio is NaN: masked because it was really 0 or inf (now counts as exceeded), or genuinely 0/0 because both groups scored exactly 0 (stays excluded, since that case really is undecidable). GroupFairnessRegression has no metric_scores and no such masking, so it is unaffected (hasattr guard). calculate_ratio() and calculate_parity_loss() are unchanged. Adds test_fairness_check_masked_ratio: a subgroup that never predicts positive (TPR/FPR/STP ratios exactly 0, PPV genuinely 0/0). Asserts the printed verdict names TPR/FPR/STP as exceeded, not PPV, and does not say the model is fair. Full FairnessTest suite (23 tests) passes. --- .../dalex/fairness/_group_fairness/utils.py | 19 +++++++++- python/dalex/test/test_fairness.py | 36 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/python/dalex/dalex/fairness/_group_fairness/utils.py b/python/dalex/dalex/fairness/_group_fairness/utils.py index 8ae807e7..14383083 100644 --- a/python/dalex/dalex/fairness/_group_fairness/utils.py +++ b/python/dalex/dalex/fairness/_group_fairness/utils.py @@ -355,7 +355,24 @@ def universal_fairness_check(self, epsilon, verbose, num_for_not_fair, num_for_n subgroups_without_privileged = subgroups[subgroups != self.privileged] metric_ratios = metric_ratios.loc[subgroups_without_privileged, metrics] - metrics_exceeded = ((metric_ratios > 1 / epsilon) | (epsilon > metric_ratios)).apply(sum, 0) + # calculate_ratio() masks a ratio that is exactly 0 or inf to NaN (so + # calculate_parity_loss()'s log stays finite), which then reads as "within + # bounds" below -- the most extreme disparity a metric can show is silently + # treated as no disparity at all. Only GroupFairnessClassification stores + # the raw (unratio'd) scores this needs; GroupFairnessRegression's ratios + # come from calculate_regression_measures(), which has no such masking. + if hasattr(self, 'metric_scores'): + raw = self.metric_scores.loc[subgroups_without_privileged, metrics] + privileged_raw = self.metric_scores.loc[self.privileged, metrics] + with np.errstate(divide='ignore', invalid='ignore'): + unmasked_ratio = raw / privileged_raw + # A ratio that is NaN here because both groups scored 0 (0/0) is + # genuinely undefined -- unmasked_ratio is NaN too, and it stays excluded. + masked_disparity = metric_ratios.isna() & ~unmasked_ratio.isna() + else: + masked_disparity = False + + metrics_exceeded = (((metric_ratios > 1 / epsilon) | (epsilon > metric_ratios)) | masked_disparity).apply(sum, 0) names_of_exceeded_metrics = list(metrics_exceeded.index[metrics_exceeded != 0]) if len(names_of_exceeded_metrics) >= num_for_not_fair: diff --git a/python/dalex/test/test_fairness.py b/python/dalex/test/test_fairness.py index ed7af7c5..2bca3503 100644 --- a/python/dalex/test/test_fairness.py +++ b/python/dalex/test/test_fairness.py @@ -1,6 +1,8 @@ import unittest from copy import copy, deepcopy import warnings +import io +from contextlib import redirect_stdout import numpy as np import pandas as pd @@ -201,6 +203,40 @@ def test_GroupFairnessClassification(self): label=exp.label) self.assertIsInstance(gfco, dx.fairness.GroupFairnessClassification) + def test_fairness_check_masked_ratio(self): + # issue #585: a subgroup ratio of exactly 0 or inf is masked to NaN by + # calculate_ratio() (so calculate_parity_loss()'s log stays finite), + # and universal_fairness_check() used to read that NaN as "within + # bounds" instead of the most extreme disparity a metric can show. + # 'sub' never predicts positive (y_hat all below cutoff): TPR, FPR and + # STP ratios are exactly 0 (masked to NaN in gfc.result), PPV is + # genuinely 0/0 (NaN already in gfc.metric_scores, before masking), + # and ACC is close enough to 'priv' to stay unflagged on its own. + y_true = np.array([1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0]) + y_hat = np.array([0.9, 0.8, 0.3, 0.6, 0.2, 0.7, 0.1, 0.4, + 0.1, 0.2, 0.1, 0.2, 0.3, 0.4, 0.1, 0.2]) + protected = np.array(['priv'] * 8 + ['sub'] * 8) + + gfc = dx.fairness.GroupFairnessClassification(y=y_true, y_hat=y_hat, protected=protected, + privileged='priv', verbose=False, label='t') + + self.assertTrue(np.isnan(gfc.metric_scores.loc['sub', 'PPV'])) + for metric in ['TPR', 'FPR', 'STP']: + self.assertEqual(gfc.metric_scores.loc['sub', metric], 0) + self.assertTrue(np.isnan(gfc.result.loc['sub', metric])) + + buffer = io.StringIO() + with redirect_stdout(buffer): + gfc.fairness_check(epsilon=0.8, verbose=False) + output = buffer.getvalue() + + self.assertNotIn('No bias was detected', output) + self.assertIn('Bias detected', output) + for metric in ['TPR', 'FPR', 'STP']: + self.assertIn(metric, output.split('Ratios of metrics')[0]) + self.assertNotIn('PPV', output.split('Ratios of metrics')[0]) + + def test_GroupFairnessRegression(self): exp = self.exp_reg protected = self.protected_reg