Ethics & Best Practices
Responsible AI development, bias mitigation, and security.
With Great Power Comes Great Responsibility
AI systems make decisions that affect people's lives: loan approvals, job applications, medical diagnoses, legal sentencing, and more. As engineers building these systems, we have a responsibility to ensure they're fair, transparent, secure, and beneficial. This isn't just about avoiding lawsuits or bad PR, it's about building technology that makes the world better, not worse.
You Can't Outsource Ethics to the AI
The AI doesn't decide what's right or wrong. You do. Every design choice, every dataset decision, every deployment, these are ethical choices made by humans.
Understanding Bias in AI Systems
AI systems learn patterns from data. If the data reflects historical biases, the AI will learn and amplify those biases. This isn't a hypothetical problem, it's happening right now.
How Bias Enters and Compounds
Figure 1: The feedback edge is what makes this urgent. A biased model's own decisions become tomorrow's training data, so the bias compounds instead of washing out.
Documented Case: Resume Screening
The Problem: Amazon built an experimental recruiting engine that scored applicants by learning from a decade of resumes the company had already received.
The Bias: Most of those resumes came from men, so the model learned that male-associated signals predicted a hire. It downgraded resumes containing the word "women's" and graduates of two all-women colleges. Gender was never an input: the model reconstructed it from the language of the document.
Result: Amazon could not convince itself the model was neutral on other, subtler proxies, and scrapped the project in 2017. Reported by Reuters, 2018.
Documented Case: Benefits Risk Scoring
The Problem: The Dutch tax administration used a self-learning risk model to decide which childcare benefit claims to investigate for fraud.
The Bias: Dual nationality and non-Dutch nationality fed the risk score. Families were selected for investigation on the basis of where they came from, and a flag triggered repayment demands for the full benefit with no proportionality and almost no route to appeal.
Result: Roughly 26,000 families were wrongly accused, many pushed into debt. The Dutch data protection authority fined the tax administration EUR 2.75 million in 2021 for discriminatory and unlawful processing, and the government resigned over the scandal in January 2021.
Sources of Bias in AI Systems
1. Historical Bias
Data reflects past inequalities and discrimination. Training on this data perpetuates these biases.
Example: Medical data where certain conditions were under-diagnosed in women leads to AI that's less accurate for female patients.
2. Representation Bias
Training data doesn't represent the full population the system will serve.
Example: Facial recognition trained mostly on lighter-skinned faces performs worse on darker skin tones.
3. Measurement Bias
The way we measure or label things introduces bias.
Example: Using arrest records as "ground truth" for crime prediction, even though arrests are biased by policing practices.
4. Aggregation Bias
One-size-fits-all models ignore important differences between groups.
Example: Health monitoring apps calibrated for average adults don't work well for children, elderly, or pregnant women.
Detecting and Measuring Bias
You can't fix bias if you can't measure it. Here are practical techniques to detect bias in your AI systems.
# bias_detection.py - Tools for detecting bias in ML models
import numpy as np
import pandas as pd
from sklearn.metrics import accuracy_score, confusion_matrix
class BiasDetector:
"""Detect and measure bias in model predictions."""
def __init__(self, protected_attributes: list[str]):
"""
Initialize bias detector.
Args:
protected_attributes: List of attributes to check for bias
(e.g., ['gender', 'race', 'age_group'])
"""
self.protected_attributes = protected_attributes
def check_demographic_parity(
self,
predictions: np.ndarray | pd.Series,
protected_attribute: pd.Series,
favorable_outcome: int = 1
) -> dict:
"""
Check if the favorable prediction rate is similar across groups.
Demographic parity: P(Y=favorable|A=a) should be similar for all A
Args:
predictions: Model predictions
protected_attribute: Protected attribute values (gender, race, etc.)
favorable_outcome: The label that benefits the person, not simply
the label encoded as 1. The four-fifths rule is defined on the
rate of getting the good outcome, so if 1 means "predicted to
default" then the favorable outcome is 0.
Returns:
Dictionary with rates per group and disparity metrics
"""
df = pd.DataFrame({
'prediction': predictions,
'group': protected_attribute
})
# Calculate favorable rate per group. int()/float() keep the report
# JSON serialisable: numpy scalars raise TypeError in json.dumps.
rates = {}
for group in df['group'].unique():
group_mask = df['group'] == group
favorable_rate = (df[group_mask]['prediction'] == favorable_outcome).mean()
rates[group] = {
'favorable_rate': float(favorable_rate),
'count': int(group_mask.sum())
}
# Calculate disparities
favorable_rates = [v['favorable_rate'] for v in rates.values()]
max_rate = max(favorable_rates)
min_rate = min(favorable_rates)
disparity_ratio = min_rate / max_rate if max_rate > 0 else 0
disparity_difference = max_rate - min_rate
return {
'rates_by_group': rates,
'disparity_ratio': disparity_ratio, # < 0.8 often considered problematic
'disparity_difference': disparity_difference,
'passes_80_percent_rule': disparity_ratio >= 0.8
}
def check_equalized_odds(
self,
y_true: np.ndarray | pd.Series,
y_pred: np.ndarray | pd.Series,
protected_attribute: pd.Series
) -> dict:
"""
Check if TPR and FPR are similar across groups.
Equalized odds: TPR and FPR should be equal across protected groups
Args:
y_true: True labels
y_pred: Predicted labels
protected_attribute: Protected attribute values
Returns:
TPR and FPR per group with disparity metrics
"""
df = pd.DataFrame({
'y_true': y_true,
'y_pred': y_pred,
'group': protected_attribute
})
metrics = {}
for group in df['group'].unique():
group_mask = df['group'] == group
group_true = df[group_mask]['y_true']
group_pred = df[group_mask]['y_pred']
# Calculate confusion matrix
tn, fp, fn, tp = confusion_matrix(
group_true,
group_pred,
labels=[0, 1]
).ravel()
# Calculate rates
tpr = tp / (tp + fn) if (tp + fn) > 0 else 0 # True Positive Rate
fpr = fp / (fp + tn) if (fp + tn) > 0 else 0 # False Positive Rate
tnr = tn / (tn + fp) if (tn + fp) > 0 else 0 # True Negative Rate
fnr = fn / (fn + tp) if (fn + tp) > 0 else 0 # False Negative Rate
metrics[group] = {
'tpr': float(tpr),
'fpr': float(fpr),
'tnr': float(tnr),
'fnr': float(fnr),
'accuracy': float(accuracy_score(group_true, group_pred)),
'count': int(group_mask.sum())
}
# Calculate disparities
tprs = [v['tpr'] for v in metrics.values()]
fprs = [v['fpr'] for v in metrics.values()]
tpr_disparity = max(tprs) - min(tprs)
fpr_disparity = max(fprs) - min(fprs)
return {
'metrics_by_group': metrics,
'tpr_disparity': tpr_disparity,
'fpr_disparity': fpr_disparity,
'satisfies_equalized_odds': tpr_disparity < 0.1 and fpr_disparity < 0.1
}
def generate_bias_report(
self,
y_true: np.ndarray | pd.Series,
y_pred: np.ndarray | pd.Series,
data: pd.DataFrame,
favorable_outcome: int = 1
) -> dict:
"""
Generate comprehensive bias report for all protected attributes.
Args:
y_true: True labels
y_pred: Predicted labels
data: DataFrame with protected attributes
favorable_outcome: The label that benefits the person
Returns:
Complete bias analysis report, JSON serialisable
"""
report = {
'timestamp': pd.Timestamp.now().isoformat(),
'total_samples': int(len(y_true)),
'favorable_outcome': favorable_outcome,
'attributes': {}
}
for attr in self.protected_attributes:
if attr not in data.columns:
continue
print(f"\nAnalyzing bias for: {attr}")
# Demographic parity
parity = self.check_demographic_parity(
y_pred,
data[attr],
favorable_outcome=favorable_outcome
)
# Equalized odds
odds = self.check_equalized_odds(
y_true,
y_pred,
data[attr]
)
report['attributes'][attr] = {
'demographic_parity': parity,
'equalized_odds': odds,
'bias_detected': (
not parity['passes_80_percent_rule'] or
not odds['satisfies_equalized_odds']
)
}
# Print summary
if report['attributes'][attr]['bias_detected']:
print(f" ⚠️ BIAS DETECTED in {attr}")
if not parity['passes_80_percent_rule']:
print(f" - Demographic parity: {parity['disparity_ratio']:.2f} (< 0.8)")
if not odds['satisfies_equalized_odds']:
print(f" - TPR disparity: {odds['tpr_disparity']:.3f}")
print(f" - FPR disparity: {odds['fpr_disparity']:.3f}")
else:
print(f" ✓ No significant bias detected in {attr}")
return report
# Example usage
if __name__ == "__main__":
# Simulate a biased loan approval model
rng = np.random.default_rng(42)
# Generate synthetic data
n_samples = 1000
data = pd.DataFrame({
'gender': rng.choice(['M', 'F'], n_samples),
'race': rng.choice(['White', 'Black', 'Asian', 'Hispanic'], n_samples),
'age': rng.integers(18, 80, n_samples),
'income': rng.integers(20000, 150000, n_samples),
'credit_score': rng.integers(300, 850, n_samples)
})
# True labels (loan default: 0=no default, 1=default)
y_true = (data['credit_score'] < 600).astype(int)
# Biased predictions (model discriminates by gender)
y_pred = y_true.copy()
# Introduce bias: more false positives for females
female_mask = data['gender'] == 'F'
bias_indices = female_mask & (y_true == 0)
flip_count = int(bias_indices.sum() * 0.3) # 30% false positives for females
flip_positions = rng.choice(
np.where(bias_indices)[0],
size=flip_count,
replace=False
)
# .iloc because np.where returns positions, not index labels. The two
# coincide here only because the frame has a default RangeIndex.
y_pred.iloc[flip_positions] = 1
# Detect bias
detector = BiasDetector(protected_attributes=['gender', 'race'])
# 1 means "predicted to default", which is the outcome that gets the loan
# refused. The favorable outcome is therefore 0, and that is the rate the
# four-fifths rule has to be computed on.
report = detector.generate_bias_report(
y_true=y_true,
y_pred=y_pred,
data=data,
favorable_outcome=0
)
print("\n" + "="*60)
print("BIAS DETECTION REPORT")
print("="*60)
for attr, results in report['attributes'].items():
print(f"\n{attr.upper()}:")
print(f" Demographic Parity Ratio: {results['demographic_parity']['disparity_ratio']:.3f}")
print(f" Passes 80% Rule: {results['demographic_parity']['passes_80_percent_rule']}")
print(f" TPR Disparity: {results['equalized_odds']['tpr_disparity']:.3f}")
print(f" FPR Disparity: {results['equalized_odds']['fpr_disparity']:.3f}")Expected Output:
Analyzing bias for: gender
⚠️ BIAS DETECTED in gender
- Demographic parity: 0.71 (< 0.8)
- TPR disparity: 0.000
- FPR disparity: 0.298
Analyzing bias for: race
✓ No significant bias detected in race
============================================================
BIAS DETECTION REPORT
============================================================
GENDER:
Demographic Parity Ratio: 0.706
Passes 80% Rule: False
TPR Disparity: 0.000
FPR Disparity: 0.298
RACE:
Demographic Parity Ratio: 0.838
Passes 80% Rule: True
TPR Disparity: 0.000
FPR Disparity: 0.051Read the two metrics together. Gender fails on both counts: the approval rate for women is 0.706 of the rate for men, under the 0.8 threshold, and their false positive rate is 29.8 points higher. Race, which the simulation leaves untouched, passes both. The TPR disparity is 0.000 everywhere because of how the bias was injected: only rows wherey_true == 0 were flipped, so no genuine defaulter was ever misclassified and the true positive rate stays at 1.0 for every group. That is the useful lesson. A model can be perfect on one half of equalized odds and still discriminate through the other, so checking TPR alone would have declared this model fair.
- Demographic Parity: Favorable outcome rates should be similar across groups, which is not always the rate of predicting 1 (four-fifths rule: min/max ratio ≥ 0.8)
- Equalized Odds: True positive rate and false positive rate should be equal across groups
- Equal Opportunity: True positive rate should be equal across groups (subset of equalized odds)
- Predictive Parity: Precision should be equal across groups
You Cannot Have All of Them
Those four definitions are not a menu you can order everything from. Whenever the base rate of the outcome genuinely differs between two groups, and the model is anything short of a perfect predictor, calibration, equalized odds and demographic parity cannot all hold at once. This is a proved result, not a limitation of any particular algorithm: it was established independently by Kleinberg, Mullainathan and Raghavan and by Chouldechova in 2016. Satisfy one and you are, as a matter of arithmetic, violating another.
The practical consequence is that "make the model fair" is not a specification. Someone has to decide which definition the system is accountable to, and that choice follows from what the errors cost real people. A screening tool where a false negative means a missed cancer is not the same problem as a lending model where a false positive means a denied mortgage, and they do not deserve the same metric. Write the decision down with its reasoning, because it is a policy commitment your organisation will be asked to defend, not an implementation detail.
Mitigating Bias
Once you've detected bias, here are practical techniques to reduce it.
Three Places You Can Intervene
Figure 2: The earlier you intervene, the more the fix generalises. Post-processing is the easiest to retrofit and the easiest to argue about.
1. Pre-processing: Fix the Data
Address bias before training by balancing datasets and removing problematic correlations.
# Technique 1: Balance representation
import numpy as np
import pandas as pd
from scipy.stats import chi2_contingency
from sklearn.utils import resample
from sklearn.utils.class_weight import compute_sample_weight
def balance_dataset(df, protected_attr, target_col, random_state=42):
"""
Downsample every (group, label) cell to the size of the smallest one.
Balancing on the protected attribute alone can leave the label skewed
inside a group, so the cells are (protected_attr, target_col) pairs.
"""
cells = df.groupby([protected_attr, target_col], observed=True)
min_size = cells.size().min()
# replace=False matters: resample() defaults to replace=True, which turns a
# downsample into a bootstrap and drops distinct rows from the very group
# the balancing exists to protect.
balanced_dfs = [
resample(cell_df, n_samples=min_size, replace=False, random_state=random_state)
for _, cell_df in cells
]
return pd.concat(balanced_dfs, ignore_index=True)
# Technique 2: Remove biased correlations
def _as_categorical(s, bins=10, max_levels=20):
"""
Bin a numeric column so it can be crosstabbed against a categorical one.
Only high-cardinality columns are binned. Running qcut on a 0/1 flag
collapses it to a single bin, and a one-column table scores 0, so a
perfect binary proxy would be graded as unrelated.
"""
if pd.api.types.is_numeric_dtype(s) and s.nunique() > max_levels:
return pd.qcut(s, q=bins, duplicates='drop')
return s
def cramers_v(a, b):
"""
Association between two columns on a 0-1 scale.
Pearson correlation is undefined for categories, so the chi-squared
statistic is normalised by the sample size and by the smaller dimension
of the contingency table.
"""
table = pd.crosstab(_as_categorical(a), _as_categorical(b))
if min(table.shape) < 2:
return 0.0
chi2 = chi2_contingency(table).statistic
n = table.to_numpy().sum()
return float(np.sqrt(chi2 / (n * (min(table.shape) - 1))))
def remove_proxy_features(df, protected_attrs, target_col=None, threshold=0.7):
"""
Drop the protected attributes and every feature that stands in for one.
Example: ZIP code often correlates with race, income
Both halves are needed. Dropping 'race' on its own leaves a ZIP code that
is determined by race to carry the same information back in, and dropping
the proxy while keeping the attribute is not a fairness measure at all.
target_col is excluded from the scan. Left in, the label is itself a proxy
candidate, so a label that correlates with a protected attribute, which is
the exact situation under investigation, would be silently deleted.
Numeric pairs use Pearson correlation; any pair involving a categorical
column uses Cramer's V. pd.api.types.is_numeric_dtype is the check that
holds across pandas versions: on pandas 3 a plain text column is
StringDtype, so a dtype == 'object' test silently misses it and .corr()
then raises trying to cast 'M' to a float.
"""
skip = set(protected_attrs) | ({target_col} if target_col else set())
proxy_features = []
for col in df.columns:
if col in skip:
continue
for protected in protected_attrs:
if (pd.api.types.is_numeric_dtype(df[col])
and pd.api.types.is_numeric_dtype(df[protected])):
strength = abs(df[col].corr(df[protected]))
else:
strength = cramers_v(df[col], df[protected])
if strength > threshold:
proxy_features.append(col)
break # one match is enough, and it keeps the list unique
print(f"Identified proxy features: {proxy_features}")
return df.drop(columns=[*protected_attrs, *proxy_features])
# Technique 3: Reweighting samples
def compute_fair_weights(y, protected_attr):
"""
Compute sample weights to balance groups.
Gives higher weight to underrepresented groups.
"""
# Combine target and protected attribute
combined = y.astype(str) + "_" + protected_attr.astype(str)
weights = compute_sample_weight('balanced', combined)
return weights
# Use in training:
# model.fit(X, y, sample_weight=compute_fair_weights(y, df['gender']))
# Example usage
if __name__ == "__main__":
rng = np.random.default_rng(42)
n_samples = 1000
race = rng.choice(['White', 'Black', 'Asian', 'Hispanic'], n_samples)
# ZIP is stored as text and is almost fully determined by race. This is the
# classic proxy: drop 'race' from the model and the ZIP column carries it
# straight back in.
zip_by_race = {'White': '90210', 'Black': '10001',
'Asian': '60601', 'Hispanic': '73301'}
zip_code = np.array([zip_by_race[r] for r in race])
df = pd.DataFrame({
'gender': rng.choice(['M', 'F'], n_samples, p=[0.8, 0.2]),
'race': race,
'zip_code': zip_code,
'income': rng.integers(20_000, 150_000, n_samples),
'approved': rng.integers(0, 2, n_samples),
})
print("Association with 'race':")
for col in ['zip_code', 'income', 'approved']:
print(f" {col:10s} Cramer's V = {cramers_v(df[col], df['race']):.3f}")
print()
print(f"Before: {df.shape[1]} columns {list(df.columns)}")
clean = remove_proxy_features(df, ['gender', 'race'], target_col='approved')
print(f"After: {clean.shape[1]} columns {list(clean.columns)}")
print("\nGroup sizes before balancing:")
print(df.groupby(['gender', 'approved']).size().to_string())
balanced = balance_dataset(df, 'gender', 'approved')
print("\nGroup sizes after balancing:")
print(balanced.groupby(['gender', 'approved']).size().to_string())
weights = compute_fair_weights(df['approved'], df['gender'])
print("\nMean sample weight by group:")
print(pd.DataFrame({'gender': df['gender'], 'weight': weights})
.groupby('gender')['weight'].mean().round(3).to_string())Expected Output:
Association with 'race':
zip_code Cramer's V = 1.000
income Cramer's V = 0.110
approved Cramer's V = 0.069
Before: 5 columns ['gender', 'race', 'zip_code', 'income', 'approved']
Identified proxy features: ['zip_code']
After: 2 columns ['income', 'approved']
Group sizes before balancing:
gender approved
F 0 106
1 108
M 0 418
1 368
Group sizes after balancing:
gender approved
F 0 106
1 106
M 0 106
1 106
Mean sample weight by group:
gender
F 2.336
M 0.636Five columns go in and two come out. gender and race are dropped because they are the protected attributes, and zip_code is dropped because it scores a Cramer's V of 1.000 against race. That second removal is the point: deleting race on its own would have accomplished nothing, since a ZIP code fully determined by race carries the same information back in through the front door. Income (0.110) and the approval label (0.069) survive.
Two details are worth pausing on. Measuring this needs Cramer's V rather than a correlation, because Pearson correlation is undefined between two categorical columns. And the label has to be excluded from the scan by name: it is a column like any other, so a target that correlates with a protected attribute, which is precisely the case you are investigating, would otherwise be quietly dropped along with the proxies.
2. In-processing: Fair Training
Modify the training process to explicitly optimize for fairness.
import numpy as np
from sklearn.base import BaseEstimator, ClassifierMixin
from sklearn.linear_model import LogisticRegression
class FairClassifier(ClassifierMixin, BaseEstimator):
"""
Wrap a classifier and train it toward demographic parity.
In-processing changes the training loop itself. Each round refits the base
model with new sample weights: the group that gets selected too often has
its positive examples down-weighted and the other group's are up-weighted,
until the selection rates converge.
ClassifierMixin is listed before BaseEstimator, and the order is load
bearing. Since scikit-learn 1.6 the estimator type is resolved through
__sklearn_tags__, and BaseEstimator's implementation wins the MRO when it
comes first, so is_classifier() returns False and cross-validation silently
falls back to KFold instead of StratifiedKFold.
"""
def __init__(self, base_model, tolerance=0.05, max_rounds=30, step=0.2):
self.base_model = base_model
self.tolerance = tolerance
self.max_rounds = max_rounds
self.step = step
def fit(self, X, y, sensitive_features=None):
"""
Train under a demographic parity target.
sensitive_features is a keyword with a default, matching fairlearn, so
that clone() and get_params() keep working. That is all it buys:
cross_val_score still fails here, because it calls fit(X, y) and this
raises. Feeding a third array through cross-validation needs
scikit-learn metadata routing (set_fit_request), not a signature tweak.
"""
if sensitive_features is None:
raise ValueError("sensitive_features is required")
X, y = np.asarray(X), np.asarray(y)
groups = np.asarray(sensitive_features)
self.classes_ = np.unique(y)
weights = np.ones(len(y), dtype=float)
self.history_ = []
for _ in range(self.max_rounds):
self.base_model.fit(X, y, sample_weight=weights)
predictions = self.base_model.predict(X)
rates = {g: predictions[groups == g].mean() for g in np.unique(groups)}
over = max(rates, key=rates.get)
under = min(rates, key=rates.get)
gap = rates[over] - rates[under]
self.history_.append(gap)
if gap < self.tolerance:
break
# Move weight off the favored group's positives and onto the other's.
# Only positives are touched: demographic parity is about who gets
# selected, so the negatives carry no signal about the gap.
weights = np.where((groups == over) & (y == 1),
weights * (1 - self.step), weights)
weights = np.where((groups == under) & (y == 1),
weights / (1 - self.step), weights)
self.sample_weight_ = weights
return self
def predict(self, X):
return self.base_model.predict(X)
def parity_gap(predictions, groups):
"""Largest difference in selection rate between any two groups."""
rates = [predictions[groups == g].mean() for g in np.unique(groups)]
return max(rates) - min(rates)
if __name__ == "__main__":
from sklearn.base import is_classifier
rng = np.random.default_rng(42)
n_samples = 4000
# Group membership genuinely shifts the label, so an unconstrained fit is
# accurate and unfair at the same time. That is the tension being managed.
group = rng.integers(0, 2, n_samples)
features = rng.normal(size=(n_samples, 3))
y = ((features[:, 0] + 1.5 * group
+ rng.normal(scale=0.5, size=n_samples)) > 0).astype(int)
X = np.column_stack([features, group])
baseline = LogisticRegression(max_iter=1000).fit(X, y)
baseline_pred = baseline.predict(X)
print(f"Unconstrained: gap={parity_gap(baseline_pred, group):.4f} "
f"accuracy={(baseline_pred == y).mean():.4f}")
fair = FairClassifier(LogisticRegression(max_iter=1000), tolerance=0.05)
print(f"\nis_classifier(fair) = {is_classifier(fair)}")
fair.fit(X, y, sensitive_features=group)
print(f"Reweighting rounds: {len(fair.history_)}")
for round_number, gap in enumerate(fair.history_):
if round_number % 3 == 0 or round_number == len(fair.history_) - 1:
print(f" round {round_number:2d}: gap={gap:.4f}")
fair_pred = fair.predict(X)
print(f"\nConstrained: gap={parity_gap(fair_pred, group):.4f} "
f"accuracy={(fair_pred == y).mean():.4f}")Expected Output:
Unconstrained: gap=0.4143 accuracy=0.8912 is_classifier(fair) = True Reweighting rounds: 12 round 0: gap=0.4143 round 3: gap=0.3065 round 6: gap=0.2041 round 9: gap=0.0956 round 11: gap=0.0327 Constrained: gap=0.0327 accuracy=0.8053
Twelve rounds take the selection gap from 0.414 to 0.033, and accuracy falls from 0.891 to 0.805. That drop is not a bug to tune away. The label in this data genuinely depends on group membership, so the unconstrained model is accurate precisely because it is unfair, and any real parity constraint has to give some accuracy back. Deciding how much is a policy question, not a modelling one.
Hand-rolling the loop shows the mechanism, but production work should use a library that solves the constrained problem properly. Fairlearn reduces fairness-constrained classification to a sequence of reweighted problems and returns an ensemble with guarantees:
# pip install fairlearn==0.14.0
import numpy as np
from fairlearn.metrics import demographic_parity_difference
from fairlearn.reductions import DemographicParity, ExponentiatedGradient
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split
rng = np.random.default_rng(42)
n_samples = 4000
group = rng.integers(0, 2, n_samples)
features = rng.normal(size=(n_samples, 3))
y = ((features[:, 0] + 1.5 * group
+ rng.normal(scale=0.5, size=n_samples)) > 0).astype(int)
X = np.column_stack([features, group])
X_train, X_test, y_train, y_test, g_train, g_test = train_test_split(
X, y, group, test_size=0.3, random_state=42
)
baseline = LogisticRegression(max_iter=1000).fit(X_train, y_train)
# ExponentiatedGradient solves the constrained problem properly instead of
# nudging weights: it returns a weighted ensemble of classifiers.
fair_model = ExponentiatedGradient(
estimator=LogisticRegression(max_iter=1000),
constraints=DemographicParity()
)
fair_model.fit(X_train, y_train, sensitive_features=g_train)
# predict() picks a predictor from that ensemble per row, so it is randomized
# by design. Pass random_state or the numbers move between runs.
results = {
"unconstrained": baseline.predict(X_test),
"DemographicParity": fair_model.predict(X_test, random_state=42),
}
for name, predictions in results.items():
dp = demographic_parity_difference(
y_test, predictions, sensitive_features=g_test
)
print(f"{name:18s} accuracy={accuracy_score(y_test, predictions):.3f} "
f"dp_difference={dp:.3f}")Expected Output:
unconstrained accuracy=0.896 dp_difference=0.399 DemographicParity accuracy=0.817 dp_difference=0.068
3. Post-processing: Adjust Predictions
Adjust model outputs after training to achieve fairness goals.
import numpy as np
def calibrate_by_group(model, X, y_true, protected_attr, target_fpr=0.1):
"""
Pick a decision threshold per group so every group lands on the same
false positive rate.
A false positive can only come from a true negative, so the threshold is
read off the negatives alone and the labels are required. Thresholding on
every score in the group instead controls the selection rate, which is
demographic parity: a different criterion that leaves FPR untouched.
Args:
model: Trained model with predict_proba
X: Features
y_true: True labels, needed to identify the negatives
protected_attr: Protected attribute values
target_fpr: False positive rate to hold every group to
Returns:
(predictions, thresholds), where thresholds maps group -> cutoff
"""
probabilities = model.predict_proba(X)[:, 1]
predictions = np.zeros(len(X), dtype=int)
thresholds = {}
for group in np.unique(protected_attr):
group_mask = protected_attr == group
negatives = group_mask & (y_true == 0)
# Cut at the score that leaves target_fpr of the negatives above it.
thresholds[group] = float(
np.quantile(probabilities[negatives], 1 - target_fpr)
)
predictions[group_mask] = (
probabilities[group_mask] >= thresholds[group]
).astype(int)
return predictions, thresholds
def false_positive_rates(predictions, y_true, protected_attr):
"""FPR per group: of the true negatives, how many were flagged."""
return {
group: predictions[(protected_attr == group) & (y_true == 0)].mean()
for group in np.unique(protected_attr)
}
if __name__ == "__main__":
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
rng = np.random.default_rng(42)
n_samples = 20000
group = rng.integers(0, 2, n_samples)
features = rng.normal(size=(n_samples, 3))
y = ((features[:, 0] + 1.5 * group
+ rng.normal(scale=0.5, size=n_samples)) > 0).astype(int)
X = np.column_stack([features, group])
# One half calibrates the thresholds, the other half never informs them.
X_cal, X_test, y_cal, y_test, g_cal, g_test = train_test_split(
X, y, group, test_size=0.5, random_state=42
)
model = LogisticRegression(max_iter=1000).fit(X_cal, y_cal)
calibrated, thresholds = calibrate_by_group(
model, X_cal, y_cal, g_cal, target_fpr=0.1
)
print("Per-group thresholds:")
for group_value, threshold in thresholds.items():
print(f" group {group_value}: {threshold:.4f}")
print("\nFPR on the calibration half:")
for group_value, rate in false_positive_rates(calibrated, y_cal, g_cal).items():
print(f" group {group_value}: {rate:.3f}")
# Apply the same thresholds to data they were not fitted on.
probabilities = model.predict_proba(X_test)[:, 1]
single = (probabilities >= 0.5).astype(int)
per_group = np.zeros(len(X_test), dtype=int)
for group_value, threshold in thresholds.items():
mask = g_test == group_value
per_group[mask] = (probabilities[mask] >= threshold).astype(int)
print("\nFPR on held-out data:")
for label, predictions in [("single 0.5 threshold", single),
("per-group thresholds", per_group)]:
rates = false_positive_rates(predictions, y_test, g_test)
gap = max(rates.values()) - min(rates.values())
detail = " ".join(f"group {k}={v:.3f}" for k, v in rates.items())
print(f" {label:22s} {detail} gap={gap:.3f}")Expected Output:
Per-group thresholds: group 0: 0.6041 group 1: 0.9030 FPR on the calibration half: group 0: 0.100 group 1: 0.101 FPR on held-out data: single 0.5 threshold group 0=0.154 group 1=0.461 gap=0.307 per-group thresholds group 0=0.109 group 1=0.069 gap=0.040
One shared cutoff of 0.5 gives group 1 a false positive rate of 0.461 against group 0's 0.154: nearly three times as many innocent people flagged. Per-group cutoffs of 0.6041 and 0.9030 close that to 0.040. Note also that the thresholds hit the 0.1 target exactly on the half they were fitted on and drift to 0.109 and 0.069 on data they never saw. That drift is why thresholds have to be calibrated on a held-out split and then monitored, not fitted once on the training set and trusted.
Equalizing one rate is the easy case. Equalized odds asks for TPR and FPR to match simultaneously, which needs a solver rather than a quantile. Fairlearn provides one that wraps any already-trained model:
# pip install fairlearn==0.14.0
import numpy as np
from fairlearn.metrics import equalized_odds_difference
from fairlearn.postprocessing import ThresholdOptimizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split
rng = np.random.default_rng(42)
n_samples = 20000
group = rng.integers(0, 2, n_samples)
features = rng.normal(size=(n_samples, 3))
y = ((features[:, 0] + 1.5 * group
+ rng.normal(scale=0.5, size=n_samples)) > 0).astype(int)
X = np.column_stack([features, group])
X_train, X_rest, y_train, y_rest, g_train, g_rest = train_test_split(
X, y, group, test_size=0.5, random_state=42
)
X_val, X_test, y_val, y_test, g_val, g_test = train_test_split(
X_rest, y_rest, g_rest, test_size=0.5, random_state=42
)
trained_model = LogisticRegression(max_iter=1000).fit(X_train, y_train)
# prefit=True is what stops ThresholdOptimizer from throwing away the model
# just trained. It defaults to False, which refits the estimator from scratch.
threshold_optimizer = ThresholdOptimizer(
estimator=trained_model,
constraints="equalized_odds",
prefit=True,
)
threshold_optimizer.fit(X_val, y_val, sensitive_features=g_val)
# predict() applies a randomized threshold rule, so pin random_state or the
# reported numbers move between runs.
fair_predictions = threshold_optimizer.predict(
X_test, sensitive_features=g_test, random_state=42
)
baseline_predictions = trained_model.predict(X_test)
for name, predictions in [("unadjusted", baseline_predictions),
("equalized_odds", fair_predictions)]:
eo = equalized_odds_difference(
y_test, predictions, sensitive_features=g_test
)
print(f"{name:15s} accuracy={accuracy_score(y_test, predictions):.3f} "
f"eo_difference={eo:.3f}")Expected Output:
unadjusted accuracy=0.895 eo_difference=0.338 equalized_odds accuracy=0.874 eo_difference=0.061
- Fairness interventions often reduce overall accuracy
- Different fairness metrics can conflict (can't satisfy all simultaneously)
- Choose fairness definition based on context and stakeholder values
- Document fairness-accuracy tradeoffs and the rationale for choices made
Privacy and Data Protection
AI systems often require sensitive personal data. Protecting user privacy isn't just legally required (GDPR, CCPA), it's an ethical obligation.
1. Data Minimization
Collect only what you need, keep it only as long as necessary.
# Bad: Collecting everything
user_data = {
'name': 'John Doe',
'ssn': '123-45-6789',
'address': '123 Main St',
'phone': '555-1234',
'email': 'john@example.com',
'browsing_history': [...],
'purchase_history': [...],
'location_history': [...]
}
# Good: Collect only what you need for the task
# For a recommendation system:
user_data = {
'user_id': 'uuid-123', # Pseudonymous identifier
'age_group': '25-34', # Aggregated, not exact age
'category_preferences': ['electronics', 'books'], # Not full history
'region': 'Northeast' # Not exact location
}
# Automatically delete old data
import sqlite3
from datetime import datetime, timedelta, timezone
def cleanup_old_data(db, retention_days=90):
"""Delete interaction rows older than the retention period."""
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
cursor = db.execute(
"DELETE FROM user_interactions WHERE created_at < ?",
(cutoff.isoformat(),)
)
db.commit()
return cursor.rowcount
if __name__ == "__main__":
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE user_interactions (user_id TEXT, created_at TEXT)")
now = datetime.now(timezone.utc)
db.executemany(
"INSERT INTO user_interactions VALUES (?, ?)",
[("u1", (now - timedelta(days=age)).isoformat())
for age in (5, 45, 100, 200)]
)
db.commit()
before = db.execute("SELECT count(*) FROM user_interactions").fetchone()[0]
deleted = cleanup_old_data(db, retention_days=90)
after = db.execute("SELECT count(*) FROM user_interactions").fetchone()[0]
print(f"rows before: {before}")
print(f"rows deleted: {deleted}")
print(f"rows remaining: {after}")Expected Output:
rows before: 4 rows deleted: 2 rows remaining: 2
2. Anonymization and Pseudonymization
Remove or hash identifying information to protect user privacy.
import hmac
import os
from hashlib import sha256
import pandas as pd
def pseudonymize_dataframe(df, id_columns, sensitive_columns, secret):
"""
Replace direct identifiers with keyed pseudonyms and coarsen numeric detail.
Identifiers are hashed with HMAC rather than a bare digest. A plain
sha256 of a low-entropy value like 'P001' or 'Alice' is reversible by
hashing every candidate and comparing, which takes seconds. The secret is
what makes the mapping one-way to anyone who does not hold it.
Pseudonymous is not anonymous. The rows still describe individuals and
remain personal data under GDPR; rotating or destroying the secret is what
actually severs the link.
Args:
df: Original dataframe
id_columns: Columns with direct identifiers (names, emails, SSN)
sensitive_columns: Numeric columns to coarsen into ranges
secret: Key held outside the dataset, from a secrets manager
Returns:
Pseudonymized copy, safe to share more widely than the original
"""
df_anon = df.copy()
for col in id_columns:
df_anon[col] = df_anon[col].map(
lambda value: hmac.new(
secret, str(value).encode(), sha256
).hexdigest()[:16]
)
for col in sensitive_columns:
# is_numeric_dtype rather than a dtype identity test: on pandas 3 a
# text column is StringDtype, so comparing against np.int64/np.float64
# misclassifies columns silently.
if pd.api.types.is_numeric_dtype(df_anon[col]):
df_anon[col] = pd.cut(
df_anon[col],
bins=5,
labels=['very_low', 'low', 'medium', 'high', 'very_high']
)
return df_anon
if __name__ == "__main__":
# In production this comes from a secrets manager, never from the source.
secret = os.environ.get("PSEUDONYM_KEY", "demo-key-not-for-production").encode()
medical_data = pd.DataFrame({
'patient_id': ['P001', 'P002', 'P003', 'P004', 'P005'],
'name': ['Alice', 'Bob', 'Charlie', 'Dana', 'Erin'],
'age': [34, 45, 29, 61, 52],
'salary': [75000, 120000, 45000, 98000, 61000],
'diagnosis': ['diabetes', 'hypertension', 'healthy',
'diabetes', 'healthy'],
})
anonymized = pseudonymize_dataframe(
medical_data,
id_columns=['patient_id', 'name'],
sensitive_columns=['age', 'salary'],
secret=secret,
)
print(anonymized.to_string(index=False))Expected Output:
patient_id name age salary diagnosis 5d4bbe2650457a6d b6da432c5d259ffc very_low low diabetes 88691e3f848c0b02 35d4e270665e711e medium very_high hypertension 84d4bb4ef2eeaa50 3def28403d83032a very_low very_low healthy afd2763001a153cc 4747f4c88faaad42 very_high high diabetes c49606de70c65ad8 03929801236e5b2e high low healthy
3. Differential Privacy
Add controlled noise to protect individual privacy while maintaining statistical utility.
# Differential privacy: Add noise to protect individuals
import numpy as np
def add_laplace_noise(value, sensitivity, epsilon, rng):
"""
Add Laplace noise calibrated to sensitivity and the privacy budget.
Args:
value: True value
sensitivity: Maximum change one individual can cause
epsilon: Privacy parameter (smaller = more privacy, less accuracy)
rng: numpy Generator, seeded so results are reproducible
Returns:
Noisy value
"""
scale = sensitivity / epsilon
return value + rng.laplace(0, scale)
def private_mean(data, lower, upper, epsilon=1.0, rng=None):
"""
Mean with a differential privacy guarantee.
The bounds are arguments, not measurements. Deriving them with
data.min()/data.max() would read the private values to decide how much
noise to add, which leaks the extremes and voids the guarantee: the
published result would depend on the very outliers it is meant to hide.
Bounds have to come from outside the data, such as a form that already
caps what a person can enter.
Args:
data: Array of values
lower, upper: Publicly known range the values must fall in
epsilon: Privacy budget
rng: numpy Generator
Returns:
Differentially private mean
"""
rng = np.random.default_rng() if rng is None else rng
clipped = np.clip(data, lower, upper)
# Swapping one person's value moves a mean of n clipped values by at
# most (upper - lower) / n, so that is the sensitivity.
sensitivity = (upper - lower) / len(data)
return add_laplace_noise(clipped.mean(), sensitivity, epsilon, rng)
if __name__ == "__main__":
rng = np.random.default_rng(42)
ages = np.array([25, 30, 35, 40, 45, 50, 55, 60])
# Published range for the field, not derived from these eight people.
AGE_LOWER, AGE_UPPER = 0, 120
print(f"True mean age: {ages.mean():.2f}\n")
print("Five independent releases at each privacy budget:")
for epsilon in (10.0, 1.0, 0.1):
draws = [private_mean(ages, AGE_LOWER, AGE_UPPER, epsilon, rng)
for _ in range(5)]
formatted = " ".join(f"{value:7.2f}" for value in draws)
print(f" epsilon={epsilon:<5} {formatted}")
print("\nSmaller epsilon means more privacy and a wider spread.")Expected Output:
True mean age: 42.50 Five independent releases at each privacy budget: epsilon=10.0 43.69 42.30 44.39 43.25 40.00 epsilon=1.0 87.81 53.58 55.23 22.07 40.93 epsilon=0.1 -2.34 330.64 93.39 198.07 24.48 Smaller epsilon means more privacy and a wider spread.
At epsilon 10 the released means sit within a couple of years of the true 42.50. At epsilon 0.1 they range from -2.34 to 330.64, values no human age can take, because Laplace noise is unbounded and a tight privacy budget makes it large. Deciding where on that scale a release belongs is the entire practice. Hand-rolled noise is fine for understanding the mechanism, but a production release should go through an audited implementation: the hard part is the sensitivity analysis, and getting it wrong silently produces a number that carries no guarantee at all.
4. Secure Data Handling
Encrypt data at rest and in transit, control access, and audit usage.
import json
import sqlite3
from datetime import datetime, timezone
from cryptography.fernet import Fernet
class SecureDataStore:
"""Store sensitive data encrypted, with an access audit trail."""
def __init__(self, db, encryption_key):
# The key is a required argument on purpose. Generating one here when
# the caller passes nothing produces a key that lives only in this
# process: restart the service and every row written so far is
# unreadable forever. Keys belong in a KMS or secrets manager and are
# loaded at startup.
self.db = db
self.cipher = Fernet(encryption_key)
def encrypt_data(self, data):
"""Encrypt data before storage."""
return self.cipher.encrypt(json.dumps(data).encode())
def decrypt_data(self, encrypted_data):
"""Decrypt data when needed."""
return json.loads(self.cipher.decrypt(encrypted_data).decode())
def store_user_data(self, user_id, data):
"""Store user data encrypted."""
self.db.execute(
"INSERT INTO secure_data (user_id, encrypted_data) VALUES (?, ?)",
(user_id, self.encrypt_data(data))
)
self._log_access(user_id, action="store")
self.db.commit()
def load_user_data(self, user_id):
"""Read user data back, logging the access."""
row = self.db.execute(
"SELECT encrypted_data FROM secure_data WHERE user_id = ?",
(user_id,)
).fetchone()
self._log_access(user_id, action="read")
self.db.commit()
return self.decrypt_data(row[0])
def _log_access(self, user_id, action):
"""Log all data access for audit trail."""
self.db.execute(
"INSERT INTO audit_log (user_id, action, timestamp) VALUES (?, ?, ?)",
(user_id, action, datetime.now(timezone.utc).isoformat())
)
# Usage
if __name__ == "__main__":
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE secure_data (user_id TEXT, encrypted_data BLOB)")
db.execute("CREATE TABLE audit_log (user_id TEXT, action TEXT, timestamp TEXT)")
# Generated inline only because this is a demo with nothing to preserve.
store = SecureDataStore(db, Fernet.generate_key())
user_data = {
'medical_history': ['asthma', 'penicillin allergy'],
'financial_info': {'account': '****4821', 'credit_limit': 5000},
}
store.store_user_data('user_123', user_data)
stored = db.execute("SELECT encrypted_data FROM secure_data").fetchone()[0]
print(f"Ciphertext on disk: {len(stored)} bytes, unreadable without the key")
print(f"Decrypted: {store.load_user_data('user_123')}")
print("\nAudit log:")
for user_id, action in db.execute("SELECT user_id, action FROM audit_log"):
print(f" {user_id} {action}")Expected Output:
Ciphertext on disk: 248 bytes, unreadable without the key
Decrypted: {'medical_history': ['asthma', 'penicillin allergy'], 'financial_info': {'account': '****4821', 'credit_limit': 5000}}
Audit log:
user_123 store
user_123 readSecurity Considerations for AI Systems
AI systems face unique security threats beyond traditional software vulnerabilities.
Threat 1: Adversarial Attacks
Attackers craft inputs designed to fool your model.
Examples:
- Spam classifier: Add invisible characters to bypass detection
- Image classifier: Tiny pixel changes that flip predictions
- Fraud detection: Structure transactions to avoid detection
# Defense: Input validation and adversarial training
import numpy as np
def detect_adversarial_input(model, x, min_confidence=0.7, probes=10,
jitter=0.05, max_swing=0.15, rng=None):
"""
Flag inputs that look engineered rather than natural.
Two signals: a prediction the model is not confident about, and a
prediction that moves sharply when the input is nudged.
min_confidence has to sit above 0.5. For a binary model the winning
probability is max(p, 1 - p), which is never below 0.5, so a threshold of
0.5 marks nothing at all.
"""
rng = np.random.default_rng() if rng is None else rng
probabilities = model.predict_proba([x])[0]
if probabilities.max() < min_confidence:
return True, "Low confidence prediction"
# Watch the probability under perturbation, not the predicted label.
# Counting distinct labels cannot measure instability: a binary model
# only ever emits two of them.
neighbourhood = x + rng.normal(0, jitter, size=(probes, len(x)))
probe_probabilities = model.predict_proba(neighbourhood)[:, 1]
swing = probe_probabilities.max() - probe_probabilities.min()
if swing > max_swing:
return True, f"Unstable under perturbation (swing={swing:.3f})"
return False, "Input appears legitimate"
def fgsm_perturbation(model, X, y, epsilon=0.3):
"""
Fast gradient sign perturbation against a linear model.
Random noise is not an adversarial example. It points in an arbitrary
direction and the model shrugs most of it off. An adversarial example
moves along the gradient of the loss, the one direction that costs the
most accuracy per unit of budget. For logistic regression that gradient
is available in closed form: d(loss)/dx = (p - y) * w.
"""
probabilities = model.predict_proba(X)[:, 1]
gradient = (probabilities - y)[:, None] * model.coef_
return X + epsilon * np.sign(gradient)
def adversarial_training(model, X_train, y_train, epsilon=0.3):
"""
Train on clean data plus adversarial examples built from it.
The examples keep their true labels, so the boundary is pushed away from
the points an attacker would aim for.
"""
X_adversarial = fgsm_perturbation(model, X_train, y_train, epsilon)
X_combined = np.vstack([X_train, X_adversarial])
y_combined = np.concatenate([y_train, y_train])
model.fit(X_combined, y_combined)
return model
if __name__ == "__main__":
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
rng = np.random.default_rng(42)
n_samples = 4000
epsilon = 0.3
features = rng.normal(size=(n_samples, 6))
weights = np.array([1.5, -1.0, 0.8, -0.6, 0.4, -0.2])
y = ((features @ weights
+ rng.normal(scale=0.5, size=n_samples)) > 0).astype(int)
X_train, X_test, y_train, y_test = train_test_split(
features, y, test_size=0.5, random_state=42
)
model = LogisticRegression(max_iter=1000).fit(X_train, y_train)
noisy = X_test + rng.uniform(-epsilon, epsilon, X_test.shape)
attacked = fgsm_perturbation(model, X_test, y_test, epsilon)
print(f"Accuracy at perturbation budget {epsilon}:")
print(f" clean input: {model.score(X_test, y_test):.3f}")
print(f" random noise: {model.score(noisy, y_test):.3f}")
print(f" FGSM attack: {model.score(attacked, y_test):.3f}")
hardened = adversarial_training(
LogisticRegression(max_iter=1000).fit(X_train, y_train),
X_train, y_train, epsilon
)
hardened_attack = fgsm_perturbation(hardened, X_test, y_test, epsilon)
print("\nAfter adversarial training:")
print(f" clean input: {hardened.score(X_test, y_test):.3f}")
print(f" FGSM attack: {hardened.score(hardened_attack, y_test):.3f}")
detector_rng = np.random.default_rng(0)
clean_flagged = np.mean([
detect_adversarial_input(model, x, rng=detector_rng)[0]
for x in X_test[:500]
])
attacked_flagged = np.mean([
detect_adversarial_input(model, x, rng=detector_rng)[0]
for x in attacked[:500]
])
print("\nDetector flag rate over 500 inputs:")
print(f" clean: {clean_flagged:.3f}")
print(f" attacked: {attacked_flagged:.3f}")Expected Output:
Accuracy at perturbation budget 0.3: clean input: 0.913 random noise: 0.902 FGSM attack: 0.495 After adversarial training: clean input: 0.884 FGSM attack: 0.561 Detector flag rate over 500 inputs: clean: 0.184 attacked: 0.264
The same perturbation budget costs 1 point of accuracy as random noise and 42 points as a gradient attack, which is the whole difference between noise and an adversary. Adversarial training buys back 0.495 to 0.561 against the attack and gives up 0.913 to 0.884 on clean input. The confidence detector separates the two populations barely at all, flagging 18.4% of clean inputs and 26.4% of attacked ones: useful as one signal among several, useless as a gate on its own.
Threat 2: Data Poisoning
Attackers inject malicious data into training set to corrupt the model.
Examples:
- Submit fake reviews to manipulate sentiment analysis
- Label legitimate emails as spam to train spam filter incorrectly
- Add backdoors: model behaves normally except on specific triggers
Defenses:
- Validate and sanitize all training data
- Use trusted, curated datasets when possible
- Detect outliers and anomalies in training data
- Implement rate limiting on user-contributed data
- Monitor model performance for sudden degradation
Threat 3: Model Stealing
Attackers query your model many times to recreate it.
Defenses:
- Rate limiting: Limit queries per user/IP
- API authentication and monitoring
- Add noise to predictions (slightly random responses)
- Detect and block suspicious query patterns
- Watermark your models (embed signatures in behavior)
Explainability and Transparency
Users and regulators need to understand how AI systems make decisions, especially for high-stakes applications (healthcare, finance, criminal justice). "Explainability" is not one thing, though: an auditor asking which features drive the model in general and a rejected applicant asking why they were turned down need different answers, and different tools produce them.
Which Question Are You Answering?
Figure 4: SHAP spans both columns: per-row values explain one decision, and the same values aggregated explain the model. Article 22 and the adverse action notice live in the right-hand column.
# explainability.py - Make AI decisions interpretable
import lime
import lime.lime_tabular
import pandas as pd
import shap
from sklearn.inspection import permutation_importance
class ModelExplainer:
"""Generate explanations for model predictions."""
def __init__(self, model, X_train, feature_names, random_state=42):
self.model = model
self.X_train = X_train
self.feature_names = feature_names
self.random_state = random_state
def shap_values(self, X):
"""
SHAP (SHapley Additive exPlanations).
Returns the contribution of each feature to each prediction. Plotting
is a separate method so this one stays usable from a service, a test,
or a report generator, none of which can open a figure window.
"""
explainer = shap.Explainer(self.model, self.X_train)
return explainer(X)
def plot_shap_summary(self, shap_values, X, class_index=1):
"""
Draw the global summary plot for one class.
A binary classifier returns SHAP values shaped
(n_samples, n_features, n_classes), so a class has to be selected
before plotting or the axes are ambiguous.
"""
shap.summary_plot(
shap_values[..., class_index], X, feature_names=self.feature_names
)
def explain_with_lime(self, instance, num_features=5):
"""
LIME (Local Interpretable Model-agnostic Explanations).
Explains individual predictions by fitting local linear model.
LIME samples around the instance, so random_state is what makes an
explanation reproducible enough to put in front of a customer twice.
"""
explainer = lime.lime_tabular.LimeTabularExplainer(
self.X_train,
feature_names=self.feature_names,
class_names=['Reject', 'Approve'],
mode='classification',
random_state=self.random_state,
)
return explainer.explain_instance(
instance,
self.model.predict_proba,
num_features=num_features
)
def get_feature_importance(self, X_test, y_test):
"""
Permutation importance: shuffle feature, measure impact on accuracy.
Shows which features matter most globally.
"""
result = permutation_importance(
self.model,
X_test,
y_test,
n_repeats=10,
random_state=self.random_state
)
return pd.DataFrame({
'feature': self.feature_names,
'importance': result.importances_mean,
'std': result.importances_std
}).sort_values('importance', ascending=False)
def generate_explanation_report(self, instance, y_pred, y_proba):
"""
Generate human-readable explanation for a prediction.
Args:
instance: Input features
y_pred: Predicted class
y_proba: Prediction probability
Returns:
Human-readable explanation string
"""
lime_exp = self.explain_with_lime(instance)
contributing_features = lime_exp.as_list()
explanation = f"Prediction: {'Approved' if y_pred == 1 else 'Rejected'}\n"
explanation += f"Confidence: {y_proba[y_pred]:.1%}\n\n"
explanation += "Top factors in this decision:\n"
for feature, contribution in contributing_features[:5]:
direction = "increased" if contribution > 0 else "decreased"
explanation += f" - {feature}: {direction} approval likelihood\n"
return explanation
# Example usage
if __name__ == "__main__":
from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
X, y = make_classification(n_samples=1000, n_features=6, random_state=42)
feature_names = [f"feature_{i}" for i in range(X.shape[1])]
model = RandomForestClassifier(n_estimators=50, random_state=42)
model.fit(X, y)
explainer = ModelExplainer(model, X, feature_names)
test_instance = X[0]
y_pred = model.predict([test_instance])[0]
y_proba = model.predict_proba([test_instance])[0]
print(explainer.generate_explanation_report(test_instance, y_pred, y_proba))
importance = explainer.get_feature_importance(X[:200], y[:200])
print("Global Feature Importance:")
print(importance.to_string(index=False))Expected Output:
Prediction: Rejected Confidence: 100.0% Top factors in this decision: - feature_5 <= -1.05: decreased approval likelihood - feature_3 <= -1.02: increased approval likelihood - feature_1 > 0.54: decreased approval likelihood - feature_4 > 0.54: increased approval likelihood - feature_2 <= -0.70: decreased approval likelihood Global Feature Importance: feature importance std feature_5 0.4275 0.030434 feature_3 0.0975 0.010782 feature_4 0.0385 0.005500 feature_1 0.0350 0.009220 feature_2 0.0265 0.006344 feature_0 0.0230 0.003317
- SHAP: Game-theory based feature attribution, works with any model
- LIME: Fits interpretable local model around prediction
- Feature importance: Shows which features matter most globally
- Attention mechanisms: For neural networks, show which inputs the model focuses on
- Counterfactual explanations: "If X had been Y, prediction would have been Z"
Model Cards
A model card is a short document that ships with the model and answers the questions someone will otherwise have to reverse engineer from your code. The format comes from Mitchell et al., 2019, and the reason to care is that most of it is now what the technical documentation requirements above ask for anyway. A usable card covers:
- Intended use, and just as importantly the uses you are ruling out
- Training data: where it came from, what period it covers, who is in it and who is not
- Evaluation: headline metrics, and the same metrics broken down by protected group, which is exactly the report the bias detector above produces
- Known limitations: the populations and conditions where performance degrades
- Ethical considerations: the fairness definition chosen and why, and the tradeoff accepted to get it
- Maintenance: who owns it, when it was last retrained, and how to report a problem
Write it while you build. A card assembled months later from memory and stale notebooks is the one that quietly omits the group the model performs worst on.
When Ethics Becomes Law
Everything above this point is engineering judgment. Increasingly, it is also a legal obligation. If you build a model that decides who gets a loan, a job, or a benefit, you are now working inside a regulatory regime, and the parts of it that bite are the parts this lesson has been teaching: measure disparity, explain decisions, keep records, keep a human in the loop.
The EU AI Act
Regulation (EU) 2024/1689 has applied since 1 August 2024 and sorts systems by what they are used for, not by how they are built. A linear model and a large transformer land in the same tier if they make the same decision about a person.
Risk Tiers and What Each One Owes
Figure 3: The tier is set by the use case. The same model can sit in two different tiers depending on what it decides.
The obligations arrive in stages, and the schedule moved recently. The Digital Omnibus on AI, in force since late July 2026, deferred the high-risk deadlines while leaving the rest of the calendar intact:
| Obligation | Applies from | Status today |
|---|---|---|
| Prohibited practices, AI literacy | 2 February 2025 | In force |
| General-purpose AI model obligations | 2 August 2025 | In force |
| Transparency duties (Article 50): disclose AI interaction, mark synthetic content, label deepfakes | 2 August 2026 | In force, with a grace period to 2 December 2026 for generative systems already on the market |
| Annex III standalone high-risk systems | 2 December 2027 | Deferred from 2 August 2026 |
| Annex I high-risk AI embedded in regulated products | 2 August 2028 | Deferred from 2 August 2027 |
Annex III is worth reading closely, because it names creditworthiness evaluation and employment decisions explicitly. The two cases that opened this lesson are not analogies: they are the regulation's own examples. Systems in that tier owe risk management, data governance, technical documentation, logging, human oversight, and accuracy and robustness testing before they go to market.
Recommendation
Treat every date on this page as perishable. The high-risk deadline moved by sixteen months about a week before it was due to bite, and Colorado repealed and replaced its own AI act in May 2026 before the original had ever taken effect. Check the primary source before you make a compliance commitment, and never let a summary like this one, or a model's recollection of one, be the last word.
GDPR Article 22 and the Right to an Explanation
Article 22 gives a person the right not to be subject to a decision based solely on automated processing that significantly affects them, along with rights to human intervention and to contest the outcome. In SCHUFA (C-634/21, December 2023) the Court of Justice held that producing the score can itself be the automated decision, where a lender draws strongly on that score to grant or refuse credit. Scoring people and leaving the formal decision to a bank does not put you outside Article 22, which is exactly why the explainability tooling above is not optional decoration.
The United States
There is no federal equivalent, so obligations come from sectoral and local rules. The four-fifths rule the bias detector implements comes from the EEOC's Uniform Guidelines on Employee Selection Procedures, which have been in force since 1978 and apply to a model exactly as they apply to a written test. New York City's Local Law 144 has required annual independent bias audits of automated employment decision tools, published results, and advance notice to candidates, since July 2023. State law is in flux: Colorado's 2024 AI Act was postponed, then repealed and replaced by SB 26-189 in May 2026, effective January 2027.
Responsible AI Development Checklist
Before Training
- Document the purpose and intended use of the model
- Identify protected attributes and potential biases
- Ensure training data is representative and balanced
- Check for and remove proxy features for protected attributes
- Obtain proper consent for data usage
- Implement data minimization principles
During Development
- Test for bias across all protected groups
- Apply bias mitigation techniques when needed
- Implement explainability from the start
- Build in privacy protections (anonymization, differential privacy)
- Test security against adversarial attacks
- Document all design decisions and tradeoffs
Before Deployment
- Comprehensive bias testing on holdout data
- Red team security testing (attempt to break the system)
- Create model card documenting capabilities and limitations
- Get stakeholder review (legal, ethics, affected communities)
- Implement monitoring and alerting for bias/drift
- Establish clear processes for appeals and recourse
In Production
- Continuously monitor for bias and drift
- Regularly audit predictions and outcomes
- Maintain audit logs of all decisions
- Provide explanations for decisions when requested
- Have human review for high-stakes decisions
- Respond quickly to identified issues
- Regular security assessments
The last column is the one teams skip, and it is the one that decides whether any of the rest held. A model that passed every fairness check at launch drifts as the population it scores changes, and nothing tells you unless you are measuring. Lesson 19: Evaluation & Observability covers the tracing, online metrics, and CI regression gates that turn "monitor continuously" from an aspiration into a pipeline.
Key Takeaways
- Ethics is your responsibility - the AI does not make ethical choices, you do
- Bias is real and harmful - test for it explicitly across every protected group
- Three places to intervene - pre-processing, in-processing, and post-processing, each with a different cost
- Fairness definitions conflict - you cannot satisfy them all, so choose one deliberately and record why
- Privacy is not optional - data minimization, pseudonymization, and encryption are the baseline
- Security matters - trained models face adversarial inputs, poisoning, and extraction
- Explainability builds trust - and increasingly it is what the law requires
- Document everything - model cards, design decisions, and bias testing results
- Monitor continuously - bias and drift emerge after deployment, not before
- Human oversight for high stakes - AI should assist humans, not replace them in critical decisions
- Build diverse teams - different perspectives surface biases that metrics miss
Remember: The goal is not perfect fairness (impossible) but continuous improvement and transparency about limitations. Build AI systems that make the world better, not worse.