☰
DTW-Kmeans与Transformer-GRU结合:多变量时间序列预测精度提升新路径
2026/10/5 4:21:26 网站建设 项目流程

简介:面向工业物联网、金融量化、能源调度与智慧城市等场景的多变量时间序列预测,这份docx文档完整呈现了DTW-KMeans聚类算法与Transformer-GRU组合模型的实战项目。方案先以动态时间规整衡量序列形状相似性完成聚类,再通过Transformer编码器与GRU回归头分层建模,兼顾异步对齐、非线性与非平稳特征,适合具备一定深度学习基础的研发人员。内容涵盖数据预处理、模型构建与训练、性能评估、GUI设计以及部署应用全流程,并对异步采样缺失值处理、DTW计算复杂度控制、聚类数量与模型容量选择等工程难点给出针对性解决方案。资源为单个docx文件,压缩包约81KB,已有79人学习。文档目录清晰,从项目背景、模型架构、聚类先验路由到推理监控逐层展开,可帮助读者快速复用这套“聚类先行、分布感知、深度细化”的预测范式,作为多变量回归与组合深度学习的落地参考。

1. 用DTW-Kmeans给Transformer-GRU分簇:多变量预测精度上不去的另一条路

如果你手里有一批多变量时间序列,比如电力负荷、股价五维特征、传感器温压数据,直接丢进Transformer-GRU组合模型训练,往往会发现测试误差一直下不来,预测曲线像是把所有样本“平均”了一遍。问题多半不在模型容量,而在于序列形态差异太大:有的陡升陡降,有的缓慢波动,有的带明显周期。用动态时间规整(DTW)做序列聚类,把形态相似的窗口分到同一簇,再在每个簇上单独训练Transformer-GRU组合模型,很多场景能把RMSE压掉10%到20%。这套方案不挑行业,适合所有“样本不算多但形态复杂”的多变量回归预测。接下来从为什么非DTW不可讲起,逐步落到能跑的Python代码和具体的坑。

2. 序列聚类为什么非DTW不可:欧氏距离的局限与DBA质心平均

2.1 相位偏移与局部伸缩:欧氏距离在时间序列上翻车的根源

Kmeans的核心是距离度量加质心更新。对普通向量,欧氏距离简单直接,但时间序列有平移、伸缩和噪声,两个形状几乎完全相同、只是相位错开的序列,欧氏距离会非常大。多变量场景更麻烦,每个维度的弯曲位置不一样,逐点相减再求和,结果基本被相位差和噪声主导,聚类出来的标签和直觉完全对不上。

用一个最小例子说明。生成两条正弦波,相位相差90度,形态一模一样,只是左右错开:

import numpy as np from fastdtw import fastdtw t = np.linspace(0, 4 * np.pi, 200) s1 = np.sin(t) s2 = np.sin(t + np.pi / 2) euclid = np.sqrt(((s1 - s2) ** 2).sum()) dtw_dist, path = fastdtw(s1, s2) print("欧氏距离:", round(euclid, 2)) print("DTW距离:", round(dtw_dist, 2))

fastdtw的第一个返回值是累计距离,第二个是路径列表。dist参数默认用欧氏距离计算两个点之间的代价,这里没有显式传,所以是按点值做的。跑完后你会看到欧氏距离是DTW距离的好几倍,但这两条曲线本质是同一个波形。这就是为什么直接拿欧氏距离跑Kmeans时,时间序列聚类总是不符合直觉。

DTW的思想是允许一个点对应另一条序列的多个点,通过动态规划寻找累计代价最小的对齐路径。每条路径从矩阵左上走到右下,就定义了一种非线性弯折。复杂度是O(n*m),n和m是两条序列长度,长序列直接算很慢,FastDTW用多层粗化加细化把复杂度降到接近线性,工程上够用。多变量情况下,tslearn内部会把每个特征维的代价加总,再套同一套动态规划。你也可以在fastdtw里传入自定义dist函数,比如对三维向量计算曼哈顿距离。点间度量的选择会影响聚类结果,通常欧氏距离就够,但各维尺度差异大时得先做标准化。

2.2 Kmeans的质心问题:DBA动态时间规整平均

