这次做的是一个基于机器学习的学生学业预测项目,把 UCI 公开的中学生成绩数据拿过来,用 Python 训练模型去预测学生的期末最终成绩,中间把数据清洗、特征工程、模型对比、结果可视化这一整套流程都走了一遍。

一、这个项目到底在做什么

如果你正在准备毕业答辩,这个选题其实挺讨巧的:它不需要复杂的深度学习环境,用的全是 sklearn 自带的算法,数据量也不大,跑起来很快,但麻雀虽小五脏俱全——数据探索、特征工程、模型训练、评估对比、可视化,一个完整的机器学习流程都覆盖到了。

先说清楚问题本身。我们要预测的是学生的期末最终成绩 G3,取值范围是 0 到 20 分,所以这是一个回归问题。数据里 G1 是第一学期成绩,G2 是第二学期成绩,G3 是期末最终成绩。数据集一共有 33 个属性,除了这三个成绩之外,剩下的都是学生的个人信息、家庭背景、学习习惯这些,比如每周学习时间、缺勤次数、父母受教育程度、是否上网、是否恋爱等等。

最终的目标就是:给一个学生的这些属性,模型能输出一个和真实期末分数比较接近的预测值,误差越小越好。

完整项目源码:https://pan.quark.cn/s/1e54aa2ae950

二、数据从哪来,长什么样

数据用的是 UCI 机器学习库里的 Student Performance 数据集,来自葡萄牙一所中学的真实数据,在很多学术论文里都被引用过。它分两个班级:数学班 395 条,葡萄牙语班 649 条。两个文件的列名完全一样,直接拼在一起就是 1044 条,数据更充足,训练出来的模型也更稳定。

先放几条真实数据感受一下格式:

schoolsexageMeduFedustudytimefailuresabsencesG1G2G3
GPF1844206566
GPF151123107810
GPF1542302151415
GPM16432010151515
GPM1622200121211

上面只挑了 5 行,实际一共 1044 行。其中 school 表示学校,sex 是性别,age 是年龄,Medu / Fedu 是母亲和父亲的受教育程度(0-4 分档),studytime 是每周学习时间(1 表示不到 2 小时,2 表示 2-5 小时,以此类推),failures 是过去挂科的次数,absences 是缺勤次数。

用 pandas 做一下描述性统计,重点看 G3 这一列:

统计量数值
样本数1044
均值11.34
标准差3.86
最小值0
25% 分位10
中位数11
75% 分位14
最大值20

可以看到成绩整体偏低,中位数只有 11 分,说明这个数据集里"差生"其实占了相当比例。把成绩按区间分档统计一下,分布更直观:

等级分数段人数
不及格0-9230
及格10-12407
中等13-14203
良好15-16134
优秀17-2070

这个分布也符合直觉:及格线附近堆了最多的人,两头少。在答辩的时候,老师问"你对自己的数据了解多少",能把这些数字报出来,印象分就不一样了。

三、环境准备

项目用到的库很简单,就下面几个,写进 requirements.txt 里:

pandas>=1.5
numpy>=1.23
scikit-learn>=1.2
matplotlib>=3.6
joblib>=1.2

先用下面的命令把依赖装上(第一次装会慢一点,后面就好了):

pip install -r requirements.txt

一个小提醒:本项目画图用的是 matplotlib,而 matplotlib 默认字体不认识中文,图里的标题、坐标轴全是方块乱码。解决办法是在代码开头设置字体为 SimHei(黑体),并且把负号显示问题也一起处理掉。下面的代码每张图都能正常显示中文,答辩时演示给老师看不会尴尬。

plt.rcParams["font.sans-serif"] = ["SimHei"]
plt.rcParams["axes.unicode_minus"] = False

四、项目结构

写代码之前先规划好整个工程的结构,答辩的时候老师十有八九会问"项目是怎么组织的",一张清晰的结构图比什么都强:

9-学生学业预测/
├── data/                      # 数据目录
│   ├── student-mat.csv        # 数学班成绩(UCI 原始数据)
│   ├── student-por.csv        # 葡萄牙语班成绩(UCI 原始数据)
│   └── student_combined.csv   # 合并后的 1044 条干净数据
├── output/                    # 输出目录(图表和结果表)
├── 1_data_explore.py          # 第一步:数据加载与探索性分析
├── 2_train_models.py          # 第二步:特征工程 + 模型训练与评估
├── 3_predict_demo.py          # 第三步:模型应用与预测演示
├── best_model.pkl             # 训练出的最优模型
└── requirements.txt           # 依赖库列表

