一番簡単なマルチラベル文書分類の方法やってみた

## 文書分類とは
NLPのタスクの中に文書分類というものがあります。
これは、ラベル付けされた文書に対し、そのラベルを推定するものです。

文書分類は文書に付けられたラベルの性質によって以下の２つに大きく分けられます。

- トピック分類 (topic classification)
    - 文書にそのトピックについてのラベリングがされたもの
    - ニュース記事に政治・スポーツ・エンタメなどのラベル付けされたものをよくみる
    - 2分類のものもあるし、マルチラベルのものもある（マルチラベルの方が多そう）
    - ニュース記事のリコメンドなどに応用される
    
- 感情分析 (sentiment analysis)
    - その文書が肯定的であるか否定的であるかについてのラベリングされたもの
    - 2値分類もあるし、それ以上の数に分類されたりもする（positive, neutral, negativeの3ラベルなど）
    - マーケティングリサーチとかに使われたりもする


## 文書分類のモデル
これらの文書分類問題を解こうとするとき、その方法もたくさんあります。
代表的な方法として次の２つがあります。（ほかにもあると思いますが）

- 文書ベクトルをつくり、それを機械学習手法により分類する
    - 文書ベクトルの作り方
        - Tf-idf
        - bag of embedding (文書内の各単語の分散表現について平均または最大をとる）
    - 分類の仕方
        - Logistic Regression
        - Naive Baise model
        - Support Vector Machine
        - Random Forest, Xgboost
        - などいろいろ
       
- row textをニューラルネットワークにぶちこむ
    - LSTM
    - BERT fine tuning
    - などいろいろ
    
## 一番簡単な方法をやってみた
一番簡単なやつやったとは言っても、どれが一番簡単なのかというのは実際よくわからないです（おい）。今回はTf-idfベクトルをSVM (with linear karnel) するという方法に取り組んでみたいと思います。
Tf-idfとは、文書内での各単語の出現頻度にその単語の重要度をかけたものを要素に持つようなベクトルです。したがって、文書ベクトルの次元は語彙数と等しくなります。

SVM with linear karnelはちょっと説明が大変そうなので割愛します。

今回はsklearnの中に入っているものを使わせていただきます。

モデルが簡単な分(?)、コーパスをちょっと複雑なもの（マルチラベル＋各文書にいくつかのトピックが付与されている）を使ってみようと思います。
使ったコーパスはロイターのニュース記事で、約10000文書で90ラベルあるものです。

## まずコーパスのダウンロード

コーパスをダウンロードします。pythonのモジュールnltkにロイターコーパスが入っているのでそれを使います。

まず、nltkが入っていない場合は

```
pip install nltk
```

次に、pythonのインタラクティブシェルで次のように打ち込みます

```
python
>>> import nltk
>>> nltk.download("reuters")
```

すると、ユーザディレクトリ下くらいに nltk_data というディレクトリができていて、そのなかにデータが
入っています。

## 実装コード

```python

import glob
import nltk
import re
import codecs
from sklearn.feature_extraction.text import TfidfVectorizer
from nltk import word_tokenize
from nltk.stem.porter import PorterStemmer
from nltk.corpus import stopwords, reuters
from sklearn.preprocessing import MultiLabelBinarizer
from sklearn.svm import SVC
from sklearn.multiclass import OneVsRestClassifier
from sklearn import metrics

path = "../nltk_data/corpora/reuters/"
with open(path+"stopwords") as sw:
    stopwords = [x for x in sw]

#トークナイザを定義
def tokenize(text):
  min_length = 3
  words = map(lambda word: word.lower(), word_tokenize(text))
  words = [word for word in words if word not in stopwords]
  tokens = (list(map(lambda token: PorterStemmer().stem(token), words)))
  p = re.compile('[a-zA-Z]+');
  filtered_tokens = list(filter (lambda token: p.match(token) and len(token) >= min_length, tokens))
  return filtered_tokens
    
#ドキュメントidとそのカテゴリをデータから取得
with codecs.open("../nltk_data/corpora/reuters/cats.txt", "r", "utf-8", "ignore") as categories:
    train_docs_id = [line.split(" ")[0][9:] for line in categories if line.split(" ")[0][:9] == 'training/']
    categories.seek(0)
    test_docs_id = [line.split(" ")[0][5:] for line in categories if line.split(" ")[0][:5] == 'test/']
    categories.seek(0)
    train_docs_cat = [line.strip("\n").split(" ")[1:] for line in categories if line.split(" ")[0][:9] == 'training/']
    categories.seek(0)
    test_docs_cat = [line.strip("\n").split(" ")[1:] for line in categories if line.split(" ")[0][:5] == 'test/']

# 文書をリストにする
train_docs = []
test_docs = []
for num in train_docs_id:
    with codecs.open(path+"training/"+num, "r", "utf-8", "ignore") as doc:
        train_docs.append(" ".join([line.strip(" ") for line in doc.read().split("\n")]))
for num in test_docs_id:
    with codecs.open(path+"test/"+num, "r", "utf-8", "ignore") as doc:
        test_docs.append(" ".join([line.strip(" ") for line in doc.read().split("\n")]))

# 文書リストから sklearn.TfidfVectorizer で文書ベクトルを生成
vectorizer = TfidfVectorizer(tokenizer=tokenize)
vectorised_train_documents = vectorizer.fit_transform(train_docs)
vectorised_test_documents = vectorizer.transform(test_docs)

# ラベルを2値 (0 or 1) のベクトルに変換
# Transform multilabel labels
mlb = MultiLabelBinarizer()
train_labels = mlb.fit_transform(train_docs_cat)
test_labels = mlb.transform(test_docs_cat)

# Classifier
# パラメータをいろいろ変えて試す
param_list = [0.001, 0.01, 0.1, 0.5, 1, 10, 100]
for C in param_list:
    classifier = OneVsRestClassifier(LinearSVC(C=C, random_state=42))
    classifier.fit(vectorised_train_documents, train_labels)
    predictions = classifier.predict(vectorised_test_documents)
    train_predictions = classifier.predict(vectorised_train_documents)
    ftest = metrics.f1_score(test_labels, predictions, average="macro")
    ftrain = metrics.f1_score(train_labels, train_predictions, average="macro")
    print("parameter       test_f1                 train_f1")
    print("c={}:\t{}\t{}".format(C, ftest, ftrain))  

```

上のコードを実行すると次の結果がでます。

```
parameter       test_f1                 train_f1
c=0.001:	0.009727246626471432	0.007884179312750742
c=0.01:	0.02568945815128711	0.02531440097069285
c=0.1:	0.20504347026711428	0.26430270726815386
c=0.5:	0.3908058642922242	0.6699048987962078
c=1:	0.45945765878179573	0.9605946547451458
c=10:	0.5253686991407462	0.9946632502765812
c=100:	0.5312185383446876	0.9949908225328556
```

思いっきり過学習していますね。下の論文による同じ方法で80%後半の精度がでるはずなんだけど..。
https://www.aclweb.org/anthology/N19-1408/

だめな理由がわかる方いらっしゃったらぜひ教えてください。