RandomForestを使用した「英語ニュース記事のカテゴリ分類」

## 目次
- はじめに
- 本記事の概要
- 作成したプログラム・サンプルデータの概要
- 実装内容
- 精度向上への道のり
- 今後の活用
- おわりに

## はじめに

※このブログはAidemy Premiumのカリキュラムの一環で、受講修了条件を満たすために公開しています

はじめまして。SE歴3年程のMiss-pancakeです。

大学時代の専攻は情報工学や理工学分野ではなかったため、未経験からキャリアをスタートしました。今後のキャリアパスに必要なスキルを再検討する機会があり、①保守運用業務の経験が長く、実装力に不安があったこと、②データサイエンス分野の課題解決手法を学ぶ必要があると思い至ったことから、仕事の傍らデータ分析講座を受講することにしました。

****

## 本記事の概要

本記事では、機械学習の手法のうち、RandomForestを使用した分類モデルの構築について説明します。

**想定読者**
機械学習の初歩的な知識がある方（※1）、分類モデルに興味がある方

**メインテーマ**
RandomForestを使用した分類モデルのデータ取得・成形から学習・検証までのプロセス、精度向上の試み（※2）

※1 プログラミング用語や機械学習用語など、詳細な説明は省略します
※2 回帰モデルや、分類モデルのRandomForest以外の手法については説明しません

## 作成したプログラム・サンプルデータの概要

趣味で外国語の習得に励んでいることもあり、語学学習の助けになるツールを作成したいと考えていました。そのため、講座で学んだ機械学習の分類モデルと自然言語処理を用いて「英語ニュース記事のカテゴリ分類モデル」を作成することにしました。

**データセット探し**
データセットを探す際、必要なデータをリストアップすることから始めました。
教師あり学習では、目的変数と説明変数を用意する必要があります。
- 目的変数：原因によって生じた結果の変数(正解ラベルになる値)
- 説明変数：求めたい事象の原因となっている変数

「英語ニュース記事のカテゴリ分類」に必要なデータとして、以下を候補に挙げました。
- 目的変数：ニュース記事のカテゴリ名
- 説明変数：ニュース記事の内容（タイトルや本文）
 
上記データを持つデータセットを探したところ、Kaggleで見つけることができました。

**実装方法**
1. 講座で学んだ知識をもとに、プログラムの処理の流れを日本語で書き出す
2. Kaggleのニュースカテゴリ分類コンペで他者が実装したコードからヒントを得て、１に肉付けしていく
3. Kaggleのふるまいで不明点がある場合やエラーハンドリングに時間がかかった場合、Aidemyの技術カウンセリングを活用してアドバイスをもらう

**環境について**
私はKaggleで最適なデータセットを発見したため、インポートしやすいKaggleで開発しました。（使用言語：Python3.10.13）

## 実装内容

**ライブラリ**
まずは、必要なライブラリをインポートします。
実装を進めていく上で必要なライブラリが増えていくため、適に追記します。
下記は最終版です。
```python:import.py
import numpy as np
import pandas as pd 
import matplotlib.pyplot as plt
import seaborn as sns
import os
import re
import nltk
from nltk.corpus import stopwords
from nltk.tokenize import sent_tokenize, word_tokenize, RegexpTokenizer
from nltk.stem import PorterStemmer
from wordcloud import WordCloud, STOPWORDS
import tensorflow as tf
from tensorflow import keras
from keras.preprocessing import sequence
from keras.preprocessing.text import Tokenizer, text_to_word_sequence
from keras.layers import Embedding
from keras.initializers import Constant
from tensorflow.keras.utils import to_categorical
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics import confusion_matrix
from sklearn.metrics import accuracy_score
```

ソースコードを実装していって、足りないモジュールがあればpipでインストールします。
私は、ライブラリをimportするソースコードのブロックの前に新しくソースコードのブロックを追加し、以下のソースコードを実行しました。
```
!pip install tfIdfInheritVectorizer
```

**データの読み込み**
データセットがjson形式のファイルだったため、以下のソースコードで読み込みます。
```python:read_json.py
# データを読み込む
df = pd.read_json('../input/news-category-dataset/News_Category_Dataset_v3.json', lines=True)
```

**データの前処理**
以下のソースコードを実行して、データのクレンジングを行います。
データセットには、今回の学習・検証に不要なカラムが含まれているため、削除します。
```python:preprocessing.py
# 不要なカラムを削除
all_df = df.drop(columns=['authors','date', 'link'])
all_df = df.reindex(columns=['headline','short_description','category'])
```

