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

The worldalready unequalCollected datahistorical, sampledLabelsproxies, not truthTrained modelDecisionswho gets hired, loansNew datarecorded outcomessampling biasmeasurement biasthe loop closes

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.051

Read 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.

Key fairness metrics:
  • 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

DataTrainingPredictions1. Pre-processingreweight, resample2. In-processingfairness constraints3. Post-processingper-group thresholds

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.636

Five 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
Important tradeoffs:
  • 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  read

Security Considerations for AI Systems

AI systems face unique security threats beyond traditional software vulnerabilities.

Scope note: this section covers attacks on trained models: adversarial inputs, poisoned training data, and model theft. These apply to any ML system, including the classifiers and regressors from Lessons 2 and 6. Attacks on LLM applications are a different threat model with different defenses: prompt injection, the OWASP LLM Top 10, guardrails, and least-privilege agents all live in Lesson 15: Securing AI Applications.

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?

Trained modelGlobalwhat drives it overallLocalwhy this one decisionPermutationimportanceSHAP summaryover a sampleLIMElocal surrogateAuditor, model cardThe person affected

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
Explainability techniques:
  • 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

Unacceptablesocial scoringHigh riskcredit, hiring, benefitsLimited riskchatbots, deepfakesMinimal riskspam filters, gamesBanned outrightsince Feb 2025Conformity assessmentfrom Dec 2027Say that it is AIfrom Aug 2026No extra dutiesvoluntary codes

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:

ObligationApplies fromStatus today
Prohibited practices, AI literacy2 February 2025In force
General-purpose AI model obligations2 August 2025In force
Transparency duties (Article 50): disclose AI interaction, mark synthetic content, label deepfakes2 August 2026In force, with a grace period to 2 December 2026 for generative systems already on the market
Annex III standalone high-risk systems2 December 2027Deferred from 2 August 2026
Annex I high-risk AI embedded in regulated products2 August 2028Deferred 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.

What this means for your backlog: the checklist below is not a counsel of perfection. Most of it is the engineering work these regimes assume you have already done, which is why the cheapest time to build a bias report, an audit log, and a model card is while the system is small enough that adding them is a morning's work.

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.

Software Engineering in AI EraLesson 7