Module 2: Bias and Fairness — Detection, Measurement, Mitigation
7. Mitigation: In-processing and Post-processing
Capsule description
Pre-processing (capsule 06) modifies the training data. It works when the solution is "more balanced data." But sometimes that isn't enough:
- Structural bias lives in patterns that persist even with balanced data.
- You can't modify the training data (you're using an external API, or the dataset is legally fixed).
- The accuracy trade-off is too high with pre-processing alone.
This capsule covers the two remaining categories:
-
In-processing: modify the training algorithm. Adversarial debiasing, fairness constraints in the loss function. More complex but often more effective.
-
Post-processing: modify the trained model's outputs. Threshold tuning per group, calibration adjustment, a reject option. Applicable to any model (including black-box APIs).
You'll learn when to choose each category, how to implement them (with code), and how to combine them with pre-processing for defense in depth.
In-processing: modifying the training
The concept
Instead of balancing the data up front, add fairness to the loss function during training.
Traditional:
loss = error(predictions, ground_truth)
With a fairness constraint:
loss = error(predictions, ground_truth) + λ * fairness_violation(predictions, groups)
λ controls the trade-off: λ=0 = accuracy only. A high λ = more fairness, with a possible cost in accuracy.
Technique 1: Fairness regularization
The simplest idea: add a term to the loss that penalizes disparate predictions across groups.
import torch
import torch.nn as nn
class FairnessRegularizedLoss(nn.Module):
"""
Traditional loss + 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):
# Traditional loss
accuracy_loss = self.base_loss(predictions, targets)
# Fairness term: difference in mean predictions across groups
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
Technique 2: Adversarial Debiasing
A more sophisticated idea: train two models simultaneously:
- A predictor: predicts the target (e.g., loan approve).
- An adversary: tries to predict the protected attribute from the predictor's predictions.
The goal: the predictor must achieve high accuracy while the adversary can't recover the protected attribute. If the adversary can't predict gender from the predictor's output, then the output contains no information about gender (= unbiased).
class AdversarialDebiasing:
"""
A simplified scheme for 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(),
}
In-processing trade-offs
Pros:
- More effective than pre-processing in many cases.
- Direct control over the fairness/accuracy trade-off via λ.
- The model learns not to use proxy variables during training.
Cons:
- It requires a customizable model (doesn't work with black-box APIs).
- Training is more complex (especially adversarial: two networks, tricky balancing).
- Less interpretable: stakeholders ask "what did you do?" → the answer is "fairness regularization in the loss."
- Harder to debug if it doesn't converge.
When to choose in-processing
- You have complete control over the training pipeline.
- A deep learning model you can modify.
- Pre-processing alone isn't enough.
- You have the expertise/time to tune hyperparameters.
Post-processing: modifying the outputs
The concept
The model is already trained (it can be a black box). You modify the outputs before returning them as a decision.
The most popular: threshold tuning per group. Instead of a single threshold (e.g., approve if score > 0.5), use a different threshold for each group:
Group A: approve if score > 0.50
Group B: approve if score > 0.43
A lower threshold for group B compensates for miscalibration or disparate impact.
Technique 1: Threshold tuning
def find_fair_thresholds(scores, ground_truth, groups, target_metric='dp',
target_value=0.95):
"""
Find per-group thresholds that satisfy a target metric.
target_metric: 'dp' (demographic parity), 'eo' (equal opportunity).
target_value: e.g., 0.95 = a 95% match on the metric.
"""
df = pd.DataFrame({
'score': scores,
'true': ground_truth,
'group': groups,
})
# For each group, find the threshold that produces a similar TPR (for EO)
# or a similar positive rate (for DP).
if target_metric == 'dp':
# Demographic parity: equal positive rate
# Find the per-group threshold that gives the target positive rate
thresholds = {}
for group_name, group_df in df.groupby('group'):
# Default: the majority group's positive rate
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: equal TPR
target_tpr = 0.80 # configurable
thresholds = {}
for group_name, group_df in df.groupby('group'):
positives = group_df[group_df['true'] == 1]
# A threshold such that 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):
"""Apply per-group thresholds."""
decisions = []
for score, group in zip(scores, groups):
threshold = thresholds.get(group, 0.5) # default if group not in thresholds
decisions.append(1 if score > threshold else 0)
return decisions
Threshold tuning trade-offs
Pros:
- It applies to any model (black-box compatible).
- Simple to implement.
- Reversible: if it doesn't work, go back to the uniform threshold.
Cons:
- Legally questionable in some contexts: applying different thresholds by group can be "disparate treatment" (treating individuals differently based on a protected attribute), which is illegal in US hiring/lending.
- It requires demographic data in production (to apply the right threshold).
- It doesn't address the root cause: the model is still biased, it's just mitigated at decision time.
An important legal caveat
In the US, applying different thresholds by group can be illegal (disparate treatment). The exception: explicitly affirmative action programs that have been court-approved.
In the EU, the EU AI Act includes provisions on equal treatment. Check the specific regulation before implementing.
Solution: sometimes tuned thresholds are used internally for analysis, but the deploy uses a single threshold + accepts that demographic parity won't be perfect.
Technique 2: Calibration adjustment
If your model is miscalibrated across groups (capsule 03), adjust the scores post-hoc:
from sklearn.isotonic import IsotonicRegression
def calibrate_per_group(scores, ground_truth, groups):
"""
Apply isotonic regression per group for 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):
"""Apply per-group calibration to 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: each group has separately calibrated scores. "Score = 0.7" means a real 70% probability for every group.
Technique 3: Reject option
In marginal zones (scores near the threshold), reject and require human review instead of auto-deciding.
def reject_option(scores, low_threshold=0.4, high_threshold=0.6):
"""
Approve/deny only if the score is very high/low. Otherwise, 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
Useful when errors carry a high cost. Marginal cases get human review = more accurate decisions in those cases.
Cost: 20-30% of cases require human review. Operational overhead.
Combining the categories
In production-grade systems, you combine all three:
DATA → MODEL → OUTPUT → DECISION
↓ ↓ ↓
Pre- In- Post-
proc proc proc
Defense in depth
- Pre-processing: reduces the initial bias in the data.
- In-processing: the model learns not to use proxies.
- Post-processing: corrects residual bias in the outputs.
Each one covers what the others don't. Combined, they reduce bias significantly more than any one alone.
An example pipeline
def fair_pipeline(X_train, y_train, X_test, y_test, groups_train, groups_test):
"""
A complete pipeline with all the categories.
"""
# 1. Pre-processing: re-weighting
weights = compute_sample_weights_for_groups(y_train, groups_train)
# 2. In-processing: train with 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 with all the corrections
test_scores = model.predict_proba(X_test)
calibrated_scores = apply_calibration(test_scores, groups_test, calibrators)
return calibrated_scores
How to choose between categories
The decision tree
Do you have access to the training data?
├── No → POST-PROCESSING ONLY (threshold tuning, calibration)
│
└── Yes → Do you have expertise in customizing training?
├── No → PRE-PROCESSING (simplest)
│
└── Yes → Is pre-processing alone enough?
├── Yes → PRE-PROCESSING
│
└── No → Does the model allow it (deep learning, custom training)?
├── Yes → COMBINE PRE + IN-PROCESSING
│
└── No → COMBINE PRE + POST-PROCESSING
By practical scenario
Scenario 1: Using an OpenAI/Anthropic API (black-box):
- You can't modify the training.
- Pre-processing of inputs (prompt engineering): limited.
- Post-processing only: filter the outputs, threshold tuning if you're scoring, a reject option.
Scenario 2: A custom sklearn model:
- Pre-processing: re-weighting or re-sampling.
- Post-processing: threshold tuning if legally tolerable.
Scenario 3: Custom deep learning:
- Combine everything: pre-processing + adversarial debiasing + post-hoc calibration.
Scenario 4: Enterprise constraints (legal sensitivity):
- Pre-processing only (more defensible).
- Post-processing only if court-approved.
Validating the mitigation
For any technique:
- Apply it.
- Re-measure the metrics (DP, EO, calibration).
- Compare against the baseline (no mitigation).
- Evaluate the accuracy impact.
- Document the trade-offs.
def evaluate_mitigation(predictions_baseline, predictions_mitigated,
ground_truth, groups):
"""
Compare baseline vs mitigated across all the metrics.
"""
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 the full table in your Bias Audit. Stakeholders decide whether the trade-off is acceptable.
Traps and common mistakes
1. Applying a mitigation without measuring
"I applied adversarial debiasing, so now it's fair." Without re-measuring, you don't know.
2. Superficial λ tuning
Adversarial debiasing and fairness regularization have a λ hyperparameter. Tuning it correctly requires a sweep + validation. The default λ=1 is rarely optimal.
3. Threshold tuning without considering legality
US hiring + per-group threshold tuning = a potential disparate treatment claim. Check with legal first.
4. Calibration alone, expecting demographic parity
Calibration does not guarantee demographic parity (impossibility theorems). Each metric requires its own mitigation.
5. Not combining techniques
Pre alone is sometimes insufficient. In alone is sometimes insufficient. Combinations tend to be more robust.
Self-check
1. When is post-processing your only option?
When you do NOT have access to the training pipeline. Cases:
-
The model is an external API (OpenAI, Anthropic, Google): you can't modify the training or access the weights.
-
The model is a vendor product: you buy a trained model, you can't retrain it.
-
Compliance restrictions: some regulated industries don't allow modifying the training data or algorithms.
-
The cost of retraining is prohibitive: foundation models that cost millions to train.
In these cases, post-processing (threshold tuning, calibration adjustment, filtering, a reject option) is all you have.
Limitations: post-processing doesn't address the root cause. The model is still biased; you're only filtering the outputs. For serious systems, ideally migrate to a model you can modify.
2. Why can adversarial debiasing be more effective than pre-processing?
Pre-processing balances the data, but the model can still learn subtle proxies for the protected attribute (remember Apple Card).
Adversarial debiasing explicitly forces the model to produce outputs that contain no information about the protected attribute. If the model learns a proxy, the adversary can recover the attribute, and the predictor is penalized.
Result: the model learns representations that are fairness-aware, not just decisions. More robust against proxy discrimination.
Trade-off: adversarial training is technically harder. It requires balancing two networks, tuning λ, debugging convergence. For teams without the expertise, pre-processing may be more practical even if less effective.
3. Why can per-group threshold tuning be legally problematic?
Because applying different thresholds based on a protected attribute = "disparate treatment" — treating individuals differently because of their group, which is illegal in the US under the Civil Rights Act, ECOA, FHA, etc.
The typical case:
- A hiring system with a 0.5 threshold for men, 0.4 for women.
- Even if the motivation is achieving demographic parity, in court → "applied a lower bar to female applicants" = disparate treatment.
Exceptions:
- Court-approved affirmative action programs: they require legal precedent.
- Disparate impact remediation: if a court determines a single threshold causes disparate impact, court-ordered differentiated thresholds may be allowed.
But by default, in the US: uniform thresholds are more defensible. The EU has similar logic under the equal treatment principle.
A common practice: use tuned thresholds for internal analysis (to understand the trade-off), but deploy with a single threshold + accept that demographic parity won't be perfect.
Exceptions by industry:
- Marketing/advertising: per-group thresholds are more acceptable (not high-stakes).
- Healthcare: clinical thresholds per population are legitimate (biologically different base rates).
4. Why is combining pre + in + post processing defense in depth?
Each category addresses bias at a different stage:
Pre-processing mitigates bias in the input data: it balances representation before training.
In-processing mitigates bias during learning: the model doesn't learn subtle proxies.
Post-processing mitigates bias in the decisions: it corrects residual disparities in the outputs.
Combined:
- Pre-processing reduces input bias.
- In-processing prevents the model from learning the remaining bias.
- Post-processing cleans up any residual bias.
The total bias reduction > any single category alone.
Defense in depth is a term from security: if one layer fails, another covers. The same concept applies to fairness.
Trade-off: complexity. Combining three is more complex than one. For small systems, one may be enough. For critical systems (loans, hiring at scale), combining is prudent.
Summary and next step
- In-processing: modify the training. Fairness regularization, adversarial debiasing. More effective but requires control over the model.
- Post-processing: modify the outputs. Threshold tuning, calibration, reject option. Applicable to black boxes, but legally questionable in some cases.
- Combining all three categories (pre + in + post) = defense in depth.
- Always validate: re-measure the metrics post-mitigation, compare the accuracy impact, document the trade-offs.
- The decision depends on constraints: if it's a black box, post only. If it's custom DL, combine.
Checkpoint: you should be able to choose the right technique based on your constraints (data access, model access, legal context).
Bridge to the next capsule: capsule 08 is the mini-project: the Bias Audit Toolkit. You'll integrate everything you learned — metrics, detection, mitigation — into a reusable toolkit you can apply to future AI systems. The deliverable is a Python repo with tests, scripts, and a README documenting the process.
Resources
- Adversarial Learning for Fair Classification (Zhang et al., 2018) — the foundational adversarial debiasing paper.
- Fairlearn — ExponentiatedGradient — an in-processing implementation.
- AIF360 — Calibrated Equalized Odds — post-processing.
- Equality of Opportunity Calibrator (Pleiss et al., 2017) — the technical paper.
Next: 08-mini-project-bias-audit-toolkit.md — Build your reusable Bias Audit Toolkit.
Capsule 07 of 08 — Module 2 — AI Ethics & Compliance Guide