0
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?

【論文解説】GNNの電力需要予測はグラフのおかげか?共有と近傍参照を切り分ける診断法

0
Posted at

グラフニューラルネットワーク(GNN)で複数の地域や配電線の電力需要をまとめて予測し、ノードごとの予測モデルより精度が上がったとします。このとき「グラフ(どのノードとどのノードがつながっているか)が効いた」と結論づけてよいでしょうか。

GNN は、全ノードで同じ重みを使う(パラメータ共有) ことと、隣接ノードの特徴を読む(近傍参照) ことの 2 つを同時に行っています。精度の改善はどちらから来ていてもおかしくありません。もし共有だけで改善の大半が説明できるなら、手間をかけてグラフを作り、保守する理由は薄くなります。

この記事では、この 2 つを切り分ける診断の手順を提案した論文 A Diagnostic Protocol for GNNs in Electricity Load Forecasting: Separating Parameter Sharing from Neighborhood Access(Campagne ほか、2026)を、数式を追いながら解説します。あわせて、手順の考え方を合成データと小さな Python コードで再現し、論文の表の数値から分解を計算し直して確かめます。

対象読者: GNN や時系列予測を実務・研究で使っていて、「GNN が勝った」という結果をどう解釈するか気になっている人。GCN・GraphSAGE の基本(隣接行列で近傍の特徴を集約する)を知っていると読みやすいです。

この論文は 2026 年 10 月に arXiv に投稿された査読前のプレプリントです。記事中の実験結果は論文に書かれた値で、論文の電力データでの実験は再現していません。自分で動かしたのは、手順の考え方を確かめる小さな合成データの実験です。

環境

自分で動かしたコードの環境です。

  • macOS(Apple Silicon)
  • Python 3.15
  • NumPy 2.5.4

GNN のライブラリは使わず、線形モデル(リッジ回帰)だけで手順の考え方を再現しています。

論文の概要:4 つの問い

論文は次の 4 つの問いに答える形で書かれています。

問い 内容
Q1 GNN は、強いグラフなしのモデルより需要をよく予測できるか
Q2 GNN が勝ったとき、その改善はグラフから来ているか
Q3 同じアーキテクチャどうしで比べたとき、近傍は役に立つか
Q4 近傍が役に立つのはどんなときか

使われているデータは 3 つです。

  • regions-fr: フランスの 12 地域の 30 分ごとの電力需要(2015〜2019 年)。気象と暦の特徴量があります。
  • weave-uk: 英国の低圧配電線(フィーダー)500 本の 30 分ごとの需要(2024 年 2 月 14〜28 日)。暦の特徴量と過去の需要だけです。
  • electricity: 公開データセット(Informer の論文で使われたもの)の 321 顧客の 1 時間ごとの消費量のうち 319 顧客。

結論を先に書くと、3 つのデータのどれでも、GNN がノードごとのモデルより優れていた分の大半は辺(グラフ)では説明されませんでした。weave-uk では、改善の多くが「全ノードで重みを共有する」だけで得られています。論文は「予測の改善をグラフの手柄とするのは、ノードごとのモデルと共有モデルの両方と比べてからにすべきだ」とまとめています。

GNN と MLP を「共有」と「近傍参照」の 2 軸で並べる

1 層の例で 2 つの仕組みを見る

論文は、GNN と MLP(多層パーセプトロン)を同じ仲間のモデルとして扱うために、次の 1 層を例に出します。

\mathbf{H} = \sigma\left(\mathbf{X}\mathbf{W}_1 + \hat{\mathbf{A}}\mathbf{X}\mathbf{W}_2\right)
\tag{1}
  • $\mathbf{X} \in \mathbb{R}^{N \times d}$: $N$ 個のノードの特徴量を縦に積んだ行列(1 行が 1 ノード)
  • $\hat{\mathbf{A}}$: 正規化した隣接行列。GCN なら $\tilde{\mathbf{D}}^{-1/2}(\mathbf{A}+\mathbf{I})\tilde{\mathbf{D}}^{-1/2}$、GraphSAGE の平均集約なら行ごとに正規化した $\mathbf{D}^{-1}\mathbf{A}$
  • $\mathbf{W}_1, \mathbf{W}_2$: 全ノードで共有される重み行列
  • $\sigma$: 要素ごとの活性化関数

第 1 項 $\mathbf{X}\mathbf{W}_1$ は自分の特徴、第 2 項 $\hat{\mathbf{A}}\mathbf{X}\mathbf{W}_2$ は隣接ノードの特徴を読む部分です。

ここで隣接行列を単位行列 $\mathbf{A} = \mathbf{I}$(自分にしかつながっていないグラフ)にすると、

\mathbf{H} = \sigma\left(\mathbf{X}\mathbf{W}_1 + \mathbf{X}\mathbf{W}_2\right) = \sigma(\mathbf{X}\mathbf{W}), \qquad \mathbf{W} = \mathbf{W}_1 + \mathbf{W}_2

となり、全ノードで重み $\mathbf{W}$ を共有する MLP と同じ関数になります。つまり「GNN から辺を取り除いたもの」は、「共有 MLP」と同じ関数のクラスです。逆に、共有をやめてノードごとに別の重み $\mathbf{W}_i$ を持たせると、$\mathbf{H}_i = \sigma(\mathbf{x}_i \mathbf{W}_i)$ というノードごとの MLP になります。

この等式を数値で確かめます。

layer_identity.py
import numpy as np

