panagiotis427's picture
Deploy current UI (code + fixture + figures; gradio 6.18)
b4e56df verified
Raw
History Blame Contribute Delete
7.09 kB
"""
Per-class / confusion error analysis for change retrieval.
Where ``src.benchmark`` reports aggregate Recall@K / mAP, this module asks
*what* a query retrieves wrongly: for each natural-language query it bins the
top-K retrieved pairs by their **actual** label transition (e.g.
``forest_and_other_vegetation->soil``, ``seasonal:snow_and_ice``, ``stable``)
and tallies precision/recall plus the false-positive transition mix. The result
is a ``[query x actual-transition]`` confusion matrix — the artefact behind the
graded seasonal-vs-permanent error analysis (it makes visible when seasonal
transitions leak into permanent-change queries).
Reuses the exact scoring path of ``run_benchmark`` (``ChangeRetriever.score_all``
+ query relevance predicates), so numbers are consistent with the benchmark.
"""
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass, field
from typing import Dict, List, Optional
import numpy as np
from src.benchmark import Query, _is_seasonal, _t1, _t2
from src.datasets.base import PairLabel
from src.retrieval import ChangeRetriever
from src.stats import rank_order
def _actual_transition(lb: Optional[PairLabel]) -> str:
"""Coarse string label for a pair's true transition (the confusion columns)."""
if lb is None:
return "unlabeled"
if _is_seasonal(lb):
snow = "snow_and_ice"
other = _t2(lb) if _t1(lb) == snow else _t1(lb)
return f"seasonal:{other}" if other and other != snow else "seasonal:snow_and_ice"
if lb.stable or _t1(lb) == _t2(lb):
return "stable"
return f"{_t1(lb)}->{_t2(lb)}"
@dataclass
class PerQueryErrors:
text: str
category: str
n_relevant: int
precision_at_k: Dict[int, float]
recall_at_k: Dict[int, float]
fp_transitions: Dict[str, int] # actual transition -> count among non-relevant top-K
fp_seasonal: int # non-relevant top-K that are seasonal
def to_dict(self) -> Dict:
return {
"text": self.text,
"category": self.category,
"n_relevant": int(self.n_relevant),
"precision_at_k": {str(k): float(v) for k, v in self.precision_at_k.items()},
"recall_at_k": {str(k): float(v) for k, v in self.recall_at_k.items()},
"fp_transitions": dict(self.fp_transitions),
"fp_seasonal": int(self.fp_seasonal),
}
@classmethod
def from_dict(cls, d: Dict) -> "PerQueryErrors":
return cls(
text=d["text"], category=d["category"], n_relevant=int(d["n_relevant"]),
precision_at_k={int(k): float(v) for k, v in d["precision_at_k"].items()},
recall_at_k={int(k): float(v) for k, v in d["recall_at_k"].items()},
fp_transitions={k: int(v) for k, v in d["fp_transitions"].items()},
fp_seasonal=int(d["fp_seasonal"]),
)
@dataclass
class ConfusionReport:
encoder: str
approach: str
dataset: str
split: Optional[str]
conf_k: int
labels: List[str] # actual-transition columns
matrix: np.ndarray # [n_query, n_labels] counts over top-conf_k
per_query: List[PerQueryErrors] = field(default_factory=list)
def to_dict(self) -> Dict:
return {
"encoder": self.encoder, "approach": self.approach,
"dataset": self.dataset, "split": self.split, "conf_k": self.conf_k,
"labels": list(self.labels),
"query_texts": [q.text for q in self.per_query],
"matrix": self.matrix.astype(int).tolist(),
"per_query": [q.to_dict() for q in self.per_query],
}
@classmethod
def from_dict(cls, d: Dict) -> "ConfusionReport":
return cls(
encoder=d["encoder"], approach=d["approach"], dataset=d["dataset"],
split=d.get("split"), conf_k=int(d["conf_k"]),
labels=list(d["labels"]),
matrix=np.array(d["matrix"], dtype=int),
per_query=[PerQueryErrors.from_dict(q) for q in d["per_query"]],
)
def build_confusion(
dataset,
retriever: ChangeRetriever,
approach: str = "zero_shot",
queries: Optional[List[Query]] = None,
k_values=(1, 3, 5, 10),
conf_k: int = 10,
max_cols: int = 12,
split: Optional[str] = None,
) -> ConfusionReport:
if queries is None:
from src.queries import get_queries
queries = get_queries(dataset.name)
pairs = retriever.store.pairs
labels = [dataset.get_pair_label(p) for p in pairs]
actual = [_actual_transition(lb) for lb in labels]
rows: List[Counter] = []
per_query: List[PerQueryErrors] = []
col_counts: Counter = Counter()
for q in queries:
rel = np.array([bool(lb is not None and q.predicate(lb)) for lb in labels])
if rel.sum() == 0:
continue # not evaluable in this corpus
scores = retriever.score_all(q.text, approach=approach)
order = rank_order(scores, rel)
prec, rec = {}, {}
for kk in k_values:
topk = order[:kk]
prec[kk] = float(rel[topk].sum() / kk)
rec[kk] = float(rel[topk].sum() / rel.sum())
top = order[:conf_k]
row_counter = Counter(actual[i] for i in top)
col_counts.update(row_counter)
fp_trans = Counter(actual[i] for i in top if not rel[i])
fp_seasonal = int(sum(1 for i in top if (not rel[i]) and _is_seasonal(labels[i])))
rows.append(row_counter)
per_query.append(PerQueryErrors(
text=q.text, category=q.category, n_relevant=int(rel.sum()),
precision_at_k=prec, recall_at_k=rec,
fp_transitions=dict(fp_trans), fp_seasonal=fp_seasonal,
))
# Columns = most frequent actual transitions; rare ones folded into "other".
common = [t for t, _ in col_counts.most_common(max_cols)]
use_other = len(col_counts) > len(common)
cols = common + (["other"] if use_other else [])
col_idx = {t: i for i, t in enumerate(common)}
M = np.zeros((len(rows), len(cols)), dtype=int)
for i, rc in enumerate(rows):
for t, c in rc.items():
if t in col_idx:
M[i, col_idx[t]] += c
elif use_other:
M[i, len(common)] += c
return ConfusionReport(
encoder=retriever.encoder.name, approach=approach, dataset=dataset.name,
split=split, conf_k=conf_k, labels=cols, matrix=M, per_query=per_query,
)
def confusion_to_csv(report: ConfusionReport, path) -> None:
import csv
from pathlib import Path
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["query"] + report.labels)
for q, row in zip(report.per_query, report.matrix):
w.writerow([q.text] + list(row))