PyTorch本地模拟横向联邦学习:FedAvg工程骨架与避坑实践

发布时间:2026/11/2 11:27:11
PyTorch本地模拟横向联邦学习:FedAvg工程骨架与避坑实践

简介:这份面向机器学习学习者与开发者的Python实践包,围绕横向联邦学习的本地模拟展开:多个虚拟客户端各自持有私有数据并独立训练,服务器负责收集参数、聚合并广播全局模型,帮助读者在单机环境中理解联邦学习的协作流程与实现要点。压缩包共25个文件,以5个Python脚本为核心,覆盖模型定义、客户端训练、服务端聚合和程序入口;另有字节码缓存、IDE工程配置及CIFAR-10数据分片等辅助内容,整体约302.68MB,目录按功能模块组织。已有729人学习下载,说明该主题在开发社区中具有一定关注度。读者可获得一套可运行、可拆解的联邦学习原型,用于观察本地训练与参数聚合的衔接方式,理解聚合策略、通信接口和迭代收敛过程,也便于课程实验、方案验证与二次开发。

1. 本地模拟横向联邦学习:一台笔记本上跑通联邦训练闭环

很多人第一次接触联邦学习,是被「数据不出本地」这句话吸引,打开框架文档却发现要先起三台机器、配证书、开端口,一个下午全耗在环境上。其实验证一个聚合算法到底有没有效,用 Python 做本地模拟横向联邦学习就够了:把一份数据集按客户端切开,轮流在各自的数据上训练,再集中聚合权重,一台笔记本跑完全程。这份资料适合两类人:一类是想搞懂 FedAvg 到底在干什么、需要能自己动手改的新手;另一类是要快速验证某个改进思路、不想被部署细节拖住的熟手。它提供的是一份能改、能加客户端、能换聚合策略的 Python 工程骨架,并非论文复现。

2. 工程骨架拆解:五个包把切数据、训本地、聚合分开

拿到一份联邦学习的 python 文档资料,最怕的写法是所有逻辑塞进一个main.py,改个聚合算法要往上翻八百行,改完还不知道自己动的是客户端还是服务端。这个项目的第一层设计就是把职责按「数据怎么切、客户端怎么训、服务端怎么合、结果怎么评」四件事拆开,每一件事落到一个独立模块,模块之间只靠state_dict和样本数这两个东西通信。这样后面想换聚合策略,只动server/一个文件;想换模型,只动models/;想换切分方式,只动data/。

2.1 目录结构:每个文件对应一个可替换的决策点

先把目录摆出来,后面所有代码都落在这张表里。注意「改动频率」这一列,它决定了你二次开发时该先读哪个文件。

路径职责改动频率
config/base.yaml轮数、客户端数、alpha、学习率等实验变量高
data/partition.py按标签做非独立同分布切分,产出客户端索引中
data/loader.py把索引包成 Dataset/DataLoader低
client/client.py本地多轮训练,返回权重与样本数中
server/server.py客户端采样、FedAvg 聚合、全局评估低
models/mlp.py模型定义,可换 CNN 或更深的网络高
utils/seed.py统一随机种子,保证实验可复现低
main.py组装上面所有模块,串起通信主循环低

这个划分有个隐含约定:client/和server/都不允许 import 对方。客户端只知道「给我一份全局权重,我还你一份本地权重」;服务端只知道「给我一堆权重和样本数,我还你一份新权重」。真实联邦部署里这两端本来就跑在不同机器上,本地模拟时用目录边界把这条线画出来,将来把client_update搬到一个 RPC 服务后面,几乎不用改逻辑。

models/mlp.py保持极简,两层全连接加一个输出层就够跑通链路。模型越简单,出问题时越容易判断是算法错了还是模型太弱——这是我在调这类实验时的一条硬习惯。

