Loading learning content...
Consider a loan approval system at two banks:
Bank A uses demographic parity—approving equal percentages of applicants from each demographic group. But this means some unqualified applicants from underrepresented groups get approved while qualified applicants from overrepresented groups are rejected.
Bank B uses a different criterion—among applicants who would actually repay their loans, approval rates are equal across groups. Qualified applicants have equal chances regardless of group membership.
Bank B implements Equality of Opportunity—a fairness criterion introduced by Hardt, Price, and Srebro (2016) that has become foundational in modern ML fairness. It captures an intuitive notion: people with the same true qualifications should have the same chance of receiving favorable outcomes.
By the end of this page, you will master the formal definition of equality of opportunity and its relationship to equalized odds, understand when and why to use this criterion, implement practical methods for achieving it, and analyze its advantages and limitations.
Equality of Opportunity requires that the True Positive Rate (TPR) is equal across protected groups.
Mathematical Definition:
$$P(\hat{Y}=1 | Y=1, A=0) = P(\hat{Y}=1 | Y=1, A=1)$$
Where:
In Plain Language:
Among individuals who truly deserve a positive outcome (Y=1), the probability of receiving that positive prediction (Ŷ=1) should be identical regardless of group membership.
Key Insight:
Equality of opportunity is a relaxation of equalized odds. While equalized odds requires both equal TPR and equal FPR across groups, equality of opportunity focuses only on TPR—the "opportunity" for qualified individuals.
| Criterion | Requirement | Focus | Use Case |
|---|---|---|---|
| Demographic Parity | P(Ŷ=1|A=0) = P(Ŷ=1|A=1) | Equal selection rates overall | Anti-classification contexts |
| Equality of Opportunity | P(Ŷ=1|Y=1,A=0) = P(Ŷ=1|Y=1,A=1) | Equal TPR (opportunities for qualified) | Merit-based selection |
| Equalized Odds | TPR and FPR equal across groups | Equal TPR AND equal FPR | Error balance for all |
| Predictive Equality | P(Ŷ=1|Y=0,A=0) = P(Ŷ=1|Y=0,A=1) | Equal FPR only | Protecting negatives from false alarms |
In many contexts, the positive outcome represents a beneficial opportunity (job, loan, admission). Equality of opportunity ensures qualified individuals aren't denied these opportunities based on group membership. It's about 'deserving' individuals getting what they 'deserve.'
There are several approaches to achieving equality of opportunity, from post-hoc threshold adjustment to in-processing constraints.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
import numpy as npfrom scipy.optimize import minimize_scalarfrom typing import Dict, Tuple class EqualityOfOpportunityClassifier: """ Wrapper that adjusts thresholds to achieve equality of opportunity. """ def __init__(self, base_model, protected_attr_idx=None): """ Args: base_model: Fitted model with predict_proba method protected_attr_idx: Index of protected attribute in features """ self.base_model = base_model self.protected_attr_idx = protected_attr_idx self.thresholds = {} def fit_thresholds(self, X, y, protected_attr): """ Find optimal thresholds per group to equalize TPR. Args: X: Features y: True labels protected_attr: Protected attribute values """ probas = self.base_model.predict_proba(X)[:, 1] protected_attr = np.array(protected_attr) y = np.array(y) groups = np.unique(protected_attr) # Find TPRs at various thresholds for each group def compute_tpr(threshold, probas, y): preds = (probas >= threshold).astype(int) positives = y == 1 if positives.sum() == 0: return 0.0 return preds[positives].mean() # Target: average TPR across groups at default threshold default_threshold = 0.5 target_tpr = np.mean([ compute_tpr(default_threshold, probas[protected_attr == g], y[protected_attr == g]) for g in groups ]) # Find threshold for each group that achieves target TPR for g in groups: mask = protected_attr == g g_probas = probas[mask] g_y = y[mask] def objective(threshold): tpr = compute_tpr(threshold, g_probas, g_y) return (tpr - target_tpr) ** 2 result = minimize_scalar(objective, bounds=(0.01, 0.99), method='bounded') self.thresholds[g] = result.x return self def predict(self, X, protected_attr): """Predict using group-specific thresholds.""" probas = self.base_model.predict_proba(X)[:, 1] protected_attr = np.array(protected_attr) predictions = np.zeros(len(X), dtype=int) for g, threshold in self.thresholds.items(): mask = protected_attr == g predictions[mask] = (probas[mask] >= threshold).astype(int) return predictions def evaluate(self, X, y, protected_attr) -> Dict: """Evaluate equality of opportunity satisfaction.""" predictions = self.predict(X, protected_attr) protected_attr = np.array(protected_attr) y = np.array(y) results = {'groups': {}, 'thresholds': self.thresholds} for g in np.unique(protected_attr): mask = protected_attr == g g_preds = predictions[mask] g_y = y[mask] positives = g_y == 1 tpr = g_preds[positives].mean() if positives.sum() > 0 else 0 negatives = g_y == 0 fpr = g_preds[negatives].mean() if negatives.sum() > 0 else 0 accuracy = (g_preds == g_y).mean() results['groups'][g] = { 'tpr': tpr, 'fpr': fpr, 'accuracy': accuracy, 'n_positive': positives.sum(), 'n_negative': negatives.sum() } # Calculate TPR disparity tprs = [r['tpr'] for r in results['groups'].values()] results['tpr_disparity'] = max(tprs) - min(tprs) results['equality_of_opportunity_satisfied'] = results['tpr_disparity'] < 0.05 return results def equality_of_opportunity_loss(y_true, y_pred_proba, protected_attr, lambda_fairness=1.0): """ Combined loss function for training with EO constraint. Loss = CrossEntropy + lambda * EO_violation Args: y_true: Ground truth labels y_pred_proba: Predicted probabilities protected_attr: Protected attribute values lambda_fairness: Fairness penalty weight Returns: Combined loss value """ import numpy as np y_true = np.array(y_true) y_pred_proba = np.clip(np.array(y_pred_proba), 1e-7, 1-1e-7) protected_attr = np.array(protected_attr) # Cross-entropy loss ce_loss = -np.mean( y_true * np.log(y_pred_proba) + (1 - y_true) * np.log(1 - y_pred_proba) ) # Equality of opportunity violation groups = np.unique(protected_attr) group_tprs = [] for g in groups: mask = (protected_attr == g) & (y_true == 1) if mask.sum() > 0: group_tprs.append(y_pred_proba[mask].mean()) if len(group_tprs) >= 2: eo_violation = np.var(group_tprs) else: eo_violation = 0.0 return ce_loss + lambda_fairness * eo_violation # Example usageif __name__ == "__main__": from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split np.random.seed(42) n = 2000 # Generate data with group-based differences protected = np.random.binomial(1, 0.4, n) x1 = np.random.randn(n) + 0.5 * protected x2 = np.random.randn(n) X = np.column_stack([x1, x2]) # True outcome (qualification) y = (x1 + x2 + np.random.randn(n) * 0.5 > 0).astype(int) X_train, X_test, y_train, y_test, prot_train, prot_test = train_test_split(X, y, protected, test_size=0.3, random_state=42) # Train base model base_model = LogisticRegression() base_model.fit(X_train, y_train) # Apply equality of opportunity adjustment eo_classifier = EqualityOfOpportunityClassifier(base_model) eo_classifier.fit_thresholds(X_train, y_train, prot_train) # Evaluate results = eo_classifier.evaluate(X_test, y_test, prot_test) print("Thresholds:", results['thresholds']) print("TPR Disparity:", f"{results['tpr_disparity']:.4f}") print("EO Satisfied:", results['equality_of_opportunity_satisfied']) for g, metrics in results['groups'].items(): print(f"Group {g}: TPR={metrics['tpr']:.3f}, FPR={metrics['fpr']:.3f}")Equality of opportunity is particularly appropriate in certain contexts but inappropriate in others. Understanding when to apply it is crucial for responsible ML practice.
EO assumes ground truth labels Y are unbiased. But if historical data reflects discrimination (biased hiring decisions, racially-biased arrests), equalizing TPR with respect to biased labels perpetuates that bias. Always audit your labels before applying EO.
No fairness criterion is perfect. Understanding the tradeoffs of equality of opportunity helps practitioners make informed decisions.
The FPR Problem:
When equalizing TPR leads to different FPRs, consider who bears the cost:
This is why equalized odds (constraining both TPR and FPR) is sometimes preferred, despite being harder to achieve.
Equality of opportunity has been applied across various domains. Here are notable examples and considerations:
| Domain | Positive Outcome | Ground Truth | Considerations |
|---|---|---|---|
| Hiring | Job offer | Job performance (future) | Must validate that performance metrics aren't biased |
| Lending | Loan approval | Loan repayment | Repayment affected by loan terms; circularity risk |
| College Admissions | Admission | Academic success / graduation | Success affected by campus resources; confounded |
| Medical Diagnosis | Positive diagnosis | Disease presence | Different disease prevalence may justify different TPRs |
| Recidivism Prediction | Predicted low-risk | Actual non-recidivism | Recidivism affected by parole conditions; label bias |
Before applying EO: (1) Validate that labels aren't biased, (2) Confirm that equalizing TPR is the right goal, (3) Analyze FPR impact on different groups, (4) Consider whether post-hoc thresholds or in-processing is more appropriate, (5) Document tradeoffs for stakeholders.
You now understand equality of opportunity as a fairness criterion. The next page explores the broader landscape of Fairness Metrics—quantitative measures for evaluating and comparing fairness across different dimensions.