La Matematica Dietro l'IA: Iniziamo Semplice

Come scriveresti normalmente un programma per sommare due numeri sotto 100? Facile, giusto? Di solito scriveresti qualcosa di semplice come:

#include <iostream>
using namespace std;

int main() {
    int a, b, sum;

    cout << "Inserisci il primo numero: ";
    cin >> a;

    cout << "Inserisci il secondo numero: ";
    cin >> b;

    sum = a + b;

    cout << "La somma è: " << sum << endl;

    return 0;
}

Ma prendiamo una strada più emozionante: insegniamo a una macchina ad apprendere l'addizione da sola, usando una rete neurale. Le reti neurali sono al cuore dell'IA moderna, alimentando tutto, dal riconoscimento delle immagini e traduzione linguistica a sistemi avanzati come ChatGPT.

In questo articolo, mostreremo come una rete neurale può "imparare" a sommare due numeri attraverso l'addestramento, non seguendo una regola codificata. Vedrai come una rete neurale impara a "sommare" individuando schemi. Riveleremo anche i numeri reali (la magia dietro il sipario) che rendono tutto ciò possibile.

Ecco uno sguardo alla struttura interna di una rete neurale addestrata:

// Pesi e bias del layer nascosto
double weight1_node1 = 0.658136;
double weight2_node1 = 0.840666;
double bias_node1 = -0.893218;

double weight1_node2 = -0.720667;
double weight2_node2 = -0.369172;
double bias_node2 = 0.036762;

double weight1_node3 = 0.512252;
double weight2_node3 = 0.292342;
double bias_node3 = -0.0745917;

// Pesi e bias del layer di output
double weight_out_node1 = 1.93108;
double weight_out_node2 = -0.718584;
double weight_out_node3 = 0.589741;
double bias_out = -0.467899;

A prima vista, questi valori potrebbero sembrare casuali, ma rappresentano un piccolo cervello artificiale capace di imparare come sommare due numeri.

Usa questa formula per calcolare l'output. Non preoccuparti se sembra complicato in questo momento: segui semplicemente il flusso:

Qui, x1 e x2 sono i due numeri che vuoi sommare (normalizzati dividendo per 100):

double x1 = 0.3; // 30 / 100
double x2 = 0.5; // 50 / 100

// Layer nascosto
double z1 = x1 * weight1_node1 + x2 * weight2_node1 + bias_node1;
double h1 = sigmoid(z1);

double z2 = x1 * weight1_node2 + x2 * weight2_node2 + bias_node2;
double h2 = sigmoid(z2);
double z3 = x1 * weight1_node3 + x2 * weight2_node3 + bias_node3;
double h3 = sigmoid(z3);

// Layer di output
double sum = (h1 * weight_out_node1 + h2 * weight_out_node2 + h3 * weight_out_node3 + bias_out) * 200.0f;

Analizziamo perché moltiplichiamo per 200: inizialmente abbiamo scalato i nostri input e output per adattarli tra 0 e 1 per un addestramento più semplice. Poiché la somma massima di due numeri sotto 100 è 200, riportiamo l'output a un valore più alto moltiplicandolo per 200.

Funzione Sigmoid:

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

Proviamo con alcuni numeri reali:

Usiamo x1 = 30 e x2 = 50. Prima normalizziamoli:

x1 = 30 / 100 = 0.3
x2 = 50 / 100 = 0.5

Ora eseguiamo i calcoli:

z1 = (0.3 × 0.658136) + (0.5 × 0.840666) - 0.893218
   ≈ 0.1974408 + 0.420333 - 0.893218
   ≈ -0.2754442
h1 = sigmoid(z1) ≈ 0.431844

z2 = (0.3 × -0.720667) + (0.5 × -0.369172) + 0.036762
   ≈ -0.2162001 - 0.184586 + 0.036762
   ≈ -0.3640241
h2 = sigmoid(z2) ≈ 0.409872

z3 = (0.3 × 0.512252) + (0.5 × 0.292342) - 0.0745917
   ≈ 0.1536756 + 0.146171 - 0.0745917
   ≈ 0.2252549
h3 = sigmoid(z3) ≈ 0.556078

// Layer di output
output = (h1 × 1.93108) + (h2 × -0.718584) + (h3 × 0.589741) - 0.467899
       ≈ (0.834389) + (-0.294320) + (0.328013) - 0.467899
       ≈ 0.400183

Somma prevista = 0.400183 × 200 ≈ 80.0366
// Perché moltiplicare per 200? Perché durante l'addestramento, abbiamo normalizzato tutti gli input e output per rimanere tra 0 e 1 per una migliore stabilità nell'apprendimento. Poiché la somma massima di due input è 100 + 100 = 200, riportiamo l'output della rete moltiplicandolo per 200.

Boom! Il risultato è circa 80, che è la somma corretta di 30 e 50.

Se non credi che questa piccola rete possa davvero sommare, proviamola di nuovo con numeri diversi: 20 e 30.

