news 2026/9/11 21:37:40

纵向联邦学习与标签差分隐私:乳腺癌数据集的隐私保护建模实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
纵向联邦学习与标签差分隐私:乳腺癌数据集的隐私保护建模实践

简介:面向信息安全、人工智能及相关专业学生的密码学课程设计资源,在 breast cancer 公开数据集上实现纵向联邦学习与标签差分隐私的联合方案,覆盖模型构建、隐私参数 epsilon 与正则化参数 lambda 对准确率影响的实验分析,适合用于毕设、课设或项目演示。资源共 16 个文件,压缩包约 1.19MB,包含 Python 源码(main.py、model.py、utils.py)、依赖与配置说明(txt、json)、可视化结果图(png、jpg)、Jupyter 教学笔记(ipynb)及密码学自选题报告 PDF,结构清晰,便于对照代码理解完整流程。目前已有 150 余人学习浏览。下载后可从 README 入手,结合教程笔记快速复现实验,并依据可视化的准确率变化图表进一步分析隐私保护强度与模型效用的权衡,是理解联邦学习与差分隐私结合实践的直观参考。

1. 为什么要在 breast cancer 数据集上同时做纵向联邦学习与标签差分隐私

不少初学者把这道大作业误看成“两门选修课拼盘”:纵向联邦学习解决两家医院不交换原始特征也能联合建模,标签差分隐私解决病理标签不能被反推患者是否患病。但真正拼在一起才会发现,联邦训练过程中的梯度与误差传递,恰好就是标签反推最容易落地的攻击面。一道负责任的密码学大作业,至少要让你看到这条攻击路径,并给出一个可验证的防护闭环。本文围绕 breast cancer 数据集(威斯康星乳腺癌二分类)展开,用两个参与方模拟纵向特征切分,用随机响应实现标签差分隐私,再用 Paillier 同态加密保护中间 logits,最后给出校准和评估方法。适合要交课的本科生,也适合想快速对比 VFL + LDP 效果的一线算法工程师。

2. 在 breast cancer 数据集上纵向切分数据:两个参与方各拿一半特征

2.1 纵向联邦学习的切分逻辑:为什么是切列不是切行

横向联邦切的是样本,纵向联邦切的是特征。在 breast cancer 数据集中,每一行是一位患者的病理测量记录,30 个特征共同描述同一例肿瘤样本。假设其中两家机构各有不同检测手段:A 机构记录了 mean radius、mean texture 等前半段形态学特征,B 机构记录了 worst perimeter、worst area 等后半段指标,而病理标签(0 为良性,1 为恶性)只由 B 机构掌握。这就是典型的纵向联邦场景:样本 ID 重叠,特征被列方向切开。

在实现前先明确角色:被动方(feature party)不持有标签,主动方(label party)持有标签,两方共同计算一个逻辑回归模型。我们不会把任何一方的原始特征数组完整发送到另一方去,双方只交换加密后的中间结果。下面先从最小原型开始,把数据准备和切分步骤写清楚。

2.2 环境准备:conda 虚拟环境与最小依赖包

为了不让各种库的版本互相干扰,建议先创建一个干净的 Python 环境。下面的命令用 conda 创建名为vfl的环境,并安装所需依赖:

conda create -n vfl python=3.8 -y conda activate vfl pip install numpy pandas scikit-learn phe

参数说明:python=3.8是一个兼容性较好的版本,phepython-paillier的安装名,提供 Paillier 加法同态加密的公开密钥与私钥实现;scikit-learn用来加载 breast cancer 数据和计算评估指标。如果机器上已有 Python 环境,直接pip install也可以,但用虚拟环境能避免后面训练循环里 NumPy 版本带来的隐性问题。

2.3 用 load_breast_cancer 构造两个 party 的本地视图

下面这段代码把原始数据集按特征列切成两份,并模拟两个参与方各自持有一部分特征。这里直接用sklearn.datasets.load_breast_cancer(),特征顺序固定为 scikit-learn 返回的原顺序,前 15 列归 A,后 15 列归 B。实际业务中如果两家机构使用不同数据库,需要在样本 ID 对齐之后再做这一步,这里用同一个 NumPy 数组模拟行对齐。

