Laboratorio 04

Gestione della Memoria e Vettori C-Style

Docente

Soluzione del Laboratorio 03

#include <iostream>
#include <cmath>
#include <iomanip>

double relu(double x)            { return (x > 0.0) ? x : 0.0; }
double relu_derivative(double x) { return (x > 0.0) ? 1.0 : 0.0; }

double sigmoid(double x) { return 1.0 / (1.0 + std::exp(-x)); }

double sigmoid_derivative(double x) {
    double sig = sigmoid(x);
    return sig * (1.0 - sig);   // si riusa sigmoid(x): non si riscrive la formula
}

int main() {
    std::cout << std::fixed << std::setprecision(4);
    std::cout << "x\t| ReLU\t\tReLU'\t| Sigmoid\tSigmoid'" << std::endl;

    for (double x = -5.0; x <= 5.0; x += 1.0) {
        std::cout << std::setw(4) << x << "\t| "
                  << std::setw(6) << relu(x) << "\t"
                  << std::setw(6) << relu_derivative(x) << "\t| "
                  << std::setw(7) << sigmoid(x) << "\t"
                  << std::setw(7) << sigmoid_derivative(x) << std::endl;
    }
}

Due cose da notare: sigmoid_derivative riusa sigmoid(x) invece di riscrivere l’esponenziale, e <iomanip> (setprecision, setw) serve a incolonnare i numeri — con la tabulazione secca le colonne si disallineano appena un numero cambia di lunghezza.

Soluzione del Laboratorio 03: la verifica numerica

double sigmoid_numerical_derivative(double x, double h) {
    return (sigmoid(x + h) - sigmoid(x - h)) / (2.0 * h);
}

// nel main: massimo errore su una griglia di x
double h = 1.0e-5, max_err = 0.0;
for (double x = -5.0; x <= 5.0; x += 0.5) {
    double e = std::fabs(sigmoid_derivative(x) - sigmoid_numerical_derivative(x, h));
    if (e > max_err) max_err = e;
}

Con h = 1e-5 l’errore massimo è circa \(10^{-11}\): le formule sono giuste. La tabella al variare di h è la parte istruttiva:

h \(10^{-1}\) \(10^{-3}\) \(10^{-5}\) \(10^{-7}\) \(10^{-9}\) \(10^{-11}\) \(10^{-13}\)
errore \(6\cdot10^{-5}\) \(6\cdot10^{-9}\) \(2\cdot10^{-12}\) \(4\cdot10^{-10}\) \(2\cdot10^{-8}\) \(3\cdot10^{-6}\) \(5\cdot10^{-4}\)

A sinistra domina l’errore di troncamento (\(\propto h^2\)); a destra la cancellazione: f(x+h) e f(x-h) coincidono nelle prime 13 cifre e la sottrazione lascia solo rumore. Il double ha 16 cifre, non infinite.

Obiettivo di Oggi

Per comprendere a fondo il C++ moderno e giustificare la necessità delle Classi (OOP), dobbiamo prima affrontare la gestione cruda e manuale della memoria (C-Style). Simuleremo i “pesi” di un singolo neurone usando array dinamici; poi passeremo a uno strato di neuroni, che richiede una matrice — e la scriveremo come si farebbe in C, con una struct e funzioni sciolte. Il Laboratorio 05 prenderà esattamente quel codice e lo chiuderà in una classe.

Task 1: Allocazione Dinamica

Dimentica i vector e gli array a dimensione fissa. Immagina di dover caricare \(N\) pesi in memoria, dove \(N\) è una variabile decisa a runtime (es. inserita dall’utente tramite std::cin).

  1. Chiedi all’utente di inserire il numero di input \(N\) (es. \(N=784\) per un’immagine MNIST).
  2. Alloca dinamicamente sull’Heap due array di double grandi \(N\): uno per i pesi (Weights) e uno per gli input (X).
  3. Usa la keyword new double[N].

Task 2: Inizializzazione

Scrivi un ciclo for che riempia questi due array con valori fittizi. - Per gli input: riempi con il valore \(1.0\). - Per i pesi: riempi con valori piccoli casuali o costanti (es. \(0.01\)).

