DylanJHJ/APRIL / qrel-analysis /sanity_check_pooling.py
DylanJHJ's picture
download
raw
9.8 kB
"""
Sanity checks for diverse_pooling.py output.
Checks:
1. Query coverage – every query has at least one pooled doc
2. Pool size – per-query unique doc counts (min/mean/max)
3. Per-system unique contribution – docs each system adds exclusively
4. Pairwise Jaccard overlap – how redundant any two systems are
5. Human-qrel recall – fraction of known-relevant docs in the pool
(only when --qrel is provided)
"""
import argparse
import sys
from collections import defaultdict
# ---------------------------------------------------------------------------
# I/O helpers
# ---------------------------------------------------------------------------
def parse_trec_run(filepath, topk=None):
"""Return {qid: set(docids)} keeping only top-k per query (by rank field)."""
raw = defaultdict(list)
with open(filepath) as f:
for line in f:
parts = line.strip().split()
if len(parts) < 6:
continue
qid, docid, rank = parts[0], parts[2], int(parts[3])
raw[qid].append((docid, rank))
result = {}
for qid, docs in raw.items():
ranked = [d for d, _ in sorted(docs, key=lambda x: x[1])]
result[qid] = set(ranked[:topk] if topk else ranked)
return result
def parse_qrel(filepath, min_relevance=1):
"""Return {qid: set(relevant_docids)}."""
relevant = defaultdict(set)
with open(filepath) as f:
for line in f:
parts = line.strip().split()
if len(parts) < 4:
continue
qid, docid, rel = parts[0], parts[2], int(parts[3])
if rel >= min_relevance:
relevant[qid].add(docid)
return dict(relevant)
# ---------------------------------------------------------------------------
# Check helpers
# ---------------------------------------------------------------------------
def check_query_coverage(pool, all_qids):
missing = all_qids - set(pool.keys())
empty = {q for q, docs in pool.items() if len(docs) == 0}
return missing | empty
def pool_size_stats(pool):
sizes = [len(docs) for docs in pool.values()]
if not sizes:
return {}
return {
"n_queries": len(sizes),
"min": min(sizes),
"max": max(sizes),
"mean": sum(sizes) / len(sizes),
"median": sorted(sizes)[len(sizes) // 2],
}
def per_system_unique(system_sets):
"""
For each system, count docs that appear in that system's top-K
but in no other system's top-K, averaged across queries.
Returns {system_name: {"total_unique": int, "queries_with_unique": int}}
"""
names = list(system_sets.keys())
stats = {n: {"total_unique": 0, "queries_with_unique": 0} for n in names}
all_qids = set()
for docs_by_qid in system_sets.values():
all_qids |= set(docs_by_qid.keys())
for qid in all_qids:
per_sys = {n: system_sets[n].get(qid, set()) for n in names}
for name in names:
others = set()
for other_name, docs in per_sys.items():
if other_name != name:
others |= docs
unique = per_sys[name] - others
stats[name]["total_unique"] += len(unique)
if unique:
stats[name]["queries_with_unique"] += 1
return stats
def pairwise_jaccard(system_sets):
"""Return {(nameA, nameB): mean_jaccard} for all pairs."""
names = list(system_sets.keys())
all_qids = set()
for docs_by_qid in system_sets.values():
all_qids |= set(docs_by_qid.keys())
results = {}
for i in range(len(names)):
for j in range(i + 1, len(names)):
a, b = names[i], names[j]
jaccards = []
for qid in all_qids:
sa = system_sets[a].get(qid, set())
sb = system_sets[b].get(qid, set())
union = sa | sb
if union:
jaccards.append(len(sa & sb) / len(union))
results[(a, b)] = sum(jaccards) / len(jaccards) if jaccards else 0.0
return results
def qrel_recall(pool, qrel):
"""
Per query: recall = |pool ∩ relevant| / |relevant|.
Returns (mean_recall, n_zero_recall_queries, n_evaluated_queries).
"""
recalls = []
zero_count = 0
for qid, rel_docs in qrel.items():
if not rel_docs:
continue
pooled = pool.get(qid, set())
r = len(pooled & rel_docs) / len(rel_docs)
recalls.append(r)
if r == 0:
zero_count += 1
mean_r = sum(recalls) / len(recalls) if recalls else 0.0
return mean_r, zero_count, len(recalls)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Sanity-check a diverse-pooling run.")
parser.add_argument("--run_files", nargs="+", required=True,
help="Individual system TREC run files (same as passed to diverse_pooling.py)")
parser.add_argument("--pool", required=True,
help="Fused pool TREC run file (output of diverse_pooling.py)")
parser.add_argument("--qrel", default=None,
help="Human qrel file (TREC format) for recall check")
parser.add_argument("--topk", type=int, default=10,
help="Top-K per system used during pooling (default: 10)")
parser.add_argument("--min_relevance", type=int, default=1,
help="Minimum relevance grade counted as relevant in qrel")
args = parser.parse_args()
sep = "-" * 60
# Load data
print(f"Loading pool from: {args.pool}")
pool = parse_trec_run(args.pool)
pool_docsets = {qid: set(docs) for qid, docs in pool.items()}
print(f"Loading {len(args.run_files)} system run(s) ...")
system_sets = {}
for fp in args.run_files:
name = fp.split("/")[-1] # use filename as label
system_sets[name] = parse_trec_run(fp, topk=args.topk)
print(f" {name}: {len(system_sets[name])} queries")
all_qids = set(pool_docsets.keys())
for docs_by_qid in system_sets.values():
all_qids |= set(docs_by_qid.keys())
print()
# ------------------------------------------------------------------
# Check 1: Query coverage
# ------------------------------------------------------------------
print(sep)
print("CHECK 1: Query coverage")
uncovered = check_query_coverage(pool_docsets, all_qids)
if uncovered:
print(f" FAIL — {len(uncovered)} queries have no pool docs: {sorted(uncovered)[:10]}")
else:
print(f" OK — all {len(all_qids)} queries have at least one pooled doc")
# ------------------------------------------------------------------
# Check 2: Pool size distribution
# ------------------------------------------------------------------
print(sep)
print("CHECK 2: Pool size per query (unique docs)")
stats = pool_size_stats(pool_docsets)
print(f" Queries : {stats['n_queries']}")
print(f" Min : {stats['min']}")
print(f" Median : {stats['median']}")
print(f" Mean : {stats['mean']:.1f}")
print(f" Max : {stats['max']}")
max_possible = args.topk * len(args.run_files)
mean_util = stats["mean"] / max_possible * 100
print(f" Utilisation vs. max possible ({max_possible}): {mean_util:.1f}%")
# ------------------------------------------------------------------
# Check 3: Per-system unique contribution
# ------------------------------------------------------------------
print(sep)
print("CHECK 3: Per-system unique contribution (docs not in any other system's top-K)")
unique_stats = per_system_unique(system_sets)
n_q = len(all_qids)
for name, s in unique_stats.items():
avg_unique = s["total_unique"] / n_q if n_q else 0
q_pct = s["queries_with_unique"] / n_q * 100 if n_q else 0
flag = " " if avg_unique > 0 else "!"
print(f" {flag} {name}")
print(f" avg unique docs/query : {avg_unique:.2f}")
print(f" queries with ≥1 unique: {s['queries_with_unique']}/{n_q} ({q_pct:.0f}%)")
# ------------------------------------------------------------------
# Check 4: Pairwise Jaccard overlap
# ------------------------------------------------------------------
print(sep)
print("CHECK 4: Mean pairwise Jaccard overlap (lower = more diverse)")
if len(system_sets) < 2:
print(" Only one system — skip.")
else:
jaccards = pairwise_jaccard(system_sets)
for (a, b), j in sorted(jaccards.items(), key=lambda x: -x[1]):
flag = "!" if j > 0.5 else " "
print(f" {flag} {a}{b} : {j:.3f}")
# ------------------------------------------------------------------
# Check 5: Human-qrel recall (optional)
# ------------------------------------------------------------------
if args.qrel:
print(sep)
print("CHECK 5: Human-qrel recall of pool")
qrel = parse_qrel(args.qrel, min_relevance=args.min_relevance)
mean_r, zero_q, n_eval = qrel_recall(pool_docsets, qrel)
print(f" Queries evaluated : {n_eval}")
print(f" Mean recall : {mean_r:.4f}")
if zero_q > 0:
print(f" ! Queries with 0 recall: {zero_q} ({zero_q/n_eval*100:.1f}%)")
else:
print(f" OK — no queries with zero recall")
else:
print(sep)
print("CHECK 5: Human-qrel recall — skipped (pass --qrel to enable)")
print(sep)
print("Done.")
if __name__ == "__main__":
main()

Xet Storage Details

Size:
9.8 kB
·
Xet hash:
e37c34421e60be37be2a3ed5668771f2ada6ace1933f03e3e20c6fc8cedd1292

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.