※カラムを削除する前後で、以下のソースコードを実行してdataframeの先頭5行を表示し、カラムを確認します。
```python:preprocessing.py
all_df.head()
```
カラム削除前
![Quiita_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3740251/3ffdd00a-7fe6-e31f-6a38-f142f4ac324f.png)


カラム削除後
![Quiita_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3740251/9f82279f-9bea-e0dc-000e-c579d1565105.png)

全体のデータ量を確認しておきます。
```python:preprocessing.py
# データ量を確認
all_df.shape
```
```.tsx:出力結果
(209527, 3)
```

続いて、目的変数となるカテゴリ名と数を確認します。
```python:preprocessing.py
#目的変数の数
cates = all_df.groupby('category')
print("total categories:", cates.ngroups)
print(cates.size())
```
```.tsx:出力結果
total categories: 42
category
ARTS               1509
ARTS & CULTURE     1339
BLACK VOICES       4583
BUSINESS           5992
COLLEGE            1144
COMEDY             5400
CRIME              3562
CULTURE & ARTS     1074
DIVORCE            3426
EDUCATION          1014
ENTERTAINMENT     17362
ENVIRONMENT        1444
FIFTY              1401
FOOD & DRINK       6340
GOOD NEWS          1398
GREEN              2622
HEALTHY LIVING     6694
HOME & LIVING      4320
IMPACT             3484
LATINO VOICES      1130
MEDIA              2944
MONEY              1756
PARENTING          8791
PARENTS            3955
POLITICS          35602
QUEER VOICES       6347
RELIGION           2577
SCIENCE            2206
SPORTS             5077
STYLE              2254
STYLE & BEAUTY     9814
TASTE              2096
TECH               2104
THE WORLDPOST      3664
TRAVEL             9900
U.S. NEWS          1377
WEDDINGS           3653
WEIRD NEWS         2777
WELLNESS          17945
WOMEN              3572
WORLD NEWS         3299
WORLDPOST          2579
dtype: int64
```

カテゴリ別のデータ量をわかりやすくグラフで可視化します。
```python:preprocessing.py
# カテゴリの頻度に基づいて並べ替えられたカテゴリのリストを取得
category_order = df['category'].value_counts().index

# 頻度順に並べ替えたカテゴリでカウントプロットを描画
sns.countplot(y="category", order=category_order, data=df)

# プロットを表示
plt.show()
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3740251/37af1279-298c-8003-314e-eafd9b7b073d.png)

以下3つの理由から、今回はデータ量が多い順に上位10カテゴリの分類を行うことにしました。
・全データ量が20万レコード以上と多く、過学習の可能性あり
・目的変数が41個と多く、学習の精度が落ちる可能性あり
・カテゴリごとのデータ量に偏りがあり、学習にも偏りが出る可能性あり

まず、目的変数を41個→10個に絞る処理を実装します。
```python:preprocessing.py
# カテゴリーのカウント数を計算
category_counts = all_df['category'].value_counts()

# カウント数の上位10個のカテゴリーを選択
top_10_categories = category_counts.head(10).index

# 上位10個のカテゴリーに該当するインデックスを抜き出す
top_10 = all_df[all_df['category'].isin(top_10_categories)]
top_10 = top_10.reset_index(drop=True)

# 結果の表示
print(top_10['category'].value_counts())
```

```.tsx:出力結果
category
POLITICS          35602
WELLNESS          17945
ENTERTAINMENT     17362
TRAVEL             9900
STYLE & BEAUTY     9814
PARENTING          8791
HEALTHY LIVING     6694
QUEER VOICES       6347
FOOD & DRINK       6340
BUSINESS           5992
Name: count, dtype: int64
```

次に、ばらつきがある各カテゴリのデータ量を、MAX6000レコードごとに統一します。
```python:preprocessing.py
カテゴリごとのデータ量の偏りをなくすためにレコード数を調整する
politics_df = top_10[top_10['category'] == 'POLITICS']
politics_df = politics_df.sample(n=6000)
wellness_df = top_10[top_10['category'] == 'WELLNESS']
wellness_df = wellness_df.sample(n=6000)
entertainment_df = top_10[top_10['category'] == 'ENTERTAINMENT']
entertainment_df = entertainment_df.sample(n=6000)
travel_df = top_10[top_10['category'] == 'TRAVEL']
travel_df = travel_df.sample(n=6000)
style_beauty_df = top_10[top_10['category'] == 'STYLE & BEAUTY']
style_beauty_df = style_beauty_df.sample(n=6000)
parenting_df = top_10[top_10['category'] == 'PARENTING']
parenting_df = parenting_df.sample(n=6000)
health_df = top_10[top_10['category'] == 'HEALTHY LIVING']
health_df = health_df.sample(n=6000)
queer_df = top_10[top_10['category'] == 'QUEER VOICES']
queer_df = queer_df.sample(n=6000)
food_df = top_10[top_10['category'] == 'FOOD & DRINK']
food_df = food_df.sample(n=6000)
business_df = top_10[top_10['category'] == 'BUSINESS']
top_10 = pd.concat([politics_df, wellness_df, entertainment_df, travel_df, style_beauty_df, parenting_df, health_df, queer_df, food_df, business_df], axis=0)

