Saltar al contenido

Indice / Pensar en tablas / 2.2

Clave, unicidad y grano

Tres preguntas que hay que poder contestar antes de tocar una tabla: qué identifica una fila, qué representa una fila, y si lo que dice apuntar a otra tabla efectivamente existe.

Para qué sirve

Es la lección de mayor retorno del curso para quien nunca vio una base de datos. Sin el concepto de grano, un groupby da un número plausible y equivocado. Sin el de clave, un merge devuelve más filas de las que entraron y nadie se entera.

Y no hace falta saber SQL: son tres ideas y se ven contando, con la biblioteca estándar, sobre las tres tablas del sistema académico.

ELI5Explicado facil

Una tabla es una lista de algo. La pregunta es siempre: ¿lista de qué?

«Lista de estudiantes» y «lista de inscripciones» son listas distintas, aunque las dos hablen de estudiantes. Contar filas en la segunda y decir «hay 110 estudiantes» es el error más caro que se puede cometer con datos, y se comete todo el tiempo.

La clave primaria

EjemploQue identifica una fila: la clave primaria
import collections
import csv


def leer(nombre):
    with open(nombre, encoding="utf-8", newline="") as f:
        return list(csv.DictReader(f))


estudiantes = leer("academico_estudiantes.csv")

print("academico_estudiantes.csv:", len(estudiantes), "filas")
print()
print(f"{'columna':<10} {'distintos':>10} {'unica':>7}")
print("-" * 30)
for columna in estudiantes[0]:
    distintos = len({x[columna] for x in estudiantes})
    print(f"{columna:<10} {distintos:>10} "
          f"{'SI' if distintos == len(estudiantes) else 'no':>7}")

print()
print("id sirve como clave primaria: 50 valores distintos en 50 filas.")
print("legajo NO, y eso es una sorpresa: se supone que es el identificador")
print("de la facultad.")

repetidos = collections.Counter(x["legajo"] for x in estudiantes)
duplicados = [k for k, v in repetidos.items() if v > 1]
print()
print("el legajo repetido:", duplicados)
for fila in estudiantes:
    if fila["legajo"] in duplicados:
        print("  ", fila)

print()
print("Son dos personas distintas con el mismo legajo. Cual de las dos es")
print("'la buena' no lo dice el archivo: es una decision, y por eso la")
print("leccion 5.5 va a pedir una regla de desempate escrita.")

print()
print("La diferencia entre las dos columnas:")
print("  id     -> clave SUBROGADA: la inventa el sistema y no significa nada")
print("  legajo -> clave NATURAL: viene del mundo real y puede fallar")
print()
print("Una clave natural siempre puede repetirse, quedar vacia o cambiar.")
print("Una subrogada nunca, y a cambio no sirve para cruzar con otro sistema.")

vacios = [x for x in estudiantes if not x["legajo"].strip()]
print()
print("legajos vacios en este archivo:", len(vacios))
print("Que hoy no haya ninguno no es una garantia: es el estado de hoy.")
academico_estudiantes.csv: 50 filas

columna     distintos   unica
------------------------------
id                 50      SI
legajo             49      no
nombre             50      SI
correo             50      SI

id sirve como clave primaria: 50 valores distintos en 50 filas.
legajo NO, y eso es una sorpresa: se supone que es el identificador
de la facultad.

el legajo repetido: ['E007']
   {'id': '7', 'legajo': 'E007', 'nombre': 'Walter Muñoz', 'correo': 'walter.munoz@uni.edu.ar'}
   {'id': '8', 'legajo': 'E007', 'nombre': 'Belén Ríos', 'correo': 'belen.rios@uni.edu.ar'}

Son dos personas distintas con el mismo legajo. Cual de las dos es
'la buena' no lo dice el archivo: es una decision, y por eso la
leccion 5.5 va a pedir una regla de desempate escrita.

La diferencia entre las dos columnas:
  id     -> clave SUBROGADA: la inventa el sistema y no significa nada
  legajo -> clave NATURAL: viene del mundo real y puede fallar

