Saltar al contenido

IA responsable: detección y mitigación de sesgos

Técnicas prácticas para detectar y mitigar el sesgo en pipelines de machine learning, desde los datos hasta el despliegue y el monitoreo del modelo.

6 min de lectura
Panel que muestra métricas de sesgo entre distintos grupos demográficos con puntuaciones de equidad

Los modelos de machine learning absorben los sesgos presentes en sus datos de entrenamiento. Un modelo de contratación entrenado con decisiones históricas perpetuará la discriminación del pasado. Un modelo de aprobación de préstamos entrenado con patrones de crédito sesgados rechazará a solicitantes solventes de grupos subrepresentados. Estos no son riesgos hipotéticos: son fallos documentados en grandes empresas.

El desarrollo responsable de IA no consiste en añadir la equidad como una ocurrencia tardía. Requiere un diseño intencional en cada etapa del pipeline de ML: recopilación de datos, ingeniería de características, entrenamiento del modelo, evaluación y monitoreo continuo.

Medir el sesgo antes de poder corregirlo

No se puede corregir lo que no se puede medir. Las métricas de equidad cuantifican cuán distinto trata tu modelo a los diferentes grupos, dándote cifras concretas sobre las cuales optimizar.

pypython
# ❌ Only looking at overall accuracy
from sklearn.metrics import accuracy_score
 
overall_accuracy = accuracy_score(y_true, y_pred)
print(f"Model accuracy: {overall_accuracy:.2%}")
# Output: 92% — looks great! But hides group disparities
pypython
# ✅ Computing fairness metrics across groups
import numpy as np
from typing import Dict, List
 
def compute_group_metrics(
    y_true: np.ndarray,
    y_pred: np.ndarray,
    sensitive_attr: np.ndarray,
    positive_label: int = 1
) -> Dict[str, Dict[str, float]]:
    """Compute accuracy, TPR, FPR, and selection rate per group."""
    groups = np.unique(sensitive_attr)
    metrics = {}
 
    for group in groups:
        mask = sensitive_attr == group
        group_true = y_true[mask]
        group_pred = y_pred[mask]
 
        tp = np.sum((group_pred == positive_label) & (group_true == positive_label))
        fp = np.sum((group_pred == positive_label) & (group_true != positive_label))
        tn = np.sum((group_pred != positive_label) & (group_true != positive_label))
        fn = np.sum((group_pred != positive_label) & (group_true == positive_label))
 
        metrics[str(group)] = {
            "accuracy": (tp + tn) / len(group_true) if len(group_true) > 0 else 0,
            "true_positive_rate": tp / (tp + fn) if (tp + fn) > 0 else 0,
            "false_positive_rate": fp / (fp + tn) if (fp + tn) > 0 else 0,
            "selection_rate": np.mean(group_pred == positive_label),
            "sample_size": int(len(group_true)),
        }
 
    return metrics
 
# Example: loan approval model
metrics = compute_group_metrics(
    y_true=actual_outcomes,
    y_pred=model_predictions,
    sensitive_attr=demographic_groups
)
 
for group, m in metrics.items():
    print(f"Group {group}:")
    print(f"  Accuracy: {m['accuracy']:.2%}")
    print(f"  Approval rate: {m['selection_rate']:.2%}")
    print(f"  True positive rate: {m['true_positive_rate']:.2%}")

Un modelo con un 92% de precisión general podría tener un 96% de precisión para un grupo y un 78% para otro. La brecha de equidad es invisible en las métricas agregadas.

Detección de sesgos en los datos de entrenamiento

El sesgo entra en los pipelines de ML principalmente a través de los datos de entrenamiento. Detectar el sesgo a nivel de datos de forma temprana evita construir modelos que codifiquen las desigualdades existentes.

