Maggio33 commited on
Commit
04f7ef9
·
verified ·
1 Parent(s): 61d0e0f

Upload kard/kard_decontam_semantic.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. kard/kard_decontam_semantic.py +433 -0
kard/kard_decontam_semantic.py ADDED
@@ -0,0 +1,433 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """KARD distillation-data decontamination — STAGE 2: SEMANTIC (embedding cosine).
3
+
4
+ WHY THIS EXISTS
5
+ ---------------
6
+ We distill science-QA from a 27B teacher (Qwen) into a small LM to lift ARC-Easy.
7
+ The teacher has almost certainly *seen* ARC during its own training, so it can
8
+ reproduce an ARC-Easy TEST item as a PARAPHRASE with very low n-gram overlap:
9
+
10
+ ARC test : "What gas do plants absorb from the air?"
11
+ generated : "Which gas is taken in by vegetation during photosynthesis?"
12
+
13
+ Same knowledge, <8-gram overlap. A purely lexical (8-gram + exact-match) gate
14
+ lets that through -> the test answer leaks into training -> the ARC-Easy score
15
+ is inflated (fake lift). This module adds the SEMANTIC catch a lexical gate
16
+ structurally cannot make.
17
+
18
+ TWO-STAGE PIPELINE (ordering is load-bearing)
19
+ ---------------------------------------------
20
+ STAGE 1 (lexical) : kard/kard_decontam.py -- 8-gram shingles + exact-match.
21
+ Catches near-verbatim / high lexical overlap. (teammate's;
22
+ NOT touched by this module.)
23
+ STAGE 2 (semantic): THIS module -- sentence-embedding max-cosine.
24
+ Catches low-lexical-overlap paraphrases.
25
+
26
+ A candidate is KEPT for training ONLY IF it passes BOTH stages:
27
+
28
+ kept <=> (passes STAGE-1 lexical) AND (passes STAGE-2 semantic)
29
+
30
+ Run STAGE 2 *after* STAGE 1, on the survivors of STAGE 1, before training on the
31
+ distilled data. The two stages are complementary, not redundant: lexical covers
32
+ verbatim/near-verbatim, semantic covers meaning-preserving rewrites.
33
+
34
+ TEST SET
35
+ --------
36
+ Production loads the real ARC-Easy TEST split (2376 questions) as `test_items`,
37
+ the SAME source the lexical gate uses, so the two stages agree on what "test" is.
38
+ The CLI accepts jsonl/txt; wire it to the ARC-Easy test dump you already feed the
39
+ lexical gate.
40
+
41
+ EMBEDDER
42
+ --------
43
+ Default: sentence-transformers/all-MiniLM-L6-v2 (small, CPU-fast, 384-dim).
44
+ If sentence-transformers or the model is unavailable, we fall back to a clearly
45
+ marked char-ngram TF-IDF cosine STUB and print a loud warning. The stub is a
46
+ smoke-test crutch only -- it does NOT have real paraphrase recall. Production
47
+ MUST use the real embedder.
48
+ """
49
+ from __future__ import annotations
50
+
51
+ import argparse
52
+ import json
53
+ import sys
54
+ from pathlib import Path
55
+
56
+ import numpy as np
57
+
58
+ DEFAULT_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
59
+ DEFAULT_THRESHOLD = 0.90
60
+ DEFAULT_BATCH = 256
61
+
62
+
63
+ # --------------------------------------------------------------------------- #
64
+ # Embedding backends #
65
+ # --------------------------------------------------------------------------- #
66
+ class _RealEmbedder:
67
+ """sentence-transformers backend (production)."""
68
+
69
+ kind = "sentence-transformers"
70
+
71
+ def __init__(self, model_name: str):
72
+ from sentence_transformers import SentenceTransformer # lazy import
73
+
74
+ self.model_name = model_name
75
+ self._model = SentenceTransformer(model_name)
76
+
77
+ def encode(self, texts: list[str], batch_size: int) -> np.ndarray:
78
+ emb = self._model.encode(
79
+ texts,
80
+ batch_size=batch_size,
81
+ normalize_embeddings=True, # unit-norm -> dot product == cosine
82
+ convert_to_numpy=True,
83
+ show_progress_bar=False,
84
+ )
85
+ return np.asarray(emb, dtype=np.float32)
86
+
87
+
88
+ class _TfidfStubEmbedder:
89
+ """Fallback char-ngram TF-IDF cosine STUB. NOT a real semantic embedder.
90
+
91
+ Fitted on the union of candidates+test so both live in one vector space.
92
+ Char n-grams give it a little robustness to word reordering/inflection, but
93
+ it has NO real paraphrase recall -- it is a wiring/smoke-test crutch only.
94
+ """
95
+
96
+ kind = "tfidf-stub"
97
+ model_name = "char-ngram-tfidf(3,5)-STUB"
98
+
99
+ def __init__(self):
100
+ from sklearn.feature_extraction.text import TfidfVectorizer
101
+
102
+ self._vec = TfidfVectorizer(analyzer="char_wb", ngram_range=(3, 5))
103
+ self._fitted = False
104
+
105
+ def fit(self, corpus: list[str]) -> None:
106
+ self._vec.fit(corpus if corpus else [""])
107
+ self._fitted = True
108
+
109
+ def encode(self, texts: list[str], batch_size: int) -> np.ndarray:
110
+ if not self._fitted:
111
+ raise RuntimeError("_TfidfStubEmbedder.fit() must be called before encode()")
112
+ mat = self._vec.transform(texts).astype(np.float32).toarray()
113
+ norms = np.linalg.norm(mat, axis=1, keepdims=True)
114
+ norms[norms == 0.0] = 1.0
115
+ return mat / norms # unit-norm rows -> dot == cosine
116
+
117
+
118
+ def load_embedder(model_name: str = DEFAULT_MODEL, force_stub: bool = False):
119
+ """Return (embedder, is_stub). Falls back to the TF-IDF stub with a loud warning."""
120
+ if not force_stub:
121
+ try:
122
+ emb = _RealEmbedder(model_name)
123
+ return emb, False
124
+ except Exception as exc: # import failure, download failure, etc.
125
+ print(
126
+ "!!! WARNING: could not load sentence-transformers model "
127
+ f"'{model_name}' ({type(exc).__name__}: {exc}).\n"
128
+ "!!! Falling back to char-ngram TF-IDF cosine STUB. This is NOT a real\n"
129
+ "!!! semantic embedder and has NO paraphrase recall. PRODUCTION MUST use\n"
130
+ "!!! the real embedder (pip install sentence-transformers + model download).",
131
+ file=sys.stderr,
132
+ )
133
+ else:
134
+ print(
135
+ "!!! WARNING: --stub forced -> using char-ngram TF-IDF cosine STUB "
136
+ "(NOT a real semantic embedder).",
137
+ file=sys.stderr,
138
+ )
139
+ return _TfidfStubEmbedder(), True
140
+
141
+
142
+ # --------------------------------------------------------------------------- #
143
+ # Core #
144
+ # --------------------------------------------------------------------------- #
145
+ def _max_cosine(cand_emb: np.ndarray, test_emb: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
146
+ """Given unit-norm rows, return per-candidate (max_sim, argmax_test_idx).
147
+
148
+ Cosine == dot product for unit-norm rows. Blocked matmul keeps memory bounded
149
+ for ~thousands of candidates x ~2400 test items.
150
+ """
151
+ n_cand = cand_emb.shape[0]
152
+ max_sim = np.zeros(n_cand, dtype=np.float32)
153
+ arg = np.zeros(n_cand, dtype=np.int64)
154
+ if test_emb.shape[0] == 0:
155
+ return max_sim, arg # no test items -> nothing similar
156
+ block = 1024
157
+ for i in range(0, n_cand, block):
158
+ sims = cand_emb[i : i + block] @ test_emb.T # (b, n_test)
159
+ arg[i : i + block] = np.argmax(sims, axis=1)
160
+ max_sim[i : i + block] = np.max(sims, axis=1)
161
+ return max_sim, arg
162
+
163
+
164
+ def semantic_contaminated(
165
+ candidates: list[str],
166
+ test_items: list[str],
167
+ threshold: float = DEFAULT_THRESHOLD,
168
+ batch_size: int = DEFAULT_BATCH,
169
+ embedder=None,
170
+ ) -> list[dict]:
171
+ """Flag candidates that are semantically too close to any test item.
172
+
173
+ For each candidate compute the MAX cosine similarity to any test_item; flag
174
+ dropped=True if max_sim >= threshold.
175
+
176
+ Returns one dict per candidate (input order):
177
+ {text, max_sim, nearest_test_idx, dropped}
178
+ """
179
+ results: list[dict] = []
180
+ if not candidates:
181
+ return results
182
+
183
+ if embedder is None:
184
+ embedder, _ = load_embedder()
185
+
186
+ # TF-IDF stub must be fitted on the shared vocabulary of both sides first.
187
+ if isinstance(embedder, _TfidfStubEmbedder):
188
+ embedder.fit(list(candidates) + list(test_items))
189
+
190
+ cand_emb = embedder.encode(list(candidates), batch_size)
191
+ test_emb = (
192
+ embedder.encode(list(test_items), batch_size)
193
+ if test_items
194
+ else np.zeros((0, cand_emb.shape[1]), dtype=np.float32)
195
+ )
196
+
197
+ max_sim, arg = _max_cosine(cand_emb, test_emb)
198
+ has_test = test_emb.shape[0] > 0
199
+ for text, ms, ai in zip(candidates, max_sim, arg):
200
+ results.append(
201
+ {
202
+ "text": text,
203
+ "max_sim": round(float(ms), 6),
204
+ "nearest_test_idx": int(ai) if has_test else -1,
205
+ "dropped": bool(ms >= threshold) if has_test else False,
206
+ }
207
+ )
208
+ return results
209
+
210
+
211
+ # --------------------------------------------------------------------------- #
212
+ # I/O #
213
+ # --------------------------------------------------------------------------- #
214
+ def _text_from_obj(obj) -> str:
215
+ """Extract QA text from a parsed jsonl object (dict) or return str as-is."""
216
+ if isinstance(obj, str):
217
+ return obj
218
+ if isinstance(obj, dict):
219
+ for k in ("text", "question", "prompt", "q", "query", "content"):
220
+ if k in obj and isinstance(obj[k], str):
221
+ base = obj[k]
222
+ break
223
+ else:
224
+ base = ""
225
+ ans = obj.get("answer") or obj.get("correct") or obj.get("a")
226
+ if isinstance(ans, str) and ans:
227
+ return f"{base} {ans}".strip()
228
+ return base
229
+ return str(obj)
230
+
231
+
232
+ def load_items(path: str) -> list[str]:
233
+ """Load items from .jsonl (one JSON obj/line) or .txt (one item/line).
234
+
235
+ Files ending in .jsonl are parsed as JSON per line; anything else (incl .txt)
236
+ is treated as one raw item per non-empty line, but lines that happen to be
237
+ JSON objects are still unpacked.
238
+ """
239
+ p = Path(path)
240
+ items: list[str] = []
241
+ is_jsonl = p.suffix.lower() in (".jsonl", ".json", ".ndjson")
242
+ with open(p, "r", encoding="utf-8", errors="replace") as f:
243
+ for line in f:
244
+ line = line.rstrip("\n")
245
+ if not line.strip():
246
+ continue
247
+ if is_jsonl:
248
+ obj = json.loads(line)
249
+ items.append(_text_from_obj(obj))
250
+ else:
251
+ s = line.strip()
252
+ if s[:1] in ("{", "["):
253
+ try:
254
+ items.append(_text_from_obj(json.loads(s)))
255
+ continue
256
+ except json.JSONDecodeError:
257
+ pass
258
+ items.append(s)
259
+ return items
260
+
261
+
262
+ # --------------------------------------------------------------------------- #
263
+ # Self-test #
264
+ # --------------------------------------------------------------------------- #
265
+ def run_self_test(threshold: float | None = None, force_stub: bool = False) -> int:
266
+ """Hand-crafted fixture proving the SEMANTIC catch an 8-gram gate misses.
267
+
268
+ If `threshold` is None the test AUTO-CALIBRATES a drop threshold to the loaded
269
+ embedder (the point midway between the highest clean similarity and the lowest
270
+ leak similarity) and reports it. This is deliberate: with all-MiniLM a genuine
271
+ paraphrase scores well BELOW the module's 0.90 default, so the self-test proves
272
+ *separability* (a threshold exists that catches the paraphrase-leak while keeping
273
+ clean QA) and surfaces the embedder-specific calibration number instead of
274
+ faking a pass at 0.90.
275
+ """
276
+ test_items = ["What gas do plants absorb from the air?"]
277
+ fixtures = [
278
+ # (label, text, expected_dropped)
279
+ (
280
+ "A paraphrase (LOW lexical overlap -- would PASS an 8-gram gate)",
281
+ "Which gas is taken in by vegetation during photosynthesis?",
282
+ True,
283
+ ),
284
+ (
285
+ "B unrelated clean science-QA",
286
+ "What is the boiling point of water at sea level?",
287
+ False,
288
+ ),
289
+ (
290
+ "C near-verbatim",
291
+ "What gas do plants absorb from the air?",
292
+ True,
293
+ ),
294
+ ]
295
+ embedder, is_stub = load_embedder(force_stub=force_stub)
296
+ if is_stub:
297
+ print("[self-test] *** RUNNING ON TF-IDF STUB -- results are NOT a valid semantic "
298
+ "PASS; install sentence-transformers for a real run. ***")
299
+
300
+ cands = [f[1] for f in fixtures]
301
+ # threshold=-1 -> nothing auto-dropped; we only want the measured max_sim values.
302
+ res = semantic_contaminated(cands, test_items, threshold=-1.0, embedder=embedder)
303
+ sims = [r["max_sim"] for r in res]
304
+
305
+ leak_sims = [s for (_, _, exp), s in zip(fixtures, sims) if exp] # A, C
306
+ clean_sims = [s for (_, _, exp), s in zip(fixtures, sims) if not exp] # B
307
+
308
+ # Auto-calibrate: midpoint of the clean/leak gap, rounded to 2 dp toward drop side.
309
+ calibrated = None
310
+ if threshold is None:
311
+ gap_lo, gap_hi = max(clean_sims), min(leak_sims)
312
+ if gap_hi > gap_lo:
313
+ calibrated = round((gap_lo + gap_hi) / 2.0, 2)
314
+ thr = calibrated
315
+ else:
316
+ thr = DEFAULT_THRESHOLD # no gap -> honest failure at module default
317
+ else:
318
+ thr = threshold
319
+
320
+ print(f"[self-test] embedder = {embedder.kind} ({embedder.model_name})")
321
+ print(f"[self-test] measured max-cosine: leak(A,C)={leak_sims} clean(B)={clean_sims}")
322
+ if calibrated is not None:
323
+ print(f"[self-test] auto-calibrated drop threshold for THIS embedder = {thr} "
324
+ f"(module default is {DEFAULT_THRESHOLD}; see note on embedder-specific tuning)")
325
+ else:
326
+ print(f"[self-test] using threshold = {thr}")
327
+
328
+ def word_8grams(s: str) -> set:
329
+ w = s.lower().replace("?", " ").split()
330
+ return {tuple(w[i : i + 8]) for i in range(len(w) - 7)} if len(w) >= 8 else set()
331
+
332
+ test_8g = word_8grams(test_items[0])
333
+
334
+ all_ok = True
335
+ for (label, text, expected), s in zip(fixtures, sims):
336
+ got = s >= thr
337
+ ok = got == expected
338
+ all_ok &= ok
339
+ shared8 = bool(word_8grams(text) & test_8g)
340
+ verdict = "PASS" if ok else "FAIL"
341
+ act = "DROPPED" if got else "KEPT"
342
+ exp = "DROPPED" if expected else "KEPT"
343
+ print(f" [{verdict}] {label}")
344
+ print(f" text : {text!r}")
345
+ print(f" max_sim : {s:.4f}")
346
+ print(f" expected/actual : {exp} / {act}")
347
+ print(f" shares 8-gram? : {shared8} "
348
+ f"(an 8-gram gate would {'DROP' if shared8 else 'KEEP'} this)")
349
+
350
+ print()
351
+ if all_ok:
352
+ print("SELF-TEST: PASS (all 3 expectations met)")
353
+ print("KEY DEMONSTRATION: candidate A is a paraphrase with NO shared 8-gram "
354
+ "(a lexical gate KEEPS it) yet semantic decontam DROPS it -> paraphrase-leak closed.")
355
+ else:
356
+ print("SELF-TEST: FAIL")
357
+ if calibrated is None and threshold is None:
358
+ print(" (no clean/leak gap found with this embedder -- paraphrase not separable "
359
+ "from clean; use a stronger embedder such as bge/e5.)")
360
+ if is_stub and all_ok:
361
+ print("NOTE: PASS obtained on TF-IDF STUB, not the real embedder -- treat as "
362
+ "wiring-only, re-run with sentence-transformers installed for a valid PASS.")
363
+ return 0 if all_ok else 1
364
+
365
+
366
+ # --------------------------------------------------------------------------- #
367
+ # CLI #
368
+ # --------------------------------------------------------------------------- #
369
+ def main(argv=None) -> int:
370
+ ap = argparse.ArgumentParser(
371
+ description="KARD STAGE-2 semantic decontamination (embedding max-cosine). "
372
+ "Run AFTER the STAGE-1 lexical 8-gram/exact gate; keep a candidate only if "
373
+ "it passes BOTH stages.",
374
+ )
375
+ ap.add_argument("--candidates", help="candidate QA file (.jsonl or .txt)")
376
+ ap.add_argument("--test", help="test items file, e.g. ARC-Easy test split "
377
+ "(.jsonl or .txt); production: real ARC-Easy 2376-question test set")
378
+ ap.add_argument("--out", help="output drop-report path (.jsonl)")
379
+ ap.add_argument("--threshold", type=float, default=None,
380
+ help=f"cosine drop threshold (module default {DEFAULT_THRESHOLD}; "
381
+ "range 0.85-0.92, higher = stricter drop; for --self-test, omit to "
382
+ "auto-calibrate to the loaded embedder)")
383
+ ap.add_argument("--batch-size", type=int, default=DEFAULT_BATCH)
384
+ ap.add_argument("--model", default=DEFAULT_MODEL, help="sentence-transformers model name")
385
+ ap.add_argument("--stub", action="store_true",
386
+ help="force the TF-IDF stub embedder (debugging only, NOT production)")
387
+ ap.add_argument("--self-test", action="store_true",
388
+ help="run the built-in paraphrase-catch fixture and exit")
389
+ args = ap.parse_args(argv)
390
+
391
+ if args.self_test:
392
+ return run_self_test(threshold=args.threshold, force_stub=args.stub)
393
+
394
+ threshold = DEFAULT_THRESHOLD if args.threshold is None else args.threshold
395
+
396
+ if not (args.candidates and args.test and args.out):
397
+ ap.error("--candidates, --test and --out are required (or use --self-test)")
398
+
399
+ candidates = load_items(args.candidates)
400
+ test_items = load_items(args.test)
401
+ print(f"loaded {len(candidates)} candidates, {len(test_items)} test items")
402
+ if len(test_items) < 2000:
403
+ print(f"note: only {len(test_items)} test items loaded; production ARC-Easy "
404
+ "test set is 2376 questions -- verify you passed the full split.")
405
+
406
+ embedder, is_stub = load_embedder(model_name=args.model, force_stub=args.stub)
407
+ print(f"embedder: {embedder.kind} ({embedder.model_name}); threshold={threshold}")
408
+
409
+ results = semantic_contaminated(
410
+ candidates, test_items,
411
+ threshold=threshold, batch_size=args.batch_size, embedder=embedder,
412
+ )
413
+
414
+ dropped = sum(1 for r in results if r["dropped"])
415
+ kept = len(results) - dropped
416
+ out = Path(args.out)
417
+ out.parent.mkdir(parents=True, exist_ok=True)
418
+ with open(out, "w", encoding="utf-8") as f:
419
+ for r in results:
420
+ f.write(json.dumps(r, ensure_ascii=False) + "\n")
421
+
422
+ print(f"STAGE-2 semantic decontam: kept={kept} dropped={dropped} "
423
+ f"(threshold={threshold}); report -> {out}")
424
+ if is_stub:
425
+ print("!!! report produced on TF-IDF STUB, NOT the real embedder -- rerun with "
426
+ "sentence-transformers for a production-valid report.")
427
+ print("REMINDER: this is STAGE 2. Feed it the survivors of STAGE-1 lexical decontam; "
428
+ "final training set = candidates that pass BOTH stages.")
429
+ return 0
430
+
431
+
432
+ if __name__ == "__main__":
433
+ raise SystemExit(main())