STL, Smart Pointer e le Strategy dell’Addestramento
L’alias che ha reso generico tutto il framework:
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.
Il Sequential del Lab 09 funziona ma è ancora fragile:
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.
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.
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.
clear() e size()Due metodi che si limitano a delegare al vettore:
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.
add() e il trasferimento di proprietàQui sta il concetto centrale del laboratorio:
Tre cose da capire:
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.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”.
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 == 0size_t come indice, non int: è il tipo che vector::size() restituisce, e mescolarli genera warning di conversione con segnolayers[i]->forward(...): l’operatore -> di unique_ptr dereferenzia il puntatore posseduto, quindi la sintassi resta identica a prima#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:
delete. Cercatelo con grep -rn "delete" *.hpp *.cpp: se ne trovate uno, non avete finito."Network capacity esaurita!", ora il vettore cresce da solo.-fsanitize=address) ed eseguite: zero leak.shared_ptrDomanda 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.
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".
loss.hpptemplate <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;
};__LOSS__).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.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.optimizers.hpptemplate <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;
};__OPTIMIZERS__; includete anche <unordered_map>, <cmath>, <stdexcept>: serviranno alle versioni future).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;update non sa cosa sta aggiornando: pesi, bias, o altro. Riceve una matrice e il suo gradiente. È questo che lo rende universale.schedulers.hppLo scheduler possiede la politica del learning rate e la impone all’optimizer, di cui tiene un puntatore.
__SCHEDULERS__, #include "./optimizers.hpp").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.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.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.
La soluzione di riferimento verrà discussa all’inizio della prossima lezione.