我把整个流程拆成了三个脚本,对应机器学习项目最常见的三段式:探索数据 → 训练模型 → 使用模型。这样每段都能独立运行,也方便在答辩现场分段演示。

五、第一步:数据探索性分析

这一步的目的很单纯:先搞清楚数据长什么样,再决定后面怎么建模。我把两个班级的数据合并、检查缺失值和重复值、输出统计信息、绘制成绩分布图和相关性热力图,全部写在一个脚本里。

# -*- coding: utf-8 -*-
"""
学生学业预测 项目 —— 第一步:数据加载与探索性分析
"""
import os
import pandas as pd
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

# 图表中文显示:使用 SimHei 字体,并关闭负号乱码
plt.rcParams["font.sans-serif"] = ["SimHei"]
plt.rcParams["axes.unicode_minus"] = False

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "data")
OUT_DIR = os.path.join(BASE_DIR, "output")
os.makedirs(OUT_DIR, exist_ok=True)


def load_and_merge():
    """读取两份成绩数据并合并"""
    df_math = pd.read_csv(os.path.join(DATA_DIR, "student-mat.csv"), sep=";")
    df_por = pd.read_csv(os.path.join(DATA_DIR, "student-por.csv"), sep=";")
    print("数学班数据:{} 行,{} 列".format(df_math.shape[0], df_math.shape[1]))
    print("葡萄牙语班数据:{} 行,{} 列".format(df_por.shape[0], df_por.shape[1]))

    df = pd.concat([df_math, df_por], ignore_index=True)
    print("合并后数据:{} 行,{} 列".format(df.shape[0], df.shape[1]))
    return df


def basic_stats(df):
    """输出数据的基本信息"""
    print("\n===== 前 8 行数据预览 =====")
    print(df.head(8).to_string())

    print("\n===== 缺失值统计 =====")
    print(df.isnull().sum().sum(), "个缺失值")

    print("\n===== 重复值统计 =====")
    print(df.duplicated().sum(), "行完全重复")

    print("\n===== 数值型特征统计 =====")
    print(df.describe().round(2).to_string())

    print("\n===== 最终成绩 G3 分档统计 =====")
    bins = [0, 10, 13, 15, 17, 21]
    labels = ["不及格(0-9)", "及格(10-12)", "中等(13-14)", "良好(15-16)", "优秀(17-20)"]
    df["grade_level"] = pd.cut(df["G3"], bins=bins, labels=labels, right=False)
    print(df["grade_level"].value_counts().sort_index().to_string())


def plot_grade_distribution(df):
    """绘制最终成绩分布直方图"""
    plt.figure(figsize=(9, 5))
    bins = list(range(-1, 21))
    plt.hist(df["G3"], bins=bins, color="#4C72B0", edgecolor="white", alpha=0.85)
    plt.axvline(df["G3"].mean(), color="red", linestyle="--",
                label="平均值 {:.2f}".format(df["G3"].mean()))
    plt.xlabel("最终成绩 G3(0-20 分)")
    plt.ylabel("人数")
    plt.title("学生最终成绩分布直方图")
    plt.legend()
    plt.tight_layout()
    path = os.path.join(OUT_DIR, "grade_distribution.png")
    plt.savefig(path, dpi=150)
    plt.close()
    print("\n已保存图表:", path)


