Skip to main content
Version: 0.14

Classifiers

Classifiers for text, images, and audio. Four families are shipped:

  • Centroid — mean embedding per class, nearest-cosine prediction. Zero hyperparameters, strong baseline.
  • KNN (HNSW) — approximate nearest-neighbour vote. Best when classes have local structure rather than a single global centroid.
  • SetFit — few-shot contrastive fine-tuning of a sentence transformer with an optional LP-FT recipe.
  • N-gram perceptron — averaged perceptron over character n-grams. Text only, and the only one with no embedder: no model download, no device, microsecond inference.

The first three share one API surface: a tagged embedder config picks the modality, and the classifier itself is modality-agnostic. Add image or audio data by swapping the embedder; everything else — training, evaluation, persistence, the inference endpoint — stays the same. The n-gram perceptron uses the same trait, dataset format and CLI, but reads raw text directly and has no embedder to configure.

:::info New in 0.14 The n-gram perceptron (--classifier ngram_perceptron) is an embedder-free text classifier: character n-grams plus per-word shape features scored by an averaged perceptron. It trains in seconds on tens of thousands of rows, ships as a few hundred kilobytes on disk, and scores a short string in around ten microseconds — no weights to fetch and nothing to warm up, so it can run on a game's UI thread where an embedding round-trip cannot. Accuracy on a well-separated task (script/language identification, keyword-ish routing) is competitive; on tasks that need semantics rather than surface form, use one of the embedding classifiers. :::

:::info Changed in 0.12.1 The classifier and embedder surface is now reachable from every SDK (Godot, Unity, Unreal, Python, Lua) and ships in the per-platform binaries. For consistency with the new image/audio variants, the text-prediction call was renamed: predict(...) is now predict_text(...) (e.g. Unity Classifiers.Predict → Classifiers.PredictText, Unreal ClassifierPredict → ClassifierPredictText, C FFI atelico_classifier_predict → atelico_classifier_predict_text). There are no compatibility aliases — if you called the 0.12.0 name, rename it. The image and audio calls (predict_image / predict_audio) are new in this surface. :::

Picking an embedder​

Classifiers are configured with a ClassifierEmbedderConfig, a tagged enum over the per-modality embedders shipped by atelico-embed:

VariantEmbedderNotes
Text(EmbedderConfig)Sentence transformers — AllMiniLML6V2, BGEBaseENV15, BGESmallENV15, etc.Default for text
Vision(VisionEmbedderConfig)DINOv2-Small or DINOv2-LargeNew in 0.9 — image classifiers

Mixing modalities in one classifier is not supported — train one classifier per modality.

Dataset format​

A single JSONL row carries exactly one of text, image_path, or audio_path, plus a label:

{"text": "US stocks rally on tech earnings", "label": "Business"}
{"image_path": "data/cats/cat_001.jpg", "label": "cat"}
{"image_path": "data/dogs/dog_017.jpg", "label": "dog"}

Rows that mix input fields, or omit all of them, are rejected at load time with an explicit error.

Training (Rust)​

Training happens in Rust via the atelico-classifiers crate; the resulting model is then loaded into the engine for serving from any binding. Example (image-modality centroid):

use atelico_classifiers::{
centroid::{CentroidClassifier, CentroidConfig},
data::InMemoryDataset,
embedder_config::ClassifierEmbedderConfig,
};
use atelico_embed::{
vision_embedder::{VisionEmbedderConfig, VisionEmbeddingModel},
EmbedInputOwned,
};
use std::path::PathBuf;

let inputs: Vec<EmbedInputOwned> = vec![
EmbedInputOwned::image(PathBuf::from("data/cats/01.jpg")),
EmbedInputOwned::image(PathBuf::from("data/dogs/01.jpg")),
];
let labels = vec!["cat".into(), "dog".into()];
let dataset = InMemoryDataset::new(inputs, labels);

