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
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.
| Concepto | Se escribe | Significa | Ejemplo |
|---|---|---|---|
| Ausente | Campo vacío, -, N/A… | No lo sabemos | ET100 no declaró créditos |
| Cero | 0 | Lo sabemos, y es cero | SO210 vale 0 créditos |
| Vacío | "" | Lo sabemos, y es la cadena vacía | Un campo «observaciones» sin texto |
| No aplica | Suele escribirse igual que «ausente» | La pregunta no tiene sentido para esta fila | «Fecha de baja» de un empleado activo |
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
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.
| Clase | Qué es | Caso real | Se detecta mirando… |
|---|---|---|---|
ausente | No hay dato | 9 celdas en clientes_crudos | una celda |
tipo_incorrecto | No es de la clase que la columna promete | edad = "treinta", 3 veces | una celda |
fuera_de_rango | Es del tipo correcto y no puede valer eso | anio = 2199 en un catálogo de libros | una celda contra una regla externa |
dominio_desconocido | No está en la lista de valores permitidos | tipo = "devolucion" y "INGRESO" | una celda contra una lista |
atipico | Está en rango y se sale del resto | Un importe de 5.000.000 con mediana 1.937 | una celda contra el resto de la columna |
duplicado | La clave se repite | legajo E007, dos personas | una fila contra las otras filas |
huerfano | La referencia apunta a algo que no existe | Inscripción al curso_id 77 | una 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
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.
| Valor | Qué es | Qué se hace |
|---|---|---|
-310.0 | Imposible. Un importe negativo no existe en este dominio | Se rechaza: es un error |
5000000.0 | Posible. Una venta grande puede existir | Hay 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.
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-15 | Sí | Sí |
2026-02-30 | Sí | No - febrero no tiene 30 días |
2026-13-01 | Sí | No - no hay mes 13 |
15/03/2026 | No | No, 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
| Clase | Expresión |
|---|---|
ausente | v.strip() in NULOS |
tipo_incorrecto | try: float(v) / except ValueError |
fuera_de_rango | not minimo <= float(v) <= maximo |
dominio_desconocido | v not in DOMINIO |
atipico | abs(v - mediana) > k * rango (el IQR de la lección 3.7) |
duplicado | Counter(f["clave"] for f in filas) |
huerfano | f["ref"] not in {p["id"] for p in padres} |
| fecha real | datetime.strptime(v, "%Y-%m-%d"), no una regex |
| mediana sin atípico | sorted(valores)[len(valores) // 2] |