13  Interpretabilidad y transparencia en IA

mlops
interpretabilidad
gobernanza
Autor/a

Wilder Ramírez Delgado

Fecha de publicación

12 de septiembre de 2026

Serie MLOps y Producción · de la ingeniería de datos al monitoreo.

Módulo V — Monitoreo, interpretabilidad y comunicación

La sesión 12 dice que el modelo se degradó. Un regulador pregunta por qué se le negó el crédito a esta persona. “El F1 era 0.71” no es una respuesta.

Este artículo abre la decisión: importancia global, contribución local (la idea de SHAP) y el riesgo de que el atajo estadístico reproduzca un sesgo. No instalamos shap. Para un lineal, Shapley tiene forma cerrada: \(\varphi_i = \beta_i(x_i-\mathbb{E}[x_i])\). Esa fórmula es SHAP en este laboratorio.

Objetivos

  1. Distinguir explicación global y local, y no confundirlas con causalidad.
  2. Leer importancias de un bosque y contrastarlas con permutación.
  3. Calcular contribuciones aditivas en un lineal y dibujar un caso.
  4. Medir un disparidad simple entre grupos y documentar gobernanza.

Qué no cubre

  • El dashboard de decisión (sesión 14).
  • Buscar el mejor bosque (sesión 6).

Ruta

  1. Transparencia como gobernanza.
  2. Dataset con un atributo sensible.
  3. Bosque: impureza vs permutación.
  4. Lineal: SHAP cerrado y waterfall.
  5. Equidad mínima.
  6. Model card.
  7. Taller.
# Bosque para lo global, lineal para lo local. Sin .to_numpy() frágil.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import RandomForestClassifier
from sklearn.inspection import permutation_importance
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

np.random.seed(42)
print("Entorno listo.")
Entorno listo.

1) Transparencia no es imprimir el predict

En una frase: un sistema es interpretable si un humano puede responder, con evidencia, qué factores pesaron —en general y, cuando importa, en este caso.

Escala Pregunta Herramienta
Global ¿En qué variables se apoya el modelo? Impureza / permutación
Local ¿Por qué esta fila salió positiva? SHAP lineal

Interpretar no es causalidad. Que ingreso importe no prueba que “subir el ingreso cause aprobación”. Prueba que el modelo usa esa variable.

La transparencia es gobernanza, no un extra de color. Si la decisión toca crédito, salud o empleo, alguien debe poder auditar. “Son 400 árboles” no es una política.

2) Laboratorio: señal, ruido y un atributo sensible

rng = np.random.default_rng(42)
n = 700
ingreso = rng.normal(0, 1, n)
visitas = rng.normal(0, 1, n)
ruido = rng.normal(0, 1, n)
grupo = rng.integers(0, 2, n)  # atributo sensible sintético (0/1)
# La etiqueta depende de ingreso y visitas; grupo NO entra a y de forma directa.
logit = 0.2 + 1.6 * ingreso + 0.7 * visitas
y = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
df = pd.DataFrame({"ingreso": ingreso, "visitas": visitas, "ruido": ruido, "grupo": grupo, "y": y})
# Correlación espuria: el grupo 1 tiende a ingreso un poco mayor (captura, no 'merecimiento').
df.loc[df["grupo"] == 1, "ingreso"] += 0.35
X = df[["ingreso", "visitas", "ruido", "grupo"]]
y = df["y"]
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.3, random_state=42, stratify=y)
print(df.head(3).to_string(index=False))
print("tasa y grupo 0:", f"{df.loc[df.grupo==0,'y'].mean():.3f}",
      "| grupo 1:", f"{df.loc[df.grupo==1,'y'].mean():.3f}")
  ingreso   visitas     ruido  grupo  y
 0.304717  1.112966  1.182531      0  0
-0.689984 -0.245061 -0.323985      1  0
 0.750451 -1.030814 -0.204070      0  1
tasa y grupo 0: 0.539 | grupo 1: 0.485

grupo no causa \(y\) en el generador original; una corrimiento de ingreso lo vuelve un atajo. El modelo puede ‘descubrirlo’. Eso es el núcleo ético de la sesión.

3) Importancia por impureza: útil y vanidosa

rf = RandomForestClassifier(n_estimators=200, max_depth=5, random_state=42)
rf.fit(Xtr, ytr)
imp_imp = pd.Series(rf.feature_importances_, index=X.columns).sort_values()
print(imp_imp.round(3).to_string())
fig, ax = plt.subplots(figsize=(6.8, 3.4))
imp_imp.plot(kind="barh", ax=ax, color="steelblue")
ax.set_title("Importancia por impureza (Gini)")
plt.tight_layout()
plt.show()
grupo      0.024
ruido      0.105
visitas    0.251
ingreso    0.620

