卷 I · 转起来CH 02深度 2/20

把信号缠到圆上,看质心跑到哪

这一章装好整本书唯一的那台机器。它只有一个动作,而且是一个你可以在脑子里做出来的动作。装完之后,傅里叶变换那个印在教材上、看一眼就想合上书的公式,会变成一句你自己就能写出来的大白话。

★★ 全书唯一的机器0.5000 vs 4.6e−17公式逐字翻译

▷ 先猜一下

手上有一秒钟的信号,它是两个正弦加起来的:一个 3 Hz、振幅 1.0,一个 5 Hz、振幅 0.5。用 256 Hz 采样,一共 256 个数。

接下来这一章会介绍一个动作:把这段信号缠到一个圆上,缠的速度由你定,然后量一量缠出来那条曲线的质心离圆心有多远。

问:如果我用 4 圈/秒去缠(信号里根本没有 4 Hz 这个成分),质心离圆心多远?

A 大约 0.25。虽然没有 4 Hz,但 3 Hz 和 5 Hz 都在它旁边,总会蹭上一点 B 大约 0.1。会小一些,但肯定不是零——毕竟信号在那儿摆着 C 是 0,而且是浮点数意义上的 0(10⁻¹⁷ 量级) D 说不准。取决于两个正弦的起始相位怎么配

问题:怎么查一段信号里有没有 3 Hz

先把问题问清楚。给你一段一秒钟的数据,256 个数,你怎么判断里面有没有「每秒转三圈」这个成分?

一个笨办法是:画出来,用尺子量周期。这在只有一个成分的时候管用,两个成分叠在一起就不行了——你看到的是一条谁也认不出来的曲线。

聪明的办法只有一个动作。把这条曲线缠到一个圆上去。

具体怎么缠:想象你手里有一个转盘,转速由你指定,比如每秒转 3 圈。现在让时间往前走,转盘匀速转;每一刻,你把画笔离转盘中心的距离,设成那一刻的信号值(信号大就往外推,信号小就往里收)。一秒钟走完,笔在转盘上画出一条闭合的花纹。

然后问一个问题:这条花纹的质心,在哪儿?(把花纹想成一根粗细均匀的铁丝,质心就是它的平衡点。)

上面这张图,缠的速度是 3 圈/秒,而信号里确实有 3 Hz。看右边:花纹整体偏向了一侧,质心被拽出圆心 0.5000

为什么?因为信号里那个 3 Hz 的成分,每秒也正好起伏三次。转盘转到某个特定方向的时候,信号也正好在高点——两者步调完全一致,于是「往外推」这个动作每一圈都发生在同一个方向上。三圈下来,那一侧被推得又胖又远,另一侧被收得又瘦又近。质心当然偏了。

现在换个速度:

同一段信号,改用 4 圈/秒去缠。这一次,「信号在高点」和「转盘在某个方向」这两件事再也对不上了——高点这一圈落在右边,下一圈落在左上,再下一圈落在左下……转完四圈,往外推的力气朝各个方向均匀地分布掉了,互相抵消。

质心回到圆心。本机实算的结果是 4.6 × 10⁻¹⁷——这不是「很小」,这是双精度浮点数里的零(相当于把一米的长度分成一亿亿份取一份)。

◆ 主线

「信号里有没有频率 f」这个问题,等价于「用 f 圈/秒把它缠到圆上,质心会不会跑出去」。

跑出去多远 = 这个频率的分量有多强。
跑向哪个方向 = 这个频率的相位

这就是傅里叶变换。剩下的全是记号。

把这句话写成公式

「缠到圆上」用坐标怎么写?第 1 章已经给了:以 f 圈/秒转,时刻 t 的方向是圆上角度 -2πft 那一点,也就是 e^(-i2πft)(负号表示顺时针,是个约定,写成正号得到的是镜像)。把画笔推到距离 x(t),那一刻笔尖的位置就是两者相乘:

笔尖位置(t) = x(t) · e^(-i2πft)
              └──┬──┘  └────┬────┘
              离中心多远    朝哪个方向

「质心」= 把所有时刻的笔尖位置加起来,除以个数:

      1  N−1
c(f) = ─  Σ   x[n] · e^(-i2πf·n/fs)
      N  n=0

—— 这就是离散傅里叶变换。全书唯一的公式,没有第二个。

逐个符号翻译一遍:

符号它的意思
x[n]第 n 个采样值。画笔离中心多远
n/fs第 n 个采样发生在第几秒(fs 是采样率)
e^(-i2πf·n/fs)那一刻转盘朝哪个方向。以 f 圈/秒顺时针转
Σ把每一刻的笔尖位置加起来(复数相加就是向量首尾相接)
1/N除以点数,把「加起来」变成「平均」,也就是质心
c(f)一个复数,也就是平面上的一个点。它的长度是这个频率有多强,它的角度是这个频率的相位

