Differential Privacy › Implementation: DP-SGD from Scratch
Language AI HandbookAll chapters 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.
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 , is where . DP-SGD requires computing this per-example gradient before any aggregation or clipping.
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_exampleNote 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.
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_batchThis 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
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,
)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.

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.
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
)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 ), 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 value. The horizontal dashed line at represents a typical deployment target: training must stop when the curve crosses that line.

The plot makes the tradeoff concrete. With , the budget is exhausted after roughly 20 steps at , severely limiting training. With , 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?