グラフニューラルネットワーク(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 になります。
この等式を数値で確かめます。
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 つの参照モデルと比べます。
- ノード別モデル: 同じ特徴量でノードごとに別々に学習したモデル。グラフが説明すべき差の合計を与えます。
- 共有モデル(恒等グラフ): 全ノードで重みを共有し、近傍は読まないモデル。共有したうえで辺がどれだけ効くかを測ります。
- 空グラフ 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) を計算してみます。
"""論文の図 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(全文)
"""論文 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 と同じフォルダで実行します。
"""式 (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」と「同じ骨格で辺を外したモデル」を参照モデルとして並べるのがよさそうです。
参考
- A Diagnostic Protocol for GNNs in Electricity Load Forecasting: Separating Parameter Sharing from Neighborhood Access(Campagne, Amara-Ouali, Goude, Zehavi, Kalogeratos, arXiv, 2026)
- GraphToolbox: A Configurable Python Framework for Graph Neural Network Forecasting(Campagne ほか, arXiv, 2026)
- Principles and Algorithms for Forecasting Groups of Time Series: Locality and Globality(Montero-Manso, Hyndman, arXiv, 2020)
- Spatial-Temporal Identity: A Simple yet Effective Baseline for Multivariate Time Series Forecasting(Shao ほか, arXiv, 2022)
- Taming Local Effects in Graph-based Spatiotemporal Forecasting(Cini ほか, arXiv, 2023)
- Inductive Representation Learning on Large Graphs(Hamilton, Ying, Leskovec, arXiv, 2017)
- Weave(weave-uk のデータの公開元)
本記事は 2026年10月時点の情報に基づいています。