🦀

【入門】1次元セルラー・オートマトンの概要からPythonによる作成まで

2023/07/14に公開
2
「セルラー・オートマトン」表記について

セルラー・オートマトンは「セル・オートマトン」や「セルラ・オートマトン」とも表記されることがありますが、本記事では参考にした「作って動かすALife」(オライリージャパン)に準拠しセルラー・オートマトンと表記します。

1. 前置き

1.1 セルラー・オートマトンとは

セルラー・オートマトンの概要を理解している人は読み飛ばしても大丈夫です。
セルラー・オートマトンは格子状に敷き詰められた「セル」が、周囲のセルの「状態」によって自身の状態を時間変化させる計算モデルです。

オートマトンについて簡単に説明すると、状態と入力をもとに結果を出力する計算機の構造です。(入力は必ずしも無くて良いです。)
例えば、自動販売機を例に挙げると「既に投入されている金額」を状態に持ち、「ボタンを押される」入力によって「商品とお釣り」を出力するオートマトンと捉えることが出来ます。

セルラー・オートマトンは各セルが状態を持ち、周囲のセルの状態と自身の状態を入力として、自身の次の状態を出力するオートマトンということですね。
セルが持つ状態には実際は様々な指定ができるのですが、今回は「生」と「死」の二つの状態を扱います。
また、自身と周囲のセルの状態と、次の状態の対応付けのことを「ルール」と呼びます。

字面での理解は難しいと思うので、以下に例として有名な二次元セルラー・オートマトンのルールである「ライフゲーム」の様子を示します。

正方形一つ一つがセルを表しており、ここでは白いセルが「生」の状態、黒いセルが「死」の状態にあります。
それぞれのセルが隣接するセルの影響を受けて状態を遷移させている様子が観察できますね。

1.2 セルラー・オートマトンの魅力

内容が難しい人、セルラー・オートマトンを早く作りたい人は読み飛ばしてください。理解できなくても問題ないです。
セルラー・オートマトンの面白さは結果を予測することの難しさや、その結果に潜む有意義さにあると思います。
セルラー・オートマトンはセル1つ1つがオートマトンでもありますが、大局的にみるとセルラー・オートマトン全体が「初期条件とルール」を入力に持ち「セルの状態の集合」を状態に持つ1つの大きなオートマトンと捉えることも出来ます。(出力はある時点で止めたときの最終的な状態です。)

セルラー・オートマトンの出力は決して意味のないものばかりではなく、有意義な結果が出力されることがあります。
例えば、上に示したライフゲームも、チューリング完全と呼ばれる性質を満たすことが知られています。「ライフゲームがチューリング完全を満たす」ことが何を意味するかをとても簡単に言うと、「パソコンが計算できることはライフゲームも計算できる」ということです。「(実際は複雑で難しいが理論上は)ライフゲームを用いてプログラミングすることができる」と言い換えても良いです。

この性質によって実現する面白い仕組みの一つとして、セルの状態遷移を(大袈裟に思えますが)セルラー・オートマトンによって計算することができます。すると、そのセルラー・オートマトンを成すセルもまたセルラー・オートマトンで計算できて、そのセルラー・オートマトを成すセルも...
と、まるで「宇宙の外には宇宙があって、その外にはもっと大きな宇宙が...」という様な無限ループが生まれるわけですね。
特に、最初のセルラー・オートマトンと、それによって計算されるセルの成すセルラー・オートマトンが全く同じものであるとき、このセルラー・オートマトンは「自己複製」していると言います。
自己複製の様子を実際に記録してくれた動画がyoutubeにあるのでリンクを共有します。
https://youtu.be/4lO0iZDzzXk
最初のセルラー・オートマトンから少しづつ離れてみると全体が最初と同じパターンを成していることがわかりますね。

また、セルラー・オートマトンは適切なルールを定義することによって人の流れや流体のシミュレーション等にも応用できることが知られています。

最初に作成する1次元のセルラー・オートマトンは、ライフゲームのような2次元のセルラー・オートマトンに比べて単純ですが、ルールによっては「シェルピンスキーのギャスケット」と呼ばれるフラクタル図形を描画するなど十分興味深い性質があります。

2. 1次元セルラー・オートマトンの基本

2.1 1次元セルラー・オートマトンの概要

1次元セルラー・オートマトンは、「1次元」という名前からも分かるように一直線に並んだセルによるセルラー・オートマトンです。任意のセルは、左隣のセルの状態と自分自身の状態と右隣りのセルの状態から次の状態を決定します。

ここまで読んで、両端のセルの処理について疑問を持つ方も多いと思います。左端のセルには左隣のセルが存在しないし、右端のセルには右隣りのセルが存在しないので両隣のセルの情報から自身の状態遷移を決定できませんよね。
これについては2つの処理方法が考えられます。
まず1つ目は、左端と右端を繋げる考え方です。つまり、左端のセルの左隣は右端のセル、右端のセルの右隣りは左端のセルと考えるという方法です。
次に、両端のセルは状態を固定するという考え方です。これは両端のセルは状態遷移を決定できないと諦めて、初期状態で固定してしまうという考え方です。
とりあえず初めは後者(両端は状態を固定)の方法で話を進めようと思います。

まずは簡単に3つのセルから成るセルラー・オートマトンを考えてみましょう。左端と右端は状態を固定しているので、中央のセルだけ状態遷移を考えれば大丈夫です。
ここでは仮に生きている状態を1、死んでいる状態を0で表します。また、1のセルを白色、0のセルを黒色でセルの並びを表現します。
例えば、010 というセルの並びは以下のように表します。
010
状態遷移について考えるために、仮に「両隣のセルがどちらも0で自身が1だった場合、0に遷移する」というルールを考えてみましょう。
先ほど示した 010 の並びは条件を満たすため、中央のセルは0に遷移し全体としては 000 となりますね。
000

以降、セルの状態の並びのことをパターン、特に遷移後のパターンのことを次世代パターンと呼びます。

2.2 ルールの定義

状態が 0 と 1 のみのとき、パターンは2^{3}=8通りしかありません。(パターン 000~111 が2進数表記の 0~7 に対応していることからも分かります。)
これならルールの定義として、各パターンに対する次世代セルを1つ1つ定義するのも難しくありませんね。

もしもこれが2次元オートマトンであれば、上下左右斜めと自身を合わせた9つのセルのパターンから遷移先は決定されるので、2^{9}=512通りの次世代パターンを決定せねばならず、ほとんど不可能に近いです。

ルールの正規表現

このように1つ1つのパターンに次世代パターンを対応付ける形でルールを定義したとき、1次元セルラー・オートマトンにはルールを簡潔に表現する方法があります。

まず、ここでは仮に次のように元のパターンと遷移後の中央セルの対応を定義してみます。
Alt text
この図で示したように、元のパターンを2進数と捉えたときの大きい順に並べ、次世代パターンの中央セルもそれに対応する順番に並べます。そして出来た数列(ここでは「00011110」)を2進数と捉えて10進数に変換します。この例では00011110_{(2)}=30_{(10)}となります。
この値を用いて、このルールを「ルール30」と呼びます。
次世代パターンの中央セルが成す数列は00000000_{(2)}から11111111_{(2)}まであるため、ルールは「ルール0」から「ルール255」まであることが分かります。

3. 1次元セルラー・オートマトンの作成

3.1 次世代パターンの生成

前置きが長くなりましたが、ここからセルラー・オートマトンの挙動をプログラミングで計算します。

まず最初に、前項で考えたような3つのセルからなり、中央のセルの遷移のみ考えればよいセルラー・オートマトンについて考えます。
具体的には、元のパターン(000~111)とルール(0~255)を入力すると次世代パターンを出力するコードを作ります。
難しくないので、最初にコード全体を示します。

#元のパターンの入力(000~111)
pattern_bin=input("Enter Original Pattern:")
#ルールの入力(0~255)
rule_dec=input("Rule:")

#元のパターンを10進数に変換
pattern_dec=int(pattern_bin,2)
#ルールを2進数に変換
rule_bin=bin(int(rule_dec))[2:].zfill(8)

#次世代パターンにおける中央セルの状態の取得
next_cell=rule_bin[7-pattern_dec]

#次世代パターンの生成
next_pattern=pattern_bin[0]+next_cell+pattern_bin[2]

#結果の出力
print(next_pattern)

例として実行結果をいくつか示します。

Enter Original Pattern:000
Rule:1
010
Enter Original Pattern:100
Rule:30
110
解説

次にコードについて簡単に解説をします。

#元のパターンの入力(000~111)
pattern_bin=input("Enter Original Pattern:")
#ルールの入力(0~255)
rule_dec=input("Rule:")

ここでは元のパターンとルールの入力を受け付けています。
元のパターンは3文字の2進数で入力し、ルールはルールの正規表現で示した方式に基づいて0~255の数で入力します。

#元のパターンを10進数に変換
pattern_dec=int(pattern_bin,2)
#ルールを2進数に変換
rule_bin=bin(int(rule_dec))[2:].zfill(8)

この後の処理でパターンは10進数、ルールは2進数として利用するので変換します。
n進数から10進数への変換にはint関数を使うことが出来ます。int関数は第二引数に2以上36以下の整数nを与えることによって、n進数から10進数への変換にも使えます。

10進数から2進数への変換にはbin関数を用いることが出来ます。bin関数は整数を引数として渡すことで値を2進数に変換してくれます。input関数の返り値であったrule_decはstr型のため、int型に変換してから引数に渡す必要があります。
また、bin関数の返り値は"0b"から始まるstr型であることに注意が必要です。頭の0bは邪魔なので、[2:]によって文字列のスライスを行い数字部分のみを取り出しています。
さらに、rule_binには8通りすべてのパターンに対して次世代パターンを明らかにしてほしいため、8桁に満たない場合はzfill(8)によって8桁になるように頭に0を補います。

#次世代パターンにおける中央セルの状態の取得
next_cell=rule_bin[7-pattern_dec]

ルールの2進数表記は左端の桁から順にパターン111_{(2)},110_{(2)},...,000_{(2)}(7_{(10)},6_{(10)},...,0_{(10)})の次世代パターンを示していました。そのことから、rule_binの左から7-pattern_dec文字目が次世代パターンにおける中央セルの状態であることが分かります。

#次世代パターンの生成
next_pattern=pattern_bin[0]+next_cell+pattern_bin[2]

#結果の出力
print(next_pattern)

最後に、文字列の結合によって次世代パターンを生成し出力します。

3.2 4列以上の1次元セルラー・オートマトン

3列のセルラー・オートマトンが完成したので、次は4列以上のセルラー・オートマトンを作成してみましょう。
4列以上のセルラー・オートマトンでは、状態の遷移するセルが複数存在します。そこで、i番目のセルの次の状態を計算した後、i番目のセルの状態を更新してからi+1番目のセルの処理に移るか、あるいは全てのセルの次の状態を計算した後一斉に状態を更新するか、の2通りの処理が考えられます。
ここでは一般的によく用いられる後者の手順(最後に一斉に状態を更新する)で話を進めます。
また、両端については3列のセルラー・オートマトンと同様に状態を固定します。

4列のセルラー・オートマトンの計算アルゴリズムを簡単に説明すると

  1. i番目のセルとその両隣のセルの状態を取得する。
  2. 3列のセルラー・オートマトンと同様にルールに基づいた遷移先を求める。
  3. 両端以外の全てのiについて1. ,2. の処理をした後、状態の一斉更新を行う。

と、3つの部分に分けられます。
まずは1. の処理を関数化しましょう。


def get_status(cells_array,i):
    status_str=f"{cells_array[i-1]}{cells_array[i]}{cells_array[i+1]}"
    return status_str

第一引数cells_arrayはセル全体の配列、第二引数iは遷移先を求める中心セルのインデックスです。
返り値は3つのセルの状態の並びです。普通ならlist型で返すと思いますが、あとで数値として扱いたい都合上、容易に型を変換できる文字列として並べています。

次に、get_state関数を用いて2. の処理を関数化します。

def calculate_pattern(cells_array,rule_dec,i):
    pattern_bin=get_status(cells_array,i)
    #元のパターンを10進数に変換
    pattern_dec=int(pattern_bin,2)
    #ルールを2進数に変換
    rule_bin=bin(rule_dec)[2:].zfill(8)
    #次世代パターンにおける中央セルの状態の取得
    next_cell=int(rule_bin[7-pattern_dec])

    return next_cell

第一引数cells_arrayはセル全体の配列、第二引数rule_decは0~255で表されたルール、第三引数iは遷移策を求める中心セルのインデックスです。
返り値は中心セルの遷移後の状態です。
この関数では、まずget_status関数を使用して、2進数表記のパターンであるpattern_binを取得します。その後の遷移パターンを計算する処理は、3列のセルラー・オートマトンの時と基本的に同じです。後で示しますが、引数rule_decはint型なので型変換は要らなくなっています。

最後に、3. の処理を行うrenew関数を示します。

def renew(cells_array,rule):
    #セルの数を取得
    array_size=len(cells_array)

    #一時的にセルの並びを保持する配列
    temp_array=[0]*array_size
    temp_array[0]=cells_array[0]
    temp_array[array_size-1]=cells_array[array_size-1]

    #i=1,2,...,array_size-1
    for i in range(1,array_size-1):
        next_pattern=calculate_pattern(cells_array,rule,i)
        temp_array[i]=next_pattern

    return temp_array

第一引数cells_arrayはセル全体の配列、第二引数ruleはルールです。

セルの状態を一斉更新するため、一時的に各セルの状態を保存する配列が必要なためtemp_arrayを定義しています。初期値として0を格納し、セル全体の配列と同じサイズにします。また、状態が固定されている両端の値のみこの段階で格納します。

for文では1~array_size-2の範囲(左端と右端を除いた全体)に対して次の状態を求め、temp_arrayの自身と同じインデックスに結果を保存しています。

返り値としてtemp_arrayを返し、renew関数の外でcells_arrayに値を渡します。

最後に、renew関数の実行も含めたコードの全体を示します。

def get_status(cells_array,i):
    #省略

def calculate_pattern(cells_array,rule_dec,i):
    #省略

def renew(cells_array,rule):
    #省略

cells_array=[0,0,0,0,1,0,0,0]
RULE=90

print(cells_array)

cells_array=renew(cells_array,RULE)
print(cells_array)

今回の例では、セルの初期配列とルールも直接コード内で定義しています。
(後で大きな配列について実行する場合に、ターミナルで初期条件を与えるよりもコード内で定義した方が楽で、タイポも防ぎやすいと個人的に考えているからです。)

このコードを実行すると次のような結果が得られます。

[0, 0, 0, 0, 1, 0, 0, 0]
[0, 0, 0, 1, 0, 1, 0, 0]

3.3 繰り返し処理

初期状態を与えて、その1つ次のセルの状態が分かるだけではあまり面白くないですよね。先程作成したコードを用いて、指定の回数だけ状態の遷移を計算するプログラムを作成します。

用いる関数は先ほど作成したget_status,calculate_pattern,renewのみです。
繰り返す回数count_numを定義し、renew関数を次のようにcount_num回for文で繰り返すだけです。

以下に実際のコードを示します。

def get_status(cells_array,i):
    #省略

def calculate_pattern(cells_array,rule_dec,i):
    #省略


def renew(cells_array,rule):
    #省略

cells_array=[0,0,0,0,1,0,0,0]
RULE=90
COUNT_NUM=8
for i in range(COUNT_NUM):
    cells_array=renew(cells_array,RULE)
    print(cells_array)

このコードを実行すると、次のような結果が得られます。

[0, 0, 0, 0, 1, 0, 0, 0]
[0, 0, 0, 1, 0, 1, 0, 0]
[0, 0, 1, 0, 0, 0, 1, 0]
[0, 1, 0, 1, 0, 1, 0, 0]
[0, 0, 0, 0, 0, 0, 1, 0]
[0, 0, 0, 0, 0, 1, 0, 0]
[0, 0, 0, 0, 1, 0, 1, 0]
[0, 0, 0, 1, 0, 0, 0, 0]
[0, 0, 1, 0, 1, 0, 0, 0]

0と1が何か模様を描こうとしているようにも見えますね。

基本的な1次元セルラー・オートマトン処理の実装はこれで終了です。

4. Pillowを用いたセルの描画

実行結果が得られても、0と1の羅列ではぱっと見ただけでは何がどうなっているのか分かりずらいです。要素数がさらに多いセルラー・オートマトンを実行すればなおさら分かりずらいです。

そこで、前置きで示したような白と黒の正方形を用いたセルの描画を実装していきます。

Pythonで扱える画像処理ライブラリにはOpenCV,Pillowなどがあります。ここでは導入の簡単なPillowを利用します。
この記事ではPillowの扱いについて必要最低限のことしか説明しないため、詳しく知りたい人は各自調べてみてください。

・Pillowのインストール

Pillowは次のコマンドでインストールできます。

pip install Pillow

インストールに失敗する場合はpipのバージョンが古い可能性もあるので、次のコマンドでpipをアップグレードしましょう。

python -m pip install --upgrade pip

4.1 正方形の描画

Pillowは様々な画像処理が可能なライブラリですが、今回の目的に限っては正方形の描画までできれば問題ありません。
手始めにセルラー・オートマトンからは少しだけ離れて、単純な正方形の描画を行いPillowに慣れましょう。

画像の生成、出力

まず、正方形を描画する前に描画するための画像が必要なので生成します。

from PIL import Image
img=Image.new("L",(600,400),color=0)
img.show()

まず、1行目ではPillowライブラリからImageモジュールをimportしています。
Imageモジュールは画像の生成、読み込み、出力など画像に対する基本的な処理を行うためのモジュールです。

2行目では、Imageモジュール内のnew関数を用いて初期画像を生成し変数imgに与えています。

第一引数では、画像の形式(mode)を与えることができます。
与えることの多い値として、カラー画像を扱う"RGB"、グレースケールを扱う"L"があります。今回は白と黒だけ表せたら十分なので、"L"を指定しています。

第二引数では、画像のサイズをタプルとして与えます。タプルの1つ目の要素が横幅、2つ目の要素が高さを表します。

第三引数では画像全体の色を指定します。
色の指定方法は第一引数で指定したmodeによって変わります。今回は8bit形式のグレースケールを扱う"L"を指定したので、0~255 の整数値を与えます。0 を与えると黒色になり、値を大きくする程白に近づき、255 で白色になります。この後の処理での色の指定も同様に行います。

最後に、imgに対してshowメソッドを呼び出すことで画像の描画を行います。

実際にこのコードを実行すると、横長の真っ黒な長方形が描画されると思います。
実行結果

正方形の描画

次に、先ほど生成した画像に正方形を描画します。
とはいっても、関数一つで描画できるのでとても簡単です。

最初にコード全体を示します。

from PIL import Image,ImageDraw

img=Image.new("L",(600,400),color=0)

draw=ImageDraw.Draw(img)
draw.rectangle(((100, 100), (300, 300)), fill=255, outline=255)

img.show()

まず、先ほどもimportしていたImageモジュールに加えてImageDrawモジュールをインポートします。これは画像に図形を描画するのに用いるモジュールです。

そして、画像imgに対して図形を描画するためのDrawオブジェクトを生成します。
オブジェクト指向に慣れておらず良く分からない方は、おまじないだと思っておけば大丈夫です。

ここで生成したdrawオブジェクトに対し、関数rectangleを実行します。」

まず、第一引数で矩形の位置と大きさを指定します。
((左上の点のx座標, 左上の点のy座標), (右下の点のx座標, 右下の点のy座標))の形で指定します。(左上の点のx座標, 左上の点のy座標, 右下の点のx座標, 右下の点のy座標)の形でも指定できますが、可読性の観点からここでは前者を使用しています。
ここで注意が必要なのが、画像処理における一般的な座標軸の設置についてです。
画像処理においては基本的に左上の角の座標を(0,0)とし、右向きをx軸の正の向き、下向きをy軸の正の向きとします。数学でよく用いるような座標軸とはy軸の向きが逆になっています。

次に、引数fillに矩形内の色を指定します。ここでは白色を描画したいので255を指定しています。
最後に、引数outlineに矩形の縁の色を指定します。ここでは矩形内の色と同様に白色を指定していますが、縁取りさせたい場合は適宜指定してください。

実行結果

4.2 セルの描画

では、実際にセルを描画してみましょう。
ここでも先ほどと同じ内容のget_state,calculate_pattern,renew関数を利用します。

それに加え、全体のパターン(複数回試行する場合は全てまとめて)を受け取りそれを描画する関数visualize_patternを定義します。早速コードを示します。

def visualize_pattern(pattern, cell_size):
    #パターンの行数(高さ)と列数(幅)を取得
    rows=len(pattern)
    cols=len(pattern[0])

    #画像の幅と高さを計算
    width=cols*cell_size
    height=rows*cell_size

    img=Image.new("L",(width, height),color=0)

    draw = ImageDraw.Draw(img)

    #各セルの描画
    for i in range(rows):
        for j in range(cols):
            #セルの状態を取得
            cell=pattern[i][j]

            #セルの左上と右下の座標を計算
            top_left=(j*cell_size, i*cell_size)
            bottom_right=(top_left[0]+cell_size, top_left[1]+cell_size)

            #セルが1なら白色
            if cell==1:
                color=255
            #セルが0なら黒色
            else:
                color=0

            #セルを描画
            draw.rectangle((top_left, bottom_right), fill=color, outline=color)

    #生成した画像を返す
    return img
解説

まずは引数について説明します。
第一引数patternは全体のパターンを受け取ります。全体のパターンというのは、3.3繰り返し処理での実行例においては、次のような二次元配列を意味します。

[
[0, 0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 1, 0, 1, 0, 0],
[0, 0, 1, 0, 0, 0, 1, 0],
[0, 1, 0, 1, 0, 1, 0, 0],
[0, 0, 0, 0, 0, 0, 1, 0],
[0, 0, 0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 1, 0, 1, 0],
[0, 0, 0, 1, 0, 0, 0, 0],
[0, 0, 1, 0, 1, 0, 0, 0],
]

この配列におけるセルの位置は今から描画する画像のセルの位置と一致しているので、感覚的に扱いやすいです。

第二引数のcell_sizeは一つのセルの一辺の長さ[px]を表します。
試行回数や列の数が少ない場合は大きい値でもいいですが、大量のセルについて計算する場合は少なくする必要があります。

#パターンの行数(高さ)と列数(幅)を取得
rows=len(pattern)
cols=len(pattern[0])

ここでは、第一引数patternの行数rowsと列数colsを取得しています。
行数は試行回数、 列数は横に並んでいるセルの数を意味してますね。

#画像の幅と高さを計算
width=cols*cell_size
height=rows*cell_size

img=Image.new("L",(width, height),color=0)

draw = ImageDraw.Draw(img)

先ほど計算したrows,colscell_sizeから生成する画像の横幅と高さを計算します。
その後の新しい画像とDrawオブジェクトの生成は先ほどと全く同じです。

    #各セルの描画
    for i in range(rows):
        for j in range(cols):
            #セルの状態を取得
            cell=pattern[i][j]

            #セルの左上と右下の座標を計算
            top_left=(j*cell_size, i*cell_size)
            bottom_right=(top_left[0]+cell_size, top_left[1]+cell_size)

            #セルが1なら白色
            if cell==1:
                color=255
            #セルが0なら黒色
            else:
                color=0

            #セルを描画
            draw.rectangle((top_left, bottom_right), fill=color, outline=color)

    #生成した画像を返す
    return img

このfor文はセルを一つずつ指定し描画する繰り返し処理になっています。
i行目j列のセルの左上の点の座標が(j*cell_size,i*cell_size)になることや、右下の点の座標が左上の点のx座標y座標それぞれにセルの一辺の長さcell_sizeを足したものであることも特に難しくないでしょう。

全てのセルについてfor文を実行した後、関数visualize_patternは画像imgを返します。

最後に、visualize_pattern関数の実行も含めた全体の処理を示します。

from PIL import Image, ImageDraw
def get_status(cells_array,i):
    #省略

def calculate_pattern(cells_array,rule_dec,i):
    #省略

def renew(cells_array,rule):
    #省略

def visualize_pattern(pattern, cell_size):
    #省略

cells_array=[0,0,0,0,0,1,0,0,0,0,0]
RULE=90
COUNT_NUM=10
CELL_SIZE=30

#全てのパターンを格納する配列
patterns=[]
patterns.append(cells_array)

for i in range(COUNT_NUM):
    cells_array=renew(cells_array,RULE)
    patterns.append(cells_array)

img=visualize_pattern(patterns, CELL_SIZE)

img.show()

visualize_pattern関数の第一引数の説明で話したように、全てのパターンを格納した配列を引数としてあたえる必要があるため、その役割を担う配列patternsを定義しています。
patternsは空の配列として定義し、定義後に初期パターンを1つ目の要素として格納しています。
その後、for i in range(COUNT_NUM):で試行するごとに生成されたパターンをpatternsに格納し、全ての試行が終了した後にvisualize_pattern関数を呼び出しています。
その返り値をimgで受け取り、.show()で出力することで処理は終了です。

実行

先ほど示したコードと同じ初期値で実行すると、次のような画像が出力されると思います。
実行結果
なんだか不思議な画像ですね。
ルールは90のまま、初期パターンを次のように定義しより大きくしてみましょう。

cells_array=[0]*301
cells_array[150]=1

301列あって、真ん中のセルのみが1という初期パターンですね。
このまま実行すると、パターンの数に対して試行回数が少なすぎる(画像がかなり横長になる)ことと、セルの一辺が大きすぎて画像が大きくなりすぎるので、COUNT_NUMCELL_SIZEも次のような値に設定してみましょう

COUNT_NUM=300
CELL_SIZE=2

この設定で再度実行してみましょう。すると、次のような画像が出力されます。
実行結果
かなり不思議な画像が生成されましたね。
これは「シェルピンスキーのギャスケット」と呼ばれる有名なフラクタル図形です。
実は、ルール90というのはシェルピンスキーのギャスケットを描画することができるルールだったんですね。

他にもいくつかの初期値による出力結果を示します。

cells_array=[0]*301
cells_array[150]=1
RULE=45
COUNT_NUM=300
CELL_SIZE=2

cells_array=[0]*301
cells_array[150]=1
RULE=86
COUNT_NUM=300
CELL_SIZE=2

また、この記事では解説を出来るだけ短くするために「両端のセルの状態は固定する」前提で最後まで話を進めました。しかし、両端を繋げる処理をした方が面白い模様が現れることもあります。この処理の実装は難しくないのでここでは省略しますが、是非試してみてください。

参考図書

作って動かすALife ―実装を通した人工生命モデル理論入門

人工生命(Alife)の作成を目標にする過程で、セルラー・オートマトンについても非常に詳しく書かれています。セルラー・オートマトンの研究がどのように進んできたかも詳細に書かれており、より興味をそそられると思います。

また、人工生命やセルラー・オートマトンを簡単に作成するためのライブラリも附属しており、複雑なプログラムを書く必要もほとんどありません。(そこで書く必要のないコードを書くのがこの記事の一つの目的でもあります。)
Amazonのリンクも張っておきます。登録(?)がめんどくさい気がしたので、アフィリエイトでは無いです。
https://amzn.asia/d/fSUIuFM

おすすめ動画

僕が直接的にセルラー・オートマトンに興味を持ったのは先述の本の影響ですが、人工生命モデルに興味を持ちこの本を読む大きなきっかけになった動画を紹介します。

https://www.nicovideo.jp/watch/sm41192001

人工生命のモデルがとても簡単に説明されており、シリーズの後の動画でも様々な自然発生した生命について面白おかしく説明されています。

Discussion