Classifiers
Embedding-based classifiers for text, images, and audio. Three 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.
All 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.
:::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.
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 = GEngine->GetEngineSubsystem<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).
Persistence and serving
Trained classifiers persist to disk (safetensors for SetFit, JSON for centroid / KNN) 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.