PyMCで学ぶ確率的プログラミング — ベイズモデリングの実践入門

ベイズ統計の理論を学ぶと、事後分布を解析的に計算できるのは共役事前分布を使える限られたケースだけであり、実際に使いたいモデルの多くでは解析解が存在しないことに気づきます。そこでMCMC(マルコフ連鎖モンテカルロ法)の出番ですが、メトロポリス・ヘイスティングス法やギブスサンプリングを手動で実装するのは、提案分布の設計やバーンイン期間の調整など、モデルの本質とは関係ない技術的な苦労が多く、非効率です。

「モデルの構造を宣言するだけで、推論アルゴリズムは自動的に適用される」 — これが確率的プログラミング(Probabilistic Programming)の思想です。ユーザーは確率モデルを定義し、データを渡すだけで、事後分布のサンプリングや予測分布の計算が自動的に行われます。

PyMCはPython向けの確率的プログラミングライブラリで、直感的なAPIでベイズモデルを記述し、高性能なNUTSサンプラーで効率的にMCMCサンプリングを行います。2023年にPyMC3からPyMC(v5系)へとメジャーアップデートされ、バックエンドがTheanoからPyTensorに移行しました。

確率的プログラミングとPyMCを理解すると、以下のような応用が開けます。

  • 複雑なベイズモデルの構築: 階層ベイズモデル、混合モデル、非線形回帰など、解析解が存在しないモデルを気軽に試せる
  • 自動微分によるサンプリング: NUTSサンプラーが自動微分で勾配を計算し、高次元パラメータ空間でも効率的にサンプリングする
  • モデルの診断と比較: ArviZとの連携により、収束診断やモデル比較が標準化された方法で行える
  • 再現可能な研究: モデルの定義がコードとして残るため、科学的な再現性が保証される

本記事の内容

  • 確率的プログラミングの思想と利点
  • PyMCの基本構文
  • コイン投げモデル(Beta-Binomial)
  • 線形回帰モデル
  • 階層ベイズモデル(8学校問題)
  • 収束診断(R-hat, ESS, トレースプロット)
  • ArviZによる可視化

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

確率的プログラミングとは

手動MCMCの限界

ベイズ推論の手順は明確です。(1) モデル(尤度関数)を定義する、(2) パラメータの事前分布を設定する、(3) データを観測して事後分布を計算する。しかし、(3) の計算が問題です。

共役事前分布を使えるケースでは、事後分布が解析的に求まります。たとえば、正規分布の平均の推定で正規事前分布を使えば、事後分布も正規分布になります。しかし、現実のモデルでは共役事前分布が存在しないことが多く、MCMCに頼ることになります。

手動でMCMCを実装する場合の典型的な苦労は以下の通りです。

  1. 提案分布の設計: メトロポリス・ヘイスティングス法では、提案分布の分散を適切に設定する必要がある。小さすぎると探索が遅く、大きすぎると受容率が下がる
  2. パラメータ間の相関への対処: パラメータが強く相関する場合、単純なランダムウォークでは非効率。ブロック更新やギブスサンプリングの設計が必要
  3. 収束の確認: バーンイン期間の決定、複数チェーンの一致の確認など
  4. 実装のバグ: 対数尤度の計算ミス、制約付きパラメータの変換忘れなど

これらの技術的な作業は、モデルの科学的な中身とは無関係であり、研究者やデータサイエンティストの貴重な時間を浪費します。

確率的プログラミングの思想

確率的プログラミングはこの問題を、モデル記述と推論アルゴリズムの分離によって解決します。

ユーザーが行うのは以下だけです。

  1. 確率モデルを宣言的に記述する(事前分布、尤度関数の定義)
  2. データを渡す
  3. 推論を実行するコマンドを呼ぶ

推論エンジン(PyMCの場合はNUTSサンプラー)が自動的に以下を処理します。

  • パラメータ空間の変換(正の値のパラメータをlog変換するなど)
  • 勾配の計算(自動微分)
  • ステップサイズの調整(デュアル平均法)
  • ウォームアップ(バーンイン)期間の設定
  • 並列チェーンの実行

これは、SQLがデータの「何を取得するか」を宣言的に記述し、クエリの「どのように実行するか」をデータベースエンジンに任せるのと同じ設計思想です。

