yams/scripts/cluster_hypothesis_check.py
Trevon 72647bbaf7
chore: stabilize CI, faiss, metadata, docker, and simeon (#25)
- CI warm: ccache dep, continue-on-error, 60min timeout, YAMS_DISABLE_FAISS
- Faiss: YAMS_HAS_FAISS guard, Apple Clang disable, OpenMP detection
- Metadata: retry constraint failures, deduplicate batch UPSERT entries
- Docker: wget for Zig, gfortran, YAMS_DISABLE_FAISS=1
- CI config: free-disk-space v1.3.1, libomp-18-dev, experimental trigger
- Weekly benchmarks: faiss disable, ccache, diagnostics capture
- Simeon: bump to 7763a97, EntropyRouter, concept_count
- CorpusStats: extraction readiness fields
- Remove committed artifacts (.wrangler, .artifacts, dispatcher_external)
- Fix .vscode/settings.json hardcoded path

Signed-off-by: trvon <git@trevon.dev>
2026-05-07 21:07:59 -04:00

203 lines
7.7 KiB
Python
Executable file

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "numpy",
# "scikit-learn",
# "sentence-transformers",
# ]
# ///
"""
R1 — Cluster Hypothesis Empirical Check (Phase V Gap)
The cluster hypothesis (van Rijsbergen 1979) states that documents relevant to
the same query tend to cluster together under a useful similarity measure. All
of YAMS's routing/clustering work (Phases P/R/S/U/V) implicitly depends on this
holding for our corpora. Five phases of negative results suggest it might not.
This script tests the hypothesis empirically without touching YAMS:
For each query with ≥2 relevant docs in qrels:
intra = mean pairwise cosine across the relevant set
rand = mean pairwise cosine across a same-size random subset
lift = intra / rand
Aggregate the lift across queries. Lift >> 1 → hypothesis holds; lift ≈ 1 →
relevant docs are no more similar to each other than random docs are to each
other, and routing-via-clusters cannot work in principle.
Embedding: sentence-transformers/all-MiniLM-L6-v2 (fast, well-known, semantic).
If the hypothesis fails under a SOTA semantic embedding, it will certainly fail
under YAMS's Simeon FWHT projection — the test is intentionally generous.
Usage:
python scripts/cluster_hypothesis_check.py \
--beir-dir ~/.cache/yams/benchmarks/scifact \
--max-queries 0 # 0 = all
"""
import argparse
import json
import random
import sys
from pathlib import Path
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
def load_jsonl(path):
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
yield json.loads(line)
def load_qrels(qrels_dir):
qrels = {}
test = qrels_dir / "test.tsv"
if not test.exists():
test = qrels_dir / "qrels-test.tsv"
if not test.exists():
candidates = list(qrels_dir.glob("*.tsv"))
if not candidates:
raise FileNotFoundError(f"no .tsv qrels found in {qrels_dir}")
test = candidates[0]
with open(test, encoding="utf-8") as f:
next(f, None) # header
for line in f:
parts = line.strip().split("\t")
if len(parts) < 3:
continue
qid, did, score = parts[0], parts[1], parts[2]
try:
if int(score) <= 0:
continue
except ValueError:
continue
qrels.setdefault(qid, []).append(did)
return qrels
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--beir-dir", required=True, type=Path)
ap.add_argument("--max-queries", type=int, default=0,
help="0 = all queries with ≥2 relevant docs")
ap.add_argument("--max-corpus", type=int, default=0,
help="0 = all docs; otherwise random subsample for embedding speed")
ap.add_argument("--seed", type=int, default=42)
ap.add_argument("--model", default="sentence-transformers/all-MiniLM-L6-v2")
args = ap.parse_args()
random.seed(args.seed)
np.random.seed(args.seed)
corpus_path = args.beir_dir / "corpus.jsonl"
queries_path = args.beir_dir / "queries.jsonl"
qrels_dir = args.beir_dir / "qrels"
if not corpus_path.exists() or not queries_path.exists() or not qrels_dir.exists():
print(f"missing scifact files in {args.beir_dir}", file=sys.stderr)
sys.exit(2)
print(f"[r1] loading corpus from {corpus_path}", flush=True)
corpus_id_to_text = {}
for r in load_jsonl(corpus_path):
did = str(r.get("_id") or r.get("id"))
title = r.get("title", "") or ""
text = r.get("text", "") or ""
corpus_id_to_text[did] = (title + " " + text).strip()
print(f"[r1] loading queries from {queries_path}", flush=True)
query_id_to_text = {}
for r in load_jsonl(queries_path):
qid = str(r.get("_id") or r.get("id"))
query_id_to_text[qid] = (r.get("text", "") or "").strip()
print(f"[r1] loading qrels from {qrels_dir}", flush=True)
qrels = load_qrels(qrels_dir)
eligible = {qid: dids for qid, dids in qrels.items() if len(dids) >= 2}
print(f"[r1] {len(eligible)} queries with ≥2 relevant docs (of {len(qrels)} total)",
flush=True)
if args.max_queries > 0:
eligible = dict(list(eligible.items())[: args.max_queries])
print(f"[r1] limited to {len(eligible)} queries", flush=True)
# Collect doc ids we need: union of relevant + sampled-random-pool docs.
needed_ids = set()
for dids in eligible.values():
needed_ids.update(dids)
all_corpus_ids = list(corpus_id_to_text.keys())
rng = random.Random(args.seed)
pool_size = max(2 * len(needed_ids), 2000)
if pool_size > len(all_corpus_ids):
pool_size = len(all_corpus_ids)
random_pool_ids = rng.sample(all_corpus_ids, pool_size)
needed_ids.update(random_pool_ids)
needed_ids = [d for d in needed_ids if d in corpus_id_to_text]
print(f"[r1] embedding {len(needed_ids)} docs (relevant + random pool)", flush=True)
print(f"[r1] loading model {args.model}", flush=True)
from sentence_transformers import SentenceTransformer
model = SentenceTransformer(args.model)
texts = [corpus_id_to_text[d] for d in needed_ids]
print(f"[r1] encoding {len(texts)} doc texts...", flush=True)
emb = model.encode(texts, batch_size=64, show_progress_bar=True,
convert_to_numpy=True, normalize_embeddings=True)
id_to_emb = {d: emb[i] for i, d in enumerate(needed_ids)}
def mean_pairwise_cos(ids):
vs = np.stack([id_to_emb[d] for d in ids if d in id_to_emb])
if vs.shape[0] < 2:
return None
sim = cosine_similarity(vs)
upper = sim[np.triu_indices_from(sim, k=1)]
return float(np.mean(upper))
intras = []
rands = []
lifts = []
skipped = 0
for qid, rel_ids in eligible.items():
rel_ids = [d for d in rel_ids if d in id_to_emb]
if len(rel_ids) < 2:
skipped += 1
continue
intra = mean_pairwise_cos(rel_ids)
# Same-size random sample, exclude relevant docs from the pool.
pool_excl = [d for d in random_pool_ids if d not in set(rel_ids) and d in id_to_emb]
rand_ids = rng.sample(pool_excl, min(len(rel_ids), len(pool_excl)))
rand = mean_pairwise_cos(rand_ids)
if intra is None or rand is None:
skipped += 1
continue
intras.append(intra)
rands.append(rand)
lifts.append(intra / rand if rand != 0 else None)
print()
print("=== R1 — Cluster Hypothesis Empirical Check ===")
print(f"corpus dir: {args.beir_dir}")
print(f"embedding model: {args.model}")
print(f"queries evaluated: {len(intras)}")
print(f"skipped (not enough): {skipped}")
print(f"mean intra-relevant cosine: {np.mean(intras):.4f}")
print(f"mean random-baseline cosine: {np.mean(rands):.4f}")
if any(v is not None for v in lifts):
ll = [v for v in lifts if v is not None]
print(f"mean intra/random lift: {np.mean(ll):.3f}")
print(f"median intra/random lift: {np.median(ll):.3f}")
n_above_1 = sum(1 for v in ll if v > 1.0)
print(f"queries with lift > 1: {n_above_1}/{len(ll)} "
f"({100.0 * n_above_1 / len(ll):.1f}%)")
print()
print("Interpretation:")
print(" lift >> 1 (e.g., 2x+): cluster hypothesis holds → routing/clusters viable")
print(" lift ≈ 1.2 - 1.5: weak signal; routing wins likely small")
print(" lift ≈ 1: hypothesis empirically fails for this corpus+embedding")
if __name__ == "__main__":
main()