Saltar al contenido

Indice / Pensar en tablas / 2.3

Vocabulario de calidad de datos

Nombrar el defecto es la mitad de arreglarlo. Esta página fija los siete nombres que se usan en el resto del curso, y las tres distinciones que hacen que dos personas puedan discutir sobre los mismos datos.

Para qué sirve

«Los datos están sucios» no permite hacer nada. «La columna edad tiene 3 valores de tipo incorrecto, 4 ausentes y 18 fuera de rango» sí: cada uno de esos tres tiene una política distinta y un número que se puede verificar.

La lección 1.5 usó estos nombres para clasificar rechazos. Acá se definen, con un caso real del corpus para cada uno, y se cierran las tres confusiones que aparecen todos los años.

ELI5Explicado facil

Es lo que hace un mecánico antes de tocar el auto: «hace un ruido raro» no es un diagnóstico. «La correa de distribución está floja» sí, y por dos motivos - se puede verificar, y otro mecánico entiende exactamente lo mismo.

Confusión 1: ausente, cero, vacío y no aplica

EjemploAusente no es cero, y cero no es vacio
import csv

with open("academico_cursos.csv", encoding="utf-8", newline="") as f:
    cursos = list(csv.DictReader(f))

print("academico_cursos.csv, columna creditos:")
for curso in cursos:
    print(f"   {curso['codigo']:<7} {curso['nombre']:<25} "
          f"creditos = {curso['creditos']!r}")

print()
print("Dos filas parecen el mismo problema y no lo son:")
print("   SO210 dice 0      -> el dato ESTA, y dice cero")
print("   ET100 dice ''     -> el dato NO esta, y no sabemos cuanto vale")

numeros = [int(c["creditos"]) for c in cursos if c["creditos"].strip().isdigit()]
print()
print("Y la diferencia se ve en cuanto se calcula algo:")
print("   suma                    :", sum(numeros))
print("   promedio sobre 7 cursos :", round(sum(numeros) / len(numeros), 2))
print("   promedio sobre 8 cursos :", round(sum(numeros) / len(cursos), 2))
print("   El segundo trata el vacio como un cero. Es una decision, y")
print("   cambia el resultado en", round(sum(numeros) / len(numeros)
                                         - sum(numeros) / len(cursos), 2))

print()
print("Cuatro cosas que se confunden todo el tiempo:")
print()
print(f"   {'concepto':<12} {'se escribe':<14} significa")
print("   " + "-" * 62)
print(f"   {'ausente':<12} {'campo vacio':<14} no lo sabemos")
print(f"   {'cero':<12} {'0':<14} lo sabemos, y es cero")
print(f"   {'vacio':<12} {chr(39)+chr(39):<14} lo sabemos, y es la cadena vacia")
print(f"   {'no aplica':<12} {'?':<14} la pregunta no tiene sentido para esta fila")

print()
print("La cuarta es la mas traicionera. 'Fecha de baja' de un empleado")
print("activo no esta ausente: NO APLICA. Rellenarla con la fecha de hoy,")
print("o con el promedio, produce un dato que nunca existio.")

print()
print("En un CSV los cuatro casos se escriben casi igual, asi que la unica")
print("forma de distinguirlos es que alguien lo haya documentado. Cuando no")
print("esta documentado, la decision la toma quien procesa: vos.")
academico_cursos.csv, columna creditos:
   BD101   Bases de datos            creditos = '6'
   PR201   Programación II           creditos = '8'
   AL110   Álgebra                   creditos = '6'
   ES220   Estadística aplicada      creditos = '5'
   RE301   Redes de computadoras     creditos = '7'
   IA401   Inteligencia artificial   creditos = '15'
   SO210   Sistemas operativos       creditos = '0'
   ET100   Ética profesional         creditos = ''

Dos filas parecen el mismo problema y no lo son:
   SO210 dice 0      -> el dato ESTA, y dice cero
   ET100 dice ''     -> el dato NO esta, y no sabemos cuanto vale

