☰
FedSTSS安全聚合:从Shamir秘密共享到梯度隐私保护的工程实践
2026/10/30 9:15:59 网站建设 项目流程

简介:基于Shamir门限秘密共享的联邦学习安全聚合模型(FedSTSS)的完整Python实现及对比实验代码,面向联邦学习、隐私计算方向的学生与研究者,可用于毕设答辩、课程设计或项目复现。包内共54个文件,以Python源码为主(22个py),涵盖FedSTSS、FedAvg、FedShare等多种聚合策略的服务端与客户端实现,并实现门限秘密共享核心算法;6个shell脚本用于一键启动与停止不同实验;4个CSV文件为实验数据;20个log记录运行结果,便于复现与比对。整个资源包仅68KB,结构精简。已有140人学习下载。代码已通过测试,答辩评审平均分96分,并配有README说明,下载后可按文档快速上手;内容包含多数据集(MNIST、Fashion-MNIST、EMNIST、MHealth)的联邦学习训练流程,适合在此基础上扩展改进,也可作为教学演示。

1. 联邦学习安全聚合与 FedSTSS:为什么梯度会泄露隐私

在联邦学习安全聚合这个方向上,最反直觉的一件事是:你不传原始数据,只把模型梯度上传给服务器,攻击者依然能从梯度里还原出训练样本。这不是理论推演,这几年公开的梯度反推实验已经在图像、文本任务上做过实际验证。FedSTSS 正是这种背景下的一个解法:把“梯度加随机掩码再上传”和 Shamir 门限秘密共享组合起来,让服务器在没有可信第三方的情况下完成安全的加权平均聚合。它解决的核心问题是“数据不出域”与“聚合可用”之间的平衡——不依赖同态加密的解密私钥,也不像差分隐私那样需要为了隐私而牺牲模型精度。适合要交课程设计或论文复现、做联邦学习对比实验的从业者和学生。我下面直接按“数学原理→协议设计→Python 实现→参数与坑→对比实验”的顺序展开。

2. Shamir 门限秘密共享原理:拉格朗日插值、素数域与安全参数

2.1 从拉格朗日插值到 (t, n) 门限:代数直觉与最小实现

Shamir 门限秘密共享的核心是多项式插值:一个 t-1 次多项式,只要知道它经过的任意 t 个点,就能唯一确定整个多项式,自然也能确定它在 x=0 处的值。把一个秘密当作多项式的常数项 f(0),再给每个参与方发一个多项式上的点,就形成了 (t, n) 门限——少于 t 个人凑不出任何信息,凑够 t 个人就能恢复秘密。

我第一次用浮点数写这段代码时翻车翻得很彻底,后面会细说。先看一个能跑的最小整数域实现:

import random def make_shares(secret: int, t: int, n: int, p: int): """把 secret 拆成 n 份,凑够 t 份可恢复。p 是大素数。""" # 多项式系数:常数项是秘密本身,其余 t-1 个系数随机 coeffs = [secret] + [random.randrange(1, p) for _ in range(t - 1)] shares = [] for x in range(1, n + 1): # 在素数域上求多项式值 f(x),x 从 1 开始而不是 0 y = sum(c * (x ** i) for i, c in enumerate(coeffs)) % p shares.append((x, y)) return shares, coeffs def reconstruct(shares, p: int): """拉格朗日插值恢复常数项 f(0)""" xs = [s[0] for s in shares] ys = [s[1] for s in shares] secret = 0 for i in range(len(shares)): num = 1 den = 1 for j in range(len(shares)): if i == j: continue # 在 x=0 处求值:分子累乘 (0 - x_j),分母累乘 (x_i - x_j) num = num * (-xs[j]) % p den = den * (xs[i] - xs[j]) % p # 费马小定理求模逆:den^(p-2) mod p secret = (secret + ys[i] * num * pow(den, p - 2, p)) % p return secret p = 2**127 - 1 # 127 位梅森素数 shares, _ = make_shares(42, 3, 5, p) assert reconstruct(shares[:3], p) == 42

