ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

轨迹异常检测新思路:双层层次聚类如何高效定位离群点

轨迹异常检测新思路:双层层次聚类如何高效定位离群点

简介:一份PDF格式的学术论文资源——面向物联网移动终端设备的用户轨迹异常检测研究,由南京邮电大学李健等撰写,面向物联网、数据挖掘及轨迹分析方向的研究者。研究针对现有异常轨迹检测算法效率低的问题,提出双层聚类的检测方法:先在特定时空条件下提取停留点并进行层次聚类,划分出停留区域;再对区域间轨迹段做二次层次聚类,从而同时发现异常停留区域与异常轨迹段。实验表明,该方法相比传统算法检测更全面、速度更快,可用于相关课题研究、毕业设计或论文写作中的方法对比与借鉴。资源为单个PDF文件,压缩包大小约689KB,目前已有292人学习,内容包含《计算机应用研究》网络首发版的摘要、引言、关键词及正式排版文本,可作为物联网安全、行为分析、时空数据挖掘方向的参考资料,适合直接下载阅读。

1. 用户轨迹异常检测:这篇论文是怎么把“找异常”变成“找离群点”的

做物联网移动终端取证和用户行为分析的人,基本都遇到过这类需求:从手机、手表、车载终端里导出一堆GPS点,要判断用户哪天走了条“不对劲”的路。大部分传统算法要么只比对空间距离,要么逐个分段算相似度,数据一上万就慢得让人想砸键盘。这篇《面向物联网移动终端设备的用户轨迹异常检测研究》给出一个很实用的思路:先把轨迹压缩成停留点和轨迹段,再做两层层次聚类,把“检测异常”从逐点比较变成“找离群簇”。我拆完这篇论文后最大的感受是,它把数据量大、分布不均匀这两个最头疼的问题,用数据压缩和聚类选型化解掉了。适合正在做轨迹挖掘、位置服务或取证分析的人当算法蓝本,也适合想复现实验的从业者直接拿去做参数参考。

2. 双层聚类框架:为什么先提取停留点,而不是直接对整条轨迹下手

这篇论文的核心框架是DCTOD(double-layer clustering trajectory outlier detection),整套流程可以用一句话概括:把轨迹先压成点,再对点和点之间的连线分别做异常检测。这个思路在思路上和常规的轨迹离群检测有本质区别,常规方法比如TROD是把轨迹分成若干段,然后用分段之间的空间距离判断异常;iBAT则是比较相同源目的地之间的轨迹差异。这些方法有一个共同问题——它们很少把“用户在一个地方待了多久”这个时间维度的信息充分利用起来。

2.1 停留点提取的数学条件与阈值逻辑

停留点这个概念是整套方法的地基。论文里的定义很明确:一段轨迹点如果满足“相邻点间的空间距离都小于Wd,且整个时间段跨度大于Wt”,就把这段轨迹点合并成一个停留点。停留点的坐标取这些轨迹点的经纬度平均值,起止时间取第一和最后一个点的时间戳。这里的本质是用一个矩形时空窗口去扫描轨迹序列,窗口内的点如果足够密、时间足够长,就认为是“停留”而不是“路过”。

我在实际处理手机导出的轨迹时,对这个定义的感受是:它比单纯用速度阈值过滤要稳得多。很多定位噪声会让瞬时速度忽高忽低,但停留点的判断是基于一段连续点列的空间分布和时间跨度的,对个别跳点不敏感。论文给的默认参数是Wt=30min,Wd=100m,在GeoLift数据集上跑出来的停留点序列和原始轨迹的空间分布基本一致。

import math def extract_stay_points(trajectory, Wd=100.0, Wt=1800): """ 从轨迹序列中提取停留点 trajectory: [(lat, lng, timestamp), ...] 按时间升序排列 Wd: 空间阈值(米), Wt: 时间阈值(秒) """ stay_points = [] i = 0 n = len(trajectory) while i < n: start_time = trajectory[i][2] sum_lat = trajectory[i][0] sum_lng = trajectory[i][1] k = 1 j = i # 遍历满足空间距离条件的连续点 while j + 1 < n: d = haversine(trajectory[j][0], trajectory[j][1], trajectory[j+1][0], trajectory[j+1][1]) if d > Wd: break sum_lat += trajectory[j+1][0] sum_lng += trajectory[j+1][1] k += 1 j += 1 # 判断时间跨度是否超过阈值 if k > 2 and (trajectory[j][2] - start_time) > Wt: stay_points.append({ 'lat': sum_lat / k, 'lng': sum_lng / k, 'start': start_time, 'end': trajectory[j][2], 'count': k }) i = j + 1 return stay_points def haversine(lat1, lng1, lat2, lng2): # 地球半径取6371000米,返回单位米 R = 6371000.0 phi1 = math.radians(lat1) phi2 = math.radians(lat2) dphi = math.radians(lat2 - lat1) dlmb = math.radians(lng2 - lng1) a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlmb/2)**2 return 2 * R * math.asin(math.sqrt(a))

