PINN 学习者问得最多的一个问题,往往不是某个偏微分方程怎么离散,不是损失函数里边界项权重该设多少,而是更底层的一句话:我到底该用 PyTorch 还是 TensorFlow?这个问题看起来像二选一,实际上它是你接下来几十次调试体验的分叉口。选错了,你会在安装环节、自动微分写法、GPU 版本匹配上反复折腾;选对了,后面每一个 PINN 实验都能快速跑通,把精力放在物理问题和网络改进上。
物理信息神经网络(Physics-Informed Neural Networks, PINN)的核心并不是“神经网络”本身,而是自动微分。PyTorch 和 TensorFlow 都能做自动微分,但二者的 API 设计、调试习惯、运行机制差别相当大。作为《PINN入门30讲》系列课程的第 11 讲,本文不做“谁比谁强”的片面结论,而是把两个框架的 PINN 开发路径都完整走一遍:从概念理解、环境准备、最小案例代码,到运行结果的验证、踩坑排查和工程建议。读完这篇文章,你既能获得清晰的框架选型判断,也能直接参考两套完整可运行的最小 PINN 实现。
1. 为什么 PINN 的框架选择比其他深度学习任务更难
如果只是做图像分类或者自然语言处理,框架选择其实很简单:团队用什么你就用什么,哪个教程多就用哪个。但 PINN 不一样,它有三个特点,导致框架选择不能只看流行度。
1.1 损失函数里不只有网络输出,还有高阶导数
普通深度学习的损失函数通常是网络输出和标签之间的差距,比如交叉熵、均方误差。PINN 的损失函数里,除了网络输出本身,还有输出对输入的一阶导数、二阶导数,甚至更高阶导数,以及这些导数参与的物理残差。框架的自动微分能力、高阶求导的方便程度,直接影响代码的简洁度和训练稳定性。
1.2 网络训练未必使用标准的数据集
训练 PINN 时,内部配点往往是随机采样或者网格点,边界点是约束条件,没有“训练集、验证集、测试集”这种天然的划分。这就导致你很难直接套用高层 API,比如model.fit(),而更多需要自定义训练循环、自定义损失函数、自己控制梯度更新。越接近底层,框架的 API 差异对你的影响就越大。
1.3 版本和硬件兼容问题更敏感
PINN 实验中常见的“跑不出来”,很多不是算法问题,而是框架和 CUDA 版本不匹配、自动微分梯度张量维度不对、create_graph或者GradientTape用错。这些坑和具体框架绑定得很深,换一个框架,排查路径几乎完全不同。
所以,本文给出的第一个判断是:选 PINN 框架,本质上是在选一套自动微分的操作习惯和调试体验,不是选“谁更流行”。
2. PINN 与自动微分:框架选择的底层逻辑
要理解框架选择为什么重要,先要理清 PINN 的数学过程和自动微分在其中的角色。
2.1 PINN 的数学过程回顾
考虑一个最简单的常微分方程示例:
u'(x) = f(x), x ∈ [a, b]或者带有二阶导数的偏微分方程:
u''(x) + g(x) = 0, x ∈ [0, 1]你的目标是找到一个神经网络近似解u_θ(x),使得方程左边的残差尽量趋近于 0,同时满足边界条件。训练过程是:
- 在求解域内采样若干内部点
x_i。 - 在边界上采样边界点
x_b。 - 将内部点输入网络,得到
u_θ(x_i),然后对输入求导,构造物理残差。 - 将边界点输入网络,构造边界损失。
- 把残差损失和边界损失加权合并成总损失。
- 反向传播更新网络参数 θ。
上面第 3 步的“对输入求导”就是自动微分。网络输出的u_θ是输入x的函数,框架需要根据计算图自动求出∂u_θ/∂x,如果需要更高阶,就继续对一阶导数求导。
2.2 PyTorch 的自动微分方式:torch.autograd
PyTorch 使用动态计算图。每次前向传播都会动态构建计算图,你可以用torch.autograd.grad或者loss.backward()获取梯度。对于 PINN 常见的二阶导数,典型写法是:
u = model(x) u_x = torch.autograd.grad(u, x, grad_outputs=torch.ones_like(u), create_graph=True)[0] u_xx = torch.autograd.grad(u_x, x, grad_outputs=torch.ones_like(u_x), create_graph=True)[0]这里create_graph=True表示在求一阶导数的同时继续构建计算图,以便对一阶导数再次求导。这个设计对 PINN 非常友好,缺点是如果你忘了create_graph=True,二阶导数一定报错。
2.3 TensorFlow 的自动微分方式:tf.GradientTape
TensorFlow 使用tf.GradientTape记录前向传播过程中的所有操作。PINN 的高阶导数需要嵌套GradientTape,外层 tape 记录内层 tape 的求导结果,再对外层求导。典型写法:
with tf.GradientTape() as tape2: tape2.watch(x) with tf.GradientTape() as tape1: tape1.watch(x) u = model(x) u_x = tape1.gradient(u, x) u_xx = tape2.gradient(u_x, x)TensorFlow 的自动微分是显式的“记录-回放”模型,你看得见梯度在哪一步被计算,但也需要更小心地管理 tape 的作用域。
2.4 两种设计对新手的影响
从机制上看,PyTorch 的torch.autograd.grad更接近数学推导的直觉:你写公式,它算导数。TensorFlow 的GradientTape更强调操作流程:先记录,再回放。对于只写过普通深度学习代码、没有接触过科学计算的人来说,PyTorch 的梯度写法通常更容易上手。但这不代表 TensorFlow 不能做 PINN,恰恰相反,TensorFlow 的GradientTape在处理复杂控制流时有时更灵活。
3. PyTorch 与 TensorFlow 的生态与体验对比
在开始写代码之前,先用一张表把两个框架在 PINN 开发中涉及的核心差异列出来。
| 对比维度 | PyTorch | TensorFlow |
|---|---|---|
| 自动微分核心 | torch.autograd.grad | tf.GradientTape |
| 计算图 | 动态图,前向传播即建图 | 动态图(eager execution)为主,可通过@tf.function转静态图 |
| 高阶导数 | 用create_graph=True连续求导 | 嵌套GradientTape实现 |
| 自定义训练循环 | 非常自然,Python 代码直接写 | 可以,但更建议配合tf.function提升性能 |
| 高层 API | PyTorch Lightning 等第三方库 | Keras 官方集成 |
| 调试体验 | 报错信息较友好,python 式调试 | 部分错误信息偏底层,需要熟悉 TensorFlow 运行时 |
| 社区教程质量 | PINN 论文和开源代码大部分基于 PyTorch | 老牌 PINN 库仍保留 TF 版本 |
| GPU 环境配置 | 和 CUDA 绑定较紧 | 2.x 安装通常绑定了 CUDA 依赖 |
从趋势看,近年来 PINN 相关的开源项目、论文代码越来越多地选择 PyTorch,这一点在 arXiv 的 PINN 论文截图、GitHub 仓库里都体现得很明显。但这不意味着 TensorFlow 就应该被放弃。如果你的团队已经有成熟的 TensorFlow 工程链路,或者你要对接生产环境的 TF Serving,继续用 TensorFlow 完全合理。
真正的关键判断是:如果你是初学者,只做 PINN 研究原型,没有历史包袱,优先选 PyTorch;如果你在公司里已经用 TensorFlow 构建了完整训练部署链路,就继续用 TensorFlow,没必要为了“流行”而切换。
4. 环境准备与安装配置
4.1 操作系统与 Python 版本
本文示例在 Windows 10/11 和 Ubuntu 20.04/22.04 上均可运行。Python 建议使用 3.9 到 3.11。PyTorch 和 TensorFlow 对 Python 版本的支持窗口不同,如果版本太新或太旧,安装时可能找不到对应 wheel。推荐使用 Anaconda 创建独立的虚拟环境,不要把框架装在系统 Python 里。
4.2 创建虚拟环境
conda create -n pinn python=3.10 -y conda activate pinn创建独立的虚拟环境是 PINN 开发的第一步,也是最容易被初学者跳过的一步。多个深度学习项目共用同一个环境,很容易出现“今天装了这个库,明天另一个项目跑不起来”的依赖冲突。
4.3 安装 PyTorch
PyTorch 的安装命令会根据 CUDA 版本变化。最稳妥的做法是打开 PyTorch 官网的 get-started 页面,选择你的系统、包管理器和 CUDA 版本,复制生成后的命令。这里给出 CPU 版和 CUDA 12.x 版的通用示例:
# CPU 版 pip install torch torchvision torchaudio # CUDA 12.x 版(具体版本号以官网生成命令为准) pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121如果你用的是 NVIDIA GPU,安装前先确认驱动支持的 CUDA 版本:
nvidia-smi4.4 安装 TensorFlow
TensorFlow 2.x 的安装相对简单,因为 pip 包已经捆绑了对应的 CUDA 运行时。CPU 版直接装即可:
pip install tensorflow如果需要 GPU 版:
pip install tensorflow[and-cuda]TensorFlow 2.18 是较新的大版本,它延续了 Keras 3.x 作为默认高层 API 的方式,底层支持 JAX、TensorFlow、PyTorch 三种后端。这也意味着,你真的想用 Keras 3 的框架无关 API 来写 PINN,也是可行的。但本文为了对比更清晰,TensorFlow 示例直接使用tf.GradientTape原生的自定义训练循环。
4.5 验证安装
# 检查 torch import torch print(torch.__version__) print(torch.cuda.is_available()) # 检查 tensorflow import tensorflow as tf print(tf.__version__) print(tf.config.list_physical_devices('GPU'))这一步出现报错,先不要急着继续写 PINN 代码。先把环境问题解决,否则后续所有“模型不收敛”的排查都会混入环境变量困扰。
5. 基于 PyTorch 的 PINN 最小实现:一维 Poisson 方程
为了让你能直接跑通并验证,我们使用一个有一阶解析解的最小方程——一维 Poisson 方程:
u''(x) = -π² · sin(πx), x ∈ [0, 1] u(0) = 0, u(1) = 0它的精确解是:
u(x) = sin(πx)这个方程足够简单,能快速验证 PINN 是否学会了物理规律,又包含了二阶导数,能体现框架自动微分能力。
5.1 完整代码
# 文件路径:pinn_poisson_torch.py import torch import torch.nn as nn import numpy as np torch.manual_seed(42) class PINN(nn.Module): def __init__(self, layers): super(PINN, self).__init__() self.activation = nn.Tanh() self.linears = nn.ModuleList() for i in range(len(layers) - 1): layer = nn.Linear(layers[i], layers[i + 1]) nn.init.xavier_uniform_(layer.weight) nn.init.zeros_(layer.bias) self.linears.append(layer) def forward(self, x): for i in range(len(self.linears) - 1): x = self.activation(self.linears[i](x)) x = self.linears[-1](x) return x model = PINN([1, 20, 20, 20, 1]) def pinn_loss(model, x_interior, x_boundary): # 内部物理残差损失 x_interior.requires_grad_(True) u = model(x_interior) u_x = torch.autograd.grad( u, x_interior, grad_outputs=torch.ones_like(u), create_graph=True )[0] u_xx = torch.autograd.grad( u_x, x_interior, grad_outputs=torch.ones_like(u_x), create_graph=True )[0] residual = u_xx + (np.pi ** 2) * torch.sin(np.pi * x_interior) loss_res = torch.mean(residual ** 2) # 边界条件损失 u(0)=0, u(1)=0 u_bc = model(x_boundary) loss_bc = torch.mean(u_bc ** 2) return loss_res + loss_bc, loss_res, loss_bc x_interior = torch.linspace(0, 1, 200).reshape(-1, 1) x_boundary = torch.tensor([[0.0], [1.0]]) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) for epoch in range(3000): optimizer.zero_grad() loss, loss_res, loss_bc = pinn_loss(model, x_interior, x_boundary) loss.backward() optimizer.step() if epoch % 500 == 0: print(f"Epoch {epoch:4d}, Total Loss: {loss.item():.2e}, " f"Res Loss: {loss_res.item():.2e}, BC Loss: {loss_bc.item():.2e}") # 验证 with torch.no_grad(): x_test = torch.linspace(0, 1, 128).reshape(-1, 1) u_pred = model(x_test) u_true = torch.sin(np.pi * x_test) mse = torch.mean((u_pred - u_true) ** 2) print(f"MSE vs exact solution: {mse.item():.2e}")5.2 代码关键点说明
第一,网络使用了 4 层全连接,每层 20 个神经元,激活函数为Tanh。tanh 是 PINN 中最常用的激活函数之一,因为它是平滑的,且有界输出,对高阶导数计算更友好。ReLU 虽然常用于普通深度学习,但在需要二阶导数的 PINN 中容易导致梯度为零或导数不连续。
第二,torch.autograd.grad是 PINN 在 PyTorch 中实现的核心。第一次求导时设置create_graph=True,这样才能在一阶导数的基础上继续构建计算图并求出二阶导数。这是初学者最常忘记的参数。
第三,损失由两部分组成:内部配点上的物理残差损失和边界点的边界损失。这里没有设置权重系数,直接相加,是因为该问题足够简单。实际问题中,两个损失的量级可能相差很大,需要引入权重或者自适应调节策略。
5.3 运行方式和预期结果
在命令行执行:
python pinn_poisson_torch.py正常的输出会看到总损失和残差损失在下降,末尾的 MSE 通常会小于1e-3。如果训练充分,MSE 可能达到1e-4甚至更低。如果 MSE 一直降不下去,优先检查网络结构是否过于简单、学习率是否过大、内部配点数量是否不足。
6. 基于 TensorFlow 的 PINN 最小实现:同一方程的另一种写法
下面用 TensorFlow 2.x 实现同一个一维 Poisson 方程。这样可以直观对比两个框架在自动微分上的写法差异。
6.1 完整代码
# 文件路径:pinn_poisson_tf.py import tensorflow as tf import numpy as np tf.random.set_seed(42) class PINN(tf.keras.Model): def __init__(self): super(PINN, self).__init__() self.hidden1 = tf.keras.layers.Dense(20, activation='tanh', kernel_initializer='glorot_uniform') self.hidden2 = tf.keras.layers.Dense(20, activation='tanh', kernel_initializer='glorot_uniform') self.hidden3 = tf.keras.layers.Dense(20, activation='tanh', kernel_initializer='glorot_uniform') self.out = tf.keras.layers.Dense(1, kernel_initializer='glorot_uniform') def call(self, x): x = self.hidden1(x) x = self.hidden2(x) x = self.hidden3(x) return self.out(x) def pinn_loss(model, x_interior, x_boundary): with tf.GradientTape() as tape2: tape2.watch(x_interior) with tf.GradientTape() as tape1: tape1.watch(x_interior) u = model(x_interior) u_x = tape1.gradient(u, x_interior) u_xx = tape2.gradient(u_x, x_interior) residual = u_xx + (np.pi ** 2) * tf.sin(np.pi * x_interior) loss_res = tf.reduce_mean(tf.square(residual)) u_bc = model(x_boundary) loss_bc = tf.reduce_mean(tf.square(u_bc)) return loss_res + loss_bc, loss_res, loss_bc model = PINN() optimizer = tf.keras.optimizers.Adam(learning_rate=1e-3) x_interior = tf.linspace(0.0, 1.0, 200)[:, None] x_boundary = tf.constant([[0.0], [1.0]]) @tf.function def train_step(): with tf.GradientTape() as tape: loss, loss_res, loss_bc = pinn_loss(model, x_interior, x_boundary) grads = tape.gradient(loss, model.trainable_variables) optimizer.apply_gradients(zip(grads, model.trainable_variables)) return loss, loss_res, loss_bc for epoch in range(3000): loss, loss_res, loss_bc = train_step() if epoch % 500 == 0: print(f"Epoch {epoch:4d}, Total Loss: {loss.numpy():.2e}, " f"Res Loss: {loss_res.numpy():.2e}, BC Loss: {loss_bc.numpy():.2e}") # 验证 x_test = np.linspace(0, 1, 128)[:, None].astype('float32') u_pred = model(x_test).numpy().flatten() u_true = np.sin(np.pi * x_test).flatten() mse = np.mean((u_pred - u_true) ** 2) print(f"MSE vs exact solution: {mse:.2e}")6.2 代码关键点说明
TensorFlow 版本的核心是嵌套GradientTape。外层tape2负责记录一阶导数u_x,内层tape1负责计算一阶导数。当tape1.gradient(u, x_interior)执行完,u_x仍然处于外层 tape 的监控范围内,因此tape2.gradient(u_x, x_interior)能得到二阶导数。
这里有一个容易被忽略的细节:在pinn_loss中我们需要显式tape2.watch(x_interior),确保外层 tape 关注输入张量。如果不写,TensorFlow 可能只关注它认为需要关注的变量,导致梯度为None。
6.3 运行方式和预期结果
在命令行执行:
python pinn_poisson_tf.py正常输出和 PyTorch 版本类似。PyTorch 版本的xavier_uniform_初始化对应 TensorFlow 的glorot_uniform,二者本质上是同一种初始化策略,所以训练初期的损失量级接近。
7. 两个实现放在一起看:设计差异与学习价值
现在把两套代码放在一起,你会发现它们的骨架几乎一模一样:构建网络、计算二阶导数、构造物理残差、计算边界损失、循环优化。真正的差异集中在自动微分部分。
| 步骤 | PyTorch 写法 | TensorFlow 写法 |
|---|---|---|
| 一阶导数 | torch.autograd.grad(u, x, create_graph=True) | tape1.gradient(u, x) |
| 二阶导数 | 对一阶导数再次调用torch.autograd.grad | 用外层tape2.gradient(u_x, x) |
| 输入梯度追踪 | 设置x.requires_grad_(True) | 显式tape.watch(x) |
| 梯度更新 | loss.backward()+optimizer.step() | tape.gradient(loss, variables)+apply_gradients |
从可读性看,PyTorch 的torch.autograd.grad更符合数学公式的书写习惯,你在代码里能直接看到“对 u 求 x 的梯度”这个过程。TensorFlow 的嵌套GradientTape更像是录音机,你告诉它“记录这段操作”,然后“回放求导结果”。
从工程集成看,TensorFlow 的@tf.function可以自动将 Python 函数编译为 TensorFlow 图,提升训练速度。PyTorch 2.x 也提供了torch.compile来做类似的事情。但 PINN 的原型阶段,训练速度通常不是瓶颈,调试方便与否才是。
这里给出第二个判断:不要因为某一篇论文用了 PyTorch 就觉得自己必须换框架,也不要因为公司用了 TensorFlow 就否定 PyTorch 在 PINN 上的易用性。两个框架的 PINN 能力是等价的,差异在开发体验。
8. 常见问题与排查思路
8.1 常见问题表格
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
安装 PyTorch 后torch.cuda.is_available()返回 False | CUDA 驱动版本与 PyTorch 版本不匹配 | 运行nvidia-smi查看驱动支持的 CUDA 版本,检查安装命令是否正确 | 按官网生成的安装命令重新安装,或安装 CPU 版先跑通代码 |
| TensorFlow 找不到 GPU | CUDA 和 cuDNN 与 TensorFlow 版本不匹配 | 运行tf.config.list_physical_devices('GPU') | 使用tensorflow[and-cuda]安装,或升级 NVIDIA 驱动 |
| PyTorch 二阶导数报错 “element 0 of tensors does not require grad” | 忘了设置create_graph=True | 检查torch.autograd.grad参数 | 在求一阶导数时加create_graph=True |
TensorFlow 一阶导数为None | 没有在GradientTape中watch输入张量 | 检查是否执行了tape.watch(x) | 在内层和外层 tape 中都显式watch(x) |
| 损失下降很慢,MSE 不收敛 | 学习率过大或过小、网络层数太浅、配点太少 | 打印 loss 曲线,尝试不同学习率 | 先用lr=1e-3的 Adam,再逐个调节网络宽度和配点数 |
| 训练初期 loss 就为 NaN | 网络输出数值过大,或者初始化不当 | 查看第一轮 loss,检查输入值域范围 | 使用 Xavier/Glorot 初始化,对输入做归一化 |
PyTorch 2.6 中torch.load报错提示weights_only | PyTorch 2.6 默认将weights_only设置为True | 查看完整异常信息 | 如果加载的是可信模型,调用时设置weights_only=False |
8.2 一个容易忽略的梯度检查方法
无论使用哪个框架,写完 PINN 代码后都应该先做一个梯度检查。最简单的方法是:用一个小测试输入,手动推导解析方程的二阶导数,再与自动微分结果对比。
比如在上面的 Poisson 方程中,网络输出的u是sin(πx)的近似解。你可以单独把x=0.25代入网络,计算u_xx,再和理论值-π² sin(π·0.25)对比。如果偏差很大,说明自动微分或者网络前向传播的实现有误。
9. PINN 框架选型的最佳实践
9.1 明确你的目标阶段
如果是课程学习、论文复现、快速验证一个新物理问题,优先选择 PyTorch。原因是:当前 PINN 开源社区的新代码大部分是 PyTorch,你可以直接参考别人的网络结构、损失函数设计、采样策略,降低入门成本。
如果是在一个已有 TensorFlow 生产环境里添加 PINN 模块,比如要用到 TF Serving 或 TensorFlow Extended,那选择 TensorFlow 更合理。你不需要制造一个“PyTorch + TensorFlow”共存的环境,那会引入非常多的部署复杂度。
9.2 保持代码结构清晰
PINN 代码很容易越写越乱,因为物理残差、边界条件、初始条件、数据项都要混合在一起。建议按以下结构组织:
pinn_project/ ├── model.py # 网络结构 ├── loss.py # 物理残差、边界损失、数据损失 ├── train.py # 训练循环 ├── evaluate.py # 验证与可视化 └── config.py # 方程参数、采样点数、超参数不要把所有逻辑塞进一个几百行的脚本。后续一旦要换方程、改边界条件,你就会明白分层结构有多重要。
9.3 损失权重不是固定的
边界损失和残差损失的量级差异很大。本文的简单示例直接相加能收敛,是因为方程简单、量级接近。实际问题中,尤其是管网流速、应力场、电磁场等复杂方程,残差和边界条件的量级可能相差多个数量级。建议先观察两类 loss 的量级,再手动设置权重,或者引入自适应权重方法。
9.4 训练策略先简单后复杂
不要一上来就上高阶优化器、学习率调度、多阶段训练。先用 Adam 把最小模型跑通,观察 loss 是否能下降,MSE 是否在可接受范围。如果收敛很慢,再考虑增加网络宽度、增加采样点、加入学习率衰减。
9.5 安全与许可提醒
如果你在公司的生产环境或者共享 GPU 服务器上安装框架,注意遵守内部的安全规范,不要随意使用sudo pip install覆盖系统级 Python 环境,也不要在未授权的机器上运行大规模训练任务。涉及模型文件加载时,要确认模型来源可信,避免加载恶意构造的权重文件。PyTorch 2.6 将torch.load的默认行为改为更安全的weights_only=True,正是为了降低反序列化风险,这是官方在安全边界上的收紧。
10. 本讲小结与下一步学习建议
这一讲围绕“PINN 与 PyTorch/TensorFlow 框架”这个主题,澄清了框架选择的底层逻辑:PINN 依赖自动微分,而自动微分的 API 差异决定了开发体验。然后给出了 PyTorch 和 TensorFlow 两套最小可运行的 Poisson 方程实现,并对比了二者在梯度写法、嵌套 tape、初始化方式、调试习惯上的差异。
建议你先不要急着追求复杂物理问题,而是把本文的两个代码各跑一遍,观察各自的 loss 下降曲线和最终 MSE。跑通之后再做三个小改动:
- 把网络层数改成 4 层 50 神经元,观察 MSE 变化。
- 把方程改成非齐次边界条件,比如
u(0)=1, u(1)=0,修改边界损失后重新训练。 - 把内部配点数量从 200 增加到 1000,对比训练时间和精度。
完成这些改动,你对 PINN 和这两个框架的理解就会从“能运行”进入“能调试”的层次。下一讲可以继续深入 PINN 的损失函数平衡策略,或者讨论如何用这两个框架实现更复杂的偏微分方程组。无论你的最终选择是 PyTorch 还是 TensorFlow,先在一维问题里积累足够的调试经验,再进入高维问题时会轻松很多。