En la primera parte llegamos a convertir esto

[
    'que se requiere para un prestamo personal',
    'me piden mi numero de cuenta es mi cbu',
]

en esto

[
    [4160, 4683, 4484, 3703, 5294, 4011, 3825],
    [3275, 3854, 3319, 3554, 1532, 1462, 2151, 3319, 950],
]

donde dijimos que las partes fundamentales son la tokenización —separar a los documentos en unidades de información— y la numericalización —el asignarle a cada uno de los tókenes un número, más que nada para que la computadora, que gusta mucho de los números, sea feliz—.

También habíamos dicho que un tóken es un atributo pero no dijimos mucho más al respecto. Veamos cómo puede ser esto. La tarea de ejemplo es clasificar documentos. Estamos acustumbrados a tener muestras y etiquetas como X e y en las que la primera es una matriz de muestras (filas) y atributos (columnas), y la segunda suele ser una columna. Cuando el dataset está sin pre-procesar tenemos las muestras (filas) pero no los atributos (columnas), por lo general tenemos una única columna con los documentos en forma de strings, lo que mucha forma de atributos no tiene.

Ahora que hemos pre-procesado el texto estamos a un paso de obtener los atributos. La función de los atributos es describir o caracterizar a las muestras. El modelo lee estos atributos para realizar inferencias. Hay distintas maneras de describir a los documentos, algunas más sofisticadas que otras, una intuitiva es aprovechar que los tókenes están numerados desde 0 hasta L (len(vocabulario)) y otorgarle una columna a cada uno en la matriz de atributos de tamaño N x L (donde N es la cantidad de muestras).

Hecho esto, solo resta contar cuántas veces aparece cada tóken en cada documento y asentarlo en la matriz.

                  |  bien  hola  si    todo
-------------------------------------------
'hola todo bien'  |  1     1     0     1
'si bien bien'    |  2     0     1     0

Como comentario, esta forma de describir los documentos ignora enteramente el órden de los tókenes, sabemos que el sentido de una oración puede cambir completamente si cambiamos algunas palabras de lugar. Para el problema en cuestión, no parece ser tan grave ya que para clasificar una pregunta podría bastar con reconocer algunas palabras claves como cambio y clave o requisito y préstamo.

Ver tf-idf.

PyTorch

El típico bucle de entrenamiento de PyTorch tiene esta pinta.

for época in range(N_ÉPOCAS):
    for lote in datos_entrenamiento:
        # reseteamos los gradientes
        optimizador.zero_grad()

        predicciones = red_neuronal(lote.X)
        pérdida = criterio(predicciones, lote.y)

        # calculamos los gradientes
        pérdida.backward()

        # aplicamos los gradientes
        optimizador.step()

Recordemos que a diferencia de otros modelos las redes neuronales revisitan varias veces el dataset, en lo que se llaman épocas, cada época es un recorrido por todas las muestras de entrenamiento.

En una época el dataset se puede mostrar entero, de a una muestra, o como es común hoy en día de a grupos o lotes (batches). La experiencia mostró que es útil variar el orden de las muestras en cada época.

PyTorch provee ciertas facilidades para el manejo de los datos con las clases definidas en torch.utils.data a ser:

  1. Dataset. Organiza los datos. Le pasamos un número o índice de muestra y nos devuelve la muestra usualmente como una tupla (atributos, etiqueta).
  2. Sampler. Salvo que lo queramos de otra manera, se encarga de brindar un orden aleatoreo de los índices del dataset; uno diferente cada vez que le preguntamos.
  3. BatchSampler. Por defecto, se inicializa con un Sampler y el tamaño de lote. Se encarga de armar grupos de índices; diferentes cada vez que le preguntamos.
  4. DataLoader. Valiéndose de los grupos de índices de BatchSampler, obtiene muestras de Dataset. De esta manera para cada época devuelve lotes de muestras al azar.

Por Sampler y BatchSampler no nos detendremos ya el comportamiento por defecto, que es barajar el dataset en cada época y armar lotes del mismo tamaño es todo lo que necesitamos.

Dataset

from torch.utils.data import Dataset

class Textset(Dataset):
    def __init__(self, documentos, etiquetas=None):
            
        self.documentos = documentos
        self.etiquetas  = etiquetas or np.full(len(documentos), np.nan)

    def __len__(self):
        return len(self.documentos)
    
    def __getitem__(self, item):
        return self.documentos[item], self.etiquetas[item]

