☰
Polar码SCL与BP译码实现:function节点抽象与工程调试
2026/10/1 1:48:22 网站建设 项目流程

简介:面向通信与信息处理领域的极化码译码算法MATLAB实现包,聚焦逐位消去(SC)、置信传播(BP)、列表消去(SCL)三种主流译码方案,并集成循环冗余校验(CRC)以提升整体可靠性。压缩包共31个文件,全部为MATLAB的.m脚本文件,包含三种译码主程序,以及路径管理、对数域计算、CRC校验等配套子函数,模块划分清晰,便于按函数逐段研读。目前已有773人学习下载,适合信道编码方向的研究人员、学生及工程师用来进行原理验证、算法性能对比和完整仿真链路搭建。借助此包可快速运行核心译码程序,直观理解SC的逐位判定、BP的消息传递以及SCL的列表搜索差异,同时通过CRC辅助检查观察纠错效果,进而可修改参数、替换信道模型或扩展新算法,支撑课程设计、毕业设计乃至科研预研。全部源码采用MATLAB编写,包体仅16KB,轻量便捷,可直接放入本地实验环境运行。

1. function_polar 到底在解决什么问题:SCL 和 BP 不是两套代码

function_polar 这个命名,我第一反应是“把 Polar 码译码器里的 function 节点单独抽出来”,而不是一个完整库的名字。实际做下来你会发现,SCL 译码和 BP 译码两套算法,底层都在反复算同一个 f/g 节点:SCL 靠它们做递归,BP 靠它们做迭代。这个看起来不起眼的抽象,能让一个工程同时产出两条性能曲线,少维护一半代码。下面按我实际做过的路径,把它拆成最小可运行实现、参数怎么调,以及调试时真正会翻车的几个地方。适合通信仿真刚起步的人,也适合想快速对比两套译码器性能的从业者。读完你能自己搭一个 N=256 的 AWGN 仿真,不依赖任何第三方库。

2. SCL 和 BP 的原理差异:为什么同一个码要两套译码器

极化码构造出来之后,最初的译码器是 SC(串行抵消),它按冻结位和信息位的顺序一位一位判决。SC 在码长较短时性能不够好,后来才分化出 SCL 和 BP 两条路。SCL 保留多条候选路径,用列表换取性能;BP 在因子图上做迭代,用并行换取时延。两者处理的是同一个极化码,却对“译码器”这件事做了完全不同的取舍。

2.1 SCL 的本质:串行比特决策,只是留了后悔药

SCL 的全称是 Successive Cancellation List,串行抵消列表。它在 SC 的基础上,把“每次选一条路走到底”改成“每步同时保留 L 条路”。SC 的问题是决策没有后悔药,当前面某个信息位判错,后面再做多少次正确的抵消计算都救不回来。SCL 的做法是把 0/1 两条分支都留下来,各自继续往下算,最后再按路径度量选一条。

路径度量(PM,Path Metric)是 SCL 的灵魂。常见实现在 LLR 域维护一个非负增量:路径与当前比特硬判一致时 PM 增加很少,不一致时增加接近该比特 LLR 的绝对值。最终 PM 最小的路径就是最大似然意义上的最佳候选。注意这里有一个很容易踩的细节:冻结位不产生路径分裂,直接按构造时给定的值继续走,所以激活路径的数量只会在信息位翻倍。

复杂度上,SCL 是 O(L·N·logN),L 通常取 4 到 32。L 从 1 涨到 8,性能提升非常明显;从 16 涨到 32 提升开始变缓。工程里如果只看 BLER,L=8 往往已经够用,L=16 适合追求极限性能的场合。但 SCL 本身是串行的,列表管理还要排序,所以吞吐率不高,硬件上不如 BP 友好。

2.2 BP 的本质:因子图上的消息迭代

BP 译码把极化码展开成一张因子图,图上每个基本处理单元就是一个 PE。消息在节点之间来回传递,左侧消息 R 从冻结位先验出发向右传,右侧消息 L 从信道 LLR 出发向左传。经过若干轮迭代后,每个信息位的 LLR 稳定下来,再做一次硬判决。

