简介:面向学习多源数据融合与证据理论的高校学生、科研入门者及工程技术人员,这份仿真实验包围绕Dempster-Shafer证据理论展开,帮助读者在Matlab环境中理解识别框架、基本概率分配及证据合成流程,并完成置信度、信任度与似真度函数的计算验证。识别框架元素个数可在3至5个之间设置,其余参数按实验需要自行选择,同时覆盖两证据融合与三证据融合两类典型场景。压缩包共5个文件,以4个m脚本和1份doc实验报告为主,脚本承担函数计算与融合演示,文档用于记录实验设计与结果分析,整体约96KB,结构精简、便于直接运行和改写。目前已有1428人学习,适合希望从公式推导走向代码实现、补齐证据融合实验环节的读者参考。
1. 三个传感器判断不准的时候,证据理论能救回来多少
多源数据融合里最常被拿来练手的一套理论就是证据理论,而这份基于 MATLAB 的证据理论融合仿真实验代码,把识别框架、BPA 构造、Dempster 组合、信任似然区间和冲突系数串成了一条能跑通的链路。三个传感器对同一批目标做识别,单看谁都不靠谱:一个把目标判成甲型的把握只有 0.45,另一个在乙型上给 0.5 却附带 0.3 的“说不清”,第三份甚至和第一份直接打架。把三份证据按组合规则合到一起,正确类的信任度经常能从 0.45 顶到 0.9 以上,这就是这套东西存在的意义。它适合正在做多传感器目标识别、设备故障诊断、态势评估的开发者,也适合手里已经有公式推导、但缺一组可复现对比实验的人。整套代码不需要任何工具箱,纯 MATLAB 基础语法就能跑,改起来也轻。
2. 技术选型与实验链路:一次融合从 BPA 输入到决策输出
2.1 这个仿真实验到底在算什么
识别框架是整件事的地基。假设要判别的目标有三类,记作 {甲, 乙, 丙},那么框架 Θ 的幂集一共有 2³ = 8 个焦元:空集、三个单元素、三个双元素、一个全集。每个传感器输出的不是单个类别,而是一张长度为 8 的概率分配表,也就是基本概率分配 BPA,满足空集质量为 0、全体之和为 1。
单个传感器给出 0.45 的把握,剩下的 0.55 可能摊在“甲或乙”这种模糊集合上,也可能摊在全集上表示完全不知道。组合规则做的事,就是把多张这样的表按交集关系相乘再归一化,让彼此支持的假设被放大、互相矛盾的假设被压缩。仿真实验要跑的,就是这套流程在上千次随机场景下的正确率和冲突分布。
一个合格的实验脚本至少要有四块输出:融合前后的分类正确率对比、冲突系数 K 的分布直方图、不同证据条数下正确率随条数变化的曲线、以及改进算法与经典 Dempster 规则的横向对比表。少任何一块,实验就只能证明“我跑通了”,证明不了“这套方法比别的强”。
2.2 为什么是 MATLAB 而不是 Python 或 C++
选 MATLAB 做这件事,核心原因是它把矩阵和位运算两件事同时做好了。BPA 本来就是一行向量,多条证据就是一个 S×2^N 的矩阵,批量融合天然是矩阵切片;而焦元的交集运算本质上是二进制位与,MATLAB 的bitand/bitor/bitget直接就能用,不需要自己写位操作工具类。
第二是画图。融合实验里最有说服力的不是某个数字,而是 K 从 0.1 涨到 0.95 时正确率怎么塌下去的曲线,或者改进算法在 K>0.9 时还能不能撑住。MATLAB 的plot/histogram几行就能出图,省掉一大截可视化代码。
第三是性能边界清楚。3 类目标、5 条证据的组合只有 8×8 的循环,一次不到毫秒。真正吃时间的是蒙特卡洛,一万次场景就是一万次完整融合。常见做法是把ds_combine写成函数后,在外层用parfor并行,或者干脆把所有场景的 BPA 堆成一个三维数组做向量化。如果识别框架扩到 5 类以上(32 个焦元),循环次数立刻涨到 1024 量级,这时候 MATLAB 的解释开销就会显出来,得提前留好向量化的口子。
2.3 整体运行链路:从 BPA 到决策的五个环节
一次完整的仿真是一条单向流水线,顺序不能乱:
- 设定识别框架:确定单元素个数 n,生成 2^n 个焦元的编码表。
- 生成证据:按真值类别加噪声,造出 S 条 BPA,噪声强度用可信度参数控制。
- 逐对融合:调用组合规则,得到融合后的 BPA 和每一步的冲突系数 K。
- 区间与决策:算出信任函数 Bel、似然函数 Pl,再用 Pignistic 概率做最终判决。
- 统计与绘图:和真值比对,累计正确率,画曲线。
这五步里最容易出问题的是第 2 步和第 4 步。生成证据时如果忘了把空集质量清零,后面归一化会把错误一路带下去;决策时如果直接用最大 mass 而不是最大 Pignistic 概率,全集焦元一旦分走大块质量,判决就会飘。
提示:把第 2 步的随机数种子固定下来(
rng(2024)之类),否则两次跑出来的正确率差几个百分点,你根本分不清是算法改进起了作用还是随机波动。
3. 目录结构与数据约定:用二进制掩码把 2^N 个焦元压进一行向量
3.1 典型目录树与文件职责
这类仿真包的文件数量不多,但职责必须分清,否则改一个组合规则要翻三个文件。常见做法是按“核心算法 / 改进算法 / 数据生成 / 工具函数 / 实验脚本”五层切开:
dsfusion/ ├── main.m % 入口:跑一次三段式实验 ├── demo_basic.m % 最小可跑示例,两条证据手工填数 ├── demo_compare.m % 四种算法横向对比 ├── conf/ │ └── sim_config.m % n、S、场景数、随机种子集中在这 ├── core/ │ ├── ds_combine.m % 两条证据的 Dempster 组合 │ ├── ds_combine_n.m % 多条证据顺序融合 │ ├── bel_pl.m % 信任函数与似然函数 │ ├── pignistic.m % Pignistic 概率变换 │ └── decide.m % 判决规则 ├── improve/ │ ├── yager_combine.m % 冲突全部丢给全集 │ ├── murphy_combine.m % 证据平均后自组合 │ ├── deng_weight.m % 距离加权平均 │ └── jousselme_dist.m % Jousselme 证据距离 ├── gen/ │ ├── gen_bpa.m % 干净证据生成 │ └── gen_bpa_noisy.m % 带冲突的证据生成 └── utils/ ├── idx2set.m % 编码转焦元名字,调试用 └── set2idx.m % 焦元名字转编码conf/sim_config.m单独拎出来是个小设计但很省事:n 改成 4、场景数改成 5000,只动这一个文件,其他脚本一行不改。utils里的两个转换函数平时用不上,但调试时把编码 5 打印成{甲,丙}能救命。
3.2 数据结构约定:一行向量就是一个 BPA
整套代码最关键的约定只有一条:长度为 2^n 的行向量,第 i 个元素对应焦元编码 i-1 的二进制位集。n=3 时,编码 5 写成二进制是 101,从低位到高位依次代表甲、乙、丙,所以 5 表示 {甲, 丙};编码 7 是 111,表示全集。
| 编码 | 二进制 | 焦元 | 含义 |
|---|---|---|---|
| 0 | 000 | ∅ | 空集,质量恒为 0 |
| 1 | 001 | {甲} | 单元素 |
| 2 | 010 | {乙} | 单元素 |
| 4 | 100 | {丙} | 单元素 |
| 5 | 101 | {甲,丙} | 双元素 |
| 7 | 111 | {甲,乙,丙} | 全集 |
这个约定的好处是交集运算可以直接用bitand:编码 5 和编码 3 做位与得 1,说明 {甲,丙} ∩ {甲,乙} = {甲},直接就是结果焦元的编码,连查表都不用。坏处是下标和编码差 1,写循环时极容易写错,后面避坑那章会专门说。
3.3 实验脚本怎么组织一次公平对比
demo_compare.m的结构值得单独说,因为它决定了你的结论可不可信。正确的写法是同一批证据喂给所有算法,而不是每个算法各生成一批。伪代码逻辑是:外层循环场景数,每个场景生成一个 S×2^N 的证据矩阵 M,然后分别调用经典 Dempster、Yager、Murphy、距离加权四个函数,各自统计是否判对,最后除以场景数。
还有一个细节:改进算法里凡是要用到全体证据的(Murphy 的平均、Deng 的加权),都必须拿到完整的 M 矩阵,不能像经典规则那样逐对折叠。所以函数签名要统一成接受 S×2^N 矩阵,经典规则内部再自己折叠,这样四个算法才能放在同一个循环里跑。
4. 核心模块拆解:组合规则、信任区间与三类冲突改进的完整代码
4.1 Dempster 组合规则的位运算实现
组合规则的定义式是 m₁₂(C) = Σ_{A∩B=C} m₁(A)m₂(B) / (1-K),其中 K = Σ_{A∩B=∅} m₁(A)m₂(B)。用二进制掩码翻译过来就是双重循环加位与:
function [m12, K] = ds_combine(m1, m2) %DS_COMBINE 两条证据的 Dempster 组合 % m1, m2 : 1 x 2^N 行向量,第 i 个元素对应焦元编码 i-1 的二进制位集 % m12 : 组合后的 BPA % K : 冲突系数 if numel(m1) ~= numel(m2) error('ds_combine:sizeMismatch', '两条证据的焦元数量不一致'); end L = numel(m1); m12 = zeros(1, L); K = 0; for i = 0:L-1 if m1(i+1) == 0 continue; % 零质量直接跳过 end for j = 0:L-1 if m2(j+1) == 0 continue; end inter = bitand(i, j); % 交集 = 位与 if inter == 0 K = K + m1(i+1) * m2(j+1); % 交集为空,累进冲突 else m12(inter+1) = m12(inter+1) + m1(i+1) * m2(j+1); end end end if K > 1 - 1e-12 warning('ds_combine:fullConflict', 'K=%.6f 接近 1,归一化已失去意义', K); m12 = m12 / max(1 - K, eps); else m12 = m12 / (1 - K); end end逐段看:L = numel(m1)拿到的是 2^n,也就是幂集大小;外层i和内层j都用 0 起始,是为了和二进制编码对齐,所以取值时统一写m1(i+1)。bitand(i, j)是整段代码的核心,它同时完成了“求交集”和“查结果下标”两件事,省掉一张 2^n × 2^n 的交集查找表。
冲突累加放在inter == 0分支里,注意这里用的是原始质量相乘,不是归一化之后的。归一化只在最后做一次,如果中途每一步都归一化,K 的含义就变了,多证据融合时会严重高估一致性。
K > 1 - 1e-12这个判断是保命的。当两条证据完全互斥(比如一个说必定是甲,另一个说必定是乙),K 会精确等于 1,直接除会产生 Inf 或 NaN,一路污染后面所有统计。这里用max(1-K, eps)兜住,同时抛 warning,让你在跑大规模实验时能立刻发现是哪个场景爆的。
4.2 多证据批量融合与 K 的传递
三条以上证据的组合在数学上不满足结合律的直观形式,但 Dempster 规则本身是可以逐对折叠的:先把 m₁ 和 m₂ 合成 m₁₂,再用 m₁₂ 和 m₃ 合成。折叠写法最省内存,也最贴近工程实现:
function [m, Khist] = ds_combine_n(M) %DS_COMBINE_N 多条证据顺序融合 % M : S x 2^N 矩阵,每行一条证据 % m : 1 x 2^N 融合结果 % Khist : 1 x (S-1),第 k 次折叠时的冲突系数 [S, L] = size(M); if S < 2 error('ds_combine_n:tooFew', '至少需要两条证据'); end m = M(1, :); Khist = zeros(1, S - 1); for k = 2:S [m, Khist(k - 1)] = ds_combine(m, M(k, :)); end endKhist这个输出容易被当成可有可无,其实它是排查冲突来源的关键。如果融合五条证据,Khist 是[0.12 0.35 0.71 0.93],说明第四条证据和前面三条严重对立,而不是整体都乱。工程上常见的处理是:一旦某一步的 K 超过阈值,就把那条证据降权或者剔掉重新融合,这个逻辑在improve目录里可以单独开一个函数。
累计冲突系数和单步 K 不是一回事。如果要做论文里的对比表,报的通常是最后一次折叠的 K 或者总体冲突度,别把 Khist 直接求平均就当成总体冲突,两者量纲不同。
4.3 Bel/Pl 与 Pignistic 决策
融合完拿到 BPA 只是中间结果,最终要落到“判成哪一类”。信任函数 Bel(A) 是 A 的所有子集质量之和,似然函数 Pl(A) 是所有与 A 有交的焦元质量之和,两者夹出的区间 [Bel, Pl] 就是证据对 A 的支持上下界:
function [Bel, Pl] = bel_pl(m) L = numel(m); Bel = zeros(1, L); Pl = zeros(1, L); for A = 0:L-1 for B = 0:L-1 if bitand(B, A) == B % B ⊆ A Bel(A+1) = Bel(A+1) + m(B+1); end if bitand(B, A) ~= 0 % B ∩ A ≠ ∅ Pl(A+1) = Pl(A+1) + m(B+1); end end end endbitand(B, A) == B是判断子集的经典技巧:B 是 A 的子集,当且仅当 B 的所有为 1 的位在 A 里也都是 1,位与之后还是 B 本身。同理bitand(B, A) ~= 0判断有交。这两个条件写反了不会报错,但结果会完全反过来,Bel 和 Pl 互换,非常隐蔽。
判决环节我一般不用最大 mass,而用 Pignistic 概率:
function BetP = pignistic(m) L = numel(m); n = log2(L); BetP = zeros(1, n); for B = 1:L-1 % 跳过空集 cardB = sum(bitget(B-1, 1:n)); % 焦元基数 if cardB == 0 continue; end w = m(B) / cardB; % 质量均分到各单元素 for k = 1:n if bitget(B-1, k) BetP(k) = BetP(k) + w; end end end end为什么要多这一步:假设融合结果是 m({甲})=0.4、m({甲,乙,丙})=0.5、m({乙})=0.1。按最大 mass 判决会选全集,等于没判;Pignistic 把 0.5 均分成三份,甲拿到 0.4+0.167=0.567,乙拿到 0.1+0.167=0.267,判决落到甲,符合直觉。cardB用bitget求和算基数,避免了再维护一张基数表。
4.4 冲突证据的三种改进路线
K 接近 1 时经典规则会失效,工程上有三类常见改法,代码量都不大:
Yager 规则把冲突全部推给全集,不做归一化:m12(全集+1) = m12(全集+1) + K。好处是绝不产生反直觉结论,坏处是 K 大时全集质量接近 1,等于放弃判决。
Murphy 平均法先对所有证据求算术平均,再把平均证据自组合 S-1 次:
function m = murphy_combine(M) S = size(M, 1); mAvg = mean(M, 1); m = mAvg; for k = 2:S m = ds_combine(m, mAvg); end end距离加权法用 Jousselme 距离衡量证据间的相似度,支持度高的证据给大权重:
function d = jousselme_dist(m1, m2) %JOUSSELME_DIST 证据距离,D 矩阵元素 = |A∩B| / |A∪B| L = numel(m1); n = log2(L); D = zeros(L, L); for i = 0:L-1 for j = 0:L-1 u = bitor(i, j); inter = bitand(i, j); if u == 0 D(i+1, j+1) = 1; % 双方都是空集,视作同一焦元 else D(i+1, j+1) = sum(bitget(inter, 1:n)) / sum(bitget(u, 1:n)); end end end dm = (m1 - m2)'; d = sqrt(0.5 * dm' * D * dm); end拿到两两距离后构造相似度矩阵Sim = 1 - d,每行求和减自身得到支持度Sup,归一化就是权重w,加权平均出一个代表证据,再自组合 S-1 次。这套流程比 Murphy 多了一层区分度,在证据质量参差时通常能多撑几个百分点正确率。
三条路线的取舍:数据量小、冲突不严重用经典规则;冲突中等且要求结论保守用 Yager;冲突大且证据条数多,用距离加权。仿真包里把四种都实现,就是为了让对比表有东西可填。
5. 踩坑排查:K 值越界、焦元错位与决策选错目标函数的五个现场
5.1 现象:K 算出来是负数,或者大于 1
原因几乎都是输入的 BPA 没归一化。手工填数时写了m = [0 0.5 0.4 0.1 0 0 0 0],看起来加起来是 1,但如果某个元素被后面一行代码改过,或者从文件读入时少读一位,和就不是 1 了,组合结果自然溢出。
解决办法是在ds_combine入口加一道断言:assert(abs(sum(m1)-1) < 1e-8 && m1(1) == 0, '证据未归一化或空集质量非零')。这道检查在调试阶段建议常开,跑大规模蒙特卡洛时再用一个开关关掉,因为它每次调用都求一次 sum,一万次场景下也是可观开销。
5.2 现象:结果整体错位,判定总是差一类
九成是编码和下标的 0/1 起始搞混了。数据结构约定里编码 1 表示 {甲},但它在向量里是第 2 个元素。写循环时如果图省事写成for i = 1:L再拿m(i)当编码用,整个映射就偏了一位,而且不会报错,只会让正确率莫名其妙地掉到随机猜测水平。
排查手法:调demo_basic.m,手工填两条极其简单的证据,比如 m₁({甲})=0.9、m₁(全集)=0.1,m₂({甲})=0.8、m₂(全集)=0.2,手算融合结果应该是 m({甲})=0.72/0.98≈0.7347、m(全集)=0.02/0.98≈0.0204,其余为 0。对不上就是下标问题。这个手算算例我建议直接写进单元测试,每次改完组合规则先跑它。
5.3 现象:跑蒙特卡洛时报 NaN,且从某个场景开始后面全是 NaN
典型原因是某次融合的 K 恰好等于 1,归一化除零。生成证据的函数如果给两条证据分配了完全互斥的单元素高置信度,K 就会精确等于 1。
解决要分两层:函数内部用max(1-K, eps)兜底,保证不产生 NaN;外层统计时把这类场景单独计数,最后在结果里报一个“完全冲突场景占比”。如果这个比例超过 1%,说明你的证据生成参数设置得太极端,真实传感器不会这么干,该调的是gen_bpa_noisy里的噪声强度,不是算法。
5.4 现象:改进算法在某些场景下反而不如经典规则
先检查决策环节用的是什么。如果改进算法把大量质量推给了全集(Yager 就是这样),而判决仍然用最大 mass,那么全集永远赢,正确率必然崩。这类算法的输出必须配 Pignistic 概率或者最大 Pl 判决,用最大 mass 判决等于把改进的效果全部抹掉。
第二个可能是 Murphy 的平均步骤写成了加权平均但权重没归一化,导致平均证据的和不等于 1。加一句mAvg = mAvg / sum(mAvg)就能验证。
5.5 现象:小规模跑得挺快,场景数一加到一万就卡死
MATLAB 在循环里动态扩展数组的代价是平方级的。如果统计结果写成acc = [acc; isCorrect]这种形式,一万次就是一万次重新分配内存加拷贝。
改成预分配:acc = zeros(numScenes, 1),循环里写acc(s) = isCorrect。如果单次融合本身也慢,把ds_combine里的双重循环用矩阵化改写——先把 m₂ 平铺成对角块,或者用bsxfun展开成三维数组再一次求和,3 类目标下能快五到十倍。再往上还有parfor,但要注意parfor里的随机数流和串行不同,得给每个 worker 单独设种子,否则没法复现。
6. 二次开发:把 3 类目标扩到 4 类并接上时序加权的改造路径
扩类是最常见的需求,改动点集中在三处。第一处是sim_config.m里的n,从 3 改成 4,幂集从 8 涨到 16,所有2^n的硬编码都要换掉——如果在某个文件里看到写死的zeros(1,8),那就是个定时炸弹,得改成zeros(1, 2^n)。第二处是gen_bpa里真值编码的计算,2^(trueIdx-1)这个式子本身通用,但调用它的地方如果传的是 1..3 的枚举值,得同步扩到 1..4。第三处是画图,柱状图的横轴标签数组长度要跟着变,否则bar会报维度不匹配。
% sim_config.m 里扩类时唯一需要改的两行 cfg.n = 4; % 单元素个数 cfg.labels = {'甲','乙','丙','丁'}; % 判决输出用 cfg.numScene = 3000; % 蒙特卡洛场景数接时序加权是第二个改造方向,也更值钱。真实场景里证据不是同时到达的,而是每个采样周期来一批。我一般会把ds_combine_n外面再包一层时间衰减:
function m = ds_combine_temporal(M, lambda) %DS_COMBINE_TEMPORAL 带时间衰减的多帧证据融合 % M : T x 2^N,按时间从旧到新排列 % lambda : 衰减系数,(0,1],越小越健忘 T = size(M, 1); w = lambda .^ (T - (1:T)'); % 越新权重越大 w = w / sum(w); mAvg = sum(M .* w, 1); mAvg = mAvg / sum(mAvg); m = mAvg; for k = 2:T m = ds_combine(m, mAvg); end endlambda取 1 就退化成普通平均,取 0.7 到 0.9 之间比较常用。这个参数不要拍脑袋定,用验证集扫一遍,看正确率随 lambda 的变化曲线在哪儿最平,取那个区间的中点。
验证方法上,我有个雷打不动的习惯:任何改动之后,先跑那个手算可对的两证据算例,再跑一次固定种子的小规模实验(比如 100 个场景),把正确率、平均 K、完全冲突占比这三个数记下来,和改动前的数字并排摆着看。只要有一个数动了超过两个百分点,就回去查,别急着扩到一万个场景——大规模实验只会把问题放大,不会把它藏起来。这套流程从我被一个下标错位的 bug 折腾了整整两天之后就一直这么走,省下来的时间比写代码的时间多得多。希望帮到你。
本文还有配套的精品资源,点击获取