[ 目次, 前節, 次節, 索引 ]

3.5.3 ストリームパラダイムの開発



遅延評価するストリームは, 局所状態と代入の利点の多くを提供し, モデル化の強力な道具となり得る. 更にプログラム言語に代入を取り入れたのに伴った理論的もつれの幾分かを回避した.

   ストリームによる問題解決は, 状態変数への代入の周囲に組織化されたシステムとは異る部品化の境界を持つシステムが構築出来るため, 照り輝く. 例えば各時点での状態変数の値ではなく, 全時系列(または信号)を関心の対象として考えることが出来る. これにより, 異る時点の状態の要素を組み合せたり比較したりするのが便利になる.

反復をストリームプロセスとして形式化する
1.2.1節で状態変数を更新しながら進行する反復的プロセスの話をした. 今では状態を, 更新する変数の組ではなく, 値の「時のない」ストリームで表現出来る. この考えを1.1.7節の平方根手続きで使ってみよう. 考え方は予測値を改良する手続きを次々と作用させ, xの平方根のよりよい予測値の並びを生成することである:
(define (sqrt-improve guess x)
  (average guess (/ x guess)))

   元々のsqrtの手続きでは, これらの予測値を状態変数の順次の値とした. そうではなく最初の予測値1から始る, 予測値の無限のストリームが生成出来る:65


(define (sqrt-stream x)
  (define guesses
    (cons-stream 1.0
                 (stream-map (lambda (guess)
                               (sqrt-improve guess x))
                             guesses)))
  guesses)

(display-stream (sqrt-stream 2))
1.
1.5
1.4166666666666665
1.4142156862745097
1.4142135623746899
...
更によい予測値を得るため, ストリームの次々の項が生成出来る. 希望するなら, 答が十分良好になるまで, 項を生成し続ける手続きも書ける. (問題3.64参照)

   同様に扱えるもう一つの反復は, 1.3.1節でみた交代級数に基づくπの近似値の生成である:



最初に級数の被加数(符号が交互になる奇数の逆数)のストリームを生成する. 次に(問題3.55のpartial-sums手続きを使い)更に多くの項の和のストリームを作り, 結果を4倍する:

(define (pi-summands n)
  (cons-stream (/ 1.0 n)
               (stream-map - (pi-summands (+ n 2)))))


(define pi-stream
  (scale-stream (partial-sums (pi-summands 1)) 4))

(display-stream pi-stream)
4.
2.666666666666667
3.466666666666667
2.8952380952380956
3.3396825396825403
2.9760461760461765
3.2837384837384844
3.017071817071818
...
近似は緩やかに収束するが, これはπの次第に改善される近似のストリームを与える. 並びの八項でπの値は3.284と3.017の間に押えられる.

   これまでは, 状態のストリームの使い方は, 状態変数の更新とあまり違わない. しかしストリームには面白いトリックをする機会がある. 例えばストリームを 並び加速(sequence accelerator)で変換し, 近似の並びを, 元々のと同じ値にずっと速く収束する新しい並びに変更することが出来る.

   そういう加速の一つは十八世紀, スイスの数学者 Leonhard Eulerによるもので, 交代級数(項の符号が交互になる級数)の部分和である並びにうまく働く. Eulerの技法では, Snを元々の和の並びのn番目の項とすると, 加速される並びは



の項を持つ. つまり元々の並びが値のストリームで表現されているなら, 変換された並びは


(define (euler-transform s)
  (let ((s0 (stream-ref s 0))           ; Sn-1
        (s1 (stream-ref s 1))           ; Sn
        (s2 (stream-ref s 2)))          ; Sn+1
    (cons-stream (- s2 (/ (square (- s2 s1))
                          (+ s0 (* -2 s1) s2)))
                 (euler-transform (stream-cdr s)))))

   Euler加速をπの近似の並びで見ることが出来る:

(display-stream (euler-transform pi-stream))
3.166666666666667
3.1333333333333337
3.1452380952380956
3.13968253968254
3.1427128427128435
3.1408813408813416
3.142071817071818
3.1412548236077655
...

   更に良好にするため, 加速された並びを加速し, それのまた再帰的な加速が続けられる. つまりストリームのストリーム (タブロー(tableau)という構造)を作り出し, そこでは各ストリームは直前のものの変換である:


(define (make-tableau transform s)
  (cons-stream s
               (make-tableau transform
                             (transform s))))
タブローは



の形を持つ. 最後にタブローの各行の第一項をとって並びを作る:

(define (accelerated-sequence transform s)
  (stream-map stream-car
              (make-tableau transform s)))

   πの並びのこの種の「超加速」を示すことが出来る:

(display-stream (accelerated-sequence euler-transform
                                      pi-stream))
4.
3.166666666666667
3.142105263157895
3.141599357319005
3.1415927140337785
3.1415926539752927
3.1415926535911765
3.141592653589778
...
結果は印象的だ. 並びの八項をとるとπの14桁の正しい値が得られる. 元々のπの並びだけを使ったらこの大きさの精度を得るには1013の程度の項の計算を必要とする(つまり級数を各項が10-13より小さくなるまで展開する.)

   ストリームを使わないでもこういう加速法を実装出来たかも知れない. しかしストリームの形式化は, 状態の全体の並びが, 一様な演算の組で操作出来るデータ構造として使えるので, 特に美しく, 便利である.

問題 3.63


Louis Reasonerは, sqrt-stream手続きは局所変数guessesを使わず, 次のように直截な方法で書けなかったかと質問した.

(define (sqrt-stream x)
  (cons-stream 1.0
               (stream-map (lambda (guess)
                             (sqrt-improve guess x))
                           (sqrt-stream x))))
Alyssa P. Hackerは手続きのこの版は, 冗長な計算をするので, かなり非効率だと答えた. Alyssaの答を説明せよ. delayの実装が, memo-proc(3.5.1節)の用意した最適化を使わず(lambda () exp)だけを使ったとしたら, 二つの版の効率に違いはあるか.

問題 3.64


引数としてストリームと数値(許容誤差)をとる手続きstream-limitを書け. それはストリームを調べて相続く二つの要素が絶対値で許容誤差より小さくなるのを見つけ, その二つの要素の二番目を返す. これを使えば
(define (sqrt x tolerance)
  (stream-limit (sqrt-stream x) tolerance))
により, 平方根を与えられた許容誤差まで計算出来る.

問題 3.65


級数



を使い, 上のπで実行したのと同様に, 2の自然対数の近似の三つの並びを計算せよ. これらの並びはどのくらい早く収束するか.
対の無限のストリーム
2.2.3節では並びのパラダイムが伝統的な入れ子のループを対の並びで定義されたプロセスとして扱うのを見た. この技法を無限ストリームへ一般化すれば, 「ループ」が無限の集合を扱わなければならないので, 容易にはループとして実現出来ないようなプログラムを書くことが出来る.

   例えば2.2.3節のprime-sum-pairs手続きを一般化し, ijで, i + jが素数である, すべての整数(i, j)の対のストリームが作りたかったとしよう. int-pairsijなる整数$(i, j)のすべての対の並びなら, 要求されたストリームは単に

(stream-filter (lambda (pair)
                 (prime? (+ (car pair) (cadr pair))))
               int-pairs)
である.66

   そうするとわれわれの問題は, ストリームint-pairsを作ることである. より一般的には二つのストリームS = (Si)とT = (Tj)があるとし,無限の正方配列



を考える. この配列の対角線上か対角線より上にある対のすべて, つまり対



を含むストリームが作りたい. (STが整数のストリームだとすれば, これが望みのストリームint-pairsである.)

   対の一般的なストリームを(pairs S T)と呼び, それが三つの部分: 対(S0, T0), 第一行の残りの対, および残りの対から出来ているとする:67



この分解の三番目のもの(第一行に入らない対)は(再帰的に) (stream-cdr S)(stream-cdr T)から作られた対であることに注意しよう. また第二部(第一行の残り)は

(stream-map (lambda (x) (list (stream-car s) x))
            (stream-cdr t))
に注意する. そこで対のストリームを次のように作ることが出来る.
(define (pairs s t)
  (cons-stream
   (list (stream-car s) (stream-car t))
   (⟨何らかの方法で組み合わせる⟩
       (stream-map (lambda (x) (list (stream-car s) x))
                   (stream-cdr t))
       (pairs (stream-cdr s) (stream-cdr t)))))

   手続きを完成するために, 二つの内部ストリームを組み合せる何らかの方法を選ぶ必要がある. 一つの考えは2.2.1節のappend手続きに似たストリーム版を使うことである:


(define (stream-append s1 s2)
  (if (stream-null? s1)
      s2
      (cons-stream (stream-car s1)
                   (stream-append (stream-cdr s1) s2))))
しかしこれは, 第二のストリームを取り込む前に, 第一のストリームからすべての要素をとるので, 無限ストリームには不適当である. 特に
(pairs integers integers)
を使い, 正の整数のすべての対を生成しようとする時, 結果のストリームは, 最初の整数が1に等しいすべての対を作ろうとし, それ以外の値を最初の整数とする対は決して作られない.

   無限のストリームを扱うには, プログラムを十分長く走らせると, すべての要素に遂にはたどり着けるということが確かな組合せの順を考える必要がある. これを達成する優美な方法は次のinterleave手続きを使うものである:68


(define (interleave s1 s2)
  (if (stream-null? s1)
      s2
      (cons-stream (stream-car s1)
                   (interleave s2 (stream-cdr s1)))))
interleaveは二つのストリームから要素を交互にとるので, 第一のストリームが無限であっても, 第二のストリームのすべての要素は, いつかは混ぜ合されたストリームへ行く道を見つける.

   要求された対のストリームは


(define (pairs s t)
  (cons-stream
   (list (stream-car s) (stream-car t))
   (interleave
    (stream-map (lambda (x) (list (stream-car s) x))
                (stream-cdr t))
    (pairs (stream-cdr s) (stream-cdr t)))))
のように生成出来る.

問題 3.66


(pairs integers integers)を調べよ. 対がストリームに置かれる順につき, 一般的に述べることが出来るか. 例えば対(1, 100), 対(99, 100), 対(100, 100)の前に近似的に何個の対が来るか. (数学的な厳密な表現が出来れば大いによい. しかし分らなくなったら, 定性的な答を出すのでもよい.)

問題 3.67


pairs手続きを修正し, (pairs integers integers)が(ijの条件なしに)整数のすべての対(i, j)のストリームを生じるようにせよ. ヒント: もう一つのストリームを混ぜる必要がある.

問題 3.68


Louis Reasonerは対のストリームを三つの部分から作るのは必要以上に複雑だと考えた. 対(S0, T0)を第一行の残りの対から分離する代り, 次のようにして第一行全体を使うことを提案した:
(define (pairs s t)
  (interleave
   (stream-map (lambda (x) (list (stream-car s) x))
               t)
   (pairs (stream-cdr s) (stream-cdr t))))
これは動くか. Louisのpairsの定義を使って(pairs integers integers)を評価すると, 何が起きるか考えよ.

問題 3.69


三つの無限ストリームS, T および Uを取り, i jkなる三つ組(Si, Tj, Uk)のストリームを生じる手続きtriplesを書け. triplesを使い, 正の整数の Pythagoras三つ組, つまりiji2 + j2 = k2である, すべての三つ組(i, j, k)のストリームを生成せよ.

問題 3.70


アドホックな混合せの結果の順でなく, 対がある有用な順で現れるようにストリームが出来ると嬉しい. 整数の一つの対が, 他の対「より小さい」という方法が定義出来れば, 問題3.56のmerge手続きに似た技法を使うことが出来る. その一つの方法は「重み関数」W(i, j)を定義し, W(i1, j1) < W(i2, j2)なら, (i1, j1)は(i2, j2)より小さいと規定することである. merge-weightedはもう一つの引数weightを取る他は, mergeに似た手続きmerge-weightedを書け. weight は対の重みを計算する手続きで, 要素が結果の混ぜ合せたストリームに現れる順を決めるのに使う.69 これを使い, pairsを, 二つのストリームと重み関数を計算する手続きをとり, 重みに従って順序づけられた対のストリームを生成する手続きweighted-pairsに一般化し, その手続きを使って

