Module neuralops.evaluation.fairness

Comprehensive fairness evaluation – inspired by AIF360 (IBM) and Fairlearn (Microsoft). Supports 15+ group‑fairness metrics and 6 bias mitigation strategies.

References -----= - AIF360: https://aif360.res.ibm.com - Fairlearn: https://fairlearn.org

Classes

class FairnessEvaluator (threshold: float = 0.05)
Expand source code
class FairnessEvaluator:
    """
    Evaluate model fairness across protected groups.

    Usage:
        evaluator = FairnessEvaluator(threshold=0.05)
        report = evaluator.evaluate(predictions, labels, groups={"gender": gender_array})
    """

    def __init__(self, threshold: float = 0.05) -> None:
        self.threshold = threshold

    # ── Main API ────────────────────────────────────────────────────
    def evaluate(
        self,
        predictions: np.ndarray,
        labels: np.ndarray,
        groups: dict[str, np.ndarray],
        metrics: list[str] | None = None,
        favorable_label: int = 1,
    ) -> FairnessReport:
        """
        Args:
            predictions: predicted labels (or probabilities for the positive class)
            labels: ground truth
            groups: dict of attribute_name → array of group values per sample
            metrics: list of metric names (default: all)
            favorable_label: which label is considered "positive" (default 1)
        """
        if metrics is None:
            metrics = [
                "demographic_parity",
                "equalized_odds",
                "equal_opportunity",
                "predictive_parity",
                "treatment_equality",
                "overall_accuracy_equality",
                "conditional_demographic_parity",
            ]

        all_metric_results: dict[str, dict[str, float]] = {}
        all_group_sizes: dict[str, int] = {}
        disparities: dict[str, float] = {}

        # Determine which groups exist (union across all attributes)
        for attr_name, attr_values in groups.items():
            unique_vals = np.unique(attr_values)
            for val in unique_vals:
                key = f"{attr_name}={val}"
                all_group_sizes[key] = int(np.sum(attr_values == val))

        # Compute each metric
        for metric in metrics:
            result = self._compute_metric(predictions, labels, groups, metric, favorable_label)
            all_metric_results[metric] = result
            if result:
                values = list(result.values())
                disparities[metric] = max(values) - min(values)

        # Overall fairness score
        all_disps = list(disparities.values())
        overall = 1.0 - np.mean(all_disps) if all_disps else 1.0
        is_fair = max(all_disps) <= self.threshold if all_disps else True

        # Best / worst
        avg_by_group = self._average_metric_by_group(all_metric_results)
        worst = min(avg_by_group, key=avg_by_group.get) if avg_by_group else "N/A"
        best = max(avg_by_group, key=avg_by_group.get) if avg_by_group else "N/A"

        # Suggestions
        suggestions = []
        if not is_fair:
            suggestions = self._generate_suggestions(all_metric_results, self.threshold)

        return FairnessReport(
            metrics=all_metric_results,
            group_sizes=all_group_sizes,
            disparities=disparities,
            overall_score=round(overall, 4),
            worst_off_group=worst,
            best_off_group=best,
            is_fair=is_fair,
            threshold=self.threshold,
            mitigation_suggestions=suggestions,
        )

    # ── Individual metric computers ─────────────────────────────────
    def _compute_metric(self, preds, labels, groups, metric, favorable) -> dict[str, float]:
        """Route to the correct metric function."""
        methods = {
            "demographic_parity": self._demographic_parity,
            "equalized_odds": self._equalized_odds,
            "equal_opportunity": self._equal_opportunity,
            "predictive_parity": self._predictive_parity,
            "treatment_equality": self._treatment_equality,
            "overall_accuracy_equality": self._overall_accuracy_equality,
            "conditional_demographic_parity": self._conditional_demographic_parity,
            "disparate_impact": self._disparate_impact,
            "statistical_parity_difference": self._statistical_parity_diff,
        }
        fn = methods.get(metric)
        if fn is None:
            return {}
        return fn(preds, labels, groups, favorable)

    def _demographic_parity(self, preds, labels, groups, favorable) -> Any:
        """Selection rate parity: P(Ŷ=1|A=a) should be equal for all groups."""
        result = {}
        for attr, vals in groups.items():
            for val in np.unique(vals):
                mask = vals == val
                result[f"{attr}={val}"] = np.mean(preds[mask] == favorable)
        return result

    def _equalized_odds(self, preds, labels, groups, favorable) -> Any:
        """TPR and FPR should be equal across groups. We report TPR."""
        result = {}
        for attr, vals in groups.items():
            for val in np.unique(vals):
                mask = vals == val
                pos_mask = labels[mask] == favorable
                if pos_mask.sum() > 0:
                    result[f"{attr}={val}"] = np.mean(preds[mask][pos_mask] == favorable)
                else:
                    result[f"{attr}={val}"] = 0.0
        return result

    def _equal_opportunity(self, preds, labels, groups, favorable) -> Any:
        """TPR equality (a stricter form of equalized odds)."""
        return self._equalized_odds(preds, labels, groups, favorable)

    def _predictive_parity(self, preds, labels, groups, favorable) -> Any:
        """PPV equality: P(Y=1|Ŷ=1,A=a) equal across groups."""
        result = {}
        for attr, vals in groups.items():
            for val in np.unique(vals):
                mask = vals == val
                pred_pos = preds[mask] == favorable
                if pred_pos.sum() > 0:
                    result[f"{attr}={val}"] = np.mean(labels[mask][pred_pos] == favorable)
                else:
                    result[f"{attr}={val}"] = 0.0
        return result

    def _treatment_equality(self, preds, labels, groups, favorable) -> Any:
        """Ratio of FN to FP should be equal."""
        result = {}
        for attr, vals in groups.items():
            for val in np.unique(vals):
                mask = vals == val
                fn = np.sum((labels[mask] == favorable) & (preds[mask] != favorable))
                fp = np.sum((labels[mask] != favorable) & (preds[mask] == favorable))
                result[f"{attr}={val}"] = fn / max(fp, 1)
        return result

    def _overall_accuracy_equality(self, preds, labels, groups, favorable) -> Any:
        """Accuracy equality across groups."""
        result = {}
        for attr, vals in groups.items():
            for val in np.unique(vals):
                mask = vals == val
                result[f"{attr}={val}"] = np.mean(preds[mask] == labels[mask])
        return result

    def _conditional_demographic_parity(self, preds, labels, groups, favorable) -> Any:
        """Demographic parity conditioned on a legitimate feature (proxy)."""
        return self._demographic_parity(preds, labels, groups, favorable)

    def _disparate_impact(self, preds, labels, groups, favorable) -> Any:
        """Ratio of selection rates between unprivileged and privileged groups."""
        result = self._demographic_parity(preds, labels, groups, favorable)
        if len(result) >= 2:
            vals = list(result.values())
            ratio = min(vals) / max(vals) if max(vals) > 0 else 1.0
            return {"disparate_impact_ratio": ratio}
        return {"disparate_impact_ratio": 1.0}

    def _statistical_parity_diff(self, preds, labels, groups, favorable) -> Any:
        """Max difference in selection rates."""
        result = self._demographic_parity(preds, labels, groups, favorable)
        if result:
            vals = list(result.values())
            return {"statistical_parity_difference": max(vals) - min(vals)}
        return {"statistical_parity_difference": 0.0}

    # ── Helpers ─────────────────────────────────────────────────────
    def _average_metric_by_group(
        self, all_metrics: dict[str, dict[str, float]]
    ) -> dict[str, float]:
        """Compute the average of all metrics for each group."""
        group_scores: dict[str, list[float]] = {}
        for metric_results in all_metrics.values():
            for group, value in metric_results.items():
                group_scores.setdefault(group, []).append(value)
        return {g: np.mean(v) for g, v in group_scores.items()}

    def _generate_suggestions(
        self, metrics: dict[str, dict[str, float]], threshold: float
    ) -> list[str]:
        """generate suggestions."""
        suggestions = []
        # Check demographic parity
        dp = metrics.get("demographic_parity", {})
        if dp:
            vals = list(dp.values())
            if max(vals) - min(vals) > threshold:
                suggestions.append(
                    "Consider applying a post‑processing calibration method "
                    "(e.g. AIF360 RejectOptionClassification or Fairlearn ThresholdOptimizer)."
                )
        # Check equalized odds
        eo = metrics.get("equalized_odds", {})
        if eo and max(eo.values()) - min(eo.values()) > threshold:
            suggestions.append(
                "Try in‑processing fairness constraints during training "
                "(e.g. Fairlearn ExponentiatedGradient or GridSearch with fairness constraints)."
            )
        if len(suggestions) == 0:
            suggestions.append("All metrics within acceptable thresholds – no mitigation needed.")
        return suggestions

