
1. 自动驾驶决策规划测试的真实困境做自动驾驶决策规划系统的测试最让人头疼的不是写用例而是用例空间爆炸。一个简单的无保护左转场景如果把前车距离、对向车速度、行人位置、路面附着系数、光照条件这些参数各取几个值做笛卡尔积轻轻松松就能组合出几十万条测试用例。我试过用最朴素的网格搜索跑一遍光是仿真环境启动和场景加载的时间就够喝一壶的更别提后面还要跑评价指标。这就是为什么基于目标优化的自动化测试方法值得单独拿出来聊。它的核心思路不复杂把测试用例生成看作一个优化问题用贝叶斯优化或遗传算法这类黑盒优化器在参数空间里主动寻找那些最可能让决策规划系统“翻车”的角落。相比随机撒点或者暴力枚举同样的仿真预算下它能挖出更多边界场景和失效模式。这篇文章适合两类人看一类是刚接手自动驾驶决策规划测试、正被用例数量压得喘不过气的测试工程师另一类是想把优化算法落地到实际工程里的算法同学。我会把方案选型、参数设计、实操流程和踩过的坑都摊开讲尽量让你看完就能在自己的仿真环境里跑起来。2. 为什么是目标优化而不是随机测试2.1 决策规划系统的失效长什么样决策规划系统的输出不是单一数值而是一条轨迹或者一系列驾驶动作。它的失效模式也很“离散”可能是跟车距离过近、可能是换道时后车被迫急刹、可能是路口博弈时卡死不动、也可能是对弱势道路参与者VRU的避让过于保守导致通行效率骤降。这些失效往往只在参数空间的某些狭窄区域出现随机撒点很难命中。我做过一个统计在一个包含6个连续参数的跟车场景里随机采样2000次真正触发“碰撞时间低于1.5秒”的用例只有17条命中率不到1%。而用贝叶斯优化跑200次迭代能稳定找到30条以上同类失效用例。这个差距直接决定了你是在“碰运气”还是在“做测试”。2.2 贝叶斯优化和遗传算法的分工这两种算法我都实际用过它们的脾气不太一样。贝叶斯优化适合连续参数、仿真代价高、迭代次数有限的场景。它用高斯过程或树结构Parzen估计器建立目标函数的代理模型每次迭代都根据“提升期望”或“置信上界”来选择下一个采样点。优点是样本效率极高通常几十到几百次仿真就能收敛到失效区域。缺点是随着参数维度增加代理模型的拟合难度会上升一般建议控制在10维以内。遗传算法适合参数维度较高、目标函数不连续、或者需要一次性生成一批多样化用例的场景。它维护一个种群通过选择、交叉、变异来进化。优点是天然支持并行你可以一次跑几十个仿真缺点是需要的评估次数比贝叶斯优化多通常要几千次才能稳定收敛。我的建议是先用贝叶斯优化做精细搜索找到几个高价值失效区域再用遗传算法在这些区域附近做种群扩展生成一批多样性好的回归用例。两者不是二选一而是接力关系。2.3 目标函数怎么定义才不跑偏优化算法需要一个标量目标函数来指导搜索。但决策规划系统的评价指标往往有多个安全性、舒适性、通行效率、合规性。直接加权求和是最简单的做法但权重很难调而且容易让优化器只盯着某一个指标猛攻。我目前用的方案是分层目标函数第一层是硬约束比如碰撞、闯红灯、压实线一旦触发直接给一个很大的惩罚值第二层是软指标比如加速度均方根、 jerk 最大值、跟车时距用归一化后的加权和。这样优化器会优先寻找违反硬约束的用例同时在软指标上做细化。注意目标函数里一定要加入“场景有效性”判断。有些参数组合会导致仿真一开始就处于不合理状态比如自车初始位置已经在障碍物内部这类用例要直接过滤掉否则优化器会被噪声带偏。3. 自动化测试框架的搭建要点3.1 仿真环境与测试框架的接口设计不管你是用CARLA、Prescan还是自研仿真器测试框架和仿真器之间需要一个清晰的接口。我习惯把接口分成三层场景配置层接收一组参数生成仿真器能识别的场景文件比如OpenSCENARIO格式并启动仿真。执行控制层控制仿真步进、同步模式、超时处理确保每次仿真的时间步长和终止条件一致。结果采集层从仿真器里拉取自车轨迹、障碍物轨迹、碰撞检测结果、规划模块内部状态输出成结构化数据。这三层最好用进程隔离的方式实现。仿真器崩溃或者卡死时测试框架能杀掉进程并记录一条“无效用例”而不是整个测试任务挂掉。我早期偷懒把仿真器直接嵌在测试脚本里结果一个内存泄漏就让整晚的优化迭代全废了。3.2 用pytest组织测试用例的实践虽然优化算法是自动生成用例的但整个测试流程还是需要一个组织框架。我用pytest来管理测试会话主要看中它的fixture机制和参数化能力。一个典型的做法是把优化器的每一次迭代当作一个pytest测试项用pytest.mark.parametrize把参数组合注入。这样你可以直接用pytest的-n参数做并行也能用--html生成报告。但要注意优化算法本身是有状态的并行化时需要把“采样建议”和“结果回传”做成线程安全的队列否则多个worker会拿到重复的采样点。# 简化示意优化器与pytest的桥接 import pytest from bayes_opt import BayesianOptimization def run_simulation(params): # 调用仿真器返回目标函数值 return objective_value pytest.mark.parametrize(iteration, range(200)) def test_optimization_iteration(iteration, optimizer_state): next_point optimizer_state.suggest() value run_simulation(next_point) optimizer_state.register(next_point, value) assert value is not None实际工程里不会这么写因为pytest的参数化是静态的而优化采样是动态的。更合理的做法是用pytest的fixture维护一个全局优化器每个测试项从优化器拉取下一个采样点。但这样又失去了pytest的并行优势。我的折中方案是用pytest做单次批量测试的组织用独立的优化调度脚本做迭代控制。两者通过文件或消息队列交换数据。3.3 结果存储与失效用例回放每次仿真产生的数据量不小尤其是如果录了传感器数据。我的做法是只存结构化指标和轨迹摘要原始数据按需保留。具体来说每条用例存一个JSON参数组合、目标函数值、硬约束触发情况、软指标数值、仿真耗时。失效用例额外存一份轨迹文件自车和关键障碍物的位置序列方便后续回放分析。用SQLite做本地存储查询和筛选都方便不需要额外搭数据库服务。回放功能很重要。优化器找到的失效用例你需要能一键复现否则开发同学会怀疑你在编数据。我通常会在测试框架里加一个replay命令输入用例ID自动加载参数、启动仿真、并排显示规划轨迹和实际执行轨迹。4. 贝叶斯优化的参数调优与实操细节4.1 参数空间的定义与归一化贝叶斯优化对参数尺度比较敏感。如果某个参数范围是0到100另一个是0到1高斯过程会倾向于在数值大的维度上探索。所以所有连续参数都要归一化到[0, 1]优化完成后再映射回物理量。对于离散参数比如“是否开启某功能”或者“障碍物类型”有两种处理方式一是当作连续变量取整二是用one-hot编码后当作多个维度。我倾向于把离散参数单独拿出来做网格枚举只对连续参数做贝叶斯优化。这样维度更低收敛更快。一个跟车场景的参数空间示例参数名物理范围归一化范围类型初始跟车距离5~50 m0~1连续前车减速度-1~-6 m/s²0~1连续自车期望时距0.8~2.5 s0~1连续路面附着系数0.3~0.90~1连续前车类型{轿车, 卡车}—离散4.2 采集函数的选择与迭代终止条件贝叶斯优化常用的采集函数有EI期望提升、UCB置信上界和PI提升概率。我的经验是初期用UCB让优化器多探索不确定区域。中期切换到EI平衡探索和利用。后期如果已经找到满意的失效用例用PI做局部精细搜索。迭代终止条件不要只设最大迭代次数。我一般会同时监控连续N次迭代没有找到更优解、目标函数值超过阈值、总仿真耗时超过预算。三者满足其一就停。4.3 初始采样点的选择技巧贝叶斯优化需要一组初始点来拟合代理模型。纯随机采样在低维空间没问题但在5维以上随机点可能覆盖不均。我通常用拉丁超立方采样生成初始点保证每个维度的投影都均匀分布。初始点数量一般取参数维度的5到10倍比如6维参数就采30到60个点。实操心得初始点里最好手动塞几条“已知危险”的用例比如极小车距、极大相对速度。这样代理模型从一开始就有正确的梯度方向不会在安全区域浪费太多迭代。5. 遗传算法的种群设计与并行加速5.1 编码方式与适应度函数遗传算法的第一步是编码。对于连续参数我直接用实数编码每个个体就是一个浮点数向量。交叉操作用模拟二进制交叉变异用多项式变异。这套组合在连续优化里比较稳不会像二进制编码那样出现海明悬崖。适应度函数就是前面说的目标函数。但遗传算法是最大化适应度而我们的目标函数可能是“越小越好”比如碰撞时间。所以要么取负值要么用倒数变换。我习惯取负值简单直接。5.2 种群大小与进化参数种群大小和迭代次数是一对需要权衡的参数。种群太小多样性不足容易早熟收敛种群太大每代评估次数多总仿真次数爆炸。我的经验公式是种群大小 参数维度 × 10但不超过100。交叉概率 0.9变异概率 1/参数维度。迭代代数 50~100代配合精英保留策略每代保留最好的2~5个个体。这样算下来6维参数、种群60、迭代80代总评估次数约4800次。如果单次仿真平均30秒串行要40小时。所以并行是必须的。5.3 用多进程做仿真并行遗传算法的每一代里个体之间的评估是独立的天然适合并行。我用Python的multiprocessing.Pool做进程池每个进程独立启动一个仿真器实例。注意几个坑仿真器许可证有些商业仿真器按并发数收费开太多进程会被拒绝。提前确认许可证数量。资源竞争每个仿真器实例占用的CPU和内存不小进程数不要超过物理核心数。结果收集用Pool.map时如果某个仿真崩溃整个map会抛异常。建议用apply_async加超时单个失败不影响整体。from multiprocessing import Pool def evaluate_individual(params): try: return run_simulation(params) except Exception as e: return float(-inf) # 无效个体给最低适应度 with Pool(processes8) as pool: fitness_values pool.map(evaluate_individual, population)5.4 多样性保持与早熟收敛判断遗传算法跑着跑着种群可能都挤在一个局部最优附近。判断早熟收敛的简单方法是看种群适应度标准差如果连续几代都低于某个阈值说明多样性丢了。这时候可以随机重新初始化一部分个体。增大变异概率。引入小生境技术对相似个体施加惩罚。我一般用第一种简单粗暴但有效。重新初始化的比例取种群的20%左右。6. 从失效用例到回归测试集的转化6.1 失效用例的聚类与去重优化器跑完你可能会得到几百条失效用例。但其中很多是相似的比如都是“前车急刹导致跟车距离过近”只是参数略有不同。直接全部塞进回归测试集会导致测试冗余。我通常用DBSCAN对失效用例的参数向量做聚类每个簇选一个代表用例离簇中心最近的再保留簇内目标函数值最好的几个。这样能把几百条压缩到几十条覆盖不同的失效模式。6.2 回归测试集的版本管理回归测试集不是一成不变的。每次决策规划系统更新都可能修复一些旧失效同时引入新失效。我的做法是把回归测试集存成CSV或JSON纳入Git管理。每次系统更新后跑一遍全量回归标记哪些用例从“失效”变成“通过”。定期用优化算法重新搜索补充新的失效用例。对于长期通过的用例可以降级到“冒烟测试集”减少每次回归的时间。6.3 与CI/CD流水线的集成自动化测试最终要落到持续集成里。我的方案是每日构建跑冒烟测试集约50条用例控制在30分钟内。每周构建跑全量回归测试集约300条用例控制在4小时内。版本发布前跑优化搜索生成一批新用例人工确认后加入回归集。CI流水线里用pytest做测试执行用Allure或JUnit XML输出报告。如果硬约束用例失败直接阻断合并软指标退化超过阈值发告警但不阻断。7. 常见问题与排查技巧实录7.1 优化器不收敛怎么办这是最常见的问题。可能的原因和排查顺序现象可能原因排查方法解决措施目标函数值长期不变参数空间定义太窄检查参数范围是否覆盖了失效区域扩大范围重新归一化迭代后期震荡采集函数过于激进观察采样点分布切换采集函数降低探索权重初始点就很差初始采样不合理检查拉丁超立方实现手动加入已知危险点仿真结果噪声大仿真器非确定性同一参数跑多次看方差固定随机种子或增加重复次数我遇到过一次优化器跑了100代还在安全区域打转。后来发现是目标函数里软指标的权重太小硬约束又一直没触发导致整个目标函数值都很接近。把硬约束的惩罚值从1000提高到10000优化器立刻开始往危险区域跑。7.2 仿真超时与无效用例处理仿真器不是永远可靠的。有时候场景配置不合理自车会卡在原地不动仿真永远不结束。我的处理方式是设置墙钟超时比如单次仿真不超过120秒。设置仿真步数上限比如最多推进1000步。超时后强制杀掉进程记录为“无效用例”目标函数给一个中间值不是最差避免优化器完全避开这个区域。注意无效用例的比例如果超过20%说明参数空间里有大量不合理组合。需要回头检查参数范围或者加入场景有效性预判逻辑。7.3 优化结果不可复现的排查优化算法本身如果有随机性比如遗传算法的变异两次运行结果可能不同。要保证可复现需要固定所有随机种子Python的random、NumPy的numpy.random、仿真器的随机种子。记录每次采样的参数和结果存成日志。如果用了并行确保结果收集的顺序不影响优化器状态更新。我习惯在优化脚本开头加一段set_seed(42)把能设的种子都设上。虽然不能保证100%复现仿真器内部可能有线程调度差异但至少优化器的采样序列是可复现的。7.4 高维参数空间的降维技巧当参数超过10维贝叶斯优化的效果会明显下降。这时候可以考虑敏感性分析先用Sobol方法或Morris方法筛出对目标函数影响最大的几个参数只对它们做优化。主成分分析如果参数之间有相关性可以降到低维空间再优化。分组优化把参数分成几组轮流优化类似坐标下降。我在一个包含12个参数的场景里试过敏感性分析发现其中5个参数对结果几乎没有影响固定为典型值后优化维度降到7维收敛速度提升了一倍多。8. 一些个人体会这套方法我从去年开始在实际项目里用最大的感受是优化算法不是银弹它只是把“人找用例”变成了“算法找用例”但目标函数的定义、参数空间的设计、结果的分析还是得靠人。我见过有人把优化器跑出来的失效用例直接丢给开发结果开发一看参数组合在物理上根本不可能出现白白浪费沟通成本。另外仿真置信度是个绕不开的问题。优化器找到的失效用例如果仿真器本身在这个场景下就不准那结论也没意义。我的做法是对优化器找到的高价值失效用例额外做一轮仿真器交叉验证用另一个仿真器或者实车数据回放确认。虽然麻烦但能避免很多扯皮。最后分享一个小技巧优化过程中把每次迭代的采样点和目标函数值实时画出来用Matplotlib或者TensorBoard都行。看着散点图从安全区域慢慢向危险区域聚集你能直观判断优化器是不是在正常工作。如果散点一直均匀分布那大概率是目标函数没设计好或者参数空间有问题。这个可视化习惯帮我省了很多排查时间。