我第一次动手写LDPC译码器,是在一个需要自研信道编码模块的项目里。当时满脑子觉得译码嘛,不过就是把教材上的和积算法(置信传播)抄成代码。结果Matlab一跑,误码率曲线惨不忍睹,逐行查了一天,最后定位到问题:概率值在迭代乘法中下溢成了0,后续的比值计算直接变NaN,整个译码器就废了。这件事逼着我把概率公式从头推了一遍,才真正理解LDPC译码在干什么。
这篇博文就把这套推导过程完整记录下来。内容从“译码为什么是个概率问题”讲起,一步步推后验概率、似然比、对数似然比(LLR),再推到校验节点消息更新那条经典的tanh规则,最后落到工程实现里最容易让人头大的数值稳定性问题上。适合刚接触信道编码、被教材里一堆术语劝退的读者,也适合已经在跑仿真但遇到性能或数值问题的同学。
1. 译码问题的本质:从校验约束到后验概率推断
1.1 校验矩阵定义了码字空间,也定义了译码目标
LDPC码(Low-Density Parity-Check Code)的核心是一个稀疏校验矩阵H。说它稀疏,是因为矩阵里1的个数远小于0的个数。一个长度为n的码字c,合法当且仅当满足:
$$H c^T = 0 \quad (\text{模2运算})$$
每一行H_j都代表一个校验约束,通俗说就是“这个约束涉及的比特中,必须有偶数个1”。所有这些约束合在一起,勾勒出码字空间的边界。发射端做的事很简单:从所有合法码字里挑一个发出去。真正难的在于接收端——你拿到的是被信道污染过的接收序列y,要反推出最有可能的发送码字c。
为什么这天然是一个概率问题?因为信道噪声是随机的,你永远无法确定地知道发的是什么,只能计算“给定接收y,发送码字是c的概率有多大”。译码器最理想的目标,是找到条件概率P(c|y)最大的那个码字,这叫做最大后验概率译码,也就是MAP译码。数学上写出来很干净,但直接求解是灾难:码字空间大小是2^k量级,遍历是不可能的。所以实际译码必须另辟蹊径。
1.2 因子图分解:把全局概率问题拆成局部消息传递
LDPC码能高效译码的真正原因,在于校验矩阵的稀疏性。稀疏意味着每个校验约束只涉及少数比特,每个比特也只参与少数几个约束。这种结构天然适合用因子图(Factor Graph)来表达:图中一边是变量节点(对应每个码字比特),另一边是校验节点(对应每一行校验约束),两者之间用边连接表示“这个比特参与了那个约束”。
置信传播(Belief Propagation, BP)算法就是在这样一张图上做消息传递。每个节点根据自己收到的输入消息,处理后把新的消息发给邻居节点,反复迭代,最终逼近每个比特的后验概率。这个过程也叫和积算法(Sum-Product Algorithm),名字里的“和”和“积”分别对应后面要讲的两类更新操作。理解这条主线,就理解了LDPC译码的骨架:全局概率推断问题,被因子图的稀疏结构分解成了一堆局部计算,每个节点只关心和自己直接相连的邻居。
2. 从概率到对数似然比:把乘法变成加法的关键一步
2.1 单个比特的后验概率怎么用贝叶斯公式展开
考虑最简单的情形:信道上只有一个比特。设发送端采用BPSK调制,把编码比特0映射为发送符号+1,编码比特1映射为发送符号-1。接收端得到y = x + n,其中n是均值为0、方差为σ²的高斯白噪声。我们关心的是条件概率P(x=+1|y)和P(x=-1|y)。
用贝叶斯公式展开:
$$P(x=+1|y) = \frac{P(y|x=+1)P(x=+1)}{P(y)}$$
$$P(x=-1|y) = \frac{P(y|x=-1)P(x=-1)}{P(y)}$$
如果发送端0和1等概率出现,即P(x=+1)=P(x=-1)=1/2,那么分母P(y)和先验项都会约掉。后验概率的比较就只剩下似然函数P(y|x=±1)的比较。这正是信道模型起作用的地方——它告诉我们给定发送符号,接收值在什么分布下产生。
2.2 高斯信道下的似然比与对数似然比
在加性高斯白噪声(AWGN)信道下,给定x=+1,接收y服从均值为+1、方差为σ²的高斯分布;给定x=-1,接收y服从均值为-1、方差为σ²的高斯分布。把高斯概率密度函数代进去:
$$L(y) = \ln\frac{P(x=+1|y)}{P(x=-1|y)} = \ln\frac{e^{-(y-1)^2/(2\sigma^2)}}{e^{-(y+1)^2/(2\sigma^2)}} = \frac{2y}{\sigma^2}$$
这个结果漂亮得让人意外:在BPSK加AWGN信道下,接收符号的对数似然比(LLR)就是接收幅度的线性函数。y越大越倾向于是+1,LLR为正;y越小越倾向于是-1,LLR为负。LLR的绝对值大小则代表置信度的高低。数值上,y=0.5、σ²=0.5时LLR=2,意味着这个比特为0的概率大约是为1的概率的e²≈7.4倍。硬判决时LLR>0判为0,LLR<0判为1即可。
2.3 对数域为什么是工程上的必然选择
顺着上面的推导,你可能觉得概率公式也没多复杂。但真实译码器面对的不是一个比特,而是成百上千个比特在图上相互传递消息。每一轮迭代都有大量概率乘法:变量节点要乘上所有相邻校验节点的贡献,校验节点又要处理一堆概率的组合。概率值通常在0到1之间,乘多了会急剧变小,动态范围极大。这件事在浮点运算下就会出问题——我在开头提到的下溢成0,就是这么来的。
引入对数的第一个好处就是压缩动态范围:概率的乘积变成对数域的加法,LLR从10⁻¹⁰量级变成-23左右的普通浮点数,计算稳定得多。对数域的第二个好处是加法的“耦合”也变简单了——两个独立事件同时成立的概率相乘,转到对数域就是LLR相加,这恰好匹配了因子图上消息合并的语义。所以现代LDPC译码器几乎都是全LLR域实现,概率域实现只存在于教学代码里。
3. 校验节点更新:用代数恒等式撬开核心公式
3.1 校验约束在BPSK映射下变成了乘积约束
校验节点更新是整个推导里最硬核的部分。先明确一下问题:校验节点j连接d个变量节点,现在要从除i以外的d-1个节点收到的消息,推算出发给节点i的消息。这d-1个消息以LLR形式给出,每个消息隐含了对应比特为+1或-1的概率。
关键一步是把校验约束翻译成产品形式。校验约束“这些比特中必须有偶数个1”,在BPSK映射下等价于“这些比特对应的±1符号相乘必须等于+1”。这是从模2加法到实数乘法的自然转化,也是后续所有代数技巧能施展的前提。
设其他d-1个比特的符号分别为s_1, s_2, ..., s_{d-1},每个符号取+1或-1。已知的LLR消息L_k = ln(p_k / q_k),其中p_k = P(s_k=+1),q_k = P(s_k=-1),且p_k + q_k = 1。为了让整个校验约束满足,目标比特的符号s_i必须等于其他符号的乘积T = s_1 s_2 ... s_{d-1}。因此问题变成了:已知一组独立符号的概率分布,求这组符号乘积为+1和-1的概率之比。
3.2 用期望算符求“偶数为-1”的概率
计算P(T=+1)和P(T=-1)看起来要枚举2^{d-1}种组合,但有一个取巧的办法。注意T的期望值满足:
$$E[T] = P(T=+1) - P(T=-1)$$
而另一方面,由于各符号独立,乘积的期望等于期望的乘积:
$$E[T] = \prod_{k=1}^{d-1} E[s_k]$$
每个符号的期望是:
$$E[s_k] = (+1)p_k + (-1)q_k = p_k - q_k = \frac{e^{L_k}-1}{e^{L_k}+1} = \tanh\left(\frac{L_k}{2}\right)$$
再加上P(T=+1) + P(T=-1) = 1这个基本约束,解二元一次方程组,就得到:
$$P(T=+1) = \frac{1 + \prod_{k=1}^{d-1}\tanh(L_k/2)}{2}$$
$$P(T=-1) = \frac{1 - \prod_{k=1}^{d-1}\tanh(L_k/2)}{2}$$
这个推导不仅省掉了组合爆炸的枚举,还直接把tanh函数引了进来。现在目标比特的似然比可以写出来了:
$$r_i = \frac{P(T=+1)}{P(T=-1)} = \frac{1 + \prod_{k=1}^{d-1}\tanh(L_k/2)}{1 - \prod_{k=1}^{d-1}\tanh(L_k/2)}$$
这个形式就是概率域校验节点更新的完整表达式。有意思的是,这个式子对任意数量的输入消息都成立,不需要单独处理d-1=2、3等特殊情况,跟暴力枚举的结果完全一致,但计算量小得多。
3.3 取对数得到tanh规则
如果消息以LLR形式流动,还需要把似然比r_i转成对数形式。这里用到反双曲正切恒等式:
$$\ln\left(\frac{1+x}{1-x}\right) = 2,\operatorname{arctanh}(x)$$
于是校验节点发给变量节点i的消息为:
$$L_{j\to i} = 2,\operatorname{arctanh}\left(\prod_{k\neq i}\tanh\left(\frac{L_{k\to j}}{2}\right)\right)$$
这就是教材里那条著名的tanh规则。推导走到这里,关键逻辑已经闭环:校验约束用乘积形式表达,输入LLR通过tanh映射到(-1,1)区间,乘积汇总所有外部证据,再用arctanh映射回LLR域。每一步都有明确的概率含义,而不是凭空冒出来的公式。
3.4 符号-幅度分解与Min-Sum的萌芽
tanh规则虽然精确,但每个校验节点每轮都要算一堆tanh和arctanh,数值代价不小。工程上常用近似,而近似的基础是把公式拆成符号和幅度两部分。由于tanh是奇函数,乘积的符号等于输入消息符号的乘积,而乘积的幅度则受限于绝对值最小的那一项——因为|tanh(L/2)|始终小于1,乘得越多,幅度越小,其中最小的那个因子对最终幅度影响最大。
这个观察直接导出了Min-Sum近似:如果把所有输入消息中绝对值最小的那个挑出来,用它作为输出消息的幅度,再用所有输入符号的乘积作为输出消息的符号,就得到了一条极简单的更新规则。它没有浮点初等函数运算,实现成本极低,代价是比精确BP差零点几个dB的性能。怎么修正这个误差,后面的章节会专门讲。
4. 变量节点更新与完整迭代流程
4.1 变量节点的消息合并其实就是一个累加器
变量节点的更新比校验节点简单得多。假设变量节点i连接若干个校验节点,它还接收到来自信道的固有LLR消息L_ch = 2y_i/σ²。在独立性假设下,所有这些来源的概率相乘,转到对数域就是LLR相加。所以变量节点发给某个校验节点j的消息,等于信道LLR加上除j以外所有校验节点传来消息的总和:
$$L_{i\to j} = L_{ch} + \sum_{j'\in N(i)\setminus{j}} L_{j'\to i}$$
这里有一个很容易忽略但极其重要的细节:计算发往j的消息时,必须排除来自j自身的消息。这就是“外部信息原则”。如果没有这个排除,一条消息沿着i→j→i→j的路径自我强化,迭代几次后LLR的绝对值会膨胀到几倍甚至几十倍,译码器要么震荡不收敛,要么过早收敛到错误码字。我在第一次实现时就踩过这个坑——去掉排除项后,低信噪比下误码率反而飙升。
4.2 从初始化到终止判定:一次完整迭代的运作顺序
整个BP译码是一个干净的两阶段交替过程。初始化时,所有校验节点发给变量节点的消息设为0,因为还没有任何统计证据。变量节点发给校验节点的初始消息直接取信道LLR,这是每个比特的第一手观测。然后开始迭代:
第一步,校验更新:对每个校验节点j,收集所有相连变量节点发来的消息,用tanh规则给每个邻居计算新的输出消息。
第二步,变量更新:对每个变量节点i,累加信道LLR和所有校验节点消息,算出新的输出消息。
第三步,计算后验LLR:变量节点把信道LLR和所有校验节点的消息全部加起来,得到当前对所有比特的概率估计。
第四步,硬判决与终止判断:根据后验LLR的符号做硬判决,然后代入校验方程H c^T = 0检查。如果所有校验都满足,说明找到了一个合法码字,提前终止;否则继续下一轮,直到达到最大迭代次数。
这个流程的核心价值在于:它不是一次性给出答案,而是让证据在图上反复流动、互相印证,每轮迭代都在修正上一轮的判断。实际实现里,大多数码字在信噪比不太差时5到10轮就能收敛,很少跑满最大迭代次数。
4.3 从公式到可执行代码的落点
把上面的流程转成代码,核心结构并不复杂。这里给一个Python风格的伪代码框架:
def ldpc_bp_decode(H, llr_ch, max_iter=50): m, n = H.shape # 初始化消息 msg_c_to_v = np.zeros((m, n)) # 校验节点给变量节点的消息 msg_v_to_c = llr_ch.copy() # 变量节点给校验节点的消息 for it in range(max_iter): # 校验节点更新(简化写法,实际按稀疏索引遍历) for j in range(m): neighbors = np.where(H[j, :] == 1)[0] for i in neighbors: others = [k for k in neighbors if k != i] product = np.prod(np.tanh(msg_v_to_c[j, others] / 2)) msg_c_to_v[j, i] = 2 * np.arctanh(product) # 变量节点更新 for i in range(n): neighbors = np.where(H[:, i] == 1)[0] for j in neighbors: others = [k for k in neighbors if k != j] msg_v_to_c[j, i] = llr_ch[i] + np.sum(msg_c_to_v[others, i]) # 后验LLR与硬判决 llr_post = llr_ch + np.sum(msg_c_to_v[:, :], axis=0) c_hat = (llr_post < 0).astype(int) # 校验终止条件 if np.all(H @ c_hat % 2 == 0): break return c_hat这段代码为了可读性牺牲了效率,真正的工程实现会用稀疏矩阵存储、消息索引压缩、并行化批量更新。但它把消息传递的骨架展示得很清楚,照着这个结构去填细节,不容易跑偏。
5. 工程实现中的数值稳定性与近似方案
5.1 概率下溢:我第一次实现时的惨痛教训
回到开头那个问题。首次实现时我图省事,直接用概率域做BP:校验节点的输出是概率比值,变量节点做概率乘法。从数值角度看,这埋了一个巨大的雷。LDPC译码的消息在迭代中会变得极度自信——某一个比特的LLR可能从初始的2迅速增长到20甚至30,换算成概率就是e²⁰/(1+e²⁰)≈0.999999998。反方向,另一个消息的概率可能小到10⁻²⁰以下,多轮迭代后直接突破双精度浮点的下限变成0。
一旦某个概率变成0,下一个对数运算就是ln(0)=-∞,再往后一步就是NaN。而NaN在浮点运算里会像病毒一样蔓延,所有涉及到这个值的消息全部失效。我当时看到误码率曲线在某个信噪比点突然塌陷,就是这个问题。解决的办法很简单但也很彻底:整个译码过程不要出现“概率”这个量,全部改用LLR。LLR的取值范围是对称的,无论多自信,绝对值也就是几十,不会出现0或∞这样的病态值,除非数学上真的需要。
5.2 tanh与arctanh的计算问题
全LLR域实现之后,新的问题是tanh和arctanh的计算代价。这两个函数涉及指数运算,在浮点DSP或FPGA上都不便宜。更麻烦的是,tanh在输入很大的时候饱和到±1,arctanh在输入接近±1的时候急剧增长到±∞,形成潜在的不稳定点。
工程上通用的做法是对LLR做钳位(clamp)。具体来说,把消息的绝对值限制在某个范围,比如±30。理由很简单:LLR=30对应的概率置信度已经超过了一切实际需求,再大的值只会在数值上找麻烦,不会对译码性能有任何帮助。在这个范围内,tanh和arctanh都可以用查表法或多项式逼近实现,精度损失在0.01dB以内。我在一个定点实现里试过用9位查表索引,性能相比浮点参考只差了不到0.05dB,但对芯片面积和功耗的节省是实打实的。
另外一个细节:如果用的是C/C++,标准库的tanhf和atanhf在IEEE浮点下会正确返回±1或±∞,并不会直接崩,但后续运算中∞参与加减或乘除时会产生NaN,所以钳位必须在函数调用前做,而不是等返回后再处理。
5.3 Min-Sum近似的误差分析与修正手段
Min-Sum近似的公式非常简洁:
$$L_{j\to i} \approx \left(\prod_{k\neq i}\operatorname{sign}(L_{k\to j})\right) \cdot \min_{k\neq i}|L_{k\to j}|$$
它把每个校验节点的消息更新压缩成了“符号乘积×最小绝对值”,不需要任何初等函数运算。但代价是误差。精确tanh规则的输出幅度总是小于或等于最小输入幅度(因为|tanh(L/2)|<1,相乘后更小,arctanh后再放大),所以Min-Sum在幅度上系统性偏大。偏大的后果是译码器过于自信,性能会有损失。
针对这个偏差,业界有两个非常成熟的修正思路。第一个是归一化Min-Sum(NMS):把Min-Sum的输出乘一个小于1的因子α,取值通常在0.7到0.9之间。第二个是偏移Min-Sum(OMS):从输出幅度中减去一个固定偏移β,如果结果为负就截断到0,β通常在0.5左右。归一化因子更像对系统误差的整体校准,偏移项则更贴合“小消息需要更多压制”的特性。实际选哪种看实现平台:NMS只需一个乘法和一个系数存储,OMS是加法减法,在定点实现里更友好。两种修正方案的性能差距通常在0.1到0.2dB以内,可以在仿真里直接对比选定。
5.4 信道LLR失配对性能的影响
很多实现者容易忽略的是,初始化时那个简单的2y_i/σ²,对整条译码性能曲线有决定性影响。σ²在这里是信道噪声方差,如果估计不准,相当于给所有信道消息加了一个错误的全局缩放因子。
在实践中我发现一个规律:σ²估小了,也就是以为信道比实际干净,信道LLR会被放大,译码器表现“过于自信”,中高信噪比下容易过早收敛到伪码字,误码率曲线出现错误平层;σ²估大了,信道LLR被压小,译码器表现“过于保守”,需要更多迭代才能收敛,性能整体下移但不会出现灾难性的错误平层。所以在接收机设计里,噪声方差估计的精度比很多人想象的重要。如果实在拿不准,宁可按保守方向估计,也不要高估信道质量。某些工程实现干脆省略σ²的精确估计,直接用固定因子初始化然后靠迭代修正,性能略差但鲁棒性更高,这也是一种取舍。
最后分享一个我在反复调参中总结的小技巧:测试译码器时,先用全零码字和零噪声输入跑一遍,理想情况下所有LLR应该为正且一轮迭代后硬判决全对。如果这一步都过不了,问题几乎一定出在初始化或消息索引上。等这个边界用例通过了,再叠加噪声做性能曲线,排错效率会高很多。推导公式的过程虽然枯燥,但它会让你在调这些参数时,清楚地知道自己在动哪块数学结构,而不是拿着经验值瞎试。