Una clave natural siempre puede repetirse, quedar vacia o cambiar.
Una subrogada nunca, y a cambio no sirve para cruzar con otro sistema.

legajos vacios en este archivo: 0
Que hoy no haya ninguno no es una garantia: es el estado de hoy.

La clave primaria es la columna (o el conjunto de columnas) cuyo valor identifica una fila y no se repite nunca. En academico_estudiantes.csv hay dos candidatas y solo una funciona:

ColumnaDistintos en 50 filasSirveQué clase de clave es
id50SíSubrogada: la inventa el sistema, no significa nada
legajo49NoNatural: viene del mundo real, y puede fallar
E007 son dos personas

Walter Muñoz y Belén Ríos comparten legajo. Cuál de los dos es «el bueno» no lo dice el archivo: es una decisión, y la lección 5.5 va a pedir que esté escrita antes de deduplicar.

Una clave natural siempre puede repetirse, quedar vacía o cambiar - un DNI mal cargado, un correo que la persona cambia. Una subrogada nunca, y a cambio no sirve para cruzar con otro sistema, que es exactamente el problema de la unidad 6.

El grano

EjemploEl grano: que representa UNA fila
import collections
import csv


def leer(nombre):
    with open(nombre, encoding="utf-8", newline="") as f:
        return list(csv.DictReader(f))


estudiantes = leer("academico_estudiantes.csv")
cursos = leer("academico_cursos.csv")
inscripciones = leer("academico_inscripciones.csv")

print(f"{'tabla':<24} {'filas':>6}  una fila es...")
print("-" * 62)
print(f"{'academico_estudiantes':<24} {len(estudiantes):>6}  un estudiante")
print(f"{'academico_cursos':<24} {len(cursos):>6}  un curso")
print(f"{'academico_inscripciones':<24} {len(inscripciones):>6}  "
      f"un estudiante EN un curso")

print()
print("Ese 'EN' es todo el concepto. El grano de inscripciones no es el")
print("estudiante ni el curso: es el PAR. Y por eso su clave primaria")
print("tiene que ser compuesta.")

print()
por_estudiante = collections.Counter(x["estudiante_id"] for x in inscripciones)
print("cuantas inscripciones tiene cada estudiante:")
reparto = collections.Counter(por_estudiante.values())
for cuantas, cuantos in sorted(reparto.items()):
    print(f"   {cuantos:>3} estudiantes con {cuantas} inscripcion(es)")
print("   total de estudiantes que aparecen:", len(por_estudiante), "de 50")

print()
print("La pregunta que hace el grano: '¿cuantos estudiantes hay?'")
print("  sobre academico_estudiantes ->", len(estudiantes))
print("  contando filas de inscripciones ->", len(inscripciones), "MAL")
print("  contando estudiante_id distintos ->", len(por_estudiante))
print()
print("Las tres son respuestas a preguntas distintas, y la del medio no")
print("responde ninguna: cuenta inscripciones y las llama estudiantes.")

print()
print("Y al reves, sumar sobre la tabla equivocada infla el resultado:")
# ET100 tiene los creditos vacios: se saltea y se dice cuantos son.
creditos = {c["id"]: int(c["creditos"])
            for c in cursos if c["creditos"].strip().isdigit()}
sin_creditos = [c["codigo"] for c in cursos
                if not c["creditos"].strip().isdigit()]
total_catalogo = sum(creditos.values())
total_cursado = sum(creditos.get(x["curso_id"], 0) for x in inscripciones)
print("   cursos sin creditos declarados             :", sin_creditos)
print("   creditos del catalogo (grano = curso)      :", total_catalogo)
print("   creditos cursados (grano = inscripcion)    :", total_cursado)
print("   El segundo cuenta cada curso una vez por alumno inscripto.")
print("   Ninguno esta mal: contestan cosas distintas.")

print()
print("Antes de sumar o contar cualquier cosa, la pregunta es siempre la")
print("misma: en esta tabla, ¿que representa una fila?")
tabla                     filas  una fila es...
--------------------------------------------------------------
academico_estudiantes        50  un estudiante
academico_cursos              8  un curso
academico_inscripciones     110  un estudiante EN un curso

