File size: 19,120 Bytes
4289dff e56b151 4289dff e56b151 6ab7c6d e56b151 6ab7c6d e56b151 6ab7c6d e56b151 6ab7c6d e56b151 4289dff e56b151 6ab7c6d e56b151 6ab7c6d e56b151 6ab7c6d e56b151 6ab7c6d e56b151 6ab7c6d 4289dff e56b151 4289dff e56b151 4289dff e56b151 4289dff e56b151 4289dff e56b151 4289dff 6ab7c6d e56b151 6ab7c6d e56b151 4289dff e56b151 4289dff e56b151 4289dff e56b151 4289dff | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 | """Recompute the published model comparisons from text-free evaluation records.
Python 3.11+, standard library only. Run: python metrics.py --directory .
"""
from __future__ import annotations
import argparse
import json
import math
from pathlib import Path
from statistics import mean
CUTOFFS = (1, 3, 5, 10, 20, 50)
FACETS = ("trap", "decision", "constraint", "mechanism", "procedure")
def classification_metrics(labels: list[int], scores: list[float]) -> dict[str, float]:
"""Threshold-grouped AP and best precision at or above each recall target."""
if len(labels) != len(scores) or not labels or set(labels) - {0, 1}:
raise ValueError("Classification labels and scores must be aligned binary rows")
if not all(math.isfinite(s) for s in scores) or sum(labels) == 0:
raise ValueError("Classification scores must be finite with positive support")
order = sorted(range(len(labels)), key=lambda i: -scores[i])
positives = sum(labels)
true_positives = 0
previous_recall = 0.0
ap = 0.0
points = []
for rank, index in enumerate(order, 1):
true_positives += labels[index]
if rank < len(order) and scores[order[rank]] == scores[index]:
continue
precision = true_positives / rank
recall = true_positives / positives
ap += (recall - previous_recall) * precision
previous_recall = recall
points.append((precision, recall))
return {
"average_precision": ap,
"precision_at_recall_90": max(p for p, r in points if r >= 0.9),
"precision_at_recall_95": max(p for p, r in points if r >= 0.95),
"prevalence": positives / len(labels),
}
def dcg(grades: list[int], k: int) -> float:
return sum((2**g - 1) / math.log2(i + 2) for i, g in enumerate(grades[:k]))
def ranking_metrics(grades: list[int], scores: list[float], k: int) -> dict[str, float]:
if len(grades) != len(scores) or len(grades) < k or k < 1:
raise ValueError("Ranking inputs must be aligned and cover the cutoff")
if any(type(g) is not int or g not in range(4) for g in grades):
raise ValueError("Ranking requires fully judged integer grades 0 through 3")
if not all(math.isfinite(s) for s in scores):
raise ValueError("Ranking scores must be finite")
order = sorted(range(len(scores)), key=lambda i: (-scores[i], i))
ranked = [grades[i] for i in order]
useful = sum(g >= 2 for g in ranked[:k])
relevant = sum(g >= 2 for g in grades)
ideal = dcg(sorted(grades, reverse=True), k)
result = {"hit": float(useful > 0), "precision": useful / k, "useful": float(useful)}
if relevant:
result["recall"] = useful / relevant
result["ndcg"] = dcg(ranked, k) / ideal
return result
def random_ranking_metrics(grades: list[int], k: int) -> dict[str, float]:
"""Exact expectation under a uniform permutation of one fixed judged pool."""
n = len(grades)
if k < 1 or k > n or any(type(g) is not int or g not in range(4) for g in grades):
raise ValueError("Random reference requires judged grades and a valid cutoff")
relevant = sum(g >= 2 for g in grades)
misses = math.comb(n - relevant, k) if n - relevant >= k else 0
result = {"hit": 1 - misses / math.comb(n, k), "precision": relevant / n, "useful": k * relevant / n}
if relevant:
expected_dcg = mean(2**g - 1 for g in grades) * sum(1 / math.log2(i + 2) for i in range(k))
result["recall"] = k / n
result["ndcg"] = expected_dcg / dcg(sorted(grades, reverse=True), k)
return result
def summarize_classifier(data: dict) -> dict:
rows = data["rows"]
result = {"rows": len(rows), "families": len({r["group"] for r in rows}), "models": {}}
for model in data["model_order"]:
by_facet = {}
for facet in FACETS:
resolved = [r for r in rows if r["labels"][facet] is not None]
if any(r["labels"][facet] not in (0, 1) for r in resolved):
raise ValueError("Invalid resolved classifier label")
by_facet[facet] = {"rows": len(resolved), **classification_metrics(
[r["labels"][facet] for r in resolved], [r["scores"][model][facet] for r in resolved]
)}
macro = {k: mean(v[k] for v in by_facet.values()) for k in ("average_precision", "precision_at_recall_90", "precision_at_recall_95")}
result["models"][model] = {"facets": by_facet, "macro": macro}
return result
def summarize_reranker(data: dict) -> dict:
rows = data["rows"]
answerable = [r for r in rows if any(g >= 2 for g in r["grades"])]
output = {"queries": len(rows), "answerable": len(answerable), "models": {}}
for model in ["random", *data["model_order"]]:
scopes = {}
for scope, subset in [("answerable", answerable), ("all", rows)]:
cuts = {}
for k in CUTOFFS:
values = [random_ranking_metrics(r["grades"], k) if model == "random" else ranking_metrics(r["grades"], r["scores"][model], k) for r in subset]
fields = ("hit", "precision", "useful", "recall", "ndcg") if scope == "answerable" else ("hit", "precision", "useful")
cuts[str(k)] = {field: mean(v[field] for v in values) for field in fields}
scopes[scope] = cuts
output["models"][model] = scopes
return output
def summarize_retriever(data: dict) -> dict:
"""Dense rankings, with both full-panel and known-answerable summaries."""
if type(data['corpus_passages']) is not int or data['corpus_passages'] <= 0:
raise ValueError('Dense comparison requires a positive corpus size')
return {**_summarize_ranked_grades(data, include_selected=False),
'answerable': _summarize_ranked_grades(data, include_selected=False, answerable_only=True),
'corpus_passages': data['corpus_passages']}
def summarize_dimensions(data: dict) -> dict:
"""Average the measured public nDCG values for each embedding width."""
rows, widths = data['rows'], data['widths']
if not rows or len({row['id'] for row in rows}) != len(rows):
raise ValueError('Width comparison requires distinct query identities')
if not widths or len(set(widths)) != len(widths) or any(type(w) is not int or w <= 0 for w in widths):
raise ValueError('Embedding widths must be distinct positive integers')
for row in rows:
if set(row['ndcg@10']) != {str(w) for w in widths} or any(
isinstance(v, bool) or not isinstance(v, int | float) or not math.isfinite(v) or not 0 <= v <= 1
for v in row['ndcg@10'].values()
):
raise ValueError('Every query must have a finite nDCG value for every width')
panels = {}
for name in sorted({row['dataset'] for row in rows}):
panel = [row for row in rows if row['dataset'] == name]
panels[name] = {'queries': len(panel), 'ndcg@10': {
str(w): mean(row['ndcg@10'][str(w)] for row in panel) for w in widths
}}
result = {'queries': len(rows), 'vector_bytes_fp32': {str(w): w * 4 for w in widths}, 'public': panels}
if 'private' in data:
private = data['private']
if private['model_order'] != [str(w) for w in widths]:
raise ValueError('Private width rankings must cover the same ordered widths')
result['private'] = summarize_record('retriever', private)
return result
def _summarize_ranked_grades(data: dict, *, include_selected: bool, answerable_only: bool = False) -> dict:
"""Reviewed exclusions inside each original dense or hybrid prefix.
A null is permitted only for an explicitly excluded judging abstention.
Cut first, remove exclusions second, and never backfill from a deeper rank.
"""
rows = data['rows']
if not rows or len({row['id'] for row in rows}) != len(rows):
raise ValueError('Ranked rows require unique nonempty query identities')
# Published promotion and serving v1 records contain twenty positions.
# Extended dense/serving records declare their measured depth explicitly.
depth_limit = data.get('ranking_depth', 20)
allowed_depths = (20, 50) if include_selected else (10, 20, 50)
if type(depth_limit) is not int or depth_limit not in allowed_depths:
raise ValueError('Unsupported measured ranking depth')
# Validate before excluding fully abstained prefixes. An omitted query
# must never conceal a malformed grade, model map or selected depth.
for row in rows:
counts = row['reference_grade_counts']
if set(counts) != {'0', '1', '2', '3'} or any(type(n) is not int or n < 0 for n in counts.values()):
raise ValueError('Reference grade counts must cover grades zero through three')
for model in data['model_order']:
ranked, excluded = row['ranked_grades'][model], row['excluded'][model]
if (len(ranked) != depth_limit or len(excluded) != depth_limit
or any(type(x) is not bool for x in excluded)):
raise ValueError('Ranked rows must cover exactly the declared ranking depth')
if include_selected:
depth = row['selected_depth'][model]
if type(depth) is not int or not 3 <= depth <= 20:
raise ValueError('Selected depth must be between three and twenty')
if any((grade is not None if drop else type(grade) is not int or grade not in range(4))
for grade, drop in zip(ranked, excluded, strict=True)):
raise ValueError('Only declared abstentions may lack grades')
# The shared relevance reference defines answerability, never one arm's
# retrieved prefix. Validate every source row before applying this filter.
if answerable_only:
rows = [row for row in rows if row['reference_grade_counts']['2'] + row['reference_grade_counts']['3'] > 0]
output = {'queries': len(rows), 'models': {}}
if not rows:
return output # No conditional score exists; do not substitute zeros.
requested_cutoffs = tuple(k for k in CUTOFFS if k <= depth_limit)
if include_selected:
requested_cutoffs += ('selected',)
for model in data['model_order']:
cutoffs = {}
for cutoff in requested_cutoffs:
eligible = [row for row in rows if all(
any(not drop for drop in row['excluded'][arm][:(
row['selected_depth'][arm] if cutoff == 'selected' else cutoff)])
for arm in data['model_order']
)]
if not eligible:
raise ValueError('No shared judged queries at this cutoff')
per_query = []
for row in eligible:
counts = row['reference_grade_counts']
ranked = row['ranked_grades'][model]
excluded = row['excluded'][model]
depth = row['selected_depth'][model] if cutoff == 'selected' else cutoff
kept = [grade for grade, drop in zip(ranked[:depth], excluded[:depth], strict=True) if not drop]
ideal_grades = [g for g in (3, 2, 1, 0) for _ in range(min(counts[str(g)], len(kept)))][:len(kept)]
useful = sum(g >= 2 for g in kept)
positives = counts['2'] + counts['3']
ideal = dcg(ideal_grades, len(kept))
per_query.append({
'hit': float(useful > 0), 'precision': useful / len(kept),
'useful': useful, 'retained': len(kept), 'excluded': depth - len(kept),
'ndcg': dcg(kept, len(kept)) / ideal if ideal else 0.0,
'known_recall': useful / positives if positives else None,
})
useful = sum(row['useful'] for row in per_query)
retained = sum(row['retained'] for row in per_query)
recalls = [row['known_recall'] for row in per_query if row['known_recall'] is not None]
cutoffs[str(cutoff)] = {
'scored_queries': len(eligible), 'excluded_queries': len(rows) - len(eligible),
'hit': mean(row['hit'] for row in per_query), 'precision': useful / retained,
'macro_precision': mean(row['precision'] for row in per_query),
'ndcg': mean(row['ndcg'] for row in per_query),
'known_positive_recall': mean(recalls) if recalls else None,
'recall_queries': len(recalls), 'useful': useful, 'retained': retained,
'excluded_positions': sum(row['excluded'] for row in per_query),
'mean_useful': useful / len(eligible), 'mean_retained': retained / len(eligible),
}
output['models'][model] = cutoffs
return output
def summarize_promotion(data: dict) -> dict:
"""Hybrid rankings and the separately measured public dense panels."""
output = {**_summarize_ranked_grades(data, include_selected=True), 'public': {}}
for dataset, panel in data['public'].items():
if not panel['rows'] or len({row['id'] for row in panel['rows']}) != len(panel['rows']):
raise ValueError('Public panel requires distinct query identities')
measured = panel['model_order']
for row in panel['rows']:
if set(row['ndcg@10']) != set(measured) or any(
isinstance(value, bool) or not isinstance(value, int | float)
or not math.isfinite(value) or not 0 <= value <= 1
for value in row['ndcg@10'].values()
):
raise ValueError('Public nDCG values must be finite measured scores')
output['public'][dataset] = {
'queries': len(panel['rows']),
'ndcg@10': {model: mean(row['ndcg@10'][model] for row in panel['rows']) for model in measured},
}
return output
def summarize_serving(data: dict) -> dict:
"""Current hybrid quality uses the same reference-defined eligible queries."""
return {**summarize_promotion(data),
'answerable': _summarize_ranked_grades(data, include_selected=True, answerable_only=True)}
SUMMARIZERS = {
'classifier': summarize_classifier, 'reranker': summarize_reranker,
'retriever': summarize_retriever, 'dimensions': summarize_dimensions,
'promotion': summarize_promotion, 'serving': summarize_serving,
}
def summarize_record(name: str, data: dict) -> dict:
"""Check anonymous publication records before recomputing their summary."""
fields = {
'classifier': {'id', 'group', 'labels', 'scores'},
'reranker': {'id', 'group', 'grades', 'scores'},
'retriever': {'id', 'group', 'reference_grade_counts', 'ranked_grades', 'excluded'},
'dimensions': {'id', 'dataset', 'ndcg@10'},
'promotion': {'id', 'group', 'reference_grade_counts', 'ranked_grades', 'excluded', 'selected_depth'},
'serving': {'id', 'group', 'reference_grade_counts', 'ranked_grades', 'excluded', 'selected_depth'},
}[name]
rows = data['rows']
if not rows or any(set(row) != fields for row in rows):
raise ValueError(f'Unexpected evaluation row fields in {name}')
ids = [row['id'] for row in rows]
if len(set(ids)) != len(ids):
raise ValueError(f'Repeated query/passage identity in {name}')
for row in rows:
identity = row['id']
if not isinstance(identity, str) or identity[:1] not in ('p', 'q') or not identity[1:].isdigit():
raise ValueError(f'Non-anonymous row identity in {name}')
if 'group' in row and (not isinstance(row['group'], str) or not row['group'].startswith('g') or not row['group'][1:].isdigit()):
raise ValueError(f'Non-anonymous group identity in {name}')
if name != 'dimensions' and set(data['model_order']) != set(data['models']):
raise ValueError(f'Model identities do not match in {name}')
models = set(data.get('model_order', ()))
for row in rows:
for field in ('scores', 'ranked_grades', 'excluded', 'selected_depth'):
if field in row and set(row[field]) != models:
raise ValueError(f'Unexpected model fields in {name}.{field}')
if name == 'classifier':
required = {facet for facet, label in row['labels'].items() if label is not None}
if set(row['labels']) != set(FACETS) or any(not required <= set(scores) <= set(FACETS) for scores in row['scores'].values()):
raise ValueError('Unexpected classifier facet fields')
if any(type(value) not in (int, float) or not math.isfinite(value)
for scores in row['scores'].values() for value in scores.values()):
raise ValueError('Classifier scores must be finite numbers, including unresolved facets')
for panel in data.get('public', {}).values():
if set(panel) != {'model_order', 'rows'} or not panel['rows']:
raise ValueError('Unexpected public-panel fields')
panel_models = set(panel['model_order'])
panel_ids = []
for row in panel['rows']:
if set(row) != {'id', 'ndcg@10'} or set(row['ndcg@10']) != panel_models:
raise ValueError('Unexpected public-panel row fields')
identity = row['id']
if not isinstance(identity, str) or identity[:1] != 'q' or not identity[1:].isdigit():
raise ValueError('Non-anonymous public-panel row identity')
if any(type(value) not in (int, float) or not math.isfinite(value) or not 0 <= value <= 1 for value in row['ndcg@10'].values()):
raise ValueError('Invalid public-panel score')
panel_ids.append(identity)
if len(panel_ids) != len(set(panel_ids)):
raise ValueError('Repeated public-panel query identity')
text = json.dumps(data).lower()
if any(value in text for value in ('c:\\', 'eval/private', 'qrel_target_id', 'query_text', 'passage_text')):
raise ValueError(f'Private payload in {name}')
return SUMMARIZERS[name](data)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--directory", type=Path, default=Path(__file__).resolve().parent)
parser.add_argument("--output", type=Path)
parser.add_argument("--model", choices=("classifier", "reranker", "retriever", "dimensions", "promotion", "serving"), help="Recompute one comparison; default: every record in this package")
args = parser.parse_args()
names = [args.model] if args.model else [
name for name in SUMMARIZERS if (args.directory / f"{name}.json").is_file()
]
if not names:
parser.error("No evaluation records found in the selected directory")
result = {}
for name in names:
data = json.loads((args.directory / f"{name}.json").read_text(encoding="utf-8"))
result[name] = summarize_record(name, data)
text = json.dumps(result, indent=2, allow_nan=False) + "\n"
if args.output:
args.output.write_text(text, encoding="utf-8")
else:
print(text, end="")
if __name__ == "__main__":
main()
|