Files
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

207 lines
8.3 KiB
Python

"""Benchmark + parity harness for the spacy vs gliner NER engines.
Runs the same payload through both engines and reports per-engine throughput
(batch analyze, the production /redact_batch path) and per-text latency, plus
an accuracy diff over the 4 NER entity types (PERSON/LOCATION/NRP/DATE_TIME).
Non-NER (regex/checksum) results must be identical between engines — both
register the same recognizers — so any mismatch there is a wiring bug and the
script exits non-zero.
Meant to run inside the pii image (both engines ship in it):
docker run --rm <pii-image> python scripts/bench_engines.py
docker run --rm -v $PWD/texts.json:/data.json <pii-image> \\
python scripts/bench_engines.py --payload /data.json
Payload format: JSON list of {"text": str, "language": str} objects.
This doubles as the tuning harness for GLINER_ENTITY_MAPPING label prompts.
"""
import argparse
import json
import statistics
import sys
import time
from collections import defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import engines # noqa: E402
# Entities sourced from the NER models rather than regex/checksum patterns.
# ORGANIZATION is emitted by the spacy engine's NER on unfiltered requests but
# is not in the app's supported set and has no GLiNER mapping — it shows up in
# the NER diff (spacy-only) rather than failing the regex-parity gate.
NER_ENTITIES = {"PERSON", "LOCATION", "NRP", "DATE_TIME", "ORGANIZATION"}
DEFAULT_PAYLOAD = Path(__file__).resolve().parent / "bench_payload.json"
def parse_args():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--payload", type=Path, default=DEFAULT_PAYLOAD)
parser.add_argument("--engines", default="spacy,gliner")
parser.add_argument("--runs", type=int, default=3)
parser.add_argument("--warmup", type=int, default=1)
parser.add_argument("--device", default=None, help="torch device for gliner (default: auto)")
parser.add_argument("--gliner-model", default="urchade/gliner_multi_pii-v1")
parser.add_argument("--max-examples", type=int, default=10)
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
return parser.parse_args()
def build(engine: str, args) -> tuple:
started = time.perf_counter()
if engine == "spacy":
analyzer = engines.build_spacy_analyzer()
elif engine == "gliner":
analyzer = engines.build_gliner_analyzer(model_name=args.gliner_model, device=args.device)
else:
raise ValueError(f"Unknown engine {engine!r}")
return analyzer, time.perf_counter() - started
def analyze_all(analyzer, items) -> list[list]:
"""One analyze() call per text, in payload order."""
return [analyzer.analyze(text=item["text"], language=item["language"]) for item in items]
def bench(analyzer, items, runs: int, warmup: int) -> dict:
for _ in range(warmup):
analyze_all(analyzer, items)
run_times = []
latencies = []
for _ in range(runs):
run_started = time.perf_counter()
for item in items:
text_started = time.perf_counter()
analyzer.analyze(text=item["text"], language=item["language"])
latencies.append(time.perf_counter() - text_started)
run_times.append(time.perf_counter() - run_started)
total_chars = sum(len(item["text"]) for item in items)
avg_run = statistics.mean(run_times)
return {
"texts_per_sec": len(items) / avg_run,
"chars_per_sec": total_chars / avg_run,
"latency_p50_ms": statistics.median(latencies) * 1000,
"latency_p95_ms": statistics.quantiles(latencies, n=20)[18] * 1000,
}
def spans(results, keep_ner: bool) -> set:
return {
(r.entity_type, r.start, r.end)
for r in results
if (r.entity_type in NER_ENTITIES) == keep_ner
}
def iou(a: tuple, b: tuple) -> float:
inter = max(0, min(a[2], b[2]) - max(a[1], b[1]))
union = max(a[2], b[2]) - min(a[1], b[1])
return inter / union if union else 0.0
def diff_ner(items, results_a, results_b, max_examples: int) -> dict:
"""Per-entity-type agreement between two engines (span IoU >= 0.5)."""
per_type = defaultdict(lambda: {"a_total": 0, "b_total": 0, "matched": 0})
examples = []
for item, res_a, res_b in zip(items, results_a, results_b):
a = sorted(spans(res_a, keep_ner=True))
b = sorted(spans(res_b, keep_ner=True))
unmatched_b = set(b)
for span_a in a:
per_type[span_a[0]]["a_total"] += 1
match = next(
(s for s in unmatched_b if s[0] == span_a[0] and iou(span_a, s) >= 0.5), None
)
if match:
per_type[span_a[0]]["matched"] += 1
unmatched_b.discard(match)
for span_b in b:
per_type[span_b[0]]["b_total"] += 1
only_a = [s for s in a if not any(s[0] == t[0] and iou(s, t) >= 0.5 for t in b)]
only_b = sorted(unmatched_b)
if (only_a or only_b) and len(examples) < max_examples:
examples.append(
{
"text": item["text"],
"language": item["language"],
"only_a": [f"{t}[{s}:{e}]={item['text'][s:e]!r}" for t, s, e in only_a],
"only_b": [f"{t}[{s}:{e}]={item['text'][s:e]!r}" for t, s, e in only_b],
}
)
return {"per_type": dict(per_type), "examples": examples}
def diff_regex(items, results_a, results_b) -> list:
"""Non-NER results must be identical: same recognizers on both engines."""
mismatches = []
for item, res_a, res_b in zip(items, results_a, results_b):
a = spans(res_a, keep_ner=False)
b = spans(res_b, keep_ner=False)
if a != b:
mismatches.append({"text": item["text"], "only_a": sorted(a - b), "only_b": sorted(b - a)})
return mismatches
def main() -> int:
args = parse_args()
items = json.loads(args.payload.read_text())
engine_names = [e.strip() for e in args.engines.split(",") if e.strip()]
report = {"payload": str(args.payload), "texts": len(items), "engines": {}}
results_by_engine = {}
for name in engine_names:
analyzer, build_secs = build(name, args)
stats = bench(analyzer, items, runs=args.runs, warmup=args.warmup)
stats["build_secs"] = build_secs
report["engines"][name] = stats
results_by_engine[name] = analyze_all(analyzer, items)
exit_code = 0
if set(engine_names) >= {"spacy", "gliner"}:
report["ner_diff"] = diff_ner(
items, results_by_engine["spacy"], results_by_engine["gliner"], args.max_examples
)
regex_mismatches = diff_regex(
items, results_by_engine["spacy"], results_by_engine["gliner"]
)
report["regex_mismatches"] = regex_mismatches
if regex_mismatches:
exit_code = 1
if args.json:
print(json.dumps(report, indent=2, default=str))
return exit_code
for name, stats in report["engines"].items():
print(f"\n== {name} ==")
print(f" build: {stats['build_secs']:.1f}s")
print(f" throughput: {stats['texts_per_sec']:.2f} texts/s ({stats['chars_per_sec']:.0f} chars/s)")
print(f" latency: p50 {stats['latency_p50_ms']:.1f}ms p95 {stats['latency_p95_ms']:.1f}ms")
if "ner_diff" in report:
print("\n== NER parity (spacy=a vs gliner=b, span IoU>=0.5) ==")
for entity, counts in sorted(report["ner_diff"]["per_type"].items()):
print(
f" {entity:<10} spacy={counts['a_total']:<4} gliner={counts['b_total']:<4} "
f"matched={counts['matched']}"
)
for example in report["ner_diff"]["examples"]:
print(f"\n [{example['language']}] {example['text']}")
if example["only_a"]:
print(f" spacy only: {', '.join(example['only_a'])}")
if example["only_b"]:
print(f" gliner only: {', '.join(example['only_b'])}")
if report["regex_mismatches"]:
print("\n!! REGEX MISMATCHES (wiring bug — engines must agree on non-NER):")
for mismatch in report["regex_mismatches"]:
print(f" {mismatch}")
else:
print("\n regex/checksum entities: identical across engines ✓")
return exit_code
if __name__ == "__main__":
raise SystemExit(main())