Ese 'EN' es todo el concepto. El grano de inscripciones no es el
estudiante ni el curso: es el PAR. Y por eso su clave primaria
tiene que ser compuesta.

cuantas inscripciones tiene cada estudiante:
    11 estudiantes con 1 inscripcion(es)
    11 estudiantes con 2 inscripcion(es)
    10 estudiantes con 3 inscripcion(es)
     9 estudiantes con 4 inscripcion(es)
     1 estudiantes con 5 inscripcion(es)
     1 estudiantes con 6 inscripcion(es)
   total de estudiantes que aparecen: 43 de 50

La pregunta que hace el grano: '¿cuantos estudiantes hay?'
  sobre academico_estudiantes -> 50
  contando filas de inscripciones -> 110 MAL
  contando estudiante_id distintos -> 43

Las tres son respuestas a preguntas distintas, y la del medio no
responde ninguna: cuenta inscripciones y las llama estudiantes.

Y al reves, sumar sobre la tabla equivocada infla el resultado:
   cursos sin creditos declarados             : ['ET100']
   creditos del catalogo (grano = curso)      : 47
   creditos cursados (grano = inscripcion)    : 636
   El segundo cuenta cada curso una vez por alumno inscripto.
   Ninguno esta mal: contestan cosas distintas.

Antes de sumar o contar cualquier cosa, la pregunta es siempre la
misma: en esta tabla, ¿que representa una fila?

El grano de una tabla es lo que representa una fila. Suena trivial hasta que hay tres tablas sobre la mesa:

TablaFilasUna fila es…Clave
academico_estudiantes50un estudianteid
academico_cursos8un cursoid
academico_inscripciones110un estudiante en un curso(estudiante_id, curso_id)

Ese «en» es todo el concepto. Y decide la respuesta a la pregunta más simple que se le puede hacer a estos datos:

«¿Cuántos estudiantes hay?»
Cómo se cuentaDaQué respondió en realidad
Filas de estudiantes50Cuántos estudiantes hay
Filas de inscripciones110Nada. Cuenta inscripciones y las llama estudiantes
estudiante_id distintos43Cuántos estudiantes se anotaron en algo

Las tres cuentas son correctas; dos responden preguntas y una no responde ninguna. Lo mismo pasa al sumar: los créditos del catálogo dan 47 y los créditos cursados 636, porque el segundo cuenta cada curso una vez por alumno inscripto. Ninguno está mal - son cosas distintas, y hay que saber cuál se pidió.

Clave compuesta y clave foránea

EjemploClave compuesta y clave foranea, sobre las inscripciones
import collections
import csv


def leer(nombre):
    with open(nombre, encoding="utf-8", newline="") as f:
        return list(csv.DictReader(f))


estudiantes = leer("academico_estudiantes.csv")
cursos = leer("academico_cursos.csv")
inscripciones = leer("academico_inscripciones.csv")

print("academico_inscripciones.csv:", len(inscripciones), "filas")
for columna in inscripciones[0]:
    print(f"   {columna:<14} {len({x[columna] for x in inscripciones}):>3} distintos")
print()
print("Ninguna columna sola alcanza como clave: hay 43 estudiantes y 9")
print("cursos en 110 filas. La clave es el PAR (estudiante_id, curso_id),")
print("que es lo mismo que decir que el grano es 'un alumno en un curso'.")

pares = collections.Counter((x["estudiante_id"], x["curso_id"])
                            for x in inscripciones)
repetidos = {k: v for k, v in pares.items() if v > 1}
print()
print("pares distintos:", len(pares), "en", len(inscripciones), "filas")
print("pares repetidos:", repetidos)
for fila in inscripciones:
    if (fila["estudiante_id"], fila["curso_id"]) in repetidos:
        print("  ", fila)

print()
print("La clave compuesta TAMPOCO se cumple: el estudiante 35 figura dos")
print("veces en el curso 5, con notas distintas. En una base de datos esto")
print("seria un UNIQUE violado; en un CSV, nada lo impide.")
print("Consecuencia practica: un merge por ese par va a duplicar filas.")