PyMCの位置づけ

確率的プログラミング言語・ライブラリはいくつかありますが、主要なものを比較すると以下のようになります。

ライブラリ 言語 バックエンド 特徴
PyMC Python PyTensor Pythonic、NUTSサンプラー、活発なコミュニティ
Stan 独自DSL(Rやから呼び出し) C++ 高速、産業界で広く利用
NumPyro Python JAX 高速、GPU対応、関数型API
TensorFlow Probability Python TensorFlow 深層学習との統合が容易

PyMCの最大の強みは、Pythonらしい直感的なAPIと、ArviZとのシームレスな連携による充実した診断・可視化機能です。

確率的プログラミングの思想を理解したところで、PyMCの具体的な構文を見ていきましょう。

PyMCの基本構文

モデルの定義

PyMCでは、pm.Model() のコンテキストマネージャ内に確率変数を定義します。

import pymc as pm
import numpy as np

# データ
observed_data = np.array([1, 0, 1, 1, 0, 1, 0, 1, 1, 1])

with pm.Model() as model:
    # 事前分布
    theta = pm.Beta("theta", alpha=1, beta=1)

    # 尤度(データの生成モデル)
    y = pm.Bernoulli("y", p=theta, observed=observed_data)

    # MCMCサンプリング
    trace = pm.sample(2000, tune=1000, cores=2, random_seed=42)

このコードの各行を解説します。

  • pm.Model(): モデルコンテキストを作成します。この中で定義された全ての確率変数は自動的にモデルに登録されます
  • pm.Beta("theta", ...): パラメータ $\theta$ の事前分布をBeta(1,1)(一様分布)と定義します。"theta" は変数名で、後の分析で使用します
  • pm.Bernoulli("y", ..., observed=...): 尤度関数を定義します。observed にデータを渡すことで、これが観測された確率変数であることを示します
  • pm.sample(...): NUTSサンプラーでMCMCサンプリングを実行します。tune はウォームアップ期間、cores は並列チェーン数です

observed キーワードが確率的プログラミングの鍵です。observed なしの確率変数は推論対象のパラメータ(潜在変数)であり、observed ありの変数はデータとして固定されます。PyMCは自動的に「観測されていないパラメータの事後分布をサンプリングする」という推論問題を設定します。

確率分布の種類

PyMCには多数の確率分布が用意されています。よく使うものを以下にまとめます。

分布 PyMCコード 用途
正規分布 pm.Normal("x", mu=0, sigma=1) 連続パラメータの事前分布・尤度
半正規分布 pm.HalfNormal("s", sigma=1) 正のパラメータ(標準偏差など)
ベータ分布 pm.Beta("p", alpha=1, beta=1) 確率パラメータ
ガンマ分布 pm.Gamma("lam", alpha=2, beta=1) 正のパラメータ(精度など)
一様分布 pm.Uniform("u", lower=0, upper=10) 範囲が限定されたパラメータ
Student-t分布 pm.StudentT("t", nu=3, mu=0, sigma=1) ロバストな尤度(外れ値対応)

PyMCの基本構文を押さえたところで、具体的なモデルを段階的に構築していきましょう。最初は最もシンプルなコイン投げモデルから始めます。

例題1: コイン投げモデル(Beta-Binomial)

問題設定

コインを20回投げて14回表が出ました。このコインの表が出る確率 $\theta$ の事後分布を推定しましょう。

このモデルは共役事前分布を使えるので、解析解との比較が可能です。Beta(1,1) 事前分布の下で14回表、6回裏を観測すると、事後分布は $\text{Beta}(1 + 14, 1 + 6) = \text{Beta}(15, 7)$ となるはずです。

PyMCでの実装

import pymc as pm
import arviz as az
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats

# データ
n_flips = 20
n_heads = 14
data = np.concatenate([np.ones(n_heads), np.zeros(n_flips - n_heads)])

# PyMCモデル
with pm.Model() as coin_model:
    # 事前分布: Beta(1, 1) = 一様分布
    theta = pm.Beta("theta", alpha=1, beta=1)

    # 尤度: ベルヌーイ分布
    y = pm.Bernoulli("y", p=theta, observed=data)

    # MCMCサンプリング
    trace = pm.sample(3000, tune=1000, cores=2, random_seed=42,
                      return_inferencedata=True)

