Google ClassroomGoogle Classroom
GeoGebraGeoGebra Classroom

圏論の視点で計算科学(プログラミング)を見よう

このページはマス旅の一部です。 今まで、圏論をターゲットにして、キーワードを軸にしていろんなツアーをやってきた。 今回からは、圏論のメガネをかけて、いろんなビルをじっくり見つめてみよう。 つまり、圏論はわき役にして、主役を立てるということです。 今回の主役は計算科学(プログラミング)です。 「自然変換」によって、圏は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 +"世代"

    ライフゲーム