La importancia por impureza favorece variables con muchos cortes posibles y puede hinchar a grupo si correlaciona con ingreso. No es un veredicto moral: es un ranking del bosque, no del mundo.

4) Permutación: romper una columna y ver cuánto duele

Se baraja una columna en el test y se mide la caída de un score. Si ruido no duele, el modelo no la usaba de verdad. Si grupo duele, el atajo está activo —aunque el generador no lo haya puesto en \(y\).

perm = permutation_importance(rf, Xte, yte, n_repeats=12, random_state=42, scoring="f1")
imp_perm = pd.Series(perm.importances_mean, index=X.columns).sort_values()
print(imp_perm.round(3).to_string())
fig, ax = plt.subplots(figsize=(6.8, 3.4))
imp_perm.plot(kind="barh", ax=ax, color="seagreen")
ax.set_title("Importancia por permutación (F1 en test)")
plt.tight_layout()
plt.show()
ruido     -0.015
grupo      0.003
visitas    0.012
ingreso    0.201

Contraste. Si impureza y permutación discrepan en grupo o ruido, el informe debe citar ambas. Una sola barra es un cartel, no una auditoría.

5) SHAP lineal: la factura de un caso

En un modelo lineal sobre features ya centradas, la contribución de \(i\) es \(\varphi_i=\beta_i(x_i-\bar{x}_i)\) (en el espacio transformado). La suma de \(\varphi\) más la base reconstruye el logit. Eso es aditividad: la ‘cuenta’ cierra.

# Lineal en espacio estandarizado: E[x]=0, luego phi = beta * x_std.
sc = StandardScaler().fit(Xtr)
Xtr_s, Xte_s = sc.transform(Xtr), sc.transform(Xte)
lin = LogisticRegression(max_iter=400).fit(Xtr_s, ytr)
beta = lin.coef_.ravel()
base = float(lin.intercept_[0])

def shap_lineal(fila_std):
    phi = beta * fila_std
    return phi, base + phi.sum()

i = 0
fila = np.asarray(Xte_s[i]).ravel()
phi, logit = shap_lineal(fila)
cuenta = pd.Series(phi, index=X.columns)
print("caso test[0] crudo:")
print(Xte.iloc[i].round(3).to_string())
print("\ncontribuciones φ:")
print(cuenta.round(3).to_string())
print(f"base={base:.3f} | suma+base={logit:.3f} | logit modelo={lin.decision_function(fila.reshape(1,-1))[0]:.3f}")
caso test[0] crudo:
ingreso    0.128
visitas   -0.380
ruido     -1.165
grupo      1.000

contribuciones φ:
ingreso   -0.039
visitas   -0.381
ruido     -0.062
grupo     -0.404
base=0.050 | suma+base=-0.836 | logit modelo=-0.836

Si suma+base no coincide con el logit, la implementación está mal. La aditividad es el test de la explicación, no un adorno.

# Waterfall didáctico.
orden = cuenta.abs().sort_values(ascending=True).index
fig, ax = plt.subplots(figsize=(7.2, 3.8))
ax.barh(orden, cuenta.loc[orden], color=np.where(cuenta.loc[orden] > 0, "indianred", "steelblue"))
ax.axvline(0, color="black")
ax.set_title("Contribuciones locales (SHAP lineal) del caso 0")
plt.tight_layout()
plt.show()

6) Un segundo caso: la explicación cambia

j = 1
phi_j, _ = shap_lineal(np.asarray(Xte_s[j]).ravel())
comp = pd.DataFrame({"caso_0": phi, "caso_1": phi_j}, index=X.columns)
print(comp.round(3).to_string())
print("Una importancia global no sustituye estas dos facturas.")
         caso_0  caso_1
ingreso  -0.039  -0.975
visitas  -0.381  -0.826
ruido    -0.062   0.019
grupo    -0.404   0.398
Una importancia global no sustituye estas dos facturas.

7) Equidad: paridad demográfica cruda

Tasa de \(\hat{y}=1\) por grupo. No es el único criterio (igualdad de oportunidades, calibración por grupo). Es el más legible para abrir el debate: si el bosque aprueba al grupo 1 el doble, hay que documentarlo, no esconderlo detrás del F1.