let cfg = CentroidConfig {
embedder: ClassifierEmbedderConfig::Vision(VisionEmbedderConfig {
model: VisionEmbeddingModel::DINOv2Small,
batch_size: 4,
}),
};
let mut clf = CentroidClassifier::new(cfg, /* assets */ None)?;
clf.train(&dataset, /* batch_size */ 4)?;
clf.save(std::path::Path::new("./models/animals"))?;
# Ok::<_, anyhow::Error>(())

Swap CentroidConfig for KnnConfig or SetFitConfig to use the other classifier types — the call sites stay identical.

N-gram perceptron​

The perceptron takes no embedder and no AssetManager, so its constructor takes only a config:

use atelico_classifiers::{
data::InMemoryDataset,
model::Classifier,
ngram_perceptron::{NgramPerceptronClassifier, NgramPerceptronConfig},
};

let mut clf = NgramPerceptronClassifier::new(NgramPerceptronConfig::default())?;
clf.train(&InMemoryDataset::from_jsonl("data/train.jsonl")?, 256)?;
clf.save(std::path::Path::new("./artifacts/lang"))?;

Or from the CLI:

cargo run --release --bin train -- \
--train-path ./data/train.jsonl \
--output-dir ./artifacts/lang \
--classifier ngram_perceptron \
--in-memory
FlagDefaultNotes
--min-n / --max-n1 / 5Character n-gram range, over a lowercased, boundary-marked copy of the text.
--no-word-featuresoffDrops the whole-word, prefix and suffix features. They matter: che, una and het are decisive as words and unremarkable as letter sequences.
--perceptron-epochs20Passes over the training set.
--mira-c0.5Largest step a single mistake may take.
--prune-below0.002Size dial — most of the table is noise seen once.
--seed0Shuffling is deterministic, so a run reproduces exactly.
--hash-widthbits32Feature key width. Training verifies no two features collide.
--hash-seedautoPins the hash offset basis. By default training searches for one under which nothing collides, so an unlucky pair costs a reshuffle rather than a wider key.
--no-quantizeoffKeeps f32 weights instead of int8 with a single global scale.
--multilabeloffScore each label independently against its own threshold.
--state-formatbinarybinary or json. Identical scores; binary is ~2.7x smaller, JSON is readable.

The saved model is a directory of three files — metadata.json, ngram_perceptron_config.json, and the weight table (ngram_perceptron_state.bin, or .json with --state-format json) — with no embedder_config.json, and loads through the same registry::load_classifier path as everything else. load prefers the binary table and falls back to JSON, so either format is a drop-in for the other.

Hash collisions​

Keys are 32-bit hashes, and training verifies no two features share one rather than assuming it. Collisions follow the birthday bound — at 136k features only about 11.5% of bases are clean — so training searches upward from seed 0 for one that is, and records it in ngram_perceptron_config.json. Re-keying an already-trained table is nearly free, so this costs no accuracy and no size: on a 418k-row corpus at 142k features, seeding kept the model at 711 KB where --hash-width bits64 would have made it 1.28 MB.

The seed is part of the model's identity. Every hashing path derives from the saved config, and a recorded canary is recomputed at load time, so a hand-edited seed refuses to load rather than silently missing every lookup.

Binary, multiclass and multilabel​

The weight table stores one value per class per feature, with one exception. A single-label decision is argmax_k (w_k · f), which is invariant to adding a constant vector to every class — so a single-label model pins one reference class to zero and omits its column, storing K-1 values per feature instead of K. That is a clean 2× saving for a binary model and 1.07× at fifteen classes.

A multilabel model never uses that layout. Multilabel compares each label's absolute score against its own threshold, so subtracting a reference would change every decision. The layout is recorded in the file header, the transform refuses to run on a multilabel model, and load rejects any file that claims the combination anyway.

Pruning follows the same logic: single-label models prune on max_k w_k − min_k w_k (a feature is useless only if it separates no pair of classes), multilabel models on max_k |w_k|.