BP 的计算原语和 SCL 的递归原语高度重合。每个 PE 在做的事情,本质上是两个消息的合并:对数和合并对应 f 节点,带已知比特的修正合并对应 g 节点。区别只在调度:SCL 是深度优先的树递归,BP 是逐层刷新的平面迭代。

迭代次数 T 是 BP 最重要的参数。太小收敛不充分,太大在 min-sum 近似下会累积误差。常见范围是 20 到 60 次。和 SCL 对比,BP 的并行度高,适合硬件和 GPU 加速,也能天然产出软信息;缺点是中短码长时,同样码率下 BP 的误块率通常比 SCL 加 CRC 差 0.3 到 0.5 dB。

2.3 function 节点抽象:两套译码器的公因数

一旦把 f 和 g 抽成独立函数,SCL 和 BP 就变成了同一组节点上的两种调度方式。我一般会建一个很薄的抽象层,接口如下:输入左右两个 LLR,外加一个可选的已知比特,输出合并后的 LLR。SCL 在递归时调用它,BP 在迭代循环里调用它。

下表是我习惯的抽象视角:

抽象层SCL 里的角色BP 里的角色
f 节点递归计算上一层 LLR迭代中合并同层两路消息
g 节点结合已判决比特修正 LLR结合另一侧消息做方向传递
调度树状深度优先按 stage 平面遍历
主要参数列表大小 L、CRC 长度迭代次数 T、调度方式、早停阈值

把 function 节点抽出来的直接收益是:你只需要维护一份经过充分测试的 f/g 实现,SCL 和 BP 各自去调它。后面做联合译码,比如先用 BP 输出软信息再喂给 SCL,也只是多接一条线。这正是标题里 function_polar 这个命名的工程价值。

3. SCL 译码实现:f/g function 节点、路径度量与三个必调参数

这一章直接给可运行的最小单元。完整译码树还需要按递归顺序逐层计算 LLR,但下面这几个代码块是可以直接抄进自己工程的核心,也是我调试时最容易改出问题的地方。

3.1 先写对 function 节点:f 和 g 的符号约定决定一切

我这里统一用 LLR 表示对数似然比,正值偏向判 0,负值偏向判 1。BPSK 映射可以约定比特 0 对应 +1 电平,比特 1 对应 -1 电平,信道 LLR 就是 2y/σ²。符号约定一旦乱了,后面所有曲线都会对不上。

import math def f_node(a: float, b: float) -> float: """min-sum 近似的对数和节点。 LLR > 0 判 0,LLR < 0 判 1。 """ sign = math.copysign(1.0, a) * math.copysign(1.0, b) return sign * min(abs(a), abs(b)) def g_node(a: float, b: float, u: int) -> float: """结合已判决比特 u 的 g 节点。 u=0 时等效 a+b,u=1 时等效 b-a。 """ return a + (1 - 2 * u) * b

f_node 用的是 max-log 近似。严格的对数和是 log(1+exp(-|a+b|)) 修正,实测在 SCL 列表大小中等时只差零点几个分贝,但 min-sum 的数值稳定性好很多,不会在极端 LLR 下冒出 inf 参与后面的加法。g_node 里 u 是当前路径对某个比特的硬判决,SCL 每条路径都有自己的 u,所以 g_node 实际上是路径相关的 function 节点,这也是 function_polar 里 function 二字的含义。

3.2 路径度量更新必须留在 LLR 域

路径度量的经典公式是累积 ln(1+exp(-(1-2u)·llr)),如果直接在概率域算,概率连乘很快下溢到 0,L=8 时可能还勉强能跑,L=16 以上几乎必出 NaN。我在工程里全部改用近似形式,增量只依赖当前比特的 LLR 和判决的一致性。

def update_pm(pm_old: float, llr_bit: float, bit: int) -> float: """SCL 路径度量增量。 bit 与 llr_bit 同号时增量接近 0,反号时增量接近 |llr_bit|。 """ return pm_old + max(0.0, (1.0 - 2.0 * bit) * -llr_bit)

