こんにちは。研究開発部の深澤(@fukkaa1225)と申します。
クックパッドでは、顧客のロイヤルティを測る指標であるNPS(ネットプロモータースコア)のアンケートを毎月実施しています。 このNPSアンケートで集まってきたユーザの声(フリーコメント)は、クックパッドにとって大変貴重なものです。しかし、毎月多くの声が届くこともあり、担当者だけで目を通して集計するというのは難しくなってきました。そこで昨年、予め定義したカテゴリにコメントを自動で分類するシステムを構築し、既に稼働させています。 NPSアンケートを自動分類した話 - クックパッド開発者ブログ
このシステムによって「いただいたコメントが何を話題にしているか」はある程度自動的に把握できるようになりました。次に課題となったのは、例えば「このコメントはレシピの多さに関するものである。でもその中にはポジティブな部分とネガティブな部分が混じっている。これを分離できないか?」というものでした。
これはもちろん、人間であればコメントを見て容易に把握し、抽出できるでしょう。では、それを自動で行えるようにしたいとき、みなさんはどのような手段でこれを実現させるでしょうか。ルールベースだけでこうした抽出問題を解くのは骨が折れそうです。ここは機械学習の力を借りることにします。
本稿では、「このNPSコメントのどの部分がポジティブな記述で、どの部分がネガティブな記述なのか」を抽出するシステムの、機械学習モデルの実験について紹介します。
まとめ
- あるコメントからポジティブ・ネガティブ部分を抽出する今回のタスクを、系列ラベリングと捉えて学習に必要なデータを作成。
- CRF++、Bidirectional-LSTM、BERTをベースとしたモデルで実験。
- sudachiで分かち書きし、学習済みword2vecにchiVeを用いたBidirectional-LSTMのモデルが最も高いF1値を記録した。しかし、CRF++と大きな差は見られなかった。
- 引き続きエラー分析を行って、NPSコメントを業務改善に活かしていけるようなシステムの開発に努めていきます。
学習データを作ろう
さて、機械学習で取り掛かるぞということで、さっそく学習データを作っていきます。どんなデータがあればよいのかを考えてみます。
今回実現したい機能は
クックパッドはたくさんレシピがあってありがたいが、ありすぎて選びきれない時もある
というコメントに対して、
- Positive:
たくさんレシピがあってありがたい
- Negative:
ありすぎて選びきれない時もある
といったように、ポジティブ・ネガティブに紐づく表現を抜き出すことです。
このようなタスクは Sequence Labeling(系列ラベリング)
・Token Classification
など色々な呼び方ができると思います。各形態素ごとに「この形態素は{ポジティブ・ネガティブ}な箇所の{始点・中間・終点}なのか」を分類する問題として捉えられるでしょう。
ということで、アノテーションは以下のようなデータを作ってもらうことにします。
クックパッドは#pたくさんレシピがあってありがたい#pが、#nありすぎて選びきれない時もある#n
ポジティブな箇所は #p
、 ネガティブな箇所は #n
で囲んでもらうようにします。アノテーションの体制は、2人のアノテータの方にそれぞれタグ付けをしていただいた上で、別の1人のアノテータの方にそれらの結果を統合してもらいます。これで統一性を確保するようにします。
実際に学習する際はこのタグ付けしてもらったデータをパースして、形態素ごとにBIOESラベル(e.g., BはBegin、IはInside、EはEnd、SはSingle、OはOther)を付与していきます。
B-positive たくさん I-Positive レシピ I-Positive が I-Positive あって E-Positive ありがたい
このようなルールのもとで、データを作っていってもらいました。最初に4,000、その後しばらく解析を進めながらもう一度ガッとタグ付けしていただいて、最終的に得られたデータ数は10,000コメント程度となりました。
どんなモデルでやるか
では、こうして得られたデータを使って抽出モデルを作成していきます。 まずはじめに考えるのはCRFですね。言わずと知れた系列ラベリングが得意なモデルです。これはCRF++をつかって容易にモデリングできます。
次に考えるのが、やはりディープラーニングを使う手法です。計算コストは当然CRFよりも高くなりますが、今回のような自然言語を扱うタスクにおいては十分な精度を出すことが期待されます。今回のタスクにおいては以下のようなアーキテクチャのモデルをベースとします。このモデルは[Lample+, 2016]で提案されたもので、インターンの学生の方が実装してくださいました。 単語をベクトルに変換するEmbedding層と文字列をBidirectional-LSTMでencodeする層を用意して、それらの出力値をconcatし、Bidirectional-LSTMに通すような構造です。 単語ベースの方は学習済みword2vecの重みを使います。
このモデルをベースとして、
- 文字列のencoderをCNNにする
- 学習済みword2vecで以下のものを試す
- wikipediaコーパスで学習したもの
- クックパッド手順コーパス(クックパッドに掲載されているレシピの手順を抜き出したもの)で学習したもの
- ワークスアプリケーションズ徳島人工知能NLP研究所が公開している国語研日本語ウェブコーパスで学習したもの(chiVe)
- Bidirectional-LSTMをtransformerに置き換える
- tokenizerをsudachiにし、形態素を正規化する
といったモデルを試していきます。
また、これに加えてやはり外せないだろうということでBERTも実験対象に加えます。
ベースのモデルはhuggingfaceに東北大の乾・鈴木研究室が提供している bert-base-japanese-whole-word-masking
を利用します。
バリエーションとしては以下の2つです。
- BERT論文にならって、BERTから得られるtokenごとの出力値をそのまま使いfine-tuningする
- 最終層にCRFを入れてfine-tuningを行う(BERT論文ではCRFは入っていなかった)
これは個人的な経験なのですが、自分が担当したタスクでBERTを用いて勝てたことがなかなかなく、今回も祈るような気持ちでBERTにトライしました。
まとめると以下の3パターンのモデルで実験を行います。
- CRF++
- 文字列encode+単語encode{by 学習済みword2vec} → Bidirectional-LSTM
- 学習済みword2vecやtokenizerで何を選ぶか、LSTM層をtransformerにするか否かなどのバリエーションあり
- BERT
- 最終層にCRFをつけるかどうか
実験の管理
さてここで少し本筋から外れますが、僕がどのようにこれらの実験を管理していたかについて述べたいと思います。
僕は実験のパラメータをyamlで管理するのが好きです。いつもだいたい以下のようなyamlを用意しています。
{実験名}: char_encode: LSTM transformer_encode:Falsechar_lstm_layer:1lstm_layer:1char_embedding_dim:50lstm_char_dim:25word_embedding_dim:300lstm_dim:100crf_drop_out:0.5lstm_drop_out:0.0tokenizer: sudachi normalized_token:Trueembed_path: /work/cache/chive-1.1-mc5-20200318.txt
python src/run_experiment.py --exp_name={実験名}
そしてこれを実験名を引数に入れたらyaml内の変数を展開してくれるwrapperを経由して、実験コードを流すようにしていました。出力結果も{実験名}_{日時}.log
のような名前にすることで、同じ実験名での結果だということがわかりやすくなるようにしています。
yamlで設定を記述することで、パラメータの調整をする際に実験コードそのものに手を入れる必要がなくなります。設定ファイルだけで済むのはとても気楽で、そういった理由からここ数年は個人的にこのスタイルでやっています。
見通しも、ひたすらargparse
やclick
の引数に渡しつづけるよりも良くなっているような気がします。モデルのtokenizerなのか単なるパラメータなど含めて書こうと思えば階層的に書ける点も好きです。
最近だとfacebookが出しているHydraなどもあり(今回は使っていませんでした)、yamlでパラメータ管理するのがどんどん楽になっており、ありがたいですね。
実験結果と考察
以上のような過程を踏みつつ、実験を行いました。得られた結果の中から主要なものを以下に表で示したいと思います(いずれのF1値もmicro-average)。
Name | all_f1 | negative_f1 | positive_f1 |
---|---|---|---|
Bidirectional-LSTMによる文字列encode・単語embed:chiVe→Stacked-Bidirectional-LSTM(tokenizer: sudachi) | 0.609 | 0.4495 | 0.6717 |
CRF++ | 0.6024 | 0.4839 | 0.6438 |
CNNによる文字列encode・単語embed:クックパッド手順コーパス→Stacked-Bidirectional-LSTM(tokenizer: mecab) | 0.5607 | 0.3977 | 0.6181 |
Bidirectional-LSTMによる文字列encode・単語embed:クックパッド手順コーパス→transformer(tokenizer: mecab) | 0.5129 | 0.3439 | 0.5695 |
Bidirectional-LSTMによる文字列encode・単語embed:クックパッド手順コーパス→Stacked-Bidirectional-LSTM(tokenizer: mecab) | 0.5066 | 0.3102 | 0.5751 |
Bidirectional-LSTMによる文字列encode・単語embed:wikipedia→Stacked-Bidirectional-LSTM(tokenizer: mecab) | 0.4898 | 0.351 | 0.5308 |
BERT-with-CRF | 0.419 | 0.248 | 0.5 |
BERT | 0.3843 | 0.2074 | 0.4734 |
chiVeを用いて最終層をStacked Bidirectional-LSTMにしたモデルが最も高いF1値を記録しました。しかしCRF++が想定以上によい結果を出しており、両者の差はほとんどないという結果になっています。
両者にあまり大きな差がないことから、いくつかの可能性が考えられます。今回採用したニューラルネットのモデルがBidirectional-LSTMを多用する計算コストの高いものであることから、恐らくデータ数が十分でなかった可能性が高いと現在は考えています。
BERTに関しては、なにかミスがあったのかなというくらいに低い結果となってしまいました。前述したようにBERT単体では相性が悪いのかもしれません。BERTにCRF層を加えたものでF1値の増加は確認できるので、全く機能していないというわけではないと思われますが、なにか根本的な改善が求められているということに変わりはなさそうです。引き続きBERTの勝利を願ってエラー分析をしていきたいと思っております。
ポジティブなコメント、ネガティブなコメント、それぞれのF1値に目を向けてみるとポジティブなコメントの抽出精度はどの手法でもネガティブなコメント抽出の精度よりも高くなっています。これは学習データにおけるラベルの不均衡に要因があると考えています。データの中でポジティブとしてタグ付けがされたのが7,218箇所あったのに対し、ネガティブとしてタグ付けが行われたものは2,346箇所と大きく差が開いていました。データ数が十分でなくネガティブに関するモデルの学習がうまく進まなかったことが考えられます。
最後にCRF++とchiVeを用いたStacked Bidirectional-LSTMの二者に絞ってエラーだった予測結果をいくつか見てみたいと思います。 基本的に短い文章でポジティブかネガティブのどちらかだけ出現するときはよく正解します。対照的に、長い文章・ポジティブとネガティブの両方出現するときに間違っていることが散見されました。
こちらの例ではどちらも前半の「いいと思う」が取れていませんが、後半は捉えられています。
こちらは、CRF++のみが前半の「料理初心者だったためとても重宝している」がとれています(ただし、短めにとっています)。
上の例と同じミスとして、CRF++が短めに範囲を捉えているケースがいくつかありました。
出来上がったシステムの全体像
さて、こうして作成された抽出モデルによって、NPSを解析するシステム全体は現在以下のような状態になっています。
毎月のNPS実施に合わせてコメント抽出・カテゴリ分類バッチが起動します。それらコメントはカテゴリごとに関連するslackチャンネルに通知されます。また解析結果は、NPSに関する数値を統合的に取り扱うために開発されているダッシュボードに取り込まれ、視覚的に分かりやすい形で残るようになっています。
今後について
NPSに対する解析は、ユーザの方々からの貴重なご意見を業務に役立てていく上で非常に重要なことであると感じています。より正確に、そして迅速に意見を取り込んでいけるように、引き続き自動解析システムの発展に努めていく所存です。