☰
多变量LSTM成绩预测:Python实战与避坑指南
2026/9/29 14:00:00 网站建设 项目流程

简介:这份资源面向希望掌握LSTM多变量预测的Python学习者与算法入门者,围绕时间序列预测这一典型场景,系统讲解如何用多个输入特征预测目标变量,并评估模型预测成绩。包内共33个文件,以19个csv数据集和14个py脚本为主,csv用于提供香皂销售等序列样本,py脚本覆盖数据预处理、时间序列转监督学习、观测值缩放、平稳化处理、LSTM模型开发与多步预测等环节,压缩包约3.88MB。目前已有3419人学习下载,说明内容在入门与进阶人群中具备一定参考价值。资源按单变量、多变量、多步预测等模块组织,读者可据此理解滑动窗口构造输入输出对、差分与缩放等预处理思路,并借助MSE、MAE、R²等指标衡量预测成绩,进而调整学习率、隐藏层节点与批次大小,形成从数据准备到模型优化的完整实践路径。

1. 多变量 LSTM 预测成绩:从一张成绩单到可复现的 Python 方案

期末成绩单上,语文、数学、英语、物理、化学五列数字排在一起,你想知道下学期哪几门会掉、哪几门能冲上去。单变量 LSTM 只能吃一列历史分数,遇到「数学下滑但物理上升」这种交叉影响就抓瞎。多变量预测要做的,是把多科成绩当成一个整体序列喂进网络,让模型自己学出科目之间的耦合关系,再输出下一阶段的分数走向。这套东西在 Python 里用 PyTorch 或 Keras 都能落地,核心不在框架,而在数据怎么切、变量怎么选、归一化怎么做。适合已经跑通过单变量时间序列、想往多特征场景推进的读者,也适合拿成绩数据练手、准备迁移到设备寿命预测或量化因子预测的人。下面按「数据准备 → 模型搭建 → 训练调参 → 避坑 → 进阶验证」的顺序,把每一步拆到能直接抄。

2. 多变量成绩序列怎么切:窗口、步长与归一化的三个决定

2.1 为什么成绩预测必须用滑动窗口而不是整段序列

LSTM 的输入是一个三维张量,形状为(样本数, 时间步长, 特征数)。成绩数据本身是二维表,行是时间点,列是科目。要变成三维,就得用滑动窗口切。假设你有 6 个学期、5 门课的成绩,窗口长度设为 3,意思是「用前 3 个学期的 5 科分数,预测第 4 个学期的某一科或全部科目」。窗口每往后滑 1 个学期,就多一条样本。6 个学期、窗口 3,能切出 3 条样本,数据量确实少,但这是成绩场景的常态,后面会用数据增强和正则化来补。

窗口长度的选择有讲究。太短,模型看不到足够的历史趋势;太长,样本数骤减,且早期数据对当前预测的贡献被稀释。我一般会先画自相关图,看分数序列在滞后几期后相关性降到 0.3 以下,就以那个滞后数作为窗口长度的起点。成绩数据通常 2 到 4 个学期就够,因为教学周期本身有阶段性。

步长(stride)决定窗口每次滑动几格。步长 1 样本最多,但相邻样本高度重叠,容易过拟合;步长等于窗口长度则样本不重叠,但样本数太少。折中做法是步长取窗口长度的一半,比如窗口 4、步长 2。

2.2 多变量归一化:别对整张表做同一个 min-max

这是翻车率最高的地方。很多人拿到成绩表,直接对整个 DataFrame 做MinMaxScaler,结果语文 150 分制和英语 100 分制被压到同一区间,模型学到的「高低」关系是错的。正确做法是逐列归一化,每门课用自己的最小值和最大值。代码上就是scaler.fit_transform(df)之前确保df的每一列是独立科目,而不是把总分混进去。

更稳的做法是用sklearn的ColumnTransformer,对每列单独指定缩放器。如果某门课分数分布偏态严重,比如竞赛加分导致个别学期异常高,可以改用RobustScaler,它用中位数和四分位距,对离群点不敏感。

import numpy as np import pandas as pd from sklearn.preprocessing import MinMaxScaler # 假设 df 的列是 ['语文','数学','英语','物理','化学'],行是按学期排列 # 逐列归一化,每科独立 scaler = MinMaxScaler() scaled = scaler.fit_transform(df.values) # shape: (学期数, 科目数) def make_windows(data, window_size, stride, target_col): """ data: 归一化后的二维数组 (T, F) window_size: 用几个时间步预测下一步 stride: 窗口滑动步长 target_col: 要预测的科目索引,-1 表示预测全部科目 """ X, y = [], [] for start in range(0, len(data) - window_size, stride): end = start + window_size X.append(data[start:end, :]) # 窗口内所有科目 if target_col == -1: y.append(data[end, :]) # 预测下一学期全部科目 else: y.append(data[end, target_col]) # 只预测指定科目 return np.array(X), np.array(y) WINDOW = 3 STRIDE = 1 X, y = make_windows(scaled, WINDOW, STRIDE, target_col=1) # 预测数学 print(X.shape, y.shape) # 例如 (3, 3, 5) (3,)

这段代码里make_windows的target_col参数是关键。如果你只想预测数学成绩,就传数学列的索引;如果想一次性输出所有科目下一学期的分数,传-1。注意y的形状会随target_col变化,后面搭网络时输出层维度要对应改。

参数说明:WINDOW=3表示用前 3 个学期;STRIDE=1表示窗口每次滑 1 格,样本间有重叠,适合小数据集。如果学期数少于 8,建议STRIDE取 1 并配合 Dropout;如果学期数超过 20,可以取 2 或 3 来降低样本相关性。

2.3 训练集/测试集切分:时间序列不能随机打乱

成绩数据是严格按时间排的,随机切分会让「未来」的信息泄漏到训练集。正确做法是按时间点切:前 70% 学期做训练,后 30% 做测试。如果样本极少,可以用「留一学期交叉验证」:每次留一个学期做验证,其余训练,轮流一遍。

split = int(len(X) * 0.7) X_train, X_test = X[:split], X[split:] y_train, y_test = y[:split], y[split:] # 注意:归一化 scaler 只能在训练集上 fit,再 transform 测试集 # 上面为了演示简化了,实际应先切原始数据再 fit scaler

提示:归一化器的fit只能见训练数据,测试数据用训练集的参数做transform。否则测试集的最小最大值会污染训练过程,评估结果虚高。

3. PyTorch 搭多变量 LSTM:输入维度、隐藏层和输出层的对齐

3.1 从 nn.LSTM 的 batch_first 说起

PyTorch 的nn.LSTM默认输入形状是(seq_len, batch, input_size),但大多数人习惯(batch, seq_len, input_size)。加batch_first=True就能按习惯来。多变量场景下input_size等于科目数,比如 5 科就是 5。隐藏层维度hidden_size是超参,成绩数据小,32 或 64 就够,再大容易过拟合。

import torch import torch.nn as nn class GradeLSTM(nn.Module): def __init__(self, input_size, hidden_size, output_size, num_layers=1, dropout=0.2): super().__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0 ) self.fc = nn.Linear(hidden_size, output_size) def forward(self, x): # x: (batch, seq_len, input_size) out, (h_n, c_n) = self.lstm(x) # 取最后一个时间步的隐藏状态 last = out[:, -1, :] # (batch, hidden_size) return self.fc(last) # (batch, output_size)

num_layers=1时dropout参数不生效,PyTorch 会警告,所以代码里做了条件判断。output_size取决于你要预测几科:只预测数学就是 1,预测全部 5 科就是 5。forward里取out[:, -1, :]是取序列最后一个时间步的输出,因为我们要用窗口内所有历史预测下一步。

3.2 训练循环里的三个必调参数

学习率、批次大小、早停轮数。成绩数据样本少,批次大小设 4 或 8,别用 32。学习率从 1e-3 开始,如果 loss 震荡就降到 1e-4。早停用验证集 loss 连续 20 轮不降就停,防止过拟合。

model = GradeLSTM(input_size=5, hidden_size=32, output_size=1) criterion = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) X_train_t = torch.tensor(X_train, dtype=torch.float32) y_train_t = torch.tensor(y_train, dtype=torch.float32).unsqueeze(-1) for epoch in range(200): model.train() optimizer.zero_grad() pred = model(X_train_t) loss = criterion(pred, y_train_t) loss.backward() optimizer.step() if epoch % 20 == 0: print(f"Epoch {epoch}, Loss {loss.item():.6f}")

unsqueeze(-1)是把y_train从(batch,)变成(batch, 1),和模型输出对齐。如果预测多科,output_size=5,y_train_t就不需要unsqueeze,直接是(batch, 5)。

3.3 评估指标:别只看 MSE

MSE 对成绩这种有量纲的数据不直观。换算成 MAE 或 RMSE 后,再除以该科满分,得到「平均误差几个百分点」。比如数学满分 150,RMSE 为 7.5,就是平均差 5 分。还可以看方向准确率:预测涨跌方向和实际一致的比例。成绩预测里,方向比绝对值更重要。

from sklearn.metrics import mean_absolute_error, mean_squared_error model.eval() with torch.no_grad(): pred_test = model(torch.tensor(X_test, dtype=torch.float32)).numpy() mae = mean_absolute_error(y_test, pred_test) rmse = np.sqrt(mean_squared_error(y_test, pred_test)) print(f"MAE: {mae:.4f}, RMSE: {rmse:.4f}") # 反归一化后才是真实分数

