Curso / HF LLM Course / Capítulo 5
HF LLM Course · 5 de 12

Hugging Face · LLM Course · Capítulo 5

La librería Datasets: de un CSV local a un buscador semántico

El Capítulo 3 ya usó datasets para cargar y tokenizar. Este capítulo es todo lo que se quedó afuera: datos que no están en el Hub, datasets que no entran en RAM, y — el cierre perfecto — construir tu propio motor de búsqueda semántica desde cero.

Antes de leercap. 2 §3, tokenizers · cap. 3 §1, preparar datos

🎯 Al terminar vas a poder

  • Cargar cualquier fuente — CSV, JSON, un DataFrame, un diccionario en memoria — como Dataset, local o remota.
  • Usar map, filter, remove_columns y with_format con criterio: qué cambia el dato, qué cambia solo la vista, y cuándo batched y num_proc ayudan de verdad.
  • Explicar por qué un dataset de 20 GB no necesita 20 GB de RAM, y cuándo pasar a streaming=True.
  • Calcular un embedding de oración con mean pooling y attention mask, normalizarlo, y explicar por qué eso convierte el producto interno en similitud coseno.
  • Armar un buscador semántico con FAISS en un notebook, y decir qué le falta para ser el RAG de producción que ya tenés.

1. Cuando el dataset no está en el Hub

load_dataset() no está atado al Hub — el primer argumento puede ser un tipo de archivo en vez de un nombre de dataset:

FormatoScriptEjemplo
CSV / TSVcsvload_dataset("csv", data_files="archivo.csv")
JSON / JSON Linesjsonload_dataset("json", data_files="archivo.jsonl")
Texto planotextload_dataset("text", data_files="archivo.txt")

data_files acepta un path local, una URL remota, o un diccionario que mapea nombres de split a archivos — y descomprime .gz/.zip/.tar automáticamente:

from datasets import load_dataset

data_files = {"train": "train.json.gz", "test": "test.json.gz"}
dataset = load_dataset("json", data_files=data_files, field="data")

Cargar desde una URL es literalmente lo mismo — data_files no distingue entre "local" y "remoto", así que todo lo que aprendas acá aplica sin cambios a un archivo en tu disco o en un bucket.

2. Cortar y trocear

Para tener una primera impresión de un dataset grande, combiná shuffle() y select() para tomar una muestra chica; usá filter() (con una lambda) para sacar filas problemáticas antes de mapear una función que no tolera esos casos:

muestra = dataset["train"].shuffle(seed=42).select(range(1000))

# .map() sobre un valor None revienta — filtrar primero
dataset = dataset.filter(lambda x: x["condition"] is not None)
dataset = dataset.map(lambda x: {"condition": x["condition"].lower()})

El argumento batched=True de map() no es un detalle menor — es la diferencia entre minutos y segundos. Los números reales del curso, tokenizando el mismo corpus:

ConfiguraciónTokenizer rápidoTokenizer lento
batched=False59.2s5min 3s
batched=True10.8s4min 41s
batched=True, num_proc=86.5s41.3s

Un tokenizer rápido con batched=True es ~30× más rápido que uno lento sin batchear — la parte en Rust de tokenizers paraleliza internamente, algo que num_proc (multiprocessing de Python) no puede igualar para el tokenizer rápido, pero sí ayuda mucho al lento.

Cuando una función de map() cambia el número de filas (por ejemplo, tokenizar con return_overflowing_tokens=True parte una reseña larga en varios ejemplos), hay que decirle explícitamente qué columnas viejas descartar o cómo remapearlas — si no, el error es un ArrowInvalid por longitudes que no coinciden. La forma más simple es remove_columns, que además es lo que querés casi siempre después de tokenizar — el modelo no acepta columnas de texto:

def tokenize_and_split(examples):
    return tokenizer(examples["review"], truncation=True, max_length=128,
                     return_overflowing_tokens=True)   # una reseña larga → varios pedazos

tokenized = dataset.map(tokenize_and_split, batched=True,
                        remove_columns=dataset["train"].column_names)   # quedan solo las columnas nuevas
print(len(dataset["train"]), len(tokenized["train"]))   # p. ej. 138514 → 206772: más filas que antes

