洋食の日記

「だ・である」調ではなく「です・ます」調で書きはじめれば良かったなと後悔してる人のブログです

Suikaの未知語処理のバグを修正した

はじめに

Pure Ruby形態素解析Suikaを作成したが、未知語処理がバグだらけで、リリースして1週間で4回もバージョンアップしてしまった。現在 ver. 0.1.4 だが、もう安定して動くはず。

suika | RubyGems.org | your community gem host

未知語処理の勘違い

バグの原因は、もともと試験実装していたスクリプトからの移設ミスだったりタイポだったりもあるが、根本的なところでの勘違いがあった。

MeCabの未知語処理の設定は、char.defに定義されている。このうち、文字種ごとに未知語処理をどう起動するかが定義されている部分がある。IPAdicでは以下のように定義されている(一部抜粋)。

...

KANJI        0 0 2
SYMBOL         1 1 0
NUMERIC        1 1 0
ALPHA        1 1 0
HIRAGANA       0 1 2
KATAKANA       1 1 2

...

行頭は文字種のカテゴリで「KANJI」は漢字を「KATAKANA」は片仮名を表す。その後ろの数字が、未知語処理の起動に重要となる。

文字種カテゴリ    動作タイミング グルーピング 長さ

動作タイミングは、1であれば、常に未知語処理を起動する。0であれば、現在チェックしている文字から始まる既知語がある場合は起動しない。グルーピングは、1であれば、同じ文字種でまとめ、0であれば、まとめない。長さは、まとめるときの未知語の長さを表す。0であれば、長さで未知語をまとめない。

勘違いしていたのは、グルーピングに「1」が指定されていた場合の、長さの指定で、例えば片仮名は以下の様になっているが、

KATAKANA       1 1 2

これの解釈は、片仮名の未知語が来た場合に2文字でまとめる、ではなく、グルーピングが「1」なので、とにかく同じ文字種でまとめる、が正解だった。つまり、グルーピングが「1」の場合には、後ろの長さは「0」を指定した場合と同様となる。片仮名の未知語が、2文字で分割されるので、どうしたものかと思っていたら、ここを勘違いしていた。

未知の文字コードによるバグ

くわえて「㋿」が与えられると、エラーになることがわかった。これは、本当に新しい未知の文字がきたときに、文字種認識メソッドが、nilを返していたのが原因だった。nilではなく、本来返すべき文字種カテゴリの「DEFAULT」を返すよう修正した。あわせて、㋿を他の年号の記号と同様に認識されるようにした。

irb(main):001:0> require 'suika'
=> true
irb(main):002:0> tagger = Suika::Tagger.new
irb(main):003:0> puts tagger.parse('時代は㍻から㋿へ')
時代    名詞,一般,*,*,*,*,時代,ジダイ,ジダイ
は      助詞,係助詞,*,*,*,*,は,ハ,ワ
㍻      名詞,サ変接続,*,*,*,*,*
から    助詞,格助詞,一般,*,*,*,から,カラ,カラ
㋿      名詞,サ変接続,*,*,*,*,*
へ      助詞,格助詞,一般,*,*,*,へ,ヘ,エ
=> nil
irb(main):004:0>

動作確認

Suikaに、様々な文章を与えて、思わぬエラーで落ちることなく処理を終えるかを確認したい。今回は、株式会社ロンウイットが提供する、livedoorニュースコーパスのデータをひたすら与えて、エラーで落ちないかを確認した。

require 'suika'

tagger = Suika::Tagger.new

Dir.glob('ldcc-20140209/text/*/*.txt').each do |filename|
  puts "--- #{filename}"
  File.foreach(filename) do |sentence|
    sentence.strip!
    puts tagger.parse(sentence) unless sentence.empty?
  end
end

これを実行したのが、以下のようになる。結果として、エラーで落ちることなく、無事に完走した。よほど変な文章を与えない限りは、Suikaは落ちないと考える。

f:id:yoshoku:20200711085350g:plain

おわりに

