2. Pytorch

Como dijimos anteriormente, PyTorch es un paquete de Python diseñado para realizar cálculos numéricos haciendo uso de la programación de tensores. Además permite su ejecución en GPU para acelerar los cálculos.

En la práctica es un sustituto bastante potente de Numpy, una librería casi estándar para trabajar con arrays en python.

2.1. ¿Cómo funciona pytorch?

Vamos a ver un tutorial rápido del tipo de datos de pytorch y cómo trabaja internamente esta librería. Para esto tendrás que haber seguido correctamente todos los pasos anteriores. Para esto necesitas la versión interactiva del notebook.

Para esta sección:

  • Abre Jupyter (consultar arriba)

  • Navega hasta el notebook 00 Práctica Deep Learning - Introducción.ipynb y ábrelo.

  • Baja hasta esta sección.

Pero antes de nada os cuento algunas diferencias entre matlab y python:

  • Python es un lenguaje de propósito general mientras que matlab es un lenguaje específico para ciencia e ingeniería. Esto no es ni bueno ni malo; matlab es más fácil de utilizar para ingeniería sin preparación, pero python es más versátil.

  • Debido a ello, Matlab carga automáticamente todas las funciones mientras que en Python, hay que cargar las librerías que vamos a utilizar. Esto hace que usar funciones en matlab sea más sencillo (dos letras menos que escribir), pero a costa de que es más difícil gestionar la memoria, y los nombres de funciones se puden superponer. Supon que A es una matriz. Para hacer la pseudoinversa, en matlab hacemos:

pinv(A)
  • en python tenemos que cargar la librería:

import scipy as sp
sp.pinv(A)
  • Esto genera una cosa llamada espacio de nombres, en el que las funciones de cada librería van precedidas por su abreviatura (si importamos con import x as y) o el propio nombre si usamos import torch, torch.tensor(), mientras que en matlab basta con llamar a la función. Por ejemplo, cuando en matlab escribimos:

    • vector = [1, 2, 3]

  • en python+pytorch necesitamos especificar que es un tensor (un array multidimensional):

    • vector = torch.tensor([1,2,3])

Vamos a cargar la librería con import torch y ver que podemos, por ejemplo, construir una matriz de 5x3 aleatoria. Para ejecutar una celda, basta con seleccionarla (bien con las flechas del teclado, bien con el ratón) y pulsando Ctrl+Enter (o bien pulsando “Run” en la barra superior).

import torch
x = torch.rand(5, 3)
print(x)
tensor([[0.2472, 0.7132, 0.1375],
        [0.7200, 0.2924, 0.3832],
        [0.4341, 0.3518, 0.9204],
        [0.3709, 0.2253, 0.6080],
        [0.9817, 0.5234, 0.2136]])

O una matriz de ceros:

x = torch.zeros(5, 3, dtype=torch.long)
print(x)
tensor([[0, 0, 0],
        [0, 0, 0],
        [0, 0, 0],
        [0, 0, 0],
        [0, 0, 0]])

O a partir de unos datos dados, y podemos mostrarla con print, pero también acceder a sus características, como el tamaño de la matriz:

x = torch.tensor([[5.5, 3, 3],[2,1, 5], [3,4,2],[7,6,5],[2,1,2]])
print(x)
print(x.shape)
tensor([[5.5000, 3.0000, 3.0000],
        [2.0000, 1.0000, 5.0000],
        [3.0000, 4.0000, 2.0000],
        [7.0000, 6.0000, 5.0000],
        [2.0000, 1.0000, 2.0000]])
torch.Size([5, 3])

Con tensores se puede operar de forma normal:

y = torch.rand(5, 3)
print(x + y)
tensor([[6.3343, 3.7080, 3.9404],
        [2.3815, 1.0040, 5.7915],
        [3.0152, 4.8507, 2.5595],
        [7.2281, 6.1131, 5.3825],
        [2.3290, 1.9387, 2.1796]])

Pero OJO CUIDAO, tienen que ser del mismo tamaño, si no, va a dar error:

y = torch.rand(2,3)
print(x+y)
---------------------------------------------------------------------------
RuntimeError                              Traceback (most recent call last)
<ipython-input-7-42fe1c64fd19> in <module>
      1 y = torch.rand(2,3)
