news 2026/10/22 3:02:10

SCMA多用户检测中DS-MPA动态调度消息传递算法原理与实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SCMA多用户检测中DS-MPA动态调度消息传递算法原理与实现

简介:这份资源面向无线通信与5G非正交多址接入方向的学习者与研究人员,聚焦SCMA系统中DS-MPA多用户检测算法的实现与性能评估。包内共4个m文件,压缩包约4KB,涵盖SCMA编码、DS-MPA检测、瑞利衰落信道仿真以及log_sum_exp数值计算等核心模块,构成一套从码字生成、星座映射到迭代检测与误码率统计的完整链路。读者可借助这些脚本理解DS-MPA如何将复杂联合检测分解为多次单用户迭代,并在瑞利信道下观察迭代次数对误码率与误符号率的影响,同时掌握对数似然比计算中避免数值溢出的处理技巧。目前已有289人学习,适合具备MATLAB基础、希望深入SCMA检测算法与链路级仿真的中高级读者参考。

1. 从一次链路仿真翻车说起:DS-MPA 到底在 SCMA 里扮演什么角色

第一次把 SCMA 的链路仿真跑起来时,我盯着误码率曲线看了很久——低信噪比下它和理论值贴得挺近,可一旦信噪比往上走,曲线就像撞了墙,怎么加功率都压不下去。后来把接收端拆开逐级打印,才发现问题出在多用户检测那一环:消息传递算法在因子图上迭代时,收敛得太早,把还没修正过来的置信度当成了最终判决。这个场景几乎每个做 SCMA 的人都会遇到,而 DS-MPA(Dynamic Scheduling Message Passing Algorithm,动态调度消息传递算法)就是冲着这个痛点来的。

SCMA(Sparse Code Multiple Access,稀疏码多址)本身是一种码域非正交多址方案,靠给每个用户分配一本稀疏码本,让多个用户在相同资源块上叠加发送。接收端要做的,是从叠加信号里把每个用户的数据分离出来。最优做法是最大似然检测,但复杂度随用户数指数增长,工程上没法用。消息传递算法把这个问题转化成因子图上的迭代置信度传播,复杂度降下来了,可固定调度顺序又带来新问题:有些边上的消息早就稳定了,还在反复算;有些边明明需要多迭代几轮,却被整体迭代次数卡死。

DS-MPA 的思路很直接:不再让所有资源节点和用户节点按固定顺序、固定轮数更新,而是根据当前消息的收敛状态动态决定下一步更新哪条边、更新几次。残差大的边优先更新,残差小的边跳过。这样一来,同样的误码率目标下,迭代次数能压下来不少,收敛行为也更可控。它适合谁?适合已经在跑 SCMA 链路、发现固定调度 MPA 要么收敛慢要么误码平台压不下去的人;也适合想把检测模块的复杂度再往下压一档的工程实现者。下面我从因子图建模开始,把 DS-MPA 的落地路径一层层拆开。

2. 把 SCMA 检测写成因子图:DS-MPA 的建模前提

2.1 因子图上的三类节点与消息定义

SCMA 的接收信号模型可以写成 y = Σ x_j + n,其中 x_j 是第 j 个用户的码字,n 是噪声。因为码本是稀疏的,每个用户只占用部分资源块,所以这个叠加关系天然就是一张稀疏因子图。图上有两类节点:资源节点(Resource Node,RN)对应每个资源块,用户节点(User Node,VN)对应每个用户。如果用户 j 在资源块 k 上有非零码字分量,就在 RN_k 和 VN_j 之间连一条边。

消息传递算法在这张图上迭代,每条边上跑两个方向的消息:从 VN 到 RN 的消息,表示用户 j 对自身码字的后验置信度;从 RN 到 VN 的消息,表示资源块 k 根据接收信号和其他用户干扰,对用户 j 码字的修正意见。固定调度 MPA 的做法是:每一轮迭代,所有边按固定顺序更新一遍,跑满预设轮数就停。DS-MPA 要改的就是这个“所有边、固定顺序、固定轮数”。