反归一化用scaler.inverse_transform,但要注意scaler当初是对全部科目 fit 的,反归一化时要把预测值放回对应列的位置,其他列填 0 或均值,再逆变换,最后取那一列。

4. 成绩预测的避坑清单:从数据泄漏到过拟合的五个血泪教训

4.1 现象:验证 loss 比训练 loss 低很多

原因:数据泄漏。常见于归一化时用了全量数据 fit,或者切分时把未来学期混进了训练集。解决:先按时间切分,再在训练集上 fit scaler,测试集只 transform。检查X_train的时间索引是否全部早于X_test。

4.2 现象:预测值几乎是一条直线

原因:模型没学到东西,可能因为窗口太长导致样本太少,或者学习率太低陷入局部最优。解决:缩短窗口到 2,提高学习率到 1e-2 试一轮,看 loss 是否下降。如果仍不降,检查输入数据是否归一化后全为 0 或 1。

4.3 现象:训练 loss 降到很低,测试集一塌糊涂

原因:过拟合。成绩数据样本少、特征多,LSTM 参数量相对过大。解决:减小hidden_size到 16,加dropout=0.3,加 L2 正则化(weight_decay=1e-4),或者用早停。

4.4 现象:多科预测时某一科误差特别大

原因:该科分数分布和其他科差异大,比如体育课是等级制被转成了数字。解决:检查每科的量纲和分布,对异常科目单独做RobustScaler,或者从特征里剔除。

4.5 现象:换一批学生数据后模型完全失效

原因:不同学校、不同考试的难度和评分标准不同,模型学到的映射不通用。解决:把「考试难度」作为额外特征输入,或者用迁移学习,先在大规模成绩数据上预训练,再在小样本上微调。

注意:成绩数据涉及隐私,做实验时用脱敏或模拟数据。别把真实学生姓名、学号喂进模型。

5. 进阶验证:用「滚动预测」和「科目间注意力」把方案做扎实

滚动预测(walk-forward validation)是时间序列最诚实的评估方式。做法是:用前 3 个学期预测第 4 个,然后把第 4 个真实值并入历史,再预测第 5 个,如此滚动。这样能模拟真实使用场景——你永远只有过去的数据。代码上就是每次预测后把真实值 append 到输入窗口,再切下一个窗口。

def walk_forward(model, data, window_size, target_col): model.eval() history = data[:window_size].copy() preds = [] for i in range(window_size, len(data)): inp = torch.tensor(history[-window_size:], dtype=torch.float32).unsqueeze(0) with torch.no_grad(): p = model(inp).item() preds.append(p) # 把真实值并入历史,继续滚动 history = np.vstack([history, data[i]]) return np.array(preds)

这个函数返回的是每一步的预测值,和真实值对比就能画出滚动误差曲线。如果误差随滚动步数增大而急剧上升,说明模型对长期依赖捕捉不足,需要加长窗口或换 GRU。

另一个进阶方向是科目间注意力。多变量 LSTM 默认把所有科目同等对待,但实际中数学和物理强相关,语文和英语强相关。可以在 LSTM 输出后加一个注意力层,让模型自己学哪些科目对当前预测更重要。PyTorch 里可以用nn.MultiheadAttention,把 LSTM 每个时间步的输出作为 query、key、value,输出加权后的表示再进全连接。

class AttnLSTM(nn.Module): def __init__(self, input_size, hidden_size, output_size): super().__init__() self.lstm = nn.LSTM(input_size, hidden_size, batch_first=True) self.attn = nn.MultiheadAttention(hidden_size, num_heads=2, batch_first=True) self.fc = nn.Linear(hidden_size, output_size) def forward(self, x): out, _ = self.lstm(x) # (batch, seq, hidden) attn_out, _ = self.attn(out, out, out) last = attn_out[:, -1, :] return self.fc(last)

注意力层的num_heads设 2 或 4,成绩数据特征少,头数多了反而分散。训练时学习率要比纯 LSTM 再低一点,1e-4 起步。

我自己的习惯是:任何时间序列方案,先跑通单变量,再加多变量,再加注意力,每加一层都重新做滚动验证。别一次性堆完再调,否则出问题不知道是哪层的锅。成绩预测这种小样本场景,简单模型往往比复杂模型稳,注意力层带来的提升通常只有 1 到 2 个百分点,但训练时间翻倍。值不值得,看你对可解释性的需求——注意力权重能告诉你哪科对预测影响大,这本身就是有价值的信息。希望帮到你。

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

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

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

立即咨询