这里有两个关键设计:第一,多项式系数里的coeffs[0]就是秘密本身,其余系数在[1, p)里随机,这样份额不会直接暴露秘密的分布信息;第二,x从 1 开始取,绝不能用 0,否则 f(0) 本身就是秘密,发出去等于直接泄露。reconstruct里pow(den, p - 2, p)用的是费马小定理,要求模数是素数,这也是为什么整个协议必须工作在素数域里。

2.2 为什么必须用大素数取模:浮点插值的坑与安全参数选择

Shamir 理论本身没有限定在整数域,但工程实现几乎只能用素数域。原因很实在:拉格朗日插值的每一步都有除法和乘法,浮点运算会产生误差,t 个点重构出来的秘密经常差几个 epsilon;而联邦学习聚合的是成千上万个梯度元素,任何一个元素恢复不对,模型参数就污染了。素数域上的加法和乘法是精确的,模逆运算也稳定,从根上消除浮点误差。

大素数 p 的选择还要兼顾两个方向:一是安全空间,p 至少要覆盖掩码的取值空间,否则随机掩码的熵不够;二是性能,p 越大,多项式求值和模幂运算越慢。我做实验时常用2**61 - 1,这个量级能覆盖 64 位整数运算,性能也好;如果梯度张量很大、需要更高安全余量,再升到2**127 - 1。门限 t 与参与方 n 的关系也有讲究:t 越小安全性越差,t=1 等于不加掩码;t 越接近 n,容错越差,一个客户端掉线就整轮作废。我一般取t = ceil(n * 0.7),既保证多数在线能重构,又留出掉线余量。

2.3 秘密共享梯度和秘密共享掩码的区别:FedSTSS 的核心设计选择

理解 FedSTSS 的关键一步:它不是把梯度直接拆成份额发给服务器,而是把每个客户端生成的随机掩码拆成份额,分发给其他客户端。服务器收到的永远是“梯度 + 掩码”的混合结果。这带来一个很实用的性质:单个份额泄露没有意义,因为掩码本身是随机数;把掩码和梯度一起上传,服务器即使拿到明文,也无法区分哪部分是真实梯度。

这个设计和同态加密、差分隐私的取舍是 FedSTSS 选型时绕不开的对比。同态加密能做无损聚合,但计算开销比普通明文聚合高一个量级,GPU 上尤其明显;差分隐私实现简单,但要调隐私预算 ε,噪声大了模型掉点,噪声小了隐私保护又不够。Shamir 门限的方案正好卡在中间:掩码是精确恢复的,不引入统计噪声,模型精度理论上不受影响;开销只在份额分发和重构阶段出现,比同态加密便宜得多。

3. FedSTSS 安全聚合协议:密钥分发、掩码共享与门限重构

3.1 协议角色与数据流:服务器、客户端与份额中继的职责边界

FedSTSS 协议里通常有四个角色:模型服务器、参与训练的客户端、份额中继通道,以及一个只负责生成系统参数(大素数 p、门限 t、参与方数量 n)的初始化组件。注意这个初始化组件和“可信第三方”不是一回事——它只公开系统参数,不接触任何掩码和梯度。服务器在 FedSTSS 里是个“半盲”角色:它能看到带掩码的梯度聚合结果,但没有能力解开掩码,除非凑够至少 t 个客户端的份额。

角色职责能看到什么
模型服务器聚合带掩码梯度、下发全局模型、接收重构份额明文参数、带掩码梯度、零散份额
客户端本地训练、生成掩码、分发份额、参与重构自己的明文梯度、别人发给自己的份额
份额中继客户端之间传递份额,通常可复用服务器通道密文份额,无密钥时不可读
初始化组件生成 p、t、n 和随机数种子规范系统参数

