☰
本地模拟横向联邦学习:FedAvg与Non-IID数据切分实战
2026/10/29 2:25:45 网站建设 项目流程

简介:这份资源面向希望入门联邦学习的Python开发者与机器学习学习者,围绕横向联邦学习的本地模拟展开,解决在单机环境下理解多客户端协作训练与参数聚合流程的问题。包内共25个文件,以py源码为主,辅以pyc缓存、xml与iml工程配置、json配置及CIFAR-10数据集分片等,压缩包约302.68MB,可直接运行观察训练过程。内容涵盖模型定义、客户端本地训练、服务端聚合与通信接口等模块,配合数据集分片可模拟多个虚拟客户端各自持有私有数据、仅上传模型更新的完整链路,便于读者对照代码理解FedAvg聚合策略、迭代训练轮次与全局模型广播机制。已有726人学习下载,适合作为课程实验、课题预研或自学联邦学习原理的实践参考,帮助读者在保护数据隐私的前提下快速搭建可调试的模拟环境。

1. 本地跑通横向联邦学习:一个不依赖真实分布数据的模拟框架

第一次接触联邦学习的人,八成会被“跨设备、跨机构、数据不出本地”这些词唬住,以为非得有几台机器、几套环境才能动手。实际上,横向联邦学习最核心的机制——多个客户端各自在本地数据上训练、只上传模型参数、由中心节点聚合后再下发——完全可以在单机单进程里模拟出来。这份用 Python 写的本地模拟项目就是干这个的:它把客户端、服务端、聚合策略、数据切分全部塞进一个工程里,用模拟的方式复现横向联邦的完整训练回路。适合两类人:一是想搞懂 FedAvg 到底怎么一步步跑起来的学生和转行者,二是准备把联邦学习接进自己业务、需要先在小规模数据上验证聚合逻辑的开发者。下面按“能干什么、怎么跑、怎么改”的顺序拆。

2. 工程结构与技术选型:从目录到一次完整训练回路

2.1 目录分层与各文件职责

这类本地模拟项目通常不会做得很重,常见做法是围绕“数据、模型、客户端、服务端、入口”五个部分切分。下面是我拆这类工程时最常看到的目录形态,也是这份资源大概率采用的组织方式:

federated_sim/ ├── main.py # 训练入口,串起服务端与客户端 ├── config.py # 超参数与路径配置 ├── data/ │ ├── loader.py # 加载数据集并按客户端切分 │ └── partition.py # IID / Non-IID 切分策略 ├── models/ │ └── mlp.py # 模型定义(MLP / CNN 视数据集而定) ├── client/ │ └── client.py # 单个客户端的本地训练逻辑 ├── server/ │ ├── server.py # 聚合与轮次调度 │ └── strategy.py # FedAvg 等聚合策略 └── utils/ └── metrics.py # 准确率、损失记录

main.py是唯一需要手动执行的入口,它负责实例化服务端、按配置生成若干客户端、然后进入“下发—本地训练—上传—聚合”的循环。data/partition.py是横向联邦模拟的关键:真实场景里每个客户端的数据天然不同,模拟时就得人为把一份完整数据集切成若干份,切法直接决定实验结论是否可信。server/strategy.py把聚合算法单独抽出来,是为了后面换 FedProx、换加权方式时不用动调度代码。client/client.py只关心本地那几轮 SGD,不碰全局状态,职责边界清楚。

技术栈上,模型训练部分基本就是 PyTorch 或 NumPy 二选一。用 PyTorch 的好处是模型定义、优化器、梯度计算都是现成的,客户端本地训练几行就能写完;用纯 NumPy 则更轻,适合只想看聚合数学过程、不想装深度学习框架的场景。从“本地模拟、快速验证”的定位看,PyTorch 是更常见的选择,因为后面想换 CNN、换真实数据集时迁移成本低。数据侧一般用 torchvision 自带的公开数据集,避免额外下载依赖。

