The classifier model

September 3, 2026 · View on GitHub

This page is for an ML engineer who wants to understand how the trained backend works as a model: what it sees, what it predicts, how each axis is learned, and where the design hits its ceiling. It is the implementation companion to classification-model.md (which explains why the output is a set of orthogonal axes) and stable-api.md (the method reference).

The runtime that loads a trained bundle is OnnxMediaClassifier, the trainer is training/train_sklearn.py, the dataset that supervises it is documented in dataset.md.


1. Feature representation

The model never sees raw text. Each utterance is reduced to a sparse 0/1 categorical feature dict by CategoricalFeatureExtractor: a feature is present (value "1") when its cue fired, absent otherwise. Two families of columns make up the vector.

Keyword / context columns, one per bundled .voc file, word-boundary matched through ovos-spec-tools (so art does not fire inside start). The stable column menu is _KEYWORD_VOCABS in features.py, the prefixes encode the cue family:

prefixwhat firedexamples
kw_*a media-type / form keywordkw_music, kw_movie, kw_anime, kw_adult, kw_hentai
verb_*a modality verbverb_audio (listen), verb_video (watch), verb_read, verb_tune
mod_*a structure / time modifiermod_episode, mod_season, mod_live, mod_continue, mod_latest
attr_*an attribute cueattr_topic (about …), attr_starring
fmt_*an explicit format hintfmt_audio_only, fmt_video_only

NER-by-construction columns, one ner_<label> per OCPEntityLabel. In the training data the flag is set when a {label} slot filled that row (ground truth, see dataset.md), at runtime a NER backend would set it when it tags an entity of that label.

Be honest about what these encode. The ner_* columns record the entity label that fired, that an artist_name was present, not the entity value text (which artist). The model knows "a music artist appears here", it cannot read "Adele" vs "Metallica". This is the load-bearing limitation of the whole representation and it recurs in §3 and §6.

meta.json in every bundle records the exact ordered feature_names the model was trained on. At inference OnnxMediaClassifier._vectorize walks that list and emits a dense float32 row in precisely that order, so the runtime never assumes a column layout, it reads it from the bundle.


2. Multi-task per-axis heads, the key design

A naive classifier predicts the leaf MediaType and derives every other axis from it. The trained backend instead predicts each axis with its own head, so an axis can be right even when the leaf is wrong. The trainer declares the heads in HEAD_SPECS (train_sklearn.py), the runtime runs whichever heads the bundle carries.

Single-label heads (argmax over their labels):

headaxislabel space
domainis this OCP at allocp_play / not_ocp
media_typethe leafthe 17 mediavocab.MediaType leaves the dataset exercises
playback_typemodalityaudio / video / paged / interactive
structuretemporal shapesingle / episodic / continuous / collection
explicitnessclean vs adultclean / adult
control_intenttransport controlplay / pause / next / … (degenerate in a play-only bundle, skipped)

Multi-label heads, a OneVsRestClassifier of logistic regressions emitting per-label probabilities, the runtime keeps every label whose probability is ≥ a per-label threshold (default 0.5, recorded in meta.json):

headaxiskindlabel space
content_form_genressensitive / content-form tagsmultiadult / anime / animation / asmr
content_genresthe real genre(s)multimediavocab.KNOWN_GENRES (capped to the top-CONTENT_GENRE_TOP_K = 40)
content_formexperiential kind (mediavocab.ContentForm)singletrailer / teaser / behind_scenes / excerpt / supplement / …
programme_formatstructural format (mediavocab.ProgrammeFormat)singledocumentary / news / concert / stand_up / sports / …
accessibilitya11y assets (mediavocab.AccessibilityKind)multisubtitles / audio_description / sign_language / …
variantwork-level cut (mediavocab.VariantKind)singledirectors / extended / remastered / colorized / fanedit / …
picture_formatpresentation attr (mediavocab.PictureFormat)multiblack_and_white / silent / 3d

