圏論の視点で計算科学(プログラミング)を見よう
このページはマス旅の一部です。
今まで、圏論をターゲットにして、キーワードを軸にしていろんなツアーをやってきた。
今回からは、圏論のメガネをかけて、いろんなビルをじっくり見つめてみよう。
つまり、圏論はわき役にして、主役を立てるということです。
今回の主役は計算科学(プログラミング)です。
「自然変換」によって、圏は3階建てになるで見たように、計算科学のビルはこうなってたね。
1階:圏 C(点a,b,...と矢f,g,...) 点が型(Type)で、つなぐ矢が関数(Function)が住む。 2階:ファンクター F: C→D (圏の間の構造を保つ写像) 型をコンテナに入れて持ち上げて、矢もこわさずに持ち上げる力持ち。 このコンテナがファンクターという住人だった。 3階:自然変換 Nat: F→G(ファンクターの間の構造を保つ写像) 公平にファンクターたちを見下ろして、通訳したり、結婚相談を自然に行うマネージャーがいる。
さあ、このビルで起きることをくわしく観察してみよう。
観察の視点は3つだ。
○ 中身ではなく、見える振る舞いから決めるという「圏論」の視点
○ 一番無駄のない作りにするという「普遍性」の視点
○ 逆の個性(Xとco-X)が表裏一体で共存する「対称性」の視点
さあ、出かけよう。
1.各階を観察しよう。
<1F圏>
プログラミングの圏のメンバーは型とその変換関数でした。
「圏論」の視点
オブジェクト指向プログラミング(OOP)には、
クラス型、インターフェース型というのがあったね。
データの中身を外から隠して、外側でやれる操作と外側から見える振る舞いだけで
安全で柔軟なデータ活用ができるという思想だった。これって圏論そのものだね。
そして、「米田の補題」は、
「君の内部構造なんて興味ないよ。君の他人への振る舞いの情報だけで、君の正体(型)は決まるからね」ということになる。
<2Fファンクター>
型Tのものに文脈をつけてくるむコンテナがファンクターだった。
「圏論」の視点
List も Option も Future も、中身のロジックは別々だ。
でも、外から見た振る舞いは、「中の要素に関数を適用して移す(map ができる)」と
いう点では同じ仲間だね。
「対称性」の視点
計算結果の貯蔵庫として常用されるのがリストだった。これもファンクターだ。
生のデータではなくそれを格納するList(律儀にコツコツため込む君)に、
放り込んだり、取り出したりできたね。
でもこれらはすべて、その都度、過去を見ている。
だから、貯蔵庫のスペースの確保とデータ評価が必要になる。
データを溜め込むファンクター(Listなど)は過去の計算結果の集まり(データ)。
それと対照的なのが遅延評価(いわれた初めて動き出すLazy君)という、
未来に向けたデータの持ち方がったね。
要求されたら計算するよ。という方式だから、無限の個数のある整数全体?も計算対象にできた。
くわしくはこちら。
データを生み出すファンクター(generatorなど)は、
未来の要求に応じて値を吐き出すプロセス(コ・データ)。
<3F自然変換>
ファンクターを自然につなぎます。
「普遍性」の視点
Listの中のデータを、Optionalへと安全に写す共通処理(例えば、リストの先頭だけを安全に取り出す safe_head 関数)を考えよう。
この処理によって、中身のデータが int であれ string であれ、「リスト全体に関数を適用してから先頭を取る」のと「先頭を取ってからその関数を適用する」結果は必ず一致するので、
ジェネリクス(型多相性)の安全性
が保証されます。自然変換があるおかげですね。
「対称性」の視点
「引数を2つ同時に受け取る関数 (A, B) -> C」と、「1つずつ受け取る関数 A -> B -> C」の間には、
完璧な1対1の対応(随伴関係)を保証する自然変換があります。
カリー化(Currying)ができるのは自然変換のおかげですね。
2.(パック/アンパック)×(データ/コ・データ)
最近、スプレッド構文やアンパック演算子など、
リスト化と反対のコ・リスト化が日常化しているようですね。そこを深堀してみましょう。
<1Fでの使い方>
たとえば、Pythonではリストやタプル変数名の先頭に「*」をつけるだけで、ごっそり取り出せます。
# 箱のまま扱う
>>>numbers = (3, 4, 12)
>>>print(numbers)
(3, 4, 12)
# アンパック(*)で箱を消し去る
>>>print(*numbers)
3 4 12
これだけだと、何のありがたみもわかりませんね。
でも、関数の引数のデータ渡しで使うとスゴ~く便利です。
numbersが3次元ベクトルの成分だとして、そのベクトルの長さを出したいとしましょう。
def Len(x,y,z):
return (x**2+y**2+z**2)**0.5
すぐ思いつくのは、
>>>Len(numbers[0],numbers[1],numbers[2])
13.0
と答えがでます。
ここで、アンパック演算子の登場。
>>>Len(*numbers)
たったこれだけです。
タプル型でもリスト型でも同じように使えますね。
辞書型の場合は**と2つになりますが、使い方は同じです。
べきみたいですが、先頭につけるので誤解はないですよね。
pointDic = {'x':3,'y':4,'z':12}
print(Len(**pointDic))
<2Fでの使い方>
パックとアンパックが共存します。
list1 = [1, 2, 3]
list2 = [4, 5, 6]
# 2つのリストからデータを取り出し、1つの新リストへ平らにつなぎます。
>>> combined = [*list1, *list2]
[1, 2, 3, 4, 5, 6]
#辞書でもフラット化して要素を展開して、大きな辞書が作れますね。
>>> dict(**{'x': 1}, y=2, **{'z': 3})
{'x': 1, 'y': 2, 'z': 3}
アンパック演算子は、キーがかぶってないと、その順にただ展開されるだけです。
>>> {'x': 1, **{'y': 2}}
{'x': 1, 'y': 2}
しかし、
キーがかぶると、右(あと)が優先されるので、このロジック無傷の更新ができるわけです。
>>> {'x': 1, **{'x': 2}}
{'x': 2}
>>> {**{'x': 2}, 'x': 1}
{'x': 1}
そうすると、bigdataという10次元の点の座標が入った辞書があるとして、'x0'の座標だけ0にしたかったら、
newbig={**bigdata, 'x0':1}
とするだけです。
この省エネのデータ更新方法は、TypeScriptではスプレッド構文として、嵐のように多用されます。
2辞書dとeの連結はd|eとかけることも使うと、辞書の更新がカンタンにかけますね。
telenum = {'sasaki': '09000900990', 'yamamoto': '09011231234', 'suzuki':'09056569898' }
tel2 = {**telenum | {'sasaki':'08012345678'}} #1キー更新
くわりくはこちら。
<3Fでの使い方>
アンパックもジェネレータもファンクターでした。
これをつないで使うことで、面白ことができます。
要求されたら計算するという遅延評価(ジェネレータ)とリストのアンパックを使うのです。
すると、geogebraのスライダーのような仕組みがつくれますよ。
# 【コ・データ】GeoGebraのスライダー t のように、要求に応じて動的に座標を吐き出すジェネレータ
def cycloid_stream(steps=3):
import math
for i in range(steps):
t = i * (math.pi / 2)
# サイクロイドの媒介変数表示 (x(t), y(t))
yield (t - math.sin(t), 1 - math.cos(t))
# ジェネレータ(未来の計算ルール)を、* で一気にメモリ上のリスト(確定した過去)へ展開する
points = [*cycloid_stream()]
print(points)
# [(0.0, 0.0), (0.5707..., 1.0), (3.1415..., 2.0)]
ここまで読んで、もうお気づきの人もいるでしょう。
Pythonで関数を学ぶと、関数に渡す引数で、位置、パラメータ、可変長という区分があることを知りますね。そのときに、可変長データは*を先頭につけました。
*/**は関数定義のときはいくつもある要素を1つの変数にパッケージ化する形の定義です。
def pack_all(*args)のように引数の場所で*args 、**kwargsとアンパック演算子を使って関数定義をします。
*/**は変数を使うときにつけると、1つの変数のパックをひらくことができました。
関数にpack_allに変数を放り込まれると、アンパックして参照されます。
同じ記号が「双対の原理」で使われていることに気づきますね。
3.モナドとコ・モナド
<モナド>
モナドは2Fのいろんな文脈のファンクターをつないでつかう自然変換です。
要するにファンクターパイプラインですね。こちらにあるように、
from pymonad.maybe import Just, Nothing
# 各ステップ:結果をJustかNothingのカプセルに入れて返す
def extract_profile(d):
return Just(d["profile"]) if "profile" in d else Nothing
def extract_address(p):
return Just(p["address"]) if "address" in p and p["address"] is not None else Nothing
def extract_zip(a):
return Just(a[:3]) if len(a) >= 3 else Nothing
def get_zip_code_monad(raw_data):
return (
Just(raw_data) # Justでくるむ
.then(extract_profile) # プロフィール抽出
.then(extract_address) # 住所抽出
.then(extract_zip) # 郵便番号抽出
)
この
get_zip_code_monad
がモナドです。
ファンクターをくるんで1つにパックしているからです。
次のリスト内包表記もモナドですね。
# 1つの要素から複数の要素(リスト)を返す関数をチェーンし、結果を1つのリストに平坦化する
result = [y for x in [1, 2, 3] for y in [x, -x]]
print(result)# 結果: [1, -1, 2, -2, 3, -3] (二重リスト[[1,-1], [2,-2], [3,-3]]が平坦化されている。)
モナドは「1つにまとめる」作用がありますね。
○ 複数のデータを1つにパックします。
unit : A → M[A](包む)
○ 平坦にして1つの中に入れます。bind : M[A] → (A → M[B]) → M[B](平坦化して連結)
<コモナド>
コ・モナドは名前の通り、モナドと双対です。
モナドを大量のデータで処理することで、エラーを起こさずに安全に処理をして、欠損データログを残すという処理ができます。これは全体をまとめて実行して、結果のまとめという静止した安全な世界です。
コモナドは「1つにまとめるの反対」の作用があります。
○ パックから1点とりだします。extract : W[A] → A(コンテキストから値を抽出)
○ 1つの中から文脈全体に広げます。extend : (W[A] → B) → W[B](コンテキスト全体に拡散)
たとえば、ライフゲーム(Cellular Automaton)や画像の平滑化(ぼかし)フィルタのロジックは共通点がありますね。
1点の情報を取り出して、それを近傍に影響させるというルールの波を平面の各点で起こします。その波の効果が次々と起きて、全体が変わっていきます。局所の影響が全体に波及しますね。
これがコモナドです。課題:10セル×10セルのライフゲームは、コモナドの発想で作ろう。
count_neighbors が「extract(局所観測)」のコモナドです。
next_generation が「extend(全体への波及)」のコモナドです。
グリッド全体を「文脈(環境)」として扱い、
各セルのローカルルールを全体に広げて新しい世界を作る。
まさに コモナドの典型例 ですね。
「ライフゲームのルール」は単純で、次の2つで生存か死になる。
# 生存ルール: 自分が「生(1)」かつ「周囲が2または3」
# 誕生ルール: 自分が「死(0)」かつ「周囲が3」
これをnext_generation に組み込もう。
[IN]Python
import os
import random
import time
# 画面サイズ(5×5)
WIDTH = 5
HEIGHT = 5
ALIVE = "◙"
DEAD = "○"
def create_initial_grid():
"""初期盤面をランダム生成"""
return [
[random.choice([1, 0]) for _ in range(WIDTH)]
for _ in range(HEIGHT)
]
def count_neighbors(grid, r, c):
"""周囲8マスの生存セル数を数える(トーラス構造)"""
count = 0
for dr in (-1, 0, 1):
for dc in (-1, 0, 1):
if dr == 0 and dc == 0:
continue
nr = (r + dr) % HEIGHT
nc = (c + dc) % WIDTH
count += grid[nr][nc]
return count
def next_generation(grid):
"""コモナドの extend:全セルにローカルルールを適用"""
new_grid = [[0] * WIDTH for _ in range(HEIGHT)]
for r in range(HEIGHT):
for c in range(WIDTH):
neighbors = count_neighbors(grid, r, c)
state = grid[r][c]
if state == 1 and neighbors in (2, 3):
new_grid[r][c] = 1
elif state == 0 and neighbors == 3:
new_grid[r][c] = 1
else:
new_grid[r][c] = 0
return new_grid
def draw(grid, generation):
os.system("cls" if os.name == "nt" else "clear")
print(f"=== LIFE GAME (Generation: {generation}) ===")
print("+" + "-" * WIDTH + "+")
for row in grid:
line = "".join(ALIVE if cell == 1 else DEAD for cell in row)
print(f"|{line}|")
print("+" + "-" * WIDTH + "+")
time.sleep(0.3)
def main():
grid = create_initial_grid()
for generation in range(5): # ★ 5世代だけ実行
draw(grid, generation)
grid = next_generation(grid)
print("\n10世代のシミュレーションが終了しました。")
if __name__ == "__main__":
main()
実行例
[OUT]
=== LIFE GAME (Generation: 0) ===
+-----+
|○○○◙○|
|○◙○○◙|
|○○○○◙|
|◙◙○◙○|
|◙◙○○○|
+-----+
=== LIFE GAME (Generation: 1) ===
+-----+
|○◙◙○◙|
|◙○○◙◙|
|○◙◙◙◙|
|○◙◙○○|
|◙◙○○○|
+-----+
=== LIFE GAME (Generation: 2) ===
+-----+
|○○◙○○|
|○○○○○|
|○○○○○|
|○○○○◙|
|○○○◙○|
+-----+
=== LIFE GAME (Generation: 3) ===
+-----+
|○○○○○|
|○○○○○|
|○○○○○|
|○○○○○|
|○○○◙○|
+-----+
=== LIFE GAME (Generation: 4) ===
+-----+
|○○○○○|
|○○○○○|
|○○○○○|
|○○○○○|
|○○○○○|
+-----+
=== LIFE GAME (Generation: 5) ===
+-----+
|○○○○○|
|○○○○○|
|○○○○○|
|○○○○○|
|○○○○○|
+-----+
課題:geogbraでもライフゲームを作ろう。
geogebraは行列が得意なので、setValueというスクリプトと行列の積を使って、次の状態を計算しよう。
#5×5のセルの1つ1つに隣接した位置があります。
#そこだけ抜き出すために、
#25個のセル1つ1つに対して、どのセルがとなりかの情報が必要です。隣接してれば1をしてなければ0を入れたリストをならべます。
#サイズ25のリストが25個格納された隣接行列がAです。
#隣接行列 A
A = {{0,1,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},{1,0,1,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},{0,1,0,1,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},{0,0,1,0,1,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},{0,0,0,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},{1,1,0,0,0,0,1,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0},{1,1,1,0,0,1,0,1,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0},{0,1,1,1,0,0,1,0,1,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0},{0,0,1,1,1,0,0,1,0,1,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0},{0,0,0,1,1,0,0,0,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0},{0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,1,1,0,0,0,0,0,0,0,0},{0,0,0,0,0,1,1,1,0,0,1,0,1,0,0,1,1,1,0,0,0,0,0,0,0},{0,0,0,0,0,0,1,1,1,0,0,1,0,1,0,0,1,1,1,0,0,0,0,0,0},{0,0,0,0,0,0,0,1,1,1,0,0,1,0,1,0,0,1,1,1,0,0,0,0,0},{0,0,0,0,0,0,0,0,1,1,0,0,0,1,0,0,0,0,1,1,0,0,0,0,0},{0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,1,1,0,0,0},{0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,1,0,1,0,0,1,1,1,0,0},{0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,1,0,1,0,0,1,1,1,0},{0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,1,0,1,0,0,1,1,1},{0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,1,0,0,0,0,1,1},{0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0},{0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,1,0,1,0,0},{0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,1,0,1,0},{0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,1,0,1},{0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,1,0}}
# 状態ベクトルvの初期値
v = {0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
# 世代カウンター
g = 0
count = {0}
# ツールバーからボタンを選び画面にはりつけます。見出しは「次の世代」
# 世代を1つ進め、A.dot(v) を行い結果をvと同サイズのベクトルcountにセットします。
# ライフゲームのルール
# 生存ルール: 自分が「生(1)」かつ「周囲が2または3」
# next_v[(v == 1) & ((neighbor_counts == 2) | (neighbor_counts == 3))] = 1
# 誕生ルール: 自分が「死(0)」かつ「周囲が3」
# next_v[(v == 0) & (neighbor_counts == 3)] = 1
# によって、v状態ベクトルを更新しましょう。次のスクリプトを貼り付けましょう。
SetValue(g, g + 1)
Execute({"count = A * Transpose({v})"})
SetValue(v, Sequence(If((Element(v, i) == 1 && (Element(count, i, 1) == 2 || Element(count, i, 1) == 3)) || (Element(v, i) == 0 && Element(count, i, 1) == 3), 1, 0), i, 1, 25))
# ツールバーからボタンを選び画面にはりつけます。見出しは「リセット」、スクリプトは以下の通り
SetValue(g, 0)
SetValue(v, {0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0})
#表示用のグリッドを作ります。
#不透明度の設定をいじるのではなく、「v の中身が 1 の場所だけに自動で四角形を描画する(0 の場所は描画しない)」にします。
# vはr行c列の行列を1次元のベクトルにしているので、読み取った結果をvの(r - 1) * 5 + c番目とします。そこが1のときだけ描画します。
Sequence(Sequence(If(Element(v, (r - 1) * 5 + c) == 1, Polygon((c, -r), (c + 1, -r), (c + 1, -r - 1), (c, -r - 1))), c, 1, 5), r, 1, 5)
# ツールバーからボタンを選び画面にはりつけます。見出しは「ランダムな初期値」、
スクリプトは以下の通り
SetValue(g, 0)
SetValue(v, Sequence(RandomBetween(0,1),k,1,25))
#数式に次の行をはりつける。できたテキストラベルを適当に移動してください。
""+g +"世代"