File size: 14,852 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 | #!/usr/bin/env python3
"""
Is the answer actually a SPAN in a retrieved passage?
WHY THIS DECIDES THE ARCHITECTURE
---------------------------------
On a MIG 2g.35gb slice (32 GB, 32 SMs), free-form generation of a 64-token answer
costs 640-1000 ms. The 200 ms budget is only reachable if most answers can be
EXTRACTED from a passage (~5-10 ms) rather than generated.
But MS MARCO answers are human-written and often ABSTRACTIVE -- a rewrite of the
passage, not a copy of it. Whether extraction works is an empirical question about
this corpus, not a design preference. This script answers it before we build.
FOUR MATCH LEVELS, strictest first:
exact normalised answer is a literal substring of the passage
subseq every answer token appears in the passage, in order (allows
"$9,438" vs "$ 9,438", inserted words)
overlap80 >= 80% of answer content tokens appear anywhere in the passage
none abstractive -- must be generated
DECISION RULE
< 30% strict -> extraction not viable; the budget must come from a
smaller model + speculative decoding, and TTFT must
be reported separately from full completion
>= 30% strict -> extraction is worth building. HOW to route is then
decided by whether query_type is discriminative:
* wide spread AND the biggest class is not the most
extractive -> route on query_type
* otherwise -> route on the READER'S
CONFIDENCE, because a type router would push the
largest, most-extractable share of traffic down
the slow generative path
MEASURED ON MSMARCO-XI (n=42,000): strict 58.6%, loose 88.9%.
DESCRIPTION -- 52% of answerable traffic -- is the MOST extractive type (62.7%),
not the least. Type-based routing is therefore the wrong axis here.
python src/extractability.py --per-lang 3000
"""
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.schema_utils import default_root, iter_passages, norm_lang, load_report # noqa: E402
# Re-exported so existing importers keep working. See src/textnorm.py for why
# this is NOT a `[^\w\s]` regex: that form deletes Indic vowel marks and splits
# every word at the gap, which silently corrupted every token-level metric here.
from src.textnorm import normalise, strip_punct # noqa: E402,F401
NO_ANSWER = "no answer present"
def is_subsequence(needle: list[str], hay: list[str]) -> bool:
it = iter(hay)
return all(tok in it for tok in needle)
def classify(answer: str, passage: str) -> str:
"""Strictest matching level that holds."""
a_n, p_n = normalise(answer), normalise(passage)
if a_n and a_n in p_n:
return "exact"
a_p, p_p = normalise(answer, True), normalise(passage, True)
if a_p and a_p in p_p:
return "exact"
a_tok, p_tok = a_p.split(), p_p.split()
if not a_tok:
return "none"
if is_subsequence(a_tok, p_tok):
return "subseq"
p_set = set(p_tok)
hits = sum(1 for t in a_tok if t in p_set)
return "overlap80" if hits / len(a_tok) >= 0.80 else "none"
# WHICH PAIR? This is the decisive question for a multilingual system.
# english Eng_Answer vs English_passages (what we measured first)
# translated Answer vs Translated_passages (what the reader ACTUALLY sees)
# Translation is not word-for-word, so verbatim overlap can survive in English and
# vanish in the target language -- which would make extract-first viable for
# English and hopeless for the other 14. The reader runs on the TRANSLATED pair,
# so the translated number is the one that governs the architecture.
PAIRS = {
# pair -> (answer logical field, passage logical key)
"english": ("answer_en", "text_en"),
"translated": ("answer", "text"),
}
def run(root: Path, per_lang: int, langs_wanted: set[str] | None, pair: str = "english"):
import polars as pl
rep = load_report(root)
fmap, pmap = rep["field_mapping"], rep["passage_mapping"]
pcol = fmap["passages"]
qid_c, lang_c, qt_c = fmap["query_id"], fmap.get("lang"), fmap.get("query_type")
ans_field, psg_key = PAIRS[pair]
ans_c = fmap.get(ans_field)
p_key = pmap.get(psg_key)
# the query column is only used to print readable examples
q_c = fmap.get("query_en") if pair == "english" else fmap.get("query")
sel_key = pmap.get("is_selected")
if not (ans_c and p_key):
raise SystemExit(f"pair '{pair}' needs fields {ans_field!r} and passage key "
f"{psg_key!r}; schema_report has {ans_c!r} / {p_key!r}")
files = [f for f in rep["files"] if "val" in Path(f).name]
cols = [c for c in (qid_c, lang_c, qt_c, q_c, ans_c, pcol) if c]
cols = list(dict.fromkeys(cols))
by_level: Counter = Counter()
by_type: dict[str, Counter] = defaultdict(Counter)
by_lang: dict[str, Counter] = defaultdict(Counter)
gold_vs_any: Counter = Counter()
examples: dict[str, list] = defaultdict(list)
n_checked = 0
for fp in files:
lang_guess = norm_lang(Path(fp).name[:3])
if langs_wanted and lang_guess not in langs_wanted:
continue
try:
df = pl.read_parquet(fp, columns=cols, n_rows=per_lang * 3)
except Exception as exc:
print(f" skip {Path(fp).name}: {exc}")
continue
lang = norm_lang(df[lang_c][0]) if lang_c and len(df) else lang_guess
get = lambda c: df[c].to_list() if c and c in df.columns else [None] * len(df) # noqa: E731
kept = 0
for q, ans, qt, plist in zip(get(q_c), get(ans_c), get(qt_c), df[pcol].to_list()):
if kept >= per_lang:
break
if not isinstance(ans, str) or not ans.strip():
continue
# the no-answer sentinel is stored in English even in translated rows,
# but the translated view may carry a rendered version -- drop both.
if ans.strip().lower().startswith(NO_ANSWER):
continue
gold, others = [], []
for _i, txt, txt_en, sel, _u in iter_passages(plist, pmap["text"],
pmap.get("text_en"), sel_key, None):
use = txt_en if psg_key == "text_en" else txt
if not isinstance(use, str) or not use.strip():
continue
(gold if sel == 1 else others).append(use)
if not gold:
continue
best_gold = min((classify(ans, p) for p in gold),
key=lambda L: ["exact", "subseq", "overlap80", "none"].index(L))
best_any = min((classify(ans, p) for p in gold + others),
key=lambda L: ["exact", "subseq", "overlap80", "none"].index(L))
by_level[best_gold] += 1
by_lang[lang][best_gold] += 1
if qt:
by_type[qt][best_gold] += 1
gold_vs_any[(best_gold != "none", best_any != "none")] += 1
if len(examples[best_gold]) < 3:
examples[best_gold].append((q, ans, gold[0][:180]))
kept += 1
n_checked += 1
print(f" {Path(fp).name:22s} {lang:3s} checked {kept:,}")
return by_level, by_type, by_lang, gold_vs_any, examples, n_checked
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--root", type=Path, default=None)
ap.add_argument("--per-lang", type=int, default=3000)
ap.add_argument("--langs", default=None, help="comma-separated ISO-2; default all")
ap.add_argument("--pair", choices=sorted(PAIRS), default="english",
help="english = Eng_Answer vs English_passages (reference); "
"translated = Answer vs Translated_passages (what the reader sees)")
args = ap.parse_args()
root = args.root.expanduser().resolve() if args.root else default_root()
print(f"==> data root: {root}")
print(f"==> pair : {args.pair}")
wanted = set(args.langs.split(",")) if args.langs else None
ans_field, psg_key = PAIRS[args.pair]
print(f"\n==> testing whether {ans_field} is a span of a gold {psg_key} passage")
by_level, by_type, by_lang, gold_vs_any, examples, n = run(
root, args.per_lang, wanted, args.pair)
if not n:
raise SystemExit("nothing checked — is the validation split present?")
order = ["exact", "subseq", "overlap80", "none"]
print(f"\n{'='*64}\nMATCH LEVEL vs the GOLD passage (n={n:,})\n{'='*64}")
cum = 0
for lvl in order:
c = by_level[lvl]
cum += c
print(f" {lvl:10s} {c:>8,} {100*c/n:5.1f}% cumulative {100*cum/n:5.1f}%")
extractive = by_level["exact"] + by_level["subseq"]
loose = extractive + by_level["overlap80"]
print(f"\n STRICT (exact+subseq) : {100*extractive/n:5.1f}%")
print(f" LOOSE (+overlap80) : {100*loose/n:5.1f}%")
print(f"\n{'='*64}\nBY LANGUAGE (strict extractive share)\n{'='*64}")
print(f" {'lang':6s}{'n':>8}{'exact':>8}{'subseq':>8}{'strict%':>9}{'loose%':>8}")
print(" " + "-"*47)
lang_strict: dict[str, float] = {}
for lg in sorted(by_lang):
c = by_lang[lg]
tot = sum(c.values())
if not tot:
continue
s = c["exact"] + c["subseq"]
lo = s + c["overlap80"]
lang_strict[lg] = 100 * s / tot
print(f" {lg:6s}{tot:>8,}{c['exact']:>8,}{c['subseq']:>8,}"
f"{100*s/tot:>8.1f}%{100*lo/tot:>7.1f}%")
if len(lang_strict) >= 2:
lo_lg = min(lang_strict, key=lang_strict.get)
hi_lg = max(lang_strict, key=lang_strict.get)
print(f"\n spread across languages: {lang_strict[hi_lg]-lang_strict[lo_lg]:.1f} pp "
f"({lo_lg} {lang_strict[lo_lg]:.1f}% .. {hi_lg} {lang_strict[hi_lg]:.1f}%)")
print(f"\n{'='*64}\nBY QUERY TYPE (strict extractive share)\n{'='*64}")
print(f" {'type':14s}{'n':>8}{'exact':>8}{'subseq':>8}{'strict%':>9}{'loose%':>8}")
print(" " + "-"*55)
for qt in sorted(by_type, key=lambda k: -sum(by_type[k].values())):
c = by_type[qt]
tot = sum(c.values())
s = c["exact"] + c["subseq"]
l = s + c["overlap80"]
print(f" {qt:14s}{tot:>8,}{c['exact']:>8,}{c['subseq']:>8,}"
f"{100*s/tot:>8.1f}%{100*l/tot:>7.1f}%")
print(f"\n{'='*64}\nEXAMPLES\n{'='*64}")
for lvl in order:
for qen, ans, psg in examples[lvl][:2]:
print(f"\n [{lvl}]")
print(f" Q: {str(qen)[:80]}")
print(f" A: {str(ans)[:100]}")
print(f" P: {psg[:150]}...")
print(f"\n{'='*64}\nVERDICT\n{'='*64}")
pct = 100 * extractive / n
# Is query_type actually discriminative enough to route on? Check before
# recommending it -- on MS MARCO the biggest class is also the MOST
# extractive, which makes type-based routing actively harmful.
rates = {qt: (c["exact"] + c["subseq"]) / max(1, sum(c.values()))
for qt, c in by_type.items()}
sizes = {qt: sum(c.values()) for qt, c in by_type.items()}
routable = False
if len(rates) >= 2:
biggest = max(sizes, key=sizes.get)
spread = 100 * (max(rates.values()) - min(rates.values()))
biggest_is_best = rates[biggest] == max(rates.values())
routable = spread >= 30 and not biggest_is_best
print(f" query_type spread : {spread:.1f} pp "
f"({min(rates, key=rates.get)} {100*min(rates.values()):.1f}% .. "
f"{max(rates, key=rates.get)} {100*max(rates.values()):.1f}%)")
print(f" largest class : {biggest} ({100*sizes[biggest]/n:.0f}% of sample), "
f"extractive {100*rates[biggest]:.1f}%"
+ (" <- also the BEST" if biggest_is_best else ""))
print(f"\n strict extractive : {pct:.1f}%")
print(f" loose (+overlap) : {100*loose/n:.1f}%")
if pct < 30:
print("\n -> EXTRACTION NOT VIABLE. Answers are abstractive. Budget must come\n"
" from a smaller model + speculative decoding; report TTFT separately.")
elif routable:
print("\n -> HYBRID ROUTER ON query_type is justified: the spread is wide and\n"
" the largest class is not the most extractive.")
else:
print("\n -> ROUTE ON EXTRACTION CONFIDENCE, NOT query_type.")
print(" The type spread is too narrow, and/or the biggest class is also the\n"
" most extractive -- so a type router would send the largest, most\n"
" extractable share of traffic down the SLOW generative path.")
print(" Instead: always run the reader, accept its span when the score clears\n"
" a threshold, else fall back. Same machinery as the abstention head,\n"
" and the threshold is calibrated on the calib split.")
if 100 * loose / n - pct > 20:
print(f"\n NOTE: {100*(loose-extractive)/n:.1f}% land in overlap80 -- the answer tokens ARE\n"
" in the passage but reordered. Those are recoverable by a SHORT\n"
" constrained generation over passage vocabulary, not free-form\n"
" decoding. Cap generation length near the observed answer median.")
if args.pair == "translated":
print("\n THIS IS THE NUMBER THAT GOVERNS THE READER. The reader scores spans of\n"
" Translated_passages against Answer. If this is far below the English\n"
" figure, the lexical reader is not underperforming -- it is being asked\n"
" for a span that does not exist, and no amount of tuning recovers it.")
out = root / "results" / f"extractability_{args.pair}.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps({
"pair": args.pair,
"answer_field": ans_field,
"passage_key": psg_key,
"n_checked": n,
"by_level": dict(by_level),
"by_query_type": {k: dict(v) for k, v in by_type.items()},
"by_language": {k: dict(v) for k, v in by_lang.items()},
"strict_pct_by_language": {k: round(v, 2) for k, v in lang_strict.items()},
"strict_pct": round(pct, 2),
"loose_pct": round(100 * loose / n, 2),
}, indent=2, ensure_ascii=False))
print(f"\n==> wrote {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
|