简介:面向物联网移动终端设备轨迹异常检测的一篇中文论文PDF,原载《计算机应用研究》网络首发,适合从事物联网、移动计算、轨迹数据挖掘与行为分析的研究生、算法工程师及安全风控人员参考。文章针对海量、分布不均的终端轨迹数据检测效率低的问题,提出停留点提取结合双层层次聚类的思路:先按空间距离与时间间隔抽取停留点并聚类划分停留区域,定位异常停留区域,再对区域间运动轨迹段二次聚类以发现异常轨迹段,并给出实验对比与速度评估。资料包共1个PDF文件,约689KB,篇幅紧凑、结构完整,含摘要、关键词、引言、方法、实验与参考文献,便于快速把握算法流程、复现实验思路并用于课程报告或相关课题调研。已有296人学习下载,可作为异常轨迹检测方向入门与选题参考。
1. 从一堆带时间戳的经纬度说起:双层聚类的用户轨迹异常检测
手机、手环、车机每天都会往本地数据库里写经纬度加时间戳,单台设备一年攒下几十万个轨迹点是常事。做面向物联网移动终端设备的用户轨迹异常检测,最直接的做法是把现成的轨迹离群算法套在原始点上跑一遍,但这条路上有个反直觉的结论:点越密,效果越差。一个人在办公楼里待八小时,产生的几千个点彼此高度相似,它们会把真正有信息量的“移动”部分淹没掉,聚类算到后面全在区分楼上楼下。这套方案把检测拆成两层:先把原始点按空间阈值和时间阈值压成停留点,第一层对停留点做层次聚类,找出离群的停留区域;第二层只对停留区域之间的轨迹段再做一次聚类,找出异常的运动轨迹。适合拿它做轨迹挖掘方向毕设、课设,或者要交一份位置数据异常检测方案的人。
2. 轨迹模型与停留点生成:把几十万轨迹点压成可聚类的四元组
原始轨迹的本质是一条带时间戳的坐标序列,记作 G=(g1,g2,…,gn),每个 gi=(xi,yi,ti),x 是经度、y 是纬度、t 是时间戳。这个定义看起来朴素,但它决定了后面所有算法的输入形态:必须严格按时间排序,必须有可比较的时间基准。从移动终端里导出来的数据常常不满足这两条,所以真正动手前,第一步不是建模,是清洗。
2.1 数据清洗:用速度上限把错误点挡在门外
移动终端采集到的轨迹点有两类典型错误:一类是定位漂移,信号弱的时候坐标会突然跳到几公里外;另一类是时间戳异常,比如跨时区、单位混用(秒和毫秒)、时钟回拨。这两类点如果不清掉,会直接污染停留点判定——一个跳变点可能让本来连续的停留段被切断,也可能把两个相隔很远的区域连成一段。
清洗的判据是相邻点之间的平均速度:按时间排序后,逐个计算相邻两点的距离与时间差,速度超过上限 Vmax 就把后一个点丢掉,然后继续比较,直到整条序列都满足条件。
import math def haversine(p, q): """球面距离,返回米。p、q 均为 (lon, lat)""" R = 6371000.0 lon1, lat1 = math.radians(p[0]), math.radians(p[1]) lon2, lat2 = math.radians(q[0]), math.radians(q[1]) dlon, dlat = lon2 - lon1, lat2 - lat1 a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2 return 2 * R * math.asin(math.sqrt(a)) def clean_by_speed(points, vmax_kmh=1000.0): """points: [(lon, lat, ts)],ts 为 epoch 秒;返回清洗后的序列""" if not points: return [] cleaned = [points[0]] for p in points[1:]: prev = cleaned[-1] dt = p[2] - prev[2] if dt <= 0: # 时间倒挂或重复,直接丢弃 continue v = haversine(prev, p) / dt * 3.6 # 米/秒 转 km/h if v <= vmax_kmh: # 超过阈值视为定位漂移点 cleaned.append(p) return cleaned这里 Vmax 取 1000 km/h 是个宽松值,它只负责挡住明显不可能的跳变,不去判断“这个速度是否合理”。原因很实际:城市里地铁、高铁、隧道口的定位点本来就不可靠,阈值定得太紧会把正常的长距离出行也删掉,最后样本量掉一大截。清洗的目标是去噪,不是做物理仿真,宁可漏掉几个坏点,也别把好数据误伤。
距离计算方式也需要统一。很多实现图省事,直接对经纬度做欧氏距离,在小范围、低纬度地区误差还能忍,一旦数据跨几个纬度,判定就会明显偏移。这套方案里所有距离都走 haversine,输出的单位是米,后面的空间阈值、簇间距离、Hausdorff 距离全部在这个单位体系下比较,不会出现“一半按度算、一半按米算”的混乱。
2.2 停留点的四元组定义:空间阈值 Wd 与时间阈值 Wt
停留点是整条流水线的基础抽象。它的含义是:如果一段连续轨迹始终落在某个很小的空间范围内,并且持续时间足够长,那么这段轨迹就可以被一个点代替,代表用户在这个位置“停下来了”。
形式化地,对轨迹段 gk…gm,若段内相邻轨迹点距离小于空间阈值 Wd,且结束时间与开始时间之差大于时间阈值 Wt,则这段轨迹合并为一个停留点 s=(x, y, t_start, t_stop),其中 x、y 是段内所有轨迹点经纬度的平均值,t_start 取 gk 的时间,t_stop 取 gm 的时间。
这个定义解决了三个问题。第一是数据量,一段待了六小时的轨迹原本可能有两千个点,合并后只剩一个记录,后续层次聚类的复杂度直接降了两三个数量级。第二是语义,平均值天然是一个“重心”,比段内任意一个点更能代表这个区域的中心。第三是分层,异常被明确拆成两类:停留异常(用户出现在他很少出现的地方)和运动异常(用户在两个停留区域之间的行走方式跟平时不一样),这两类异常在业务上的含义完全不同,混在一起检测反而说不清。
两个阈值的作用要分开理解。Wd 控制空间范围,取值太小会把一个商场拆成十几个停留点,取值太大会把相邻两个小区合并成一个;Wt 控制时间下限,它负责过滤“路过”,一个人等红灯也算停了几十秒,但显然不该算作有意义的停留。原文实验里取 Wd=100 米、Wt=30 分钟,这两个数不是玄学,它们的量级对应“一个建筑或街区”和“一次办事、一顿饭、一次会议”。换场景就要换值:做车载终端、跑网约车的轨迹分析,停留时间降到 5~10 分钟更合理;做园区人员管理,Wd 收到 30 米都不会过分。
2.3 停留点生成算法:一趟遍历,复杂度 O(n)
算法本身很简单,从头到尾扫一遍轨迹序列,维护一个当前停留段的起点,只要后续点还落在空间阈值内就继续吞,吞到条件不满足就结算:看这个段的点数是否大于 2、时长是否超过 Wt,满足就产出一个停留点,然后从下一个点重新开始。
def extract_stay_points(points, wd=100.0, wt=1800): """points: 清洗后的 [(lon, lat, ts)];wd 单位米,wt 单位秒""" s_list, i, n = [], 0, len(points) while i < n - 1: start = i sum_lon, sum_lat, cnt = points[i][0], points[i][1], 1 # 以段首点为参照,避免链式漂移把停留段越拉越长 while i + 1 < n and haversine(points[start], points[i + 1]) < wd: i += 1 sum_lon += points[i][0] sum_lat += points[i][1] cnt += 1 duration = points[i][2] - points[start][2] if cnt > 2 and duration > wt: s_list.append({"lon": sum_lon / cnt, "lat": sum_lat / cnt, "t_start": points[start][2], "t_stop": points[i][2], "cnt": cnt}) i += 1 return s_list几点说明。第一,参照点选段首点而不是前一个点,这是个容易翻车的地方:如果按“相邻点距离”判定,轨迹在阈值边缘反复横跳时,停留段会像雪球一样越滚越远,最后把一个跨三站地铁的行程压成一个停留点。以段首为圆心,等于给这个停留点加了一个半径上限。第二,cnt>2 这个条件是为了排除只有一两个点的碎片段,那种段做平均没有统计意义。第三,整个算法只遍历一次轨迹序列,时间复杂度 O(n),空间复杂度 O(n),二十多万个点跑下来是毫秒级的事,所以它可以放心放到采集端或者预处理阶段去做。
跑完之后,输入的几十万个坐标点会变成一个几百到几千条的停留点序列,每条带坐标、开始时间、结束时间、点数和平均停留时长。这份序列就是第一层聚类的输入。判断标准很直接:把停留点画到地图上,如果它的空间轮廓和原始轨迹的轮廓基本重合,说明阈值选得合适;如果轮廓明显塌缩成几个点,就是 Wd 给大了。
3. 第一层聚类:停留点层次聚类与异常停留区域识别
有了停留点序列,接下来要回答的是“哪些停留点属于正常活动范围,哪些是孤零零的”。这里的核心矛盾在于,停留点分布极不均匀:家和工作地可能占掉八成点数,其余零散分布在几十个位置。DBSCAN 这类基于密度的算法在这种数据上很难调,邻域半径和最小点数两个参数互相牵制,改一个另一个就得跟着改,而层次聚类不需要预先指定簇数,也不依赖密度假设,正好对得上这个场景。
3.1 簇间距离:用平均连接定义两个集合的远近
层次聚类自底向上:初始时每个停留点自成一簇,每轮找出距离最近的两个簇合并,直到所有点并成一簇。关键在于“簇与簇的距离”怎么定义。单连接取两簇间最近点对的距离,容易产生长链;全连接取最远点对,对离群点过于敏感。这套方案用平均连接,也就是把两个簇内所有点对的距离求平均,公式上等价于对簇内每对停留点算欧氏距离再取均值。
平均连接的抗噪性比单连接好得多,代价是计算量。每轮合并都要重新计算所有簇对之间的距离,簇内点对求平均又是两层循环,整体会到 O(n³)。这也是为什么第 2 章要把几十万个原始点先压成几千个停留点——直接对原始点做层次聚类,别说跑完,内存都吃不住。
def avg_link(c1, c2, coords): """两簇间平均连接距离:所有点对距离的均值""" total = 0.0 for i in c1: for j in c2: total += haversine(coords[i], coords[j]) return total / (len(c1) * len(c2)) def agglomerate(coords, min_size=2): """自底向上层次聚类,返回合并过程快照与最终簇划分""" clusters = [[i] for i in range(len(coords))] snapshots = [([c[:] for c in clusters], None)] while len(clusters) > 1: bi = bj = -1 bd = float("inf") for i in range(len(clusters)): for j in range(i + 1, len(clusters)): d = avg_link(clusters[i], clusters[j], coords) if d < bd: bi, bj, bd = i, j, d merged = clusters[bi] + clusters[bj] clusters = [c for k, c in enumerate(clusters) if k not in (bi, bj)] clusters.append(merged) snapshots.append(([c[:] for c in clusters], bd)) return snapshots返回的 snapshots 记录了每一层的簇划分和当时的合并距离,这个中间结果别丢,后面选最优切分、画树状图、做答辩演示都靠它。
3.2 轮廓系数选最优切分:把聚类结果从树上切下来
层次聚类跑完得到的是一整棵树,从“每个点一簇”到“全体一簇”,中间有 n-1 种切法。到底切在哪一层,需要一个客观指标。这套方案用轮廓系数:对每个停留点 si,a(i) 是它到同簇其他点的平均距离,b(i) 是它到最近的其他簇所有点的平均距离的最小值,单点轮廓值定义为 (b(i)-a(i))/max{a(i), b(i)},全体取平均得到 R,取值范围 [-1,1],越大说明簇内越紧、簇间越开。
def silhouette(coords, clusters): """按当前簇划分计算平均轮廓系数""" label = {} for k, c in enumerate(clusters): for i in c: label[i] = k if len(clusters) < 2: return -1.0 scores = [] for i, p in enumerate(coords): same = [j for j in clusters[label[i]] if j != i] if not same: continue a = sum(haversine(p, coords[j]) for j in same) / len(same) b = float("inf") for k, c in enumerate(clusters): if k == label[i]: continue b = min(b, sum(haversine(p, coords[j]) for j in c) / len(c)) scores.append((b - a) / max(a, b)) return sum(scores) / len(scores)把 snapshots 逐层喂进去算出 R 值曲线,取 R 最大的那一层作为最终划分。这一步的工程细节比公式更重要:一是轮廓系数在簇数为 2 附近常常出现虚高,需要加一个最小簇大小约束,把只有一两个点的簇先并掉再看;二是点数上千之后逐层算轮廓系数会明显拖慢流程,可以在合并到簇数小于 50 之后再开始算,前面几百层直接跳过;三是 R 最大不代表业务可解释,最终还要人工看一眼每个簇的中心落在哪,是不是对应真实的地点群。
3.3 从簇到停留区域:离群点与区域的双重产出
选定切分后,得到两类结果。一类是规模较大、空间紧凑的簇,把它们定义为用户的停留区域,比如家、单位、常去的商场,这些区域构成了用户的日常活动骨架。另一类是规模极小、或者在树状图上很晚才被合并进来的簇,这些就是异常的停留点或异常停留区域——用户去了一个他平时几乎不去的地方,或者在一个陌生的地方反复出现。
这里有个细节值得强调:离群点的判定不要只看“簇大小”。一个簇里只有一个停留点,很有可能只是数据稀疏,比如用户三个月才去一次的地方;真正的异常应该是“这个点离所有已知区域都远,且它本身停留时间不短”。所以实际落地时我一般会给离群判定加两个条件:到最近簇中心的距离超过某个分位阈值,且该停留点的持续时长超过中位数。这两个条件把“偶发但正常”和“偶发且反常”区分开了。
另外,第一层的产出不只是异常点,还有区域之间的连接关系。哪些区域之间有过往、来往频次多少、每次用多长时间,这些信息在第二层会变成轨迹段的组织依据。
4. 第二层聚类:三段距离度量与异常运动轨迹检测
第一层解决的是“停在哪”的问题,第二层解决的是“怎么走”的问题。把停留点按时间顺序排成序列 s1,s2,…,sn,相邻两个停留点之间的原始轨迹点构成一条轨迹段,整条轨迹就被切成了若干段。其中同一停留区域内部的段意义不大——都是同一个地方的不同时段,几何形态高度近似;真正有信息量的是不同停留区域之间的段,也就是通勤、出差、外出办事这些行程。
4.1 轨迹段划分与待检测集合的构造
划分规则很直接:以停留点为界,每两个相邻停留点之间的点序列就是一段。设停留点序列为 S=(s1,…,sn),轨迹段集合就是 P={⟨s1,s2⟩, ⟨s2,s3⟩, …, ⟨s(n-1),sn⟩}。
按停留点所属的簇,这个集合可以分成两部分:簇内段和簇间段。簇内段是同一个停留区域内部不同时间的活动,比如中午下楼吃饭又回来,这类段的几何差异极小,参与聚类只会贡献噪声;簇间段才是用户在两个活动区域之间的真实位移。所以第二层聚类只用簇间段,这一步的过滤往往能把候选集砍掉一半以上,对后面 O(n³) 的复杂度来说是实打实的提速。
这里还有一个隐含前提:一段轨迹被切完之后,段与段之间不再有时间重叠,也不会有跨段的跳跃。如果停留点生成阶段出了错(比如该生成的停留点没生成),那么一条本该被切成三段的长途行程会变成一整段,第二层就会把它当成一条异常轨迹报出来。这是两层结构之间的耦合点,排查时优先看这里。
4.2 速度、角度、位置:三段距离怎么算才不打架
两条轨迹段之间的差异,这套方案从三个维度度量。
速度距离是两个段平均速度之差的绝对值,段内平均速度由段内所有相邻点速度的平均求得。角度距离看的是轨迹的偏转角:对段内每个中间点 gi+1,由 gi、gi+1、gi+2 三点连线的夹角确定一个偏转角 β,两条段的距离取各自平均偏转角之差。位置距离用 Hausdorff 距离,即两条段之间点对距离的最大最小值,衡量两条轨迹在空间上的形状偏差。
def seg_speed(seg): """段平均速度,单位 m/s""" vs = [] for a, b in zip(seg, seg[1:]): dt = b[2] - a[2] if dt > 0: vs.append(haversine(a, b) / dt) return sum(vs) / len(vs) if vs else 0.0 def seg_angle(seg): """段平均偏转角,单位弧度""" angs = [] for a, b, c in zip(seg, seg[1:], seg[2:]): v1 = (b[0] - a[0], b[1] - a[1]) v2 = (c[0] - b[0], c[1] - b[1]) n1 = math.hypot(*v1) n2 = math.hypot(*v2) if n1 == 0 or n2 == 0: continue cos = max(-1.0, min(1.0, (v1[0]*v2[0] + v1[1]*v2[1]) / (n1 * n2))) angs.append(math.acos(cos)) return sum(angs) / len(angs) if angs else 0.0 def hausdorff(seg1, seg2): """段间 Hausdorff 距离,单位米""" def directed(A, B): return max(min(haversine(p, q) for q in B) for p in A) return max(directed(seg1, seg2), directed(seg2, seg1))三个分量算出来之后按权重相加得到综合距离。原文三个权重取相等,各 1/3。这个设定可以照抄,但有一件事必须自己补上:三个分量的量纲完全不同。速度是米每秒,偏转角是弧度,位置是米,直接按 1/3 相加,位置项会以绝对数值优势压过另外两项,实际上等于只做了空间形状聚类,速度和角度形同虚设。
我一般的做法是先把每个分量在全量样本上做 min-max 归一化或 z-score 标准化,映射到同一量级,再按权重相加。权重本身也不再固定,可以按场景调:做网约车行程分析,位置和角度更重要;做步行轨迹分析,速度差异本来就小,权重可以压低;做车辆异常行为识别,速度权重要往上提。权重取 1/3 只是起点,不是答案。
4.3 第二次聚类与异常轨迹段的产出
第二层的聚类流程跟第一层完全一致,只是把输入从停留点换成轨迹段、把距离函数从点间距离换成段间综合距离。同样的自底向上合并、同样的轮廓系数选层、同样的离群识别逻辑。这种复用是这套双层结构最大的工程红利——代码只写一份,换个距离函数就能跑第二遍,参数和日志体系也是共用的。
最终输出的异常有两类。第一类是异常停留区域,来自第一层;第二类是异常轨迹段,来自第二层,表现为某个段在速度、角度或空间走向上明显偏离同源同目的地的大量正常段。两类结果合并之后,一份完整的异常轨迹报告就有了:什么时候、在哪个位置停留异常,以及哪一段行程走得反常。
还有一点在论文里不会写、但落地时一定会遇到:异常段的数量要对得上业务预期。如果第二层输出的异常段占了总段数的三成,那基本不是算法发现了问题,而是距离函数或归一化出了问题。我的经验值是异常比例控制在 3%~8% 之间比较可信,超出这个范围就要回头查参数,而不是急着往报告里写结论。
5. 避坑与排查:这套流程里最容易翻车的五处
5.1 数据与参数层面的三处硬伤
现象一:停留点画出来是一串长条,横跨好几个街区。原因多半出在判定逻辑上——如果按相邻两点距离判定,轨迹在阈值边缘来回摆动时,停留段会不断向外延伸,起点和终点早已相距很远。解决办法是把参照点固定在段首,每进一个点都跟段首比距离,同时给停留段加一个最大半径上限,超过就强制结算。
现象二:换了台设备的数据,停留点数量骤减。大概率是时间戳单位不一致,一台设备写的是秒,另一台写的是毫秒,Wt=1800 秒的判定在毫秒数据上等于没有门槛。统一改成 epoch 秒、统一到 UTC 时区,并在数据入口处做一次类型和量级校验。我吃过这个亏,那次排查花了整整一个下午,最后发现是两行数据差了一千倍。
现象三:聚类跑出来簇数特别多,但每个簇只有一两个点。这种情况通常是 Wd 给得太小,把一个真实区域切碎了,也可能是轨迹本身采样率太高、噪声点太多。处理方式是把 Wd 调到对应“一个建筑尺度”,并在停留点生成后加一步轻量合并:中心距离小于 Wd 且时间相邻的停留点,先合并再进聚类。
5.2 算法与工程层面的两处硬伤
现象四:第一层聚类在几十万点上跑不完,内存直接爆掉。原因很明确,层次聚类每轮都要重算所有簇对的距离,簇内又是点对全遍历,复杂度是三次方级别,直接喂原始点必然崩。正确路径是先做停留点压缩,把输入规模降到几千条;再在合并到一定簇数之后才开算轮廓系数;如果停留点本身还是太多,可以按用户或按时间段分片处理,各片独立聚类后再合并结果。
现象五:第二层报出的异常段占了总量的一大半,但业务上看不出任何异常。十有八九是三个距离分量没做归一化,位置项以米为单位独大,速度和角度被淹没,聚类实际上退化成了空间形状聚类,那些绕路但合理的行程全被判成异常。修法是把三个分量先标准化再按权重相加,同时检查权重是否与实际场景匹配。这个坑很隐蔽,因为代码不报错、结果也“有”,只是结论不能用。
提示:这五处问题里有四处出在数据入口和距离度量上,只有一处出在聚类算法本身。排查顺序也应该按这个比例分配时间。
6. 验收与答辩:怎么证明这套检测真的有效
这套方案最难回答的问题不是“怎么实现”,而是“你怎么知道检测出来的就是异常”。真实轨迹数据没有标注,谁也没法给几十万条坐标打上“这条是异常”的标签。所以验收必须自己造一个可计算的环境。
标准做法是注入式验证:取一份正常轨迹集,人工注入两类异常——空间异常(把某几个停留点搬到用户从未去过的区域)和路径异常(把某两条停留区域之间的轨迹段替换成绕行路线)。注入比例控制在 5%~10%,然后看算法能不能把它们找回来。
| 验收项 | 度量方式 | 参考落点 |
|---|---|---|
| 检测完整性 | 召回率:注入的异常中被检出的比例 | 两类异常分别统计,不低于 80% |
| 检测准确性 | 精确率:报出的异常中真是注入的比例 | 与召回率一起看,避免靠多报刷分 |
| 效率 | 相同数据集下的端到端耗时 | 与同类分段聚类算法对比,量级上要占优 |
| 稳定性 | 换 5 个不同用户的数据各跑一遍 | 召回率波动不超过 10 个百分点 |
| 参数敏感性 | Wd、Wt 做网格扫描 | 结论在合理区间内不翻转 |
参数敏感性这一项在答辩时特别加分。把 Wd 从 50 米到 300 米、Wt 从 10 分钟到 60 分钟扫一遍,画出召回率的热力图,指出存在一片稳定的高原区,说明方法不是靠调参调出来的。相反,如果曲线是一根尖峰,那就得老实说清楚适用边界。
对比实验也要设计得有说服力。至少跑三组:只做空间聚类的基线、不做停留点压缩直接对原始点聚类的版本、完整双层方案。第一组说明时间维度的价值,第二组说明停留点压缩带来的效率提升,第三组是完整效果。数据规模、硬件环境、参数设置都要写进表里,别只报一个“快了 40%”这样的结论。
答辩被追问最多的是三个点。第一,“你的停留点阈值凭什么取这个值”——答案不能是“参考文献”,要给出场景对应关系,比如 100 米约等于一个街区,30 分钟约等于一次完整办事,同时给出敏感性分析的图。第二,“聚类结果的簇数怎么定的”——答轮廓系数选层,但一定要补一句最小簇大小约束和人工抽检,否则会被追着问“轮廓系数虚高怎么办”。第三,“你这套方法和已有的分段聚类有什么本质区别”——区别就在双层:第一层把停留和移动分开,第二层只对移动部分建模,异常被拆成两种语义不同的类型,这比在统一空间里一锅炖更容易解释。
代码和实验都跑通之后,把参数表、注入脚本、结果统计脚本一起归档。我吃过一次亏:答辩前一周想复现三个月前的一组数据,发现当时改了阈值却没记录,只能从头再跑一遍。从那以后我每次做轨迹实验,都在脚本入口强制把 Wd、Wt、Vmax、权重、随机种子写进一个 config 并打印出来。希望帮到你。
本文还有配套的精品资源,点击获取