# Clave foranea: cada id de inscripciones tiene que existir en su tabla.
ids_estudiante = {x["id"] for x in estudiantes}
ids_curso = {x["id"] for x in cursos}

huerfanas_e = [x for x in inscripciones if x["estudiante_id"] not in ids_estudiante]
huerfanas_c = [x for x in inscripciones if x["curso_id"] not in ids_curso]

print()
print("CLAVE FORANEA: ¿cada referencia apunta a algo que existe?")
print("   inscripciones con estudiante_id inexistente:", len(huerfanas_e))
for fila in huerfanas_e:
    print("     ", fila, "<- no hay estudiante 999")
print("   inscripciones con curso_id inexistente:", len(huerfanas_c))
for fila in huerfanas_c:
    print("     ", fila, "<- no hay curso 77")

print()
sin_inscripcion = sorted(ids_estudiante - {x["estudiante_id"] for x in inscripciones},
                         key=int)
print("Y el caso simetrico, que NO es un error:")
print("   estudiantes sin ninguna inscripcion:", len(sin_inscripcion))
print("   ids:", sin_inscripcion)
print("   Una fila sin hijos es normal (un alumno que no se anoto).")
print("   Una fila sin padre es una violacion (una inscripcion a la nada).")
academico_inscripciones.csv: 110 filas
   estudiante_id   43 distintos
   curso_id         9 distintos
   nota            59 distintos

Ninguna columna sola alcanza como clave: hay 43 estudiantes y 9
cursos en 110 filas. La clave es el PAR (estudiante_id, curso_id),
que es lo mismo que decir que el grano es 'un alumno en un curso'.

pares distintos: 109 en 110 filas
pares repetidos: {('35', '5'): 2}
   {'estudiante_id': '35', 'curso_id': '5', 'nota': '5.8'}
   {'estudiante_id': '35', 'curso_id': '5', 'nota': '6.0'}

La clave compuesta TAMPOCO se cumple: el estudiante 35 figura dos
veces en el curso 5, con notas distintas. En una base de datos esto
seria un UNIQUE violado; en un CSV, nada lo impide.
Consecuencia practica: un merge por ese par va a duplicar filas.

CLAVE FORANEA: ¿cada referencia apunta a algo que existe?
   inscripciones con estudiante_id inexistente: 1
      {'estudiante_id': '999', 'curso_id': '1', 'nota': '8.0'} <- no hay estudiante 999
   inscripciones con curso_id inexistente: 1
      {'estudiante_id': '1', 'curso_id': '77', 'nota': '7.0'} <- no hay curso 77

Y el caso simetrico, que NO es un error:
   estudiantes sin ninguna inscripcion: 8
   ids: ['23', '24', '45', '46', '47', '48', '49', '50']
   Una fila sin hijos es normal (un alumno que no se anoto).
   Una fila sin padre es una violacion (una inscripcion a la nada).

En inscripciones ninguna columna sola alcanza: hay 43 estudiantes y 9 cursos en 110 filas. La clave es el par, que es otra forma de decir que el grano es «un alumno en un curso».

Y la clave compuesta tampoco se cumple

El estudiante 35 figura dos veces en el curso 5, con notas 5.8 y 6.0: 109 pares distintos en 110 filas. En una base de datos sería un UNIQUE violado; en un CSV nada lo impide.

Consecuencia concreta: un merge por ese par va a devolver una fila de más, y el resultado va a parecer perfectamente válido.

La clave foránea es la pregunta simétrica: lo que esta tabla dice apuntar, ¿existe?

SituaciónCuántas¿Es un error?
Inscripción con estudiante_id = 9991Sí. Es un huérfano: apunta a la nada
Inscripción con curso_id = 771Sí. Otro huérfano
Estudiante sin ninguna inscripción8No. Un alumno que no se anotó es un caso normal

Una fila sin hijos es normal; una fila sin padre es una violación. La asimetría importa porque decide qué tipo de cruce se usa en la unidad 6: los 8 estudiantes sin inscripción se conservan con un left join, y los 2 huérfanos se encuentran con un anti-join.