以上により、未知語が与えられるとエラーで落ちることがなくなった。ひととおり完成したので、次は、Suika::Tagger.newをした際の、辞書の読み込みが遅い問題を対処しようと思う。

github.com

SuikaというPure Rubyな形態素解析器を作成した

はじめに

Pure Ruby形態素解析Suikaを作成した。開発中でバッリバリにα版だが、思い切ってリリースすることにした。

suika | RubyGems.org | your community gem host

最も有名な形態素解析器であるMeCabもそうだが、形態素解析器は食べ物の名前がつくことが多い。「Rubyなので赤い食べ物が良いかな」と考えて、文字数とかわいらしさからSuika(スイカ)とした。

使い方

SuikaはPure Rubyで作られているため、MeCabをはじめ特別なライブラリを別途インストールする必要はない。

gem install suika

バッリバリにα版なので、機能はないに等しく、オプションなしのMeCabコマンドと同様となる。

$ irb
irb(main):001:0> require 'suika'
=> true
irb(main):002:0> tagger = Suika::Tagger.new
irb(main):003:0> puts tagger.parse('すもももももももものうち')
すもも  名詞, 一般, *, *, *, *, すもも, スモモ, スモモ
も      助詞, 係助詞, *, *, *, *, も, モ, モ
もも    名詞, 一般, *, *, *, *, もも, モモ, モモ
も      助詞, 係助詞, *, *, *, *, も, モ, モ
もも    名詞, 一般, *, *, *, *, もも, モモ, モモ
の      助詞, 連体化, *, *, *, *, の, ノ, ノ
うち    名詞, 非自立, 副詞可能, *, *, *, うち, ウチ, ウチ
=> nil

Taggerのparseメソッドは、形態素解析した結果を、StringのArrayで返す。

irb(main):004:0> tagger.parse('すもももももももものうち')
=> ["すもも\t名詞, 一般, *, *, *, *, すもも, スモモ, スモモ", "\t助詞, 係助詞, *, *, *, *, も, モ, モ", 
"もも\t名詞, 一般, *, *, *, *, もも, モモ, モモ", "\t助詞, 係助詞, *, *, *, *, も, モ, モ", 
"もも\t名詞, 一般, *, *, *, *, もも, モモ, モモ", "\t助詞, 連体化, *, *, *, *, の, ノ, ノ", 
"うち\t名詞, 非自立, 副詞可能, *, *, *, うち, ウチ, ウチ"]

実装のはなし

MeCabの作者であるTaku Kudoさんが書かれた、形態素解析器に関する本を読んで、勉強のために実装した。 ホントにわかりやすい本で、ホントすごい(語彙)。

形態素解析器の実装では、候補となる単語の検索のための、トライ木の実装が重要になるのだが、Rubyでトライ木を実装したGemがすでにあり(自作してた拙いモノはスッパリ捨てて)これを使うことにした。動作も早く、ホントすごい。

github.com

さらに、単語の品詞推定などには辞書が必要になる。これについては、Pure Pythonで書かれたJanomeという形態素解析器を参考にした (Pure Pythonで動作も軽いJanomeの存在はSuikaを開発する気合を与えてくれた)。

github.com

JanomeはIPAdicによるバイナリ辞書を同梱している。Suikaでも、IPAdicをパースしてRuby Hashにしたりしたモノを持たせることにした。Suika::Tagger.newすると、この同梱したバイナリ辞書を読み込むのだが、ここがとっても遅いので、なんとか改善したい。

おわりに

最初はRumaleの応用も兼ねて、点推定による形態素解析器(単語分割などを機械学習で行う)を遊びで作っていた。作っていると、段々とそれなりに動くものが欲しくなって、ここまで来てしまった。Ruby形態素解析する場合は、FFIMeCabを叩くNattoというGemが有名で、これをオススメする。Suikaは、まだ、遊んでみるには良いけど、使ってはいけない。

