焼きなまし法(Simulated Annealing)の仕組みとPython実装

焼きなまし法(Simulated Annealing, SA)の理論・設計・Python実装と比較(numpy, scipy.optimize.dual_annealing)。Metropolis基準とBoltzmann遷移確率の数理、温度スケジュール(指数冷却・対数冷却)、連続関数最適化とTSP(巡回セールスマン問題)への応用、GA等メタヒューリスティクスとの違いを解説。

はじめに

焼きなまし法(Simulated Annealing, SA)は、金属の焼きなまし(アニーリング)プロセスに着想を得たメタヒューリスティクス最適化手法です。高温から徐々に冷却することで、金属の結晶構造が最低エネルギー状態に向かうように、目的関数の大域的最適解を探索します。

SAの特徴は、改悪解を確率的に受容することで局所最適から脱出できる点です。温度が高い序盤では大胆な探索を行い、温度が低下するにつれて精緻な局所探索に移行します。

アルゴリズム

メトロポリス基準

SAの核となるのはメトロポリス基準です。現在の解 \(x\) から近傍解 \(x'\) への遷移を以下の確率で受容します。

\[P(\text{accept}) = \begin{cases} 1 & \text{if } \Delta E \leq 0 \\ \exp\left(-\frac{\Delta E}{T}\right) & \text{if } \Delta E > 0 \end{cases} \tag{1}\]

ここで \(\Delta E = f(x') - f(x)\) は目的関数値の変化量、\(T\) は現在の温度です。

  • \(\Delta E \leq 0\) (改善): 常に受容
  • \(\Delta E > 0\) (改悪): 温度 \(T\) に依存する確率で受容。\(T\) が高いほど受容確率が高い

メトロポリス基準の導出:MH法の特殊ケースとして

式(1)の受理規則は、天下り的に与えられた公式ではありません。実は本ブログの別記事「 MCMC入門:メトロポリス・ヘイスティングス法とギブスサンプリング 」で導出したメトロポリス・ヘイスティングス(MH)法の受理確率の、対称提案分布に対する特殊ケースそのものです。ここではその位置づけを、本記事の文脈(最適化)に即して具体的に確認します。

温度 \(T\) を固定した状態で、目的関数値 \(E(x) = f(x)\) をエネルギーとみなし、次のボルツマン分布を「目標分布」とみなします。

\[ \pi_T(x) = \frac{\exp(-E(x)/T)}{Z(T)}, \qquad Z(T) = \int \exp\left(-\frac{E(x)}{T}\right) dx \]

正規化定数 \(Z(T)\) (分配関数)を陽に計算する必要はない、というMH法の重要な性質(前掲MCMC記事)がここでも成立します。\(\pi_T\) を目標分布として、対称な近傍提案分布 \(q(x' | x) = q(x | x')\) (例えば2-optスワップやガウス摂動のように、\(x \to x'\) の生成確率と \(x' \to x\) の生成確率が等しい提案)を用いてMH法の受理確率(前掲記事の式(4))を適用すると、

\[ \alpha(x' | x) = \min\left(1, \frac{\pi_T(x')}{\pi_T(x)}\right) = \min\left(1, \frac{\exp(-E(x')/T)}{\exp(-E(x)/T)}\right) = \min\left(1, \exp\left(-\frac{E(x') - E(x)}{T}\right)\right) \]

となり、\(\Delta E = E(x') - E(x) = f(x') - f(x)\) とおけば、これはまさに式(1)のメトロポリス基準に一致します。すなわち焼きなまし法は、「温度 \(T\) でのボルツマン分布 \(\pi_T\) を目標分布とするメトロポリス法を、\(T\) を徐々に下げながら繰り返し適用する」手法だと位置づけられます。分配関数 \(Z(T)\) が比の計算で消えるため、\(E(x)\) さえ計算できれば \(Z(T)\) の困難な数値積分を避けられる、という点もMH法と全く同じ恩恵です。

なお、提案分布が非対称な場合(例えば近傍の生成確率が \(x\) と \(x'\) で異なるような設計)には、MCMC記事の式(3)にある提案分布の比の補正項 \(q(x | x') / q(x' | x)\) (ヘイスティングス補正)を式(1)に加える必要があります。本記事のPython実装(ガウス摂動、2-optスワップ)はいずれも対称提案なのでこの補正は不要ですが、これはあくまで「対称提案」という設計上の選択の帰結であり、一般には省略できない点に注意してください。

ボルツマン分布との関係

温度 \(T\) で十分長い時間探索を続けると、解の分布はボルツマン分布に収束します。

\[P(x) \propto \exp\left(-\frac{f(x)}{T}\right) \tag{2}\]

\(T \to 0\) では最適解に集中します。

詳細釣り合いによる証明

式(2)のボルツマン分布への収束は、MCMC記事で証明した「詳細釣り合いを満たす遷移核は目標分布を定常分布に持つ」という一般的な定理の直接の帰結です。固定した温度 \(T\) のもとで、メトロポリス基準(1)による遷移核 \(T_{\text{SA}}(x' | x) = q(x' | x) \alpha(x' | x)\) (\(x' \neq x\) の場合)が詳細釣り合い

\[ \pi_T(x) \, T_{\text{SA}}(x' | x) = \pi_T(x') \, T_{\text{SA}}(x | x') \]

を満たすことは、MCMC記事で示したMH受理確率の証明(受理確率の比 \(r = \pi_T(x') q(x | x') / (\pi_T(x) q(x' | x))\) に対して \(\alpha = \min(1, r)\) と \(\alpha = \min(1, 1/r)\) を代入する場合分け)をそのまま \(\pi = \pi_T\) に置き換えれば成立します(対称提案の場合は \(q\) の項が消えるため、証明はさらに単純化されます)。したがって、温度 \(T\) を固定して十分長く焼きなましを走らせれば、解の分布は式(2)のボルツマン分布 \(\pi_T\) へ収束します。

ただし、ここで一つ重要な留保が必要です。この収束の議論はあくまで「温度 \(T\) を固定した」場合の話です。実際の焼きなまし法は温度を各ステップで変化させるため、遷移核 \(T_{\text{SA}}\) 自体が時刻とともに変化する非定常(time-inhomogeneous)マルコフ連鎖になります。MCMC記事で紹介したエルゴード定理は遷移核が固定された定常(time-homogeneous)連鎖を前提としているため、そのままでは適用できません。次節でこの点を掘り下げます。

SAとMCMCの関係:非定常マルコフ連鎖と貪欲法への退化

SAは温度を下げ続けるMCMCである

前節で見た通り、焼きなまし法の各ステップは「その時点の温度 \(T_k\) におけるボルツマン分布 \(\pi_{T_k}\) を目標としたメトロポリス法の1ステップ」そのものです。冷却スケジュール \(T_0 > T_1 > T_2 > \cdots\) に沿って温度を下げながらこれを繰り返すため、SA全体は目標分布が時々刻々変化していくMCMCと見なせます。この非定常性こそが、SAが通常のMCMCと違って「サンプリング」ではなく「最適化」の道具として使われる理由です。\(T\) が固定されていればいずれ \(\pi_T\) からのサンプルが得られるだけですが、\(T \to 0\) で \(\pi_T\) 自体が最適解へ収縮していくため、連鎖もそれを追いかけて最適解へ近づいていきます。

\(T \to 0\) の極限で貪欲法に退化することの導出

ボルツマン分布 \(\pi_T(x) \propto \exp(-E(x)/T)\) は、\(T \to 0\) の極限で最小エネルギー状態に完全に集中します。これを確認するため、最適解を \(x^*\) (\(E(x^*) = E_{\min}\) )、それ以外の任意の状態を \(x\) (\(E(x) > E_{\min}\) )として、両者の相対確率を計算すると、

\[ \frac{\pi_T(x)}{\pi_T(x^*)} = \exp\left(-\frac{E(x) - E_{\min}}{T}\right) \xrightarrow{T \to 0^+} 0 \]

となり(\(E(x) - E_{\min} > 0\) は定数なので、\(T \to 0\) で指数部が \(-\infty\) に発散する)、最適解以外の状態の相対確率がゼロに潰れることが分かります。

同じ極限操作を受理確率(1)に施すと、\(\Delta E > 0\) の場合、

\[ \lim_{T \to 0^+} \exp\left(-\frac{\Delta E}{T}\right) = 0 \qquad (\Delta E > 0 \text{ は固定}) \]

となり、改悪解は一切受理されなくなります。一方 \(\Delta E \leq 0\) の場合は式(1)より \(T\) に依らず常に受理されます。したがってメトロポリス基準は \(T \to 0\) で

\[ P(\text{accept}) \xrightarrow{T \to 0^+} \begin{cases} 1 & \Delta E \leq 0 \\ 0 & \Delta E > 0 \end{cases} \]

という規則に退化し、これはまさに**貪欲法(改善する遷移のみ受理する山登り法、hill climbing)**そのものです。つまり焼きなまし法は、高温では一様に近いランダムウォーク、低温では貪欲法という2つの極限を、冷却スケジュールに沿って滑らかに橋渡しするアルゴリズムだと言えます。

実行検証:十分低い温度で貪欲法と一致することの確認

上記の極限操作が実装レベルでも本当に成り立つか、TSPで実際に検証しました。同一の初期巡回路・同一の近傍提案列(乱数シードを固定し、\((i, j)\) の提案と受理判定用の一様乱数 \(u\) の列を事前生成して両者に共有)を用意し、(a) メトロポリス基準に従うSA(温度 \(T\) を一定値に固定)と(b) 改善する提案のみを受理する貪欲法(山登り法)を、全く同じ20,000回の提案列に対して走らせ、各ステップの受理/棄却の決定が完全に一致するかを比較しました。

import numpy as np

np.random.seed(42)
n_cities = 30
cities = np.random.rand(n_cities, 2) * 100


def tour_length(tour, cities):
    n = len(tour)
    d = 0.0
    for i in range(n):
        d += np.linalg.norm(cities[tour[i]] - cities[tour[(i + 1) % n]])
    return d


def two_opt_delta(tour, cities, i, j):
    """2-optスワップ(tour[i:j+1]を反転)による距離変化量を差分計算"""
    n = len(tour)
    a, b = tour[i - 1], tour[i]
    c, d = tour[j], tour[(j + 1) % n]
    old = np.linalg.norm(cities[a] - cities[b]) + np.linalg.norm(cities[c] - cities[d])
    new = np.linalg.norm(cities[a] - cities[c]) + np.linalg.norm(cities[b] - cities[d])
    return new - old


def two_opt_swap(tour, i, j):
    new_tour = tour.copy()
    new_tour[i : j + 1] = tour[i : j + 1][::-1]
    return new_tour


def run_with_rule(cities, init_tour, proposals, accept_rule):
    """proposals: (i, j, u, T) の共通提案列。accept_rule だけを差し替えて比較する"""
    tour = init_tour.copy()
    current = tour_length(tour, cities)
    best_dist = current
    decisions = []
    for i, j, u, T in proposals:
        delta = two_opt_delta(tour, cities, i, j)
        accept = accept_rule(delta, T, u)
        decisions.append(accept)
        if accept:
            tour = two_opt_swap(tour, i, j)
            current += delta
            best_dist = min(best_dist, current)
    return best_dist, decisions


# --- 共通の提案列を事前生成 ---
max_iter = 20000
rng = np.random.RandomState(7)
init_tour = list(rng.permutation(n_cities))
proposals_template = []
for _ in range(max_iter):
    i, j = sorted(rng.choice(n_cities, 2, replace=False))
    if i == 0:
        i = 1
    if j <= i:
        j = min(i + 1, n_cities - 1)
    u = rng.random()
    proposals_template.append((i, j, u))

# --- 温度 T を固定したSA vs 貪欲法(同一提案列) ---
for T_const in [1.0, 1e-3, 1e-6, 1e-9]:
    proposals = [(i, j, u, T_const) for (i, j, u) in proposals_template]

    def sa_rule(delta, T, u):
        return delta <= 0 or u < np.exp(-delta / T)

    def greedy_rule(delta, T, u):
        return delta <= 0

    sa_dist, sa_decisions = run_with_rule(cities, init_tour, proposals, sa_rule)
    greedy_dist, greedy_decisions = run_with_rule(cities, init_tour, proposals, greedy_rule)
    mismatches = sum(a != b for a, b in zip(sa_decisions, greedy_decisions))

    print(
        f"T={T_const:.0e}  SA={sa_dist:.4f}  greedy={greedy_dist:.4f}  mismatches={mismatches}/{max_iter}"
    )

実行結果は次の通りです。

固定温度 \(T\)SAの最終距離貪欲法の最終距離決定が食い違った回数
1.0453.9023466.6980120 / 20,000
\(10^{-3}\)466.6980466.69800 / 20,000
\(10^{-6}\)466.6980466.69800 / 20,000
\(10^{-9}\)466.6980466.69800 / 20,000

\(T = 1.0\) では120回(0.6%)の改悪提案が確率的に受理され、SAは貪欲法(466.6980)より良い解(453.9023)に到達しています。これは改悪解の受容が局所最適からの脱出に貢献した具体例です。一方 \(T = 10^{-3}\) 以下では、20,000回全ての受理判定が貪欲法と完全に一致し、最終距離も小数点以下まで完全に一致しました。これは、\(\Delta E\) の典型的な大きさ(今回のTSPでは数〜数十のオーダー)に対して \(T = 10^{-3}\) 程度まで下がれば \(\exp(-\Delta E/T)\) が浮動小数点精度の範囲で実質ゼロになり、理論通り貪欲法へ退化することを裏付けています。

温度スケジュール

幾何冷却(最も一般的)

\[T_{k+1} = \alpha \cdot T_k, \quad 0.9 \leq \alpha < 1 \tag{3}\]

\(\alpha = 0.95\) が典型的な値です。

線形冷却

\[T_{k+1} = T_k - \delta \tag{4}\]

対数冷却(理論的保証あり)

\[T_k = \frac{T_0}{\ln(k + 1)} \tag{5}\]

大域最適解への収束が理論的に保証されますが、収束速度は極めて遅く実用的ではありません。

なぜ対数冷却だけが収束を保証するのか

対数冷却(5)が大域最適解への収束を理論的に保証する、という主張は天下り的な引用ではなく、次の直感的な確率論的議論から理解できます。

エネルギー地形上のある局所最適解(大域最適ではない)から脱出するには、エネルギー障壁の高さ \(d\) だけ登る改悪遷移を少なくとも1回受理する必要があります。この改悪遷移が時刻 \(k\) のステップで受理される確率は、メトロポリス基準(1)よりおおよそ \(\exp(-d/T_k)\) のオーダーです(\(d\) は経路上で登る必要のある最大のエネルギー差)。対数冷却スケジュール \(T_k = c / \ln(k+1)\) (\(c\) は定数)をこれに代入すると、

\[ \exp\left(-\frac{d}{T_k}\right) = \exp\left(-\frac{d \ln(k+1)}{c}\right) = (k+1)^{-d/c} \]

というべき乗則が得られます。ここで、無限級数 \(\sum_{k=1}^{\infty} (k+1)^{-d/c}\) は、指数 \(d/c \leq 1\) (すなわち \(c \geq d\) )のとき調和級数的に発散し、\(d/c > 1\) (\(c < d\) )のとき収束します。確率論のボレル・カンテリの補題(独立性を仮定した簡略版の直感)により、「各時刻に確率 \((k+1)^{-d/c}\) で起こりうる事象」の生起回数の期待値に対応するこの級数が発散するなら、この事象(=エネルギー障壁 \(d\) を乗り越える改悪遷移が受理される)は時間が経てば無限回起こり得ます。つまりエネルギー障壁の深さ \(d\) がどれだけ大きくても、\(c\) を \(d\) 以上に選んでおけば、いつか必ず脱出のチャンスが訪れる、という直感が成り立ちます。

これを厳密に定式化したのが、Geman & Geman (1984)が確率的画像修復の文脈で示した収束定理です。すなわち、対数冷却スケジュール \(T_k = c/\ln(k+1)\) において定数 \(c\) を、全ての局所最適解(大域最適を除く)の脱出に必要なエネルギー障壁の最大値以上に選べば、時刻 \(k \to \infty\) で解の分布は大域最適解に確率1で収束します。この十分条件を与える定数の最小値(「全ての非大域的な局所最適解が持つエネルギー障壁の深さの最大値」に一致する)は、後にHajek (1988)によってより精密に特徴づけられ、この条件が必要十分条件であることが示されています。

一方、幾何冷却 \(T_k = T_0 \alpha^k\) (\(0 < \alpha < 1\) )では、同じ計算を行うと

\[ \exp\left(-\frac{d}{T_k}\right) = \exp\left(-\frac{d}{T_0} \alpha^{-k}\right) \]

となり、\(\alpha^{-k}\) が \(k\) に対して指数的に増大するため、この確率は \(k\) の増加に対して二重指数的に、対数冷却よりはるかに急速にゼロへ収束します。級数 \(\sum_k \exp(-d \alpha^{-k}/T_0)\) は任意の \(d > 0\) に対して有限の値に収束するため、上記のボレル・カンテリ型の議論は逆方向に働き、「深いエネルギー障壁を持つ局所最適解に一度捕まると、有限時間の後は脱出の期待回数が急速に頭打ちになり、確率1では脱出できない」という結果になります。これが、幾何冷却や線形冷却が大域最適解への収束を理論的に保証できない理由です。

ただし、これは「実用上使えない」という意味ではありません。後述の数値実験で示す通り、幾何冷却は限られた反復回数の中で対数冷却よりもはるかに高品質な解に到達します。対数冷却の理論的保証は「反復回数に制限がない」という非現実的な前提の下でのみ意味を持ち、有限の計算予算の中では、冷却速度を上げて多くの反復を低温での精緻な局所探索に使う方が実用上優れている、というのがSAの重要な設計トレードオフです。

初期温度・冷却率の設定とエッジケース

SAの実用上の性能は、理論的な収束保証よりもむしろ初期温度 \(T_0\) と冷却率の選び方に強く依存します。ここでは代表的な失敗モードと、その定量的な挙動を数値実験で確認します。

初期温度が高すぎる場合:ほぼ全ての改悪提案が受理されるため、序盤は単なるランダムウォークになります。目的関数の情報をほとんど活用できないまま反復を消費してしまい、冷却が追いつく前に反復予算が尽きると、最終的な解の質はかえって悪化します。

初期温度が低すぎる場合:改悪提案がほとんど受理されず、探索は開始直後から実質的に貪欲法(前節参照)と化します。局所最適に即座に収束し、そこから抜け出す機会がほぼ失われます。

これを確認するため、TSP(30都市、幾何冷却 \(\alpha=0.9995\) 、20,000反復、5シード平均)で初期温度 \(T_0\) を\(0.01\) から\(10^5\) まで変化させ、全体の受理率と反復の序盤(最初の10%)・中盤(40〜50%地点)・終盤(最後の10%)での受理率、そして最終的な巡回路長を計測しました。

import numpy as np

np.random.seed(42)
n_cities = 30
cities = np.random.rand(n_cities, 2) * 100


def tour_length(tour, cities):
    n = len(tour)
    d = 0.0
    for i in range(n):
        d += np.linalg.norm(cities[tour[i]] - cities[tour[(i + 1) % n]])
    return d


def two_opt_delta(tour, cities, i, j):
    n = len(tour)
    a, b = tour[i - 1], tour[i]
    c, d = tour[j], tour[(j + 1) % n]
    old = np.linalg.norm(cities[a] - cities[b]) + np.linalg.norm(cities[c] - cities[d])
    new = np.linalg.norm(cities[a] - cities[c]) + np.linalg.norm(cities[b] - cities[d])
    return new - old


def two_opt_swap(tour, i, j):
    new_tour = tour.copy()
    new_tour[i : j + 1] = tour[i : j + 1][::-1]
    return new_tour


def sa_tsp_track_acceptance(cities, T0, alpha, max_iter, seed, n_bins=10):
    rng = np.random.RandomState(seed)
    n = len(cities)
    tour = list(rng.permutation(n))
    current = tour_length(tour, cities)
    best_dist = current
    T = T0
    bin_size = max_iter // n_bins
    accept_counts = np.zeros(n_bins)
    propose_counts = np.zeros(n_bins)

    for k in range(max_iter):
        i, j = sorted(rng.choice(n, 2, replace=False))
        if i == 0:
            i = 1
        if j <= i:
            continue
        delta = two_opt_delta(tour, cities, i, j)
        b = min(k // bin_size, n_bins - 1)
        propose_counts[b] += 1
        if delta <= 0 or rng.random() < np.exp(-delta / T):
            tour = two_opt_swap(tour, i, j)
            current += delta
            accept_counts[b] += 1
            best_dist = min(best_dist, current)
        T *= alpha

    acc_rate_per_bin = accept_counts / np.maximum(propose_counts, 1)
    overall_acc = accept_counts.sum() / propose_counts.sum()
    return best_dist, overall_acc, acc_rate_per_bin


max_iter = 20000
alpha = 0.9995
for T0 in [0.01, 1, 10, 100, 1000, 10000, 100000]:
    dists, overalls, bins_all = [], [], []
    for seed in range(5):
        best_dist, overall_acc, acc_bins = sa_tsp_track_acceptance(cities, T0, alpha, max_iter, seed)
        dists.append(best_dist)
        overalls.append(overall_acc)
        bins_all.append(acc_bins)
    bins_mean = np.mean(bins_all, axis=0)
    print(
        f"T0={T0:g}  overall_acc={np.mean(overalls)*100:.2f}%  "
        f"final_dist={np.mean(dists):.2f}  "
        f"bins(0-10%,40-50%,90-100%)={bins_mean[0]*100:.1f}%,{bins_mean[4]*100:.1f}%,{bins_mean[9]*100:.1f}%"
    )

実行結果は次の通りです(5シード平均)。

\(T_0\)全体受理率序盤(0–10%)中盤(40–50%)終盤(90–100%)最終距離(平均)
0.010.75%3.3%0.4%0.5%471.52
10.85%3.9%0.5%0.5%481.35
101.60%9.7%0.6%0.5%453.98
10012.44%71.4%1.1%0.5%456.97
1,00033.83%97.2%16.4%0.6%456.03
10,00056.70%99.7%84.3%0.8%467.47
100,00079.48%100.0%98.3%10.0%507.92

\(T_0=0.01\) や\(1\) では、序盤の受理率がすでに3〜4%程度しかなく、探索はほぼ開始直後から貪欲法的に振る舞い、最終距離は471〜481と中間的な \(T_0\) (10〜1,000)の454〜457より明らかに悪化しています。逆に \(T_0=10^5\) では、序盤(0-10%)は受理率100%、中盤(40-50%)ですら98.3%と、20,000反復の半分を消費してもなおほぼ純粋なランダムウォークの状態から抜け出せておらず、最終距離は507.92と全設定中最悪でした。最良の結果は \(T_0=10\) (453.98)で得られましたが、\(T_0=100\) 〜\(1{,}000\) (456〜457)も同程度に良好であり、「反復予算の範囲内で、序盤の受理率がおよそ70〜97%程度になる」中間的な \(T_0\) が実用上妥当な範囲であることが確認できます。この「初期受理率をある程度高く保つ」という設計指針は、MCMC記事で紹介したRandom-Walk Metropolisの最適受理率(約44%、多次元では約23%)とは異なる指標である点に注意してください。あちらは定常分布からの推定効率を最大化する基準ですが、SAの初期温度はあくまで「探索の初期段階で十分広い範囲を探索できるか」という別の目的のための調整です。

冷却率のトレードオフ:冷却率(幾何冷却の \(\alpha\) 、対数冷却の \(c\) など)を小さく(速く冷却)すると、少ない反復回数で局所探索フェーズに移行できる一方、大域的な探索が不十分なまま局所最適に収束するリスクが高まります。逆に冷却を遅くすると、後述の数値実験で確認する通り探索の質は向上しますが、同じ反復回数の中で得られる解の質は(低温での精緻な局所探索に使える反復数が減るため)低下します。この「遅い冷却=高品質・低速」と「速い冷却=低品質・高速」のトレードオフは、SAのハイパーパラメータ設計における中心的な考慮事項です。

再加熱(reheating)戦略:一度冷え切った温度を周期的に、あるいは一定反復数にわたって改善が見られない場合に再上昇させ、探索をやり直す手法です。標準的な単調冷却スケジュールでは、低温域で一度深い局所最適に落ち込むと、脱出に必要な改悪確率 \(\exp(-d/T)\) がほぼゼロに固定されてしまい、それ以降は事実上貪欲法から抜け出せません。再加熱はこの問題に対し、温度を意図的に引き上げることで一時的に脱出確率を回復させ、その後改めて冷却し直すことで、単一の冷却サイクルでは到達できなかった解を探索します。実装上は、幾何冷却や対数冷却を複数回繰り返す(サイクルごとに \(T_0\) へリセットする)か、一定反復数だけ改善が見られない場合に温度を数倍に引き上げる、といった形で組み込まれます。

Python実装:連続最適化

Rastrigin関数(多峰性ベンチマーク)を焼きなまし法で最適化します。

import numpy as np
import matplotlib.pyplot as plt

def rastrigin(x):
    """Rastrigin関数"""
    return 10 * len(x) + np.sum(x**2 - 10 * np.cos(2 * np.pi * x))

def simulated_annealing(objective, dim, bounds=(-5.12, 5.12),
                        T_init=100.0, alpha=0.995, max_iter=10000):
    """焼きなまし法による連続最適化"""
    # 初期解
    x = np.random.uniform(*bounds, size=dim)
    best_x = x.copy()
    best_f = objective(x)
    current_f = best_f

    T = T_init
    history = [best_f]

    for i in range(max_iter):
        # 近傍解の生成(ガウスノイズ)
        sigma = 0.5 * (T / T_init)  # 温度に比例してステップ幅を調整
        x_new = x + np.random.normal(0, sigma, size=dim)
        x_new = np.clip(x_new, *bounds)

        new_f = objective(x_new)
        delta = new_f - current_f

        # メトロポリス基準
        if delta <= 0 or np.random.random() < np.exp(-delta / T):
            x = x_new
            current_f = new_f

            if current_f < best_f:
                best_x = x.copy()
                best_f = current_f

        T *= alpha
        history.append(best_f)

    return best_x, best_f, history

# --- 実行 ---
np.random.seed(42)
best_x, best_f, history = simulated_annealing(rastrigin, dim=10)

print(f"最良解の適応度: {best_f:.6f}")

# --- 収束曲線のプロット ---
plt.figure(figsize=(10, 5))
plt.plot(history)
plt.xlabel('Iteration')
plt.ylabel('Best Objective Value')
plt.title('Simulated Annealing on Rastrigin Function (10D)')
plt.yscale('log')
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

Python実装:巡回セールスマン問題(TSP)

SAは組合せ最適化にも有効です。TSPを2-opt近傍で解きます。

import numpy as np
import matplotlib.pyplot as plt

def total_distance(tour, cities):
    """巡回路の総距離を計算"""
    n = len(tour)
    dist = 0
    for i in range(n):
        dist += np.linalg.norm(cities[tour[i]] - cities[tour[(i + 1) % n]])
    return dist

def two_opt_swap(tour, i, j):
    """2-optスワップ: tour[i:j+1]を反転"""
    new_tour = tour.copy()
    new_tour[i:j+1] = tour[i:j+1][::-1]
    return new_tour

def sa_tsp(cities, T_init=1000.0, alpha=0.9995, max_iter=100000):
    """焼きなまし法によるTSP"""
    n = len(cities)
    tour = list(range(n))
    np.random.shuffle(tour)

    current_dist = total_distance(tour, cities)
    best_tour = tour.copy()
    best_dist = current_dist
    T = T_init
    history = [best_dist]

    for _ in range(max_iter):
        # ランダムな2-optスワップ
        i, j = sorted(np.random.choice(n, 2, replace=False))
        new_tour = two_opt_swap(tour, i, j)
        new_dist = total_distance(new_tour, cities)

        delta = new_dist - current_dist
        if delta <= 0 or np.random.random() < np.exp(-delta / T):
            tour = new_tour
            current_dist = new_dist
            if current_dist < best_dist:
                best_tour = tour.copy()
                best_dist = current_dist

        T *= alpha
        history.append(best_dist)

    return best_tour, best_dist, history

# --- 実行 ---
np.random.seed(42)
n_cities = 30
cities = np.random.rand(n_cities, 2) * 100

best_tour, best_dist, history = sa_tsp(cities)
print(f"最短距離: {best_dist:.2f}")

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

# 巡回路
tour_cities = cities[best_tour + [best_tour[0]]]
axes[0].plot(tour_cities[:, 0], tour_cities[:, 1], 'b-o', markersize=5)
axes[0].set_title(f'Best Tour (distance={best_dist:.2f})')
axes[0].set_aspect('equal')
axes[0].grid(True, alpha=0.3)

# 収束曲線
axes[1].plot(history)
axes[1].set_xlabel('Iteration')
axes[1].set_ylabel('Best Distance')
axes[1].set_title('SA Convergence on TSP')
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

数値実験:冷却スケジュールの比較(TSP)

理論的な収束保証と実用上の性能の間のトレードオフを定量的に確認するため、前節で導入した対数冷却・幾何冷却・線形冷却の3つのスケジュールを、同一のTSPインスタンス(30都市、乱数シード42)・同一の反復回数(100,000反復)・同一の初期温度(\(T_0=1000\) )で比較しました。対数冷却は式(5)を \(T_0\) に合わせて \(T_k = \frac{T_0 \ln 2}{\ln(k+2)}\) と正規化し(\(k=0\) で\(T_0\) に一致するように定数を調整)、線形冷却は100,000反復でちょうど温度がほぼ0になるよう \(\delta = T_0 / 100000\) としました。各スケジュールを5つの乱数シードで実行し、最終的な巡回路長の平均・標準偏差、および反復10%・50%・100%地点での最良巡回路長の推移を記録しました。

import numpy as np

np.random.seed(42)
n_cities = 30
cities = np.random.rand(n_cities, 2) * 100


def tour_length(tour, cities):
    n = len(tour)
    d = 0.0
    for i in range(n):
        d += np.linalg.norm(cities[tour[i]] - cities[tour[(i + 1) % n]])
    return d


def two_opt_delta(tour, cities, i, j):
    n = len(tour)
    a, b = tour[i - 1], tour[i]
    c, d = tour[j], tour[(j + 1) % n]
    old = np.linalg.norm(cities[a] - cities[b]) + np.linalg.norm(cities[c] - cities[d])
    new = np.linalg.norm(cities[a] - cities[c]) + np.linalg.norm(cities[b] - cities[d])
    return new - old


def two_opt_swap(tour, i, j):
    new_tour = tour.copy()
    new_tour[i : j + 1] = tour[i : j + 1][::-1]
    return new_tour


def sa_tsp_schedule(cities, T0, schedule, max_iter, seed, T_min=1e-6):
    rng = np.random.RandomState(seed)
    n = len(cities)
    tour = list(rng.permutation(n))
    current = tour_length(tour, cities)
    best_dist = current
    checkpoints = {}
    cp_iters = sorted(set(int(max_iter * f) for f in (0.1, 0.5, 1.0)))

    for k in range(max_iter):
        if schedule == "log":
            c = T0 * np.log(2)
            T = max(c / np.log(k + 2), T_min)
        elif schedule == "exp":
            T = max(T0 * 0.9995**k, T_min)
        elif schedule == "linear":
            T = max(T0 - (T0 / max_iter) * k, T_min)

        i, j = sorted(rng.choice(n, 2, replace=False))
        if i == 0:
            i = 1
        if j <= i:
            continue
        delta = two_opt_delta(tour, cities, i, j)
        if delta <= 0 or rng.random() < np.exp(-delta / T):
            tour = two_opt_swap(tour, i, j)
            current += delta
            best_dist = min(best_dist, current)

        if (k + 1) in cp_iters:
            checkpoints[k + 1] = best_dist

    return best_dist, checkpoints


T0 = 1000.0
max_iter = 100000
for schedule in ["log", "exp", "linear"]:
    finals, cps_list = [], []
    for seed in range(5):
        best_dist, cps = sa_tsp_schedule(cities, T0, schedule, max_iter, seed)
        finals.append(best_dist)
        cps_list.append(cps)
    finals = np.array(finals)
    iters = sorted(cps_list[0].keys())
    cp_means = {it: np.mean([cps_list[s][it] for s in range(5)]) for it in iters}
    print(f"{schedule}: final={finals.mean():.3f}±{finals.std():.3f}  checkpoints={cp_means}")

実行結果は次の通りです(5シード平均)。

冷却スケジュール最終距離(平均±標準偏差)10%地点50%地点100%地点
対数冷却906.02 ± 29.441012.27939.34906.02
幾何冷却(\(\alpha=0.9995\) )454.84 ± 1.87567.86454.84454.84
線形冷却532.78 ± 27.771128.751103.00532.78

反復回数10万回という同一の予算の下では、幾何冷却が最終距離454.84と圧倒的に最良で、しかもばらつき(標準偏差1.87)も最も小さく安定しています。対数冷却は反復10万回終了時点での温度が \(T = \frac{1000 \ln 2}{\ln(100002)} \approx 60.2\) と、まだ全く冷え切っておらず、50%地点(939.34)から100%地点(906.02)までの改善もわずかで、実質的にまだ探索フェーズの途中にあることが分かります。これは前節で導いた「対数冷却は理論上いつか大域最適に到達するが、そのために必要な反復数が桁違いに大きい」という予測と整合する結果です。線形冷却は、10%・50%地点では対数冷却よりもさらに悪い巡回路長(1128.75、1103.00)を示しながら、50%から100%の間に温度が線形に0まで落ち切ることで急速に改善し(532.78)、幾何冷却には及ばないものの対数冷却よりは大幅に良い解に到達しています。これは、線形冷却が反復の後半にならないと十分低い温度に達しないため、精緻な局所探索に使える反復数が幾何冷却より少なくなることが原因と考えられます。総じて、限られた計算予算の下では、理論的な収束保証を持たない幾何冷却の方が実用上優れた解を与える、という広く知られた経験則が、本記事の設定でも定量的に再現されました。

他の最適化手法との比較

特性SAGACEM
解の数単一解集団ベース集団ベース
探索メカニズム確率的受容交叉・突然変異分布更新
離散問題得意得意不得意
パラメータ温度スケジュール交叉率・突然変異率エリート割合
並列化困難容易容易

関連記事

参考文献

  • Kirkpatrick, S., Gelatt, C. D., & Vecchi, M. P. (1983). “Optimization by Simulated Annealing”. Science, 220(4598), 671-680.
  • Metropolis, N., et al. (1953). “Equation of State Calculations by Fast Computing Machines”. The Journal of Chemical Physics, 21(6), 1087-1092.
  • Bertsimas, D., & Tsitsiklis, J. (1993). “Simulated annealing”. Statistical Science, 8(1), 10-15.
  • Geman, S., & Geman, D. (1984). “Stochastic Relaxation, Gibbs Distributions, and the Bayesian Restoration of Images”. IEEE Transactions on Pattern Analysis and Machine Intelligence, 6(6), 721-741.
  • Hajek, B. (1988). “Cooling Schedules for Optimal Annealing”. Mathematics of Operations Research, 13(2), 311-329.