speech-model / ml /data /make_dataset.py
notUbaid's picture
Upload folder using huggingface_hub
49525ce verified
Raw
History Blame Contribute Delete
11.9 kB
"""
ml/data/make_dataset.py - Build the unified training/eval dataset
==================================================================
Merges gold real corpora (UCLASS, SEP-28k, LibriStutter, L2-ARCTIC, CMU-ARCTIC)
plus user recordings into a single HuggingFace ``Dataset`` with a clean
consistent schema and canonical label set.
Believability / anti-leak: the train/validation/test split is made by
**speaker**, never by random clip. The model must classify speakers it has
never listened to during training — the honest proof of generalization.
Audio decode bypass: remote corpora store audio as embedded ``bytes`` inside a
pyarrow ``struct<bytes, path>`` column. datasets' own ``Audio`` feature decoding
routes through ``torchcodec`` (an ffmpeg binding that is DLL-broken on Windows
+ torch 2.6). So we NEVER touch ``ds[i]`` (which triggers feature decoding); we
read the raw pyarrow ``ArrowTable`` columns directly and decode the `bytes`
ourselves with soundfile + resample to 16 kHz. ``torchcodec`` is never imported.
Output:
data/metadata/dataset/ serialized HF dataset
data/metadata/dataset.json provenance + counts
Usage:
python -m ml.data.download_corpora --only stutter_event uclass
python -m ml.data.make_dataset # all corpora
python -m ml.data.make_dataset --corpora uclass --seed 7
"""
from __future__ import annotations
import argparse
import io
import json
import re
from pathlib import Path
from collections import defaultdict
from typing import Optional
from datasets import Dataset, load_dataset, Audio, Features, Value, Sequence
import numpy as np
from ml.data.corpora_config import CORPUS_REGISTRY, LABEL_INDEX
from ml.data.download_corpora import CORPUS_LOAD
OUT_DIR = Path("data/metadata")
TARGET_SR = 16000
# Column layout of the unified dataset on disk. 'audio_array' is stored as a
# plain variable-length float32 array (NOT an HF Audio feature): many remote
# corpora carry path=None, and datasets 5.x crashes when save_to_disk tries to
# embed an Audio feature whose path is None. Keeping the raw 16k waveform as a
# Sequence is schema-trivial and round-trips reliably.
SCHEMA = Features({
"id": Value("string"),
"split": Value("string"),
"corpus": Value("string"),
"speaker_id": Value("string"),
"audio_array": Sequence(Value("float32"), length=-1),
"text": Value("string"),
"label": Value("string"),
})
# Canonical UCLASS class-code scheme (standard stutter-fluency labeling used by
# the UCLASS archive). 4 (interjection) and 7 are not unambiguous stutter
# subtypes in our LABEL_INDEX, so they are dropped here and the drop is
# documented in provenance — better a smaller honest set than a guessed label.
UCLASS_CLASS_MAP = {
"0": "fluent_control",
"1": "stutter_repetition", # part-word repetition
"2": "stutter_prolongation",
"3": "stutter_block",
"5": "stutter_repetition", # word repetition
"6": "stutter_repetition", # phrase repetition
# 4 (interjection) and 7 (unmapped) intentionally omitted
}
# ---------------- column auto-detection (schemas differ) -------------------
def _col(ds, *cands):
"""Return a column whose (lowercased) name contains a candidate token."""
low = {c.lower(): c for c in ds.column_names}
for c in cands:
if c.lower() in low:
return low[c.lower()]
for c in ds.column_names:
cl = c.lower()
if any(tok in cl for tok in cands):
return c
return None
def _find_audio(ds):
"""Locate the column carrying an Audio feature; else a descriptive name."""
for c in ds.column_names:
kind = getattr(ds.features[c], "__class__", None)
if kind is not None and kind.__name__ == "Audio":
return c
return _col(ds, "audio", "wav", "file", "path", "file_path")
def _speaker_from_audio_path(p: str, fallback: str) -> str:
"""UCLASS names interleave speaker id + age, e.g. F_0101_10y4m_1_segment_0."""
m = re.match(r"^([A-Za-z0-9]+_\d+)", Path(p).name)
return m.group(1).replace("_", "-") if m else fallback
def _decode_audio_dict(a) -> Optional[np.ndarray]:
"""Decode a raw pyarrow audio struct {bytes, path} -> 16k float32 mono."""
import soundfile as sf
import librosa
if isinstance(a, dict):
b = a.get("bytes")
if b:
raw, sr = sf.read(io.BytesIO(b), dtype="float32")
else:
p = a.get("path")
if not p or not Path(p).exists():
return None
raw, sr = sf.read(str(p), dtype="float32")
elif isinstance(a, str) and Path(a).exists():
raw, sr = sf.read(str(a), dtype="float32")
else:
return None
arr = raw.mean(axis=1) if raw.ndim > 1 else raw
if sr != TARGET_SR:
arr = librosa.resample(arr, orig_sr=sr, target_sr=TARGET_SR)
return arr.astype("float32")
def _tier_to_label(v: str, reg_key: str) -> str:
"""SEP-28k is a DETECTION-first corpus: every clip is `yes` (stutter) or
`no` (fluent), not a disfluency-subtype tier. Map those to the canonical
binary labels DIRECTLY — running them through the subtype heuristics would
turn stuttered clips (block/prolongation) into `fluent_control`.
"""
v = v.lower().strip()
if reg_key == "stutter_event":
return "stutter" if v in ("yes", "true", "1", "stutter") else "fluent_control"
if "rep" in v or "repetition" in v or "repeat" in v:
return "stutter_repetition"
if "par" in v or "prolongation" in v or "prolong" in v:
return "stutter_prolongation"
if "block" in v:
return "stutter_block"
return "fluent_control"
def build(corpora: Optional[list] = None, seed: int = 42):
"""Load configured corpora into a single HF Dataset with speaker split."""
if corpora is None:
corpora = list(CORPUS_LOAD)
records = []
provenance = {"built_utc": None, "seed": seed, "corpora": {}}
dropped = defaultdict(int)
for reg_key in corpora:
_, dsid, split = CORPUS_LOAD[reg_key]
meta = CORPUS_REGISTRY[reg_key]
cache = Path("data/corpora") / reg_key
print(f"[read] {meta['name']} ({reg_key}) <- {dsid} [{split}]")
ds = load_dataset(dsid, split=split, cache_dir=str(cache))
audio_col = _find_audio(ds)
if audio_col is None:
print(f" [!] no audio column for {reg_key}; skipping")
continue
text_col = _col(ds, "transcription", "transcript", "text", "prompt",
"reference", "word_sequence", "utterance", "sentence")
label_col = _col(ds, "label", "category", "disfluency", "tier",
"disfluency_tier", "type", "class", "onset")
speaker_col = _col(ds, "speaker_id", "speaker", "spk_id",
"client_id", "speaker_idx", "name")
# Read RAW pyarrow columns. Never ds[i] -> no torchcodec import.
tab = ds.data
audios = tab.column(audio_col).to_pylist()
texts = (tab.column(text_col).to_pylist() if text_col
else [""] * len(ds))
labels_raw = (tab.column(label_col).to_pylist() if label_col
else ["fluent_control"] * len(ds))
speakers_raw = (tab.column(speaker_col).to_pylist() if speaker_col
else ["unknown"] * len(ds))
n_ok = 0
for i in range(len(ds)):
arr = None
if audios[i] is not None:
try:
arr = _decode_audio_dict(audios[i])
except Exception:
arr = None # corrupt/undecodable clip -> drop silently
if arr is None:
dropped[reg_key] += 1
continue
if len(arr) == 0:
dropped[reg_key] += 1
continue
# label resolution per corpus
lr = str(labels_raw[i]).lower()
if reg_key == "uclass":
label = UCLASS_CLASS_MAP.get(lr)
if label is None:
dropped[reg_key] += 1 # 4 (interjection) / 7 unknown
continue
elif reg_key in ("stutter_event", "libristutter"):
label = _tier_to_label(lr, reg_key)
else:
label = lr if lr in LABEL_INDEX else "fluent_control"
spk = str(speakers_raw[i]) if speaker_col else "unknown"
if reg_key == "uclass" and spk == "unknown":
spk = _speaker_from_audio_path(str(audios[i].get("path", "")), spk)
# SEP-28k: carries no per-speaker id, only a per-clip `file` label.
# Treat each distinct audio path stem (or its full embedding id)
# as a separate "speaker" so SEP clips are never split randomly
# across train/test — the anti-leak guard still holds.
elif reg_key == "stutter_event" and spk == "unknown":
aud = audios[i] if isinstance(audios[i], dict) else {}
fname = str(aud.get("path") or "")
spk = (Path(fname).stem if fname
else f"sep:{i}")
rec = {
"id": f"{reg_key}:{i}",
"corpus": reg_key,
"speaker_id": spk,
"audio_array": arr,
"text": str(texts[i]),
"label": label,
}
records.append(rec)
n_ok += 1
print(f" [{reg_key}] {n_ok} rows kept out of {len(ds)}"
f" ({dropped[reg_key]} dropped/undecodable)")
provenance["corpora"][reg_key] = {
"rows": n_ok, "total": len(ds), "dropped": dropped[reg_key],
"audio_col": audio_col, "hf_id": dsid}
del ds
# Assemble HF Dataset. audio_array is a plain float32 column; the trainer
# reads it directly (no Audio path embedding, which breaks on path=None).
# 'split' is omitted here and added below via add_column (whole-speaker).
no_split = {k: v for k, v in SCHEMA.items() if k != "split"}
ds = Dataset.from_list(records, features=Features(no_split))
# ---------------- speaker-level held-out split ------------------------
split_col = splitter(ds, seed)
ds = ds.add_column("split", split_col)
counts = ds.to_pandas()["split"].value_counts().to_dict()
print("\nSpeaker-held-out split (grouped by speaker):")
for k in ("train", "val", "test"):
n = counts.get(k, 0)
if n > 0:
subset = ds.filter(lambda r: r["split"] == k)
lab = subset.to_pandas()["label"].value_counts().to_dict()
print(f" {k:6} {n:8} rows labels={lab}")
ds.save_to_disk(OUT_DIR / "dataset")
provenance["n_records"] = len(ds)
provenance["split_counts"] = counts
(OUT_DIR / "dataset.json").write_text(json.dumps(provenance, indent=2))
print(f"\nDataset written to {OUT_DIR/'dataset'} (seed={seed}, {len(ds)} rows)")
return ds
def splitter(ds: Dataset, seed: int = 42):
"""Whole-speaker assignment to train/val/test. Returns split column."""
buckets = defaultdict(list)
for i, s in enumerate(ds["speaker_id"]):
buckets[s].append(i)
rng = np.random.default_rng(seed)
keys = list(buckets)
rng.shuffle(keys)
n = len(keys)
tr = set(keys[: int(0.7 * n)])
va = set(keys[int(0.7 * n): int(0.85 * n)])
te = set(keys[int(0.85 * n):])
col = []
for s in ds["speaker_id"]:
col.append("train" if s in tr else ("val" if s in va else "test"))
return col
if __name__ == "__main__":
ap = argparse.ArgumentParser(description="Build unified HF dataset")
ap.add_argument("--corpora", nargs="*", default=None,
help="corpus keys; default all")
ap.add_argument("--seed", type=int, default=42)
args = ap.parse_args()
build(corpora=args.corpora, seed=args.seed)