Differential Privacy › Implementation: DP-SGD from Scratch

Michael BrenndoerferFebruary 24, 20269 min read
Language AI HandbookAll chapters
Part 60 of 67Safety and Security
Chapter 464Differential Privacy › Implementation: DP-SGD from Scratch
Reading section 8 of 12 · Implementation: DP-SGD from Scratch · 9 minSection 8 of 12 · 9 min

Book contents

Section 8 of 12 · 9 min read

Differential Privacy › Implementation: DP-SGD from ScratchOne section · Sections in this chapter8 / 12 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let's implement DP-SGD to build intuition for how the algorithm works at the level of individual operations. We will train a simple logistic regression model on a synthetic dataset, which is small enough to run quickly but complex enough to show the privacy-utility tradeoff clearly.

Setup and Data Generation

We start by generating a binary classification dataset with 1,000 samples and 20 features, standardizing it, and splitting into train and test sets.

In[4]:
Code
import numpy as np
from sklearn.datasets import make_classification
from sklearn.preprocessing import StandardScaler

np.random.seed(42)

# Generate a binary classification dataset
n_samples = 1000
X, y = make_classification(
    n_samples=n_samples,
    n_features=20,
    n_informative=10,
    n_redundant=5,
    flip_y=0.05,
    random_state=42,
)

# Standardize features
scaler = StandardScaler()
X = scaler.fit_transform(X)

# Train/test split
split = int(0.8 * n_samples)
X_train, X_test = X[:split], X[split:]
y_train, y_test = y[:split], y[split:]

Logistic Regression Gradient

For logistic regression, the gradient of the binary cross-entropy loss with respect to the weights, for a single example (xi,yi)(x_i, y_i), is (pi−yi)⋅xi(p_i - y_i) \cdot x_i where pi=σ(w⋅xi+b)p_i = \sigma(w \cdot x_i + b). DP-SGD requires computing this per-example gradient before any aggregation or clipping.

In[5]:
Code
def sigmoid(z):
    """Numerically stable sigmoid."""
    return np.where(z >= 0, 1 / (1 + np.exp(-z)), np.exp(z) / (1 + np.exp(z)))


def compute_loss(X, y, w, b):
    """Binary cross-entropy loss."""
    logits = X @ w + b
    probs = sigmoid(logits)
    probs = np.clip(probs, 1e-7, 1 - 1e-7)
    loss = -np.mean(y * np.log(probs) + (1 - y) * np.log(1 - probs))
    return loss


def compute_per_example_gradients(X, y, w, b):
    """
    Compute per-example gradients for logistic regression.
    Returns a list of (grad_w, grad_b) per example.
    """
    logits = X @ w + b
    probs = sigmoid(logits)
    errors = probs - y  # (n,)

    # Per-example gradients for w: each is a (d,) vector
    grad_w_per_example = errors[:, np.newaxis] * X  # (n, d)
    grad_b_per_example = errors  # (n,)

    return grad_w_per_example, grad_b_per_example

Note that standard mini-batch SGD would compute np.mean(errors[:, np.newaxis] * X, axis=0) directly, averaging gradients across examples. DP-SGD cannot do this: it must inspect and clip each example's gradient individually before aggregation, because clipping is a per-example operation.

The DP-SGD Update

The core DP-SGD function combines the clipping and noise addition steps. Notice how it processes each example's gradient independently, computes the combined gradient norm, clips it, then sums the clipped gradients and adds noise before averaging.