Es una clase que necesita implementar __len__ y __getitem__. Podría encargarse de levantar y pre-procesar el dataset, que por comodidad lo hemos cargado con Pandas y pre-procesado por fuera: el constructor (__init__) podría recibir el nombre del archivo, leerlo y aplicarle las funciones pertinentes. No lo hemos hecho internamente porque el vocabulario debe nutrirse del dataset de entrenamiento ya pre-procesado [falta].

También necesitaremos crear un Textset para el dataset de inferencia, para el cual no contamos con las etiquetas. En el caso de no pasar etiquetas generamos una lista llena de NaNs del mismo largo que la lista de documentos.

train_ds = Textset(train_índices, etiquetas_train_índices)

len(train_ds)
18093
train_ds[10_000]
([8, 169, 1, 4652, 0, 17, 65], 40)

Es bastante similar a lo que una lista de tuplas podría lograr, aunque fue una buena oportunidad para juntar los documentos y las etiquetas que luego de cargar el DataFrame y hasta ahora recorrieron caminos separados. Lo realmente importante es el DataLoader, no podemos usar una lista como dataset porque requiere que sea una instancia de Dataset.

DataLoader

DataLoader es un iterable. Los iterables son colecciones de elementos que se pueden recorrer; implementan el método __iter__, del que se espera que devuelva un objeto iterador (iterador = iter(iterable)). A su vez el iterador implementa el método __next__ que se encarga devolver secuencialmente los elementos de la colección hasta que se agota; una vez que esto sucede el iterador debe ser descartado y en todo caso le pedimos al iterable que nos arme un nuevo iterador. Cuando usamos la construcción for ítem in iterable, el intérprete de Python implícitamente obtiene un iterador.

Ver la sección de interables en el tutorial de Python.

lista = iter(['uno','dos'])

next(lista)
'uno'
next(lista)
'dos'
next(lista)
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
<ipython-input-203-cfa830c9416d> in <module>
----> 1 next(lista)

StopIteration: 

No hay próximo elemento. Cuando se llega al fin del iterador se levanta la excepción StopIteration.

Suficientes detalles por ahora. Todo esto para decir que DataLoader es un iterable que particularmente devuelve un iterador distinto cada vez, a diferencia de una lista en la que los elementos siempre se recorren en el mismo orden. Es decir, se trata de una colección de lotes pero cada iterador agrupa lotes según como dicte BatchSampler, que suele ser aleatorio.

En cada época le pedimos un iterador a DataLoader, por lo que recorremos todo el Dataset agrupado en lotes de manera diferente cada vez.

from torch.utils.data import DataLoader

train_dl = DataLoader(train_ds, batch_size=32, shuffle=True)

Le estamos diciendo a DataLoader que queremos lotes de 32 muestras (batch_size) y que el armado de los lotes sea aleatorio (shuffle).

un_lote = next(iter(train_dl))
un_lote
[[tensor([  12, 5168,   26,    9,   16,    8,   24,   10,    8,   15,   49,   49,
            46,   16,   15,    8,    1,   62,   26,   12,    8,    7,   12,  157,
            44, 2082,    5,   62,    1,   76,    8,   74]),
  tensor([ 140,   10,   75,    4,    4,   22,   84, 1519,   48,   75,   27,  357,
           105,   40,   48, 1911,  213,  585,   14,   48,   19,  203,  102,  164,
            57,    0,    2,   22, 1009,  262,  274,  630]),
  tensor([  17,   51,  371, 1058,   64,    4,   71,   27,   17,    9,    1,    2,
            59,   57,    2,    6,    0,  928,   62,    6, 1973,    3,   17,  713,
            10,   36,   56,    4,   18,   21,    1,    5]),
  tensor([  56,   67,   83,    0,   22,  724,   18,   24,    3,  765,   64,  207,
          1454,    2,  765,   36,  179,    2,  358,   13,   38,  236,   56,    3,
             3,    0,    5,   32,    1,   98, 1009,    6])],
 tensor([144, 153, 247,   3,  55,   0, 223,  15,  18,   6,  26, 160,  89, 199,
         149,  49, 260, 285,  13,   3, 198,  18,  23,   0,   1, 103,  35, 112,
         128,  20, 128,   3])]

