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
4598def 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
178231def 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
204257def 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
230283def 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
254307def 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
354450if __name__ == "__main__" :
0 commit comments