基于停留点与双层聚类的物联网轨迹异常检测
简介:这是一份面向物联网移动终端轨迹数据挖掘与异常检测方向的研究论文PDF,适合从事用户行为分析、轨迹聚类、智慧城市与移动计算的研究者及高年级学生阅读,聚焦海量、分布不均轨迹数据下异常检测效率偏低的问题。压缩包内含1个PDF文件,约689KB,轻量便于收藏与查阅。论文提出双层聚类思路:先按特定空间距离与时间间隔提取停留点,经层次聚类划分停留区域并识别异常停留区域;再对停留区域间的运动轨迹段二次层次聚类,以发现异常轨迹段。内容涵盖问题背景、方法设计、实验对比与结果分析,可帮助读者理解停留点、停留区域与异常轨迹之间的检测链路,并借鉴其聚类流程、实验设置与效率优化思路。已有295人学习下载,适合作为轨迹异常检测课题的入门参考与方案对照材料。
1. 需求场景:248217 个轨迹点,为什么第一件事是把它压成停留点
某实验室拿到一批从物联网移动终端设备导出的轨迹数据,单个用户就有 248217 个带时间戳的经纬度点。最直接的想法是上聚类、算距离、找离群点,结果跑了一整晚还在算距离矩阵。问题出在数据形态上:用户在办公室坐了一上午,终端就记了几千个几乎重合的点,这些点全都是「正常」的,却占掉了绝大部分算力。
面向物联网移动终端设备的用户轨迹异常检测,真正难的不是聚类算法本身,而是先把「人待着不动」这件事从数据里剥出来。这套双层聚类的做法是:先用空间阈值与时间间隔把轨迹点合并成停留点,让数据量掉一个数量级;再对停留点做一次层次聚类划出停留区域,找出异常停留区域;最后对停留区域之间的运动轨迹段做二次聚类,找出异常轨迹段。两层各管一类异常,一套流程同时覆盖停留异常和运动段异常。适合正在做轨迹挖掘方向毕设、课程设计,或者要给终端行为分析出一份可交付方案的人。
2. 轨迹模型与问题定义:三个字段、两个阈值、两类异常
2.1 从终端数据到轨迹序列:排序、清洗与速度阈值
终端里的轨迹信息来源很杂,导航类应用、社交类应用的数据库、甚至图片文件里都可能带着位置和时间。格式不统一是常态,但落到算法入口只有一种形态:按时间先后排好序的三元组序列。形式化地写,用户轨迹是 G = (g₁, g₂, …, gₙ),其中 gᵢ = (xᵢ, yᵢ, tᵢ),xᵢ 是经度、yᵢ 是纬度、tᵢ 是时间戳。这里有个容易忽略的前提:经纬度是角度单位,直接拿来做欧式距离算出来的数值没有物理意义,必须先换算成米制平面距离。短距离场景下用等距圆柱近似就够,纬度差乘 111320 米,经度差再乘一个 cos(纬度) 的系数。
数据清洗要解决的是「瞬移点」。终端定位在室内、隧道、高楼之间会漂移,一个点跳到几公里外再跳回来,这种点在后续停留点提取里会直接把一整段轨迹切碎。清洗思路是按时间排序后,逐对计算相邻点的平均速度,超过阈值 Vmax 就把后一个点删掉,删完再从头扫一遍,直到整条序列里不再有超速的相邻对。这里的「反复扫」不是多余动作:删掉一个跳点之后,它前一个点和后一个点会变成新的相邻对,可能又是一次超速跳变,只扫一遍是清不干净的。
import math from collections import namedtuple Point = namedtuple("Point", ["lon", "lat", "ts"]) # 经度、纬度、时间戳(秒) Stay = namedtuple("Stay", ["lon", "lat", "t_start", "t_stop", "n"]) M_PER_DEG_LAT = 111_320.0 def to_meters(p, q): """经纬度差换算为平面米制距离,城市尺度下等距圆柱近似误差可接受""" dlat = (q.lat - p.lat) * M_PER_DEG_LAT dlon = (q.lon - p.lon) * M_PER_DEG_LAT * math.cos(math.radians(p.lat)) return math.hypot(dlat, dlon) def clean(points, v_max_kmh=1000.0): """排序后反复剔除超速点,直到整条序列收敛;同时丢掉时间戳重复的点""" pts = sorted(points, key=lambda p: p.ts) changed = True while changed and len(pts) > 1: changed = False kept = [pts[0]] for p in pts[1:]: prev = kept[-1] dt = p.ts - prev.ts if dt <= 0: # 同一秒的重复点,保留先到的那个 changed = True continue v = to_meters(prev, p) / dt * 3.6 # m/s 转 km/h if v > v_max_kmh: changed = True continue kept.append(p) pts = kept return ptsv_max_kmh的取值要跟着采样间隔走。原文实验里设的是 1000 km/h,看似离谱,但考虑到终端采样间隔可能长达几分钟、定位本身有几十米抖动,这个宽松阈值是为了只砍掉真正的跳点,不误伤正常步行和乘车。如果数据是秒级高频采样,这个值应该压到 200 以内。to_meters里的 cos 系数用的是起点纬度,在城市尺度(几十公里内)误差在千分之一量级,用来做阈值判断完全够用。
清洗完的序列长度会明显下降,但仍然是万级。真正让数据量掉下来的是下一步。
2.2 停留点:空间阈值 Wd 与时间阈值 Wt 怎么配合
停留点的定义直白:轨迹序列中连续的一段 gₖ…gₘ,段内相邻点的距离都小于空间阈值 Wd,整段的起止时间差大于时间阈值 Wt,那么这一段就合并成一个停留点。停留点是一个四元组 s = (x, y, t_start, t_stop),其中 x、y 是段内所有轨迹点经纬度的平均值,t_start 取段首点时间,t_stop 取段尾点时间。
这两个阈值承担的是完全不同的职责,调参时必须分开看。Wd 决定「多近才算同一个地方」,100 米是原文实验取值,大致对应一栋楼或一个街区;调小会把一次停留拆成七八个停留点,调大会把相邻的两个商场合并成一个。Wt 决定「待多久才算停留」,30 分钟同样来自原文实验,对应吃饭、开会、上课这类行为的典型时长;调小会把等红灯、路边接电话都算成停留,调大会把便利店买东西这种短停留全部漏掉。
还有一个隐含条件经常被漏掉:段内轨迹点数量要大于 2。少于三个点的「停留」在统计学上没有意义,很可能是两次定位之间的偶然停顿。三条约束叠加,才是一个有效停留点。
def extract_stays(pts, wd=100.0, wt=1800, k_min=3): """双指针扫描生成停留点序列;wt 单位是秒,1800 即 30 分钟""" stays, i, n = [], 0, len(pts) while i < n: j = i # 向后扩张,直到下一个点跳出空间阈值 while j + 1 < n and to_meters(pts[j], pts[j + 1]) < wd: j += 1 seg = pts[i:j + 1] if len(seg) >= k_min and (seg[-1].ts - seg[0].ts) > wt: stays.append(Stay( lon=sum(p.lon for p in seg) / len(seg), lat=sum(p.lat for p in seg) / len(seg), t_start=seg[0].ts, t_stop=seg[-1].ts, n=len(seg), )) i = j + 1 # 整段已消费,从段后第一个点继续 else: i += 1 # 段不满足条件,只前进一个点重新起段 return stays原始伪代码用的是单层 for 循环加内层 while,内层里continue之后i++的写法很容易写出死循环或者跳过点。改成双指针之后逻辑清晰很多:外层指针指向候选段的起点,内层指针负责向后扩张,扩张结束再判断这一整段是否够格。i = j + 1和i += 1这两个分支的区别是关键——前者表示整段被合并消费掉了,后者表示这个点不构成停留,但它可能是下一段的起点,不能跳。
这一步的时间复杂度是 O(n),空间复杂度也是 O(n),全程只遍历一遍原始序列。248217 个点跑完,停留点数量通常落在几百到两千之间,后面所有聚类都只在这个量级上做,这才是这套方案能跑得动的原因。
2.3 把异常拆成两类:停留异常与运动段异常
异常轨迹不是一个单一概念。用户在某个从没去过的区域停留了两小时,与用户在两栋楼之间走了一条完全绕远的路线,是两种性质完全不同的异常,用同一个模型去抓必然顾此失彼。
停留点的分布本身携带信息:经常活动的区域(家、公司、常去的食堂)会积累大量停留点,在空间上表现为局部密集;很少去的区域只有零星几个停留点,表现为局部稀疏。所以第一类异常——异常停留区域——本质上是一个空间密度离群问题,对停留点集合做一次聚类,那些自成一簇、离其他簇很远的点就是异常。
第二类异常要从停留点序列里切出来。把停留点按时间排序得到 S = (s₁, s₂, …, sₙ),相邻两个停留点之间夹着的那段原始轨迹点,就构成一条轨迹段,全部轨迹段记作 P = {<s₁,s₂>, <s₂,s₃>, …}。这些段要分成两组:一组是同一个停留区域内部两个停留点之间的段,另一组是不同停留区域之间的段。前者只是同一个人在同一个地方不同时段的活动,物理意义上是「区域内位移」,干扰大、价值低;后者才是真正的移动行为,异常检测只针对后者。
两类异常、两层聚类,这个划分不是算法上的凑数,而是把「人在哪停」和「人怎么走」这两个问题彻底解耦。第一层的聚类结果同时给第二层提供了切分依据——哪些停留点属于同一个区域,直接决定了哪些轨迹段要进入第二层。
3. 第一层聚类:停留点层次聚类与停留区域划分
3.1 为什么选层次聚类而不是密度聚类
密度聚类在这类问题上是很自然的第一反应,但用起来会卡在两个参数的确定上。邻域半径 eps 和邻域内最小点数 minPts,在停留点分布极不均匀的情况下很难给出一组通用值:用户在市中心的停留点密集成团,在郊区的停留点稀稀拉拉,同一组参数要么把市中心全划成一簇,要么把郊区全判成噪声。原文对此的判断是,类与类之间距离过长时,密度聚类的参数确定非常困难。
层次聚类没有这个包袱。它不需要预设簇数,也不需要假设数据分布均匀,只需要一个集合间距离的定义,然后从每个点自成一簇开始,反复合并距离最近的两个簇,直到所有点归为一簇。整个过程产出一棵聚类树,簇数由后续的轮廓系数来挑,而不是提前拍板。
这套方案里还有两个额外理由。其一,停留点聚类面对的是小数据量问题——几百到两千个点,层次聚类的 O(n³) 在这个量级上完全可以接受,实测下来秒级完成。其二,层次聚类天然给出的是一棵完整的合并树,每一层的聚类结果都能回看,这比密度聚类只吐一个最终标签更适合做分析型任务,答辩时想解释「为什么这两个区域被合并了」也有据可依。
| 维度 | 密度聚类 | 层次聚类 |
|---|---|---|
| 需要预设参数 | eps、minPts,对密度不均敏感 | 只需集合间距离定义 |
| 簇数 | 由参数间接决定 | 由轮廓系数从聚类树中选 |
| 噪声处理 | 显式标记为噪声点 | 通过小簇离群体现 |
| 复杂度 | O(n log n) 量级 | O(n³),小数据量可接受 |
| 结果可解释性 | 只有最终标签 | 完整合并树,可逐层回看 |
注意:层次聚类的复杂度是它唯一的硬伤。停留点数量到了万级,纯 Python 实现会明显吃力,这时候要么先用网格把空间粗划分一层,要么换成基于最近邻链的 linkage 实现,把每轮找最近对的代价压下来。
3.2 集合间平均距离与轮廓系数定簇数
层次聚类要跑起来,先得定义两个簇之间的距离。原文用的是平均距离:簇 Cᵢ 与 Cⱼ 之间的距离,等于两簇内所有停留点对之间欧式距离的平均值。停留点之间的欧式距离直接用经纬度换算后的米制距离,因为停留点坐标本身就是段内轨迹点的平均值,代表的是一个位置而非一段路径。
平均距离相比最短距离和最长距离的好处是不容易被极端点带偏。最短距离会让两个簇只要有一个点挨得近就合并,容易拉出长条状的巨型簇;最长距离又过于保守,两个明显同属一片区域的簇可能迟迟不合并。平均距离在两者之间,对停留点这种「一团一团」的分布比较稳。
簇数怎么定,靠轮廓系数。对某个聚类结果,对每个停留点 sᵢ 计算两个量:a(i) 是它到同簇内其他点的平均距离,b(i) 是它到其他各簇的平均距离中最小的那个。单点轮廓系数定义为 (b(i) − a(i)) / max{a(i), b(i)},全部点的平均值就是这次聚类的 R 值,取值范围 [−1, 1]。R 越接近 1,说明簇内越紧、簇间越开。做法是从底往上合并的过程中,每合并一次算一次 R,最后取 R 最大的那一次聚类结果作为最终划分。
def dist_matrix(items, metric): """一次性算好距离矩阵,后续所有合并、轮廓系数计算都复用它""" n = len(items) D = [[0.0] * n for _ in range(n)] for i in range(n): for j in range(i + 1, n): D[i][j] = D[j][i] = metric(items[i], items[j]) return D def silhouette(clusters, D): """clusters 是下标列表的列表,返回整体轮廓系数""" if len(clusters) < 2: return -1.0 scores = [] for ci, c in enumerate(clusters): for i in c: a = sum(D[i][j] for j in c if j != i) / max(len(c) - 1, 1) b = min( sum(D[i][j] for j in other) / len(other) for k, other in enumerate(clusters) if k != ci ) m = max(a, b) scores.append((b - a) / m if m > 0 else 0.0) return sum(scores) / len(scores) def hierarchical(stays, metric): """自底向上凝聚,返回 R 最大的聚类结果与完整历史""" n = len(stays) D = dist_matrix(stays, metric) clusters = [[i] for i in range(n)] best = {"R": -2.0, "clusters": None} history = [] while len(clusters) > 1: bi = bj = None bd = float("inf") for i in range(len(clusters)): for j in range(i + 1, len(clusters)): # 平均距离:两簇内所有点对距离的均值 d = sum(D[a][b] for a in clusters[i] for b in clusters[j]) \ / (len(clusters[i]) * len(clusters[j])) if d < bd: bd, bi, bj = d, i, j merged = clusters[bi] + clusters[bj] clusters = [c for k, c in enumerate(clusters) if k not in (bi, bj)] + [merged] R = silhouette(clusters, D) history.append({"k": len(clusters), "R": R}) if R > best["R"]: best = {"R": R, "clusters": [list(c) for c in clusters]} return best, history距离矩阵只算一次是关键优化。每轮合并后簇结构变了,但点与点之间的距离不变,所以 D 可以全程复用;如果不缓存,每一轮都要重算两两距离,实际耗时会翻好几倍。合并时用len(clusters[i]) * len(clusters[j])做分母算平均距离,注意这个分母是点对数而不是簇数,写成簇数会让平均距离偏大,导致合并顺序整体错乱。
需要明确的一点是复杂度。每轮合并要遍历所有簇对,簇对的平均距离计算又是两层内循环,整体是 O(n³);每轮还要算一次轮廓系数,同样是 O(n²·k) 量级。原文给出的时间与空间复杂度都是 O(n³),实际实现里空间可以压到 O(n²)——只保留距离矩阵,不保留每轮的簇结构快照。以两千个停留点估算,n² 是四百万,n³ 是八十亿,前者勉强能扛,后者必须靠 language 层面的优化或者换 linkage 实现来兜底,这点在做方案预算时要提前算清。
3.3 离群停留点的判定与停留区域生成
拿到最优聚类结果之后,哪一簇算异常?原文的做法是取最小的那一簇作为离群停留点集。这个判断有其合理性:经常去的区域会积累大量停留点,簇一定大;只去过一两次的地方停留点少,簇一定小。但直接用「最小」有风险,如果所有停留点分布比较均匀,最小簇可能只是「稍微少一点」的常规区域,会被误判成异常。
我一般会在「最小簇」之外再叠两个条件,让它更稳。第一是占比约束:这一簇的点数占全部停留点的比例要低于 5%,否则不算离群。第二是距离约束:这一簇的质心到最近的其他簇质心的距离,要大于两倍的空间阈值 Wd,否则说明它其实紧挨着某个正常区域,只是被切开了。两个条件都满足才判为异常停留区域。
def find_outlier_clusters(stays, clusters, wd=100.0, ratio=0.05): """在最优聚类结果里筛出离群簇,返回异常簇与正常簇""" total = len(stays) stats = [] for c in clusters: lon = sum(stays[i].lon for i in c) / len(c) lat = sum(stays[i].lat for i in c) / len(c) stats.append({"idx": c, "n": len(c), "lon": lon, "lat": lat}) stats.sort(key=lambda s: s["n"]) outlier, normal = [], [] for s in stats: others = [o for o in stats if o is not s] if not others: normal.append(s); continue d_min = min(to_meters(Point(s["lon"], s["lat"], 0), Point(o["lon"], o["lat"], 0)) for o in others) is_out = s["n"] / total < ratio and d_min > 2 * wd (outlier if is_out else normal).append(s) return outlier, normal这里把每个簇压成了一个质心点参与距离计算,是为了让判定逻辑足够轻。真正生成停留区域时,用的是正常簇的原始点集——每个正常簇就是一个停留区域,区域内的停留点构成该区域的活动记录。这些区域接下来会作为第二层的切分依据:落在同一个区域内部的轨迹段被排除,跨越两个不同区域的轨迹段进入二次聚类。
ratio和2 * wd这两个值不是死的。数据量小的时候(比如只有几十个停留点),5% 的占比门槛会把正常的小簇也划进去,这时候应该放宽到 10%;如果用户活动范围本来就小,两倍 Wd 的距离约束可能永远不满足,那就只保留占比约束。参数要跟着数据规模走,照抄实验值是最常见的翻车姿势之一。
4. 第二层聚类:轨迹段距离度量与异常运动段检测
4.1 三个分量:速度、角度、位置
第一层聚的是点,距离用欧式就够。第二层聚的是段,两条轨迹段之间的「不一样」不止一个维度:走的快慢不同、转向的角度不同、经过的位置不同,都算异常。所以轨迹段距离必须是多个分量的加权组合,原文给的是速度距离、角度距离、位置距离三项加权。
速度距离最直白,就是两条段各自平均速度的差值。段内每个轨迹点的瞬时速度由相邻点距离除以时间差得到,整段的平均速度是这些瞬时速度的均值。两条段平均速度一减,量纲是 m/s。
角度距离看的是转向。对段内每个中间点 gᵢ₊₁,用前一个点 gᵢ、当前点 gᵢ₊₁、后一个点 gᵢ₊₂ 三条连线构成一个偏转角 βᵢ₊₁,也就是进入方向和离开方向的夹角。两条段的角度距离,是它们各自所有偏转角之差的绝对值求和。这个量能抓住「绕路」「急转」这类行为,比如正常走直线过马路,异常轨迹可能绕了一个大圈。
位置距离用 Hausdorff 距离。它的定义是:先对段 P 里每个轨迹点,找它在段 Q 中距离最近的点,取这些最近距离的最大值;再反过来对 Q 做一次;两个最大值取大的那个。Hausdorff 的好处是不要求两条段长度相同、点一一对应,对采样频率不一致的数据很友好。缺点是它对离群点极其敏感——一条段里只要有一个漂移点,整个 Hausdorff 距离就被它拉满。所以第一层清洗里那个「反复扫直到收敛」的动作,在这里体现出价值:没清干净的跳点会直接污染位置距离。
def seg_velocity(seg): """段内所有相邻点的瞬时速度均值,单位 m/s""" vs = [] for a, b in zip(seg, seg[1:]): dt = b.ts - a.ts if dt > 0: vs.append(to_meters(a, b) / dt) return sum(vs) / len(vs) if vs else 0.0 def deflection(seg): """段内所有中间点的偏转角,单位弧度""" angles = [] for a, b, c in zip(seg, seg[1:], seg[2:]): v1 = (b.lon - a.lon, b.lat - a.lat) v2 = (c.lon - b.lon, c.lat - b.lat) 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))) angles.append(math.acos(cos)) return angles def hausdorff(p, q): """双向最近点最大距离,对采样频率不一致的段也能比""" def one_way(a, b): return max(min(to_meters(x, y) for y in b) for x in a) return max(one_way(p, q), one_way(q, p)) def seg_distance(p, q, w=(1/3, 1/3, 1/3), scale=(1.0, 1.0, 1.0)): """三分量加权距离;scale 负责把三个分量归一化到可比的量级""" dv = abs(seg_velocity(p) - seg_velocity(q)) / scale[0] ap, aq = deflection(p), deflection(q) m = min(len(ap), len(aq)) da = sum(abs(x - y) for x, y in zip(ap[:m], aq[:m])) / scale[1] if m else 0.0 dl = hausdorff(p, q) / scale[2] return w[0] * dv + w[1] * da + w[2] * dlseg_distance里那个scale参数是原文没有明说、但实现时绕不过去的一环。速度差是几 m/s 的量级,偏转角差累加后是几弧度的量级,Hausdorff 是几百米的量级,三者不加处理直接加权,位置距离会压倒性主导,速度和角度基本等于没参与。scale应该取各自分量的经验量级,比如速度取全体段速度标准差、角度取 π 的倍数、位置取两倍 Wd,把三者拉到同一量级再乘权重。
4.2 权重取值与归一化顺序
原文实验里三个权重都取 1/3。这个取值背后的假设是三个分量同等重要,放在没有先验的场景下是合理的默认值。但真正决定结果是否可用的不是权重,而是归一化顺序——先归一化再乘权重,还是先乘权重再归一化,结果完全不同。
正确的顺序是:先算原始分量,用全局统计量把每个分量缩放到标准差为 1 的尺度,再乘权重求和。这样权重才真正表达「我更在意哪个维度」。如果反过来先乘权重再归一化,等于把权重的效果直接归一化掉了,三个权重设成多少结果都一样,调参就变成了自欺欺人。
权重的调整方向也要看业务。做校园场景的学生行为分析,位置和角度更关键,速度可以降到 0.2;做车辆轨迹分析,速度权重应该提上来,因为超速和急刹是主要异常形态。我一般会保留一组默认权重跑通流程,再用一小批带标注的数据做网格搜索,把三组权重的比例在 [0.1, 0.8] 区间内粗扫一遍,看哪一组在召回和误报之间平衡得最好。
| 参数 | 原文实验取值 | 含义 | 调整建议 |
|---|---|---|---|
| Vmax | 1000 km/h | 清洗速度上限 | 秒级采样压到 200 以内 |
| Wd | 100 m | 停留点空间阈值 | 一栋楼取 80~120,一个街区取 200 |
| Wt | 30 min | 停留点时间阈值 | 吃饭/上课 30 min,短停留场景 10 min |
| k_min | 3 | 停留点最少点数 | 采样稀疏时可放宽到 2 |
| w₁/w₂/w₃ | 各 1/3 | 速度/角度/位置权重 | 车辆场景提速度,室内场景提位置 |
4.3 二次聚类与异常轨迹段输出
第二层的聚类流程和第一层完全一样,只是输入换了:第一层输入是停留点集合、距离函数是点间欧式距离;第二层输入是区域间轨迹段集合、距离函数是三分量加权距离。原文对此的处理很干净——只需要把算法 2 的输入替换掉,距离方法替换掉,其余逻辑原样复用。这也是这套双层方案在工程上好维护的原因,一套聚类引擎被调用了两次。
进入第二层的轨迹段要满足两个条件:起点和终点分属两个不同的停留区域,且这两个区域都是第一层判定的正常区域。起点或终点落在异常停留区域的段要单独拎出来,它们本身就是异常的延伸,再混进正常段里聚类会把距离分布整体拉歪。
def detect_anomalous_segments(segments, w, scale, ratio=0.05): """segments: [(seg_points, from_region, to_region), ...],只含区域间段""" if len(segments) < 3: return [], segments # 段太少,聚类没有统计意义 metric = lambda a, b: seg_distance(a[0], b[0], w=w, scale=scale) best, history = hierarchical(segments, metric) clusters = best["clusters"] total = len(segments) result = [] for c in sorted(clusters, key=len): if len(c) / total < ratio: result.extend(segments[i] for i in c) else: break return result, history输出结构建议直接给成能落库的形态,每一条异常段带上起止区域 ID、起止时间、段内点数、以及触发它的距离分量。只给「这段是异常的」在答辩时说服力不够,能说清「因为速度分量偏离到 3.2 个标准差」才是可复现的结论。ratio沿用第一层的占比思路,段数量少的时候直接返回空——三条段的聚类结果没有任何统计意义,硬凑出来的「异常」只会让人怀疑整套方法。
到这里两层就跑完了。第一层输出异常停留区域,第二层输出异常运动段,两者合起来构成完整的异常轨迹结论。原文在公开 GPS 轨迹数据集上做过对比,选取单个用户 248217 个轨迹点的数据,与几种已有方法比较运行时间,结论是检测更全面、速度更快。这类对比的意义不在于跑分,而在于说明一个事实:先把数据压成停留点,后面的开销就落在可控的量级上。
5. 避坑与排查:这套双层聚类最容易翻车的五处
5.1 现象:清洗只跑一遍,停留点数量异常偏多
现象很典型——清洗前后的点数只差几十个,提取出来的停留点数量却比预期多出两三倍,很多停留点的时长刚好卡在阈值边缘。翻代码会发现清洗函数是个单层 for 循环,删完跳点就返回了。
原因在于删除操作会制造新的相邻关系。原始序列里 A 和 C 之间隔着一个跳点 B,A→B 超速被删、B→C 超速被删,但 A→C 这一段从来没被检查过,如果 A 到 C 的距离除以时间差依然超速,它就留在序列里。这种残留点会把本来连续的停留段切成两截,每截单独判断时长,很容易掉到 Wt 以下被丢弃,或者两截各自成段导致停留点翻倍。
解决就是把清洗写成不动点迭代:每轮完整扫描,只要有任何一个点被删除就把 changed 置真,外层 while 继续跑,直到某一轮一个点都没删掉为止。经验上迭代 3 到 5 轮就收敛,代价可以忽略。判断是否真收敛还有个土办法——打印每轮删除的点数,如果最后一轮仍有非零值,说明循环条件写错了。
5.2 现象:阈值填 100,结果一个停留点都没提取出来
把 Wd 设成 100 之后跑出来空列表,或者只有一两个停留点。检查参数发现单位搞混了——代码里to_meters返回的是米,但某处比较时用的还是原始经纬度差,而经纬度差的值域是 0.001 量级,100 这个阈值相当于要求两点相距 100 度,地球上不存在这样的相邻点。
这类问题在混用地理库函数时特别容易发生。有的库返回度,有的返回米,有的返回投影后的坐标单位。统一口径的做法是:所有对外暴露的阈值参数一律以米为单位,所有内部计算一律先过to_meters,绝不让未经换算的经纬度差参与任何比较。投影近似里那个 cos 系数也别忘了,纬度 40 度的地方 cos 约 0.77,漏掉它会让东西向距离虚高 30%,停留点会比预期更少。
另一个相关陷阱是时间单位。Wt 在定义里是「秒」,但如果从数据库读出来的时间戳是毫秒,1800 就成了 1.8 秒,所有停留都会被判为有效。读取时统一除以 1000,或者在入口处断言一下时间戳量级。
5.3 现象:轮廓系数选出的簇数,聚类结果完全不可用
R 值算出来最大是 0.62,看着不错,但对应的聚类结果是三个簇,其中两个簇各含一个点。也就是说算法把绝大多数点塞进一个大簇,剩下两个点单独成簇,轮廓系数因为「簇间距离大」而给出了高分。
这是轮廓系数在小簇上的固有偏好。单点簇的 a(i) 无定义(分母用 max(len−1, 1) 兜底为 1,实际取 0),b(i) 又很大,单点自身的轮廓系数接近 1,直接把整体均值拉高。停留点数据里本来就存在少量真正的离群点,这个偏好会被放大。
解决是在扫描 R 的同时加一条硬约束:簇内点数不能少于总点数的 2%,少于 2% 的簇不参与 R 的计算,或者直接把这条聚类结果从候选里剔除。另外可以把 R 的历史曲线打出来看,正常的曲线应该有一个明显的单峰,如果 R 在接近全并成一簇时还在上升,说明数据本身没有明显的簇结构,这时候任何簇数都是强行切出来的。
5.4 现象:新用户第一次跑,所有停留点都被判成异常
换一个只有几十条轨迹的新用户数据,第一层聚完,最小的簇占比 100%,ratio < 0.05永远不成立;放宽到 0.5 之后,又有一半的簇被划成异常。整个结论没法用。
原因在于离群判定依赖的「簇大小差异」这个前提,在数据量不足时不成立。一个用户如果只记录了三天,他去过的每个地方都是「第一次去」,每个区域的停留点数量差不多,最小簇并不比别的簇特殊。这不是算法错了,是数据还不够支撑「常态」这个概念。
处理方式分两档。数据量低于一定门槛(比如停留点少于 30 个)时,直接跳过离群判定,只输出停留区域划分,把异常检测标为「样本不足,仅供参考」。数据量中等时,用绝对数量替代占比:簇内点数少于 3 个才判为离群,同时把距离约束的门槛从 2×Wd 提到 3×Wd。这套组合拳的实质是承认「小样本下只能抓极端离群」,而不是硬凑一个看起来完整的结论。
5.5 现象:第二层聚类的异常段里,混进了大量区域内位移
跑完两层,异常轨迹段列表里有一半是「同一个商场内从东门走到西门」这种段。检查输入发现,进入第二层的段集合里确实包含了区域内部的段。
原因通常出在区域归属的判断上。第一层聚出来的簇是点集的索引列表,生成区域时如果只存了区域的质心没存成员索引,第二层判断某个停留点属于哪个区域时就得重新算最近质心,边界上的点很容易被分到隔壁区域,导致本该是「区域内」的段被判成「区域间」。
解决是让第一层把成员索引原样传下去,每个停留点带一个明确的 region_id,第二层切段时直接查表,不做任何距离重算。另外要顺手处理一种边界情况:起止点属于同一个 region_id 但时间跨度超过一天的段,它可能是区域内的一次长时位移(比如在一个园区里从宿舍走到教学楼),也可能是数据缺失导致的段跨越,这类段建议单独标记待人工确认,不要直接扔进聚类。
6. 验收与答辩:怎么证明这套检测既快又全
验收这件事,最忌讳只报一个「准确率 92%」。这类无监督方法没有天然标签,单点指标撑不住追问。我一般会准备三组材料,答辩时按顺序摆出来。
第一组是耗时对比。同一份数据,分别跑「直接对原始轨迹点聚类」和「先压停留点再双层聚类」,记录端到端的墙钟时间。原文在公开数据集上做的就是这件事,单用户 248217 个点,对比几种已有方法的运行时间。这组数据的说服力在于它不依赖标注,谁都能复现,而且能直接回答「为什么要多这一层停留点提取」。
第二组是小样本人工标注上的检测效果。从数据里抽 200 条轨迹段,人工标出哪些是绕路、哪些是异常停留,然后跑一遍流程看命中情况。数量不用多,够算召回和精确率就行,关键是要把标注口径写清楚——什么样算绕路、多长的停留算异常,这些定义得在答辩前定死,不能跑完再看结果改口径。
第三组是参数敏感性。固定其他参数,把 Wd 从 50 到 200、Wt 从 10 分钟到 60 分钟各扫一遍,看异常数量和耗时的变化曲线。这组材料的作用是证明结论不是靠某一组「调出来的魔法参数」得到的,参数在合理区间内波动时结论是稳的。
| 验收项 | 检查内容 | 通过标准 |
|---|---|---|
| 数据清洗 | 相邻点速度分布 | 无超过 Vmax 的相邻对,迭代至收敛 |
| 停留点提取 | 停留点数量与时长分布 | 数量在原始点数的 1% 量级,时长中位数接近 Wt 的 2~3 倍 |
| 第一层聚类 | 轮廓系数历史曲线 | 有明显单峰,最优簇数大于 2 |
| 异常停留区域 | 离群簇占比与距离 | 占比低于 5%,质心距大于 2×Wd |
| 第二层聚类 | 区域间段占比 | 输入段中区域内段占比为 0 |
| 端到端耗时 | 与基线对比 | 明显快于直接对原始点聚类 |
答辩时被问得最多的三个问题,提前准备好答案基本就稳了。第一个是「为什么是两层而不是一层」——因为停留异常和运动异常是两种不同性质的问题,前者是空间密度离群,后者是路径形态离群,用同一个距离度量去抓必然有一类抓不准。第二个是「O(n³) 怎么敢用」——因为第一层输入已经被压到千级,且距离矩阵只算一次;如果数据量再上一个量级,就要换 linkage 实现或者先做空间网格粗划分。第三个是「参数怎么定的」——给敏感性曲线,说明结论在参数区间内稳定,而不是只给一组数字。
从那以后我每次做这类无监督的检测方案,都强制先跑一遍「空模型」——把所有参数设成最宽松的值,看流程能不能输出一个空结果而不报错。这个习惯是被一次答辩问住的经历换来的:当时评委问「如果这个用户根本没有异常,你的程序会输出什么」,现场才发现代码里有个除零的分支,空输入直接崩了。空模型跑通,才说明这套流程是真的能交付,而不是只能在演示数据上好看。希望帮到你。
本文还有配套的精品资源,点击获取