Laboratorio 10

STL, Smart Pointer e le Strategy dell’Addestramento

Docente

Soluzione del Laboratorio 09

L’alias che ha reso generico tutto il framework:

template <typename ntype>
using Matrix = Eigen::Matrix<ntype, Eigen::Dynamic, Eigen::Dynamic>;

Il DenseLayer nella sua forma definitiva — senza bias, con inputs_ptr:

DenseLayer(int Nn, int Ni, int type = 0, int distro = 1) : Nnodes(Nn), Nin(Ni) {
    Wt.resize(Nn, Ni);
    dWt.resize(Nn, Ni);
    if (type >= 0) init_weights(type, distro);
}

Matrix<ntype>& forward(const Matrix<ntype>& inp) override {
    if (inp.rows() != Nin)
        throw std::runtime_error("[DenseLayer] Input size non corrisponde");
    inputs_ptr = &inp;              // memorizzo DOVE è l'input, non lo copio
    outputs = Wt * (*inputs_ptr);
    return outputs;                 // riferimento al mio buffer
}

Chi ha provato la trappola DenseLayer<float>(784, 128) ha visto l’eccezione: con la nuova convenzione (Nnodes, Nin) quel layer si aspetta 128 input.

Obiettivo di Oggi

Il Sequential del Lab 09 funziona ma è ancora fragile:

layer<ntype>** layers;
int capacity;                 // e se servono più layer del previsto?
int size;
~Sequential() { for (...) delete layers[i]; delete[] layers; }

Un array crudo, una capacità fissa, e un distruttore scritto a mano. Oggi sostituiamo tutto con std::vector e std::unique_ptr, e la Regola del Tre scompare.

Nella seconda parte scriviamo tre classi piccole ma decisive: Loss, Optimizer e Scheduler. Sono le tre Strategy della lezione di oggi (Lezione 20): interfacce astratte con una sola implementazione concreta ciascuna, che il Sequential userà nel Lab 13 senza sapere quale implementazione ha davanti. Le scriviamo oggi, da sole e testabili, perché il Lab 13 avrà già abbastanza da fare con la backpropagation.

Dalla Regola del Tre alla Regola dello Zero

Il principio: se nessun membro della classe possiede memoria cruda, non serve scrivere né distruttore, né costruttore di copia, né operatore di assegnazione. Il compilatore genera quelli corretti da sé.

Lab 06 (Matrix) Lab 09 (Sequential) Oggi
Membro double* data layer<ntype>** layers std::vector<std::unique_ptr<...>>
Distruttore a mano a mano non serve
Copy ctor a mano mancante (bug) generato
operator= a mano mancante (bug) generato

Si chiama Rule of Zero, ed è il modo in cui si scrive C++ moderno: la gestione della memoria è delegata a tipi che la sanno già fare.

Task 1: Il nuovo contenitore

In sequential.hpp, includete <vector> e <memory>, poi sostituite i tre membri con uno:

template <typename ntype>
class Sequential : public layer<ntype> {
protected:
    // Il vettore conterrà tutti i layer del modello usando gli smart pointer.
    // L'utilizzo di unique_ptr dealloca automaticamente la memoria.
    std::vector<std::unique_ptr<layer<ntype>>> layers;

public:
    Sequential() = default;    // non serve più un costruttore complesso

    // La Rule of 3 non è più necessaria: ci pensano vector e unique_ptr
    // (Rule of 0)

    std::string get_type() const override { return "Sequential"; }

Cancellate il distruttore. Se avete la tentazione di tenerlo “per sicurezza”, resistete: un distruttore vuoto scritto a mano disabilita le ottimizzazioni di move che il compilatore genererebbe.

Task 2: clear() e size()

Due metodi che si limitano a delegare al vettore:

    void clear() {
        // svuota il vettore: unique_ptr chiama automaticamente i distruttori
        layers.clear();
    }

    size_t size() const { return layers.size(); }

Notate che size() è diventato un metodo, non più una variabile membro: il vettore conosce già la propria dimensione, tenerne una copia significa avere due verità che possono divergere.

Task 3: add() e il trasferimento di proprietà

Qui sta il concetto centrale del laboratorio:

    // Aggiunge un layer trasferendone l'ownership con std::move
    void add(std::unique_ptr<layer<ntype>> l) {
        if (l == nullptr)
            throw std::invalid_argument(
                "[ERROR] Impossibile aggiungere un layer nullo (nullptr).");
        layers.push_back(std::move(l));
    }

Tre cose da capire:

  • Il parametro è per valore, non per riferimento: chi chiama add() deve cedere il proprio unique_ptr, perché un unique_ptr non è copiabile.
  • std::move(l) trasferisce la proprietà nel vettore. Dopo quella riga l è vuoto: la rete è l’unica proprietaria del layer.
  • Il controllo su nullptr resta utile: std::move di un puntatore nullo non dà errore, e il crash arriverebbe più tardi nel forward().

Note

unique_ptr significa letteralmente “proprietario unico”. È il tipo che esprime nel codice quello che prima era solo un commento: “questa rete possiede i suoi layer e li distruggerà lei”.

Task 4: Il forward pass

Il ciclo cambia poco, ma tre dettagli sì:

    // FORWARD PASS: passaggio concatenato per reference (zero copie Eigen)
    Matrix<ntype>& forward(const Matrix<ntype>& input) override {
        if (layers.empty())
            throw std::runtime_error(
                "[ERROR] Impossibile eseguire la forward pass su una rete vuota.");

        // current_out è l'indirizzo della matrice outputs del layer attuale
        Matrix<ntype>* current_out = &layers[0]->forward(input);

        for (size_t i = 1; i < layers.size(); ++i)
            current_out = &layers[i]->forward(*current_out);

        return *current_out;
    }
  • layers.empty() invece di size == 0
  • size_t come indice, non int: è il tipo che vector::size() restituisce, e mescolarli genera warning di conversione con segno
  • layers[i]->forward(...): l’operatore -> di unique_ptr dereferenzia il puntatore posseduto, quindi la sintassi resta identica a prima

Task 5: Il Test

#include <iostream>
#include <memory>
#include "eigen_wrap.hpp"
#include "sequential.hpp"

int main() {
    rng.seed(42);

    Sequential<double> net;      // non serve più passare la capacità

    // make_unique è la forma da preferire (C++14)
    net.add(std::make_unique<DenseLayer<double>>(128, 784));
    net.add(std::make_unique<SigmoidLayer<double>>());

    // Forma equivalente in stile C++11, se make_unique non fosse disponibile
    net.add(std::unique_ptr<layer<double>>(new DenseLayer<double>(10, 128)));
    net.add(std::make_unique<tanhLayer<double>>());

    std::cout << "Numero di layer: " << net.size() << std::endl;

    Matrix<double> X = Matrix<double>::Random(784, 1);
    std::cout << "Output:\n" << net.forward(X).transpose() << std::endl;

    std::cout << "\nNessun delete chiamato. Nessun memory leak." << std::endl;
}

Le verifiche che contano:

  1. Nel vostro codice non deve comparire nessun delete. Cercatelo con grep -rn "delete" *.hpp *.cpp: se ne trovate uno, non avete finito.
  2. Provate ad aggiungere 50 layer: prima esplodeva con "Network capacity esaurita!", ora il vettore cresce da solo.
  3. Compilate con AddressSanitizer (-fsanitize=address) ed eseguite: zero leak.

Perché non shared_ptr

Domanda legittima: perché unique_ptr e non shared_ptr?

Perché il modello di proprietà qui è esclusivo: un layer appartiene a una sola rete, e quando la rete muore il layer muore con lei. shared_ptr servirebbe se lo stesso layer dovesse essere condiviso fra due reti — non è il nostro caso, e costerebbe un contatore atomico a ogni copia.

La regola pratica: unique_ptr per default, shared_ptr solo quando la proprietà è genuinamente condivisa e non sapete chi morirà per ultimo.

Seconda Parte: le Strategy dell’Addestramento

Per addestrare una rete servono tre decisioni indipendenti fra loro:

decisione domanda a cui risponde oggi in futuro
Loss quanto è sbagliato l’output? MSELoss Cross-Entropy
Optimizer come muovo i pesi dato il gradiente? SDOptimizer Adam, momentum
Scheduler quale learning rate a questa epoca? fixedScheduler step, cosine

Ognuna è una classe astratta con metodi virtuali puri, più una classe concreta che la implementa. La rete terrà solo un puntatore all’interfaccia: cambiare strategia sarà una riga nel main. È il pattern Strategy della Lezione 20, applicato tre volte.

Tre file header-only, tutti template su ntype, tutti con #include "./eigen_wrap.hpp".

Task 6: loss.hpp

template <typename ntype>
class Loss {
public:
    virtual ~Loss() = default;
    // l'errore scalare
    virtual ntype forward(const Matrix<ntype>& predictions, const Matrix<ntype>& targets) = 0;
    // il gradiente rispetto alle predizioni: reference a un membro, zero allocazioni
    virtual const Matrix<ntype>& backward(const Matrix<ntype>& predictions, const Matrix<ntype>& targets) = 0;
};
  1. Scrivete l’interfaccia qui sopra (guardia __LOSS__).
  2. Scrivete MSELoss<ntype> : public Loss<ntype> con un membro Matrix<ntype> d_loss.
    • forward restituisce \(\sum (p - t)^2\): (predictions - targets).array().square().sum().
    • backward fa d_loss.resize(...) alle dimensioni di predictions, poi d_loss = static_cast<ntype>(2.0) * (predictions - targets) e restituisce d_loss.
  3. Perché backward restituisce una const& a un membro e non una Matrix per valore? Stessa ragione del forward dei layer nel Lab 09: in un ciclo di addestramento verrà chiamata milioni di volte.

Task 7: optimizers.hpp

template <typename ntype>
class Optimizer {
public:
    virtual ~Optimizer() = default;
    virtual void step() {}                                 // gancio per epoca: qui non serve
    virtual void set_lr(ntype new_lr) = 0;
    virtual ntype get_lr(void) = 0;
    // l'unico metodo che conta: aggiorna UN parametro dato il SUO gradiente
    virtual void update(Matrix<ntype>& param, const Matrix<ntype>& grad) = 0;
};
  1. Scrivete l’interfaccia (guardia __OPTIMIZERS__; includete anche <unordered_map>, <cmath>, <stdexcept>: serviranno alle versioni future).
  2. Scrivete SDOptimizer<ntype> (steepest descent) con un membro privato ntype lr, costruttore SDOptimizer(ntype learning_rate = 0.01), i due accessori, e update che fa una sola riga: param -= lr * grad;
  3. Notate che update non sa cosa sta aggiornando: pesi, bias, o altro. Riceve una matrice e il suo gradiente. È questo che lo rende universale.

Task 8: schedulers.hpp

Lo scheduler possiede la politica del learning rate e la impone all’optimizer, di cui tiene un puntatore.

template <typename ntype>
class Scheduler {
protected:
    ntype current_lr;
    Optimizer<ntype>* optimizer;
public:
    virtual ~Scheduler() = default;
    virtual ntype get_lr() const = 0;
    virtual void set_lr(ntype lr) = 0;
    virtual void step(int current_epoch) = 0;     // chiamato una volta per epoca
};
  1. Scrivete l’interfaccia (guardia __SCHEDULERS__, #include "./optimizers.hpp").
  2. Scrivete fixedScheduler<ntype>: nel costruttore fixedScheduler(Optimizer<ntype>* opt, ntype lr = 0.001) salvate entrambi e chiamate subito optimizer->set_lr(current_lr); set_lr aggiorna il membro e l’optimizer; step non cambia nulla — stampa solo il learning rate corrente.
  3. I membri sono protected nella base: nella derivata portateli nello scope con using bc = Scheduler<ntype>; using bc::optimizer, bc::current_lr; — con i template, senza questa riga il compilatore non li trova. Lo avete già visto nel Lab 09.

Task 9: Testare le Strategy da sole

Aggiungete al main di oggi, dopo il forward:

Matrix<double> Y = Matrix<double>::Zero(10, 1);  Y(3, 0) = 1.0;   // bersaglio one-hot
MSELoss<double> loss;
double L = loss.forward(Z, Y);
const Matrix<double>& dL = loss.backward(Z, Y);    // 10x1 come Z

Matrix<double> P = Matrix<double>::Ones(2, 2), G = Matrix<double>::Ones(2, 2);
SDOptimizer<double> opt(0.1);
opt.update(P, G);                                  // P(0,0) deve valere 0.9

fixedScheduler<double> sched(&opt, 0.05);          // opt.get_lr() deve valere 0.05
sched.step(1);

Optimizer<double>* opt_ptr = &opt;                 // la rete vedrà solo questo
Loss<double>* loss_ptr = &loss;

Stampate L, le dimensioni e la norma di dL, P(0,0), opt.get_lr(), e poi loss_ptr->forward(Z, Y) e opt_ptr->get_lr() attraverso l’interfaccia: è così che le userà il Sequential.

Task da Completare

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