Machine Learning for CybersecuritySource on GitHub

Unit 8 · Lab 8b

Membership inference and DP-SGD

Lab 8b

Membership inference and DP-SGD

# On Google Colab, install the scikit-learn version the course was built with.
# On your own computer, install requirements.txt instead; this cell then does nothing.
import subprocess, sys
if "google.colab" in sys.modules:
    subprocess.run([sys.executable, "-m", "pip", "install", "-q", "scikit-learn==1.9.0"], check=True)

Lab 8b. Membership inference and DP-SGD

This lab measures how much a trained model reveals about whether a given record was in its training set, and how differentially private training limits that leak. It uses a subset of the MNIST handwritten digits, because membership inference is easiest to see on a model that can memorize individual examples, and it runs in a few minutes on a laptop CPU.

You will:

  1. split a pool of images at random into members (used for training) and non-members;
  2. train a small network until it overfits and run a loss-threshold membership inference attack, reporting the area under the ROC curve (AUC) and the true positive rate at false positive rates of 1% and 0.1%;
  3. run a stronger attack that calibrates each example's score with reference models;
  4. train the same network with DP-SGD, written out by hand, at three noise levels, compute the privacy parameter \(\epsilon\) with the Rényi differential privacy accountant (RDPAccountant) of the Opacus library, and measure test accuracy and both attacks again.

The next cell installs Opacus if it is missing (it is not preinstalled on Colab). Opacus is used only for privacy accounting.

try:
    import opacus  # noqa: F401
except ImportError:
    %pip install -q opacus
import os
import time
import warnings

import numpy as np
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torchvision
from opacus.accountants import RDPAccountant
from sklearn.metrics import roc_auc_score, roc_curve

warnings.filterwarnings("ignore")
torch.manual_seed(0)
torch.set_num_threads(2)   # the networks are small; more threads only add overhead
t_start = time.time()

ROOT = "../../data/downloads"
os.makedirs(ROOT, exist_ok=True)
train_set = torchvision.datasets.MNIST(root=ROOT, train=True, download=True)
test_set = torchvision.datasets.MNIST(root=ROOT, train=False, download=True)


def prepare(ds):
    X = ds.data.float().div(255).view(-1, 784)
    return (X - 0.1307) / 0.3081, ds.targets.clone()


X_all, Y_all = prepare(train_set)
X_test, Y_test = prepare(test_set)

1. Members and non-members

We draw a pool of 4,000 training images and flip a fair coin for each one. Heads, the image is a member and is used to train the target model; tails, it is a non-member. The attacker sees an image and the model, and must decide which. Because membership was decided by a coin, an attacker who knows nothing has an AUC of 0.5 and a true positive rate equal to its false positive rate.

POOL = 4_000
rng = np.random.default_rng(0)
pool_idx = rng.choice(len(X_all), POOL, replace=False)
X_pool, Y_pool = X_all[pool_idx], Y_all[pool_idx]
member = rng.random(POOL) < 0.5
X_tr, Y_tr = X_pool[member], Y_pool[member]
print(f"members: {member.sum():,}, non-members: {(~member).sum():,}")
members: 1,999, non-members: 2,001

2. An overfit model and the loss-threshold attack

The target model is a multilayer perceptron with one hidden layer of 256 units, trained with Adam for 60 epochs and no regularization, long enough to classify every training image correctly.

HIDDEN = 256


def make_net(seed):
    torch.manual_seed(seed)
    return nn.Sequential(nn.Linear(784, HIDDEN), nn.ReLU(), nn.Linear(HIDDEN, 10))


def train_adam(X, Y, seed, epochs=60, batch=128):
    net = make_net(seed)
    opt = torch.optim.Adam(net.parameters(), lr=1e-3)
    g = torch.Generator().manual_seed(seed)
    for _ in range(epochs):
        perm = torch.randperm(len(X), generator=g)
        for i in range(0, len(X), batch):
            b = perm[i:i + batch]
            opt.zero_grad()
            nn.functional.cross_entropy(net(X[b]), Y[b]).backward()
            opt.step()
    return net


def accuracy(net, X, Y):
    with torch.no_grad():
        return (net(X).argmax(1) == Y).float().mean().item()


target = train_adam(X_tr, Y_tr, seed=1)
acc_train, acc_test = accuracy(target, X_tr, Y_tr), accuracy(target, X_test, Y_test)
print(f"train accuracy {acc_train:.4f}, test accuracy {acc_test:.4f}")
train accuracy 1.0000, test accuracy 0.9227

The attack of Yeom et al. (2018) predicts "member" when the model's loss on the example is below a threshold. We use an equivalent score that does not lose precision when the loss is tiny, the logit-scaled confidence \(\log\big(p_y/(1-p_y)\big)\), where \(p_y\) is the probability the model gives the true label (Carlini et al., 2022). The cross-entropy loss is \(-\log p_y\), so a high score is the same as a low loss, and a threshold on one is a threshold on the other.

def membership_score(net, X, Y):
    """log(p_y / (1 - p_y)), computed from the logits in float64."""
    with torch.no_grad():
        z = net(X).double()
    rows = torch.arange(len(Y))
    z_true = z[rows, Y]
    z_other = z.clone()
    z_other[rows, Y] = -float("inf")
    return (z_true - torch.logsumexp(z_other, dim=1)).numpy()


def attack_metrics(score, is_member):
    fpr, tpr, _ = roc_curve(is_member, score)

    def tpr_at(f):
        return float(tpr[np.searchsorted(fpr, f, side="right") - 1])

    return {"auc": float(roc_auc_score(is_member, score)), "tpr_1": tpr_at(0.01), "tpr_01": tpr_at(0.001),
            "fpr": fpr, "tpr": tpr}


def intervals(score, is_member, n_boot=1000, seed=0):
    """95% bootstrap intervals for the AUC and the TPR at FPR 1% and 0.1%, resampling members and non-members separately."""
    rng_b = np.random.default_rng(seed)
    m_idx, n_idx = np.flatnonzero(is_member), np.flatnonzero(~is_member)
    stats = []
    for _ in range(n_boot):
        idx = np.concatenate([rng_b.choice(m_idx, len(m_idx)), rng_b.choice(n_idx, len(n_idx))])
        a = attack_metrics(score[idx], is_member[idx])
        stats.append((a["auc"], a["tpr_1"], a["tpr_01"]))
    lo, hi = np.percentile(np.array(stats), [2.5, 97.5], axis=0)
    return {"auc": (lo[0], hi[0]), "tpr_1": (lo[1], hi[1]), "tpr_01": (lo[2], hi[2])}


def show(name, att, ci):
    print(f"{name}: AUC {att['auc']:.3f} [{ci['auc'][0]:.3f}, {ci['auc'][1]:.3f}], "
          f"TPR at 1% FPR {att['tpr_1']:.4f} [{ci['tpr_1'][0]:.4f}, {ci['tpr_1'][1]:.4f}], "
          f"TPR at 0.1% FPR {att['tpr_01']:.4f} [{ci['tpr_01'][0]:.4f}, {ci['tpr_01'][1]:.4f}]")


score_target = membership_score(target, X_pool, Y_pool)
loss_attack = attack_metrics(score_target, member)
loss_ci = intervals(score_target, member)
show("loss-threshold attack", loss_attack, loss_ci)
print(f"balanced attack accuracy of the best threshold: {0.5 + 0.5 * np.max(loss_attack['tpr'] - loss_attack['fpr']):.3f}")
loss-threshold attack: AUC 0.571 [0.553, 0.589], TPR at 1% FPR 0.0065 [0.0015, 0.0120], TPR at 0.1% FPR 0.0000 [0.0000, 0.0025]
balanced attack accuracy of the best threshold: 0.616

The numbers in brackets are 95% intervals from a bootstrap that resamples the members and the non-members of the pool. They show how much each number depends on which records happened to be evaluated; they do not include the variation from retraining the target model.

