データ処理の基礎

権藤研 全体ゼミ 2020/04/23
新山

1. 類似度の判定

実験などで、あるプログラムの出力が「正しい (等しい)」かどうかを 判定するのは実は簡単ではない。 出力が 1つしかない場合や、「完全一致かどうか」だけしか考慮しない場合は 簡単だが、難しいのは:

  1. 結果が複雑であり
  2. しかも可変長

であるような場合である。 ここでは離散的なデータのみを扱う。

1.1. 集合の場合 (Jaccard Index, F-measure)

離散的な集合

A = {ai}, B = {bi}
があるとき、 A と B の「重なり」を調べる式には次のものがある:
Jaccard Index = |A ∩ B| / |A ∪ B|
F-measure = 2 × (precision ・ recall) / (precision + recall)

1.2. ベクトルの場合 (Vector Space Model)

A, B がベクトル

A = {ai}, B = {bi}
であり、各要素が「重み (頻度)」であるようなとき、 この 2つの類似度を計算する Vector Space Model (VSM) と 呼ばれる方法がある。

これはようするに {ai}, {bi} の各要素を並べ、これを ベクトルとしてみたときの cosine距離である:

類似度 = Σ (ai × bi) / √ (Σ |ai|2) × √ (Σ |bi|2)

これがなぜうまくいくのかは、次の例を考えてみるとわかる。

素性が揃っているとき素性が揃ってないとき
類似度: 高い類似度: 低い

さらに、次のようなケースもカバーできる:

高さが違うが形は似ているとき
類似度: 高い

Pythonによる実装

各素性の集合からなるベクトルは、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
演習. 上の関数を完成させよ。

1.3. 文字列の場合 (Levenshtein Distance)

いっぽう A と B が文字列 (あるいはビット列) であり、 その順序が重要となる場合は、 編集距離 (edit distance) あるいは レベンシュタイン距離 (Levenshtein distance) と 呼ばれる尺度を利用する。 これはようするに「文字列 A → 文字列 B に変換するとき、 文字を挿入・削除・置換する回数はどれくらいか」を表したものであり、 これは diff の長さに相当する。

実際の編集距離を定義するのは面倒くさいので、新山は LCS (Longest Common Subsequence) と もとの文字列との比率で計算することが多い。

たとえば

の LCS は ?? 文字である。

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]

1.4. 実際のデータへの適用

VSM は「離散的な、頻度情報あるもの」を比較するのに向いているので よく自然言語処理の文書比較に用いられる。 ここでは、以下の英語 Wikipedia 記事 10000個から、 たがいに良く似ている (単語の出現パターンが近い) 記事を発見してみる。

演習. 上の記事データをダウンロードし、 各記事中の単語を単純な正規表現で切り出してカウントする Python プログラムを書け。 このデータは以下のような構造になっている。
# 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

2. 機械学習の超基本 (学部1年生向け)

2.1. 典型的な人工知能

古くからある「典型的な」人工知能では、 次のような問題を扱っている:

上の例からわかるように、多くの人工知能の問題は、探索として扱われる:

a = [すべての可能性]
answer = なし
for x in a:
    # answer と x の「良さそう度」を比較する
    if E(answer) < E(x):
        answer = x
print(answer)

これをあまりにも大量かつ高速にやると、人間には「賢そう」に見える。 人工知能の原理は、人間の脳が働く原理とはまったく別でもかまわない。 (自動車が走る原理は、人間が走る原理とはまったく異なる。) 問題は、「すべての可能性」を現実的にどうやって調べるか? ということである。普通にやると計算量が大きくなりすぎてしまうので、 何らかの方法でサバを読む必要がある。また「良さそう度」を判定する関数 E(x) はなにか、という問題もある。 現在のほとんどの AI研究は、このような問題に対する解決策の研究である。

2.2. 機械学習とは何か?

以下のような処理を考えよう。 100×100ピクセルの画像 (リストのリスト) を与えられると、 それが「どれくらい人間の顔らしいか」を判定する 関数 faceness があるとする:

def faceness(image):
    ...
    return x
画像顔らしさ
0.95
0.87
0.02

