这周的组会轮到我讲 Fourier-DeepONet,本来打算交两页提纲糊弄过去,结果越读越觉得这套东西跟自己大半年前踩过的坑正好对得上,索性把笔记整理成一篇完整的记录。Fourier-DeepONet 属于算子学习(operator learning)这条线里的一个改进变体:在 DeepONet 的 trunk 分支前面插一层傅里叶特征映射,让网络在拟合高频、强振荡的解场时不至于塌成一团平滑的糊。它针对的是坐标型多层感知机固有的谱偏置(spectral bias)问题,典型落点是偏微分方程代理模型、参数反演这类任务。如果你正在用 DeepONet 做"输入函数到输出场"的映射,或者被 FNO 对规则网格的硬性依赖卡住,这篇笔记应该能帮你省掉几天试错。下面按我读论文、写代码、跑实验的顺序展开,中间穿插一些只有真正动手才会遇到的细节。
1. 先分清 Fourier-DeepONet 与 FNO 不是同一类改法
很多人第一次看到这个名字,第一反应是"又一个把傅里叶变换塞进网络的结构",然后下意识把它跟 FNO(Fourier Neural Operator)归成一类。我一开始也这么想,直到把两者的前向过程画在纸上,才意识到它们动刀的位置完全不同,这个区分如果不先厘清,后面调参会一直处在稀里糊涂的状态。
1.1 一个在频域截断,一个在输入端扩维
FNO 的思路是在网络层内部做频域卷积:对特征图做 FFT,只保留低频的若干模态,乘上一组可学习的复数权重,再逆变换回物理域。它的"傅里叶"体现在算子参数化方式上,本质是用频域截断来近似积分算子,物理意义清晰,但代价是必须工作在规则网格上,因为 FFT 要求均匀采样。
Fourier-DeepONet 的做法温和得多:主干还是 DeepONet 那一套 branch/trunk 双塔内积结构,只是把 trunk 网络接收的坐标 y 先过一层固定的傅里叶特征映射,把低维坐标抬到一个高维的正弦/余弦空间,再喂给后面的 MLP。它没有改变算子的参数化方式,改变的是输入表示。这个差别听起来抽象,落到工程上就是:FNO 的输出分辨率受训练网格约束,超分辨率要靠零填充这类技巧;Fourier-DeepONet 的 trunk 天然是坐标的连续函数,你想在哪个点求值就在哪个点求值,采样是否均匀无所谓。
我拿一个简单的对比表把两者的边界摆清楚,这个表后来直接放进了组会 PPT:
| 维度 | FNO | Fourier-DeepONet |
|---|---|---|
| 傅里叶所在位置 | 网络层内的频域卷积 | trunk 输入侧的坐标编码 |
| 网格要求 | 规则均匀网格 | 任意散点 |
| 分辨率外推 | 依赖零填充等技巧 | 天然连续,换点即可 |
| 可学习频率 | 是(复数权重) | 否(频率矩阵固定) |
| 主要开销 | FFT 与模态数相关 | trunk 首层维度随频率数增长 |
看这张表就能明白,选型的判断依据不是"谁更先进",而是"你的数据是不是落在规则网格上"。观测点稀疏、几何边界不规则、需要逐点查询的场景,FNO 会很别扭;反过来,如果输入输出本身就是高分辨率图像式的均匀场,FNO 的效率优势明显。
1.2 谱偏置才是这层特征真正要治的病
要理解为什么非得在 trunk 上加傅里叶特征,绕不开谱偏置这个概念。标准 MLP 在用梯度下降训练时,学习到的函数会优先拟合低频分量,高频分量收敛得极慢,甚至在某些设置下根本学不动。直观解释是网络的神经正切核(NTK)特征值随频率快速衰减,高频方向上的更新信号被压制了。有一类专门分析坐标网络的工作给出了更细的结论:把低维坐标直接送进 MLP,网络在拟合高频细节时需要的参数量和训练时间会呈指数级恶化。
对 DeepONet 来说这个问题尤其致命,因为 trunk 输出的 t_k(y) 承担的是"基函数"的角色。如果解场本身含有明显的振荡结构,比如波传播问题里的波前、地层反演里的速度界面,trunk 学不出这些高频基,branch 那边系数算得再准也没用,内积结果必然是平滑过头的。我早期跑一个简单的一维波动方程代理任务时就遇到过这个现象:训练损失降到某个平台后死活不动,可视化出来解的包络对得上,但相位和峰值全糊在一起。
傅里叶特征映射的作用机制可以这么理解:它把原始坐标先投影到一组固定的高频基上,γ(y) = [cos(2πBy), sin(2πBy)],其中 B 是从高斯分布采样的频率矩阵。这样后面的 MLP 不再需要"从零开始生成高频",只需要对这些已经存在的高频基做线性组合。从核方法的角度看,这等价于把核函数换成了一个带周期性成分的核,高频方向上的特征值不再被压制。工程上的体感就是:原本卡住的损失平台被突破了,解的高频细节开始出现。
1.3 一个容易忽略的前提:trunk 的输入维度通常很低
这层特征之所以在 DeepONet 上效果明显,还有一个结构性原因:trunk 的输入是查询坐标,维度一般就是 2 或 3(空间维加时间维),极少数情况到 4。低维输入恰恰是谱偏置最严重的区间,因为网络没有足够的输入维度去"分散"高频信息。反过来,如果 trunk 的输入维度已经很高(比如把一堆物理量都拼进坐标),傅里叶特征带来的边际收益会明显下降,这时候更该考虑的是特征工程而不是加频率。
我在一个三维问题上试过把 trunk 输入从 3 维扩到 9 维(拼了三个标量场),加傅里叶特征后提升很小,最后反而是简化输入、把标量场交给 branch 处理效果更好。这个经验让我形成了一个判断习惯:先看 trunk 输入维度,低于 4 维再考虑上傅里叶特征,高于 6 维先想想输入设计是不是有问题。
2. 把 DeepONet 拆到零件级:Branch 管系数,Trunk 管基
傅里叶特征只是加在入口的一层,如果对 DeepONet 本身的内积结构没有清晰的物理直觉,调参基本靠蒙。我见过不少同学把两个子网当成黑盒,损失不降就狂调学习率,其实问题往往出在 p 的取值或者两个子网的信息分工上。
2.1 内积结构为什么天然适合表达算子
DeepONet 的核心表达式是 G(u)(y) = Σ_{k=1}^{p} b_k(u) · t_k(y) + b_0。这里 u 是输入函数(可以是离散采样后的一串观测值),y 是查询坐标,b_k 是 branch 网络的第 k 个输出,t_k 是 trunk 网络的第 k 个输出,p 是潜在维度,b_0 是一个标量偏置。
这个形式跟分离变量法几乎是同构的:把解写成一组基函数的线性组合,系数由输入决定。理论上,只要 p 足够大、t_k 构成一组完备基,这个结构可以逼近任意连续算子。论文里通常会给一个基于紧集上算子逼近定理的论证,核心结论是逼近误差随 p 增大而下降,与输入函数的采样点数关系不大——这一点很关键,它意味着 DeepONet 对输入的分辨率不敏感,粗网格上训练的模型可以直接用在细网格观测上。
工程上我把它理解成一个"可学习的广义模态分解":trunk 学基,branch 学系数。这个类比虽然不严格,但指导调参非常有效。
2.2 两个子网的信息分工决定了它们该吃什么
分工原则其实很朴素:branch 吃输入函数,trunk 吃坐标。但落到具体任务上,边界常常模糊。以反演类问题为例,输入是观测数据(可能是一维的时间序列,也可能是二维的接收阵列),输出是空间分布场。branch 的输入是观测,输出是 p 维系数;trunk 的输入是空间坐标,输出是 p 维基函数值。这个划分是清晰的。
麻烦出在有些任务里,输入函数本身也是空间分布的(比如给定初始条件求演化结果),这时候 branch 到底该接收全场的离散采样,还是接收某种压缩表示?我的做法是:如果输入场和输出场在同一网格上,branch 直接吃展平的采样值,简单直接;如果输入分辨率高、维度大,先做一个固定的降维(比如截断的 POD 或者几层卷积)再进 branch,否则 branch 的第一层参数量会爆炸。
trunk 这边相对固定,输入就是坐标,必要时做归一化。加了傅里叶特征之后,trunk 的输入维度变成 2m(m 是频率数),第一层参数从 hidden×d 变成 hidden×2m。当 m 取 64 时,输入维度从 3 变成 128,参数量涨了四十多倍。这是实打实的显存开销,后面第 5 节会详细算。
2.3 p 的取值:一个被低估的超参数
p 是潜在维度,我习惯叫它"秩"。它的作用类似于矩阵分解里的秩:太小,表达能力不够,损失降不下去;太大,参数变多,过拟合风险上来,而且 trunk 输出的 p 个基函数之间容易出现共线性,训练会变得不稳定。
常见的取值区间是 64 到 256。我的经验是跟问题的"有效自由度"挂钩:如果解场的主要结构用前 20 个 POD 模态就能解释 99% 的能量,p 取 64 到 128 足够;如果解场本身很复杂,p 要往上走。有个简单的诊断方法:训完之后算一下 trunk 输出矩阵的奇异值谱,如果尾部奇异值很小,说明 p 有冗余,可以往下压;如果谱衰减很慢,说明 p 可能不够。
还有一个技巧是用 POD 预训练 trunk。具体做法是先对训练集的输出场做 POD 分解,取前 p 个模态,用一个小网络去拟合"坐标到模态值"的映射,把它作为 trunk 的初始化。这样做能显著加快收敛,尤其在数据量不大的时候。我试过一次,收敛所需的 epoch 数大概少了三成。缺点是如果 POD 基和真实最优基偏差较大,初始化反而会拖后腿,所以这个方法更适合解场统计特性稳定的场景。
3. 傅里叶特征层的实现细节与几个维度陷阱
理论讲完,真正花时间的还是把这层特征实现对。我前后改了三版,前两版都有隐蔽的 bug,训练能跑但效果差,排查起来非常折磨。
3.1 频率矩阵的采样尺度决定了带宽
傅里叶特征的标准形式是 γ(x) = [cos(2πBx), sin(2πBx)],B 是一个 m×d 的矩阵,每个元素独立地从 N(0, σ²) 采样。这里 σ 是唯一需要调的超参数,它的物理含义是频率带宽:σ 越大,采样到的频率越高,网络能表达的最高频成分越丰富。
σ 的取值非常依赖坐标的归一化方式,这一点是新手最容易翻车的地方。如果坐标没有归一化,比如直接用量纲上的米或者千米,那么 2πBx 里 x 的量级直接决定了有效频率,σ 就失去了可比性。我的做法是永远先把坐标线性映射到 [-1, 1],然后再谈 σ。在归一化之后,σ 取 1 到 10 是比较安全的区间,具体值跟解场的最短特征尺度有关:特征尺度越短,σ 要越大。
有个粗略的估算方法:假设解场里最小的结构尺度是 L(归一化后,整个域长度为 2),那么需要覆盖的最高频率大约是 1/L 量级,σ 取这个值的一半到一倍差不多。比如界面厚度占全域的 5%,L≈0.1,σ 取 5 到 10。这个估算当然不精确,但能给你一个起点,比盲试强。
3.2 一个可以直接用的特征映射模块
下面这段是我目前在用的实现,跑了几个任务都比较稳:
import math import torch import torch.nn as nn class FourierFeature(nn.Module): """固定频率的傅里叶特征映射,坐标需先归一化到 [-1, 1]。""" def __init__(self, in_dim: int, num_freqs: int, sigma: float = 4.0, seed: int = 0): super().__init__() g = torch.Generator().manual_seed(seed) weight = torch.randn(in_dim, num_freqs, generator=g) * sigma # 固定不训练,注册为 buffer 以便跟随模型搬到 GPU self.register_buffer("weight", weight) self.out_dim = 2 * num_freqs def forward(self, x: torch.Tensor) -> torch.Tensor: # x: (N, in_dim) proj = 2.0 * math.pi * x @ self.weight # (N, num_freqs) return torch.cat([torch.cos(proj), torch.sin(proj)], dim=-1)几个实现上的决定值得说明。频率矩阵我选择固定而不是可学习。可学习的版本在理论上更灵活,但实测下来容易在训练早期被梯度推到一个退化的频率分布上,反而丢掉了多尺度的先验;固定版本相当于给了一个稳定的归纳偏置,配合足够多的频率数,效果更可控。另外register_buffer这一步不能省,否则模型搬到 GPU 之后这个矩阵还在 CPU 上,会报设备不一致的错——这个错我调了半小时才定位到。
还有一个变体是多尺度拼接:用几组不同 σ 的频率矩阵,把结果拼起来。比如 σ 分别取 1、4、16,各取 16 个频率,总共 96 维。这种做法在解场同时包含大尺度背景和小尺度细节时效果更好,代价是输入维度进一步膨胀。
3.3 DeepONet 主体的拼装与前向的广播陷阱
特征的输出接到 trunk 上,整个模型长这样:
class MLP(nn.Module): def __init__(self, in_dim, out_dim, hidden=128, depth=4, act=nn.Tanh): super().__init__() layers = [nn.Linear(in_dim, hidden), act()] for _ in range(depth - 1): layers += [nn.Linear(hidden, hidden), act()] layers.append(nn.Linear(hidden, out_dim)) self.net = nn.Sequential(*layers) def forward(self, x): return self.net(x) class FourierDeepONet(nn.Module): def __init__(self, branch_in, coord_dim, p=128, num_freqs=64, sigma=4.0, hidden=128, depth=4): super().__init__() self.branch = MLP(branch_in, p, hidden, depth) self.feat = FourierFeature(coord_dim, num_freqs, sigma) self.trunk = MLP(self.feat.out_dim, p, hidden, depth) self.bias = nn.Parameter(torch.zeros(1)) def forward(self, u, y, mode="pair"): b = self.branch(u) # (N, p) t = self.trunk(self.feat(y)) # (M, p) if mode == "pair": # 每个样本配自己的坐标,要求 N == M return (b * t).sum(dim=-1, keepdim=True) + self.bias # 全组合:N 个输入函数分别在 M 个坐标上求值 return b @ t.transpose(0, 1) + self.bias前向这里有个隐蔽的坑:pair模式和全组合模式的语义完全不同,但形状检查不一定能发现。我在一个任务里误用了全组合模式,模型照样能训,损失也在降,只是收敛得慢。后来对比才发现,本来应该逐点对应的坐标被做成了笛卡尔积,有效样本量被稀释了。建议在 forward 里加一个断言,检查u.shape[0] == y.shape[0],能省很多事。
另外激活函数的选择上,trunk 用 Tanh 比 ReLU 稳,原因是傅里叶特征输出的是有正有负的连续值,ReLU 会把一半的相位信息直接砍掉。branch 那边倒是无所谓,ReLU 也能用。
4. 训练过程中的观察:损失曲线到底在说什么
模型搭好只是开始,真正花时间的是读懂训练过程。我把几个任务里反复出现的现象整理了一下,这部分的经验属性最强,因为大部分论文不会写。
4.1 高频残差的下降有滞后性
加了傅里叶特征之后,损失曲线会呈现一个很特征性的形状:前期下降很快,中间有一个明显的、持续几十个 epoch 的平缓段,然后再次下落。我一开始以为中间那段是学习率不合适,调了半天没效果,后来把预测场做 FFT 分解才明白:平缓段对应的是低频部分已经拟合好、高频部分刚开始学。
这个现象的启示是,不要因为看到平台期就急着改超参数。我的做法是监控两个量:一个是总损失,另一个是高频能量的相对误差(把预测和真值都做 FFT,只看高于某个阈值的频段的能量差)。后者能提前告诉你高频到底有没有在学,避免误判。
还有一个相关经验:warmup 对这类模型帮助明显。前 500 步用线性的小学习率,让 branch 和 trunk 先进入一个合理的区域,再放开。不做 warmup 的话,前期梯度容易把固定频率特征的权重推到饱和区,cos 和 sin 的输出都卡在 ±1 附近,梯度接近零,后面就很难救回来。
4.2 两路子网的更新节奏不一致
DeepONet 有个结构性的训练难点:branch 和 trunk 是耦合的,但它们的梯度尺度经常差得很远。branch 的输入是观测数据,维度高、数值范围大;trunk 的输入是坐标,维度低、范围在 [-1, 1]。结果就是 branch 那边的梯度往往比 trunk 大一到两个数量级,训练早期 trunk 几乎不动。
处理办法有几种,我试过有效的有两个。一是给两个子网分别设学习率,trunk 的学习率取 branch 的 3 到 5 倍。二是加梯度裁剪,按子网分别裁,而不是整体裁。前者实现简单,后者更稳。我在一个输入维度特别高的任务上两种都用上了,收敛曲线明显干净很多。
还有一个更彻底的做法是分阶段训练:先冻结 trunk 随机初始化下的输出,只训 branch 若干轮,再联合训练。这个思路来自 POD 预训练的变体,实践下来前期收敛快,但要注意别训太久,否则 branch 会过拟合到那个随机基上。
4.3 验证集要按分布切,不是按样本切
这一点跟模型本身无关,但影响最大。算子学习任务的泛化性体现在"对没见过的输入函数也能给出正确输出",而随机切分训练/验证集往往会掩盖问题——因为同一个生成过程产出的样本,统计特性高度相似,随机切分后验证集跟训练集几乎同分布,指标好看但没意义。
我现在的做法是按生成参数切分。比如输入函数由若干物理参数控制,我就留出某个参数区间作为验证,或者留出某一类边界条件。这样训出来的模型才敢往实际场景上放。Fourier-DeepONet 这类模型在分布外样本上的表现,跟频率带宽的设置强相关:σ 设得太小,模型在训练分布内很准,一旦遇到更短的尺度就彻底失效;σ 稍大一些,分布内的精度略降,但外推能力好得多。这个权衡需要根据实际用途来定。
5. 与 FNO、PINN 的横向比较与选型建议
组会上被问得最多的就是"为什么不直接用 FNO"或者"为什么不直接上 PINN"。这两个问题其实有比较清晰的答案,但前提是你先明确自己的约束条件。
5.1 三类方法的适用边界
FNO 的优势在于规则网格上的效率。它的频域卷积是全局的,感受野天然覆盖全域,参数效率高,训练稳定。如果你的数据是均匀采样的二维或三维场,输入输出同分辨率,FNO 通常是最省事的选择。它的短板也很明确:非均匀采样要重采样,几何边界复杂时处理麻烦,超分辨率需要额外技巧。
PINN 的定位完全不同,它不需要标注数据,靠方程残差做监督。优点是数据成本低,缺点是训练慢、超参数敏感、对高频解容易欠拟合。它适合的场景是数据获取代价极高、但方程已知且相对简单的问题。如果你的任务已经有大量仿真数据,用 PINN 属于自找麻烦。
Fourier-DeepONet 的位置介于两者之间:需要数据(跟 FNO 一样是监督学习),但支持任意采样点(比 FNO 灵活),同时又有频率先验来对抗谱偏置(比纯 DeepONet 强)。我在选型时用的判断顺序是:先看数据是否规则网格,是则优先 FNO;否则看是否有监督数据,有则 DeepONet 系;再看解场是否有明显高频结构,有则加傅里叶特征。
5.2 算一笔显存和时间的账
很多人低估了傅里叶特征的显存成本。假设坐标维度 d=3,频率数 m=64,trunk 隐藏层宽度 128:
| 配置 | trunk 首层参数量 | 相对开销 |
|---|---|---|
| 无傅里叶特征 | 3×128 = 384 | 1× |
| m=32 | 64×128 = 8192 | 21× |
| m=64 | 128×128 = 16384 | 43× |
| m=128 | 256×128 = 32768 | 85× |
只看首层的话,参数量涨了几十倍,但要注意这只是 trunk 的第一层,整个模型的参数量里 branch 往往占大头,所以总参数的涨幅没有这么夸张。真正吃显存的是中间激活:如果 trunk 的输入点数 M 很大(比如一次要在十万个坐标上求值),2m 维的中间特征会占不少内存。
我的经验值是:m 取 32 到 64 在大多数任务上够用,往上加收益递减明显。真要提升高频表达能力,多尺度拼接比单纯增加 m 更划算。
时间开销方面,傅里叶特征本身的计算量可以忽略(一次矩阵乘加 cos/sin),瓶颈还是在 MLP 上。
5.3 什么情况下不值得加这层特征
不是所有任务都需要傅里叶特征,强行加反而有害。几种明确不该加的情况:解场本身很平滑,主要能量集中在低频,这时候加高频特征只会引入噪声;训练数据量很小,额外的输入维度会让过拟合来得更快;坐标维度已经很高,前面分析过,边际收益低。
还有一个容易被忽略的情况:如果 trunk 后面接的是很深的网络(超过 8 层),傅里叶特征带来的高频信息在传播过程中会被逐渐平滑掉,效果会打折扣。这种时候要么减深度,要么在中间层再做一次特征注入,后者我没试过,不确定稳定性。
6. 复现实验里最容易翻车的几个地方
最后这部分是我踩过的坑,按痛苦程度排序。这些坑大多不会报错,只会让你的模型"看起来能跑但效果不对",排查成本极高。
6.1 坐标归一化的作用域搞错
最常见的问题:训练时用训练集的坐标范围做归一化,推理时换了新的坐标范围,忘了同步。结果就是 2πBx 里的 x 尺度变了,有效频率整体偏移,输出直接崩掉。
正确做法是把归一化的参数(最小值、最大值或者均值方差)作为模型的一部分保存下来,推理时严格复用。我现在的习惯是写一个Normalizer小模块,注册成 buffer,跟模型一起存盘。这样不管部署到哪,尺度都是对的。
还有一个细节:如果坐标是物理量(比如深度、时间),不同样本的物理范围可能不一样,这时候要决定是做全局归一化还是逐样本归一化。全局归一化保持了物理尺度的一致性,逐样本归一化则让模型对尺度不敏感。反演类任务我倾向全局,因为绝对深度是有物理意义的;纯数学的算子学习任务逐样本更合适。
6.2 频率矩阵的随机种子没固定
这个坑很隐蔽。频率矩阵是从高斯分布采样的,种子不同,采样到的频率就不同,模型表现会有可观测的差异。如果每次实验都不固定种子,你会误以为某个超参数的改动带来了提升,其实只是换了组随机频率。
我的做法是把种子作为配置项显式管理,所有对比实验用同一组种子,并且至少跑三个种子看方差。有一次我在一个小数据集上做的"改进",三个种子跑下来均值确实涨了,但方差大到覆盖了提升幅度,最后判定为噪声。
6.3 训练和推理的 batch 组织方式不一致
DeepONet 的前向有两种模式(逐点配对和全组合),训练时为了省显存通常用配对模式,推理时为了出整场结果用全组合模式。如果这两种模式下的数值路径有细微差异(比如某个维度上的归一化只在训练时做),推理结果就会偏。
我的验证方法是在训练集上随机抽一批样本,分别用两种模式算一遍,比较输出的最大绝对差。正常情况下应该是零(除了浮点误差),如果差得明显,说明有分支没对齐。这个检查我写成了单元测试,每次改完模型跑一遍,救过好几次。
6.4 损失函数的尺度问题
如果输出场的量级差异很大(比如某些区域数值是 1e-3,另一些是 1e3),直接用 MSE 会让大值区域主导梯度,小值区域基本学不到。这在反演任务里很常见,因为界面上下的物性差异可能好几个量级。
解决办法是用相对误差或者对数变换。相对 L2 损失(||pred - true||₂ / ||true||₂)在每个样本内部做归一化,能缓解量级问题,但要注意真值接近零时的数值稳定性,通常加一个小的 epsilon。对数变换适合严格为正的物理量,但要小心反变换时的偏差。
我现在的默认配置是相对 L2 加一个小的绝对项,权重 0.9 比 0.1,兼顾两者。这个配比在不同任务上做过几次简单搜索,比较稳。
6.5 一个关于复现的小体会
复现论文结果这件事,我的体会是别指望一次跑通。论文里写的超参数往往是调好的最终值,中间过程没写;数据预处理的具体细节通常一笔带过,但恰恰是这些细节决定了成败。我现在的流程是先把论文里的每个数字都列出来,逐个对照实现,遇到论文没写的就用最常见的做法补上并记录下来。跑通之后再做消融,一次只改一个变量。
Fourier-DeepONet 这个工作对我来说最大的价值不是那些性能数字,而是它把"频率先验该放在哪"这个问题讲清楚了。放在网络层里是 FNO 的答案,放在输入表示里是它的答案,两条路各有适用场景。想清楚这个,比记住任何一个 benchmark 的排名都有用。后续我打算试试把多尺度频率拼接和课程学习结合起来,先在大尺度上训,再逐步加入高频,看看能不能进一步缓解那个平台期的问题。