Indice / Pensar en tablas / 2.1
De list[dict] a tabla
Una lista de diccionarios no es todavía una tabla. Le falta lo que hace útil a una tabla: que cada columna signifique una sola cosa y tenga un solo tipo.
Para qué sirve
Es la unidad que no está en ninguna diapositiva y sin la cual el resto no se entiende. NumPy y pandas dan por sabidos tres conceptos - fila, columna y tipo de columna - que en Python puro no existen: una lista de diccionarios no tiene columnas, tiene claves repetidas.
Tres páginas cortas, sin ninguna librería, y después el resto del curso deja de ser sintaxis suelta.
ELI5Explicado facil
Una lista de diccionarios es una pila de fichas. Cada ficha tiene sus campos escritos, y para responder «¿cuál es la edad promedio?» hay que pasarlas todas.
Una tabla es la misma información puesta en una grilla: la columna «edad» existe como cosa, se puede mirar entera de una vez, y - la parte que cuesta - promete que todo lo que hay ahí adentro es una edad.
Filas o columnas
import csv
with open("libros_catalogo.csv", encoding="utf-8", newline="") as f:
por_filas = list(csv.DictReader(f))
# 1. Orientado a FILAS: una lista de diccionarios. Es lo que devuelve
# DictReader y lo que viaja en un JSON de una API.
print("orientado a filas: una lista de", len(por_filas), "diccionarios")
print(" por_filas[0] ->", por_filas[0])
print(" un libro entero se lee de una:", por_filas[0]["titulo"])
# 2. Orientado a COLUMNAS: un diccionario de listas. Es como guarda los
# datos pandas, y por eso hay operaciones que le salen gratis.
por_columnas = {c: [fila[c] for fila in por_filas] for c in por_filas[0]}
print()
print("orientado a columnas: un dict de", len(por_columnas), "listas")
for columna, valores in por_columnas.items():
print(f" {columna:<8} {len(valores):>3} valores {valores[:2]}")
print()
print("Cada forma hace facil una cosa distinta:")
print(" el libro 5 completo ->", por_filas[4]["titulo"])
print(" todos los anios ->", por_columnas["anio"][:6], "...")
print()
# Con la lista de dicts, mirar una columna cuesta recorrer todo.
anios = [fila["anio"] for fila in por_filas]
print("sacar una columna de la forma por filas cuesta un recorrido:")
print(" [fila['anio'] for fila in por_filas] ->", anios[:6], "...")
print("sacarla de la forma por columnas es un acceso:")
print(" por_columnas['anio'] -> lo mismo, sin recorrer nada")
print()
print("Y al reves: reconstruir la fila 5 desde las columnas obliga a")
print("visitar las 5 listas.")
fila5 = {c: por_columnas[c][4] for c in por_columnas}
print(" ", fila5)
print(" ¿coincide con por_filas[4]?", fila5 == por_filas[4])
print()
print("Ninguna de las dos es 'la correcta'. pandas elige la segunda porque")
print("casi todo lo que hace un analisis es una operacion por columna:")
print("promediar, contar distintos, convertir tipos, filtrar por un valor.")
orientado a filas: una lista de 50 diccionarios
por_filas[0] -> {'isbn': '978-1000-001', 'titulo': 'Algoritmos vol. 1', 'autor': 'Ximena Molina', 'anio': '2003', 'precio': '57.24'}
un libro entero se lee de una: Algoritmos vol. 1
orientado a columnas: un dict de 5 listas
isbn 50 valores ['978-1000-001', '978-8695-002']
titulo 50 valores ['Algoritmos vol. 1', 'Bases de datos vol. 1']
autor 50 valores ['Ximena Molina', 'Guadalupe Gutiérrez']
anio 50 valores ['2003', '2016']
precio 50 valores ['57.24', '73.18']
Cada forma hace facil una cosa distinta:
el libro 5 completo -> Compiladores vol. 1
todos los anios -> ['2003', '2016', '2007', '2025', '2006', '2004'] ...
sacar una columna de la forma por filas cuesta un recorrido:
[fila['anio'] for fila in por_filas] -> ['2003', '2016', '2007', '2025', '2006', '2004'] ...
sacarla de la forma por columnas es un acceso:
por_columnas['anio'] -> lo mismo, sin recorrer nada
Y al reves: reconstruir la fila 5 desde las columnas obliga a
visitar las 5 listas.
{'isbn': '978-3259-005', 'titulo': 'Compiladores vol. 1', 'autor': 'Delfina Molina', 'anio': '2006', 'precio': '81.58'}
¿coincide con por_filas[4]? True
Ninguna de las dos es 'la correcta'. pandas elige la segunda porque
casi todo lo que hace un analisis es una operacion por columna:
promediar, contar distintos, convertir tipos, filtrar por un valor.
Por filas: list[dict] | Por columnas: dict[list] | |
|---|---|---|
| Un registro completo | Un acceso: filas[4] | Visitar las N listas |
| Una columna entera | Recorrer todas las filas | Un acceso: cols["anio"] |
| Agregar un registro | .append() y listo | Un append por columna |
| Convertir una columna a número | Recorrer todo | Una operación sobre una lista |
| Quién la usa | csv.DictReader, JSON, una API | pandas, NumPy, una base columnar |
Ninguna es la correcta. pandas elige la segunda porque casi todo lo que hace un
análisis es una operación por columna: promediar, contar distintos,
convertir tipos, filtrar por un valor. Que un DataFrame sea internamente
un diccionario de columnas explica la mitad de su comportamiento, incluida la lección
4.3.
Datos ordenados
import csv
with open("asistencia_sesiones.csv", encoding="utf-8", newline="") as f:
ancho = list(csv.DictReader(f))
print("FORMA ANCHA, que es como viene el archivo:")
print(" columnas:", len(ancho[0]), "| filas:", len(ancho))
print(" ", list(ancho[0])[:5], "...")
for fila in ancho[:2]:
valores = [fila[c] for c in fila if c.startswith("sesion")]
print(f" {fila['legajo']} {fila['estudiante']:<18} {valores}")
print()
print("Comoda para leer de un vistazo, y con un problema: las 12 columnas")
print("de sesion son la MISMA variable medida 12 veces. El numero de sesion")
print("esta en el NOMBRE de la columna, no en un dato.")
print()
print("Por eso 'la asistencia de la sesion 7' obliga a nombrar la columna a")
print("mano, y '¿cuantas sesiones hubo?' se contesta contando columnas.")
# La forma larga pone esa variable donde corresponde: en una columna.
largo = []
for fila in ancho:
for columna, valor in fila.items():
if columna.startswith("sesion"):
largo.append({
"legajo": fila["legajo"],
"estudiante": fila["estudiante"],
"sesion": int(columna.split("_")[1]),
"estado": valor,
})
print()
print("FORMA LARGA, la misma informacion:")
print(" columnas:", len(largo[0]), "| filas:", len(largo),
f"= {len(ancho)} estudiantes x 12 sesiones")
for registro in largo[:4]:
print(" ", registro)
print()
print("Ahora las tres preguntas se contestan sin tocar nombres de columna:")
print(" sesiones distintas :", len({r['sesion'] for r in largo}))
print(" registros de la 7 :", sum(1 for r in largo if r["sesion"] == 7))
print(" presentes totales :", sum(1 for r in largo if r["estado"] == "P"))
print()
print("Las tres reglas de los datos ordenados:")
print(" 1. cada VARIABLE es una columna")
print(" 2. cada OBSERVACION es una fila")
print(" 3. cada tipo de unidad observada, su propia tabla")
print()
print("La forma ancha se lee mejor; la larga se procesa mejor. En la unidad")
print("6, melt() y pivot_table() son exactamente este ida y vuelta.")
FORMA ANCHA, que es como viene el archivo:
columnas: 14 | filas: 30
['legajo', 'estudiante', 'sesion_01', 'sesion_02', 'sesion_03'] ...
L2701 Rocío Duarte ['P', 'P', 'A', 'P', 'P', 'A', 'P', 'A', 'A', 'P', 'A', 'A']
L2702 Yamila Escobar ['P', 'P', 'P', 'P', 'A', 'A', 'P', 'P', 'P', 'P', 'P', 'P']
Comoda para leer de un vistazo, y con un problema: las 12 columnas
de sesion son la MISMA variable medida 12 veces. El numero de sesion
esta en el NOMBRE de la columna, no en un dato.
Por eso 'la asistencia de la sesion 7' obliga a nombrar la columna a
mano, y '¿cuantas sesiones hubo?' se contesta contando columnas.
FORMA LARGA, la misma informacion:
columnas: 4 | filas: 360 = 30 estudiantes x 12 sesiones
{'legajo': 'L2701', 'estudiante': 'Rocío Duarte', 'sesion': 1, 'estado': 'P'}
{'legajo': 'L2701', 'estudiante': 'Rocío Duarte', 'sesion': 2, 'estado': 'P'}
{'legajo': 'L2701', 'estudiante': 'Rocío Duarte', 'sesion': 3, 'estado': 'A'}
{'legajo': 'L2701', 'estudiante': 'Rocío Duarte', 'sesion': 4, 'estado': 'P'}
Ahora las tres preguntas se contestan sin tocar nombres de columna:
sesiones distintas : 12
registros de la 7 : 30
presentes totales : 283
Las tres reglas de los datos ordenados:
1. cada VARIABLE es una columna
2. cada OBSERVACION es una fila
3. cada tipo de unidad observada, su propia tabla
La forma ancha se lee mejor; la larga se procesa mejor. En la unidad
6, melt() y pivot_table() son exactamente este ida y vuelta.
Las 12 columnas sesion_01…sesion_12 son
la misma variable medida doce veces. El número de sesión está en el
nombre de la columna, que es el único lugar de una tabla donde un dato no se
puede consultar.
- Cada variable es una columna.
- Cada observación es una fila.
- Cada tipo de unidad observada, su propia tabla.
La forma ancha se lee mejor y la larga se procesa mejor, así que se convierte de una
a otra según lo que haga falta. En la unidad 6, melt() y
pivot_table() son exactamente este ida y vuelta - y el ejercicio
EJ-6.6 es este mismo archivo, convertido a 360 filas.
La promesa de la columna
import collections
import csv
NULOS = {"", "-", "N/A", "NaN", "null"}
def clasificar(texto):
"""Que clase de dato parece este texto."""
crudo = texto.strip()
if crudo in NULOS:
return "ausente"
try:
int(crudo)
return "entero"
except ValueError:
pass
try:
float(crudo)
return "decimal"
except ValueError:
return "texto"
with open("clientes_crudos.csv", encoding="utf-8", newline="") as f:
filas = list(csv.DictReader(f))
print(f"clientes_crudos.csv: {len(filas)} filas\n")
print(f"{'columna':<12} {'clases':<40} homogenea")
print("-" * 62)
for columna in filas[0]:
clases = collections.Counter(clasificar(x[columna]) for x in filas)
utiles = {c for c in clases if c != "ausente"}
resumen = ", ".join(f"{c}:{n}" for c, n in sorted(clases.items()))
print(f"{columna:<12} {resumen:<40} {'si' if len(utiles) == 1 else 'NO'}")
print()
print("La columna edad tiene enteros Y texto. Eso no es un detalle de")
print("formato: significa que la columna NO tiene un tipo, y por lo tanto")
print("no se le puede pedir un promedio.")
edades = [x["edad"] for x in filas]
sospechosos = [v for v in edades if clasificar(v) == "texto"]
print()
print("los valores de texto en edad:",
dict(collections.Counter(sospechosos)))
print()
print("Y esta es la consecuencia concreta:")
numericas = [int(v) for v in edades if clasificar(v) == "entero"]
print(" edades que si son numero:", len(numericas), "de", len(edades))
print(" promedio sobre esas :", round(sum(numericas) / len(numericas), 2))
print(" ordenar la columna como texto pondria '9' despues de '85':",
sorted({"9", "85", "100"}))
print()
print("Una tabla es una lista de filas MAS la promesa de que cada columna")
print("tiene un solo tipo. El archivo trae lo primero; lo segundo hay que")
print("conseguirlo, y es todo el trabajo de la unidad 5.")
clientes_crudos.csv: 120 filas
columna clases homogenea
--------------------------------------------------------------
id texto:120 si
nombre ausente:2, texto:118 si
correo ausente:2, texto:118 si
edad ausente:4, entero:113, texto:3 NO
ciudad ausente:1, texto:119 si
fecha_alta texto:120 si
La columna edad tiene enteros Y texto. Eso no es un detalle de
formato: significa que la columna NO tiene un tipo, y por lo tanto
no se le puede pedir un promedio.
los valores de texto en edad: {'treinta': 3}
Y esta es la consecuencia concreta:
edades que si son numero: 113 de 120
promedio sobre esas : 46.5
ordenar la columna como texto pondria '9' despues de '85': ['100', '85', '9']
Una tabla es una lista de filas MAS la promesa de que cada columna
tiene un solo tipo. El archivo trae lo primero; lo segundo hay que
conseguirlo, y es todo el trabajo de la unidad 5.
Cinco de las seis columnas de clientes_crudos.csv son homogéneas.
edad no: tiene 113 enteros y 3 veces el texto treinta. Eso no
es un problema de formato, es que la columna no tiene tipo, y sin tipo
no se le puede pedir un promedio ni un orden.
sorted({"9", "85", "100"}) devuelve ['100', '85', '9'].
Compara carácter por carácter, y '1' < '8' < '9'.
Es la causa del defecto del quiz de la lección 1.3, y va a volver en la 4.8 cuando una columna que parecía numérica se ordene mal.
Una tabla es una lista de filas más la promesa de que cada columna tiene un solo tipo. El archivo trae lo primero. Lo segundo hay que conseguirlo, y es todo el trabajo de la unidad 5.
Lo que hay que decidir para armar la tabla
import csv
import json
# Un CSV siempre da filas con las mismas claves: la cabecera las fija.
with open("libros_catalogo.csv", encoding="utf-8", newline="") as f:
del_csv = list(csv.DictReader(f))
claves_csv = {tuple(sorted(fila)) for fila in del_csv}
print("desde un CSV: formas distintas de fila ->", len(claves_csv))
print(" siempre las mismas 5 claves, porque la cabecera manda.")
# Un JSON no promete nada de eso: cada objeto trae lo que quiso traer.
with open("usuarios_perfiles.json", encoding="utf-8") as f:
del_json = json.load(f)["usuarios"]
formas = {}
for usuario in del_json:
llave = tuple(sorted((usuario.get("preferencias") or {})))
formas.setdefault(llave, []).append(usuario["id"])
print()
print("desde un JSON: formas distintas de 'preferencias' ->", len(formas))
for llave, ids in sorted(formas.items(), key=lambda t: -len(t[1])):
print(f" {len(ids):>2} usuarios claves {list(llave)} ids {ids}")
print()
print("Para armar una TABLA hay que decidir tres cosas que el archivo no dice:")
print()
print("1. Que columnas hay. La union de las claves, o la interseccion.")
todas, comunes = set(), None
for usuario in del_json:
claves = set((usuario.get("preferencias") or {}))
todas |= claves
comunes = claves if comunes is None else comunes & claves
print(" union :", sorted(todas), "-> 3 columnas, con huecos")
print(" interseccion:", sorted(comunes), "-> 0 columnas, sin huecos")
print(" La union con huecos es lo que hace pandas, y casi siempre es")
print(" lo que se quiere: perder columnas es peor que tener NaN.")
print()
print("2. Que valor va donde no habia clave. NO es lo mismo que un null")
print(" que si vino en el archivo, y la tabla los va a mostrar igual.")
print()
print("3. En que orden van las filas. Una lista tiene orden; un conjunto")
print(" de registros, no. Si el orden importa, hay que declararlo.")
print()
tabla = []
for usuario in del_json:
prefs = usuario.get("preferencias") or {}
tabla.append({"id": usuario["id"],
**{c: prefs.get(c, "") for c in sorted(todas)}})
print("la tabla resultante, primeras 4 filas:")
print(f" {'id':>3} {'idioma':<8} {'notificaciones':<15} tema")
for fila in tabla[:4]:
print(f" {fila['id']:>3} {fila['idioma']:<8} "
f"{str(fila['notificaciones']):<15} {fila['tema']}")
print(" ...")
for fila in tabla[11:13]:
print(f" {fila['id']:>3} {fila['idioma']:<8} "
f"{str(fila['notificaciones']):<15} {fila['tema']}")
print()
print("El usuario 12 no traia 'idioma' y el 13 no traia 'preferencias':")
print("los dos quedan con la celda vacia, y la tabla ya no distingue")
print("cual de los dos casos era. Esa perdida es una decision, no un bug.")
desde un CSV: formas distintas de fila -> 1
siempre las mismas 5 claves, porque la cabecera manda.
desde un JSON: formas distintas de 'preferencias' -> 3
13 usuarios claves ['idioma', 'notificaciones', 'tema'] ids [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 14, 15]
1 usuarios claves ['notificaciones', 'tema'] ids [12]
1 usuarios claves [] ids [13]
Para armar una TABLA hay que decidir tres cosas que el archivo no dice:
1. Que columnas hay. La union de las claves, o la interseccion.
union : ['idioma', 'notificaciones', 'tema'] -> 3 columnas, con huecos
interseccion: [] -> 0 columnas, sin huecos
La union con huecos es lo que hace pandas, y casi siempre es
lo que se quiere: perder columnas es peor que tener NaN.
2. Que valor va donde no habia clave. NO es lo mismo que un null
que si vino en el archivo, y la tabla los va a mostrar igual.
3. En que orden van las filas. Una lista tiene orden; un conjunto
de registros, no. Si el orden importa, hay que declararlo.
la tabla resultante, primeras 4 filas:
id idioma notificaciones tema
1 es True oscuro
2 es False automatico
3 es False oscuro
4 es True oscuro
...
12 False oscuro
13
El usuario 12 no traia 'idioma' y el 13 no traia 'preferencias':
los dos quedan con la celda vacia, y la tabla ya no distingue
cual de los dos casos era. Esa perdida es una decision, no un bug.
Un CSV siempre da filas con las mismas claves: la cabecera las fija. Un JSON no
promete nada de eso - en usuarios_perfiles.json hay tres formas
distintas de preferencias - y convertirlo en tabla obliga a tres
decisiones que el archivo no toma:
| Decisión | Opciones | Lo que hace pandas |
|---|---|---|
| Qué columnas hay | La unión de las claves, o la intersección | La unión, con huecos. Perder columnas es peor que tener NaN |
| Qué va donde no había clave | Un valor de relleno, o marcarlo ausente | NaN, igual que un null que sí vino |
| En qué orden van las filas | El del archivo, o uno declarado | El del archivo, hasta que vos digas otra cosa |
Al usuario 12 le faltaba la clave idioma; el usuario 13
tenía preferencias en null. En la tabla los dos quedan con la
celda vacía, y ya no se distingue cuál era cuál.
Esa pérdida es una decisión, no un error - pero si el enunciado pregunta «¿cuántos usuarios no declararon idioma?», la respuesta depende de si contás antes o después de aplanar.
Recetario
| Expresión | Qué hace |
|---|---|
{c: [f[c] for f in filas] for c in filas[0]} | Filas → columnas |
[dict(zip(cols, v)) for v in zip(*cols.values())] | Columnas → filas |
{k for f in filas for k in f} | La unión de las claves |
{c: f.get(c, "") for c in columnas} | Completar una fila incompleta |
len({tuple(sorted(f)) for f in filas}) | Cuántas formas distintas de fila hay |
Counter(clasificar(f[c]) for f in filas) | Los tipos que conviven en una columna |
sorted(filas, key=lambda f: f["anio"]) | Ordenar por una columna (¡como texto!) |
sorted(filas, key=lambda f: int(f["anio"])) | Ordenar por una columna, como número |