rng = np.random.default_rng(0)
N, d, H = 5, 3, 4
X = rng.normal(size=(N, d))
W1, W2 = rng.normal(size=(d, H)), rng.normal(size=(d, H))
relu = lambda z: np.maximum(z, 0)

# 式 (1): H = σ(X W1 + Â X W2)。Â = I のとき
H_gnn = relu(X @ W1 + np.eye(N) @ X @ W2)
H_mlp = relu(X @ (W1 + W2))                 # 重み W = W1 + W2 を全ノードで共有する MLP
print("A = I の GNN 層と共有 MLP の差:", np.abs(H_gnn - H_mlp).max())

# 疎な隣接行列(SAGE の平均集約 D^-1 A)なら、ノード 0 の出力は隣接ノードの特徴に依存する
A = np.array([[0, 1, 1, 0, 0], [1, 0, 0, 1, 0], [1, 0, 0, 0, 1], [0, 1, 0, 0, 1], [0, 0, 1, 1, 0]], float)
A_hat = A / A.sum(1, keepdims=True)
H0 = relu(X @ W1 + A_hat @ X @ W2)[0]
X2 = X.copy(); X2[3] += 10                  # ノード 0 の隣接ではないノード 3 を変える
X3 = X.copy(); X3[1] += 10                  # ノード 0 の隣接ノード 1 を変える
print("隣接でないノードを変えたときの変化:", np.abs(relu(X2 @ W1 + A_hat @ X2 @ W2)[0] - H0).max())
print("隣接ノードを変えたときの変化    :", np.abs(relu(X3 @ W1 + A_hat @ X3 @ W2)[0] - H0).max().round(3))
A = I の GNN 層と共有 MLP の差: 4.440892098500626e-16
隣接でないノードを変えたときの変化: 0.0
隣接ノードを変えたときの変化    : 3.157

差は浮動小数点の誤差だけで、2 つは同じ計算です。疎なグラフでは、隣接ノードを変えたときだけノード 0 の出力が変わります。

ただし論文も注意しているとおり、これは関数のクラスが同じというだけです。実際の MLP と残差接続つきの GNN では、層の深さ、パラメータの持ち方、学習のしやすさが違います。この違いは後で出てくる「空グラフ GNN」で切り分けます。

4 つのノード単位モデル

共有するか(行)、他のノードを読むか(列)の 2 軸で、論文は 4 つのモデルを並べます(論文の図 1)。$d$ は特徴量の数、$H$ は隠れ層の幅です。

自分の特徴だけ読む 他のノードも読む
ノードごとに別の重み ノード別 MLP(パラメータ $NdH$) 全ノード MLP(全ノードの特徴をつなげて入力、$NdH$)
全ノードで共有 恒等グラフ GNN = 共有 MLP($dH$) GNN($2dH$)

共有するとパラメータ数が $N$ 分の 1 になり、全ノードのデータをまとめて学習に使えます。その代わり、性質の違うノードにも同じ入出力の規則を当てはめることになります。

全ノード MLP は全ノードの特徴を固定の位置に並べて読むので、ノードの並べ替えに対して結果が変わります。GNN は近傍を対称に集約するので、並べ替えても結果は変わりません。

診断プロトコル:誤差の差を 2 通りに分解する

3 つの参照モデル

提案手法は、評価したい GNN を次の 3 つの参照モデルと比べます。

  1. ノード別モデル: 同じ特徴量でノードごとに別々に学習したモデル。グラフが説明すべき差の合計を与えます。
  2. 共有モデル(恒等グラフ): 全ノードで重みを共有し、近傍は読まないモデル。共有したうえで辺がどれだけ効くかを測ります。
  3. 空グラフ GNN: 同じ GNN の骨格(論文では backbone と呼ぶ、畳み込みのまわりのネットワーク)で隣接行列を $\mathbf{A} = \mathbf{0}$ にしたもの。辺の効果と、骨格が変わった効果を分けます。

さらに、次数を保ったまま辺をつなぎ替えたグラフ(リワイヤリング)で GNN を学習し直し、「どのノードが隣か」が効いているのかを調べます。

誤差の差の分解(式 4)

誤差(後で定義する nRMSE)を $E$ と書き、モデルを次の記号で表します。

  • $M_{\mathrm{pn}}$: ノード別 MLP(per-node)
  • $M_{\mathrm{sh}}$: 共有 MLP(shared)
  • $M_{\mathrm{pn+n}}$: ノード別 MLP に、隣接ノードの特徴の平均も入力したもの
  • $M_{\mathrm{gr}}$: GNN(graph)

$E_{\cdot} := E(M_{\cdot})$ とすると、ノード別モデルから GNN までの誤差の差は、2 つの経路で分解できます。

\begin{aligned}
E_{\mathrm{pn}} - E_{\mathrm{gr}}
&= \underbrace{(E_{\mathrm{pn}} - E_{\mathrm{sh}})}_{\text{共有の増分}} + \underbrace{(E_{\mathrm{sh}} - E_{\mathrm{gr}})}_{\text{共有したうえでの辺の増分}} \\
&= \underbrace{(E_{\mathrm{pn}} - E_{\mathrm{pn+n}})}_{\text{共有なしでの辺の増分}} + \underbrace{(E_{\mathrm{pn+n}} - E_{\mathrm{gr}})}_{\text{GNN までの残り}}
\end{aligned}
\tag{4}

