import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
site_data = [
pd.DataFrame({
"age": rng.integers(40, 71, size=n),
"sex": rng.choice(["M", "F"], size=n),
"biomarker": rng.uniform(0.0, 1.0, size=n),
})
for n in (1000, 500, 1500)
]Privacy-Preserving Count Aggregation
The problem
Multiple research sites each hold patient data and want to know the total count of patients matching a criterion (say age < 50 and sex == "F" and biomarker < 0.2) without revealing the individual per-site counts to anyone, including the aggregator running the aggregation.
Here we use OpenFHE’s BFV scheme via the openfhe-python binding. BFV operates on integer vectors with both addition and multiplication — well-suited to counting and other exact integer-valued aggregates.
This is the BFV companion to the CKKS-based real-valued master/worker pages (maximum likelihood, Cox regression). For exact integer aggregation BFV is the right choice; for real-valued sufficient statistics CKKS is the right choice.
Setup
The aggregator sets up encryption
from homomorphepy import fhe_context, packed_codec
# BFV: exact integer arithmetic mod plaintext_modulus.
cc = fhe_context("BFV", plaintext_modulus=65537, multiplicative_depth=1)
keys = cc.KeyGen()
pk, sk = keys.publicKey, keys.secretKey
codec = packed_codec(cc)In a real deployment the aggregator distributes the public key (and the serialized context) to each site. The secret key stays with the aggregator. We simulate this in one Python session by sharing cc and pk locally.
Each site computes locally and encrypts
from homomorphepy import Ct
QUERY = 'age < 50 and sex == "F" and biomarker < 0.2'
def site_encrypt(site_df, cc, pk):
count = len(site_df.query(QUERY))
pt = codec.encode(count)
return Ct(cc.Encrypt(pk, pt), cc.cc)
ct_site1 = site_encrypt(site_data[0], cc, pk)
ct_site2 = site_encrypt(site_data[1], cc, pk)
ct_site3 = site_encrypt(site_data[2], cc, pk)These encrypted counts are opaque to the aggregator — it learns nothing about any individual site’s count.
The aggregator aggregates
# Homomorphic addition: the aggregator never decrypts intermediate values.
ct_total = ct_site1 + ct_site2 + ct_site3
# Only the aggregator holds the secret key.
result = cc.Decrypt(ct_total.raw, sk)
total_count = codec.decode(result, 1)[0]
total_count113
Verification
true_count = sum(len(df.query(QUERY)) for df in site_data)
true_count113
assert total_count == true_countThe aggregated count matches the cleartext computation exactly. BFV is an exact scheme over the integers: unlike the real-valued arithmetic used elsewhere in this package, it introduces no approximation error at all.
The details of what happened
- The aggregator created an encryption context and distributed the public key to all sites.
- Each site computed its local count in the clear, encrypted it, and sent the encrypted count to the aggregator.
- The aggregator added the encrypted counts using
+, which gives the same answer as adding the counts themselves. - Only the aggregator, holding the secret key, could decrypt the total.
No site revealed its individual count. The aggregator never saw any patient-level data. The total is exact.
Serializing for a real distributed protocol
In a real deployment each site needs the aggregator’s context and public key. openfhe-python provides serialization:
import tempfile
from pathlib import Path
import openfhe
tdir = Path(tempfile.mkdtemp())
openfhe.SerializeToFile(str(tdir / "context.bin"), cc.cc, openfhe.BINARY)
openfhe.SerializeToFile(str(tdir / "pubkey.bin"), pk, openfhe.BINARY)
# At a site: read the context and the public key, encrypt a count.
cc_remote, _ = openfhe.DeserializeCryptoContext(str(tdir / "context.bin"), openfhe.BINARY)
pk_remote, _ = openfhe.DeserializePublicKey(str(tdir / "pubkey.bin"), openfhe.BINARY)
ct = cc_remote.Encrypt(pk_remote, cc_remote.MakePackedPlaintext([42]))
openfhe.SerializeToFile(str(tdir / "site_count.bin"), ct, openfhe.BINARY)
# At the aggregator: read the encrypted count and decrypt it.
ct_received, _ = openfhe.DeserializeCiphertext(str(tdir / "site_count.bin"), openfhe.BINARY)
codec.decode(cc.Decrypt(ct_received, sk), 1)[0]42
The same run, in one call
from homomorphepy.examples import aggregation
result = aggregation.run()
result.total_encrypted, result.total_cleartext, result.exact(113, 113, True)