The AUC is above 0.5, so the model leaks membership on average. The true positive rate at a false positive rate of 0.1% is the more telling number. It answers the question that matters to a person whose record might be in the training set, whether an attacker can identify some members with confidence. A global loss threshold does poorly there, because many non-members are easy images that any model classifies with tiny loss, and they are indistinguishable from members by loss alone.

3. Calibrating each example with reference models

Carlini et al. (2022) showed that the attack becomes much stronger at low false positive rates when each example's score is compared with how that same example scores on models that did not train on it. We train 16 reference models, each on a random half of the pool, with the same training procedure as the target. For every image, about eight reference models did not see it; the mean and standard deviation of its score on those models describe how confident a model is on that image when it is not a member. The calibrated score is the target score minus that mean, divided by that standard deviation. This is a simplified form of the offline variant of their likelihood ratio attack. It assumes the attacker knows the training procedure and has data from the same distribution, which Lesson 8.2 discusses.

K = 16


def calibrated_scores(score, train_fn, seed0, k_ref=K):
    """Standardize each example's target score by its scores on reference models that did not train on it."""
    ref = np.zeros((k_ref, POOL))
    out = np.zeros((k_ref, POOL), dtype=bool)
    for k in range(k_ref):
        in_k = np.random.default_rng(seed0 + k).random(POOL) < 0.5
        ref[k] = membership_score(train_fn(X_pool[in_k], Y_pool[in_k], seed0 + k), X_pool, Y_pool)
        out[k] = ~in_k
    cnt = out.sum(0)
    mu = (ref * out).sum(0) / np.maximum(cnt, 1)
    var = (((ref - mu) ** 2) * out).sum(0) / np.maximum(cnt - 1, 1)
    return (score - mu) / np.sqrt(np.maximum(var, 1e-2))


cal_score = calibrated_scores(score_target, train_adam, seed0=100)
cal_attack = attack_metrics(cal_score, member)
cal_ci = intervals(cal_score, member)
show("calibrated attack", cal_attack, cal_ci)
print(f"minutes so far: {(time.time() - t_start) / 60:.1f}")
calibrated attack: AUC 0.633 [0.616, 0.650], TPR at 1% FPR 0.0720 [0.0555, 0.0906], TPR at 0.1% FPR 0.0130 [0.0030, 0.0530]
minutes so far: 1.1
fig, ax = plt.subplots(figsize=(5, 4.2))
ax.plot(loss_attack["fpr"], loss_attack["tpr"], label=f"loss threshold (AUC {loss_attack['auc']:.2f})")
ax.plot(cal_attack["fpr"], cal_attack["tpr"], label=f"calibrated (AUC {cal_attack['auc']:.2f})")
ax.plot([1e-4, 1], [1e-4, 1], "k:", label="chance")
ax.set_xscale("log")
ax.set_yscale("log")
ax.set_xlim(1e-4, 1)
ax.set_ylim(1e-4, 1)
ax.set_xlabel("False positive rate")
ax.set_ylabel("True positive rate")
ax.legend()
plt.show()
Plot produced by the cell above

On log axes, the region that matters (false positive rates of 1% and below) is visible. On linear axes, both curves would look close to the diagonal. With 2,000 or so non-members, a false positive rate of 0.1% corresponds to about two non-members, so the numbers at that rate are noisy, which is why their intervals printed above are wide. A reliable measurement at a false positive rate of 0.1% needs many thousands of non-members.

4. DP-SGD by hand

DP-SGD (Abadi et al., 2016) changes one step of ordinary minibatch training. Each example's gradient is clipped to Euclidean norm at most \(C\), the clipped gradients are summed, Gaussian noise with standard deviation \(\sigma C\) is added to the sum, where \(\sigma\) is the noise multiplier, and the result is divided by the expected batch size. The batch is formed by Poisson sampling, each example joining independently with probability \(q\), which is what the privacy accountant assumes.

Per-example gradients are cheap for a network made of dense layers. For a layer with input \(a\) and gradient \(u\) with respect to its output, the per-example weight gradient is the outer product \(a\,u^\top\), whose Frobenius norm is \(\lVert a\rVert\,\lVert u\rVert\). The function below uses this to compute every example's full gradient norm without forming the gradients, and then forms only the clipped sum.

