简介:面向毕业设计、课程设计与软件工程实践的联邦学习安全聚合项目,基于TensorFlow与同态加密技术,重点解决模型聚合阶段的数据隐私泄露风险,可作为隐私保护机器学习方向的学习范本。源码包共55个文件,约2.06MB,以Python脚本与模型文件(model/modle)为主体,另含批处理启动脚本、配置文件、密钥证书及README运行说明,目录结构清晰,便于直接对照代码与说明学习。当前已有221人学习下载。项目完整展示了联邦学习框架搭建、同态加密库调用、模型并行与数据并行策略,以及秘密共享等安全聚合算法的实现细节;运行说明覆盖环境配置、参数设置、训练循环与结果评估等关键步骤,并提供了服务端/客户端启动脚本,适合希望深入理解隐私保护机器学习、动手复现安全聚合流程的开发者。
1. 同态加密联邦学习到底在解决什么问题
在联邦学习流程中,客户端把梯度上传给中心服务器,服务器在内存中聚合后再下发全局参数。数据没有离开本地,但梯度以明文形式出现在服务器里的那一瞬间,就是系统最大的破绽。梯度泄露攻击已能还原出高保真训练样本,“诚实但好奇”的服务器和被攻破的服务器都能从中提取敏感信息。
同态加密把“梯度可见”这个环节去掉:客户端本地加密,服务器在密文上求和,只有聚合完成后才由持私钥的一方解密。服务器自始至终看不到原始梯度。以 TensorFlow 做训练框架、以 Paillier 这类加法同态加密做安全聚合,是当前联邦学习工程落地里最常见的一套搭配,标题里这份源码系统也遵守了这条主线。
接下来按威胁模型、选型、最小实现、参数排错、隐私增强五部分展开。前两节给后面的代码和配置建立依据,后三节可以当作实施清单直接用。
2. 梯度即隐私:联邦学习的聚合风险与同态加密选型
2.1 安全聚合的威胁模型与边界
在安全聚合设计里,攻击者通常按四种身份建模:
- 诚实但好奇的服务器:严格按协议执行聚合,但会留存每轮明文梯度,做成员推断或属性推断;
- 被攻破的服务器:攻击者拿到内存、磁盘或容器权限,明文梯度直接落网;
- 恶意客户端:提交伪造梯度参与聚合,或在收到全局模型后反推其他客户端的梯度贡献;
- 网络嗅探者:在不安全的链路上抓包,直接获得通信内容。
同态加密解决第 1、2、4 类的机密性问题,因为梯度在客户端本地就完成加密,链路和服务器内存里物理上不存在明文。对第 3 类恶意客户端,同态加密无能为力——恶意客户端提交什么,它就被加密成什么。抗拜占庭的鲁棒聚合算法比如 Krum、Trimmed Mean 需要在加密之外单独接入。所以这套系统的总体架构是:同态加密保护机密性,鲁棒聚合保护完整性,TLS 保护传输层,三者叠加才称得上完整的安全聚合。
这也是标题里“安全聚合”四个字最容易引起误解的地方:它不是某个单点加密算法,而是一组互补机制的集合。开始选型之前,先把自己要防的攻击者身份说清楚,否则后续参数和流程设计都会失真。
2.2 加法同态够用吗:Paillier 与 CKKS 的取舍
联邦学习最经典的 FedAvg 聚合公式可以写成:
w_{t+1} = w_t - lr * (1 / K) * Σ Δw_k其中 Δw_k 是第 k 个客户端的梯度更新,服务端要做的事是求和后取平均。去掉减法和学习率系数,核心运算是加法,这正好落在加法同态的射程内:密文相加对应明文相加。因此标题这一类以 TensorFlow 为前端、聚合逻辑只有平均和加权平均的工程实现,优先选 Paillier 而不是全同态方案。
| 维度 | Paillier | CKKS |
|---|---|---|
| 同态能力 | 加法同态 | 加法和乘法,浮点近似 |
| 数值类型 | 整数 | 浮点复数 |
| 精度 | 精确 | 近似,有截断误差 |
| 密文膨胀率 | 约 2~4 倍 | 数十倍,依赖安全参数 |
| 工程成本 | 低,python-paillier 可用 | 高,依赖 SEAL/OpenFHE |
| 典型聚合算法 | FedAvg、FedSGD | FedProx、带正则/动量的聚合 |
选定方案之后,真正耗时间的是数值编码。Paillier 工作在整数域,CKKS 虽然能直接处理浮点,但参数配置复杂,对内存和 CPU 的要求高一个量级。
2.2.1 Paillier 的缩放与取整
TensorFlow 默认梯度是 float32,Paillier 只能加密整数。常见做法是选一个缩放因子 precision,把梯度乘以 1/precision 后取整,解密后除以同一个因子恢复浮点值。缩放因子直接决定量化误差,也会在后面影响明文溢出判断,所以它既是加密参数,也是训练超参的一部分。
2.2.2 CKKS 的适用场景
如果聚合逻辑需要在密文状态做乘法,例如计算梯度方差,或者 FedProx 里对权重偏离加正则项,Paillier 就不够用了。此时才值得升级到 CKKS,代价是密文体积膨胀到极高,旋转和重线性化操作非常吃内存,服务端聚合速度会明显下降。把这些成本放在一起比较,选型结论很直接:聚合只有平均就选 Paillier,有复杂聚合逻辑再考虑 CKKS。
2.3 差分隐私不能替代同态加密的两个原因
第一,差分隐私通过注入噪声隐藏个体贡献,噪声进入最终模型后会造成精度损失,并且和隐私预算 ε 强相关;同态加密不引入统计噪声,解密结果和明文聚合在数学上等价,只差浮点缩放误差。第二,差分隐私保护的是“某个样本是否参与了训练”这类统计性信息,同态加密保护的是“这次上传的梯度具体值是多少”,两者隐私语义完全不同。
一个常见误用是把两者当作同一件事:加了同态加密之后,全局模型仍然以明文下发给各方参与方,从最终模型反推训练数据的攻击依然成立。防这一类泄露需要差分隐私或数据消毒来兜底,而不是加密本身。
3. 在 TensorFlow 中落地安全聚合的最小实现
3.1 系统目录与模块职责
一套可运行的联邦安全聚合系统至少包含客户端、服务端、配置三部分。按源码包的形式组织,比较常见的目录结构是:
fedsec-aggregator/ ├── client/ │ ├── model.py # TensorFlow 模型定义与训练循环 │ ├── encrypt.py # 梯度展平、缩放、加密 │ └── client_main.py # 客户端入口 ├── server/ │ ├── aggregator.py # 密文聚合与轮次管理 │ ├── decrypt.py # 解密、反缩放、重构模型 │ └── server_main.py # 服务端入口 ├── config/ │ ├── he_config.yaml # 同态加密参数:密钥位数、precision │ └── fl_config.yaml # 联邦学习超参:轮次、客户端数、批次大小 └── utils/ └── serialize.py # 密文序列化与网络传输封装模块职责如下表:
| 模块 | 职责 | 对外接口 |
|---|---|---|
| client/encrypt.py | 本地加密梯度更新 | encrypt_client_update(grads, pub_key) |
| server/aggregator.py | 聚合多个客户端的密文 | secure_aggregate(encrypted_updates) |
| server/decrypt.py | 私钥解密并恢复更新 | decrypt_and_reconstruct(...) |
| config/*.yaml | 配置密钥与训练参数 | yaml.safe_load()加载 |
3.2 客户端侧:从 TensorFlow 梯度到 Paillier 密文
TensorFlow 训练产生的梯度是tf.Tensor,加密前要先取出数值并展平成一维 float64 数组。有一个关键约束:加密过程必须在 eager 模式下执行,不能放进@tf.function图模式里,python-paillier 的加密操作无法进入 TensorFlow 图执行环境。
import numpy as np from phe import paillier def encrypt_client_update(grads, pub_key, precision=1e-6): """将 TensorFlow 梯度列表加密为 Paillier 密文列表。 grads: tf.GradientTape 计算得到的梯度列表,元素为 tf.Tensor pub_key: paillier.PaillierPublicKey,训练开始前由服务端广播 precision: 浮点缩放精度,决定密文可分辨的最小梯度步长 返回: encrypted_updates: 每个元素是 list[EncryptedNumber] grad_shapes: 每个参数的原始 shape,解密后用于重构 """ encrypted_updates = [] grad_shapes = [] for g in grads: if g is None: continue # float64 展平,避免 float32 位数不够引入额外量化误差 flat = g.numpy().astype(np.float64).flatten() # 放大到整数域并四舍五入,Paillier 只能加密整数消息 scaled = np.round(flat / precision) # 逐元素加密,得到 EncryptedNumber 对象 encrypted = [pub_key.encrypt(float(v)) for v in scaled] encrypted_updates.append(encrypted) grad_shapes.append(g.shape) return encrypted_updates, grad_shapes逻辑说明:precision=1e-6表示把梯度放大 10^6 倍。假设梯度值是 0.0001234,缩放后取整得到 123,解密后再乘以 1e-6 还原为 0.000123。astype(np.float64)是为了保证缩放后的大整数不会被 float32 的 7 位有效数字截断。
参数说明:precision 取值必须结合训练数值范围。它太小会把明文推到接近 Paillier 上限,太大又会让小梯度直接变成 0,导致这一轮更新丢失。另外在加密前必须检查梯度是否有限:
if not np.all(np.isfinite(flat)): raise ValueError("gradient contains NaN/Inf, abort this round")这一步成本几乎为零,但能避免 NaN 被转换成极端整数后污染整轮聚合结果,是客户端加密前必做的校验。
3.3 服务端侧:密文聚合与解密
所有客户端完成本地训练后,把密文列表上传到服务端。服务端只有公钥,只能对密文做加法:
def secure_aggregate(encrypted_updates): """多个客户端的密文更新逐位累加。 encrypted_updates: list[list[EncryptedNumber]], 每个子列表来自一个客户端,长度必须一致。 """ client_count = len(encrypted_updates) if client_count == 0: raise RuntimeError("no encrypted updates received") aggregate_len = len(encrypted_updates[0]) aggregated = [] for i in range(aggregate_len): cipher_sum = encrypted_updates[0][i] for c in range(1, client_count): cipher_sum = cipher_sum + encrypted_updates[c][i] aggregated.append(cipher_sum) return aggregated逻辑说明:python-paillier 重载了+运算符,底层执行的是密文乘法模 n^2 的运算,因为 Paillier 的数学性质是E(m1) * E(m2) mod n^2 == E(m1 + m2)。密文加法满足交换律和结合律,客户端上传顺序不影响最终结果。
聚合完成后,由持有私钥的一方解密:
def decrypt_and_reconstruct(aggregated, priv_key, shapes, precision=1e-6, client_count=3): """将聚合密文解密并还原为平均梯度。""" raw = np.array([priv_key.decrypt(c) for c in aggregated], dtype=np.float64) # 先除客户端数再乘回精度,避免整数截断误差 raw = raw / client_count * precision restored = [] offset = 0 for shape in shapes: size = int(np.prod(shape)) restored.append(raw[offset:offset + size].reshape(shape)) offset += size return restored注意这里必须先做浮点除法再乘回精度。如果反过来先乘后除,整数除法会直接把小梯度截断成 0,哪怕 precision 设得合理也救不回来。
3.4 运行的最小命令序列
以本地多进程模拟多客户端为例,安装和启动命令如下:
conda create -n fedsec python=3.9 -y conda activate fedsec pip install tensorflow phe pyyaml python server/server_main.py --config config/he_config.yaml & python client/client_main.py \ --server-addr 127.0.0.1:50051 \ --rounds 5 \ --batch-size 32命令说明:先启动服务端再启动客户端。--rounds是联邦学习轮数,--batch-size会直接决定梯度幅度分布,间接影响 Paillier 缩放精度,改动后需要同步检查he_config.yaml里的 precision。服务端启动时若配置了生成密钥对,会在内存中生成公私钥并把公钥广播给所有客户端;私钥只在解密阶段使用,不要落盘,也不要写进任何配置文件。
3.5 密钥复用与重放防护
同态加密的密钥不需要每轮更换,同一个公钥可以复用到训练会话结束。但复用的代价是重放攻击:攻击者截获一轮有效密文,在后续轮次重复提交,相当于反复放大该批梯度的影响。常见做法是在明文中拼接轮次编号和客户端 ID 后整体加密,服务端解密后使用这些元信息校验合法性。由于只有服务端持有私钥,这些元信息不会暴露给其他客户端。
4. 参数边界、网络开销与三个高频坑位
4.1 precision 与明文上限的联动
Paillier 的明文空间是模 n 的整数域,n 是两个大素数的乘积。2048 位密钥下 n 约 2^2048,3072 位下约 2^3072,理论明文上限非常大。工程上真正要关注的是 precision、梯度尺度和密钥位数三者组合后的净效应。
一个容易踩的具体场景:LSTM 或 Transformer 这类模型在长序列上容易出现较大中间值,某个梯度元素达到 500,precision 设为 1e-6 时缩放后是 5e8,这是 9 位数,完全安全。但如果把 precision 调到 1e-9,同样梯度的缩放结果变成 5e11,仍在上限之内,但 float64 转整数时的尾数误差开始放大。反过来,precision 设成 1e-2,梯度 0.003 缩放后直接变成 0,整轮更新丢失。
精度选型的实用做法是先在明文状态统计一轮梯度最大绝对值max_abs,然后按precision = 10^(-floor(log10(max_abs)) - 1)设定,留出至少一个数量级的余量。固定批次大小是前提,批次从 32 突然调到 512,梯度范数也会跟着跳一个量级,原设置的边界安全就不成立了。
提示:precision 一旦确定,整个训练会话内所有客户端必须使用同一个值。建议把 precision 作为公共配置随公钥一起广播,避免客户端各自理解出现偏差。
4.2 密文膨胀率与通信瓶颈
Paillier 的密文是模 n^2 下的整数,2048 位密钥时每个密文约 512 字节。一个 100 万参数的模型,明文梯度大约 4 MB,加密后约 300 到 400 MB。这类数据在广域网上完全不可接受,所以系统一定会在加密前做维度压缩。
常见手段是 Top-K 稀疏化和低精度量化配合使用:
def sparse_and_encrypt(grad_flat, pub_key, keep_ratio=0.01, precision=1e-6): """只加密 Top-K 个梯度元素,其余位置显式置零。""" k = max(1, int(len(grad_flat) * keep_ratio)) indices = np.argsort(np.abs(grad_flat))[-k:] sparse_flat = np.zeros_like(grad_flat) sparse_flat[indices] = grad_flat[indices] scaled = np.round(sparse_flat / precision) encrypted = [pub_key.encrypt(float(v)) for v in scaled] return encrypted, indices代码逻辑:keep_ratio=0.01只保留绝对值最大的 1% 梯度,其余位置补零。补零操作在加密前完成,密文数量和通信量直接降到原来的 1%,服务端加法不受影响,零位置求和仍然是零。由于 Top-K 选择本身可能泄露梯度分布信息,对隐私要求更严格的系统会把索引集合也一并加密,或者改用固定的随机掩码代替动态选择。
4.3 三个高频坑位与对应解法
第一个坑:tf.function 图模式下直接加密报错。TensorFlow 2.x 大量使用 Eager 模式,但不少团队会把训练循环包进@tf.function提性能。python-paillier 的加密方法会创建 Python 对象并调用 GMP 的 C 扩展,在 AutoGraph 转写过程中直接抛异常。解法是把训练和加密拆成两个阶段:训练在图模式内完成,加密在退出图模式、进入网络传输之前执行。
第二个坑:梯度中的 NaN 或 Inf 在加密后不可见。NaN 转整数后变成-9223372036854775808,加密解密都能“成功”,但聚合结果被污染,模型发散后很难定位问题。检查点必须放在客户端加密前,就是前面代码里np.all(np.isfinite(flat))的那一步。
第三个坑:客户端掉线导致密文序列长度不一致。联邦训练中客户端掉线是常态。服务端如果按第一个客户端的长度做聚合,缺失位置按 0 补齐,但上层逻辑并不知道哪个客户端缺失。工程解法是服务端先做元信息核对:客户端在密文 payload 前附加一个明文清单,包含参数个数和各层 shape 的哈希,哈希不匹配直接判定本轮失败,不进入聚合流程。这也是服务端逻辑里最容易忽略的边界条件。
5. 用差分隐私补上同态加密盖不住的泄漏面
5.1 解密后的模型本身还在泄露信息
同态加密隐藏了聚合过程中的梯度,但每轮结束后的全局模型仍然以明文下发给参与方。服务端如果积累了多轮明文模型,可以做两类攻击:成员推断攻击判断某条样本是否在训练集中,属性推断攻击猜测训练数据中某个敏感属性的取值。这两类攻击都不需要看到某一方的梯度,模型参数和输出分布本身携带了统计性信息。同态加密对这个泄漏面没有任何防护能力,因为它保护的是聚合过程,不是模型本身。
另一个容易被忽略的路径是梯度稀疏化的选择逻辑。Top-K 稀疏化选出的索引集合如果明文传输,攻击者可以从索引的聚集模式推断训练数据分布。在意这个泄露面的系统,需要把索引集合也纳入加密范围,或者改掉动态 Top-K,使用与数据无关的固定随机掩码。
5.2 本地差分隐私:在加密之前注入噪声
把差分隐私和同态加密叠加时,我习惯先在客户端本地做一次梯度裁剪加噪声,再做同态加密。优点是噪声在本地注入,服务端即使解密也拿不到原始梯度;噪声不经过密文通道,不会增加密文体积。
一个可以直接抄的参数组合:裁剪阈值 C=1.0,噪声乘子 σ=1.1。
def clip_and_noise(flat_grad, clip_norm=1.0, noise_scale=1.1): """L2 裁剪 + 高斯噪声,返回可输入加密管线的 float64 数组。 clip_norm: L2 范数裁剪阈值,决定单条梯度的敏感度上限 noise_scale: 高斯噪声标准差,与裁剪阈值共同决定隐私预算 """ l2_norm = np.linalg.norm(flat_grad) if l2_norm > clip_norm: flat_grad = flat_grad * (clip_norm / l2_norm) noise = np.random.normal(0, noise_scale, size=flat_grad.shape) return flat_grad + noise参数说明:裁剪阈值 1.0 加上噪声尺度 1.1,在 batch size 32 到 128 的图像分类任务上通常能把精度损失控制在 1% 以内,成员推断攻击成功率会被压到接近随机猜测的 0.5。这两组参数必须一起调:固定裁剪阈值,从 σ=0.5 逐步上调,同时记录验证集准确率和攻击成功率两条曲线,取准确率可接受且攻击成功率接近 0.5 的最小 σ。
如果发现梯度裁剪后 L2 范数长期贴在上限边界,说明裁剪阈值设小了,把 clip_norm 上调到 2.0 再跑一轮,直到裁剪生效的比例低于 20%。
本文还有配套的精品资源,点击获取