ラベル プログラム運算 の投稿を表示しています。 すべての投稿を表示
ラベル プログラム運算 の投稿を表示しています。 すべての投稿を表示

2017/11/05

HylomorphismとMetamorphism

はいどうも! Recursion SchemeのHylomorphismとMetamorphismの話です。 今年のアドベントカレンダーに向けたブログ投稿リハビリ用の記事になります。 

hylomorphismは、catamorphismとanamorphismの合成、 metamorphismは、hylmorphismの双対、anamorphismとcatamorphismの合成に当たる射になります。

catamorphismやanamorphismは、(単方向連結)Listで言う所のfoldrとunfoldに当たるものです。

(以下、長いため、catamorphismをcataと書くなど、適宜morphismを省いて書きます。)

hylomorphismは、cataとanaの合成射であり、
hylo = cata . ana
という合成です。 

プログラムにおけるhyloは、foldr/unfoldの合成、つまりデータの生成処理(unfold)と、 データの消費処理(foldr)が融合するような関数になります。

hyloを直接再帰の形式で書くことにより、anaで生成されるデータをcataが直接消費することで、 anaが生成する(代数的データ型による)データを実際に生成することなくcata/anaの合成処理が記述することが可能になります。

このような合成は、プログラムの融合変換に応用できます。
実際の所、recursion schemeは、cata、ana、hyloをプログラムで直接手で記述するというよりは、コンパイル時の中間処理として登場するイメージに近いと思います。

例えば、プログラム中に登場するmap、filter、foldr、unfoldなどをcata、ana、hyloに展開し、展開後のコードの合成部分を見て、cata/ana融合が登場すると、直接再帰の形式のhyloに書き換えるといった最適化が可能になります。 (例えば、参考文献3では、mapやsum等、リストを扱う関数をhylo形式に書き換え、hylo同士を融合するといった最適化を行っています。)

この種の最適化は、実行時に生成される中間のデータの生成、消費処理を除去し、実行時のヒープ使用量を減らすなどの効果を目当てとして、行われます。

次に、metamorphismですが、これは、hyloの双対にあたり、cata(foldr)が生成した計算結果を元に、 ana(unfoldr)によりデータを展開するという処理の記述の一般化された形式で、 
meta = ana . cata
となる合成関数です。 

hylo同様、metaもana/cataの融合であることから、同じように説明することが出来ます。 つまり、展開されているデータを畳み込み、その結果から再度、データを展開する。 ところで、そんな関数は、リスト操作上だとどのような関数でしょうか? 

その一つの答えは、リスト操作関数におけるmap関数はcataでありanaであるという事です。 例えば、anaやcataにconsを生成させる関数を渡す事で、cataとana、いずれでもmapの役割を果たすことが出来ます。

この事は、map関数でありがちな、map f . map g = map (f . g)の融合をcata/ana融合、言い換えると hyloで表現できることを意味するわけですが、それと同時に、metaでも実現出来るわけです。 ana/cata融合、つまりmetaもまた、map/map融合を表す射になりうるのです。

つまり、 map f . map g = map (f . g) は、hyloで表すと、
(cata f') . (ana g') = hylo f' g'
であり、 これと同様の意味をmetaで書くと、
(ana f'') . (cata g'') = meta g'' f'' 
と書くことができます。
f'、f''、g'、g''は、それぞれ、元のf、gをhylo/meta用に修正(wrapping)した関数になります。 cata/ana共に、そもそも、foldr/unfoldなので、mapしたい関数を直接渡す事はできません。。。

実際に、Haskellのコードで、リスト上のcata/anaを書くと以下のようになります。 まず、標準のリスト上のcataとanaです。
lcata :: (alpha -> beta -> beta) -> beta -> [alpha] -> beta
lcata f b []     = b
lcata f b (a:as) = f a (lcata f b as)

lana :: (beta -> Maybe(alpha, beta)) -> beta -> [alpha]
lana f b = case f b of
  Just (a, b') -> a:lana f b'
  Nothing      -> []
anaでは、JustとNothingでデータを生成し続けるのか、停止するのか、切り替えています。 ちなみに、これは基本的にフラグなので、代わりに1, 2とそのタプルを使う文献も多いです。(下の参考文献2、3あたりを参照。というかそっちが主流かも?) 今回は、後で紹介するコードに合わせて、Maybeで書きました。

次に直接再帰形式のhylo/metaです。cata/ana同様にHaskellの標準のリストを対象にしています。
lhylo :: beta -> (alpha -> Maybe(gam, alpha)) -> (gam -> beta -> beta) -> alpha -> beta
lhylo init f g a = case f a of
  Nothing     -> init
  Just (x, y) -> g x (lhylo init f g y)

lmeta :: (gam -> Maybe (beta, gam)) -> (alpha -> gam -> gam) -> gam -> [alpha] -> [beta]
lmeta f g c x = case f c of
  Just(b, c') -> b:(lmeta f g c' x)
  Nothing     -> case x of
    []   -> []
    a:x' -> lmeta f g (g a c) x'
hyloの直接再帰の形式はよく見るのですが、metaについては、ネットではあまり見かけません。。。上記のmetaの形式は、参考文献1の10pの直接再帰形式のコードからHaskellのリスト向けに復元したものです。

このcata/anaで書いたコードに対するmap関数は以下のようになります。元はfoldr/unfoldだけあって、mapしたい関数を直接渡す事はできません。。。
mapC f = lcata func []
  where
    func x xs = (f x):xs

mapA f = lana func
  where
    func []     = Nothing
    func (x:xs) = Just(f x, xs)
次に、hylo/metaによる(map f) . (map g) = map (f . g)の合成。それぞれ、hylo版がmapH、meta版がmapEになります。
mapH f g = lcata func1 [] . lana func2
  where
    func1 x xs = (f x):xs
    func2 []     = Nothing
    func2 (x:xs) = Just(g x, xs)

mapE f g = lana func2 . lcata func1 []
  where
    func1 x xs = (g x):xs
    func2 []     = Nothing
    func2 (x:xs) = Just(f x, xs)
しかし、この例はあまりに面白くありませんね。。。

mapをrecursion schemeで表すなら前述したようにcataかana十分なはずで、 そもそもmap自体、簡単に融合出来てしまいます。 致命的なのが、metaの特徴でもある畳込み後の値が使えるという性質を上手く活かしきれていません。

しかし、上記の例から分かることは、foldで畳み込んだ結果をリストとすることで、metamorphismもそれなりに使いみちが出てくるかも知れません。

というわけで、やってみたのが、metamorphismによるfilterとmapの融合です。
mapfilterM f g = lana func2 . lcata func1 []
  where
    func1 x xs   = if f x then x:xs else xs
    func2 []     = Nothing
    func2 (x:xs) = Just(g x, xs)
これを直接再帰の形式のmetaに差し替えると、こうなります。
mapfilterMF f g = lmeta func2 func1 []
  where
    func1 x xs   = if f x then x:xs else xs
    func2 []     = Nothing
    func2 (x:xs) = Just(g x, xs)
fにfilter用の関数を渡し、gにmapに渡す関数を渡します。
map after filter(filter関数の実結果にmap関数を実行)の融合変換は、map関数を展開があるため、metamorphismを使うことで綺麗に表現出来ています。。。(多分

以下実行結果です。
*Main> mapfilterMF (\x-> (mod x 5) == 0) (3 +) [1..20]
[8,13,18,23]
*Main> map (3 +) $ filter (\x-> (mod x 5) == 0) [1..20]
[8,13,18,23]
*Main> mapfilterM (\x-> (mod x 5) == 0) (3 +) [1..20]
[8,13,18,23]
このようにして、hylomorphismやmetamorphismは融合変換に使用することができます。

そして、最後に! ググったら出てきた、面白いhylomorphism/metamorphismの例を紹介します。(参考文献1より)
それが、hylomorphismとmetamorphismによるQuicksort & Heapsortです。 例によって、in-placeではないので、それぞれ偽のQuicksort、Heapsortになります。

まず、hylomorphismによるQuicksort(参考文献1からの引用)!
data Tree alpha = Node(Maybe(alpha, Tree(alpha), Tree(alpha))) deriving Show

foldt :: (Maybe(alpha, beta, beta) -> beta) -> Tree(alpha) -> beta
foldt f (Node Nothing)         = f Nothing
foldt f (Node (Just(a, t, u))) = f $ Just(a, foldt f t, foldt f u)

unfoldt :: (beta -> Maybe(alpha, beta, beta)) -> beta -> Tree(alpha)
unfoldt f b = case f b of
  Nothing         -> Node Nothing
  Just(a, b1, b2) -> Node(Just(a, unfoldt f b1, unfoldt f b2))

partition :: (Ord alpha) => [alpha] -> Maybe(alpha, [alpha], [alpha])
partition []          = Nothing
partition (a:as)      = Just(a, filter (< a) as, filter (> a) as)

join :: Maybe (alpha, [alpha], [alpha]) -> [alpha]
join Nothing          = []
join (Just(a, x, y))  = x ++ [a] ++ y

quicksort :: (Ord alpha) => [alpha] -> [alpha]
quicksort = foldt join . unfoldt partition
主に、partitionとjsonそして、unfoldt/foldtは木構造を生成して、それをリストに引き戻している処理になります。 Haskellのfake Quicksortのコードがjoinとpartitionの箇所にそのまま現れていることがわかります。

同様にして、metamorphismによるHeapsort(Quicksort同様、参考文献1からのほぼ引用ですが、一部修正)。
insert :: (Ord alpha) =>  alpha -> Tree(alpha) -> Tree(alpha)
insert a t = merge (Node(Just(a, e, e)), t)
  where
    e = Node Nothing

splitMin ::  (Ord alpha) => Tree(alpha) -> Maybe(alpha, Tree(alpha))
splitMin (Node t) = case t of
  Nothing       -> Nothing
  Just(a, u, v) -> Just(a, merge(v, u))

merge :: (Ord alpha) => (Tree(alpha), Tree(alpha)) -> Tree(alpha)
merge(t, Node Nothing) = t
merge(Node Nothing, u) = u
merge(Node x, Node y)  = if a < b
  then Node(Just(a, t2, merge(t1, Node y)))
  else Node(Just(b, u2, merge(u1, Node x)))
  where
    Just(a, t1, t2) = x
    Just(b, u1, u2) = y

heapsort :: (Ord alpha) => [alpha] -> [alpha]
heapsort = lana splitMin . lcata insert (Node Nothing)
こちらは少し特殊ですが、補助関数mergeを作っています。 mergeが別に再帰的な処理を行っていますが、これは本体の再帰とは別の処理です。

まあ、ここまで来ると、素直に再帰を書けば良い気もしますが、再帰関数がこのように分離出来るのは面白いですね。

Haskellのcataやana、hyloなどで書ける関数は、The pointless-haskell package(のExample)に色々紹介してあるので、それらを使って 遊んで見るのも良いかも知れません。

今回作成したコードの全体はここ(Gist)に置いてます。

ps. 急ぎで書いてしまったので、後で修正が入るかも知れません。

参考文献

  1. J.Gibbsons, Metamorphisms: Streaming Representation-Changers, 2005(PDF)
  2. F. Domínguez, Alberto Pardo, Exploiting algebra/coalgebra duality for program fusion extensions, 2011 (PDF)
  3. Yoshiyuki Onoue, Zhenjiang Hu, Hideya Iwasaki, Masato Takeichi, A Calculational Fusion System HYLO, 1997 (PDF)

2015/02/10

ClojureでリストのHylomorphismの変換マクロ

 前に書いた記事(Clojureでunfoldとhylomorphism) の続き.

 上の記事では, ClojureにおいてList版のHylomorphismを中間のデータ構造を生成しない純粋な再帰呼び出しで書きなおすことで高速化しました. しかし, 可読性が最悪で, はっきりいって読めない...
(defn correct-answer? [arrangement]
  (reduce andf
    (map not-conflict?
      (take-while first (iterate rest arrangement)))))

(defn correct-answer?-v2 [arrangement]
  (hylol andf true #(not (first %)) not-conflict? rest arrangement))
 上の関数が, リストを生成しながら計算するHylomorphism(に相当するもの), 下の関数が, リスト(中間のデータ構造)なしで同じ計算をするものです. (iterateで)リストを生成せずにプログラムが実行される分, 下の関数の方が高速であるという結果になったという話でした.

 hylolとはどいういった関数かというと, 次のような関数です.
(defn hylol
  [f e p g h b]
  (if (p b)
    e
    (f (g b) (hylol2 f e p g h (h b)))))
 ピカソの絵画ような抽象的な定義ですが, しかし抽象的であるがために, ある程度普遍性があります(様々なプログラムに出現する可能性が高いという意味です).

 できれば可読性を維持したまま(つまり, reduceとかmapとかが書かれた状態のまま) Hylomorphismに相当するプログラムを純粋な再帰に変換したいですね. というわけで, 関数でhylolを実装するのではなく, マクロで実装すれば良いのでは? という結論に至りました.
(hylol-expand
  (reduce + 0
       (take-while (partial not= 0)
                    (map dec (iterate dec 100)))))
とか
(hylol-expand
 (->> (iterate dec 100)
      (take-while (partial not= 0))
      (reduce + 0)))
みたいな書き方なら, 可読性に関しては問題ありません.

 というわけで上記のような書き方を維持したまま, Hylomorphismを再帰呼び出しに書き戻すマクロを作ってみました. 以下がそのマクロの実装. hylol-formで展開後の構造を定義し, hylol-expandで記法のパターンマッチを行い, それに対応するマクロフォームに展開します. 素朴な実装なので, 特に柔軟性などはありません.
(use '[clojure.core.match :only (match)])

(defn hylol-form
  "returns quoted hylol form"
  ([f-in-reduce init-in-reduce pred use-map-option f-in-map
   f-in-iterate init-in-iterate]
     `(letfn [(~'f [~'b]
                (if (~pred ~'b)
                  (~f-in-reduce
                   ~(if use-map-option (list f-in-map 'b) 'b)
                   (~'f (~f-in-iterate ~'b)))
                  ~init-in-reduce))]
        (~'f ~init-in-iterate))))

(defmacro hylol-expand
  "hylomorphism of lists without generating list"
  [hylol-exp]
  (match [hylol-exp]
    [(['reduce f-in-reduce init-in-reduce ;; no map & natural
       (['map f-in-map
         (['take-while pred
           (['iterate f-in-iterate init-in-iterate] :seq)]
            :seq)] :seq)] :seq)]
    (hylol-form f-in-reduce init-in-reduce pred true f-in-map
                f-in-iterate init-in-iterate)
    [(['reduce f-in-reduce init-in-reduce ;; no map & natural
       (['take-while pred
         (['iterate f-in-iterate init-in-iterate] :seq)]
            :seq)] :seq)]
    (hylol-form f-in-reduce init-in-reduce pred false nil
                f-in-iterate init-in-iterate)
    [(['->> (['iterate f-in-iterate init-in-iterate] :seq) ;; map & thread
       (['take-while pred] :seq)
       (['map f-in-map] :seq)
       (['reduce f-in-reduce init-in-reduce] :seq)] :seq)]
    (hylol-form f-in-reduce init-in-reduce pred true f-in-map
                f-in-iterate init-in-iterate)
    [(['->> (['iterate f-in-iterate init-in-iterate] :seq) ;; no map & thread
       (['take-while pred] :seq)
       (['reduce f-in-reduce init-in-reduce] :seq)] :seq)]
    (hylol-form f-in-reduce init-in-reduce pred false nil
                f-in-iterate init-in-iterate)
     [the-other-cases]
     (throw (Exception. (str "malformed hylol : " the-other-cases)))))
 上記のようなマクロ定義で, 次のような4通りの記述が可能になります.
(println (hylol-expand
          (reduce + 0
                  (map dec
                       (take-while (partial not= 0)
                                   (iterate dec 100))))))

(println (hylol-expand
          (reduce + 0
                  (take-while (partial not= 0) (iterate dec 100)))))

(println (hylol-expand
          (->> (iterate dec 100)
               (take-while (partial not= 0))
               (map inc)
               (reduce + 0))))

(println (hylol-expand
          (->> (iterate dec 100)
               (take-while (partial not= 0))
               (reduce + 0))))
 reduce, map,  take-while, iterateの位置は固定で, これ以外の書き方はできません. 引数となる「関数/(繰り返しの)初期値」のみが変更可能です. 上の二例は, 引数渡しで普通に書けるケース, 下の二例は, スレッディングマクロで書かれたものを再帰に書きなおすものです.

 簡単なベンチマークテストのため, 前の記事で作成した8queen問題でテストしてみました. 前回書いたのは, 次のような書き方(関数コール)でした.
(defn correct-answer?-v2 [arrangement]
  (hylol andf true #(not (first %)) not-conflict? rest arrangement))
 hylolの変換マクロの場合, 同じ意味のプログラムが,
(defn correct-answer?-mbt [arrangement]
  (hylol-expand ;; = identify
   (->> (iterate rest arrangement)
        (take-while first)
        (map not-conflict?)
        (reduce andf true))))
で書けます. 見た目は, 大分改善されました.

 以下が簡単なベンチマーク結果. correct-answers-with-hylol-macroがhylolの変換のマクロ実装, correct-answers-with-hylol-functionは, 前に書いた記事のcorrect-answers-v2, correct-answersは, ナイーブな(hylolの変換が入らない)実装でこれも前回書いた記事と同じ.
user> (time (count correct-answers-with-hylol-macro))
"Elapsed time: 590.56785 msecs"
92
user> (time (count correct-answers-with-hylol-function))
"Elapsed time: 594.688247 msecs"
92
user> (time (count correct-answers))
"Elapsed time: 739.69738 msecs"
92
 これで, 可読性を維持したまま, Hylomorphismのマクロ展開により高速化が可能になりました.

2014/12/31

ClojureでunfoldとHylomorphism

unfold

 unfoldは, fold(reduce)の双対として定義される関数で, リストを生成する一種のジェネレータのような働きをするものです. 畳み込み関数に対応する, 要素を展開する関数とみなすことができます. Clojureだとiterateが一番近い関数でしょう.
user> (take 10 (iterate (partial + 2) 0))
(0 2 4 6 8 10 12 14 16 18)
 Haskellでは, foldが二種類あり, foldrとfoldlで, 右から畳み込みと左から畳み込みに対応しています. Clojureのreduceは, foldlに相当します. そして, foldr/foldlそれぞれにunfoldr/unfoldlが定義できますが, 以下の文章では, foldlを前提に話を進めます. (Haskellのfoldとunfoldrについてはここで書いています.)

 unfoldlをClojureで書くとしたら以下のような感じになると思います.
(defn unfoldl [pred g h start]
   (map g (take-while #(not (pred %)) (iterate h start))))
 計算を始めるオブジェクトと繰り返し適用するh, 生成したリストにmapをかけるためのgと, リストを終わらせる条件式predからなります. map gがtake-whileよりも後の処理になるのも重要な点です.

Hylomorphism

 Hylomorphismとは, (代数的データ型へ)一般化されたfoldとunfoldの合成のことで, リストにおけるfoldlとunfoldlについて,

hylol f e p g h = foldl f e . unfoldl p g h

と書ける関数です. (太字部分 2015/01/08追記) unfoldlで生成したリストをfoldlで畳み込むような計算になります. map/reduceならぬ, generate(リスト生成)/map/reduceの処理が一つにまとまりました. Clojureだと次のようになります.
(defn hylol1
  "hylol f e p g h = foldl f e . unfoldl p g h"
  [f init pred g h start]
  (reduce f init (unfoldl pred g h start)))
 これだけだと, 抽象的な関数を組み合わせたまた別の抽象的なだけの関数といった印象しか残りません. しかし, Hylomorphismは, これを別のリストを使わないコードへ書き直すことができるのです.
(defn hylol2
  [f e p g h b]
  (if (p b)
    e
    (f (g b) (hylol2 f e p g h (h b)))))
 これが, HylomorphismLの別の実装です.

 foldlとunfoldlを組み合わせると当然, unfoldlで生成されたリストが, foldlにおいて, 畳み込まれてしまうため, 結果的に, 最終的な計算結果(戻り値)に不要なリストを計算途中で生成する必要が出てきてしまいます. しかし, 上記の2番目の実装では, リストを生成する様子がありません.

 Hylomorphismの変換では, foldとunfoldの合成関数を, これと同等の意味を持つ「リストを生成しないプログラム」へ書き換えることでプログラムを高速化します. この辺はmap分配則と同じような感じですね.

 例えば, 1〜100の和を計算するようなプログラムについて, foldl/unfoldlを使って,
(defn sum-from1to [n]
  (reduce + 0 (unfoldl (partial = 0) identity dec n)))
 と書けるわけですが, これをHylomorphismで書きなおしてみると, hylol2の実装を使って,
user> (hylol2 + 0 (partial = 0) identity dec 10)
55
と書けます. これをhylol2の実装へ展開すると,
(defn hylol2-sum
  [b]
  (if ((partial = 0) b)
    0
    (+ (identity b) (hylol2-sum (dec b)))))
 のように書きなおすことができて, foldl/unfoldlを使った場合に比べて素直な書き方になっていることがわかるかと思います.

 ある意味では, Hylomorphismの変換とは, リストを使って抽象化した処理を, 素朴な再帰関数に書き戻す手法だとみなすことができます.

Hylomorphismの変換例

 さて, Hylomorphismの変換に関する一番の関心事は, 本当に高速化するのかという点と, 具体的にプログラムへ適用できるのかという点です. というわけで, 以下は, 8Queen問題のプログラムでHylomorphismを使ってみたケースです.

 前に書いた記事の8Queen問題の解法では, バックトラックにより計算していましたが, 今回は, 素朴な総当り法を使います.

 8Queen問題の解とは, 究極的には, 1〜8の数字の並び, だとみなすことができます. 例えば, x座標を固定した場合, 鳩の巣論法的に, 各x座標(= 1, 2, ... 8)には必ず一つQueenが入っている必要があるからです. というわけで, x座標の情報は不要になり, 順序を持ったy座標列が, 解となります.

 というわけで, このプログラムでは, y座標のpermutationを与え, そこから, xy座標の組を計算し, その座標列が正しいかどうかを判定し, 正しければ解を返します. 以下がコードです.
(use 'clojure.math.combinatorics)

;; unfoldl & hylomorphism
(defn unfoldl [pred g h start]
   (map g (take-while #(not (pred %)) (iterate h start))))

(defn hylol2
  [f e p g h b]
  (if (p b)
    e
    (f (g b) (hylol2 f e p g h (h b)))))

;; 8 queen
(defn conflict? [x y others]
  (and (not (empty? others))
       (let [x1 (ffirst others) y1 (second (first others))]
         (or (= x x1) (= y y1)
             (= (- x x1) (- y y1)) (= (- x x1) (- y1 y))
             (conflict? x y (rest others))))))

(defn not-conflict? [ls]
  (let [xy (first ls) others (rest ls)]
    (not (conflict? (first xy) (second xy) others))))

(defn andf
  ([a] a)
  ([a b] (and a b)))

(defn correct-answer? [arrangement]
  (reduce andf
    (map not-conflict?
      (take-while first (iterate rest arrangement)))))

(defn correct-answer?-v2 [arrangement]
  (hylol2 andf true #(not (first %)) not-conflict? rest arrangement))

(defn check-and-cons [checker yss]
  (let [arrangement (map list (range 8) yss)]
    (if (checker arrangement) arrangement [])))

(defn get-correct-answers [permutations checker]
  (filter first (map (partial check-and-cons checker) permutations)))

(def perm (doall (permutations (range 8))))

(def correct-answers
  (get-correct-answers perm correct-answer?))

(def correct-answers-v2
  (get-correct-answers perm correct-answer?-v2))
 実行時間を計測してみると, 確かに高速化されていることがわかります. correct-answersの方が, foldl/unfoldl(に相当するもの)を使った場合で, correct-answers-v2がhylol2によりプログラム変換を行ったケースです.
user> (time (count correct-answers))
"Elapsed time: 733.504269 msecs"
92
user> (time (count correct-answers-v2))
"Elapsed time: 592.678338 msecs"
92
 上記の結果を見た場合, それ以外の部分でボトルネックになっている部分もありそうですが, Hylomorphismの変換により, 一応, 高速化ができていることが観察できました.

 さて, 問題のプログラム変換は以下の部分で行っています. correct-answer?が, 素朴なfoldl/unfoldlで, correct-answer?-v2がリストを生成しないHylomorphismを使ったコードになります. correct-answer?の方にunfoldlというキーワードは見当たりませんが, map/take-while/iterateの組み合わせがそれに相当します.
(defn correct-answer? [arrangement]
  (reduce andf
    (map not-conflict?
      (take-while first (iterate rest arrangement)))))

(defn correct-answer?-v2 [arrangement]
  (hylol2 andf true #(not (first %)) not-conflict? rest arrangement))
 ここでは, [[1 2] [3 4] ...]のようなQueenの位置を表すリスト(arrangement)を受け取り, そこから, 部分リストを生成し(iterate/take-while), 各部分リストの「先頭の要素」と「他の要素」が衝突しないか(互いが取り合える位置にいないか)をチェックして(map not-conflict?), 矛盾しているケースがないかどうかをandf(andの関数版)で確認し(reduce), その答えの正解/不正解をboolで返すというものです.

 例えば, 座標のリスト[p1 p2 ... p8]があるときに互いに衝突していないかを判定するためには, p1と[p2 ... p8], p2と[p3 ... p8], p3と[p4 ... p8]と判定していくため, 上記のようなrestをiterateする実装になっています.

 reduce, map. take-while, iterateが一堂に会しているので, 図らずともHylomorphismの変換が簡単にできました.

 Hylomorphismの変換から得られる教訓(?)は, プログラム中に, ジェネレータによるリスト生成を行う部分があり, その先で, reduce(畳み込み関数)がそのリストを畳もうと待ち構えているとき, そのコードは, リストを使わないコードへ書き換えることができるかもしれない, ということです. そして, 書き換えたコードは, 余分な(本来の計算結果に不要な)データ構造の生成が不要になることで, 高速化できる筈だという結論です.

参考文献
  • 関数プログラミングの楽しみ, Jeremy Gibbons & Oege de Moor(編集), 山下 伸夫 (翻訳), オーム社, 2010/6/23
    • ifphの続編(?), この記事の内容は本書のhylomorphismのセクションを元にかかれています.
  • 構成的アルゴリズム論
    • hyloだけでなく, map分配則などプログラム運算の様々な規則が紹介されています. チュートリアル.
  • Anamorphic Adventure in Clojure - Matlux
    • Clojureで, Anamorphism(unfold相当)を定義する話. 

2014/12/08

手作業でプログラム運算

  • 関数プログラミング入門 Haskellで学ぶ原理と技法, Richard Bird(著) / 山下伸夫(訳), 2012
 p368の練習問題12.1.3
zipp . pair (map f, map g) = map (pair (f, g))という法則は両辺を同じ結果に単純化することで証明できるか. 証明はzipp . unzip . map (pair (f, g))という式を2つの本質的に異なる方法(1つはzipp . unzip = idという法則を使い, もう1つはunzipの定義を使うことで単純化することで可能になる. 詳細を示せ.
 プログラム運算を実感するために, 上記の問題をやってみたいと思います. zippは, カリー化されてないzip関数.
 各関数の定義.
pair (f, g) x = (f x, g x)
zipp = uncurry zip
uncurry f xy = f (fst xy) (snd xy)
unzip = pair (map fst, map snd)

 早速, 変換を始めてみると,
zipp . unzip . map (pair (f, g))
{- zipp . unzip = idより -}
= map (pair (f, g))
= 右辺

{- unzipの定義から, -}
zipp . unzip . map (pair (f, g))
= zipp . pair (map fst, map snd) . map (pair (f, g))  --(1)

{- ここで, -}
pair (map fst, map snd) . map (pair (f, g))
{- pair (f, g) . h = pair (f . h, g . h) だから -}
= pair (map fst . (map (pair (f, g))), map snd . (map (pair (f, g))))
{- map分配則(map f . map g = map (f . g))から -}
= pair (map (fst . (pair (f, g))), map (snd . (pair (f, g))))
{- fst . pair (f, g) = fなどから -}
= pair (map f, map g)

{- なので, -}
zipp . unzip . map (pair (f, g))
= zipp . pair (map f, map g)
= 左辺

 左辺の変換は, ポイントフリーだとわかりにくいので, リストxsを使って考えると,
pair (map fst, map snd) (map (pair (f, g)) xs)
= (map fst (map (pair (f, g)) xs), (map snd (map (pair (f, g)) xs))
{- point freeで書き直すと -}
= (map fst . map (pair (f, g)) xs), ((map snd . map (pair (f, g)) xs))
{- map分配則から -}
= (map (fst . pair (f, g)) xs), map (snd . pair (f ,g)) xs)
{- fst .pair (f ,g) = f などから -
= (map f xs, map g xs)
= pair (map f, map g) xs
{- のように変形できるので, -}
unzip . map (pair (f, g))
= pair (map f, map g)

と考えることも可能.

というわけで, zipp . pair (map f, map g) = map (pair (f, g))が成立.

まとめると,
zipp . pair (map f, map g)
{- fst . pair (f, g) = fなどから -}
= zipp . pair (map (fst . (pair (f, g))), map (snd . (pair (f, g))))
{- map分配則(map f . map g = map (f . g))から -}
= zipp . pair (map fst . (map (pair (f, g))), map snd . (map (pair (f, g))))
{- pair (f, g) . h = pair (f . h, g . h) だから -}
= zipp . pair (map fst, map snd) . map (pair (f, g))
{- unzipの定義から-}
= zipp . unzip . map (pair (f, g))
{- zipp . unzip = idより -}
= map (pair (f, g))

2014/12/11, 間違っていたので一部修正.

2014/11/28

map分配則

 プログラム運算は, プログラムの変形なので, そのための変換規則というのがありますが, そのうちの一つに, map分配則というものがあります.

 map分配則は, 次のような規則です.

map (f . g) = map f . map g

 両辺とも意味的には同じなのですが, 片方の式が実行時間が短くなります.
 さてどちらでしょうか.
 高速化(等)のためのプログラム変換なので, 上記の式の片方がプログラム中に登場した時もう片方へ書き換えることで, 高速化を図るというテクニックです.

 実行時間は 左辺 > 右辺 左辺 < 右辺となります. つまり, 上の等式の右辺のような式を見つけた時, 左辺に変換可能であり, かつ, 左辺に変換することで高速化します.

 理由は簡単で, 右辺 map f . map g は, リストを生成を二回行いますが, 左辺 map (f . g)は, mapの回数が一回なので新しいリストを一回生成するに留まります. 右辺はほとんど, 二度ループを行っているようなものですね.

 等式ついては例えば, 次のように証明できます.

mapの定義は次のものとし,
map f [] = []
map f (x:xs) = f x : map f xs

また, 合成関数は次のように定義します.
(f . g) x = f (g x)

この時, 数学的帰納法により等式の証明を試みると
i) 空リストについて, mapの定義より,
map (f . g) [] = []であり,
(map f . map g) []
= map f (map g [])
= map f [] = []で空リストについて成立するので,  成立します.

ii) あるリストxsについて, map (f . g) xs  = (map f . map g) xs が成立すると仮定する.
xsに1つ要素をconsしたリスト(x:xs)について,
map (f . g) x:xs
= (f . g) x : map (f . g) xs
= f (g x) : map (f . g) xs
= {ここで仮定より} f (g x) : (map f . map g) xs    ......①
(map f . map g) (x:xs)
= {合成関数の定義より} map f (map g (x:xs))
= map f (g x : map g xs)
= f (g x) : map f (map g xs)
= {合成関数の定義から} f (g x) : (map f . map g) xs
= {①より} map (f . g) x:xs
より, 与えられたリストがx:xsの時にも map (f . g) = map f . map gが成立.

 というわけで数学的帰納法によりmap分配速が成立する. この辺までは高校レベルの数学に登場する数学的帰納法を知っていれば, 理解できます.
※) Haskellの記法なので, 関数適用がcons演算子や関数合成の演算子よりも優先順位が高いことに注意.

 map分配則は直感的に理解できるもので, わざわざ証明するまでもないようなものですが, もう少し複雑な規則なら上記の手法を用いることで, バグを含まない健全さを手に入れることができるかもしれません.

 さて, 問題はホントに速いのかということですが, 実際にやってみました.

こんなプログラムを用意して,
import Data.Time

gettime ls = do
  start <- getCurrentTime
  print $ sum ls
  end <- getCurrentTime
  print $ "elapsed time : " ++  (show $ diffUTCTime end start)

f = (20 *)
g = (1 +)
ghci上で実際に次のように計測してみたところ,
*Main> gettime $ (map f . map g) [1..10000000]
1000000300000000
"elapsed time : 7.2572358s"
*Main> gettime $ map (f . g) [1..10000000]
1000000300000000
"elapsed time : 5.2496645s"
map (f . g)の方が高速であることが分かるかと思います.

2013/05/20

プログラム運算

(2015/01/07修正)

 プログラム運算というのがあるらしい。
最初に見た時は、演算のtypoかと思ったけど、それとは違うらしい。
英語では、「calculational programming」というらしい。
プログラムの最適化手法の一種で、あるプログラムをプログラムの意味を変えずに、変換していくことでより効率的なプログラムに書き換える手法なのだそうです。この手法とコンパイラにおける最適化との違いは、アルゴリズム(抽象)レベルで最適化するか、変換後のコードを最適化するかという違いのようです。

エレガントで綺麗なコード(ただし遅い) → 高速なコード(ただし、汚く読みにくい)

「→」は「数学的理論(プログラム意味論)に基づいた変換」です。
速度的な面から見た、プログラム/アルゴリズムのリファクタリングってことなのかな?

例えば、関数型言語で記述された(速度を考慮していない)フィボナッチ数列を返す関数は、数学的な表現をそのまま表すこができ、非常に綺麗ですが、重たいです。それを、プログラムの意味を数学的な表現に落とし込み、数学的に変形する(もちろん、高速化されることは大前提という)ことで、数学的な等価性から、プログラムの等価性を証明するってことのようです。 (フィボナッチ数列の計算が運算によって高速化できるのかはわかりません.)
最適化すると同時に、その最適化が適切であることを証明する(保証する)ことができると。
 ちなみに、この変換自体は、基本的に人が手で行う模様…… 
(以下, 2015/01/07追記)
 ある意味では, 関数型言語の実行速度のチューニングの一種と言えるのかもしれませんね.

 基本的に手作業に勝るプログラム運算はありませんが, 自動化するシステムもいくつか開発されているようです. 日本語で読める書籍としては, 少なくとも以下の二冊があります(他にもあるかもしれませんが).
  • 関数プログラミングの楽しみ, Jeremy Gibbons & Oege de Moor(編集), 山下 伸夫 (翻訳)
  • 関数プログラミング入門 ―Haskellで学ぶ原理と技法―, Richard Bird (著), 山下伸夫 (翻訳)
 『関数プログラミング入門』については, 自動運算器の解説があります. 『関数プログラミングの楽しみ』でも, 同様に運算(融合変換)の自動化とその実装例であるMAGシステムの説明がありました.

 手作業で出来る簡単な例として, このブログでは, map分配則と, hylomorphismについて紹介しています.

 以下の文献が見つかりました。まだ、読んでないのですが。
参考文献

  • 定理証明支援系 Coq を用いたプログラム運算

  • 融合変換による関数プログラムの最適化

  • 構成的アルゴリズム論