pypython
def detect_data_bias(
    df,
    target_col: str,
    sensitive_cols: List[str],
    threshold: float = 0.8
) -> Dict[str, List[Dict]]:
    """Detect statistical disparities in the training dataset."""
    findings = {"warnings": [], "critical": []}
 
    for col in sensitive_cols:
        groups = df[col].unique()
 
        # Check representation
        group_sizes = df[col].value_counts(normalize=True)
        min_representation = group_sizes.min()
 
        if min_representation < 0.05:
            findings["critical"].append({
                "type": "underrepresentation",
                "column": col,
                "detail": f"Group '{group_sizes.idxmin()}' is only "
                          f"{min_representation:.1%} of data",
            })
 
        # Check outcome rates across groups
        outcome_rates = df.groupby(col)[target_col].mean()
        max_rate = outcome_rates.max()
        min_rate = outcome_rates.min()
 
        if max_rate > 0 and (min_rate / max_rate) < threshold:
            disparate_groups = {
                "highest": f"{outcome_rates.idxmax()} ({max_rate:.2%})",
                "lowest": f"{outcome_rates.idxmin()} ({min_rate:.2%})",
            }
            findings["warnings"].append({
                "type": "outcome_disparity",
                "column": col,
                "ratio": min_rate / max_rate,
                "groups": disparate_groups,
            })
 
        # Check label noise differences
        for group in groups:
            group_mask = df[col] == group
            group_variance = df.loc[group_mask, target_col].var()
            overall_variance = df[target_col].var()
 
            if overall_variance > 0 and group_variance / overall_variance > 1.5:
                findings["warnings"].append({
                    "type": "label_noise",
                    "column": col,
                    "group": str(group),
                    "detail": "Higher label variance suggests noisier labels",
                })
 
    return findings

La regla de los cuatro quintos (umbral de 0.8) es un estándar legal habitual: si la tasa de selección de un grupo es inferior al 80% de la tasa del grupo con mayor tasa, existe evidencia de impacto adverso. Detectarlo en la etapa de datos es mucho más sencillo que corregirlo en el modelo.

Mitigación en el preprocesamiento: reequilibrio y reponderación

Cuando el sesgo existe en los datos, las técnicas de preprocesamiento pueden reducirlo antes de que comience el entrenamiento del modelo.

pypython
def compute_sample_weights(
    df,
    target_col: str,
    sensitive_col: str
) -> np.ndarray:
    """Compute sample weights to equalize outcome rates across groups."""
    groups = df[sensitive_col].unique()
    overall_positive_rate = df[target_col].mean()
 
    weights = np.ones(len(df))
 
    for group in groups:
        mask = df[sensitive_col] == group
        group_positive_rate = df.loc[mask, target_col].mean()
 
        if group_positive_rate > 0:
            # Upweight underrepresented positive outcomes
            positive_mask = mask & (df[target_col] == 1)
            negative_mask = mask & (df[target_col] == 0)
 
            pos_weight = overall_positive_rate / group_positive_rate
            neg_weight = (1 - overall_positive_rate) / (1 - group_positive_rate)
 
            weights[positive_mask] = pos_weight
            weights[negative_mask] = neg_weight
 
    # Normalize weights
    weights = weights / weights.mean()
    return weights
 
# Usage with scikit-learn
sample_weights = compute_sample_weights(train_df, "approved", "demographic")
 
from sklearn.ensemble import GradientBoostingClassifier
model = GradientBoostingClassifier(n_estimators=100)
model.fit(X_train, y_train, sample_weight=sample_weights)
pypython
# ❌ Naive oversampling: can overfit minority groups
from sklearn.utils import resample
 
minority = df[df["demographic"] == "group_b"]
oversampled = resample(minority, n_samples=len(df) - len(minority))
# Duplicated samples lead to overfitting
pypython
# ✅ Synthetic minority oversampling (SMOTE) with caution
from imblearn.over_sampling import SMOTE
 
smote = SMOTE(random_state=42, k_neighbors=5)
X_resampled, y_resampled = smote.fit_resample(X_train, y_train)
 
# Always evaluate on original distribution, never on resampled data

La reponderación es, en general, más segura que el remuestreo porque no crea puntos de datos sintéticos ni duplica los existentes. El modelo aprende de los mismos ejemplos, pero otorga la importancia adecuada a los grupos subrepresentados.

Posprocesamiento: calibración de umbrales

Cuando no se puede modificar el pipeline de entrenamiento (por ejemplo, al usar un modelo de terceros o un ensamble complejo), el posprocesamiento ajusta los umbrales de decisión por grupo para alcanzar los objetivos de equidad.

pypython
from scipy.optimize import minimize_scalar
 