class DPMLP:
    """The same one-hidden-layer network, with weights held as plain tensors."""

    def __init__(self, seed):
        net = make_net(seed)
        self.W1 = net[0].weight.detach().T.clone()
        self.b1 = net[0].bias.detach().clone()
        self.W2 = net[2].weight.detach().T.clone()
        self.b2 = net[2].bias.detach().clone()

    def __call__(self, X):
        return torch.relu(X @ self.W1 + self.b1) @ self.W2 + self.b2

    def params(self):
        return [self.W1, self.b1, self.W2, self.b2]


def clipped_gradient_sum(m, X, Y, C):
    """Sum over the batch of per-example gradients, each clipped to norm C."""
    a = torch.relu(X @ m.W1 + m.b1)
    p = torch.softmax(a @ m.W2 + m.b2, dim=1)
    d2 = p.clone()
    d2[torch.arange(len(Y)), Y] -= 1.0                 # gradient of the loss w.r.t. the logits
    d1 = (d2 @ m.W2.T) * (a > 0)                       # w.r.t. the hidden pre-activations
    sq_norm = (d1 ** 2).sum(1) * ((X ** 2).sum(1) + 1) + (d2 ** 2).sum(1) * ((a ** 2).sum(1) + 1)
    scale = (C / (sq_norm.sqrt() + 1e-12)).clamp(max=1.0)
    d1s, d2s = d1 * scale[:, None], d2 * scale[:, None]
    return [X.T @ d1s, d1s.sum(0), a.T @ d2s, d2s.sum(0)], sq_norm.sqrt()


# Check the per-example norms against autograd on a few examples.
m_check = DPMLP(seed=5)
_, norms = clipped_gradient_sum(m_check, X_tr[:4], Y_tr[:4], C=1e9)
for i in range(4):
    ps = [p.clone().requires_grad_(True) for p in m_check.params()]
    out = torch.relu(X_tr[i:i + 1] @ ps[0] + ps[1]) @ ps[2] + ps[3]
    gs = torch.autograd.grad(nn.functional.cross_entropy(out, Y_tr[i:i + 1]), ps)
    print(f"example {i}: closed form {norms[i]:.5f}, autograd {torch.sqrt(sum((g ** 2).sum() for g in gs)):.5f}")
example 0: closed form 12.88036, autograd 12.88036
example 1: closed form 13.89285, autograd 13.89285
example 2: closed form 14.61452, autograd 14.61451
example 3: closed form 10.34340, autograd 10.34340

The training loop follows. With \(\sigma = 0\) it is clipped minibatch SGD with Poisson sampling and no noise, which we also run as a reference, with the same algorithm and the same number of steps but no privacy guarantee. For each setting we run both attacks; the calibrated attack trains its reference models with the same algorithm and noise level as the target, again with 16 reference models.

Q = 0.05          # sampling probability: expected batch of about 100 members
STEPS = 300       # 15 epochs
CLIP = 1.0
LR = 1.0
DELTA = 1e-5


def train_dpsgd(X, Y, sigma, seed=0):
    m = DPMLP(seed)
    g = torch.Generator().manual_seed(seed + 1)
    n = len(X)
    for _ in range(STEPS):
        idx = torch.nonzero(torch.rand(n, generator=g) < Q).squeeze(1)
        if len(idx) == 0:
            continue
        grads, _ = clipped_gradient_sum(m, X[idx], Y[idx], CLIP)
        for P, G in zip(m.params(), grads):
            noise = sigma * CLIP * torch.randn(G.shape, generator=g) if sigma > 0 else 0.0
            P -= LR * (G + noise) / (Q * n)
    return m


def epsilon(sigma):
    acc = RDPAccountant()
    acc.history = [(sigma, Q, STEPS)]
    return float(acc.get_epsilon(delta=DELTA))