Para EDA con Pandas, set_format("pandas") cambia solo el formato de salida, no el dato subyacente (que sigue siendo Arrow) — y train_test_split() te da un split de validación con una línea, con semilla para reproducibilidad. Lo mismo vale para el formato que va al modelo: with_format("torch") hace que cada acceso devuelva tensores en vez de listas, sin convertir nada en disco. La diferencia entre las dos variantes es solo de estilo — set_format modifica el objeto en el lugar, with_format devuelve uno nuevo:

ds = tokenized["train"]
print(type(ds[0]["input_ids"]))          # <class 'list'>

ds_torch = ds.with_format("torch")          # nuevo objeto; ds sigue devolviendo listas
print(type(ds_torch[0]["input_ids"]))    # <class 'torch.Tensor'>

ds.set_format("torch", columns=["input_ids", "attention_mask", "label"])   # en el lugar, y solo esas columnas
ds.reset_format()                            # vuelve a listas / Python nativo

Lo que ninguna de las dos hace es tocar los datos: el archivo Arrow es el mismo antes y después. Si querés una conversión real (una columna nueva con otro tipo), eso es un map.

3. Datasets que no entran en RAM: Apache Arrow y Memory-Mapping

La regla empírica clásica de Pandas establece que manipular un dataset requiere entre 5× y 10× su tamaño en memoria RAM. Si intentás cargar un archivo JSON o CSV de 20 GB en Pandas, el proceso colapsará exigiendo entre 100 y 200 GB de memoria física. La librería datasets de Hugging Face erradica este cuello de botella utilizando el estándar Apache Arrow y mapeo directo a memoria virtual del sistema operativo (memory mapping).

Arquitectura en Memoria: Filas de Python vs Columnas de Apache Arrow

¿Por qué Pandas y Python estándar sufren una explosión de memoria mientras Arrow permanece constante?

  • Sobrecarga por Objeto en Python (Row-Oriented): En Python estándar, cada fila de una tabla es un diccionario o una lista que contiene punteros a instancias individuales de PyObject. Un simple entero de 64 bits (que a nivel de CPU ocupa 8 bytes) en Python consume al menos 28 bytes de encabezado (tipo de objeto, conteo de referencias). Una cadena de caracteres pequeña añade 50 bytes adicionales más su longitud. Un dataset con 10 millones de filas y 10 columnas instancia 100 millones de objetos dispersos en el heap, provocando una fragmentación severa y destruyendo la localidad de caché del procesador (L1/L2 cache misses superiores al 40%).
  • Disposición Columnar de Apache Arrow: Arrow almacena los datos en memoria por columnas contiguas, sin envoltorios de Python. Para una columna de texto, Arrow utiliza únicamente tres buffers contiguos de bytes:
    1. Validity Bitmap: Un arreglo de bits contiguo donde cada bit (0 o 1) indica si el valor es nulo o válido (1 byte cubre 8 filas).
    2. Offsets Buffer: Un buffer contiguo de enteros de 32 o 64 bits que marca el inicio y fin en bytes de cada string dentro del buffer de datos.
    3. Data Buffer: Una secuencia contigua y compacta de bytes con todos los caracteres concatenados.

Zero-Copy IPC y Paginación por Demanda (Demand Paging)

Debido a que el formato de memoria de Arrow está estrictamente estandarizado, datasets implementa Zero-Copy IPC (Inter-Process Communication): transferir datos entre procesos de Python, extensiones de C++, Rust o motores analíticos (como Polars o DuckDB) no requiere serializar ni deserializar datos. Ambos procesos simplemente comparten un puntero a la misma región de memoria compartida sin costo de copia.

Cuando cargás un dataset local de 50 GB desde disco mediante load_from_disk() o load_dataset():

  1. El archivo .arrow en disco se mapea al espacio de memoria virtual del proceso con mmap(MAP_SHARED).
  2. La memoria residente (RSS) del proceso de Python permanece prácticamente en cero bytes al iniciar.
  3. Cuando el bucle de entrenamiento o el DataLoader solicita el batch i, la CPU genera Page Faults de hardware y el kernel de Linux transfiere únicamente las páginas físicas de 4 KB correspondientes a ese lote desde el disco al Page Cache de la RAM.
  4. Una vez procesadas y copiadas a la VRAM de la GPU, si el sistema sufre presión de memoria, el kernel puede descartar inmediatamente esas páginas de RAM sin escribirlas a disco (ya que son de solo lectura).

Streaming Masivo con IterableDataset

