Tiny AI — training¶
Train, export and register a tiny ONNX model with vmaf-train, the typer CLI in ai/. This page covers the training recipes. The full subcommand reference is vmaf-train. What a tiny model is and how to run one is in overview.md and inference.md.
Note
The models shipped today were trained against the vmaf_v0.6.1 teacher. The one-shot retrain against the vmaf_v1.0.16_3d0h teacher is RC9 work, see the roadmap and the retrain runbook.
Pick a track¶
| Track | Model | Config | Input | Output |
|---|---|---|---|---|
| C1 | Full-reference regressor on libvmaf features | fr_tiny_v1.yaml | 6 canonical features per frame | score in 0..100 |
| C2 | No-reference metric | nr_mobilenet_v1.yaml | decoded frames | score |
| C3 | Learned residual filter | filter_residual_v1.yaml | BVI-DVC encoder-distortion pairs | filtered frame |
Install¶
This pulls torch>=2.14.1,<3.0 and pytorch-lightning>=2.6.6,<3.0 (the lightning PyPI package was renamed to pytorch-lightning on 2026-04-30). If you have a GPU-capable PyTorch wheel installed separately, the extras do not reinstall it.
Commands¶
vmaf-train has 15 subcommands: extract-features, fit, tune, export, eval, manifest-scan, validate-norm, profile, audit-compat, check-ops, audit-learned-filter, quantize-int8, cross-backend, bisect-model-quality and register. Flags and examples for each are in vmaf-train. The training flow uses five of them:
| Step | Command | Purpose |
|---|---|---|
| 1 | extract-features | dump per-frame libvmaf features to parquet |
| 2 | fit | train from a YAML config |
| 3 | export | export a checkpoint to ONNX with a roundtrip check |
| 4 | eval | PLCC, SROCC and RMSE on a held-out split |
| 5 | register | write the sidecar metadata JSON |
Dataset acquisition¶
Datasets are not committed. ai/src/vmaf_train/data/datasets.py knows five canonical sources and caches them under ${VMAF_DATA_ROOT:-~/.cache/vmaf-train}/datasets/<name>/. Each dataset ships a manifests/<name>.yaml SHA-256 manifest so downloads are verifiable. The shipped manifests are empty: after fetching a dataset, regenerate its manifest with vmaf-train manifest-scan --dataset <name> --root <dir>.
| Dataset | Use | License | Purpose |
|---|---|---|---|
| Netflix Public (NFLX) | C1, C2 | Netflix research | Same source as upstream vmaf_v0.6.1 |
| KoNViD-1k | C2 | No licence named ("freely available to the research community") | NR-friendly UGC clips with MOS |
| LIVE-VQC | C2 | Academic | NR validation |
| YouTube-UGC | C2 | CC BY 3.0 | Large-scale NR |
| BVI-DVC | C3 | Academic | Encoder distortion pairs for learned filters |
You are responsible for complying with each dataset's license. The manifests record hashes, not bytes. The larger MOS corpora (KonViD-150k, LSVQ, CHUG, Waterloo IVC and others) are covered in mos-corpora.md, and the local Netflix layout in training-data.md.
C1 FR regressor¶
C1 has three entry points that share one model factory and one ONNX output layout. Use the parquet flow for the vmaf-train CLI, the Netflix corpus flow for the runnable ai/train/ pipeline, and the KoNViD flow to add a second corpus.
Parquet flow¶
-
Extract feature vectors from the dataset pairs with the libvmaf CPU backend. The
nflxmanifest must exist first (see Dataset acquisition). -
Train a 2-layer MLP on the extracted features.
--cacheoverrides thecache:path in the config;fithas no--featuresoption. -
Export the trained weights to ONNX and validate the roundtrip (torch eval against onnxruntime, default
--atol 1e-5).--modelselects the family (fr_regressor,nr_metricorlearned_filter). -
Evaluate on the held-out split. The command prints PLCC, SROCC and RMSE against MOS.
-
Write the sidecar next to the ONNX file. Use the license of your own training data and code; the shipped models in
model/tiny/registry.jsoncarryBSD-2-Clause-Patent,BSD-2-ClauseorMIT.
register writes <model>.json with this metadata (ai/src/vmaf_train/registry.py::ModelMetadata; unknown keys are rejected):
{
"schema_version": 1,
"name": "fr_tiny_v1",
"kind": "fr",
"opset": 17,
"input_names": ["features"],
"output_names": ["score"],
"normalization": { "mean": [], "std": [] },
"expected_output_range": [0.0, 100.0],
"dataset": "nflx",
"train_commit": "…",
"train_config_hash": "sha256:…",
"license": "BSD-2-Clause-Patent"
}
parent_dataset_manifest, cosign_signature (filled in by the release workflow) and notes are optional. train_config_hash is computed when --train-config is passed.
Note
Sidecars of models shipped under model/tiny/ come from the dedicated exporters and use a flatter layout (id, input_name, output_name, input_mean, input_std, onnx_has_scaler, ...). See model-registry.md.
Netflix corpus flow¶
Once the local Netflix corpus exists at .corpus/netflix/ (layout in training-data.md, scope in ADR-0242), the prep stack under ai/data/ and ai/train/ gives a runnable end-to-end pipeline instead of the parquet flow. ADR-0203 records the decisions: distillation source, val-split policy, architecture roster and cache layout.
-
Build libvmaf once, so the cache warm-up can call it.
-
Train. The first run pre-warms the per-clip cache at
$VMAF_TINY_AI_CACHE(default~/.cache/vmaf-tiny-ai); later runs only re-train. The defaults are--model-arch mlp_small,--val-source Tennisand--epochs 10.python ai/train/train.py \ --data-root .corpus/netflix \ --model-arch mlp_small \ --epochs 30 \ --batch-size 256 \ --lr 1e-3 \ --out-dir runs/tiny_nflxThe wrapper
bash ai/scripts/run_training.shis equivalent. -
Evaluate the final checkpoint on the validation split.
python -c " from pathlib import Path import numpy as np from ai.train.dataset import NetflixFrameDataset from ai.train.eval import evaluate val = NetflixFrameDataset(Path('.corpus/netflix'), split='val') X, y = val.numpy_arrays() report = evaluate( features=X, targets=y, onnx_path=Path('runs/tiny_nflx/mlp_small_final.onnx'), out_path=Path('runs/tiny_nflx/eval_report.json'), ) print(report) "The JSON report contains
n_samples,plcc,srocc,krocc,rmse,latency_ms_p50_per_clip,latency_ms_p95_per_clip,modelandfeature_dim. Latency is measured against a synthetic 240-frame clip on the CPU EP, because the point of a tiny model is to be meaningfully faster than the SVR.
Flags of ai/train/train.py¶
| Flag | Default | Notes |
|---|---|---|
--data-root | .corpus/netflix | Directory with ref/ and dis/. |
--model-arch | mlp_small | One of linear, mlp_small, mlp_medium. |
--epochs | 10 | 0 runs the smoke-export path and exits. |
--batch-size | 256 | SGD batch size. |
--lr | 1e-3 | Adam learning rate. |
--out-dir | runs/tiny_nflx | ONNX checkpoints land at <out-dir>/<arch>_epoch<n>.onnx and <arch>_final.onnx. |
--val-source | Tennis | Source name held out for validation. |
--max-pairs | unset | Cap on (ref, dis) pairs (smoke / debugging). |
--no-export-onnx | unset | Skip per-epoch ONNX dump (final still written). |
--assume-dims WxH | unset | For tests / mock corpora with non-1080p YUVs. |
Architectures¶
| Arch | Layers | Params (feature_dim=6) |
|---|---|---|
linear | Linear(6, 1) | 7 |
mlp_small | Linear(6,16) -> ReLU -> Linear(16,8) -> ReLU -> Linear(8,1) | 257 |
mlp_medium | Linear(6,64) -> ReLU -> Linear(64,32) -> ReLU -> Linear(32,1) | 2 561 |
Expected runtime¶
Indicative timings, not measured by CI:
| Phase | CPU-only (8-core) | CUDA (RTX 3060) |
|---|---|---|
| Cache warm (full corpus, 70 pairs) | 30–60 min (libvmaf-bound) | 5–8 min (libvmaf CUDA backend) |
Train 30 epochs mlp_small | 1–2 min | <30 s |
Train 30 epochs mlp_medium | 2–4 min | <60 s |
| ONNX export | <1 s | <1 s |
The cache is the bottleneck on the first run. Later runs re-use the JSON cache and skip libvmaf. To force a re-extract, delete $VMAF_TINY_AI_CACHE.
Smoke command¶
CI runs only the --epochs 0 smoke test, because the real corpus and a real training run do not fit a GitHub runner. The test is ai/tests/test_train_smoke.py; the equivalent shell command exports an initial-weights ONNX without touching the real corpus or invoking libvmaf, and is the documented reproducer in the PR template:
python ai/train/train.py \
--epochs 0 \
--data-root /tmp/mock_corpus \
--assume-dims 16x16 \
--val-source BetaSrc \
--out-dir /tmp/tiny_smoke
KoNViD-1k synthetic-distortion pairs¶
The 9-source Netflix Public corpus is fully used by the LOSO sweep. Research-0023 section 5 documents how the FoxBird outlier reflects content-distribution variance within those 9 clips. A different or larger training corpus reduces that variance. KoNViD-1k (the Konstanz natural video database: 1 200 user-generated clips at 540p with crowd-sourced MOS) is the natural starting point, available at $VMAF_DATA_ROOT/konvid-1k/ or $VMAF_KONVID_1K_DIR after ai/scripts/fetch_konvid_1k.py.
KoNViD-1k is no-reference (clip plus MOS), not (ref, dis) pairs. To produce the FR pairs the LOSO trainer expects, the acquisition step synthesises a distorted variant per clip with a libx264 CRF=35 round trip, the recipe used for the Netflix dis-pairs. It then runs libvmaf to extract the 6 canonical features and the per-frame VMAF teacher score for each pair (the teacher is resolved from a single source, ADR-1168 and ADR-1173).
Acquisition¶
-
Smoke run (5 clips, about 30 s wall):
-
Full run (1 200 clips, about 30 min wall on the ryzen-4090 profile):
Output is ai/data/konvid_vmaf_pairs.parquet (gitignored). The schema matches NetflixFrameDataset.numpy_arrays(): (key, frame_index, vif_scale0..3, adm2, motion2, vmaf, teacher_model) per row. The command also writes ai/data/konvid_vmaf_pairs.manifest.json by default, with CRF, feature names, clip and frame counts, failed clip IDs, the VMAF binary and model inputs, and run_provenance. Pass --manifest-out PATH when the parquet lives under a different experiment directory. Per-clip JSON caches live under $VMAF_TINY_AI_CACHE/konvid-1k/<key>.json, so re-runs are idempotent and only newly added clips re-extract.
Full-feature refresh¶
For the current full-feature FR refresh, use the fork CPU vmaf binary explicitly:
# smoke
python ai/scripts/konvid_to_full_features.py \
--konvid-root "$VMAF_KONVID_1K_DIR" \
--vmaf-bin core/build-cpu/tools/vmaf \
--max-clips 5
# full run
python ai/scripts/konvid_to_full_features.py \
--konvid-root "$VMAF_KONVID_1K_DIR" \
--vmaf-bin core/build-cpu/tools/vmaf
This writes runs/full_features_konvid.parquet plus runs/full_features_konvid_with_folds.parquet. The folded file adds source=fold0..fold4 using a deterministic balanced hash order over clip keys, so eval_multiseed_v3_v4.py can reproduce the KoNViD 5-fold gate without stale local parquet files.
The script also writes runs/full_features_konvid.manifest.json by default. It records the KoNViD root, resolved videos directory, cache directory, vmaf binary, model path, CRF/codec recipe, fold settings, selected and processed clip counts, row and column counts, and the ADR-0661 run_provenance block. Pass --manifest-out PATH to keep the sidecar inside a dated experiment bundle.
Combine refreshed FULL_FEATURES shards¶
After the Netflix, KoNViD, BVI-DVC and optional UGC refreshes finish, rebuild aggregate training tables with the combiner instead of manual pandas.concat:
python ai/scripts/combine_full_feature_parquets.py \
--input netflix=runs/full_features_netflix_refresh_20260520.parquet \
--input konvid=runs/full_features_konvid_refresh_20260520.parquet \
--input bvi=runs/full_features_bvi_dvc_D_refresh_20260520.parquet \
--out runs/full_features_4corpus_refresh_20260520.parquet
python ai/scripts/combine_full_feature_parquets.py \
--input base=runs/full_features_4corpus_refresh_20260520.parquet \
--input ugc=runs/full_features_ugc_refresh_20260520.parquet \
--out runs/full_features_5corpus_refresh_20260520.parquet
The combiner normalizes every input to corpus, source, frame_index, codec, teacher_model, <FULL_FEATURES>, vmaf, fills missing feature columns with NaN, and preserves the caller-provided corpus label. It also writes <out>.manifest.json by default, with per-input row counts, missing-feature fill lists, output column order, the aggregate corpus distribution and run_provenance. Pass --manifest-out PATH only when the manifest must live next to a separate experiment bundle.
The standalone KoNViD and BVI-DVC full-feature builders follow the same sidecar rule (runs/full_features_konvid.manifest.json and runs/full_features_bvi_dvc_<tier>.manifest.json by default), so each refreshed shard can be replayed before it is combined.
Teacher model provenance¶
Every producer distils from one teacher: the fork default model (vmaf_v1.0.16_3d0h, single-sourced from core/include/libvmaf/model.h and vmaftune.defaultmodel.DEFAULT_MODEL, ADR-1168; ADR-1173). The teacher is resolved in this order by ai.data.scores.resolve_teacher_model():
- an explicit override:
--vmaf-model(extract_full_features.py,extract_k150k_features.py) or--model(bvi_dvc_to_full_features.py,extract_ugc_features.py,konvid_to_full_features.py,konvid_to_vmaf_pairs.py,bvi_dvc_to_corpus_jsonl.py). It accepts a version name (vmaf_v0.6.1), aversion=orpath=libvmaf model spec, or a model JSON path; $VMAF_MODEL_PATH(a model JSON file);- the single-source default.
Every feature row and run manifest carries a teacher_model column naming the teacher that produced its vmaf target. Consumers refuse to mix teachers:
combine_full_feature_parquets.py,train_vmaf_tiny_v5.pyandeval_loso_vmaf_tiny_v5.pyraiseValueErrorwhen a shard contains more than oneteacher_model, when two inputs disagree, or when an input has noteacher_modelcolumn. Legacy tables (extracted before the column existed, that is withvmaf_v0.6.1) are accepted only with an explicit--assume-teacher vmaf_v0.6.1, which stamps that value on every row. The flag must match any stamp already present.- The
NetflixFrameDatasetper-clip cache ($VMAF_TINY_AI_CACHE, default~/.cache/vmaf-tiny-ai/) is revalidated on read. An entry whose stamped teacher differs from the resolved teacher, or a legacy entry with no stamp, is a miss and is recomputed (logged at INFO), never relabelled. Expect one full re-extraction the first time a pre-ADR-1173 cache is reused.
Mixed-teacher tables are never merged silently, and no flag overrides a genuine conflict.
Loader¶
ai/train/konvid_pair_dataset.py::KoNViDPairDataset mirrors the interface of NetflixFrameDataset: the same feature_dim (6) and the same numpy_arrays() → (X, y) shape, so the existing _train_loop consumes it without modification.
from ai.train.konvid_pair_dataset import KoNViDPairDataset
# all 1 200 clips
ds = KoNViDPairDataset("ai/data/konvid_vmaf_pairs.parquet")
# LOSO-style holdout: 1 clip val, rest train
val_keys = {ds.unique_keys[0]}
train_keys = set(ds.unique_keys) - val_keys
val_ds = KoNViDPairDataset("ai/data/konvid_vmaf_pairs.parquet", keep_keys=val_keys)
train_ds = KoNViDPairDataset("ai/data/konvid_vmaf_pairs.parquet", keep_keys=train_keys)
X, y = train_ds.numpy_arrays() # (n_train_frames, 6), (n_train_frames,)
Combine KoNViD with the Netflix corpus¶
ai/train/train_combined.py concatenates the Netflix NetflixFrameDataset train slice with the KoNViD KoNViDPairDataset train slice on the feature axis. It feeds the union to the same _build_model, _train_loop and export_onnx pipeline that ai/train/train.py uses, so the model factory and ONNX layout stay identical.
# Default: hold out the Netflix Tennis source for val; KoNViD is
# fully in training. Mirrors the canonical ADR-0203 split so the
# result is directly comparable to mlp_small / mlp_medium baselines.
python ai/train/train_combined.py \
--netflix-root .corpus/netflix \
--konvid-parquet ai/data/konvid_vmaf_pairs.parquet \
--model-arch mlp_small \
--epochs 30 \
--out-dir runs/tiny_combined
--val-mode selects the validation split:
| Mode | Validation set |
|---|---|
netflix-source (default) | Netflix --val-source (default Tennis) |
konvid-holdout | Deterministic 10 % of KoNViD clip keys |
netflix-source-and-konvid-holdout | Union of the two above |
netflix-only | KoNViD slice is omitted entirely |
konvid-only | Netflix slice is omitted entirely |
KoNViD train/val splits hold out whole clips, not random frames, keyed off --seed and --konvid-val-fraction, so frames from one clip cannot leak across the split. ONNX checkpoints land at <out-dir>/<arch>_combined_epoch<n>.onnx and <arch>_combined_final.onnx.
When the parquet is missing, the trainer prints a warning and falls back to the Netflix-only path. When both corpora are missing it exports an initial-weights ONNX and exits 0, so the smoke command still produces a deterministic artefact.
MOS label materialization¶
Real MOS-head training expects feature tables that already carry mos or mos_raw_0_100. If an extraction pass produced only metric columns, join the subjective labels first with ai/scripts/materialize_mos_labels.py. The command, its flags and the batch runner live in mos-label-materializer.md.
The KonViD MOS trainer rejects real-path inputs that yield zero labelled rows and writes no checkpoint. Use --smoke when the input is synthetic.
C2 NR metric¶
C2 uses the same flow with a different config, ai/configs/nr_mobilenet_v1.yaml. extract-features is replaced by a direct frame loader (frame_loader.py) that feeds ffmpeg-decoded tensors into training.
The loader accepts these pixel formats:
- single-channel
gray, asHxWarrays; - packed colour formats
rgb24,bgr24,rgbaandbgra, asHxWxCarrays.
Other FFmpeg pixel formats fail before the decoder is spawned, so training jobs never reinterpret planar or subsampled layouts as packed tensors.
C3 learned filter¶
ai/configs/filter_residual_v1.yaml trains a residual CNN where the model is clamped to x + residual in normalized space. The target is BVI-DVC encoder-distortion pairs.
Determinism¶
vmaf-train fit seeds Python, NumPy and PyTorch with the config's seed field and sets Lightning's deterministic=True. Given the same train_commit, train_config_hash, dataset_manifest_sha and seed, the output weights are reproducible to within float-rounding nondeterminism. CI flags a regression when that difference exceeds a tight allclose.
Hyperparameter sweeps¶
The ai[tune] extra pulls in Optuna and Ray Tune. vmaf-train tune wraps the Optuna sweep helper around a base YAML config and searches model_args entries. Each trial writes to <output>/trial_NNN, and the objective minimises the best validation loss (val/mse for regressors, val/l1 for learned filters) recorded by Lightning.
pip install -e 'ai[tune]'
vmaf-train tune \
--config ai/configs/fr_tiny_v1.yaml \
--output runs/fr_tiny_sweep \
--trials 20 \
--param hidden=choice:16,32,64 \
--param lr=float:0.0001:0.01:log
--param is repeatable and accepts three forms:
| Form | Example | Trial API |
|---|---|---|
name=float:LOW:HIGH[:log] | lr=float:0.0001:0.01:log | trial.suggest_float |
name=int:LOW:HIGH | depth=int:1:4 | trial.suggest_int |
name=choice:A,B,... | hidden=choice:16,32,64 | trial.suggest_categorical |
Values from choice are coerced to int, float or boolean when possible, otherwise they stay strings. Use --storage sqlite:///... to resume or share an Optuna study.
Authoring training scripts¶
Scripts under ai/scripts/ use the shared aiutils helpers and write a run_provenance block into every durable report. The block schema, the helper layer, the script bootstrap and the per-script report table are in run-provenance.md.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
extract-features is slow | libvmaf CPU-only | rebuild with -Denable_cuda=true and rerun |
fit OOM | batch size too big for GPU | edit ai/configs/*.yaml batch_size, or drop precision to 16-mixed |
| Export roundtrip fails atol=1e-5 | op using float16 with a value near inf | retrain in float32 end-to-end, or tighten clamping |