K_DP = 16         # reference models per setting, as for the target
runs = {}
for sigma in (0.0, 0.6, 1.0, 3.0):
    m = train_dpsgd(X_tr, Y_tr, sigma)
    s = membership_score(m, X_pool, Y_pool)
    loss_att = attack_metrics(s, member)
    s_cal = calibrated_scores(s, lambda X, Y, seed: train_dpsgd(X, Y, sigma, seed), seed0=200, k_ref=K_DP)
    cal_att = attack_metrics(s_cal, member)
    runs[sigma] = {"eps": epsilon(sigma) if sigma > 0 else float("inf"), "acc_test": accuracy(m, X_test, Y_test),
                   "loss": loss_att, "cal": cal_att, "loss_ci": intervals(s, member), "cal_ci": intervals(s_cal, member)}
    r = runs[sigma]
    print(f"sigma {sigma:.1f}: epsilon {r['eps']:.2f}, test accuracy {r['acc_test']:.3f}")
    show("  loss-threshold attack", loss_att, r["loss_ci"])
    show("  calibrated attack", cal_att, r["cal_ci"])
print(f"minutes so far: {(time.time() - t_start) / 60:.1f}")
sigma 0.0: epsilon inf, test accuracy 0.901
  loss-threshold attack: AUC 0.545 [0.527, 0.562], TPR at 1% FPR 0.0095 [0.0035, 0.0160], TPR at 0.1% FPR 0.0005 [0.0000, 0.0030]
  calibrated attack: AUC 0.603 [0.586, 0.621], TPR at 1% FPR 0.0340 [0.0180, 0.0550], TPR at 0.1% FPR 0.0110 [0.0040, 0.0195]
sigma 0.6: epsilon 21.02, test accuracy 0.867
  loss-threshold attack: AUC 0.535 [0.516, 0.553], TPR at 1% FPR 0.0055 [0.0025, 0.0105], TPR at 0.1% FPR 0.0005 [0.0000, 0.0045]
  calibrated attack: AUC 0.550 [0.533, 0.568], TPR at 1% FPR 0.0110 [0.0035, 0.0220], TPR at 0.1% FPR 0.0005 [0.0000, 0.0020]
sigma 1.0: epsilon 6.46, test accuracy 0.803
  loss-threshold attack: AUC 0.529 [0.512, 0.547], TPR at 1% FPR 0.0090 [0.0040, 0.0145], TPR at 0.1% FPR 0.0010 [0.0000, 0.0030]
  calibrated attack: AUC 0.535 [0.518, 0.551], TPR at 1% FPR 0.0070 [0.0035, 0.0160], TPR at 0.1% FPR 0.0010 [0.0000, 0.0045]
sigma 3.0: epsilon 1.27, test accuracy 0.586
  loss-threshold attack: AUC 0.519 [0.502, 0.538], TPR at 1% FPR 0.0135 [0.0080, 0.0200], TPR at 0.1% FPR 0.0010 [0.0000, 0.0040]
  calibrated attack: AUC 0.516 [0.498, 0.534], TPR at 1% FPR 0.0090 [0.0050, 0.0190], TPR at 0.1% FPR 0.0005 [0.0000, 0.0025]
minutes so far: 3.3

For an \((\epsilon, \delta)\)-differentially private training algorithm, no attack can have a true positive rate above \(e^{\epsilon}\cdot\mathrm{FPR} + \delta\) in the game where each candidate record is included at random (Lesson 8.3). The next cell evaluates that ceiling at the two false positive rates. When \(\epsilon\) is large the ceiling exceeds 1 and says nothing; when \(\epsilon\) is small it is a real guarantee that holds for every attack, including attacks not yet invented.

for sigma in (0.6, 1.0, 3.0):
    e = runs[sigma]["eps"]
    print(f"epsilon {e:5.2f}: TPR ceiling at 1% FPR {min(1, np.exp(e) * 0.01 + DELTA):.4f}, "
          f"at 0.1% FPR {min(1, np.exp(e) * 0.001 + DELTA):.4f}; measured (calibrated) "
          f"{runs[sigma]['cal']['tpr_1']:.4f} and {runs[sigma]['cal']['tpr_01']:.4f}")
