☰
学生成绩预测实战:数据清洗、特征工程与随机森林建模
2026/10/3 10:07:00 网站建设 项目流程

简介:这是一套基于机器学习的学生成绩预测系统,面向高校毕业设计、课程设计与期末大作业场景,围绕学生历史成绩、出勤率、作业完成情况等多维数据,采用线性回归、决策树、随机森林及XGBoost等算法训练预测模型,并配有可视化前端,便于教师和学生输入数据、查看预测结果。压缩包共34个文件,涵盖Python源码(应用入口与数据增强、聚类、调参等脚本)、训练好的模型文件(pkl/model)、样例数据集(xlsx/csv)、网页模板与静态资源(html/js/css)以及依赖说明,总大小816KB,目录按data、scripts、models、templates等划分,结构清晰。已有51人学习下载。项目中包含网格搜索超参调优结果、KMeans聚类可视化与特征重要性图表,能够直观展示模型优化过程和数据规律;同时提供README说明和requirements.txt,可快速安装运行,适合作为课程设计答辩项目,也可作为机器学习完整流程的参考。

1. 为什么拿它做课设:从交作业到能讲清原理的成绩预测项目

拿到《基于机器学习的学生成绩预测系统.zip》这份资源的人,大概率正处于两件事的交汇点:一是课程设计、期末大作业或毕业设计选题表上「机器学习」四个字赫然在列,二是翻遍了网上的案例却发现要么是鸢尾花、波士顿房价这类跑烂了的例子,要么是挂着「预测」的名头却连数据集都没提供的空架子。这个压缩包解决的核心问题,是让一个尚未系统接触过机器学习的人,能在两三天内从零跑通一条完整的预测流程——数据清洗、特征构造、模型训练、评估对比到最后的结果可视化,并且能对着答辩老师把每一步讲出道理。它适合三类人:第一次做课设的大三学生,准备毕设、需要一份能扩展成论文的底座的应届生,以及想要快速搭建一个「能演示、能截图、能交差」的项目的在职读者。下文按我复现这个项目时的真实路径展开,包含每一段代码的意图说明和踩过的具体坑。

2. 数据与特征工程:先搞清楚预测什么,再谈算法

2.1 数据集的典型结构与字段语义

解压这个项目包之后,先别急着打开模型训练脚本,第一件事是去读数据。这类学生成绩预测系统里最常见的数据集结构,是一个以student_id为主键的宽表,包含学习时长、出勤率、平时作业提交次数、期中考试成绩、历年挂科数,以及可能存在的家庭背景类字段。目标列通常是期末考试成绩或者「是否及格」的二分类标签。

先跑一遍下面的代码把数据读进来,看形状和缺失情况。

import pandas as pd df = pd.read_csv("data/grades.csv", encoding="utf-8-sig") print("数据集形状:", df.shape) print("列名:", df.columns.tolist()) print("\n缺失值统计:\n", df.isnull().sum()) print("\n目标列分布:\n", df["final_score"].describe() if "final_score" in df.columns else "目标列不存在")

逻辑说明:encoding="utf-8-sig"是为了兼容 Windows 下 Excel 另存的带 BOM 头文件;isnull().sum()用来快速定位哪些字段有空洞,后续清洗才有针对性。参数上,describe()会输出目标列的均值、标准差、四分位数,你重点看count和min/max是否合理——成绩列最小值是负数或者最大值超过 100,基本就是脏数据。

这个项目里常见的目标字段名有final_score、score或GPA,不同压缩包版本命名不一致。我的习惯是先打印列名再确认目标列,不要先入为主。数据行数通常在几百到几千之间,特征数量在 8 到 15 这个范围,属于典型的小规模表格学习场景。

2.2 缺失值、分类编码与特征构造

读进来之后要做三件事:处理缺失值、把文本类特征转成数值、构造有业务含义的新特征。这一步决定了模型上限,很多人拿着原始表直接丢给随机森林,结果效果很差,根源往往不是模型不行,而是特征没做。

