Serializzazione del Modello e Factory Method
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.
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.
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).
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.
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.
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.
DenseLayervoid 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.
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);
}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.
SequentialNella 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.
save_model e load_modelvoid 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).
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:
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.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.
Se il round-trip dà numeri vicini ma non identici, non cercate il bug nella factory: è la precisione di stampa.
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.
La soluzione di riferimento verrà discussa all’inizio della prossima lezione.