协议每一轮的数据流是固定的:客户端本地训练得到梯度 g_i → 生成随机掩码 m_i → 把 m_i 的 Shamir 份额分发给 t 个其他客户端 → 上传 g_i + m_i → 服务器累加所有带掩码梯度 → 服务器从客户端收集份额、重构出掩码总和 Σm_i → 从带掩码总和中减去掩码,得到真实梯度总和 → 求平均并更新模型。这里最容易被忽视的是份额中继的保密性:份额不能在服务器面前裸奔,否则服务器自己凑够 t 份就能解开掩码。常见做法是客户端之间用对称密钥加密份额通道,或者让份额走客户端直连的 TLS 链路。

3.2 客户端掩码生成与份额分发:随机多项式的构造细节

每一轮训练开始前,客户端要生成一个新的随机掩码。掩码必须满足两个约束:和梯度张量形状一致、每个元素独立且均匀分布在[0, p)上。这里最容易偷懒也最容易出安全问题的地方是随机数来源——用 Python 的random模块做科研演示可以,但真实场景要用secrets或密码学安全随机源,否则攻击者如果拿到随机种子,整个掩码机制就形同虚设。

import secrets def client_prepare(grad_int, t, n, p): """客户端为单个梯度元素生成掩码并产生份额""" mask = secrets.randbelow(p) # 密码学安全的随机掩码 shares, _ = make_shares(mask, t, n, p) # Shamir 拆分掩码 masked_grad = (grad_int + mask) % p # 梯度加掩码 return masked_grad, shares

这段代码看似简单,但要注意secrets.randbelow(p)的性能:梯度动辄几十万维,逐元素生成掩码会非常慢。工程上的常见做法是只生成一个随机种子,用 PRNG 把种子扩展成整个掩码张量,然后只对种子做 Shamir 分享——这一优化能把份额通信量从“每个梯度元素一份”降到“每个客户端一份”,我在第 6 章会给出具体写法。

3.3 服务器端聚合与去掩码顺序:平均聚合下的代数恒等式

服务器把所有带掩码梯度加起来,得到G_total = Σ(g_i + m_i) mod p。同时从客户端收集份额,逐个重构出每个客户端的掩码,再求和得到M_total = Σm_i mod p。真实梯度总和就是G_total - M_total mod p。这里有一个顺序细节:必须先做“求和再减掩码”,而不是先对单个梯度减掩码再求和,否则服务器就要能识别每个客户端的掩码,那它收集到的份额就足够解开单个掩码了。

def server_unlock(masked_sum, shares_by_client, n, p): """从带掩码总和中扣除掩码总和,恢复真实梯度总和""" mask_sum = 0 for client_id, shares in shares_by_client.items(): mask_sum = (mask_sum + reconstruct(shares, p)) % p real_sum = (masked_sum - mask_sum) % p # 整数域上的带符号映射:p//2 以上视为负数 if real_sum > p // 2: real_sum -= p return real_sum / n

real_sum最后除以 n 得到平均梯度,这一步在模域之外做,因为我们在前面已经把整数映射回带符号的实数空间了。如果整个流程都在模域里做除法,就要用模逆pow(n, p - 2, p),一个不留神就会得到完全错误的结果。我建议把“模域运算”和“实数除法”严格分开,中间用一个带符号映射作为开关,这样代码可读性和正确性都更好。

3.4 掉线与恶意行为:门限重构的容错边界

Shamir 门限方案天然带容错:只要在线客户端数量 ≥ t,掩码和就能被重构;掉线的人越多,能收集到的份额越少。掉线超过 n - t 时,本轮无法重构,常见处理是让在线客户端重新生成掩码并协商新份额,或者直接放弃这一轮。另一种更常见的工程决策是动态调整门限:如果 n 个参与方里有d个已知掉线,就把 t 临时设为n - d,但 t 绝不能低于安全底线t > n/2,否则少数客户端就能串通解开掩码。