import numpy as np # 1. 缺失值处理:数值列用中位数填充,分类列用众数填充 num_cols = df.select_dtypes(include=[np.number]).columns cat_cols = df.select_dtypes(include=["object"]).columns df[num_cols] = df[num_cols].fillna(df[num_cols].median()) df[cat_cols] = df[cat_cols].fillna(df[cat_cols].mode().iloc[0]) # 2. 分类字段编码:性别、家庭背景等 from sklearn.preprocessing import LabelEncoder for col in cat_cols: if col != "student_id": df[col + "_enc"] = LabelEncoder().fit_transform(df[col]) # 3. 业务特征构造:学习时长与出勤的交互项 df["study_efficiency"] = df["study_hours_per_week"] * df["attendance_rate"] df["assignment_completion"] = df["homework_submitted"] / (df["homework_total"] + 1e-6)

逻辑说明:缺失值用中位数而不是均值填充,是因为成绩、学时这类字段往往有离群点,中位数更稳健;+ 1e-6是防止除零。LabelEncoder适合定类特征,但对「家庭背景」这类有序等级使用OrdinalEncoder更合理,这一步要根据数据实际语义调整。

参数上的坑:mode()返回的可能是 Series,取.iloc[0]才能拿到第一行众数值;LabelEncoder对高基数特征(比如学号)不要编码,这也是我在代码里显式排除student_id的原因。做完这些后,把原始对象列和student_id从特征矩阵中去掉,否则会被模型当成数值特征使用。

2.3 特征相关性排查与冗余剔除

特征造完之后,先看一眼相关性矩阵,验证两个判断:第一,哪些特征和目标列强相关;第二,特征之间是否存在严重共线性。冗余特征不会直接让树模型崩溃,但会让线性模型的系数解读变得不可信,也会增加训练噪音。

import seaborn as sns import matplotlib.pyplot as plt feature_cols = [c for c in df.columns if c not in ["student_id", "final_score"]] # 排除掉未编码的原始文本列 feature_cols = [c for c in feature_cols if c not in cat_cols] corr = df[feature_cols + ["final_score"]].corr() plt.figure(figsize=(10, 8)) sns.heatmap(corr, annot=True, fmt=".2f", cmap="RdBu_r") plt.title("Feature Correlation Matrix") plt.savefig("output/corr_matrix.png", dpi=150) print("与目标列相关性最高的前5个特征:") print(corr["final_score"].drop("final_score").abs().sort_values(ascending=False).head(5))

逻辑说明:这个热力图是课设答辩里的高频截图之一,它能直观证明你做过特征分析而不是瞎跑模型。annot=True把相关系数写进格子,方便老师直接读取数值。

我的习惯是看一眼热力图后再决定是否去掉相关性绝对值超过 0.9 的特征对。如果发现「学习效率」和「周均学习时长」相关性极高,优先保留业务解释性更强的那个。项目包里如果自带特征筛选脚本,也不要直接用,先对比筛选前后的模型效果再下结论。

3. 模型落地全流程:线性回归跑基线,随机森林做提升,逻辑回归做分类对照

3.1 数据集划分与精细调参前的基线

所有的模型训练都应该在同一个数据切分上比较,否则结果没有可比性。我一般固定用 80% 训练、20% 测试,并且显式指定random_state,这个数字一旦定下来就全程不改,保证每次复现的结果一致。

from sklearn.model_selection import train_test_split X = df[feature_cols].copy() y = df["final_score"].copy() X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) print(f"训练集: {X_train.shape}, 测试集: {X_test.shape}")

逻辑说明:random_state=42让随机切分变得可复现。只要种子不变,所有人在任何一台机器上执行都会得到完全相同的训练测试划分——这在答辩现场被要求「再跑一遍」时特别重要。

下一步跑两个基线模型:线性回归和决策树回归。线性回归给一个直观的下限,决策树给一个非线性的参考。我用的是Pipeline,好处是标准化和模型训练绑定在一起,不会出现「训练时标准化、预测时忘掉标准化」的低级错误。

