研究でも実務でも「測定コストが高くてデータが少ない」「物理モデル(微分方程式)はある程度わかっている」という状況はよくあります。
そんなときに選択肢になるのが PINN(Physics-Informed Neural Networks) です。
この記事では、ダミーデータ(少数&ノイズあり)を作り、
- データだけで学習するNN(data-only)
- 物理法則も同時に満たすPINN
を比較して、「PINNって結局なにが嬉しいの?」を図で理解します。
TL;DR
- PINN = NNの学習に「微分方程式(物理)」と「境界条件」を“損失”として追加する手法
- 少数データ・ノイズありだと、data-onlyは観測点に当てにいくほど形が崩れやすい
- 今回のtoyでは、相対L2誤差が
- data-only: 約7.86%
- PINN: 約0.11%
と、PINNが大きく改善しました
- PINNの学習曲線にスパイクが出るのは珍しくなく、多目的最適化の揺れとして説明できます(改善策も紹介)
1. PINNとは?(超ざっくり)
通常の回帰NNは、
- 入力
x→ 出力u(x)をNNで近似 - 観測データ
(x_i, y_i)でu(x_i) ≈ y_iになるように学習
という枠組みです。
PINNはこれに加えて、
- 微分方程式を満たす(PDE/ODEの残差が小さい)
- 境界条件(BC)を満たす
という制約も、損失関数に入れます。
「データだけでは決めきれない自由度」を、物理法則で“締める”イメージです。
結果として、ノイズに引っ張られにくくなり、“それっぽい解”が出やすくなります。
2. 今回のtoy問題(1Dの微分方程式)
区間 [0,1] で、次の2階微分方程式(Poisson型のODE)を考えます。
u''(x) + \pi^2 \sin(\pi x) = 0,\quad x \in (0,1)
u(0)=0,\quad u(1)=0
このとき、(toyなので)真の解は
u(x) = \sin(\pi x)
です。
ただし今回は「真の解は知らない」前提で、観測点だけを与えます。
3. データの作り方(ダミーデータ)
-
xをランダムに少数点サンプル(例:10点) - 真値
sin(πx)に小さなノイズを足して観測yにする
4. 比較する2つのモデル
4.1 data-only NN(基準モデル)
損失はシンプルに
- データ損失:観測点でのMSE
だけです。
\mathcal{L}_{data} = \frac{1}{N}\sum_i \left( u_\theta(x_i) - y_i \right)^2
4.2 PINN(物理も入れる)
PINNでは、損失を足し合わせます。
- データ損失(同じ)
- 物理損失:微分方程式の残差が0に近いほど良い
-
境界損失:
u(0)=0, u(1)=0を満たすほど良い
残差は例えば次のように定義します(r(x)=0が理想):
r(x) = u_\theta''(x) + \pi^2\sin(\pi x)
PINNの損失は
\mathcal{L}_{PINN} = \mathcal{L}_{data} + \lambda_{phys}\mathcal{L}_{phys} + \lambda_{bc}\mathcal{L}_{bc}
という形になります。
PINNの肝は コロケーション点(collocation points) です。
観測点とは別に、区間内の点 x_f をたくさん撒き、そこで残差 r(x_f) を小さくするよう学習します。
5. Colabで動く最小コード
以下をColabで実行すると、図と誤差が出ます。
実行後に
fig1_data.png,fig2_prediction_compare.png,fig3_residual.png,fig4_loss_curves.pngが生成されます。
# Colab想定(torch, numpy, matplotlib が入っていればOK)
import math
import numpy as np
import torch
import matplotlib.pyplot as plt
# ----------------------------
# 0) seed & device
# ----------------------------
torch.manual_seed(0)
np.random.seed(0)
device = "cpu"
# ----------------------------
# 1) ground truth (toy)
# ----------------------------
def u_true(x):
return np.sin(np.pi * x)
# ----------------------------
# 2) toy data (few points + noise)
# ----------------------------
n_data = 10
x_data = np.sort(np.random.rand(n_data))
y_data = u_true(x_data) + 0.03 * np.random.randn(n_data)
x_data_t = torch.tensor(x_data, dtype=torch.float32, device=device).view(-1, 1)
y_data_t = torch.tensor(y_data, dtype=torch.float32, device=device).view(-1, 1)
# plot fig1
x_grid = np.linspace(0, 1, 400)
plt.figure(figsize=(7,4.5))
plt.plot(x_grid, u_true(x_grid), label="ground truth: sin(pi x)")
plt.scatter(x_data, y_data, s=60, label="noisy observations")
plt.title("Toy data (few points + noise)")
plt.xlabel("x"); plt.ylabel("u(x)")
plt.grid(True); plt.legend()
plt.savefig("fig1_data.png", dpi=150, bbox_inches="tight")
plt.show()
# ----------------------------
# 3) MLP
# ----------------------------
class MLP(torch.nn.Module):
def __init__(self, width=64, depth=3):
super().__init__()
layers = []
in_dim = 1
for _ in range(depth - 1):
layers.append(torch.nn.Linear(in_dim, width))
layers.append(torch.nn.Tanh())
in_dim = width
layers.append(torch.nn.Linear(in_dim, 1))
self.net = torch.nn.Sequential(*layers)
def forward(self, x):
return self.net(x)
# ----------------------------
# 4) data-only training
# ----------------------------
net_data = MLP().to(device)
opt = torch.optim.Adam(net_data.parameters(), lr=1e-3)
loss_data_hist = []
n_steps = 4000
for step in range(1, n_steps + 1):
opt.zero_grad()
pred = net_data(x_data_t)
loss = torch.mean((pred - y_data_t) ** 2)
loss.backward()
opt.step()
loss_data_hist.append(loss.item())
if step % 500 == 0:
print(f"[data-only] step={step:4d} loss={loss.item():.6f}")
# ----------------------------
# 5) PINN training
# ----------------------------
net_pinn = MLP().to(device)
opt = torch.optim.Adam(net_pinn.parameters(), lr=1e-3)
# collocation points
N_f = 200
x_f = torch.rand(N_f, 1, device=device, dtype=torch.float32)
x_f.requires_grad_(True)
lam_phys = 1.0
lam_bc = 50.0
loss_pinn_hist = []
loss_phys_hist = []
loss_bc_hist = []
loss_fit_hist = []
for step in range(1, n_steps + 1):
opt.zero_grad()
# data loss
u_pred = net_pinn(x_data_t)
loss_fit = torch.mean((u_pred - y_data_t) ** 2)
# boundary loss
x0 = torch.zeros(1, 1, device=device, dtype=torch.float32)
x1 = torch.ones(1, 1, device=device, dtype=torch.float32)
loss_bc = torch.mean(net_pinn(x0) ** 2 + net_pinn(x1) ** 2)
# physics residual loss
u = net_pinn(x_f)
du = torch.autograd.grad(u, x_f, grad_outputs=torch.ones_like(u), create_graph=True)[0]
d2u = torch.autograd.grad(du, x_f, grad_outputs=torch.ones_like(du), create_graph=True)[0]
resid = d2u + (math.pi ** 2) * torch.sin(math.pi * x_f)
loss_phys = torch.mean(resid ** 2)
loss_total = loss_fit + lam_phys * loss_phys + lam_bc * loss_bc
loss_total.backward()
opt.step()
loss_pinn_hist.append(loss_total.item())
loss_fit_hist.append(loss_fit.item())
loss_phys_hist.append(loss_phys.item())
loss_bc_hist.append(loss_bc.item())
if step % 500 == 0:
print(f"[PINN] step={step:4d} total={loss_total.item():.6f} "
f"(data={loss_fit.item():.6f}, phys={loss_phys.item():.6f}, bc={loss_bc.item():.6f})")
# ----------------------------
# 6) evaluation + plots
# ----------------------------
xg = torch.tensor(x_grid, dtype=torch.float32, device=device).view(-1, 1)
with torch.no_grad():
y_dataonly = net_data(xg).cpu().numpy().reshape(-1)
y_pinn = net_pinn(xg).cpu().numpy().reshape(-1)
y_true = u_true(x_grid)
rel_l2_data = np.linalg.norm(y_dataonly - y_true) / np.linalg.norm(y_true)
rel_l2_pinn = np.linalg.norm(y_pinn - y_true) / np.linalg.norm(y_true)
print("\nRelative L2 error")
print(" NN (data-only):", rel_l2_data)
print(" PINN :", rel_l2_pinn)
# fig2: prediction compare
plt.figure(figsize=(7,4.5))
plt.plot(x_grid, y_true, label="ground truth: sin(pi x)")
plt.scatter(x_data, y_data, s=60, label="noisy data")
plt.plot(x_grid, y_dataonly, "--", label="NN (data-only)")
plt.plot(x_grid, y_pinn, "-", label="PINN")
plt.title("Prediction comparison")
plt.xlabel("x"); plt.ylabel("u(x)")
plt.grid(True); plt.legend()
plt.savefig("fig2_prediction_compare.png", dpi=150, bbox_inches="tight")
plt.show()
# fig3: physics residual for PINN
# NOTE: residual needs autograd, so no torch.no_grad()
x_plot = torch.tensor(x_grid, dtype=torch.float32, device=device).view(-1, 1)
x_plot.requires_grad_(True)
u_plot = net_pinn(x_plot)
du_plot = torch.autograd.grad(u_plot, x_plot, grad_outputs=torch.ones_like(u_plot), create_graph=True)[0]
d2u_plot = torch.autograd.grad(du_plot, x_plot, grad_outputs=torch.ones_like(du_plot), create_graph=True)[0]
resid_plot = (d2u_plot + (math.pi ** 2) * torch.sin(math.pi * x_plot)).detach().cpu().numpy().reshape(-1)
plt.figure(figsize=(7,4.5))
plt.plot(x_grid, resid_plot, label="physics residual r(x)")
plt.axhline(0, ls="--")
plt.title("Physics residual r(x) = u''(x) + pi^2 sin(pi x) (PINN)")
plt.xlabel("x"); plt.ylabel("residual")
plt.grid(True); plt.legend()
plt.savefig("fig3_residual.png", dpi=150, bbox_inches="tight")
plt.show()
# fig4: loss curves (log scale)
plt.figure(figsize=(7.2,4.2))
plt.semilogy(loss_data_hist, label="data-only: MSE(data)")
plt.semilogy(loss_pinn_hist, label="PINN: total loss")
plt.title("Training curves (log scale)")
plt.xlabel("step"); plt.ylabel("loss")
plt.grid(True); plt.legend()
plt.savefig("fig4_loss_curves.png", dpi=150, bbox_inches="tight")
plt.show()
6. 結果(図の読み方)
6.1 図2:予測比較(data-only vs PINN)
ポイントは2つです。
- data-only:観測点には寄るが、端点付近で形が崩れやすい(境界条件を知らない)
- PINN:境界条件と物理を満たすように曲線が整い、少数点でも形状復元が強い
6.2 相対L2誤差(toyなので真値で評価できる)
今回の実行例では、相対L2誤差が
- data-only:
0.07856(約 7.86%) - PINN:
0.00111(約 0.11%)
となり、PINNのほうが大幅に良くなりました。
ここがPINNの重要なメッセージです:
“学習損失(data MSE)が小さい = 真の解に近い”とは限らない。
少数&ノイズありだと、物理制約が強い正則化として効きます。
6.3 図4:学習曲線(PINNのスパイクはなぜ?)
PINNでは data / phys / bc を同時に最適化するため、学習中に損失が一時的に跳ねる(スパイクする)ことがあります。
- 多目的最適化で「データに寄せる」と「物理に寄せる」が引っ張り合う
- 学習率が大きいと一時的に踏み外す
- コロケーション点を再サンプルしている場合、残差分布が変わって損失が跳ねる
今回のログでも途中で一時的に増えましたが、最終的に収束しています。
6.4 図3:物理残差(physics residual)
残差 r(x)=u''(x)+π^2 sin(πx) は、真の解なら全域で0です。
PINNはこれを小さくするよう学習しています。
端点近傍で残差が大きく見える場合は、次の改善がよく効きます。
- コロケーション点を端点近傍に厚めに配置する
- 境界条件を「ハード制約」で埋め込む(下の補足参照)
-
float64(倍精度)を試す(2階微分が安定しやすい)
7. よくある質問:いつPINNを使うべき?
-
使うと嬉しい
- データが少ない / 測定が高コスト
- 物理(PDE/ODE)が信頼できる
- 逆問題(未知パラメータ推定)をやりたい
-
注意が必要
- 物理モデルが間違っていると、PINNは間違いを“強く信じる”
- 高次元・複雑PDEでは学習が不安定になりやすい(工夫が必要)
8. 発展(optional):境界条件を“ハード制約”で入れる
境界条件を損失で入れる代わりに、モデル構造で必ず満たす方法
たとえば u(0)=u(1)=0 を必ず満たしたいなら、
u_hat(x) = x(1-x) * NN(x)
の形にすると、どんなNNでも端点は必ず0になります(bc lossが不要)。
こうすると学習が安定したり、端点近傍の挙動が改善することがあります。
まとめ
- PINNは “物理を損失に入れる” ことで、少数データ・ノイズありでも解の形が安定しやすい
- 学習損失が小さいモデルが、必ずしも真の解に近いとは限らない
- スパイクや端点近傍の残差など、PINN特有の挙動はあるが、改善策も定番化している



