Proyecto final e IA responsable

Tema 30 · Fase 6, Ver, oír y crear · Toda la ruta

Los 9 bloques de código de la teoría, en el mismo orden. Cópialos y córrelos en tu Python o en Colab; en la ficha Teoría está la explicación de cada uno.

Los datos: un censo de 1994

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.datasets import fetch_openml

# La primera vez se descarga (unos 4 MB); después sale de la caché
adult = fetch_openml("adult", version=2, as_frame=True).frame

# Nombres en español; se dejan fuera fnlwgt (un peso de muestreo
# del censo) y education (repite anios_estudio en palabras)
datos = adult.rename(columns={
    "age": "edad", "workclass": "empleador",
    "education-num": "anios_estudio", "marital-status": "estado_civil",
    "occupation": "ocupacion", "relationship": "parentesco",
    "capital-gain": "ganancia_capital", "capital-loss": "perdida_capital",
    "hours-per-week": "horas", "native-country": "pais", "race": "raza",
}).drop(columns=["fnlwgt", "education"])
sexos = {"Female": "mujer", "Male": "hombre"}
datos["sexo"] = datos["sex"].astype(str).map(sexos)
datos["ingreso_alto"] = (datos["class"] == ">50K").astype(int)
datos = datos.drop(columns=["sex", "class"])

print(datos.shape)
print(f"Ganan más de 50 000 dólares: {datos['ingreso_alto'].mean():.1%}")
# tasa_base: la fracción del grupo que realmente es positiva
print(datos.groupby("sexo")["ingreso_alto"]
      .agg(personas="size", tasa_base="mean").round(3))

Borrar una columna no basta

from sklearn.model_selection import train_test_split

NUMERICAS = ["edad", "anios_estudio", "ganancia_capital",
             "perdida_capital", "horas"]
CATEGORICAS = ["empleador", "estado_civil", "ocupacion", "parentesco",
               "pais"]
X = datos[NUMERICAS + CATEGORICAS]
y = datos["ingreso_alto"]
sexo = datos["sexo"]

# Se estratifica por ingreso y sexo a la vez: las tres partes tienen
# las mismas proporciones de cada combinación
estratos = y.astype(str) + sexo
X_ent, X_resto, y_ent, y_resto, s_ent, s_resto = train_test_split(
    X, y, sexo, test_size=0.4, stratify=estratos, random_state=0)
X_val, X_pru, y_val, y_pru, s_val, s_pru = train_test_split(
    X_resto, y_resto, s_resto, test_size=0.5,
    stratify=estratos[X_resto.index], random_state=0)
print(f"Entrenamiento: {len(X_ent)}   validación: {len(X_val)}"
      f"   prueba: {len(X_pru)}")

# ¿Quitar la columna sexo basta para que el modelo no la vea?
print(pd.crosstab(datos["parentesco"], datos["sexo"]))

Primero, ganarle a la línea base

from sklearn.compose import ColumnTransformer
from sklearn.dummy import DummyClassifier
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, roc_auc_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

def crear_pipeline(modelo, numericas=NUMERICAS, categoricas=CATEGORICAS):
    """Prepara las columnas de la tabla y termina en el modelo dado."""
    prep_categoricas = Pipeline([
        ("imputar", SimpleImputer(strategy="most_frequent")),
        ("onehot", OneHotEncoder(handle_unknown="ignore",
                                 sparse_output=False))])
    preparar = ColumnTransformer([
        ("num", StandardScaler(), numericas),
        ("cat", prep_categoricas, categoricas)])
    return Pipeline([("preparar", preparar), ("modelo", modelo)])

candidatos = {
    "línea base": DummyClassifier(strategy="most_frequent"),
    "logística": LogisticRegression(max_iter=1000),
    "boosting": HistGradientBoostingClassifier(random_state=0),
}
exactitudes = {}
for nombre, estimador in candidatos.items():
    pipe = crear_pipeline(estimador).fit(X_ent, y_ent)
    prob = pipe.predict_proba(X_val)[:, 1]
    exactitudes[nombre] = accuracy_score(y_val, prob >= 0.5)
    print(f"{nombre:10s}  exactitud = {exactitudes[nombre]:.3f}"
          f"   AUC = {roc_auc_score(y_val, prob):.3f}")
modelo = pipe  # el último: el boosting

