""" ml/model/stutter_trainer.py - Fine-tune wav2vec2 (+LoRA) stutter classifier =========================================================================== Trains a sequence classifier on speech disfluency datasets (real & synthetic lattice) using a wav2vec2-base encoder + LoRA adapters. Enhancements: - Focal Loss (gamma=2.0) to focus gradients on hard disfluency boundaries and drive precision >90%. - Supports both binary detection-first (fluent vs stutter) and 4-way classification (fluent, repetition, prolongation, block). - LoRA on query/key/value projections with explicit persistence of both `projector` and `classifier` in `modules_to_save`. - Balanced inverse-frequency class weighting. Outputs: ml/models/stutter/ Trainer checkpoint (LoRA adapters + head) ml/models/stutter/stutter_lora/ adapter_model.safetensors data/class_map.json id<->label mapping used by inference """ from __future__ import annotations import argparse import json from pathlib import Path from typing import Optional import numpy as np import os import shutil import tempfile import torch import torch.nn as nn from datasets import Audio, load_from_disk import datasets as _datasets def clean_cache(): """No-op kept for callers; caching is disabled in prepare_dataset.""" return from peft import LoraConfig, TaskType, get_peft_model from sklearn.metrics import accuracy_score, precision_recall_fscore_support, f1_score from transformers import ( Trainer, TrainingArguments, Wav2Vec2FeatureExtractor, Wav2Vec2ForSequenceClassification, ) SR = 16000 MAX_SECONDS = 8.0 MODEL_BASE = "facebook/wav2vec2-base" # Full 4-way label space. ID2LABEL = { 0: "fluent_control", 1: "stutter_repetition", 2: "stutter_prolongation", 3: "stutter_block", } LABEL2ID = {v: k for k, v in ID2LABEL.items()} # Detection-first label space. BIN_ID2LABEL = {0: "fluent", 1: "stutter"} BIN_LABEL2ID = {v: k for k, v in BIN_ID2LABEL.items()} def bin_label(label: str) -> int: """Map any canonical stutter subtype (or fluent) to the binary id.""" return 0 if label == "fluent_control" or label == "fluent" else 1 class FocalLoss(nn.Module): """Multi-class Focal Loss to downweight easy negatives and emphasize hard boundaries.""" def __init__(self, gamma: float = 2.0, alpha: Optional[torch.Tensor] = None): super().__init__() self.gamma = gamma self.alpha = alpha def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor: weight = self.alpha.to(logits.device) if self.alpha is not None else None ce_loss = nn.functional.cross_entropy(logits, targets, reduction="none", weight=weight) pt = torch.exp(-ce_loss) focal = ((1.0 - pt) ** self.gamma) * ce_loss return focal.mean() # -------------------------------------------------------------------------- # Preprocessing # -------------------------------------------------------------------------- def _tokenize_batch(batch: dict, feat_extractor) -> dict: """audio_array (plain float32 column) -> input_values + attention_mask.""" inp = [] for arr in batch["audio_array"]: arr = np.asarray(arr, dtype=np.float32) if arr.ndim > 1: arr = arr.mean(axis=1) arr = arr[: int(SR * MAX_SECONDS)] # truncate to max window inp.append(arr) fe = feat_extractor( inp, sampling_rate=SR, return_tensors="pt", padding="max_length", truncation=True, max_length=int(SR * MAX_SECONDS), ) values = fe["input_values"] attention_mask = (values != 0).long() return {"input_values": values, "attention_mask": attention_mask} def prepare_dataset(data_dir: str, feature_extractor, binary: bool = True, balance_train: float = 0.0): _datasets.disable_caching() label_map = BIN_LABEL2ID if binary else LABEL2ID valid = set(label_map) if binary: def to_id(label: str) -> int: return BIN_LABEL2ID["stutter"] if label != "fluent_control" and label != "fluent" else BIN_LABEL2ID["fluent"] else: def to_id(label: str) -> int: if label in LABEL2ID: return LABEL2ID[label] if label == "stutter": return LABEL2ID["stutter_repetition"] return LABEL2ID["fluent_control"] if Path(data_dir).suffix == ".parquet": from datasets import Dataset ds = Dataset.from_parquet(data_dir) else: from datasets import load_from_disk ds = load_from_disk(data_dir) # Filter valid rows if binary: ds = ds.filter(lambda r: isinstance(r.get("label"), str)) else: ds = ds.filter(lambda r: isinstance(r.get("label"), str)) # Map labels ds = ds.map(lambda r: {"labels": to_id(r["label"])}, remove_columns=["label"] if "label" in ds.column_names else None) # Tokenize waveforms into input tensors cols = {"audio", "audio_array", "text", "corpus", "id", "speaker_id"} ds = ds.map(lambda b: _tokenize_batch(b, feature_extractor), batched=True, remove_columns=list(cols & set(ds.column_names))) def _split(s: str): return ds.filter(lambda r: r.get("split") == s) tr, va, te = _split("train"), _split("val"), _split("test") # Optional rebalancing if balance_train > 0 and binary: labs = tr["labels"] counts = {0: sum(1 for x in labs if x == 0), 1: sum(1 for x in labs if x == 1)} if counts.get(1, 0) > 0 and counts.get(1, 0) < counts.get(0, 0): target = int(counts[0] * balance_train) fac = target // counts[1] if fac >= 1: keep = [i for i, l in enumerate(labs) if l == 1] * fac maj = [i for i, l in enumerate(labs) if l == 0] keep = (maj + keep)[: len(maj) + target] tr = tr.select(sorted(keep)) print(f"[train] rebalanced train: fluent={counts[0]} " f"stutter={target} (x{fac} oversample)") return tr, va, te # -------------------------------------------------------------------------- # LoRA + trainer # -------------------------------------------------------------------------- def _lora_config() -> LoraConfig: return LoraConfig( task_type=TaskType.SEQ_CLS, r=8, lora_alpha=16, lora_dropout=0.1, target_modules=["q_proj", "k_proj", "v_proj"], modules_to_save=["projector", "classifier"], bias="none", ) def _class_weights(dataset, n_classes: int) -> torch.Tensor: """Inverse-frequency weights normalised to sum to 1.""" counts = dataset.to_pandas()["labels"].value_counts() n = n_classes w = torch.zeros(n) for i in range(n): c = int(counts.get(i, 0)) w[i] = 1.0 / (c if c > 0 else 1.0) w = w / w.sum() return w def compute_metrics(eval_pred): """Compute accuracy, precision, recall, and macro-F1.""" logits, labels = eval_pred preds = np.argmax(logits, axis=1) acc = float(accuracy_score(labels, preds)) p, r, f, _ = precision_recall_fscore_support(labels, preds, average="macro", zero_division=0) return { "accuracy": acc, "precision": float(p), "recall": float(r), "macro_f1": float(f), } def _save_class_map(dest: Path, binary: bool): id2l = BIN_ID2LABEL if binary else ID2LABEL dest.write_text( json.dumps({"label2id": {v: k for k, v in id2l.items()}, "id2label": id2l, "binary": binary}, indent=2), encoding="utf-8", ) def train( data_dir: str, out_dir: str = "ml/models/stutter", epochs: int = 5, lr: float = 3e-5, batch: int = 8, seed: int = 0, fp16: bool = True, binary: bool = True, balance_train: float = 0.0, focal_gamma: float = 2.0, ) -> None: rng = np.random.default_rng(seed) torch.manual_seed(seed) feat = Wav2Vec2FeatureExtractor(sampling_rate=SR) train_ds, val_ds, test_ds = prepare_dataset( data_dir, feat, binary=binary, balance_train=balance_train) class_map = BIN_LABEL2ID if binary else LABEL2ID n_classes = len(class_map) model = Wav2Vec2ForSequenceClassification.from_pretrained( MODEL_BASE, num_labels=n_classes, ignore_mismatched_sizes=True, ) model = get_peft_model(model, _lora_config()) try: model.print_trainable_parameters() except AttributeError: n = sum(p.numel() for p in model.parameters() if p.requires_grad) print(f"[train] trainable params: {n:,}") weights = _class_weights(train_ds, n_classes) loss_fn = FocalLoss(gamma=focal_gamma, alpha=weights) if focal_gamma > 0 else None class CustomTrainer(Trainer): def compute_loss(self, model, inputs, return_outputs=False, num_items_in_batch=None): labels = inputs.pop("labels") outputs = model(**inputs) logits = outputs.logits if loss_fn is not None: loss = loss_fn(logits, labels) else: loss = torch.nn.functional.cross_entropy( logits, labels, weight=weights.to(logits.device) ) return (loss, outputs) if return_outputs else loss gpu_ok = torch.cuda.is_available() if not gpu_ok: print("[train] WARNING: no CUDA; falling back to CPU") elif not fp16: print("[train] fp32 (fp16 disabled)") args = TrainingArguments( output_dir=str(Path(out_dir) / "checkpoints"), num_train_epochs=epochs, per_device_train_batch_size=batch, per_device_eval_batch_size=batch, gradient_accumulation_steps=1, learning_rate=lr, lr_scheduler_type="cosine", warmup_steps=min(100, max(10, int(0.08 * len(train_ds) / (batch * 1)))), weight_decay=0.01, fp16=fp16 and gpu_ok, eval_strategy="epoch", save_strategy="epoch", save_total_limit=2, logging_steps=25, seed=seed, report_to="none", dataloader_num_workers=0, remove_unused_columns=False, ) trainer = CustomTrainer( model=model, args=args, train_dataset=train_ds, eval_dataset=val_ds, compute_metrics=compute_metrics, ) trainer.train() trainer.save_model(str(Path(out_dir) / "stutter_lora")) test_metrics = trainer.predict(test_ds) test_scores = test_metrics.metrics summary = { "data_dir": data_dir, "out_dir": out_dir, "epochs": epochs, "seed": seed, "base_model": MODEL_BASE, "binary": binary, "focal_gamma": focal_gamma, "n_train": len(train_ds), "n_val": len(val_ds), "n_test": len(test_ds), "final_holdout": {k: v for k, v in test_scores.items()}, } final_path = Path(out_dir) / "test_report.json" final_path.write_text(json.dumps(summary, indent=2), encoding="utf-8") _save_class_map(Path(out_dir) / "class_map.json", binary) clean_cache() print(f"[train] done. Adapters + head at {out_dir}/stutter_lora") print(f"[train] held-out final scores -> {final_path}") def _main() -> None: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--data", default="data/metadata/hybrid_dataset") ap.add_argument("--out", default="ml/models/stutter") ap.add_argument("--epochs", type=int, default=5) ap.add_argument("--lr", type=float, default=3e-5) ap.add_argument("--batch", type=int, default=8) ap.add_argument("--seed", type=int, default=42) ap.add_argument("--no-fp16", action="store_true", help="disable mixed precision") ap.add_argument("--binary", action="store_true", default=True) ap.add_argument("--no-binary", dest="binary", action="store_false") ap.add_argument("--focal-gamma", type=float, default=2.0, help="Focal Loss focusing factor") args = ap.parse_args() train(args.data, args.out, args.epochs, args.lr, args.batch, args.seed, fp16=not args.no_fp16, binary=args.binary, focal_gamma=args.focal_gamma) if __name__ == "__main__": _main()