在动手写代码前,先把因子图的邻接关系确定下来。SCMA 码本的稀疏度通常用 d_f 表示每个资源块承载的用户数,d_v 表示每个用户占用的资源块数。这两个参数直接决定因子图上每条边的计算量。常见配置里 d_f 取 3 或 4,d_v 取 2。d_f 越大,单个资源节点上的联合检测维度越高,复杂度涨得越快,这也是 DS-MPA 想通过动态调度省掉冗余计算的原因。

2.2 用 Python 搭一张可复用的因子图

下面这段代码把因子图的邻接表、边列表和消息缓存都建好,后续 DS-MPA 的调度逻辑直接在这上面跑。码本我用随机生成的稀疏码本占位,实际替换成标准码本即可。

import numpy as np def build_factor_graph(num_users, num_resources, dv=2, df=3, seed=0): """ 构建 SCMA 因子图邻接关系。 num_users: 用户数 J num_resources: 资源块数 K dv: 每个用户占用的资源块数 df: 每个资源块承载的用户数 返回: 边列表 edges, 每个VN的邻居RN列表, 每个RN的邻居VN列表 """ rng = np.random.default_rng(seed) # 每个用户随机选 dv 个资源块 vn_to_rn = [] for j in range(num_users): neighbors = rng.choice(num_resources, size=dv, replace=False) vn_to_rn.append(sorted(neighbors.tolist())) # 反向索引 rn_to_vn = [[] for _ in range(num_resources)] for j, rns in enumerate(vn_to_rn): for k in rns: rn_to_vn[k].append(j) # 边列表: (rn, vn) edges = [] for k in range(num_resources): for j in rn_to_vn[k]: edges.append((k, j)) return edges, vn_to_rn, rn_to_vn def init_messages(edges, num_codewords): """初始化每条边两个方向的消息,均匀分布""" msg_vn_to_rn = {} msg_rn_to_vn = {} for (k, j) in edges: msg_vn_to_rn[(k, j)] = np.ones(num_codewords) / num_codewords msg_rn_to_vn[(k, j)] = np.ones(num_codewords) / num_codewords return msg_vn_to_rn, msg_rn_to_vn

这段代码里有两个关键点。第一,vn_to_rn和rn_to_vn是双向索引,DS-MPA 在计算残差时需要快速拿到某个资源节点下所有用户的消息,没有反向索引会反复遍历边列表,效率很低。第二,消息初始化用均匀分布而不是全零,因为后续残差计算依赖消息之间的差异,全零会让所有边的残差一开始都相同,动态调度退化成随机调度。num_codewords是码本大小,常见取 4 或 8,它决定每条边上消息向量的长度。

提示:如果你的码本是标准码本,vn_to_rn的邻居关系要和码本的非零位置严格对应,不能随机生成。随机邻接只适合先跑通算法逻辑,验证调度策略是否有效。

3. DS-MPA 的动态调度逻辑:残差怎么算、边怎么选

3.1 残差定义与调度准则

DS-MPA 的核心是给每条边定义一个残差,残差越大说明这条边上的消息离收敛越远,越应该优先更新。残差通常取当前消息与上一次更新后消息的欧氏距离,或者取消息向量中最大分量与最小分量之差。我一般用前者,因为它对消息的整体变化更敏感。

具体流程是:每轮迭代开始前,先算所有边的残差,按残差从大到小排序,取前 N 条边作为本轮更新集合。N 可以固定,也可以按残差阈值动态确定。更新完这些边后,重新计算残差,进入下一轮。停止条件有两个:一是最大迭代次数,二是所有边的残差都低于阈值。第二个条件往往先触发,这就是 DS-MPA 省计算量的地方。

和固定调度 MPA 相比,DS-MPA 多了一个残差计算和排序的开销,但省下的是大量已经收敛的边的重复更新。在 d_f 较大、用户数较多的场景里,这个交换是划算的。如果用户数很少、因子图很小,残差计算的开销占比反而会上去,这时候固定调度可能更直接。

3.2 可运行的 DS-MPA 单轮迭代实现

