傅里叶变换通常被解释为将一个信号分解到不同频率的正弦和余弦基上。这个理解很直观,不过在处理离散信号、卷积以及差分算子时,我更习惯从线性代数的角度来看傅里叶变换:离散傅里叶变换可以写成一次矩阵乘法,而卷积、差分等很多离散线性算子,在傅里叶基下恰好具有非常简单的表示。
顺着这个思路,离散傅里叶变换、循环卷积以及差分算子其实可以自然地串在一起。首先将 DFT 写成矩阵形式 x^=Fx,然后把循环卷积表示为循环矩阵与向量的乘法,最后利用循环矩阵与傅里叶变换之间的关系,将卷积和差分运算转换到频域。
本文主要整理这几部分内容,同时利用 NumPy 和 SciPy 对其中的一些结论进行简单验证。
1. 离散傅里叶变换的矩阵表示
假设有一个长度为 N 的离散序列
x=[x0x1⋯xN−1]T.
用 x^ 表示其离散傅里叶变换。按照 DFT 的定义,第 m 个频率分量可以写成
x^m=F(x)m=n=0∑N−1xne−N2πmni.(1)
定义
w=e−N2πi=cosN2π−isinN2π,
那么式(1)可以写成
x^m=n=0∑N−1xnwnm.
对于固定的 m,这实际上就是向量 x 与一组复指数基的乘积:
x^m=[w0mw1m⋯w(N−1)m]x.
将所有 m=0,1,…,N−1 的结果组合起来,可以得到
x^=w0×0w0×1⋮w0×(N−1)w1×0w1×1⋮w1×(N−1)⋯⋯⋱⋯w(N−1)×0w(N−1)×1⋮w(N−1)×(N−1)x0x1⋮xN−1.
定义傅里叶矩阵
F=111⋮11ww2⋮wN−1⋯⋯⋯⋱⋯1wN−1w2(N−1)⋮w(N−1)2,
那么 DFT 就可以非常紧凑地写成
x^=Fx.
因此,从矩阵的角度来看,离散傅里叶变换可以理解为使用复指数函数构成的基矩阵,对原始序列进行一次线性变换。
按照这里使用的傅里叶矩阵定义,可以得到
FT=F,
(F∗)T=F∗,
FF∗=NI,
FFH=NI,
以及
F−1=N1F∗.
这里 F∗ 表示元素逐个取复共轭,FH 表示共轭转置。由于这里构造的傅里叶矩阵满足 FT=F,因此 FH=(F∗)T=F∗。
这些性质可以直接通过 NumPy 进行验证:
import numpy as np
N = 6
x = np.array([-1, 0, 0, 0, 0, 1])
theta = np.arange(N)[:, None] * np.arange(N)[None, :]
F = np.exp(theta * (-2j * np.pi) / N)
Fx = F @ x
xhat = np.fft.fft(x)
assert np.allclose(xhat, Fx, rtol=1e-9)
assert np.allclose(np.linalg.inv(F), F.conj() / N)
assert np.allclose(F, F.T)
assert np.allclose(F @ F.conj().T, N * np.eye(N))
这里直接构造出来的 F 与 np.fft.fft 所采用的 DFT 定义一致,因此 F @ x 与 np.fft.fft(x) 可以得到相同的结果。
不过,如果只是把傅里叶变换写成矩阵乘法,意义还不是特别明显。真正有意思的是,当卷积也被写成矩阵形式以后,可以看到两者之间非常直接的关系。
2. 离散傅里叶变换与循环卷积
傅里叶变换和卷积定理通常会一起出现。最常见的一句话是:
时域中的卷积,对应频域中的逐元素乘法。
对于有限长度的离散序列,更准确地说,DFT 对应的是循环卷积。
设 x 和 z 是两个长度均为 N 的离散序列,它们的循环卷积记为 x∗z。循环卷积定义为
(x∗z)m=n=0∑N−1x(m−n)modNzn.
其中 (m−n)modN 表示当索引超出范围时,会重新绕回序列的另一端。因此,循环卷积实际上隐含了周期性的边界处理。
循环卷积定理可以写成
F(x∗z)=x^⊙z^,
其中 ⊙ 表示逐元素乘法。
在直接讨论这个结论之前,可以先把循环卷积写成矩阵形式。
假设 N=4,那么循环卷积可以展开成
x∗z=x0x1x2x3x3x0x1x2x2x3x0x1x1x2x3x0z0z1z2z3.
可以看到,卷积实际上对应一个具有循环结构的矩阵。
对于序列 x,定义循环矩阵
C(x)=x0xN−1xN−2⋮x1x1x0xN−1⋮x2x2x1x0⋱⋯⋯⋯⋯⋮xN−1xN−1xN−2xN−3⋮x0.
按照这里采用的定义,循环卷积可以写成
x∗z=CT(x)z.
这样一来,卷积就从一个“不断平移序列并求和”的过程变成了普通的矩阵乘法问题。
3. 循环矩阵与傅里叶变换
循环矩阵和傅里叶变换之间存在非常紧密的联系。原来的推导可以从一个循环移位置换矩阵开始。
定义
P=010⋮0001⋮0000⋱⋯⋯⋯⋯⋱1100⋮0.
矩阵 P 作用于序列时,相当于将序列循环移动一个位置。利用 wN=1,可以写成
P=Fdiag(1,w−1,w−2,…,w−(N−1))F−1.
进一步有
Pn=Fdiag(1,w−n,w−2n,…,w−(N−1)n)F−1.
另一方面,循环矩阵可以通过不同次数的 P 组合得到:
CT(x)=x0I+x1P+x2P2+⋯+xN−1PN−1.
将 P 的傅里叶表示代入,可以得到
CT(x)=Fdiag(n=0∑N−1xn,n=0∑N−1xnw−n,n=0∑N−1xnw−2n,…,n=0∑N−1xnw−(N−1)n)F−1.
也就是说,循环矩阵经过傅里叶变换以后,会转换为对角矩阵。按照前面的符号,可以写成
C(x)=Fdiag(x^)F−1,
以及
CT(x)=Fdiag(x^∗)F−1.
由循环卷积 x∗z=CT(x)z,可以进一步得到
CT(x)z=F−1(x^⊙z^),
也就是
x∗z=F−1(x^⊙z^).
两边进行傅里叶变换,最终得到
F(x∗z)=x^⊙z^.
这就是前面提到的循环卷积定理。
从这个角度来看,“卷积在频域中变成乘法”并不是一个孤立的公式。其背后真正发生的事情,是循环卷积所对应的循环矩阵在傅里叶基下变成了对角形式。
原本需要进行矩阵与向量乘法的运算,在傅里叶域中只需要对对应的频率分量逐元素相乘。
4. 循环矩阵的一些性质
有了傅里叶表示以后,循环矩阵的一些性质也比较容易整理出来。例如两个循环矩阵满足
C(x)C(z)=C(z)C(x),
以及线性关系
αC(x)+βC(z)=C(αx+βz).
另外,
C(x)C(z)=C(F−1(x^⊙z^)).
如果 C(x) 非奇异,那么还可以写成
C−1(x)=C(F−1(1/x^)).
这些关系背后的原因基本相同:循环矩阵经过傅里叶变换以后,对应的是一个对角矩阵,而对角矩阵的乘法、求逆等操作都可以直接通过其对角元素完成。
因此,很多原本表现为矩阵运算的问题,到了傅里叶域以后都会转化为更加简单的逐元素运算。
5. 用代码验证循环卷积
循环卷积在实际代码中的一个重要问题是卷积核的位置。
考虑一个长度为 3 的卷积核:
x = np.array([1, 2, 3])
如果使用 scipy.ndimage.convolve,卷积核的中心位于中间位置。对于长度为 3 的卷积核,其半径为 r = len(x) // 2。
而使用 FFT 实现循环卷积时,需要先将卷积核补零到与信号相同的长度,并通过循环移动将卷积核的中心移动到数组原点。
对于周期边界,可以写成:
import numpy as np
from scipy.ndimage import convolve
ks = 3
N = 5
r = ks // 2
x = np.array([1, 2, 3])
y = np.random.randn(N)
z = convolve(y, x, mode="wrap")
x_periodic = np.roll(np.pad(x, (0, N - ks)), shift=-r)
xhat = np.fft.fft(x_periodic)
yhat = np.fft.fft(y)
z_ = np.fft.ifft(xhat * yhat)
assert np.allclose(z_, z, rtol=1e-9)
mode="wrap" 使用循环边界,因此能够和 DFT 所对应的循环卷积直接对应。
如果采用 mode="constant",则需要先对信号进行补零,将问题嵌入一个更大的序列中。例如:
ks = 3
N = 5
r = ks // 2
L = N + 2 * r
x = np.array([1, 2, 3])
y = np.random.randn(N)
z = convolve(y, x, mode="constant")
x_periodic = np.roll(np.pad(x, (0, L - ks)), shift=-r)
y_periodic = np.pad(y, (r, r))
xhat = np.fft.fft(x_periodic)
yhat = np.fft.fft(y_periodic)
z_ = np.fft.ifft(xhat * yhat)[r:r + N]
assert np.allclose(z_, z, rtol=1e-9)
这里实际做的是先扩展计算区域,再利用扩展区域中的循环卷积模拟原始边界条件下的卷积。
因此,在实际利用傅里叶变换处理卷积时,除了卷积定理本身,还需要特别注意两个问题:边界条件是否一致,以及卷积核中心在离散序列中的位置是否正确。
如果这两个地方处理不一致,即使频域中的逐元素乘法没有任何问题,最终结果也可能出现平移或者边界上的差异。
6. 差分算子也可以写成卷积
理解卷积以后,差分算子的傅里叶表示就比较自然了。
对于一维离散序列,一阶差分可以通过卷积核
[1,−1]
实现。二阶差分则可以通过
[1,−2,1]
实现,因此可以写成
∇2x=[1,−2,1]∗x.
在周期边界条件下,对应的矩阵形式为
∇2x=−210⋮11−21⋮001−2⋱⋯⋯⋯⋯⋱1100⋮−2x.
可以看到,这仍然是一个循环矩阵,因此可以直接使用前面的傅里叶分析方法。
按照循环卷积中的卷积核排列方式,与二阶差分对应的周期序列可以写成
[−210⋯01]T.
对这个序列进行 DFT,第 k 个频率分量为
wk+w−k−2.
由于 wk=e−2πik/N,有
wk+w−k=2cosN2πk.
因此,二阶差分算子在傅里叶域中的表示为
λk=2cosN2πk−2.
于是空间域中的二阶差分运算可以写成
∇2x=F−1(λ⊙Fx).
也就是说,一维二阶差分在空间域中需要访问相邻位置,而在傅里叶域中只需要将每一个频率分量乘以对应的 λk。
可以用下面的代码进行验证:
import numpy as np
from scipy.ndimage import convolve
N = 7
x = np.random.randn(N)
kernel = np.array([1, -2, 1])
A = 2 * np.cos(2 * np.pi * np.arange(N) / N) - 2
z = convolve(x, kernel, mode="wrap")
z_ = np.fft.ifft(np.fft.fft(x) * A)
assert np.allclose(z, z_, rtol=1e-9)
这里的 A 就是差分算子在每一个离散频率位置上的响应。
7. 二维差分算子的频域形式
同样的思路可以自然推广到二维。
对于二维数据,常见的四邻域 Laplacian 卷积核为
K=0101−41010.
假设输入数据大小为 H×W。在周期边界条件下,其傅里叶域表示可以写成
Ap,q=2(cosH2πp+cosW2πq)−4.
因此二维 Laplacian 可以表示为
∇2x=F−1(A⊙F(x)).
代码验证如下:
import numpy as np
from scipy.ndimage import convolve
H, W = 10, 10
kernel = np.array([[0., 1., 0.], [1., -4., 1.], [0., 1., 0.]])
x = np.random.randn(H, W)
H_ind = np.arange(H)[:, None]
W_ind = np.arange(W)[None, :]
A = 2 * (np.cos(2 * np.pi * H_ind / H) + np.cos(2 * np.pi * W_ind / W)) - 4
z = convolve(x, kernel, mode="wrap")
z_ = np.fft.ifft2(np.fft.fft2(x) * A)
assert np.allclose(z, z_, rtol=1e-5)
二维情况下与一维情况并没有本质区别,只是原来的一维频率索引 k 变成了二维索引 (p,q)。每增加一个空间维度,只需要把对应方向上的频率响应继续加入即可。
8. 三维差分算子的频域形式
对于三维数据,使用六邻域 Laplacian 时,卷积核中心位置为 −6,并在前后、上下、左右六个相邻位置取值 1。
假设三维数据尺寸为 H×W×D,那么对应的频率响应为
Ap,q,r=2(cosH2πp+cosW2πq+cosD2πr)−6.
因此三维 Laplacian 同样可以写成
∇2x=F−1(A⊙F(x)).
代码验证如下:
import numpy as np
from scipy.ndimage import convolve
H, W, D = 30, 100, 50
kernel = np.zeros((3, 3, 3))
kernel[1, 1, 1] = -6
kernel[0, 1, 1] = kernel[2, 1, 1] = 1
kernel[1, 0, 1] = kernel[1, 2, 1] = 1
kernel[1, 1, 0] = kernel[1, 1, 2] = 1
x = np.random.randn(H, W, D)
H_ind = np.arange(H)[:, None, None]
W_ind = np.arange(W)[None, :, None]
D_ind = np.arange(D)[None, None, :]
A = 2 * (np.cos(2 * np.pi * H_ind / H) + np.cos(2 * np.pi * W_ind / W) + np.cos(2 * np.pi * D_ind / D)) - 6
z = convolve(x, kernel, mode="wrap")
z_ = np.fft.ifftn(np.fft.fftn(x) * A)
assert np.allclose(z, z_, rtol=1e-5)
从一维到二维再到三维,整个推导过程实际上没有发生本质变化。空间域中的差分算子仍然对应一个卷积,而周期卷积具有循环结构,因此可以通过傅里叶变换将其转换成逐频率的乘法。
9. 从 DFT 再回头看卷积和差分
回过头来看,本文实际上只用了一个非常简单的起点:
x^=Fx.
但是将傅里叶变换放在线性代数框架下以后,可以逐步得到很多有用的结果。
首先,循环卷积可以写成循环矩阵与向量的乘法:
x∗z=CT(x)z.
循环矩阵可以通过傅里叶变换转化成对角形式,因此循环卷积满足
F(x∗z)=x^⊙z^.
差分算子本身又只是特殊的卷积核,因此同样可以转换到傅里叶域。一维二阶差分对应
Ak=2cosN2πk−2,
二维四邻域 Laplacian 对应
Ap,q=2(cosH2πp+cosW2πq)−4,
三维六邻域 Laplacian 则对应
Ap,q,r=2(cosH2πp+cosW2πq+cosD2πr)−6.
因此,在空间域中表现为局部邻域计算的卷积和差分运算,进入傅里叶域以后,都可以转化成对应频率分量上的逐元素乘法。
这也是我觉得从矩阵角度理解傅里叶变换比较有意思的地方。很多看起来彼此独立的公式,其实都可以归结到同一个结构上:DFT 提供了一个新的基底,而循环卷积和周期差分算子在这个基底下具有更加简单的表示。
真正理解这一点以后,“时域卷积对应频域乘法”就不再只是一条需要记忆的结论,而是能够从循环矩阵与傅里叶变换之间的关系自然推导出来。
在实际计算中,还需要特别留意边界条件和卷积核位置。DFT 天然对应周期性的离散序列,因此 mode="wrap" 可以直接与循环卷积对应;如果使用常数填充或其他边界处理,则需要先进行相应的扩展和对齐,之后才能正确地利用 FFT 实现对应运算。