Está bueno que ya veamos tensores de PyTorch porque vamos a necesitar los datos en forma de tensor para alimentar a la red neuronal. Sin embargo, algo no parece andar bien con el lote que acabamos de obtener.

len(un_lote)
2

Tenemos dos elementos adentro del lote, podríamos pensar que el primero agrupa documentos y el segundo, etiquetas.

type(un_lote[1]), len(un_lote[1])
(torch.Tensor, 32)

Las etiquetas del lote están perfecto, son un tensor de una dimensión con largo 32.

type(un_lote[0]), len(un_lote[0])
(list, 4)

En cambio la agrupación de documentos no tiene sentido. Es otra lista de tamaño 4 con tensores adentro. ¿Qué está pasando?

Tensores

El problema parece radicar en los tensores. Son estructuras que las podemos imaginar como una columna cuando tienen una dimensión, una tabla cuando son dos, un cubo cuando tres...

Los tensores son similares a los ndarrays de NumPy, con el aditivo que también pueden ser usados en la GPU para acelerar los cómputos. Ver más de tensores en el tutorial de PyTorch.

En el caso de los documentos que a la altura del Dataset son listas de listas de índices, son dos dimensiones, y al llevarlos a una tabla vemos que tendríamos tantas filas como documentos y tantas columnas como índices tenga el documento más largo de la colección pero que no todos los documentos tienen tantos índices como columnas la tabla.

índices = [
    [2,2],
    [4,4,4,4],
    [7,7,7,7,7,7,7],
]

índices
[[2, 2], [4, 4, 4, 4], [7, 7, 7, 7, 7, 7, 7]]
import torch

torch.tensor(índices)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-232-121200966211> in <module>
      1 import torch
      2 
----> 3 torch.tensor(índices)

ValueError: expected sequence of length 2 at dim 1 (got 4)

Como anticipamos, no le gustó nada.

Tókenes especiales

Lo mencionamos al pasar, a veces se utilizan tókenes especiales como <separador de palabra>, <separador de oración>, <inicio del texto>, <fin del texto>. Hay de todo tipo, según la tarea a realizar. Uno que está presente generalmente en los proyectos es el tóken de relleno <relleno> (en inglés padding).

El tóken de relleno nos va a servir para hacer que todos los documentos tengan el mismo largo y finalmente podamos convertirlos en un tensor. No lo vamos a hacer inmediatamente ya que no nos interesa que tengan el mismo largo en todo el dataset sino en todo el lote. Como los lotes son generados en el DataLoader, este último tendrá que encargarse de rellenar los documentos.

Vamos a modificar Vocab quien se encarga de la lista de tókenes para que incluya a <relleno>.

import numpy as np
from itertools import chain
from collections import Counter