# models/mlp.py import torch.nn as nn class MLP(nn.Module): def __init__(self, in_dim=784, hidden=256, num_classes=10): super().__init__() self.net = nn.Sequential( nn.Flatten(), nn.Linear(in_dim, hidden), nn.ReLU(inplace=True), nn.Linear(hidden, num_classes), ) def forward(self, x): return self.net(x)

in_dim=784对应 28×28 的灰度图展平,num_classes=10是分类数。这两个参数不要写死在训练循环里,从配置读,换数据集时才不用全局搜索替换。Flatten放在模型内部而不是在 Dataset 里做,好处是 Dataset 那边可以原样返回[C, H, W]张量,数据增强按图像格式处理,不会提前把空间结构压平。

2.2 技术选型:为什么用 PyTorch 手写循环而不是套框架

联邦学习有现成的框架,功能也全,但用来做算法验证会有两个不舒服的地方。一是客户端采样、聚合、评估全被封装进一个Trainer,你想在聚合前插一个梯度裁剪,得先读半小时源码搞清 hook 在哪;二是很多框架默认按「联邦」场景优化,通信、加密、断点续传的代码占了大半,真正跑起来日志被这些信息淹掉。

PyTorch 手写循环的优势在state_dict:它就是一个dict[str, Tensor],天然可以遍历、可以加权平均、可以存成文件、可以 diff。FedAvg 的核心操作在这里变成五行循环,没有任何黑匣子。NumPy 只承担切分数据这一件事,np.random.default_rng的狄利克雷采样比手写比例分配稳定得多。

依赖清单也短,requirements.txt里通常就四行:

torch>=2.0 numpy>=1.24 pyyaml>=6.0 tqdm>=4.65

不引入 pandas、不引入 sklearn,不是因为它们不好,而是这类实验里少一个依赖就少一个版本冲突的翻车点。设备统一走配置里的device,默认cpu。二十个客户端、几万条样本、五十轮通信,CPU 上大概十几分钟,改算法够用了,真要跑大数据集再切 CUDA。

2.3 配置与入口:一份 yaml 管住所有实验变量

实验做到后面一定会遇到「上次那个 alpha=0.1 的结果是哪份配置跑出来的」。解决办法很土但有效:所有会影响的变量都进 yaml,命令行只做覆盖,不新增参数。

# config/base.yaml experiment: name: fedavg_alpha05 seed: 42 device: cpu data: root: ./data_cache num_classes: 10 alpha: 0.5 # 狄利克雷浓度,越小客户端之间越不均衡 min_client_size: 20 # 小于这个数量的客户端直接判为切分异常 federate: num_clients: 20 frac: 0.2 # 每轮参与的客户端比例,1.0 表示全参与 rounds: 50 train: local_epochs: 1 batch_size: 32 lr: 0.01 momentum: 0.9

frac这个参数很多人会忽略。全参与(frac=1.0)在本地模拟里跑得动,但真实联邦每轮只有一小部分设备在线,frac=0.2更接近实际,也能让你观察「部分参与导致的聚合抖动」。local_epochs也一样,设成 1 是最标准的 FedAvg,设成 5 就变成 FedSGD 的反面——本地过拟合会明显拖慢全局收敛,这个对比值得自己跑一次看看。

入口文件负责把 yaml 读进来并允许命令行覆盖,逻辑不超过二十行:

# main.py import argparse import yaml from pathlib import Path def load_config(path="config/base.yaml"): cfg = yaml.safe_load(Path(path).read_text(encoding="utf-8")) parser = argparse.ArgumentParser() # 只暴露最常扫的几个参数,其余一律改 yaml parser.add_argument("--rounds", type=int) parser.add_argument("--alpha", type=float) parser.add_argument("--frac", type=float) parser.add_argument("--device", type=str) args = parser.parse_args() if args.rounds is not None: cfg["federate"]["rounds"] = args.rounds if args.alpha is not None: cfg["data"]["alpha"] = args.alpha if args.frac is not None: cfg["federate"]["frac"] = args.frac if args.device is not None: cfg["experiment"]["device"] = args.device return cfg if __name__ == "__main__": cfg = load_config() print(cfg["experiment"]["name"], cfg["federate"])