def plot_correlation_heatmap(df):
    """绘制数值特征与最终成绩的相关性热力图"""
    numeric_cols = ["age", "Medu", "Fedu", "traveltime", "studytime", "failures",
                    "famrel", "freetime", "goout", "Dalc", "Walc", "health",
                    "absences", "G1", "G2", "G3"]
    corr = df[numeric_cols].corr()

    fig, ax = plt.subplots(figsize=(11, 9))
    im = ax.imshow(corr, cmap="RdBu_r", vmin=-1, vmax=1)
    ax.set_xticks(range(len(corr.columns)))
    ax.set_yticks(range(len(corr.columns)))
    ax.set_xticklabels(corr.columns, rotation=45, ha="right")
    ax.set_yticklabels(corr.columns)
    plt.colorbar(im, shrink=0.75)
    for i in range(len(corr.columns)):
        for j in range(len(corr.columns)):
            ax.text(j, i, "{:.2f}".format(corr.iloc[i, j]), ha="center", va="center", fontsize=8)
    plt.title("数值特征相关性热力图")
    plt.tight_layout()
    path = os.path.join(OUT_DIR, "correlation_heatmap.png")
    plt.savefig(path, dpi=150)
    plt.close()
    print("已保存图表:", path)

    print("\n===== 与最终成绩 G3 相关性最高的前 6 个特征 =====")
    corr_with_g3 = corr["G3"].drop("G3").sort_values(ascending=False)
    print(corr_with_g3.head(6).round(3).to_string())


def main():
    df = load_and_merge()
    basic_stats(df)
    plot_grade_distribution(df)
    plot_correlation_heatmap(df)

    # 保存合并后的干净数据,供后续建模使用
    save_path = os.path.join(DATA_DIR, "student_combined.csv")
    df.drop(columns=["grade_level"]).to_csv(save_path, index=False, encoding="utf-8-sig")
    print("\n合并后的数据已保存:", save_path)


if __name__ == "__main__":
    main()

运行:

python 1_data_explore.py

脚本运行完,output 目录里会多出两张图,一张是成绩分布直方图,一张是相关性热力图。

值得记住的几个关键发现(答辩时直接讲这些就行):

第一,合并后的 1044 条数据没有任何缺失值,也没有完全重复的行,省掉了最麻烦的清洗工作。

第二,从相关性分析看,和最终成绩 G3 相关性最高的几个特征是:

排名特征与 G3 的相关性
1G2(第二学期成绩)0.911
2G1(第一学期成绩)0.809
3Medu(母亲受教育程度)0.201
4studytime(每周学习时间)0.162
5Fedu(父亲受教育程度)0.160
6famrel(家庭关系质量)0.054

这个结论非常直观:过去考得好不好,是预测未来成绩最有力的信号;其次是家庭背景里的母亲受教育程度,再往后才是学习时间、家庭关系这些。这也给后面的消融实验埋下了伏笔。

六、第二步:特征工程与模型训练

数据探索完,就要进入正题了。

6.1 特征工程做了什么

数据里有两类特征:数值型和类别型。数值型像年龄、缺勤次数、成绩这些直接就能用;类别型像学校、性别、是否上网这些,计算机不认识"GP"和"MS",必须转成数值。

我用的方法是独热编码(One-Hot Encoding):比如"是否上网"这一列有 yes / no 两种取值,就拆成"是否上网_yes"和"是否上网_no"两列,分别用 0/1 表示。一共 17 个类别特征拆完,加上 15 个数值特征,每行数据就变成了几十个维度的向量。

另外还有一个细节:线性回归这类模型对特征的量纲很敏感,比如"缺勤次数"最大能到 75,而"家庭关系"只有 1 到 5,如果不处理,数值大的特征会主导模型。所以我又用 StandardScaler 对数值特征做了标准化,让每个特征的均值是 0、标准差是 1。这两个操作我用 sklearn 的 ColumnTransformer 包在了一起,训练和预测用的都是同一套预处理,不会出现"训练的时候处理了、预测的时候忘了处理"这种低级错误。

6.2 选哪些模型来对比

毕设项目不建议一上来就上大模型,先拿几个经典模型做对比,说服力反而更强。我选了五个:

  1. 线性回归——最基础的基准线,模型简单,结果容易解释;
  2. 岭回归——在线性回归基础上加了 L2 正则,能抑制过拟合;
  3. 决策树——可解释性强,能画出树来看;
  4. 随机森林——决策树的集成,稳定性好;
  5. 梯度提升(GradientBoosting)——也是集成方法,但每个新树都去拟合前面的残差,通常精度最高。

6.3 评估方式