2.2 一次训练回路的调度逻辑

服务端的主循环是整个项目的骨架,理解它就理解了横向联邦的时序。下面这段是这类工程里服务端调度的典型写法,我按可读性做了整理:

# server/server.py class FedServer: def __init__(self, global_model, clients, strategy, rounds=10): self.global_model = global_model # 全局模型,持有最新参数 self.clients = clients # 客户端列表 self.strategy = strategy # 聚合策略对象 self.rounds = rounds # 通信轮次 def train(self): for r in range(self.rounds): # 1. 下发:把当前全局参数同步给每个客户端 global_state = self.global_model.state_dict() for c in self.clients: c.load_global(global_state) # 2. 本地训练:各客户端在自己的数据上跑若干 epoch local_results = [c.local_train() for c in self.clients] # 3. 聚合:按样本量加权平均,得到新的全局参数 new_state = self.strategy.aggregate(local_results) self.global_model.load_state_dict(new_state) # 4. 评估:用全局模型在验证集上打分 acc = self.evaluate() print(f"round {r+1}/{self.rounds} acc={acc:.4f}")

这段代码里三个参数值得盯住。rounds是通信轮次,横向联邦里通信成本远高于本地计算,轮次不是越多越好,通常 10 到 50 轮就能看出趋势。local_results里每个元素一般包含客户端训练后的参数字典和本地样本数,样本数是后面加权平均的权重来源。strategy.aggregate是唯一需要替换就能换算法的钩子,FedAvg 就是“按样本数加权平均”,换成 FedProx 则要在客户端损失里加近端项,聚合本身不变。评估放在每轮末尾,是为了观察全局模型是否真的在收敛,而不是被某个客户端带偏。

2.3 客户端本地训练与参数上传

客户端这一侧的逻辑比服务端简单,但有两个细节容易写错:一是本地训练必须从全局参数出发,不能沿用上一轮的本地参数;二是上传的应该是训练后的参数,而不是梯度。典型实现如下:

# client/client.py class FedClient: def __init__(self, client_id, dataloader, model, lr=0.01, local_epochs=1): self.id = client_id self.loader = dataloader # 该客户端独有的数据 self.model = model # 本地模型副本 self.lr = lr self.local_epochs = local_epochs def load_global(self, state_dict): # 每轮开始前用全局参数覆盖本地模型 self.model.load_state_dict(state_dict) def local_train(self): opt = torch.optim.SGD(self.model.parameters(), lr=self.lr) loss_fn = torch.nn.CrossEntropyLoss() self.model.train() for _ in range(self.local_epochs): for x, y in self.loader: opt.zero_grad() loss = loss_fn(self.model(x), y) loss.backward() opt.step() # 只回传参数和样本量,不回传原始数据 return { "id": self.id, "state_dict": {k: v.clone() for k, v in self.model.state_dict().items()}, "n_samples": len(self.loader.dataset), }

local_epochs控制每个客户端在本地把数据过几遍,设成 1 是最经典的 FedAvg 设定,设大了会加剧客户端漂移,Non-IID 场景下尤其明显。n_samples是聚合时的权重,样本多的客户端话语权更大,这是 FedAvg 加权平均的依据。v.clone()不能省,否则回传的是引用,后续训练会污染已上传的参数,这个坑我在早期实现里踩过,表现为聚合结果莫名其妙地抖动。整个客户端不接触其他客户端的数据,也不接触服务端的评估集,数据隔离在模拟层面是靠“各客户端持有独立 DataLoader”来保证的。

3. 数据切分与聚合策略:模拟是否可信全看这两块

3.1 IID 与 Non-IID 切分方式

横向联邦模拟最容易糊弄的地方就是数据切分。如果直接把数据集随机均分给每个客户端,那是 IID 场景,训练曲线会很漂亮,但和真实业务差距很大——现实中每个客户端的数据分布往往偏斜。工程里一般提供两种切法:

# data/partition.py def split_iid(dataset, num_clients): # 随机打乱后均分,各客户端分布近似一致 indices = np.random.permutation(len(dataset)) return np.array_split(indices, num_clients) def split_noniid(dataset, num_clients, classes_per_client=2): # 按标签排序后切片,每个客户端只拿少数几类 labels = np.array([y for _, y in dataset]) sorted_idx = np.argsort(labels) shards = np.array_split(sorted_idx, num_clients * classes_per_client) client_indices = [] for i in range(num_clients): # 每个客户端取若干 shard,模拟标签偏斜 idx = np.concatenate(shards[i * classes_per_client:(i + 1) * classes_per_client]) client_indices.append(idx) return client_indices

classes_per_client是 Non-IID 程度的旋钮,设成 2 表示每个客户端只见过两类样本,设成 10(假设总共 10 类)就退化成 IID。做实验时建议先跑 IID 确认回路通,再切 Non-IID 看准确率掉多少,这个差值本身就是横向联邦要解决的核心问题。切分函数返回的是索引列表,交给data/loader.py用Subset包一层就能生成各客户端的 DataLoader,原始数据集只加载一次,内存友好。

3.2 FedAvg 聚合的加权实现

聚合策略是全局模型质量的直接决定因素。FedAvg 的公式看着简单,实现时权重取错就会得到“平均了个寂寞”的结果:

# server/strategy.py class FedAvg: def aggregate(self, local_results): total = sum(r["n_samples"] for r in local_results) new_state = {} for key in local_results[0]["state_dict"]: # 按样本量加权求和 weighted = sum( r["state_dict"][key] * (r["n_samples"] / total) for r in local_results ) new_state[key] = weighted return new_state

total是所有客户端样本量之和,每个客户端的权重是n_samples / total,这样聚合后的参数等价于“把所有客户端数据拼起来做一次大 batch 训练”的近似。如果偷懒用等权平均,样本量差异大时小客户端会被过度放大,准确率曲线会出现明显毛刺。local_results[0]["state_dict"]的 key 顺序要和全局模型一致,PyTorch 的state_dict天然有序,一般不会出问题,但换模型结构后要重新确认。这套聚合只处理浮点参数,BatchNorm 的 running_mean 之类也一并平均,模拟场景下够用,真实部署时这些统计量的处理要单独考虑。

3.3 配置项与运行命令

把超参数集中到config.py是这类项目的惯例,改实验不用翻代码:

# config.py CONFIG = { "num_clients": 10, # 客户端数量 "rounds": 20, # 通信轮次 "local_epochs": 1, # 本地训练轮数 "batch_size": 32, "lr": 0.01, "noniid": True, # 是否启用 Non-IID 切分 "classes_per_client": 2, # 每个客户端的类别数 "dataset": "mnist", # 数据集名称 }

跑起来就一条命令,首次执行会自动下载数据集:

python main.py --config config.py

num_clients设成 10 是常见起点,客户端越多每轮通信开销越大,但模拟环境里这点开销可以忽略,主要影响的是 Non-IID 下的收敛难度。rounds和local_epochs是一对需要联调的参数:轮次少、本地轮数多,等价于让客户端多跑几步再同步,能省通信但会加剧漂移;反过来则同步频繁、收敛稳但慢。建议先用默认值跑通,再单独调这两个看曲线变化。

4. 避坑与排查:本地模拟最容易翻车的几个点

4.1 现象:准确率每轮剧烈抖动,甚至越训越差

原因通常是客户端上传的参数被后续训练污染了。如果local_train里直接return self.model.state_dict()而不做 clone,服务端聚合时拿到的是引用,下一轮客户端继续训练会改掉这些张量,聚合结果自然乱套。解决办法就是前面代码里的v.clone(),或者回传前先copy.deepcopy。这个坑隐蔽在于单客户端时看不出来,多客户端一跑就现形。

