ガウス過程(Gaussian Process)は柔軟なノンパラメトリックモデルですが、データ数 $N$ に対して予測分布の計算量が $O(N^3)$ になるという欠点があります。$N$ が数千を超えると、実用的な計算時間で推論を行うことが困難になります。
疎ガウス過程(Sparse Gaussian Processes)は、この計算量の問題を解決するための手法です。少数の「誘導点(inducing points)」を導入し、計算量を $O(NM^2)$($M \ll N$)に削減します。
本記事では、疎ガウス過程の基本的なアイデアから数学的な導出、Pythonでの実装までを解説します。
本記事の内容
- 標準ガウス過程の計算量の問題
- 誘導点による近似の考え方
- 変分疎ガウス過程(SVGP)の導出
- Pythonでの実装と比較
前提知識
この記事を読む前に、以下の記事を読んでおくと理解が深まります。
標準ガウス過程の計算量
まず、標準的なガウス過程回帰の計算量を確認しましょう。訓練データ $\bm{X} = \{x_1, \dots, x_N\}$ と観測値 $\bm{y} = \{y_1, \dots, y_N\}$ が与えられたとき、新しい入力 $x_*$ に対する予測分布は次のようになります。
$$ \begin{align} \mu_* &= \bm{k}_*^T (\bm{K} + \sigma_n^2 \bm{I})^{-1} \bm{y} \\ \sigma_*^2 &= k_{**} – \bm{k}_*^T (\bm{K} + \sigma_n^2 \bm{I})^{-1} \bm{k}_* \end{align} $$
ここで $\bm{K}$ は $N \times N$ のグラム行列です。この逆行列の計算に $O(N^3)$ の計算量がかかります。
| データ数 $N$ | 計算量のオーダー | 実用性 |
|---|---|---|
| 100 | $10^6$ | 問題なし |
| 1,000 | $10^9$ | やや重い |
| 10,000 | $10^{12}$ | 非常に重い |
| 100,000 | $10^{15}$ | 事実上不可能 |
誘導点による近似
疎ガウス過程の基本的なアイデアは、$N$ 個の訓練データ全てを使う代わりに、$M$ 個($M \ll N$)の誘導点(inducing points) $\bm{Z} = \{z_1, \dots, z_M\}$ を導入することです。
イメージとしては、大量のデータの特徴を少数の代表的な点で要約するということです。誘導点での関数値 $\bm{u} = f(\bm{Z})$ を介して予測を行うことで、計算量を $O(NM^2)$ に削減します。
誘導点の位置 $\bm{Z}$ はハイパーパラメータとして最適化の対象になります。
変分疎ガウス過程(SVGP)の導出
変分疎ガウス過程(Stochastic Variational Gaussian Process, SVGP)は、変分推論の枠組みで疎ガウス過程を定式化する手法です。
同時分布
ガウス過程の事前分布のもとで、訓練データでの関数値 $\bm{f}$ と誘導点での関数値 $\bm{u}$ の同時分布は次のようになります。
$$ \begin{equation} p(\bm{f}, \bm{u}) = p(\bm{f}|\bm{u}) \, p(\bm{u}) \end{equation} $$
ここで、
$$ \begin{align} p(\bm{u}) &= \mathcal{N}(\bm{u} | \bm{0}, \bm{K}_{MM}) \\ p(\bm{f}|\bm{u}) &= \mathcal{N}(\bm{f} | \bm{K}_{NM}\bm{K}_{MM}^{-1}\bm{u}, \, \tilde{\bm{K}}) \end{align} $$
ただし $\bm{K}_{MM}$ は誘導点間のカーネル行列($M \times M$)、$\bm{K}_{NM}$ は訓練データと誘導点間のカーネル行列($N \times M$)、$\tilde{\bm{K}} = \bm{K}_{NN} – \bm{K}_{NM}\bm{K}_{MM}^{-1}\bm{K}_{MN}$ です。
変分下界(ELBO)
誘導点の変分分布 $q(\bm{u}) = \mathcal{N}(\bm{u}|\bm{m}, \bm{S})$ を導入します。ここで $\bm{m}$ は $M$ 次元の平均ベクトル、$\bm{S}$ は $M \times M$ の共分散行列です。
ELBOは次のように導出されます。
$$ \begin{equation} \ln p(\bm{y}) \geq \text{ELBO} = \sum_{i=1}^{N} E_{q(f_i)}\left[\ln p(y_i|f_i)\right] – D_{\text{KL}}(q(\bm{u}) \| p(\bm{u})) \end{equation} $$
ここで $q(f_i)$ は次のように計算されます。
$$ \begin{equation} q(f_i) = \int p(f_i|\bm{u}) \, q(\bm{u}) \, d\bm{u} = \mathcal{N}(f_i | \mu_i, \sigma_i^2) \end{equation} $$
$$ \begin{align} \mu_i &= \bm{k}_i^T \bm{K}_{MM}^{-1} \bm{m} \\ \sigma_i^2 &= k_{ii} – \bm{k}_i^T \bm{K}_{MM}^{-1}(\bm{K}_{MM} – \bm{S})\bm{K}_{MM}^{-1}\bm{k}_i \end{align} $$
ただし $\bm{k}_i = \bm{K}_{Mi}$ は誘導点と $x_i$ 間のカーネルベクトルです。
ガウス尤度の場合
尤度が $p(y_i|f_i) = \mathcal{N}(y_i|f_i, \sigma_n^2)$ の場合、期待値が解析的に計算できます。
$$ \begin{equation} E_{q(f_i)}[\ln p(y_i|f_i)] = -\frac{1}{2}\ln(2\pi\sigma_n^2) – \frac{1}{2\sigma_n^2}\left((y_i – \mu_i)^2 + \sigma_i^2\right) \end{equation} $$
予測分布
学習後の予測分布は、新しい入力 $x_*$ に対して次のようになります。
$$ \begin{align} \mu_* &= \bm{k}_*^T \bm{K}_{MM}^{-1} \bm{m} \\ \sigma_*^2 &= k_{**} – \bm{k}_*^T \bm{K}_{MM}^{-1}(\bm{K}_{MM} – \bm{S})\bm{K}_{MM}^{-1}\bm{k}_* \end{align} $$
計算量は $O(M^2)$ であり、$M$ が小さければ高速に予測できます。
Pythonでの実装
疎ガウス過程をスクラッチで実装し、標準ガウス過程と比較します。
import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import minimize
np.random.seed(42)
# RBFカーネル
def rbf_kernel(X1, X2, length_scale=1.0, variance=1.0):
"""RBFカーネル行列を計算"""
sq_dist = np.sum(X1**2, axis=1, keepdims=True) - 2 * X1 @ X2.T + np.sum(X2**2, axis=1)
return variance * np.exp(-0.5 * sq_dist / length_scale**2)
# 標準ガウス過程回帰
def gp_predict(X_train, y_train, X_test, noise=0.1, length_scale=1.0):
"""標準GPの予測"""
N = len(X_train)
K = rbf_kernel(X_train, X_train, length_scale) + noise**2 * np.eye(N)
K_s = rbf_kernel(X_train, X_test, length_scale)
K_ss = rbf_kernel(X_test, X_test, length_scale)
L = np.linalg.cholesky(K)
alpha = np.linalg.solve(L.T, np.linalg.solve(L, y_train))
mu = K_s.T @ alpha
v = np.linalg.solve(L, K_s)
var = np.diag(K_ss) - np.sum(v**2, axis=0)
return mu, var
# 疎ガウス過程(SVGP)
class SparseGP:
def __init__(self, X_inducing, noise=0.1, length_scale=1.0):
self.Z = X_inducing # 誘導点
self.M = len(X_inducing)
self.noise = noise
self.length_scale = length_scale
# 変分パラメータ
self.m = np.zeros(self.M)
self.S = np.eye(self.M)
def fit(self, X_train, y_train, n_iter=100, lr=0.01):
"""ELBO最大化による学習"""
N = len(X_train)
M = self.M
elbo_history = []
for it in range(n_iter):
# カーネル行列の計算
Kmm = rbf_kernel(self.Z, self.Z, self.length_scale) + 1e-6 * np.eye(M)
Knm = rbf_kernel(X_train, self.Z, self.length_scale)
L_mm = np.linalg.cholesky(Kmm)
Kmm_inv = np.linalg.solve(L_mm.T, np.linalg.solve(L_mm, np.eye(M)))
# q(f_i)の平均と分散
A = Knm @ Kmm_inv # N x M
mu_f = A @ self.m # N
# 各データ点の分散
Knn_diag = np.ones(N) # variance=1のRBFカーネルの対角要素
AS = A @ self.S
var_f = Knn_diag - np.sum(A * (Knm @ Kmm_inv), axis=1) + np.sum(AS * A, axis=1)
var_f = np.maximum(var_f, 1e-6)
# ELBO計算
# 尤度項
like_term = -0.5 * N * np.log(2 * np.pi * self.noise**2) \
- 0.5 / self.noise**2 * (np.sum((y_train - mu_f)**2) + np.sum(var_f))
# KLダイバージェンス
L_S = np.linalg.cholesky(self.S + 1e-6 * np.eye(M))
kl = 0.5 * (np.trace(Kmm_inv @ self.S)
+ self.m @ Kmm_inv @ self.m
- M
+ np.sum(np.log(np.diag(L_mm))) * 2
- np.sum(np.log(np.diag(L_S))) * 2)
elbo = like_term - kl
elbo_history.append(elbo)
# 自然勾配に基づく更新
Lambda = A.T @ A / self.noise**2 + Kmm_inv
self.S = np.linalg.inv(Lambda + 1e-6 * np.eye(M))
self.m = self.S @ (A.T @ y_train / self.noise**2)
return elbo_history
def predict(self, X_test):
"""予測分布の計算"""
M = self.M
Kmm = rbf_kernel(self.Z, self.Z, self.length_scale) + 1e-6 * np.eye(M)
Ksm = rbf_kernel(X_test, self.Z, self.length_scale)
Kmm_inv = np.linalg.inv(Kmm)
A = Ksm @ Kmm_inv
mu = A @ self.m
Kss_diag = np.ones(len(X_test))
var = Kss_diag - np.sum(A * (Ksm @ Kmm_inv), axis=1) + np.sum((A @ self.S) * A, axis=1)
var = np.maximum(var, 1e-6)
return mu, var
# データ生成
N = 500
X_train = np.sort(np.random.uniform(-5, 5, N)).reshape(-1, 1)
y_true = np.sin(X_train.ravel()) + 0.5 * np.cos(2 * X_train.ravel())
y_train = y_true + np.random.normal(0, 0.3, N)
X_test = np.linspace(-6, 6, 200).reshape(-1, 1)
# 標準GP
mu_full, var_full = gp_predict(X_train, y_train, X_test, noise=0.3)
# 疎GP(誘導点数 M=20)
M = 20
Z = np.linspace(-5, 5, M).reshape(-1, 1)
sgp = SparseGP(Z, noise=0.3, length_scale=1.0)
elbo_hist = sgp.fit(X_train, y_train, n_iter=50)
mu_sparse, var_sparse = sgp.predict(X_test)
# 可視化
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
# 左: 標準GP
axes[0].scatter(X_train, y_train, s=3, alpha=0.3, color='gray', label='Training data')
axes[0].plot(X_test, mu_full, 'b-', linewidth=2, label='Full GP mean')
axes[0].fill_between(X_test.ravel(),
mu_full - 2*np.sqrt(var_full),
mu_full + 2*np.sqrt(var_full),
alpha=0.2, color='blue')
axes[0].set_title(f'Full GP (N={N})')
axes[0].set_xlabel('x')
axes[0].set_ylabel('y')
axes[0].legend()
# 中央: 疎GP
axes[1].scatter(X_train, y_train, s=3, alpha=0.3, color='gray', label='Training data')
axes[1].plot(X_test, mu_sparse, 'r-', linewidth=2, label='Sparse GP mean')
axes[1].fill_between(X_test.ravel(),
mu_sparse - 2*np.sqrt(var_sparse),
mu_sparse + 2*np.sqrt(var_sparse),
alpha=0.2, color='red')
axes[1].scatter(Z, np.zeros(M), marker='^', s=100, color='green',
zorder=5, label=f'Inducing points (M={M})')
axes[1].set_title(f'Sparse GP (M={M})')
axes[1].set_xlabel('x')
axes[1].set_ylabel('y')
axes[1].legend()
# 右: ELBO推移
axes[2].plot(elbo_hist, 'o-', markersize=3, color='purple')
axes[2].set_xlabel('Iteration')
axes[2].set_ylabel('ELBO')
axes[2].set_title('ELBO Convergence')
plt.tight_layout()
plt.show()
左のグラフは標準ガウス過程($N=500$ のデータ全てを使用)、中央は疎ガウス過程($M=20$ の誘導点のみ使用)の結果です。緑の三角が誘導点の位置を示しています。少数の誘導点でも標準GPに近い予測が得られることがわかります。
まとめ
本記事では、疎ガウス過程(Sparse Gaussian Processes)について解説しました。
- 標準ガウス過程は計算量 $O(N^3)$ が問題となり、大規模データに適用できない
- 疎ガウス過程は $M$ 個の誘導点を導入し、計算量を $O(NM^2)$ に削減する
- 変分疎ガウス過程(SVGP)はELBOの最大化として定式化され、誘導点の位置も最適化できる
- 予測分布は $O(M^2)$ で計算でき、誘導点数が少なくても標準GPに近い精度が得られる
次のステップとして、以下の記事も参考にしてください。