import numpy as np from sklearn.datasets import load_breast_cancer from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler data = load_breast_cancer() X, y = data.data, data.target feature_names = data.feature_names # 按特征列顺序做纵向切分 split = 15 X_A = X[:, :split] # party A 持有前 15 个特征 X_B = X[:, split:] # party B 持有后 15 个特征 # 样本 ID 只是一个占位数组,用于体现“行对齐” patient_id = np.arange(X.shape[0])

代码逻辑很简单:X_AX_B的行索引完全一致,代表同一批患者;列则被分开。注意patient_id在这里没有实际参与运算,真实场景中不同机构需要通过安全的样本对齐协议,比如基于哈希或密码学 PSI,来找出共有患者,而本文重点是建模部分。

2.4 训练集测试集划分与局部标准化

纵向联邦的训练集测试集划分必须在切分之后进行,而且要保证 A 和 B 使用相同的样本 ID 集合。最稳妥的方式是先把索引划分好,再分别从两边抽取。另外,标准化不能使用合并后的全局统计数据,否则等价于让双方交换了均值和方差,构成统计信息泄露。正确的做法是各参与方只用自己的训练集计算均值和标准差。

# 先用同样的 random_state 划分,保证两边拿到的样本 ID 一致 X_A_train, X_A_test, X_B_train, X_B_test, y_train, y_test, id_train, id_test = \ train_test_split(X_A, X_B, y, patient_id, test_size=0.3, random_state=42, stratify=y) # 分别 fit 和 transform,避免跨方触碰对方数据 sc_A = StandardScaler().fit(X_A_train) sc_B = StandardScaler().fit(X_B_train) X_A_train_s = sc_A.transform(X_A_train) X_A_test_s = sc_A.transform(X_A_test) X_B_train_s = sc_B.transform(X_B_train) X_B_test_s = sc_B.transform(X_B_test) print("A 方训练集形状:", X_A_train_s.shape) print("B 方训练集形状:", X_B_train_s.shape) print("标签形状:", y_train.shape)

参数说明:stratify=y让训练集和测试集中良性与恶性的比例保持一致,避免切分带来的类别分布偏移。random_state=42固定随机种子,保证 A、B 双方对同一批样本进行切分。标准化对象分别是sc_Asc_B,这符合纵向联邦“本地特征本地统计”的原则。如果把X_AX_B堆叠起来再做StandardScaler,就会引入跨机构隐私泄露,这点在代码评审时常被单独考察。

2.5 两个参与方的数据视图一览

下面的表格直接看参与方持有情况,方便对照后文中的训练协议:

参与方持有特征标签 y角色对外发送的信息
party AX[:, :15],前 15 个形态学特征被动方 / 特征方加密后的本地 logits
party BX[:, 15:],后 15 个纹理与最差区域特征主动方 / 标签方加噪后的误差信号 e

这里一个容易踩的坑是:千万不要把测试集也混入训练前的标准化。只能先 fit 训练集,再用训练集得到的均值和方差去 transform 测试集,否则测试集信息会以统计量形式进入训练,导致评估结果虚高。下一节会对标签做差分隐私扰动,翻转后的标签会直接影响训练,所以数据准备阶段最好只做当前这一步。

3. 标签差分隐私实现:用随机响应翻转 y,而不是给标签加高斯噪声

3.1 为什么二分类标签不能直接加高斯或拉普拉斯噪声

标签 y 是离散的 0/1 值,如果给每个标签加上一个连续噪声,得到的是“0.37”“1.24”这类没有任何监督意义的实数,模型无法训练。因此标签差分隐私的常见做法是随机响应:以一定概率把样本标签翻转到相反的类别,这个翻转过程满足本地化差分隐私,并且保留下一个合法的二分类标签。随机响应尤其适合纵向联邦,因为翻转可以在标签方本地完成,其他参与方即使拿到误差信号,也无法区分原始标签和翻转后的标签。

3.2 ε 与翻转概率的关系

对于一个二分类标签,随机响应的翻转概率设计为:

p_flip = 1 / (1 + exp(ε))

当 ε = 0 时,p_flip = 0.5,标签完全随机,隐私最强但数据不可用;当 ε 趋向无穷大时,p_flip趋向 0,等价于不扰动。这里的 ε 是差分隐私预算,直觉上预算越小,噪声越大。比较实用的范围在 0.5 到 2 之间,既能提供一定保护,又不会让模型精度崩掉。

