The cox vignette fits a stratified Cox model across
three sites under CKKS. There the master holds the secret key and
decrypts the encrypted sum at every iteration of the optimizer, so the
master must be trusted not to decrypt anything else, such as a single
site’s contribution.
In this vignette the three sites generate the CKKS key pair jointly, and each keeps its own share of the secret key. No single party holds the whole key. The master becomes an aggregator: it adds the encrypted contributions and collects the sites’ partial decryptions, but it cannot decrypt anything by itself.
The data and the model are the same as in
vignette("cox"). Only who can decrypt changes.
Three sites and one untrusted aggregator:
What the aggregator sees, by stage:
Step 4 reveals \(\ell(\beta)\) to
the aggregator, which is what the master saw in
vignette("cox"). The difference is that no single party can
decrypt an individual contribution or any intermediate value. That takes
a partial decryption from every site.
cox.Rmd)suppressPackageStartupMessages(library(survival))
library(homomorpheR)
data(DLBCL)
cox_data <- split(
DLBCL[, c("time", "status", "GCB_sig", "LN_sig",
"Prolif_sig", "BMP6", "MHC2_sig", "Subgroup")],
DLBCL$Subgroup)
Setup (once):
key_gen(cc) to produce its keypair \((\mathit{pk}_1, \mathit{sk}_1)\).multiparty_key_gen(cc, pk_1) to produce
\((\mathit{pk}_{12},
\mathit{sk}_2)\).multiparty_key_gen(cc, pk_{12}) to produce
\((\mathit{pk}_{123},
\mathit{sk}_3)\).Per query (called inside the optimizer):
make_threshold_master() runs this chain across the sites
in one call and returns a ThresholdMaster holding the joint
public key. Each site keeps the share it generated. To decrypt, the
master asks every site for a partial decryption and combines them. This
happens inside the decrypt() method, so the
master_aggregate() runner from cox.Rmd works
unchanged.
cph_control <- replace(coxph.control(), "iter.max", 0)
local_cox_nll <- function(data, beta) {
fit <- tryCatch(
coxph(Surv(time, status) ~ GCB_sig + LN_sig + Prolif_sig +
BMP6 + MHC2_sig,
data = data,
init = beta,
control = cph_control),
error = function(e) NULL)
if (is.null(fit)) NA_real_ else -fit$loglik[1]
}
The CKKS context needs the MULTIPARTY feature enabled so
the chained multiparty_key_gen() calls work:
cc <- openfhe.R::fhe_context("CKKS",
multiplicative_depth = 1L,
scaling_mod_size = 59L,
first_mod_size = 60L,
batch_size = 8L,
features = c(openfhe.R::Feature$MULTIPARTY))
The sites come first, because the joint public key is built from
them. make_threshold_master() then takes the sites and
returns the master.
worker_gcb <- make_worker(name = "GCB", data = cox_data[["GCB"]],
contribution_fn = local_cox_nll)
worker_abc <- make_worker(name = "ABC", data = cox_data[["ABC"]],
contribution_fn = local_cox_nll)
worker_t3 <- make_worker(name = "Type III", data = cox_data[["Type III"]],
contribution_fn = local_cox_nll)
master <- make_threshold_master("Aggregator",
crypto_context = cc,
sites = list(worker_gcb, worker_abc, worker_t3))
The check below confirms that the master has no property holding key shares and that the GCB site holds its own share:
share_check <- c(master_holds_shares = "secret_keys" %in% names(S7::props(master)),
gcb_holds_own_share = !is.null(worker_gcb@state$sk))
share_check
## master_holds_shares gcb_holds_own_share
## FALSE TRUE
The optimizer code is the same as in cox.Rmd. Only the
master class differs.
library(stats4)
encrypted_nLL <- function(GCB_sig, LN_sig, Prolif_sig, BMP6, MHC2_sig) {
master_aggregate(master, c(GCB_sig, LN_sig, Prolif_sig, BMP6, MHC2_sig))
}
fit <- mle(encrypted_nLL,
start = list(GCB_sig = 0, LN_sig = 0, Prolif_sig = 0,
BMP6 = 0, MHC2_sig = 0),
method = "BFGS",
control = list(reltol = 1e-7))
summary(fit)
logLik(fit)
## Estimate Std. Error
## GCB_sig -0.2638698 0.11940447
## LN_sig -0.2543587 0.08515178
## Prolif_sig 0.3031250 0.14981283
## BMP6 0.3036367 0.10727837
## MHC2_sig -0.3191459 0.09412946
## 'log Lik.' -495.229022 (df=5)
As in vignette("cox"), the check is the identical
mle() objective with the encrypted aggregation replaced by
an ordinary sum of the three sites’ cleartext values.
library(stats4)
plain_nLL <- function(GCB_sig, LN_sig, Prolif_sig, BMP6, MHC2_sig) {
beta <- c(GCB_sig, LN_sig, Prolif_sig, BMP6, MHC2_sig)
sum(vapply(cox_data, local_cox_nll, numeric(1), beta = beta))
}
fit_plain <- mle(plain_nLL,
start = list(GCB_sig = 0, LN_sig = 0, Prolif_sig = 0,
BMP6 = 0, MHC2_sig = 0),
method = "BFGS",
control = list(reltol = 1e-7))
| Coefficient |
\(\hat\beta\), mle()
threshold
|
\(\hat\beta\), mle()
cleartext
|
\(\lvert \text{difference} \rvert\) |
|---|---|---|---|
| GCB_sig | -0.2638698 | -0.2638698 | \(1.73 \times 10^{-12}\) |
| LN_sig | -0.2543587 | -0.2543587 | \(8.39 \times 10^{-13}\) |
| Prolif_sig | 0.3031250 | 0.3031250 | \(5.27 \times 10^{-13}\) |
| BMP6 | 0.3036367 | 0.3036367 | \(1.37 \times 10^{-12}\) |
| MHC2_sig | -0.3191459 | -0.3191459 | \(2.72 \times 10^{-13}\) |
The threshold fit agrees with the cleartext fit to within 1.73e-12 in every coefficient.
cox.Rmd only the setup
changed: the workers are built first and handed to
make_threshold_master() instead of being wired to a
make_ckks_master() afterwards, because the joint key cannot
exist before the sites do. The optimizer sees nothing different.mle() asks for. The individual
site contributions stay hidden. Hiding \(\ell(\beta)\) as well would require running
the optimizer on encrypted values, which is possible but considerably
more complex.