Cardinalidad: cuántas filas salen de un cruce

EjemploCardinalidad: cuantas filas sale de cruzar dos tablas
# Tablas de juguete, chiquitas a proposito: la idea se ve contando a mano.
# Los numeros sobre los archivos reales son el ejercicio EJ-2.1.


def cruzar(izquierda, derecha, clave_i, clave_d):
    """Todas las combinaciones cuya clave coincide (un inner join)."""
    salida = []
    for fila_i in izquierda:
        for fila_d in derecha:
            if fila_i[clave_i] == fila_d[clave_d]:
                salida.append({**fila_i, **fila_d})
    return salida


print("CASO 1:1 - cada clave aparece una vez de los dos lados")
personas = [{"id": 1, "nombre": "Ana"}, {"id": 2, "nombre": "Beto"}]
dnis = [{"id": 1, "dni": "30111222"}, {"id": 2, "dni": "31222333"}]
r = cruzar(personas, dnis, "id", "id")
print(f"   {len(personas)} x {len(dnis)} -> {len(r)} filas."
      " El resultado tiene el mismo grano que las entradas.")

print()
print("CASO 1:N - cada clave, una vez a la izquierda y varias a la derecha")
cursos = [{"curso": "BD101"}, {"curso": "PR201"}]
notas = [{"curso": "BD101", "nota": 8}, {"curso": "BD101", "nota": 6},
         {"curso": "BD101", "nota": 9}, {"curso": "PR201", "nota": 7}]
r = cruzar(cursos, notas, "curso", "curso")
print(f"   {len(cursos)} x {len(notas)} -> {len(r)} filas."
      " El grano pasa a ser el del lado N.")
print("   Es el caso normal, y el que hay que reconocer: despues de cruzar,")
print("   'contar filas' ya no cuenta cursos.")

print()
print("CASO N:M - la clave se repite de LOS DOS lados")
izq = [{"k": "x", "a": 1}, {"k": "x", "a": 2}]
der = [{"k": "x", "b": 10}, {"k": "x", "b": 20}, {"k": "x", "b": 30}]
r = cruzar(izq, der, "k", "k")
print(f"   {len(izq)} x {len(der)} -> {len(r)} filas = 2 x 3.")
print("   Multiplica. Casi nunca es lo que se queria, y casi siempre es")
print("   un duplicado no detectado de alguno de los dos lados.")

print()
print("EL CASO QUE MUERDE: una sola clave repetida")
alumnos = [{"legajo": "E007", "nombre": "Walter"},
           {"legajo": "E007", "nombre": "Belen"},
           {"legajo": "E008", "nombre": "Ciro"}]
inscr = [{"legajo": "E007", "curso": "BD101"},
         {"legajo": "E008", "curso": "BD101"}]
r = cruzar(inscr, alumnos, "legajo", "legajo")
print(f"   {len(inscr)} inscripciones x {len(alumnos)} alumnos -> "
      f"{len(r)} filas")
for fila in r:
    print("     ", fila)
print("   La inscripcion de E007 se DUPLICO, porque el legajo apunta a dos")
print("   personas. Nadie avisa: el resultado tiene una fila de mas y")
print("   parece perfectamente valido.")

print()
print("La regla, y es toda la leccion 6.3 por adelantado:")
print("  1:1 -> la cantidad de filas se mantiene")
print("  1:N -> queda el grano del lado N")
print("  N:M -> multiplica, y casi siempre es un error")
print()
print("Antes de cruzar, la pregunta es: ¿la clave es UNICA de cada lado?")
print("Si la respuesta es 'creo que si', hay que contarlo.")
CASO 1:1 - cada clave aparece una vez de los dos lados
   2 x 2 -> 2 filas. El resultado tiene el mismo grano que las entradas.

CASO 1:N - cada clave, una vez a la izquierda y varias a la derecha
   2 x 4 -> 4 filas. El grano pasa a ser el del lado N.
   Es el caso normal, y el que hay que reconocer: despues de cruzar,
   'contar filas' ya no cuenta cursos.