def find_fair_thresholds(
    y_true: np.ndarray,
    y_scores: np.ndarray,
    sensitive_attr: np.ndarray,
    target_metric: str = "equal_opportunity"
) -> Dict[str, float]:
    """Find per-group thresholds that equalize the target metric."""
    groups = np.unique(sensitive_attr)
 
    if target_metric == "equal_opportunity":
        # Equalize true positive rates
        target_fn = lambda threshold, mask: (
            np.sum((y_scores[mask] >= threshold) & (y_true[mask] == 1))
            / max(np.sum(y_true[mask] == 1), 1)
        )
    elif target_metric == "demographic_parity":
        # Equalize selection rates
        target_fn = lambda threshold, mask: (
            np.mean(y_scores[mask] >= threshold)
        )
    else:
        raise ValueError(f"Unknown metric: {target_metric}")
 
    # Find the average metric value at default threshold (0.5)
    default_values = []
    for group in groups:
        mask = sensitive_attr == group
        default_values.append(target_fn(0.5, mask))
    target_value = np.mean(default_values)
 
    # Optimize per-group thresholds to match target
    thresholds = {}
    for group in groups:
        mask = sensitive_attr == group
 
        result = minimize_scalar(
            lambda t: abs(target_fn(t, mask) - target_value),
            bounds=(0.1, 0.9),
            method="bounded"
        )
        thresholds[str(group)] = float(result.x)
 
    return thresholds
 
# Apply group-specific thresholds
thresholds = find_fair_thresholds(
    y_true, model_scores, demographics,
    target_metric="equal_opportunity"
)
 
fair_predictions = np.zeros_like(model_scores)
for group, threshold in thresholds.items():
    mask = demographics == group
    fair_predictions[mask] = (model_scores[mask] >= threshold).astype(int)

La calibración de umbrales introduce una disyuntiva: se mejora la equidad a costa de algo de precisión general. La clave está en hacer explícita y medible esta disyuntiva, en lugar de fingir que no existe.

Monitoreo continuo de la equidad

El sesgo no es un problema puntual. Las distribuciones de datos cambian, las poblaciones de usuarios evolucionan y el comportamiento del modelo puede desviarse de formas que afectan desproporcionadamente a ciertos grupos.

pypython
class FairnessMonitor:
    def __init__(
        self,
        sensitive_columns: List[str],
        alert_threshold: float = 0.8
    ):
        self.sensitive_columns = sensitive_columns
        self.alert_threshold = alert_threshold
        self.history: List[Dict] = []
 
    def evaluate(
        self,
        y_true: np.ndarray,
        y_pred: np.ndarray,
        sensitive_data: Dict[str, np.ndarray],
        timestamp: str
    ) -> Dict:
        report = {"timestamp": timestamp, "alerts": []}
 
        for col in self.sensitive_columns:
            attrs = sensitive_data[col]
            metrics = compute_group_metrics(y_true, y_pred, attrs)
 
            selection_rates = {
                g: m["selection_rate"] for g, m in metrics.items()
            }
            max_rate = max(selection_rates.values())
            min_rate = min(selection_rates.values())
 
            if max_rate > 0:
                disparity_ratio = min_rate / max_rate
            else:
                disparity_ratio = 1.0
 
            if disparity_ratio < self.alert_threshold:
                report["alerts"].append({
                    "column": col,
                    "disparity_ratio": round(disparity_ratio, 3),
                    "max_group": max(selection_rates, key=selection_rates.get),
                    "min_group": min(selection_rates, key=selection_rates.get),
                    "severity": "critical" if disparity_ratio < 0.6 else "warning",
                })
 
            report[col] = metrics
 
        self.history.append(report)
        return report

Puntos clave

Construir sistemas de ML justos no consiste en elegir entre precisión y equidad, sino en hacer las disyuntivas explícitas e intencionales. Mide las métricas a nivel de grupo desde el primer día, no solo la precisión agregada. Detecta el sesgo en los datos de entrenamiento antes de que llegue a tu modelo. Usa la reponderación para el preprocesamiento y la calibración de umbrales para el posprocesamiento. Y, lo más importante, monitorea la equidad de forma continua en producción, porque la deriva de los datos puede reintroducir un sesgo que no estaba presente en el lanzamiento.

Las organizaciones que gestionan bien el sesgo en la IA comparten un rasgo común: tratan la equidad como un requisito de ingeniería de primer nivel, con el mismo rigor que la latencia, la disponibilidad y la corrección. El sesgo que no se mide no se gestiona.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX