独学Common Lisp

ベクトルと期待値

概要

前回は行列を使って一次方程式を解きましたが、実際の計算はLLAのsolve関数が行ってくれたため、行ったことは方程式を行列表現に置き換えただけです。

今回は行列の基本であるベクトルについて、統計的な見地から計算方法などを確認してみます。

ベクトル = データの列

一般的な数学のテキストではベクトルの意味について幾何学的な観点から座標における矢印として説明することが多いと思います。

ただし、行列は基本的に代数学に属する概念であり、特に統計においてはあまり幾何学的な意味を考える意義はありません。むしろベクトルは「データの列」と捉えるべきです。

例えば、サイコロの目は1から6までの6つの整数ですから、サイコロの目を表すデータは以下のように表現できます。

$$ S = \left( \begin{array}{c} 1 \\ 2 \\ 3 \\ 4 \\ 5 \\ 6 \end{array} \right) $$

前回説明したように、行が個体を、列は変数を表します。サイコロの目は一つの変数ですから一列で表し、サイコロの面に書かれた数が個体と見なせますから、6行で表します。つまり、6行1列の行列ですが、列ベクトルでもあります。ベクトルは横に表記する場合もありますが、基本的には紙面の都合によるものであり、特に明示がなければ全て「列ベクトル」です。

同じように、サイコロの目が出る確率についてのデータもベクトルで表すことができます。

$$ P = \left( \begin{array}{c} \frac{1}{6} \\ \frac{1}{6} \\ \frac{1}{6} \\ \frac{1}{6} \\ \frac{1}{6} \\ \frac{1}{6} \end{array} \right) $$

あるサイコロの目が出る確率が1/6なのはなぜでしょうか。それは、サイコロが正しく設計されているだろうと仮定しているからです。八百長があれば確率は違います。

ベクトルはこれらのようなデータの列に過ぎません。すでに説明した通り、ベクトルに入っているデータは基本的に同質なものである必要があります。

ベクトルの内積と期待値

さて、データの列をベクトルとして定義すると、何かメリットがあるのでしょうか。もちろん、メリットがあるからこそデータをわざわざベクトルにするのです。

ここで、先に示した2つのベクトルSPから、サイコロの目の「期待値(expected value)」を求めることを考えてみます。

例えば、正しく設計されたサイコロの場合は1から6までの値が出る確率は全て同じですから、単純に考えればいわゆる「平均」に等しくなります。平均とは平らで均しいと書く通り、全ての目の出る確率が均しいことが暗黙の仮定になっています。

しかし、実世界においては起こりうる確率は違うことが大半です。例えばお年玉付き年賀状では、1等の出る確率と切手シートが当たる確率は全く違います。1等は6桁が一致する確率ですから単純に考えて$ \frac{1}{10^6} = \frac{1}{1,000,000} $ですが、切手シートは2桁一致で2つの番号が選ばれますから$ \frac{2}{10^2} = \frac{2}{100} $です。当選確率の違いを考慮しようとすると、どうしても単純な平均では計算できません。

そこで、「平均」よりも広い概念として「期待値」が存在します。ある値と、その値が発生する確率を使って、全体としてどの程度の値が発生しそうかを考える、というのが「期待値」です。

今回の例では、1から6までの数についてそれぞれ1/6の確率で発生しますから、Common Lispでコツコツ計算しようと思うと、以下のようになります。

(+ (* 1 1/6)
   (* 2 1/6)
   (* 3 1/6)
   (* 4 1/6)
   (* 5 1/6)
   (* 6 1/6))
; => 7/2

7/2は分数の表現であり、小数の表現では3.5です。つまり、正しく設計されたサイコロの目の期待値は3.5ということになります。

しかし、このCommon Lispのコードを見れば分かるように、あまり美しいとは言えません。もし、正しく設計されていないサイコロの期待値を求める必要があれば、コードを直接書き換える必要があります。また、1から6までではなく、もっと面の多いような多面体的サイコロが登場した場合はどうでしょうか。計算式を増やさざるを得ません。

これは単純に計算上そうせざるを得ない、というだけの話で、期待値の概念上求められるものではありません。コードを見れば分かるように、正しいサイコロでも正しくないサイコロでも、6面体ではないサイコロではあっても、「値」と「値の確率」を「掛けて足す」という概念自体は変わらないのです。

ベクトルはこの概念の通り計算するために存在します。前述の通り、値のベクトルと確率のベクトルをそれぞれ定義しておけば、あとは「掛けて足す」という処理はいつも変わりませんので、「値のベクトルと確率のベクトルの対応する要素を掛けて、全部足したら期待値になる」という関係は普遍的に成立します。