yhat = rf.predict(Xte)
eq = pd.DataFrame({"grupo": Xte["grupo"].to_numpy(), "yhat": yhat, "y": yte.to_numpy()})
tab = eq.groupby("grupo").agg(n=("yhat", "size"), tasa_pred=("yhat", "mean"), tasa_real=("y", "mean"))
print(tab.round(3).to_string())
print("disparidad de tasa predicha (1 − 0):", f"{tab.loc[1,'tasa_pred']-tab.loc[0,'tasa_pred']:+.3f}")
         n  tasa_pred  tasa_real
grupo                           
0      109      0.303      0.523
1      101      0.505      0.505
disparidad de tasa predicha (1 − 0): +0.202

Una disparidad no prueba ‘el modelo es racista’ en este sintético: prueba que el atajo de captura (ingreso corrido) se filtró. La gobernanza consiste en verlo y decidir si grupo debe existir como feature (casi nunca) o si hay que bloquear proxies.

8) ¿Hay que quitar grupo?

rf2 = RandomForestClassifier(n_estimators=200, max_depth=5, random_state=42)
cols_sin = ["ingreso", "visitas", "ruido"]
rf2.fit(Xtr[cols_sin], ytr)
eq2 = pd.DataFrame({"grupo": Xte["grupo"].to_numpy(),
                    "yhat": rf2.predict(Xte[cols_sin])})
print(eq2.groupby("grupo")["yhat"].mean().round(3).to_string())
print("Quitar la columna no borra el proxy (ingreso). La auditoría no termina al dropear el nombre.")
grupo
0    0.284
1    0.535
Quitar la columna no borra el proxy (ingreso). La auditoría no termina al dropear el nombre.

9) Model card mínima

card = {
    "proposito": "didáctico: prioridad / crédito sintético",
    "features": list(X.columns),
    "atributo_sensible": "grupo",
    "explicacion_global": "permutación F1 + impureza",
    "explicacion_local": "SHAP lineal (espacio estandarizado)",
    "limitacion": "interpretar ≠ causa; grupo es proxy de captura",
    "owner": "equipo MLOps del curso",
}
print(pd.Series(card).to_string())
proposito                   didáctico: prioridad / crédito sintético
features                            [ingreso, visitas, ruido, grupo]
atributo_sensible                                              grupo
explicacion_global                         permutación F1 + impureza
explicacion_local                SHAP lineal (espacio estandarizado)
limitacion            interpretar ≠ causa; grupo es proxy de captura
owner                                         equipo MLOps del curso

La model card viaja con el run_id (sesión 8), no en un slide suelto. Un auditor pregunta por este artefacto.

10) Lo que no se explica en el endpoint

La sesión 9 dejó /predict delgado. /explain es otro recurso: más CPU, otro contrato, otro riesgo de filtrar \(\beta\). No se mezcla en el 200 por costumbre.

11) Relación con 12 y 14

Si hay drift (12), las \(\varphi\) de hoy no son las del train: explicar un caso con un lineal viejo es una factura de un mundo muerto. 14 traducirá disparidad y F1 a una frase que negocio pueda rechazar.

14b) Coeficientes del lineal en lenguaje humano

print(pd.Series(beta, index=X.columns).round(3).to_string())
print("Signo positivo empuja el logit hacia la clase 1 *en el espacio estandarizado*.")
ingreso    1.854
visitas    0.960
ruido      0.057
grupo     -0.401
Signo positivo empuja el logit hacia la clase 1 *en el espacio estandarizado*.

14c) Fidelidad: ¿el lineal cuenta la misma historia que el bosque?

# Acuerdo de etiquetas en test: si es bajo, NO use el lineal para explicar al bosque.
acord = (rf.predict(Xte) == lin.predict(Xte_s)).mean()
print(f"acuerdo rf vs lineal: {acord:.3f}")
print("Explicar un bosque con un lineal es un *surrogate*. Hay que declarar el acuerdo.")
acuerdo rf vs lineal: 0.886
Explicar un bosque con un lineal es un *surrogate*. Hay que declarar el acuerdo.

14d) Una frase de examen

“Global dice de qué vive el modelo; local dice por qué esta fila; ninguna de las dos prueba causa; dropear el atributo sensible no mata el proxy.”

14e) Dependencia parcial (idea)

Un PDP pregunta: si muevo ingreso y promedio el resto, ¿cómo cambia \(P(\hat{y}=1)\)? No se implementa aquí. Se nombra para no vender SHAP como la única gráfica posible. PDP es global y asume independencia que a menudo es falsa.

