fold/scripts/benchmarks/fold_eval/metrics.py
trvon e30764fb19
bench: extend fold_eval scoring with labeled negative cases
Add negative_cases_from_universe to expand an explicit file-by-rule universe
into labeled negatives (enabling TP/FP/TN/FN, not just TP/FP), plus project-eval
harness updates in run_project_eval and the score/score_fp/quality-gate helpers.
2026-08-16 16:43:51 -06:00

331 lines
11 KiB
Python

"""Metric calculations shared by deterministic and randomized evaluations."""
from __future__ import annotations
import math
import random
import statistics
from collections.abc import Callable, Mapping, Sequence
from typing import Any
CLASSIFICATION_THRESHOLD_METRICS = (
"precision",
"recall",
"f1",
"specificity",
"accuracy",
"balanced_accuracy",
"matthews_correlation_coefficient",
)
def _count(name: str, value: int) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise ValueError(f"{name} must be a non-negative integer")
return value
def classification_metrics(
true_positive: int,
false_positive: int,
false_negative: int,
true_negative: int | None = None,
*,
zero_division: float = 0.0,
) -> dict[str, Any]:
"""Return confusion counts and derived binary-classification metrics.
Static-analysis corpora often do not enumerate all true negatives. In that
case ``true_negative`` must remain ``None`` and specificity/FPR are omitted
rather than being fabricated from unlabelled source locations.
"""
tp = _count("true_positive", true_positive)
fp = _count("false_positive", false_positive)
fn = _count("false_negative", false_negative)
tn = None if true_negative is None else _count("true_negative", true_negative)
if not math.isfinite(zero_division):
raise ValueError("zero_division must be finite")
precision_denominator = tp + fp
recall_denominator = tp + fn
precision_defined = precision_denominator > 0
recall_defined = recall_denominator > 0
precision = tp / precision_denominator if precision_defined else zero_division
recall = tp / recall_denominator if recall_defined else zero_division
f1_denominator = 2 * tp + fp + fn
f1_defined = f1_denominator > 0
f1 = 2 * tp / f1_denominator if f1_defined else zero_division
result: dict[str, Any] = {
"true_positive": tp,
"false_positive": fp,
"false_negative": fn,
"precision": precision,
"recall": recall,
"f1": f1,
"defined": {
"precision": precision_defined,
"recall": recall_defined,
"f1": f1_defined,
},
}
if tn is not None:
negative_denominator = tn + fp
negative_prediction_denominator = tn + fn
total = tp + fp + fn + tn
specificity_defined = negative_denominator > 0
negative_predictive_value_defined = negative_prediction_denominator > 0
accuracy_defined = total > 0
balanced_accuracy_defined = recall_defined and specificity_defined
mcc_denominator = math.sqrt(
(tp + fp) * (tp + fn) * (tn + fp) * (tn + fn)
)
mcc_defined = mcc_denominator > 0
result.update(
{
"true_negative": tn,
"specificity": (
tn / negative_denominator
if specificity_defined
else zero_division
),
"false_positive_rate": (
fp / negative_denominator
if specificity_defined
else zero_division
),
"negative_predictive_value": (
tn / negative_prediction_denominator
if negative_predictive_value_defined
else zero_division
),
"accuracy": (
(tp + tn) / total if accuracy_defined else zero_division
),
"balanced_accuracy": (
(recall + tn / negative_denominator) / 2
if balanced_accuracy_defined
else zero_division
),
"matthews_correlation_coefficient": (
(tp * tn - fp * fn) / mcc_denominator
if mcc_defined
else zero_division
),
}
)
result["defined"].update(
{
"specificity": specificity_defined,
"false_positive_rate": specificity_defined,
"negative_predictive_value": negative_predictive_value_defined,
"accuracy": accuracy_defined,
"balanced_accuracy": balanced_accuracy_defined,
"matthews_correlation_coefficient": mcc_defined,
}
)
return result
def distribution_summary(values: Sequence[float]) -> dict[str, float | int]:
"""Summarize repeated measurements without external statistics packages.
The quartile indexing intentionally preserves the historical Fold benchmark
convention, keeping existing performance baselines comparable.
"""
finite = [float(value) for value in values]
if any(not math.isfinite(value) for value in finite):
raise ValueError("distribution values must be finite")
if not finite:
return {"n": 0, "median": 0.0, "min": 0.0, "max": 0.0, "iqr": 0.0}
ordered = sorted(finite)
q1 = ordered[len(ordered) // 4]
q3 = ordered[(3 * len(ordered)) // 4]
return {
"n": len(ordered),
"median": round(float(statistics.median(ordered)), 3),
"min": round(ordered[0], 3),
"max": round(ordered[-1], 3),
"iqr": round(q3 - q1, 3),
}
def bootstrap_interval(
values: Sequence[float],
*,
statistic: Callable[[Sequence[float]], float] = statistics.median,
confidence: float = 0.95,
resamples: int = 2_000,
seed: int = 0,
) -> dict[str, float | int]:
"""Return a deterministic percentile-bootstrap interval.
This is intended for randomized DAST/fuzzing campaigns. It should not be
used to manufacture certainty from one or two smoke-test repetitions.
"""
sample = [float(value) for value in values]
if not sample:
raise ValueError("bootstrap_interval requires at least one value")
if any(not math.isfinite(value) for value in sample):
raise ValueError("bootstrap values must be finite")
if not 0.0 < confidence < 1.0:
raise ValueError("confidence must be between zero and one")
if resamples < 1:
raise ValueError("resamples must be positive")
rng = random.Random(seed)
draws = sorted(
float(statistic([rng.choice(sample) for _ in sample]))
for _ in range(resamples)
)
alpha = (1.0 - confidence) / 2.0
low_index = max(0, min(resamples - 1, math.floor(alpha * resamples)))
high_index = max(
0, min(resamples - 1, math.ceil((1.0 - alpha) * resamples) - 1)
)
return {
"estimate": float(statistic(sample)),
"low": draws[low_index],
"high": draws[high_index],
"confidence": confidence,
"resamples": resamples,
"seed": seed,
}
def aggregate_classification_metrics(
slices: Mapping[str, Mapping[str, int | None]],
) -> dict[str, Any]:
"""Return per-slice, micro, and macro classification measurements.
A pooled score can hide a weak detector/corpus stratum behind a large easy
stratum. This helper preserves every count, computes the micro aggregate by
summing counts, and computes each macro ratio only across slices where that
ratio is actually defined. True negatives remain unavailable unless every
slice explicitly enumerates them.
"""
per_slice: dict[str, dict[str, Any]] = {}
total_tp = total_fp = total_fn = 0
total_tn = 0
all_have_tn = True
for name in sorted(slices):
row = slices[name]
tp = _count(f"{name}.true_positive", row.get("true_positive", 0))
fp = _count(f"{name}.false_positive", row.get("false_positive", 0))
fn = _count(f"{name}.false_negative", row.get("false_negative", 0))
raw_tn = row.get("true_negative")
tn = None if raw_tn is None else _count(f"{name}.true_negative", raw_tn)
per_slice[name] = classification_metrics(tp, fp, fn, tn)
total_tp += tp
total_fp += fp
total_fn += fn
if tn is None:
all_have_tn = False
else:
total_tn += tn
micro = classification_metrics(
total_tp,
total_fp,
total_fn,
total_tn if all_have_tn and per_slice else None,
)
ratio_names = (
"precision",
"recall",
"f1",
"specificity",
"false_positive_rate",
"negative_predictive_value",
"accuracy",
"balanced_accuracy",
"matthews_correlation_coefficient",
)
macro: dict[str, float] = {}
macro_support: dict[str, int] = {}
for metric in ratio_names:
values = [
float(row[metric])
for row in per_slice.values()
if metric in row and row.get("defined", {}).get(metric, False)
]
if values:
macro[metric] = sum(values) / len(values)
macro_support[metric] = len(values)
return {
"slices": per_slice,
"micro": micro,
"macro": macro,
"macro_support": macro_support,
}
def paired_comparison(
baseline: Sequence[float],
candidate: Sequence[float],
*,
lower_is_better: bool = True,
) -> dict[str, Any]:
"""Compare paired trials with an effect size and exact sign test.
Pairing is by seed/target/trial identity. The common-language effect is the
fraction of non-tied pairs where the candidate wins; the two-sided sign
test is dependency-free and makes no distributional assumption. This is a
useful minimum for DAST/fuzz comparisons, not a substitute for enough
independent trials or a predeclared experiment design.
"""
before = [float(value) for value in baseline]
after = [float(value) for value in candidate]
if not before or len(before) != len(after):
raise ValueError("paired samples must be non-empty and have equal length")
if any(not math.isfinite(value) for value in (*before, *after)):
raise ValueError("paired samples must be finite")
deltas = [right - left for left, right in zip(before, after, strict=True)]
better = [
delta < 0 if lower_is_better else delta > 0
for delta in deltas
]
worse = [
delta > 0 if lower_is_better else delta < 0
for delta in deltas
]
wins = sum(better)
losses = sum(worse)
ties = len(deltas) - wins - losses
non_ties = wins + losses
if non_ties:
tail = sum(
math.comb(non_ties, count)
for count in range(0, min(wins, losses) + 1)
) / (2**non_ties)
sign_test_p = min(1.0, 2.0 * tail)
common_language = wins / non_ties
else:
sign_test_p = 1.0
common_language = 0.5
relative = [
100.0 * delta / left
for left, delta in zip(before, deltas, strict=True)
if left != 0
]
return {
"pairs": len(deltas),
"wins": wins,
"losses": losses,
"ties": ties,
"median_delta": float(statistics.median(deltas)),
"median_relative_change_pct": (
float(statistics.median(relative)) if relative else None
),
"common_language_effect": common_language,
"two_sided_sign_test_p": sign_test_p,
"lower_is_better": lower_is_better,
}