这段代码的意义在于把概率域的乘法变成了 LLR 域的加法,并且返回的 PM 永远非负递增。你不需要在每个比特上做 exp/log 切换,也就少了一个常见的数值黑匣子坑。后面要在不改变行为的前提下做优化,可以提前算好每个可能 llr_bit 对应的增量查表,但先保证逻辑正确再说性能。

3.3 列表管理:翻倍、剪枝、冻结位跳过

拿到某个比特位置的 LLR 之后,SCL 要做的是扩展路径并剪枝。信息位每条路径分裂成两条,冻结位不分裂。这个逻辑单独抽出来非常容易测,也方便后续加 CRC 辅助选择。

def extend_and_prune(paths, llr_bit, is_frozen, frozen_bit, list_size): """paths: list of (pm, u_list) 返回扩展并剪枝后的前 list_size 条路径。 """ new_paths = [] for pm, u_list in paths: if is_frozen: pm2 = update_pm(pm, llr_bit, frozen_bit) new_paths.append((pm2, u_list + [frozen_bit])) else: pm0 = update_pm(pm, llr_bit, 0) pm1 = update_pm(pm, llr_bit, 1) new_paths.append((pm0, u_list + [0])) new_paths.append((pm1, u_list + [1])) return sorted(new_paths, key=lambda x: x[0])[:list_size]

这段代码看起来简单,但它是 SCL 的主干,因为真正的递归译码树最终就是在每个叶子节点上调用它。注意冻结位分支里 frozen_bit 也能带来 PM 增量,如果冻结位的 LLR 与构造时给定值不一致,这条路径会被自然惩罚。这是对的,BP 里的冻结位先验也是一样的逻辑。

如果 list_size 偏大,全量 sorted 的开销会排在解码时间的第一位。我一般会改成heapq.nsmallest(list_size, new_paths, key=lambda x: x[0]),路径扩展到上万条时差别非常明显。

3.4 落地到完整译码器时只调三个参数

第一个是列表大小 L。N=256、码率 1/2 时,L=8 起步,L=16 够用,L=32 收益很小。第二个是 CRC 长度,普通仿真推荐 8 到 16 bit,太长会吃掉码率,太短则选不出正确路径。第三个是冻结位位置,最稳妥的做法是用高斯近似构造,或者直接用 5G 标准里给好的位置文件,不要自己随机挑。

我见到的常见误用是把 CRC 位插在信息位中间,导致 SCL 在 CRC 位上也分裂路径。正确做法是把 CRC 位当作普通信息位的一种,但路径分裂时不对它做 0/1 扩张;最终终选时用 CRC 校验过滤候选路径。这一点在避坑章节还会再展开。

4. BP 译码实现:消息初始化、迭代模板与统一接口

BP 的实现比 SCL 更依赖因子图的具体布局,所以我不会硬塞一个可能和你的节点编号对不上的完整译码器。这一章给的是工程上最常用的初始化方式、迭代调度思路,以及一个让 SCL/BP 共用同一入口的接口模板。

4.1 消息初始化:冻结位先验和信道 LLR 怎么给

BP 译码需要两组消息:从左侧向右传的 R 消息,从右侧向左传的 L 消息。R 的初始值来自冻结位先验,L 的初始值来自信道。初始化做对,后面迭代才有意义。

import numpy as np def bp_init(ch_llr, frozen_pos, frozen_val, stage_count, prior_strength=1e6): """ch_llr: 信道 LLR,长度 N frozen_pos: 冻结位索引列表 frozen_val: 每个冻结位对应的固定比特值 stage_count: log2(N) """ n = len(ch_llr) L = np.zeros((stage_count, n)) R = np.zeros((stage_count, n)) L[-1, :] = ch_llr for pos, val in zip(frozen_pos, frozen_val): R[0, pos] = prior_strength if val == 0 else -prior_strength return L, R

