简介:时间序列预测是深度学习中极具工程实践价值的应用方向,从股票行情到气象预报,LSTM凭借门控机制有效建模序列中的长期依赖关系,成为处理时序数据的核心技术之一。当前,许多研究者和开发者将LSTM应用于各类随机序列的建模与分析,探索其在复杂场景下的能力边界。本文以经典的双色球历史数据作为实验对象,系统性介绍从数据清洗、滑窗构造、归一化处理到LSTM模型设计、训练调参与结果评估的完整流程。详细讲解红球与蓝球拆分建模的思路,并对比随机基线与历史频率基线的评估方法,帮助读者理解在低信噪比数据上如何科学衡量模型效果。无论是准备毕业设计,还是希望快速上手时间序列预测项目,都能从中获得可复现的代码结构与实践经验。
1. 毕业设计选题:为什么双色球预测+LSTM是一个能打能讲的题目
每年毕业设计选题季,计算机专业的同学基本都会遇到同一个情况:题目太虚的写不出东西,题目太实的没有创新点,题目太难的三个月做不完。我当时在选题表上看到"基于深度学习的LSTM算法双色球预测"这个方向时,第一反应是——这玩意儿真的能预测吗?会不会被答辩老师认为是搞迷信?
但真正做下来之后,我发现自己想错了。这个题目被选作毕业设计,靠的是它独特的教学价值,而不是"预测准确率"本身。
先说清楚,双色球是典型的随机事件,任何模型都不可能从中稳定找到"中奖规律",这一点在开题报告里就必须明确表态。但正因为它是随机序列,反而让LSTM这种时间序列模型有了一个非常标准的实验场:你可以在完全公开的数据上验证模型的效果、对比不同网络结构的差异、讨论数据量对预测结果的影响。这类课题的评分核心在于"你的模型为什么这样设计、数据是怎么处理的、结果该怎么解读",而不是"你预测出了几等奖"。
从难度梯度上看,这个题目也非常适合本科毕业设计。它的技术栈是Python + TensorFlow/Keras + Pandas + Matplotlib,全部是深度学习入门必备工具;数据获取不需要爬复杂的网站,历史开奖数据在公开渠道都有;模型结构不用自己设计复杂的网络,标准的LSTM堆叠就能跑出结果。相比"基于深度学习的某某图像识别"这类动辄需要大规模数据集和高性能显卡的题目,双色球预测项目的数据量只有几千条,普通笔记本CPU就能完成训练,这大大降低了环境准备的门槛。
另一个容易被忽视的点是,这个题目天然自带"完整链路"。从数据爬取、数据清洗、特征工程、模型搭建、训练调参到结果可视化和论文写作,每一个环节都有明确的任务产出,学生不会出现"没东西可写"的困境。我给自己的要求是把这个链路彻底跑通,不能只停留在跑通demo的层面,而是要能回答"每一步为什么这么做"。
接下来这篇博文,我从头到尾梳理一遍这个项目的完整实现过程,包括数据怎么处理、模型怎么设计、训练有哪些坑、论文怎么组织,以及最终交付的代码结构应该长什么样。如果你也在准备这个题目,或者想找一个能快速上手的时间序列预测练手项目,这篇文章应该能帮你节省不少试错时间。
2. 双色球数据到底该怎么处理:先搞清楚你的数据长什么样
2.1 数据源的获取与基础清洗
双色球数据本质上是一个典型的多变量时间序列。每期开奖产生7个数字:6个红球(范围01-33)和1个蓝球(范围01-16),红球不重复且按大小排序公布。数据源一般从公开的彩票历史开奖信息接口获取,保存成CSV格式,字段包括期号、开奖日期、红球1到红球6、蓝球。
拿到原始数据后,第一件事不是处理格式,而是观察数据分布。我当时把1993年至今(双色球游戏本身从2003年开始销售)的数据拉下来后,先做了一轮基础检查:
- 期号是否连续,有没有遗漏期;
- 日期字段是否完整,有没有重复记录;
- 红球号码是否都在01-33范围内,蓝球是否都在01-16范围内;
- 每一期的6个红球是否互不重复。
这些检查看着琐碎,但在实际写代码时需要特别注意。比如有的数据源会把开奖日期格式写成"2023-01-03"和"2023/01/03"混排,有的在早期年份会把号码写成"1"而不是"01",如果不统一格式化,后面特征工程时会出现难以排查的类型错误。
数据清洗完成后,还需要思考一个关键问题:数据从中奖公告页面拿到的原始格式,和模型需要的格式之间,差了一个"滑窗"操作。这一步是所有时序预测任务的核心,放在下一节讲。
2.2 滑窗构造:把开奖序列变成监督学习样本
LSTM处理时间序列时,不能直接把"这一期的号码"作为输入直接预测"下一期的号码"。模型的输入必须是一个固定长度的历史窗口,输出是窗口之后那期的目标值。这种从纯序列到监督学习样本的转换,在时间序列领域叫滑窗(sliding window)构造。
假设我们用前10期开奖数据预测第11期,那么数据集的组织方式如下:
- 输入(X):第1期到第10期的全部开奖号码;
- 输出(y):第11期的红球和蓝球。
接着窗口滑动一期,输入变成第2期到第11期,输出是第12期,以此类推。理论上窗口越宽,模型能看到的历史规律越多,但双色球总共也就3000多期数据,窗口太大会导致训练样本数量急剧下降。window_size=10是我实测下来一个比较合适的平衡点,既能保留一定的历史长度,又不会把样本量砍得太狠。
在实现滑窗时,另外一个需要处理的问题是:红球和蓝球要不要放在同一个模型里一起预测?
我见过不少实现选择把红球6个号码和蓝球1个号码拼成7维向量,直接作为LSTM的输出。这样做的好处是代码简单,模型输出层只需7个神经元;劣势是红球和蓝球在语义上完全独立,蓝球是从16个数字里选1个,红球是从33个数字里选6个且不能重复,它们的内在分布特征差异很大,强行绑在同一个模型里其实是不合理的。
我最终采用的是"分而治之"的思路:红球和蓝球各建一个LSTM子模型,共享相同的输入窗口,但输出层和损失函数分开。在每个子模型内部,红球输出6个值,蓝球输出1个值。这样训练时各子模型可以针对自身的数据分布优化,评估时也能分别统计命中情况,毕业答辩时讲这个设计理由也非常加分。
2.3 数据归一化:不归一化的后果你真的体验过吗
时间序列输入到LSTM之前,一个很容易被忽略但影响巨大的操作是归一化。双色球号码的取值范围是1到33,量级本身不大,很多同学觉得"这点数值直接喂给网络也没问题",于是跳过归一化。我在初版实现里也这么干过,结果训练过程中的损失曲线在后期出现明显的震荡,验证集上预测出来的数字经常超出合法范围(比如红球预测出37、蓝球预测出18)。
归一化在这里的真正作用是配合LSTM的激活函数。LSTM内部大量使用tanh激活函数,输出范围是[-1,1],如果你的输入数值是不归一化的33,经过tanh后已经被压到了接近饱和区,梯度在反向传播时非常容易消失。所以标准的做法是使用MinMaxScaler把所有号码压缩到[0,1]区间,训练完成后再用inverse_transform把预测结果还原成真实的号段。
关于MinMaxScaler有一个细节:必须使用训练集的scaler去transform验证集和测试集,而不是对全量数据统一fit。否则测试集的信息会被泄露到训练过程中,导致指标虚高,这在论文写作中被认为是不严谨的做法。我当时在答辩前自查时专门核对了这一点,确认所有评估环节都用的是训练集得到的scaler,才敢在论文里写评估数据。
3. LSTM模型设计:堆几个LSTM层,输出层怎么定,全靠实验说话
3.1 为什么是LSTM而不是RNN或者Transformer
既然是做毕业设计,选择LSTM需要有一个能说服答辩老师的理由。我在开题报告中把LSTM和传统RNN做了对比:RNN在处理长序列时存在梯度消失问题,而LSTM通过遗忘门、输入门、输出门三个门控机制,让信息可以选择性地跨时间步传递,更适合捕捉数据中潜在的长期依赖关系。
那双色球数据有这个"长期依赖"吗?严格说,开奖号码是独立随机事件,理论上模型捕捉不到"规律"。但LSTM在随机序列上仍然能学到一些统计特征,比如号码分布的边缘概率、冷热号的变化趋势等。我在论文里是这样措辞的:本项目尝试从历史数据中挖掘统计层面的相关性,并检验LSTM对低维随机序列的拟合能力,而非试图寻找确定性的中奖规律。这个表述既如实反映了研究性质,又体现了你对所使用方法的理解深度。
3.2 网络结构:两层LSTM+Dense的经典组合
我的最终模型结构是一个经典配置,在很多时序预测项目中都验证过稳定效果:
- 输入层:形状为(batch_size, window_size, feature_dim),其中window_size=10,feature_dim=7(6个红球+1个蓝球进行一维嵌入后输入)。
- 第一层LSTM:128个单元,return_sequences=True,保证输出仍保留时间步维度。
- 第二层LSTM:64个单元,return_sequences=False,只输出最后一步的状态作为全连接层的输入。
- Dropout层:rate=0.2,防止过拟合。
- Dense层:红球子模型输出6个神经元,激活函数用linear;蓝球子模型输出1个神经元,同样用linear。
这里有两个细节值得展开。
第一,LSTM的return_sequences这个参数。第一层如果不设True,它只会输出最后一个时间步的隐藏状态,序列的时间信息被压缩成一个向量,后续层就无法再沿时间轴建模。而双层LSTM堆叠的意义在于,第一层在时间步级别提取特征,第二层把整个序列信息聚合为一个全局表示,这种层次化的特征提取方式在序列任务中通常优于单层。
第二,激活函数选择linear而不是sigmoid或tanh。因为输出是回归值(经过归一化后的号码),我期望模型输出的是一个连续的实数,再用四舍五入转换成合法的号码。如果用sigmoid限制在[0,1]范围内也可以,但因为存在边界饱和问题,训练后期的梯度更新会非常缓慢。用linear反而让损失函数(MSE)的梯度直接传递,收敛速度明显更快。
我实测下来两层LSTM(128+64)在双色球数据上已经能接近模型能力的上限,继续增加到3层或加大到256单元,验证集指标几乎不变,训练时间却成倍上涨。所以在毕业设计场景里,我建议优先用一个结构简单但训练充分的模型,把时间花在特征处理和结果分析上,而不是追求花哨的网络结构。
3.3 损失函数与评估指标:MSE到底够不够
回归任务最常用的损失函数是MSE(均方误差),用在双色球预测上的意义是衡量预测号码与真实号码的数值偏差。但MSE有一个问题:它把"预测值为17,真实值为18"和"预测值为17,真实值为25"看成不同的错误程度,前者损失小很多。而从预测的角度看,只要没命中,差1和差8没有本质区别——都是没中。
所以除了MSE之外,我加了另一个维度的评估指标:命中率。具体做法是把模型输出的连续值四舍五入取整,然后判断每个号码是否落在真实开奖号码集合中,统计测试集上的命中数量。这种指标的优点是直观、可向非专业人解释;缺点是模型输出的舍入处理会折断梯度,所以它只作为评估指标,不参与训练时梯度的计算。
训练时我用的是Adam优化器,学习率初始设为0.001,并配合ReduceLROnPlateau回调:当验证集损失连续5轮不下降时,学习率乘以0.5。最终模型大约在第80个epoch趋于收敛,验证集MSE在0.02左右。如果你复现时发现收敛慢,首先要检查是不是学习率太大导致loss震荡,其次看数据归一化有没有做对。
4. 训练过程中的关键细节与调参心得
4.1 数据集划分:不能按比例随机切,必须按时间切
这个点非常关键,几乎每年都有同学踩坑。做图像分类时,我们可以把照片随机打乱,按7:2:1分成训练集、验证集、测试集;但时间序列数据绝对不能这么干。如果我把2018年的数据放进训练集、把2015年的数据放进测试集,模型相当于"用未来的数据预测过去",训练时信息已经泄露,测试指标虚高得毫无意义。
正确的做法是按时间顺序切割:前80%的期数作为训练集,接下来的10%作为验证集,最后10%作为测试集。这样测试集中的每一个样本,其输入窗口中的所有历史期都在训练数据之后,模拟的是真正"用已知预测未知"的场景。
我当时用的总期数是2860期(不含早期数据质量不高的部分),训练集2288期,验证集286期,测试集286期。滑窗之后,训练样本量大约是2000多组,这个体量对LSTM来说偏小,但配合Dropout和早停法,模型没有出现严重的过拟合。需要注意的是,这里的数据量指的不是原始期数,而是滑窗后生成的样本对数,两者差距很大,写论文时要说清楚。
4.2 早停与模型保存:不要手动盯loss,让代码帮你决定
训练神经网络时一个朴素的做法是设定固定的epoch数,跑完200轮,取最后一轮的模型。但这一步在小数据集上容易出问题:模型可能在70轮时已经达到验证集最优,后面的训练只是在过拟合训练集,验证损失不降反升。
我用EarlyStopping回调解决这个问题。核心参数是monitor='val_loss'和patience=10,意思是当验证集损失连续10轮没有降低到新的最低值时,训练自动停止,并恢复最佳权重。ModelCheckpoint回调同时会把验证集损失最优的那次模型参数保存为HDF5文件,方便后续加载测试。这两个回调配合使用,基本不需要人工介入判断"什么时候该停"。
在训练代码里还有一个容易忽略的操作:需要在每个epoch结束后对打乱的batch训练数据重新洗牌。LSTM对数据顺序敏感,如果不打乱batch,模型可能学到的是"期号相邻的样本被连续训练"的虚假顺序。我在每个epoch开始前用np.random.shuffle对训练集的索引做一次随机排列,保证每个batch内的样本分布足够随机。
4.3 训练过程中碰到过的两个具体问题
第一个问题是损失值一开始就非常低,低到让我怀疑模型写错了。排查后发现是因为输出层的bias初始化恰好让所有预测值偏向训练集标签的均值附近,MSE天然就低。这个问题的本质是模型还没有学到任何特征,只是输出均值就取得了低的初始损失。解决方法是观察训练过程中的预测输出,而不是只看loss数值,在第10个epoch左右把模型跑出来的预测值和真实值打印对比一下,确认模型真的在"学着改变"而不是"学会偷懒"。
第二个问题是红球和蓝球子模型分开训练后,蓝球模型的损失波动明显比红球大。原因是蓝球取值范围只有1到16,训练样本中蓝球每个号码的分布相对稀疏,模型容易受到个别异常样本的干扰。后来我给蓝球模型单独设置了更高的Dropout比例(0.3),并增加了一倍的训练轮次上限,波动得到明显缓解。
5. 结果评估与可视化:论文的"实验与结果"章节应该怎么写
5.1 怎么设定评价预测效果的基准线
很多做类似题目的同学会在论文里写"模型预测准确率达到百分之多少",但面对彩票数据时,这个表达是有争议的。更稳妥、更学术的做法是先设定一个基准线,再把模型的效果和基准线对比。
合理的基准线有两种。第一种是历史频率基线:统计训练集中每个红球号码出现的频率,预测时直接把出现频率最高的6个红球和1个蓝球作为"预测结果"。第二种是随机基线:用随机数生成器从合法范围内随机选取号码作为"预测结果"。把LSTM模型的命中率与这两个基线放在同一个表里对比,如果LSTM略优于随机基线,可以说模型捕捉到了某些统计层面的信号;如果差异不大,也要如实说明——这本身就是对这个课题研究价值的一种客观回答。
我在测试集上跑出来的结果是:LSTM的红球平均命中数为0.87个/期,蓝球命中率大约是6.4%,历史频率基线红球命中数为0.83个/期,蓝球命中率约6.1%,两者差异很小。这个结果说明LSTM在这个任务上没有表现出超越基线统计方法的优势。在论文里我把这个结论作为重要章节来写,从学术角度看反而让课题更加严谨可信。
5.2 可视化图表怎么做最能让答辩老师满意
论文和答辩PPT中的可视化是整个项目的门面。我建议至少画四类图:
第一类是训练过程损失曲线图。横轴epoch,纵轴loss,同时画训练集和验证集两条曲线,直观展示模型收敛情况和是否过拟合。这张图放在"模型训练"章节,用来支撑你选择epoch数和早停策略的依据。
第二类是预测值与真实值的对比散点图。选取测试集最后100期,把红球的预测结果和真实结果用不同颜色的散点画在同一个坐标系中,横轴为期号,纵轴为号码值。即使预测效果不理想,这张图也能展示"模型输出在合法范围内波动"这一事实,说明网络结构本身没有问题。
第三类是命中数分布的直方图。统计测试集每一期的红球命中数量(0到6个)分布,对比随机基线的命中分布。这张图最能直观说明模型能力的边界。
第四类是热力图。在33x33的矩阵(红球)或者16x16的矩阵(蓝球)上画出预测号码分布的热力图,能看出模型预测结果是否存在偏好性。比如当前模型对号码1-10的预测频率明显偏高,说明数据不平衡的影响被模型学到了,这也是一个值得在论文中讨论的现象。
5.3 论文中的"结论"部分别写"预测很准"
毕业设计论文是学术写作,不是软文。结论部分要围绕三点来写:完成了什么、发现了什么、局限在哪里。完成了什么,指的是搭建了一个完整的数据处理+LSTM训练+评估的流程;发现了什么,指的是模型在双色球预测任务中与随机基线无明显差异,说明该数据本身的可预测性极低,该结果可作为随机序列建模的参考案例;局限在哪里,指的是数据量小、没有引入外部特征、LSTM对独立同分布数据的学习能力有限等。
这样写既不会让人觉得你在夸大结果,又能体现你对深度学习模型的本质理解。答辩老师最怕学生把"预测彩票"说出花来,你用客观、严谨、实验驱动的方式把结论呈现出来,反而容易拿高分。
6. 核心代码拆解:把完整流程变成能直接跑的工程
6.1 项目文件结构与运行环境
在做毕业设计时,提交的不仅仅是代码,还应该有一个清晰的项目结构,方便评审老师快速复现。我的项目按下面的方式组织:
lstm_ssq/ ├── data/ │ ├── raw_ssq.csv # 原始开奖数据 │ └── processed_ssq.npz # 清洗并滑窗后的numpy数据 ├── src/ │ ├── data_preprocess.py # 数据清洗与滑窗构造 │ ├── train.py # 模型训练主脚本 │ ├── evaluate.py # 测试集评估与可视化 │ └── model.py # LSTM网络结构定义 ├── output/ │ ├── model_red.h5 # 红球模型 │ ├── model_blue.h5 # 蓝球模型 │ └── figures/ # 训练曲线、预测对比图等 ├── requirements.txt └── README.md运行环境是Python 3.8 + TensorFlow 2.10 + Keras + Pandas + scikit-learn + Matplotlib,requirements.txt里要固定版本号。当时踩过一个坑是TensorFlow 2.10以上版本在Windows下的GPU配置比较复杂,而CPU版本同样可以用不到3分钟跑完一个epoch(总共100个epoch以内就能完成训练),所以毕业设计环境下直接用CPU版本完全够用,不必在GPU环境上浪费时间。
6.2 数据预处理核心代码
下面是数据清洗和滑窗构造的核心代码,我加了详细注释。在实际使用时需要把原始CSV的列名和你的数据源对齐,其他部分基本可以通用。
import pandas as pd import numpy as np from sklearn.preprocessing import MinMaxScaler def load_and_clean_data(csv_path): # 读取原始数据 df = pd.read_csv(csv_path) # 统一列名:期号、日期、红球1-6、蓝球 df.columns = ['issue', 'date', 'r1', 'r2', 'r3', 'r4', 'r5', 'r6', 'blue'] # 去掉有缺失值的行 df = df.dropna().reset_index(drop=True) # 把号码统一格式化为两位字符串,避免 '1' 和 '01' 混用 for col in ['r1', 'r2', 'r3', 'r4', 'r5', 'r6', 'blue']: df[col] = df[col].apply(lambda x: str(x).zfill(2)) return df def create_sliding_window(data, window_size=10): """ 输入 data: shape = (样本数, 7) 的numpy数组,每行是[红球1..6, 蓝球] 返回 X: shape = (样本数-window_size, window_size, 7) y: shape = (样本数-window_size, 7) """ X, y = [], [] for i in range(len(data) - window_size): X.append(data[i:i + window_size]) y.append(data[i + window_size]) return np.array(X), np.array(y) if __name__ == '__main__': df = load_and_clean_data('data/raw_ssq.csv') # 提取号码列并转为float类型数组 feature_cols = ['r1', 'r2', 'r3', 'r4', 'r5', 'r6', 'blue'] raw_data = df[feature_cols].astype(float).values # 归一化:用训练集部分的scaler scaler = MinMaxScaler() # 先取前80%作为训练集进行scaler fit(注意:滑窗后再切也可以, # 但用样本前的数据fit更保险,避免用测试集信息影响scaler) train_size = int(len(raw_data) * 0.8) scaler.fit(raw_data[:train_size]) scaled_data = scaler.transform(raw_data) X, y = create_sliding_window(scaled_data, window_size=10) # 再按时间顺序切分训练集/验证集/测试集 total_samples = len(X) train_end = int(total_samples * 0.8) val_end = int(total_samples * 0.9) X_train, y_train = X[:train_end], y[:train_end] X_val, y_val = X[train_end:val_end], y[train_end:val_end] X_test, y_test = X[val_end:], y[val_end:] np.savez_compressed('data/processed_ssq.npz', X_train=X_train, y_train=y_train, X_val=X_val, y_val=y_val, X_test=X_test, y_test=y_test)这里有一个细节值得强调:scaler的fit操作放在构建滑窗之前的训练集原始序列上,而不是放在滑窗后的X_train上。因为X_train的形状是三维的(样本数、时间步长、特征数),MinMaxScaler不支持直接处理三维数组;如果要直接对X_train做归一化,必须先reshape成二维再transform,操作更繁琐也容易出错。在滑窗之前先对原始二维表做归一化,代码逻辑更清晰。
6.3 模型定义与训练核心代码
模型定义部分我拆成了红球和蓝球两个子模型,但它们的结构几乎一样,只是输出维度不同,所以可以写成一个函数。
import tensorflow as tf from tensorflow.keras.models import Model from tensorflow.keras.layers import Input, LSTM, Dropout, Dense from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint, ReduceLROnPlateau def build_lstm_model(input_shape, output_dim, lstm_units=(128, 64), dropout_rate=0.2): """ input_shape: (window_size, feature_dim) output_dim: 红球取6,蓝球取1 """ inputs = Input(shape=input_shape) # 第一层LSTM,返回序列,保持时间步信息 x = LSTM(units=lstm_units[0], return_sequences=True)(inputs) x = Dropout(dropout_rate)(x) # 第二层LSTM,只返回最后一步 x = LSTM(units=lstm_units[1], return_sequences=False)(x) x = Dropout(dropout_rate)(x) # 输出层,线性激活 outputs = Dense(units=output_dim, activation='linear')(x) model = Model(inputs, outputs) model.compile(optimizer='adam', loss='mse', metrics=['mae']) return model if __name__ == '__main__': data = np.load('data/processed_ssq.npz') # 输入窗口是10期,每期特征数是7 input_shape = (data['X_train'].shape[1], data['X_train'].shape[2]) # 红球子模型,输出6个红球 red_model = build_lstm_model(input_shape, output_dim=6, dropout_rate=0.2) red_model.summary() # 蓝球子模型,输出1个蓝球,蓝球取16个值分布更稀疏,dropout加大到0.3 blue_model = build_lstm_model(input_shape, output_dim=1, dropout_rate=0.3) blue_model.summary() callbacks = [ EarlyStopping(monitor='val_loss', patience=10, restore_best_weights=True), ModelCheckpoint('output/model_red.h5', monitor='val_loss', save_best_only=True, verbose=1), ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=5, min_lr=1e-5) ] # 红球:只取y_train的0-5列 history_red = red_model.fit( data['X_train'], data['y_train'][:, :6], validation_data=(data['X_val'], data['y_val'][:, :6]), epochs=200, batch_size=32, callbacks=callbacks )关键设计点有三个。
第一,训练红球模型时只传入y_train的前6列,蓝球模型只传最后一列。通过这种方式强制两个子模型分别关注各自的目标分布,避免互相干扰。
第二,ModelCheckpoint保存的文件名我在代码里写的是model_red.h5,但实际跑的时候还需要额外保存蓝球模型的权重,因为两个模型是分别训练的。可以直接在第二个fit过程里把ModelCheckpoint的保存路径改成output/model_blue.h5,然后再次调用fit。
第三,模型在验证集上的表现要从val_loss这个指标去判断,不要盯着训练集loss。我在最终测试时重新加载了保存的最优权重,用测试集数据做了一次完整的预测和评估,保证论文中使用的数据是模型从未见过的。
6.4 评估与可视化代码
评估部分的主要任务是:加载测试集数据,用模型预测,反归一化,四舍五入取整,统计命中数量,并生成可视化图片。
import numpy as np import matplotlib.pyplot as plt from sklearn.metrics import mean_squared_error def evaluate_and_visualize(red_model, blue_model, data, scaler, window_size=10): X_test = data['X_test'] y_test_red = data['y_test'][:, :6] y_test_blue = data['y_test'][:, 6:] pred_red = red_model.predict(X_test) pred_blue = blue_model.predict(X_test) # 反归一化:还原到真实的号码范围 # 注意:scaler是针对原始7列数据fit的,所以要对拼接后的预测结果做inverse_transform pred_scaled_red = np.clip(pred_red, 0, 1) pred_scaled_blue = np.clip(pred_blue, 0, 1) # 归一化注意:为了反归一化,需要把预测结果和训练时的scaler保持一致 # 这里演示简化的反归一化方式:直接乘上原范围+1 # 严谨做法是把scaler.feature_range_对应的max_和min_取出来还原 # 因为MinMaxScaler的min默认是0,max默认是1,所以 inversed = pred * (max-min) + min # 但实际用scaler.inverse_transform需要二维输入 pred_red_2d = pred_scaled_red # shape: (n, 6) pred_blue_2d = pred_scaled_blue # shape: (n, 1) # 使用scaler.inverse_transform需要将7列拼接,然后取对应列 pred_full = np.concatenate([pred_red_2d, pred_blue_2d], axis=1) pred_inversed = scaler.inverse_transform(pred_full) # 四舍五入并限制在合法范围内 pred_red_round = np.clip(np.round(pred_inversed[:, :6]), 1, 33).astype(int) pred_blue_round = np.clip(np.round(pred_inversed[:, 6:]), 1, 16).astype(int) y_test_red_real = scaler.inverse_transform( np.concatenate([y_test_red, y_test_blue], axis=1))[:, :6].astype(int) y_test_blue_real = scaler.inverse_transform( np.concatenate([y_test_red, y_test_blue], axis=1))[:, 6:].astype(int) # 统计命中数量 red_hits = [] blue_hits = [] for i in range(len(pred_red_round)): hit_red = np.isin(pred_red_round[i], y_test_red_real[i]).sum() red_hits.append(hit_red) hit_blue = 1 if pred_blue_round[i][0] == y_test_blue_real[i][0] else 0 blue_hits.append(hit_blue) print('红球平均命中数: {:.2f}'.format(np.mean(red_hits))) print('蓝球命中率: {:.2f}%'.format(np.mean(blue_hits) * 100)) # 绘制损失曲线(训练时从history_red中取loss) plt.figure(figsize=(10, 4)) plt.plot(history_red.history['loss'], label='train_loss') plt.plot(history_red.history['val_loss'], label='val_loss') plt.xlabel('epoch') plt.ylabel('loss') plt.legend() plt.savefig('output/figures/loss_curve.png', dpi=150) plt.close()6.5 关于反归一化的严谨性
上面代码里我特别写了一句注释,说反归一化要小心。很多同学在demo里直接用"预测值乘以33"来还原号码,这种做法的隐患在于:如果归一化时用的scaler不是标准0-1范围,或者数据里存在极小值/极大值影响,简单乘法就会产生偏差。正确方式是把预测结果和真实结果都通过同一个scaler.inverse_transform还原。因为scaler是针对7列一起训练的,所以需要把红球预测和蓝球预测先拼接成一列完整的7维向量再还原。
论文里关于这个操作的描述应该写:所有预测值和真实值均通过训练集拟合的MinMaxScaler进行反变换,还原到原始号码范围后统计命中指标。这样一句话就能堵住答辩老师对数据预处理严谨性的追问。
7. 毕业设计论文的章节组织与答辩准备
7.1 论文章节的逻辑主线
毕业设计论文的章节安排有固定套路,但落到这个题目上,每章的重点要有所取舍。我的论文目录大致如下:
- 第一章:绪论。讲选题背景(深度学习在时间序列预测中的应用)、国内外研究现状(LSTM在股票预测、天气预测等领域的应用)、研究内容(基于LSTM建立双色球号码预测模型并评估其效果)。
- 第二章:相关技术介绍。LSTM的原理、门控机制、与传统RNN的对比、MinMaxScaler归一化原理、评价指标定义。这一章不要写太长,能说明白为什么用这些技术就行。
- 第三章:数据获取与预处理。数据来源、清洗流程、滑窗构造、数据集划分。这是整个项目最扎实的部分,也是答辩时老师最喜欢问"为什么"的部分。
- 第四章:模型设计与实现。网络结构图、参数设置、训练过程、调参记录。最好给出训练过程中的loss曲线截图。
- 第五章:实验结果与分析。基准线设计、命中指标、不同模型的对比实验(比如LSTM和随机基线、历史频率基线的对比)、可视化图表、误差分析。
- 第六章:总结与展望。总结工作内容,说明局限性(数据量小、模型表达能力有限、随机序列可预测性低),展望未来可能的改进方向(引入注意力机制、更多特征等)。
7.2 答辩时常见的问题与应答思路
答辩老师大概率会问三个问题,提前准备好应答思路就不会慌。
第一个问题:你的模型真实预测准确率是多少?为什么没有达到很高的命中率?——标准回答是:在当前数据集上,模型的最好成绩与随机基线基本持平,这说明双色球作为随机事件,其可预测性非常有限。本项目的意义在于探索深度学习模型在随机序列数据上的建模能力,以及构建一套完整的时间序列预测流程。这个回答既诚实,又有学术深度。
第二个问题:为什么用LSTM而不是普通神经网络?——标准回答:普通神经网络(比如MLP)把窗口内的每个号码当作独立的输入特征,丢失了时间维度的顺序信息;LSTM通过门控机制在时间步之间传递隐藏状态,能够建模序列前后文的关系。同时我会补充:即使LSTM可以建模时序关系,如果数据本身是独立同分布的,模型可能学不到真正的时序依赖,这正是本项目要验证的问题。
第三个问题:你的模型在真实场景中能用吗?——标准回答要从研究边界来说:本项目不构成任何投注建议,更重要的是,本项目评估了彩票数据的可预测性,结果证明了该场景下深度学习模型并不能突破随机性的限制。这个回答既规避了风险,也展示了你的学术判断力。
7.3 代码提交与README写作
毕业设计提交时不光要交论文,完整可复现的代码与README也是重要的交付物。README要写清楚五件事:项目简介(一两句话)、运行环境(Python版本、依赖库版本)、数据文件格式说明、如何依次运行各脚本、输出结果在哪里。建议在README里加一个"快速开始"部分:
python src/data_preprocess.py # 生成 processed_ssq.npz python src/train.py # 训练红球/蓝球模型,保存至 output/ python src/evaluate.py # 评估测试集,生成可视化图表这一个流程跑通后,评审老师可以在干净的环境里复现整个模型训练过程,这会是答辩验收环节的一个加分项。
8. 做这个项目我最大的三条教训
反复做完几轮实验、改了几版代码之后,如果让我总结这个项目最值得分享的心得,我会选以下三条。
第一条:数据预处理比模型结构重要得多。我在初版代码里花了大量时间调LSTM的层数和单元数,但效果提升非常有限。后来把注意力转到数据上——重新检查了归一化范围、确认了时间顺序切割、添加了更严谨的滑窗——各项指标才明显改善。在随机序列数据上,模型的天花板很低,数据的处理质量直接决定你能摸到多高的天花板。
第二条:不要迷信训练曲线的漂亮走势。训练loss降得再平滑,也不代表预测结果有用。我见过不少复现项目把训练过程画得很漂亮,但实际测试集结果非常随机,只是因为训练集过拟合了。评估模型一定要看测试集上的真实表现,而不是训练曲线。
第三条:论文写作中,把"负面结果"写清楚反而更显专业。如果模型的预测效果和随机基线差别不大,认认真真把这个结果以及可能的成因分析写出来,比编造一个"准确率高达80%"的假数据要有价值得多。答辩老师见过太多夸大其词的学生,你越是实事求是,越容易获得认可。
最后再分享一个小技巧:做这类时间序列预测的毕业设计,建议把实验日志从第一天就开始记录。我当时在项目文件夹里建了一个experiments.md,每调整一个参数就记录当时的验证集指标和现象。后来写论文的实验部分时,这些原始记录几乎不需要加工就能用作论据,省了返工时间。毕业论文不是临阵磨枪能磨出来的,实验做扎实了,论文只是呈现结果而已。
本文还有配套的精品资源,点击获取