恶意客户端的处理要复杂一些。一个恶意客户端可以上交错误的份额,导致重构出来的掩码值是错的。常见的防御方式是给份额加哈希承诺:客户端在分发份额时同时广播每个份额的哈希;重构时服务器先验算哈希,对不上的份额直接丢弃,如果剩余合法份额仍 ≥ t 就继续重构。这部分逻辑不在聚合代码里,而在份额分发前加一层校验,我在第 5 章的避坑里会再展开。

4. FedSTSS 的 Python 实现:模块划分、核心类与最小可跑例子

4.1 模块划分与依赖准备:shamir、masking、aggregator 三层结构

依赖只需要 numpy 和 cryptography 两个库。numpy 负责梯度张量和聚合运算,cryptography 用来给份额传输做对称加密。如果你刚在 Linux 系统安装完 Python,记得先确认 pip 和解释器是对齐的,否则会出现“pip 装好了但 import 报错”的经典问题;装 numpy 的标准做法就一条命令pip install numpy,装完立刻用python -c "import numpy; print(numpy.__version__)"验证,不要在环境变量配置上花太多时间。

项目代码我习惯按三层组织,和协议职责严格对应:

shamir.py # 秘密分享原语:make_shares / reconstruct / 素数域工具 masking.py # 掩码生成、量化、反量化、份额加密打包 aggregator.py # SafeAggregator 类:带掩码聚合、份额重构、去掩码 train.py # 联邦训练主循环:本地训练、上传、聚合、下发

分层的直接好处是能单独测试:shamir.py 完全不依赖 numpy,用纯 Python 测;masking.py 只处理张量格式;aggregator.py 不关心模型结构,只接收np.ndarray。这样任何一层出问题,定位范围和 debug 成本都很小。

4.2 安全聚合器核心类:带掩码累加、份额重构与整数域换算

下面这个SafeAggregator是服务器端的核心,输入的是客户端提交的“带掩码梯度”,输出的是真实聚合平均梯度。注意它的接口不涉及任何模型结构,所以可以复用到不同任务上:

import numpy as np class SafeAggregator: def __init__(self, n_clients: int, threshold: int, p: int, q: int): self.n = n_clients self.t = threshold self.p = p # 大素数模 self.q = q # 量化缩放因子 def submit(self, masked_grad: np.ndarray): """接收客户端上传的带掩码梯度(已在整数域)""" if not hasattr(self, "masked_sum"): self.masked_sum = np.zeros(masked_grad.shape, dtype=np.int64) self.masked_sum = (self.masked_sum + masked_grad) % self.p def unlock(self, shares_by_client: dict) -> np.ndarray: """收集份额,重构掩码总和,扣除后返回真实梯度""" mask_sum = np.zeros_like(self.masked_sum, dtype=np.int64) for client_id, shares in shares_by_client.items(): # 每个客户端的一整套份额重构出它的掩码张量 mask_vec = np.array([reconstruct(shares[i], self.p) for i in range(len(shares))]) mask_sum = (mask_sum + mask_vec) % self.p real_sum = (self.masked_sum - mask_sum) % self.p # 中心化映射:把 [0, p) 映射回带符号区间 real_sum = np.where(real_sum > self.p // 2, real_sum - self.p, real_sum) return real_sum / self.n