nombres = list(exactitudes)
colores = ["C1" if n == "boosting" else "C0" for n in nombres]
plt.barh(nombres, list(exactitudes.values()), color=colores)
for i, v in enumerate(exactitudes.values()):
    plt.text(v - 0.01, i, f"{v:.3f}", ha="right", va="center",
             color="white")
plt.xlim(0, 1)
plt.xlabel("Exactitud en validación")
plt.title("Primero, ganarle a la línea base")
plt.show()

Auditar por subgrupos

prob_pru = modelo.predict_proba(X_pru)[:, 1]
pred_pru = (prob_pru >= 0.5).astype(int)
print(f"Exactitud en prueba: {accuracy_score(y_pru, pred_pru):.3f}")

def metricas(tabla):
    """Métricas de un grupo. tabla tiene las columnas y y pred."""
    real, pred = tabla["y"], tabla["pred"]
    return pd.Series({
        "n": len(tabla),
        "tasa_base": real.mean(),
        "aprobacion": pred.mean(),
        "tvp": pred[real == 1].mean(),
        "tfp": pred[real == 0].mean(),
        "precision": real[pred == 1].mean(),
        "exactitud": (real == pred).mean(),
    })

auditoria = pd.DataFrame({"y": y_pru.to_numpy(), "pred": pred_pru,
                          "sexo": s_pru.to_numpy()})
por_sexo = auditoria.groupby("sexo")[["y", "pred"]].apply(metricas)
por_sexo["n"] = por_sexo["n"].astype(int)
print(por_sexo.round(3).to_string())

¿La diferencia es real? Intervalos de bootstrap

rng = np.random.default_rng(0)

def intervalos(tabla, repeticiones=1000):
    """Intervalo del 95 % de cada métrica, por bootstrap."""
    muestras = []
    for _ in range(repeticiones):
        filas = rng.integers(0, len(tabla), size=len(tabla))
        muestras.append(metricas(tabla.iloc[filas]))
    muestras = pd.DataFrame(muestras)
    return muestras.quantile([0.025, 0.975])

MOSTRAR = ["aprobacion", "tvp", "exactitud"]
grupos = {g: t for g, t in auditoria.groupby("sexo")}
for g, tabla in grupos.items():
    iv = intervalos(tabla)
    texto = "   ".join(f"{m}: {iv.loc[0.025, m]:.3f}–"
                       f"{iv.loc[0.975, m]:.3f}" for m in MOSTRAR)
    print(f"{g:7s} {texto}")
    grupos[g] = iv

ancho = 0.38
posiciones = np.arange(len(MOSTRAR))
estilos = [("hombre", "hombres", "C2", ""),
           ("mujer", "mujeres", "C3", "//")]
for k, (g, rotulo, color, rayas) in enumerate(estilos):
    valores = por_sexo.loc[g, MOSTRAR].to_numpy(dtype=float)
    iv = grupos[g][MOSTRAR].to_numpy()
    errores = [valores - iv[0], iv[1] - valores]
    plt.bar(posiciones + (k - 0.5) * ancho, valores, ancho, yerr=errores,
            capsize=4, color=color, edgecolor="C2", hatch=rayas,
            ecolor="C0", label=rotulo)
plt.xticks(posiciones, ["Aprobación", "TVP", "Exactitud"])
plt.ylim(0, 1)
plt.ylabel("Tasa en el conjunto de prueba")
plt.title("Métricas por grupo")
plt.legend(loc="upper left")
plt.show()

Mirar los errores

prueba = X_pru.assign(sexo=s_pru, real=y_pru, prob=prob_pru.round(2))
errores_fn = prueba[(prueba["real"] == 1) & (prueba["prob"] < 0.5)]
errores_fp = prueba[(prueba["real"] == 0) & (prueba["prob"] >= 0.5)]
print("Falsos negativos por grupo:",
      errores_fn["sexo"].value_counts().to_dict())
print("Falsos positivos por grupo:",
      errores_fp["sexo"].value_counts().to_dict())

columnas = ["sexo", "edad", "anios_estudio", "estado_civil", "horas",
            "prob"]
# Tres falsos negativos al azar de cada grupo
muestra = errores_fn.groupby("sexo").sample(3, random_state=0)
print(muestra[columnas].to_string())

¿En qué se apoya el modelo?

from sklearn.inspection import permutation_importance

imp = permutation_importance(modelo, X_pru, y_pru, n_repeats=5,
                             random_state=0)
orden = np.argsort(imp.importances_mean)
for j in orden[::-1][:5]:
    print(f"{X.columns[j]:17s} {imp.importances_mean[j]:+.3f}")

