Módulo 2 · Datos
Auditoría de un dataset
En esta páginaEl problema
El problema
Una coordinación escolar quiere estimar qué estudiantes abandonarán un taller sabatino. Recibes una tabla sintética con 405 filas y ocho columnas. Estas cinco filas, con su etiqueta de índice, muestran su forma:
| índice | id_estudiante | edad | escuela | turno | distancia_km | ingreso_familiar | asistencia_pct | abandona |
|---|---|---|---|---|---|---|---|---|
| 0 | E-0001 | 18 | 147 | matutino | 1.6 | 32200 | 90 | 0 |
| 1 | E-0002 | 17 | 116 | vespertino | 1.7 | celda vacía | 82 | 0 |
| 12 | E-0013 | 17 | 143 | vespertino | 4.0 | 9400 | 85 | 0 |
| 37 | E-0038 | 0 | 144 | matutino | 6.1 | 12000 | 80 | 0 |
| 400 | E-0013 | 17 | 143 | vespertino | 4.0 | 9400 | 85 | 0 |
escuela es la clave de la escuela de procedencia. ingreso_familiar está en pesos mensuales. asistencia_pct es el porcentaje de sesiones asistidas. abandona vale 1 si el estudiante dejó el taller.
Antes de entrenar cualquier modelo necesitas una auditoría: una lista verificable de lo que la tabla contiene y de lo que hay que preparar. Cinco filas ya muestran una edad imposible, un faltante y un identificador repetido. Las otras 400 pueden esconder más.
Esquema y tipos
La celda plegada define generar_taller(semilla). Sortea 400 estudiantes con np.random.default_rng(semilla), así que la misma semilla produce la misma tabla, y añade algunos problemas como los de una tabla real.
Mostrar códigoOcultar código
import numpy as np
import pandas as pd
def generar_taller(semilla):
rng = np.random.default_rng(semilla)
n = 400
tabla = pd.DataFrame(
{
"id_estudiante": [f"E-{i:04d}" for i in range(1, n + 1)],
"edad": rng.integers(15, 19, size=n),
"escuela": rng.integers(101, 149, size=n),
"turno": rng.choice(["matutino", "vespertino"], size=n, p=[0.6, 0.4]),
"distancia_km": np.round(rng.gamma(2.0, 3.0, size=n), 1),
# -2 redondea a centenas de pesos
"ingreso_familiar": np.round(
rng.normal(18000, 6000, size=n).clip(4000), -2
),
"asistencia_pct": np.round(rng.normal(88, 6, size=n).clip(50, 100)),
}
)
riesgo = (
-3.2 + 0.15 * tabla["distancia_km"] - 0.1 * (tabla["asistencia_pct"] - 88)
)
# La probabilidad de abandono es la logística del riesgo
tabla["abandona"] = (rng.random(n) < 1 / (1 + np.exp(-riesgo))).astype(int)
# Problemas plantados
tabla.loc[rng.choice(n, size=24, replace=False), "ingreso_familiar"] = np.nan
tabla.loc[[37, 211], "edad"] = [0, 160]
tabla.loc[[58, 144, 305], "asistencia_pct"] = [104.0, 112.0, -5.0]
duplicadas = tabla.iloc[[12, 90, 177, 250, 333]]
return pd.concat([tabla, duplicadas], ignore_index=True)
Con la semilla 0 se obtiene la tabla del problema:
taller = generar_taller(0)
print(taller.shape)
La salida esperada está oculta.
Son las 405 filas y las ocho columnas descritas en el problema. dtypes muestra cómo guarda pandas cada columna. El esquema, en cambio, dice qué significa cada una.
Pregunta. Con la descripción de las columnas, decide qué tipo asignará pandas a cada una. Después señala una columna cuyo tipo no coincida con su significado.
print(taller.dtypes)
La salida esperada está oculta.
Rangos y duplicados
Una regla de rango escribe lo que el dominio permite. En este taller la edad va de 12 a 20 años, la distancia de 0 a 60 km y la asistencia de 0 a 100 %. between marca los valores dentro del intervalo, con extremos incluidos. ~ invierte la máscara. duplicated() marca cada fila que repite por completo una fila anterior; sobre una sola columna, marca los valores ya vistos.
Pregunta. La fila 37 del problema ya viola una regla. ¿Qué columnas esperas con valores fuera de rango? Para los duplicados, la fila 400 repite E-0013. ¿Coincidirán el número de filas repetidas completas y el número de identificadores repetidos?
reglas = {
"edad": (12, 20),
"distancia_km": (0, 60),
"asistencia_pct": (0, 100),
}
for columna, (minimo, maximo) in reglas.items():
fuera = ~taller[columna].between(minimo, maximo)
print(columna, fuera.sum(), taller.loc[fuera, "id_estudiante"].tolist())
print("filas repetidas:", taller.duplicated().sum())
print("ids repetidos:", taller["id_estudiante"].duplicated().sum())
La salida esperada está oculta.
Faltantes y desbalance
Los faltantes se cuentan por columna, como en Tablas y valores faltantes. El objetivo también se audita: su proporción de clases decide cómo se divide la tabla. value_counts() cuenta las filas de cada clase y, con normalize=True, devuelve proporciones.
Pregunta. ¿Qué columnas tendrán faltantes? ¿La clase 1 de abandona será cercana al 50 % o una minoría?
faltantes = taller.isna().sum()
print(faltantes[faltantes > 0])
print(taller["abandona"].value_counts())
print(taller["abandona"].value_counts(normalize=True).round(3))
La salida esperada está oculta.
Cardinalidad y escalas
La cardinalidad de una columna categórica es su número de valores distintos. La codificación one-hot crea una columna nueva por valor. Las columnas numéricas, por su parte, pueden tener escalas muy distintas. nunique() cuenta los valores distintos de cada columna y agg(["min", "max", "std"]) calcula varios resúmenes a la vez, uno por fila.
Pregunta. ¿Cuántas columnas crearía one-hot para id_estudiante, para escuela y para turno? Entre las columnas numéricas, ¿cuál tendrá la desviación estándar más grande?
print(taller[["id_estudiante", "escuela", "turno"]].nunique())
numericas = ["edad", "distancia_km", "ingreso_familiar", "asistencia_pct"]
print(taller[numericas].agg(["min", "max", "std"]).round(1))
La salida esperada está oculta.
Preprocesamiento reproducible sin fuga
Hay dos tipos de corrección. Una regla fija no aprende estadísticas de la tabla: marcar como faltante una edad de 160 depende solo de esa fila, y quitar copias exactas solo compara filas entre sí. Esa regla puede aplicarse antes de dividir. Además, los duplicados deben salir antes de dividir para que una copia no quede en entrenamiento y la otra en prueba. En cambio, imputar con la mediana, escalar con media y desviación o descubrir las categorías de escuela aprende estadísticas de los datos. Esos pasos deben ajustarse solo con entrenamiento.
drop_duplicates() quita las copias exactas y .copy() deja una tabla independiente de taller. where conserva los valores que cumplen la regla y deja NaN en los demás.
Pregunta. Después de quitar las copias y marcar los valores imposibles como faltantes, ¿cuántas filas quedan y cuántos faltantes tendrá cada columna numérica?
datos = taller.drop_duplicates().copy()
for columna, (minimo, maximo) in reglas.items():
datos[columna] = datos[columna].where(datos[columna].between(minimo, maximo))
print(datos.shape)
print(datos[numericas].isna().sum())
La salida esperada está oculta.
Antes de ajustar nada se aparta la prueba. train_test_split reserva el 20 % de las filas con test_size=0.2. stratify=y sortea por separado dentro de cada clase, y random_state=0 fija el sorteo.
from sklearn.model_selection import train_test_split
categoricas = ["escuela", "turno"]
X = datos[numericas + categoricas]
y = datos["abandona"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=0
)
# La media de una columna 0/1 es la proporción de unos
print(round(y_train.mean(), 3), round(y_test.mean(), 3))
La salida esperada está oculta.
La estratificación conservó la proporción de abandono: 14.1 % en entrenamiento y 13.8 % en prueba.
Un ColumnTransformer aplica un preprocesamiento distinto a cada grupo de columnas. Para las numéricas encadena dos pasos en un Pipeline: SimpleImputer(strategy="median") rellena los faltantes con la mediana de cada columna y StandardScaler resta la media y divide entre la desviación. OneHotEncoder(handle_unknown="ignore") codifica con ceros una categoría que no apareció en entrenamiento.
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
numerico = Pipeline(
[("imputar", SimpleImputer(strategy="median")), ("escalar", StandardScaler())]
)
prep = ColumnTransformer(
[
("num", numerico, numericas),
("cat", OneHotEncoder(handle_unknown="ignore"), categoricas),
]
)
Un segundo Pipeline pone prep delante de una LogisticRegression; max_iter=1000 permite al optimizador hasta 1000 iteraciones en lugar de las 100 por omisión. Dentro de él, el imputador, el escalador y el codificador se ajustan al llamar a modelo.fit. named_steps recupera un paso del Pipeline por su nombre.
Pregunta. ¿Cuántas columnas produce prep para la prueba?
from sklearn.linear_model import LogisticRegression
modelo = Pipeline(
[("prep", prep), ("clasificador", LogisticRegression(max_iter=1000))]
)
modelo.fit(X_train, y_train)
print(modelo.named_steps["prep"].transform(X_test).shape)
La salida esperada está oculta.
named_transformers_ recupera del mismo modo un grupo del ColumnTransformer, y statistics_ guarda las medianas que aprendió el imputador.
Pregunta. ¿La mediana que guarda el imputador para ingreso_familiar será la del entrenamiento o la de las 400 filas?
imputador = modelo.named_steps["prep"].named_transformers_["num"]["imputar"]
medianas = pd.DataFrame(
{
"imputador": imputador.statistics_,
"entrenamiento": X_train[numericas].median(),
"todas": X[numericas].median(),
}
)
print(medianas)
La salida esperada está oculta.
La auditoría también debe repetirse igual en otra máquina. Para eso se usan una semilla fija y una ruta relativa, como en Entorno de trabajo y ejecución limpia. equals compara dos tablas valor por valor. Path, de pathlib, une partes de una ruta con /; as_posix() la devuelve como texto e is_absolute() dice si empieza en la raíz del disco.
generar_taller(0).equals(taller)
ruta = Path("datos") / "taller.csv"
ruta.as_posix(), ruta.is_absolute()
Pregunta. ¿generar_taller(1) reproduce taller? ¿Qué escribe ruta.as_posix()?
Ver resultado
La tabla reúne las comprobaciones de reproducibilidad. La columna «comprobación» nombra la expresión o la prueba y «resultado» es lo que devuelve. Por ejemplo, la fila con ruta.as_posix() y datos/taller.csv dice que la ruta se escribe con barras normales.
| comprobación | resultado |
|---|---|
generar_taller(0).equals(taller) | True |
generar_taller(1).equals(taller) | False |
índice de entrenamiento de una segunda partición con random_state=0 igual al de X_train | True |
ruta.as_posix() | datos/taller.csv |
ruta.is_absolute() | False |
random_state=0 repite exactamente las mismas filas de entrenamiento. La ruta es relativa a la carpeta del cuaderno. Si la tabla llegara como archivo, pd.read_csv(ruta) la leería igual en cualquier máquina que conserve la carpeta datos.Práctica
Traza guiada
Sin ejecutar, predice para esta tabla: filas repetidas completas, identificadores repetidos, valores de asistencia_pct fuera de 0 a 100, el conteo de ~between(0, 100) en esa columna, faltantes por columna y cardinalidad de grupo.
mini = pd.DataFrame(
{
"id": ["M-1", "M-2", "M-3", "M-3", "M-4", "M-5"],
"asistencia_pct": [95.0, 101.0, 80.0, 80.0, None, 70.0],
"grupo": ["a", "b", "a", "a", "c", "b"],
"aprobado": [1, 1, 0, 0, 1, 1],
}
)
Comprueba cada predicción con duplicated(), between, isna().sum() y nunique(). Si un conteo no coincide, localiza la fila con su etiqueta de índice antes de corregir la predicción.
Escuela como número
Mueve escuela de categoricas a numericas en el ColumnTransformer y vuelve a ajustar modelo. Antes de ejecutar, predice el número de columnas que produce prep para la prueba. Comprueba la forma y explica qué supone el modelo sobre las escuelas 101 y 148 cuando la clave se escala como una cantidad.
Función de auditoría
Escribe auditar(tabla, objetivo, reglas) que devuelva un diccionario con:
- forma de la tabla
- filas repetidas completas
- conteo fuera de rango por cada regla
- faltantes por columna, solo las que tengan alguno
- proporción de cada clase del objetivo
- cardinalidad de cada columna de tipo
str
Pruébala con generar_taller(1). Las edades, las asistencias imposibles y las copias están plantadas en las mismas posiciones, así que los conteos de filas repetidas y de valores fuera de rango deben coincidir con los de la semilla 0. Añade un assert para cada uno.
Reto opcional
Agrega a taller una fila con id_estudiante igual a "E-0100" y una asistencia distinta de la original. Predice qué devuelven duplicated().sum() y duplicated(subset=["id_estudiante"]).sum(). Explica por qué la auditoría no puede decidir cuál de los dos registros es el correcto sin información externa.
Errores que revisar
- Si ajustas
SimpleImputeroStandardScalercon todas las filas antes detrain_test_split, sus medianas y medias incluyen la prueba. Ponlos dentro delPipeliney ajusta solo conX_train. - Si
escuelaaparece con media y desviación endescribe(), pandas la trata como cantidad. Colócala en la lista de categóricas delColumnTransformer. - Si la forma de la matriz transformada tiene cientos de columnas más de las previstas, revisa si
id_estudianteu otra columna de alta cardinalidad entró al one-hot. - Si una regla de rango cuenta más violaciones de las previstas, revisa los faltantes de esa columna:
betweendevuelveFalseparaNaN, y~lo convierte en «fuera de rango». - Si los conteos de la auditoría cambian entre ejecuciones, revisa la semilla del generador y el
random_statede la partición.
Cierre
Clasifica cada corrección de esta auditoría —quitar copias, marcar valores imposibles, imputar el ingreso, escalar las numéricas y codificar escuela— como aplicable antes de dividir o ajustable solo con entrenamiento, y di qué información usa cada una.
Lista de comprobación
- ¿Qué distingue una regla fija de un paso que aprende estadísticas de la tabla?
- ¿Por qué
duplicated()puede devolver 0 aunque un identificador se repita? - ¿Por qué una clave numérica como
escuelano debe escalarse? - ¿De qué filas salen las medianas guardadas en
imputador.statistics_? - ¿Qué conserva
stratify=ycuando la clase1es el 14 % de las filas?
Documentación oficial
DataFrame.duplicateden pandas: filas repetidas completas y por subconjunto de columnas consubset.- Pipelines y estimadores compuestos:
Pipeline,ColumnTransformery preprocesamiento por grupo de columnas. - Preprocesamiento de datos:
StandardScaler,OneHotEncodery categorías desconocidas. - Errores comunes y prácticas recomendadas: fuga por preprocesamiento ajustado fuera de la partición de entrenamiento.