# 結果の可視化
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# 左: 事後分布 vs 解析解
ax = axes[0]
theta_samples = trace.posterior["theta"].values.flatten()
ax.hist(theta_samples, bins=50, density=True, alpha=0.5, color='blue',
        label='MCMCサンプル')

x = np.linspace(0, 1, 200)
analytic_posterior = stats.beta(15, 7)
ax.plot(x, analytic_posterior.pdf(x), 'r-', linewidth=2,
        label='解析解 Beta(15, 7)')
ax.set_xlabel(r'$\theta$')
ax.set_ylabel('密度')
ax.set_title('事後分布: MCMC vs 解析解')
ax.legend(fontsize=11)

# 右: トレースプロット
ax = axes[1]
for chain in range(trace.posterior.dims['chain']):
    chain_samples = trace.posterior["theta"].values[chain, :]
    ax.plot(chain_samples, alpha=0.5, linewidth=0.5, label=f'Chain {chain}')
ax.set_xlabel('サンプル番号')
ax.set_ylabel(r'$\theta$')
ax.set_title('トレースプロット')
ax.legend(fontsize=11)

plt.tight_layout()
plt.savefig("pymc_coin_flip.png", dpi=150, bbox_inches="tight")
plt.show()

# 要約統計量
print(az.summary(trace, var_names=["theta"]))

上のグラフから、以下の重要なことが確認できます。

  1. 事後分布のヒストグラム(左): MCMCサンプルから得られた事後分布は、解析解 Beta(15, 7) とほぼ完全に一致しています。PyMCのNUTSサンプラーが正確に事後分布をサンプリングしていることが確認できます
  2. トレースプロット(右): 2つのチェーンが同じ領域を十分に探索しており、収束していることが視覚的に確認できます。特定の値に長時間滞留することなく、事後分布全体を均等に探索しています

この結果から、PyMCが共役モデルの解析解を正確に再現することが確認できました。次に、解析解が得られない問題でPyMCの真価を発揮する線形回帰モデルに進みましょう。

例題2: ベイズ線形回帰

問題設定

ノイズを含むデータに直線を当てはめるベイズ線形回帰を実装します。パラメータ(傾き、切片、ノイズ標準偏差)の事後分布を推定します。

PyMCでの実装

import pymc as pm
import arviz as az
import numpy as np
import matplotlib.pyplot as plt

# データ生成
np.random.seed(42)
N = 50
true_intercept = 1.5
true_slope = 2.3
true_sigma = 0.8

x = np.random.uniform(0, 5, N)
y = true_intercept + true_slope * x + np.random.normal(0, true_sigma, N)

# PyMCモデル
with pm.Model() as linear_model:
    # 事前分布
    intercept = pm.Normal("intercept", mu=0, sigma=10)
    slope = pm.Normal("slope", mu=0, sigma=10)
    sigma = pm.HalfNormal("sigma", sigma=5)

    # 線形モデル
    mu = intercept + slope * x

    # 尤度
    y_obs = pm.Normal("y_obs", mu=mu, sigma=sigma, observed=y)

    # サンプリング
    trace = pm.sample(3000, tune=1000, cores=2, random_seed=42,
                      return_inferencedata=True)

# 結果の表示
print(az.summary(trace, var_names=["intercept", "slope", "sigma"]))

pm.HalfNormal は正の値のみをとる半正規分布で、標準偏差のように正の値でなければならないパラメータの事前分布として適しています。PyMCは自動的にパラメータ空間の変換(log変換など)を行い、NUTSサンプラーが効率的に動作するようにします。

要約統計量を見ると、各パラメータの事後平均が真の値(intercept=1.5, slope=2.3, sigma=0.8)に近いことが確認できるはずです。

# 事後予測分布の可視化
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# 左: 回帰直線の不確実性
ax = axes[0]
ax.scatter(x, y, c='gray', s=20, alpha=0.6, label='データ')

# 事後サンプルから回帰直線を描画
x_plot = np.linspace(0, 5, 100)
intercept_samples = trace.posterior["intercept"].values.flatten()
slope_samples = trace.posterior["slope"].values.flatten()