这段代码的要点是,内层while循环负责找连续满足空间条件的点,外层while控制整个序列的遍历。k > 2这个条件是为了排除只有两个点的“伪停留”,因为两个点无法形成有效的空间密集分布。Wd和Wt这两个参数直接决定停留点的粒度——Wd设大了会把相邻的多个地点合并成一个停留点,Wt设小了则会把真正的停留打散成多个短停留。

2.2 层次聚类的选型理由:为什么不是DBSCAN

论文在停留点聚类这一步明确弃用了DBSCAN,理由是“类与类之间距离过长的情况下确定领域半径和领域内点的最小数目十分困难”。这个判断在我的复现经验里是成立的。DBSCAN对eps参数非常敏感,停留点的分布特性是“日常活动区域密集、偶尔远行区域稀疏”,密集区的点间距可能只有几十米,而稀疏区的点间距可能到几公里,这种情况下一个固定的eps很难同时照顾两类区域。

层次聚类的好处是不需要预设类别数,通过自底向上的合并,每次合并距离最近的两个集合,最终可以画出一整棵聚类树,然后用轮廓系数选最优层。论文里对停留点集合间距离的定义是所有点对距离的平均值,也就是average-linkage策略。轮廓系数R的取值范围是[-1,1],R越大聚类效果越好,每次合并后都算一次R,取R最大的聚类结果作为最终划分。

2.3 轨迹段异常检测的三个距离分量

第二层聚类针对的是停留区域之间的轨迹段。论文把轨迹段之间的距离拆成三个加权分量:速度距离、角度距离、位置距离,权重在实验里设成1:1:1。速度距离是两个轨迹段平均速度之差的绝对值;角度距离基于偏转角——就是轨迹上连续三个点构成的两条线段之间的夹角变化量;位置距离用的是改进的Hausdorff距离。这三个分量合在一起,是为了能区分“同样是绕路,但一个是开车绕、一个是走路绕”这类不同性质的异常。

3. 从论文到代码:DCTOD的完整实现步骤与参数设定

把论文的方法落地成可运行的代码,核心是三段逻辑:数据清洗、停留点生成、双层聚类。我在复现时选用了Python,主要原因是GeoLife数据集的解析和可视化生态更成熟,但如果你在生产环境用Java,逻辑完全一致。

3.1 数据清洗:基于最大速度的迭代过滤

论文里的数据清洗逻辑是:按时间排序后,计算相邻轨迹点的平均速度,速度大于Vmax=1000(公里/小时)就删除后一个点。注意这里用的是迭代过滤——所有点访问完后再从头比较一遍,直到整条序列没有超速点。为什么要迭代?因为删除一个点后,它前一个点和后一个点变成了新的相邻对,如果不重新扫描,可能漏掉原本被中间点“掩盖”的超速段。

def clean_trajectory(traj, vmax_kmh=1000.0): """ 迭代清洗轨迹中的超速点 traj: [(lat, lng, timestamp), ...] vmax_kmh: 最大允许速度(公里/小时) """ cleaned = traj[:] changed = True while changed: changed = False i = 0 while i < len(cleaned) - 1: p1 = cleaned[i] p2 = cleaned[i+1] d = haversine(p1[0], p1[1], p2[0], p2[1]) # 米 dt = p2[2] - p1[2] # 秒 if dt <= 0: # 时间戳异常,直接删后点 del cleaned[i+1] changed = True continue v_ms = d / dt v_kmh = v_ms * 3.6 if v_kmh > vmax_kmh: del cleaned[i+1] changed = True continue i += 1 return cleaned

