做时间序列聚类的时候,我最开始以为直接套Kmeans就行,结果在一条真实业务数据上栽了大跟头:两条形状几乎一样的波形,只因为其中一个往前平移了几个采样点,欧氏距离就被拉得巨大,硬生生被分到了两个簇里。后来把距离度量换成DTW(动态时间弯曲距离),配合Kmeans做时间序列聚类分析,在Matlab里实现了完整模型,这个问题才彻底解决。这篇文章把我从零搭建这套DTW-Kmeans模型的完整过程、代码、调参思路和踩过的坑全部整理出来,适合正在做时序聚类、传感器波形分类、用户行为分群的读者参考。
先说结论:DTW+Kmeans这套组合之所以实用,不是因为算法本身多高深,而是它解决了时间序列聚类里最核心的痛点——“形状相似但相位错位”的问题。下面从原理讲起,再把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 = i+1: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之后,聚类出来的每一类都有了明确的形态特征:一类是“前段操作密集、后段迅速冷却”,对应新用户尝鲜后流失;一类是“持续低频稳定操作”,对应核心忠实用户;还有一类是“周期性脉冲”,对应定时批量操作的用户。这种分群结果直接可以用来做差异化的运营策略。
这个例子说明,DTW+Kmeans的真正价值不在于算法炫技,而在于它能帮你把“时间模式”这个维度从数据里拎出来。
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,那就设w=5。
- 没有先验知识时,按序列长度的10%到20%设一个窗口。例如序列长度100,w取10到20。这个范围在多数场景下既能吸收合理的相位偏差,又不会让算法无节制弯曲。
- 用交叉验证或网格搜索:在少量样本上试w=[0, 2, 5, 10, 20],观察聚类轮廓系数或业务指标,选最优值。
w设置为0的时候,DTW就退化成等长序列的欧氏距离,所以你可以用“w=0”作为一个对照基线,检验DTW到底带来了多少提升。如果w=0和w=5的结果差别不大,说明数据里根本没有明显的相位错位,那用普通Kmeans就够了,没必要付出DTW的计算代价。
5.2 聚类数K的确定:肘部法加轮廓系数
聚类数K是另一个必须面对的参数。最常用的组合是肘部法加轮廓系数。
肘部法的思路很直白:分别跑K=2到K=8,记录每次聚类的总簇内距离和(所有样本到所属质心的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 什么时候应该放弃DTW
DTW不是灵丹妙药。如果你发现加了DTW之后,聚类结果跟普通Kmeans差别不大,说明你的数据可能压根没有相位错位问题。这时候继续用DTW只会白白增加计算量。还有一种情况也不适合DTW:序列之间的形态差异主要体现在频率成分或长期趋势上,而不是局部的错位对齐。这种数据更适合先做特征提取(比如提取均值、方差、过零率、频谱特征),再对特征做聚类,效率和数据可解释性都更好。
做DTW-Kmeans的这段时间,我最大的体会是:这类模型真正决定成败的不是算法本身多巧妙,而是你是否理解数据生成的过程。DTW解决的是“时间错位”,Kmeans解决的是“按距离分簇”,但“什么样的错位是业务上有意义的错位”“什么样的距离才反映业务差异”,这些判断仍然要回到对业务的理解上。建议你拿到任何一组时间序列数据时,先可视化几条,观察错位规律,再决定要不要上DTW,以及窗口w和K怎么设。磨刀不误砍柴工,聚类前的这些功夫,往往比调参本身更有价值。