Laboratorio 03

Tipi Base e Funzioni di Attivazione

Cristiano De Michele

Il Laboratorio 02: cosa chiedeva

  • Task 1: il progetto del Lab 01 compila da zero e stampa “Inizio progetto BASIC_NEURAL++!”
  • Task 2: 5 / 2 fra due int, e con static_cast<double>
  • Task 3: la discesa del gradiente su \(x^2\) per \(\eta = 0{,}25\,k\), \(k = 1, \dots, 5\) (quesito 1 della Lezione 03)
  • Task 4: forward, backward e gradient check della catena di due neuroni (esercizio della Lezione 03)
  • Task 5: addestramento della catena con \(y = 1\) e con \(y = 0{,}8\)

Un file e un eseguibile per task, ognuno con il suo main() (./build/task2, …).

Soluzione del Laboratorio 02, Task 2: tipi e divisione intera

task2_tipi.cpp
#include <iostream>

int main() {
    int epochs = 100;
    double learning_rate = 0.01;
    std::cout << "[Task 2] epoche: " << epochs
              << ", learning rate: " << learning_rate << std::endl;

    int a = 5, b = 2;
1    std::cout << "5 / 2 = " << a / b << " (divisione intera)" << std::endl;
2    std::cout << "con il cast = " << static_cast<double>(a) / b << std::endl;
    return 0;
}
1
Stampa 2: fra due int, come in C, la divisione è intera, senza alcun avviso del compilatore.
2
static_cast<double>(a) non modifica a: ne produce una copia temporanea di tipo double. È sufficiente convertire uno dei due operandi: l’altro viene convertito implicitamente, come in C. Le altre forme di cast arrivano nella Lezione 16.

Soluzione del Laboratorio 02, Task 3: discesa su \(x^2\)

task3_discesa.cpp
#include <iostream>

int main() {
    std::cout << "[Task 3] E(x) = x^2, x0 = 1" << std::endl;
    for (int k = 1; k <= 5; ++k) {
        double eta = 0.25 * k;              // int * double: k diventa double
        double x = 1.0;
        std::cout << "eta = " << eta << ":";
        for (int n = 1; n <= 5; ++n) {
            x = x - eta * 2.0 * x;          // E'(x) = 2x
            std::cout << "  " << x;
        }
        std::cout << std::endl;
    }
    return 0;
}

Soluzione del Laboratorio 02, Task 3: cosa stampa

[Task 3] E(x) = x^2, x0 = 1
eta = 0.25:  0.5  0.25  0.125  0.0625  0.03125
eta = 0.5:  0  0  0  0  0
eta = 0.75:  -0.5  0.25  -0.125  0.0625  -0.03125
eta = 1:  -1  1  -1  1  -1
eta = 1.25:  -1.5  2.25  -3.375  5.0625  -7.59375

A ogni passo \(x\) si moltiplica per \(1 - 2\eta\): con \(\eta = 0{,}25\) converge, con \(0{,}5\) raggiunge il minimo al primo passo, con \(0{,}75\) converge oscillando, con \(1\) oscilla senza convergere, con \(1{,}25\) diverge.

Soluzione del Laboratorio 02, Task 4: forward e backward

task4_backprop.cpp
#include <iostream>
#include <cmath>