class Vocab():
    @property
    def índice_relleno(self):
        return self.mapeo.get(self.tóken_relleno)
    
    def __init__(self, tóken_desconocido='<unk>', tóken_relleno='<pad>', frecuencia_mínima=0.0, frecuencia_máxima=1.0,
                 longitud_mínima=1, longitud_máxima=np.inf, stop_words=[], límite_vocabulario=None):
        
        self.tóken_desconocido = tóken_desconocido
        self.tóken_relleno = tóken_relleno
        self.frecuencia_mínima = frecuencia_mínima
        self.frecuencia_máxima = frecuencia_máxima
        self.longitud_mínima = longitud_mínima
        self.longitud_máxima = longitud_máxima
        self.stop_words = stop_words
        self.límite_vocabulario = límite_vocabulario
    
    # ningún cambio aquí
    def reducir_vocabulario(self, lote):
        contador_absoluto = Counter(chain(*lote))
        
        contador_documentos = Counter()
        
        for doc in lote:
            contador_documentos.update(set(doc))
        
        # frecuencia mínima
        if isinstance(self.frecuencia_mínima, int): # frecuencia de tóken
            vocabulario_mín = [tóken for tóken, frecuencia in contador_absoluto.most_common() if frecuencia >= self.frecuencia_mínima]
        else: # frecuencia de documento
            vocabulario_mín = [tóken for tóken, frecuencia in contador_documentos.most_common() if frecuencia/len(lote) >= self.frecuencia_mínima]
        
        # frecuencia máxima
        if isinstance(self.frecuencia_máxima, int): # frecuencia de tóken
            vocabulario_máx = [tóken for tóken, frecuencia in contador_absoluto.most_common() if self.frecuencia_máxima >= frecuencia]
        else: # frecuencia de documento
            vocabulario_máx = [tóken for tóken, frecuencia in contador_documentos.most_common() if self.frecuencia_máxima >= frecuencia/len(lote)]

        # intersección de vocabulario_mín y vocabulario_máx preservando el órden
        vocabulario = [tóken for tóken in vocabulario_mín if tóken in vocabulario_máx]

        # longitud
        vocabulario = [tóken for tóken in vocabulario if self.longitud_máxima >= len(tóken) >= self.longitud_mínima]
        
        # stop words
        vocabulario = [tóken for tóken in vocabulario if tóken not in self.stop_words]
        
        # límite
        vocabulario = vocabulario[:self.límite_vocabulario]
        
        return vocabulario
        
    def fit(self, lote):
        vocabulario = self.reducir_vocabulario(lote)
        
        if self.tóken_desconocido:
            vocabulario.append(self.tóken_desconocido)
        
        if self.tóken_relleno:
            vocabulario.insert(0, self.tóken_relleno)
        
        self.mapeo = {tóken: índice for índice, tóken in enumerate(vocabulario)}

        return self
    
    # ningún cambio aquí
    def transform(self, lote):
        if self.tóken_desconocido: # reemplazar
            return [[tóken if tóken in self.mapeo else self.tóken_desconocido for tóken in doc] for doc in lote]
        else: # ignorar
            return [[tóken for tóken in doc if tóken in self.mapeo] for doc in lote]
    
    # ningún cambio aquí
    def tókenes_a_índices(self, lote):
        lote = self.transform(lote)
        
        return [[self.mapeo[tóken] for tóken in doc] for doc in lote]
    
    # ningún cambio aquí
    def índices_a_tókenes(self, lote):
        mapeo_inverso = list(self.mapeo.keys())
        
        return [[mapeo_inverso[índice] for índice in doc] for doc in lote]
    
    def __len__(self):
        return len(self.mapeo)

El índice del tóken de relleno suele ser 0 y para continuar con esta tradición en vez de hacerle append al vocabulario le hicimos un prepend para que el tóken encabece el listado. Además usamos el decorador @property para tener un atributo índice_relleno (en vez de un método) que nos devuelva el índice del tóken.

v = Vocab().fit(train_docs)

v.índice_relleno
0

La función que rellena

def rellenar_documentos(lote, largos, índice_relleno):
    máximo_largo = max(largos)
    
    return [doc + [índice_relleno] * (máximo_largo - largos[i]) for i, doc in enumerate(lote)]

Le tenemos que pasar el lote, el largo o tamaño de cada documento del lote y el índice de relleno.

índices = [
    [2,2],
    [4,4,4,4],
    [7,7,7,7,7,7,7],
]

largos = [2,4,7]

rellenos = rellenar_documentos(índices, largos, v.índice_relleno)

rellenos
[[2, 2, 0, 0, 0, 0, 0], [4, 4, 4, 4, 0, 0, 0], [7, 7, 7, 7, 7, 7, 7]]
torch.tensor(rellenos)
tensor([[2, 2, 0, 0, 0, 0, 0],
        [4, 4, 4, 4, 0, 0, 0],
        [7, 7, 7, 7, 7, 7, 7]])

¡Ahora sí funcionó!

Tamaño del documento

Vamos a incluir el tamaño del documento (en cantidad de tókenes/índices) junto a cada ítem del dataset ya que nos va a hacer falta para la función que rellena.

from torch.utils.data import Dataset

class Textset(Dataset):
    def __init__(self, documentos, etiquetas=None):
            
        self.documentos = documentos
        self.etiquetas  = etiquetas or np.full(len(documentos), np.nan)

    def __len__(self):
        return len(self.documentos)
    
    def __getitem__(self, item):
        return self.documentos[item], len(self.documentos[item]), self.etiquetas[item]
train_ds = Textset(train_índices, etiquetas_train_índices)

train_ds[10_000]
([8, 169, 1, 4652, 0, 17, 65], 7, 40)

Bonus: AtributoDiccionario

¿Alguna vez quisiste acceder a los elementos de un diccionario como si fuesen atributos de un objeto? Es decir así

