【チュートリアル】機械学習を使って30分で固有表現抽出器を作る

# はじめに
**固有表現抽出**は、テキストに出現する**人名**や**地名**などの固有名詞や、**日付**や**時間**などの数値表現を抽出する技術です。固有表現抽出は、**質問応答システム**、**対話システム**、**情報抽出**といった自然言語処理を用いた応用アプリケーションの要素技術としても使われています。

今回は**機械学習技術**を使って固有表現抽出器を作ってみます。

**※注意事項**
<font color="red">理論的な話は一切出てきません。理論を知りたい方は他を当たってください。</font>

# 対象読者

* 固有表現抽出を少しは知っている方
* 固有表現抽出器を作ってみたい方
* Pythonコードを読める方


# 固有表現抽出とは？
ここでは、固有表現抽出の概要と方法について説明します。

## 概要
固有表現抽出は、テキストに出現する人名や地名などの固有名詞や、日付や時間などの数値表現を抽出する技術です。具体例を見てみましょう。以下の文から固有表現を抽出してみます。

```
太郎は5月18日の朝9時に花子に会いに行った。
```
上記の文に含まれる固有表現を抽出すると**人名**として**太郎**と**花子**、**日付**として**5月18日**、**時間**として**朝9時**が抽出できます。

上記の例では固有表現のクラスとして人名、日付、時間が抽出されました。一般的には以下の8つのクラス ([Information Retrieval and Extraction Exercise](http://nlp.cs.nyu.edu/irex/index-j.html) (IREX) の[固有表現抽出タスク](http://nlp.cs.nyu.edu/irex/NE/)における定義) がよく使われます。

|クラス  |例  |
|------------|-----------------------|
|ART 固有物名　|ノーベル文学賞、Windows7  |
|LOC 地名     |アメリカ、千葉県          |
|ORG 組織     |自民党、NHK            　|
|PSN 人名     |安倍晋三、メルケル     　　|
|DAT 日付     |1月29日、2016/01/29 　　 |
|TIM 時間     |午後三時、10:30          |
|MNY 金額     |241円、8ドル           　|
|PNT 割合     |10％、3割           　　 |

## 方法
固有表現抽出を行う方法として、形態素解析済みの文に対してラベル付けを行う方法があります。以下は「太郎は5月18日の朝9時に・・・」という文を形態素解析してからラベル付けを行った例です。

<img width="954" alt="スクリーンショット 2016-01-28 14.35.17.png" src="https://qiita-image-store.s3.amazonaws.com/0/77079/1ff148a3-2372-5906-9422-d28b6b9cdf9a.png">

B-XXX、I-XXX というラベルがこれらの文字列が固有表現であることを表現しています。B-XXX は固有表現文字列の始まり、I-XXX は固有表現文字列が続いていることを意味しています。XXX 部分にはORG、PSN などの固有表現クラスが入ります。固有表現でない部分には O というラベルが付与されます。

ラベル付けは規則を用いて行うこともできますが、今回は**機械学習技術**を用いて行います。つまり、あらかじめラベル付けされた学習データからモデルを作成し、そのモデルを用いてラベルの付いていない文にラベル付けを行います。具体的にはCRFというアルゴリズムを用いて学習を行っていきます。

それでは実際に手を動かして行きましょう。


# インストール
まずは必要なPythonモジュールをインストールするところから始めます。ターミナルで以下のコマンドを実行してモジュールをインストールしてください。CRFのライブラリとしてCRFsuiteをインストールしています。

```shell-session
pip install numpy
pip install scipy
pip install sklearn
pip install python-crfsuite
```

インストールしたら必要なモジュールをimportします。以下のコードを実行してください。

```py
from itertools import chain
import pycrfsuite
import sklearn
from sklearn.metrics import classification_report
from sklearn.preprocessing import LabelBinarizer
```


# 固有表現抽出器を構築するのに使用するデータ
CRFは教師あり学習なので教師データがタグ付けされたデータが必要です。今回はあらかじめタグ付けしたデータを用意しておきました。[こちら](https://github.com/Hironsan/IOB2Corpus)からダウンロードしてください。ファイル名は「hironsan.txt」です。

それでは、まずはダウンロードしたデータを読み込むためのクラスを定義します。

```py
import codecs

class CorpusReader(object):

    def __init__(self, path):
        with codecs.open(path, encoding='utf-8') as f:
            sent = []
            sents = []
            for line in f:
                if line	== '\n':
                    sents.append(sent)
                    sent = []
                    continue
                morph_info = line.strip().split('\t')
                sent.append(morph_info)
        train_num = int(len(sents) * 0.9)
        self.__train_sents = sents[:train_num]
        self.__test_sents = sents[train_num:]

    def iob_sents(self, name):
        if name == 'train':
            return self.__train_sents
        elif name == 'test':
            return self.__test_sents
        else:
            return None
```

次に、作成したクラスを用いてダウンロードしたデータを読み込みます。学習データ数が450文、テストデータ数が50文となっています。

```py
c = CorpusReader('hironsan.txt')
train_sents = c.iob_sents('train')
test_sents = c.iob_sents('test')
```

読み込んだデータの形式は以下のようになっています。形態素解析器「MeCab」で形態素解析を行った後にIOB2タグを付けています。データは一文ごとに分かれており、一文は複数の形態素情報の集まりから構成されます。

```py
>>> train_sents[0]
[['2005', '名詞', '数', '*', '*', '*', '*', '*', 'B-DAT'],
 ['年', '名詞', '接尾', '助数詞', '*', '*', '*', '年', 'ネン', 'ネン', 'I-DAT'],
 ['7', '名詞', '数', '*', '*', '*', '*', '*', 'I-DAT'],
 ['月', '名詞', '一般', '*', '*', '*', '*', '月', 'ツキ', 'ツキ', 'I-DAT'],
 ['14', '名詞', '数', '*', '*', '*', '*', '*', 'I-DAT'],
 ['日', '名詞', '接尾', '助数詞', '*', '*', '*', '日', 'ニチ', 'ニチ', 'I-DAT'],
 ['、', '記号', '読点', '*', '*', '*', '*', '、', '、', '、', 'O'],
...
]
```
それでは次に、固有表現抽出に使う素性について説明していきます。


# 使用する素性
ここでは、使用する素性の概要を説明してから、それをコーディングしていきます。

## 概要
次に、使用する素性について説明します。今回は、前後２文字の単語、品詞細分類、文字種、固有表現タグを使います。以下にこれらの素性を用いた場合の例を示します。枠で囲まれた部分が使用する素性です。
![ner.png](https://qiita-image-store.s3.amazonaws.com/0/77079/c78cb70a-06d0-bc18-9c81-e704fbc7792a.png)

文字種の分類は以下のようになっています。全部で７種類あります。

| 文字種タグ |     説明    |
|-----------|-------------|
| ZSPACE    | 空白        |
| ZDIGIT    | アラビア数字 |
| ZLLET     | 英字小文字   |
| ZULET     | 英字大文字  |
| HIRAG     | ひらがな    |
| KATAK     | カタカナ    |
| OTHER     | その他      |

素性として用いる文字種は単語に含まれるすべての文字種を結合したものです。例えば「多い」という単語は漢字とひらがなを含んでいます。ひらがなの文字種タグはHIRAGであり、漢字の文字種タグはOTHERです。そのため、「多い」という単語の文字種は「HIRAG-OTHER」になります。

## 素性抽出のコーディング
### 文字種の判定
文字種の判定については以下のようなコードになります。文字列に含まれるすべての文字種を-(ハイフン)で結合しています。

```py
def is_hiragana(ch):
    return 0x3040 <= ord(ch) <= 0x309F

def is_katakana(ch):
    return 0x30A0 <= ord(ch) <= 0x30FF

def get_character_type(ch):
    if ch.isspace():
        return 'ZSPACE'
    elif ch.isdigit():
        return 'ZDIGIT'
    elif ch.islower():
        return 'ZLLET'
    elif ch.isupper():
        return 'ZULET'
    elif is_hiragana(ch):
        return 'HIRAG'
    elif is_katakana(ch):
        return 'KATAK'
    else:
        return 'OTHER'

def get_character_types(string):
    character_types = map(get_character_type, string)
    character_types_str = '-'.join(sorted(set(character_types)))

    return character_types_str
```

### 品詞細分類の抽出
形態素情報から品詞細分類を抽出するコードは以下のようになります。

```py
def extract_pos_with_subtype(morph):
    idx = morph.index('*')

    return '-'.join(morph[1:idx])
```

### 文からの素性抽出
以上をふまえて、各単語に対して素性抽出をするコードを書くと以下のようになります。少し冗長ですがわかると思います。

```py
def word2features(sent, i):
    word = sent[i][0]
    chtype = get_character_types(sent[i][0])
    postag = extract_pos_with_subtype(sent[i])
    features = [
        'bias',
        'word=' + word,
        'type=' + chtype,
        'postag=' + postag,
    ]
    if i >= 2:
        word2 = sent[i-2][0]
        chtype2 = get_character_types(sent[i-2][0])
        postag2 = extract_pos_with_subtype(sent[i-2])
        iobtag2 = sent[i-2][-1]
        features.extend([
            '-2:word=' + word2,
            '-2:type=' + chtype2,
            '-2:postag=' + postag2,
            '-2:iobtag=' + iobtag2,
        ])
    else:
        features.append('BOS')

    if i >= 1:
        word1 = sent[i-1][0]
        chtype1 = get_character_types(sent[i-1][0])
        postag1 = extract_pos_with_subtype(sent[i-1])
        iobtag1 = sent[i-1][-1]
        features.extend([
            '-1:word=' + word1,
            '-1:type=' + chtype1,
            '-1:postag=' + postag1,
            '-1:iobtag=' + iobtag1,
        ])
    else:
        features.append('BOS')

    if i < len(sent)-1:
        word1 = sent[i+1][0]
        chtype1 = get_character_types(sent[i+1][0])
        postag1 = extract_pos_with_subtype(sent[i+1])
        features.extend([
            '+1:word=' + word1,
            '+1:type=' + chtype1,
            '+1:postag=' + postag1,
        ])
    else:
        features.append('EOS')

    if i < len(sent)-2:
        word2 = sent[i+2][0]
        chtype2 = get_character_types(sent[i+2][0])
        postag2 = extract_pos_with_subtype(sent[i+2])
        features.extend([
            '+2:word=' + word2,
            '+2:type=' + chtype2,
            '+2:postag=' + postag2,
        ])
    else:
        features.append('EOS')

    return features


def sent2features(sent):
    return [word2features(sent, i) for i in range(len(sent))]


def sent2labels(sent):
    return [morph[-1] for morph in sent]


def sent2tokens(sent):
    return [morph[0] for morph in sent]
```

sent2featuresで文から素性を抽出します。実際に抽出される素性は以下のようになります。

```py
>>> sent2features(train_sents[0])[0]
['bias',
 'word=2005',
 'type=ZDIGIT',
 'postag=名詞-数',
 'BOS',
 'BOS',
 '+1:word=年',
 '+1:type=OTHER',
 '+1:postag=名詞-接尾-助数詞',
 '+2:word=7',
 '+2:type=ZDIGIT',
 '+2:postag=名詞-数']
```

データから素性が抽出できることがわかりました。後で使用するために、データから学習データとテストデータ用の素性とラベルを抽出しておきます。

```py
X_train = [sent2features(s) for s in train_sents]
y_train = [sent2labels(s) for s in train_sents]

X_test = [sent2features(s) for s in test_sents]
y_test = [sent2labels(s) for s in test_sents]
```

# モデルの学習
モデルを学習するために、pycrfsuite.Trainerオブジェクトを作成し、学習データを読み込ませた後、trainメソッドを呼び出します。まずは、Trainerオブジェクトの作成と学習データの読み込みを行います。

```py
trainer = pycrfsuite.Trainer(verbose=False)

for xseq, yseq in zip(X_train, y_train):
    trainer.append(xseq, yseq)
```

次に学習パラメータを設定します。本来は開発用データを用いて決めるべきですが、今回は固定値としておきます。

```py
trainer.set_params({
    'c1': 1.0,   # coefficient for L1 penalty
    'c2': 1e-3,  # coefficient for L2 penalty
    'max_iterations': 50,  # stop earlier

    # include transitions that are possible, but not observed
    'feature.possible_transitions': True
})
```

<!--

デフォルトの学習アルゴリズムに対して設定可能なパラメータは以下の通りです。

```py
>>> trainer.params()

['feature.minfreq',
 'feature.possible_states',
 'feature.possible_transitions',
 'c1',
 'c2',
 'max_iterations',
 'num_memories',
 'epsilon',
 'period',
 'delta',
 'linesearch',
 'max_linesearch']
```
-->

それでは準備が整ったのでモデルを学習させます。ファイル名を指定して、trainメソッドを実行してください。

```py
trainer.train('model.crfsuite')
```
実行が終わると、指定したファイル名のファイルが作成されます。この中に、学習したモデルが格納されています。


# テストデータの予測
学習したモデルを使用するためには、pycrfsuite.Taggerオブジェクトを作成し、学習したモデルを読み込み、tagメソッドを使用します。まずは、Taggerオブジェクトの作成と学習済みモデルの読み込みを行います。

```py
tagger = pycrfsuite.Tagger()
tagger.open('model.crfsuite')
```

それでは、実際に文に対してタグ付けをしてみます。

```py
example_sent = test_sents[0]
print(' '.join(sent2tokens(example_sent)))

print("Predicted:", ' '.join(tagger.tag(sent2features(example_sent))))
print("Correct:  ", ' '.join(sent2labels(example_sent)))
```

以下のような結果が得られるはずです。Predictedは作成したモデルを用いて予想したタグ列で、Correctは正解のタグ列です。今回の文の場合は、モデルの予想結果と正解データが一致していました。

```shell-session
昨年 10 月 に は 、 34 人 が 、 今回 の 現場 に 近い エジプト の タバ で 爆発 事件 の ため 死亡 し て いる 。
Predicted: B-DAT I-DAT I-DAT O O O O O O O O O O O O B-LOC O B-LOC O O O O O O O O O O
Correct:   B-DAT I-DAT I-DAT O O O O O O O O O O O O B-LOC O B-LOC O O O O O O O O O O
```

固有表現抽出器の構築はこれで終了しました。

# モデルの評価
モデルを作成しましたが、これだけでは良いのか悪いのかわかりません。そのため、作成したモデルについては評価を行うことが重要です。それでは作成したモデルについて評価していきましょう。評価は、適合率、再現率、F値で行います。以下が評価を行うコードです。

```py
def bio_classification_report(y_true, y_pred):
    lb = LabelBinarizer()
    y_true_combined = lb.fit_transform(list(chain.from_iterable(y_true)))
    y_pred_combined = lb.transform(list(chain.from_iterable(y_pred)))
        
    tagset = set(lb.classes_) - {'O'}
    tagset = sorted(tagset, key=lambda tag: tag.split('-', 1)[::-1])
    class_indices = {cls: idx for idx, cls in enumerate(lb.classes_)}
    
    return classification_report(
        y_true_combined,
        y_pred_combined,
        labels = [class_indices[cls] for cls in tagset],
        target_names = tagset,
    )
```

評価に使うためのテストデータ集合内の文に対してタグ付けします。

```py
y_pred = [tagger.tag(xseq) for xseq in X_test]
```

学習したモデルを用いてタグ付けしたデータと、正解データを評価用の関数に渡して結果を表示します。各カテゴリについて、適合率、再現率、F値、タグ数を表示しています。

```py
>>> print(bio_classification_report(y_test, y_pred))
             precision    recall  f1-score   support

      B-ART       1.00      0.89      0.94         9
      I-ART       0.92      1.00      0.96        12
      B-DAT       1.00      1.00      1.00        12
      I-DAT       1.00      1.00      1.00        22
      B-LOC       1.00      0.95      0.97        55
      I-LOC       0.94      0.94      0.94        17
      B-ORG       0.75      0.86      0.80        14
      I-ORG       1.00      0.90      0.95        10
      B-PSN       0.00      0.00      0.00         3
      B-TIM       1.00      0.71      0.83         7
      I-TIM       1.00      0.81      0.90        16

avg / total       0.95      0.91      0.93       177
```

ちょっと結果が良すぎる気もしますが、用いたデータに同じような文が含まれていたのでしょう。

※注意
UndefinedMetricWarningが出るかもしれません。予測したサンプルに存在しないラベルに対して適合率などが定義できないからのようです。用意できたデータ数が少ないので・・・

# おわりに
今回は、Pythonのライブラリであるcrfsuiteを使うことで簡単に固有表現抽出器を作ることができました。タグ付けにはIREXの定義に基づいて8種類の固有表現を付けました。しかし、実際に使うにはIREXの定義は粗いことが多いです。そのため、固有表現抽出を何かのタスクに使う場合には、タスクに応じて必要なタグを付けたデータを用意する必要があります。

また、より良い素性やモデルのパラメータを探してみると良いと思います。


# 参考

* [python-crfsuite Documentation](https://python-crfsuite.readthedocs.org/en/latest/)
* [Support Vector Machineを用いた日本語固有表現抽出](http://ci.nii.ac.jp/naid/110002726221)
* [日本語固有表現抽出におけるわかち書き問題の解決](http://ci.nii.ac.jp/naid/110002712193)
* [IREXによる固有表現の定義](http://nlp.cs.nyu.edu/irex/NE/df990214.txt)