epsilon 21.02: TPR ceiling at 1% FPR 1.0000, at 0.1% FPR 1.0000; measured (calibrated) 0.0110 and 0.0005
epsilon  6.46: TPR ceiling at 1% FPR 1.0000, at 0.1% FPR 0.6389; measured (calibrated) 0.0070 and 0.0010
epsilon  1.27: TPR ceiling at 1% FPR 0.0357, at 0.1% FPR 0.0036; measured (calibrated) 0.0090 and 0.0005
fig, axes = plt.subplots(1, 2, figsize=(10, 3.6))
for sigma, r in runs.items():
    name = "clipped SGD, no noise" if sigma == 0 else f"sigma {sigma}, epsilon {r['eps']:.1f}"
    axes[0].plot(r["cal"]["fpr"], r["cal"]["tpr"], label=name)
axes[0].plot([1e-4, 1], [1e-4, 1], "k:")
axes[0].set_xscale("log")
axes[0].set_yscale("log")
axes[0].set_xlim(1e-4, 1)
axes[0].set_ylim(1e-4, 1)
axes[0].set_xlabel("False positive rate")
axes[0].set_ylabel("True positive rate (calibrated attack)")
axes[0].legend(fontsize=8)
eps_list = [runs[s]["eps"] for s in (0.6, 1.0, 3.0)]
axes[1].plot(eps_list, [runs[s]["acc_test"] for s in (0.6, 1.0, 3.0)], "o-")
axes[1].axhline(runs[0.0]["acc_test"], ls="--", color="gray", label="same training without noise")
axes[1].set_xscale("log")
axes[1].set_xticks(eps_list, [f"{e:.2f}" for e in eps_list])
axes[1].xaxis.set_minor_formatter(plt.NullFormatter())
axes[1].set_xlabel("Privacy parameter ϵ (δ = 1e-5)")
axes[1].set_ylabel("Test accuracy")
axes[1].legend()
plt.tight_layout()
plt.show()
Plot produced by the cell above

A smaller \(\epsilon\) costs test accuracy. The reference without noise shows how much of the drop in attack success comes from the training procedure itself (clipping and a short schedule) rather than from the noise, since a model trained this way memorizes less than the overfit target of Section 2. At a 1% false positive rate, compare each interval of the calibrated attack with the chance rate of 0.01. The intervals of the three noisy models contain it, and the interval of the model without noise lies above it. The calibrated attack's AUC tells a different story at the two larger values of \(\epsilon\), where its interval stays above 0.5, so the noise removed the confident identifications that this attack can make but not all of the leakage on average. The loss-threshold attack's AUC interval lies above 0.5 at all three noise levels, including the smallest \(\epsilon\). Only the noisy models carry a guarantee, and only the smallest \(\epsilon\) gives a ceiling tight enough to mean something at a 1% false positive rate.

Try this

Results summary

These are the numbers quoted in the lesson text.

def downsample(fpr, tpr, k=200):
    grid = np.unique(np.concatenate([np.logspace(-4, 0, k), [1.0]]))
    idx = np.searchsorted(fpr, grid, side="right") - 1
    return grid.tolist(), [float(v) for v in tpr[np.clip(idx, 0, len(tpr) - 1)]]


results = {
    "pool": POOL,
    "n_members": int(member.sum()),
    "n_nonmembers": int((~member).sum()),
    "target_acc_train": acc_train,
    "target_acc_test": acc_test,
    "loss_auc": loss_attack["auc"],
    "loss_tpr_1": loss_attack["tpr_1"],
    "loss_tpr_01": loss_attack["tpr_01"],
    "loss_balanced_acc": float(0.5 + 0.5 * np.max(loss_attack["tpr"] - loss_attack["fpr"])),
    "cal_auc": cal_attack["auc"],
    "cal_tpr_1": cal_attack["tpr_1"],
    "cal_tpr_01": cal_attack["tpr_01"],
    "n_ref": K,
    "n_ref_dp": K_DP,
    "dp_q": Q,
    "dp_steps": STEPS,
    "dp_epochs": float(STEPS * Q),
    "dp_clip": CLIP,
    "dp_delta": DELTA,
    "minutes_total": float((time.time() - t_start) / 60),
}