論文では、誤差が減った量(前のモデルの誤差 − 後のモデルの誤差)を増分(increment)と呼びます。正なら改善です。

  • 経路 1 は、先に重みを共有し、そのあと辺を加えます。
  • 経路 2 は、先に近傍を読ませ、そのあと共有します。

両方の経路を見るのは、増分の大きさが「どちらを先に入れるか」で変わりうるからです。2 つの経路で共有の増分が違えば、共有と近傍参照のあいだに交互作用があることになります。

経路 1 の後半 $E_{\mathrm{sh}} - E_{\mathrm{gr}}$ では、MLP から GNN に変わるので骨格も変わってしまいます。そこで空グラフ GNN $M_{\varnothing}$ を間に入れ、さらに 2 つに分けます。

E_{\mathrm{sh}} - E_{\mathrm{gr}} = \underbrace{(E_{\mathrm{sh}} - E_{\varnothing})}_{\text{骨格の増分}} + \underbrace{(E_{\varnothing} - E_{\mathrm{gr}})}_{\text{骨格を固定したときの辺の増分}}

空グラフ GNN は GNN と骨格もパラメータ数も同じなので、$E_{\varnothing} - E_{\mathrm{gr}}$ は容量をそろえたうえでの辺だけの効果です。

経路 2 にはこれに当たる対照がありません。$M_{\mathrm{pn+n}}$ が読むのは 1 ホップの特徴の平均で、GNN は 2 層で学習した表現を集約するので、経路 2 の比較はアーキテクチャがそろっていません。論文もこの点を限界として書いています。

評価指標

系全体の誤差は、全ノードの合計 $s_t = \sum_{i=1}^{N} y_{i,t}$ と、その予測 $\hat{s}_t$ の差 $e_t = s_t - \hat{s}_t$ で測ります。

\mathrm{MAPE} = \frac{100}{T}\sum_{t=1}^{T}\left|\frac{e_t}{s_t}\right|, \qquad
\mathrm{RMSE} = \sqrt{\frac{1}{T}\sum_{t=1}^{T} e_t^2}
\tag{3}

合計で測ると、ノード間で符号が逆の誤差が打ち消し合います。そこで分解には、ノードごとの正規化 RMSE(nRMSE)を主な指標として使います。ノード $i$ の目的変数を学習期間の平均 $\mu_i$ と標準偏差 $s_i$ で標準化し、ノードごとに RMSE をとってから、ノードについて平均します(weave-uk では予測の先の時点ごとにも標準化します)。

\mathrm{nRMSE} = \frac{1}{N}\sum_{i=1}^{N}\sqrt{\frac{1}{|\mathcal{T}|}\sum_{t \in \mathcal{T}}\left(\frac{\hat{y}_{i,t} - y_{i,t}}{s_i}\right)^2}

nRMSE は全ノードを同じ重みで扱います。一方、系全体の RMSE は需要の大きいノードの重みが大きくなります。nRMSE が 1 なら、誤差が学習期間の標準偏差と同じくらいという意味です。

論文の数値で分解を計算し直す

論文の図 4(weave-uk、学習期間 30 時間)の 4 つの nRMSE から、式 (4) を計算してみます。

fig4_check.py
"""論文の図 4(weave-uk, 30 時間)の 4 つの誤差から、式 (4) の 2 通りの分解を計算する"""
E = {"pn": 1.0509, "sh": 0.9074, "pn+n": 0.9995, "gr": 0.8808}
p1 = (E["pn"] - E["sh"], E["sh"] - E["gr"])
p2 = (E["pn"] - E["pn+n"], E["pn+n"] - E["gr"])
print(f"経路1: 共有 {p1[0]:.4f} + 辺 {p1[1]:.4f} = {sum(p1):.4f}")
print(f"経路2: 辺 {p2[0]:.4f} + 共有 {p2[1]:.4f} = {sum(p2):.4f}")
print(f"共有の取り分: 経路1 {p1[0] / sum(p1):.0%}, 経路2 {p2[1] / sum(p2):.0%}")
print(f"2 経路での共有の増分の差(交互作用): {p1[0] - p2[1]:.4f}")
経路1: 共有 0.1435 + 辺 0.0266 = 0.1701
経路2: 辺 0.0514 + 共有 0.1187 = 0.1701
共有の取り分: 経路1 84%, 経路2 70%
2 経路での共有の増分の差(交互作用): 0.0248

論文の本文にある値(共有 +0.1435、辺 +0.0266、近傍平均 +0.0514、合計 +0.1702、2 経路の差 0.0248)と一致します(合計の 0.0001 の違いは、表の値が 4 桁に丸められているためです)。

経路 1 で見ると、ノード別モデルから GNN までの差のうち 84% を、近傍を読まない共有 MLP がすでに埋めています。

隣が「誰か」は効いているか:次数保存リワイヤリング

辺を加えて精度が上がっても、それが「適切な相手とつながったから」なのか、「入力が増えた・平均をとってノイズが減った」だけなのかはわかりません。そこで論文は、次数(各ノードの辺の数)を変えずに相手だけをでたらめにしたグラフと比べます。

double edge swap

グラフの 2 本の辺 $(a, b)$ と $(c, d)$ を選び、$(a, d)$ と $(c, b)$ に付け替えます。自己ループや重複辺ができる場合は付け替えません。

\{(a, b), (c, d)\} \;\longrightarrow\; \{(a, d), (c, b)\}