覆盖逻辑刻意只放四个参数。参数一多,命令行就会变成第二份配置文件,两份配置打架的时候排查成本极高。experiment.name建议和 yaml 文件名、输出目录保持一致,后面存日志、存权重都用它拼路径,翻历史结果时一眼能对上。

3. 数据切分实战:把一份数据集拆成多个 Non-IID 客户端

联邦学习的效果好坏,一大半取决于数据怎么切。如果每个客户端手里的数据都是均匀的十类各占十分之一,那 FedAvg 收敛曲线会漂亮得像集中式训练,你也就看不出聚合算法到底有没有价值。真实场景里每个人手机上的数据都是偏的——有人只拍猫,有人只拍风景。所以切分这一步必须制造出标签倾斜,把「非独立同分布」这个前提坐实。

3.1 狄利克雷切分:用 alpha 控制倾斜程度

狄利克雷分布适合干这件事:给每个类别采一组和为 1 的比例向量,按比例把该类样本分给各个客户端。alpha越小,比例向量越极端,客户端越「纯」;alpha趋近无穷大时退化成均匀分配。工程上常用0.1(极度倾斜)、0.5(中等)、1.0(较均匀)三档做对比。

# data/partition.py import numpy as np from collections import defaultdict def dirichlet_partition(targets, num_clients, alpha=0.5, seed=42): """按标签做狄利克雷切分。 targets: 长度等于样本总数的标签数组 alpha: 浓度参数,越小客户端之间分布差异越大 返回: list[list[int]],每个元素是一个客户端的样本下标 """ rng = np.random.default_rng(seed) targets = np.asarray(targets) num_classes = int(targets.max()) + 1 # 先按类别把下标归堆,切分以类为单位进行 idx_by_class = defaultdict(list) for idx, y in enumerate(targets): idx_by_class[int(y)].append(idx) client_indices = [[] for _ in range(num_clients)] for c in range(num_classes): idx_c = np.array(idx_by_class[c]) rng.shuffle(idx_c) # 采一组和为 1 的比例,长度等于客户端数 props = rng.dirichlet(np.repeat(alpha, num_clients)) # 前缀和去掉最后一项当作切分点,避免取整后丢样本 cut_points = (np.cumsum(props)[:-1] * len(idx_c)).astype(int) for cid, chunk in enumerate(np.split(idx_c, cut_points)): client_indices[cid].extend(chunk.tolist()) return client_indices

代码里有两个细节值得说。一是cut_points用前缀和去掉最后一项,np.split会自动把剩余部分给最后一段,这样即使比例乘出来有小数被截断,样本也不会漏掉——手写累加边界时经常在这里少几个样本,跑起来不报错但总量对不上,属于典型的静默 bug。二是每个类别都单独采一次比例,所以同一个客户端在不同类别上分到的量是独立的,最终每个客户端的类别组成会非常杂,这才像真实数据。

seed参数必须传进来而不是函数内部写死,否则你没法用不同种子重复实验、也没法做「同一份切分下比较两种聚合算法」的公平对比。这一点在做消融实验时是血泪经验:切分一变,两个算法的差距可能直接被数据分布的随机波动盖掉。

3.2 客户端视图:Dataset 只暴露本地下标

切分产出的是一堆下标,真正喂给模型前要包一层 Dataset。这层包装的作用是模拟「数据不出本地」——客户端在逻辑上只能看到自己那份下标对应的样本,物理上虽然共享同一个底层数据集对象,但训练循环里拿不到别的客户端的任何东西。