Rubyには、PythonのNLTKやSpaCyのような包括的な自然言語処理ライブラリで、コレだ!!というのはまだない。RSpecなどがそうだが、自然言語的にかけることを意識するRubyの文化と、自然言語処理は相性が良いと思っている。Ruby自然言語処理ライブラリを夢見て、Suikaの開発は、少しずつでも続けていきたいと思っている。

github.com

画像をNumo::NArrayで扱えるMagroにフィルタを追加した

はじめに

Rubyの画像処理ライブラリMagroに、フィルタをかけるメソッドを追加し、version 0.3.0としてリリースした。

magro | RubyGems.org | your community gem host

使い方

Magroは画像ファイルの読み書きにlibpngとlibjpegを必要とする。Magroをインストールすると、依存関係でNumo::NArrayがインストールされる。

$ brew install libpng libjpeg
$ gem install magro

フィルタを使うことでエンボス加工っぽいことをしてみる。

require 'magro'

img = Magro::IO.imread('sample.jpg')

kernel = Numo::DFloat[
  [ 1, 2, 1],
  [ 0, 0, 0],
  [-1,-2,-1]
]
img = Magro::Filter.filter2d(img, kernel, scale: 1, offset: 128)

Magro::IO.imsave('emboss.png', img)

Magro::Filter.filter2dメソッドに、画像とフィルタカーネルを渡す。引数としてscaleとoffsetがある。scaleはフィルタカーネルを正規化する値、offsetはフィルタ後に足す値となる。

はてなでアイコンに使用しているハンバーグの画像を、上記スクリプトに入れると次のようになる。

f:id:yoshoku:20200510013338p:plain

おわりに

追加したのは、フィルタをかけるためのメソッドのみで、ぼかしや先鋭化といったメソッドは追加しなかった。最初は、Pillowライクなものも検討したが、あれこれとフィルタを駆使するようなこともないかと思い、すっかり外してしまった。フィルタをかけるための畳み込みの実装には、いわゆるim2colを使用した(画像のブロックを並べて行列表現にすることで、画像とフィルタの畳み込みを行列積で実現する技法)。画像をNumo::NArrayで表現しているので、容易に実装できた。今後もできるだけミニマルに、できるだけRubyで実装してみようと思う。

github.com

画像をNumo::NArrayで扱えるMagroに画像のサイズ変更を追加した

はじめに

Magroという画像をNumo::NArrayで扱える(読み書きできる)Gemを作っていたが、いろいろと時間がとれず更新が滞っていた。

yoshoku.hatenablog.com

せっかくの連休なので、Bilinear補完によるサイズ変更を追加した。基本的なとこから実装するならばNearest Neighbor法であるし、クオリティを重視するならばLanczos法であるが、実装しやすさからBilinear補完にした。OpenCVのresizeのデフォルトがBliinearなのもある。

使い方

Magroは画像ファイルの読み書きにlibpngとlibjpegを必要とする。Magroをインストールすると、依存関係でNumo::NArrayがインストールされる。

$ brew install libpng libjpeg
$ gem install magro

画像の読み込み・サイズ変更・保存までは以下のようになる。

require 'magro'

img = Magro::IO.imread('hoge.png')

resized = Magro::Transform.resize(img, height: 128, width: 128)

Magro::IO.imsave('hoge_resized.png', resized)

おわりに

Bilinear補完によるサイズ変更の実装では、Extensionは使わずにすべてRubyで書いてある。画像をNumo::NArrayで扱える便利さを、自身で体験することになった。次に追加するとすればフィルタかな。

github.com

Rumaleにカーネル判別分析を追加した

はじめに

Rumaleにカーネル判別分析を追加して、これを version 0.18.4 としてリリースした。カーネル判別分析は、version 0.18.0 で追加したフィッシャー判別分析 (Fisher Discriminant Analysis, FDA) を、カーネル法により非線形化したものである。

rumale | RubyGems.org | your community gem host

使い方

Rumaleはgemコマンドでインストールできる。線形代数の計算にNumo::Linalg、実行結果の描画にNumo::Gnuplotを使いたいので、一緒にインストールする。

$ gem install rumale numo-linalg numo-gnuplot

