Learn AI Series (#128) - Privacy-Preserving AI
Learn AI Series (#128) - Privacy-Preserving AI

What will I learn
- You will learn differential privacy (DP) -- the one idea in this whole area that hands you an actual mathematical guarantee instead of a hopeful shrug, and the epsilon knob that measures exactly how strong that guarantee is;
- DP-SGD, the recipe that turns ordinary training into private training with two small moves (clip, then add noise), and how Opacus does it for you in about four lines;
- federated learning -- the genuinely different idea of bringing the model to the data instead of hoarding everyone's data in one big tempting pile (this is how your phone keyboard learns without shipping your messages anywhere);
- secure multi-party computation (SMPC), where several parties compute a joint answer while each keeps its own inputs secret -- with a tiny secret-sharing scheme you can actually read;
- homomorphic encryption, the almost-magical trick of computing directly on encrypted data without ever decrypting it (and the sobering reason it is not everywhere yet);
- and how to budget privacy -- because every private query spends something you can never get back.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Python 3(.10+) distribution with PyTorch (
pip install torch), andpip install opacusif you want to run the differentially private training for real -- but as usual you do NOT need a GPU, every idea here is easiest to understand on a small model you could train on a laptop; - You've been through episode #35 (data ethics and bias, where we first admitted data is people), and especially episode #127 (AI security) -- today is the constructive sequel to the membership-inference and model-inversion attacks we met there.
Difficulty
- Beginner
Curriculum (of the Learn AI Series):
- Learn AI Series (#1) - What Machine Learning Actually Is
- Learn AI Series (#2) - Setting Up Your AI Workbench - Python and NumPy
- Learn AI Series (#3) - Your Data Is Just Numbers - How Machines See the World
- Learn AI Series (#4) - Your First Prediction - No Math, Just Intuition
- Learn AI Series (#5) - Patterns in Data - What "Learning" Actually Looks Like
- Learn AI Series (#6) - From Intuition to Math - Why We Need Formulas
- Learn AI Series (#7) - The Training Loop - See It Work Step by Step
- Learn AI Series (#8) - The Math You Actually Need (Part 1) - Linear Algebra
- Learn AI Series (#9) - The Math You Actually Need (Part 2) - Calculus and Probability
- Learn AI Series (#10) - Your First ML Model - Linear Regression From Scratch
- Learn AI Series (#11) - Making Linear Regression Real
- Learn AI Series (#12) - Classification - Logistic Regression From Scratch
- Learn AI Series (#13) - Evaluation - How to Know If Your Model Actually Works
- Learn AI Series (#14) - Data Preparation - The 80% Nobody Talks About
- Learn AI Series (#15) - Feature Engineering and Selection
- Learn AI Series (#16) - Scikit-Learn - The Standard Library of ML
- Learn AI Series (#17) - Decision Trees - How Machines Make Decisions
- Learn AI Series (#18) - Random Forests - Wisdom of Crowds
- Learn AI Series (#19) - Gradient Boosting - The Kaggle Champion
- Learn AI Series (#20) - Support Vector Machines - Drawing the Perfect Boundary
- Learn AI Series (#21) - Mini Project - Predicting Crypto Market Regimes
- Learn AI Series (#22) - K-Means Clustering - Finding Groups
- Learn AI Series (#23) - Advanced Clustering - Beyond K-Means
- Learn AI Series (#24) - Dimensionality Reduction - PCA
- Learn AI Series (#25) - Advanced Dimensionality Reduction - t-SNE and UMAP
- Learn AI Series (#26) - Anomaly Detection - Finding What Doesn't Belong
- Learn AI Series (#27) - Recommendation Systems - "Users Like You Also Liked..."
- Learn AI Series (#28) - Time Series Fundamentals - When Order Matters
- Learn AI Series (#29) - Time Series Forecasting - Predicting What Comes Next
- Learn AI Series (#30) - Natural Language Processing - Text as Data
- Learn AI Series (#31) - Word Embeddings - Meaning in Numbers
- Learn AI Series (#32) - Bayesian Methods - Thinking in Probabilities
- Learn AI Series (#33) - Ensemble Methods Deep Dive - Stacking and Blending
- Learn AI Series (#34) - ML Engineering - From Notebook to Production
- Learn AI Series (#35) - Data Ethics and Bias in ML
- Learn AI Series (#36) - Mini Project - Complete ML Pipeline
- Learn AI Series (#37) - The Perceptron - Where It All Started
- Learn AI Series (#38) - Neural Networks From Scratch - Forward Pass
- Learn AI Series (#39) - Neural Networks From Scratch - Backpropagation
- Learn AI Series (#40) - Training Neural Networks - Practical Challenges
- Learn AI Series (#41) - Optimization Algorithms - SGD, Momentum, Adam
- Learn AI Series (#42) - PyTorch Fundamentals - Tensors and Autograd
- Learn AI Series (#43) - PyTorch Data and Training
- Learn AI Series (#44) - PyTorch nn.Module - Building Real Networks
- Learn AI Series (#45) - Convolutional Neural Networks - Theory
- Learn AI Series (#46) - CNNs in Practice - Classic to Modern Architectures
- Learn AI Series (#47) - CNN Applications - Detection, Segmentation, Style Transfer
- Learn AI Series (#48) - Recurrent Neural Networks - Sequences
- Learn AI Series (#49) - LSTM and GRU - Solving the Memory Problem
- Learn AI Series (#50) - Sequence-to-Sequence Models
- Learn AI Series (#51) - Attention Mechanisms
- Learn AI Series (#52) - The Transformer Architecture (Part 1)
- Learn AI Series (#53) - The Transformer Architecture (Part 2)
- Learn AI Series (#54) - Vision Transformers
- Learn AI Series (#55) - Generative Adversarial Networks
- Learn AI Series (#56) - Mini Project - Building a Transformer From Scratch
- Learn AI Series (#57) - Language Modeling - Predicting the Next Word
- Learn AI Series (#58) - GPT Architecture - Decoder-Only Transformers
- Learn AI Series (#59) - BERT and Encoder Models
- Learn AI Series (#60) - Training Large Language Models
- Learn AI Series (#61) - Instruction Tuning and Alignment
- Learn AI Series (#62) - Prompt Engineering - Getting the Most from LLMs
- Learn AI Series (#63) - Embeddings and Vector Search
- Learn AI Series (#64) - Retrieval-Augmented Generation (RAG) - Basics
- Learn AI Series (#65) - RAG - Advanced Techniques
- Learn AI Series (#66) - Working with LLM APIs
- Learn AI Series (#67) - Building AI Agents (Part 1) - Foundations
- Learn AI Series (#68) - Building AI Agents (Part 2) - Advanced Patterns
- Learn AI Series (#69) - Fine-Tuning Language Models
- Learn AI Series (#70) - Running Local Models
- Learn AI Series (#71) - Text Generation Techniques
- Learn AI Series (#72) - Tokenization Deep Dive
- Learn AI Series (#73) - LLM Evaluation
- Learn AI Series (#74) - The Hugging Face Ecosystem
- Learn AI Series (#75) - Multimodal Models - Text Meets Vision
- Learn AI Series (#76) - Mini Project - Your Own AI Assistant
- Learn AI Series (#77) - Image Processing Fundamentals
- Learn AI Series (#78) - Object Detection (Part 1) - Foundations
- Learn AI Series (#79) - Object Detection (Part 2) - Modern Approaches
- Learn AI Series (#80) - Image Segmentation
- Learn AI Series (#81) - Pose Estimation and Tracking
- Learn AI Series (#82) - Optical Character Recognition
- Learn AI Series (#83) - Video Understanding
- Learn AI Series (#84) - Generative Images - Diffusion Models (Part 1)
- Learn AI Series (#85) - Generative Images - Diffusion Models (Part 2)
- Learn AI Series (#86) - Image-to-Image and Editing
- Learn AI Series (#87) - 3D Vision
- Learn AI Series (#88) - Face Analysis
- Learn AI Series (#89) - Medical and Scientific Imaging
- Learn AI Series (#90) - Self-Supervised Learning for Vision
- Learn AI Series (#91) - Mini Project - Building a Visual AI System
- Learn AI Series (#92) - Audio Fundamentals for AI
- Learn AI Series (#93) - Speech Recognition
- Learn AI Series (#94) - Text-to-Speech (TTS)
- Learn AI Series (#95) - Audio Classification
- Learn AI Series (#96) - Music Generation
- Learn AI Series (#97) - Speaker Recognition and Diarization
- Learn AI Series (#98) - Natural Language Understanding for Voice
- Learn AI Series (#99) - Audio Enhancement
- Learn AI Series (#100) - Multimodal Audio-Visual Models
- Learn AI Series (#101) - Mini Project: Voice-Controlled AI Assistant
- Learn AI Series (#102) - What Is Reinforcement Learning?
- Learn AI Series (#103) - Multi-Armed Bandits
- Learn AI Series (#104) - Dynamic Programming
- Learn AI Series (#105) - Monte Carlo Methods
- Learn AI Series (#106) - Temporal Difference Learning
- Learn AI Series (#107) - Deep Q-Networks (DQN)
- Learn AI Series (#108) - Policy Gradient Methods
- Learn AI Series (#109) - Advanced Policy Optimization
- Learn AI Series (#110) - Model-Based Reinforcement Learning
- Learn AI Series (#111) - Multi-Agent Reinforcement Learning
- Learn AI Series (#112) - RL for Games
- Learn AI Series (#113) - RL for Real-World Applications
- Learn AI Series (#114) - Inverse Reinforcement Learning
- Learn AI Series (#115) - Offline Reinforcement Learning
- Learn AI Series (#116) - Mini Project: Training a Game-Playing AI
- Learn AI Series (#117) - ML System Design
- Learn AI Series (#118) - Data Engineering for AI
- Learn AI Series (#119) - Experiment Tracking and Reproducibility
- Learn AI Series (#120) - Model Optimization: Making Models Fast
- Learn AI Series (#121) - Model Serving Architecture
- Learn AI Series (#122) - Edge AI: Running Models on Devices
- Learn AI Series (#123) - Monitoring ML in Production
- Learn AI Series (#124) - CI/CD for Machine Learning
- Learn AI Series (#125) - GPU Programming Basics
- Learn AI Series (#126) - Distributed Training
- Learn AI Series (#127) - AI Security
- Learn AI Series (#128) - Privacy-Preserving AI (this post)
Learn AI Series (#128) - Privacy-Preserving AI
I ended episode #127 on a deliberately uncomfortable note. We had just watched a model quietly betray the people it was trained on -- membership inference figuring out whether your record was in the training set, model inversion clawing back blurry-but-recognisable faces -- and I asked the obvious follow-up question: could we train useful models without ever piling everyone's raw data into one big, tempting heap in the first place? Today is the constructive answer to that question. We stop attacking and start defending, and this time the defenses come with receipts ;-)
Here is the framing I want you to carry through the whole episode. Every technique below is chasing the same goal from a different angle: extract the pattern from the data without leaking the individuals in it. Hospitals want a model trained on patient records from a dozen institutions, but no hospital may ship a single row to another. Banks want to spot fraud rings that span competitors, without handing rivals their customer lists. Your phone wants a keyboard that learns how you type, without your late-night messages ever leaving the device. In every case the data cannot move, for reasons that are legal, competitive or plain ethical. Privacy-preserving AI is the toolbox that makes learning happen anyway -- and the important thing, the thing that separates it from a corporate pinky-promise, is that it leans on mathematics and cryptography rather than trust. Nobody has to believe you won't peek; the math makes peeking provably useless.
Solutions to episode #127's exercises
As promised, let's clear last time's homework before we build anything new.
Exercise 1 -- break your own model with FGSM. The point was to sweep the perturbation budget and find where the label flips:
import torch
import torch.nn as nn
def epsilon_sweep(model, image, true_label):
"""Run FGSM at increasing epsilon and watch the prediction crumble."""
for epsilon in [0.0, 0.01, 0.03, 0.1, 0.3]:
img = image.clone().detach()
img.requires_grad = True
loss = nn.functional.cross_entropy(model(img), true_label)
model.zero_grad()
loss.backward()
adv = torch.clamp(img + epsilon * img.grad.sign(), 0, 1)
with torch.no_grad():
probs = torch.softmax(model(adv), dim=1)
pred = probs.argmax(dim=1).item()
conf = probs[0, pred].item()
flipped = "FLIPPED" if pred != true_label.item() else "ok"
print(f"eps={epsilon:>4}: pred={pred} conf={conf:.2f} [{flipped}]")
Almost everyone finds the label survives epsilon=0.0 and 0.01, wobbles around 0.03, and is comprehensively wrong by 0.1 -- while your own eyes still see the original picture. That gap between "the model is fooled" and "I can see nothing wrong" is the whole reason episode #127 called adversarial examples a property of high-dimensional geometry, not a bug.
Exercise 2 -- watch a model betray its training set. The membership signal is the confidence gap between data the model has seen and data it hasn't:
def membership_gap(model, train_batch, test_batch):
"""Average confidence on training data minus on unseen data."""
model.eval()
def mean_conf(x, y):
with torch.no_grad():
probs = torch.softmax(model(x), dim=1)
return probs.gather(1, y.unsqueeze(1)).mean().item()
seen = mean_conf(*train_batch)
unseen = mean_conf(*test_batch)
print(f"seen={seen:.3f} unseen={unseen:.3f} gap={seen - unseen:.3f}")
return seen - unseen
The overfit model (tiny dataset, no regularisation, too many epochs) shows a fat gap -- it is far too confident on data it memorised. The well-regularised model shows a thin one. That is the punchline I wanted you to feel in your bones: regularisation, which back in episode #40 was just an accuracy trick, is quietly also a privacy defense. Hold onto that thought, because differential privacy is about to make it rigorous.
Exercise 3 -- design the defense stack. There was no single right answer here, only defensible reasoning. A public paid image API: top threats are model stealing (rate-limit plus top-k outputs) and adversarial inputs (input sanitisation, maybe adversarial training). An internal LLM assistant that reads documents and sends email: indirect prompt injection (sandbox the email tool behind a human/permission check) and data exfiltration (output filtering). A diagnostic model on hospital records: membership inference / inversion (which is precisely today's episode) and data poisoning (provenance and vetting). If you justified why each defense matched that system's biggest risk, you nailed it.
Differential privacy: a guarantee, not a vibe
Right, on to the star of the show. Every defense in episode #127 was a heuristic -- helpful, sensible, but ultimately a "trust me, this usually helps". Differential privacy is a different species entirely. It makes a formal promise: the output of a computation should be essentially the same whether or not any single individual's data was included. If one person's presence cannot meaningfully move the result, then no downstream attack can reliably pull that person back out. The strength of the promise is dialled by a single number, epsilon:
import numpy as np
def explain_epsilon():
"""What epsilon means, in plain terms."""
return {
'epsilon=0.1': 'Very strong: essentially impossible to single anyone out',
'epsilon=1.0': 'Strong: individual influence bounded by a factor ~2.7',
'epsilon=10': 'Weak: some privacy, but detectable in principle',
'epsilon=100': 'Essentially no guarantee at all',
}
# Formal statement -- for ANY output O and any two datasets D1, D2
# that differ by exactly one record:
# P(mechanism(D1) = O) <= e**epsilon * P(mechanism(D2) = O)
Read that formal line in the comment slowly, because it is the entire idea compressed into one inequality. It says: swapping one person in or out can change the probability of any given output by at most a factor of e**epsilon. Small epsilon means that factor is close to 1, which means the two worlds -- you-in, you-out -- are nearly indistinguishable. Large epsilon means the worlds are easy to tell apart, which means your presence shows. Smaller epsilon is stronger privacy. That is the one sentence to tattoo on the inside of your eyelids.
So how do you actually achieve it? By adding carefully calibrated noise, scaled to something called sensitivity -- the most that any one individual could possibly change the answer:
class DPMechanism:
"""The two classic ways to privatise a numeric answer."""
@staticmethod
def laplace_mechanism(true_value, sensitivity, epsilon):
"""Laplace noise -- the textbook mechanism for pure epsilon-DP."""
scale = sensitivity / epsilon
return true_value + np.random.laplace(0, scale)
@staticmethod
def gaussian_mechanism(true_value, sensitivity, epsilon, delta=1e-5):
"""Gaussian noise -- the workhorse for machine learning."""
sigma = sensitivity * np.sqrt(2 * np.log(1.25 / delta)) / epsilon
return true_value + np.random.normal(0, sigma)
# Releasing the average age of 1000 patients, privately.
true_avg = 45.3
sensitivity = 100 / 1000 # ages capped at 100, so one person shifts the mean by <= 100/n
private_avg = DPMechanism.gaussian_mechanism(true_avg, sensitivity, epsilon=1.0)
print(f"True average: {true_avg} Private average: {private_avg:.1f}")
Notice the shape of the trade-off baked into scale = sensitivity / epsilon. Want stronger privacy (smaller epsilon)? The scale goes UP, so you inject more noise, so your answer gets fuzzier. Want a crisper answer? You spend more epsilon and protect people less. There is no free lunch here, only a dial -- and the entire craft of differential privacy is choosing where to set that dial for the data in front of you. For a mean of n values bounded in [0, 100] the sensitivity is a tidy 100/n. For the gradient of a neural network it is not tidy at all, which is exactly the problem the next section solves.
DP-SGD: private training in two moves
Here is the lovely part. To make ordinary neural-network training differentially private, you do NOT need to rederive backpropagation or invent a new optimiser. You bolt two small moves onto the training loop you have used since episode #41: clip each example's gradient so no single person can shove the model too hard, then add noise to the aggregate so their fingerprint blurs away.
class DPSGD:
"""Differentially Private SGD, spelled out the slow, readable way."""
def __init__(self, model, lr=0.01, max_grad_norm=1.0, noise_multiplier=1.1):
self.model = model
self.lr = lr
self.max_grad_norm = max_grad_norm # the clipping bound
self.noise_multiplier = noise_multiplier # how much Gaussian noise
def train_step(self, batch_x, batch_y):
per_example = self._per_example_grads(batch_x, batch_y)
# MOVE 1: clip every example's gradient to bound its influence
clipped = []
for grads in per_example:
total_norm = torch.sqrt(sum((g ** 2).sum() for g in grads))
factor = min(1.0, self.max_grad_norm / (total_norm + 1e-6))
clipped.append([g * factor for g in grads])
n = len(clipped)
avg = [sum(clipped[i][j] for i in range(n)) / n
for j in range(len(clipped[0]))]
# MOVE 2: add calibrated Gaussian noise to the averaged gradient
for param, grad in zip(self.model.parameters(), avg):
noise_std = self.noise_multiplier * self.max_grad_norm / n
noisy = grad + torch.randn_like(grad) * noise_std
param.data -= self.lr * noisy
def _per_example_grads(self, batch_x, batch_y):
out = []
for x, y in zip(batch_x, batch_y):
self.model.zero_grad()
loss = nn.functional.cross_entropy(self.model(x.unsqueeze(0)),
y.unsqueeze(0))
loss.backward()
out.append([p.grad.clone() for p in self.model.parameters()])
return out
The two moves earn their keep together, and it is worth being precise about why. Clipping guarantees a bounded sensitivity: after clipping, no single training example can move the gradient by more than max_grad_norm, full stop. That is what makes one person's influence finite instead of unlimited. Then the noise, scaled to that same bound, hides whether any particular person was in the batch at all. Take away either move and the guarantee collapses -- noise without clipping is noise scaled to infinity, and clipping without noise still lets a determined attacker average away the clipping.
Now, that per-example loop above is honest but slow (it runs one backward pass per sample, which for a batch of 256 is 256 backward passes). Nobody trains like that in production. In real life you reach for Opacus, PyTorch's DP library, which computes per-example gradients efficiently with hooks and tracks your spent epsilon for you:
from opacus import PrivacyEngine
privacy_engine = PrivacyEngine()
model, optimizer, data_loader = privacy_engine.make_private(
module=model,
optimizer=optimizer,
data_loader=data_loader,
noise_multiplier=1.1,
max_grad_norm=1.0,
)
# ... your ORDINARY training loop, unchanged ...
# and afterwards, ask how much privacy you actually spent:
epsilon = privacy_engine.get_epsilon(delta=1e-5)
print(f"Trained with (epsilon={epsilon:.2f}, delta=1e-5)")
Four lines wrap your model, your optimiser and your data loader, and the training loop itself does not change one character. That is the bit that always makes people sit up: private training is not a different algorithm, it is your algorithm wearing a seatbelt. The bill, in practice, is roughly 5-15% accuracy versus non-private training, depending on the model and how tight you set epsilon. For cat photos, do not bother. For genomes, that 15% is the difference between a responsible system and a headline.
Federated learning: bring the model to the data
Differential privacy fixes what the model memorises. But look back at the hospital and the phone from the intro -- their problem starts one step earlier. They cannot even gather the data in one place. So flip the whole pipeline around: instead of bringing the data to the model, bring the model to the data. Each participant trains locally on data that never leaves their machine, and only the model update travels back. This is federated learning, and its simplest recipe is FedAvg -- literally averaging everyone's updated weights:
class FederatedServer:
"""Coordinates training without ever seeing a raw data point."""
def __init__(self, model_fn):
self.global_model = model_fn()
def aggregate(self, client_updates):
"""FedAvg: average the client weight dictionaries."""
global_dict = self.global_model.state_dict()
for key in global_dict:
global_dict[key] = torch.stack(
[update[key] for update in client_updates]
).mean(dim=0)
self.global_model.load_state_dict(global_dict)
return global_dict
class FederatedClient:
"""Trains on private local data, ships back ONLY the weights."""
def __init__(self, model_fn, local_data, local_labels):
self.model = model_fn()
self.data, self.labels = local_data, local_labels
def local_train(self, global_weights, epochs=5, lr=0.01):
self.model.load_state_dict(global_weights)
opt = torch.optim.SGD(self.model.parameters(), lr=lr)
for _ in range(epochs):
loss = nn.functional.cross_entropy(self.model(self.data), self.labels)
opt.zero_grad()
loss.backward()
opt.step()
return self.model.state_dict() # raw data stays home
def federated_training(server, clients, rounds=50):
for r in range(rounds):
weights = server.global_model.state_dict()
updates = [c.local_train(weights) for c in clients]
server.aggregate(updates)
print(f"Round {r}: aggregated {len(clients)} client updates")
This is not a toy from a textbook -- it is how Gboard, Google's Android keyboard, learns next-word prediction across a billion-odd phones without your typing ever being uploaded, and how Apple nudges bits of Siri. The model visits your device, learns a little from your data locally, and only the update goes home.
Having said that, do NOT walk away thinking federated learning is privacy by itself. It has real teeth-grinding challenges. Client data is non-IID (your phone's data looks nothing like mine, so naive averaging can wobble). Communication is expensive (shipping a full set of weights every round eats bandwidth). Stragglers -- one slow phone on a train tunnel -- hold up the whole round. And here is the sting: those innocent-looking weight updates can still leak information about the local data that produced them (there are gradient-inversion attacks that reconstruct inputs from updates alone). The grown-up move is to combine federated learning with the differential privacy from two sections ago, so each update is clipped and noised before it ever leaves the device. Belt AND braces.
Secure multi-party computation
Federated learning still trusts a central server to do the averaging. What if you do not want to trust anyone -- not even the aggregator? Enter secure multi-party computation (SMPC): several parties jointly compute a function while each keeps its own input completely secret. The gateway idea is additive secret sharing. You split your private number into random-looking shares that sum back to it, hand one share to each party, and no single share reveals a thing:
class SimpleSecretSharing:
"""Additive secret sharing -- the 'hello world' of SMPC."""
def __init__(self, n_parties):
self.n_parties = n_parties
def share(self, value):
"""Split a value into n shares that look like noise individually."""
shares = [np.random.uniform(-1000, 1000) for _ in range(self.n_parties - 1)]
shares.append(value - sum(shares)) # last share makes the sum come out right
return shares
def reconstruct(self, shares):
return sum(shares)
def private_sum(self, values_per_party):
"""Compute the SUM of everyone's value without revealing any single one."""
all_shares = [self.share(v) for v in values_per_party]
# Each party adds up the shares it was handed...
party_totals = [sum(all_shares[j][i] for j in range(self.n_parties))
for i in range(self.n_parties)]
# ...and only the aggregated totals get combined.
return self.reconstruct(party_totals)
# Three hospitals compute the combined average patient age,
# and NONE of them learns another's number.
sharing = SimpleSecretSharing(3)
hospital_means = [45.2, 52.1, 48.7]
total = sharing.private_sum(hospital_means)
print(f"Average across hospitals: {total / 3:.1f}")
The magic is that a single share is just a uniform random number -- it tells you nothing about the secret. Only when the shares are combined does the real answer reappear. This little example only does addition, mind you. Real protocols (SPDZ, ABY3 and friends) handle multiplication, comparison and, in principle, arbitrary computation -- but the honesty tax is steep: SMPC typically runs 100 to 1000 times slower than plaintext, because every multiplication turns into a flurry of network messages between the parties. Beautiful, rigorous, and something you reach for only when the trust problem genuinely demands it.
Homomorphic encryption: computing in the dark
If SMPC felt like sorcery, homomorphic encryption is the part where you start checking whether the universe is allowed to work this way. The promise: compute directly on encrypted data, never decrypting, and get back an encrypted result that -- once you and only you decrypt it -- is exactly the answer you wanted. Send your encrypted numbers to an untrusted cloud, let it crunch, get encrypted output back, and the cloud learns nothing:
# Conceptual toy -- real HE uses lattice libraries like SEAL, TFHE, OpenFHE.
class ConceptualHE:
"""Illustrates the ONE property that matters: additive homomorphism."""
def __init__(self, key=42):
self.key = key
def encrypt(self, value):
return value + self.key # real HE: hard lattice problems, not addition
def decrypt(self, ciphertext):
return ciphertext - self.key
def add_encrypted(self, ct1, ct2):
# The whole point: we add WITHOUT knowing the key or the plaintexts.
# decrypt(ct1 + ct2) == decrypt(ct1) + decrypt(ct2)
return ct1 + ct2
# What it looks like with a real library (TenSEAL, CKKS scheme):
# import tenseal as ts
# ctx = ts.context(ts.SCHEME_TYPE.CKKS, poly_modulus_degree=8192, ...)
# enc = ts.ckks_vector(ctx, [1.0, 2.0, 3.0])
# enc_result = enc + enc # arithmetic on ciphertext
# print(enc_result.decrypt()) # -> [2.0, 4.0, 6.0]
Fully Homomorphic Encryption (FHE) goes all the way -- both addition and multiplication on ciphertext, which is enough to compute anything in principle. So why is your bank not doing everything this way? One word: cost. FHE runs somewhere between 10,000 and 1,000,000 times slower than plaintext, which makes training a network on encrypted data a non-starter today. Where it starts to make sense is inference on a small model in a genuinely high-stakes setting -- a medical diagnosis, a financial computation -- where the privacy is worth paying a fortune in compute for. It is the most theoretically powerful tool in the box and the least practically used, and it is worth knowing that tension exists.
Budgeting privacy, because it runs out
One last idea, and it is the one people forget until it bites them. Privacy is a consumable. Every differentially private query you answer spends a little epsilon, and once it is gone, it is gone -- you cannot answer more questions without weakening every earlier answer. So you keep an accountant:
class PrivacyAccountant:
"""Track cumulative privacy expenditure against a fixed budget."""
def __init__(self, total_epsilon=10.0):
self.total_epsilon = total_epsilon
self.spent = 0.0
self.log = []
def record_query(self, query_epsilon, description):
if self.spent + query_epsilon > self.total_epsilon:
raise ValueError(
f"Budget exceeded: spent {self.spent:.2f}, "
f"requested {query_epsilon:.2f}, limit {self.total_epsilon}"
)
self.spent += query_epsilon
self.log.append((description, query_epsilon, self.spent))
remaining = self.total_epsilon - self.spent
print(f"{description}: eps={query_epsilon:.2f} remaining={remaining:.2f}")
acct = PrivacyAccountant(total_epsilon=10.0)
acct.record_query(2.0, "Train model v1")
acct.record_query(1.0, "Release aggregate statistics")
acct.record_query(3.0, "Train model v2")
# 4.0 epsilon left in the tank.
The reason this matters is the composition theorem: privacy degrades every time you use the data, and if you naively add up the epsilons you run out fast. The clever bit -- and the reason Opacus can train for many epochs without blowing the budget -- is advanced composition (the "moments accountant"), which tracks the spend far more tightly than plain addition and lets you squeeze many more queries out of the same total epsilon. Nota bene: this is a governance question as much as a maths one. Somebody has to own the budget and decide what it gets spent on, exactly like a team owns a cloud bill.
TL;DR - defending the people inside the data
- Differential privacy trades a heuristic for a mathematical guarantee: the epsilon knob bounds how much any single individual can affect the output -- smaller epsilon, stronger privacy, more noise, fuzzier answers;
- DP-SGD makes training private with just two moves, clip per-example gradients (bound one person's influence) then add calibrated noise (blur the fingerprint) -- and Opacus does it in about four lines while tracking your spent epsilon for you;
- federated learning brings the model to the data instead of hoarding the data (this is how phone keyboards learn) -- but weight updates can still leak, so pair it with DP;
- secure multi-party computation lets parties compute a joint answer while each keeps its input secret, via secret sharing, at roughly 100-1000x the cost of plaintext;
- homomorphic encryption computes on encrypted data without decrypting -- theoretically able to compute anything, but 10,000x+ slower, so realistic only for small-model inference in high-stakes cases;
- privacy is a budget: every private query spends epsilon you never get back, so account for it (advanced composition tracks the spend tightly enough to actually train with).
Exercises
Three to chew on before next time -- and as always I'll walk through full solutions in the following episode.
Feel the privacy-utility trade-off. Take the
gaussian_mechanismabove and privately release the mean of a synthetic dataset of 1000 values in[0, 100]. For eachepsilonin[0.1, 0.5, 1.0, 5.0, 10.0], run the mechanism 200 times and record the average absolute error versus the true mean. Plot error against epsilon (or just print the table) and write one sentence describing the shape of the curve you see.Train something for real with Opacus. Install Opacus, train a small classifier on MNIST twice -- once normally, once wrapped with
make_privateatnoise_multiplier=1.1,max_grad_norm=1.0. Report both test accuracies AND the final epsilon fromget_epsilon(delta=1e-5). Then re-run the private version at a larger noise multiplier and describe what happens to both the accuracy and the epsilon.Reason about the right tool. For each scenario, pick ONE technique from this episode (DP, federated learning, SMPC, or HE) and justify it in two sentences: (a) three banks want a shared fraud model but will not share customer data with each other or a central server; (b) a research team wants to publish aggregate statistics from a sensitive survey; (c) a cloud provider offers to run inference for a hospital but must never see the patient scans. There is no single correct mapping -- the reasoning is the point.
We now have a model that can learn from data without memorising the individuals inside it, spread across machines that never share a raw row, on inputs it may never even see in the clear. That is a serious amount of engineering. Which raises a slightly mischievous question for next time: we have spent this whole series making humans do the hard design work -- choosing architectures, tuning knobs, deciding what to try. What if we handed that job to the machine as well, and let it search for the best model on its own? That is where we head next ;-)