submit里维护了一个masked_sum累加器,所有客户端都提交完后才调用unlock。unlock里最关键的一行是np.where(real_sum > self.p // 2, real_sum - self.p, real_sum)——不做这一步,重构出来的梯度全是正数,模型更新方向直接错乱。量化缩放因子q放在类外面由训练脚本管理,因为我需要在梯度进入聚合器前完成量化,聚合器只处理整数。

4.3 联邦训练主循环:本地训练、量化掩码与模型更新

训练循环可以用纯 numpy 写通,这样不需要 GPU 也能验证整个安全聚合逻辑。模型就用一个线性层,本地训练做几步梯度下降,每一步都返回真实梯度:

def local_train(model, data, steps, lr): """极简本地训练:返回最后一步的梯度(真实场景换成 PyTorch)""" for _ in range(steps): grad = compute_gradient(model, data) # 解析梯度或数值梯度 model -= lr * grad return grad def quantize(grad: np.ndarray, q: int) -> np.ndarray: """把 [-1, 1] 范围的梯度缩放为整数""" return np.round(grad * q).astype(np.int64) % p

真实项目里local_train会换成 PyTorch 的optimizer.zero_grad(); loss.backward(); optimizer.step(),但返回梯度张量的接口不用变。量化时注意梯度要先做裁剪或归一化到固定范围,否则超出[0, p)的部分会在模运算里回绕,重构后数值对不上。我的习惯是先把梯度按最大绝对值缩放到[-1, 1],再乘以q,这样可以最大限度利用整数域。

4.4 最小可跑配置:客户端数、门限、量化位宽与轮次怎么定

写一个能验证协议正确性的最小配置,参数表如下:

参数典型值作用调参思路
n_clients5参与客户端总数数据异构时建议更多
threshold3重构掩码所需最少份额设为 ceil(n*0.7),留 2 个掉线余量
p2**61 - 1素数域模数至少覆盖掩码随机空间和梯度量化积
q1024梯度量化缩放因子太小丢精度,太大会溢出模数
local_steps10本地 SGD 步数影响模型收敛速度,不影响安全聚合
rounds50联邦训练轮次收敛后看精度曲线

第一次跑通时把rounds设为 3 就够,重点检查日志里每一轮是否都出现“重构成功”的标记。如果第 2 章的最小实现能在 3 个客户端上传后通过断言,这个配置就基本没问题;接下来再逐步把n_clients加到 10、20,观察重构耗时和通信量的增长曲线。

5. FedSTSS 实现与实验的避坑指南:从量化误差到对比基线

5.1 份额重构结果对不上:量化误差是头号元凶

现象:assert reconstruct(...) == mask偶尔失败,或者模型精度比 FedAvg 低一大截但找不到原因。第一次做时你会怀疑 Shamir 实现写错了,但真相往往在量化环节。原因:梯度是浮点数,量化时np.round(grad * q)本身就有舍入误差;如果 q 太小,梯度被量化后信息丢失严重,重构出来的掩码虽然正确,但“真实梯度 + 量化误差”和原始梯度已经不是同一个值。解决:先做一次空跑对比——把掩码设成全零,如果模型精度还掉,说明就是量化问题,和安全聚合无关。q 的取值要保证梯度最大值乘以 q 后至少有几万到几十万的整数跨度,我常用 1024 起步,不够再加。

5.2 一个客户端掉线整轮失败:threshold 设成了 n

现象:日志里出现received 3 shares < threshold 5, abandon round,整个训练卡住。原因:配置时直接把 threshold 设成了客户端总数,等于要求所有客户端必须在线,Shamir 的容错能力一点没发挥。解决:把 threshold 降到ceil(n * 0.7),并且在代码里显式写断言assert threshold > n // 2,防止有人为了追求安全性把 t 推到 n。这里的安全底线是:t 必须大于 n/2,否则少数客户端就能自行重构掩码,隐私保护失去意义;t 的上限由你愿意容忍的掉线数决定。

5.3 多轮之后掩码被抵消:随机掩码复用引发隐私回退

现象:训练跑到第 30 轮,某次日志里带掩码梯度和去掩码后的真实梯度几乎一样,看起来“聚合成功”,实际上隐私保护已经失效。原因:掩码是用同一个随机种子生成的,服务器只要收集到两轮的带掩码梯度,就能通过做差把掩码抵消——这正是掩码复用的典型漏洞。解决:每轮必须重新生成随机种子,且种子的来源要绑定轮次和客户端标识。我常用secrets.randbelow(p),如果为了可复现性需要固定种子,至少要保证它是“轮次 + 客户端 ID + 密钥”的哈希结果,而不是一个全局常量。

5.4 python 环境依赖问题:numpy 版本与解释器错位

现象:import numpy直接报DLL load failed,或者代码里调用np.int时抛AttributeError: module 'numpy' has no attribute 'int'。原因:系统里同时存在多个 Python 解释器,pip 装到了一个环境,运行用的是另一个环境;numpy 版本过新导致旧写法失效。解决:不要在全局环境硬扛,直接建虚拟环境。依次执行python3 -m venv venv、source venv/bin/activate、python -m pip install numpy cryptography,然后用which python确认解释器路径指向 venv 内部。numpy 版本别追新,挑一个稳定的 1.x 版本足够,代码里也尽量别用np.int这类被移除的别名。

5.5 对比实验“安全聚合掉点”被归因错误

现象:FedSTSS 跑出来的精度比 FedAvg 低 0.5%,你直接得出结论“安全聚合有隐私代价”,但这大概率是错误归因。原因:掉点可能来自量化误差、掩码复用、份额重构的边界 bug,甚至只是随机种子不同导致的实验抖动。解决:做三层对照实验——先跑一次 mask 全零的 FedSTSS,如果精度和 FedAvg 一致,说明量化没问题;再跑一次每轮固定种子的正常 FedSTSS,如果精度掉了,说明是掩码复用问题;最后才轮到真正的安全聚合逻辑。这个分层排查法能帮你把“协议缺陷”和“实现缺陷”分开,不至于把代码 bug 当成算法代价写进论文。

6. 对比实验设计与验证技巧:让 FedSTSS 的收益可量化

6.1 对比分组与量化评估

做对比实验时我通常跑三组基线:明文 FedAvg、FedSTSS、以及加高斯噪声的差分隐私方案。评估指标不只看精度,还要记录每轮通信开销、聚合耗时、掉线容忍度,以及隐私保护是否真的生效。一个简洁的对比表长这样:

方案精度损耗是否需可信第三方通信增量掉线容错
FedAvg无是基线无
FedSTSS可忽略(量化好时)否每轮多 O(n) 份额容忍 n-t 个掉线
DP 加噪ε 越小时掉点越明显是无无

验证隐私生效的方法很直接:在服务器日志里打印每轮收到的份额数和门限值,例如round=5, received=4, threshold=3, unlock=ok。如果模拟两个客户端掉线后仍然出现unlock=ok,说明门限重构确实在工作;如果收到份额数低于 threshold,日志应当出现abandon round。这两条日志就是安全聚合的审计点。

6.2 用种子掩码降低通信量:分享种子而非整张梯度

最后一个实用技巧是种子掩码。逐元素生成掩码再分享,通信量是梯度维度的好几倍,在真实图像任务上根本跑不动。常见做法是让每个客户端生成一个随机种子seed_i,用 PRNG 把它展开成和梯度相同形状的掩码张量,然后只需要对seed_i做 Shamir 分享:

rng = np.random.default_rng(seed_i) mask = rng.integers(0, p, size=grad.shape, dtype=np.int64) masked_grad = (grad_int + mask) % p

重构侧拿到seed_i后,用同一个np.random.default_rng(seed_i)重新生成掩码张量,再执行去掩码。这样 Shamir 分享的对象从“几十万维张量”变成了“一个整数”,通信量降了几个数量级。我最早做这一版时把种子写成了固定值,跑到第三轮精度就开始异常,排查了大半天才发现是 PRNG 种子重复;后来强制规定种子必须是hash(round || client_id || secret_key)的结果,才彻底根治。

这类安全聚合项目如果把海量份额直接传输,几乎无法离开实验环境;换成种子掩码后,通信量就能支撑起真实规模的数据集和模型。我自己在后续实验里一直保留这个设计,也希望这个技巧和前面的避坑记录能帮你在 FedSTSS 上少走一段弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询