前口上
そして、深夜、俺一人の祭が始まる
たった今、全社をあげて飲めや歌えの勇壮な大忘年会を行っている。祭のあと、男達は集会所に集まり、普段着に着替え、飲み合う。六尺は、激しい祭でドロドロボロボロになるから、使い捨てで、ゴミとして出される。
俺はいつもそれが狙いだ。(中略)
そして、深夜、俺一人の祭が始まる。(中略)仕舞ってあるんだぜ。
要するにAdvent Calendarの原稿が全然できあがってなかったので、ひとりで忘年しながら書いてます。
注:当然ですがしらふです。
自分について
freee社でアプリケーションエンジニアやってます @krt です。会計のアプリケーション回りに便利機能追加したり、当社比最速で終わる確定申告機能の準備をしたりしています。
新・確定申告機能は、「気付いたら確定申告が終わってた」くらいのすごい機能になる予定です[要出典]。
freeeもうひとつのウリ
さてそんな、「会計のことよく分からなくてもサクサク使えて本業にフォーカスできる」freee ですが、もうひとつのウリは 手厚いサポート体制 です。ささっとチャットで聞けば疑問を解決してくれる丁寧なサポートで、日々の経理における 「どうすんだよこれ」 や、freeeそのものの使い方に関する 「あれやりたいけどどうすればいいんだよ」 または 「この操作もっと簡単にならないかな」 といったお客様の課題を迅速に解決する強力なチームです。
お問い合わせを分析してプロダクトに生かしたい
サポートに寄せられるお客様からの疑問の声、これを解決するのはサポートの役割ですが、普段「何に関して」「どんな疑問」があって、「どう言う答えを求めていたか」、今までの数十万件のお問い合わせから定量・定性的に測ることが出来ないのか?ひいてはそこからプロダクトのUXにおける「弱点」をあぶり出して、「マジで簡単すぎてヤバイ会計ソフト」freeeをさらに使いやすくすることは出来ないか?この課題意識から、お問い合わせ分析をやってみようと思い、調べてみました。
あくまで業務外でやっていることなので、時間を捻出しながら少しずつやってます。
ちなみに、業務としては、弊社のデータサイエンティスト @fuji_tip (17日のアドベントカレンダー) が着手しているところで、時折テキトーに情報交換しながらやってみてます。
また @kompiro (6日のアドベントカレンダー) がipa辞書の強化の事例を当advent calendar で書いてくれてます。
そしてUXの凶器 @sousuke (先日のアドベントカレンダー) もいろいろやってます。
今回は、チャットログを自動的に内容で分類できるようにするために、自分が試してみた体験を、現在進行形で忘年しながら書いてみます。
やってみた
チャットログを前処理する
チャットログの構造
典型的なチャットログは、以下のような体裁を持っています。
カスタマー: ちょっと伺いたいんですが、aaをbbとして登録したいのですが、ccになってしまいます。どうすればいいですか。
サポート: 少々お待ちくださいませ。
サポート: その場合は、
サポート:取引一覧画面のddより、「ee」を選択した後、再度「bb」を登録してください。
サポート:その後、先ほどの「bb」を除いた額を「cc」として登録します。
カスタマー: なるほど。できました。ありがとうございます。
サポート: 恐れ入ります。
カスタマー: もうひとつ、質問があります。
カスタマー: 今登録している口座にeeがあるのですが、これは正しい状態でしょうか?
サポート: そのようになっている原因としては、ffが考えられます。恐れ入りますが、ggのページよりhhの項目を確認いただき、もしここがiiになっている場合は、以下のサポートページに従って操作頂くと解消できます。
サポート: http://xxxx
カスタマー: ありがとうございました!助かります。
サポート: お役に立てたようであれば何よりです。
サポート: また何か操作でご不明点ございましたらお気軽にお問い合わせくださいませ。
サポート: 本日はお問い合わせいただき、ありがとうございました。
カスタマー: こちらこそありがとうございます!
ここで気付く点としては、
-
チャットの場合、短いセンテンスで文章を区切るので、同じ話者による発言はなるべくひとまとめにする必要がある。
-
一つのやりとりで、複数の話題が含まれることに留意する必要がある。
-
チャットにおける「はい」「いいえ」「そうです」などは、当然、文脈依存になる。
-
freee固有の機能名称と会計用語(勘定科目など)が多用される。
- また、問題の核心にあたる単語は、必ずサポートの発言に含まれると思って間違いないので、その単語が特徴語となりうる。
-
挨拶など、内容とは直接関係の無いフレーズも含まれる。ただし、これをストップワードとして除外するべきかどうかは判断が難しい。
- 例として、お客様の「なるほど」「ありがとうございます」「助かりました」「了解です」などは、課題が解決したことを示すワードとなり得る。
があります。
辞書を鍛える
日本語のテキストマイニングをする上で、「分かち書き」はその基本となり、避けては通れないものです。先ほどの例のようなものを分かち書きをしたときに、mecab標準のipadicでは、以下のような単語を以下のように切り出してしまいます。
{
"仮受金": ["仮", "受", "金'],
"自動で経理": ["自動", "で", "経理"]
}
前者は勘定科目(会計用語)、後者はfreee固有の機能名称です。
これらを単語として認識するために、ユーザ辞書を作ります。
結構地道な作業です。
ストップワードを準備し、分かち書き結果から除外する
一般的すぎて、検索や特徴語抽出の役に立たない単語のことを「ストップワード」と呼びます。
これは何度か分かち書きを試行錯誤しながら、自力でリストアップしていきます。
結構辛い作業です。
以上のことをコードで表現してみる
コード中にもコメントしてあるとおり、あくまで模擬コードなので、参考としてお読みください。
require 'natto' # mecab をFFI経由で呼び出すgem
# ユーザ辞書はすでに作っており、dicrcで指定してあるものとする。
# 以下、説明の便利上 ActiveRecord風の記法で書いてますが、
# お客様のサポートチケットは通常このようにはアクセスできません。
# あくまでも本アドベントカレンダーでの説明用コードです。
# なので正しく動作しない可能性があります。参考としてお読みください。
class Ticket
scope :chat, -> { where(channel: 'chat') }
scope :closed, -> { where(status: 'closed')}
scope :satisfy_or_offered, -> {where(satisfaction: ['good', 'offered'])}
scope :analyze_target, -> { chat.closed.satisfy_or_offered }
def self.sample(n=10)
analyze_target.order('issued_at DESC').limit(n)
end
def convert_body
filtered_body = fileter_out_chat_ctrl_line(self.body)
grouped_body = concat_lines_by_speaker(filtered_body)
morphemed_body = morphemes(grouped_body)
morphemed_body
end
# チャットのシステム表示などを除外
def fileter_out_chat_ctrl_line(body)
body.split("\n").delete_if{|l|
l.blank? ||
l =~ /^\d{4}年(.+)にチャットが開始されました/ ||
l =~ /がチャットに参加しました\*\*\*/ ||
l =~ /left the chat \*\*\*/
}
end
# 同じ発言者の一連の発言を1行にまとめる
def concat_lines_by_speaker(body)
prev_chat_owner = ''
trimmed = []
body.each{|l|
l =~ /^(\([\d\:]+ [AP]M\) )(.+)(\: )(.+)$/
if prev_chat_owner == $2
trimmed[trimmed.size - 1] = "#{trimmed[trimmed.size - 1]} #{$4}"
else
trimmed.push $4
end
prev_chat_owner = $2
}
trimmed.compact.join("\n")
end
# 初期化したNattoインスタンス
def natto
@natto ||= Natto::MeCab.new()
end
# 分かち書き
def morphemes(body)
body.split("\n").map do |line|
line_morphemes = []
natto.parse(line) do |morpheme|
surface = morpheme.surface
# ストップワードは含めない
line_morphemes << surface unless stop_words.include?(surface)
end
line_morphemes.join(" ").gsub(/([ ]+)/, ' ')
end
end
# ストップワード
def stop_words
%w(こと もの ない ある お に 。 、 ? し た ます ... とかとか)
end
end
# 例えばこんな感じで呼び出してみる
def main
Ticket.sample(300).map(&:convert_body)
end
だいたいこんな感じで前処理は完了です。
前処理したチャットログを解析したい
目指すこと(理想)
お客様が日々の操作の上でどのような課題を持っているか、をfreeeらしく全自動で把握したい
目指すこと(現実)
とりまサポートチケットの分類をしたいです...
どのようにやっていくか(方針)
ここからは手探りで現在進行形でぼちぼちとやってます。おそらくこの先はpython, gensim, scikit-learn などに手を染めていかなければならず一夜の祭どころの話ではないのですが、今やっている方向性としては以下の手続きになりそうです。
- 文書のベクトル化
- 分類(教師あり分類またはクラスタリング)
各手順について、軽く紹介してみます。
####文書のベクトル化
まずは各チケットをベクトル表現に落とします。
BOW (Bag Of Words)を作り、次元削減をした上で解析する方法
Bag Of Words を簡単に説明すると、先ほどのチケットの集合で使われている単語を全抽出し、それぞれのチケットで、各単語が何回使われたかを足し上げたものになります。文書をベクトルで表現した形になります。(コーパスとも呼ばれたりしています)
このままだと、ものすごくパラメータが多くなるので(カラムが多すぎて横に伸びきったテーブルを想像してください)、要約します。計算時間や資源の問題もありますが、精度も出にくくなるようです。この辺については「次元の呪い」でググって頂けると幸いです。「要約」(次元削減)の方法としてはLSI, LDAなどがあるようですが、まだその違いを説明できるほど理解をしてないので冬休みの宿題とさせてください。
文書(各チケット)を低次元のベクトル表現で表した後、それを分類器にかけます。
分類
教師あり
すでに相当数のチケットを正しくカテゴライズできているならば、それらを教師データとして分類できそうです。実際に人力によるカテゴライズもなされていますが、それらを正として扱うには、属人的な分類のブレが大きそうです。よって、新規にクラスタリングしていきます。
クラスタリング
似たもの同士に分類していく手法です。デンドログラムを用いた階層的クラスタリングやk-means法などがよく知られているものですが、クラスタをいくつにすべきかを手作業でチューニングしていくことになりそうで、その度に各チケットを目で読んで妥当性を確認するのは辛そうなので、うまい手法を見つけたいものです。
と思っていたところ、k-meanの改良として、妥当なクラスタ数を推定しながらクラスタリングを行うx-means法というのがあるようで、こちらを使って分類を行っていきます。
戦いはこれからだ
ここまで書いてきて 23:50 を回ってしまったので、結果については追記でリバイズしていきます。 @krt 先生の次回作にご期待ください。打ち切りフラグじゃないよ(´・ω・`)
お知らせ
freeeでは、あらゆるものを分析的にとらえて、徹底的にプロダクトをよくしていきたいアプリケーションエンジニアを募集してます!
そしてついに明日は、freee の モバイル王子こと @laprasDrum が、freee advent calendarをお届けします!!これは必読なり!!!!!!