3.3 Python 实现随机响应标签翻转

下面这个函数接收原始标签数组和隐私预算 ε,返回翻转后的标签与翻转概率:

import numpy as np def randomize_label(y, epsilon): """对二分类标签做随机响应翻转。 epsilon: 差分隐私预算,越小翻转概率越大。 返回: y_random: 翻转后的标签 p_flip : 翻转概率,训练后校准会用到 """ p_flip = 1.0 / (1.0 + np.exp(epsilon)) flip_mask = np.random.random(len(y)) < p_flip y_random = np.where(flip_mask, 1 - y, y) return y_random, p_flip

参数说明:np.random.random(len(y))生成[0.0, 1.0)的均匀随机数,小于p_flip的位置被翻转。np.whereflip_mask为 True 的位置把 0 变 1、1 变 0。注意这个函数只在训练集上调用,测试集不能用翻转后的标签,否则评估结果就不代表真实分类能力了。

3.4 不同 ε 对应的翻转概率与数据可用性

εp_flip = 1/(1+e^ε)标签保持率 1-p_flip直观影响
0.10.4750.525接近一半标签被翻转,模型几乎学不到有效信号
0.50.3780.622有一定噪声,需要更多 epoch 或更大样本
1.00.2690.731约四分之一标签被翻转,精度仍可接受
2.00.1190.881隐私较弱,模型精度接近未加噪
10.00.0000450.999955几乎无扰动,可以视为没有标签保护

实际使用中,我一般把 ε 当作超参数扫一遍,而不是拍脑袋选 1.0。值得强调的一点是:随机响应只在训练开始时执行一次,不要在每次 epoch 重新翻转,否则同一个样本在不同 epoch 会被随机赋给不同标签,模型将很难收敛。每次训练运行设置同一个随机种子,可以让结果可复现。

在纵向联邦场景下,执行randomize_label的必须是持有标签的 B 方。A 方不需要知道你到底翻了多少标签,只需要从 B 方拿到包含噪声的误差信号。这正好把标签差分隐私和密码学保护串了起来:差分隐私抵抗统计推断,同态加密抵抗中间链路偷听。下一章把它们放进同一个训练循环。

4. 纵向联邦学习训练循环:从明文原型到 Paillier 加密聚合

4.1 一次迭代的通信协议设计

这里以逻辑回归为例,模型整体可以写成z = X_A @ w_A + X_B @ w_B。在一个 batch 内,通信协议按下面几步进行:

  1. party A 用本地特征和本地权重计算logit_A = X_A[idx] @ w_A
  2. A 将logit_A加密后发给 B;B 计算logit_B = X_B[idx] @ w_B,并与解密的logit_A相加,得到全局线性输出z
  3. B 计算预测概率p = sigmoid(z)和误差e = p - y_random
  4. B 用e更新本地权重w_B,同时把加噪后的误差发送给 A,A 再更新w_A

这个协议把完整的逻辑回归拆成两个本地线性层,中间只交换 logits 和误差。密码学的角色是保护第 2 步的 logits,让 B 无法直接拿到X_Aw_A;标签差分隐私的角色是保护第 3 步和第 4 步的标签信息,让 A 即使拿到误差,也无法还原出真实的y。这是一个教学级简化方案,真正的生产系统会用更严格的安全多方计算协议来保护误差信号,但这里的信息链路已经具备“密码学 + 标签差分隐私”双保险的形态。

4.2 明文原型:先把联邦逻辑回归跑通

先不上密码学,写一个朴素明文版本,用于验证数据切分和标签翻转正确,同时也作为后续加密版的性能基线。