# data/loader.py import torch from torch.utils.data import Dataset, DataLoader class ClientDataset(Dataset): """按给定下标裁剪原数据集,模拟本地数据视图""" def __init__(self, base_dataset, indices, transform=None): self.base = base_dataset self.indices = indices self.transform = transform def __len__(self): return len(self.indices) def __getitem__(self, i): x, y = self.base[self.indices[i]] if self.transform is not None: x = self.transform(x) return x, y def build_loader(base_dataset, indices, batch_size=32, shuffle=True, seed=0): ds = ClientDataset(base_dataset, indices) g = torch.Generator() g.manual_seed(seed) # num_workers=0:本地模拟优先保证确定性,别让多进程打乱顺序 return DataLoader(ds, batch_size=batch_size, shuffle=shuffle, generator=g, num_workers=0, drop_last=False)

num_workers=0在这里是刻意的选择。多进程加载确实快,但每个 worker 有自己的随机状态,generator只控制主进程的采样顺序,两次跑出来的批次顺序可能不一样,排查「为什么结果不可复现」时会多花很多时间。等算法稳定了再开多进程提速,顺序别搞反。

transform只在客户端内部生效,这一点很重要。如果对所有客户端用同一套随机增强(比如随机裁剪),那增强本身引入的随机性会叠加到客户端采样上。稳妥做法是给每个客户端传一个用自己cid做种子的 transform,让随机性跟着客户端走。

3.3 切分验证:三个统计量判断切得对不对

切完不验证,等于赌运气。最省事的验证方式是打印每个客户端的样本数、覆盖类别数、最大类别占比,这三个数能覆盖绝大多数切分错误。

# data/partition.py 追加 def describe_partition(client_indices, targets): targets = np.asarray(targets) rows = [] for cid, idx in enumerate(client_indices): if len(idx) == 0: rows.append({"client": cid, "n": 0, "classes": 0, "top_ratio": 0.0}) continue labels, counts = np.unique(targets[idx], return_counts=True) rows.append({ "client": cid, "n": len(idx), "classes": len(labels), "top_ratio": round(counts.max() / len(idx), 3), }) return rows

三个指标的判读标准:n的最大最小值之比最好控制在 5 倍以内,差距太大说明某些客户端几乎没数据,聚合时权重会被稀释到接近 0;classes在alpha=0.1下应该是 2 到 4 之间,如果每个客户端都是 10 类全覆盖,说明 alpha 太大,倾斜没造出来;top_ratio超过 0.8 就属于极度倾斜,这时候全局模型在少数类别上会明显掉点,是正常现象而非 bug。

min_client_size这个配置项就是在这里用的:扫一遍n,发现有客户端低于阈值就直接报错退出,别让它带着空 DataLoader 跑完五十轮再发现异常。空 loader 在 PyTorch 里不会抛异常,只会让这一轮训练白跑,聚合时除零还可能出nan,属于最难查的一类问题。

4. 训练与聚合:一轮通信的完整链路

到这里数据和模型都就位了,接下来是最核心的部分:客户端拿全局权重训几轮本地数据,把权重交回去,服务端按样本数加权平均。这条链路看着简单,但每一环都有容易写错的地方,尤其是权重拷贝和类型处理。

4.1 客户端本地训练:从 state_dict 到 state_dict

客户端函数的契约要定死:输入全局权重字典,输出本地训完的权重字典加样本数。中间不返回 loss 曲线、不返回梯度,保持接口干净。

# client/client.py import copy import torch import torch.nn as nn def client_update(global_state, model, loader, device, local_epochs=1, lr=0.01, momentum=0.9): # 必须 deepcopy:load_state_dict 会持有张量引用, # 直接用全局字典训练会把服务端那份一起改掉 local_state = copy.deepcopy(global_state) model.load_state_dict(local_state) model.to(device) model.train() opt = torch.optim.SGD(model.parameters(), lr=lr, momentum=momentum) loss_fn = nn.CrossEntropyLoss() total_loss, total_n = 0.0, 0 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 = loss_fn(model(x), y) loss.backward() opt.step() total_loss += loss.item() * y.size(0) total_n += y.size(0) # 再拷一份,避免后面服务端聚合时和模型参数共享内存 return copy.deepcopy(model.state_dict()), total_n, total_loss / max(total_n, 1)