このような関数は頑張れば作れるかもしれないが、非常に大変である。 判定に使う変数が極端に多い (100×100個) うえに「何が顔らしいのか」を 論理的に規定するのが難しいからである。そこで、 あらかじめ「顔らしい画像」と「顔らしくない画像」を大量に用意しておき、 コンピュータを使ってこのような関数を自動的に発見させよう、 というアイデアが 機械学習 (Machine Learning, ML) である。 このようにして発見されたプログラムを「分類器 (classifier)」と呼ぶ。 機械学習は人工知能の一分野であり、現在さかんに研究されているが、 基本的には「プログラムを発見 (作成) するプログラム」といえる。 機械学習はおもに自然界の現象 (規則できっちり定義できないもの) を対象に使われることが多い。 (消費税の計算をするのに機械学習を使ったりはしない)

分類器 素性1 素性2 ... 結果

機械学習には、大きく分けて 3種類ある:

  1. 教示つき学習 (supervised learning): あらかじめ人が集めた正解データをもとに学習する。
  2. 教示なし学習 (unsupervised learning): 分類されていない生のデータのみをもとに学習する。
  3. 強化学習 (reinforcement learning): データがほとんどない状態から学習する。

2.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法 を紹介する。

3. Python による Naive Bayes の実装

Naive Bayes 法は、与えられた素性 (feature) と予測したい答え (prediction) との相関を、 条件つき確率を使って求める方法である。 離散的な少数の素性 (〜1000個程度) のみからなるデータを てっとり早く学習したいときに有用である。 自然言語処理では Naive Bayes 法は spam の判定などに用いられるが、 離散的なデータであれば何でも利用することができ、しかも精度はそれほど悪くない。

利点

欠点

3.1. 原理

  1. 学習: 素性の集合 F = {fi} とクラス k に対して、 条件つき確率 P(k | f1, f2, ..., fn) を計算する。
  2. 予測: F が与えられたら、 argmaxk P(k | F) となるような k を求める。

で、どうやって P(k | F) を計算するのか? 以下の式を使う。 (Naive Bayes といわれるゆえんである。)

P(k | F) = P(k)・P(F|k) / P(F)
ここで F はあらかじめ与えられているので P(F) は無視できて
argmaxk P(k | F) = argmaxk P(k)・P(F | k)
さらに、各素性 fik は相関しているが、 各素性 f1, f2, ... fn どうしは それぞれ 独立 (independent) して現れると仮定する。つまり
P(f1 | f2) = P(f1 | f3) = ... P(f1 | fn) = P(f1)

実際には、この仮定は正しくない。 (Naive Bayes といわれるゆえんである。) しかしこの仮定により、

P(F | k) = P(f1, f2, ..., fn | k) = P(f1 | k) × P(f2 | k) × ... × P(fn | k)
と近似することができる。 P(fi | k) を求めるのは簡単である。 学習データを見て、各素性 fi と それに対応するクラス k が同時に現れる回数をただ数えればよい。 このように、 Naive Bayes では素性と応答の数をただかぞえるだけで 学習が可能である。

3.2. Python における実装

Naive Bayes を実装するには

  1. 各クラス k が学習データ中に何回現れたか。
  2. 各クラスと素性の対 (fi, k) が 学習データ中に何回現れたか。
を記録しておく必要がある。 a. は簡単である。いっぽう b. は、以下のように格納しておくと便利である:
fcount = {
  クラス1: { 素性a: 回数, 素性b: 回数, ... }
  クラス2: { 素性a: 回数, 素性b: 回数, ... }
  ...
}
さらに (素性と関係なく) k が現れた回数は、 それ自体をひとつの特殊な素性 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

3.3. 予測する

モデルが学習できたら、予測である。 素性の集合 feats が与えられたら、 各 k に対して

P(k)・Π P(fi | k) = P(k)・Π {P(fi, k) / P(k)}
を計算すればよいのであるが、実際には 母数 N が同じなのでこれは確率である必要がない。
fcount[k]['ALL'] * Π (fcount[k][f] / fconut[k]['ALL'])
だけで済んでしまう。 さらに、あらかじめ kcountfcount の 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 の大きな利点である。

演習. データ picnic.csv に 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.4. スムージング

実際に上の例を実行してみると、 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 ))

Yusuke Shinyama