遺伝的アルゴリズム(GA)の基礎とPython実装

numpy.random.uniform・numpy.random.randint・numpy.random.normal・numpy.argmin で遺伝的アルゴリズム(GA)をPythonスクラッチ実装。トーナメント選択・ルーレット選択・交叉・ガウス突然変異の数理からRastrigin関数最適化のベンチマーク、焼きなまし法・PSOなど他メタヒューリスティクスとの違いまでまとめます。

はじめに

遺伝的アルゴリズム(Genetic Algorithm, GA)は、生物の進化メカニズムに着想を得たメタヒューリスティクス最適化手法です。解の集団(個体群)に対して選択交叉突然変異を繰り返し適用することで、目的関数の最適解を探索します。

GAは勾配情報を必要としないブラックボックス最適化手法であり、多峰性関数や離散最適化問題にも適用可能です。

アルゴリズムの概要

GAの基本的な流れは以下のとおりです。

  1. 初期化: \(N\) 個の個体をランダムに生成
  2. 適応度評価: 各個体の目的関数値を計算
  3. 選択: 適応度の高い個体を次世代の親として選択
  4. 交叉: 親のペアから子個体を生成
  5. 突然変異: 一定確率で個体の遺伝子を変化させる
  6. 世代交代: 新しい個体群で次世代を構成
  7. ステップ2-6を収束まで繰り返す

選択(Selection)

トーナメント選択

集団から \(k\) 個体をランダムに選び、その中で最も適応度の高い個体を親として選択します。\(k\) をトーナメントサイズと呼び、大きいほど選択圧が高くなります。

\[P(\text{最良個体が選択される}) = 1 - \left(1 - \frac{1}{N}\right)^k \tag{1}\]

ルーレット選択

適応度に比例した確率で個体を選択します。個体 \(i\) の選択確率は次のとおりです。

\[P(i) = \frac{f(x_i)}{\sum_{j=1}^{N} f(x_j)} \tag{2}\]

