HappyDBで『幸せな瞬間』を文書分類してみる

突然ですが…__この１日の間で、幸せだと感じた瞬間ってありますか？__

日本語で「幸せ」と言うとやや仰々しい感じがするので、
__この１日の間で、良かったことってありますか？__
という質問と捉えてもらってよいと思います。

この質問に対する回答を集めたデータベースが「HappyDB」です。
その数なんと、100,000個、です！

HappyDBには、幸せな瞬間を7つのカテゴリに分類したアノテーションが付与されているのですが、本記事は、__HappyDBを題材に文書分類を試してみた__、という内容になります。

本記事の内容が何らかの形で参考になりましたら幸いです。

# 対象読者
- 自然言語処理の初学者
- 文書分類に興味がある方
- HappyDB（幸せな瞬間データベース）に興味がある方

# HappyDB とは？
HappyDBは、リクルートのAI研究所 [Megagon Labs](https://www.megagon.ai/jp/)が2017年に作成した、「幸せな瞬間」（"Happy Moment"）の記述文を集めたデータセットです。言語は英語です。

クラウドソーシングサービスAmazon Mechanical Turkを使用して、10,843名の人に、24時間／3ヶ月の間にあった「幸せな瞬間」を記述してもらったもので、合計100,922個にも及ぶ「幸せな瞬間」の記述文が含まれています。

```txt:記述文の例
My son gave me a big hug in the morning when I woke him up.
（今朝、息子を起こすと、ギューっとハグをしてくれた。）

I finally managed to make 40 pushups.
（ついに40回の腕立て伏せが出来るようになった。）

I had dinner with my husband.
（夫とディナーを食べたこと。）

Morning started with the chirping of birds and the pleasant sun rays.
（鳥のさえずりと心地よい日の光で朝が始まりまったこと。）
```

["HappyDB: A Corpus of 100,000 Crowdsourced Happy Moments" (Asai, et al., 2018)](https://arxiv.org/pdf/1801.07746.pdf)として、論文としても発表されています。コーパスとしての特徴や、記述文のトピックに関する分析なども記載されていますので、ご興味ある方はご参照ください。

また、手前味噌ではありますが、こちらの[noteの記事](https://note.com/izaki_shin/n/n35eb2e0558f1)では、本記事よりはもう少し詳しい内容を説明しています。よろしければご覧ください。

# 文書分類してみる
HappyDB内の幸せな瞬間の記述文を、7カテゴリ（後述）に分類します。
原著論文の中で、Bag of Words（BoW）とロジスティック回帰で、5分割の交差検証を実施した結果が載っています。

![スクリーンショット 2022-05-11 23.39.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2612472/20da4390-058a-9250-a142-bd3fd47dfbf8.png)


以下では、こちらを追試してみます。
また、それ以外にも、BoWの出現頻度に関するパラメータの影響や、BoWではなくTF-IDFを使用した場合の影響についても見ていきたいと思います。

なお、コードはGoogle Colaboratory上で動作させており、こちら↓に格納しています。

http://colab.research.google.com/github/izaki-shin/notebooks/blob/master/nlp/nlp_classification_happydb_en.ipynb

## データの読み込み
[GitHub - megagonlabs/HappyDB](https://github.com/megagonlabs/HappyDB) よりダウンロードします。
ここでは、メインのデータが含まれている`cleaned_hm.csv`のみをダウンロードします。
```sh
! wget https://github.com/megagonlabs/HappyDB/raw/master/happydb/data/cleaned_hm.csv
```

pandasで読み込んでいきます。
```python:データ読み込み
import pandas as pd
df_hm = pd.read_csv('cleaned_hm.csv')
df_hm.head(3)
```
![df_hm-head.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2612472/172d27cb-8291-44c1-38b1-a059cd9aff07.png)

必要な情報は、以下の２列です。
- `cleaned_hm`：記述文（誤字脱字を修正したもの）
- `ground_truth_category`：幸せな瞬間の7カテゴリのアノテーション

また、7カテゴリは、こんな風になっています。
- Achievement（達成）
- Affection（家族やペットとの愛）
- Bonding（人との繋がり）
- Enjoy the moment（楽しい時間）
- Excercise（運動、エクササイズ）
- Leisure（趣味、娯楽）
- Nature（自然を感じる）

アノテーションされているサンプルは15,000程度なのですが、7カテゴリの分布も見てみます。描画結果を見ると、だいぶ偏りがあることがわかります。
```python:7カテゴリの分布描画
import seaborn as sns

# ラベル無しデータは除外
df_hm_target = df_hm.dropna(subset=['ground_truth_category'])
# ラベルの分布を描画
LABELS_ALL = sorted(df_hm_target.ground_truth_category.unique())
sns.countplot(y='ground_truth_category', data=df_hm, order=LABELS_ALL)
```
![categories_dist.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2612472/532dc314-d767-5a08-fed9-cafe514d4e5b.png)

## BoWで文書分類 （論文の追試）
それでは本題、Bag of Words（BoW）とロジスティック回帰で、カテゴリ分類を試してみます。

その前に、Bag of Wordsについて簡単に説明すると、__ある文書中の単語の出現頻度を特徴量として用いる__ 手法です。

例えば、"I have a pen. I have an apple."であれば、`{"I":2, "have":2, "a":1, "an":1, "pen":1, "aplle":1, ".":2}`という具合です。実際には、事前に定義された単語列を使用するので、文書中に出てきてもカウントされない単語もありますし、文書中に出てこない単語の部分は`0`となります。これにより、各文書は`[2, 0, 1, 1, 0, 0, ...]`というようなベクトルに変換されます。

scikit-learnには`CountVectorizer`というBoWのための便利なクラスが用意されていますので、以下ではそれを使用して文書分類を実施しています。
```python:BoWで文書分類（論文追試）
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import precision_score, recall_score, f1_score

# 入力データとラベルの準備
docs = df_hm_target['cleaned_hm'].tolist()
labels = df_hm_target['ground_truth_category'].tolist()
label_ids = np.array([LABELS_ALL.index(label) for label in labels])

#BoW (CountVectorizer)
bow_vectorizer = CountVectorizer(min_df=0.0001, stop_words='english')
bow_vectorizer.fit(docs)
x_data = bow_vectorizer.transform(docs).toarray()

# ロジスティック回帰
logreg = LogisticRegression(max_iter=1000)

# 層化k分割交差検証
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)

scores_all = []
for train_index, test_index in skf.split(x_data, label_ids):
    x_train, x_test = x_data[train_index], x_data[test_index]
    y_train, y_true = label_ids[train_index], label_ids[test_index]

    # 学習
    logreg.fit(x_train, y_train)
    y_pred = logreg.predict(x_test)

    # クラス別のPrecision, Recall, F1-scoreを計算
    scores = []
    scores.append(precision_score(y_true=y_true, y_pred=y_pred, average=None))
    scores.append(recall_score(y_true=y_true, y_pred=y_pred, average=None))
    scores.append(f1_score(y_true=y_true, y_pred=y_pred, average=None))
    scores_all.append(scores)

# 各スコアの平均値を算出
scores_mean = np.stack(scores_all).mean(axis=0)
df_scores_mean = pd.DataFrame(scores_mean.T, index=LABELS_ALL, columns=['precision', 'recall', 'f1'])
df_scores_mean
```
コードに関していくつか補足します。
- `CountVectorizer`の引数
    - `min_df`
        - カウント対象の単語を決める際の、出現頻度（Document Frequency）の下限を設定します。
        - 0.0001に設定した場合、10,000文書に1回以上の出現頻度の単語をカウント対象に含めます（本データはサンプル数が15,000程度なので、2度以上出現した単語が対象となることになります）
        - 上限を設定する`max_df`もあります。
    - `stop_words`
        - カウント対象に含めたくない単語を設定できます。
        - `'english'`と指定することで、事前定義された英単語（`and`, `the`など）が設定されます。
- ロジスティック回帰の引数`max_iter`
    - デフォルト値（100）では収束せずWarningが出力されたので、1000に設定しています。
- `StratifiedKFold`
    - 層化k分割と呼ばれるもので、各クラスのサンプル数の割合を保った形で分割を行ってくれます。
    - 今回のように、分布の偏りが大きい場合に特に有効です（単純に分割すると、一部のクラスが含まれない集合となってしまう可能性があります）


さて、本題の、文書分類の結果ですが、以下の表になります。
F1スコアで最大3%程度の差はありますが、論文の表を概ね再現できていることが確認できます。
ExerciseとNatureは、そもそもサンプル数が極端に少ないので難しそうですが、Enjoy the momentが最も精度が悪いのは気になりますね。他のカテゴリと比較して、特有の単語がないのかもしれません。
![bow_results_comb.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2612472/9316412d-827e-ea15-cb8e-424fb681ecb2.png)
論文の追試は以上です。
BoWとロジスティック回帰を用いた文書分類について、scikit-learnを使用することでとてもお手軽に実現できることを確認できました。

## BoWの出現頻度に関するパラメータの影響

コード補足に記載した通り,`CountVectorizer`には、出現頻度（Document Frequency）に関するパラメータが存在します。

どの文章にも出てくるような高頻度に出現する単語や、１つの文章にしか出てこないような稀な単語は、特徴量として使用するのは適切ではないから除外する、ということを実現するパラメータです。

ここでは、そのパラメータをいくつか変えて、「カウント対象の単語数」と「精度（正解率）」がどのように変化するかを見てみます。

`CountVectorizer.get_feature_names_out()`で、カウント対象の単語を取得できます。

```python:出現頻度に関するパラメータ（max_df, min_df）の影響
for min_df in (0.0, 0.0001, 0.001, 0.005, 0.01):
    for max_df in (0.2, 0.5, 1.0):
        
        bow_vectorizer = CountVectorizer(max_df=max_df, min_df=min_df, stop_words='english')
        bow_vectorizer.fit(docs)
        n_vocab = len(bow_vectorizer.get_feature_names_out())
        x_train = bow_vectorizer.transform(docs_train).toarray()
        x_test = bow_vectorizer.transform(docs_test).toarray()

        logreg = LogisticRegression(max_iter=1000)
        logreg.fit(x_train, y_train)
        acc = logreg.score(x_test, y_test)

        print('min_df: {}, max_df: {}, # vocabularies: {} --- Acc. {}'.format(min_df, max_df, n_vocab, acc))
```
```
min_df: 0.0, max_df: 0.2, # vocabularies: 9539 --- Acc. 0.8278595696489242
min_df: 0.0, max_df: 0.5, # vocabularies: 9539 --- Acc. 0.8278595696489242
min_df: 0.0, max_df: 1.0, # vocabularies: 9539 --- Acc. 0.8278595696489242
min_df: 0.0001, max_df: 0.2, # vocabularies: 5135 --- Acc. 0.8281426953567383
min_df: 0.0001, max_df: 0.5, # vocabularies: 5135 --- Acc. 0.8281426953567383
min_df: 0.0001, max_df: 1.0, # vocabularies: 5135 --- Acc. 0.8281426953567383
min_df: 0.001, max_df: 0.2, # vocabularies: 1112 --- Acc. 0.8207814269535674
min_df: 0.001, max_df: 0.5, # vocabularies: 1112 --- Acc. 0.8207814269535674
min_df: 0.001, max_df: 1.0, # vocabularies: 1112 --- Acc. 0.8207814269535674
min_df: 0.005, max_df: 0.2, # vocabularies: 261 --- Acc. 0.7607587768969423
min_df: 0.005, max_df: 0.5, # vocabularies: 261 --- Acc. 0.7607587768969423
min_df: 0.005, max_df: 1.0, # vocabularies: 261 --- Acc. 0.7607587768969423
min_df: 0.01, max_df: 0.2, # vocabularies: 121 --- Acc. 0.7284824462061155
min_df: 0.01, max_df: 0.5, # vocabularies: 121 --- Acc. 0.7284824462061155
min_df: 0.01, max_df: 1.0, # vocabularies: 121 --- Acc. 0.7284824462061155
```
結果を見てみると…
- max_df
    - 指定した範囲においては、全く効果がないことがわかります。`stop_words='english'`を設定しているからかもしれません。
- min_df
    - 想定よりかなりシビアでした。本データセットが、1,2文のみの短い文書が多いため、全体的に出現頻度の値が小さくなっている可能性があります。
- 精度（Acc）
    - 出現頻度で単語を削らない方が正解率が高いことがわかります。（なお、単語数=次元数が大きいと計算コストは高くなります）

これらの結果はこのデータセット特有の傾向と思われますので、今後他のデータセットでも見ていきたいと思いました。（特に、`max_df`や`min_df`の値については、データセットに依存して大きく変わるもののはずです。）

## TF-IDFでも文書分類してみる
続いて、TF-IDFを用いた文書分類も試してみます。
なお、TF-IDFは、Term Frequency - Inverse Document Frequencyの略です。

BoWは、ある文書内の単語の出現数を特徴量として使用するものでした。
TF-IDFでは、それに加えて、それぞれの単語の出現頻度（すなわち、一般的によく使用される単語なのか、特定の場面でのみ使用される稀な単語なのか）という情報も特徴量として使用します。

出現頻度が高いものほど、特徴量としての重要性は低いものと考えて、
`(ある文書内のその単語の出現数) / (全文書内でのその単語の出現頻度)`
という形で特徴量が算出されます。
TF-IDFという名前は、分子がTF(Term Frequency)、分母がDF（Document Frequency）で、DFで割っているのでInverseが付いてIDFという訳です。

それでは、実際に文書分類してみます。
`CountVectorizer`同様、scikit-learnには`TfidfVectorizer`というクラスが定義されているのでそれを使用します。
```python:TF-IDFで文書分類
from sklearn.feature_extraction.text import TfidfVectorizer

# TF-IDF (TfidfVectorizer)
tfidf_vectorizer = TfidfVectorizer(min_df=0.0001, stop_words='english')
tfidf_vectorizer.fit(docs)
x_data = tfidf_vectorizer.transform(docs).toarray()

# 他はBoWの場合と同様のため省略
```
![tfidf_results_comb.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2612472/f70374ca-b4b3-d504-6281-d5ce3358f7e9.png)
結果を見ると、全体的にF1スコアが下がっていることがわかります。
特に、ExerciseとNatureで顕著です。どちらも、Precisionは上がっているけれどRecallが大きく下がって、結果F1スコアも下がった、という状況です。

想定では、出現頻度の情報を使用するTF-IDFの方が、BoWより精度が上がるのでは？と思っていたのですが、そうはなりませんでした。

考えられる理由としては、
- 出現頻度の低い（IDFの大きい）単語の影響力が強まりすぎた
- 結果、それらの単語が含まれているかどうかといった単純な識別になってしまっている可能性がある

というところでしょうか。
TF-IDFでは、各クラスのサンプル数に偏りが大きい場合には悪影響を及ぼす場合がある、ということかもしれません。

# おわりに
以上、幸せな瞬間を集めたデータベース『HappyDB』を題材に文書分類をしてみました。
BoWとロジスティック回帰を用いて、論文で示されている結果を再現可能なことを確認しました。

今回はBoWやTF-IDFの使い方を確認することが主眼に置いていたため、結果の分析はあまり深入りできませんでした。今後、どんな単語が効いているのか、分類に間違ったサンブルといった部分も確認していきたいと思います。

また、DeepLearningを用いたBERTによる文書分類も試してみて比較してみたいなと思っています。

最後までお読みいただきありがとうございました。
本記事の内容が何かの参考になりましたら嬉しいです。

# 参考文献
- ["HappyDB: A Corpus of 100,000 Crowdsourced Happy Moments" (Asai, et al., 2018)](https://arxiv.org/pdf/1801.07746.pdf)
- [Qiita - 機械学習 〜 テキスト特徴量（CountVectorizer, TfidfVectorizer） 〜](https://qiita.com/fujin/items/b1a7152c2ec2b4963160)
- [静かなる名辞 - 【python】sklearnのCountVectorizerの使い方](https://www.haya-programming.com/entry/2018/02/25/044525)
- [はじめての自然言語処理 第1回 類似文書検索の手法と精度比較](https://www.ogis-ri.co.jp/otc/hiroba/technical/similar-document-search/part1.html)
- [Qiita - sklearnの交差検証の種類とその動作](https://qiita.com/chorome/items/54e99093050a9473a189)