kink-discovery / scripts /merge_dedup_eval.py
Perplexed7675's picture
Sync from kink_cli (Docker Space)
2496081 verified
Raw
History Blame Contribute Delete
12.2 kB
"""Evaluate kink-name merge techniques on a hand-labeled ground-truth set.
Uses OTS tooling — no hand-rolled stemmer:
- **spaCy** (`en_core_web_sm`) for proper lemmatization + POS-aware token filtering
- **rapidfuzz** for normalized-edit-distance fuzzy matching (catches typos, doubled letters)
- **sentence-transformers** (`all-MiniLM-L6-v2`) for semantic paraphrase scoring (lazy-loaded;
skip via ``--no-embeddings`` if you only want the deterministic passes)
Each technique produces a verdict per pair (merge / no-merge); we report precision, recall,
F1 against the labeled set so you can see which combination of signals generalizes.
Run:
.venv/bin/python scripts/merge_dedup_eval.py
.venv/bin/python scripts/merge_dedup_eval.py --no-embeddings # skip the SBERT pass
"""
from __future__ import annotations
import argparse
import json
import sqlite3
import sys
from collections import defaultdict
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO))
from backend.kink_merge import merge_signature as merge_signature_v1 # noqa: E402
GROUND_TRUTH_PATH = REPO / "tests" / "data" / "merge_dedup_ground_truth.json"
# ---------------------------------------------------------------------------
# OTS library wrappers — module-level so the eval shows clearly which signal
# each technique uses. None of these are hand-rolled.
# ---------------------------------------------------------------------------
_NLP = None
_SBERT = None
def _spacy() -> "spacy.language.Language":
global _NLP
if _NLP is None:
import spacy
# Disable parser/NER — we only need the tagger/lemmatizer.
_NLP = spacy.load("en_core_web_sm", disable=["parser", "ner"])
return _NLP
def _sbert():
global _SBERT
if _SBERT is None:
from sentence_transformers import SentenceTransformer
_SBERT = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
return _SBERT
# Spacy POS tags whose tokens carry kink-identity meaning. We drop DET / ADP / AUX / PRON / etc
# automatically — those are spaCy's stopword-equivalent classes.
_KEEP_POS = frozenset({"NOUN", "PROPN", "VERB", "ADJ", "ADV", "NUM", "X"})
def lemma_signature(name: str) -> str:
"""spaCy-lemmatized signature: {sorted unique lemmas of content tokens}."""
base = merge_signature_v1(name) # carry over FFM/MMF + threesome canonicalization
if not base:
return ""
doc = _spacy()(base)
lemmas = sorted({tok.lemma_.lower() for tok in doc if tok.pos_ in _KEEP_POS and tok.lemma_.strip()})
if not lemmas:
return ""
if len(lemmas) == 1 and len(lemmas[0]) < 5:
return "" # avoid degenerate single-letter collisions
return " ".join(lemmas)
def fuzzy_merge(a: str, b: str, *, threshold: int = 90) -> bool:
"""rapidfuzz token_set_ratio — catches typos / doubled letters / minor reorderings."""
from rapidfuzz import fuzz
return fuzz.token_set_ratio(a.lower(), b.lower()) >= threshold
def sbert_merge(a: str, b: str, *, threshold: float = 0.80) -> bool:
"""SBERT cosine similarity — catches paraphrases ('cuddling' ↔ 'snuggling')."""
from sentence_transformers import util
model = _sbert()
emb = model.encode([a, b], normalize_embeddings=True)
return float(util.cos_sim(emb[0], emb[1])) >= threshold
# ---------------------------------------------------------------------------
# Ground truth — pulled from real catalog names (verified against
# data/store_slim_b2.db) plus user-reported failures.
# ---------------------------------------------------------------------------
GROUND_TRUTH: list[dict] = [
# POSITIVE: morphological variants of single-word kinks
{"a": "Cuddling", "b": "Cuddles", "merge": True, "rationale": "real: fetlife_2275 + fetlife_590"},
{"a": "Kissing", "b": "Kisses", "merge": True, "rationale": "plural vs gerund"},
{"a": "Spanking", "b": "Spanks", "merge": True, "rationale": "gerund vs plural"},
{"a": "Biting", "b": "Bites", "merge": True, "rationale": "gerund vs plural"},
{"a": "Choking", "b": "Chokes", "merge": True, "rationale": "gerund vs verb"},
{"a": "Tickling", "b": "Tickles", "merge": True, "rationale": "gerund vs plural"},
{"a": "Licking", "b": "Licks", "merge": True, "rationale": "gerund vs plural"},
# POSITIVE: word-order permutations (v1 already catches)
{"a": "Cum Swallowing", "b": "Swallowing Cum", "merge": True, "rationale": "real: fetlife_19006 + fetlife_8045"},
{"a": "Hair Pulling", "b": "Pulling Hair", "merge": True, "rationale": "tokens equal"},
{"a": "Nipple Sucking", "b": "Sucking Nipples", "merge": True, "rationale": "morph + perm"},
# POSITIVE: morphology + word-order (the hard case)
{"a": "Cuddling after sex", "b": "After sex cuddles", "merge": True, "rationale": "user-reported"},
{"a": "Cuddling after sex", "b": "Cuddly after sex", "merge": True, "rationale": "user-reported"},
{"a": "After sex cuddles", "b": "Cuddly after sex", "merge": True, "rationale": "user-reported"},
{"a": "Spanking with a paddle", "b": "Paddle spanking", "merge": True, "rationale": "morph + perm + stopword"},
{"a": "Sucking on nipples", "b": "Nipple sucking", "merge": True, "rationale": "stopword + morph + perm"},
# POSITIVE: morphology siblings of action verbs
{"a": "Tease", "b": "Teasing", "merge": True, "rationale": "morph variants"},
{"a": "Tease", "b": "Teases", "merge": True, "rationale": "noun vs verb, same act"},
{"a": "MMF Threesome", "b": "FMM Threesomes", "merge": True, "rationale": "v1's gender-balance canonicalization"},
# NEGATIVE: same root, different concept
{"a": "Anal Sex", "b": "Anal Beads", "merge": False, "rationale": "act vs toy"},
{"a": "Cuddling", "b": "Cuddly Mogwai", "merge": False, "rationale": "specific named scenario"},
{"a": "Cum on Face", "b": "Cum on Tits", "merge": False, "rationale": "target body part is the kink"},
{"a": "Choking", "b": "Chokers", "merge": False, "rationale": "act vs accessory"},
{"a": "Whipping", "b": "Whips", "merge": False, "rationale": "act vs toy"},
{"a": "Bondage", "b": "Bonding", "merge": False, "rationale": "different roots (bond vs bondage)"},
{"a": "Tease", "b": "Tearing", "merge": False, "rationale": "tear ≠ tease"},
{"a": "MMF Threesome", "b": "FFM Threesome", "merge": False, "rationale": "different gender balance"},
]
def save_ground_truth() -> None:
GROUND_TRUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
GROUND_TRUTH_PATH.write_text(json.dumps(GROUND_TRUTH, indent=2), encoding="utf-8")
# ---------------------------------------------------------------------------
# Technique evaluation harness.
# Each technique is a name + a "would these merge" predicate.
# ---------------------------------------------------------------------------
def eval_technique(name: str, predicate) -> dict:
tp = fp = fn = tn = 0
misses: list[tuple[dict, bool]] = []
for row in GROUND_TRUTH:
merged = predicate(row["a"], row["b"])
if row["merge"] and merged: tp += 1
elif row["merge"] and not merged:
fn += 1
misses.append((row, merged))
elif not row["merge"] and merged:
fp += 1
misses.append((row, merged))
else: tn += 1
n = len(GROUND_TRUTH)
precision = tp / (tp + fp) if (tp + fp) else 0.0
recall = tp / (tp + fn) if (tp + fn) else 0.0
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
return {
"name": name, "n": n,
"tp": tp, "fp": fp, "fn": fn, "tn": tn,
"accuracy": (tp + tn) / n,
"precision": precision, "recall": recall, "f1": f1,
"misses": misses,
}
def print_report(res: dict) -> None:
print(f"\n=== {res['name']} ===")
print(f" acc={res['accuracy']:.2%} P={res['precision']:.2f} R={res['recall']:.2f} F1={res['f1']:.2f} "
f"tp={res['tp']} fp={res['fp']} fn={res['fn']} tn={res['tn']}")
for row, merged in res["misses"][:8]:
kind = "FN" if row["merge"] and not merged else "FP"
print(f" {kind}: {row['a']!r}{row['b']!r} ({row['rationale']})")
def show_v2_finds_in_catalog(db_path: Path, *, limit: int = 12) -> None:
if not db_path.is_file():
print(f"\n(skip catalog scan — {db_path} not present)")
return
print(f"\n=== Catalog scan: {db_path} ===")
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
rows = conn.execute("SELECT id, name FROM kink WHERE name IS NOT NULL").fetchall()
conn.close()
print(f" rows: {len(rows)}")
v1_groups = defaultdict(list)
v2_groups = defaultdict(list)
for kid, name in rows:
v1_groups[merge_signature_v1(name)].append((kid, name))
v2_groups[lemma_signature(name)].append((kid, name))
new_merges = []
for sig, members in v2_groups.items():
if len(members) < 2:
continue
v1_sigs = {merge_signature_v1(name) for _kid, name in members}
if len(v1_sigs) == 1:
continue
new_merges.append((sig, members))
print(f" v1 buckets ≥2: {sum(1 for v in v1_groups.values() if len(v) >= 2)}")
print(f" spacy-lemma buckets ≥2: {sum(1 for v in v2_groups.values() if len(v) >= 2)}")
print(f" NEW merges (lemma-only): {len(new_merges)}")
new_merges.sort(key=lambda x: -len(x[1]))
print(f"\n --- top {min(limit, len(new_merges))} clusters lemma catches that v1 misses ---")
for sig, members in new_merges[:limit]:
names = sorted({n for _kid, n in members})
if len(names) < 2:
continue
print(f" sig={sig!r}")
for n in names[:5]:
print(f" • {n}")
if len(names) > 5:
print(f" … +{len(names) - 5} more")
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--no-embeddings", action="store_true",
help="Skip the slow SBERT pass; useful when iterating on the deterministic stages.")
p.add_argument("--save-truth", action="store_true",
help="Persist GROUND_TRUTH to tests/data/merge_dedup_ground_truth.json")
args = p.parse_args()
if args.save_truth:
save_ground_truth()
print(f"wrote {GROUND_TRUTH_PATH}")
print("Techniques (all OTS, no hand-rolled stemmers):")
print(" v1: backend.kink_merge.merge_signature — sorted unique tokens, FFM canonicalization")
print(" lemma: spaCy en_core_web_sm — POS-filtered + lemmatized signature equality")
print(" fuzzy: rapidfuzz.fuzz.token_set_ratio ≥ 90")
if not args.no_embeddings:
print(" semantic: sentence-transformers/all-MiniLM-L6-v2 — cosine ≥ 0.80")
techniques = [
("v1 token-set", lambda a, b: merge_signature_v1(a) == merge_signature_v1(b) and merge_signature_v1(a) != ""),
("spaCy lemma signature", lambda a, b: lemma_signature(a) == lemma_signature(b) and lemma_signature(a) != ""),
("rapidfuzz token_set_ratio≥90", lambda a, b: fuzzy_merge(a, b, threshold=90)),
# Combined: lemma OR fuzzy (OR pass — if EITHER thinks merge, merge)
("lemma OR fuzzy(≥90)", lambda a, b: (
(lemma_signature(a) == lemma_signature(b) and lemma_signature(a) != "")
or fuzzy_merge(a, b, threshold=90)
)),
]
if not args.no_embeddings:
techniques.append(
("lemma OR sbert(≥0.80)", lambda a, b: (
(lemma_signature(a) == lemma_signature(b) and lemma_signature(a) != "")
or sbert_merge(a, b, threshold=0.80)
)),
)
results = [eval_technique(name, fn) for name, fn in techniques]
for r in results:
print_report(r)
print("\n=== summary table ===")
print(f" {'technique':<35} acc P R F1")
for r in results:
print(f" {r['name']:<35} {r['accuracy']:.2%} {r['precision']:.2f} {r['recall']:.2f} {r['f1']:.2f}")
show_v2_finds_in_catalog(REPO / "data" / "store_slim_b2.db")
return 0
if __name__ == "__main__":
raise SystemExit(main())