Ricorda: Puoi accedere agli elementi di un array dinamico usando la notazione pesi[i].

Task 3: Il Forward Pass (a mano)

L’equazione di un singolo neurone (prima della funzione di attivazione) è un prodotto scalare: \(Z = (\sum_{i=0}^{N-1} W_i \cdot X_i) + b\).

  1. Dichiara una variabile scalare b (bias) inizializzata a \(0.5\).
  2. Scrivi un ciclo for che moltiplichi ogni peso per il rispettivo input e accumuli il risultato in una variabile z.
  3. Aggiungi il bias a z e stampa il risultato finale.

Task 4: Evitare i Memory Leak!

La regola d’oro del C++ è: “Per ogni new[], ci deve essere un delete[].

  1. Alla fine del tuo main, dealloca esplicitamente i due array usando delete[] pesi; e delete[] input;.
  2. (Domanda per te): Cosa succede se ometti il delete[] e metti l’intero programma in un ciclo infinito che fa addestramento?

Task 5: Una Matrice “alla C”

Un neurone ha \(N\) pesi: un array. Uno strato di \(M\) neuroni ne ha \(M \times N\): una matrice. In C una matrice dinamica è tre informazioni che devono viaggiare insieme — quante righe, quante colonne, dove sono i numeri:

struct CMatrix {
    int rows;
    int cols;
    double* data;   // rows*cols double consecutivi, in ordine row-major
};
  1. Scrivi CMatrix cmat_alloc(int rows, int cols): riempie i due interi e alloca new double[rows * cols].
  2. Scrivi void cmat_free(CMatrix& m): delete[], poi azzera data (a nullptr), rows e cols, così una seconda chiamata per sbaglio non fa danni.
  3. Scrivi double cmat_get(const CMatrix& m, int i, int j) e void cmat_set(CMatrix& m, int i, int j, double val). L’elemento \((i, j)\) sta in data[i * cols + j]: la riga \(i\) occupa cols celle consecutive. Row-major — tenetelo a mente, tornerà con Eigen.

Task 6: Da un Neurone a uno Strato

Con \(W\) di dimensione \(M \times N\), l’uscita del neurone \(j\) è \(z_j = \sum_{i} W_{ji}\, x_i + b_j\): un prodotto matrice-vettore.

  1. Scrivi void layer_forward(const CMatrix& W, const double* x, const double* b, double* z): due cicli annidati, quello esterno sui neuroni (W.rows), quello interno sugli input (W.cols). Usa cmat_get, non l’aritmetica degli indici a mano.
  2. Nel main, con \(M = 3\) e la stessa \(N\) dell’utente: alloca W con cmat_alloc, e bias, x, z_out con new[]. Riempi x con \(1.0\), i bias con \(0.5\), e la riga \(j\) di W con \(0.01 \cdot (j+1)\).
  3. Chiama layer_forward e stampa z_out. Atteso: \(z_j = 0.01\,(j+1)\,N + 0.5\). Con \(N = 784\): \(8.34\), \(16.18\), \(24.02\).
  4. Libera tutto: cmat_free(W) e tre delete[].

Task 7: Il Conto (domanda, non codice)

Guardate il main: quattro new, quattro delete, tre numeri da passare a ogni funzione. E ora due domande a cui rispondere prima di venire al Laboratorio 05:

  1. Che succede con CMatrix W2 = W; seguito da cmat_free(W); cmat_free(W2);? (Suggerimento: cosa copia l’assegnamento di una struct che contiene un puntatore?)
  2. Chi garantisce che x abbia davvero W.cols elementi quando lo passate a layer_forward? Nessuno. Il compilatore non può saperlo.

Le risposte sono il motivo per cui esistono le classi. Il Laboratorio 05 prende CMatrix e le sue quattro funzioni e le trasforma in class Matrix: i tre dati diventano private, cmat_alloc diventa il costruttore, cmat_get/cmat_set diventano metodi.

La soluzione (con i commenti sui pericoli di questo approccio) verrà discussa all’inizio della prossima lezione!