Laboratorio 11

Serializzazione del Modello e Factory Method

Docente

Soluzione del Laboratorio 10

Il contenitore che ha fatto sparire la Regola del Tre:

std::vector<std::unique_ptr<layer<ntype>>> layers;

Sequential() = default;      // e nessun distruttore: Rule of Zero

void add(std::unique_ptr<layer<ntype>> l) {
    if (l == nullptr)
        throw std::invalid_argument("[ERROR] Impossibile aggiungere un layer nullo.");
    layers.push_back(std::move(l));    // trasferimento di proprietà
}

Matrix<ntype>& forward(const Matrix<ntype>& input) override {
    if (layers.empty())
        throw std::runtime_error("[ERROR] forward pass su una rete vuota.");
    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;
}

Chi ha fatto grep -rn "delete" sul progetto non ha trovato nulla. È il segno che la memoria è gestita dai tipi, non da noi.

Soluzione del Laboratorio 10: le tre Strategy

Tre interfacce, tre implementazioni, e il test che le usa solo attraverso il puntatore alla base:

// loss.hpp — MSELoss
ntype forward(const Matrix<ntype>& predictions, const Matrix<ntype>& targets) override {
    return (predictions - targets).array().square().sum();
}
// optimizers.hpp — SDOptimizer: un tensore alla volta, non "pesi e bias"
void update(Matrix<ntype>& param, const Matrix<ntype>& grad) override {
    param -= lr * grad;
}
// schedulers.hpp — fixedScheduler: il costruttore impone subito il lr all'optimizer
fixedScheduler(Optimizer<ntype>* opt, ntype lr = 0.001) {
    current_lr = lr;  optimizer = opt;  optimizer->set_lr(current_lr);
}

L’errore più comune: backward della loss che restituisce Matrix<ntype> per valore invece di const Matrix<ntype>& a un membro. Compila, funziona, e alloca una matrice a ogni chiamata: nel Lab 13 sarebbero milioni.

Da oggi questi tre header restano nel progetto, intatti, fino alla fine del corso. Il Sequential li incontrerà nel Lab 13, il Trainer nel Lab 14.

Obiettivo di Oggi

Addestrare una rete costa minuti o ore. Se alla fine del programma i pesi svaniscono, tutto quel lavoro è perso.

Oggi rendiamo il modello persistente, e per farlo dobbiamo risolvere un problema non banale: ricaricare da file una rete di cui non sappiamo, a priori, di quali layer sia composta. La soluzione è il pattern Factory Method (Lezione 20).

Il problema del caricamento

Salvare è facile: scorro i layer e scrivo i pesi.

Ricaricare no. Il file contiene una sequenza di numeri, ma per costruire gli oggetti bisogna sapere quale tipo istanziare, e in che ordine:

DenseLayer          <- il tipo
128 784             <- la sua configurazione
0.0231  -0.0117 ... <- i suoi pesi
SigmoidLayer        <- il tipo
                    <- (nessuna configurazione)
DenseLayer
10 128
...

Serve quindi che ogni layer sappia dichiarare tre cose: come si chiama, come si configura, e quali pesi ha.

Task 1: I tre metodi nell’interfaccia

In layer.hpp questi metodi esistono già dal Lab 09, con corpo vuoto di default — ed è la scelta giusta: un layer di attivazione non ha nulla da salvare, e non deve essere costretto a dichiararlo.

virtual std::string get_type() const = 0;                 // il nome
virtual void save_config(std::ostream& os) const {}       // gli iperparametri
virtual void save_weights(std::ostream& os) const {}      // i pesi
virtual void load_weights(std::istream& is) {}            // rilettura pesi

Tip

Le firme usano std::ostream e std::istream, non std::ofstream. Così lo stesso codice scrive su file, su std::cout per il debug, o su uno std::stringstream nei test. È il consiglio della Lezione 11.

Task 2: Implementarli in DenseLayer

void save_config(std::ostream& os) const override {
    // Gli stessi argomenti richiesti dal costruttore, nello stesso ordine
    os << Nnodes << " " << Nin << "\n";
}

void save_weights(std::ostream& os) const override {
    for (int i = 0; i < Wt.rows(); ++i)
        for (int j = 0; j < Wt.cols(); ++j)
            os << Wt(i, j) << "\n";
}

void load_weights(std::istream& is) override {
    for (int i = 0; i < Wt.rows(); ++i)
        for (int j = 0; j < Wt.cols(); ++j)
            is >> Wt(i, j);
}

Il commento su save_config è la regola d’oro della serializzazione: scrivere esattamente ciò che il costruttore chiede, nell’ordine in cui lo chiede. Chi rilegge farà l’operazione simmetrica, e se i due ordini divergono il file si disallinea silenziosamente.

Task 3: Il Factory Method

Ogni layer concreto espone un metodo statico che costruisce un’istanza leggendo la propria configurazione dallo stream:

// in DenseLayer
static DenseLayer<ntype>* create_from_stream(std::istream& is) {
    int nodes, in_features;

    // Lettura con lo stesso identico ordine di save_config
    is >> nodes >> in_features;

    // type = -1 evita di generare pesi casuali: subito dopo li carichiamo
    return new DenseLayer<ntype>(nodes, in_features, -1, 1);
}
// in SigmoidLayer e tanhLayer: nessuna configurazione da leggere
static SigmoidLayer<ntype>* create_from_stream(std::istream& is) {
    return new SigmoidLayer<ntype>();
}

Il -1 come type è un dettaglio elegante: nel costruttore la condizione è if (type >= 0) init_weights(...), quindi un valore negativo salta l’inizializzazione casuale. Su una rete con milioni di pesi, generarli per poi sovrascriverli tutti sarebbe puro spreco.