# ランダムに100本の回帰直線を描画
idx = np.random.choice(len(intercept_samples), 100, replace=False)
for i in idx:
    ax.plot(x_plot, intercept_samples[i] + slope_samples[i] * x_plot,
            'b-', alpha=0.03, linewidth=1)

# 事後平均の回帰直線
ax.plot(x_plot, np.mean(intercept_samples) + np.mean(slope_samples) * x_plot,
        'r-', linewidth=2, label='事後平均')

# 真の直線
ax.plot(x_plot, true_intercept + true_slope * x_plot,
        'k--', linewidth=1.5, label='真の関数')

ax.set_xlabel('x')
ax.set_ylabel('y')
ax.set_title('ベイズ線形回帰: 事後回帰直線')
ax.legend()

# 右: パラメータの事後分布
ax = axes[1]
from matplotlib.gridspec import GridSpec

fig2, axes2 = plt.subplots(1, 3, figsize=(15, 4))
for ax2, var, true_val in zip(axes2,
                               ["intercept", "slope", "sigma"],
                               [true_intercept, true_slope, true_sigma]):
    samples = trace.posterior[var].values.flatten()
    ax2.hist(samples, bins=50, density=True, alpha=0.6, color='blue')
    ax2.axvline(true_val, color='red', linestyle='--', linewidth=2,
                label=f'真の値 ({true_val})')
    ax2.set_xlabel(var)
    ax2.set_ylabel('密度')
    ax2.set_title(f'{var}の事後分布')
    ax2.legend()

ax.set_visible(False)
plt.tight_layout()
plt.savefig("pymc_linear_regression.png", dpi=150, bbox_inches="tight")
plt.show()

これらの可視化から、以下のことが読み取れます。

  1. 事後回帰直線の束(左): 事後分布からサンプルした100本の回帰直線が、データの周りに集中しつつもばらつきを持っています。このばらつきがパラメータの不確実性を視覚化しています。データの密な領域では直線が収束し、端点付近ではやや広がっています

  2. パラメータの事後分布(右の3つ): 各パラメータの事後分布が真の値(赤い破線)を含んでおり、推定が正しく行われていることが確認できます。分布の幅はパラメータの不確実性を表しており、データの量や質に応じて変化します

ベイズ線形回帰は解析的にも解ける問題でしたが、PyMCの真価はより複雑なモデルで発揮されます。次に、代表的な階層ベイズモデルである「8学校問題」を扱いましょう。

例題3: 階層ベイズモデル(8学校問題)

問題設定

8学校問題(Eight Schools)は、Rubin (1981) が提示した階層ベイズモデルの古典的な例題です。8つの高校で試験対策プログラムの効果を測定する実験が行われ、各学校の推定効果量 $y_j$ とその標準誤差 $\sigma_j$ が報告されました。

学校 A B C D E F G H
効果量 $y_j$ 28.39 7.94 -2.75 6.82 -0.64 0.63 18.01 12.16
標準誤差 $\sigma_j$ 14.9 10.2 16.3 11.0 9.4 11.4 10.4 17.6

問題は、各学校の「真の効果量 $\theta_j$」を推定することです。学校Aの観測値28.39は他と比べて高いですが、標準誤差も大きいため、真の効果がそれほど大きいとは限りません。階層ベイズモデルは、全学校の情報を適切に「借り入れ」て、このような推定を改善します。

モデルの構造

階層モデルは以下の3段階構造を持ちます。

$$ y_j | \theta_j, \sigma_j \sim \mathcal{N}(\theta_j, \sigma_j^2) \quad \text{(データモデル)} $$

$$ \theta_j | \mu, \tau \sim \mathcal{N}(\mu, \tau^2) \quad \text{(グループレベル)} $$

$$ \mu \sim \mathcal{N}(0, 10^2), \quad \tau \sim \text{HalfNormal}(10) \quad \text{(ハイパー事前分布)} $$

$\mu$ は全学校に共通する平均効果、$\tau$ は学校間のばらつきを表します。$\tau$ が小さいと全学校の効果は $\mu$ に近くなり(プール推定に近い)、$\tau$ が大きいと各学校の観測値をそのまま信頼します(個別推定に近い)。

PyMCでの実装

import pymc as pm
import arviz as az
import numpy as np
import matplotlib.pyplot as plt

