latentASR / scripts /analysis /paper500_per_sample_analysis.py
voidful's picture
Release latentASR adapter, code, docs, and reproducibility artifacts
262fa3f verified
Raw
History Blame Contribute Delete
27.9 kB
#!/usr/bin/env python3
"""Per-sample paper analysis for LatentASR.
The script runs three paths on one ASR split:
1. frozen baseline,
2. LatentASR with the deployed halting threshold,
3. LatentASR with forced full compute.
It writes per-sample JSON plus a LaTeX snippet containing difficulty bins,
gate-quality diagnostics, qualitative examples, and latent-delta statistics.
"""
from __future__ import annotations
import argparse
import gc
import json
import math
import os
import sys
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
import numpy as np
import torch
from datasets import Audio, load_dataset
from jiwer import cer, wer
from tqdm import tqdm
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from eval import ( # noqa: E402
build_base_model_bundle,
build_latent_bundle,
clean_prediction,
configure_text_normalizer,
normalize_text,
)
MODEL_ID = "Qwen/Qwen3-ASR-0.6B"
LATENT_CKPT = "eval_runs/paper_tbd_retrain_20260518/checkpoints/activation_500/activation_500_epoch10.pth"
MAX_NEW_TOKENS = 128
DATASET_PRESETS = {
"fleurs_en_us": {
"dataset_name": "google/fleurs",
"config": "en_us",
"split": "test",
"text_columns": ["transcription", "raw_transcription", "sentence", "text"],
"normalizer": "english",
"language_hint": "English",
"label": r"FLEURS (\texttt{en\_us})",
"source_label": "FLEURS Q4",
},
"voxpopuli_en": {
"dataset_name": "facebook/voxpopuli",
"config": "en",
"split": "test",
"text_columns": ["normalized_text", "raw_text", "text", "sentence"],
"normalizer": "english",
"language_hint": "English",
"label": r"VoxPopuli (\texttt{en})",
"source_label": "VoxPopuli Q4",
},
}
def choose_device() -> str:
return "cuda" if torch.cuda.is_available() else "cpu"
def choose_dtype(device: str) -> torch.dtype:
return torch.bfloat16 if device == "cuda" and torch.cuda.is_bf16_supported() else torch.float16
def tensor_to_float(value: Any) -> Optional[float]:
if value is None:
return None
if torch.is_tensor(value):
if value.numel() == 0:
return None
return float(value.detach().flatten()[0].cpu().item())
try:
return float(value)
except Exception:
return None
def tensor_to_list(value: Any) -> List[float]:
if value is None:
return []
if torch.is_tensor(value):
return [float(x) for x in value.detach().flatten().cpu().tolist()]
if isinstance(value, (list, tuple)):
return [float(x) for x in value]
return []
def get_ref(sample: Dict[str, Any], text_columns: List[str]) -> str:
for col in text_columns:
value = sample.get(col)
if isinstance(value, str) and value.strip():
return value
return ""
def sample_to_features(model: Any, processor: Any, sample: Dict[str, Any]) -> Optional[Dict[str, Any]]:
audio = sample.get("audio")
if not isinstance(audio, dict) or "array" not in audio or "sampling_rate" not in audio:
return None
audio_array = np.array(audio["array"], dtype=np.float64)
target_dtype = model.thinker.dtype if hasattr(model.thinker, "dtype") else torch.float32
feat_out = processor.feature_extractor(
audio_array,
sampling_rate=audio["sampling_rate"],
return_attention_mask=True,
)
device = model.base_model.device
feats = torch.tensor(feat_out.input_features[0], dtype=target_dtype, device=device).unsqueeze(0)
n_frames = feats.size(-1)
if getattr(feat_out, "attention_mask", None) is not None:
raw_mask = feat_out.attention_mask[0]
if not isinstance(raw_mask, (list, torch.Tensor)):
raw_mask = list(raw_mask)
if isinstance(raw_mask, torch.Tensor):
raw_mask = raw_mask.long()
else:
raw_mask = torch.tensor(raw_mask, dtype=torch.long)
if raw_mask.size(-1) < n_frames:
raw_mask = torch.cat([raw_mask, torch.zeros(n_frames - raw_mask.size(-1), dtype=torch.long)])
else:
raw_mask = raw_mask[:n_frames]
feature_attention_mask = raw_mask.to(device=device).unsqueeze(0)
else:
feature_attention_mask = torch.ones((1, n_frames), dtype=torch.long, device=device)
if int(feature_attention_mask.sum().item()) < 10:
return None
return {"feats": feats, "feature_attention_mask": feature_attention_mask}
@torch.no_grad()
def transcribe(
model: Any,
processor: Any,
sample: Dict[str, Any],
*,
use_baseline: bool,
theta: float,
language_hint: str,
) -> Optional[Dict[str, Any]]:
feats = sample_to_features(model, processor, sample)
if feats is None:
return None
prompt_text = f"Transcribe the {language_hint} audio into text." if language_hint else "Transcribe the audio into text."
gen_kwargs = {
"feature_attention_mask": feats["feature_attention_mask"],
"max_new_tokens": MAX_NEW_TOKENS,
"use_baseline": use_baseline,
"return_thoughts": False,
"return_stats": True,
"do_sample": False,
"eos_token_id": [151645, 151643],
"num_beams": 1,
"language_hint": language_hint,
"prompt_text": prompt_text,
"dynamic_halt_threshold": theta,
}
out = model.generate(feats["feats"], **gen_kwargs)
if isinstance(out, tuple):
gen_ids = out[0]
stats = out[1] if len(out) > 1 and isinstance(out[1], dict) else {}
else:
gen_ids = out
stats = {}
ids = gen_ids[0]
eos_id = processor.tokenizer.eos_token_id
if eos_id is not None and (ids == eos_id).any():
eos_pos = (ids == eos_id).nonzero(as_tuple=True)[0][0]
ids = ids[:eos_pos]
raw = processor.tokenizer.decode(ids, skip_special_tokens=True)
pred = clean_prediction(raw)
deq = tensor_to_float(stats.get("deq_iters"))
return {
"pred": pred,
"pred_norm": normalize_text(pred),
"stats": {
"deq_iters": 0 if deq is None else int(round(deq)),
"skipped": bool(stats.get("skipped", False)) if stats else False,
"v_preds": tensor_to_list(stats.get("v_preds")),
"scaled_norm_mean": tensor_to_list(stats.get("scaled_norm_mean")),
"step_cos": tensor_to_float(stats.get("step_cos")),
"diff_norm": tensor_to_float(stats.get("diff_norm")),
},
}
def load_rows(path: Path) -> List[Dict[str, Any]]:
if not path.exists():
return []
return json.loads(path.read_text(encoding="utf-8"))
def save_rows(path: Path, rows: List[Dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(rows, indent=2, ensure_ascii=False), encoding="utf-8")
def iter_dataset(preset: Dict[str, Any], streaming: bool) -> Iterable[Dict[str, Any]]:
ds = load_dataset(
preset["dataset_name"],
preset["config"],
split=preset["split"],
streaming=streaming,
trust_remote_code=True,
)
if not streaming:
ds = ds.cast_column("audio", Audio(sampling_rate=16000))
return ds
def collect_refs(preset: Dict[str, Any], streaming: bool) -> List[Dict[str, Any]]:
refs: List[Dict[str, Any]] = []
for idx, sample in enumerate(tqdm(iter_dataset(preset, streaming), desc="refs")):
ref_raw = get_ref(sample, preset["text_columns"])
ref_norm = normalize_text(ref_raw)
if ref_norm:
refs.append({"idx": idx, "ref_raw": ref_raw, "ref_norm": ref_norm})
return refs
def run_baseline(args: argparse.Namespace, preset: Dict[str, Any], refs: List[Dict[str, Any]], rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
done = {r["idx"] for r in rows if "baseline_norm" in r}
if len(done) == len(refs):
return rows
by_idx = {r["idx"]: r for r in rows}
ref_idx = {r["idx"]: r for r in refs}
bundle = build_base_model_bundle(args.model_id, args.device, args.dtype)
for idx, sample in enumerate(tqdm(iter_dataset(preset, args.streaming), desc="baseline")):
if idx not in ref_idx or idx in done:
continue
out = transcribe(
bundle.model,
bundle.processor,
sample,
use_baseline=True,
theta=args.theta,
language_hint=preset["language_hint"],
)
if out is None:
continue
row = by_idx.setdefault(idx, {"idx": idx, **ref_idx[idx]})
row["baseline_pred"] = out["pred"]
row["baseline_norm"] = out["pred_norm"]
save_rows(args.out_json, sorted(by_idx.values(), key=lambda x: x["idx"]))
del bundle
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
return sorted(by_idx.values(), key=lambda x: x["idx"])
def run_latent_path(
args: argparse.Namespace,
preset: Dict[str, Any],
rows: List[Dict[str, Any]],
*,
theta: float,
pred_key: str,
norm_key: str,
stats_key: str,
desc: str,
) -> List[Dict[str, Any]]:
eligible = {r["idx"] for r in rows if "baseline_norm" in r}
done = {r["idx"] for r in rows if norm_key in r}
if eligible and done == eligible:
return rows
by_idx = {r["idx"]: r for r in rows}
bundle = build_latent_bundle(args.model_id, args.latent_ckpt, args.n_latent, args.device, args.dtype)
for idx, sample in enumerate(tqdm(iter_dataset(preset, args.streaming), desc=desc)):
if idx not in eligible or idx in done:
continue
out = transcribe(
bundle.model,
bundle.processor,
sample,
use_baseline=False,
theta=theta,
language_hint=preset["language_hint"],
)
if out is None:
continue
row = by_idx[idx]
row[pred_key] = out["pred"]
row[norm_key] = out["pred_norm"]
row[stats_key] = out["stats"]
save_rows(args.out_json, sorted(by_idx.values(), key=lambda x: x["idx"]))
del bundle
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
return sorted(by_idx.values(), key=lambda x: x["idx"])
def group_wer(group: List[Dict[str, Any]], key: str) -> float:
if not group:
return 0.0
return 100 * wer([r["ref_norm"] for r in group], [r[key] for r in group])
def group_cer(group: List[Dict[str, Any]], key: str) -> float:
if not group:
return 0.0
return 100 * cer([r["ref_norm"] for r in group], [r[key] for r in group])
def esc_latex(text: str) -> str:
replacements = {
"\\": r"\textbackslash{}",
"&": r"\&",
"%": r"\%",
"$": r"\$",
"#": r"\#",
"_": r"\_",
"{": r"\{",
"}": r"\}",
"~": r"\textasciitilde{}",
"^": r"\textasciicircum{}",
}
return "".join(replacements.get(ch, ch) for ch in text)
def format_delta(value: float, bold_negative: bool = False) -> str:
if bold_negative and value < 0:
return rf"$\boldsymbol{{{value:+.2f}}}$"
return rf"${value:+.2f}$"
def write_latex(args: argparse.Namespace, preset: Dict[str, Any], rows: List[Dict[str, Any]]) -> None:
final_rows = [
r for r in rows
if all(k in r for k in ("baseline_norm", "latent_norm", "full_norm"))
]
for r in final_rows:
ref = r["ref_norm"]
r["baseline_wer"] = float(wer(ref, r["baseline_norm"]))
r["latent_wer"] = float(wer(ref, r["latent_norm"]))
r["full_wer"] = float(wer(ref, r["full_norm"]))
r["baseline_cer"] = float(cer(ref, r["baseline_norm"]))
r["latent_cer"] = float(cer(ref, r["latent_norm"]))
r["full_cer"] = float(cer(ref, r["full_norm"]))
base_wer = group_wer(final_rows, "baseline_norm")
lat_wer = group_wer(final_rows, "latent_norm")
full_wer = group_wer(final_rows, "full_norm")
base_cer = group_cer(final_rows, "baseline_norm")
lat_cer = group_cer(final_rows, "latent_norm")
full_cer = group_cer(final_rows, "full_norm")
sorted_rows = sorted(final_rows, key=lambda r: (r["baseline_wer"], r["idx"]))
n = len(sorted_rows)
bins = []
for q in range(4):
part = sorted_rows[math.floor(q * n / 4): math.floor((q + 1) * n / 4)]
bw = group_wer(part, "baseline_norm")
lw = group_wer(part, "latent_norm")
skip_q = 100 * sum(1 for r in part if r["latent_stats"].get("deq_iters", 0) == 0) / max(1, len(part))
bins.append((q + 1, len(part), bw, lw, lw - bw, skip_q))
skipped = [r for r in final_rows if r["latent_stats"].get("deq_iters", 0) == 0]
processed = [r for r in final_rows if r["latent_stats"].get("deq_iters", 0) > 0]
skip_base = group_wer(skipped, "baseline_norm")
skip_full = group_wer(skipped, "full_norm")
proc_base = group_wer(processed, "baseline_norm")
proc_lat = group_wer(processed, "latent_norm")
proc_full = group_wer(processed, "full_norm")
step_counts = {i: 0 for i in range(args.n_latent + 1)}
for r in final_rows:
deq = int(r["latent_stats"].get("deq_iters", 0))
step_counts[deq] = step_counts.get(deq, 0) + 1
step_rates = {k: 100 * v / max(1, len(final_rows)) for k, v in step_counts.items()}
avg_steps = sum(k * v for k, v in step_counts.items()) / max(1, len(final_rows))
processed_full = processed
norms_by_step: List[List[float]] = [[] for _ in range(args.n_latent)]
cos_vals: List[float] = []
diff_vals: List[float] = []
for r in processed_full:
norms = r["full_stats"].get("scaled_norm_mean", [])
for i, val in enumerate(norms[:args.n_latent]):
norms_by_step[i].append(float(val))
if r["full_stats"].get("step_cos") is not None:
cos_vals.append(float(r["full_stats"]["step_cos"]))
if r["full_stats"].get("diff_norm") is not None:
diff_vals.append(float(r["full_stats"]["diff_norm"]))
step_scale_means = [float(np.mean(vals)) if vals else 0.0 for vals in norms_by_step]
step_scale_text = ", ".join(f"${v:.4f}$" for v in step_scale_means)
def dist_stats(vals: List[float]) -> Dict[str, float]:
if not vals:
return {
"mean": 0.0,
"std": 0.0,
"p25": 0.0,
"median": 0.0,
"p75": 0.0,
"min": 0.0,
"max": 0.0,
}
arr = np.asarray(vals, dtype=np.float64)
return {
"mean": float(np.mean(arr)),
"std": float(np.std(arr)),
"p25": float(np.percentile(arr, 25)),
"median": float(np.percentile(arr, 50)),
"p75": float(np.percentile(arr, 75)),
"min": float(np.min(arr)),
"max": float(np.max(arr)),
}
cos_stat = dist_stats(cos_vals)
diff_stat = dist_stats(diff_vals)
examples = [
r for r in final_rows
if r["baseline_wer"] > r["latent_wer"]
and r["latent_stats"].get("deq_iters", 0) >= 1
and len(r["ref_norm"].split()) >= 6
]
examples.sort(key=lambda r: (r["baseline_wer"] - r["latent_wer"], r["baseline_wer"]), reverse=True)
examples = examples[:4]
lines: List[str] = []
lines.append("% ================================================================\n")
lines.append(f"% Per-sample analysis generated for {args.dataset_key}\n")
lines.append(f"% Samples used: {len(final_rows)}\n")
lines.append("% ================================================================\n\n")
lines.append("\\subsection{Analysis}\n")
lines.append("\\label{sec:analysis}\n\n")
lines.append("\\textbf{Difficulty-Binned Reductions.}\\quad\n")
lines.append(
f"We partition the {preset['label']} test set into four equal-sized bins by per-utterance "
f"Baseline WER and recompute WER within each bin (Table~\\ref{{tab:difficulty_bins}}). "
f"The aggregate result is small but positive: \\method{{}} reduces WER from "
f"${base_wer:.2f}\\%$ to ${lat_wer:.2f}\\%$ at $\\theta{{=}}{args.theta:.1f}$, "
f"while forced full compute reaches ${full_wer:.2f}\\%$. "
)
best_bin = min(bins, key=lambda x: x[4])
lines.append(
f"The largest reduction appears in Q{best_bin[0]}, where $\\Delta$WER is "
f"${best_bin[4]:+.2f}$~pp. This confirms that the average gain should not be read "
"as a uniform per-utterance improvement; latent scaling mainly changes the subset "
"where the frozen baseline leaves residual errors.\n\n"
)
lines.append("\\begin{table}[ht]\n")
lines.append(
f" \\caption{{Difficulty-binned analysis on {preset['label']} at $\\theta{{=}}{args.theta:.1f}$ "
f"({len(final_rows):,} utterances total). Utterances are partitioned into Baseline-WER quartiles. "
"$\\Delta$WER denotes \\method{} minus Baseline, so negative values indicate improvement.}\n"
)
lines.append(" \\label{tab:difficulty_bins}\n")
lines.append(" \\centering\n")
lines.append(" \\resizebox{\\columnwidth}{!}{\n")
lines.append(" \\begin{tabular}{l c c c c c}\n")
lines.append(" \\toprule\n")
lines.append(" \\textbf{Bin} & \\textbf{\\#utts} & \\textbf{Baseline WER (\\%)} & \\textbf{\\method{} WER (\\%)} & \\textbf{$\\Delta$WER (pp)} & \\textbf{Skip (\\%)} \\\\\n")
lines.append(" \\midrule\n")
labels = ["Q1 (easiest)", "Q2", "Q3", "Q4 (hardest)"]
for q, count, bw, lw, d, skip_q in bins:
method_cell = rf"\textbf{{{lw:.2f}}}" if d < 0 else f"{lw:.2f}"
lines.append(
f" {labels[q - 1]} & {count:,} & {bw:.2f} & {method_cell} & "
f"{format_delta(d, bold_negative=True)} & {skip_q:.1f} \\\\\n"
)
lines.append(" \\bottomrule\n")
lines.append(" \\end{tabular}\n")
lines.append(" }\n")
lines.append("\\end{table}\n\n")
lines.append("\\textbf{Value Head Decision Quality.}\\quad\n")
lines.append(
"The step distribution shows how much compute the Value Head allocates: "
f"at $\\theta{{=}}{args.theta:.1f}$, it skips {step_rates.get(0, 0.0):.1f}\\% of utterances "
f"and uses an average of {avg_steps:.2f} latent steps. "
"We further test selectivity by forcing the full $N{=}4$ path on the utterances "
"that the deployed gate skips. Table~\\ref{tab:gate_quality} reports the actual "
"deployed change and this counterfactual full-compute change.\n\n"
)
lines.append("\\begin{table}[ht]\n")
lines.append(
f" \\caption{{Value Head decision quality on {preset['label']} at $\\theta{{=}}{args.theta:.1f}$. "
"\\textbf{Counterfactual $\\Delta$WER} forces the $N{=}4$ latent path on each subset.}\n"
)
lines.append(" \\label{tab:gate_quality}\n")
lines.append(" \\centering\n")
lines.append(" \\resizebox{\\columnwidth}{!}{\n")
lines.append(" \\begin{tabular}{l c c c}\n")
lines.append(" \\toprule\n")
lines.append(" \\textbf{Subset (at $\\theta{=}0.0$)} & \\textbf{\\#utts} & \\textbf{Actual $\\Delta$WER (pp)} & \\textbf{Counterfactual $\\Delta$WER (pp)} \\\\\n")
lines.append(" \\midrule\n")
lines.append(f" Skipped ($v_0 < 0$) & {len(skipped):,} & $0.00$ (by construction) & {format_delta(skip_full - skip_base, True)} \\\\\n")
lines.append(f" Processed ($v_0 \\geq 0$) & {len(processed):,} & {format_delta(proc_lat - proc_base, True)} & {format_delta(proc_full - proc_base, True)} \\\\\n")
lines.append(" \\bottomrule\n")
lines.append(" \\end{tabular}\n")
lines.append(" }\n")
lines.append("\\end{table}\n\n")
lines.append("\\textbf{Step Allocation.}\\quad\n")
lines.append(
f"Table~\\ref{{tab:vox_step_dist}} gives the full $N$-step distribution on {preset['label']}. "
"Compared with forced full compute, the deployed policy keeps most examples away from "
"the deepest path while retaining the aggregate WER reduction.\n\n"
)
lines.append("\\begin{table}[ht]\n")
lines.append(
f" \\caption{{N-step distribution on {preset['label']} at $\\theta{{=}}{args.theta:.1f}$.}}\n"
)
lines.append(" \\label{tab:vox_step_dist}\n")
lines.append(" \\centering\n")
lines.append(" \\resizebox{0.9\\columnwidth}{!}{\n")
lines.append(" \\begin{tabular}{l c c c c c c}\n")
lines.append(" \\toprule\n")
lines.append(" \\textbf{Dataset} & \\textbf{Avg. steps} & \\textbf{N=0} & \\textbf{N=1} & \\textbf{N=2} & \\textbf{N=3} & \\textbf{N=4} \\\\\n")
lines.append(" \\midrule\n")
lines.append(
f" {preset['label']} & {avg_steps:.2f} & "
f"{step_rates.get(0, 0.0):.1f}\\% & {step_rates.get(1, 0.0):.1f}\\% & "
f"{step_rates.get(2, 0.0):.1f}\\% & {step_rates.get(3, 0.0):.1f}\\% & "
f"{step_rates.get(4, 0.0):.1f}\\% \\\\\n"
)
lines.append(" \\bottomrule\n")
lines.append(" \\end{tabular}\n")
lines.append(" }\n")
lines.append("\\end{table}\n\n")
if examples:
lines.append("\\textbf{Qualitative Examples.}\\quad\n")
lines.append(
f"Table~\\ref{{tab:qualitative}} shows hard-bin {preset['label']} utterances where "
"the latent loop changes the transcript.\n\n"
)
lines.append("\\begin{table}[ht]\n")
lines.append(
f" \\caption{{Qualitative examples from {preset['label']} hard bins.}}\n"
)
lines.append(" \\label{tab:qualitative}\n")
lines.append(" \\centering\n")
lines.append(" \\resizebox{\\columnwidth}{!}{\n")
lines.append(" \\begin{tabular}{p{0.13\\columnwidth} p{0.27\\columnwidth} p{0.27\\columnwidth} p{0.27\\columnwidth}}\n")
lines.append(" \\toprule\n")
lines.append(" \\textbf{Source} & \\textbf{Reference} & \\textbf{Baseline} & \\textbf{\\method{}} \\\\\n")
lines.append(" \\midrule\n")
for r in examples:
lines.append(
f" {preset['source_label']} & {esc_latex(r['ref_raw'])} & "
f"{esc_latex(r['baseline_pred'])} & {esc_latex(r['latent_pred'])} \\\\\n"
)
lines.append(" \\bottomrule\n")
lines.append(" \\end{tabular}\n")
lines.append(" }\n")
lines.append("\\end{table}\n\n")
lines.append("\\textbf{Refinement-Path Diagnostics.}\\quad\n")
lines.append(
"We recompute the forced-full $N{=}4$ path on the "
f"{preset['label']} processed subset ({len(processed_full):,} utterances with $N{{>}}0$ "
f"under $\\theta{{=}}{args.theta:.1f}$). The scaled delta norms are identical across "
"utterances because each delta is $L_2$-normalized and multiplied by the learned "
"per-step scale; they therefore measure the bounded step-size constraint rather "
f"than dataset-specific refinement behavior. For this run, the per-step scales are {step_scale_text}. "
"To characterize sample-dependent behavior, Table~\\ref{tab:refinement_dynamics} "
"instead reports the distribution of consecutive-delta cosine and consecutive-delta "
"difference norm. "
f"The cosine range is ${cos_stat['min']:.4f}$--${cos_stat['max']:.4f}$ and the "
f"difference-norm range is ${diff_stat['min']:.4f}$--${diff_stat['max']:.4f}$, "
"confirming that the forced refinement path is not a constant copied trajectory "
"while the update magnitudes remain bounded.\n\n"
)
lines.append("\\begin{table}[ht]\n")
lines.append(
f" \\caption{{Forced-full refinement diagnostics on the {preset['label']} processed subset "
f"({len(processed_full):,} utterances with $N{{>}}0$ under $\\theta{{=}}{args.theta:.1f}$). "
"Scaled delta norms are fixed by the learned step scales; cosine and difference "
"statistics vary across utterances.}\n"
)
lines.append(" \\label{tab:refinement_dynamics}\n")
lines.append(" \\centering\n")
lines.append(" \\resizebox{\\columnwidth}{!}{\n")
lines.append(" \\begin{tabular}{l c c c c c}\n")
lines.append(" \\toprule\n")
lines.append(r" \textbf{Metric} & \textbf{Mean} & \textbf{Std.} & \textbf{P25} & \textbf{Median} & \textbf{P75} \\" + "\n")
lines.append(" \\midrule\n")
lines.append(
f" Consecutive-delta cosine & {cos_stat['mean']:.4f} & {cos_stat['std']:.4f} & "
f"{cos_stat['p25']:.4f} & {cos_stat['median']:.4f} & {cos_stat['p75']:.4f} "
+ r"\\"
+ "\n"
)
lines.append(
f" Consecutive-delta diff. norm & {diff_stat['mean']:.4f} & {diff_stat['std']:.4f} & "
f"{diff_stat['p25']:.4f} & {diff_stat['median']:.4f} & {diff_stat['p75']:.4f} "
+ r"\\"
+ "\n"
)
lines.append(" \\bottomrule\n")
lines.append(" \\end{tabular}\n")
lines.append(" }\n")
lines.append("\\end{table}\n\n")
lines.append("% Overall metrics for cross-checking:\n")
lines.append(f"% Baseline WER/CER: {base_wer:.4f}/{base_cer:.4f}\n")
lines.append(f"% Latent theta={args.theta:.1f} WER/CER: {lat_wer:.4f}/{lat_cer:.4f}\n")
lines.append(f"% Forced full WER/CER: {full_wer:.4f}/{full_cer:.4f}\n")
args.out_tex.write_text("".join(lines), encoding="utf-8")
save_rows(args.out_json, final_rows)
print(f"wrote {args.out_json}")
print(f"wrote {args.out_tex}")
print(f"samples={len(final_rows)} baseline_wer={base_wer:.4f} latent_wer={lat_wer:.4f} full_wer={full_wer:.4f}")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dataset-key", choices=sorted(DATASET_PRESETS), default="voxpopuli_en")
parser.add_argument("--model-id", default=MODEL_ID)
parser.add_argument("--latent-ckpt", default=LATENT_CKPT)
parser.add_argument("--n-latent", type=int, default=4)
parser.add_argument("--theta", type=float, default=0.0)
parser.add_argument("--full-theta", type=float, default=-2.0)
parser.add_argument("--streaming", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--out-dir", type=Path, default=Path("eval_runs/paper_activation500_voxpopuli_analysis"))
args = parser.parse_args()
args.out_dir.mkdir(parents=True, exist_ok=True)
args.out_json = args.out_dir / f"{args.dataset_key}_per_sample.json"
args.out_tex = args.out_dir / f"{args.dataset_key}_analysis_latex.tex"
args.device = choose_device()
args.dtype = choose_dtype(args.device)
return args
def main() -> None:
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
args = parse_args()
preset = DATASET_PRESETS[args.dataset_key]
configure_text_normalizer(preset["normalizer"])
rows = load_rows(args.out_json)
refs = collect_refs(preset, args.streaming)
existing = {r["idx"]: r for r in rows}
for ref in refs:
existing.setdefault(ref["idx"], {"idx": ref["idx"], **ref})
rows = sorted(existing.values(), key=lambda x: x["idx"])
save_rows(args.out_json, rows)
rows = run_baseline(args, preset, refs, rows)
rows = run_latent_path(
args,
preset,
rows,
theta=args.theta,
pred_key="latent_pred",
norm_key="latent_norm",
stats_key="latent_stats",
desc=f"latent_theta{args.theta:g}",
)
rows = run_latent_path(
args,
preset,
rows,
theta=args.full_theta,
pred_key="full_pred",
norm_key="full_norm",
stats_key="full_stats",
desc="latent_full",
)
write_latex(args, preset, rows)
if __name__ == "__main__":
main()