fastText

fastTextでesaに書かれた文章を分類する

fastTextでesaに書かれた文章を分類する

最近、機械学習が流行っていますが、画像解析系が多くてあまり興味が出ませんでした。

しかし、Word2vecやseq2seqなどを使ってテキスト処理も機械学習で色々遊べることに最近気がついたので、まずは簡単な所から試してみることにしました。

この文章は

トレタ Advent Calendar 2017 (紹介ブログ)の一環で書いてみました。よければ他の日の記事も見てください。

なにをしよう?

トレタでは社内ドキュメントの管理にesaを使っています。
こういうドキュメント管理で難しいのは文章が散らばることですよね。
同じ「プロジェクトA」の文章でも開発や営業など、部署毎にドキュメントがまとめられていたりします。

しかも同じような単語があちらこちらで使われているため、全文検索だとノイズが多くなり利便性が低くなります。

解決策としては文書毎に正しくタグ付けすることですが、それを徹底するのも現実的ではありません。

そこでFacebookが出している機械学習を使ったテキスト分類を行えるライブラリfastTextを使って、esaに上がっている文章をプロジェクトごとに分類してみることにします。

環境構築

第一の障害は環境構築です。大体新しく始めることはココが問題になるので、Dockerで一気に環境構築できるようにしました。

https://github.com/masuidrive/fasttext1 とDockerをダウンロードして実行してください。

UbuntuにfastText、Ruby、PythonとMecab+ipadic-neologd辞書を入れてあります。このイメージのビルドには結構時間が掛かるので気長にお待ちください。1時間ぐらい掛かったりします。

今後の作業は下記のコマンドの中で行います。

docker-compose run fastext /bin/bash

教師データ選別

機械学習で一番面倒なのは教師データの準備です。ここには質と量の問題があります。
トレタでは約二年分のデータで6600ページほどのデータがありました。
esaはデータをダウンロード出来るのでそれで全件ダウンロードして、そこから使いたいデータをプロジェクトに手で分類します。

今回は12のプロジェクトで分類分けしたいので手でコツコツとプロジェクトごとのフォルダに分けていきます。今回は12種類で下記の様に分けました。

A(15), B(45), C(5), D(50), E(18), F(19), G(22), H(66), I(81), J(35), K(35), L(64)

括弧の中がドキュメント数です。

プロジェクト開発に関するのは全455ドキュメント。もっとあるはずですが6000ページ以上確認するのは難しいのでタイトルと全文検索でざっくりと取り出しました。案外少ないけど大丈夫かなぁ・・・

教師データ作成

次にMarkdownで書かれた文章をfasttextで処理出来る下記の様な形に加工します。

__label__ラベル名 , 単語をスペース区切りにした本文

今回だと1行1ファイルファイルになるので、455行のファイルが出来るはずです。

社内文章は日本語で書かれているので、形態素解析をしてスペースで区切るようにする必要があります。
今回のような文章を判別するときには名詞や動詞だけあれば十分で副詞や形容詞などは必要ありません。なのでフォーマット変更するときに一緒に取り除いてしまいましょう。

という訳で簡単なスクリプト書きました。docsフォルダ以下に、カテゴリ名のフォルダを作りテキストファイルを置いておけば、generatedフォルダに.lstファイルが生成されます。

generatelst.rb
#!/usr/bin/env ruby
require 'natto'

# 元文章と生成したファイルの設置場所
DOCS_DIR = 'docs'
DEST_DIR = 'generated'

# 品詞ID
NOUNS_POSID = (36..67).to_a
VERBS_POSID = (31..33).to_a

# テキストから名詞と動詞だけ取り出す
def pick_words(text)
  words = [](#)
  natto = NattoMeCab.new
  natto.parse(text) do |n|
if !n.is_eos? && (NOUNS_POSID + VERBS_POSID).include?(n.posid)
  words << n.surface.to_s
end
  end
  words
end

# ディレクトリに分けたファイルリスト
files = 
Dir.entries(DOCS_DIR).each do |category|
  path = File.join(DOCS_DIR, category)
  if File.directory?(path) && !%w(. ..).include?(category)
files[category](#) = [](#)
Dir.entries(path).each do |name|
  filename = File.join(path, name)
  if File.file?(filename) && !/^./.match(name)
files[category] << filename
  end
end
  end
end

# カテゴリ毎に.lstファイルを作って単語を並べる
files.each do |category, filenames|
  puts "Parse: #{category}"
  f = open(File.join(DEST_DIR, "#{category}.lst"), 'w')
  filenames.each do |filename|
words = pick_words(IO.read(filename).gsub(',', ' '))
f.puts "__label__#{category}, #{words}.join(' ')"
  end
end

学習させてみる

今回は文書数がかなり少ないので次元数を30にして学習させてみます。
lstファイルは結合して、all.trainを作って学習させてみます。

do_training.sh
#!/bin/sh

ruby generate_lst.rb 
cat generated/*.lst > generated/all.train
/usr/local/fastText/fasttext supervised -input generated/all.train -output generated/all -dim 30 -lr 1.0 -epoch 25

次元数を30に決めたのはドキュメントを読みながら、50を基本に値を変えて試行錯誤しました。今回パラメータの調整が必要な部分はココだけです。
カテゴリの数が増えるならココを増やして調整する必要が出ると思います。

分類してみる

次に学習内容を使って実際に分類してみましょう。

この時にも分類するテキストも教師データと同じように名詞と動詞だけを取り出しておく必要があります。

pick_words.rb
#!/usr/bin/env ruby
require 'natto'

# 品詞ID
NOUNS_POSID = (36..67).to_a
VERBS_POSID = (31..33).to_a

text = STDIN.read.gsub(',', ' '))
words = []
natto = NattoMeCab.new
natto.parse(text) do |n|
  if !n.is_eos? && (NOUNS_POSID + VERBS_POSID).include?(n.posid)
    words << n.surface.to_s
  end
end
print words.join(' ')

このスクリプトを使って下記の様にtest1.mdを分類してみます。

ruby pick_words.rb < test1.md | /usr/local/fastText/fasttext predict-prob generated/all.bin - 3

これで下記の様に上位3分類が出てきます。

__label__C, 0.703125 __label__H, 0.0859375 __label__A, 0.0605469

この文章はプロジェクトCの文章なので当たっていますね。

今後

これを元に自動的にプロジェクトごとのesaリンク集を作ったりすると、あとからプロジェクトを見直すのが楽になるでしょう。

また議事録のように1つのドキュメントの中に複数のプロジェクトの話題がある場合には認識できないので、markdownの構造をみてセクション事に分解して学習させたほうが良い結果が出るような気がします。