Indice / Repaso: archivos y errores / 1.5
Las cuatro capas de error
Esta es la lección que sostiene el resto del curso. Todo lo que viene después - NumPy, pandas, los 39 ejercicios - usa el mismo esquema: en qué capa falló el dato, y qué se hace con él.
Para qué sirve
«El archivo está mal» no es un diagnóstico. Puede significar que no se pudo abrir,
que una línea no tiene la forma esperada, que un campo dice TRACE donde
solo puede decir GET, o que la suma no cierra con la del mes pasado. Son
cuatro problemas distintos, se detectan en momentos distintos y se resuelven
distinto.
Separarlos es lo que convierte «hay un error» en «la línea 412 tiene el método
TRACE, que no está en el dominio, y por eso se rechazó». Ese salto es todo
el contenido de esta lección.
ELI5Explicado facil
Es como recibir una encomienda. Primero: ¿llegó? Después: ¿se puede abrir la caja? Después: ¿lo que hay adentro es lo que decía el remito? Y recién al final: ¿me sirve para lo que lo pedí?
Cada pregunta supone que la anterior salió bien. No tiene sentido revisar si el color es el correcto si la caja no llegó.
Las cuatro capas
| Capa | La pregunta | Cómo se manifiesta | Herramienta |
|---|---|---|---|
| 1. Lectura | ¿Existe el archivo y se puede decodificar? | FileNotFoundError, PermissionError, UnicodeDecodeError |
try/except OSError, encoding= |
| 2. Parseo | ¿Tiene la forma esperada? | JSONDecodeError, una fila con 4 campos en vez de 6, un patrón que no matchea |
csv, json, re |
| 3. Validación | ¿Cada campo es lo que dice ser? | Casi siempre en silencio. Una fecha imposible, un método desconocido, una edad de 200 | Código que vos escribís |
| 4. Negocio | ¿El conjunto tiene sentido? | También en silencio. Un pedido sin cliente, un total que no cierra, un duplicado | Código que vos escribís, mirando todas las filas |
Las capas 1 y 2 avisan solas; de las capas 3 y 4 no avisa nadie. Ese es el punto entero. La lección 1.2 mostró la primera mitad de la idea sobre una columna de notas; acá se completa.
import re
# El patron describe la FORMA: seis campos separados por espacios.
# Que cada campo sea de veras lo que dice ser es otra pregunta.
PATRON = re.compile(
r"^(?P<fecha>\S+) (?P<ip>\S+) (?P<metodo>\S+) "
r"(?P<ruta>\S+) (?P<estado>\S+) (?P<ms>\S+)$")
FECHA = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
METODOS = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"}
CASOS = [
"2026-08-24T08:01:01 10.0.0.28 PUT /login 200 102",
"2026-08-24T08:04:12 10.0.0.9 DELETE /reportes 200",
"fecha_invalida 10.0.0.6 GET / 200 45",
"2026-08-24T08:07:33 10.0.0.4 TRACE /admin 200 61",
"2026-08-24T08:09:02 10.0.0.7 GET /home 200 -30",
]
for linea in CASOS:
print(repr(linea))
# Capa 2, parseo: ¿tiene la forma esperada?
m = PATRON.match(linea)
if m is None:
print(" 2 parseo -> RECHAZADA: no tiene 6 campos")
print()
continue
campos = m.groupdict()
print(" 2 parseo -> ok, 6 campos")
# Capa 3, validacion: ¿cada campo es lo que dice ser?
problemas = []
if not FECHA.fullmatch(campos["fecha"]):
problemas.append("fecha_invalida")
if campos["metodo"] not in METODOS:
problemas.append("dominio_desconocido (" + campos["metodo"] + ")")
try:
if int(campos["ms"]) < 0:
problemas.append("negativo_incoherente (ms)")
except ValueError:
problemas.append("no_numerico (ms)")
if problemas:
print(" 3 validacion -> RECHAZADA:", "; ".join(problemas))
print()
continue
print(" 3 validacion -> ok")
print(" 4 negocio -> ya se puede contar, promediar y agrupar")
print()
print("La capa 1, la de lectura, ya ocurrio antes de todo esto: el archivo")
print("existe, se abrio con su encoding y el JSON parseo bien.")
print()
print("Cada capa supone que la anterior salio bien, y por eso el orden es")
print("obligatorio: preguntar si el metodo es valido antes de saber si hay")
print("un metodo no tiene sentido. Fijate que la linea del '-30' tiene la")
print("forma correcta: solo la capa 3 puede rechazarla.")
'2026-08-24T08:01:01 10.0.0.28 PUT /login 200 102'
2 parseo -> ok, 6 campos
3 validacion -> ok
4 negocio -> ya se puede contar, promediar y agrupar
'2026-08-24T08:04:12 10.0.0.9 DELETE /reportes 200'
2 parseo -> RECHAZADA: no tiene 6 campos
'fecha_invalida 10.0.0.6 GET / 200 45'
2 parseo -> ok, 6 campos
3 validacion -> RECHAZADA: fecha_invalida
'2026-08-24T08:07:33 10.0.0.4 TRACE /admin 200 61'
2 parseo -> ok, 6 campos
3 validacion -> RECHAZADA: dominio_desconocido (TRACE)
'2026-08-24T08:09:02 10.0.0.7 GET /home 200 -30'
2 parseo -> ok, 6 campos
3 validacion -> RECHAZADA: negativo_incoherente (ms)
La capa 1, la de lectura, ya ocurrio antes de todo esto: el archivo
existe, se abrio con su encoding y el JSON parseo bien.
Cada capa supone que la anterior salio bien, y por eso el orden es
obligatorio: preguntar si el metodo es valido antes de saber si hay
un metodo no tiene sentido. Fijate que la linea del '-30' tiene la
forma correcta: solo la capa 3 puede rechazarla.
Fijate en fecha_invalida 10.0.0.6 GET / 200 45: tiene seis campos
separados por espacios, así que pasa la capa 2 sin problemas. Que el
primer campo no sea una fecha es una pregunta de la capa 3.
Es el malentendido más caro de la unidad: «si la expresión regular matchea, el dato es válido». Una regex verifica forma. El significado hay que preguntarlo aparte.
Los cinco tokens de ausente
import collections
import csv
from pathlib import Path
# La lista no es un capricho: es lo que aparece de verdad en los datos.
CANDIDATOS = ["", "-", "N/A", "NaN", "null", "NA", "None", "n/a", "?"]
conteo = collections.Counter()
por_archivo = collections.defaultdict(set)
for ruta in sorted(Path(".").glob("*.csv")):
with open(ruta, encoding="utf-8", newline="") as f:
for fila in csv.DictReader(f):
for valor in fila.values():
if valor is not None and valor.strip() in CANDIDATOS:
token = valor.strip()
conteo[token] += 1
por_archivo[token].add(ruta.name)
print("token celdas archivos")
for token in CANDIDATOS:
marca = "" if conteo[token] else " <- no aparece nunca"
print(f"{token!r:>7} {conteo[token]:>7} {len(por_archivo[token]):>9}{marca}")
print()
print("total de celdas ausentes:", sum(conteo.values()))
print()
print("Los cinco que si aparecen son la constante NULOS del curso:")
NULOS = {"", "-", "N/A", "NaN", "null"}
print(" ", sorted(NULOS))
print()
print("Y no estan repartidos parejo: cada generador sembro los suyos.")
for token in sorted(NULOS):
archivos = sorted(por_archivo[token])
print(f" {token!r:>6} en {len(archivos)} archivo(s):",
", ".join(a.replace('.csv', '') for a in archivos[:4]),
"..." if len(archivos) > 4 else "")
print()
print("Por eso la lista va escrita en el codigo, arriba y en un solo lugar.")
print("Adivinarla archivo por archivo es como se pierden datos en silencio.")
token celdas archivos
'' 62 26
'-' 5 4
'N/A' 10 10
'NaN' 5 5
'null' 3 3
'NA' 0 0 <- no aparece nunca
'None' 0 0 <- no aparece nunca
'n/a' 0 0 <- no aparece nunca
'?' 0 0 <- no aparece nunca
total de celdas ausentes: 85
Los cinco que si aparecen son la constante NULOS del curso:
['', '-', 'N/A', 'NaN', 'null']
Y no estan repartidos parejo: cada generador sembro los suyos.
'' en 26 archivo(s): academico_cursos, academico_estudiantes, academico_inscripciones, asistencia_sesiones ...
'-' en 4 archivo(s): asistencia_sesiones, clientes_crudos, estudiantes_notas, transacciones_categorias
'N/A' en 10 archivo(s): clientes_crudos, contactos_origen, dw_hecho_ventas, estudiantes_notas ...
'NaN' en 5 archivo(s): academico_inscripciones, catalogo_sistema_a, estudiantes_notas, inventario_productos ...
'null' en 3 archivo(s): clientes_crudos, tienda_pedidos, transacciones_categorias
Por eso la lista va escrita en el codigo, arriba y en un solo lugar.
Adivinarla archivo por archivo es como se pierden datos en silencio.
85 celdas ausentes en los 28 CSV del corpus, escritas de cinco maneras distintas y
repartidas de forma despareja: - aparece en 4 archivos,
null en 3, la celda vacía en 26. Un programa que solo contempla
"" se pierde 23 de esas 85 y ni se entera.
NULOS = {"", "-", "N/A", "NaN", "null"}
Va escrita una vez, arriba del programa, y se usa en todos lados. Adivinarla archivo por archivo es exactamente como se pierden datos en silencio.
Y anotá el detalle que va a volver en la lección 5.4: pandas reconoce por su cuenta
NaN, N/A, null y NA…
pero no -. Un solo guion deja la columna entera como
texto, y el promedio deja de dar.
Acá hay que elegir
import csv
import statistics
NULOS = {"", "-", "N/A", "NaN", "null"}
with open("transacciones_categorias.csv", encoding="utf-8", newline="") as f:
filas = list(csv.DictReader(f))
def numero(texto):
"""El importe como float, o None si no se puede."""
crudo = texto.strip()
if crudo in NULOS:
return None
try:
return float(crudo)
except ValueError:
return None
valores = [numero(x["importe"]) for x in filas]
usables = [v for v in valores if v is not None]
sin_dato = [x["id"] for x, v in zip(filas, valores) if v is None]
print("filas:", len(filas), "| importes usables:", len(usables),
"| sin importe:", len(sin_dato))
print("ids sin importe:", ", ".join(sin_dato))
print()
media = statistics.mean(usables)
mediana = statistics.median(usables)
print("1. DESCARTAR: la fila sin importe no participa de nada.")
print(" total:", round(sum(usables), 2), "| filas:", len(usables))
print("2. IMPUTAR: se completa el hueco. Con que, es otra decision.")
print(" con la media :", round(sum(usables) + len(sin_dato) * media, 2))
print(" con la mediana:", round(sum(usables) + len(sin_dato) * mediana, 2))
print("3. INFORMAR: se descarta Y queda escrito que se descarto.")
print(" total:", round(sum(usables), 2), "+ una tabla de 6 rechazos")
print()
print("La media y la mediana de esta columna no se parecen en nada:")
print(" media :", round(media, 2))
print(" mediana:", round(mediana, 2))
print(" maximo :", max(usables))
print(" sin ese unico valor, la media seria:",
round(statistics.mean(sorted(usables)[:-1]), 2))
print()
print("Un solo importe de 5.000.000 mueve la media 34.000 pesos por fila.")
print("Imputar con la media reparte ese atipico entre los seis huecos:")
print("la eleccion del relleno importa tanto como la de imputar o no.")
print()
print("Ninguna de las tres politicas es 'la correcta'. La incorrecta es la")
print("cuarta: aplicar una de las tres sin dejar dicho cual fue.")
filas: 150 | importes usables: 144 | sin importe: 6
ids sin importe: OP0025, OP0050, OP0075, OP0100, OP0125, OP0150
1. DESCARTAR: la fila sin importe no participa de nada.
total: 5286326.75 | filas: 144
2. IMPUTAR: se completa el hueco. Con que, es otra decision.
con la media : 5506590.36
con la mediana: 5297823.41
3. INFORMAR: se descarta Y queda escrito que se descarto.
total: 5286326.75 + una tabla de 6 rechazos
La media y la mediana de esta columna no se parecen en nada:
media : 36710.6
mediana: 1916.11
maximo : 5000000.0
sin ese unico valor, la media seria: 2002.28
Un solo importe de 5.000.000 mueve la media 34.000 pesos por fila.
Imputar con la media reparte ese atipico entre los seis huecos:
la eleccion del relleno importa tanto como la de imputar o no.
Ninguna de las tres politicas es 'la correcta'. La incorrecta es la
cuarta: aplicar una de las tres sin dejar dicho cual fue.
Con un dato que falta hay tres políticas. La columna importe de
transacciones_categorias.csv tiene 6 huecos sobre 150 filas:
| Política | Qué hace | Qué cuesta |
|---|---|---|
| Descartar | La fila no participa: total sobre 144 filas | Se pierde el resto de la fila, que podía estar perfecto |
| Imputar | Se rellena el hueco con media, mediana, cero o el valor anterior | Inventa datos. Y elegir con qué es una segunda decisión, tan grande como la primera |
| Informar | Se descarta y se publica la tabla de rechazos | Un archivo más. Es lo que este curso pide por defecto |
Mirá los números del ejemplo antes de elegir: la media de esa columna es 36.710 y la mediana 1.916, porque hay un solo importe de 5.000.000. Imputar con la media reparte ese atípico entre los seis huecos; imputar con la mediana no. Ninguna de las tres es la correcta. La incorrecta es la cuarta: aplicar una sin dejar dicho cuál.
El vocabulario de rechazos
import collections
import csv
import re
# Los diez motivos del curso. Cerrado: si un defecto no entra en
# ninguno, se discute el vocabulario, no se inventa una redaccion.
MOTIVOS = [
"ausente", "no_numerico", "fuera_de_rango", "fecha_invalida",
"dominio_desconocido", "duplicado", "huerfano",
"negativo_incoherente", "atipico", "restriccion_violada",
]
NULOS = {"", "-", "N/A", "NaN", "null"}
FECHA = re.compile(r"\d{4}-\d{2}-\d{2}")
TIPOS = {"ingreso", "gasto"}
TECHO = 100000.0
def revisar(fila, vistos):
"""Todos los defectos de la fila, como (motivo, columna)."""
fallas = []
if fila["id"] in vistos:
fallas.append(("duplicado", "id"))
if fila["categoria"].strip() in NULOS:
fallas.append(("ausente", "categoria"))
if not FECHA.fullmatch(fila["fecha"].strip()):
fallas.append(("fecha_invalida", "fecha"))
if fila["tipo"].strip() not in TIPOS:
fallas.append(("dominio_desconocido", "tipo"))
crudo = fila["importe"].strip()
if crudo in NULOS:
fallas.append(("ausente", "importe"))
else:
try:
valor = float(crudo)
except ValueError:
fallas.append(("no_numerico", "importe"))
else:
if valor < 0:
fallas.append(("negativo_incoherente", "importe"))
elif valor > TECHO:
fallas.append(("atipico", "importe"))
return fallas
with open("transacciones_categorias.csv", encoding="utf-8", newline="") as f:
filas = list(csv.DictReader(f))
conteo = collections.Counter()
rechazadas, vistos = [], set()
for fila in filas:
fallas = revisar(fila, vistos)
vistos.add(fila["id"])
if fallas:
rechazadas.append((fila["id"], fallas))
for motivo, columna in fallas:
conteo[(motivo, columna)] += 1
print("filas:", len(filas), "| con al menos un defecto:", len(rechazadas))
print()
print("motivo columna casos")
for motivo in MOTIVOS:
for (m, columna), cuantos in sorted(conteo.items()):
if m == motivo:
print(f"{motivo:<21} {columna:<11} {cuantos:>5}")
print()
print("Los motivos que NO aparecen en este archivo:")
presentes = {m for m, _ in conteo}
print(" ", ", ".join(m for m in MOTIVOS if m not in presentes))
print("Que el vocabulario sea mas grande que el archivo es lo correcto:")
print("es el mismo en los 39 ejercicios, y por eso los informes de")
print("rechazos se pueden comparar entre si.")
print()
print("las", len(rechazadas), "filas rechazadas, en detalle:")
for identificador, fallas in rechazadas:
detalle = "; ".join(f"{m} ({c})" for m, c in fallas)
print(f" {identificador} {detalle}")
filas: 150 | con al menos un defecto: 11
motivo columna casos
ausente categoria 1
ausente importe 6
dominio_desconocido tipo 2
negativo_incoherente importe 1
atipico importe 1
Los motivos que NO aparecen en este archivo:
no_numerico, fuera_de_rango, fecha_invalida, duplicado, huerfano, restriccion_violada
Que el vocabulario sea mas grande que el archivo es lo correcto:
es el mismo en los 39 ejercicios, y por eso los informes de
rechazos se pueden comparar entre si.
las 11 filas rechazadas, en detalle:
OP0025 ausente (importe)
OP0041 negativo_incoherente (importe)
OP0050 ausente (importe)
OP0073 ausente (categoria)
OP0075 ausente (importe)
OP0096 dominio_desconocido (tipo)
OP0100 ausente (importe)
OP0118 dominio_desconocido (tipo)
OP0125 ausente (importe)
OP0132 atipico (importe)
OP0150 ausente (importe)
Los motivos de rechazo son una lista cerrada, la misma en las 39 prácticas del curso. No es burocracia: si cada uno redacta el motivo a su manera, dos informes correctos son incomparables, y el archivo de rechazos deja de poder verificarse.
| Motivo | Capa | Ejemplo del corpus |
|---|---|---|
ausente | 3 | importe vacío, o con - |
no_numerico | 3 | edad = treinta |
fuera_de_rango | 3 | anio = 2199 en un catálogo de libros |
fecha_invalida | 3 | 2026-02-30, o fecha_invalida literal |
dominio_desconocido | 3 | tipo = devolucion cuando solo hay ingreso y gasto |
negativo_incoherente | 3 | Un importe de -310, una cantidad de -5 |
atipico | 3 | Un importe de 5.000.000 entre valores de tres cifras |
duplicado | 4 | Dos filas con el mismo isbn |
huerfano | 4 | Una inscripción a un curso que no existe |
restriccion_violada | 4 | Un NOT NULL o un CHECK que no se cumple |
Los siete primeros se deciden mirando una fila sola. Los tres
últimos son de capa 4: hace falta el conjunto entero, o incluso otra tabla. Por eso
duplicado y huerfano no se pueden detectar en el mismo
recorrido que los demás, y por eso la validación completa es casi siempre dos
pasadas.
len(validos) + len(rechazados) == len(entrada). Si no da, hay una fila
que no fue ni aceptada ni rechazada, y desapareció sin que nadie se entere. Es una
línea de código y atrapa la clase entera de errores.
En este ejemplo: 150 filas, 11 rechazadas, 139 válidas. Acá los motivos contados también dan 11, pero eso es casualidad de este archivo: una fila puede tener varios defectos a la vez, así que el total de motivos y el de filas rechazadas no tienen por qué coincidir. El que hay que contar es el de filas.
Dónde se rompe
Causa: alguna capa descartó en silencio. Casi siempre un
except: pass, o un if que no contempla un token de
ausente.
Arreglo: el invariante de la suma, y contar por motivo.
fecha_invalida como fecha
Causa: se confió en que el patrón de parseo ya valida. El patrón dice «un campo sin espacios», y eso lo cumple cualquier cosa.
Arreglo: validar cada campo aparte, con fullmatch y no con
match - match se conforma con el principio.
Causa: la política frente a los ausentes no está escrita, y cada uno eligió una.
Arreglo: declararla arriba del archivo y publicarla junto al resultado.
Es lo que hace el contrato.json de cada práctica.
Causa: un None de la capa 3 llegó vivo hasta la capa 4.
Arreglo: filtrar antes de agregar. Si un None llega al
cálculo, la separación de capas se rompió en algún punto anterior.
Recetario
| Expresión | Qué hace | Capa |
|---|---|---|
NULOS = {"", "-", "N/A", "NaN", "null"} | Los tokens de ausente del corpus | 3 |
valor.strip() in NULOS | ¿Está ausente? | 3 |
re.fullmatch(patron, valor) | ¿Coincide todo el valor? | 2 y 3 |
valor in DOMINIO | ¿Está en la lista permitida? | 3 |
minimo <= valor <= maximo | ¿Está en rango? | 3 |
datetime.strptime(v, "%Y-%m-%d") | Fecha real, no solo con forma de fecha | 3 |
if clave in vistos: | Duplicado | 4 |
if clave not in tabla_padre: | Huérfano | 4 |
Counter() por motivo | El informe de rechazos | - |
assert len(ok) + len(no) == len(entrada) | Que no se pierda nada | - |