cc licensed ( BY ) flickr photo shared by Loco Steve
週末に試そうのコーナー。
ちょうど良いチュートリアルがあったので、データセットを用意してやってみました。
問題
How can I get a computer to tell me what an article is about (provided methods such as bribery and asking politely do not work)?
ある記事が何について書かれているのか、コンピュータに理解させるにはどうすれば良いか?
チュートリアルでは手動で作ったデータを使って犬もしくはサンドイッチの2クラス分類をしています。
ここでは、Google NewsでiPadのニュース、ソチ五輪のニュースとカテゴリ分けされている記事のタイトルを使って、
あるタイトルがiPadかソチ五輪のどちらのカテゴリに属するかの2クラスに分類してみます。
アルゴリズム
チュートリアルでは、動物名当てクイズのアナロジーでアルゴリズムを解説しています。
You provide your partner with a list of animals, and a list of attributes about each animal. You describe a domesticated animal that is furry, says ‘meow,’ and is prominently featured on the internet. Based on these clues, and the information you initially supplied, the person you’re playing with guesses “cat.”
あなたは動物名のリストと、動物の属性(特徴)のリストを提示します。柔らかい毛質の家庭的な動物で、みゃーと鳴き、インターネットで人気といったように。これらの手がかりを基に、クイズを出された人は"猫"と予測します。
文書分類の問題も同じように解きます。つまり、(1)既知の文書から特徴語を抽出し、(2)この特徴語を使って文書を表現する。そして、(3)特徴語に基づく文書表現を手がかりとして、文書をクラスに分類します。
この文書表現として最もよく使われているのがBoW(Bag of Words)ベクトルで、ここでもこれを使っています。Bag of Wordsは検索すればたくさんの解説ページが見つかると思いますが、ここでは既知の文書集合から特徴語を抽出して、各特徴語の頻度を文書ベクトルの値として用います。
例えば、dogとcatが特徴語だとすると、"dog cat cat dog dog"という文書は<3, 2>と表されます。
scikit-learnとgensimで分類システムを作る
チュートリアルと同じで、scikit-learnとgensimを使ってpythonで実装します。機械学習や自然言語処理を使ったシステムを作る場合、ライブラリが充実しているのでpythonを使うと楽です。全体はGitHub - satojkovic/NLP-ML-Exampleにあります。
(1) 特徴語の抽出
まずは、元となる文書集合(コーパス)をロードします。チュートリアルとは異なり、Google Newsのページをスクレイピングして、1タイトル1ファイルの形で保存したものをコーパスとして使用します。全部で87ファイル、そのうちipad関連が39、sochi関連が48ファイルとなっています。
$ cat corpus/ipad10.txt --- "Lighter and Faster, It's iPad Air" $ cat corpus/sochi25.txt --- "'Road to Sochi' tour is coming through Utah" (その他省略)
コーパスの読み込み後、タイトルを単語に分割し、ストップワードの除去とステミング処理を行います。チュートリアルでは、ストップワードを自前で定義して処理していますが、gensimのprocess_stringで二つの処理を一気に実行するようにしました。またダミーの文書の場合は問題ないようですが、実際のニュースのタイトル文章ではステミング処理をしないと、重要な単語が残らないということがあります。
# ストップワードの除去, ステミング print "\n---Corpus with Stopwords Removed---" preprocessed_docs = {} for name in names: preprocessed = gensim.parsing.preprocess_string(docs[name]) preprocessed_docs[name] = preprocessed print name, ":", preprocessed
残った中から特徴語となる単語を絞り込みます。TFIDFと同じ考え方で、コーパスの中で出現頻度の低すぎる単語と高すぎる単語は、文書間の違いを表せないので特徴語には不適切と考えて除去します。これは、gensimのfilter_extremesで一発です。
# 辞書を作成 # 低頻度と高頻度のワードは除く dct = gensim.corpora.Dictionary(preprocessed_docs.values()) unfiltered = dct.token2id.keys() dct.filter_extremes(no_below=3, no_above=0.6) filtered = dct.token2id.keys() filtered_out = set(unfiltered) - set(filtered)
実際、特徴語として39個の単語が残りましたが、sochiやipadというそのものずばりな単語の他に、mini / retina / thinner(薄い) や torch / russian などの関連する語も選ばれています。
Vocabulary after filtering... ['mini', 'thinner', 'bomb', 'lighter', 'torch', 'usa', 'month', 'expect', 'keyboard', 'best', 'size', 'welcom', 'tablet', 'review', 'sochi', 'uniform', 'countdown', 'start', 'appl', 'new', 'ipad', 'putin', 'retina', 'dai', 'unveil', 'game', 'gai', 'logitech', 'russian', 'olymp', 'athlet', 'winter', 'case', 'faster', 'ahead', 'displai', 'air', 'team', 'russia'] (39 words)
また、今回用意したコーパスでは、filter_extremesのパラメータによっては特徴語の数が少なくなって、この後のBoWベクトルを作ったときに、すべての特徴語の頻度が0となってしまうことがありました。そのため、チュートリアルとは違うパラメータを使用して頻度が0にならないようにしています。
(2) Bag of Wordsベクトルの作成
各文書について特徴語の出現頻度を要素とした39次元のBoWベクトルを作ります。これも、gensimのdoc2bowを使えば、特徴語の頻度をカウントして、BoWベクトルを簡単に作成することが出来ます。
出来上がったBoWベクトルは次のような感じです。
---Bag of Words Corpus--- sochi16.txt : [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] ipad30.txt : [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0] ipad4.txt : [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0] (その他省略)
次元の呪い(The curse of dimensionality)と次元削減
BoWベクトルの次元数が増えると、良くないことが起こります。
- 一つ明らかなのは、ベクトルを扱う処理の時間と空間の複雑さが指数的に増えてしまう
- もう一つの問題は、有用な結果を得るために必要なサンプル数が急激に増えてしまう(高次元の空間をモデル化するためには多くのサンプルが必要)
そこで、チュートリアルではLSI(Latent Semantic Indexing)を使って次元削減をしています。LSIの具体的な内容は他を調べて欲しいのですが(詳細なところはよくわかってないので)、アルゴリズムの考え方は直感的にわかりやすく説明してくれています。
---Bag of Words Corpus--- sandwich2.txt : [1.0, 0.0, 1.0, 3.0, 0.0, 0.0] dog2.txt : [0.0, 1.0, 0.0, 0.0, 1.0, 1.0] dog1.txt : [0.0, 1.0, 0.0, 0.0, 1.0, 2.0] sandwich1.txt : [2.0, 0.0, 1.0, 1.0, 0.0, 0.0]
これらのBoWベクトルを見ると、ある次元が他のある次元に関連していることが分かる。例えば、dog1.txtとdog2.txtを見ると、1番目と4番目、5番目の次元は一緒になって現れている。これは文書で考えると、"犬"が"走る"や"吠える"というような頻繁に一緒になる語であることを意味している。そこで、独立した単語で考える代わりに、単語をトピックにまとめるのがLSIの考え方である。
実際、iPadとSochiのコーパスから作成した39次元のBoWベクトルについて、LSIを用いて2次元のベクトル(トピック)に削減してみると、
Topics ['-0.798*"ipad" + -0.432*"air" + -0.209*"appl" + -0.171*"mini" + -0.138*"review" + -0.122*"retina" + -0.119*"new" + -0.093*"tablet" + -0.088*"size" + -0.088*"lighter"', '0.718*"sochi" + 0.594*"olymp" + 0.189*"winter" + 0.173*"russia" + 0.094*"dai" + 0.090*"usa" + 0.088*"team" + 0.085*"gai" + 0.076*"countdown" + 0.074*"putin"']
ipadについては、airやapplやminiなどが一つのトピックとなり、sochiについては、olympやwinterやrussiaなどがもう一つのトピックとしてまとめられていることがわかります。
実装は、gensimのLsiModelにトピック数をパラメータとして与えれば簡単に次元削減を行うことが出来ます。
# LSIにより次元削減 print "\n---LSI Model---" lsi_docs = {} num_topics = 2 lsi_model = gensim.models.LsiModel(bow_docs.values(), id2word=dct.load_from_text('id2word.txt'), num_topics=num_topics) for name in names: vec = bow_docs[name] sparse = lsi_model[vec] dense = vec2dense(sparse, num_topics) lsi_docs[name] = sparse print name, ":", dense print "\nTopics" print lsi_model.print_topics()
次元削減後のBoWベクトル
2次元のBoWベクトルは次のようになりました。iPadの文書は1つ目の次元に重みが大きくなり、一方でSochiの文書は2つ目の次元に重みが大きくなっています。なお、ベクトルは正規化をしています。
sochi16.txt : [-0.021845942161157976, 0.99976134892837865] ipad30.txt : [-0.99986349315131484, -0.016522562248343441] ipad4.txt : [-0.99966844307550351, -0.025748862479710709] (その他省略)
(3) SVMによる学習と2クラス識別
コーパスとして使用した89個の文書を使って、SVMの学習とiPadとSochiのどちらのクラスに属するかの識別を行います。scikit-learnのtrain_test_splitを使って、学習3:テスト1の割合に文書を分割します。後は、scikit-learnのSVC.fitとSVC.predictを使って、学習と識別を実行するだけです。結果は、classification_reportで行います。
# 2クラス分類 print "\n---Classification---\n" all_data = [unit_vecs[name] for name in names if re.match("ipad", name)] all_data.extend([unit_vecs[name] for name in names if re.match("sochi", name)]) all_labels = [1 for name in names if re.match("ipad", name)] all_labels.extend([2 for name in names if re.match("sochi", name)]) train_data, test_data, train_label, test_label = train_test_split(all_data, all_labels) # SVMの学習 classifier = SVC() classifier.fit(train_data, train_label) # 予測 predict_label = classifier.predict(test_data) target_names = ["class 'ipad'", "class 'sochi'"] print classification_report(test_label, predict_label, target_names=target_names)
---Classification--- precision recall f1-score support class 'ipad' 1.00 1.00 1.00 6 class 'sochi' 1.00 1.00 1.00 16 avg / total 1.00 1.00 1.00 22
文書といってもタイトルだけで、しかもiPadとSochiではトピックとしてはっきり分かれているせいか、正解率が100%となりました:-)
まとめ
データセットを用意し、チュートリアルを追っていくことで、自然言語処理と機械学習を使ったシステムについて一通りの処理を実現することが出来ました。
gensimやscikit-learnを使うことで、実装も非常に簡単に出来ました。
しかし、チュートリアルよりも複雑とはいえ、簡単なサンプルデータなので、実用的なシステムとは乖離があります。チュートリアルの最後にも、チェックすべき項目として記載があるのでこれをまとめとして引用しておきます。
最初に自分に問うべきこと
- 解こうとしている問題は何か
- データから学びたいことは何か、学んだことをどのように実行可能にするのか
- 問題を解くために使えるツールは何があるか
前処理
- コーパスはどこで見つかるか
- ウェブページからテキストを抽出するスケーラブルかつ一般化可能な方法は?
- 文書を単語に分割する方法と単語の根の抽出方法は?
- 語彙をどのように構築するのか
- どの単語を除去するのか
ベクトル表現
- どんな種類の正規化が必要か
- どの距離尺度が問題に有効か
次元削減
- 次元数をどのように選べばよいか
機械学習
テスト
- アルゴリズムがうまく動作しているかをどのように計測するか
- 正解率を見るだけで十分か
- 適合率や再現率を検討すべきか
- 同サイズのクラスなのか、それともあるクラスが支配的になっているのか、それを改善する方法は?
- 学習とテストデータは実際の使用に即したものになっているか