from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LinearRegression from sklearn.tree import DecisionTreeRegressor lin_pipe = Pipeline([ ("scaler", StandardScaler()), ("lr", LinearRegression()) ]) lin_pipe.fit(X_train, y_train) dt_pipe = Pipeline([ ("dt", DecisionTreeRegressor(max_depth=5, random_state=42)) ]) dt_pipe.fit(X_train, y_train) for name, model in [("线性回归", lin_pipe), ("决策树", dt_pipe)]: train_score = model.score(X_train, y_train) test_score = model.score(X_test, y_test) print(f"{name} -> 训练集 R^2: {train_score:.4f}, 测试集 R^2: {test_score:.4f}")

逻辑说明:score()对回归模型默认返回决定系数 R²。只看这一行输出,你就能看出模型是否过拟合——如果训练集 R² 0.98、测试集 0.72,典型过拟合;如果两者都在 0.85 左右,说明拟合程度健康。max_depth=5就是第一次直剪枝,把树的复杂度压住。

决策树我没做标准化是刻意的,因为树模型对特征尺度不敏感,而线性回归前必须标准化,所以只能把StandardScaler放进线性回归那一条 Pipeline 里,而不是对全量数据统一做变换。

3.2 随机森林与网格搜索调参

基线跑完后,上随机森林做主力模型。随机森林对这次的数据集几乎是最稳的选择:能处理非线性关系、对异常值不敏感、还能输出特征重要性。网格搜索这里不追求全局最优,而是快速圈定n_estimators、max_depth和min_samples_leaf三个核心参数的范围。

from sklearn.ensemble import RandomForestRegressor from sklearn.model_selection import GridSearchCV param_grid = { "n_estimators": [100, 300], "max_depth": [6, 10, None], "min_samples_leaf": [2, 5] } rf = RandomForestRegressor(random_state=42) grid = GridSearchCV( rf, param_grid, cv=5, scoring="neg_mean_squared_error", n_jobs=-1 ) grid.fit(X_train, y_train) print("最佳参数:", grid.best_params_) best_rf = grid.best_estimator_ print("最优模型测试集 R^2:", best_rf.score(X_test, y_test))

逻辑说明:scoring="neg_mean_squared_error"表示网格搜索内部用负均方误差做评价,越大越好,所以最佳参数对应的是 MSE 最小的组合。交叉验证设为cv=5是为了复用全部训练数据,防止单次切分运气好坏主导结果。n_jobs=-1让所有 CPU 核心一起跑,省时间。

参数解读:n_estimators从 100 加到 300,除非数据集很大,否则收益有限,但训练时间近似翻倍,所以这个网格只试探两档;max_depth=None让树完全生长,再由min_samples_leaf控制叶子最小样本数,这是防过拟合最常见的组合策略。如果搜索出来的最佳max_depth是 6,而你发现测试集 R² 比决策树高不了多少,那就说明特征侧的问题大于模型侧,该回头加特征。

3.3 分类视角对照:把回归任务改成及格预测

很多课设要求里会同时写「预测成绩」和「分析挂科风险」,前者是回归,后者是分类。项目包通常自带逻辑回归或支持向量机的分类脚本,用来判断「是否及格」。我做对照实验时,会把 60 分当作阈值生成二分类标签,然后比较逻辑回归和随机森林分类器的表现。

from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import accuracy_score, f1_score, confusion_matrix y_cls = (y >= 60).astype(int) X_train_c, X_test_c, y_train_c, y_test_c = train_test_split( X, y_cls, test_size=0.2, random_state=42 ) log_clf = Pipeline([ ("scaler", StandardScaler()), ("lr", LogisticRegression(max_iter=1000, random_state=42)) ]) log_clf.fit(X_train_c, y_train_c) pred_log = log_clf.predict(X_test_c) rf_clf = RandomForestClassifier(n_estimators=200, min_samples_leaf=3, random_state=42) rf_clf.fit(X_train_c, y_train_c) pred_rf = rf_clf.predict(X_test_c) for name, pred in [("逻辑回归", pred_log), ("随机森林分类", pred_rf)]: print(f"{name} -> 准确率: {accuracy_score(y_test_c, pred):.4f}, F1: {f1_score(y_test_c, pred):.4f}")