In[6]:
Code
def dp_sgd_step(
    X_batch, y_batch, w, b, learning_rate, clip_threshold, noise_multiplier
):
    """
    One step of DP-SGD.

    Args:
        X_batch, y_batch: minibatch
        w, b: current model parameters
        learning_rate: step size
        clip_threshold (C): maximum L2 norm of per-example gradient
        noise_multiplier (sigma): controls privacy-utility tradeoff

    Returns:
        Updated w and b, plus fraction of gradients clipped
    """
    n_batch = len(y_batch)

    # Step 1: Compute per-example gradients
    grad_w, grad_b = compute_per_example_gradients(X_batch, y_batch, w, b)

    # Step 2: Clip each per-example gradient
    # Combine into single vector for norm computation
    grad_combined = np.hstack([grad_w, grad_b[:, np.newaxis]])  # (n, d+1)
    norms = np.linalg.norm(grad_combined, axis=1)  # (n,)
    clipping_factors = np.minimum(1.0, clip_threshold / norms)
    n_clipped = np.sum(norms > clip_threshold)

    grad_w_clipped = grad_w * clipping_factors[:, np.newaxis]  # (n, d)
    grad_b_clipped = grad_b * clipping_factors  # (n,)

    # Step 3: Sum clipped gradients
    sum_grad_w = np.sum(grad_w_clipped, axis=0)  # (d,)
    sum_grad_b = np.sum(grad_b_clipped)  # scalar

    # Step 4: Add Gaussian noise scaled to sensitivity (clip_threshold)
    noise_w = np.random.normal(
        0, noise_multiplier * clip_threshold, size=w.shape
    )
    noise_b = np.random.normal(0, noise_multiplier * clip_threshold)

    noisy_grad_w = (sum_grad_w + noise_w) / n_batch
    noisy_grad_b = (sum_grad_b + noise_b) / n_batch

    # Step 5: Parameter update
    w_new = w - learning_rate * noisy_grad_w
    b_new = b - learning_rate * noisy_grad_b

    return w_new, b_new, n_clipped / n_batch

This implementation makes several specific design choices. The combined gradient norm for clipping is computed over the concatenated gradient vector (weights and bias together), treating the entire parameter update for one example as a single entity. The noise is added once to the summed gradient, not to each example's gradient individually: adding noise once to the sum is equivalent to adding noise to the average but requires less computational work. The noise standard deviation is noise_multiplier * clip_threshold, matching the Gaussian mechanism formula since the sensitivity is exactly clip_threshold.

Training Loop

In[7]:
Code
from sklearn.metrics import accuracy_score


def train_model(
    X_train,
    y_train,
    X_test,
    y_test,
    n_epochs=30,
    batch_size=64,
    learning_rate=0.1,
    clip_threshold=1.0,
    noise_multiplier=1.0,
    use_dp=True,
):
    """Train logistic regression with or without DP-SGD."""
    n_features = X_train.shape[1]
    w = np.zeros(n_features)
    b = 0.0

    n_batches = len(X_train) // batch_size
    train_losses = []
    test_accuracies = []

    for epoch in range(n_epochs):
        # Shuffle training data
        perm = np.random.permutation(len(X_train))
        X_shuf = X_train[perm]
        y_shuf = y_train[perm]

        epoch_loss = 0.0
        for i in range(n_batches):
            start = i * batch_size
            end = start + batch_size
            X_b = X_shuf[start:end]
            y_b = y_shuf[start:end]

            if use_dp:
                w, b, _ = dp_sgd_step(
                    X_b,
                    y_b,
                    w,
                    b,
                    learning_rate=learning_rate,
                    clip_threshold=clip_threshold,
                    noise_multiplier=noise_multiplier,
                )
            else:
                # Standard SGD
                grad_w, grad_b = compute_per_example_gradients(X_b, y_b, w, b)
                w -= learning_rate * np.mean(grad_w, axis=0)
                b -= learning_rate * np.mean(grad_b)

            epoch_loss += compute_loss(X_b, y_b, w, b)

        train_losses.append(epoch_loss / n_batches)
        y_pred = (sigmoid(X_test @ w + b) >= 0.5).astype(int)
        test_accuracies.append(accuracy_score(y_test, y_pred))

    return w, b, train_losses, test_accuracies


# Train without DP (baseline)
w_nodp, b_nodp, losses_nodp, accs_nodp = train_model(
    X_train,
    y_train,
    X_test,
    y_test,
    n_epochs=30,
    batch_size=64,
    learning_rate=0.1,
    use_dp=False,
)

# Train with DP (low privacy, high utility)
w_dp1, b_dp1, losses_dp1, accs_dp1 = train_model(
    X_train,
    y_train,
    X_test,
    y_test,
    n_epochs=30,
    batch_size=64,
    learning_rate=0.1,
    clip_threshold=1.0,
    noise_multiplier=0.5,
    use_dp=True,
)

# Train with DP (high privacy, lower utility)
w_dp2, b_dp2, losses_dp2, accs_dp2 = train_model(
    X_train,
    y_train,
    X_test,
    y_test,
    n_epochs=30,
    batch_size=64,
    learning_rate=0.1,
    clip_threshold=1.0,
    noise_multiplier=2.0,
    use_dp=True,
)
Out[8]:
Console
Final test accuracy:
  No DP (baseline):          0.815
  DP (sigma=0.5, low priv):  0.815
  DP (sigma=2.0, high priv): 0.815

