Reti neurali attraverso algoritmi genetici in C++. Parte III

28 November 2008 da Francesco

IMPLEMENTAZIONE

Ok, siamo arrivati alla parte più tecnica cioè all’implementazione vera e propria della rete. Ho scelto di utilizzare il C++ e di programmare tutto ad oggetti perchè, anche se questo approccio sacrifica forse un po’ la velocità, rende, almeno per me, il tutto più comprensibile e riutilizzabile (dalle classi che qui espongo ho costruito poi una libreria per l’utilizzo delle reti neurali).

Cominciamo a partire dalle classi della rete neurale.

Le classi Neuron, NeuronLayer e NeuralNet

La prima classe che implementeremo è la classe Neuron, che rappresenta la struttura più piccola della rete neurale: il neurone.

Ogni neurone come abbiamo visto ha dei collegamenti pesati in entrata (tranne i neuroni di input) e un valore, che è contiene il valore del neurone.

Il codice della classe Neuron è il seguente:

class Neuron {
	public:
		Neuron(int c_numWeights);
		void setValue(double v);
		double getValue() const;
		void setWeight(int i, double v);
		double getWeight(int i) const;
		int getWeightsNum(bool bias) const;
	private:
		double value;
		vector<double> weights;
};

I metodi di questa classe sono tutti molto facili da capire: riporto qui solamente il codice del costrutture e di getWeightsNum(bool):

// Costruttore
Neuron::Neuron(int c_numWeights) {
	for (int i = 0; i < c_numWeights+1; i++) {
		weights.push_back(wRand());
	}
}
int Neuron::getWeightsNum(bool bias) const {
	return (bias) ? weights.size() : weights.size() 1;
}

Il costruttore riceve come argomento il numero dei pesi in entrata per questo neurone e li inizializza a valori random (wRand() è una funzione definita in un file header che restituisce un numero compreso tra -1 e 1). Se osservate bene il ciclo va da 0 a c_numWeights+1 questo perchè viene creato un peso aggiuntivo chiamato bias, la cui funzione vi sarà chiara fra poco.

Nella classe Neuron avremmo potuto anche creare un puntatore a funzione che puntasse alla funzione di attivazione (o funzione di trasferimento) del neurone ed in questa maniera avremmo potuto impostare una funzione di attivazione diversa per ogni neurone (o per ogni livello), ma visto che la nostra funzione sarà uguale per tutti i neuroni, ho preferito non farlo per questioni di semplicità.

E’ arrivato il momento di spendere due parole proprio sulle funzioni di attivazione. La funzione di attivazione può essere una qualunque funzione, come per esempio , ma alcune offrono risultati migliori in base al contesto nella quale sono utilizzate. Scegliere la funzione giusta può essere determinante per ottenere buoni risultati.

Vediamo alcune funzioni comunemente usate:

Funzione a gradino: y = 1 per x >= 0, 0 per x < 0

La funzione a gradino (chiamata così per il suo grafico) è la funzione che più rispetta il comportamento del neurone biologico. Infatti essa restituisce 1, cioè propaga il segnale, se la somma degli input ricevuti e maggiore o uguale a un certo numero , che altro non è che la soglia di attivazione. E’ utile utilizzare questa funzione quando vogliamo che la nostra rete abbia un risultato che o 1 o 0 e che quindi separi gli input che gli forniamo in due classi distinte.

Funzione identità: y = x

Non penso ci sia molto da dire su questa funzione, l’unica cosa da sapere è che si utilizza quando il nostro output non è limitato e può variare da meno infinito a più infinito.

Funzione sigmoidale: y = 1/(1+e^(-x))

La funzione sigmoidale è una delle funzioni più utilizzate. Essa passa sempre per il punto (0, 1/2) ed è compresa tra 0 e 1. Proprio per quest’ultimo fatto non può essere utilizzata se ci aspettiamo come output numeri numeri che non sono compresi in questo intervallo, per i quali è necessario utilizzare altre funzioni.

Tangente iperbolica: y = tanh(x)

