1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

研究でも実務でも「測定コストが高くてデータが少ない」「物理モデル(微分方程式)はある程度わかっている」という状況はよくあります。
そんなときに選択肢になるのが 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 にする

図1に、真値曲線と観測点(ノイズ付き)を示します。
fig1_data.png


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)

fig2_prediction_compare.png

ポイントは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のスパイクはなぜ?)

fig4_loss_curves.png

PINNでは data / phys / bc を同時に最適化するため、学習中に損失が一時的に跳ねる(スパイクする)ことがあります。

  • 多目的最適化で「データに寄せる」と「物理に寄せる」が引っ張り合う
  • 学習率が大きいと一時的に踏み外す
  • コロケーション点を再サンプルしている場合、残差分布が変わって損失が跳ねる

今回のログでも途中で一時的に増えましたが、最終的に収束しています。


6.4 図3:物理残差(physics residual)

fig3_residual.png

残差 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特有の挙動はあるが、改善策も定番化している
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?