int main() {
    double x = 1.0, y = 1.0;
    double w1 = 1.0, w2 = 1.0;

    // forward
    double z1 = w1 * x;
    double a1 = 1.0 / (1.0 + std::exp(-z1));
    double z2 = w2 * a1;
    double a2 = 1.0 / (1.0 + std::exp(-z2));
    double E  = (a2 - y) * (a2 - y);

    // backward
    double s1 = a1 * (1.0 - a1);            // sigma'(z1) = sigma(1 - sigma)
    double s2 = a2 * (1.0 - a2);            // sigma'(z2)
    double delta2 = 2.0 * (a2 - y) * s2;
    double delta1 = w2 * delta2 * s1;
    double dE_dw2 = delta2 * a1;
    double dE_dw1 = delta1 * x;

Soluzione del Laboratorio 02, Task 4 (segue)

task4_backprop.cpp
    std::cout << "[Task 4]\tstrato 1\tstrato 2" << std::endl;
    std::cout << "z\t\t" << z1 << "\t" << z2 << std::endl;
    std::cout << "a\t\t" << a1 << "\t" << a2 << std::endl;
    std::cout << "sigma'(z)\t" << s1 << "\t" << s2 << std::endl;
    std::cout << "delta\t\t" << delta1 << "\t" << delta2 << std::endl;
    std::cout << "dE/dw\t\t" << dE_dw1 << "\t" << dE_dw2 << std::endl;
    std::cout << "E = " << E << ",  delta1/delta2 = " << delta1 / delta2 << std::endl;
    // gradient check: il forward ricopiato con w1 + h e w1 - h (con sigmoid(), Task 3: due righe)
    double h = 0.01;
    double a1p = 1.0 / (1.0 + std::exp(-(w1 + h) * x));
    double a2p = 1.0 / (1.0 + std::exp(-w2 * a1p));
    double E_piu = (a2p - y) * (a2p - y);
    double a1m = 1.0 / (1.0 + std::exp(-(w1 - h) * x));
    double a2m = 1.0 / (1.0 + std::exp(-w2 * a1m));
    double E_meno = (a2m - y) * (a2m - y);
    std::cout << "gradient check: (E(w1+h) - E(w1-h)) / 2h = "
              << (E_piu - E_meno) / (2.0 * h)
              << "  (backprop: " << dE_dw1 << ")" << std::endl;
    return 0;
}

Soluzione del Laboratorio 02, Task 4: cosa stampa