mediavocab axes. Every descriptive head emits mediavocab's own vocabulary so the classifier and the resolver / providers share one taxonomy. The finer classifier cues collapse onto mediavocab's set (making_of / bloopers / deleted_scenes / featurette → behind_scenes, clip → excerpt, interview → supplement). black_and_white / silent / 3d are picture-presentation attributes (T6) that map to the mediavocab.PictureFormat axis (classify_picture_format), dubbed maps to mediavocab.AccessibilityKind. mood / era are dropped from the taxonomy (not axiom-admissible, a release year feeds Signals.year).

The content_form_genres head is the one the content filter reads. Because it is its own head, it can flag adult independently of the leaf, a request can be blocked even when the model is unsure whether it is a movie or an episodic_series. That is what keeps detect-to-block reliable (a single leaf mistake never unblocks adult content).

Soft-gating and the derive fallback

Predicting axes separately enables soft-gating: OnnxMediaClassifier trusts an axis head even when the leaf is uncertain, and the leaf head is domain-gated (a non-ocp_play domain short-circuits to GENERIC) rather than vetoed by a hard cascade. See _play_label, _media_type_from_head and classify_full in onnx.py.

Every per-axis method follows the same pattern: use the head when the bundle carries it, else fall back to the inherited derive/empty default. A head whose column was degenerate on the training data is simply skipped at train time (train_single_head returns skipped), its .onnx file is absent, and from_path derives that axis instead. This is what lets partial bundles, a bundle that ships only some of the heads, load and run unchanged, deriving the rest.


3. The ladder

Each head is trained and reported on a rising sequence of feature sets, the lift from one rung to the next is the headline result.

  1. rules, the deterministic bundled keyword classifier (no learning). The floor.
  2. context-only, the keyword columns only (kw_* / verb_* / mod_* / attr_* / fmt_*), ner_* excluded. This is the "works with no registered entities" baseline a fresh install sees, before any skill has populated a NER store.
  3. context+NER, the keyword columns plus the ner_* columns: the features a populated NER would surface.
  4. semantic, features that read the entity value text itself rather than just which cue fired: hashed character n-grams and trained domain word-vectors, shipped behind the onnx extra. §7 benchmarks this rung head-to-head against the sklearn ladder.

The two sklearn rungs are the FEATURE_SETS = ("context", "context_ner") in train_sklearn.py, feature_columns builds each column set. Semantic features matter because a bag of cue-presence flags cannot read entity value text (§1); the only way to recover the signal that lives inside the slot value (which genre, which mood, which decade) is to embed the surface string. §6 quantifies exactly which axes are starved without it, §7 is the experimental neural backend that adds it.


4. Self-describing bundle / retrain contract

A bundle is a directory that fully describes itself, so the runtime needs no out-of-band knowledge of what it trained on:

<bundle>/
  ├── domain.onnx              # ocp_play / not_ocp
  ├── media_type.onnx          # the leaf mediavocab.MediaType
  ├── playback_type.onnx       # audio / video / paged / interactive
  ├── structure.onnx           # single / episodic / continuous / collection
  ├── content_form_genres.onnx # MULTI-LABEL adult/anime/animation/asmr  ← content filter
  ├── content_genres.onnx      # MULTI-LABEL  mediavocab.KNOWN_GENRES
  ├── content_form.onnx        # SINGLE  trailer/teaser/behind_scenes/excerpt/…
  ├── programme_format.onnx    # SINGLE  documentary/news/concert/stand_up/…
  ├── accessibility.onnx       # MULTI-LABEL subtitles/audio_description/sign_language
  ├── variant.onnx             # SINGLE  directors/extended/remastered/colorized/…
  ├── explicitness.onnx        # clean / adult  (when trained)
  ├── play.onnx                # alias of the media_type head (for leaf-only loaders)
  └── meta.json

meta.json carries the ordered feature_names, the input_name, and a heads manifest, one entry per axis naming its .onnx file, kind (single/multi), index→label map, and (for multi-label heads) the threshold. It also carries flat domain_labels / play_labels keys, so a loader that reads only the domain and leaf heads can consume the bundle without parsing the full manifest.

