<meta name="monetization" content="$ilp.uphold.com/EXa8i9DQ32qy">
Antes de crear una red neuronal necesitamos importar nuestras imágenes en un formato apto. Este texto busca introducir los básicos de la importación, visualización y manejo de datos en el framework para deep learning PyTorch. Nos enfocamos en imágenes pero los conceptos son aplicables para todo tipo de datos; al final, las imágenes no son más que números, comunmente enteros, asignados a cada pixel, o sea datos.
Importaremos imágenes en formato numpy, crearemos una subclase de
torch.utils.data.Dataset
especial para nuestros datos, aplicaremos transformaciones las imágenes con torchvision.transforms
y las visualizaremos con matplotlib.pyplot
. Finalmente, utilizaremos torch.utils.data.DataLoader
para poder iterar fácilmente por nuestros datos, juntar las imágenes en grupos, batches, y barajearlas.Una nota antes de empezar. Si bien es más sencillo escribir
Dataset
que torch.utils.data.Dataset
, considero que para ojos que miran por primera vez estos términos es mejor entender de dónde viene cada paquete, función o clase. Posteriormente se podrá importar directamente qué buscamos, de hecho es recomendable, pero por ahora importo torchvision
y torch
en su totalidad.
En mi opinión, la mejor forma de aprender deep learning es escribir y escribir código. Esto es irrealizable, en terminos de tiempo, si se utiliza el poder de una CPU. Estaremos trabajando en Google Colab donde se nos asigna de manera gratuita por máximo 12 horas un GPU modelo Tesla K80. Solo necesitamos una cuenta de Google.
Ingresar a
y hacer click en https://colab.research.google.com
NUEVO BLOCK DE NOTAS DE PYTHON 3
. Ya podemos comenzar a escribir código python en la nube completamente gratis. Para acceder a un GPU dar click en Entorno de ejecución
-Cambiar tipo de entorno de ejecución
y en acelerador de hardware seleccionar GPU. Eso es todo, estamos listos para hacer deep learning.Con
torch.cuda.is_available()
podemos verificar que estamos en efeco trabajando con un GPU.
Para aprender a navegar por la documentación de PyTorch puedes leer: Entendiendo PyTorch: las bases de las bases para hacer inteligencia artificial.
En la sección Python API de la barra vertical izquierda de la documentación de PyTorch encontramos el paquete
.torch.utils.data
En él encontramos herramientas útiles para importar nuestros datos y tenerlos listos para operar sobre ellos;
torch.utils.data.Dataset
y torch.utils.data.DataLoader
.El segundo paquete que utilizaremos es
, se encuentra en la misma barra vertical izquierda. Este paquete, junto con torchvision
torchaudio
y torchtext
, nos proporciona de funcionalidades únicas para diferentes tipos de datos, en este caso imágenes.En este texto usaremos únicamente el paquete
torchvision.transforms
.
Dataset
Con nuestros datos originales debemos crear un objeto de la clase
que en la documentación de PyTorch se encuentra en Dataset
torch.utils.data.Dataset
. La forma más sencilla de crearlo es haciéndolo de estilo map, como se le llama en la documentación, es decir, crearemos una subclase de torch.utils.data.Dataset
de donde heredaremos sus propiedades y métodos pero especialmente sobreescribiremos los métodos __getitem__()
y __len__()
. permitirá que__len__()
nos retorne el número de datos en nuestrolen(dataset)
; yDataset
nos permite hacer indexing de tal forma que, por ejemplo,__getitem()__
te devuelva el primer elemento en elMiDataset[0]
.Dataset
Importemos librerías necesarias para este texto.
import torch
import torchvision
import tensorflow as tf
Por la facilidad de descarga, usaremos Tensorflow para descargar la famosa base de datos MNIST, que cuenta con imágenes de números, en formato numpy (no te preocupes si no sabes qué es). Con
torchvision.datasets.MNIST
podríamos obtener las mismas imágenes ya como objeto torch.utils.data.Dataset
, sin embargo, este texto busca que nosotros mismos procesemos los datos y los llevemos a este formato "desde cero".Queremos construir el dataset para una red neuronal que mapee cada imagen con su respectivo número. Por ejemplo, si la imagen tiene un 7 entonces queremos que nuestra red neuronal identifique que dicha imagen tiene un 7, así de sencillo. Así luce la base de datos:
Primero, aprendamos cómo crear nuestro set de datos. Importamos:
(x_train, y_train), (x_val, y_val) = tf.keras.datasets.mnist.load_data()
Por el momento solo hagamos caso a
y x_train
; la primera incluye las imágenes en formato numpy, la segunda el número que corresponde a cada imagen, las "etiquetas". Nuestros datos están en formato numpy, lo podemos ver con y_train
type(x_train)
que imprime en nuestro notebook numpy.ndarray
. Podríamos en este momento convertir las imágenes de numpy a tensores operables en un GPU:x_train_tensors = torch.from_numpy(x_train).float().cuda()
El tipo de
es x_train_tensors
. Por ahora dejemos nuestros datos en formato numpy, más tarde utilizaremos torch.Tensor
torchvision.transforms
para convertir a tensores. Instanciamos la clase torch.utils.data.Dataset
para crear nuestra propia subclase que llamamos MNISTDataset
:class MNISTDataset(torch.utils.data.Dataset):
def __init__(self, numpy_data):
self.data = numpy_data
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
idx_numpy = self.data[idx]
return idx_numpy
Antes de explicar lo que hicimos, introduzcamos brevemente los métodos mágicos (magic methods en inglés). Estos métodos siempre van rodeados de dos guiones bajos y fueron creados para proveer de ciertas cualidades "especiales" a los objetos instanciados de las clases en las que son definidos.
El método mágico más común es
__init__
, clave en la programación orientada a objetos, y sirve para definir cómo se inicializará un objeto. Para la creación de una subclase de torch.utils.data.Dataset requerimos definir dos métodos mágicos más, __len__
y __getitem__
. Ambos argumentos son requeridos para la creación de contenedores inmutables de información; no podemos alterar lo que contiene el objeto una vez definido. Ahora vayamos al código paso por paso. Primero, con el método constructor,
, pedimos dos parámetros formales (__init__
, self
). numpy_data
self
es un parámetro especial que sirve para hacer referencia al objeto en cuestión, en este caso una instancia de MNISTDataset
; siempre debe ser el primer parámetro formal a pesar de que al instanciar no se use. El segundo parámetro es numpy_data
y es donde colocaremos nuestros datos tipo numpy. Dentro del constructor,
indica que el objeto de clase self.data
va a tener un objeto de datos internamente almacenado llamado MNISTDataset
. Para esto, definimos que lo recibido a través del parámetro data
numpy_data
se asigne internamente a data
. En los siguientes métodos de nuestra subclase llamamos a lo recibido en numpy_data
haciendo referencia al mismo objeto, self
, de forma que self.data
convoque nuestros datos. Es un poco confuso, lo sé, pero continúa con la lectura y verás que no lo es tanto. Creemos un objeto, dataset
, con nuestra subclase MNISTDataset
:dataset = MNISTDataset(x_train)
El segundo método mágico, y es obligatorio para crear un objeto
, es Dataset
. Este método recibe como único argumento formal al objeto de clase __len__
MNISTDataset
, en este caso dataset
, y definimos que retorne el número de datos guardados internamente en data
. Ahora lo aplicamos a nuestra instancia: len(dataset)
nos retorna el número de imágenes MNIST que tenemos, 60,000.El tercer método mágico, también obligatorio, es
y nos retorna el valor en la posición __getitem__
de nuestros datos (recuerda que en python el valor 0 es el primer valor). Para esto retornamos idx
self.data[idx]
. Aplicado a nuestra instancia: dataset[0]
nos retorna el tensor de la primera imagen, dataset[1]
el de la segunda, dataset[n]
el de la n imagen y así sucesivamente.Tenemos nuestro primer objeto instanciado de una subclase de tipo
torch.utils.data.Dataset.
dataset
Con el paquete
es suficiente para visualizar el estado de nuestras imágenes. Este paquete contiene funciones que permiten interactuar con nuestras figuras como si fuera Matlab, o incluso R (ver documentación de matplotlib.pyplot
). Cada función de pyplot nos permite agregar algo a nuestra figura: título, labels, cuadriculado, un reacomodo de las figuras, etc. Para más se pueden consultar los tutoriales oficiales. matplotlib
Primero usemos el siguiente código sobre nuestro
dataset
instanciado de nuestra subclase MNISTDataset
:import matplotlib.pyplot as plt
plt.figure()
for i in range(len(dataset)):
imagen = dataset[i]
plt.subplot(1, 4, i + 1)
plt.tight_layout()
plt.title("Imagen {i}".format(i=i))
plt.imshow(imagen, cmap="gray")
if i == 3:
break
que nos retorna:
El código importa el paquete
, con el sobrenombre matplotlib.pyplot
, para crear una serie de cuatro imágenes en el eje horizontal. plt
indica que vamos a crear una nueva figura en las siguientes líneas de código. Con un plt.figure()
for
indicamos que vamos a ir por todas las fotos de nuestro dataset: len(dataset) nos retorna el número de imágenes en nuestro dataset mientras que range nos crea un rango de 0 al valor ingresado como su argumento, en este caso el número de imágenes. Por lo tanto, dataset[i]
nos retorna la imagen número i
de dataset. Al final, incluimos la condición if i == 3
para solo mostrar, con plt.show()
, las imágenes con índice 0,1,2 y 3 y terminamos el loop con break
.plt.subplot(1, 4, i + 1)
indica en el primer argumento el número de filas que tendrá nuestra figura; en el segundo el número de columnas; y en el tercero nos dice la posición que toma la imagen i
en nuestra subplot. En este caso queremos que la posición cambié según el índice, i
, en el que vamos, por ejemplo, queremos que la primera imagen, i = 0
, aparezca en la posición número i + 1 = 1
y así sucesivamente.plt.tight_layout()
permite que nuestras imágenes quepan adecuadamente en nuestra figura, podemos no utilizar este comando pero hace que nuestra figura se vea mejor. Finalmente, plt.imshow(imagen, cmap='gray')
imprime la imagen. cmap="gray"
indica que queremos que el "mapa de color" sea en escala de grises. Listo, podemos observar nuestras imágenes.
PyTorch nos permite agregar transformaciones a nuestras imágenes, esto es útil, por ejemplo, en los casos en que nuestras imágenes tengan diferentes tamaños (para poderlas pasar por el grafo de nuestro modelo tenemos que tenerlas con el mismo tamaño), necesitemos aumentar el tamaño de nuestro dataset, o simplemente queramos modificarlas.
Para esto recurrimos al paquete
. torchvision.transforms
En este paquete encontramos herramientas para crear transformaciones en forma de clases con métodos mágicos
que les permite ser llamadas como funciones, por ejemplo, __call__
torchvision.transforms.CenterCrop
para cortar una imagen por el centro, o torchvision.transforms.ToTensor
para convertir una imagen en formato PIL o numpy a tensores.Más aún, encontramos una herramienta para encadenar transformaciones y aplicarlas de golpe a una imagen.
permite poner en orden las transformaciones que se aplicarán. El ejemplo de la documentación es:torchvision.transforms.Compose
transformaciones = torchvision.transforms.Compose([
torchvision.transforms.CenterCrop(10),
torchvision.transforms.ToTensor()
])
En ese sentido, estamos primero cortando por el centro nuestras imágenes y después convirtiéndolas en tensores. Las transformaciones en torchvision.transforms se aplican solo en tensores o PIL Images, no numpy, no pandas. En la columna derecha de la documentación de
encuentras las secciones "Transforms on PIL Image" y "Transforms on Torch.*Tensor". Si nuestros datos no vienen originalmente en estos formatos, como nuestros MNIST originalmente en numpy, podemos convertirlos facilmente a PIL o tensores. La mayoría de las transformaciones son aplicables solo para PIL Images por lo que preferimos convertir nuestros datos a este formato agregando torchvision.transforms
al inicio de nuestro transforms.ToPILImage()
: torchvision.transforms.Compose
transformaciones = torchvision.transforms.Compose([
torchvision.transforms.ToPILImage(),
torchvision.transforms.CenterCrop(10),
torchvision.transforms.ToTensor()
])
Ahora algo de Python. Si dentro de la documentación ingresamos al código fuente de
torchvision.transforms.Compose
, veremos que es una clase con el método mágico __call__(self, img)
(ver fragmento de código en la siguiente imagen). Esto quiere decir que podemos aplicar la clase como si aplicáramos una función con el argumento img
. Podemos correr las transformaciones sobre una de nuestras imágenes en nuestro objeto
instanciado de dataset
: MNISTDataset
y nos retornará nuestra primera imagen pero con las transformaciones aplicadas. Originalmente, la imagen estaba en formato numpy, ahora es un tensor. imagen_trans = transformaciones(dataset[0])
Para visualizar nuestras nuevas imágenes utilicemos el mismo código previo pero adecuado a la visualización de un tensor.
plt.figure()
for i in range(len(dataset)):
imagen = dataset[i]
imagen_trans = transformaciones(imagen)
imagen_trans = torch.squeeze(imagen_trans)
plt.subplot(1, 4, i + 1)
plt.tight_layout()
plt.title("Imagen {i}".format(i=i))
plt.imshow(imagen_trans.cpu(), cmap="gray")
if i == 3:
break
¿Qué es nuevo? Con
imagen_trans = transformaciones(imagen)
transformamos nuestra imagen extraida de dataset
. al poner imagen_trans.shape
notamos que la forma de la imagen es torch.Size([1, 14, 14])
. plt.imshow
no nos permite visualizar imágenes con este formato (corre el código sin esa línea y el error que verás será muy claro), tenemos que eliminar el 1 de la primera dimensión. Para esto corremos imagen_trans = torch.squeeze(imagen_trans)
que busca la dimensión donde haya un 1 y la elimina dejándonos el tensor con forma torch.Size([14, 14])
. Para más sobre cómo manipular tensores puedes leer "Manipulación de tensores en PyTorch. ¡El primer paso para el deep learning!". Lo último diferente es plt.imshow(imagen_trans.cpu(), cmap="gray")
; estamos diciendo que imagen_trans
puede ser corrido por un CPU, requerimiento de plt.imshow
. El resultado muestra que las transformaciones elegidas no eran las ideales para el caso particular de nuestras imágenes:Recortar a la mitad el tamaño de nuestras imágenes con
torchvision.transforms.CenterCrop(14)
no fue lo más inteligente (¡que conste que estábamos siguiendo el ejemplo de la documentación!). Usemos una transformación más adecuada, hagamos que nuestras imágenes se volteen verticalmente.transformaciones = torchvision.transforms.Compose([
torchvision.transforms.ToPILImage(),
torchvision.transforms.RandomVerticalFlip(p=0.9),
torchvision.transforms.ToTensor()
])
Con
indicamos que con probabilidad de 0.9 vamos a voltear imágenes de nuestro torchvision.transforms.RandomVerticalFlip(p=0.9)
. En este caso nuestras primeras cuatro imágenes todas fueron volteadas. dataset
Es conveniente que las transformaciones se hagan imagen por imagen pero dentro de nuestra subclase de
, torch.utils.data.Dataset
. Así evitamos ocupar más espacio en la memoria al tener al mismo tiempo el conjunto de datos sin transformaciones y el conjunto con transformaciones; ante una base de datos con miles de imágenes apreciaremos esto. Alteremos nuestro MNISTDataset
para que el método MNISTDataset
haga las transformaciones por nosotros cada vez que llamemos una imagen de nuestros __getitem__
. Además, incluyamos nuestras etiquetas, dataset
y_train
, para cada una de las imágenes, que la imagen con un 5 dibujado vaya acompañada de un 5 en número entero como etiqueta. Para esto haremos que nuestra MNISTDataset
nos retorne un diccionario de Python con dos cosas, la imagen (como lo habíamos estado trabajando) y su etiqueta. Así queda nuestra subclase:class MNISTDataset(torch.utils.data.Dataset):
def __init__(self, numpy_data, etiquetas, transformaciones = None):
self.data = numpy_data
self.transformaciones = transformaciones
self.etiquetas = etiquetas
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
idx_numpy = self.data[idx]
idx_etiqueta = self.etiquetas[idx]
if self.transformaciones:
idx_numpy = self.transformaciones(idx_numpy)
muestra = {"imagen" : idx_numpy,
"etiqueta" : idx_etiqueta}
return muestra
¿Qué es nuevo? Primero, en el método constructor estamos incluyendo dos argumentos formales: las etiquetas y las transformaciones. Segundo, el método
ahora también guarda la etiqueta correspondiente a la imagen número __getitem__(self, idx)
en idx
. Tercero, si un objeto de clase idx_etiqueta
fue ingresado como argumento, se aplica su lista de transformaciones a la imagen número torchvision.transforms.Compose
, en este caso idx
. Por último, creamos un diccionario que contiene dos keys (llaves en español): la primera es "imagen" y contiene a la imagen idx_numpy
; la segunda es "etiqueta" y contiene la respectiva etiqueta idx
. idx
Listo. Tenenemos un
dataset
que cada que vez que sea convocado leerá una imagen, la transformará y nos la regresará con su etiqueta.
El último paso para tener listos nuestros datos para el entrenamiento es convertir nuestro dataset, instancia de la subclase MNISTDataset, en un objeto de tipo
. Con él podemos, entre otras cosas, manejar la cantidad de imágenes que queremos que tenga cada batch (lote en español) y podemos hacer que nuestras imágenes sean shuffled (barajeadas en español) cada vez que comencemos una nueva ronda de entrenamiento (epoch es el nombre oficial, lo veremos en otro texto).torch.utils.data.DataLoader
A él debemos ingresar un objeto instanciado de una subclase de la clase
; perfecto para nuestro torch.utils.data.Dataset
dataset
de clase MNISTDataset
. dataloader = torch.utils.data.DataLoader(dataset, batch_size=8,
shuffle=True)
Estamos indicando que queremos que cada batch tenga cuatro imágenes; y que cada vez que iniciemos una nueva ronda de entrenamiento, el
Dataset
sea barajeado de forma que nuestro DataLoader
nos regrese diferentes imágenes en cada batch. En otro texto veremos cómo nos ayuda esto para el entrenamiento.Si bien un objeto
torch.utils.data.DataLoader
no es un iterador, podemos utilizar next(iter(dataloader))
para acceder a los batches de nuestro Dataset
, más explícitamente, cada vez que corramos next(iter(dataloader))
accederemos a un batch diferente.batch = next(iter(dataloader))
Aquí está la magia de
torch.utils.data.DataLoader
. batch
es un diccionario, tal como lo que imprime nuestro dataset
, que acumuló ocho imágenes y ocho etiquetas. En otras palabras, dataset[i] nos retorna un diccionario con una imagen, key imagen, y con una etiqueta, key etiqueta, dataloader nos regresa un diccionaro con las mismas keys pero cada una ocho elementos correspondientes al tamaño definido del batch. En siguientes textos veremos por qué es tan útil tener nuestros datos en este formato. Comenzaremos con la creación de nuestros modelos.