El perceptrón visto en el post anterior es una red neuronal de una sola capa. Otro ejemplo de este tipo de redes es la llamada neurona lineal adaptativa, en inglés ADAptative LIneal NEuron, o ADALINE. Nos dice el libro que la importancia de este algoritmo se debe a que muestra con claridad la definición y minimización de las funciones de costos (cost functions).

Función de costos

Función de costos es un término que procede de la economía, y hace referencia a la función que expresa los costes de producción en términos de la cantidad producida. En el campo de las redes neuronales la función de costos devuelve un número que determina indica lo bien que el algoritmo genera las salidas correctas para las muestras de entrenamiento. Este número es mejor cuanto más bajo, por eso se busca minimizar el valor de la función. Esta minimización de la función de costos supone la base de muchas técnicas de clasificación.

La diferencia básica entre Adaline y el perceptrón está en la función de activación, que pasa de ser una función escalón a ser una función lineal.

Actualizamos los pesos con una función lineal de la entrada
Actualizamos los pesos con una función lineal de la entrada

En concreto, la función lineal de activación de Adaline es la función identidad , con lo que . Como vemos en la gráfica superior, se sigue usando un cuantizador para clasificar la muestra, aunque no se use ya para corregir los pesos.

Minimización de funciones de costos

Igual que en el caso del perceptrón, la actualización de pesos se realiza calculando . Para el perceptrón tanto como son etiquetas de clases con solo dos posibles valores. En el caso de Adaline se usan valores continuos.

La idea de la función de costos es definir una función que se pueda optimizar durante el proceso de aprendizaje. Para ello utilizamos la suma de los errores al cuadrado (Sum of Squared Errors, o SSE, también llamado en inglés Residual Sum of Squareds):

donde se incluye para simplificar cálculos posteriores. Como buscamos el que minimiza el valor de esa función, multiplicarla por una constante no afecta al resultado. Como siempre, es el resultado esperado para la muestra . es la salida de la función de activación () para la muestra .

Esta función es diferenciable y convexa, lo que nos permite calcular un mínimo. Como nuestra función de costos sólo depende de los pesos , minimizarla nos dará el peso que tenga una menor diferencia entre la salida esperada y la obtenida (). Para calcular el mínimo se usa un algoritmo llamado gradiente descendente.

Nuestro objetivo, como siempre, es ajustar el peso en incrementos :

Para calcular vamos a necesitar conocer el gradiente de nuestra función de costos: . Gradiente es un término matemático que hace referencia al vector que indica la dirección en la que un campo varía con más rapidez. Para una función de variables el gradiente es el vector de dimensiones que marca la dirección en la que la función se incrementa más rápidamente.

Usamos el gradiente en cada punto para calcular los decrementos que nos lleven al mínimo
Usamos el gradiente en cada punto para calcular los decrementos que nos lleven al mínimo

Nosotros vamos a usar el gradiente para buscar el mínimo de , así que en vez de sumarlo lo restaremos (de ahí lo de gradiente descendente). Además usaremos nuestra tasa de aprendizaje para modular esta iteración:

Dado que

tendremos que cada uno de los pesos se actualizará con este incremento:

Para una explicación del cálculo de este gradiente podemos leer este artículo del propio Sebastian Raschka.

Adaline en Python

Como en la entrada anterior, el código está sacado de GitHub.

La implementación de Adaline es muy similar a la del Perceptrón. La única diferencia está en el método fit, donde los pesos se actualizan con el gradiente descendente

  
	class AdalineGD(object):
	    def __init__(self, eta=0.01, n_iter=50):
	        self.eta = eta
	        self.n_iter = n_iter

	    def fit(self, X, y):
	        self.w_ = np.zeros(1 + X.shape[1])
	        self.cost_ = []

	        for i in range(self.n_iter):
	            output = self.activation(X)
	            errors = (y - output)
	            self.w_[1:] += self.eta * X.T.dot(errors)
	            self.w_[0] += self.eta * errors.sum()
	            cost = (errors**2).sum() / 2.0
	            self.cost_.append(cost)
	        return self

	    def net_input(self, X):
	        return np.dot(X, self.w_[1:]) + self.w_[0]

	    def activation(self, X):
	        return self.net_input(X)

	    def predict(self, X):
	        return np.where(self.activation(X) >= 0.0, 1, -1)
  

Recordemos cómo funciona el método fit. Se le pasan dos parámetros: una matriz , que contiene una fila por cada muestra y una columna por cada característica, y que constituye nuestro conjunto de datos de entrenamiento; y un array , que contiene el resultado objetivo para cada muestra.

Lo primero que hace el método fit es inicializar los pesos y los costos:

self.w_ = np.zeros(1 + X.shape[1])
self.cost_ = []

