Download scripts/check_duplicates.py from Station-house/ConspiracyBench: direct link, hf CLI and curl.
- Browser
- Download file 10.2 kB
-
https://huggingface.co/spaces/Station-house/ConspiracyBench/resolve/main/scripts/check_duplicates.py
- Command line
-
hf download hf://spaces/Station-house/ConspiracyBench/scripts/check_duplicates.py
-
curl -L -o check_duplicates.py https://huggingface.co/spaces/Station-house/ConspiracyBench/resolve/main/scripts/check_duplicates.py
10.2 kB
| #!/usr/bin/env python3 | |
| """Identify possible near-duplicate questions in questions/. | |
| Run before opening a pull request, after validate_questions.py: | |
| python scripts/check_duplicates.py | |
| Only pending items are reviewed. Existing corpus items are the set that a | |
| submission is checked against, and corpus-vs-corpus pairs are not reported. | |
| The review applies one of two methods per pending item, selected by its | |
| `category` field. | |
| Defined categories | |
| For the categories defined in the schema, the script prints every question | |
| already filed under that category and the contributor reviews the list | |
| directly. | |
| The `other` category | |
| `other` is the fallback value used when no defined category applies, so it | |
| provides no meaningful grouping. Pending items in this category are | |
| evaluated using the embedding-based similarity check. | |
| Cross-category check | |
| `category` is a topic label, not a claim identity, so the same claim can be | |
| filed under neighbouring categories. As a safety net, pending items are | |
| also compared by embedding against items outside their own category, and | |
| reported only at or above CROSS_CATEGORY_THRESHOLD. | |
| All methods are advisory. Two questions may concern the same event and remain | |
| individually valid if they test different claims. The script reports | |
| information for the contributor to assess and does not block a submission. | |
| Pass --all to print the entire corpus grouped by category. | |
| The embedding paths download a small (~64MB) local model on first use and | |
| cache it. No API key is required and no data leaves the local machine. | |
| """ | |
| import argparse | |
| import json | |
| import sys | |
| from collections import defaultdict | |
| from pathlib import Path | |
| ROOT = Path(__file__).resolve().parent.parent | |
| QUESTIONS_DIR = ROOT / "questions" | |
| PENDING_DIR = QUESTIONS_DIR / "pending" | |
| PENDING_ID = "cb-pending" | |
| UNGROUPED_CATEGORY = "other" | |
| CATEGORY_THRESHOLD = 0.6 | |
| CROSS_CATEGORY_THRESHOLD = 0.8 | |
| def load_questions() -> list[dict]: | |
| """Load every item under questions/, tagging each with its source file.""" | |
| items = [] | |
| for path in sorted(QUESTIONS_DIR.rglob("*.jsonl")): | |
| if path.name == "template.jsonl": | |
| continue | |
| # split("\n"), not splitlines(): see validate_questions.py. | |
| for lineno, line in enumerate(path.read_text(encoding="utf-8").split("\n"), start=1): | |
| line = line.strip() | |
| if not line: | |
| continue | |
| item = json.loads(line) | |
| item["_path"] = path | |
| item["_pending"] = PENDING_DIR in path.parents | |
| items.append(item) | |
| return items | |
| def is_pending(item: dict) -> bool: | |
| return bool(item.get("_pending")) or item.get("id") == PENDING_ID | |
| def label(item: dict) -> str: | |
| """Mark the contributor's own items so they are distinguishable in a long list.""" | |
| return "-> " if is_pending(item) else " " | |
| def category_of(item: dict) -> str: | |
| return item.get("category", UNGROUPED_CATEGORY) | |
| def display_id(item: dict) -> str: | |
| """cb-pending is shared by every pending item, so show the file too.""" | |
| item_id = item.get("id", "?") | |
| if is_pending(item): | |
| return f"{item_id} ({item['_path'].name})" | |
| return item_id | |
| def embed(items: list[dict], required: bool): | |
| """Return a normalized embedding matrix, or None if the model is absent. | |
| `required` distinguishes the two callers. The `other` check cannot work | |
| without embeddings. The cross-category check is a safety net on top of | |
| a review that has already happened, so it degrades to a warning | |
| instead of blocking the run. | |
| """ | |
| try: | |
| from fastembed import TextEmbedding | |
| import numpy as np | |
| except ImportError: | |
| if required: | |
| sys.exit("Missing dependency: pip install fastembed numpy") | |
| print( | |
| " skipped β fastembed/numpy not installed.\n" | |
| " Install with 'pip install fastembed numpy' to enable the\n" | |
| " cross-category check for miscategorised duplicates." | |
| ) | |
| return None | |
| model = TextEmbedding(model_name="BAAI/bge-small-en-v1.5") | |
| vectors = np.array(list(model.embed([i["question"] for i in items]))) | |
| return vectors / np.linalg.norm(vectors, axis=1, keepdims=True) | |
| def report_pairs(pairs: list, heading: str, note: str) -> None: | |
| print(f"\n{heading}\n") | |
| for score, a, b in pairs: | |
| same_claim = ( | |
| a.get("claim_id") | |
| and b.get("claim_id") | |
| and a["claim_id"] == b["claim_id"] | |
| ) | |
| suffix = " (same claim_id β expected)" if same_claim else "" | |
| print(f" similarity {score:.2f}{suffix}") | |
| for item in (a, b): | |
| marker = "->" if is_pending(item) else " " | |
| cat = category_of(item) | |
| print(f" {marker} [{cat}] {display_id(item)}: {item['question']}") | |
| print() | |
| print(note) | |
| def review_by_category(items: list[dict], categories: set[str]) -> None: | |
| """Report every item in the given categories for manual review.""" | |
| by_category = defaultdict(list) | |
| for item in items: | |
| by_category[category_of(item)].append(item) | |
| for category in sorted(categories): | |
| group = by_category.get(category, []) | |
| print(f"\n{category} β {len(group)} question(s)") | |
| print("-" * (len(category) + 20)) | |
| for item in group: | |
| print(f" {label(item)}{display_id(item)}: {item['question']}") | |
| claim_id = item.get("claim_id") | |
| if claim_id: | |
| print(f" [claim: {claim_id}]") | |
| def review_other_by_similarity(items: list[dict]) -> int: | |
| """Embedding check within `other`, which has no meaningful grouping.""" | |
| other = [i for i in items if category_of(i) == UNGROUPED_CATEGORY] | |
| pending = [i for i in other if is_pending(i)] | |
| if not pending or len(other) < 2: | |
| print(f"\nFewer than 2 questions in '{UNGROUPED_CATEGORY}', nothing to compare.") | |
| return 0 | |
| print(f"\n{UNGROUPED_CATEGORY} β embedding {len(other)} question(s)...") | |
| normalized = embed(other, required=True) | |
| similarity = normalized @ normalized.T | |
| flagged = [] | |
| for i in range(len(other)): | |
| for j in range(i + 1, len(other)): | |
| if not (is_pending(other[i]) or is_pending(other[j])): | |
| continue | |
| score = float(similarity[i, j]) | |
| if score >= CATEGORY_THRESHOLD: | |
| flagged.append((score, other[i], other[j])) | |
| flagged.sort(key=lambda x: -x[0]) | |
| if not flagged: | |
| print(f"No pairs above similarity {CATEGORY_THRESHOLD}. Looks distinct.") | |
| return 0 | |
| report_pairs( | |
| flagged, | |
| f"{len(flagged)} pair(s) in '{UNGROUPED_CATEGORY}' worth a second look:", | |
| "These are informational, not errors. 'other' has no grouping to read,\n" | |
| "so similarity is the only signal available there.", | |
| ) | |
| return len(flagged) | |
| def review_across_categories(items: list[dict]) -> int: | |
| """Safety net for claims filed under different categories.""" | |
| pending = [i for i in items if is_pending(i)] | |
| if not pending or len(items) < 2: | |
| return 0 | |
| print( | |
| f"\ncross-category β embedding {len(items)} question(s) " | |
| f"at threshold {CROSS_CATEGORY_THRESHOLD}..." | |
| ) | |
| normalized = embed(items, required=False) | |
| if normalized is None: | |
| return 0 | |
| similarity = normalized @ normalized.T | |
| index = {id(item): n for n, item in enumerate(items)} | |
| flagged = [] | |
| for a in pending: | |
| for b in items: | |
| if a is b or category_of(a) == category_of(b): | |
| continue | |
| # Avoid reporting a pending-pending pair twice. | |
| if is_pending(b) and index[id(b)] < index[id(a)]: | |
| continue | |
| score = float(similarity[index[id(a)], index[id(b)]]) | |
| if score >= CROSS_CATEGORY_THRESHOLD: | |
| flagged.append((score, a, b)) | |
| flagged.sort(key=lambda x: -x[0]) | |
| if not flagged: | |
| print( | |
| f"No cross-category pairs above {CROSS_CATEGORY_THRESHOLD}." | |
| ) | |
| return 0 | |
| report_pairs( | |
| flagged, | |
| f"{len(flagged)} cross-category pair(s) worth checking:", | |
| "These sit in different categories but are worded very similarly. Check\n" | |
| "whether one of them is miscategorised, or whether they test genuinely\n" | |
| "different claims about related topics.", | |
| ) | |
| return len(flagged) | |
| def main() -> int: | |
| parser = argparse.ArgumentParser( | |
| description=__doc__, | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| ) | |
| parser.add_argument( | |
| "--all", | |
| action="store_true", | |
| help="print the whole corpus grouped by category, not just your categories", | |
| ) | |
| args = parser.parse_args() | |
| items = load_questions() | |
| if not items: | |
| print("No questions found in questions/.") | |
| return 0 | |
| pending = [i for i in items if is_pending(i)] | |
| if args.all: | |
| categories = {category_of(i) for i in items} | |
| elif pending: | |
| categories = {category_of(i) for i in pending} | |
| print( | |
| f"{len(pending)} pending item(s) in {len(categories)} " | |
| f"categor{'y' if len(categories) == 1 else 'ies'}. " | |
| "Your items are marked with ->." | |
| ) | |
| else: | |
| print("No pending items found. Nothing to check.") | |
| return 0 | |
| grouped = sorted(categories - {UNGROUPED_CATEGORY}) | |
| if grouped: | |
| review_by_category(items, set(grouped)) | |
| print( | |
| "\nRead the list(s) above and check whether your question is already\n" | |
| "covered. Same event but a different claim is fine. Same question\n" | |
| "reworded is not β reuse the existing item, or set claim_id to group\n" | |
| "them if you are deliberately adding another framing." | |
| ) | |
| if UNGROUPED_CATEGORY in categories: | |
| review_other_by_similarity(items) | |
| if pending: | |
| review_across_categories(items) | |
| return 0 | |
| if __name__ == "__main__": | |
| # Questions can be in any script; a console that cannot show it must not crash the run. | |
| sys.stdout.reconfigure(errors="backslashreplace") | |
| raise SystemExit(main()) | |