L[-1, :] = ch_llr是把信道信息放到因子图最右侧,R[0, :]是因子图最左侧。冻结位先验用prior_strength=1e6,不要用np.inf。一旦消息里混入 inf,min-sum 的取最小绝对值操作会让同一个 PE 的另一路也被污染,之后整个迭代矩阵都会变成 NaN。信息位位置 R 初始给 0 即可,第一次迭代后消息自然填充。

4.2 迭代调度:flooding 和 sequential 怎么选

BP 的一次迭代要遍历所有 stage。flooding 调度是所有 stage 同一轮并行更新,实现简单,适合 numpy 批量算或者 GPU 加速;sequential 调度是从某一侧开始逐层更新,收敛更快,但串行依赖强。中短码我一般先用 flooding 调通逻辑,再考虑换 sequential 拉低迭代次数。

每次迭代结束后可以对信息位做硬判决,如果连续两次迭代的硬判决结果完全一致,就提前终止。这个早停条件能省掉大约三分之一无效迭代。注意 min-sum 近似的 BP 在迭代后期可能出现 LLR 幅度震荡,所以早停不要只看 LLR 绝对值,要比较硬判决序列是否稳定。

4.3 BP 的三个必调参数

迭代次数 T 是最关键的。N=256 时 T=40 是一个稳妥起点,往上加到 100 通常没有明显收益,反而可能让误块率回弹。调度方式上面说过,先 flooding 后 sequential,性能曲线一致后再优化。最后是冻结位先验强度,prior_strength取 1e5 到 1e6 都可以,太小会让冻结位约束失效,太大会在迭代初期把相邻消息拉爆。

这里有一个经常被忽略的点:BP 的硬判决输出天然是软信息,信息位的 LLR 绝对值可以作为一种置信度参考。这个特性在最后一章做 BP 辅助 SCL 时会用到。

4.4 把两套译码器收进同一个接口

工程里同时维护 SCL 和 BP 时,我习惯用一个小门面类把两者包起来,主仿真脚本只关心 mode 参数。

class PolarDecoder: def __init__(self, N, info_idx, frozen_idx, frozen_val): self.N = N self.info_idx = info_idx self.frozen_idx = frozen_idx self.frozen_val = frozen_val def decode(self, ch_llr, mode="scl", list_size=8, max_iter=40): if mode == "scl": return self._decode_scl(ch_llr, list_size) elif mode == "bp": return self._decode_bp(ch_llr, max_iter) else: raise ValueError(f"unknown mode: {mode}")

这段代码本身不实现具体算法,但它让回归测试、蒙特卡洛仿真脚本都在同一入口上跑。后面想加一个联合译码模式,只需要在 decode 里多开一个分支,不用动上层的 SNR 扫描循环。

5. 用起来才遇到的坑:SCL/BP 译码实现常见问题排查

理论跑通之后真正耗时间的是调试。下面五条都是我自己在 N=256、码率 1/2 仿真里遇到过的问题,每条按现象、原因、解决三步记录。

5.1 路径度量在概率域下溢,L 一大就整片 NaN

现象:L=8 一切正常,L 调到 16 后译码器开始输出 NaN,误块率反而比 L=4 还高。

原因:路径度量在概率域做连乘,候选路径翻倍后浮点下溢;也有人把 update_pm 写成了pm + np.log(...),在 LLR 极端值时对数参数趋近 0 造成 NaN。

解决:路径度量统一走 LLR 域近似增量,也就是第 3.2 节的update_pm。不要为了追求理论上的“精确”而把公式换回概率域,min-sum 近似在 SCL 列表场景下性能损失很小,数值收益却非常明显。

5.2 BP 迭代次数加得越大,误码率反而越高

现象:T 从 40 提到 200,BLER 从 4% 涨到 9%,曲线明显变差。

原因:min-sum 近似在迭代中不断累积误差,后期消息震荡,硬判决被往错误方向推。

解决:固定 T 在 40 到 60,或加早停条件。早停用连续两次硬判决序列一致作为判据,这样既保住性能,又省迭代时间。也不要为了对比把 BP 的 T 和 SCL 的 L 硬凑成同样数值,二者没有一一对应关系。

5.3 f/g 节点符号方向写反,SCL 能跑但 BP 完全不对

