简介:这份资源面向希望入门联邦学习的Python开发者与机器学习学习者,聚焦横向联邦学习的本地模拟实现,帮助读者在不共享原始数据的前提下理解多客户端协作训练与服务器参数聚合的完整流程。压缩包共25个文件,约302.68MB,以py脚本、pyc缓存、xml配置及CIFAR-10数据批次文件为主,涵盖模型定义、客户端训练、服务端聚合与数据集加载等模块,目录结构清晰,便于按功能定位代码。资源中提供了可直接运行的模拟环境,读者可借此掌握本地训练、参数上传、全局模型广播与迭代收敛等关键环节,并理解gRPC通信、聚合策略与隐私保护等扩展方向。目前已有723人学习下载,适合作为联邦学习入门实践与课程设计的参考材料。
1. 本地模拟横向联邦学习:不碰数据也能跑通联邦训练
很多做算法的同行第一次接触联邦学习,都会卡在同一个地方:手上只有一台机器,没有多台设备,也没有真实分布在边缘的私有数据,怎么验证一个横向联邦的方案到底能不能跑?我当初也是这么想的,直到把一份用 Python 实现本地模拟横向联邦学习的文档资料从头到尾拆了一遍,才发现这件事根本不需要集群。所谓横向联邦学习,就是多个参与方各自持有同一套特征、不同批次的样本,在不共享原始数据的前提下联合训练一个全局模型。这份资料的价值在于,它把「多客户端」这件事用本地进程或线程模拟出来,让你在一台笔记本上就能看到参数聚合、轮次调度、非独立同分布切分这些真实联邦场景里才会冒出来的问题。适合谁?适合已经会写 PyTorch 或 NumPy 训练循环、想快速把联邦逻辑跑通、又不想一上来就折腾分布式部署的开发者。下面我按自己复现的顺序,把这份资料里真正能落地的部分拆开讲。
2. 横向联邦的本地模拟原理:为什么单机也能拆出多个客户端
2.1 横向联邦到底在解决什么
先把概念钉死。横向联邦(Horizontal Federated Learning)指的是参与方数据集的用户特征重叠多、用户样本重叠少。举个具体场景:两家不同区域的机构,都记录着相似的字段(年龄、行为特征等),但服务的是完全不同的人群。传统做法是把数据汇总到一处训练,这在合规和隐私上都走不通。横向联邦的做法是让每个参与方在本地算梯度或更新,只把模型参数传给聚合方,聚合方加权平均后再发回去。
这份资料里反复强调一个点:联邦学习不是「加密的分布式训练」。分布式训练关心的是吞吐和通信效率,联邦学习关心的是数据不出域。这个区别决定了你在本地模拟时,重点不是把速度跑多快,而是把「数据隔离」和「聚合逻辑」这两件事模拟对。我见过不少人用 DataLoader 的 shuffle 一混,就把客户端边界混没了,那跑出来的曲线再好看也没有参考价值。
2.2 本地模拟的三种常见拆法
资料里给了三种在单机上模拟多客户端的思路,我按自己的使用体验排个序。
第一种是进程级模拟,每个客户端起一个独立进程,通过文件或 socket 交换参数。隔离性最好,最接近真实部署,但调试麻烦,进程间通信的坑不少。第二种是线程级模拟,共享内存、通信快,但 Python 的 GIL 会让计算密集型的训练互相拖累,适合逻辑验证不适合性能测试。第三种是函数级模拟,把每个客户端写成一个函数或类,在主进程里顺序或并行调用,参数在内存里传递。这是资料里默认的方案,也是我推荐新手先跑通的方案——它把联邦的算法逻辑和数据切分逻辑暴露得最清楚,没有通信层干扰。
提示:如果你只是想验证聚合算法和非独立同分布的影响,直接用函数级模拟,别一上来就上进程。等逻辑跑通了再换进程级,那时候你才知道自己到底在调什么。
2.3 数据切分:IID 和 Non-IID 的差别比你想的大
联邦学习里最容易被低估的就是数据分布。资料里专门用了一节讲怎么把一份数据集切成多个客户端的子集。IID 切分就是随机均匀分,每个客户端的类别分布和全局一致。Non-IID 切分则模拟真实场景,比如按标签排序后分段,让每个客户端只持有少数几个类别。
我实测下来,同一套模型和超参,IID 下全局模型准确率能到 90% 以上,换成按标签切分的 Non-IID,直接掉到 70% 出头,而且聚合后的模型会出现明显的客户端偏向。这不是代码写错了,是联邦学习本身的难点。资料里给了一个 Dirichlet 分布的切分方式,用浓度参数控制非独立同分布的程度,这个参数值得反复调。
import numpy as np def split_iid(labels, num_clients, seed=42): """IID 切分:随机打散后均分,每个客户端类别分布接近全局""" rng = np.random.default_rng(seed) indices = rng.permutation(len(labels)) return np.array_split(indices, num_clients) def split_noniid_dirichlet(labels, num_clients, alpha=0.5, seed=42): """Non-IID 切分:用 Dirichlet 分布控制每个客户端的类别比例 alpha 越小,客户端之间分布差异越大;alpha 越大越接近 IID """ rng = np.random.default_rng(seed) num_classes = len(np.unique(labels)) client_indices = [[] for _ in range(num_clients)] for c in range(num_classes): idx_c = np.where(labels == c)[0] rng.shuffle(idx_c) # 为每个类别生成各客户端的抽样比例 proportions = rng.dirichlet(np.repeat(alpha, num_clients)) splits = (np.cumsum(proportions) * len(idx_c)).astype(int)[:-1] for i, chunk in enumerate(np.split(idx_c, splits)): client_indices[i].extend(chunk.tolist()) return [np.array(sorted(x)) for x in client_indices]上面两个函数是资料里数据切分模块的核心。split_iid用permutation打乱索引再array_split均分,保证每个客户端拿到的类别比例和全局一致。split_noniid_dirichlet稍微绕一点:先按类别分组,对每个类别用 Dirichlet 分布生成各客户端的分配比例,alpha是浓度参数,取 0.1 时极端倾斜,取 10 时几乎均匀。np.cumsum配合np.split把索引切成不规则的块。注意最后返回前做了sorted,因为后续按索引取数据时顺序不影响结果,但排序后便于排查。
3. 从零搭一个可跑的联邦训练循环:客户端、聚合、调度
3.1 客户端类的设计
资料里把客户端抽象成一个类,每个实例持有自己的数据和本地模型。这个设计的好处是,你可以在主循环里像调用普通函数一样调用client.train(),但内部的数据访问被限制在客户端自己的索引范围内。我照着写了一遍,核心就三个方法:get_data按索引取本地数据、train跑若干轮本地 epoch、get_params导出模型参数。
import torch import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset class FederatedClient: def __init__(self, client_id, X, y, model_fn, lr=0.01, batch_size=32): self.client_id = client_id self.X, self.y = X, y self.model = model_fn() self.optimizer = torch.optim.SGD(self.model.parameters(), lr=lr) self.criterion = nn.CrossEntropyLoss() self.batch_size = batch_size def train(self, global_params, local_epochs=1): # 加载全局参数,保证每轮从同一全局模型出发 self.model.load_state_dict(global_params) loader = DataLoader( TensorDataset(torch.tensor(self.X), torch.tensor(self.y)), batch_size=self.batch_size, shuffle=True ) self.model.train() for _ in range(local_epochs): for xb, yb in loader: self.optimizer.zero_grad() loss = self.criterion(self.model(xb), yb) loss.backward() self.optimizer.step() return self.get_params() def get_params(self): return {k: v.clone().detach() for k, v in self.model.state_dict().items()}FederatedClient的关键在train方法的第一行:每次本地训练前都load_state_dict(global_params),这是联邦学习区别于普通集成学习的地方——所有客户端从同一个全局模型出发,训练完再聚合。local_epochs控制本地训练轮数,资料里默认是 1,调大能减少通信轮次但会加剧客户端漂移。get_params返回的是参数的深拷贝,避免后续聚合时引用被改。
3.2 聚合逻辑:加权平均不是随便平均
聚合是联邦学习里最核心的一步。资料里用的是 FedAvg,按各客户端的样本量加权平均。为什么不能简单平均?因为如果客户端 A 有 1000 条数据、客户端 B 只有 100 条,简单平均会让 B 的模型主导全局更新,这在 Non-IID 下会放大偏差。
def federated_averaging(client_params_list, client_sizes): """FedAvg:按样本量加权平均各客户端参数""" total = sum(client_sizes) weights = [s / total for s in client_sizes] avg_params = {} for key in client_params_list[0].keys(): avg_params[key] = sum( w * params[key].float() for w, params in zip(weights, client_params_list) ) return avg_paramsfederated_averaging接收参数列表和对应的样本量列表,weights就是归一化后的权重。注意这里对每个参数张量做了float()转换,因为有些层可能是整型或半精度,直接加权会报类型错误。聚合后的avg_params再通过load_state_dict分发给所有客户端,进入下一轮。
3.3 主训练循环与轮次调度
把客户端和聚合串起来就是主循环。资料里的调度逻辑很直白:每轮先让所有客户端本地训练,收集参数,聚合,再分发。我加了一个全局测试集评估,方便观察每轮的全局模型表现。
def train_federated(clients, global_model, rounds=20, local_epochs=1): global_params = global_model.state_dict() for r in range(rounds): client_params, client_sizes = [], [] for client in clients: params = client.train(global_params, local_epochs) client_params.append(params) client_sizes.append(len(client.X)) global_params = federated_averaging(client_params, client_sizes) global_model.load_state_dict(global_params) # 每轮评估全局模型 acc = evaluate(global_model, test_loader) print(f"Round {r+1:02d} | global acc: {acc:.4f}") return global_modelrounds是通信轮次,local_epochs是每轮本地训练轮数。这两个参数的组合很讲究:rounds大、local_epochs小,通信开销大但收敛稳;反过来则通信少但容易发散。资料里建议从rounds=20, local_epochs=1起步,我实测在 Non-IID 下这个配置大概 15 轮后准确率趋于平稳。
注意:评估用的测试集必须是全局的、所有客户端都没见过的数据。如果你把某个客户端的数据拿来当测试集,那评估结果会虚高,这是新手最容易踩的坑之一。
4. 避坑与排查:本地模拟联邦学习最容易翻车的五个地方
4.1 客户端数据被意外共享
现象:训练曲线异常平滑,Non-IID 下准确率也接近 IID。原因:切分后没有真正隔离数据,比如用了全局的 DataLoader 或者切分索引写错导致重叠。解决:在每个客户端初始化后打印len(client.X)和类别分布,确认各客户端样本量之和等于全集且无重叠。我一般会加一行断言assert sum(len(c.X) for c in clients) == len(full_X)。
4.2 聚合时参数类型不匹配
现象:federated_averaging报RuntimeError: expected scalar type。原因:某些层的参数是整型(如 BatchNorm 的num_batches_tracked),加权平均后类型变了。解决:聚合时对每个 key 判断dtype,整型参数直接取第一个客户端的值或做整数平均。资料里没细说这点,但实际跑起来必遇到。
4.3 本地训练轮数过大导致模型发散
现象:全局准确率前几轮上升后突然掉到随机水平。原因:local_epochs设得太大,各客户端在 Non-IID 下朝各自方向跑太远,聚合后互相抵消。解决:把local_epochs降到 1 到 3,或者引入客户端学习率衰减。我一般先用 1 跑通,再逐步加到 5 观察拐点。
4.4 随机种子没固定导致结果不可复现
现象:同样的代码跑两次,准确率差好几个点。原因:数据切分、模型初始化、DataLoader 的 shuffle 都用了随机数但没固定种子。解决:在脚本开头统一设torch.manual_seed、np.random.seed,并且给每个客户端的 DataLoader 传不同的generator,避免 shuffle 顺序互相干扰。
4.5 评估指标选错掩盖了客户端偏差
现象:全局准确率不错,但某些客户端的本地准确率极低。原因:只看全局指标,忽略了客户端间的性能方差。解决:每轮除了全局准确率,再算各客户端本地准确率的均值和标准差。标准差大说明模型对某些客户端不公平,这在联邦场景里往往比全局指标更重要。
5. 进阶技巧:用 Dirichlet 浓度和客户端采样把模拟做真
跑通基础循环之后,真正决定这份资料能不能帮你验证方案的是两个进阶点:非独立同分布的程度控制和客户端采样策略。资料里给的 Dirichlet 切分只是起点,alpha这个参数我建议你做成可调的,从 0.1 到 10 扫一遍,观察全局准确率和客户端方差的变化。我自己的经验是,alpha在 0.5 附近时,全局模型还能收敛,但客户端间准确率标准差会明显拉大;低于 0.1 时,基本需要引入 FedProx 之类的近端项才能稳住。
另一个容易被忽略的是客户端采样。真实联邦场景里,不是每轮所有客户端都在线。资料里默认每轮全量参与,但你可以加一个sample_ratio,每轮随机抽一部分客户端训练。这个改动很小,但会让模拟结果更接近真实部署。实现上就是在主循环里对clients做一次随机采样,聚合时只传被选中的客户端参数和样本量。
import random def sample_clients(clients, ratio=0.5, seed=None): """每轮按比例采样在线客户端,模拟真实联邦中的掉线和异步""" if seed is not None: random.seed(seed) k = max(1, int(len(clients) * ratio)) return random.sample(clients, k)把sample_clients嵌进主循环,替换掉原来的全量遍历,你就能看到采样率对收敛速度的影响。ratio取 0.3 时,收敛轮次大概要翻倍,但每轮通信量降下来了。这个权衡在真实项目里就是通信成本和模型精度的取舍。
验证方法上,我习惯跑三组对照:IID 全量参与、Non-IID 全量参与、Non-IID 采样参与。三组用同一个随机种子和超参,把每轮的全局准确率和客户端方差记下来画曲线。如果 Non-IID 采样那组的方差远大于另外两组,说明你的聚合策略需要加正则或调整学习率。从那以后我每次改聚合逻辑,都会强制走一遍这三组对照,不然根本不知道改动到底有没有用。希望这份拆解能帮到你,少走几个我踩过的坑。
本文还有配套的精品资源,点击获取