Читать книгу Python Machine Learning - Vahid Mirjalili - Страница 9
ОглавлениеEntrenar algoritmos simples de aprendizaje automático para clasificación
En este capítulo, utilizaremos dos de los primeros algoritmos de aprendizaje automático descritos algorítmicamente para clasificación: el perceptrón y las neuronas lineales adaptativas. Empezaremos implementando un perceptrón paso a paso en Python y entrenándolo para que clasifique diferentes especies de flores en el conjunto de datos Iris. Esto nos ayudará a entender el concepto de algoritmos de aprendizaje automático para clasificación y cómo pueden ser implementados de forma eficiente en Python.
Tratar los conceptos básicos de la optimización utilizando neuronas lineales adaptativas sentará las bases para el uso de clasificadores más potentes con la librería de aprendizaje automático scikit-learn, como veremos en el Capítulo 3, Un recorrido por los clasificadores de aprendizaje automático con scikit-learn.
Los temas que trataremos en este capítulo son los siguientes:
•Crear una intuición para algoritmos de aprendizaje automático.
•Utilizar pandas, NumPy y Matplotlib para leer, procesar y visualizar datos.
•Implementar algoritmos de clasificación lineal en Python.
Neuronas artificiales: un vistazo a los inicios del aprendizaje automático
Antes de hablar con más detalle del perceptrón y de los algoritmos relacionados, echemos un vistazo a los comienzos del aprendizaje automático. Para tratar de entender cómo funciona el cerebro biológico, para diseñar la Inteligencia Artificial, Warren McCullock y Walter Pitts publicaron, en 1943, el primer concepto de una célula cerebral simplificada, la denominada neurona McCullock-Pitts (MCP), recogido en su libro A Logical Calculus of the Ideas Immanent in Nervous Activity [Un cálculo lógico de las ideas inmanentes en la actividad nerviosa], W. S. McCulloch y W. Pitts, Bulletin of Mathematical Biophysics [Boletín de Biofísica Matemática], 5(4): 115-133, 1943. Las neuronas son células nerviosas interconectadas en el cerebro que participan en el proceso y la transmisión de señales eléctricas y químicas, como se ilustra en la siguiente figura:
McCullock y Pitts describieron una célula nerviosa como una simple puerta lógicacon salidas binarias; múltiples señales llegan a las dendritas, a continuación se integran en el cuerpo de la célula y, si la señal acumulada supera un umbral determinado, se genera una señal de salida que será transmitida por el axón.
Solo unos años después, Frank Rosenblatt publicó el primer concepto de la regla de aprendizaje del perceptrón, basado en el modelo de la neurona MCP, en The Perceptron: A Perceiving and Recognizing Automaton [El perceptrón: un autómata de percepción y reconocimiento], F. Rosenblatt, Cornell Aeronautical Laboratory, 1957). Con esta regla del perceptrón, Rosenblatt propuso un algoritmo que podía automáticamente aprender los coeficientes de peso óptimo que luego se multiplican con las características de entrada para tomar la decisión de si una neurona se activa o no. En el contexto del aprendizaje supervisado y la clasificación, un algoritmo como este podría utilizarse para predecir si una muestra pertenece a una clase o a otra.
La definición formal de una neurona artificial
De un modo más formal, para simplificar, podemos situar la idea de las neuronas artificiales en el contexto de una tarea de clasificación binaria donde hacemos referencia a nuestras dos clases como 1 (clase positiva) y -1 (clase negativa). También podemos definir una función de decisión () que toma una combinación lineal de determinados valores de entrada x y un vector de peso correspondiente w, donde z es la denominada entrada de red :
Ahora, si la entrada de red de una muestra concreta es mayor que un umbral definido , predecimos de otro modo la clase 1 y la clase -1. En el algoritmo de perceptrón, la función de decisión es una variante de una función escalón unitario:
Para simplificar, podemos traer el umbral al lado izquierdo de la ecuación y definir un peso cero como y , por lo que escribimos z de un modo más compacto:
Y:
En la literatura del aprendizaje automático, el umbral negativo, o peso , se denomina habitualmente «sesgo» (bias, en inglés).
En las siguientes secciones, usaremos con frecuencia notaciones básicas de álgebra lineal. Por ejemplo, abreviaremos la suma de los productos de los valores en x y w con un producto escalar vectorial, donde el superíndice T se refiere a trasposición, que es una operación que transforma una vector columna en un vector fila y viceversa.Por ejemplo:Además, la operación de trasposición también se puede aplicar a matrices para reflejarlas sobre su diagonal, por ejemplo:En este libro, utilizaremos solo conceptos muy básicos de álgebra lineal; sin embargo, si necesitas un repaso rápido, puedes echar un vistazo a la excelente obra de Zico Kolter Linear Algebra Review and Reference [Revisión y referencia de álgebra lineal], disponible de forma gratuita en http://www.cs.cmu.edu/~zkolter/course/linalg/linalg_notes.pdf. |
La siguiente figura ilustra cómo la función de decisión del perceptrón (subfigura izquierda) comprime la entrada de red en una salida binaria (-1 o 1) y cómo se puede utilizar para discriminar entre dos clases separables linealmente (subfigura derecha):
La regla de aprendizaje del perceptrón
La idea general que hay detrás de la neurona MCP y del modelo de perceptrón umbralizado de Rosenblatt es utilizar un enfoque reduccionista para imitar cómo trabaja una simple neurona en el cerebro: si se excita o si no. Así, la regla del perceptrón inicial de Rosenblatt es bastante sencilla y se puede resumir en los siguientes pasos:
1.Iniciar los pesos a 0 o a números aleatorios más pequeños.
2.Para cada muestra de entrenamiento :
a.Calcular el valor de salida .
b.Actualizar los pesos.
Aquí, el valor de salida es la etiqueta de clase predicha por la función escalón unitario que hemos definido anteriormente, y la actualización simultánea de cada peso en el vector peso w se puede escribir formalmente como:
El valor de , que se utiliza para actualizar el peso , se calcula mediante la regla de aprendizaje del perceptrón:
donde es el rango de aprendizaje (normalmente una constante entre 0.0 y 1.0), es la etiqueta de clase verdadera de la muestra de entrenamiento i, y es la etiqueta de clase predicha. Es importante observar que todos los pesos en el vector peso han sido actualizados simultáneamente, lo que significa que no podemos volver a calcular el antes de que todos los pesos estén actualizados. Concretamente, para un conjunto de datos de dos dimensiones, podríamos escribir la actualización como:
Antes de implementar la regla del perceptrón en Python, haremos un sencillo experimento mental para mostrar la preciosa simplicidad de esta regla de aprendizaje. En los dos casos donde el perceptrón predice correctamente la etiqueta de clase, los pesos no cambian:
Sin embargo, en caso de una predicción errónea, los pesos se verán empujados hacia la dirección de la clase de destino negativa o positiva:
Para obtener una mejor intuición del factor multiplicativo , veamos otro ejemplo sencillo, donde:
Asumimos que y clasificamos erróneamente esta muestra como -1. En este caso, podríamos aumentar el correspondiente peso en 1, de manera que la entrada de red fuera más positiva la próxima vez que se encontrar con esta muestra y, por tanto, fuera más probable que estuviera por encima del umbral de la función escalón unitario para clasificar la muestra como +1:
La actualización del peso es proporcional al valor de . Por ejemplo, si tenemos otra muestra clasificada de forma incorrecta como -1, empujaríamos el límite de decisión por una medida aún mayor para clasificar la próxima vez correctamente esta muestra:
Es importante observar que la convergencia del perceptrón solo está garantizada si las dos clases son linealmente separables y si el rango de aprendizaje es suficientemente pequeño. Si las dos clases no pueden ser separadas por un límite de decisión lineal, podemos ajustar un número máximo de pasos sobre el conjunto de datos de entrenamiento (épocas) y/o un umbral para el número de clasificaciones erróneas toleradas. De otro modo, el perceptrón nunca dejaría de actualizar los pesos:
Descarga del código de ejemploRecuerda que en la parte inferior de la primera página del libro encontrarás el código de acceso que te permitirá descargar de forma gratuita los contenidos adicionales del libro. |
Ahora, antes de saltar a la implementación en la sección siguiente, vamos a resumir cuanto acabamos de aprender en un simple diagrama que ilustra el concepto general del perceptrón:
Este diagrama muestra cómo el perceptrón recibe las entradas de una muestra x y las combina con los pesos w para calcular la entrada de red. A continuación, la entrada de red pasa por la función de umbral, que genera una salida binaria -1 o +1; la etiqueta de clase predicha de la muestra. Durante la fase de aprendizaje, esta salida se utiliza para calcular el error de la predicción y actualizar los pesos.
Implementar un algoritmo de aprendizaje de perceptrón en Python
En la sección anterior, hemos aprendido cómo funciona la regla del perceptrón de Rosenblatt. Sigamos adelante. Vamos a implementarla en Python y a aplicarla al conjunto de datos Iris que presentamos en el Capítulo 1, Dar a los ordenadores el poder de aprender de los datos.
Una API perceptrón orientada a objetos
Vamos a tomar un enfoque orientado a objetos para definir la interfaz del perceptrón como una clase de Python, lo cual nos permite iniciar nuevos objetos Perceptron que pueden aprender de los datos mediante un método fit y hacer predicciones mediante un método predict separado. Como norma, agregamos un guion bajo (_) a aquellos atributos que no han sido creados durante la inicialización del objeto sino mediante la llamada de otros métodos del objeto, por ejemplo, self.w_.
Si aún no estás familiarizado con las librerías científicas de Python o necesitas un repaso, puedes consultar estos recursos (en inglés):•NumPy: https://sebastianraschka.com/pdf/books/dlb/appendix_f_numpy-intro.pdf•pandas: https://pandas.pydata.org/pandas-docs/stable/10min.html•Matplotlib: http://matplotlib.org/users/beginner.html |
Esta es la implementación de un perceptrón:
import numpy as np
class Perceptron(object):
"""Perceptron classifier.
Parameters
------------
eta : float
Learning rate (between 0.0 and 1.0)
n_iter : int
Passes over the training dataset.
random_state : int
Random number generator seed for random weight
initialization.
Attributes
-----------
w_ : 1d-array
Weights after fitting.
errors_ : list
Number of misclassifications (updates) in each epoch.
"""
def __init__(self, eta=0.01, n_iter=50, random_state=1):
self.eta = eta
self.n_iter = n_iter
self.random_state = random_state
def fit(self, X, y):
"""Fit training data.
Parameters
----------
X : {array-like}, shape = [n_samples, n_features]
Training vectors, where n_samples is the number of
samples and
n_features is the number of features.
y : array-like, shape = [n_samples]
Target values.
Returns
-------
self : object
"""
rgen = np.random.RandomState(self.random_state)
self.w_ = rgen.normal(loc=0.0, scale=0.01,
size=1 + X.shape[1])
self.errors_ = []
for _ in range(self.n_iter):
errors = 0
for xi, target in zip(X, y):
update = self.eta * (target - self.predict(xi))
self.w_[1:] += update * xi
self.w_[0] += update
errors += int(update != 0.0)
self.errors_.append(errors)
return self
def net_input(self, X):
"""Calculate net input"""
return np.dot(X, self.w_[1:]) + self.w_[0]
def predict(self, X):
"""Return class label after unit step"""
return np.where(self.net_input(X) >= 0.0, 1, -1)
Con esta implementación del perceptrón, ya podemos inicializar nuevos objetos Perceptron con un rango de aprendizaje proporcionado eta y n_iter, que es el número de épocas (pasos por el conjunto de entrenamiento). Mediante el método fit, inicializamos los pesos en self.w_ para un vector , donde m significa el número de dimensiones (características) en el conjunto de datos, al cual añadimos 1 para el primer elemento en este vector que representa el parámetro del sesgo. Recuerda que el primer elemento en este vector, self.w_[0], representa el denominado parámetro del sesgo del cual hemos hablado anteriormente.
Observa también que este vector contiene pequeños números aleatorios extraídos de una distribución normal con desviación estándar 0.01 con rgen.normal(loc=0.0, scale=0.01, size=1 + X.shape[1]), donde rgen es un generador de números aleatorios NumPy que hemos sembrado con una semilla aleatoria especificada por el usuario, por lo que podemos reproducir, si lo deseamos, resultados previos.
La razón por la cual no ponemos los pesos a cero es que el rango de aprendizaje (eta) solo tiene efecto sobre el resultado de la clasificación si los pesos empiezan por valores distintos a cero. Si todos los pesos empiezan en cero, el parámetro eta del rango de aprendizaje afecta solo a la escala del vector peso, no a la dirección. Si estás familiarizado con la trigonometría, considera un vector , donde el ángulo entre y un vector sería exactamente cero, como queda demostrado en el siguiente fragmento de código:
>>> v1 = np.array([1, 2, 3])
>>> v2 = 0.5 * v1
>>> np.arccos(v1.dot(v2) / (np.linalg.norm(v1) *
... np.linalg.norm(v2)))
0.0
En este caso, np.arccos es el coseno inverso trigonométrico y np.linalg.norm es una función que calcula la longitud de un vector. La razón por la cual hemos extraído los números aleatorios de una distribución normal aleatoria –en lugar de una distribución uniforme, por ejemplo– y por la que hemos utilizado una desviación estándar de 0.01 es arbitraria; recuerda que solo nos interesan valores pequeños aleatorios para evitar las propiedades de vectores todo cero, como hemos dicho anteriormente.
La indexación de NumPy para matrices unidimensionales funciona de forma similar a las listas de Python, utilizando la notación de corchetes ([]). Para matrices bidimensionales, el primer indexador se refiere al número de fila y el segundo al número de columna. Por ejemplo, utilizaríamos X[2, 3] para seleccionar la tercera fila y la cuarta columna de una matriz X bidimensional X. |
Tras haber puesto a cero los pesos, el método fit recorre todas las muestras individuales del conjunto de entrenamiento y actualiza los pesos según la regla de aprendizaje del perceptrón tratada en la sección anterior. Las etiquetas de clase son predichas por el método predict, que es llamado en el método fit para predecir la etiqueta de clase para la actualización del peso, aunque también puede ser utilizado para predecir las etiquetas de clase de nuevos datos una vez ajustado nuestro modelo. Además, también recopilamos el número de errores de clasificación durante cada época en la lista self.errors_, de manera que posteriormente podemos analizar si nuestro perceptrón ha funcionado bien durante el entrenamiento. La función np.dot que se utiliza en el método net_input simplemente calcula el producto escalar de un vector .
En lugar de utilizar NumPy para calcular el producto escalar entre dos matrices a y b mediante a.dot(b) o np.dot(a, b), también podemos realizar el cálculo con Python puro mediante sum([j * j for i, j in zip(a, b)]). Sin embargo, la ventaja de utilizar NumPy frente a las estructuras clásicas de Python for loop es que sus operaciones aritméticas son vectorizadas. La vectorización significa que una operación aritmética elemental se aplica automáticamente a todos los elementos de una matriz. Formulando nuestras operaciones aritméticas como una secuencia de instrucciones sobre una matriz, en lugar de llevar a cabo un conjunto de operaciones para cada elemento cada vez, se utilizan mejor las arquitecturas de CPU modernas con soporte SIMD (Single Instruction, Multiple Data o, en español, Una Instrucción, Múltiples Datos). Además, NumPy utiliza librerías de álgebra lineal altamente optimizadas como la Basic Linear Algebra Subprograms (BLAS) y la Linear Algebra Package (LAPACK), escritas en C o Fortran. Por último, NumPy también nos permite escribir nuestro código de un modo más compacto e intuitivo utilizando los conceptos básicos del álgebra lineal, como productos escalares de matrices y vectores. |
Entrenar un modelo de perceptrón en el conjunto de datos Iris
Para probar nuestra implementación del perceptrón, vamos a cargar dos clases de flor, Setosa y Versicolor, del conjunto de datos Iris. Aunque la regla del perceptrón no está restringida a dos dimensiones, por razones de visualización solo tendremos en cuenta las características de longitud de sépalo y longitud de pétalo. Además, por razones prácticas, elegimos solo las dos clases de flor: Setosa y Versicolor. Sin embargo, el algoritmo perceptrón se puede ampliar a una clasificación multidimensional –por ejemplo, la técnica One-versus-All (OvA)–.
OvA, a veces también llamada One-versus-Rest (OvR), es una técnica que nos permite ampliar un clasificador binario a problemas multiclase. Mediante OvA, podemos entrenar un clasificador por clase, donde cada clase individual se trata como una clase positiva y las muestras procedentes de otras clases se consideran clases negativas. Si tuviéramos que clasificar una nueva muestra de datos, utilizaríamos nuestros clasificadores n, donde n es el número de etiquetas de clase, y asignaríamos la etiqueta de clase con la fiabilidad más alta a cada muestra individual. En el caso del perceptrón, utilizaríamos OvA para elegir la etiqueta de clase asociada al mayor valor absoluto de entrada de red. |
Primero, utilizaremos la librería pandas para cargar el conjunto de datos Iris directamente del UCI Machine Learning Repository dentro de un objeto DataFrame e imprimir las últimas cinco líneas mediante el método tail para comprobar que los datos se han cargado correctamente:
>>> import pandas as pd
>>> df = pd.read_csv('https://archive.ics.uci.edu/ml/'
... 'machine-learning-databases/iris/iris.data',
... header=None)
>>> df.tail()
Puedes encontrar una copia del conjunto de datos Iris (y de todos los otros conjuntos de datos utilizados en este libro) en el paquete de código de este libro, que puedes utilizar si estás trabajando offline o si el servidor UCI https://archive.ics.uci.edu/ml/machine-learning-databases/iris/iris.data está temporalmente no disponible. Por ejemplo, para cargar el conjunto de datos Iris desde el directorio local, puedes sustituir esta línea:df = pd.read_csv('https://archive.ics.uci.edu/ml/' 'machine-learning-databases/iris/iris.data', header=None)por esta otra:df = pd.read_csv('your/local/path/to/iris.data', header=None) |
A continuación, extraemos las 100 primeras etiquetas de clase que corresponden a las 50 flores Iris-setosa y a las 50 Iris-versicolor, y convertimos las etiquetas de clase en las dos etiquetas de clase enteras 1 (versicolor) y -1 (setosa) que asignamos a un vector y, donde el método de valores de un DataFrame pandas produce la correspondiente representación NumPy.
De forma similar, extraemos la primera columna de características (longitud del sépalo) y la tercera columna de características (longitud del pétalo) de las 100 muestras de entrenamiento y las asignamos a una matriz X de características, que podemos ver a través de un diagrama de dispersión bidimensional:
>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>> # seleccionar setosa y versicolor
>>> y = df.iloc[0:100, 4].values
>>> y = np.where(y == 'Iris-setosa', -1, 1)
>>> # extraer longitud de sépalo y longitud de pétalo
>>> X = df.iloc[0:100, [0, 2]].values
>>> # representar los datos
>>> plt.scatter(X[:50, 0], X[:50, 1],
... color='red', marker='o', label='setosa')
>>> plt.scatter(X[50:100, 0], X[50:100, 1],
... color='blue', marker='x', label='versicolor')
>>> plt.xlabel('sepal length [cm]')
>>> plt.ylabel('petal length [cm]')
>>> plt.legend(loc='upper left')
>>> plt.show()
Después de ejecutar el ejemplo de código precedente, deberíamos ver el siguiente diagrama de dispersión:
El diagrama de dispersión anterior muestra la distribución de las muestras de flor en el conjunto de datos Iris sobre los dos ejes de características, longitud del pétalo y longitud del sépalo. En este subespacio de características bidimensional, podemos ver que un límite de decisión lineal puede ser suficiente para separar flores Setosa de flores Versicolor. Por tanto, un clasificador lineal como el perceptrón podría ser capaz de clasificar las flores en este conjunto de datos perfectamente.
Ahora, ha llegado el momento de entrenar nuestro algoritmo de perceptrón en el subconjunto de datos Iris que acabamos de extraer. Además, reflejaremos en un gráfico el error de clasificación incorrecta para cada época para comprobar si el algoritmo ha convergido y encontrado un límite de decisión que separa las dos clases de flor Iris:
>>> ppn = Perceptron(eta=0.1, n_iter=10)
>>> ppn.fit(X, y)
>>> plt.plot(range(1, len(ppn.errors_) + 1),
... ppn.errors_, marker='o')
>>> plt.xlabel('Epochs')
>>> plt.ylabel('Number of updates')
>>> plt.show()
Después de ejecutar el código anterior, deberíamos ver el diagrama de los errores de clasificación incorrecta frente al número de épocas, como se muestra a continuación:
Como podemos ver en el diagrama anterior, nuestro perceptrón ha convergido después de seis épocas y debería ser capaz de clasificar perfectamente las muestras de entrenamiento. Vamos a implementar una pequeña función de conveniencia para visualizar los límites de decisión para dos conjuntos de datos bidimensionales:
from matplotlib.colors import ListedColormap
def plot_decision_regions(X, y, classifier, resolution=0.02):
# definir un generador de marcadores y un mapa de colores
markers = ('s', 'x', 'o', '^', 'v')
colors = ('red', 'blue', 'lightgreen', 'gray', 'cyan')
cmap = ListedColormap(colors[:len(np.unique(y))])
# representar la superficie de decisión
x1_min, x1_max = X[:, 0].min() - 1, X[:, 0].max() + 1
x2_min, x2_max = X[:, 1].min() - 1, X[:, 1].max() + 1
xx1, xx2 = np.meshgrid(np.arange(x1_min, x1_max, resolution),
np.arange(x2_min, x2_max, resolution))
Z = classifier.predict(np.array([xx1.ravel(), xx2.ravel()]).T)
Z = Z.reshape(xx1.shape)
plt.contourf(xx1, xx2, Z, alpha=0.3, cmap=cmap)
plt.xlim(xx1.min(), xx1.max())
plt.ylim(xx2.min(), xx2.max())
# representar muestras de clase
for idx, cl in enumerate(np.unique(y)):
plt.scatter(x=X[y == cl, 0],
y=X[y == cl, 1],
alpha=0.8,
c=colors[idx],
marker=markers[idx],
label=cl,
edgecolor='black')
En primer lugar, definimos un número de colors y markers y creamos un mapa de colores a partir de la lista de colores a través de ListedColormap. A continuación, determinamos los valores mínimos y máximos para las dos características y utilizamos los vectores de características para crear un par de matrices de cuadrícula xx1 y xx2 mediante la función meshgrid de NumPy. Como hemos entrenado nuestro clasificador de perceptrón en dos dimensiones de características, necesitamos acoplar las matrices y crear una matriz que tenga el mismo número de columnas que el subconjunto de entrenamiento Iris. Para ello, podemos utilizar el método predict para predecir las etiquetas de clase Z de los correspondientes puntos de la cuadrícula. Después de remodelar las etiquetas de clase Z predichas en una cuadrícula con las mismas dimensiones que xx1 y xx2, ya podemos dibujar un diagrama de contorno con la función contourf de Matplotlib, que mapea las diferentes regiones de decisión en distintos colores para cada clase predicha en la matriz de cuadrícula:
>>> plot_decision_regions(X, y, classifier=ppn)
>>> plt.xlabel('sepal length [cm]')
>>> plt.ylabel('petal length [cm]')
>>> plt.legend(loc='upper left')
>>> plt.show()
Después de ejecutar el ejemplo de código anterior, deberíamos ver un diagrama de las regiones de decisión, como se muestra en la siguiente figura:
Como podemos ver en el diagrama, el perceptrón ha aprendido un límite de decisión capaz de clasificar perfectamente todas las muestras de flor en el subconjunto de entrenamiento Iris.
Aunque el perceptrón ha clasificado a la perfección las dos clases de flor Iris, la convergencia es uno de los mayores problemas del perceptrón. Frank Rosenblatt probó matemáticamente que la regla de aprendizaje del perceptrón converge si las dos clases pueden ser separadas por un hiperplano lineal. Sin embargo, si las clases no pueden ser separadas perfectamente por un límite de decisión lineal, los pesos no dejarán nunca de actualizarse a menos que indiquemos un número máximo de épocas. |
Neuronas lineales adaptativas y la convergencia del aprendizaje
En esta sección, echaremos un vistazo a otro tipo de red neuronal de capa única: las neuronas lineales adaptativas, en inglés ADAptive LInear NEuron (Adaline). Adaline fue publicada por Bernard Widrow y su alumno Ted Hoff, pocos años después del algoritmo de perceptrón de Frank Rosenblatt, y puede considerarse como una mejora de este último. (Puedes consultar An Adaptive "Adaline" Neuron Using Chemical "Memistors", Technical Report Number 1553-2, B. Widrow and others, Stanford Electron Labs, Stanford, CA, October 1960).
El algoritmo Adaline es especialmente interesante porque ilustra los conceptos clave para definir y minimizar las funciones de coste continuas. Esto sienta las bases para la comprensión de algoritmos de aprendizaje automático más avanzados para la clasificación, como la regresión logística, máquinas de vectores de soporte y modelos de regresión, que trataremos en capítulos posteriores.
La diferencia clave entre la regla Adaline (también conocida como regla Widrow-Hoff) y el perceptrón de Rosenblatt es que los pesos se actualizan en base a una función de activación lineal en vez de en base a una función escalón unitario como sucede en el perceptrón. En Adaline, esta función de activación lineal es simplemente la función de identificación de la entrada de red, por lo que:
Mientras que la función de activación lineal se utiliza para aprender los pesos, seguimos utilizando una función de umbral que realiza la predicción final, que es parecida a la función escalón unitario que hemos visto anteriormente. Las diferencias principales entre el perceptrón y el algoritmo Adaline se encuentran destacadas en la siguiente imagen:
Esta ilustración muestra que el algoritmo Adaline compara las etiquetas de clase verdaderas con la salida de valores continuos de la función de activación lineal para calcular el error del modelo y actualizar los pesos. Por el contrario, el perceptrón compara las etiquetas de clase verdaderas con las etiquetas de clase predichas.
Minimizar funciones de coste con el descenso de gradiente
Uno de los ingredientes clave de los algoritmos de aprendizaje automático supervisado es una función objetivo definida que debe ser optimizada durante el proceso de aprendizaje. Esta función objetivo suele ser una función de coste que queremos minimizar. En el caso de Adaline, podemos definir la función de coste para aprender los pesos como la Suma de Errores Cuadráticos (SSE, del inglés Sum of Squared Errors) entre la salida calculada y la etiqueta de clase verdadera:
Hemos añadido el término simplemente para facilidad nuestra, porque nos permitirá derivar el gradiente de un modo más fácil, como veremos en los siguientes párrafos. La principal ventaja de esta función de activación lineal continua, en comparación con la función escalón unitario, es que la función de coste pasa a ser diferenciable. Otra propiedad a tener en cuenta de esta función de coste es que es convexa; esto significa que podemos utilizar un simple pero potente algoritmo de optimización, denominado descenso de gradiente, para encontrar los pesos que minimicen nuestra función de coste y clasificar así las muestras del conjunto de datos Iris.
Como se muestra en la imagen siguiente, podemos describir la idea principal que hay detrás del descenso de gradiente como bajar una colina hasta obtener un mínimo de coste global o local. En cada iteración, realizamos un paso en la dirección opuesta del gradiente donde el tamaño del paso está determinado por el valor del rango de aprendizaje, así como por la pendiente del gradiente:
Con el descenso de gradiente, podemos actualizar los pesos haciendo un paso en la dirección opuesta del gradiente de nuestra función de coste :
donde el cambio de peso se define como el gradiente negativo multiplicado por el rango de aprendizaje :
Para calcular el gradiente de la función de coste, necesitamos calcular la derivación parcial de la función de coste con respecto a cada peso :
Por lo que podemos escribir la actualización del peso como:
Como actualizamos todos los pesos a la vez, nuestra regla de aprendizaje Adaline es:
Para los que están familiarizados con el cálculo, la derivación parcial de la función de coste SSE con respecto al peso j se puede obtener así: |
Aunque la regla de aprendizaje Adaline puede parecer idéntica a la regla del perceptrón, podemos observar que el con [no image in epub file] es un número real y una etiqueta de clase completa. Además, la actualización del peso se calcula en base a todas las muestras del conjunto de entrenamiento (en lugar de actualizar los pesos de forma incremental después de cada muestra), razón por la cual este enfoque también se conoce como descenso de gradiente en lotes.
Implementar Adaline en Python
Como la regla del perceptrón y Adaline son muy parecidos, tomaremos la implementación del perceptrón que definimos anteriormente y cambiaremos el método fit de manera que los pesos se actualicen minimizando la función de coste mediante el descenso de gradiente:
class AdalineGD(object):
"""ADAptive LInear NEuron classifier.
Parameters
------------
eta : float
Learning rate (between 0.0 and 1.0)
n_iter : int
Passes over the training dataset.
random_state : int
Random number generator seed for random weight
initialization.
Attributes
-----------
w_ : 1d-array
Weights after fitting.
cost_ : list
Sum-of-squares cost function value in each epoch.
"""
def __init__(self, eta=0.01, n_iter=50, random_state=1):
self.eta = eta
self.n_iter = n_iter
self.random_state = random_state
def fit(self, X, y):
""" Fit training data.
Parameters
----------
X : {array-like}, shape = [n_samples, n_features]
Training vectors, where n_samples is the number of
samples and
n_features is the number of features.
y : array-like, shape = [n_samples]
Target values.
Returns
-------
self : object
"""
rgen = np.random.RandomState(self.random_state)
self.w_ = rgen.normal(loc=0.0, scale=0.01,
size=1 + X.shape[1])
self.cost_ = []
for i in range(self.n_iter):
net_input = self.net_input(X)
output = self.activation(net_input)
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):
"""Calculate net input"""
return np.dot(X, self.w_[1:]) + self.w_[0]
def activation(self, X):
"""Compute linear activation"""
return X
def predict(self, X):
"""Return class label after unit step"""
return np.where(self.activation(self.net_input(X))
>= 0.0, 1, -1)
En lugar de actualizar los pesos después de evaluar cada muestra de entrenamiento individual, como en el perceptrón, calculamos el gradiente en base a todo el conjunto de datos de entrenamiento mediante self.eta * errors.sum() para el parámetro del sesgo (peso cero) y mediante self.eta * X.T.dot(errors) para los pesos 1 a m, donde X.T.dot(errors) es una multiplicación matriz por vector entre nuestra matriz de características y el vector de error.
Observa que el método activation no tiene ningún efecto sobre el código, puesto que es simplemente una función de identidad. En este caso, hemos añadido la función de activación (calculada mediante el método activation) para mostrar cómo fluye la información a través de una red neuronal de una sola capa: características a partir de los datos de entrada, entrada de red, activación y salida. En el siguiente capítulo, conoceremos un clasificador de regresión logística que utiliza una función de activación no lineal sin identidad. Veremos que un modelo de regresión logística está estrechamente relacionado con Adaline, siendo la única diferencia su activación y función de coste.
Ahora, de forma parecida a la implementación del perceptrón anterior, recogemos los valores de coste en una lista self.cost_ para comprobar si el algoritmo converge después del entrenamiento.
Llevar a cabo una multiplicación matriz por vector es similar a calcular un producto escalar, donde cada fila de la matriz es tratada como un único vector de fila. Este enfoque vectorizado representa una notación más compacta y da como resultado un cálculo más eficiente con NumPy. Por ejemplo: |
A la práctica, esto suele requerir algo de experimentación para encontrar un buen rango de aprendizaje para una óptima convergencia. Así que vamos a elegir dos rangos de aprendizaje distintos, y , para empezar y mostrar en un diagrama las funciones de coste frente al número de épocas, y ver cómo aprende la implementación de Adaline de los datos de entrenamiento.
El rango de aprendizaje (eta), así como el número de épocas (n_iter), también se conocen como hiperparámetros del perceptrón y algoritmos de aprendizaje Adaline. En el Capítulo 6, Aprender las mejores prácticas para la evaluación de modelos y el ajuste de hiperparámetros, veremos diferentes técnicas para encontrar automáticamente los valores de distintos hiperparámetros que producen un rendimiento óptimo del modelo de clasificación. |
Ahora veamos en un diagrama el coste contra el número de épocas para los dos rangos de aprendizaje distintos:
>>> fig, ax = plt.subplots(nrows=1, ncols=2, figsize=(10, 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.show()
Como podemos ver en los diagramas de función coste obtenidos, nos encontraríamos con dos tipos de problemas. El gráfico de la izquierda muestra qué pasaría si eligiéramos un rango de aprendizaje demasiado amplio. En lugar de minimizar la función de coste, el error es mayor en cada época, porque sobrepasamos el mínimo global. Por otro lado, podemos ver que el coste disminuye en el diagrama de la derecha, pero el rango de aprendizaje elegido es tan pequeño que el algoritmo requeriría un número de épocas muy elevado para converger con el mínimo coste global:
La siguiente imagen ilustra qué pasaría si cambiáramos el valor de un parámetro de peso concreto para minimizar la función de coste . La imagen de la izquierda muestra el caso de una buena elección del rango de aprendizaje, donde el coste disminuye gradualmente, moviéndose en la dirección del mínimo global. Sin embargo, la imagen de la derecha muestra qué pasa si elegimos un rango de aprendizaje demasiado amplio (que sobrepasamos el mínimo global):
Mejorar el descenso de gradiente mediante el escalado de características
Muchos de los algoritmos de aprendizaje automático con los que nos encontraremos en este libro requieren algún tipo de escalado de características para un rendimiento óptimo, como veremos con mayor detalle en el Capítulo 3, Un recorrido por los clasificadores de aprendizaje automático con scikit-learn y en el Capítulo 4, Generar buenos modelos de entrenamiento - Preprocesamiento de datos.
El descenso de gradiente es uno de los muchos algoritmos que se benefician del escalado de características. En esta sección, utilizaremos un método de escalado de características denominado normalización, que proporciona a nuestros datos la propiedad de una distribución normal estándar, la cual ayuda al descenso de gradiente a converger más rápidamente. La normalización cambia la media de cada característica para que se centre en cero y para que cada característica tenga una desviación estándar de 1. Por ejemplo, para normalizar la característica j, podemos simplemente sustraer la media de muestra de cada muestra de entrenamiento y dividirla por su desviación estándar :
En este caso, es un vector que consta de los valores de la característica j de todas las muestras de entrenamiento n, y esta técnica de normalización se aplica a cada característica j de nuestro conjunto de datos.
Una de las razones por las que la normalización ayuda al aprendizaje del descenso de gradiente es que el optimizador tiene que realizar menos pasos para encontrar una buena u óptima solución (el mínimo coste global), como se muestra en la siguiente figura, donde las dos imágenes representan la superficie de coste como una función de dos pesos modelo en un problema de clasificación bidimensional:
La normalización se puede conseguir fácilmente mediante el método integrado de NumPy mean y std:
>>> X_std = np.copy(X)
>>> X_std[:,0] = (X[:,0] - X[:,0].mean()) / X[:,0].std()
>>> X_std[:,1] = (X[:,1] - X[:,1].mean()) / X[:,1].std()
Tras la normalización, volveremos a entrenar Adaline y veremos que ahora converge después de un pequeño número de épocas con un rango de aprendizaje :
>>> ada = AdalineGD(n_iter=15, eta=0.01)
>>> ada.fit(X_std, y)
>>> plot_decision_regions(X_std, y, classifier=ada)
>>> plt.title('Adaline - Gradient Descent')
>>> plt.xlabel('sepal length [standardized]')
>>> plt.ylabel('petal length [standardized]')
>>> plt.legend(loc='upper left')
>>> plt.tight_layout()
>>> plt.show()
>>> plt.plot(range(1, len(ada.cost_) + 1), ada.cost_, marker='o')
>>> plt.xlabel('Epochs')
>>> plt.ylabel('Sum-squared-error')
>>> plt.show()
Una vez ejecutado el código, deberíamos ver una imagen de las regiones de decisión, así como un diagrama del coste decreciente, como se muestra en la siguiente figura:
Como podemos ver en los diagramas, Adaline ahora ha convergido después de entrenar las características normalizadas mediante un rango de aprendizaje . Sin embargo, se observa que el SSE no es cero, incluso si todas las muestras están correctamente clasificadas.
Aprendizaje automático a gran escala y descenso de gradiente estocástico
En la sección anterior, hemos aprendido a minimizar la función de coste dando un paso en la dirección opuesta a un gradiente de coste calculado a partir de un conjunto de entrenamiento completo. Esta es la razón por la que a veces este enfoque también se conoce como descenso de gradiente en lotes. Ahora imaginemos que tenemos un conjunto de datos muy amplio con millones de puntos de datos, cosa bastante frecuente en aplicaciones de aprendizaje automático. En casos como este, ejecutar un descenso de gradiente en lotes puede ser computacionalmente muy costoso, puesto que necesitamos reevaluar todo el conjunto de datos de entrenamiento cada vez que realizamos un paso hacia el mínimo global.
Una conocida alternativa al algoritmo del descenso de gradiente en lotes es el descenso de gradiente estocástico, llamado también a veces «descenso de gradiente online o iterativo». En lugar de actualizar los pesos en base a la suma de los errores acumulados en todas las muestras :
Actualizamos los pesos de forma incremental para cada muestra de entrenamiento:
Aunque el descenso de gradiente estocástico se puede considerar como una aproximación al descenso de gradiente, normalmente consigue la convergencia mucho más rápido debido a unas actualizaciones del peso más frecuentes. Como cada gradiente se calcula en base a un único ejemplo de entrenamiento, la superficie de error es más ruidosa que en el descenso de gradiente. El descenso de gradiente estocástico también puede tener la ventaja de que puede escapar de los mínimos locales poco profundos más fácilmente si trabajamos con funciones de coste no lineales, como veremos más adelante en el Capítulo 12, Implementar una red neuronal artificial multicapa desde cero. Para que los resultados sean satisfactorios con el descenso de gradiente estocástico, es importante presentar los datos de entrenamiento en un orden aleatorio; además, nos interesa mezclar los conjuntos de entrenamiento para cada época con el fin de evitar ciclos.
En las implementaciones de descenso de gradiente estocástico, el rango de aprendizaje fijado a menudo se sustituye por un rango de aprendizaje adaptativo que disminuya con el tiempo; por ejemplo:donde y son constantes. Debemos observar que el descenso de gradiente estocástico no alcanza el mínimo global, sino un área muy cercana a él. Y mediante un rango de aprendizaje adaptativo, podemos conseguir un mayor recorrido hacia el coste mínimo. |
Otra ventaja del descenso de gradiente estocástico es que podemos utilizarlo para aprendizaje online. En el aprendizaje online, nuestro modelo se entrena sobre la marcha al mismo tiempo que van llegando nuevos datos de entrenamiento. Esto resulta especialmente útil si estamos acumulando grandes cantidades de datos, por ejemplo, datos de clientes en aplicaciones web. Con el aprendizaje online, el sistema se puede adaptar de inmediato a los cambios y los datos de entrenamiento pueden ser descartados después de actualizar el modelo si existen problemas con el espacio de almacenamiento.
El equilibrio entre el descenso de gradiente en lotes y el descenso de gradiente estocástico se denomina aprendizaje de minilote. El aprendizaje de minilote puede ser entendido como la aplicación de un descenso de gradiente en lote a subconjuntos más pequeños de datos de entrenamiento; por ejemplo, 32 muestras a la vez. La ventaja sobre el descenso de gradiente en lote es que la convergencia se consigue más rápido mediante minilotes gracias a que las actualizaciones de peso son más frecuentes. Además, el aprendizaje de minilote nos permite sustituir el bucle for en las muestras de entrenamiento en el descenso de gradiente estocástico con operaciones vectorizadas, que pueden mejorar aún más la eficiencia computacional de nuestro algoritmo de aprendizaje. |
Como ya hemos implementado la regla de aprendizaje Adaline con un descenso de gradiente, solo necesitamos realizar algunos ajustes para modificar el algoritmo de aprendizaje y actualizar los pesos mediante el descenso de gradiente estocástico. Dentro del método fit, actualizaremos los pesos después de cada muestra de aprendizaje. Además, implementaremos un método partial_fit adicional, que no reiniciará los pesos, para el aprendizaje online. Con el fin de comprobar si nuestro algoritmo converge después del entrenamiento, vamos a calcular el coste como el coste medio de las muestras de entrenamiento en cada época. Además, añadiremos una opción para mezclar los datos de entrenamiento antes de cada época y evitar así ciclos repetitivos cuando estemos optimizando la función de coste. A través del parámetro random_state permitimos la especificación de una semilla aleatoria para la reproducibilidad:
class AdalineSGD(object):
"""ADAptive LInear NEuron classifier.
Parameters
------------
eta : float
Learning rate (between 0.0 and 1.0)
n_iter : int
Passes over the training dataset.
shuffle : bool (default: True)
Shuffles training data every epoch if True
to prevent cycles.
random_state : int
Random number generator seed for random weight
initialization.
Attributes
-----------
w_ : 1d-array
Weights after fitting.
cost_ : list
Sum-of-squares cost function value averaged over all
training samples in each epoch.
"""
def __init__(self, eta=0.01, n_iter=10,
shuffle=True, random_state=None):
self.eta = eta
self.n_iter = n_iter
self.w_initialized = False
self.shuffle = shuffle
self.random_state = random_state
def fit(self, X, y):
""" Fit training data.
Parameters
----------
X : {array-like}, shape = [n_samples, n_features]
Training vectors, where n_samples is the number
of samples and
n_features is the number of features.
y : array-like, shape = [n_samples]
Target values.
Returns
-------
self : object
"""
self._initialize_weights(X.shape[1])
self.cost_ = []
for i in range(self.n_iter):
if self.shuffle:
X, y = self._shuffle(X, y)
cost = []
for xi, target in zip(X, y):
cost.append(self._update_weights(xi, target))
avg_cost = sum(cost) / len(y)
self.cost_.append(avg_cost)
return self
def partial_fit(self, X, y):
"""Fit training data without reinitializing the weights"""
if not self.w_initialized:
self._initialize_weights(X.shape[1])
if y.ravel().shape[0] > 1:
for xi, target in zip(X, y):
self._update_weights(xi, target)
else:
self._update_weights(X, y)
return self
def _shuffle(self, X, y):
"""Shuffle training data"""
r = self.rgen.permutation(len(y))
return X[r], y[r]
def _initialize_weights(self, m):
"""Initialize weights to small random numbers"""
self.rgen = np.random.RandomState(self.random_state)
self.w_ = self.rgen.normal(loc=0.0, scale=0.01,
size=1 + m)
self.w_initialized = True
def _update_weights(self, xi, target):
"""Apply Adaline learning rule to update the weights"""
output = self.activation(self.net_input(xi))
error = (target - output)
self.w_[1:] += self.eta * xi.dot(error)
self.w_[0] += self.eta * error
cost = 0.5 * error**2
return cost
def net_input(self, X):
"""Calculate net input"""
return np.dot(X, self.w_[1:]) + self.w_[0]
def activation(self, X):
"""Compute linear activation"""
return X
def predict(self, X):
"""Return class label after unit step"""
return np.where(self.activation(self.net_input(X))
>= 0.0, 1, -1)
El método _shuffle que estamos utilizando ahora en el clasificador AdalineSGD funciona del siguiente modo: a través de la función permutation en np.random generamos una secuencia aleatoria de números únicos en el rango de 0 a 100. Estos números se pueden utilizar después como índice para mezclar nuestra matriz de características y el vector de etiqueta de clase.
Podemos utilizar el método fit para entrenar el clasificador AdalineSGD y el método plot_decision_regions para representar los resultados del entrenamiento:
>>> ada = AdalineSGD(n_iter=15, eta=0.01, random_state=1)
>>> ada.fit(X_std, y)
>>> plot_decision_regions(X_std, y, classifier=ada)
>>> plt.title('Adaline - Stochastic Gradient Descent')
>>> plt.xlabel('sepal length [standardized]')
>>> plt.ylabel('petal length [standardized]')
>>> plt.legend(loc='upper left')
>>> plt.show()
>>> plt.plot(range(1, len(ada.cost_) + 1), ada.cost_, marker='o')
>>> plt.xlabel('Epochs')
>>> plt.ylabel('Average Cost')
>>> plt.show()
Los dos diagramas que obtenemos de la ejecución del ejemplo de código anterior se muestran en la siguiente imagen:
Como podemos ver, el coste medio disminuye con gran rapidez, y el límite de decisión final después de 15 épocas se asemeja al descenso de gradiente en lotes Adaline. Si queremos actualizar nuestro modelo –por ejemplo, en un caso de aprendizaje online con transmisión de datos– podríamos llamar simplemente el método partial_fit sobre muestras individuales: ada.partial_fit(X_std[0, :], y[0]).
Resumen
En este capítulo, hemos podido conocer los conceptos básicos de los clasificadores lineales para el aprendizaje supervisado. Después de haber implementado un perceptrón, hemos visto cómo podemos entrenar neuronas lineales adaptativas de manera eficiente mediante la implementación vectorizada del descenso de gradiente, y el aprendizaje online mediante el descenso de gradiente estocástico.
Ahora que hemos visto cómo implementar clasificadores simples en Python, ya estamos preparados para pasar al siguiente capítulo, donde utilizaremos la librería de aprendizaje automático de Python scikit-learn para tener acceso a clasificadores de aprendizaje automático más potentes y avanzados que se utilizan habitualmente tanto en formación como en industria. El enfoque orientado a objetos que hemos utilizado para implementar los algoritmos del perceptrón y Adaline nos ayudará a entender el API scikit-learn, cuya implementación se basa en los mismos conceptos principales que hemos utilizado en este capítulo: los métodos fit y predict. Basándonos en estos conceptos, aprenderemos sobre la regresión logística para modelar probabilidades de clase y máquinas de vectores de soporte para trabajar con límites de decisión no lineales. Además, presentaremos una clase distinta de algoritmos de aprendizaje supervisado: algoritmos basados en árboles, que habitualmente se combinan en clasificadores de conjuntos robustos.