RubyのEnumerable#each_consを使ってN-gramを簡単に作る
タグ: ruby / 初版公開: 2016-10-29 / 最終更新: 2016-11-01

はじめに

RubyではEnumerable#each_consを使って簡単にN-gramを作ることができます。

このエントリはkawasaki.rb #41で、パーフェクトRubyの”5-5-1 Enumerableなオブジェクト”を読んだことをきっかけに書きました。

パーフェクトRuby (PERFECT SERIES 6)
Rubyサポーターズ すがわら まさのり 寺田 玄太郎 三村 益隆 近藤 宇智朗 橋立 友宏 関口 亮一
技術評論社
売り上げランキング: 74,779

N-gram

N-gramは文字列をN文字単位で区切って1要素としたものです。

N-gramは全文検索を高速に行うためのインデックスとして良く使われます。 Nが2のものをbi-gram(バイグラム)、Nが3のものをtri-gram(トライグラム)と呼びます。 Nが4以上は応用例が少なくメジャーでないためか、私は聞いたことがありません。

例えば最初の文章をbi-gramで表現して並べると以下のとおりです。 文字列の先頭からはじめて、1文字ずつずらしながら、2文字単位で1要素としていきます。

N- -g gr ra am mは は文 文字 字列 列を をN N文 文字 字単 単位 位で で区 区切 切っ って て1 1要 要素 素と とし した たも もの ので です す。

Enumerable#each_cons

RubyではEnumerable#each_consを使って、このようなN-gramを簡単に作ることができます。

Enumerable#each_consのRubyリファレンスマニュアルの解説は以下のとおりです。

要素を重複ありで n 要素ずつに区切り、 ブロックに渡して繰り返します。

ブロックを省略した場合は重複ありで n 要素ずつ繰り返す Enumerator を返します。

ほぼ同じメソッドにEnumerable#each_sliceがありますが、”重複ありで”の部分がポイントです。 このメソッドは実際に使ってみないと、なかなか用途に気づきにくいかも知れません。

Enumerable#each_consはRuby 1.8.7や1.9以降では何もしなくても使えます。 Ruby 1.8.6ではenumeratorモジュールで使えるようです。

each_consで文字列をN-gramにする例

文字列を入力にbi-gramを作ってみます。 この例ではbi-gramの各要素を2文字の文字列で表現し、それを配列にして出力することにします。

"I love Ruby!".each_char
              .each_cons(2)
              .map{|chars| chars.join }
#=> ["I ", " l", "lo", "ov", "ve", "e ", " R", "Ru", "ub", "by", "y!"]

簡単ですね。メソッドチェーンを使えば、実質一行で書くことができます。

一応、処理を分解してみます。

まずString#each_charを使って、文字列中の文字を1文字つずつ取り出すEnumeratorオブジェクトを得ます。

EnumeratorオブジェクトはEnumerableの機能を提供するラッパクラスです。 よってEnumerable#each_consに引数2を与えて使えば、2要素ごとに区切った配列を返すEnumeratorが得られます。

このEnumeratorから取り出せる値は['a', 'b']のような配列です。 配列の要素をArray#joinで結合するよう、Enumerable#mapで処理すればbi-gramの配列になります。

Enumerable#each_sliceは引数で与えた任意の要素数で区切ってくれます。 単に引数を3にすれば、tri-gramも同じ要領で作れます。

"I love Ruby!".each_char
              .each_cons(3)
              .map{|chars| chars.join }
#=> ["I l", " lo", "lov", "ove", "ve ", "e R", " Ru", "Rub", "uby", "by!"]

簡単にN-gramが作れるようにStringクラスにto_ngramメソッドを追加してみるとこんな感じでしょうか。

class String
  def to_ngram(n)
    self.each_char
        .each_cons(n)
        .map{|chars| chars.join }
  end
end

"I love Ruby!".to_ngram(2)
#=> ["I ", " l", "lo", "ov", "ve", "e ", " R", "Ru", "ub", "by", "y!"]

"I love Ruby!".to_ngram(3)
#=> ["I l", " lo", "lov", "ove", "ve ", "e R", " Ru", "Rub", "uby", "by!"]

余談

kawasaki.rb #41ではArray#*は引数に文字列を渡すとArray#joinと同じ働きをするので、配列を結合してN-gramにする部分は以下のように書けるという話題もありました。

chars = ['a', 'b']

chars.join #=> "ab"

chars * '' #=> "ab"

玄人感が高まりました。

仮に無駄なスペースを省くと、配列に*''の3文字を足すだけで結合でき、.joinより2文字短くできます。 コードゴルフでは役に立ちそうですが、この例ではjoinを使った方が無難です。

さらに良い書き方

mapのブロック内でjoinするよりmap &:joinを使うと良い、というコメントをTwitterでいただきました。 ご指摘ごもっともです。この書き方でString.to_ngramを書き直すとすっきりしますね。

class String
  def to_ngram(n)
    self.each_char
        .each_cons(n)
        .map(&:join)
  end
end

おわりに

kawasaki.rbは和気あいあいとした楽しい地域Rubyコミュニティです。 次回kawasaki.rb #42は11月30日(水)にJR川崎駅徒歩3分のミューザ川崎で開催予定です。 開催が近づくとConnpassで告知が出ます。

みなさまもぜひお越しください。