a. 和i + jに従って順序づけられた, ijなる正の整数の対(i, j)のすべてのストリーム

b. 和2i + 3j + 5ij に従って順序づけられた, ijで, ijも2, 3, 5で割り切れない正の整数の対(i, j)のすべてのストリーム

を生成せよ.

問題 3.71


二通り以上の二つの立方数の和で表される数を, 数学者Srinivasa Ramanujanを記念しRamanujan(Ramanujan number)という.70 対の順序づけられたストリームは, こういう数を計算する問題に対する優美な解を提供する. 二通りの方法で二つの立方数の和として書ける数を見つけるには, 和i3 + j3に従って順序づけられた整数の対(i, j)のストリームを生成するだけでよい(問題3.70参照). そして同じ重みで二つ連続する対をストリームから探す. Ramanujan数を生成する手続きを書け. 最初のそういう数は1729である. 次の5個は何か.

問題 3.72


問題3.71と似た方法で, 三通りの異る方法で二つの平方数の和として書けるすべての数のストリームを生成せよ. (なぜそう書いたかを示すこと.)
信号としてのストリーム
ストリームの議論は, それを信号処理システムの「信号」の計算的類似と述べることで始めた. 実際ストリームを使い, 連続する時間間隔での信号の値をストリームの次々の要素で表現することで, 信号処理システムを直接的にモデル化することが出来る. 例えば, 入力ストリームx = (xi), 初期値Cと,小さい増分dtに対し, 和



を蓄積し, S = (Si)の値のストリームを返すことで, 積分器(integrator)や加算器(summer)を実装することが出来る. 次のintegral手続きは整数のストリーム(3.5.2節)の「暗黙スタイル」の定義を思い出させる:

(define (integral integrand initial-value dt)
  (define int
    (cons-stream initial-value
                 (add-streams (scale-stream integrand dt)
                              int)))
  int)




図3.32 信号処理システムとして見たintegral手続き

図3.32はintegral手続きに対応する信号処理システムの絵である. 入力ストリームはdt倍され加算器を通過し, その出力は同じ加算器に送り返される. intの定義の自己言及は図では加算器の出力をその入力の一つに接続するフィードバックループに反映されている.

問題 3.73


時系列での電流や電圧の値を表現するのにストリームを使い, 電気回路をモデル化出来る. 例えば, 抵抗値Rの抵抗と, 容量Cのキャパシタが直列の RC回路(RC circuit)があるとしよう. 注入電流iに対する回路の電圧応答vは, 図3.33の式で決り, その構造は付随する信号流れ図に示す.



図3.33 RC回路と対応する信号流れ図

   この回路をモデル化する手続きRCを書け. RCは入力としてR, Cdtの値をとり, 入力として電流iを表現するストリームとキャパシタの電圧の初期値v0をとり, 出力として電圧vのストリームを返す手続きを返すものとする. 例えば(define RC1 (RC 5 1 0.5))を評価することでR = 5オーム, C = 1ファラド, 0.5秒の時間間隔のRC回路をモデル化するのにRCが使えなければならない. これは電流の時系列を表すストリームキャパシタの初期電圧をとり, 電圧の出力ストリームを生じる手続きとしてRC1を定義する.

問題 3.74


Alyssa P. Hackerは物理的検出器からの信号を処理するシステムを設計している. 彼女が作ろうとしている一つの重要な機能は, 入力信号の零交差 (zero crossing)を記述する信号である. つまり結果の信号は, 入力信号が負から正に変った時は+1, 入力信号が正から負へ変った時は-1, それ以外は0とする. (0入力の符号は正と仮定する.) 例えば典型的な入力信号と対応する零交差信号は

... 1  2  1.5  1  0.5  -0.1  -2  -3  -2  -0.5  0.2  3  4 ...
...  0  0    0  0    0     -1  0   0   0     0    1  0  0 ...
となる. Alyssaのシステムでは検出器からの信号はストリーム sense-dataで表現され, ストリームzero-crossingsが対応する零交差のストリームである. Alyssaはまず手続きsign-change-detectorを書いた. これは引数として二つの値をとり, それらの値の符号を比較し, 適切な0, 1または-1を生じる. 彼女はそれから零交差ストリームを次のように構成した.
(define (make-zero-crossings input-stream last-value)
  (cons-stream
   (sign-change-detector (stream-car input-stream) last-value)
   (make-zero-crossings (stream-cdr input-stream)
                        (stream-car input-stream))))

(define zero-crossings (make-zero-crossings sense-data 0))
Alyssaの上司のEva Lu Atorは通りかかり, このプログラムは問題3.50のstream-mapの一般化した版を使う次のと大体同じだといった:
(define zero-crossings
  (stream-map sign-change-detector sense-data ⟨expression⟩))
expression⟩を補ってプログラムを完成せよ.

問題 3.75


困ったことに問題3.74のAlyssaの零交差検出器は, 検出器からの雑音の多い信号が疑似的零交差を生じるので, 不十分なことが分かった. ハードウェアの専門家のLem E. TweakitはAlyssaに零交差を抽出する前に信号を平滑化し, 雑音を除去するよう忠告した. Alyssaはその忠告に従い, 検出データの各値を直前の値と平均して構成した信号から零交差を取り出すことにした. 彼女は助手のLouis Reasonerに問題を説明すると, 彼はAlyssaのプログラムを次のように変更して, この考えを実装しようとした:
(define (make-zero-crossings input-stream last-value)
  (let ((avpt (/ (+ (stream-car input-stream) last-value) 2)))
    (cons-stream (sign-change-detector avpt last-value)
                 (make-zero-crossings (stream-cdr input-stream)
                                      avpt))))
これはAlyssaの計画を正しくは実装していない. Louisが入れた虫を見つけ, プログラムの構造を変えずに修正せよ. (ヒント: make-zero-crossingsの引数の個数を増やす必要がある.)

問題 3.76


Eva Lu Atorは問題3.75のLouisの解決法に意見を持っている. 彼の書いたプログラムは, 平滑化の演算と零交差の抽出とが混ざっているので, 部品化になっていない. 例えばAlyssaが入力信号を改善するよりよい方法を見つけても, 抽出部分は変更すべきではない. 入力としてストリームをとり, 各要素が入力ストリームの二つの連続する要素の平均であるストリームを生じる手続きsmoothを書いてLouisを援助せよ. 次にsmoothをより部品化スタイルでの零交差検出器の実装の部品として使用せよ.



65 内部変数guessesを束縛するletは, guessesの値はguessesそれ自身に依存するので, 使えない. 問題3.63は, なぜ局所変数が必要かを考える.

66 2.2.3節のように, 整数の対をLispの対ではなく, リストで表現する.

67 なぜこういう分解を選んだかは問題3.68に洞察があるのを見よ.

68 組合せの順に関して要求される性質の正確な表現は次の通り: 第一のストリームの要素iと, 第二のストリームの要素jに対応する対が, 出力ストリームのf(i,j)番目現れるような二引数関数fが存在すべきである. これを達成すべく, interleaveを使うトリックは, それを言語 KRC(Turner 1981)で使った David Turnerによって示された.

69 重み関数には, 対の配列の行を右へ, 並びを下へ進むに従い,対の重みが増えるよう, 要求する.

70 HardyのRamanujan追悼文(Hardy 1921)から引用すると「『すべての正の整数は彼の友人であった』といったのはLittlewoodだった(と思う.) 彼がパトニーで療養していた時, 見舞いにいったのを覚えている. 1729番というタクシーに乗ったので, その数には何の面白味もないといい, それが悪い予兆でないことを望むといった. 『いや』彼は答えた『それは非常に興味のある数だ. それは二通りで二つの立方数の和で表現出来る最小の数だ』」. 重みつきの対を使ってRamanujan数を作るトリックは Charles Leisersonが示した.

[ 目次, 前節, 次節, 索引 ]