我用了两层评估:先在训练集上做 5 折交叉验证,把数据切成 5 份,轮流用 4 份训练、1 份验证,得到 5 个 R2 取平均,这样模型的稳定性能看得比较准;然后再把 80% 训练、20% 测试的划分跑一遍,在从来没见过的测试集上算最终的 MAE、RMSE、R2。指标含义:

  • MAE:预测值和真实值差的绝对值的平均,越小越好;
  • RMSE:均方根误差,对大的偏差更敏感,越小越好;
  • R2:决定系数,表示模型解释了多大比例的方差,越接近 1 越好。

完整代码:

# -*- coding: utf-8 -*-
"""
学生学业预测 项目 —— 第二步:特征工程 + 模型训练与评估
"""
import os
import warnings
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split, cross_val_score, KFold
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
from sklearn.linear_model import LinearRegression, Ridge
from sklearn.tree import DecisionTreeRegressor
from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
import joblib

warnings.filterwarnings("ignore")

# 图表中文显示:使用 SimHei 字体
plt.rcParams["font.sans-serif"] = ["SimHei"]
plt.rcParams["axes.unicode_minus"] = False

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "data")
OUT_DIR = os.path.join(BASE_DIR, "output")
os.makedirs(OUT_DIR, exist_ok=True)

RANDOM_STATE = 42

# 类别型特征(需要独热编码)
CAT_COLS = ["school", "sex", "address", "famsize", "Pstatus", "Mjob", "Fjob",
            "reason", "guardian", "schoolsup", "famsup", "paid", "activities",
            "nursery", "higher", "internet", "romantic"]

# 数值型特征
NUM_COLS = ["age", "Medu", "Fedu", "traveltime", "studytime", "failures",
            "famrel", "freetime", "goout", "Dalc", "Walc", "health",
            "absences", "G1", "G2"]

TARGET = "G3"


def load_data():
    """加载第一步合并好的数据"""
    path = os.path.join(DATA_DIR, "student_combined.csv")
    df = pd.read_csv(path)
    print("加载数据:{} 行,{} 列".format(df.shape[0], df.shape[1]))
    return df


def build_preprocessor(include_grades=True):
    """构造特征预处理流水线:类别独热编码 + 数值标准化"""
    if include_grades:
        num_cols = NUM_COLS
    else:
        # 不含期中成绩 G1、G2 的方案
        num_cols = [c for c in NUM_COLS if c not in ("G1", "G2")]

    preprocessor = ColumnTransformer(
        transformers=[
            ("num", StandardScaler(), num_cols),
            ("cat", OneHotEncoder(handle_unknown="ignore"), CAT_COLS),
        ]
    )
    return preprocessor, num_cols


def get_models():
    """定义参与对比的模型列表"""
    models = {
        "线性回归": LinearRegression(),
        "岭回归": Ridge(alpha=1.0),
        "决策树": DecisionTreeRegressor(max_depth=6, random_state=RANDOM_STATE),
        "随机森林": RandomForestRegressor(n_estimators=300, max_depth=12,
                                       min_samples_leaf=1, random_state=RANDOM_STATE,
                                       n_jobs=-1),
        "梯度提升": GradientBoostingRegressor(n_estimators=200, max_depth=3,
                                           learning_rate=0.05, random_state=RANDOM_STATE),
    }
    return models


def evaluate_model(name, model, X_train, X_test, y_train, y_test):
    """对单个模型做 5 折交叉验证,并在测试集上计算指标"""
    kf = KFold(n_splits=5, shuffle=True, random_state=RANDOM_STATE)
    cv_r2 = cross_val_score(model, X_train, y_train, cv=kf, scoring="r2").mean()

    model.fit(X_train, y_train)
    y_pred = model.predict(X_test)
    mae = mean_absolute_error(y_test, y_pred)
    rmse = np.sqrt(mean_squared_error(y_test, y_pred))
    r2 = r2_score(y_test, y_pred)

    print("\n[{}] 交叉验证 R2 = {:.4f} | 测试集 R2 = {:.4f} | MAE = {:.3f} | RMSE = {:.3f}".format(
        name, cv_r2, r2, mae, rmse))
    return {
        "模型": name,
        "交叉验证R2": round(cv_r2, 4),
        "测试集R2": round(r2, 4),
        "MAE": round(mae, 3),
        "RMSE": round(rmse, 3),
    }


