1. 项目概述:为什么“数学不硬核,模型就飘”是数据科学最真实的生存法则
我带过三十多个从零起步转行的数据科学学员,也给十几家中小企业的算法团队做过技术复盘。每次聊到模型效果瓶颈、训练不稳定、调参像玄学,最后八成问题都卡在同一个地方——不是代码写错了,也不是数据质量差,而是对背后那几行数学公式的理解,停留在“抄作业”层面。比如你用 PyTorch 写完一个线性层nn.Linear(784, 128),真清楚它内部做的矩阵乘法W @ x + b中,W的形状为什么是(128, 784)?为什么不能反过来?当梯度爆炸时,你第一反应是加torch.nn.utils.clip_grad_norm_,但有没有想过,这个操作本质上是在约束哪个范数?它和L2正则项在数学上是什么关系?这些都不是“知道就行”的知识点,而是你每天调试模型时,键盘敲下的每一行代码所依赖的底层逻辑。
这篇内容,就是为那些已经写过pip install torch、跑通过 MNIST 分类、却在读论文时被∇_θ J(θ)吓退,或在调优时对着learning_rate=1e-3盲目试错的人准备的。它不讲抽象证明,不堆积分符号,只聚焦一个目标:让你在写np.dot(A, B)时,脑子里能立刻浮现出 A 的列空间如何被 B 的行向量线性组合;在看到svd(A)输出三个矩阵时,能马上对应到图像压缩中哪部分存了轮廓、哪部分存了纹理噪声;在计算KL(p||q)时,能意识到这不是一个对称距离,而是在强制让 q 去拟合 p 的“信息重心”。核心关键词就是:标量、向量、矩阵、张量、行列式、特征值与特征向量、范数、矩阵分解、熵、KL 散度、梯度下降——它们不是教科书里的静态定义,而是你构建、调试、解释每一个深度学习模型时,手边最趁手的几把螺丝刀。
适合谁来读?第一类是刚结束 Python 基础、正准备啃《深度学习》花书的自学者,你需要的不是从极限开始的数学分析,而是“这个公式现在就能用在哪”;第二类是工作两年、能调参但总被问“为什么选这个 loss”的工程师,你需要补上那层“原理透明”的底气;第三类是带团队的技术负责人,你需要一套能快速帮新人建立直觉的讲解框架,而不是扔一本《矩阵论》过去。我不会假设你记得拉格朗日乘子法,但会确保你亲手算出一个 2×2 矩阵的特征向量后,能指着结果说:“看,这个方向上的向量,被矩阵变换后只伸缩,不旋转——这就是神经网络里‘主成分’的原始模样。”
2. 核心概念解构:从“是什么”到“为什么非得这样设计”
2.1 标量、向量、矩阵、张量:数据容器的层级进化论
很多人一上来就被“张量”这个词吓住,觉得是 TensorFlow 或 PyTorch 专属的高阶概念。其实完全不是。我们从最基础的物理世界说起:你手机屏幕上一个像素点的亮度,就是一个标量(scalar)——它只有一个数字,比如128。这个数字本身没有方向,没有结构,它就是“多少”的终极答案。在 NumPy 里,np.array(128)创建的就是一个标量。它的数学意义在于:它是所有线性代数运算的“原子单位”,是构成更复杂结构的基石。
当你需要描述一个物体的运动时,“速度”就不能只用一个数字了。你说“车速 60 公里/小时”,这信息不完整——它往东开还是往西开?所以你必须加上方向,变成“向东 60 公里/小时”。这就是向量(vector)——一个有序的数字列表,每个数字代表在某个坐标轴(如 x, y, z)上的分量。在 NumPy 中,np.array([60, 0, 0])就是一个三维向量,表示纯 x 方向的速度。关键点在于“有序”和“可运算”:两个向量能相加([60,0,0] + [0,30,0] = [60,30,0]),能被一个标量缩放(2 * [60,0,0] = [120,0,0])。这种运算能力,正是神经网络中“权重更新”的数学原型:w = w - lr * grad,本质就是用一个标量(学习率lr)去缩放梯度向量grad,再与原权重向量w相加。
再往上走,当你需要描述一个力作用在物体上产生的效果时,单靠一个向量就不够了。比如一个力不仅让物体移动,还让它旋转。这时就需要矩阵(matrix)——一个二维的数字表格。你可以把它想象成一个“变换说明书”:它规定了空间中的每一个点,经过这个变换后,应该挪到哪里。例如,一个 2×2 矩阵[[0,-1],[1,0]],作用在向量[1,0]上,得到[[0,-1],[1,0]] @ [1,0] = [0,1],这恰好是将 x 轴正方向逆时针旋转了 90 度。神经网络里的全连接层y = Wx + b,W就是这样一个矩阵,它把输入特征x这个向量,“扭曲”、“拉伸”、“旋转”成一个新的向量y,为后续的非线性激活做准备。
那么张量(tensor)是什么?它就是矩阵的自然延伸。矩阵是二维的(行×列),张量可以是任意维度。一张彩色图片,在计算机里就是一个三维张量:(高度, 宽度, 通道),比如(224, 224, 3)。一个包含 32 张图片的 mini-batch,就是一个四维张量:(批次大小, 高度, 宽度, 通道),即(32, 224, 224, 3)。PyTorch 和 TensorFlow 的核心数据结构Tensor,就是这个名字的由来——它不是一个特定的数学对象,而是一个通用的、支持自动微分的多维数组容器。理解这一点至关重要:你不需要背诵“张量的协变与逆变”,只需要记住,张量是数据的“形状”本身,而矩阵运算是对这个形状施加的“规则”。当你调用torch.nn.Conv2d时,卷积核就是一个小的四维张量(out_channels, in_channels, kernel_h, kernel_w),它在输入张量上滑动,执行的是一系列小矩阵乘法,最终输出另一个张量。整个过程,就是不同维度的张量,按照预设的矩阵运算规则,进行着精密的“形状匹配”与“数值变换”。
提示:初学者最容易混淆的是“向量是列还是行”。在 NumPy 中,
np.array([1,2,3])默认创建的是一个一维数组,它既不是严格的列向量也不是行向量。当你需要明确其方向时,要用reshape:v_col = np.array([1,2,3]).reshape(-1,1)得到列向量(3,1),v_row = np.array([1,2,3]).reshape(1,-1)得到行向量(1,3)。这个细节在矩阵乘法A @ B中极其关键:A的列数必须等于B的行数。如果你不小心把一个(3,)的一维数组当作(3,1)来用,@操作会静默失败或给出错误结果。
2.2 行列式与特征值:矩阵的“灵魂刻度”与“不变方向”
如果把矩阵比作一个黑箱变换器,那么行列式(determinant)就是这个黑箱的“体积缩放因子”。它告诉你,这个变换对空间做了多大程度的“拉伸”或“压缩”。一个 2×2 矩阵[[a,b],[c,d]]的行列式是ad - bc。这个公式看起来像凭空捏造,但它有非常直观的几何解释:它等于由矩阵的两列向量所张成的平行四边形的有向面积。如果行列式是 2,意味着这个变换把任何图形的面积都放大了 2 倍;如果是 0.5,就缩小了一半;如果是负数,比如 -1,则表示除了缩放,还做了一次镜像翻转(改变了空间的“手性”)。
为什么这很重要?在深度学习里,行列式直接关联到可逆性。一个矩阵只有在行列式不为零时,才存在逆矩阵。而逆矩阵,是求解线性方程组Ax = b的钥匙。在某些生成模型(如 Normalizing Flows)中,模型的核心就是构造一系列可逆变换,其目标函数里就包含了对所有变换行列式的对数求和(log-determinant)。如果你不理解行列式代表“体积变化”,你就无法理解为什么模型要费尽心思去保证每一步变换都是可逆的——因为不可逆,就意味着信息丢失,就像把一张高清图压缩成一个像素点,再也无法还原。
而特征值(eigenvalue)和特征向量(eigenvector),则是这个黑箱的“灵魂”。它们回答了一个根本问题:在这个变换下,有没有一些特殊的向量,它们被变换后,方向完全不变,只是被拉长或缩短了?这些“方向不变”的向量,就是特征向量;那个“拉长或缩短”的倍数,就是对应的特征值。
举个具体例子。假设你有一个简单的 2×2 矩阵A = [[2,0],[0,0.5]]。它的特征向量很容易猜出来:[1,0]和[0,1]。因为A @ [1,0] = [2,0] = 2 * [1,0],所以[1,0]是特征向量,对应特征值2;同理,A @ [0,1] = [0,0.5] = 0.5 * [0,1],所以[0,1]是特征向量,对应特征值0.5。这意味着,沿着 x 轴的方向,这个变换会把它拉长 2 倍;而沿着 y 轴的方向,会把它压缩到一半。整个空间,被这个矩阵“拉扯”成了一个椭圆。
这个概念在 PCA(主成分分析)中是核心。PCA 的目标,就是找到数据分布中“方差最大”的方向,这个方向,恰恰就是数据协方差矩阵的最大特征值所对应的特征向量。它代表了数据最主要的“伸展”方向。在神经网络中,权重矩阵的特征值谱(所有特征值的集合)是判断训练稳定性的关键指标。如果最大特征值远大于 1,梯度在反向传播时就会指数级放大(梯度爆炸);如果远小于 1,梯度就会指数级衰减(梯度消失)。很多初始化方法(如 He 初始化、Xavier 初始化)的理论依据,就是通过控制权重矩阵的初始方差,来让其特征值尽量落在[-1, 1]区间内,从而保证梯度流动的健康。
注意:NumPy 的
np.linalg.eig返回的特征向量是按列排列的。eigenvectors[:, i]才是第i个特征值eigenvalues[i]对应的特征向量。新手常犯的错误是直接用eigenvectors[i],这取到的是第i行,完全不是特征向量。另外,特征向量的长度是任意的(因为A @ (2*v) = 2*(A @ v) = 2*λ*v),所以np.linalg.eig返回的通常是单位长度的特征向量,这是为了标准化。
2.3 范数、矩阵分解与伪逆:模型优化的三大支柱
在深度学习的日常工作中,你几乎每分钟都在和范数(norm)打交道,只是可能没意识到。L2范数,也就是欧几里得范数,||x||₂ = √(x₁² + x₂² + ... + xₙ²),它衡量的是向量x的“长度”。在 PyTorch 里,torch.norm(x, p=2)就是计算这个。它的平方||x||₂²,就是我们最熟悉的L2正则化项(Weight Decay)。为什么加这个?因为它惩罚的是权重向量的“总长度”,迫使模型倾向于选择一组“小而分散”的权重,而不是一组“大而稀疏”的权重,从而提升泛化能力,防止过拟合。
L1范数||x||₁ = |x₁| + |x₂| + ... + |xₙ|则不同,它倾向于产生稀疏解。因为L1的等高线是菱形,它与损失函数等高线相切的点,更容易落在坐标轴上(即某个xᵢ = 0)。这就是 Lasso 回归的原理,也是为什么在某些需要特征选择的场景下,L1正则化比L2更受青睐。
矩阵分解(Matrix Factorization),则是将一个复杂的矩阵“拆解”成几个更简单、更有意义的矩阵的乘积。这就像把一道复杂的菜谱,拆解成几味基础调料的组合。最常见的几种:
LU 分解:把一个方阵
A拆成一个下三角矩阵L和一个上三角矩阵U的乘积,A = L @ U。它的主要价值在于高效求解线性方程组。一旦你有了L和U,解Ax = b就变成了先解Ly = b(前向替换),再解Ux = y(后向替换),这两个步骤都比直接解Ax = b快得多。虽然在现代深度学习框架中我们很少手动做 LU,但理解它,能让你明白为什么torch.linalg.solve这样的函数如此高效。QR 分解:把一个
m×n矩阵A拆成一个正交矩阵Q(Q.T @ Q = I)和一个上三角矩阵R的乘积,A = Q @ R。正交矩阵的性质是“保距”的,它不会改变向量的长度和夹角。因此,QR 分解常用于最小二乘问题的求解,它比直接计算(A.T @ A)⁻¹ @ A.T @ b更加数值稳定,尤其是在A接近奇异(行列式接近零)时。SVD(奇异值分解):这是最强大、应用最广的分解。它把任意
m×n矩阵A拆成A = U @ Σ @ V.T,其中U和V都是正交矩阵,Σ是一个对角矩阵,对角线上的元素叫奇异值(singular values),它们都是非负实数,并且按降序排列。U的列向量叫左奇异向量,V的列向量叫右奇异向量。SVD 的威力在于,它揭示了矩阵A的内在秩结构。最大的几个奇异值,对应了A中最主要的信息;而后面很小的奇异值,往往对应着噪声。因此,截断 SVD(Truncated SVD)是一种强大的降维和去噪工具。在推荐系统中,用户-物品评分矩阵通常非常稀疏,用 SVD 可以将其近似为低秩矩阵,从而预测用户对未评分物品的喜好。
Moore-Penrose 伪逆(Pseudoinverse),记作A⁺,是当矩阵A不可逆(比如不是方阵,或者行列式为零)时,“最接近”逆矩阵的东西。它的定义是满足四个 Penrose 条件的唯一矩阵。在实践中,它最常用的地方就是求解最小二乘解。对于超定方程组Ax ≈ b(方程数多于未知数),x = A⁺ @ b给出的是使||Ax - b||₂最小的那个x。在 NumPy 中,np.linalg.pinv(A)就是计算它。值得注意的是,A⁺的计算通常就是通过 SVD 来完成的:A⁺ = V @ Σ⁺ @ U.T,其中Σ⁺是将Σ中非零的奇异值取倒数,零奇异值保持为零。这再次印证了 SVD 的基础地位——它是连接矩阵结构、优化问题和数值计算的桥梁。
3. 实操精要:从代码片段到可复现的工程化理解
3.1 向量与矩阵运算:不只是+,-,*,更是空间直觉的构建
让我们从最基础的 NumPy 代码开始,但这次,我们要“看见”它背后的几何意义。首先,创建两个向量:
import numpy as np import matplotlib.pyplot as plt # 创建两个二维向量 a = np.array([3, 1]) b = np.array([1, 2]) # 向量加法:平行四边形法则 c = a + b # [4, 3] # 可视化 plt.figure(figsize=(8, 6)) plt.quiver(0, 0, a[0], a[1], angles='xy', scale_units='xy', scale=1, color='r', label='a=[3,1]') plt.quiver(0, 0, b[0], b[1], angles='xy', scale_units='xy', scale=1, color='b', label='b=[1,2]') plt.quiver(0, 0, c[0], c[1], angles='xy', scale_units='xy', scale=1, color='g', label='c=a+b=[4,3]') plt.quiver(a[0], a[1], b[0], b[1], angles='xy', scale_units='xy', scale=1, color='b', alpha=0.5, linestyle='dashed') plt.quiver(b[0], b[1], a[0], a[1], angles='xy', scale_units='xy', scale=1, color='r', alpha=0.5, linestyle='dashed') plt.xlim(0, 5) plt.ylim(0, 4) plt.grid(True) plt.legend() plt.title("Vector Addition: The Parallelogram Rule") plt.show()运行这段代码,你会看到一个清晰的平行四边形。红色箭头是a,蓝色箭头是b,绿色箭头是c。两条虚线分别表示“把b平移到a的头顶”和“把a平移到b的头顶”。这个图景,就是你大脑里应该有的向量加法模型。它不再是3+1=4, 1+2=3的机械计算,而是空间中两个力的合成。
接下来,理解矩阵乘法。创建一个 2×2 矩阵M,它代表一个“剪切”(shear)变换:
# 创建一个剪切矩阵 M = [[1, 1], [0, 1]] # 它的作用是:x' = x + y, y' = y M = np.array([[1, 1], [0, 1]]) # 对向量 a 和 b 分别进行变换 a_transformed = M @ a # [3+1, 1] = [4, 1] b_transformed = M @ b # [1+2, 2] = [3, 2] # 可视化变换前后的对比 fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5)) # 原始空间 ax1.quiver(0, 0, a[0], a[1], angles='xy', scale_units='xy', scale=1, color='r', label='a') ax1.quiver(0, 0, b[0], b[1], angles='xy', scale_units='xy', scale=1, color='b', label='b') ax1.set_xlim(0, 5) ax1.set_ylim(0, 4) ax1.grid(True) ax1.set_title("Original Space") ax1.legend() # 变换后空间 ax2.quiver(0, 0, a_transformed[0], a_transformed[1], angles='xy', scale_units='xy', scale=1, color='r', label="M@a") ax2.quiver(0, 0, b_transformed[0], b_transformed[1], angles='xy', scale_units='xy', scale=1, color='b', label="M@b") ax2.set_xlim(0, 5) ax2.set_ylim(0, 4) ax2.grid(True) ax2.set_title("After Shear Transformation (M)") ax2.legend() plt.show()你会发现,b的 y 分量没变,但 x 分量增加了y的值(1+2=3),这就是剪切的效果。更重要的是,a + b的变换结果,一定等于a的变换结果加上b的变换结果:M @ (a + b) == M @ a + M @ b。这就是线性变换的叠加性。这个性质,是神经网络能够层层堆叠、每一层都只做线性变换(Wx + b)然后接非线性激活(σ)的数学根基。没有叠加性,多层网络就失去了可组合的意义。
实操心得:在调试自定义层时,一个极其实用的检查是“线性性验证”。随机生成两个输入
x1,x2和一个标量alpha,然后检查layer(alpha*x1 + x2)是否等于alpha*layer(x1) + layer(x2)(允许微小的浮点误差)。如果不等,说明你的层里混入了非线性操作(比如torch.mean在 batch 维度上求均值,它就不是线性的),这可能会在分布式训练或某些优化器下引发难以察觉的 bug。
3.2 SVD 与伪逆:从图像压缩到最小二乘的完整闭环
SVD 是一个“重武器”,我们用一个具体的图像压缩例子来展示它的威力。我们将一张灰度图(一个二维矩阵)进行 SVD,然后只保留前k个最大的奇异值,看看重建的图像质量如何变化。
from PIL import Image import numpy as np import matplotlib.pyplot as plt # 加载并转换为灰度图(这里用一个简单的 8x8 矩阵模拟) # 实际中,你可以用 Image.open('your_image.jpg').convert('L') original_img = np.array([ [255, 200, 150, 100, 50, 0, 0, 0], [200, 180, 160, 140, 120, 100, 80, 60], [150, 160, 170, 180, 190, 200, 210, 220], [100, 140, 180, 220, 255, 255, 255, 255], [ 50, 120, 190, 255, 255, 255, 255, 255], [ 0, 100, 200, 255, 255, 255, 255, 255], [ 0, 80, 210, 255, 255, 255, 255, 255], [ 0, 60, 220, 255, 255, 255, 255, 255] ]) # 执行 SVD U, s, Vt = np.linalg.svd(original_img, full_matrices=False) # 计算原始图像的 Frobinius 范数(总能量) original_norm = np.linalg.norm(original_img, 'fro') print(f"Original image shape: {original_img.shape}") print(f"Number of singular values: {len(s)}") print(f"Original Frobenius norm: {original_norm:.2f}") # 尝试不同的 k 值进行重建 ks = [1, 2, 4, 8] fig, axes = plt.subplots(2, 2, figsize=(10, 10)) axes = axes.flatten() for i, k in enumerate(ks): # 用前 k 个奇异值重建 # s_k 是一个长度为 k 的向量,我们需要把它变成一个 k x k 的对角矩阵 s_k = np.diag(s[:k]) # U_k 是 U 的前 k 列,Vt_k 是 Vt 的前 k 行 U_k = U[:, :k] Vt_k = Vt[:k, :] # 重建图像 reconstructed_img = U_k @ s_k @ Vt_k # 计算重建误差 error_norm = np.linalg.norm(original_img - reconstructed_img, 'fro') error_ratio = error_norm / original_norm * 100 # 绘制 im = axes[i].imshow(reconstructed_img, cmap='gray', vmin=0, vmax=255) axes[i].set_title(f'k={k}\nError: {error_ratio:.1f}%') axes[i].axis('off') plt.colorbar(im, ax=axes, shrink=0.8) plt.suptitle("Image Reconstruction using Truncated SVD", fontsize=16) plt.tight_layout() plt.show()运行这段代码,你会看到,当k=1时,重建图像是一个非常模糊的、只保留了大致明暗对比的“影子”;当k=4时,已经能看到主要的结构;当k=8(即使用全部奇异值)时,重建图像是完美的,误差为 0%。这个过程,完美诠释了 SVD 如何将一个矩阵的信息,按重要性(奇异值大小)进行排序和剥离。
现在,我们把这个知识迁移到一个实际的机器学习问题上:线性回归的最小二乘解。假设我们有一组数据点(x_i, y_i),想用一条直线y = wx + b来拟合。我们可以把这个问题写成矩阵形式X @ β = y,其中X是设计矩阵(每行是[x_i, 1]),β = [w, b]是参数向量,y是目标向量。由于数据点通常多于参数个数,这是一个超定系统,没有精确解,只有最小二乘解β = (X.T @ X)⁻¹ @ X.T @ y。但直接计算(X.T @ X)⁻¹在X接近奇异时是病态的。更好的方法是用伪逆:β = np.linalg.pinv(X) @ y。
# 生成模拟数据 np.random.seed(42) x_data = np.linspace(0, 10, 50) y_true = 2.5 * x_data + 1.0 # 真实的直线 y_noise = y_true + np.random.normal(0, 2, size=x_data.shape) # 加入噪声 # 构建设计矩阵 X: 每行是 [x_i, 1] X = np.column_stack((x_data, np.ones_like(x_data))) y = y_noise # 方法1:直接计算 (X.T @ X)⁻¹ @ X.T @ y try: beta_direct = np.linalg.inv(X.T @ X) @ X.T @ y except np.linalg.LinAlgError: print("Direct method failed: X.T @ X is singular or ill-conditioned.") # 方法2:使用伪逆(推荐) beta_pinv = np.linalg.pinv(X) @ y # 方法3:使用 SVD 手动计算(理解伪逆的本质) U, s, Vt = np.linalg.svd(X, full_matrices=False) # 计算 Σ⁺:对 s 中的非零值取倒数,零值保持为零 s_inv = np.where(s > 1e-10, 1.0 / s, 0.0) # Σ⁺ 是一个 n x m 矩阵,其中 n 是 Vt 的行数,m 是 U 的列数 # 这里 X 是 (50, 2),所以 U 是 (50, 2), s 是 (2,), Vt 是 (2, 2) # Σ⁺ 应该是 (2, 50),但我们可以通过广播来简化 # 更标准的做法是:A⁺ = V @ diag(s_inv) @ U.T Sigma_plus = np.diag(s_inv) beta_svd = Vt.T @ Sigma_plus @ U.T @ y print(f"Direct method result: w={beta_direct[0]:.4f}, b={beta_direct[1]:.4f}") print(f"Pseudo-inverse result: w={beta_pinv[0]:.4f}, b={beta_pinv[1]:.4f}") print(f"SVD manual result: w={beta_svd[0]:.4f}, b={beta_svd[1]:.4f}") # 绘制拟合结果 plt.figure(figsize=(10, 6)) plt.scatter(x_data, y_noise, alpha=0.6, label='Noisy Data') plt.plot(x_data, 2.5*x_data + 1.0, 'g--', label='True Line (y=2.5x+1)') plt.plot(x_data, beta_pinv[0]*x_data + beta_pinv[1], 'r-', label=f'Fitted Line (w={beta_pinv[0]:.2f}, b={beta_pinv[1]:.2f})') plt.xlabel('x') plt.ylabel('y') plt.legend() plt.title("Linear Regression via Pseudo-Inverse") plt.grid(True) plt.show()这段代码展示了三种求解方法。你会发现,直接法在数据量大或X条件数差时容易失败,而伪逆和 SVD 手动法总是能给出稳定的结果。更重要的是,通过手动实现 SVD,你彻底明白了伪逆A⁺的计算过程:它本质上是利用 SVD 将A的“有效信息”(大的奇异值)保留下来,而将“无效信息”(小的、接近零的奇异值)过滤掉,从而获得一个鲁棒的解。这正是深度学习中许多正则化思想的源头。
3.3 熵与 KL 散度:从信息论到模型评估的量化语言
熵(Entropy)和 KL 散度(Kullback-Leibler Divergence)是连接概率论、信息论和机器学习的桥梁。它们提供了一套精确的、可计算的“语言”,来描述模型的不确定性、数据的分布差异以及学习过程的效率。
首先,澄清一个常见误区:scipy.stats.entropy函数的输入,必须是概率质量函数(PMF)或概率密度函数(PDF)的离散采样值,且这些值的和必须为 1。在你提供的原始代码中,scipy.stats.norm.pdf(a)计算的是正态分布在点a处的概率密度,这些密度值的和并不等于 1(因为 PDF 是连续函数,其积分等于 1,而非求和)。直接将它们传给entropy函数,得到的结果是没有严格信息论意义的。正确的做法是,先对数据进行直方图统计,得到经验 PMF。
import numpy as np import matplotlib.pyplot as plt from scipy import stats # 生成两组不同的数据分布 np.random.seed(42) data_p = np.random.normal(loc=0, scale=1, size=1000) # 标准正态分布 data_q = np.random.normal(loc=0.5, scale=1.2, size=1000) # 偏移且更宽的正态分布 # 方法1:使用直方图估计 PMF(推荐,更符合信息论直觉) def estimate_pmf(data, bins=50): hist, bin_edges = np.histogram(data, bins=bins, density=True) # density=True 返回的是 PDF,我们需要 PMF,所以乘以 bin width bin_width = bin_edges[1] - bin_edges[0] pmf = hist * bin_width # 确保和为1(浮点精度) pmf = pmf / pmf.sum() return pmf, bin_edges pmf_p, _ = estimate_pmf(data_p) pmf_q, _ = estimate_pmf(data_q) # 计算熵 entropy_p = stats.entropy(pmf_p, base=2) # 以2为底,单位是比特 entropy_q = stats.entropy(pmf_q, base=2) # 计算 KL 散度 D(P||Q) 和 D(Q||P) kl_pq = stats.entropy(pmf_p, pmf_q, base=2) kl_qp = stats.entropy(pmf_q, pmf_p, base=2) print(f"Distribution P (N(0,1)):") print(f" Entropy: {entropy_p:.4f} bits") print(f"Distribution Q (N(0.5,1.2)):") print(f" Entropy: {entropy_q:.4f} bits") print(f"KL Divergence D(P||Q): {kl_pq:.4f} bits") print(f"KL Divergence D(Q||P): {kl_qp:.4f} bits") print(f"Note: D(P||Q) != D(Q||P), confirming asymmetry.") # 可视化 fig, (ax1