传统Kmeans更新质心时,对簇内样本逐点求平均。但DTW下序列长度可能不同,逐点平均没有意义,而且即便长度相同,逐点平均也会把相位信息磨平。tslearn里的TimeSeriesKMeans用的质心更新方式是DBA,全称DTW Barycenter Averaging。它不采用算术平均,而是构造一条“质心序列”,使得它到簇内所有序列的DTW距离平方和最小。迭代过程大致是:先随机选一条序列作为初始质心,然后对每个簇内样本求DTW对齐路径,把质心上的每个点映射到对应的多个样本点,再把这些点的均值作为质心新值,重复到收敛。

tslearn的调用非常短:

from tslearn.clustering import TimeSeriesKMeans kmeans = TimeSeriesKMeans(n_clusters=3, metric="dtw", n_init=2, max_iter=50, random_state=42) labels = kmeans.fit_predict(X_3d)

X_3d是形状为(n_samples, n_timestamps, n_features)的三维数组。metric="dtw"告诉模型用动态时间规整,质心自然走DBA。n_init是随机初始化的次数,每次跑完后保留损失最小的结果,建议设2到4;max_iter是单次迭代上限,50对大多数数据够用。random_state保证你能复现同一次划分。

为什么序列聚类仍然可以选Kmeans而不是密度聚类?因为Kmeans每次迭代只需要计算每个样本到K个质心的距离,不需要预先算全量两两距离矩阵。样本量到几千时,DBSCAN类方法要n^2的距离矩阵直接爆内存,Kmeans的n*K次距离计算反而可行。代价是它对噪声敏感,而且质心都是DBA迭代算出来的,比普通Kmeans慢得多。序列长度超过500后这种慢会被放大,第5章会讲压缩手段。

还有一点容易被忽略:Kmeans假设簇的分布比较均匀,如果时间序列形态本身极度不平衡,比如90%是缓慢上升、10%是剧烈震荡,Kmeans会因为距离量纲的问题把大簇再切一刀。我一般会先跑一版聚类看簇样本量分布,如果出现明显的大簇吞小簇,就得检查标准化策略,而不是急着调K。后续的3.1节会针对这个问题给一个可复现的处理方式。

3. Python数据准备与DTW-Kmeans聚类:从滑窗到簇划分的可执行代码

3.1 构造多变量回归样本:滑窗切片与多步标签

假设原始数据是一张没有索引的二维表,行按时间排列,列是多种特征,其中一列或多列是预测目标。要做过去L步预测未来H步,最常见做法是滑窗:每个输入样本是连续L行特征,标签是紧接其后的H行目标。下面的函数把这种转换封装好:

import numpy as np def sliding_window(data, input_cols, target_cols, lookback, horizon): X, y = [], [] n = len(data) for start in range(n - lookback - horizon + 1): X.append(data[start:start + lookback, input_cols]) y.append(data[start + lookback:start + lookback + horizon, target_cols]) return np.array(X), np.array(y)

data是二维numpy数组,input_cols和target_cols是列索引列表,lookback是历史窗口长度,horizon是预测步数。比如data有10000步,lookback取48,horizon取12,那么X的每个元素是48行输入特征,y的每个元素是12行目标值,形状分别为(n_samples, 48, 输入特征数)和(n_samples, 12, 目标列数)。这种形状正好能被tslearn和Keras接受。

滑窗时要注意两个问题。一是步长:相邻窗口如果完全重叠,样本之间的自相关极强,聚类和训练都容易过拟合到重叠信息;完全不重叠又会损失样本量。我一般让相邻窗口重叠50%,也就是步长取lookback//2。二是切分:必须先沿时间划分训练集和测试集,再分别滑窗,绝不能先滑窗再随机打乱。如果把未来的窗口混进训练集,验证指标会虚高,上线后立刻露馅。多个独立序列的场景还要按序列ID分组切分,详见5.5。

标准化要在滑窗之后做,但只能在训练集上fit。这里有两种标准化容易混:聚类的标准化建议用样本内独立z-score,即每个窗口减自身均值除自身标准差,这样聚类只关心形态,不受绝对数值影响;而训练Transformer-GRU用的标准化建议合并所有训练样本做全局StandardScaler,保留不同窗口之间的绝对尺度。tslearn的TimeSeriesScalerMeanVariance专门做第一种,代码如下:

