"""Base classifier class and registry for ARCADE.
Built-in discriminators register with :func:`register_classifier`. External
methods can either use the same decorator (if they import ARCADE) or be
referenced from YAML via a dotted path ``package.module.ClassName``.
"""
from __future__ import annotations
import difflib
import importlib
from abc import ABC, abstractmethod
from typing import Any, Literal
import numpy as np
_CLASSIFIER_REGISTRY: dict[str, type["BaseClassifier"]] = {}
[docs]
def register_classifier(name: str) -> Any:
"""Register a :class:`BaseClassifier` subclass under *name*.
The *name* is what appears in ``classifiers.run`` in YAML.
Args:
name: Short registry key (e.g. ``"fnn"``, ``"my_clf"``).
Returns:
A class decorator that stores the class in the registry.
Example::
@register_classifier("my_clf")
class MyClassifier(BaseClassifier):
FEATURE_KIND = "filter_features"
def fit(self, train_features, train_labels, **kwargs):
return self
def predict(self, features):
...
"""
def decorator(cls: type["BaseClassifier"]) -> type["BaseClassifier"]:
_CLASSIFIER_REGISTRY[name] = cls
cls.name = name
return cls
return decorator
[docs]
def get_classifier(name: str) -> type["BaseClassifier"]:
"""Resolve a classifier class by registry name or dotted import path.
Args:
name: Either a registered key (``"lda"``) or
``"package.module.ClassName"`` (last segment is the class).
Returns:
The classifier **class** (not an instance). Call
:meth:`BaseClassifier.from_config` to construct.
Raises:
KeyError: Unknown registry name (includes close-match hints).
TypeError: Dotted path did not resolve to a ``BaseClassifier`` subclass.
"""
if name in _CLASSIFIER_REGISTRY:
return _CLASSIFIER_REGISTRY[name]
if "." in name:
module_path, cls_name = name.rsplit(".", 1)
mod = importlib.import_module(module_path)
cls = getattr(mod, cls_name)
if isinstance(cls, type) and issubclass(cls, BaseClassifier):
return cls
raise TypeError(f"{name} resolved to {cls!r}, not a BaseClassifier subclass.")
close = difflib.get_close_matches(name, _CLASSIFIER_REGISTRY.keys(), n=3, cutoff=0.5)
hint = f" Did you mean: {', '.join(close)}?" if close else ""
raise KeyError(
f"Unknown classifier {name!r}.{hint} "
f"Registered: {sorted(_CLASSIFIER_REGISTRY)}. "
f"For external classifiers, use a dotted import path."
)
[docs]
def list_classifiers() -> list[str]:
"""Return sorted registry names of all built-in classifiers.
Triggers :func:`ensure_classifiers_registered` so the list is complete
even before any classifier module was imported.
"""
ensure_classifiers_registered()
return sorted(_CLASSIFIER_REGISTRY)
[docs]
def iq_trace_classifier_names() -> frozenset[str]:
"""Return names of classifiers that consume IQ / raw traces (not MF scores)."""
ensure_classifiers_registered()
names = {
n
for n, cls in _CLASSIFIER_REGISTRY.items()
if getattr(cls, "USES_IQ_TRACES", False) or getattr(cls, "FEATURE_KIND", "") == "iq_trace"
}
names |= {"path_signature", "ngrc", "remf"}
return frozenset(names)
[docs]
def seq_trace_classifier_names() -> frozenset[str]:
"""Return names of classifiers that consume sequential trace tensors."""
ensure_classifiers_registered()
names = {
n
for n, cls in _CLASSIFIER_REGISTRY.items()
if "trace_seq" in getattr(cls, "FEATURE_KIND", "")
}
names |= {"transformer"}
return frozenset(names)
FILTER_SET_CLASSIFIERS = frozenset({"threshold", "leakage", "multilevel"})
[docs]
def list_classifiers_with_descriptions() -> list[tuple[str, str, str]]:
"""Return ``(name, category, description)`` for every registered classifier."""
ensure_classifiers_registered()
out: list[tuple[str, str, str]] = []
for name in sorted(_CLASSIFIER_REGISTRY):
cls = _CLASSIFIER_REGISTRY[name]
out.append(
(
name,
getattr(cls, "CATEGORY", "classical"),
getattr(cls, "DESCRIPTION", ""),
)
)
return out
[docs]
def ensure_classifiers_registered() -> None:
"""Import built-in classifier packages so the registry is populated."""
import arcade.classifiers.classical # noqa: F401
import arcade.classifiers.ensemble # noqa: F401
import arcade.classifiers.hybrid # noqa: F401
import arcade.classifiers.neural # noqa: F401
[docs]
class BaseClassifier(ABC):
"""Contract every ARCADE discriminator must satisfy.
Class attributes control how the pipeline builds features and trains:
- ``FEATURE_KIND`` — which feature matrix the classify stage builds
(``filter_features``, ``iq_trace``, …).
- ``CATEGORY`` — ``classical`` · ``neural`` · ``hybrid`` · ``ensemble``.
- ``REQUIRES_NN_CONFIG`` — if true, YAML must supply ``config_file`` / NN dict.
- ``CLASSIFICATION_MODE`` — joint vs per-qubit decoding hints for metrics.
Subclass, set those attributes, implement :meth:`fit` / :meth:`predict`,
and decorate with :func:`register_classifier`.
Example::
@register_classifier("my_clf")
class MyClassifier(BaseClassifier):
CATEGORY = "classical"
FEATURE_KIND = "filter_features"
def fit(self, train_features, train_labels, **kwargs):
...
return self
def predict(self, features):
...
return labels
"""
name: str = "base"
DESCRIPTION: str = ""
REQUIRES_NN_CONFIG: bool = False
REQUIRES_TRAINING: bool = True
CATEGORY: Literal["classical", "neural", "hybrid", "ensemble"] = "classical"
CLASSIFICATION_MODE: str = "joint"
FEATURE_KIND: str = "filter_features"
SUPPORTED_FEATURE_KINDS: tuple[str, ...] | None = None
USES_IQ_TRACES: bool = False
TUNABLE: dict[str, Any] | bool = {}
[docs]
def validate_feature_kind(self, kind: str) -> None:
"""Raise ``ValueError`` if *kind* is not supported by this classifier."""
if not self.supports_feature_kind(kind):
allowed = self.SUPPORTED_FEATURE_KINDS or (self.FEATURE_KIND,)
raise ValueError(
f"{self.__class__.__name__} needs feature kind one of {allowed}, got {kind!r}",
)
[docs]
def supports_feature_kind(self, kind: str) -> bool:
"""Return True if this classifier can consume the given feature kind."""
allowed = self.SUPPORTED_FEATURE_KINDS
if allowed is None:
allowed = (self.FEATURE_KIND,)
return kind in allowed
[docs]
@abstractmethod
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,
) -> "BaseClassifier":
"""Train or fit the classifier on the prepared feature matrix.
Args:
train_features: Array shaped ``(n_train, feature_dim)`` (or method-specific).
train_labels: Integer labels aligned with *train_features*.
val_features: Optional validation features for early stopping / model selection.
val_labels: Optional validation labels.
**kwargs: Method-specific options (epochs, device, …).
Returns:
``self``, for method chaining.
"""
...
[docs]
@abstractmethod
def predict(self, features: np.ndarray) -> np.ndarray:
"""Return predicted integer labels for each row of *features*.
Args:
features: Feature matrix with the same layout used in :meth:`fit`.
Returns:
1-D integer array of length ``n_shots``.
"""
...
[docs]
def predict_proba(self, features: np.ndarray) -> np.ndarray | None:
"""Return class probabilities, or ``None`` if unsupported.
Args:
features: Same layout as :meth:`predict`.
Returns:
Array shaped ``(n_shots, n_classes)``, or ``None``.
"""
return None
[docs]
@classmethod
def from_config(cls, config: dict[str, Any]) -> "BaseClassifier":
"""Construct an instance from a YAML / dict section for this classifier.
Args:
config: Merged options from the global classifiers block and any
per-name subsection (plus pipeline-injected keys such as
``filter_set`` for some methods).
Returns:
A ready-to-:meth:`fit` instance.
Raises:
NotImplementedError: If the subclass does not override this method.
"""
raise NotImplementedError(f"{cls.__name__} does not support construction from config.")