最早我还在用传统统计方法做时序预测的时候,身边一个搞算法的朋友跟我说,你迟早要上深度学习的,LSTM是少不了的。我不信,觉得ARIMA跑得好好的干嘛折腾。结果换了一个带长周期依赖的业务数据,ARIMA直接翻车,这才乖乖去研究LSTM。等我真正把LSTM跑起来之后,又发现这玩意儿在序列特别长的时候也不太行——输入长度超过一两百步,前面的信息基本就丢了。后来在模型里接上了Attention机制,效果才有了质的提升。
这篇博文会把Attention-LSTM从原理到落地的完整链路拆开讲清楚,重点是最后那个"直接替换数据就能跑"的教程,我会把每一步细节、参数含义、还有我踩过的坑都写出来。适合有一定Python基础、了解深度学习基本概念、但还没系统性搞过时序预测的开发者,跟着走一遍就能在自己的数据集上落地。
1. 为什么是Attention-LSTM:先理解要解决什么问题
1.1 LSTM在时间序列预测中的优势与局限
LSTM毕竟是经典的循环神经网络改进版本,它之所以比普通RNN强,核心在于引入了一个细胞状态(cell state),配合遗忘门、输入门、输出门这三道闸门来控制信息的保留与丢弃。
打个比方:普通RNN相当于你在读书的时候只看前一页的内容,时间一长前面讲了什么都忘了;而LSTM相当于你随身带了一本笔记,重要信息提前记下来,每翻一页就更新一次笔记,该丢的丢、该留的留,所以它能比普通RNN更有效地捕捉中长期依赖。
但这里有个问题——LSTM的"长期"是有上限的。当序列长度超过某个阈值,比如超过一两百个时间步时,细胞状态里的信息会不断被"挤压"和稀释,前面的关键特征已经被严重削弱了。这时候模型预测下一时刻的值,就会偏向于只依赖最近一小段窗口内的信息,丢失了序列中更早的、可能关键的模式。
在实际业务数据里,这种"关键信息不在尾巴上"的情况太常见了。比如预测销售额,去年同期的促销活动、前几个月季节性波动的拐点,这些离预测点很远但因果性很强的信息,纯LSTM经常学不到。
1.2 Attention机制究竟补上了哪块短板
Attention机制的核心思想一句话就能概括:不再把序列中所有时间步的信息无差别压缩成一个最终状态,而是让模型自己学会给每个时间步分配不同的权重,最后加权求和得到一个综合的上下文向量。
权重越大的时间步,表示模型认为它对当前预测目标越重要。
我之前看到过一个很贴切的类比:在读一段长文章时,你并不会逐字逐句平均用力,你会把注意力集中在几个关键词和关键句上,其他部分扫一眼就过了。Attention干的事就是这个。
Attention-LSTM的典型结构是:先用LSTM把整个输入序列编码成一组隐藏状态,Attention层在这些隐藏状态上计算权重,然后把加权后的向量交给全连接层做最终预测。这样一来,LSTM负责捕捉序列的时序依赖,Attention负责从全部时间步中挑出真正重要的信息,两个模块各司其职,配合起来能覆盖更长的依赖范围。
我在实际对比中观察到的效果差异很明显:同一份数据集、同样的预测目标,纯LSTM的测试集误差如果是一百分,加了Attention之后通常能降两成到三成,而且对预测曲线后半段的漂移抑制效果明显。这也是为什么现在生产环境里做时序预测,单纯的LSTM已经很少见了,基本都是LSTM+Attention的组合。
2. 模型架构设计与实现:从整体到局部看懂每个模块
2.1 整体数据流向
动手写代码之前,脑子里必须对整套架构有一个清晰的流程图,不然写着写着就容易乱。我这个项目的Attention-LSTM整体是这样的:
输入张量的形状是[batch_size, seq_len, input_size],其中seq_len是滑窗长度,input_size是每个时间步的特征数。
数据进来之后,先经过LSTM层。这里用的是多层LSTM,返回的是每个时间步的隐藏输出,形状是[batch_size, seq_len, hidden_size]。注意很多新手在这里会直接用最后一个时间步的输出去做预测,这恰恰是纯LSTM的思路,Attention就没用上。
然后,这组序列经过Attention层。Attention会为每个时间步的隐藏状态算出一个得分,经过softmax归一化成权重,再把所有时间步的隐藏状态按权重加权求和,得到一个上下文向量。这个向量形状是[batch_size, hidden_size],它是整个序列经过LSTM编码、Attention筛选之后的综合表示。
最后把这个上下文向量丢进一个或者多个全连接层,映射到目标维度。如果是单步预测目标维度就是1,多步预测就是预测步数。
2.2 Attention模块代码解析
我见过网上很多Attention实现版本,有的写得花里胡哨,引入了一堆变量,反而不好懂。这里用一个最经典的加性注意力(additive attention)实现,足够应对大多数场景。
import torch import torch.nn as nn import torch.nn.functional as F class Attention(nn.Module): def __init__(self, hidden_size): super(Attention, self).__init__() self.hidden_size = hidden_size # 第一个全连接层:将hidden_size维映射到hidden_size维,引入可学习的变换 self.W = nn.Linear(hidden_size, hidden_size, bias=False) # 第二个全连接层:将hidden_size维映射到1维,算出每个时间步的得分 self.V = nn.Linear(hidden_size, 1, bias=False) def forward(self, lstm_outputs): # lstm_outputs形状:[batch_size, seq_len, hidden_size] # 先过一层线性变换再做tanh激活,等价于一个打分函数 scores = self.V(torch.tanh(self.W(lstm_outputs))) # scores形状:[batch_size, seq_len, 1] # 在seq_len维度上做softmax,得到每个时间步的权重 weights = torch.softmax(scores, dim=1) # weights形状:[batch_size, seq_len, 1] # 权重与隐藏状态逐元素相乘,再求和,得到上下文向量 context = torch.sum(weights * lstm_outputs, dim=1) # context形状:[batch_size, hidden_size] return context, weights这段代码关键的几个点:
self.W先对每个时间步的隐藏状态做一次线性变换,目的是让不同维度的重要性先做一个对齐;self.V再把变换后的结果压成一个标量得分;softmax把得分转成分布在所有时间步上的权重。最后乘法和求和是加权平均操作,把整个序列的信息浓缩成一个向量。
我建议你注意一下weights的维度是[batch_size, seq_len, 1]而不是[batch_size, 1, seq_len],这个顺序搞错的话后面weights * lstm_outputs就会形状不匹配直接报错。我刚开始是从别的项目抄了一个多头注意力代码,维度改来改去改了半天才跑通。
2.3 完整模型组装
有了Attention模块,把整个Attention-LSTM模型搭起来就简单了。我项目中用的模型结构如下:
class AttentionLSTM(nn.Module): def __init__(self, input_size, hidden_size, num_layers, output_size, dropout=0.1): super(AttentionLSTM, self).__init__() self.hidden_size = hidden_size self.num_layers = num_layers # batch_first=True意味着输入形状是[batch, seq, feature] 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, bidirectional=False ) self.attention = Attention(hidden_size) self.fc = nn.Linear(hidden_size, output_size) def forward(self, x): # x形状:[batch_size, seq_len, input_size] lstm_out, _ = self.lstm(x) # lstm_out形状:[batch_size, seq_len, hidden_size] context, weights = self.attention(lstm_out) # context形状:[batch_size, hidden_size] output = self.fc(context) # output形状:[batch_size, output_size] return output, weights有几个设计细节值得一提:
dropout参数只在num_layers > 1时才生效,所以如果你的模型只有一层LSTM,这个参数传再大也是没用的。这是PyTorch的一个细节,不留意的话可能以为自己加了Dropout实际上根本没加。
bidirectional我设置成了False。在部分时序预测场景中,双向LSTM会引入未来信息,造成数据泄漏,所以做预测任务时我用单向。如果是做序列标注或特征提取而不是预测未来值,双向才是合适的,这一点很容易被搞混。
模型前向传播返回两个值——预测结果和Attention权重。返回权重不是为了好看,是为了后面能可视化注意力分配情况,排查模型到底关注了哪些时间步。这个在调试时非常有用,我后面会细说。
3. 数据预处理与核心细节
3.1 数据格式与滑窗构造
模型再花哨,数据准备不到位一样白搭。Attention-LSTM接收的输入是三维张量,所以原始的一维或二维表格数据必须先转换成滑窗样本。
假设我们的原始数据是一列时间序列,长度是N,滑窗长度是seq_len,那么能构造出的样本数量是N - seq_len个。每第i个样本的输入是第i到i+seq_len-1个时间步的数据,标签是第i+seq_len个时间步的值。
import numpy as np def create_sequences(data, seq_len): xs, ys = [], [] for i in range(len(data) - seq_len): x = data[i:i+seq_len] y = data[i+seq_len] xs.append(x) ys.append(y) return np.array(xs), np.array(ys)这段代码是单变量场景的写法,输入data是二维的[N, 1],输出xs是[N-seq_len, seq_len, 1]。
如果是多变量场景,也就是说除了要预测的目标列,还有几个辅助特征列,那data就是[N, feature_num],xs的输出形状变成[N-seq_len, seq_len, feature_num],y是[N-seq_len, 1]。非常简单,结构不用改,只要喂进来的原始数据列数是对的就行。
seq_len的选择是个关键决策。窗口太小,模型看的信息不足;窗口太大,训练样本减少,而且会引入很多噪声。我实践中的经验是先试三种长度:20、48、96,看验证集表现再定。如果是周期性的业务数据,建议至少覆盖两个完整周期。比如数据是按小时采样、存在24小时日周期,那seq_len至少48起步,否则模型根本没法捕捉周期特征。
3.2 归一化不是随便调的
时序预测里,归一化这一步几乎必不可少。LSTM内部用的是tanh和sigmoid激活函数,如果输入数据动辄几千几万的量级,梯度很容易爆炸或者消失,模型怎么训都不收敛。
我项目里用的是MinMaxScaler,把数据压缩到0到1之间:
from sklearn.preprocessing import MinMaxScaler data = data.reshape(-1, 1) # 确保是二维 scaler = MinMaxScaler(feature_range=(0, 1)) scaled_data = scaler.fit_transform(data)要注意的是,fit_transform只能在训练集上执行,然后用同一个scaler去transform验证集和测试集,不能在全部数据上fit。因为测试集代表的是"未来数据",在训练阶段不应该出现在统计信息里,否则就是泄漏了未来信息,测试集评估结果会虚高。
预测完之后,要把结果逆变换回原尺度:
preds = scaler.inverse_transform(outputs.cpu().numpy())如果不做这一步,预测出来的曲线会一直在0到1之间,看着还行但没法跟原始数据的误差对比。
我最初犯过把MinMaxScaler直接用在含异常值数据上的错误,结果异常值把最大值撑得很大,正常数据几乎全部压缩到了0到0.1的区间,模型学起来极其费劲。后来我检查数据分布,发现存在个别极端大值,就把它们单独处理后再做归一化,问题就解决了。
3.3 训练/验证/测试集划分
时序数据划分和普通分类数据不一样,绝对不能随机打乱,否则会破坏时间顺序。常规做法是按时间先后切分:前70%当训练集、中间15%当验证集、最后15%当测试集。
我在项目里是这么切的:
train_len = int(len(scaled_data) * 0.7) val_len = int(len(scaled_data) * 0.15) train_data = scaled_data[:train_len] val_data = scaled_data[train_len:train_len+val_len] test_data = scaled_data[train_len+val_len:]然后分别对这三段数据调用create_sequences。这里有个小坑:如果先把整个数据集做成序列再做划分,会导致训练集和验证集的样本在时间上重叠,因为某个样本窗口跨过了切分点。正确做法是先切原始数据,再分别构造序列,我上面这个写法就是为了避开这个问题。
4. 训练配置与调参心得
4.1 关键超参数选择
模型结构定下来之后,超参数对结果的影响不亚于模型结构本身。我项目的默认配置是这样的:
| 参数 | 我用的值 | 说明 |
|---|---|---|
| hidden_size | 64 | LSTM隐藏层维度,太大容易过拟合,太小欠拟合 |
| num_layers | 2 | 两层LSTM比一层能捕捉更高阶的模式,但不是越多越好 |
| seq_len | 48 | 滑窗长度,根据数据周期调整 |
| batch_size | 64 | 综合考虑显存与训练稳定性 |
| learning_rate | 1e-3 | 默认Adam初始值,根据loss曲线再调整 |
| output_size | 1 | 单步预测 |
loss函数我用的MSE,因为预测任务本质是回归问题,MSE是连续值预测最稳的选择。绝对误差平均MAE也可以,但MSE对异常值更敏感,训练时会推动模型优先减小大偏差。
优化器选的Adam,这个不需要多解释,它带自适应学习率,收敛速度快。不过注意Adam不等于可以完全不调学习率。我把初始学习率设为1e-3,配合ReduceLROnPlateau动态降低学习率:
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=5, verbose=True )如果连续5个epoch验证loss不下降,学习率就减半。这个策略帮我省去了很多手动调学习率的功夫。
4.2 训练监控与诊断
训练过程中我习惯每轮至少打印三个指标:训练loss、验证loss、当前学习率。trace不复杂,但信息量很大:
train_loss = 0.0 val_loss = 0.0 model.train() for x, y in train_loader: x, y = x.to(device), y.to(device) optimizer.zero_grad() pred, _ = model(x) loss = criterion(pred.squeeze(), y.squeeze()) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() train_loss += loss.item() * x.size(0) model.eval() with torch.no_grad(): for x, y in val_loader: x, y = x.to(device), y.to(device) pred, _ = model(x) loss = criterion(pred.squeeze(), y.squeeze()) val_loss += loss.item() * x.size(0)注意第14行的clip_grad_norm_,给梯度做了裁剪,上限1.0。这个操作在LSTM类模型里几乎是必备的,LSTM在长序列上容易出现梯度爆炸,不裁剪的话损失值偶尔会突然跳到NaN。
我在训练的时候遇到过一个奇怪现象:前面几十轮loss正常下降,突然某一轮loss变成NaN,然后永远恢复不回来。排查了半天发现是梯度爆炸导致的,但设置了梯度裁剪之后就再也没出现过。
可视化调用权重是一个被很多人忽略但非常有效的诊断手段。把Attention权重取出来画出来,就能看到模型在每个时间步上分配了多少注意力。如果权重分布非常均匀,说明Attention没有学到有意义的东西,模型退化成类似简单平均池化的机制;如果权重集中在序列靠前的位置,说明前面这些时间步确实对预测有重要贡献。我见过不少案例,权重可视化一画出来,问题就清楚了。
5. 直接替换你的数据:保姆级操作教程
5.1 检查原始数据格式
这节是整套教程的核心,我把操作步骤整理成可以直接照做的清单。
首先,确认你的数据文件是什么格式。最常见的两种情况:
- 单变量CSV:只有一列数值,比如一列销售额、一列温度、一列流量。
- 多变量CSV:有多列数据,其中一列是预测目标,其他列是特征。
无论哪种情况,我都建议把数据整理成CSV格式,第一列是时间戳可以保留也可以不管,模型不会用到它;剩下的一列或多列必须是数值型,不能有字符串或空值。
如果数据里有缺失值,提前处理掉。我一般用前向填充:
df = df.fillna(method='ffill')如果因为节假日等原因有连续缺失,用插值法:
df = df.interpolate(method='linear')不要直接不处理就把含NaN的数据喂进去,模型会直接学歪。
5.2 替换数据的一步步操作
假设你已经拿到了一个符合上述要求的CSV文件,比如叫my_data.csv。
第一步,修改数据读取部分的路径和参数:
df = pd.read_csv('my_data.csv', encoding='utf-8') # 如果是多列数据,选择你要用的列 feature_cols = ['feature1', 'feature2', 'feature3'] # 换成你的特征列名 target_col = 'target' # 换成你的目标列名 data = df[feature_cols + [target_col]].values.astype(float)如果只有一列数据,更简单:
data = df.values.astype(float).reshape(-1, 1)第二步,修改create_sequences的调用参数。seq_len按你的数据周期调整。假设数据是以分钟为单位、有日周期,seq_len可以用60或120;如果数据是按天为单位、有年度周期,seq_len反而不能设成365,因为训练样本不够,模型会过拟合,建议用30或60。
第三步,确认input_size的值正确。这个值是每个时间步的特征个数,在单变量场景是1,多变量场景是len(feature_cols) + 1。很多新手替换数据报错,十有八九是这个值没改对:
input_size = data.shape[1]第四步,设置训练参数并运行。整个训练入口在一个main函数里,我一般这样组织:
if __name__ == '__main__': seq_len = 48 batch_size = 64 num_epochs = 100 # 数据加载 df = pd.read_csv('my_data.csv', encoding='utf-8') data = df[['target']].values.astype(float) # 归一化 scaler = MinMaxScaler(feature_range=(0, 1)) scaled_data = scaler.fit_transform(data) # 切分 train_len = int(len(scaled_data) * 0.7) val_len = int(len(scaled_data) * 0.15) train_data = scaled_data[:train_len] val_data = scaled_data[train_len:train_len+val_len] test_data = scaled_data[train_len+val_len:] # 构造序列 train_x, train_y = create_sequences(train_data, seq_len) val_x, val_y = create_sequences(val_data, seq_len) test_x, test_y = create_sequences(test_data, seq_len) # 转Tensor并创建DataLoader train_dataset = TensorDataset( torch.tensor(train_x, dtype=torch.float32), torch.tensor(train_y, dtype=torch.float32) ) ...注意这里create_sequences里的data如果是二维的,返回的x形状会自动带上feature_num这一维,直接转Tensor就是[batch, seq_len, feature_num],模型接收起来没问题。
5.3 替换后必须检查的3个细节
数据能跑通只是第一步,结果合理才是真本事。我第一次跑通的时候特别兴奋,结果画出来的预测曲线比随机猜还差,排查了一圈才发现是三个低级错误。
第一个细节是特征列和目标列不能全放到一起归一化。如果你做了多变量预测,目标列和特征列的数值分布完全不同(比如目标列是0到10000的销量,特征列是0到1的折扣率),这会直接导致归一化失真。正确做法是分开归一化:给特征列一个scaler,给目标列一个scaler,预测完之后用目标列的scaler逆变换。
第二个细节是如果特征里含有历史滞后值,构造方式和当期值不同。比如你想用前24小时的历史特征来预测下一时刻的目标,滞后列要往过去平移才能与目标对齐。这个处理起来稍微复杂,新手容易搞错,但影响很大。我的原则是:宁可先只用目标列本身做单变量预测,也不要用还没对齐的多列特征硬上,等模型流程全部理顺了再加特征。
第三个细节是检查训练集和验证集之间没有样本重叠。前面已经说过要先切数据再做滑窗,但很多人写着写着就忘了。你可以在拿到train_x和val_x之后,检查它们的第一个样本在原始数据里的位置,确认中间有时间间隔。我吃过这个亏,因为切分方式的疏忽,验证集表现虚高,等到上线时才发现效果远没有实验时好。
6. 常见问题与避坑指南
6.1 预测曲线是一条直线
这个问题出现频率非常高。模型训练正常,loss也在下降,但是画出预测曲线来几乎是一条水平的直线,抖动非常小。
我第一次遇到这问题时百思不得其解,后来排查下来有几种可能:
第一种是目标序列本身变化幅度就很小,模型学到的"最优策略"是预测平均值附近的值,这在统计上叫均值回归。解决方式是检查数据标准化后的方差,如果方差太小,尝试放宽feature_range为(-1, 1),或者换用StandardScaler。
第二种是模型欠拟合,hidden_size太小或者训练轮数不足。这种情况loss曲线其实还在降,但你提前停了。我把num_epochs从50加到100之后,曲线明显有了波动。
第三种是序列中的高频成分太多,模型没能学好全局趋势。这时可以考虑对原始数据做一次平滑预处理,比如简单的移动平均,但要注意别把信号也抹掉了。
6.2 Loss下降很慢或不降
如果你的loss在前几个epoch几乎没有动静,先不要急着加训练轮数。检查三件事:
学习率是不是太小了。虽然Adam自适应,但初始学习率设成1e-4也能跑,只是收敛速度慢得让人着急。我一般先1e-3起步,观察前5个epoch,如果loss确实在缓慢下降就继续,不动它;如果完全不降,把学习率调到1e-2试一次。
数据归一化出问题。前面说过,如果原始数据含异常大值,归一化后正常数据的范围会被压缩到极小,模型学起来会很费劲。画一下归一化后的数据直方图,看看分布是不是集中在很小的区间里。如果是,先处理异常值。
梯度爆炸。训练曲线突然往上跳甚至变NaN,十有八九是梯度爆炸。加上clip_grad_norm_,限制max_norm在1.0左右。
6.3 训练速度慢怎么办
LSTM本身就是需要串行计算的模型结构,不像Transformer那样能完全并行,所以训练速度天生不快。如果觉得慢,优先考虑三件事:
减小batch_size。很多人觉得batch_size越大训练越快,实际上在显存没有跑满的情况下,batch_size过大会导致多余的等待时间,而梯度更新次数变少,收敛反而变慢。我一般先试64,慢就换32。
减少num_layers。两层LSTM的参数量和计算量都是一层的两倍还多,而性能提升并不总是成比例的。你可以在同等条件下跑一次一层LSTM,验证集loss如果相差在5%以内,直接用一层,收益最高。
用torch.cuda.amp做混合精度训练,这个优化在PyTorch里只需要几行代码,基本无痛:
scaler_cuda = torch.cuda.amp.GradScaler() with torch.cuda.amp.autocast(): pred, _ = model(x) loss = criterion(pred.squeeze(), y.squeeze()) scaler_cuda.scale(loss).backward() scaler_cuda.step(optimizer) scaler_cuda.update()实测下来训练时间能缩短三分之一到一半,尤其是在支持Tensor Core的卡上。
6.4 维度报错排查
维度不匹配是新手最常见的报错,错误信息里通常带着RuntimeError: size mismatch或者mat1 and mat2 shapes cannot be multiplied这一类话。
排查思路很简单,逆向追踪每一步的形状:
- 原始数据:确认
data.shape是[N, feature_num] - 滑窗之后:确认
train_x.shape是[N-seq_len, seq_len, feature_num] - DataLoader批量之后:确认批量后的形状是
[batch_size, seq_len, feature_num] - LSTM输出:确认
lstm_out.shape是[batch_size, seq_len, hidden_size] - Attention输出:确认
context.shape是[batch_size, hidden_size] - 全连接输出:确认
pred.shape是[batch_size, output_size]
只要每一步的形状和预期一致,代码就一定能跑通。我处理报错的经验是不要只盯着code看,要学会在关键节点中间打印shape,很多维度问题一眼就能看出来。
7. 关于Attention权重可视化
这个内容本来可以并入前面的章节,但我单独拿出来说,是因为它为模型解释性提供了非常有力的抓手,值得单独强调。
你要做的就是在训练结束后的测试阶段,把模型预测时算出来的weights保存下来,然后画出来看:
import matplotlib.pyplot as plt model.eval() with torch.no_grad(): sample_x = torch.tensor(test_x[:1], dtype=torch.float32).to(device) pred, weights = model(sample_x) w = weights.squeeze().cpu().numpy() # [seq_len] plt.plot(w) plt.xlabel('time step') plt.ylabel('attention weight') plt.title('Attention Weights Distribution') plt.show()看这时候的权重分布,你会发现模型对序列中某些时间步的关注度明显高于其他步。有一次我在做电力负荷预测时,发现Attention把最大的权重给了前一天同时段的负荷值,而序列末端附近的权重反而不高。这说明模型学会了"跨日对齐"这个规律,比单纯死记最近几个时间步要聪明得多。
这个信息也可以帮助你反过来调整模型:如果Attention权重集中在一个小范围内变化,说明seq_len可以适当缩小,没必要让模型看那么长的历史;如果权重非常分散,说明模型还没找到重点,可能需要增大hidden_size或者加深网络来增强表达力。
最后再分享一个实际操作中的小技巧:训练模型之前,先看一眼你的数据是平稳的还是非平稳的。如果是非平稳序列,比如有明显的趋势或者周期性,但业务上又没法做差分,那模型的训练目标会天然变难。这时候我习惯在代码里加一个简单的时间特征作为辅助输入,比如把"第几个时刻""今天是周几"这类信息拼进特征矩阵。加了之后模型通常能更好地感知时间位置,注意力权重分布也会更合理。这个方法简单、改造成本低,但很多人不会主动想到。动手试试,你可能会发现预测效果还有明显的提升空间。