这个实现里有个容易踩的坑:时间戳倒挂。GPS设备漂移时可能出现后一个点的时间戳比前一个点还早,如果不处理会导致速度为负值,影响后续所有判断。我一般会先把时间戳倒挂的点对找出来,按“删除后点”或“交换顺序”两种策略处理,论文里没写这一步,但实际数据几乎必然存在这种情况。

清洗的Vmax参数需要结合场景调整。论文里设1000km/h,基本只滤掉卫星跳变导致的极限异常点。如果做行人轨迹分析,Vmax可以设50km/h;做车辆轨迹,按高速限速120-140km/h设比较合理。

3.2 层次聚类实现:用轮廓系数自动选最优切割层

层次聚类最直接的实现是每次合并距离最近的两个集合,更新距离矩阵,循环直到只剩一个簇。每次合并后计算一次轮廓系数,记录当前的聚类结果和对应的R值。所有合并结束后,取R最大的那次结果作为最优聚类。

import numpy as np from sklearn.metrics import silhouette_score def hierachical_cluster(stay_points): """ 自底向上层次聚类,返回最优聚类标签 stay_points: [(lat, lng), ...] """ n = len(stay_points) if n <= 1: return [0] # 初始化:每个点单独成一簇 clusters = [[i] for i in range(n)] coords = np.array([[p[0], p[1]] for p in stay_points]) best_score = -1 best_labels = list(range(n)) step = 0 while len(clusters) > 1: # 计算簇间距离(average linkage) min_dist = float('inf') merge_pair = (0, 1) for i in range(len(clusters)): for j in range(i+1, len(clusters)): d = avg_cluster_distance(clusters[i], clusters[j], coords) if d < min_dist: min_dist = d merge_pair = (i, j) # 合并 ci, cj = merge_pair merged = clusters[ci] + clusters[cj] new_clusters = [clusters[k] for k in range(len(clusters)) if k != ci and k != cj] new_clusters.append(merged) clusters = new_clusters # 计算本次聚类结果的轮廓系数 labels = np.zeros(n, dtype=int) for idx, cluster in enumerate(clusters): for point_idx in cluster: labels[point_idx] = idx # 轮廓系数需要至少2个簇且每簇不止1个点 if len(clusters) >= 2 and all(len(c) > 1 for c in clusters): score = silhouette_score(coords, labels) if score > best_score: best_score = score best_labels = labels.copy() step += 1 if step > 200: break return best_labels def avg_cluster_distance(cluster_a, cluster_b, coords): total = 0.0 cnt = 0 for ia in cluster_a: for ib in cluster_b: total += np.linalg.norm(coords[ia] - coords[ib]) cnt += 1 return total / cnt if cnt > 0 else float('inf')

这段实现复现了论文算法2的核心逻辑,但有个工程化差异要说明:论文里每次合并后都重新算一遍所有簇间距离,时间复杂度是O(n³)。实际数据到几千个停留点时,这个速度会非常慢。我的做法是先用欧式距离矩阵预计算所有停留点两两之间的距离,然后簇间距离用预计算的距离矩阵求平均,能省掉重复的坐标计算开销。

3.3 轨迹段间距离计算:速度、角度、位置的加权融合

第二层聚类的输入是停留区域之间的轨迹段。轨迹段的距离计算是这套方法最繁琐的部分,需要先把原始轨迹点按停留点边界切段,然后对每个轨迹段计算平均速度、偏转角序列,再两两比较。

def trajectory_segment_features(segment): """ 计算轨迹段的三个特征:平均速度, 平均偏转角, 位置点集 segment: [(lat, lng, timestamp), ...] """ if len(segment) < 2: return None # 平均速度(m/s) total_dist = 0.0 total_time = 0.0 for i in range(len(segment)-1): total_dist += haversine(segment[i][0], segment[i][1], segment[i+1][0], segment[i+1][1]) total_time += max(segment[i+1][2] - segment[i][2], 1) avg_speed = total_dist / total_time if total_time > 0 else 0 # 偏转角: 遍历连续三点计算转向角 angles = [] for i in range(1, len(segment)-1): # 向量1: point[i-1] -> point[i] v1_lat = segment[i][0] - segment[i-1][0] v1_lng = segment[i][1] - segment[i-1][1] # 向量2: point[i] -> point[i+1] v2_lat = segment[i+1][0] - segment[i][0] v2_lng = segment[i+1][1] - segment[i][1] angle = abs(math.atan2(v2_lat, v2_lng) - math.atan2(v1_lat, v1_lng)) if angle > math.pi: angle = 2 * math.pi - angle angles.append(angle) avg_angle = sum(angles) / len(angles) if angles else 0 # 位置点集: 转为经纬度对列表 points = [(p[0], p[1]) for p in segment] return {'speed': avg_speed, 'angle': avg_angle, 'points': points}