Cuando el corpus supera la capacidad del almacenamiento local (por ejemplo, datasets de preentrenamiento como FineWeb o RedPajama, de decenas de terabytes), ni siquiera el disco es suficiente. En este escenario, streaming=True conmuta la arquitectura a un IterableDataset:

from datasets import load_dataset

# Descarga diferida (lazy evaluation) por HTTP chunked transfer
dataset = load_dataset("HuggingFaceFW/fineweb", split="train", streaming=True)
print(next(iter(dataset)))  # Lee el primer ejemplo; el disco local no almacena nada

Un IterableDataset opera como un generador de Python sobre conexiones HTTP persistentes. Para mezclar ejemplos sin descargar el archivo completo, IterableDataset.shuffle(buffer_size=10_000) mantiene una ventana deslizante de 10.000 ejemplos en memoria RAM, extrayendo pseudo-aleatoriamente elementos del buffer y reponiéndolos con nuevos datos del flujo entrante.

La regla de Pandas dice que necesitás 5-10× la memoria del tamaño del dataset. datasets no sigue esa regla porque no carga el dataset a RAM — lo trata como un archivo memory-mapped en formato Apache Arrow: podés operar sobre 20 GB de datos con una fracción de esa RAM, porque el sistema operativo trae a memoria solo las páginas que realmente tocás.

Cuando ni siquiera el disco alcanza (el ejemplo del original: 825 GB), streaming=True cambia el tipo de retorno a IterableDataset — descarga y procesa un ejemplo a la vez, sin bajar el dataset completo:

dataset = load_dataset("json", data_files=url, split="train", streaming=True)
next(iter(dataset))  # primer ejemplo, sin haber descargado el resto

IterableDataset.shuffle(buffer_size=...) mezcla dentro de un buffer, no el dataset entero (no puede — no lo tiene completo en ningún lado), e interleave_datasets() combina varias fuentes streameadas en una sola, alternando entre ellas.

Tres formas de tocar un dataset grande: todo a RAM, memory mapping, streaming Pandas carga el archivo entero a RAM y necesita de cinco a diez veces su tamaño. Un Dataset de Arrow queda en disco mapeado a memoria y el sistema operativo trae solo las páginas que se tocan. Con streaming el dataset remoto ni siquiera se descarga: los ejemplos llegan de a uno a un buffer chico. Pandas: todo a RAM pd.read_json(...) archivo de 20 GB en disco RAM: 100 a 200 GB 5 a 10 veces el archivo no entra en una laptop datasets: Arrow memory-mapped load_dataset(...) archivo Arrow de 20 GB en disco mapeado a memoria, no copiado RAM: solo las páginas que tocás el sistema operativo las trae y las suelta streaming: sin descargar load_dataset(..., streaming=True) dataset remoto de 825 GB ni el disco alcanza de a un ejemplo buffer de shuffle (buffer_size) RAM: unos ejemplos a la vez nada queda en disco · IterableDataset misma API en los tres casos: lo que cambia es dónde vive el dato y cuánto de él está en RAM en un momento dado
El mismo dataset de 20 GB, tres lugares distintos. Pandas lo copia entero a RAM; Arrow lo deja en disco y mapea a memoria solo las páginas que se leen; con streaming=True el dato ni siquiera se descarga y los ejemplos pasan de a uno por un buffer. Es el mismo problema resuelto a dos escalas: no cargar todo a RAM, y no bajar todo al disco.

🟢 Actualización 2026

El problema que este capítulo resuelve para "un dataset de texto" es, a escala de LLM, el problema por defecto: el preentrenamiento hoy se mide en billones de tokens, no en gigabytes. Memory mapping y streaming dejaron de ser un truco para casos extremos — son la forma normal de tocar cualquier corpus de preentrenamiento serio.

4. Crear tu propio dataset

El ejemplo del original — scrapear los issues de GitHub del propio repo de datasets vía la REST API, filtrar pull requests, sumar los comentarios de cada issue — sigue siendo el mejor ejemplo didáctico de un patrón real: API pública → JSON crudo → load_dataset("json", ...). No hay nada especial en el formato final; cualquier fuente que puedas convertir a JSON Lines entra por la misma puerta que usaste en la sección 1.

Y ni siquiera hace falta pasar por un archivo: si los datos ya están en memoria — un DataFrame que armaste en Pandas, o listas paralelas que salieron de un scraper — Dataset.from_pandas y Dataset.from_dict los convierten a Arrow directamente:

from datasets import Dataset

ds = Dataset.from_dict({"text": ["primer issue", "segundo issue"], "n_comments": [3, 0]})
ds = Dataset.from_pandas(df)               # agrega una columna "__index_level_0__" si el índice del df no es el default
ds = Dataset.from_pandas(df, preserve_index=False)   # ...salvo que le digas que no
print(ds.features)                          # los tipos se infieren: Value('string'), Value('int64')...

Es el camino inverso de set_format("pandas"): de Pandas a Arrow, en vez de una vista Arrow con cara de Pandas. Un patrón habitual es hacer la limpieza fea en un DataFrame chico y pasar a Dataset para el map tokenizado y el push_to_hub.

Una vez armado, subirlo es la misma llamada que ya conocés del Capítulo 4 — push_to_hub() también existe en Dataset, no solo en modelos:

dataset.push_to_hub("mi-usuario/github-issues")

Y como con los modelos, un dataset sin documentar es un dataset a medio publicar — la dataset card (mismo README.md, mismo principio) es lo que le permite a otra persona (o a vos, en seis meses) saber qué hay adentro sin tener que inspeccionar filas a mano.

5. Búsqueda semántica con FAISS

Esta es la sección que más vale la pena mirar de cerca, porque es — a mano, en unas pocas líneas — exactamente lo que hace un sistema de RAG: convertir texto a vectores, e indexarlos para buscar por significado en vez de por palabra exacta.

  1. Embeber cada documento: un modelo encoder-only (Capítulo 1) convierte cada texto en un vector, agrupando el estado oculto del token [CLS] como resumen de toda la secuencia.
  2. Indexar los vectores: Dataset.add_faiss_index(column="embeddings") arma una estructura de búsqueda por similitud sobre esa columna.
  3. Embeber la pregunta con el mismo modelo, y pedirle al índice los vecinos más cercanos:
pregunta_emb = get_embeddings(["¿Cómo cargo un dataset offline?"])
scores, ejemplos = dataset.get_nearest_examples("embeddings", pregunta_emb, k=5)

Ni una palabra de la pregunta necesita aparecer literalmente en la respuesta — lo que se compara son vectores de significado, no substrings. Esa es la diferencia de fondo entre búsqueda semántica y Ctrl+F.

Búsqueda semántica: indexar una vez, consultar muchas veces con el mismo encoder Arriba, cada documento pasa por el encoder, el pooling y la normalización L2 y su vector entra al índice, una sola vez. Abajo, la pregunta pasa por el mismo encoder y el mismo pooling, y el índice devuelve los k vectores de mayor producto interno, que sobre vectores de norma 1 es la similitud coseno. INDEXAR · UNA VEZ documentos encoder e5-large · mpnet pooling + normalizar mean con mask · ‖v‖ = 1 un vector por doc índice FAISS · ChromaDB el mismo modelo y el mismo pooling para documentos y pregunta CONSULTAR · CADA VEZ pregunta «¿cómo cargo offline?» encoder el mismo pooling + normalizar el mismo busca los k vecinos top-k k documentos ordenados por coseno con vectores de norma 1, producto interno = coseno = mismo orden que la distancia L2: el índice default de FAISS también sirve
El buscador semántico de §5 y, sin cambiar nada de concepto, el RAG de tu proyecto. Ninguna palabra de la pregunta tiene que aparecer en el documento: se comparan vectores de significado, y lo único que hace falta para que la comparación sea justa es el mismo encoder, el mismo pooling y vectores normalizados en los dos caminos.

🔧 Esto es literalmente el RAG de tu propio proyecto

Cambiá multi-qa-mpnet-base-dot-v1 por intfloat/multilingual-e5-large, add_faiss_index() por una collection de ChromaDB, y "issues de GitHub" por "81 obras de Lacan y Freud", y tenés exactamente la memoria de interpretante-lacaniano — 45.904 pasajes embebidos, indexados, y consultados por similitud semántica. La diferencia entre el ejemplo de este capítulo y tu sistema en producción no es de concepto, es de escala e infraestructura de persistencia.

🟢 Actualización 2026

