call-qa-processing / ml-services /scripts /error_analysis.py
aniketqxp's picture
feat: add processing service runtime
f1ef7e2
Raw
History Blame Contribute Delete
9.09 kB
"""
Error analysis over the Phase 1 per-channel probe results. No new transcription.
Answers the two questions that decide the next phase:
Q1 (Phase 5 / LLM): WHAT are the remaining errors? Categorize normalized
substitutions/deletions/insertions (numbers / morphology / function words /
lexical-content) and surface the most frequent lexical ref->hyp pairs --
i.e. exactly what an LLM cleanup pass would need to fix.
Q2 (Phase 3 / routing): does word CONFIDENCE predict errors? Bucket hypothesis
words by their decoding probability (and segment no_speech_prob) and show the
error rate per bucket. If errors concentrate in low-confidence words, we can
gate any correction pass to those spans (cheaper, less over-correction).
Pass 1 (categorization) runs at the normalized level so number/contraction
formatting is collapsed -> only genuine errors are counted.
Pass 2 (confidence) runs at the raw level so hypothesis tokens map 1:1 onto the
original word objects that carry prob/ns.
"""
import os
import json
import re
import jiwer
from collections import Counter, defaultdict
from eval_common import normalise, normalise_raw
DATA = r"d:\Desktop\ai-ml-capstone\data\na_testset"
RES = "results_channels" # Phase 1
FUNCTION_WORDS = set("a an the is are was were be been being am to of in on at for "
"and or but so it its i you he she we they me him her us them my "
"your his our their this that these those as if then than do does "
"did has have had will would can could may might shall should must "
"not no yes ok okay oh uh um mm yeah".split())
NUMBER_WORDS = set("zero one two three four five six seven eight nine ten eleven twelve "
"thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty "
"thirty forty fifty sixty seventy eighty ninety hundred thousand million "
"first second third fourth fifth".split())
def is_number(tok):
return bool(re.search(r"\d", tok)) or tok in NUMBER_WORDS
def same_stem(a, b):
"""Crude morphology check: same word, different inflection (plural/tense)."""
if a == b:
return False
for suf in ("s", "es", "d", "ed", "ing", "'s"):
if a == b + suf or b == a + suf:
return True
# shared stem of length >=4
i = 0
while i < min(len(a), len(b)) and a[i] == b[i]:
i += 1
return i >= 4 and abs(len(a) - len(b)) <= 3
def categorize(ref_w, hyp_w):
if is_number(ref_w) or is_number(hyp_w):
return "number"
if same_stem(ref_w, hyp_w):
return "morphology"
if ref_w in FUNCTION_WORDS or hyp_w in FUNCTION_WORDS:
return "function"
return "lexical"
def align_chunks(ref_str, hyp_str):
out = jiwer.process_words(ref_str, hyp_str)
refs, hyps = out.references[0], out.hypotheses[0]
return refs, hyps, out.alignments[0]
def main():
# ── Pass 1: normalized categorization ─────────────────────────────────────
with open(os.path.join(DATA, "manifest.json"), encoding="utf-8") as f:
manifest = {m["call_id"]: m for m in json.load(f)}
with open(os.path.join(DATA, "probe_set.json"), encoding="utf-8") as f:
probe = json.load(f)["calls"]
cat_counts = Counter()
lexical_pairs = Counter()
del_words = Counter()
ins_words = Counter()
per_call_err = {}
tot_ref = tot_S = tot_D = tot_I = 0
# ── Pass 2 accumulators (raw level) ───────────────────────────────────────
prob_buckets = [(0.0, 0.3), (0.3, 0.5), (0.5, 0.7), (0.7, 0.85), (0.85, 1.01)]
ns_buckets = [(0.0, 0.1), (0.1, 0.3), (0.3, 0.6), (0.6, 1.01)]
prob_stat = {b: [0, 0] for b in prob_buckets} # [n_words, n_errors]
ns_stat = {b: [0, 0] for b in ns_buckets}
err_lowprob = err_total = words_lowprob = words_total = 0
for p in probe:
cid, accent = p["call_id"], p["accent"]
m = manifest[cid]
with open(os.path.join(DATA, RES, accent, cid + ".json"), encoding="utf-8") as f:
hyp = json.load(f)
call_err = 0
for ref_text, words in [(m["agent_transcript"], hyp["agent"]),
(m["customer_transcript"], hyp["customer"])]:
# Pass 1: normalized
refs, hyps, chunks = align_chunks(normalise(ref_text),
normalise(" ".join(w["word"] for w in words)))
for ch in chunks:
if ch.type == "equal":
continue
rspan = refs[ch.ref_start_idx:ch.ref_end_idx]
hspan = hyps[ch.hyp_start_idx:ch.hyp_end_idx]
if ch.type == "substitute":
tot_S += len(rspan); call_err += len(rspan)
for rw, hw in zip(rspan, hspan):
c = categorize(rw, hw)
cat_counts[c] += 1
if c == "lexical":
lexical_pairs[f"{rw} -> {hw}"] += 1
elif ch.type == "delete":
tot_D += len(rspan); call_err += len(rspan)
for rw in rspan:
del_words[rw] += 1
cat_counts["number" if is_number(rw) else
"function" if rw in FUNCTION_WORDS else "del_other"] += 1
elif ch.type == "insert":
tot_I += len(hspan); call_err += len(hspan)
for hw in hspan:
ins_words[hw] += 1
tot_ref += len(refs)
# Pass 2: raw-level confidence mapping (1:1 token<->word meta)
toks, meta = [], []
for w in words:
for t in normalise_raw(w["word"]).split():
toks.append(t); meta.append((w.get("prob", 1.0), w.get("ns", 0.0)))
_, _, rchunks = align_chunks(normalise_raw(ref_text), " ".join(toks))
err_flag = [False] * len(toks)
for ch in rchunks:
if ch.type in ("substitute", "insert"):
for hi in range(ch.hyp_start_idx, ch.hyp_end_idx):
if hi < len(err_flag):
err_flag[hi] = True
for (prob, ns), is_err in zip(meta, err_flag):
words_total += 1; err_total += is_err
if prob < 0.5:
words_lowprob += 1; err_lowprob += is_err
for b in prob_buckets:
if b[0] <= prob < b[1]:
prob_stat[b][0] += 1; prob_stat[b][1] += is_err; break
for b in ns_buckets:
if b[0] <= ns < b[1]:
ns_stat[b][0] += 1; ns_stat[b][1] += is_err; break
per_call_err[cid] = call_err
# ── Report ────────────────────────────────────────────────────────────────
print("=== ERROR ANALYSIS (Phase 1 per-channel, 12 probe calls) ===\n")
acc = (1 - (tot_S + tot_D + tot_I) / tot_ref) * 100
print(f"Normalized: {acc:.1f}% accuracy | {tot_ref} ref words | "
f"S={tot_S} D={tot_D} I={tot_I}\n")
print("Q1 -- Error type breakdown (normalized S+D+I):")
total_cat = sum(cat_counts.values())
for c, n in cat_counts.most_common():
print(f" {c:<12} {n:>4} ({n/total_cat*100:4.1f}%)")
print("\n Top lexical substitutions (ref -> hyp) -- the LLM-fixable content errors:")
for pair, n in lexical_pairs.most_common(20):
print(f" {pair:<32} x{n}")
print("\n Top deletions (ref words missing):", ", ".join(f"{w}({n})" for w, n in del_words.most_common(10)))
print(" Top insertions (extra hyp words): ", ", ".join(f"{w}({n})" for w, n in ins_words.most_common(10)))
print("\nQ2 -- Does confidence predict errors? (raw-level hyp words)")
print(f" {'prob bucket':<14} {'#words':>7} {'#err':>6} {'err-rate':>9}")
for b in prob_buckets:
n, e = prob_stat[b]
print(f" [{b[0]:.2f},{b[1]-0.01:.2f}] {n:>7} {e:>6} {(e/n*100 if n else 0):>8.1f}%")
print(f" {'no_speech':<14} {'#words':>7} {'#err':>6} {'err-rate':>9}")
for b in ns_buckets:
n, e = ns_stat[b]
print(f" ns[{b[0]:.1f},{b[1]-0.01:.2f}] {n:>7} {e:>6} {(e/n*100 if n else 0):>8.1f}%")
if err_total:
print(f"\n Words with prob<0.5 hold {err_lowprob/err_total*100:.0f}% of all errors "
f"while being only {words_lowprob/words_total*100:.0f}% of all words.")
print("\nPer-call error count (normalized S+D+I), worst first:")
for cid, n in sorted(per_call_err.items(), key=lambda x: -x[1]):
tier = next(p["tier"] for p in probe if p["call_id"] == cid)
print(f" {tier:<13} {cid:<32} {n}")
if __name__ == "__main__":
main()