逻辑说明:逻辑回归作为分类基线,主要意义是给出一个可解释的决策边界,答辩时可以回答「哪些特征对挂科风险影响最大」这类问题;随机森林分类器则是为了直接对比树模型在分类任务上是否有优势。max_iter=1000是给逻辑回归迭代兜底,防止特征多时收敛警告干扰正常输出。

注意一点:分类任务的数据划分一定要用新的train_test_split,不要直接复用回归任务已经切好的X_train,因为y_cls是由y生成的,再切一次更干净,也方便你单独调整分类任务的random_state做敏感性验证。

3.4 回归评估指标的交叉验证:R²、RMSE、MAE 各有什么用

模型对比表是课设报告的核心部分,除了 R² 之外,RMSE 和 MAE 必须一起给出。RMSE 对预测误差中大偏差惩罚更重,适合判断是否存在「个别学生预测分差极大」的情况;MAE 反映平均偏差的绝对水平,直观意义就是「平均看预测差了多少分」。一个 5 分以内的 MAE 对成绩预测场景来说已经相当可用。

from sklearn.metrics import mean_squared_error, mean_absolute_error models = { "线性回归": lin_pipe, "决策树": dt_pipe, "随机森林(调参后)": best_rf } results = [] for name, model in models.items(): pred = model.predict(X_test) rmse = mean_squared_error(y_test, pred, squared=False) mae = mean_absolute_error(y_test, pred) r2 = model.score(X_test, y_test) results.append({ "模型": name, "R^2": round(r2, 4), "RMSE": round(rmse, 4), "MAE": round(mae, 4) }) print(pd.DataFrame(results).to_string(index=False))

逻辑说明:squared=False直接返回 RMSE 而非 MSE,这是新版 scikit-learn 的写法,旧版用np.sqrt(mean_squared_error(...))也行,但至少别把 MSE 当 RMSE 写进报告。这个表最终会被放进项目文档里作为「实验对比」章节的唯一数据支撑。

观察这张表有个规律:线性回归的 MAE 通常比随机森林稍差,但 RMSE 可能拉得更开,原因是线性模型对极端低分的预测往往差得很离谱,一个预测值为 28、实际只考了 17 的样本,它的平方误差对 RMSE 的贡献是 (11²=121),会让 RMSE 变大。如果你的项目里出现了这种极端样本,回到上一章做离群点检查,这才是正经解法。

4. 结果可视化与答辩解读:把模型的「黑匣子」讲成人话

4.1 特征重要性可视化与业务解释

随机森林训练完成后,feature_importances_是最直接的「黑匣子探针」。把这些数值画出来,不仅能证明模型学到了规律,更重要的是给了你一道答辩题的答案——「这个模型认为什么最重要」。

importances = best_rf.feature_importances_ importance_df = pd.DataFrame({ "feature": feature_cols, "importance": importances }).sort_values("importance", ascending=True) plt.figure(figsize=(8, 6)) plt.barh(importance_df["feature"], importance_df["importance"]) plt.xlabel("Importance") plt.title("Random Forest Feature Importance") plt.tight_layout() plt.savefig("output/feature_importance.png", dpi=150)

逻辑说明:barh画水平条形图,时间序列排在前面的特征会出现在图的上方,便于阅读。temperature这种在成绩预测数据集里不存在的特征就不用管,重点是你的图表字段必须与feature_cols严格对应,否则答辩时被问到某个特征含义会当场卡壳。

这里有一个几乎必然出现的现象:出勤率、学习时长、作业完成率会排在性别、家庭背景前面。这是合理的,因为行为类特征与学业表现之间的因果关系比背景类特征更直接。答辩时不要说「机器学习证明家庭背景不重要」,要说「在当前数据规模下,模型从行为特征中捕获了更多可用的区分信息,这为干预策略提供了依据」。

4.2 残差分布图:判断模型系统误差

特征重要性回答「模型靠什么」,残差图回答「模型哪里不行」。残差即y_test - y_pred,画成散点图之后有两种典型形态:残差随机分布在 0 轴上下,说明模型已经吃掉了大部分可学习信息;残差在某个分数段整体偏高,说明存在系统性偏差,比如模型系统性低估高分段学生。

