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
19 changes: 18 additions & 1 deletion python/dalex/dalex/fairness/_group_fairness/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
36 changes: 36 additions & 0 deletions python/dalex/test/test_fairness.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down