Laboratorio 08

Tipi Enumerati, Inizializzazione dei Pesi e Gerarchia delle Attivazioni

Docente

Soluzione del Laboratorio 07

Il SigmoidLayer, primo layer senza pesi:

Eigen::MatrixXd SigmoidLayer::forward(const Eigen::MatrixXd& input) {
    return input.array()
                .unaryExpr([](double x) { return 1.0 / (1.0 + std::exp(-x)); })
                .matrix();
}

ILayer* SigmoidLayer::clone() const { return new SigmoidLayer(*this); }

E il benchmark che ha mostrato l’effetto di -O3:

auto start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < 10000; ++i) net.forward(X);
auto end = std::chrono::high_resolution_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();

Il tempo dovrebbe essere crollato di un fattore fra 5 e 20: con -O3 il compilatore fonde i cicli, elimina le temporanee e usa le istruzioni vettoriali (AVX). Senza, Eigen è più lenta di un for scritto a mano.

Obiettivo di Oggi

Finora i pesi erano MatrixXd::Random, cioè uniformi in \([-1, 1]\). È una scelta pessima: con 784 input, la somma di 784 prodotti finisce nell’ordine delle decine, la sigmoide satura e la rete non impara più (Lezione 2).

Oggi:

  1. Un generatore di numeri casuali serio (randnumgen.hpp).
  2. Inizializzazioni He e Xavier, selezionate con enum class.
  3. Una gerarchia per le attivazioni, con TanhLayer accanto a SigmoidLayer.

Perché l’inizializzazione conta

La regola è mantenere la varianza del segnale costante attraversando i layer. Se cresce, le attivazioni saturano; se decresce, il segnale si spegne.

Due ricette, ognuna tarata su una famiglia di attivazioni:

pensata per scala \(s\) (gaussiana) scala \(s\) (uniforme)
He (Kaiming) ReLU e varianti \(\sqrt{2 / N_{in}}\) \(\sqrt{6 / N_{in}}\)
Xavier (Glorot) sigmoide, tanh \(\sqrt{2/(N_{in}+N_{out})}\) \(\sqrt{6/(N_{in}+N_{out})}\)

Nel caso gaussiano si estrae da una normale di deviazione standard \(s\); nel caso uniforme da \([-s, +s]\).

Task 1: Il generatore randnumgen

Aggiungete al progetto randnumgen.hpp (fornito dal docente). Espone un oggetto globale rng basato su Mersenne Twister a 64 bit:

rng.seed(42);     // seme fisso: esperimenti riproducibili
rng.rseed();      // seme casuale da std::random_device
rng.ranf();       // un double in [0.0, 1.0)

Tip

Durante lo sviluppo usate sempre rng.seed(42): se i pesi iniziali cambiano a ogni esecuzione non potete distinguere un bug da una fluttuazione statistica.

Task 2: Gli enum class

In dense_layer.hpp, fuori dalla classe:

// Gli enum class evitano i "magic numbers" sparsi nel codice
enum class InitType   { HE, XAVIER };
enum class DistroType { UNIFORM, GAUSSIAN };

class DenseLayer : public ILayer {
private:
    Eigen::MatrixXd weights;
    Eigen::MatrixXd biases;
public:
    DenseLayer(int input_size, int output_size,
               InitType   init   = InitType::HE,
               DistroType distro = DistroType::UNIFORM);
    // ...
};

Con int type, int distro nessuno vi impedirebbe di passare 3, o di invertire i due argomenti. Con enum class il compilatore rifiuta entrambi gli errori: è questo il significato di type safe.

Task 3: Box-Muller e il costruttore

rng.ranf() genera numeri uniformi. Per averli gaussiani serve la trasformazione di Box-Muller:

static double gauss() {
    double x1, x2;
    do { x1 = rng.ranf(); x2 = rng.ranf(); } while (x1 == 0 || x2 == 0);
    return std::cos(2 * M_PI * x2) * std::sqrt(-2.0 * std::log(x1));
}

Il do/while esclude lo zero: std::log(0) è \(-\infty\).

DenseLayer::DenseLayer(int input_size, int output_size,
                       InitType init, DistroType distro) {
    weights = Eigen::MatrixXd::Zero(output_size, input_size);
    biases  = Eigen::MatrixXd::Zero(output_size, 1);
    rng.rseed();

    double s = 0.0;
    switch (init) {
        case InitType::HE:
            s = (distro == DistroType::UNIFORM) ? std::sqrt(6.0 / input_size)
                                                : std::sqrt(2.0 / input_size);
            break;
        case InitType::XAVIER:
            s = (distro == DistroType::UNIFORM)
                    ? std::sqrt(6.0 / (input_size + output_size))
                    : std::sqrt(2.0 / (input_size + output_size));
            break;
    }

    for (int i = 0; i < weights.rows(); ++i)
        for (int j = 0; j < weights.cols(); ++j)
            weights(i, j) = (distro == DistroType::UNIFORM)
                ? 2.0 * s * (rng.ranf() - 0.5)   // [0,1) -> [-s, +s)
                : s * gauss();
}

Il vantaggio dello switch su enum class: se domani aggiungete InitType::LECUN, il compilatore vi avverte che manca un caso.

Task 4: La gerarchia delle attivazioni

In activation_layer.hpp, inseriamo una classe intermedia fra ILayer e i layer concreti:

// Classe base astratta per raggruppare tutte le attivazioni
class ActivationLayer : public ILayer {
    // Per ora vuota: diventerà utile con i template
};

class SigmoidLayer : public ActivationLayer {
public:
    SigmoidLayer() = default;
    Eigen::MatrixXd forward(const Eigen::MatrixXd& input) override;
    ILayer* clone() const override;
};

class TanhLayer : public ActivationLayer {
public:
    TanhLayer() = default;
    Eigen::MatrixXd forward(const Eigen::MatrixXd& input) override;
    ILayer* clone() const override;
};

Sembra inutile una classe vuota, ma serve a distinguere una categoria: fra poco vorremo salvare il modello su file e chiedere “questo layer ha pesi da salvare?”. La risposta sarà data dal ramo della gerarchia, non da un if.

La tanh in Eigen è diretta:

Eigen::MatrixXd TanhLayer::forward(const Eigen::MatrixXd& input) {
    return input.array().tanh().matrix();
}

Task 5: Il Test

SequentialNetwork net(4);

// Niente numeri magici: la sintassi dice cosa sta accadendo
net.add(new DenseLayer(784, 128, InitType::HE,     DistroType::GAUSSIAN));
net.add(new SigmoidLayer());
net.add(new DenseLayer(128,  10, InitType::XAVIER, DistroType::UNIFORM));
net.add(new TanhLayer());

Eigen::MatrixXd X = Eigen::MatrixXd::Random(784, 1);
std::cout << net.forward(X).transpose() << std::endl;

Verifica quantitativa: stampate la deviazione standard dei pesi del primo layer con net… ovvero, dal DenseLayer direttamente:

DenseLayer l(784, 128, InitType::HE, DistroType::GAUSSIAN);
std::cout << "std dei pesi: "
          << std::sqrt((l.getWeights().array() - l.getWeights().mean())
                       .square().sum() / l.getWeights().size()) << std::endl;
std::cout << "attesa: " << std::sqrt(2.0 / 784) << std::endl;

I due numeri devono coincidere a meno del 5%: è la prova che l’inizializzazione fa quello che dice.

Notate anche che l’output finale ora sta in \((-1, 1)\) e non in \((0, 1)\): l’ultimo layer è una tanh.

Task da Completare

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