下面这段代码实现 DS-MPA 的一轮完整迭代,包括残差计算、边选择和消息更新。资源节点更新时用的是 max-sum 近似,实际工程里也可以换成 max-log 或 exact marginalization,取决于你对复杂度和精度的取舍。

def compute_residuals(edges, msg_vn_to_rn, prev_msg_vn_to_rn): """计算每条VN->RN边的残差""" residuals = {} for e in edges: cur = msg_vn_to_rn[e] prev = prev_msg_vn_to_rn[e] residuals[e] = np.linalg.norm(cur - prev) return residuals def select_edges(residuals, top_n): """按残差从大到小选前 top_n 条边""" sorted_edges = sorted(residuals.items(), key=lambda x: x[1], reverse=True) return [e for e, _ in sorted_edges[:top_n]] def update_rn_to_vn(edge, rn_to_vn, msg_vn_to_rn, received, codebook, noise_var): """ 更新一条 RN->VN 消息。 received: 该资源块上的接收信号 codebook: 码本,shape=(num_users, num_codewords, num_resources) """ k, j = edge neighbors = rn_to_vn[k] num_cw = codebook.shape[1] msg = np.zeros(num_cw) for cw_j in range(num_cw): # 对除 j 外其他用户的码字组合做 max-sum acc = [] for cw_other in range(num_cw): # 简化: 只取一个干扰用户做演示,实际需遍历组合 interference = codebook[neighbors[0]][cw_other][k] if neighbors[0] != j else 0 symbol = codebook[j][cw_j][k] + interference dist = -np.abs(received[k] - symbol) ** 2 / (2 * noise_var) acc.append(dist + np.log(msg_vn_to_rn.get((k, neighbors[0]), np.ones(num_cw)/num_cw)[cw_other] + 1e-12)) msg[cw_j] = max(acc) msg = msg - np.max(msg) msg = np.exp(msg) return msg / (np.sum(msg) + 1e-12) def ds_mpa_iteration(edges, rn_to_vn, msg_vn_to_rn, msg_rn_to_vn, prev_msg_vn_to_rn, received, codebook, noise_var, top_n=8): """执行一轮 DS-MPA 迭代""" residuals = compute_residuals(edges, msg_vn_to_rn, prev_msg_vn_to_rn) selected = select_edges(residuals, top_n) for e in selected: k, j = e # 更新 RN->VN msg_rn_to_vn[e] = update_rn_to_vn(e, rn_to_vn, msg_vn_to_rn, received, codebook, noise_var) # 更新 VN->RN neighbors = [rn for rn in rn_to_vn if rn[1] == j and rn != e] prod = np.ones(codebook.shape[1]) for ne in neighbors: prod *= msg_rn_to_vn.get(ne, np.ones(codebook.shape[1])) msg_vn_to_rn[e] = prod / (np.sum(prod) + 1e-12) return msg_vn_to_rn, msg_rn_to_vn, selected

这段代码里有几个参数需要重点说明。top_n控制每轮更新多少条边,取值太小会导致收敛慢,取值太大就退化成固定调度。我一般从边总数的 20% 到 30% 开始试,再根据误码率曲线微调。noise_var是噪声方差,它直接影响资源节点更新时距离度量的权重,设得比实际噪声大方差小,会让算法过于相信接收信号,容易在低信噪比下震荡。update_rn_to_vn里做了 max-sum 近似,实际多用户组合需要遍历所有邻居用户的码字组合,代码里为了可读性只演示了一个干扰用户,完整实现要把组合展开。

注意:残差计算用的是 VN->RN 消息,但调度决策影响的是 RN->VN 和 VN->RN 两个方向的更新。如果你发现某条边残差一直很大但误码率不降,检查一下是不是该边对应的资源块上用户数太多,max-sum 近似误差累积导致的。

4. 参数怎么设:DS-MPA 的五个关键旋钮

4.1 迭代停止阈值与最大迭代次数

DS-MPA 的停止条件通常写成:所有边残差最大值小于eps,或迭代次数达到max_iter。eps设得太小,算法会为了压最后一点残差多跑很多轮,收益很低;设得太大,消息还没收敛就停了,误码率会明显恶化。我的经验是先把eps设成噪声标准差的 1% 到 5%,跑几条误码率曲线看拐点,再往回微调。max_iter作为兜底,一般设 20 到 50 轮,具体看因子图直径。