La tangente iperbolica è molto simile alla funzione sigmoidale solo che il suo output è compreso tra -1 e 1 e passa sempre per l’origine. Come al solito va utilizzata solo nei casi in cui l’output deve essere compreso tra questi valori e per esempio non andrebbe bene se volessimo allenare la rete a fare la somma di due numeri qualsiasi. In realtà le cose non stanno esattamente così, perchè, come vedremo in seguito, esiste un apposito valore (bias) per ogni neurone che serve a spostare il centro della funzione e quindi ad ottenere valori che non sono più compresi tra -1 e 1 ma tra meno infinito e più infinito.

In ogni caso è comunque preferibile una funzione di trasferimento lineare nei casi in cui il nostro output possa variare in maniera illimitata.

Dopo questa parentesi a proposito delle funzioni di attivazione torniamo alla nostra implementazione. Una volta che il neurone fa la sommatoria pesata di tutti gli input ricevuti passa questo valore alla funzione di attivazione che, a sua volta, restituisce un altro valore. Quest’ultimo sarà propagato ai neuroni successivi e così via.

La seconda classe di cui ci occuperemo è NeuronLayer, che rappresenta uno strato di neuroni:

class NeuronLayer {
	public:
		NeuronLayer(int c_numNeurons, int c_weightsPerNeuron);
		Neuron* getNeuron(int i) const;
		int getNeuronsNum() const;
	private:
		vector<Neuron*> neurons;
};

Come è logico, la classe NeuronLayer dovrà contenere un insieme di neuroni e per questo usiamo la classe vector della STL per istanziare un vettore di oggetti Neuron*.

Anche qui i metodi sono abbastanza semplici: il costruttore riceve come parametri il numero dei neuroni facenti parte del layer ed il numero di collegamenti in entrata (e quindi di pesi) per ogni neurone. La funzione getNeuron(int) riceve come parametro un intero i e restituisce l’i-esimo neurone del livello; l’ultima funzione restituisce il numero totale dei neuroni facenti parti del livello.

Siamo giunti alla parte cruciale del nostro programma e cioè la classe NeuralNet.

Sarà lei che si occuperà di collegare i neuroni fra di loro, di propagare i segnali attraverso i neuroni e di darci l’output restituito dalla rete.

class NeuralNet {
	public:
		NeuralNet(int c_numInputNeurons, int c_numOutputNeurons,
		int c_numHiddenLayers, int c_numNeuronsPerLayer);
		void createNet();
		void run(vector<double> input, Chromosome* d);
		int totalNumWeights(bool bias);
		void dump();
		double globalError(vector<double> des);
		void printOutput();
	private:
		vector<NeuronLayer*> layers;
		int numInputNeurons,
		numOutputNeurons,
		numHiddenLayers,
		numNeuronsPerLayer;
}

Il costruttore della classe NeuralNet riceve in input 4 parametri che sono rispettivamente il numero dei neuroni di input, il numero dei neuroni di output, il numero dei livelli nascosti e il numero di neuroni contenuti nei livelli nascosti.

Il costruttore non fa altro che prendere questi argomenti e salvarli nelle quattro variabili apposite. Il vettore layers contiene dei puntatori ad oggetti NeuronLayer che saranno appunto i vari livelli della rete neurale. Due sono i metodi più importanti di questa classe: createNet() e run(vector<double>, Chromosome*). Il primo inizializza l’array layers creando i livelli necessari ed i collegamenti tra i vari neuroni, mentre il secondo, esegue la rete neurale passandogli in input i valori contenuti nell’array input ed utilizzando come pesi della rete il DNA dell’oggeto Chromosome*. Vediamo il codice di queste due funzioni:

void NeuralNet::createNet() {
	if (numHiddenLayers > 0) {
		// Se ci sono layer nascosti li crea
		NeuronLayer* HL = new NeuronLayer(numNeuronsPerLayer,
							numInputNeurons);
		layers.push_back(HL);
		for (int i = 1; i < numHiddenLayers; i++) {
			NeuronLayer* HL = new NeuronLayer(numNeuronsPerLayer,
								numNeuronsPerLayer);
			layers.push_back(HL);
		}
		NeuronLayer* OL = new NeuronLayer(numOutputNeurons,
							numNeuronsPerLayer);
		layers.push_back(OL);
	} else {
		NeuronLayer* OL = new NeuronLayer(numOutputNeurons,
							numInputNeurons);
		layers.push_back(OL);
	}
}

