paint-brush
Procesando Datos Para Deep Learning: Datasets, visualizaciones y DataLoaders en PyTorchby@espejelomar
1,606 reads
1,606 reads

Procesando Datos Para Deep Learning: Datasets, visualizaciones y DataLoaders en PyTorch

by Omar U. EspejelFebruary 3rd, 2020
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Aplicaremos transformaciones las imágenes cony las visualizaremos with PyTorch. Estaremos trabajando en Google Colab donde se nos asigna de manera gratuita por máximo 12 horas un GPU modelo Tesla K80. The mejor forma de aprender deep learning is escribir and escribar código. Esto es irrealizable, en terminos de tiempo, if se utiliza el poder de una CPU.

Companies Mentioned

Mention Thumbnail
Mention Thumbnail
featured image - Procesando Datos Para Deep Learning: Datasets, visualizaciones y DataLoaders en PyTorch
Omar U. Espejel HackerNoon profile picture

<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.


¿Dónde correr el código?

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 

https://colab.research.google.com
 y hacer click en 
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.


¿Dónde está lo que necesitamos?

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

torchvision
, se encuentra en la misma barra vertical izquierda. Este paquete, junto con
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
.


Instanciando
Dataset

Con nuestros datos originales debemos crear un objeto de la clase

Dataset
que en la documentación de PyTorch se encuentra en
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__()
.

__len__()
permitirá que
len(dataset)
nos retorne el número de datos en nuestro
Dataset
; y
__getitem()__
nos permite hacer indexing de tal forma que, por ejemplo,
MiDataset[0]
te devuelva el primer elemento en el
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

x_train
y
y_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
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

x_train_tensors
es
torch.Tensor
. Por ahora dejemos nuestros datos en formato numpy, más tarde utilizaremos
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,

__init__
, pedimos dos parámetros formales (
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,

self.data
indica que el objeto de clase
MNISTDataset
va a tener un objeto de datos internamente almacenado llamado
data
. Para esto, definimos que lo recibido a través del parámetro
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

Dataset
, es
__len__
. Este método recibe como único argumento formal al objeto de clase
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

__getitem__
y nos retorna el valor en la posición
idx
de nuestros datos (recuerda que en python el valor 0 es el primer valor). Para esto retornamos
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.

Visualizando nuestro
dataset

Con el paquete

matplotlib.pyplot
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
). 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.

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

matplotlib.pyplot
, con el sobrenombre
plt
, para crear una serie de cuatro imágenes en el eje horizontal.
plt.figure()
indica que vamos a crear una nueva figura en las siguientes líneas de código. Con un
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.


Transformando los datos

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

__call__
que les permite ser llamadas como funciones, por ejemplo,
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.

torchvision.transforms.Compose
permite poner en orden las transformaciones que se aplicarán. El ejemplo de la documentación es:

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

torchvision.transforms
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
transforms.ToPILImage()
al inicio de nuestro
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

dataset
instanciado de
MNISTDataset
:
imagen_trans = transformaciones(dataset[0])
y nos retornará nuestra primera imagen pero con las transformaciones aplicadas. Originalmente, la imagen estaba en formato numpy, ahora es un tensor.

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

torchvision.transforms.RandomVerticalFlip(p=0.9)
indicamos que con probabilidad de 0.9 vamos a voltear imágenes de nuestro
dataset
. En este caso nuestras primeras cuatro imágenes todas fueron volteadas.

Es conveniente que las transformaciones se hagan imagen por imagen pero dentro de nuestra subclase de

torch.utils.data.Dataset
,
MNISTDataset
. 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
__getitem__
haga las transformaciones por nosotros cada vez que llamemos una imagen de nuestros
dataset
. Además, incluyamos nuestras etiquetas,
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

__getitem__(self, idx)
ahora también guarda la etiqueta correspondiente a la imagen número
idx
en
idx_etiqueta
. Tercero, si un objeto de clase
torchvision.transforms.Compose
fue ingresado como argumento, se aplica su lista de transformaciones a la imagen número
idx
, en este caso
idx_numpy
. Por último, creamos un diccionario que contiene dos keys (llaves en español): la primera es "imagen" y contiene a la imagen
idx
; la segunda es "etiqueta" y contiene la respectiva etiqueta
idx
.

Listo. Tenenemos un

dataset
que cada que vez que sea convocado leerá una imagen, la transformará y nos la regresará con su etiqueta.


DataLoader

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

torch.utils.data.DataLoader
. 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).

A él debemos ingresar un objeto instanciado de una subclase de la clase

torch.utils.data.Dataset
; perfecto para nuestro
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.