Skip to content

Commit 7bd47d4

Browse files
JarbasAlclaude
andcommitted
docs: add F_0.5 calibration helpers and padacioso-tailored benchmark analysis
Port fbeta / recall_at_precision / calibrate_threshold helpers and the per-engine calibration table from ovos-markov-pipeline-plugin into benchmark/compare.py. Widen all runners to return raw (label, conf) pairs so the sweep can re-threshold offline. Rewrite docs/benchmark.md with actual numbers from the intents-for-eval run: explain why threshold tuning is a no-op for a regex engine (binary conf scale), why padacioso ships flat+hierarchical only (Domain parallel would be a no-op for a stateless matcher), the flat vs hierarchical trade, and the padacioso-vs-nebulento precision/recall comparison. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7b4ac1b commit 7bd47d4

2 files changed

Lines changed: 253 additions & 76 deletions

File tree

benchmark/compare.py

Lines changed: 118 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,59 @@
4040
_CI_MODE = "--ci" in sys.argv
4141

4242

43+
# ── calibration helpers ────────────────────────────────────────────────────
44+
45+
def fbeta(precision, recall, beta=0.5):
46+
"""F_β score. β<1 weights precision over recall — appropriate for voice
47+
assistants where a false positive (wrong skill fires) is unrecoverable
48+
while a false negative falls through to fallback handlers (LLM, etc.).
49+
"""
50+
b2 = beta * beta
51+
denom = b2 * precision + recall
52+
return ((1 + b2) * precision * recall / denom) if denom else 0.0
53+
54+
55+
def recall_at_precision(results_no_thresh, cases, p_floor=0.99, step=0.01):
56+
"""Sweep the threshold and return the max recall achievable while
57+
keeping precision >= ``p_floor`` (and the threshold that gets there).
58+
"""
59+
best_r, best_t = 0.0, None
60+
t = 0.0
61+
while t <= 1.0 + 1e-9:
62+
thresholded = [
63+
(lbl if c >= t else None, c) for (lbl, c) in results_no_thresh
64+
]
65+
m = compute_metrics(thresholded, cases)
66+
if m["precision"] >= p_floor and m["recall"] > best_r:
67+
best_r, best_t = m["recall"], round(t, 4)
68+
t += step
69+
return best_r, best_t
70+
71+
72+
def calibrate_threshold(results_no_thresh, cases, step=0.01, metric="f0.5"):
73+
"""Sweep threshold and return (best_threshold, best_metric_value, best_metrics).
74+
75+
``metric`` selects the scalar to maximise:
76+
- ``"f1"`` — standard F1.
77+
- ``"f0.5"`` — F_β=0.5 (precision-weighted; OVOS default).
78+
"""
79+
def score(m):
80+
return m["f1"] if metric == "f1" else fbeta(m["precision"], m["recall"], 0.5)
81+
82+
best = (0.0, -1.0, None)
83+
t = 0.0
84+
while t <= 1.0 + 1e-9:
85+
thresholded = [
86+
(lbl if c >= t else None, c) for (lbl, c) in results_no_thresh
87+
]
88+
m = compute_metrics(thresholded, cases)
89+
s = score(m)
90+
if s > best[1]:
91+
best = (round(t, 4), s, m)
92+
t += step
93+
return best
94+
95+
4396
# ── shared helpers ─────────────────────────────────────────────────────────
4497

4598
def all_cases(bundle):
@@ -172,7 +225,7 @@ def run_padaos(bundle, cases):
172225

173226
m = compute_metrics(results, cases)
174227
print_report("padaos (regex, no fuzz)", m, latencies, bundle.intents, train_ms)
175-
return m, statistics.median(latencies), statistics.mean(latencies), train_ms
228+
return m, statistics.median(latencies), statistics.mean(latencies), train_ms, None
176229

177230

178231
def run_padatious(bundle, cases, threshold=0.5):
@@ -187,18 +240,18 @@ def run_padatious(bundle, cases, threshold=0.5):
187240
c.train(single_thread=True, debug=False)
188241
train_ms = (time.perf_counter() - t0) * 1000
189242

190-
results, latencies = [], []
243+
raw, latencies = [], []
191244
for utt, _ in cases:
192245
t0 = time.perf_counter()
193246
r = c.calc_intent(normalize_utterance(utt))
194247
latencies.append((time.perf_counter() - t0) * 1000)
195-
predicted = r.name if (r and r.conf >= threshold) else None
196-
results.append((predicted, r.conf if r else 0.0))
248+
raw.append((r.name if r else None, r.conf if r else 0.0))
197249