4.2 每轮更新边数 top_n 的选取

top_n是 DS-MPA 里最需要动手调的量。它和用户数、资源块数、d_f 都有关系。一个粗略的起点是top_n = 0.25 * len(edges),然后观察每轮实际被选中的边是否集中在少数几条上。如果发现每轮选中的边几乎不变,说明残差区分度不够,可能是消息初始化太均匀,或者噪声方差设得不对。如果选中的边每轮都在大幅跳动,说明top_n太小,消息在边之间来回震荡。

4.3 码本稀疏度 d_f 与 d_v 的匹配

d_f 和 d_v 不是独立选的,它们满足 J * d_v = K * d_f,其中 J 是用户数,K 是资源块数。d_f 越大,单个资源节点上的联合检测越复杂,DS-MPA 省计算量的收益越明显,但 max-sum 近似的误差也会放大。d_v 一般取 2,因为取 3 以上会让用户节点上的消息更新复杂度上升,而收益有限。如果你的场景允许,优先把 d_f 控制在 3 到 4 之间。

4.4 消息阻尼系数的引入

在消息更新时加一个阻尼系数,可以抑制震荡。做法是:新消息 = (1 - alpha) * 旧消息 + alpha * 新计算的消息,alpha 取 0.5 到 0.8。DS-MPA 因为动态选边,本身比固定调度更容易出现局部震荡,加阻尼往往能让残差曲线更平滑。代价是收敛轮数可能增加,需要和top_n一起调。

4.5 噪声方差估计误差的容忍度

实际系统里噪声方差是估计出来的,有误差。DS-MPA 对噪声方差误差的容忍度比固定调度 MPA 略低,因为动态调度会放大消息之间的差异。如果噪声方差估计偏小,算法会过度信任接收信号,残差大的边被反复选中,收敛变慢。一个实用的做法是给噪声方差乘一个大于 1 的修正因子,比如 1.2 到 1.5,再观察误码率是否改善。

5. 避坑与排查:DS-MPA 落地时最容易翻车的五件事

5.1 残差一直不降,误码率卡在平台

现象:迭代跑了很多轮,残差曲线下降很慢,误码率在某个信噪比之后不再改善。原因通常是因子图里有短环,消息在环上反复传播同一份错误置信度。解决:检查码本对应的因子图围长,如果围长是 4,考虑换码本或调整用户与资源块的映射关系。另一个原因是 max-sum 近似在 d_f 较大时误差累积,可以改用 exact marginalization 或加阻尼。

5.2 动态调度选出的边每轮都一样

现象:每轮选中的 top_n 条边几乎不变,算法退化成只更新固定几条边。原因是消息初始化太均匀,或者残差计算用了未归一化的消息。解决:初始化时给消息加一点随机扰动,残差计算前对消息做归一化,确保不同边的消息在同一个尺度上比较。

5.3 高信噪比下误码率反而变差

现象:低信噪比时曲线正常,高信噪比时误码率不降反升。原因是噪声方差设得太小,资源节点更新时距离度量过于尖锐,导致消息置信度过度集中,一旦某个用户码字判错,错误会通过因子图快速传播。解决:给噪声方差加修正因子,或者在高信噪比区间适当增大eps,让算法早停。

5.4 迭代次数比固定调度还多

现象:DS-MPA 的迭代次数没有比固定调度少,甚至更多。原因是top_n设得太小,每轮只更新少量边,虽然单轮计算量小了,但总轮数上去了。解决:把top_n提高到边总数的 30% 到 40%,或者改用残差阈值动态确定更新集合,而不是固定条数。

5.5 消息数值下溢导致全零

现象:迭代几轮后消息向量全变成零,后续残差计算失效。原因是消息更新时做了多次连乘,数值下溢。解决:每次更新后做对数域处理,或者归一化后加一个极小值保护。代码里1e-12就是干这个的,但如果连乘次数多,这个保护不够,建议直接在 log 域实现消息更新。

