之前在做时序预测项目时,经常会遇到一个尴尬的情况:整体数据看着有规律,但一个模型学完所有样本之后,预测精度总是提不上去。后来仔细分析发现,数据里其实隐藏着几种不同变化模式,被混合在一起,模型很难用一个隐状态覆盖所有规律。于是尝试了“K-means 聚类 + LSTM”的组合思路,先用 K-means 把不同模式的数据分到不同簇,再对每个簇分别训练 LSTM 模型,最终在多个预测目标上同时输出,效果提升非常明显。
这篇文章会完整拆解这套实战方案,内容包括 K-means 聚类原理、LSTM 多输出回归的思路、完整 Python 代码、可视化分析以及常见坑点。适合有一定 Python 基础、想把聚类和时间序列预测结合起来的读者;如果你刚接触 LSTM,也能通过代码逐步跑通整个流程。
1. 背景与核心概念
1.1 为什么把 K-means 和 LSTM 组合起来
K-means 是一种无监督学习算法,它的目标是把样本划分成 K 个簇,使得同一个簇内的样本尽可能相似,不同簇之间的样本差异尽量大。它不关心样本的标签是什么,只根据特征距离来分组。
LSTM(Long Short-Term Memory)是循环神经网络的一种改进结构,专门用来处理序列数据。它通过“门”结构控制信息的遗忘、记忆和输出,能够缓解长序列训练中的梯度消失问题,适合时间序列预测、文本建模、语音识别等场景。
单看两者,一个做聚类,一个做预测,好像没有直接关系。但业务场景中,很多序列数据并不是单一模式。
比如同一台设备的传感器数据,在正常运行、低速运行、故障前期三种状态下,曲线形态完全不同。如果只用一条 LSTM 去学习所有状态,模型会变得比较“平庸”。更合理的做法是:
- 先用 K-means 把历史样本按形态聚类;
- 每个簇内部的数据模式更一致;
- 再为每个簇训练一个 LSTM 多输出回归模型;
- 预测新样本时,先判断它属于哪个簇,再使用对应模型预测。
这样相当于把一个复杂问题拆成了多个相对简单的问题,每个子问题都由专门的模型负责。
1.2 LSTM 模型能做什么
LSTM 模型擅长捕捉序列中的时间依赖关系。它输入的不是单独一个向量,而是一段连续时间窗口的数据。
比如我们采集了 72 个小时的负荷数据,每 1 小时一个点,那就可以用前 48 个小时的数据去预测后 24 个小时的数据。这种任务可以看成“多步时间序列预测”,如果每个时刻需要预测多个变量(例如温度、湿度、压力),就变成了“多输出回归”。
LSTM 内部通常包含:
- 遗忘门:决定哪些历史信息需要丢弃;
- 输入门:决定哪些新信息需要写入记忆;
- 输出门:决定当前隐状态输出什么。
在实际编码中,我们通常直接使用 TensorFlow/Keras 提供的LSTM层,不需要手动实现门控逻辑。
1.3 多输出回归的含义
普通回归问题一般输出一个连续值,比如预测明天的销售额。多输出回归则是同时预测多个连续值,比如同时预测明天的销售额、订单量和退换货数量。
在 Keras 中实现多输出回归非常简单,只需让最后一层 Dense 层的神经元数量等于输出维度即可。例如:
model.add(Dense(3))这里的3就表示每个样本输出 3 个数值。
2. 环境准备与版本说明
本文示例代码在 Windows 11 环境下测试通过,Python 使用 3.9 版本。建议使用虚拟环境管理依赖,避免不同项目之间的包版本冲突。
需要安装的核心库如下:
| 库名 | 用途 |
|---|---|
| numpy | 数值计算与模拟数据生成 |
| pandas | 数据处理与 DataFrame 操作 |
| matplotlib | 数据可视化 |
| scikit-learn | K-means 聚类、数据标准化、评估指标 |
| tensorflow | 构建 LSTM 模型 |
安装命令:
pip install numpy pandas matplotlib scikit-learn tensorflow版本方面,TensorFlow 2.x 都可以运行本文代码。需要注意的是,不同版本之间的 API 基本一致,但如果你使用的是 TensorFlow 2.10 以下版本,keras已经内嵌在tensorflow中;如果使用 TensorFlow 2.16+,keras变成了独立包,建议统一安装keras并保持版本匹配。
示例项目结构如下:
kmeans_lstm_demo/ ├── main.py ├── data_generator.py ├── model.py └── requirements.txt为了方便阅读,本文会把核心代码集中展示在几个代码块中,你可以按文件拆分保存。
3. 核心原理拆解
3.1 K-means 聚类的思路
K-means 的算法流程可以用四个字概括:迭代收敛。
- 随机选择 K 个样本作为初始簇中心;
- 计算每个样本到 K 个中心的距离,把它归到最近的中心;
- 重新计算每个簇内所有样本的均值,作为新的中心;
- 重复步骤 2 和 3,直到簇中心不再发生明显变化。
在 scikit-learn 中,调用方式如下:
from sklearn.cluster import KMeans kmeans = KMeans(n_clusters=3, random_state=42, n_init=10) labels = kmeans.fit_predict(X)其中n_init表示 K-means 会运行多次,选择惯性最小的结果,避免陷入局部最优。random_state用来固定随机种子,保证结果可以复现。
3.2 时序数据如何聚类
时序数据不能直接丢给 K-means,因为原始序列长度可能不一致。常见做法有两种:
方式一:基于统计特征聚类
对每段序列提取均值、方差、斜率、极值、周期长度等统计特征,形成特征向量,再对特征向量做 K-means。
方式二:基于窗口序列聚类
把每个滑动窗口内的数据展开成一条平坦向量,直接作为聚类特征。这种方式保留了原始形态,但要求所有窗口长度一致。
本文采用方式二,因为它的实现最简单,并且与 LSTM 的滑动窗口输入自然对齐。每个样本就是一个长度为lookback的窗口,窗口中包含多个特征维度,展开后变成 K-means 的输入。
3.3 LSTM 多输出模型结构
一个标准的多输出 LSTM 回归模型结构如下:
输入层:形状为 (lookback, n_features) ↓ LSTM 层:返回隐藏状态 ↓ Dropout 层:防止过拟合 ↓ 全连接层:Dense(64, activation='relu') ↓ 输出层:Dense(n_targets)输入形状里的lookback表示用过去多少个时间步,n_features表示每个时间步有多少个特征。输出层的n_targets表示一次预测多少个目标变量。
3.4 为什么要先聚类再预测
直接用一个 LSTM 模型预测所有数据,相当于让模型同时拟合多种变化规律。如果不同模式的样本在特征空间距离较远,模型会出现“顾此失彼”的问题。
聚类之后,每个簇内的数据分布更加集中,LSTM 更容易学到簇内共同的时间依赖关系。这种方式在工业设备预测性维护、能源负荷预测、金融时序分析中都有应用价值。
当然,聚类也不是万能的。它适合数据天然存在多种模式的情况;如果数据本来就是单一平稳序列,聚类反而会切割上下文,导致样本量减少、模型训练不充分。所以使用前要对数据分布有基本判断。
4. 完整实战案例
下面我们用一个模拟数据来跑通全流程。假设现在有两台设备,每台设备有 3 个传感器特征,设备的运行模式分为“正常”“剧烈波动”和“缓慢漂移”三种。我们的目标是:
- 根据历史 48 个时间步的 3 个特征,聚类出不同运行模式;
- 使用 LSTM 模型同时预测未来 3 个特征的值。
4.1 模拟数据生成
为了模拟三种不同模式,我们生成三段不同频率和振幅的周期序列,并叠加随机噪声。
import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.cluster import KMeans from sklearn.preprocessing import StandardScaler from sklearn.model_selection import train_test_split from sklearn.metrics import mean_absolute_error, mean_squared_error from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout from tensorflow.keras.optimizers import Adam # 设置随机种子,保证结果可复现 np.random.seed(42) # 生成三种模式的时间序列 def generate_series(mode, length=1000): t = np.arange(length) if mode == "normal": # 低频小幅波动 base = np.sin(t / 50) * 0.5 elif mode == "volatile": # 高频大幅波动 base = np.sin(t / 5) * 2.0 + np.cos(t / 3) * 1.5 elif mode == "drift": # 缓慢上升趋势 base = 0.02 * t + np.sin(t / 40) * 0.3 else: raise ValueError("Unknown mode") # 三个特征分别加上不同权重和噪声 data = np.zeros((length, 3)) data[:, 0] = base + np.random.normal(0, 0.1, length) data[:, 1] = base * 1.2 + np.random.normal(0, 0.15, length) data[:, 2] = base * 0.8 + np.random.normal(0, 0.08, length) return data # 三段各 1000 个时间步 normal_data = generate_series("normal") volatile_data = generate_series("volatile") drift_data = generate_series("drift") # 拼接在一起,并记录真实标签(用于后续分析,不用于聚类训练) all_data = np.vstack([normal_data, volatile_data, drift_data]) true_labels = np.array([0] * 1000 + [1] * 1000 + [2] * 1000) print(all_data.shape) # (3000, 3)这里我们把三种模式的数据顺序拼接,实际业务中数据是连续采集的,不会提前知道边界。后面聚类时会暴露这个信息,但我们不会使用true_labels参与 K-means。
4.2 特征工程与 K-means 聚类
先用滑动窗口把数据切分成样本。假设用lookback = 48,则每个样本包含 48 个时间步 × 3 个特征,展开后得到 144 维特征向量。
def create_windows(data, lookback=48): windows = [] for i in range(len(data) - lookback): win = data[i:i + lookback] windows.append(win) return np.array(windows) lookback = 48 all_windows = create_windows(all_data, lookback) print(all_windows.shape) # (2952, 48, 3) # 聚类输入:把窗口展平成向量 X_cluster = all_windows.reshape(all_windows.shape[0], -1) print(X_cluster.shape) # (2952, 144) # 标准化,避免不同特征量纲影响距离计算 scaler = StandardScaler() X_cluster_scaled = scaler.fit_transform(X_cluster)接下来用肘部法则确定 K。我们计算不同 K 值下的簇内误差平方和(inertia)。
inertia = [] for k in range(2, 7): kmeans_tmp = KMeans(n_clusters=k, random_state=42, n_init=10) kmeans_tmp.fit(X_cluster_scaled) inertia.append(kmeans_tmp.inertia_) plt.figure(figsize=(8, 4)) plt.plot(range(2, 7), inertia, marker='o') plt.xlabel('K') plt.ylabel('Inertia') plt.title('Elbow Method for Optimal K') plt.grid(True) plt.show()从图中通常能看到弯曲最明显的位置,这里我们已知生成数据有三种模式,因此直接选择 K=3。实际项目中,需要结合业务可解释性和惯性曲线综合判断。
kmeans = KMeans(n_clusters=3, random_state=42, n_init=10) cluster_labels = kmeans.fit_predict(X_cluster_scaled)4.3 聚类效果可视化
为了快速验证聚类是否合理,可以把每个窗口的聚类结果映射回时间轴,并对比真实模式标签。
# 每个窗口的起始时间索引,从 0 到 2951 start_indices = np.arange(len(all_windows)) plt.figure(figsize=(12, 4)) plt.scatter(start_indices, cluster_labels, c=cluster_labels, cmap='viridis', s=10) plt.xlabel('Window Start Index') plt.ylabel('Cluster Label') plt.title('K-means Cluster Assignment on Time Windows') plt.colorbar() plt.show()如果聚类有效,你会看到前 952 个窗口(对应 normal 段),中间 952 个窗口(volatile 段),最后 952 个窗口(drift 段)分别被分到不同簇,边界位置因为窗口跨越了模式切换点,会有少量过渡样本。
4.4 构造 LSTM 训练数据
接下来为每个簇单独准备训练数据。每个样本的输入是连续 48 个时间步的 3 个特征,输出是下一个时间步的 3 个特征。
def create_xy(windows): X = windows[:, :-1, :] # 前 47 步 y = windows[:, -1, :] # 最后 1 步的 3 个特征 return X, y # 按聚类标签拆分数据 cluster_to_data = {} for label in np.unique(cluster_labels): mask = cluster_labels == label cluster_windows = all_windows[mask] X, y = create_xy(cluster_windows) cluster_to_data[label] = (X, y) for label, (X, y) in cluster_to_data.items(): print(f"Cluster {label}: X shape {X.shape}, y shape {y.shape}")这里 y 的形状是(样本数, 3),代表每个样本同时预测 3 个特征,这就是多输出回归的目标。
4.5 构建多输出 LSTM 模型
定义一个通用函数,输入特征数量和输出目标数量,返回一个 LSTM 回归模型。
def build_lstm_model(input_shape, n_targets): model = Sequential() model.add(LSTM(64, input_shape=input_shape, return_sequences=True)) model.add(Dropout(0.2)) model.add(LSTM(32)) model.add(Dropout(0.2)) model.add(Dense(16, activation='relu')) model.add(Dense(n_targets)) # 多输出 model.compile(optimizer=Adam(learning_rate=0.001), loss='mse', metrics=['mae']) return model注意,最后一层 Dense 没有激活函数,或者使用线性激活。多输出回归任务中,输出层一般使用线性激活,因为我们要预测连续数值,不需要限制在 0 到 1 之间。
输入形状为(lookback - 1, n_features),这里因为窗口长度是 48,输入用前 47 步,所以input_shape=(47, 3)。
4.6 训练与结果评估
为每个聚类簇构建并训练一个独立的 LSTM 模型。这里为了演示,先对簇 0 单独训练。
X0, y0 = cluster_to_data[0] # 划分训练集和测试集 X_train, X_test, y_train, y_test = train_test_split(X0, y0, test_size=0.2, random_state=42, shuffle=False) # 标准化:注意按特征维度标准化 scaler_x = StandardScaler() scaler_y = StandardScaler() # 输入数据形状 (样本数, 时间步, 特征数),需要将三维数据reshape后标准化 orig_shape = X_train.shape X_train_reshaped = X_train.reshape(-1, orig_shape[-1]) X_train_scaled = scaler_x.fit_transform(X_train_reshaped).reshape(orig_shape) X_test_reshaped = X_test.reshape(-1, orig_shape[-1]) X_test_scaled = scaler_x.transform(X_test_reshaped).reshape(X_test.shape) y_train_scaled = scaler_y.fit_transform(y_train) y_test_scaled = scaler_y.transform(y_test) model = build_lstm_model((orig_shape[1], orig_shape[2]), n_targets=3) model.summary() history = model.fit( X_train_scaled, y_train_scaled, validation_data=(X_test_scaled, y_test_scaled), epochs=30, batch_size=32, verbose=1 )训练完成后,用测试集进行预测并反标准化。
y_pred_scaled = model.predict(X_test_scaled) y_pred = scaler_y.inverse_transform(y_pred_scaled) y_test_inv = scaler_y.inverse_transform(y_test_scaled) # 计算评估指标 mae = mean_absolute_error(y_test_inv, y_pred) mse = mean_squared_error(y_test_inv, y_pred) print(f"Cluster 0 - MAE: {mae:.4f}, MSE: {mse:.4f}") # 可视化第一个样本的预测 vs 真实值 plt.figure(figsize=(12, 4)) for i in range(3): plt.subplot(1, 3, i + 1) plt.plot(range(len(y_test_inv[:50, i])), y_test_inv[:50, i], label='True') plt.plot(range(len(y_pred[:50, i])), y_pred[:50, i], label='Pred') plt.xlabel('Sample Index') plt.ylabel(f'Feature {i + 1}') plt.legend() plt.suptitle('Cluster 0: Prediction vs True (first 50 test samples)') plt.tight_layout() plt.show()对于其他簇,可以写一个循环来完成同样的训练和测试,然后汇总各簇的评估指标。
完整循环示例:
all_metrics = {} models = {} for label, (X, y) in cluster_to_data.items(): X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, shuffle=False ) orig_shape = X_train.shape X_train_reshaped = X_train.reshape(-1, orig_shape[-1]) X_test_reshaped = X_test.reshape(-1, orig_shape[-1]) scaler_x = StandardScaler() scaler_y = StandardScaler() X_train_scaled = scaler_x.fit_transform(X_train_reshaped).reshape(orig_shape) X_test_scaled = scaler_x.transform(X_test_reshaped).reshape(X_test.shape) y_train_scaled = scaler_y.fit_transform(y_train) y_test_scaled = scaler_y.transform(y_test) model = build_lstm_model((orig_shape[1], orig_shape[2]), n_targets=3) model.fit(X_train_scaled, y_train_scaled, validation_data=(X_test_scaled, y_test_scaled), epochs=30, batch_size=32, verbose=0) y_pred_scaled = model.predict(X_test_scaled, verbose=0) y_pred = scaler_y.inverse_transform(y_pred_scaled) y_test_inv = scaler_y.inverse_transform(y_test_scaled) mae = mean_absolute_error(y_test_inv, y_pred) mse = mean_squared_error(y_test_inv, y_pred) all_metrics[label] = {'mae': mae, 'mse': mse} models[label] = model print(f"Cluster {label} - MAE: {mae:.4f}, MSE: {mse:.4f}")保存模型:
import os os.makedirs('saved_models', exist_ok=True) for label, model in models.items(): model.save(f'saved_models/lstm_cluster_{label}.h5')这样在部署时可以加载对应簇的模型进行预测。
5. 常见问题与排查思路
在实际运行这套代码时,可能会遇到下面这些问题。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| K-means 聚类结果乱跳,每次运行不同 | 没有设置random_state,初始簇中心随机 | 固定random_state,必要时调大n_init |
| 聚类效果差,不同模式没分开 | 未对特征做标准化,量纲差异大 | 使用StandardScaler对窗口向量标准化 |
| LSTM 训练 loss 不下降 | 学习率过大或数据未归一化 | 降低学习率,检查输入输出是否标准化 |
| 预测值全部接近某个常数 | 数据标准化不彻底,或 LSTM 学习不足 | 增加训练轮数,检查输出层是否有激活函数 |
| 模型训练速度慢 | 窗口数量大、LSTM 层数多 | 减小lookback,减少 LSTM 单元数,使用 GPU 或增加batch_size |
| 评估指标在不同簇之间差异很大 | 不同簇的数据分布范围不同 | 可以按簇分别报告指标,或者使用加权平均对比总效果 |
| 新数据预测时不知道属于哪类 | 需要先对新数据做同样的窗口化和标准化,然后用 K-means 模型predict得到簇标签 | 保存 K-means 模型和 scaler,预测时先走同一套流程 |
关于新样本的簇归属,部署时流程如下:
# 假设 new_window 是形状为 (48, 3) 的窗口数组 # 先标准化 new_flat = new_window.reshape(1, -1) new_flat_scaled = scaler.transform(new_flat) new_cluster = kmeans.predict(new_flat_scaled)[0] # 再加载对应模型预测 model_new = models[new_cluster] # 注意训练时输入是 47 步,所以需要截取前 47 步 input_seq = new_window[:-1].reshape(1, 47, 3) input_seq_scaled = scaler_x.transform(input_seq.reshape(1, -1)).reshape(1, 47, 3) pred_scaled = model_new.predict(input_seq_scaled) pred = scaler_y.inverse_transform(pred_scaled)这里需要注意,每个簇训练时都有独立的scaler_x和scaler_y,生产环境中要把它们一起保存。
6. 最佳实践与工程建议
6.1 关于聚类效果
K-means 对初始值和异常值比较敏感。使用前可以先对窗口特征做异常值剔除,例如基于分位数截断极端值。另外,聚类数量 K 不光看肘部法则,还要考虑业务含义、每个簇的样本量是否足够训练 LSTM。如果某个簇样本太少,可以适当合并相似簇,或者对少数类簇使用数据增强。
6.2 关于滑动窗口长度
lookback的选择决定了 LSTM 能看到多长的历史信息。窗口太短会丢失长期依赖,窗口太长会增加计算量并引入噪声。通常可以尝试多个候选值,比如 24、48、72、96,在验证集上比较表现。
窗口滑动步长也值得关注。本文使用步长为 1,样本之间存在大量重叠,会增强数据量但也会导致样本高度相关。如果担心过拟合,可以增大步长,比如每 3 个时间步采样一个窗口。
6.3 关于数据标准化
时间序列预测中,输入和输出最好分别标准化,并且必须使用训练集统计量来变换测试集,避免数据泄漏。标准化器需要与模型一起保存,否则新数据无法正确还原。
6.4 关于模型结构
对于多输出回归,LSTM 层和 Dense 层的单元数可以根据数据复杂度调整。如果输出目标之间关联紧密,LSTM 隐状态可以自动捕捉。如果两个目标量纲差距很大,建议先分别做标准化,必要时可以拆成多个输出分支,每个分支有自己的 Dense 层,然后再合并 loss。
在 Keras 中多输出分支的写法如下:
# 多输出分支示例 from tensorflow.keras.layers import Input, LSTM, Dense from tensorflow.keras.models import Model inputs = Input(shape=(47, 3)) lstm_out = LSTM(32)(inputs) # 两个输出分支 output1 = Dense(1, name='feature1')(lstm_out) output2 = Dense(1, name='feature2')(lstm_out) model = Model(inputs, [output1, output2]) model.compile(optimizer='adam', loss='mse')如果你希望不同目标有不同权重,还可以在compile时传入loss_weights。
6.5 关于评估与对比
建议不要只和真实值画图对比,还要和“直接使用一个全局 LSTM 模型”做对比。全局模型的实现方式相同,只是不区分聚类标签,把所有窗口数据放在一起训练。两者对比后,你才能确认“K-means + LSTM”是否真的带来了提升。
评估指标可以同时使用 MAE 和 RMSE。MAE 容易理解,RMSE 对大误差更敏感。如果关心极端情况预测,RMSE 更合适。
6.6 关于生产部署
生产环境下,建议使用 TensorFlow Serving 或者 ONNX 转换后部署。每个簇一个模型文件,预测前先通过 K-means 路由。为了减少模型数量,也可以考虑只对变化差异大的模式分别建模,差异小的模式合并处理。
日志记录建议包含以下信息:
- 样本所属簇;
- 模型版本;
- 预测值与真实值的误差;
- 特征取值是否越界。
这样即使预测效果下降,也能快速定位是聚类漂移还是模型退化。
7. 总结与学习路线
本文完整实现了“K-means 聚类 + LSTM 多输出回归”的实战流程。你可以从代码中看到,核心其实只有四步:
- 将时间序列切分成窗口;
- 用 K-means 找到不同模式;
- 对每个模式分别训练 LSTM;
- 预测时先路由到对应模型,再得到多目标输出。
这套方法的优势在于用聚类把复杂时序数据分解为多个相对简单的子问题,让 LSTM 可以在更一致的数据分布上学习。同时,多输出回归结构允许我们一次预测多个特征,比单独训练多个模型更节省时间,也更容易捕捉目标之间的相关性。
接下来如果你想继续深入,可以从几个方向入手:
- 尝试用 K-Shape、DBSCAN 等时序聚类算法替代 K-means,比较不同聚类方法对预测效果的影响;
- 使用 Attention 机制改造 LSTM,提升长序列预测能力;
- 引入更多特征(如外部温度、节假日)构造多变量 LSTM;
- 把这个方案应用到真实数据集,比如电力负荷、交通流量、股票指数等公开数据。
动手实践是掌握这套流程最好的方式。你可以先运行本文代码,修改模拟数据中的模式种类、窗口长度和模型层数,观察不同设置下的输出差异。遇到问题后,再回来看第 5 节的排查表,基本可以覆盖大多数常见场景。如果这篇文章对你有帮助,建议收藏备用,后续做时序项目时可以直接对照实现。