Download code/speaker_v2/train.py from laion/tyger-speaker-predictor: direct link, hf CLI and curl.
- Browser
- Download file 29.6 kB
-
https://huggingface.co/laion/tyger-speaker-predictor/resolve/main/code/speaker_v2/train.py
- Command line
-
hf download hf://laion/tyger-speaker-predictor/code/speaker_v2/train.py
-
curl -L -o train.py https://huggingface.co/laion/tyger-speaker-predictor/resolve/main/code/speaker_v2/train.py
29.6 kB
| """Reproducible pilot ablations on cached frozen-encoder features. | |
| Run from robo-integration: python -m speaker_v2.train --data speaker_v2/data | |
| Only a validation split selects architecture/thresholds; held-out test is read | |
| once after selection. Window-level metrics share speakers/utterances and are | |
| not independent trials. This is a small synthetic-voice pilot, not biometrics. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import html | |
| import json | |
| import random | |
| import time | |
| from pathlib import Path | |
| import numpy as np | |
| import torch | |
| from torch.nn import functional as F | |
| from torch.utils.data import DataLoader, Dataset | |
| from .models import SpeakerPredictor, parameter_count | |
| class Windows(Dataset): | |
| def __init__(self, root, split, context, max_windows=12000): | |
| self.context = context | |
| self.arrays = [] | |
| self.speaker_frames = [] | |
| self.records = [] | |
| self.index = [] | |
| root = Path(root) | |
| manifest = json.loads((root / "manifest.json").read_text()) | |
| entries = manifest["conversations"] if isinstance(manifest, dict) else manifest | |
| self.teachers = np.load(root / "teacher_embeddings.npy").astype(np.float32) | |
| self.teachers /= np.maximum(np.linalg.norm(self.teachers, axis=-1, keepdims=True), 1e-8) | |
| for entry in entries: | |
| entry_split = "val" if entry["split"] == "validation" else entry["split"] | |
| if entry_split != split: | |
| continue | |
| with np.load(root / entry["path"]) as f: | |
| x = np.asarray(f["features"], dtype=np.float16) | |
| ids = np.asarray(f["speaker_idx"], dtype=np.int64) | |
| speech = np.asarray(f.get("speech_fraction", (ids >= 0).astype(float))) | |
| ambiguity = np.asarray(f.get("ambiguity", (ids == -2).astype(float))) | |
| valid = np.asarray(f.get("valid_mask", ids >= 0)) | |
| arr_id = len(self.arrays) | |
| self.arrays.append(x) | |
| self.speaker_frames.append(ids) | |
| self.records.append(entry) | |
| for end in range(4, len(x) + 1, 4): | |
| tail = ids[end - 4:end] | |
| if ambiguity[end - 4:end].mean() > .25: | |
| cls, target_id = 2, -1 | |
| elif speech[end - 4:end].mean() < .25: | |
| cls, target_id = 1, -1 | |
| else: | |
| positive = tail[tail >= 0] | |
| if len(positive) == 0 or valid[end - 4:end].mean() < .5: | |
| cls, target_id = 2, -1 | |
| else: | |
| target_id = int(np.bincount(positive).argmax()) | |
| cls = 0 if (tail == target_id).mean() >= .5 else 2 | |
| if cls != 0: | |
| target_id = -1 | |
| self.index.append((arr_id, end, cls, target_id)) | |
| if len(self.index) > max_windows: | |
| rng = random.Random(71) | |
| self.index = sorted(rng.sample(self.index, max_windows)) | |
| if not self.index: | |
| raise ValueError(f"No {split} windows found in {root}") | |
| def __len__(self): | |
| return len(self.index) | |
| def __getitem__(self, i): | |
| arr_id, end, cls, target_id = self.index[i] | |
| x = self.arrays[arr_id][max(0, end-self.context):end].astype(np.float32) | |
| if len(x) < self.context: | |
| x = np.concatenate([np.repeat(x[:1], self.context-len(x), axis=0), x]) | |
| y = self.teachers[target_id] if target_id >= 0 else np.zeros(192, np.float32) | |
| return torch.from_numpy(x), torch.from_numpy(y), cls, target_id | |
| def roc_metrics(positive, negative, threshold=None): | |
| positive, negative = np.asarray(positive), np.asarray(negative) | |
| if len(positive) == 0 or len(negative) == 0: | |
| return {} | |
| thresholds = np.unique(np.r_[positive, negative]) | |
| # Avoid allocating [millions of pairs x thresholds]. | |
| thresholds = np.quantile(thresholds, np.linspace(0, 1, min(1001, len(thresholds)))) | |
| far = np.array([(negative >= t).mean() for t in thresholds]) | |
| frr = np.array([(positive < t).mean() for t in thresholds]) | |
| at = int(np.abs(far-frr).argmin()) | |
| result = {"eer": float((far[at]+frr[at])/2), "eer_threshold": float(thresholds[at]), | |
| "far05_threshold": float(np.quantile(negative, .95)), | |
| "positive_cosine": float(positive.mean()), "impostor_cosine": float(negative.mean()), | |
| "genuine_trials": len(positive), "impostor_trials": len(negative)} | |
| if threshold is not None: | |
| result.update(threshold=float(threshold), false_accept_rate=float((negative >= threshold).mean()), | |
| false_reject_rate=float((positive < threshold).mean())) | |
| return result | |
| def predictions(model, dataset, device): | |
| embeddings, targets, classes, identities, probabilities = [], [], [], [], [] | |
| for x, target, cls, speaker in DataLoader(dataset, batch_size=128, shuffle=False): | |
| emb, logits = model(x.to(device)) | |
| embeddings.append(emb.cpu().numpy()) | |
| probabilities.append(logits.softmax(-1).cpu().numpy()) | |
| targets.append(target.numpy()) | |
| classes.append(cls.numpy()) | |
| identities.append(speaker.numpy()) | |
| return tuple(np.concatenate(x) for x in (embeddings, targets, classes, identities, probabilities)) | |
| def evaluate(pred, teachers, threshold=None): | |
| embeddings, targets, classes, identities, probabilities = pred | |
| valid = classes == 0 | |
| e, ids = embeddings[valid], identities[valid] | |
| speakers = np.unique(ids) | |
| if len(speakers) < 2: | |
| raise ValueError("Verification needs at least two distinct speakers per split") | |
| gallery = teachers[speakers] | |
| sim = e @ gallery.T | |
| own = speakers[None, :] == ids[:, None] | |
| result = roc_metrics(sim[own], sim[~own], threshold) | |
| result.update(closed_set_accuracy=float((speakers[sim.argmax(1)] == ids).mean()), | |
| activity_accuracy=float((probabilities.argmax(1) == classes).mean()), | |
| speech_windows=int(valid.sum()), total_windows=len(classes), | |
| speakers=int(len(speakers))) | |
| result["per_speaker"] = {str(int(s)): {"windows":int((ids==s).sum()), | |
| "teacher_cosine":float(sim[own][ids==s].mean()), | |
| "closed_set_accuracy":float((speakers[sim.argmax(1)][ids==s]==s).mean())} | |
| for s in speakers} | |
| result["balanced_closed_set_accuracy"] = float(np.mean([v["closed_set_accuracy"] for v in result["per_speaker"].values()])) | |
| result["majority_class_accuracy_baseline"] = float(max((ids==s).mean() for s in speakers)) | |
| for cls, name in enumerate(("speech", "silence", "overlap")): | |
| mask = classes == cls | |
| result[name+"_recall"] = float((probabilities.argmax(1)[mask] == cls).mean()) if mask.any() else None | |
| result[name+"_windows"] = int(mask.sum()) | |
| if len(speakers) >= 2 and threshold is not None: | |
| enrolled = speakers[::2] | |
| known = np.isin(ids, enrolled) | |
| score = (e @ teachers[enrolled].T).max(1) | |
| result["unknown_rejection"] = float((score[~known] < threshold).mean()) | |
| result["known_acceptance"] = float((score[known] >= threshold).mean()) | |
| result["unknown_protocol"] = "alternating held-out speaker IDs enrolled; disjoint remaining IDs unknown" | |
| return result | |
| def loss_value(embedding, target, classes, logits, loss, class_weights=None, target_mean=None): | |
| mask = classes == 0 | |
| if mask.any(): | |
| e,t = embedding[mask],target[mask] | |
| if target_mean is not None: | |
| e,t = F.normalize(e-target_mean,dim=-1),F.normalize(t-target_mean,dim=-1) | |
| cosine = (1 - (e*t).sum(-1)).mean() | |
| mse = F.mse_loss(e,t) * 192 | |
| regression = {"cosine": cosine, "mse": mse, "mixed": .5*cosine+.5*mse}[loss] | |
| else: | |
| regression = embedding.sum() * 0 | |
| return regression + .15 * F.cross_entropy(logits, classes,weight=class_weights) | |
| def train_one(cfg, train, validation, out, device, epochs, seed): | |
| torch.manual_seed(seed) | |
| np.random.seed(seed) | |
| random.seed(seed) | |
| model = SpeakerPredictor(cfg["architecture"], cfg["width"],centered=cfg.get("centered_target",False)).to(device) | |
| target_mean = None | |
| if cfg.get("centered_target",False): | |
| ids = sorted(set(i[3] for i in train.index if i[3]>=0)) | |
| teacher = torch.from_numpy(train.teachers[ids]).to(device) | |
| target_mean = teacher.mean(0) | |
| radius = (teacher-target_mean).norm(dim=-1).mean() | |
| model.teacher_mean.copy_(target_mean) | |
| model.teacher_radius.copy_(radius) | |
| optimizer = torch.optim.AdamW(model.parameters(), lr=7e-4, weight_decay=1e-3) | |
| generator = torch.Generator().manual_seed(seed) | |
| loader = DataLoader(train, batch_size=64, shuffle=True, generator=generator) | |
| class_weights = None | |
| if cfg.get("balanced_activity",False): | |
| counts = torch.bincount(torch.tensor([i[2] for i in train.index]),minlength=3).float().clamp_min(1) | |
| class_weights = counts.rsqrt() | |
| class_weights = (class_weights/class_weights.mean()).to(device) | |
| best, best_epoch, best_state = -float("inf"), 0, None | |
| history = [] | |
| start = time.perf_counter() | |
| for epoch in range(epochs): | |
| model.train() | |
| running = 0 | |
| for x, target, classes, _ in loader: | |
| x, target, classes = x.to(device), target.to(device), classes.to(device) | |
| optimizer.zero_grad(set_to_none=True) | |
| embedding, logits = model(x) | |
| loss = loss_value(embedding, target, classes, logits, cfg["loss"],class_weights,target_mean) | |
| loss.backward() | |
| torch.nn.utils.clip_grad_norm_(model.parameters(), 2.) | |
| optimizer.step() | |
| running += float(loss.detach()) * len(x) | |
| model.eval() | |
| metrics = evaluate(predictions(model, validation, device), validation.teachers) | |
| # Identity ranking matters, not the regression objective alone. | |
| objective = 1 - metrics["eer"] + .1 * metrics["positive_cosine"] | |
| history.append({"epoch": epoch+1, "loss": running/len(train), **metrics}) | |
| if objective > best: | |
| best, best_epoch = objective, epoch+1 | |
| best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()} | |
| if epoch >= 14 and epoch+1 - best_epoch >= 8: | |
| break | |
| elapsed = time.perf_counter()-start | |
| model.load_state_dict(best_state) | |
| model.eval() | |
| val = evaluate(predictions(model, validation, device), validation.teachers) | |
| path = out / (f"{cfg['architecture']}-w{cfg['width']}-c{cfg['context']}-{cfg['loss']}.pt") | |
| state = {"model": best_state, "config": cfg, "seed": seed, "epoch": best_epoch, | |
| "validation": val, "activity_threshold": .6, | |
| "format_version": 1, "feature_source": "frozen Nemotron ASR 3.5 layer15 1024D/80ms", | |
| "teacher": "speechbrain/spkrec-ecapa-voxceleb normalized192", | |
| "warning": "Synthetic-voice pilot; not biometric authentication; no microphone recordings"} | |
| torch.save(state, path) | |
| # Batch1 CPU latency, warmed; avoid asynchronous GPU timing ambiguity. | |
| cpu_model = SpeakerPredictor(cfg["architecture"], cfg["width"],centered=cfg.get("centered_target",False)) | |
| cpu_model.load_state_dict(best_state) | |
| cpu_model.eval() | |
| x = torch.randn(1, cfg["context"], 1024) | |
| with torch.inference_mode(): | |
| for _ in range(10): cpu_model(x) | |
| t = time.perf_counter() | |
| for _ in range(100): cpu_model(x) | |
| latency_ms = (time.perf_counter()-t)*10 | |
| return {"config": cfg, "parameters": parameter_count(model), "validation": val, | |
| "epochs": len(history), "best_epoch": best_epoch, "train_seconds": elapsed, | |
| "training_windows_per_second": len(train)*len(history)/elapsed, | |
| "cpu_batch1_ms": latency_ms, "checkpoint": str(path), "history": history}, model | |
| def accumulation_metrics(pred, dataset, threshold): | |
| e, targets, cls, identities, _ = pred | |
| result = {} | |
| for updates in (1, 2, 3, 6, 12): | |
| out = e.copy() | |
| for i in range(len(e)): | |
| if cls[i] != 0: | |
| continue | |
| used = [i] | |
| for j in range(i-1, max(-1, i-updates), -1): | |
| # Consecutive windows only, same conversation and same ground truth. | |
| if cls[j] != 0 or identities[j] != identities[i] or dataset.index[j][0] != dataset.index[i][0] or dataset.index[j+1][1]-dataset.index[j][1] != 4: | |
| break | |
| used.append(j) | |
| value = e[used].mean(0) | |
| out[i] = value/max(np.linalg.norm(value), 1e-8) | |
| result[str(updates*320)+"ms"] = evaluate((out, targets, cls, identities, pred[4]), dataset.teachers, threshold) | |
| return result | |
| def baseline_predictions(train, dataset): | |
| """Frozen-feature ridge and constant-embedding controls, train speakers only.""" | |
| def collect(data): | |
| features, targets, classes, identities = [], [], [], [] | |
| for x,y,c,s in DataLoader(data,batch_size=256): | |
| features.append(F.layer_norm(x,(1024,)).mean(1)) | |
| targets.append(y) | |
| classes.append(c) | |
| identities.append(s) | |
| return tuple(torch.cat(z) for z in (features,targets,classes,identities)) | |
| x,y,c,s = collect(train) | |
| tx,ty,tc,ts = collect(dataset) | |
| keep = c == 0 | |
| x = torch.cat([x[keep],torch.ones(int(keep.sum()),1)],-1) | |
| tx = torch.cat([tx,torch.ones(len(tx),1)],-1) | |
| regularizer = torch.eye(x.shape[1])*10 | |
| weights = torch.linalg.solve(x.T@x+regularizer,x.T@y[keep]) | |
| ridge = F.normalize(tx@weights,dim=-1).numpy() | |
| mean = F.normalize(y[keep].mean(0),dim=-1).numpy() | |
| constant = np.repeat(mean[None],len(tx),axis=0) | |
| probabilities = F.one_hot(tc,num_classes=3).float().numpy() | |
| # Ground-truth activity supplied only to isolate embedding performance; not deployable gates. | |
| rest = ty.numpy(),tc.numpy(),ts.numpy(),probabilities | |
| return {"ridge":(ridge,*rest),"constant_train_teacher_mean":(constant,*rest)} | |
| def grouped_metrics(pred, dataset, threshold): | |
| result = {} | |
| scenarios = sorted(set(r.get("scenario","unspecified") for r in dataset.records)) | |
| for scenario in scenarios: | |
| mask = np.array([dataset.records[i[0]].get("scenario","unspecified")==scenario for i in dataset.index]) | |
| subset = tuple(x[mask] for x in pred) | |
| speakers = set(subset[3][subset[2]==0].tolist()) | |
| if len(speakers) >= 2: | |
| result[scenario] = evaluate(subset,dataset.teachers,threshold) | |
| else: | |
| result[scenario] = {"windows":int(mask.sum()),"speakers":len(speakers),"note":"Too few identities for verification"} | |
| return result | |
| def stable_context_metrics(pred,dataset,threshold): | |
| """Diagnostic only: ground-truth-pure contexts exclude boundary contamination.""" | |
| selected = [] | |
| for arr,end,cls,speaker in dataset.index: | |
| ids = dataset.speaker_frames[arr][max(0,end-dataset.context):end] | |
| selected.append(cls==0 and len(ids)>=dataset.context and (ids==speaker).mean()>=.9) | |
| mask = np.array(selected) | |
| if len(set(pred[3][mask].tolist()))<2: | |
| return {"windows":int(mask.sum()),"note":"Too few identities"} | |
| value = evaluate(tuple(x[mask] for x in pred),dataset.teachers,threshold) | |
| value["note"] = "Oracle-selected contexts with >=90% frames from same target speaker; diagnostic, not deployable filtering" | |
| return value | |
| def teacher_oracle(root, validation, test): | |
| path = Path(root)/"probe_teacher_embeddings.npz" | |
| if not path.exists(): return {"available":False} | |
| with np.load(path,allow_pickle=False) as f: | |
| vectors, ids = f["embeddings"].astype(np.float32),f["speaker_idx"].astype(np.int64) | |
| vectors /= np.maximum(np.linalg.norm(vectors,axis=1,keepdims=True),1e-8) | |
| def subset(dataset): | |
| speakers = set(i[3] for i in dataset.index if i[3]>=0) | |
| mask = np.isin(ids,list(speakers)) | |
| n = int(mask.sum()) | |
| probs = np.zeros((n,3),np.float32) | |
| probs[:,0] = 1 | |
| return vectors[mask],dataset.teachers[ids[mask]],np.zeros(n,np.int64),ids[mask],probs | |
| val_metrics = evaluate(subset(validation),validation.teachers) | |
| test_metrics = evaluate(subset(test),test.teachers,val_metrics["far05_threshold"]) | |
| return {"available":True,"validation":val_metrics,"test":test_metrics, | |
| "note":"Frozen ECAPA on complete separate clean probe source utterances vs enrollment-only long utterances. More audio than a320ms student window: a quality ceiling/domain-shift control, not an equal-context speed comparison."} | |
| def write_report(results, destination): | |
| rows = "".join("<tr>"+"".join(f"<td>{html.escape(str(value))}</td>" for value in ( | |
| r["config"]["architecture"], r["config"]["width"], r["config"]["context"]*80, | |
| r["config"]["loss"], r["parameters"], round(r["validation"]["eer"]*100,2), | |
| round(r["validation"]["positive_cosine"],3), round(r["cpu_batch1_ms"],2), | |
| round(r["train_seconds"],1)))+"</tr>" for r in results["ablations"]) | |
| payload = html.escape(json.dumps({k:v for k,v in results.items() if k != "ablations"}, indent=2)) | |
| test,selected = results["test"],results["selected"] | |
| comparison = [("Selected student",test)] | |
| comparison += [(name,value["test"]) for name,value in results.get("embedding_controls",{}).items()] | |
| if results.get("long_clean_teacher_oracle",{}).get("available"): | |
| comparison.append(("Long clean ECAPA teacher",results["long_clean_teacher_oracle"]["test"])) | |
| controls = "".join(f"<tr><td>{html.escape(name)}</td><td>{v['eer']*100:.1f}</td><td>{v['positive_cosine']:.3f}</td><td>{v['closed_set_accuracy']*100:.1f}</td><td>{v['false_accept_rate']*100:.1f}</td><td>{v['false_reject_rate']*100:.1f}</td></tr>" for name,v in comparison) | |
| summary = f"""<h2>Measured finding</h2><p><strong>Keep automatic identity filtering off by default.</strong> | |
| The validation-selected {html.escape(selected['config']['architecture'].upper())} has {selected['parameters']:,} trainable parameters, | |
| {selected['config']['context']*80} ms of feature context and {selected['cpu_batch1_ms']:.3f} ms CPU batch-one inference. | |
| Held-out EER is {test['eer']*100:.1f}%; at the validation-calibrated approximately 5% false-accept operating point, | |
| the test false-reject rate is {test['false_reject_rate']*100:.1f}%. Speed is promising; this is not yet a reliable enrollment gate.</p> | |
| <table><tr><th>Held-out control</th><th>EER % ↓</th><th>Teacher cosine ↑</th><th>Closed-set % ↑</th><th>False accept % ↓</th><th>False reject % ↓</th></tr>{controls}</table> | |
| <p>The long-clean teacher sees full clean source utterances, while the student sees short noisy contextual windows: its result is an identity-quality control, not an equal-context benchmark. The constant embedding control demonstrates why high average cosine alone is not proof of speaker discrimination.</p>""" | |
| destination.write_text("""<!doctype html><html lang=en><meta charset=utf-8><meta name=viewport content='width=device-width, initial-scale=1'><title>TYGER V2 speaker-predictor pilot</title><style>body{max-width:1100px;margin:3rem auto;padding:0 1rem;font:17px/1.6 system-ui;color:#20313b;background:#fbfaf7}table{border-collapse:collapse;width:100%;font-size:14px}td,th{padding:.6rem;border:1px solid #ccc;text-align:left}pre{white-space:pre-wrap;background:#eee;padding:1rem}a{color:#255b83}.warning{border-left:5px solid #b07020;padding:1rem;background:#fff1d8}</style><h1>TYGER V2: held-out speaker-embedding pilot</h1><p class=warning>Exploratory synthetic-voice pilot, not a validated biometric system. Architecture and thresholds are selected on validation identities, never on test identities. Window-level trials are correlated; small speaker counts do not support population-level claims.</p><p>The original TYGER GRU and turn-taking rules are unchanged. This extra network consumes frozen Nemotron ASR layer-15 features (1024 dimensions / 80 ms), predicts normalized SpeechBrain ECAPA-192 embeddings, and emits speech/silence/ambiguous-overlap indicators. Confidence is not authentication.</p>"""+summary+"<h2>Architecture ablations (validation only)</h2><table><tr><th>Model</th><th>Width</th><th>Context ms</th><th>Loss</th><th>Parameters</th><th>EER %</th><th>Teacher cosine</th><th>CPU batch1 ms</th><th>Train s</th></tr>"+rows+"</table><h2>Selected model, untouched held-out test and protocols</h2><details><summary>Complete machine-readable results and protocols</summary><pre>"+payload+"</pre></details><p>Accumulation metrics use ground-truth speaker-change resets and therefore are an optimistic analysis, not deployment results. Unknown rejection enrolls alternating held-out speakers and treats the remainder as unseen. Enrollment uses clean long-utterance teacher embeddings, not the probe prediction.</p></html>") | |
| def main(): | |
| p = argparse.ArgumentParser() | |
| p.add_argument("--data", default="speaker_v2/pilot") | |
| p.add_argument("--out", default="speaker_v2/results") | |
| p.add_argument("--artifacts", default="speaker_v2/artifacts") | |
| p.add_argument("--epochs", type=int, default=30) | |
| p.add_argument("--device", default="cpu") | |
| p.add_argument("--threads", type=int, default=6) | |
| p.add_argument("--quick", action="store_true") | |
| p.add_argument("--balanced-activity", action="store_true", help="Inverse-square-root class-frequency auxiliary loss") | |
| p.add_argument("--max-windows", type=int, default=12000, help="Per-split pilot cap, not a scalable full-corpus loader") | |
| p.add_argument("--centered-target",action="store_true",help="Train-only mean/residual direction targets, reconstruct standard ECAPA output") | |
| p.add_argument("--only-config",help='One predefined config JSON, no adaptive extra ablations') | |
| args = p.parse_args() | |
| torch.set_num_threads(args.threads) | |
| torch.set_num_interop_threads(1) | |
| torch.backends.cuda.matmul.allow_tf32 = False | |
| torch.backends.cudnn.allow_tf32 = False | |
| out = Path(args.out) | |
| out.mkdir(parents=True, exist_ok=True) | |
| cache = {} | |
| def dataset(split, context): | |
| key = split, context | |
| if key not in cache: cache[key] = Windows(args.data, split, context,args.max_windows) | |
| return cache[key] | |
| speaker_sets = {s:set(x[3] for x in dataset(s,12).index if x[3]>=0) for s in ("train","val","test")} | |
| for a,b in (("train","val"),("train","test"),("val","test")): | |
| if speaker_sets[a] & speaker_sets[b]: | |
| raise ValueError(f"Speaker leakage between {a} and {b}") | |
| configs = [{"architecture": a, "width": w, "context": 12, "loss": "mixed", "balanced_activity":args.balanced_activity,"centered_target":args.centered_target} | |
| for a in ("mlp", "gru", "tcn") for w in ((96,) if args.quick else (96,192))] | |
| if args.only_config: | |
| configs = [{**json.loads(args.only_config),"balanced_activity":args.balanced_activity,"centered_target":args.centered_target}] | |
| results = [] | |
| models = {} | |
| def run(cfg): | |
| print(json.dumps({"starting": cfg}), flush=True) | |
| r, model = train_one(cfg, dataset("train",cfg["context"]), dataset("val",cfg["context"]), | |
| out, args.device, args.epochs, 1729) | |
| results.append(r) | |
| models[r["checkpoint"]] = model.cpu() | |
| (out / "progress.json").write_text(json.dumps(results, indent=2)) | |
| print(json.dumps({k:v for k,v in r.items() if k != "history"}), flush=True) | |
| for cfg in configs: run(cfg) | |
| chosen = min(results, key=lambda r:(r["validation"]["eer"], -r["validation"]["positive_cosine"]))["config"] | |
| extra = [{**chosen,"context": c} for c in (4,8,25)] + [{**chosen,"loss": l} for l in ("cosine","mse")] | |
| if args.only_config: extra = [] | |
| for cfg in extra: run(cfg) | |
| best = min(results, key=lambda r:(r["validation"]["eer"], -r["validation"]["positive_cosine"])) | |
| model = models[best["checkpoint"]] | |
| test = dataset("test",best["config"]["context"]) | |
| pred = predictions(model,test,"cpu") | |
| threshold = best["validation"]["far05_threshold"] | |
| payload = {"protocol_version": 1, "ablations": results, "selected": {k:v for k,v in best.items() if k != "history"}, | |
| "threshold_source": "validation held-out identity impostor 95th percentile (~5% FAR); test labels never calibrate", | |
| "test": evaluate(pred,test.teachers,threshold), | |
| "test_by_scenario": grouped_metrics(pred,test,threshold), | |
| "test_stable_contexts_oracle":stable_context_metrics(pred,test,threshold), | |
| "accumulation_test_oracle_resets": accumulation_metrics(pred,test,threshold), | |
| "dataset": {s: {"windows":len(dataset(s,12)),"speakers":len(set(x[3] for x in dataset(s,12).index if x[3]>=0))} for s in ("train","val","test")}, | |
| "cost_estimate": {"500000_windows_one_epoch_seconds": 500000/best["training_windows_per_second"], | |
| "excludes": "download, ASR/teacher extraction, augmentation, validation; CPU pilot throughput is not GPU full-scale throughput"}} | |
| baseline_val = baseline_predictions(dataset("train",12),dataset("val",12)) | |
| baseline_test = baseline_predictions(dataset("train",12),dataset("test",12)) | |
| payload["embedding_controls"] = {} | |
| for name in baseline_val: | |
| v = evaluate(baseline_val[name],dataset("val",12).teachers) | |
| t = evaluate(baseline_test[name],dataset("test",12).teachers,v["far05_threshold"]) | |
| payload["embedding_controls"][name] = {"validation":v,"test":t,"activity_note":"oracle activity; compare embedding metrics only"} | |
| payload["loss_note"] = "For normalized vectors, 192*MSE = 2*(1-cosine). These losses change relative auxiliary-activity weighting, not embedding geometry." | |
| quality_path = Path(args.data)/"teacher_quality.json" | |
| if quality_path.exists(): payload["teacher_enrollment_quality"] = json.loads(quality_path.read_text()) | |
| payload["long_clean_teacher_oracle"] = teacher_oracle(args.data,dataset("val",12),dataset("test",12)) | |
| val_pred = predictions(model,dataset("val",best["config"]["context"]),"cpu") | |
| nonspeech = val_pred[4][val_pred[2]!=0,0] | |
| activity_threshold = float(max(.5,min(.95,np.quantile(nonspeech,.95)))) if len(nonspeech) else .9 | |
| payload["activity_calibration"] = {"threshold":activity_threshold,"source":"validation nonspeech speech-probability95thpercentile, bounded.5–.95", | |
| "test_false_speech_rate":float((pred[4][pred[2]!=0,0]>=activity_threshold).mean()), | |
| "test_speech_acceptance":float((pred[4][pred[2]==0,0]>=activity_threshold).mean()), | |
| "warning":"Activity confidence is not identity probability; microphone RMS gate is separate"} | |
| manifest = json.loads((Path(args.data)/"manifest.json").read_text()) | |
| payload["dataset_limitations"] = ["Only 32 synthetic identities and 4 held-out test identities; no real-microphone robustness claim.", | |
| "Alignment occupancy may mislabel untranscribed vocal bursts.",*manifest.get("limitations",[])] | |
| payload["source_metadata"] = {k:manifest.get(k) for k in ("dataset_repo","dataset_revision","source_license","attribution","teacher_repo","teacher_revision","pilot_revision")} | |
| records = manifest["conversations"] | |
| seconds = sum(r.get("duration_s",0) for r in records) | |
| feature_wall = sum(r.get("feature_seconds",0) for r in records) | |
| payload["feature_scaling"] = {"audio_seconds":seconds,"feature_call_wall_seconds":feature_wall, | |
| "feature_call_wall_rtf":feature_wall/max(seconds,1), | |
| "100000_x24s_feature_call_hours":100000*24*feature_wall/max(seconds,1)/3600, | |
| "all_frames_100000_x24s_float16_GB":100000*24/.08*1024*2/1e9, | |
| "500000_context25_windows_float16_GB":500000*25*1024*2/1e9, | |
| "note":"Feature call wall time includes local HTTP and serialization, excludes waveform construction/download; not isolated on-device ASR timing. Storage estimates exclude metadata and teacher caches."} | |
| (out / "results.json").write_text(json.dumps(payload,indent=2)) | |
| write_report(payload, out / "report.html") | |
| selected = torch.load(best["checkpoint"],map_location="cpu",weights_only=False) | |
| selected["activity_threshold"] = activity_threshold | |
| selected["verification_threshold"] = threshold | |
| selected["test"] = payload["test"] | |
| torch.save(selected,out / "best.pt") | |
| artifacts = Path(args.artifacts) | |
| artifacts.mkdir(parents=True,exist_ok=True) | |
| torch.save(selected,artifacts / "best.pt") | |
| test_result = payload["test"] | |
| passed = (test_result["eer"] <= .2 and test_result["false_accept_rate"] <= .1 | |
| and test_result.get("known_acceptance",0) >= .7 | |
| and (test_result.get("silence_recall") or 0) >= .8) | |
| selection = {"checkpoint":"best.pt","config":best["config"],"parameters":best["parameters"], | |
| "verification_threshold":threshold,"activity_threshold":activity_threshold, | |
| "threshold_source":payload["threshold_source"],"recommended_enabled":False, | |
| "pilot_pass":bool(passed),"test":test_result,"validation":best["validation"], | |
| "cpu_batch1_ms":best["cpu_batch1_ms"], | |
| "note":"Experimental synthetic-voice pilot only. Default identity gate remains off; held-out speakers are too few for real-user deployment claims. Confidence is activity/context, not calibrated identity probability.", | |
| "feature_source":selected["feature_source"],"teacher":selected["teacher"]} | |
| (artifacts / "selection.json").write_text(json.dumps(selection,indent=2)) | |
| print(json.dumps({"complete":str(out),"test":payload["test"]}),flush=True) | |
| if __name__ == "__main__": main() | |