速度距离和角度距离的计算可以用这个函数直接完成。位置距离用Hausdorff距离,这里有个实现细节——论文中用的是单向Hausdorff,即max(min(distance)),计算时要注意两个轨迹段长度不一致的情况。我会先对较短的轨迹段做插值,让两条轨迹段的点数量一致,再计算逐点最小距离,这样对采样率不一致的轨迹更公平。

4. 实验复现:GeoLife数据集上的参数配置与结果对照

论文的实验用了一个非常有名的公开GPS轨迹数据集——GeoLift。我一直觉得这个数据集的编号方式很反直觉,很多人第一次用会把“用户标号002”理解成第二个用户的第二次录制,实际它就是某个参与者的所有轨迹文件合集。

4.1 数据集预处理与轨迹点合并

我在复现时发现,从GeoLife原始下载的数据和论文里描述的248217个轨迹点对不上,主要原因有两个:一是原始数据是多个plt文件,按日期分散存储,需要全部读取并按时间戳合并;二是部分plt文件里存在字段缺失或异常,比如经纬度为0的记录。论文里没有详细写预处理步骤,我一般会在数据清洗前先做一次基础过滤:剔除经纬度同时为0的点、剔除时间戳重复的点、剔除明显超出正常经纬度范围的记录。

# 合并所有plt文件为一个CSV,按时间升序排序 cat Data/000/Trajectory/*.plt | \ awk -F',' 'NF==7 && $1!=0 && $2!=0 {print $1","$2","$6}' | \ sort -t',' -k3,3n > merged_trajectory.csv # 统计总行数 wc -l merged_trajectory.csv

用awk过滤掉经纬度为0且字段数不足的行,再按第三列时间戳排序,出来的结果就是一个干净的轨迹序列。这里有个awk的细节要注意:GeoLife的plt文件前6行是元数据,不是轨迹点,所以直接cat所有文件会把元数据行也当作轨迹行。我一般先用sed -n '7,$p'跳过每个文件的前6行,或者用tail -n +7。

4.2 核心参数设置表

参数论文设定我的调整建议影响范围
Vmax1000 km/h按场景设50-120 km/h清洗力度,影响轨迹点数量
Wt30 min15-60 min停留点粒度,设越大停留点越少
Wd100 m50-200 m停留点空间范围,设越大越容易合并
轨迹段距离权重1:1:1按业务侧重调整速度/角度/位置的异常敏感度
轮廓系数选择最大R同左,但注意局部最优聚类层数,决定离群点判定

论文把Vmax设成1000基本只过滤卫星跳变,但如果你的终端是手机且处于步行场景,1000km/h这个阈值形同虚设。我的建议是先画一张速度分布直方图,取99.9分位数作为Vmax,再根据业务场景做一次人工抽查。

4.3 算法时间对比实测

论文里对比了文献[5]的基于DBSCAN的方法,结论是DCTOD在运行时间上明显更快。这个结论在我的复现中部分成立——当停留点数量在几百个时,两层层次聚类的耗时可接受;一旦停留点数量超过2000个,纯Python实现的层次聚类会非常吃力,因为每次合并都要重新计算距离矩阵。我的解法是结合scipy的linkage函数算聚类树,再用fcluster按轮廓系数切层,速度能快一个数量级。

5. 复现避坑指南:论文没写的四个关键问题

5.1 现象:轮廓系数出现负值,最优聚类结果只剩一个簇

我在第一次跑实验时就遇到这个情况。现象:在停留点数量较多的用户数据上,整个层次聚类过程里轮廓系数一直为负,最后取最大值时对应的聚类结果是所有点合成一个簇,异常检测完全失效。原因:轮廓系数适合“簇内紧凑、簇间分离”的数据,但用户的停留点分布在空间上天然是连续的——家附近的点、公司附近的点、常去商场的点之间没有明显的间隔带,负值不代表算法错了,而是数据本身不适合这个评价指标。解决:我后来把轮廓系数的计算从“每个点到簇心的距离”改成“按停留点之间的实际经纬度距离计算”,并且增加了一个过滤条件——只保留簇内点数大于3的簇参与评价,这样能把离群点的干扰压下去。