# 8学校のデータ
y_obs = np.array([28.39, 7.94, -2.75, 6.82, -0.64, 0.63, 18.01, 12.16])
sigma = np.array([14.9, 10.2, 16.3, 11.0, 9.4, 11.4, 10.4, 17.6])
school_names = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
J = len(y_obs)

# 非心パラメータ化(ファネル問題の回避)
with pm.Model() as eight_schools:
    # ハイパーパラメータ
    mu = pm.Normal("mu", mu=0, sigma=10)
    tau = pm.HalfNormal("tau", sigma=10)

    # 非心パラメータ化
    theta_raw = pm.Normal("theta_raw", mu=0, sigma=1, shape=J)
    theta = pm.Deterministic("theta", mu + tau * theta_raw)

    # 尤度
    y = pm.Normal("y", mu=theta, sigma=sigma, observed=y_obs)

    # サンプリング
    trace = pm.sample(4000, tune=2000, cores=2, random_seed=42,
                      target_accept=0.95, return_inferencedata=True)

print(az.summary(trace, var_names=["mu", "tau", "theta"]))

ここで非心パラメータ化(non-centered parameterization)を使っている点が重要です。素朴なパラメータ化 theta = pm.Normal("theta", mu=mu, sigma=tau, shape=J) では、$\tau$ が小さいとき $\theta_j$ と $\mu$ の間に強い相関が生じ、NUTSサンプラーが効率的にサンプリングできません。これは「ニールのファネル」(Neal’s funnel)と呼ばれる問題です。

非心パラメータ化では、$\theta_j = \mu + \tau \cdot \theta_{\text{raw}, j}$ と変換することで、$\theta_{\text{raw}, j} \sim \mathcal{N}(0, 1)$ と $\mu, \tau$ の間の相関を緩和します。数学的には同じモデルですが、サンプリングの効率が大幅に改善されます。

target_accept=0.95 はNUTSサンプラーの目標受容率を上げるオプションで、階層モデルのような困難な事後分布に対して有効です。

# 結果の可視化
fig, axes = plt.subplots(2, 2, figsize=(14, 10))

# 左上: 各学校の事後分布
ax = axes[0, 0]
theta_samples = trace.posterior["theta"].values.reshape(-1, J)
positions = np.arange(J)

bp = ax.boxplot([theta_samples[:, j] for j in range(J)],
                positions=positions, widths=0.6, patch_artist=True,
                showfliers=False)
for patch in bp['boxes']:
    patch.set_facecolor('lightblue')
    patch.set_alpha(0.7)

ax.scatter(positions, y_obs, c='red', s=80, zorder=5, marker='x',
           linewidth=2, label='観測値')
ax.axhline(np.mean(trace.posterior["mu"].values), color='green',
           linestyle='--', linewidth=1.5, label=r'全体平均 $\mu$')
ax.set_xticks(positions)
ax.set_xticklabels(school_names)
ax.set_xlabel('学校')
ax.set_ylabel('効果量')
ax.set_title('各学校の効果量の事後分布')
ax.legend()

# 右上: 縮小推定の可視化
ax = axes[0, 1]
theta_means = np.mean(theta_samples, axis=0)
mu_mean = np.mean(trace.posterior["mu"].values)

for j in range(J):
    ax.plot([y_obs[j], theta_means[j]], [j, j], 'b-', linewidth=2, alpha=0.7)
    ax.scatter(y_obs[j], j, c='red', s=60, zorder=5, marker='x', linewidth=2)
    ax.scatter(theta_means[j], j, c='blue', s=60, zorder=5)

ax.axvline(mu_mean, color='green', linestyle='--', linewidth=1.5,
           label=r'全体平均 $\mu$')
ax.set_yticks(range(J))
ax.set_yticklabels(school_names)
ax.set_xlabel('効果量')
ax.set_ylabel('学校')
ax.set_title('縮小推定: 観測値(赤x) → 事後平均(青●)')
ax.legend()

# 左下: muとtauの事後分布
ax = axes[1, 0]
mu_samples = trace.posterior["mu"].values.flatten()
ax.hist(mu_samples, bins=50, density=True, alpha=0.6, color='blue',
        label=r'$\mu$ の事後分布')