OnnxMediaClassifier.from_path loads whatever heads exist: it iterates meta["heads"], opens an InferenceSession per present .onnx, and skips any whose file is missing (that axis falls back to derive). The retrain contract is therefore: produce a bundle in this layout and any version of the runtime consumes it. The reference producer is training/train_sklearn.py (export_bundle), installable via the [train] extra. End-to-end retraining, including adding a new axis, is covered in extending.md.

Building it locally + the (manual) publish step

The whole bundle is reproduced from source with three local commands, all artifacts stay local under the gitignored data/, nothing is published automatically:

python -m training.ingest_entities --relations   # flat pools + non-IMDb relations
python -m training.imdb_relations                 # IMDb relations + popularity weights
python -m training.build_dataset                  # → data/release/*
python -m training.train_sklearn                  # → data/models/{context,context_ner}/
python -m benchmarks.ladder                        # → benchmarks/ladder_results.{json,md}

Publishing the dataset / model bundle to the Hub is a separate, manual, explicitly-authorised step, it is never run by the build. When authorised, the dataset is pushed with python -m training.build_dataset --push --repo TigreGotico/ocp-media-intents [--private], a model bundle is uploaded by hand from data/models/. Until then the bundle lives only in the local gitignored data/ tree.


Pre-trained models (download)

The trained bundles are published as a private Hugging Face collection, OpenVoiceOS / OCP Media Classification (token-gated), one repo per approach so you can pick the cost/quality point:

repoapproachpick it for
OpenVoiceOS/ovos-media-classifier-onnx-defaultsklearn, keyword + NERrecommended default, lean (289 KiB, 0.25 ms), best content-filter precision
OpenVoiceOS/ovos-media-classifier-onnx-keywordsklearn, keyword-onlyworks with zero registered entities
OpenVoiceOS/ovos-media-classifier-onnx-tfidf-charTF-IDF char n-grams → linearbest media_type (0.978), no entities needed
OpenVoiceOS/ovos-media-classifier-onnx-tfidf-wordTF-IDF word n-grams → linearbest tags / genre·mood·era (0.93 F1)
OpenVoiceOS/ovos-media-classifier-onnx-neural-wordvecneural + domain word-vectorsbest content-filter recall (adult/hentai 0.98)
OpenVoiceOS/ovos-media-classifier-onnx-neuralneural, all featuresbalanced across axes
from huggingface_hub import snapshot_download
from ovos_media_classifier.onnx import OnnxMediaClassifier
path = snapshot_download("OpenVoiceOS/ovos-media-classifier-onnx-default", token="<hf_token>")
clf = OnnxMediaClassifier.from_path(path)

All bundles are trained on TigreGotico/ocp-media-intents and run on onnxruntime+numpy only.


5. Benchmark

Measured on the aligned mediavocab axes (content_form / programme_format / content_genres / picture_format), on a freshly rebuilt dataset that includes the full template set (conversational / agentpipe slot / context-aware / gazetteer-routing) plus an ASR-noise realism layer (§5a).

Held-out test split: 34,700 utterances. Per axis, the lift across the three implemented rungs (rules → learned context-only → learned context+NER) is the result. (Source: benchmarks/ladder_results.md.)

Single-label axes, accuracy

axisruleslearned-contextlearned-context+NER
domain0.8780.8730.989
media_type0.6750.7890.962
playback_type0.7240.8990.988
structure0.7460.9020.988
explicitness0.9890.9900.998
content_form0.7510.9840.997
programme_format0.8960.9930.998

Multi-label axes, macro-F1

axisruleslearned-contextlearned-context+NER
content_form_genres0.7310.7500.973
content_genres0.0000.5560.586
picture_format0.8780.8780.965

The content_genres macro-F1 is scored over the head's modelled label space (its top-CONTENT_GENRE_TOP_KKNOWN_GENRES labels), the honest in-scope task, not over the thousands of distinct raw genre values it cannot model (§6b). The accessibility and variant heads are skipped in this bundle: the current template set exercises a single accessibility value (audio_description) and no variant cut, so those columns are degenerate (one/zero class) and the runtime derives them instead of carrying a degenerate head (see §2's derive-fallback contract).