5.2 现象:参数Wd=100m时,同一个商场被拆成好几个停留点

现象:用户在大型商场里逛了一圈,导出轨迹后发现同一个商场区域生成了3-4个停留点,导致后续聚类把它们当成不同的活动区域。原因:商场内部GPS信号受室内遮挡,定位点漂移严重,且购物行为本身就是“走走停停”,100m的空间阈值把每个停留的商铺区域单独成点。解决:我在这类室内场景会把Wd放大到300m,同时把Wt从30分钟改成15分钟,让停留点的时间跨度更敏感。但这会引入一个新问题——Wd太大可能把两条相邻街道的不同活动也合并了,需要根据终端类型动态调整。

5.3 现象:轨迹段距离计算中角度距离导致误报

现象:一条轨迹是笔直的直线,但因为GPS采样抖动,个别点有微小的横向偏移,计算出来的偏转角很大,被判定为异常轨迹段。原因:GPS定位精度在10-30米,正常行驶时连续三个点的夹角会因为定位噪声产生波动,直接用原始偏转角参与距离计算会把噪声当成行为异常。解决:我一般会先对轨迹段做一次轻量平滑,比如用滑动窗口平均经纬度坐标,然后再算偏转角。平滑窗口大小设为3-5个点,太大会把真实的弯道特征也抹掉。

5.4 现象:算法在密集停留区域完全失效

现象:用户的生活轨迹高度规律,每天两点一线,停留点全部集中在三个区域,但异常检测依然把其中某个正常停留点标成了离群点。原因:层次聚类把绝大多数停留点合并成一个大簇后,边缘停留点到簇内其他点的平均距离变大,轮廓系数反而给小簇打分更高,导致最优聚类结果把正常的边缘区域切出去了。解决:这个问题论文没有给出好答案,我自己的做法是增加一个“最小簇大小”约束——簇内停留点数量小于总停留点数的5%才考虑判定为异常簇,否则保留。这个比例的设定,取决于你对“异常”的定义是低频还是低概率,低频行为可能占比5-10%。

6. 进阶技巧:把论文方法用在实时轨迹监控和轨迹相似度评估上

论文的方法在设计之初更多是面向离线分析,比如从设备取证数据里判断哪些轨迹可疑。但它这套“先压缩、再聚类、后判定”的流程,完全可以改造用于在线场景。我在一个模拟项目X里做过一次尝试,把停留点提取的窗口从“遍历全量数据”改成“滑窗增量更新”,每收到一个新轨迹点就判断它是否属于当前停留点,如果属于就更新停留点的坐标和时间范围;否则封口当前停留点,开启新窗口。这个方法的好处是,实时场景下只需要维护当前活跃的停留点和历史停留点列表,不需要缓存整条轨迹。

轨迹段的异常判定也可以做成流式处理。每次新完成一段“区域间移动”,就把它和该区域间最近N段历史轨迹做距离计算,如果距离超过历史均值的3倍标准差就报警提示。权重设置这里要注意,速度权重在实时场景里要适当加大——角度和位置距离对GPS噪声更敏感,而速度距离相对稳定。

还有一种用法是轨迹相似度检索。把双层聚类产出的停留区域序列当成一条轨迹的“签名”,两个用户的轨迹相似度就是各自停留区域序列的编辑距离。这在多终端关联分析里很有用:同一个用户的两台设备,在相同时间段内停留区域序列应该高度一致,如果不一致,说明其中一台设备记录有缺失或存在设备分离。我在实践里验证过,这个思路比直接对比原始GPS点序列要稳定得多,数据量小了约两个数量级。

最后留一个我在复现中得到的血泪经验:无论你用什么聚类算法,第一版跑通后,先别急着调参,一定要把停留点序列画出来,和原始轨迹对比着看一遍。眼睛看出来的问题往往比任何指标都直接——比如哪个参数导致停留区域错位、哪段轨迹被错误合并、哪个离群点其实是设备跳变而不是行为异常。从那以后,我每次做轨迹分析都强制走一遍“原始轨迹可视化→停留点叠加→异常点标注”的流程,至少能避免三分之一的无意义调参。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进