机器学习预测学生成绩:从数据预处理到模型上线的完整流程
简介这是一个面向机器学习入门学习者的学生成绩预测分类项目基于Python与Pandas、NumPy、Scikit-Learn等库实现。项目通过分析国籍、年级、举手次数、出勤情况等学生特征使用数据清洗、特征选择、特征缩放等步骤完成预处理并尝试决策树、支持向量机、随机森林等多种分类器结合交叉验证与网格搜索调优最终以图表和混淆矩阵直观展示各因素对学生成绩的影响。压缩包共3个文件包含Python主脚本、CSV数据集和Markdown说明文档体积仅8KB轻量且易于复现。整个分析流程中不仅比较了多种算法效果还针对关键特征进行了可视化探索帮助读者理解特征与成绩之间的关系。已有966人学习下载适合正在练习分类建模、特征工程与结果可视化的学习者作为参考。1. 用机器学习预测学生成绩先搞清楚这个问题到底是什么开学第六周教务老师拿着几千行考勤、作业、测验记录问我能不能提前锁定期末可能挂科的学生名单。这就是“用机器学习来预测学生成绩表现”这个项目的典型场景从历史行为数据里学出一个从特征到成绩的映射再用来对当前学期做预警。它本质上是监督学习里的回归或分类问题选型逻辑取决于你要的是“几分”还是“挂不挂”。这套东西谁在用教务数据组做学业预警、计算机学生做课程设计、教育产品方做学习风险识别都合适。下面我按自己做过的方案从数据处理一直讲到上线避坑全部用Python落地。2. 数据和特征工程预测成绩的第一步不是选模型而是把数据变成能喂给算法的东西2.1 先回答“机器学习中的数据处理是什么”从原始表到特征矩阵的一步才是决定上限的一步很多人一开始就急着跑模型这不对。机器学习里的数据处理不是简单地“把缺失值填掉”而是要把一张原始表变成“每一列都能代表学生某个可测量状态”的特征矩阵。特征能表达什么模型的预测上限就是什么后面调模型只能在特征上限下面挣扎。拿公开的学生成绩数据集Student Performance举例它包含的字段大致分两类。一类是静态背景数据性别、年龄、父母教育水平、家庭地址类型、通勤时间另一类是行为过程数据每周学习时间、历史挂科次数、缺课次数、是否参加补习班、前两学期成绩G1和G2。我在实际项目里做特征筛选时会优先保留行为过程数据尤其缺课次数和前序成绩跟最终成绩的相关性非常强而父母职业这类背景字段既含有较强的社会属性预测力又不稳定能不碰就尽量不碰。数据处理还有一个容易忽略的维度特征的时间边界。如果你在第六周做预警那么“期末成绩”显然不能用甚至“期中成绩”如果用得不当也会造成过拟合。我会把特征表按数据产生的时间点打上标签哪些是预测时刻已有的哪些是预测时刻还没发生的后者一律不放进去。这就是后来避坑章里要重点展开的特征泄露问题。2.2 用pandas做清洗、编码、切分一份可以直接跑的Python示例下面这段代码是数据处理部分最常见的写法我按“读取—看缺失—填缺失—编码—切分”的顺序走每一步都有实际业务含义。import pandas as pd from sklearn.model_selection import train_test_split # UCI的学生成绩公开数据常用分号分隔读的时候别用默认逗号 df pd.read_csv(student-mat.csv, delimiter;) # 先确认数据规模和缺失情况缺失严重的列要考虑是否直接丢掉 print(df.shape) print(df.isnull().sum()) # 数值类特征用中位数填充避免极端缺勤值把均值拉偏 num_cols [absences, studytime, traveltime, health] for col in num_cols: df[col] df[col].fillna(df[col].median()) # 二分类的哑变量字段手动映射成 0/1 比 OneHotEncoder 更好排查 df[schoolsup] df[schoolsup].map({yes: 1, no: 0}) df[higher] df[higher].map({yes: 1, no: 0}) # 对多类别字段做 one-hotdrop_first 能把列数压下来避免共线性 df pd.get_dummies(df, columns[reason, guardian], drop_firstTrue) # 清理列名选出预测用的特征矩阵 X 和标签 y feature_cols [c for c in df.columns if c not in [G1, G2, G3]] X df[feature_cols] y df[G3] # 最终成绩作为目标列 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42 ) print(训练集行数:, X_train.shape[0], 测试集行数:, X_test.shape[0])这段代码里有几个参数值得细说。delimiter;和isnull().sum()是最容易踩的第一道坎格式没读对后面所有字段都是乱的缺失值不舍得丢模型就会拿噪声硬凑。drop_firstTrue在类别字段多的时候能减少一列同时消除 one-hot 带来的完全共线性。random_state42固定随机种子让切分结果可复现这在跑实验时要反复对不同的特征组合做对比种子不固定连“哪个特征更有效”都说不清。2.3 回归还是分类根据你要做的决策来选择标签口径同样的数据业务问题不同标签构造就完全不同。做“期末成绩是多少分”这是回归任务y 是连续值 0 到 20做“会不会挂科”这是二分类y 变成 0 和 1做“成绩分档”这是多分类y 变成优、良、中、及格、不及格五档。我一般会根据使用方怎么决策来定班主任想精准抓出最可能挂科的十个人那就得回归加阈值或者直接做二分类教务处要统计各分数段人数那就多分类。手动构造标签的代码很轻量但口径得想清楚。# 回归标签直接用原始成绩 y_reg df[G3] # 二分类标签以 10 分为及格线UCI 里 G3 满分是 20 y_bin (df[G3] 10).astype(int) # 多分类标签按 0-9 / 10-13 / 14-17 / 18-20 分成四档 import numpy as np bins [-1, 9, 13, 17, 20] labels [0, 1, 2, 3] y_multi pd.cut(df[G3], binsbins, labelslabels)注意pd.cut的边界设计直接决定分类难度如果某档样本太少模型会在那一档上完全失效这就是后面避坑章里要说的类别不平衡。另外一个容易被忽略的点G3 这个目标列是不能出现在特征矩阵里的我在 2.2 的feature_cols里把它排除掉了但很多人做二分类时会把 G1、G2 也一起丢掉这就浪费了很强的信号因为前序成绩本来就是预测最终成绩最有效的合法特征。关键要看你的预警时间点距离 G1、G2 的发布时间有多远时间上已经发生过的就能用。3. 算法选型与模型训练从线性回归到集成模型按数据量走一遍3.1 先跑线性回归和决策树当基线把数据处理管道和评估代码先验一遍我见过很多新手直接上 XGBoost结果数据处理管道本身有 bug模型报错都不知道是数据的问题还是模型的问题。正确的做法是先用最简单的模型把整个流程跑通。线性回归在成绩预测里不是摆设成绩和历史行为之间的关系很多是近似线性的比如缺课次数增加、成绩下降这种线性趋势线性回归就能抓住。决策树则能处理非线性交互比如“缺课多但家教补课的学生反而没掉队”这类规则。from sklearn.linear_model import LinearRegression from sklearn.tree import DecisionTreeRegressor from sklearn.metrics import mean_absolute_error models { 线性回归: LinearRegression(), 决策树回归: DecisionTreeRegressor(max_depth5, random_state42) } for name, model in models.items(): model.fit(X_train, y_train) pred model.predict(X_test) mae mean_absolute_error(y_test, pred) print(f{name} 测试集平均绝对误差: {mae:.2f})这里max_depth5是决策树的剪枝参数限制深度就是限制模型在训练集上记住太多“个别学生”的细节。成绩预测的数据量通常只有几百到几千行树一旦不限制深度很容易把每个训练样本都背下来得到一个训练集 R² 接近 1 但测试集惨不忍睹的模型。先跑基线还有个好处之后用集成模型做对比时你能明确说出“复杂度更高的模型到底带来了多少增益”而不是凭感觉说它“效果更好”。如果没有这一点选型就是在碰运气。3.2 该上随机森林还是XGBoost表格数据上的默认答案但要注意样本量当基线跑通后下一个合理的选择是在决策树之上引入集成学习。随机森林通过随机采样和随机选特征降低方差适合中等规模数据不容易过拟合几乎不需要太多调参就能得到一个稳定的效果。XGBoost 这类梯度提升树则通过逐步拟合残差来压误差在数据足够时往往精度更高但它对参数更敏感在几百行的学生数据上很容易跑出过拟合的“假漂亮指标”。下面这段代码是随机森林和梯度提升标准的训练加交叉验证组合。from sklearn.ensemble import RandomForestRegressor from sklearn.model_selection import cross_val_score rf RandomForestRegressor( n_estimators300, max_depth6, min_samples_leaf4, random_state42 ) # 用 5 折交叉验证看模型在未见数据上的波动 scores cross_val_score(rf, X_train, y_train, cv5, scoringneg_mean_absolute_error) print(交叉验证MAE: , -scores.mean(), /-, scores.std())n_estimators是树的数量这个参数并不一定是越大越好300 到 500 之后增益就已经饱和了再加大只是增加训练时间。min_samples_leaf4强制每个叶子节点至少有 4 个样本这在学生数据这种小样本场景里比盲目增加树的数量更管用。我还喜欢加一句如果环境里没装 XGBoost在 Python 环境里用pip install xgboost装完就能跑不想装的话sklearn自带的GradientBoostingRegressor也能顶上。成绩预测这种表格数据任务树模型家族几乎就是默认选择神经网络反而不太适合因为样本量撑不起深度模型的容量。3.3 数据少就别想着自动调参用KFold把验证做扎实学生成绩数据集往往只有几百行到几千行。在这种体量下网格搜索调参是个陷阱你把测试集反复用来挑参数本质上就是把测试集也变成了训练集的一部分最后得到的“最优参数”在下一批真实学生上一定会翻车。我一般会把数据先分成训练集和留存测试集然后只在训练集上用 K 折交叉验证去选参数最后才用留存测试集做一次最终评估。from sklearn.model_selection import KFold kf KFold(n_splits5, shuffleTrue, random_state42) for fold, (train_idx, val_idx) in enumerate(kf.split(X_train)): X_fold_train, X_fold_val X_train.iloc[train_idx], X_train.iloc[val_idx] y_fold_train, y_fold_val y_train.iloc[train_idx], y_train.iloc[val_idx] rf_fold RandomForestRegressor(n_estimators300, max_depth6, min_samples_leaf4, random_state42) rf_fold.fit(X_fold_train, y_fold_train) fold_mae mean_absolute_error(y_fold_val, rf_fold.predict(X_fold_val)) print(f折 {fold1} MAE: {fold_mae:.2f})shuffleTrue让样本在切分前先打乱避免原始表里相同班级或相同批次的记录连在一起造成分布偏差。random_state42在这里的作用是对照实验别人复现你的实验时能得到完全一致的数据划分。数据量小的另一个对策是尝试用留一法做评估但那样方差会很大结果不稳定折数设成 5 或 10 已经是性价比最高的区间了。4. 评估指标与结果解读不要只看准确率成绩预测的坑在指标选择上4.1 回归成绩需要看三个数MAE定业务口径RMSE揪大误差R²看解释力回归任务中单一的准确率无法衡量预测偏差业界常用 MAE、RMSE 和 R²。三个指标从业务视角看完全不同我常用下面这段代码把它们一起算出来然后逐个解读。from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score import numpy as np pred rf.predict(X_test) mae mean_absolute_error(y_test, pred) rmse np.sqrt(mean_squared_error(y_test, pred)) r2 r2_score(y_test, pred) print(fMAE: {mae:.2f}) print(fRMSE: {rmse:.2f}) print(fR²: {r2:.3f})在成绩预测场景里MAE 是给班主任看的因为“预测平均偏差 1.5 分”这句话任何人都能理解。RMSE 则是给教务数据分析师看的它把大偏差样本的权重放大了如果模型对某个学生预测偏差 6 分RMSE 会被这个样本拉得明显高于 MAE这提醒我们单纯看 MAE 可能会忽略少数极端预测。R² 表示模型解释了成绩方差的比例成绩预测通常能做到 0.6 到 0.8 之间就已经非常可用如果出现 R² 接近 1你要警惕是不是特征里混入了和目标同时期的数据。三个指标放在一张表里会更直观指标含义业务解读适用场景MAE平均绝对误差每个学生平均被估偏多少分给老师看整体预警精度RMSE均方根误差对大误差更敏感排查极端误判R²决定系数模型解释了多大比例方差对比模型选型结果4.2 分类任务别拿准确率当面子混淆矩阵里的召回率才是预警系统的命门做二分类“是否挂科”时如果不平衡问题不处理准确率会非常具有迷惑性。比如 100 个学生里 85 人及格、15 人挂科模型把所有学生都判及格准确率也有 85%。这种模型在预警场景里等于摆设。这时要看混淆矩阵尤其挂科样本的召回率也就是预测为挂科且实际真挂科的学生人数除以实际挂科总人数漏掉一个真挂科学生比误报一个及格学生要严重得多。from sklearn.metrics import confusion_matrix, classification_report y_pred_cls (rf.predict(X_test) 10).astype(int) y_test_cls (y_test 10).astype(int) cm confusion_matrix(y_test_cls, y_pred_cls) print(混淆矩阵第一行实际及格、第二行实际挂科:) print(cm) print(classification_report(y_test_cls, y_pred_cls, target_names[及格, 挂科]))classification_report会一次性给出精确率、召回率和 F1。实际业务里我不只看 F1更看重挂科类的召回率因为预警系统如果漏报等于对真正需要帮助的学生没有反应。为了提升召回率可以调低预测阈值比如从成绩 10 分改成 9 分就预警代价是多抓几个不会挂科的学生但在“宁可多干预、不可漏干预”的业务逻辑下这是划算的。4.3 特征重要性是给老师看的最好交付物让树模型告诉你什么在影响成绩模型训练完了不能只甩给教务处一个“预测名单”你得解释为什么这个学生被标记为高风险。树模型自带特征重要性虽然它基于分割点的使用频次不够精确但足以用来做方向性判断。我每次交付项目时都会优先输出特征重要性排序这样业务方知道模型不是黑匣子。import matplotlib.pyplot as plt importance pd.Series( rf.feature_importances_, indexX_train.columns ).sort_values(ascendingFalse) print(importance.head(10)) importance.head(10).plot.barh() plt.gca().invert_yaxis() plt.xlabel(特征重要性) plt.tight_layout() plt.savefig(feature_importance.png, dpi150)在大多数成绩预测模型里排在前面的都会是缺课次数、前序成绩、学习时间这几个特征而不是家庭收入和父母职业。这本身就传达了一个有用的信息学生的行为参与度比家庭背景更直接地影响成绩表现。这里有个隐藏参数值得留意特征重要性是对模型分裂增益的汇总如果两个特征高度相关比如 G1 和 G2模型会把重要性分散到两者之间这时候不要急着下结论说某一个不重要可以把它们合并成一个“前序成绩均值”再训练一次。5. 成绩预测避坑指南我在这类项目里踩过的五个最常见坑5.1 特征泄露和数据切分上的翻车模型越“准”越要警惕第一个坑是模型在测试集上表现异常好RMSE 只有 2 分甚至更低。原因基本是特征泄露数据加工时把目标列 G3 或者与 G3 同时期产生的字段混进了特征矩阵。我见过最隐蔽的一种写法是在构造标签时顺手把归一化后的最终成绩也留在训练数据里模型实际上是在“看答案做题”。解决方法是严格按时间边界检查每个特征字段的产生时间凡是预测时刻尚未发生的值一律不能出现在特征中。第二个坑是国内课程项目中发生率极高的同班学生串组。训练集和测试集如果按随机切分同一名学生或同一个班级的多次记录被分到两边模型在训练时“认识”了这批学生测试时靠记忆作答。表现就是离线评估很好上线一遇到新班级就崩。解决方法是按学生 ID 或班级 ID 做分组切分用GroupKFold保证同一个 ID 的所有记录都只在训练集或测试集中出现一次。这个坑排查起来很隐蔽因为代码不会报错只有指标反常时你才会想起来查数据泄露。5.2 样本不平衡和时间特征乱用的具体填坑细节第三个坑是挂科样本占比太低模型把所有人都判断及格准确率依然好看。原因就是类别不平衡时准确率指标失真分类器直接学会了“多数类万事大吉”。解决分两路评估层面看混淆矩阵尤其是挂科类召回率训练层面可以给少数类加权或者对多数类做下采样。对于几百行的学生数据我通常先试class_weightbalanced这个参数简单有效几乎不会有副作用。第四个坑是缺失值填充方式太粗暴比如把“缺课次数”填成中位数是合理的但把“学习时间”缺失也填成中位数就可能扭曲特征分布。成绩预测里碰到最严重的一种错误是把没有提交作业的学生填成 0 次然后模型把“作业提交少”当成了“成绩一定差”的绝对信号。解决方法是按列的业务含义去决定填充策略计数型字段填 0 或中位数率类字段先看分母比值型字段填充那个不改变分布的值。如果不确定可以把这个字段的缺失单独做成一个“是否缺失”的哑变量让模型自己学这个信息是否有用。第五个坑是上线后发现预测分数越到学期末越离谱。原因是成绩预测本质上是一个时间敏感问题开学第一周预测期末成绩和学习过半之后再预测难度完全不一样。模型如果没有把“预测时点”作为特征记录进去自然会在时间变化后失效。解决方法是把训练数据按学期分组用“以过去预测未来”的方式滚动验证而不是把所有历史数据混在一起随机切分。这个思路我放在下一章展开它也是我目前自认为这个项目里最值得复用的经验。6. 让模型真正上学业预警系统滚动验证与可解释性检查6.1 上线前用滚动验证模拟真实节奏按学期切分而不是随机切分假定你有三个学期的记录我会这样切分用前两个学期训练模型在第三个学期上做评估相当于模拟“我拿着过去的数据去预测此刻正在发生的事”。然后每过一个学期把模型重训一次用最新数据滚动更新。这才是预警系统该有的真实运行方式也是验证模型在时间维度上有没有失效的唯一可靠办法。# 假设 df 中已有学期字段 term且按时间升序排列 train_mask df[term].isin([2023-2024-1, 2023-2024-2]) test_mask df[term] 2024-2025-1 X_train_time, X_test_time X[train_mask], X[test_mask] y_train_time, y_test_time y[train_mask], y[test_mask] rf.fit(X_train_time, y_train_time) mae_time mean_absolute_error(y_test_time, rf.predict(X_test_time)) print(滚动验证MAE:, mae_time)这段代码的要点在于按学期划界而不是按学生 ID 打散。教育数据本身就有极强的学期特点比如某一个学期整体学风变差成绩分布整体下移如果不按时间切分模型永远学不到这种宏观波动。6.2 用SHAP解释单个学生的预测结果让预警名单能被业务方接受树模型虽然能做特征重要性排序但那是全局结论单个学生为什么被预警还需要做局部解释。SHAP 是我现在做这类项目必用的工具它对每个样本计算每个特征的贡献值正数表示把预测往上推负数表示往下压。给班主任看的时候一张 SHAP 图就能回答“为什么偏偏是他”。import shap explainer shap.TreeExplainer(rf) shap_values explainer.shap_values(X_test) # 看第一个被预警的学生 shap.initjs() shap.force_plot(explainer.expected_value, shap_values[0, :], X_test.iloc[0, :])用 SHAP 还有一个附带好处它能暴露你在数据处理阶段埋下的问题。比如某个特征对模型贡献异常大你回去看发现是填充策略写错了这时候模型反而帮你做了数据质量审计。这行代码本身不长但它往往能挽回那些几乎无法挽回的交付事故。我的个人习惯是每次训练完模型先做一次随机切分验证再做一次滚动切分验证。两个结果如果差异很大那说明模型对随机切分的分布记忆太深不能直接上线。如果滚动验证还过得去再继续往下调参。这个顺序帮我规避过好几次线上风险。如果你正打算在成绩预测这个方向上继续做建议保留一个简单版本的基线模型不要只盯着集成模型的报告好看就完事基线模型在线上出问题时往往是第一道回退防线。希望帮到你。本文还有配套的精品资源点击获取