def plot_model_comparison(results_df):
    """绘制模型对比柱状图"""
    fig, axes = plt.subplots(1, 2, figsize=(13, 5))
    names = results_df["模型"].tolist()

    axes[0].bar(names, results_df["交叉验证R2"], color="#4C72B0", alpha=0.9)
    axes[0].set_title("各模型交叉验证 R2 对比")
    axes[0].set_ylabel("R2")
    axes[0].set_ylim(0, 1)
    for i, v in enumerate(results_df["交叉验证R2"]):
        axes[0].text(i, v + 0.01, "{:.3f}".format(v), ha="center", fontsize=9)

    axes[1].bar(names, results_df["RMSE"], color="#DD8452", alpha=0.9)
    axes[1].set_title("各模型测试集 RMSE 对比")
    axes[1].set_ylabel("RMSE")
    for i, v in enumerate(results_df["RMSE"]):
        axes[1].text(i, v + 0.02, "{:.3f}".format(v), ha="center", fontsize=9)

    plt.setp(axes, xticklabels=names)
    plt.tight_layout()
    path = os.path.join(OUT_DIR, "model_comparison.png")
    plt.savefig(path, dpi=150)
    plt.close()
    print("\n已保存图表:", path)


def plot_feature_importance(model, feature_names, top_n=15, model_name="模型"):
    """绘制模型的特征重要性条形图"""
    importances = model.feature_importances_
    idx = np.argsort(importances)[::-1][:top_n]
    plt.figure(figsize=(9, 6))
    plt.barh([feature_names[i] for i in idx][::-1], importances[idx][::-1], color="#55A868")
    plt.xlabel("特征重要性")
    plt.title("{}特征重要性 Top{}".format(model_name, top_n))
    plt.tight_layout()
    path = os.path.join(OUT_DIR, "feature_importance.png")
    plt.savefig(path, dpi=150)
    plt.close()
    print("已保存图表:", path)
    return [(feature_names[i], round(float(importances[i]), 4)) for i in idx]


def plot_real_vs_pred(y_test, y_pred, name):
    """绘制真实值与预测值散点图"""
    plt.figure(figsize=(7, 7))
    plt.scatter(y_test, y_pred, alpha=0.55, color="#4C72B0", edgecolor="white", s=45)
    line_x = np.linspace(0, 20, 50)
    plt.plot(line_x, line_x, "r--", label="理想线 y = x")
    plt.xlabel("真实成绩 G3")
    plt.ylabel("预测成绩 G3")
    plt.title("{} 真实值与预测值散点图".format(name))
    plt.legend()
    plt.tight_layout()
    path = os.path.join(OUT_DIR, "real_vs_pred.png")
    plt.savefig(path, dpi=150)
    plt.close()
    print("已保存图表:", path)


def ablation_test(df):
    """对比"含 G1/G2"与"不含 G1/G2"两种方案的随机森林性能"""
    print("\n===== 消融实验:期中成绩对最终成绩预测的影响 =====")
    results = []
    for label, include in [("含期中成绩G1/G2", True), ("不含期中成绩G1/G2", False)]:
        pp, num_cols = build_preprocessor(include)
        X = df[NUM_COLS + CAT_COLS] if include else df[[c for c in NUM_COLS if c not in ("G1", "G2")] + CAT_COLS]
        y = df[TARGET]
        X_train, X_test, y_train, y_test = train_test_split(
            X, y, test_size=0.2, random_state=RANDOM_STATE)
        model = RandomForestRegressor(n_estimators=200, max_depth=10,
                                      random_state=RANDOM_STATE, n_jobs=-1)
        pipe = Pipeline([("pre", pp), ("model", model)])
        pipe.fit(X_train, y_train)
        y_pred = pipe.predict(X_test)
        r2 = r2_score(y_test, y_pred)
        rmse = np.sqrt(mean_squared_error(y_test, y_pred))
        results.append({"方案": label, "测试集R2": round(r2, 4), "RMSE": round(rmse, 3)})
        print("[{}] 测试集 R2 = {:.4f} | RMSE = {:.3f}".format(label, r2, rmse))
    return pd.DataFrame(results)