Il metodo è static perché deve poter essere chiamato senza avere un oggetto: serve proprio a crearlo.

Task 4: Il dispatcher in Sequential

Nella sezione protected di sequential.hpp, la funzione che traduce una stringa in un oggetto:

layer<ntype>* create_layer(std::istream& is, const std::string& type,
                           const std::string& msg) {
    layer<ntype>* l = nullptr;

    if      (type == "DenseLayer")   l = DenseLayer<ntype>::create_from_stream(is);
    else if (type == "SigmoidLayer") l = SigmoidLayer<ntype>::create_from_stream(is);
    else if (type == "tanhLayer")    l = tanhLayer<ntype>::create_from_stream(is);
    else {
        std::cerr << msg << " -> Tipo sconosciuto: " << type << "\n";
        exit(1);
    }
    return l;
}

Questa è l’unica catena di if che ci concediamo, e non è l’anti-pattern denunciato nella Lezione 16: là il problema era usare dynamic_cast per chiedere a un oggetto che tipo sei. Qui il tipo non esiste ancora — stiamo decidendo quale creare. È esattamente il compito di una factory.

Note

Il costo di questo design: aggiungere un nuovo layer richiede di toccare create_layer. I framework industriali lo evitano con un registro auto-popolato (std::map<std::string, funzione>), ma la catena di if è più leggibile e per tre tipi va benissimo.

Task 5: save_model e load_model

void save_model(const std::string& filename) const {
    std::ofstream os(filename);
    if (!os.is_open()) {
        std::cerr << "[ERROR] Cannot open file " << filename << "\n";
        exit(1);
    }
    for (const auto& l : this->layers) {
        os << l->get_type() << "\n";   // 1. il nome del tipo
        l->save_config(os);            // 2. i suoi iperparametri
        l->save_weights(os);           // 3. i suoi pesi
    }
    std::cout << "[INFO] Model saved successfully to " << filename << "\n";
}
void load_model(const std::string& filename) {
    std::ifstream is(filename);
    if (!is.is_open()) {
        std::cerr << "[ERROR] Cannot open file " << filename << "\n";
        exit(1);
    }
    this->layers.clear();              // cancella un eventuale modello esistente

    std::string type;
    while (is >> type) {               // finché c'è un tipo da leggere
        layer<ntype>* raw_l = this->create_layer(is, type,
                                     "[load_model] Wrong layer name");
        if (raw_l != nullptr) {
            std::unique_ptr<layer<ntype>> l(raw_l);   // subito in un unique_ptr
            l->load_weights(is);
            this->add(std::move(l));
        }
    }
    std::cout << "[INFO] Model loaded successfully from " << filename << "\n";
}

Notate il punto in cui il puntatore crudo restituito dalla factory viene avvolto in un unique_ptr: immediatamente. Se load_weights lanciasse un’eccezione due righe più sotto, la memoria sarebbe già protetta dallo stack unwinding (Lezione 19).

Task 6: Gli accessori pubblici

save_model itera su this->layers, che è protected. Dall’esterno serve un modo per ispezionare la rete — per i test, per la diagnostica, per il gradient check del Lab 13:

    // restituisce tutto il vettore dei layer
    const std::vector<std::unique_ptr<layer<ntype>>>& get_layers() const {
        return layers;
    }

    // restituisce un layer specifico
    layer<ntype>* get_layer(int i) const {
        return layers[i].get();
    }

Due dettagli di design:

  • get_layers() restituisce un riferimento costante: chi lo riceve può leggere il vettore e contarlo, ma non può aggiungere o rimuovere layer né rubarne la proprietà. Restituirlo per valore sarebbe impossibile, perché unique_ptr non è copiabile.
  • get_layer(i) restituisce un puntatore crudo via .get(). È corretto: il chiamante ottiene un accesso, non la proprietà — che resta della rete. Restituire un unique_ptr significherebbe cedere il layer.

Task 6: La verifica del round-trip

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

    Sequential<double> net;
    net.add(std::make_unique<DenseLayer<double>>(128, 784));
    net.add(std::make_unique<SigmoidLayer<double>>());
    net.add(std::make_unique<DenseLayer<double>>(10, 128));

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

    net.save_model("modello_test.txt");

    Sequential<double> net_loaded;      // vuota: non sa nulla dell'architettura
    net_loaded.load_model("modello_test.txt");

    Matrix<double> Z2 = net_loaded.forward(X);
    std::cout << "Rete ricaricata:\n" << Z2.transpose() << std::endl;
}

Il criterio: Z1 e Z2 devono coincidere. E notate quanto è forte questo test: net_loaded viene costruita vuota, senza che il codice dichiari mai l’architettura. Se l’output è identico, la factory ha ricostruito da sola tre layer del tipo giusto, con le dimensioni giuste, con i pesi giusti.

Aprite poi modello_test.txt con un editor: è leggibile, e le prime righe dicono DenseLayer, 128 784, poi i pesi. Un formato testuale si ispeziona a occhio, e questo vale più della compattezza di un formato binario.

Un dettaglio che vi salverà

Se il round-trip dà numeri vicini ma non identici, non cercate il bug nella factory: è la precisione di stampa.

os.precision(17);   // per un double; 9 cifre bastano per un float

Per default ostream scrive 6 cifre significative, e un peso 0.023147829 diventa 0.0231478. Con centinaia di migliaia di pesi troncati, l’accuratezza della rete ricaricata scende in modo misurabile.

È l’errore della Lezione 11, e si manifesta esattamente qui.

Task da Completare

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