简介:面向机器学习初学者与联邦学习研究者的横向联邦学习本地模拟资源,使用Python构建,以公开数据集搭建可复现的单机模拟环境,让没有分布式集群的读者也能体验多客户端协作建模的完整过程。资源从数据加载、模型定义、客户端本地训练到服务端参数聚合均有独立脚本,并含有配置文件与数据集分片,覆盖模型定义、通信接口、服务器聚合等联邦学习核心环节,可直观理解多客户端协同训练、参数上传与全局模型广播的完整闭环。包内共25个文件,以源码为核心,附编译后字节码、工程配置、数据分片等文件类型,压缩包约302MB,目录划分清晰,便于按模块对照学习。目前已有723人学习下载,适合作为横向联邦学习入门实践的参考;读者可在此基础上修改客户端数量、数据划分方式,观察不同设置对全局模型收敛的影响,也为后续研究安全聚合、通信优化等方向提供了可扩展的实验基底。
1. 用Python实现本地模拟横向联邦学习:为什么在单机上跑通框架比你想的更重要
横向联邦学习是多方协作训练一种常见设定:参与各方的数据特征维度一致,但样本各自私有,模型在“数据不出本地”的约束下被反复训练与聚合。很多开发者一开始就奔着分布式框架去,装集群、配通信,结果在环境上花了大量时间,迟迟没有验证核心算法。实际上,联邦学习的关键机制——数据切分、本地训练、参数聚合——完全可以在单个 Python 进程里建模。在一台普通开发机上实现本地模拟横向联邦学习,用几百行代码就能跑出一个行为可信的最小闭环,用来验证 FedAvg 的收敛性、测试 non-IID 数据的影响、甚至对比不同聚合策略。作为研究者,它是你快速验证假设的试验台;作为工程师,它是你理解横向联邦学习内部机制的低成本起点。我在多轮项目里靠这套模拟器排掉了大量逻辑错误,这篇文章把完整做法展开讲清楚。
2. 横向联邦学习的最小闭环:FedAvg 的数学直觉与代码骨架
2.1 FedAvg 到底在聚合什么:参数平均和梯度平均的差别
横向联邦学习里最常见的聚合算法是 FedAvg(联邦平均)。它做的事情一句话能说清:每个客户端用本地数据把模型训练若干轮(local epochs),把训练后的模型参数交给服务器,服务器按每个客户端样本数量的占比做加权平均,得到新一轮全局模型,再广播回去。整个横向联邦学习 round 就这么循环下去。
容易被忽略的一个区别是:FedAvg 平均的是模型参数,不是梯度。分布式训练常用的 AllReduce 每次同步平均梯度,FedAvg 则允许客户端本地跑完多个 epoch 才上传模型参数。这带来两个直接的好处:通信次数急剧下降,因为一次通信携带的是本地训练了 E 轮之后的完整模型状态,而不是每个小 step 的梯度;同时,样本更多的客户端在聚合时的权重更大,因为它对全局模型的贡献理应更显著。
# FedAvg 聚合伪代码 # 第 t+1 轮全局模型 = Σ (n_k / N) * w_k # n_k : 客户端 k 的本地样本数 # N : 本轮参与客户端样本总数 # w_k : 客户端 k 本地训练 E 轮后的模型参数 def fedavg_aggregate(local_weights, local_sizes): total_size = sum(local_sizes) global_weight = {} for key in local_weights[0].keys(): global_weight[key] = sum( w[key] * (s / total_size) for w, s in zip(local_weights, local_sizes) ) return global_weight代码逻辑:local_weights是各客户端上传的参数 dict 列表,local_sizes是对应的样本数列表。聚合时对每一层参数做加权求和,权重是样本数占比,返回的global_weight就是下一轮全局模型。参数说明:此处的权重是“样本占比”,不是参与客户端数量,当各客户端数据非均匀分布时,这个细节直接决定模型偏向谁;如果你的客户端数据量完全均等,那么样本占比与数量占比等价,但为了通用性仍然建议显式传local_sizes。
提示:聚合时使用
float类型累加,注意样本数差距过大时,大样本客户端的参数会主导全局模型,这既可能符合直觉,也可能掩盖 non-IID 带来的模型退化,后面会专门讨论。
这里要再强调一点:FedAvg 假设各客户端本地数据是同分布的(即 IID)。真实场景往往不是这样,但如果连模拟器里的基础逻辑都不正确,后面做 non-IID 分析就会分不清是聚合算法问题还是数据切分问题。
2.2 单机模拟的核心抽象:数据分片、客户端训练、服务端聚合三层拆分
真实横向联邦系统的结构是:客户端进程(或设备)通过网络与服务器通信,服务器负责调度、聚合和广播。单机模拟不需要模拟网络——它需要的是把“多个客户端”这一个概念翻译成进程内对象。我习惯按三个层次去建模,这也是代码结构的基本原则。
第一层是数据切分器(DataPartitioner)。它接收完整数据集和客户端数量 K,返回一个字典,键是客户端 ID,值是该客户端持有的数据索引。第二层是客户端训练器(ClientTrainer)。它是一个函数或者类方法,输入是全局模型参数、本地数据索引、超参数(学习率、本地轮次、batch size),输出是一份训练后的本地模型参数。第三层是服务端聚合器(ServerAggregator)。它维护全局模型参数,调度每个 round 参与的客户端,收集参数并聚合。
我一般用 Python 的state_dict字典结构表示模型参数,让每个客户端训练时由模型工厂创建全新的模型实例,再加载全局权重,这样避免客户端之间参数相互污染。在单机模拟里,所有客户端训练一般串行执行,避免同时开多个进程带来的资源竞争。如果你用的是 PyTorch,最好把每一层参数从state_dict中取出来单独存成 NumPy 数组或直接以张量形式交换,这样可以避免后续聚合时出现设备不匹配(CPU/GPU 张量混用)的问题。下面是一个最小骨架示例:
class ClientTrainer: """客户端本地训练器:输入全局权重 -> 输出本地权重""" def __init__(self, model_fn, device="cpu"): self.model_fn = model_fn self.device = device def train(self, global_weight, data_loader, lr=0.01, local_epochs=1): model = self.model_fn().to(self.device) model.load_state_dict(global_weight) # 以全局模型为起点 optimizer = torch.optim.SGD(model.parameters(), lr=lr) # 具体训练循环见 3.2 节 return model.state_dict()代码逻辑:train方法是整个模拟器的核心入口,每次从全局权重出发创建一个新模型,用本地数据训练若干轮后返回更新的权重,这样客户端之间互不污染。参数说明:model_fn是一个返回全新模型的工厂函数,保证每个客户端拿到的是独立模型实例;device参数让代码能在 CPU 与 GPU 之间切换,模拟真实客户端可能使用不同算力的场景。注意这里没有在__init__里创建模型,原因是为了避免多个客户端共享同一个模型对象的坑。
注意:不要在构造函数里创建模型后又在
train里 load_state_dict 复用同一个实例,容易踩“权重原地修改”的坑,导致某个客户端训练完,其他客户端的模型也被污染。
这一层抽象几乎就是横向联邦学习模拟器的全部骨架。剩下的工作就是把骨架填实:数据怎么切、训练循环怎么写、聚合循环怎么调度。下面从代码层面逐块展开。
3. 从零搭建模拟器:数据切分、客户端训练与服务端聚合
3.1 数据切分:模拟 IID 与 non-IID 数据分布的两种做法
数据切分是本地模拟横向联邦学习里最需要注意的一步。IID 场景指的是每个客户端的数据分布与总体分布一致,non-IID 则是每个客户端只拥有部分类别或分布偏移的数据。真实世界中,non-IID 几乎是一种常态,因为每个参与方采到的数据天然有偏。在模拟器里,这一步直接决定后续所有实验结论的适用范围。
先看 IID 切分。做法非常简单:把样本索引打乱后平均切给 K 个客户端即可。下面这个函数返回一个字典,键是客户端编号,值是该客户端持有的样本索引列表:
import numpy as np def iid_split(labels, num_clients, seed=42): """IID 切分:随机打乱索引后均分给各客户端 labels: 一维数组,每行样本的类别 num_clients: 客户端数量 seed: 随机种子,固定后实验结果可复现 """ rng = np.random.RandomState(seed) indices = rng.permutation(len(labels)) # 打乱全局索引 # np.array_split 会把 indices 均匀分成 num_clients 份 client_indices = np.array_split(indices, num_clients) return {k: idx.tolist() for k, idx in enumerate(client_indices)}代码逻辑:rng.permutation生成一个确定性的乱序索引,np.array_split把索引均匀切成 K 份,返回一个键为客户端编号、值为样本索引列表的字典。参数说明:seed必须固定,否则每次运行切分结果不同,后续做对比实验时无法判断准确率变化来自算法还是数据分布;labels在这里实际只用于确定样本数,IID 并不要求依据类别信息。
non-IID 切分最经典的做法是按标签排序后切块。每个客户端拿到的样本类别高度集中,甚至完全属于某一个类别。最简单的实现:
def noniid_split_by_label(labels, num_clients, shard_size=2, seed=42): """按标签排序的非 IID 切分:每个客户端拿到 shard_size 个类别的样本 labels: 一维类别数组 num_clients: 客户端数量 shard_size: 每个客户端占有的类别数,越小 non-IID 程度越强 """ rng = np.random.RandomState(seed) labels = np.array(labels) sorted_idx = np.argsort(labels) # 按类别排序 shards = np.array_split(sorted_idx, num_clients) client_indices = {} for k, shard in enumerate(shards): # 每个 shard 内部再随机打乱,避免同一客户端的样本永远按原始顺序排列 client_indices[k] = rng.permutation(shard).tolist() return client_indices代码逻辑:先按标签排序,再平分给客户端,使同类别的样本集中在少数客户端手里,模拟真实世界的偏斜分布。参数说明:shard_size在这里只是一个命名提示,实际用于控制每个客户端持有样本的类别集中度——shard_size 越小,客户端类别越单一,non-IID 程度越高。如果你需要更精细的非 IID 控制,常见方案是用狄利克雷分布为每个客户端采样类别比例,alpha越小分布越偏斜。
提示:判断你的切分是否有意义,可以打印每个客户端各类别样本占比矩阵,一眼就能看出是不是“看似切了、实则还是均匀”的假 non-IID。
拿到client_indices之后,用一行torch.utils.data.Subset(dataset, indices)就能构建出每个客户端各自的 DataLoader,后续所有客户端本地训练都基于这个子集进行。
3.2 客户端本地训练:用 PyTorch 写一个可复用的本地更新函数
接下来是客户端本地训练。我以 PyTorch 为例,但逻辑同样适用于 TensorFlow 或纯 NumPy 实现。关键在于:训练函数只依赖「输入全局权重 + 本地数据加载器」,不感知其他客户端的存在。这样无论你后面想串行跑还是并行跑,这个函数都可以原样复用。
import torch def client_local_train(model, global_weight, train_loader, lr=0.01, local_epochs=1, device="cpu"): """客户端本地训练函数 model: 模型实例(每个客户端调用前由外部创建) global_weight: 服务端下发的全局模型权重,OrderedDict train_loader: 该客户端本地数据的 DataLoader lr: 本地学习率 local_epochs: 本地训练轮数 device: 运行设备(cpu/cuda) """ model = model.to(device) model.load_state_dict(global_weight) # 必须加载全局权重,不能从头初始化 model.train() optimizer = torch.optim.SGD(model.parameters(), lr=lr) criterion = torch.nn.CrossEntropyLoss() for epoch in range(local_epochs): for inputs, targets in train_loader: inputs, targets = inputs.to(device), targets.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, targets) loss.backward() optimizer.step() return model.state_dict()这段代码的逻辑比表面看上去要重要。load_state_dict这一步决定了每个客户端是从全局模型开始继续训练,而不是从随机初始化出发——后者等于每个客户端训练了一个完全独立的模型,聚合没有任何意义。训练循环本身与普通监督训练没有区别,唯一的差异是训练数据是本地的子集。参数说明:local_epochs=1时每个客户端只遍历本地数据一遍,通信频率最高;lr在联邦场景通常要比集中式训练小一些,因为多客户端平均会在迭代过程中引入更大的隐式批量尺度。
注意:如果不显式调用
model.train(),PyTorch 在加载权重后可能默认保持 eval 模式,BatchNorm/Dropout 行为异常,聚合后的模型表现会出现令人困惑的波动。
3.3 服务端聚合与完整模拟循环:FedAvg 的加权平均实现与调度逻辑
服务端聚合函数在 2.1 节已经给出,这里把它放进完整的轮次循环里,同时加上「每轮只采样部分客户端参与」的逻辑,这是横向联邦学习模拟中最重要的调度参数之一。真实系统里不是所有设备每轮都在线,采样率就是用来模拟这种不确定性的。
def run_federated_rounds(models_fn, client_data, num_clients, global_lr=1.0, client_lr=0.01, local_epochs=1, sample_ratio=1.0, num_rounds=50, device="cpu", seed=42): """横向联邦学习主循环 models_fn: 模型工厂函数,返回未初始化的模型实例 client_data: 字典,客户端 ID -> DataLoader sample_ratio: 每轮实际参与的客户端比例,1.0 表示全部参与 num_rounds: 通信轮次 """ rng = np.random.RandomState(seed) global_model = models_fn().to(device) global_weight = global_model.state_dict() history = [] for round_idx in range(num_rounds): # 1. 采样本轮参与客户端 num_participants = max(1, int(sample_ratio * num_clients)) participants = rng.choice(num_clients, size=num_participants, replace=False) # 2. 各客户端本地训练 local_weights, local_sizes = [], [] for client_id in participants: local_loader = client_data[client_id] local_weight = client_local_train( models_fn(), global_weight, local_loader, lr=client_lr, local_epochs=local_epochs, device=device ) local_weights.append(local_weight) local_sizes.append(len(local_loader.dataset)) # 3. 聚合 global_weight = fedavg_aggregate(local_weights, local_sizes) # 4. 在全局测试集上评估(略) history.append(evaluate(global_model, global_weight, valid_loader)) return global_weight, history代码逻辑:每一轮先按sample_ratio采样参与客户端(模拟真实系统中不是所有设备每轮都在线),再逐客户端执行本地训练,最后用 FedAvg 聚合,之后在验证集上评估全局模型。参数说明:global_lr是额外加入的一个缩放系数,当你把聚合结果乘上一个小于 1 的系数时,全局更新变得更保守,这在客户端数据高度异构时能稳定训练;sample_ratio直接影响每轮信息量,值越小每轮计算开销越低,但需要更多通信轮次才能收敛。
值得注意的是,models_fn()在循环里被反复调用,每次创建独立的模型实例——这正是 2.2 节强调的抽象,它避免了多个客户端共享模型实例带来的参数污染。这样三步走完,一个本地模拟横向联邦学习的最小闭环就完整跑通了。接下来考虑里面最重要的超参数怎么设。
4. 参数怎么设:客户端数量、本地轮次、学习率与批量大小的联动关系
4.1 客户端数量与采样率:训练开销与收敛速度的权衡
客户端数量在横向联邦学习里是很微妙的参数。总数据量不变,切分的客户端越多,每个客户端的数据就越少,本地训练的统计波动就越大。反而是客户端数量较少时,每个客户端的数据更充足,本地模型质量更高,但 non-IID 程度通常更严重。
我常见的一组参考配置表格如下:
| 参数 | 典型值 | 对结果的影响 |
|---|---|---|
| 客户端数量 K | 5~20(模拟)、50~100(大规模) | K 越大,单客户端数据越少,本地模型越不稳定 |
| 采样率 sample_ratio | 0.1~1.0 | 越低通信开销越小,但收敛可能需要更多轮次 |
| 本地轮次 local_epochs | 1~5 | 越大通信越少,但容易 client drift |
| 客户端学习率 | 0.001~0.05 | 需要随 local_epochs 增大而相应降低 |
| 批量大小 | 16~64 | 与 local_epochs 联动影响本地更新步数 |
这个表可以帮助你做初选。K 值特别极端(比如 K=100)时,有的客户端可能只有几个样本,本地训练几乎等于噪声更新,一定要配合采样率避免每轮都让全体参与。
采样率的设计逻辑在于模拟真实系统里的设备可用性。真实系统中,每轮只有一部分设备处于空闲、电量充足、网络联通的可用状态。在模拟器里,sample_ratio=0.5表示每轮随机挑一半客户端参与,这既能节省算力,也提高了每轮之间的随机性,间接相当于一种正则化。如果你发现训练曲线抖动剧烈,可以试着调低采样率并增大轮数,看看是否因为高采样率下的过拟合了某些客户端的数据。
4.2 本地迭代轮次与学习率:local epoch 不是越大越好
这是新手最容易翻车的地方。直觉上,本地训练越多,客户端模型应该越好,但联邦学习的聚合机制决定了本地训练步数过多会导致“客户端偏移”(client drift)——每个客户端模型都朝着自己的数据分布过度优化,聚合后全局模型反而被拉偏。
我自己的经验是:local_epochs 在 1 到 5 之间比较常见,具体选多少取决于数据分布。IID 场景下可以取较大的 local_epochs,比如 5,减少通信轮次;non-IID 场景下建议从 1 开始,优先保证聚合的稳定性,再逐步增加观察测试曲线变化。
关键联动关系是学习率。本地训练步数与批量大小和 local_epochs 的关系可以用公式粗略表示:
# 本地更新步数(近似) # num_local_steps ≈ (样本数 / batch_size) * local_epochs # 等效全局批量 ≈ num_local_steps * batch_size * num_clients_per_round这个公式的意义在于:联邦学习的每一步全局更新,实际上用了非常多“本地累积”的信息。等效全局批量远大于普通集中训练,所以学习率不能直接沿用集中式的经验值(比如 0.1),通常要下调一个数量级到 0.01 甚至 0.001。如果你观察到训练曲线发散或者先降后爆,先检查一下是不是 learning rate 没有针对本地迭代步数做调整。
4.3 批量大小与本地更新步数的联动:batch size 对联邦训练的非线性影响
在很多人的直觉里,批量大小的变化不会带来本质差别,但在横向联邦学习中,它是与 local_epochs 强相关的超参数。本地迭代步数等于数据量除以批大小再乘以本地轮数;批大小减半等于本地步数翻倍,相当于隐式地把 local_epochs 加倍。
这带来一个比较隐蔽的坑:如果你在实验 A 中设 batch size 为 64、local_epochs 为 1,在实验 B 中设 batch size 为 32、local_epochs 仍为 1,实际上训练量并不一样,B 的每个客户端本地更新步数翻倍了。比较实验时要把总本地训练量对齐,或者明确记录「每轮的等效步数」,否则实验结果被混淆。
此外,批大小影响优化噪声水平。小批量带来更大的梯度噪声,在 non-IID 场景下会让 client drift 更严重。我一般的做法:在 non-IID 模拟中优先选择较大的 batch size(比如 64),减少本地梯度方向的偏移;在 IID 场景下可以放小到 32 或 16,增加随机性有时反而带来更好的泛化。如果你发现训练曲线震荡剧烈并且最终准确率低,先尝试增大 batch size 看看是否缓解。
注意:在跨客户端聚合时,sample_ratio 和 batch size 共同决定每轮全局模型的“平均梯度”质量。两者不宜同时取极值——比如采样率 0.1 加 batch size 16,会使得单轮的信息量极低,模型基本在噪声中徘徊。
5. 本地模拟避坑盘点:5 个让新手翻车的场景与排查思路
5.1 全局模型不收敛:学习率与本地轮次的组合失控
现象:训练轮数增加,全局测试准确率没有明显上升,甚至下降;loss 曲线震荡剧烈。
原因:最常踩的是两个参数同时失控:学习率过大和 local_epochs 过大。在 non-IID 数据下,每个客户端的本地优化方向已经偏离全局最优,local_epochs 越大偏离越远,服务端聚合得到的模型像在多个方向之间来回“拉锯”。此时如果学习率再按集中式训练的习惯设 0.1,全局模型被反复拉扯后偏移会更严重。
解决:把客户端的本地学习率降到 0.01 或更低,同时把 local_epochs 降到 1,先让系统稳定收敛,再逐步放大参数观察边界。如果仍然震荡,可以给聚合函数加一个全局学习率系数(比如乘以 0.5),让每轮更新更平滑。
5.2 聚合后模型崩溃:权重归一化与客户端权重设置
现象:聚合后的全局模型在验证集上准确率接近随机猜测,甚至输出 NaN,loss 显示 inf。
原因:最常见的是聚合时没有按样本数归一化。如果直接对参数做简单平均(而非加权),数据量最小的客户端与最大的客户端对全局模型的贡献一样,模型会被极小样本训练出的噪声参数带崩。NaN 则通常来自学习率过大导致的梯度爆炸或不稳定的批量归一化统计量。
解决:严格按样本占比加权聚合,并且校验total_size > 0。在聚合函数里加一个小 epsilon 防止除零。另外,如果模型里有 BatchNorm 层,在本地训练后要把 running_mean/running_var 也一并上传和聚合,或者改用不依赖全局统计量的层(如 GroupNorm)。
5.3 IID 切分太均匀,模拟结果失去参考意义
现象:模拟中 IID 场景的准确率很高,与真实生产环境表现差距很大,导致你高估了算法能力。
原因:你在模拟器里切分的 IID 实际上把数据均匀地打乱分给了每个客户端,每个客户端的本地数据分布与全局完全一致,这是最理想的场景。真实世界的用户数据按行为聚类,天然是 non-IID。
解决:至少跑一组 non-IID 作对照。最简单的是按标签排序切分;要更精细就使用狄利克雷分布控制每个客户端各类别的比例,alpha取 0.1~10,能覆盖从极端 non-IID 到接近 IID 的连续谱系,记录下alpha同收敛性的变化,实验结论的可信度会明显提升。
5.4 内存暴涨:多个客户端模型副本的显存管理
现象:客户端数量调大后,模拟器内存或显存使用直线上升,甚至直接崩溃。
原因:每个客户端的本地训练都会创建一个新的模型实例,如果这些实例同时保持在 GPU 显存或内存中,K 个客户端就相当于 K 份模型参数。在 PyTorch 里,如果训练函数返回值是state_dict(),而你在循环里又保留了模型的引用,模型不会被垃圾回收,显存就一直增长。
解决:串行训练,每一轮循环内部用完的模型显式删除,或把返回值限制为纯参数张量的 dict 而不是模型对象。如果数据本身太大,可以只把 batch 加载到 GPU,模型保持在 CPU 上,训练完成后再转回 CPU 保存权重;这样模型本身就是 CPU 存储,不影响后续 CPU 聚合。
注意:使用 PyTorch 时,训练完一个客户端后最好调用
torch.cuda.empty_cache()(如果用了 GPU),并尽量让client_local_train内部创建模型和优化器,避免在函数外部持有引用。
5.5 随机种子没固定:实验重复性无从谈起
现象:同一个配置重复跑两次,结果相差很大,甚至收敛趋势都不同。
原因:横向联邦学习模拟中随机来源很多——数据切分的索引、模型初始化的权重、每轮参与客户的采样、DataLoader 的 shuffle 顺序。任何一个没有固定,实验结果就不可复现。
解决:把所有随机源都固定下来。在代码入口设置torch.manual_seed(seed)、np.random.seed(seed),在切分数据和每轮客户端采样时都使用RandomState(seed)或generator传入而不是全局随机源。推荐的做法是把 seed 作为模拟器的唯一配置项记录在日志里,实验对比时只改 seed、其他全不变,再取多次运行的均值和方差。
6. 从模拟到可信实验:验证收敛、多种子运行与参数记录
6.1 收敛性验证的三个关键信号
模拟器跑通之后,你需要回答一个问题:这个实验结果是可信的吗?我用三个信号来判断。
第一,训练集上的全局 loss 是否在整体下降。在每一轮聚合后,把数据混合起来在全局训练集上算一次 loss,如果这个值不下降,说明聚合出了问题——这时再去看单个客户端本地 loss 其实没什么意义,因为本地每个客户端都可以正常过拟合。
第二,测试集准确率曲线的形态。如果在几十个 round 内仍然处于上升期,说明还没收敛,需要增大通信轮次;如果先升后降,考虑是不是学习率过高导致的过拟合或模型震荡。还有个容易忽略的信号:验证曲线波动幅度。如果相邻轮次的准确率抖动超过 5%,说明客户端数据分布差异太大或者采样率过低,结果不稳定。
第三,需要与集中式训练进行对比。集中式训练是最强的 baseline,可以引入同一个模型在所有数据上集中训练的测试准确率。联邦学习的性能通常会略低一点点,但如果相差过多,考虑是不是 non-IID 程度过于极端。这个对比也能帮你判断 non-IID 模拟的合理性。
6.2 实验配置记录与管理:可复现的模拟器配置
为了让实验真正可信,我习惯把所有参数集中到一个配置对象中,并在每次运行前把所有配置打印或写入 JSON。这个习惯能避免和朋友、同事来回确认参数时出现各种误解。
def run_experiment(config): """运行一次完整实验并返回历史记录 config: 字典,包含种子、客户端数、采样率、本地轮次、学习率等全部超参数 """ print("[config]", config) torch.manual_seed(config["seed"]) np.random.seed(config["seed"]) # 数据切分 client_indices = ( iid_split(config["num_clients"], config["seed"]) if config["data_dist"] == "iid" else noniid_split_by_label(config["num_clients"], config["seed"]) ) # 构建 DataLoader、运行主循环……(略)这个函数的关键点是:一张配置表中包含数据分布的类型、non-IID 的 alpha 参数(或 cut 的类别个数)、参与客户端数量、采样率、本地轮数、客户端学习率、全局学习率、批量大小、优化器类型、训练轮数、随机种子。这样每一次运行都是自包含的,可以在同一代码库中重复执行多次实验并对结果取均值。
提示:在一次实验中,我通常跑 3~5 个不同种子,取测试准确率的均值和标准差。这样得到的结果才谈得上可复现,写报告时更有说服力。
最后一个习惯是:不要追求复杂先简单。我是从固定一个 seed、K=5、sample_ratio=1.0、local_epochs=1、lr=0.01 开始,跑通后再逐个放开限制。这个顺序让我在每一步都能清楚定位问题出现在哪里:是数据切分的坑,还是本地训练的坑,还是聚合的坑。如果你第一次模拟横向联邦学习就同时上 non-IID、大量客户端、低采样率和高 local_epochs,任何指标异常都很难解释。我的习惯是不做玄学调参,把每次改动限制在一个参数以内。希望帮到你。
本文还有配套的精品资源,点击获取