14f) Recurso / contrafactual

“¿Qué tendría que cambiar esta persona para ser aprobada?” es otra pregunta (recurso). No es \(\varphi_i\). Un contrafactual mal hecho sugiere subir el ingreso 300 %: legalmente torpe y éticamente vacío.

14g) Features que se decidió no usar

La model card lista también lo excluido (nacionalidad, código postal fino). La omisión es una decisión de diseño, no un olvido. Debe poder auditarse.

print("excluidas por política: nacionalidad, geohash de 6 dígitos")
print("aun así se vigilan proxies (sesión 12 + ingreso).")
excluidas por política: nacionalidad, geohash de 6 dígitos
aun así se vigilan proxies (sesión 12 + ingreso).

14h) Estabilidad de la explicación

Dos filas casi iguales no deberían tener \(\varphi\) opuestas. Si las tienen, el surrogate o el bosque son frágiles. Se puede medir la norma de \(\varphi(x)-\varphi(x+\epsilon)\).

eps = np.zeros_like(fila); eps[0] = 0.05
phi_eps, _ = shap_lineal(fila + eps)
print("||φ(x)-φ(x+ε)|| =", float(np.linalg.norm(phi - phi_eps)))
||φ(x)-φ(x+ε)|| = 0.09268981934452443

14i) Importancia ≠ acción de negocio

Aunque visitas lidere el ranking, ‘pedir más visitas’ puede ser inútil o dañino. La explicación informa la auditoría, no automáticamente el product design.

14j) Logs de explicación

Si se sirve /explain, se audita quién lo pidió. Una factura local es dato personal del caso. No se vierte a un canal público de Slack.

14k) Límites de Shapley en la práctica

La axiomatización asume un juego cooperativo entre features. Con features dependientes, el valor cambia según el background. El lineal centrado de esta sesión elude parte de esa guerra; un SHAP de bosque no. Se declara el background (train).

14l) Prueba de consistencia del signo

print("signo de β_ingreso:", np.sign(beta[list(X.columns).index('ingreso')]))
print("si el negocio espera el signo contrario, hay un bug de T o de etiqueta, no 'IA misteriosa'")
signo de β_ingreso: 1.0
si el negocio espera el signo contrario, hay un bug de T o de etiqueta, no 'IA misteriosa'

14m) El atributo sensible en el servicio

Aunque se quite de \(X\), puede hacer falta después para el informe de disparidad. Se guarda aparte, con control de acceso, no se mete otra vez al predict.

15) Errores frecuentes

  1. Publicar solo Gini.
  2. Llamar causal a \(\varphi_i\).
  3. Explicar el bosque con un lineal sin medir acuerdo.
  4. Dropear grupo y dar por cerrada la equidad.
  5. Meter la explicación en cada /predict.

16) Checklist

  1. ¿Hay ranking global y factura local?
  2. ¿La cuenta lineal cierra (aditividad)?
  3. ¿Se midió disparidad?
  4. ¿La model card cita limitaciones?
  5. ¿El surrogate declara su fidelidad?

17) Conclusiones

Interpretar es producir evidencia auditable, no un gráfico bonito. Impureza, permutación y SHAP lineal se contrastan. La equidad empieza por mirar el atajo. La sesión 14 convierte esa mirada en una recomendación que alguien puede firmar.

18) Taller

A. Recalcule permutación con scoring="accuracy". ¿Cambia el ranking?

B. Elija el caso de test con mayor \(|\varphi_{grupo}|\). Interprete.

C. Entrene el lineal sin grupo. ¿Las \(\varphi\) de ingreso crecen?

D. Redacte tres líneas de model card sobre el proxy.

E. ¿Autorizaría /explain público? Argumente riesgo.

print("acuerdo surrogate:", f"{acord:.3f}")
print("disparidad:", f"{tab.loc[1,'tasa_pred']-tab.loc[0,'tasa_pred']:+.3f}")
print("Taller A–E. Si solo exporta Gini, no auditó.")
acuerdo surrogate: 0.886
disparidad: +0.202
Taller A–E. Si solo exporta Gini, no auditó.

💬 ¿Te sirvió?

Deja en los comentarios una duda o un caso donde aplicarías esto — respondo todos. Sígueme para no perderte el próximo artículo de la serie y comparte con alguien que esté aprendiendo arquitectura de IA.

👉 El código completo está disponible para ejecutar directamente.