def main():
    df = load_data()

    # ---------- 含期中成绩的方案(主模型) ----------
    print("\n===== 主建模方案:使用全部特征预测最终成绩 G3 =====")
    X = df[NUM_COLS + CAT_COLS]
    y = df[TARGET]
    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.2, random_state=RANDOM_STATE)
    print("训练集:{} 条,测试集:{} 条".format(len(X_train), len(X_test)))

    models = get_models()
    results = []
    trained = {}
    for name, model in models.items():
        preprocessor, _ = build_preprocessor()
        pipe = Pipeline([("pre", preprocessor), ("model", model)])
        r = evaluate_model(name, pipe, X_train, X_test, y_train, y_test)
        results.append(r)
        trained[name] = pipe

    results_df = pd.DataFrame(results)
    results_df = results_df.sort_values("测试集R2", ascending=False).reset_index(drop=True)
    print("\n===== 模型性能对比表 =====")
    print(results_df.to_string(index=False))

    # 保存结果表
    results_df.to_csv(os.path.join(OUT_DIR, "model_results.csv"),
                      index=False, encoding="utf-8-sig")
    plot_model_comparison(results_df)

    # ---------- 最优模型 ----------
    best_name = results_df.iloc[0]["模型"]
    best_model = trained[best_name]
    print("\n最优模型:", best_name)

    # 保存模型
    joblib.dump(best_model, os.path.join(BASE_DIR, "best_model.pkl"))
    print("最优模型已保存:best_model.pkl")

    # 特征重要性
    rf = best_model.named_steps["model"]
    if hasattr(rf, "feature_importances_"):
        pre = best_model.named_steps["pre"]
        cat_names = pre.named_transformers_["cat"].get_feature_names_out(CAT_COLS).tolist()
        feature_names = NUM_COLS + cat_names
        imp = plot_feature_importance(rf, feature_names, model_name=best_name)
        print("Top 特征:", imp[:5])

    # 真实值与预测值散点图
    y_pred = best_model.predict(X_test)
    plot_real_vs_pred(y_test, y_pred, best_name)

    # ---------- 消融实验 ----------
    abl = ablation_test(df)
    abl.to_csv(os.path.join(OUT_DIR, "ablation_results.csv"),
               index=False, encoding="utf-8-sig")
    print("\n消融实验结果已保存:ablation_results.csv")

    print("\n全部完成,输出文件位于:", OUT_DIR)


if __name__ == "__main__":
    main()

运行:

python 2_train_models.py

6.4 五个模型的实测结果

跑完的模型对比表(测试集 209 条数据):

模型交叉验证 R2测试集 R2MAERMSE
梯度提升0.84130.83340.9231.605
随机森林0.84320.80940.9681.716
岭回归0.82320.79291.0471.790
线性回归0.82300.79281.0481.790
决策树0.77890.75281.1121.955

几个值得在答辩时展开的点:

  • 梯度提升拿下第一:测试集 R2 达到 0.8334,MAE 只有 0.923,也就是说平均误差还不到 1 分。它比单棵决策树强了一大截,这说明集成学习方法确实比单个模型稳定;
  • 线性模型表现也不错:线性回归和岭回归的测试集 R2 都在 0.79 左右,说明成绩和学生属性之间本身就存在比较强的线性关系;
  • 单棵决策树最弱:只有 0.75 左右,单棵树太容易过拟合,深度有限,学不到太多规律。

最终我选了梯度提升作为最优模型,用 joblib 保存成 best_model.pkl 文件,后面第三步直接加载使用。

模型的特征重要性排名(梯度提升输出):

排名特征重要性
1G2(第二学期成绩)0.8506
2absences(缺勤次数)0.0610
3G1(第一学期成绩)0.0408
4goout(外出频率)0.0073
5studytime(每周学习时间)0.0072

第二学期成绩 G2 一个特征就占了 85% 的重要性,和前面相关性分析的结果完全对上了。这也说明:学生成绩预测这件事,历史成绩是最强的信号

6.5 消融实验:去掉期中成绩还能不能预测

答辩的时候老师很可能会追问:"你预测的依据主要就是 G1、G2,那如果学生刚入学、还没有期中成绩呢?"这个问题我提前做了实验来回答,学术上叫消融实验——把 G1、G2 从特征里去掉,重新用随机森林训练,看看效果差多少。