课本上写的 DFT 通常不带那个 1/Nnumpy.fft.fft 也不带),所以课本的 X[k] 是我们这里 c(f) 的 N 倍。这只是把「质心」换成了「总和」,几何意义一样。本机核对过:fft(x)[3] / 256 与绕圈机器算出来的质心,差 5.6 × 10⁻¹⁶——同一个东西。

把速度从 0 扫到 8

机器装好了,现在把缠绕速度从 0 一路扫过去,每个速度记一个质心长度。同一段「3 Hz 振幅 1.0 + 5 Hz 振幅 0.5」的信号,本机实算:

缠绕速度      质心到圆心的距离 |c|
────────────────────────────────────────
  0 圈/秒       4.0 × 10⁻¹⁷
  1 圈/秒       3.8 × 10⁻¹⁷
  2 圈/秒       2.2 × 10⁻¹⁷
  3 圈/秒       0.5000        ←──  在这儿
  4 圈/秒       4.6 × 10⁻¹⁷
  5 圈/秒       0.2500        ←──  也在这儿
  6 圈/秒       1.3 × 10⁻¹⁶
  7 圈/秒       4.2 × 10⁻¹⁷
  8 圈/秒       2.9 × 10⁻¹⁷
────────────────────────────────────────
不是 3 和 5 的那些,最大的一个是 1.3 × 10⁻¹⁶。

把这张表画成柱状图,就是你在无数音乐播放器里见过的那个东西:

请注意两件事。

第一,那些「没有」的频率,答案不是「比较小」,是零。10⁻¹⁷ 和 0.5 之间隔着十七个数量级。这不是一台「大致能分辨」的机器,是一台精确的机器。它为什么能这么干净,是下一章的全部内容。

第二,3 Hz 的振幅明明是 1.0,为什么质心只有 0.5000?这不是误差,是一个必须现在就说清楚的事:一个实数的正弦,在这台机器眼里,其实是两个反向旋转的和。

     e^(iθ) + e^(-iθ)
cos θ = ─────────────────
              2

一个逆时针转的点,加上一个顺时针转的点,
两个纵坐标永远反号、互相抵消,只剩下横坐标的两倍。

于是一个振幅 1.0 的实正弦,
它的「能量」被平分给了 +3 圈/秒 和 −3 圈/秒 两个转速,
每边各拿 0.5。

所以这台机器如实地报告了 0.5000。要拿回振幅,得把两边加起来,也就是乘 2。这是新手最常犯的一个数值错误,下面「✗ 这个直觉是错的」专门讲它。

顺手记一个数:3.5 圈/秒

表里都是整数速度。试试非整数的:用 3.5 圈/秒去缠同一段信号(信号里当然没有 3.5 Hz),质心是

|c| at 3.5 圈/秒  =  0.2315

不是 10⁻¹⁷,是 0.2315——将近 3 Hz 那根线的一半。信号里明明没有 3.5 Hz,机器却报了一个不小的数。

这不是 bug,而且它一点也不冤枉:问题出在「一秒钟」这个边界上。为什么会这样、以及它会造成多大的麻烦,是第 8 章的全部内容。现在只要记住这个数:0.2315。它是这本书里第一个账单。

⌨ 自己跑一遍

整台机器,十四行,零依赖。wind(x, fs, f) 就是上面那个公式。

import math

def wind(x, fs, f):
    """把 x 以 f 圈/秒缠到圆上,返回质心 (实部, 虚部)"""
    sr = si = 0.0
    for n, v in enumerate(x):
        a = -2 * math.pi * f * n / fs
        sr += v * math.cos(a)
        si += v * math.sin(a)
    return sr / len(x), si / len(x)

fs = 256
x = [math.sin(2*math.pi*3*n/fs) + 0.5*math.sin(2*math.pi*5*n/fs)
     for n in range(fs)]

for f in range(9):
    re, im = wind(x, fs, f)
    print(f, '%.4f' % math.hypot(re, im))

打出来的就是上面那张表。想验证它和标准实现是同一个东西:

import numpy as np
X = np.fft.fft(x) / len(x)
print(abs(X[3]), abs(X[5]))      # 0.5000000000000002 0.2500000000000001

range(9) 换成小数(比如 [3.5]),就能看到那个 0.2315。

python3 -c "import math;fs=256;x=[math.sin(2*math.pi*3*n/fs) for n in range(fs)];print(sum(x[n]*math.cos(-2*math.pi*3*n/fs) for n in range(fs))/fs)"

