简介:一套基于联邦学习与NSL-KDD数据集的网络入侵检测Python项目源码及运行指南,属于经导师指导并认可的高分项目(评审98分),适合计算机相关专业学生用于课程设计、期末大作业,以及想要进行项目实战的机器学习/网络安全学习者。资源共63个文件,压缩包大小约26.19MB,包含12个Python源代码、26个Pyc编译文件、10个Txt说明文档、模型权重、CSV数据、结果对比PNG以及带GUI界面的数据集等,覆盖数据预处理、模型构建、联邦训练与测试等模块。已有88人学习下载。项目将联邦学习与NSL-KDD数据集结合,既演示了如何在隐私保护前提下进行分布式训练,也提供了带图形界面的数据操作方式;借助附带的运行说明、模型文件和对比图,学习者可以快速复现入侵检测实验,深入理解从数据准备到模型部署的完整流程,为网络安全与联邦学习方向的实践提供扎实参考。
1. 联邦学习与NSL-KDD做网络入侵检测:先别急着跑代码,想清楚这三件事
用联邦学习与NSL-KDD数据集做网络入侵检测,本质上是把两个成熟技术接到一起:联邦学习解决流量日志“数据不出域”的合规诉求,NSL-KDD给出一道能反复验证的基准题,Python则是把它们黏在一起的胶水。这几年相关高分项目几乎都从集中式往联邦上靠,因为评分重点已经从“检测准不准”变成了“数据隔离条件下还能不能准”。拿到压缩包先别急着解压跑训练,先想清楚三件事:数据是天然按节点分片还是要人工模拟Non-IID;模型做二分类还是五分类;指南里说的联邦是本机模拟还是真多机通信。想清楚后再动代码,每个参数都能说出改它的理由。这篇笔记写给不满足于“能运行”的Python从业者和安全方向同学,重点放在可复现步骤和踩坑记录上。
2. 为什么要联邦化:FedAvg机制、数据不出域的价值与NSL-KDD的基准定位
2.1 联邦学习在入侵检测里的角色:客户端训练什么、服务器聚合什么
入侵检测的常规做法是把流量特征汇集到一个训练中心,在全部数据上训一个全局模型。问题在于安全日志是最敏感的数据之一,跨部门、跨机房、跨公司的流量特征往往不能直接汇总,别说原始报文,就连统计特征也要走审批。联邦学习在这里的角色很直接:各个节点用自己的日志训练本地模型,训练完只上传模型权重,原始特征永远留在本地;服务器把权重按某种策略加权平均后下发,反复迭代。
这里最常用的聚合策略就是FedAvg(联邦平均)。每轮通信,服务器把当前全局权重广播给参与节点,每个节点在本地数据上做几个epoch的梯度下降,再把更新后的权重和本地样本量一起传回,服务器按样本量占比加权求平均,得到下一轮的全局权重。整套机制里没有原始数据流动,传输的只是跟模型大小等量的一堆张量,这也是联邦学习能过合规审查的核心原因。
在入侵检测场景里有个容易被忽略的点:这种训练方式天然适配“分公司/节点/机房”的组织结构。每个节点的流量分布不同,有的节点DDoS流量多,有的节点主要是扫描探测,联邦框架并不要求各节点数据同分布,它只要求各节点把梯度朝各自数据的方向推一步,整体模型再在“每个方向的平均值”上迈一步。
实际项目里,联邦学习还能叠一层安全聚合(Secure Aggregation),让服务器无法从收到的梯度反推某个节点的样本信息。NSL-KDD规模小,没必要上这个复杂度,但如果以后把这套代码接到真实流量上,安全聚合就是必须考虑的下一步。做课程项目时在报告里提一句“联邦只解决了数据不出域,梯度本身仍可能泄露分布信息”,导师会认为你理解到了这一层的边界,比空写“隐私保护”要有说服力。
2.2 从KDD99到NSL-KDD:去冗余之后,测评分数才真实
标题里的NSL-KDD是KDDCUP99数据集的修正版,官方提供Train+、Test+和Test-21三份文件,每行是一条网络连接记录,共41个特征外加一个标签。它与旧版KDD99最大的差别在于去掉了大量重复记录:旧版训练集里同一类记录重复几十万条,模型记性好一点的都能靠背答案拿高分,而NSL-KDD的训练集规模和重复度都被控制,测试集还额外按难度分了级。因此拿NSL-KDD报告的数字更接近真实泛化能力,不再是一种“背题考高分”。
| 对比项 | KDD99 | NSL-KDD |
|---|---|---|
| 训练集冗余记录 | 大量重复,易被模型记忆 | 已去重,训练集规模约12万条 |
| 测试集难度分级 | 无 | 分低/中/高三档 |
| 实验可复现性 | 切分混乱 | 官方切分明确,训练/测试文件固定 |
| 联邦实验适配度 | 不推荐,数据分布失真 | 常用于Non-IID与联邦消融实验 |
这个数据集之所以到今天还在被各类项目采用,是因为它足够小、有现成的二分/多分标签、还能和旧版KDD99贯通,做论文做毕设都有参照系。指望它替代真实流量不现实,但把联邦方案先在NSL-KDD上验证一轮,再迁移到自己的流量特征上,是一条性价比很高的技术路线。注意官方三份文件经常被重新打包发布,你拿到的csv可能是带表头、不带表头、多一列难度系数三种版本之一,属于正常现象,本文第3章会给出对应处理。
2.3 二分类还是五分类:检测率、误报率与每类召回
NSL-KDD的标签可以归成正常加上四类攻击:DoS(拒绝服务)、Probe(扫描探测)、R2L(远程到本地)、U2R(提权攻击)。二分类只管正常和异常,最简单,训练快,适合快速验证联邦链路通不通;五分类要求模型分辨攻击类型,难度明显上升,尤其是R2L和U2R的样本量极少,经常只占总量的百分之几,模型天然偏向多数类。
实战里我不会只看总体准确率。入侵检测更真实的指标是检测率(也就是召回率,攻击样本里被揪出来的比例)和误报率(正常样本里被冤枉的比例)。在联邦场景里,这两个指标还要拆到每个客户端上去看,因为全局准确率高完全有可能掩盖某个节点上检测率为零的翻车情况。我一般会固定评估五分类,至少要在报告里给每类召回率,不然项目答辩时很难回答“你的模型到底能不能防住U2R”这种问题。
多分类在联邦训练中有一个额外负担:各节点的类别分布不一致会导致所谓的客户端漂移。一个节点全是R2L样本,它的本地模型更新方向就偏向R2L那条梯度;另一个节点只有normal和probe,方向就完全不同。这两种方向平均在一起,全局模型可能两头都学不好。所以第5章里的Non-IID模拟和第3章的数据分片方式,是决定这个项目成败的关键步骤,不能跳过。
最后谈一下Python在这个方向几乎是唯一选项的原因:PyTorch处理神经网络训练、pandas做特征表、sklearn出混淆矩阵和分类报告,三个库一条链路能打通整个实验。对Python刚入门的人,这个项目反而比纯工程项目友好,因为跑通最小例子的代码量不到两百行;要做的杂活不过是先装好Python环境,再把numpy、pandas、scikit-learn、torch四个库用pip装到最新稳定版而已。
3. 数据预处理落地:用Python把NSL-KDD的41维特征变成能喂PyTorch的张量
3.1 读入CSV与字段梳理:先分清数值特征和符号特征
读文件这一步用pandas一行就能搞定,但列名要跟官方顺序对齐,否则后续编码全乱。NSL-KDD的csv没有表头,需要手动指定列名。读入之前先确认Python环境就绪,缺库就pip install numpy pandas scikit-learn torch一条命令装齐,Windows和Linux下没有区别。
import pandas as pd import numpy as np FEATURES = [ 'duration', 'protocol_type', 'service', 'flag', 'src_bytes', 'dst_bytes', 'land', 'wrong_fragment', 'urgent', 'hot', 'num_failed_logins', 'logged_in', 'num_compromised', 'root_shell', 'su_attempted', 'num_root', 'num_file_creations', 'num_shells', 'num_access_files', 'num_outbound_cmds', 'is_host_login', 'is_guest_login', 'count', 'srv_count', 'serror_rate', 'srv_serror_rate', 'rerror_rate', 'srv_rerror_rate', 'same_srv_rate', 'diff_srv_rate', 'srv_diff_host_rate', 'dst_host_count', 'dst_host_srv_count', 'dst_host_same_srv_rate', 'dst_host_diff_srv_rate', 'dst_host_same_src_port_rate', 'dst_host_srv_diff_host_rate', 'dst_host_serror_rate', 'dst_host_srv_serror_rate', 'dst_host_rerror_rate', 'dst_host_srv_rerror_rate' ] def load_nsl_kdd(path): df = pd.read_csv(path, header=None, names=FEATURES + ['label']) return df逻辑说明:FEATURES按官方文档顺序列出41个特征名,read_csv时用header=None跳过默认表头,names参数把列名挂上去,第42列在代码里命名为label。这里有一个本地容易踩的坑:有的公开渠道放的NSL-KDD文件多了一列难度系数,直接读会报“列数不匹配”,这时候给names多加一个'level',或者读进来后用drop列处理掉。
参数说明:path是训练集或测试集文件路径;函数返回的DataFrame要保持行顺序不变,因为后面做Non-IID分片时会用它来回放标签索引。csv分隔符是英文逗号,文本列里出现引号也没关系,pandas会自动处理。训练集用load_nsl_kdd('KDDTrain+.csv'),测试集用load_nsl_kdd('KDDTest-21.csv'),文件名以你实际解压出来的为准。
3.2 类别特征one-hot与数值归一化:先编码还是先缩放
41维里有三个符号特征:protocol_type(协议类型,tcp/udp/icmp三种)、service(服务类型,约70种)、flag(连接状态标志,约11种)。剩下的38维都是数值或比率字段。符号特征不能直接喂给线性层,常见做法是转成one-hot;service种类太多,全量one-hot会把维度顶到110以上,我一般先按出现频率筛出前20个,其余并成other这一类,把维度压在可控范围。
from sklearn.preprocessing import StandardScaler TOP_SERVICE = 20 def encode_and_normalize(df, scaler=None, top_service=None, fit=False): label_int = df['label'].map(build_label_map()) label_int = label_int.values.astype(np.int64) # 三个符号特征统一转 one-hot,前缀区分来源 proto = pd.get_dummies(df['protocol_type'], prefix='proto') flag = pd.get_dummies(df['flag'], prefix='flag') if fit: # 只在训练集上统计高频 service,避免测试集信息泄漏 top_service = df['service'].value_counts().index[:TOP_SERVICE].tolist() df = df.copy() df['service'] = df['service'].apply( lambda s: s if s in top_service else 'other') service = pd.get_dummies(df['service'], prefix='svc') # 剩余38列都是数值型,注意先转换类型再缩放 numeric_cols = [c for c in FEATURES if c not in ('protocol_type', 'service', 'flag')] numeric = df[numeric_cols].astype(np.float32).values if fit: scaler = StandardScaler().fit(numeric) elif scaler is None: raise ValueError('fit=False时必须传入训练集fit好的scaler') numeric_scaled = scaler.transform(numeric) x = np.concatenate([ numeric_scaled, proto.values.astype(np.float32), flag.values.astype(np.float32), service.values.astype(np.float32) ], axis=1) return x, label_int, scaler, top_service逻辑说明:encode_and_normalize做的事是:符号特征转one-hot、数值特征用StandardScaler做z-score归一化、最后按列方向拼接成一个大矩阵。fit参数决定这次调用是“拟合scaler并统计service”还是“复用训练集返回的scaler和top_service”。用astype(np.float32)做数据类型转换是为了跟PyTorch默认的float32对齐,顺便把内存减半。
参数说明:scaler和top_service的传递是这套代码的命门。训练集上传入fit=True拿到scaler和top_service,测试集上必须传fit=False并原样代入,否则训练和测试的特征分布不一致,测试集准确率虚高,这在第5章避坑中会细讲。TOP_SERVICE不是固定值,看重区分度可以提到30,看重维度稀疏可以降到10,改了之后注意模型的in_dim要跟着变。数值特征这一列最容易出问题的是num_outbound_cmds,在KDD99里全是0,在NSL-KDD里接近全0,StandardScaler对它做z-score得到一堆接近0的小数,不会影响训练,但别好奇地去删列,删了维度就乱了。
3.3 标签映射:把攻击名归并成五分类
NSL-KDD的原始标签是具体的攻击名,需要归并才能做五分类。归并逻辑很简单,normal是一类,其余按DoS、Probe、R2L、U2R四大族归类。
DOS = {'back', 'land', 'neptune', 'pod', 'smurf', 'teardrop'} PROBE = {'ipsweep', 'nmap', 'portsweep', 'satan'} R2L = {'ftp_write', 'guess_passwd', 'imap', 'multihop', 'phf', 'spy', 'warezclient', 'warezmaster'} U2R = {'buffer_overflow', 'loadmodule', 'perl', 'rootkit'} def build_label_map(): label_map = {'normal': 0} for idx, group in enumerate([DOS, PROBE, R2L, U2R], start=1): for attack in group: label_map[attack] = idx return label_map逻辑说明:build_label_map返回的字典把normal映射为0,四个攻击族映射为1到4。后面encode_and_normalize里用的就是这个字典,如果只想做二分类,把字典改成{'normal': 0}后其他所有攻击归为1即可,但那样会丢掉攻击类型信息,联邦轮次里各个类别的差异化学习就看不清了。
参数说明:这个映射表要覆盖官方所有攻击名。如果你拿到的NSL-KDD文件里有字典之外的标签,encode_and_normalize的map会返回NaN,训练时交叉熵直接报错。保险做法是在map调用后加一行assert label_int.notna().all()。测试集里出现的攻击名不一定在训练集里出现过,这属于NSL-KDD的刻意设计,联邦测试时模型对“没见过”的攻击名没有先验,跨类别泛化会直接体现在召回率上,别把这个当成bug。
3.4 Non-IID分片:用Dirichlet分布模拟各节点数据不均
真实联邦场景里,各节点的数据分布从来不是均匀的:有的节点全是web服务,有的节点只跑数据库协议。要在本地验证联邦算法对“分布不均”的容忍度,就用Dirichlet分布来控制每个类别在各客户端上的占比。
def non_iid_split(labels, n_clients=5, alpha=0.5, seed=42): """按Dirichlet(alpha)把样本分给n_clients个客户端。 alpha越小各客户端类别分布越倾斜,alpha=100时接近均匀。""" rng = np.random.default_rng(seed) n = len(labels) client_ids = np.zeros(n, dtype=int) for cat in np.unique(labels): idx = np.where(labels == cat)[0] if len(idx) == 0: continue p = rng.dirichlet([alpha] * n_clients) counts = rng.multinomial(len(idx), p) counts[-1] = len(idx) - counts[:-1].sum() order = rng.permutation(idx) start = 0 for cid, cnt in enumerate(counts): client_ids[order[start:start + cnt]] = cid start += cnt return client_ids逻辑说明:逐类别处理,每个类别下的样本按Dirichlet比例随机分给各客户端。rng.multinomial保证每个类别的样本全部被分到某个客户端,不会因为浮点取整丢样本。返回的client_ids和原始数据行一一对应,后面训练循环里用client_ids == cid做布尔索引切片就行,用不着复杂的数据集类。
参数说明:alpha是出镜率最高的联邦实验参数。alpha=0.5代表比较极端的Non-IID,每个客户端可能只拥有两三个类别的样本;alpha=100则几乎均匀。课程项目建议两端都跑一遍,在非均匀分布下模型准确率掉几个点属于正常现象,报告里把这个趋势写清楚反而是加分项。这里有个数组索引的小技巧:如果想检查每个客户端分到了什么,用np.bincount(client_ids)配合labels[client_ids == cid]看一眼类别直方图,比打印几十行日志直观得多。
4. Python实现FedAvg:模型定义、客户端训练与服务端加权聚合
4.1 网络结构选择:MLP足够,把复杂度留给联邦机制
网络结构不需要多深。入侵检测的特征虽然维度不低,但大部分是归一化后的统计量,三到四层全连接加Dropout就能拟合得很好。不建议在这个数据集上上CNN或Transformer,不是用不了,而是模型变大之后联邦通信开销线性上涨,训练时间拉长,项目收益却不明显。
import torch import torch.nn as nn class NIDSMLP(nn.Module): def __init__(self, in_dim, num_classes=5): super().__init__() self.net = nn.Sequential( nn.Linear(in_dim, 64), nn.ReLU(), nn.Dropout(0.2), nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, num_classes) ) def forward(self, x): return self.net(x)逻辑说明:用64宽度的两层MLP,刻意不用BatchNorm。这里是有意为之——联邦聚合的时候,BN层里的running_mean和running_var不能像普通权重那样直接加权平均,一旦引入就得在聚合时做特殊处理,很多新手在这里栽跟头。用Dropout代替BN,聚合代码就只剩weight和bias的加权求和,复杂度低一截。
参数说明:in_dim在第3章预处理后得到,一般九十多维;num_classes二分类传2、五分类传5。Dropout取0.2是为了在几十轮联邦训练中既提供一点正则,又不至于让每轮本地更新太弱。如果你发现训练集上loss收敛慢,先把Dropout调到0.1试试,比动网络层数见效快。
4.2 客户端本地训练:一个类封装一轮本地更新
把每个参与方封装成一个Client对象,内部只做一件事:接收全局权重,在本地数据上训几个epoch,返回新权重。这个设计让后续从单机模拟切到真联邦时改动量最小。
class Client: def __init__(self, cid, x, y, device='cpu', lr=1e-3): self.cid = cid self.x = torch.tensor(x, dtype=torch.float32, device=device) self.y = torch.tensor(y, dtype=torch.long, device=device) self.device = device self.lr = lr def local_train(self, global_state, epochs=2, batch_size=64): model = NIDSMLP(self.x.shape[1]).to(self.device) model.load_state_dict(global_state) optimizer = torch.optim.Adam(model.parameters(), lr=self.lr) loss_fn = nn.CrossEntropyLoss() dataset = torch.utils.data.TensorDataset(self.x, self.y) loader = torch.utils.data.DataLoader( dataset, batch_size=batch_size, shuffle=True) model.train() for _ in range(epochs): for xb, yb in loader: optimizer.zero_grad() out = model(xb) loss = loss_fn(out, yb) loss.backward() optimizer.step() return model.state_dict(), len(dataset)逻辑说明:Client对象保存一个客户端的数据和标签,local_train接收服务器下发的全局权重,在本地数据上做几个epoch的梯度下降,最后返回更新后的state_dict和样本量。关键点是每次训练都从global_state开始,而不是从上一轮本地状态继续,这保证联邦学习收敛的语义是“围绕全局模型做局部修正”,而不是各节点在自己的模型上一路跑到黑。定义函数和类的时候把变量名写清楚,后面跑消融实验时就不用每次重读代码。
参数说明:epochs对应本地训练轮数,是联邦里最敏感的超参,一般取1到3。取20以上的话每个客户端都过拟合到自己的局部数据上,聚合出来的全局模型反而变差。batch_size用64,lr用1e-3,这两个参数和集中训练差别不大,但如果Non-IID程度高,lr降到5e-4更稳。device参数默认cpu,数据量大或要跑多轮实验时改成'cuda'即可,注意把x和y都放到同一个设备,否则torch会报device mismatch。
4.3 服务端聚合:FedAvg的加权平均实现
服务端聚合是整个联邦学习的数学核心,FedAvg的原理一句话就能说清:按各客户端本地样本量占比,对模型参数做加权平均。
def fed_avg(global_model, client_states, client_sizes): new_state = {} for k, v in global_model.state_dict().items(): new_state[k] = torch.zeros_like(v) total = sum(client_sizes) for state, size in zip(client_states, client_sizes): weight = size / total for k, v in state.items(): new_state[k] += v * weight global_model.load_state_dict(new_state) return new_state逻辑说明:先按key初始化全零张量,然后每个客户端的权重按样本数占比累加进去。torch.zeros_like保证跟原模型张量形状一致,load_state_dict不会报错。这个实现只支持weight和bias这类普通张量,所以4.1节特意避开了BN层,否则这里还要写BN统计量的合并逻辑。
参数说明:client_sizes来自每个Client返回的len(dataset)。要做的是加权平均而不是简单平均,如果某个客户端样本量是另一个的100倍,前者的梯度方向会在聚合结果里占绝对主导。课程项目里的典型错误是只想“公平”用简单平均,结果小客户端的数据直接被淹没,聚合出来的模型对大节点过拟合。如果你希望加入权重衰减或模型剪枝,在这个函数里对new_state统一操作即可,位置就在load_state_dict之前。
4.4 主训练循环与端到端跑通
把前面的块拼起来,主循环只需要做三件事:按client_ids切片、调用local_train、调用fed_avg。为了能看到收敛过程,每10轮打印一次测试集准确率。
def evaluate(model, x, y): model.eval() x_t = torch.tensor(x, dtype=torch.float32) with torch.no_grad(): pred = model(x_t).argmax(dim=1).numpy() return np.mean(pred == y) def run_federated(x_train, y_train, client_ids, x_test, y_test, n_rounds=60, epochs_per_client=2, n_clients=5): in_dim = x_train.shape[1] global_model = NIDSMLP(in_dim) global_state = global_model.state_dict() for rnd in range(1, n_rounds + 1): states, sizes = [], [] for cid in range(n_clients): mask = client_ids == cid client = Client(cid, x_train[mask], y_train[mask]) state, size = client.local_train(global_state, epochs=epochs_per_client) states.append(state) sizes.append(size) global_state = fed_avg(global_model, states, sizes) if rnd % 10 == 0: acc = evaluate(global_model, x_test, y_test) print(f"round {rnd}: test acc = {acc:.4f}") return global_model逻辑说明:run_federated在循环里依次执行切片、训练、聚合三步。这里是在单机进程内顺序执行所有客户端,模拟的是服务端逐个接收客户端上传的过程;真实分布式环境里这些调用会被网络通信替代,但聚合的数学完全一致。evaluate函数在测试集上做前向推理,argmax取预测类别后和真实标签比均值。
参数说明:n_rounds是联邦通信轮数,这个项目里50到100轮基本收敛,再多收益很小。epochs_per_client=2是计算量与精度的折中,每轮每个客户端在本地只跑两遍数据。n_clients要和非IID分片时的数字保持一致,不一致时布尔索引就会漏掉一部分样本。跑完看一下最后10轮的准确率波动,波动超过±0.5%说明还没收敛,把n_rounds往上加到100即可。
5. 训练避坑与排查:灾难性遗忘、模型漂移与测试集选择的四个翻车现场
5.1 坑一:训练/测试特征不一致,全局准确率虚高
现象:训练完在KDDTest+上一测,准确率接近99%,换成KDDTest-21直接跌到91%,同一个模型两份测试集差出一大截。
原因:一个是测试集选择的差异,另一个是更隐蔽的预处理泄漏——有的教程让scaler在训练集和测试集整份数据上一起fit,测试集的信息已经被偷走了。NSL-KDD的KDDTest+本身包含大量与训练集重复的连接,模型见过类似样本,分数天然偏高;Test-21去掉重复后才是真实泛化水平。
解决:报告数字一律用KDDTest-21,并且scaler严格只在训练集上fit。第3.2节里fit参数就是为这个设计的,测试集上必须传fit=False和训练集返回的scaler。做对比实验时固定这套流程,防止不同实验间的预处理不一致影响结论。有一个小技巧:如果某轮实验得到的结果异常高,先检查是不是把测试集混进fit了,这是这个项目里最容易犯也是最不容易发现的错误。
5.2 坑二:本地epochs设太大,聚合后模型漂移
现象:本地训练20个epoch,每个客户端本地准确率都98%以上,聚合后的全局模型在测试集上只有70%,还不如只训2个epoch的结果。
原因:每个客户端在本地数据上反复迭代,模型被拉向本节点的局部最优方向,多个方向的平均变成了一个四不像。联邦学习论文里管这叫client drift,本地训练步数越多漂移越严重。
解决:把epochs降到1到3。这是一种“后悔药”式的参数修正:如果发现已经跑了一轮epochs=20的实验,不用重写代码,把run_federated(epochs_per_client=2)重跑一遍即可。如果降到1仍然不稳,可以在客户端loss里加一个近端项惩罚本地权重偏离全局权重,这个思路对应FedProx:在loss上再加一项mu/2 * ||w - w_global||^2,mu取0.01到0.1之间。我一般先试epochs=2,不稳再加近端项,不急着动lr。
5.3 坑三:Non-IID分片后训练发散,loss持续增大
现象:alpha=0.5的Non-IID分片下,全局和本地loss都不下降,有时直接变成NaN,alpha=100时一切正常。
原因:一个客户端只有一种攻击类型时,它的梯度方向跟全局方向几乎垂直,几轮平均下来形成震荡;如果再用Adam的默认lr,个别客户端梯度炸了就NaN了。
解决:两个改动一起做。一是把学习率从1e-3降到3e-4,二是在每个客户端的交叉熵上做类别加权,让样本量少的R2L、U2R类别即使在一个客户端上出现次数很少也不至于被遗忘。也可以在分片时先用alpha=1跑通链路,再逐步调低alpha观察模型退化曲线,这个曲线本身就是项目报告里很好的素材。要记住在大规模联邦里,Non-IID下的发散可能来自单个客户端的病态梯度,打印每个客户端各自的loss,而不是只看聚合后的全局loss,能更快定位到是谁拖垮了全局。
5.4 坑四:灾难性遗忘——轮次推进后旧攻击类型“突然不会了”
现象:第20轮时五分类的每类召回都正常,第40轮开始U2R的召回率从80%掉到30%,后续轮次再也没有恢复。
原因:这是联邦学习里灾难性遗忘的典型表现。全局模型在新轮次里被大多数客户端的大类样本主导,少数类别(尤其样本量极小的U2R和R2L)的梯度信号被淹没,模型把参数空间里原先识别U2R的区域逐渐覆盖掉。集中式训练同样有遗忘问题,但联邦环境里各客户端数据不平衡让遗忘来得更快更隐蔽。
解决:三个手段按成本从低到高排列。第一,每轮聚合后对每个类别单独算召回,一旦某类比上一轮跌超过5个点就回滚到上一轮权重再小步重训,给项目加一个简单的“刹车机制”。第二,在服务端保存一份验证集,每轮用验证集挑出最优round的权重,相当于给模型存了后悔药,训练结束后加载最优权重而不是最后一轮权重。第三,如果类别不平衡是常态,考虑在客户端本地做少数类过采样,或者干脆改用二分类加一个专门检测R2L/U2R的小模型,别让一个模型背所有锅。我自己做实验时最常用的是第二条,简单可靠,而且在答辩时可以直接展示“最优权重出现在第几轮、为什么”。
关于评估指标还有一条硬规矩:五分类下只报accuracy是不够的,至少要带上每类recall和误报率,否则灾难性遗忘根本不会被你发现。用sklearn的classification_report一行就能输出全部类别精确率、召回率、F1,画混淆矩阵时如果类别标签挤成一团,把figure size调大到(8, 6)以上就好。联邦场景下我更建议额外打印每个客户端在测试集上的单独表现,因为全局指标好看不能掩盖某个节点上的检测失效,这一点在答辩时也是容易被追问的角落。
6. 从模拟到真联邦:消融实验、通信开销与三行改造清单
模拟毕竟是模拟,真联邦在网络不稳定的真实环境里还要面对掉线、传输延迟和梯度过期问题。做验证时有三个技巧值得用。
第一个是消融实验。把集中式训练、联邦5客户端、联邦10客户端三组跑在同一份预处理代码下,记录测试准确率、每类召回率和训练耗时。集中式可以看作联邦的上限参照,两个联邦实验的差距体现数据分片带来的信息损失。我习惯把结果画成一张对比表:集中式94.2%、联邦5客户端92.8%、联邦10客户端91.5%这种形式,比文字描述“联邦效果还行”有说服力得多。
第二个是通信开销的量化。单个模型参数量乘以2(上传加下载)再乘以通信轮数,就能估算传输字节量。以文中这个MLP为例,in_dim约90、两层64宽,参数量大约一万出头,float32一个参数4字节,一轮通信约80KB,60轮不到5MB。这个数字放进报告里,就能正面回应“联邦学习到底多耗带宽”的质疑。
第三个是迁移真联邦的改造清单:把Client里的数据加载换成远程文件路径、把local_train返回值序列化后走消息队列传输、服务端fed_avg换成异步聚合以容纳掉线节点。常见做法是直接用Flower这类联邦框架改配置,或者自己拉一套gRPC,两者都能保留第4章代码本体的八到九成。
我做这个方向时踩过最大的坑是默认“本机模拟的结果等于分布式结果”,真搬到多机后才发现网络延时会放大每个round的耗时,原来用60轮训练的代价在真环境下翻了不止三倍。后来我在模拟阶段就把round数压到40,把epochs压到1,用更多客户端替代更多轮次,反而拿到更稳定的结果。把“通信轮次”当成跟学习率一样的超参去调,是联邦项目跟普通深度学习项目最大的习惯差异。这个思路贯穿了我后来所有的联邦实验,希望这些记录对你有所帮助。
本文还有配套的精品资源,点击获取