Redes neuronales y algoritmos evolutivos III
Con este artículo finalizo la serie dedicada a la aplicación de algoritmos genéticos al diseño de redes neuronales. Explicaré el código más relevante del programa de ejemplo que acompaña a estos artículos, principalmente las clases dedicadas al tratamiento de los genes y el proceso de selección. Podéis encontrar más información en los anteriores artículos de la serie.
En este enlace podéis acceder al primer artículo de esta serie, donde explico las ideas básicas en las que está basada esta aplicación. En este otro enlace podéis descargar el código fuente de la aplicación GANNTuning, escrita en csharp con Visual Studio 2017.
El código fuente correspondiente a la red neuronal y su entrenamiento está basado en el de este otro artículo sobre el algoritmo de retro propagación, por lo que puede ser consultado allí.
Clase NNGene
En el espacio de nombres GANNTuning.Genes se encuentra la clase NNGene que representa los genes. La clase implementa los interfaces IEquatable e IComparable, de manera que los genes se puedan ordenar en una lista. Estos genes representan propiedades de la estructura y configuración de la red neuronal. El número de neuronas en la primera capa se almacena en la variable _input. El número de capas ocultas y el número de neuronas en cada una de ellas en el array _hidden; si esta variable es null, no hay capas ocultas, en caso contrario, habrá tantas capas ocultas como elementos en el array, siendo el valor de cada uno de ellos el número de neuronas en la capa. La variable _alpha contiene el valor del parámetro que configura la función sigmoide bipolar de las neuronas que conforman la red. El resto de variables de la clase almacenan diferentes estadísticos de los errores de la red, que representan su eficiencia; la más importante es _fcost, a la que se accede mediante la propiedad Cost, que es el valor de la función de coste.
El cálculo de la función de coste se realice mediante el método ComputeCost, que recibe como parámetro la red a valorar, y que devuelve true en el caso de que la red tenga un coste inferior al actual del gen.
public bool ComputeCost(Network net)
{
string[] pnet = net.Params.Split(';');
if ((Input != pnet[0]) ||
(Hidden != pnet[1]) ||
(_alpha != net.Alpha))
{
throw new Exception("Network and gene params do not match");
}
double cost = net.FailRate +
(0.00005 * (net.Layers - 1) * (net.Neurons - 1));
if (_fcost > cost)
{
_fcost = cost;
_minerrNN = net.TrainErrorMin;
_maxerrNN = net.TrainErrorMax;
_meanerrNN = net.TrainError;
_miderrNN = net.TrainErrorMedian;
_dverrNN = net.TrainErrorDev;
return true;
}
return false;
}
El coste se calcula a partir de la tasa de errores de la red. Esta tasa es el porcentaje de muestras que la red ha clasificado incorrectamente. Este valor se penaliza según la complejidad de la red, que se calcula multiplicando el número de neuronas por el número de capas, sin contar la de salida; de esta forma, de entre dos redes con la misma tasa de fallo, preferimos la más simple.
Esta clase tiene dos constructores, en el primero de ellos, el gen se construye a partir de una red existente. Esto permite calcular la función de coste de una red neuronal cualquiera:
public NNGene(Network net, bool compute)
{
_input = net.Inputs;
_hidden = net.Hidden;
_alpha = net.Alpha;
if (compute)
{
ComputeCost(net);
}
}
El segundo constructor permite crear nuevos genes de manera aleatoria o mediante cruce de dos genes ya existentes:
public NNGene(NNGene g1, NNGene g2, int maxn, int maxh)
{
Random r = new Random();
if ((g1 == null) || (g2 == null))
{
CreateNewGene(maxn, maxh);
}
else
{
double p = r.NextDouble();
if (p < 0.5)
{
Cross(g1, g2);
}
else
{
Cross(g2, g1);
}
p = r.NextDouble();
if ((p < 0.05) || SameAs(g1) || SameAs(g2))
{
Mutate(maxn, maxh);
}
}
}
Los parámetros son g1 y g2, que son los dos genes que queremos cruzar, o null si queremos crear uno nuevo; maxn es el número máximo de neuronas por capa, y maxh el número máximo de capas ocultas.
El método CreateNewGene simplemente crea un nuevo gen utilizando valores aleatorios dentro del rango indicado:
private void CreateNewGene(int maxn, int maxh)
{
Random r = new Random();
_input = r.Next(3, maxn + 1);
int nh = r.Next(maxh + 1);
if (nh > 0)
{
_hidden = new int[nh];
for (int l = 0; l < nh; l++)
{
_hidden[l] = r.Next(1, maxn + 1);
}
}
double da = r.NextDouble();
double sgn = r.NextDouble() > 0.5 ? 1 : -1;
_alpha = 2 + (sgn * Math.Round(da * da * da, 2));
}
El valor de _alpha lo centramos en 2, con un pequeño desvío aleatorio hacia arriba o hacia abajo.
Cruce de genes
El cruce de genes en esta implementación no es simétrico, por lo que el orden de cruce se invierte con una probabilidad de 0.5. El método Cross realiza el cruce:
private void Cross(NNGene g1, NNGene g2)
{
_input = g1._input;
if (g1._hidden == null)
{
_hidden = g2._hidden;
}
else
{
if (g2._hidden == null)
{
_hidden = g1._hidden;
}
else
{
_hidden = new int[Math.Min(g1._hidden.Length,
g2._hidden.Length)];
for (int h = 0; h < _hidden.Length; h++)
{
if ((h & 1) != 0)
{
_hidden[h] = g2._hidden[h];
}
else
{
_hidden[h] = g1._hidden[h];
}
}
}
}
_alpha = g2._alpha;
}
Para el número de neuronas de entrada, se toma la cantidad del primer gen. Si uno de los genes no tiene capas ocultas, el gen resultante toma las capas ocultas del otro; en caso de que los dos tengan capas ocultas, se crea un gen con tantas capas ocultas como el que menos tenga, y luego se toma el número de neuronas en cada capa alternativamente del primer y segundo gen. El valor del parámetro _alpha se toma del segundo gen.
Por supuesto, estas reglas son arbitrarias, las he puesto solo como ejemplo. En un problema real pueden estar mucho más elaboradas y diseñadas con vistas a maximizar la eficiencia del algoritmo.
Mutaciones
Si, como resultado del cruce, el gen resultante es igual que uno de los padres, o bien con una probabilidad menor del 5%, se produce una mutación, mediante el método Mutate:
private void Mutate(int maxn, int maxh)
{
Random r = new Random();
double da = r.NextDouble();
double p = r.NextDouble();
_alpha = Math.Max(0.5, _alpha +
(p < 0.5 ? Math.Round(da * da * da, 2) :
-Math.Round(da * da * da, 2)));
p = r.NextDouble();
if (p < 0.3)
{
_input = r.Next(3, maxn + 1);
}
p = r.NextDouble();
if (p < 0.3)
{
int nh = r.Next(maxh + 1);
if (nh > 0)
{
_hidden = new int[nh];
for (int l = 0; l < nh; l++)
{
_hidden[l] = r.Next(1, maxn + 1);
}
}
}
}
El valor de _alpha se incrementa o decrementa en un pequeño valor. Con una probabilidad del 30% cambiamos el número de neuronas de entrada y, de nuevo con probabilidad del 30%, modificamos aleatoriamente el número de capas ocultas y la cantidad de neuronas que hay en ellas.
Estas reglas también son arbitrarias y solo valen a modo de ejemplo. El proceso de diseñar unos buenos algoritmos de cruce y mutación puede ser muy laborioso. Los genes pueden asimismo representar más parámetros y propiedades de la red que los tratados en este ejemplo. También sería posible, por ejemplo, tener diferentes tipos de genes con diferentes reglas; el sistema puede hacerse tan complejo como sea necesario, de cara a optimizar la exploración de todo el espacio de posibilidades.
Para generar la red neuronal representada por el gen, se utiliza el método BuildNN:
public Network BuildNN(int inputs)
{
return new Network(inputs, _input, _hidden, _alpha);
}
Dónde el parámetro inputs representa el número de datos en cada una de las muestras.
Implementación del algoritmo genético
La selección de los genes está implementada en el controlador asíncrono del evento Click del botón de comando correspondiente, en la clase MainForm del formulario principal. Previamente habremos cargado un conjunto de datos mediante la clase CSVReader, que estará almacenado en la variable _data. Para obtener una muestra con un porcentaje determinado de los datos totales, se procede de la siguiente forma:
int pc = GetTrainParams();
_data.GetSample((pc * _data.MinSampleCount) / 100);
Donde pc es el porcentaje de muestras utilizado para el entrenamiento de la red, y se obtiene desde los controles de entrada de datos del formulario, junto con el resto de parámetros.
Para entrenar y valorar la red generada por un determinado gen, se utiliza el método TrainGene:
private async Task<Network> TrainGene(NNGene gen, int trials)
{
Network nn = null;
_network = gen.BuildNN(_data.ValueCount);
for (int fc = 0; fc < trials; fc++)
{
_network.InitializeWeights();
InitializeFeedback();
await _network.TrainNetwork(_data.Inputs,
_data.Outputs,
_learningrate,
_momentum,
_iterations);
_network.ComputeErrors(_data.Inputs,
_data.Outputs,
_data.MaxError);
if (gen.ComputeCost(_network))
{
nn = _network;
}
}
return nn;
}
Este método recibe como parámetros el gen a valorar y el número de veces que se realizará el entrenamiento. Los pesos de las neuronas de la red se inicializaran aleatoriamente cada una de las veces mediante el método InitializeWeights de la clase Network, que representa la red neuronal generada por el gen. La eficacia de la red puede variar bastante en función de los pesos iniciales utilizados, por lo que realizar varias valoraciones puede resultar de utilidad.
El método TrainNetwork realiza el entrenamiento en sí, con tantas iteraciones como se indiquen en la variable _iterations. En cada iteración, las muestras de entrada son reordenadas aleatoriamente, lo que optimiza en cierta medida el proceso de aprendizaje.
Una vez entrenada la red, con el método ComputeErrors se calculan los estadísticos relativos a los errores, y se utiliza el método ComputeCost del gen para determinar si el coste de la red es inferior al último coste calculado. En caso afirmativo, se devuelve la red mejorada como valor de retorno del método.
Volviendo al algoritmo principal, estas son las variables más importantes:
_genes = new List<NNGene>();
double mincost = double.MaxValue;
double maxcost = double.MinValue;
NNGene gene = null;
List<NNGene> nwgen = new List<NNGene>();
int generation = 0;
_genes es una variable global del tipo List<NNGene>, que contiene los genes de toda la población. Las variables locales mincost y maxcost contienen los costes mínimo y máximo encontrados, para informar en el interfaz de usuario de la evolución del algoritmo. La variable local gene se utiliza para crear nuevos genes, y nwgen es la lista de genes de la nueva población de la nueva generación que va a sustituir a los peores de la generación anterior. La variable local generation lleva la cuenta de las generaciones.
La población de genes se inicializa de la siguiente manera:
for (int i = 0; i < pop; i++)
{
gene = new NNGene(null, null, maxnn, maxh);
_genes.Add(gene);
Network nn = await TrainGene(gene, _trials);
if ((nn != null) && (bestgene.Cost > gene.Cost))
{
bestnn = nn;
bestgene = new NNGene(nn, true);
}
mincost = Math.Min(mincost, gene.Cost);
maxcost = Math.Max(maxcost, gene.Cost);
tbResults.Text = "Population: " + _genes.Count.ToString();
}
A partir de aquí, en cada iteración, se crea una nueva generación con las siguientes reglas:
- Se cruzan los 3 primeros mejores genes, cada uno con los siguientes 5 mejores. En total se generan 15 nuevos genes.
- Se cruzan los 3 primeros genes, cada uno con 10 genes seleccionados aleatoriamente de entre toda la población. En total 30 nuevos genes.
- El 25% de los genes, seleccionados aleatoriamente, se cruza con otro gen, también seleccionado aleatoriamente. El número de nuevos genes será el 25% de la población total.
- Se generan aleatoriamente un número de genes igual al 25% de la población total.
Este fragmento de código, por ejemplo, corresponde a la primera de las operaciones anteriores:
for (int g = 0; g < 3; g++)
{
for (int ix = 1; ix <= 5; ix++)
{
gene = new NNGene(_genes[g], _genes[g + ix], maxnn, maxh);
nwgen.Add(gene);
Network nn = await TrainGene(gene, _trials);
if ((nn != null) && (bestgene.Cost > gene.Cost))
{
bestnn = nn;
bestgene = new NNGene(nn, true);
}
mincost = Math.Min(mincost, gene.Cost);
maxcost = Math.Max(maxcost, gene.Cost);
tbResults.Text = "Min Cost: " +
mincost.ToString() +
" / Max Cost: " + maxcost.ToString() +
" / Gen.: " + generation.ToString() +
" / New Pop.: " + nwgen.Count.ToString();
}
}
A continuación, se elimina un número de genes de la población actual igual al de la nueva población, de entre los genes con peores puntuaciones:
int sup = Math.Max(0, (_genes.Count - nwgen.Count));
_genes.RemoveRange(sup, _genes.Count - sup);
Se vuelven a entrenar los genes que han quedado, para darles una nueva oportunidad de puntuar mejor, y se añade la nueva población a la lista, para volver a comenzar el proceso.
De nuevo, estas reglas son arbitrarias. Están seleccionadas solo a modo de ejemplo. En una implementación real, se deben definir de forma meditada, y en base a las características del problema en el que estamos trabajando.
Eso es todo con respecto a esta serie sobre los algoritmos genéticos aplicados a las redes neuronales. Hay mucho por mejorar en los procedimientos que he explicado, pero pueden servir de base para construir aplicaciones más serias, o simplemente para trastear un poco con esta tecnología.