老实讲,驾驶行为分析这个方向我关注了很久,市面上能拿到的公开轨迹数据集本身就不多,而像NGSIM这样带完整车辆轨迹、时间戳和局部坐标的开源数据,几乎算是做驾驶风格研究的“标准练习册”了。最近我正好把一个基于NGSIM的驾驶风格识别小项目跑通了,核心链路就是从原始轨迹里提取纵向和横向的行为特征,再用高斯混合模型自动聚类出不同的驾驶风格群体。这活儿做完以后,我很确信这套流程不只是实验室作业,它对辅助驾驶参数标定、车队安全管理和车险定价都有直接的参考意义。这篇就把整个项目的过程、每个环节的选择逻辑和踩过的坑原原本本写出来,给后续要做类似工作的朋友一条可以照抄的路线。
1. 数据准备与预处理
1.1 NGSIM数据集长什么样
NGSIM(Next Generation Simulation)是由国外某交通研究机构发布的一套自然驾驶轨迹数据,它的最大特点是用路边高架摄像机连续拍摄真实车流,再通过图像识别技术把每一辆车的运动轨迹提取出来。拿到手的数据是典型的CSV表格,每个文件里包含多条车辆轨迹,采样频率是10Hz,也就是说每隔0.1秒记录一次车辆的位置、速度、加速度,以及所在车道、前后车关系等关键信息。这类数据对研究驾驶行为特别友好,因为它是真实道路上的自然驾驶,不是实验室里被试者故意表演出来的,因此有很高的生态效度。
我用的数据是其中的高速公路路段数据,具体是某个工作日下午大约45分钟的路段记录,覆盖一条主线和一个交织区。原始CSV里的核心字段包括车辆ID、帧ID、时间戳、局部坐标X/Y、车速、加速度、所在车道编号、前车ID、后车ID,以及跟车距离。读取之后你会发现数据没有想象中的干净,树荫遮挡、车辆换道时ID切换、偶尔的图像识别抖动都会造成轨迹毛刺,所以在提取特征之前,必须先做清洗和重构。
1.2 轨迹清洗与平滑
拿到原始数据的第一个操作是按车辆ID分组,把每一辆车的行驶过程拼成一条连续轨迹。这个看起来简单,实际坑不少。NGSIM里不是每辆车都会从头到尾出现,很多车在某个帧突然出现又突然消失,那是因为它开进了摄像机视野边缘或者被大车遮挡了。我设了一个清洗规则:单条有效轨迹至少连续出现25秒,否则丢弃。这个阈值不是拍脑袋定的,因为后面要算跟车时距、加速度分布这类统计特征,样本太短计算出来的统计量方差巨大,聚类结果会跟着失真。
轨迹平滑也是必须做的一步。原始数据虽然提供了加速度字段,但那是从位置差分得到的,噪声相当大。我试过直接拿原始加速度算急加速频率,结果出现一秒内的加速度突变值达到每秒几米级别,这明显不符合真实车辆物理特性。后来我用Savitzky-Golay滤波器对速度序列做平滑,窗口取11帧,多项式阶数取3,再用平滑后的速度差分出加速度。这个组合我实际跑下来噪声抑制效果好,对峰值的保留也比简单滑动平均强不少,急加速事件不会被糊掉。
import pandas as pd import numpy as np from scipy.signal import savgol_filter # 按车辆ID和时间戳排序 df = df.sort_values(['Vehicle_ID', 'Frame_ID']).reset_index(drop=True) # 只保留连续轨迹足够长的车辆 valid_ids = df.groupby('Vehicle_ID')['Frame_ID'].apply( lambda x: x.max() - x.min() >= 250 # 0.1s一帧,250帧=25秒 ) df = df[df['Vehicle_ID'].isin(valid_ids[valid_ids].index)] # 对每辆车分别做速度平滑,再重新计算加速度 smoothed_speeds = [] for vid, group in df.groupby('Vehicle_ID'): spd = group['Velocity'].values if len(spd) >= 11: spd_s = savgol_filter(spd, window_length=11, polyorder=3) else: spd_s = spd smoothed_speeds.append(spd_s) df['Velocity_Smooth'] = np.concatenate(smoothed_speeds) df['Acc_Calc'] = np.gradient(df['Velocity_Smooth'], 0.1)平滑完加速度后,还有一个容易忽略的点:车辆长时间停车状态会带来大量零速度、零加速度的帧。比如车辆在匝道出口排队,这一分钟它压根没有驾驶行为可言,但统计特征会被这些静置帧稀释。我保留这些帧单独算“停车占比”这个指标,而计算加加速度和急加减速频率时则先把速度低于0.5米/秒的帧剔除,保证动态特征集中反映真正的“驾驶动作”。
2. 驾驶风格特征工程
2.1 特征选择思路
驾驶风格这个词听起来抽象,落到数据上其实可以拆成几个维度:纵向激进程度、横向稳定性、跟车风险习惯、对加减速的控制能力。每个维度都要选出有代表性的统计指标,而且指标之间不能高度共线,否则聚类结果会被某一类特征主导。我最初一口气算了二十多个特征,跑完相关性矩阵后发现很多指标冗余,比如平均加速度和加速度标准差相关性超过0.8,保留一个就够了。这一步“做减法”很重要,后续GMM在高维小样本下容易过拟合,特征精简后模型稳定性和可解释性都明显提升。
2.2 纵向驾驶特征
纵向维度是驾驶风格最直观的体现,我最终留下了四个特征:
第一个是正加速度的95分位数。相比平均加速度,分位数能更好地刻画“急”的程度,因为日常巡航状态下平均值几乎都是零点几,根本区分不出风格差异,但95分位数会显著拉开激进驾驶者和保守驾驶者的差距。
第二个是急减速事件频率,定义是加速度小于-3米每秒平方且持续超过0.3秒的次数,归一化到每千秒频次。高速公路上急减速往往发生在车流密集或者前车突然刹车时,习惯贴着前车跑的驾驶员这类事件频率通常更高。
第三个是加加速度的均方根值,也就是jerk的RMS。这个指标衡量的是加速度变化的剧烈程度,数值越大说明驾驶员操作越“突兀”,松踩踏板不够平顺。很多人可能忽略jerk,但我实测下来它对风格聚类的区分度比单纯加速度标准差还要好。
第四个是巡航速度的85分位数。虽然跟风格没有绝对关系,但综合同路段的流量状态后,它仍然能反映驾驶员对速度的偏好倾向。
2.3 横向与跟车特征
横向稳定性选了两个特征:横向速度标准差和每千秒换道次数。NGSIM里有一个字段是车辆中心的横向坐标,我对它做差分得到横向速度,然后算标准差。这个值大说明车辆在车道内频繁轻微摆动,反映的是驾驶员的注意力分配和操控精细度。换道次数可以直接从车道编号的变化统计出来,喜欢频繁变道超车的驾驶员显然和常年守着一条道的不是同一类人。
跟车行为特征中,我觉得最实用的是跟车时距THW(Time Headway)的中位数,THW等于车头间距除以当前车速,物理含义是“如果前车突然刹停,我多久会撞上去”。这个指标能体现驾驶员对安全距离的耐受程度,THW常年小于1秒的驾驶员风格激进程度基本不会低。顺便提一句,THW只在前方有车时才有定义,没有前车自由行驶的片段要单独剔除,否则会把无穷大值混进来。
到这里,我整理了一张表记录每个特征的计算方式和对应场景,方便后面聚类结果不理想时回溯问题:
| 特征名 | 计算方式 | 代表的风格维度 |
|---|---|---|
| 正加速度95分位数 | 加速度序列>0部分的分位数 | 急加速倾向 |
| 急减速事件频率 | 加速度<-3m/s²且持续0.3s以上事件的千秒频次 | 紧急制动习惯 |
| 加加速度RMS | 加速度差分的均方根 | 操作平顺性 |
| 巡航速度85分位数 | 速度序列的85分位数 | 速度偏好 |
| 横向速度标准差 | 横向坐标差分后的标准差 | 车道内稳定性 |
| 千秒换道次数 | 车道编号变化次数/时长*1000 | 变道激进程度 |
| THW中位数 | 车头间距/车速,取中位数 | 跟车安全距离习惯 |
不同特征之间的量纲差异很大,有的零点几,有的几百,直接丢进聚类模型会让数值大的特征主导距离计算,所以聚类前必须做标准化。这一步千万别省,很多初学者拿到数据直接GMM,结果聚类结果是按速度尺度切开的,而不是按行为风格切开的,问题就出在没有标准化。
3. 高斯混合聚类实现
3.1 为什么选高斯混合模型
驾驶风格聚类这件事,K-Means也能做,但有一个本质区别:K-Means是硬划分,每个样本要么属于A类要么属于B类,且它假设簇是球形且大小相近。而现实中的驾驶风格存在大量“中间派”,比如一个驾驶员可能在高速上开得很激进,但到了交织区又变得极其保守,你很难非黑即白地把他划进某一类。高斯混合模型(GMM)是软聚类,它假设所有样本来自若干个高斯分布的叠加,每个样本属于每个类是一个概率值,而不是0或1。
具体原理上,GMM用多个二维或高维高斯分布来拟合数据分布,每个高斯分布代表一个潜在风格簇。模型训练通过EM算法迭代:E步根据当前参数计算每个样本属于每个高斯的概率(责任度),M步用这些责任度加权更新每个高斯的均值、协方差和权重。迭代到对数似然收敛,就得到了一个概率化的聚类结果。这种软划分对驾驶风格这个场景特别合适,因为驾驶员风格本来就是一个连续谱,硬贴标签反而失真。
3.2 特征标准化与分量数选择
训练GMM之前,我先把特征做了标准化,用的StandardScaler,保证每个特征方差为1均值0。这一步让我在实验对比中明显看到聚类轮廓系数上升,因为消除了量纲干扰。
接下来是选K,也就是高斯分量个数。GMM不会自己决定分成几类,需要我们先给一个K。业界最常见的做法是画BIC曲线,BIC(贝叶斯信息准则)在似然值的基础上加了一个对参数数量的惩罚项,避免模型为了拟合数据无限增加分量。公式是BIC等于负二倍对数似然加上参数数量乘以样本数量的自然对数。我遍历K从1到6,跑每个模型的BIC,选BIC值最小的K。
有一点要单独提醒:BIC最小值有时候不是那么尖锐,K=3和K=4的BIC可能差很小。这时候可以看一下每个簇的样本占比,如果某个簇占比不足5%,说明K选大了,那个簇大概率是从一个连续分布里硬切出来的细碎部分,业务上没有独立存在的意义。我最后选了K=3,三类风格分别对应保守型、普通型和激进型,每一类占比都超过了20%,解释起来也很顺。
3.3 聚类代码与训练过程
代码用scikit-learn的GaussianMixture就能完成,不需要自己实现EM。但有几个参数值得手动调一下:n_init建议设10以上,因为EM算法对初始值敏感,多跑几次取最优的才稳定;covariance_type我试过full和diag两种,full对特征间相关性建模更完整,但参数数量随特征数平方增长,在样本量只有几万的时候容易过拟合,diag模式参数量更少,跑出来的结果在轮廓系数上差不太多。我最后用了full,因为能捕捉到“激进驾驶往往伴随频繁换道”这种特征相关性。
from sklearn.preprocessing import StandardScaler from sklearn.mixture import GaussianMixture from sklearn.metrics import silhouette_score import numpy as np # feature_matrix是车辆级别的特征矩阵 scaler = StandardScaler() X_scaled = scaler.fit_transform(feature_matrix) # 遍历K值,记录BIC和轮廓系数 bic_scores = [] sil_scores = [] K_range = range(1, 7) for k in K_range: gmm = GaussianMixture(n_components=k, covariance_type='full', n_init=10, random_state=42) gmm.fit(X_scaled) bic_scores.append(gmm.bic(X_scaled)) if k >= 2: labels = gmm.predict(X_scaled) sil_scores.append(silhouette_score(X_scaled, labels)) # 选K=3重新训练,保存预测概率 best_k = 3 gmm_final = GaussianMixture(n_components=best_k, covariance_type='full', n_init=10, random_state=42) gmm_final.fit(X_scaled) prob = gmm_final.predict_proba(X_scaled) labels = gmm_final.predict(X_scaled)跑完K=3的模型后,我顺手看了每个样本的最大责任度概率。大部分样本的归属概率都在0.8以上,说明三类之间边界还算清晰。但也有大概15%的样本最大归属概率只有0.5左右,这些就是前面说的中间派驾驶员,他们的特征在两个簇之间摇摆。这恰好是GMM相比K-Means的优势,我可以用这个概率值做“风格置信度”的输出,而不是直接硬着嘴说他就属于某一类。
4. 聚类结果分析与场景落地
4.1 三类风格画像
聚类完成后,我把三个簇的特征均值做了对比,结果非常符合直觉。保守型驾驶员的正加速度95分位数最低,千秒换道次数很少,THW中位数明显偏大,说明他们跟前车的距离余量给得很足,基本不怎么急加速也不乱变道。激进型驾驶员几乎每项指标都相反,急减速事件频繁,加加速度RMS高出保守型一截,THW经常压到1秒以内。普通型居中,但有趣的是普通型在横向速度标准差上和保守型比较接近,差异主要集中在纵向维度的加速度分布上,这说明大多数驾驶员在方向盘操作上差别没那么大,真正区分风格的主要在油门和刹车脚法。
我还画了每个特征在三个簇之间的箱线图,发现不同簇的重叠区主要集中在中间带。也就是说,单纯一个特征很难把三类完全分开,必须用多维特征组合才能稳定区分。这解释了为什么这个项目值得用聚类来做,而不是靠单一指标设阈值一刀切。
4.2 聚类稳定性验证
聚类模型最怕的是结果只是对当前这批数据的偶然拟合。我做了两轮稳定性验证。第一轮是把样本随机打乱后按7比3切训练集和测试集,单独对训练集重新拟合GMM,再把测试集映射到训练好的模型上,对比两种方式下测试集样本的标签一致性。我算出的调整兰德指数(ARI)大约是0.83,说明标签跨批次稳定性可以接受。
第二轮是减少特征数量,看看聚类结果是否依赖于特定特征。我发现去掉横向速度标准差以后,大多数样本的标签保持不变,但去掉THW中位数以后,保守型和普通型之间的边界明显漂移。这提示THW是区分这两类的关键特征,后续如果采集数据时跟车距离字段缺失,需要谨慎对待模型输出。
4.3 结果能用在哪些场景
这个聚类模型做完以后可不是躺在笔记本里的。我至少想到了三个落地方向。第一个是车队管理,通过车载终端上传轨迹数据,每天自动更新每个司机的风格标签,把连续N天被标为激进型的司机拉出来做专项培训,比单纯靠事故记录去做事后管理要前置得多。
第二个是辅助驾驶参数标定,量产车上自适应巡航的跟车距离和舒适加减速阈值,完全可以依据驾驶风格动态调整,激进型用户给一个较短的默认跟车时距,保守型用户给一个更保守的设定,这样能提升系统对不同用户的适配度。
第三个是保险定价,尤其是按里程付费的险种,驾驶风格特征可以直接作为风险分层的输入。同样的行驶里程,急加速频繁和THW过短的用户未来出险概率理论上更高,保费里应该体现这个差异。当然这个方向涉及数据合规和个人隐私保护,能不能商用要打一个问号,但技术链条是通的。
5. 常见问题与排查经验
这部分是我实际跑数据过程中遇到的典型问题,每一条都是拿时间换来的教训。
5.1 拿不到理想的聚类效果
如果你跑出来的GMM聚类轮廓系数很低,比如连0.3都不到,先别急着换算法。我遇到过的第一个原因是特征里混入了异常值,尤其是急减速频率这个特征,某辆车因为数据抖动被算出了几十次根本不存在的急减速,直接拉坏了整个特征分布。解决办法是先对每个特征做箱线图检查,把超过四分位距三倍以上的样本单独审查,确认不是真实行为再剔除。
第二个原因是时间窗口问题。NGSIM数据只有几十分钟,不代表每辆车都有机会展现完整的风格。有的车在数据里只有5分钟,还全堵在路上,平均速度低得可怜。这类样本的瞬时特征根本不能代表驾驶员的真实习惯。后来我加了一条硬性规则:参与聚类分析的车辆轨迹时长必须在5分钟以上,否则只保留给后续出行特征分析,不进聚类模型。
5.2 分量数选择纠结
刚才提到BIC有时候对K不敏感,这时候别只盯着数值。一个实用的做法是看每个簇的责任度概率分布:把每个样本在那个簇里的最大概率画出来,如果所有簇的最大概率都集中在0.4到0.6,说明这个K值下的簇划分很模糊,样本在两个簇之间来回漂,这时候要么降K,要么看一下是不是特征空间本身没有明显的多峰结构。我刚开始一度想强行分4类,结果第四个簇的责任度概率最高也就0.5,最后还是回归到3类,更干净也更好解释。
5.3 特征分布偏斜严重
特征工程里有一类特征,比如急加速事件频率,它的分布是高度右偏的,大部分司机事件数很少,极少数司机事件数很多。如果不做任何处理直接标准化,大多数样本会被压向负值区间,离群点却甩得很远,模型会把离群点单独切出一类来。后来我做了Box-Cox变换把分布往正态方向拉了一下,再去标准化,聚类结果才回归正常。碰到类似计数类特征,建议都先做分布变换,不要直接放进高斯模型,毕竟GMM的核心假设就是每个簇服从高斯分布,输入特征也得大致满足这个前提。
5.4 计算性能问题
NGSIM单段数据只有几十万行,跑起来其实很快,但要处理多段甚至跨多个文件的数据时,逐帧Python循环会成为性能瓶颈。我一开始用for循环逐行算跟车时距,跑一个下午才处理完一段路,后来改成向量化的pandas操作,同样的任务十几秒就完成了。如果是更大规模的数据集,建议用polars或者干脆把特征聚合写成SQL查询算子,效率差距是数量级的。
还有一点,GaussianMixture在训练时会把样本数乘以分量数再乘以迭代次数,如果样本量上了百万,内存占用会变得可观。这种场景可以先用MiniBatchKMeans做一次粗聚类得到中心点,再用这些中心点初始化GMM的均值参数,收敛速度和稳定性都会好不少,我后面的多路段实验中就是这么干的。
整个项目跑下来,我最深的感受是:驾驶风格分析最核心的功夫不在模型本身,而在特征定义和数据清洗。你把每一个物理量定义清楚,把每一条轨迹的异常清洗干净,GMM只是一个水到渠成的收尾工具。那个把NGSIM轨迹换算成THW和jerk分布的过程,才是整个项目里最花时间、也最值得反复打磨的地方。