资讯动态

DTW-Kmeans时间序列聚类:原理、Matlab代码与参数调优

发布时间:2026/10/6 13:19:08 来源:尧图企业网站定制
做时间序列聚类的时候我最开始以为直接套Kmeans就行结果在一条真实业务数据上栽了大跟头两条形状几乎一样的波形只因为其中一个往前平移了几个采样点欧氏距离就被拉得巨大硬生生被分到了两个簇里。后来把距离度量换成DTW动态时间弯曲距离配合Kmeans做时间序列聚类分析在Matlab里实现了完整模型这个问题才彻底解决。这篇文章把我从零搭建这套DTW-Kmeans模型的完整过程、代码、调参思路和踩过的坑全部整理出来适合正在做时序聚类、传感器波形分类、用户行为分群的读者参考。先说结论DTWKmeans这套组合之所以实用不是因为算法本身多高深而是它解决了时间序列聚类里最核心的痛点——“形状相似但相位错位”的问题。下面从原理讲起再把Matlab代码逐段掰开揉碎。1. 为什么时间序列聚类不能只靠欧氏距离DTW的底层逻辑1.1 欧氏距离在时序对齐上的天然缺陷不少人在做序列聚类时第一反应就是把每条序列当成一个高维向量然后按点计算欧氏距离。这个做法有两个前提限制一是序列必须等长二是采样点必须一一对应。可真实场景里几乎没有这么理想的数据。传感器采样的时间间隔可能不同人的动作快慢会变电器的启动时序有先后这些都会导致两条序列在时间轴上“对不齐”。我举个最直观的例子有两条温度曲线一条在第10分钟达到峰值38度另一条在第20分钟才达到峰值。它们的形状完全一样只是相位错开了10分钟。如果按欧氏距离计算对应时间点的温度差值会全部叠加起来距离反而很大。而人眼一眼就能看出这就是同一种变化模式。欧氏距离度量的是“同一时刻的数值差”不是“形状的相似度”这就在根本上限制了它在时间序列上的使用。1.2 DTW的核心思想允许时间轴弯曲DTW的全称是Dynamic Time Warping动态时间弯曲。它的想法非常朴素在比较两条序列的时候不需要让第i个点对齐第i个点而是允许一个点对齐另一个序列上的多个点也允许跳过部分点只要整体对齐代价最小就行。具体计算过程分为三步构造一个距离矩阵矩阵的第i行第j列存放序列x的第i个点与序列y的第j个点之间的欧氏距离从矩阵左上角到右下角找一条累计距离最小的路径这条路径要满足连续性和单调性约束路径终点的累计距离就是两个序列的DTW距离。如果写成递推公式就是[ g(i,j) d(x_i, y_j) \min{ g(i-1,j), g(i,j-1), g(i-1,j-1) } ]其中(g(i,j))表示从起点到((i,j))的累计代价。这个公式实际上就是动态规划只不过把一维序列的比对变成了二维网格上的最短路径搜索。用生活化的类比来理解两条序列好比两条不同长度的绳子你要把它们的纹理对齐。欧氏距离是硬邦邦地把绳子切成同样等份逐段对齐DTW则是允许你拉伸或者压缩绳子的任意一段让特征点尽量对上最终对齐得更好代价也自然更小。1.3 举个实际例子对比两种距离我用Matlab构造两条序列一条是正弦波另一条是同一正弦波左移了5个点同时两端补零t1 0:0.1:6.28; x sin(t1); y [sin(t1(6:end)), zeros(1, 5)]; % 左移5个点尾巴补零 d_euc sqrt(sum((x - y).^2)); d_dtw dtw_distance(x, y, inf);在我的测试里欧氏距离的值大约是4.5左右而DTW距离只有0.3左右。差距数量级都很明显。这就是典型的相位错位场景欧氏距离完全失真DTW却能忠实反映形状的相似程度。但也要提醒一点DTW不是万能的。如果两条序列本身形状差异很大DTW距离照样会大。它只负责消除“时间轴上的扭曲”不负责抹平“数值形态上的本质差异”。2. DTW-Kmeans的整体架构从距离计算到质心更新2.1 经典Kmeans为什么不能直接搬过来熟悉Kmeans的人都知道它的迭代分两步先把每个样本分配到距离最近的质心然后重新计算每个簇的质心也就是该簇所有样本的均值。对普通向量数据来说这个流程没有任何问题。但换成时间序列之后质心的计算立刻变成棘手的事。原因在于DTW距离不是定义在欧氏空间里的标准距离。两条序列按DTW对齐之后对应点很可能不是原始索引对齐的。如果按常规方法把同一个索引位置上的数值直接平均得到的新序列没有任何一个点能代表这个簇的“典型形态”因为它忽略了序列之间在时间轴上的对齐关系。我在第一次实现时就踩了这个坑直接把簇内所有序列逐点求平均当作新质心结果迭代了两次质心就变成一条完全失真的波形聚类结果也跟着崩了。2.2 两种可靠的质心更新策略解决质心问题我实际用过两种方法各有优劣。第一种是Medoid策略。在每个簇中找到一条到簇内其他序列DTW距离之和最小的序列把它当作这个簇的质心。这个方法实现简单质心永远是真实存在的序列可解释性很强但缺点是质心形态受初始簇内成员影响较大如果簇内多样性高质心的代表性就不足。第二种是DBA策略DTW Barycenter Averaging。它的思路是先给一个初始质心然后对簇内每条序列做DTW对齐记录质心每个位置被哪些点对齐到再把这些点的数值累加取平均得到新的质心反复迭代直到收敛。DBA得到的质心可以不是原始序列形状更像簇的平均形态聚类紧凑度通常更好。我在实际项目中优先用DBA因为聚类效果更稳定。文章后面给的代码也是DBA实现。2.3 完整算法流程梳理整个DTW-Kmeans的迭代思路整理如下初始化K个质心序列可以随机抽取K条样本序列作为初始质心对每条样本序列分别计算它与K个质心的DTW距离把它归到距离最小的那个簇对每个簇用DBA更新质心序列重复步骤2和3直到聚类标签不再变化或者达到最大迭代次数。这里有一个容易被忽略的细节初始化方式对聚类结果影响很大。随机抽K条作为质心如果抽到了离群序列聚类可能收敛到很差的局部最优。建议要么用Kmeans的思想让初始质心彼此尽量远要么多跑几次随机初始化取轮廓系数最高的一次结果。我在代码演示里就固定了初始化索引实际生产环境建议增加多次随机初始化。3. Matlab代码实现与逐段详解可直接运行3.1 DTW距离函数核心中的核心这个函数是整套模型的地基任何一步聚类都需要它来计算距离。我先把代码贴出来function d dtw_distance(x, y, w) % DTW_DISTANCE 计算两个时间序列之间的DTW距离 % 输入 % x, y两个一维时间序列向量长度可以不同 % w 弯曲窗口限制默认为inf表示不限制 % 输出 % d DTW距离标量 if nargin 3 || isempty(w) w inf; end x x(:); y y(:); nx length(x); ny length(y); % 距离矩阵利用Matlab向量化写法避免双重for循环 D (x - y.).^2; % 累积距离矩阵初始化未到达的位置设为inf g inf(nx, ny); g(1, 1) D(1, 1); % 边界列和边界行只能沿着一条线走单独处理 for i 2:nx g(i, 1) D(i, 1) g(i-1, 1); end for j 2:ny g(1, j) D(1, j) g(1, j-1); end % 主循环动态规划填表 for i 2:nx if isfinite(w) j_start max(2, i - w); j_end min(ny, i w); else j_start 2; j_end ny; end for j j_start:j_end g(i, j) D(i, j) min([g(i-1, j), g(i, j-1), g(i-1, j-1)]); end end % 右下角的值就是DTW距离 d g(nx, ny); end有一个性能细节值得单独说D (x - y.).^2这行让我避开了双重循环求距离矩阵在Matlab里向量化写法比两层for快非常多。当序列长度在几百个点以内时这点速度差异可能不明显但聚类要算几万次距离的话这个优化能省下大量时间。弯曲窗口w的作用是限制路径偏离对角线的范围避免算法为了追求最小距离而把两条序列扭曲得面目全非。窗口越小计算量越小但也越接近欧氏距离的刚性对齐窗口越大DTW的灵活性越高。后面第5章我会专门讲怎么选w。3.2 DBA质心更新函数这个函数实现了我前面说的DBA策略把簇内所有序列对齐到当前质心上按质心位置累加所有对齐点的值然后取平均得到新质心。function center dtw_centroid(seqs, init_center, max_iter) % DTW_CENTROID 基于DBA思想更新聚类质心 % 输入 % seqs cell数组每个元素是簇内一条时间序列 % init_center 初始质心序列向量 % max_iter DBA内部迭代次数 % 输出 % center 更新后的质心序列 center init_center(:); n_center length(center); for iter 1:max_iter acc_sum zeros(n_center, 1); acc_cnt zeros(n_center, 1); for s 1:length(seqs) x seqs{s}(:); y center; nx length(x); ny n_center; % 计算DTW累积距离矩阵 D (x - y.).^2; g inf(nx, ny); g(1, 1) D(1, 1); for i 2:nx g(i, 1) D(i, 1) g(i-1, 1); end for j 2:ny g(1, j) D(1, j) g(1, j-1); end for i 2:nx for j 2:ny g(i, j) D(i, j) min([g(i-1, j), g(i, j-1), g(i-1, j-1)]); end end % 回溯对齐路径将序列点累加到质心对应位置 i nx; j ny; while ~(i 1 j 1) acc_sum(j) acc_sum(j) x(i); acc_cnt(j) acc_cnt(j) 1; if i 1 j j - 1; elseif j 1 i i - 1; else [~, idx] min([g(i-1, j), g(i, j-1), g(i-1, j-1)]); if idx 1 i i - 1; elseif idx 2 j j - 1; else i i - 1; j j - 1; end end end % 起点位置也要累加 acc_sum(1) acc_sum(1) x(1); acc_cnt(1) acc_cnt(1) 1; end % 取平均得到新质心 center acc_sum ./ max(acc_cnt, eps); end end这段代码最需要注意的地方是回溯部分。while循环里不断更新i和j并把x(i)累加到质心的第j个位置。这里虽然质心的索引只有n_center个但DTW路径里序列的多个点可能会映射到质心同一个点上所以要用acc_cnt记录每个位置被累加的次数最后除以次数取平均而不是直接除以序列总数。否则质心的幅值会明显偏小聚类结果必然出错。3.3 主脚本完整跑通DTW-Kmeans下面的主脚本演示了在合成数据集上完成聚类的完整流程。数据包含三类不同形态的时间序列每类10条并且长度不完全相同这是为了贴近真实场景。%% 1. 生成模拟数据三类不同形态的时序 rng(42); num_each 10; % 类1高斯脉冲形态峰值位置随机 data1 cell(1, num_each); for i 1:num_each t (0:randi([60, 90])); peak_pos randi([15, 30]); data1{i} exp(-((t - peak_pos) / 8).^2) 0.05 * randn(size(t)); end % 类2正弦振荡形态频率略有变化 data2 cell(1, num_each); for i 1:num_each t (0:randi([70, 100])); freq 0.2 0.05 * rand; data2{i} sin(freq * t) 0.05 * randn(size(t)); end % 类3线性上升趋势 data3 cell(1, num_each); for i 1:num_each t (0:randi([60, 85])); data3{i} 0.05 * t 0.03 * randn(size(t)); end all_seqs [data1, data2, data3]; N length(all_seqs); K 3; %% 2. 初始化质心这里固定取第1、11、21条 init_idx [1, 11, 21]; centers all_seqs(init_idx); %% 3. DTW-Kmeans主迭代 w 5; % 弯曲窗口 max_outer_iter 20; max_dba_iter 10; labels zeros(N, 1); for iter 1:max_outer_iter % 分配步骤 for i 1:N dist_to_center zeros(K, 1); for c 1:K dist_to_center(c) dtw_distance(all_seqs{i}, centers{c}, w); end [~, labels(i)] min(dist_to_center); end % 更新质心 new_centers cell(1, K); for c 1:K member_idx find(labels c); if isempty(member_idx) new_centers{c} centers{c}; else new_centers{c} dtw_centroid(all_seqs(member_idx), centers{c}, max_dba_iter); end end centers new_centers; end %% 4. 可视化聚类结果 figure; hold on; colors [0.85 0.3 0.3; 0.3 0.6 0.85; 0.4 0.75 0.4]; for i 1:N plot(all_seqs{i}, Color, [colors(labels(i), :) 0.35]); end for c 1:K plot(centers{c}, Color, [colors(c, :) 1], LineWidth, 2.5); end hold off; title(DTW-Kmeans聚类结果粗线为各簇质心);3.4 另一种思路用距离矩阵配合层次聚类如果你手头的问题对“指定K个簇”没要求只是想先看数据大体分成几类那么还有一个更省事的方式先算所有序列两两之间的DTW距离矩阵再把这个距离矩阵交给层次聚类。Matlab里自带linkage和cluster两个函数可以直接用。这种做法在探索阶段特别合适因为它避免了对质心初始化的依赖聚类结果更直观。distM zeros(N, N); for i 1:N for j i1:N d dtw_distance(all_seqs{i}, all_seqs{j}, w); distM(i, j) d; distM(j, i) d; end end Z linkage(squareform(distM), average); labels_hier cluster(Z, maxclust, K);不过要注意层次聚类的“平均连接”策略和Kmeans优化目标不完全一致两者结果可能有差异。我的经验是先用距离矩阵做层次聚类探索数据轮廓再拿Kmeans做精细化分群两个结果互相印证是最稳妥的路径。4. 实战验证从合成数据到真实场景4.1 合成数据上的分类效果我在自己的Matlab环境里跑了上面的主脚本三类数据各10条总共30条序列。因为类别是已知的我可以直接比较聚类标签和真实标签算出一个准确率。在我的测试里聚类准确率稳定在100%也就是30条序列全部归到了正确的簇。这里有一个容易被误解的点不是说三条初始质心选得恰好对应三个类别所以结果才这么好。事实上我把初始化改成完全随机的抽样多跑几次绝大多数情况下也能达到100%或只错一两条。这说明这套算法对合成数据中的“形状差异”有很强的分辨能力。真正会让结果翻车的场景是两类形状本身就有交叠或者噪声太大把形状特征都掩盖了这种时候任何聚类算法都会吃力。4.2 一个具体业务场景用户操作行为分群为了不让你觉得这套东西只能用来跑仿真我讲一个实际做过的例子。有段时间我需要给一个Web产品的用户操作序列分群每一条序列记录的是用户在某个功能模块上的操作次数随时间的变化。由于用户操作节奏不同同样是“活跃用户”有的早高峰活跃有的晚高峰活跃直接用欧氏距离聚类得到的分群结果基本就是把“平均值高的人”和“平均值低的人”分开毫无业务洞察。换成DTW-Kmeans之后聚类出来的每一类都有了明确的形态特征一类是“前段操作密集、后段迅速冷却”对应新用户尝鲜后流失一类是“持续低频稳定操作”对应核心忠实用户还有一类是“周期性脉冲”对应定时批量操作的用户。这种分群结果直接可以用来做差异化的运营策略。这个例子说明DTWKmeans的真正价值不在于算法炫技而在于它能帮你把“时间模式”这个维度从数据里拎出来。4.3 真实数据和合成数据的关键差异真实数据永远比合成数据脏。我在跑真实数据时遇到过三个问题一是缺失值。传感器中途断连、用户停止操作都会留下空洞。我的处理原则是如果是短段缺失比如连续5个点以内做线性插值如果是长段缺失直接截断或标记断点不要强行插值否则会制造出虚假的连续变化模式。二是噪声量级不稳定。不同设备、不同用户的噪声方差可能差很多。建议在进入聚类前对每条序列做z-score标准化seq (seq - mean(seq)) / std(seq);这样处理之后聚类的核心依据就是“形状”和“相对变化幅度”而不是绝对的振幅高低。如果振幅本身也是业务关心的维度那就另当别论需要保留原始尺度或者把振幅特征单独抽出来参与聚类。三是序列长度差异巨大。有的序列几十个点有的上千个点。DTW虽然允许长度不同但序列越长越容易积累更大的距离值导致聚类偏向长度接近的序列。稳妥的做法是给DTW距离做一个长度归一化把最终距离除以路径长度或者两序列长度之和。我在代码里没有默认加这个归一化因为有些场景希望保留长度信息但你在实际使用时应该根据业务判断是否要加。4.4 质心的可解释性验证聚类完成之后我还习惯做一个“质心合理性”检查把每个簇的质心画出来和簇内几条代表性序列叠在一起看。如果质心形状明显偏离所有成员、出现过度光滑或者异常尖刺通常说明DBA迭代收敛出了问题或者簇内序列形态本身就不统一。有一次我就遇到质心变得几乎是一条水平线的情况排查了半个多小时最后发现是数据里混入了噪声特别大的异常序列它被分配进一个簇之后DBA平均时把每个位置的数值都拉向中间。剔除异常值后重跑质心形状马上就正常了。所以聚类结果出来后不要急着下结论先看质心这是最廉价的校验方法。5. 参数调优与性能优化窗口、聚类数、计算瓶颈5.1 弯曲窗口w到底怎么选前面提到w用来限制DTW路径偏离对角线的距离。这个参数直接影响计算量和算法的“灵活度”。我在实践中总结出三个选择方法如果对问题的时序错位程度有先验知识直接按最大可能偏移量设置。比如已知两个设备的采样时钟最多差5秒采样率1Hz那就设w5。没有先验知识时按序列长度的10%到20%设一个窗口。例如序列长度100w取10到20。这个范围在多数场景下既能吸收合理的相位偏差又不会让算法无节制弯曲。用交叉验证或网格搜索在少量样本上试w[0, 2, 5, 10, 20]观察聚类轮廓系数或业务指标选最优值。w设置为0的时候DTW就退化成等长序列的欧氏距离所以你可以用“w0”作为一个对照基线检验DTW到底带来了多少提升。如果w0和w5的结果差别不大说明数据里根本没有明显的相位错位那用普通Kmeans就够了没必要付出DTW的计算代价。5.2 聚类数K的确定肘部法加轮廓系数聚类数K是另一个必须面对的参数。最常用的组合是肘部法加轮廓系数。肘部法的思路很直白分别跑K2到K8记录每次聚类的总簇内距离和所有样本到所属质心的DTW距离之和然后画折线图。随着K增大总距离一定下降但下降幅度会有一个明显从“急剧”变“平缓”的转折点这个拐点就是较优的K。轮廓系数则是更精细的评价它同时考虑样本与同簇样本的相似度和与最近异簇样本的差异度数值范围在[-1,1]之间越接近1说明聚类越合理。Matlab里可以自己实现轮廓系数的计算核心公式不复杂% silhouette_value对每个样本计算轮廓系数 % a样本到同簇其他样本的平均DTW距离 % b样本到最近异簇所有样本的平均DTW距离 % s (b - a) / max(a, b)我通常的做法是先看肘部法确认一个大致范围再在这个范围内比较平均轮廓系数选平均轮廓系数最高且K不要过大的那个值。记住K不是越大越好业务上的可解释性优先级永远高于数学指标。5.3 计算瓶颈与优化策略DTW-Kmeans最大的毛病就是慢。一次DTW距离计算的复杂度是O(n*m)其中n和m是两条序列的长度聚类又要反复迭代整体复杂度相当可观。我在实际项目中优化性能主要靠四板斧弯曲窗口限制。把w从inf缩小到长度的15%之后计算量能降到原来的十分之一以下。预计算距离矩阵。如果聚类迭代中样本分配步骤占了大头可以先算好所有样本两两之间的DTW距离并缓存Medoid策略的质心就可以直接查表。DBA策略仍然要实时计算对齐路径但至少样本分配的阶段可以省下来。降采样。对超长序列先做降采样或者滑动窗口平滑把几千个点缩到几百个点DTW距离的数值趋势不会大变但计算速度提升几十倍。并行化。Matlab的parfor可以直接把样本分配步骤里的两个for循环并行化前提是各个迭代之间没有数据依赖。我在一次跑120条序列、每条长度800左右的聚类时用parfor把迭代时间从十几分钟压到了三分钟以内。5.4 多跑几次随机初始化Kmeans本质上是坐标下降类算法最终结果依赖初始质心。我见过很多同学跑一次聚类得到一组结果就以为这是“稳定结果”了。实际上换个初始化结果可能完全不同。稳妥的做法是best_labels []; best_score inf; for trial 1:10 init_idx randperm(N, K); % 随机抽K条序列作为初始质心 % 跑一遍DTW-Kmeans得到当前labels % 计算簇内距离总和 if current_score best_score best_score current_score; best_labels current_labels; end end多试几次随机初始化取簇内距离总和最小的一次作为最终结果这是一个成本极低、收益很高的改动。6. 实操中我踩过的坑与改进方向6.1 质心长度漂移问题DBA质心的长度在整个迭代过程中是固定的初始质心多长最后质心还是多长。问题是如果初始质心选得太短它可能无法代表簇内那些更长的序列选得太长又会引入多余的平坦区域。我的经验是初始质心长度取簇内序列长度的中位数比较稳妥。如果你发现某个簇的质心明显偏短且簇内成员基本都更长可以考虑在初始化阶段用该簇最长的序列作为质心让DBA有足够的“空间”容纳所有对齐点。6.2 异常序列的干扰DTW距离对单个时间点的突变不敏感但DBA平均对异常序列很敏感。因为回溯对齐时异常序列的极端值会被累加到质心的某几个位置上把质心局部拉出尖峰。我处理这个问题的办法是第一轮聚类结束后计算每个样本到所属质心的DTW距离找出距离明显偏大的离群点人工确认后再决定剔除还是单独成簇。这个操作在业务上往往也有意义因为离群点可能对应着特殊用户或异常设备。6.3 标准化时机我看到很多人一上来就把所有序列做了z-score标准化然后聚类。这里有个细节容易被忽略标准化应该按每条序列独立做还是按整个数据集统一做我的建议是如果关心的是“形状”按每条序列独立标准化如果关心的是“模式幅值”就按数据集统一标准化。两种情况得到的分群结果可能完全不同。你必须在一开始就明确业务目标而不是事后根据聚类结果去套理由。6.4 什么时候应该放弃DTWDTW不是灵丹妙药。如果你发现加了DTW之后聚类结果跟普通Kmeans差别不大说明你的数据可能压根没有相位错位问题。这时候继续用DTW只会白白增加计算量。还有一种情况也不适合DTW序列之间的形态差异主要体现在频率成分或长期趋势上而不是局部的错位对齐。这种数据更适合先做特征提取比如提取均值、方差、过零率、频谱特征再对特征做聚类效率和数据可解释性都更好。做DTW-Kmeans的这段时间我最大的体会是这类模型真正决定成败的不是算法本身多巧妙而是你是否理解数据生成的过程。DTW解决的是“时间错位”Kmeans解决的是“按距离分簇”但“什么样的错位是业务上有意义的错位”“什么样的距离才反映业务差异”这些判断仍然要回到对业务的理解上。建议你拿到任何一组时间序列数据时先可视化几条观察错位规律再决定要不要上DTW以及窗口w和K怎么设。磨刀不误砍柴工聚类前的这些功夫往往比调参本身更有价值。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