
简介面向数据挖掘与机器学习初学者这套LSTM时间序列预测实战资料基于香烟销售场景完整演示了从业务数据到深度学习模型的落地流程有助于理解LSTM如何捕捉销售数据中的时间依赖关系。压缩包共13个文件、6.14MB主体为10份Excel销售数据集涵盖多个门店与不同时段辅以1份Jupyter Notebook分析代码、1份HTML可视化结果和1份约8000字的Word实验报告。代码覆盖数据预览、序列构造、LSTM训练与预测评估等关键环节报告详细说明模型原理、参数选择及误差分析并给出实验环境配置说明多门店数据集还可用于对比模型在不同规模样本上的表现。HTML页面直观呈现预测曲线与实际值对比训练集与测试集拟合效果一目了然适合课程设计、毕业设计或自学LSTM时参考。目前已有66人学习是数据挖掘方向难得的完整小项目。1. 用LSTM做香烟销售预测先搞清楚它在解决什么问题香烟销售数据是典型的长周期序列既有年度季节性波动又有促销、节假日带来的脉冲式变化还夹杂着渠道铺货造成的滞后效应。传统ARIMA对这类多周期叠加、非平稳特征明显的数据往往要在差分阶数和季节性周期上反复试错且难以捕捉序列内部的非线性依赖。而LSTM长短期记忆网络通过门控机制把「记忆多久」和「记住什么」变成可学习的参数在电商销量、水文径流预报等场景已经被验证过。但要注意LSTM不是万能药——它适合的是样本量中等偏上、存在长期依赖关系的时间序列如果只有几百条日度数据简单线性模型加上滞后特征反而更稳。这篇实践把完整链路走一遍数据集预处理、滑窗构造、PyTorch模型搭建、训练调参与评估最后落到多步预测和特征增强的进阶用法上。2. 数据准备香烟销售数据集怎么清洗、归一化、构造滑窗样本2.1 先看清香烟销售数据集的原始结构拿到数据集后不要急着写模型第一步是观察CSV的字段和粒度。常见的香烟销售数据有两种组织方式按天记录的日度销量字段包括日期、品牌、销量、库存、促销标记或者按门店/区域汇聚的月度汇总。前者适合做短中期预测后者适合做年度趋势分析。先用pd.read_csv()读进来检查日期列是否解析正确数据类型是否一致。import pandas as pd import numpy as np df pd.read_csv(cigarette_sales.csv, parse_dates[date]) df df.sort_values(date).reset_index(dropTrue) print(df.info()) print(df.head())这段代码先把日期解析成datetime类型并按时间排序df.info()用来确认有没有空值和错误的数据类型。注意日期列的名称在不同数据集里可能叫date、sale_date或时间需要先人工确认列名。另外如果数据是月度粒度后面构造序列长度时要以12年周期为基准而不是7周周期。2.2 缺失值处理和节假日标记香烟销售数据最常见的缺失原因是节假日门店歇业或系统漏传。用前向填充会掩盖真实的波动模式更好的做法是先把缺失日期的销量标记为NaN再用前后N天的均值插补同时生成一个is_holiday特征列把缺失点对应的日期如果是节假日这个标记就能帮助模型理解当天的销量异常。df[is_holiday] df[date].dt.month.isin([1, 2, 5, 10]).astype(int) df[sales] df[sales].interpolate(methodlinear, limit_directionboth)这里interpolate用线性插值处理缺失值month.isin把春节1-2月、劳动节5月、国庆节10月粗粒度标记为节假日。真实项目中如果有更精确的法定节假日表应该用外部数据join进来。插值只解决了序列连续性问题如果某个月的缺失率超过20%建议直接删除该月数据或者用同比去年的同一天替换不然插值会引入假信号。归一化一定要在划分训练集之后做否则测试集的信息会通过min/max泄漏到训练过程中。from sklearn.preprocessing import MinMaxScaler train_size int(len(df) * 0.8) train_data df[sales].iloc[:train_size].values.reshape(-1, 1) test_data df[sales].iloc[train_size:].values.reshape(-1, 1) scaler MinMaxScaler(feature_range(0, 1)) scaler.fit(train_data) train_scaled scaler.transform(train_data) test_scaled scaler.transform(test_data)为什么用MinMaxScaler而不用StandardScalerLSTM的激活函数是tanh输出范围在[-1, 1]输入归一化到[0, 1]能让梯度传播更平稳。如果你的数据存在极端值比如双十一销量是平时的10倍MinMaxScaler会被极端值压缩正常区间的分辨率这时改用RobustScaler更稳妥。记住scaler的实例要保存下来预测完反归一化时要用同一个对象。2.3 滑窗构造序列长度选择决定了模型看到多远滑窗的目标是把一维时间序列转成监督学习需要的(样本数, 时间步长, 特征数)三维张量。窗口长度lookback是最关键的参数它决定模型每次能看到多少天的历史。对于香烟日度数据我一般先从7开始因为一周七天存在明显的周期性如果数据是月度数据lookback设为12或24。窗口太短模型看不到周期模式太长训练样本量骤减而且超过一定长度后LSTM的记忆增益会饱和。def create_sequences(data, lookback7): X, y [], [] for i in range(len(data) - lookback): X.append(data[i:ilookback, 0]) y.append(data[ilookback, 0]) return np.array(X), np.array(y) lookback 7 X_train, y_train create_sequences(train_scaled, lookback) X_test, y_test create_sequences(test_scaled, lookback) X_train X_train.reshape((-1, lookback, 1)) X_test X_test.reshape((-1, lookback, 1)) print(X_train.shape, y_train.shape)create_sequences的逻辑很简单从第0个位置开始每lookback个连续数据点生成一个样本第lookback1个点作为标签。最后reshape成(样本数, 序列长度, 特征维度)——这里的特征维度是1因为只用了销量本身。如果你后续加入了节假日标记、促销标记等特征最后的维度要相应改成特征数量。提示构造训练集时要保证训练集的最后一个样本和测试集的第一个样本之间没有重叠不然会出现数据泄漏。上面的实现直接按索引切分已经天然避开了这个问题。3. LSTM模型构建与参数设计从门控原理到PyTorch代码3.1 LSTM如何解决长期记忆问题理解LSTM的关键在于门控机制。相比标准RNN在每个时间步直接覆盖隐藏状态LSTM引入了细胞状态C_t——类似于一条传送带让信息可以跨多个时间步稳定传递。遗忘门决定旧信息保留多少输入门决定新信息写入多少输出门决定当前隐藏状态输出什么三个门的计算由当前输入x_t和上一隐藏状态h_{t-1}共同决定。遗忘门的输入数据就是这两个向量拼接后与权重矩阵做线性变换再经过sigmoid激活输出值越接近1表示越该保留。在香烟销售预测里遗忘门的意义很直观模型通过训练学会了「当检测到上个月销量异常高比如节前囤货时遗忘门会降低对那个状态的记忆权重避免把节日脉冲误当成趋势带入下月预测」。这就是LSTM相比滑窗线性回归的核心优势——它不是在用固定的窗口加权平均而是根据数据内容动态决定记忆的留存。3.2 单向LSTM还是双向LSTM对时序预测任务只能使用单向LSTM因为预测未来时不能依赖未来的数据。双向LSTM适用于序列标注、文本分类这类「完整序列可用」的任务它会在时间维度上正反各跑一遍再拼接输出这会引入未来信息泄漏。网络热词里搜得到的「单向lstm」指的就是这种标准用法。有些论文里会在训练时用双向编码、预测时用单向解码的seq2seq结构那是生成任务的范畴不适合直接套用在这个销售预测场景。3.3 PyTorch实现LSTM预测模型下面给出一个可以直接跑的PyTorch实现。网络结构是单层LSTM加全连接输出层这个结构在销售数据上足够用了层数多了反而容易过拟合。import torch import torch.nn as nn import torch.optim as optim class LSTMPredictor(nn.Module): def __init__(self, input_size1, hidden_size64, num_layers2, output_size1): super().__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropout0.2 if num_layers 1 else 0 ) self.fc nn.Linear(hidden_size, output_size) def forward(self, x): # x shape: (batch, seq_len, features) out, _ self.lstm(x) # out shape: (batch, seq_len, hidden) out self.fc(out[:, -1, :]) # 只取最后一个时间步的隐藏状态 return out.squeeze(-1)nn.LSTM的核心参数说明input_size是每个时间步的特征数这里为1hidden_size是隐藏层维度64是起步值数据量大可以加到128num_layers是堆叠层数2层能捕捉更复杂的非线性关系但也更容易过拟合batch_firstTrue让输入张量的第一个维度是batch省去后续转置的麻烦dropout只在层数大于1时生效它随机丢弃部分神经元的输出缓解过拟合。forward里取out[:, -1, :]而不是把所有时间步的输出都输入全连接层因为预测目标只关心最后一个时间步之后的销量中间时间步的输出对最终预测没有直接意义。提示hidden_size并非越大越好。在香烟销售这种日度数据上64已经能容纳复杂的周期模式了过大反而让模型记住了训练集的噪声测试集上的loss会不降反升。4. 训练、评估与预测损失函数、评价指标和超参数调优4.1 训练流程与模型保存训练过程要关注梯度是否稳定、验证集loss是否持续下降。下面代码包含了完整的训练循环并配置了早停机制——如果验证集loss连续patience个epoch没有改善就停止训练防止过拟合。def train_model(model, X_train, y_train, X_val, y_val, epochs100, lr0.001, patience10): criterion nn.MSELoss() optimizer optim.Adam(model.parameters(), lrlr) scheduler optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience5 ) train_loader torch.utils.data.DataLoader( torch.utils.data.TensorDataset( torch.FloatTensor(X_train), torch.FloatTensor(y_train) ), batch_size32, shuffleTrue ) val_loader torch.utils.data.DataLoader( torch.utils.data.TensorDataset( torch.FloatTensor(X_val), torch.FloatTensor(y_val) ), batch_size32, shuffleFalse ) best_val_loss float(inf) early_stop_counter 0 for epoch in range(epochs): model.train() train_loss 0.0 for X_batch, y_batch in train_loader: optimizer.zero_grad() predictions model(X_batch) loss criterion(predictions, y_batch) loss.backward() # 梯度裁剪防止梯度爆炸 nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() train_loss loss.item() * X_batch.size(0) model.eval() val_loss 0.0 with torch.no_grad(): for X_batch, y_batch in val_loader: predictions model(X_batch) loss criterion(predictions, y_batch) val_loss loss.item() * X_batch.size(0) train_loss / len(train_loader.dataset) val_loss / len(val_loader.dataset) scheduler.step(val_loss) if val_loss best_val_loss: best_val_loss val_loss torch.save(model.state_dict(), best_lstm_model.pth) early_stop_counter 0 else: early_stop_counter 1 if early_stop_counter patience: print(fEarly stop at epoch {epoch1}) break if (epoch 1) % 10 0: print(fEpoch {epoch1}/{epochs}, Train Loss: {train_loss:.6f}, Val Loss: {val_loss:.6f})这段代码里的关键参数及调优理由lr0.001是Adam优化器的默认学习率对大多数时间序列任务是一个稳定的起点clip_grad_norm_(max_norm1.0)限制梯度向量的L2范数不超过1LSTM在长序列上容易梯度爆炸裁剪后训练更稳ReduceLROnPlateau在验证集loss连续5个epoch没下降时把学习率减半这比固定学习率衰减更贴合实际loss曲线。早停的patience10给了模型充分的探索空间又不至于让它在过拟合区域空转。4.2 评价指标RMSE、MAE和MAPE各自说明什么回归类预测任务不能只看MSE。MSE对大误差的惩罚是指数级的两个模型可能MSE相同但一个误差分布均匀另一个偶尔出现巨大偏差这时MAE能揭示平均偏差水平。MAPE则把误差标准化为百分比方便向业务方解释预测精度。对香烟销售预测MAPE的合理性取决于是否存在接近0的销量——门店歇业当天销量为0会让MAPE急剧膨胀这时改用sMAPE对称平均绝对百分比误差更鲁棒。from sklearn.metrics import mean_squared_error, mean_absolute_error def evaluate_model(model, X_test, y_test, scaler): model.eval() with torch.no_grad(): predictions model(torch.FloatTensor(X_test)).numpy() # 反归一化 pred_inv scaler.inverse_transform(predictions.reshape(-1, 1)).flatten() y_inv scaler.inverse_transform(y_test.reshape(-1, 1)).flatten() rmse np.sqrt(mean_squared_error(y_inv, pred_inv)) mae mean_absolute_error(y_inv, pred_inv) mape np.mean(np.abs((y_inv - pred_inv) / y_inv)) * 100 print(fRMSE: {rmse:.2f}, MAE: {mae:.2f}, MAPE: {mape:.2f}%) return pred_inv, y_inv评估时最容易忽略的一步是反归一化。训练时数据被压缩到[0,1]区间算出的RMSE也在这个区间不反归一化得到的误差数值没有任何业务含义。上面代码里scaler.inverse_transform把预测值和真实值都还原到原始销量单位比如「箱」这样RMSE12.5就表示平均偏差12.5箱。另外要注意predictions是从torch.FloatTensor转来的numpy数组维度要reshape(-1, 1)才能满足inverse_transform的输入要求。4.3 超参数推荐范围与验证策略超参数推荐范围说明lookback7、14、30日度数据优先7的倍数覆盖周周期和月周期hidden_size32、64、128数据量小于5000条时不要超过128num_layers1、22层以上需要数据量支撑否则过拟合batch_size16、32、64小batch引入正则化效果但训练慢learning_rate0.001、0.0005先用默认值loss震荡再降dropout0.1、0.2、0.3仅对多层LSTM生效验证策略上时间序列不能使用随机划分的k折交叉验证——那会让未来的数据泄露到训练集里。正确的做法是滚动验证比如训练集用前70%的数据验证集用接下来10%测试集用最后20%。如果数据量充足可以尝试TimeSeriesSplit它按时间顺序划分多折数据每折的训练集都在验证集之前。测试集必须是时间上最靠后的那一段这一点和热词表格里搜到的经验一致因为真正使用模型预测的是未来不是过去。5. 进阶技巧与排错多步预测、特征工程和常见坑5.1 从单步预测扩展到多步预测的两种策略单步预测看未来一天没问题但业务上往往需要未来14天或30天的趋势。最简单的做法是递归多步预测把模型输出的下一天预测值拼到历史序列尾部作为下一次预测的输入循环往复。这种方法代码量小但误差会随步长累积——第一步错了后面每一步都建立在错误输入上。如果数据充足我一般用seq2seq架构编码器LSTM读入历史序列解码器LSTM逐时间步生成未来序列训练时用真实历史值做teacher forcing教师强制预测时用上一步的输出。在后端工程实现中还可以把待预测的节假日标记序列提前构造好喂给解码器让模型在生成未来销量时能「看到」节假日特征。对资源有限的个人项目递归预测加误差修正比如对预测值按历史同期的偏差比例做微调是性价比最高的方案。5.2 让模型吃进更多特征节假日、促销、滞后特征把销量之外的信息作为特征输入能显著提升预测效果。常见的做法是构造以下特征列拼接到滑窗张量的特征维度上df[dayofweek] df[date].dt.dayofweek # 0周一, 6周日 df[is_promotion] (df[promotion_flag] 0).astype(int) df[sales_lag7] df[sales].shift(7) # 上周同期销量 df[sales_rolling7] df[sales].rolling(7).mean() # 7日均线这些特征经过MinMaxScaler归一化后与销量特征一起进入LSTM的input_size。注意shift(7)会引入NaN值要处理掉前7行。dayofweek作为类别特征可以直接用数值表示但更严谨的做法是做one-hot编码或者用torch.nn.Embedding嵌入层让模型自己学周几的分布表示。滞后特征sales_lag7对周周期业务尤其重要它相当于给模型一条直接的捷径通常能很快降低训练loss。这里要小心重叠问题如果同时用sales_lag7和lookback7的LSTM模型会重复看到同一份信息但实验表明这样做并不会影响效果反而加速了收敛。5.3 训练中常见问题排查训练loss不下降先检查数据标准化是否真的把范围压到0-1再确认create_sequences生成的X和y有没有错位还可以缩小学习率到0.0001试跑5个epoch看loss是否有下降趋势。预测曲线整体滞后真实值一天这是单步预测的「慢性病」本质是模型把上一天的值带了过来。缓解办法是让序列特征更丰富或者改用seq2seq架构。MAPE异常大但RMSE正常数据里存在接近0的值改用sMAPE并排查是否有节假日歇业记录混入。Linux或Windows在执行脚本时报告编码错误用UTF-8带BOM保存CSV文件。中文字体问题发生在绘图和代码里设置中文字体后重画。把验证时落到一句话拿模型去做一次「重放测试」——把时间线回拨到三个月前只用当时已有的数据训练然后逐日预测并记录偏差这比一次性划分训练集测试集更能反映模型的真实泛化性能。最后补一个收尾技巧训练完成后把best_lstm_model.pth和scaler.pkl两个文件一起保存因为预测时反归一化必须要用训练时的scaler实例框架不同漏存的人不在少数。本文还有配套的精品资源点击获取