Para instalar Pytorch, siga las instrucciones en el sitio web oficial:
pip install torch torchvision
Nuestro objetivo es expandir gradualmente esta serie agregando nuevos artículos y mantener el contenido actualizado con los últimos lanzamientos de la API de Pytorch. Si tiene sugerencias sobre cómo mejorar esta serie o encontrar las explicaciones ambiguas, no dude en crear un problema, enviar parches o comunicarse por correo electrónico.
Pytorch es una de las bibliotecas más populares para el cálculo numérico y actualmente se encuentra entre las bibliotecas más utilizadas para realizar investigaciones de aprendizaje automático. En muchos sentidos, Pytorch es similar a Numpy, con el beneficio adicional de que Pytorch le permite realizar sus cálculos en CPU, GPU y TPU sin ningún cambio de material en su código. Pytorch también facilita la distribución de su cálculo en múltiples dispositivos o máquinas. Una de las características más importantes de Pytorch es la diferenciación automática. Permite calcular los gradientes de sus funciones analíticamente de manera eficiente, lo cual es crucial para capacitar a los modelos de aprendizaje automático que utilizan el método de descenso de gradiente. Nuestro objetivo aquí es proporcionar una introducción suave a Pytorch y discutir las mejores prácticas para usar Pytorch.
Lo primero que debe aprender sobre Pytorch es el concepto de tensores. Los tensores son simplemente matrices multidimensionales. Un tensor de pytorch es muy similar a una matriz numpy con algunos mágico funcionalidad adicional.
Un tensor puede almacenar un valor escalar:
import torch
a = torch . tensor ( 3 )
print ( a ) # tensor(3)o una matriz:
b = torch . tensor ([ 1 , 2 ])
print ( b ) # tensor([1, 2])una matriz:
c = torch . zeros ([ 2 , 2 ])
print ( c ) # tensor([[0., 0.], [0., 0.]])o cualquier tensor dimensional arbitrario:
d = torch . rand ([ 2 , 2 , 2 ])Los tensores se pueden usar para realizar operaciones algebraicas de manera eficiente. Una de las operaciones más utilizadas en aplicaciones de aprendizaje automático es la multiplicación de matriz. Digamos que desea multiplicar dos matrices aleatorias de tamaño 3x5 y 5x4, esto se puede hacer con la operación de multiplicación de matriz (@):
import torch
x = torch . randn ([ 3 , 5 ])
y = torch . randn ([ 5 , 4 ])
z = x @ y
print ( z )Del mismo modo, para agregar dos vectores, puede hacer:
z = x + yPara convertir un tensor en una matriz Numpy, puede llamar al método Numpy () de Tensor:
print ( z . numpy ())Y siempre puedes convertir una matriz numpy en un tensor por:
x = torch . tensor ( np . random . normal ([ 3 , 5 ]))La ventaja más importante de Pytorch sobre Numpy es su funcionalidad de diferenciación automática, que es muy útil en aplicaciones de optimización, como la optimización de los parámetros de una red neuronal. Intentemos entenderlo con un ejemplo.
Digamos que tiene una función compuesta que es una cadena de dos funciones: g(u(x)) . Para calcular la derivada de g con respecto a x podemos usar la regla de cadena que establece que: dg/dx = dg/du * du/dx . Pytorch puede calcular analíticamente los derivados para nosotros.
Para calcular las derivadas en Pytorch primero, creamos un tensor y establecemos su requires_grad a verdadero. Podemos usar operaciones tensoras para definir nuestras funciones. Suponemos que u es una función cuadrática y g es una función lineal simple:
x = torch . tensor ( 1.0 , requires_grad = True )
def u ( x ):
return x * x
def g ( u ):
return - u En este caso, nuestra función compuesta es g(u(x)) = -x*x . Entonces su derivada con respecto a x es -2x . En el punto x=1 , esto es igual a -2 .
Verifiquemos esto. Esto se puede hacer utilizando la función Grad en Pytorch:
dgdx = torch . autograd . grad ( g ( u ( x )), x )[ 0 ]
print ( dgdx ) # tensor(-2.) Para comprender cómo se puede ver la poderosa diferenciación automática, echemos un vistazo a otro ejemplo. Suponga que tenemos muestras de una curva (digamos f(x) = 5x^2 + 3 ) y queremos estimar f(x) en función de estas muestras. Definimos una función paramétrica g(x, w) = w0 x^2 + w1 x + w2 , que es una función de la entrada x y los parámetros latentes w , nuestro objetivo es encontrar los parámetros latentes como g(x, w) ≈ f(x) . Esto se puede hacer minimizando la siguiente función de pérdida: L(w) = Σ (f(x) - g(x, w))^2 . Aunque hay una solución de forma cerrada para este simple problema, optamos por usar un enfoque más general que se puede aplicar a cualquier función diferenciable arbitraria, y que utiliza descenso de gradiente estocástico. Simplemente calculamos el gradiente promedio de L(w) con respecto a w sobre un conjunto de puntos de muestra y nos movemos en la dirección opuesta.
Así es como se puede hacer en Pytorch:
import numpy as np
import torch
# Assuming we know that the desired function is a polynomial of 2nd degree, we
# allocate a vector of size 3 to hold the coefficients and initialize it with
# random noise.
w = torch . tensor ( torch . randn ([ 3 , 1 ]), requires_grad = True )
# We use the Adam optimizer with learning rate set to 0.1 to minimize the loss.
opt = torch . optim . Adam ([ w ], 0.1 )
def model ( x ):
# We define yhat to be our estimate of y.
f = torch . stack ([ x * x , x , torch . ones_like ( x )], 1 )
yhat = torch . squeeze ( f @ w , 1 )
return yhat
def compute_loss ( y , yhat ):
# The loss is defined to be the mean squared error distance between our
# estimate of y and its true value.
loss = torch . nn . functional . mse_loss ( yhat , y )
return loss
def generate_data ():
# Generate some training data based on the true function
x = torch . rand ( 100 ) * 20 - 10
y = 5 * x * x + 3
return x , y
def train_step ():
x , y = generate_data ()
yhat = model ( x )
loss = compute_loss ( y , yhat )
opt . zero_grad ()
loss . backward ()
opt . step ()
for _ in range ( 1000 ):
train_step ()
print ( w . detach (). numpy ())Al ejecutar este código, debería ver un resultado cercano a esto:
[ 4.9924135 , 0.00040895029 , 3.4504161 ]Que es una aproximación relativamente cercana a nuestros parámetros.
Esto es solo la punta del iceberg para lo que Pytorch puede hacer. Muchos problemas, como la optimización de grandes redes neuronales con millones de parámetros, se pueden implementar de manera eficiente en Pytorch en solo unas pocas líneas de código. Pytorch se encarga de escalar en múltiples dispositivos e hilos, y admite una variedad de plataformas.
En el ejemplo anterior utilizamos tensores de huesos desnudos y operaciones de tensor para construir nuestro modelo. Para hacer que su código sea un poco más organizado, se recomienda usar los módulos de Pytorch. Un módulo es simplemente un contenedor para sus parámetros y encapsula las operaciones del modelo. Por ejemplo, diga que desea representar un modelo lineal y = ax + b . Este modelo se puede representar con el siguiente código:
import torch
class Net ( torch . nn . Module ):
def __init__ ( self ):
super (). __init__ ()
self . a = torch . nn . Parameter ( torch . rand ( 1 ))
self . b = torch . nn . Parameter ( torch . rand ( 1 ))
def forward ( self , x ):
yhat = self . a * x + self . b
return yhatPara usar este modelo en la práctica, instancia el módulo y simplemente lo llame como una función:
x = torch . arange ( 100 , dtype = torch . float32 )
net = Net ()
y = net ( x ) Los parámetros son esencialmente tensores con requires_grad establecido en True. Es conveniente usar parámetros porque simplemente puede recuperarlos todos con el método parameters() :
for p in net . parameters ():
print ( p ) Ahora, digamos que tiene una función desconocida y = 5x + 3 + some noise , y desea optimizar los parámetros de su modelo para adaptarse a esta función. Puede comenzar muestras de algunos puntos desde su función:
x = torch . arange ( 100 , dtype = torch . float32 ) / 100
y = 5 * x + 3 + torch . rand ( 100 ) * 0.3Similar al ejemplo anterior, puede definir una función de pérdida y optimizar los parámetros de su modelo de la siguiente manera:
criterion = torch . nn . MSELoss ()
optimizer = torch . optim . SGD ( net . parameters (), lr = 0.01 )
for i in range ( 10000 ):
net . zero_grad ()
yhat = net ( x )
loss = criterion ( yhat , y )
loss . backward ()
optimizer . step ()
print ( net . a , net . b ) # Should be close to 5 and 3 Pytorch viene con una serie de módulos predefinidos. Uno de esos módulos es torch.nn.Linear , que es una forma más general de una función lineal que la que definimos anteriormente. Podemos reescribir nuestro módulo arriba usando torch.nn.Linear como este:
class Net ( torch . nn . Module ):
def __init__ ( self ):
super (). __init__ ()
self . linear = torch . nn . Linear ( 1 , 1 )
def forward ( self , x ):
yhat = self . linear ( x . unsqueeze ( 1 )). squeeze ( 1 )
return yhat Tenga en cuenta que usamos Squeeze e Unsqueeze ya que torch.nn.Linear opera en lote de vectores en lugar de escalares.
Por defecto, llamar a los parámetros () en un módulo devolverá los parámetros de todos sus submódulos:
net = Net ()
for p in net . parameters ():
print ( p ) Hay algunos módulos predefinidos que actúan como un contenedor para otros módulos. El módulo de contenedor más comúnmente utilizado es torch.nn.Sequential . Como su nombre lo implica, se usa para apilar múltiples módulos (o capas) uno encima del otro. Por ejemplo, para apilar dos capas lineales con una no linealidad ReLU entre usted, puede hacer:
model = torch . nn . Sequential (
torch . nn . Linear ( 64 , 32 ),
torch . nn . ReLU (),
torch . nn . Linear ( 32 , 10 ),
) Pytorch admite operaciones de transmisión de elementos. Normalmente, cuando desea realizar operaciones como suma y multiplicación, debe asegurarse de que las formas de los operandos coincidan, por ejemplo, no puede agregar un tensor de forma [3, 2] a un tensor de forma [3, 4] . Pero hay un caso especial y es cuando tienes una dimensión singular. Pytorch mueve implícitamente el tensor a través de sus dimensiones singulares para que coincida con la forma del otro operando. Por lo tanto, es válido agregar un tensor de forma [3, 2] a un tensor de forma [3, 1] .
import torch
a = torch . tensor ([[ 1. , 2. ], [ 3. , 4. ]])
b = torch . tensor ([[ 1. ], [ 2. ]])
# c = a + b.repeat([1, 2])
c = a + b
print ( c )La transmisión nos permite realizar un mosaico implícito, lo que hace que el código sea más corto y más eficiente en la memoria, ya que no necesitamos almacenar el resultado de la operación de mosaico. Un lugar ordenado que se puede usar es cuando se combina características de longitud variable. Para concatenar las características de la longitud variable, comúnmente muele los tensores de entrada, concatenamos el resultado y aplicamos cierta no linealidad. Este es un patrón común en una variedad de arquitecturas de redes neuronales:
a = torch . rand ([ 5 , 3 , 5 ])
b = torch . rand ([ 5 , 1 , 6 ])
linear = torch . nn . Linear ( 11 , 10 )
# concat a and b and apply nonlinearity
tiled_b = b . repeat ([ 1 , 3 , 1 ])
c = torch . cat ([ a , tiled_b ], 2 )
d = torch . nn . functional . relu ( linear ( c ))
print ( d . shape ) # torch.Size([5, 3, 10]) Pero esto se puede hacer de manera más eficiente con la transmisión. Usamos el hecho de que f(m(x + y)) es igual a f(mx + my) . Por lo tanto, podemos hacer las operaciones lineales por separado y usar la transmisión para hacer una concatenación implícita:
a = torch . rand ([ 5 , 3 , 5 ])
b = torch . rand ([ 5 , 1 , 6 ])
linear1 = torch . nn . Linear ( 5 , 10 )
linear2 = torch . nn . Linear ( 6 , 10 )
pa = linear1 ( a )
pb = linear2 ( b )
d = torch . nn . functional . relu ( pa + pb )
print ( d . shape ) # torch.Size([5, 3, 10])De hecho, este código es bastante general y se puede aplicar a tensores de forma arbitraria siempre que sea posible transmisión entre tensores:
class Merge ( torch . nn . Module ):
def __init__ ( self , in_features1 , in_features2 , out_features , activation = None ):
super (). __init__ ()
self . linear1 = torch . nn . Linear ( in_features1 , out_features )
self . linear2 = torch . nn . Linear ( in_features2 , out_features )
self . activation = activation
def forward ( self , a , b ):
pa = self . linear1 ( a )
pb = self . linear2 ( b )
c = pa + pb
if self . activation is not None :
c = self . activation ( c )
return cHasta ahora discutimos la buena parte de la transmisión. Pero, ¿cuál es la parte fea que puedes preguntar? Las suposiciones implícitas casi siempre hacen que la depuración sea más difícil de hacer. Considere el siguiente ejemplo:
a = torch . tensor ([[ 1. ], [ 2. ]])
b = torch . tensor ([ 1. , 2. ])
c = torch . sum ( a + b )
print ( c ) ¿Cuál crees que sería el valor de c después de la evaluación? Si adivinaste 6, eso está mal. Va a ser 12. Esto se debe a que cuando el rango de dos tensores no coincide, Pytorch expande automáticamente la primera dimensión del tensor con un rango más bajo antes de la operación de elementos, por lo que el resultado de la adición sería [[2, 3], [3, 4]] , y la reducción sobre todos los parámetros nos daría 12.
La forma de evitar este problema es ser lo más explícito posible. Si hubiéramos especificado qué dimensión nos gustaría reducir, atrapar este error habría sido mucho más fácil:
a = torch . tensor ([[ 1. ], [ 2. ]])
b = torch . tensor ([ 1. , 2. ])
c = torch . sum ( a + b , 0 )
print ( c ) Aquí el valor de c sería [5, 7] , e inmediatamente adivinaríamos en base a la forma del resultado de que hay algo mal. Una regla general es especificar siempre las dimensiones en las operaciones de reducción y al usar torch.squeeze .
Al igual que Numpy, Pytorch sobrecarga a varios operadores de Python para que el código Pytorch sea más corto y legible.
El OP de corte es uno de los operadores sobrecargados que puede hacer que la indexación de tensores sea muy fácil:
z = x [ begin : end ] # z = torch.narrow(0, begin, end-begin)Sin embargo, tenga mucho cuidado al usar este OP. El OP de corte, como cualquier otro OP, tiene algo de sobrecarga. Debido a que es un OP e inocente común, puede ser muy utilizado mucho, lo que puede conducir a ineficiencias. Para comprender cuán ineficiente puede ser este OP, veamos un ejemplo. Queremos realizar manualmente la reducción en las filas de una matriz:
import torch
import time
x = torch . rand ([ 500 , 10 ])
z = torch . zeros ([ 10 ])
start = time . time ()
for i in range ( 500 ):
z += x [ i ]
print ( "Took %f seconds." % ( time . time () - start )) Esto se ejecuta bastante lento y la razón es que estamos llamando a Slice OP 500 veces, lo que agrega muchas sobrecargas. Una mejor opción habría sido usar torch.unbind OP para cortar la matriz en una lista de vectores de una vez:
z = torch . zeros ([ 10 ])
for x_i in torch . unbind ( x ):
z += x_iEsto es significativamente (~ 30% en mi máquina) más rápido.
Por supuesto, la forma correcta de hacer esta simple reducción es usar torch.sum .
z = torch . sum ( x , dim = 0 )que es extremadamente rápido (~ 100 veces más rápido en mi máquina).
Pytorch también sobrecarga una gama de operadores aritméticos y lógicos:
z = - x # z = torch.neg(x)
z = x + y # z = torch.add(x, y)
z = x - y
z = x * y # z = torch.mul(x, y)
z = x / y # z = torch.div(x, y)
z = x // y
z = x % y
z = x ** y # z = torch.pow(x, y)
z = x @ y # z = torch.matmul(x, y)
z = x > y
z = x >= y
z = x < y
z = x <= y
z = abs ( x ) # z = torch.abs(x)
z = x & y
z = x | y
z = x ^ y # z = torch.logical_xor(x, y)
z = ~ x # z = torch.logical_not(x)
z = x == y # z = torch.eq(x, y)
z = x != y # z = torch.ne(x, y) También puede usar la versión aumentada de estos OP. Por ejemplo, x += y y x **= 2 también son válidos.
Tenga en cuenta que Python no permite sobrecargar and , or not palabras clave.
Pytorch está optimizado para realizar operaciones en grandes tensores. Hacer muchas operaciones en pequeños tensores es bastante ineficiente en Pytorch. Por lo tanto, siempre que sea posible, debe reescribir sus cálculos en forma de lotes para reducir la sobrecarga y mejorar el rendimiento. Si no hay forma de que pueda agrupar manualmente sus operaciones, usar Torchscript puede mejorar el rendimiento de su código. Torchscript es simplemente un subconjunto de funciones de Python que reconocen Pytorch. Pytorch puede optimizar automáticamente su código Torchscript utilizando su compilador justo en el tiempo (JIT) y reducir algunos gastos generales.
Veamos un ejemplo. Una operación muy común en las aplicaciones ML es "Batch Reungue". Esta operación simplemente puede escribir como output[i] = input[i, index[i]] . Esto se puede implementar simplemente en Pytorch de la siguiente manera:
import torch
def batch_gather ( tensor , indices ):
output = []
for i in range ( tensor . size ( 0 )):
output += [ tensor [ i ][ indices [ i ]]]
return torch . stack ( output ) Para implementar la misma función utilizando TorchScript, simplemente use el decorador torch.jit.script :
@ torch . jit . script
def batch_gather_jit ( tensor , indices ):
output = []
for i in range ( tensor . size ( 0 )):
output += [ tensor [ i ][ indices [ i ]]]
return torch . stack ( output )En mis pruebas, esto es aproximadamente un 10% más rápido.
Pero nada mejor que por un lío manual de sus operaciones. Una implementación vectorizada en mis pruebas es 100 veces más rápida:
def batch_gather_vec ( tensor , indices ):
shape = list ( tensor . shape )
flat_first = torch . reshape (
tensor , [ shape [ 0 ] * shape [ 1 ]] + shape [ 2 :])
offset = torch . reshape (
torch . arange ( shape [ 0 ]). cuda () * shape [ 1 ],
[ shape [ 0 ]] + [ 1 ] * ( len ( indices . shape ) - 1 ))
output = flat_first [ indices + offset ]
return output En la última lección hablamos sobre escribir un código de pytorch eficiente. Pero para que su código se ejecute con la máxima eficiencia, también necesita cargar sus datos de manera eficiente en la memoria de su dispositivo. Afortunadamente, Pytorch ofrece una herramienta para facilitar la carga de datos. Se llama DataLoader . Un DataLoader usa múltiples trabajadores para cargar simultáneamente datos de un Dataset y opcionalmente usa una Sampler para probar entradas de datos y formar un lote.
Si puede acceder aleatoriamente a sus datos, usar un DataLoader es muy fácil: simplemente necesita implementar una clase Dataset que implementa __getitem__ (para leer cada elemento de datos) y __len__ (para devolver el número de elementos en los métodos de conjunto de datos). Por ejemplo, aquí le va cómo cargar imágenes de un directorio determinado:
import glob
import os
import random
import cv2
import torch
class ImageDirectoryDataset ( torch . utils . data . Dataset ):
def __init__ ( path , pattern ):
self . paths = list ( glob . glob ( os . path . join ( path , pattern )))
def __len__ ( self ):
return len ( self . paths )
def __item__ ( self ):
path = random . choice ( paths )
return cv2 . imread ( path , 1 )Para cargar todas las imágenes JPEG de un directorio determinado, puede hacer lo siguiente:
dataloader = torch . utils . data . DataLoader ( ImageDirectoryDataset ( "/data/imagenet/*.jpg" ), num_workers = 8 )
for data in dataloader :
# do something with dataAquí estamos utilizando 8 trabajadores para leer simultáneamente nuestros datos del disco. Puede ajustar el número de trabajadores en su máquina para obtener resultados óptimos.
El uso de un DataLoader para leer datos con acceso aleatorio puede estar bien si tiene un almacenamiento rápido o si sus elementos de datos son grandes. Pero imagine tener un sistema de archivos de red con una conexión lenta. Solicitar archivos individuales de esta manera puede ser extremadamente lento y probablemente terminaría convirtiéndose en el cuello de botella de su canal de entrenamiento.
Un mejor enfoque es almacenar sus datos en un formato de archivo contiguo que se puede leer secuencialmente. Por ejemplo, si tiene una gran colección de imágenes, puede usar TAR para crear un solo archivo y extraer archivos del archivo secuencialmente en Python. Para hacer esto, puede usar IterableDataset de Pytorch. Para crear una clase IterableDataset , solo necesita implementar un método __iter__ que lee y produce secuencialmente elementos de datos del conjunto de datos.
Una implementación ingenua desea esto:
import tarfile
import torch
def tar_image_iterator ( path ):
tar = tarfile . open ( self . path , "r" )
for tar_info in tar :
file = tar . extractfile ( tar_info )
content = file . read ()
yield cv2 . imdecode ( content , 1 )
file . close ()
tar . members = []
tar . close ()
class TarImageDataset ( torch . utils . data . IterableDataset ):
def __init__ ( self , path ):
super (). __init__ ()
self . path = path
def __iter__ ( self ):
yield from tar_image_iterator ( self . path )Pero hay un gran problema con esta implementación. Si intenta usar dataLoader para leer desde este conjunto de datos con más de un trabajador, observaría muchas imágenes duplicadas:
dataloader = torch . utils . data . DataLoader ( TarImageDataset ( "/data/imagenet.tar" ), num_workers = 8 )
for data in dataloader :
# data contains duplicated items El problema es que cada trabajador crea una instancia separada del conjunto de datos y cada uno comenzaría desde el comienzo del conjunto de datos. Una forma de evitar esto es en lugar de tener un archivo num_workers alquitrán, divida sus datos en archivos de alquitrán separados y cargue cada uno con un trabajador separado:
class TarImageDataset ( torch . utils . data . IterableDataset ):
def __init__ ( self , paths ):
super (). __init__ ()
self . paths = paths
def __iter__ ( self ):
worker_info = torch . utils . data . get_worker_info ()
# For simplicity we assume num_workers is equal to number of tar files
if worker_info is None or worker_info . num_workers != len ( self . paths ):
raise ValueError ( "Number of workers doesn't match number of files." )
yield from tar_image_iterator ( self . paths [ worker_info . worker_id ])Así es como se puede usar nuestra clase de conjunto de datos:
dataloader = torch . utils . data . DataLoader (
TarImageDataset ([ "/data/imagenet_part1.tar" , "/data/imagenet_part2.tar" ]), num_workers = 2 )
for data in dataloader :
# do something with dataDiscutimos una estrategia simple para evitar el problema de las entradas duplicadas. El paquete TFRecord utiliza estrategias ligeramente más sofisticadas para fregar sus datos sobre la marcha.
Cuando se usa cualquier biblioteca de cálculo numérica como Numpy o Pytorch, es importante tener en cuenta que escribir código matemáticamente correcto no necesariamente conduce a resultados correctos. También debe asegurarse de que los cálculos sean estables.
Comencemos con un ejemplo simple. Matemáticamente, es fácil ver que x * y / y = x para cualquier valor no cero de x . Pero veamos si eso siempre es cierto en la práctica:
import numpy as np
x = np . float32 ( 1 )
y = np . float32 ( 1e-50 ) # y would be stored as zero
z = x * y / y
print ( z ) # prints nan La razón del resultado incorrecto es que y es simplemente demasiado pequeño para el tipo Float32. Un problema similar ocurre cuando y es demasiado grande:
y = np . float32 ( 1e39 ) # y would be stored as inf
z = x * y / y
print ( z ) # prints nanEl valor positivo más pequeño que puede representar Float32 es 1.4013E-45 y cualquier cosa por debajo que se almacenara como cero. Además, cualquier número más allá de 3.40282e+38, se almacenaría como inf.
print ( np . nextafter ( np . float32 ( 0 ), np . float32 ( 1 ))) # prints 1.4013e-45
print ( np . finfo ( np . float32 ). max ) # print 3.40282e+38Para asegurarse de que sus cálculos sean estables, desea evitar valores con un valor absoluto pequeño o muy grande. Esto puede sonar muy obvio, pero este tipo de problemas puede ser extremadamente difícil de depurar, especialmente al hacer descenso de gradiente en Pytorch. Esto se debe a que no solo necesita asegurarse de que todos los valores en el pase hacia adelante estén dentro del rango válido de sus tipos de datos, sino que también debe asegurarse de lo mismo para el pase hacia atrás (durante el cálculo de gradiente).
Veamos un ejemplo real. Queremos calcular el Softmax sobre un vector de logits. Una implementación ingenua se vería algo así:
import torch
def unstable_softmax ( logits ):
exp = torch . exp ( logits )
return exp / torch . sum ( exp )
print ( unstable_softmax ( torch . tensor ([ 1000. , 0. ])). numpy ()) # prints [ nan, 0.] Tenga en cuenta que calcular el exponencial de los logits para resultados relativamente pequeños con resultados gigantescos que están fuera del rango Float32. El logit válido más grande para nuestra implementación ingenua de Softmax es ln(3.40282e+38) = 88.7 , cualquier cosa más allá de eso conduce a un resultado de NAN.
Pero, ¿cómo podemos hacer que esto sea más estable? La solución es bastante simple. Es fácil ver que exp(x - c) Σ exp(x - c) = exp(x) / Σ exp(x) . Por lo tanto, podemos restar cualquier constante de los logits y el resultado seguiría siendo el mismo. Elegimos esta constante para ser el máximo de logits. De esta manera, el dominio de la función exponencial se limitaría a [-inf, 0] , y en consecuencia su rango sería [0.0, 1.0] que es deseable:
import torch
def softmax ( logits ):
exp = torch . exp ( logits - torch . reduce_max ( logits ))
return exp / torch . sum ( exp )
print ( softmax ( torch . tensor ([ 1000. , 0. ])). numpy ()) # prints [ 1., 0.] Veamos un caso más complicado. Considere que tenemos un problema de clasificación. Utilizamos la función Softmax para producir probabilidades de nuestros logits. Luego definimos que nuestra función de pérdida sea la entropía cruzada entre nuestras predicciones y las etiquetas. Recuerde que la entropía cruzada para una distribución categórica se puede definir simplemente como xe(p, q) = -Σ p_i log(q_i) . Entonces, una implementación ingenua de la entropía cruzada se vería así:
def unstable_softmax_cross_entropy ( labels , logits ):
logits = torch . log ( softmax ( logits ))
return - torch . sum ( labels * logits )
labels = torch . tensor ([ 0.5 , 0.5 ])
logits = torch . tensor ([ 1000. , 0. ])
xe = unstable_softmax_cross_entropy ( labels , logits )
print ( xe . numpy ()) # prints infTenga en cuenta que en esta implementación como la salida de Softmax se acerca a cero, la salida del log se acerca al infinito, lo que causa inestabilidad en nuestro cálculo. Podemos reescribir esto expandiendo el Softmax y haciendo algunas simplificaciones:
def softmax_cross_entropy ( labels , logits , dim = - 1 ):
scaled_logits = logits - torch . max ( logits )
normalized_logits = scaled_logits - torch . logsumexp ( scaled_logits , dim )
return - torch . sum ( labels * normalized_logits )
labels = torch . tensor ([ 0.5 , 0.5 ])
logits = torch . tensor ([ 1000. , 0. ])
xe = softmax_cross_entropy ( labels , logits )
print ( xe . numpy ()) # prints 500.0También podemos verificar que los gradientes también se calculen correctamente:
logits . requires_grad_ ( True )
xe = softmax_cross_entropy ( labels , logits )
g = torch . autograd . grad ( xe , logits )[ 0 ]
print ( g . numpy ()) # prints [0.5, -0.5]Permítanme recordarle nuevamente que se debe tener un cuidado adicional al hacer un descenso de gradiente para asegurarse de que el rango de sus funciones y los gradientes para cada capa estén dentro de un rango válido. Las funciones exponenciales y logarítmicas cuando se usan ingenuamente son especialmente problemáticas porque pueden asignar pequeños números a los enormes y al revés.
Por defecto, los tensores y los parámetros del modelo en Pytorch se almacenan en precisión de punto flotante de 32 bits. El entrenamiento de redes neuronales que utilizan carrozas de 32 bits suelen ser estables y no causan problemas numéricos importantes, sin embargo, se ha demostrado que las redes neuronales funcionan bastante bien en precisiones de 16 bits e incluso más bajas. El cálculo en precisiones más bajas puede ser significativamente más rápido en las GPU modernas. También tiene el beneficio adicional de usar menos memoria que habilita la capacitación de modelos más grandes y/o con tamaños de lotes más grandes que pueden aumentar aún más el rendimiento. Sin embargo, el problema es que el entrenamiento en 16 bits a menudo se vuelve muy inestable porque la precisión generalmente no es suficiente para realizar algunas operaciones como acumulaciones.
Para ayudar con este problema, Pytorch admite el entrenamiento en precisión mixta. En pocas palabras, el entrenamiento de precisión mixta se realiza realizando algunas operaciones costosas (como convoluciones y multiplicaciones de matriz) en 16 bits al bajar las entradas mientras realizan otras operaciones numéricamente sensibles como acumulaciones en 32 bits. De esta manera, obtenemos todos los beneficios del cálculo de 16 bits sin sus inconvenientes. A continuación, hablamos sobre el uso de AutoCast y Gradscaler para realizar entrenamiento automático de precisión mixta.
autocast ayuda a mejorar el rendimiento del tiempo de ejecución mediante la reducción automática de datos a 16 bits para algunos cálculos. Para entender cómo funciona, veamos un ejemplo:
import torch
x = torch . rand ([ 32 , 32 ]). cuda ()
y = torch . rand ([ 32 , 32 ]). cuda ()
with torch . cuda . amp . autocast ():
a = x + y
b = x @ y
print ( a . dtype ) # prints torch.float32
print ( b . dtype ) # prints torch.float16 Tenga en cuenta que tanto x como y son tensores de 32 bits, pero autocast realiza la multiplicación de la matriz en 16 bits mientras mantiene la operación de suma en 32 bits. ¿Qué pasa si uno de los operandos está en 16 bits?
import torch
x = torch . rand ([ 32 , 32 ]). cuda ()
y = torch . rand ([ 32 , 32 ]). cuda (). half ()
with torch . cuda . amp . autocast ():
a = x + y
b = x @ y
print ( a . dtype ) # prints torch.float32
print ( b . dtype ) # prints torch.float16 Nuevamente, autocast y lanza el operando de 32 bits a 16 bits para realizar la multiplicación de la matriz, pero no cambia la operación de adición. Por defecto, la adición de dos tensores en Pytorch da como resultado un yeso a una precisión más alta.
En la práctica, puede confiar en autocast para hacer el lanzamiento correcto para mejorar la eficiencia del tiempo de ejecución. Lo importante es mantener todos sus cálculos de avance en el contexto autocast :
model = ...
loss_fn = ...
with torch . cuda . amp . autocast ():
outputs = model ( inputs )
loss = loss_fn ( outputs , targets )Tal vez todo lo que necesite si tiene un problema de optimización relativamente estable y si usa una tasa de aprendizaje relativamente baja. Agregar esta línea de código adicional puede reducir su entrenamiento hasta la mitad en el hardware moderno.
Como mencionamos al comienzo de esta sección, la precisión de 16 bits puede no siempre ser suficiente para algunos cálculos. Un caso particular de interés es representar valores de gradiente, una gran parte de la cual generalmente son valores pequeños. Representarlos con flotadores de 16 bits a menudo conduce a subflees de amortiguación (es decir, serían representados como ceros). Esto hace que la capacitación de redes neuronales sea muy inestable. GradScalar está diseñado para resolver este problema. Toma como entrada su valor de pérdida y lo multiplica por un gran escalar, inflando los valores de gradiente y, por lo tanto, los hace representar en precisión de 16 bits. Luego los escala durante la actualización de gradiente para garantizar que los parámetros se actualicen correctamente. Esto es generalmente lo que hace GradScalar . Pero debajo del capó, GradScalar es un poco más inteligente que eso. Inflar los gradientes en realidad puede dar lugar a desbordamientos que son igualmente malos. Por lo tanto, GradScalar en realidad monitorea los valores de gradiente y si detecta los desbordados, omite las actualizaciones, reduciendo el factor escalar de acuerdo con un horario configurable. (El horario predeterminado generalmente funciona, pero es posible que deba ajustar eso para su caso de uso).
Usar GradScalar es muy fácil en la práctica:
scaler = torch . cuda . amp . GradScaler ()
loss = ...
optimizer = ... # an instance torch.optim.Optimizer
scaler . scale ( loss ). backward ()
scaler . step ( optimizer )
scaler . update () Tenga en cuenta que primero creamos una instancia de GradScalar . En el bucle de entrenamiento llamamos a GradScalar.scale para escalar la pérdida antes de llamar hacia atrás para producir gradientes inflados, luego usamos GradScalar.step que (puede) actualizar los parámetros del modelo. Luego llamamos a GradScalar.update , que realiza la actualización escalar si es necesario. ¡Eso es todo!
El siguiente es un código de muestra que muestra casos de entrenamiento de precisión mixta en un problema sintético de aprender a generar un tablero de verificación a partir de coordenadas de imágenes. Puede pegarlo en un Google Colab, establecer el backend en GPU y comparar el rendimiento de precisión simple y mixta. Tenga en cuenta que este es un pequeño ejemplo de juguete, en la práctica con redes más grandes, puede ver aumentos más grandes en el rendimiento utilizando una precisión mixta.
import torch
import matplotlib . pyplot as plt
import time
def grid ( width , height ):
hrange = torch . arange ( width ). unsqueeze ( 0 ). repeat ([ height , 1 ]). div ( width )
vrange = torch . arange ( height ). unsqueeze ( 1 ). repeat ([ 1 , width ]). div ( height )
output = torch . stack ([ hrange , vrange ], 0 )
return output
def checker ( width , height , freq ):
hrange = torch . arange ( width ). reshape ([ 1 , width ]). mul ( freq / width / 2.0 ). fmod ( 1.0 ). gt ( 0.5 )
vrange = torch . arange ( height ). reshape ([ height , 1 ]). mul ( freq / height / 2.0 ). fmod ( 1.0 ). gt ( 0.5 )
output = hrange . logical_xor ( vrange ). float ()
return output
# Note the inputs are grid coordinates and the target is a checkerboard
inputs = grid ( 512 , 512 ). unsqueeze ( 0 ). cuda ()
targets = checker ( 512 , 512 , 8 ). unsqueeze ( 0 ). unsqueeze ( 1 ). cuda () class Net ( torch . jit . ScriptModule ):
def __init__ ( self ):
super (). __init__ ()
self . net = torch . nn . Sequential (
torch . nn . Conv2d ( 2 , 256 , 1 ),
torch . nn . BatchNorm2d ( 256 ),
torch . nn . ReLU (),
torch . nn . Conv2d ( 256 , 256 , 1 ),
torch . nn . BatchNorm2d ( 256 ),
torch . nn . ReLU (),
torch . nn . Conv2d ( 256 , 256 , 1 ),
torch . nn . BatchNorm2d ( 256 ),
torch . nn . ReLU (),
torch . nn . Conv2d ( 256 , 1 , 1 ))
@ torch . jit . script_method
def forward ( self , x ):
return self . net ( x ) net = Net (). cuda ()
loss_fn = torch . nn . MSELoss ()
opt = torch . optim . Adam ( net . parameters (), 0.001 )
start_time = time . time ()
for i in range ( 500 ):
opt . zero_grad ()
outputs = net ( inputs )
loss = loss_fn ( outputs , targets )
loss . backward ()
opt . step ()
print ( loss )
print ( time . time () - start_time )
plt . subplot ( 1 , 2 , 1 ); plt . imshow ( outputs . squeeze (). detach (). cpu ());
plt . subplot ( 1 , 2 , 2 ); plt . imshow ( targets . squeeze (). cpu ()); plt . show () net = Net (). cuda ()
loss_fn = torch . nn . MSELoss ()
opt = torch . optim . Adam ( net . parameters (), 0.001 )
scaler = torch . cuda . amp . GradScaler ()
start_time = time . time ()
for i in range ( 500 ):
opt . zero_grad ()
with torch . cuda . amp . autocast ():
outputs = net ( inputs )
loss = loss_fn ( outputs , targets )
scaler . scale ( loss ). backward ()
scaler . step ( opt )
scaler . update ()
print ( loss )
print ( time . time () - start_time )
plt . subplot ( 1 , 2 , 1 ); plt . imshow ( outputs . squeeze (). detach (). cpu (). float ());
plt . subplot ( 1 , 2 , 2 ); plt . imshow ( targets . squeeze (). cpu (). float ()); plt . show ()