prakharanand85's picture
Deploy harm-classifier robustness scanner
94a3cb4
Raw
History Blame Contribute Delete
4.78 kB
"""Evaluation orchestration: baseline -> slices -> adversarial -> error analysis.
Produces a single results dict consumed by report.py.
"""
from __future__ import annotations
from typing import Dict, List, Optional
import numpy as np
import pandas as pd
from . import metrics as M
from .defenses import normalize_pipeline
from .perturbations import EVASIONS
def run_evaluation(
model,
df: pd.DataFrame,
threshold: float = 0.5,
slice_cols: Optional[List[str]] = None,
evasions: Optional[List[str]] = None,
n_examples: int = 6,
min_slice_support: int = 20,
) -> Dict:
df = df.copy().reset_index(drop=True)
slice_cols = slice_cols or [c for c in ("language", "subgroup", "identity_mention",
"functionality") if c in df.columns]
evasions = evasions or list(EVASIONS.keys())
# ---- baseline -------------------------------------------------------- #
df["score"] = model.predict_proba(df["text"].tolist())
df["pred"] = M.binarize(df["score"].values, threshold)
baseline = M.prf(df["label"].values, df["pred"].values)
baseline["ece"] = M.expected_calibration_error(df["label"].values, df["score"].values)
pr = M.pr_curve(df["label"].values, df["score"].values)
# ---- slice analysis -------------------------------------------------- #
slices = {
col: M.slice_metrics(df, col, "score", "label", threshold)
for col in slice_cols
}
# ---- adversarial robustness ------------------------------------------ #
# Apply each evasion to the items the model CAUGHT at baseline, then rescore
# both raw and after defensive normalization.
pos = df[df["label"] == 1].copy()
caught_mask = pos["score"].values >= threshold
caught = pos[caught_mask].reset_index(drop=True)
scores_before = caught["score"].values
adversarial = []
for name in evasions:
fn = EVASIONS[name]["fn"]
attacked = [fn(t) for t in caught["text"].tolist()]
scores_after = model.predict_proba(attacked)
esr = M.evasion_success_rate(scores_before, scores_after, threshold)
# defended: normalize the attacked text, then rescore
defended_text = [normalize_pipeline(t) for t in attacked]
scores_def = model.predict_proba(defended_text)
esr_def = M.evasion_success_rate(scores_before, scores_def, threshold)
adversarial.append({
"evasion": name,
"effort": EVASIONS[name]["effort"],
"fluency_penalty": EVASIONS[name]["fluency_penalty"],
"esr": round(esr["esr"], 3),
"recall_after": round(esr["recall_after"], 3),
"recall_after_defense": round(esr_def["recall_after"], 3),
"n_caught": esr["n_caught"],
})
adversarial = sorted(adversarial, key=lambda r: r["esr"], reverse=True)
# ---- error analysis -------------------------------------------------- #
fns = df[(df["label"] == 1) & (df["pred"] == 0)].head(n_examples)
fps = df[(df["label"] == 0) & (df["pred"] == 1)].head(n_examples)
errors = {
"false_negatives": fns[["text", "score"] + slice_cols].to_dict("records"),
"false_positives": fps[["text", "score"] + slice_cols].to_dict("records"),
}
# ---- headline detection ---------------------------------------------- #
# Pick the worst-recall slice, but only among slices with enough POSITIVES to
# make recall meaningful. Slices with support < min_slice_support (e.g. the
# non-hateful HateCheck functionalities, which have zero positives) have a
# trivially-0 recall that is not a real failure — including them would yield a
# nonsensical "slice cliff". Those slices are a precision/FPR story, not recall.
worst_slice = None
for col, tbl in slices.items():
sized = tbl[tbl["support"] >= min_slice_support]
if sized.empty:
continue
cand = sized.sort_values("recall").iloc[0]
if worst_slice is None or cand["recall"] < worst_slice["recall"]:
worst_slice = {"column": col, "value": cand[col],
"recall": float(cand["recall"]),
"support": int(cand["support"])}
cheapest_break = next(
(r for r in adversarial
if r["effort"] == "low" and r["fluency_penalty"] in ("zero", "low")),
adversarial[0] if adversarial else None,
)
return {
"threshold": threshold,
"n": len(df),
"baseline": baseline,
"pr_curve": pr,
"slices": {c: t.to_dict("records") for c, t in slices.items()},
"adversarial": adversarial,
"errors": errors,
"headline": {"worst_slice": worst_slice, "cheapest_break": cheapest_break},
}