Numo::Linalgのためにopenblasを、Numo::Gnuplotのためにgnuplotをインストールする。

$ brew install openblas gnuplot

カーネル判別分析は、教師あり次元削減手法とも捉えられる。一般に、カーネル判別分析により写像した部分空間で、k-近傍法で分類するとこが想定されている。Rumaleではfitメソッドで学習し、transformメソッドで次元削減を行う。

LIBSVM Dataで公開されているUCI Vowelデータセットを利用して、次元削減してk-近傍法で分類する例を示す。

カーネル判別分析では、カーネル関数の選択と正則化パラメータが、ハイパーパラメータとなる。カーネル関数自体にパラメータがあれば、それも調整する必要がある。

require 'numo/linalg/autoloader'
require 'numo/gnuplot'
require 'rumale'

# 分類器には, 最近傍法を使用する.
est = Rumale::NearestNeighbors::KNeighborsClassifier.new(n_neighbors: 1)

# データセットを読み込み, 訓練・テストデータに分割する.
x, y = Rumale::Dataset.load_libsvm_file('vowel.scale')

x_train, x_test, y_train, y_test = Rumale::ModelSelection.train_test_split(x, y, test_size: 0.4, random_seed: 1)

# 判別分析法で二次元空間に射影し, 部分空間で分類器の性能を評価する.
fda = Rumale::MetricLearning::FisherDiscriminantAnalysis.new(n_components: 2)
fda.fit(x_train, y_train)
z_train = fda.transform(x_train)
z_test = fda.transform(x_test)

est.fit(z_train, y_train)
fda_score = est.score(z_test, y_test)

# カーネル判別分析法で二次元空間に写像し, 部分空間で分類器の性能を評価する.
kfda = Rumale::KernelMachine::KernelFDA.new(n_components: 2, reg_param: 1e-8)

gamma = 0.05
kmat_train = Rumale::PairwiseMetric.rbf_kernel(x_train, nil, gamma)
kfda.fit(kmat_train, y_train)
z_train = kfda.transform(kmat_train)

kmat_test = Rumale::PairwiseMetric.rbf_kernel(x_test, x_train, gamma)
z_test = kfda.transform(kmat_test)

est.fit(z_train, y_train)
kfda_score = est.score(z_test, y_test)

# それぞれの正確度を出力する.
puts "Accuracy (FDA): #{fda_score}"
puts "Accuracy (KFDA): #{kfda_score}"

# 訓練データセットの二次元空間を散布図としてプロットする.
plots = y_train.to_a.uniq.sort.map do |l|
  [z_train[y_train.eq(l), 0], z_train[y_train.eq(l), 1], t: l.to_s]
end

Numo.gnuplot do
  set(terminal: 'png')
  #set(output: 'fda.png')
  set(output: 'kfda.png')
  plot(*plots)
end

これを実行した結果が次のようになる。カーネル判別分析のほうが、優れた分類性能を得られている。

$ ruby kfda.rb
Accuracy (FDA): 0.6445497630331753
Accuracy (KFDA): 0.9146919431279621

部分空間のサンプルを可視化したものが次のようになる。Vowelデータセットは、11クラスからなるデータセットだが、判別分析 (FDA) よりも、カーネル判別分析 (KFDA) のほうが、部分空間で同一クラスのサンプル同士が集まって位置していることがわかる。

f:id:yoshoku:20200411153645p:plain
FDA

f:id:yoshoku:20200411153702p:plain
KFDA

どんなデータセットでも、このように上手く行くわけではないが、判別分析で高い分類性能が得られない場合などに、カーネル判別分析を試してみる価値はある。

おわりに

Rumaleに、version 0.18.0 で判別分析を追加したので、カーネル判別分析も追加してみた。カーネル判別分析は、非線形な教師あり次元削減手法でもあるので、多くの場合で判別分析よりも良い結果を得られると思う。一方で、カーネル関数の選択と、ハイパーパラメータの調整には気を配る必要がある。また、データセットが大きいと、カーネル関数の計算などで計算時間を必要とする。

github.com