Módulo 2: Bias and Fairness — Detection, Measurement, Mitigation
4. Bias Detection: Slicing, A/B Testing y Counterfactual Testing
Descripción de la cápsula
Las cápsulas 02 y 03 te dieron las métricas: demographic parity, equalized odds, calibration. Ahora viene la pregunta práctica: ¿cómo aplicarlas a tu sistema en la realidad?
Esta cápsula cubre tres técnicas concretas para detectar bias en práctica:
- Slicing por subgrupo — desglosar predictions por atributos demográficos y comparar performance.
- A/B testing demográfico — comparar versiones del modelo desde perspectiva de cada grupo.
- Counterfactual testing — generar pares de inputs que difieren solo en atributo protegido y medir delta de outputs.
Las tres son complementarias. Slicing es lo más simple cuando tenés data demográfica. Counterfactual es la salvación cuando NO tenés data demográfica (común en producción). A/B testing demográfico es para validar mitigations.
Al terminar, vas a poder integrar bias detection como CI gate en tu pipeline, no solo como audit one-time.
Técnica 1: Slicing por subgrupo
Concepto
Tomá tu test set, separalo por atributo protegido, y calculá métricas por subset.
Test set total → 95% accuracy
Split por género:
Group M → 97% accuracy
Group F → 92% accuracy
Difference: 5 puntos.
5 puntos de diferencia entre grupos es señal de bias.
Por qué slicing detecta lo que accuracy global oculta
Accuracy global es ponderada: si grupo M es 80% del dataset y tiene 97%, mientras grupo F es 20% y tiene 92%, accuracy global = 0.96 (suena bien).
Pero la experiencia individual del usuario del grupo F es 92%, no 96%. Slicing expone esto.
Implementación básica
import pandas as pd
from sklearn.metrics import accuracy_score, precision_score, recall_score
def slice_by_subgroup(predictions, ground_truth, groups, attribute_name=''):
"""
Slicing de performance por subgrupo.
"""
df = pd.DataFrame({
'pred': predictions,
'true': ground_truth,
'group': groups,
})
results = {}
for group_name, group_df in df.groupby('group'):
results[group_name] = {
'n': len(group_df),
'accuracy': accuracy_score(group_df['true'], group_df['pred']),
'precision': precision_score(group_df['true'], group_df['pred'], zero_division=0),
'recall': recall_score(group_df['true'], group_df['pred'], zero_division=0),
'positive_rate': (group_df['pred'] == 1).mean(),
}
# Diferencias
accuracies = [r['accuracy'] for r in results.values()]
return {
'attribute': attribute_name,
'metrics_per_group': results,
'accuracy_difference': max(accuracies) - min(accuracies),
'concerning': max(accuracies) - min(accuracies) > 0.05,
}
Slicing intersectional
Single-attribute slicing puede ocultar problemas interseccionales:
# Métrica por género y por raza separadamente:
# - Hombres: 95% accuracy
# - Mujeres: 92% accuracy
# - Blancos: 95% accuracy
# - Negros: 92% accuracy
# Pero...
# - Hombres blancos: 97%
# - Mujeres negras: 84% ← problema OCULTO
Solution: slicing intersectional por combinaciones de atributos:
def slice_intersectional(df, attributes):
"""
Slicing por combinaciones de atributos.
Args:
df: DataFrame con 'pred', 'true', y los atributos.
attributes: lista de columnas a combinar (e.g., ['gender', 'race']).
"""
grouped = df.groupby(attributes)
results = {}
for combo, group_df in grouped:
if len(group_df) < 30:
continue # Skip subgrupos con sample insuficiente
results[combo] = {
'n': len(group_df),
'accuracy': accuracy_score(group_df['true'], group_df['pred']),
'positive_rate': (group_df['pred'] == 1).mean(),
}
return results
Trade-off: más dimensiones = más subgrupos = sample size por subgrupo decrece. Si tu dataset es chico, intersectional slicing puede no ser viable.
Visualización: heatmap
Para presentar resultados intersectionales, heatmap funciona bien:
import seaborn as sns
import matplotlib.pyplot as plt
def heatmap_subgroup_metric(df, row_attr, col_attr, metric='accuracy'):
"""
Heatmap de metric por combinación de atributos.
"""
pivot = df.groupby([row_attr, col_attr]).apply(
lambda x: accuracy_score(x['true'], x['pred'])
).unstack()
fig, ax = plt.subplots(figsize=(10, 6))
sns.heatmap(pivot, annot=True, fmt='.2%', cmap='RdYlGn', vmin=0.8, vmax=1.0)
ax.set_title(f'{metric} by {row_attr} × {col_attr}')
return fig
Output visual donde celdas rojas = subgrupos sub-servidos. Útil para presentar a stakeholders no-técnicos.
Técnica 2: A/B testing demográfico
Concepto
Una vez que hacés un cambio en el modelo (re-train, mitigation), querés saber: ¿mejoró fairness sin destruir accuracy?
A/B testing demográfico:
- Tomar predictions del modelo viejo (A) y nuevo (B) en el mismo test set.
- Calcular fairness metrics para cada uno.
- Comparar: ¿B es mejor en fairness? ¿Sin pérdida significativa en accuracy?
Implementación
def ab_test_fairness(predictions_a, predictions_b, ground_truth, groups,
fairness_metric_func):
"""
Compara dos versiones del modelo en fairness y accuracy.
Args:
predictions_a: predictions modelo viejo.
predictions_b: predictions modelo nuevo.
ground_truth: labels verdaderos.
groups: atributos protegidos.
fairness_metric_func: función que toma (pred, ground_truth, groups) y devuelve dict con métrica.
"""
metric_a = fairness_metric_func(predictions_a, ground_truth, groups)
metric_b = fairness_metric_func(predictions_b, ground_truth, groups)
accuracy_a = accuracy_score(ground_truth, predictions_a)
accuracy_b = accuracy_score(ground_truth, predictions_b)
return {
'model_a': {'metric': metric_a, 'accuracy': accuracy_a},
'model_b': {'metric': metric_b, 'accuracy': accuracy_b},
'fairness_improvement': (
metric_b.get('parity_ratio', 0) - metric_a.get('parity_ratio', 0)
),
'accuracy_change': accuracy_b - accuracy_a,
}
Decision matrix
| Δ Fairness | Δ Accuracy | Decisión |
|---|---|---|
| Mejora | Mejora | ✅ Deploy modelo B |
| Mejora | Sin cambio | ✅ Deploy modelo B |
| Mejora | Pequeña pérdida (<2%) | 🟡 Discusión: ¿vale el trade-off? |
| Mejora | Pérdida significativa (>5%) | 🔴 Investigar: hay solution con menos cost |
| Sin cambio | Cualquiera | ❌ La mitigation no funcionó |
| Empeora | Cualquiera | ❌ Revertir |
Importante: prueba en producción puede ser problemática
A/B testing tradicional involucra dar el modelo nuevo a un % de usuarios reales. Si el modelo nuevo tiene bias, ese % de usuarios reales sufre el bias.
Para fairness specifically, el A/B testing debe ser en test set offline, NO en producción real con usuarios. Después, si offline pasa, deploy gradualmente con monitoring estricto.
Técnica 3: Counterfactual testing
El problema que resuelve
Slicing requiere datos demográficos (género, raza, etc.) en tu test set. En muchos contextos:
- No los recolectás (no aplicas).
- Recolectarlos es ilegal (Europa).
- Los usuarios no quieren proveerlos.
- Son inferenciables solo con error.
¿Qué hacés sin datos demográficos? Counterfactual testing.
Concepto
Generá pares de inputs que difieren solo en marcadores asociados con atributo protegido. Pasá ambos por el modelo. Si los outputs difieren, hay bias causal.
Input A: "John Smith, captain of chess club"
Input B: "Mary Smith, captain of women's chess club"
Diferencia: marcadores de género.
Si el modelo da score muy distinto a A vs B, eso es bias causal.
Implementación
def counterfactual_test(model, inputs, gender_swap_function,
threshold=0.05):
"""
Test counterfactual de género (puede generalizarse a otros atributos).
Args:
model: callable que toma input y retorna score.
inputs: lista de inputs originales.
gender_swap_function: función que toma input y retorna versión con género swapped.
threshold: max delta aceptable.
Returns:
dict con resultados.
"""
results = []
for input_original in inputs:
score_original = model(input_original)
input_swapped = gender_swap_function(input_original)
score_swapped = model(input_swapped)
delta = abs(score_original - score_swapped)
results.append({
'original': input_original[:80],
'swapped': input_swapped[:80],
'score_original': score_original,
'score_swapped': score_swapped,
'delta': delta,
'fair': delta < threshold,
})
df = pd.DataFrame(results)
return {
'all_results': results,
'mean_delta': df['delta'].mean(),
'max_delta': df['delta'].max(),
'pct_fair': df['fair'].mean(),
'examples_unfair': df[~df['fair']].head(5).to_dict('records'),
}
Implementar gender_swap_function
Esto es lo difícil. Para texto en inglés:
def swap_gender_markers(text):
"""
Swap simple de marcadores de género en texto inglés.
Imperfecto pero útil para detection.
"""
swaps = {
'he': 'she', 'she': 'he',
'his': 'her', 'her': 'his',
'him': 'her', 'mr.': 'ms.',
'mr ': 'ms ', 'man': 'woman', 'woman': 'man',
'men': 'women', 'women': 'men',
'boy': 'girl', 'girl': 'boy',
'father': 'mother', 'mother': 'father',
'son': 'daughter', 'daughter': 'son',
'husband': 'wife', 'wife': 'husband',
}
# Simple substitution; in production usar NLP library
result = text
for original, replacement in swaps.items():
result = result.replace(original, replacement)
# Más sophisticated: nombres usar list de gendered names
# ...
return result
Limitations: simple string swap es imperfecto. Para production:
- Usar NLP library (spaCy, NLTK) para handling correcto de tokens.
- Lista curada de nombres gendered.
- Handling de pronombres en distintos languages.
- Considerar context (un cargo "captain of chess team" no debería swap "team").
Counterfactual para otros atributos
Mismo principio:
- Race: swap names asociados con grupos raciales (ojo: error-prone, hacer con cuidado).
- Age: cambiar fechas en CV ("graduated 1985" → "graduated 2015").
- Disability: agregar/quitar mention de disability.
Cada caso requiere domain knowledge para swap correctly.
Por qué counterfactual es poderoso
- No requiere demographic data en test set.
- Causal: si el delta es alto, es causa directa del marcador swapped (no proxy via otro feature).
- Integrable en CI: corre rapido, no requiere humanos.
- Funciona con cualquier modelo: black-box agnostic.
Integración en CI/CD
Combiná las tres técnicas en un test de fairness automatizado:
# tests/test_fairness.py
import pytest
from your_model import model
from your_test_data import test_set, counterfactual_inputs
def test_demographic_parity():
"""Bloqueante: deploy fails si demographic parity < 0.80."""
predictions = model.predict(test_set['features'])
for attribute in ['gender', 'race', 'age_bracket']:
if attribute not in test_set.columns:
continue
result = demographic_parity(predictions, test_set[attribute])
assert result['meets_4_5_rule'], (
f"DP violation for {attribute}: ratio={result['parity_ratio']:.3f}"
)
def test_equalized_odds():
"""Warning si difference > 5%."""
predictions = model.predict(test_set['features'])
truth = test_set['ground_truth']
for attribute in ['gender', 'race']:
if attribute not in test_set.columns:
continue
result = equalized_odds(predictions, truth, test_set[attribute])
assert result['meets_threshold_5pct'], (
f"EO violation for {attribute}: "
f"TPR diff={result['tpr_difference']:.3f}, "
f"FPR diff={result['fpr_difference']:.3f}"
)
def test_counterfactual_fairness():
"""Counterfactual: max delta debe ser <= 0.10."""
result = counterfactual_test(
model=model,
inputs=counterfactual_inputs,
gender_swap_function=swap_gender_markers,
)
assert result['max_delta'] < 0.10, (
f"Counterfactual delta too high: {result['max_delta']:.3f}. "
f"Examples: {result['examples_unfair'][:3]}"
)
En tu CI:
# .github/workflows/ci.yml
- name: Fairness tests
run: pytest tests/test_fairness.py -v
Si fairness regresiona, deploy falla. Si pasa, continúa.
Monitoring en producción
Tests offline son necesarios pero no suficientes. Modelo en producción puede driftear. Monitoring continuo:
# monitoring/fairness_monitor.py
def monitor_fairness_in_production(predictions_prod, demographic_data_prod):
"""
Calcula fairness metrics en data de producción reciente.
Alerts si métrica degrada.
"""
metric = demographic_parity(predictions_prod, demographic_data_prod)
log_to_dashboard({
'timestamp': now(),
'demographic_parity_ratio': metric['parity_ratio'],
'rates_by_group': metric['rates_by_group'],
})
# Alert si baja del threshold
if metric['parity_ratio'] < 0.80:
send_alert(
severity='HIGH',
message=f"Demographic parity dropped to {metric['parity_ratio']:.3f}",
)
# Alert también si decay desde baseline
baseline = get_baseline_parity_ratio()
if metric['parity_ratio'] < baseline - 0.05:
send_alert(
severity='MEDIUM',
message=f"Parity ratio decayed: {baseline:.3f} → {metric['parity_ratio']:.3f}",
)
Run weekly o más frecuente. Dashboard accesible para stakeholders.
Trampas y errores comunes
1. Slicing solo por single attribute
Ocultaba problemas interseccionales (mujeres negras en facial recognition). Hacé intersectional slicing siempre que data lo permita.
2. Sample sizes insuficientes
Subgrupo con 5 samples = métrica unstable. Filtra subgrupos con n < 30.
3. Counterfactual swap incompleto
Swap solo "he/she" pierde nombres, otras references. Usar NLP library para completeness.
4. Tests fairness no en CI
Si bias testing es manual one-time, regression es certain. CI es no negociable.
5. Olvidar monitoring post-deploy
Tests offline ≠ realidad de producción. Drift requiere monitoring continuo.
Auto-verificación
1. ¿Cuándo usás counterfactual testing en lugar de slicing?
Cuando NO tenés datos demográficos en tu test set. Razones comunes:
- No los recolectás (no se requiere).
- Legal restriction (Europa).
- Usuarios no proveen.
Counterfactual testing no requiere demographic data: generás pares de inputs swapping marcadores y medís delta de outputs. Si delta significativo, hay bias causal.
También counterfactual es causal mientras slicing es observational — counterfactual identifies the marcador como cause, slicing solo identifica la correlation.
Idealmente usás ambas: slicing donde tenés data, counterfactual where no o complementario.
2. ¿Por qué slicing intersectional puede ocultar problemas con sample insuficiente?
Más combinaciones de atributos = más subgrupos = menor sample por subgrupo.
Si tenés 1,000 samples y splitearas por 4 atributos con 3 valores cada uno, terminás con 81 subgrupos. Promedio 12 samples por subgrupo. Métricas con n=12 son muy variables y no informativas.
Trade-off:
- Pocos atributos: más sample por subgrupo, pero ocultás problems interseccionales.
- Muchos atributos: detectás interseccionales, pero métrica unstable por low n.
Solution práctica:
- Empezar con single-attribute slicing.
- Para atributos donde encontrás bias, hacer intersectional con esos.
- Reportar n por subgrupo siempre. Skip subgrupos con n < 30.
3. ¿Cómo integrarías bias testing en CI sin demographic data?
Counterfactual testing es la respuesta:
- Crear test set de inputs representativos de tu domain.
- Para cada input, generar versión "swapped" en marcadores demográficos.
- Medir delta de outputs entre original y swapped.
- Threshold: delta < X (ej., 0.05).
- Test en CI: pasa si todos los pares satisfacen threshold.
# tests/test_fairness.py
def test_counterfactual_gender_fairness():
inputs = load_test_inputs()
for input_original in inputs:
score_original = model.predict(input_original)
input_swapped = swap_gender(input_original)
score_swapped = model.predict(input_swapped)
delta = abs(score_original - score_swapped)
assert delta < 0.05, f"Delta {delta} too high"
No requiere demographic data en test set (los inputs no necesitan tener atributo protegido annotated). Solo requiere función de swap correcta.
Limitations: solo detecta bias causal por marcadores explícitos. No detecta proxies sutiles que slicing detecta (si tenés data).
4. ¿Por qué fairness debe monitorearse en producción y no solo offline?
Tres tipos de drift afectan a fairness:
-
Data drift: distribución de inputs cambia. Por ejemplo, demografía de aplicantes cambia, contexto evoluciona.
-
Concept drift: lo que significa "good outcome" cambia. Por ejemplo, regulation cambia, social norms cambian.
-
Model drift: si modelo se re-entrena con production data, errores compounding pueden introducir bias.
Cualquiera puede causar que un modelo que pasaba fairness offline empiece a violar fairness en producción.
Monitoring continuous con dashboards + alerts es defense en profundidad:
- Tests offline (CI) = preventiva, before deploy.
- Monitoring producción = detectiva, durante uso.
Combinadas, reducen probabilidad de bias issues en práctica significativamente.
Resumen y siguiente paso
- Slicing por subgrupo desglosa performance, expone gaps que accuracy global oculta. Usá intersectional cuando data lo permite.
- A/B testing demográfico valida que mitigations mejoran fairness sin destroying accuracy. Offline antes de production.
- Counterfactual testing detecta bias causal sin requerir demographic data en test set. Salvavidas en producción.
- Integrá en CI: tests bloqueantes + warnings.
- Monitor en producción: drift detection con dashboards + alerts.
Checkpoint: deberías poder elegir y aplicar la técnica correcta según tu situation (con/sin demographic data, offline/online, single/intersectional).
Puente a la siguiente cápsula: la cápsula 05 cubre los impossibility theorems — la verdad incómoda de que no podés satisfacer todas las métricas simultaneamente. Vas a aprender Chouldechova (2017) y Kleinberg-Mullainathan-Raghavan (2017), entender por qué COMPAS expuso este conflicto, y cómo elegir conscientemente cuál métrica priorizar según context.
Recursos
- What-If Tool — Google — slicing visual interactivo.
- Aequitas — Bias audit toolkit — open source.
- Counterfactual Fairness (Kusner et al., 2017) — paper foundational.
- Slice Finder (Chung et al., 2019) — automatización de slicing.
Siguiente: 05-impossibility-theorems-tradeoffs.md — Por qué no podés satisfacer todas las métricas simultaneamente.
Cápsula 04 de 08 — Módulo 2 — AI Ethics & Compliance Guide