Laboratorio 14

Il Trainer e le Metriche — Addestrare su MNIST

Docente

Soluzione del Laboratorio 13

Le due formule della backpropagation nel DenseLayer:

Matrix<ntype>& backward(const Matrix<ntype>& del_l) override {
    dWt = del_l * (*inputs_ptr).transpose();   // colonna x riga = MATRICE
    delta_l = Wt.transpose() * del_l;          // il delta per il layer precedente
    return delta_l;
}

La derivata della sigmoide che riusa outputs invece di ricalcolare l’esponenziale:

delta_l = del_l.array() * outputs.array();
delta_l = delta_l.array() * (static_cast<ntype>(1.0) - outputs.array());

E il collegamento fra layer e ottimizzatore, una riga che non sa quale ottimizzatore c’è dietro:

void update_parameters(Optimizer<ntype>* optimizer) override {
    optimizer->update(Wt, dWt);
}

Chi ha superato il gradient check ha la certezza matematica che i gradienti sono corretti. Chi ha visto la Loss salire aveva += invece di -=.

Obiettivo di Oggi

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.

Task 1: metric.hpp — l’interfaccia

Perché 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.

Task 2: MnistMetric — l’argmax con Eigen

template <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.

Lo Scheduler: già scritto nel Lab 10

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().

Task 3: trainer.hpp — i quattro collaboratori

template <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.

Task 4: Il metodo 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.

Perché train() e eval()

Il membro is_training sta in layer<ntype> fin dal Lab 09, e finora non serviva a nulla. Serve ora.

virtual void train() { is_training = true; }
virtual void eval()  { is_training = false; }

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).

Task 5: Il metodo 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.

La barra di avanzamento (codice fornito)

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.

Task 6: L’addestramento vero

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.

Accenno: altre Loss e altri ottimizzatori

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.

Task da Completare

La soluzione di riferimento verrà discussa all’inizio della prossima lezione.