では、言葉で説明したことをそのままCommon Lispのコードにしてみましょう。ベクトルの定義は当然ながらCommon Lispのベクタ(vecotr)が担当しますし、「要素を掛けて足す」という計算はLLAのlla:dotという関数が担当してくれます。

(defvar S #(1 2 3 4 5 6))
(defvar P #(1/6 1/6 1/6 1/6 1/6 1/6))

(ql:quickload :lla)
(lla:dot S P)
; => 3.5D0

このように、ベクトル同士を掛けて、その要素を全て足した値は「ベクトルの内積(inner product)」と呼ばれます。内積は期待値の計算にとても便利で、確率のベクトルではなく数量のベクトルであれば「加重平均」を求めることができます。やっていることは「掛けて足す」というシンプルなものですが、概念をそのまま表現することができます。

確率のベクトルを全て1のベクトルに置き換えれば、全く同じ関数で「和」を求めることもできます。

(lla:dot S #(1 1 1 1 1 1))
; => 21.0D0

改めて確認ですが、ベクトルの内積は以下のように計算されます。これはあくまでもベクトル同士の内積であり、行列の積ではありませんから注意してください。ベクトルには内積と外積がありますが、積はありません。

$$ \left( \begin{array}{c} a \\ b \\ c \end{array} \right) \cdot \left( \begin{array}{c} e \\ e \\ f \end{array} \right) = a \cdot e + b \cdot e + c \cdot f $$

寄り道: 縮約関数reduce

さて、少し寄り道してlla:dot関数をCommon Lispで模倣してみましょう。lla:dot自体はBLASのdotという関数をコールしていますので、ベクトルが大きくなればなるほどlla:dotの方が早く処理できます。ここでは特にreduceというCommon Lispの関数について説明します。

Common Lispは関数型言語としての性格もあり、Minimum Common Lispで説明したマップ関数を使えば、二つのベクトルを掛け合わせること自体は簡単にできます。リストに対するマップ関数はmapcarですが、ベクタの場合はシーケンス全般に対する汎用的なマップ関数mapを使うことができます。

(map 'vector #'* S P)
; => #(1/6 1/3 1/2 2/3 5/6 1)

しかし、ここまではできても、要素を全て足し合わせるために「変数と関数、定義する力」で使ったapplyによるsum関数は使うことができません。applyはその名の通り、関数の「適用(application)」を担う関数であり、Common Lispのソースコード記法である「リスト」に対してしか関数を適用することはできません。ここで扱うのはベクタですから、そもそも型が違います。

一つの方法は、loopマクロのacross節を使ってベクタにアクセスし、sum節で値を集計していくことです。

(loop for i across (map 'vector #'* S P) sum i)
; => 7/2

これはこれで正解ですが、おそらく普通のLisperはこのコードを使いません。なぜなら、Common Lispにはもっと美しいreduceという関数があるからです。

reduceは縮約関数とか、畳み込み関数などと言われます。マップ関数は「マッピング」、つまり元のリストやベクタと同じ長さを持つリストやベクタを作るために一対一対応していくのが主な目的ですが、reduceはその名前の通り、「減らす」関数です。しかもその減らし方が独特であり、2つの引数を関数に適用し、一つの評価結果を得たあと、その評価結果と次の引数を合わせてまた関数に適用していきます。

例えば、1から6までの数を全て加算する場合、実際の我々人間の計算は以下のように考えるのが一般的です。

(+ (+ (+ (+ (+ 1 2)
            3)
         4)
      5)
   6)
; => 21

これは再帰の時に扱った「継続」のトレースによく似ています。ある計算をしている時に、次に残っている計算を一つずつ処理しながらトップレベルまで戻っていきます。数式で表すと以下のようになるでしょう。

$$ \begin{eqnarray} 1 + 2 & = & 3 \\ 3 + 3 & = & 6 \\ 6 + 4 & = & 10 \\ 10 + 5 & = & 15 \\ 15 + 6 & = & 21 \end{eqnarray} $$

見ての通り、2つの引数から1つの解を得て、その解を次の「継続」(残りの計算)に渡してまた次の解を得ています。このような処理を行うのがreduceです。

(reduce #'+ (map 'vector #'* S P))
; => 7/2

reduceはシーケンス全般に使うことのできる汎用的な縮約関数なので、多くのライブラリでsumreduceを用いて実装されています。

reduceはより複雑な計算もできます。reduceには:initial-valueというオプションが用意されていて、一番最初に取る引数を指定することができます。これを使うと、シーケンスの要素数を簡単に調べることができます。

(reduce (lambda (x y) (1+ x)) S :initial-value 0)
;Compiler warnings :
;   In an anonymous lambda form at position 8: Unused lexical variable Y
; => 6

:initial-valueを使って、最初の引数、つまり最初のxに0を入れます。yにはシーケンスの要素が束縛されてきますが、束縛するだけで実際には使いません。「束縛した」ことをカウントし、(1+ x)を評価結果として返すことで、一つの引数を消費すると1を加算していくことになります。つまり、要素の数を数えることができるのです。

直線的な「シーケンス」に対して重層的な処理をすることができるreduceはとても重要な関数の一つです。もし自分でsum関数を実装する必要があれば、そして今回のテーマのようにベクトルの内積のように内部的に「和」を扱う必要があれば、applyではなくreduceで実装すべきです。また、+*という関数は右辺と左辺を入れ替えても同じ結果ですが、-/という関数は入れ替えると答えが変わります。畳み込みながら減算をしたい場合などにも便利です。

ベクトルの和、スカラー倍

さて、本題に戻り、ベクトルの計算についてもう少し説明しましょう。ベクトルの内積は、「要素毎の積の総和」として定義されました。

ベクトルにはもう少し単純な計算が定義されています。

例えば、直近3ヶ年の民間最終消費支出(P)と政府最終消費支出(G)は以下のようになっていました。

$$ P = \left( \begin{array}{c} 300001.9 \\ 298414.0 \\ 299862.1 \end{array} \right) , G = \left( \begin{array}{c} 101846.9 \\ 104258.0 \\ 106026.3 \end{array} \right) $$

二つのベクトルを要素毎に加算すると最終消費支出のベクトルが得られますから、例えば消費と投資、というように比較することができるようになります。要素毎の和はcl-num-utilsclnu:e2+で計算できます。

(defvar p #(300001.9 298414.0 299862.1))
(defvar g #(101846.9 104258.0 106026.3))
(clnu:e2+ p g)
; => #(401848.8 402672.0 405888.37)

同様に、要素毎の差も計算できますし、要素毎の単純な積も計算できます。ただし、要素毎の積はベクトルの積とは呼びません。「要素毎の積」です。(なお、サイコロのところで使ったPというシンボルを使っていますから、処理系を再起動せずにdefvarしても値が変更されることはありません。一度終了するか、別の変数名を使いましょう。再起動した場合は:llaをロードするか、:cl-num-utilsをロードしてください。)

ベクトル同士の演算は便利ですが、もっと単純な計算も用意されています。スカラー、つまり普通の数との計算です。

雑多な作業でよくあるのが、年次のデータが平成になっているので西暦に変換したい、という時です。平成は1988を加算すると西暦に変換できますが、これはベクトル毎の加算ではなく、ただ単に全ての要素に1988を加算するだけです。もちろん1988という要素だけを持つベクトルを用意して、ベクトル同士の和として計算しても構いませんが、スカラーとの和についてもcl-num-utilsclnu:e2+関数で簡単に計算できます。

(clnu:e2+ 1988 #(26 27 28))
; => #(2014 2015 2016)

これはmapで書くと少し冗長になります。

(map 'vector (lambda (x) (+ x 1988)) #(26 27 28))
; => #(2014 2015 2016)

スカラーとの和が計算できるので、スカラーとの積も計算できます。ただ、スカラーとの積はなぜか積ではなく「スカラー倍」と呼ばれます。

これも地味な作業ですが、単位調整で使うことがあります。例えば、1万円単位のデータを1円単位に変えたい場合は、10000をスカラーとして掛けてやればいいことになります。これもclnu:e2*で簡単に処理できます。

(clnu:e* 10000 #(300 400 500))
; => #(3000000 4000000 5000000)

少し脱線しますが、ベクタのデータをリストに変換するにはcoerceを使います。リストにするとformatによる繰り返しが使えるので、お金の表記で定番の3桁区切りによる出力ができます。

(format t "~{~:d~^ ~}~%" (coerce (clnu:e2* 10000 #(300 400 500)) 'list))
3,000,000 4,000,000 5,000,000
; => NIL

ベクトルを使おう

ベクトルを使うと、データを一連の集合として抽象化して扱うことができます。また、ベクトルにはベクトルの計算が用意されているので、自分で個別の要素を計算するのではなく、計算自体も抽象化により手続きを隠蔽することができます。手続きを抽象的に隠蔽すると、異なるデータに対しても適用しやすくなり、応用が効くようになります。

行列は行と列で構成されますが、計算機のメモリに行と列はなく、ただビットが一直線に並んでいるだけです。ベクトルのデータ型としてのベクタ(vector)は計算機の構造にも近く、効率的な計算が可能です。

C言語のような手続き型に慣れているとついループで計算してしまいがちですが、ベクトルを使うとソースコードが大幅に簡素化されますから、積極的にベクトルを使っていきましょう。


Copyright © 2017- satoshiweb.net All rights reserved.