Algorithmic Fairness in R: fairml & aif360 for Bias Auditing
Algorithmic fairness ensures that machine learning models don't systematically discriminate against protected groups. This guide teaches you to measure, audit, and improve fairness using R tools — because a model that's accurate on average can still be unfair to specific groups.
A hiring model that rejects 80% of female applicants but only 30% of male applicants is unfair — even if its overall accuracy is high. A credit scoring model that gives higher rates to minorities with the same creditworthiness as non-minorities is unfair. These aren't hypothetical: they've happened in real deployments. This guide gives you the tools to catch and fix these problems.
Fairness Definitions
There are multiple definitions of fairness, and they can conflict with each other. Understanding them is essential for choosing the right one for your context.
Definition
Meaning
Formula
Demographic parity
Equal selection rates across groups
P(Y=1\
A=a) = P(Y=1\
A=b)
Equalized odds
Equal TPR and FPR across groups
P(Yhat=1\
Y=y,A=a) = P(Yhat=1\
Y=y,A=b)
Equal opportunity
Equal TPR across groups
P(Yhat=1\
Y=1,A=a) = P(Yhat=1\
Y=1,A=b)
Calibration
Same meaning of scores across groups
P(Y=1\
Score=s,A=a) = P(Y=1\
Score=s,A=b)
Predictive parity
Equal precision across groups
P(Y=1\
Yhat=1,A=a) = P(Y=1\
Yhat=1,A=b)
Individual fairness
Similar individuals treated similarly
d(x,x') small implies d(f(x),f(x')) small
The Impossibility Theorem
A critical result: except in trivial cases, you cannot simultaneously satisfy demographic parity, equalized odds, and calibration. You must choose which fairness criterion matters most for your application.
# Demonstrating the fairness trade-off
set.seed(42)
n <- 1000
# Simulate two groups with different base rates
group <- rep(c("A","B"), each = n/2)
base_rate <- ifelse(group == "A", 0.4, 0.2) # Different base rates
true_label <- rbinom(n, 1, base_rate)
# A perfectly calibrated model
score <- true_label + rnorm(n, 0, 0.3)
predicted <- as.integer(score > 0.5)
cat("=== Fairness Trade-off Demo ===\n")
cat("Base rates differ between groups:\n")
cat(" Group A base rate:", mean(true_label[group == "A"]), "\n")
cat(" Group B base rate:", mean(true_label[group == "B"]), "\n")
cat("\nSelection rates (demographic parity check):\n")
cat(" Group A:", mean(predicted[group == "A"]), "\n")
cat(" Group B:", mean(predicted[group == "B"]), "\n")
cat("\nTrue Positive Rates (equal opportunity check):\n")
tpr_a <- mean(predicted[group == "A" & true_label == 1])
tpr_b <- mean(predicted[group == "B" & true_label == 1])
cat(" Group A TPR:", round(tpr_a, 3), "\n")
cat(" Group B TPR:", round(tpr_b, 3), "\n")
cat("\nEqualizing selection rates would break calibration.\n")
cat("Equalizing TPR would change selection rates.\n")
cat("You must choose which fairness criterion to prioritize.\n")
Measuring Disparate Impact
The four-fifths rule: the selection rate for any protected group should be at least 80% of the rate for the most-selected group.
# Step 5 example: threshold tuning for fairness
set.seed(42)
n <- 500
df <- data.frame(
group = rep(c("A","B"), each = n/2),
score = c(rnorm(n/2, 0.6, 0.2), rnorm(n/2, 0.5, 0.2)),
actual = c(rbinom(n/2, 1, 0.6), rbinom(n/2, 1, 0.4))
)
# Single threshold: may create disparate impact
single_threshold <- 0.5
df$pred_single <- as.integer(df$score > single_threshold)
# Group-specific thresholds: equalize selection rates
target_rate <- mean(df$actual)
thresh_a <- quantile(df$score[df$group == "A"], 1 - target_rate)
thresh_b <- quantile(df$score[df$group == "B"], 1 - target_rate)
df$pred_adjusted <- ifelse(df$group == "A",
as.integer(df$score > thresh_a),
as.integer(df$score > thresh_b))
cat("=== Threshold Tuning ===\n")
cat("Single threshold (0.5):\n")
cat(" Group A rate:", mean(df$pred_single[df$group == "A"]), "\n")
cat(" Group B rate:", mean(df$pred_single[df$group == "B"]), "\n")
cat("\nAdjusted thresholds:\n")
cat(" Group A threshold:", round(thresh_a, 3), "-> rate:",
mean(df$pred_adjusted[df$group == "A"]), "\n")
cat(" Group B threshold:", round(thresh_b, 3), "-> rate:",
mean(df$pred_adjusted[df$group == "B"]), "\n")
Summary
Fairness Criterion
Best For
Trade-off
Demographic parity
Employment, lending
May select less qualified from one group
Equalized odds
Criminal justice, medical
Harder to achieve with different base rates
Equal opportunity
Scholarship, hiring
Only equalizes true positive rate
Calibration
Risk assessment, insurance
Doesn't guarantee equal rates
Individual fairness
Any
Hard to define similarity metric
FAQ
Which fairness metric should I use? It depends on context. For hiring: demographic parity or disparate impact. For criminal risk: equalized odds (equal TPR and FPR). For medical diagnostics: equal opportunity (equal sensitivity). Discuss with stakeholders and domain experts — this is not a purely technical decision.
Can I just remove the protected attribute from the model? No. This is "fairness through unawareness" and it doesn't work because other features (zip code, name patterns, school attended) can serve as proxies. You need to test outcomes by protected group regardless.
Is there a legal requirement for algorithmic fairness? Increasingly, yes. The EU AI Act classifies hiring and credit models as "high-risk" requiring bias audits. US agencies (EEOC, CFPB) use disparate impact analysis. Several US states have enacted algorithmic accountability laws. The legal landscape is evolving rapidly.