4 つのノードの次数はどれも変わりません。論文ではこれを辺の数 $|\mathcal{E}|$ の 10 倍の回数くり返します。ノード集合、辺の数、次数の列、モデル、特徴量、学習の条件はそのままで、「誰とつながっているか」だけが変わります。

残差がグラフに沿ってそろっているかを測る

付け替えで本当に構造が壊れたかを確かめるために、論文はノード別リッジ回帰の残差 $\mathbf{r}_t \in \mathbb{R}^N$ を使って、次の残差エネルギーを計算します($\mathbf{L}$ はグラフの正規化ラプラシアン)。

s(\mathbf{r}) = \frac{\sum_t \mathbf{r}_t^\top \mathbf{L}\,\mathbf{r}_t}{\sum_t \mathbf{r}_t^\top \mathbf{r}_t}
\tag{F.2}

$\mathbf{r}^\top \mathbf{L}\mathbf{r}$ は、辺でつながったノードどうしの値の差の二乗を足したものに当たるので、辺でつながったノードの残差が似ているほど小さくなります。

どのくらい小さければ「そろっている」と言えるかを決めるために、ノードのラベルをでたらめに並べ替えた残差 $\mathbf{r}^{\pi}$ で同じ量を計算し、その平均 $\mu_{\mathcal{G}}$ と標準偏差 $\sigma_{\mathcal{G}}$ で標準化します。

z_{\mathcal{G}} = \frac{\mu_{\mathcal{G}} - s(\mathbf{r})}{\sigma_{\mathcal{G}}}
\tag{F.3}

$z$ が大きいほど、残差はでたらめな割り当てより辺に沿ってそろっています。0 に近ければ、辺は残差と関係していません。

論文では、目的変数そのものの近さを Moran の $I$ でも測っています($W_{ij}$ は辺の重み、$S_0 = \sum_{i,j} W_{ij}$)。

I(\mathbf{x}_t) = \frac{N}{S_0}\,\frac{\sum_{i,j} W_{ij}(x_{i,t} - \bar{x}_t)(x_{j,t} - \bar{x}_t)}{\sum_i (x_{i,t} - \bar{x}_t)^2}
\tag{F.1}

需要そのものの Moran の $I$ は electricity で +0.555、weave-uk で +0.192 と、隣どうしはよく似ています。ところが、ノード別リッジ回帰の残差では +0.079 と +0.063 まで下がります。論文は、隣のノードの情報の多くは、すでに自分の過去の値に含まれていると解釈しています。

なお残差エネルギーの $z$ は真のグラフで 10 以上あり、リワイヤ後は 0.7〜1.9 まで下がります(論文の表 F.1)。残差のそろい方は小さいものの、偶然とは言えない程度にはあり、リワイヤでそれが消えていることがわかります。

合成データで手順を再現する

論文の 6.3 節には、近傍が役に立つ状況を作った合成データ(ノイズ除去型の生成器)があります。きれいな気温の場が空間的に相関していて、各ノードはそれを独立なノイズ付きで観測します。需要はきれいな気温の関数です。隣のノードのノイズは独立なので、隣の観測を平均すれば自分の観測のノイズを減らせます。

これをまねた小さな合成データで、4 つのモデル、式 (4) の分解、リワイヤリングを再現しました。論文の GNN の代わりに、次の線形モデル(リッジ回帰、特徴量は観測気温の 3 次までの多項式)を使います。

  • ノード別: ノードごとに別のリッジ回帰
  • 共有: 全ノードのデータで 1 つのリッジ回帰
  • ノード別 + 近傍: 自分の特徴に、隣接ノードの観測の平均の多項式を加える
  • GNN 相当: 共有したうえで近傍の平均も読む(1 層の線形 GraphSAGE に当たる)

主な設定は次のとおりです。

  • ノード数 60、各ノードの近い 5 ノードを残して無向グラフにする
  • きれいな気温場の共分散 $\rho\,e^{-\|p_i - p_j\|^2/0.1} + (1-\rho)\,\delta_{ij}$
  • 観測ノイズ $\sigma_{\mathrm{obs}}$
  • 需要は $a_i T^2 + b_i T + 0.1$(係数はノードごとに少しずつ違う)
  • 学習データは各ノード 30 時点、テストは 2,000 時点
  • 乱数のシード 10 通りの平均
protocol.py(全文)
protocol.py
"""論文 6.3 節のノイズ除去型の合成データで、診断プロトコルの 4 モデルと次数保存リワイヤリングを再現する(線形版)"""
import numpy as np

N, K_NN, LAM = 60, 5, 1e-2
T_TRAIN, T_TEST = 30, 2000


def make_graph(pos, rng):
    """各ノードの近い 5 ノードを残し、無向の単純グラフにする"""
    d = np.linalg.norm(pos[:, None] - pos[None], axis=-1)
    np.fill_diagonal(d, np.inf)
    A = np.zeros((N, N))
    for i in range(N):
        A[i, np.argsort(d[i])[:K_NN]] = 1
    return np.maximum(A, A.T)


def rewire(A, rng, n_swap_factor=10):
    """次数を保ったまま辺 (a,b),(c,d) を (a,d),(c,b) に入れ替える(double edge swap)"""
    A = A.copy()
    edges = [tuple(e) for e in np.argwhere(np.triu(A))]
    done, tries = 0, 0
    while done < n_swap_factor * len(edges) and tries < 100 * n_swap_factor * len(edges):
        tries += 1
        i, j = rng.choice(len(edges), 2, replace=False)
        (a, b), (c, d) = edges[i], edges[j]
        if rng.random() < 0.5:
            c, d = d, c
        if len({a, b, c, d}) < 4 or A[a, d] or A[c, b]:
            continue                            # 自己ループ・多重辺になる入れ替えはしない
        A[a, b] = A[b, a] = A[c, d] = A[d, c] = 0
        A[a, d] = A[d, a] = A[c, b] = A[b, c] = 1
        edges[i], edges[j] = (a, d), (c, b)
        done += 1
    return A