方案测试集 R2RMSE
含期中成绩 G1/G20.80901.719
不含期中成绩 G1/G20.09833.734

结果非常明显:没有期中成绩时,R2 直接从 0.809 掉到 0.098,几乎等于瞎猜。这说明单靠家庭背景和学习习惯这些属性,很难精确预测一个学生的期末分数——这不是模型的锅,而是这些信息本身对"考试结果"的解释力有限。这个实验的结论可以用来回答老师的问题,也能顺带体现你对"特征选择"和"模型局限性"是有思考的。

七、第三步:用训练好的模型做预测

模型训好不能只会看数字,还得能实际用起来。第三步我写了一个演示脚本:加载 best_model.pkl,模拟两个截然不同的学生,把他们的特征填进去,让模型输出预测分数和对应的成绩等级。

# -*- coding: utf-8 -*-
"""
学生学业预测 项目 —— 第三步:模型应用与预测演示
"""
import os
import pandas as pd
import joblib

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
MODEL_PATH = os.path.join(BASE_DIR, "best_model.pkl")

# 数值型与类别型特征列顺序,必须与训练时保持一致
NUM_COLS = ["age", "Medu", "Fedu", "traveltime", "studytime", "failures",
            "famrel", "freetime", "goout", "Dalc", "Walc", "health",
            "absences", "G1", "G2"]
CAT_COLS = ["school", "sex", "address", "famsize", "Pstatus", "Mjob", "Fjob",
            "reason", "guardian", "schoolsup", "famsup", "paid", "activities",
            "nursery", "higher", "internet", "romantic"]


def grade_level(score):
    """把预测分数转换为成绩等级"""
    if score < 10:
        return "不及格(0-9 分)"
    if score < 13:
        return "及格(10-12 分)"
    if score < 15:
        return "中等(13-14 分)"
    if score < 17:
        return "良好(15-16 分)"
    return "优秀(17-20 分)"


def predict_one(model, student_info):
    """根据单个学生的特征字典,输出预测结果"""
    df = pd.DataFrame([student_info])
    pred = model.predict(df)[0]
    pred = round(float(pred), 2)
    return pred, grade_level(pred)


def main():
    if not os.path.exists(MODEL_PATH):
        print("未找到模型文件,请先运行 2_train_models.py")
        return

    model = joblib.load(MODEL_PATH)
    print("模型加载成功:best_model.pkl")

    # 演示 1:一位认真学习的学霸型学生
    student_a = {
        "age": 16, "Medu": 4, "Fedu": 4, "traveltime": 1, "studytime": 4,
        "failures": 0, "famrel": 5, "freetime": 3, "goout": 2, "Dalc": 1,
        "Walc": 1, "health": 5, "absences": 0, "G1": 16, "G2": 17,
        "school": "GP", "sex": "F", "address": "U", "famsize": "LE3",
        "Pstatus": "T", "Mjob": "teacher", "Fjob": "teacher", "reason": "home",
        "guardian": "mother", "schoolsup": "yes", "famsup": "yes", "paid": "no",
        "activities": "yes", "nursery": "yes", "higher": "yes",
        "internet": "yes", "romantic": "no",
    }

    # 演示 2:一位出勤率低、上学期挂科较多的学生
    student_b = {
        "age": 18, "Medu": 1, "Fedu": 1, "traveltime": 3, "studytime": 1,
        "failures": 3, "famrel": 3, "freetime": 5, "goout": 4, "Dalc": 3,
        "Walc": 3, "health": 2, "absences": 18, "G1": 6, "G2": 7,
        "school": "MS", "sex": "M", "address": "R", "famsize": "GT3",
        "Pstatus": "A", "Mjob": "health", "Fjob": "other", "reason": "course",
        "guardian": "father", "schoolsup": "no", "famsup": "no", "paid": "no",
        "activities": "no", "nursery": "no", "higher": "no",
        "internet": "no", "romantic": "yes",
    }

    print("\n===== 预测演示 1:学霸型学生 =====")
    score1, level1 = predict_one(model, student_a)
    print("预测最终成绩:{} 分,等级:{}".format(score1, level1))

    print("\n===== 预测演示 2:学习困难型学生 =====")
    score2, level2 = predict_one(model, student_b)
    print("预测最终成绩:{} 分,等级:{}".format(score2, level2))

    # 批量预测展示:输出一张表格
    print("\n===== 预测结果汇总表 =====")
    summary = pd.DataFrame({
        "学生编号": ["学生A(学霸型)", "学生B(困难型)"],
        "预测成绩": [score1, score2],
        "成绩等级": [level1, level2],
    })
    print(summary.to_string(index=False))
    summary.to_csv(os.path.join(BASE_DIR, "output", "prediction_demo.csv"),
                   index=False, encoding="utf-8-sig")
    print("\n预测结果已保存:output/prediction_demo.csv")


