はじめに
Conway's Game of Life(ライフゲーム)は、シンプルな3つのルールから驚くほど複雑な挙動を生み出すセルオートマトンである。初期パターン次第で、数ステップで消滅するものもあれば、何百ステップも複雑な変化を続けるものもある。
Arduino UNO Qには8×13のLEDマトリクスが搭載されており、ライフゲームをリアルタイムで可視化できる。しかし、「どの初期パターンが長く生き残り、かつ複雑な挙動をするか」を手探りで探すのは非効率である。
本記事では、遺伝的アルゴリズム(GA)とサロゲートモデルを組み合わせ、長寿命かつ多様性の高い初期パターンを自動探索する仕組みを実装する。
ライフゲームの基本実装(セルオートマトンのルール・LED表示)については前回の記事を参照とする。本記事ではAI探索部分に絞って解説する。
※推論やモデル生成のアルゴリズム部分に関しては、Claude Code による実装を実施した。
動作環境
| 項目 | 内容 |
|---|---|
| ハードウェア | Arduino UNO Q |
| 開発・実行環境 | Arduino App Lab |
| LEDマトリクス | 8×13 |
| Python | 3.13 |
| 主要ライブラリ | onnxruntime、numpy |
App LabはArduino UNO Q向けのアプリケーション実行環境である。PythonスクリプトとArduinoスケッチをまとめたzipをデプロイするだけで動作する。
ライフゲームの実装
基本的な実装は前回の記事を参照とする。
トーラス型(周期境界条件) の採用
8×13のグリッドを トーラス(ドーナツ面) として扱う。左端と右端、上端と下端がつながっているイメージである。端のセルも常に8近傍を持つため、境界での特別扱いが不要となる。
ROWS = 8
COLS = 13
TOROIDAL = True
def _next_gen(g):
new = [[0] * COLS for _ in range(ROWS)]
for r in range(ROWS):
for c in range(COLS):
n = 0
for dr in (-1, 0, 1):
for dc in (-1, 0, 1):
if (dr, dc) == (0, 0):
continue
if TOROIDAL:
# トーラス境界:端を反対側に折り返す
n += g[(r + dr) % ROWS][(c + dc) % COLS]
if g[r][c]:
new[r][c] = 1 if n in (2, 3) else 0
else:
new[r][c] = 1 if n == 3 else 0
return new
トーラス型にすることで小さなグリッドでも複雑なパターンが長く持続しやすくなる。固定境界(端は常に死セル)では端に近いセルが消えやすく、短命なパターンが増える傾向がある。
フィットネス関数の設計
GAでは各個体(初期パターン)の「良さ」をスカラー値で評価するフィットネス関数が核心となる。今回は以下の2指標の重み付き和を用いる。
fitness = 0.6 × (longevity / 1000) + 0.4 × diversity
指標① 生存世代数(longevity)
初期パターンから何ステップでサイクルに突入するかを測る。静止(still-life)や消滅もサイクルとして検出される。
- 最大1000ステップシミュレーション
- サイクル検出時はそのステップ数をlongevityとする
- 1000ステップ以内にサイクルなし → longevity = 1000(最大値)
指標② 多様性スコア(diversity)
サイクルに突入したときの周期の長さを正規化した値である。
| パターン種別 | サイクル周期 | diversity |
|---|---|---|
| still-life(静止) | 1 | 0.01 |
| blinker(点滅) | 2 | 0.02 |
| 複雑な振動子 | 大きい | 大きい |
| 1000ステップ超 | — | 1.0 |
MAX_CYCLE = 100 # 周期の正規化上限
def evaluate(pattern, max_gens=1000):
grid = _make_grid(pattern)
seen = {} # state -> 最初に見たステップ番号
longevity = max_gens
cycle_period = max_gens
for step in range(max_gens):
state = tuple(tuple(row) for row in grid)
if state in seen:
longevity = step
cycle_period = step - seen[state] # 実際のサイクル周期
break
seen[state] = step
next_grid = _next_gen(grid)
if not any(cell for row in next_grid for cell in row):
longevity = step + 1
cycle_period = 0 # 全滅:diversity = 0
break
grid = next_grid
diversity = min(cycle_period, MAX_CYCLE) / MAX_CYCLE
fitness = 0.6 * (longevity / max_gens) + 0.4 * diversity
return fitness, longevity, diversity
実装のポイント
2指標を独立させた背景
当初、多様性を「サイクル突入までに出現したユニーク状態数」で定義していた。しかしこれだとユニーク状態数 ≒ 生存世代数になってしまう。サイクル検出時点で過去のすべての状態がseenに記録されているため、数学的にlen(seen) == longevityが成立するためである。
サイクル周期を使うことで独立した指標となる。例えば:
- 「500ステップ生き残ったが、最後はblinker(周期2)」→ longevity高・diversity低
- 「200ステップで複雑な振動子(周期80)に落ち着いた」→ longevity中・diversity高
この2つは性質が異なるパターンであり、独立した指標でそれぞれを評価できるようになる。
遺伝的アルゴリズムの実装
個体の表現
初期パターンは7×7 = 49セルのバイナリ配列である。これを8×13グリッドの中央に配置してシミュレーションする。
PATTERN_SIZE = 7 # 7×7
def random_pattern(n):
return [random.randint(0, 1) for _ in range(n * n)]
設定パラメータ
POP_SIZE = 20 # 個体数
ELITE_N = 2 # エリート保存数(上位2個体を次世代に引き継ぐ)
MUTATION_RATE = 0.05 # 突然変異率(各ビットが5%の確率で反転)
選択・交叉・突然変異
def next_generation(population, fitnesses, mutation_rate, elite_n):
# エリート保存:上位2個体をそのまま次世代へ
sorted_idx = sorted(range(len(population)), key=lambda i: -fitnesses[i])
new_pop = [population[i][:] for i in sorted_idx[:elite_n]]
# ルーレット選択 → 一点交叉 → 突然変異
while len(new_pop) < len(population):
p1 = _select(population, fitnesses) # fitness比例選択
p2 = _select(population, fitnesses)
point = random.randint(1, len(p1) - 1)
child = p1[:point] + p2[point:] # 一点交叉
child = [b ^ 1 if random.random() < mutation_rate else b
for b in child] # ビット反転突然変異
new_pop.append(child)
return new_pop
サロゲートモデルによる高速スクリーニング
evaluate()は最大1000世代シミュレーションするため重い処理である。20個体すべてを毎世代CPU評価すると1世代に数十秒かかる。そこで**サロゲートモデル(代理モデル)**で事前スクリーニングを行う。
仕組み
- 全20個体をサロゲートMLPで高速予測
- 予測値上位TOP K = 5個体だけを
evaluate()で正確評価 - 残り15個体にはサロゲート予測値をfitnessとして使用(GAの選択圧は維持)
SURROGATE_TOP_K = 5
inputs = [surrogate.pattern_to_input(p) for p in population]
predicted = surrogate.predict(inputs)
# サロゲート上位K個体のみCPU評価
top_k = sorted(range(POP_SIZE), key=lambda i: -predicted[i])[:SURROGATE_TOP_K]
for i in top_k:
fit, lon, div = evaluate(population[i])
# 残りはサロゲート値で代用
for i in range(POP_SIZE):
if i not in top_k:
fitnesses[i] = predicted[i]
CPU評価回数を20→5に削減でき、1世代あたりの処理時間が大幅に短縮される。
モデル構造
入力は7×7パターンを8×8にゼロパディングして平坦化した64次元ベクトルである。
入力 (64) → 全結合 (32) → ReLU → 全結合 (16) → ReLU → 全結合 (1) → Sigmoid
バックエンドの自動選択
利用可能なバックエンドを優先順に自動選択する。
| バックエンド | 条件 |
|---|---|
| ONNX-CPU |
onnxruntimeインストール済み(推奨) |
| TFLite-CPU |
tflite-runtimeインストール済み |
| NumPy-CPU | 常に使用可能(外部MLライブラリ不要) |
trials = [
("ONNX-CPU", self._try_onnx_cpu),
("TFLite-CPU", self._try_tflite_cpu),
("NumPy-CPU", self._try_numpy),
]
for name, fn in trials:
try:
fn()
self.backend = name
break
except Exception as e:
logger.warning(f"[{name}] 使用不可: {e}")
実装のポイント
NumPyフォールバック
model_numpy.jsonは重み行列をJSON化したものであり、onnxruntimeやtflite-runtimeがない環境でも動作する。NumPy(Python標準環境に同梱)だけでMLPの順伝播を実行する。
def _predict_numpy(self, patterns):
x = np.array(patterns, dtype=np.float32)
for i, (W, b) in enumerate(zip(self._np_W, self._np_b)):
x = x @ W + b
if i < len(self._np_W) - 1:
x = np.maximum(0, x) # 中間層:ReLU
return np.clip(x.flatten(), 0, 1).tolist()
実行結果
動作中のLEDマトリクスの様子を以下に示す。
また、ログの出力例を以下に示す。
04:07:51 INFO life_game_ai: [CPU eval] 個体16: fitness=0.095 longevity=159 diversity=0.000
04:07:56 INFO life_game_ai: [CPU eval] 個体04: fitness=0.412 longevity=412 diversity=0.280
04:08:01 INFO life_game_ai: Gen 5: best_fitness=0.412 longevity=412 diversity=0.280 elapsed=5.1s
上記ではlongevityとdiversityが独立した値を示している。これより「長生きだが単純振動」と「中程度の寿命で複雑な振動子」がそれぞれ評価されていることが確認できる。
まとめ
Arduino UNO QのLEDマトリクス上で、GAとサロゲートモデルを組み合わせた長寿命・高多様性パターンの自動探索を実現した。
今回はテスト的な実装であるが、
- フィットネスを longevity(生存世代数) と diversity(サイクル周期) という独立した2指標で設計した
- サロゲートモデルによりCPU評価を全体の1/4に削減した
- バックエンド自動選択によりONNX-CPUからNumPyまで環境を問わず動作する
等により、Arduino環境とLinux環境を並列で使用したAI推論を実施することができた。
これはArduino UNO QがMPU×MCUから構成されるために、1ボードにて表現できたものである。
展望
次の記事では、この探索プロセスをブラウザからリアルタイムで監視する Web GUI(Flask + Canvas) の作成を検討してる。推論中のTOP5パターンの可視化等できる仕組みも実装したい。