from tslearn.preprocessing import TimeSeriesScalerMeanVariance scaler = TimeSeriesScalerMeanVariance(mu=0., std=1.) X_cluster = scaler.fit_transform(X_train)

mu和std是每个样本缩放后的目标均值和标准差,设0和1代表每窗口独立标准化。fit_transform只作用于训练集,测试集预测新样本时调用scaler.transform即可。注意这个scaler和后面训练模型用的StandardScaler是两套对象,别混。

3.2 用TimeSeriesKMeans实现DTW序列聚类

把滑窗后的X_train交给TimeSeriesKMeans,它对形状(n_samples, n_timestamps, n_features)直接处理。这里的关键是聚类对象是“窗口”,而不是整条长序列。窗口级的含义是:每个局部形态都被单独归类,后续模型能针对不同局部模式分别抓规律。

from tslearn.clustering import TimeSeriesKMeans import numpy as np kmeans = TimeSeriesKMeans(n_clusters=3, metric="dtw", n_init=2, max_iter=30, random_state=42) labels = kmeans.fit_predict(X_cluster) unique, counts = np.unique(labels, return_counts=True) print(dict(zip(unique, counts)))

TimeSeriesKMeans的metric参数除dtw外还有softdtw和euclidean。softdtw是可微版本,速度略快但需要额外参数,第一版先用标准dtw。n_init和max_iter按数据量调整,窗口数量几千时n_init=2、max_iter=30足够。cluster结果如果出现某个簇只有十几个样本,后面对应模型的训练集就太小,此时要么增大K,要么调整标准化,让簇分配更均衡。

聚类完成之后需要按簇拆分训练集和测试集,做法很直接:

X_by_cluster = [] y_by_cluster = [] for cid in range(kmeans.n_clusters): idx = np.where(labels == cid)[0] X_by_cluster.append(X_train[idx]) y_by_cluster.append(y_train[idx])

这里用kmeans.n_clusters而不是硬编码3,避免后面改参数时漏改。labels是模型直接输出的整数数组,长度等于n_samples。拆分后X_by_cluster和y_by_cluster长度相同,每个元素是一个二维或三维数组。后面训练模型时,只需要遍历这两个列表。

3.3 选K的实用方法:肘部法则与样本量下限

序列聚类的轮廓系数计算成本高,因为每个样本到所有样本的DTW距离都算一遍才能得到轮廓值,几千窗口根本跑不动。更实际的选K方法是画“簇内平均距离”的肘部曲线。TimeSeriesKMeans的transform方法返回每个样本到每个质心的DTW距离矩阵,取每行的最小值就是该样本到所属簇的距离。

import matplotlib.pyplot as plt K_range = range(2, 8) inners = [] for k in K_range: km = TimeSeriesKMeans(n_clusters=k, metric="dtw", n_init=1, max_iter=20, random_state=42) km.fit(X_cluster) dist_to_center = km.transform(X_cluster).min(axis=1) inners.append(dist_to_center.mean()) print(f"K={k}, 簇内平均DTW距离={inners[-1]:.4f}") plt.plot(list(K_range), inners, marker='o') plt.xlabel("K") plt.ylabel("簇内平均DTW距离") plt.show()

肘部曲线下降变缓的位置是合理K。别只追求曲线拐点后继续增大K,因为每个簇样本量会被切薄。比如总窗口数量是3000,K=5时每簇平均600个,K=8时每簇只有370个,如果某个簇只分到150个,Transformer-GRU这种容量不小的模型很容易过拟合。实际选择时我会取肘部右侧一位,同时保证最小簇样本量不低于batch_size的10倍。

如果你发现某一簇的样本量明显偏少,不要急着换K,先看这些窗口是不是形态过于特殊。可以打印几个质心序列看形状,确认是真实模式还是噪声。噪声主导的小簇可以直接并入距离最近的大簇,合并方式是把该簇样本标签改为最近质心所属簇,再重新训练模型。

4. Keras实现Transformer-GRU组合模型:分簇训练多变量回归预测器

