
1. 为什么用矩阵视角看卷积1.1 卷积的经典理解和学习痛点卷积这玩意儿刚接触信号处理的人几乎都会被绕一下。教科书给的定义长这样y[n]Σx[k]h[n−k]然后解释成“反褶、平移、相乘、求和”四步走。听起来不难但真动手做起来边界下标一多人就开始晕。最典型的问题是线性卷积结果该有几项循环卷积的下标取模什么时候生效为什么某些题目里两个结果一样某些题目里又不一样我当年学的时候也是反复翻书直到后来某天把卷积写成了矩阵乘法才发现很多困惑其实是“描述方式”造成的。卷积本身不难难的是你脑子里没有一个稳定的几何结构去承载它。矩阵刚好能补上这一块——它把卷积从“一步一动地滑动”变成“一次性把所有组合关系摆在你面前”。这篇文章打算完全站在矩阵视角把线性卷积和循环卷积重新讲一遍。不讲太多泛泛的公式推导重点放在“为什么矩阵长成那样”以及“这两种卷积在矩阵层面到底差在哪”。如果你正在学数字信号处理、信号与系统或者在搞深度学习里的卷积加速、滤波器设计应该都能从这套思路里得到一些启发。1.2 矩阵视角的核心价值矩阵视角第一个价值是让卷积的“形状”变得非常清晰。给定两个序列一个当卷积核一个当输入把它们扔进矩阵乘法之后结果长度是多少、哪些位置参与了运算、哪些位置是补零留出来的全都一目了然。你不用再对着下标算半天矩阵的每一行自己就能告诉你答案。第二个价值是它能直接解释频域卷积定理的底层结构。很多人知道“时域卷积等于频域相乘”但不知道为什么。从矩阵看就很简单循环卷积对应的是循环矩阵而循环矩阵可以被DFT矩阵对角化。一旦对角化矩阵乘法就变成了逐点相乘这就是卷积定理的本来面目。第三个价值是工程上能帮你少踩坑。做FFT加速卷积时如果忘了补零结果就会出现边界混叠重叠相加法和重叠保留法为什么要那样分块处理本质上也是在处理循环卷积矩阵和线性卷积矩阵之间的差异。这些坑通过公式也能理解但矩阵图画出来之后你会记得特别牢基本不会再犯。1.3 这篇文章能帮你解决什么问题我会从线性卷积的Toeplitz矩阵结构讲起再对比循环卷积的Circulant矩阵结构然后讨论两者的桥梁——补零等价关系。最后放一些实操中的问题排查经验和一张速查表方便你随时对照。整个过程尽量不用太深的数学用到DFT的地方也只提取必要结论。你可以把它当作一份“卷积矩阵化”的入门地图看完之后再去翻任何一本信号处理的教材应该都会顺畅很多。2. 线性卷积的矩阵构造Toeplitz结构2.1 从滑动求和到矩阵乘法先看线性卷积。假设输入序列是x长度N卷积核是h长度M线性卷积结果y的长度是NM−1。它的定义式是y[n]Σ_{k0}^{N−1} x[k]·h[n−k]注意这里的h下标没有取模只有当下标落在[0, M−1]范围内才有效范围外一律当成0。这也是线性卷积和循环卷积最本质的区别——线性卷积默认“卷积核之外全是零”没有任何回绕。如果把这个式子展开写成一个方程组矩阵结构就浮现出来了。以N3、M2为例y[0] x[0]·h[0] y[1] x[0]·h[1] x[1]·h[0] y[2] x[1]·h[1] x[2]·h[0] y[3] x[2]·h[1]写成矩阵乘法就是[ \begin{bmatrix} y[0] \ y[1] \ y[2] \ y[3] \end{bmatrix}\begin{bmatrix} h[0] 0 0 \ h[1] h[0] 0 \ 0 h[1] h[0] \ 0 0 h[1] \end{bmatrix} \begin{bmatrix} x[0] \ x[1] \ x[2] \end{bmatrix} ]这个矩阵就是线性卷积矩阵学术上叫Toeplitz矩阵。它每一行相当于把卷积核h平移一个位置然后和x做点乘。注意行数和列数不一样行数是NM−1列数是N。矩阵本身不一定是方阵只有当NM时才碰巧是方阵。这个结构让我第一次理解了一件事卷积不是“某一对元素相乘”而是“整个卷积核在输入序列上滑动时每次覆盖到的局部区域和卷积核做内积”。矩阵的行就是在记录每一次滑动的覆盖情况。2.2 一个具体的数值算例光有符号推导还不够我习惯拿具体数字验证一遍心里才踏实。设x[1,2,3]h[4,5]那么线性卷积手工算y[0] 1×4 4y[1] 1×5 2×4 13y[2] 2×5 3×4 22y[3] 3×5 15结果是[4,13,22,15]。矩阵乘法结果也一样 第一行4×14第二行5×14×213第三行5×24×322第四行5×315。这个例子虽然简单但它暴露了一个容易忽略的点线性卷积结果长度是NM−14比输入长。这意味着如果你写代码时结果数组只分配了N个位置最后几位会被悄悄丢掉。很多初学卷积实现的人第一次跑卷积程序发现输出变长了会以为算错了其实不是卷积本来就会“扩展”数据长度。补一句线性卷积矩阵不一定非要按h来构造。如果我们把h当列向量、把x的元素构造成Toeplitz矩阵也可以得到同样的结果。区别只是谁站在矩阵里、谁站在向量里。实际操作中一般把较短的那个序列构造成矩阵这样矩阵规模更小、计算量更省。2.3 Toeplitz矩阵的结构特性Toeplitz矩阵的一个重要特征任意一条从左上到右下的对角线上元素都相同。看前面那个4×3矩阵主对角线全是h[0]它下方的次对角线全是h[1]再往下全是0。这是因为h[r−c]只和“行号减列号”的差值有关而一条对角线上的(r−c)是同一个常数。这个性质有什么用它让矩阵乘法的计算复杂度有优化空间。朴素矩阵乘法是O(N²)但Toeplitz矩阵乘法可以通过FFT降到O(N log N)级别。方法是把Toeplitz矩阵嵌入到一个更大的循环矩阵里然后利用FFT一次性算完。这就是后面要讲的补零等价思想的雏形。另外从数值线性代数的角度看Toeplitz矩阵在实际中经常出现信号去卷积、系统辨识、时间序列预测、机器学习里的某些核方法都会遇到类似结构的矩阵。如果你只想理解DSP里的卷积知道“对角线恒定”就够了如果想往深处走可以去看Toeplitz系统的快速求解算法那是另外一个富矿。2.4 构造线性卷积矩阵的通用方法编码的时候手写这个矩阵很简单不需要背公式直接按定义来。下面这段Python代码可以生成任意长度h、任意长度x的线性卷积矩阵认真读一遍它比公式直观得多import numpy as np def linear_conv_matrix(h, N): M len(h) L N M - 1 # 结果长度 H np.zeros((L, N)) for r in range(L): for c in range(N): idx r - c if 0 idx M: H[r, c] h[idx] return H x np.array([1.0, 2.0, 3.0]) h np.array([4.0, 5.0]) H linear_conv_matrix(h, len(x)) print(H.dot(x)) # [ 4. 13. 22. 15.]这里的核心就是H[r,c]h[r−c]有效范围由h的长度约束。循环清楚地体现了“每一行的卷积核偏移量不同”这件事。实际工程里如果用numpy可以直接用scipy.linalg.toeplitz构造但初学者我建议先手写一遍理解比省事重要。3. 循环卷积的矩阵构造Circulant结构3.1 循环卷积到底在卷什么循环卷积的定义和线性卷积长得很像唯一的区别是下标变成了模N运算。假设两个序列长度都为Ny[n]Σ_{k0}^{N−1} x[k]·h[(n−k) mod N]这个“mod N”让卷积核在滑动时“绕回来了”。你可以把两个序列放在一个圆环上卷积的时候h在圆环上转圈。因此循环卷积的结果长度仍然是N不会像线性卷积那样变长。但绕回来会带来一个让人头疼的副作用线性卷积里h超出边界的部分当作0循环卷积里这些部分却会“卷”回到序列头部参与运算。这意味着循环卷积的结果会在开头和结尾处混入一些来自尾部的贡献。学过DFT的人都知道这是所谓的时间混叠现象。从矩阵上看这个“绕回来”非常生动循环卷积矩阵里本该是0的右上角和左下角被h的元素填满了。整个矩阵看起来像一个首尾相接的环。3.2 循环矩阵怎么搭出来还是用具体例子。设N3x[x[0],x[1],x[2]]h[h[0],h[1],h[2]]。循环卷积展开y[0] x[0]h[0] x[1]h[2] x[2]h[1] y[1] x[0]h[1] x[1]h[0] x[2]h[2] y[2] x[0]h[2] x[1]h[1] x[2]h[0]矩阵形式[ \begin{bmatrix} y[0] \ y[1] \ y[2] \end{bmatrix}\begin{bmatrix} h[0] h[2] h[1] \ h[1] h[0] h[2] \ h[2] h[1] h[0] \end{bmatrix} \begin{bmatrix} x[0] \ x[1] \ x[2] \end{bmatrix} ]这个矩阵的每一行是上一行向右循环移动一个位置得到的。每一列也是上一列向下循环移动一个位置得到的。这种每一行都是前一行循环移位的矩阵就是循环矩阵也叫Circulant矩阵。同样看一个数值例子。x[1,2,3]h[4,5,6]直接按上面公式算y[0] 1×4 2×6 3×5 31y[1] 1×5 2×4 3×6 31y[2] 1×6 2×5 3×4 28结果是[31,31,28]。如果用线性卷积算先把结果算出来再截断完全是另一码事。线性卷积[4,13,28,27,18]如果只取前3项是[4,13,28]和循环卷积对不上就是因为线性卷积末尾两项本来应该另起炉灶循环卷积却把它们折回开头参与累加了。3.3 循环矩阵与DFT对角化循环矩阵有个非常漂亮的代数性质任何循环矩阵都可以被DFT矩阵对角化。换句话说设C是循环矩阵存在可逆矩阵F就是DFT变换矩阵使得C F⁻¹ · diag(λ₀, λ₁, …, λ_{N−1}) · F其中λ_k恰好等于h的DFT结果的第k个分量。这解释了为什么循环卷积在频域做逐点相乘就行先对x做DFT左乘F逐点乘上h的DFTdiag对角阵再做IDFT左乘F⁻¹就回到了时域卷积结果。用直觉理解DFT的本质是把信号分解成不同频率的复指数基。对循环矩阵来说这些复指数基正好是它的特征向量循环移位不会改变复指数的“形状”只会改变它的相位。因此循环矩阵在频率基底下变成了一个纯缩放操作没有耦合——这就是对角化的含义。新接触这个概念的人可能会觉得“对角化”很抽象。我建议你亲手算一个2×2循环矩阵的特征向量你会发现特征向量就是[1,1]和[1,−1]这样的向量而DFT在这两点的基也正好是这两个向量。遇到具体例子之后“为什么能被对角化”就变成一个很自然的事情了。3.4 这就是卷积定理的矩阵版本很多人背过“时域循环卷积等于频域乘积”却不知道这个定理在矩阵世界其实只是一句话循环矩阵可对角化。用矩阵语言重写一遍y C·x F⁻¹ · diag(H_k) · F · x左乘F DFT右乘F⁻¹ IDFT中间的diag(H_k)就是逐点相乘。每一步都有明确的矩阵运算含义和你在代码里做的FFT、逐点乘、IFFT完全一一对应。这也是为什么我在实际工程里遇到卷积问题时第一反应都是先画矩阵。因为只要矩阵形态对了DFT怎么加速、补零补多少、边界怎么处理全都顺势就有了结论。矩阵就像是这套知识的“文件系统”公式是文件内容而矩阵结构是目录结构。4. 补零等价线性卷积与循环卷积的桥梁4.1 关键定理和补零规则前面说了线性卷积和循环卷积的区别现在来说它们怎么统一起来。有一个重要的结论如果两个序列x长度N和h长度M都补零到LNM−1长度再做L点循环卷积得到的结果和线性卷积完全一样。这个结论太有用了。因为循环卷积可以借助FFT加速所以实际工程里做长卷积基本都是靠这个定理先补零再FFT逐点乘IFFT一气呵成。再强调一次补零的规则结果长度L必须是NM−1。少了不行多了可以但不是必须。如果只补到max(N,M)长度那就还是循环卷积结果会发生混叠。回到前面x[1,2,3]h[4,5]的例子。这里N3、M2、L4。把x补成[1,2,3,0]把h补成[4,5,0,0]做4点循环卷积y[0] 1×4 2×0 3×0 0×0 4y[1] 1×5 2×4 3×0 0×0 13y[2] 1×0 2×5 3×4 0×0 22y[3] 1×0 2×0 3×5 0×4 15结果[4,13,22,15]和线性卷积分毫不差。这就是补零等价定理的实际效果。4.2 补零的矩阵解释从矩阵视角看补零操作非常直观。还是那个例子3点循环卷积矩阵本来长这样[ \begin{bmatrix} 4 0 5 \ 5 4 0 \ 0 5 4 \end{bmatrix} ]右上角的“5”就是把线性卷积矩阵尾部的h[1]给卷回来的产物。补零到4点之后循环卷积矩阵变成[ \begin{bmatrix} 4 0 0 0 \ 5 4 0 0 \ 0 5 4 0 \ 0 0 5 4 \end{bmatrix} ]右上角变成0了因为补零让卷积核“够不着”那么远的回绕位置。这个4×4矩阵的行结构其实已经和线性卷积矩阵几乎一样只是多了一列0列对应x补的那个0。换句话说补零的本质是把循环矩阵右上角那些“不该出现的元素”清空让矩阵退化成Toeplitz结构的一部分。我特别喜欢用这个角度跟人解释混叠循环卷积的混叠量恰好等于循环矩阵右上角那堆非零元素贡献的值。只要把卷积核补足够的零让右上角元素全部归零循环卷积就变成了线性卷积。清晰明了。4.3 重叠相加法与重叠保留法的矩阵理解长序列卷积是实时信号处理里的常见需求。比如音频处理时输入可能长达几十秒卷积核却只有几百个点如果等整段信号收齐再做FFT延迟根本扛不住。所以工程实现通常分块处理块与块之间的衔接方式有两派重叠相加法Overlap-Add和重叠保留法Overlap-Save。从矩阵视角看重叠相加法是把整个大Toeplitz矩阵按行切成若干小块每一块和对应的信号片段做卷积然后把相邻块的输出在重叠区相加。因为线性卷积是线性的整个系统满足叠加原理所以分块算完再加和整体算完结果一样。重叠保留法的思路则不同。它每一块做的是循环卷积但故意保留循环卷积中“没混叠”的中间部分丢掉两端受污染的部分。具体做法是每块输入包含上一块最后P−1个采样点和本块的新数据做循环卷积后只取中间N−P1个有效点。矩阵视角下就是循环卷积矩阵的中间几行没有受到右上角回绕元素的影响可以放心采用。这两类方法原理上有点绕但如果你在纸上把循环卷积矩阵画出来把“卷回来污染”的位置标出来会发现保留哪些行、丢弃哪些行都清清楚楚。矩阵真是傅里叶方法最好的说明书。4.4 实操中的补零策略补零看起来简单实操中还是有几个容易被忽视的点。第一补零长度必须是NM−1但FFT通常喜欢2的幂或3、5的倍数所以实际操作中经常再补到下一个2的幂。这样FFT更快结果长度不受影响多出来的位置本身就是零。第二很多库函数的“卷积”默认是线性卷积而“FFT逐点相乘再IFFT”默认是循环卷积。用FFT加速线性卷积时两个序列都要先补零。我曾见过有人只补一个序列结果边界处出现奇怪的不连续——那就是混叠没清干净的典型症状。第三浮点误差会随着序列长度积累。FFT卷积算出来的结果和直接线性卷积的结果会有微小差异这很正常不必恐慌。但如果你的应用要求严格精确比如某些数值计算就要评估一下误差能否接受必要时还是直接用线性卷积或使用更高精度的FFT实现。5. 常见问题、踩坑实录与速查表5.1 边界混叠怎么排查我最早被循环卷积坑过一次。当时做频域滤波信号和滤波器长度差不多我没补零就直接把两者FFT乘了一下再IFFT结果输出开头一段明显有“不该出现的回声”。后来才知道那就是循环卷积把尾部数据卷到了前面。排查方法很简单拿同样的信号和滤波器用直接线性卷积算一遍做对照。如果两个结果在中间段一致、两端不一致基本就是循环卷积的时间混叠。解决办法就是按4.1补零把长度拉到至少NM−1。如果你已经在做补零了但边界还是对不上建议检查一下两个序列是不是都补了零以及DFT长度有没有设置成至少L。有些FFT库允许指定的输出长度小于输入长度用了之后等价于时间混叠结果自然不对。5.2 矩阵规模与性能取舍理论上把卷积写成矩阵乘法可以直接用矩阵库加速比如GPU上的cuBLAS。但要注意复杂度直接矩阵乘法是O(N²)而FFT卷积是O(N log N)。序列长度几百时两者差距还不明显长度上万之后几乎是天壤之别。所以我的建议是如果只是做验证、做教学演示直接用矩阵或numpy.convolve都行如果做实时音频、图像处理、深度学习推理尽量用FFT路径。矩阵视角是用来理解原理的不是让你真的去构建一个巨大矩阵再乘。不过有些场景反而适合矩阵形式。比如卷积核特别短、输入特别长用Toeplitz矩阵和稀疏向量乘法可能比FFT更快。因为FFT需要整块变换有固定的开销而稀疏矩阵乘法可以聚焦在非零元素上。工程上没有银弹建议用基准测试说话。5.3 实际应用中的五个坑第一深度学习里的“卷积”默认是互相关cross-correlation不翻转卷积核。它和信号处理里的卷积差一个旋转180度的操作。写代码时看库函数文档别默认它和numpy.convolve等价。第二DFT逐点相乘的结果是循环卷积不是线性卷积。如果你用它去近似线性卷积必须先补零。这个坑我在音频滤波里踩过不止一次后来养成了习惯每次写频域卷积先写注释标明两个序列的长度和补零后的FFT点数。第三补零不是多多益善。补到2的幂只是图FFT方便如果你补零过多虽然结果正确但会白白浪费内存和算力。注意FFT长度和输入长度是两个概念别搞混。第四循环卷积的混叠不是全有或全无。如果L取在N和NM−1之间结果中有一部分位置是正确的另一部分混入了尾部数据。比如前面3点循环卷积的y[1]13恰好和线性卷积的y[1]一样但y[0]、y[2]都不对。不能只看中间一个点对就以为整体没问题。第五滤波器的群延迟会影响你对齐信号。线性卷积的结果长度比原信号长如果你只需要和原信号等长的输出通常会做“same”模式的截断。但截断位置是前补后补不同库定义不一样结果会对不齐。矩阵画出来后你在哪一行截断、保留哪些索引心里应该有一本账。5.4 线性卷积与循环卷积速查表下面这张表是我自己常看的版本放在身边随时翻。对比项线性卷积循环卷积结果长度NM−1max(N,M)通常等长N边界处理越界视为0越界回绕到另一端矩阵结构Toeplitz矩阵Circulant矩阵矩阵对角线每条对角线元素相同每行是上一行循环移位频域对应多项式乘积 / z变换乘积DFT逐点相乘FFT加速需先补零到NM−1直接FFT相乘典型算法直接卷积、重叠相加FFT卷积、重叠保留混叠风险无有补零可消除最后再分享一个我自己的习惯。每次要写一个卷积相关的模块不管多简单我都会先在纸上把矩阵形态画出来哪怕只是草草几笔。比如x长度是3、h长度是2就画一个4行3列的Toeplitz矩阵三步看一眼再写代码。这个习惯帮我省下的排查时间远比画图那几秒多。矩阵不是考试用的它是你调试程序时的坐标系。只要你愿意先花一分钟把它摆出来卷积的很多问题就不再是问题。