CREチームへのお問い合わせをscikit-learnでラベリングしてみた

:::note info
この記事はOPENLOGI Advent Calendar 2023の3日目の記事です。
:::

私はCREチームに所属しており、弊社サービスである[オープンロジ](https://service.openlogi.com/)のシステム面に関するお問い合わせをCS経由で日々受けて対応しています。

- (参考）[Google の新しい専門職 : CRE が必要な理由](https://cloudplatform-jp.googleblog.com/2016/10/google-cre.html)

CREチームでは日々のお問い合わせの対応以外に、そもそもできるだけお問い合わせをしなくてもいい状態を目指しており、その一環としてまずは現状分析のためにお問い合わせに関するデータの集計と各お問い合わせについてラベリングをしています。

今回はそのラベリングの作業を効率化するべく機械学習を用いて自動化を試してみたので、その手順や所感について紹介していきます。

なお、筆者の経歴としてはいわゆるWebアプリの開発がメインで、機械学習やデータサイエンスに関するバックグラウンドは大学時代に入門レベルの授業を受けた程度で、業務経験としては全くありません。

したがって機械学習のアルゴリズムに関する深い知識は持っておらず本記事では触れませんが、同じような状況の方にとって自身の業務にどう活かせる可能性があるか、という観点で読んでいただける内容になっているかと思います。

## 前提
CREチームへのお問い合わせデータはスプレッドシートで以下のように集計しています。
![スクリーンショット 2023-12-02 18.36.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145670/56228376-e68a-85b3-c67c-292ee651e8ec.png)

このスプレッドシートには2種類のデータが存在します。
- お問い合わせの管理で利用しているRedmineからそのまま引っ張ってきたデータ
- CREチームでラベリングしたデータ

今回着目したのはG列の「分類」で、これは「CREチームでラベリングしたデータ」にあたり、お問い合わせの大まかな傾向を把握することを目的としており、各行について4種類のラベルをつけています。
- 仕様調査...何らかのシステムにおける操作や処理においてどのような挙動や結果になるかを知りたい
- 原因調査...何らかの実際に発生した想定外のシステム上の挙動や結果に対してなぜそうなったかを知りたい
- 運用相談...ユーザが実現したいことがシステム上で実現可能かどうかを知りたい
- 運用作業依頼...事業側で対応できない設定作業やデータ抽出・更新作業などをエンジニアに依頼したい

お問い合わせの傾向を把握した上でどう活用するについては課題感があり、また上記のラベルが本当に適切かどうかは再考の余地は多分にありますが、まずは始めてみるということで一旦このようにしています。

*追記：現状この分類は社内向けにお問い合わせ対応のナレッジを作る際に活用したりはしています。ただ、お問い合わせ対応の効率化に向けて具体的な改善につなげるにはもう少し詳細なラベリングと分析が必要であるというのが正直な感想ではあります。
（参考）[CREチーム発足から１年の取り組みを振り返る#お問い合わせの分類](
https://qiita.com/kt-tsutsumi/items/59ba37a0f4018e8e4176#%E3%81%8A%E5%95%8F%E3%81%84%E5%90%88%E3%82%8F%E3%81%9B%E3%81%AE%E5%88%86%E9%A1%9E)


そのうえで1件1件お問い合わせの内容をチェックしてラベリングしていましたが、この作業が結構大変だったためどうにかして楽にできないかと考えて自動化を試みたのが本記事の背景となります。

## 手法
結論としてはPythonのライブラリであるscikit-learnで、ランダムフォレストという手法を利用することにしました。
ランダムフォレストの詳しい説明は省略しますが、なぜランダムフォレストにしたかその流れを簡単にご紹介できればと思います。

### なぜランダムフォレストにしたか
今回のような正解データを用意できる場合は機械学習の中でも教師あり学習になることはなんとなく知っていたため、そのような切り口から機械学習でよく利用されるPythonで実現する方法を探していたところ、ランダムフォレストを知りました。

ランダムフォレストに関するいろいろな記事を読む中で比較的短時間で実装できそうでかつ一定の効果が期待できそうと感じたためランダムフォレストを試してみることにしました。

以下で実際の具体的な流れを紹介します。

## 1.事前準備
### Dockerで環境構築
既にローカルで環境あればそちらで全然問題ないですが、今回はDockerを利用しました。
設定ファイルは以下となり、バージョンはPython3.12、必要なライブラリはrequirements.txtに記載のものになります。

```yaml:docker-compose.yml
version: '3'
services:
  python3:
    restart: always
    build: .
    container_name: 'python3'
    working_dir: '/root/'
    tty: true
    volumes:
      - ./:/root
```

```Dockerfile:Dockerfile
FROM python:3
USER root

RUN apt-get update
RUN apt-get -y install locales && \
    localedef -f UTF-8 -i ja_JP ja_JP.UTF-8
ENV LANG ja_JP.UTF-8
ENV LANGUAGE ja_JP:ja
ENV LC_ALL ja_JP.UTF-8
ENV TZ JST-9
ENV TERM xterm

RUN apt-get install -y vim less
RUN apt -y install mecab libmecab-dev mecab-utils mecab-jumandic-utf8 mecab-naist-jdic python3-mecab
RUN pip install --upgrade pip
COPY requirements.txt /
RUN pip install -r requirements.txt
```

```requirements.txt
mecab-python3
pandas
gensim
scikit-learn
```




### 学習データと評価データの準備
前提の項でご紹介したスプレッドシートをそのままCSVでダウンロードして利用しました。
特に加工せずそのままでも使えますが今回は不要なデータは除いて分かりやすくするために必要な列のみを残します。

今回はシンプルに説明変数をお問い合わせのタイトル、目的変数を発生事象ラベルとして2列のCSVを用意して、学習データを`train.csv`、評価データを`predict.csv`と命名します。

- （参考）[説明変数と目的変数](https://www.gen-info.osaka-u.ac.jp/MEPHAS/express/express8.html)

＊以降の説明では便宜的に以下のようなCSVを用意したと仮定して進めます。
```train.csv
タイトル,分類
入庫依頼がエラーになり作成できません,原因調査
海外宛の出庫依頼は作成できますか？,仕様調査
```

＊実際は学習データを600行、評価データを200行ほど用意して検証しました。

## 2.学習データ（train.csv)を読み込む
ここからがPythonでの実装になります。
まずは用意したCSV形式の学習データをpandasというライブラリを利用して読み込みます。

```python:main.py
import pandas as pd

if __name__ == '__main__':
    df = pd.read_csv('train.csv', usecols=[0,1]) 
    print(df.values)  #debug
```

結果
```python3:出力
[
 ['入庫依頼がエラーになり作成できません' '原因調査']
 ['海外宛の出庫依頼は作成できますか？' '仕様調査']
]
```

## 3.目的変数を用意する
次に目的変数を用意します。
目的変数は学習データの2列目を1次元配列の形で用意すればOKです。

```python:main.py
 if __name__ == '__main__':
    ## 目的変数を取り出す
    response_variable = []

    for row in df.values: #dfはさきほど読み込んだCSV
        response_variable.append(row[1]) #目的変数はCSVの2列目なのでインデックスは1
    
    print(response_variable)  #debug
```

結果
```python3:出力
[
    '原因調査',
    '仕様調査'
]
```

## 4.説明変数を用意する
次に説明変数を用意します。
説明変数については学習データの1列目をそのまま使うのではなく、学習させるための形式に加工させる必要があります。

学習させるためにはデータを文字列ではなく数値で用意する必要があり、数値化する手法はいくつかあるのですが今回はBag-of-Wordsという形式を利用しました。Bag-of-Wordsとは、"文書中に出現する単語を数え、その数を特徴とするという手法"になります。
- [Bag-of-Wordsを理解する](https://data-analytics.fun/2020/03/12/bag-of-words/)

このデータの加工が今回の内容で一番難しい箇所になり、手順については以下の記事を参考にさせていただきました。
- （参考）[scikit-learnとgensimでニュース記事を分類する](https://qiita.com/yasunori/items/31a23eb259482e4824e2)

### 動詞と名詞のみを抜き出す
お問合せタイトルのうち、お問合せ内容を表す品詞のみを抜きます。
たとえば助詞はお問い合わせの内容を表さない不要な情報になるので取り除いた方が良く、ここでは名詞と動詞のみを抜き出します。

```python:main.py
import MeCab
mecab = MeCab.Tagger()

def tokenize(text):
    '''
    とりあえず形態素解析して名詞だけ取り出す感じにしてる
    '''
    node = mecab.parseToNode(text)
    while node:
        if node.feature.split(',')[0] == '名詞' or node.feature.split(',')[0] == '動詞':
            yield node.surface.lower()
        node = node.next

def get_words(contents):
    
    '''
    記事群のdictについて、形態素解析してリストにして返す
    '''
    ret = []
    for k, content in contents.items():
        ret.append(get_words_main(content))
    return ret

def get_words_main(content):
    '''
    一つの記事を形態素解析して返す
    '''
    return [token for token in tokenize(content)]

if __name__ == '__main__':
    ## 説明変数を取り出す
    inquiry_title_words_list = []

    for row in df.values: #dfはさきほど読み込んだCSV
        # 名詞と動詞のみ抜き出す
        words = get_words({'train.csv': row[0]}) #keyはなんでもよい
        inquiry_title_words_list.append(words[0])

    print(inquiry_title_words_list) #debug
```

結果
```python3:出力
[
    ['入庫', '依頼', 'エラー', 'なり', '作成', 'でき'], 
    ['海外', '出庫', '依頼', '作成', 'でき']
]
```
名詞と動詞のみ抜き出されました。


### 特徴語辞書を作る
名詞と動詞のみ抜き出されたデータに対して、重複の単語を除いた上で各単語にIdを割り振ったdictionaryを用意します。

```python3
from gensim import corpora

if __name__ == '__main__':
    #inquiry_title_words_listは先ほどお問い合わせタイトルから名詞と動詞を抜き出した2次元配列
    dictionary = corpora.Dictionary(inquiry_title_words_list) 
    
    print(dictionary.token2id) #debug
```

結果
```python3:出力
{'でき': 0, 'なり': 1, 'エラー': 2, '作成': 3, '依頼': 4, '入庫': 5, '出庫': 6, '海外': 7}
```

重複の単語を除いた上で各単語にidが割り振られています。
今回はそのまま使いますが、ここで不要そうな単語は除くといったようなチューニングをするとより良いかもしれません。
この辞書の単語が、各説明変数（タイトル）にどのくらい含まれてるかどうかで各説明変数（タイトル）の類似度を図る、といったようなことが大まかなやりたいことのイメージになります。

### 特徴ベクトルを作る
上記で作成した辞書の各単語が、各説明変数の各単語に含まれているかどうかを表現したデータを用意します。こういった特徴を並べたものを機械学種では特徴ベクトルと呼ぶみたいです。

今回の例だと各単語の出現頻度は以下のようになっています。
|| でき | なり | エラー | 作成　|依頼　|入庫　| 出庫　|海外
| ---| --- | --- | --- | --- |--- |---| --- |--- |
|入庫依頼がエラーになり作成できません| 1 | 1 | 1 | 1 | 1 | 1 | 0 |0 |
|海外宛の出庫依頼は作成できますか？| 1 | 0 | 0 | 1 | 1 | 0 | 1 |1 |

上記のような単語の出現頻度に関する情報を配列の形式で用意し、用意したデータが実際に学習させる形式のデータになります。


```python3
from gensim import corpora, matutils

if __name__ == '__main__':
    explanatory_variable = []

    for row in inquiry_title_words_list:
        tmp = dictionary.doc2bow(row)
        dense = list(matutils.corpus2dense([tmp], num_terms=len(dictionary)).T[0])
        explanatory_variable.append(dense)

    print(explanatory_variable) #debug
```

結果
```python3:出力
[
    [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0], 
    [1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0]
]
```

1つ目の`[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0]`は`入庫依頼がエラーになり作成できません`
の特徴ベクトルで、
2つ目の`[1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0]`は`海外宛の出庫依頼は作成できますか？`
の特徴ベクトルになります。


それぞれのインデックスの0の1.0は、上記で作成した特徴語辞書のインデックス0が含まれてるかどうかを示しており、特徴語辞書のインデックス0は`でき`であり両方に含まれているので両方とも1.0となっています。

特徴語辞書のインデックスの1は`なり`という単語ですが、これは1つ目の`入庫依頼がエラーになり作成できません`にのみに含まれているので、1つ目は1.0で2つ目は0.0となります。



### 関数にまとめる
以上で学習データの用意は完了しましたが、ここまでの処理は評価データの評価の時にも使う共通の処理が多いので関数にまとめました。

```python
import pandas as pd
import MeCab
from gensim import corpora, matutils
from sklearn.ensemble import RandomForestClassifier
mecab = MeCab.Tagger()

def tokenize(text):
    '''
    とりあえず形態素解析して名詞だけ取り出す感じにしてる
    '''
    node = mecab.parseToNode(text)
    while node:
        if node.feature.split(',')[0] == '名詞' or node.feature.split(',')[0] == '動詞':
            yield node.surface.lower()
        node = node.next

def get_words(contents):
    
    '''
    記事群のdictについて、形態素解析してリストにして返す
    '''
    ret = []
    for k, content in contents.items():
        ret.append(get_words_main(content))
    return ret

def get_words_main(content):
    '''
    一つの記事を形態素解析して返す
    '''
    return [token for token in tokenize(content)]
    
def get_explanatory_and_response_data(csvFilePath, dictionary = None):
    ## CSVを読み込む
    df = pd.read_csv(csvFilePath, usecols=[0,1])
   
    ## 目的変数を取り出す
    response_variable = []

    for row in df.values: #dfはさきほど読み込んだCSV
        response_variable.append(row[1]) #目的変数はCSVの2列目なのでインデックスは1

    ## 説明変数を取り出す
    inquiry_title_words_list = []

    for row in df.values: #dfはさきほど読み込んだCSV
        # 名詞と動詞のみ抜き出す
        words = get_words({csvFilePath: row[0]}) #keyはなんでもよい
        inquiry_title_words_list.append(words[0])

    # 学習データの時のみ辞書を作成する、評価データの時は学習データの時に作成した辞書を利用する
    if dictionary == None:
        dictionary = corpora.Dictionary(inquiry_title_words_list)
    
    explanatory_variable = []

    for row in inquiry_title_words_list:
        tmp = dictionary.doc2bow(row)
      
        dense = list(matutils.corpus2dense([tmp], num_terms=len(dictionary)).T[0])
        explanatory_variable.append(dense)

    return {
        'explanatory_variable': explanatory_variable,
        'response_variable': response_variable,
        'dictionary': dictionary
    }

if __name__ == '__main__':
    # 学習する
    # 学習データから説明変数と目的変数を取得
    train_data = get_explanatory_and_response_data('train.csv')
```

## 5.学習させてモデルを作成する
先ほど作成した関数を引数に学習データの`train.csv`を指定して呼び出して学習データを取り出した上で学習させてモデルを作成します。
`estimator.fit`はシンプルに第一引数を説明変数、第二引数を目的変数とすればOKです。


```python
if __name__ == '__main__':
    # 学習する
    #　学習データから説明変数と目的変数を取得
    train_data = get_explanatory_and_response_data('train.csv')

    #　ランダムフォレストで学習する
    estimator = RandomForestClassifier()
    estimator.fit(train_data['explanatory_variable'], train_data['response_variable'])
```


結果
出力なし

実際は毎回学習させる必要はないので作成したモデルを保存しておき次回以降はそれを使用する形が望ましいですが今回は省略します。


## 6.評価する
学習が完了したので最後に評価データの評価をしてみます。

### 評価データの説明変数と目的変数を用意する
学習データと同様に評価データの説明変数と目的変数を用意します。
辞書データは学習データで作成したものを使うので、第2引数に指定します。


```python
if __name__ == '__main__':
    # 評価データから説明変数と目的変数（正解）を取得する
    predict_data = get_explanatory_and_response_data('predict.csv', train_data['dictionary'])
```

### 正解率を知る
正解率を知りたい場合は`estimator.score`に評価データの説明変数と目的変数を指定して呼ぶだけでOKです。

```python
if __name__ == '__main__':
    # 正答率を出力する
    score = estimator.score(predict_data['explanatory_variable'])
    print(score) 
```

結果
```python:出力
0.8554216867469879
```

約85%の正解率でした。個人的に想像してたよりも高かったです。


### 評価結果を出力する
評価データの各行について、評価結果と正解を照らし合わせてみたい時は例えば以下のようにすることで知ることができます。

```python
if __name__ == '__main__':
    # 評価データの各行の評価と正解を出力する
    for i,row in enumerate(predict_data['explanatory_variable']):
        label_predict = estimator.predict([row])
        
        response = label_predict[0]
        answer = predict_data['response_variable'][i]

        print(f'評価:{response} 正解:{answer}')
```


結果（イメージ）

```python:出力
評価:原因調査 正解:原因調査
評価:仕様調査 正解:運用作業依頼
評価:運用作業依頼 正解:運用作業依頼
評価:原因調査 正解:原因調査
```


## 所感
- 始める前は説明変数がお問い合わせタイトルだけで正確な結果が出るのかな？と結構懐疑的だったのですが、正解率85%というのは想像してた以上に高かったです。GASと連携すれば今まで手作業でのラベリング作業が最終チェックをするのみで半自動化できるので、実装コストを考えてもやってよかったと思いました。
- 今回は比較的シンプルだったので深く踏みこまなくても問題なかったですが、説明変数を増やしたり重み付けしたりでチューニングするなどもっと高度なことをしたい場合はもっとアルゴリズムについても知る必要があるなと感じました。他にも応用できそうだなとは感じているので、使えそうな場面があればキャッチアップしつつどんどん使っていきたいです。

## 参考
- https://www.stat.go.jp/teacher/dl/pdf/c4learn/materials/fourth/dai3.pdf
- https://avinton.com/academy/classification-regression/
- https://data-analytics.fun/2020/03/12/bag-of-words/
- https://www.gen-info.osaka-u.ac.jp/MEPHAS/express/express8.html