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:
| Variant | Embedder | Notes |
|---|---|---|
Text(EmbedderConfig) | Sentence transformers — AllMiniLML6V2, BGEBaseENV15, BGESmallENV15, etc. | Default for text |
Vision(VisionEmbedderConfig) | DINOv2-Small or DINOv2-Large | New 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
| Flag | Default | Notes |
|---|---|---|
--min-n / --max-n | 1 / 5 | Character n-gram range, over a lowercased, boundary-marked copy of the text. |
--no-word-features | off | Drops the whole-word, prefix and suffix features. They matter: che, una and het are decisive as words and unremarkable as letter sequences. |
--perceptron-epochs | 20 | Passes over the training set. |
--mira-c | 0.5 | Largest step a single mistake may take. |
--prune-below | 0.002 | Size dial — most of the table is noise seen once. |
--seed | 0 | Shuffling is deterministic, so a run reproduces exactly. |
--hash-width | bits32 | Feature key width. Training verifies no two features collide. |
--hash-seed | auto | Pins 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-quantize | off | Keeps f32 weights instead of int8 with a single global scale. |
--multilabel | off | Score each label independently against its own threshold. |
--state-format | binary | binary 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:
- Load the classifier under a logical id —
load_classifier(classifier_id, directory), wheredirectoryis the folder the trained model was saved to. - Predict by modality —
predict_text(id, text, top_k),predict_image(id, image_path, top_k), orpredict_audio(id, audio_path, top_k). - Read the result: a top
label, itsprobability, and atoplist 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_k | label / probability | top list |
|---|---|---|
omitted / null | the single best class | all labels, ranked by probability |
0 | the single best class | empty (just the top label, no ranking) |
N (≥ 1) | the single best class | the 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.
- Python
- Godot (GDScript)
- Unity (C#)
- Unreal (C++)
- C FFI
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"])
@onready var classifiers: AtelicoClassifierNode = $AtelicoClassifiers
func _ready() -> void:
classifiers.initialize()
classifiers.load_classifier("sentiment", "/abs/path/to/models/sentiment")
func classify(text: String) -> void:
var json := classifiers.predict_text("sentiment", text, 3)
var result := JSON.parse_string(json)
print("label=%s p=%.2f" % [result.label, result.probability])
var engine = new AtelicoEngine();
string requestJson = JsonSerializer.Serialize(new {
model_id = "sentiment",
text = "I love this!",
top_k = 3,
});
string resultJson = engine.Classifiers.PredictText(requestJson);
using var doc = JsonDocument.Parse(resultJson);
Debug.Log($"label = {doc.RootElement.GetProperty("label").GetString()}");
auto* Atelico = GetGameInstance()->GetSubsystem<UAtelicoAISubsystem>();
const FString Request = TEXT(R"({
"model_id": "sentiment",
"text": "I love this!",
"top_k": 3
})");
FString ResultJson = Atelico->ClassifierPredictText(Request);
const char *request =
"{\"model_id\":\"sentiment\",\"text\":\"I love this!\",\"top_k\":3}";
const char *result_json = NULL;
if (atelico_classifier_predict_text(engine, request, &result_json) == ATELICO_OK) {
printf("%s\n", result_json);
}
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.
- Python
- Godot (GDScript)
- Unity (C#)
- Unreal (C++)
- C FFI
- Rust SDK
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"])
@onready var classifiers: AtelicoClassifierNode = $AtelicoClassifiers
func classify_image(path: String) -> void:
var json := classifiers.predict_image("animals", path, 3)
var result := JSON.parse_string(json)
print("label=%s p=%.2f" % [result.label, result.probability])
string request = JsonSerializer.Serialize(new {
model_id = "animals",
image_path = "/abs/path/cat.jpg",
top_k = 3,
});
string resultJson = engine.Classifiers.PredictImage(request);
const FString Request = TEXT(R"({
"model_id": "animals",
"image_path": "/abs/path/cat.jpg",
"top_k": 3
})");
FString ResultJson = Atelico->ClassifierPredictImage(Request);
const char *request =
"{\"model_id\":\"animals\","
" \"image_path\":\"/abs/path/cat.jpg\","
" \"top_k\":3}";
const char *result_json = NULL;
if (atelico_classifier_predict_image(engine, request, &result_json) == ATELICO_OK) {
printf("%s\n", result_json);
}
let result = engine.classifiers().predict_image_sync(
"animals",
std::path::Path::new("/abs/path/cat.jpg"),
Some(3),
)?;
println!("{} {:.2}", result.label, result.probability);
# Ok::<_, atelico_sdk::SdkError>(())
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. :::
- Python
- Godot (GDScript)
- Unity (C#)
- Unreal (C++)
- C FFI
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"])
@onready var classifiers: AtelicoClassifierNode = $AtelicoClassifiers
func classify_audio(path: String) -> void:
var json := classifiers.predict_audio("ambience", path, 3)
var result := JSON.parse_string(json)
print("label=%s p=%.2f" % [result.label, result.probability])
string request = JsonSerializer.Serialize(new {
model_id = "ambience",
audio_path = "/abs/path/clip.wav",
top_k = 3,
});
string resultJson = engine.Classifiers.PredictAudio(request);
const FString Request = TEXT(R"({
"model_id": "ambience",
"audio_path": "/abs/path/clip.wav",
"top_k": 3
})");
FString ResultJson = Atelico->ClassifierPredictAudio(Request);
const char *request =
"{\"model_id\":\"ambience\","
" \"audio_path\":\"/abs/path/clip.wav\","
" \"top_k\":3}";
const char *result_json = NULL;
if (atelico_classifier_predict_audio(engine, request, &result_json) == ATELICO_OK) {
printf("%s\n", result_json);
}
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:
| Modality | Embedder model id |
|---|---|
| Image | "dinov2-small" (DINOv2-Small, the default image embedder), "dinov2-large" |
| Audio | "clap" (the CLAP audio embedder) |
| Text | the 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:
| Variant | HF repo | Embedding dim | Use when |
|---|---|---|---|
DINOv2Small | facebook/dinov2-small | 384 | Default. Fast, ~22M params. Good for general object / scene categories. |
DINOv2Large | facebook/dinov2-large | 1024 | Higher 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.
| Kind | How one example is learned | Pick it when |
|---|---|---|
centroid | running mean of the label's embeddings | Default. Stable with a handful of examples; meaning-based ("come here" ≈ "over here"). |
knn | inserted into a live HNSW index | Labels whose utterances vary a lot; keeps every example. |
ngram_perceptron | margin-driven MIRA steps, rehearsing earlier examples | No 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"}(embedderis the same model idembed_texttakes; omit it forngram_perceptron; optionalmultilabel, andk_neighborsforknn). - 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 withembed_textand 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 itsexample_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.
- Godot (GDScript)
- Python
- Unity (C#)
- Unreal (C++)
- C FFI
- Rust SDK
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>)
import json
import atelico
engine = atelico.Engine()
engine.classifier_create("dragon_cues", json.dumps({"kind": "centroid", "embedder": "all-MiniLM-L6-v2"}))
engine.classifier_train_incremental_text("dragon_cues", "come", "Stormfly, come")
engine.classifier_train_incremental_text("dragon_cues", "spin", "do a spin")
print(json.loads(engine.classifier_predict_text("dragon_cues", "come here", top_k=2))["label"])
print(json.loads(engine.classifier_info("dragon_cues"))["labels"])
engine.classifier_save("dragon_cues", "/saves/slot1/dragon_cues")
var classifiers = engine.Classifiers;
classifiers.Create("dragon_cues", "{\"kind\":\"centroid\",\"embedder\":\"all-MiniLM-L6-v2\"}");
classifiers.TrainIncremental("dragon_cues", "come", "Stormfly, come"); // from text
classifiers.TrainIncremental("dragon_cues", "come", vector); // from float[]
string info = classifiers.GetInfo("dragon_cues");
classifiers.Save("dragon_cues", Path.Combine(Application.persistentDataPath, "slot1/dragon_cues"));
auto* Atelico = GetGameInstance()->GetSubsystem<UAtelicoAISubsystem>();
Atelico->ClassifierCreate(TEXT("dragon_cues"),
TEXT(R"({"kind":"centroid","embedder":"all-MiniLM-L6-v2"})"));
Atelico->ClassifierTrainIncrementalText(TEXT("dragon_cues"), TEXT("come"), TEXT("Stormfly, come"));
FString Info = Atelico->ClassifierGetInfo(TEXT("dragon_cues"));
Atelico->ClassifierSave(TEXT("dragon_cues"), FPaths::ProjectSavedDir() / TEXT("slot1/dragon_cues"));
atelico_classifier_create(engine, "dragon_cues",
"{\"kind\":\"centroid\",\"embedder\":\"all-MiniLM-L6-v2\"}");
atelico_classifier_train_incremental_text(engine, "dragon_cues", "come", "Stormfly, come");
atelico_classifier_train_incremental_embedding(engine, "dragon_cues", "come", vector, 384);
const char *info = NULL;
atelico_classifier_info(engine, "dragon_cues", &info);
atelico_classifier_save(engine, "dragon_cues", "/saves/slot1/dragon_cues");
use atelico_sdk::{ClassifierConfig, ClassifierKind};
let classifiers = engine.classifiers();
classifiers.create_sync("dragon_cues", ClassifierConfig {
kind: ClassifierKind::Centroid,
embedder: Some("all-MiniLM-L6-v2".into()),
multilabel: false,
k_neighbors: None,
})?;
classifiers.train_incremental_text_sync("dragon_cues", "come", "Stormfly, come")?;
let info = classifiers.info_sync("dragon_cues")?;
classifiers.save_sync("dragon_cues", std::path::Path::new("saves/slot1/dragon_cues"))?;
# Ok::<_, atelico_sdk::SdkError>(())
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.