Content filter (driven by the content_form_genres axis)

rungadult recallhentai recallfalse-blockmedian msp95 mssize
rules0.581 (436/751)0.5100.0020.590.97,
learned-context0.578 (434/751)0.5030.0010.170.2097 KiB
learned-context+NER0.904 (679/751)0.9030.0000.250.392.1 MiB

The headline lifts (rules → context → context+NER): media_type accuracy 0.68 → 0.79 → 0.96, playback_type 0.72 → 0.90 → 0.99, structure 0.75 → 0.90 → 0.99, content_form accuracy 0.75 → 0.98 → 1.00, programme_format 0.90 → 0.99 → 1.00, content_form_genres macro-F1 0.73 → 0.75 → 0.97, picture_format 0.88 → 0.88 → 0.97, adult-block recall 0.58 → 0.58 → 0.90, hentai recall 0.51 → 0.50 → 0.90, at a near-zero false-block rate and sub-millisecond latency. The context+NER bundle's ner_* columns are ground-truth by construction (§7.4 caveat), so it is the near-oracle ceiling, the context bundle is the realistic no-NER floor a fresh install sees.

5a. The ASR-noise realism layer, does it help?

The dataset includes a spoken/ASR-style augmentation (training/build_dataset.py --asr-noise-fraction): a configurable fraction of rows is also emitted as a lowercased, punctuation-stripped, run-on variant with casual elisions (wanna / gimme / lemme / gonna), dropped courtesy lead-ins, occasional disfluency prepends (um / uh / like), and a light function-word mishear, the clean rows are kept. At the shipped fraction (0.20, ≈ 11 % of the balanced set) the layer adds ≈ 37 k spoken variants. A dedicated conversational slice (36 messy-spoken cases) in the routing eval measures the effect (docs/routing-eval.md).

Honest finding: ASR-noise is a near-no-op for the categorical-feature backends (the shipped sklearn / embedding heads), and it does not regress anything. The reason is structural: the runtime CategoricalFeatureExtractor is orthography-invariant by construction, it lowercases and matches .voc keywords on word boundaries, so a clean row and its ASR variant fire almost the same feature flags (measured: 1.69 vs 1.72 mean keyword flags, 0.97 vs 1.04 mean NER flags). The conversational-slice mis-route / resolved figures are therefore unchanged by the augmentation for these backends. The layer is retained because it is harmless and adds genuine realism for the value-text backends (the char-hash / word-vector neural variants of §7, which read the surface string and can be helped by it) and because the published dataset should carry the spoken register. The default --asr-noise-fraction is 0 so a plain build_dataset is unchanged, the shipped bundles' dataset was built at 0.20.


6. Limitations

The benchmark above is honest about where the model is strong, it is just as important to read where it is weak and why.

(a) The bag-of-cue-presence ceiling. The feature vector encodes which cues and which entity labels fired, never the entity value text (§1). Any axis whose ground truth lives in the slot value, not in a cue word, is fundamentally under-determined by these features. This is a property of the representation, not of the chosen estimator, a bigger model on the same features hits the same wall.

(b) the content_genres axis is starved. The genre head scores low and barely moves from context to context+NER, because its signal is exactly the value text the features drop: the real genre is in the genre slot value, not in a cue word. This is the direct motivation for the semantic rung (§3): embedding the surface string is the only way to read which genre was named. The head is shipped so the rung is ready to train, not because the current features predict it well. (mood / era are not modelled axes, a release year feeds Signals.year directly.)

(c) Synthetic / degenerate label regions. The domain head's negative class is synthetic, the all-zero feature vector (no keyword or NER evidence) labelled not_ocp, see train_domain_head. It learns "any media evidence ⇒ OCP", which is the right prior but is not trained against real non-media utterances. And because every dataset row is ocp_play, the control_intent column is constant, so its head is skipped at train time and the ocp_control domain is untrained / degenerate in this bundle.