def add_ci(prefix, ci):
    for m in ("auc", "tpr_1", "tpr_01"):
        results[f"{prefix}_{m}_lo"] = float(ci[m][0])
        results[f"{prefix}_{m}_hi"] = float(ci[m][1])


add_ci("loss", loss_ci)
add_ci("cal", cal_ci)
for sigma, tag in ((0.0, "s00"), (0.6, "s06"), (1.0, "s10"), (3.0, "s30")):
    r = runs[sigma]
    results.update({f"{tag}_acc_test": r["acc_test"],
                    f"{tag}_loss_auc": r["loss"]["auc"], f"{tag}_loss_tpr_1": r["loss"]["tpr_1"],
                    f"{tag}_loss_tpr_01": r["loss"]["tpr_01"],
                    f"{tag}_cal_auc": r["cal"]["auc"], f"{tag}_cal_tpr_1": r["cal"]["tpr_1"],
                    f"{tag}_cal_tpr_01": r["cal"]["tpr_01"]})
    add_ci(f"{tag}_loss", r["loss_ci"])
    add_ci(f"{tag}_cal", r["cal_ci"])
    if sigma > 0:
        results.update({f"{tag}_eps": r["eps"],
                        f"{tag}_bound_1": float(min(1, np.exp(r["eps"]) * 0.01 + DELTA)),
                        f"{tag}_bound_01": float(min(1, np.exp(r["eps"]) * 0.001 + DELTA))})
export = {"roc_loss": downsample(loss_attack["fpr"], loss_attack["tpr"]),
          "roc_cal": downsample(cal_attack["fpr"], cal_attack["tpr"])}
for sigma, tag in ((0.0, "s00"), (0.6, "s06"), (1.0, "s10"), (3.0, "s30")):
    export[f"roc_cal_{tag}"] = downsample(runs[sigma]["cal"]["fpr"], runs[sigma]["cal"]["tpr"])
for k, v in results.items():
    print(f"{k:<20} {v}")

# When the course build runs this notebook, it saves the numbers for the lesson pages.
if os.environ.get("COURSE_RESULTS"):
    import json
    with open(os.environ["COURSE_RESULTS"], "w") as f:
        json.dump({"results": results, "data": export}, f)
