Il Trainer e le Metriche — Addestrare su MNIST
Le due formule della backpropagation nel DenseLayer:
La derivata della sigmoide che riusa outputs invece di ricalcolare l’esponenziale:
E il collegamento fra layer e ottimizzatore, una riga che non sa quale ottimizzatore c’è dietro:
Chi ha superato il gradient check ha la certezza matematica che i gradienti sono corretti. Chi ha visto la Loss salire aveva += invece di -=.
Il Lab 13 ha dimostrato che la rete impara — su un campione, per cinque passi, con il codice sparso nel main.
Oggi costruiamo l’infrastruttura per addestrare davvero:
| file | responsabilità | stato |
|---|---|---|
metric.hpp |
come si misura la qualità | da scrivere |
mnist_metric.hpp |
l’accuratezza su MNIST | da scrivere |
trainer.hpp |
il ciclo di addestramento | da scrivere |
schedulers.hpp |
come varia il learning rate | già scritto nel Lab 10 |
Tre file nuovi, una responsabilità ciascuno; il quarto collaboratore del Trainer, lo scheduler, è pronto dal Lab 10. Alla fine addestreremo su MNIST vero.
La barra di avanzamento è codice fornito: non insegna nulla di C++ che non sappiate già, e il tempo di oggi serve al Trainer.
metric.hpp — l’interfacciaPerché una metrica non è “una funzione che calcola l’accuratezza”? Perché l’accuratezza si accumula su molti batch: serve uno stato.
template <typename ntype>
class Metric {
public:
virtual ~Metric() = default;
// Azzera i contatori all'inizio di una fase di test
virtual void reset() = 0;
// Accumula i risultati del batch corrente
virtual void update(const Matrix<ntype>& preds, const Matrix<ntype>& targets) = 0;
// Il valore finale (es. 0.982)
virtual ntype get_result() const = 0;
// Il nome per i log (es. "Performance")
virtual std::string get_name() const = 0;
};Il ciclo di vita è sempre reset() → tanti update() → get_result(). Domani una F1Score o una ConfusionMatrix si inseriranno senza toccare il Trainer.
MnistMetric — l’argmax con Eigentemplate <typename ntype>
class MnistMetric : public Metric<ntype> {
private:
int correct_predictions = 0;
int total_samples = 0;
public:
MnistMetric() { reset(); }
void reset() override {
correct_predictions = 0;
total_samples = 0;
}
void update(const Matrix<ntype>& preds, const Matrix<ntype>& targets) override {
total_samples += 1;
typename Eigen::Index pred_class, dummy, target_class;
// maxCoeff scrive negli argomenti l'indice del massimo
preds.array().maxCoeff(&pred_class, &dummy);
targets.array().maxCoeff(&target_class, &dummy);
if (pred_class == target_class) correct_predictions++;
}
ntype get_result() const override {
if (total_samples == 0) return 0.0;
return static_cast<ntype>(correct_predictions) / total_samples;
}
std::string get_name() const override { return "Performance"; }
};maxCoeff(&riga, &colonna) è l’argmax di Eigen: restituisce il valore e scrive negli argomenti la posizione. dummy sarà sempre 0 perché lavoriamo con vettori colonna 10×1.
Gli indici devono essere di tipo Eigen::Index — l’intero (con segno, a 64 bit) con cui Eigen conta righe e colonne. Passare un int* non compila: è l’errore che tutti fanno la prima volta. Il typename davanti è facoltativo, perché Eigen::Index non dipende da ntype, ma è innocuo e lo troverete così nel framework.
Notate che si confrontano due argmax: la classe predetta e quella del target. Non serve sapere che il target vale 0.99: basta che sia il massimo.
Il learning rate non deve necessariamente restare costante. Lo scheduler decide come varia, e per farlo parla con l’ottimizzatore — ecco perché set_lr/get_lr erano puri virtuali. Il fixedScheduler che avete scritto nel Lab 10 non fa nulla: tiene il learning rate costante e lo stampa a ogni epoca.
Perché averlo, se non fa nulla? Perché serve come base concreta: avere sempre uno scheduler, anche banale, evita di disseminare il Trainer di if (scheduler != nullptr). Oggi il Trainer lo chiamerà una volta per epoca, prima di iniziare i batch: scheduler->step(ep).
Note
Scheduler più interessanti — Cosine Annealing, ReduceLROnPlateau — sono la traccia 2 dei progetti finali. La struttura è già pronta: basta reimplementare step().
trainer.hpp — i quattro collaboratoritemplate <typename ntype>
class Trainer {
layer<ntype>* model; // per ora può essere solo Sequential
Optimizer<ntype>* optimizer;
Loss<ntype>* lossfunc;
Scheduler<ntype>* scheduler; // può essere nullptr
public:
Trainer(layer<ntype>* m, Loss<ntype>* crit,
Optimizer<ntype>* opt = nullptr,
Scheduler<ntype>* sch = nullptr)
: model(m), optimizer(opt), lossfunc(crit), scheduler(sch) {}Notate il tipo del primo membro: layer<ntype>*, non Sequential<ntype>*. Il Trainer non sa di stare addestrando una rete sequenziale — conosce solo l’interfaccia layer. È il motivo per cui nel Lab 09 abbiamo fatto derivare Sequential da layer.
Tutti puntatori crudi, e qui è corretto: il Trainer non possiede nulla, usa oggetti che vivono nel main. unique_ptr esprimerebbe una proprietà che non ha.
test()ntype test(BaseDataset<ntype>& test_dataset, Metric<ntype>& metric,
int maxelem = -1, bool info = true) {
model->eval(); // modalità inferenza
test_dataset.reset_and_shuffle(false); // sul test NON si mescola
metric.reset();
ntype total_loss = 0.0;
int num_batches = 0;
int tmax = (maxelem > 0)
? std::min(test_dataset.get_num_batches(), maxelem)
: test_dataset.get_num_batches();
for (int t = 0; t < tmax; t++) {
Batch<ntype> batch = test_dataset.get_next_batch();
Matrix<ntype>& preds = model->forward(batch.inputs); // SOLO forward
total_loss += lossfunc->forward(preds, batch.targets);
metric.update(preds, batch.targets); // il calcolo è tutto nella metrica
num_batches++;
}
ntype avg_loss = total_loss / num_batches;
ntype metric_result = metric.get_result();
if (info)
std::cout << std::setprecision(8) << "---> Loss Media: " << avg_loss
<< " | " << metric.get_name() << ": "
<< metric_result * 100.0 << "%\n";
return metric_result;
}Tre cose che distinguono il test dal training: model->eval(), nessun backward(), nessun update_parameters(). E lo shuffle è disattivato: mescolare il test set non cambia il risultato, e non mescolarlo rende i log confrontabili fra epoche.
train() e eval()Il membro is_training sta in layer<ntype> fin dal Lab 09, e finora non serviva a nulla. Serve ora.
Alcuni layer devono comportarsi diversamente in addestramento e in inferenza. Il caso classico è il Dropout: durante il training spegne neuroni casuali, in inferenza deve lasciarli tutti accesi — altrimenti la stessa immagine darebbe predizioni diverse a ogni chiamata.
Nessuno dei nostri layer attuali usa is_training, ma l’infrastruttura c’è, ed è quello che rende possibile la traccia 3 dei progetti (augmentation e regolarizzazione).
train()void train(BaseDataset<ntype>& train_dataset, BaseDataset<ntype>& val_dataset,
Metric<ntype>& metric, int epochs,
int maxelem = -1, bool running_test = true) {
using cl = std::chrono::high_resolution_clock;
ntype performance;
int tmax = (maxelem > 0)
? std::min(train_dataset.get_num_batches(), maxelem)
: train_dataset.get_num_batches();
auto tini = cl::now();
for (int ep = 0; ep < epochs; ++ep) {
auto ep_time = cl::now();
std::cout << ">>> Epoch " << ep + 1 << "/" << epochs << "\n";
if (scheduler) scheduler->step(ep); // il lr PRIMA di usarlo
model->train(); // modalità addestramento
train_dataset.reset_and_shuffle(); // sul training SI mescola
ntype loss = 0.0;
int t = 0;
while (t < tmax && train_dataset.has_next_batch()) {
Batch<ntype> batch = train_dataset.get_next_batch();
// 1A. Forward
Matrix<ntype>& preds = model->forward(batch.inputs);
// 1B. Loss
loss += lossfunc->forward(preds, batch.targets);
// 2A. Backward
model->backward(lossfunc->backward(preds, batch.targets));
// 2B. Update
model->update_parameters(optimizer);
if (t % 100 == 0 || t == tmax - 1) progress_bar(ntype(t + 1) / tmax);
t++;
}
auto ep_delt = std::chrono::duration_cast<std::chrono::microseconds>(
cl::now() - ep_time).count();
std::cout << "Time: " << ep_delt / 1e6 << " s | Train Loss: "
<< std::setprecision(6) << loss / tmax << "\n";
if (running_test) {
model->eval();
performance = test(val_dataset, metric, -1, true);
}
}
std::cout << "[INFO] final performance=" << performance << "\n";
}Le quattro righe numerate sono l’intero machine learning. Tutto il resto — timer, barra di avanzamento, shuffle, scheduler — è contorno.
Un addestramento che stampa nulla per due minuti sembra bloccato. Una barra costa dieci righe e cambia l’esperienza. Non c’è nulla da imparare a scriverla: copiatela in trainer.hpp così com’è e chiamatela in train() ogni 100 batch con progress_bar(ntype(t + 1) / tmax), come nel codice di train().
void progress_bar(double progresso) {
int len_barra = 50;
std::cout << "[";
int pos = len_barra * progresso;
std::cout << "\033[32m"; // verde
for (int i = 0; i < len_barra; ++i) {
if (i < pos) std::cout << "=";
else if (i == pos) std::cout << ">";
else std::cout << " ";
}
std::cout << "\033[0m"; // reset colore
std::cout << "] " << int(progresso * 100.0) << " %\r";
std::cout.flush();
if (pos == len_barra) std::cout << "\n";
}Due dettagli: \r (carriage return) riporta il cursore a inizio riga senza andare a capo, così la barra si sovrascrive; std::cout.flush() è obbligatorio, perché senza \n il buffer non viene svuotato e non vedreste nulla fino alla fine.
\033[32m è una sequenza di escape ANSI per il colore verde.
int main() {
rng.seed(42);
// il secondo argomento è il numero MASSIMO di elementi da caricare
MnistDataset<double> train_dataset("mnist_train.csv", 10000);
MnistDataset<double> test_dataset("mnist_test.csv", 2000);
Sequential<double> net;
net.add(std::make_unique<DenseLayer<double>>(128, 784, 1, 1)); // Xavier
net.add(std::make_unique<SigmoidLayer<double>>());
net.add(std::make_unique<DenseLayer<double>>(10, 128, 1, 1));
net.add(std::make_unique<SigmoidLayer<double>>());
MSELoss<double> loss;
SDOptimizer<double> opt(0.1);
MnistMetric<double> metric;
fixedScheduler<double> sched(&opt, 0.1);
Trainer<double> trainer(&net, &loss, &opt, &sched);
trainer.train(train_dataset, test_dataset, metric, /*epochs=*/5);
net.save_model("mlp.model");
}Cosa aspettarsi, con learning rate 0.1 e una rete 784→128→10:
| epoca | Train Loss | Performance |
|---|---|---|
| 1 | ~0.9 | 85–90% |
| 3 | ~0.4 | 92–94% |
| 5 | ~0.3 | 94–95% |
Se la performance resta intorno al 10%, la rete tira a caso su dieci classi: update_parameters non viene chiamato, o il learning rate è azzerato. Se la Loss diventa nan, il learning rate è troppo alto — provate 0.01.
Cominciate con maxelem piccolo (50 batch) per verificare che tutto girri, poi togliete il limite.
Il framework usa MSELoss con sigmoide finale, che su MNIST arriva tranquillamente sopra il 95%. Esistono però scelte più moderne, e sono due delle tracce per il progetto finale.
Softmax + Cross-Entropy. Il softmax normalizza le uscite in una distribuzione di probabilità:
\[ \text{softmax}(z_i) = \frac{e^{z_i}}{\sum_j e^{z_j}} \qquad L = -\sum_k t_k \log(p_k) \]
Il fatto notevole è che, componendo i due, il gradiente si semplifica a \(\delta = p - t\): nessuna derivata da propagare attraverso l’esponenziale. Con MSE + sigmoide, invece, il gradiente porta il fattore \(\sigma(1-\sigma)\) che si annulla quando la rete sbaglia con grande sicurezza — proprio quando servirebbe correggere di più.
Adam. Invece di un learning rate unico, ne adatta uno per ogni parametro usando le medie mobili del gradiente (\(m\)) e del suo quadrato (\(v\)):
\[ m_t = \beta_1 m_{t-1} + (1-\beta_1)\, g_t \qquad v_t = \beta_2 v_{t-1} + (1-\beta_2)\, g_t^2 \]
\[ \theta_t = \theta_{t-1} - \eta \, \frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \varepsilon} \]
dove \(\hat{m}\) e \(\hat{v}\) sono corretti per il bias iniziale. La divisione per \(\sqrt{v}\) è il punto: i parametri con gradienti storicamente grandi vengono aggiornati più cautamente.
Implementarli è materia da progetto: Optimizer e Loss sono già le interfacce giuste, non serve toccare nient’altro. Attenzione a un dettaglio di design — Adam ha uno stato per parametro, quindi un singolo oggetto condiviso fra layer diversi non basta: è il problema che il metodo step() e una mappa indicizzata sul tensore risolvono.
La soluzione di riferimento verrà discussa all’inizio della prossima lezione.