250+
results = [(lbl if c >= threshold else None, c) for (lbl, c) in raw]
198251
m = compute_metrics(results, cases)
199252
print_report(f"padatious (neural, threshold={threshold})", m, latencies,
200253
bundle.intents, train_ms)
201-
return m, statistics.median(latencies), statistics.mean(latencies), train_ms
254+
return m, statistics.median(latencies), statistics.mean(latencies), train_ms, raw
202255

203256

204257
def run_nebulento(bundle, cases, threshold=0.5):
@@ -214,17 +267,17 @@ def run_nebulento(bundle, cases, threshold=0.5):
214267
print(f"[SKIP] nebulento registration failed: {e}")
215268
return None
216269

217-
results, latencies = [], []
270+
raw, latencies = [], []
218271
for utt, _ in cases:
219272
t0 = time.perf_counter()
220273
r = c.calc_intent(utt)
221274
latencies.append((time.perf_counter() - t0) * 1000)
222-
predicted = r.get("name") if (r and r.get("conf", 0) >= threshold) else None
223-
results.append((predicted, r.get("conf", 0.0) if r else 0.0))
275+
raw.append((r.get("name") if r else None, r.get("conf", 0.0) if r else 0.0))
224276

277+
results = [(lbl if c >= threshold else None, c) for (lbl, c) in raw]
225278
m = compute_metrics(results, cases)
226279
print_report("nebulento damerau-levenshtein", m, latencies, bundle.intents)
227-
return m, statistics.median(latencies), statistics.mean(latencies), None
280+
return m, statistics.median(latencies), statistics.mean(latencies), None, raw
228281

229282

230283
def run_padacioso_flat(bundle, cases, threshold=0.5):
@@ -238,17 +291,17 @@ def run_padacioso_flat(bundle, cases, threshold=0.5):
238291
list(c.calc_intents("warm up the cache"))
239292
train_ms = (time.perf_counter() - t0) * 1000
240293

241-
results, latencies = [], []
294+
raw, latencies = [], []
242295
for utt, _ in cases:
243296
t0 = time.perf_counter()
244297
r = c.calc_intent(utt)
245298
latencies.append((time.perf_counter() - t0) * 1000)
246-
predicted = r.get("name") if (r and r.get("conf", 0) >= threshold) else None
247-
results.append((predicted, r.get("conf", 0.0) if r else 0.0))
299+
raw.append((r.get("name") if r else None, r.get("conf", 0.0) if r else 0.0))
248300

301+
results = [(lbl if c >= threshold else None, c) for (lbl, c) in raw]
249302
m = compute_metrics(results, cases)
250303
print_report("padacioso flat", m, latencies, bundle.intents, train_ms)
251-
return m, statistics.median(latencies), statistics.mean(latencies), train_ms
304+
return m, statistics.median(latencies), statistics.mean(latencies), train_ms, raw
252305

253306

