Build your own classifier
ARCADE ranks discriminators under one shared pipeline. Adding a method means writing a small Python class that implements the classifier contract, registering it under a short name, and listing that name in YAML. Splits, optional duration sweep, analytical hardware estimation, and the summary report stay the same as for built-in methods.
This guide covers three patterns:
A minimal classical toy on filter features.
How to leverage custom classes from YAML and from Python imports.
A transformer-style custom class that consumes time-resolved sequences.
A checked-in toy lives under examples/custom_classifier/.
flowchart TB
subgraph Your code
CLS["@register_classifier name<br/>BaseClassifier subclass"]
end
subgraph ARCADE pipeline
DATA[Data stage] --> FEAT["Feature build<br/>FEATURE_KIND"]
FEAT --> FIT[fit / predict]
FIT --> REP[Report row]
end
CLS --> FEAT
CLS --> FIT
Contract you must satisfy
Piece |
Role |
|---|---|
|
Puts the class in the registry for YAML |
|
Tells the classify stage which matrix to build |
|
Train and infer |
|
Optional; maps a YAML option block into constructor arguments |
|
Shown by |
Supported feature kinds you will use most often are filter_features,
iq_trace, and raw_trace_seq. Wrong kinds usually surface as shape errors
rather than clear messages, so set FEATURE_KIND deliberately.
Pattern A: nearest-mean on filter features
# my_pkg/nearest_mean.py
from __future__ import annotations
import numpy as np
from arcade.classifiers.base import BaseClassifier, register_classifier
@register_classifier("nearest_mean")
class NearestMeanClassifier(BaseClassifier):
DESCRIPTION = "Nearest class-mean on filter features"
CATEGORY = "classical"
FEATURE_KIND = "filter_features"
def __init__(self) -> None:
self._means: dict[int, np.ndarray] = {}
def fit(self, train_features, train_labels, **kwargs):
X = np.asarray(train_features)
y = np.asarray(train_labels).astype(int)
self._means = {int(c): X[y == c].mean(axis=0) for c in np.unique(y)}
return self
def predict(self, features):
X = np.asarray(features)
labels = sorted(self._means)
means = np.stack([self._means[c] for c in labels], axis=0)
d = ((X[:, None, :] - means[None, :, :]) ** 2).sum(axis=-1)
return np.asarray(labels, dtype=int)[d.argmin(axis=1)]
@classmethod
def from_config(cls, config: dict) -> "NearestMeanClassifier":
return cls()
Ensure the module is importable before arcade run. Install your package into
the same environment, or set PYTHONPATH so Python can find my_pkg.
Leverage the class from YAML
Registered name after the import side effect:
classifiers:
run:
- threshold
- lda
- nearest_mean
filter_types: [MF, RMF]
Dotted path when you do not want to rely on prior registration. The loader imports the class from the path string:
classifiers:
run:
- threshold
- my_pkg.nearest_mean.NearestMeanClassifier
Leverage the class from Python
from arcade.classifiers.base import get_classifier
import my_pkg.nearest_mean # registration side effect
clf = get_classifier("nearest_mean").from_config({})
# or pass the class into a notebook cell that builds features yourself
You can also keep the class unregistered and pass the dotted path only in YAML. That is useful when you are prototyping inside a private package and do not want a global short name yet.
Pattern B: transformer-style custom class
The built-in transformer method already consumes raw_trace_seq features
and trains a TraceTransformer through arcade.nn.builder.build_nn. Use it
when the stock path is enough:
classifiers:
run: [threshold, transformer]
transformer:
input_mode: raw
nn_config:
model:
type: transformer
d_model: 64
nhead: 4
num_layers: 2
dim_feedforward: 128
dropout: 0.1
training:
epochs: 50
batch_size: 512
lr: 1.0e-3
device: auto
When you need a custom variant, for example a different default input mode, an
extra preprocessing step, or a renamed experimental method, subclass the same
contract and keep FEATURE_KIND = "raw_trace_seq" so the pipeline builds
time-resolved sequences.
# my_pkg/tiny_transformer.py
from __future__ import annotations
from typing import Any
import numpy as np
from arcade.classifiers.base import BaseClassifier, register_classifier
@register_classifier("tiny_transformer")
class TinyTransformerClassifier(BaseClassifier):
"""Minimal attention classifier on raw IQ sequences."""
DESCRIPTION = "Custom tiny transformer on raw_trace_seq features"
CATEGORY = "neural"
FEATURE_KIND = "raw_trace_seq"
REQUIRES_NN_CONFIG = True
def __init__(self, *, nn_config: dict | None = None) -> None:
self.nn_config = nn_config or {}
self._model = None
self._seq_len = 0
self._feature_dim = 0
def fit(
self,
train_features: np.ndarray,
train_labels: np.ndarray,
val_features: np.ndarray | None = None,
val_labels: np.ndarray | None = None,
**kwargs: Any,
) -> "TinyTransformerClassifier":
from arcade.nn.builder import build_nn
from arcade.training.transformer_training import train_trace_transformer
train_seq = np.asarray(train_features, dtype=np.float32)
if train_seq.ndim != 3:
raise ValueError(f"expected (n,T,F), got {train_seq.shape}")
self._seq_len = int(train_seq.shape[1])
self._feature_dim = int(train_seq.shape[2])
num_classes = int(np.asarray(train_labels).max()) + 1
cfg = dict(self.nn_config)
model_cfg = dict(cfg.get("model", cfg))
train_cfg = dict(cfg.get("training", {}))
model_cfg.setdefault("type", "transformer")
self._model = build_nn(
{"model": model_cfg},
seq_len=self._seq_len,
feature_dim=self._feature_dim,
output_dim=num_classes,
)
result = train_trace_transformer(
self._model,
train_seq,
np.asarray(train_labels).ravel(),
np.asarray(val_features, dtype=np.float32) if val_features is not None else None,
np.asarray(val_labels).ravel() if val_labels is not None else None,
epochs=int(train_cfg.get("epochs", 30)),
batch_size=int(train_cfg.get("batch_size", 256)),
lr=float(train_cfg.get("lr", 1e-3)),
device=str(train_cfg.get("device", "auto")),
)
self._model = result["model"]
return self
def predict(self, features: np.ndarray) -> np.ndarray:
import torch
if self._model is None:
raise RuntimeError("Call fit() before predict()")
seq = np.asarray(features, dtype=np.float32)
self._model.eval()
dev = next(self._model.parameters()).device
x = torch.tensor(seq, dtype=torch.float32, device=dev)
with torch.no_grad():
return self._model(x).argmax(dim=-1).cpu().numpy().astype(np.int64)
@classmethod
def from_config(cls, config: dict[str, Any]) -> "TinyTransformerClassifier":
nn_cfg = config.get("nn_config")
if nn_cfg is None and config.get("config_file"):
import yaml
with open(config["config_file"]) as f:
nn_cfg = yaml.safe_load(f)
return cls(nn_config=nn_cfg)
flowchart LR
RAW[Prepared traces] --> SEQ["raw_trace_seq<br/>(n shots × T × F)"]
SEQ --> TT[tiny_transformer fit]
TT --> MET[Metrics + report row]
TT --> HW[Analytical hardware]
YAML that runs the custom transformer beside a baseline
classifiers:
run:
- threshold
- tiny_transformer
tiny_transformer:
nn_config:
model:
type: transformer
d_model: 64
nhead: 4
num_layers: 2
dim_feedforward: 128
training:
epochs: 30
batch_size: 256
lr: 1.0e-3
device: auto
visualization:
output_dir: output/custom_transformer_bakeoff
Install torch first:
pip install -e ".[torch]"
export PYTHONPATH=path/to/my_pkg_parent:$PYTHONPATH
arcade run path/to/config.yaml
The custom method appears in the same summary report as threshold. That is
the point of the registry: your experiment gets the shared recipe for free.
What you get for free
The same train, validation, and test cuts as every other method in the run.
Feature matrices selected by FEATURE_KIND. Optional duration sweep when
enabled. Analytical FPGA estimates when hardware estimation is enabled. A row
in the summary report next to zoo methods.
Pitfalls
- Match
FEATURE_KINDto whatfitexpects. Sequence models needraw_trace_seq; filter models needfilter_features. - Neural methods need the
torchextra and usually annn_configorconfig_file. - Import the module before
arcade run, or use a dotted class path in YAML. - Do not mix a custom optimized net and an unoptimized zoo net in one ranking table without labeling the rows.
For registry names and method-level pipeline notes, see Classifiers reference.