Tipi Base e Funzioni di Attivazione
5 / 2 fra due int, e con static_cast<double>Un file e un eseguibile per task, ognuno con il suo main() (./build/task2, …).
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;
}2: fra due int, come in C, la divisione è intera, senza alcun avviso del compilatore.
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.
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;
}[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.
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;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;
}[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)
"\t" non restano allineate: per incolonnare i numeri serve <iomanip>, che arriva nel Laboratorio 04.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;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;
}| \(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\).
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.
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:
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.
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.)
main.cpp scrivete una funzione double relu(double x).if/else.std::cout.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.
double relu_derivative(double x).<cmath>La sigmoide trasforma ogni numero reale in un valore dell’intervallo \((0, 1)\): \(\sigma(x) = \frac{1}{1 + e^{-x}}\).
math.h del C: #include <cmath>double sigmoid(double x), usando l’esponenziale std::exp().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.forNel 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:
A ogni passo stampate una riga di tabella, con le colonne separate da "\t":
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\).
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)\).
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).double tanh_derivative(double x): calcolate std::tanh(x) una volta sola in una variabile e riusatela.tanhCompila, 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.
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}\).
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).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).std::cout << std::scientific; (notazione esponenziale, già in <iostream>) e un [PASS] se entrambi sono sotto 1e-6, [FAIL] altrimenti.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:
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.
La soluzione verrà discussa all’inizio del prossimo laboratorio.
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.
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).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.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:
task7_attivazioni.cpp accanto a main.cpp, in basic_neural_plusplus;CMakeLists.txt la riga add_executable(task7 task7_attivazioni.cpp) (slide successiva);task7):CMakeLists.txtCMakeLists.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})task7 non usa Eigen né OpenCV, quindi niente target_link_libraries.
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.