def sigmoid(z): z = np.clip(z, -30, 30) return 1.0 / (1.0 + np.exp(-z)) def vfl_train_plain(X_A, X_B, y, lr=0.1, epochs=20, batch_size=32): """明文版本的纵向联邦逻辑回归训练。 仅在单个进程内模拟两个参与方,不涉及加密。 """ rng = np.random.default_rng(0) w_A = np.zeros(X_A.shape[1]) w_B = np.zeros(X_B.shape[1]) # 训练前只翻转一次标签 y_random, p_flip = randomize_label(y, epsilon=1.0) for epoch in range(epochs): perm = rng.permutation(len(y_random)) total_loss = 0.0 for i in range(0, len(perm), batch_size): idx = perm[i:i + batch_size] # 双方各自计算本地 logits logit_A = X_A[idx] @ w_A logit_B = X_B[idx] @ w_B z = logit_A + logit_B pred = sigmoid(z) e = pred - y_random[idx] # 分别更新两个参与方的权重 w_A -= lr * (X_A[idx].T @ e) / len(idx) w_B -= lr * (X_B[idx].T @ e) / len(idx) loss = -np.mean(y_random[idx] * np.log(pred + 1e-9) + (1 - y_random[idx]) * np.log(1 - pred + 1e-9)) total_loss += loss if (epoch + 1) % 5 == 0: print(f"epoch {epoch + 1}, loss = {total_loss / (len(perm) // batch_size):.4f}, p_flip = {p_flip:.3f}") return w_A, w_B

参数说明:lr=0.1是逻辑回归常见的初始学习率,如果发现 loss 震荡可以降到 0.05。batch_size=32是为了模拟小批量通信,实际纵向联邦中每次通信都需要加密和解密,batch 太小会让通信开销占比过大,太大又容易收敛变慢。代码中的注释明确标出哪些计算发生在 A、哪些发生在 B,训练过程中并没有真正发送数据,只是把双方本地结果相加,这种设计便于后续替换成加密版本。

4.3 用 Paillier 做安全聚合:同态加法的核心代码

Paillier 加密支持密文相加等于明文相加,也就是E(m1) * E(m2) = E(m1 + m2)。在phe库中,两个密文对象用+运算符即可完成同态加。下面这段代码展示 party A 把本地 logits 加密发给 B,B 同态加自己的 logits 后解密:

from phe import paillier # 生成密钥对,n_length=2048 是模数位数 public_key, private_key = paillier.generate_paillier_keypair(n_length=2048) # 模拟 party A 的本地 logits logit_A = X_A_train_s[:8] @ w_A logit_B = X_B_train_s[:8] @ w_B # party A 加密后发送给 B enc_logit_A = [public_key.encrypt(x) for x in logit_A] # party B 收到密文,与本地明文做同态加 enc_z = [c + b for c, b in zip(enc_logit_A, logit_B)] # party B 解密得到全局 logits z = np.array([private_key.decrypt(c) for c in enc_z])

这段代码的关键点有三个。第一,public_key.encrypt(x)会在密文中加入随机性,所以即使加密相同明文,两次结果也不同,这是 Paillier 的概率加密特性,但解密结果仍然一致。第二,c + bb是明文,phe库允许密文加明文,等价于同态加法。第三,解密后的z只有 B 知道,A 无法看到全局 logits,因此中间链路只暴露部分加权信息,不暴露原始特征矩阵。

4.4 把误差信号加噪后传给 A:纵向联邦与标签差分隐私的结合点

在完整训练循环中,B 更新完w_B后,还需要把误差e发给 A,否则 A 无法更新本地权重。但直接发送明文e会泄露标签信息,因此这里在误差上叠加 Laplace 噪声,再发给 A。这个操作与标签差分隐私的“输出扰动”理念一致,也让密码学不再是孤立模块。

# B 计算明文误差 pred = sigmoid(z) e = pred - y_random[:8] # B 更新自己的权重 w_B -= 0.1 * (X_B_train_s[:8].T @ e) / 8 # B 给误差加 Laplace 噪声后发给 A noisy_e = e + np.random.laplace(loc=0.0, scale=0.05, size=e.shape) # A 用噪声化误差更新本地权重 w_A -= 0.1 * (X_A_train_s[:8].T @ noisy_e) / 8

参数说明:np.random.laplacescale控制噪声强度,scale=0.05意味着噪声标准差约为 0.05,对训练影响较小;如果希望隐私更强,可以提高到 0.2。这里 A 拿到的不是精确梯度,所以模型收敛速度会变慢,在验证时需要加大 epoch 或稍调学习率。这种“误差扰动”与第 3 章的标签翻转形成了二级保护:即使通信链路被窃听,攻击者拿到的也只是翻转后的标签和额外噪声的叠加。

