简介:这份压缩包面向联邦学习、分布式训练与隐私保护方向的初学者和项目实践者,围绕MNIST手写数字识别给出可运行的联邦学习与差分隐私实验材料,适合用于课程设计、入门复现和算法对比。包内共17个文件,约56.29MB,主要包括Python训练与测试脚本、pth与pt模型权重、MNIST原始图像与标签文件及npz数据,并配有说明文本和压缩数据,覆盖服务端聚合、客户端本地训练、LeNet模型定义与测试评估等环节。已有669人学习下载,说明其在入门实践中具有一定参考价值。通过梳理联邦平均聚合、数据分片、差分隐私加噪和模型迭代流程,读者可理解原始数据不出本地、仅上传模型更新的协作训练机制,也可借助预训练权重复现实验结果、观察不同轮次的性能变化,为后续扩展到医疗、金融等敏感场景打下基础。
1. 联邦学习跑 MNIST:一个压缩包能省掉的三天搭环境时间
第一次把联邦学习跑起来,卡住大多数人的不是 FedAvg 那几行加权平均,而是数据:60 万张 MNIST 要切成几十份互不重叠、标签分布还各不相同的子集,切完还得保证每轮聚合时索引对得上、样本数能当权重用。这份「联邦学习分布式训练 MNIST 数据集」压缩包解决的正是这段最枯燥的准备工作——数据集、划分脚本、客户端/服务端训练骨架打包在一起,跳过造轮子直接进到调参和排错。适合两类人:要复现 Non-IID 场景下 FedAvg 表现的学生,和准备把自己业务数据套进联邦流程、先拿 MNIST 验证链路通不通的工程师。下面按拆包、切分、训练、通信、排错的顺序走一遍。
2. 拆包与数据切分:Non-IID 的 Dirichlet 划分怎么落地
联邦学习分布式训练里,数据集这一层的坑比模型层多得多。集中式训练只要一个DataLoader就完事,联邦要的是「N 个客户端各自的 DataLoader」,而且这 N 份数据得满足三个条件:互不重叠、并集等于全集、标签分布可控。这一章把压缩包里的数据侧内容拆开讲透。
2.1 包内结构与运行环境确认
解压之后,文件命名各家不同,但按作用找,一般是这四类东西:原始 MNIST(或已经转好的 idx/npz)、划分脚本、客户端与服务端的训练脚本、以及一份配置。我拿到任何训练包的第一件事都不是急着跑,而是先看清楚它把「数据划分」放在哪一步——是预处理阶段生成固定分区,还是每次启动时现切。这两种做法的差别很大:前者可复现、能缓存,后者省磁盘但每次随机种子一变,分区就变了,实验结果没法比。
| 位置 | 典型内容 | 上手必须先确认的点 |
|---|---|---|
data/ | MNIST 原始文件或已打包的 npz | 是原始 28×28 灰度图,还是已经做过归一化 |
partition*.py | 划分脚本,IID / Dirichlet / Shard | 是否固定 seed,是否落盘 |
client*.py | 本地训练循环 | 本地 epoch 数、优化器、是否复制全局模型 |
server*.py | 聚合与轮次控制 | 加权方式是按样本数还是按客户端等权 |
configs/ | 超参文件 | 客户端数、参与率、通信轮次 |
先把环境跑通,别在这一步省事:
# 看包里到底有什么,先看清单再解压,避免覆盖同名目录 unzip -l federated_mnist.zip | head -40 # 解压到独立目录 unzip -q federated_mnist.zip -d fl_mnist && cd fl_mnist # 确认关键依赖,MNIST 规模下 CPU 也能跑,有卡更快 python -c "import torch, torchvision, numpy; \ print('torch', torch.__version__); \ print('cuda', torch.cuda.is_available())" # 固定随机性,联邦实验最怕每轮分区都不一样 export PYTHONHASHSEED=0 export CUBLAS_WORKSPACE_CONFIG=:4096:8unzip -l先看清单是为了确认压缩包是不是「平铺」结构——很多包解压出来直接是十几个 py 文件铺在当前目录,和已有工程混在一起,后面清理很麻烦。PYTHONHASHSEED管的是 Python 层的哈希顺序,影响set、dict遍历;CUBLAS_WORKSPACE_CONFIG是开启torch.use_deterministic_algorithms(True)时 cuBLAS 需要的环境变量,不设会在第一次矩阵乘时报错。MNIST 上这两项不是必须,但联邦实验动辄要跑五六组对照,结果不能复现的话,后面所有对比都是白做。
还有一点:如果包里的数据是.pt或.npz打包好的,先用 numpy 看一眼形状和取值范围。
import numpy as np d = np.load("data/mnist_partitioned.npz") print(list(d.keys())[:5]) # 期望形如 c0, c1, ... 或 x_train / y_train x = d[list(d.keys())[0]] print(x.shape, x.dtype, x.min(), x.max()) # 期望 (N, 28, 28) uint8 且 0~255;若是 (N, 784) float 且 0~1,说明已经归一化过这一步看的是两件事:形状和值域。如果拿到的已经是float32且落在 0~1,训练脚本里再来一次Normalize((0.1307,), (0.3081,))就会把数据压到接近全零,模型直接学不动,而且 loss 曲线看起来还挺「平稳」,非常容易误判成收敛。归一化只能做一次,这是我在联邦场景里见过最多的静默失败。
2.2 从 IID 到 Dirichlet:客户端数据划分的实现
为什么要专门讲划分?因为联邦学习分布式训练的价值全在 Non-IID 上。如果每个客户端的数据都是均匀的十类分布,那 FedAvg 的收敛曲线和集中式训练几乎重合,你根本验证不出任何东西。真实的端侧数据天然带偏,MNIST 上模拟这种偏态最常用的两种做法是 Shard(每个客户端只分到固定几类)和 Dirichlet(按浓度参数抽比例)。
Shard 简单粗暴:把训练集按标签排序,切成2 × num_clients个 shard,每个客户端拿 2 个。缺点是偏得太极端,客户端本地只有两个类,模型的输出层会严重偏向这两类,聚合后全局模型在别的类上掉点非常明显,容易让人误以为算法有问题。
Dirichlet 更接近真实:每个客户端都持有全部十类,但比例各不相同。核心是按类独立做一次 Dirichlet 抽样,得到这一类的样本在各客户端上的分配比例。落地代码如下,可以直接抄:
import numpy as np from torchvision import datasets def dirichlet_partition(targets, num_clients, alpha, seed=0, min_size=10): """按类做 Dirichlet 抽样,返回每个客户端的样本索引列表""" rng = np.random.default_rng(seed) num_classes = int(targets.max()) + 1 # 先按类别把索引分组,并打乱,避免同类样本扎堆 idx_by_class = [np.where(targets == c)[0] for c in range(num_classes)] for c in range(num_classes): rng.shuffle(idx_by_class[c]) client_idx = [[] for _ in range(num_clients)] for c in range(num_classes): # alpha 越小,比例越极端;alpha 越大越接近均匀 p = rng.dirichlet(np.repeat(alpha, num_clients)) cuts = (np.cumsum(p) * len(idx_by_class[c])).astype(int)[:-1] for cid, part in enumerate(np.split(idx_by_class[c], cuts)): client_idx[cid].extend(part.tolist()) # 兜底:alpha 很小时会出现空客户端,从最大的客户端借样本 for cid in range(num_clients): if len(client_idx[cid]) < min_size: donor = max(range(num_clients), key=lambda i: len(client_idx[i])) need = min_size - len(client_idx[cid]) client_idx[cid].extend(client_idx[donor][:need]) client_idx[donor] = client_idx[donor][need:] return client_idx train = datasets.MNIST(root="./data", train=True, download=True) parts = dirichlet_partition(train.targets.numpy(), num_clients=50, alpha=0.5, seed=42) sizes = [len(p) for p in parts] print("客户端数", len(parts), "最少", min(sizes), "最多", max(sizes), "总计", sum(sizes))逻辑上有三个点值得说。第一,rng.shuffle在分类之后做,是为了让同一类里的样本随机落到不同客户端,否则 MNIST 原始顺序本身带一点聚集性。第二,np.split用cuts切分,cumsum保证比例严格按 Dirichlet 抽出来的p,[:-1]去掉末尾的 1.0,否则会多切出一个空段。第三,兜底循环不能省:alpha=0.1时几乎必然出现某个客户端只分到个位数样本甚至 0 个,这类客户端参与聚合时权重极小却会贡献一个偏差很大的梯度方向,我在早期实验里被它坑过一次。
参数怎么选:
| 参数 | 含义 | 建议取值 | 影响 |
|---|---|---|---|
num_clients | 客户端总数 | 20 / 50 / 100 | 数量越多单客户端样本越少,本地 epoch 要相应调小 |
alpha | Dirichlet 浓度 | 0.1 / 0.5 / 1.0 | 0.1 极端偏态,100 近似 IID,做对照实验用 0.5 起步 |
seed | 分区随机种子 | 固定值 | 换了它等于换了整个实验,报告里必须写清 |
min_size | 客户端最小样本数 | 10~50 | 低于这个数,本地 BatchNorm 和 loss 都会抖 |
想要一组有说服力的对照,就用同一个seed跑alpha ∈ {0.1, 0.5, 1.0}三条曲线。注意alpha=1.0也只是「接近」IID,真 IID 要单独用均匀切分,别拿它冒充。
2.3 分区落盘与加载:别每轮重新切
划分这步的计算量不大,但如果你每次启动实验都重切一遍,一是慢,二是只要 seed 忘了固定,前后两次实验的分区就不一样,结果完全不可比。所以我一般的做法是把分区结果落成一份索引文件,训练脚本只读不切。
import numpy as np # 只存索引,不存图像本体,几十 MB 变几百 KB np.savez_compressed( "data/partitions_a0.5_c50_s42.npz", **{f"c{i}": np.asarray(v, dtype=np.int64) for i, v in enumerate(parts)} ) print("已保存", len(parts), "个客户端分区")存索引不存图像,是很实际的一条经验。MNIST 训练集 6 万张,50 个客户端如果各自存一份图像副本,压缩包体积会膨胀十几倍,而索引文件只有几百 KB。文件名里把alpha、客户端数、seed 都写进去,跑十组实验也不会搞混,这比在代码里维护一堆常量靠谱得多。
读取侧用Subset包一层就够了,别自己写 Dataset:
import torch from torch.utils.data import DataLoader, Subset from torchvision import datasets, transforms tf = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)), # 只做一次,数据侧不要再归一化 ]) base = datasets.MNIST(root="./data", train=True, download=True, transform=tf) parts = np.load("data/partitions_a0.5_c50_s42.npz") def client_loader(cid, batch_size=32, num_workers=2): idx = parts[f"c{cid}"] ds = Subset(base, idx.tolist()) return DataLoader(ds, batch_size=batch_size, shuffle=True, num_workers=num_workers, pin_memory=True, drop_last=False) test_set = datasets.MNIST(root="./data", train=False, download=True, transform=tf) test_loader = DataLoader(test_set, batch_size=512, shuffle=False)Subset共享同一个base的图像缓存,50 个客户端不会各存一份原始数据,内存占用和客户端数基本无关,这是它比「每个客户端单独建 Dataset」强的地方。drop_last=False是刻意的:联邦里客户端样本数直接当聚合权重用,丢掉最后不满一个 batch 的样本会让权重算错,尤其是小客户端上误差能到 20% 以上。num_workers在单机多进程模拟时要注意,每个客户端进程再开 2 个 worker,50 个客户端就是 100 个进程,机器资源很快就撑不住。
最后一件必须做的事:把全局测试集和各客户端的本地测试集分清楚。上面这份test_loader是全局测试集,用来评估聚合后的全局模型;如果你想看「个性化」效果,才需要给每个客户端再切一份本地测试集。这两者混用是最常见的指标虚高来源,第 5 章会细说。
3. 本地训练与参数聚合:FedAvg 一轮完整流程的手写实现
数据准备好之后,联邦学习分布式训练的主干其实很短:服务端下发全局模型 → 各客户端本地跑若干 epoch → 回传参数 → 服务端加权平均 → 进入下一轮。真正花时间的是每一段的参数怎么定、状态怎么对齐。这一章把一轮完整的 FedAvg 手写一遍,包括那些文档里通常不写的对齐细节。
3.1 客户端本地训练:epoch、batch 与优化器的一致性
本地训练这段代码看起来和普通训练没区别,但有三处必须和集中式训练区别对待:优化器每轮都要重建、本地 epoch 不能太大、学习率要跟着本地步数调。
import copy, torch import torch.nn.functional as F from torch import nn class SmallCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 28 -> 14 nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 14 -> 7 ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(), nn.Linear(128, num_classes) ) def forward(self, x): return self.classifier(self.features(x)) def local_train(global_state, loader, local_epochs=1, lr=0.01, device="cpu"): # 关键:深拷贝,绝不能让客户端直接改全局 state_dict model = SmallCNN().to(device) model.load_state_dict(copy.deepcopy(global_state)) # 优化器必须每轮新建,动量和权重衰减状态不能跨轮继承 opt = torch.optim.SGD(model.parameters(), lr=lr, momentum=0.9, weight_decay=5e-4) model.train() for _ in range(local_epochs): for x, y in loader: x, y = x.to(device, non_blocking=True), y.to(device, non_blocking=True) opt.zero_grad(set_to_none=True) loss = F.cross_entropy(model(x), y) loss.backward() opt.step() return copy.deepcopy(model.state_dict()), len(loader.dataset)copy.deepcopy(global_state)这一行是整段代码里最容易出事的地方。load_state_dict默认做的是param.data.copy_(input),本身是拷贝语义,所以单次调用没问题;但如果多个客户端共享同一个模型对象(比如[global_model] * N这种列表乘法),它们改的是同一份权重,聚合结果等于最后一个客户端的结果,曲线会呈现出一种诡异的「一直在学但学不好」的状态。深拷贝是最省心的做法,内存代价在一个 42 万参数的小网络上可以忽略。
opt = SGD(...)放在函数内部而不是外面,对应的是 FedAvg 的标准定义:每轮客户端从全局模型重新出发,优化器状态清零。如果跨轮复用优化器,动量项会把上一轮的方向带进来,相当于在客户端之间引入了隐式的历史依赖,聚合出来的模型和不复用的情况差别不小。想做 FedOpt 那一类服务端自适应优化是另一回事,那是服务端的优化器,别和客户端混了。
参数上,MNIST 这种量级本地 epoch 取 1 到 3 就够。取到 10 以上,客户端会严重过拟合本地分布,Non-IID 下全局模型反而掉点——这不是玄学,本地步数越多,客户端漂移越远,加权平均出来的方向越偏。学习率从 0.01 起步配合 momentum 0.9,alpha=0.1这种极端偏态下可以降到 0.005。
3.2 服务端聚合:加权平均与 state_dict 对齐
聚合这三行代码谁都会写,难的是「哪些 key 能平均、哪些不能」。MNIST 上如果用带 BatchNorm 的网络,num_batches_tracked是 int64,直接乘浮点权重会报 dtype 错;running_mean和running_var也不能跨客户端平均,语义上讲不通遍历不同分布得到的统计量。
def fedavg(global_state, client_states, client_sizes): """按样本数加权平均,只对浮点张量做,整数 buffer 保留全局值""" total = float(sum(client_sizes)) weights = [n / total for n in client_sizes] new_state = {} for k, v in global_state.items(): if not torch.is_floating_point(v): new_state[k] = v.clone() # num_batches_tracked 这类直接沿用 continue acc = torch.zeros_like(v, dtype=torch.float32) for ws, w in zip(client_states, weights): acc += ws[k].float() * w new_state[k] = acc.to(v.dtype) return new_state先说权重。client_sizes用的是各客户端的样本数,不是等权。这两者在 IID 下几乎没差别,但在alpha=0.1这种偏态下,等权会让小客户端(可能只有几十个样本)的梯度方向和有大几千样本的客户端一样重,全局模型会被拉偏。这个差别我在 50 客户端、alpha=0.1的设置下实测过,最终准确率能差到 3 到 5 个百分点,所以别图省事写1/len(states)。
再说acc用float32累加。模型参数本来就是 float32,但如果你开了amp或者用了 bfloat16,累加过程中精度损失会累积,尤其是 100 个客户端依次相加的时候。先.float()累加最后再.to(v.dtype)回转,是一个几乎零成本的习惯。至于整数 buffer,直接保留全局值最省事;如果一定要处理 BatchNorm 的统计量,正确做法是让客户端在本地重新统计一遍再聚合,而不是对running_var求平均。
聚合完还有一步经常被漏掉:把新 state_dict 推回全局模型。
global_model = SmallCNN() global_state = copy.deepcopy(global_model.state_dict()) for rnd in range(1, 101): client_states, client_sizes = [], [] for cid in sampled_clients: state, n = local_train(global_state, client_loader(cid), local_epochs=1, lr=0.01) client_states.append(state) client_sizes.append(n) global_state = fedavg(global_state, client_states, client_sizes) global_model.load_state_dict(global_state) # 漏了这行,评估的还是初始权重global_model.load_state_dict(global_state)这行漏掉的后果是:训练照跑、loss 照降、评估指标一动不动。因为评估用的是global_model,而它从头到尾没被更新过。这个 bug 我在别人代码里见过不止一次,排查时先看这行在不在。
3.3 轮次、参与率与收敛口径
超参里最需要想清楚的是三个:总轮次R、每轮参与率C、本地 epochE。这三个量共同决定「总共算了多少本地步」和「通信了多少次」,而通信次数才是联邦场景下真正的成本。
| 参数 | 典型值 | 调大的后果 | 调小的后果 |
|---|---|---|---|
R总轮次 | 50~200 | 线性增加通信开销,收益递减 | 欠拟合,曲线还没平就停了 |
C参与率 | 0.1~1.0 | 每轮更稳定,但单轮耗时上升 | 梯度噪声大,曲线抖 |
E本地 epoch | 1~5 | 通信轮次可减少,但客户端漂移加剧 | 通信轮次需要成倍增加 |
Bbatch size | 32~128 | 本地步数减少,收敛变慢 | 小客户端一个 epoch 只有几步 |
观察收敛曲线有个经验口径:前 20 轮掉点、甚至比初始模型还差,是正常的。Non-IID 下各客户端的初始方向互相冲突,前几轮平均出来的模型往往不如随机初始化,第 30 轮之后才开始稳定爬升。如果 100 轮之后准确率还在 10% 附近打转(MNIST 十类的随机水平),基本可以断定是聚合那一步出了问题,而不是「联邦学不动」。
评估频率也别每轮都做。MNIST 测试集 1 万张,每轮评估一次在小网络上要多花几秒,100 轮下来就是几分钟的纯开销。我一般每 5 轮评一次,同时记录参与客户端的平均本地准确率,两条曲线一起看:如果全局准确率涨但平均本地准确率不涨,说明聚合在做「平均化」,各客户端的个性化能力被抹平了,这时候该考虑 FedProx 或者个性化层。
4. 分布式通信层:单机多进程与多机拓扑的取舍
标题里带「分布式训练」,但压缩包里的实现大概率是单机多进程模拟,这一点要提前认清,否则上真机时会发现处处不对劲。这一章讲通信层的选型、开销估算,以及掉线这类真分布式才会遇到的问题。
4.1 三种跑法的边界:多进程、Ray、gRPC
同样是「多个客户端并行训练」,实现方式差别很大,成本和调试难度也不一样。
| 方案 | 适用规模 | 优点 | 代价 |
|---|---|---|---|
multiprocessing/concurrent.futures | 单机 8~50 客户端 | 零依赖,改几行就能并行 | 只能单机,进程间靠 pickle 传参 |
| Ray Actor | 单机到小集群 | 天然支持异构资源和调度 | 多一层框架,调试栈变深 |
| gRPC / HTTP 自建 | 跨机 | 最接近真实部署形态 | 要自己处理序列化、超时、重连 |
我一般的顺序是:先用多进程把算法逻辑跑通、曲线调对,再换 Ray 验证并行调度,最后才考虑真跨机。反过来做的话,你会同时面对算法 bug 和网络 bug,排查成本翻倍。压缩包里的多进程版本价值就在这——它把「算法正确性」和「通信实现」解耦了,你可以先只关心前者。
用多进程时有个参数必须显式设:
import torch.multiprocessing as mp if __name__ == "__main__": # 每个进程开 OMP 线程会互相抢核,必须限制 torch.set_num_threads(1) ctx = mp.get_context("spawn") # fork 在多线程 + CUDA 下容易死锁 with ctx.Pool(processes=8) as pool: results = pool.starmap(run_client, [(cid, global_state) for cid in sampled])spawn而不是默认的fork,是因为fork出来的子进程会继承父进程已经初始化的线程池和 CUDA 上下文,在多线程环境下偶发死锁,而且不报错、只是卡住,非常难查。torch.set_num_threads(1)是因为 8 个客户端进程各自默认开满 OMP 线程的话,48 核机器也会被打爆,反而比单进程还慢。这两个坑都属于「不设也能跑,但跑得莫名其妙」。
4.2 通信开销:先算清楚再优化
联邦学习论文里翻来覆去讲通信效率,但很多人没算过自己这个模型传一次到底多大。一个两层卷积的 MNIST 小网络大约 42 万参数,float32 下约 1.7 MB,一轮 5 个客户端参与就是上传 8.5 MB、下发 8.5 MB(下发如果做广播可以省),100 轮总计 1.7 GB 左右。这个量级在单机上是噪音,但在真实端侧就是实打实的成本。
def state_bytes(state): total = 0 for v in state.values(): total += v.numel() * v.element_size() return total n = state_bytes(global_state) print(f"单份模型 {n/1024/1024:.2f} MB") print(f"100 轮 x 每轮 5 客户端 上行合计 {n*5*100/1024/1024/1024:.2f} GB")先算清楚,再决定要不要上压缩。常见的三种做法:量化(float32 转 int8,体积降到 1/4,MNIST 上精度损失很小)、稀疏化(只传绝对值大的 top-k 梯度,需要配合残差累积)、以及减少通信轮次(增大本地 epoch)。前两种要改序列化层,第三种只改超参,所以如果你只是想让实验跑得快一点,先调E和R,别一上来就写压缩算法。
序列化本身也有讲究。多进程之间传state_dict走的是 pickle,一个 1.7 MB 的 dict 序列化加反序列化大概在毫秒级,50 个客户端就有几十毫秒的纯开销,跑到几百轮就很可观。能传张量列表就别传带嵌套结构的 dict,能用torch.save的二进制格式就别用 json。
4.3 掉线与慢客户端:真分布式才有的问题
单机多进程不会有掉线,但一旦上真机,客户端中途退出就是常态,而不是异常。处理好这件事,联邦流程才算完整。
import random def sample_clients(available, frac, seed): rng = random.Random(seed) k = max(1, int(len(available) * frac)) return rng.sample(available, k) # 无放回,避免同一客户端被抽两次 def run_round(global_state, clients, frac, round_seed, timeout_s=120): picked = sample_clients(list(clients.keys()), frac, round_seed) states, sizes = [], [] for cid in picked: try: # 真实场景这里是 RPC 调用,超时抛异常 state, n = clients[cid].train(global_state, timeout=timeout_s) except TimeoutError: print(f"client {cid} 超时,本轮跳过") continue states.append(state); sizes.append(n) if not states: # 全部失败时回退,别让训练中断 return global_state, 0 return fedavg(global_state, states, sizes), len(states)三个细节。random.sample是无放回抽样,用rng.choice(..., replace=True)会抽到重复客户端,聚合时同一个客户端的权重被算两次,等价于给它加权,曲线会莫名偏好某些客户端的数据分布。timeout必须显式给,没有超时的 RPC 在客户端掉线时会一直挂着,整个训练卡死。最后那个if not states的回退分支,是为了防止某一轮所有客户端都失败时直接抛异常中断——真机上这种情况会发生,回退到上一轮的全局模型,下一轮重新采样就行。
还有一个「慢客户端」问题:某个客户端每个 batch 要跑 10 秒,其他只要 0.1 秒,同步聚合下整轮就被它拖住。常见做法是设一个单轮时间上限,超时的客户端本轮结果丢弃,但要记账——如果某个客户端连续 5 轮超时,说明它的算力或网络确实不行,应该从候选池里降权而不是每轮都白等。
5. 避坑排查:联邦训练里最容易被忽略的五个翻车点
这一章全是踩过的坑。每条按「现象 → 原因 → 解决」写,碰到对应的现象可以直接对号入座。
5.1 数据侧的两个静默失败
坑一:客户端数据不重不漏,但准确率就是上不去。
现象:训练能跑完,loss 正常下降,全局准确率停在 60% 左右,怎么调学习率都没用。
原因:分区时用了带放回的抽样,或者多个客户端的索引区间有重叠。重复样本在聚合时被反复加权,等价于给这部分数据放大了权重,模型在这部分上过拟合,在没被覆盖的样本上表现差。这种问题不会报错,只能靠检查。
解决:划分完成后立刻做一次并集和交集检查,并写进流程里:
import numpy as np all_idx = np.concatenate([np.asarray(v) for v in parts.values()]) assert len(all_idx) == len(np.unique(all_idx)), "存在重复样本" assert len(all_idx) == 60000, f"样本数不对: {len(all_idx)}" print("分区校验通过,无重复无遗漏")这两行断言建议每个新分区都跑一遍,成本几毫秒,能省掉半天的困惑。
坑二:全局准确率和本地准确率对不上,差得离谱。
现象:全局模型在全局测试集上 92%,但把模型发到某个客户端上测,只有 40%。
原因:多半是客户端本地测试集和全局测试集混用了,或者本地测试集本身就来自这个客户端的训练分区(数据泄漏)。Non-IID 下客户端本地分布和全局分布本来就不同,40% 不一定是 bug;但如果本地测试集是从训练索引里切的,那就是泄漏,数字完全没有参考价值。
解决:训练集、全局测试集、各客户端本地测试集三份索引必须分开生成、分开落盘、命名带前缀。评估时明确标注用的是哪一份。要看个性化效果,本地测试集必须从该客户端「没参与训练」的样本里切——而 MNIST 上这往往意味着要从全局测试集里按标签分布再切一份,别偷懒从训练分区里拿。
5.2 训练侧的两个隐蔽偏差
坑三:聚合后模型性能还不如单个客户端单独训练。
现象:某一轮聚合完成,全局模型准确率突然掉一大截,下一轮又恢复,来回震荡。
原因:客户端漂移。本地 epoch 太多、学习率太大,各客户端在自己那份偏态数据上跑得太远,回传的参数方向互相冲突,加权平均的结果落在一个谁都不满意的位置。alpha越小、E越大,这个现象越明显。
解决:先把E降到 1,把学习率减半,看震荡是否消失。如果消失,说明确实是漂移,后续可以用 FedProx 加近端项约束(第 6 章讲),或者用学习率衰减让后期的本地更新更保守。别一上来就怀疑聚合代码写错了,先排除超参。
坑四:换了个随机种子,结果差 5 个点。
现象:同一份代码、同一组超参,只改了 seed,最终准确率从 91% 掉到 86%。
原因:联邦实验的方差本来就比集中式大——客户端采样、数据划分、本地 shuffle、权重初始化,四个随机源叠在一起。只跑一次就下结论,等于在噪声里找信号。
解决:固定三个种子(分区 seed、客户端采样 seed、torch 全局 seed),报告结果时至少跑 3 个种子取均值和标准差。如果两组配置的差距小于标准差,那这个差距不值得写进结论。这一条听起来像废话,但我在对照实验里见过太多次「A 比 B 好 2 个点」其实是种子噪声。
5.3 聚合与通信的一个致命细节
坑五:聚合代码没错,但全局模型像是没更新过。
现象:训练日志正常打印每轮 loss,评估指标从头到尾一条直线。
原因:三个可能,按概率排序——评估用的模型对象没有被load_state_dict更新;聚合结果赋给了局部变量没传出去;多进程里子进程改了副本,父进程的global_state还是旧的。
解决:在聚合后加一行断言,直接比对参数是否真的变了:
before = global_state["classifier.3.weight"].clone() global_state = fedavg(global_state, client_states, client_sizes) after = global_state["classifier.3.weight"] delta = (after - before).abs().mean().item() assert delta > 0, "聚合后参数没有变化,检查 state_dict 传递链路" print(f"参数平均变化量 {delta:.6f}")这个delta还能当健康指标用。正常情况下它随轮次递减,如果某几轮突然变大,说明有客户端贡献了异常大的更新,往回去查那个客户端的数据量是不是太小、学习率是不是太高。把调试信息变成断言留在代码里,比每次出问题再手写 print 强得多。
6. 对齐集中式上界:给联邦结果找一个可比的标尺
联邦实验最容易自欺欺人的地方,是只跟自己比。准确率从 85% 涨到 90% 听起来不错,但集中式训练在同样数据上能到 99%,那这 90% 到底说明算法有效还是流程有瓶颈?我的习惯是每次都跑一条集中式基线当上界,用完全相同的网络结构、优化器和总本地步数,只是把数据合在一起训练。
# 集中式基线:同样的模型、同样的总 epoch 预算,数据不切分 base_model = SmallCNN() opt = torch.optim.SGD(base_model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4) full_loader = DataLoader(Subset(base, np.arange(60000)), batch_size=64, shuffle=True) for epoch in range(5): # 5 x 60000/64 ≈ 4680 步,约等于联邦 100 轮 x 5 客户端 x 9 步 base_model.train() for x, y in full_loader: opt.zero_grad(); F.cross_entropy(base_model(x), y).backward(); opt.step()对齐的关键是「总本地步数」而不是「轮数」。联邦 100 轮、每轮 5 个客户端、每客户端 1 个 epoch 大约 9 步,合计约 4500 步;集中式基线就按 4500 步来配,这样两条曲线才有可比性。如果集中式基线只跑 1 个 epoch 就来对比,那联邦赢了也没意义。
表里是我在alpha=0.5、50 客户端、C=0.2这组设置下常见的结果形态,可以作为你自查的参照:
| 配置 | 预期测试准确率 | 说明 |
|---|---|---|
| 集中式基线(4500 步) | 98.5%~99.2% | 上界,用来判断流程有没有问题 |
FedAvg,alpha=1.0 | 97%~98.5% | 接近 IID,损失很小 |
FedAvg,alpha=0.5 | 95%~97.5% | 常规 Non-IID,主要看这个区间 |
FedAvg,alpha=0.1 | 88%~94% | 方差大,必须多种子取均值 |
| 单客户端本地训练 | 60%~85% | 只见过自己那部分数据,泛化差 |
如果alpha=1.0都跑不到 97%,先别怀疑算法,回去查数据归一化是不是做了两次、聚合权重是不是等权、评估模型有没有被更新。这三个查完,绝大多数「联邦学不动」的问题就解决了。
再往上走一步是 FedProx:在客户端本地 loss 里加一个近端项(mu/2) * ||w - w_global||^2,把本地更新拉向全局模型,抑制漂移。mu从 0.001 试起,alpha越小越需要它。加了之后alpha=0.1那组通常能提 2 到 4 个点,代价是多一个要调的超参。
从那以后我每次开新的联邦实验,都强制先跑一遍分区校验和集中式基线,两条都过了才动算法。这个习惯救过我很多次——有回怎么调都不收敛,最后发现是分区脚本里alpha被写死成 100,等于在跑 IID,自然什么都验证不出来。希望帮到你。
本文还有配套的精品资源,点击获取