----> 2 print(x+y)

RuntimeError: The size of tensor a (5) must match the size of tensor b (2) at non-singleton dimension 0

Se puede hacer slicing como en numpy o Matlab. Por ejemplo, para extraer la primera columna:

print(x[:, 1])
tensor([3., 1., 4., 6., 1.])

Otra característica que nos será de mucha utilidad es cambiar la forma de la matriz, que en otros lenguajes se conoce como reshape, y aquí es un método del objeto tensor llamado view():

x = torch.randn(4, 4)
y = x.view(16)
z = x.view(-1, 8)  # the size -1 is inferred from other dimensions
print(x.size(), y.size(), z.size())
torch.Size([4, 4]) torch.Size([16]) torch.Size([2, 8])

Podemos operar con tensores y valores escalares:

y = x + 2
print(y)
tensor([[2.9619, 1.8762, 2.1108, 2.6227],
        [2.4012, 2.8760, 0.8042, 1.2621],
        [1.2319, 2.2896, 1.6425, 3.2375],
        [3.3435, 2.0807, 3.2948, 0.7555]])

Y también podemos definir funciones que realicen estas operaciones que apliquemos a los diferentes tensores:

def modulo(x,y):
    aux = x**2 + y**2
    salida = torch.sqrt(aux)
    return salida

print(modulo(x,y))
tensor([[3.1142, 1.8803, 2.1137, 2.6956],
        [2.4345, 3.0064, 1.4411, 1.4620],
        [1.4517, 2.3079, 1.6809, 3.4660],
        [3.6033, 2.0823, 3.5401, 1.4559]])

Y, una parte fundamental es que pytorch conserva memoria de las operaciones realizadas en un vector:

x = torch.ones(2, 2, requires_grad=True)
y = x + 2
print(y)
tensor([[3., 3.],
        [3., 3.]], grad_fn=<AddBackward0>)

La propiedad grad_fn será fundamental en el entrenamiento de redes neuronales, ya que guarda el gradiente de la operación o función que se haya aplicado a los datos. Esto se conserva a traves de todas las operaciones:

z = y * y * 3
out = z.mean()

print(z, out)
tensor([[27., 27.],
        [27., 27.]], grad_fn=<MulBackward0>) tensor(27., grad_fn=<MeanBackward0>)

O incluso llevan cuenta de las operaciones realizadas con funciones:

print(modulo(x,y))
tensor([[3.1623, 3.1623],
        [3.1623, 3.1623]], grad_fn=<SqrtBackward>)

Para calcular el gradiente a lo largo de estas operaciones se utiliza la función .backward(), que realiza la propagación del gradiente hacia atrás. Podemos mostrar el gradiente \(\frac{\partial out}{\partial x}\) con la propiedad x.grad, así que lo vemos:

out.backward()
print(x.grad)
tensor([[4.5000, 4.5000],
        [4.5000, 4.5000]])

Habrá aquí una matriz de 2x2 con valores 4.5. Si llamamos el tensor de salida \(o\), tenemos que:

\[ o = \frac{1}{4} \sum_iz_i, \quad z_i = 3(x_i + 2)^2 \]

Así que \(z_i|_{x_i=1} = 27\). Entonces, la \(\frac{\partial o}{\partial x_i} = \frac{3}{2}(x_i+2)\) y \(\frac{\partial o}{\partial x_i} |_{x_i=1} = \frac{9}{2} = 4.5\)

Gracias a esto, y a las matemáticas del algoritmo de propagación hacia atrás (backpropagation, ver video de introducción a la práctica), se pueden actualizar los pesos en función de una función de pérdida en las redes neuronales. Se puede activar y desactivar el cálculo del gradiente con la expresión torch.no_grad().

print(x.requires_grad)
print((x ** 2).requires_grad)

with torch.no_grad():
    print((x ** 2).requires_grad)
True
True
False

En la próxima sección, 01 Práctica Deep Learning - Perceptrón Multicapa.ipynb, veremos como se construye y se entrena nuestra primera red neuronal utilizando estas características de pytorch.