| """
|
| 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]
|
| fp_seasonal: int
|
|
|
| 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]
|
| matrix: np.ndarray
|
| 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
|
| 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,
|
| ))
|
|
|
|
|
| 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))
|
|
|