Generadores de Python: Cómo procesar conjuntos de datos masivos sin colapsar tu computadora
El muro de ‘memoria llena’
¿Alguna vez has intentado abrir un archivo CSV enorme, solo para ver tu computadora convertirse en un pisapapeles muy caro? El cursor se congela, el ventilador zumba como un motor a reacción, y finalmente Python lanza ese temido mensaje: MemoryError.
Esto es lo que ocurre por debajo. Cuando creas una lista estándar de Python, le estás pidiendo a tu computadora que construya todo el producto terminado y lo almacene en tu RAM. Es como cocinar una comida para 1,000 personas y poner todos los platos en tu encimera de cocina al mismo tiempo. A menos que tengas una cocina enorme, te quedarás sin espacio.
Ahora veamos qué ocurre cuando creamos una lista con 50 millones de números.
import sys
try:
# Creating a list of 50 million integers
massive_list = [i for i in range(50000000)]
print(f"List size: {sys.getsizeof(massive_list) / (1024**2):.2f} MB")
except MemoryError:
print("Your computer just ran out of RAM!")
En una laptop estándar, esta lista podría ocupar más de 400 MB de RAM solo para la estructura misma — sin contar los enteros reales. Escala esto a 500 millones, o intenta cargar un archivo de registro de 10GB, y tu sistema se colapsa. Ese es el muro de ‘Memoria Llena’.
En lugar de una lista (la comida terminada), necesitamos una receta — un generador. Un generador no retiene los datos. Retiene las instrucciones para generar los datos, una pieza a la vez.
Conoce la palabra clave ‘yield’: el botón de pausa de tu código
En una función normal, usas return. Cuando Python llega a return, la función termina: entrega el resultado y limpia su memoria. yield funciona de manera diferente. Piensa en ello como un botón de ‘pausa’.
Cuando una función usa yield, se convierte en un generador. Llámala, y nada se ejecutará todavía. Solo se queda en espera. Pide un valor, y se ejecuta hasta que llega a yield, te entrega ese único valor, y luego se queda justo ahí, esperando la siguiente petición.
La parte complicada: la función ‘recuerda’ su estado. Comparemos una función estándar con un generador.
def get_numbers_list(n):
result = []
for i in range(n):
result.append(i)
return result
def get_numbers_generator(n):
for i in range(n):
yield i
# The list function builds everything first
numbers = get_numbers_list(5)
print(f"List: {numbers}")
# The generator function just waits
gen = get_numbers_generator(5)
print(f"Generator object: {gen}")
# We get values one by one
print(f"First value: {next(gen)}")
print(f"Second value: {next(gen)}")
Lo que esto significa en la práctica: la función generadora solo necesita suficiente memoria para contener un elemento a la vez. La función de lista necesita suficiente memoria para todos ellos.
La expresión generadora: un superpoder en una línea
Probablemente conozcas las comprensiones de lista como [x for x in data]. Son útiles, pero “eager”: la lista completa se construye de inmediato.
Sin embargo, cambia esos corchetes [] por paréntesis () y obtendrás un generador. Eso es una expresión generadora.
import sys
# A list comprehension
list_comp = [i for i in range(1000000)]
# A generator expression
gen_exp = (i for i in range(1000000))
print(f"List size: {sys.getsizeof(list_comp)} bytes")
print(f"Generator size: {sys.getsizeof(gen_exp)} bytes")
En mi máquina, la lista ocupa 8,448,728 bytes. ¿El generador? 112 bytes.
Diez elementos o diez mil millones: el generador se mantiene en 112 bytes. ¿Por qué? Porque no está almacenando los números. Está almacenando la lógica de cómo contar hasta un millón.
Datos del mundo real: streaming de un CSV masivo
Apliquemos esto a un problema real de ciencia de datos. Digamos que tienes un CSV con 10 millones de filas de datos de ventas, y quieres solo las filas donde ‘price’ es mayor a $100. pandas.read_csv() carga todo el archivo en la RAM.
Un generador te permite procesar el archivo como un flujo en su lugar. El uso de RAM se mantiene constante, incluso con 100GB.
import csv
def stream_expensive_items(filename):
with open(filename, mode='r') as f:
# csv.DictReader is a generator! It reads one line at a time.
reader = csv.DictReader(f)
for row in reader:
if float(row['price']) > 100:
yield row
# Let's imagine 'huge_data.csv' exists
# expensive_items = stream_expensive_items('huge_data.csv')
# for item in expensive_items:
# print(item)
La idea es esta: abrimos el archivo, tomamos una fila, verificamos el precio. La descartamos si es barata; la pasamos si es cara. Luego, la siguiente fila. Nunca más de una fila en memoria a la vez. El resultado: puedes procesar archivos más grandes que tu RAM sin ralentizar tu computadora.
El problema: cuando los generadores no son la solución
Los generadores suenan como magia, pero tienen dos limitaciones reales.
Primero, son de un solo uso. Recorre un generador con un bucle y queda agotado. Piensa en un rollo de película: una vez que lo has revelado, no puedes reproducirlo de nuevo sin empezar desde cero y releer la fuente.
Segundo, no hay indexación. ¿Quieres el elemento 500? El generador tiene que ejecutar la lógica de los primeros 499 para llegar ahí.
gen = (i for i in range(3))
# First pass
for val in gen:
print(val)
# Second pass - this will print NOTHING
for val in gen:
print("This won't run!")
try:
print(gen[0])
except TypeError as e:
print(f"Error: {e}")
Si necesitas ordenar, mezclar o acceder a filas específicas múltiples veces, quédate con una lista (o un DataFrame de Pandas). Pero para limpieza, filtrado o agregación, los generadores son difíciles de superar.
Usa un generador cuando:
- La fuente es más grande que la RAM — archivos de log, CSVs grandes, cursores de base de datos, flujos de red.
- Solo necesitas cada valor una vez: un filter, map o reduce de una sola pasada (
sum(),max(), conteo, escribir filas a un archivo). - Quieres empezar a producir salida antes de que toda la entrada esté lista (streaming verdadero).
- El código aguas abajo solo itera con un bucle
fory nunca pidedata[i].
Usa una lista (o un DataFrame de Pandas/Polars) cuando:
- Necesitas acceso aleatorio:
data[500], slicing o búsqueda por posición. - Necesitas múltiples pasadas sobre los mismos datos — por ejemplo, calcular una media y luego restarla de cada fila.
- Necesitas ordenar, mezclar, agrupar o unir, todo lo cual requiere mantener el dataset completo en memoria para comparar valores entre sí.
- El dataset cabe cómodamente en RAM, y prefieres pagar el costo de memoria una sola vez que reconstruir el generador desde la fuente cada vez que necesitas reiterar.
El compromiso fundamental en una oración: la evaluación perezosa te da memoria O(1) pero te cuesta la reiteración y la indexación — y si el código aguas abajo termina llamando list(gen) para recuperar esas habilidades, has pagado tanto la contabilidad del generador como la lista materializada completa, lo cual es lo peor de ambos mundos.
Prueba de olfato: si te encuentras escribiendo data = list(my_generator) inmediatamente después de crearlo, no querías un generador. Querías una lista.
Dos salidas de emergencia que vale la pena conocer:
itertools.tee(gen, n)divide un generador enniteradores independientes — pero tiene que almacenar valores internamente, así que si las bifurcaciones avanzan a diferentes velocidades, la memoria se acerca de nuevo al tamaño de una lista.itertools.islice(gen, start, stop)te permite tomar un slice sin indexación — útil para inspeccionar las primeras N filas de un flujo (list(islice(gen, 10))) sin materializar todo el contenido.
Regla práctica: si la respuesta a “¿necesito mirar algún valor más de una vez?” es sí, usa una lista. Si es no, usa un generador y deja que el ahorro de memoria se acumule a través de todo tu pipeline.
Resumen
Entonces, aquí está el resumen:
- Las listas son ‘ansiosas’: cada elemento reside en la RAM.
- Los generadores son ‘perezosos’: un elemento se produce solo cuando lo solicitas, lo cual utiliza mucha menos memoria.
- La palabra clave
yield: permite que una función se pause y se reanude. - El truco de los paréntesis:
(x for x in data)construye un generador sobre la marcha. - Sentido único: los generadores se ejecutan una vez y no admiten indexación.
La próxima vez que te topes con un MemoryError, intenta usar yield antes de actualizar tu RAM.
Comprueba tu comprensión
Las preguntas a continuación progresan desde el simple recuerdo hasta el diseño abierto, siguiendo aproximadamente la Taxonomía de Bloom.
Recordar
¿Cuál es la diferencia clave en el uso de memoria entre una lista por comprensión [x for x in data] y una expresión generadora (x for x in data)?
Comprender
Con sus propias palabras, explique por qué no puede solicitar data[500] directamente a un generador, utilizando la explicación del artículo sobre cómo un generador llega a su elemento 500.
Aplicar
Utilizando el patrón stream_expensive_items del artículo, ¿cambiaría el uso de memoria de esta función si el archivo CSV de entrada pasara de 10 millones de filas a 100 millones de filas? ¿Por qué sí o por qué no?
Analizar El artículo menciona que los generadores son de “un solo uso”: una vez agotados, volver a iterar no imprime nada. Analice paso a paso qué saldría mal si le pasara el mismo objeto generador a dos funciones diferentes, en las que cada una esperaría iterar sobre el conjunto de datos completo (p. ej., una calcula una suma y la siguiente calcula un promedio).
Evaluar El artículo recomienda generadores para “limpiar, filtrar o agregar datos”, pero listas y DataFrames para ordenar, mezclar o acceso repetido. Critique el diseño de un pipeline que intenta procesar un archivo de 50GB mediante una cadena de generadores de extremo a extremo, incluyendo un paso que necesita ordenar los datos por una columna: ¿qué debe cambiar en la arquitectura del pipeline en ese paso específico?
Crear Diseñe un pipeline basado en generadores para un nuevo escenario: procesar un archivo de logs del servidor de 20GB para contar cuántas solicitudes provienen de cada dirección IP única, sin cargar todo el archivo en la memoria. Bosqueje la(s) función(es) generadora(s) que escribiría y explique por qué el paso final de conteo (que necesita recordar cada IP vista hasta el momento) no anula el propósito de ahorro de memoria de los pasos de streaming anteriores.
Artículos relacionados
- ¿Por qué se cae mi pipeline de datos? Una guía amigable — cuando tu pipeline se cae, este artículo repasa a los sospechosos habituales (memoria, deriva de esquema, cambios en la forma de los datos aguas arriba) y cómo priorizarlos.
- Vectorización en Python: por qué es 100-1000x más rápido — una vez que tus datos están en memoria, la siguiente palanca para acelerar el proceso es reemplazar los bucles a nivel de Python con operaciones vectorizadas de NumPy/Pandas. Combina el “no cargar todo” de este artículo con el “hacer menos por fila” de ese artículo para tener el panorama completo.
Referencias y lecturas adicionales
- Documentación de
itertoolsde Python — iteradores para una iteración eficiente, incluyendotee,islice,chainy la sección de recetas: https://docs.python.org/3/library/itertools.html - Documentación del módulo
csvde Python —DictReader,readery entrada/salida de archivos en streaming: https://docs.python.org/3/library/csv.html
Aplica lo que aprendiste
Resumen. Has heredado un servicio de inferencia por lotes que puntúa transacciones de clientes a partir de un CSV de 10 millones de filas. Sigue el patrón stream_expensive_items de este artículo — csv.DictReader devuelve una fila a la vez, cada fila se puntúa y se recopilan las predicciones. En el entorno de desarrollo (dev), con un conjunto de datos de prueba de 1,000 filas, devolvió predicciones correctas. Cuando se desplegó en el entorno de preproducción (staging) con el archivo real de 10M filas, se ejecutó hasta completarse pero devolvió cero predicciones — sin MemoryError, sin fallos, solo una lista vacía. El ingeniero de guardia dijo: “es como si las filas hubieran desaparecido”.
Aquí está el servicio:
import csv
import logging
logger = logging.getLogger(__name__)
def stream_rows(filename):
with open(filename) as f:
reader = csv.DictReader(f)
for row in reader:
yield row
def run_inference(filename, model):
rows = stream_rows(filename)
# Production monitoring: log total row count
total = sum(1 for _ in rows)
logger.info(f"Processing {total} rows from {filename}")
# Score every row
predictions = [model.predict(row) for row in rows]
return predictions
La línea total = sum(...) se añadió durante el despliegue en staging para observabilidad — no estaba en la rama de dev.
Entregable. Escribe un informe post-mortem del incidente de 150–250 palabras que (1) nombre el error exacto de una línea, (2) explique el mecanismo — por qué sum(1 for _ in rows) causa que la comprensión de lista en la siguiente línea produzca [] — usando el lenguaje de “one-and-done” / StopIteration del artículo, y (3) proponga una solución que preserve el diseño de transmisión por secuencias (RAM plana independientemente del tamaño del archivo). Tu solución no debe ser rows = list(stream_rows(filename)) — explica por qué eso reintroduciría el pico de memoria de 400 MB+ del que advertía el artículo y anularía la ventaja de 112 bytes del generador.
Rúbrica:
- Error identificado:
total = sum(1 for _ in rows)agota el generador antes de que se ejecute el pase de inferencia. - Mecanismo explicado:
sum(1 for _ in rows)itera el generador hasta completarlo, alcanzandoStopIterationinternamente. Cuando la comprensión de lista[model.predict(row) for row in rows]llama anext(rows)nuevamente, recibeStopIterationinmediatamente — el cuerpo del bucle nunca se ejecuta, por lo quepredictionspermanece como[]. Hace referencia al comportamiento del artículo de “el segundo bucleforno hace nada en silencio”. - Dev-vs-staging justificado: La línea de monitoreo se añadió en staging, no en dev — por lo que el generador de dev nunca se agotó previamente y la inferencia funcionó normalmente con el conjunto de datos de prueba de 1,000 filas.
- La solución preserva la transmisión por secuencias: Propone una solución de un solo pase (p. ej., contar y predecir en un solo bucle
for, o llamar astream_rows(filename)una segunda vez para el pase de inferencia) — nolist(stream_rows(...)). - Rechaza el mal olor de
list(): Nota explícitamente quelist(stream_rows(filename))materializaría las 10M de filas en la RAM — el mismo escenario deMemoryErrorque el ejemplo de 50 millones de enteros del artículo (~400 MB+ solo para la estructura de la lista) — y que el objetivo principal del generador era su huella constante de ~112 bytes independientemente del tamaño del conjunto de datos.
Esta traducción fue generada automáticamente y puede contener errores. Si el idioma inglés es tu preferencia, puedes leer el artículo original en inglés .
Artículos relacionados
- Ingeniería en Python En revisión
¿Por qué falla mi pipeline de datos? Una guía amigable para el perfilado de memoria en Python
Aprende a diagnosticar y solucionar los bloqueos por MemoryError en Python en tus pipelines de datos usando memory_profiler, Fil y chunking para gestionar grandes volúmenes de datos con memoria RAM limitada.
- Ingeniería en Python En revisión
¿Por qué mi código de Pandas es tan lento? Una guía práctica para la vectorización
Aprende por qué los ciclos fila por fila hacen que Pandas sea dolorosamente lento y cómo las operaciones vectorizadas, np.select y groupby ofrecen una aceleración de 10,000x con cambios mínimos en el código.
- Ingeniería en Python En revisión
Deja de hacer sopa de variables: una guía para el encadenamiento de métodos en Pandas
Reemplaza los dataframes intermedios desordenados con cadenas de métodos limpias de Pandas usando .assign(), .pipe() y .query() para construir pipelines de datos legibles y mantenibles.
- Estadística En revisión
Pearson vs Spearman vs Kendall: Cómo elegir la correlación adecuada para tus datos
Aprende cuándo usar la correlación de Pearson, Spearman o Kendall en Python — y por qué un puntaje bajo puede significar que simplemente elegiste la herramienta incorrecta para tus datos.
¿Buscas otra cosa?
Busca en todos los artículos por título, resumen o tema.