在线跑:python.org/shell。要看动画版的同一件事,3Blue1Brown 的《But what is the Fourier Transform?》把「缠绕」这个画面做成了视频,是这个直觉在中文互联网之外最出名的一次讲述。

▸ 在现实里
  • 吉他调音 App。它做的事和上面那十四行一模一样:录一小段,扫一遍转速,看质心在哪儿最长,再把那个频率换算成音名。唯一的额外工作是第 7 章的分辨率问题——要区分 440 Hz 和 442 Hz,它必须至少听你半秒。
  • 手表上的心率。光电传感器采到的是一条被运动噪声糟蹋得看不出形状的曲线。绕一圈,在 0.8–3 Hz(48–180 次/分)这段里找最长的质心,就是心率。
  • 电网频率监测。调度中心要知道电网现在是 49.98 Hz 还是 50.02 Hz——那 0.04 Hz 的偏差意味着全国发电和用电之间差了几百兆瓦。这需要极高的频率精度,也就是第 7 章会讲的:只能靠看得更久
  • 听歌识曲。Shazam 的第一步就是把音频切段做变换,取每段里质心最长的几个频率当「指纹」。它不存音频,只存这些峰的位置和时间差。
✗ 这个直觉是错的

「频谱图上那根线有多高,对应的正弦就有多大振幅。」

差一个因子,而且这个因子取决于你用的是哪个库、有没有除以 N、信号是实数还是复数。上面那段信号里 3 Hz 的振幅是 1.0,而质心长度是 0.5000;如果用 numpy.fft.fft 且不除以 N,那根线的高度是 128.0。三个数,同一个东西。

规矩是这样的:numpy.fft.fft 不除以 N(所以幅度正比于点数);除以 N 之后,一个实正弦的能量被平分到 +f 和 −f 两根线上,所以还要再乘 2 才是振幅。至于「−f 那根线在哪」——它在数组的后半段,X[N-k] 就是 X[k] 的共轭。

判据是:任何时候拿到一个频谱,先用一个你完全知道答案的信号标定一遍。喂一个振幅正好 1.0 的正弦进去,看看出来的那根线有多高,把比例记下来。这一步花三十秒,能省掉后面几个小时的困惑。

◇ 揭晓

正确答案是 C4.6 × 10⁻¹⁷,浮点意义上的严格零。

A 「3 和 5 都在旁边,总会蹭上一点」——这个直觉在连续的世界里是对的,在这里错,而且错的原因很有意思:它之所以严格为零,是因为记录长度正好是一秒,而 3 Hz、4 Hz、5 Hz 在一秒里都正好转了整数圈。整数圈是一切干净的前提。把记录长度改成 1.13 秒,A 立刻就对了——那正是第 8 章要收拾的烂摊子。 B 「肯定不是零,信号在那儿摆着」——信号在那儿,但它在 4 圈/秒这个方向上的投影是零。就像一根竖直的杆子有长度,但它在水平方向上的影子长度是严格的零,不是「很小」。下一章会说明,这个「投影为零」是一条可以证明的恒等式,不是巧合。 D 「取决于起始相位」——起始相位会改变质心跑向哪个方向,但改不了它跑多远。这正是幅度谱不随时移改变的原因,第 4 章会实测这一条:把信号整体平移 5 个采样,幅度谱的最大变化是 4.4 × 10⁻¹⁶,而相位每一根都变了。
⟳ 换个域看
你有 256 个数。你能看出它们上下起伏,但看不出里面藏着什么——两个正弦叠在一起,肉眼就已经认不出来了。 同样这份东西,是两根线:3 的位置上一根 0.5000,5 的位置上一根 0.2500,其余全是 10⁻¹⁷。

这一章真正的成果不是那个公式,是那个动作。往后每次看到一个吓人的积分号,都可以先问一句:这是在把什么东西缠到什么上面,然后取平均?——傅里叶级数、拉普拉斯变换、z 变换、特征函数、矩母函数,全都能用这一句话破开。它们的区别只在于缠的是什么样的圈(或者螺旋)。

这一章的一句话

把信号缠到圆上,质心跑出去多远,那个频率就有多强——傅里叶变换从头到尾只做这一件事,剩下的全部都是记号、快法和现实开出的账单。

下一章补上这台机器唯一的漏洞:凭什么「缠错速度」就一定严格抵消?如果不严格,那 3 Hz 的答案里就会混进 5 Hz 的一部分,整台机器立刻报废。这一条叫正交性。本机把 64 点里全部 2016 对不同频率两两算了一遍内积,最大的一个残留是 1.6 × 10⁻¹³——而这个数还不是数学上的误差,是浮点数自己的锅。