pred_final = best_rf.predict(X_test) residuals = y_test.values - pred_final plt.figure(figsize=(8, 5)) plt.scatter(pred_final, residuals, alpha=0.6) plt.axhline(y=0, color="red", linestyle="--") plt.xlabel("Predicted Score") plt.ylabel("Residual (y_test - y_pred)") plt.title("Residual Plot - Random Forest") plt.savefig("output/residual_plot.png", dpi=150)

逻辑说明:alpha=0.6降低点的透明度,重叠太多时能看到密度分布。红色虚线是零误差参考线。如果点呈现漏斗状——右侧的点越来越分散,说明模型对高分段学生的预测方差更大,这在成绩预测里很常见,因为高分段学生受临场发挥影响大,数据里的信息量本身就不够。

这段分析对应答辩时的加分句:「从残差图可以看到,模型的预测误差在 60 到 80 分区段相对集中,而在 90 分以上发散,说明对于拔尖学生,当前特征体系不足以完全刻画其成绩表现。」这句话比「模型表现良好」有说服力得多。

4.3 成绩预测系统里最重要的「预测对照表」

很多课设报告只放散点图(真实值 vs 预测值),但现场答辩老师会直接翻到附录看你能不能让测试集上的每个样本都看得见摸得着。我建议生成一张前十个测试样本的对照表,包含student_id、真实成绩、预测成绩、绝对误差四列。

test_df = df.loc[X_test.index].copy() test_df["pred_score"] = pred_final test_df["abs_error"] = abs(test_df["final_score"] - test_df["pred_score"]) display_cols = ["student_id", "final_score", "pred_score", "abs_error"] print(test_df[display_cols].head(10).to_string(index=False))

逻辑说明:.loc[X_test.index]把原始 DataFrame 在测试集里的对应行全部捞回来,这样student_id才能对上,否则预测结果只是一组没有业务标识的数值。abs_error列让审查者一眼抓到每个样本的偏差。

做这一步至少有三个用途:第一,在论文里贴表比贴代码更有「完成度」;第二,答辩时如果老师问「你这模型到底准不准」,你可以指着某一行说「这位同学真实成绩 78,预测 76.5,误差 1.5 分」;第三,如果发现个别样本绝对误差超过 15 分,回到数据里查这个学生的原始记录,大概率能找到异常值或缺失值处理的纰漏。

5. 避坑指南:我在复现这个项目时踩过的 5 个坑

5.1 中文路径和编码问题导致的数据读取失败

现象:pd.read_csv("数据集/第一次提交/grades.csv")直接抛FileNotFoundError或UnicodeDecodeError;有时读到的是乱码标题行。

原因:多数课程设计资源包是在 Windows 环境打包的,文件夹名带中文或者数据文件是 UTF-8 带 BOM 编码。pandas 默认用系统编码读取,在 Windows 下是 GBK,与 UTF-8 文件不一致就报错。

解决:全项目路径改成纯英文字母,数据读取统一加encoding参数。如果文件是从 Excel 另存得到的,用encoding="utf-8-sig";如果是从 Python 里to_csv导出的,用encoding="utf-8"。实在不行就用errors="ignore"先读进来看一眼,再决定清洗方案。

5.2 成绩列离群值导致 R² 虚高或虚低

现象:训练完线性回归后 R² 高达 0.97,但测试集的 MAE 却有 9 分;另一个同学复现同样代码,结果 R² 只有 0.6,互相都对不上。

原因:数据里可能存在录入错误,比如某位学生final_score=150或者 0,这类极端值对线性模型影响极大——它可能单独把 R² 拉低 0.3 个点,也可能因为恰好分布在特征极端处而让模型把「总分虚高」学进去。

解决:训练前对所有成绩列做描述性统计,发现min < 0或max > 100的样本,先打印出来看是否真实存在。手工确认是录入错误就用df.drop()剔除,而不是clip到 0-100 区间。我一般会把剔除后的数据集单独保存一份grades_clean.csv,作为所有后续实验的公共数据源。

5.3 特征泄漏:把未来信息当成了预测依据

现象:模型测试集 R² 0.99,MAE 不到 2 分,看似完美,但换一批新学生预测时就崩了。