ax.set_xlabel(r'$\mu$ (全体平均効果)')
ax.set_ylabel('密度')
ax.set_title(r'$\mu$ の事後分布')
ax.legend()

ax = axes[1, 1]
tau_samples = trace.posterior["tau"].values.flatten()
ax.hist(tau_samples, bins=50, density=True, alpha=0.6, color='orange',
        label=r'$\tau$ の事後分布')
ax.set_xlabel(r'$\tau$ (学校間標準偏差)')
ax.set_ylabel('密度')
ax.set_title(r'$\tau$ の事後分布')
ax.legend()

plt.tight_layout()
plt.savefig("pymc_eight_schools.png", dpi=150, bbox_inches="tight")
plt.show()

8学校問題の結果から、階層ベイズモデルの核心的な性質を読み取ることができます。

  1. 左上(事後分布): 各学校の事後分布は、観測値(赤い×)と全体平均(緑の破線)の間に位置しています。標準誤差が大きい学校(A, C, H)ほど全体平均に強く「引き寄せ」られ、標準誤差が小さい学校(E)ほど観測値に近い推定が得られています

  2. 右上(縮小推定): 各学校の推定値が観測値から全体平均の方向に移動(縮小)している様子が明確に示されています。特に学校Aの観測値28.39は大きく縮小され、事後平均は10前後になっています。これは、学校Aの高い観測値が部分的には偶然のばらつきによるものであり、真の効果はそれほど大きくないという推定を反映しています

  3. 左下・右下(ハイパーパラメータ): $\mu$ の事後分布は7-8付近にピークがあり、試験対策プログラムには平均的にある程度の効果があることを示しています。$\tau$ の事後分布は0付近にも密度があり、学校間の差が小さい(つまりプール推定が妥当な)可能性も示唆されています

この結果は、手動でMCMCを実装していたら非常に手間のかかる計算ですが、PyMCでは20行程度のコードで完了しました。次に、MCMCの品質を評価するための収束診断について解説しましょう。

収束診断

なぜ収束診断が必要なのか

MCMCサンプリングは、マルコフ連鎖が目標分布(事後分布)に収束して初めて正しい結果を与えます。しかし、有限のサンプルでは本当に収束しているかを完全に確認することはできません。収束していないサンプルに基づく推論は全くの無意味であるため、収束診断はベイズ分析の必須ステップです。

R-hat(ゲルマン-ルービン統計量)

R-hat($\hat{R}$)は、複数の独立なMCMCチェーンが同じ分布をサンプリングしているかを評価する統計量です。

直感的には、「チェーン内のばらつき」と「チェーン間のばらつき」を比較します。全てのチェーンが同じ分布に収束していれば、チェーン間のばらつきはチェーン内のばらつきと同程度になるはずです。

$M$ 本のチェーンがそれぞれ $n$ 個のサンプルを持つとします。チェーン $m$ の平均を $\bar{\theta}_m$、全体平均を $\bar{\theta}$ とすると、

チェーン間分散は次のようになります。

$$ B = \frac{n}{M – 1} \sum_{m=1}^{M} (\bar{\theta}_m – \bar{\theta})^2 $$

チェーン内分散は次のように計算します。

$$ W = \frac{1}{M} \sum_{m=1}^{M} s_m^2, \quad s_m^2 = \frac{1}{n-1}\sum_{i=1}^{n}(\theta_{m,i} – \bar{\theta}_m)^2 $$

これらを組み合わせて、推定分散は $\hat{V} = \frac{n-1}{n}W + \frac{1}{n}B$ となり、

$$ \hat{R} = \sqrt{\frac{\hat{V}}{W}} $$

$\hat{R} \approx 1$ なら収束、$\hat{R} > 1.01$ なら収束していない可能性があります。ArviZでは az.rhat(trace) で簡単に計算できます。

ESS(有効サンプルサイズ)

MCMCサンプルは連続するサンプル間で自己相関があるため、独立なサンプルとは扱えません。ESS(Effective Sample Size)は、自己相関を考慮した「実効的に独立なサンプル数」です。

$$ \text{ESS} = \frac{MN}{1 + 2\sum_{k=1}^{\infty} \rho_k} $$