(d) The runtime feature path is keyword-only. The shipped CategoricalFeatureExtractor produces only the keyword columns, the NER value-extraction path is not part of this release (features.py documents this). So even a context+NER bundle only ever sees keyword features at runtime unless a NER backend is wired in to populate the ner_* columns. The context+NER numbers above are the model's capability given populated entities, the out-of-the-box runtime sees the context-only behaviour until a NER store is attached.

(e) Near-tie leaves where the keyword default is already right. A handful of leaves share almost all of their cue features and differ only in a token the bag under-weights, music vs music_video is the canonical case (both fire the music keywords, only the video modality cue separates them), book vs interactive_fiction is another (both fire verb_read). The trained media_type head can confuse such pairs where the deterministic keyword classifier, matching leaf-first on the more specific music_video voc chain, gets them right. The aggregate media_type accuracy is high, but on these specific near-ties the rules floor is not strictly dominated, which is exactly why the backends are interchangeable behind one contract and the keyword default stays the zero-config baseline rather than being retired.

(f) The dataset is English-dominated. Templates are built across the seven core languages, but the en-us .intent / .voc set is by far the richest, its alternations and lead-ins expand to the large majority of the rows, so the trained bundle is strongest on en-us and thinner on the other locales (and on the many languages with no templates at all). The fix is more translated templates, not a model change: the .intent / .voc files are managed through ovos-localize, so adding or translating a locale (dataset.md) lifts that language's coverage with no code change. The keyword backend degrades gracefully on a thin locale (it finds no axis vocab and falls back to leaf-only matching), so a missing language is under-served, not broken.


7. Neural backend + richer text features (does seeing the value text help?)

§6(a, b) names the load-bearing limitation: the categorical features encode which cue/entity-label fired, never the value text, so any axis whose ground truth lives in the slot value is under-determined. This section is the experiment that attacks that wall directly, two new feature families that can read the surface string, a neural (PyTorch → ONNX) trainer that consumes them, and a head-to-head benchmark against the sklearn ladder on the same held-out test split.

7.1 Two text feature families (numpy-only at runtime)