Dataset.add_faiss_index() es perfecto para un notebook con unos miles de filas — el índice vive en memoria y desaparece con el proceso. Para algo persistente, que crezca con el tiempo y soporte filtrar por metadata (por autor, por fecha, por idioma), la pieza que se usa en producción es una base de datos vectorial dedicada (ChromaDB, Qdrant, pgvector...) — resuelve el mismo problema de fondo, con almacenamiento en disco e inserciones incrementales en vez de reconstruir el índice cada vez.

Un detalle de pooling que vale la pena señalar porque cambia según el modelo: el original usa CLS pooling (tomar el vector del token especial [CLS]), pero muchos modelos de embeddings modernos —incluido multilingual-e5-large— promedian los vectores de todos los tokens en vez de quedarse con uno solo. Cuál usar no es una preferencia estética: hay que usar el que el modelo específico espera, documentado en su model card.

Las dos variantes, en código. El detalle que define la validez matemática del mean pooling es la attention mask:

Formalmente, dado un lote de secuencias donde cada token i tiene un estado oculto de salida hi∈Rd y una máscara binaria mi∈{0,1} (donde 1 representa texto real y 0 representa relleno [PAD]), el embedding condensado de la oración e∈Rd se define estrictamente como:

e=∑i=1Nhi⋅mimax∑i=1Nmi,ϵ

¿Por qué es indispensable este enmascaramiento y por qué no se puede simplemente dividir por la longitud total del tensor N?

  • Los tokens de padding NO son vectores nulos (hpad≠0): Debido a las capas de Layer Normalization (LN(x)=x-μσγ+β) y a los biases de las capas feed-forward en la arquitectura Transformer, las posiciones correspondientes a [PAD] producen vectores con magnitud significativa. Si se promedian sin máscara, los tokens de padding actúan como un atractor artificial que desvía drásticamente el significado semántico del texto.
  • Sesgo de Dilución Dependiente de la Longitud: Si se dividiera la suma enmascarada por la longitud máxima del batch Nmax (ej. 512) en lugar de la longitud real Nreal=∑mi (ej. 10 tokens), la representación de una oración corta sufriría una atenuación artificial de más de 50×, distorsionando cualquier comparación de distancias o proyecciones.
Mean pooling con attention mask La salida del encoder tiene un vector por posición, incluidas las de relleno. Se multiplica cada fila por la mask, que es 1 en texto real y 0 en padding, se suman las filas, y se divide por la cantidad de tokens reales, cuatro, no por el largo del tensor, seis. Después se normaliza a norma 1. last_hidden_state [CLS] cargar offline [SEP] [PAD] [PAD] mask 1 1 1 1 0 0 × tokens * mask filas de padding en 0 .sum(1) suma [hidden] ÷ 4 mask.sum(1) no ÷ 6 normalize(p=2) → ‖v‖ = 1 → el producto interno entre dos de estos vectores es su coseno sin la mask, el promedio mete los vectores del relleno; sin dividir por los tokens reales, las frases cortas salen diluidas
Las dos líneas que separan «funciona» de «funciona bien». La mask anula las filas de relleno antes de sumar, y el divisor es la cantidad de tokens reales de cada frase, no el largo del tensor; la normalización final es lo que hace que el índice mida coseno.
import torch
from transformers import AutoTokenizer, AutoModel

tokenizer = AutoTokenizer.from_pretrained(model_ckpt)
model = AutoModel.from_pretrained(model_ckpt)

def cls_pooling(model_output):
    return model_output.last_hidden_state[:, 0]          # el vector del primer token ([CLS])

def mean_pooling(model_output, attention_mask):
    tokens = model_output.last_hidden_state                # [batch, seq, hidden]
    mask = attention_mask.unsqueeze(-1).float()             # [batch, seq, 1]: 1 en texto real, 0 en padding
    return (tokens * mask).sum(1) / mask.sum(1).clamp(min=1e-9)

def get_embeddings(textos, pooling="mean"):
    enc = tokenizer(textos, padding=True, truncation=True, return_tensors="pt")
    with torch.no_grad():
        out = model(**enc)
    emb = mean_pooling(out, enc["attention_mask"]) if pooling == "mean" else cls_pooling(out)
    return torch.nn.functional.normalize(emb, p=2, dim=1)   # norma 1 → producto interno = coseno

Demostración Matemática: Equivalencia en la Hiperesfera Unitaria Sd-1

