Plaiglab / scripts /eval_similarity.py
SanidhyaDhangar's picture
PlaigLab — Hugging Face Space (Docker) clean deploy
ebebfe8
Raw
History Blame Contribute Delete
7.12 kB
"""Honest test of the sentence-similarity engine on REAL text neither the
synthetic Siamese encoder nor MiniLM was trained on.
Two evidence sources:
A) PLOS real academic corpus (data/oa_corpus, 40 real 2024 papers). We build
plagiarism-style pairs in the ACTUAL domain:
positive (reuse, SHOULD match): a real source sentence + an attacked copy
(synonymize / word-dropout — the very transforms the pipeline must catch;
note this FAVOURS the Siamese, which trained on these exact transforms).
negative (SHOULD NOT match): two unrelated real sentences.
B) PAWS (real human paraphrase benchmark, adversarial: high word-overlap pairs
that are NOT paraphrases) — the gold standard for semantic-vs-surface. The
char-trigram Siamese is purely lexical, so PAWS exposes it directly.
Metric: ROC-AUC of the similarity score at separating positives from negatives,
for the Siamese encoder vs MiniLM. Higher = the engine actually understands
reuse rather than memorising the synthetic generator.
"""
import os
# Windows: onnxruntime and sklearn/scipy ship their own OpenMP runtimes; loading
# scipy first makes onnxruntime's DLL init fail. Allow the duplicate and warm up
# onnxruntime BEFORE sklearn is imported.
os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
import json
import sys
import urllib.request
import numpy as np
import onnxruntime # noqa: F401 (import first to win the OpenMP race)
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
from plagdetect import semantic # noqa: E402
semantic._load() # warm up before sklearn
from plagdetect.siamese import SiameseEncoder # noqa: E402
from plagdetect.textutils import sentences, synonymize # noqa: E402
from sklearn.metrics import roc_auc_score # noqa: E402
PLOS = os.path.join(ROOT, "data", "oa_corpus", "plos_complex_systems.jsonl")
PAWS_URL = ("https://raw.githubusercontent.com/google-research-datasets/"
"paws/master/paws_wiki_labeled_final_dev_head.tsv") # may 404; we fallback
def _word_dropout(text, rng, p=0.15):
w = text.split()
kept = [x for x in w if rng.rand() > p]
return " ".join(kept if len(kept) > 3 else w)
def _embed_lookup(pairs, embed_fn):
"""Embed every unique sentence ONCE, return {sentence: vector}."""
uniq = list(dict.fromkeys([s for ab in pairs for s in ab]))
V = embed_fn(uniq)
return {s: V[i] for i, s in enumerate(uniq)}
def _pair_scores(pos, neg, embed_fn):
look = _embed_lookup(pos + neg, embed_fn)
sc = [float(look[a] @ look[b]) for a, b in pos] + \
[float(look[a] @ look[b]) for a, b in neg]
return sc
def build_plos_pairs(n=200, seed=1):
rng = np.random.RandomState(seed)
rows = [json.loads(l) for l in open(PLOS, encoding="utf-8")]
sents = []
for r in rows:
for fld in ("abstract", "fulltext"):
for s in sentences(r.get(fld, "")):
if 8 <= len(s.split()) <= 40:
sents.append(s)
sents = list(dict.fromkeys(sents)) # dedupe, keep order
rng.shuffle(sents)
pos, neg = [], []
for i in range(min(n, len(sents) - 1)):
s = sents[i]
atk = synonymize(s, rng, p=0.6) if rng.rand() < 0.5 else _word_dropout(s, rng)
pos.append((s, atk))
j = rng.randint(len(sents))
while sents[j][:30] == s[:30]:
j = rng.randint(len(sents))
neg.append((s, sents[j]))
return pos, neg
def try_load_paws(limit=400):
"""Best-effort fetch of a small real paraphrase benchmark. Returns
(pos_pairs, neg_pairs) or (None, None) if unavailable."""
candidates = [
"https://raw.githubusercontent.com/IBM/quality-controlled-paraphrase-generation/main/data/mrpc/msr_paraphrase_test.txt",
"https://raw.githubusercontent.com/wasiahmad/paraphrase_identification/master/dataset/msr-paraphrase-corpus/msr_paraphrase_test.txt",
]
for url in candidates:
try:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
raw = urllib.request.urlopen(req, timeout=15).read().decode("utf-8", "ignore")
except Exception:
continue
pos, neg = [], []
for ln in raw.splitlines()[1:]: # MRPC: Quality \t id \t id \t s1 \t s2
parts = ln.split("\t")
if len(parts) < 5:
continue
lab, s1, s2 = parts[0].strip(), parts[3].strip(), parts[4].strip()
(pos if lab == "1" else neg).append((s1, s2))
if len(pos) + len(neg) >= limit:
break
if pos and neg:
return pos[:limit // 2], neg[:limit // 2], url.split("/")[-1]
return None, None, None
def auc_on(pos, neg, scores):
y = [1] * len(pos) + [0] * len(neg)
return (roc_auc_score(y, scores), float(np.mean(scores[:len(pos)])),
float(np.mean(scores[len(pos):])))
def report(name, pos, neg, enc):
print(f"\n=== {name} (pos={len(pos)} neg={len(neg)}) ===")
s_sc = _pair_scores(pos, neg, enc.embed_texts)
m_raw = _pair_scores(pos, neg, semantic.embed)
# map MiniLM cosine onto the siamese/lexical threshold scale, then ENSEMBLE
# by max: verbatim reuse -> siamese high, paraphrase -> MiniLM high.
m_scaled = semantic.to_lexical_scale(m_raw).tolist()
ens = [max(s, m) for s, m in zip(s_sc, m_scaled)]
a_s, p_s, n_s = auc_on(pos, neg, s_sc)
a_m, p_m, n_m = auc_on(pos, neg, m_raw)
a_e, p_e, n_e = auc_on(pos, neg, ens)
print(f" Siamese (synthetic char-trigram) : AUC={a_s:.3f} "
f"pos_sim={p_s:.3f} neg_sim={n_s:.3f}")
print(f" MiniLM (pretrained, real) : AUC={a_m:.3f} "
f"pos_sim={p_m:.3f} neg_sim={n_m:.3f}")
print(f" ENSEMBLE max(siamese, MiniLM) : AUC={a_e:.3f} "
f"pos_sim={p_e:.3f} neg_sim={n_e:.3f} <-- proposed")
return a_s, a_m, a_e
def main():
enc = SiameseEncoder.load(os.path.join(ROOT, "models", "siamese.npz"))
if not semantic.available():
print("MiniLM unavailable — cannot run."); return
pos, neg = build_plos_pairs()
s1, m1, e1 = report("A) PLOS real academic reuse (favours Siamese: its train transforms)",
pos, neg, enc)
ppos, pneg, src = try_load_paws()
if ppos:
s2, m2, e2 = report(f"B) {src} real human paraphrase benchmark (semantic-vs-surface)",
ppos, pneg, enc)
else:
print("\n=== B) real paraphrase benchmark: download unavailable, skipped ===")
s2 = m2 = e2 = None
print("\n" + "=" * 70)
print("VERDICT (AUC: Siamese | MiniLM | Ensemble-max)")
print(f" PLOS reuse domain : {s1:.3f} | {m1:.3f} | {e1:.3f}")
if s2 is not None:
print(f" Real paraphrase : {s2:.3f} | {m2:.3f} | {e2:.3f}")
print(" Ship the ensemble if it is >= both on each row (no regression, adds")
print(" MiniLM's real semantic channel without losing verbatim sensitivity).")
if __name__ == "__main__":
main()