简介:这是一份基于Python实现的轨迹聚类项目源码,面向数据挖掘、时空轨迹分析学习者和开发者,可用于从GPS、移动设备定位等轨迹数据中提取运动模式并定位聚类中心。压缩包共7个文件,约998KB,内含4个Python脚本、1个Markdown说明文档、1张PNG效果图和1张GIF动图;Python脚本覆盖数据预处理、轨迹距离计算、聚类算法及可视化等关键环节,Markdown文档用于说明项目结构,两张图片可直接查看聚类前后的可视化对比。已有1216人学习下载。通过该源码可掌握完整的轨迹聚类流程,包括相似度度量、聚类参数调整与结果评估,并能在实际轨迹数据上快速复现聚类中心;项目结构清晰,适合作为入门学习、算法对比或课题开发的参考实现,尤其有助于理解轨迹数据挖掘中从原始点到簇中心的处理链路。
1. 轨迹聚类不是画线,是给行为建索引:TrajectoryClustering-master 到底在解决什么问题
拿到 TrajectoryClustering-master 这个项目标题时,很多人的第一反应是「又是个把 GPS 点连成线的可视化 Demo」。真跑一遍会发现完全不是这么回事——这个 Python 轨迹聚类工程解决的是一个很具体的诉求:当你有几十万条带时间戳的坐标序列,怎么把「行为模式相似」的轨迹自动归到同一类。比如共享单车平台要区分「通勤潮汐」和「闲逛散骑」,外卖调度要识别「顺路单」和「绕路单」,靠人眼盯屏幕是盯不过来的,得让算法自己把形态相近的轨迹挑出来。
这里的难点不在于聚类算法本身,而在于轨迹数据天生的三个坑:长度不等、采样率不一致、空间偏移但形态相似。传统 KMeans 直接吃坐标点是行不通的,因为轨迹是变长序列,没法塞进固定维度的特征矩阵。TrajectoryClustering 这类项目通常的做法是先用距离度量(如 Hausdorff、DTW、LCSS)算出轨迹两两之间的相似度,再交给聚类算法去分族。整条链路跑通后,你拿到的不只是一张五颜六色的地图散点图,而是一份可以直接喂给业务系统的「轨迹族群标签」。这篇笔记我会从距离度量选型、代码复现、参数调优和坑位排查四个层面,把这个项目拆到能直接照着落地。
2. 先搞懂轨迹聚类的地基:距离度量选型决定结果上限
2.1 为什么不能直接对坐标做 KMeans:变长序列的数学困境
轨迹数据的原始形态是一串(lng, lat, timestamp)三元组,假设一条轨迹有 120 个点,另一条只有 45 个点,直接拼成矩阵就是 120 维对 45 维,连加减都没法做。就算你用插值把两条轨迹补到同样长度,KMeans 的欧氏距离也会被采样密度欺骗——同一条路线,A 设备每 2 秒上报一个点,B 设备每 10 秒上报一个点,插值补出来的坐标序列形态差异极大,但实际走的却是同一段路。
所以轨迹聚类的第一步永远是「绕开坐标点本身,去定义两条轨迹之间的距离」。这个距离不是点对点的直线距离,而是「形态距离」——两条轨迹在空间上有多不像。常见做法是把每条轨迹看成一个几何对象,然后去度量几何对象之间的相似性。这个环节选错了度量方式,后面不管用什么聚类算法都是白搭。
2.2 三种主流轨迹距离度量:Hausdorff、DTW、LCSS 的适用边界
从业界落地频率来看,TrajectoryClustering-master 这类项目里最常出现的是下面三种距离度量,我按推荐程度排序讲。
Hausdorff 距离计算的是「轨迹 A 上每个点到轨迹 B 的最近距离,取最大值」——数学定义不用背,你只需要记住它的性格:对离群点极其敏感。一条轨迹上有 5 个点因为 GPS 漂移偏出去 200 米,Hausdorff 值就会被这 5 个点主导,把两条本来同路的轨迹判成不相似。它适合点云轮廓比对,但用在城市道路轨迹上容易翻车。
DTW(动态时间规整)允许两条轨迹的点做「错位匹配」:A 的第 10 个点可以去对齐 B 的第 8 个点,只要累计距离最小。这天然适配采样率不一致的场景,也是我在这类项目里默认首选的度量。代价是计算复杂度高,两两比较 n 条轨迹要算 n 的平方次 DTW,每条 DTW 本身又是 O(n²) 的矩阵填充,总量一上去就得想办法降采样。
LCSS(最长公共子序列)走的是另一条路:它找两条轨迹间能「对上」的最长点序列,允许中间跳过若干点。它的优势是抗噪能力最强,GPS 偶尔跳一个点不影响匹配;劣势是阈值参数难调——两个点多近算「对上」?这个距离阈值直接决定聚类粒度,设大了把不同路线揉成一团,设小了把同一条路线劈成两半。
2.3 代码里怎么落地距离矩阵:一个最小可跑的相似度计算脚本
拿到 TrajectoryClustering-master 的源码,核心入口一般不是聚类函数本身,而是距离矩阵的构建模块。下面是我在实际项目里常用的最小实现,基于 DTW,顺便帮你把「该在哪里动手改参数」标出来。
import numpy as np from fastdtw import fastdtw from scipy.spatial.distance import euclidean def compute_dtw_matrix(trajectories, radius=5): """ 构建轨迹两两之间的 DTW 距离矩阵 :param trajectories: list of numpy arrays,每条轨迹 shape (n_points, 2),列顺序为 [lng, lat] :param radius: fastdtw 的搜索半径,越小越快但精度越低 :return: 对称距离矩阵,shape (n_trajectories, n_trajectories) """ n = len(trajectories) dist_matrix = np.zeros((n, n)) for i in range(n): for j in range(i + 1, n): dist, _ = fastdtw(trajectories[i], trajectories[j], radius=radius, dist=euclidean) dist_matrix[i, j] = dist dist_matrix[j, i] = dist return dist_matrix这段代码里有两个关键参数要说明。radius控制 DTW 的窗口大小,我一般先设 5 跑通流程,数据量大再调小;它不是越大越好,radius 过大等价于退化成全矩阵 DTW,几十条轨迹还算得动,几百条就开始卡。dist=euclidean表示单点距离用平面欧氏距离,但你要注意:如果轨迹坐标跨的经度范围很大,直接算经纬度的欧氏距离会失真,正确做法是先做投影转换,把经纬度转成 Web Mercator 或 UTM 坐标再算距离,否则高纬度地区的横向距离会被严重低估。
距离矩阵构建完,建议立刻检查两个东西。第一,矩阵是否对称、对角是否为 0;第二,随机抽 3 对轨迹,手动画出两条轨迹的对应点连线,肉眼确认 DTW 的对齐方向没有出现「跨城市错位匹配」。这一步不做好,聚类结果的合理性就没有保证。
2.4 数据预处理的第一道坎:归一化和降采样不能省
距离度量选好之后,数据预处理会直接影响度量结果。我在真实项目里吃过一次亏:轨迹 A 是 200 个点,轨迹 B 是 800 个点,DTW 算出来的距离值明显偏大,不是因为两条路线不像,而是因为 B 的采样点太多,累计匹配步数天然比 A 多。这个问题的解法是先对每条轨迹做降采样,统一到同一个数量级——我一般把每条轨迹压到 50 到 100 个点之间,用等间距抽点而不是随机抽,等间距能保留形态特征。
归一化同样不能省。如果轨迹数据的经纬度范围是 [116.2, 116.5] 和 [39.8, 40.1],直接算欧氏距离,数值上看起来差异很小,但不同城市的轨迹混在一起时,这个绝对坐标范围没有可比性。常见做法是做逐条轨迹的 Min-Max 归一化,把每条轨迹的坐标范围缩放到 [0, 1] 区间,这样聚类时比较的是「形态」而不是「绝对位置」。当然,这个选择要看业务场景——如果就是要按空间区域聚类(比如把北京市内的轨迹和上海市内的轨迹分开),就不能归一化;如果是聚类通勤行为模式,归一化是必须的。
3. 把 TrajectoryClustering-master 跑起来:完整流程与核心模块改造
3.1 从原始数据到聚类输入:轨迹切割和清洗的落地脚本
如果你手头的数据是原始 GPS 点表,每条记录长这样(user_id, lng, lat, timestamp),第一步是先按 user_id 和时间戳排序,把每个用户连续的点串成轨迹。这里有个容易忽略的细节:GPS 轨迹不是连续不断的,中间可能隔了 10 分钟没上报。10 分钟内用户可能从 A 点走到 B 点,硬拼成一条轨迹会引入巨大的形态噪声。我处理这类数据的做法是设一个切割阈值:相邻两个点的时间间隔超过gap_seconds,就切分成两条独立轨迹。
import pandas as pd import numpy as np def split_trajectories(df, user_col='user_id', time_col='timestamp', lng_col='lng', lat_col='lat', gap_seconds=300): """ 将原始 GPS 点表按时间间隔切割为轨迹列表 """ trajectories = [] df = df.sort_values([user_col, time_col]).reset_index(drop=True) for user, group in df.groupby(user_col): group = group.reset_index(drop=True) split_idx = [0] for i in range(1, len(group)): time_diff = (group[time_col][i] - group[time_col][i - 1]).total_seconds() if time_diff > gap_seconds: split_idx.append(i) split_idx.append(len(group)) for k in range(len(split_idx) - 1): seg = group.iloc[split_idx[k]:split_idx[k + 1]] if len(seg) >= 10: # 过滤掉点数太少的碎片轨迹 traj = seg[[lng_col, lat_col]].values.astype(float) trajectories.append(traj) return trajectoriesgap_seconds这个参数我建议结合业务来定:城市内步行场景 300 秒合理,高速驾驶场景 60 秒都嫌长,因为车速快,一分钟就能跑出去一两公里。len(seg) >= 10这个下限也要想清楚,少于 10 个点的轨迹本身信息量不够,聚类出来也解释不了业务含义。清理完之后的轨迹列表,再喂给上一节的compute_dtw_matrix,就完成了从原始表到距离矩阵的完整链路。
3.2 选聚类算法:为什么常见项目默认用 DBSCAN 而不是 KMeans
距离矩阵拿到手之后,聚类算法怎么选?TrajectoryClustering-master 这类项目里最常见的答案是用 KMeans,但实际落地时我更推荐你先试 DBSCAN,原因在于「轨迹数量 k 是未知的」。KMeans 要求你预设簇的个数,而轨迹数据的自然族群数量通常无法预估——共享单车的潮汐出行可能有 6 类模式,换了另一个城市可能就是 12 类。DBSCAN 只要给两个参数eps(邻域半径)和min_samples(核心点最少邻居数),就能自动发现任意形状的簇,还能把那些跟谁都不像的轨迹标成噪声点。
用距离矩阵喂 DBSCAN 的标准姿势是预计算距离矩阵后传给metric="precomputed"。实际工程中,我不会直接用默认参数,而是先跑一遍k-distance图来确定eps:把距离矩阵每行的最小值升序排列,找到曲线拐点处的距离值作为eps初值,再微调。这个过程放代码里就是一句np.sort(np.min(dist_matrix, axis=1)),画出折线图,人工挑拐点。第一次跑通时没人能一次写对参数,这个拐点法是血泪经验里最省事的办法。
3.3 聚类质量验证:轮廓系数和可视化都对得上才算完
聚类跑完不是终点,验证环节反而比调参更花时间。我最常用的两个手段是轮廓系数(Silhouette Score)和轨迹叠加可视化。轮廓系数的计算方式不需要背公式,你只需要知道它取值在 [-1, 1],越接近 1 说明簇内紧密、簇间分离,低于 0.3 基本说明聚类结果没有实际区分度。
from sklearn.cluster import DBSCAN from sklearn.metrics import silhouette_score def cluster_trajectories(dist_matrix, eps=0.5, min_samples=3): """ 基于预计算距离矩阵的 DBSCAN 聚类 """ model = DBSCAN(eps=eps, min_samples=min_samples, metric="precomputed") labels = model.fit_predict(dist_matrix) # 排除噪声点(label = -1)后计算轮廓系数 mask = labels != -1 if mask.sum() > 1 and len(set(labels[mask])) > 1: score = silhouette_score(dist_matrix[mask][:, mask], labels[mask], metric="precomputed") print(f"当前 eps={eps}, min_samples={min_samples}, 轮廓系数={score:.3f}") else: score = -1 print("有效簇太少,无法计算轮廓系数,请调大 eps 或减小 min_samples") return labels, score这代码里dist_matrix[mask][:, mask]的索引要特别注意——DBSCAN 会把离群轨迹标成 -1,这一部分点只有距离、没有归属簇,直接送进轮廓系数计算会把分数拉低。所以先过滤噪声点再算评估指标。如果轮廓系数很低,不要急着调聚类参数,回头去检查距离矩阵——大概率是 DTW 的radius设得太大,把细节形态抹平了。
模型跑完后,建议把同一簇的轨迹按颜色画在同一张地图上。一条肉眼可读的验证规则是:同一个簇里的轨迹应该在空间上有明显重叠,簇与簇之间如果有交叉但不重叠,说明聚类粒度合理;如果同一个簇里的轨迹在空间上完全不沾边,那是距离度量和归一化出了问题,跟聚类算法无关。
4. 从聚类结果反推业务语义:标签工程才是这步的真正价值
4.1 聚类中心的提取:怎么用一条「虚拟轨迹」代表一个族群
聚类完成只是得到了每个簇的成员列表,要想把结果交付给业务方,还得再往前一步——为每个簇提取一条「代表轨迹」。最常见做法是选离簇中心最近的原始轨迹:对每个簇,算簇内所有轨迹到其他轨迹的平均距离,取平均距离最小那条轨迹作为代表。这条代表轨迹可以直接画图展示给业务方看,告诉他们「这类轨迹长这样」。
另外还有一个做法是把簇内轨迹做点集融合:对每个时间戳对应的位置取中位数,拼出一条平滑的平均轨迹。但这种方法在采样点错位严重时容易产生奇怪的折线,我不太推荐。直接用原始轨迹当代表,解释成本最低,也便于后续做人工抽查。
4.2 给轨迹簇打语义标签:从空间分布和时间特征推断行为
聚类出来第 0 类、第 1 类、第 2 类,业务方根本看不懂,你得给它们起名。「早高峰通勤」「午间周边闲逛」「夜间长距离出行」这类标签怎么来?靠两个特征:空间范围和时段分布。
def infer_cluster_semantics(cluster_trajectories, timestamps_list): """ 根据轨迹的空间跨度和时间分布打上语义标签 :param cluster_trajectories: list of (n_points, 2) 轨迹数组 :param timestamps_list: 与轨迹对应的 list of pandas DatetimeIndex :return: 描述性标签 """ all_points = np.vstack(cluster_trajectories) lng_span = all_points[:, 0].max() - all_points[:, 0].min() lat_span = all_points[:, 1].max() - all_points[:, 1].min() hour_dist = np.concatenate([ts.hour.values for ts in timestamps_list]) peak_hour = np.bincount(hour_dist).argmax() if lng_span + lat_span < 0.05: return f"局部驻留型,活跃时段约{peak_hour}点" elif 7 <= peak_hour <= 9 or 17 <= peak_hour <= 19: return f"潮汐通勤型,跨度{lng_span:.3f}度" else: return f"长距机动型,峰值{peak_hour}点"这里的阈值(0.05 度大概是 5 公里左右)不是固定标准,跟城市尺度有关。跑一版聚类后,把每个簇的空间跨度打印出来看一眼分布,再回头调阈值,比对着文档猜参数靠谱得多。语义标签是轨迹聚类项目真正交付价值的一环——算法输出的数字标签没有业务解释能力,等于白做。
4.3 业务场景映射:聚类输出如何对接运营和调度系统
聚类结果落到业务上,常见做法是沉淀成一张「轨迹模式字典」。比如外卖平台识别出「长距机动型」轨迹簇之后,可以结合订单时间戳估算每一单的平均配送时长,把聚类标签作为配送时长预估模型的一个特征输入。共享单车平台则可以把「潮汐通勤型」的起终点提取出来,辅助潮汐点位调度。这条链路说明了一个关键认知:轨迹聚类的产出物不是「分好类的图」,而是「下游模型的输入特征」。如果你的项目做完聚类就停住了,那它的价值会大打折扣。
5. 避坑手册:TrajectoryClustering 跑出离谱结果的 5 个常见问题
5.1 现象:所有轨迹被聚成一团,轮廓系数接近 0
原因通常是距离矩阵的数值范围过小,DBSCAN 的eps设得比最大距离还大,导致所有点都落在邻域半径内。解决:先打印距离矩阵的最大值和中位数,把eps初始值设为距离分布 10 分位数附近,或者直接画出 k-distance 曲线选拐点。不要靠猜。
5.2 现象:噪声点比例超过 30%,大部分轨迹被标成 -1
原因大概率是min_samples设得太大,轨迹数量本身不多时,这个参数要降到 2 或 3。我在 200 条轨迹的数据集上就翻过车,min_samples=5直接把 40% 的轨迹标成噪声。解决:min_samples从 2 开始往上加,每次加 1,看噪声比例和簇内样本量的变化。顺带说一句,如果eps设得极小,同样会导致噪声点暴增,这两个参数要联动调。
5.3 现象:同一个簇里的轨迹在空间上根本不重叠
这是归一化和距离度量同时出问题的典型症状。如果你做了逐条轨迹的 Min-Max 归一化,两条轨迹即使一个在北京一个在上海,归一化后形态相似也会被分到同一簇。解决:先确认业务是否需要「位置无关的形态聚类」;如果不是,去掉归一化,改用原始经纬度或投影坐标参与距离计算。
5.4 现象:轨迹点数相差悬殊,DTW 距离被长轨迹主导
200 个点的轨迹和 40 个点的轨迹算 DTW,累计路径长度天然不均衡,短轨迹会被长轨迹「碾压」。解决:在预处理阶段统一降采样,目标点数建议 50 到 100,同时检查降采样后的轨迹是否仍然保留道路转弯特征。如果降采样太狠(比如压到 10 个点),回形针路径会被拉成一条直线,形态特征全丢。
5.5 现象:聚类结果每次跑都不一样,分布不稳定
DBSCAN 本身是确定性算法,结果不稳定通常是因为距离矩阵受输入顺序影响——没有先按 user_id 和 timestamp 排序就分组。另一个隐藏原因是 DTW 的radius太小时,fastdtw 的近似搜索受初始路径影响,多次运行会有细微波动。解决:固定随机种子,并在切分轨迹前严格排序。需要注意的是,不是所有项目的源码里都内置了固定随机种子,你可能需要自己补一行np.random.seed(42)。
6. 进阶:用轨迹聚类的输出反哺路线推荐和时间预估
聚类结果稳定之后,下一步要做的是把标签用起来。我最近在一个物流调度项目里做的是这样一件事:把过去一个月的历史轨迹聚类出 8 类模式,然后为每一类训练一个行程时间预估模型。原来对全量订单用同一个平均速度预估,误差 20% 以上;按聚类标签分组建模后,误差压到了 11%。这个收益的根源在于轨迹聚类把「绕路型」「直行型」「驻留型」这些行为结构提前切分开了,下游模型不需要自己从数据里折腾出这个结构。
落地时的一个具体技巧是:把聚类标签做成一个离线特征表,每一行是(轨迹ID, 标签, 代表轨迹ID, 空间跨度, 时间峰值),下游模型上线时直接 JOIN 这张表,而不是每次都重新跑聚类。因为聚类本身是个计算密集型任务(距离矩阵构建是 O(n²)),对线上延迟敏感的系统来说不可能实时跑,离线计算好标签、在线查表是唯一现实的做法。另外,聚类模型的更新频率要看业务波动速度——共享单车的模式会随季节变化,建议每月重跑一次;外卖配送的轨迹模式每周就要看一次。
我做这类项目养成的习惯是:每跑一版聚类,就把距离矩阵、聚类标签、代表轨迹、轮廓系数四个产物一起归档成一个以日期命名的目录。这样复现结果时不用从头再算一遍距离矩阵,对比不同版本的聚类效果也有据可查。轨迹聚类这个方向真正吃功夫的地方,不在算法本身,而在数据清洗和结果解释这两端——这两端做好,算法用最朴素的 DBSCAN 都能出彩;两端偷懒,换什么先进模型都是白搭。希望这篇笔记能帮你少踩几个我踩过的坑,把轨迹数据真正变成可用的业务资产。
本文还有配套的精品资源,点击获取