254307
def run_padacioso_hierarchical(bundle, cases, threshold=0.5,
@@ -273,19 +326,19 @@ def run_padacioso_hierarchical(bundle, cases, threshold=0.5,
273326
c.calc_intent("warm up the cache")
274327
train_ms = (time.perf_counter() - t0) * 1000
275328

276-
results, latencies = [], []
329+
raw, latencies = [], []
277330
for utt, _ in cases:
278331
t0 = time.perf_counter()
279332
r = c.calc_intent(utt)
280333
latencies.append((time.perf_counter() - t0) * 1000)
281-
predicted = r.get("name") if (r and r.get("conf", 0) >= threshold) else None
282-
results.append((predicted, r.get("conf", 0.0) if r else 0.0))
334+
raw.append((r.get("name") if r else None, r.get("conf", 0.0) if r else 0.0))
283335

336+
results = [(lbl if c >= threshold else None, c) for (lbl, c) in raw]
284337
m = compute_metrics(results, cases)
285338
print_report(f"padacioso hierarchical (two-stage, "
286339
f"domain_threshold={domain_threshold})", m, latencies,
287340
bundle.intents, train_ms)
288-
return m, statistics.median(latencies), statistics.mean(latencies), train_ms
341+
return m, statistics.median(latencies), statistics.mean(latencies), train_ms, raw
289342

290343

291344
# ── summary table ──────────────────────────────────────────────────────────
@@ -327,28 +380,71 @@ def run_dataset(name):
327380
print("Splits : " + ", ".join(f"{k}={len(v)}" for k, v in bundle.splits.items()))
328381

329382
rows = []
383+
cal_rows = []
330384

331385
# ── fixed baselines ──
332-
m, lat, mean_lat, tr = run_padaos(bundle, cases)
386+
m, lat, mean_lat, tr, raw = run_padaos(bundle, cases)
333387
rows.append(("padaos (regex)", m, lat, mean_lat, tr))
388+
# padaos has no conf knob — skip calibration
334389

335-
m, lat, mean_lat, tr = run_padatious(bundle, cases, threshold=0.5)
390+
m, lat, mean_lat, tr, raw = run_padatious(bundle, cases, threshold=0.5)
336391
rows.append(("padatious neural threshold=0.5", m, lat, mean_lat, tr))
392+
cal_rows.append(_calibrate_row("padatious", raw, cases, 0.5, m))
337393

338394
neb = run_nebulento(bundle, cases, threshold=0.5)
339395
if neb is not None:
340-
m, lat, mean_lat, tr = neb
396+
m, lat, mean_lat, tr, raw = neb
341397
rows.append(("nebulento damerau-levenshtein", m, lat, mean_lat, tr))
398+
cal_rows.append(_calibrate_row("nebulento", raw, cases, 0.5, m))
342399

343400
# ── subject — this repo's engines ──
344-
m, lat, mean_lat, tr = run_padacioso_flat(bundle, cases, threshold=0.5)
401+
m, lat, mean_lat, tr, raw = run_padacioso_flat(bundle, cases, threshold=0.5)
345402
rows.append(("padacioso flat", m, lat, mean_lat, tr))
403+
cal_rows.append(_calibrate_row("padacioso flat", raw, cases, 0.5, m))
346404

347-
m, lat, mean_lat, tr = run_padacioso_hierarchical(
405+
m, lat, mean_lat, tr, raw = run_padacioso_hierarchical(
348406
bundle, cases, threshold=0.5, domain_threshold=0.0)
349407
rows.append(("padacioso hierarchical (two-stage)", m, lat, mean_lat, tr))
408+
cal_rows.append(_calibrate_row("padacioso hierarchical", raw, cases, 0.5, m))
350409

351410
summary(f"{name}{bundle.repo}", rows)
411+
_print_calibration_table(cal_rows)
412+
413+
414+
def _calibrate_row(label, raw, cases, default_thr, default_metrics):
415+
"""Compute the calibration row for one engine."""
416+
df1 = default_metrics["f1"]
417+
dfp = default_metrics["fp"]
418+
df05 = fbeta(default_metrics["precision"], default_metrics["recall"], 0.5)
419+
opt_thr, _, opt_metrics = calibrate_threshold(raw, cases, step=0.01,
420+
metric="f0.5")
421+
of1 = opt_metrics["f1"]
422+
ofp = opt_metrics["fp"]
423+
of05 = fbeta(opt_metrics["precision"], opt_metrics["recall"], 0.5)
424+
rec_at_p, rec_thr = recall_at_precision(raw, cases, p_floor=0.99, step=0.01)
425+
return (label, default_thr, df1, df05, dfp,
426+
opt_thr, of1, of05, ofp, rec_at_p, rec_thr)
427+
428+
429+
def _print_calibration_table(rows):
430+
print(f"\n{'─'*108}")
431+
print(" Per-engine threshold calibration (sweep 0..1 step 0.01, max F_0.5)")
432+
print(f" {'Engine':<24} {'def_thr':>7} {'def_F1':>7} {'defF.5':>7} {'def_FP':>6}"
433+
f" {'opt_thr':>7} {'opt_F1':>7} {'optF.5':>7} {'opt_FP':>6}"
434+
f" {'R@P99':>6} {'thr':>5}")
435+
print(f"{'─'*108}")
436+
for r in rows:
437+
(label, dthr, df1, df05, dfp,
438+
othr, of1, of05, ofp, rec_at_p, rec_thr) = r
439+
rec_t = f"{rec_thr:.2f}" if rec_thr is not None else "--"
440+
print(f" {label:<24} {dthr:>7.2f} {df1:>7.3f} {df05:>7.3f} {dfp:>6d}"
441+
f" {othr:>7.2f} {of1:>7.3f} {of05:>7.3f} {ofp:>6d}"
442+
f" {rec_at_p:>6.1%} {rec_t:>5}")
443+
print(f"{'─'*108}")
444+
print(" F_0.5 (β=0.5) weights precision 2x recall — the right summary metric")
445+
print(" for OVOS, where a wrong intent is unrecoverable but a missed intent")
446+
print(" falls through to fallback handlers. R@P99 = max recall achievable")
447+
print(" with the threshold tuned to keep precision >= 99%.")
352448

353449

354450
if __name__ == "__main__":

0 commit comments

Comments
 (0)