new_df = top_10.sample(frac=1, ignore_index=True)
```

最後に、データ量と、レコードがカテゴリごとにまとまっていないことを確認します。
```.python:preprocessing.py
new_df.shape
new_df.head()
```
```.tsx:出力結果
(59992, 5)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3740251/e0749afa-b226-09fe-7aae-eddecf59c49d.png)

**データのクレンジング**
データの前処理が完了したら、学習・検証に使用しやすいようにクレンジングしていきます。
今回必要な処理は主に以下の２つです。
（１）文字列を数値化する
（２）文字列を単語ごとに分解する

（１）文字列を数値化する
　　　機械学習の際、目的変数と説明変数は数値にする必要があるため、目的変数のカテゴリ名をIDに変換し、category_idカラムに格納し、文字列を数値ベクトルに変換します。
```python:preprocessing.py
# カテゴリをIDに変換する
categories = new_df.groupby('category').size().index.tolist()
category_int = {}
int_category = {}
for i, k in enumerate(categories):
    category_int.update({k:i})
    int_category.update({i:k})

new_df['category_id'] = new_df['category'].apply(lambda x: category_int[x])

#文字列を数値ベクトルに変換する
EMBEDDING_DIM = 100

embeddings_index = {}
f = open('../input/glove-global-vectors-for-word-representation/glove.6B.100d.txt')
for line in f:
    values = line.split()
    word = values[0]
    coefs = np.asarray(values[1:], dtype='float32')
    embeddings_index[word] = coefs
f.close()

print('Total %s word vectors.' % len(embeddings_index))
```
（２）文字列を単語ごとに分解する
　　　文章を単語レベルに分解する処理を実装します。
```python:preprocessing.py
# シーケンスの長さを50に設定
maxlen = 50

# 文章を単語に分解する
tokenizer = Tokenizer()
word_index = tokenizer.word_index

embedding_matrix = np.zeros((len(word_index) + 1, EMBEDDING_DIM))
for word, i in word_index.items():
    embedding_vector = embeddings_index.get(word)
    if embedding_vector is not None:
        embedding_matrix[i] = embedding_vector

embedding_layer = Embedding(len(word_index)+1,
                            EMBEDDING_DIM,
                            embeddings_initializer=Constant(embedding_matrix),
                            input_length=maxlen,
                            trainable=False)
```
（２）文字列を単語ごとに分解する　※続き
　　　テキストを分割するための３段階の処理を行う関数を実装します。引数として受け取ったテキストを単語に分割し、次にストップワードを取り除きます。最後に、品詞の原型を取り出し、品詞の原型を統合した文字列を返却する処理を行います。
```python:preprocessing.py
def preprocess_text(text):
    # 1. textを単語に分解する
    tk = RegexpTokenizer('\s+', gaps=True)
    tokenized_data = tk.tokenize(text) 
    
    # 2. ストップワードを取り除く
    sw = stopwords.words('english')
    clean_text = [word.lower() for word in tokenized_data if word.lower() not in sw]
    
    # 3. Stemming（品詞の原型を抽出する）
    ps = PorterStemmer()
    stemmed_text = [ps.stem(word) for word in clean_text]
    
    # 品詞の原型のみを統合した文字列を返却する
    return " ".join(stemmed_text)
```

上記で実装した関数を使用し、学習・検証に使用する説明変数の形を整えます。
```python:preprocessing.py
# 学習・検証対象のdataframeに、preprocess_text関数を適用する
new_df['headline'] = new_df['headline'].apply(preprocess_text)
new_df['short_description'] = new_df['short_description'].apply(preprocess_text)

# 前処理の完了したテキストを一つに統合し、新規カラムに格納する
new_df['text'] = new_df['headline'] + " " + new_df['short_description']
```

**学習・検証**
今回は、データ量も豊富なため、複数の学習器を組み合わせて汎化性能を高めることができるアンサンブル学習の手法、RandomForestを採用しました。
```python:learning_and_verifying.py
# 説明変数をXに格納
X = new_df['text']

# 整数のクラスベクトルをバイナリクラス行列に変換
Y = to_categorical(new_df.category_id)

# データを訓練データと検証データに分割
seed = 29
x_train, x_val, y_train, y_val = train_test_split(X, Y, test_size=0.1, random_state=seed, stratify=Y)

# tf-idfで訓練データと検証データをベクトル化
vectorizer = TfidfVectorizer()
train_vec = vectorizer.fit_transform(x_train)
test_vec = vectorizer.transform(x_val)

# ランダムフォレストで学習
rf = RandomForestClassifier(n_estimators=100)
rf.fit(train_vec, y_train)

# 精度の出力
print("学習の結果：" + str(rf.score(train_vec, y_train)))
print("検証の結果：" + str(rf.score(test_vec, y_val)))
```
```.tsx:出力結果
学習の結果：0.9998703511631353
検証の結果：0.4221666666666667
```

**結果の可視化**
最後に、学習・検証の結果を可視化し、考察をまとめます。
(1)正解ラベルの数と、実際に予測した数を並べてグラフ化
(2)ヒートマップ

(1)正解ラベルの数と、実際に予測した数を並べてグラフ化
　グラフにしたところ、"ENTERTAINMENT"を最も多く予測していることがわかりました。正解率が上がらない原因がここにありそうなことが見えました。
```python:visualize.py
# 正解categoryごとにグループ化し、各カテゴリの頻度を計算
category_freq = top_10.groupby('category').size()
# 頻度を降順でソート
category_freq_sorted = category_freq.sort_values(ascending=False)
# ワンホットエンコーディングから元のクラスラベルに戻す
original_class_labels = np.argmax(y_val, axis=1)
# category_id とカテゴリ名のマッピングを作成
id_to_category = {category_id: category_name for category_id, category_name in enumerate(pd.DataFrame(category_freq_sorted).index)}
# 元に戻したラベルIDをカテゴリ名にマッピング
category_names = [id_to_category[label_id] for label_id in original_class_labels]


# 予測categoryごとにグループ化し、各カテゴリの頻度を計算
category_freq_pred = top_10.groupby('category').size()
# 頻度を降順でソート
category_freq_sorted_pred = category_freq_pred.sort_values(ascending=False)
# ワンホットエンコーディングから元のクラスラベルに戻す
original_class_labels_pred = np.argmax(pred_df, axis=1)
# category_id とカテゴリ名のマッピングを作成
id_to_category_pred = {category_id: category_name for category_id, category_name in enumerate(pd.DataFrame(category_freq_sorted_pred).index)}
# 元に戻したラベルIDをカテゴリ名にマッピング
category_names_pred = [id_to_category_pred[label_id] for label_id in original_class_labels_pred]
```

```python:visualize.py
pd.DataFrame(category_freq_sorted).index

true_df = pd.DataFrame(category_names, columns=['category'])
pred_df = pd.DataFrame(category_names_pred, columns=['category'])
true_df['Type'] = 'True'
pred_df['Type'] = 'Pred'

concat_df = pd.concat([true_df, pred_df])

# カテゴリの頻度に基づいて並べ替えられたカテゴリのリストを取得
category_order = true_df.value_counts().index

# countplotで2種類のデータを重ねて表示
sns.countplot(y='category', hue='Type', data=concat_df)

# プロットを表示
plt.show()

# カテゴリの頻度に基づいて並べ替えられたカテゴリのリストを取得
category_order = top_10['category'].value_counts().index

# 頻度順に並べ替えたカテゴリでカウントプロットを描画
sns.countplot(y="category", order=category_order, data=top_10)

# プロットを表示
plt.show()
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3740251/8a13b29e-8041-0ac4-70f0-9b1a38702371.png)

(2)ヒートマップ
　ヒートマップ化したことで、index=0、つまりENTERTAINMENTを最も多く予測していることがわかりました。さらなる制度改善のために、カテゴリのindex=0、という点に着目する必要がありそうです。

```python:visualize.py
pd.DataFrame(category_freq_sorted).index
```
```.txt:出力結果
Index(['ENTERTAINMENT', 'FOOD & DRINK', 'HEALTHY LIVING', 'PARENTING',
       'POLITICS', 'QUEER VOICES', 'STYLE & BEAUTY', 'TRAVEL', 'WELLNESS',
       'BUSINESS'],
      dtype='object', name='category')