ここで \(f(x_i)\) は個体 \(i\) の適応度です。最小化問題の場合は適応度の変換(例: \(f' = f_{\max} - f\) )が必要です。

交叉(Crossover)

BLX-\(\alpha\) 交叉(実数値最適化向け)

連続値最適化では、BLX-\(\alpha\) 交叉がよく使われます。親 \(p_1, p_2\) から子を生成する際、各次元 \(d\) について次のように計算します。

\[c_d = U\left[\min(p_{1,d}, p_{2,d}) - \alpha \cdot I_d, \; \max(p_{1,d}, p_{2,d}) + \alpha \cdot I_d\right] \tag{3}\]

ここで \(I_d = |p_{1,d} - p_{2,d}|\) 、\(U[a, b]\) は一様分布、\(\alpha\) は典型的に 0.5 です。

突然変異(Mutation)

ガウス突然変異

連続値最適化では、ガウスノイズを加える突然変異が一般的です。

\[x'_d = x_d + \mathcal{N}(0, \sigma^2) \tag{4}\]

\(\sigma\) は突然変異の強度を制御します。世代が進むにつれて \(\sigma\) を減少させる適応的突然変異も有効です。

スキーマ定理:GAが機能する理論的根拠

ここまで選択・交叉・突然変異という3つの演算を個別に導入してきましたが、これらを組み合わせるとなぜ「良い解の探索」が効率よく進むのでしょうか。Holland (1975) の**スキーマ定理(Schema Theorem)**は、この問いに対する最初の数学的な答えです。

スキーマとビルディングブロック仮説

長さ \(l\) の個体を \(\{0,1\}^l\) の二進文字列で表現するとき、スキーマ(schema) \(H\) とは各位置に \(0\) 、\(1\) 、ワイルドカード \(*\) のいずれかを割り当てたテンプレートです。例えば \(l=6\) のとき \(H = 1{*}1{*}{*}0\) は、1番目のビットが1、3番目のビットが1、6番目のビットが0であるような個体(残り3ビットは自由なので \(2^3=8\) 個体)の集合を表します。

スキーマの性質は2つの量で特徴づけられます。

  • 次数(order) \(o(H)\) : ワイルドカードでない固定ビットの数(上の例では \(o(H)=3\) )
  • 定義長(defining length) \(\delta(H)\) : 固定ビットのうち最初と最後の位置の距離(上の例では \(6-1=5\) )

世代 \(t\) の個体群のうちスキーマ \(H\) にマッチする個体数を \(m(H,t)\) 、その平均適応度を \(f(H,t)\) 、個体群全体の平均適応度を \(\bar f(t)\) と書きます。GAは個々の解を評価しているように見えて、実際には個体群に暗黙的に内包される無数のスキーマを並行して評価している、という考え方を**暗黙的並列性(implicit parallelism)**と呼びます。

選択によるスキーマ個体数の増加

適応度比例選択では、個体 \(x_i\) が1回の選択で選ばれる確率は式(2)より \(p_i = f(x_i)/(N\bar f)\) です。\(N\) 回の選択(復元抽出)を行うとき、個体 \(x_i\) の期待選択回数は \(Np_i = f(x_i)/\bar f\) です。スキーマ \(H\) にマッチする個体全体について期待選択回数の総和を取ると、

\[ m(H,t+1)\big|_{\text{選択のみ}} \;=\; \sum_{i:\, x_i \in H} \frac{f(x_i)}{\bar f(t)} \;=\; m(H,t)\cdot\frac{f(H,t)}{\bar f(t)} \tag{6} \]

選択だけを考えると、スキーマ \(H\) の個体数は比 \(f(H,t)/\bar f(t)\) を公比とする等比数列的に増減します。もしこの比が世代を通じてほぼ一定の \(1+c\) であれば \(m(H,t) \approx m(H,0)(1+c)^t\) となり、平均以上の適応度を持つスキーマは指数的に増加します。

交叉によるスキーマの破壊確率

一点交叉では、長さ \(l\) の文字列に対して \(l-1\) 個の交叉点候補が一様ランダムに選ばれます。スキーマ \(H\) の固定ビットが張る区間(長さ \(\delta(H)\) )の内側に交叉点が入ると、子個体は両親の別々の断片を継承するため \(H\) が破壊されうります。区間の外側に交叉点が入れば \(H\) は常に保存されます。交叉が確率 \(p_c\) で発生するとすると、破壊確率は高々

\[ P(\text{破壊} \mid \text{交叉}) \;\le\; \frac{\delta(H)}{l-1} \tag{7} \]

です(区間内に交叉点が入っても偶然マッチが保たれる場合を無視した、破壊確率の悲観的な上界)。したがって交叉後の生存確率は \(P(\text{生存}) \ge 1 - p_c\,\delta(H)/(l-1)\) となります。

突然変異によるスキーマの破壊確率

ビット反転突然変異では各ビットが独立に確率 \(p_m\) で反転します。スキーマ \(H\) の \(o(H)\) 個の固定ビットがすべて変化しない確率は \((1-p_m)^{o(H)}\) です。ベルヌーイの不等式 \((1-p_m)^n \ge 1-np_m\) (\(p_m\in[0,1]\) 、\(n\) は非負整数で常に成立)を \(n=o(H)\) で用いると、

\[ P(\text{生存}) \;=\; (1-p_m)^{o(H)} \;\ge\; 1-o(H)\,p_m \tag{8} \]

スキーマ定理(統合)

選択・交叉・突然変異の3つの効果をほぼ独立とみなして掛け合わせると、Hollandのスキーマ定理が得られます。

\[ m(H,t+1) \;\ge\; m(H,t)\cdot\frac{f(H,t)}{\bar f(t)}\cdot\left[1-p_c\frac{\delta(H)}{l-1}-o(H)p_m\right] \tag{9} \]

(交叉と突然変異の破壊確率を単純加算しているのは、両者が小さいという前提のもとで高次の交差項 \(p_c p_m\) を無視する一次近似です。)

この不等式が主張しているのは、「短く(\(\delta(H)\) が小)、低次数で(\(o(H)\) が小)、平均以上の適応度を持つ(\(f(H)/\bar f>1\) )スキーマ」=ビルディングブロックは、世代を経るごとに個体群内での割合を指数的に増やす、ということです。GAが交叉によってこれらのビルディングブロックを組み合わせながら大域探索を進める、という仮説が**ビルディングブロック仮説(Building Block Hypothesis)**です。ただし式(9)はあくまで下界であり、交叉の破壊確率は悲観的な上界、突然変異の生存確率は一次近似、3演算の独立性も近似にすぎない点には注意が必要です。

実行検証:ビルディングブロックの増殖を確認する

式(9)が実際のシミュレーションでどの程度成立するかを確認します。長さ \(l=20\) の二進文字列を個体とし、スキーマ \(H = 1111{*}{*}\cdots{*}\) (先頭4ビットが1、\(o(H)=4\) 、\(\delta(H)=3\) )にマッチする個体数 \(m(H,t)\) を追跡します。適応度は \(f(x)=\sum_i x_i\) (1の数の総和)とし、\(H\) にマッチする個体は先頭4ビットが保証された1であるぶん平均適応度が高くなります。ルーレット選択・一点交叉(\(p_c=0.7\) )・ビット反転突然変異(\(p_m=0.01\) )でGAを走らせます。

import numpy as np

L = 20          # 個体の長さ l
O_H = 4         # スキーマHの次数 o(H): 固定ビット数
DELTA_H = 3     # スキーマHの定義長 delta(H): 先頭4ビット(位置0-3)なので3
N = 200         # 個体数
PC = 0.7        # 交叉率
PM = 0.01       # 突然変異率
GENERATIONS = 30
TRIALS = 200    # 期待値評価のための試行回数

def matches_schema(pop):
    """スキーマ H = 1111************** (先頭4ビットが1) にマッチするか"""
    return np.all(pop[:, :O_H] == 1, axis=1)

def run_trial(seed):
    rng = np.random.default_rng(seed)
    pop = rng.integers(0, 2, size=(N, L))

    m_actual = []
    rhs_bound = []

    for t in range(GENERATIONS):
        fitness = pop.sum(axis=1).astype(float)
        f_bar = fitness.mean()

        match = matches_schema(pop)
        m_t = match.sum()
        m_actual.append(m_t)
        f_H = fitness[match].mean() if m_t > 0 else 0.0

        selection_term = (f_H / f_bar) if f_bar > 0 else 0.0
        disruption_term = max(1 - PC * DELTA_H / (L - 1) - O_H * PM, 0.0)
        rhs_bound.append(m_t * selection_term * disruption_term)

        # --- 世代交代: ルーレット選択 -> 一点交叉 -> 突然変異 ---
        probs = fitness / fitness.sum() if fitness.sum() > 0 else np.ones(N) / N
        parent_idx = rng.choice(N, size=N, p=probs)
        parents = pop[parent_idx]

        children = parents.copy()
        for i in range(0, N - 1, 2):
            if rng.random() < PC:
                point = rng.integers(1, L)
                children[i, point:], children[i + 1, point:] = (
                    parents[i + 1, point:].copy(),
                    parents[i, point:].copy(),
                )

        mutate_mask = rng.random((N, L)) < PM
        children = np.where(mutate_mask, 1 - children, children)
        pop = children

    match = matches_schema(pop)
    m_actual.append(match.sum())
    return np.array(m_actual), np.array(rhs_bound)

all_m, all_rhs = [], []
for trial in range(TRIALS):
    m_actual, rhs_bound = run_trial(seed=trial)
    all_m.append(m_actual)
    all_rhs.append(rhs_bound)

all_m, all_rhs = np.array(all_m), np.array(all_rhs)
mean_m, mean_rhs = all_m.mean(axis=0), all_rhs.mean(axis=0)

print(f"m(H,0) 平均 = {mean_m[0]:.3f} (理論値 N*0.5^{O_H} = {N*0.5**O_H:.3f})")
print(f"m(H,{GENERATIONS}) 平均 = {mean_m[-1]:.3f}")
print(f"下界を下回った世代数(平均ベース): {np.sum(mean_m[1:] < mean_rhs)}/{GENERATIONS}")
violations = np.sum(all_m[:, 1:] < all_rhs)
print(f"個別試行での下界違反率: {violations}/{all_m[:,1:].size} = {violations/all_m[:,1:].size*100:.2f}%")

200試行の平均で見ると、次の結果が得られました。

  • 初期世代の \(m(H,0)\) の平均は12.68(全200個体の6.3%、理論値 \(N\times0.5^4=12.5\) に一致)
  • 30世代後には \(m(H,30)\) の平均が80.64(全200個体の40.3%)まで増加し、初期状態から6.4倍以上に達しました
  • 世代平均の系列で見ると、式(9)の下界は30世代すべてで成立(下回った世代数 0/30)
  • 一方、個々の試行単位(1試行=1回のGA実行)では下界を下回るケースが**890/6000 = 14.83%**存在しました。これはスキーマ定理が「期待値についての不等式」であり、確率的なゆらぎを持つ個々の試行では成立しないことがある、という理論的な性質を裏付けています。

平均以上の適応度を持つ短く低次数のスキーマが、スキーマ定理の予測どおり世代を経て個体群中で増殖していく様子を定量的に確認できました。

No Free Lunch定理:GAは万能ではない

スキーマ定理はGAが「なぜ機能するか」を説明しますが、これは適応度地形にビルディングブロックへの分解可能性という構造がある場合の話です。Wolpert and Macready (1997) の**No Free Lunch定理(NFL定理)**は、次の事実を証明しています。

ありうる全ての目的関数を一様な集合として平均した場合、任意の2つのブラックボックス最適化アルゴリズム(GAを含む)の性能は等しい。すなわち、あらゆる問題に対して平均的に他より優れたアルゴリズムは存在しない。

これは、GAがランダム探索より優れて見えるのは、実世界の問題が完全にランダムな目的関数ではなく、GAの暗黙の仮定(ビルディングブロックへの分解可能性、適応度地形の局所的な滑らかさなど)と相性の良い構造を持つ問題に限られていることを意味します。目的関数がGAの仮定と相性が悪い、あるいは完全にランダムであれば、GAはランダムサーチと同等かそれ以下の性能しか示さないことがあります。GAを採用する際は「この問題にビルディングブロック的な構造があるか」を常に意識する必要があり、GAは決して万能の最適化アルゴリズムではありません。

Python実装:Rastrigin関数の最適化

Rastrigin関数は多峰性のベンチマーク関数で、大域的最小値は \(f(\mathbf{0}) = 0\) です。

\[f(\mathbf{x}) = 10n + \sum_{i=1}^{n} \left[x_i^2 - 10\cos(2\pi x_i)\right] \tag{5}\]
import numpy as np
import matplotlib.pyplot as plt

def rastrigin(x):
    """Rastrigin関数(バッチ評価対応: 定数項は x.shape[-1](次元数)で計算する)"""
    return 10 * x.shape[-1] + np.sum(x**2 - 10 * np.cos(2 * np.pi * x), axis=-1)

class GeneticAlgorithm:
    def __init__(self, dim, pop_size=100, bounds=(-5.12, 5.12),
                 crossover_rate=0.8, mutation_rate=0.1, mutation_sigma=0.5,
                 tournament_size=3):
        self.dim = dim
        self.pop_size = pop_size
        self.bounds = bounds
        self.crossover_rate = crossover_rate
        self.mutation_rate = mutation_rate
        self.mutation_sigma = mutation_sigma
        self.tournament_size = tournament_size

    def initialize(self):
        """ランダムな初期集団を生成"""
        low, high = self.bounds
        return np.random.uniform(low, high, (self.pop_size, self.dim))

    def tournament_selection(self, population, fitness):
        """トーナメント選択"""
        indices = np.random.randint(0, self.pop_size, size=self.tournament_size)
        best = indices[np.argmin(fitness[indices])]
        return population[best]

    def blx_alpha_crossover(self, parent1, parent2, alpha=0.5):
        """BLX-α交叉"""
        low = np.minimum(parent1, parent2) - alpha * np.abs(parent1 - parent2)
        high = np.maximum(parent1, parent2) + alpha * np.abs(parent1 - parent2)
        child = np.random.uniform(low, high)
        return np.clip(child, *self.bounds)

    def gaussian_mutation(self, individual):
        """ガウス突然変異"""
        mask = np.random.random(self.dim) < self.mutation_rate
        noise = np.random.normal(0, self.mutation_sigma, self.dim)
        individual[mask] += noise[mask]
        return np.clip(individual, *self.bounds)

    def run(self, objective, max_generations=200):
        """GAの実行"""
        population = self.initialize()
        best_history = []

        for gen in range(max_generations):
            fitness = objective(population)
            best_idx = np.argmin(fitness)
            best_history.append(fitness[best_idx])

            # 次世代の生成
            new_population = [population[best_idx].copy()]  # エリート保存
            while len(new_population) < self.pop_size:
                p1 = self.tournament_selection(population, fitness)
                p2 = self.tournament_selection(population, fitness)
                if np.random.random() < self.crossover_rate:
                    child = self.blx_alpha_crossover(p1, p2)
                else:
                    child = p1.copy()
                child = self.gaussian_mutation(child)
                new_population.append(child)

            population = np.array(new_population[:self.pop_size])

        fitness = objective(population)
        best_idx = np.argmin(fitness)
        return population[best_idx], fitness[best_idx], best_history

# --- 実行 ---
np.random.seed(42)
ga = GeneticAlgorithm(dim=10, pop_size=200)
best_solution, best_fitness, history = ga.run(rastrigin, max_generations=300)

print(f"最良解の適応度: {best_fitness:.6f}")
print(f"最良解: {best_solution[:3]}... (最初の3次元)")

# --- 収束曲線のプロット ---
plt.figure(figsize=(10, 5))
plt.plot(history, color="#2a78d6", linewidth=2)
plt.xlabel('Generation')
plt.ylabel('Best Fitness')
plt.title('GA Convergence on Rastrigin Function (10D)')
plt.yscale('log')
plt.grid(True, alpha=0.25)
plt.gca().spines[["top", "right"]].set_visible(False)
plt.tight_layout()
plt.savefig("ga_convergence.png", dpi=150)
plt.show()

10次元Rastrigin関数に対するGAの収束曲線(対数スケール)。世代0の最良適応度約103.7から300世代後には0.54まで低下し、大域的最小値0に近づく

上記コードを実行すると、世代0の最良適応度は103.6658、300世代後は0.538853まで低下し、10次元Rastrigin関数の大域的最小値 \(f(\mathbf{0})=0\) にほぼ収束します。最良解は \([0.0322, -0.0124, -0.0085, \ldots]\) と全次元が0近傍に集まっており、多峰性関数でも局所解に陥らず大域探索に成功していることがわかります。

早熟収束と多様性維持:選択圧のトレードオフ

早熟収束(premature convergence)とは

選択圧が強すぎると、個体群の多様性がわずかな世代数で失われ、大域最適解に到達する前に個体群全体が局所解(あるいは単に初期に見つかった優良個体)に収束してしまいます。これを**早熟収束(premature convergence)**と呼びます。トーナメント選択のトーナメントサイズ \(k\) を大きくすると、式(1)からわかるように最良個体が選ばれる確率が上がり選択圧が強まりますが、同時に多様性の減衰も速くなります。

実行検証:選択のみを繰り返した場合の多様性減衰速度

交叉・突然変異を行わず選択のみを繰り返すことで、選択圧が多様性に与える影響を切り分けて観察します。1次元のRastrigin関数上に一様分布する200個体の集団に対し、ルーレット選択とトーナメント選択(\(k=2,3,5,10\) )をそれぞれ適用し、個体群の標準偏差(多様性の指標)が世代とともにどう減衰するかを比較します。

import numpy as np

N = 200
GENERATIONS = 40
TRIALS = 100
BOUNDS = (-5.12, 5.12)

def rastrigin_1d(x):
    return 10 + x**2 - 10 * np.cos(2 * np.pi * x)

def roulette_select(pop, fitness, rng):
    """ルーレット選択(最小化問題なので f' = fmax - f で適応度変換)"""
    f_prime = fitness.max() - fitness
    total = f_prime.sum()
    probs = f_prime / total if total > 0 else np.ones(N) / N
    idx = rng.choice(N, size=N, p=probs)
    return pop[idx]

def tournament_select(pop, fitness, rng, k):
    """トーナメント選択(トーナメントサイズk)"""
    idx = rng.integers(0, N, size=(N, k))
    best = idx[np.arange(N), np.argmin(fitness[idx], axis=1)]
    return pop[best]

def run_trial(method, seed, k=None):
    rng = np.random.default_rng(seed)
    pop = rng.uniform(*BOUNDS, size=N)
    stds = [pop.std()]
    for gen in range(GENERATIONS):
        fitness = rastrigin_1d(pop)
        pop = roulette_select(pop, fitness, rng) if method == "roulette" \
            else tournament_select(pop, fitness, rng, k)
        stds.append(pop.std())
    return np.array(stds)

methods = [("roulette", None), ("tournament", 2), ("tournament", 3),
           ("tournament", 5), ("tournament", 10)]

for method, k in methods:
    all_stds = np.array([run_trial(method, seed=t, k=k) for t in range(TRIALS)])
    mean_stds = all_stds.mean(axis=0)
    half = mean_stds[0] / 2
    gen_half = next((t + (mean_stds[t] - half) / (mean_stds[t] - mean_stds[t + 1])
                      for t in range(len(mean_stds) - 1)
                      if mean_stds[t] >= half > mean_stds[t + 1]), None)
    gen_1pct = next((t for t, s in enumerate(mean_stds) if s < mean_stds[0] * 0.01), None)
    label = f"{method}(k={k})" if k else method
    print(f"{label:20s} 半減期(世代)={gen_half}  1%到達世代={gen_1pct}")

実行結果(100試行平均、初期標準偏差2.9459)は以下のとおりです。

選択方式多様性半減期(世代)多様性が初期値の1%未満になる世代
ルーレット選択4.4217
トーナメント(\(k=2\) )2.9511
トーナメント(\(k=3\) )1.897
トーナメント(\(k=5\) )1.315
トーナメント(\(k=10\) )0.924

ルーレット選択では多様性が初期値の半分になるまで約4.4世代かかるのに対し、トーナメントサイズ\(k=10\) では1世代未満で半減し、わずか4世代で個体群がほぼ完全に収束(多様性1%未満)してしまいます。選択圧が強いほど収束は速いものの、それは同時に「まだ探索すべき多峰性の谷を調べきる前に均質化してしまうリスク」を意味します。下図は5つの選択方式についてこの多様性減衰過程を可視化したものです。

選択のみ(交叉・突然変異なし)を繰り返した場合の個体群多様性(xの標準偏差)の減衰。トーナメントサイズが大きいほど選択圧が強く、多様性がより速く失われる

実運用では、この早熟収束を防ぐため、エリート保存と組み合わせた選択圧の調整、多様性を積極的に維持するニッチング・シェアリング手法、あるいは次節で扱う突然変異率の調整が用いられます。

交叉率・突然変異率のトレードオフ

突然変異率 \(p_m\) は、多様性維持と探索効率のトレードオフを支配する最も重要なハイパーパラメータの一つです。

  • \(p_m\) が低すぎると、選択と交叉だけでは失われた多様性を補充できず、個体群が早期に均質化して局所解に留まりやすくなります(前節の早熟収束と同じ機序)
  • \(p_m\) が高すぎると、良い遺伝子座もランダムに破壊されてしまい、GAは実質的にランダムサーチに近づき、選択・交叉によって蓄積した情報を活かせなくなります

実行検証:突然変異率と最終解の質・多様性の関係

先ほどのRastrigin関数最適化コードを、乱数生成器を外部注入できるよう拡張し(rng引数)、各世代の個体群多様性diversity_historyも記録するようにした上で、突然変異率 \(p_m\) を\(0\) から\(1.0\) まで変化させ、30個の乱数シードで平均した最終世代(150世代目)の最良適応度と多様性を計測しました。

import numpy as np

def rastrigin(x):
    """Rastrigin関数(バッチ評価対応: 定数項は x.shape[-1](次元数)で計算する)"""
    return 10 * x.shape[-1] + np.sum(x**2 - 10 * np.cos(2 * np.pi * x), axis=-1)

class GeneticAlgorithm:
    def __init__(self, dim, pop_size=100, bounds=(-5.12, 5.12),
                 crossover_rate=0.8, mutation_rate=0.1, mutation_sigma=0.5,
                 tournament_size=3, rng=None):
        self.dim = dim
        self.pop_size = pop_size
        self.bounds = bounds
        self.crossover_rate = crossover_rate
        self.mutation_rate = mutation_rate
        self.mutation_sigma = mutation_sigma
        self.tournament_size = tournament_size
        self.rng = rng if rng is not None else np.random.default_rng()

    def initialize(self):
        low, high = self.bounds
        return self.rng.uniform(low, high, (self.pop_size, self.dim))

    def tournament_selection(self, population, fitness):
        indices = self.rng.integers(0, self.pop_size, size=self.tournament_size)
        best = indices[np.argmin(fitness[indices])]
        return population[best]

    def blx_alpha_crossover(self, parent1, parent2, alpha=0.5):
        low = np.minimum(parent1, parent2) - alpha * np.abs(parent1 - parent2)
        high = np.maximum(parent1, parent2) + alpha * np.abs(parent1 - parent2)
        child = self.rng.uniform(low, high)
        return np.clip(child, *self.bounds)

    def gaussian_mutation(self, individual):
        mask = self.rng.random(self.dim) < self.mutation_rate
        noise = self.rng.normal(0, self.mutation_sigma, self.dim)
        individual[mask] += noise[mask]
        return np.clip(individual, *self.bounds)

    def run(self, objective, max_generations=150):
        population = self.initialize()
        best_history = []
        diversity_history = []

        for gen in range(max_generations):
            fitness = objective(population)
            best_idx = np.argmin(fitness)
            best_history.append(fitness[best_idx])
            diversity_history.append(population.std())

            new_population = [population[best_idx].copy()]  # エリート保存
            while len(new_population) < self.pop_size:
                p1 = self.tournament_selection(population, fitness)
                p2 = self.tournament_selection(population, fitness)
                if self.rng.random() < self.crossover_rate:
                    child = self.blx_alpha_crossover(p1, p2)
                else:
                    child = p1.copy()
                child = self.gaussian_mutation(child)
                new_population.append(child)

            population = np.array(new_population[:self.pop_size])

        fitness = objective(population)
        best_idx = np.argmin(fitness)
        return population[best_idx], fitness[best_idx], best_history, diversity_history

mutation_rates = [0.0, 0.01, 0.05, 0.1, 0.2, 0.4, 0.6, 0.8, 1.0]
DIM, POP, GENS, SEEDS = 5, 100, 150, 30

for mr in mutation_rates:
    finals, diversities = [], []
    for seed in range(SEEDS):
        rng = np.random.default_rng(seed * 1000 + int(mr * 100))
        ga = GeneticAlgorithm(dim=DIM, pop_size=POP, mutation_rate=mr, rng=rng)
        _, best_fitness, history, div_history = ga.run(rastrigin, max_generations=GENS)
        finals.append(best_fitness)
        diversities.append(div_history[-1])
    finals = np.array(finals)
    print(f"mutation_rate={mr:.2f}  最終最良適応度(平均)={finals.mean():.5f}  "
          f"(std={finals.std():.5f})  最終多様性(平均)={np.mean(diversities):.5f}")

実行結果は次のとおりです。

突然変異率 \(p_m\)最終最良適応度(平均)標準偏差最終多様性(平均)
0.002.354831.271190.61979
0.010.199000.397980.12307
0.050.033360.178570.12191
0.100.127420.483290.23791
0.200.353290.425280.44111
0.402.356550.984520.94158
0.603.619381.049751.12032
0.804.800381.257261.25964
1.005.390441.505491.34802

結果は教科書的なU字カーブを明確に示しています。\(p_m=0\) (突然変異なし)では最終適応度が2.35と悪化しますが、これはBLX-\(\alpha\) 交叉だけでは補いきれない多様性不足によって早熟収束が起きているためです。\(p_m\) を上げていくと\(p_m=0.05\) 付近で最良の適応度0.033に達しますが、そこからさらに\(p_m\) を上げると単調に悪化し、\(p_m=1.0\) (全遺伝子が毎世代ガウスノイズで上書きされる、ほぼ純粋なランダムサーチ)では平均適応度が5.39まで悪化します。最終多様性も\(p_m\) の増加に伴って単調に増加しており(0.12→1.35)、突然変異率が個体群の多様性を直接制御していることが定量的に確認できます。

近年の研究動向(2023年以降)

進化計算とGAは伝統的な数値最適化を超えて、近年は以下のような応用に広がっています。急速に発展している分野のため、詳細は各一次文献を参照してください。

  • ニューラルアーキテクチャ探索(Neural Architecture Search, NAS)との融合: ニューラルネットワークの層構造やハイパーパラメータを個体のゲノムとしてエンコードし、GAの交叉・突然変異でアーキテクチャ空間を探索する研究が継続的に行われています。勾配が定義できない離散的なアーキテクチャ探索空間はGAと相性がよく、計算コストの高い評価(学習・検証)を効率化するための代理モデルやマルチフィデリティ評価との併用が研究され続けています。
  • CMA-ES(Covariance Matrix Adaptation Evolution Strategy)との比較: CMA-ESは共分散行列を適応的に更新することで探索分布の形状そのものを学習する進化戦略であり、連続最適化ではGAよりサンプル効率が高いとされる場面が多く報告されています。一方GAは交叉によって離散的な組み合わせ構造を扱える柔軟性を持つため、問題の性質(連続か離散か、変数間相互作用の構造)に応じた使い分けが実務上重要という整理が続いています。
  • 進化戦略とLLMプロンプト最適化: 大規模言語モデル(LLM)のプロンプトやin-context exemplarの探索に、進化的手法(プロンプトの「交叉」「変異」をLLM自身に行わせる、あるいは離散的なプロンプト集団を評価・淘汰する)を用いる研究が2023年以降複数報告されています。勾配による逆伝播ができない離散的なテキスト空間の最適化という点で、GAの黒箱最適化としての性質が活かされている領域です。

これらの応用の詳細な性能比較や再現性については、各分野の急速な進展を踏まえて一次文献を確認することを推奨します。

他の最適化手法との比較

手法集団ベース勾配不要離散問題特徴
GAYesYesYes交叉による探索空間の組み合わせ的探索
CEMYesYes困難確率分布の反復更新
PSOYesYes困難速度ベースの粒子移動
SANoYesYes温度による確率的受容

関連記事

参考文献

  • Holland, J. H. (1975). Adaptation in Natural and Artificial Systems. University of Michigan Press.
  • Goldberg, D. E. (1989). Genetic Algorithms in Search, Optimization, and Machine Learning. Addison-Wesley.
  • Eshelman, L. J., & Schaffer, J. D. (1993). “Real-coded genetic algorithms and interval-schemata”. Foundations of Genetic Algorithms, 2, 187-202.
  • Wolpert, D. H., & Macready, W. G. (1997). “No free lunch theorems for optimization”. IEEE Transactions on Evolutionary Computation, 1(1), 67-82.
  • Back, T. (1996). Evolutionary Algorithms in Theory and Practice. Oxford University Press.