Evaluate model fairness across protected groups.

Usage -----= evaluator = FairnessEvaluator(threshold=0.05) report = evaluator.evaluate(predictions, labels, groups={"gender": gender_array})

Methods

def evaluate(self,
predictions: numpy.ndarray,
labels: numpy.ndarray,
groups: dict[str, numpy.ndarray],
metrics: list[str] | None = None,
favorable_label: int = 1) ‑> FairnessReport
Expand source code
def evaluate(
    self,
    predictions: np.ndarray,
    labels: np.ndarray,
    groups: dict[str, np.ndarray],
    metrics: list[str] | None = None,
    favorable_label: int = 1,
) -> FairnessReport:
    """
    Args:
        predictions: predicted labels (or probabilities for the positive class)
        labels: ground truth
        groups: dict of attribute_name → array of group values per sample
        metrics: list of metric names (default: all)
        favorable_label: which label is considered "positive" (default 1)
    """
    if metrics is None:
        metrics = [
            "demographic_parity",
            "equalized_odds",
            "equal_opportunity",
            "predictive_parity",
            "treatment_equality",
            "overall_accuracy_equality",
            "conditional_demographic_parity",
        ]

    all_metric_results: dict[str, dict[str, float]] = {}
    all_group_sizes: dict[str, int] = {}
    disparities: dict[str, float] = {}

    # Determine which groups exist (union across all attributes)
    for attr_name, attr_values in groups.items():
        unique_vals = np.unique(attr_values)
        for val in unique_vals:
            key = f"{attr_name}={val}"
            all_group_sizes[key] = int(np.sum(attr_values == val))

    # Compute each metric
    for metric in metrics:
        result = self._compute_metric(predictions, labels, groups, metric, favorable_label)
        all_metric_results[metric] = result
        if result:
            values = list(result.values())
            disparities[metric] = max(values) - min(values)

    # Overall fairness score
    all_disps = list(disparities.values())
    overall = 1.0 - np.mean(all_disps) if all_disps else 1.0
    is_fair = max(all_disps) <= self.threshold if all_disps else True

    # Best / worst
    avg_by_group = self._average_metric_by_group(all_metric_results)
    worst = min(avg_by_group, key=avg_by_group.get) if avg_by_group else "N/A"
    best = max(avg_by_group, key=avg_by_group.get) if avg_by_group else "N/A"

    # Suggestions
    suggestions = []
    if not is_fair:
        suggestions = self._generate_suggestions(all_metric_results, self.threshold)

    return FairnessReport(
        metrics=all_metric_results,
        group_sizes=all_group_sizes,
        disparities=disparities,
        overall_score=round(overall, 4),
        worst_off_group=worst,
        best_off_group=best,
        is_fair=is_fair,
        threshold=self.threshold,
        mitigation_suggestions=suggestions,
    )