def simulate(pos, rho, sigma_obs, T, rng):
    """空間相関 rho のきれいな気温場を、各ノードが独立なノイズ sigma_obs 付きで観測する"""
    d2 = ((pos[:, None] - pos[None]) ** 2).sum(-1)
    C = rho * np.exp(-d2 / 0.1) + (1 - rho) * np.eye(N)
    L = np.linalg.cholesky(C + 1e-9 * np.eye(N))
    clean = (L @ rng.normal(size=(N, T))).T                    # (T, N)
    obs = clean + sigma_obs * rng.normal(size=(T, N))
    return clean, obs


def load(clean, coef):
    """ノードごとに少し違う気温→負荷の曲線(暖房と冷房で U 字)"""
    return coef[0] * clean ** 2 + coef[1] * clean + 0.1


def feats(x):
    return np.stack([np.ones_like(x), x, x ** 2, x ** 3], -1)  # (T, N, 4)


def ridge(F, y):
    p = F.shape[-1]
    return np.linalg.solve(F.T @ F + LAM * np.eye(p), F.T @ y)


def fit_predict(Ftr, ytr, Fte, shared):
    if shared:                                                  # 全ノードのデータをまとめて 1 つの重み
        w = ridge(Ftr.reshape(-1, Ftr.shape[-1]), ytr.reshape(-1))
        return Fte @ w
    return np.stack([Fte[:, i] @ ridge(Ftr[:, i], ytr[:, i]) for i in range(N)], 1)


def nrmse(pred, y, ytr):
    """ノードごとに学習期間の標準偏差で割った RMSE を、ノードについて平均する"""
    return float(np.mean(np.sqrt(((pred - y) ** 2).mean(0)) / ytr.std(0)))


def run(rho, sigma_obs, seed, t_train):
    rng = np.random.default_rng(seed)
    pos = rng.random((N, 2))
    A = make_graph(pos, rng)
    A_rw = rewire(A, rng)
    coef = np.stack([1 + 0.2 * rng.normal(size=N), 0.5 + 0.2 * rng.normal(size=N)])
    c_tr, x_tr = simulate(pos, rho, sigma_obs, t_train, rng)
    c_te, x_te = simulate(pos, rho, sigma_obs, T_TEST, rng)
    y_tr, y_te = load(c_tr, coef), load(c_te, coef)

    def with_nbr(x, Adj):                     # 自ノードの特徴 + 隣接ノードの特徴の平均
        m = x @ (Adj / Adj.sum(1, keepdims=True)).T
        return np.concatenate([feats(x), feats(m)[..., 1:]], -1)

    E = {}
    E["pn"] = nrmse(fit_predict(feats(x_tr), y_tr, feats(x_te), False), y_te, y_tr)
    E["sh"] = nrmse(fit_predict(feats(x_tr), y_tr, feats(x_te), True), y_te, y_tr)
    E["pn+n"] = nrmse(fit_predict(with_nbr(x_tr, A), y_tr, with_nbr(x_te, A), False), y_te, y_tr)
    E["gr"] = nrmse(fit_predict(with_nbr(x_tr, A), y_tr, with_nbr(x_te, A), True), y_te, y_tr)
    E["gr_rw"] = nrmse(fit_predict(with_nbr(x_tr, A_rw), y_tr, with_nbr(x_te, A_rw), True), y_te, y_tr)
    assert (A.sum(0) == A_rw.sum(0)).all()    # 次数は変わっていない
    return E


def report(rho, sigma_obs, t_train=T_TRAIN, seeds=range(10)):
    Es = [run(rho, sigma_obs, s, t_train) for s in seeds]
    E = {k: np.mean([e[k] for e in Es]) for k in Es[0]}
    share = E["pn"] - E["sh"]
    cond = E["sh"] - E["gr"]
    nonsh = E["pn"] - E["pn+n"]
    rest = E["pn+n"] - E["gr"]
    total = E["pn"] - E["gr"]
    rw_loss = (E["gr_rw"] - E["gr"]) / cond if cond > 0 else float("nan")
    print(f"rho={rho:.1f} sigma_obs={sigma_obs:.1f} T={t_train} | "
          + " ".join(f"E_{k}={v:.4f}" for k, v in E.items()))
    print(f"   経路1: 共有 {share:+.4f} + 辺(共有後) {cond:+.4f} = {share + cond:+.4f}"
          f" | 経路2: 辺(非共有) {nonsh:+.4f} + 残り {rest:+.4f} = {nonsh + rest:+.4f}"
          f" | 合計 {total:+.4f} | リワイヤで消えた辺の利得 {rw_loss:.0%}")


if __name__ == "__main__":
    for rho, s, t in [(0.7, 1.0, 30), (0.7, 0.0, 30), (0.0, 1.0, 30), (0.7, 1.0, 300)]:
        report(rho, s, t)