原因:数据集里可能混入了「最终绩点」「奖学金等级」「补考标记」这类期末成绩已经确定后才产生的列,这些特征里直接携带了目标信息。这是课设翻车率最高的问题,比参数没调好严重得多。

解决:建模前先按时间业务逻辑审查特征。成绩预测系统的合理特征只应该是学期开始前或学期中段已知的信息——入学成绩、前测成绩、学习行为统计数据;凡是期末才产生的字段一律剔除。我还会打印特征与目标列的相关性并人工检查,相关系数超过 0.9 的非目标列基本都是泄漏嫌疑。

5.4 random_state 没固定导致结果无法复现

现象:同一个模型脚本,上午跑 R² 0.87,下午跑变成 0.83;答辩前重新跑一次,输出和报告里写的完全不一致。

原因:train_test_split里没指定random_state,随机森林和决策树也没有固定种子。sklearn 的随机性来自 NumPy 全局随机流,每次执行都会重新洗牌。

解决:入口处统一设置np.random.seed(42),每次划分、每个模型都显式传random_state=42。如果项目里同时调多个模型,所有随机数都固定在同一套数字上。这套操作在报告里写一句话:「为保证实验可复现性,所有数据划分和模型实例化均固定随机种子为 42。」即可。

5.5 scikit-learn 版本差异导致 API 不兼容

现象:别人给的代码里model.score(X_test, y_test)正常,我这里报AttributeError;或者mean_squared_error(..., squared=False)报参数不存在。

原因:不同版本的 scikit-learn 对同一 API 的签名和默认值有调整。例如新版里squared=False才返回 RMSE,旧版用的是np.sqrt()手动转换;再比如旧版GridSearchCV的scoring传neg_mean_squared_error没问题,但极早期版本还没有这个选项。

解决:项目包里如果附了requirements.txt,先创建虚拟环境按指定版本装;没附就用pip freeze > requirements.txt把你本地跑通的依赖版本都记下来,交作业时连同代码一起提交。这样至少能保证别人复现时用的是同一套版本组合,不至于因为 API 变化卡在第一步。

6. 让项目变成「加分项」:从预测走向归因分析

基础模型跑通只能保证及格分,想让这个项目在答辩时成为加分项,建议在原系统里加一条「预警与干预」逻辑,它不改变预测结果,却能明显提升项目的完整度和业务深度。做法是给随机森林加一层归因规则:当模型预测某学生成绩低于 60 分时,取该学生特征值与全体学生平均值的差值绝对值最大的三个特征,作为「最可能的薄弱项」。

def suggest_intervention(sample_feature_series, importance_df, top_n=3): mean_vals = X_train.mean() diff = (sample_feature_series - mean_vals).abs() diff = diff.sort_values(ascending=False) top_cols = diff.index[:top_n].tolist() return top_cols sample = X_test.iloc[0] if best_rf.predict([sample])[0] < 60: weak_features = suggest_intervention(sample, importance_df) print(f"预测低于60分,建议关注特征: {weak_features}")

逻辑说明:这个函数的核心假设是与群体均值差距最大的特征,可能在塑造低成绩上起了更大作用。它不是严谨的因果推断,但作为系统的一个附加模块,能在答辩时展示「模型结果不是终点,而是决策起点」的产品思维。

把这段代码嵌入主流程后,系统就多了一个输出维度:预测分、风险等级、干预建议。答辩时可以顺着这条线讲——「当模型预测不及格时,系统会提示该生出勤率远低于均值、作业提交率低,那老师据此发出的提醒就会比」该生可能不及格「更具体」。从这类资源包出发做课程设计,最容易拉开差距的不是在算法上堆出新花样,而是把每个模块的工程闭环走完整。

从那以后,我每次拿到新的课设项目包,第一件事都不是跑模型,而是先看数据和特征有没有踩到上面那几个隐性坑,强制走一遍「读文件 → 查分布 → 查泄漏 → 固定种子 → 跑基线」的流程,这个习惯救了我很多次。如果你也在用这份资源,希望这篇笔记能帮你少走同样的弯路,把时间花在真正能得分的地方。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询