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

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

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

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

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

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

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

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

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

school sex age Medu Fedu studytime failures absences G1 G2 G3
GP F 18 4 4 2 0 6 5 6 6
GP F 15 1 1 2 3 10 7 8 10
GP F 15 4 2 3 0 2 15 14 15
GP M 16 4 3 2 0 10 15 15 15
GP M 16 2 2 2 0 0 12 12 11

上面只挑了 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-9 230
及格 10-12 407
中等 13-14 203
良好 15-16 134
优秀 17-20 70

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

三、环境准备

项目用到的库很简单,就下面几个,写进 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 的相关性
1 G2(第二学期成绩) 0.911
2 G1(第一学期成绩) 0.809
3 Medu(母亲受教育程度) 0.201
4 studytime(每周学习时间) 0.162
5 Fedu(父亲受教育程度) 0.160
6 famrel(家庭关系质量) 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 测试集 R2 MAE RMSE
梯度提升 0.8413 0.8334 0.923 1.605
随机森林 0.8432 0.8094 0.968 1.716
岭回归 0.8232 0.7929 1.047 1.790
线性回归 0.8230 0.7928 1.048 1.790
决策树 0.7789 0.7528 1.112 1.955

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

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

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

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

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

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

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

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

方案 测试集 R2 RMSE
含期中成绩 G1/G2 0.8090 1.719
不含期中成绩 G1/G2 0.0983 3.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 里的顺序把三个脚本依次跑一遍,就能复现出上面所有图表和结果。

更多推荐