4.2 现象:Non-IID 下全局模型准确率卡在随机猜测附近

先检查切分函数是不是把同一批索引重复分给了多个客户端,导致某些客户端实际没数据。np.array_split在索引数不能被整除时会产生空数组,空 DataLoader 训练不出东西,聚合时它的n_samples为 0 还好,若代码里没做保护直接除零就会报错或产生 NaN。解决方式是在切分后过滤掉空客户端,或在聚合时跳过n_samples == 0的项。另一个常见原因是classes_per_client设得太小,客户端只见过一类样本,本地模型严重偏斜,这时要么增大该值,要么改用 FedProx 加近端项。

4.3 现象:训练能跑但显存/内存一路涨

多半是每轮都在新建模型或 DataLoader 而没有释放。客户端load_global只应该覆盖参数,不应该重新实例化模型;DataLoader 应该在初始化时建好复用。还有一种情况是评估时忘了torch.no_grad(),计算图越积越多。排查时可以在每轮末尾打印一下torch.cuda.memory_allocated()(用 GPU 时),看是否单调上升,是的话基本就是引用没释放。

4.4 现象:换数据集后维度对不上直接报错

模型输入维度和数据集不匹配是换数据集时的头号问题。MNIST 是 1×28×28,换成别的数据集输入通道和尺寸都可能变,models/mlp.py里的第一层Linear(784, ...)就得跟着改。建议把输入维度也放进config.py,模型定义时从配置读,而不是写死。另外标签类别数变化时,输出层维度和损失函数都要同步调整,这类错误报错信息通常很直白,照着改就行,但别忘了改完重新确认聚合时各客户端模型结构一致。

4.5 现象:结果无法复现,两次跑差异很大

随机种子没固定。数据切分用了np.random.permutation,模型初始化、DataLoader 的 shuffle 也都带随机性,不设种子每次结果都不同,调参时根本分不清是参数起作用还是运气。常见做法是在main.py开头统一设random.seed、np.random.seed、torch.manual_seed,DataLoader 的generator也传同一个种子。固定之后同一配置跑两遍结果应完全一致,这是做对比实验的前提。

5. 二次开发:把模拟框架改成你自己的实验台

拿到这份代码后,最值钱的改造方向不是加功能,而是让它能回答你自己的问题。我一般会先做三件事。第一,把聚合策略做成可插拔的注册表,strategy.py里加一个字典映射名字到类,config.py里用字符串选策略,这样加 FedProx、加自定义加权都不用改调度代码:

# server/strategy.py STRATEGIES = {"fedavg": FedAvg, "fedprox": FedProx} def build_strategy(name, **kwargs): return STRATEGIES[name](**kwargs)

第二,把评估从“只看全局准确率”扩成“每轮记录各客户端本地准确率 + 全局准确率”,存成 CSV。横向联邦里客户端漂移是核心矛盾,只看全局曲线会漏掉“全局还行但某些客户端已经崩了”的情况,分客户端记录才能看出偏斜程度。第三,把数据切分参数化到能批量跑实验的程度,比如写个脚本遍历classes_per_client从 2 到 10,每个值跑三次取均值,输出一张“Non-IID 程度 vs 最终准确率”的表。这类对比表比任何文字描述都有说服力,也是二次开发时验证改动是否有效的直接手段。

改造时有个验证习惯值得坚持:任何改动先在小规模配置上跑通再放大。num_clients=3、rounds=3、数据集只取前 1000 条,几十秒就能跑完一轮,确认聚合没报错、准确率不是 NaN,再切回完整配置。我早期图省事直接上全量,一个维度写错要等好几分钟才看到报错,来回几次半天就没了。从那以后我每次改聚合或切分逻辑,都强制先用三客户端三轮的小配置走一遍,确认回路干净再谈效果。希望这份拆解能帮你少走几步弯路,把这份本地模拟工程真正跑成自己的实验台。

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

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

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

立即咨询