
做多步时间序列预测的读者多半经历过这样的场景数据已经整理成“窗口滑过”的样本LSTM 模型也按教程搭好了但 loss 就是降不下去。你调学习率、加隐藏层、把序列长度从 12 改成 24效果依然不稳定。这时候很多人会质疑模型却很少质疑数据本身。问题很可能不在网络结构而在数据构成。如果你的时间序列里本来就混杂着几种完全不同的运行模式——有的时期平稳缓变有的时期周期性波动有的时期频繁突变——那么让同一个 LSTM 用一个全局模型去拟合所有模式它只能学到所有模式的“平均值”而不是任何一种模式的真实规律。一个很自然的解法是先把序列按模式分群再对每个群分别训练 LSTM。这就是本文要讲的核心方案——K-means 聚类 LSTM 多输出回归。文章会从一个完整可运行的 Python 实战出发讲清楚为什么要先聚类、怎么把聚类结果路由到不同 LSTM、多输出回归的损失与评估怎么做以及这个方案在真实工程里的适用边界和常见坑。1. 这篇文章真正要解决的问题1.1 一个典型的生产场景假设你在做设备故障预警手里有几十台机器的传感器数据每台机器的运行状态并不一致。有的机器长期稳定振动幅值波动很小有的机器处于周期性负载状态数据呈现明显波形还有的机器偶尔出现冲击性异常数据曲线会突然抬升。你要预测的是未来几个时刻的多个指标比如振动、温度和电流。如果把所有机器的数据混在一起训练一个 LSTM你会发现模型总是“反应迟钝”平稳段预测得还行但突变段经常滞后一拍周期段的相位也经常对不上。这不是模型能力不够而是数据分布本身不一致。一个全局模型要同时拟合多种模式本质上是在做一种折中。1.2 问题本质全局模型拟合多种模式深度模型擅长拟合复杂函数但它的前提是训练样本来自同一分布。当数据里存在多个潜在模式且模式之间的统计特性差异较大时全局模型只能学到条件期望的平均值。更直白地说模型把“平稳模式”和“突变模式”的样本一起计算 MSE最终学出来的参数会让两种模式都不满意。尤其在多输出回归场景下不同输出可能对输入特征的敏感度不同全局模型的容量会被“平均化”浪费掉。1.3 为什么选择 K-means LSTM解决这类问题通常有三条路线人工按业务规则切分数据比如按日期、按机器类型。用更复杂的模型比如 Transformer、注意力机制让模型自己学会“不同模式用不同权重”。用无监督聚类把数据先分群再分别建模。前两条路线都有明显问题业务规则不一定能覆盖所有隐藏模式复杂模型虽然能力强但训练成本高、解释性差对数据量要求也高。而 K-means LSTM 的好处是化整为零先用 K-means 做无监督分段把复杂问题拆成几个相对简单的子问题每个子问题用一个小 LSTM 拟合。这样训练快、模型简单、可解释性强而且能直观地看到不同簇的误差差异。2. 核心概念K-means、LSTM 与多输出回归2.1 多输出回归多输出回归指一个模型同时预测多个连续目标值而不是只预测一个值。输入过去 24 个时刻的 3 个特征 输出未来 3 个时刻的目标值例如振动、温度、电流和单输出回归相比多输出回归需要考虑输出之间的相关性。如果直接用多个独立模型分别预测会丢失输出之间的联合信息用 LSTM 直接在输出层接多个神经单元则可以通过共享的隐藏层自动学习输出之间的耦合关系。2.2 K-means 聚类K-means 是一种基于距离的无监督聚类算法。它的目标是把样本划分成 K 个簇使得每个样本到所属簇中心的距离平方和最小。算法过程可以概括为四步随机初始化 K 个簇中心。计算每个样本到各簇中心的距离分给最近的簇。重新计算每个簇的均值作为新的簇中心。重复上述两步直到簇中心不再明显变化。需要注意K-means 聚类的是“向量”而不是“时间序列”。因此对序列数据做 K-means 前一般要先提取每一条序列的统计特征比如均值、标准差、趋势项、前后段差异等然后用这些特征向量参与聚类。2.3 LSTMLSTMLong Short-Term Memory是循环神经网络RNN的一种改进结构专门解决长序列训练中的梯度消失和梯度爆炸问题。它通过输入门、遗忘门、输出门和记忆单元控制信息在时间步之间的传递。在时间序列预测里LSTM 的典型用途是输入一个窗口序列输出未来一个或多个时刻的值。由于它会按时间顺序逐步处理输入可以捕捉到序列中的时序依赖关系比单纯用全连接网络或统计模型更擅长挖掘短期模式和趋势。2.4 三者的结合方式按照 K-means 的使用位置常见的组合方式有三种组合方式思路适用场景全局 LSTM不聚类一个模型拟合所有数据数据模式单一或样本量很小先聚类再分簇训练 LSTMK-means 把序列分成 K 类每类训练独立 LSTM预测时按聚类结果路由数据存在多种清晰模式且每个簇样本量足够聚类标签作为额外输入特征K-means 先给每条序列打标签再把标签拼接到 LSTM 输入特征里不想维护多个模型但希望模型感知模式差异本文的主角是第二种先聚类再分簇训练。这种方案最直观也最容易在生产环境中排查问题。3. 方案设计两种主流路线3.1 方案 A先聚类再按簇训练独立 LSTM这是“硬路由”方案。整体流程如下把时间序列切成固定长度的窗口样本。对每个窗口提取特征用 K-means 聚类给每个窗口一个簇标签。按簇标签把数据拆成 K 个子集。为每个子集训练一个 LSTM 多输出回归模型。预测新样本时先提取特征、计算属于哪个簇再交给对应模型预测。这个方案的优点是每个模型只负责一种模式训练时更容易收敛预测精度通常更高缺点是需要维护 K 个模型且聚类不准时误差会被放大。3.2 方案 B把聚类标签作为额外输入特征这个方案不拆分模型。K-means 仍然用来给序列打标签但标签作为离散特征拼接到 LSTM 的输入特征中或者用一个 Embedding 层把簇 ID 映射成向量再参与后续计算。优点是只维护一个模型工程部署简单缺点是模型容量有限如果簇与簇之间的模式差异太大单个模型依然可能顾此失彼。3.3 如何选择从工程实践看我的建议是先看两个指标簇内样本量如果每个簇至少有几千条样本优先用方案 A如果某些簇样本太少强行拆分会造成过拟合用方案 B 更稳。簇间差异可以先做一次全局模型的误差分析如果误差集中在某几类数据上再用方案 A 针对性拆分。本文实战以方案 A 为主线因为它最能体现 K-means 的价值也最容易观察到对比效果。4. 环境准备与数据构造4.1 环境与依赖本文代码基于 Python核心依赖如下pip install numpy pandas scikit-learn tensorflow matplotlib使用 TensorFlow 2.x 版本Keras 层直接从tensorflow.keras导入。如果你的机器有 GPU训练会更快没有 GPU 也能跑通只是示例数据量不大CPU 训练时间也可以接受。4.2 数据构造思路因为没有现成的工业数据集可用本文采用一份合成数据来做完整演示。数据设计要满足三个要求存在多种潜在运行模式LSTM 能从序列特征中学习到目标值聚类能够通过统计特征区分不同模式。我们生成三种模式模式 0平稳型序列围绕一个基础值小幅波动模式 1周期型序列带有明显正弦周期模式 2突变型序列后半段出现趋势性抬升。每条样本是一个长度为 24 的窗口特征数为 3输出为未来 3 个时刻的 3 个目标值。4.3 数据预处理对时间序列回归预处理的核心是归一化。序列特征和输出目标值的量纲差异会导致 LSTM 很难收敛因此我们统一用StandardScaler处理。需要特别注意的是归一化的缩放器只能基于训练集拟合再应用到训练集和测试集避免信息泄露。5. 完整代码实现5.1 生成合成数据# 文件路径data_generator.py import numpy as np def generate_data(n_samples4000, seq_len24, n_features3, n_outputs3, seed42): 生成包含三种运行模式的多输出时间序列数据。 返回 X : 形状 (n_samples, seq_len, n_features) y : 形状 (n_samples, n_outputs) regime : 每个样本的真实潜在模式用于事后验证聚类效果 np.random.seed(seed) X np.zeros((n_samples, seq_len, n_features)) y np.zeros((n_samples, n_outputs)) regime np.zeros(n_samples, dtypeint) for i in range(n_samples): regime[i] np.random.choice([0, 1, 2]) if regime[i] 0: # 平稳型低噪声基础值 10 左右 coef np.array([0.6, 0.2, 0.1]) base 10.0 noise 0.15 elif regime[i] 1: # 周期型叠加正弦波基础值 20 左右 coef np.array([0.1, 0.8, 0.3]) base 20.0 noise 0.35 else: # 突变型后半段抬升基础值 5 左右 coef np.array([-0.3, 0.5, 1.0]) base 5.0 noise 0.4 t np.arange(seq_len) feat_noise np.random.normal(0, noise, (seq_len, n_features)) if regime[i] 1: phase np.random.uniform(0, 2 * np.pi) for f in range(n_features): feat_noise[:, f] 2.0 * np.sin(2 * np.pi * t / (6 f) phase) if regime[i] 2: step_idx np.random.randint(seq_len // 2, seq_len) feat_noise[step_idx:, :] np.linspace(5, 8, seq_len - step_idx)[:, None] X[i] base feat_noise # 目标值由最近时刻的特征和基值决定 for o in range(n_outputs): y[i, o] base np.dot(X[i, seq_len - o - 1], coef) np.random.normal(0, noise) return X, y, regime if __name__ __main__: X, y, regime generate_data() print(X shape:, X.shape) print(y shape:, y.shape) print(模式分布:, np.bincount(regime))关键逻辑说明不同模式对应不同的特征系数、基础值和噪声水平。模式 1 通过改变相位和频率产生周期性模式 2 通过后半段的线性抬升模拟突变。这样设计的数据既能让 K-means 通过统计特征区分模式也能让 LSTM 从序列中学到目标值。5.2 序列特征提取与 K-means 聚类# 文件路径cluster_utils.py import numpy as np from sklearn.cluster import KMeans from sklearn.preprocessing import StandardScaler def extract_features(X): 从序列中提取统计特征用于聚类。 输入 X 形状(n_samples, seq_len, n_features) 输出特征形状(n_samples, 5) feats [] for i in range(X.shape[0]): seq X[i] feats.append([ seq.mean(), # 整体水平 seq.std(), # 波动程度 seq[-1].mean(), # 最新水平 seq[:5].mean(), # 前段水平 seq[-5:].mean() - seq[:5].mean() # 前后段变化捕捉趋势/突变 ]) return np.array(feats) def fit_cluster_model(X_train, k3, random_state42): 基于训练集拟合特征缩放器和 K-means 模型。 返回feat_scaler, km, train_labels train_feats extract_features(X_train) feat_scaler StandardScaler().fit(train_feats) train_feats_scaled feat_scaler.transform(train_feats) km KMeans(n_clustersk, random_staterandom_state, n_init10).fit(train_feats_scaled) train_labels km.labels_ return feat_scaler, km, train_labels def predict_cluster(feat_scaler, km, X_new): feats extract_features(X_new) feats_scaled feat_scaler.transform(feats) return km.predict(feats_scaled)这里最容易踩坑的地方是聚类模型和缩放器必须在训练集上拟合测试集只能调用transform和predict。如果直接用全部数据拟合再切分相当于把测试集的统计信息泄漏进了训练过程。5.3 LSTM 多输出回归模型构建# 文件路径lstm_model.py import tensorflow as tf from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout from tensorflow.keras.callbacks import EarlyStopping def build_lstm(seq_len, n_features, n_outputs): model Sequential([ LSTM(64, return_sequencesTrue, input_shape(seq_len, n_features)), Dropout(0.2), LSTM(32, return_sequencesFalse), Dropout(0.2), Dense(16, activationrelu), Dense(n_outputs) ]) model.compile(optimizeradam, lossmse, metrics[mae]) return model def train_lstm(X_train, y_train, X_val, y_val, epochs40, batch_size64, verbose0): model build_lstm(X_train.shape[1], X_train.shape[2], y_train.shape[1]) early_stop EarlyStopping(monitorval_loss, patience8, restore_best_weightsTrue) history model.fit( X_train, y_train, validation_data(X_val, y_val), epochsepochs, batch_sizebatch_size, callbacks[early_stop], verboseverbose ) return model, history模型结构说明第一层 LSTM 返回完整序列让第二层 LSTM 能继续提取时序信息Dropout 用于降低过拟合最后一层 Dense 的神经元数量等于输出目标个数所以它天然就是一个多输出回归头。5.4 数据划分、归一化与训练# 文件路径train_and_evaluate.py import numpy as np from sklearn.preprocessing import StandardScaler from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score from data_generator import generate_data from cluster_utils import extract_features, fit_cluster_model, predict_cluster from lstm_model import train_lstm import tensorflow as tf # 1. 生成数据 X, y, regime generate_data(n_samples4000, seed42) n_samples, seq_len, n_features X.shape n_outputs y.shape[1] # 2. 划分训练集 / 测试集 split int(n_samples * 0.8) X_train_raw, X_test_raw X[:split], X[split:] y_train_raw, y_test_raw y[:split], y[split:] # 3. 归一化缩放器只在训练集上拟合 x_scaler StandardScaler().fit(X_train_raw.reshape(X_train_raw.shape[0], -1)) y_scaler StandardScaler().fit(y_train_raw) X_train x_scaler.transform(X_train_raw.reshape(X_train_raw.shape[0], -1)).reshape(X_train_raw.shape) X_test x_scaler.transform(X_test_raw.reshape(X_test_raw.shape[0], -1)).reshape(X_test_raw.shape) y_train y_scaler.transform(y_train_raw) y_test y_scaler.transform(y_test_raw) # 4. 全局 LSTM 基线模型 val_cut int(len(X_train) * 0.8) X_tr, X_val X_train[:val_cut], X_train[val_cut:] y_tr, y_val y_train[:val_cut], y_train[val_cut:] print(训练全局 LSTM 基线模型...) global_model, global_history train_lstm(X_tr, y_tr, X_val, y_val, epochs40, verbose0) # 5. K-means 聚类 feat_scaler, km, train_labels fit_cluster_model(X_train, k3, random_state42) test_labels predict_cluster(feat_scaler, km, X_test) print(训练集簇分布:, np.bincount(train_labels, minlength3)) # 6. 按簇训练独立 LSTM cluster_models {} for c in np.unique(train_labels): idx np.where(train_labels c)[0] X_c, y_c X_train[idx], y_train[idx] cut int(len(X_c) * 0.8) print(f训练簇 {c}: 样本数 {len(X_c)}) model, history train_lstm(X_c[:cut], y_c[:cut], X_c[cut:], y_c[cut:], epochs40, verbose0) cluster_models[c] model # 7. 预测与评估 def predict_with_cluster_models(models, X, labels, n_outputs): preds np.zeros((len(X), n_outputs)) for c, model in models.items(): idx np.where(labels c)[0] if len(idx) 0: preds[idx] model.predict(X[idx], verbose0) return preds def report(name, y_true_scaled, y_pred_scaled, y_scaler): y_true y_scaler.inverse_transform(y_true_scaled) y_pred y_scaler.inverse_transform(y_pred_scaled) rmse np.sqrt(mean_squared_error(y_true, y_pred)) mae mean_absolute_error(y_true, y_pred) r2 r2_score(y_true, y_pred) print(f{name}: RMSE{rmse:.4f}, MAE{mae:.4f}, R2{r2:.4f}) global_pred global_model.predict(X_test, verbose0) cluster_pred predict_with_cluster_models(cluster_models, X_test, test_labels, n_outputs) report(全局 LSTM, y_test, global_pred, y_scaler) report(K-means分簇LSTM, y_test, cluster_pred, y_scaler) # 8. 按簇分别看测试误差便于定位差异来自哪个簇 print(\n按簇评估测试集误差) for c in sorted(cluster_models.keys()): idx np.where(test_labels c)[0] if len(idx) 0: report(f簇 {c}, y_test[idx], cluster_pred[idx], y_scaler)这段代码把整个过程串成了一条完整链路建议直接保存为一个 Python 文件运行。运行后你会发现模型会训练 4 次一次全局基线三次分簇模型。每个簇的样本数不同训练时间也不一样。5.5 关键逻辑说明代码里有一个容易被忽略的细节report函数先调用y_scaler.inverse_transform把预测值和真实值还原回原始量纲再计算 RMSE、MAE 和 R2。这样做有两个好处指标可以直接和生产环境中的实际数值对比比如温度误差几度、电流误差几安避免因为归一化导致指标看起来很小、实际没有意义。另一个细节是预测阶段的路由predict_with_cluster_models根据test_labels把每个样本分到对应模型只有在样本数很多、模型很多的情况下才需要担心性能问题。工程上可以把每个模型保存成独立文件再用一个路由函数统一调度。6. 运行结果与效果验证6.1 怎么判断成功运行结束后重点看三个层面的输出训练集簇分布是否均匀有没有出现某个簇只有几十条样本的情况全局模型和各簇模型的 val_loss 是否正常下降最终测试集上K-means 分簇 LSTM 的 RMSE、MAE 是否低于全局 LSTMR2 是否更高。由于本示例数据设计了三组差异明显的模式按经验来看分簇方案通常比全局方案误差更低。尤其是包含突变模式的簇因为全局模型在这个簇上容易滞后拆分后收益会更明显。但要注意每次运行由于随机初始化不完全一致数值会有波动判断标准应该看整体趋势而不是单次绝对数值。6.2 示例输出与解读运行结束后控制台输出格式类似下面这样数值仅用于说明实际以你本机跑出的结果为准训练集簇分布: [1200 1100 900] 全局 LSTM : RMSE1.8321, MAE1.4210, R20.7812 K-means分簇LSTM : RMSE1.5123, MAE1.1076, R20.8425 按簇评估测试集误差 簇 0: RMSE0.9821, MAE0.7210, R20.9132 簇 1: RMSE1.3215, MAE0.9126, R20.8421 簇 2: RMSE1.7842, MAE1.3021, R20.7225如何解读这份结果全局模型和分簇模型的对比说明拆分是否有效。三个簇的误差差异说明不同模式的学习难度不同。簇 2 往往是误差最高的因为它包含突变模式预测难度天然更大。6.3 如果效果没有提升如果跑完发现分簇方案并没有明显优于全局模型不要急着怀疑模型先检查这几件事K-means 的簇是不是真的对应不同模式可以画出每个簇的序列均值曲线对比每个簇的样本量是否足够训练 LSTM如果某个簇不足拆分反而有害是否出现数据泄漏比如归一化用的是全量数据而不是训练集聚类特征是不是选得太弱区分度不够导致簇内仍然混杂多种模式。7. 常见问题与排查思路问题现象可能原因排查方式解决方案某个簇样本太少LSTM 无法训练K 值过大或数据分布本身不均匀打印每个簇样本数调小 K或对小簇回退到全局模型聚类结果与业务认知对不上聚类特征区分度不够画出各簇序列均值对比特征分布更换特征频域特征、自相关系数、小波能量等分簇训练比全局模型更差数据模式本身单一或簇内仍然混杂分簇计算 val_loss观察各簇收敛情况先用全局基线确认问题是否存在再决定是否拆分LSTM 出现 NaN loss输入未归一化或梯度爆炸检查 X、y 的数值范围对 X、y 做 StandardScaler或降低学习率训练时间过长序列长、样本多、LSTM 层数深统计每轮训练耗时减小隐藏单元数、增大 batch_size、使用 GPU预测时刻的簇分配和训练时不一致没有