Después iteramos el proceso tantas veces como épocas se hayan definido. La entrada neta (la función net_input) es el producto escalar de los atributos de las muestras y sus pesos. Para la salida utilizamos una función activation que es igual a la entrada neta. La existencia de este método se justifica para hacer el código más general, y poder reutilizarlo con otros algoritmos. En nuestro caso, esta función es la función identidad, por lo que podríamos haber usado directamente net_input. El error será la difencia entre la salida esperada, y, y la real, output.

output = self.activation(X)
errors = (y - output)

Si recordamos las entradas anteriores, la salida esperada y es un array con las clases reales de 100 muestras, las 50 primeras con valor -1 (Iris-setosa), y las 50 siguientes con valor 1 (Iris-versicolor). El valor de output es la salida de la función identidad, pero en este caso vamos a considerar todas las muestras a la vez, con lo que tendremos un array de 100 elementos en vez de un escalar. El método net_input calcula el producto escalar de la matriz de muestras X por el array de pesos w_.

def net_input(self, X):
    return np.dot(X, self.w_[1:]) + self.w_[0]

Cálculo de errores

El resultado es un array de errores (errors) de 100 elementos, uno para cada muestra. Con ellos se actualizan los pesos:

self.w_[1:] += self.eta * X.T.dot(errors)
self.w_[0] += self.eta * errors.sum()

En la primera línea se aplica un incremento self.eta * X.T.dot(errors) al array de pesos , salvo al elemento (peso asociado a una entrada ficticia para simplificar los cálculos). Recordando la fórmula de incremento de pesos vista más arriba:

tenemos que los elementos del array errors se corresponden con . Por lo tanto, el producto escalar entre la traspuesta de la matriz de atributos de cada muestra X y el array de errores errors (X.T.dot(errors)) se corresponden con el sumatorio .

Para el caso del elemento , al ser el valor de los atributos de esa muestra ficticia igual a uno, basta con sumar los errores para obtener el incremento de peso (también podríamos haber añadido una fila con valores unitarios a la matriz X).

Aunque el algoritmo no usa la función de costos directamente, ya que el ajuste de pesos se realiza mediante el gradiente descendente que acabamos de aplicar y que se explica más arriba, vamos a almacenar también el valor de la función para dibujar después su evolución.

Cálculo de costos

Recordemos la función de costos:

El programa almacena un arrary errors = (y - output) que se corresponde con , así que para calcular en valor de la función sólo tenemos que sumar el cuadrado de los valores del array y dividir entre dos. Esto nos dará el valor calculado por la función de costos para los pesos actuales. Como estamos iterando mediante el gradiente descendente vamos a almacenar los resultados de la función obtenidos para cada conjunto de pesos:

cost = (errors**2).sum() / 2.0
self.cost_.append(cost)

Una vez definida la clase, el libro pasa a utilizarla con dos tasas de aprendizaje distintas. Esta tasa va a determinar la convergencia del algoritmo, y encontrar el valor que nos de la mejor convergencia posible requiere muchas pruebas. Vamos a probar con y :

fig, ax = plt.subplots(nrows=1, ncols=2, figsize=(8, 4))

ada1 = AdalineGD(n_iter=10, eta=0.01).fit(X, y)
ax[0].plot(range(1, len(ada1.cost_) + 1), np.log10(ada1.cost_), marker='o')
ax[0].set_xlabel('Epochs')
ax[0].set_ylabel('log(Sum-squared-error)')
ax[0].set_title('Adaline - Learning rate 0.01')

ada2 = AdalineGD(n_iter=10, eta=0.0001).fit(X, y)
ax[1].plot(range(1, len(ada2.cost_) + 1), ada2.cost_, marker='o')
ax[1].set_xlabel('Epochs')
ax[1].set_ylabel('Sum-squared-error')
ax[1].set_title('Adaline - Learning rate 0.0001')

plt.tight_layout()
plt.show()

Pintamos una gráfica con cada tasa, representado la evolución del valor de la función de costos para cada iteración (época). Para la tasa de 0.1 representamos en realidad el logaritmo del valor, ya que el valor real diverge exponencialmente. En cambio, podemos ver como la tasa de 0.0001 converge, pero lentamente.

Una tasa de aprendizaje muy alta provoca que el algoritmo no converja, una tasa muy baja provoca que converja lentamente
Una tasa de aprendizaje muy alta provoca que el algoritmo no converja, una tasa muy baja provoca que converja lentamente

Llevado al ejemplo visual del gradiente descendente, vemos que la tasa de aprendizaje influye en la longitud del “paso” que se da hacia el mínimo. Si este paso es muy grande el algoritmo “se sale”:

Visualización gráfica de los pasos dados con el algoritmo
Visualización gráfica de los pasos dados con el algoritmo

El libro acaba esta parte con una referencia al escalado de características que se verá en el tema 3, y que nosotros dejaremos hasta entonces.

En la próxima entrada veremos cómo modificar este algoritmo para adaptarlo a grandes grupos de datos.