Both run at train time and inference from the same code, so a bundle stays self-describing, the spec goes in meta.json and the runtime rebuilds the exact vector in numpy (no torch, no gensim, no transformers):

  • Hashed character n-grams, features_text.py. Char 3, 5 grams of the utterance → a fixed dim (default 4096) via the signed hashing trick, L2-normalized. This sees subwords: jazz, horror, title fragments, the exact tokens the binary flags drop. Spec (dim / ngram range / analyzer) is recorded in meta.json["text_hash"].
  • Trained domain word vectors, features_wordvec.py + training/build_corpus.py. A gensim Word2Vec (skip-gram, dim 100) trained on the full domain corpus: every entity pool (~4.35 M artist / track / album / movie / tv / anime / book / podcast / game strings), the relational co-occurrence records (~1.17 M, each record's fields joined so an artist, its album and its genre share a window), and the 347 k utterances. The learned matrix captures media semantics the flags can't, jazz ≈ swing, reggae, horror ≈ thriller, mystery, rock ≈ punk, pop. An utterance is mean-pooled over its in-vocab token rows, the matrix is saved as a pruned .npy (only tokens reachable from the dataset utterances) + a token→row vocab in the bundle, and meta.json["wordvec"] records the pooling config.

The model input becomes [categorical ⊕ char-hash ⊕ word-vectors], any subset selectable per variant.

7.2 The neural net, training/train_torch.py

A shared-trunk multi-task net: featurizer → shared MLP trunk (LayerNorm + ReLU + dropout, optional residual skips) → one linear head per axis (softmax for single-label, sigmoid for multi-label). AdamW, class-weighting / pos_weight for the imbalanced axes, early-stop on mean val macro-F1, fixed seed. Each head exports as its own ONNX graph into the existing bundle format, so OnnxMediaClassifier loads it unchanged, the only addition is reading the featurizer spec from meta.json to build the txt_* / wv_* blocks at runtime (a categorical-only sklearn bundle simply has no such spec and loads as-is). torch→onnxruntime round-trip parity is verified at export (max |Δ| ≈ 1e-7 on the softmax outputs).

7.3 The comparison (held-out test split, 34 700 utterances)

Single-label accuracy / multi-label macro-F1, scored identically across rungs. cat is categorical-only (the neural counterpart of sklearn context), +text adds char-hash, +wordvec adds the trained word vectors, +all both, (deep) / (wide) are arch sweeps on +all. Full table + content-filter + latency + size in benchmarks/ladder_results.md.

axisrulessklearn ctxsklearn ctx+NERneural catneural cat+textneural cat+wvneural cat+allcat+all (wide)
media_type (acc)0.6630.7860.9670.7870.9720.9650.9750.974
playback_type0.7170.8950.9900.8870.9870.9840.9880.988
structure0.7380.9080.9920.8330.9850.9780.9860.986
content_form_genres (F1)0.7200.7290.9790.5100.8580.7170.8750.876
tags (F1)0.0000.5610.5960.5110.8000.5280.7620.840
qualifiers (F1)0.0000.7800.9450.5610.9640.7300.9520.956
adult recall0.4790.4790.9320.8670.9210.9770.9190.943
median ms0.430.230.220.166.290.626.1016.1
bundle size,380 KiB289 KiB1.3 MiB79 MiB42 MiB114 MiB204 MiB

7.4 Findings, honest

Does the char-hash text help? Emphatically yes, and it is the headline. On the realistic no-NER inputs, adding char-hash to the categorical block lifts exactly the value-text-dependent axes §6(b) said were starved: tags 0.511 → 0.800, media_type 0.787 → 0.972, content_form_genres 0.510 → 0.858, qualifiers 0.561 → 0.964. Seeing subwords is what reads the genre / title / qualifier out of the surface string. This is the direct answer to §6(a, b): the wall was the representation, and a text-aware representation climbs it.

Do the trained domain word-vectors help? On the value-text axes that depend on semantics over an open vocabulary they help most for the content filter: +wordvec gives the best adult recall of any rung (0.977, beating even the NER-oracle), because the embedding pulls unseen adult-domain titles/terms toward the blocked region. They lift media_type to 0.965 on raw text alone. But on tags (0.528) mean-pooling underperforms char-hash, pooling averages away the specific token that names the decade/mood, which the order-preserving char-hash keeps. So word-vectors buy semantic generalization (content safety, coarse type) more than fine descriptive precision.

Does neural beat sklearn? Not on the same features, neural catsklearn context on media_type (0.787 vs 0.786) and is worse on the multi-label axes (content_form_genres 0.510 vs 0.729). A plain MLP buys nothing over a calibrated linear model on the binary flags. Neural wins only because it unlocks the richer features: a linear model cannot consume a 4096-dim hashed block as usefully, and the trunk lets all axes share that representation. The lift is the features, delivered through the net, not the net itself.

The honest caveat about sklearn context+NER. It tops media_type (0.967) and content_form_genres (0.979), but its ner_* columns are ground-truth by construction (set from the slot that filled the row, §1), it is a near-oracle that the runtime only realizes once a NER store is wired in. The neural text/wv rungs reach comparable accuracy reading only the raw utterance, which is what a fresh install actually sees, so for the out-of-the-box, no-NER deployment the char-hash neural bundle is the strongest realistic option.

Is it worth the size / latency? That is the real tradeoff. Char-hash costs ~6 ms/utterance (vs 0.2 ms sklearn) and a 79, 204 MiB bundle, fine for a server, heavy for a Pi. Word-vectors are the sweet spot for content safety: 0.6 ms, 42 MiB, best adult recall. The artifacts stay local (gitignored data/), the shipped default remains the lean zero-config keyword classifier, with these bundles an opt-in for deployments that can pay for the accuracy.


See also


← Classification model · Home · Extending →