4.1 模型结构:Transformer编码层加GRU层,为什么不是相反

很多人看transformer模型详解时,注意力集中在多头注意力的矩阵计算上,却忽略了它在时序回归里的一个短板:位置编码是绝对的,对局部连续变化不敏感。GRU擅长捕捉短期的上升下降趋势,但长序列上容易遗忘早期信息。把两者串起来,先用Transformer编码层处理整段输入,得到每个时间步携带全局依赖的上下文表示,再把这个表示序列交给GRU,让GRU在全局背景下提取局部演化规律,最后输出未来H步预测。

结构依次是:输入特征经过Dense层映射到d_model维,加上正弦位置编码;再接一个MultiHeadAttention自注意力层,带残差和LayerNorm;然后GRU只输出最后一个时间步的隐藏状态;最后通过Dense输出horizon乘out_dim个数,reshape成(batch, horizon, out_dim)。如果去掉GRU直接对Transformer输出做全局池化,短期预测会明显钝化;如果去掉Transformer只留GRU,长序列的滞后问题又会回来。组合的价值正在于此。

基于Keras的实现如下:

import tensorflow as tf from tensorflow.keras import layers, Model class PositionalEncoding(layers.Layer): def __init__(self, d_model): super().__init__() self.d_model = d_model def call(self, inputs): seq_len = tf.shape(inputs)[1] pos = tf.range(seq_len, dtype=tf.float32)[:, None] i = tf.range(self.d_model, dtype=tf.float32)[None, :] angle = pos / tf.pow(10000.0, (2 * (i // 2)) / self.d_model) pe = tf.where(i % 2 == 0, tf.sin(angle), tf.cos(angle)) return tf.cast(pe, inputs.dtype) + inputs def build_combined_model(feature_dim, d_model=64, num_heads=4, gru_units=32, horizon=1, out_dim=1): inputs = tf.keras.Input(shape=(None, feature_dim)) x = layers.Dense(d_model)(inputs) x = PositionalEncoding(d_model)(x) attn = layers.MultiHeadAttention(num_heads=num_heads, key_dim=d_model // num_heads) attn_out = attn(x, x) x = layers.Add()([x, attn_out]) x = layers.LayerNormalization(epsilon=1e-6)(x) x = layers.GRU(gru_units, return_sequences=False)(x) x = layers.Dense(horizon * out_dim)(x) outputs = layers.Reshape((horizon, out_dim))(x) model = Model(inputs, outputs) return model

PositionalEncoding实现的是经典sin/cos位置编码,d_model必须能被num_heads整除,否则key_dim算出来是小数,Keras会直接报错。MultiHeadAttention的key_dim是每个头的投影维度,令它等于d_model//num_heads,让不同头关注不同子空间。attn(x, x)表示query、key、value都是同一个输入,即自注意力。残差和LayerNorm放在注意力之后是Transformer标准做法。GRU的return_sequences=False表示只输出最后一个时间步的隐藏状态,作为整个序列的摘要,后续Dense层拿这个摘要生成未来预测。

有个容易被忽略的点:滑窗数据都是等长的,所以这里不需要mask。如果以后换成不定长序列,用padding补齐,必须给MultiHeadAttention手动传mask,否则注意力会看到padding位置。Keras的GRU本身能自动继承mask,但Attention层不会。

4.2 按簇训练模型:时间顺序切分、编译与保存

训练各簇模型时,一定要按时间顺序切分训练集和验证集。随机切分会把未来窗口混进训练,指标好看但没有参考价值。下面的函数接收第3章产出的X_by_cluster和y_by_cluster,逐簇构建模型并训练:

def train_by_clusters(X_by_cluster, y_by_cluster, d_model=64, num_heads=4, gru_units=32, horizon=1, out_dim=1, epochs=30, batch_size=64): cluster_models = {} for cid, (Xc, yc) in enumerate(zip(X_by_cluster, y_by_cluster)): split = int(len(Xc) * 0.8) X_tr, X_val = Xc[:split], Xc[split:] y_tr, y_val = yc[:split], yc[split:] model = build_combined_model(feature_dim=X_tr.shape[2], d_model=d_model, num_heads=num_heads, gru_units=gru_units, horizon=horizon, out_dim=out_dim) model.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3), loss="mse", metrics=["mae"]) history = model.fit(X_tr, y_tr, validation_data=(X_val, y_val), epochs=epochs, batch_size=batch_size, verbose=1) cluster_models[cid] = model return cluster_models

split取整后,Xc[:split]是较早的窗口,Xc[split:]是较晚的窗口。如果数据来自多条独立序列,这里不能直接按行切,而应按序列ID分组,保证同一序列的尾部窗口不会出现在训练集、头部窗口出现在验证集。Adam初始学习率1e-3对Transformer编码层来说偏大,如果loss震荡,先看第5.3节。训练结束后,保存模型可以用model.save(f"cluster_{cid}.h5")。加载时因为自定义了PositionalEncoding层,需要传custom_objects;更省事的做法是用model.export("cluster_0"),新版TensorFlow直接把整层代码打包,加载不需要额外参数。

每簇样本量不同,训练epochs可以相同,但batch_size建议维持一致,避免某些簇因样本太少导致每个epoch步骤数过少。实际项目里,我会先看每簇样本量,最少的那一簇适当增大epochs或做轻微数据增强。时间序列增强常用缩放和加噪声,但要注意不能破坏窗口的时间顺序。

4.3 多变量回归预测评估:RMSE、MAE与分步误差曲线

每个簇的模型训练完成后,在对应验证集上评估。多步预测的输出是三维数组,sklearn的指标大多不接受三维,需要先reshape:

from sklearn.metrics import mean_squared_error, mean_absolute_error def evaluate_clusters(cluster_models, X_by_cluster, y_by_cluster): for cid, model in cluster_models.items(): y_pred = model.predict(X_by_cluster[cid], verbose=0) y_true = y_by_cluster[cid] y_pred_flat = y_pred.reshape(-1, y_pred.shape[-1]) y_true_flat = y_true.reshape(-1, y_true.shape[-1]) rmse = np.sqrt(mean_squared_error(y_true_flat, y_pred_flat)) mae = mean_absolute_error(y_true_flat, y_pred_flat) eps = 1e-6 mape = np.mean(np.abs((y_true - y_pred) / (np.abs(y_true) + eps))) * 100 print(f"cluster {cid}: RMSE={rmse:.4f}, MAE={mae:.4f}, MAPE={mape:.2f}%")

MAPE对接近零的真实值非常敏感,如果目标序列本身有零值,MAPE会变大到失去意义,这时候改用SMAPE或MASE更合适。多步预测还应该按预测步单独看误差,下面的代码计算未来H步各自的RMSE:

step_rmse = np.sqrt(((y_true - y_pred) ** 2).mean(axis=(0, -1))) print("Each horizon RMSE:", step_rmse)

如果输出是[0.10, 0.15, 0.22, 0.31]这样的递增序列,说明模型对远期预测确实更难,这是正常的。但如果你看到最后一步误差突然比前一步大一倍以上,下一步要检查训练标签是否构造错误,或者模型是否在输出历史均值。真正合格的组合模型,误差增长通常是缓慢的,不会在第1步到第4步之间直接翻三倍。

5. 避坑笔记:DTW-Kmeans+Transformer-GRU的5个翻车现场与解决参数

5.1 坑:DTW距离计算慢到跑不完,聚类半小时没结果

现象是TimeSeriesKMeans.fit无限等待,训练集只有几千个窗口也迟迟不返回。原因在于每次迭代要计算每个样本到每个质心的DTW距离,质心更新又要跑多轮DBA,序列长度一旦超过1000,复杂度立刻上来。解决办法是下采样和加约束窗。下采样就是每隔一个点取一个,比如原本序列长度96,下采样到48,聚类结果通常不变:DTW本身是按全局形态对齐的,局部细节砍掉一半影响不大。约束窗用Sakoe-Chiba band:

kmeans = TimeSeriesKMeans( n_clusters=3, metric="dtw", metric_params={"sakoe_chiba_global_constraint": True, "sakoe_chiba_radius": 16}, n_init=1, max_iter=20, random_state=42)

sakoe_chiba_radius是带宽,单位是时间步。设16意味着任一时刻的点最多只能对齐到另一条序列偏移16个位置范围内的点,超出范围直接不允许。这个参数先试序列长度的10%,如果聚类结果和原来差不多就保持,能省一半以上计算量。n_init设1避免重复随机初始化,速度翻倍。窗口约束还有一个额外好处:过滤掉那些距离很远但碰巧对齐的顽固样本,让聚类更稳健。

5.2 坑:聚类后预测误差不降反升,还不如一个全局模型

现象是加了DTW-Kmeans之后,每个簇单独训练,验证误差反而比不聚类更大。原因有两层:一是数据本身可能同质,强行分簇只是把样本切薄,模型每个簇的数据量不足;二是聚类用的输入特征和预测目标不一定相关,两个簇的形态差异明显但目标分布几乎一样,分簇就白做了。解决是先跑一个不聚类的全量Transformer-GRU做基线,再来看分簇收益。然后检查每个簇的目标值分布,简单做一步:

for cid in range(kmeans.n_clusters): yc = y_by_cluster[cid] print(cid, np.mean(yc), np.std(yc), len(yc))

如果不同簇的y均值、标准差差异很小,说明聚类没有为目标预测提供有效切分,此时应该改用聚类特征增强方式,而不是硬分簇训练多个模型。第6.3节的方案就是给全量模型拼接聚类距离特征,效果通常比硬分簇稳定。

5.3 坑:Transformer加GRU训练时loss震荡,验证集不收敛

现象是训练前几百步loss剧烈摆动,到后面也停不下来。原因是Transformer的注意力层对学习率过于敏感,固定1e-3的Adam在浅层时可能发散,加上多变量特征尺度不统一,即使做了StandardScaler,Dense层输入的梯度也会抖动。解决是用学习率衰减和梯度裁剪:

lr_schedule = tf.keras.optimizers.schedules.ExponentialDecay( initial_learning_rate=1e-3, decay_steps=500, decay_rate=0.96) optimizer = tf.keras.optimizers.Adam(learning_rate=lr_schedule, clipnorm=1.0) model.compile(optimizer=optimizer, loss="mse", metrics=["mae"])

ExponentialDecay每500步衰减4%,能缓解后期震荡。clipnorm=1.0把梯度的L2范数裁剪到1,GRU虽然比LSTM温和,但长序列上梯度爆炸仍然可能发生。如果loss还是不稳,把初始学习率降到5e-4或者扩大batch_size。还有一个很多人踩的坑:不要一上来就加多层Transformer编码层。一层自注意力对大多数回归任务已经够用,层数加深只会让训练更脆。

5.4 坑:多步预测后面几步输出接近均值,曲线像“冬眠”

现象是第1步预测有起伏,第2到第H步逐渐变成一条水平线。原因通常是标签构造时采用了递归预测方式,模型在训练时看到的都是上一步预测作为下一步输入,误差被一步步放大,最后学会输出一个保守的均值以降低远期loss。更重要的一种情况是,模型输出的多步维度与标签维度不一致,loss在reshape时被错误平均,模型被迫对远期目标采取最保险的策略。解决方式是确认训练标签是直接多步输出:y[i]存的是未来H步真实值,模型一次输出H步,不做递归。然后在损失函数里给近期步更高权重:

weights = np.linspace(1.5, 0.5, horizon) def weighted_mse(y_true, y_pred): return tf.reduce_mean(tf.square(y_true - y_pred) * weights)

传给compile的loss时用weighted_mse。这样模型会优先把近期步拟合准,远期步随权重自然放松。注意weights要在loss函数外定义好,如果horizon是1,这个函数退化成语义MSE,没有副作用。

5.5 坑:验证集指标漂亮,上线后偏差大到没法用

现象是本地按KFold随机切分出来的RMSE很低,实际部署时误差翻倍。原因是时间序列窗口之间天然有自相关,随机切分后训练集和验证集会出现同一个长序列的相邻窗口,模型实质上见过验证集的“近亲”信息。解决是严格按时间顺序切分,验证集必须在时间上晚于训练集所有窗口。遇到多条独立序列时还要按序列ID分组切分,绝不能让同一设备的窗口同时落在两侧:

group_ids = np.array([seq_id[i] for i in range(n_samples)]) # 按时间顺序取后20% group_id作为验证集 val_mask = group_ids >= group_ids_sorted[int(len(group_ids) * 0.8)] train_mask = ~val_mask

这段示意代码强调一个原则:分组切分按业务实体走,而不是按行走。聚类本身也可能加速泄露,因为聚类把不同设备中形态相似的窗口聚到同一簇,训练集和验证集如果来自同一设备的不同时段,模型就很容易“记住”设备的特征。所以我在实践里先分组再滑窗,滑窗切分后把组ID传下去,每一步都带着它做评估。

6. 进阶:两段式簇推断、滚动验证与聚类特征扩展

6.1 新样本到达时,先选簇再预测

推断阶段与训练阶段不同,新来一个窗口要先用kmeans找它属于哪个簇,再调用对应模型预测。tslearn的scaler同样要保存,顺序是先样本内标准化再predict:

def predict_new(kmeans, scaler, cluster_models, X_new): X_scaled = scaler.transform(X_new) cid = kmeans.predict(X_scaled)[0] return cluster_models[cid].predict(X_new, verbose=0)

注意这里不能对新样本做fit_transform,因为scaler已经在训练集上fit过。TimeSeriesScalerMeanVariance是逐样本独立标准化,fit阶段几乎没有统计量,但为了保证API一致,你仍然要复用同一个scaler对象。如果你在聚类前用的是StandardScaler,那必须保存fit后的均值方差,推断时用transform而不是重新fit。

6.2 用滚动时间序列交叉验证替代随机切分

滚动验证模拟真实上线过程:在测试区间中逐窗口滑动,每次都只看没被训练过的窗口。实现简单,但能测出模型在时间漂移面前的稳定表现:

def rolling_validation(X, y, kmeans, scaler, cluster_models, test_start, test_len): preds, trues = [], [] for i in range(test_start, test_start + test_len): X_new = X[i:i+1] y_new = y[i:i+1] cid = kmeans.predict(scaler.transform(X_new))[0] pred = cluster_models[cid].predict(X_new, verbose=0) preds.append(pred[0, 0, 0]) trues.append(y_new[0, 0, 0]) return np.array(preds), np.array(trues)

这个循环里没有重新训练模型,验证的是固定模型在新数据上的表现。如果你想模拟模型定期更新,可以把循环改成每滑N步重训一次,但成本会很高。我通常先把固定模型滚一遍,和单模型基线做对比,如果分簇方式在滚动验证下没有正向收益,就果断放弃多模型策略。做这个对比时,要保证两种方案使用完全相同的滚动窗口和损失指标。

6.3 把簇ID和质心距离作为特征扩展

除了硬分簇,还可以把聚类信息拼进输入特征,让全量模型感知当前窗口与历史典型形态的距离。这一步相当于给Transformer-GRU加了一个先验门控,在样本量不足时往往比硬分簇更稳。

dist_to_centers = kmeans.transform(X_cluster) dist_feat = np.repeat(dist_to_centers[:, None, :], X_train.shape[1], axis=1) X_aug = np.concatenate([X_train, dist_feat], axis=-1)

dist_to_centers形状是(n_samples, n_clusters),每个样本到每个质心的DTW距离。np.repeat把它沿时间轴复制,使每个时间步都携带当前窗口到质心的编码。原始输入特征维度F变成F+n_clusters,其余结构和4.1的build_combined_model完全一致。训练时用X_aug,预测新样本时也要用同一个kmeans.transform生成距离再拼接。这种方式的好处是不需要维护多个模型,一个模型全搞定;缺点是聚类误差会直接传导进模型输入,所以聚类本身要稳。

我的习惯是先用6.2的滚动验证跑一个不加聚类特征的全量模型做基线,再跑分簇多模型,最后试聚类特征增强。每次只加一个变量,看增量收益是否在5%以上。如果只有特征增强提升而分簇模型没提升,说明硬分簇过于武断,边界样本在两个簇之间来回切换反而吃亏。这套检查逻辑帮我在多个项目里避免了为复杂度而复杂度的方案。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询