File size: 21,946 Bytes
11ecc5b | 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 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 | #!/usr/bin/env python3
"""
WHY DID THE READER SCORE F1 0.36 / EM 0.004?
There are exactly three ways an extractive reader can fail, and they demand
three completely different fixes. Reporting one aggregate F1 cannot tell them
apart, so this script separates them:
1. THE SPAN DOES NOT EXIST.
The translated answer is a paraphrase, not a quote. No extractor can win.
-> the architecture must generate, not extract. Retrain the plan, not the model.
2. THE SPAN EXISTS BUT IN A PASSAGE THE READER DID NOT PICK.
-> fix passage selection (retrieval / reranking), leave the span scorer alone.
3. THE SPAN EXISTS IN THE PASSAGE THE READER PICKED, AND IT PICKED THE WRONG
WORDS.
-> fix the span scorer. This is the one a trained neural reader repairs.
THE LADDER (each rung is an upper bound on the one below it)
oracle_any best token-F1 over every contiguous span in ANY passage
oracle_picked ... restricted to the passage the reader actually chose
reader what the reader returned
oracle_any - oracle_picked = passage-selection loss (failure mode 2)
oracle_picked - reader = span-selection loss (failure mode 3)
1 - oracle_any = irreducible / abstractive (failure mode 1)
Run it on a few languages first; the span enumeration is O(words x span_len).
python src/diagnose_reader.py --per-lang 400 --langs hi,ta,bn
python src/diagnose_reader.py --per-lang 300 # all 14
"""
from __future__ import annotations
import argparse
import json
import sys
from collections import Counter, defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from src.evaluate_reader import iter_eval_rows, token_f1 # noqa: E402
from src.extractability import normalise # noqa: E402
from src.chunkers.base import split_sentences, words_of # noqa: E402
from src.reader import LexicalSpanReader # noqa: E402
from src.router import Passage # noqa: E402
from src.schema_utils import LANG_NAMES, default_root # noqa: E402
# ------------------------------------------------------------------ oracle
def best_span_f1(passage: str, gold_tokens: list[str], max_extra: int = 8) -> tuple[float, str]:
"""
Highest token-F1 achievable by ANY contiguous word span of `passage`, plus
the span itself.
Computed with an incremental overlap counter rather than by scoring every
span from scratch: for a fixed start, extending the window by one word
changes the overlap by at most one, so the whole sweep is O(W * L) cheap
increments instead of O(W * L * |gold|) Counter intersections.
max_extra bounds the window at |gold| + max_extra words. A span much longer
than the answer cannot beat a tighter one -- precision falls faster than
recall rises -- so the bound costs nothing and saves the quadratic term.
"""
words = normalise(passage, True).split()
if not words or not gold_tokens:
return 0.0, ""
need = Counter(gold_tokens)
G = len(gold_tokens)
limit = min(len(words), G + max_extra)
best, best_at = 0.0, (0, 0)
for i in range(len(words)):
have: Counter = Counter()
same = 0
hi = min(i + limit, len(words))
for j in range(i, hi):
w = words[j]
have[w] += 1
if have[w] <= need.get(w, 0):
same += 1
if same:
n = j - i + 1
f1 = 2 * same / (n + G) # == 2PR/(P+R) with P=same/n, R=same/G
if f1 > best:
best, best_at = f1, (i, j + 1)
if best >= 1.0:
return 1.0, " ".join(words[i:j + 1])
return best, " ".join(words[best_at[0]:best_at[1]])
# ------------------------------------------------------------------ run
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--root", type=Path, default=None)
ap.add_argument("--langs", default=None)
ap.add_argument("--per-lang", type=int, default=400)
ap.add_argument("--examples", type=int, default=6)
ap.add_argument("--prior-weight", type=float, default=0.0,
help="how hard to trust Passage.score when choosing a sentence")
ap.add_argument("--ablate", action="store_true",
help="sweep prior-weight instead of running one config")
ap.add_argument("--real-scores", action="store_true",
help="re-score passages with bge-m3 instead of the gold label "
"(REQUIRED for an honest prior ablation -- see below)")
ap.add_argument("--model", default=None)
ap.add_argument("--answer-mode", default="span", choices=["span", "sentence", "both"],
help="'span' trims inside the winning sentence; 'sentence' returns "
"the whole sentence. Gold answers average 19.2 words -- about a "
"sentence -- so trimming may be costing F1. 'both' measures it.")
args = ap.parse_args()
root = args.root.expanduser().resolve() if args.root else default_root()
langs = set(args.langs.split(",")) if args.langs else None
print(f"==> data root: {root}")
# ---------------------------------------------------------- ablation
# The retrieval prior is either worth using or it is not; guessing which is
# how the last two days got spent. Sweep it once, on cached rows, and read
# the answer off the table.
if args.ablate:
rows = list(iter_eval_rows(root, langs, args.per_lang))
rows = [r for r in rows if r[4]] # answerable only
# ------------------------------------------------------------------
# WHY --real-scores IS NOT OPTIONAL FOR A HONEST NUMBER
#
# iter_eval_rows builds Passage(score = 1.0 if is_selected else 0.5).
# That score IS THE GOLD LABEL. Feeding it to the reader as a "retrieval
# prior" and reporting the improvement would be measuring an ORACLE
# reranker -- a number no deployed system can reach, and exactly the
# kind of leak that makes a leaderboard entry collapse on the held-out
# set.
#
# With the gold label -> the ablation is an UPPER BOUND (headroom).
# With --real-scores -> bge-m3 cosine, i.e. what the pipeline
# actually produces. That is the number to
# build on.
# Run both. The gap between them is what better reranking is worth.
# ------------------------------------------------------------------
if args.real_scores:
import torch
from src.evaluate_retrieval import Embedder
model = args.model
if model is None:
hits = list((root / "hf_cache" / "hub").glob("models--BAAI--bge-m3/snapshots/*"))
model = str(hits[0]) if hits else "BAAI/bge-m3"
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"==> re-scoring passages with {model} on {device}")
emb = Embedder(model, device, batch=64, max_len=192)
rescored = []
for lang, qid, q, gold, a, psgs in rows:
qv = emb.encode([q])
pv = emb.encode([p.text for p in psgs])
sims = (pv @ qv.T).squeeze(-1).float().tolist()
rescored.append((lang, qid, q, gold, a,
[Passage(p.chunk_id, p.text, float(s), p.lang)
for p, s in zip(psgs, sims)]))
rows = rescored
print(f"==> scores are bge-m3 cosine (REAL). "
f"Numbers below are achievable, not an oracle.")
else:
print("\n !! Passage.score here is the GOLD LABEL, not a retrieval score.")
print(" These numbers are an UPPER BOUND on what reranking can buy.")
print(" Re-run with --real-scores for the achievable figure.\n")
print(f"\n==> ablating prior_weight over {len(rows):,} answerable queries")
print(f"\n {'prior_w':>9}{'reader F1':>12}{'exact match':>13}"
f"{'read gold psg':>15}{'span len':>10}")
print(" " + "-" * 57)
modes = (["span", "sentence"] if args.answer_mode == "both"
else [args.answer_mode])
modes = ["span", "sentence"] if args.answer_mode == "both" else [args.answer_mode]
best = {}
for mode in modes:
if len(modes) > 1:
print(f"\n --- answer_mode={mode} ---")
print(f" {'prior_w':>9}{'reader F1':>12}{'exact match':>13}"
f"{'read gold psg':>15}{'span len':>10}")
print(" " + "-" * 57)
best_w, best_f1, base_f1 = 0.0, -1.0, None
for w in (0.0, 0.25, 0.50, 0.75, 1.0):
rd = LexicalSpanReader(prior_weight=w, answer_mode=mode)
f1s = em = gold_hit = ratio = 0.0
for lang, qid, q, gold, _a, psgs in rows:
gt = normalise(gold, True).split()
if not gt:
continue
sp = rd.read(q, psgs)
f = token_f1(sp.text, gold)
f1s += f
em += float(f >= 0.999)
top = max(psgs, key=lambda p: p.score)
gold_hit += float(sp.chunk_id == top.chunk_id)
ratio += len(normalise(sp.text, True).split()) / max(1, len(gt))
n_r = max(1, len(rows))
print(f" {w:>9.2f}{f1s/n_r:>12.4f}{em/n_r:>13.4f}"
f"{gold_hit/n_r:>14.1%}{ratio/n_r:>10.2f}x")
if base_f1 is None:
base_f1 = f1s / n_r # the w=0 row of THIS run
if f1s / n_r > best_f1:
best_w, best_f1 = w, f1s / n_r
# Compare against the w=0 row of the SAME subset. A hardcoded
# all-language baseline mixes populations and overstates the gain.
print(f"\n best prior_weight = {best_w:.2f} (reader F1 {best_f1:.4f}, "
f"{100*(best_f1/max(1e-9, base_f1)-1):+.0f}% vs prior_weight 0 "
f"on these same {len(rows):,} queries = {base_f1:.4f})")
best[mode] = (best_w, best_f1)
if len(best) > 1:
# Gold answers average 19.2 words -- about a sentence. Trimming to a
# sub-span may be cutting sentences down to fragments to match
# targets that are sentences. This is the line that settles it.
sp_f1, se_f1 = best["span"][1], best["sentence"][1]
print(f"\n {'='*56}")
print(f" span best F1 {sp_f1:.4f} @ prior {best['span'][0]:.2f}")
print(f" sentence best F1 {se_f1:.4f} @ prior {best['sentence'][0]:.2f}")
win = "sentence" if se_f1 > sp_f1 else "span"
print(f" -> {win} wins by {abs(se_f1-sp_f1):.4f} F1")
if win == "span":
print(" NOTE: serving still uses answer_mode=sentence. A trimmed span")
print(" starts mid-clause and stops mid-number; through TTS that is")
print(" unusable regardless of F1. Report BOTH numbers.")
print("\n 'read gold psg' = share of queries where the reader read the")
print(" top-RETRIEVED passage. At prior_weight 0 this is what the reader")
print(" happened to agree with; at 1.0 it is forced.")
return 0
mode = "span" if args.answer_mode == "both" else args.answer_mode
reader = LexicalSpanReader(prior_weight=args.prior_weight, answer_mode=mode)
print(f"==> reader : {reader.name} (prior_weight {args.prior_weight}, "
f"answer_mode {mode})\n")
agg: dict[str, dict[str, float]] = defaultdict(lambda: defaultdict(float))
examples: list[dict] = []
n_total = 0
for lang, qid, q, gold, answerable, psgs in iter_eval_rows(root, langs, args.per_lang):
if not answerable:
continue
gold_tokens = normalise(gold, True).split()
if not gold_tokens:
continue
span = reader.read(q, psgs)
r_f1 = token_f1(span.text, gold)
# oracle over every passage, and over the one the reader chose
o_any, o_any_text = 0.0, ""
o_picked = 0.0
picked_id = span.chunk_id
matched = False
picked_psg = None
for p in psgs:
f1, txt = best_span_f1(p.text, gold_tokens)
if f1 > o_any:
o_any, o_any_text = f1, txt
if p.chunk_id == picked_id:
matched = True
picked_psg = p
o_picked = max(o_picked, f1)
if not matched: # reader abstained or returned an unattributed span
o_picked = o_any
# Split the span-selection loss one level further. "Wrong words" is two
# different bugs wearing one number:
# picked the wrong SENTENCE -> a ranking problem (query overlap picks
# the sentence that restates the question)
# right sentence, wrong CUT -> a boundary problem (trim/extend)
# Only the second is cheap. Measuring which one dominates costs one
# best_span_f1 call over ~40 words.
o_sent = o_picked
if picked_psg is not None and span.start >= 0:
sent_text, cursor = None, 0
for sent in split_sentences(picked_psg.text):
n = len(words_of(sent))
if n and cursor <= span.start < cursor + n:
sent_text = sent
break
cursor += n
if sent_text:
o_sent = best_span_f1(sent_text, gold_tokens)[0]
a = agg[lang]
a["n"] += 1
a["reader_f1"] += r_f1
a["oracle_picked"] += o_picked
a["oracle_any"] += o_any
a["oracle_sent"] += o_sent
a["oracle_em"] += float(o_any >= 0.999)
a["reader_em"] += float(r_f1 >= 0.999)
# Span-length ratio separates "found the wrong place" from "found the
# right place and did not know where to stop". A reader that returns
# whole sentences sits at 3-5x with decent recall and dead precision --
# which is EM ~0 with F1 ~0.4, exactly the shape we saw.
n_pred, n_gold = len(normalise(span.text, True).split()), len(gold_tokens)
a["len_ratio"] += n_pred / max(1, n_gold)
a["pred_words"] += n_pred
a["gold_words"] += n_gold
n_total += 1
if len(examples) < args.examples and o_any - r_f1 > 0.4:
examples.append({"lang": lang, "query": q[:90], "gold": gold[:90],
"reader": span.text[:90], "oracle": o_any_text[:90],
"reader_f1": round(r_f1, 3), "oracle_f1": round(o_any, 3)})
if not n_total:
raise SystemExit("no answerable rows — check --langs / --root")
# ---------------------------------------------------------- report
W = 78
print(f"\n{'='*W}\nPER-LANGUAGE LADDER (answerable queries only)\n{'='*W}")
print(f" {'lang':6s}{'n':>7}{'reader':>9}{'oracle@':>9}{'oracle':>9}"
f"{'passage':>10}{'span':>9}{'absent':>9}")
print(f" {'':6s}{'':>7}{'F1':>9}{'picked':>9}{'any':>9}{'loss':>10}{'loss':>9}{'':>9}")
print(" " + "-" * (W - 2))
tot = defaultdict(float)
for lang in sorted(agg):
a = agg[lang]
n = a["n"]
rf, op, oa = a["reader_f1"] / n, a["oracle_picked"] / n, a["oracle_any"] / n
print(f" {lang:6s}{int(n):>7,}{rf:>9.3f}{op:>9.3f}{oa:>9.3f}"
f"{oa-op:>10.3f}{op-rf:>9.3f}{1-oa:>9.3f}")
for k in ("n", "reader_f1", "oracle_picked", "oracle_sent", "oracle_any", "oracle_em",
"reader_em", "len_ratio", "pred_words", "gold_words"):
tot[k] += a[k]
n = tot["n"]
rf, op, oa = tot["reader_f1"] / n, tot["oracle_picked"] / n, tot["oracle_any"] / n
o_em, r_em = tot["oracle_em"] / n, tot["reader_em"] / n
print(" " + "-" * (W - 2))
print(f" {'ALL':6s}{int(n):>7,}{rf:>9.3f}{op:>9.3f}{oa:>9.3f}"
f"{oa-op:>10.3f}{op-rf:>9.3f}{1-oa:>9.3f}")
os_ = tot["oracle_sent"] / n
print(f"\n{'='*W}\nWHERE THE F1 GOES\n{'='*W}")
absent, psg_loss, span_loss = 1 - oa, oa - op, op - rf
total_loss = max(1e-9, absent + psg_loss + span_loss)
for label, val, fix in (
("answer not in ANY passage (abstractive)", absent,
"no extractor can fix this -- must generate"),
("in a passage the reader did not pick", psg_loss,
"fix passage selection / reranking"),
("in the picked passage, wrong words", span_loss,
"fix the span scorer -- a trained reader repairs this"),
):
print(f" {label:42s} {val:6.3f} {100*val/total_loss:5.1f}% of the loss")
print(f" {'':42s} -> {fix}")
# Split the "wrong words" bar into its two very different halves.
sent_loss, bound_loss = op - os_, os_ - rf
sub_tot = max(1e-9, sent_loss + bound_loss)
print(f"\n ...and 'wrong words' ({span_loss:.3f}) splits into:")
print(f" {'wrong SENTENCE in the right passage':42s} {sent_loss:6.3f} "
f"{100*sent_loss/sub_tot:5.1f}% of it")
print(f" {'':42s} -> ranking: query overlap picks the sentence that")
print(f" {'':42s} RESTATES the question, not the one that answers it")
print(f" {'right sentence, wrong cut':42s} {bound_loss:6.3f} "
f"{100*bound_loss/sub_tot:5.1f}% of it")
print(f" {'':42s} -> boundary: trim/extend inside the sentence. CHEAP.")
print(f"\n exact-match ceiling (oracle) : {o_em:.3f}")
print(f" exact-match achieved (reader): {r_em:.3f}")
if o_em > 0:
print(f" reader captures {100*r_em/o_em:.1f}% of the achievable exact matches")
ratio = tot["len_ratio"] / n
print(f"\n span length vs gold : {ratio:.2f}x "
f"({tot['pred_words']/n:.1f} words returned vs {tot['gold_words']/n:.1f} in the answer)")
if ratio > 2.0:
print(" ^ THE READER IS RETURNING SENTENCES, NOT SPANS. Precision is capped at\n"
f" ~{1/ratio:.2f} before the span is even in the right place, and exact match\n"
" is structurally near zero. Fix the STOP boundary, not the search.")
elif ratio < 0.6:
print(" ^ spans are TRUNCATED -- the scorer stops too early and drops recall.")
print(f"\n{'='*W}\nVERDICT\n{'='*W}")
bars = {"absent (must generate)": absent,
"wrong passage (rerank)": psg_loss,
"wrong sentence (ranking)": sent_loss,
"wrong cut (boundary)": bound_loss}
top = max(bars, key=bars.get)
fixable = {k: v for k, v in bars.items() if not k.startswith("absent")}
top_fix = max(fixable, key=fixable.get)
spread = max(fixable.values()) - min(fixable.values())
print(f" largest bar overall : {top} ({bars[top]:.3f})")
print(f" largest FIXABLE bar : {top_fix} ({fixable[top_fix]:.3f})")
if absent >= max(fixable.values()):
print(f"\n THE BIGGEST SINGLE LOSS IS UNFIXABLE BY EXTRACTION ({absent:.3f}).")
print(f" The extraction ceiling is {oa:.3f} and no reader beats it. Before")
print(" spending days on any bar below, decide whether the answering path")
print(" should be extractive at all.")
if spread < 0.06:
print(f"\n The three fixable bars are within {spread:.3f} of each other -- there is")
print(" NO dominant fix. Sequential work on any one of them buys little on its")
print(" own. Treat this as a signal to change the approach, not to grind.")
else:
print(f"\n -> work {top_fix} first ({fixable[top_fix]:.3f}).")
if examples:
print(f"\n{'='*W}\nEXAMPLES WHERE THE ORACLE BEATS THE READER BY >0.4 F1\n{'='*W}")
for e in examples:
print(f"\n [{e['lang']}] reader {e['reader_f1']} oracle {e['oracle_f1']}")
print(f" Q : {e['query']}")
print(f" gold : {e['gold']}")
print(f" reader : {e['reader']}")
print(f" oracle : {e['oracle']}")
out = root / "results" / "reader_diagnosis.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps({
"n_answerable": int(n),
"per_language": {
lg: {"n": int(a["n"]),
"reader_f1": round(a["reader_f1"] / a["n"], 4),
"oracle_picked": round(a["oracle_picked"] / a["n"], 4),
"oracle_sent": round(a["oracle_sent"] / a["n"], 4),
"oracle_any": round(a["oracle_any"] / a["n"], 4),
"oracle_em": round(a["oracle_em"] / a["n"], 4),
"language": LANG_NAMES.get(lg, lg)}
for lg, a in agg.items()},
"overall": {"reader_f1": round(rf, 4), "oracle_picked": round(op, 4),
"oracle_sent": round(os_, 4),
"oracle_any": round(oa, 4), "oracle_em": round(o_em, 4),
"reader_em": round(r_em, 4),
"span_len_ratio": round(tot["len_ratio"] / n, 3),
"mean_pred_words": round(tot["pred_words"] / n, 2),
"mean_gold_words": round(tot["gold_words"] / n, 2)},
"loss_decomposition": {"absent": round(absent, 4),
"passage_selection": round(psg_loss, 4),
"span_selection": round(span_loss, 4),
"sentence_selection": round(sent_loss, 4),
"boundary": round(bound_loss, 4)},
"examples": examples,
}, indent=2, ensure_ascii=False))
print(f"\n==> wrote {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
|