TL;DR
Real bias testing uses three numbers: demographic parity, equalized odds, calibration. Run them across protected attributes (race, gender, age) and intersections. Wire the checks into CI so a biased model can't ship. Code below.
Most companies do bias testing wrong. They hire an auditor, get a 50-page report on their commitment to fairness, then ship the next model without re-checking.
That's theater. Real bias testing is continuous, quantitative, and part of deploy. It's boring. It's unglamorous. It actually keeps biased models out of production.
Why the usual approach misses
The usual sequence: build a model. Someone says "we should check for bias." A data scientist spends a week on metrics. They notice the model works worse for women than men. Then what?
If you catch bias after deployment, you've already harmed people. Your hiring algorithm rejected hundreds of qualified women. Your lending model approved loans for men who couldn't afford them. Your content moderation system suppressed voices from one demographic.
Bias testing only works if it's part of your gating criteria for deployment. If bias is "nice to check" instead of "must pass, " your model will ship biased.
The solution is to make bias testing part of your CI/CD, just like you gate on accuracy. If a model fails bias checks, it doesn't ship. Period.
The Three Fairness Metrics You Actually Need
There are dozens of fairness definitions in academic literature. Here are the three that matter for production systems:
1. Demographic Parity (Selection Rate)
Do different groups get selected/approved/recommended at similar rates?
Example: Your hiring model approves 60% of male candidates but only 40% of female candidates. That's demographic disparity, and it's bad even if the model is technically "accurate."
Measure it this way:
interface BiasTestData {
predictions: Array<{
actual: boolean;
predicted: boolean;
protectedAttribute: string; // "M", "F", etc.
}>;
}
function calculateDemographicParity(data: BiasTestData) {
const byGroup = {};
for (const item of data.predictions) {
if (!byGroup[item.protectedAttribute]) {
byGroup[item.protectedAttribute] = {
total: 0,
positive: 0
};
}
byGroup[item.protectedAttribute].total++;
if (item.predicted) {
byGroup[item.protectedAttribute].positive++;
}
}
const selectionRates = {};
for (const [group, counts] of Object.entries(byGroup)) {
selectionRates[group] =
counts.positive / counts.total;
}
// Calculate disparity ratio: minority rate / majority rate
const rates = Object.values(selectionRates).sort((a, b) => a - b);
const disparityRatio = rates[0] / rates[rates.length - 1];
return {
selectionRates,
disparityRatio,
pass: disparityRatio >= 0.80 // 80% rule
};
}
// Example: If men are approved 60% and women 40%,
// disparity ratio = 0.40 / 0.60 = 0.67
// This FAILS the 80% rule and should block deploymentThe "80% rule" is a practical threshold: if one group is selected at less than 80% the rate of another group, you have a documented disparity.
2. Equalized Odds (False Positive & False Negative Rates)
For high-stakes decisions, it's not enough to have equal selection rates. You need equal accuracy across groups. Specifically: the same false positive rate and false negative rate regardless of protected attributes.
Example: Your loan approval model correctly identifies 90% of creditworthy men but only 70% of creditworthy women. Even if overall selection rates are equal, women are being systematically misjudged.
interface ConfusionMatrixByGroup {
[group: string]: {
TP: number; // True positives
FP: number; // False positives
TN: number; // True negatives
FN: number; // False negatives
};
}
function calculateEqualizedOdds(data: BiasTestData) {
const byGroup: ConfusionMatrixByGroup = {};
for (const item of data.predictions) {
if (!byGroup[item.protectedAttribute]) {
byGroup[item.protectedAttribute] = { TP: 0, FP: 0, TN: 0, FN: 0 };
}
const matrix = byGroup[item.protectedAttribute];
if (item.actual && item.predicted) matrix.TP++;
else if (!item.actual && item.predicted) matrix.FP++;
else if (item.actual && !item.predicted) matrix.FN++;
else matrix.TN++;
}
// Calculate FPR and FNR for each group
const ratesByGroup = {};
for (const [group, matrix] of Object.entries(byGroup)) {
const fpr = matrix.FP / (matrix.FP + matrix.TN); // False positive rate
const fnr = matrix.FN / (matrix.FN + matrix.TP); // False negative rate
const tpr = matrix.TP / (matrix.TP + matrix.FN); // True positive rate (recall)
ratesByGroup[group] = { fpr, fnr, tpr };
}
// Check if rates are roughly equal across groups
const fprValues = Object.values(ratesByGroup).map((r: any) => r.fpr);
const fprMax = Math.max(...fprValues);
const fprMin = Math.min(...fprValues);
return {
ratesByGroup,
fprDisparity: fprMax / (fprMin || 0.001),
pass: (fprMax - fprMin) < 0.05 // Threshold: <5% difference
};
}
// High-stakes use case: hiring, lending, bail decisions
// Equalized odds is the right metric3. Calibration (Predicted = Actual)
When your model says something has 80% confidence, is it actually right 80% of the time? Calibration checks this, separately for each demographic group.
Example: Your recommendation system is 85% confident about recommendations for men and right 85% of the time. But for women, it's 85% confident and only right 60% of the time. It's overconfident for women.
function calculateCalibration(
data: BiasTestData,
confidenceScores: number[]
) {
const byGroup: Record = {};
for (let i = 0; i < data.predictions.length; i++) {
const item = data.predictions[i];
const confidence = confidenceScores[i];
if (!byGroup[item.protectedAttribute]) {
byGroup[item.protectedAttribute] = { predictions: [], confidences: [] };
}
byGroup[item.protectedAttribute].predictions.push(item);
byGroup[item.protectedAttribute].confidences.push(confidence);
}
// For each group, check if predicted confidence matches actual accuracy
const calibrationByGroup = {};
for (const [group, {predictions, confidences}] of Object.entries(byGroup)) {
// Bin predictions by confidence level (e.g., 0-10%, 10-20%, etc)
const bins: Record = {};
for (let i = 0; i < predictions.length; i++) {
const binKey = Math.floor(confidences[i] * 10) * 10; // 0,10,20, etc
if (!bins[binKey]) bins[binKey] = { count: 0, correct: 0 };
bins[binKey].count++;
if (predictions[i].predicted === predictions[i].actual) {
bins[binKey].correct++;
}
}
// Calculate expected calibration error (ECE)
let ece = 0;
for (const [confLevel, {count, correct}] of Object.entries(bins)) {
const confidence = parseInt(confLevel) / 100;
const accuracy = correct / count;
ece += Math.abs(confidence - accuracy) * (count / predictions.length);
}
calibrationByGroup[group] = { ece, calibrated: ece < 0.05 };
}
return calibrationByGroup;
}
// Calibration is crucial for high-stakes decisions
// Users need to trust the confidence scores Intersectional Bias: The Hidden Killer
Here's where most bias testing falls apart: bias isn't just about single attributes. A woman of color might experience worse outcomes than women generally or people of color generally.
You need to test intersectional combinations:
interface IntersectionalTest {
race: string;
gender: string;
age: string;
predictions: Array<{ actual: boolean; predicted: boolean }>;
}
function testIntersectionalBias(data: IntersectionalTest[]) {
const groupStats: Record = {};
// Create intersectional groups
for (const item of data) {
const groupKey = `${item.race}_${item.gender}_${item.age}`;
if (!groupStats[groupKey]) {
groupStats[groupKey] = { total: 0, correct: 0, accuracy: 0 };
}
groupStats[groupKey].total++;
if (item.predictions[0].actual === item.predictions[0].predicted) {
groupStats[groupKey].correct++;
}
}
// Calculate accuracy for each group
for (const group of Object.values(groupStats)) {
group.accuracy = group.correct / group.total;
}
// Find worst-performing intersectional group
const sorted = Object.entries(groupStats)
.sort(([, a], [, b]) => a.accuracy - b.accuracy);
return {
allGroups: groupStats,
worstPerformingGroup: sorted[0],
bestPerformingGroup: sorted[sorted.length - 1],
worstBestGap: sorted[sorted.length - 1][1].accuracy - sorted[0][1].accuracy
};
}
// A woman of color might have 65% accuracy while men have 80%
// But women generally have 75% and people of color have 74%
// Intersectional testing catches this Building Bias Testing Into Your CI/CD
Here's what non-negotiable bias testing in CI/CD looks like:
name: Bias Testing Gate
on:
push:
branches: [main]
jobs:
bias-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run bias evaluation
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
python scripts/run_bias_tests.py \
--model models/current.gguf \
--dataset data/bias_test_dataset.json \
--output bias_results.json
- name: Check demographic parity
run: |
python scripts/check_demographic_parity.py \
--results bias_results.json \
--min-disparity-ratio 0.80
- name: Check equalized odds
run: |
python scripts/check_equalized_odds.py \
--results bias_results.json \
--max-fpr-difference 0.05 \
--max-fnr-difference 0.05
- name: Check intersectional bias
run: |
python scripts/check_intersectional_bias.py \
--results bias_results.json \
--max-accuracy-gap 0.10
- name: Fail build if any check fails
run: |
python scripts/aggregate_bias_results.py \
--results bias_results.json \
--fail-if-any-fail true
The threshold you set depends on your use case. For hiring, lending, and bail decisions, be strict (80% rule for demographic parity, <5% difference for equalized odds). For lower-stakes applications like product recommendations, you can be more lenient.
Building Your Bias Test Dataset
Bias testing is only as good as your test dataset. You need diverse examples that will expose biases:
- Stratified sampling: Include enough examples from each demographic group (at least 100 per group)
- Domain representation: Test cases should represent real-world distribution of inputs
- Adversarial examples: Include edge cases where bias is most likely to emerge
- Multiple protected attributes: Test across race, gender, age, location, disability status, whatever's relevant
Don't use historical data to build your bias test dataset. Historical data is biased by definition. Synthetic data generation or crowd-sourced labeling works better.
What To Do When You Find Bias
Finding bias is actually the easy part. Here's what to do:
Document it. Record which groups are affected, how large the disparity is, and which protected attributes matter.
Don't just retrain. Retraining often just shifts bias to a different group. Root cause analysis matters: is the training data biased? Is the task itself asking the model to discriminate? Are features correlated with protected attributes?
Consider fairness interventions: Balanced sampling during training, fairness constraints in the loss function, post-processing corrections. Each has tradeoffs.
Monitor continuously. Bias can emerge in production as the user base changes. Keep testing.
Make it a business decision. Some bias is acceptable if mitigated responsibly. Some bias is unacceptable. That's not a data science decision; it's a product decision. Involve leadership.
Your 90-Day Bias Testing Roadmap
- Identify your protected attributes and fairness definition (demographic parity for product features, equalized odds for high-stakes decisions)
- Build a diverse bias test dataset (300-1000 examples, stratified across groups)
- Measure baseline fairness metrics for your current model
- Identify the worst-performing demographic groups
- Add bias checking to your CI/CD pipeline
- Set thresholds and make bias failures block deployment
- Document your fairness approach in a public model card
You won't fix all bias in 90 days. The goal is to establish continuous monitoring and make bias visible as a quantity you track and improve.
Stop Shipping Biased Models
Bias testing is tedious, but it's non-negotiable. alt.qa automates bias measurement across multiple fairness definitions and makes it part of your deployment checklist.
Start measuring bias