Y la diferencia se ve en cuanto se calcula algo:
   suma                    : 47
   promedio sobre 7 cursos : 6.71
   promedio sobre 8 cursos : 5.88
   El segundo trata el vacio como un cero. Es una decision, y
   cambia el resultado en 0.84

Cuatro cosas que se confunden todo el tiempo:

   concepto     se escribe     significa
   --------------------------------------------------------------
   ausente      campo vacio    no lo sabemos
   cero         0              lo sabemos, y es cero
   vacio        ''             lo sabemos, y es la cadena vacia
   no aplica    ?              la pregunta no tiene sentido para esta fila

La cuarta es la mas traicionera. 'Fecha de baja' de un empleado
activo no esta ausente: NO APLICA. Rellenarla con la fecha de hoy,
o con el promedio, produce un dato que nunca existio.

En un CSV los cuatro casos se escriben casi igual, asi que la unica
forma de distinguirlos es que alguien lo haya documentado. Cuando no
esta documentado, la decision la toma quien procesa: vos.
Cuatro cosas distintas que en un CSV se escriben casi igual
ConceptoSe escribeSignificaEjemplo
AusenteCampo vacío, -, N/A…No lo sabemosET100 no declaró créditos
Cero0Lo sabemos, y es ceroSO210 vale 0 créditos
Vacío""Lo sabemos, y es la cadena vacíaUn campo «observaciones» sin texto
No aplicaSuele escribirse igual que «ausente»La pregunta no tiene sentido para esta fila«Fecha de baja» de un empleado activo
«No aplica» es el que más daño hace

Un empleado activo no tiene la fecha de baja ausente: no la tiene, punto. Rellenarla con el promedio, con la fecha de hoy o con cualquier cosa produce un dato que nunca existió, y que después nadie va a poder distinguir de uno real.

Con los créditos: tomar el vacío de ET100 como 0 baja el promedio de 6,71 a 5,88. Es una decisión legítima si está escrita, y una mentira si no.

Las siete clases

EjemploLas siete clases de defecto, buscadas en el corpus
import collections
import csv

NULOS = {"", "-", "N/A", "NaN", "null"}


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


def numero(texto):
    try:
        return float(texto.strip())
    except ValueError:
        return None


hallazgos = []

# 1. AUSENTE: no hay dato.
clientes = leer("clientes_crudos.csv")
ausentes = sum(1 for x in clientes for v in x.values() if v.strip() in NULOS)
hallazgos.append(("ausente", "clientes_crudos", "celdas sin dato", ausentes))

# 2. TIPO INCORRECTO: el valor no es de la clase que la columna promete.
mal_tipo = [x["edad"] for x in clientes
            if x["edad"].strip() not in NULOS and numero(x["edad"]) is None]
hallazgos.append(("tipo_incorrecto", "clientes_crudos",
                  f"edad = {set(mal_tipo)}", len(mal_tipo)))

# 3. FUERA DE RANGO: es del tipo correcto y no puede valer eso.
libros = leer("libros_catalogo.csv")
anios = [x for x in libros
         if numero(x["anio"]) is not None and not 1400 <= numero(x["anio"]) <= 2026]
hallazgos.append(("fuera_de_rango", "libros_catalogo",
                  f"anio = {[x['anio'] for x in anios]}", len(anios)))

