Skip to content

fairness_check() reports "No bias was detected!" when a subgroup ratio is exactly 0 or infinite (ratio masked to NaN before the epsilon comparison) #585

Open

Description

@shaurya416

python/dalex/dalex/fairness/_group_fairness/utils.py (checked at commit 77ea3ad)

calculate_ratio() converts any subgroup/privileged metric ratio that comes out as exactly 0, or as inf (privileged metric is 0), into NaN:

def calculate_ratio(sub_confusion_matrix_metrics, privileged):
    """
    Calculates ratio of metrics - divides all by privileged
    Does not allow for zeros in ratios, instead puts NaN
    """
    ...
    df_ratio[df_ratio == 0] = np.nan          # utils.py:151
    df_ratio[np.isinf(df_ratio)] = np.nan     # utils.py:152
    ...

GroupFairnessClassification stores this frame as self.result (object.py:27,34). universal_fairness_check() then feeds it straight into the pass/fail comparison. There is no NaN-aware branch:

metrics_exceeded = ((metric_ratios > 1 / epsilon) | (epsilon > metric_ratios)).apply(sum, 0)   # utils.py:358
...
else:
    print("No bias was detected!")                                                              # utils.py:366

In numpy/pandas, both NaN > x and x > NaN evaluate to False. So a ratio of exactly 0 contributes 0 to metrics_exceeded and is never added to names_of_exceeded_metrics, even though it is the most extreme disparity a metric can show. An example is PPV of 0.8 for the privileged subgroup and 0.0 for the unprivileged one. An infinite ratio behaves the same way. The headline verdict ("is fair" / "No bias was detected!") depends only on that count, so both ratios are treated as within the (epsilon, 1/epsilon) band. The fairness_check() docstring defines a ratio outside that band as a failure.

The problem is wider than PPV. SubgroupConfusionMatrixMetrics rounds every metric to 3 decimals (utils.py:91), so any subgroup rate below 0.0005 becomes exactly 0.0 before the division and is masked as well. The most realistic trigger is a model that never predicts the positive class for one subgroup (tp = fp = 0). TPR, FPR and STP are then 0 for that subgroup (ratios masked to NaN), and PPV is NaN (0/0). Only the ACC ratio remains, and the verdict is "fair".

With verbose=True (the default), a NaN warning is printed after the verdict, together with the ratio table showing NaN. The Conclusion: line still says the model is fair, and with verbose=False nothing flags it.

Measured

The code was reproduced standalone: calculate_ratio(), calculate_parity_loss(), fairness_check_metrics() and universal_fairness_check() were copied verbatim into a bare Python process (numpy/pandas only, no dalex import). They were run in the same order GroupFairnessClassification uses (calculate_ratio() → universal_fairness_check()) with epsilon=0.8, the library default. All other metrics were identical between the two subgroups unless stated.

case unprivileged value ratio flagged as bias? verdict
exact zero PPV 0.0 (privileged 0.8) NaN False "No bias was detected!" / is fair
never predicts positive for subgroup TPR 0, FPR 0, STP 0, PPV NaN (0/0) TPR/FPR/STP/PPV all NaN, ACC 0.933 False "No bias was detected!" / is fair
privileged metric zero (inf ratio) FPR 0.3 (privileged 0.0) NaN False "No bias was detected!" / is fair
control: near-zero, not exact PPV 0.001 0.00125 True 1 metric exceeded
control: parity PPV 0.75 0.9375 False fair (correct)
control: ordinary violation PPV 0.5 0.625 True 1 metric exceeded

The unprivileged subgroup with the worse outcome (exactly 0) is the only PPV case that is not flagged. Every less extreme rate tested, from 0.001 to 0.75, is handled correctly by the same functions. test_calculate_ratio in test/test_fairness.py pins the zero→NaN conversion, which exists to keep np.log finite in calculate_parity_loss(). No test checks the fairness_check() verdict for a subgroup whose ratio is 0 or inf.

Consequence

GroupFairnessClassification.fairness_check() is the library's one-call fairness audit. Consider a subgroup whose confusion-matrix metric is exactly zero while the privileged subgroup's rate is nonzero, or the reverse. That subgroup is dropped from the "exceeded" tally and cannot push the verdict to "not fair" or "cannot be called fair". A subgroup 1000x less extreme (rate 0.001 instead of 0.000) is caught. A model that assigns no positive predictions at all to a protected subgroup can be reported as fair on statistical parity, equal opportunity and predictive equality.

The same masking affects calculate_parity_loss(). Its .apply(sum) uses Python's builtin, non-skipna sum, so one masked subgroup turns the whole metric's parity loss into NaN, even when other subgroups have real, computable disparities. Measured with three subgroups (PPV ratios 0 → NaN and 0.5): parity loss for PPV = nan.

Suggested fix

The codebase already has a pattern for surfacing NaN explicitly. _unwrap_parity_loss_data() in the same file checks any(pd.isna(data.score)) and reports which models are affected. PR #403 (fixing #399, "NA in metric_scores plot causes random lines to appear") added a similar explicit NaN branch to the fairness plotting code.

universal_fairness_check() should handle the masked ratios before the threshold comparison. A ratio that was 0 or inf before masking should either count as "exceeded" for that metric/subgroup, since it is further outside the epsilon band than any finite ratio, or produce an explicit "cannot be decided, review required" verdict. It should not silently count as "not exceeded". The masking in calculate_ratio() can stay as it is for the parity-loss log. The check only needs to tell apart a NaN from a 0/inf ratio and a NaN from an undefined metric (0/0), or look at the unmasked ratio.

Happy to open the PR.

Activity

  1. hbaniecki commented on Sep 27, 2026

    @hbaniecki
    Member

    Hi @shaurya416, thanks for raising this issue; feel free to open a PR, and I will review it. Please make it concise where possible.

  2. shaurya416 commented on Sep 27, 2026

    @shaurya416
    Author

    Opened as #586. It turned out universal_fairness_check is shared with GroupFairnessRegression, which has no metric_scores and no such masking — running the full FairnessTest suite caught that before I pushed, so the fix is guarded to only apply where the attribute exists. Kept it to the one function plus the new test, per your note.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions