遺伝的アルゴリズムによるニューラルネットワーク学習のPython実装

遺伝的アルゴリズム(GA)でニューラルネットワークの重みを最適化するNeuroevolutionをPythonで実装。重みエンコーディングの定式化、隠れユニット置換対称性による交叉破壊(Competing Conventions Problem)、実装に潜む参照共有バグを実行検証込みで解説します。

はじめに

本記事は、「遺伝的アルゴリズム(GA)の一般論」と「ニューラルネットワーク(NN)の勾配法学習」という2つのトピックの交差点——Neuroevolution(神経進化)、すなわちGAを使ってNNの重み・バイアスを直接最適化する手法——に焦点を絞って解説します。

GAの選択・交叉・突然変異の一般論やスキーマ定理などの理論的基盤は 遺伝的アルゴリズム(GA)の基礎とPython実装 で詳しく導出済みのため、本記事では重複を避け、NNの重み空間特有の数理的性質、具体的には

  1. NNの重み最適化をGAの探索問題として定式化するとどうなるか
  2. 隠れユニットの並べ替え対称性が交叉に与える破壊的影響(Competing Conventions Problem
  3. 適応度スケーリング(\(1/\text{MSE}\) )がルーレット選択の選択圧に与える影響
  4. 実装に潜みやすい参照共有(aliasing)バグとその実害

の4点を、実際にコードを実行して得られた数値とともに掘り下げます。勾配法(誤差逆伝播)による同じ関数の学習は ニューラルネットワークを用いた教師あり学習のpythonプログラム を参照してください。

学習対象の関数は以下の通りです。

\[ f(x,y) = \frac{\sin(x^2) / \cos(y) + x^2 - 5y + 30}{80} \]

問題設定:NNの重み最適化をGAの探索問題として定式化する

本記事で使うNNは、入力2・隠れ2・出力1のシンプルな3層順伝播ネットワークです。ネットワークの出力は

\[ f_\theta(x) = \sum_{h=1}^{H} w_{ho}[h] \cdot \sigma\!\left(\sum_{i=1}^{2} x_i\, w_{ih}[i,h] + b_h[h]\right) + b_o \tag{1} \]

と書けます(\(H=2\) 、\(\sigma\) はシグモイド関数)。GAでは、このネットワークが持つ全パラメータ

\[ \theta = (w_{ih}, b_h, w_{ho}, b_o), \qquad w_{ih}\in\mathbb{R}^{2\times2},\ b_h\in\mathbb{R}^2,\ w_{ho}\in\mathbb{R}^{2\times1},\ b_o\in\mathbb{R} \tag{2} \]

を1本のベクトル(=1個体の「遺伝子」)とみなします。パラメータ数は \(2\times2 + 2 + 2\times1 + 1 = 9\) で、\(\theta\in\mathbb{R}^9\) です。損失関数を教師データに対する平均二乗誤差

\[ L(\theta) = \frac{1}{N}\sum_{i=1}^N \bigl(f_\theta(x_i) - y_i\bigr)^2 \tag{3} \]

とすると、コード中の適応度は

\[ \mathrm{fitness}(\theta) = \frac{1}{L(\theta) + \varepsilon}, \qquad \varepsilon = 10^{-9} \tag{4} \]

で定義されています。\(1/x\) は \(x>0\) で単調減少なので \(\arg\max_\theta \mathrm{fitness}(\theta) = \arg\min_\theta L(\theta)\) であり、GAが最大化しているのは実質的にNNの訓練誤差の最小化と同じです。

勾配法(誤差逆伝播)では \(\partial L/\partial\theta\) を連鎖律で計算する必要がありますが(導出は NN記事 参照)、GAは式(3)の \(L(\theta)\) を順伝播だけで評価できればよく、微分可能性を一切要求しません。これは活性化関数が非連続・非微分な場合や、そもそも解析的な勾配が定義できない探索空間(ネットワーク構造そのものなど)でも適用できるという意味で強力です。一方で、勾配という「どちらに動けば良くなるか」の情報を使わないぶん、サンプル効率(同じ精度に到達するための評価回数)では勾配法に劣ります。

遺伝的アルゴリズム (GA: Genetic Algorithm)

GAは、生物の進化のメカニズム、特に「適者生存」の原則を模倣した探索アルゴリズムです。解の候補を「個体(遺伝子)」の集団として表現し、以下の遺伝的操作を繰り返すことで、より良い解へと進化させていきます。

  1. 初期集団の生成: 解の候補となる個体(本記事ではNNの重みとバイアス)の集団をランダムに生成します。
  2. 適応度の計算: 各個体がどの程度問題に適しているかを評価する「適応度」を計算します。今回は、NNの出力と教師データとの誤差が小さいほど適応度が高くなります。
  3. 選択(再生): 適応度が高い個体ほど、次世代に遺伝子を残す機会が多くなるように個体を選択します。
  4. 交叉: 選択された個体のペアから、遺伝子の一部を交換することで新しい個体(子)を生成します。これにより、有望な解の要素が組み合わさることが期待されます。
  5. 突然変異: 一定の確率で、個体の遺伝子の一部をランダムに変化させます。これにより、局所解からの脱出や多様性の維持を促します。
  6. 世代交代: 新しく生成された個体群で、既存の個体群を置き換えます。
  7. 終了条件の判定: 設定した世代数に達するか、満足のいく解が得られたら終了します。そうでなければ、ステップ2に戻ります。

選択・交叉・突然変異それぞれの一般的な数理(スキーマ定理、ビルディングブロック仮説、早熟収束と選択圧のトレードオフなど)は GA記事 で詳しく扱っているので、そちらを参照してください。本記事ではこれ以降、NNの重み空間という具体的な文脈でこれらの操作がどう振る舞うかに絞って検証します。

Pythonによる実装

NNの重みとバイアスのセットを一つの「遺伝子」とみなし、GAを用いてこの遺伝子を最適化します。以下のコードは、この記事で実際に実行・検証したものと同一です。

主要なパラメータ

import numpy as np
import math
import random
import matplotlib.pyplot as plt

# パラメータ設定
GENERATIONS = 100       # 世代数
POPULATION_SIZE = 1000  # 集団の個体数(NNの数)
NUM_TEACHER_DATA = 1000 # 教師データの数

# NNの構造
NUM_INPUT = 2
NUM_HIDDEN = 2
NUM_OUTPUT = 1

# GAのパラメータ
CROSSOVER_RATE = 0.8    # 交叉率
MUTATION_RATE = 0.05    # 突然変異率

# 学習対象の関数
def target_function(x, y):
    # cos(y)が0に近づくと発散するため、小さな値を加える
    cos_y = math.cos(y)
    if abs(cos_y) < 1e-6:
        cos_y = 1e-6
    return (math.sin(x*x) / cos_y + x*x - 5*y + 30) / 80

# 活性化関数
def sigmoid(x):
    return 1.0 / (1.0 + np.exp(-x))

ニューラルネットワークのクラス

各個体に対応するNNをクラスとして定義します。

class NeuralNetwork:
    def __init__(self):
        # 重みとバイアスをランダムに初期化
        self.w_ih = np.random.uniform(-1, 1, (NUM_INPUT, NUM_HIDDEN))
        self.b_h = np.random.uniform(-1, 1, NUM_HIDDEN)
        self.w_ho = np.random.uniform(-1, 1, (NUM_HIDDEN, NUM_OUTPUT))
        self.b_o = np.random.uniform(-1, 1, NUM_OUTPUT)

        self.fitness = 0.0 # 適応度

    def predict(self, x):
        # 順伝播計算
        hidden_layer_input = np.dot(x, self.w_ih) + self.b_h
        hidden_layer_output = sigmoid(hidden_layer_input)
        output_layer_input = np.dot(hidden_layer_output, self.w_ho) + self.b_o
        # 出力層の活性化関数は恒等関数とする
        return output_layer_input[0]

    def calculate_fitness(self, teacher_inputs, teacher_outputs):
        # 全ての教師データに対する平均二乗誤差を計算
        error = 0.0
        for i in range(len(teacher_inputs)):
            prediction = self.predict(teacher_inputs[i])
            error += (prediction - teacher_outputs[i]) ** 2

        mean_squared_error = error / len(teacher_inputs)

        # 誤差が小さいほど適応度が高くなるように定義
        self.fitness = 1.0 / (mean_squared_error + 1e-9) # 0除算を避ける

GAのクラス

GAの操作(選択、交叉、突然変異)を実装します。

class GeneticAlgorithm:
    def __init__(self):
        self.population = [NeuralNetwork() for _ in range(POPULATION_SIZE)]

    def run_generation(self, teacher_inputs, teacher_outputs):
        # 1. 全個体の適応度を計算
        for individual in self.population:
            individual.calculate_fitness(teacher_inputs, teacher_outputs)

        # 2. 新しい世代を生成
        new_population = []

        # エリート選択: 最も優れた個体をそのまま次世代に残す
        elite = max(self.population, key=lambda ind: ind.fitness)
        new_population.append(elite)

        while len(new_population) < POPULATION_SIZE:
            # 3. 選択 (ルーレット選択)
            parent1 = self._roulette_selection()
            parent2 = self._roulette_selection()

            # 4. 交叉
            child1, child2 = self._crossover(parent1, parent2)

            # 5. 突然変異
            self._mutate(child1)
            self._mutate(child2)

            new_population.extend([child1, child2])

        self.population = new_population[:POPULATION_SIZE]

    def _roulette_selection(self):
        total_fitness = sum(ind.fitness for ind in self.population)
        pick = random.uniform(0, total_fitness)
        current = 0
        for individual in self.population:
            current += individual.fitness
            if current > pick:
                return individual
        return self.population[-1]

    def _crossover(self, parent1, parent2):
        child1 = NeuralNetwork()
        child2 = NeuralNetwork()

        if random.random() < CROSSOVER_RATE:
            # 重みとバイアスの各要素をランダムに交換(一様交叉)
            # (実装を簡略化するため、ここではパラメータセット全体を交換)
            child1.w_ih, child2.w_ih = (parent1.w_ih, parent2.w_ih) if random.random() < 0.5 else (parent2.w_ih, parent1.w_ih)
            child1.b_h, child2.b_h = (parent1.b_h, parent2.b_h) if random.random() < 0.5 else (parent2.b_h, parent1.b_h)
            child1.w_ho, child2.w_ho = (parent1.w_ho, parent2.w_ho) if random.random() < 0.5 else (parent2.w_ho, parent1.w_ho)
            child1.b_o, child2.b_o = (parent1.b_o, parent2.b_o) if random.random() < 0.5 else (parent2.b_o, parent1.b_o)
        else:
            child1, child2 = parent1, parent2 # 交叉しない

        return child1, child2

    def _mutate(self, individual):
        # 各重み・バイアスを突然変異確率でランダムな値に置き換える
        for w in [individual.w_ih, individual.b_h, individual.w_ho, individual.b_o]:
            if random.random() < MUTATION_RATE:
                w += np.random.uniform(-0.1, 0.1, w.shape)

main関数

def main():
    # 教師データの生成
    teacher_inputs = np.random.uniform(-5, 5, (NUM_TEACHER_DATA, NUM_INPUT))
    teacher_outputs = np.array([target_function(x[0], x[1]) for x in teacher_inputs])

    # テストデータの生成
    test_inputs = np.random.uniform(-5, 5, (NUM_TEACHER_DATA, NUM_INPUT))
    test_outputs = np.array([target_function(x[0], x[1]) for x in test_inputs])

    ga = GeneticAlgorithm()

    elite_errors = []
    print("学習開始...")
    for gen in range(GENERATIONS):
        ga.run_generation(teacher_inputs, teacher_outputs)

        # 最も優れた個体(エリート)を見つける
        elite = max(ga.population, key=lambda ind: ind.fitness)

        # テストデータでエリートの誤差を評価
        test_error = 0.0
        for i in range(len(test_inputs)):
            prediction = elite.predict(test_inputs[i])
            test_error += (prediction - test_outputs[i]) ** 2

        mean_squared_error = test_error / len(test_inputs)
        elite_errors.append(mean_squared_error)

        if (gen + 1) % 10 == 0:
            print(f"世代: {gen + 1}, テスト誤差 (MSE): {mean_squared_error:.6f}")

    # 結果のプロット
    plt.plot(elite_errors)
    plt.title("Elite Individual's Error on Test Data")
    plt.xlabel("Generation")
    plt.ylabel("Mean Squared Error")
    plt.grid(True)
    plt.savefig("ga_nn_learning_curve.png")
    plt.show()

if __name__ == '__main__':
    main()

実行検証:学習曲線を再現したら収束しなかった

上記のコードをnumpy.random.seed(42) / random.seed(42)で固定し、実際に100世代・集団サイズ1000で実行しました。ところが結果は、記事の従来の説明(「世代が進むにつれて誤差が単調に減少する」)とは異なるものでした。

世代102030405060708090100
MSE0.0632450.0947820.0749140.1662710.0656100.0981910.1785540.0887970.0662600.081628

100世代を通じたテスト誤差は最小0.057615、最大0.229795、平均0.087463と大きくばらつき、単調減少どころか一切収束していません。集団内の平均MSE(訓練データに対する適応度の逆数)も初期値2.414から最終世代でも2.148までしか下がらず、1000個体中のほとんどは全く学習が進んでいませんでした。

原因:交叉が値のコピーではなく参照の共有になっている

原因は_crossover_mutateの実装にあります。Pythonではオブジェクトの代入は参照の共有であり、child1.w_ih, child2.w_ih = (parent1.w_ih, parent2.w_ih)は配列を複製せず、親のNumPy配列オブジェクトをそのまま子にも紐付けます。さらに_mutate内のw += np.random.uniform(...)は、+=がNumPy配列に対してはインプレース演算であるため、共有された配列そのものを書き換えます。

交叉が起きなかった場合(CROSSOVER_RATEの残り20%)は child1, child2 = parent1, parent2 で子と親が同一のオブジェクトになる点も見逃せません。

この結果、次のような実害が生じます。

  • new_population.append(elite) で「保護されるはず」のエリート個体も、参照を共有する別の子個体が突然変異を受けた瞬間に同一世代の処理中に書き換えられてしまう
  • ある個体への突然変異が、無関係な別個体(親、あるいは別の子)の重みまで意図せず変化させる

実際に、集団サイズ20・突然変異率0.5の小規模な再現実験で検証しました。

import numpy as np, random

np.random.seed(1); random.seed(1)
POPULATION_SIZE = 20

# (NeuralNetworkクラス・crossover・mutateは元コードと同一ロジック)
pop = [NeuralNetwork() for _ in range(POPULATION_SIZE)]

elite = max(pop, key=lambda i: i.fitness)
elite_w_ih_snapshot = elite.w_ih.copy()
new_population = [elite]  # 元コードと同じく参照のまま追加

while len(new_population) < POPULATION_SIZE:
    p1, p2 = random.choice(pop), random.choice(pop)
    c1, c2 = crossover(p1, p2)
    mutate(c1); mutate(c2)
    new_population.extend([c1, c2])

changed = not np.array_equal(elite_w_ih_snapshot, new_population[0].w_ih)
print("new_population[0] is elite:", new_population[0] is elite)
print("エリートのw_ihが世代処理中に変化したか:", changed)
ids = [id(ind.w_ih) for ind in new_population]
print("w_ihとしてユニークなオブジェクト数:", len(set(ids)), "/", POPULATION_SIZE)

実行結果:

new_population[0] is elite: True
エリートのw_ihが世代処理中に変化したか: True
  変化前: [-0.59341353 -0.49534851  0.48765171 -0.60914104]
  変化後: [-0.73847006 -0.6086954   0.48052509 -0.71388218]
w_ihとしてユニークなオブジェクト数: 12 / 20

「エリート保存」で守られているはずの最良個体が、世代交代が完了する前に別個体への突然変異によって書き換えられています。20個体中w_ih配列としてユニークなオブジェクトはわずか12個で、8個体分の重みが他の個体と暗黙に共有されている(=一方を変えるともう一方も変わる)状態でした。これは母集団サイズ1000・100世代というスケールで積み重なると、重みが際限なく「ランダムウォーク」してしまう挙動として現れます。実際、集団内の最大絶対値重みは初期値1.056から最終世代には3.217まで、無制御に増大し続けました。

修正:交叉・エリート保存でcopy.deepcopyする

修正は単純で、参照ではなく値をコピーするようにします。

import copy

# エリート保存
new_population.append(copy.deepcopy(elite))

# 交叉(交叉ありの場合も、なしの場合も、必ずdeepcopyしてから代入する)
def _crossover(self, parent1, parent2):
    child1 = NeuralNetwork()
    child2 = NeuralNetwork()
    if random.random() < CROSSOVER_RATE:
        p1, p2 = copy.deepcopy(parent1), copy.deepcopy(parent2)
        child1.w_ih, child2.w_ih = (p1.w_ih, p2.w_ih) if random.random() < 0.5 else (p2.w_ih, p1.w_ih)
        child1.b_h, child2.b_h = (p1.b_h, p2.b_h) if random.random() < 0.5 else (p2.b_h, p1.b_h)
        child1.w_ho, child2.w_ho = (p1.w_ho, p2.w_ho) if random.random() < 0.5 else (p2.w_ho, p1.w_ho)
        child1.b_o, child2.b_o = (p1.b_o, p2.b_o) if random.random() < 0.5 else (p2.b_o, p1.b_o)
    else:
        child1, child2 = copy.deepcopy(parent1), copy.deepcopy(parent2)
    return child1, child2

同じseed・同じ100世代・同じ集団サイズ1000でこの修正版を実行した結果は次の通りです。

世代102030405060708090100
MSE0.0610700.0626350.0626350.0612410.0585390.0585390.0593470.0593470.0593380.059338

100世代を通じたテスト誤差は最小0.058539、最大0.062846、平均0.060388と、バグ版とは対照的にごく狭い範囲に収まり、世代とともに単調に近い形で0.059付近へ収束しています。集団内平均MSEも2.414→1.904へと着実に改善し、最大絶対値重みも1.056〜1.292の範囲にとどまり続けました(バグ版は3.217まで増大)。図1に両者の学習曲線と重みの発散傾向を示します。

元コード(参照共有バグあり)と修正版(deepcopy適用)の学習曲線比較。左: エリート個体のテスト誤差の推移。右: 集団内の重み絶対値の最大値の推移

参照共有バグにより誤差が発散気味に振動する一方、修正版は滑らかに収束し、重みも発散しないことが一目でわかります。この種の「配列は参照で共有され、+=はインプレース演算になる」という落とし穴は、NumPyで進化計算を実装する際に非常に踏まれやすいバグです。 交叉・突然変異・エリート保存のいずれの箇所でも、個体を複製する意図があるなら明示的にcopy.deepcopy(または各配列に対する.copy())を呼ぶ必要があります。

交叉の理論的限界:隠れユニットの並べ替え対称性とCompeting Conventions Problem

参照共有バグを修正しても、この実装の交叉演算子にはもう一つの本質的な問題が残っています。それは、隠れユニットの並べ替えに対するNNの対称性に由来するものです。

置換対称性の証明

式(1)の \(H\) 個の隠れユニットに対して、任意の置換 \(\pi:\{1,\ldots,H\}\to\{1,\ldots,H\}\) を考えます。パラメータを

\[ w_{ih}'[i,h] = w_{ih}[i,\pi(h)], \quad b_h'[h] = b_h[\pi(h)], \quad w_{ho}'[h] = w_{ho}[\pi(h)], \quad b_o' = b_o \tag{5} \]

と定義すると、

\[ f_{\theta'}(x) = \sum_{h=1}^{H} w_{ho}[\pi(h)] \cdot \sigma\!\left(\sum_i x_i w_{ih}[i,\pi(h)] + b_h[\pi(h)]\right) + b_o \tag{6} \]

は、\(\pi\) が全単射なので \(h'=\pi(h)\) と置き換えた和を取り直せば \(f_{\theta'}(x) = f_\theta(x)\) (全ての \(x\) で恒等)が成り立ちます。つまり、隠れユニットの順序を入れ替えただけの \(\theta'\) は \(\theta\) と全く同じ関数を計算します。\(H=2\) の本記事の設定では \(2!=2\) 通りの等価な表現(恒等置換と転置)が存在します(シグモイドは奇関数ではないため、\(\tanh\) のような符号反転対称性は生じません)。

この対称性が交叉と相性が悪いのは、「関数として同一」の2つの親であっても、内部のラベル付け(どちらの列が"1番目の隠れユニット"か)が異なれば、パラメータ空間上ではまったく別の点だからです。ブロック単位で交叉すると、\(w_{ih}\) と \(w_{ho}\) の対応関係(どの列とどの行が同じ隠れユニットを指すか)が崩れ、機能的に破綻した子が生まれることがあります。文献ではこれはCompeting Conventions Problemと呼ばれています。

実行検証:機能的に同一な双子の交叉

理論の効果を明確に示すため、隠れユニット間の役割が明確に異なる(対称ではない)ネットワークを手構築し、その隠れユニット順序を入れ替えた双子との交叉を検証しました。

import numpy as np

def make_network(w_ih, b_h, w_ho, b_o):
    # 元記事のNeuralNetworkは常にランダム初期化するため、
    # 明示的な重みを注入できるようにインスタンス生成後に属性を上書きする
    net = NeuralNetwork()
    net.w_ih, net.b_h, net.w_ho, net.b_o = w_ih, b_h, w_ho, b_o
    return net

def permute_hidden_units(net, perm):
    # 列(w_ih)・要素(b_h)・行(w_ho)を同じpermで並べ替えれば関数として不変
    w_ih2 = net.w_ih[:, perm].copy()
    b_h2 = net.b_h[perm].copy()
    w_ho2 = net.w_ho[perm, :].copy()
    return make_network(w_ih2, b_h2, w_ho2, net.b_o.copy())

# 隠れユニットの役割が明確に異なるよう手構築(出力重みが逆符号・大きさも異なる)
w_ih = np.array([[0.6, -0.3], [-0.2, 0.5]])
b_h = np.array([0.4, -0.7])
w_ho = np.array([[1.4], [-1.1]])
b_o = np.array([0.1])
net = make_network(w_ih, b_h, w_ho, b_o)

twin = permute_hidden_units(net, [1, 0])  # 隠れユニット0と1を入れ替え

# ブロック単位の交叉(元コードの_crossoverと同じロジック)でnetとtwinを交叉
mismatch = make_network(twin.w_ih.copy(), net.b_h.copy(), net.w_ho.copy(), net.b_o.copy())

実行結果(テストデータ500点、numpy.random.seed(3)):

ネットワークテストMSE
Net(元のネットワーク)0.4219
Twin(隠れユニット順序を入れ替え)0.4219
交叉子A(\(w_{ih}\) =Twin, 他=Net)0.7286
交叉子B(\(w_{ho}\) =Twin, 他=Net)0.9407
交叉子C(\(w_{ih}, b_h\) =Twin, \(w_{ho}\) =Net)0.9407
ランダム初期化200個体の平均0.8755

NetとTwinは全テスト点で予測値の差が厳密に0(浮動小数点の丸め誤差すら生じない完全な機能的等価性)でありながら、両者をブロック単位で交叉させただけでMSEは0.42から0.73〜0.94まで悪化し、ランダムに初期化しただけの個体群の平均(0.8755)と同等かそれより悪い結果になりました。図2に可視化を示します。

Competing Conventions Problem: 機能的に同一な双子の交叉により、ランダム初期化と同等以上に悪い子個体が生成される様子

一方、実際にGAで進化させたエリート個体(本記事の教師データで学習)で同じ実験を行うと、興味深いことに交叉による悪化はわずかでした(エリートMSE 0.3668に対し、双子との交叉子は0.363〜0.367)。原因を調べると、このエリートの出力重みが \(w_{ho}\approx[0.380, 0.364]\) とほぼ等しく、2つの隠れユニットがほぼ冗長な(対称に近い)役割を担うように進化していたためでした。これは偶然の産物であり、一般には期待できません。 隠れユニットの役割が分化するほど(\(w_{ho}\) の値が離れるほど)、交叉によるCompeting Conventions Problemの悪影響は大きくなります。

Pretorius & Pillay (2024)も指摘するように、この問題はNNの重み進化における交叉演算子の有効性そのものへの根強い懐疑の背景にあります。同論文は遺伝的プログラミングを用いて交叉演算子自体を自動設計するアプローチを提案していますが、裏を返せば「人手で設計した単純な交叉は信頼できない」という前提が研究の出発点になっています。

ルーレット選択と適応度スケーリング:選択圧の弱さと暴走の両面性

式(4)の適応度定義 \(\mathrm{fitness}(\theta)=1/(L(\theta)+\varepsilon)\) は、ルーレット選択の選択確率

\[ P(i) = \frac{\mathrm{fitness}(\theta_i)}{\sum_{j=1}^{N} \mathrm{fitness}(\theta_j)} \tag{7} \]

を通じて、GAの探索ダイナミクスに直接影響します。ここで実際の実行結果(修正版、世代100)の数値を当てはめてみます。エリートのテストMSEは0.059338、集団内平均MSE(訓練誤差)は1.904でした。これをおおまかな目安として使うと、

\[ \mathrm{fitness}_{\text{elite}} \approx \frac{1}{0.059} \approx 16.9, \qquad \mathrm{fitness}_{\text{avg}} \approx \frac{1}{1.904} \approx 0.53 \tag{8} \]

\(N=1000\) として \(P(\text{elite}) \approx 16.9 / (16.9 + 999\times 0.53) \approx 3.1\%\) となります。一様選択なら\(0.1\%\) のはずが、エリート1個体だけで約31倍の選択確率を得ている計算です。これは選択圧としては「有意だが暴走はしていない」水準であり、実際に修正版でも集団平均MSEが100世代かけて2.41から1.90までしか改善しなかった(劇的な収束は起きていない)事実と整合的です。

この \(1/(L+\varepsilon)\) という設計の怖いところは、もし1個体だけが桁違いに良い解(例えば \(L\approx10^{-4}\) )を見つけると、その瞬間に選択確率がほぼ独占状態になる点です。\(L=10^{-4}\) なら \(\mathrm{fitness}\approx10^4\) となり、集団の残り999個体(\(\mathrm{fitness}\approx0.5\) 前後)を合計しても敵いません。この場合 \(P(\text{elite})\) は瞬時に90%を超え、多様性が数世代で失われる**早熟収束(premature convergence)**を引き起こします。つまりこの適応度設計は、通常時は選択圧が弱く収束が遅い一方、突出した個体が現れた途端に選択圧が過剰になるという、両極端な振る舞いを内包しています。より安定した選択圧を得るには、ランク選択(適応度の絶対値でなく順位で選択確率を決める)や適応度の対数スケーリングなどの代替設計が有効です。早熟収束一般のメカニズムと選択圧のトレードオフについては GA記事の該当セクション で詳しく扱っています。

エッジケース・注意点まとめ

Neuroevolutionを実装・運用する上で押さえておくべき注意点を整理します。

  • 参照共有(aliasing)バグ: 交叉・エリート保存で個体やその内部配列を複製せずに代入すると、+=などのインプレース演算が意図せず他個体(保護されているはずのエリートも含む)を書き換える。必ずcopy.deepcopyまたは.copy()を使う。
  • Competing Conventions Problem: 隠れユニットの置換対称性により、機能的に同一な親同士でも単純な交叉は破壊的になりうる。隠れ層が大きいほど \(H!\) 通りの等価表現が存在し、問題は深刻化する。
  • 適応度スケーリングの両面性: \(1/(L+\varepsilon)\) のような比率スケーリングは、通常時は選択圧が弱く収束が遅い一方、外れ値的な優良個体が出現すると選択圧が急激に強まり早熟収束を招く。
  • 局所解・早熟収束: 選択圧が強すぎる、あるいは集団サイズが小さいと、大域最適に達する前に多様性が失われる。一般論は GA記事 を参照。
  • 勾配法との使い分け: パラメータ数が増えるほど(本記事は9次元だが、実用的なNNは数千〜数百万次元)GAのサンプル効率の悪さが顕在化する。Neuroevolutionが実用上有効なのは、勾配が定義できない・信頼できない場合(強化学習の一部設定、非微分な報酬、アーキテクチャ探索など)に限られる。

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

  • 交叉演算子の自動設計: Pretorius, C. J., & Pillay, N. (2024)は、NNの重み進化における交叉演算子の有効性が長年議論の的であったことを踏まえ、遺伝的プログラミングを用いて再利用可能・使い捨ての交叉演算子を自動生成し、人手設計の交叉や交叉なしのGAと比較する手法を提案しています。本記事で確認したCompeting Conventions Problemの深刻さを踏まえると、単純な交叉に頼らない設計の必要性がうかがえます。
  • 自己適応的突然変異: Hiraga, M., Komura, M., Miyamoto, A., Morimoto, D., & Ohkura, K. (2024)は、突然変異ベースの進化的NN(MBEANN)にステップサイズを自動調整する自己適応機構を導入し、固定の突然変異率よりも性能が向上することを報告しています。本記事で使った固定突然変異率(0.05)の設計は単純さゆえに調整が難しく、この方向性は実務上重要です。
  • GPUによる大規模並列化: Wang, L., Zhao, M., Liu, E., Sun, K., & Cheng, R. (2025)のTensorNEATは、NEAT(NeuroEvolution of Augmenting Topologies)のネットワーク構造を統一的なテンソル形式に再定式化し、集団全体をGPU上で並列評価することで、NEAT-Python比で最大500倍の高速化を報告しています。本記事のように集団サイズ1000・100世代でも数分かかる評価コストは、Neuroevolutionのスケーラビリティ上の主要なボトルネックであり、この種の並列化はより大きなネットワークへの適用に直結します。

関連記事

参考文献

  • Stanley, K. O., & Miikkulainen, R. (2002). “Evolving neural networks through augmenting topologies.” Evolutionary Computation, 10(2), 99-127.
  • Pretorius, C. J., & Pillay, N. (2024). “Neural network crossover in genetic algorithms using genetic programming.” Genetic Programming and Evolvable Machines, 25, Article 7.
  • Hiraga, M., Komura, M., Miyamoto, A., Morimoto, D., & Ohkura, K. (2024). “Improving the performance of mutation-based evolving artificial neural networks with self-adaptive mutations.” PLOS ONE, 19(7), e0307084.
  • Wang, L., Zhao, M., Liu, E., Sun, K., & Cheng, R. (2025). “TensorNEAT: A GPU-accelerated Library for NeuroEvolution of Augmenting Topologies.” arXiv:2504.08339 (accepted at ACM Transactions on Evolutionary Learning and Optimization).