FastTextを使ってサクッと多クラス分類

# はじめに
FastTextはMeta社（旧Facebook）によって開発されたオープンソースの自然言語処理ライブラリです。[livedoorニュースコーパス](https://www.rondhuit.com/download.html#ldcc)の多クラス分類を行ったところ、非常にお手軽に実装でき、かなり良い精度が得られたので記事にしました。

開発環境はGoogle Colabを使用しました。この記事のコードを動作確認したGoogle Colabのノートブックは以下です。興味がある人は是非試してみて下さい。
[ニュース記事の分類.ipynb](https://colab.research.google.com/drive/1PEVelBDLqqiOXRECT596r42RqtTV8JZ4?usp=sharing)

# データの取得
- データセットは[livedoor ニュースコーパス](https://www.rondhuit.com/download.html#ldcc)を使用しました。[こちら](https://radiology-nlp.hatenablog.com/entry/2019/11/25/124219)の記事を参考にさせていただき簡単にDataFrame化できました。


```python
# Livedoorニュースのファイルをダウンロード&解凍
! wget "https://www.rondhuit.com/download/ldcc-20140209.tar.gz"
!tar -zxvf ldcc-20140209.tar.gz

# tsvファイルの作成
!echo -e "filename\tarticle"$(for category in $(basename -a `find ./text -type d` | grep -v text | sort); do echo -n "\t"; echo -n $category; done) > ./text/livedoor.tsv
!for filename in `basename -a ./text/dokujo-tsushin/dokujo-tsushin-*`; do echo -n "$filename"; echo -ne "\t"; echo -n `sed -e '1,3d' ./text/dokujo-tsushin/$filename`; echo -e "\t1\t0\t0\t0\t0\t0\t0\t0\t0"; done >> ./text/livedoor.tsv
!for filename in `basename -a ./text/it-life-hack/it-life-hack-*`; do echo -n "$filename"; echo -ne "\t"; echo -n `sed -e '1,3d' ./text/it-life-hack/$filename`; echo -e "\t0\t1\t0\t0\t0\t0\t0\t0\t0"; done >> ./text/livedoor.tsv
!for filename in `basename -a ./text/kaden-channel/kaden-channel-*`; do echo -n "$filename"; echo -ne "\t"; echo -n `sed -e '1,3d' ./text/kaden-channel/$filename`; echo -e "\t0\t0\t1\t0\t0\t0\t0\t0\t0"; done >> ./text/livedoor.tsv
!for filename in `basename -a ./text/livedoor-homme/livedoor-homme-*`; do echo -n "$filename"; echo -ne "\t"; echo -n `sed -e '1,3d' ./text/livedoor-homme/$filename`; echo -e "\t0\t0\t0\t1\t0\t0\t0\t0\t0"; done >> ./text/livedoor.tsv
!for filename in `basename -a ./text/movie-enter/movie-enter-*`; do echo -n "$filename"; echo -ne "\t"; echo -n `sed -e '1,3d' ./text/movie-enter/$filename`; echo -e "\t0\t0\t0\t0\t1\t0\t0\t0\t0"; done >> ./text/livedoor.tsv
!for filename in `basename -a ./text/peachy/peachy-*`; do echo -n "$filename"; echo -ne "\t"; echo -n `sed -e '1,3d' ./text/peachy/$filename`; echo -e "\t0\t0\t0\t0\t0\t1\t0\t0\t0"; done >> ./text/livedoor.tsv
!for filename in `basename -a ./text/smax/smax-*`; do echo -n "$filename"; echo -ne "\t"; echo -n `sed -e '1,3d' ./text/smax/$filename`; echo -e "\t0\t0\t0\t0\t0\t0\t1\t0\t0"; done >> ./text/livedoor.tsv
!for filename in `basename -a ./text/sports-watch/sports-watch-*`; do echo -n "$filename"; echo -ne "\t"; echo -n `sed -e '1,3d' ./text/sports-watch/$filename`; echo -e "\t0\t0\t0\t0\t0\t0\t0\t1\t0"; done >> ./text/livedoor.tsv
!for filename in `basename -a ./text/topic-news/topic-news-*`; do echo -n "$filename"; echo -ne "\t"; echo -n `sed -e '1,3d' ./text/topic-news/$filename`; echo -e "\t0\t0\t0\t0\t0\t0\t0\t0\t1"; done >> ./text/livedoor.tsv
```
- 作成したtsvファイルをpandasで読み込みます。下記画像のように、クリーニングした記事の内容とニュースカテゴリのone-hot表現を持ったDataFrameが作成できました。

```python
import pandas as pd 
df = pd.read_csv('text/livedoor.tsv', sep='\t')
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/587560/d545c7a8-d77b-d08b-3471-a2431220e3d9.png)

# クリーニング
- 形態素解析器としてMeCabを用います。以下記事を参考にクリーニングクラスを定義しています。
[Mecab+neologd辞書を用いる前に推奨される正規化](https://github.com/neologd/mecab-ipadic-neologd/wiki/Regexp.ja)

```python
import re, unicodedata

class CleaningData:
    def __init__(self, df, target_column):
        self.df = df
        self.target_column = target_column

    def cleaning(self):
        self.df[self.target_column] = self.df[self.target_column].map(self.remove_extra_spaces)
        self.df[self.target_column] = self.df[self.target_column].map(self.normalize_neologd)

        # クリーニングの過程でtextが空になった行を削除
        self.df = self.df[self.df[self.target_column] != '']
        self.df = self.df[self.df[self.target_column] != '']
        self.df = self.df.reset_index()
        return self.df

    def unicode_normalize(self, cls, s):
        pt = re.compile('([{}]+)'.format(cls))

        def norm(c):
            return unicodedata.normalize('NFKC', c) if pt.match(c) else c

        s = ''.join(norm(x) for x in re.split(pt, s))
        s = re.sub('－', '-', s)
        return s

    def remove_extra_spaces(self, s):
        s = re.sub('[ 　]+', ' ', s)
        blocks = ''.join(('\u4E00-\u9FFF',  # CJK UNIFIED IDEOGRAPHS
                          '\u3040-\u309F',  # HIRAGANA
                          '\u30A0-\u30FF',  # KATAKANA
                          '\u3000-\u303F',  # CJK SYMBOLS AND PUNCTUATION
                          '\uFF00-\uFFEF'   # HALFWIDTH AND FULLWIDTH FORMS
                          ))
        basic_latin = '\u0000-\u007F'

        def remove_space_between(cls1, cls2, s):
            p = re.compile('([{}]) ([{}])'.format(cls1, cls2))
            while p.search(s):
                s = p.sub(r'\1\2', s)
            return s

        s = remove_space_between(blocks, blocks, s)
        s = remove_space_between(blocks, basic_latin, s)
        s = remove_space_between(basic_latin, blocks, s)
        return s

    def normalize_neologd(self, s):
        s = s.strip()
        s = self.unicode_normalize('０-９Ａ-Ｚａ-ｚ｡-ﾟ', s)

        def maketrans(f, t):
            return {ord(x): ord(y) for x, y in zip(f, t)}

        s = re.sub('[˗֊‐‑‒–⁃⁻₋−]+', '-', s)  # normalize hyphens
        s = re.sub('[﹣－ｰ—―─━ー]+', 'ー', s)  # normalize choonpus
        s = re.sub('[~∼∾〜〰～]', '', s)  # remove tildes
        s = s.translate(
            maketrans('!"#$%&\'()*+,-./:;<=>?@[¥]^_`{|}~｡､･｢｣',
                  '！”＃＄％＆’（）＊＋，－．／：；＜＝＞？＠［￥］＾＿｀｛｜｝〜。、・「」'))

        s = self.remove_extra_spaces(s)
        s = self.unicode_normalize('！”＃＄％＆’（）＊＋，－．／：；＜＞？＠［￥］＾＿｀｛｜｝〜', s)  # keep ＝,・,「,」
        s = re.sub('[’]', '\'', s)
        s = re.sub('[”]', '"', s)
        return s

    def remove_symbols(self, text):
        text = re.sub(r'[◎, 〇, △, ▲, ×, ◇, □]', '', text)
        return text
```
- クリーニングの実行

```python
cd = CleaningData(df, 'article')
df = cd.cleaning()
```

# 形態素解析
- 形態素分析ライブラリーMeCab と 辞書(mecab-ipadic-NEologd)のインストール 

```python
!apt-get -q -y install sudo file mecab libmecab-dev mecab-ipadic-utf8 git curl python-mecab
!git clone --depth 1 https://github.com/neologd/mecab-ipadic-neologd.git
!echo yes | mecab-ipadic-neologd/bin/install-mecab-ipadic-neologd -n
!pip install mecab-python3
!ln -s /etc/mecabrc /usr/local/etc/mecabrc
```

- 形態素解析クラスの作成。今回は学習に名詞・形容詞・動詞のみ用います。

```python
import MeCab

class Wakati:
    """ 形態素解析クラス """
    # クラス変数
    MECAB_PATH = "-d /usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd"

    def __init__(self, df, target_column):
        self.df = df
        self.target_column = target_column

    def wakati_document(self):
        self.df[self.target_column] = self.df[self.target_column].map(self.wakati_sentence)
        return self.df

    def wakati_sentence(self, text):
        tagger = MeCab.Tagger(Wakati.MECAB_PATH)
        words = []
        for c in tagger.parse(text).splitlines()[:-1]:
            #surfaceに単語、featureに解析結果が入る
            try:
                surface, feature = c.split('\t')
            except:
                continue
            pos = feature.split(',')[0]
            surface = feature.split(',')[6] # 原型に直す
            if pos in ['名詞','形容詞','動詞']:
                words.append(surface)
            else:
                continue
        return ' '.join(words)
```

- 形態素解析の実行

```python
w = Wakati(df, 'article')
df = w.wakati_document()
```

# 正解ラベルの付与
- Fasttextの学習用のテキストファイルを作成します。
- テキストファイルの各行には、ラベルのリストと、対応する文書を含みます。すべてのラベルは 
 \__label__という接頭辞で始まります。

```python
label_dict = {
    'dokujo-tsushin': 0, 
    'it-life-hack': 1,
    'kaden-channel': 2, 
    'livedoor-homme': 3, 
    'movie-enter': 4, 
    'peachy': 5, 
    'smax': 6,
    'sports-watch': 7, 
    'topic-news': 8}

for category, index in label_dict.items():
    df.loc[df[category]==1, 'article'] = f'__label__{index} ' + df.loc[df[category]==1, 'article']
```

- 記事本文の先頭にラベルを振ることができました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/587560/b233f40a-7515-0063-da04-14cdb9a7db19.png)

# データセットの分割
- scikit-learnのtrain_test_split()を用いて訓練データ、テストデータ、検証データに分割します。

```python
from sklearn.model_selection import train_test_split
X_train, X_test = train_test_split(df['article'], test_size=0.2, random_state=100)
X_valid, X_test = train_test_split(X_test, test_size=0.5, random_state=100)
```

- 作成したデータセットをテキストファイルに書き込みます。ここまでで学習の準備は完了です。

```python
with open('drive/My Drive/Colab Notebooks/livedoor_fasttext_train.txt', 'w') as temp_file:
    for text in X_train:
        temp_file.write(f"{text}\n")

with open('drive/My Drive/Colab Notebooks/livedoor_fasttext_valid.txt', 'w') as temp_file:
    for text in X_valid:
        temp_file.write(f"{text}\n")

with open('drive/My Drive/Colab Notebooks/livedoor_fasttext_test.txt', 'w') as temp_file:
    for text in X_test:
        temp_file.write(f"{text}\n")
```

# 学習
- FastTextライブラリのインストール

```python
!pip install fasttext
```

- 訓練データと検証データを渡すと、よしなに学習してくれます。各パラメータ等詳しく知りたい方は[公式](https://fasttext.cc/docs/en/supervised-tutorial.html)を参照してみてください。今回は**autotuneDuration**パラメータを600[s]に設定しているので、学習は10分で終わります。

```python
import fasttext

model = fasttext.train_supervised(input='drive/My Drive/Colab Notebooks/livedoor_fasttext_train.txt', autotuneValidationFile='drive/My Drive/Colab Notebooks/livedoor_fasttext_valid.txt',autotuneDuration=600,epoch=20,wordNgrams=2)
```

# 結果
- 実際にテストデータを与えて精度を確認した結果がこちら

```python
ret = model.test('drive/My Drive/Colab Notebooks/livedoor_fasttext_test.txt')
print(ret)

> (737, 0.9430122116689281, 0.9430122116689281)
```

なんと正解率94.3%で9クラス分類ができました。FastText恐るべし、、、
最後に、t-SNEを用いて可視化してみました

```python
!pip install japanize_matplotlib
%matplotlib inline

import matplotlib.pyplot as plt
import japanize_matplotlib, re
import seaborn as sns
import pandas as pd
from sklearn.manifold import TSNE

sns.set(font="IPAexGothic")

def t_SNE(df, label_dict):
    df['category'] = df['article'].map(lambda x: re.sub(r'\D', '', x.split(' ')[0]))
    df['article'] = df['article'].map(lambda x: ' '.join(x.split(' ')[1:]))
    df = df[['category','article']]
    # 分散表現の獲得
    vectors = []
    for split_word in df['article']:
      vectors.append(model.get_sentence_vector(split_word))

    #t-SNEで次元削減
    tsne = TSNE(n_components=2, random_state = 0, perplexity = 30, n_iter = 1000)
    vectors_embedded = tsne.fit_transform(vectors)

    ddf = pd.concat([df, pd.DataFrame(vectors_embedded, columns = ['col1', 'col2'])], axis = 1)

    label_list = list(label_dict.keys())

    colors =  ["r", "g", "b", "c", "m", "y", "k", "orange","pink"]
    plt.figure(figsize = (30, 30))
    for i , v in enumerate(label_list):
        tmp_df = ddf[ddf["category"] == str(i)]
        plt.scatter(tmp_df['col1'],  
                    tmp_df['col2'],
                    label = v,
                    color = colors[i])

    plt.legend(fontsize = 30)
    plt.savefig('livedoor_classification.png')
```

```python
t_SNE(df, label_dict)
```

- t-SNEで可視化した結果がこちら。すごく綺麗に分類できています！
![Untitled (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/587560/d7f7cf0d-9760-61b8-803a-f3e784de493c.png)

# まとめ
今回、ニュース記事の9クラス分類を行ったところ、正解率は94.3%でした。基本的な前処理やクリーニングを加えただけでここまでの精度が出るのは凄いです（笑）

今後、他のモデルについてもまとめられたいいなと思っています。
最後まで読んでいただきありがとうございました！

# 参考記事
- [Automatic hyperparameter optimization · fastText](https://fasttext.cc/docs/en/autotune.html)
- [t-SNEを理解して可視化力を高める](https://qiita.com/g-k/items/120f1cf85ff2ceae4aba)
- [Livedoorニュースコーパスを文書分類にすぐ使えるように整形する](https://radiology-nlp.hatenablog.com/entry/2019/11/25/124219)