Listas, diccionarios, conjuntos, bucles y comprensiones
Cuándo usar cada uno de los cuatro contenedores de Python, bucles y condiciones, comprensiones, y contar palabras en tickets desordenados para obtener un top cinco que salga igual cada vez.
¿Cómo quieres aprender?
El problema que vamos a resolver
La responsable de soporte quiere saber de qué se quejaron más los clientes en los tickets de esta semana. Los tickets son texto desordenado: algunos en mayúsculas, otros llenos de signos de puntuación, otros que dicen lo mismo dos veces. Un compañero escribió un script en cinco minutos y envió una lista: the arriba, luego refund, y después Refund y refund. como entradas separadas.
La segunda ejecución trajo otro problema: un pequeño cambio en el orden de los tickets cambió la quinta palabra del top cinco, aunque el recuento de ninguna palabra había cambiado. Nadie que leyera el informe podía saber cuál de las dos versiones era la correcta.
La respuesta no es una biblioteca especial. Son los cuatro contenedores básicos de Python —listas, tuplas, diccionarios y conjuntos— y los bucles y condiciones usados como es debido. Una gran parte del trabajo diario de un ingeniero de IA es exactamente esto: limpiar texto, contar, filtrar y ordenar, de forma que los mismos datos de entrada den siempre el mismo resultado.
Al terminar este capítulo podrás
- Elegir entre lista, tupla, diccionario y conjunto para cada tarea, y explicar por qué
- Escribir bucles limpios con
for,while,if/elif/else,enumeratey.items() - Filtrar y transformar datos con comprensiones de listas, de diccionarios y de conjuntos
- Extraer palabras de un texto desordenado y contarlas con un diccionario y con
Counter - Ordenar las palabras con el mismo recuento según una regla explícita, para que el resultado sea siempre igual
Requisitos previos: pathlib, configuración JSON/YAML y variables de entorno, y saber ejecutar un script con uv run.
Cuatro contenedores, cuatro usos
Primero, los cuatro uno al lado del otro:
tickets = ["refund", "login", "refund", "billing"]
latest = ("T-1042", "refund", 3)
owners = {"refund": "Maya", "login": "Omar"}
seen = set(tickets)
print(tickets[0], tickets[-1], len(tickets))
print(latest[1])
print(owners["login"])
print(sorted(seen))
print("login" in seen, "shipping" in seen)refund billing 4
refund
Omar
['billing', 'login', 'refund']
True FalseCada uno hace una promesa distinta, y eliges según la promesa:
- Lista (
[...]) — mantiene el orden, admite el mismo valor más de una vez y se puede modificar. Para «una fila de cosas que van una detrás de otra»: una lista de tickets, un lote de prompts. - Tupla (
(...)) — mantiene el orden, pero no se puede modificar después de crearla. Para unos pocos valores que juntos forman un solo registro, como("T-1042", "refund", 3): id, categoría, prioridad. Aquí la posición tiene significado. - Diccionario (
{clave: valor}) — busca un valor por su clave. «Qué dueño tiene cada categoría», «cuántas veces aparece cada palabra»: cualquier lugar donde pasas de una cosa a otra. - Conjunto (
set(...)o{a, b}) — cada valor una sola vez, sin orden garantizado. Para cuando la pregunta es solo «¿está?» o «cuántos valores distintos hay».
sorted(seen) se imprime porque el orden de un conjunto impreso no está garantizado: puede verse de una forma en una ejecución y de otra en la siguiente. Si la salida de un programa depende del orden de un conjunto, esa salida tampoco será estable.
insobre una lista comprueba cada elemento uno por uno; sobre un conjunto o un diccionario responde casi al instante, tenga diez elementos o diez millones. Si compruebasif word in stop_wordsuna y otra vez,stop_wordsdebería ser un conjunto, no una lista. El motivo y cómo medir la diferencia llegarán más adelante en el curso.
Bucles: di sobre qué estás iterando
Los bucles sobre tuplas y diccionarios se leen mejor con desempaquetado: los valores van directamente a variables con nombre:
ticket_id, category, priority = ("T-1042", "refund", 3)
print(ticket_id, category, priority)
owners = {"refund": "Maya", "login": "Omar"}
for category, owner in owners.items():
print(f"{category} -> {owner}")
for position, category in enumerate(["refund", "login"], start=1):
print(position, category)T-1042 refund 3
refund -> Maya
login -> Omar
1 refund
2 login.items() devuelve un par (clave, valor) en cada vuelta, y enumerate entrega cada elemento junto con su posición: nada de contar índices con range(len(...)). Desde Python 3.7, un diccionario recuerda el orden en que se insertaron sus claves, así que el bucle sobre owners empieza por refund.
¿Qué pasa si olvidas .items()?
owners = {"refund": "Maya", "login": "Omar"}
for category, owner in owners:
print(category, owner)Traceback (most recent call last):
File "main.py", line 2, in <module>
for category, owner in owners:
^^^^^^^^^^^^^^^
ValueError: too many values to unpack (expected 2)Recorrer directamente un diccionario da solo las claves. La primera clave, "refund", es una cadena de seis caracteres, y Python intenta repartirla en dos variables: seis caracteres, dos huecos. El mensaje parece extraño hasta que recuerdas que una cadena también es una secuencia.
if/elif/else se detiene en la primera condición que sea verdadera, así que el orden de las condiciones es la lógica:
latencies_ms = [120, 340, 95, 1800, 210, 2600]
for latency in latencies_ms:
if latency >= 2000:
label = "critical"
elif latency >= 1000:
label = "slow"
else:
label = "ok"
print(latency, label)120 ok
340 ok
95 ok
1800 slow
210 ok
2600 criticalEscribe las condiciones en orden inverso —primero>= 1000y después>= 2000— y no habrá ningún error, pero2600pasará a serslow, porque la primera condición es verdadera y la cadena se detiene ahí. La ramacriticalnunca se ejecuta. Cuando separas por umbrales, pon primero la condición más estricta.
for recorre una colección conocida. while se ejecuta mientras una condición se cumpla: cuando no sabes de antemano cuántas veces hará falta. Pedir datos a una API página a página es el caso clásico. Aquí un diccionario hace de API:
pages = {1: ["t1", "t2"], 2: ["t3"], 3: []}
page = 1
collected = []
while True:
batch = pages[page]
if not batch:
break
collected.extend(batch)
page += 1
print(collected, "pages read:", page - 1)['t1', 't2', 't3'] pages read: 2Una página vacía significa break. if not batch funciona porque una lista vacía es falsy. En bucles como este, escribe primero la condición de parada y el resto después: un while True que olvida parar se ejecuta para siempre, sin ningún error.
Comprensiones: una colección a partir de otra
«Una lista vacía, un bucle, un if, un append»: este patrón de cuatro líneas es tan habitual que Python tiene una forma de una sola línea para él:
latencies_ms = [120, 340, 95, 1800, 210, 2600]
slow = [ms for ms in latencies_ms if ms >= 1000]
seconds = [ms / 1000 for ms in latencies_ms]
labels = {ms: ("slow" if ms >= 1000 else "ok") for ms in latencies_ms}
buckets = {ms // 1000 for ms in latencies_ms}
print(slow)
print(seconds)
print(labels)
print(sorted(buckets))[1800, 2600]
[0.12, 0.34, 0.095, 1.8, 0.21, 2.6]
{120: 'ok', 340: 'ok', 95: 'ok', 1800: 'slow', 210: 'ok', 2600: 'slow'}
[0, 1, 2]Cómo se lee: [resultado for elemento in colección if condición]. La parte izquierda dice qué construir con cada elemento; el if final dice cuáles conservar. La misma forma con {clave: valor for ...} construye un diccionario, y {valor for ...} un conjunto: buckets contiene tres intervalos distintos de un segundo, y cada uno aparece una sola vez.
No confundas los dos tipos de if. El if ms >= 1000 final es un filtro: cuando es falso, el elemento se descarta. "slow" if ms >= 1000 else "ok" es una elección: todos los elementos se conservan, y su valor es uno de los dos. Por eso labels tiene los seis.
Una comprensión sirve para construir una colección nueva. Cuando solo quieres imprimir algo o escribir en un archivo —cuando el resultado se tiraría a la basura—, escribe un for normal. Y una comprensión anidada de tres niveles puede caber en una línea, pero un bucle de cinco líneas suele ser más fácil de leer para un compañero.
Contar palabras: a mano y con Counter
Casi todo el mundo, al contar con un diccionario por primera vez, escribe algo así:
words = ["refund", "late", "refund", "login", "refund", "late"]
counts = {}
for word in words:
counts[word] += 1Traceback (most recent call last):
File "main.py", line 5, in <module>
counts[word] += 1
~~~~~~^^^^^^
KeyError: 'refund'counts[word] += 1 significa counts[word] = counts[word] + 1, y al leer counts[word] a la derecha se encuentra con que la clave "refund" todavía no existe. Una palabra vista por primera vez necesita un valor inicial. .get(clave, 0) hace exactamente eso:
words = ["refund", "late", "refund", "login", "refund", "late"]
counts = {}
for word in words:
counts[word] = counts.get(word, 0) + 1
print(counts){'refund': 3, 'late': 2, 'login': 1}Esta tarea es tan común que la biblioteca estándar tiene un tipo para ella: collections.Counter, una especie de diccionario:
from collections import Counter
words = ["refund", "late", "login", "refund", "login", "late", "slow"]
counts = Counter(words)
print(counts)
print(counts.most_common(2))
print(counts["missing"])Counter({'refund': 2, 'late': 2, 'login': 2, 'slow': 1})
[('refund', 2), ('late', 2)]
0Leer una clave inexistente de un Counter devuelve 0, no un KeyError: exactamente lo correcto para contar. Pero fíjate en most_common(2): refund, late y login tienen todos un recuento de 2, y se devolvieron los dos primeros. ¿Por qué se quedó fuera login? Porque, con recuentos iguales, most_common mantiene el orden en que se vio cada palabra por primera vez. login apareció en tercer lugar de la lista, así que perdió. Cambia el orden de los tickets y el resultado también cambia: el misterio del principio del capítulo.
Limpiar texto: ¿qué palabras son realmente palabras?
split() solo corta por espacios en blanco:
text = "Refund not received. REFUND please! Login fails; login page is slow."
print(text.split())
print(text.lower().split())['Refund', 'not', 'received.', 'REFUND', 'please!', 'Login', 'fails;', 'login', 'page', 'is', 'slow.']
['refund', 'not', 'received.', 'refund', 'please!', 'login', 'fails;', 'login', 'page', 'is', 'slow.'].lower() resolvió la diferencia entre mayúsculas y minúsculas, pero received., please! y slow. siguen con signos de puntuación, y al contar, slow. y slow son dos palabras distintas. Aquí es donde ayuda una expresión regular: en lugar de decir qué descartar, dices cómo es una palabra:
import re
text = "Refund not received. REFUND please! Login fails; login page is slow. Can't pay."
words = re.findall(r"[a-z']+", text.lower())
print(words)['refund', 'not', 'received', 'refund', 'please', 'login', 'fails', 'login', 'page', 'is', 'slow', "can't", 'pay'][a-z']+ significa «una o más letras inglesas minúsculas o apóstrofes». re.findall devuelve cada lugar donde el patrón coincide en el texto, y todo lo demás —puntos, comas, dígitos— se descarta. can't sigue siendo una sola palabra.
Este patrón tiene un fallo: si alguien escribe'invalid token', la primera palabra sale como'invalid, con la comilla pegada. El siguiente ejemplo ajusta el patrón para que un apóstrofe solo pueda estar entre dos letras. Y recuerda que[a-z]solo son letras inglesas; un texto en otros alfabetos necesita otro patrón.
Recuentos iguales: decide tú la regla de ordenación
Para convertir recuentos en un ranking hay que ordenar. Tres formas, sobre un diccionario donde refund y login tienen ambos un recuento de 3:
counts = {"refund": 3, "late": 2, "login": 3, "slow": 1}
print(sorted(counts, reverse=True))
print(sorted(counts.items(), key=lambda item: item[1], reverse=True))
print(sorted(counts.items(), key=lambda item: (-item[1], item[0])))['slow', 'refund', 'login', 'late']
[('refund', 3), ('login', 3), ('late', 2), ('slow', 1)]
[('login', 3), ('refund', 3), ('late', 2), ('slow', 1)]La primera línea es la más peligrosa, porque parece razonable. Ordenar un diccionario ordena sus claves, no sus valores; así que esto es orden alfabético inverso y no tiene nada que ver con los recuentos. slow apareció una sola vez, y sin embargo está arriba. Ningún error.
La segunda línea ordena por recuento. Pero ¿cuál va primero, refund o login? La función sorted de Python es «estable»: los elementos con claves iguales conservan el orden que tenían en la entrada. refund se insertó primero en el diccionario, así que va primero. Dicho de otro modo, el resultado depende del orden en que llegaron los datos.
La tercera línea expresa la regla en sí. key devuelve una tupla, y las tuplas se comparan elemento a elemento: primero -item[1] —el recuento en negativo, así que los recuentos mayores van primero— y, en caso de empate, item[0], la propia palabra, en orden alfabético. Ahora el resultado es el mismo sea cual sea el orden de llegada de la entrada. No hace falta reverse=True, porque el signo menos ya invirtió el orden de los recuentos y dejó el orden alfabético como estaba.
Siempre que los resultados vayan a compararse —informes, tests, evaluaciones—, da una regla explícita para ordenar los valores iguales. Si no, el informe de hoy y el de mañana diferirán en los puntos donde los datos en realidad no cambiaron.
Un ejemplo completo
Ahora todo junto: las cinco palabras principales de los tickets. Las stop words —palabras como the o and que aparecen en cualquier texto sin aportar nada— se descartan, también las palabras de dos letras, y los empates se resuelven en orden alfabético. top_words.py:
import re
from collections import Counter
STOP_WORDS = {
"a", "and", "for", "i", "is", "it", "my", "not", "of", "on", "the", "to", "was", "with",
}
TICKETS = [
"Refund not received for order 1182. I was charged twice!",
"The app is slow and the login page freezes.",
"Charged twice again. Please refund the second charge.",
"Login fails with 'invalid token' on my phone.",
"Still waiting on my refund, it was promised last week.",
"App slow after the update; login takes a minute.",
]
def tokenize(text: str) -> list[str]:
return re.findall(r"[a-z]+(?:'[a-z]+)?", text.lower())
def top_words(texts: list[str], n: int = 5) -> list[tuple[str, int]]:
counts = Counter(
word
for text in texts
for word in tokenize(text)
if word not in STOP_WORDS and len(word) > 2
)
ranked = sorted(counts.items(), key=lambda item: (-item[1], item[0]))
return ranked[:n]
def print_ranking(ranking: list[tuple[str, int]]) -> None:
if not ranking:
print("no words to rank")
return
width = max(len(word) for word, _ in ranking)
for rank, (word, count) in enumerate(ranking, start=1):
print(f"{rank}. {word:<{width}} {count} {'#' * count}")
if __name__ == "__main__":
print_ranking(top_words(TICKETS))
print_ranking(top_words([]))uv run top_words.py1. login 3 ###
2. refund 3 ###
3. app 2 ##
4. charged 2 ##
5. slow 2 ##
no words to rankLas decisiones detrás de esto:
El patrón de palabras [a-z]+(?:'[a-z]+)?. Una o más letras y, opcionalmente, un apóstrofe seguido de más letras. can't sigue siendo una palabra, pero la comilla de 'invalid token' ya no se pega a ninguna palabra. (?:...) es un grupo que no se captura: con un (...) normal, re.findall devolvería solo la parte del grupo en lugar de la palabra completa.
Las stop words son un conjunto. Cada palabra se comprueba con word not in STOP_WORDS; en un conjunto esa comprobación es casi instantánea, sin importar lo larga que crezca la lista.
Counter alimentado directamente por un generador. Dos for y un if forman un único flujo: cada palabra de cada ticket que pasa el filtro. No se construye ninguna lista intermedia grande con todas las palabras. Los dos for están en el mismo orden que en unos bucles anidados normales: primero el externo.
Un desempate explícito. login y refund tienen ambos 3; app, charged, slow y twice tienen todos 2. El orden alfabético decidió quién quedó en quinto lugar, así que twice se quedó fuera. La regla no es perfecta —twice no se quedó fuera por ser menos importante—, pero está declarada y es siempre la misma. Si alguien que lee el informe pregunta, hay una respuesta.
Entrada vacía tratada a propósito. max() sobre una colección vacía lanza ValueError: max() iterable argument is empty. Una semana tranquila sin tickets es normal, no un error; por eso print_ranking comprueba primero si el ranking está vacío. La segunda línea de salida lo muestra.
Contar y mostrar, separados. top_words devuelve una lista y no imprime nada; print_ranking solo imprime. Así, los mismos recuentos pueden usarse desde un test, una API o un dashboard. {word:<{width}} alinea a la izquierda cada palabra en un espacio tan ancho como la más larga, de modo que la columna de recuentos queda alineada.
Una limitación: charged y charge son dos palabras distintas aquí. Unir las formas de una misma palabra es un problema aparte, y los sistemas de IA suelen emparejar por significado en lugar de contar palabras; ese enfoque llegará más adelante en el curso. Pero incluso allí, limpiar texto, filtrar y ordenar de forma estable —el trabajo de este capítulo— son los primeros pasos.
Cuando falla
KeyError: 'refund' counts[word] += 1 con una clave que todavía no está en el diccionario. Escribe counts[word] = counts.get(word, 0) + 1, o usa collections.Counter.
ValueError: too many values to unpack (expected 2) El código dice for key, value in d: sobre un diccionario. Recorrer directamente un diccionario da solo las claves; escribe d.items() para obtener pares.
TypeError: unhashable type: 'list' Se metió una lista en un conjunto o se usó como clave de diccionario. Los elementos de un conjunto y las claves de un diccionario deben ser cosas que no puedan cambiar. Usa una tupla en lugar de una lista: seen.add(("refund", "late")).
RuntimeError: dictionary changed size during iteration Se borraron o añadieron claves en un diccionario mientras se recorría. Construye un diccionario nuevo en lugar de borrar: counts = {word: n for word, n in counts.items() if n >= 2}.
AttributeError: 'tuple' object has no attribute 'append' Una tupla no se puede modificar después de crearla. Si los valores realmente crecen y se reducen, debería haber sido una lista; si la tupla es un registro, construye una tupla nueva con el valor nuevo.
ValueError: max() iterable argument is empty max() o min() sobre una colección vacía. Comprueba antes si está vacía, o da un valor por defecto: max(lengths, default=0).
Paso 4 de 6 — Predice
Comprueba lo que has entendido
Un compañero saca «la palabra más frecuente» de un conjunto de recuentos. ¿Qué se imprime?
counts = {"refund": 5, "slow": 1, "login": 3}
print(sorted(counts, reverse=True)[0])- A`refund`
- B`('refund', 5)`
- C`slow`
- D`5`
label(2600) debería devolver "critical", pero devuelve "slow", sin ningún error. ¿Dónde está el fallo?
def label(latency_ms):
if latency_ms >= 1000:
return "slow"
elif latency_ms >= 2000:
return "critical"
return "ok"
print(label(2600))- AEl orden de las condiciones: `>= 1000` es verdadera primero y la cadena se detiene, así que `>= 2000` nunca se alcanza; la condición más estricta debe ir primero
- B`return "ok"` necesita un `else:` antes
- CEl segundo condicional debe usar `if` en lugar de `elif`
- D`2600` es un entero, así que hay que convertirlo con `float()` antes de compararlo con `>=`
Un pipeline comprueba, para cada uno de un millón de palabras, si es una de 300 stop words. ¿Qué contenedor debe guardar las stop words?
- AUna cadena, con todas las palabras unidas en un solo texto largo
- BUna tupla, porque las stop words no cambian
- CUn conjunto, porque una comprobación `in` es casi instantánea sea cual sea su tamaño
- DUna lista, porque se puede mantener en orden alfabético
Jugar es parte de Pro
+30 XP te esperan
Juega este capítulo con Pro
Leer es gratis y el capítulo está abierto entero. Los primeros 5 capítulos de cada curso gratuito se pueden jugar gratis. Desde el capítulo 6, responder las preguntas, ganar XP y hacer crecer tu racha es parte de Pro.
Desbloquea el juego con ProTu turno
Ahora busca las palabras principales por categoría. En un archivo llamado categories.py, copia STOP_WORDS y tokenize del capítulo, y añade estos tickets:
TICKETS = [
("billing", "Charged twice for one order."),
("billing", "Refund for the double charge, please."),
("login", "Login fails on the new phone."),
("billing", "Still no refund after the charge."),
("login", "Password reset email never arrives."),
("login", "Login page shows a blank screen."),
]Después escribe código para que:
- Cada categoría tenga sus propios recuentos: un diccionario cuyas claves son los nombres de las categorías y cuyos valores son el
Counterde esa categoría (pista:dict.setdefaultyCounter.update) - Las stop words y las palabras de dos letras o menos no se cuenten
- Cada categoría muestre sus dos palabras principales, con los recuentos más altos primero y orden alfabético en caso de empate
- Las categorías se impriman en orden alfabético, sea cual sea el orden en que lleguen los tickets
uv run categories.py debe imprimir exactamente esto:
billing charge=2, refund=2
login login=2, arrives=1Después invierte la lista TICKETS (TICKETS[::-1]) y ejecútalo de nuevo: ¿cambia la salida en algo? Si cambia, ¿qué condición no se cumple? Por último, quita el desempate y ordena solo por recuento: ¿cuál es la segunda palabra de login, y se mantiene igual cuando inviertes el orden?
Paso 6 de 6
Reto — el quiz del capítulo
Diez preguntas de fácil a difícil. Las últimas son difíciles a propósito.
Juega con Pro