d = {'uno':1, 'dos':2, 'tres':3}

d.uno # => 1

en vez de así

d['uno'] # => 1

Con esta magia ahora es posible:

class AtriDicc():
    def __init__(self, *args, **kwargs):
        self.__dict__ = dict(*args, **kwargs)
    
    def __repr__(self):
        return repr(self.__dict__)
AtriDicc(uno=1, dos=2, tres=3).uno
1

Vamos a pimpiar la clase Textset con esto para que en vez de devolver elementos del dataset como tuplas (documento, largo, etiqueta) en el que debemos acordarnos que el orden de los elementos, devolvemos un AtriDicc en el que accedemos las cosas por su nombre y es más cómodo que un diccionario.

from torch.utils.data import Dataset

class Textset(Dataset):
    def __init__(self, documentos, etiquetas=None):
            
        self.documentos = documentos
        self.etiquetas  = etiquetas or np.full(len(documentos), np.nan)

    def __len__(self):
        return len(self.documentos)
    
    def __getitem__(self, item):
        return AtriDicc(
            documento = self.documentos[item],
            largo = len(self.documentos[item]),
            etiqueta =  self.etiquetas[item],
        )
train_ds = Textset(train_índices, etiquetas_train_índices)

train_ds[10_000].documento
[8, 169, 1, 4652, 0, 17, 65]

Función collate

Collate significa juntar diferentes piezas de información para ver sus similaridades y diferencias, también puede ser colectar y organizar las hojas de un reporte, un libro. En el contexto de DataLoader quiere decir arreglar el lote. Entonces esta función recibe una lista de elementos del Dataset, en nuestro caso una lista de de AtriDiccs, y debe devolver el lote en una forma útil y en lo posible realizar conversiones a tensores.

DataLoader posee una collate function por defecto que utiliza internamente y que en muchos casos funciona correctamente, pero otros como ahora que tenemos documentos de distinto largo nos toca definir una función propia.

def rellenar_lote(lote):
    """Prepara lotes para ingresar a nn.Embedding"""
    documentos = [elemento.documento for elemento in lote]
    largos     = [elemento.largo     for elemento in lote]
    etiquetas  = [elemento.etiqueta  for elemento in lote]

    rellenos = rellenar_documentos(documentos, largos, v.índice_relleno)
    
    return AtriDicc(
        documentos = torch.tensor(rellenos),
        etiquetas  = torch.tensor(etiquetas),
    )

Cuando instanciamos un DataLoader le pasamos la función que acabamos de definir.

train_dl = DataLoader(train_ds, collate_fn=rellenar_lote, batch_size=3, shuffle=True)
un_lote = next(iter(train_dl))
un_lote.documentos
tensor([[781,  31,  17, 104, 111,   9, 383,  93,  18,  11, 489,   0,   0],
        [ 20,   4,  11,   7, 272,  78,  29,  96,   5, 396,  16,  86,  16],
        [ 26,  69,  17, 313,   4, 258,  22,   4, 102,   0,   0,   0,   0]])
un_lote.etiquetas
tensor([ 80, 316,  16])

Funciona de maravillas.

Una función alternativa

La función anterior es compatible con el módulo de PyTorch nn.Embedding que suele se la puerta de entrada en los modelos de procesamiento de texto. Todavía no hemos hablado nada de los embeddings. Quizás sea un momento para mencionar a nn.EmbeddingBag, que tiene requerimientos completamente diferentes al primer módulo.

def offsetear_lote(lote):
    """Prepara lotes para ingresar a nn.EmbeddingBag"""
    documentos = [torch.tensor(elemento.documento) for elemento in lote]
    offsets    = [0] + [elemento.largo for elemento in lote][:-1] 
    etiquetas  = [elemento.etiqueta for elemento in lote]

    return AtriDicc(
        documentos = torch.cat(documentos),
        offsets    = torch.tensor(offsets).cumsum(dim=0),
        etiquetas  = torch.tensor(etiquetas),
    )

Esta función yuxtapone los documentos por un lado, y por otro (offsets) indica cuándo comienza cada documento en ese continuo.

