import warnings
import cvxpy as cp
import numpy as np
warnings.simplefilter("ignore", UserWarning) # silence solver warnings
N, P, LAM = 3, 4, 1.0
SOLVER = cp.CLARABELConsensus ADMM with Gaussian Noise (Demonstration)
Introduction
This page is a demonstration. It fits an L2-regularized logistic regression across \(N\) sites by consensus ADMM under threshold FHE, the protocol the Cox-lasso page uses. Each site solves its local problem with cvxpy, and the aggregator decrypts only the average of the sites’ encrypted vectors.
The problem and its consensus split
With \(N\) sites, local data \((X_i, y_i)\) at site \(i\), and a shared coefficient \(x \in \mathbb{R}^p\), the global problem is
\[ \min_{x \in \mathbb{R}^p} \sum_{i=1}^{N} \ell_i(x; X_i, y_i) + \frac{\lambda}{2}\, \lVert x \rVert_2^2 \]
where \(\ell_i\) is the logistic loss on site \(i\). The standard consensus split (Boyd et al. 2011) introduces local copies \(x_i \in \mathbb{R}^p\) and a single global consensus \(z \in \mathbb{R}^p\):
\[ \min_{\{x_i\}, z} \sum_i \ell_i(x_i; X_i, y_i) + \frac{\lambda}{2}\, \lVert z \rVert_2^2 \quad \text{s.t.}\quad x_i = z, \ \forall i. \]
The augmented-Lagrangian iteration is:
\[ \begin{aligned} x_i^{k+1} &= \arg\min_{x_i}\, \ell_i(x_i) + \frac{\lambda}{2N}\lVert x_i\rVert_2^2 + \frac{\rho}{2}\lVert x_i - z^k + u_i^k\rVert_2^2, \\ z^{k+1} &= \frac{1}{N}\sum_i (x_i^{k+1} + u_i^k), \\ u_i^{k+1} &= u_i^k + (x_i^{k+1} - z^{k+1}). \end{aligned} \]
The \(x\)-update is local at each site; the \(z\)-update is the consensus average that has to traverse the encrypted channel; the \(u\)-update is local again. Only the \(z\)-update needs cryptography.
Adding noise
Here we ask what happens if each site also adds Gaussian noise of the kind an output differential privacy mechanism uses. The sensitivity \(\Delta = 1\) below is a placeholder, so the \(\varepsilon\) values are not a privacy guarantee. Adding the noise takes one extra rng.normal() call per site per iteration. With this optimizer, the fits are poor at any noise level that gives a small \(\varepsilon\). The page runs the fits and reports the numbers.
A brief output-DP primer
Given a query \(f: \mathcal{D} \to \mathbb{R}^p\), the Gaussian mechanism releases \(\tilde f(D) = f(D) + \mathcal{N}(0, \sigma^2 I)\). Sensitivity-driven \(\sigma\) gives single-query \((\varepsilon, \delta)\) DP. Multi-query composition uses zCDP (Bun and Steinke 2016): each release is \((\Delta/\sigma)^2/2\)-zCDP; \(T\) releases compose linearly to \(T \cdot \rho\); convert back to \((\varepsilon, \delta)\) via \(\varepsilon = \rho + 2\sqrt{\rho \log(1/\delta)}\).
The protocol modification
Without noise, site \(i\) encrypts \(x_i + u_i\) and the aggregator decrypts only their average \(z\). The change here is that at every ADMM iteration site \(i\) adds an independent draw \(\eta_i \sim \mathcal{N}(0, \sigma^2 N \cdot I)\) to \(x_i + u_i\) before encrypting. The encrypted noise terms sum under the joint key, the \(1/N\) scaling contracts the variance back to \(\sigma^2\) per coordinate, and the recovered \(z\) has noise \(\mathcal{N}(0, \sigma^2 I)\).
As on the Cox DP page, each site draws its own noise, so only site \(i\) sees its noiseless \(x_i + u_i\). The aggregator sees encrypted noisy contributions and, after decryption, their noisy average.
In the code below, site_contribution_dp() adds the noise and encrypts inside the site, so encrypted_consensus_dp() receives only encrypted values. If the aggregator added the noise instead, the numbers would be the same, but the aggregator would see each site’s noiseless \(x_i + u_i\).
The stopping rule changes
Under noise the residuals do not shrink below the per-iteration noise, so the number of iterations \(T\) is fixed in advance. Stopping on the residuals would also make \(T\) depend on the data, and \(T\) multiplies the privacy budget below. The next section picks \(\rho\) and \(T\) without using the cohort.
Setup
from homomorphepy import Site
# Inherits the abstract `Site` (which supplies `name` and the key share),
# so it can take part in threshold key generation and keep its own
# share. z and u are cvxpy Parameters, so the problem canonicalizes
# once and every iteration reuses it.
class ConsensusSite(Site):
_needs_local_fn = False # ADMM does not use the contribute() round
def __init__(self, name, X, y, rho):
super().__init__(name, (X, y), None)
self.x_var = cp.Variable(P)
self.z_par = cp.Parameter(P, value=np.zeros(P))
self.u_par = cp.Parameter(P, value=np.zeros(P))
margins = -cp.multiply(2.0 * y - 1.0, X @ self.x_var)
local_loss = (cp.sum(cp.logistic(margins))
+ (LAM / (2 * N)) * cp.sum_squares(self.x_var))
augmented = (rho / 2) * cp.sum_squares(self.x_var - self.z_par + self.u_par)
self.problem = cp.Problem(cp.Minimize(local_loss + augmented))
self.x_curr = np.zeros(P)
self.u_curr = np.zeros(P)
def local_update(self, z_curr):
self.z_par.value = z_curr
self.u_par.value = self.u_curr
self.problem.solve(solver=SOLVER)
if self.problem.status not in ("optimal", "optimal_inaccurate"):
raise RuntimeError(f"local solve at {self.name} ended {self.problem.status}")
self.x_curr = np.asarray(self.x_var.value).ravel()
def dual_update(self, z_new):
self.u_curr = self.u_curr + (self.x_curr - z_new)# The loop takes the consensus step as a function, so the same loop
# runs in the clear and through the encrypted channel. It stops when
# both residuals fall below tol; tol = 0 runs exactly max_iter iterations.
def admm_loop(sites, rho, max_iter, tol, consensus_fn):
z = np.zeros(P)
for k in range(1, max_iter + 1):
for s in sites:
s.local_update(z)
z_new = consensus_fn(sites)
for s in sites:
s.dual_update(z_new)
primal = np.sqrt(np.mean([np.sum((s.x_curr - z_new) ** 2) for s in sites]))
dual = rho * np.linalg.norm(z_new - z)
z = z_new
if primal < tol and dual < tol:
return z, k, True
return z, max_iter, False
def plain_consensus(sites):
return sum(s.x_curr + s.u_curr for s in sites) / len(sites)Simulated cohort
N_PER = (500, 1000, 1500)
BETA_TRUE = np.array([-0.5, 0.4, -0.3, 0.6]) # intercept, age, bmi, sex
COVARIATES = ["intercept", "age", "bmi", "sex"]
def draw_cohort(beta, rng):
cohort = []
for n in N_PER:
X = np.column_stack([np.ones(n), rng.normal(size=n), rng.normal(size=n),
rng.binomial(1, 0.5, size=n)]).astype(float)
prob = 1.0 / (1.0 + np.exp(-(X @ beta)))
cohort.append((X, (rng.uniform(size=n) < prob).astype(float)))
return cohort
site_data = draw_cohort(BETA_TRUE, np.random.default_rng(20260412))Choosing \(\rho\) and \(T\) without touching the cohort
Tuning \(\rho\) on the real data would itself be a release: the chosen \(\rho\) and \(T\) depend on the records, and the budget below counts only the \(T\) Gaussian releases. So the sweep runs on a surrogate cohort built only from facts the protocol already treats as public: the number of sites, their sizes, and the covariate schema. The effect sizes are nominal values fixed in the analysis plan. No record from any site enters it, so the sweep needs no encryption.
import pandas as pd
TOL, MAX_ITER = 1e-3, 60
BETA_NOMINAL = np.array([0.0, 0.5, 0.5, 0.5]) # nominal, not the cohort's
surrogate_data = draw_cohort(BETA_NOMINAL, np.random.default_rng(20260413))
def build(cohort, rho):
return [ConsensusSite(f"Site {i + 1}", X, y, rho) for i, (X, y) in enumerate(cohort)]
rows = []
for rho in (10.0, 20.0, 50.0, 100.0, 500.0):
_, k, ok = admm_loop(build(surrogate_data, rho), rho, MAX_ITER, TOL, plain_consensus)
rows.append({"rho": rho, "iters": k, "converged": ok})
rho_sweep = pd.DataFrame(rows)
converged = rho_sweep[rho_sweep["converged"]]
if converged.empty:
raise RuntimeError("No rho in the grid converged within max_iter on the surrogate.")
best = converged["iters"].idxmin() # the first minimum, in grid order
rho_chosen, T_fixed = float(converged.loc[best, "rho"]), int(converged.loc[best, "iters"])
rho_sweep| rho | iters | converged | |
|---|---|---|---|
| 0 | 10.0 | 60 | False |
| 1 | 20.0 | 60 | True |
| 2 | 50.0 | 30 | True |
| 3 | 100.0 | 27 | True |
| 4 | 500.0 | 60 | False |
The value of \(\rho\) with the fewest iterations to convergence is \(\rho = 100\), with \(T = 27\). Both are now fixed. The DP-ADMM loop below runs exactly \(T = 27\) iterations, whatever the residuals do.
Threshold-FHE setup
from homomorphepy import fhe_context, make_threshold_master
cc = fhe_context("CKKS", multiplicative_depth=1, scaling_mod_size=59,
first_mod_size=60, batch_size=8)The DP version of the consensus step. The only change from the Cox-lasso page’s encrypted_consensus() is the rng.normal(0.0, sigma * np.sqrt(N), size=P) term inside the per-site step:
def site_contribution_dp(site, sigma, rng):
# Site-side: the site draws its own noise, adds it, and encrypts with
# the public parameters it received at setup, all before anything
# leaves the site. The noiseless x_i + u_i never leaves.
noise = rng.normal(0.0, sigma * np.sqrt(N), size=P) if sigma > 0 else 0.0
return site.encrypt(site.x_curr + site.u_curr + noise)
def encrypted_consensus_dp(master, sites, sigma, rngs):
# Aggregator-side: sum the encrypted values, scale, threshold-decrypt.
# The 1/N scaling contracts the summed noise variance back to sigma^2.
cts = [site_contribution_dp(s, sigma, r) for s, r in zip(sites, rngs)]
ct_avg = sum(cts) * (1.0 / len(sites))
return np.asarray(master.decrypt(ct_avg, length=P), dtype=float)The DP-ADMM loop
def run_dp_admm(sigma, seed):
# The sites exist first: the joint public key is built from them,
# each keeping the share it generates.
sites = build(site_data, rho_chosen)
master = make_threshold_master("Aggregator", cc, sites)
rngs = [np.random.default_rng([seed, i]) for i in range(N)]
z, _, _ = admm_loop(sites, rho_chosen, T_fixed, 0.0,
lambda s: encrypted_consensus_dp(master, s, sigma, rngs))
return zCentralized cvxpy fit
This fit pools the raw data, so it is not part of the protocol. We use it only to measure how far the noise moves the answer, in the last column of the table below. It is not released, so it is not charged to the budget.
X_pooled = np.vstack([X for X, _ in site_data])
y_pooled = np.concatenate([y for _, y in site_data])
beta_var = cp.Variable(P)
margins = -cp.multiply(2.0 * y_pooled - 1.0, X_pooled @ beta_var)
cp.Problem(cp.Minimize(cp.sum(cp.logistic(margins))
+ (LAM / 2) * cp.sum_squares(beta_var))).solve(solver=SOLVER)
beta_central = np.asarray(beta_var.value).ravel()The \(\sigma\) sweep
Six \(\sigma\) values from zero to one. The \(\sigma = 0\) row checks that the protocol without noise matches the centralized fit.
sigma_grid = [0.0, 1e-4, 1e-3, 1e-2, 1e-1, 1.0]
sweep_results = [run_dp_admm(s, seed=100 + j) for j, s in enumerate(sigma_grid, start=1)]clean_dev = float(np.max(np.abs(sweep_results[0] - beta_central)))
agree_tol = 10 * TOL
if clean_dev > agree_tol:
raise RuntimeError("DP-ADMM at sigma = 0 disagrees with the centralized fit.")At \(\sigma = 0\) the largest coefficient deviation from the centralized fit is 3.19e-05, within \(10 \times\) the ADMM tolerance of 0.001.
summary = pd.DataFrame(
[list(z) + [np.max(np.abs(z - beta_central))] for z in sweep_results]
+ [list(beta_central) + [0.0]],
columns=COVARIATES + ["max_dev"],
index=[f"sigma={s:g}" for s in sigma_grid] + ["centralized"],
)
summary.round(6)| intercept | age | bmi | sex | max_dev | |
|---|---|---|---|---|---|
| sigma=0 | -0.488292 | 0.395497 | -0.292848 | 0.602268 | 0.000032 |
| sigma=0.0001 | -0.488225 | 0.395567 | -0.292813 | 0.602169 | 0.000131 |
| sigma=0.001 | -0.488050 | 0.393451 | -0.291553 | 0.602096 | 0.002042 |
| sigma=0.01 | -0.482145 | 0.388394 | -0.288560 | 0.609925 | 0.007624 |
| sigma=0.1 | -0.494716 | 0.422191 | -0.351942 | 0.546630 | 0.059092 |
| sigma=1 | -1.183212 | 1.272715 | -2.147010 | 2.059519 | 1.854161 |
| centralized | -0.488296 | 0.395493 | -0.292849 | 0.602300 | 0.000000 |
Fidelity decays monotonically as expected.
Privacy budget
Each iteration releases one noisy average \(z\), with noise \(\mathcal{N}(0, \sigma^2 I)\). Let \(\Delta\) be the largest change in \(z\) (L2 norm over all \(p\) coordinates) from adding or removing one record. Each record sits at one site, and the aggregator decrypts only the average, so one iteration is a single Gaussian release with \(\rho_{\text{iter}} = (\Delta/\sigma)^2/2\). Over \(T = 27\) iterations the total is \(\rho = T \cdot (\Delta/\sigma)^2/2\), whatever the number of sites. This is the same accounting as the Cox DP page. With sensitivity \(\Delta = 1\) (placeholder) and target \(\delta = 10^{-5}\), zCDP composition gives:
def zcdp_to_eps(rho, delta=1e-5):
return rho + 2 * np.sqrt(rho * np.log(1 / delta))
budget = pd.DataFrame({"sigma": [s for s in sigma_grid if s > 0]})
budget["rho_total"] = T_fixed * (1 / budget["sigma"]) ** 2 / 2
budget["epsilon_at_delta_1e-5"] = zcdp_to_eps(budget["rho_total"])
budget.round(4)| sigma | rho_total | epsilon_at_delta_1e-5 | |
|---|---|---|---|
| 0 | 0.0001 | 1.350000e+09 | 1.350249e+09 |
| 1 | 0.0010 | 1.350000e+07 | 1.352493e+07 |
| 2 | 0.0100 | 1.350000e+05 | 1.374934e+05 |
| 3 | 0.1000 | 1.350000e+03 | 1.599339e+03 |
| 4 | 1.0000 | 1.350000e+01 | 3.843390e+01 |
The smallest \(\varepsilon\), at \(\sigma = 1\) where the fit is already poor, is 38. Whether that is acceptable depends on the application.
One could also explore a tighter sensitivity bound \(\Delta\), or pay for the choice of \(\rho\) and \(T\) with a DP selection mechanism, etc. We don’t do that here.
The same run, in one call
from homomorphepy.examples.dp import consensus_sweep
result = consensus_sweep()
result.choice.rho, result.choice.T, f"{result.clean_dev:.2e}"(100.0, 27, '3.18e-05')