# 4. ATIPICO: esta en rango y se sale del resto por varios ordenes.
tx = leer("transacciones_categorias.csv")
importes = sorted(v for v in (numero(x["importe"]) for x in tx) if v is not None)
mediana = importes[len(importes) // 2]
extremos = [v for v in importes if v > mediana * 100]
hallazgos.append(("atipico", "transacciones_categorias",
                  f"mediana {mediana}, maximo {importes[-1]:.0f}", len(extremos)))

# 5. DOMINIO DESCONOCIDO: un valor que no esta en la lista permitida.
TIPOS = {"ingreso", "gasto"}
fuera = collections.Counter(x["tipo"] for x in tx if x["tipo"] not in TIPOS)
hallazgos.append(("dominio_desconocido", "transacciones_categorias",
                  f"tipo = {dict(fuera)}", sum(fuera.values())))

# 6. DUPLICADO: la clave se repite.
estudiantes = leer("academico_estudiantes.csv")
repetidos = [k for k, v in
             collections.Counter(x["legajo"] for x in estudiantes).items() if v > 1]
hallazgos.append(("duplicado", "academico_estudiantes",
                  f"legajo = {repetidos}", len(repetidos)))

# 7. HUERFANO: la referencia apunta a algo que no existe.
cursos = leer("academico_cursos.csv")
inscripciones = leer("academico_inscripciones.csv")
ids = {x["id"] for x in cursos}
huerfanas = [x for x in inscripciones if x["curso_id"] not in ids]
hallazgos.append(("huerfano", "academico_inscripciones",
                  f"curso_id = {[x['curso_id'] for x in huerfanas]}",
                  len(huerfanas)))

print(f"{'clase':<20} {'dataset':<26} {'casos':>6}  evidencia")
print("-" * 86)
for clase, dataset, evidencia, cuantos in hallazgos:
    print(f"{clase:<20} {dataset:<26} {cuantos:>6}  {evidencia}")

print()
print("Las siete se detectan de forma distinta:")
print("  ausente y tipo_incorrecto  -> mirando UNA celda")
print("  fuera_de_rango y dominio   -> UNA celda contra una regla externa")
print("  atipico                    -> una celda contra el RESTO de la columna")
print("  duplicado                  -> una fila contra las otras filas")
print("  huerfano                   -> una fila contra OTRA TABLA")
print()
print("Por eso las dos ultimas necesitan dos pasadas, o dos archivos.")
clase                dataset                     casos  evidencia
--------------------------------------------------------------------------------------
ausente              clientes_crudos                 9  celdas sin dato
tipo_incorrecto      clientes_crudos                 3  edad = {'treinta'}
fuera_de_rango       libros_catalogo                 1  anio = ['2199']
atipico              transacciones_categorias        1  mediana 1936.98, maximo 5000000
dominio_desconocido  transacciones_categorias        2  tipo = {'INGRESO': 1, 'devolucion': 1}
duplicado            academico_estudiantes           1  legajo = ['E007']
huerfano             academico_inscripciones         1  curso_id = ['77']

Las siete se detectan de forma distinta:
  ausente y tipo_incorrecto  -> mirando UNA celda
  fuera_de_rango y dominio   -> UNA celda contra una regla externa
  atipico                    -> una celda contra el RESTO de la columna
  duplicado                  -> una fila contra las otras filas
  huerfano                   -> una fila contra OTRA TABLA

Por eso las dos ultimas necesitan dos pasadas, o dos archivos.
Cada clase, con un caso real del corpus y cómo se detecta
ClaseQué esCaso realSe detecta mirando…
ausenteNo hay dato9 celdas en clientes_crudosuna celda
tipo_incorrectoNo es de la clase que la columna prometeedad = "treinta", 3 vecesuna celda
fuera_de_rangoEs del tipo correcto y no puede valer esoanio = 2199 en un catálogo de librosuna celda contra una regla externa
dominio_desconocidoNo está en la lista de valores permitidostipo = "devolucion" y "INGRESO"una celda contra una lista
atipicoEstá en rango y se sale del restoUn importe de 5.000.000 con mediana 1.937una celda contra el resto de la columna
duplicadoLa clave se repitelegajo E007, dos personasuna fila contra las otras filas
huerfanoLa referencia apunta a algo que no existeInscripción al curso_id 77una fila contra otra tabla

La última columna es la que organiza el trabajo. Las cuatro primeras se resuelven en una sola pasada, fila por fila. atipico necesita haber visto la columna entera; duplicado, todas las filas; huerfano, otro archivo. Por eso una validación completa son siempre dos pasadas, y por eso las tres últimas son «capa 4» en la lección 1.5.

Confusión 2: atípico no es error

EjemploAtipico no es error, y sintactico no es semantico
import csv
import re
from datetime import datetime


def numero(texto):
    try:
        return float(texto.strip())
    except ValueError:
        return None


with open("transacciones_categorias.csv", encoding="utf-8", newline="") as f:
    tx = list(csv.DictReader(f))

importes = sorted(v for v in (numero(x["importe"]) for x in tx) if v is not None)
n = len(importes)
mediana = importes[n // 2]

print("columna importe:", n, "valores")
print("   minimo :", importes[0])
print("   mediana:", mediana)
print("   maximo :", importes[-1])
print("   los cinco mas grandes:", importes[-5:])

print()
print("Dos valores extremos, y NO son la misma clase de problema:")
print()
print("   -310.0     -> IMPOSIBLE. Un importe negativo no existe en este")
print("                 dominio: es un error, y se rechaza.")
print("   5000000.0  -> POSIBLE. Una venta grande puede existir. Es un")
print("                 ATIPICO, y decidir que hacer con el es otra cosa.")

print()
sin_extremo = importes[:-1]
print("Lo que cambia el atipico si se lo deja:")
print("   promedio con el :", round(sum(importes) / len(importes), 2))
print("   promedio sin el :", round(sum(sin_extremo) / len(sin_extremo), 2))
print("   mediana         :", mediana, "(no se mueve: por eso se usa)")

print()
print("Con un atipico hay tres politicas y ninguna es obvia:")
print("   descartarlo -> si es un error de carga, corresponde")
print("   recortarlo  -> llevarlo al techo del rango razonable (clip)")
print("   conservarlo -> si es real, sacarlo es falsear el resultado")
print("Y elegir la mediana en vez del promedio suele resolver el problema")
print("sin tocar un solo dato.")

print()
print("=" * 66)
print("Y la otra distincion, la que mas cuesta:")
print()
print("   SINTACTICO: la forma. Se detecta sola, con un parser o una regex.")
print("     '2026-02-30'  tiene forma de fecha ISO y matchea el patron")
print()
print("   SEMANTICO: el significado. No se detecta solo NUNCA.")
print("     '2026-02-30'  no existe: febrero no tiene 30 dias")

PATRON = re.compile(r"\d{4}-\d{2}-\d{2}")
for texto in ("2026-03-15", "2026-02-30", "2026-13-01", "15/03/2026"):
    forma = "si" if PATRON.fullmatch(texto) else "no"
    try:
        datetime.strptime(texto, "%Y-%m-%d")
        real = "si"
    except ValueError:
        real = "no"
    print(f"   {texto:<12} forma valida: {forma:<3} fecha real: {real}")

print()
print("Las dos del medio pasan la forma y no existen. La regex no puede")
print("saberlo: '30' es un numero de dos digitos, y con eso se conforma.")
columna importe: 144 valores
   minimo : -310.0
   mediana: 1936.98
   maximo : 5000000.0
   los cinco mas grandes: [4079.25, 4099.04, 4145.66, 4162.86, 5000000.0]

Dos valores extremos, y NO son la misma clase de problema:

   -310.0     -> IMPOSIBLE. Un importe negativo no existe en este
                 dominio: es un error, y se rechaza.
   5000000.0  -> POSIBLE. Una venta grande puede existir. Es un
                 ATIPICO, y decidir que hacer con el es otra cosa.

Lo que cambia el atipico si se lo deja:
   promedio con el : 36710.6
   promedio sin el : 2002.28
   mediana         : 1936.98 (no se mueve: por eso se usa)

Con un atipico hay tres politicas y ninguna es obvia:
   descartarlo -> si es un error de carga, corresponde
   recortarlo  -> llevarlo al techo del rango razonable (clip)
   conservarlo -> si es real, sacarlo es falsear el resultado
Y elegir la mediana en vez del promedio suele resolver el problema
sin tocar un solo dato.

==================================================================
Y la otra distincion, la que mas cuesta:

   SINTACTICO: la forma. Se detecta sola, con un parser o una regex.
     '2026-02-30'  tiene forma de fecha ISO y matchea el patron

   SEMANTICO: el significado. No se detecta solo NUNCA.
     '2026-02-30'  no existe: febrero no tiene 30 dias
   2026-03-15   forma valida: si  fecha real: si
   2026-02-30   forma valida: si  fecha real: no
   2026-13-01   forma valida: si  fecha real: no
   15/03/2026   forma valida: no  fecha real: no

Las dos del medio pasan la forma y no existen. La regex no puede
saberlo: '30' es un numero de dos digitos, y con eso se conforma.
ValorQué esQué se hace
-310.0Imposible. Un importe negativo no existe en este dominioSe rechaza: es un error
5000000.0Posible. Una venta grande puede existirHay que decidir, y es otra cosa

Los dos son extremos y solo uno es un error. Con el atípico hay tres políticas - descartar si fue un error de carga, recortar al techo razonable (clip), o conservar si es real - y ninguna es obvia. Lo que sí es seguro es el efecto de ignorarlo: el promedio de esa columna pasa de 2.002 a 36.711. La mediana, mientras tanto, no se mueve: 1.937.

La salida barata

Elegir la mediana en vez del promedio resuelve el problema del atípico sin tocar un solo dato. No siempre se puede - a veces la pregunta es literalmente por el total - pero cuando se puede, es la respuesta más honesta: no borra nada y no inventa nada.

Confusión 3: sintáctico no es semántico

Texto¿Tiene forma de fecha ISO?¿Es una fecha real?
2026-03-15SíSí
2026-02-30SíNo - febrero no tiene 30 días
2026-13-01SíNo - no hay mes 13
15/03/2026NoNo, con ese formato

Un validador sintáctico revisa la forma, y lo hace solo: una regex, un parser de CSV, json.load. Un validador semántico revisa el significado, y no se detecta solo nunca: hay que escribirlo.

Las dos fechas del medio pasan el patrón \d{4}-\d{2}-\d{2} sin problema, porque 30 es un número de dos dígitos y con eso la regex se conforma. Es el mismo caso del fecha_invalida 10.0.0.6 GET / 200 45 de la lección 1.5, y en la 5.3 va a reaparecer como el NaT silencioso de to_datetime(errors="coerce").

Recetario

Detectar cada clase, en Python puro
ClaseExpresión
ausentev.strip() in NULOS
tipo_incorrectotry: float(v) / except ValueError
fuera_de_rangonot minimo <= float(v) <= maximo
dominio_desconocidov not in DOMINIO
atipicoabs(v - mediana) > k * rango (el IQR de la lección 3.7)
duplicadoCounter(f["clave"] for f in filas)
huerfanof["ref"] not in {p["id"] for p in padres}
fecha realdatetime.strptime(v, "%Y-%m-%d"), no una regex
mediana sin atípicosorted(valores)[len(valores) // 2]

Ponete a prueba

1. Un curso declara creditos = 0 y otro tiene la celda vacía. ¿Es lo mismo?Nivel 1 · Recordar
2. Una inscripción apunta a curso_id = 77 y no existe ningún curso 77. ¿Cómo se llama ese defecto?Nivel 1 · Recordar
3. Una columna de importes tiene un -310 y un 5000000. ¿Cuál es un error y cuál un atípico?Nivel 2 · Predecir la salida
4. El texto 2026-02-30, ¿pasa un validador con la expresión regular \d{4}-\d{2}-\d{2}?Nivel 2 · Predecir la salida
5. De las siete clases del vocabulario, ¿cuáles no se pueden decidir mirando una fila sola?Nivel 3 · Elegir entre dos casi correctas
6. El promedio de una columna es 36.711 y su mediana 1.937. ¿Qué conviene hacer si te piden «el importe típico»?Nivel 3 · Elegir entre dos casi correctas