{ "cells": [ { "cell_type": "markdown", "id": "7fc31bcd", "metadata": {}, "source": [ "# UD06 · Notebook 1 — Detección y corrección de sesgos\n", "\n", "Vamos a **auditar la equidad** de un modelo real con la biblioteca **Fairlearn**: medir el sesgo por\n", "grupos, mitigarlo con postprocesado y —lo más importante— **ver el precio que se paga** por hacerlo.\n", "\n", "Este notebook acompaña al [Taller 2](https://martinezpenya.es/ModelosIA/UD06/notebooks/UD06_N03_auditoria_sesgos.html)\n", "y al bloque **§9 · Sesgos de género y algorítmicos** de la\n", "[teoría de la unidad](https://martinezpenya.es/ModelosIA/UD06/UD06_ES.html).\n", "\n", "**Criterio de evaluación**: RA6-f, con conexión a RA6-b (minimización) y RA6-e (privacidad)." ] }, { "cell_type": "markdown", "id": "934442d3", "metadata": {}, "source": [ "## 1. Instalación y datos\n", "\n", "Usamos el conjunto **UCI Adult** (48 842 filas): predice si una persona gana más de 50 000 $ al año a\n", "partir de datos demográficos y laborales. Lo cargamos con `fairlearn.datasets.fetch_adult`, que lo\n", "descarga de OpenML y lo deja cacheado: **no hay que bajar nada a mano**." ] }, { "cell_type": "code", "execution_count": null, "id": "de16fc3b", "metadata": { "execution": { "iopub.execute_input": "2026-08-22T08:12:44.019566Z", "iopub.status.busy": "2026-08-22T08:12:44.019465Z", "iopub.status.idle": "2026-08-22T08:12:44.411576Z", "shell.execute_reply": "2026-08-22T08:12:44.410447Z" } }, "outputs": [], "source": [ "%pip install -q fairlearn scikit-learn pandas matplotlib" ] }, { "cell_type": "code", "execution_count": null, "id": "af6f3a8d", "metadata": { "execution": { "iopub.execute_input": "2026-08-22T08:12:44.413759Z", "iopub.status.busy": "2026-08-22T08:12:44.413528Z", "iopub.status.idle": "2026-08-22T08:12:45.160775Z", "shell.execute_reply": "2026-08-22T08:12:45.159626Z" } }, "outputs": [], "source": [ "import pandas as pd\n", "from fairlearn.datasets import fetch_adult\n", "\n", "datos = fetch_adult(as_frame=True)\n", "\n", "# Quitamos «sex» de las características: no queremos que el modelo decida con ella...\n", "X = datos.data.drop(columns=[\"sex\"])\n", "y = (datos.target == \">50K\").astype(int)\n", "# ...pero la guardamos aparte, porque SIN ella no se puede auditar\n", "sexo = datos.data[\"sex\"]\n", "\n", "print(\"filas:\", len(X), \"| columnas:\", len(X.columns))\n", "print(\"reparto por sexo:\", sexo.value_counts().to_dict())" ] }, { "cell_type": "markdown", "id": "f9773dcc", "metadata": {}, "source": [ "**Fíjate en lo que acabamos de hacer**\n", "\n", "Hemos **quitado el sexo de las características y lo hemos guardado aparte**. No es un truco: es la\n", "única forma de auditar. Si borras el atributo del todo —lo que la teoría llama **equidad por\n", "desconocimiento**— pierdes la capacidad de comprobar si discriminas.\n", "\n", "Y eso choca con el principio de **minimización** del RGPD. Es la **paradoja de los sesgos**, y te la\n", "encuentras en la segunda celda del notebook." ] }, { "cell_type": "code", "execution_count": null, "id": "4c6c14e0", "metadata": { "execution": { "iopub.execute_input": "2026-08-22T08:12:45.162650Z", "iopub.status.busy": "2026-08-22T08:12:45.162498Z", "iopub.status.idle": "2026-08-22T08:12:45.166599Z", "shell.execute_reply": "2026-08-22T08:12:45.165959Z" } }, "outputs": [], "source": [ "# Tasas base: qué proporción de cada grupo gana de verdad más de 50 000 $\n", "tasas = y.groupby(sexo, observed=True).mean().round(4)\n", "print(tasas.to_dict())" ] }, { "cell_type": "markdown", "id": "1dd31f69", "metadata": {}, "source": [ "Anota estas dos cifras: **las tasas base son muy distintas**. Esa es exactamente la condición del\n", "resultado de imposibilidad de **Kleinberg et al. (2016)**, y explica lo que veremos en el apartado 5." ] }, { "cell_type": "markdown", "id": "7497dca1", "metadata": {}, "source": [ "## 2. Preparación y entrenamiento\n", "\n", "Codificamos las columnas categóricas y entrenamos. Nada de esto es específico de equidad: es un\n", "pipeline normal de `scikit-learn`." ] }, { "cell_type": "code", "execution_count": null, "id": "9e8d368e", "metadata": { "execution": { "iopub.execute_input": "2026-08-22T08:12:45.168445Z", "iopub.status.busy": "2026-08-22T08:12:45.168288Z", "iopub.status.idle": "2026-08-22T08:12:46.208722Z", "shell.execute_reply": "2026-08-22T08:12:46.208294Z" } }, "outputs": [], "source": [ "from sklearn.compose import ColumnTransformer\n", "from sklearn.ensemble import HistGradientBoostingClassifier\n", "from sklearn.model_selection import train_test_split\n", "from sklearn.pipeline import Pipeline\n", "from sklearn.preprocessing import OneHotEncoder\n", "\n", "categoricas = X.select_dtypes(include=[\"category\", \"object\"]).columns.tolist()\n", "\n", "# sparse_output=False es OBLIGATORIO: HistGradientBoostingClassifier no acepta matrices dispersas\n", "prep = ColumnTransformer(\n", " [(\"cat\", OneHotEncoder(handle_unknown=\"ignore\", sparse_output=False), categoricas)],\n", " remainder=\"passthrough\")\n", "\n", "modelo = Pipeline([(\"prep\", prep),\n", " (\"clf\", HistGradientBoostingClassifier(random_state=42))])\n", "\n", "X_tr, X_te, y_tr, y_te, s_tr, s_te = train_test_split(\n", " X, y, sexo, test_size=0.3, random_state=42, stratify=y)\n", "\n", "modelo.fit(X_tr, y_tr)\n", "pred = modelo.predict(X_te)\n", "print(\"modelo entrenado sobre\", len(X_tr), \"filas\")" ] }, { "cell_type": "markdown", "id": "03fbd99a", "metadata": {}, "source": [ "## 3. Métricas de equidad\n", "\n", "Tres números. **Diferencia ≈ 0** significa que la métrica se cumple entre grupos." ] }, { "cell_type": "code", "execution_count": null, "id": "7b6055ed", "metadata": { "execution": { "iopub.execute_input": "2026-08-22T08:12:46.210685Z", "iopub.status.busy": "2026-08-22T08:12:46.210509Z", "iopub.status.idle": "2026-08-22T08:12:46.399340Z", "shell.execute_reply": "2026-08-22T08:12:46.398723Z" } }, "outputs": [], "source": [ "from sklearn.metrics import accuracy_score\n", "from fairlearn.metrics import (MetricFrame, demographic_parity_difference,\n", " equalized_odds_difference, selection_rate)\n", "\n", "exactitud = accuracy_score(y_te, pred)\n", "dp = demographic_parity_difference(y_te, pred, sensitive_features=s_te)\n", "eo = equalized_odds_difference(y_te, pred, sensitive_features=s_te)\n", "\n", "print(f\"exactitud global .................. {exactitud:.4f}\")\n", "print(f\"paridad demográfica (dif) ......... {dp:.4f}\")\n", "print(f\"igualdad de oportunidades (dif) ... {eo:.4f}\")" ] }, { "cell_type": "markdown", "id": "34e80f0b", "metadata": {}, "source": [ "## 4. Comparación por grupo: por qué la métrica global engaña\n", "\n", "Aquí está el corazón del notebook. La exactitud global parecía respetable; mírala por grupo." ] }, { "cell_type": "code", "execution_count": null, "id": "013b6218", "metadata": { "execution": { "iopub.execute_input": "2026-08-22T08:12:46.401576Z", "iopub.status.busy": "2026-08-22T08:12:46.401477Z", "iopub.status.idle": "2026-08-22T08:12:46.513993Z", "shell.execute_reply": "2026-08-22T08:12:46.513471Z" } }, "outputs": [], "source": [ "mf = MetricFrame(\n", " metrics={\"exactitud\": accuracy_score, \"tasa de selección\": selection_rate},\n", " y_true=y_te, y_pred=pred, sensitive_features=s_te)\n", "print(mf.by_group)" ] }, { "cell_type": "code", "execution_count": null, "id": "b815acfd", "metadata": { "execution": { "iopub.execute_input": "2026-08-22T08:12:46.516696Z", "iopub.status.busy": "2026-08-22T08:12:46.516378Z", "iopub.status.idle": "2026-08-22T08:12:46.819747Z", "shell.execute_reply": "2026-08-22T08:12:46.819078Z" } }, "outputs": [], "source": [ "import matplotlib.pyplot as plt\n", "\n", "ejes = mf.by_group.plot.bar(subplots=True, layout=(1, 2), figsize=(9, 3.5),\n", " legend=False, rot=0, title=[\"Exactitud\", \"Tasa de selección\"])\n", "plt.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "e29af534", "metadata": {}, "source": [ "Dos cosas que hay que ver, y las dos son contraintuitivas:\n", "\n", "1. El modelo **acierta más con las mujeres**. No es que funcione mejor para ellas: es que hay muchas\n", " menos positivas, así que **decir «no» casi siempre ya acierta**. La exactitud es una métrica\n", " tramposa con clases desequilibradas.\n", "2. La **tasa de selección** es unas **tres veces mayor** en hombres. El modelo propone a un hombre\n", " para el grupo de altos ingresos mucho más a menudo.\n", "\n", "Una sola cifra global habría ocultado las dos cosas. Es, en la práctica, la **paradoja de Simpson**." ] }, { "cell_type": "markdown", "id": "1f64a021", "metadata": {}, "source": [ "## 5. Mitigación con `ThresholdOptimizer`\n", "\n", "`ThresholdOptimizer` ajusta **un umbral distinto por grupo** para forzar la métrica que le pidas. Es\n", "mitigación de **postprocesado**: no toca los datos ni reentrena el modelo." ] }, { "cell_type": "code", "execution_count": null, "id": "550c0bb4", "metadata": { "execution": { "iopub.execute_input": "2026-08-22T08:12:46.821556Z", "iopub.status.busy": "2026-08-22T08:12:46.821286Z", "iopub.status.idle": "2026-08-22T08:12:47.210917Z", "shell.execute_reply": "2026-08-22T08:12:47.210178Z" } }, "outputs": [], "source": [ "from fairlearn.postprocessing import ThresholdOptimizer\n", "\n", "opt = ThresholdOptimizer(estimator=modelo, constraints=\"equalized_odds\",\n", " predict_method=\"predict_proba\", prefit=True)\n", "opt.fit(X_tr, y_tr, sensitive_features=s_tr)\n", "pred_opt = opt.predict(X_te, sensitive_features=s_te, random_state=42)\n", "\n", "exactitud_opt = accuracy_score(y_te, pred_opt)\n", "dp_opt = demographic_parity_difference(y_te, pred_opt, sensitive_features=s_te)\n", "eo_opt = equalized_odds_difference(y_te, pred_opt, sensitive_features=s_te)\n", "\n", "comparativa = pd.DataFrame(\n", " {\"antes\": [exactitud, eo, dp], \"después\": [exactitud_opt, eo_opt, dp_opt]},\n", " index=[\"exactitud global\", \"igualdad de oportunidades (dif)\", \"paridad demográfica (dif)\"])\n", "print(comparativa.round(4))" ] }, { "cell_type": "code", "execution_count": null, "id": "57e07fa2", "metadata": { "execution": { "iopub.execute_input": "2026-08-22T08:12:47.212612Z", "iopub.status.busy": "2026-08-22T08:12:47.212498Z", "iopub.status.idle": "2026-08-22T08:12:47.398076Z", "shell.execute_reply": "2026-08-22T08:12:47.397494Z" } }, "outputs": [], "source": [ "mf2 = MetricFrame(\n", " metrics={\"exactitud\": accuracy_score, \"tasa de selección\": selection_rate},\n", " y_true=y_te, y_pred=pred_opt, sensitive_features=s_te)\n", "print(mf2.by_group)" ] }, { "cell_type": "markdown", "id": "13deef9e", "metadata": {}, "source": [ "Tres lecturas de esa tabla:\n", "\n", "1. **La igualdad de oportunidades se resuelve**: cae casi a cero. Es lo que le pedimos con\n", " `constraints=\"equalized_odds\"`.\n", "2. **Se paga un precio**: la exactitud global baja. No es gratis, y hay que poder justificar ese coste\n", " ante quien paga el sistema.\n", "3. **La paridad demográfica NO se resuelve.** Mejora, pero sigue lejos de cero. Y **no es un fallo de\n", " la herramienta**: las tasas base del apartado 1 son muy distintas, así que por Kleinberg et al.\n", " (2016) forzar la igualdad de oportunidades **no** te da paridad demográfica." ] }, { "cell_type": "markdown", "id": "13569bbc", "metadata": {}, "source": [ "## 6. Actividad\n", "\n", "Responde en celdas nuevas debajo de cada punto.\n", "\n", "1. Rellena la comparativa antes/después con **tus** números y explica en dos frases **a costa de qué**\n", " mejoró la equidad.\n", "2. Cambia `constraints` a `\"demographic_parity\"` y vuelve a ejecutar el apartado 5. ¿Qué métrica se\n", " arregla ahora y cuál empeora? Relaciónalo con el resultado de imposibilidad.\n", "3. Cambia el grupo sensible a **`race`** y repite el análisis completo. ¿El sesgo es mayor o menor que\n", " con el sexo? ¿Cambia tu conclusión sobre el modelo?\n", "4. Este modelo, aplicado a selección de personal: ¿qué **nivel de riesgo** tendría en el AI Act y qué\n", " obligaciones concretas le tocarían? ¿Qué dice el **RGPD** sobre usar el sexo en producción?\n", "5. **Reto**: escribe una **model card** de este modelo. Debe incluir, como mínimo: para qué sirve y\n", " para qué no, con qué datos se entrenó, las métricas por subgrupo del apartado 4 y las limitaciones\n", " de equidad que has encontrado." ] }, { "cell_type": "markdown", "id": "27524d11", "metadata": {}, "source": [ "---\n", "\n", "**Material de la unidad**:\n", "[Teoría](https://martinezpenya.es/ModelosIA/UD06/UD06_ES.html) ·\n", "[Ejercicios](https://martinezpenya.es/ModelosIA/UD06/UD06_Ejercicios.html) ·\n", "[Notebook 2](https://martinezpenya.es/ModelosIA/UD06/notebooks/UD06_N02_analisis_caso_etico.html) ·\n", "[Taller 2](https://martinezpenya.es/ModelosIA/UD06/notebooks/UD06_N03_auditoria_sesgos.html) ·\n", "[Debate 1](https://martinezpenya.es/ModelosIA/UD06/UD06_D01_Debate_limites_eticos_ES.html) ·\n", "[Debate 2](https://martinezpenya.es/ModelosIA/UD06/UD06_D02_Debate_algoritmo_crimen_ES.html)" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.14.4" } }, "nbformat": 4, "nbformat_minor": 5 }