La última línea del pipeline de embeddings (torch.nn.functional.normalize(emb, p=2, dim=1)) es la pieza angular que unifica la geometría del espacio vectorial. FAISS por defecto implementa índices basados en Distancia Euclidiana (L2), mientras que los modelos de representación semántica se entrenan maximizando la Similitud Coseno o el Producto Interno.

Veamos la demostración paso a paso de por qué, bajo normalización euclidiana, ambas métricas inducen exactamente el mismo orden topológico:

Sean dos vectores normalizados en la hiperesfera unitaria u,v∈Sd-1, de modo que ∥u∥2=∥v∥2=1. Desarrollemos el cuadrado de su distancia euclidiana:

∥u-v∥22=⟨u-v,u-v⟩=∥u∥22+∥v∥22-2⟨u,v⟩

Sustituyendo las normas unitarias (∥u∥22=1 y ∥v∥22=1):

∥u-v∥22=1+1-2⟨u,v⟩=2-2cos(θ)

Despejando la similitud coseno (o producto interno):

cos(θ)=⟨u,v⟩=1-12∥u-v∥22

Dado que la función f(x)=1-12x es estrictamente decreciente y monótona respecto al cuadrado de la distancia, se concluye la equivalencia de ranking:

argmaxvcos(θ)≡argmaxv⟨u,v⟩≡argminv∥u-v∥22≡argminv∥u-v∥2

Consecuencia práctica en FAISS y Bases Vectoriales: El índice por defecto de FAISS (IndexFlatL2) devuelve con exactitud matemática el mismo orden topológico de vecinos más cercanos que un índice de producto interno (IndexFlatIP). Sin normalizar, en cambio, un pasaje largo con norma ∥v∥2=10 superaría espuriamente en producto interno a un pasaje corto de norma ∥v∥2=1, aun cuando el pasaje corto compartiera un ángulo coseno mucho más cercano a la consulta.

Para configurar explícitamente el índice en FAISS con producto interno sobre vectores unitarios:

import faiss

dataset = dataset.map(lambda x: {"embeddings": get_embeddings(x["text"]).numpy()}, batched=True, batch_size=64)
dataset.add_faiss_index(column="embeddings", metric_type=faiss.METRIC_INNER_PRODUCT)

pregunta_emb = get_embeddings(["¿Cómo cargo un dataset offline?"]).numpy()[0]
scores, ejemplos = dataset.get_nearest_examples("embeddings", pregunta_emb, k=5)
# con vectores normalizados, scores son cosenos: 1.0 = idéntico, 0 = nada que ver

Ese es el mismo e5-large + ChromaDB de tu proyecto, en su forma mínima: e5 espera mean pooling y vectores normalizados (y los prefijos query: / passage: en el texto, según su model card), y ChromaDB con hnsw:space = "cosine" hace exactamente lo que hace acá METRIC_INNER_PRODUCT sobre vectores de norma 1.

6. Errores comunes

La mayoría viene de confundir "cambiar los datos" con "cambiar cómo los veo", o de esperar que map adivine.

SíntomaCausa probableQué hacer
ArrowInvalid: Column 1 named review expected length 1000 but got length 1463La función de map devuelve más (o menos) filas que las que recibió y las columnas viejas no se pueden alinearremove_columns=dataset.column_names en el map, o repetir las columnas viejas por cada pedazo nuevo (§2)
num_proc=8 con un tokenizer rápido tarda igual o más que sin élEl tokenizer rápido ya paraleliza en Rust; ocho procesos de Python solo agregan overhead de serializaciónbatched=True y listo; reservar num_proc para funciones de Python puro o tokenizers lentos (§2)
AttributeError: 'NoneType' object has no attribute 'lower' en medio de un mapFilas con None en la columna; el JSON de origen tenía nulosfilter(lambda x: x["col"] is not None) antes del map (§2)
Hiciste set_format("torch"), guardaste con save_to_disk, y al recargar vuelven listasEl formato es una vista de salida, no una conversión: no se persiste con los datosVolver a llamar set_format/with_format después de cargar; si necesitás el dato convertido, es un map (§2)
El DataLoader falla con TypeError por una columna de stringsQuedaron columnas de texto en el dataset formateado a torchremove_columns del texto original, o set_format("torch", columns=[...]) solo con las columnas numéricas (§2)
El proceso se queda sin RAM al iterar un dataset que "debería" entrar por memory mappingSe convirtió todo a Python de golpe (dataset[:], list(dataset), to_pandas() sin filtrar)Iterar por batches, select un rango, o streaming=True (§3)
La búsqueda semántica devuelve siempre los mismos documentos largos, sin importar la preguntaEmbeddings sin normalizar en un índice de producto interno: los vectores "grandes" ganan siempreNormalizar L2 antes de indexar y antes de consultar (§5)
Resultados de búsqueda mediocres con un modelo que "debería" andar bienPooling incorrecto para el modelo (CLS donde espera mean, o al revés), o faltan los prefijos que el modelo espera en el textoLeer la model card del modelo de embeddings y copiar exactamente su receta (§5)