copy.deepcopy(global_state)这一行是整个项目里最容易被删掉又最容易出事的地方。load_state_dict默认assign=False,它做的是param.data.copy_(input_param),也就是说模型参数和传入的字典在数值上同步,但不共享内存——看起来安全。问题出在你如果为了省事写成model.load_state_dict(global_state)之后又直接返回model.state_dict(),返回的字典里的张量就是模型参数的引用,下一轮训练一更新参数,服务端刚存下来的那份「本轮结果」也跟着变了。现象是第一轮正常、第二轮开始精度诡异跳动,查起来非常费劲。

total_loss * y.size(0)是加权累计,最后除以样本总数得到按样本加权的平均 loss。直接对 batch loss 求算术平均会让最后一个小 batch 权重过高,日志上的 loss 曲线会抖得不正常。

4.2 服务端聚合:FedAvg 的加权平均与类型陷阱

聚合函数的输入是一组权重字典和对应的样本数,输出一份新的全局权重。公式就是加权平均,但实现时要处理两件事:整型 buffer 和浮点精度。

# server/server.py import copy import torch def fedavg(state_dicts, weights): """按样本数加权平均,weights 通常是各客户端的样本数""" total = float(sum(weights)) avg = {} for k in state_dicts[0].keys(): ref = state_dicts[0][k] if ref.dtype.is_floating_point: # 用 float32 累加,避免 float16 累加溢出或精度丢失 acc = torch.zeros_like(ref, dtype=torch.float32) for sd, w in zip(state_dicts, weights): acc += sd[k].float() * (w / total) avg[k] = acc.to(ref.dtype) else: # num_batches_tracked 这类整型 buffer 不做平均,取第一个客户端的值 avg[k] = ref.clone() return avg

ref.dtype.is_floating_point这个判断不能省。模型里如果有 BatchNorm,state_dict会带一个num_batches_tracked的 int64 张量,对它做乘法会直接抛类型错误;就算你用.float()强转算出来了,再转回 int64 也只是把某个客户端的计数截断,语义上毫无意义。整型 buffer 的正确处理方式是原样保留,或者按参与客户端取最大值。

acc.to(ref.dtype)这一步在混合精度训练时会派上用场。如果各客户端用 fp16 训练,直接加权累加会掉精度,先用 fp32 累加再转回去是常规做法。本地模拟一般全 fp32,写在这里是为了将来切半精度时不用回头改。

聚合函数不修改入参,ref.clone()保证了这一点。返回的字典和所有客户端权重完全独立,这样服务端保存检查点时不会莫名其妙跟着变。

4.3 主循环:一轮通信到底发生了什么

主循环负责把上面这些拼起来,同时记录每轮的关键指标。这里的日志设计直接决定了你后面排查问题的效率,指标太少看不出问题,太多又刷屏。

# main.py 续 def run(cfg, server, client_loaders, client_models, test_loader, device): history = [] for rnd in range(1, cfg["federate"]["rounds"] + 1): # 1. 按比例采样本轮参与的客户端 selected = server.sample_clients(cfg["federate"]["frac"], rnd) states, sizes, losses = [], [], [] for cid in selected: sd, n, loss = client_update( server.global_state, client_models[cid], client_loaders[cid], device, local_epochs=cfg["train"]["local_epochs"], lr=cfg["train"]["lr"], momentum=cfg["train"]["momentum"], ) states.append(sd) sizes.append(n) losses.append(loss) # 2. 服务端聚合,得到新的全局权重 server.global_state = fedavg(states, sizes) # 3. 全局评估:用一份所有客户端都没见过的测试集 acc, test_loss = server.evaluate(test_loader) # 4. 通信量统计:上行 + 下行,单位 MB param_bytes = sum(v.numel() * v.element_size() for v in server.global_state.values()) comm_mb = param_bytes * len(selected) * 2 / 1024 / 1024 history.append({"round": rnd, "acc": acc, "loss": test_loss, "comm_mb": comm_mb}) print(f"[{rnd:03d}] clients={len(selected):2d} " f"local_loss={sum(losses)/len(losses):.4f} " f"test_acc={acc:.4f} comm={comm_mb:.2f}MB") return history