rho=0.7 sigma_obs=1.0 T=30 | E_pn=1.1963 E_sh=1.0152 E_pn+n=1.2553 E_gr=0.9428 E_gr_rw=1.0136
   経路1: 共有 +0.1811 + 辺(共有後) +0.0724 = +0.2535 | 経路2: 辺(非共有) -0.0590 + 残り +0.3125 = +0.2535 | 合計 +0.2535 | リワイヤで消えた辺の利得 98%
rho=0.7 sigma_obs=0.0 T=30 | E_pn=0.0011 E_sh=0.2821 E_pn+n=0.0018 E_gr=0.2833 E_gr_rw=0.2831
   経路1: 共有 -0.2809 + 辺(共有後) -0.0012 = -0.2821 | 経路2: 辺(非共有) -0.0006 + 残り -0.2815 = -0.2821 | 合計 -0.2821 | リワイヤで消えた辺の利得 nan%
rho=0.0 sigma_obs=1.0 T=30 | E_pn=1.2083 E_sh=1.0329 E_pn+n=1.3336 E_gr=1.0344 E_gr_rw=1.0338
   経路1: 共有 +0.1754 + 辺(共有後) -0.0015 = +0.1739 | 経路2: 辺(非共有) -0.1253 + 残り +0.2992 = +0.1739 | 合計 +0.1739 | リワイヤで消えた辺の利得 nan%
rho=0.7 sigma_obs=1.0 T=300 | E_pn=0.8833 E_sh=0.8931 E_pn+n=0.8207 E_gr=0.8313 E_gr_rw=0.8914
   経路1: 共有 -0.0098 + 辺(共有後) +0.0618 = +0.0520 | 経路2: 辺(非共有) +0.0626 + 残り -0.0106 = +0.0520 | 合計 +0.0520 | リワイヤで消えた辺の利得 97%

表にまとめます(nRMSE、シード 10 通りの平均)。

条件 ノード別 共有 ノード別+近傍 GNN 相当 共有の増分 共有後の辺の増分 共有なしの辺の増分
① 相関あり・ノイズあり・短い履歴 1.196 1.015 1.255 0.943 +0.181 +0.072 −0.059
② ノイズなし 0.001 0.282 0.002 0.283 −0.281 −0.001 −0.001
③ 空間相関なし 1.208 1.033 1.334 1.034 +0.175 −0.002 −0.125
④ ①で履歴を 10 倍に 0.883 0.893 0.821 0.831 −0.010 +0.062 +0.063

結果から、次のことが読み取れます。

  • ① 短い履歴でノイズがあるとき: GNN 相当のモデルが最良です。ただし改善 0.254 のうち 0.181(71%)は、近傍を読まない共有だけで得られています。リワイヤしたグラフでは、辺による改善の 98% が消えました。近傍の「誰か」が効いています。
  • 共有しないで近傍を足すと悪化(① と ③): 30 時点しかないデータで入力を倍にしたため、ノードごとのモデルが過学習しています。論文の electricity の 11 日窓でも、ノード別モデルに近傍の平均を足すと悪化しており(−0.0171)、論文は「パラメータが増えた代償と、近傍の寄与が混ざっている」と述べています。
  • ② ノイズがないとき: 自分の観測だけで需要が決まるので、近傍は役に立ちません。共有はかえって大きく悪化します。ノードごとに曲線が違うのに、同じ規則を押しつけるためです。論文の regions-fr で、条件を固定したときにノード別 MLP が一番よかった状況に似ています。
  • ③ 空間相関がないとき: 隣は自分のノイズを減らす手がかりにならないので、辺の増分はほぼ 0 です。論文の合成実験でも、$\rho = 0$ で GNN とノード別 MLP の差が約 0.02 まで縮んでいます。
  • ④ 履歴が長いとき: ノードごとのモデルが十分に学習できるので、共有の増分は消えます。辺の増分は残ります。論文の electricity でも、共有の増分は 11 日の +0.0226 から 60 日の +0.0006 まで小さくなっています。

残差エネルギーの z も計算する

① の設定(履歴 300 時点)で、ノード別モデルの残差について式 (F.2)(F.3) の $z$ を計算しました(並べ替えは 500 回)。protocol.py と同じフォルダで実行します。

residual_z.py
"""式 (F.2)(F.3): 残差がグラフの辺に沿ってそろっているかを、ノードの付け替えで較正した z で測る"""
import numpy as np

from protocol import N, feats, fit_predict, load, make_graph, rewire, simulate

rng = np.random.default_rng(1)
pos = rng.random((N, 2))
A = make_graph(pos, rng)
A_rw = rewire(A, rng)
coef = np.stack([1 + 0.2 * rng.normal(size=N), 0.5 + 0.2 * rng.normal(size=N)])
c_tr, x_tr = simulate(pos, 0.7, 1.0, 300, rng)
c_te, x_te = simulate(pos, 0.7, 1.0, 2000, rng)
y_tr, y_te = load(c_tr, coef), load(c_te, coef)
R = y_te - fit_predict(feats(x_tr), y_tr, feats(x_te), shared=False)   # ノードごとのモデルの残差 (T, N)


def norm_laplacian(Adj):
    dinv = 1 / np.sqrt(Adj.sum(1))
    return np.eye(N) - dinv[:, None] * Adj * dinv[None]


def energy(R, Lap):
    return np.einsum("ti,ij,tj->", R, Lap, R) / (R ** 2).sum()