ここで $\rho_k$ はラグ $k$ の自己相関です。一般に、信頼できる事後推定には ESS > 400 が推奨されています。ESS が低い場合は、サンプル数を増やすか、モデルのパラメータ化を見直す必要があります。

PyMCでの収束診断の実践

import pymc as pm
import arviz as az
import numpy as np
import matplotlib.pyplot as plt

# 8学校モデルの trace を使用(再実行が必要な場合は例題3のコードを実行)
# ここではダミーの簡単なモデルで実演

np.random.seed(42)
data = np.random.normal(5, 2, 100)

with pm.Model() as demo_model:
    mu = pm.Normal("mu", mu=0, sigma=10)
    sigma = pm.HalfNormal("sigma", sigma=5)
    y = pm.Normal("y", mu=mu, sigma=sigma, observed=data)
    trace = pm.sample(3000, tune=1000, cores=4, chains=4,
                      random_seed=42, return_inferencedata=True)

# R-hat と ESS の確認
summary = az.summary(trace, var_names=["mu", "sigma"])
print("=== 収束診断 ===\n")
print(summary[["mean", "sd", "hdi_3%", "hdi_97%", "ess_bulk", "ess_tail", "r_hat"]])

# 可視化
fig, axes = plt.subplots(2, 2, figsize=(14, 10))

# トレースプロット
for i, var in enumerate(["mu", "sigma"]):
    # トレース
    ax = axes[i, 0]
    for chain in range(trace.posterior.dims['chain']):
        samples = trace.posterior[var].values[chain, :]
        ax.plot(samples, alpha=0.5, linewidth=0.5)
    ax.set_xlabel('サンプル番号')
    ax.set_ylabel(var)
    ax.set_title(f'{var}: トレースプロット')

    # 自己相関
    ax = axes[i, 1]
    for chain in range(trace.posterior.dims['chain']):
        samples = trace.posterior[var].values[chain, :]
        n = len(samples)
        max_lag = 50
        autocorr = np.array([np.corrcoef(samples[:n-lag], samples[lag:])[0, 1]
                            for lag in range(1, max_lag + 1)])
        ax.plot(range(1, max_lag + 1), autocorr, alpha=0.5)
    ax.axhline(0, color='black', linestyle='-', linewidth=0.5)
    ax.set_xlabel('ラグ')
    ax.set_ylabel('自己相関')
    ax.set_title(f'{var}: 自己相関プロット')

plt.tight_layout()
plt.savefig("pymc_diagnostics.png", dpi=150, bbox_inches="tight")
plt.show()

収束診断の結果から、以下のことが確認できます。

  1. トレースプロット: 4つのチェーンが同じ領域を均等に探索しており、特定の値に長時間滞留する「スタック」が見られません。これは良好な収束の証拠です
  2. 自己相関プロット: ラグが増えるにつれて自己相関が急速にゼロに減衰しています。NUTSサンプラーの効率の高さを示しており、ESS(有効サンプルサイズ)が大きいことが期待できます
  3. R-hat: 全パラメータで $\hat{R} \approx 1.0$ となっていれば、チェーン間の一致が確認されています

収束が悪い場合の対処法

収束診断で問題が見つかった場合の対処法をまとめます。

症状 考えられる原因 対処法
$\hat{R} > 1.01$ チェーンが異なる領域を探索 チェーン数・サンプル数を増やす、事前分布を見直す
ESS が低い 高い自己相関 target_accept を上げる、非心パラメータ化を使う
トレースが「ファネル状」 ニールのファネル問題 非心パラメータ化に変更する
発散サンプル(divergences) 事後分布の幾何学的問題 target_accept を0.95以上に、パラメータ化を見直す

PyMCの大きな利点は、これらの診断がArviZとの連携で標準化されていることです。az.plot_trace(trace) で一目でチェーンの状態を確認でき、az.summary(trace) で R-hat と ESS が自動計算されます。

ArviZによる可視化

ArviZの概要

ArviZ(「アービズ」と読みます)は、ベイズ推論の結果を分析・可視化するためのPythonライブラリです。PyMC だけでなく、Stan や NumPyro の出力にも対応しています。

ArviZが提供する主な可視化機能を紹介します。

import pymc as pm
import arviz as az
import numpy as np
import matplotlib.pyplot as plt

