nida-tts / src /nida /train /runner.py
Bilal7Dev's picture
Nida v0.5 (research preview): weights, 16 voices, code, model card
c0bce59 verified
Raw History Blame Contribute Delete
14.7 kB
"""``nida train``: config + latent dataset directory -> a (resumable) training run.
Workspace layout under ``--out``::
checkpoints/ckpt-<step>.pt atomic, newest ``keep_last`` kept
metrics.jsonl train/eval records (replayed steps are removed on resume)
run_info.json config, seed, git commit, data fingerprint, load reports,
``data_dirs`` (all dataset directories, in load order),
``dataset_meta`` (the single directory's ``meta.json``, null
for several directories) and ``dataset_metas`` (one
``meta.json`` or null per directory)
(``resume_info-<step>.json`` when a run is resumed)
data_rejections.jsonl every item rejected at load time, with the reason
latent_stats.json per-channel mean/std the model was trained on (inference
needs it to de-normalise generated latents)
config.yaml verbatim copy of the training config file
"""
from __future__ import annotations
import json
import logging
import subprocess
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
import torch
from nida.train.checkpoint import list_checkpoints
from nida.train.config import TrainConfig, load_train_config
from nida.train.data import (
LANGUAGES,
DataFormatError,
LatentDataset,
LoadReport,
load_latent_datasets,
)
from nida.train.loop import CHECKPOINT_DIR, ResumeError, Trainer, TrainResult
from nida.utils.io import atomic_write_json, atomic_write_text
__all__ = ["TrainOutcome", "check_languages", "render_report", "resolve_git_commit", "run_training"]
log = logging.getLogger(__name__)
@dataclass
class TrainOutcome:
"""What ``run_training`` returns: the raw result plus the JSON/markdown summaries."""
result: TrainResult
metrics: dict[str, object]
report_md: str
def resolve_git_commit(explicit: str | None, repo_dir: Path) -> str | None:
"""``explicit`` if given, else ``git rev-parse HEAD`` in ``repo_dir`` (None if unavailable)."""
if explicit is not None:
return explicit
try:
out = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=repo_dir,
capture_output=True,
text=True,
check=True,
timeout=10,
)
except (OSError, subprocess.SubprocessError):
return None
return out.stdout.strip() or None
def _rejection_lines(reports: list[LoadReport]) -> str:
lines = [
json.dumps(
{"split": r.split, "segment_id": x.segment_id, "reason": x.reason, "detail": x.detail},
ensure_ascii=False,
sort_keys=True,
)
for r in reports
for x in r.rejections
]
return "".join(f"{line}\n" for line in lines)
def _read_dataset_meta(data_dir: Path) -> object:
"""The directory's ``meta.json`` content, or None when it has none."""
meta_path = data_dir / "meta.json"
if not meta_path.is_file():
return None
return json.loads(meta_path.read_text(encoding="utf-8"))
def _pick_device(name: str | None) -> torch.device:
if name is None or name == "auto":
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
return torch.device(name)
def check_languages(n_languages: int, datasets: Sequence[LatentDataset]) -> None:
"""Refuse data whose languages the model cannot represent (ADR-0007), before training.
``n_languages == 0`` (no language embedding) accepts only Turkish (id 0); otherwise every
segment's language id must be below ``n_languages``.
Raises:
DataFormatError: naming the first offending language and how many segments use it.
"""
allowed = max(n_languages, 1)
counts: dict[str, int] = {}
for data in datasets:
for seg in data.segments:
if LANGUAGES.index(seg.language) >= allowed:
counts[seg.language] = counts.get(seg.language, 0) + 1
if counts:
raise DataFormatError(
f"model n_languages={n_languages} cannot represent languages {counts} "
f"(order {LANGUAGES}); set model.overrides.n_languages"
)
def run_training(
config_path: Path,
data_dirs: Path | Sequence[Path],
out_dir: Path,
*,
resume: bool,
device: str | None = None,
git_commit: str | None = None,
repo_dir: Path | None = None,
handle_sigterm: bool = True,
init_from: Path | None = None,
) -> TrainOutcome:
"""Load config and data, train, and summarise.
``data_dirs`` is one latent dataset directory or several, trained on as their union
(:func:`nida.train.data.load_latent_datasets`); latent stats come from the merged
train split.
``resume=True`` continues from the newest checkpoint in ``out_dir`` (starting fresh,
with a warning, when there is none -- so a restart loop can always pass ``--resume``).
``resume=False`` refuses to run when checkpoints already exist.
``init_from`` (a checkpoint of another run with the same model config, or the parent of
a ``dur_cond`` "none" -> "emotion" step, see :meth:`Trainer.init_from`) starts a fresh
run -- a fine-tune -- from its weights and latent stats (:meth:`Trainer.init_from`). It
applies only when no checkpoint of this run was resumed, so ``--resume --init-from``
restarts safely: the first launch inherits the weights, later launches continue the
fine-tune's own checkpoints.
Raises:
ConfigError, DataFormatError, BudgetError, ResumeError, CheckpointError: see the
respective modules; nothing is swallowed.
"""
cfg = load_train_config(config_path)
dirs = [data_dirs] if isinstance(data_dirs, Path) else list(data_dirs)
dirs_label = ", ".join(str(d) for d in dirs)
# E11: the aligned text path also needs each directory's timings.jsonl.
timings = cfg.model_config().text_path == "aligned"
train_data, train_report = load_latent_datasets(dirs, "train", timings=timings)
try:
val_data, val_report = load_latent_datasets(dirs, "val", timings=timings)
except Exception:
train_data.close()
raise
try:
if len(train_data) == 0:
raise DataFormatError(
f"no usable training items in {dirs_label} "
f"(rejections: {train_report.rejection_counts})"
)
check_languages(cfg.model_config().n_languages, [train_data, val_data])
if len(val_data) == 0 and cfg.eval_every is not None:
log.warning(
"no usable validation items in %s: evaluation (eval_every=%s) is skipped",
dirs_label,
cfg.eval_every,
)
checkpoint_dir = out_dir / CHECKPOINT_DIR
existing = list_checkpoints(checkpoint_dir)
if existing and not resume:
raise ResumeError(
f"{checkpoint_dir} already holds checkpoints (latest step {existing[-1][0]}); "
"pass --resume to continue or choose a fresh --out"
)
commit = resolve_git_commit(git_commit, repo_dir or Path.cwd())
trainer = Trainer(
cfg,
train_data,
out_dir,
val_data=val_data,
device=_pick_device(device),
git_commit=commit,
)
out_dir.mkdir(parents=True, exist_ok=True)
atomic_write_text(
out_dir / "data_rejections.jsonl", _rejection_lines([train_report, val_report])
)
atomic_write_text(out_dir / "config.yaml", config_path.read_text(encoding="utf-8"))
dataset_metas = [_read_dataset_meta(d) for d in dirs]
resumed_path = trainer.resume_latest() if resume else None
if resume and resumed_path is None:
log.warning("--resume given but %s has no checkpoint; starting fresh", checkpoint_dir)
if resumed_path is None and init_from is not None:
trainer.init_from(init_from)
elif init_from is not None and trainer.init_from_path != str(init_from.resolve()):
log.warning(
"--init-from %s ignored: resumed %s, whose run started from %s",
init_from,
resumed_path,
trainer.init_from_path,
)
atomic_write_json(out_dir / "latent_stats.json", trainer.latent_stats.to_dict())
info_name = (
"run_info.json" if resumed_path is None else f"resume_info-{trainer.step:09d}.json"
)
atomic_write_json(
out_dir / info_name,
{
"config": cfg.to_dict(),
"seed": cfg.seed,
"git_commit": commit,
"device": str(trainer.device),
"dataset_fingerprint": train_data.fingerprint(),
"resumed_from": None if resumed_path is None else str(resumed_path),
"init_from": trainer.init_from_path,
"init_from_model_diff": trainer.init_from_model_diff,
"latent_stats_inherited": trainer.latent_stats_inherited,
"data": {"train": train_report.to_dict(), "val": val_report.to_dict()},
"data_dirs": [str(d) for d in dirs],
"dataset_meta": dataset_metas[0] if len(dirs) == 1 else None,
"dataset_metas": dataset_metas,
"prompt_pairing": {
**cfg.prompt_pairing.to_dict(),
**trainer.speaker_dirs.to_dict(),
},
},
)
result = trainer.run(handle_sigterm=handle_sigterm)
num_params = sum(p.numel() for p in trainer.model.parameters())
metrics = _metrics(cfg, result, train_report, val_report, trainer, commit, num_params)
finally:
_close(train_data, val_data)
return TrainOutcome(result, metrics, render_report(metrics))
def _close(*datasets: LatentDataset) -> None:
for dataset in datasets:
dataset.close()
def _metrics(
cfg: TrainConfig,
result: TrainResult,
train_report: LoadReport,
val_report: LoadReport,
trainer: Trainer,
commit: str | None,
num_params: int,
) -> dict[str, object]:
return {
"completed": result.completed,
"stopped_by_signal": result.stopped_by_signal,
"steps_done": result.steps_done,
"total_steps": cfg.steps,
"last_loss": result.last_loss,
"mean_frames_per_s": result.mean_frames_per_s,
"resumed_from_step": result.resumed_from_step,
"evals": result.evals,
"checkpoints": result.checkpoints,
"best_checkpoint": result.best_checkpoint,
"best_val_loss": trainer.best_val_loss,
"seed": cfg.seed,
"git_commit": commit,
"device": str(trainer.device),
"model": cfg.model.name,
"num_params": num_params,
"overfit_one_batch": cfg.overfit_one_batch,
# E12: the pairing config, the matched speaker directories and the whole-run totals
# (cross-segment prompts given, and why selected items kept the in-clip prompt).
"prompt_pairing": {
**cfg.prompt_pairing.to_dict(),
**trainer.speaker_dirs.to_dict(),
"totals": trainer.pairing_totals.to_dict(),
},
"dataset_fingerprint": trainer.train_data.fingerprint(),
"out_dir": str(trainer.out_dir),
"data": {"train": train_report.to_dict(), "val": val_report.to_dict()},
}
def render_report(metrics: dict[str, object]) -> str:
"""Markdown run report from the ``metrics`` dict (a human-readable summary)."""
def show(value: object) -> str:
return "-" if value is None else str(value)
state = (
"completed"
if metrics["completed"]
else (
"INTERRUPTED by SIGTERM (checkpoint saved, resume with --resume)"
if metrics["stopped_by_signal"]
else "stopped early"
)
)
pairing = metrics["prompt_pairing"]
assert isinstance(pairing, dict)
totals = pairing["totals"]
pairing_line = (
"off (in-clip prompt)"
if pairing["cross_p"] == 0
else f"cross_p={pairing['cross_p']}, prompt {pairing['min_prompt_frames']}.."
f"{pairing['max_prompt_frames']} frames, speaker dirs {pairing['matched_dirs']}; "
f"{totals['paired']} of {totals['items']} items cross-segment, "
f"fallbacks {totals['fallbacks']}"
)
lines = [
"# train",
"",
f"- state: {state}",
f"- steps: {metrics['steps_done']} / {metrics['total_steps']}",
f"- resumed from step: {show(metrics['resumed_from_step'])}",
f"- last loss: {show(metrics['last_loss'])}",
f"- mean frames/s (this session): {show(metrics['mean_frames_per_s'])}",
f"- model: {metrics['model']} ({metrics['num_params']} parameters)",
f"- device: {metrics['device']}",
f"- seed: {metrics['seed']}",
f"- git commit: {show(metrics['git_commit'])}",
f"- overfit_one_batch: {metrics['overfit_one_batch']}",
f"- prompt pairing: {pairing_line}",
f"- dataset fingerprint: {metrics['dataset_fingerprint']}",
f"- workspace: {metrics['out_dir']}",
"",
"## Data",
"",
]
data = metrics["data"]
assert isinstance(data, dict)
for split in ("train", "val"):
rep = data[split]
lines.append(
f"- {split}: {rep['num_accepted']} accepted of {rep['num_in_split']} "
f"({rep['accepted_frames']} frames), rejected {rep['num_rejected']} "
f"{rep['rejection_counts']}"
)
evals = metrics["evals"]
assert isinstance(evals, list)
if evals:
lines += [
"",
"## Validation",
"",
"| step | val_loss | val_loss_ema | val_loss_ema (no text) |",
"|---|---|---|---|",
]
lines += [
f"| {int(e['step'])} | {e['val_loss']:.5f} | {e['val_loss_ema']:.5f} | "
f"{e.get('val_loss_ema_notext', float('nan')):.5f} |"
for e in evals
]
lines += [
"",
f"- best val_loss_ema: {show(metrics['best_val_loss'])} "
f"({show(metrics['best_checkpoint'])})",
]
checkpoints = metrics["checkpoints"]
assert isinstance(checkpoints, list)
if checkpoints:
lines += ["", "## Checkpoints written this session", ""]
lines += [f"- {c}" for c in checkpoints]
return "\n".join(lines) + "\n"