for name, Adj in [("元のグラフ", A), ("リワイヤ後", A_rw)]:
    Lap = norm_laplacian(Adj)
    s = energy(R, Lap)
    perm = [energy(R[:, rng.permutation(N)], Lap) for _ in range(500)]
    print(f"{name}: s(r) = {s:.3f}, 付け替えの平均 = {np.mean(perm):.3f}, z = {(np.mean(perm) - s) / np.std(perm):.1f}")
元のグラフ: s(r) = 0.767, 付け替えの平均 = 0.956, z = 32.4
リワイヤ後: s(r) = 0.946, 付け替えの平均 = 0.955, z = 1.6

元のグラフでは残差が辺に沿ってはっきりそろっていて($z = 32.4$)、リワイヤすると偶然と区別できない程度($z = 1.6$)まで下がります。論文の表 F.1 と同じ傾向です。

この合成データはノイズ除去が効くように作ったものなので、近傍が効くのは当然です。論文も合成実験について「近傍が役立つ仕組みの 1 つを示しただけで、実データでの境界を決めるものではない」と書いています。

論文の実験結果

Q1: GNN はグラフなしのモデルに勝つか

8 種類の GNN(GCN、GraphSAGE、APPNP、GAT、GATv2、TransformerConv、ChebConv、TAGConv)を、Optuna で 40 試行ずつ調整して比べています。論文の表 2 から一部を抜き出します(GNN は 3 シードの平均、括弧内は標準偏差)。

モデル regions-fr RMSE(MW) weave-uk RMSE(kWh) weave-uk MAPE(%)
XGBoost(全ノードまとめて学習) 859(0.4) 263.5(1.1) 7.67
ノード別 MLP 922(10) 325.2(9.3) 9.41
共有 MLP 937(26) 236.5(15.4) 6.62
t0-gamma(基盤モデル、ゼロショット) 1724 144.6 3.51
GCN 891(26) 161.9(15.5) 4.14
GATv2 923(32) 140.6(15.8) 3.56
APPNP 986(27) 142.8(5.4) 3.81
  • regions-fr: どの GNN も、全ノードをまとめて学習した XGBoost(859 MW)に届きません。ただし、8 種類の GNN をノードごとに混ぜた予測は 809 MW で、表 2 のどのモデルよりよい値です(論文の表 3)。
  • weave-uk: 最良のベースラインは時系列の基盤モデル t0-gamma(144.6 kWh)です。それより平均 RMSE が低い GNN は GATv2 と APPNP の 2 つだけで、差はシード間の標準偏差より小さいです。
  • 次数 1 で近傍を伝えない ChebConv($K = 1$)でも 167.3 kWh に達していて、Chronos-2(170.2 kWh)と同じくらいです。近傍を読まなくても、GNN の骨格だけでこの水準に届いています。

Q2・Q3: 改善はグラフから来ているか

electricity と weave-uk で、学習期間をわざと短くして(electricity は 11 日と 60 日、weave-uk は 30 時間と 10 日)、全手順を走らせています。論文の表 4 の nRMSE です(3 シードの平均)。

モデル electricity 11 日 electricity 60 日 weave-uk 30 時間 weave-uk 10 日
ノード別 MLP 0.3030 0.2776 1.0509 0.8956
ノード別 MLP+近傍平均 0.3201 0.2752 0.9995 0.8762
共有 MLP 0.2804 0.2770 0.9074 0.8013
全ノード MLP 0.4527 0.4336 1.0609 0.8787
空グラフ GNN 0.2827 0.2797 0.8894 0.7941
GNN 0.2775 0.2752 0.8808 0.7745

この表から骨格と辺の増分を計算すると、次のようになります(論文の本文の値と、丸めの範囲で一致します)。

electricity 11 日 electricity 60 日 weave-uk 30 時間 weave-uk 10 日
骨格の増分 $E_{\mathrm{sh}} - E_{\varnothing}$ −0.0023 −0.0027 +0.0180 +0.0072
骨格を固定した辺の増分 $E_{\varnothing} - E_{\mathrm{gr}}$ +0.0052 +0.0045 +0.0086 +0.0196

論文の主な結論は次のとおりです。

  • weave-uk では共有が主役です。ノード別 MLP から GNN までの差のうち 78〜84% を、近傍を読まない共有 MLP が埋めています。ノード別のリッジ回帰(正則化した、より強い参照モデル)と比べても、共有 MLP は差の 63% と 71% を埋めます。要旨の「約 3 分の 2」はこの値です。
  • 辺の効果は小さいが、weave-uk では一貫してある: 骨格を固定した辺の増分は +0.0086 と +0.0196 で、共有の増分の 6〜21% ほどです。ノードを 5,000 回復元抽出した 95% 区間は 0 を含みません(30 時間で [+0.0052, +0.0120])。
  • リワイヤすると、その半分以上が消える: 各辺を 1 回だけ数えた単純グラフでの辺の増分 +0.0069 と +0.0191 のうち、次数を保ったリワイヤで 52%(0.0036)と 93%(0.0178)が消えました。weave-uk では隣が「誰か」が効いています。
  • electricity では辺の効果は 0 と区別できない: 辺の増分は +0.005 程度以下です。シードのばらつきも含めて再標本化すると区間が 0 を含み、60 日の窓で各辺を 1 回だけ数えると +0.0002 まで小さくなります。
  • ノード別モデルに近傍平均を足す効果は、データしだい: weave-uk では差の 16〜30% を埋めますが、リワイヤしたグラフでもその 98% と 71% が再現されます。つまり「誰が隣か」より「入力が増えたこと」による部分が大きいです。electricity の 11 日窓では悪化しました(−0.0171)。