La prima cosa che la funzione fa è quella di verificare se deve creare dei layer nascosti o meno. Nel primo caso crea il primo layer nacosto in cui ogni neurone ha un numero di pesi pari al numero dei neuroni in input (infatti i neuroni del primo layer ricevono i dati proprio dai neuroni di input) e crea i layer seguenti in cui ogni neurone ha un numero di pesi pari al numero di neuroni presenti negli strati precedenti a lui, che essendo tutti strati nascosti avranno un numero di neuroni pari al contenuto della variabile numNeuronsPerLayer; come ultima cosa viene creato il layer di output. Se non ci sono layer nascosto la prima parte viene saltata e viene creato un layer di output collegato direttamente a quello di input.

La funzione run() è un po più complessa. Ho evitato di scriverla tutta qui per problemi di impagnazione, quindi vi conviene leggerla direttamente dal file sorgente. Comunque, la prima cosa che la funziona fa è controllare che il numero degli input ricevuti dalla funzione corrisponda effettivamente al numero di neuroni di input: se così non è l’esecuzione del programma termina dopo aver stampato un messaggio di errore.

Successivamente la funzione deve impostare i pesi della rete neurale con il DNA del cromosoma che gli passiamo come argomento, per fare questo si utilizzano tre cicli for annidati: il primo scorre tutti i livelli della rete neurale, il secondo tutti i neuroni di ogni livello ed il terzo tutti i pesi di ogni neurone, sosituendoli con quelli presenti nel cromosoma.

Dopo aver impostato i pesi non ci resta altro che dare alla rete l’input e propagarlo attraverso i neuroni. Si usano anche qui tre cicli for annidati per scorrere tutti i neuroni della rete neurale e per calcolare la somma degli input pesati. Notate che c’è un if che distingue due casi: se ci troviamo al primo livello nascosto l’input dovremo prenderlo dai neuroni di input (che altro non sono che gli elementi dell’array passato come argomento): questo lo facciamo alla linea:

totInput += layers[i]->getNeuron(j)->getWeight(k) * input[k];

Come vedete, il k-esimo peso del neurone viene moltiplicato per il k-esimo input, che altro non è che un valore dall’array.

Quando non siamo più al primo livello, ma a quelli successivi l’input andrà preso da neuroni precedenti ed infatti:

totInput += layers[i]->getNeuron(j)->getWeight(k) * layers[i-1]->getNeuron(k)>getValue();

Il k-esimo peso del neurone viene moltiplicato per il valore contenuto nel k-esimo neurone del livello precedente (layers[i-1]). La backslash dopo il * sta solamente ad indicare che la linea è spezzata per ragioni di formattazione, ma che il codice va su una sola riga. La variabile totInput conterrà alla fine del ciclo la sommatoria pesata degli input del neurone. Subito dopo il ciclo viene definita una variabile con il valore dell’ultimo peso del neurone (questo peso non è stato preso in considerazione quando facevamo la sommatoria, se notate, nel ciclo abbiamo passato il parametro false alla funzione getWeightsNum() facendoci così restituire il numero dei pesi meno 1). Questo sarà il bias che alla riga successiva viene sottratto al valore totale del neurone dopo che quest’ultimo è stato passato attraverso la funzione sigmoid() cioè la funzione di trasferimento sigmoidale. Dal punto di vista grafico potete immaginare il bias semplicemente come una traslazione sull’asse y del grafico della funzione e questo comporta che la funzione non sia centrata nel punto per ogni neurone ma che ogni neurone abbia appunto una funzione centrata in un punto differente.

Il compito della funzione è finito qui, poiché dopo tutti questi cicli i neuroni dell’ultimo livello, cioè i neuroni di output conterrano il risultato ottenuto dalla rete.

L’ultima funzione degna di nota è globalError(): essa calcola l’errore della rete facendo la differenza tra ogni output ed il rispettivo output desiderato specificato nel training set. Ci servirà in seguito per assegnare il fitness ad i nostri cromosomi.

Nella prossima parte parleremo delle classi relative alla gestione dei cromosomi e metteremo in pratica quello che abbiamo visto teoricamente nella parte seconda quindi continuate a visitarci!

Link alle parti precedenti:

Parte I

Parte II

Condividi l'articolo!
  • Digg
  • del.icio.us
  • Facebook
  • Diggita
  • StumbleUpon
  • Technorati
  • Twitter

Tags: , , ,
Pubblicato in Informatica | Commenti (0)

No comments yet

Leave a Reply