四个步骤对应一次完整的通信轮次:采样、本地训练、聚合、评估。comm_mb这一项很多人不做,但它是判断「这个改进到底值不值」的关键——把本地轮数从 1 提到 5,精度可能涨两个点,通信量不变但本地算力涨五倍;把模型从两层 MLP 换成 ResNet,精度涨十个点,通信量涨几十倍。没有这个数字,优化方向全靠感觉。

server.sample_clients的实现要按轮次做种子,保证同一轮采样结果可复现:

# server/server.py 续 import numpy as np class Server: def __init__(self, global_state, num_clients, seed=42): self.global_state = global_state self.num_clients = num_clients self.seed = seed def sample_clients(self, frac, rnd): # 每轮用 rounds 拼种子,跨轮不相关但整体可复现 rng = np.random.default_rng(self.seed + rnd) k = max(1, int(round(self.num_clients * frac))) return sorted(rng.choice(self.num_clients, size=k, replace=False).tolist())

sorted不是必须的,但它让日志里客户端顺序稳定,两次跑同一配置时打印出来能逐行对比。max(1, ...)防止frac设得过小时采样出零个客户端——这种情况聚合会直接崩在state_dicts[0]上。

5. 避坑与排查:本地模拟最容易翻车的五件事

这条链路我自己重写过好几遍,下面五个问题每一次都真实出现过,而且现象都和根因差得比较远。按「现象 → 原因 → 解决」记下来,下次再遇到能省半天。

5.1 第二轮开始精度诡异跳动

现象:第一轮准确率正常涨,第二轮开始忽高忽低,把frac改成 1.0 后现象消失。

原因:客户端返回的state_dict和服务端持有的全局权重共享了张量内存。全参与时每轮所有客户端都会重新覆盖一遍,问题被掩盖;部分参与时,没被选中的客户端那份引用还指着旧数据,聚合时混进了被污染的值。

解决:client_update返回前copy.deepcopy(model.state_dict()),服务端聚合里对整型 buffer 用ref.clone()。加一行断言辅助定位:聚合前比较id(states[0][k])和id(server.global_state[k]),相同就是没拷干净。

5.2 第一轮准确率就有 0.9 以上

现象:刚跑一轮,测试集准确率直接飙到 0.9,后面涨得很慢,曲线异常平滑。

原因:测试集数据混进了客户端切分。常见写法是先random_split出训练集和测试集,再对训练集切分,但如果切分函数作用在了完整数据集上,测试样本就漏进了客户端。另一种情况是归一化用了全量数据的均值和方差,测试集信息通过统计量泄漏进来了。

解决:切分只在训练集索引上做,测试集单独封成一个DataLoader,任何客户端代码不持有它的引用。归一化统计量要么用训练集单独算,要么直接用数据集给定的固定值。

5.3 加上 BatchNorm 之后掉点严重

现象:MLP 换成带 BN 的 CNN,集中式训练正常,联邦训练准确率明显低于预期。

原因:BN 的running_mean/running_var是靠本地数据统计出来的,各客户端数据分布差异大时,这些 buffer 被简单平均后语义就乱了。而且num_batches_tracked是整型,参与平均会直接报错。

解决:小规模实验直接用GroupNorm或LayerNorm替代,避开跨客户端的统计量聚合问题。必须用 BN 时,要么只聚合可学习参数、buffer 由服务端在一份代理数据上重新统计,要么按样本数加权 buffer(效果有限,得实测)。