现象:SCL 在 L=1 时曲线正常,BP 无论怎么调 T 都输出接近随机的结果。

原因:SCL 对某些符号错误有容忍,因为路径度量会吸收方向偏差;BP 是迭代网络,方向错了会在多轮更新里被指数级放大。

解决:先拿 N=2 的最小因子图手算一组 LLR,把 f/g 节点在这个最小用例上的输入输出抄下来,写成一个 pytest 用例。以后每次改 function 节点都先跑这个用例,再跑整体仿真。这个习惯帮我少翻了至少三次车。

5.4 冻结位列表版本不一致,曲线差 0.5 dB 找不出原因

现象:本地复现论文,N=256 曲线在低信噪比段比论文差 0.5 dB 左右,调列表大小、迭代次数都不见好转。

原因:发送端用的是 5G 标准位置表,接收端用的是另一个版本的 Gaussian Approximation 构造结果,两边冻结位不完全相同。Polar 码对齐的是接收端已知的冻结位,而不是双方共同的计算历史。

解决:把构造结果落成一个文件,.npy或.csv都行,发射端调制脚本、SCL 译码器、BP 译码器都从同一个文件读冻结位列表。这一步看起来多余,但能杜绝一个非常隐蔽的复现问题。

5.5 CRC 位位置放错,加了 CRC 后性能反而变差

现象:加了 16 bit CRC 后,SCL 的 BLER 比不带 CRC 还要高,而且主要崩溃在 CRC 位附近的信息位。

原因:CRC 作为冗余比特,不应该参与路径分裂。如果把 CRC 位夹在信息位中间,SCL 会在这些位置上继续做 0/1 分裂,白白消耗列表容量,最后 CRC 校验反而把正确路径过滤掉了。

解决:CRC 固定放尾部。实现上把 CRC 位当作一种“不分裂的伪冻结位”,参与 PM 计算但不扩张路径,只在最终选取路径时做 CRC 校验。

6. 进阶:让 BP 的软输出给 SCL 打分,再用回归脚本守住 function 节点下限

SCL 和 BP 如果能互相利用,价值比单独调参更大。一个务实的做法是先跑 BP,把迭代稳定后的信息位 LLR 拿出来,和信道 LLR 做加权融合,再用融合后的软信息喂给 SCL。这样做不能保证每一次都变好,但处理突发深衰落时,BP 提供的先验可以帮 SCL 把正确路径留在列表尾部,而不是被一次噪声拉偏的 PM 淘汰掉。

权重 α 我用信道 LLR 占比 0.6 到 0.8,BP 软输出占比 0.2 到 0.4,在不同 SNR 下做一次小网格搜索。注意这里的软信息不是严格意义上的译码先验,所以权重设置属于工程经验,不要套用教科书里的概率公式。

def regression_sanity(snr_list, n_trials=1000): for snr in snr_list: bler_scl = run_mc("scl", snr, list_size=8) bler_bp = run_mc("bp", snr, max_iter=40) # 中短码下 SCL 应当明显好于或至少不差于 BP assert bler_scl < bler_bp + 0.05, f"FAIL at SNR={snr}" print("PASS", snr, round(bler_scl, 4), round(bler_bp, 4))

这份回归脚本固定随机种子、固定信噪比列表,每天改动 function 节点后跑一遍。f/g 节点是两套译码器的公共底座,只要回归断言不崩,上层的大部分问题都能被快速定位。

还有一个零成本 sanity check 我每换一个码长都会先做:拿 N=64、K=32 的译码器和未编码 BPSK 对比。如果译出码字的误块率在 4 dB 时都没有明显低于未编码 BPSK 的误码率,说明代码里还有逻辑 bug,先别急着调列表大小或迭代次数,回去检查消息方向。

我现在的习惯是把冻结位位置单独存成 JSON,仿真脚本固定 seed,每次改动只碰 function 层。f/g 先过 N=2 最小用例,再跑回归,最后才上蒙特卡洛。这套流程走顺之后,SCL 和 BP 的踩坑时间明显下降,希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询