[Task 4]        strato 1        strato 2
z               1       0.731059
a               0.731059        0.675038
sigma'(z)       0.196612        0.219362
delta           -0.0280307      -0.142569
dE/dw           -0.0280307      -0.104226
E = 0.105601,  delta1/delta2 = 0.196612
gradient check: (E(w1+h) - E(w1-h)) / 2h = -0.0280308  (backprop: -0.0280307)
  • È la tabella della soluzione nella Lezione 03, e il gradient check coincide con la backpropagation per cinque cifre significative.
  • \(\delta_1/\delta_2 = w_2\,\sigma'(z_1) \approx 0{,}2\): il fattore per strato della Lezione 03.
  • Le colonne separate da "\t" non restano allineate: per incolonnare i numeri serve <iomanip>, che arriva nel Laboratorio 04.

Soluzione del Laboratorio 02, Task 5: addestramento

task5_addestramento.cpp
#include <iostream>
#include <cmath>

int main() {
    double x = 1.0, eta = 1.0;
1    for (int caso = 0; caso < 2; ++caso) {
        double y = 1.0;
        if (caso == 1) {
            y = 0.8;
        }
        double w1 = 1.0, w2 = 1.0;
        std::cout << "\n[Task 5] y = " << y << ", eta = " << eta << std::endl;
1
Il ciclo esterno gira due volte, con \(y = 1\) e con \(y = 0{,}8\), ripartendo ogni volta da \(w_1 = w_2 = 1\).

Soluzione del Laboratorio 02, Task 5 (segue)

task5_addestramento.cpp
        for (int t = 0; t <= 200; ++t) {
            double a1 = 1.0 / (1.0 + std::exp(-w1 * x));
            double a2 = 1.0 / (1.0 + std::exp(-w2 * a1));
            double E  = (a2 - y) * (a2 - y);
            double delta2 = 2.0 * (a2 - y) * a2 * (1.0 - a2);
            double delta1 = w2 * delta2 * a1 * (1.0 - a1);
            if (t % 20 == 0) {
                std::cout << "t = " << t << "\tE = " << E
                          << "\tw1 = " << w1 << "\tw2 = " << w2 << std::endl;
            }
            w1 = w1 - eta * delta1 * x;     // dE/dw1 = delta1 * x
            w2 = w2 - eta * delta2 * a1;    // dE/dw2 = delta2 * a1
        }
    }
    return 0;
}

Soluzione del Laboratorio 02, Task 5: i target

\(t = 0\) \(t = 100\) \(t = 200\) \(w_1\), \(w_2\) a \(t = 200\)
\(y = 1\) \(E = 0{,}106\) \(3{,}7 \cdot 10^{-3}\) \(1{,}7 \cdot 10^{-3}\) \(2{,}02\) e \(3{,}58\), in crescita
\(y = 0{,}8\) \(E = 0{,}016\) \(3{,}8 \cdot 10^{-6}\) \(2{,}5 \cdot 10^{-9}\) \(1{,}262\) e \(1{,}778\), stabili

Con \(y = 1\) l’errore cala sempre più lentamente e i pesi crescono: \(\sigma(z) = 1\) solo per \(z \to \infty\), quindi nessun peso finito dà \(E = 0\). Con \(y = 0{,}8\) il target è raggiungibile: \(E\) cala di un fattore circa costante ogni 20 passi e i pesi convergono.

Per questo nel framework (Laboratorio 12) i target sono \(0{,}01\) e \(0{,}99\), come nel libro di Rashid, e non \(0\) e \(1\).

Obiettivo di oggi

Oggi scriviamo la parte matematica di un neurone artificiale: le funzioni di attivazione e le loro derivate. Dopo if e for del Laboratorio 02 arrivano le funzioni, come in C (Lezione 05). Le derivate vanno poi verificate numericamente: nel Laboratorio 13 sono la base della backpropagation, dove un errore sarebbe molto più difficile da individuare.

Prima di iniziare: progetto e compilazione

Si lavora sempre nel progetto del Laboratorio 01, basic_neural_plusplus; oggi nel suo main.cpp. Non si crea un progetto nuovo per ogni laboratorio: il CMakeLists.txt cresce, non si riscrive.

Dalla cartella del progetto:

cmake -B build                                # la prima volta: configura
cmake --build build && ./build/basic_neural   # a ogni modifica: compila ed esegue

In VS Code gli stessi passi corrispondono ai pulsanti di CMake Tools. Se la configurazione in build/ dà errori, si cancella e si riparte: rm -rf build && cmake -B build.

Il CMakeLists.txt del Laboratorio 01 contiene già -Wall: gli avvisi del compilatore compaiono senza opzioni aggiuntive.

Task 1: la funzione ReLU

La Rectified Linear Unit (ReLU) è la funzione di attivazione più usata nel deep learning moderno: \(f(x) = \max(0, x)\). Si scrive con un if: per questo è la prima.

(Nel framework finale implementeremo SigmoidLayer e tanhLayer: la ReLU resta una delle estensioni possibili per il progetto d’esame. Qui serve a scrivere una prima funzione con un if/else, su un caso semplice.)

  1. Nel vostro main.cpp scrivete una funzione double relu(double x).
  2. Implementatela con un costrutto if/else.
  3. Provatela con un numero negativo e uno positivo, stampando i risultati con std::cout.

Task 2: la derivata della ReLU

Nella backpropagation la rete deve calcolare la pendenza, cioè la derivata, della funzione di attivazione. Per la ReLU: \(f'(x) = 1\) se \(x > 0\), altrimenti \(0\). In \(x = 0\) la derivata non esiste: il valore \(0\) è una convenzione, e per questo la ReLU resta fuori dalla verifica numerica del Task 6.

  1. Scrivete una funzione double relu_derivative(double x).
  2. Implementatela e provatela.

Task 3: la sigmoide e <cmath>

La sigmoide trasforma ogni numero reale in un valore dell’intervallo \((0, 1)\): \(\sigma(x) = \frac{1}{1 + e^{-x}}\).

  1. Includete l’header della libreria matematica standard, il math.h del C: #include <cmath>
  2. Scrivete una funzione double sigmoid(double x), usando l’esponenziale std::exp().
  3. Scrivete la sua derivata, double sigmoid_derivative(double x): \(\sigma'(x) = \sigma(x) \cdot (1 - \sigma(x))\). Conviene calcolare sigmoid(x) una sola volta e salvarlo in una variabile, perché compare due volte.

Task 4: validazione con un ciclo for

Nel main() fate variare x da \(-5\) a \(+5\) a passi di \(1\). Come nel Laboratorio 02, il contatore è un int e x si ricava dal contatore:

for (int i = -5; i <= 5; ++i) {
    double x = i;
    // ... una riga della tabella
}

A ogni passo stampate una riga di tabella, con le colonne separate da "\t":

  • il valore di x;
  • relu(x) e relu_derivative(x);
  • sigmoid(x) e sigmoid_derivative(x).

Verificate alcuni valori noti: per esempio \(\sigma(0) = 0{,}5\) e \(\sigma'(0) = 0{,}25\).

Task 5: la tangente iperbolica

La terza funzione di attivazione classica, a valori in \((-1, 1)\) e centrata sullo zero: \(f(x) = \tanh(x)\), con derivata \(f'(x) = 1 - \tanh^2(x)\).

  1. Scrivete double tanh_act(double x) usando std::tanh di <cmath>. Il nome tanh da solo non darebbe un errore di compilazione, ed è proprio questo il problema (slide successiva).
  2. Scrivete double tanh_derivative(double x): calcolate std::tanh(x) una volta sola in una variabile e riusatela.
  3. Aggiungete le due colonne alla tabella, o stampate una seconda tabella.

Perché non chiamarla tanh

Compila, e senza -Wall non dà avvisi: in esecuzione termina con un errore

Se scrivete double tanh(double x) { return std::tanh(x); }, il programma compila. In esecuzione termina con Segmentation fault. Il motivo: <cmath> dichiara la tanh della libreria C nello spazio dei nomi globale, e std::tanh(double) è quella stessa funzione. Scrivendo double tanh(double) non create una funzione nuova: definite quella, e il linker usa la vostra definizione al posto di quella della libreria. std::tanh(x) chiama quindi la vostra funzione, che richiama se stessa all’infinito, fino all’esaurimento dello stack.

Con -Wall il compilatore lo segnala, warning: infinite recursion detected [-Winfinite-recursion] (da GCC 12, e in clang); senza opzioni g++ non emette alcun avviso, e il programma esce con codice 139. È il primo dei due casi annunciati nel Laboratorio 01 (il secondo è nel Laboratorio 08): si compila sempre con -Wall.

Task 6: verifica numerica

Una derivata sbagliata di un segno o di un fattore 2 non dà nessun errore di compilazione: dà una rete che non impara. Il controllo standard è la derivata numerica a differenze centrali, \(f'(x) \approx \dfrac{f(x+h) - f(x-h)}{2h}\).

  1. Scrivete double sigmoid_numerical_derivative(double x, double h) e l’analoga per la tanh (una funzione per attivazione: passarla come argomento lo vedremo più avanti).
  2. Nel main, con h = 1e-5 e x da \(-5\) a \(5\) a passi di \(0{,}5\) (double x = 0.5 * i;), tenete il massimo errore assoluto std::fabs(analitica - numerica) per ciascuna funzione (fabs come in C).
  3. Stampate i due massimi dopo std::cout << std::scientific; (notazione esponenziale, già in <iostream>) e un [PASS] se entrambi sono sotto 1e-6, [FAIL] altrimenti.

Task 6 (segue): perché proprio \(h = 10^{-5}\)?

  1. In x = 1, per h = \(10^{-1}, 10^{-3}, \dots, 10^{-13}\), stampate l’errore assoluto e quello relativo: se vale \(10^{-k}\), la derivata numerica ha \(k\) cifre corrette.

L’errore scende, poi risale, per l’effetto di due errori opposti:

  • per \(h\) grande domina l’errore di troncamento della formula alle differenze centrali, che va come \(h^2\);
  • per \(h\) piccolo domina la cancellazione numerica: f(x+h) e f(x-h) diventano quasi uguali, e sottraendoli si perdono cifre significative.

Il minimo sta dove i due si bilanciano: per la sigmoide e per la tanh, intorno a \(10^{-5}\). È un primo esempio dei limiti della precisione finita del double.

Task da completare

La soluzione verrà discussa all’inizio del prossimo laboratorio.

Task 7 (facoltativo): la catena con tre attivazioni

Nel Laboratorio 02 la catena di due neuroni con la sigmoide non raggiunge \(y = 1\), perché \(\sigma(z) = 1\) solo per \(z \to \infty\). Qui si ripete l’addestramento con tanh e ReLU.

  1. In un nuovo file task7_attivazioni.cpp scrivete double attiva(int tipo, double z) e double deriva(int tipo, double z), con le formule dei Task 1–5: con un if scelgono sigmoide (tipo == 0), tanh (1) o ReLU (2). Il file ha un suo main() e si compila come eseguibile a parte (slide successive).
  2. Scrivete void addestra(int tipo, double y, double w0, double eta): il ciclo del Task 5 del Laboratorio 02 con attiva e deriva al posto della sigmoide, \(x = 1\), 1000 passi da \(w_1 = w_2 = w_0\). Stampate \(E\), \(w_1\) e \(w_2\) ogni 500 passi.

Task 7 (segue): un secondo eseguibile nel progetto

Ogni add_executable del CMakeLists.txt produce un eseguibile distinto, con il proprio main(): due main() nello stesso eseguibile darebbero un errore del linker (main definito due volte). Il Task 7 richiede quindi un secondo add_executable, come nella soluzione del Laboratorio 02:

  1. salvate task7_attivazioni.cpp accanto a main.cpp, in basic_neural_plusplus;
  2. aggiungete al CMakeLists.txt la riga add_executable(task7 task7_attivazioni.cpp) (slide successiva);
  3. dalla cartella del progetto configurate, compilate ed eseguite (in VS Code: CMake Tools, target task7):
cmake -B build && cmake --build build && ./build/task7

Task 7 (segue): il CMakeLists.txt

CMakeLists.txt
cmake_minimum_required(VERSION 3.15)
project(BasicNeuralPlusPlus)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall")

find_package(Eigen3 QUIET)
find_package(OpenCV REQUIRED)
message(STATUS "OpenCV ${OpenCV_VERSION} trovata")

add_executable(basic_neural main.cpp)
1add_executable(task7 task7_attivazioni.cpp)

if(Eigen3_FOUND)
    target_link_libraries(basic_neural Eigen3::Eigen)
else()
    message(STATUS "Eigen non trovata: uso libs/eigen")
    target_include_directories(basic_neural PRIVATE libs/eigen)
endif()
target_link_libraries(basic_neural ${OpenCV_LIBS})
1
L’unica riga nuova rispetto al Laboratorio 01: task7 non usa Eigen né OpenCV, quindi niente target_link_libraries.

Task 7 (segue): gli esperimenti

  1. Con \(w_0 = 0{,}5\) ed \(\eta = 0{,}25\) addestrate le tre attivazioni con \(y = 1\) e con \(y = 0{,}8\). Quali raggiungono \(y = 1\), e perché?
  2. Ripetete con la ReLU e i parametri del Laboratorio 02, \(w_0 = 1\) ed \(\eta = 1\):
    • con \(y = 1\) l’errore è nullo già al passo 0: perché?
    • con \(y = 0{,}8\) l’errore si ferma a \(0{,}64\). Guardate il segno dei pesi e spiegate perché il gradiente si annulla.
  3. Ripetete il punto 3 con \(y = 0{,}8\) e con pesi iniziali \(w_0 = 0\) e \(w_0 = 5\). Quali attivazioni non imparano, o imparano molto lentamente? Spiegatelo con i valori di \(a_1\), \(a_2\) e delle derivate al passo 0.

Suggerimento per il punto 4: stampate i pesi a ogni passo per i primi 25 passi e calcolate a mano \(a_2 = \mathrm{ReLU}(w_2\,\mathrm{ReLU}(w_1))\).

Anche la soluzione del Task 7 verrà discussa all’inizio del prossimo laboratorio.