Los clasificadores no solo trabajan sobre dos clases, es muy común encontrarnos con problemas con más de dos. Para adaptar una red neuronal a un problema de n clases basta con colocar en la capa de salida n neuronas.
Vamos a estudiar esto haciendo uso de un dataset muy conocido utilizado para clasificar el género de plantas iris. La flor de iris se presenta en tres especies distintas denominadas: setosa, versicolor y virgínica. Pueden identificarse atendiendo a los anchos y altos de sus pétalos y sépalos. Este conjunto de datos contiene 150 muestras de estas flores, recogiendo 50 muestras de cada especie. Aquí tienes el dataset, descárgalo y échale un vistazo.
Las cuatro primeras columnas corresponden al alto y ancho del sépalo y al alto y ancho del pétalo. La quinta columna indica la especie a la que pertenece: 0 → setosa, 1 → versicolor y 2 → virgínica.
Vemos que la quinta columna contiene los valores 0,1 y 2. Son las etiquetas correspondientes a las tres clases. Decíamos que la forma de adaptar ahora una red para clasificar tres clases es poner en la capa de salida tres neuronas. Recordemos que la función de activación que estamos usando es la sigmoide, cuyo rango es (0,1). ¿Cómo hacemos encajar esto para que podamos definir una función de error sobre la cual podamos descender por el gradiente hasta un mínimo?
La solución está en utilizar la codificación one-hot. Esta codificación hace lo siguiente, se toma un vector de una dimensión igual al número de clases que haya, en este caso, 3. Todas las componentes del vector estarán a 0, excepto una que tendrá el valor 1 y que corresponderá con la clase que defina. Es decir: 0 →[1,0,0], 1 →[0,1,0] y 2 →[0,0,1].
Definamos ahora la red que vamos a utilizar para nuestro clasificador. Se compondrá de: una capa de entrada con 4 entradas (ancho y alto de pétalo y sépalo), 5 neuronas en la capa oculta y 3 neuronas en la capa de salida, dado que hay tres clases. ¿Y por qué cinco neuronas en la capa oculta? -Porque lo digo yo, que para eso soy el profesor.-
Habíamos dicho que cada neurona tiene como función de activación la función sigmoide. Vamos a cambiar esto para la última, y solo para la última capa. En la última capa quitamos la función sigmoide y colocamos la función softmax. La función softmax es parecida a la sigmoide, ésta también convierte cualquier valor a un valor entre cero y uno. La diferencia está en que la función softmax no trabaja sobre un valor sino sobre un vector. De esta forma, convierte todas las componentes de un vector en valores entre cero y uno, pero, además, garantiza que la suma de todos estos valores sea 1.
La salida de la función softmax conforma una distribución de probabilidad.
La expresión de la función softmax es:
Softmax(→v)i=evi∑nj=1evjVeamos esto con un poco de código:
import numpy as np
logits = np.array([8.34, -2.87, 0.002]) # Terminología: al valor de salida de una neurona antes de pasar por la función de activación se le denomina: "logit".
def softmax(i, v):
return np.exp(v[i]) / np.sum(np.exp(v))
a = softmax(0, logits)
b = softmax(1, logits)
c = softmax(2, logits)
print(a,"+",b,"+",c,"=", a+b+c)
0.999747275384849 + 1.3534714121386372e-05 + 0.0002391899010295041 = 0.9999999999999999
Nuestra función de error va a comparar las salidas de la red con la etiqueta. Supongamos que nuestra red devuelve (salida del softmax) para una determinada entrada los valores: o1=0.78,o2=0.03,o3=0,19, y que su correspondiente etiqueta es: (1,0,0) en codificación softmax. Por tanto, el error, para esta muestra, será:
error=(1−0.78)2+(0−0.03)2+(0−0.19)2=0.0854Pero, dado que tenemos 150 muestras, la función de error final la calcularíamos sumando los 150 errores particulares de cada muestra.
La forma de calcular la función de error que acabamos de ver no se hace así en la práctica. Lo entenderemos mejor con el siguiente símil. Supongamos que queremos saber la media de altura de todos los habitantes de España. Deberíamos medir a cada persona, sumar todas las alturas y dividir por el número total de personas. Esta es la media exacta, pero nos iba a costar mucho poder llevar a cabo todas estas medidas (somos algo más de 46 millones). Otra forma mucho más asequible sería tomar una muestra de esta población y calcular la media muestral. Si la muestra es suficientemente variada y amplia obtendremos un valor muy aproximado a la media real pero con mucho menos esfuerzo.
Algo parecido podemos aplicar sobre la función de error. Si, en lugar de utilizar todo el conjunto de datos para calcular el error y luego el gradiente que nos lleve a un nuevo conjunto de pesos y umbrales, tomamos una muestra variada de los datos para calcular el error, el gradiente que obtendremos será muy parecido y nos llevará mucho menos tiempo. Por lo tanto, podremos avanzar mucho más rápido descendiendo por el gradiente.
A esta muestra (entiéndase como "muestra poblacional", no "muestra" como elemento del dataset) que tomamos del dataset lo llamaremos mini-batch. Por ejemplo, en el caso del dataset del iris, son 150 muestras, que, una vez bien “barajadas” para que el mini-batch sea variado y representativo, podremos tomar mini-lotes de 15 muestras. Teniendo un total de 10 mini-lotes. Con datasets pequeños como éste no se aprecia visiblemente la mejora en velocidad, pero con conjuntos de decenas de miles de muestras o más, la reducción de velocidad es drástica.
Por tanto, iremos tomando sucesivos mini-batchs de forma iterativa hasta completar el número de estos. Cuando hayamos procesado todos los mini-batchs diremos que hemos completado una época (epoch), y volveremos a repetir todo el proceso hasta aproximarnos suficientemente a un mínimo local de la función de error.
Implementemos ya todos estos conceptos. Para ello, utilizaremos el módulo nn4t
(aquí tienes el enlace). Este módulo incluye una clase denominada Net
para implementar redes sencillas de una forma muy simple.
import numpy as np
import nn4t
data = np.genfromtxt('data/iris.data', delimiter=",") # Cargamos el dataset iris
np.random.shuffle(data) # Desordenamos el dataset para conseguir minibatchs con suficiente variación de muestras
x_data = data[:, 0:4].astype('f4') # Muestras
y_data = nn4t.one_hot(data[:, 4].astype(int), 3) # Etiquetas en formato one-hot
batch_size = 15 # tamaño del minibatch
epochs = 100 # Número de épocas
net = nn4t.Net(layers=[4, 5, 3]) # Configurasmos nuestra red con 4 entradas, 5 neuronas en la capa oculta y 3 salidas
for _ in range(epochs): # Bucle de épocas
for i in range(int(data.shape[0]/batch_size)): # Número de minilotes en el dataset
p = i*batch_size # Índice del próximo minilote
net.train(x_data[p:p+batch_size], y_data[p:p+batch_size]) # Entrenamos la red. Esto significa actualizar los pesos con los gradientes calculados
# Una vez terminado el entrenamiento, vamos a ver qué tal clasifica
for x, y in zip(x_data[:15], y_data[:15]): # Para esta prueba utilizaremos solamente las 15 primeras muestras
print(y, "-->", net.output(x)) # Comparamos con las etiquetas
[1. 0. 0.] --> [9.66955454e-01 4.55074164e-02 5.64529518e-04] [0. 0. 1.] --> [0.01510159 0.80636312 0.15927768] [0. 1. 0.] --> [0.05402217 0.97904703 0.00893667] [1. 0. 0.] --> [9.61763759e-01 5.35497406e-02 6.04159967e-04] [0. 0. 1.] --> [0.00223394 0.08652608 0.92866322] [1. 0. 0.] --> [9.67630107e-01 4.44672126e-02 5.59050666e-04] [0. 0. 1.] --> [0.00423358 0.25013567 0.75412165] [0. 0. 1.] --> [0.00201236 0.07221637 0.94400104] [0. 1. 0.] --> [0.06717663 0.9731432 0.00784728] [0. 1. 0.] --> [0.03721496 0.96095768 0.02302334] [0. 1. 0.] --> [0.06337599 0.97202536 0.00887602] [0. 0. 1.] --> [0.00288139 0.13325937 0.87803716] [1. 0. 0.] --> [9.63036962e-01 5.15704784e-02 5.95590656e-04] [0. 0. 1.] --> [0.0032084 0.16221549 0.85218151] [0. 0. 1.] --> [0.01434341 0.78955372 0.17471567]
Es posible que, ya a estas alturas, te estés preguntando cómo se decide el número de capas ocultas que debe tener una red, o cuántas neuronas por capa oculta, cómo establecer el tamaño del mini-batch, el número de épocas... Estos y otros muchos, aún por ver, valores en el diseño de la red se denominan hiperparámetros. No son valores que la red aprenda por sí sola, sino que le han de ser dados. No hay un procedimiento sistemático para establecerlos, no hay forma de saber a priori cuáles son los mejores valores. En muchos casos, no queda más remedio que probar entre diferentes configuraciones y/o seguir algunas recetas, más o menos fiables, provenientes de la experiencia.
Al conjunto de todos los pesos (y umbrales, en el caso de que los tengamos diferenciados de los pesos) los denominamos parámetros y, a diferencia de los hiperparámetros, sí son aprendidos por la red.
Desarrolla un código que calcule el número de aciertos total al pasar las 150 muestras por la red ya entrenada.
Añade al programa anterior código para que se vaya mostrando el valor del error a medida que descendemos por el gradiente. Recuerda que este valor lo puedes calcular de la siguiente forma:
donde m corresponde al número de muestras y n al número de salidas. Puedes hacer este cálculo después de que se complete cada época. Al final toma todos esos valores para mostrar una gráfica donde el eje x represente a las épocas y el eje y al error. Para esto, puedes utilizar la librería matplotlib.