cascade_risk / src /eval /metrics.py
Lucasoppem's picture
Sync from GitHub main (part 2)
36f9d47 verified
Raw
History Blame Contribute Delete
16.9 kB
"""Numeric metrics for predicted vs gold cascade chains.
The judge route in ``src/eval/evaluator.py`` produces qualitative grades
(evidence_level / timing_alignment) by reading the news; this module
takes the orthogonal route — compare the predicted chain directly
against a reference (gold) chain and produce precision / recall / F1.
The gold chain is the test-event cascade chain extracted by step 03 of
the data pipeline (``data/processed/cascade_chains/{event_id}.json``).
That chain was extracted with full news as input; the predictor only
sees event metadata + RAG-retrieved historical chains, so the
information gap is large enough that the gold side serves as a
defensible reference.
Bumping ``ALGORITHM_VERSION`` invalidates every cached gold evaluation
via ``GoldEvaluator``'s fingerprint. Do this whenever the matching
algorithm or any default threshold changes in a way that would alter
prior numeric results.
"""
from __future__ import annotations
from typing import Protocol
import numpy as np
from src.models.schemas import CascadeChain, CascadeNode, NodeMatch, NodeMatchCandidate
ALGORITHM_VERSION = "v10" # v0.6 issue A — BFS predictor re-tune; v9 caches were produced under BFS knobs calibrated for v0.4 retrieval distribution and must regenerate
DEFAULT_COSINE_THRESHOLD = 0.35 # was 0.5 (v2); recalibrated for new gold's named-entity density
class SupportsEmbedTexts(Protocol):
def embed_texts(self, texts: list[str]) -> list[list[float]]: ...
def match_nodes(
predicted: list[CascadeNode],
gold: list[CascadeNode],
embedder: SupportsEmbedTexts,
threshold: float = DEFAULT_COSINE_THRESHOLD,
) -> list[NodeMatch]:
"""Greedy node alignment with a hard same-domain filter.
Returns the list of accepted matches only. Thin wrapper around
``match_nodes_with_diagnostics`` that drops the candidate list.
"""
matches, _ = match_nodes_with_diagnostics(
predicted, gold, embedder, threshold=threshold
)
return matches
def match_nodes_with_diagnostics(
predicted: list[CascadeNode],
gold: list[CascadeNode],
embedder: SupportsEmbedTexts,
threshold: float = DEFAULT_COSINE_THRESHOLD,
) -> tuple[list[NodeMatch], list[NodeMatchCandidate]]:
"""Same matching as ``match_nodes`` but also surfaces every same-domain pair.
For every (predicted, gold) pair where the domains match, compute the
cosine similarity between description embeddings. Sort candidates by
similarity, then greedily accept pairs whose similarity ≥ threshold
and where neither side has been claimed yet.
Returns a tuple ``(matches, candidates)`` where ``matches`` is the
accepted alignment (same as ``match_nodes``), and ``candidates`` is
every same-domain pair (including those below threshold or crowded
out by greedy selection), sorted by descending cosine. Used by
--dump-match-debug to support offline threshold sweeps.
Both inputs typically have ≤40 nodes (extraction cap), so the
O(P·G) pairing is fine; Hungarian assignment is in the backlog.
"""
if not predicted or not gold:
return [], []
p_texts = [n.description for n in predicted]
g_texts = [n.description for n in gold]
# Single batched call — cheaper than per-pair embed.
p_vecs = np.array(embedder.embed_texts(p_texts), dtype=float)
g_vecs = np.array(embedder.embed_texts(g_texts), dtype=float)
# sentence-transformers returns L2-normalized vectors when
# normalize_embeddings=True (Embedder default), so dot product == cosine.
# Renormalize defensively to keep this module independent of caller.
p_vecs = _l2_normalize(p_vecs)
g_vecs = _l2_normalize(g_vecs)
sim = p_vecs @ g_vecs.T # shape (P, G)
# Collect ALL same-domain pairs first (no threshold filter) so the
# diagnostics output can show the full distribution, including the
# near-misses that --threshold sweeps are interested in.
all_pairs: list[tuple[float, int, int]] = []
for i, p_node in enumerate(predicted):
for j, g_node in enumerate(gold):
if p_node.domain != g_node.domain:
continue # hard same-domain filter — applies to diag output too
score = float(sim[i, j])
all_pairs.append((score, i, j))
all_pairs.sort(key=lambda x: x[0], reverse=True)
matched_p: set[int] = set()
matched_g: set[int] = set()
matches: list[NodeMatch] = []
accepted_pairs: set[tuple[int, int]] = set()
for score, i, j in all_pairs:
if score < threshold:
break # candidates are sorted desc; nothing further can pass
if i in matched_p or j in matched_g:
continue
matched_p.add(i)
matched_g.add(j)
accepted_pairs.add((i, j))
matches.append(
NodeMatch(
p_id=predicted[i].id,
g_id=gold[j].id,
cosine=round(score, 4),
severity_match=(predicted[i].severity == gold[j].severity),
)
)
candidates = [
NodeMatchCandidate(
p_id=predicted[i].id,
g_id=gold[j].id,
cosine=round(score, 4),
severity_match=(predicted[i].severity == gold[j].severity),
accepted=((i, j) in accepted_pairs),
)
for score, i, j in all_pairs
]
return matches, candidates
def compute_metrics(
predicted: CascadeChain,
gold: CascadeChain,
matches: list[NodeMatch],
) -> dict:
"""Aggregate node-level matches into the per-event metric dict.
Keys:
- precision, recall, f1: node-level (matched / total)
- domain_jaccard: |P_dom ∩ G_dom| / |P_dom ∪ G_dom|
- severity_match_rate: fraction of matches with identical severity
(None when no matches)
"""
p_total = len(predicted.cascade_events)
g_total = len(gold.cascade_events)
m = len(matches)
precision = m / p_total if p_total else 0.0
recall = m / g_total if g_total else 0.0
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
p_domains = {n.domain for n in predicted.cascade_events}
g_domains = {n.domain for n in gold.cascade_events}
union = p_domains | g_domains
domain_jaccard = (
len(p_domains & g_domains) / len(union) if union else 0.0
)
severity_match_rate = (
sum(1 for x in matches if x.severity_match) / m if m else None
)
return {
"precision": round(precision, 4),
"recall": round(recall, 4),
"f1": round(f1, 4),
"domain_jaccard": round(domain_jaccard, 4),
"severity_match_rate": (
round(severity_match_rate, 4) if severity_match_rate is not None else None
),
}
def _l2_normalize(arr: np.ndarray) -> np.ndarray:
"""Row-wise L2 normalization, safe against zero rows."""
norms = np.linalg.norm(arr, axis=1, keepdims=True)
norms[norms == 0] = 1.0
return arr / norms
# v0.5 issue #65 — domain-bag (category-level) matching.
# Drops the description-cosine threshold from match_nodes_with_diagnostics;
# pairs pred and gold within each domain by multiset min count.
# Defended in technical_report/v0.5/metric_refactor_report.md §2.
# Sentinel for the cosine field on domain-bag NodeMatch. Cosine is in
# [0, 1] for L2-normalized embeddings, so -1.0 is unambiguously "no
# embedding similarity was computed" and JSON-serializes cleanly
# (unlike NaN, which round-trips to null and fails pydantic float
# validation on reload).
_DOMAIN_BAG_COSINE_SENTINEL: float = -1.0
def match_nodes_domain_bag(
predicted: list[CascadeNode],
gold: list[CascadeNode],
) -> list[NodeMatch]:
"""Domain-bag matching — pair pred and gold nodes within each domain
by multiset min, without requiring description cosine similarity.
For each domain present in both predicted and gold, this emits
``min(pred_count_d, gold_count_d)`` NodeMatch objects. The 1-to-1
constraint is preserved (each pred / gold node appears in at most
one match), but the cosine threshold is dropped.
Severity_match flag is computed by greedy multiset alignment per
severity bucket: pairs that share severity within a domain are
paired first; remaining nodes are then paired across severities
(severity_match=False for these). The cosine field is set to
``_DOMAIN_BAG_COSINE_SENTINEL`` (-1.0) since no embedding
similarity is computed here.
"""
from collections import defaultdict
pred_by_dom: dict[str, list[CascadeNode]] = defaultdict(list)
gold_by_dom: dict[str, list[CascadeNode]] = defaultdict(list)
for n in predicted:
pred_by_dom[n.domain].append(n)
for n in gold:
gold_by_dom[n.domain].append(n)
matches: list[NodeMatch] = []
domains = set(pred_by_dom) & set(gold_by_dom)
for d in sorted(domains):
ps = pred_by_dom[d]
gs = gold_by_dom[d]
sev_pred: dict[str, list[CascadeNode]] = defaultdict(list)
sev_gold: dict[str, list[CascadeNode]] = defaultdict(list)
for n in ps:
sev_pred[n.severity].append(n)
for n in gs:
sev_gold[n.severity].append(n)
used_p_ids: set[str] = set()
used_g_ids: set[str] = set()
# Pass 1: pair within same severity (severity_match=True)
for sev in sorted(set(sev_pred) | set(sev_gold)):
k = min(len(sev_pred[sev]), len(sev_gold[sev]))
for i in range(k):
pn = sev_pred[sev][i]
gn = sev_gold[sev][i]
used_p_ids.add(pn.id)
used_g_ids.add(gn.id)
matches.append(
NodeMatch(
p_id=pn.id, g_id=gn.id,
cosine=_DOMAIN_BAG_COSINE_SENTINEL,
severity_match=True,
)
)
# Pass 2: cross-severity leftover (up to multiset min)
leftover_p = [n for n in ps if n.id not in used_p_ids]
leftover_g = [n for n in gs if n.id not in used_g_ids]
for pn, gn in zip(leftover_p, leftover_g):
matches.append(
NodeMatch(
p_id=pn.id, g_id=gn.id,
cosine=_DOMAIN_BAG_COSINE_SENTINEL,
severity_match=(pn.severity == gn.severity),
)
)
return matches
def compute_metrics_domain_bag(
matches: list[NodeMatch],
predicted: list[CascadeNode],
gold: list[CascadeNode],
) -> tuple[float, float, float, float, float | None]:
"""Compute (precision, recall, f1, domain_jaccard, severity_match_rate)
for domain-bag matches. severity_match_rate is None when no matches.
"""
m = len(matches)
p_count = len(predicted)
g_count = len(gold)
precision = m / p_count if p_count else 0.0
recall = m / g_count if g_count else 0.0
f1 = (
2 * precision * recall / (precision + recall)
if (precision + recall) else 0.0
)
pred_doms = {n.domain for n in predicted}
gold_doms = {n.domain for n in gold}
union = pred_doms | gold_doms
domain_jaccard = (
len(pred_doms & gold_doms) / len(union) if union else 0.0
)
sev_rate = (
sum(1 for x in matches if x.severity_match) / m if m else None
)
return (precision, recall, f1, domain_jaccard, sev_rate)
# v0.7 issue A — bootstrap CI telemetry.
# Spec: docs/superpowers/specs/2026-06-07-v07-bootstrap-ci-design.md
# CI is reported as additional AGGREGATE telemetry; it does NOT enter the
# KEEP/ROLLBACK acceptance panel (amendment §3.1 ±0.005 tolerance stays
# authoritative). See spec §1.2 for the framing decision.
def bootstrap_macro_ci(
per_event_values: list[float | None],
n_resamples: int = 1000,
confidence: float = 0.95,
seed: int = 42,
) -> dict[str, float | int]:
"""Event-level bootstrap CI for a single macro-averaged metric.
Resamples ``per_event_values`` with replacement ``n_resamples`` times,
takes the mean of each resample, and returns the requested-confidence
percentile interval over those means. ``None`` entries are dropped
before resampling; ``n`` in the returned dict reflects survivors.
Returns a dict with keys: ``mean``, ``ci_low``, ``ci_high``, ``n``,
``n_resamples``, ``confidence``.
Raises ``ValueError`` when no non-None values remain — silent NaN
masks a real programmer error (e.g. an empty evaluable-event set).
Degenerate inputs (n=1 or zero variance) collapse the CI onto the
point estimate; this is well-defined and exercised by the test suite.
"""
filtered = [float(v) for v in per_event_values if v is not None]
if not filtered:
raise ValueError(
"per_event_values must contain at least one non-None entry"
)
arr = np.asarray(filtered, dtype=float)
if arr.size == 1:
v = float(arr[0])
return {
"mean": v,
"ci_low": v,
"ci_high": v,
"n": 1,
"n_resamples": int(n_resamples),
"confidence": float(confidence),
}
rng = np.random.default_rng(seed)
samples = rng.choice(arr, size=(n_resamples, arr.size), replace=True)
means = samples.mean(axis=1)
alpha = (1.0 - confidence) / 2.0
lo, hi = np.percentile(means, [100.0 * alpha, 100.0 * (1.0 - alpha)])
return {
"mean": float(arr.mean()),
"ci_low": float(lo),
"ci_high": float(hi),
"n": int(arr.size),
"n_resamples": int(n_resamples),
"confidence": float(confidence),
}
def aggregate_with_ci(
evaluations: "list[GoldEvaluation]",
metrics_to_ci: tuple[str, ...] = (
"category_recall",
"category_severity_match_rate",
"cosine_f1",
"category_f1",
),
bootstrap_seed: int = 42,
outlier_event_ids: set[str] | None = None,
) -> dict[str, dict]:
"""Compute mean + bootstrap CI for each macro metric across an
evaluation set, grouping by event first and averaging across seeds.
Pipeline:
1. Drop evaluations whose event_id is in ``outlier_event_ids``.
2. Group surviving evaluations by event_id; for each metric build
the list of seed-level values.
3. Per event, average across seeds → one float per event.
4. Feed the per-event means into ``bootstrap_macro_ci``.
Returns a dict ``{metric_name: ci_dict}`` where each ``ci_dict`` is
the output of ``bootstrap_macro_ci`` plus two extra keys:
``per_event_values`` (event_id → seed-mean) and
``per_event_seed_values`` (event_id → list of seed values).
Cosine F1 is stored on the GoldEvaluation schema as ``f1`` (legacy
name); the alias map below resolves that without making callers care.
Caches missing a metric (e.g. category_severity_match_rate is None
for pre-v0.5 caches) contribute no value for that metric. A metric
with zero contributing events is omitted from the output and logged.
"""
import logging
from collections import defaultdict
log = logging.getLogger(__name__)
outlier_set = outlier_event_ids or set()
# GoldEvaluation stores cosine F1 under the legacy `f1` attribute name.
# All other metric names match their attribute names 1:1.
metric_attr_map = {
"cosine_f1": "f1",
"category_f1": "category_f1",
"category_recall": "category_recall",
"category_severity_match_rate": "category_severity_match_rate",
"category_precision": "category_precision",
"precision": "precision",
"recall": "recall",
"domain_jaccard": "domain_jaccard",
}
grouped: dict[str, dict[str, list[float]]] = defaultdict(
lambda: defaultdict(list)
)
for ev in evaluations:
if ev.event_id in outlier_set:
continue
for m in metrics_to_ci:
attr = metric_attr_map.get(m, m)
v = getattr(ev, attr, None)
if v is None:
continue
grouped[ev.event_id][m].append(float(v))
out: dict[str, dict] = {}
for m in metrics_to_ci:
per_event_seed_values = {
eid: per_metric[m]
for eid, per_metric in grouped.items()
if per_metric.get(m)
}
if not per_event_seed_values:
log.warning(
"aggregate_with_ci: no evaluable events for metric '%s'", m
)
continue
per_event_means = {
eid: sum(vs) / len(vs)
for eid, vs in per_event_seed_values.items()
}
ci = bootstrap_macro_ci(
list(per_event_means.values()),
seed=bootstrap_seed,
)
ci["per_event_values"] = per_event_means
ci["per_event_seed_values"] = per_event_seed_values
out[m] = ci
return out