简介:一套基于联邦学习的高校学生成绩预测Python项目,采用Streamlit搭建可视化平台,适合作为毕业设计、课程设计或科研训练的参考。面向计算机、人工智能、数据科学等相关专业的初学者与进阶者,也适合需要完成课程作业或毕业设计的学生。压缩包共55个文件,以Python源码(18个py)为主,另含28个pyc字节码文件、7个CSV数据文件、1个说明文档和1张混淆矩阵图,整体仅2.25MB,目录按算法与工具模块做了清晰划分。项目覆盖FedProx、FedRep、Ditto、APFL与L2GD等多种联邦学习算法,包含数据加载、模型训练、参数配置、通信辅助等模块,能够完整体现从数据处理到模型评估的流程,并提供可视化界面方便查看预测结果。目前已有671人学习下载,适合希望快速上手联邦学习并用于教育数据场景的开发者。
1. 联邦学习遇上高校成绩预测:这个组合到底解决什么问题
把几所高校的成绩数据合并到一起训练,模型通常更好,但这件事在现实中基本办不成:学生成绩属于个人敏感信息,跨校共享数据面临合规压力,各校信息化系统也不会把学生明细开放给外部。如果各校自己训自己的,样本量小,模型泛化能力又不够。标题里的方案走的是第三条路:联邦学习让数据留在各校本地,只交换模型参数,协作训练一个全局模型;配套的 Streamlit 平台把训练过程、指标对比和结果导出做成可视化界面。对教务部门做学业预警、对有课题任务的研究生,或者只是想验证联邦学习在真实表格数据上效果的开发者,这条路径能直接落地,而且跑一遍的成本比想象中低得多。
2. 为什么成绩预测要选联邦学习:数据孤岛与 FedAvg 的工作机制
2.1 集中式训练的两个死穴:数据孤岛和数据合规
成绩预测是一个标准的回归问题,常用特征包括每周学习时长、出勤率、前序课程均分、作业平均分等。特征并不多,真正的瓶颈在样本量。一所学校、一个学院能拿到的有效样本往往只有几千条,按成绩段、专业、学期一切分,每个子组只剩几百条,模型很容易过拟合,训练集上表现不错,换一届学生就明显退化。把多校数据集中起来能缓解这个问题,但成绩数据落到第三方服务器这件事,光评审流程就够走好几个月,更别说很多学校的数据根本不允许出内网。
这不是一个技术难题,是一个制度约束。只要数据不能出域,集中式训练就走不通。于是业内反复提同一句话:数据不动模型动。联邦学习把训练过程搬到了数据所在的地方,各参与方只在本地用数据更新模型,再让协调方把更新聚合起来,整个过程中没有人接触原始明细,跨机构协作才变得现实。做学业预警这类应用,数据越多人用越能发现共性规律,但隐私和合规这条线不能碰,联邦学习恰好把这两件事解开了。
2.2 FedAvg 机制拆解:本地训练加参数聚合
最常见的联邦学习算法是 FedAvg。第一次通信前,服务器生成一个初始全局模型,或者由某个参与方贡献一个预训练模型。之后的每一轮重复三个动作:服务器把当前全局参数下发到选中的客户端;客户端用本地数据在本地模型上训练若干个 epoch,然后只把新的模型参数传回去;服务器收集本轮所有客户端的参数,按每个客户端的样本量做加权平均,得到下一轮全局参数。下发的、回传的、聚合的都是参数,原始数据全程留在客户端本地。
加权平均这一点值得展开。假设 3 个客户端样本量分别是 2000、800、500,聚合时各自的权重就是 2000/3300、800/3300、500/3300。样本量大的客户端对全局模型的贡献更大,这符合直觉,因为它学到了更可靠的数据模式。在 PyTorch 里实现就体现在对 state_dict 里的每个 key 做同样的加权求和,第 3 章会给到完整代码。如果你想在论文里描述清楚,这一步就是 FedAvg 和普通分布式训练最本质的差别:分布式训练追求步调一致,联邦学习允许各客户端异步、异构、本地多跑几步。
参数平均为什么能生效,直觉解释是这样:每个客户端在自己的数据上做的更新,相当于朝自己最优点方向迈出一步。如果各客户端数据分布接近,大家的最优点挨得不远,平均后的方向就是共性方向;如果分布差异过大,本地更新会把模型推向各自偏科的最优点,平均之后互相抵消,这就是后面要说的模型漂移。联邦学习的大部分调参动作,本质上都是在平衡"本地学到更多"和"别跑离全局方向太远"这两件事。
2.3 选型边界:什么时候上联邦学习划算
联邦学习不是万能药,边界要在一开始说清楚。数据量足够、可以集中训练的场景,直接做集中式,省事且效果通常更好。各参与方数据分布严重不一致,比如一所学校全是基础课成绩、另一所全是专业课成绩,任务目标统一不了,硬上联邦学习可能连收敛都保证不了。真正适合上联邦学习的场景有两个特征:数据不能出域,且各参与方在预测目标上是一致的。多校联合做学业预警正好满足这两条,各校都关心同一个问题:这个学生期末会不会掉队。
还有一个实际考量是通信成本。图像、语言模型动辄上亿参数,每轮通信传输量很大;成绩预测这种表格任务,模型是几千参数的小 MLP,通信成本几乎可以忽略。从入门角度说,联邦学习结合表格数据的组合,能让你把注意力集中在算法本身,而不是花大量的时间解决通信框架和工程问题。标题里用 Streamlit 搭可视化平台,也正是因为整个方案够轻,能在一台普通机器上跑完整流程。
3. 数据准备与联邦训练核心代码:从一份 CSV 到 FedAvg 跑通
3.1 字段选择与归一化:成绩预测的特征工程底线
先约定数据的模样。一份典型的学生成绩明细 CSV,至少包含学号、学校编号、每周学习时长、出勤率、前序课程均分、作业平均分、期末成绩。做成绩预测时,学号不用进模型,学校编号是联邦切分的依据,真正进入特征的通常是后面四列。特征不是越多越好,学生行为类的特征比家庭背景类更稳定,也更容易在不同学校之间对齐。
| 字段名 | 含义 | 类型 |
|---|---|---|
| study_hours | 每周学习时长 | 数值,小时 |
| attendance_rate | 出勤率 | 数值,0 到 1 |
| prior_gpa | 前序课程均分 | 数值,0 到 100 |
| assignment_avg | 历次作业平均分 | 数值,0 到 100 |
| final_score | 期末成绩,预测目标 | 数值,0 到 100 |
归一化这里有一个联邦场景特有的坑:各客户端如果各自算均值和标准差,不同学校的学习时长分布本来就不一样,归一化之后尺度又被人为拉开,聚合出来的全局模型很容易失真。常见做法是让协调方下发一份全局的 mean 和 std,所有客户端用同一份参数做标准化。演示代码里直接对全量数据 fit,只是为了把缩放逻辑跑通,真正替换到联邦环境时,只需要把 scaler 换成全局统计量。
import pandas as pd from sklearn.preprocessing import StandardScaler df = pd.read_csv("student_scores.csv") feature_cols = ["study_hours", "attendance_rate", "prior_gpa", "assignment_avg"] target_col = "final_score" # 全局归一化:统计量在协调方汇总后统一下发,各客户端不要各自 fit scaler = StandardScaler() df[feature_cols] = scaler.fit_transform(df[feature_cols]) y = df[target_col].values这段代码的要点是:特征全部缩放成均值为 0、方差为 1 的分布,模型在数值稳定性上会好很多;不归一化直接丢进 MLP,学习率要调得很小,而且不同客户端之间如果特征尺度差异大,聚合的结果会偏向尺度大的那方。往后的所有操作都基于这个处理过的 DataFrame。
3.2 用 Dirichlet 分布切出 Non-IID 客户端数据
联邦学习里客户端数据往往是非独立同分布的,各学校的学生水平、选课结构、测评难度都不一样。实验里最常见的人造方式有两种:按学校编号分组,以及用 Dirichlet 分布做非均匀切分。前者更贴近真实部署,每个学校天然是一个客户端;后者可以连续调节 Non-IID 强度,适合做控制变量实验。我在做模拟项目时常用 Dirichlet,因为改一个 alpha 参数就能看到数据偏斜程度对收敛速度和最终精度的影响。
import numpy as np def split_by_dirichlet(df, n_clients, alpha=0.5, seed=42): """按狄利克雷分布把样本分到 n_clients 个客户端。 alpha 越小,样本越集中在少数客户端,数据分布越不均匀。 """ rng = np.random.default_rng(seed) n_samples = len(df) prop = rng.dirichlet(np.full(n_clients, alpha), size=n_samples) client_id = prop.argmax(axis=1) clients = {} for cid in range(n_clients): idx = np.where(client_id == cid)[0] clients[cid] = df.iloc[idx].reset_index(drop=True) return clients clients = split_by_dirichlet(df, n_clients=5, alpha=0.5, seed=42)参数说明:n_clients 是参与方数量,对应模拟中的学校数量;alpha 控制偏斜程度,alpha 取 0.1 时数据极度不均衡,alpha 取 10 时接近均匀切分,几乎退化成独立同分布。seed 必须固定,否则后面训练结果没法复现。实际项目里如果数据本来就有 school_id 列,直接用 groupby 切分更符合真实场景,Dirichlet 更多用在论文和对比实验里。切分完之后,把每个客户端的特征和标签单独准备好,后面训练循环直接取用。
def split_by_school(df): """按真实学校分组,每个学校一个客户端,最贴近工程部署。""" clients = {} for school_id, group in df.groupby("school_id"): clients[school_id] = group.reset_index(drop=True) return clients两种切分方式我都会在代码里保留,跑实验用 Dirichlet 调节难度,做demo用按学校分组,观众一眼能看出联邦的参与方是谁。
3.3 本地训练与 FedAvg 聚合:可运行的最小实现
模型部分用一个轻量 MLP 就够。成绩预测是连续值回归,输出层一个神经元,损失函数选均方误差。隐藏层宽度不需要很大,这个任务的特征维度很低,过大的网络只会增加过拟合风险。网络结构定成两层隐藏层、每层 32 个神经元,常规表格任务基本够用。
import torch import torch.nn as nn class ScoreNet(nn.Module): def __init__(self, input_dim=4, hidden_dim=32): super().__init__() self.net = nn.Sequential( nn.Linear(input_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, 1), ) def forward(self, x): return self.net(x).squeeze(-1)模型里没有加 BatchNorm,位置刻意为之。BN 层在联邦学习里是出了名的坑,它的 running_mean 和 running_var 属于状态而非参数,各客户端本地统计不一致,聚合之后全局模型的 BN 统计量经常是乱的。表格任务用不用 BN 影响不大,直接删掉反而省心。如果你要处理的任务必须用 BN,那聚合时要单独处理这些 buffer,避坑章节里会说。
客户端本地训练函数接收一个模型、一份本地数据和超参数,跑若干轮后返回 state_dict。这里只更新模型参数,不返回任何原始数据。
def local_train(model, X, y, lr=0.01, local_epochs=5, batch_size=64): model.train() optimizer = torch.optim.Adam(model.parameters(), lr=lr) loss_fn = nn.MSELoss() dataset = torch.utils.data.TensorDataset( torch.tensor(X, dtype=torch.float32), torch.tensor(y, dtype=torch.float32) ) loader = torch.utils.data.DataLoader(dataset, batch_size=batch_size, shuffle=True) for _ in range(local_epochs): for xb, yb in loader: optimizer.zero_grad() loss = loss_fn(model(xb), yb) loss.backward() optimizer.step() return model.state_dict()local_epochs 是联邦学习里最重要的旋钮之一。调大了,客户端往自己的局部最优走得太远,聚合后模型震荡;调小了,本地数据没学够,通信效率低。我的经验是从 3 起步,观察训练曲线后再往 1 或者 5 调。batch_size 在表格数据上不用太讲究,64 或者 128 都行,关键是每个客户端都要做 shuffle。
接下来是 FedAvg 的核心,按样本量加权平均参数。
def fedavg_aggregate(global_model, client_states, client_sizes): """按样本量加权平均各客户端的 state_dict。""" total = sum(client_sizes) aggregated = {} for key in global_model.state_dict(): weighted = torch.stack([ state[key] * (size / total) for state, size in zip(client_states, client_sizes) ]) aggregated[key] = weighted.sum(dim=0) return aggregated这个函数遍历全局模型的所有参数项,对每一个权重矩阵做同样的加权求和。注意顺序要对,state 列表和 size 列表必须按同一个客户端顺序排列,zip 才不会错位。如果某个客户端数据量特别大,它的权重会接近 1,模型的偏好会被这个大客户端主导,这也是后面按客户端单独评估的原因之一。
最后一个环节,把整个联邦训练循环串起来:每轮随机采样一部分客户端,下发全局参数,收集本地更新,做聚合,记录测试指标。
def run_federated(clients, n_rounds=30, client_fraction=1.0, local_epochs=3, lr=0.01, seed=42): rng = np.random.default_rng(seed) global_model = ScoreNet() history = [] for r in range(n_rounds): selected = list(rng.choice( list(clients.keys()), size=int(len(clients) * client_fraction), replace=False )) states, sizes = [], [] for cid in selected: cdf = clients[cid] X_c = cdf[feature_cols].values.astype("float32") y_c = cdf[target_col].values.astype("float32") local_model = ScoreNet() local_model.load_state_dict(global_model.state_dict()) states.append(local_train(local_model, X_c, y_c, lr=lr, local_epochs=local_epochs)) sizes.append(len(cdf)) new_state = fedavg_aggregate(global_model, states, sizes) global_model.load_state_dict(new_state) # 每轮在全局测试集上评估一次,记录 MAE mae = evaluate(global_model, test_features, test_labels) history.append({"round": r + 1, "mae": mae}) return global_model, historyclient_fraction 是每轮实际参与通信的客户端比例。5 个客户端时可以取 1.0,模拟的参与方多了之后取 0.5 左右更接近真实场景,因为真实系统里每个通信轮不可能等所有机构都在线。n_rounds 对应通信轮数,表格任务通常 30 轮以内就能看到收敛趋势,如果曲线还在明显下降就继续加。
4. 用 Streamlit 把训练过程搬上浏览器:可视化平台搭建
4.1 页面骨架:侧边栏参数加主区域图表
Streamlit 的优势是写脚本式页面,不需要前端基础,适合这类内部工具。整个页面按两部分组织:侧边栏放训练参数和启动按钮,主区域放训练曲线、对比结果和下载入口。布局用 st.set_page_config 调成宽屏,一行标题说明平台用途,底部用一个 expander 展示训练日志,避免主区域被大量过程信息占满。
import streamlit as st st.set_page_config(page_title="联邦成绩预测平台", layout="wide") st.title("多校联邦训练与成绩预测可视化") with st.sidebar: st.header("训练参数") n_rounds = st.slider("通信轮数", 10, 60, 30) local_epochs = st.slider("本地训练轮数", 1, 10, 3) lr = st.number_input("学习率", 0.0001, 0.1, 0.01, step=0.001) client_fraction = st.slider("客户端采样比例", 0.5, 1.0, 1.0, 0.1) alpha = st.slider("Non-IID 强度 alpha", 0.1, 5.0, 0.5, 0.1) run_btn = st.button("开始训练", type="primary")侧边栏每个控件都对应 run_federated 的一个参数,alpha 对应数据切分的偏斜程度。这样设计的好处是,观众可以直接在页面上复现"数据分布越偏,收敛越慢"这个结论。lr 用 number_input 而不是 slider,因为学习率通常需要精确到小数点后两位,slider 的精度不够。
4.2 训练过程实时刷新:利用 rerun 模型而不是定时器
很多初学者会在 Streamlit 里写一个 while 循环定时刷新页面,这是多余的。Streamlit 的执行模型是脚本重跑,每次交互都会从头执行一次脚本,所以实时进度的正确做法是在训练循环里更新同一个占位符。小模型的训练一轮很快,为了让人眼看清楚,在每轮结束时 sleep 一小段时间。
progress_bar = st.progress(0) chart_ph = st.empty() status_text = st.empty() history_df = pd.DataFrame() for r in range(1, n_rounds + 1): # 这里放单轮联邦训练的调用 round_mae = run_one_round(...) new_row = pd.DataFrame({"round": [r], "mae": [round_mae]}) history_df = pd.concat([history_df, new_row], ignore_index=True) progress_bar.progress(r / n_rounds) chart_ph.line_chart(history_df.set_index("round")["mae"]) status_text.info(f"第 {r} 轮完成,全局测试 MAE = {round_mae:.3f}") time.sleep(0.3)进度条是真正在训练期间持续变化的元素,line_chart 会在每轮结束后重画一遍曲线。time.sleep 不是为了刷新,而是控制人眼的感知节奏,不加也能跑,但看起来像卡死了。等训练结束后,把 history_df 存进 st.session_state,避免页面任何交互导致结果丢失。
4.3 对比标签页:联邦、单校基线、集中式上界
成绩预测的价值要通过对比体现,单独一张联邦曲线没有说服力。我习惯在一个页面里放三个标签页,分别展示三种训练方式的结果:联邦学习用全部客户端协作,单校基线只取某一个客户端的数据训练,集中式上界把所有数据合并训练。三者一对比,联邦学习的精度损失和收益一目了然。
tab_fed, tab_single, tab_central = st.tabs(["联邦训练", "单校基线", "集中式上界"]) with tab_fed: st.line_chart(fed_history.set_index("round")[["mae"]]) st.caption("所有客户端协作,数据不离开本地") with tab_single: st.line_chart(single_history.set_index("round")[["mae"]]) st.caption("只用某校数据训练,样本量最小的基线") with tab_central: st.line_chart(central_history.set_index("round")[["mae"]]) st.caption("所有数据合并训练,模型性能的天花板") st.download_button( "下载联邦训练结果 CSV", fed_history.to_csv(index=False).encode("utf-8"), file_name="fed_result.csv" )st.caption 在这里是必要的,三张图长得像,不带说明观众很难记住哪个是哪个。下载按钮用 st.download_button 比 st.button 更合适,它不需要后端文件服务,直接前端生成 CSV。对比指标的选取也要注意,成绩预测用 MAE 比用准确率直观得多,均方误差会把大误差放大,在学业预警场景里你更关心平均差几分,而不是少数极端学生的误差。
5. 联邦成绩预测避坑指南:五个高频问题与排查
5.1 训练结果每次跑都不一样,重点排查随机种子
现象是同一组参数跑两次,测试 MAE 差一大截,甚至最优通信轮数都对不上。原因在于三层随机叠加:Dirichlet 切分是随机的,每轮客户端采样是随机的,DataLoader 的 shuffle 也是随机的,只要有任意一层没固定,结果就不可复现。解决方式是统一固定种子,而且要分别固定 numpy、torch、random 三个库的种子,因为 PyTorch 和 NumPy 各自维护一套随机状态。切分函数和训练循环都要接收 seed 参数,用同一个 rng 实例控制采样,而不是每处都临时调 np.random.seed。
固定种子之后,同一个实验跑三次,结果波动会明显变小。如果固定了种子波动还大,那问题多半在数据切分上,某个客户端分到了太少样本,小样本客户端的随机性直接影响聚合结果。此时优先检查每个客户端拿到多少样本,而不是继续调超参数。
5.2 loss 震荡不收敛,核心是模型漂移
现象是全局测试 MAE 在 30 轮里不下降,甚至上下跳。先在本地训练里加入近端项,限制模型不要离全局参数太远,这就是 FedProx 的核心思路。原因方面,模型漂移是主因:local_epochs 设太大,每个客户端在自己的偏斜数据上训练太多步,模型被推到了各自的最优点附近,聚合之后互相抵消,全局模型反而在原地打转。解决思路是先降低 local_epochs,从 5 降到 2 或者 1,同时把通信轮数加上去,让模型小步快走。如果还震荡,再把学习率调低。
5.3 点第二次训练按钮没反应,缓存背锅
现象是修改了学习率再点开始训练,页面刷新一下,出来的结果和上次完全相同。原因大概率是训练函数被 st.cache_data 装饰了,Streamlit 的缓存 key 是函数参数和函数体签名,如果随机种子没变,参数哈希也没变,点击按钮只是命中缓存直接返回旧结果。解决方式是训练函数不要加缓存装饰,即使加了也要把 seed 显式作为参数传进去,并且主动清空缓存。更稳妥的方式是把训练结果放到 st.session_state,页面重新运行时先检查状态里有没有数据,有就直接展示,不重新训练。
5.4 聚合报参数不匹配,state_dict 没对齐
现象是 fedavg_aggregate 里抛 KeyError,或者维度对不上。原因是客户端本地模型和全局模型结构不一致,有种情况是 load_state_dict 时用了 strict=False,缺失的参数被静默接受,参数项的数量对不齐。解决方式是所有客户端统一用同一个模型类,不允许各自改结构;load_state_dict 必须开 strict=True,让错误尽早暴露。如果模型里用了 BN 层,还要额外处理 running_mean 和 running_var 这两个 buffer,它们是状态不是参数,很多新手在聚合时只遍历 named_parameters,把 BN 统计量漏掉了。
5.5 只看全局指标,单校异常被掩盖
现象是全局 MAE 看着不错,但某个学校的学生预测值平均偏高了 10 分。原因是 FedAvg 按样本量加权,大客户端主导全局模型,小客户端如果数据分布特殊,它的误差会被平均掉。解决方式是训练完成后按客户端分组评估,输出每个客户端单独的 MAE 表,哪所学校异常,先查它的特征分布而不是急着换算法。学业预警场景里,全局指标好看但没有落到每个学校都可用,这个平台就是不合格的。
6. 收敛验证与效果评估的实用技巧
判断联邦训练是否真正收敛,不要只看训练过程的 loss 曲线,要在独立测试集上算 MAE,并且把联邦、单校、集中式三条曲线放在同一张图里。联邦学习的合理位置应该在单校基线和集中式上界之间,如果联邦结果还不如单校,优先检查数据切分是不是太极端,再检查超参。还有一个容易被忽略的量,固定种子跑三次实验,记录每次的最终 MAE,联邦学习在 Non-IID 数据上本身方差就大,一次实验的结论不可信,三次取均值再下判断。
我自己的习惯是训练结束后先输出按学校分组的评估表,再决定要不要调参。记得有一次全局 MAE 已经降到 7.2,看起来可以收官了,但按学校一看,某模拟院校的 MAE 高达 18,原因是这所学校样本量最少且成绩分布整体偏高,全局模型对它的学生完全没有区分度。把 local_epochs 从 3 降到 1、通信轮数从 20 提到 40 之后,那所学校的误差降到了 11,全局指标只是略有波动。从那以后,评估表成了我的固定动作。
另一个技巧是记录每轮被采样到的客户端列表。联邦训练过程里,客户端采样是随机的,如果不记录,后期发现某所学校贡献异常时根本无法回溯。把 round、client_id、样本量、本地 loss 一起写进日志,几百行而已,排查时能省掉大部分猜测。这些技巧加起来,联邦成绩预测这个方向的落地闭环就比较完整了:数据切分可控、训练过程可视、评估结果可信。希望帮到你。
本文还有配套的精品资源,点击获取