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
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:
| Columna | Distintos en 50 filas | Sirve | Qué clase de clave es |
|---|---|---|---|
id | 50 | Sí | Subrogada: la inventa el sistema, no significa nada |
legajo | 49 | No | Natural: 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
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:
| Tabla | Filas | Una fila es… | Clave |
|---|---|---|---|
academico_estudiantes | 50 | un estudiante | id |
academico_cursos | 8 | un curso | id |
academico_inscripciones | 110 | un 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:
| Cómo se cuenta | Da | Qué respondió en realidad |
|---|---|---|
Filas de estudiantes | 50 | Cuántos estudiantes hay |
Filas de inscripciones | 110 | Nada. Cuenta inscripciones y las llama estudiantes |
estudiante_id distintos | 43 | Cuá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
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».
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ón | Cuántas | ¿Es un error? |
|---|---|---|
Inscripción con estudiante_id = 999 | 1 | Sí. Es un huérfano: apunta a la nada |
Inscripción con curso_id = 77 | 1 | Sí. Otro huérfano |
| Estudiante sin ninguna inscripción | 8 | No. 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
# 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.
| Cardinalidad | Qué pasa con las filas | Cuándo aparece |
|---|---|---|
| 1:1 | Se mantienen | Dos tablas del mismo grano |
| 1:N | Queda el grano del lado N | El caso normal: cliente → pedidos |
| N:M | Multiplica | Casi siempre, un duplicado no detectado |
¿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
| Expresión | Contesta |
|---|---|
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} - referenciados | Los 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 |