# 簡単なモデルで ArviZ の可視化を実演
np.random.seed(42)
x = np.random.uniform(0, 5, 50)
y = 1.5 + 2.3 * x + np.random.normal(0, 0.8, 50)

with pm.Model() as demo:
    a = pm.Normal("intercept", mu=0, sigma=10)
    b = pm.Normal("slope", mu=0, sigma=10)
    s = pm.HalfNormal("sigma", sigma=5)
    mu = a + b * x
    y_obs = pm.Normal("y_obs", mu=mu, sigma=s, observed=y)
    trace = pm.sample(3000, tune=1000, cores=2, random_seed=42,
                      return_inferencedata=True)

# フォレストプロット(HDI区間の比較)
fig, ax = plt.subplots(figsize=(10, 4))
az.plot_forest(trace, var_names=["intercept", "slope", "sigma"],
               combined=True, hdi_prob=0.94, ax=ax)
ax.set_title('フォレストプロット (94% HDI)')
plt.tight_layout()
plt.savefig("arviz_forest.png", dpi=150, bbox_inches="tight")
plt.show()

このフォレストプロットは、各パラメータの事後分布を94% HDI(Highest Density Interval)として一覧表示します。パラメータ間の推定精度を一目で比較でき、特に多数のパラメータを持つモデルで有用です。

# 事後予測チェック(Posterior Predictive Check)
with demo:
    ppc = pm.sample_posterior_predictive(trace, random_seed=42)

fig, ax = plt.subplots(figsize=(10, 5))
ppc_samples = ppc.posterior_predictive["y_obs"].values.reshape(-1, len(y))
for i in range(min(100, len(ppc_samples))):
    ax.hist(ppc_samples[i], bins=20, alpha=0.02, color='blue', density=True)
ax.hist(y, bins=20, alpha=0.5, color='red', density=True, label='観測データ')
ax.set_xlabel('y')
ax.set_ylabel('密度')
ax.set_title('事後予測チェック (PPC)')
ax.legend()
plt.tight_layout()
plt.savefig("arviz_ppc.png", dpi=150, bbox_inches="tight")
plt.show()

事後予測チェック(Posterior Predictive Check, PPC)は、ベイズモデリングにおける最も重要なモデル診断手法の一つです。

  1. 事後分布からパラメータをサンプリングする
  2. そのパラメータを使ってデータを疑似的に生成する
  3. 疑似データの分布と実際のデータの分布を比較する

上のグラフでは、青い薄いヒストグラムが事後予測サンプルの分布、赤いヒストグラムが実際の観測データです。両者が重なっていれば、モデルがデータの生成過程をよく捉えていることを意味します。もし大きくずれていれば、モデルの仮定に問題がある可能性を示唆しており、モデルの改善が必要です。

PPCは、「モデルの仮定がデータと整合するか」を直接的にテストする方法であり、ベイズモデリングのワークフローにおいて不可欠なステップです。

まとめ

本記事では、確率的プログラミングの思想とPyMCを使ったベイズモデリングの実践を解説しました。

  • 確率的プログラミングの思想: モデルの記述と推論アルゴリズムを分離し、ユーザーはモデルを宣言的に定義するだけで推論が自動的に行われる
  • PyMCの基本構文: pm.Model() コンテキスト内に確率変数を定義し、observed キーワードでデータを与え、pm.sample() で推論を実行する
  • コイン投げモデル: 最もシンプルなBeta-Binomialモデルで、PyMCの出力が解析解と一致することを確認した
  • ベイズ線形回帰: パラメータの事後分布から回帰直線の不確実性を可視化した
  • 階層ベイズモデル(8学校問題): 非心パラメータ化の重要性と、縮小推定の効果を確認した
  • 収束診断: R-hat、ESS、トレースプロットによるMCMCの品質評価が不可欠であることを示した
  • ArviZ: 事後予測チェック(PPC)によるモデル診断の方法を紹介した

PyMCの真価は、これらの基本的なモデルの先にある、より複雑なモデル(混合モデル、時系列モデル、空間モデルなど)をわずかなコード変更で試せる柔軟性にあります。「モデルのアイデアを思いついたら、すぐにコードで試せる」というのが確率的プログラミングの最大の価値です。

次のステップとして、以下の記事も参考にしてください。