kNN グラフの細かい点にも触れておきます。論文の GNN は $\mathbf{A} = \mathbf{A}_{\mathrm{kNN}} + \mathbf{A}_{\mathrm{kNN}}^\top$ を受け取るので、お互いに相手を近傍に選んだペアの辺は重みが 2 倍になります。リワイヤ後のグラフは単純グラフなので、リワイヤとの比較は $\max(\mathbf{A}_{\mathrm{kNN}}, \mathbf{A}_{\mathrm{kNN}}^\top)$ の単純グラフに対して行っています。どちらのグラフで測るかで辺の増分の値が変わる点(例: electricity 60 日で +0.0046 → +0.0002)に注意が必要です。

Q4: 近傍が役立つのはいつか

論文の合成実験(6.3 節)では、GNN がほぼすべての設定で最良かそれに近い誤差でした。

  • ノード数を 12 から 250 に増やすと、GNN の誤差は約 0.38 から 0.29 に下がります。全ノード MLP は 0.66 から 1.13 に悪化します。全ノードを無制限に読めばよいわけではありません。
  • 学習データを 5% にしても GNN は約 0.40 で、ノード別 MLP は 0.85、全ノード MLP は 1.7 以上です。
  • 気温場の相関 $\rho = 0$ ではノード別 MLP との差が約 0.02 まで縮み、観測ノイズ $\sigma_{\mathrm{obs}} = 0$ では共有 MLP(恒等グラフ)が GNN に並びます。

実データでは、近傍の寄与はデータと学習期間によって違い、手順を走らせてその都度測るしかない、というのが論文の結論です。

補足:オンラインで予測を混ぜる実験

探索的な分析として、6 つの GNN、共有 MLP、一般化加法モデル(GAM)の 8 つの予測を、ML-Poly というオンラインの重み付け法で混ぜています。regions-fr の 1 時点ずつの予測では、混ぜた予測は 970 MW で、最良の単独モデルより 279 MW よくなりました。GNN をすべて抜くと 132 MW、GNN 以外の 2 つを抜くと 117 MW 悪化するので、2 つの系統は補い合っています。

一方で、重みは貢献に比例しませんでした。近傍に使える情報がない合成データでも GNN が重みの 0.86 を占めました(数の上での割合は 0.75)。それでも GNN を抜いたときの悪化は 34 MW(誤差の 4%)だけでした。「混ぜたときに重みが大きい=グラフが効いている」とは読めない、ということです。この分析は古い設定、別の分割、1 シードで行われていて、論文も記述的な結果として扱っています。

限界と注意点

論文自身が挙げている限界と、読むときに気をつけたい点です。

  • 比較はシード 3 つの平均です。ばらつきと同じくらいの差は順位として主張していません。
  • 固定した設定での比較です。分解はモデルごとに調整していない、隠れ層の幅 64 の固定設定で行っています。regions-fr では調整するとノード別 MLP と GNN の順位が入れ替わりました。幅を 32・64・128 に変えても weave-uk の増分の符号は変わりませんでしたが、electricity の 60 日窓では符号が変わります。
  • 幅をそろえても容量はそろっていない: ノード別 MLP のパラメータは weave-uk で約 342 万、GNN は約 2.4 万〜3.2 万で、深さも違います。容量をそろえた比較は、空グラフ GNN との比較(骨格を固定した辺の増分)だけです。
  • データの期間が限られる: weave-uk は 1 つの配電事業者の 1 週間分、electricity は 2019 年の 11 日の窓 5 つと最後の 60 日です。季節や学習期間の長さが違うと、割合は変わりうると論文は書いています。
  • 気象は実測値です。実際の前日予測では気象予報を使うのでノイズが入ります。合成実験からは、そのノイズで近傍の価値が上がる可能性がありますが、試されていません。
  • グラフの作り方は固定: 分解に使った GNN は相関の強い 5 ノードを残す kNN グラフです。グラフを学習する MTGNN も付録で試していますが、固定グラフの GNN を上回りませんでした。30 時間の窓の中でグラフを推定し直すと、辺の増分は 2 倍になったとも報告されています。
  • この記事の合成実験は線形モデルです。論文の GNN(残差ブロックつきの 2 層)や生成器(実際の地域の気温→需要のスプライン)とは違います。手順の考え方を確かめるためのもので、論文の数値を再現したものではありません。

まとめ

  • GNN は「全ノードでの重みの共有」と「近傍の参照」を同時に行うので、ノード別モデルより精度が上がっても、それだけではグラフの手柄とは言えません。
  • 論文は、ノード別モデル、共有モデル、空グラフ GNN の 3 つと比べ、誤差の差を 2 つの経路で分解する手順(式 4)と、次数を保ったリワイヤリングで「隣が誰か」を確かめる手順を提案しています。
  • weave-uk では差の 78〜84% を共有だけで埋めていて、骨格を固定した辺の効果は共有の 6〜21% 程度、その半分以上がリワイヤで消えました。electricity では辺の効果は 0 と区別できませんでした。
  • 合成データの再現では、近傍が効くのは「観測にノイズがあり、隣と相関があるとき」で、履歴が短いときは共有の効果が大きく、履歴が長くなると共有の効果は消えました。
  • GNN を導入・保守するか決めるときは、少なくとも「共有 MLP」と「同じ骨格で辺を外したモデル」を参照モデルとして並べるのがよさそうです。

参考

本記事は 2026年10月時点の情報に基づいています。

0
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
0
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?