7. Resumen

  1. load_dataset() no depende del Hub — un tipo de archivo (csv/json/text) más data_files cubre cualquier fuente local o remota; y from_pandas/from_dict ni siquiera necesitan un archivo.
  2. map y filter cambian los datos; set_format/with_format cambian solo cómo los ves. Si map cambia el número de filas, remove_columns no es opcional.
  3. map(batched=True) es la optimización más importante de este capítulo — hasta 30× más rápido combinado con un tokenizer rápido.
  4. Memory mapping (Arrow) y streaming resuelven el mismo problema en dos escalas distintas: no cargar todo a RAM, y no descargar todo al disco.
  5. Crear un dataset propio es API pública → JSON → load_dataset() → push_to_hub(), con una dataset card documentando qué hay adentro.
  6. Embeddings + un índice de similitud es búsqueda semántica — la misma idea detrás de cualquier sistema de RAG, incluido el que ya corre en tu propio proyecto. Mean pooling con attention mask y normalización L2 son las dos líneas que separan "funciona" de "funciona bien".

8. Ponete a prueba

Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.

1. ¿Qué diferencia hay entre dataset.map(f) y dataset.with_format("torch") en términos de lo que pasa en disco?

map ejecuta f sobre cada fila y escribe un archivo Arrow nuevo (cacheado) con el resultado: cambia los datos. with_format no escribe nada — registra que, al acceder, las columnas se devuelvan como tensores en vez de listas. Por eso el formato se pierde al guardar y recargar. (§2)

2. Tokenizás con return_overflowing_tokens=True y map da ArrowInvalid. ¿Por qué, y cuál es la salida más simple?

La función devolvió más filas de las que recibió (una reseña larga se partió en varias), y datasets no sabe qué hacer con las columnas viejas, que siguen teniendo el número original de filas. remove_columns=dataset.column_names descarta las viejas y deja solo las nuevas; alternativamente, repetís cada columna vieja según overflow_to_sample_mapping. (§2)

3. Un tokenizer rápido, batched=True: agregás num_proc=8 y no mejora. ¿Es un bug?

No. El tokenizer rápido ya paraleliza en Rust dentro de cada batch; ocho procesos de Python solo agregan el costo de serializar datos entre procesos. En la tabla de §2, el salto grande es batched=True (59 s → 11 s); num_proc ayuda mucho al tokenizer lento (5 min → 41 s), que sí es Python puro. (§2)

4. ¿Por qué datasets puede manejar 20 GB con 2 GB de RAM, y en qué caso ni eso alcanza?

Porque el dataset vive en disco en formato Arrow y se mapea a memoria: el sistema operativo trae solo las páginas que tocás. No alcanza cuando ni el disco alcanza (el ejemplo de 825 GB del original) — ahí streaming=True devuelve un IterableDataset que descarga y procesa de a un ejemplo. (§3)

5. En mean pooling, ¿qué pasaría si dividieras por seq_len en vez de por mask.sum(1)?

Las frases cortas de un batch con padding quedarían "diluidas": sumaste solo sus tokens reales (la mask ya anuló el padding) pero dividís por el largo total del tensor, así que su vector sale más chico en proporción a cuánto padding tuvieron. Como después normalizás L2, la dirección no cambia — pero si no normalizás, el sesgo se mete directo en el ranking. (§5)

6. ¿Por qué normalizar los embeddings hace que un índice de producto interno mida similitud coseno?

El coseno entre dos vectores es su producto interno dividido por el producto de sus normas. Si las dos normas son 1, la división no hace nada y el producto interno es el coseno. Y sobre vectores de norma 1, ordenar por distancia L2 da el mismo orden que ordenar por coseno — así que el índice default de FAISS también funciona. (§5)