Normalizziamo prima gli input:

x1 = 20 / 100 = 0.2
x2 = 30 / 100 = 0.3

Poi i calcoli:

z1 = (0.2 × 0.658136) + (0.3 × 0.840666) - 0.893218
   ≈ 0.1316272 + 0.2521998 - 0.893218
   ≈ -0.509391
h1 = sigmoid(z1) ≈ 0.375241

z2 = (0.2 × -0.720667) + (0.3 × -0.369172) + 0.036762
   ≈ -0.1441334 - 0.1107516 + 0.036762
   ≈ -0.218123
h2 = sigmoid(z2) ≈ 0.445714

z3 = (0.2 × 0.512252) + (0.3 × 0.292342) - 0.0745917
   ≈ 0.1024504 + 0.0877026 - 0.0745917
   ≈ 0.1155613
h3 = sigmoid(z3) ≈ 0.528860

// Layer di output
output = (h1 × 1.93108) + (h2 × -0.718584) + (h3 × 0.589741) - 0.467899
       ≈ (0.724688) + (-0.320095) + (0.311644) - 0.467899
       ≈ 0.248338

Somma prevista = 0.248338 × 200 ≈ 49.6676

Ha previsto circa 49.67. È molto vicino alla somma reale: 50!

Abbastanza interessante, eh?

Quindi... Cos'è una Rete Neurale Comunque?

Breve introduzione: una rete neurale è come una versione matematica del tuo cervello. È composta da unità chiamate neuroni che elaborano gli input moltiplicandoli per pesi, aggiungendo un bias e poi applicando una funzione di attivazione. Una funzione di attivazione comune è la sigmoid:

sigmoid(x) = 1 / (1 + e^(-x))

Questo schiaccia qualsiasi input in un valore tra 0 e 1, consentendo alla rete di catturare schemi complessi.

Questo è il grafico della funzione sigmoid, che mostra chiaramente come gli input vengano trasformati in modo fluido in valori tra 0 e 1:

Ci sono anche altre funzioni di attivazione, come ReLU (Rectified Linear Unit), tanh e softmax, ognuna con i propri casi d'uso e comportamenti. Ma la sigmoid è un ottimo punto di partenza perché è semplice e fluida: perfetta per piccole reti neurali come la nostra.

L'Impostazione: Insegnare alla Nostra Rete di Sommare

Per questo esperimento, abbiamo creato una piccola rete con:

  • 2 nodi di input (per i due numeri da sommare)
  • 3 nodi nascosti (che fanno il lavoro pesante con la magia della sigmoid)
  • 1 nodo di output (che ci dà la somma)

Ecco un diagramma che mostra la struttura della rete neurale che stiamo usando per sommare due numeri. Puoi vedere come ogni nodo di input si collega a tutti i nodi del layer nascosto, come vengono applicati pesi e bias e come tutto fluisce verso l'output finale:

E sì, abbiamo scalato tutto. Poiché i nostri numeri sono sotto 100, dividiamo gli input per 100 e riportiamo l'output a un valore più alto moltiplicandolo per 200 alla fine.

Questo è il codice che scriviamo per sommare due numeri usando la rete neurale:

#include <iostream>
#include <cmath>
using namespace std;

float sigmoid(float x) {
    return 1.0f / (1.0f + exp(-x));
}

float sigmoid_derivative(float y) {
    return y * (1 - y); // y = sigmoid(x)
}