CASO N:M - la clave se repite de LOS DOS lados
   2 x 3 -> 6 filas = 2 x 3.
   Multiplica. Casi nunca es lo que se queria, y casi siempre es
   un duplicado no detectado de alguno de los dos lados.

EL CASO QUE MUERDE: una sola clave repetida
   2 inscripciones x 3 alumnos -> 3 filas
      {'legajo': 'E007', 'curso': 'BD101', 'nombre': 'Walter'}
      {'legajo': 'E007', 'curso': 'BD101', 'nombre': 'Belen'}
      {'legajo': 'E008', 'curso': 'BD101', 'nombre': 'Ciro'}
   La inscripcion de E007 se DUPLICO, porque el legajo apunta a dos
   personas. Nadie avisa: el resultado tiene una fila de mas y
   parece perfectamente valido.

La regla, y es toda la leccion 6.3 por adelantado:
  1:1 -> la cantidad de filas se mantiene
  1:N -> queda el grano del lado N
  N:M -> multiplica, y casi siempre es un error

Antes de cruzar, la pregunta es: ¿la clave es UNICA de cada lado?
Si la respuesta es 'creo que si', hay que contarlo.
La regla, que es toda la lección 6.3 por adelantado
CardinalidadQué pasa con las filasCuándo aparece
1:1Se mantienenDos tablas del mismo grano
1:NQueda el grano del lado NEl caso normal: cliente → pedidos
N:MMultiplicaCasi siempre, un duplicado no detectado
La pregunta antes de cruzar

¿La clave es única de cada lado? Si la respuesta es «creo que sí», hay que contarlo - son dos líneas. En el ejemplo, una sola clave repetida (E007) convierte 2 inscripciones en 3 filas, sin ningún aviso.

pandas tiene una forma de exigirlo: merge(..., validate="m:1") levanta MergeError en vez de duplicar en silencio. Es de la lección 6.3, y esta página es el motivo por el que existe.

Recetario

Las tres preguntas, en Python puro
ExpresiónContesta
len({f["id"] for f in filas}) == len(filas)¿id sirve como clave primaria?
Counter(f["legajo"] for f in filas)¿Cuál se repite, y cuántas veces?
{(f["a"], f["b"]) for f in filas}Los valores de una clave compuesta
hijos - {f["id"] for f in padres}Las referencias huérfanas
{f["id"] for f in padres} - referenciadosLos padres sin hijos (que no es un error)
Counter(f["clave"] for f in izq).most_common(1)Si la clave es única a la izquierda
len(izq) * max(Counter(...).values())El techo de filas que puede dar el cruce

Ponete a prueba

1. ¿Qué es el grano de una tabla?Nivel 1 · Recordar
2. En academico_estudiantes.csv, id tiene 50 valores distintos en 50 filas y legajo tiene 49. ¿Cuál sirve de clave primaria?Nivel 1 · Recordar
3. academico_inscripciones.csv tiene 110 filas y 43 estudiante_id distintos. ¿Cuántos estudiantes hay?Nivel 2 · Predecir la salida
4. Hay 1 inscripción con estudiante_id = 999 (no existe) y 8 estudiantes sin ninguna inscripción. ¿Cuál de las dos situaciones es un error?Nivel 2 · Predecir la salida
5. Vas a cruzar 110 inscripciones con 50 estudiantes por estudiante_id, y sabés que un legajo está repetido. ¿Qué revisás antes?Nivel 3 · Elegir entre dos casi correctas
6. Los créditos del catálogo suman 47 y los «créditos cursados» suman 636. ¿Cuál está mal?Nivel 3 · Elegir entre dos casi correctas
7. Este código cruza 2 inscripciones con 3 alumnos por legajo y devuelve 3 filas, todas con datos correctos. ¿Qué pasó?
alumnos = [{"legajo": "E007", "nombre": "Walter"},
           {"legajo": "E007", "nombre": "Belen"},
           {"legajo": "E008", "nombre": "Ciro"}]
inscr   = [{"legajo": "E007", "curso": "BD101"},
           {"legajo": "E008", "curso": "BD101"}]
Nivel 4 · Encontrar el defecto

Prácticas de esta subunidad