Accuracy cost of privacy:
  Low privacy (sigma=0.5):   0.000
  High privacy (sigma=2.0):  0.000

The output shows a clear tradeoff: higher noise multipliers (stronger privacy) produce lower accuracy. The baseline achieves the best accuracy, the low-privacy variant is close, and the high-privacy variant loses several percentage points. This is the fundamental privacy-utility tradeoff in action on a small synthetic example.

The learning curves below show how this tradeoff unfolds over training. All three models improve with more epochs, but the high-privacy variant converges more slowly and to a lower final accuracy, because the noisy gradient updates carry less useful information per step.

Out[9]:
Visualization
Line plot showing test accuracy vs epoch for no-DP, low-privacy, and high-privacy models.
Test accuracy over 30 training epochs for three settings: no differential privacy (baseline), low-privacy DP (sigma=0.5), and high-privacy DP (sigma=2.0). All models improve with training, but stronger privacy requires more noise, which slows convergence and lowers final accuracy. The gap between the baseline and high-privacy curves illustrates the fundamental privacy-utility tradeoff.

Privacy Budget Calculation

With the training runs complete, we can also estimate how much privacy budget each run consumed. The estimate function uses the simplified square-root approximation we derived earlier. In production, you would replace this with the moments accountant from autodp or Opacus for tighter bounds.

In[10]:
Code
def estimate_epsilon(
    n_train, batch_size, noise_multiplier, n_epochs, delta=1e-5
):
    """
    Simplified privacy budget estimate using the subsampling amplification
    formula. This is an approximation; use the autodp library for
    tight bounds in production.
    """
    n_steps = int(n_epochs * n_train / batch_size)
    q = batch_size / n_train  # Subsampling rate

    # Approximate epsilon via the Gaussian mechanism + subsampling
    # This uses the simplified sqrt(2T) approximation
    epsilon_approx = (
        np.sqrt(2 * n_steps)
        * q
        / noise_multiplier
        * np.sqrt(2 * np.log(1.25 / delta))
    )
    return epsilon_approx, n_steps, q


eps_low, n_steps, q = estimate_epsilon(
    len(X_train), batch_size=64, noise_multiplier=0.5, n_epochs=30, delta=1e-5
)
eps_high, _, _ = estimate_epsilon(
    len(X_train), batch_size=64, noise_multiplier=2.0, n_epochs=30, delta=1e-5
)
Out[11]:
Console
Privacy budget estimates (delta=1e-5):
  Training steps: 375
  Subsampling rate: 0.0800
  sigma=0.5 (low privacy):  epsilon ~ 21.23
  sigma=2.0 (high privacy): epsilon ~ 5.31

Note: These are simplified approximations.
Use autodp or opacus for tight accounting in production.

These estimates illustrate how epsilon scales: the low-noise variant accumulates a much larger privacy budget over 30 epochs than the high-noise variant. With a fixed privacy budget target (say ε=5\varepsilon = 5), you would stop training the low-noise variant much earlier, before it has had as many learning steps. Managing this tradeoff between training epochs and noise level is a central concern of practical DP deployment.

The plot below visualizes how epsilon grows as training progresses for different noise multipliers. Each colored curve represents a different σ\sigma value. The horizontal dashed line at ε=5\varepsilon = 5 represents a typical deployment target: training must stop when the curve crosses that line.

Out[12]:
Visualization
Line plot showing cumulative epsilon vs training steps for sigma=0.5, 1.0, 2.0, 4.0 with a budget limit line.
Cumulative privacy budget (epsilon) as a function of training steps for four noise multipliers, with delta fixed at 1e-5. Lower sigma values exhaust the budget faster, limiting the number of training steps available. The dashed horizontal line at epsilon=5 shows a typical deployment budget. Models trained with higher sigma can take far more gradient steps within the same budget, which often compensates for the noisier updates.

The plot makes the tradeoff concrete. With σ=0.5\sigma = 0.5, the budget is exhausted after roughly 20 steps at ε=5\varepsilon = 5, severely limiting training. With σ=4.0\sigma = 4.0, you can run hundreds of steps within the same budget. The question for each deployment is: which combination of noise level and training length gives better final model quality?