Loading and predicting​

Every binding exposes the same three steps:

  1. Load the classifier under a logical id — load_classifier(classifier_id, directory), where directory is the folder the trained model was saved to.
  2. Predict by modality — predict_text(id, text, top_k), predict_image(id, image_path, top_k), or predict_audio(id, audio_path, top_k).
  3. Read the result: a top label, its probability, and a top list of the top-k {label, probability} pairs.

The top_k parameter (optional)​

top_k controls how many ranked predictions come back in the top list. It is optional:

top_klabel / probabilitytop list
omitted / nullthe single best classall labels, ranked by probability
0the single best classempty (just the top label, no ranking)
N (≥ 1)the single best classthe N highest-probability labels

label and probability always describe the single most likely class, regardless of top_k.

Serving — text input​

Once a classifier is saved to disk, load it under an ID and call predict_text.

import json
import atelico

engine = atelico.Engine()
# (Implementation note: classifier loading is currently performed at engine
# startup via the ATELICO_CLASSIFIERS environment variable; programmatic load
# from Python follows the same pattern as other subsystems.)

result = json.loads(engine.classifier_predict_text("sentiment", "I love this!", top_k=3))
print(result["label"], result["probability"])

Serving — image input (DINOv2)​

The classifier referenced by model_id must have been trained with a Vision embedder. Calling the image endpoint against a text-only classifier returns an error.

import json
import atelico

engine = atelico.Engine()
result = json.loads(engine.classifier_predict_image(
"animals",
"/abs/path/to/cat.jpg",
top_k=3,
))
print(result["label"], result["probability"])

Serving — audio input​

predict_audio classifies a sound clip against a classifier backed by an audio embedder. The result schema and top_k rules are identical to text and image.

:::caution Audio format: WAV / RIFF only The audio classifier decodes WAV / RIFF files only. MP3, FLAC, and other compressed formats are not supported — convert to WAV first. :::

import json
import atelico

engine = atelico.Engine()
result = json.loads(engine.classifier_predict_audio(
"ambience",
"/abs/path/to/clip.wav",
top_k=3,
))
print(result["label"], result["probability"])

Download the embeddings first (gating)​

Image and audio classifiers compute their embeddings with a model (e.g. DINOv2-Small for vision) that lives in the on-device cache. Predict calls resolve those weights from the cache only — they never trigger a silent blocking download. If the weights aren't present yet, the call fails (or returns an empty result) so you stay in control of when the download happens.

The contract:

  • Check with is_model_cached(model_id).
  • Fetch with model_download_async(model_id) if it's missing, and wait for it to finish before predicting.

The model id to gate on is the embedder the classifier was trained with, by modality:

ModalityEmbedder model id
Image"dinov2-small" (DINOv2-Small, the default image embedder), "dinov2-large"
Audio"clap" (the CLAP audio embedder)
Textthe text embedder id, e.g. "bge-small-en"

Worked example: a "turn left / turn right" image classifier​

A common in-game use is reacting to what's on screen. Train a centroid classifier over DINOv2-Small image embeddings with two labels — left and right — then, at runtime, make sure DINOv2-Small is cached and classify a frame:

import json, atelico
engine = atelico.Engine()

# 1. Gate: make sure the image embedder is on device.
if not engine.is_model_cached("dinov2-small"):
engine.model_download_async("dinov2-small") # await completion in real code

# 2. Load the trained classifier and predict a frame.
engine.load_classifier("steering", "/abs/path/to/models/steering")
result = json.loads(engine.classifier_predict_image("steering", "/abs/path/frame.png", top_k=0))
print(result["label"]) # "left" or "right"

Using top_k=0 here returns just the winning label with an empty top list — all you need when you only act on the top class.

DINOv2 vision embeddings​

VisionEmbeddingModel ships two sizes:

VariantHF repoEmbedding dimUse when
DINOv2Smallfacebook/dinov2-small384Default. Fast, ~22M params. Good for general object / scene categories.
DINOv2Largefacebook/dinov2-large1024Higher quality at ~300M params. Worth the cost when classes are visually subtle.

DINOv2 produces strong general-purpose visual features without per-task pre-training, which makes the Centroid classifier a surprisingly capable baseline for image tasks — try it first before reaching for SetFit fine-tuning.

The same VisionEmbedder is also exposed as a standalone embedder via atelico-embed if you only need raw image vectors (e.g. for similarity search, clustering, or feeding the Hybrid Search store).

Teaching a classifier during play (incremental training)​

A classifier can also start empty and learn one example at a time while the game runs — the player invents a cue ("Stormfly, come"), rewards the creature for the right response, and that utterance becomes an example of a label that did not exist a moment earlier. There is no retraining pass: the very next prediction already knows it.

KindHow one example is learnedPick it when
centroidrunning mean of the label's embeddingsDefault. Stable with a handful of examples; meaning-based ("come here" ≈ "over here").
knninserted into a live HNSW indexLabels whose utterances vary a lot; keeps every example.
ngram_perceptronmargin-driven MIRA steps, rehearsing earlier examplesNo embedder at all, microsecond scoring; matches spelling, so it suits invented words.

SetFit classifiers, and n-gram perceptrons trained in batch, cannot learn incrementally (the call errors and says why; supports_incremental_training in the classifier info tells you up front). A batch-trained perceptron refuses because it can no longer rehearse the dataset it came from, and new labels would erode what it learned.

The operations — every binding has all of them, named in its own convention (e.g. Godot get_classifier_info, Python classifier_info, Unity GetInfo, Unreal ClassifierGetInfo, FFI atelico_classifier_info):

  • create an empty classifier: {"kind": "centroid", "embedder": "all-MiniLM-L6-v2"} (embedder is the same model id embed_text takes; omit it for ngram_perceptron; optional multilabel, and k_neighbors for knn).
  • train_incremental_text (id, label, text) — creates the label on its first example.
  • train_incremental_embedding (id, label, vector) — the same from a vector you already computed with embed_text and the classifier's embedder, which skips a second embedding of the same string. predict_embedding is its counterpart.
  • remove_label (id, label).
  • info (id) — kind, embedding_dim, supports_incremental_training, and every label with its example_count, for a debug panel.
  • save (id, directory) — into the player's save slot; bring it back with the ordinary load. The engine owns the file format; the game owns the directory.
var clf := AtelicoClassifierNode.new()
add_child(clf)
clf.initialize()
clf.create_classifier("dragon_cues", JSON.stringify({"kind": "centroid", "embedder": "all-MiniLM-L6-v2"}))

clf.train_incremental_text("dragon_cues", "come", "Stormfly, come")
# Already embedded the utterance for memory? Hand over the vector instead.
var vector: PackedFloat32Array = embedder.embed_text("all-MiniLM-L6-v2", "come here girl")
clf.train_incremental_embedding("dragon_cues", "come", vector)

var result = JSON.parse_string(clf.predict_text("dragon_cues", "come on Stormfly", 3))
print(result.label) # "come"
print(JSON.parse_string(clf.get_classifier_info("dragon_cues")).labels)
clf.save_classifier("dragon_cues", ProjectSettings.globalize_path("user://slot1/dragon_cues"))
# Next session: clf.load_classifier("dragon_cues", <same path>)

Persistence and serving​

Trained classifiers persist to disk (safetensors for SetFit, JSON for centroid / KNN, and a compact binary or JSON table for the n-gram perceptron) and are loaded into the engine via the ATELICO_CLASSIFIERS environment variable on startup, or programmatically through the SDK and bindings shown above.

The same classifier infrastructure also powers the Guardrails ML-classifier layer for content moderation.