Módulo 2: Bias and Fairness — Detection, Measurement, Mitigation
7. Mitigation: In-processing y Post-processing
Descripción de la cápsula
Pre-processing (cápsula 06) modifica training data. Funciona cuando la solution es "más data balanceada". Pero a veces no es suficiente:
- Bias estructural está en patterns que persisten incluso con data balanceada.
- No podés modificar el training data (usando API externa, dataset legalmente fixed).
- Trade-off accuracy es muy alto con pre-processing solo.
Esta cápsula cubre las dos categorías restantes:
-
In-processing: modificar el algoritmo de training. Adversarial debiasing, fairness constraints en loss function. Más complejo pero a menudo más efectivo.
-
Post-processing: modificar outputs del modelo entrenado. Threshold tuning per group, calibration adjustment, reject option. Aplicable a cualquier modelo (incluyendo black-box APIs).
Vas a aprender cuándo elegir cada categoría, cómo implementarlas (con código), y cómo combinarlas con pre-processing para defense en profundidad.
In-processing: modificar el training
Concepto
En lugar de balancear data antes, agregar fairness al loss function durante training.
Tradicional:
loss = error(predictions, ground_truth)
Con fairness constraint:
loss = error(predictions, ground_truth) + λ * fairness_violation(predictions, groups)
λ controla el trade-off: λ=0 = solo accuracy. λ alto = más fairness, possible cost en accuracy.
Técnica 1: Fairness regularization
La idea más simple: agregar un término al loss que penaliza disparate predictions entre grupos.
import torch
import torch.nn as nn
class FairnessRegularizedLoss(nn.Module):
"""
Loss tradicional + fairness regularization.
"""
def __init__(self, base_loss=nn.BCELoss(), lambda_fair=0.5):
super().__init__()
self.base_loss = base_loss
self.lambda_fair = lambda_fair
def forward(self, predictions, targets, group_labels):
# Loss tradicional
accuracy_loss = self.base_loss(predictions, targets)
# Fairness term: difference de mean predictions entre grupos
unique_groups = torch.unique(group_labels)
group_means = []
for g in unique_groups:
mask = (group_labels == g)
group_means.append(predictions[mask].mean())
# Penalty: max difference between group means
fairness_loss = max(group_means) - min(group_means)
return accuracy_loss + self.lambda_fair * fairness_loss
Técnica 2: Adversarial Debiasing
Idea más sophisticated: entrenar simultaneamente:
- Predictor: predice el target (ej., loan approve).
- Adversary: trata de predecir el atributo protegido a partir de las predictions del predictor.
Goal: predictor debe lograr accuracy alto mientras adversary no puede recover el atributo protegido. Si adversary no puede predecir género del output del predictor, el output no contiene information sobre género (= unbiased).
class AdversarialDebiasing:
"""
Esquema simplificado de adversarial debiasing.
"""
def __init__(self, predictor, adversary, alpha=1.0):
self.predictor = predictor # Predicts target
self.adversary = adversary # Predicts protected attribute
self.alpha = alpha # Trade-off
def train_step(self, X, y, protected, optimizer_pred, optimizer_adv):
# 1. Predictor forward
y_pred = self.predictor(X)
# 2. Adversary tries to predict protected from y_pred
protected_pred = self.adversary(y_pred)
# 3. Predictor loss = task_loss - alpha * adversary_loss
# (predictor wants to MAX adversary's loss = MIN its accuracy)
task_loss = nn.BCELoss()(y_pred, y)
adv_loss = nn.BCELoss()(protected_pred, protected)
predictor_loss = task_loss - self.alpha * adv_loss
# 4. Update predictor
optimizer_pred.zero_grad()
predictor_loss.backward(retain_graph=True)
optimizer_pred.step()
# 5. Update adversary (just minimize its own loss)
optimizer_adv.zero_grad()
adv_loss.backward()
optimizer_adv.step()
return {
'task_loss': task_loss.item(),
'adv_loss': adv_loss.item(),
}
Trade-offs de in-processing
Pros:
- Más efectivo que pre-processing en muchos casos.
- Direct control sobre fairness/accuracy trade-off via λ.
- Modelo aprende a no usar proxy variables durante training.
Cons:
- Requiere modelo customizable (no funciona con black-box APIs).
- Training más complejo (especialmente adversarial: dos networks, balancing tricky).
- Menos interpretable: stakeholders pregunta "qué hizo" → respuesta es "fairness regularization en loss".
- Harder to debug si no convergen.
Cuándo elegir in-processing
- Tenés control completo sobre training pipeline.
- Modelo deep learning que podés modificar.
- Pre-processing solo insuficiente.
- Tenés expertise/time para tuning hyperparameters.
Post-processing: modificar outputs
Concepto
Modelo ya está entrenado (puede ser black-box). Modificás los outputs antes de devolverlos como decision.
Más popular: threshold tuning per group. En lugar de threshold único (e.g., approve si score > 0.5), usar threshold distinto para cada grupo:
Group A: approve si score > 0.50
Group B: approve si score > 0.43
Threshold más bajo para grupo B compensa miscalibration o disparate impact.
Técnica 1: Threshold tuning
def find_fair_thresholds(scores, ground_truth, groups, target_metric='dp',
target_value=0.95):
"""
Encontrar thresholds per group para satisfacer target metric.
target_metric: 'dp' (demographic parity), 'eo' (equal opportunity).
target_value: e.g., 0.95 = 95% de match en métrica.
"""
df = pd.DataFrame({
'score': scores,
'true': ground_truth,
'group': groups,
})
# Para cada grupo, encontrar threshold que produce similar TPR (para EO)
# o similar positive rate (para DP).
if target_metric == 'dp':
# Demographic parity: igual positive rate
# Encontrar threshold per group que da target positive rate
thresholds = {}
for group_name, group_df in df.groupby('group'):
# Default: positive rate del grupo majority
target_pos_rate = (df['score'] > 0.5).mean()
sorted_scores = sorted(group_df['score'].values, reverse=True)
threshold_idx = int(target_pos_rate * len(sorted_scores))
threshold = sorted_scores[threshold_idx]
thresholds[group_name] = threshold
return thresholds
elif target_metric == 'eo':
# Equal opportunity: igual TPR
target_tpr = 0.80 # configurable
thresholds = {}
for group_name, group_df in df.groupby('group'):
positives = group_df[group_df['true'] == 1]
# Threshold tal que TPR = target
sorted_pos_scores = sorted(positives['score'].values, reverse=True)
tpr_idx = int(target_tpr * len(sorted_pos_scores))
threshold = sorted_pos_scores[tpr_idx]
thresholds[group_name] = threshold
return thresholds
def apply_per_group_threshold(scores, groups, thresholds):
"""Aplicar thresholds per group."""
decisions = []
for score, group in zip(scores, groups):
threshold = thresholds.get(group, 0.5) # default si group no en thresholds
decisions.append(1 if score > threshold else 0)
return decisions
Trade-offs de threshold tuning
Pros:
- Aplica a cualquier modelo (black-box compatible).
- Simple de implementar.
- Reversible: si no funciona, volver al threshold uniforme.
Cons:
- Legalmente cuestionable en algunos contextos: aplicar thresholds distintos por grupo puede ser "disparate treatment" (treating individuals differently based on protected attribute), lo cual es ilegal en US hiring/lending.
- Requiere data demographic en production (para aplicar el threshold correcto).
- No aborda root cause: el modelo sigue siendo biased, solo se mitiga at decision time.
Caveat legal importante
En US, aplicar thresholds distintos por grupo puede ser ilegal (disparate treatment). Excepción: programs explícitamente affirmative action que han sido court-approved.
En EU, EU AI Act incluye provisions sobre equal treatment. Verificar regulación específica antes de implementar.
Solution: a veces se usa thresholds tuneados internalmente para análisis, pero el deploy uses single threshold + accept that demographic parity not perfect.
Técnica 2: Calibration adjustment
Si tu modelo está miscalibrated entre grupos (cápsula 03), ajustar scores post-hoc:
from sklearn.isotonic import IsotonicRegression
def calibrate_per_group(scores, ground_truth, groups):
"""
Aplicar isotonic regression per grupo para calibration.
"""
df = pd.DataFrame({
'score': scores,
'true': ground_truth,
'group': groups,
})
calibrators = {}
for group_name, group_df in df.groupby('group'):
ir = IsotonicRegression(out_of_bounds='clip')
ir.fit(group_df['score'].values, group_df['true'].values)
calibrators[group_name] = ir
return calibrators
def apply_calibration(scores, groups, calibrators):
"""Aplicar calibration per group a new predictions."""
calibrated = []
for score, group in zip(scores, groups):
if group in calibrators:
calibrated.append(calibrators[group].predict([score])[0])
else:
calibrated.append(score)
return calibrated
Output: cada grupo tiene scores calibrated separadamente. "Score = 0.7" significa real 70% probability para cada grupo.
Técnica 3: Reject option
En zonas marginales (scores cerca del threshold), reject and require human review instead of auto-decide.
def reject_option(scores, low_threshold=0.4, high_threshold=0.6):
"""
Approve/deny solo si score muy alto/bajo. Sino, manual review.
"""
decisions = []
for score in scores:
if score >= high_threshold:
decisions.append('approve')
elif score <= low_threshold:
decisions.append('deny')
else:
decisions.append('manual_review')
return decisions
Útil cuando errores tienen alto cost. Marginal cases get human review = más accurate decisions in those cases.
Cost: 20-30% de cases requieren human review. Operational overhead.
Combinando categorías
En sistemas production-grade, combinás las tres:
DATA → MODEL → OUTPUT → DECISION
↓ ↓ ↓
Pre- In- Post-
proc proc proc
Defense en profundidad
- Pre-processing: reduce el bias inicial en data.
- In-processing: el modelo aprende a no usar proxies.
- Post-processing: corrige residual bias en outputs.
Cada uno cubre lo que los otros no. Combinados, reducen bias significativamente más que cualquier solo.
Pipeline ejemplo
def fair_pipeline(X_train, y_train, X_test, y_test, groups_train, groups_test):
"""
Pipeline completo con todas las categorías.
"""
# 1. Pre-processing: re-weighting
weights = compute_sample_weights_for_groups(y_train, groups_train)
# 2. In-processing: train con fairness regularization
model = FairModel(lambda_fair=0.3)
model.fit(X_train, y_train, sample_weight=weights, groups=groups_train)
# 3. Post-processing: calibrate per group
train_scores = model.predict_proba(X_train)
calibrators = calibrate_per_group(train_scores, y_train, groups_train)
# 4. Inference con todas las correcciones
test_scores = model.predict_proba(X_test)
calibrated_scores = apply_calibration(test_scores, groups_test, calibrators)
return calibrated_scores
Cómo elegir entre categorías
Decision tree
¿Tenés acceso a training data?
├── No → POST-PROCESSING ONLY (threshold tuning, calibration)
│
└── Sí → ¿Tenés expertise en customizing training?
├── No → PRE-PROCESSING (más simple)
│
└── Sí → ¿Pre-processing solo es suficiente?
├── Sí → PRE-PROCESSING
│
└── No → ¿Modelo lo permite (deep learning, custom training)?
├── Sí → COMBINAR PRE + IN-PROCESSING
│
└── No → COMBINAR PRE + POST-PROCESSING
Por scenario práctico
Scenario 1: Usando OpenAI/Anthropic API (black-box):
- No podés modificar training.
- Pre-processing of inputs (prompt engineering): limited.
- Post-processing only: filter outputs, threshold tuning if scoring, reject option.
Scenario 2: Custom sklearn model:
- Pre-processing: re-weighting o re-sampling.
- Post-processing: threshold tuning si tolerable legal.
Scenario 3: Custom deep learning:
- Combiná todas: pre-processing + adversarial debiasing + calibration post-hoc.
Scenario 4: Enterprise constraints (legal sensitivity):
- Pre-processing only (más defendible).
- Post-processing solo si court-approved.
Validar mitigation
Para cualquier técnica:
- Aplicar.
- Re-medir las métricas (DP, EO, calibration).
- Comparar vs baseline (sin mitigation).
- Evaluar accuracy impact.
- Documentar trade-offs.
def evaluate_mitigation(predictions_baseline, predictions_mitigated,
ground_truth, groups):
"""
Comparar baseline vs mitigated en todas las métricas.
"""
return {
'baseline': {
'accuracy': accuracy_score(ground_truth, predictions_baseline),
'demographic_parity': demographic_parity(predictions_baseline, groups),
'equalized_odds': equalized_odds(predictions_baseline, ground_truth, groups),
},
'mitigated': {
'accuracy': accuracy_score(ground_truth, predictions_mitigated),
'demographic_parity': demographic_parity(predictions_mitigated, groups),
'equalized_odds': equalized_odds(predictions_mitigated, ground_truth, groups),
},
}
Reportá full table en tu Bias Audit. Stakeholders deciden si trade-off es aceptable.
Trampas y errores comunes
1. Aplicar mitigation sin medir
"Apliqué adversarial debiasing, ahora es fair". Sin re-medir, no sabés.
2. λ tuning superficial
Adversarial debiasing y fairness regularization tienen hyperparameter λ. Tuning correctly requires sweep + validation. Default λ=1 raramente óptimo.
3. Threshold tuning sin considerar legality
US hiring + threshold tuning per group = potencial disparate treatment claim. Verificar con legal antes.
4. Calibration solo, esperando demographic parity
Calibration no garantiza demographic parity (impossibility theorems). Cada métrica requires su propia mitigation.
5. No combinar técnicas
Pre solo a veces insuficiente. In solo a veces insuficiente. Combinaciones suelen ser más robust.
Auto-verificación
1. ¿Cuándo es post-processing tu única opción?
Cuando NO tenés acceso al training pipeline. Casos:
-
Modelo es API externa (OpenAI, Anthropic, Google): no podés modificar training ni access weights.
-
Modelo es vendor product: comprás un modelo entrenado, no podés re-train.
-
Compliance restrictions: algunas regulated industries no permiten modify training data o algoritmos.
-
Costo de re-training es prohibitivo: foundation models que costaron millions to train.
En estos casos, post-processing (threshold tuning, calibration adjustment, filtering, reject option) es lo único disponible.
Limitations: post-processing no aborda root cause. El modelo sigue biased; solo se filter outputs. Para systems serios, idealmente migrar a model que podés modificar.
2. ¿Por qué adversarial debiasing puede ser más efectivo que pre-processing?
Pre-processing balancea data, pero el modelo aún puede aprender proxies sutiles del atributo protegido (recordá Apple Card).
Adversarial debiasing fuerza explícitamente al modelo a producir outputs que no contienen información sobre el atributo protegido. Si el modelo aprende un proxy, el adversary puede recover el atributo, y el predictor es penalizado.
Resultado: modelo aprende representations que son fairness-aware, no solo decisions. Más robusto contra proxy discrimination.
Trade-off: adversarial training es technically harder. Requiere balancing dos networks, tuning λ, debugging convergence. Para teams sin expertise, pre-processing puede ser más práctico aunque menos efectivo.
3. ¿Por qué threshold tuning per group puede ser legalmente problemático?
Porque aplicar diferentes thresholds basándose en atributo protegido = "disparate treatment" — tratar individuos diferentemente por su grupo, lo cual es ilegal in US under Civil Rights Act, ECOA, FHA, etc.
Caso típico:
- Hiring system con threshold 0.5 para hombres, 0.4 para mujeres.
- Aún si la motivación es achieving demographic parity, en court → "applied lower bar to female applicants" = disparate treatment.
Excepciones:
- Court-approved affirmative action programs: requieren legal precedent.
- Disparate impact remediation: si court determina que single threshold causes disparate impact, court-ordered diferentiated thresholds may be allowed.
Pero por default, en US: thresholds uniformes son más defensibles. EU tiene similar logic bajo equal treatment principle.
Práctica común: usar thresholds tuneados para análisis interno (entender el trade-off), pero deploy con single threshold + accept that demographic parity won't be perfect.
Excepciones por industry:
- Marketing/advertising: thresholds per group más aceptable (no high-stakes).
- Healthcare: clinical thresholds per population legitimate (different base rates biologically).
4. ¿Por qué combinar pre + in + post processing es defense en profundidad?
Cada categoría aborda el bias en distinto stage:
Pre-processing mitiga bias en data input: balancea representation antes de training.
In-processing mitiga bias durante learning: modelo no aprende proxies sutiles.
Post-processing mitiga bias en decisions: corrige residual disparities en outputs.
Combinar:
- Pre-processing reduce input bias.
- In-processing previene que el modelo learning bias remaining.
- Post-processing cleanup any residual bias.
Total reduction de bias > cualquier categoría sola.
Defense en profundidad es término del security: si una capa falla, otra cubre. Mismo concepto aplica a fairness.
Trade-off: complexity. Combiar tres es más complex que una. Para sistemas chicos, una puede bastar. Para sistemas critical (loans, hiring at scale), combinar es prudent.
Resumen y siguiente paso
- In-processing: modificar training. Fairness regularization, adversarial debiasing. Más efectivo pero requiere control de modelo.
- Post-processing: modificar outputs. Threshold tuning, calibration, reject option. Aplicable a black-box, pero legalmente cuestionable en algunos casos.
- Combinar las tres categorías (pre + in + post) = defense en profundidad.
- Validar siempre: re-medir métricas post-mitigation, comparar accuracy impact, documentar trade-offs.
- Decisión depende de constraints: si black-box, post only. Si custom DL, combinar.
Checkpoint: deberías poder elegir technique apropiada según tus constraints (data access, model access, legal context).
Puente a la siguiente cápsula: cápsula 08 es el mini-proyecto: el Bias Audit Toolkit. Vas a integrar todo lo que aprendiste — métricas, detection, mitigation — en un toolkit reusable que podés aplicar a sistemas AI futuros. El entregable es un repo de Python con tests, scripts, y un README documentando proceso.
Recursos
- Adversarial Learning for Fair Classification (Zhang et al., 2018) — paper foundational adversarial debiasing.
- Fairlearn — ExponentiatedGradient — in-processing implementation.
- AIF360 — Calibrated Equalized Odds — post-processing.
- Equality of Opportunity Calibrator (Pleiss et al., 2017) — paper técnico.
Siguiente: 08-mini-proyecto-bias-audit-toolkit.md — Construir tu Bias Audit Toolkit reusable.
Cápsula 07 de 08 — Módulo 2 — AI Ethics & Compliance Guide