独学Common Lisp

変数と関数、定義する力

lambdaとdefun

前回lambdaの持つ様々な特徴と、ラムダ計算のための前置的リスト表記について説明しました。lambdaはCommon Lispにおいてラムダ表現のためのシンボルであり、functionスペシャルオペレータと組み合わせることで関数を作り出すのですが、作り出した関数に名前をつけることはできません。このため、「ラムダ」のことを「無名関数」とも呼びます。

無名関数は当然ながら名前がないので、一度データとして関数が作成されてもあとで呼び出すことができません。全ての処理がその場限りであれば名前をつけて保存しておく必要はありませんが、いくつかの関数は複数回利用されるかもしれません。

そこでCommon Lispには名前付きの関数を作成するためのdefunというオペレーターが用意されています。

ところで、どのような関数に名前をつけて保存しておくべきなのでしょうか。

言葉を定義する

C言語をはじめとした手続き型言語においてのプログラミングは、問題解決までの道のりをいくつかのステップに分けて、それをさらに小さな手続きに分け、その手続きを順番に実行していくというスタイルです。しかし、問題が複雑化したり、そもそも問題すらも最初は明確でないという場合、手続きをどのように分割すればいいのか、どのように組み合わせればいいのかが分からなくなりがちです。そこで、プログラマの意向によってどこまでをどの手続きに含めるかが決められていきます。

しかし、プログラミング言語は「言語」であることを思い出してください。自然言語における「語彙」は他の語彙の組み合わせによって説明されますが、新しい言葉は新しい名前を得ることで新しい「概念」が追加されるのです。

例えば大辞林第三版で「平均」という言葉を検索すると、「いくつかの数値の代表として採用する値の一。」と出てきました。ここでは平均が「数値の代表」としての性格を持つことが示されていますが、その定義上は平均をどのように求めるかは記述されていません。しかし、どのように求めるのかという手続きよりも、その言葉を定義することで「数値の代表」という新しい概念を得ることの方が「言語」として重要です。なぜなら「平均」という言葉を使って会話をするときには、その値が標本(サンプル)または母集団を代表する値であり、集団の性格を表現する手段として使っているからです。(さらに、どのように平均を求めるかという手段(手続き)には複数の方法がありますが、どれも「平均」と呼ばれるのはその意味が「数値の代表」としての性格を多かれ少なかれ有しているからこそです。)

プログラミング言語において「関数」に名前をつけるのは、前節で述べた通り後で呼び出されるからですが、その関数が呼び出される場所ではその関数の手続き的な中身ではなく、その関数によって得られる値が他の関数を定義する際に使われたりデータを処理した結果としてユーザーに何かを表現したりするために使われるのですから、その関数の「意味」が重要なのです。

よって、関数を名前付きで定義するときは以下の点に留意しなければなりません。

偏差値を求めるまで

さて、前置きが長くなりましたが、今回はCommon Lispに少しだけ「統計」の概念を追加してみましょう。

あなたが学校の先生であるとして、生徒の試験結果をどのように分析しているでしょうか。校内の定期試験ではほとんどが点数の代表としての「平均点」を公表する程度でしょうが、実際には「分散」か「標準偏差」などの分布に関する情報も公開すべきだと思います。なぜなら、ほとんどの人が平均点の周辺に集中している中でその平均的集団から抜きん出て高得点を取ることは特別な努力が必要ですが、点数が広く分布しているような場合には努力した分だけ点数の向上に直結する可能性が高いと言えます。

「平均」や「標準偏差」(または「分散」)が標本全体の特徴を捉えるものであるのに対して、その特徴ある標本の中で個々の生徒がどのような相対的位置にいるのかを表すのが「偏差値」です。60点という得点はそれだけでは何も分からず、平均点が50点であると分かっても平均点よりも10点高いということしか分かりませんが、60点の人の偏差値が60であるということは、自分がおよそ上位15〜20%くらいの所にいることが分かります(正規分布が前提)。偏差値が70であれば上位3%くらいのボジションにいます。先生にとってはクラスの得点状況の分布が重要であり、試験を受けた生徒にとっては自分のポジションが重要でしょうから、生徒には偏差値を教えます。

以上が、今回取り扱う問題の「意味」です。問題の解決に到るまでに様々な概念を導入する必要がありますから、それらは意味を持つ「関数」として定義します。

quoteとdefvar

今回、10人分の試験結果が以下の通りであるとします。

55 36 89 67 43 34 46 86 70 79

平均を求めるために全てを加算するには、以下のようにします。

(+ 55 36 89 67 43 34 46 86 70 79)
; =>605

しかし、テストの点数を保存しておく方が便利です。テストの点数をまとめてカッコでくくってみます。

(55 36 89 67 43 34 46 86 70 79)
; => ERROR

カッコの役割を思い出してください。カッコの先頭の要素は関数で、後に続けて引数を列挙するのでした。ここでは55は関数ではありませんから、「先頭の要素が関数名でもラムダ表現でもありません」というエラーになります。

一連のデータを関数の適用としてではなくただのデータとして表現するにはquoteスペシャルオペレータを使用します。

(quote (55 36 89 67 43 34 46 86 70 79))
; => (55 36 89 67 43 34 46 86 70 79)

このようにして得られる一連のデータを保存しておく、つまり、生徒の得点として定義するにはdefvarを使います。

(defvar score (quote (55 36 89 67 43 34 46 86 70 79)))
; => SCORE

これはいわゆる「変数」ですが、Common Lispでは大域変数を定義する際に*NAME*というように*で囲む慣習があります。lambdaで使われる引数リストは大域変数ではありませんから、*はつけません。

(defvar *score* (quote (55 36 89 67 43 34 46 86 70 79)))
; => *SCORE*

今回はコードが長くなりますから、このコードをテキストファイルに保存して、Common Lispにロードさせていきましょう。"stat.lisp"というファイルにコードを記述し、

(load "stat")
; => #P"/home/.../.../stat.lisp"

とすると、ファイルの中身を打ち込んだのと同じようにCommon Lispにロードされます。

applyとlength

生徒の得点データは*score*で定義できましたから、次は「平均」を定義します。

平均はデータの「重心」です。平均を支点としてデータを支えると、右と左が釣り合います。そのような重心を求めるには、全体の「重さ」とデータの「個数」が必要です。ここで「重さ」は得点の合計であり、「個数」は生徒の数です。

生徒の数は10人だと分かっていますが、Common Lispには要素数を数える関数があります。lengthです。

(length *score*)
; => 10

合計する関数はすでに使ったことのある+です。しかし、*score*に直接使うことはできません。

(+ *score*)
; => ERROR

+関数は「数(Number)」を引数にとる関数ですが、*score*は数がまとまったデータで、数自体ではありません。このような時に使うのがapply関数で、適用すべき関数とデータをそれぞれ引数に取ります。

(apply (function +) *score*)
; => 605

前回の最後の参考文献で書きましたが、functionスペシャルオペレータは#'で代用することができます。

(apply #'+ *score*)
; => 605

これで「合計得点」と「生徒数」から「平均点」を求める新しい関数を定義することができます。

(defun mean (data)
  (/ (apply #'+ data)
     (length data)))
; => MEAN

/関数は除算をします。meanはいわゆる算術平均、単純平均を求める関数です。defunlambdaと非常によく似た構文ですが、defunの直後に名前が来る点だけが違います。実際に行なっているのはlambdaで作られるラムダに名前(シンボル)をつけて定義していることになり、関数の作成とシンボルへの束縛を同時に行なっていることになります。

lambda over lambda

平均はデータの重心としてデータを代表しますが、その分布に関する情報は削ぎ落としてしまいます。つまり、平均の周りに集中しているのか、散らばっているのかという情報です。すでに述べたように、得点の分布が生徒の学習モチベーション向上や教員の指導方針の検討に有効な情報ですから、その情報を求める必要があります。

平均点が0点からどれほど離れているかを示す「原点周り」の情報であるとすると、平均点からどれほど離れているかを示す情報は「平均周り」の情報と言えます。統計の世界では平均(期待値)のことを「原点周りの1次モーメント」と呼び、「平均周りの2次モーメント」として「分散」の概念を導入します。

分散は、個々の値が平均値から離れている距離を二乗したものを合計し、その個数で除した値になります。なぜ二乗するかというと、距離は必ずプラスですが、値の差分はマイナスになるかもしれず、プラスとマイナスの場合で条件判断を行うよりは全て二乗した方が単純だからです。現在は計算機で条件分岐を行うことも容易になりましたが、2方向に別れる条件分岐が登場したのはLISPが最初です。

分散は前述の定義の通りですが、データが標本(サンプル)として与えられた場合、除す値を「個数」ではなく「個数 - 1」とするのが一般的です。個数をNとすると、平均を求める時に1/Nを使っており、値Xの平均からの距離(差分)は(X - 1/N * SUM)で得られますから、Nで割ってしまうと分布量が小さくなってしまいます。除すべきはN - 1であり、これを分散の自由度と呼びます。

では、分散を少しずつ求めていきましょう。一つの得点をxとし、平均がmeanであるとすると、平均からの距離の二乗は以下のlambda式で表すことができます。

(lambda (x) (* (- x mean) (- x mean)))
;Compiler warnings :
;   In an anonymous lambda form: Undeclared free variable MEAN
; => #<Anonymous Function #x3020009373EF>

私が使っている処理系では警告が出ました。lambda表現の中に宣言されていない「自由変数」meanが存在しますよ、という警告です。このラムダにとって個々の得点であるxは引数リストに書いてありますが、平均点meanは書いてありませんから、meanがまだ何にも束縛されていない状態になります。meanも引数リストに加えれば警告は出なくなりますが、平均はどの点数に対しても同じ値が使われますから、平均からの距離を求めるラムダにとっては「定数」になります。

今、言葉で説明したことをラムダに置き換えると次のようになります。

(lambda (mean)
  (lambda (x)
    (* (- x mean) (- x mean))))
; => #<Anonymous Function #x302000AB8D7F>

外側のlambdameanが束縛され、内側のlambdaでは束縛された状態のmeanを使うことができます。内側のlambdaは外側で束縛されているmeanを包み込んだ状態で処理を行うので、「クロージャ(closure)」と呼ばれます。また、ラムダの引数リストで束縛される値はそのラムダの定義の中だけで参照ができ、このような変数参照の範囲を持ったクロージャを「レキシカルクロージャ(lexical closure)」と呼びます。Common Lispでlambdadefunで作られる「関数」の実体は「レキシカルクロージャ」です。

ここでは-関数も初めて利用しましたが、(- x mean)を2回続けるのが格好が悪いので、ついでにべき乗を行う関数も導入しましょう。べき乗はexpt関数で行います。

(expt 2 10)
; => 1024

exptを使って書き換えると、以下のようになります。

(lambda (mean)
  (lambda (x) (expt (- x mean) 2)))
; => #<Anonymous Function #x3020009B9D0F>

mapcar

さて、平均得点meanに対するある得点xの距離の二乗を求めることは前節で可能となりましたが、「分散」を求めるためにはその処理を全ての引数に対して行い、さらにその合計を求めなくてはなりません。

他の言語でプログラミングを学んだことがある人であればすぐに「繰り返し」が思いつくかもしれませんが、他の言語で使うような繰り返しはCommon Lispでは不要な場合があります。なぜなら、Common Lispには「マップ関数」が用意されているからです。

マップ関数とは、値の集合に対応する別の集合を作り出す関数です。例えば(1 2 3 4 5)というデータ列に対して(1 4 9 16 25)という列は(lambda (x) (expt x 2))という関係にありますが、元のデータと関係を表す関数を使って新しいデータを作り出すのがマップ関数です。代表的なマップ関数はmapcarです。

(mapcar (lambda (x) (expt x 2)) (quote (1 2 3 4 5)))
; => (1 4 9 16 25)

Common Lispには1-という「1を引く」関数も用意されています。lambdaによる無名関数ではなく関数が束縛されたシンボルを使う場合は、function(#')を使います。

(mapcar #'1- (quote (1 2 3 4 5)))
; => (0 1 2 3 4)

前回説明した通り、シンボルとしてのlambdaを使った場合は自動的にfunctionが補完されますからfunctionは不要ですが、自分でつけても構いません。

(mapcar #'(lambda (x) (expt x 2)) (quote (1 2 3 4 5)))
; => (1 4 9 16 25)

使い方はfuncallapplyと同じで、第1引数に関数を、第2引数にデータを取ります。このように関数を引数に取る関数を「高階関数(high-order function)」と言います。

高階関数を使えば繰り返しがなくても似たような処理がより簡潔に記述できます。先ほどの二重のラムダを思い出してください。

(lambda (mean)
  (lambda (x) (expt (- mean x) 2)))

このラムダを使って分散の元になる平均からの距離の二乗のデータを得るには、外側のラムダに(mean *score*)を渡してmeanを束縛し、内側のラムダをmapcarの関数に渡して*score*をマップしていけばいいことになります。

((lambda (mean)
   (mapcar (lambda (x) (expt (- x mean) 2)) *score*))
 (mean *score*))
; => (121/4 2401/4 3249/4 169/4 1225/4 2809/4 841/4 2601/4 361/4 1369/4)

これも他言語から来た人は面食らうかもしれません。Common Lispは分数を扱うことができます。小学生でも分数の計算ができるのですから計算機が分数を扱えない方がおかしいような気もしますが、小数のみサポートしていて分数はサポートしていない言語も多いでしょう。

あとは平均を求めた時と同じようにapply+し、lengthで個数を求めて1-で1を引いた自由度で割れば分散が出ます。

(/ (apply #'+ ((lambda (mean)
                 (mapcar (lambda (x) (expt (- x mean) 2)) *score*))
               (mean *score*)))
   (1- (length *score*)))
; => 7573/18

動くことが確認できたので、このラムダにvarianceという名前をつけて定義しておきましょう。mean関数を定義した時にデータを渡したら平均が求められるようにしたので、今回も渡すのはデータだけにして、平均はvarianceの中でmean関数を呼び出すことによって得ることにします。*score*と書いてあるところをdefunの引数と合わせればすぐに定義できます。

(defun variance (data)
  (/ (apply #'+ ((lambda (mean)
                   (mapcar (lambda (x) (expt (- x mean) 2)) data))
                 (mean data)))
     (1- (length data))))

これでCommon Lispは「分散」の概念を獲得しました。

標準偏差と偏差値

あとは分散の平方根として標準偏差を定義します。Common Lispで平方根を求める関数はsqrtです。

(defun sd (data)
  (sqrt (variance data)))

こんな短い関数なら定義しなくてもいいではないかと思うかもしれませんが、定義してください。これは「分散の平方根」を求めて返すだけですが、「標準偏差(Standard Deviation)」という新しい概念です。分散は二乗しているので単位を失いますが、標準偏差にすると単位が復活します。標準偏差の単位は元のデータの単位と同じで、今回であれば「点」になります。分散から単純に求めることができますが、分散とは違う意味を持ちます。だから、定義してください。

最後に、偏差値を求める関数を作りますが、偏差値は平均、分散、標準偏差とは異なる点があります。平均、分散、標準偏差はデータの集合の性質を表すものですが、標準偏差はデータの集合内における相対的な位置を示すものであり、どの得点(生徒)の偏差値かという形で「個」を特定する必要があります。方法は2つ考えられます。

一つはマップ関数を使って集合の偏差値を全て求める方法です。これまで引数には集合そのものを渡していたので、得点の集合を渡すと偏差値の集合が評価結果として戻ってくるような関数が考えられます。

もう一つは、偏差値を計算するのに必要な情報を全て引数として渡す方法です。つまり、個の得点、平均点、標準偏差の3つの引数を取る関数として設計します。

標準偏差を求める時は分散の時と同じように、個の得点が可変的であり、平均点と標準偏差は固定的(定数)です。どちらの関数を使うにせよ、二重のラムダを使うことになりそうです。そう考えると、設計の違いはマップするかどうかだけに過ぎません。

では、ベースとなる偏差値の計算式をラムダで表現してみましょう。

(lambda (mean sd)
  (lambda (x)
    (+ (* (/ (- x mean) sd)
          10)
       50)))

個の得点xから平均点meanを引き、標準偏差sdで割って10を掛け、50を足します。1050という定数は日本の偏差値で用いられるものですが、それぞれ「1標準偏差」と「平均値の偏差値」を意味します。偏差値においては10が標準偏差の幅に対応し、平均点とちょうど同じ人は50になります。

試しに実行するには分散の時と同じように実際のデータを入れます。「個の値」は試しに一番最初の55点を使ってみます。

((lambda (mean sd)
   ((lambda (x)
      (+ (* (/ (- x mean) sd)
            10)
         50))
    55))
 (mean *score*)
 (sd *score*))
; => 47.31858

mapcarにより全てのデータの標準偏差を求める場合は、内側のラムダの手前にmapcarを置き、適用する値を「個の値」ではなく「データの集合」に置き換えます。

((lambda (mean sd)
   (mapcar #'(lambda (x)
               (+ (* (/ (- x mean) sd)
                     10)
                  50))
           *score*))
 (mean *score*)
 (sd *score*))
; => (47.31858 38.05549 63.894634 53.168953 41.46821 37.08043 42.9308 62.43204 54.631546 59.019325)

さて、実際の偏差値取得関数はどのように実装するべきでしょうか。使い方にもよりますが、今回はCommon Lispらしく「関数を返す関数」で実装してみます。

関数を返す関数

Common Lispで解決する問題は可変的であることが多いです。つまり、「最初はこの答えを欲しいと思っていたが、実際に解決していく過程で別の問題の方がより価値のある問題であることが分かったので方針転換をする」というようなことがあります。統計を扱う時は最初はデータの特性が分かりませんから、少しずつ特性が分かることで何を分析するべきかが見えてくるということもあります。特に経済分析やビッグデータ解析などは実験で得られるデータではなくリアルな世界のデータなので、分析をしながら方針を決めていくという方が多いように思います。

今回の「偏差値」は計算に必要なベース部分は二重のラムダの部分であり、あとはどう使うかはその時その時で違うでしょう。つまり、とりあえずラムダを返す関数さえあれば、あとは「インターフェース」の問題だということです。

ラムダを返すのは簡単で、ラムダを書いておくだけです。つまり、データの集合を与えると、定数としての平均と標準偏差は固定されますから、その2つのデータを包み込んだクロージャとしてラムダを返すのです。そのクロージャを使えば、「個の値」を適用すると対応する偏差値を返し、点数の集合をmapcarすれば偏差値の集合が帰ってくることになります。

動作のイメージを掴むには、まず先にすでに書いた二重のラムダをそのままdefunで関数にしてしまうのが早いでしょう。

(defun standard-score (data)
  ((lambda (mean sd)
     (lambda (x)
       (+ (* (/ (- x mean) sd)
             10)
          50)))
   (mean data)
   (sd data)))
; => STANDARD-SCORE

この偏差値取得用関数standard-scoreは点数の集合を与えるとその集合の平均と標準偏差を内包したクロージャを返します。試しにそのクロージャを見てみましょう。

(standard-score *score*)
; => #<COMPILED-LEXICAL-CLOSURE 
;      (:INTERNAL STANDARD-SCORE) #x30200099E72F>

Emacs+SLIMEを使って書いているので、Ctrl+Cを2回押すことで編集中のファイルの関数をその場でコンパイルできます。standard-scoreをコンパイルすると、中のラムダもコンパイルされます。ここではコンパイルされたレキシカルクロージャが評価結果であることが分かります。

このクロージャをdefvarを使って変数に保存すれば、いつでも使いまわせます。

(defvar std-sc (standard-score *score*))
; => STD-SC

これで自由に標準偏差を求めることが可能です。

(funcall std-sc 55)
; => 47.31858

(mapcar std-sc *score*)
; => (47.31858 38.05549 63.894634 53.168953 41.46821 37.08043 42.9308 62.43204 54.631546 59.019325)

一回限りの利用であれば、変数に保存する必要はありません。

(funcall (standard-score *score*) 55)
; => 47.31858

いずれもfunction(#')が付いていないことに注意してください。functionスペシャルオペレータは関数が束縛されたシンボルまたはラムダ表現からレキシカルクロージャを作るものですが、standard-scoreは返り値がレキシカルクロージャですから、functionを付けると二重になってしまって意味がありません。standard-score関数の中の内側のlambdaが(マクロとしてのlambdaにより)すでにfunctionをつけた状態で処理されます。

ソースコード

今回作成した関数の一覧をファイルにして掲載しておきます。今回直接は使いませんでしたが、せっかくなので合計用の関数sumも追加して、meanではsumを使うようにしています。

;;; stat.lisp

;; Function: SUM list => number
(defun sum (data)
  (apply #'+ data))

;; Function: MEAN list => number
(defun mean (data)
  (/ (sum data)
     (length data)))

;; Function: VARIANCE list => number
(defun variance (data)
  (/ (apply #'+ ((lambda (mean)
                   (mapcar (lambda (x) (expt (- mean x) 2)) data))
                 (mean data)))
     (1- (length data))))

;; Function: SD list => number
;; (SD = Standard Deviation)
(defun sd (data)
  (sqrt (variance data)))

;; Function: STANDARD-SCORE list => function
(defun standard-score (data)
  ((lambda (mean sd)
     (lambda (x)
       (+ (* (/ (- x mean) sd)
             10)
          50)))
   (mean data)
   (sd data)))

参考文献


Copyright © 2017- satoshiweb.net All rights reserved.