実験などで、あるプログラムの出力が「正しい (等しい)」かどうかを 判定するのは実は簡単ではない。 出力が 1つしかない場合や、「完全一致かどうか」だけしか考慮しない場合は 簡単だが、難しいのは:
であるような場合である。 ここでは離散的なデータのみを扱う。
離散的な集合
A, B がベクトル
これはようするに {ai}, {bi} の各要素を並べ、これを ベクトルとしてみたときの cosine距離である:
これがなぜうまくいくのかは、次の例を考えてみるとわかる。
素性が揃っているとき | 素性が揃ってないとき |
---|---|
類似度: 高い | 類似度: 低い |
さらに、次のようなケースもカバーできる:
高さが違うが形は似ているとき |
---|
類似度: 高い |
各素性の集合からなるベクトルは、Python では辞書 (dict
) として
表すのがもっとも自然である。そこで、2つの与えられた辞書オブジェクトから
cosine距離を求めるような関数 calcdot()
を作る。
def calcdot(a, b):
...
return
v1 = {'x':1, 'y':2}
v2 = {'x':2, 'y':1, 'z':3}
print(calcdot(v1, v2)) # 0.47809144373375745
いっぽう A と B が文字列 (あるいはビット列) であり、 その順序が重要となる場合は、 編集距離 (edit distance) あるいは レベンシュタイン距離 (Levenshtein distance) と 呼ばれる尺度を利用する。 これはようするに「文字列 A → 文字列 B に変換するとき、 文字を挿入・削除・置換する回数はどれくらいか」を表したものであり、 これは diff の長さに相当する。
実際の編集距離を定義するのは面倒くさいので、新山は LCS (Longest Common Subsequence) と もとの文字列との比率で計算することが多い。
たとえば
LCS を計算するには、動的計画法を使うのが普通である。
def find_lcs_len(s1, s2):
m = [ [ 0 for x in s2 ] for y in s1 ]
for p1 in range(len(s1)):
for p2 in range(len(s2)):
if s1[p1] == s2[p2]:
if p1 and p2:
m[p1][p2] = m[p1-1][p2-1]+1
else:
m[p1][p2] = 1
elif m[p1-1][p2] < m[p1][p2-1]:
m[p1][p2] = m[p1][p2-1]
else: # m[p1][p2-1] < m[p1-1][p2]
m[p1][p2] = m[p1-1][p2]
return m[-1][-1]
VSM は「離散的な、頻度情報あるもの」を比較するのに向いているので よく自然言語処理の文書比較に用いられる。 ここでは、以下の英語 Wikipedia 記事 10000個から、 たがいに良く似ている (単語の出現パターンが近い) 記事を発見してみる。
# 2428190 Melbourne Shuffle The Melbourne Shuffle (also known as Rocking or simply The Shuffle) is a rave and club dance that originated in the late 1980s in the underground rave music scene in Melbourne, Australia. The basic ... (空行) # 442370 List of prime numbers By Euclid's theorem, there is an infinite number of prime numbers. Subsets of the prime numbers may be generated with various formulas for primes. The first 500 primes are listed below, followed ... (空行)
まず文字列を単語のリストに変換する関数 splitwords
を考える。
英単語の正確な切り出しは本当は複雑なのであるが、
ここでは正規表現を使って簡単にすませる:
import re def splitwords(text): return [ w.lower() for w in re.findall(r'\w+', text) ]
つぎに上の doit()
を改良して、
読み込みんだデータファイルを解析する:
def doit(args): for line in fileinput.input(args): line = line.strip() if line.startswith('#'): (artid, _, title) = line[2:].partition(' ') artid = int(artid) elif line: # 単語に区切る。 words = splitwords(line) else: # この時点で artid, title, words が設定されているはず。 print(artid, title, words) # 各単語の頻度情報を格納した辞書 wordcount を求める。 wordcount = countwords(words)
gzip圧縮されたデータを読み込むには、以下のようにする手もあるが:
$ gzip -dc enwiki-20140102-10000.txt.gz | python doit.py
Pythonのfileinput
にオプションを与えると、gzipをそのまま読み込むことができる。
(ただし、UTF-8をデコードする必要があるので注意!)
for line in fileinput.input(args, openhook=fileinput.hook_compressed): ...
calcdot()
を
10000×10000のベクトル対に適用し、
もっとも高い類似度をもつ記事ペアを表示せよ。
これは別の Python スクリプトにする。
基本戦略は、すべての記事対 |A| × |B| に対して、
calcdot(a,b)
を計算し、最高となる a, b を求めればよい:
articles = [ ... ] maxsim = 0 maxpair = None for a in articles: for b in articles: sim = calcdot(a, b) if maxsim < sim: maxsim = sim maxpair = (a,b)
実際には calcdot(a,b) == calcdot(b,a)
であることを
利用して、計算時間を半分にする:
for (i,a) in enumerate(articles): for b in articles[i+1:]: sim = calcdot(a, b) ...
(注意: なお、ここで紹介した方法は完璧ではない。 一般的には、自然言語文の類似度計算には 各単語の出現頻度だけでなく、単語の重み (IDF) も考慮している)
新山による実装: https://github.com/euske/python3-toys/blob/master/vsm.py
古くからある「典型的な」人工知能では、 次のような問題を扱っている:
上の例からわかるように、多くの人工知能の問題は、探索として扱われる:
a = [すべての可能性]
answer = なし
for x in a:
# answer と x の「良さそう度」を比較する
if E(answer) < E(x):
answer = x
print(answer)
これをあまりにも大量かつ高速にやると、人間には「賢そう」に見える。
人工知能の原理は、人間の脳が働く原理とはまったく別でもかまわない。
(自動車が走る原理は、人間が走る原理とはまったく異なる。)
問題は、「すべての可能性
」を現実的にどうやって調べるか?
ということである。普通にやると計算量が大きくなりすぎてしまうので、
何らかの方法でサバを読む必要がある。また「良さそう度」を判定する関数
E(x)
はなにか、という問題もある。
現在のほとんどの AI研究は、このような問題に対する解決策の研究である。
以下のような処理を考えよう。
100×100ピクセルの画像 (リストのリスト) を与えられると、
それが「どれくらい人間の顔らしいか」を判定する
関数 faceness
があるとする:
def faceness(image): ... return x
画像 | 顔らしさ |
---|---|
0.95 | |
0.87 | |
0.02 |
このような関数は頑張れば作れるかもしれないが、非常に大変である。 判定に使う変数が極端に多い (100×100個) うえに「何が顔らしいのか」を 論理的に規定するのが難しいからである。そこで、 あらかじめ「顔らしい画像」と「顔らしくない画像」を大量に用意しておき、 コンピュータを使ってこのような関数を自動的に発見させよう、 というアイデアが 機械学習 (Machine Learning, ML) である。 このようにして発見されたプログラムを「分類器 (classifier)」と呼ぶ。 機械学習は人工知能の一分野であり、現在さかんに研究されているが、 基本的には「プログラムを発見 (作成) するプログラム」といえる。 機械学習はおもに自然界の現象 (規則できっちり定義できないもの) を対象に使われることが多い。 (消費税の計算をするのに機械学習を使ったりはしない)
機械学習には、大きく分けて 3種類ある:
他の人工知能の例にもれず、機械学習もまた探索として定義される:
a = [存在しうるすべてのプログラム]
classifier = なし
for p in a:
# プログラム p の良さそう度を比較する。
if E(classifier) < E(p):
classifier = p
print(classifier)
教示つき機械学習においては、ある分類器 (プログラム) が 「良いかどうか」は比較的簡単に測定できる。 たとえば顔認識では、あらかじめ顔らしい画像とそれ以外の画像が与えられるので、 与えられた分類器がどれくらい顔を正しく認識できるかを数えればよい:
# 顔判定プログラム p の「良さそう度」を計算 def E(p): score = 0 # 顔を顔として判定したら得点。 for image in [すべての顔らしい画像]: if プログラム p が image を顔と判定する: score = score + 1 # 顔でないものを顔でないととして判定したら得点。 for image in [すべての顔らしくない画像]: if プログラム p が image を顔でないと判定する: score = score + 1 return score
機械学習の一番の問題は 「存在しうるすべてのプログラム」が無限にあって、探索しつくせないということである。 したがって、ふつう機械学習では「分類器」として Python のような本物のプログラムではなく、 非常に限定された形のもののみを扱う。ここでの分類器の形式としては 決定木、線形分類器、 ニューラルネットワークなどが提案されているが、 今回は Naive Bayes法 を紹介する。
Naive Bayes 法は、与えられた素性 (feature) と予測したい答え (prediction) との相関を、 条件つき確率を使って求める方法である。 離散的な少数の素性 (〜1000個程度) のみからなるデータを てっとり早く学習したいときに有用である。 自然言語処理では Naive Bayes 法は spam の判定などに用いられるが、 離散的なデータであれば何でも利用することができ、しかも精度はそれほど悪くない。
で、どうやって P(k | F) を計算するのか? 以下の式を使う。 (Naive Bayes といわれるゆえんである。)
実際には、この仮定は正しくない。 (Naive Bayes といわれるゆえんである。) しかしこの仮定により、
Naive Bayes を実装するには
さらに (素性と関係なく) k が現れた回数は、 それ自体をひとつの特殊な素性fcount = { クラス1: { 素性a: 回数, 素性b: 回数, ... } クラス2: { 素性a: 回数, 素性b: 回数, ... } ... }
ALL
とみなせるので
のようにできる。fcount = { クラス1: { ALL: 回数, 素性a: 回数, 素性b: 回数, ... } クラス2: { ALL: 回数, 素性a: 回数, 素性b: 回数, ... } ... }
これをふまえて、
NaiveBayes
クラスを定義する:
class NaiveBayes: def __init__(self): self.fcount = {} # 素性 (k,f) の出現回数。 return # 素性とクラスの相関をひとつ学習する。 def learn(self, k, feats): # クラス k と同時に現れた素性一覧をとりだす。 if k in self.fcount: fc = self.fcount[k] else: fc = self.fcount[k] = {} # k の数を数える。 if 'ALL' not in fc: fc['ALL'] = 0 fc['ALL'] += 1 # (f,k) の数を数える。 for f in feats: if f not in fc: fc[f] = 0 fc[f] += 1 return
モデルが学習できたら、予測である。
素性の集合 feats
が与えられたら、
各 k に対して
N
が同じなのでこれは確率である必要がない。
fcount[k]['ALL'] * Π (fcount[k][f] / fconut[k]['ALL'])だけで済んでしまう。 さらに、あらかじめ
kcount
と fcount
の log を記録しておき
log(fcount[k]['ALL']) + Σ (log(fcount[k][f]) - log(fconut[k]['ALL']))のようにすれば加減算だけでよくなる。 これをふまえて、メソッド
predict()
を設計する:
class NaiveBayes: ... # 与えられた素性から推定される各クラスの確率を返す。 def predict(self, feats): klass = [] for (k,fc) in self.fcount.items(): # P(k)・P(fi | k) を計算する。 pk = log(fc['ALL']) p = (pk + sum( (log(fc[f]) - pk) for f in feats )) klass.append((p, k)) # クラスの一覧を確率の大きい順にソートする。 klass.sort(reverse=True) return klass
この方法がよいのは、結果が確率 (のlog) つきで
返されるということである。
もっとも確実な予想だけを知りたければ klass[0]
を使えばよいし、
第2候補も欲しければ klass[1]
も見ればよい。
複数の候補が返されるのは Naive Bayes の大きな利点である。
決定木における「素性」とは、何がしかの値を持つものであったが、
Naive Bayes における「素性」は、実際には「存在するか否か」
という二値的なものであることに注意。
したがって、 picnic.csv
のようなデータを使うには、
各素性を "Outlook=Sunny
" のように
まるごと文字列として表してやる必要がある。
つまり「素性 Outlook
の値が
"Sunny
" / "Overcast
" / "Rain
" のどれかだ」と
考えるのではなく、
「"Outlook=Sunny
"、
"Outlook=Overcast
"、
"Outlook=Rain
" という別々の素性が存在する」
と考えるのである。
当然、Outlook
の値が排他的だという情報は
Naive Bayes にはわからない。したがって Naive Bayes は
「"Outlook=Sunny
" かつ "Outlook=Overcast
"」
というありえない状況も排除しない。
これは独立性の仮定を置いたことによる帰結で、
Naive Bayes 法の限界である。
nb = NaiveBayes() FEATS = ( 'Outlook', 'Temp', 'Humidity', 'Wind' ) for obj in objs: # オブジェクトの各素性の値を、二値的な素性に変換する。 feats = [ f'{k}={obj[k]}' for k in FEATS ] # Decision の値と各素性との相関を学習する。 nb.learn(obj['Decision'], feats)
新山による実装: https://github.com/euske/python3-toys/blob/master/naivebayes.py
実際に上の例を実行してみると、
3番目のオブジェクトのあたりで
「"Outlook=Overcast
" という素性が存在しない」
という KeyError
例外が発生してしまう。
これは P(No
| Outlook=Overcast
) の
確率を計算しようとしたことによる。
(fcount['No']
の fc
中には
Outlook=Overcast
というキーが存在しない。)
これは Naive Bayes を使うさいによく現れる問題で、
このような学習データが存在しなかったのであるから、
そもそも確率を計算できないのである。
このような場合、逃げの一種として
「素性の各出現回数に 1 を出す」という方法がある。
いわゆる "Laplace smoothing" である。
これは
「どんな可能性もゼロではない」という信念のもとに成り立っている。
これは Python のコード上では、
fc[f]
でキーが存在しなかったときに
1
を返すように実装するだけである。
# 使用前 p = (pk + sum( (log(fc[f]) - pk) for f in feats )) # 使用後 p = (pk + sum( (log(fc.get(f,0)+1) - pk) for f in feats ))