Capítulo 4. Bajo el capó: Entrenamiento de un clasificador de dígitos
Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com
Después de haber visto lo que parece entrenar una variedad de modelos enel Capítulo 2, ahora vamos a mirar bajo el capó y ver exactamente lo que está pasando. Empezaremos utilizando la visión por ordenador para introducir herramientas y conceptos fundamentales para el aprendizaje profundo.
Para ser exactos, hablaremos del papel de las matrices y los tensores y de la difusión, una potente técnica para utilizarlos de forma expresiva. Explicaremos el descenso de gradiente estocástico (SGD), el mecanismo para aprender actualizando los pesos automáticamente. Hablaremos de la elección de una función de pérdida para nuestra tarea básica de clasificación y del papel de los minilotes. También describiremos las operaciones matemáticas que realiza una red neuronal básica. Por último, uniremos todas estas piezas.
En próximos capítulos, nos sumergiremos en otras aplicaciones y veremos cómo se generalizan estos conceptos y herramientas. Pero este capítulo trata de poner los cimientos. Para ser franco, eso también hace que éste sea uno de los capítulos más difíciles, por la forma en que todos estos conceptos dependen unos de otros. Como en un arco, todas las piedras tienen que estar en su sitio para que la estructura se mantenga en pie. También como un arco, una vez que eso ocurre, es una estructura poderosa que puede soportar otras cosas. Pero su montaje requiere cierta paciencia.
Comencemos. El primer paso es considerar cómo se representan las imágenes en un ordenador.
Píxeles: Los fundamentos de la visión por ordenador
Para entender lo que ocurre en un modelo de visión por ordenador, primero tenemos que entender cómo tratan las imágenes los ordenadores. Utilizaremos uno de los conjuntos de datos más famosos en visión por ordenador,MNIST, para nuestros experimentos. MNIST contiene imágenes de dígitos escritos a mano, recopiladas por el Instituto Nacional de Estándares y Tecnología y cotejadas en un conjunto de datos de aprendizaje automático por Yann Lecun y sus colegas. Lecun utilizó MNIST en 1998 en LeNet-5, el primer sistema informático que demostró un reconocimiento prácticamente útil de secuencias de dígitos manuscritos. Fue uno de los avances más importantes en la historia de la IA.
Para este tutorial inicial, sólo vamos a intentar crear un modelo que pueda clasificar cualquier imagen como un 3 o un 7. Así que vamos a descargar una muestra de MNIST que contenga imágenes sólo de estos dígitos:
path
=
untar_data
(
URLs
.
MNIST_SAMPLE
)
Podemos ver lo que hay en este directorio utilizando ls
, un métodoañadido por fastai. Este método devuelve un objeto de una clase especial de fastai llamada L
, que tiene toda la funcionalidad del método incorporado de Python list
, y mucho más. Una de sus características más útiles es que, cuando se imprime, muestra el recuento de elementos antes de listar los propios elementos (si hay más de 10 elementos, muestra sólo los primeros):
path
.
ls
()
(#9) [Path('cleaned.csv'),Path('item_list.txt'),Path('trained_model.pkl'),Path(' > models'),Path('valid'),Path('labels.csv'),Path('export.pkl'),Path('history.cs > v'),Path('train')]
El conjunto de datos MNIST sigue la disposición habitual de los conjuntos de datos de aprendizaje automático: carpetas separadas para el conjunto de entrenamiento y el conjunto de validación (y/o prueba). Veamos qué hay dentro del conjunto de entrenamiento:
(
path
/
'train'
)
.
ls
()
(#2) [Path('train/7'),Path('train/3')]
Hay una carpeta de 3 y otra de 7. En lenguaje de aprendizaje automático, decimos que "3" y "7" son las etiquetas(u objetivos) de este conjunto de datos. Echemos un vistazo a una de estas carpetas (utilizando sorted
para asegurarnos de que todos obtenemos el mismo orden de archivos):
threes
=
(
path
/
'train'
/
'3'
)
.
ls
()
.
sorted
()
sevens
=
(
path
/
'train'
/
'7'
)
.
ls
()
.
sorted
()
threes
(#6131) [Path('train/3/10.png'),Path('train/3/10000.png'),Path('train/3/10011.pn > g'),Path('train/3/10031.png'),Path('train/3/10034.png'),Path('train/3/10042.p > ng'),Path('train/3/10052.png'),Path('train/3/1007.png'),Path('train/3/10074.p > ng'),Path('train/3/10091.png')...]
Como era de esperar, está lleno de archivos de imagen. Echemos ahora un vistazo a uno de ellos en . Aquí hay una imagen de un número 3 escrito a mano, tomada del famoso conjunto de datos MNIST de números escritos a mano:
im3_path
=
threes
[
1
]
im3
=
Image
.
open
(
im3_path
)
im3
Aquí estamos utilizando la clase Image
de la Python Imaging Library(PIL), que es el paquete de Python más utilizado para abrir, manipular y visualizar imágenes. Jupyter conoce las imágenes PIL, por lo que nos muestra la imagen automáticamente.
En un ordenador, todo se representa como un número. Para ver los números que componen esta imagen, tenemos que convertirla a una matriz NumPy o a un tensor PyTorch. Por ejemplo, éste es el aspecto de una sección de la imagen convertida en una matriz NumPy:
array
(
im3
)[
4
:
10
,
4
:
10
]
array([[ 0, 0, 0, 0, 0, 0], [ 0, 0, 0, 0, 0, 29], [ 0, 0, 0, 48, 166, 224], [ 0, 93, 244, 249, 253, 187], [ 0, 107, 253, 253, 230, 48], [ 0, 3, 20, 20, 15, 0]], dtype=uint8)
El 4:10
indica que solicitamos las filas desde el índice 4 (incluido) hasta el 10 (no incluido), y lo mismo para las columnas. NumPy indexa de arriba abajo y de izquierda a derecha, por lo que esta sección se encuentra cerca de la esquina superior izquierda de la imagen. Esto es lo mismo que un tensor PyTorch:
tensor
(
im3
)[
4
:
10
,
4
:
10
]
tensor([[ 0, 0, 0, 0, 0, 0], [ 0, 0, 0, 0, 0, 29], [ 0, 0, 0, 48, 166, 224], [ 0, 93, 244, 249, 253, 187], [ 0, 107, 253, 253, 230, 48], [ 0, 3, 20, 20, 15, 0]], dtype=torch.uint8)
Podemos trocear la matriz para elegir sólo la parte con la parte superior del dígito en ella, y luego utilizar un Pandas DataFrame para codificar los valores por colores utilizando un degradado , que nos muestra claramente cómo se crea la imagen a partir de los valores de los píxeles:
im3_t
=
tensor
(
im3
)
df
=
pd
.
DataFrame
(
im3_t
[
4
:
15
,
4
:
22
])
df
.
style
.
set_properties
(
**
{
'font-size'
:
'6pt'
})
.
background_gradient
(
'Greys'
)
Puedes ver que los píxeles blancos del fondo se almacenan como el número 0, el negro es el número 255, y los tonos de gris están entre los dos. La imagen completa contiene 28 píxeles a lo ancho y 28 píxeles a lo bajo, lo que da un total de 784 píxeles. (Esto es mucho más pequeño que una imagen que obtendrías de la cámara de un teléfono, que tiene millones de píxeles, pero es un tamaño conveniente para nuestro aprendizaje y experimentos iniciales. Pronto pasaremos a imágenes más grandes y a todo color).
Así que, ahora que ya has visto qué aspecto tiene una imagen para un ordenador, recordemos nuestro objetivo: crear un modelo que pueda reconocer 3s y 7s. ¿Cómo podrías conseguir que un ordenador lo hiciera?
¡Párate y piensa!
Antes de seguir leyendo, tómate un momento para pensar cómo podría ser capaz un ordenador de reconocer estos dos dígitos. ¿Qué tipo de características podría observar? ¿Cómo podría identificar esas características? ¿Cómo podría combinarlas? El aprendizaje funciona mejor cuando intentas resolver los problemas por ti mismo, en lugar de limitarte a leer las respuestas de otros; así que aléjate de este libro durante unos minutos, coge papel y bolígrafo, y apunta algunas ideas.
Primer intento: Similitud de píxeles
Así pues, he aquí una primera idea: ¿qué tal si hallamos el valor medio de los píxeles de cada píxel de los 3, y luego hacemos lo mismo con los 7? Esto nos dará dos medias de grupo, que definirán lo que podríamos llamar el 3 y el 7 "ideales". Luego, para clasificar una imagen como un dígito u otro, vemos a cuál de estos dos dígitos ideales se parece más la imagen. Desde luego, esto parece que debería ser mejor que nada, así que será una buena base de referencia.
Jerga: Línea de base
Un modelo sencillo que estés seguro de que funcionará razonablemente bien . Debe ser sencillo de aplicar y fácil de probar, para que luego puedas probar cada una de tus ideas mejoradas y asegurarte de que siempre son mejores que tu línea de base. Sin partir de una línea de base sensata, es difícil saber si tus modelos superelegantes son buenos. Un buen enfoque para crear una línea de base es hacer lo que hemos hecho aquí: pensar en un modelo sencillo y fácil de implementar. Otro buen enfoque es buscar por ahí a otras personas que hayan resuelto problemas similares al tuyo, y descargar y ejecutar su código en tu conjunto de datos. Lo ideal es que pruebes las dos cosas.
El paso 1 de nuestro modelo sencillo es obtener la media de los valores de los píxeles de cada uno de nuestros dos grupos. Al hacerlo, aprenderemos un montón de trucos de programación numérica en Python.
Vamos a crear un tensor que contenga todos nuestros 3s apiladosjuntos. Ya sabemos cómo crear un tensor que contenga una sola imagen. Para crear un tensor que contenga todas las imágenes de un directorio, utilizaremos primero una comprensión de listas de Python para crear una lista simple de los tensores de una sola imagen.
Utilizaremos Jupyter para hacer algunas pequeñas comprobaciones de nuestro trabajo a lo largo del camino; en este caso, para asegurarnos de que el número de elementos devueltos parece razonable:
seven_tensors
=
[
tensor
(
Image
.
open
(
o
))
for
o
in
sevens
]
three_tensors
=
[
tensor
(
Image
.
open
(
o
))
for
o
in
threes
]
len
(
three_tensors
),
len
(
seven_tensors
)
(6131, 6265)
Comprensión de listas
Las comprensiones de listas y diccionarios son una característica maravillosa de Python. Muchos programadores de Python las utilizan a diario, incluidos los autores de este libro: forman parte del "Python idiomático". Pero es posible que los programadores procedentes de otros lenguajes no las hayan visto nunca. Hay un montón de tutoriales estupendos a sólo una búsqueda en Internet, así que no dedicaremos mucho tiempo a hablar de ellos ahora. Aquí tienes una explicación rápida y un ejemplo para empezar. Una comprensión de lista tiene este aspecto: new_list = [f(o) for o in a_list if o>0]
. Esto devolverá cada elemento de a_list
que sea mayor que 0, después de pasarlo a la función f
. Aquí hay tres partes: la colección sobre la que estás iterando (a_list
), un filtro opcional (if o>0
) y algo que hacer con cada elemento (f(o)
). No sólo es más corto de escribir, sino también mucho más rápido que las formas alternativas de crear la misma lista con un bucle.
También comprobaremos que una de las imágenes se ve bien. Como ahora tenemos tensores (que Jupyter imprimirá por defecto como valores), en lugar de imágenes PIL (que Jupyter mostrará por defecto como imágenes), tenemos que utilizar la función show_image
de fastai para mostrarla:
show_image
(
three_tensors
[
1
]);
Para cada posición de píxel, queremos calcular la media sobre todas las imágenes de la intensidad de ese píxel. Para ello, primero combinamos todas las imágenes de esta lista en un único tensor tridimensional. La forma más habitual de describir un tensor de este tipo es llamarlo tensor de rango 3. A menudo necesitamos apilar los tensores individuales de una colección en un único tensor. Como era de esperar, PyTorch incluye una función llamada stack
que podemos utilizar para este fin.
Algunas operaciones en PyTorch, como sacar una media, requieren que convirtamosnuestros tipos enteros en tipos flotantes. Como lo necesitaremos más adelante, ahora también pasaremos nuestro tensor apilado a float
. La conversión en PyTorch es tan sencilla como escribir el nombre del tipo al que quieres convertir y tratarlo como un método.
Generalmente, cuando las imágenes son flotantes, se espera que los valores de los píxeles estén entre 0 y 1, por lo que aquí también dividiremos por 255:
stacked_sevens
=
torch
.
stack
(
seven_tensors
)
.
float
()
/
255
stacked_threes
=
torch
.
stack
(
three_tensors
)
.
float
()
/
255
stacked_threes
.
shape
torch.Size([6131, 28, 28])
Quizá el atributo más importante de un tensor sea su forma. Estete indica la longitud de cada eje. En este caso, podemos ver que tenemos 6.131 imágenes, cada una de tamaño 28×28 píxeles. No hay nada específico en este tensor que diga que el primer eje es el número de imágenes, el segundo la altura y el tercero la anchura: la semántica de un tensor depende totalmente de nosotros y de cómo lo construyamos. En lo que respecta a PyTorch, no es más que un montón de números en memoria.
La longitud de la forma de un tensor es su rango:
len
(
stacked_threes
.
shape
)
3
Es muy importante que memorices y practiques estos fragmentos de jerga tensorial: el rango es el número de ejes o dimensiones de un tensor; la forma es el tamaño de cada eje de un tensor.
Alexis dice
Cuidado porque el término "dimensión" se utiliza a veces de dos formas. Considera que vivimos en un "espacio tridimensional", donde una posición física puede describirse mediante un vector v
, de longitud 3. Pero según PyTorch, el atributo v.ndim
(que seguro que se parece al "número de dimensiones" de v
) es igual a uno, ¡no a tres! ¿Por qué? Porque v
es un vector, que es un tensor de rango uno, lo que significa que sólo tiene un eje (aunque ese eje tenga una longitud de tres). En otras palabras, a veces dimensión se utiliza para el tamaño de un eje ("el espacio es tridimensional"), mientras que otras veces se utiliza para el rango, o el número de ejes ("una matriz tiene dos dimensiones"). Cuando me confundo, me resulta útil traducir todas las afirmaciones en términos de rango, eje y longitud, que son términos inequívocos.
También podemos obtener el rango de un tensor directamente con ndim
:
stacked_threes
.
ndim
3
Por último, podemos calcular cómo es el 3 ideal. Calculamos la media de todos los tensores de las imágenes tomando la media a lo largo de la dimensión 0 de nuestro tensor apilado de rango 3. Ésta es la dimensión que indexa todas las imágenes.
En otras palabras, para cada posición de píxel, se calculará la media de ese píxel en todas las imágenes. El resultado será un valor para cada posición de píxel, o una sola imagen. Aquí lo tienes:
mean3
=
stacked_threes
.
mean
(
0
)
show_image
(
mean3
);
Según este conjunto de datos, ¡éste es el número 3 ideal! (Puede que no te guste, pero así es como es el rendimiento máximo del número 3.) Puedes ver cómo es muy oscuro donde todas las imágenes coinciden en que debería ser oscuro, pero se vuelve difuso y borroso donde las imágenes discrepan.
Hagamos lo mismo con los 7, pero juntemos todos los pasos a la vez para ahorrar tiempo:
mean7
=
stacked_sevens
.
mean
(
0
)
show_image
(
mean7
);
Elijamos ahora un 3 arbitrario y midamos sudistancia a nuestros "dígitos ideales".
¡Párate a pensar!
¿Cómo calcularías lo parecida que es una imagen concreta a cada uno de nuestros dígitos ideales? Recuerda alejarte de este libro y anotar algunas ideas antes de seguir adelante. Las investigaciones demuestran que el recuerdo y la comprensión mejoran notablemente cuando te implicas en el proceso de aprendizaje resolviendo problemas, experimentando y probando nuevas ideas por ti mismo.
a_3
=
stacked_threes
[
1
]
show_image
(
a_3
);
¿Cómo podemos determinar su distancia respecto a nuestro 3 ideal? No podemos limitarnos a sumar las diferencias entre los píxeles de esta imagen y el dígito ideal. Algunas diferencias serán positivas, mientras que otras serán negativas, y estas diferencias se anularán, dando lugar a una situación en la que una imagen que es demasiado oscura en algunos lugares y demasiado clara en otros podría mostrarse como que tiene cero diferencias totales respecto al ideal. ¡Eso seríaengañoso!
Para evitarlo, los científicos de datos utilizan dos formas principales de medir la distancia en este contexto:
-
Toma la media del valor absoluto de las diferencias (el valor absoluto es la función que sustituye los valores negativos por valores positivos). Esto se llama diferencia absoluta media o norma L1.
-
Toma la media del cuadrado de las diferencias (que hace que todo sea positivo) y luego saca la raíz cuadrada (que deshace el cuadrado). Esto se llama error cuadrático medio (RMSE ) o norma L2.
No pasa nada por haber olvidado las matemáticas
En este libro, generalmente asumimos que has terminado las matemáticas del instituto y que recuerdas al menos parte de ellas, ¡pero todo el mundo olvida algunas cosas! Todo depende de lo que hayas tenido que practicar mientras tanto. Quizá hayas olvidado qué es una raíz cuadrada, o cómo funcionan exactamente. ¡No hay problema! Cada vez que te encuentres con un concepto matemático que no esté completamente explicado en este libro, no sigas adelante; en lugar de eso, detente y búscalo. Asegúrate de que comprendes la idea básica, cómo funciona y por qué podríamos estar utilizándola. Uno de los mejores lugares para refrescar tu comprensión de es Khan Academy. Por ejemplo, Khan Academy tiene una gran introducción a las raíces cuadradas.
Vamos a probar las dos cosas ahora:
dist_3_abs
=
(
a_3
-
mean3
)
.
abs
()
.
mean
()
dist_3_sqr
=
((
a_3
-
mean3
)
**
2
)
.
mean
()
.
sqrt
()
dist_3_abs
,
dist_3_sqr
(tensor(0.1114), tensor(0.2021))
dist_7_abs
=
(
a_3
-
mean7
)
.
abs
()
.
mean
()
dist_7_sqr
=
((
a_3
-
mean7
)
**
2
)
.
mean
()
.
sqrt
()
dist_7_abs
,
dist_7_sqr
(tensor(0.1586), tensor(0.3021))
En ambos casos, la distancia entre nuestro 3 y el 3 "ideal" es menor que la distancia al 7 ideal, por lo que nuestro modelo simple dará la predicción correcta en este caso.
PyTorch ya proporciona ambas como funciones de pérdida. Las encontrarás dentro de torch.nn.functional
, que el equipo de PyTorch recomienda importar como F
(y está disponible por defecto con ese nombre en fastai):
F
.
l1_loss
(
a_3
.
float
(),
mean7
),
F
.
mse_loss
(
a_3
,
mean7
)
.
sqrt
()
(tensor(0.1586), tensor(0.3021))
Aquí, MSE
significa error cuadrático medio,y l1
se refiere a la jerga matemática estándar para el valor absoluto medio (en matemáticas se llama norma L1).
Sylvain Dice
Intuitivamente, la diferencia entre la norma L1 y el error cuadrático medio (ECM) es que este último penalizará más los errores grandes que el primero (y será más indulgente con los errores pequeños).
Jeremy dice
Cuando me encontré por primera vez con esta cosa de L1, la busqué para ver qué demonios significaba. Encontré en Google que es una norma vectorial que utiliza el valor absoluto, así que busqué "norma vectorial" y empecé a leer: Dado un espacio vectorial V sobre un campo F de los números reales o complejos, una norma en V es una función cualquiera p de valor no negativo: V → \[0,+∞) con las siguientes propiedades: Para todo a ∈ F y todo u, v ∈ V, p(u + v) ≤ p(u) + p(v)...Entonces dejé de leer. "¡Uf, nunca entenderé las matemáticas!". pensé por milésima vez. Desde entonces, he aprendido que cada vez que estos complejos trozos de jerga matemática aparecen en la práctica, ¡resulta que puedo sustituirlos por un trocito de código! Por ejemplo, la pérdida L1 es igual a (a-b).abs().mean()
, donde a
y b
son tensores. Supongo que los matemáticos piensan de forma distinta a la mía... Me aseguraré de que en este libro, cada vez que aparezca una jerga matemática, te dé también el trocito de código al que equivale, y te explique en términos de sentido común lo que está pasando.
Acabamos de completar varias operaciones matemáticas sobre tensores PyTorch. Si has hecho antes programación numérica en NumPy, puede que los reconozcas como similares a las matrices de NumPy. Echemos un vistazo a estas dos importantes estructuras de datos.
Matrices NumPy y Tensores PyTorch
NumPy es la biblioteca más utilizada para la programación científica y numérica en Python. Proporciona una funcionalidad y una API similares a las de PyTorch; sin embargo, no admite el uso de la GPU ni el cálculo de gradientes, ambos fundamentales para el aprendizaje profundo. Por lo tanto, en este libro, utilizaremos generalmente tensores PyTorch en lugar de matrices NumPy, siempre que sea posible.
(Ten en cuenta que fastai añade algunas características a NumPy y PyTorch para hacerlos un poco más similares entre sí. Si algún código de este libro no funciona en tu ordenador, es posible que hayas olvidado incluir una línea como ésta al principio de tu cuaderno:from
fastai.vision.all import *
.)
Pero, ¿qué son las matrices y los tensores, y por qué deberían importarte?
Python es lento en comparación con muchos lenguajes. Cualquier cosa rápida en Python, NumPy o PyTorch es probablemente una envoltura de un objeto compilado escrito (y optimizado) en otro lenguaje, concretamente en C. De hecho, las matrices de NumPy y los tensores de PyTorch pueden terminar los cálculos muchos miles de veces más rápido que utilizando Python puro.
Una matriz NumPy es una tabla multidimensional de datos, con todos los elementos del mismo tipo. Como puede ser de cualquier tipo, incluso pueden ser matrices de matrices, con las matrices más internas potencialmente de distintos tamaños: esto se denomina matriz dentada. Por "tabla multidimensional", entendemos, por ejemplo, una lista (dimensión de uno), una tabla o matriz (dimensión de dos), una tabla de tablas o cubo (dimensión de tres), etc. Si los elementos son todos de tipo simple, como entero o flotante, NumPy los almacenará como una estructura de datos C compacta en memoria. Aquí es donde NumPy brilla. NumPy tiene una gran variedad de operadores y métodos que pueden ejecutar cálculos en estas estructuras compactas a la misma velocidad que C optimizado, porque están escritas en C optimizado.
Un tensor PyTorch es casi lo mismo que un array NumPy, pero con una restricción adicional que desbloquea capacidades adicionales. Es lo mismo en el sentido de que también es una tabla multidimensional de datos, con todos los elementos del mismo tipo. Sin embargo, la restricción es que un tensor no puede utilizar cualquier tipo: tiene que utilizar un único tipo numérico básico para todos los componentes. Como resultado, un tensor no es tan flexible como una auténtica matriz de matrices. Por ejemplo, un tensor PyTorch no puede ser dentado. Siempre es una estructura rectangular multidimensional de forma regular.
La gran mayoría de los métodos y operadores soportados por NumPy en estas estructuras también son soportados por PyTorch, pero los tensores de PyTorch tienen capacidades adicionales. Una capacidad importante es que estas estructuras pueden vivir en la GPU, en cuyo caso su cálculo se optimizarápara la GPU y puede ejecutarse mucho más rápido (dados muchos valores sobre los que trabajar). Además, PyTorch puede calcular automáticamente las derivadas de estas operaciones, incluidas las combinaciones de operaciones. Como verás, sería imposible hacer aprendizaje profundo en la práctica sin esta capacidad.
Sylvain Dice
Si no sabes lo que es C, no te preocupes: no lo necesitarás para nada. En pocas palabras, es unlenguaje de bajo nivel (bajo nivel significa más parecido al lenguaje que utilizan internamente los ordenadores) que es muy rápido en comparación con Python. Para aprovechar su velocidad mientras programas en Python, intenta evitar en lo posible escribir bucles, y sustitúyelos por comandos que trabajen directamente sobre matrices o tensores.
Quizá la nueva habilidad de codificación más importante para un programador de Python que aprenda es cómo utilizar eficazmente las API de matrices/tensores. Mostraremos muchos más trucos más adelante en este libro, pero aquí tienes un resumen de las cosas clave que necesitas saber por ahora.
Para crear una matriz o un tensor, pasa una lista (o una lista de listas, o una lista de listas de listas, etc.) a array
o tensor
:
data
=
[[
1
,
2
,
3
],[
4
,
5
,
6
]]
arr
=
array
(
data
)
tns
=
tensor
(
data
)
arr
# numpy
array([[1, 2, 3], [4, 5, 6]])
tns
# pytorch
tensor([[1, 2, 3], [4, 5, 6]])
Todas las operaciones que siguen se muestran sobre tensores, pero la sintaxis y los resultados para matrices NumPy son idénticos.
Puedes seleccionar una fila (ten en cuenta que, como las listas en Python, los tensores tienen índice 0, por lo que 1 se refiere a la segunda fila/columna):
tns
[
1
]
tensor([4, 5, 6])
O una columna, utilizando :
para indicar todo el primer eje (a veces nos referimos a las dimensiones de los tensores/matrices como ejes):
tns
[:,
1
]
tensor([2, 5])
Puedes combinarlos con la sintaxis de cortes de Python ([start:end]
con end
excluidos) para seleccionar parte de una fila o columna:
tns
[
1
,
1
:
3
]
tensor([5, 6])
Y puedes utilizar los operadores estándar, como +
, -
, *
, y /
:
tns
+
1
tensor([[2, 3, 4], [5, 6, 7]])
tns
.
type
()
'torch.LongTensor'
Y cambiará automáticamente ese tipo según sea necesario; por ejemplo, de int
a float
:
tns
*
1.5
tensor([[1.5000, 3.0000, 4.5000], [6.0000, 7.5000, 9.0000]])
Entonces, ¿es bueno nuestro modelo de referencia? Para cuantificarlo, debemos definir una métrica.
Cálculo de métricas mediante difusión
Recordemos que una métrica es un número que se calcula a partir de las predicciones de nuestro modelo y las etiquetas correctas de nuestro conjunto de datos, para indicarnos lo bueno que es nuestro modelo. Por ejemplo, podríamos utilizar cualquiera de las funciones que vimos en la sección anterior, error cuadrático medio o error absoluto medio, y tomar la media de ellas en todo el conjunto de datos. Sin embargo, ninguno de los dos son números muy comprensibles para la mayoría de la gente; en la práctica, normalmente utilizamos la precisióncomo métrica para los modelos de clasificación.
Como ya hemos dicho, queremos calcular nuestra métrica en un conjunto de validación. Esto es para no sobreajustar inadvertidamente, es decir, entrenar un modelo para que funcione bien sólo con nuestros datos de entrenamiento. Esto no es realmente un riesgo con el modelo de similitud de píxeles que estamos utilizando aquí como primer intento, ya que no tiene componentes entrenados, pero de todos modos utilizaremos un conjunto de validación para seguir las prácticas normales y estar preparados para nuestro segundo intento más adelante.
Para obtener un conjunto de validación, tenemos que eliminar por completo algunos datos del entrenamiento, de modo que el modelo no los vea en absoluto. Resulta que los creadores del conjunto de datos MNIST ya han hecho esto por nosotros. ¿Te acuerdas ende que había todo un directorio aparte llamado válido? ¡Para eso está este directorio!
Así que, para empezar, vamos a crear tensores para nuestros 3s y 7s a partir de ese directorio. Éstos son los tensores que utilizaremos para calcular una métrica que mide la calidad de nuestro modelo de primer intento, que mide la distancia respecto a una imagen ideal:
valid_3_tens
=
torch
.
stack
([
tensor
(
Image
.
open
(
o
))
for
o
in
(
path
/
'valid'
/
'3'
)
.
ls
()])
valid_3_tens
=
valid_3_tens
.
float
()
/
255
valid_7_tens
=
torch
.
stack
([
tensor
(
Image
.
open
(
o
))
for
o
in
(
path
/
'valid'
/
'7'
)
.
ls
()])
valid_7_tens
=
valid_7_tens
.
float
()
/
255
valid_3_tens
.
shape
,
valid_7_tens
.
shape
(torch.Size([1010, 28, 28]), torch.Size([1028, 28, 28]))
Aquí vemos dos tensores, uno que representa el conjunto de validación 3s de 1.010 imágenes de tamaño 28×28, y otro que representa el conjunto de validación 7s de 1.028 imágenes de tamaño 28×28.
En última instancia, queremos escribir una función, is_3
, que decida si una imagen arbitraria es un 3 o un 7. Lo hará decidiendo a cuál de nuestros dos "dígitos ideales" se acerca más esa imagen arbitraria. Para ello necesitamos definir una noción de distancia,es decir, una función que calcule la distancia entre dos imágenes.
Podemos escribir una función sencilla que calcule el error medio absoluto utilizando una expresión muy parecida a la que escribimos en el apartado anterior:
def
mnist_distance
(
a
,
b
):
return
(
a
-
b
)
.
abs
()
.
mean
((
-
1
,
-
2
))
mnist_distance
(
a_3
,
mean3
)
tensor(0.1114)
Éste es el mismo valor que calculamos anteriormente para la distancia entre estas dos imágenes, la ideal 3 mean3
y la muestra arbitraria 3 a_3
, que son ambas tensores de una sola imagen con forma de[28,28]
.
Pero para calcular una métrica de la precisión global, tendremos que calcular la distancia al ideal 3 de cada imagen del conjunto de validación. ¿Cómo hacemos ese cálculo? Podríamos escribir un bucle sobre todos los tensores de una sola imagen que están apilados dentro de nuestro tensor del conjunto de validación, valid_3_tens
, que tiene una forma de [1010,28,28]
que representa 1.010 imágenes. Pero hay una forma mejor.
Algo interesante ocurre cuando tomamos exactamente esta misma función de distancia, diseñada para comparar dos imágenes individuales, pero le pasamos como argumento valid_3_tens
, el tensor que representa el conjunto de validación 3s:
valid_3_dist
=
mnist_distance
(
valid_3_tens
,
mean3
)
valid_3_dist
,
valid_3_dist
.
shape
(tensor([0.1050, 0.1526, 0.1186, ..., 0.1122, 0.1170, 0.1086]), torch.Size([1010]))
En lugar de quejarse de que las formas no coincidían, devolvía la distancia de cada imagen como un vector (es decir, un tensor de rango 1) de longitud 1.010 (el número de 3s de nuestro conjunto de validación). ¿Cómo ha ocurrido?
Echa otro vistazo a nuestra función mnist_distance
, y en verás que tenemos ahí la resta (a-b)
. El truco mágico es que PyTorch, cuando intente realizar una simple operación de resta entre dos tensores de distinto rango, utilizarála difusión: expandirá automáticamente el tensor de menor rango para que tenga el mismo tamaño que el de mayor rango. La difusión es una capacidad importante que facilita mucho la escritura de código tensorial.
Después de emitir para que los dos tensores argumento tengan el mismo rango, PyTorch aplica su lógica habitual para dos tensores del mismo rango: realiza la operación en cada elemento correspondiente de los dos tensores, y devuelve el resultado del tensor. Por ejemplo:
tensor
([
1
,
2
,
3
])
+
tensor
(
1
)
tensor([2, 3, 4])
Así que, en este caso, PyTorch trata mean3
, un tensor de rango 2 que representa una sola imagen, como si fueran 1.010 copias de la misma imagen, y luego resta cada una de esas copias de cada 3 de nuestro conjunto de validación. ¿Qué forma esperarías que tuviera este tensor? Intenta averiguarlo tú mismo antes de mirar la respuesta aquí:
(
valid_3_tens
-
mean3
)
.
shape
torch.Size([1010, 28, 28])
Calculamos la diferencia entre nuestro 3 ideal y cada uno de los 1.010 3s del conjunto de validación, para cada una de las 28×28 imágenes, lo que da como resultado la forma [1010,28,28]
.
Hay un par de puntos importantes sobre cómo se implementa la difusión , que la hacen valiosa no sólo por su expresividad, sino también por su rendimiento:
-
PyTorch no copia realmente
mean3
1.010 veces, sino que hace como si fuera un tensor de esa forma, pero no asigna memoria adicional. -
Realiza todo el cálculo en C (o, si utilizas una GPU, en CUDA, el equivalente de C en la GPU), decenas de miles de veces más rápido que el Python puro (¡hasta millones de veces más rápido en una GPU!).
Esto es válido para todas las operaciones y funciones de transmisión y elementales que se realizan en PyTorch. Es la técnicamás importante que debes conocer para crear código PyTorch eficiente.
A continuación en mnist_distance
vemos abs
. Ya puedes adivinar lo que hace cuando se aplica a un tensor. Aplica el método a cada elemento individual del tensor, y devuelve un tensor de los resultados (es decir, aplica el método elemento a elemento). En este caso, obtendremos 1.010 matrices de valores absolutos.
Por último, nuestra función llama a mean((-1,-2))
. La tupla (-1,-2)
representa un rango de ejes. En Python, -1
se refiere al último elemento, y -2
al penúltimo. Así que, en este caso, esto le dice a PyTorch que queremos tomar la media que varía sobre los valores indexados por los dos últimos ejes del tensor. Los dos últimos ejes son las dimensiones horizontal y vertical de una imagen. Tras tomar la media sobre los dos últimos ejes, sólo nos queda el primer eje del tensor, que indexa nuestras imágenes, por lo que nuestro tamaño final fue (1010)
. En otras palabras, para cada imagen, hemos promediado la intensidad de todos los píxeles de esa imagen.
Aprenderemos mucho más sobre la emisión a lo largo de este libro, especialmente en el capítulo 17, y también la practicaremos con regularidad.
Podemos utilizar mnist_distance
para averiguar si una imagen es un 3 utilizando la siguiente lógica: si la distancia entre el dígito en cuestión y el 3 ideal es menor que la distancia al 7 ideal, entonces es un 3. Esta función hará automáticamente la emisión y se aplicará de forma elemental, como todas las funciones y operadores de PyTorch:
def
is_3
(
x
):
return
mnist_distance
(
x
,
mean3
)
<
mnist_distance
(
x
,
mean7
)
Vamos a probarlo en nuestro caso de ejemplo:
is_3
(
a_3
),
is_3
(
a_3
)
.
float
()
(tensor(True), tensor(1.))
Observa que cuando convertimos la respuesta booleana a flotante, obtenemos1.0
para True
y 0.0
para False
.
Gracias a la difusión, también podemos probarlo en el conjunto completo de validación de 3s:
is_3
(
valid_3_tens
)
tensor([True, True, True, ..., True, True, True])
Ahora podemos calcular la precisión para cada uno de los 3s y 7s, tomando la media de esa función para todos los 3s y su inversa para todos los 7s:
accuracy_3s
=
is_3
(
valid_3_tens
)
.
float
()
.
mean
()
accuracy_7s
=
(
1
-
is_3
(
valid_7_tens
)
.
float
())
.
mean
()
accuracy_3s
,
accuracy_7s
,(
accuracy_3s
+
accuracy_7s
)
/
2
(tensor(0.9168), tensor(0.9854), tensor(0.9511))
¡Esto parece un buen comienzo! Estamos obteniendo más del 90% de precisión tanto en 3s como en 7s, y hemos visto cómo definir una métrica convenientemente utilizando la emisión. Pero seamos sinceros: los 3s y los 7s son dígitos de aspecto muy diferente. Y hasta ahora sólo estamos clasificando 2 de los 10 dígitos posibles. Así que ¡vamos a tener que hacerlo mejor!
Para hacerlo mejor, quizá haya llegado el momento de probar un sistema que haga un verdadero aprendizaje , uno que pueda modificarse automáticamente para mejorar su rendimiento. En otras palabras, es hora de hablar del proceso de entrenamiento y del SGD.
Descenso Gradiente Estocástico
¿Recuerdas la forma en que Arthur Samuel describió el aprendizaje automático, que citamos en el Capítulo 1?
Supongamos que disponemos algún medio automático para comprobar la eficacia de cualquier asignación de pesos actual en términos de rendimiento real y proporcionamos un mecanismo para alterar la asignación de pesos de modo que se maximice el rendimiento. No necesitamos entrar en los detalles de tal procedimiento para ver que podría hacerse totalmente automático y ver que una máquina así programada "aprendería" de su experiencia.
Como ya hemos dicho, ésta es la clave que nos permite tener un modelo cada vez mejor, que puede aprender. Pero nuestro enfoque de similitud de píxeles no lo hace realmente. No tenemos ningún tipo de asignación de pesos, ni ninguna forma de mejorar basándonos en la comprobación de la eficacia de una asignación de pesos. En otras palabras, no podemos mejorar realmente nuestro enfoque de similitud de píxeles modificando un conjunto de parámetros. Para aprovechar la potencia del aprendizaje profundo, primero tendremos que representar nuestra tarea de la forma en que Samuel la describió.
En lugar de intentar encontrar la similitud entre una imagen y una "imagen ideal", podríamos fijarnos en cada píxel individual e idear un conjunto de pesos para cada uno, de forma que los pesos más altos se asocien a los píxeles con más probabilidades de ser negros para una categoría concreta. Por ejemplo, no es muy probable que los píxeles situados en la parte inferior derecha se activen para un 7, por lo que deberían tener un peso bajo para un 7, pero es probable que se activen para un 3, por lo que deberían tener un peso alto para un 3. Esto puede representarse como una función y un conjunto de valores de peso para cada categoría posible: por ejemplo, la probabilidad de ser el número 3:
def pr_three(x, w): return (x*w).sum()
Aquí estamos suponiendo que x
es la imagen, representada como un vector -en otras palabras, con todas las filas apiladas de extremo a extremo en una única línea larga-. Y suponemos que los pesos son un vector w
. Si tenemos esta función, sólo necesitamos alguna forma de actualizar los pesos para mejorarlos un poco. Con un enfoque así, podemos repetir ese paso varias veces, haciendo que los pesos sean cada vez mejores, hasta que sean tan buenos como podamos hacerlos.
Queremos encontrar los valores específicos del vector w
que hacen que el resultado de nuestra función sea alto para las imágenes que son 3s, y bajo para las que no lo son. Buscar el mejor vector w
es una forma de buscar la mejor función para reconocer 3s. (Como todavía no estamos utilizando una red neuronal profunda, estamos limitados por lo que puede hacer nuestra función; vamos a solucionar esa limitación más adelante en este capítulo).
Para ser más concretos, he aquí los pasos necesarios para convertir esta función en un clasificador de aprendizaje automático:
-
Inicializa los pesos.
-
Para cada imagen, utiliza estos pesos para predecir si parece un 3 o un 7.
-
A partir de estas predicciones, calcula lo bueno que es el modelo (su pérdida).
-
Calcula el gradiente, que mide para cada peso cómo cambiaría la pérdida al cambiar ese peso.
-
Pisa (es decir, cambia) todos los pesos basándote en ese cálculo.
-
Vuelve al paso 2 y repite el proceso.
-
Iterar hasta que decidas detener el proceso de entrenamiento (por ejemplo, porque el modelo es suficientemente bueno o no quieres esperar más).
Estos siete pasos, ilustrados en la Figura 4-1, son la clave del entrenamiento de todos los modelos de aprendizaje profundo. Que el aprendizaje profundo resulte depender enteramente de estos pasos es extremadamente sorprendente y contrario a la intuición. Es increíble que este proceso pueda resolver problemas tan complejos. Pero, como verás, ¡realmente lo hace!
Hay muchas formas de realizar cada uno de estos siete pasos, y las iremos conociendo a lo largo del resto de este libro. Estos son los detalles que marcan una gran diferencia para los profesionales del aprendizaje profundo, pero resulta que el enfoque general de cada uno sigue algunos principios básicos. He aquí algunas directrices:
- Inicializa
-
Inicializamos los parámetros con valores aleatorios. Esto puede parecer sorprendente. Ciertamente hay otras opciones que podríamos tomar, como inicializarlos al porcentaje de veces que se activa ese píxel para esa categoría, pero como ya sabemos que tenemos una rutina para mejorar estos pesos, resulta que empezar simplemente con pesos aleatorios funciona perfectamente bien.
- Pérdida
-
A esto se refería Samuel cuando hablaba de probar la eficacia de cualquier asignación de pesos actual en términos de rendimiento real. Necesitamos una función que devuelva un número que sea pequeño si el rendimiento del modelo es bueno (el enfoque estándar es tratar una pérdida pequeña como buena y una pérdida grande como mala, aunque esto es sólo una convención).
- Paso
-
Una forma sencilla de averiguar si un peso debe aumentarse un poco o disminuirse un poco sería simplemente probarlo: aumenta el peso en una pequeña cantidad, y observa si la pérdida sube o baja. Una vez que encuentres la dirección correcta, podrías cambiar esa cantidad un poco más, o un poco menos, hasta que encuentres una cantidad que funcione bien. Sin embargo, ¡esto es lento! Como veremos, la magia del cálculo nos permite averiguar directamente en qué dirección, y en cuánto aproximadamente, cambiar cada peso, sin tener que probar todos estos pequeños cambios. La forma de hacerlo es calculando gradientes. Esto es sólo una optimización del rendimiento; también obtendríamos exactamente los mismos resultados utilizando el proceso manual, que es más lento.
- Para
-
Una vez que hemos decidido durante cuántas épocas entrenar el modelo (en la lista anterior se daban algunas sugerencias al respecto), aplicamos esa decisión. Para nuestro clasificador de dígitos, seguiríamos entrenando hasta que la precisión del modelo empezara a empeorar, o se nos acabara el tiempo.
Antes de aplicar estos pasos a nuestro problema de clasificación de imágenes, vamos a ilustrar cómo son en un caso más sencillo. Primero definiremos una función muy simple, la cuadrática -imaginemos que es nuestra función de pérdida, y x
es un parámetro de peso de la función-:
def
f
(
x
):
return
x
**
2
Aquí tienes una gráfica de esa función:
plot_function
(
f
,
'x'
,
'x**2'
)
La secuencia de pasos que hemos descrito antes comienza eligiendo un valor aleatorio para un parámetro, y calculando el valor de la pérdida:
plot_function
(
f
,
'x'
,
'x**2'
)
plt
.
scatter
(
-
1.5
,
f
(
-
1.5
),
color
=
'red'
);
Ahora miramos a ver qué ocurriría si aumentáramos o disminuyéramos un poco nuestro parámetro: el ajuste. Esto es simplemente la pendiente en un punto concreto:
Podemos cambiar un poco nuestro peso en la dirección de la pendiente, calcular de nuevo nuestra pérdida y el ajuste, y repetir esto unas cuantas veces. Al final, llegaremos al punto más bajo de nuestra curva:
Esta idea básica se remonta a Isaac Newton, que señaló que podemos optimizar funciones arbitrarias de este modo. Independientemente de lo complicadas que se vuelvan nuestras funciones, este planteamiento básico del descenso gradiente no cambiará significativamente. Los únicos cambios menores que veremos más adelante en este libro son algunas formas prácticas de hacerlo más rápido, encontrando mejores pasos.
Calcular gradientes
El único paso mágico es la parte en la que calculamos los gradientes. Como mencionamos en , utilizamos el cálculo como una optimización del rendimiento; nos permite calcular más rápidamente si nuestra pérdida subirá o bajará al ajustar nuestros parámetros hacia arriba o hacia abajo. En otras palabras, los gradientes nos dirán cuánto tenemos que cambiar cada peso para que nuestro modelo sea mejor.
Puede que recuerdes de tus clases de cálculo en el instituto que laderivada de una función te dice cuánto cambiará su resultado un cambio en sus parámetros. Si no es así, no te preocupes; ¡muchos olvidamos el cálculo cuando dejamos atrás el instituto! Pero necesitarás alguna comprensión intuitiva de lo que es una derivada antes de continuar, así que si todo esto está muy borroso en tu cabeza, dirígete a Khan Academy y completa las lecciones sobre derivadas básicas. No tendrás que saber calcularlas por ti mismo; sólo tienes que saber qué es una derivada.
El punto clave sobre la derivada es el siguiente: para cualquier función, como la función cuadrática que vimos en el apartado anterior, podemos calcular su derivada. La derivada es otra función. Calcula el cambio, en lugar del valor. Por ejemplo, la derivada de la función cuadrática en el valor 3 nos dice lo rápido que cambia la función en el valor 3. Más concretamente, recordarás que el gradiente se define como subida/bajada; es decir, el cambio en el valor dela función, dividido por el cambio en el valor del parámetro. Cuando sabemos cómo cambiará nuestra función, sabemos lo que tenemos que hacer para reducirla. Ésta es la clave del aprendizaje automático: disponer de una forma decambiar los parámetros de una función para hacerla más pequeña. El cálculo nos proporciona un atajo computacional, la derivada, que nos permite calcular directamente los gradientes de nuestras funciones.
Una cosa importante que debes tener en cuenta es que nuestra función tiene muchos pesos que debemos ajustar, así que cuando calculemos la derivada, no obtendremos un número, sino muchos, un gradiente para cada peso. Pero no hay nada matemáticamente complicado en esto; puedes calcular la derivada con respecto a un peso y tratar todos los demás como constantes, y luego repetirlo para cada uno de los demás pesos. Así se calculan todos los gradientes, para cada peso.
Acabamos de mencionar que no tendrás que calcular ningún gradiente. ¿Cómo es posible? Sorprendentemente, PyTorch es capaz de calcular automáticamente la derivada de casi cualquier función. Y lo que es más, lo hace muy rápido. La mayoría de las veces, será al menos tan rápida como cualquier función derivada que puedas crear a mano. Veamos un ejemplo.
En primer lugar, elijamos un valor del tensor en el que queramos gradientes:
xt
=
tensor
(
3.
)
.
requires_grad_
()
¿Te has fijado en el método especial requires_grad_
? Es el encantamiento mágico que utilizamos para decirle a PyTorch que queremos calcular gradientes con respecto a esa variable en ese valor. En esencia, se trata de etiquetar la variable, para que PyTorch recuerde cómo calcular los gradientes de los demás cálculos directos sobre ella que le pidas.
Alexis dice
Esta API puede confundirte si vienes de las matemáticas o la física. En esos contextos, el "gradiente" de una función no es más que otra función (es decir, su derivada), por lo que podrías esperar que las API relacionadas con el gradiente te dieran una nueva función. Pero en el aprendizaje profundo, "gradiente" suele significar el valor de la derivada de una función en un valor de argumento concreto. La API de PyTorch también se centra en el argumento, no en la función de la que estás calculando los gradientes. Puede parecer retrógrado al principio, pero es sólo una perspectiva diferente.
Ahora calculamos nuestra función con ese valor. Observa cómo PyTorch imprime no sólo el valor calculado, sino también una nota de que tiene una función gradiente que utilizará para calcular nuestros gradientes cuando sea necesario:
yt
=
f
(
xt
)
yt
tensor(9., grad_fn=<PowBackward0>)
Por último, le decimos a PyTorch que calcule los gradientes por nosotros:
yt
.
backward
()
El "hacia atrás" se refiere aquí a la retropropagación, que es el nombre que da al proceso de calcular la derivada de cada capa. Veremos cómo se hace exactamente en el Capítulo 17, cuando calculemos los gradientes de una red neuronal profunda desde cero. Esto se denomina el paso hacia atrás de la red, a diferencia del paso hacia delante, que es donde se calculan las activaciones. La vida sería probablemente más fácil si backward
se llamara simplemente calculate_grad
, ¡pero a la gente del aprendizaje profundo realmente le gusta añadir jerga siempre que puede!
Ahora podemos ver los gradientes comprobando el atributo grad
de nuestro tensor:
xt
.
grad
tensor(6.)
Si recuerdas tus reglas de cálculo del instituto, la derivada dex**2
es 2*x
, y nosotros tenemos x=3
, así que los gradientes deberían ser 2*3=6
, ¡que es lo que PyTorch ha calculado para nosotros!
Ahora repetiremos los pasos anteriores, pero con un argumento vectorial para nuestra función:
xt
=
tensor
([
3.
,
4.
,
10.
])
.
requires_grad_
()
xt
tensor([ 3., 4., 10.], requires_grad=True)
Y añadiremos sum
a nuestra función para que pueda tomar un vector (es decir, un tensor de rango 1) y devolver un escalar (es decir, un tensor de rango 0):
def
f
(
x
):
return
(
x
**
2
)
.
sum
()
yt
=
f
(
xt
)
yt
tensor(125., grad_fn=<SumBackward0>)
Nuestros degradados son 2*xt
, ¡como era de esperar!
yt
.
backward
()
xt
.
grad
tensor([ 6., 8., 20.])
Los gradientes sólo nos indican la pendiente de nuestra función; no nos dicen exactamente hasta dónde debemos ajustar los parámetros. Pero sí nos dan una idea de hasta dónde: si la pendiente es muy grande, eso puede sugerir que tenemos que hacer más ajustes, mientras que si la pendiente es muy pequeña, eso puede sugerir que estamos cerca del valor óptimo.
Pisar con ritmo de aprendizaje
Decidir cómo cambiar nuestros parámetros en función de los valores de los gradientes es una parte importante del proceso de aprendizaje profundo. Casi todos los enfoques de parten de la idea básica de multiplicar el gradiente por algún número pequeño, llamado tasa de aprendizaje (LR). La tasa de aprendizaje suele ser un número entre 0,001 y 0,1, aunque puede ser cualquier cosa. A menudo la gente selecciona una tasa de aprendizaje simplemente probando unas cuantas, y encontrando cuál da como resultado el mejor modelo después del entrenamiento (más adelante en este libro te mostraremos un método mejor, llamado buscador de tasas de aprendizaje). Una vez que hayas elegido una tasa de aprendizaje, puedes ajustar sus parámetros utilizando esta sencilla función:
w -= w.grad * lr
Esto se conoce como escalonar tus parámetros, utilizando un paso de optimización.
Si eliges un ritmo de aprendizaje demasiado bajo, puede significar tener que hacer muchos pasos. La Figura 4-2 lo ilustra.
Pero elegir una tasa de aprendizaje demasiado alta es aún peor: ¡puede hacer que la pérdida empeore, como vemos enla Figura 4-3!
Si la velocidad de aprendizaje es demasiado alta, también puede "rebotar", en lugar de divergir; la Figura 4-4 muestra cómo esto hace que se necesiten muchos pasos para entrenar con éxito.
Ahora apliquemos todo esto en un ejemplo de extremo a extremo.
Un ejemplo de SGD de extremo a extremo
Hemos visto cómo utilizar gradientes para minimizar nuestra pérdida. Ahora es el momento de ver un ejemplo de SGD y ver cómo se puede utilizar la búsqueda de un mínimo para entrenar un modelo que se ajustemejor a los datos.
Empecemos con un modelo de ejemplo sencillo y sintético. Imagina que midieras la velocidad de una montaña rusa al pasar por encima de una joroba. Empezaría rápido, y luego se volvería más lenta al subir la cuesta; sería más lenta en la cima, y luego volvería a acelerarse al bajar la cuesta. Quieres construir un modelo de cómo cambia la velocidad con el tiempo. Si midieras manualmente la velocidad cada segundo durante 20 segundos, podría tener este aspecto:
time
=
torch
.
arange
(
0
,
20
)
.
float
();
time
tensor([ 0., 1., 2., 3., 4., 5., 6., 7., 8., 9., 10., 11., 12., 13., > 14., 15., 16., 17., 18., 19.])
speed
=
torch
.
randn
(
20
)
*
3
+
0.75
*
(
time
-
9.5
)
**
2
+
1
plt
.
scatter
(
time
,
speed
);
Hemos añadido un poco de ruido aleatorio, ya que medir las cosas manualmente no es preciso. Esto significa que no es tan fácil responder a la pregunta: ¿cuál era la velocidad de la montaña rusa? Utilizando el SGD, podemos intentar encontrar una función que se ajuste a nuestras observaciones. No podemos considerar todas las funciones posibles, así que vamos a suponer que será cuadrática; es decir, una función de la forma a*(time**2)+(b*time)+c
.
Queremos distinguir claramente entre la entrada de la función (el tiempo en el que estamos midiendo la velocidad de la montaña rusa) y sus parámetros (los valores que definen qué cuadrática estamos probando). Así que reunamos los parámetros en un argumento y separemos así la entrada, t
, y los parámetros, params
, en la firma de la función:
def
f
(
t
,
params
):
a
,
b
,
c
=
params
return
a
*
(
t
**
2
)
+
(
b
*
t
)
+
c
En otras palabras, hemos restringido el problema de encontrar la mejor función imaginable que se ajuste a los datos a encontrar la mejor funcióncuadrática. Esto simplifica enormemente el problema, ya que toda función cuadrática está totalmente definida por los tres parámetros a
, b
, y c
. Así, para encontrar la mejor función cuadrática, sólo necesitamos encontrar los mejores valores para a
, b
, y c
.
Si podemos resolver este problema para los tres parámetros de una función cuadrática, podremos aplicar el mismo enfoque para otras funciones más complejas con más parámetros, como una red neuronal. Vamos a encontrar primero los parámetros para f
, y luego volveremos y haremos lo mismo para el conjunto de datos MNIST con una red neuronal.
Primero tenemos que definir qué entendemos por "mejor". Lo definimos con precisión eligiendo una función de pérdida, que devolverá un valor basado en una predicción y un objetivo, donde los valores más bajos de la función corresponden a predicciones "mejores". Para los datos continuos, es habitual utilizar el error cuadrático medio:
def
mse
(
preds
,
targets
):
return
((
preds
-
targets
)
**
2
)
.
mean
()
.
sqrt
()
Ahora, trabajemos en nuestro proceso de siete pasos.
Paso 1: Inicializar los parámetros
En primer lugar, inicializamos los parámetros con valores aleatorios y le decimos a PyTorch que queremos seguir sus gradientes utilizando requires_grad_
:
params
=
torch
.
randn
(
3
)
.
requires_grad_
()
Paso 2: Calcula las predicciones
A continuación, calculamos las predicciones:
preds
=
f
(
time
,
params
)
Creemos una pequeña función para ver lo cerca que están nuestras predicciones de nuestros objetivos, y echemos un vistazo:
def
show_preds
(
preds
,
ax
=
None
):
if
ax
is
None
:
ax
=
plt
.
subplots
()[
1
]
ax
.
scatter
(
time
,
speed
)
ax
.
scatter
(
time
,
to_np
(
preds
),
color
=
'red'
)
ax
.
set_ylim
(
-
300
,
100
)
show_preds
(
preds
)
Esto no se parece mucho: ¡nuestros parámetros aleatorios sugieren que la montaña rusa acabará yendo hacia atrás, ya que tenemos velocidades negativas!
Paso 3: Calcula la pérdida
Calculamos la pérdida del siguiente modo:
loss
=
mse
(
preds
,
speed
)
loss
tensor(25823.8086, grad_fn=<MeanBackward0>)
Nuestro objetivo ahora es mejorar esto. Para ello, necesitaremos conocer los gradientes.
Paso 4: Calcular los gradientes
El siguiente paso es calcular los gradientes, o una aproximación de cómo deben cambiar los parámetros:
loss
.
backward
()
params
.
grad
tensor([-53195.8594, -3419.7146, -253.8908])
params
.
grad
*
1e-5
tensor([-0.5320, -0.0342, -0.0025])
Podemos utilizar estos gradientes para mejorar nuestros parámetros. Tendremos que elegir una tasa de aprendizaje (discutiremos cómo hacerlo en la práctica en el próximo capítulo; por ahora, utilizaremos simplemente 1e-5 o 0,00001):
params
tensor([-0.7658, -0.7506, 1.3525], requires_grad=True)
Paso 5: Escalonar los pesos
Ahora tenemos que actualizar los parámetros basándonos en los gradientes que acabamos de calcular:
lr
=
1e-5
params
.
data
-=
lr
*
params
.
grad
.
data
params
.
grad
=
None
Alexis dice
Comprender este bit depende de recordar la historia reciente. Para calcular los gradientes, llamamos a backward
sobre loss
. Pero este loss
fue calculado a su vez por mse
, que a su vez tomó como entrada preds
, que se calculó utilizando f
tomando como entrada params
, que era el objeto sobre el que originalmente llamamos a required_grads_
-que es la llamada original que ahora nos permite llamar a backward
sobre loss
. Esta cadena de llamadas a funciones representa la composición matemática de funciones, que permite a PyTorch utilizar la regla de la cadena del cálculo para calcular estos gradientes.
Veamos si la pérdida ha mejorado:
preds
=
f
(
time
,
params
)
mse
(
preds
,
speed
)
tensor(5435.5366, grad_fn=<MeanBackward0>)
Y echa un vistazo a la trama:
show_preds
(
preds
)
Necesitamos repetir esto unas cuantas veces, así que crearemos una función para aplicar un paso:
def
apply_step
(
params
,
prn
=
True
):
preds
=
f
(
time
,
params
)
loss
=
mse
(
preds
,
speed
)
loss
.
backward
()
params
.
data
-=
lr
*
params
.
grad
.
data
params
.
grad
=
None
if
prn
:
(
loss
.
item
())
return
preds
Paso 6: Repite el proceso
Ahora iteramos. Haciendo bucles y realizando muchas mejoras, esperamos llegar a un buen resultado:
for
i
in
range
(
10
):
apply_step
(
params
)
5435.53662109375 1577.4495849609375 847.3780517578125 709.22265625 683.0757446289062 678.12451171875 677.1839599609375 677.0025024414062 676.96435546875 676.9537353515625
La pérdida está disminuyendo, ¡tal como esperábamos! Pero mirar sólo estos números de pérdidas oculta el hecho de que cada iteración representa una función cuadrática totalmente distinta que se está probando, en el camino hacia la búsqueda de la mejor función cuadrática posible. Podemos ver este proceso visualmente si, en lugar de imprimir la función de pérdida, trazamos la función en cada paso. Entonces podremos ver cómo la forma se aproxima a la mejor función cuadrática posible para nuestros datos:
_
,
axs
=
plt
.
subplots
(
1
,
4
,
figsize
=
(
12
,
3
))
for
ax
in
axs
:
show_preds
(
apply_step
(
params
,
False
),
ax
)
plt
.
tight_layout
()
Resumiendo el Descenso Gradiente
Ahora que has visto lo que ocurre en cada paso, echemos otro vistazo a nuestra representación gráfica del proceso de descenso de gradiente(Figura 4-5) y hagamos un rápido resumen.
Al principio, los pesos de nuestro modelo pueden ser aleatorios (entrenamiento desde cero) o proceder de un modelo preentrenado(aprendizaje por transferencia). En el primer caso, la salida que obtendremos de nuestras entradas no tendrá nada que ver con lo que queremos, e incluso en el segundo caso, es probable que el modelo preentrenado no sea muy bueno en la tarea específica a la que nos dirigimos. Así que el modelo tendrá que aprender mejores ponderaciones.
Empezamos comparando los resultados que nos da el modelo con nuestros objetivos (tenemos datos etiquetados, así que sabemos qué resultado debe dar el modelo) mediante una función de pérdida, que devuelve un número que queremos hacer lo más bajo posible mejorando nuestros pesos. Para ello, tomamos unos cuantos datos (como imágenes) del conjunto de entrenamiento y los introducimos en nuestro modelo. Comparamos los objetivos correspondientes utilizando nuestra función de pérdida, y la puntuación que obtenemos nos dice lo equivocadas que estaban nuestras predicciones. A continuación, cambiamos un poco los pesos para hacerlo ligeramente mejor.
Para saber cómo cambiar los pesos para que la pérdida sea un poco mejor, utilizamos el cálculo para calcular los gradientes. (¡En realidad, dejamos que PyTorch lo haga por nosotros!) Consideremos una analogía. Imagina que estás perdido en las montañas con tu coche aparcado en el punto más bajo. Para encontrar el camino de vuelta a él, podrías vagar en una dirección aleatoria, pero eso probablemente no serviría de mucho. Como sabes que tu vehículo está en el punto más bajo, sería mejor que fueras cuesta abajo. Dando siempre un paso en la dirección de la pendiente descendente más pronunciada, deberías llegar finalmente a tu destino. Utilizamos la magnitud del gradiente (es decir, la inclinación de la pendiente) para saber el tamaño del paso que debemos dar; en concreto, multiplicamos el gradiente por un número que elegimos llamado tasa de aprendizaje para decidir el tamaño del paso. Luego iteramos hasta que hayamos alcanzado el punto más bajo, que será nuestro aparcamiento; entonces podemos parar.
Todo lo que acabamos de ver puede transponerse directamente al conjunto de datos MNIST, excepto la función de pérdida. Veamos ahora cómo podemos definir un buen objetivo de entrenamiento.
La función de pérdida MNIST
Ya tenemos nuestras x
s, es decir, nuestras variables independientes, las propias imágenes. Vamos a concatenarlas todas en un único tensor, y también a cambiarlas de una lista de matrices (un tensor de rango 3) a una lista de vectores (un tensor de rango 2). Podemos hacerlo utilizando view
, que es un método de PyTorch que cambia la forma de un tensor sin cambiar su contenido. -1
es un parámetro especial de view
que significa "haz este eje tan grande como sea necesario para que quepan todos los datos":
train_x
=
torch
.
cat
([
stacked_threes
,
stacked_sevens
])
.
view
(
-
1
,
28
*
28
)
Necesitamos una etiqueta para cada imagen. Utilizaremos 1
para 3s y 0
para 7s:
train_y
=
tensor
([
1
]
*
len
(
threes
)
+
[
0
]
*
len
(
sevens
))
.
unsqueeze
(
1
)
train_x
.
shape
,
train_y
.
shape
(torch.Size([12396, 784]), torch.Size([12396, 1]))
Un Dataset
en PyTorch debe devolver una tupla de (x,y)
cuando se indexa. Python proporciona una función zip
que, combinada conlist
, proporciona una forma sencilla de obtener esta funcionalidad:
dset
=
list
(
zip
(
train_x
,
train_y
))
x
,
y
=
dset
[
0
]
x
.
shape
,
y
(torch.Size([784]), tensor([1]))
valid_x
=
torch
.
cat
([
valid_3_tens
,
valid_7_tens
])
.
view
(
-
1
,
28
*
28
)
valid_y
=
tensor
([
1
]
*
len
(
valid_3_tens
)
+
[
0
]
*
len
(
valid_7_tens
))
.
unsqueeze
(
1
)
valid_dset
=
list
(
zip
(
valid_x
,
valid_y
))
Ahora necesitamos un peso (inicialmente aleatorio) para cada píxel (éste es el pasoinicializar de nuestro proceso de siete pasos):
def
init_params
(
size
,
std
=
1.0
):
return
(
torch
.
randn
(
size
)
*
std
)
.
requires_grad_
()
weights
=
init_params
((
28
*
28
,
1
))
La función weights*pixels
no será lo suficientemente flexible: siempre es igual a 0 cuando los píxeles son iguales a 0 (es decir, su intercepto es 0). Puede que recuerdes de las matemáticas del instituto que la fórmula para una línea es y=w*x+b
; aún así necesitamos b
. También la inicializaremos con un número aleatorio:
bias
=
init_params
(
1
)
En las redes neuronales, el w
de la ecuación y=w*x+b
se llamapesos, y el b
se llama sesgo. Juntos, los pesos y el sesgo constituyen los parámetros.
Jerga: Parámetros
Los pesos y sesgos de un modelo. Los pesos son los w
de la ecuación w*x+b
, y los sesgos son los b
de esa ecuación.
Ahora podemos calcular una predicción para una imagen:
(
train_x
[
0
]
*
weights
.
T
)
.
sum
()
+
bias
tensor([20.2336], grad_fn=<AddBackward0>)
Aunque podríamos utilizar un bucle Python for
para calcular la predicción decada imagen, eso sería muy lento. Como los bucles de Python no se ejecutan en la GPU, y como Python es un lenguaje lento para los bucles en general, necesitamos representar la mayor parte posible del cálculo en un modelo utilizando funciones de nivel superior.
En este caso, existe una operación matemática muy cómodaque calcula w*x
para cada fila de una matriz: se llama multiplicación de matrices.La Figura 4-6 muestra el aspecto de la multiplicación de matrices.
Esta imagen muestra dos matrices, A
y B
, que se multiplican entre sí. Cada elemento del resultado, que llamaremos AB
, contiene cada elemento de su correspondiente fila de A
multiplicado por cada elemento de su correspondiente columna de B
, sumados. Por ejemplo, la fila 1, columna 2 (el punto amarillo con borde rojo) se calcula como. Si necesitas un repaso sobre lamultiplicación de mat rices, te sugerimos que eches un vistazo a la"Introducción a la multiplicación de matrices" en Khan Academy, ya que se trata de la operación matemática más importante en el aprendizaje profundo.
En Python, la multiplicación de matrices se representa con el operador @
. Vamos a probarlo:
def
linear1
(
xb
):
return
xb
@weights
+
bias
preds
=
linear1
(
train_x
)
preds
tensor([[20.2336], [17.0644], [15.2384], ..., [18.3804], [23.8567], [28.6816]], grad_fn=<AddBackward0>)
El primer elemento es el mismo que calculamos antes, comocabría esperar. Esta ecuación, batch @ weights + bias
, es una de las dos ecuaciones fundamentales de cualquier red neuronal (la otra es la función de activación, que veremos dentro de un momento).
Comprobemos nuestra precisión. Para decidir si una salida representa un 3 o un 7, basta con comprobar si es mayor que 0, de modo que nuestra precisión para cada elemento puede calcularse (utilizando la difusión, ¡así que nada de bucles!) como sigue:
corrects
=
(
preds
>
0.0
)
.
float
()
==
train_y
corrects
tensor([[ True], [ True], [ True], ..., [False], [False], [False]])
corrects
.
float
()
.
mean
()
.
item
()
0.4912068545818329
Ahora veamos cuál es el cambio en la precisión para un pequeño cambio en uno de los pesos:
weights
[
0
]
*=
1.0001
preds
=
linear1
(
train_x
)
((
preds
>
0.0
)
.
float
()
==
train_y
)
.
float
()
.
mean
()
.
item
()
0.4912068545818329
Como hemos visto, necesitamos gradientes para mejorar nuestro modelo utilizando SGD, y para calcular los gradientes necesitamos una función de pérdida que represente lo bueno que es nuestro modelo. Esto se debe a que los gradientes son una medida de cómo cambia esa función de pérdida con pequeños ajustes en los pesos.
Por tanto, tenemos que elegir una función de pérdida. El enfoque obvio sería utilizar también la precisión, que es nuestra métrica, como función de pérdida. En este caso, calcularíamos nuestra predicción para cada imagen, reuniríamos estos valores para calcular una precisión global, y luego calcularíamos los gradientes de cada peso con respecto a esa precisión global.
Desgraciadamente, aquí tenemos un problema técnico importante. El gradiente de una función es su pendiente, o su inclinación, que puede definirse como el aumento sobre la disminución,es decir, cuánto sube o baja el valor de la función, dividido por cuánto cambiamos la entrada. Podemos escribirlo matemáticamente como
(y_new – y_old) / (x_new – x_old)
Esto da una buena aproximación del gradiente cuando x_new
es muy similar a x_old
, lo que significa que su diferencia es muy pequeña. Pero la precisión sólo cambia en absoluto cuando una predicción pasa de un 3 a un 7, o viceversa. El problema es que un pequeño cambio en los pesos de x_old
a x_new
no es probable que haga cambiar ninguna predicción, por lo que (y_new – y_old)
será casi siempre 0. En otras palabras, el gradiente es 0 en casi todas partes.
Un cambio muy pequeño en el valor de un peso a menudo no cambiará en absoluto la precisión. Esto significa que no es útil utilizar la precisión como función de pérdida: si lo hacemos, la mayoría de las veces nuestros gradientes serán 0, y el modelo no podrá aprender a partir de esenúmero.
Sylvain Dice
En términos matemáticos, la precisión es una función que es constante en casi todas partes (excepto en el umbral, 0,5), por lo que su derivada es nula en casi todas partes (e infinita en el umbral). Esto da entonces gradientes que son 0 o infinitos, que son inútiles para actualizar el modelo.
En su lugar, necesitamos una función de pérdida que, cuando nuestros pesos den lugar a predicciones ligeramente mejores, nos proporcione una pérdida ligeramente mejor. ¿Qué significa exactamente una "predicción ligeramente mejor"? Bueno, en este caso, significa que si la respuesta correcta es un 3, la puntuación es un poco más alta, o si la respuesta correcta es un 7, la puntuación es un poco más baja.
Escribamos ahora dicha función. ¿Qué forma tiene?
La función de pérdida no recibe las imágenes en sí, sino las predicciones del modelo. Así que hagamos un argumento, prds
, de valores entre 0 y 1, donde cada valor es la predicción de que una imagen es un 3. Es un vector (es decir, un tensor de rango 1) indexado sobre las imágenes.
El objetivo de la función de pérdida es medir la diferencia entre los valores predichos y los valores verdaderos, es decir, los objetivos (también conocidos como etiquetas). Por tanto, hagamos otro argumento, trgts
, con valores de 0 ó 1, que diga si una imagen es realmente un 3 o no. También es un vector (es decir, otro tensor de rango 1) indexado sobre las imágenes.
Por ejemplo, supongamos que tenemos tres imágenes que sabemos que son un 3, un 7 y un 3. Y supongamos que nuestro modelo predijo con alta confianza (0.9
) que la primera era un 3, con ligera confianza (0.4
) que la segunda era un 7, y con bastante confianza (0.2
), pero incorrectamente, que la última era un 7. Esto significaría que nuestra función de pérdida recibiría estos valores como sus entradas:
trgts
=
tensor
([
1
,
0
,
1
])
prds
=
tensor
([
0.9
,
0.4
,
0.2
])
He aquí un primer intento de función de pérdida que mide la distancia entre predictions
y targets
:
def
mnist_loss
(
predictions
,
targets
):
return
torch
.
where
(
targets
==
1
,
1
-
predictions
,
predictions
)
.
mean
()
Vamos a utilizar una nueva función, torch.where(a,b,c)
. Es lo mismo que ejecutar la comprensión de listas[b[i] if a[i] else c[i] for i in range(len(a))]
, salvo que funciona con tensores, a velocidad C/CUDA. En pocas palabras, esta función medirá lo lejos que está cada predicción de 1 si debe ser 1, y lo lejos que está de 0 si debe ser 0, y luego tomará la media de todas esas distancias.
Lee los documentos
Es importante conocer funciones de PyTorch como ésta, porque los bucles sobre tensores en Python funcionan a la velocidad de Python, ¡no a la velocidad de C/CUDA! Intenta ejecutar ahora help(torch.where)
para leer la documentación de esta función o, mejor aún, búscala en el sitio de documentación de PyTorch.
Vamos a probarlo en nuestro prds
y trgts
:
torch
.
where
(
trgts
==
1
,
1
-
prds
,
prds
)
tensor([0.1000, 0.4000, 0.8000])
Puedes ver que esta función devuelve un número más bajo cuando las predicciones son más exactas, cuando las predicciones exactas tienen más confianza (valores absolutos más altos) y cuando las predicciones inexactas tienen menos confianza. En PyTorch, siempre suponemos que un valor más bajo de una función de pérdida es mejor. Como necesitamos un escalar para la pérdida final, mnist_loss
toma la media del tensor anterior:
mnist_loss
(
prds
,
trgts
)
tensor(0.4333)
Por ejemplo, si cambiamos nuestra predicción para el único objetivo "falso" de 0.2
a 0.8
, la pérdida bajará, lo que indica que ésta es una predicción mejor:
mnist_loss
(
tensor
([
0.9
,
0.4
,
0.8
]),
trgts
)
tensor(0.2333)
Un problema de mnist_loss
tal y como está definido actualmente es que supone que las predicciones están siempre entre 0 y 1. ¡Necesitamos asegurarnos, entonces, de que realmente es así! Resulta que existe una función que hace exactamente eso: echémosle un vistazo.
Sigmoide
La función sigmoid
siempre da como resultado un número entre 0 y 1. Se define comosigue:
def
sigmoid
(
x
):
return
1
/
(
1
+
torch
.
exp
(
-
x
))
PyTorch define una versión acelerada para nosotros, así que realmente no necesitamos la nuestra. Ésta es una función importante en el aprendizaje profundo, ya que a menudo queremos asegurarnos de que los valores están entre 0 y 1. Éste es su aspecto:
plot_function
(
torch
.
sigmoid
,
title
=
'Sigmoid'
,
min
=-
4
,
max
=
4
)
Como puedes ver, toma cualquier valor de entrada, positivo o negativo, y lo convierte en un valor de salida entre 0 y 1. También es una curva suave que sólo sube, lo que facilita al SGD encontrar gradientes significativos.
Actualicemos mnist_loss
para aplicar primero sigmoid
a las entradas:
def
mnist_loss
(
predictions
,
targets
):
predictions
=
predictions
.
sigmoid
()
return
torch
.
where
(
targets
==
1
,
1
-
predictions
,
predictions
)
.
mean
()
Ahora podemos estar seguros de que nuestra función de pérdida funcionará, aunque las predicciones no estén entre 0 y 1. Lo único que hace falta es que una predicción más alta corresponda a una confianza más alta.
Una vez definida una función de pérdida, ahora es un buen momento para recapitular por qué lo hicimos. Después de todo, ya teníamos una métrica, que era la precisión global. Entonces, ¿por qué definimos una pérdida?
La diferencia clave es que la métrica es para impulsar la comprensión humana y la pérdida es para impulsar el aprendizaje automatizado. Para impulsar el aprendizaje automatizado, la pérdida debe ser una función que tenga una derivada significativa. No puede tener grandes secciones planas y grandes saltos, sino que debe ser razonablemente suave. Por eso diseñamos una función de pérdida que respondiera a pequeños cambios en el nivel de confianza. Este requisito significa que a veces no refleja exactamente lo que intentamos conseguir, sino que es más bien un compromiso entre nuestro objetivo real y una función que puede optimizarse utilizando su gradiente. La función de pérdida se calcula para cada elemento de nuestro conjunto de datos, y luego, al final de una época, se promedian todos los valores de pérdida y se informa de la media global de la época.
Las métricas, en cambio, son los números que nos importan. Son los valores que se imprimen al final de cada época y que nos dicen cómo va nuestro modelo. Es importante que aprendamos a centrarnos en estas métricas, y no en la pérdida, cuando juzguemos el rendimiento de un modelo.
SGD y Minilotes
Ahora que tenemos una función de pérdida adecuada para manejar el SGD, podemos considerar algunos de los detalles implicados en la siguiente fase del proceso de aprendizaje, que consiste en cambiar o actualizar los pesos basándose en los gradientes. Esto se denomina paso de optimización.
Para dar un paso de optimización, necesitamos calcular la pérdida sobre uno o más elementos de datos. ¿Cuántos debemos utilizar? Podríamos calcularla para todo el conjunto de datos y sacar la media, o podríamos calcularla para un solo elemento de datos. Pero ninguna de las dos opciones es ideal. Calcularla para todo el conjunto de datos llevaría mucho tiempo. Calcularlo para un único elemento no utilizaría mucha información, por lo que daría como resultado un gradiente impreciso e inestable. Te tomarías la molestia de actualizar las ponderaciones, pero teniendo en cuenta sólo cómo mejoraría el rendimiento del modelo en ese único elemento.
Así que, en lugar de eso, hacemos un compromiso: calculamos la pérdida media de unos pocos datos a la vez. Esto se llamaminilote. El número de datos del minilote se denominatamaño del lote. Un tamaño de lote mayor significa que obtendrás una estimación más precisa y estable de los gradientes de tu conjunto de datos a partir de la función de pérdida, pero te llevará más tiempo y procesarás menos minilotes por época. Elegir un buen tamaño de lote es una de las decisiones que debes tomar como profesional del aprendizaje profundo para entrenar tu modelo con rapidez y precisión. Hablaremos de cómo hacer esta elección a lo largo de este libro.
Otra buena razón para utilizar minilotes en lugar de calcular el gradiente en elementos de datos individuales es que, en la práctica, casi siempre realizamos nuestro entrenamiento en un acelerador como una GPU. Estos aceleradores sólo rinden bien si tienen mucho trabajo que hacer a la vez, por lo que es útil que les demos muchos datos con los que trabajar. Utilizar minilotes es una de las mejores formas de hacerlo. Sin embargo, si les das demasiados datos para trabajar a la vez, se quedan sin memoria: ¡hacer felices a las GPU también es complicado!
Como has visto en nuestra discusión sobre el aumento de datos en el Capítulo 2, conseguimos una mejor generalización si podemos variar cosas durante el entrenamiento. Una cosa sencilla y eficaz que podemos variar es qué elementos de datos ponemos en cada minilote. En lugar de limitarnos a enumerar nuestro conjunto de datos en orden para cada época, lo que hacemos normalmente es barajarlo aleatoriamente en cada época, antes de crear los minilotes. PyTorch y fastai proporcionan una clase que hará el barajado y el cotejo de minilotes por ti, llamada DataLoader
.
Un DataLoader
puede tomar cualquier colección de Python y convertirla en un iterador sobre muchos lotes, así:
coll
=
range
(
15
)
dl
=
DataLoader
(
coll
,
batch_size
=
5
,
shuffle
=
True
)
list
(
dl
)
[tensor([ 3, 12, 8, 10, 2]), tensor([ 9, 4, 7, 14, 5]), tensor([ 1, 13, 0, 6, 11])]
Para entrenar un modelo, no queremos cualquier colección de Python, sino una colección que contenga variables independientes y dependientes (las entradas y los objetivos del modelo). Una colección que contiene tuplas de variables independientes y dependientes se conoce en PyTorch como Dataset
. He aquí un ejemplo de una Dataset
extremadamente sencilla:
ds
=
L
(
enumerate
(
string
.
ascii_lowercase
))
ds
(#26) [(0, 'a'),(1, 'b'),(2, 'c'),(3, 'd'),(4, 'e'),(5, 'f'),(6, 'g'),(7, > 'h'),(8, 'i'),(9, 'j')...]
Cuando pasemos un Dataset
a un DataLoader
obtendremos de vuelta muchos lotes que son a su vez tuplas de tensores que representan lotes devariables independientes y dependientes:
dl
=
DataLoader
(
ds
,
batch_size
=
6
,
shuffle
=
True
)
list
(
dl
)
[(tensor([17, 18, 10, 22, 8, 14]), ('r', 's', 'k', 'w', 'i', 'o')), (tensor([20, 15, 9, 13, 21, 12]), ('u', 'p', 'j', 'n', 'v', 'm')), (tensor([ 7, 25, 6, 5, 11, 23]), ('h', 'z', 'g', 'f', 'l', 'x')), (tensor([ 1, 3, 0, 24, 19, 16]), ('b', 'd', 'a', 'y', 't', 'q')), (tensor([2, 4]), ('c', 'e'))]
¡Ya estamos listos para escribir nuestro primer bucle de entrenamiento para un modelo utilizando SGD!
Ponerlo todo junto
Es hora de implementar el proceso que vimos enla Figura 4-1. En código, nuestro proceso se implementará algo así para cada época:
for
x
,
y
in
dl
:
pred
=
model
(
x
)
loss
=
loss_func
(
pred
,
y
)
loss
.
backward
()
parameters
-=
parameters
.
grad
*
lr
En primer lugar, vamos a reinicializar nuestros parámetros:
weights
=
init_params
((
28
*
28
,
1
))
bias
=
init_params
(
1
)
Se puede crear un DataLoader
a partir de un Dataset
:
dl
=
DataLoader
(
dset
,
batch_size
=
256
)
xb
,
yb
=
first
(
dl
)
xb
.
shape
,
yb
.
shape
(torch.Size([256, 784]), torch.Size([256, 1]))
Haremos lo mismo con el conjunto de validación:
valid_dl
=
DataLoader
(
valid_dset
,
batch_size
=
256
)
Vamos a crear un minilote de tamaño 4 para probar:
batch
=
train_x
[:
4
]
batch
.
shape
torch.Size([4, 784])
preds
=
linear1
(
batch
)
preds
tensor([[-11.1002], [ 5.9263], [ 9.9627], [ -8.1484]], grad_fn=<AddBackward0>)
loss
=
mnist_loss
(
preds
,
train_y
[:
4
])
loss
tensor(0.5006, grad_fn=<MeanBackward0>)
Ahora podemos calcular los gradientes:
loss
.
backward
()
weights
.
grad
.
shape
,
weights
.
grad
.
mean
(),
bias
.
grad
(torch.Size([784, 1]), tensor(-0.0001), tensor([-0.0008]))
Pongamos todo eso en una función:
def
calc_grad
(
xb
,
yb
,
model
):
preds
=
model
(
xb
)
loss
=
mnist_loss
(
preds
,
yb
)
loss
.
backward
()
Y ponlo a prueba:
calc_grad
(
batch
,
train_y
[:
4
],
linear1
)
weights
.
grad
.
mean
(),
bias
.
grad
(tensor(-0.0002), tensor([-0.0015]))
Pero mira lo que pasa si lo llamamos dos veces:
calc_grad
(
batch
,
train_y
[:
4
],
linear1
)
weights
.
grad
.
mean
(),
bias
.
grad
(tensor(-0.0003), tensor([-0.0023]))
¡Los degradados han cambiado! Esto se debe a que loss.backward
añade los degradados de loss
a los degradados almacenados actualmente. Así que primero tenemos que poner a 0 los gradientes actuales:
weights
.
grad
.
zero_
()
bias
.
grad
.
zero_
();
Operaciones in situ
Los métodos de PyTorch cuyos nombres terminan en guión bajo modifican sus objetos en su lugar. Por ejemplo, bias.zero_
pone a 0 todos los elementos del tensor bias
.
Nuestro único paso restante es actualizar los pesos y sesgos basándonos en el gradiente y la tasa de aprendizaje. Cuando lo hagamos, tenemos que decirle a PyTorch que no tome también el gradiente de este paso -¡de lo contrario, las cosas se volverán confusas cuando intentemos calcular la derivada en la siguiente tanda! Si asignamos al atributo data
de un tensor, PyTorch no tomará el gradiente de ese paso. Éste es nuestro bucle de entrenamiento básico para una época:
def
train_epoch
(
model
,
lr
,
params
):
for
xb
,
yb
in
dl
:
calc_grad
(
xb
,
yb
,
model
)
for
p
in
params
:
p
.
data
-=
p
.
grad
*
lr
p
.
grad
.
zero_
()
También queremos comprobar cómo lo estamos haciendo, mirando la precisión del conjunto de validación. Para decidir si una salida representa un 3 o un 7, basta con comprobar si es mayor que 0,5. Así que nuestra precisión para cada elemento puede calcularse (utilizando la difusión, ¡así que nada de bucles!) de la siguiente manera:
(
preds
>
0.5
)
.
float
()
==
train_y
[:
4
]
tensor([[False], [ True], [ True], [False]])
Eso nos da esta función para calcular nuestra precisión de validación:
def
batch_accuracy
(
xb
,
yb
):
preds
=
xb
.
sigmoid
()
correct
=
(
preds
>
0.5
)
==
yb
return
correct
.
float
()
.
mean
()
Podemos comprobar que funciona:
batch_accuracy
(
linear1
(
batch
),
train_y
[:
4
])
tensor(0.5000)
Y luego junta los lotes:
def
validate_epoch
(
model
):
accs
=
[
batch_accuracy
(
model
(
xb
),
yb
)
for
xb
,
yb
in
valid_dl
]
return
round
(
torch
.
stack
(
accs
)
.
mean
()
.
item
(),
4
)
validate_epoch
(
linear1
)
0.5219
Ese es nuestro punto de partida. Entrenemos durante una época y veamos si mejora la precisión:
lr
=
1.
params
=
weights
,
bias
train_epoch
(
linear1
,
lr
,
params
)
validate_epoch
(
linear1
)
0.6883
Luego haz unas cuantas más:
for
i
in
range
(
20
):
train_epoch
(
linear1
,
lr
,
params
)
(
validate_epoch
(
linear1
),
end
=
' '
)
0.8314 0.9017 0.9227 0.9349 0.9438 0.9501 0.9535 0.9564 0.9594 0.9618 0.9613 > 0.9638 0.9643 0.9652 0.9662 0.9677 0.9687 0.9691 0.9691 0.9696
¡Tiene buena pinta! Ya estamos más o menos en la misma precisión que con nuestro enfoque de "similitud de píxeles", y hemos creado una base de uso general sobre la que podemos construir. Nuestro siguiente paso será crear un objeto que se encargue del paso SGD por nosotros. En PyTorch, se llama optimizador.
Crear un optimizador
Como se trata de una base tan general, PyTorch proporciona algunas clases útiles para facilitar su implementación. Lo primero que podemos hacer es sustituir nuestra función linear1
por el módulonn.Linear
de PyTorch. Un módulo es un objeto de una clase que hereda de la clase nn.Module
de PyTorch. Los objetos de esta clase se comportan de forma idéntica a las funciones estándar de Python, en el sentido de que puedes llamarlos utilizando paréntesis, y devolverán las activaciones de un modelo.
nn.Linear
hace lo mismo que nuestro init_params
y linear
juntos. Contiene tanto los pesos como los sesgos en una sola clase. Así es como reproducimos nuestro modelo de la sección anterior:
linear_model
=
nn
.
Linear
(
28
*
28
,
1
)
Cada módulo PyTorch sabe qué parámetros tiene que se pueden entrenar; están disponibles a través del método parameters
:
w
,
b
=
linear_model
.
parameters
()
w
.
shape
,
b
.
shape
(torch.Size([1, 784]), torch.Size([1]))
Podemos utilizar esta información para crear un optimizador:
class
BasicOptim
:
def
__init__
(
self
,
params
,
lr
):
self
.
params
,
self
.
lr
=
list
(
params
),
lr
def
step
(
self
,
*
args
,
**
kwargs
):
for
p
in
self
.
params
:
p
.
data
-=
p
.
grad
.
data
*
self
.
lr
def
zero_grad
(
self
,
*
args
,
**
kwargs
):
for
p
in
self
.
params
:
p
.
grad
=
None
Podemos crear nuestro optimizador pasando los parámetros del modelo:
opt
=
BasicOptim
(
linear_model
.
parameters
(),
lr
)
Ahora podemos simplificar nuestro bucle de entrenamiento:
def
train_epoch
(
model
):
for
xb
,
yb
in
dl
:
calc_grad
(
xb
,
yb
,
model
)
opt
.
step
()
opt
.
zero_grad
()
Nuestra función de validación no tiene que cambiar en absoluto:
validate_epoch
(
linear_model
)
0.4157
Pongamos nuestro pequeño bucle de entrenamiento en una función, para simplificar las cosas:
def
train_model
(
model
,
epochs
):
for
i
in
range
(
epochs
):
train_epoch
(
model
)
(
validate_epoch
(
model
),
end
=
' '
)
Los resultados son los mismos que en la sección anterior:
train_model
(
linear_model
,
20
)
0.4932 0.8618 0.8203 0.9102 0.9331 0.9468 0.9555 0.9629 0.9658 0.9673 0.9687 > 0.9707 0.9726 0.9751 0.9761 0.9761 0.9775 0.978 0.9785 0.9785
fastai proporciona la clase SGD
que, por defecto, hace lo mismoque nuestroBasicOptim
:
linear_model
=
nn
.
Linear
(
28
*
28
,
1
)
opt
=
SGD
(
linear_model
.
parameters
(),
lr
)
train_model
(
linear_model
,
20
)
0.4932 0.852 0.8335 0.9116 0.9326 0.9473 0.9555 0.9624 0.9648 0.9668 0.9692 > 0.9712 0.9731 0.9746 0.9761 0.9765 0.9775 0.978 0.9785 0.9785
fastai también proporciona Learner.fit
, que podemos utilizar en lugar detrain_model
. Para crear un Learner
, primero tenemos que crear unDataLoaders
, pasando nuestros DataLoader
s de entrenamiento y validación:
dls
=
DataLoaders
(
dl
,
valid_dl
)
Para crear un Learner
sin utilizar una aplicación (comocnn_learner
), necesitamos pasar todos los elementos que hemos creado en este capítulo: el DataLoaders
, el modelo, la función de optimización (a la que se le pasarán los parámetros), la función de pérdida y, opcionalmente, cualquier métrica que se quiera imprimir:
learn
=
Learner
(
dls
,
nn
.
Linear
(
28
*
28
,
1
),
opt_func
=
SGD
,
loss_func
=
mnist_loss
,
metrics
=
batch_accuracy
)
Ahora podemos llamar a fit
:
learn
.
fit
(
10
,
lr
=
lr
)
época | tren_pérdida | pérdida_válida | precisión_lote | tiempo |
---|---|---|---|---|
0 | 0.636857 | 0.503549 | 0.495584 | 00:00 |
1 | 0.545725 | 0.170281 | 0.866045 | 00:00 |
2 | 0.199223 | 0.184893 | 0.831207 | 00:00 |
3 | 0.086580 | 0.107836 | 0.911187 | 00:00 |
4 | 0.045185 | 0.078481 | 0.932777 | 00:00 |
5 | 0.029108 | 0.062792 | 0.946516 | 00:00 |
6 | 0.022560 | 0.053017 | 0.955348 | 00:00 |
7 | 0.019687 | 0.046500 | 0.962218 | 00:00 |
8 | 0.018252 | 0.041929 | 0.965162 | 00:00 |
9 | 0.017402 | 0.038573 | 0.967615 | 00:00 |
Como puedes ver, no hay nada mágico en las clases PyTorch y fastai. Sólo son prácticas piezas preempaquetadas que te facilitan un poco la vida. (También proporcionan mucha funcionalidad extra que utilizaremos en futuros capítulos).
Con estas clases, ya podemos sustituir nuestro modelo lineal por una red neuronal.
Añadir una no linealidad
Hasta ahora, tenemos un procedimiento general para optimizar los parámetros de una función, y lo hemos probado con una función aburrida: un simple clasificador lineal. Un clasificador lineal está limitado en cuanto a lo que puede hacer. Para hacerlo un poco más complejo (y capaz de manejar tareas más ), tenemos que añadir algo no lineal (es decir, distinto de ax+b) entre dos clasificadores lineales: esto es lo que nos da una red neuronal.
Aquí tienes la definición completa de una red neuronal básica:
def
simple_net
(
xb
):
res
=
xb
@w1
+
b1
res
=
res
.
max
(
tensor
(
0.0
))
res
=
res
@w2
+
b2
return
res
Ya está. Todo lo que tenemos en simple_net
son dos clasificadores lineales con una función max
entre ellos.
Aquí, w1
y w2
son tensores de peso, y b1
y b2
son tensores de sesgo; es decir, parámetros que se inicializan inicialmente de forma aleatoria, tal y como hicimos en el apartado anterior:
w1
=
init_params
((
28
*
28
,
30
))
b1
=
init_params
(
30
)
w2
=
init_params
((
30
,
1
))
b2
=
init_params
(
1
)
El punto clave es que w1
tiene 30 activaciones de salida (lo que significa que w2
debe tener 30 activaciones de entrada, para que coincidan). Eso significa que la primera capa puede construir 30 características diferentes, cada una de las cuales representa una mezcla distinta de píxeles. Puedes cambiar ese 30
por lo que quieras, para que el modelo sea más o menos complejo.
Esa pequeña función res.max(tensor(0.0))
se llama unidad lineal rectificada, también conocida como ReLU. Creemos que todos estamos de acuerdo en queunidad lineal rectificada suena bastante extravagante y complicado... Pero en realidad, no hay nada más queres.max(tensor(0.0))
-en otras palabras, sustituir cada número negativo por un cero. Esta diminuta función también está disponible en PyTorch comoF.relu
:
plot_function
(
F
.
relu
)
Jeremy dice
Hay una enorme cantidad de jerga en el aprendizaje profundo, incluidos términos de como unidad lineal rectificada. La gran mayoría de esta jerga no es más complicada de lo que puede implementarse en una breve línea de código, como vimos en este ejemplo. La realidad es que para que los académicos consigan publicar sus artículos, necesitan que suenen lo más impresionantes y sofisticados posible. Una forma de hacerlo es introducir jerga. Por desgracia, esto hace que el campo se vuelva mucho más intimidatorio y difícil de entrar de lo que debería ser. Tienes que aprender la jerga, porque de lo contrario los documentos y tutoriales no van a significar gran cosa para ti. Pero eso no significa que la jerga deba intimidarte. Sólo recuerda que, cuando te encuentres con una palabra o frase que no hayas visto antes, casi con toda seguridad se referirá a un concepto muy sencillo.
La idea básica es que, utilizando más capas lineales, podemos hacer que nuestro modelorealice más cálculos y, por tanto, modele funciones más complejas. Pero no tiene sentido poner una capa lineal directamente detrás de otra, porque cuando multiplicamos cosas y luego las sumamos varias veces, ¡eso podría sustituirse por multiplicar cosas diferentes y sumarlas una sola vez! Es decir, una serie de cualquier número de capas lineales seguidas puede sustituirse por una sola capa lineal con un conjunto diferente de parámetros.
Pero si ponemos una función no lineal entre ellas, como max
, esteya no es cierto. Ahora cada capa lineal está algo desacoplada de las demás y puede hacer su propio trabajo útil. La función max
es especialmente interesante, porque funciona como una simple declaración if
.
Sylvain Dice
Matemáticamente, decimos que la composición de dos funciones lineales es otra función lineal. Por tanto, podemos apilar tantos clasificadores lineales como queramos unos sobre otros, y sin funciones no lineales entre ellos, será lo mismo que un clasificador lineal.
Sorprendentemente, se puede demostrar matemáticamente que esta pequeña función puede resolver cualquier problema computable con un nivel de precisión arbitrariamente alto, si puedes encontrar los parámetros adecuados para w1
y w2
y si haces que estas matrices sean lo suficientemente grandes. Para cualquier función arbitrariamente ondulante, podemos aproximarla como un montón de líneas unidas; para que se parezca más a la función ondulante, sólo tenemos que utilizar líneas más cortas. Esto se conoce como teorema de aproximación universal. Las tres líneas de código que tenemos aquí se conocen como capas. La primera y la tercera se conocen como capas lineales, y la segunda línea de código se conoce como no linealidad ofunción de activación.
Al igual que en la sección anterior, podemos sustituir este código por algo un poco más sencillo aprovechando PyTorch:
simple_net
=
nn
.
Sequential
(
nn
.
Linear
(
28
*
28
,
30
),
nn
.
ReLU
(),
nn
.
Linear
(
30
,
1
)
)
nn.Sequential
crea un módulo que llamará sucesivamente a cada una de las capas o funciones enumeradas.
nn.ReLU
es un módulo de PyTorch que hace exactamente lo mismo que la función F.relu
. La mayoría de las funciones que pueden aparecer en un modelo también tienen formas idénticas que son módulos. Generalmente, sólo se trata de sustituir F
por nn
y cambiar las mayúsculas. Cuando utilicemos nn.Sequential
, PyTorch nos pedirá que utilicemos la versión del módulo. Como los módulos son clases, tenemos que instanciarlos, por eso ves nn.ReLU
en esteejemplo.
Como nn.Sequential
es un módulo, podemos obtener sus parámetros, lo que nos devolverá una lista de todos los parámetros de todos los módulos que contiene. ¡Vamos a probarlo! Como se trata de un modelo más profundo, utilizaremos una tasa de aprendizaje más baja y algunas épocas más:
learn
=
Learner
(
dls
,
simple_net
,
opt_func
=
SGD
,
loss_func
=
mnist_loss
,
metrics
=
batch_accuracy
)
learn
.
fit
(
40
,
0.1
)
No mostramos aquí las 40 líneas de resultados para ahorrar espacio; el proceso de entrenamiento se registra en learn.recorder
, con la tabla de resultados almacenada en el atributo values
, para que podamos trazar la precisión a lo largo del entrenamiento:
plt
.
plot
(
L
(
learn
.
recorder
.
values
)
.
itemgot
(
2
));
Y podemos ver la precisión final:
learn
.
recorder
.
values
[
-
1
][
2
]
0.982826292514801
Llegados a este punto, tenemos algo que es bastante mágico:
-
Una función que puede resolver cualquier problema con cualquier nivel de precisión (la red neuronal) dado el conjunto correcto de parámetros
-
Una forma de encontrar el mejor conjunto de parámetros para cualquier función (descenso por gradiente estocástico)
Por eso el aprendizaje profundo puede hacer cosas tan fantásticas. Creer que esta combinación de técnicas sencillas puede resolver realmente cualquier problema es uno de los mayores pasos que muchos estudiantes tienen que dar. Parece demasiado bueno para ser verdad: seguro que las cosas deberían ser más difíciles y complicadas que esto... Nuestra recomendación: ¡pruébalo! Acabamos de probarlo con el conjunto de datos MNIST, y ya has visto los resultados. Y como nosotros mismos lo hacemos todo desde cero (excepto calcular los gradientes), sabes que no hay ninguna magia especial escondida entre bastidores.
Profundizar
No es necesario detenerse en sólo dos capas lineales. Podemos añadir tantas como queramos, siempre que añadamos una no linealidad entre cada par de capas lineales. Sin embargo, como aprenderás, cuanto más profundo sea el modelo, más difícil será optimizar los parámetros en la práctica. Más adelante en este libro, conocerás algunas técnicas sencillas pero brillantemente eficaces para entrenar modelos más profundos.
Ya sabemos que una sola no linealidad con dos capas lineales es suficiente para aproximar cualquier función. Entonces, ¿por qué utilizar modelos más profundos? La razón es el rendimiento. Con un modelo más profundo (uno con más capas), no necesitamos utilizar tantos parámetros; resulta que podemos utilizar matrices más pequeñas, con más capas, y obtener mejores resultados que los que obtendríamos con matrices más grandes y pocas capas.
Eso significa que podemos entrenar el modelo más rápidamente, y que ocuparámenos memoria. En los años 90, los investigadores estaban tan centrados en el teorema de aproximación universal que pocos experimentaban con más de una no linealidad. Esta base teórica, pero no práctica, frenó el campo durante años. Sin embargo, algunos investigadores sí experimentaron con modelos profundos, y finalmente pudieron demostrar que estos modelos podían funcionar mucho mejor en la práctica. Con el tiempo, se desarrollaron resultados teóricos que mostraban por qué ocurre esto. Hoy en día, es extremadamente inusual encontrar a alguien que utilice una red neuronal con una sola no linealidad.
Esto es lo que ocurre cuando entrenamos un modelo de 18 capas utilizando el mismo enfoque que vimos en el Capítulo 1:
dls
=
ImageDataLoaders
.
from_folder
(
path
)
learn
=
cnn_learner
(
dls
,
resnet18
,
pretrained
=
False
,
loss_func
=
F
.
cross_entropy
,
metrics
=
accuracy
)
learn
.
fit_one_cycle
(
1
,
0.1
)
época | tren_pérdida | pérdida_válida | precisión | tiempo |
---|---|---|---|---|
0 | 0.082089 | 0.009578 | 0.997056 | 00:11 |
¡Casi un 100% de precisión! Es una gran diferencia en comparación con nuestra sencilla red neuronal. Pero como aprenderás en el resto de este libro, sólo hay unos pequeños trucos que debes utilizar para obtener tú mismo resultados tan buenos desde cero. Ya conoces las piezas fundamentales. (Por supuesto, incluso cuando conozcas todos los trucos, casi siempre querrás trabajar con las clases preconstruidas proporcionadas por PyTorch y fastai, porque te ahorran tener que pensar tú mismo en todos los pequeños detalles).
Recapitulación de la jerga
Enhorabuena: ¡ya sabes cómo crear y entrenar una red neuronal profunda desde cero! Hemos seguido bastantes pasos para llegar a este punto, pero puede que te sorprenda lo sencillo que es en realidad.
Ahora que estamos en este punto, es una buena oportunidad para definir, y repasar, algunas jergas y conceptos clave.
Una red neuronal contiene muchos números, pero sólo son de dos tipos: los números que se calculan y los parámetros a partir de los cuales se calculan esos números. Esto nos da las dos piezas más importantes de la jerga que hay que aprender:
- Activaciones
-
Números que se calculan (tanto por capas lineales como no lineales)
- Parámetros
-
Números inicializados aleatoriamente y optimizados (es decir, los números que definen el modelo)
En este libro hablaremos a menudo de activaciones y parámetros. Recuerda que tienen significados específicos. Son números. No son conceptos abstractos, sino que son números específicos reales que están en tu modelo. Parte de convertirse en un buen profesional del aprendizaje profundo es acostumbrarse a la idea de mirar tus activaciones y parámetros, y trazarlos y comprobar si se comportan correctamente.
Nuestras activaciones y parámetros están contenidos en tensores. Éstos sonsimplemente matrices de forma regular; por ejemplo, una matriz. Las matrices tienen filas y columnas; las llamamos ejes o dimensiones. El número de dimensiones de un tensor es su rango. Hay algunos tensores especiales:
-
Rango-0: escalar
-
Rango-1: vector
-
Rango-2: matriz
Una red neuronal contiene varias capas. Cada capa puede serlineal o no lineal. Por lo general, en una red neuronal alternamos entre estos dos tipos de capas. A veces la gente se refiere a una capa lineal y a su posterior no linealidad juntas como una sola capa. Sí, esto es confuso. A veces, una no linealidad se denominafunción de activación.
La Tabla 4-1 resume los conceptos clave relacionados con el SGD.
Recordatorio"Elige tu propia aventura
¿Decidiste saltarte los Capítulos 2 y 3 en tu entusiasmo por echar un vistazo bajo el capó? Pues aquí tienes un recordatorio para que vuelvas alCapítulo 2, ¡porque pronto necesitarás saberlo!
Cuestionario
-
¿Cómo se representa una imagen en escala de grises en un ordenador? ¿Y una imagen en color?
-
¿Cómo están estructurados los archivos y carpetas del conjunto de datos
MNIST_SAMPLE
? ¿Por qué? -
Explica cómo funciona el método de "similitud de píxeles" para clasificar dígitos.
-
¿Qué es una comprensión de lista? Crea ahora una que seleccione números impares de una lista y los duplique.
-
¿Qué es un tensor de rango 3?
-
¿Cuál es la diferencia entre el rango del tensor y la forma? ¿Cómo se obtiene el rango a partir de la forma?
-
¿Qué son el RMSE y la norma L1?
-
¿Cómo puedes aplicar un cálculo sobre miles de números a la vez, muchos miles de veces más rápido que un bucle de Python?
-
Crea un tensor o matriz de 3×3 que contenga los números del 1 al 9. Duplícalo. Selecciona los cuatro números de abajo a la derecha.
-
¿Qué es la radiodifusión?
-
¿Las métricas se calculan generalmente utilizando el conjunto de entrenamiento o el conjunto de validación? ¿Por qué?
-
¿Qué es la SGD?
-
¿Por qué el SGD utiliza minilotes?
-
¿Cuáles son los siete pasos del SGD para el aprendizaje automático?
-
¿Cómo inicializamos los pesos en un modelo?
-
¿Qué es la pérdida?
-
¿Por qué no podemos utilizar siempre una tasa de aprendizaje alta?
-
¿Qué es un gradiente?
-
¿Necesitas saber cómo calcular tú mismo los gradientes?
-
¿Por qué no podemos utilizar la precisión como función de pérdida?
-
Dibuja la función sigmoidea. ¿Qué tiene de especial su forma?
-
¿Cuál es la diferencia entre una función de pérdida y una métrica?
-
¿Cuál es la función para calcular nuevos pesos utilizando una tasa de aprendizaje?
-
¿Qué hace la clase
DataLoader
? -
Escribe un pseudocódigo que muestre los pasos básicos que se dan en cada época del SGD.
-
Crea una función que, si se le pasan dos argumentos
[1,2,3,4]
y'abcd'
, devuelva[(1, 'a'), (2, 'b'), (3, 'c'), (4, 'd')]
. ¿Qué tiene de especial esa estructura de datos de salida? -
¿Qué hace
view
en PyTorch? -
¿Qué son los parámetros de sesgo en una red neuronal? ¿Por qué los necesitamos?
-
¿Qué hace el operador
@
en Python? -
¿Qué hace el método
backward
? -
¿Por qué tenemos que poner a cero los gradientes?
-
¿Qué información tenemos que pasar a
Learner
? -
Muestra en Python o en pseudocódigo los pasos básicos de un bucle de entrenamiento.
-
¿Qué es ReLU? Dibújala para valores comprendidos entre
-2
y+2
. -
¿Qué es una función de activación?
-
¿Cuál es la diferencia entre
F.relu
ynn.ReLU
? -
El teorema de aproximación universal demuestra que cualquier función puede aproximarse tanto como sea necesario utilizando una sola no linealidad. Entonces, ¿por qué solemos utilizar más?
Investigación adicional
-
Crea tu propia implementación de
Learner
desde cero, basándote en el bucle de entrenamiento que se muestra en este capítulo. -
Completa todos los pasos de este capítulo utilizando los conjuntos de datos MNIST completos (para todos los dígitos, no sólo 3s y 7s). Se trata de un proyecto importante y te llevará bastante tiempo completarlo. Tendrás que investigar por tu cuenta para averiguar cómo superar los obstáculos que encontrarás en el camino.
Get Aprendizaje profundo para programadores con fastai y PyTorch now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.