mobiusguard-eval 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mobiusguard_eval/__init__.py +7 -0
- mobiusguard_eval/checkpoint.py +72 -0
- mobiusguard_eval/cli.py +317 -0
- mobiusguard_eval/dedup.py +106 -0
- mobiusguard_eval/detectors/__init__.py +110 -0
- mobiusguard_eval/detectors/hf.py +38 -0
- mobiusguard_eval/detectors/keyword.py +51 -0
- mobiusguard_eval/detectors/proxy.py +71 -0
- mobiusguard_eval/detectors/tfidf.py +74 -0
- mobiusguard_eval/fetch.py +63 -0
- mobiusguard_eval/items.py +84 -0
- mobiusguard_eval/latency.py +67 -0
- mobiusguard_eval/ledger.py +60 -0
- mobiusguard_eval/metrics.py +85 -0
- mobiusguard_eval/obfuscate.py +86 -0
- mobiusguard_eval/py.typed +0 -0
- mobiusguard_eval/redaction.py +638 -0
- mobiusguard_eval/run.py +306 -0
- mobiusguard_eval/sources.py +362 -0
- mobiusguard_eval/suite.py +154 -0
- mobiusguard_eval-0.1.0.dist-info/METADATA +62 -0
- mobiusguard_eval-0.1.0.dist-info/RECORD +26 -0
- mobiusguard_eval-0.1.0.dist-info/WHEEL +4 -0
- mobiusguard_eval-0.1.0.dist-info/entry_points.txt +2 -0
- mobiusguard_eval-0.1.0.dist-info/licenses/LICENSE +202 -0
- mobiusguard_eval-0.1.0.dist-info/licenses/NOTICE +9 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Checkpoints: save finished work as it happens, so a run resumes instead of restarting.
|
|
2
|
+
|
|
3
|
+
A checkpoint is an append-only JSONL file. Its first line is a *key*: everything that
|
|
4
|
+
decides the result (detector and model revision, the suite's item IDs, the patterns). The
|
|
5
|
+
other lines are finished units of work. Opening a checkpoint whose key differs sets the
|
|
6
|
+
old file aside (``.stale``) and starts afresh, so saved work is never reused for a
|
|
7
|
+
different question. A last line cut short by a crash is ignored, and its unit redone.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, cast
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def fingerprint(value: Any) -> str:
|
|
20
|
+
"""A stable hash of JSON-able data (keys sorted)."""
|
|
21
|
+
return hashlib.sha256(json.dumps(value, sort_keys=True, default=str).encode()).hexdigest()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Checkpoint:
|
|
25
|
+
def __init__(self, path: Path, key: dict[str, Any]) -> None:
|
|
26
|
+
self.path = path
|
|
27
|
+
self.key = key
|
|
28
|
+
self.key_hash = fingerprint(key)
|
|
29
|
+
self.units: dict[str, Any] = {}
|
|
30
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
if path.exists() and not self._load():
|
|
32
|
+
path.replace(path.with_suffix(path.suffix + ".stale"))
|
|
33
|
+
if not path.exists():
|
|
34
|
+
with path.open("w", encoding="utf-8") as f:
|
|
35
|
+
f.write(json.dumps({"checkpoint": self.key_hash, "key": key}) + "\n")
|
|
36
|
+
|
|
37
|
+
def _load(self) -> bool:
|
|
38
|
+
"""Read saved units; False when the file belongs to a different key."""
|
|
39
|
+
with self.path.open(encoding="utf-8") as f:
|
|
40
|
+
lines = f.read().splitlines()
|
|
41
|
+
try:
|
|
42
|
+
header = cast("dict[str, Any]", json.loads(lines[0])) if lines else {}
|
|
43
|
+
except json.JSONDecodeError:
|
|
44
|
+
return False
|
|
45
|
+
if header.get("checkpoint") != self.key_hash:
|
|
46
|
+
return False
|
|
47
|
+
for line in lines[1:]:
|
|
48
|
+
try:
|
|
49
|
+
row = json.loads(line)
|
|
50
|
+
except json.JSONDecodeError:
|
|
51
|
+
continue # cut short by a crash: that unit is done again
|
|
52
|
+
self.units[row["unit"]] = row["data"]
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
def __contains__(self, unit: str) -> bool:
|
|
56
|
+
return unit in self.units
|
|
57
|
+
|
|
58
|
+
def add(self, unit: str, data: Any) -> None:
|
|
59
|
+
"""Record a finished unit; it is on disk before this returns."""
|
|
60
|
+
with self.path.open("a", encoding="utf-8") as f:
|
|
61
|
+
f.write(json.dumps({"unit": unit, "data": data}, ensure_ascii=False) + "\n")
|
|
62
|
+
f.flush()
|
|
63
|
+
os.fsync(f.fileno())
|
|
64
|
+
self.units[unit] = data
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def write_atomic(path: Path, text: str) -> None:
|
|
68
|
+
"""Write a file so that readers see the old or the new content, never half of it."""
|
|
69
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
71
|
+
tmp.write_text(text, encoding="utf-8")
|
|
72
|
+
tmp.replace(path)
|
mobiusguard_eval/cli.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Command line: ``mobiusguard-eval <command>``.
|
|
2
|
+
|
|
3
|
+
sources list the ledger
|
|
4
|
+
build --out runs/suite fetch sources and build a suite
|
|
5
|
+
score --suite S --detector D --out runs/D [--device cuda]
|
|
6
|
+
report --suite S --runs runs/a runs/b --out runs/report
|
|
7
|
+
latency --detector D [--threads N] --out runs/latency-D.json [--suite S]
|
|
8
|
+
redaction --suite S --out runs/redaction [--per-pattern 200]
|
|
9
|
+
pipeline --out runs [--tag v0.1] [--device cuda] all of the above, in order
|
|
10
|
+
|
|
11
|
+
Every step saves its work as it goes and skips what is already done for the same inputs,
|
|
12
|
+
so running a command again resumes it. ``--force`` redoes a step from the start.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import importlib
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
import traceback
|
|
23
|
+
from collections.abc import Callable, Sequence
|
|
24
|
+
from datetime import UTC, datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from . import detectors
|
|
29
|
+
from .checkpoint import fingerprint, write_atomic
|
|
30
|
+
from .fetch import fetch
|
|
31
|
+
from .items import Item
|
|
32
|
+
from .suite import build, is_built, ledger_sources, load_suite
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def log(message: str) -> None:
|
|
36
|
+
print(f"[{datetime.now(UTC):%H:%M:%S}] {message}", file=sys.stderr, flush=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ------------------------------------------------------------------------------ steps
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def step_build(out: Path, source_ids: Sequence[str] | None = None, *, force: bool = False) -> None:
|
|
43
|
+
sources = ledger_sources(source_ids)
|
|
44
|
+
if not force and is_built(sources, out):
|
|
45
|
+
log(f"suite: {out} is up to date, skipping (--force to rebuild)")
|
|
46
|
+
return
|
|
47
|
+
loaded: dict[str, list[Item]] = {}
|
|
48
|
+
for s in sources:
|
|
49
|
+
loaded[s.id] = list(s.parse(fetch(s)))
|
|
50
|
+
log(f"suite: {s.id} {len(loaded[s.id]):,} items")
|
|
51
|
+
manifest = build(sources, loaded, out)
|
|
52
|
+
log(f"suite: {manifest['items']:,} items, dropped {manifest['dropped']}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def step_score(
|
|
56
|
+
suite: Path,
|
|
57
|
+
detector: str,
|
|
58
|
+
out: Path,
|
|
59
|
+
*,
|
|
60
|
+
device: str = "cpu",
|
|
61
|
+
batch_size: int = 32,
|
|
62
|
+
limit: int = 0,
|
|
63
|
+
force: bool = False,
|
|
64
|
+
) -> None:
|
|
65
|
+
from .run import score
|
|
66
|
+
|
|
67
|
+
items = load_suite(suite)
|
|
68
|
+
if limit:
|
|
69
|
+
items = items[:limit]
|
|
70
|
+
score(items, detectors.get(detector, device=device, batch_size=batch_size), out,
|
|
71
|
+
force=force, log=log) # fmt: skip
|
|
72
|
+
log(f"score: wrote {out}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def step_latency(
|
|
76
|
+
detector: str,
|
|
77
|
+
out: Path,
|
|
78
|
+
*,
|
|
79
|
+
suite: Path | None = None,
|
|
80
|
+
threads: int = 0,
|
|
81
|
+
iterations: int = 200,
|
|
82
|
+
force: bool = False,
|
|
83
|
+
) -> None:
|
|
84
|
+
from .latency import LENGTHS, measure
|
|
85
|
+
|
|
86
|
+
if threads:
|
|
87
|
+
importlib.import_module("torch").set_num_threads(threads)
|
|
88
|
+
det = detectors.get(detector)
|
|
89
|
+
config = {k: v for k, v in det.fingerprint().items() if k != "output"}
|
|
90
|
+
key: dict[str, Any] = {"config": config, "lengths": list(LENGTHS),
|
|
91
|
+
"iterations": iterations, "threads": threads} # fmt: skip
|
|
92
|
+
if detector == "tfidf":
|
|
93
|
+
if suite is None:
|
|
94
|
+
raise ValueError("tfidf latency needs --suite (it trains first)")
|
|
95
|
+
from .detectors.tfidf import _pipeline # pyright: ignore[reportPrivateUsage]
|
|
96
|
+
|
|
97
|
+
items = [it for it in load_suite(suite) if not it.family.startswith("obf:")]
|
|
98
|
+
key["suite"] = fingerprint([it.id for it in items])
|
|
99
|
+
if not force and _latency_done(out, key):
|
|
100
|
+
return
|
|
101
|
+
model = _pipeline().fit([it.text for it in items], [it.label for it in items])
|
|
102
|
+
|
|
103
|
+
def one(text: str) -> float:
|
|
104
|
+
return float(model.predict_proba([text])[0][1])
|
|
105
|
+
elif detector == "keyword":
|
|
106
|
+
from .detectors.keyword import KeywordDetector
|
|
107
|
+
|
|
108
|
+
one = KeywordDetector().score_text
|
|
109
|
+
elif detector == "mobiusguard-rules":
|
|
110
|
+
from .detectors.proxy import RulesDetector
|
|
111
|
+
|
|
112
|
+
one = RulesDetector().score_text
|
|
113
|
+
elif detector.endswith("-norm"):
|
|
114
|
+
from mobiusguard.normalize import views
|
|
115
|
+
|
|
116
|
+
from .detectors.proxy import NormalizedClassifier
|
|
117
|
+
|
|
118
|
+
if not isinstance(det, NormalizedClassifier):
|
|
119
|
+
raise TypeError(detector)
|
|
120
|
+
nc = det
|
|
121
|
+
|
|
122
|
+
def one(text: str) -> float: # every view is scored, as the detector does
|
|
123
|
+
return max(nc.score_texts([v.text for v in views(text)]))
|
|
124
|
+
else:
|
|
125
|
+
from .detectors.hf import HFClassifier
|
|
126
|
+
|
|
127
|
+
if not isinstance(det, HFClassifier):
|
|
128
|
+
raise TypeError(detector)
|
|
129
|
+
hf = det
|
|
130
|
+
|
|
131
|
+
def one(text: str) -> float:
|
|
132
|
+
return hf.score_texts([text])[0]
|
|
133
|
+
|
|
134
|
+
if detector != "tfidf" and not force and _latency_done(out, key):
|
|
135
|
+
return
|
|
136
|
+
result = measure(det, one, iterations=iterations)
|
|
137
|
+
result["key"] = fingerprint(key)
|
|
138
|
+
write_atomic(out, json.dumps(result, indent=2) + "\n")
|
|
139
|
+
log(f"latency: {detector} {json.dumps(result['tokens'])}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _latency_done(out: Path, key: dict[str, Any]) -> bool:
|
|
143
|
+
if out.exists() and json.loads(out.read_text()).get("key") == fingerprint(key):
|
|
144
|
+
log(f"latency: {out} is up to date, skipping (--force to redo)")
|
|
145
|
+
return True
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def step_redaction(suite: Path, out: Path, *, per_pattern: int = 200, force: bool = False) -> None:
|
|
150
|
+
from .redaction import evaluate
|
|
151
|
+
|
|
152
|
+
summary = evaluate(load_suite(suite), out, per_pattern=per_pattern, force=force, log=log)
|
|
153
|
+
log(f"redaction: {summary['texts']:,} texts; wrote {out}/redaction_report.md")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def step_report(suite: Path, runs: Sequence[Path], out: Path) -> None:
|
|
157
|
+
from .run import report
|
|
158
|
+
|
|
159
|
+
ledger_sources() # register sources so contamination marks resolve
|
|
160
|
+
report(load_suite(suite), runs, out)
|
|
161
|
+
log(f"report: wrote {out / 'report.md'}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------------------------------ pipeline
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def pipeline(
|
|
168
|
+
root: Path,
|
|
169
|
+
*,
|
|
170
|
+
tag: str = "v0.1",
|
|
171
|
+
names: Sequence[str] = detectors.NAMES,
|
|
172
|
+
device: str = "cpu",
|
|
173
|
+
batch_size: int = 32,
|
|
174
|
+
threads: int = 4,
|
|
175
|
+
per_pattern: int = 200,
|
|
176
|
+
latency: bool = True,
|
|
177
|
+
redaction: bool = True,
|
|
178
|
+
force: bool = False,
|
|
179
|
+
) -> int:
|
|
180
|
+
"""Every step in order. A failed step is reported and the rest still run; running the
|
|
181
|
+
pipeline again resumes from what was saved."""
|
|
182
|
+
suite = root / f"suite-{tag}"
|
|
183
|
+
scores = root / f"scores-{tag}"
|
|
184
|
+
steps: list[tuple[str, Callable[[], None]]] = [
|
|
185
|
+
("build", lambda: step_build(suite, force=force))
|
|
186
|
+
]
|
|
187
|
+
for name in names:
|
|
188
|
+
dev = device if name in detectors.GPU else "cpu"
|
|
189
|
+
steps.append((f"score {name}", lambda n=name, d=dev: step_score(
|
|
190
|
+
suite, n, scores / n, device=d, batch_size=batch_size, force=force))) # fmt: skip
|
|
191
|
+
if redaction:
|
|
192
|
+
steps.append(("redaction", lambda: step_redaction(
|
|
193
|
+
suite, root / f"redaction-{tag}", per_pattern=per_pattern, force=force))) # fmt: skip
|
|
194
|
+
if latency: # last before the report, so nothing else competes for the CPU
|
|
195
|
+
for name in names:
|
|
196
|
+
steps.append((f"latency {name}", lambda n=name: step_latency(
|
|
197
|
+
n, root / f"latency-{tag}" / f"{n}.json", suite=suite, threads=threads,
|
|
198
|
+
force=force))) # fmt: skip
|
|
199
|
+
|
|
200
|
+
def report() -> None:
|
|
201
|
+
done = [scores / n for n in names if (scores / n / "run.json").exists()]
|
|
202
|
+
if not done:
|
|
203
|
+
raise RuntimeError("no finished scores to report")
|
|
204
|
+
step_report(suite, done, root / f"report-{tag}")
|
|
205
|
+
|
|
206
|
+
steps.append(("report", report))
|
|
207
|
+
|
|
208
|
+
results: list[tuple[str, str, float]] = []
|
|
209
|
+
for name, run in steps:
|
|
210
|
+
t0 = time.perf_counter()
|
|
211
|
+
log(f"== {name}")
|
|
212
|
+
try:
|
|
213
|
+
run()
|
|
214
|
+
results.append((name, "ok", time.perf_counter() - t0))
|
|
215
|
+
except Exception:
|
|
216
|
+
traceback.print_exc()
|
|
217
|
+
results.append((name, "FAILED", time.perf_counter() - t0))
|
|
218
|
+
if name == "build":
|
|
219
|
+
break # nothing else can run without a suite
|
|
220
|
+
log("summary:")
|
|
221
|
+
for name, status, seconds in results:
|
|
222
|
+
log(f" {status:<6} {name:<24} {seconds:7.1f}s")
|
|
223
|
+
return 1 if any(status != "ok" for _, status, _ in results) else 0
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ------------------------------------------------------------------------------ main
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _sources(_: argparse.Namespace) -> int:
|
|
230
|
+
for s in ledger_sources():
|
|
231
|
+
trained = ", ".join(s.trained_on_by) or "-"
|
|
232
|
+
print(f"{s.id:<14} {s.role:<12} {s.licence:<11} trained on by: {trained:<22} {s.title}")
|
|
233
|
+
return 0
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _run(a: argparse.Namespace) -> int:
|
|
237
|
+
cmd: str = a.command
|
|
238
|
+
if cmd == "sources":
|
|
239
|
+
return _sources(a)
|
|
240
|
+
if cmd == "build":
|
|
241
|
+
step_build(Path(a.out), a.sources, force=a.force)
|
|
242
|
+
elif cmd == "score":
|
|
243
|
+
step_score(Path(a.suite), a.detector, Path(a.out), device=a.device,
|
|
244
|
+
batch_size=a.batch_size, limit=a.limit, force=a.force) # fmt: skip
|
|
245
|
+
elif cmd == "report":
|
|
246
|
+
step_report(Path(a.suite), [Path(r) for r in a.runs], Path(a.out))
|
|
247
|
+
elif cmd == "latency":
|
|
248
|
+
step_latency(a.detector, Path(a.out), suite=Path(a.suite) if a.suite else None,
|
|
249
|
+
threads=a.threads, iterations=a.iterations, force=a.force) # fmt: skip
|
|
250
|
+
elif cmd == "redaction":
|
|
251
|
+
step_redaction(Path(a.suite), Path(a.out), per_pattern=a.per_pattern, force=a.force)
|
|
252
|
+
elif cmd == "pipeline":
|
|
253
|
+
return pipeline(Path(a.out), tag=a.tag, names=a.detectors, device=a.device,
|
|
254
|
+
batch_size=a.batch_size, threads=a.threads, per_pattern=a.per_pattern,
|
|
255
|
+
latency=not a.no_latency, redaction=not a.no_redaction,
|
|
256
|
+
force=a.force) # fmt: skip
|
|
257
|
+
return 0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
261
|
+
ap = argparse.ArgumentParser(prog="mobiusguard-eval", description=__doc__,
|
|
262
|
+
formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
|
|
263
|
+
sub = ap.add_subparsers(dest="command", required=True)
|
|
264
|
+
|
|
265
|
+
sub.add_parser("sources")
|
|
266
|
+
|
|
267
|
+
p = sub.add_parser("build")
|
|
268
|
+
p.add_argument("--out", required=True)
|
|
269
|
+
p.add_argument("--sources", nargs="*", help="source IDs (default: all)")
|
|
270
|
+
p.add_argument("--force", action="store_true")
|
|
271
|
+
|
|
272
|
+
p = sub.add_parser("score")
|
|
273
|
+
p.add_argument("--suite", required=True)
|
|
274
|
+
p.add_argument("--detector", required=True, choices=detectors.NAMES)
|
|
275
|
+
p.add_argument("--out", required=True)
|
|
276
|
+
p.add_argument("--device", default="cpu")
|
|
277
|
+
p.add_argument("--batch-size", type=int, default=32)
|
|
278
|
+
p.add_argument("--limit", type=int, default=0, help="score only the first N items")
|
|
279
|
+
p.add_argument("--force", action="store_true")
|
|
280
|
+
|
|
281
|
+
p = sub.add_parser("report")
|
|
282
|
+
p.add_argument("--suite", required=True)
|
|
283
|
+
p.add_argument("--runs", nargs="+", required=True)
|
|
284
|
+
p.add_argument("--out", required=True)
|
|
285
|
+
|
|
286
|
+
p = sub.add_parser("latency")
|
|
287
|
+
p.add_argument("--detector", required=True, choices=detectors.NAMES)
|
|
288
|
+
p.add_argument("--out", required=True)
|
|
289
|
+
p.add_argument("--suite", help="needed for tfidf, which trains first")
|
|
290
|
+
p.add_argument("--threads", type=int, default=0)
|
|
291
|
+
p.add_argument("--iterations", type=int, default=200)
|
|
292
|
+
p.add_argument("--force", action="store_true")
|
|
293
|
+
|
|
294
|
+
p = sub.add_parser("redaction")
|
|
295
|
+
p.add_argument("--suite", required=True)
|
|
296
|
+
p.add_argument("--out", required=True)
|
|
297
|
+
p.add_argument("--per-pattern", type=int, default=200)
|
|
298
|
+
p.add_argument("--force", action="store_true")
|
|
299
|
+
|
|
300
|
+
p = sub.add_parser("pipeline", help="every step in order, resuming what was saved")
|
|
301
|
+
p.add_argument("--out", default="runs")
|
|
302
|
+
p.add_argument("--tag", default="v0.1")
|
|
303
|
+
p.add_argument("--detectors", nargs="+", default=list(detectors.NAMES),
|
|
304
|
+
choices=detectors.NAMES) # fmt: skip
|
|
305
|
+
p.add_argument("--device", default="cpu", help="for the transformer baselines")
|
|
306
|
+
p.add_argument("--batch-size", type=int, default=32)
|
|
307
|
+
p.add_argument("--threads", type=int, default=4, help="CPU threads for latency")
|
|
308
|
+
p.add_argument("--per-pattern", type=int, default=200)
|
|
309
|
+
p.add_argument("--no-latency", action="store_true")
|
|
310
|
+
p.add_argument("--no-redaction", action="store_true")
|
|
311
|
+
p.add_argument("--force", action="store_true", help="redo every step")
|
|
312
|
+
|
|
313
|
+
return _run(ap.parse_args(argv))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
if __name__ == "__main__":
|
|
317
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Exact and near-duplicate detection.
|
|
2
|
+
|
|
3
|
+
Near duplicates are found with MinHash over character 5-gram sets and grouped into
|
|
4
|
+
clusters; a suite puts a whole cluster in one split, so a near copy of a test item can't
|
|
5
|
+
sit in the calibration split (or in a baseline's training data).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# numpy's stubs leave some overloads partially unknown under strict mode.
|
|
9
|
+
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from typing import cast
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import numpy.typing as npt
|
|
19
|
+
|
|
20
|
+
from .items import normalize
|
|
21
|
+
|
|
22
|
+
SHINGLE = 5
|
|
23
|
+
N_PERM = 128
|
|
24
|
+
BANDS = 32 # 32 bands x 4 rows: pairs above ~0.5 Jaccard become candidates
|
|
25
|
+
JACCARD = 0.8 # candidates at or above this estimated Jaccard are duplicates
|
|
26
|
+
|
|
27
|
+
_U64 = np.uint64
|
|
28
|
+
_rng = np.random.default_rng(20261027)
|
|
29
|
+
_A = _rng.integers(1, 2**63, N_PERM, dtype=np.int64).astype(_U64) | _U64(1)
|
|
30
|
+
_B = _rng.integers(0, 2**63, N_PERM, dtype=np.int64).astype(_U64)
|
|
31
|
+
_P = _U64(1_000_003)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _shingle_hashes(text: str) -> npt.NDArray[np.uint64]:
|
|
35
|
+
codes = np.frombuffer(normalize(text).encode("utf-32-le"), dtype=np.uint32).astype(_U64)
|
|
36
|
+
if len(codes) < SHINGLE:
|
|
37
|
+
codes = np.concatenate([codes, np.zeros(SHINGLE - len(codes), dtype=_U64)])
|
|
38
|
+
n = len(codes) - SHINGLE + 1
|
|
39
|
+
h = np.zeros(n, dtype=_U64)
|
|
40
|
+
with np.errstate(over="ignore"):
|
|
41
|
+
for j in range(SHINGLE):
|
|
42
|
+
h = h * _P + codes[j : j + n]
|
|
43
|
+
return np.unique(h)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def signature(text: str) -> npt.NDArray[np.uint64]:
|
|
47
|
+
"""MinHash signature: for each of N_PERM hash functions, the minimum over shingles."""
|
|
48
|
+
h = _shingle_hashes(text)
|
|
49
|
+
with np.errstate(over="ignore"):
|
|
50
|
+
sig = (np.outer(_A, h) + _B[:, None]).min(axis=1)
|
|
51
|
+
return cast("npt.NDArray[np.uint64]", sig)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def similarity(a: npt.NDArray[np.uint64], b: npt.NDArray[np.uint64]) -> float:
|
|
55
|
+
"""Estimated Jaccard similarity of the two shingle sets."""
|
|
56
|
+
return float(np.mean(a == b))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _UnionFind:
|
|
60
|
+
def __init__(self, n: int) -> None:
|
|
61
|
+
self.parent = list(range(n))
|
|
62
|
+
|
|
63
|
+
def find(self, i: int) -> int:
|
|
64
|
+
while self.parent[i] != i:
|
|
65
|
+
self.parent[i] = self.parent[self.parent[i]]
|
|
66
|
+
i = self.parent[i]
|
|
67
|
+
return i
|
|
68
|
+
|
|
69
|
+
def union(self, i: int, j: int) -> None:
|
|
70
|
+
a, b = self.find(i), self.find(j)
|
|
71
|
+
if a != b:
|
|
72
|
+
self.parent[max(a, b)] = min(a, b)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def clusters(texts: Sequence[str], *, threshold: float = JACCARD) -> list[int]:
|
|
76
|
+
"""Cluster index for each text: exact (normalised) and near duplicates share one.
|
|
77
|
+
|
|
78
|
+
The index is the position of the cluster's first member, so it is deterministic for a
|
|
79
|
+
given input order.
|
|
80
|
+
"""
|
|
81
|
+
n = len(texts)
|
|
82
|
+
uf = _UnionFind(n)
|
|
83
|
+
first: dict[str, int] = {}
|
|
84
|
+
for i, t in enumerate(texts):
|
|
85
|
+
key = normalize(t)
|
|
86
|
+
if key in first:
|
|
87
|
+
uf.union(first[key], i)
|
|
88
|
+
else:
|
|
89
|
+
first[key] = i
|
|
90
|
+
|
|
91
|
+
reps = sorted(first.values())
|
|
92
|
+
sigs = {i: signature(texts[i]) for i in reps}
|
|
93
|
+
rows = N_PERM // BANDS
|
|
94
|
+
# Each bucket keeps a few representatives; a new text is compared with those only.
|
|
95
|
+
buckets: dict[tuple[int, bytes], list[int]] = defaultdict(list)
|
|
96
|
+
for i in reps:
|
|
97
|
+
sig = sigs[i]
|
|
98
|
+
for band in range(BANDS):
|
|
99
|
+
key = (band, sig[band * rows : (band + 1) * rows].tobytes())
|
|
100
|
+
members = buckets[key]
|
|
101
|
+
for j in members:
|
|
102
|
+
if uf.find(i) != uf.find(j) and similarity(sig, sigs[j]) >= threshold:
|
|
103
|
+
uf.union(i, j)
|
|
104
|
+
if len(members) < 8:
|
|
105
|
+
members.append(i)
|
|
106
|
+
return [uf.find(i) for i in range(n)]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Detectors the harness can score: our own and the baselines (decision 0006, rule 6).
|
|
2
|
+
|
|
3
|
+
A detector gives every item a score where higher means "more likely an attack". It sees the
|
|
4
|
+
whole list of items so that a baseline which needs training (TF-IDF) can train
|
|
5
|
+
leave-one-source-out; detectors that don't train just score the texts.
|
|
6
|
+
|
|
7
|
+
Work is split into *groups* (chunks of items, or one source for TF-IDF) so that a run can
|
|
8
|
+
save each group as it finishes and resume after a crash (:mod:`..checkpoint`).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable, Sequence
|
|
14
|
+
from typing import Protocol
|
|
15
|
+
|
|
16
|
+
from ..items import Item
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Detector(Protocol):
|
|
20
|
+
name: str
|
|
21
|
+
|
|
22
|
+
def info(self) -> dict[str, str]:
|
|
23
|
+
"""Model ID, revision, versions, device: whatever identifies what was measured."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
def fingerprint(self) -> dict[str, str]:
|
|
27
|
+
"""What must match for saved scores to be reused (no timings or counts)."""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
def groups(self, items: Sequence[Item]) -> list[tuple[str, list[int]]]:
|
|
31
|
+
"""Units of work: a name and the positions of the items it scores."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
def score_group(self, items: Sequence[Item], idx: Sequence[int]) -> list[float]:
|
|
35
|
+
"""Scores for ``items[i]`` for each ``i`` in ``idx``, in that order."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
CHUNK = 2048
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def chunks(n: int, size: int = CHUNK) -> list[tuple[str, list[int]]]:
|
|
43
|
+
"""Fixed-size groups by position, for detectors that score items independently."""
|
|
44
|
+
return [(f"chunk-{i // size:05d}", list(range(i, min(i + size, n)))) for i in range(0, n, size)]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def score_items(detector: Detector, items: Sequence[Item]) -> list[float]:
|
|
48
|
+
"""Score everything in one go, without checkpoints."""
|
|
49
|
+
scores = [0.0] * len(items)
|
|
50
|
+
for _, idx in detector.groups(items):
|
|
51
|
+
for i, s in zip(idx, detector.score_group(items, idx), strict=True):
|
|
52
|
+
scores[i] = s
|
|
53
|
+
return scores
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get(name: str, *, device: str = "cpu", batch_size: int = 32) -> Detector:
|
|
57
|
+
"""A detector by name (see NAMES)."""
|
|
58
|
+
factories: dict[str, Callable[[], Detector]] = {}
|
|
59
|
+
|
|
60
|
+
from .keyword import KeywordDetector
|
|
61
|
+
|
|
62
|
+
factories["keyword"] = KeywordDetector
|
|
63
|
+
|
|
64
|
+
def tfidf() -> Detector:
|
|
65
|
+
from .tfidf import TfidfLosoDetector
|
|
66
|
+
|
|
67
|
+
return TfidfLosoDetector()
|
|
68
|
+
|
|
69
|
+
def hf(key: str) -> Callable[[], Detector]:
|
|
70
|
+
def make() -> Detector:
|
|
71
|
+
from .hf import HF_MODELS, HFClassifier
|
|
72
|
+
|
|
73
|
+
return HFClassifier(HF_MODELS[key], device=device, batch_size=batch_size)
|
|
74
|
+
|
|
75
|
+
return make
|
|
76
|
+
|
|
77
|
+
def rules() -> Detector:
|
|
78
|
+
from .proxy import RulesDetector
|
|
79
|
+
|
|
80
|
+
return RulesDetector()
|
|
81
|
+
|
|
82
|
+
def norm(key: str) -> Callable[[], Detector]:
|
|
83
|
+
def make() -> Detector:
|
|
84
|
+
from .proxy import NormalizedClassifier
|
|
85
|
+
|
|
86
|
+
return NormalizedClassifier(key, device=device, batch_size=batch_size)
|
|
87
|
+
|
|
88
|
+
return make
|
|
89
|
+
|
|
90
|
+
factories["tfidf"] = tfidf
|
|
91
|
+
factories["protectai-v2"] = hf("protectai-v2")
|
|
92
|
+
factories["piguard"] = hf("piguard")
|
|
93
|
+
factories["mobiusguard-rules"] = rules
|
|
94
|
+
factories["protectai-v2-norm"] = norm("protectai-v2")
|
|
95
|
+
factories["piguard-norm"] = norm("piguard")
|
|
96
|
+
if name not in factories:
|
|
97
|
+
raise KeyError(f"unknown detector {name!r}; choose from {sorted(factories)}")
|
|
98
|
+
return factories[name]()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
NAMES = (
|
|
102
|
+
"keyword",
|
|
103
|
+
"tfidf",
|
|
104
|
+
"protectai-v2",
|
|
105
|
+
"piguard",
|
|
106
|
+
"mobiusguard-rules",
|
|
107
|
+
"protectai-v2-norm",
|
|
108
|
+
"piguard-norm",
|
|
109
|
+
)
|
|
110
|
+
GPU = ("protectai-v2", "piguard", "protectai-v2-norm", "piguard-norm")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Pretrained classifiers for the harness: the proxy's own ``mobiusguard.classifier``,
|
|
2
|
+
with the methods the harness needs (fingerprint, groups). Needs the ``hf`` extra.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
|
|
9
|
+
from mobiusguard.classifier import HF_MODELS, HFModel
|
|
10
|
+
from mobiusguard.classifier import HFClassifier as _ProxyClassifier
|
|
11
|
+
|
|
12
|
+
from ..items import Item
|
|
13
|
+
from . import chunks
|
|
14
|
+
|
|
15
|
+
__all__ = ["HF_MODELS", "HFClassifier", "HFModel"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HFClassifier(_ProxyClassifier):
|
|
19
|
+
def fingerprint(self) -> dict[str, str]:
|
|
20
|
+
return {
|
|
21
|
+
"repo": self.spec.repo,
|
|
22
|
+
"revision": self.spec.revision,
|
|
23
|
+
"max_length": str(self.spec.max_length),
|
|
24
|
+
"device": self.device.split(":")[0],
|
|
25
|
+
"output": "log-odds",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
def groups(self, items: Sequence[Item]) -> list[tuple[str, list[int]]]:
|
|
29
|
+
return chunks(len(items))
|
|
30
|
+
|
|
31
|
+
def score_group(self, items: Sequence[Item], idx: Sequence[int]) -> list[float]:
|
|
32
|
+
# Sort by length so batches pad less; return in the given order.
|
|
33
|
+
order = sorted(range(len(idx)), key=lambda k: len(items[idx[k]].text))
|
|
34
|
+
scores = self.score_texts([items[idx[k]].text for k in order])
|
|
35
|
+
result = [0.0] * len(idx)
|
|
36
|
+
for k, s in zip(order, scores, strict=True):
|
|
37
|
+
result[k] = s
|
|
38
|
+
return result
|