4.5 纵向联邦训练的关键参数表

下面是整套训练中我比较关注的一组参数,适合作为大作业报告的参数说明表:

参数推荐值说明
lr0.05 ~ 0.2梯度噪声较大时建议用较小学习率
batch_size32 ~ 128越小加密次数越多,越大越接近全批量下降
epochs20 ~ 50breast cancer 数据只有 569 条样本,太多容易过拟合
ε1.0翻转概率约 26.9%,隐私与效用的平衡点
laplace_scale0.05 ~ 0.2控制误差信号中的额外噪声幅度
n_length2048Paillier 密钥模数位数,作业可临时降为 1024 提速

注意不要把加密版的精度和明文版直接对比后要求完全一致。密码学保护与标签差分隐私都会带来效用损失,重点是把损失控制在一个可接受范围内,并画出隐私预算与 AUC 的关系曲线。

5. 验证标签差分隐私与加密带来的收益:AUC、校准和性能开销

5.1 翻转标签导致的概率偏移怎么校准确

用随机响应翻转标签训练出来的模型,输出的概率是“翻转后标签为 1”的概率,而不是真实患病概率。举个例子,若p_flip = 0.2,模型输出p = 0.8,那么真实恶性概率实际上是0.8 * 0.8 + 0.2 * 0.2 = 0.68。如果直接用 0.5 作为阈值,会高估风险。校准函数需要用到第 3 章随机响应函数返回的p_flip

def calibrate_prob(p, p_flip): """将翻转标签训练得到的概率还原为真实正类概率。""" return p * (1 - p_flip) + (1 - p) * p_flip

逻辑说明:p * (1 - p_flip)项表示“模型预测为 1 且标签没有被翻转”的贡献;(1 - p) * p_flip项表示“模型预测为 0 但标签被翻转”的贡献。两者相加才是真实标签为 1 的概率。这个校正步骤在报告里非常重要,它能直接证明你理解随机响应如何影响模型输出。

5.2 用 ε 扫描代替单点手工调参

与其只跑一个 ε 值,不如写一个小脚本扫一遍,观察隐私预算对 AUC 的边际影响。下面这段代码用测试集上的 ROC_AUC 来衡量模型效用:

from sklearn.metrics import roc_auc_score for eps in [0.1, 0.5, 1.0, 2.0, 5.0]: y_random, p_flip = randomize_label(y_train, epsilon=eps) w_A, w_B = vfl_train_plain(X_A_train_s, X_B_train_s, y_random, lr=0.1, epochs=30, batch_size=32) test_logit = X_A_test_s @ w_A + X_B_test_s @ w_B test_prob = sigmoid(test_logit) test_prob_cal = calibrate_prob(test_prob, p_flip) auc = roc_auc_score(y_test, test_prob_cal) print(f"eps={eps:.1f}, flip={p_flip:.3f}, AUC={auc:.4f}")

扫描结果通常呈现一条向下凹的曲线:ε 从 5 降到 1 时,AUC 损失很小;继续降到 0.1,AUC 会显著下滑,因为标签接近随机。把这条曲线画出来,比单独给一个精度值更有说服力。若发现 AUC 在某一档突然崩塌,说明该 ε 低于数据可用性的阈值,后续报告可以依据这条曲线选择预算。

5.3 加密性能的几个直接相关技巧

Paillier 加密慢在模指数运算,尤其是 2048 位密钥。实际作业里可以先小批量跑通正确性,再对全量训练。若觉得速度太慢,可以尝试把n_length降到 1024,并同时修改训练 epoch 数来观察精度变化。另一个常用的优化是每轮只加密一次批量向量,而不是对每条样本逐个加密,这在phe里可以用列表推导式直接完成。还有一个细节:n_length=2048时密文尺寸大约是明文的 4 到 8 倍,在记录通信开销时最好测量enc_logit_A的字节数,而不是估计值。把这些数值写进文档,就能把“密码学大作业”从简单的“调库跑通”提升到“有工程量化”的水准。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 21:34:37

K8s中部署vLLM推理服务:GPU利用率与吞吐优化实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 21:34:26

OpenClaw本地部署实战:大模型接入与Skill配置指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华