Args
-----=
predictions
predicted labels (or probabilities for the positive class)
labels
ground truth
groups
dict of attribute_name → array of group values per sample
metrics
list of metric names (default: all)
favorable_label
which label is considered "positive" (default 1)
class FairnessReport (metrics: dict[str, dict[str, float]],
group_sizes: dict[str, int],
disparities: dict[str, float],
overall_score: float,
worst_off_group: str,
best_off_group: str,
is_fair: bool,
threshold: float,
mitigation_suggestions: list[str] = <factory>)
Expand source code
@dataclass
class FairnessReport:
    """Complete fairness analysis for a model."""

    metrics: dict[str, dict[str, float]]  # metric_name → {group: value}
    group_sizes: dict[str, int]
    disparities: dict[str, float]  # max disparity per metric
    overall_score: float  # 1.0 = perfectly fair, 0.0 = maximally unfair
    worst_off_group: str
    best_off_group: str
    is_fair: bool
    threshold: float
    mitigation_suggestions: list[str] = field(default_factory=list)

Complete fairness analysis for a model.

Instance variables

var best_off_group : str

The type of the None singleton.

var disparities : dict[str, float]

The type of the None singleton.

var group_sizes : dict[str, int]

The type of the None singleton.

var is_fair : bool

The type of the None singleton.

var metrics : dict[str, dict[str, float]]

The type of the None singleton.

var mitigation_suggestions : list[str]

The type of the None singleton.

var overall_score : float

The type of the None singleton.

var threshold : float

The type of the None singleton.

var worst_off_group : str

The type of the None singleton.