两条序列摆在一起,肉眼看形状几乎一样,只是其中一条慢了半拍。你把它们各自抽成向量丢进欧氏距离,结果是全场最大的一对;换成动态时间规整(Dynamic Time Warping,DTW),距离立刻掉到最小的一档。这个反差我第一次遇到时盯着屏幕看了好一会儿——因为所有默认做法都在告诉我"用距离就完事了",而现实是,时间轴上的一点错位,足以让整个度量体系崩掉。
tslearn 就是冲着这类问题来的。它是一个 Python 时间序列机器学习库,接口风格贴着 scikit-learn 走,核心提供三样东西:带弹性对齐的距离度量(DTW、soft-DTW、GAK 核)、配套的预处理工具(缩放、重采样、变长序列装箱),以及建立在这些度量之上的聚类、分类、最近邻模型。适合谁看?如果你手上的数据是"按时间排好序的一串数",而不是一堆独立特征列,并且你已经发现直接套常规机器学习那一套效果很差,那这个库基本绕不开。下面我不讲它有什么功能列表,讲我在真实数据上怎么用它、哪些参数决定结果对错、哪些地方会白白浪费时间。
1. 先搞清楚 tslearn 的数据形状,不然第一步就错
tslearn 最大的心智负担不在算法,而在数据容器。你熟悉的那套是X.shape == (n_samples, n_features),每个样本是一个点。tslearn 里每个样本是一条曲线,所以X.shape == (n_samples, sz, d),其中sz是时间步数,d是每条曲线在同一个时刻有几个通道。这多出来的一维不是花架子,它决定了后面所有 API 的行为。
1.1 三维数组的每一轴分别代表什么
举个具体的:200 条传感器记录,每条采 100 个时间点,每条记录有 3 个通道(比如三轴加速度),那X.shape就是(200, 100, 3)。第一轴是样本数,和 sklearn 完全一致;第二轴是时间,是"同一条序列内部的顺序";第三轴是特征,是"同一时刻的多个观测"。
关键点在于:第二轴是有序的,第三轴是无序的。判断一个数组该放哪一轴,就问自己一句话:交换这两个维度上的两个值,数据还成立吗?同一时刻的两个通道值互换,数据仍有意义,那就是特征轴;把第 3 秒和第 30 秒的值互换,物理意义就毁了,那就是时间轴。我见过不止一次把(n_samples, d, sz)传进去的代码,程序不报错,只是结果全是噪声——因为形状检查只看维度数量,不看语义。
1.2 单变量序列也别偷懒传一维数组
只做单通道时,很多人会写x = np.array([1.0, 2.0, 3.0, ...]),形状(sz,),然后直接扔给dtw()。这类写法在 tslearn 的不同版本里行为不完全一致,有的会帮你补一维,有的会把整条序列当成一个长度为 1 的样本。与其猜,不如恒定写成(sz, 1):
import numpy as np from tslearn.metrics import dtw, dtw_path # 显式写成 (sz, 1),不给自己留隐患 s1 = np.array([0.0, 1.0, 2.0, 3.0]).reshape(-1, 1) s2 = np.array([0.0, 0.5, 1.5, 2.5, 3.0]).reshape(-1, 1) path, dist = dtw_path(s1, s2) print(dist) # 对齐后的累计代价 print(len(path)) # 对齐路径上的点对数dtw_path返回的是(对齐路径, 距离)这个顺序,path在前。这一点和直觉相反,很多人第一反应是dist, path = dtw_path(...),结果拿到一个列表去做数值运算,报错信息极难看懂。我建议养成习惯:要么显式解包成path, dist,要么只调dtw(s1, s2)拿距离,别混着用。
1.3 变长序列用 NaN 填充,不是补零
现实数据几乎不可能等长。tslearn 的做法是用to_time_series_dataset把变长列表装箱成一个三维数组,短的序列尾部补NaN:
from tslearn.utils import to_time_series_dataset dataset = to_time_series_dataset([s1, s2]) print(dataset.shape) # (2, 5, 1) print(dataset[0, 4, 0]) # nan,短的那条被补齐了这里是补 NaN,不是补 0,而且 DTW 系列度量内部会做 NaN 掩码,把补齐的位置排除在计算之外。这一点非常重要:如果你是手工np.zeros补的,DTW 会老老实实把这些 0 当成真实观测参与对齐,尾部凭空多出一段"归零"的波形,距离被系统性拉偏。补零导致的模型退化特别隐蔽,它不会报错,只会让你的聚类结果里出现一个奇怪的簇,里面全是短序列。
2. DTW 的参数在决定你的距离到底算得对不对
DTW 的原始定义允许一条序列的任意一个点和另一条序列的任意一个点匹配,代价是O(sz1 * sz2)的时间复杂度和同样量级的搜索空间。tslearn 给了两个约束参数来限制对齐路径的搜索范围:sakoe_chiba_radius和itakura_max_slope。这两个参数是精度与速度的交换开关,不是可有可无的调优项。
2.1 全局约束:为什么加了半径反而更准
不加约束的 DTW 有个著名的病:它可以靠"过度拉伸"把两条本来不像的序列对得很像。极端情况是把一条序列的某个点反复匹配到另一条的整段上,路径严重弯曲,距离被压得很低,这叫病态对齐。加 Sakoe-Chiba 带就是给路径加一个横向偏移上限,只允许在|i - j| <= radius的区域内走。
from tslearn.metrics import dtw, sakoe_chiba_band d_free = dtw(s1, s2) # 无约束 d_band = dtw(s1, s2, sakoe_chiba_radius=1) # 只允许偏移 1 步 band = sakoe_chiba_band(4, 5, radius=1) # 顺手看看掩码长什么样半径怎么定?我的经验是:先看你的物理采样率能容忍多大错位。如果两条曲线来自同类事件,只是启动时间差了几拍,半径设成"合理错位步数"就够,比如采样 100 点、预期错位不超过 10%,半径给 10。设得太小,真正的对齐路径被切掉,距离会变大,模型会误判为"不像";设得太大,病态对齐就回来了,距离变小得离谱,模型又会误判为"很像"。两头都会错,而且错的方向相反,这解释了为什么有人调完半径发现效果时好时坏——其实是没搞清自己数据里错位的量级。
一个实用的检查手段:固定随机取 20 对样本,把半径从 1 试到sz//5,画出距离分布。如果距离随半径增大先快速下降然后走平,说明真实错位在走平点之前;如果一直下降不停,大概率你的数据里有病态对齐在作祟,该考虑加约束而不是放开。
2.2 变长序列上 DTW 距离不能跨长度横比
DTW 距离是累计代价,序列越长,路径上的点对越多,累计值天然越大。这意味着一条 50 步的序列和一条 500 步的序列算出的 DTW 值,量纲根本不同。在聚类里这会带来一个很恶心的后果:长序列之间距离普遍大,短序列之间普遍小,中心点更新时短序列主导,长序列被边缘化。
我的处理办法是先按业务逻辑分桶,长度差异超过 3 倍的序列不要放进同一个模型;确实必须混着做的,先做长度归一化(见下一节重采样),或者用soft-dtw这类有平滑处理的度量降低敏感度。别指望用"把 DTW 距离除以路径长度"来修,除以路径长度之后性质就不再是距离了,聚类中心会飘。
2.3 第一次调用会卡住几秒,那是编译不是卡死
tslearn 的 DTW 底层用 numba 做即时编译。第一次调用dtw()时你会看到明显停顿,几秒到十几秒不等,取决于机器。这不是库有问题,是编译开销。我最早不知道这件事,在循环里逐对算距离,还以为是自己代码写崩了,中途 Ctrl-C 了好几次。
正确做法是:在正式跑任务之前,先拿两条小序列做一次预热调用,把编译开销挪到前面;或者干脆用批量接口cdist_dtw/pairwise_distances一次性算完,把编译成本摊薄。批量接口还支持并行:
from tslearn.metrics import cdist_dtw import numpy as np D = cdist_dtw(dataset, dataset, n_jobs=4)n_jobs在较新版本的批量接口上是有的,如果你装的版本报"unexpected keyword",退回用joblib手动把数据集切块并行,再拼成大矩阵,多写十行代码的事。
3. 预处理是逐样本做的,和大多数人的默认理解不一致
这一节是我认为最容易被误用、后果又最难排查的部分。tslearn 的预处理器继承 sklearn 的TransformerMixin,接口一模一样,但语义不同:它们对每条序列独立处理,而不是对整个数据集统一处理。
3.1 TimeSeriesScalerMeanVariance 的 mu 和 std 是目标值
看名字容易以为是"用数据算出均值和方差再标准化",实际上它的参数是你希望每条序列被变换到的目标均值和目标方差:
from tslearn.preprocessing import TimeSeriesScalerMeanVariance scaler = TimeSeriesScalerMeanVariance(mu=0.0, std=1.0) X_scaled = scaler.fit_transform(X)这段代码的语义是:把每一条序列各自变换到均值 0、标准差 1。mu和std不是数据的统计量,是目标值。别写成TimeSeriesScalerMeanVariance(mu=X.mean(), std=X.std()),那是把语义彻底搞反了。
逐样本标准化的适用场景很明确:当幅值本身不携带类别信息时,比如判断两段动作的手势类别,手臂抬多高不重要,重要的是运动模式。反例同样明确:如果是异常检测,异常常常就表现为幅值突变,这时候逐样本标准化会把这个信号洗掉,你的模型会变得毫无敏感性。判断标准就一句话:同一个类别内,幅值是否会自然大幅波动?会,就逐样本标准化;不会,就别动幅值。
3.2 TimeSeriesResampler 能统一长度,但会抹平高频细节
from tslearn.preprocessing import TimeSeriesResampler resampler = TimeSeriesResampler(sz=64) X_resampled = resampler.fit_transform(X) # 所有序列统一到 64 步它做的是沿时间轴重采样,不是截断也不是补零。作用是把长度参差不齐的数据强行拉齐,好处是后面可以放心用欧氏距离、可以批量算距离矩阵,坏处是有信息损失。
损失在哪?重采样本质上是插值或抽取,原始采样率高的序列被压到 64 点时,高频成分被平滑掉了。如果你的判别信息恰好藏在高频抖动里,比如设备故障的冲击特征,这一步等于把关键特征扔了。我的做法是:重采样之前先看一眼频谱,确认主要能量集中在低频段再做;或者把目标长度设得保守一些,宁可长一点多花点计算时间,也别压得太狠。
还有一个坑:sz如果设得比某些序列的原始长度还大,会做上采样,序列被插值拉长,看起来更"平滑",但那是虚假的平滑,不是新信息。这种情况在变长数据集里很常见,短的序列被插值放大后,在 DTW 里反而显得"细节丰富",混进聚类中心里污染结果。稳妥策略是sz取所有序列长度的中位数附近,而不是最大值。
3.3 装箱顺序:先装箱还是先缩放
这个顺序会实打实影响结果。to_time_series_dataset引入 NaN 补齐,而TimeSeriesScalerMeanVariance在计算每条序列的统计量时会不会把 NaN 算进去?不同版本处理细节有差异,NaN 参与mean计算会得到 NaN,整条序列就被污染了。所以安全的顺序是:
- 长度本来就不齐的,先重采样成等长,再装箱;
- 或者保持变长,先装箱(得到 NaN 补齐的数组),缩放时只对 DTW 这类自带掩码的流程使用,并且验证输出里没有整片 NaN。
我的习惯是干脆不用 NaN 路径,统一走"重采样 → 等长 → 缩放"三步。牺牲一点信息,换来确定性,在工程上通常是划算的。
4. 聚类:三种 metric 对应三种完全不同的模型
TimeSeriesKMeans是这个库里我最常用的类,因为它是把"时间序列"和"经典算法"结合得最干净的一个。它的metric参数有三个主流取值:"euclidean"、"dtw"、"softdtw"。很多人以为这只是"距离算法不同",实际上它们隐含的簇形状假设都不一样。
4.1 三种度量各自的成型前提
| metric | 中心点计算方式 | 隐含假设 | 计算量级 | 适合什么 |
|---|---|---|---|---|
euclidean | 逐位置算术平均 | 所有样本严格时间对齐 | 最低,和大小的线性关系 | 采样同步、相位一致的场景 |
dtw | DBA 迭代求重心 | 允许时间轴伸缩 | 高,与序列长度平方相关 | 存在相位漂移、速度差异 |
softdtw | soft-DTW 平滑重心 | 允许伸缩,且可微 | 最高,还要额外平滑参数 | 序列短、要求光滑中心 |
euclidean版的时间序列 KMeans 其实和一个普通 KMeans 没本质区别,只是把展平操作封装好了。它的结果高度依赖"对齐"这件事,如果你没做对齐就直接用,簇会按相位切分,而不是按形状切分。我见过一个很典型的失败案例:一批周期性振动数据,同一个故障类型只是发生时刻不同,用euclidean聚出来 4 个簇,每个簇对应一个相位段,和故障类型完全无关。换成dtw之后两个簇,和标注对上了。
softdtw的关键参数是平滑系数,值越小越接近硬 DTW,值越大中心越平滑但可能糊成一团。这个参数没有普适值,得靠试。
4.2 DBA 重心迭代:dba_iters 别设太大
metric="dtw"时,簇中心不是算平均,而是用 DBA(DTW Barycenter Averaging)迭代逼近一个"在 DTW 意义下到所有成员距离和最小"的序列。这个内部迭代次数由metric_params里的dba_iters控制:
from tslearn.clustering import TimeSeriesKMeans km = TimeSeriesKMeans( n_clusters=3, metric="dtw", metric_params={"dba_iters": 1, "sakoe_chiba_radius": 3}, max_iter=10, n_init=1, random_state=0, n_jobs=4, ) labels = km.fit_predict(X) centers = km.cluster_centers_ # 形状 (3, sz, d)注意这里有两层迭代:max_iter是外层的"分配-更新"轮数,dba_iters是内层每次求重心的迭代数。总耗时大致正比于max_iter * dba_iters。很多教程给dba_iters设 5 或 10,在小数据上无所谓,几千条序列上就是十倍差距。
我的经验是dba_iters=1起步,外层max_iter给到 8~15。原因很实际:外层本来就要反复更新中心,内层不需要每次都求到精确极小值,粗略一轮配合多轮外层,收敛效果差不多而总耗时低得多。真需要更精细的中心,最后单独调一次dtw_barycenter拿精确值就行。
顺带说一个版本坑:metric_params里控制内层迭代的字段名在不同版本间改过,老代码里可能叫别的名字。跑起来如果看到版本弃用提示,别忽略,按提示改成dba_iters,否则你的参数其实是静默失效的,还以为自己设了。这个坑我踩过一次,调了半天参数发现耗时一点没变,才反应过来根本没生效。
4.3 KShape 的姿态归一化假设
KShape是另一个聚类器,它和TimeSeriesKMeans的区别在于:它在每一步都对序列做 z 归一化(同时沿时间维做减均值除标准差),然后基于形状相似度分配。带来的直接后果是,它对幅值完全不敏感,只认形状。
from tslearn.clustering import KShape ks = KShape(n_clusters=3, n_init=3, max_iter=20, random_state=0) labels = ks.fit_predict(X)这个特性是双刃剑。识别"动作类型"这类任务,幅值不重要,形状重要,KShape 表现很好;但识别"严重程度"这类任务,幅值恰是关键,KShape 会把轻症和重症聚到一起,你还在纳闷为什么分不开。用之前先问自己一句:同一类别内的样本,幅值差异大不大?大,就用 KShape;不大,用TimeSeriesKMeans的euclidean或dtw。
n_init的默认值偏大,意味着会跑多次随机初始化取最优,小数据上没问题,大数据上时间直接翻几倍。我的做法是先用n_init=1快速看结果量级,确认方向没问题再提到 3~5 做最终结果。别一上来就给 10。
4.4 长序列上算不完时,先降规模再换算法
DTW-KMeans 的耗时大致是O(n_samples * n_clusters * max_iter * dba_iters * sz^2 * d)。代入一组真实数字:5000 条序列、5 个簇、10 轮外层、1 轮内层、长度 200、单通道,量级大约在5e3 * 5 * 10 * 1 * 4e4 = 1e10次基本操作。这个量级在单机上没有并行的话,基本是"跑一晚上"的节奏。
三条可行的压缩路径,按推荐顺序:
- 降采样。长度 200 压到 50,复杂度降 16 倍,多数周期性数据的形状信息丢不了太多。这是性价比最高的一步。
- 分段聚合。把长序列切成等长小段,每段用均值或标准差代表,也就是常说的 PAA 思路,tslearn 里有
PiecewiseAggregateApproximation可以直接用。适合超长序列。 - 先粗后精。先用
euclidean快速聚一版,看簇的数量和大致形态,再用dtw在某个簇内部细化。别指望一次到位。
我要强调一点:如果数据量真的到几万条、长度上千,DTW-KMeans 就是不适合的工具,换用基于特征工程(统计特征 + 常规树模型)的路线反而更快更可靠。库不是万能的,认清边界比调参重要。
5. 分类:先立 KNN-DTW 基线,再考虑 shapelet
分类这块 tslearn 提供了好几条路,选择依据主要是你的数据量和你能接受的依赖成本。
5.1 KNN-DTW:零训练,但要注意导入路径
KNeighborsTimeSeriesClassifier是最省心的一个:没有训练过程,预测时现算 DTW 距离。它在这类任务上常常是个惊人的强基线,很多论文的复杂模型也不过比它高几个点。
from tslearn.classification import KNeighborsTimeSeriesClassifier knn = KNeighborsTimeSeriesClassifier(n_neighbors=1, metric="dtw") knn.fit(X_train, y_train) pred = knn.predict(X_test)这里有个版本坑值得单独说:这个类的导入路径在版本演进中调整过,老代码写from tslearn.neighbors import ...,新版本会给你一条弃用警告,提示改到tslearn.classification。警告不影响运行,但如果你的环境把警告配置成异常,或者你正好在做代码审查,就会卡住。我的建议是新代码统一从tslearn.classification导入,老项目遇到警告顺手改掉,别留着。
另外n_neighbors=1是这类任务上的常见起点,且很多时候就是最优。原因是 DTW 距离分布往往很"尖锐",多个邻居投票反而引入噪声。别默认设 5。
5.2 ShapeletTransform 不需要深度学习框架,LearningShapelets 需要
shapelet 的思路是从训练数据里挖出"最具判别力的短子序列",然后用这些子序列作为特征。tslearn 里对应两个实现,差别在依赖:
| 类 | 依赖 | 训练方式 | 适用场景 |
|---|---|---|---|
ShapeletTransform | 仅 numpy/scipy/sklearn | 随机候选 + 评估,一次性转换 | 大多数场景,推荐先用 |
LearningShapelets | 需要深度学习框架 | 梯度下降学习 shapelet 形状 | 数据量大、追求精度 |
ShapeletTransform的用法:
import numpy as np from tslearn.shapelets import ShapeletTransform, grabocka_params_to_shapelet_size_dict n_ts, ts_sz, dim = X_train.shape n_classes = len(np.unique(y_train)) # l 是 shapelet 长度占序列长度的比例,r 是每个尺寸下挖几个 size_dict = grabocka_params_to_shapelet_size_dict( n_ts=n_ts, ts_sz=ts_sz, n_classes=n_classes, l=0.2, r=1 ) print(size_dict) # 例如 {40: 2},表示只挖长度 40 的、共 2 个 st = ShapeletTransform( n_shapelets_per_size=size_dict, min_shapelet_length=10, max_iter=10, random_state=0, ) X_train_feat = st.fit_transform(X_train, y_train) X_test_feat = st.transform(X_test)转换出来的特征是"每条序列到每个 shapelet 的最小距离",形状是(n_samples, n_shapelets)。拿到之后随便接一个常规分类器都行,我一般接逻辑回归或者梯度提升,快且好解释。shapelet 最大的好处是可解释性:你可以把挖出来的那段子序列画出来,告诉业务方"模型就是盯着这段波形判的",这在很多落地场景里比多两个点准确率更值钱。
LearningShapelets的问题纯粹是工程成本:它要拉深度学习框架,环境体积大、版本兼容麻烦,在很多受限的部署环境里根本装不上。我现在的默认策略是先把ShapeletTransform跑通,看精度够不够,够就结束,别为了几个点去折腾环境。
5.3 TimeSeriesSVC 与 GAK 核:适合小样本
第三条路是支持向量机配全局对齐核(GAK):
from tslearn.svm import TimeSeriesSVC svc = TimeSeriesSVC(C=1.0, kernel="gak", gamma="auto") svc.fit(X_train, y_train)GAK 核本质是把 DTW 的最优路径代价映射成一个相似度,所以它能处理时间轴伸缩,又保留了 SVM 在小样本上的优势。gamma="auto"只是个起点,GAK 的gamma对结果影响很大,通常要在[0.01, 0.1, 1]里做一轮网格搜索。
但要注意它的计算代价:训练时要算核矩阵,复杂度大致是样本数的平方乘序列长度的平方。样本数超过一两千就会明显变慢,超过五千基本不建议。所以这条路我留给"样本少、维度低、要求精度"的场景,样本一多就回到 KNN-DTW 或者 shapelet + 常规分类器。
6. 并行、内存和"算不完"的几条工程边界
技术细节讲完了,讲讲工程侧。tslearn 这类库真正的门槛不在 API,在于你会在某个时刻发现它跑不动,然后需要判断是等一等还是换方案。
6.1 n_jobs 用在哪、用多少
n_jobs主要作用于距离计算环节。TimeSeriesKMeans的n_jobs参数控制的是样本到中心的距离计算并行度;批量距离接口cdist_dtw也一样。经验值:设成物理核数,别设成-1。
为什么不用-1?因为底层是进程级并行,每个进程要复制一份数据,数据大时内存会先炸。设成核数的一半更保险,尤其是序列长、样本多的时候。我一般先用n_jobs=2跑小规模测个耗时,再线性推算,决定要不要往上加。
6.2 距离矩阵是 O(N²),别随手就全算
cdist_dtw(X, X)会得到一个(N, N)的浮点矩阵。N=10000 时,矩阵本身 800MB,而计算量是1e8 * sz^2。这个规模在没有充分理由的情况下不该算。
如果只是为了聚类,不需要完整矩阵,直接用TimeSeriesKMeans的按需计算;如果是为了找最近邻,可以用下界剪枝。tslearn 里有lb_keogh和lb_kim两个下界函数,思路是先算下界,下界已经超过当前最优的候选直接跳过,不算精确 DTW。这在最近邻搜索里能省掉大部分计算:
from tslearn.metrics import lb_keogh, dtw # 先用下界筛,再对少量候选算精确 DTW bound = lb_keogh(query, candidate) if bound < best_so_far: exact = dtw(query, candidate)下界永远小于等于真实距离,所以剪枝是安全的,不会漏掉真正的最近邻。这个技巧写起来十几行,在大规模检索里能把耗时降一个数量级。
6.3 长度、样本数、维度:三个必须做取舍的旋钮
给你一个粗略的可行性判断表,是我自己踩坑攒出来的:
| 序列长度 | 样本数 | 可行方案 |
|---|---|---|
| ≤ 100 | ≤ 2000 | DTW-KMeans、SVC、全量距离矩阵都行 |
| 100~500 | ≤ 5000 | DTW-KMeans 需降采样,n_jobs 必开 |
| > 500 | 任意 | 先 PAA 分段降维,或改用特征工程路线 |
| 任意 | > 20000 | DTW 系列基本不适用,换思路 |
这张表不是硬边界,但越出越要谨慎。真正决定成败的是"信息密度":如果你的信号在降采样到 50 点后还能分开,那就该降;如果降完就分不开,那要么上更强的算力,要么换更适合长序列的表示方法。
7. 几个让我返工过的细节
最后集中说几个具体到让人烦、但不知道就会浪费半天的问题。
随机种子的传播。TimeSeriesKMeans和KShape的初始化都带随机性,random_state要显式给。我遇到过一次结果复现不了的情况,排查半天发现是并行的n_jobs影响了初始化时数据的切分顺序,加上random_state之后才对齐。所以做对比实验时,固定种子 + 固定n_jobs,两个都锁死。
空簇的处理。聚类时如果某个簇在迭代中分不到任何样本,不同版本的兜底策略不一样,有的重选中心,有的保留上一个中心。表现出来就是"簇数明明设了 5,结果只有 4 类"。这不是 bug,是数据里根本没有 5 个自然分组。我的做法是拿到labels后先看np.unique(labels)的数量,少了就调整n_clusters,别硬套。
距离的量级会随长度变化。前面提过一次,这里再强调:如果你在做阈值判断(比如"距离小于 X 就认为是同一类"),这个 X 必须固定在某个序列长度下。换个长度,阈值就作废了。我一般是把阈值定义成"该长度下所有样本对距离的中位数乘个系数",跟着数据走,而不是写死一个常数。
中心点的可视化。km.cluster_centers_是可以直接画出来的三维数组,画出来和簇内成员叠在一起,一眼就能看出这个簇是不是被少数异常样本带偏了。这一步花不了一分钟,却能提前发现绝大多数"结果奇怪"的问题。我现在的习惯是每次聚类必画,不画不看结论。
版本升级后先跑冒烟测试。这类库的 API 位置、参数名在小版本间会调整,升级之后别急着跑全量,先用十几条序列把所有用到的类过一遍,看有没有警告、有没有静默失效。我在一次升级后吃过亏:参数名变了没报错,只是不生效,结果全量任务跑完才发现结果和上一版差了很远,白跑一晚上。
回过头看,tslearn 这类库的价值不在于它实现了多复杂的算法,而在于它把"时间是有序的"这件事,严肃地贯穿到了数据形状、距离度量、模型接口的每一层。你用它的时候会不断被提醒:这是一条曲线,不是一个点。习惯了这种视角之后,再回去看那些把时间序列展平成特征列的做法,会明确地知道哪里丢了信息、丢了多少。我个人的建议是,先拿手上最小的一个数据集,把 DTW 距离和欧氏距离各算一遍,把两组距离分布画在同一张图上看清楚,这比读十页文档更能建立起对这个领域的直觉。