9. Para practicar

✏️ Ejercicio 1 — datos vs. vista, con tus propios ojos

Armá un dataset en memoria, tokenizalo con remove_columns, y comprobá qué cambia con with_format. Solo CPU; el tokenizer de DistilBERT pesa unos cientos de KB.

from datasets import Dataset
from transformers import AutoTokenizer

tok = AutoTokenizer.from_pretrained("distilbert-base-uncased")
ds = Dataset.from_dict({
    "text": ["cargar un dataset offline", "el modelo no entra en memoria", "cómo subo un dataset al hub", None],
    "label": [0, 1, 0, 1],
})
print(ds.features)

ds = ds.filter(lambda x: x["text"] is not None)   # sacá esta línea y mirá qué error da el map
tokenized = ds.map(lambda x: tok(x["text"], truncation=True), batched=True, remove_columns=["text"])
print(tokenized.column_names, type(tokenized[0]["input_ids"]))

t = tokenized.with_format("torch")
print(type(t[0]["input_ids"]), type(tokenized[0]["input_ids"]))   # tensor vs. list: el original no cambió
print(t.cache_files == tokenized.cache_files)                # mismo archivo Arrow detrás
Pista / qué esperar

Sin el filter, el map falla adentro del tokenizer con un error sobre NoneType. Después del map, column_names no incluye text. Y las dos últimas líneas muestran el punto de §2: el formato es una vista — mismos archivos de cache, distinto tipo al acceder.

✏️ Ejercicio 2 — mean pooling, normalización y coseno a mano

Con un modelo de embeddings chico que espera mean pooling, embebé cuatro frases y calculá la matriz de similitud. Comprobá que producto interno = coseno después de normalizar. CPU, ~90 MB de modelo.

import torch
from transformers import AutoTokenizer, AutoModel

ckpt = "sentence-transformers/all-MiniLM-L6-v2"
tok, model = AutoTokenizer.from_pretrained(ckpt), AutoModel.from_pretrained(ckpt)

def embed(textos):
    enc = tok(textos, padding=True, truncation=True, return_tensors="pt")
    with torch.no_grad():
        h = model(**enc).last_hidden_state
    mask = enc["attention_mask"].unsqueeze(-1).float()
    return (h * mask).sum(1) / mask.sum(1)

frases = ["How do I load a dataset without internet?", "Loading data offline",
          "The model runs out of GPU memory", "My cat sleeps all day"]
e = embed(frases)
print(e.norm(dim=1))                                   # normas distintas de 1 y distintas entre sí
en = torch.nn.functional.normalize(e, p=2, dim=1)
print((en @ en.T).round(decimals=2))                   # producto interno de normalizados
print(torch.nn.functional.cosine_similarity(e[0], e[1], dim=0))   # coseno de los crudos: mismo número
Pista / qué esperar

La diagonal de la matriz es 1,0; las frases 1 y 2 tienen la similitud más alta del resto (hablan de lo mismo con palabras distintas); la del gato es la más baja contra todas. El coseno de los vectores crudos coincide con el producto interno de los normalizados hasta el último decimal: es la identidad de §5.

✏️ Ejercicio 3 — el buscador semántico completo, en 15 líneas

Sumá FAISS al ejercicio anterior: indexá las frases y consultá con una pregunta que no comparta ninguna palabra con su respuesta. Necesitás pip install faiss-cpu.

import faiss
from datasets import Dataset

ds = Dataset.from_dict({"text": frases})
ds = ds.map(lambda x: {"emb": torch.nn.functional.normalize(embed(x["text"]), dim=1).numpy()}, batched=True)
ds.add_faiss_index(column="emb", metric_type=faiss.METRIC_INNER_PRODUCT)

q = torch.nn.functional.normalize(embed(["reading files with no network connection"]), dim=1).numpy()[0]
scores, ej = ds.get_nearest_examples("emb", q, k=2)
for s, t in zip(scores, ej["text"]):
    print(f"{s:.2f}  {t}")
Pista / qué esperar

Los dos primeros resultados son las frases sobre cargar datos offline, con scores claramente por encima de las otras dos, aunque "network connection" no aparece en ninguna. Probá sacar la normalización de la consulta (no del índice): el orden puede mantenerse pero los scores dejan de ser cosenos interpretables.

10. Fuentes y para seguir