首页 / 资讯中心 / 文章详情

单机模拟横向联邦学习:FedAvg工程实践与调参指南

单机模拟横向联邦学习:FedAvg工程实践与调参指南 ★ FEATURED ARTICLE
简介面向希望理解横向联邦学习完整流程的Python开发者与机器学习初学者这份项目资料以本地模拟方式复现多客户端独立训练、中央服务器聚合并广播全局模型的协作过程适合用作课程实验、毕业设计原型或分布式机器学习入门练习。压缩包共25个文件约302.68MB包含5个py源码、4个pyc缓存、4个xml配置及json、gz压缩数据等核心围绕服务端协调、客户端训练、模型定义、数据集加载与CIFAR-10批次数据组织便于直接运行和二次修改。已有729人学习下载。读者可从中梳理联邦学习的通信接口设计、参数聚合策略、迭代训练流程以及加密通信、差分隐私等安全考量并借助现成脚本观察多客户端模拟中的异步更新与数据不一致问题快速搭建可复现的实验环境。1. 在一台笔记本上模拟横向联邦这个项目把「多方协作训练」拆成了可调参的本地实验第一次接触联邦学习的人多半会被「跨机构、数据不出本地」这套说法绕进去以为没有几台服务器就没法动手。其实横向联邦学习最核心的那部分——多个客户端各用私有数据训练、只上传模型参数、服务端做加权聚合——完全能在一台机器上用 Python 本地模拟出来。这份资料做的就是这件事把 N 个虚拟客户端、非独立同分布的数据切分、FedAvg 聚合、每轮评估和日志全收进一个跑得起来的工程里。它适合两类人一类是想把论文里的 FedAvg 公式跑成看得见的准确率曲线的同学另一类是准备上真实集群、想先在单机上把超参和通信轮次摸清楚的工程师。下面按工程结构往里拆。2. 工程骨架与配置层入口脚本怎么串起服务端、客户端和数据切分2.1 目录结构服务端、客户端、策略三块彻底分开拿到这种模拟器我第一眼看的不是模型定义而是目录。模型改起来最便宜结构和配置改起来最贵。一份合格的联邦学习模拟工程目录通常长这样fed-sim/ ├── main.py # 入口读配置 - 建数据 - 建联邦 - 跑轮次 ├── configs/ │ ├── base.yaml # IID 基线 │ └── dirichlet_0.1.yaml # 非 IID 实验 ├── fedsim/ │ ├── __init__.py │ ├── config.py # YAML - Config 对象附带参数合法性校验 │ ├── dataset.py # 数据集加载、归一化、训练/测试划分 │ ├── partition.py # IID / Dirichlet / 按标签切分 │ ├── client.py # 客户端本地训练只依赖 state_dict │ ├── server.py # 聚合、下发、评估调度 │ ├── strategy.py # fedavg / fedprox 等聚合策略 │ ├── models/ │ │ ├── mlp.py │ │ └── cnn.py # 小 CNNCPU 上也跑得动 │ └── utils.py # 种子、日志、指标落盘 ├── scripts/ │ └── run_grid.sh # 批量扫 alpha / client_frac └── requirements.txt这么拆的理由很实在。client.py里不许出现from .server import ...客户端的输入只有一个参数字典和一份Subset输出只有「样本数 新参数 本地指标」这样把客户端换成真实设备时改动面只有一层。strategy.py和server.py分开是因为服务端要做的事选客户端、广播、收参数、评估和聚合算法本身是两件事混在一起以后想加 FedProx、加梯度压缩就很别扭。数据切分单独成模块则是为了让划分结果能被np.save下来复用——同一份数据划分配不同的聚合算法才谈得上公平对比。模块输入输出不该做的事partition.pytargets 数组、客户端数、alpha每个客户端的索引列表不碰模型、不碰 DataLoaderclient.py全局 state_dict、本地 Subset新 state_dict、样本数、loss/acc不写全局日志、不决定谁参与strategy.py一组客户端结果新的全局 state_dict不碰数据、不碰优化器server.py客户端池、策略、评估集每轮指标不实现具体的加权公式这份划分还有个隐藏好处做对照实验时日志里的client_id到样本分布是一一对应的发现某一轮准确率异常下跌时能直接回查是哪个客户端发来的参数带偏了全局模型。2.2 依赖与运行环境一份 requirements 跑通 CPU 版依赖我一向主张给最小的集合联邦学习模拟器的算力开销主要在「轮次 × 客户端数 × 本地 epoch」多装一个用不上的库只会拖慢环境搭建。# requirements.txt torch2.0 # 张量与自动求导聚合时也靠它做张量运算 numpy # 数据切分、随机数、统计 pyyaml # 实验配置外置 tqdm # 轮次进度条长实验没它很煎熬 matplotlib # 画 alpha 与最终准确率的关系曲线安装和启动就两条命令配置靠--config切# CPU 环境足够不需要 CUDA 也能跑通全流程 pip install -r requirements.txt # IID 基线先确认链路是通的 python main.py --config configs/base.yaml # 非 IIDalpha 越小越偏 python main.py --config configs/dirichlet_0.1.yaml --seed 42main.py里对设备的选择我一般写成「有 GPU 用 GPU但聚合永远在 CPU 上做」。原因不是玄学聚合是逐元素的加权求和data-parallel 那套调度在这种小张量上几乎没有收益反而每次torch.stack都要在显存里再开一份客户端数一多就爆。客户端本地训练放 GPU参数回传前.detach().cpu()聚合完再.to(device)这条路径最省心。另一个容易忽略的点是数据集。默认配置下用 torchvision 直接下 MNIST 或 CIFAR-10 到./data离线环境下把dataset.py里的加载换成自己的.npz即可只要保证__getitem__返回(tensor, int_label)后面的切分和训练代码一行都不用动。这也是把dataset.py单独拆出来的价值——数据来源换来换去联邦逻辑保持不动。2.3 配置层用一份 YAML 描述整个联邦实验联邦学习的超参比普通训练多一倍既有机器的学习率、batch、epoch又有联邦的客户端数、每轮参与比例、alpha、聚合策略。这些参数散在代码里跑三组实验就开始记混。常见做法是全部外置成 YAML# configs/dirichlet_0.1.yaml seed: 42 data: name: cifar10 root: ./data num_classes: 10 batch_size: 64 partition: scheme: dirichlet # iid | dirichlet | by_label num_clients: 20 alpha: 0.1 # 越小越偏0.1 通常已经很不 IID min_size: 20 # 客户端最少样本数低于它会被合并 train: model: cnn_small rounds: 100 client_frac: 0.5 # 每轮只抽一半客户端参与模拟掉线 local_epochs: 2 lr: 0.01 momentum: 0.9 weight_decay: 0.0005 mu: 0.0 # 0 时 FedProx 的近端项系数 strategy: name: fedavg # fedavg | fedprox weight_by: samples # samples | uniform eval: every: 1 batch_size: 256# fedsim/config.py from dataclasses import dataclass, field import yaml dataclass class Config: seed: int 42 data: dict field(default_factorydict) partition: dict field(default_factorydict) train: dict field(default_factorydict) strategy: dict field(default_factorydict) eval: dict field(default_factorydict) classmethod def load(cls, path: str) - Config: with open(path, r, encodingutf-8) as f: raw yaml.safe_load(f) cfg cls(**raw) # 把约束卡在入口别等跑了 30 轮才发现在除零 assert cfg.partition[num_clients] 2, 至少两个客户端才叫联邦 assert 0 cfg.train[client_frac] 1, 参与比例必须在 (0, 1] assert cfg.train[local_epochs] 1 assert cfg.strategy[weight_by] in (samples, uniform) if cfg.strategy[name] fedprox: assert cfg.train[mu] 0, fedprox 必须给正的 mu return cfg这里有个取舍嵌套的dict而不是嵌套的dataclass。字段少的时候嵌套 dataclass 更优雅但联邦实验的参数集合几乎每加一个算法就变一次FedProx 加mu压缩方案加compress_ratio用 dict 加 YAML 能让新增参数零改动地流到使用处代价是 IDE 没有补全。我个人更吃这套因为改算法的人往往不是写配置类的人。Config.load里那几行assert不是形式主义。真实翻车现场是这样的client_frac写成 0int(20 * 0) 0rng.choice(..., size0)返回空数组最后聚合时sum(weights)为 0报一个ZeroDivisionError而报错位置离配置十万八千里。入口处挡住能省半小时。3. 数据切分模块Dirichlet 采样怎么造出「非独立同分布」的 N 份私有数据3.1 非 IID 切分Dirichlet 采样怎么把一份训练集拆成 20 份联邦学习之所以不是「分布式训练」关键就在数据不同源。本地模拟要还原这一点最常用的手段是 Dirichlet 分布采样对每个类别抽一组和为 1 的比例向量再按比例把该类样本分给各个客户端。alpha越小比例向量越极端客户端手里的类别就越单一。# fedsim/partition.py import numpy as np from collections import defaultdict def dirichlet_partition(targets, num_clients, alpha0.1, min_size20, seed0): 按类别做 Dirichlet 切分返回每个客户端的样本索引列表。 targets: 一维数组训练集每个样本的标签 alpha: 集中度参数越小越不 IIDalpha - inf 接近均匀切分 min_size: 客户端最少样本数低于它的碎片会被合并掉 rng np.random.default_rng(seed) num_classes int(np.max(targets)) 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.asarray(idx_by_class[c]) rng.shuffle(idx_c) # 每个客户端在该类上分到的比例总和为 1 props rng.dirichlet(np.repeat(alpha, num_clients)) # 用累积和算切分点比逐个 round 更不容易因浮点误差丢样本 cuts (np.cumsum(props) * len(idx_c)).astype(int)[:-1] for cid, chunk in enumerate(np.split(idx_c, cuts)): client_indices[cid].extend(chunk.tolist()) # Dirichlet 在 alpha 很小时会切出只有两三个样本的碎片本地 SGD 根本跑不动 for cid in range(num_clients): if 0 len(client_indices[cid]) min_size: donor max(range(num_clients), keylambda j: len(client_indices[j])) if donor ! cid: client_indices[donor].extend(client_indices[cid]) client_indices[cid] [] kept [idx for idx in client_indices if len(idx) min_size] assert len(kept) 2, falpha{alpha} 太小有效客户端只剩 {len(kept)} 个 return kept这段代码有几处值得说道。np.cumsum(props) * len(idx_c)之后取astype(int)最后一个切分点必然等于len(idx_c)所以取[:-1]正好得到num_clients - 1个内部切分点np.split分出来恰好num_clients份不会漏样本也不会越界。这是我自己踩过坑之后固定下来的写法早先用round(props * n)逐个算偶尔会出现总数比原集合少一条的情况排查起来很折磨。min_size那段是实用主义的补丁。alpha 取 0.05、客户端取 50 的时候必然出现「某个客户端只有 2 张图」的情况。这种客户端本地训练时batch_size64连一个完整 batch 都凑不齐drop_lastTrue就直接一个 batch 都没有loss 是 nan。把碎片并进最大的客户端等于承认「这个客户端没代表性」比让它污染聚合结果要好。rng np.random.default_rng(seed)而不是np.random.seed(seed)是为了让划分结果不受全局随机状态影响。这个细节在做多组对照实验时非常关键后面第 5 章还会提到。3.2 客户端数据视图Subset DataLoader 的正确用法切分只产出索引真正喂给模型的是DataLoader。这里最常见的错误是把每个客户端的数据复制成一份独立 Dataset——20 个客户端把 CIFAR-10 复制 20 遍内存直接起飞。正确姿势是Subset只持有索引引用底层张量共享。# fedsim/client.py 片段 from torch.utils.data import DataLoader, Subset class Client: def __init__(self, cid, indices, train_set, cfg): self.cid cid self.indices indices self.num_samples len(indices) self.cfg cfg # Subset 不复制数据只记索引drop_lastFalse 保住小客户端的尾巴 batch self.loader DataLoader( Subset(train_set, indices), batch_sizecfg.train[batch_size], shuffleTrue, # 每个 epoch 重新打乱小数据集上影响很明显 drop_lastFalse, num_workers0, # 本地模拟客户端多多进程开销反而更大 )num_workers0在真实训练里通常是要被批评的但在本地联邦模拟里恰恰是对的20 个客户端各开 2 个 worker就是 40 个进程抢同一块 CPU进程间切换的时间比数据加载本身还长。我实测过把这行改成 4整体反而慢了还偶尔卡在DataLoader的析构上。shuffleTrue也不是可选项。非 IID 切分之后一个客户端手里的样本往往按类别成块排列如果不打乱本地 SGD 每个 epoch 都是「先全 A 类再全 B 类」模型会在两类之间反复横跳本地 loss 曲线像心电图。这一点在 alpha 很小的时候尤其明显是「联邦训练不收敛」问题里最容易被忽略的一个诱因。3.3 切完先自检把每个客户端的类别分布打出来数据切分是整个工程里最不该「相信它是对的」的一环。我一般会在划分完成后立刻跑一次统计把每个客户端的样本数和类别直方图打到日志里# fedsim/utils.py import numpy as np def summarize_partition(client_indices, targets, num_classes): rows [] for cid, idx in enumerate(client_indices): cnt np.bincount(np.asarray(targets)[idx], minlengthnum_classes) rows.append({ cid: cid, n: int(cnt.sum()), classes: int((cnt 0).sum()), # 该客户端覆盖了几类 max_ratio: float(cnt.max() / max(cnt.sum(), 1)), # 最大类占比 }) ns [r[n] for r in rows] print(fclients{len(rows)} samples{sum(ns)} fmin{min(ns)} max{max(ns)} favg_classes{np.mean([r[classes] for r in rows]):.2f}) for r in rows[:5]: print(r) return rows几个数值得盯住avg_classes掉的厉害说明 alpha 太小任务会退化成「每个客户端只会认一两类」max_ratio接近 1 表示这个客户端基本单类别min/max差距过大时按样本数加权的 FedAvg 会明显偏向大客户端。这些数字和后面第 4 章的聚合权重是直接挂钩的先看清楚再训练能省下不少盲目调参的时间。顺带说一句划分结果建议np.save(partition_alpha0.1_seed42.npy, client_indices)存下来。同一份划分反复用你的对照组之间差异才只来自算法本身不存的话每次都重新抽跑出来 0.5% 的准确率差异你根本分不清是算法好还是这次运气好。4. 算法内核FedAvg 一轮通信从本地 SGD 到权重聚合的完整链路4.1 客户端本地训练load_state_dict 之后跑几个 epoch客户端这一侧的逻辑其实很朴素拿到全局参数覆盖自己的模型跑local_epochs个 epoch把新参数和样本数返回。真正需要注意的是「模型实例复用」和「参数搬运」。# fedsim/client.py import torch import torch.nn.functional as F class Client: # __init__ 见 3.2 def train(self, global_state, model, device): # 复用同一个模型实例20 个客户端不会各占一份显存/内存 model.load_state_dict(global_state) model.to(device).train() opt torch.optim.SGD( model.parameters(), lrself.cfg.train[lr], momentumself.cfg.train[momentum], weight_decayself.cfg.train[weight_decay], ) total_loss, total_cnt, correct 0.0, 0, 0 for _ in range(self.cfg.train[local_epochs]): for x, y in self.loader: x, y x.to(device, non_blockingTrue), y.to(device) opt.zero_grad(set_to_noneTrue) out model(x) loss F.cross_entropy(out, y) if self.cfg.train[mu] 0: # FedProx 的近端项 prox torch.zeros((), devicedevice) for name, p in model.named_parameters(): prox prox ((p - global_state[name].to(device)) ** 2).sum() loss loss 0.5 * self.cfg.train[mu] * prox loss.backward() opt.step() bs y.size(0) total_loss loss.item() * bs total_cnt bs correct (out.argmax(1) y).sum().item() # 回传 CPU 上的参数副本别把 GPU 张量挂在结果里 new_state {k: v.detach().cpu().clone() for k, v in model.state_dict().items()} return { cid: self.cid, n: total_cnt, state: new_state, loss: total_loss / max(total_cnt, 1), acc: correct / max(total_cnt, 1), }几个参数的实际影响值得说清楚。local_epochs是联邦学习里最有政策味道的超参调大通信轮次能降下来但各客户端会朝各自的数据分布跑得更远聚合后的全局模型反而更容易发散这在 alpha 很小的时候几乎是必然。我一般先用local_epochs1确认整条链路能收敛再往 2、3 加看准确率曲线有没有变陡。mu是 FedProx 的近端项系数作用是把本地更新拉住不让它离全局参数太远。注意代码里global_state[name]每步都要.to(device)这一步在客户端多的时候会变成性能热点更好的写法是在train开头一次性把参数搬到 device 上存成字典循环里直接用。返回值里的n用的是total_cnt也就是「样本数 × 本地 epoch 数」。因为所有客户端local_epochs相同这个量和纯样本数在比例上等价用它做加权权重是安全的。但如果哪天你实现了「不同客户端跑不同 epoch 数」的异步模拟这个权重就要改成纯样本数否则聚合结果会被跑得多的客户端带偏。opt.zero_grad(set_to_noneTrue)而不是默认的zero_grad()省一次全参数写零客户端数乘以轮次之后差别是肉眼可见的。4.2 服务端聚合FedAvg 的加权平均与 buffer 的特殊处理聚合这一段代码不到二十行但它是整个项目里翻车率最高的地方。最朴素的写法是sum(w_i * state_i) / sum(w_i)跑 MNIST 的 MLP 一点问题都没有换成带 BatchNorm 的 CNN 就开始出怪事。# fedsim/strategy.py import torch def fedavg(results, global_state, weight_bysamples): 按样本数或均匀加权平均客户端参数。 results: [{cid:..., n:..., state: {...}}, ...] if weight_by uniform: weights [1.0] * len(results) else: weights [float(r[n]) for r in results] total sum(weights) assert total 0, 所有参与客户端的样本数为 0检查 partition 结果 new_state {} for key, ref in global_state.items(): if ref.dtype.is_floating_point: # 用 float64 累加客户端数多时 float32 会累积明显误差 acc torch.zeros_like(ref, dtypetorch.float64) for r, w in zip(results, weights): acc r[state][key].double() * (w / total) new_state[key] acc.to(ref.dtype) else: # num_batches_tracked 这类整型 buffer 不能加权平均取第一个客户端的 new_state[key] results[0][state][key].clone() return new_stateref.dtype.is_floating_point这个分支看着琐碎其实是血泪经验。BatchNorm会往state_dict里塞一个num_batches_tracked的 int64 张量如果你无脑对它做加权平均acc变成浮点.to(torch.int64)之后数值就以某种奇怪的方式增长下一轮load_state_dict报 shape 不匹配或者干脆不报错但训练崩掉。整型 buffer 正确做法是不同步。BatchNorm 的running_mean/running_var是浮点能参与平均但严格来说它们不是「可学习参数」跨客户端平均在统计意义上并不成立尤其是在非 IID 场景下。这就是为什么联邦学习里做 CNN 常用 GroupNorm 替代 BN——GROUPNORM 没有需要跨客户端同步的 buffer聚合逻辑立刻变干净。如果你的实验一直不收敛第一条要试的就是把 BN 换成 GN。累加用 float64 也不是洁癖。20 个客户端、每轮累加一次float32 的相对误差在 1e-7 量级100 轮之后确实能看出全局参数和「理论值」的偏离虽然多数时候不影响准确率但在做「聚合实现是否正确」的单元测试时会让你怀疑人生。4.3 主循环客户端采样、聚合并发与评估落盘main.py里的一轮训练顺序是采样客户端 → 广播全局参数 → 各自本地训练 → 聚合 → 评估 → 记日志。看似简单几个细节决定了实验能不能跑出有意义的结果。# main.py 片段 import numpy as np import torch rng np.random.default_rng(cfg.seed) history [] for rnd in range(1, cfg.train[rounds] 1): # 每轮按比例抽客户端模拟设备掉线/休眠 k max(1, int(len(clients) * cfg.train[client_frac])) chosen rng.choice(len(clients), sizek, replaceFalse) chosen [clients[i] for i in chosen] global_state {k_: v.detach().cpu() for k_, v in model.state_dict().items()} results [c.train(global_state, model, device) for c in chosen] if cfg.strategy[name] fedprox: new_state fedavg(results, global_state, cfg.strategy[weight_by]) else: new_state fedavg(results, global_state, cfg.strategy[weight_by]) model.load_state_dict(new_state) if rnd % cfg.eval[every] 0: metrics evaluate(new_state, model, test_loader, device) metrics.update({ round: rnd, n_clients: len(chosen), mean_local_loss: float(np.mean([r[loss] for r in results])), mean_local_acc: float(np.mean([r[acc] for r in results])), }) history.append(metrics) print(f[round {rnd:03d}] acc{metrics[acc]:.4f} floss{metrics[loss]:.4f} local_acc{metrics[mean_local_acc]:.4f}) np.save(history.npy, history, allow_pickleTrue)chosen用rng.choice(..., replaceFalse)保证一轮内不重复抽到同一个客户端这是 FedAvg 的标准设定。client_frac0.5除了省时间还有一个实际意义它模拟了真实环境里「这一轮只有一半设备在线」能让你提前看到参与度波动对收敛的影响。日志里同时记三个量——全局准确率、全局 loss、客户端本地准确率的均值——是有讲究的。三者出现分歧的时候问题的性质完全不一样全局跌但本地均值高说明各客户端在各自数据上过拟合聚合没把它们拉到一起要么调小local_epochs要么开 FedProx全局和本地一起跌多半是学习率太大或者数据切分把某一类整个划没了全局涨但本地均值不动属于正常收敛说明全局模型在泛化。global_state每轮都从model.state_dict()重新取一次 CPU 副本看起来像是重复劳动但它是防止「聚合完的模型和客户端拿到的模型不是同一个」这类隐蔽 bug 最直接的办法。有段时间我图省事直接传引用结果客户端里load_state_dict之后做原地操作把服务端那份也改了聚合结果和预期差得离谱查了一晚上。5. 排查手册本地模拟联邦学习最容易翻车的五处细节5.1 现象准确率从第 3 轮开始一路下跌最后接近随机原因通常不在聚合公式而在本地训练跑得太狠。alpha0.1、local_epochs5的组合下每个客户端在 2 个类别上跑 5 个 epoch本地模型已经严重过拟合到自己那点数据回传的参数离全局最优点非常远加权平均之后得到的是一个「谁也不像」的模型。解决办法按成本从低到高先把local_epochs降到 1 或 2看曲线是否恢复单调再把lr砍到 1e-3 级别本地 SGD 步子小一点偏离也小仍然不收敛就开 FedProxmu从 1e-3 试到 1e-2它是专门为这种客户端漂移准备的。我一般还会顺手把alpha从 0.1 提到 0.5 做一次对照如果提上去就好了说明问题本质上是数据太偏跟算法无关。5.2 现象跑到第 10 轮左右内存持续上涨最后被系统杀掉本地模拟最反直觉的一点是「客户端越多越吃内存」。每个DataLoader会缓存 collate 的中间结果每轮results里又保存了完整的state_dict拷贝20 个客户端 × 100 轮如果不及时释放Python 的 GC 未必跟得上。处理办法有三个客户端复用同一个模型实例不要copy.deepcopyresults用完立刻results.clear()别挂在history里日志只存标量指标绝不把state_dict写进 list。我见过最狠的一版是把每轮的全局参数都存下来画轨迹跑到 40 轮直接 OOM其实只想要 loss 曲线没必要留参数。5.3 现象同样的配置跑两次结果差 3 个点联邦学习里随机源比普通训练多一个数量级数据切分、每轮客户端采样、每个客户端的 batch 顺序、参数初始化四个地方都要种子。只torch.manual_seed(42)是远远不够的。我的做法是写一个set_seed(seed)里面同时处理random、numpy、torch、torch.cuda并且在partition.py和主循环里各用一个np.random.default_rng(seed offset)独立实例而不是共享全局随机状态。另外DataLoader的worker_init_fn、torch.use_deterministic_algorithms(True)这两处做正式对比实验时该开就开慢一点总比结论不可信强。5.4 现象聚合后的模型准确率比任何一个客户端本地模型都低这种情况多见于用均匀加权weight_by: uniform跑样本数极不均衡的划分。样本数 500 的客户端和样本数 50 的客户端在均匀加权下话语权一样而小客户端本地训练几乎没有进展它的参数接近随机初始化平均进去就是在稀释。先看 3.3 的自检输出里min/max的比值差距超过 10 倍就老老实实按样本数加权。另一个隐蔽原因是 BatchNorm 的 buffer 被错误处理回看 4.2 那段代码确认num_batches_tracked没被加权。还有一种可能是评估时忘了model.eval()Dropout 和 BN 都在训练模式下跑评估数字天生偏低这种低级错误我至少犯过两次。5.5 现象模拟结果很好但不敢往真实场景上搬这一条不是 bug是认知上的坑。本地模拟把通信压缩到了函数调用没有带宽限制、没有延迟、没有客户端异步到达也没有真正的隐私保护——你上传的明文参数在模拟器里没人在意但在真实联邦里那就是攻击面。所以看这份资料的成果时正确的心态是把它当作「算法验证平台」而不是「系统原型」。想往真实场景靠至少在模拟器里补上三件事记录每轮的参数量sum(v.numel() for v in state.values()) * 4字节来估算通信成本给客户端加随机延迟模拟 straggler把明文参数换成加噪后的版本看看精度掉多少。这三件事都不难但跳过它们模拟出来的漂亮曲线会给你错误的信心。6. 二次开发把模拟器改成你自己的算法试验台6.1 换聚合器策略层只有两个函数要动改造这个工程最划算的入口是strategy.py。它只有一个约定输入一组客户端结果和上一轮全局参数输出新的全局参数。想试自己设计的聚合算法写好函数后在main.py里按cfg.strategy[name]分发即可客户端和数据的代码一行都不用改。比如做「按客户端 loss 反向加权」这种思路核心就是把weights换掉def loss_aware(results, global_state, weight_bysamples): # 本地 loss 越低的客户端说明它离全局目标越近给它更大权重 inv [1.0 / (r[loss] 1e-8) for r in results] scale sum(inv) return fedavg(results, global_state, uniform, manual_w[w / scale for w in inv])给fedavg加一个可选的manual_w参数就能复用它里面那套 float64 累加和整型 buffer 的处理不用重写一遍。改造时唯一要守住的是「权重归一化后的和必须为 1」否则聚合出来的参数尺度会漂模型会以肉眼可见的速度发散。6.2 验证改动三条对照实验比调参更重要改完算法别急着跑全量轮次。我固定会做三组对照跑完这三组基本能判断改动是有效还是只是随机波动对照实验配置期望结果单客户端全集num_clients1数据不切分准确率是这套模型和超参的上限其他结果不该超过它IID 基线schemeiid其余不变应该接近上限如果差距大说明联邦逻辑本身有问题非 IID 基线schemedirichletalpha 保持与你的实验一致作为你新算法的唯一起点所有比较都对它做第一组最容易被跳过但它能直接告诉你「问题出在联邦还是出在模型」。有次我把第一组补上才发现上限只有 62%那我折腾半天的聚合算法从 55% 调到 57% 其实毫无意义——瓶颈在模型容量。第二组是检查链路正确性的IID 下 FedAvg 理论上应该和集中式训练很接近差得远就说明聚合、采样或评估里有一环写错了。第三组才是你真正的对照组注意 alpha 和 seed 必须和实验组完全一致否则前面所有的种子控制都白做了。从那以后我每次动聚合逻辑都强制先把「单客户端全集」那条上限跑一遍再谈优化不然很容易对着一个注定 60 分的模型调出 58 分的「改进」。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站