6. 把 DS-MPA 用对:从仿真验证到复杂度核算的一个习惯

仿真跑通只是第一步,真正决定 DS-MPA 值不值得用的,是复杂度核算。我一般会在同一张误码率图上叠三条曲线:固定调度 MPA、DS-MPA、以及 DS-MPA 加阻尼。然后单独统计每条曲线达到目标误码率所需的平均迭代次数和平均更新边数。如果 DS-MPA 的更新边数只有固定调度的 40%,但迭代次数多了 20%,总计算量还是降的,那就值得继续优化。

验证 DS-MPA 是否真的在按残差调度,有一个很直接的办法:把每轮选中的边索引打印出来,观察它们是否集中在因子图中围长较小、或者用户间干扰较强的区域。如果选中的边在因子图上均匀分布,说明残差没有区分度,调度逻辑没起作用。这个检查比看误码率曲线更早发现问题。

最后一个技巧是关于停止条件的。很多人只设最大迭代次数,不设残差阈值,结果 DS-MPA 的动态调度优势发挥不出来。我的习惯是双条件并行:残差阈值先触发就停,最大迭代次数兜底。残差阈值不要设成固定值,而是设成当前噪声标准差的函数,这样在不同信噪比下都有合理的停止行为。

我自己在这个方向上踩过最深的坑,是早期为了省事,把残差计算放在消息更新之后,结果残差反映的是更新后的状态,调度决策总是慢一拍,收敛轮数反而比固定调度多。后来改成更新前算残差、更新后只更新缓存,才把动态调度的节奏理顺。这个顺序问题在文档里很少提,但实际写代码时很容易搞反。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 3:02:00

中文搜索相关度排序优化:从字面匹配到语义理解的实战指南

1. 2026年谈相关度排序:先看"简单匹配"在哪失灵我在一个中文搜索项目里接手相关度优化时,第一周就被一个现象震住了:系统明明已经能搜到正确答案,可它永远排不到第一位。查了日志才发现,模型把大量权重给了&…

作者头像 李华
网站建设 2026/10/22 3:01:59

传值还是传引用?一次讲透C/Java/Python/JS的参数传递机制

1. 一个改不动的swap函数:传值传引用最容易翻车的第一现场带新人入门时,我习惯先让他们写一个交换函数试水。不多解释,就一句话:"把两个数字交换一下。"于是经常看到一个结构上挑不出毛病的版本:public stat…

作者头像 李华
网站建设 2026/10/22 3:01:29

VSCode+MSYS2+ImGui+ImPlot:轻量C++实时绘图桌面环境搭建

说实话,如果你跟我一样,受够了 Visual Studio 每次启动都要转半天圈、装个组件恨不得下载好几个 GB,又不想为了画一个带坐标轴的折线图就背着 Qt 全家桶狂跑三四个小时编译,那这套VSCode MSYS2 ImGui (Docking) ImPlot的组合&a…

作者头像 李华
网站建设 2026/10/22 3:00:53

支持向量生成:核化零样本分类器的技术解析与复现

最近在整理零样本分类相关工作的时候,翻到一篇很有意思的投稿,标题长这样:"Support Vector Generation: Kernelizing Zero-Shot Classifiers from Pre-Trained Language Models"。单看题目有几个关键词值得琢磨:support…

作者头像 李华
网站建设 2026/10/22 3:00:52

医学图像分割实战:从脑白质到视网膜血管的完整技术链路

医学图像里,分割这个动作可以说是最“出活”的一步。就拿脑部CT来说,把大脑白质从一堆灰质、脑脊液、颅骨里拎出来,或者从视网膜照片里把密密麻麻的血管网络完整抽出来,这些任务听起来很酷,但真上手做一遍,…

作者头像 李华
网站建设 2026/10/22 3:00:35

Umami自托管网站分析:从Docker部署到公网访问全指南

最近我把博客的访问统计从第三方平台换成了自托管的Umami。说实话,这个决定纠结了一阵子,但实际用下来,本地部署Umami并把分析面板开放到公网访问,这件事本身并不复杂。真正花时间的是理解“外部访问”这条链路里每一步在干什么、…

作者头像 李华