信息论上机实战:信源熵、Shannon/Huffman编码与信道容量迭代求解 第一次上机作业的题目发下来的时候实验指导书上只有三个干巴巴的任务算信源熵、做符号编码、求信道容量。看着倒是挺简单可真正动手才发现课堂上一堆求和符号糊弄过去还行落到代码里每一个细节都在跟你较劲。这篇就记录我完成信息论第一次上机作业的完整过程包括踩过的坑、改过的方案以及最后交上去还被导师夸了两句的地方。正为这门课第一次实验挠头的同学可以直接照着我这路线走。1. 上机作业的任务清单先搞清楚到底要你做什么1.1 任务表象与真实考点我拿到的实验指导书写得很克制——大概是三段话第一段说从投硬币、掷骰子或自己构造一个离散信源统计符号概率并计算信源熵第二段说设计一个Shannon编码和一个Huffman编码对比效率第三段说给定信道转移概率矩阵用迭代法求信道容量。题目看起来是三个独立任务但等你真做进去就会发现它其实在考三件事你能不能把概率模型变成可计算的程序结构你能不能把课堂推导的公式落到具体的循环和边界判断上以及你能不能解释结果为什么合理。说白了导师要的不是一个能跑出数字的脚本而是你对“信息量到底怎么度量”这件事有没有手感。这里给还没开始动手的同学一个建议不要急着写代码。先把每个任务的输入、输出、中间量画一张小表列清楚。比如“信源熵”任务输入是概率分布输出是一个实数中间要处理的是负号、对数的底数、概率为0的情况。把它们写下来后面写程序就是填肉的过程。1.2 环境准备和我踩的第一个坑环境我用的Python 3.10加Jupyter Notebook库只用了math、heapq、collections和matplotlib没有装任何机器学习相关的重型依赖。信息论实验本质是离散概率计算这些够了。但第一个坑是环境之外的——随机数种子。如果你打算用随机模拟来“生成”一长串符号再统计频率不固定种子的话每次跑出来的熵都不一样。我第一次没加种子同一个程序连跑三次熵值在小数点后第四位抖来抖去差点以为自己程序写错了。后来在文件开头统一放一个random.seed(42)才算稳下来。这类实验里固定随机源不是小事它直接关系到你报告里那张表格可不可以复现。2. 信源熵计算从公式到程序的三个零点问题2.1 频率估计与随机数种子信源熵的定义是离散信源符号集合上的概率分布函数公式长这样H(X) -Σ p(x_i) · log₂ p(x_i)任务是造一个信源把概率估计出来然后算这个值。最直接的做法是用频率近似概率——生成10000个符号统计每个符号出现次数除以总数得到经验分布。我自己构造了一个五符号信源概率设置成不太均匀的样子比如0.4, 0.25, 0.15, 0.12, 0.08。理论熵可以直接按这个概率算出来然后用随机生成的10000个符号再算一次经验熵两者一比较就能看出“样本越少估计偏差越大”这个直观结论。随机数种子在这里的作用刚才提过了。具体操作时我会建议把种子和样本量都写成常量放在开头import random random.seed(42) N 10000这样后面换样本量做对比实验的时候同一N下重复运行结果完全一致报告里写“当N10000时熵为2.103 bit”才有意义。2.2 log底数、浮点误差和“0概率”处理写熵计算函数的时候三个细节最容易出错。第一是log的底数。信息论默认用2做底单位是bit。Python的math.log(x)默认是自然对数所以必须写math.log2(x)或者math.log(x, 2)。别小看这个我一开始就用错了底算出来熵值大了1.4427倍查了半天不知道哪出了问题。第二是浮点求和误差。符号多、概率很小的时候累加一堆负数可能引入可观的误差。一个稳妥做法是累加过程用Kahan求和或者干脆把中间结果用decimal高精度存。不过对实验课来说直接sum加round(…, 6)已经完全够用我提这点只是提醒你别在高精度问题上过度纠缠。第三是“0概率”问题。统计频率时有可能出现某符号一次都没被采样到这时对应项是p0。按照公式就要算0 * log₂ 0这在数学上是0但程序里直接报错。我处理的办法是写个辅助函数过滤掉零值或者给频率估计加了一个极小的平滑项。这里要分清两件事如果是模拟数据且符号本身概率非0只是样本不够导致没出现加平滑是合理的如果符号设计概率本来就是0那它就不属于信源符号集合应该直接从字典里删掉。提示课堂上讲熵时从来不提0 log 0的分支判断写代码时这是第一个编译之外的运行时错误提前处理能省一大半调试时间。2.3 联合熵与条件熵从一维走向二维有的实验要求不止算单符号熵还要算联合熵和条件熵那就得把统计对象从一维扩展到二维。逻辑其实一脉相承——定义在二元符号对上的联合分布求和嵌套一层而已。我当时设计了一个二元对称信源X是原符号Y是信道的输出符号用一个转移概率矩阵把Y的分布造出来。联合熵就统计(x, y)组合的频率条件熵用关系式H(X|Y) H(X,Y) - H(Y)间接算绕开了直接按条件概率求和可能遇到的分母为0问题。这种设计的好处是后面做信道容量那题正好能复用这个模型代码和数据都不用重造。做作业别把几个任务完全隔离能复用的中间结果尽量复用省下的时间都够你多核验几遍数据了。3. 符号编码实验Shannon编码与Huffman编码的完整实现3.1 Shannon编码码长的理论下界如何落地Shannon编码是信息论里最“直给”的一种构造方法它的出发点特别朴素概率越大的符号码长越短。具体步骤分三步每步都有坑。第一步把所有符号按概率降序排列。第二步计算每个符号的累积概率F_i也就是排在它前面的所有符号概率之和第一个符号的累积概率为0。第三步令码长l_i ceil(-log₂ p_i)然后把F_i写成二进制小数截取前l_i位作为这个符号的码字。代码里最需要小心的就是“把小数转成二进制位串”这一步。我写了一个专门函数def frac_to_bits(frac, length): bits [] for _ in range(length): frac * 2 if frac 1: bits.append(1) frac - 1 else: bits.append(0) return .join(bits)这函数干的事就是不断“乘2取整”跟十进制转二进制的整数部分逻辑正好镜像。一开始我没单独写这个函数而是用Python浮点数的bin()去转换结果因为浮点精度问题某些累积概率的二进制展开在小数位数很长时会失真导致最后编码结果跟手算对不上。这是我的第二个大坑教训是涉及手工拼接二进制位的时候别依赖语言内置的浮点转二进制工具自己写循环最踏实。Shannon编码有一个天然缺陷需要知道它不保证是前缀码。也就是说可能会出现某个码字是另一个码字的前缀这在解码时会变成灾难。因为l_i ceil(-log₂ p_i)只能保证平均码长比较接近熵但码字的分配方式并没有严格规避前缀冲突。所以实验报告里我专门对比了一下构造出来的码表注明“本例未发生冲突但Shannon编码并不保证前缀性”。3.2 Huffman编码优先队列与回溯建码Huffman编码的实现推荐用最小堆优先队列反复合并概率最小的两个节点最后回溯得到每个符号的码字。核心逻辑不复杂但有两个容易写错的地方。第一是堆元素的结构。Python的heapq默认按元组第一个元素排序所以直接用(prob, symbol)当堆元素最方便。但问题来了——如果两个节点概率恰好相等堆会去比较第二个元素的类型。如果第二个元素一个是内部节点没有symbol一个是叶子节点比较就会直接抛异常。解决方法是给每个节点加一个自增序号用(prob, seq, node)做堆元素彻底避开比较对象本身的歧义。import heapq def huffman(probs): heap [] seq 0 for sym, p in probs.items(): node [p, sym, None, None] heapq.heappush(heap, (p, seq, node)) seq 1 while len(heap) 1: p1, _, left heapq.heappop(heap) p2, _, right heapq.heappop(heap) merged [p1 p2, None, left, right] heapq.heappush(heap, (p1 p2, seq, merged)) seq 1 root heap[0][2] codes {} def walk(node, prefix): if node[1] is not None: codes[node[1]] prefix return walk(node[2], prefix 0) walk(node[3], prefix 1) walk(root, ) return codes第二是回溯顺序。我喜欢统一取“概率小的走0、概率大的走1”从左孩子到右孩子这样回溯。顺序本身不影响平均码长但影响码字的具体样子。报告里如果要和别人对比得先确认双方的左右分支约定是否一致否则对着“Huffman编码结果表”争论谁对谁错纯属浪费时间。3.3 两种编码的对比实验平均码长、方差与鲁棒性作业里真正拉开差距的地方在于最后那张对比表。我生成的表格是这样的符号概率Shannon码字Huffman码字a0.40001b0.250101c0.15110000d0.121110001e0.081111011一眼看过去Huffman码明显更紧凑。但对比实验不能只列码字表要算平均码长。平均码长的定义是每个符号码长按概率加权求和L Σ p_i · l_i我的数据里Shannon平均码长是2.20 bit/符号Huffman算出来是2.15 bit/符号信源熵约2.10 bit/符号。两个编码都满足H ≤ L H1这个理论区间Huffman更贴近下界。更细致的对比是码长方差。Huffman倾向于让大概率符号码长很短、小概率符号码长很长所以码长方差通常比Shannon大。这说明“平均码长更优”不等于“码长更均匀”在实时通信场景里码长抖动太大会让缓冲区管理变得困难某些场景下反而会选Shannon这类码长方差小的编码。这些延伸分析写进报告会让导师看出你不是只会调包。4. 信道容量计算迭代算法为什么容易跑偏4.1 容量迭代法的核心思想第三个任务给了一个信道转移概率矩阵要求用迭代法求信道容量。信息论里信道容量的定义是互信息的最大值最大化的变量是输入符号的分布C max Q(x) I(X;Y)理论上这是个凸优化问题但课堂上不会让你直接求导而是教Blahut-Arimoto迭代算法核心思路是固定信道转移概率矩阵让输入分布反复迭代互信息逐渐逼近上确界同时每次迭代都能得到一个当前分布对应的互信息值。算法流程其实很短初始化输入分布为均匀分布根据当前输入分布和信道矩阵计算输出分布计算一个“增益”向量用它按指数方式更新输入分布归一化检查迭代前后的分布差异是否小于阈值否则回到第2步。实现的时候要记住信道容量本质上由信道本身决定跟输入分布无关。迭代只是在“找”那个能发挥信道最大能力的输入分布而不是“改变”信道。4.2 BSC信道的理论值与程序验证为了验证迭代算法对不对我先选了一个有解析解的信道——二进制对称信道BSC。它的转移概率是输入0的时候以1-p收到0、以p收到1输入1同理。这个信道的容量理论值是C 1 - H_b(p)其中H_b(p) -p log₂ p - (1-p) log₂(1-p)。我取p0.1理论值大约是1 - 0.469 0.531 bit/符号。用迭代程序跑出来设置收敛阈值1e-8大概几十次迭代后就停在0.531附近小数点后五六位都对得上。有这层验证打底后面算通用信道矩阵就有了参照系程序错了也能立刻意识到而不是对着一个莫名其妙的数值发呆。4.3 收敛慢的根因与加速技巧实际计算时我发现当信道矩阵接近“无噪声”或者接近“完全噪声”时迭代收敛得特别慢。无噪声信道容量趋近log₂ |X|而完全噪声信道容量趋近0。这两种极端情况下增益向量之间差异太小指数更新几乎不动迭代过程像蜗牛爬。我做的第一个调整是换收敛判据。不要用“容量值连续两次差值小于阈值”这个判据在极端信道下容易误判。我改成同时看输入分布变化的max |Q_new(x) - Q_old(x)|要求小于1e-10再停。第二个调整是给迭代次数加一个上限比如3000次防止极端情况无限循环。第三个调整是把初始分布从均匀分布改成跟信道矩阵的行和成正比的一个分布能显著减少起步阶段的震荡次数。这些调整思路比代码本身更值得写进报告。导师看到你不光会跑标准例程还会针对收敛性问题做数值实验那这次作业的评价基本就稳了。5. 实验报告的数据可视化导师真正想看的三类图5.1 熵函数曲线与等概率直观上机作业的报告大部分人写成了代码附上输出结果这其实浪费了实验机会。我建议至少画三张图每张图都能撑起一段分析文字。第一张图是二元信源的熵函数曲线横轴是概率p纵轴是H_b(p)。这张曲线图中凸、顶点在p0.5处取到最大值1两端归零。画出来之后你可以做一件课堂不好做但代码里很容易的事把熵曲线的“凸性”和信源的不确定性可视化。你会在图上直接看到“概率越平均熵越大”这句话对任何概率分布都成立。用matplotlib画这个图也就十行代码但它是报告里的门面。5.2 编码效率对比表与码树图第二类图是编码方案对比。导师最爱看的是“码树图”也就是把Huffman合并节点的过程画出来。根节点在左叶子在右每条边上标0或1。画这种图用matplotlib的annotate加线段就行稍微费点功夫但效果拔群。比码树更重要的是平均码长对比的柱状图。把信源熵当成一条水平基准线然后画Shannon平均码长和Huffman平均码长的柱子哪个离基准线近一目了然。我当时还顺手加了误差条展示随机样本量不够时平均码长会上下波动这属于额外的加分细节。5.3 容量曲线的震荡现象第三张图是信道容量随转移概率变化的曲线。拿BSC信道举例横轴是错误概率p纵轴是容量C。容量在p0时为1在p0.5时跌到0之后又慢慢回升到1。这个对称的“V”字形曲线很美而且特别适合用来论述一个直觉信道噪声达到完全随机时信息传输能力确实会归零。画这张图的过程中我注意到一个有意思的现象程序在p从0.5附近往两侧移动时收敛到精确解的迭代次数有一个明显的“山脊”。这个发现纯属做实验时的副产品写进报告却成了亮点——说明你在干活的时候观察到的是数据本身的性格而不仅仅是把作业交差。6. 从“交作业”到“玩明白”几个值得深挖的扩展实验作业本身做完我建议别急着关电脑。有几个方向拓展一下对后续学习帮助很大。第一个方向是数据压缩的“熵界限”验证。找一份真实文本文件比如一段英文小说统计字符概率计算熵再用Huffman编码压缩一遍看看实际平均码长跟熵差多少。你会直观理解“无损压缩的极限是熵”这句话到底意味着什么——Huffman已经能压到非常接近熵的码率再想往下压就必须换模型而不是换码表。第二个方向是编码对错误传播的鲁棒性。改几个码字里的比特模拟信道噪声看解码结果崩成什么样。Shannon编码和Huffman编码的错误传播表现不一样这种实验能帮你建立“信源编码”和“信道编码”之间的分界感——很多时候导师上第二节课就会问这个问题提前做过实验就是优势。第三个方向是信道容量迭代算法的可视化。把每次迭代互信息的变化曲线画出来会看到一个前期快速攀升、后期缓慢逼近的“对数式”曲线非常直观地解释了为什么要在程序里设迭代上限。这比单看最终数值有说服力得多。最后分享一个实操小技巧把所有实验涉及的关键数值都统一round到6位小数再输出同时把随机种子、样本量、收敛阈值这些参数写在一个字典里方便反复跑对比实验。我这一次作业跑下来光是参数调整就迭代了十几个版本没有这套参数管理最后写报告时根本说不清每个数据是从哪组配置出来的。信息论这东西公式看十遍不如亲手算一遍算十遍不如让程序跑一百遍。做完这次上机那些符号和求和才算真正变成你自己的工具。