Team I.A. Italia

6 min

Come creare una rete neurale con java

In questo post affronteremo l'Intelligenza Artificiale a piccoli passi e proveremo a costruire una rete neurale molto semplice in Java.

Come creare una rete neurale con java

COS'È UNA RETE NEURALE?

Una rete neurale è una rappresentazione software di come funziona il cervello. Sfortunatamente, non sappiamo ancora come funzioni esattamente il cervello, ma conosciamo un po' la biologia alla base di questo processo: il cervello umano è costituito da 100 miliardi di cellule chiamate neuroni, collegate tra loro da sinapsi. Se un numero sufficiente di sinapsi si collega a un neurone si attiverà.

Questo processo è noto come "pensare".

Proviamo quindi a modellare il processo di cui sopra utilizzando un esempio molto semplice che ha 3 input (sinapsi) e si traduce in un singolo output (1 neurone di attivazione).

Come creare una rete neurale con java

Creare una semplice rete neurale con java

Addestreremo la nostra rete neurale nell' immagine sopra per risolvere il seguente problema.

Riesci a capire lo schema e indovinare quale dovrebbe essere il valore del nuovo input?

0 o 1?

Come creare una rete neurale con java

La risposta è in realtà molto semplicemente il valore della colonna più a sinistra, cioè 1!

Quindi, ora che abbiamo il modello di un cervello umano, cercheremo di far apprendere alla nostra rete neurale quale schema è dato al set di addestramento. Per prima cosa assegneremo a ciascun input un numero casuale per produrre un output.

Come creare una rete neurale con java

La formula per calcolare l'uscita è data come segue:

∑ weioghtio.ionptutio = weioght1.ionptut1 + weioght2.ionptut2 + weioght3.ionptut3

A quanto pare vorremmo normalizzare questo valore di output su qualcosa tra 0 e 1 in modo che la previsione abbia senso. Dopo la normalizzazione confrontiamo l'output con l'output atteso dei nostri input. Questo ci dà l'errore, o quanto è lontana la nostra previsione. Possiamo quindi utilizzare questo errore per regolare leggermente i pesi della nostra rete neurale e tentare nuovamente la fortuna sullo stesso input. Questo può essere riassunto nella seguente immagine:

Come creare una rete neurale con java

Ripetiamo questo processo di addestramento per tutti gli input 10.000 volte per raggiungere una rete neurale adeguatamente addestrata. Possiamo quindi utilizzare questa rete neurale per fare previsioni su nuovi input!

Prima di passare all'implementazione, tuttavia, dobbiamo ancora chiarire come abbiamo ottenuto la normalizzazione e l'adeguamento del peso in base all'errore (noto anche come back-propagation).

Normalizzare i dati per la rete neurale con java

In una rete neurale di ispirazione biologica, l'output di un neurone è solitamente un'astrazione che rappresenta la velocità di attivazione del potenziale d'azione nella cellula. Nella sua forma più semplice, questo è un valore binario, cioè o il neurone si sta attivando o meno. Da qui la necessità di normalizzazione di questo valore di uscita.

Per ottenere questa normalizzazione applichiamo quella che è nota come funzione di attivazione all'output del neurone. Se prendiamo l'esempio di una funzione passo Heaviside davvero semplice che assegna uno 0 a qualsiasi valore negativo e un 1 a qualsiasi valore positivo, allora sarebbe necessario un gran numero di neuroni per raggiungere la granularità richiesta di regolare lentamente i pesi per raggiungere un consenso accettabile del set di formazione.

Come vedremo nella prossima sezione sulla retropropagazione, questo concetto di aggiustamento lento dei pesi può essere rappresentato matematicamente come la pendenza della funzione di attivazione. In termini biologici, può essere considerato come l'aumento della velocità di accensione che si verifica all'aumentare della corrente di ingresso. Se dovessimo utilizzare una funzione lineare invece della funzione di Heaviside, scopriremmo che la rete risultante avrebbe una convergenza instabile perché gli input dei neuroni lungo i percorsi preferiti tenderebbero ad aumentare senza limiti, poiché una funzione lineare non è normalizzabile.