int main() {
    float weight1_node1 = 0.5f, weight2_node1 = 0.5f, bias_node1 = 0.0f;
    float weight1_node2 = -0.5f, weight2_node2 = -0.5f, bias_node2 = 0.0f;
    float weight1_node3 = 0.25f, weight2_node3 = -0.25f, bias_node3 = 0.0f;

    float weight_out_node1 = 0.5f, weight_out_node2 = 0.5f, weight_out_node3 = 0.5f, bias_out = 0.0f;

    float learning_rate = 0.01f;
    int epochs = 1000;

    for (int epoch = 0; epoch < epochs; ++epoch) {
        float total_loss = 0.0f;
        for (int a = 0; a < 100; ++a) {
            for (int b = 0; b < 100; ++b) {
                float x1 = a / 100.0f;
                float x2 = b / 100.0f;
                float target = (a + b) / 200.0f; // Normalizza il target nell'intervallo 0-1

                // Passaggio in avanti per il layer nascosto
                float z1 = x1 * weight1_node1 + x2 * weight2_node1 + bias_node1;
                float h1 = sigmoid(z1);

                float z2 = x1 * weight1_node2 + x2 * weight2_node2 + bias_node2;
                float h2 = sigmoid(z2);

                float z3 = x1 * weight1_node3 + x2 * weight2_node3 + bias_node3;
                float h3 = sigmoid(z3);

                // Layer di output con attivazione lineare
                float y_pred = h1 * weight_out_node1 + h2 * weight_out_node2 + h3 * weight_out_node3 + bias_out;

                float error = target - y_pred;
                total_loss += error * error;

                // Passaggio all'indietro (layer di output - lineare)
                float delta_out = error;
                float grad_out_w1 = delta_out * h1;
                float grad_out_w2 = delta_out * h2;
                float grad_out_w3 = delta_out * h3;
                float grad_out_b = delta_out;

                // Passaggio all'indietro (layer nascosto)
                float delta_h1 = delta_out * weight_out_node1 * sigmoid_derivative(h1);
                float delta_h2 = delta_out * weight_out_node2 * sigmoid_derivative(h2);
                float delta_h3 = delta_out * weight_out_node3 * sigmoid_derivative(h3);

                float grad_w1_node1 = delta_h1 * x1;
                float grad_w2_node1 = delta_h1 * x2;
                float grad_b_node1 = delta_h1;

                float grad_w1_node2 = delta_h2 * x1;
                float grad_w2_node2 = delta_h2 * x2;
                float grad_b_node2 = delta_h2;

                float grad_w1_node3 = delta_h3 * x1;
                float grad_w2_node3 = delta_h3 * x2;
                float grad_b_node3 = delta_h3;

                // Aggiorna i pesi
                weight_out_node1 += learning_rate * grad_out_w1;
                weight_out_node2 += learning_rate * grad_out_w2;
                weight_out_node3 += learning_rate * grad_out_w3;
                bias_out += learning_rate * grad_out_b;

                weight1_node1 += learning_rate * grad_w1_node1;
                weight2_node1 += learning_rate * grad_w2_node1;
                bias_node1 += learning_rate * grad_b_node1;

                weight1_node2 += learning_rate * grad_w1_node2;
                weight2_node2 += learning_rate * grad_w2_node2;
                bias_node2 += learning_rate * grad_b_node2;

                weight1_node3 += learning_rate * grad_w1_node3;
                weight2_node3 += learning_rate * grad_w2_node3;
                bias_node3 += learning_rate * grad_b_node3;
            }
        }

        if ((epoch + 1) % 100 == 0 || epoch == 0)
            cout << "[Riepilogo] Epoca " << epoch + 1 << ": Perdita = " << total_loss << endl;
    }

    // Testa il modello
    float a, b;
    cout << "\nTest di previsione della somma (a + b)\nInserisci a: ";
    cin >> a;
    cout << "Inserisci b: ";
    cin >> b;

    float x1 = a / 100.0f;
    float x2 = b / 100.0f;

    float h1 = sigmoid(x1 * weight1_node1 + x2 * weight2_node1 + bias_node1);
    float h2 = sigmoid(x1 * weight1_node2 + x2 * weight2_node2 + bias_node2);
    float h3 = sigmoid(x1 * weight1_node3 + x2 * weight2_node3 + bias_node3);
    float y_pred = h1 * weight_out_node1 + h2 * weight_out_node2 + h3 * weight_out_node3 + bias_out;
    float predicted_sum = y_pred * 200.0f;

    cout << "w1_node1: " << weight1_node1 << ", w2_node1: " << weight2_node1
                             << ", w1_node2: " << weight1_node2 << ", w2_node2: " << weight2_node2
                             << ", w1_node3: " << weight1_node3 << ", w2_node3: " << weight2_node3
                             << ", out_w1: " << weight_out_node1 << ", out_w2: " << weight_out_node2
                             << ", out_w3: " << weight_out_node3 << ", bias_out: " << bias_out
                             << ", bias_node1: " << bias_node1 << ", bias_node2: " << bias_node2 << ", bias_node3: " << bias_node3 << endl;

    cout << "Somma prevista: " << predicted_sum << "\nSomma reale: " << (a + b) << endl;

    return 0;
}

Quindi... Come Ha Imparato?

Ecco la parte interessante: non abbiamo codificato le regole per l'addizione. Abbiamo semplicemente fornito alla rete un sacco di coppie di numeri e le loro somme, e ha lentamente regolato quei pesi e bias finché le previsioni non sono diventate sempre migliori.

È un po' come un tentativo ed errore, ma più intelligente: con un pizzico di calcolo.

Ha Davvero Imparato a Sommare?

Eh, non esattamente come facciamo noi. Non capisce cosa significhi "più". Ma impara una super buona approssimazione. Vede schemi nei numeri e prevede somme che sono spesso corrette.

È più come imparare a percepire schemi piuttosto che conoscere regole. Se sei curioso, puoi cambiare i numeri di input e testarlo. Più giochi, più capirai come funzionano questi piccoli cervelli.

Conclusione

Insegnare a una rete neurale a sommare due numeri potrebbe sembrare sciocco all'inizio, ma è un ottimo modo per dare un'occhiata dentro la mente dell'IA. Dietro a tutto il clamore, ci sono solo pesi, bias e un po' di matematica intelligente.

Nella prossima parte, approfondiremo come funzionano le reti neurali: esploreremo i layer, le funzioni di attivazione, le derivate e cosa succede realmente durante l'addestramento.