train_dl = DataLoader(train_ds, collate_fn=offsetear_lote, batch_size=3, shuffle=True)
un_lote = next(iter(train_dl))
un_lote.documentos
tensor([  35,   14,    8,  544,   46,    6, 2493,   30,  384,    2, 1062,   27,
         236,    5,  778,  378,   22,    4,   53,    1,  866,    9,   17, 1564,
         109,   68,  186,   16,    6, 1419])
un_lote.offsets
tensor([ 0,  9, 16])

Avanzado: Memory pinning

https://pytorch.org/docs/stable/data.html#memory-pinning

Esta técnica consiste en pre-disponibilizar los tensores en el GPU. Llevar un lote del disco o de la memoria a la GPU insume tiempo y puede causar un cuello de botella durante el entrenamiento.

Pasar la opción pin_memory=True al DataLoader pondrá automáticamente a los tensores en la pinned memory. Por defecto funciona con tensores y colecciones de tensores. Cuando los lotes son de un tipo personalizado (por ejemplo AtriDicc), normal cuando se utiliza una collate function propia, es necesario que el tipo defina el método pin_memory.

class AttrDict():
    def __init__(self, *args, **kwargs):
        self.__dict__ = dict(*args, **kwargs)
    
    def __repr__(self):
        return repr(self.__dict__)

    def pin_memory(self):
        for atributo, valor in self.__dict__.items():
            self.__dict__[atributo] = valor.pin_memory() if hasattr(valor, 'pin_memory') else valor

        return self

El pre-procesamiento hasta ahora

vocabulario_documentos = Vocab().fit(train_docs)

train_índices = vocabulario_documentos.tókenes_a_índices(train_docs)
valid_índices = vocabulario_documentos.tókenes_a_índices(valid_docs)
infer_índices = vocabulario_documentos.tókenes_a_índices(infer_docs)
train_ds = Textset(train_índices)
valid_ds = Textset(valid_índices)
infer_ds = Textset(infer_índices)

Solo definimos el modelo, no lo entrenamos. Elegimos la función offsetear_lote ya que el modelo usa nn.EmbeddingBag.

from torch.utils.data import DataLoader

train_dl = DataLoader(train_ds, batch_size=32, shuffle=True,  collate_fn=offsetear_lote, pin_memory=True)

# validación e inferencia no requieren `shuffle`
valid_dl = DataLoader(valid_ds, batch_size=32, shuffle=False, collate_fn=offsetear_lote, pin_memory=True)
infer_dl = DataLoader(infer_ds, batch_size=32, shuffle=False, collate_fn=offsetear_lote, pin_memory=True)

Hacemos unas definiciones necesarias. No es el punto de lo que queremos mostrar, lo podés pasar por alto. Para mayores detalles recomendamos ver Text Classification with TorchText.

import torch.nn as nn
import torch.nn.functional as F

DIM_EMBEDDINGS = 8

class ClasificadorBolsa(nn.Module):
    def __init__(self, vocab_size, embed_dim, num_class):
        super().__init__()
        self.embedding = nn.EmbeddingBag(vocab_size, embed_dim, sparse=False, mode='max')
        self.fc = nn.Linear(embed_dim, num_class)

    def forward(self, text, offsets):
        embedded = self.embedding(text, offsets)
        return self.fc(embedded)

modelo = ClasificadorBolsa( len(vocabulario_documentos), DIM_EMBEDDINGS, len(vocabulario_etiquetas) ).to(device)

Ídem.

if torch.cuda.is_available():
    device = torch.device('cuda')
else:
    device = torch.device('cpu')

Nuevamente.

optimizador = torch.optim.Adam(modelo.parameters(), lr=1e-3, weight_decay=1e-5)
criterio = nn.CrossEntropyLoss()

Alto aquí. Así es como se usa un DataLoader.

ÉPOCAS = 10

for época in range(ÉPOCAS):
    for lote in train_dl:
        optimizador.zero_grad()

        predicciones = modelo(lote.documentos.to(device), lote.offsets.to(device))
        pérdida = criterio(predicciones, lote.etiquetas.to(device))

        pérdida.backward()
        optimizador.step()

Con esto concluye la segunda parte. Quedaron los embeddings para la tercera. Ahora deberíamos tener más control sobre la carga de datos en PyTorch. Muchos ejemplos de uso y tutoriales dan por sentada esta parte al utilizar datasets de ejemplos, que ya vienen pre-procesados y/o que la carga por defecto de PyTorch maneja sin inconvenientes.