Tutti i problemi sopra menzionati possono essere gestiti utilizzando una funzione di attivazione sigmoidea normalizzabile. Un modello realistico rimane a zero fino a quando non viene ricevuta la corrente di ingresso, a quel punto la frequenza di attivazione aumenta rapidamente all'inizio, ma si avvicina gradualmente a un asintoto con una frequenza di attivazione del 100%. Matematicamente, questo è simile a:

Come creare una rete neurale con java

Se tracciata su un grafico, la funzione Sigmoid disegna una curva a forma di S:

Come creare una rete neurale con java

Pertanto, diventa ora la formula finale per l'output di un neurone :

Come creare una rete neurale con java

Ci sono altre funzioni di normalizzazione che possiamo usare, ma il sigmoide ha il vantaggio di essere abbastanza semplice e di avere anche una semplice derivata che sarà utile quando osserveremo la propagazione posteriore di seguito.

Back Propagation per la rete neurale con java

Durante il ciclo di allenamento, abbiamo regolato i pesi in base all'errore. Per fare ciò, possiamo utilizzare la formula "Derivata ponderata per l'errore".

UNdjtuStment = error.ionptut.SiogmoiodCturveGrundioent(otutptut)

Il motivo per cui utilizziamo questa formula è che in primo luogo, vogliamo rendere la regolazione proporzionale alla dimensione dell'errore. In secondo luogo, moltiplichiamo per l'input, che è uno 0 o un 1. Se l'input è 0, il peso non viene regolato. Infine, moltiplichiamo per il gradiente della curva Sigmoide (o la derivata).