```

```python:visualize.py
cm = confusion_matrix(original_class_labels, original_class_labels_pred)
print(cm)

#sns.heatmap(cm, annot_kws={'size': 15},cmap='Accent')
sns.heatmap(cm,annot=True,fmt="d")
```
```.txt:出力結果
[[560   4   3   6   2  16   1   2   6   0]
 [338 220   4   4   6  21   4   0   3   0]
 [233   2 358   1   1   1   0   3   1   0]
 [435   4   4 133   6   9   2   0   3   4]
 [339   1   2   5 245   1   1   3   3   0]
 [231   1   2   3   7 350   5   0   1   0]
 [228  16   2   2   4  11 336   0   1   0]
 [224   4   4   1   0   2   1 361   3   0]
 [320   2  12   3   0   0   0   5 257   1]
 [516   2  12  10   5   0   1   1   2  51]]
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3740251/49827af0-1c2e-2307-bb48-fd6e51b6ea55.png)



```python:visualize.py
# 普通のコンフュージョンマトリックスの2次元配列
mat = confusion_matrix(original_class_labels, original_class_labels_pred)
# 各行合計に対する割合を計算し、小数点第3位を四捨五入した2次元配列
mat_dec = np.round(mat / np.sum(mat, axis=1), decimals=2)

fig, axes = plt.subplots(1, 2, figsize=(10, 10))
kwargs = dict(square=True, annot=True, cbar=False, cmap='RdPu')

# 2つのヒートマップを描画
for i, dat in enumerate([mat, mat_dec]):
    sns.heatmap(dat, **kwargs, ax=axes[i])

# グラフタイトル、x軸とy軸のラベルを設定
for ax, t in zip(axes, ['Real number', 'Percentage(per row)']):
    plt.axes(ax)
    plt.title(t)
    plt.xlabel('predicted value')
    plt.ylabel('true value')
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3740251/e9cde159-5802-8b07-643e-4d8319be6c51.png)


## 精度向上への道のり
今回、実装する上で最も苦労したのが精度向上です。
結果的に、0.24211 → 0.42283 まで向上させることができたため、試したことをまとめます。
※上記にまとめたソースコードは、比較的精度の高かったNo.3です。

|No.|カテゴリ数|レコード数|テストサイズ|RandomForestのハイパーパラメータ|学習スコア|検証スコア|
|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
|1|42|209,527|0.1|n_estimators=100|0.99924|<font color="red">0.24211</font>|
|2|10|124,787|0.1|n_estimators=100|0.99980|0.49507(※)|
|3|10|65,992|0.1|n_estimators=100|0.99987|0.42216|
|4|10|65,992|0.1|n_estimators=500|0.99994|<font color="red">0.42283</font>|
|5|10|65,992|0.3|n_estimators=500|0.99997|0.41593|

実際には、テストサイズの変更やハイパーパラメータのチューニングを表に書ききれない組み合わせの数で試していますが、スコアの変化が大きかった条件の組み合わせをピックアップしています。

※表を見るとNo.2の検証スコアが最も高くなっていますが、サンプルデータ量に偏りがあります。

▼No.2の正解カテゴリ数と予測カテゴリ数のグラフ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3740251/5213c7e5-6ed5-6c72-a376-a5b69f9c607d.png)

No.3-5は、検証スコアがNo.2より低いものの、サンプルデータ量を調整しているため正解ラベルの数が均一です。

▼No.3の正解カテゴリ数と予測カテゴリ数のグラフ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3740251/a8bf0723-8ee4-e663-eb94-3d049430a12e.png)


今回実装したプログラムには、精度面で課題が残っています。プログラムの修正も視野に入れ、引き続き精度向上に挑みたいと思います。


****

## 今後の活用


データ分析の手法は数多くあり、解決したい課題や使用するデータの特徴によって最適解が異なります。講座で学んだことを土台にし、精度の高いモデルを構築するアイデアや新しい知識を習得していきたいと思います。



## おわりに

データ分析講座は、前半はインプット、後半はアウトプット中心のとても実践的な内容だったと感じています。Kaggleで他者のソースコードを見たり、第一線で活躍されているデータサイエンティストの方にカウンセリングをしていただいたり、初学者も微経験者もスキルアップできるカリキュラムだと感じました。

いずれは、今回作成したモデルを拡張し、英語や英語以外の言語の「ニュース記事の穴埋め問題」を開発したいと思っています。これまでの業務で培った経験と新しく学んだ知識を組み合わせ、これからも意欲的に学び続けていきたいです。