5.4 同一份配置跑两次结果不一样

现象:yaml 一个字没改,两次运行的最终准确率差一两个点。

原因:种子只设了torch.manual_seed,漏了np.random;或者 DataLoader 开了多进程,worker 的随机状态不受主进程控制;还有dropout、数据增强这些模块各自持有随机源。

解决:写一个set_seed统一入口,把random、numpy、torch、torch.cuda全部设一遍,并且关掉cudnn.benchmark。本地模拟阶段num_workers保持 0。真要开多进程,用worker_init_fn给每个 worker 单独设种子。

5.5 客户端越多跑得越慢甚至 OOM

现象:客户端数从 20 提到 100,内存线性上涨,跑到三四十轮直接崩。

原因:为了省事给每个客户端都建了一个模型实例,每个实例还都.to(device),参数量按客户端数翻倍。本地模拟里这些模型其实是分时复用的,不需要同时驻留。

解决:只保留一份模型实例,client_update开头load_state_dict覆盖参数即可,不要为每个客户端 new 一个。真需要保留各客户端状态(比如做个性化联邦),也只存state_dict不存整个nn.Module,并且注意及时del掉不用的中间变量。

6. 二次开发:把它改成你自己的算法试验台

骨架跑通之后,真正有价值的部分才开始。这个项目最值得改的三个地方,按投入产出比排序是:聚合策略、客户端选择、攻击模拟。

聚合策略的扩展点设计得很轻,fedavg就是一个纯函数,想换成 FedProx 只需要在客户端 loss 里加一个近端项:

# client/client.py 里替换 loss 计算,mu 从配置读 def proximal_loss(model, global_state, x, y, mu=0.01): base = nn.CrossEntropyLoss()(model(x), y) prox = 0.0 for name, p in model.named_parameters(): prox += ((p - global_state[name].to(p.device)) ** 2).sum() return base + mu / 2 * prox

mu控制本地模型偏离全局权重的惩罚强度,设 0 就退化成标准 FedAvg,设大了本地几乎训不动。这个参数和local_epochs强相关:本地轮数越多,越需要靠mu把模型拉回来。我一般固定local_epochs=5再扫mu ∈ {0, 0.001, 0.01, 0.1},能明显看到客户端漂移被抑制。

客户端选择策略换起来更简单,sample_clients换个采样逻辑就行。常见的对比是随机采样和按样本数加权采样,后者会让大客户端被选中更频繁,收敛更快但公平性差。做这类对比时记得固定seed,否则轮次间的随机波动会盖过策略差异。

改造方向改动文件关键参数观察指标
FedProxclient/client.pymu客户端权重与全局权重的 L2 距离
按样本加权采样server/server.py采样概率表各客户端被选次数分布
梯度裁剪client/client.pyclip_norm聚合前的梯度范数
恶意客户端模拟client/client.py标签翻转比例全局准确率下降幅度

验证改造有没有效果,别只看最终准确率。加一条「每轮全局权重与上一轮的余弦相似度」曲线,能直观看出聚合是在稳定收敛还是在来回震荡;再加一条「各客户端本地 loss 的方差」,方差大说明客户端漂移严重,该上 FedProx 了。这两个指标比准确率敏感得多,改一个小参数就能看出差别。

还有一个容易被忽略的技巧:把切分结果缓存成.npz。同一份alpha和seed下反复调聚合算法时,每次都重跑切分不但慢,还可能因为底层数据集加载顺序变化导致索引错位。存下来之后,实验之间的差异就只剩算法本身,对比才有意义。

我最早做这类实验时,图省事没缓存切分也没固定种子,两个算法各跑一遍,看到准确率差 0.5 就兴奋地下了结论,后来发现同一配置重复跑三次的波动就有 0.8。从那以后每次开新实验,我都强制先把切分固化、种子钉死、跑一遍 baseline 拿到波动区间,再谈改进有没有效。希望帮到你。

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