nikkie-ftnextの日記

イベントレポートや読書メモを発信

『入門 自然言語処理』6章に取り組み、NLTKだけでも機械学習の分類問題にアプローチできることを知りました

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。
2019年12月末から自然言語処理のネタで毎週1本ブログを書いています。

2/3の週から自然言語処理の基礎固めとして『入門 自然言語処理』に取り組んでいます。

入門 自然言語処理

入門 自然言語処理

今週は、6章「テキスト分類の学習」に取り組みました。

6章は以下で公開されています:

目次

動作環境

先週までと同じ環境を引き続き使っていきます(macOSのアップデートによりBuildVersionが変わりました)。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G3020
$ python -V  # venvによる仮想環境を使用
Python 3.7.3
$ pip list  # grepを使って抜粋して表示
beautifulsoup4   4.8.2
ipython          7.12.0
matplotlib       3.1.3
nltk             3.4.5
wordcloud        1.6.0

自然言語処理機械学習

自然言語処理の問題設定には、機械学習における分類1として扱えるものがあります。
例えば、

  • 名前が与えられた時に、男性か女性か分類する
  • 映画のレビューを肯定的か否定的か分類する
  • 品詞タグ付け(トークンが与えられた時に、品詞(名詞、動詞など)を分類する)
  • 文の分割(セグメンテーション) 句読点を文が終了するかどうかで分類する

などです。

NLTKと機械学習

分類の問題設定に使える実装は、NLTKのnltk.classifyパッケージに用意されています。
分類器(classifier)のクラスに用意されたtrainメソッドを使って、データを学習させた分類器を作成します。

分類器のクラスは、次の5つが定義されています2(太字のものが6章で言及されています)。

  • ConditionalExponentialClassifier
  • DecisionTreeClassifier
  • MaxentClassifier
  • NaiveBayesClassifier
  • WekaClassifier

NLTKで分類に取り組む際の流れ

『入門 自然言語処理』では feature という語を素性と訳します。
訳注にありましたが、featureは特徴量とも訳される語だそうです(p.241)。
私には特徴量という呼び方のほうが馴染みがあるので、素性は特徴量に読み替えました。

分類に取り組む時、ゴールは分類器の作成です。

  1. 入力に素性(特徴量)抽出器(feature extractor)を適用して特徴量を取り出す
    • 分類に関連する特徴量を決定
    • 特徴量の符号化方法を決定
  2. 特徴量とラベルから分類器を作成

1と2を1回行っただけで高性能な分類器が作られることはまれです。
分類器の誤りをもとに、手順1から再度取り組むこと(エラー分析)が有益だと知りました。

エラー分析を行うためにはデータの分割がポイントになります3

  • 開発セット
    • 訓練セット 分類器の学習に使う
    • 検証セット エラー分析に使う
  • テストセット 分類器が知らないデータで性能を評価する

データの分割について

開発セットとテストセットを分けることにはトレードオフがあります。

  • 開発セットのデータが少なければ、十分に学習した分類器はできません
  • テストセットのデータが少なければ、分類器の汎用的な性能は分かりません

6.3には、ラベル付けされた大量のデータが利用可能な場合は、全データの10%をテストデータとして使うのが一般的とありました(p.257)。

また、ある文書から開発セットにもテストセットにもデータを分けるべきではありません4
開発セットに使う文書とテストセットに使う文書というように文書単位で分けます(同様に、開発セットの中で、訓練セットに使う文書と検証セットに使う文書も分けます)。

🙆OKな例(開発セットとテストセットで文書を分ける5

In [3]: from nltk.corpus import brown

In [4]: file_ids = brown.fileids(categories='news')

In [5]: len(file_ids)
Out[5]: 44

In [6]: type(file_ids)
Out[6]: list

In [7]: file_ids[:3]
Out[7]: ['ca01', 'ca02', 'ca03']

In [8]: size = int(len(file_ids) * 0.1)

In [9]: size
Out[9]: 4

In [10]: train_set = brown.tagged_sents(file_ids[size:])

In [11]: test_set = brown.tagged_sents(file_ids[:size])

In [12]: len(train_set)
Out[12]: 4227

In [13]: len(test_set)
Out[13]: 396

file_idsをshuffleしてもよさそうですね。

🙅NGな例(tagged_sentsを使ったことでリーケージの懸念あり)

In [1]: import random

In [2]: random.seed(42)

In [14]: tagged_sents = list(brown.tagged_sents(categories='news'))  # NGなので真似しないでください

In [15]: random.shuffle(tagged_sents)

In [16]: size = int(len(tagged_sents) * 0.1)

In [17]: size
Out[17]: 462

In [18]: train_set, test_set = tagged_sents[size:], tagged_sents[:size]

In [19]: len(train_set)
Out[19]: 4161

In [20]: len(test_set)
Out[20]: 462

名前が男性か女性かの分類に取り組む

それでは、例題として名前が男性か女性かの分類に取り組んでみます。
アルファベットの名前が与えられた時に、男性の名前か女性の名前かを分類します。

  • namesコーパスを用います
  • 名前の最後の1文字を特徴にします
  • NaiveBayesClassifierを扱います(後ほどDecisionTreeClassifier、MaxentClassifierも試します)
  • 書籍に沿ってエラー分析を実施し、特徴量抽出器を更新します

namesコーパス

In [21]: from nltk.corpus import names

In [29]: print(names.readme())
Names Corpus, Version 1.3 (1994-03-29)
Copyright (C) 1991 Mark Kantrowitz
Additions by Bill Ross

This corpus contains 5001 female names and 2943 male names, sorted
alphabetically, one per line.

# 省略

Mark Kantrowitz <mkant+@cs.cmu.edu>
http://www-2.cs.cmu.edu/afs/cs/project/ai-repository/ai/areas/nlp/corpora/names/

In [30]: names = [(name, 'male') for name in names.words('male.txt')] + [(name, 'female') for name in names.words('female.txt')]

In [31]: len(names)
Out[31]: 7944

特徴量抽出 & データの分割

In [32]: random.shuffle(names)

In [33]: def gender_features(word):  # 最後の1文字を取り出す
    ...:     return {'last_letter': word[-1]}
    ...:

In [35]: names[:3]
Out[35]: [('Raye', 'female'), ('Marita', 'female'), ('Fey', 'female')]

In [51]: devtest_names = names[500:1500]  # 1000件

In [52]: test_names = names[:500]  # 500件

In [54]: train_names = names[1500:]

In [55]: len(train_names)
Out[55]: 6444

In [57]: train_set = [(gender_features(n), g) for n, g in train_names]

In [58]: devtest_set = [(gender_features(n), g) for n, g in devtest_names]

In [59]: test_set = [(gender_features(n), g) for n, g in test_names]

分類器作成(NaiveBayesClassifier)

nltk.classify.naivebayes.NaiveBayesClassifierから分類器を作ります(仕組みについては6.5で説明されています)。

In [26]: import nltk

In [60]: classifier = nltk.NaiveBayesClassifier.train(train_set)

In [61]: nltk.classify.accuracy(classifier, devtest_set)
Out[61]: 0.766

検証セットにおける正解率は76.6%でした。

分類に有益な特徴量の上位も確認できます。

In [62]: classifier.show_most_informative_features(5)
Most Informative Features
             last_letter = 'a'            female : male   =     31.4 : 1.0
             last_letter = 'f'              male : female =     26.9 : 1.0
             last_letter = 'k'              male : female =     26.8 : 1.0
             last_letter = 'p'              male : female =     11.3 : 1.0
             last_letter = 'd'              male : female =      9.8 : 1.0

エラー分析

In [63]: errors = []

In [65]: for name, tag in devtest_names:
    ...:     guess = classifier.classify(gender_features(name))
    ...:     if guess != tag:
    ...:         errors.append((tag, guess, name))
    ...:

In [66]: len(errors)
Out[66]: 234

In [67]: errors[:5]
Out[67]:
[('female', 'male', 'Marlo'),
 ('female', 'male', 'Hildagard'),
 ('female', 'male', 'Melicent'),
 ('female', 'male', 'Moll'),
 ('male', 'female', 'Georgie')]

エラー分析でどう間違えたかを確認するためnames(名前と教師ラベルの組)を訓練、検証、テストに分けています。
たしかに、この出力を見れば工夫できそうですね。

エラーから分かることの一例:
分類器はhで終わる名前をfemaleと分類するが、chで終わる名前はmaleに分類してほしい

そこで、最後の2文字も特徴量として抽出します。

In [68]: def gender_features(word):
    ...:     return {'suffix1': word[-1:],
    ...:             'suffix2': word[-2:]}
    ...:

In [69]: train_set = [(gender_features(n), g) for n, g in train_names]

In [70]: devtest_set = [(gender_features(n), g) for n, g in devtest_names]

In [72]: classifier = nltk.NaiveBayesClassifier.train(train_set)

In [73]: nltk.classify.accuracy(classifier, devtest_set)
Out[73]: 0.788

In [75]: classifier.show_most_informative_features(5)
Most Informative Features
                 suffix2 = 'na'           female : male   =     87.6 : 1.0
                 suffix2 = 'la'           female : male   =     62.8 : 1.0
                 suffix2 = 'ta'           female : male   =     37.4 : 1.0
                 suffix2 = 'rd'             male : female =     35.3 : 1.0
                 suffix2 = 'ia'           female : male   =     33.7 : 1.0

正解率が78.8%に増加しました。
エラー分析はさらに繰り返せそうです。

なお、エラー分析を繰り返すたびに、訓練セットと検証セットを分け直したほうがいいそうです。
理由は検証セットのデータの偏りを分類器に反映させないためです。

別の分類器を試す

1.決定木(DecisionTreeClassifier)

nltk.classify.decisiontree.DecisionTreeClassifierを試します。

In [76]: classifier = nltk.DecisionTreeClassifier.train(train_set)

In [77]: nltk.classify.accuracy(classifier, devtest_set)
Out[77]: 0.782

決定木は6.4で説明されています。
決定木はフローチャートであり、解釈しやすい場合が多いという特徴があるそうです。

pseudocodeメソッドでフローチャートを文字で確認できます。

In [80]: print(classifier.pseudocode(depth=2))
if suffix2 == 'Ag': return 'female'
if suffix2 == 'Al': return 'male'
if suffix2 == 'Bo': return 'male'
if suffix2 == 'Cy': return 'male'
if suffix2 == 'Di': return 'female'
# 省略

決定木は決定株(1つの特徴量に基づき、分岐を1つだけ持つ決定木)を選ぶ6ことで作られます。
根となる決定株を選び、葉の正解率を調べ、十分な正解率でない場合は葉を決定株で置き換えて、決定木を育てていきます。

解釈しやすいという利点がある決定木ですが、欠点もあります。

  • 特徴量が比較的独立したものである場合であっても、特定の順番で調べることを強制する
  • ラベル付けに関する関与の小さな特徴量を扱うのが得意でない

決定木はフローチャートにせざるを得ないので、特徴量を特定の順番でチェックすることになるのだと理解しました。

なお、単純ベイズ分類器(NaiveBayesClassifier)は、全ての特徴量を「並列に」扱うことで、決定木の問題を克服しているそうです。

2. 最大エントロピー分類器(MaxentClassifier)

nltk.classify.maxent.MaxentClassifierを試します。

In [81]: classifier = nltk.MaxentClassifier.train(train_set)
  ==> Training (100 iterations)

      Iteration    Log Likelihood    Accuracy
      ---------------------------------------

             1          -0.69315        0.367
             2          -0.34214        0.792
      # 省略
            99          -0.30114        0.805
         Final          -0.30113        0.805

In [82]: nltk.classify.accuracy(classifier, devtest_set)
Out[82]: 0.788

最大エントロピー分類器は6.6で説明されています。

  • 単純ベイズ分類器は、モデルのパラメタとして確率を使用
  • 最大エントロピー分類器は、分類器の性能(≒全体尤度)を最大化するパラメタのセットを探す

※理論部分は今回は読めておらず、宿題事項です

試したいこと

  • LazyMapを作るnltk.classify.util.apply_features(メモリ消費を抑える)
  • 名前の判定以外の例:映画レビュー
  • 品詞タグ付け
    • 文脈を利用する
    • 同時分類器の利用(系列分類器)
  • アルゴリズムの部分は『見て試してわかる機械学習アルゴリズムの仕組み 機械学習図鑑』で分かるところから補強
  • 訓練セットと検証セットに交差検証を適用したい(nltkにある?sklearnから使う?)
  • 「recommended machine learning packages that are supported by NLTK」があるとのことなのですが、どんなパッケージがあるのだろう(NLTKのサイトに見つけること能わず)

感想

「NLTKは高機能!」この一言につきます。
これまではステミングやタグ付けなどの自然言語の取り扱い方や、コーパスを使っての集計方法を学んできました。
それだけではなく、scikit-learnにあるような分類器も実装されていたとは!
Web開発におけるDjangoのような"電池同梱"っぷりですね。
書籍で紹介されていた交差検証までNLTKでできるのか、それともscikit-learnに任せたほうがいいのか、NLTKにおける機械学習の限界が気になります。

これまで『入門 自然言語処理』で自然言語処理の基本を見てきました。
「実務のあの部分はもっとうまくできたな」という発見がいくつもありました。

さて、この本が書かれた時点(2009)と現在とでは、自然言語処理を取り巻く状況は異なります。
10年前と比べて機械学習の発展はめざましく、この本には載っていない深層学習を使った文書分類は、各フレームワークチュートリアルにも見られます。
そこで次回は機械学習いまへのキャッチアップを目指し、ついにBERTを触る予定です。
最近出て話題の"あの本"が候補です。


  1. 教師あり学習の分類です(離散値を予測する問題設定のことです)

  2. NLTK HOWTOの Classifiers より

  3. 機械学習のデータの分け方の話と同じです。機械学習で作りたいのは汎用的なモデルです。なので、学習に使っていないデータについてどれだけ正しく分類できるかが、モデルを作成する中で最終的に知りたいことになります

  4. 自然言語処理におけるリーケージとして理解しました

  5. NLTKのコーパスtagged_sentsメソッドは、第1引数にファイルIDを指定できます(これまではファイルIDを指定せずに使ってきました)

  6. 選ぶ方法で一般的な方法が情報利得だそうです(積ん読部分)