colores = ["C1" if j == orden[-1] else "C0" for j in orden]
plt.barh(X.columns[orden], imp.importances_mean[orden], color=colores)
plt.xlabel("Exactitud que se pierde")
plt.title("Importancia por permutación")
plt.show()

Métricas de equidad

prob_val = modelo.predict_proba(X_val)[:, 1]
UMBRALES = np.round(np.arange(0.05, 0.96, 0.01), 2)

def tasas(real, prob, umbral):
    """Tasas de aprobación, de verdaderos positivos (TVP) y de falsos
    positivos (TFP)."""
    pred = prob >= umbral
    return pred.mean(), pred[real == 1].mean(), pred[real == 0].mean()

def umbral_parecido(real, prob, objetivo, cual):
    """El umbral cuya tasa (0: aprobación, 1: TVP) queda más cerca
    del objetivo."""
    distancias = [abs(tasas(real, prob, u)[cual] - objetivo)
                  for u in UMBRALES]
    return UMBRALES[int(np.argmin(distancias))]

# En validación: los hombres quedan con 0,5 y se busca el de las mujeres
h, m = (s_val == "hombre").to_numpy(), (s_val == "mujer").to_numpy()
yv = y_val.to_numpy()
meta = tasas(yv[h], prob_val[h], 0.5)
politicas = {
    "un solo umbral": 0.5,
    "igualar aprobación": umbral_parecido(yv[m], prob_val[m], meta[0], 0),
    "igualar oportunidades": umbral_parecido(yv[m], prob_val[m],
                                             meta[1], 1),
}

# En prueba: cada política se mide una sola vez
hp, mp = (s_pru == "hombre").to_numpy(), (s_pru == "mujer").to_numpy()
yp = y_pru.to_numpy()
print("política               umbral M  aprob H/M  TVP H/M    TFP H/M"
      "    exactitud")
for nombre, u_m in politicas.items():
    umbral = np.where(mp, u_m, 0.5)
    pred = prob_pru >= umbral
    ah, th, fh = tasas(yp[hp], prob_pru[hp], 0.5)
    am, tm, fm = tasas(yp[mp], prob_pru[mp], u_m)
    print(f"{nombre:22s} {u_m:.2f}      {ah:.2f}/{am:.2f}  "
          f"{th:.2f}/{tm:.2f}  {fh:.2f}/{fm:.2f}  "
          f"{(pred == yp).mean():.3f}")
# Todas las parejas de umbrales (hombres, mujeres), en validación
rejilla = np.round(np.arange(0.05, 0.96, 0.01), 2)
puntos = []
for u_h in rejilla:
    for u_m in rejilla:
        pred = prob_val >= np.where(m, u_m, u_h)
        dif_aprob = abs(pred[h].mean() - pred[m].mean())
        dif_tvp = abs(pred[h & (yv == 1)].mean()
                      - pred[m & (yv == 1)].mean())
        puntos.append((dif_aprob, dif_tvp, (pred == yv).mean()))
puntos = np.array(puntos)

def frontera(diferencias, exactitud):
    """Para cada diferencia, la mejor exactitud que no la supera."""
    orden = np.argsort(diferencias)
    return diferencias[orden], np.maximum.accumulate(exactitud[orden])

for col, nombre in [(0, "aprobación"), (1, "TVP")]:
    for tope in [0.10, 0.05, 0.02]:
        ok = puntos[:, col] <= tope
        print(f"Diferencia de {nombre} <= {tope:.2f}: mejor exactitud "
              f"{puntos[ok, 2].max():.3f}")

x_a, y_a = frontera(puntos[:, 0], puntos[:, 2])
x_t, y_t = frontera(puntos[:, 1], puntos[:, 2])
plt.step(x_a, y_a, where="post", color="C0", label="aprobación")
plt.step(x_t, y_t, where="post", color="C2", linestyle="--",
         label="oportunidades (TVP)")
ok = puntos[:, 0] <= 0.02
plt.scatter([0.02], [puntos[ok, 2].max()], s=80, color="C1", zorder=3)
plt.xlim(0, 0.3)
plt.ylim(0.84, 0.88)
plt.xlabel("Diferencia máxima entre grupos")
plt.ylabel("Mejor exactitud (validación)")
plt.title("Cuánto cuesta la equidad")
plt.legend(loc="lower right")
plt.show()