一、OVERVIEW
- 简要介绍 Particle Filter(粒子滤波)
- 顺带提一下 C++ Ranges 库(作为实现工具)
- 用 C++23 完整实现一个 Particle Filter
- 总结工程实践中的建议与注意事项
整体逻辑是:
理论 → 抽象流程 → C++23 落地实现 → 工程经验
二、什么是 Bayesian Filters(贝叶斯滤波)
1⃣ 问题背景
Bayesian Filters 用来解决这样一类问题:
在系统本身有随机扰动、观测存在噪声的情况下,如何估计系统的“真实内部状态”?
- 系统内部状态:不可直接观测
- 只能看到 noisy observations(带噪观测)
- 系统演化过程本身也有随机性
2⃣ 状态、观测与概率建模
我们通常定义:
- 状态(state):
XtX_tXt
表示系统在时刻ttt的真实内部状态 - 观测(observation):
ZtZ_tZt
表示在时刻ttt能观测到的数据(有噪声)
3⃣ 核心目标:后验分布
贝叶斯滤波的目标不是“一个值”,而是一个概率分布:
bel(Xt)=p(Xt∣Z1:t) bel(X_t) = p(X_t \mid Z_{1:t})bel(Xt)=p(Xt∣Z1:t)
含义是:
在看到从111到ttt的所有观测后,系统当前状态XtX_tXt的概率分布
三、贝叶斯滤波的两个核心模型(对应图中的上半部分)
你的 SVG 图上半部分清晰表达了两个模型:
1⃣ Transition Model(状态转移模型)
图中左上角:
Transition → Transition model
数学表达:
p(Xt∣Xt−1) p(X_t \mid X_{t-1})p(Xt∣Xt−1)
含义:
描述系统状态是如何从Xt−1X_{t-1}Xt−1随机演化到XtX_tXt的
例如:
- 机器人运动模型
- 金融价格随机游走
- 物体运动 + 随机噪声
2⃣ Observation Model(观测模型)
图中右上角:
Observation → Observation model
数学表达:
p(Zt∣Xt) p(Z_t \mid X_t)p(Zt∣Xt)
含义:
在真实状态是XtX_tXt时,观测到ZtZ_tZt的概率
它刻画了:
- 传感器噪声
- 测量误差
- 不完美的观测过程
四、Prediction(预测)阶段 —— 图中左下
1⃣ 预测的输入
- 上一时刻的 belief:
bel(Xt−1)bel(X_{t-1})bel(Xt−1) - 状态转移模型:
p(Xt∣Xt−1)p(X_t \mid X_{t-1})p(Xt∣Xt−1)
2⃣ 预测公式(Chapman–Kolmogorov)
预测步骤就是对上一时刻的 belief 做积分(或求和):
bel‾(Xt)=∫p(Xt∣Xt−1)⋅bel(Xt−1),dXt−1 \overline{bel}(X_t) = \int p(X_t \mid X_{t-1}) \cdot bel(X_{t-1}) , dX_{t-1}bel(Xt)=∫p(Xt∣Xt−1)⋅bel(Xt−1),dXt−1
解释:
- 把所有可能的旧状态都考虑进来
- 看它们各自以什么概率转移到XtX_tXt
- 得到预测分布bel‾(Xt)\overline{bel}(X_t)bel(Xt)
注意:这是还没看新观测之前的状态估计
五、Update(更新)阶段 —— 图中右下
1⃣ 更新的输入
- 预测分布:
bel‾(Xt)\overline{bel}(X_t)bel(Xt) - 当前观测:
ZtZ_tZt - 观测模型:
p(Zt∣Xt)p(Z_t \mid X_t)p(Zt∣Xt)
2⃣ 贝叶斯更新公式
bel(Xt)=η⋅p(Zt∣Xt)⋅bel‾(Xt) bel(X_t) = \eta \cdot p(Z_t \mid X_t) \cdot \overline{bel}(X_t)bel(Xt)=η⋅p(Zt∣Xt)⋅bel(Xt)
其中:
- η\etaη是归一化常数,保证:
∫bel(Xt),dXt=1\int bel(X_t),dX_t = 1∫bel(Xt),dXt=1
直观理解:
- 如果某些状态更可能生成当前观测
- 那它们的概率就被放大
- 不符合观测的状态被削弱
六、整条闭环(对应图中大回路)
你的 SVG 最下面画了一个反馈闭环:
bel(X_{t-1}) → Prediction → Update → bel(X_t) ↑ Observation含义是:
- 每一轮滤波完成后
- 当前的bel(Xt)bel(X_t)bel(Xt)
- 会作为下一轮的bel(Xt−1)bel(X_{t-1})bel(Xt−1)
这构成了一个递归在线算法
七、Particle Filter 在这里的角色
1⃣ 为什么需要 Particle Filter?
上述公式中有两个致命问题:
- 积分:
∫p(Xt∣Xt−1)⋅bel(Xt−1),dXt−1 \int p(X_t \mid X_{t-1}) \cdot bel(X_{t-1}) , dX_{t-1}∫p(Xt∣Xt−1)⋅bel(Xt−1),dXt−1 - 分布可能是:
- 非高斯
- 多峰
- 高维
解析解不可行
2⃣ Particle Filter 的核心思想
用一组带权重的样本(粒子)来近似分布:
bel(Xt)≈(xt(i),wt(i))i=1N bel(X_t) \approx { (x_t^{(i)}, w_t^{(i)}) }_{i=1}^Nbel(Xt)≈(xt(i),wt(i))i=1N
其中:
- xt(i)x_t^{(i)}xt(i):第iii个粒子的状态
- wt(i)w_t^{(i)}wt(i):该粒子的权重
- NNN:粒子数量
3⃣ Particle Filter 对应流程映射
| 贝叶斯步骤 | 粒子滤波实现 |
|---|---|
| Prediction | 对每个粒子采样p(Xt∣Xt−1)p(X_t \mid X_{t-1})p(Xt∣Xt−1) |
| Update | 用p(Zt∣Xt)p(Z_t \mid X_t)p(Zt∣Xt)重新计算权重 |
| Normalize | 权重归一化 |
| Resample | 去掉低权重粒子,复制高权重粒子 |
八、为什么 C++23 + Ranges 很适合
在你这场 Talk 里:
- 粒子是range
- 预测是transform
- 更新是transform + zip
- 归一化是fold / reduce
- 重采样是views + algorithms
这让:
Particle Filter 的数学结构 ≈ C++ Ranges 管道
九、小结(一句话版)
Bayesian Filter 是概率递推框架,Particle Filter 是用随机采样把这个框架变成可执行算法,而你的图完整表达了 Prediction + Update 的递归闭环。
一、核心概念说明(是什么)
1⃣ BELIEF(信念)
Belief表示:
在当前时刻,系统对“真实状态”的概率认知。
在粒子滤波中,Belief 不再用一个解析概率分布表示,而是:
用一组带权重的粒子(particles)来近似表示
可以写成:
Belieft≈(xt(i),wt(i))i=1N Belief_t \approx {(x_t^{(i)}, w_t^{(i)})}_{i=1}^{N}Belieft≈(xt(i),wt(i))i=1N
其中:
- xt(i)x_t^{(i)}xt(i):第iii个粒子表示的一种状态假设
- wt(i)w_t^{(i)}wt(i):该假设的权重(可信度)
- NNN:粒子数量
这是一种蒙特卡洛近似思想。
2⃣ PARTICLE(粒子)
Particle = 对系统状态的一种假设
你给出的定义非常准确:
PARTICLE - A single hypothesis of the state of the system with an associated weight
具体来说:
- 粒子本身:x(i)x^{(i)}x(i)
- 权重:w(i)w^{(i)}w(i)
直观理解:
“如果世界是这样的状态,那么它发生的可能性有多大?”
二、状态、模型定义(系统建模)
1⃣ STATE(状态)
你给的例子是机器人:
STATE - Position and orientation of the robot
典型形式:
xt=(x,y,θ) x_t = (x, y, \theta)xt=(x,y,θ)
含义:
- (x,y)(x, y)(x,y):机器人在平面中的位置
- θ\thetaθ:机器人朝向角
2⃣ TRANSITION(状态转移模型)
TRANSITION - Control commands, odometry, etc.
描述的是:
在给定控制输入下,状态如何从t−1t-1t−1演化到ttt
数学上表示为条件概率:
p(xt∣xt−1,ut) p(x_t \mid x_{t-1}, u_t)p(xt∣xt−1,ut)
其中:
- utu_tut:控制输入(速度、转角、里程计)
在粒子滤波中,这个模型通常是随机的,即加入噪声。
3⃣ OBSERVATION(观测模型)
OBSERVATION - Sensor data (LIDAR, Camera, etc.)
描述的是:
在给定状态下,看到当前观测的可能性
数学表示为:
p(zt∣xt) p(z_t \mid x_t)p(zt∣xt)
其中:
- ztz_tzt:传感器观测(激光、图像等)
三、Particle Filter 的核心流程(怎么做)
你列出的三步正是粒子滤波的标准循环:
1⃣ Prediction(预测)
Compute new states for each particle (transition model)
含义
根据系统运动模型,对每个粒子进行状态推进:
xt(i)∼p(xt∣xt−1(i),ut) x_t^{(i)} \sim p(x_t \mid x_{t-1}^{(i)}, u_t)xt(i)∼p(xt∣xt−1(i),ut)
直观解释
- 每个粒子“假装自己是真的”
- 根据控制输入和噪声,各自向前“走一步”
- 走法略有不同 → 引入不确定性
结果:
粒子云被“拉伸、平移、旋转”
2⃣ Update(更新 / 加权)
Compute weights for each particle (observation model)
含义
使用传感器观测,评估每个粒子“合理不合理”:
wt(i)=p(zt∣xt(i)) w_t^{(i)} = p(z_t \mid x_t^{(i)})wt(i)=p(zt∣xt(i))
然后归一化:
∑i=1Nwt(i)=1 \sum_{i=1}^{N} w_t^{(i)} = 1i=1∑Nwt(i)=1
直观解释
- 假设粒子的位置是真的
- 用该位置预测传感器读数
- 与真实观测对比:
- 越接近 → 权重越大
- 差得越远 → 权重越小
结果:
粒子有“好坏之分”了
3⃣ Resample(重采样)
Duplicate particles with high weights
Eliminate particles with low weights
含义
按照权重分布重新抽取粒子:
xt(i)i=1N∼Discrete(wt(i)) {x_t^{(i)}}_{i=1}^{N} \sim \text{Discrete}(w_t^{(i)})xt(i)i=1N∼Discrete(wt(i))
直观解释
- 好的粒子:
复制多份 - 差的粒子:
被淘汰
目的:
把计算资源集中在“更可能的状态”附近
四、整体直觉总结(一句话版)
粒子滤波 = 用一群“带概率的猜测”,不断用运动模型扩散、用传感器收敛
循环过程可以概括为:
Belieft−1→PredictionBelieft−→UpdateBelieft→ResampleBelieft Belief_{t-1} \xrightarrow{\text{Prediction}} Belief_t^{-} \xrightarrow{\text{Update}} Belief_t \xrightarrow{\text{Resample}} Belief_tBelieft−1PredictionBelieft−UpdateBelieftResampleBelieft
五、为什么要用 Particle Filter?
与卡尔曼滤波相比:
| 特性 | 粒子滤波 |
|---|---|
| 非线性 | 支持 |
| 非高斯 | 支持 |
| 多峰分布 | 支持 |
| 计算代价 | 较高 |
特别适合:
- 机器人定位(Monte Carlo Localization)
- SLAM
- 视觉 / 激光融合
一、C++ Ranges Library 是什么(核心定位)
C++ Ranges Library 是对传统 algorithm + iterator 模型的扩展与泛化
你给的第一句非常关键:
Extension and generalization of the algorithm and iterator libraries.
传统 C++98 的问题
在 C++98 ~ C++17 中,算法与容器之间的关系是:
algorithm(begin(range),end(range))->result特点:
- 算法不认识容器
- 只认识一对迭代器
begin()/end()必须手动写- 极易出现:
- 迭代器不匹配
- 越界
- 生命周期错误
二、语法与抽象层级的演进
1⃣ C++98:算法基于迭代器
algorithm(begin(range),end(range))➡ result抽象模型:
Algorithm:(Iteratorbegin,Iteratorend)→Result Algorithm : (Iterator_{begin}, Iterator_{end}) \rightarrow ResultAlgorithm:(Iteratorbegin,Iteratorend)→Result
问题:
- 语义分散
- “这是一段什么数据”完全靠程序员记忆
2⃣ C++20:算法直接作用于 range
algorithm(range)➡ result抽象模型升级为:
Algorithm:Range→Result Algorithm : Range \rightarrow ResultAlgorithm:Range→Result
含义变化:
- 算法的输入从「两个指针」
- 变成了「一个语义完整的对象」
安全性与可读性大幅提升
3⃣ C++20:adaptor 生成 view
adaptor(range)➡ view含义:
- adaptor不是算法
- 它不立刻计算
- 只是生成一个惰性视图(view)
数学类比:
View=f(Range) View = f(Range)View=f(Range)
但这是惰性函数,不立即求值。
4⃣ C++20:管道(Pipe)风格
range|adaptor ➡ view这是语法糖,但语义极其重要:
adaptor(range)≡
range|adaptor多个 adaptor 可以串联:
range|adaptor1|adaptor2 ➡ view对应函数复合:
View=adaptor2(adaptor1(Range)) View = adaptor2(adaptor1(Range))View=adaptor2(adaptor1(Range))
5⃣ C++23:用户自定义 adaptor
range|adaptor1|user_defined_adaptor|adaptor2 ➡ view意义:
- adaptor 不再是标准库专属
- 你可以定义领域特定语言(DSL)
Ranges = 算法管道语言
6⃣ C++23:to(物化)
range|to<non-view>➡ non-view这是非常关键的一步:
- 之前全是惰性 view
to<>表示:“现在我真的要结果了”
数学上相当于:
Materialize(View)→Concrete Container Materialize(View) \rightarrow Concrete\ ContainerMaterialize(View)→ConcreteContainer
三、Ranges 的核心思想(一句话)
Ranges 把“数据流 + 变换”变成了一等公民
而不是:
- 算法调用细节
- 迭代器操作细节
四、为什么「Particle sets are ranges」
你给的这句话非常深刻:
Particle sets are ranges
粒子集合的本质
在粒子滤波中:
Particles=(x(i),w(i))∣i=1…N Particles = { (x^{(i)}, w^{(i)}) \mid i = 1 \ldots N }Particles=(x(i),w(i))∣i=1…N
这是一个:
- 可遍历
- 可过滤
- 可映射
- 可重采样
的集合
完全符合 Range 的抽象
粒子滤波步骤 = 对 Range 的变换
| 粒子滤波步骤 | Range 操作 |
|---|---|
| Prediction | transform |
| Weight update | transform |
| Normalize | transform/reduce |
| Resample | sample/filter/generate |
抽象为:
Particlest+1=fresample(fupdate(fpredict(Particlest))) Particles_{t+1} = f_{resample}(f_{update}(f_{predict}(Particles_t)))Particlest+1=fresample(fupdate(fpredict(Particlest)))
五、为什么「Particle filters are complex algorithms applied to ranges」
粒子滤波不是一个简单算法,而是:
- 多阶段
- 状态ful
- 含随机性
- 可组合
的复杂算法管道
用 Ranges 可以写成:
particles|predict(model)|update(sensor)|normalize|resample|to<std::vector>这在语义上几乎等同于论文中的流程图。
六、Ranges 带来的三大工程优势
你给的三点总结非常精准,我逐条展开:
1⃣ Easy to read(易读)
particles|predict|update|resample代码即算法描述
不需要在脑子里模拟迭代器流动。
2⃣ Less error-prone(更不易出错)
- 不再手动管理
begin/end - adaptor 只接受合法的 range
- 编译期约束(concepts)
数学意义上:
把「运行期错误」前移到「编译期拒绝」
3⃣ Easy to reuse(易复用)
- Prediction / Update / Resample 都是:
- 独立 adaptor
- 可插拔组件
可以自由组合:
particles|predict_A|update_LIDAR|resample particles|predict_B|update_CAMERA|resample七、核心一句话总结
C++ Ranges 把粒子滤波这种“多阶段概率算法”,表达成了“类型安全、可组合、惰性求值的数据流语言”
这正是它与传统for + iterator + algorithm模式的本质差别。
concept在语义层、类型层、以及粒子滤波(Particle Filter)中的数学含义。
// =======================// ParticleLike 概念// =======================//// 语义目标:// 描述“什么样的类型 T 可以被当作一个粒子(Particle)”//// 在粒子滤波中,一个粒子至少包含:// 1) 状态 state(state of the system)// 2) 权重 weight(该状态假设的概率权重)//// 数学上:// 一个粒子可以表示为// $$ p^{(i)} = (x^{(i)}, w^{(i)}) $$// 其中 $x^{(i)}$ 是状态,$w^{(i)} \in \mathbb{R}$ 是权重//template<typenameT>conceptParticleLike=// 要求 T 是一个“对象类型”// 排除 void、函数类型、引用等不可能作为粒子实体的类型std::is_object_v<T>&&// requires 表达式:检查“语法是否成立”,而不是检查继承关系requires(T a){// 要求 a.state 这个表达式是合法的// 不关心 state 的具体类型,只关心“存在”//// 语义:粒子必须包含一个“状态”{a.state};// 要求 a.weight 表达式存在,并且其结果// 可以隐式转换为 double//// 语义:粒子权重是一个实数// 数学对应:$$ w^{(i)} \in \mathbb{R} $${a.weight}->std::convertible_to<double>;};// =======================// StateUpdateFn 概念// =======================//// 语义目标:// 描述“一个可以用于更新粒子状态的函数/函数对象”//// 对应粒子滤波中的 Prediction / Transition 步骤:// $$ x_t^{(i)} = f(x_{t-1}^{(i)}) $$//// 即:// - 输入:旧状态// - 输出:新状态// - 就地写回粒子的 state//template<typenameF,typenameT>conceptStateUpdateFn=// 首先要求 T 必须是一个粒子// 状态更新函数只能作用在粒子上ParticleLike<T>&&// requires 检查 F 和 T 之间是否满足“状态更新”的语法约束requires(F f,T t){// 要求以下表达式合法://// t.state = f(t.state)//// 这隐含了多个约束:// 1) f(t.state) 必须是合法调用// 2) f(t.state) 的返回值类型// 必须可以赋值给 t.state// 3) t.state 不是 const(可写)//// 语义:这是一个“就地状态转移函数”//// 数学对应:// $$ x \leftarrow f(x) $${t.state=f(t.state)};};// =======================// ReweightFn 概念// =======================//// 语义目标:// 描述“一个根据粒子状态重新计算粒子权重的函数”//// 对应粒子滤波中的 Observation / Likelihood 步骤:// $$ w_t^{(i)} = p(z_t \mid x_t^{(i)}) $$//// 即:// - 输入:粒子的状态// - 输出:该状态对应的概率权重// - 写回粒子的 weight//template<typenameF,typenameT>conceptReweightFn=// 同样,只能对粒子进行权重更新ParticleLike<T>&&requires(F f,T t){// 要求以下表达式合法://// t.weight = f(t.state)//// 隐含约束:// 1) f 可以接受一个 state// 2) f(state) 的返回值// 可以赋值给 t.weight// 3) t.weight 是可写的//// 语义:// - 权重只依赖于状态// - 不修改粒子的状态本身//// 数学对应:// $$ w \leftarrow g(x) $${t.weight=f(t.state)};};总结(语义层面)
这三组concept本质上做了一件事:
把粒子滤波的数学定义,精确地嵌入到 C++ 类型系统中
| Concept | 数学含义 | 粒子滤波步骤 |
|---|---|---|
ParticleLike | (x,w)(x, w)(x,w) | 粒子定义 |
StateUpdateFn | x←f(x)x \leftarrow f(x)x←f(x) | Prediction |
ReweightFn | w←g(x)w \leftarrow g(x)w←g(x) | Observation |
一句话评价
这不是“语法约束”,而是“算法语义的类型化表达”
编译器现在可以在算法还没运行之前,就证明你的粒子滤波代码是否满足数学定义。
一、整体背景:为什么要用 Concepts
这组concept的目标不是“限制类型”,而是:
用编译期语义,精确描述“什么是一个粒子,以及哪些函数可以作用在粒子上”
在粒子滤波中,我们天然有三类角色:
- 粒子(Particle)
- 状态更新函数(Prediction / Transition)
- 权重更新函数(Observation / Likelihood)
这三者在数学上是清楚的,但在传统 C++ 模板中是模糊的。
Concepts 的作用就是:把数学语义成类型约束。
二、ParticleLike:什么叫“像一个粒子”
template<typenameT>conceptParticleLike=std::is_object_v<T>&&requires(T a){{a.state};{a.weight}->std::convertible_to<double>;};1⃣std::is_object_v<T>
含义:
T必须是一个对象类型- 不能是:
- 引用
- 函数
void
排除模板被误用在“非实体类型”上
2⃣requires(T a) { { a.state }; }
这并不是检查类型,而是检查表达式是否成立。
含义:
对任意一个
T a,表达式a.state必须是合法的
注意:
- 不要求类型
- 不要求 public / private
- 只要求“能访问”
3⃣{ a.weight } -> std::convertible_to<double>;
这是最关键的一行:
a.weight的结果必须能转换为double
这精确对应粒子滤波中的定义:
w(i)∈R w^{(i)} \in \mathbb{R}w(i)∈R
即:
- 权重是实数
- 允许:
floatdoublelong double
- 不允许:
std::stringbool- 离散类型
4⃣ 数学语义总结
ParticleLike<T>等价于:
T∈[Particle] ⟺ {T 是对象类型T.state 存在T.weight∈R T \in [\text{Particle}] \iff \begin{cases} T \text{ 是对象类型} \\ T.\text{state} \text{ 存在} \\ T.\text{weight} \in \mathbb{R} \end{cases}T∈[Particle]⟺⎩⎨⎧T是对象类型T.state存在T.weight∈R
也就是说:
这是一个“结构语义”概念,而不是继承层次概念
三、StateUpdateFn:状态转移函数(Prediction)
template<typenameF,typenameT>conceptStateUpdateFn=ParticleLike<T>&&requires(F f,T t){{t.state=f(t.state)};};1⃣ 先要求ParticleLike<T>
这是语义闭包:
只有“粒子”,才谈得上“状态更新”
2⃣requires(F f, T t)
我们引入两个抽象对象:
f:一个函数对象t:一个粒子
3⃣{ t.state = f(t.state) };
这一行同时约束了3 件事:
(1)f(t.state)必须合法
说明:
f接受一个state,返回一个“能赋给 state 的值”
(2) 返回类型必须可赋值给t.state
隐含约束:
f(state)的返回类型- 与
state类型相容
(3)t.state是可写的
意味着:
state不是const- 这是一个in-place 更新
4⃣ 对应粒子滤波数学模型
Prediction 步骤:
xt(i)=f(xt−1(i)) x_t^{(i)} = f(x_{t-1}^{(i)})xt(i)=f(xt−1(i))
而 Concept 中直接写成:
t.state=f(t.state)代码和数学公式是一一对应的
四、ReweightFn:权重更新函数(Observation)
template<typenameF,typenameT>conceptReweightFn=ParticleLike<T>&&requires(F f,T t){{t.weight=f(t.state)};};1⃣ 依然要求ParticleLike<T>
说明:
权重更新只针对粒子
2⃣{ t.weight = f(t.state) };
这行约束的语义是:
f接收粒子的状态- 返回一个值
- 该值可赋给
t.weight
3⃣ 对应粒子滤波数学模型
Observation / Likelihood:
wt(i)=p(zt∣xt(i)) w_t^{(i)} = p(z_t \mid x_t^{(i)})wt(i)=p(zt∣xt(i))
Concept 把它写成:
t.weight=f(t.state)这意味着:
- 观测模型不修改状态
- 只从状态计算权重
五、三个 Concept 之间的关系图
ParticleLike<T> | +-- StateUpdateFn<F, T> | +-- ReweightFn<F, T>语义层级:
- ParticleLike:数据结构约束
- StateUpdateFn:状态演化算子
- ReweightFn:观测似然算子
六、为什么这是“Ranges + Particle Filter”的完美搭档
有了这些 Concept,你可以安全地写出:
particles|predict(model)|reweight(sensor)并且保证:
particles里的元素一定有 state / weightpredict只能修改 statereweight只能修改 weight
错误在编译期被拒绝,而不是运行期爆炸
七、核心总结(一句话)
这些 Concepts 把粒子滤波的数学定义
xt=f(xt−1),;wt=g(xt)x_t = f(x_{t-1}),; w_t = g(x_t)xt=f(xt−1),;wt=g(xt)
精确、可组合、零运行时开销地编码进了 C++ 类型系统
一、粒子滤波算法在做什么(算法语义)
粒子滤波(Particle Filter)用一组带权重的样本(粒子)来近似一个概率分布(信念 Belief)。
数学上,当前时刻的信念可以表示为一组粒子集合:
Bt≈(xt(i),wt(i))∣i=1…N \mathcal{B}_t \approx { (x_t^{(i)}, w_t^{(i)}) \mid i = 1 \dots N }Bt≈(xt(i),wt(i))∣i=1…N
其中:
- xt(i)x_t^{(i)}xt(i):第iii个粒子的状态假设
- wt(i)w_t^{(i)}wt(i):该假设的重要性权重
二、filter函数要做的两件事
你给出的说明非常关键:
The filter function should perform two steps
1⃣ Update states and weights(预测 + 观测)
这一步其实包含两个子步骤:
(1)状态更新(Prediction / Transition)
根据系统模型,把旧状态推进到新状态:
xt(i)=f(xt−1(i)) x_t^{(i)} = f(x_{t-1}^{(i)})xt(i)=f(xt−1(i))
这一步只改变state,不直接改变权重。
(2)权重更新(Update / Observation)
根据传感器观测,评估当前状态的可信度:
wt(i)=p(zt∣xt(i)) w_t^{(i)} = p(z_t \mid x_t^{(i)})wt(i)=p(zt∣xt(i))
这一步只改变weight,不再改变状态。
2⃣ Resample(重采样)
重采样的目标是:
- 复制高权重粒子
- 淘汰低权重粒子
- 保持粒子数量NNN不变
数学意义是:
从离散分布
P(i)=wt(i)∑jwt(j) P(i) = \frac{w_t^{(i)}}{\sum_j w_t^{(j)}}P(i)=∑jwt(j)wt(i)
中采样NNN次,形成新的粒子集合。
三、代码接口的整体语义
现在来看你的接口:
template<ParticleLike T>voidfilter(std::vector<T>&particles,StateUpdateFn<T>autostate_update_fn,ReweightFn<T>autoreweight_fn){...}这段代码本身就精确地编码了粒子滤波的数学结构。
四、逐项详细注释与语义解释
1⃣template <ParticleLike T>
template<ParticleLike T>语义含义:
T必须满足ParticleLike概念- 即:
T必须至少包含stateweight
这对应数学中粒子的定义:
T≡(x,w) T \equiv (x, w)T≡(x,w)
这里并不关心
state是几维、是 struct 还是 vector
状态空间的维度和表示完全解耦
2⃣std::vector<T>& particles
std::vector<T>&particles语义含义:
particles表示当前的粒子集合- 这是对信念分布B\mathcal{B}B的离散近似
数学对应:
B≈(x(i),w(i))i=1N \mathcal{B} \approx { (x^{(i)}, w^{(i)}) }_{i=1}^NB≈(x(i),w(i))i=1N
使用引用&的含义是: - 滤波器就地更新粒子
- 不产生新的容器,减少内存开销
3⃣StateUpdateFn<T> auto state_update_fn
StateUpdateFn<T>autostate_update_fn语义含义:
- 这是一个“状态转移函数”
- 它定义了系统的动态模型
从 Concept 的角度,它满足:
t.state=state_update_fn(t.state);数学含义是:
x←f(x) x \leftarrow f(x)x←f(x)
重要的是:
filter并不知道这个函数具体做什么- 它只要求:能把旧状态变成新状态
这就是算法与模型的解耦。
4⃣ReweightFn<T> auto reweight_fn
ReweightFn<T>autoreweight_fn语义含义:
- 这是一个“权重更新函数”
- 它实现了观测模型(似然函数)
Concept 约束的是:
t.weight=reweight_fn(t.state);数学含义:
w←g(x) w \leftarrow g(x)w←g(x)
通常对应:
g(x)=p(z∣x) g(x) = p(z \mid x)g(x)=p(z∣x)
同样:
filter不关心传感器类型- 不关心概率分布形式
- 只关心“状态 → 权重”这个映射存在
五、filter内部逻辑(你...里应该做什么)
语义上,filter内部一定是类似下面的流程(伪代码):
// 1. Prediction + Updatefor(auto&p:particles){p.state=state_update_fn(p.state);p.weight=reweight_fn(p.state);}// (通常还会做一次权重归一化)// $$ w_i \leftarrow \frac{w_i}{\sum_j w_j} $$// 2. Resampleparticles=resample(particles);六、为什么这种接口设计“非常现代 C++”
1⃣ Concepts 把“算法前提”变成编译期事实
如果你传入:
- 没有
state的类型 weight不是double- 状态更新函数返回错误类型
直接编译失败,而不是运行期出 bug
2⃣ 数学 → 类型 → 算法 的一一对应关系
| 数学对象 | C++ 表达 |
|---|---|
| 粒子(x,w)(x, w)(x,w) | ParticleLike T |
| 状态转移x←f(x)x \leftarrow f(x)x←f(x) | StateUpdateFn |
| 权重计算w←g(x)w \leftarrow g(x)w←g(x) | ReweightFn |
| 粒子集合 | std::vector<T> |
七、一句话总结
这个
filter接口不是“写算法”,而是把粒子滤波的数学定义嵌进了 C++ 类型系统:
只要代码能通过编译,就已经满足粒子滤波的数学前提。
从算法含义 → ranges 语义 → 视图的惰性本质 → 与粒子滤波数学模型的对应关系
一、这一段代码在粒子滤波中的位置
PARTICLE FILTER ALGORITHM
UPDATE STATES AND WEIGHTS
也就是说,这一段并不是完整的粒子滤波,而是只覆盖了算法中的第一阶段:
Prediction + Update \text{Prediction + Update}Prediction + Update
在数学上,这一步对应的是对每一个粒子(x(i),w(i))(x^{(i)}, w^{(i)})(x(i),w(i))执行:
x(i)←f!(x(i))w(i)←g!(x(i)) \begin{aligned} x^{(i)} &\leftarrow f!\left(x^{(i)}\right) \\ w^{(i)} &\leftarrow g!\left(x^{(i)}\right) \end{aligned}x(i)w(i)←f!(x(i))←g!(x(i))
二、std::views::transform在这里“不是算法”,而是“视图构造器”
先看你给出的代码结构:
template<ParticleLike T>voidfilter(std::vector<T>&particles,StateUpdateFn<T>autostate_update_fn,ReweightFn<T>autoreweight_fn){autostates=particles|std::views::transform(&T::state);autoweights=particles|std::views::transform(&T::weight);...}这里最重要的一点理解是:
states和weights不是容器,也不是拷贝出来的数据,而是对particles的惰性视图(view)。
三、particles | transform(&T::state)的精确语义
1⃣&T::state是什么?
&T::state这是一个指向数据成员的指针(pointer-to-member),语义是:
“给我一个
T对象,我就能返回它的state成员的引用”
在 ranges 中,它被当作一个“投影函数”。
2⃣std::views::transform(&T::state)做了什么?
particles|std::views::transform(&T::state)语义等价于:
对
particles中的每个T& p,生成p.state
但注意:
这不是一次性生成,而是按需访问。
3⃣states的真实类型含义
autostates=particles|std::views::transform(&T::state);可以理解为:
states是一个“对particles的每个粒子状态的引用序列”
数学上对应的是从粒子集合中抽取状态分量:
(x(i),w(i))∗i=1N;⟶;x(i)∗i=1N {(x^{(i)}, w^{(i)})}*{i=1}^N ;\longrightarrow; {x^{(i)}}*{i=1}^N(x(i),w(i))∗i=1N;⟶;x(i)∗i=1N
但这里并没有创建新的集合,只是建立了一个映射视图。
四、weights视图的语义是完全对称的
autoweights=particles|std::views::transform(&T::weight);这行代码的语义是:
构造一个“对每个粒子权重的视图”
数学对应:
(x(i),w(i))∗i=1N;⟶;w(i)∗i=1N {(x^{(i)}, w^{(i)})}*{i=1}^N ;\longrightarrow; {w^{(i)}}*{i=1}^N(x(i),w(i))∗i=1N;⟶;w(i)∗i=1N
同样:
- 没有拷贝
- 没有分配新内存
- 只是建立“如何访问”的规则
五、为什么要先把state和weight拆成视图?
这一步在设计上非常重要,它不是语法花活,而是算法表达方式的改变。
1⃣ 数学结构的直接映射
在粒子滤波的推导中,我们经常把状态和权重当成两个向量:
x=(x(1),…,x(N)) w=(w(1),…,w(N)) \mathbf{x} = (x^{(1)}, \dots, x^{(N)}) \ \mathbf{w} = (w^{(1)}, \dots, w^{(N)})x=(x(1),…,x(N))w=(w(1),…,w(N))states和weights正是这种“分量视角”的 C++ 表达。
2⃣ 让后续算法更清晰
一旦你有了:
states weights后续可以自然地写出:
- 对
states做状态推进 - 对
weights做归一化 - 对
weights做前缀和(用于重采样)
而不是每次都:
p.state p.weight六、views的关键性质:引用、惰性、零开销
1⃣ 引用语义(非常关键)
transform(&T::state)返回的是对原始成员的引用,这意味着:
for(auto&s:states){s=state_update_fn(s);}等价于:
for(auto&p:particles){p.state=state_update_fn(p.state);}这一步在数学上正是:
x(i)←f(x(i)) x^{(i)} \leftarrow f(x^{(i)})x(i)←f(x(i))
2⃣ 惰性求值
- 不遍历就不计算
- 不使用就不产生任何开销
这和粒子滤波中“逐粒子处理”的思想是高度一致的。
3⃣ 零抽象成本
在优化级别足够高时:
- 编译器会把
transform完全内联 - 生成的汇编与手写循环基本一致
七、这一步还没做什么(但你马上就会做)
你现在的代码只构造了视图,还没有执行真正的更新。
接下来合理的步骤应该是:
- 通过
states更新状态 - 通过
weights重新赋权 - 对
weights做归一化 - 进入 resample 阶段
数学流程仍然是:
(x,w)→transition(x′,w)→observation(x′,w′) (x, w) \xrightarrow{\text{transition}} (x', w) \xrightarrow{\text{observation}} (x', w')(x,w)transition(x′,w)observation(x′,w′)
八、一句话总结
这两行
std::views::transform的真正意义是:
用 C++20 ranges 把粒子滤波中的“状态向量”和“权重向量”显式建模出来,而且不产生任何运行期开销。
一、整体在粒子滤波算法中的位置
这一段代码明确对应PARTICLE FILTER ALGORITHM中的:
UPDATE STATES AND WEIGHTS
也就是对每个粒子执行状态预测 + 权重更新,在数学上可以写成:
∀i=1,…,N:{x(i)←f!(x(i))w(i)←g!(x(i)) \forall i = 1,\dots,N: \quad \begin{cases} x^{(i)} \leftarrow f!\left(x^{(i)}\right) \\ w^{(i)} \leftarrow g!\left(x^{(i)}\right) \end{cases}∀i=1,…,N:{x(i)←f!(x(i))w(i)←g!(x(i))
其中:
- x(i)x^{(i)}x(i)是第iii个粒子的状态
- w(i)w^{(i)}w(i)是对应的权重
- fff是状态转移模型(prediction)
- ggg是观测模型(update)
二、states和weights:把“结构体数组”投影成“数学向量”
autostates=particles|std::views::transform(&T::state);autoweights=particles|std::views::transform(&T::weight);这两行的语义是:
把
std::vector<T>这个“粒子结构体的集合”,分别投影成
状态序列和权重序列
在数学上等价于从集合:
(x(i),w(i))i=1N {(x^{(i)}, w^{(i)})}_{i=1}^N(x(i),w(i))i=1N
构造出两个“视图”:
x(i)∗i=1N和w(i)∗i=1N {x^{(i)}}*{i=1}^N \quad\text{和}\quad {w^{(i)}}*{i=1}^Nx(i)∗i=1N和w(i)∗i=1N
但关键点在于:
这里没有创建新的数组,只是建立了“如何访问”的规则,states/weights都是惰性的、按引用的 view。
三、第一条std::ranges::transform:状态预测
std::ranges::transform(states,states.begin(),std::move(state_update_fn));1⃣ ranges::transform 的精确形式
你用的是这个重载:
transform(input_range,output_iterator,unary_function)语义是:
对
input_range中的每个元素x,
计算unary_function(x),
并把结果写入output_iterator指向的位置。
2⃣ 为什么 input 和 output 指向同一个states
这里:
- 输入是
states - 输出起点是
states.begin()
这意味着这是一个就地变换(in-place transform):
x←f(x) x \leftarrow f(x)x←f(x)
对应粒子滤波中的:
x(i)←f!(x(i)) x^{(i)} \leftarrow f!\left(x^{(i)}\right)x(i)←f!(x(i))
由于states是对p.state的引用视图,所以这一行实际上等价于:
for(auto&p:particles){p.state=state_update_fn(p.state);}但表达方式更贴近数学形式。
3⃣ 为什么std::move(state_update_fn)
state_update_fn是一个函数对象(可能捕获环境):
- 只用一次
- 不再需要保留
std::move允许: - 避免拷贝
- 让 lambda / 仿函数被高效转移
这是性能和语义都正确的写法。
四、第二条std::ranges::transform:权重更新
std::ranges::transform(states,weights.begin(),std::move(reweight_fn));1⃣ 这是一个“从状态到权重”的映射
这里输入是:
states输出写入:
weights.begin()即:
用更新后的状态重新计算权重
数学上正是:
w(i)←g!(x(i)) w^{(i)} \leftarrow g!\left(x^{(i)}\right)w(i)←g!(x(i))
这和粒子滤波中“先 prediction,再 update”是完全一致的。
2⃣ 关键顺序保证
因为上一条 transform 已经完成:
x^{(i)}\leftarrowf(x^{(i)})所以这里用到的是更新后的x(i)x^{(i)}x(i),而不是旧状态。
这保证了算法顺序与理论一致:
x(i)→transitionx′(i);→observation;w′(i) x^{(i)} \xrightarrow{\text{transition}} x'^{(i)} ;\xrightarrow{\text{observation}}; w'^{(i)}x(i)transitionx′(i);observation;w′(i)
3⃣weights也是引用视图
weights.begin()指向的是:
p.weight因此这行代码等价于:
for(auto&p:particles){p.weight=reweight_fn(p.state);}五、把整个 update 阶段合起来看
把这两条transform合起来,你实际上写出了:
for(auto&p:particles){p.state=state_update_fn(p.state);p.weight=reweight_fn(p.state);}但通过 ranges,你得到了:
- 更接近数学表达的形式
- 状态 / 权重逻辑的明确分离
- 不易写错索引、不易遗漏成员
六、从算法设计角度看,这种写法的优势
1⃣ 数学结构直接映射到代码
公式:
(x,w)↦(f(x),g(f(x))) (x, w) \mapsto (f(x), g(f(x)))(x,w)↦(f(x),g(f(x)))
代码:
transform(states,states.begin(),f);transform(states,weights.begin(),g);2⃣ 明确的“数据流”
states→ 更新状态states→ 计算权重- 没有隐式依赖
- 没有混杂循环逻辑
3⃣ 为后续 resample 做好准备
现在你已经得到了:
- 一组更新后的x(i)x^{(i)}x(i)
- 一组未归一化的w(i)w^{(i)}w(i)
接下来只需要:
w~(i)=w(i)∑jw(j) \tilde{w}^{(i)} = \frac{w^{(i)}}{\sum_j w^{(j)}}w~(i)=∑jw(j)w(i)
然后进入resample。
七、一句话总结
这两条
std::ranges::transform用就地变换 + 投影视图,
精确实现了粒子滤波中
x(i)←f(x(i)),w(i)←g(x(i))x^{(i)} \leftarrow f(x^{(i)}),\quad w^{(i)} \leftarrow g(x^{(i)})x(i)←f(x(i)),w(i)←g(x(i))
同时保持了代码的数学清晰性、零额外内存开销和高度可组合性。
voidfilter(std::vector<T>&particles,StateUpdateFn<T>autostate_update_fn,ReweightFn<T>autoreweight_fn){// 将粒子集合视为一个 range,通过 transform 视图“投影”出所有粒子的状态(state)// states 不是拷贝,而是一个惰性视图,逻辑上等价于:// states[i] ≡ particles[i].stateautostates=particles|std::views::transform(&T::state);// 同理,将粒子集合投影出权重(weight)// weights[i] ≡ particles[i].weightautoweights=particles|std::views::transform(&T::weight);// === Prediction(状态预测)阶段 ===// 对每一个粒子的 state 执行状态转移函数:// x_i ← f(x_i)// 这里使用 ranges::transform:// - 输入:states// - 输出:仍然写回 states.begin()// 即“原地更新”每个粒子的状态std::ranges::transform(states,states.begin(),std::move(state_update_fn));// === Update(权重更新)阶段 ===// 根据更新后的状态重新计算权重:// w_i ← g(x_i)// 输入是 states,输出写入 weights.begin()// 即每个粒子的 weight 被重新赋值std::ranges::transform(states,weights.begin(),std::move(reweight_fn));// === Resample(重采样)阶段 ===// 创建随机数生成器(Mersenne Twister)// random_device 用作种子,保证每次运行具有随机性autogen=std::mt19937{std::random_device()()};// 使用当前粒子的权重构造一个离散概率分布// std::discrete_distribution 会自动将权重归一化:// P(i) = w_i / sum_j w_j// dist(gen) 的返回值是一个索引 i,表示被选中的粒子下标autodist=weights|std::ranges::to<std::discrete_distribution<std::size_t>>();// 构造新的粒子集合,用于存放重采样后的粒子// 新集合大小与原集合相同autonew_particles=std::vector<T>{};new_particles.reserve(particles.size());// 多项式重采样(Multinomial Resampling):// 重复 N 次:// 1. 根据权重分布随机抽取一个粒子索引// 2. 将该粒子复制到新集合中// 高权重粒子可能被多次复制,低权重粒子可能被淘汰for(std::size_t i=0;i<particles.size();++i){new_particles.push_back(particles[dist(gen)]);}// 用重采样后的粒子集合替换原集合// 逻辑上完成一次完整的粒子滤波迭代std::swap(particles,new_particles);}一、最终推荐结构(对外安全接口 + 内部实现)
#include<ranges>#include<random>#include<generator>// =======================// 内部实现:只接受 view// =======================template<typenameV,typenameRNG,typenameP=std::ranges::range_value_t<V>>requiresstd::ranges::view<V>&&// 必须是 view(生命周期安全)std::ranges::random_access_range<V>&&// 需要 O(1) 随机索引std::uniform_random_bit_generator<RNG>&&// 合法 RNGParticleLike<P>// 粒子概念(有 weight)std::generator<constP&>sample_impl(V particles,RNG&gen){// 1⃣ 从粒子 view 中“提取权重”,构造离散分布// 数学意义:Pr(i = k) = w_kautodist=particles|std::views::transform([](constP&p){returnp.weight;// 提取每个粒子的权重})|std::ranges::to<std::discrete_distribution<std::size_t>>();// 2⃣ 无限惰性生成样本// 每次 co_yield 都是一次“有放回抽样”while(true){// dist(gen) → 根据权重随机生成索引 i// 返回的是对原粒子的 const 引用(零拷贝)co_yieldparticles[dist(gen)];}}二、对外接口:把“任意 range”安全地转成 view
// =======================// 对外接口:接受任意 viewable_range// =======================template<typenameR,typenameRNG>requiresstd::ranges::viewable_range<R>&&// 能被 views::all 包装std::uniform_random_bit_generator<RNG>autosample(R&&particles,RNG&gen){// std::views::all 的作用://// 1⃣ 如果 particles 本身是 view → 原样返回// 2⃣ 如果是 lvalue 容器 → 生成 ref_view(不拷贝)// 3⃣ 如果是 rvalue 容器 → 编译期拒绝(避免悬垂引用)//// 这是一个“刻意的安全设计”returnsample_impl(std::views::all(std::forward<R>(particles)),gen);}三、使用示例(配合语义理解)
std::vector<Particle>particles=/* ... */;std::mt19937 gen{std::random_device{}()};// 安全:vector 是 lvalue → ref_viewautosamples=sample(particles,gen);// 惰性 + 无限autofirst_100=samples|std::views::take(100);// 编译失败(这是好事)// 临时 vector 无法延长生命周期autobad=sample(std::vector<Particle>{},gen);四、关键语义点速查表
std::generator<constP&>不是返回粒子副本,而是返回对“原粒子集合”的引用
数学上等价于:
X0,X1,X2,⋯∼Discrete(w0,…,wN−1) X_0, X_1, X_2, \dots \sim \text{Discrete}(w_0, \dots, w_{N-1})X0,X1,X2,⋯∼Discrete(w0,…,wN−1)
工程上等价于:
- 无拷贝
- 可无限
- 可与
views::take / transform / filter组合
五、一句话注释总结(给代码 reviewer 用)
这是一个按权重定义概率测度的、惰性的、无限的随机 view,
使用std::generator实现有放回重采样,
并通过std::views::all在接口层保证生命周期安全。
在数学上,重采样可以描述为:
对i=1,…,Ni = 1,\dots,Ni=1,…,N,独立地从离散分布中采样索引kik_iki:
ki∼Categorical(w(1),w(2),…,w(N)) k_i \sim \text{Categorical}(w^{(1)}, w^{(2)}, \dots, w^{(N)})ki∼Categorical(w(1),w(2),…,w(N))
然后构造新的粒子集合:
x′(i)=x(ki),w′(i)=1N x'^{(i)} = x^{(k_i)}, \quad w'^{(i)} = \frac{1}{N}x′(i)=x(ki),w′(i)=N1
在代码中,前半部分已经完成了:
x(i)←f(x(i)),w(i)←g(x(i)) x^{(i)} \leftarrow f(x^{(i)}), \qquad w^{(i)} \leftarrow g(x^{(i)})x(i)←f(x(i)),w(i)←g(x(i))
现在进入RESAMPLE。
autogen=std::mt19937{std::random_device()()};这一行创建了一个伪随机数引擎:
std::mt19937是 Mersenne Twister- 周期长、统计性质好,适合蒙特卡洛方法
std::random_device()用作种子,使每次运行的采样不同
在概率意义上,这是之后所有随机抽样的随机源。
autodist=weights|std::ranges::to<std::discrete_distribution<std::size_t>>();这是整段代码中最“关键”的一行。
这里发生的事情是:
weights是一个view,按顺序提供:
w(1),w(2),…,w(N) w^{(1)}, w^{(2)}, \dots, w^{(N)}w(1),w(2),…,w(N)std::ranges::to<std::discrete_distribution<>>会:- 读取整个权重序列
- 构造一个离散分布对象
在概率论中,这个分布满足:
P(K=i)=w(i)∑jw(j) P(K = i) = \frac{w^{(i)}}{\sum_j w^{(j)}}P(K=i)=∑jw(j)w(i)
注意一个非常重要的细节:std::discrete_distribution自动做归一化,因此你不需要显式计算:
w~(i)=w(i)∑jw(j) \tilde{w}^{(i)} = \frac{w^{(i)}}{\sum_j w^{(j)}}w~(i)=∑jw(j)w(i)
这也是为什么重采样阶段可以直接接在权重计算之后。
autonew_particles=std::vector<T>{};new_particles.reserve(particles.size());这里准备一个新的粒子容器:
- 大小和原粒子集相同
- 预留容量,避免多次重新分配
在算法意义上,这是要构造新的集合:
x′(1),x′(2),…,x′(N) {x'^{(1)}, x'^{(2)}, \dots, x'^{(N)}}x′(1),x′(2),…,x′(N)
for(std::size_t i=0;i<particles.size();++i){new_particles.push_back(particles[dist(gen)]);}这是重采样的核心循环。
每一次循环都执行以下步骤:
dist(gen):- 从离散分布中抽取一个索引kkk
- 满足:
P(k=j)=w(j)∑lw(l) P(k = j) = \frac{w^{(j)}}{\sum_l w^{(l)}}P(k=j)=∑lw(l)w(j)
particles[k]:- 取出被选中的粒子
- 高权重粒子更容易被多次选中
- 低权重粒子可能一次都不会被选中
push_back:- 将该粒子复制到新集合中
整体等价于:
∀i:x′(i)=x(ki),ki∼Categorical(w) \forall i: \quad x'^{(i)} = x^{(k_i)}, \quad k_i \sim \text{Categorical}(w)∀i:x′(i)=x(ki),ki∼Categorical(w)
这正是多项式重采样(multinomial resampling)。
- 将该粒子复制到新集合中
std::swap(particles,newparticles);(此处应为new_particles,假设是笔误)
这一步完成集合替换:
- 原
particles被丢弃 - 新集合成为当前粒子集
- 逻辑上完成了一次完整的粒子滤波迭代
在算法意义上,现在系统处于状态:
x(i)∗i=1N服从后验分布 p(x∣z∗1:t) {x^{(i)}}*{i=1}^N \quad\text{服从后验分布 } p(x \mid z*{1:t})x(i)∗i=1N服从后验分布p(x∣z∗1:t)
并且粒子权重已经“隐式均匀化”(即虽然代码里没改权重,但常见做法是在下一轮重新计算)。
总体一口气总结
这段RESAMPLE代码完成了以下数学—工程映射:
- 用
std::discrete_distribution精确表达
Categorical(w(1),…,w(N)) \text{Categorical}(w^{(1)}, \dots, w^{(N)})Categorical(w(1),…,w(N)) - 用标准库保证权重归一化的正确性
- 用简单循环实现多项式重采样
- 用
swap保证数据结构原地更新、接口不变
最终形成的完整粒子滤波步骤是:
(x(i),w(i))→prediction(x′(i),w(i))→update(x′(i),w~(i))→resample(x′′(i),1N) (x^{(i)}, w^{(i)}) \xrightarrow{\text{prediction}} (x'^{(i)}, w^{(i)}) \xrightarrow{\text{update}} (x'^{(i)}, \tilde{w}^{(i)}) \xrightarrow{\text{resample}} (x''^{(i)}, \tfrac{1}{N})(x(i),w(i))prediction(x′(i),w(i))update(x′(i),w~(i))resample(x′′(i),N1)
一、三步流程的本质含义
给出的三步:
1. Generate random samples (with replacement) 2. Take N of those samples 3. Create a new particle set在粒子滤波中,实际上描述的是重采样阶段:
从当前带权粒子集合中,按照权重概率分布,有放回地抽样NNN次,生成一个新的粒子集合。
1⃣ Generate random samples (with replacement)
含义
- 抽样是**有放回(with replacement)**的
- 同一个粒子可能被抽中0 次、1 次、或多次
数学上:
给定当前粒子集
x1,x2,…,xM {x_1, x_2, \dots, x_M}x1,x2,…,xM
及其归一化权重
∑i=1Mwi=1 \sum_{i=1}^{M} w_i = 1i=1∑Mwi=1
每一次抽样都是从如下离散分布中独立采样:
P(X=xi)=wi P(X = x_i) = w_iP(X=xi)=wi
这意味着: - 权重越大,被复制到新集合中的次数期望越多
- 权重小的粒子会逐渐“消失”
2⃣ TakeNNNof those samples
含义
- 抽样次数固定为NNN
- 通常N=MN = MN=M(保持粒子数量不变)
如果我们用KiK_iKi表示粒子xix_ixi在新集合中被选中的次数,那么:
(K1,K2,…,KM)∼Multinomial(N;w1,w2,…,wM) (K_1, K_2, \dots, K_M) \sim \text{Multinomial}(N; w_1, w_2, \dots, w_M)(K1,K2,…,KM)∼Multinomial(N;w1,w2,…,wM)
并且满足期望关系:
E[Ki]=N⋅wi \mathbb{E}[K_i] = N \cdot w_iE[Ki]=N⋅wi
这正是粒子滤波“用数量代替权重”的核心思想。
3⃣ Create a new particle set
含义
- 新粒子集只包含状态xix_ixi
- 权重被重置为均匀分布
即:
winew=1N w_i^{\text{new}} = \frac{1}{N}winew=N1
这样做的目的是: - 避免数值退化(多数粒子权重趋近 0)
- 把概率信息“编码”进粒子的出现次数
二、SAMPLE VIEW / std::ranges::sample 对照理解
你列出的这一部分,本质上是在比较不同“抽样模型”是否适合粒子重采样。
下面逐条对照说明
std::ranges::sample(C++20)
标准语义:
- Without replacement(无放回)
- Fixed size(固定大小)
- 权重不可控(等概率)
也就是说:
P(X=xi)=1M P(X = x_i) = \frac{1}{M}P(X=xi)=M1
这不符合粒子滤波重采样的数学需求,因为: - 粒子滤波要求P(X=xi)=wiP(X=x_i)=w_iP(X=xi)=wi
- 且必须with replacement
➡结论:std::ranges::sample不适合粒子滤波重采样
三、Algorithm(eager) vs View(lazy)
这是一个C++ 设计层面的关键区分。
1⃣ Algorithm(急切算法,eager)
特征:
- 一次性生成结果
- 有确定的大小
- 适合 “我要立刻得到NNN个粒子”
典型实现:
std::vector<Particle>new_particles;new_particles.reserve(N);for(inti=0;i<N;++i){new_particles.push_back(draw_weighted());}数学上就是:
x(k)∼∑i=1Mwiδ(x−xi) x^{(k)} \sim \sum_{i=1}^M w_i \delta(x - x_i)x(k)∼i=1∑Mwiδ(x−xi)
非常符合粒子滤波
2⃣ View(惰性视图,lazy)
特征:
- 表示的是“一个抽样过程”
- 可能是无限的
- 元素按需生成
例如一个weighted_sample_view:
autosamples=weighted_sample_view(particles,weights);autofirst_N=samples|std::views::take(N);数学上可以理解为:
Xkk=1∞,Xk∼iidw {X_k}_{k=1}^{\infty}, \quad X_k \stackrel{iid}{\sim} wXkk=1∞,Xk∼iidw
理论上完全正确
非常适合用 C++ ranges 表达
四、Fixed size vs Infinite
| 属性 | 粒子滤波需求 |
|---|---|
| Fixed size | (抽样过程本身不固定) |
| Infinite | (抽样是 i.i.d 过程) |
正确模型是:
一个无限的、带权的随机样本流,
然后take(N)得到有限粒子集。
数学表达:
Particles=X1,X2,…,Xk∼w \text{Particles} = {X_1, X_2, \dots}, \quad X_k \sim wParticles=X1,X2,…,Xk∼w
再截断:
X1,…,XN {X_1, \dots, X_N}X1,…,XN
五、With replacement vs Without replacement(关键区别)
Without replacement
- 每个粒子最多被选一次
- 概率分布随抽样过程变化
- 不符合多项分布
With replacement(粒子滤波要求)
- 每次抽样独立
- 概率恒定为wiw_iwi
- 服从多项分布
(K1,…,KM)∼Multinomial(N,w) (K_1, \dots, K_M) \sim \text{Multinomial}(N, w)(K1,…,KM)∼Multinomial(N,w)
六、All elements same probability vs Weighted probability
等概率(std::ranges::sample)
P(X=xi)=1M P(X = x_i) = \frac{1}{M}P(X=xi)=M1
加权概率(粒子滤波)
P(X=xi)=wi P(X = x_i) = w_iP(X=xi)=wi
其中:
wi∝p(zt∣xi) w_i \propto p(z_t \mid x_i)wi∝p(zt∣xi)
七、总结一句话(核心理解)
粒子滤波的重采样 = 一个“按权重分布、可无限生成、带放回的随机视图”,再
take(N)得到新的粒子集合。
一、问题本质:这是在做什么?
你描述的三步:
- Generate random samples (with replacement)
- Take N of those samples
- Create a new particle set
本质上就是粒子滤波(Particle Filter)中的重采样(Resampling)。
二、数学模型(为什么用discrete_distribution)
设当前粒子集合为:
P=p0,p1,…,pM−1 \mathcal{P} = {p_0, p_1, \dots, p_{M-1}}P=p0,p1,…,pM−1
每个粒子有一个权重:
wi≥0,∑i=0M−1wi=1 w_i \ge 0,\quad \sum_{i=0}^{M-1} w_i = 1wi≥0,i=0∑M−1wi=1
重采样的目标是构造一个新的序列:
pk0,pk1,pk2,… p_{k_0}, p_{k_1}, p_{k_2}, \dotspk0,pk1,pk2,…
其中每个索引kjk_jkj独立同分布,满足:
Pr(kj=i)=wi \Pr(k_j = i) = w_iPr(kj=i)=wi
关键点
- 有放回(with replacement)
- 每次抽样独立
- 概率由权重决定
- 理论上是无限序列
这正好对应:
std::discrete_distribution<std::size_t>三、为什么std::ranges::sample不适合?
你列的对比非常关键,我们逐条解释:
| 特性 | std::ranges::sample | 你的 SAMPLE VIEW |
|---|---|---|
| 惰性 | eager | lazy |
| 是否无限 | fixed size | infinite |
| 是否有放回 | without replacement | with replacement |
| 概率 | 均匀 | 按权重 |
| 抽样模型 | 组合 | 概率分布 |
结论std::ranges::sample在语义上就不匹配粒子滤波的重采样问题。
四、SAMPLE VIEW 的语义定义(非常重要)
Definition
Given a particle range and a random number generator (RNG), generate alazy computed infinite sequenceof random sampleswith replacement.
也就是说:
sample(P,RNG)→View \text{sample}(P, \text{RNG}) \rightarrow \text{View}sample(P,RNG)→View
而不是:
sample(P,RNG,N)→Container \text{sample}(P, \text{RNG}, N) \rightarrow \text{Container}sample(P,RNG,N)→Container
五、为什么用std::generator(C++23)
1⃣ 无限序列 + 惰性
while(true){co_yieldparticles[dist(gen)];}语义上表示:
x0,x1,x2,… x_0, x_1, x_2, \dotsx0,x1,x2,…
每次co_yield才计算一个样本。
2⃣ 与 ranges 完美匹配
autosamples=sample(particles,gen);autofirst_100=samples|std::views::take(100);这正是range pipeline 思维。
六、权重 → 离散分布的构造
autodist=particles|std::views::transform(&P::weight)|std::ranges::to<std::discrete_distribution<std::size_t>>();数学上等价于:
dist∼Discrete(w0,w1,…,wM−1) \text{dist} \sim \text{Discrete}(w_0, w_1, \dots, w_{M-1})dist∼Discrete(w0,w1,…,wM−1)
然后每次:
dist(gen)// 产生索引 i满足:
Pr(i=k)=wk \Pr(i = k) = w_kPr(i=k)=wk
七、返回const P&的深层含义(非常关键)
std::generator<constP&>为什么不是P?
- 拷贝粒子(可能很大)
- 破坏“视图”的零开销语义
语义等价于:
“我只是引用已有粒子集合中的元素”
这正是view 的本质。
八、生命周期问题:为什么临时对象会炸?
错误用法
autooutput=sample(std::vector{Particle{}},gen);问题在于:
std::vector{Particle{}}是一个临时对象,在表达式结束后立刻销毁。
但你的 generator 内部:
co_yieldparticles[...];// 引用!等价于:
dangling reference \text{dangling reference}dangling reference
未定义行为
九、三种设计方案的对比(你列得非常专业)
方案 1:按引用接收 range(最快,但危险)
std::generator<constP&>sample(R&&particles,RNG&gen);| 情况 | 是否安全 |
|---|---|
| lvalue | |
| temporary |
方案 2:按值接收 container(安全,但复制)
std::generator<constP&>sample(R particles,RNG&gen);语义变为:
“我拥有这份粒子集合”
安全
复制开销
不再是 view
方案 3:正确的 view 设计(推荐)
Step 1:实现只接受 view 的内部函数
template<typenameV,typenameRNG>requiresstd::ranges::view<V>&&std::ranges::random_access_range<V>std::generator<constP&>sample_impl(V particles,RNG&gen);Step 2:外部接口用std::views::all
template<typenameR,typenameRNG>requiresstd::ranges::viewable_range<R>autosample(R&&particles,RNG&gen){returnsample_impl(std::views::all(std::forward<R>(particles)),gen);}十、std::views::all的魔法(核心)
std::views::all(r)的规则:
| r 是什么 | 返回 |
|---|---|
| view | 原样 |
| lvalue range | ref_view |
| rvalue range | (C++23 禁止) |
这正是你看到的:
autooutput=sample(input,gen);//autooutput=sample(std::vector{},gen);//这是刻意设计的安全失败。
十一、整体设计哲学总结
你这个 SAMPLE VIEW:
不是算法(algorithm)
不是容器(container)
是一个概率意义上的无限 view
它满足:
- 惰性(lazy)
- 无边界(infinite)
- 带权重(non-uniform)
- 可组合(ranges pipeline)
- 零拷贝(reference semantics)
在粒子滤波语境下,这是一个非常干净、现代、正确的 C++23 设计。
一、编译错误在“本质上”说了什么?
报错核心信息(提炼)
no match for 'operator|' operand types are: std::ranges::owning_view<std::vector<Particle>> and std::views::transform(...)也就是说:
owning_view<vector<Particle>> | views::transform不成立
二、为什么会出现owning_view?
看这行代码:
returnsample_impl(std::views::all(std::forward<R>(particles)),gen);std::views::all的规则(关键)
| 传入的 range | 返回的 view |
|---|---|
| 本身就是 view | 原样返回 |
| lvalue 容器 | ref_view<T> |
| rvalue 容器 | owning_view<T> |
| 所以: |
sample(std::vector<Particle>{},gen);会变成:
sample_impl(std::ranges::owning_view<std::vector<Particle>>,gen);这一步是标准规定的,而且是为了延长临时对象生命周期。
三、那为什么owning_view不能直接| transform?
这是C++23 ranges 的一个非常反直觉但标准一致的点。
问题不在owning_view不是 view
它是 view
问题在这里:
particles|std::views::transform(...)operator|的约束是:
viewable_range<R>而owning_view<T>&不满足viewable_range。
为什么标准要这么设计?
因为:
owning_view<T>语义是:
“我拥有这个 range,一旦我被移动,内部状态就可能被掏空”
而| adaptor语法可能会隐式移动左操作数。
为了防止这种:
autox=owning_view(...)|transform;// 可能隐式 move标准刻意禁止这种用法。
这是一个防止隐式 use-after-move 的设计决策。
四、为什么ref_view(particles)可以?
你后来改成了:
autodist=std::ranges::ref_view(particles)|std::views::transform([](constP&p){returnp.weight;})|std::ranges::to<std::discrete_distribution<std::size_t>>();关键点
particles// 类型:V(可能是 owning_view)ref_view(particles)// 类型:ref_view<V>而:
ref_view<V>
是 view
是轻量、不可 owning 的借用
不会被 move 掏空
所以:
ref_view(particles)|transform满足viewable_range
五、从语义上解释这一步在干嘛(非常重要)
std::ranges::ref_view(particles)语义等价于一句话:
“我只是想遍历
particles,但我不想转移或消费它的所有权。”
这正好符合你这里的需求:
- 你要用
particles:- 一次构造
dist - 多次索引
particles[dist(gen)]
- 一次构造
- 你不希望
particles在管道中被 move
六、这和数学模型是完全一致的
你在做的事是:
- 从粒子集合pi{p_i}pi中提取权重wi{w_i}wi
- 构造离散分布:
Pr(i=k)=wk\Pr(i = k) = w_kPr(i=k)=wk - 多次采样索引iii
第 1 步是“只读遍历”
所以:
ref_view(particles)在数学意义上就是:
pi→read-onlywi {p_i} \xrightarrow{\text{read-only}} {w_i}piread-onlywi
不涉及 ownership 的变化
七、为什么这个改法是“标准推荐写法”?
你现在的实现:
autodist=std::ranges::ref_view(particles)|std::views::transform(...)|std::ranges::to<std::discrete_distribution<std::size_t>>();它同时满足:
| 维度 | 结果 |
|---|---|
| lvalue 容器 | √ |
| rvalue 容器 | (通过 owning_view + ref_view) |
| 生命周期 | √ |
| 不隐式 move | √ |
| ranges 语义 | √ |
这是C++23 下能写出的最稳妥版本之一。
八、为什么现在这两种调用都能工作?
autoinput=std::vector{Particle{}};autooutput1=sample(input,gen);// ref_view<vector>autooutput2=sample(std::vector{Particle{}},gen);// owning_view + ref_view原因总结为一句话:
owning_view负责“活着”,ref_view负责“被遍历”。
九、你后面提到std::views::generate(C++26)是对的
你给的这个版本:
returnstd::views::generate([particles,dist=std::move(dist),&gen]{returnparticles[dist(gen)];});语义变化
- 不再需要
std::generator - 完全落在 ranges 世界
- 无限 lazy view
- 性能上通常更好(无 coroutine frame)
这是C++26 的 Tier-1 设计方向,你判断非常准。
十、一句话总结(非常关键)
这个错误不是你写错了,而是你刚好撞到了 C++23 ranges 对 ownership + piping 的边界条件。
你的最终修复:
std::ranges::ref_view(particles)|views::transform是:
- 标准语义正确
- 生命周期安全
- 与概率模型一致
- 工业级可维护
一、问题背景:我们到底想要什么?
在粒子滤波(Particle Filter)重采样中,我们要做的是:
- 根据权重分布随机抽样
- 允许重复抽到同一个粒子(有放回)
- 可以无限抽(直到 take N 为止)
- 支持 range pipeline(
|)组合
数学模型(核心)
假设有MMM个粒子,每个粒子有权重wiw_iwi,满足:
∑i=1Mwi=1 \sum_{i=1}^{M} w_i = 1i=1∑Mwi=1
一次采样的概率分布是:
P(X=i)=wi P(X = i) = w_iP(X=i)=wi
每一次采样相互独立,这是一个离散分布的独立同分布(i.i.d.)采样过程。
这正是std::discrete_distribution的语义。
二、为什么不能用std::ranges::sample?
| 特性 | std::ranges::sample | 你的sample_view |
|---|---|---|
| 是否是 view | eager 算法 | lazy view |
| 是否无限 | 固定 N | 无限 |
| 是否有放回 | 无放回 | 有放回 |
| 是否支持权重 | 等概率 | 按权重 |
| 是否可 pipeline |
本质不同,所以必须自己写 view。
三、sample_view的整体设计
template<typenameV,typenameRNG,typenameP=std::ranges::range_value_t<V>>requiresstd::ranges::view<V>&&std::ranges::random_access_range<V>&&std::uniform_random_bit_generator<RNG>&&ParticleLike<P>classsample_view:publicstd::ranges::view_interface<sample_view<V,RNG,P>>{...};逐条解释requires
std::ranges::view<V>V本身是 view(或被views::all包装后是 view)
std::ranges::random_access_range<V>- 需要
first_ + index - 因为采样是通过索引访问
std::uniform_random_bit_generator<RNG>std::discrete_distribution的要求
ParticleLike<P>- 约束粒子必须有
weight成员
四、构造函数:权重 → 离散分布
sample_view(V base,RNG&gen):base_{std::move(base)},first_{std::ranges::begin(base_)},gen_{std::addressof(gen)},dist_{std::ranges::ref_view(base_)|std::views::transform(&P::weight)|std::ranges::to<std::discrete_distribution<std::size_t>>()}{}逐行解释
base_{std::move(base)}- 保存底层 range(粒子集合)
first_{std::ranges::begin(base_)}- 缓存起始迭代器,用于
first_ + index
gen_{std::addressof(gen)}- 只保存 RNG 指针
- 避免复制 RNG(语义 + 性能)
dist_{...}这是关键:
std::ranges::ref_view(base_)- 不复制粒子,仅引用
std::views::transform(&P::weight)- 把粒子映射成权重序列
std::ranges::to<std::discrete_distribution<std::size_t>>()- 构造离散分布:
P(i)=wi∑jwj P(i) = \frac{w_i}{\sum_j w_j}P(i)=∑jwjwi
五、这是一个「无限 view」
autobegin(){returniterator{this};}autoend()constnoexcept{returnstd::unreachable_sentinel;}关键点
- 没有真实的 end
- 这是一个无限序列
- 使用者必须配合:
take(N)take_until_kld()六、核心采样逻辑:next()
autonext(){returnfirst_+dist_(*gen_);}数学含义
index~Discrete({w₀,w₁,...,wₙ})returnbase[index]也就是:
Xk∼Categorical(w) X_k \sim \text{Categorical}(w)Xk∼Categorical(w)
七、迭代器语义(Input Iterator)
classiterator{sample_view*parent_;public:usingvalue_type=P;usingdifference_type=std::ptrdiff_t;iterator&operator++(){return*this;}autooperator*()const{return*parent_->next();}};static_assert(std::input_iterator<iterator>);为什么只是input_iterator?
- 每次解引用都会重新随机
- 不可重复访问
- 不满足 forward / random access 语义
八、为什么禁止 copy?
sample_view(constsample_view&)=delete;sample_view&operator=(constsample_view&)=delete;原因:
- view 内部持有 RNG 指针
- 拷贝会导致随机序列语义混乱
- 明确表达:这是有状态 view
九、CTAD(推导指南)
template<typenameR,typenameRNG,typenameP=std::ranges::range_value_t<R>>sample_view(R&&,RNG&)->sample_view<std::views::all_t<R>,RNG,P>;作用
让你可以写:
sample_view{particles,gen};而不用显式写模板参数。
十、为什么要range_adaptor_closure(C++23)
问题
sample(particles,gen);//particles|sample(gen);// (一开始)原因
|需要的是range adaptor closure
正确做法
template<typenameRNG>classsample_closure:publicstd::ranges::range_adaptor_closure<sample_closure<RNG>>{public:explicitsample_closure(RNG&gen):gen_{gen}{}template<typenameR>autooperator()(R&&range)const{returnsample_view{std::forward<R>(range),gen_};}private:RNG&gen_;};配套函数
template<typenameRNG>autosample(RNG&gen){returnsample_closure{gen};}十一、最终效果(完美)
sample(particles,gen)//sample(gen)(particles)//particles|sample(gen)//十二、真实粒子滤波用法(非常漂亮)
particles=particles|sample(gen)|take(particles.size())|to<std::vector>();数学上等价于:
xi′i=1N∼i.i.d.(w) {x_i'}_{i=1}^N \sim \text{i.i.d.}(w)xi′i=1N∼i.i.d.(w)
十三、总结(重点)
你这个设计的本质是:
一个「有状态、无限、有放回、按权重采样」的 C++23 ranges view
核心亮点
- 完全 lazy
- 完美 pipeline
- 数学语义清晰
- 粒子滤波 / AMCL / MCL 直接复用
- 比 eager 算法更强