去年年初,朋友丢过来一组站点负荷数据,要求用历史值预测下一时刻的负荷。一开始我想当然套LSTM,结果数据量不到一千个点,LSTM在这种小样本时间序列上并不划算,训练不稳定,预测效果也一般。后来换了个思路:BP神经网络打底,再用麻雀搜索算法(SSA)去优化BP的初始权值和阈值,跑了一轮对比,效果反而更稳。这篇就把整套时间序列单步预测的代码和调参经验写出来,重点讲清楚每一步为什么这么做,运行环境只需要Python和numpy,适合做电力负荷、交通流量、设备温度这类短序列预测的朋友参考,也是给想入门“群体智能优化神经网络”的同学一份能直接抄作业的起点。
1. 单步预测的真相:数据怎么进、结果怎么出
1.1 为什么连续的时间序列要先切成监督样本
时间序列预测本质上是一个“从历史找规律”的问题。原始数据是一串按时间排列的数值,比如[x1, x2, x3, ...],但神经网络不会直接理解时间顺序,它只能吃“输入特征->输出标签”的样本对。所以第一步一定是把连续序列改造成监督学习样本。
做法非常简单:假设我要用过去10个时刻的值预测下一时刻的值,那滑动窗口大小look_back=10,第一个样本就是x1到x10作为输入,x11作为标签;第二个样本是x2到x11作为输入,x12作为标签。这样切完之后,X的形状是(样本数, 10),y的形状是(样本数, 1)。
很多人第一次接触会觉得这个窗口选多少无所谓,实际上窗口大小对预测效果影响很大。窗口太小,模型能看到的局部模式太短,学不到周期规律;窗口太大,输入里塞进了大量和预测点关联很弱的远古信息,不仅增加计算量,还容易把噪声学进去。我自己的经验是先看数据有没有明显的周期性,比如电力负荷一般有24小时节律,那窗口可以先从12、24、48里试;如果看不出周期,就从5、10、20这三个档位慢慢做对比实验,选验证集误差最小的那个。
1.2 在模型选型上,BP凭什么能打
经常有人一听到“时间序列预测”就想到LSTM、Transformer。这类模型确实在某些长序列任务上很强,但有个前提:数据量管够。LSTM的参数量大,几千个样本根本不够它吃,训练的时候不是不收敛就是严重过拟合;Transformer更是数据饥渴型选手,小样本上很容易变成“每跑一次换一张脸”。
BP神经网络结构简单,通常就输入层、一个隐藏层、输出层,参数数量级可能只有LSTM的几十分之一。在小样本场景下,它拟合速度快、训练稳定,而且因为隐藏层够窄,反而对噪声有更强的抑制能力。严格来说,BP本身不建模时序依赖,它处理的是“把过去一段窗口向量映射成下一时刻数值”的问题。所以滑动窗口这一步不是可选项,它是让BP理解时间语义的关键。只要你把窗口构造好了,BP就能像处理普通回归问题一样处理时间序列。
1.3 单步和多步的分界线在哪
单步预测就是只预测下一时刻,也就是t+1;多步预测要预测t+1, t+2, ..., t+H,H是预测步数。很多工程问题最后要的是多步结果,但业界普遍做法是先做单步,再把单步结果递归拼到窗口里继续预测。比如窗口是10个点,预测出一个新值后,把窗口最前面的点丢掉,把新值塞到末尾,再预测下一个。这种做法实现简单,但缺点是误差会累积,后面几步的质量会明显下降。
所以我一直觉得,单步预测是时间序列建模的最小闭环。如果单步都做不稳,多步更不用谈。先把单步模型的输入输出设计、网络结构、误差评估全部跑通,后面再往多步扩展才有底气。这篇文章分享的代码就是围绕单步预测来写的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SSA为什么能解决BP的“天生缺陷”
2.1 随机初始化让BP看脸吃饭
BP网络训练用的是梯度下降,从一组随机初始化的权值开始,沿着损失函数下降方向一步步迭代到局部最优。问题就出在“随机初始化”四个字上:BP的损失曲面在参数空间里是坑坑洼洼的,有大量局部极小值和平坦区域。你随机落到哪个“坑”附近,最后就到哪个局部最优点,不同的随机种子跑出来结果能差一大截。
这个场景可以用一个比喻理解:一个人蒙着眼睛从山腰往下走,他只能感受脚下坡度的变化,最后停在哪块洼地完全看运气。如果起点正好在某条山沟上游,他能走到一个比较低的谷底;如果起点在一块凸起的小平台上,可能很快就陷进一个浅坑里。BP就是这样,你看它训练过程中损失曲线在降,但不代表降到了一个好的位置。
2.2 麻雀算法里三个角色的默契配合
麻雀搜索算法(SSA)是2020年前后提出的一种群体智能优化算法,灵感来自麻雀觅食和反捕食行为。它在优化BP时不是去改训练过程,而是在训练开始前去搜索一组更优的初始权值和阈值。算法内部有三类角色:
- 发现者:种群中适应度最好的一批麻雀,相当于先头侦察部队,负责在较广范围内搜索食物资源。它们的位置更新受到预警值R2和安全阈值ST的控制:R2<ST表示周围安全,可以安心觅食;R2≥ST说明发现了危险,整支队伍要飞到新的区域搜索。
- 加入者:适应度较差的麻雀,它们会跟着发现者移动,也有机制让它们向全局最优位置靠拢。如果某个加入者实在太差,它还会主动飞到新的地方碰碰运气,这保证了种群不会过早挤成一团。
- 警戒者:每次迭代随机抽一小部分麻雀出来“放哨”,一旦发现危险就发出预警信号,所有麻雀会重新规划飞行方向。警戒者机制的最大价值是让算法具备跳出局部最优的能力——哪怕当前整个种群都聚集在一个较优但不够好的区域,警戒者的突发扰动也可能带大家走到更开阔的地方。
相比遗传算法要调交叉率、变异率,相比粒子群容易早熟收敛,SSA的参数更少,种群结构更贴近“探索-跟随-预警”的协同搜索逻辑,用它来找BP的初始权值在我的实际测试里稳定性和收敛速度都比前两者更省心。
2.3 全局搜索加局部精调的组合逻辑
SSA优化BP的整体思路是两阶段:第一阶段,用麻雀算法在权值空间中做全局搜索,目标函数是“把这组权值作为初始值训练BP一小段时间后,在验证集上得到的误差”。第二阶段,把SSA找到的最优个体解码成BP的初始权值,再让BP在完整训练集上继续梯度下降做精调。
为什么要分两步?因为梯度下降擅长在局部区域精细搜索,但不擅长在广阔空间里找方向;而群体智能算法正好相反,它们牺牲了局部精度,换来了对全局空间的覆盖能力。两者结合就是典型的“大类搜索、小类精修”,比单独用BP多了一份从容,比单独用SSA直接当预测器又多了一份精度。代码层面的好处是,这个组合非常模块化,BP部分和前向传播逻辑一点不用动,只要在训练前多跑一段SSA搜索就行。
3. 代码逐段拆解:用numpy手写一套SSA-BP
3.1 造一份可复现的周期序列
为了演示方便,我用两条不同频率的正弦波叠加,再加一点随机噪声,模拟一段有周期、有波动的物理量。换成你自己的真实数据时,只需要把data替换成一维时间序列,后面所有处理流程都不用变。
python复制import numpy as np
import matplotlib.pyplot as plt
from sklearn.preprocessing import MinMaxScaler
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
np.random.seed(7)
N = 1000
t = np.arange(N)
data = np.sin(0.03 * t) + 0.3 * np.sin(0.008 * t) + 0.08 * np.random.randn(N)
data = data.reshape(-1, 1)
train_raw_len = int(len(data) * 0.8)
# 注意:只对训练段做fit,测试段信息不参与缩放参数计算
scaler = MinMaxScaler(feature_range=(0, 1))
scaler.fit(data[:train_raw_len])
scaled = scaler.transform(data).flatten()
这里有个新手很容易忽略的坑:scaler.fit的对象只能是训练段,如果对整个序列做fit,测试段的数值分布已经参与了归一化参数的生成,严格来说也算信息泄漏。虽然在很多教程里图省事直接全量fit,但我建议一开始就养成这个习惯,后面第五部分还会展开说。
滑动窗口切样本的函数也很简单:
python复制def create_dataset(series, look_back=10):
X, y = [], []
for i in range(len(series) - look_back - 1):
X.append(series[i:i + look_back])
y.append(series[i + look_back])
return np.array(X), np.array(y)
look_back = 10
X_all, y_all = create_dataset(scaled, look_back)
train_size = int(len(X_all) * 0.8)
X_train, X_test = X_all[:train_size], X_all[train_size:]
y_train, y_test = y_all[:train_size], y_all[train_size:]
input_dim = look_back
hidden_dim = 15
output_dim = 1
dims = (input_dim, hidden_dim, output_dim)
切分这里一定不能用train_test_split的默认随机打乱模式,时间序列样本相邻之间有强关联,打乱会让未来信息混进训练集,验证结果虚高得离谱。按时间顺序从前到后切分是最稳妥的做法。
3.2 BP前向反向与防坑细节
我用手写numpy的方式实现BP,不依赖任何深度学习框架。这样后续SSA嵌入时,对“权值从哪里来”会看得特别清楚。
python复制def forward(W1, b1, W2, b2, X):
hidden = np.tanh(X @ W1 + b1)
output = hidden @ W2 + b2
return hidden, output
def train_bp(W1, b1, W2, b2, X, y, epochs=100, lr=0.01):
# 先copy一份,防止原地修改污染麻雀个体本身的染色体
W1 = W1.copy()
b1 = b1.copy()
W2 = W2.copy()
b2 = b2.copy()
for _ in range(epochs):
hidden, output = forward(W1, b1, W2, b2, X)
d_out = 2 * (output - y) / len(X)
dW2 = hidden.T @ d_out
db2 = np.sum(d_out, axis=0)
d_hidden = (d_out @ W2.T) * (1 - hidden ** 2)
dW1 = X.T @ d_hidden
db1 = np.sum(d_hidden, axis=0)
W1 -= lr * dW1
b1 -= lr * db1
W2 -= lr * dW2
b2 -= lr * db2
return W1, b1, W2, b2
隐藏层激活函数选的是tanh,输出层不加激活函数,这样输出值可以是任意实数,符合回归任务的要求。train_bp开头的copy操作是我踩过坑之后特意加的:在SSA的适应度评估里,每个个体解出来的权值会被拿去训练,如果不copy,训练过程的原地更新会直接改掉这个个体在种群中的编码,导致整个搜索逻辑全乱。这个细节常规文档里不会提,但实际跑代码时能帮你省掉半小时排查的时间。
3.3 麻雀算法怎么把“优秀起点”挖出来
SSA里,个体的基因就是BP的全部权值加上阈值,把它们编码成一个一维向量。优化过程就是不断进化这个向量,让它的适应度(验证集误差)越来越小。先写解码和适应度函数:
python复制def decode(ind, dims):
input_dim, hidden_dim, output_dim = dims
w1_len = input_dim * hidden_dim
b1_len = hidden_dim
w2_len = hidden_dim * output_dim
b2_len = output_dim
w1 = ind[:w1_len].reshape(input_dim, hidden_dim)
b1 = ind[w1_len:w1_len + b1_len]
w2 = ind[w1_len + b1_len:w1_len + b1_len + w2_len].reshape(hidden_dim, output_dim)
b2 = ind[w1_len + b1_len + w2_len:]
return w1, b1, w2, b2
def ssa_fitness(ind, X_tr, y_tr, X_va, y_va, dims, fit_epochs=30):
w1, b1, w2, b2 = decode(ind, dims)
w1, b1, w2, b2 = train_bp(w1, b1, w2, b2, X_tr, y_tr, epochs=fit_epochs, lr=0.01)
_, pred = forward(w1, b1, w2, b2, X_va)
return np.mean((pred - y_va) ** 2)
适应度评估里只训练30轮,而不是完整训练几百轮,是因为SSA只需要比较“哪个起点相对更好”,不需要精确知道它最终能收敛到多少。训练太多轮会让每次适应度计算都变得很重,整轮搜索慢得没法接受。这是一个非常实用的加速技巧。
然后是SSA主循环。为了代码可读性,我按个体为单位循环更新,没有强行做矩阵向量化:
python复制def ssa(X_tr, y_tr, X_va, y_va, dims, pop=30, max_iter=50, lb=-5, ub=5):
input_dim, hidden_dim, output_dim = dims
dim = input_dim * hidden_dim + hidden_dim + hidden_dim * output_dim + output_dim
X = np.random.uniform(lb, ub, (pop, dim))
fit = np.array([ssa_fitness(x, X_tr, y_tr, X_va, y_va, dims) for x in X])
gbest_idx = np.argmin(fit)
gbest = X[gbest_idx].copy()
gbest_fit = fit[gbest_idx]
pd_num = max(1, int(pop * 0.2))
sd_num = max(1, int(pop * 0.1))
ST = 0.8
curve = []
for it in range(max_iter):
order = np.argsort(fit)
worst_idx = order[-1]
worst_fit = fit[worst_idx]
best_fit = fit[order[0]]
R2 = np.random.rand()
# 更新发现者
for i in order[:pd_num]:
if R2 < ST:
new_pos = X[i] * np.exp(-i / (0.2 * max_iter))
else:
new_pos = X[i] + np.random.randn(dim)
X[i] = np.clip(new_pos, lb, ub)
# 更新加入者
for i in order[pd_num:]:
if i > pop // 2:
new_pos = X[i] + np.random.randn(dim) * (X[i] - gbest) / (i + 1)
else:
A = np.random.choice([-1, 1], size=dim)
A_plus = A / np.dot(A, A)
new_pos = gbest + np.abs(X[i] - gbest) * A_plus
X[i] = np.clip(new_pos, lb, ub)
# 更新警戒者
for _ in range(sd_num):
idx = np.random.randint(0, pop)
if fit[idx] > best_fit:
beta = np.random.randn(dim)
new_pos = gbest + beta * np.abs(X[idx] - gbest)
else:
K = np.random.uniform(-1, 1, size=dim)
eps = 1e-10
new_pos = X[idx] + K * np.abs(X[idx] - X[worst_idx]) / (fit[idx] - worst_fit + eps)
X[idx] = np.clip(new_pos, lb, ub)
# 重新计算全部适应度
for i in range(pop):
new_fit = ssa_fitness(X[i], X_tr, y_tr, X_va, y_va, dims)
if new_fit < fit[i]:
fit[i] = new_fit
current_best_idx = np.argmin(fit)
if fit[current_best_idx] < gbest_fit:
gbest = X[current_best_idx].copy()
gbest_fit = fit[current_best_idx]
curve.append(gbest_fit)
return gbest, gbest_fit, curve
代码里np.clip(new_pos, lb, ub)是边界约束处理,把越界的个体拉回搜索空间边界。这一步很多人会漏,漏掉之后个体坐标可能飞到一个极远的区域,后面的适应度评估完全没有意义。
3.4 主流程组装与结果输出
SSA跑完之后,全局最优个体就是一组比较可靠的初始权值。接下来用它初始化BP,在完整训练集上训练,再对测试集做预测:
python复制inner_split = int(len(X_train) * 0.9)
X_tr, y_tr = X_train[:inner_split], y_train[:inner_split]
X_va, y_va = X_train[inner_split:], y_train[inner_split:]
gbest, gbest_fit, curve = ssa(X_tr, y_tr, X_va, y_va, dims, pop=30, max_iter=50, lb=-5, ub=5)
w1_best, b1_best, w2_best, b2_best = decode(gbest, dims)
w1, b1, w2, b2 = train_bp(w1_best, b1_best, w2_best, b2_best, X_train, y_train, epochs=200, lr=0.01)
_, pred_test = forward(w1, b1, w2, b2, X_test)
pred_test_inv = scaler.inverse_transform(pred_test)
y_test_inv = scaler.inverse_transform(y_test)
print("MAE:", mean_absolute_error(y_test_inv, pred_test_inv))
print("RMSE:", np.sqrt(mean_squared_error(y_test_inv, pred_test_inv)))
print("R2:", r2_score(y_test_inv, pred_test_inv))
plt.figure(figsize=(10, 3))
plt.plot(curve)
plt.title("SSA convergence curve")
plt.show()
plt.figure(figsize=(10, 3))
plt.plot(y_test_inv[:200], label="true", marker='o')
plt.plot(pred_test_inv[:200], label="pred", marker='x')
plt.legend()
plt.show()
这段跑完之后你会看到两幅图:一幅是SSA收敛曲线,正常情况是前期快速下降,后期逐渐平稳;另一幅是测试集前200个点的真实值和预测值对比,直观感受模型拟合效果。
4. 调参和评估:不只看一张预测效果图
4.1 一组能直接跑通的初始参数
我把这套代码在不同数据上调过的参数整理成一张表,你拿到自己的数据时可以用这些数值当起点:
| 参数 | 推荐初始值 | 调试方向 |
|---|---|---|
| look_back | 10 | 有周期就设为1到2个周期,比如24、48;无周期先试5到20 |
| hidden_dim | 15 | 先按2*input_dim+1估算,再上下微调 |
| pop | 30 | 收敛太慢可以加到50,超过100收益很小 |
| max_iter | 50 | 看收敛曲线,如果20代就平了,可以适当减少 |
| lb/ub | -5/5 | 权值范围太大容易让tanh进入饱和区,太小又限制搜索空间 |
| ssa_fit_epochs | 30 | 数据量大时可以降到10到20,换取速度 |
| bp_epochs | 200 | 正式训练阶段可以加大到500 |
| lr | 0.01 | tanh激活下常用0.005到0.05,过大会震荡 |
这里最值得强调的是ssa_fit_epochs。很多第一次跑SSA-BP的人会把它设成和正式训练一样的迭代次数,结果搜索一轮要跑特别久,而且适应度函数越精确不代表最终效果越好——它在验证集上过拟合之后,反而会把一组“只对验证集友好”的起点选出来。
4.2 收敛曲线和对比实验要怎么读
SSA收敛曲线不是装饰品,它至少能回答三个问题。第一,迭代次数够不够:如果曲线在最后一代还在明显下降,说明max_iter设小了,可以往上加;如果30代左右就完全平了,那50代绰绰有余。第二,有没有陷入早熟:如果曲线在很低的代数就停止下降,而且最终gbest_fit数值明显偏高,说明种群多样性不足,需要调大pop或者放宽lb/ub的范围。第三,搜索有没有效果:把SSA给出的gbest_fit和普通BP随机初始化在验证集上的误差放在一起比,如果差距很小,可能你的任务本身就不需要SSA;如果SSA明显更低,说明优化是有效的。
做对比实验时,我建议同一个配置至少跑三到五次,记录MAE和RMSE的均值与标准差。随机初始化BP每次都不同,指标方差可能会很大;SSA-BP的方差通常会明显收窄。很多时候单次效果提升不明显,但看标准差就会发现稳定性优势很大。
4.3 计算指标前必须做的反归一化
预测结果是0到1之间的归一化数值,如果直接拿它算MAE和RMSE,会得到一个“看上去了不起但实际上没有物理意义”的数。必须先用scaler.inverse_transform把所有预测值和真实标签恢复到原数量级,再计算指标。
常规回归指标公式大概是这样的:
| 指标 | 公式 | 注意点 |
|---|---|---|
| MAE | mean(abs(y - yhat)) | 和原数据同一单位,直观 |
| RMSE | sqrt(mean((y - yhat)^2)) | 对较大误差更敏感 |
| MAPE | mean(abs((y - yhat)/y)) * 100% | 真实值接近0时会爆炸 |
| R2 | 1 - SSE/SST | 越接近1说明模型解释力越强 |
尤其注意MAPE,当时间序列本身有接近0的点时,这个指标会变得巨大且不可信,这时候建议用SMAPE,或者干脆改用MAE和RMSE就够了。预测值如果出现负值,MAPE也会被严重放大,不要盲目用。
5. 最容易翻车的五个细节:实战记录
5.1 训练测试划分错了,指标全是虚的
时间序列数据自带顺序属性,相邻样本高度相关。如果你用了train_test_split(X, y, test_size=0.2),默认参数会随机打乱数据,测试集里混进来的样本可能是训练集那段时间窗口的“未来近亲”,模型等于变相偷看了答案。结果就是测试集指标非常漂亮,一上真实环境立刻拉胯。
正确做法就是代码里那样,先确定分割点,训练数据只取前80%,测试数据取后20%。训练集内部的验证集划分也要按时间切,而不是随机抽样。如果是做交叉验证,也应该用滚动式或扩张式的时间窗口,不要使用普通的K折随机划分。
5.2 归一化的拟合范围会偷看未来
归一化需要一个scaler参数,这个参数的来源是数据的均值和极值。如果对整个序列先算scaler再划分训练测试,那么测试段的统计信息已经进入了数据预处理过程,严格来说也是一种泄漏。代码里我特意只对前80%的数据段调用scaler.fit,再用这个训练段学到的尺度去transform整段数据,这样测试段没有参与任何参数拟合。
这个问题在论文里经常被审稿人抓出来,但在网上的教程代码里却很少见人强调。如果你只做基础演示,全量fit可能问题不大;但只要涉及正式评估或者论文复现,建议一律按“训练段fit,测试段transform”来处理。
5.3 适应度函数不能拿测试集来算
SSA的搜索目标是让适应度函数最小化,如果适应度直接用测试集误差来算,那就等于用测试集引导了权值搜索,相当于对测试集过拟合,最后得到的测试集误差一定是虚低的。这是“在测试集上调参”的教科书式错误。
正确做法是把训练集内部再分出一个小验证集,比如前90%训练、后10%验证。SSA在验证集上算适应度,选出最优起点后,再把这个起点放到全量训练集上训练,最后才在留出的测试集上评估。这套流程虽然多了一步,但能确保最终指标是可信的。
5.4 运行时间失控:计算一下这笔账
SSA-BP的耗时大头在适应度评估上。假设pop=30、max_iter=50,那么总共有1500次适应度评估,每次评估里面要训练30轮BP。如果单次适应度耗时0.5秒,整轮SSA就要跑12分钟左右;如果数据量上万,单次评估可能要好几秒,一轮下来就是一两个小时。这不是算法不行,而是很多人一开始把参数拍得太大。
我的建议是正式开跑前先做一次耗时探测:只调一次ssa_fitness,记录它的耗时,再乘上pop * max_iter,估算出总时间。如果预算超了,优先降低ssa_fit_epochs到10到20,其次缩减pop到20,最后才是砍max_iter。SSA的搜索质量对max_iter的敏感度通常没有对pop的敏感度高。
5.5 单步预测怎么扩展成递归多步
如果你想预测未来H个时刻,最简单的方案是滚动预测:把单步模型预测出的新值当作下一个时刻的真实值,拼到窗口末尾,丢掉窗口最前面的旧数据,反复循环。核心逻辑类似这样:
python复制def recursive_predict(model, last_window, steps):
res = []
window = last_window.copy()
for _ in range(steps):
pred = model(window.reshape(1, -1))[0]
res.append(pred)
window = np.append(window[1:], pred)
return np.array(res)
这个方案的问题在于预测误差会逐步累积,越往后越不准。想缓解,可以每隔几步用真实观测值校准一下窗口;或者直接训练一个多输入多输出的模型,让它一次性输出未来H个点。两者各有取舍,但前提都是先把单步预测做到足够稳。单步误差都很大的时候,滚动到后面基本上就是放飞自我。
最后分享一个我自己的使用习惯:跑任何优化类算法之前,先固定随机种子,然后在代码里打印几条关键日志,比如每次适应度评估的耗时、当前最优适应度、当前迭代次数。有了这些信息,你才能在调试的时候快速判断是搜索不充分、还是适应度函数计算太重、还是权值边界设得不合理。当初我在一批设备温度数据上跑SSA-BP,就是因为没算耗时账,把pop和max_iter都拉得特别大,结果跑了快一个小时才反应过来,调参节奏整个被打乱。后来养成了先做小规模探路、再加规模的习惯,整个流程顺畅多了。
