Saltar al contenido

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

Dónde puede fallar un dato, en orden
CapaLa preguntaCómo se manifiestaHerramienta
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.

EjemploLas cuatro capas, sobre una sola linea del log
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.
El patrón describe la forma, no el significado

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

EjemploLos cinco tokens de ausente, contados en los 36 datasets
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.

Guardá esta lista: 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

EjemploDescartar, imputar, informar: la misma columna, tres totales
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íticaQué haceQué 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

EjemploEl vocabulario cerrado de rechazos, aplicado a un archivo
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.

Los diez motivos, y en qué capa se detecta cada uno
MotivoCapaEjemplo del corpus
ausente3importe vacío, o con -
no_numerico3edad = treinta
fuera_de_rango3anio = 2199 en un catálogo de libros
fecha_invalida32026-02-30, o fecha_invalida literal
dominio_desconocido3tipo = devolucion cuando solo hay ingreso y gasto
negativo_incoherente3Un importe de -310, una cantidad de -5
atipico3Un importe de 5.000.000 entre valores de tres cifras
duplicado4Dos filas con el mismo isbn
huerfano4Una inscripción a un curso que no existe
restriccion_violada4Un 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.

El invariante que cierra todo

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

(sin error) el informe sale con menos filas de las esperadas

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.

(sin error) la validación acepta 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.

(sin error) el promedio cambia según quién corrió el script

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.

TypeError: unsupported operand type(s) for +: 'int' and 'NoneType'

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

Validación
ExpresiónQué haceCapa
NULOS = {"", "-", "N/A", "NaN", "null"}Los tokens de ausente del corpus3
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 fecha3
if clave in vistos:Duplicado4
if clave not in tabla_padre:Huérfano4
Counter() por motivoEl informe de rechazos-
assert len(ok) + len(no) == len(entrada)Que no se pierda nada-

Ponete a prueba

1. ¿En qué capa se detecta que una línea del log tiene 4 campos en vez de 6?Nivel 1 · Recordar
2. ¿Cuál de estos motivos de rechazo no se puede decidir mirando una sola fila?Nivel 1 · Recordar
3. La línea fecha_invalida 10.0.0.6 GET / 200 45 pasa el patrón de parseo. ¿Por qué?Nivel 2 · Predecir la salida
4. En los 28 CSV del corpus hay 85 celdas ausentes. Un programa que solo trata "" como ausente, ¿cuántas deja pasar?Nivel 2 · Predecir la salida
5. Hay 6 importes ausentes sobre 150 filas, la media de la columna es 36.710 y la mediana 1.916. Te piden el total facturado. ¿Qué hacés?Nivel 3 · Elegir entre dos casi correctas
6. ¿Para qué sirve que el vocabulario de motivos de rechazo sea una lista cerrada de diez?Nivel 3 · Elegir entre dos casi correctas
7. Un compañero valida el archivo de transacciones y le da 139 filas válidas y 11 rechazos. Vos corrés tu propio script sobre el mismo archivo y te da 144 válidas y 6 rechazos, sin ningún error. ¿Cuál es la explicación más probable?Nivel 4 · Encontrar el defecto

Prácticas de esta subunidad