if __name__ == "__main__":
    main()

运行:

python 3_predict_demo.py

实际跑出来的结果:

学生编号预测成绩成绩等级
学生A(学霸型)16.79良好(15-16 分)
学生B(困难型)6.90不及格(0-9 分)

学霸型学生(学习时间每周 4 档、从不缺勤、零挂科、前两次成绩 16 和 17 分)被预测为 16.79 分;而困难型学生(缺勤 18 次、挂科 3 门、前两次成绩只有 6 和 7 分)被预测为 6.90 分。这个结果完全符合直觉,模型的行为是合理的。

答辩现场就可以这么演示:先讲学生 A 的各项特征,问一句"你觉得他能考多少分",再跑代码给出预测,效果比干讲一堆指标好得多。

八、答辩时容易被问到的问题

把项目做完,重点准备下面这几个问题,基本就能应付大部分情况:

1. 为什么用回归而不是分类?
因为目标是 0-20 的连续分数,用回归能得到更细粒度的结果;而且 RMSE 这种连续指标能反映误差的绝对大小。如果只看"及格/不及格",信息就损失太多了。

2. 训练集和测试集为什么要分开?
如果直接在训练用的数据上评估,模型"背题"了,指标虚高,根本反映不了真实水平。用 20% 没见过的数据测试,才能说明模型真的学到了规律。再加上交叉验证,能进一步排除随机切分的运气成分。

3. 为什么梯度提升效果最好?
它每轮都在拟合前面模型的残差,把上一轮没学好的部分继续学,集成下来精度自然更高。对比单棵决策树只有 0.75 的 R2,集成方法的优势很明显。

4. 特征重要性里 G2 占了 85%,模型是不是没意义?
这个问题其实正好引出消融实验。没有 G1/G2 时 R2 只有 0.098,说明光靠背景和习惯信息确实预测不准;但反过来,如果把 G1/G2 当成"已有的期中成绩",这个模型在学期中就可以提前预警哪些学生期末可能不及格,这就是实际应用价值。

5. 模型有没有局限性?
有。数据只来自葡萄牙两所学校,样本量 1044 条,放到别的国家、别的教育体系可能就不适用;另外预测只能做到"大概率",不能替代老师的主观判断,个别学生可能被误判。这些如实说出来,反而显得你思考全面。

九、总结与展望

到这里,一个完整的学生学业预测项目就做完了。回顾一下整个流程:

  1. 数据探索:合并两个班级共 1044 条数据,确认无缺失值,发现历史成绩与最终成绩高度相关;
  2. 特征工程:17 个类别特征独热编码,15 个数值特征标准化;
  3. 模型训练与对比:5 种经典模型,5 折交叉验证加独立测试集,梯度提升以测试集 R2 0.8334、MAE 0.923 胜出;
  4. 消融实验:去掉期中成绩后 R2 跌到 0.098,验证了历史成绩的核心地位;
  5. 应用演示:用保存的模型对两名典型学生做预测,结果符合常识。

如果想让这个项目再往深走一步,可以考虑几个方向:一是把预测结果做成一个小型 Web 界面或者图形界面,老师输入学生信息就能看预测结果,展示效果更好;二是试试更细的超参数调优(比如网格搜索);三是把成绩按等级做成分类任务,和回归结果对比。数据侧也可以考虑引入更多特征,比如课堂表现、作业完成度这些,能进一步提升模型的表现。

整个项目的代码都放在文末链接的源码包里,需要的同学可以自取,按 README 里的顺序把三个脚本依次跑一遍,就能复现出上面所有图表和结果。

更多推荐