简介:一份基于安全多方计算的隐私保护系统完整毕设项目,针对大规模电子投票与人工智能加密训练场景,实现数据不出域即可完成联合计算与模型推理。项目面向计算机、通信、人工智能、自动化等专业的学生、教师或从业者,尤其适合作为课程设计、大作业或毕业设计的参考范本。压缩包共110个文件,以55个Python源码文件与30个HTML展示页面为主体,辅以CSS样式、CSV格式的标准数据集、GIF动态演示及XML配置等,整体体积仅1.13MB,结构清晰便于查阅。目前已有386人学习该资源,项目代码均经过调试测试,可直接运行或二次开发。配套文档说明、可视化监控页面和动图演示,能够直观呈现安全多方计算在机器学习中的落地流程,对理解隐私计算、密码协议及Python工程实现有较高借鉴价值。
1. 从98分的答辩现场说起:为什么MPC能同时搞定电子投票和加密训练
这套基于安全多方计算的隐私保护系统设计与实现,答辩拿到98分不是因为PPT华丽,而是评委当场看到三件事同时跑通:framingham.csv在密文状态下完成了逻辑回归训练,heart.csv和breast_cancer.csv的预测结果在不暴露明文的前提下正常返回,电子投票模块在无法解析单个选票的情况下正确统计出各候选人票数。安全多方计算(MPC)允许多个参与方在互不泄露私有输入的前提下共同完成一次计算,这套毕设把它拆成两个典型落地场景:人工智能加密训练和大规模电子投票。LR.gif里那条收敛曲线就是逻辑回归在密文上训练的损失下降过程,两个可视化页面把训练和测试过程完整呈现。适合正在做隐私计算课设、毕设的学生,也适合想搞懂MPC协议层与业务层如何衔接的研发人员。
2. MPC协议层:加性秘密共享与Beaver三元组的工程落地
2.1 为什么选加性秘密共享而不是Shamir门限方案
MPC协议层是第一道技术门槛。工程上最常用的两种线性秘密共享是Shamir门限方案和加性秘密共享。Shamir支持(t, n)门限重构,n个参与方里任意t个凑齐就能恢复秘密,这在授权恢复场景里很有吸引力;但它每次乘法都需要拉格朗日插值,逻辑回归一个epoch要跑几百次乘法,插值开销会直接拖垮训练节奏。加性秘密共享把秘密s拆成s1+s2+…+sn mod p,所有参与方各持一份分片,加法直接本地完成,乘法借助Beaver三元组也只需要一轮掩码交换,工程实现简单,对必须完整跑通的毕业设计更合适。
这套系统选的是Mersenne素数域p=2^127-1,原因很直接:Python大整数在这个域上的模运算速度快,且所有中间结果都限制在固定位数内,方便后续转成前端可展示的指标。分片数量默认3方,单机演示时用多线程模拟多个参与方,改配置也能直接扩展成多进程或多机部署。
2.2 医疗数据集预处理:三个csv各自承担什么角色
项目里的framingham.csv是包含4240条记录的Framingham心脏病风险数据,breast_cancer.csv是569个样本的威斯康星乳腺癌数据,heart.csv是303条记录的UCI心脏病数据。三个数据集都是二分类任务,但在系统里的分工不同:framingham作为主训练集,预测十年内冠心病风险;breast_cancer和heart.csv用于跨数据集验证,证明模型不是只在单一数据上有效。
预处理的关键是特征统一和缺失值处理。framingham原始16列里education、BPMeds、prevalentStroke缺失率差别很大,我取age、totChol、sysBP、diaBP、BMI、heartRate、glucose七个连续特征加TenYearCHD标签,缺失值用中位数填充。比较关键的一点:MPC训练时会频繁做特征归一化,但归一化的均值和方差必须在明文域先算好,因为密文上做除法和开方代价太高,属于工程取舍而非理论限制。
| 数据集 | 样本数 | 特征数 | 任务 | 在系统中的角色 |
|---|---|---|---|---|
| framingham.csv | 4240 | 16 | 十年冠心病风险二分类 | 主训练集 |
| breast_cancer.csv | 569 | 30 | 乳腺肿瘤良恶性分类 | 跨数据集验证 |
| heart.csv | 303 | 14 | 心脏病存在性分类 | 跨数据集验证 |
2.2.1 特征归一化在进入MPC前的固定点量化
归一化后的特征值都在0到1之间,但秘密共享域是整数,浮点数不能直接分片。做法是固定点量化:把浮点数乘上缩放因子后取整,再送入MPC协议层。
import pandas as pd import numpy as np SCALE = 1 << 16 # 65536,保留16位小数精度 def preprocess_and_quantize(path, feature_cols, label_col): df = pd.read_csv(path) for col in feature_cols: df[col] = df[col].fillna(df[col].median()) mean, std = df[col].mean(), df[col].std() # 均值和方差在明文域计算,密文上做除法代价过高 df[col] = (df[col] - mean) / (std + 1e-9) df[col] = (df[col] * SCALE).astype(np.int64) df[label_col] = df[label_col].astype(np.int32) return df[feature_cols].values, df[label_col].values量化缩放因子取65536是固定点训练的常见取值。缩放因子太小会丢失梯度,逻辑回归的每轮更新量本身就在0.001量级,小于1/65536的部分会被直接截断;缩放因子太大会让中间结果快速逼近p域上限,模运算后数值错乱。训练过程中所有中间结果保持在这个整数域里,只在输出层做反量化还原成浮点。
2.3 秘密分片与重构:随机源比分片算法更值得注意
预处理完成后,特征矩阵每一行都要拆成分片。加性秘密共享的分片逻辑很简洁:一个随机数加一个差值。但随机数必须来自密码学安全随机源,Python内置random模块的Mersenne Twister不适用,它的状态可预测,攻击者拿到若干连续输出后可能恢复整个序列。
import os P = 2**127 - 1 # Mersenne素数域 def split_secret(secret: int, n_parties: int = 3) -> list: """把整数秘密拆成n份,重构时全部相加即可恢复""" if not (0 <= secret < P): raise ValueError("secret out of field") shares = [] acc = secret % P for _ in range(n_parties - 1): r = int.from_bytes(os.urandom(32), 'big') % P shares.append(r) acc = (acc - r) % P shares.append(acc) return shares def reconstruct(shares: list) -> int: return sum(shares) % Psplit_secret尾部那个acc是核心:它保证所有分片相加等于原始秘密,而每个单独分片在域上均匀分布,任何一方都无法从自己持有的分片反推明文。n_parties默认3,对应系统里的三个计算参与方;改成2或5不需要动重构逻辑。secret大于等于P时直接抛异常,是为了防止模运算静默吞掉明文信息。
2.4 密文乘法:Beaver三元组的正确打开方式
逻辑回归训练里最频繁的操作是sigmoid的逐元素乘法和梯度计算中的矩阵乘法。加性秘密共享下乘法不能本地完成,因为两个分片和的乘积会裂解出交叉项,而这些交叉项无法只由本地信息算出。Beaver三元组的思路是预处理阶段生成满足c=a*b的随机三元组,每方只持有a、b、c各自的分片,计算乘法时交换一次掩码后的差值,把乘法转成域上的标量组合。
def beaver_mul(x_share: int, y_share: int, beaver: tuple, party_idx: int, n_parties: int = 3) -> int: """ beaver = (a_share, b_share, c_share),满足 c = a * b mod P 所有参与方本地组合各自结果后,求和即得 x * y """ a_share, b_share, c_share = beaver d_share = (x_share - a_share) % P e_share = (y_share - b_share) % P # 真实网络环境中,这里把 d_share/e_share 广播给其他参与方 # 并接收其他方的分片,下面用占位符表示收到的完整值 d_all = 0 e_all = 0 for j in range(n_parties): # 伪代码:d_recv, e_recv = network.recv(j, "d_e_pair") d_all = (d_all + receive(j, "d")) e_all = (e_all + receive(j, "e")) z_share = (c_share + d_all * b_share + e_all * a_share) % P if party_idx == 0: z_share = (z_share + d_all * e_all) % P return z_share安全性逻辑在于:d和e是经过随机掩码后的值,与原始x、y之间隔着随机a、b的干扰,单独拿到d或e无法反推明文。d_all和e_all对所有参与方可见,但掩码后的数值在统计意义上不泄露输入。真正需要保密的数据x和y从未以明文形式出现在任何一方。代码里的receive函数是占位,接实际网络层时用gRPC或消息队列替换即可。sigmoid的指数运算在密文上无法精确计算,工程里用3阶多项式在[-5, 5]区间内逼近,精度足够训练收敛,而且多项式求值只有乘法和加法,正好落在Beaver框架能力范围内。
3. 电子投票模块:Paillier同态聚合与可验证审计
3.1 五阶段投票状态机
同样是隐私计算,电子投票和加密训练的需求完全相反。训练关心梯度不泄露,投票关心选票不可追踪、结果可验证、不能重复投票。这套系统把投票流程拆成五个阶段:注册、投票权认证、选票编码、密文聚合、公开验票。注册阶段每个投票者拿到一次性token;认证阶段通过签名验证token合法性;编码阶段把选择转为同态加密密文;聚合阶段在密文上直接做加法;验票阶段由计票方解密汇总结果并公开哈希链。
投票模块用Paillier同态加密而不是秘密共享,是因为投票的计算形态是典型的单输入多输出加性聚合,正好落在Paillier的加法同态性质覆盖范围内,而且解密只需要计票方一个角色,不需要多方同时在线。
| 阶段 | 输入 | 输出形式 | 安全保障 |
|---|---|---|---|
| 注册 | 投票者身份信息 | 一次性token | token与身份绑定 |
| 投票权认证 | token + nonce | 签名凭证 | 防止重复投票 |
| 选票编码 | 候选人编号 | Paillier密文向量 | CPA安全 |
| 密文聚合 | 密文集合 | 聚合密文 | 不泄露单张选票 |
| 公开验票 | 聚合密文 + 哈希链 | 明文票数 | 可审计 |
3.2 选票的向量编码与同态累加
Paillier加密的核心性质是D(E(m1) * E(m2)) = m1 + m2,密文相乘对应明文相加。把这个性质用在投票上,每个投票者把选择编码成One-Hot向量,每个分量单独加密,计票方在密文上逐项累加,得到每个候选人的总票数。
# 依赖 phe 库,项目中也内置了等价实现 from phe import paillier pub_key, priv_key = paillier.generate_paillier_keypair(n_length=2048) def cast_ballot(choice: int, num_options: int = 5): """One-Hot编码后逐项加密,返回密文向量""" if not 0 <= choice < num_options: raise ValueError("choice index out of range") plain_vec = [0] * num_options plain_vec[choice] = 1 return [pub_key.encrypt(x) for x in plain_vec] def tally_ballots(ballot_list: list, num_options: int = 5): """密文域内累加,全程不接触单张明文票""" agg = [pub_key.encrypt(0) for _ in range(num_options)] for ballot in ballot_list: for i in range(num_options): agg[i] = agg[i] + ballot[i] return agg def publish_result(agg): return [priv_key.decrypt(c) for c in agg]cast_ballot里加密0和1时,Paillier的加密算法自带随机因子,同一明文在不同加密调用下密文完全不同,外界无法通过比对密文识别两张相同的选票。tally的双重循环时间复杂度是O(票数乘以候选人数),10万张选票、5个候选人的场景约50万次同态加法,2048位密钥下单次加法在亚毫秒量级,统计过程能控制在分钟级。
3.3 防止重复投票与选票可验证性
同态加密保护了选票机密性,但解决不了投票者是否重复投票的问题。系统在投票权认证阶段为每个token绑定一个nonce,计票阶段检查nonce是否已被使用。投票者提交选票后拿到一个收据哈希,验票时把收据与公开的哈希链根节点比对,能确认自己的票进了票箱,又无法向第三方证明投给了谁。
3.3.1 哈希链与批次验票
Merkle树在这个场景里可以做简化处理,用增量哈希链替代。每批次256张选票的密文拼接后与上一批次哈希值串接,再做一次SHA256。
echo -n "${ballot_cipher_b64}:${prev_hash}" | sha256sumballot_cipher_b64是当前批次256张选票密文的base64拼接,prev_hash是上一批次计算出的哈希值,两者之间用冒号分隔,冒号作为分隔符避免拼接歧义。任何人拿到公开的参与方公钥和批次顺序都能重算这条链,验证是否有选票被插入或删除。批次大小选256主要是平衡审计粒度和计算量:批次太大,单批内选票被篡改时定位困难;批次太小,验票文件膨胀,审计时间变长。
这里有个面试常追问的点:既然哈希链能防篡改,为什么还需要Paillier?答案是要防内部攻击。计票员即使看到全部密文,也无法判断每张票投给谁;哈希链只负责完整性,同态加密负责机密性,两者互相补齐,缺一不可。
4. 训练与测试可视化:AI_TRAIN_SHOW和AI_TEST_SHOW怎么联动后端
4.1 两层数据流:解密结果出节点,聚合指标上页面
训练过程的实时展示最难的不是画图,而是让页面看到的是MPC跑出来的真实数据而不是mock值。系统的做法是把后端拆成两层:MPC节点层负责密文训练,聚合服务层在每轮epoch结束后解密出loss和accuracy,通过异步GET接口暴露给浏览器。聚合层用一个带过期时间的缓存,每5秒刷新一次,避免前端轮询直接打到MPC节点上干扰训练线程。
数据流是:MPC训练、每个epoch结束、聚合层拉取各参与方分片并解密、写入缓存、前端fetch轮询接口。解密只在聚合层出现一次,训练过程中参与方之间交换的全是密文或掩码后的差值,可视化页面看到的已经是聚合后的明文指标。
4.2 训练页面的loss曲线与LR.gif的对应关系
AI_TRAIN_SHOW.html负责把训练过程可视化为三块信息:右上角损失曲线、左上角准确率数字、下方最近五个epoch的参数分布柱状图。LR.gif就是训练过程中录制的损失下降动画截帧。页面用递归setTimeout完成轮询而不是setInterval,因为setInterval在请求响应慢时会堆积回调,递归调用天然规避了这个问题。
let currentEpoch = 0; async function pollTrainingMetrics() { try { const resp = await fetch(`/api/training/epoch/${currentEpoch + 1}`); if (resp.status !== 200) { return; // 聚合层缓存还没有新epoch的数据,静默跳过 } const data = await resp.json(); appendLossPoint(currentEpoch, data.loss); updateAccuracyBanner(data.train_acc); currentEpoch += 1; } catch (err) { console.error(`polling epoch ${currentEpoch + 1} failed:`, err); } finally { setTimeout(pollTrainingMetrics, 2000); } } function appendLossPoint(epoch, loss) { const el = document.getElementById('loss-curve'); const arr = el.dataset.points ? JSON.parse(el.dataset.points) : []; arr.push({ epoch, loss }); el.dataset.points = JSON.stringify(arr); drawLossLine(arr); }fetch里用resp.status !== 200做提前返回,因为聚合层在缓存未命中时返回204,前端看到204就静默跳过,不报错也不产生噪音数据。轮询间隔设2000毫秒,与聚合层缓存刷新周期错开,避免每次轮询都穿透到后端。appendLossPoint每次把新点追加进dataset,再用SVG polyline重绘折线,数据量上限设1000个点,超出后丢弃最早的数据点,防止长时间训练占用浏览器内存。
4.3 测试页面:混淆矩阵与跨数据集对比
AI_TEST_SHOW.html展示三个数据集上的预测结果,布局是三栏对比表加一个主混淆矩阵。页面加载时请求一次/api/evaluate,后端返回三个数据集各自的准确率、精确率、召回率和特征重要度排序。breast_cancer和heart.csv规模小,预测结果几乎瞬时返回;正式演示时我会把framingham的测试批次设成32,让页面进度条有一点滚动感,体现等待密文计算的过程。
4.3.1 演示环境的跨域与静态服务
答辩时常用演示机直接开html文件,本地file协议下fetch跨域是最常见的翻车点。解决方法是起一个轻量HTTP服务,把两个html和css放在同源目录下:
python3 -m http.server 8080 --bind 0.0.0.0在项目根目录执行后,浏览器访问http://localhost:8080/AI_TRAIN_SHOW.html即可正常fetch。配合awesome-bootstrap-checkbox.css和MPC_style.css两套样式文件,页面基本不需要改就能适配不同分辨率的屏幕。如果只在服务器本机演示,把--bind改成127.0.0.1更安全;需要局域网其他机器访问时再用0.0.0.0。
4.4 可视化相关参数速查
| 参数 | 取值 | 作用 | 调优方向 |
|---|---|---|---|
| SCALE | 65536 | 固定点量化缩放 | 梯度爆炸时降为4096 |
| EPOCHS | 100 | 训练轮次 | 医疗小数据集50即可收敛 |
| BATCH_SIZE | 32 | 每批样本数 | 增大可减少训练噪声 |
| 轮询间隔 | 2000ms | 前端刷新频率 | 网络抖动时升高到5000ms |
| 缓存过期 | 5s | 聚合层保护 | MPC节点增多时升高到10s |
EPOCHS和BATCH_SIZE应该进配置文件,不要硬编码。复用这套源码时需要注意:把三个数据集同时塞进训练流程后,BATCH_SIZE设成64的话,framingham的最后一个batch会不足,训练代码里需要drop_last=True丢掉尾部,否则shape不匹配会直接报错。
5. 复现与调整:分片粒度、浮点精度和并发安全
5.1 浮点精度是MPC训练里最先爆炸的地方
直接照搬明文逻辑,把浮点权重转成整数参与秘密共享,两个epoch就出NaN。根因是逻辑回归的梯度更新是累积运算,每轮乘法后都要把结果约回量化域,而加性秘密共享的域是模P的循环群,数值越过P就会回绕。复现时先跑单特征最小实验,把SCALE从65536降到4096跑通收敛,再逐步恢复,能快速定位是精度问题还是协议实现问题。另一个验证技巧是明文对照:同一份数据同时跑明文逻辑回归和MPC版本,损失曲线近似、最终准确率差在2个百分点以内,说明协议层实现和量化参数都没有问题。
5.2 分片数量与通信开销的爬升曲线
3方和5方在协议层几乎没有差别,但Beaver三元组的分发量随参与方数量增长很快。2方乘法只需要一组三元组,5方时掩码交换的配对关系增加到C(5,2)=10组,通信量接近线性翻倍,训练耗时会明显拉长。毕设答辩用3方就足够说明问题,展示可扩展性时把n_parties改成5,但要在训练开始前预热生成足够多的三元组,不要让训练进程等待三元组生成。三元组生成是离线步骤,可以和训练线程完全解耦,用单独进程提前跑完存盘。
5.3 并发安全与随机源隔离
训练线程和投票服务如果共用一个随机数生成器,存在状态竞争风险。每个线程必须独立持有自己的随机源,用os.urandom包装的SystemRandom实例,不要用全局random.Random。我写过一个自查测试:并发条件下对同一个秘密分片1000次,断言所有分片互不相同且重构后等于原值,这个测试可以挂在CI里,协议层改动后第一时间发现回归。投票token的过期时间设成会话级,每周跑一次过期token清理任务,防止长驻内存占用积累。这三处修完,从高分毕设到可产品化的隐私计算系统,只差一个审计日志的运维界面。
本文还有配套的精品资源,点击获取