Saltar al contenido

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

EjemploLos mismos datos, guardados de dos formas
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.
Las dos orientaciones
Por filas: list[dict]Por columnas: dict[list]
Un registro completoUn acceso: filas[4]Visitar las N listas
Una columna enteraRecorrer todas las filasUn acceso: cols["anio"]
Agregar un registro.append() y listoUn append por columna
Convertir una columna a númeroRecorrer todoUna operación sobre una lista
Quién la usacsv.DictReader, JSON, una APIpandas, 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

EjemploDatos ordenados: una fila, una observacion
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.

Las tres reglas de los datos ordenados
  1. Cada variable es una columna.
  2. Cada observación es una fila.
  3. 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

EjemploUna columna es homogenea, o no es una 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.

Ordenar texto no es ordenar números

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

EjemploLo que una lista de dicts NO garantiza
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ónOpcionesLo que hace pandas
Qué columnas hayLa unión de las claves, o la intersecciónLa unión, con huecos. Perder columnas es peor que tener NaN
Qué va donde no había claveUn valor de relleno, o marcarlo ausenteNaN, igual que un null que sí vino
En qué orden van las filasEl del archivo, o uno declaradoEl del archivo, hasta que vos digas otra cosa
La tabla pierde información y hay que saberlo

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

Convertir entre formas, en Python puro
ExpresiónQué 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

Ponete a prueba

1. En un archivo de ventas, ¿qué es una fila y qué es una columna?Nivel 1 · Recordar
2. ¿Qué operación le sale más barata a un dict de listas (orientado a columnas) que a una lista de dict?Nivel 1 · Recordar
3. El archivo de asistencia tiene columnas sesion_01 … sesion_12. ¿Por qué esa forma no está «ordenada»?Nivel 2 · Predecir la salida
4. Una columna de 120 valores tiene 113 enteros, 3 veces el texto treinta y 4 celdas vacías. ¿Qué implica?Nivel 2 · Predecir la salida
5. Tenés 15 objetos JSON donde 13 traen tres claves, uno trae dos y uno no trae ninguna. Vas a armar una tabla: ¿qué columnas ponés?Nivel 3 · Elegir entre dos casi correctas
6. El usuario 12 no traía la clave idioma; el 13 traía preferencias en null. En la tabla los dos quedan con la celda vacía. ¿Cómo se llama eso?Nivel 3 · Elegir entre dos casi correctas
7. Este código ordena el catálogo por año y corre sin error, pero 2003 aparece antes que 987 y después que 1998. ¿Qué pasa?
filas = list(csv.DictReader(f))
filas.sort(key=lambda x: x["anio"])
Nivel 4 · Encontrar el defecto