Il motivo per cui utilizziamo il gradiente è perché stiamo cercando di ridurre al minimo la perdita. In particolare, lo facciamo con un metodo di discesa del gradiente . Fondamentalmente significa che dal nostro punto corrente nello spazio dei parametri (determinato dall'insieme completo dei pesi correnti), vogliamo andare in una direzione che diminuirà la funzione di perdita. Visualizza in piedi su una collina e cammina lungo la direzione in cui il pendio è più ripido. Il metodo di discesa del gradiente applicato alla nostra rete neurale è illustrato come segue:

  1. Se l'output del neurone è un grande numero positivo o negativo, significa che il neurone era abbastanza sicuro in un modo o nell'altro.

  2. Dal grafico sigmoideo, possiamo vedere che in grandi numeri la curva sigmoidea ha un gradiente poco profondo.

  3. Pertanto, se il neurone è sicuro che il peso esistente sia corretto, non vuole regolarlo molto e moltiplicandolo per il gradiente della curva sigmoidea si ottiene questo.

La derivata della funzione sigmoidea è data dalla seguente formula

SiogmoiodCturveGrundioent(otutptut)=
 
otutptut.(1−otutptut)

Sostituendo questo di nuovo nella formula di regolazione ci dà

UNdjtuStment=error.ionptut.otutptut.(1−otutptut)

Codice Java per la rete neurale

Un punto importante ma sottile che è stato tralasciato quando si spiega la matematica di cui sopra è che per ogni iterazione di addestramento, le operazioni matematiche vengono eseguite contemporaneamente sull'intero set di addestramento. Pertanto, utilizzeremo le matrici per memorizzare l'insieme dei vettori di input, i pesi e gli output attesi.

Inizieremo con la classe NeuronLayer che è solo un "punto di accesso" per i pesi nella nostra implementazione della rete neurale. Gli forniamo il numero di input per neurone e il numero di neuroni che può utilizzare per costruire una tabella dei pesi. Nel nostro esempio attuale, questo è semplicemente l'ultimo neurone di output che ha i 3 neuroni di input.

public class NeuronLayer {
 
public final Function<Double, Double> activationFunction, activationFunctionDerivative;
 
double[][] weights;
 
public NeuronLayer(int numberOfNeurons, int numberOfInputsPerNeuron) {
 

 
weights = new double[numberOfInputsPerNeuron][numberOfNeurons];
 
for (int i = 0; i < numberOfInputsPerNeuron; ++i) {
 
for (int j = 0; j < numberOfNeurons; ++j) {
 
weights[i][j] = (2 * Math.random()) - 1; // shift the range from 0-1 to -1 to 1
 
}
 
}
 
activationFunction = NNMath::sigmoid;
 
activationFunctionDerivative = NNMath::sigmoidDerivative;
 
}
 
public void adjustWeights(double[][] adjustment) {
 
this.weights = NNMath.matrixAdd(weights, adjustment);
 
}
 
}
 

La nostra classe di rete neurale è dove si svolgono tutte le azioni. Prende come costruttore NeuronLayere ha 2 funzioni principali:

  • think: calcola le uscite di un dato set di ingressi

  • train: esegue i tempi del ciclo di allenamento numberOfTrainingIterations(solitamente un numero elevato come 10.000). Si noti che l'allenamento stesso implica il calcolo dell'output e quindi la regolazione dei pesi di conseguenza

public class NeuralNetSimple {
 
private final NeuronLayer layer1;
 
private double[][] outputLayer1;
 
public NeuralNetSimple(NeuronLayer layer1) {
 
this.layer1 = layer1;
 
}
 
public void think(double[][] inputs) {
 
outputLayer1 = apply(matrixMultiply(inputs, layer1.weights), layer1.activationFunction);
 
}
 

 
public void train(double[][] inputs, double[][] outputs, int numberOfTrainingIterations) {
 
for (int i = 0; i < numberOfTrainingIterations; ++i) {// pass the training set through the network
 
think(inputs);// adjust weights by error * input * output * (1 - output)
 
double[][] errorLayer1 = matrixSubtract(outputs, outputLayer1);
 
double[][] deltaLayer1 = scalarMultiply(errorLayer1, apply(outputLayer1, layer1.activationFunctionDerivative));
 

 
double[][] adjustmentLayer1 = matrixMultiply(matrixTranspose(inputs), deltaLayer1);
 
// adjust the weights
 
this.layer1.adjustWeights(adjustmentLayer1);
 
}
 
}
 
public double[][] getOutput() {
 
return outputLayer1;
 
}
 
}

Infine abbiamo il nostro metodo principale in cui impostiamo i nostri dati di allenamento, alleniamo la nostra rete e le chiediamo di fare previsioni sui dati di test

public class LearnFirstColumnSimple {
 
public static void main(String args[]) {
 
// create hidden layer that has 1 neuron and 3 inputs
 
NeuronLayer layer1 = new NeuronLayer(1, 3);
 
NeuralNetSimple net = new NeuralNetSimple(layer1);
 
// train the net
 
double[][] inputs = new double[][]{{0, 0, 1},{1, 1, 1},{1, 0, 1},{0, 1, 1}};
 
double[][] outputs = new double[][]{{0},{1},{1},{0}};
 
System.out.println("Training the neural net...");
 
net.train(inputs, outputs, 10000);
 
System.out.println("Finished training");
 
System.out.println("Layer 1 weights");
 
System.out.println(layer1);
 
// calculate the predictions on unknown data// 1, 0, 0
 
predict(new double[][], net);// 0, 1, 0
 
predict(new double[][], net);// 1, 1, 0
 
predict(new double[][], net);
 
}
 

 
public static void predict(double[][] testInput, NeuralNetSimple net) {
 
net.think(testInput);
 
// then
 
System.out.println("Prediction on data "+ testInput[0][0] + " "+ testInput[0][1] + " "+ testInput[0][2] + " -> "+ net.getOutput()[0][0] + ", expected -> " + testInput[0][0]);
 
}
 
}

Eseguendo il nostro esempio sopra, vediamo che la nostra rete ha fatto un buon lavoro nel prevedere quando l'input più a sinistra è 1 ma non sembra che riesca a ottenere lo 0 giusto! Questo perché il secondo e il terzo peso di input dovevano essere entrambi più vicini a 0.

Training the neural net...
 
Finished training
 
Layer 1 weights
 
[[9.672988220005456 ]
 
[-0.2089781536334558 ]
 
[-4.628957430141331 ]
 
]
 

 
Prediction on data 1.0 0.0 0.0 -> 0.9999370425325528, expected -> 1.0
 
Prediction on data 0.0 1.0 0.0 -> 0.4479447696095623, expected -> 0.0
 
Prediction on data 1.0 1.0 0.0 -> 0.9999224112145153, expected -> 1.0
 

Nel prossimo post vedremo se l'aggiunta di un altro livello alla nostra rete neurale può aiutare a migliorare le previsioni ;)