Source code for iguanas.rule_fairness

"""Post-hoc fairness auditing for already-generated rules.

This module provides *measurement* only. It computes standard performance
metrics broken down by the levels of a protected attribute
(:func:`compute_subgroup_metrics`) and the ratio of those metrics against a
reference group (:func:`compute_disparate_impact_ratio`).

Fairness is measured, not optimised. Iguanas contains no fairness-aware rule
generation, no fairness-constrained selection, and no bias-mitigation step:
nothing in this module feeds back into how rules are learned or chosen. The
functions here are a bias-measurement report to be read by a human, who must
decide what — if anything — to do about the disparities it surfaces.

The metrics computed are descriptive statistics on the supplied sample. No
statistical significance test, confidence interval, or hypothesis test is
performed, so small subgroups can produce unstable ratios.
"""
from __future__ import annotations

import polars as pl

from .metrics import compute_metrics


[docs] def compute_subgroup_metrics( R: pl.DataFrame, y: pl.Series, group_col: pl.Series, weights: pl.Series | None = None, betas: list[float] | None = None, ) -> pl.DataFrame: """Compute rule performance metrics broken down by a protected attribute. Evaluates each rule's precision, recall, and other metrics within every subgroup defined by the unique values of ``group_col``. This is a post-hoc audit: it surfaces performance disparities — e.g. a rule that has high precision overall but systematically mis-fires on a particular demographic group — but does not change the rules in any way. Parameters ---------- R : pl.DataFrame Boolean DataFrame of rule predictions (columns = rules, rows = samples). y : pl.Series Target series (boolean or binary). group_col : pl.Series Series defining subgroup membership. Any dtype is supported; unique values define the groups. Must have the same length as ``R`` and ``y``. weights : pl.Series | None, default=None Optional sample weights. Forwarded to :func:`~iguanas.metrics.compute_metrics` for each subgroup. betas : list[float] | None, default=None F-beta values to compute. Forwarded to :func:`~iguanas.metrics.compute_metrics`. Returns ------- pl.DataFrame Long-format DataFrame with one row per *(group, rule)* pair. Columns: - ``group`` — subgroup label - ``group_size`` — number of samples in that group - ``rule`` — rule expression string - All metric columns from :func:`~iguanas.metrics.compute_metrics` (TP, FP, precision, recall, f1, …) Sorted by ``group`` then ``rule``. Returns an empty DataFrame if ``R`` is empty. Notes ----- Descriptive measurement only. The returned numbers are raw per-subgroup metrics on the supplied sample: no statistical test, confidence interval, or minimum-subgroup-size check is applied, and no rule is modified, reweighted, or filtered as a result. Groups with few samples can therefore show large apparent disparities from sampling noise alone. Examples -------- >>> import polars as pl >>> R = pl.DataFrame({ ... "rule_A": [True, False, True, False, True], ... "rule_B": [False, True, False, True, True], ... }) >>> y = pl.Series([1, 0, 1, 0, 1]) >>> group = pl.Series(["M", "M", "F", "F", "F"]) >>> compute_subgroup_metrics(R, y, group) See Also -------- compute_metrics : Aggregate (non-split) metric computation. """ if R.is_empty(): return pl.DataFrame() groups = group_col.unique().sort().to_list() frames: list[pl.DataFrame] = [] for g in groups: mask = group_col == g R_g = R.filter(mask) y_g = y.filter(mask) w_g = weights.filter(mask) if weights is not None else None m = compute_metrics(R_g, y_g, weights=w_g, betas=betas) m = m.with_columns( [ pl.lit(g).alias("group"), pl.lit(mask.sum()).alias("group_size"), ] ) frames.append(m) result = pl.concat(frames) # Reorder: group columns first front = ["group", "group_size"] rest = [c for c in result.columns if c not in front] return result.select(front + rest).sort(["group", "rule"])
[docs] def compute_disparate_impact_ratio( subgroup_df: pl.DataFrame, reference_group: str, metric: str = "precision", ) -> pl.DataFrame: """Compute the disparate-impact ratio relative to a reference group. For each rule and each non-reference group, the ratio is: .. math:: \\text{DIR} = \\frac{\\text{metric}_{\\text{group}}}{\\text{metric}_{\\text{reference}}} A ratio below **0.8** (the "four-fifths rule") is a conventional screening threshold associated with US EEOC guidance. It is a heuristic flag for human review, not a statistical test and not a legal determination. Parameters ---------- subgroup_df : pl.DataFrame Output of :func:`compute_subgroup_metrics` — must contain columns ``group``, ``rule``, and the requested ``metric``. reference_group : str The group whose metric value is used as the denominator. metric : str, default="precision" Name of the metric column to use for the ratio. Returns ------- pl.DataFrame One row per *(rule, group)* pair for non-reference groups with columns: - ``rule`` - ``group`` - ``{metric}_group`` — metric value for this group - ``{metric}_reference`` — metric value for the reference group - ``disparate_impact_ratio`` — ratio (group / reference); ``null`` when reference metric is zero or null. Sorted by ``rule`` then ``disparate_impact_ratio`` ascending. Notes ----- This is a post-hoc audit metric. Computing it does not alter, reweight, or filter any rule — Iguanas performs no fairness-aware generation or selection. The ratio is a point estimate with no confidence interval and no significance test, so it should be interpreted alongside ``group_size`` from :func:`compute_subgroup_metrics`. Examples -------- >>> subgroup_df = compute_subgroup_metrics(R, y, group_col) >>> compute_disparate_impact_ratio(subgroup_df, reference_group="1", metric="precision") See Also -------- compute_subgroup_metrics : Compute per-subgroup metrics first. """ required = {"group", "rule", metric} missing = required - set(subgroup_df.columns) if missing: raise ValueError(f"subgroup_df is missing columns: {missing}") ref = subgroup_df.filter(pl.col("group") == reference_group).select( ["rule", pl.col(metric).alias(f"{metric}_reference")] ) non_ref = subgroup_df.filter(pl.col("group") != reference_group).select( ["rule", "group", pl.col(metric).alias(f"{metric}_group")] ) return ( non_ref.join(ref, on="rule", how="left") .with_columns( (pl.col(f"{metric}_group") / pl.col(f"{metric}_reference")).alias( "disparate_impact_ratio" ) ) .select( ["rule", "group", f"{metric}_group", f"{metric}_reference", "disparate_impact_ratio"] ) .sort(["rule", "disparate_impact_ratio"]) )