pool                 4000
n_members            1999
n_nonmembers         2001
target_acc_train     1.0
target_acc_test      0.9226999878883362
loss_auc             0.5708766427191607
loss_tpr_1           0.0065032516258129065
loss_tpr_01          0.0
loss_balanced_acc    0.6159365289841323
cal_auc              0.6334491583622895
cal_tpr_1            0.0720360180090045
cal_tpr_01           0.013006503251625813
n_ref                16
n_ref_dp             16
dp_q                 0.05
dp_steps             300
dp_epochs            15.0
dp_clip              1.0
dp_delta             1e-05
minutes_total        3.2869449694951376
loss_auc_lo          0.5528409944602487
loss_auc_hi          0.5888872972218243
loss_tpr_1_lo        0.0015007503751875938
loss_tpr_1_hi        0.012018509254627302
loss_tpr_01_lo       0.0
loss_tpr_01_hi       0.0025012506253126563
cal_auc_lo           0.6156989164247291
cal_auc_hi           0.6504152126038031
cal_tpr_1_lo         0.05551525762881441
cal_tpr_1_hi         0.09055777888944472
cal_tpr_01_lo        0.0030015007503751876
cal_tpr_01_hi        0.05302651325662831
s00_acc_test         0.901199996471405
s00_loss_auc         0.544616136154034
s00_loss_tpr_1       0.009504752376188095
s00_loss_tpr_01      0.0005002501250625312
s00_cal_auc          0.6032694008173503
s00_cal_tpr_1        0.03401700850425213
s00_cal_tpr_01       0.011005502751375688
s00_loss_auc_lo      0.5267566004391501
s00_loss_auc_hi      0.5618773217193305
s00_loss_tpr_1_lo    0.003501750875437719
s00_loss_tpr_1_hi    0.016008004002001
s00_loss_tpr_01_lo   0.0
s00_loss_tpr_01_hi   0.0030015007503751876
s00_cal_auc_lo       0.5860046027511506
s00_cal_auc_hi       0.6209050427262607
s00_cal_tpr_1_lo     0.018009004502251125
s00_cal_tpr_1_hi     0.05502751375687844
s00_cal_tpr_01_lo    0.00400200100050025
s00_cal_tpr_01_hi    0.01950975487743872
s06_acc_test         0.8666999936103821
s06_loss_auc         0.534558383639596
s06_loss_tpr_1       0.005502751375687844
s06_loss_tpr_01      0.0005002501250625312
s06_cal_auc          0.5500638875159719
s06_cal_tpr_1        0.011005502751375688
s06_cal_tpr_01       0.0005002501250625312
s06_loss_auc_lo      0.5160926790231698
s06_loss_auc_hi      0.5525412818853204
s06_loss_tpr_1_lo    0.0025012506253126563
s06_loss_tpr_1_hi    0.010505252626313157
s06_loss_tpr_01_lo   0.0
s06_loss_tpr_01_hi   0.004502251125562781
s06_cal_auc_lo       0.5327984831996208
s06_cal_auc_hi       0.5675532543883136
s06_cal_tpr_1_lo     0.003501750875437719
s06_cal_tpr_1_hi     0.022011005502751375
s06_cal_tpr_01_lo    0.0
s06_cal_tpr_01_hi    0.002001000500250125
s06_eps              21.01633243440065
s06_bound_1          1.0
s06_bound_01         1.0
s10_acc_test         0.8029999732971191
s10_loss_auc         0.5288356322089081
s10_loss_tpr_1       0.009004502251125562
s10_loss_tpr_01      0.0010005002501250625
s10_cal_auc          0.5350381337595334
s10_cal_tpr_1        0.007003501750875438
s10_cal_tpr_01       0.0010005002501250625
s10_loss_auc_lo      0.5123440968360242
s10_loss_auc_hi      0.5469749242437311
s10_loss_tpr_1_lo    0.00400200100050025
s10_loss_tpr_1_hi    0.014507253626813406
s10_loss_tpr_01_lo   0.0
s10_loss_tpr_01_hi   0.0030015007503751876
s10_cal_auc_lo       0.517713435678359
s10_cal_auc_hi       0.551279500319875
s10_cal_tpr_1_lo     0.003501750875437719
s10_cal_tpr_1_hi     0.016020510255127553
s10_cal_tpr_01_lo    0.0
s10_cal_tpr_01_hi    0.004502251125562781
s10_eps              6.459696760236281
s10_bound_1          1.0
s10_bound_01         0.6388772972250103
s30_acc_test         0.5857999920845032
s30_loss_auc         0.5194963798740949
s30_loss_tpr_1       0.013506753376688344
s30_loss_tpr_01      0.0010005002501250625
s30_cal_auc          0.5161556290389072
s30_cal_tpr_1        0.009004502251125562
s30_cal_tpr_01       0.0005002501250625312
s30_loss_auc_lo      0.5024547506136876
s30_loss_auc_hi      0.5377851156962788
s30_loss_tpr_1_lo    0.0080040020010005
s30_loss_tpr_1_hi    0.02001000500250125
s30_loss_tpr_01_lo   0.0
s30_loss_tpr_01_hi   0.00400200100050025
s30_cal_auc_lo       0.4978553932138483
s30_cal_auc_hi       0.5335114771278693
s30_cal_tpr_1_lo     0.0050025012506253125
s30_cal_tpr_1_hi     0.019022011005502742
s30_cal_tpr_01_lo    0.0
s30_cal_tpr_01_hi    0.0025137568784392083
s30_eps              1.2725164984602888
s30_bound_1          0.035708247268301636
s30_bound_01         0.0035798247268301634