Quickstart: classification¶
This page fits a three-class model to the iris data, one block at a time. It follows the same steps as the regression quickstart, which explains the parts the two share in more detail. On a CPU the run takes under a minute.
1. Load the data and split it¶
import numpy as np
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
iris = load_iris()
x, y = iris.data.astype(np.float32), iris.target
feature_names = list(iris.feature_names)
# 70% train, 15% dev, 15% test, each split with the same class proportions.
x_train, x_rest, y_train, y_rest = train_test_split(
x, y, test_size=0.30, stratify=y, random_state=0)
x_dev, x_test, y_dev, y_test = train_test_split(
x_rest, y_rest, test_size=0.50, stratify=y_rest, random_state=0)
Iris has 150 flowers, 50 of each of three species, with 4 measurements per flower: sepal length and width, petal length and width. The targets are the class indices 0, 1 and 2. As in regression, the train split fits the neurons, the dev split chooses them and the depth, and the test split measures the result. Stratifying keeps all three classes in every split.
2. Standardize and wrap in loaders¶
from sklearn.preprocessing import StandardScaler
from torch.utils.data import DataLoader
from torchsonn import SONNDataset
scaler = StandardScaler().fit(x_train)
x_train, x_dev, x_test = (scaler.transform(a).astype(np.float32)
for a in (x_train, x_dev, x_test))
batch = 128 # larger than the 105 training rows
train_dl = DataLoader(SONNDataset(x_train, y_train), batch_size=batch,
shuffle=True)
dev_dl = DataLoader(SONNDataset(x_dev, y_dev), batch_size=batch)
test_dl = DataLoader(SONNDataset(x_test, y_test), batch_size=batch)
The scaler is fitted on the training rows only. One batch holds the whole training split, as the LBFGS optimizer prefers.
3. Configure the model¶
from omegaconf import OmegaConf
from torchsonn import SONN
config = OmegaConf.merge(SONN.default_config(), {
"model": {
"type": "multi-class",
"num_classes": 3,
"ref_functions": ["linear_cov"],
"nbest_neurons": 4,
"max_neuron_models": 6,
"shortcut": False,
},
"train": {
"max_layer_count": 5,
"steps": 900,
"eval_step_interval": 5,
"early_stop_patience": 1e-4,
"early_stop_tolerance_steps": 5,
"checkpoint_dir": "checkpoints/iris",
"optimizer": {
"name": "lbfgs",
"optimizer_params": {
"lr": 0.1, "min_lr": 0.01, "history_size": 10,
},
},
},
})
type: multi-classandnum_classes: 3: a classifier over three classes.ref_functions,nbest_neuronsandmax_neuron_models:linear_covpair neurons, 4 survivors per layer. With 4 inputs there are exactly 6 pairs, and the cap of 6 tries them all.shortcut: False: every layer after the first reads only the 4 survivors of the layer before, not the original features.train: the same kind of candidate fit as in the regression quickstart, with up to 5 layers and a slightly more patient early stop (5 evaluations without improvement before the learning rate halves).
Each neuron outputs one number. The default multi-class head, the soft
binner, turns that number into class probabilities. It places the classes
at fixed points between 0.05 and 0.95, in label order, and scores each
class by how close the neuron's output lies to the class's point. The
softmax of those scores gives the probabilities, so each neuron learns to
output the point of the right class. With soft_binner: False the model
learns a linear map from the neuron's output to the class scores instead
(see Classification).
For imbalanced classes, Trainer(config, class_weights=...) takes a tensor
with one loss weight per class. Iris is balanced and needs none.
4. Train¶
from torchsonn import Trainer
from torchsonn.logger import setup_logger
setup_logger()
Trainer.set_seed(config.train.seed)
model = SONN(config, d_model=x.shape[1], feature_names=feature_names)
trainer = Trainer(config, feature_names=feature_names)
trainer.train(model, train_dl, dev_dl, test_dl)
The growth-rule lines of the log read the same way as in regression:
Layer #0: error 0.0195, best 0.0195 at layer 0; first layer; 0 of 5 layers without improvement
Layer #1: error 0.0132, best 0.0132 at layer 1; improved by +0.0063 (margin 0.0000); 0 of 5 layers without improvement
Layer #2: error 0.0172, best 0.0132 at layer 1; improved by -0.0040 (margin 0.0000); 1 of 5 layers without improvement
Layer #3: error 0.0109, best 0.0109 at layer 3; improved by +0.0023 (margin 0.0000); 0 of 5 layers without improvement
Layer #4: error 0.0148, best 0.0109 at layer 3; improved by -0.0039 (margin 0.0000); 1 of 5 layers without improvement
For a classifier, the error is the normalized cross-entropy on the dev
split: the average negative log-likelihood of the true class, divided by
the entropy of the class frequencies. 1 is no better than predicting the
class frequencies, and 0 is a perfect fit (see
Criteria). The search reaches max_layer_count
after layer 4, which is worse than layer 3, so the model keeps layers 0 to
3.
5. Restore and evaluate¶
model = SONN(config, d_model=x.shape[1], feature_names=feature_names)
trainer.load_model_checkpoint(model, "cpu")
print("layers kept:", len(model.layers))
from sklearn.metrics import confusion_matrix, log_loss
log_probs, targets = trainer.infer(model, test_dl)
probs = log_probs.exp().numpy()
y_true, y_pred = targets.numpy(), probs.argmax(axis=1)
print(f"test log loss {log_loss(y_true, probs, labels=[0, 1, 2]):.4f}")
print(f"test accuracy {(y_pred == y_true).mean():.3f}")
print(confusion_matrix(y_true, y_pred))
print("features used:", model.get_selected_features())
layers kept: 4
test log loss 0.0029
test accuracy 1.000
[[8 0 0]
[0 8 0]
[0 0 7]]
features used: sepal length (cm), sepal width (cm), petal length (cm), petal width (cm)
For a multi-class model, infer returns log-probabilities, one column per
class, so exp() gives the probabilities. The model classifies all 23 test
flowers correctly. With 23 test rows, that shows the model works on this
data; it is too few rows to measure its accuracy precisely.
What to change next¶
- The head:
soft_binner: Falsereplaces the soft binner with a learned projection shared by a layer's candidates; addinguse_neuron_proj: True(which needssoft_binner: False) gives every candidate its own.use_output_projectionadds a linear head over the last layer's survivors (see Classification). - Class weights: pass
class_weightsto theTrainerwhen some classes are rare. - The search: more neuron families, more survivors and deeper networks, as in the regression quickstart. The Otto tutorial is a larger classification example, scored by multi-class log loss.