MeCabの半角スペース、全角スペース、タブ、改行に対する挙動について

某所でMeCabは半角スペースを無視するというコメントを見かけ、ちょっと疑問に思ったので調べました。そのついでに、スペースと似たような文字(全角スペースやタブ、改行など)についても調査しています。
ちなみに、Pythonラッパーの mecab-python3==1.0.4 で動作確認していますがコマンドラインの生MeCabでも挙動は同様です。

まず前提として、単語(形態素)の途中に半角スペースが入った場合、MeCabはその半角スペースの位置で単語を区切ります。こういう意味ではMeCabは半角スペースを無視しないと言えます。

print(tagger.parse("メロスは激怒した"))
"""
メロス	名詞,一般,*,*,*,*,*
は	助詞,係助詞,*,*,*,*,は,ハ,ワ
激怒	名詞,サ変接続,*,*,*,*,激怒,ゲキド,ゲキド
し	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
た	助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
EOS
"""

# 激怒 の激と怒の間に半角スペースを挟むと単語が割れる
print(tagger.parse("メロスは激 怒した"))
"""
メロス	名詞,一般,*,*,*,*,*
は	助詞,係助詞,*,*,*,*,は,ハ,ワ
激	名詞,サ変接続,*,*,*,*,激,ゲキ,ゲキ
怒	動詞,自立,*,*,五段・ラ行,体言接続特殊2,怒る,イカ,イカ
し	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
た	助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
EOS*,特殊・タ,基本形,た,タ,タ
EOS
"""

これだけ見ると、MeCabは半角スペースを無視しないじゃないか、という話なのですが、実はそれは僕の早とちりで、MeCabが半角スペースを無視するケースはありました。それは単語と単語の間に半角スペースがあった場合です。

今度は「激怒」の前に半角スペースを置いてみます。そして事業をもっと正確に見るために、単語の生起コストと連接コストを出力するようにします。
参考: MeCabの出力形式を変更する
%cが生起コストで、%pcが連接コストです。

# コストを表示する設定でtaggerを生成
tagger = MeCab.Tagger(f"-d {dicdir}/ipadic" +
                      r" -F %m\\t%c\\t%pC\\t%H\\n"
                     )

print(tagger.parse("メロスは激怒した"))
"""
メロス	9461	-283	名詞,一般,*,*,*,*,*
は	3865	-3845	助詞,係助詞,*,*,*,*,は,ハ,ワ
激怒	4467	238	名詞,サ変接続,*,*,*,*,激怒,ゲキド,ゲキド
し	8718	-5350	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
た	5500	-7956	助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
EOS
"""

# 激怒の前(はの後ろ)に半角スペースを挟む
print(tagger.parse("メロスは 激怒した"))
"""
メロス	9461	-283	名詞,一般,*,*,*,*,*
は	3865	-3845	助詞,係助詞,*,*,*,*,は,ハ,ワ
激怒	4467	238	名詞,サ変接続,*,*,*,*,激怒,ゲキド,ゲキド
し	8718	-5350	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
た	5500	-7956	助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
EOS
"""

結果が全く同じになりましたね。注目すべきは、はと激怒の間の連接コスト、238です。
前の単語との連接コストが計算されているのですが、空白との連接コストではなくその前の「助詞,系助詞のは」との連接コストが使われています。
この例では、半角スペースは無視された、と言えるでしょう。

ちなみに、タブ、改行(\n)は半角スペース同様に無視されます。一方で全角スペース、改行(\r\n)は無視されません。(正確には\r\nの\n部分は無視されますが、\rが記号,一般として残ります。)

# タブは無視される
print(tagger.parse("メロスは\t激怒した"))
"""
メロス	9461	-283	名詞,一般,*,*,*,*,*
は	3865	-3845	助詞,係助詞,*,*,*,*,は,ハ,ワ
激怒	4467	238	名詞,サ変接続,*,*,*,*,激怒,ゲキド,ゲキド
し	8718	-5350	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
た	5500	-7956	助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
EOS
"""

# 改行(\n)も無視される
print(tagger.parse("メロスは\n激怒した"))
"""
メロス	9461	-283	名詞,一般,*,*,*,*,*
は	3865	-3845	助詞,係助詞,*,*,*,*,は,ハ,ワ
激怒	4467	238	名詞,サ変接続,*,*,*,*,激怒,ゲキド,ゲキド
し	8718	-5350	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
た	5500	-7956	助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
EOS
"""

# 全角スペースは無視されない
print(tagger.parse("メロスは 激怒した"))
"""
メロス	9461	-283	名詞,一般,*,*,*,*,*
は	3865	-3845	助詞,係助詞,*,*,*,*,は,ハ,ワ
 	1287	-355	記号,空白,*,*,*,*, , , 
激怒	4467	341	名詞,サ変接続,*,*,*,*,激怒,ゲキド,ゲキド
し	8718	-5350	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
た	5500	-7956	助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
EOS
"""

# 改行(\r\n)も無視されない。printすると見えませんが表層形に\r だけ残ります。\nは消えて。
print(tagger.parse("メロスは\r\n激怒した"))
"""
メロス	9461	-283	名詞,一般,*,*,*,*,*
は	3865	-3845	助詞,係助詞,*,*,*,*,は,ハ,ワ
	4769	-71	記号,一般,*,*,*,*,*
激怒	4467	-272	名詞,サ変接続,*,*,*,*,激怒,ゲキド,ゲキド
し	8718	-5350	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
た	5500	-7956	助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
EOS
"""

4パターンとも激怒の前に各記号を入れましたが、無視されるものと考慮されるものに分かれました。全角スペースや\rについては形態素の一つとして扱われ、その次の単語である「激怒」の前の単語との連接コストの計算にも反映されています。

テキストデータを前処理するときに、表記揺れの対応として全角スペースを半角スペースに置き換えたり、改行コードを\r\nを\nに揃えたりといったことをよくやっていたのですが、この操作はその後の形態素解析の結果に影響を与えてしまっていたのですね。
大体その後、改行を空白スペースに置換したりするのですが、これは影響なさそうです。

この改行を無視して、その前の単語との間の連接コストが形態素解析の結果に反映されるというのは僕にとっては非常に驚きでした。(BOSを挿入してるわけではないので、言われてみればそうかという気もしますが。)

例えば、次の2つのテキスト中の「中国語を勉強します。」の形態素分析結果が違う、ということが予想できた人ってあまりいなのではないでしょうか。

text1 = """来月から留学します。
中国語を勉強します。"""
text2= """来月から留学します
中国語を勉強します"""

# 文末に。があるテキストの場合
print(tagger.parse(text1))
"""
来月	5123	-316	名詞,副詞可能,*,*,*,*,来月,ライゲツ,ライゲツ
から	4159	-4367	助詞,格助詞,一般,*,*,*,から,カラ,カラ
留学	5355	2	名詞,サ変接続,*,*,*,*,留学,リュウガク,リューガク
し	8718	-5350	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
ます	5537	-9478	助動詞,*,*,*,特殊・マス,基本形,ます,マス,マス
。	215	-3050	記号,句点,*,*,*,*,。,。,。
中国語	5383	-952	名詞,一般,*,*,*,*,中国語,チュウゴクゴ,チューゴクゴ
を	4183	-4993	助詞,格助詞,一般,*,*,*,を,ヲ,ヲ
勉強	4452	-1142	名詞,サ変接続,*,*,*,*,勉強,ベンキョウ,ベンキョー
し	8718	-5350	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
ます	5537	-9478	助動詞,*,*,*,特殊・マス,基本形,ます,マス,マス
。	215	-3050	記号,句点,*,*,*,*,。,。,。
EOS
"""

# 句読点が省略されたテキストの場合
print(tagger.parse(text2))
"""
来月	5123	-316	名詞,副詞可能,*,*,*,*,来月,ライゲツ,ライゲツ
から	4159	-4367	助詞,格助詞,一般,*,*,*,から,カラ,カラ
留学	5355	2	名詞,サ変接続,*,*,*,*,留学,リュウガク,リューガク
し	8718	-5350	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
ます	5537	-9478	助動詞,*,*,*,特殊・マス,基本形,ます,マス,マス
中国	4757	825	名詞,固有名詞,地域,国,*,*,中国,チュウゴク,チューゴク
語	7810	-7313	名詞,接尾,一般,*,*,*,語,ゴ,ゴ
を	4183	-4541	助詞,格助詞,一般,*,*,*,を,ヲ,ヲ
勉強	4452	-1142	名詞,サ変接続,*,*,*,*,勉強,ベンキョウ,ベンキョー
し	8718	-5350	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
ます	5537	-9478	助動詞,*,*,*,特殊・マス,基本形,ます,マス,マス
"""

text1の方は、「中国語」という単語が登場しましたが、text2の方は、「中国」と「語」という2つの単語に割れましたね。
これは、改行を無視して、その前の単語である「助動詞のます」との連接が考慮された結果になります。「記号,句点の。」と「中国語」の連接コストは小さいですが、「ます」と「中国語」の連接コストは大きい(1436)ので「中国語」という形態素が採用されなかったのです。(中国と語の連接コストが非常に小さく、単語が一つ増えるデメリットがあまりなかったのも要因)

以上をまとめると、以下のようになるでしょうか。
– 全角スペースや\rは他の文字と同じように形態素(単語)として扱われる。
– 半角スペース、タブ、改行(\n)は区切り位置として使われその位置で必ず形態素は切られる。
– 半角スペース、タブ、改行(\n)はそれ自体は形態素としては扱わず結果にも表示されない。
– 半角スペース、タブ、改行(\n)は連接コストの計算時は無視される。

半角スペースも形態素結果に表示してほしいよ、という場合は、表示形式のオプションで、%Mを使うことで表示できます。半角スペースの次の単語の表層系に存在したスペースをくっつけて表示してくれるようです。ただ、正直これを使う場面がすぐには思いつきません。

# %m の代わりに %M を使うと半角スペースも表示される
tagger = MeCab.Tagger(f"-d {dicdir}/ipadic" +
                      r" -F %M\\t%H\\n"
                      )

print(tagger.parse("メロスは 激怒した"))
"""
メロス	名詞,一般,*,*,*,*,*
は	助詞,係助詞,*,*,*,*,は,ハ,ワ
 激怒	名詞,サ変接続,*,*,*,*,激怒,ゲキド,ゲキド
し	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
た	助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
EOS
"""

激怒、の前にスペースが入って半角1文字分字下げされているのがわかりますね。
読みはゲキドだけなので、原型、読み、発音では無視されたままであることもわかります。

NumPyの2次元配列(行列)から三角行列を取り出す

今回もNumPyの小ネタです。特に難しい処理ではないのですが、NumPyの行列(2次元配列)から三角行列を取り出したいことがあります。
自分の場合は、距離行列とか相関行列などに対して、$x_i$と$x_j$の距離や相関と、$x_j$と$x_i$の距離や相関は等しいから一方だけでいいとかそういう状況が多いです。

まぁ、値を取り出すだけなら、2重のforループなどをちゃんと書けば済む話なのですが、前回の記事で紹介したようなちょっとしたテクニックを使いたい場合など、明示的に三角行列を作りたいことがあります。
参考: NumPyの多次元配列から値が大きいn個のインデックスを取得する

自分で実装しても特に難しくないのですが、NumPyに専用関数が用意されていたのでこの記事ではそれを紹介したいと思います。

とりあえず、適当な行列を作って自分でやってみましょう。(実は実務では要素数が数万*数万みたいな巨大行列扱うことがあり、2重for文回すのはちょっと待つので嫌だなと思うのですが、小さい行列ならこれで十分です)

対角成分も0にする、狭義の下三角行列を作る場合は以下のようになるでしょうか。

import numpy as np

# 適当にデータを生成する
np.random.seed(4)
ary = np.random.randint(10, 100, size=(5, 5))
print(ary)
"""
[[56 65 79 11 97]
 [82 60 19 68 65]
 [65 67 46 60 54]
 [48 62 13 10 65]
 [31 31 83 48 66]]
"""

# 上三角成分を0にすることで下三角行列を作る
for i in range(ary.shape[0]):
    for j in range(i, ary.shape[1]):
        ary[i, j] = 0

print(ary)
"""
[[ 0  0  0  0  0]
 [82  0  0  0  0]
 [65 67  0  0  0]
 [48 62 13  0  0]
 [31 31 83 48  0]]
"""

はい、何も難しくなくできましたね。

これを2重のfor文を使わずにやる場合、NumPyには triuとtril というそれぞれ上三角行列と下三角行列を取り出すメソッドが用意されています。
参考:
numpy.triu — NumPy v1.22 Manual
numpy.tril — NumPy v1.22 Manual

とりあえず、動かしてみましょうか。2個目にkっていうオプションの引数(デフォルトは0)を取り、これを調整することで対角成分を残すかどうか、また対角成分に限らずどの斜めラインまで成分を残すかを調整できます。

# もう一回データを生成する
np.random.seed(4)
ary = np.random.randint(10, 100, size=(5, 5))

# 上三角行列
print(np.triu(ary))
"""
[[56 65 79 11 97]
 [ 0 60 19 68 65]
 [ 0  0 46 60 54]
 [ 0  0  0 10 65]
 [ 0  0  0  0 66]]
"""

# k=1とすると、対角成分も消える。(消える行が右上に広がる)
print(np.triu(ary, k=1))
"""
[[ 0 65 79 11 97]
 [ 0  0 19 68 65]
 [ 0  0  0 60 54]
 [ 0  0  0  0 65]
 [ 0  0  0  0  0]]
"""

# k=2とすると、さらに消える(消える行が右上に広がる)
print(np.triu(ary, k=2))
"""
[[ 0  0 79 11 97]
 [ 0  0  0 68 65]
 [ 0  0  0  0 54]
 [ 0  0  0  0  0]
 [ 0  0  0  0  0]]
"""

# k=-1とすると、逆に残す範囲が左下に広がる。より小さい負の数も同様。
print(np.triu(ary, k=-1))
"""
[[56 65 79 11 97]
 [82 60 19 68 65]
 [ 0 67 46 60 54]
 [ 0  0 13 10 65]
 [ 0  0  0 48 66]]
"""

# trilは下三角行列
print(np.tril(ary))
"""
[[56  0  0  0  0]
 [82 60  0  0  0]
 [65 67 46  0  0]
 [48 62 13 10  0]
 [31 31 83 48 66]]
"""

# tril の k=1 は残す範囲が広がる。境界線が右上にスライドするという意味ではtriuと同じ。
print(np.tril(ary, k=1))
"""
[[56 65  0  0  0]
 [82 60 19  0  0]
 [65 67 46 60  0]
 [48 62 13 10 65]
 [31 31 83 48 66]]
"""

# tril で対角成分も消したい場合はk=-1
print(np.tril(ary, k=-1))
"""
[[ 0  0  0  0  0]
 [82  0  0  0  0]
 [65 67  0  0  0]
 [48 62 13  0  0]
 [31 31 83 48  0]]
"""

狭義の三角行列(要するに対角成分も0)を取り出したい時、上三角行列(triu)の時はk=1で、下三角行列(tril)の時はk=-1 ってのがちょっと厄介ですね。まぁ、境界線が上に移動するか下に移動するかと考えるのが誤解が少ないかと思います。

三角行列といえば、NumPyには、tri という 三角行列を生成するメソッドもあります。(これもkという引数を取ります。)
参考: numpy.tri — NumPy v1.22 Manual
このtriで生成した三角行列を使ってマスクすることで、三角行列を作ることも可能です。というより、triuやtril の実装をみるとそういう作りになっています。
参考: triuのソースコード(GitHub)

NumPyのwhereメソッドとか使っていい感じに実装されていいますが、ぶっちゃけ掛け算(要素積)するだけで良いでしょう。

# tri で三角成分が1の三角行列を作れる
print(np.tri(5, k=-1))
"""
[[0. 0. 0. 0. 0.]
 [1. 0. 0. 0. 0.]
 [1. 1. 0. 0. 0.]
 [1. 1. 1. 0. 0.]
 [1. 1. 1. 1. 0.]]
"""

# これを要素積すると、元の行列は下三角行列になる。
print(ary * np.tri(5, k=-1))
"""
[[ 0.  0.  0.  0.  0.]
 [82.  0.  0.  0.  0.]
 [65. 67.  0.  0.  0.]
 [48. 62. 13.  0.  0.]
 [31. 31. 83. 48.  0.]]
"""

# 上三角行列が欲しいなら転置してから掛ける
print(ary * np.tri(5, k=-1).T)
"""
[[ 0. 65. 79. 11. 97.]
 [ 0.  0. 19. 68. 65.]
 [ 0.  0.  0. 60. 54.]
 [ 0.  0.  0.  0. 65.]
 [ 0.  0.  0.  0.  0.]]
"""

さて、最後に処理時間を見ておきましょう。最初に少しぼやいていましたが、2重for文のデメリットは巨大な行列を処理する場合の処理時間です。2万行*2万行(要素数4億)の行列で見てみましょう。

データ生成。

big_ary = np.random.randint(100, size=(20000, 20000))

まず、tirlです。こちら3秒ちょっとで終わりました。

% % time
big_ary_tril = np.tril(big_ary, k=-1)

"""
CPU times: user 1.18 s, sys: 1.33 s, total: 2.5 s
Wall time: 3.25 s
"""

次にfor文を回した場合です。こちらは50秒近くかかっています。もう少しで1分超えますね。

% % time
for i in range(big_ary.shape[0]):
    for j in range(i, big_ary.shape[1]):
        big_ary[i, j] = 0

"""
CPU times: user 45.3 s, sys: 1.59 s, total: 46.8 s
Wall time: 49.5 s
"""

速度、計算効率的にはtirl/triuを使った方が良いということが確認できました。

triu/trilは実装上は全ての要素に対して残すか0埋めするかの判定をかけているのに対し、for文の方は0で埋める要素だけにアクセスしているので、計算量というか演算回数だけならfor文の方が半分以下のはずですが、for文の方が圧倒的に遅いのが不思議です。NumPyのもっとコアな部分で並列処理がかなりしっかりと作り込まれているのでしょう。

NumPyの多次元配列から値が大きいn個のインデックスを取得する

NumPyの多次元配列(といってもここで扱うのは2次元の行列ですが)の要素の中から、値が大きい順に数個取り出して、そのインデックスが欲しいことがありました。
これが多次元ではなく、1次元の配列だったら、argsort使えば一発です。
参考: numpyのarrayを並び替えた結果をインデックスで取得する

多次元になるとargsordだけではうまく動きません。
まともに実装すると、やや手間だったのですが、unravel_index というメソッドを使うとうまく書けたので紹介します。

とりあえず参考に使うデータを用意しておきます。実際はもっと巨大なデータでやったのですが、この記事では結果を確認しやすいように 5*5の行列の25個の要素から上位5要素のインデックスを取得することを目指します。
まずデータを作っておきます。

import numpy as np


np.random.seed(4)
ary = np.random.randint(10, 100, size=(5, 5))
print(ary)
"""
[[56 65 79 11 97]
 [82 60 19 68 65]
 [65 67 46 60 54]
 [48 62 13 10 65]
 [31 31 83 48 66]]
"""

このデータから値が大きい順に5このインデックスを取得することを目指します。もし、欲しいのがインデックスではなく値であればこれは簡単です。ravel() か flatten()、reshape()あたりで1次元に変形してソートするだけです。こんな感じに。

np.sort(ary.ravel())[-5:]
# array([68, 79, 82, 83, 97])

今回の用件では欲しいのは、これらの値が入ってたインデックスです。つまり、
[[1, 3], [0, 2], [1, 0], [4, 2], [0, 4]] を求めたいのです。

単純に argsortすると、行ごとにその行内でソートして結果を返してくれます。
また、axis引数にNoneを指定すると行列全体でソートしてくれるのですが結果が、ravel等で1次元に直した後にargsortしたようなイメージで帰ってきます。

print(np.argsort(ary))
"""
[[3 0 1 2 4]
 [2 1 4 3 0]
 [2 4 3 0 1]
 [3 2 0 1 4]
 [0 1 3 4 2]]
"""

print(np.argsort(ary, axis=None))
"""
array([18,  3, 17,  7, 21, 20, 12, 15, 23, 14,  0, 13,  6, 16,  9, 19,  1,
       10, 24, 11,  8,  2,  5, 22,  4])
"""

axis=Noneの方の結果(小さい順なので最後の5つ、[8, 2, 5, 22, 4]が求める5つの数のインデックス)を元の(5*5)の配列に読み替えると欲しい結果が得られることになります。
1次元でインデックスが8ってことは9番目の要素で、5*5行列の9個目の要素は[1, 4]だ、同様に2は[0, 2]、5は[1, 0] と読み替えていくわけですね。割り算と余りを使って実装できそうです。やってみると次のようになります。

for i in np.argsort(ary, axis=None)[-5:]:
    print([i//5, i%5])

"""
[1, 3]
[0, 2]
[1, 0]
[4, 2]
[0, 4]
"""

上記の変換をあらかじめ用意されたメソッドで行うのが、この記事冒頭で名前を出した unravel_index です。
ドキュメント: numpy.unravel_index — NumPy v1.22 Manual

動かしてみます。

# 以下の引数を渡していることに注意して見てください。
# np.argsort(ary, axis=None)[-5:] = [ 8  2  5 22  4]
# ary.shape = (5, 5)

print(np.unravel_index(np.argsort(ary, axis=None)[-5:], ary.shape))
# (array([1, 0, 1, 4, 0]), array([3, 2, 0, 2, 4]))

かなり近いところまで来ましたね。最終的な結果が二つのarrayからなるタプルになっていますが、それぞれから1個ずつ値を取り出してペアにすれば欲しい結果になっています。

人間が見るには微妙に扱いにくい形に見えるのですが、次のように値を取り出すのに使えます。

index_list = np.unravel_index(np.argsort(ary, axis=None)[-5:], ary.shape)
print(ary[index_list])
# [68 79 82 83 97]

今回はインデックスの方が欲しかったので、これを人が見ても見やすい形にするため、vstackして、さらに転置(.T)しても良いでしょう。

print(np.vstack(index_list).T)
"""
[[1 3]
 [0 2]
 [1 0]
 [4 2]
 [0 4]]
"""
# 以下のように書けば1行で可能。
print(np.vstack(np.unravel_index(np.argsort(ary, axis=None)[-5:], ary.shape)).T)

以上で欲しかった結果が得られました。unravel_indexがちょっと馴染みが薄かったのでまだ慣れないのですが、個人的にはこれが一番いいのではないかと思います。

大き方から順ではなく、小さい方からとたい、って時は、argsortに対するスライスの部分を少し変えるだけで対応できます。([-5:]ではなく[:5]に。)

ちなみに、argsort/ unravel_index/ vstack など少々マイナーなメソッドをいくつも使うのが嫌な場合は次のような方法もあります。多次元の配列を、インデックスと値のペアのようなデータ構造に変換してソートするやり方です。

# インデックスと値を組にしたデータにする。
lil_ary = [(i, j, ary[i, j]) for i in range(ary.shape[0]) for j in range(ary.shape[1])]
print(lil_ary[:5])  # 中身を一部確認
# [(0, 0, 56), (0, 1, 65), (0, 2, 79), (0, 3, 11), (0, 4, 97)]

# 3番目の要素(indexは2)をキーにしてソート
lil_ary.sort(key=lambda x: x[2])
# 最後の5項目を取る
print(lil_ary[-5:])
# [(1, 3, 68), (0, 2, 79), (1, 0, 82), (4, 2, 83), (0, 4, 97)]

あとはこれの、タプルのそれぞれの先頭2要素を取り出せば欲しかったインデックスです。
何をやっているのかはこちらの方がわかりやすいかも、と思っていますがこのやり方はメモリ効率などの観点でデメリットもあるので巨大なデータへの適用はお勧めしません。

gensim の phrases で用意されているスコア関数について

前回の記事から引き続き、gensim の phrases モデルの話です。
参考(前回の記事): gensimでフレーズ抽出
参考(公式ドキュメント): models.phrases – Phrase (collocation) detection — gensim
参考(Githubのソース): gensim/phrases.py at master · RaRe-Technologies/gensim · GitHub

前回の記事でこのモデルの使い方を紹介しました。このモデルは連続して出現した単語のペアに対してスコアを計算して、そのスコアが指定した閾値を超えたらその単語のペアをフレーズとして抽出しているのでした。この時に使われているスコアの計算式を具体的に見ていこうという記事です。このスコアの計算式は用意されているものが2種類と、後自分で作ったオリジナルの関数もつかえますが一旦用意されている2種類を見ていきます。

データは前回の記事と同じものを使います。形態素解析(分かち書き)まで終わったデータが、データフレームに格納されているものとします。

計算に使われる引数について

当然計算式はそれぞれ違うのですが、関数に渡される引数は共通です。(とはいえ、Pythonの実装上引数として渡されるだけで、両方が全部使っているわけではありません。)
それらの値について最初に見ておきましょう。
ドキュメント、もしくはソースコードを見ると以下のように6個の値を受け取っていますね。

def npmi_scorer(worda_count, wordb_count, bigram_count, len_vocab, min_count, corpus_word_count):
# 実装は略

def original_scorer(worda_count, wordb_count, bigram_count, len_vocab, min_count, corpus_word_count):
# 実装は略

説明と動作確認のため、適当にモデルを学習させておきます。

from gensim.models.phrases import Phrases


phrase_model = Phrases(
    sentences=df["tokens"],  # 学習するデータ
    min_count=20,  # 最低何回出現した単語および単語ペアを対象とするか。デフォルト5
    scoring='default',  # スコアリングに用いる関数。 "default", "npmi", もしくは自作の関数を指定。
    threshold=1000,  # スコアが何点を超えたらフレーズとみなすか。でフォルト10.0
)

それでは、順番に引数を見ていきます。

まず、 worda_count / wordb_count はそれぞれ1つ目、2つ目の単語が出てきた回数です。
モデルでいうと、 phrase_model.vocab から取得されます。それぞれの単語が出てきたテキスト数ではなく、出てきた回数であるところが注意が必要です。(1つのテキストに5回出てきたらそれで5と数えられます。)

bigram_count は 1つ目の単語と2つ目の単語が連続して登場した回数です。これも同様に連続して登場したテキスト数ではなく回数です。

len_vocab は vocab 辞書の要素数です。コードでいうとlen(phrase_model.vocab)。ユニグラムとバイグラムを両方数えた語彙数になります。前回の記事でいうと、{“キン”: 81, “ドル”: 111, “キン_ドル”: 81} となっていたらこれで3と数えます。 モデルを学習する時、 min_count で一定回数以下しか出現しなかった単語を足切りしますが、このvocab 作成時は足切りが行われません。1回でも出現したユニグラム、バイグラムが全部数えられるので注意が必要です。

min_count はシンプルに、モデル学習時に指定したバイグラムの最低出現回数です。ちなみに、min_count と全く同じ回数だけ出現した単語は対象に含まれるようですが、スコア関数がデフォルトのoriginal_scoreの場合は定義からスコアが絶対に0になるので結果的に抽出されません。

corpus_word_count は学習したテキストの単語の数を単純に足したものです。次のコードの二つの値が一致することからわかります。

print(phrase_model.corpus_word_count)
# 592490

print(df.tokens.apply(len).sum())
# 592490

さて、スコア計算に使われる6個の値が確認できたところで、順番にスコアの定義を見ていきましょう。

オリジナルスコア

scoring=’default’ と指定された時に採用されるのが、original_scorer です。
以下の論文で提唱されたものをベースとしています。
参考: Distributed Representations of Words and Phrases and their Compositionality

ドキュメントによると次の式で定義されています。
$$
\frac{(\text{bigram_count}-\text{min_count})\times \text{len_vocab}}{\text{worda_count}\times\text{wordb_count}}
$$

シンプルでわかりやすいですね。バイグラムが最小出現回数に比べてたくさん出現するほどスコアが伸びるようになっています。一方でユニグラムでの出現回数が増えるとスコアは下がります。出現した時は高確率で連続して出現するという場合に高くなるスコアです。
len_vobabを掛けているのはイマイチ意図が読めないですね。両単語が全く関係ないテキストをコーパスに追加していくとスコアが伸びていってしまいます。
また、min_countがスコアの計算に使われているので、min_countを変えると足切りラインだけでなくスコアも変わる点も個人的にはちょっとイマイチかなと思いました。

ソースコードも一応見ましたが、定義そのままですね。

def original_scorer(worda_count, wordb_count, bigram_count, len_vocab, min_count, corpus_word_count):
    denom = worda_count * wordb_count
    if denom == 0:
        return NEGATIVE_INFINITY
    return (bigram_count - min_count) / float(denom) * len_vocab

もう何回も使っている”キン_ドル”を例に、モデルが計算した結果と、定義通りの計算結果が一致することを見ておきましょう。

# モデルが実際に計算したスコア
print(phrase_model.export_phrases()["キン_ドル"])
# 1062.89667445223

# 計算に使われた値たち
print(len(phrase_model.vocab))
# 156664
print(phrase_model.vocab["キン"])
# 81
print(phrase_model.vocab["ドル"])
# 111
print(phrase_model.vocab["キン_ドル"])
# 81
print(phrase_model.min_count)
# 20

# 定義に沿った計算結果
print((81-20)*156664/(81*111))
# 1062.89667445223

 以上で、デフォルトのオリジナルスコアが確認できました。次はNPMIスコアです。

NPMIスコア

scoring=’npmi’ と指定すると使われるのがnpmi_scorerです。
以下の論文で提唱されたものをもとにしています。
参考: Normalized (Pointwise) Mutual Information in Colocation Extraction

ドキュメントによると定義は次の式です。
$$
\frac{\ln{(prob(\text{worda}, \text{wordb}) / (prob(\text{worda})\times prob(\text{wordb}))))}}{-\ln{(prob(\text{worda}, \text{wordb}))}}
$$

ここで、
$$
prob(\text{word}) = \frac{\text{word_count}}{\text{corpus_word_count}}
$$
だそうです。
僕は、だいたい想像つくけど、$prob(\text{worda}, \text{wordb})$の定義も書けや、と思いました。
ソースを読んだ限りでは、以下の定義のようです。
$$
prob(\text{worda}, \text{wordb}) = \frac{\text{bigram_count}}{\text{corpus_word_count}}
$$

分子と分母の両方にバイグラムの出現割合が登場するので直感的には少しわかりにくいですね。分母にマイナスがついているのも理解をややこしくしています。

注意点として、このスコアは-1〜1の範囲(もしくは-inf)で値を返します。モデルの閾値はデフォルト10ですが、これは明らかにoriginal_scorerを使うことを想定しているので、これを使う時は閾値も合わせて調整しなければなりません。

一応ソースコードも見ておきましょう。

def npmi_scorer(worda_count, wordb_count, bigram_count, len_vocab, min_count, corpus_word_count):
    if bigram_count >= min_count:
        corpus_word_count = float(corpus_word_count)
        pa = worda_count / corpus_word_count
        pb = wordb_count / corpus_word_count
        pab = bigram_count / corpus_word_count
        try:
            return log(pab / (pa * pb)) / -log(pab)
        except ValueError:  # some of the counts were zero => never a phrase
            return NEGATIVE_INFINITY
    else:
        # Return -infinity to make sure that no phrases will be created
        # from bigrams less frequent than min_count.
        return NEGATIVE_INFINITY

これも一応、モデルの計算結果と自分で計算した値を突き合わせておきます。

import numpy as np  # logを使うためにimport

# npmi をtukausetteidemoderuwogakusyuu
phrase_model = Phrases(
    sentences=df["tokens"],  # 学習するデータ
    min_count=20,  # 最低何回出現した単語および単語ペアを対象とするか。デフォルト5
    scoring='npmi',  # スコアリングに用いる関数。 "default", "npmi", もしくは自作の関数を指定。
    threshold=0.8,  # スコアが何点を超えたらフレーズとみなすか。でフォルト10.0
)

# モデルが計算したスコア
print(phrase_model.export_phrases()["キン_ドル"])
# 0.9645882456014411

# 計算に使われた値たち
print(phrase_model.vocab["キン"])
# 81
print(phrase_model.vocab["ドル"])
# 111
print(phrase_model.vocab["キン_ドル"])
# 81
print(phrase_model.corpus_word_count)
# 592490

# 定義に沿って計算
pa = 81/592490
pb = 111/592490
pab = 81/592490
print(np.log(pab / (pa * pb)) / -np.log(pab))
# 0.9645882456014411

想定通りの結果になりましたね。

これで、gensimのphrasesモデルでフレーズ抽出に使われているスコアの計算式が理解できました。

どういうスコアなのかが分かればそれをもとに閾値を適切に決めれるのでは、という期待があったのですが、正直、この計算式だからこうだみたいな目安はまだあまり見えてきませんでした。

一回 min_countと閾値を両方ともものすごく低い値にして学習し、スコアの分布を見たり、だいたい何単語くらい抽出したいのかといったことをかがえて何パターンか試して使っていくのが良いのかなと思います。

gensimでフレーズ抽出

以前このブログで、テキストデータ中のよく連続する単語を検出するコードを紹介しました。
参考: Pythonを使ってよく連続する文字列を検索する

これは単純にある単語の前か後に出現しやすい単語を探すだけのコードだったのですが、実は同じような目的のモデルでもう少しスマートなロジックで実装されたものがgensimにあることがわかったのでそれを紹介します。

なお、今回の記事は以下のバージョンのgensimで動かすことを前提とします。

$ pip freeze | grep gensim
gensim==4.1.2

僕は複数開発環境を持っているのですが、gensim==3.8.0 など、3系の環境と、今使っている4系の環境で細かい挙動が色々異なり少し手こずりました。(会社のMacで動いたコードが私物のMacで動きませんでした。)
今回紹介するモデルに限らず、githubのgensimのリポジトリのWikiにマイグレーションガイドが出てるので、gensimを頻繁に使われる方は一読をお勧めします。
参考: Migrating from Gensim 3.x to 4 · RaRe-Technologies/gensim Wiki · GitHub

前置きが長くなりました。今回紹介するのは、gensimのphrasesです。
ドキュメント: models.phrases – Phrase (collocation) detection — gensim

要は、分かち書き済みの文章から学習して、頻繁に連続する2単語をフレーズとして抽出してくれるモデルです。
「頻繁に連続する」の基準として、僕が以前の記事で紹介したような単純な割合ではなく、論文で提唱されている手法(を元にした関数)を使ってスコアリングし、そのスコアが閾値を超えたらフレーズとして判定するという手法が採られています。(デフォルトで使われるのは1個目の方です。2個目はオプションで使うことができます。)
参考:
– Distributed Representations of Words and Phrases and their Compositionality
– Normalized (Pointwise) Mutual Information in Collocation Extraction” by Gerlof Bouma

今回の記事は使い方をメインで扱いたいので、このスコアリング関数については次の記事で紹介しましょうかね。

早速使っていきましょう。まず学習させるデータの準備です。以前用意したライブドアニュースコーパスを使います。今回はお試しで、そんなたくさんのデータ量いらないので、「ITライフハック」のデータだけ使います。
参考: livedoorニュースコーパスのファイルをデータフレームにまとめる

上記の記事で作ったCSVデータの読み込みと、分かち書きまでやっておきます。

import subprocess
import pandas as pd
import MeCab


# データの読み込み
df = pd.read_csv("./livedoor_news_corpus.csv")
# 今回は"it-life-hack" だけ使う
df = df[df.category=="it-life-hack"].reset_index(drop=True)
# ユニコード正規化とアルファベットの小文字統一
df.text = df.text.str.normalize("NFKC").str.lower()

# 辞書のパス取得
dicdir = subprocess.run(["mecab-config", "--dicdir"], capture_output=True, text=True).stdout.strip()
# 今回は品詞情報も原型変換も行わないので -Owakati で実行する。
tagger = MeCab.Tagger(f"-Owakati -d {dicdir}/ipadic")


# 分かち書きした結果を配列で返す関数
def mecab_tokenizer(text):
    return tagger.parse(text).split()


# 動作確認
print(mecab_tokenizer("すもももももももものうち"))
# ['すもも', 'も', 'もも', 'も', 'もも', 'の', 'うち']

# 分かち書き
df["tokens"] = df.text.apply(mecab_tokenizer)

これで、各テキストを分かち書きして配列にしたものがdf[“tokens”]に入りました。 (scikit-learnの場合は空白区切りの文字列にしますが、gensimの場合は単語を要素とする配列でデータを用意します。)
早速Phrasesモデルを作ります。

デフォルトのスコア関数、閾値はかなり大きめの1000で学習してみます。(あまりたくさんフレーズを見つけられても、この記事ではどうせ紹介できないのでかなり絞っています。デフォルトは10なので、通常の利用では1000は大きすぎです。)

from gensim.models.phrases import Phrases


phrase_model = Phrases(
    sentences=df["tokens"],  # 学習するデータ
    min_count=20,  # 最低何回出現した単語および単語ペアを対象とするか。デフォルト5
    scoring='default',  # スコアリングに用いる関数。 "default", "npmi", もしくは自作の関数を指定。
    threshold=1000,  # スコアが何点を超えたらフレーズとみなすか。でフォルト10.0
)

さて、これで学習ができました。学習した語彙は vocab プロパティが持っています。

phrase_model.vocab
"""
{'マイクロンジャパン': 2,
 'は': 12486,
 'マイクロンジャパン_は': 1,
 '、': 21839,
 'は_、': 3765,
 '従来': 138,
 '、_従来': 54,
 'の': 25248,
 '従来_の': 68,
# 以下略
"""

単語とその単語の出現回数に加えて、アンダーバーで二つの単語を繋いだbi-gram について、その出現回数の辞書となっています。(4系のgensimではvocabが単純な辞書ですが、実は3系では違ったのですよ。gensimオリジナルの型でしたし、単語はエンコーディングされていました。)

このモデルが結果的に見つけてくれたフレーズは、export_phrases()メソッドで取得することができます。(これも3系4系で挙動が違うメソッドです。)

phrase_model.export_phrases()
"""
{'ガ_ジェット': 1793.2403746097816,
 'インター_フェイス': 1398.7857142857142,
 '池田_利夫': 1409.4339100346021,
 '岡本_奈知': 1444.5520523497917,
 'ジャム_ハウス': 1377.0331304935767,
 'エヌプラス_copyright': 1409.4339100346021,
 'all_rights': 1259.6684809500248,
 'rights_reserved': 1393.045143638851,
 '上倉_賢': 1367.7015873015873,
 'キン_ドル': 1062.89667445223,
# 以下略
"""

見つけたフレーズと、そのフレーズのスコアの辞書として結果が得られます。

あの単語と、この単語の組み合わせのスコアって何点だったのかな?と思ったら、scoringメソッドで調べられます。気になるフレーズが検出されなかったら見てみましょう。

引数は結構たくさん渡す必要あります。まずヘルプ見てみましょう。

phrase_model.scoring?
"""
Signature:
phrase_model.scoring(
    worda_count,
    wordb_count,
    bigram_count,
    len_vocab,
    min_count,
    corpus_word_count,
)
"""

試しに、「キン_ドル」で1062.89… であることを見ておきましょうかね。worda_count とかは先に述べた通り、vocabから拾ってこれます。コーパスの単語数頭の情報はモデルが持ってるのでそこからとりましょう。

phrase_model.scoring(
    phrase_model.vocab["キン"],
    phrase_model.vocab["ドル"],
    phrase_model.vocab["キン_ドル"],
    len(phrase_model.vocab),
    phrase_model.min_count,
    phrase_model.corpus_word_count
)
# 1062.89667445223

学習に使ったデータとは別のテキストから、学習済みのフレーズを検索することもできます。

sample_data = [
    ['アマゾン', 'の', '新しい', 'ガ', 'ジェット'],
    ['新しい', 'キン', 'ドル', 'を', '買い', 'まし', 'た'],
]

print(phrase_model.find_phrases(sample_data))
# {'ガ_ジェット': 1793.2403746097816, 'キン_ドル': 1062.89667445223}

また、次のようにdictのようにモデルを使うと、渡されたデータ内で見つけたフレーズを _ で連結してくれます。結果がジェネレーターで帰ってくるので、listを使って配列にしてからprintしました。これはとても便利な機能なのですが、このような辞書的な呼び出し方ではなく、transformか何か名前のあるメソッドにしてほしかったですね。

print(list(phrase_model[sample_data]))
# [['アマゾン', 'の', '新しい', 'ガ_ジェット'], ['新しい', 'キン_ドル', 'を', '買い', 'まし', 'た']]

ちなみに、 _ だと不都合がある場合は、モデル学習時に delimiter 引数で違う文字を使うこともできます。

スコア関数を変えたり、閾値を変えたらり、また、スコア関数の中でmin_countなども使われていますので、この辺の値を変えることで結果は大きく変わります。なかなか面白いので色々試してみましょう。

また、このモデルを重ねがけするように使うことで、3単語以上からなるフレーズを抽出することもできます。(閾値などの調整に少々コツが必要そうですが。)
そのような応用もあるので、なかなか面白いモデルだと思います。

scikit-learnでテキストをBoWやtfidfに変換する時に空白以外の場所で単語を区切らないようにする

以前、scikit-learnのテキスト系の前処理モデルである、CountVectorizer
TfidfVectorizer において、1文字の単語を学習結果に含める方法の記事を書きました。
参照: scikit-learnでテキストをBoWやtfidfに変換する時に一文字の単語も学習対象に含める

この記事の最後の方で、以下のようなことを書いてました。

これ以外にも “-” (ハイフン) などが単語の境界として設定されていて想定外のところで切られたり、デフォルトでアルファベットを小文字に統一する設定になっていたり(lowercase=True)と、注意する時に気をつけないといけないことが、結構あります。

このうち、ハイフンなどの空白以外のところで単語を切ってしまう問題はワードクラウドで可視化したり単語の出現頻度変化を調べたりする用途の時に非常に厄介に思っていました。機械学習の特徴量を作る時などは最終的な精度にあまり影響しないことが多いので良いのですが。

これを回避するスマートな方法を探していたのですが、それがようやく分かったので紹介します。

前の記事でも行ったように、token_patternの指定で対応を目指したわけですが、以下のコード中の\\\\b の部分をどう調整したら良いのかがわからず苦戦していました。
このbは、\\\\wと\\\\Wの境にマッチするという非常に特殊な正規表現ですが、これをどうすれば空白と\\\\wの間にマッチさせらるのかが分からなかったのです。

# 1文字の単語も学習する設定
bow_model = CountVectorizer(token_pattern='(?u)\\b\\w+\\b')

それがあるとき気づいたのですが、この\\\\b とついでに(?u)は無くても動作変わらないんですよ。

先にそれを紹介しておきます。以前作ったニュースコーパスのデータでやってみます。
参考: livedoorニュースコーパスのファイルをデータフレームにまとめる

import MeCab
import subprocess
import pandas as pd
from sklearn.feature_extraction.text import CountVectorizer

# データの読み込み
df = pd.read_csv("./livedoor_news_corpus.csv")
# ユニコード正規化とアルファベットの小文字統一
df.text = df.text.str.normalize("NFKC").str.lower()
# 改行コードを取り除く
df.text = df.text.str.replace("\n", " ")


# 辞書のパス取得
dicdir = subprocess.run(["mecab-config", "--dicdir"], capture_output=True, text=True).stdout.strip()
# 今回は品詞による絞り込みも原型への変換も行わないので -Owakati で実行する。
tagger = MeCab.Tagger(f"-Owakati -d {dicdir}/ipadic")


# 分かち書きした結果を返す。
def mecab_tokenizer(text):
    # 末尾に改行コードがつくのでstrip()で取り除く
    return tagger.parse(text).strip()


# 動作確認
print(mecab_tokenizer("すもももももももものうち"))
# すもも も もも も もも の うち

# 分かち書き
df["tokens"] = df.text.apply(mecab_tokenizer)

# 普段指定している token_pattern で学習
bow_model_1 = CountVectorizer(token_pattern='(?u)\\b\\w+\\b', min_df=10)
bow_model_1.fit(df["tokens"])
# 学習した語彙数
print(len(bow_model_1.vocabulary_))
# 15332

# # token_pattern を \\w+ だけにしたもの
bow_model_2 = CountVectorizer(token_pattern='\\w+', min_df=10)
bow_model_2.fit(df["tokens"])
# 学習した語彙数
print(len(bow_model_2.vocabulary_))
# 15332

語彙数が同じなだけでなく、学習した単語の中身も全く同じです。

# 学習した単語は一致する
set(bow_model_1.get_feature_names()) == set(bow_model_2.get_feature_names())
# True

さて、本題に戻ります。\\\\w+ でこれにマッチする単語が抜き出せるとなれば、単純にスペース以外にマッチする正規表現を書いてあげれればそれで解決です。
“[^ ]+” (ハット”^”と閉じ大括弧”]”の間にスペースを忘れないでください)や、\\\\S+ などを使えばOKです。
それぞれ試しておきます。

bow_model_3 = CountVectorizer(token_pattern='\\S+', min_df=10)
bow_model_3.fit(df["tokens"])
print(len(bow_model_3.vocabulary_))
# 15479

bow_model_4 = CountVectorizer(token_pattern="[^ ]+", min_df=10)
bow_model_4.fit(df["tokens"])
print(len(bow_model_4.vocabulary_))
# 15479

学習した語彙数が増えましたね。

「セ・リーグ」とか「ウォルト・ディズニー」が「・」で区切られずに学習されているのがわかりますよ。

print("セ・リーグ" in bow_model_1.get_feature_names())  # \\w+のモデルでは学習されていない。
# False

print("セ・リーグ" in bow_model_3.get_feature_names())  # \\S+のモデルでは学習されている
# True

print("ウォルト・ディズニー" in bow_model_1.get_feature_names())  # \\w+のモデルでは学習されていない。
# False

print("ウォルト・ディズニー" in bow_model_3.get_feature_names())  # \\S+のモデルでは学習されている
# True

これで、変なところで区切られずにMeCabで切った通りの単語で学習ができました。

ただし、この方法でもデメリットがないわけではありません。
\\\\w+ にはマッチしないが、\\\\S+にはマッチする文字がたくさん存在するのです。
要するに\\\\Wにマッチする文字たちのことです。

「?」や「!」などの感嘆符や「◆」のようなこれまで語彙に含まれなかった文字やそれを含む単語も含まれるようになります。これらが不要だという場合は、分かち書きする前か後に消しておいた方が良いでしょう。

もしくは逆に、不要に切ってほしくない文字は「- (ハイフン)」と「・(中点)」だけなんだ、みたいに特定できているのであれば、token_pattern=”[\\w\-・]+” みたいに指定するのも良いと思います。(ハイフンは正規表現の[]内で使うときは文字の範囲指定を意味する特殊文字なのでエスケープ必須なことに気をつけてください。)

いずれにせよ、結果を慎重に検証しながら使った方が良さそうです。

PythonでMeCabを動かそうとしたらmecabrc ファイルが無いというエラーが出たので原因を調べた

会社と私物でそれぞれMacbookを持っていて、AWSのアカウントとそこで動くEC2インスタンスもそれぞれあり、さらにDocker等含めていくつもPython環境を使っています。その中の一つで、突然PythonからMeCabが動かせなくなってしまったのでその解決方法のメモです。

具体的にはそれまで普通にMeCabが動かせていた環境にもかかわらず、次の様なエラーが出るようになってしまいました。MeCabをインポートして、MeCab.Tagger()するだけでエラーになるのでお手上げ状態でした。

>>> import MeCab
>>> MeCab.Tagger()

Failed initializing MeCab. Please see the README for possible solutions:

    https://github.com/SamuraiT/mecab-python3#common-issues

If you are still having trouble, please file an issue here, and include the
ERROR DETAILS below:

    https://github.com/SamuraiT/mecab-python3/issues

issueを英語で書く必要はありません。

------------------- ERROR DETAILS ------------------------
arguments:
error message: [ifs] no such file or directory: {Macのユーザー名を含む長いPathなのでマスク}/site-packages/unidic/dicdir/mecabrc

この事象が発生した環境には、MeCabは正常にインストールされていて、元々 MeCab.Tagger() も正常に実行できていました。

mecabrc ファイルも $ mecab-config –sysconfdir で取得できる場所、
つまり、/usr/local/etc/mecabrc にしっかり配置されているのに、全然違う .pyenv が管理してる各種ライブラリの配置場所を見に行ってそこに mecabrc が無いというエラーを起こしています。

原因調査編

確認してみると確かに site-packages ディレクトリ配下に mecabrc ファイルはありませんでした。というよりも、site-packages/unidic/dicdir 自体がありませんでした。

ネットで検索するとこのエラーメッセージで表示されたパスに /usr/local/etc/mecabrc をコピーして配置するという応急処置を取っている人がいましたが、ここは pipやcondaなどのパッケージ管理システムが管理している場所なので軽はずみに手動でいじりたくはありません。ということで腰を入れて原因と対策を調査しました。

結果、以下のことが原意で起きてるのがわかりました。
– transformers を、ドキュメントに沿って、[ja] というオプション付きで入れた。
-依存パッケージとしてunidic(MeCabの辞書の一つ)が一緒にインストールされた。
– unidicはインストールしただけでは辞書本体がダウンロードされない。
参考: unidic · PyPI
– 最近のmecab-python3はunidicを優先的に使おうとする。
(事象が発生したのは mecab-python3==1.0.4。mecab-python3==0.996では発生しない。)

transformers というのはBert等の学習済みモデルを手軽に使えるパッケージですね。インストール時にtransformers[ja] として入れると、日本語モデルを使うためのパッケージも一緒に入れてくれます。

これをやった時に、unidicというパッケージが入ったのです。
そして、unidicのドキュメントにある通り、この時点では辞書本体は端末にダウンロードされていませんでした。

しかし、インストールはされているので、Pythonコード上で、 import unidic は成功するし、unidic.DICDIR という値も取得できるわけです。(しかしそのディレクトリに辞書本体は無い。)

そしてさらに、このエラーが発生した環境のmecab-python3は割と最近の version 1.0.4 が入っていたのです。

mecab-python3のリポジトリで、 Add support for unidic installs via pypi というコミット を見ていただくとわかりやすいと思うのですが、この修正以降、 unidicが import できたら unidic を使おうとするようになっています。

def try_import_unidic():
    """Import unidic or unidic-lite if available. Return dicdir.
    This is specifically for dictionaries installed via pip.
    """
    try:
        import unidic
        return unidic.DICDIR
    except ImportError:
        try:
            import unidic_lite
            return unidic_lite.DICDIR
        except ImportError:
            # This is OK, just give up.
            return


class Tagger(_MeCab.Tagger):
    def __init__(self, rawargs=""):
        # First check for Unidic.
        unidicdir = try_import_unidic()
        args = rawargs
        if unidicdir:
            mecabrc = os.path.join(unidicdir, 'mecabrc')
            args = '-r "{}" -d "{}" '.format(mecabrc, unidicdir) + args

        # The first argument here isn't used. In the MeCab binary the argc and
        # argv from the shell are re-used, so the first element will be the
        # binary name.
        args = ['', '-C'] + shlex.split(args)

        # need to encode the strings to bytes, see here:
        # https://stackoverflow.com/questions/48391926/python-swig-in-typemap-does-not-work
        args = [x.encode('utf-8') for x in args]

        try:
            super(Tagger, self).__init__(args)
        except RuntimeError as ee:
            raise RuntimeError(error_info(rawargs)) from ee

Tagger作る時に最初に unidicを調べて、インポートに成功したら、引数に
args = ‘-r “{}” -d “{}” ‘.format(mecabrc, unidicdir) + args
として、unidicのmecabrcファイルと辞書のパスを追加していますね。

私物のMacの環境は、 mecab-python3 のバージョンが古く、この処理が無かったので素直にIPA辞書を使ってくれているようです。そして、このエラーが発生した環境は、つい最近までunidicが入ってなかったので、 「# This is OK, just give up.」 のコメントの通り、importできなかったので、unidicを使うのを諦めてIPA辞書を使ってくれていたようです。

対応編

エラーになる原因が分かったので対応案を検討してやっていきましょう。

案1. mecab-python3のバージョンを下げる。

要するにversion 0.996 だったら無理してunidic使おうとしないので解決です。
ただ、この先ずっとmecab-python3だけバージョンを上げずに使い続けるのか、という問題があるので個人的にはこれはお勧めしません。僕も採用しませんでした。

案2. unidicの本体をダウンロードする。

unidicのimport ができるのに、辞書本体がダウンロードされていないのが原因なので本体ダウンロードしましょうというのが方針です。実際はこれを採用しました。

コマンドはドキュメントの通りです。1回だけ実行すればOK。

python -m unidic download

これを実行すると、 MeCab.Tagger() が成功するようになりました。ただし、デフォルトでunidicが使われるようになります。 テキストをparseした結果の品詞等の情報の出力がIPA辞書と全然違うものになってしまいました。

今後、IPA辞書を使いたいときは次のようにしてIPA辞書のディレクトリを明示的に指定する必要があります。ちょっと面倒になりました。(環境によってIPA辞書のパスは違うので注意してください。)

import MeCab
MeCab.Tagger("-d /usr/local/lib/mecab/dic/ipadic/") 

案3. mecabrc ファイルパスを指定してTaggerを生成する。

実はこれも試し、成功しています。何らかの事情でunidicをダウンロードしたく無い場合は、
-r オプションで mecabrc ファイルを指定し、-d で辞書を指定することで動かすことができます。 上で参照した mecab-python3のソースコードで、
「args = ‘-r “{}” -d “{}” ‘.format(mecabrc, unidicdir) + args」
となっていますが、自分が指定した -r と -d もargsとしてMeCabに渡す引数に加えられるようなのです。そしてこれらはどちらも1つしか指定できないので後に付け加えられる分が先に書かれたunidic分を上書きしているようです。

具体的には次のように使います。(具体的なパスは環境に応じて変えてください。)

MeCab.Tagger("-r /usr/local/etc/mecabrc -d /usr/local/lib/mecab/dic/ipadic/") 

-r と -d は両方必須なので面倒です。片方だけだと指定しなかった方が unidicを見に行ってしまいます。(mecabrcファイル内に辞書ディレクトリのパスが指定されていますが、-dで指定した方が優先。)
結局これは採用しませんでした。記述量が多いから。

感想と今後の方針

MeCabは動かせるようになりましたが、デフォルトの辞書がunidicになってしまって毎回IPA辞書を指定しないといけない不便を感じるようになりました。

しかし、そもそもなぜ mecab-python3がunidic推しになったのかという問題があります。これは結構明らかで、IPA辞書がかなり昔に更新が止まってしまっているのに対して、unidicの方は最近も更新が続いているからでしょう。

新目の単語がIPA辞書に含まれていないので、その点では確かにunidicの方が優れているのですが、ざっと比較したところ、全面的にunidicが優秀というわけでもなく慣れもあってまだ個人的にIPA辞の方が使いやすい印象でいます。語彙だけでなく出力形式はかなり違いますし。

とはいえ、これを機会に、unidicの思想や特徴、活用方法をきちんと学んで、こちらを使うように寄せていくことも検討した方がいいのかなと思う出来事でした。

とりあえず、MeCab.Tagger() したときの挙動が僕が持っている環境間で異なり、コードの使い回しがしにくくなったというのが目下の自分の課題なので、これをどうにかしていこうと思います。全環境でIPA辞書を指定したコードを書くのか、もう諦めて全面的にunidicに移行するのか。

Bashのブレース展開について

普段、ターミナルでコマンドを打ちはしますが、Bashスクリプトを書くことも読むこともあまりありません。ただ、必要とあれば読み書きできるつもりでいたのに他の人のコードを見ていて知らない記法に出会ったので、それについて調べたメモです。

見かけたのはこんな記述でした。

$ cp file.txt{,.backup}

みなさんはこれが何をしているかわかりますか?
実は上記のコマンドは以下のコマンドと同じ動きになります。

$ cp file.txt file.txt.backup

{}の中身が,で区切られて、0文字の文字列と「.backup」という文字列に分けられ、多項式の展開(因数分解の逆)のように、それぞれに file.txt がくっついて解釈されるのです。

このような記法をブレース展開(Brace Expansion)というそうです。ドキュメントはbashのマニュアル中にあります。 $man bash でマニュアルを開いて、 /Brace Expansion でマニュアル中を検索しましょう。

,(カンマ)区切りの単語群をそれぞれの単語に展開するというのが一番シンプルな動きです。また、数値や文字(1文字)であれば、{開始..終了}のようにして連番も生成できます。
また、面白いことに複数のブレース展開をくっつけると、数式の展開みたいなこともできます。

$ echo a{d,c,b}e
ade ace abe
$ echo {1..5}
1 2 3 4 5
$ echo {e..h}
e f g h
$ echo {1..3}{x..z}
1x 1y 1z 2x 2y 2z 3x 3y 3z

マニュアルを見ると、これを使って複数のディレクトリを作るサンプルなどが載っていますね。

$ mkdir /usr/local/src/bash/{old,new,dist,bugs}

これを実行すると、/usr/local/src/bash/ 配下に、old、new、dist、bugs、の4ディレクトリが作れるようです。
自分なら以下のように書きますが。

$ cd /usr/local/src/bash/
$ mkdir old new dist bugs

mv や cp のように 引数を2つ取るコマンドに対して、このブレース展開をさっと書けると確かにかっこいいかもしれないですね。特にmv やcpは既存のファイルに対する操作なので補完が効きます。
cp file.txt まで補完でさっと入力して {,.backup} をつけて実行と。
ただ、ファイルパスが短い場合、特にカレントディレクトリでの作業の場合は cp file.txt file.txt と補完で入力して .backup をつけるのと比べてどれほど手間の削減になっているのかと考えると微妙な気もします。

cdで該当ディレクトリにどうせずに深い階層にあるファイルをバックアップするときは確かに便利です。以下の例のような長いパスを2回書かずに済みます。

$ cp /Users/username/Documents/folder1/folder2/folder3/sample_file.txt{,.bk}

このブレース展開はbashのfor文の範囲指定でも使うことができます。というより、こちらの方が一般的な使い方だと思います。

$ for i in {1..5}
> do
> echo $i
> done
1
2
3
4
5

正確では無いかもしれませんが、このfor文の記法の{1..5}の部分にブレース展開という名前がついていて実は他のコマンドの引数を生成するのにも使えますよ、と理解するのが良いように思っています。

MeCabの辞書に単語が重複した場合の挙動を調べてみた

以前、MeCabのユーザー辞書を作る方法を紹介しました。
参考: MeCabでユーザー辞書を作って単語を追加する

システム辞書に無い単語をユーザー辞書に登録して使えば、当然システム辞書の単語とユーザー辞書の単語の両方を使って形態素解析が行えるようになります。
この時にもし、システム辞書に登録済みの単語を改めてユーザー辞書に登録してしまったらどのような挙動になるのか気になったのでドキュメントを確認してみましたがそれらしい記載がありませんでした。(他サイトにユーザー辞書がシステム辞書を上書きするという情報もあったのですが、本当にそうなのか疑わしいとも思いました。)
そこで実験してみようと思ったのがこの記事です。

また、MeCabは起動時にシステム辞書は1つしか指定できませんが、ユーザー辞書は複数指定できます。その複数のユーザー辞書に登録したらどういう挙動になるのかも確認しました。
それとついでにですが、1個のユーザー辞書に同じ単語を複数回登録した場合(これはもうただの辞書作成時のミスでしかあり得ないのですが。)の事象も見ています。

え、システム辞書に登録されてる単語をユーザー辞書に登録することなんてある?と思われる方もいらっしゃると思いますが、これは普通にあります。気づかずに登録してしまった、という場合はもちろんですが、解析結果の誤りを修正するために生起コストの設定を変えたいというケースがあるのです。

例えば、IPA辞書そのままだと、「りんごジュース」の形態素解析結果は次のように誤ったものになります。

$ echo りんごジュース | mecab
りん	副詞,助詞類接続,*,*,*,*,りん,リン,リン
ご	接頭詞,名詞接続,*,*,*,*,ご,ゴ,ゴ
ジュース	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS

IPA辞書に「りんご」が登録されていないわけではありません。バッチリ含まれています。

# ビルド前のIPA辞書のファイルが含まれているディレクトリで実行
$ grep りんご * -r
Noun.csv:りんご,1285,1285,7277,名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ

「りんご」自体の生起コストが高いこととか、「BOS」と「名詞,一般」の連接コストなどの諸々の事情によりこのような誤りが発生しています。これを是正する手段の一つが、「りんご」をもっと低い生起コストで登録することなのです。

とりあえず、生起コストを5000に落としてやってみます。下のコードでcatしてるようなテキストをファイルを作り、ユーザー辞書をコンパイルしてMeCabを動かしてみます。

# seedファイルの中身確認
$ cat apple1.csv
りんご,1285,1285,5000,名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
# コンパイル
$ /usr/local/Cellar/mecab/0.996/libexec/mecab/mecab-dict-index -d /usr/local/lib/mecab/dic/ipadic -u apple1.dic -f utf-8 -t utf-8 apple1.csv
reading apple1.csv ... 1
emitting double-array: 100% |###########################################|

done!
# 生成されたユーザー辞書を使って形態素解析(生起コストも表示)
$ echo りんごジュース | mecab -F %m\\t%c\\t%H\\n -u apple1.dic
りんご	5000	名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
ジュース	3637	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS

ユーザー辞書に登録した生起コスト5000のりんごを使って形態素解析されましたね。
この結果だけ見ると、システム辞書にある単語をユーザー辞書に登録したら情報が上書きされたように見えます。ただし、実際の動きはそうでは無いのです。

上書きされたように見えるだけで、システム辞書とユーザー辞書それぞれのりんごは別々の独立した単語として処理されていて、解には生起コストが低いユーザー辞書のりんごが採用されたというのが正確な動きになります。このことはN-Best解を表示すると確認できます。

$ echo りんごジュース | mecab -F %m\\t%c\\t%H\\n -N3 -u apple1.dic
りんご	5000	名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
ジュース	3637	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS
りん	4705	副詞,助詞類接続,*,*,*,*,りん,リン,リン
ご	6655	接頭詞,名詞接続,*,*,*,*,ご,ゴ,ゴ
ジュース	3637	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS
りんご	7277	名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
ジュース	3637	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS

3番目の解として、システム辞書の生起コスト7277のりんごもバッチリ登場していますね。上書きされて消えているわけでは無いのです。

つまりユーザー辞書に単語を登録しても、元のシステム辞書より高い生起コストを設定してたらそれは1番目の解としては使われないということです。apple2って名前で、生起コスト8000のりんごを登録してやってみます。

$ cat apple2.csv
りんご,1285,1285,8000,名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
$ /usr/local/Cellar/mecab/0.996/libexec/mecab/mecab-dict-index -d /usr/local/lib/mecab/dic/ipadic -u apple2.dic -f utf-8 -t utf-8 apple2.csv
reading apple2.csv ... 1
emitting double-array: 100% |###########################################|

done!
$ echo りんごジュース | mecab -F %m\\t%c\\t%H\\n -u apple2.dic
りん	4705	副詞,助詞類接続,*,*,*,*,りん,リン,リン
ご	6655	接頭詞,名詞接続,*,*,*,*,ご,ゴ,ゴ
ジュース	3637	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS

システム辞書だけの場合と結果変わりませんでしたね。このことからも、ユーザー辞書の単語がシステム辞書の単語を上書きする説は誤りであることがわかります。

実は元々、他のサイトの記事で単語が上書きされる説を見かけて、ユーザー辞書を複数登録したら最後にどっちの単語が残るんだ?という疑問からこの検証を始めています。
しかし、「そもそも上書きしないで別の単語として扱われる」が結論であれば、同じ辞書に複数回単語登録したり、ユーザー辞書を複数使用してそれぞれに重複してた単語があったとしても、別の単語として扱われて生起コストで判定される、と予想が付きます。

一応、「りんご」が2回登録された辞書も作って、上で作った2辞書と合わせて3辞書で動かしてみましょう。

$ cat apple3.csv
りんご,1285,1285,6000,名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
りんご,1285,1285,4000,名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
$ /usr/local/Cellar/mecab/0.996/libexec/mecab/mecab-dict-index -d /usr/local/lib/mecab/dic/ipadic -u apple3.dic -f utf-8 -t utf-8 apple3.csv
reading apple3.csv ... 2
emitting double-array: 100% |###########################################|

done!

$ echo りんごジュース | mecab -F %m\\t%c\\t%H\\n -u apple1.dic,apple2.dic,apple3.dic -N6
りんご	4000	名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
ジュース	3637	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS
りんご	5000	名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
ジュース	3637	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS
りんご	6000	名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
ジュース	3637	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS
りん	4705	副詞,助詞類接続,*,*,*,*,りん,リン,リン
ご	6655	接頭詞,名詞接続,*,*,*,*,ご,ゴ,ゴ
ジュース	3637	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS
りんご	7277	名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
ジュース	3637	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS
りんご	8000	名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
ジュース	3637	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS

3つの辞書に登録した4つのりんごと、システム辞書に元々あったりんごが全部使われていますね。

NBest解に登場する順番もシンプルに生起コストの順番になっています。
ユーザー辞書で指定した順番に上書きされて最後の辞書の一番最後の単語しか残らないんじゃ無いか、みたいなことを懸念していましたが、そんなことは全くありませんでした。

UAとGTMが導入済みのブログにGA4も設定してみた

2020年10月に正式にリリースされた GA4 (Google アナリティクス 4 プロパティ) をこのブログでも使うことにしました。このブログでは元々前世代のUA (ユニバーサルアナリティクス)を導入しています。現時点ではGA4よりUAの方が機能が充実しているように感じていますが、今後はGoogleさんがGA4の方に力を入れて改善していき、そちらをスタンダードにするということなので、使い始めた次第です。
ただ、いきなり乗り換えるのではなく当分並行稼働させていきます。

作業の前にこのブログでの設定状況についてです。このブログでは、Wordpressのプラグインを使って、GTM(Googleタグマネージャー)を導入し、タグマネージャーを経由してUAのタグを発火させていました。
参考: Google タグマネージャー導入
また、当然Googleアナリティクスのアカウント等も元々保有しています。無い場合はそこから作る必要があります。
あくまでもこの記事は、すでにUA+GTMが稼働中のページにGA4を追加する手順です。

では進めていきましょう。

手順1. GA4のプロパティを作成する。
以下の手順で作成できます。

GA4ではUAとは別のプロパティを作成し使用する必要があります。
1. Googleアナリテクスにアクセスする。
2. 左ペイン一番下の「管理」をクリックする。
3. プロパティ のところにある、 + プロパティを作成 をクリックする。
4. プロパティの設定をする。
– プロパティ名に自分がわかりやすい名前を入力する。(僕は「分析ノートGA4」にしました。)
– レポートのタイムゾーンは日本を選択
– 通貨に日本円を選択
5. 次へをクリック
6. ビジネスの概要設定画面が出てくるのでサイトの特性に合わせて適切なものを選びます。
– 悩んだのですが、業種はコンピュータ、電気製品にしました。
– 一人で更新しているので、ビジネスの規模は小規模-従業員数1〜10名にしています。
7. 利用目的を聞かれるので、該当するものを選ぶ。
– 自分は次の二つを選びました。
サイトまたはアプリでの顧客エンゲージメントを測定する
サイトまたはアプリの利便性を最適化する
8. 作成をクリックする

少しステップが多いですが、画面に従い順次行えば途中で迷うことはないと思います。

手順2. データストリームの設定
プロパティができたら続いてデータストリームを設定します。このブログはWeb版しか無い(アプリなど提供していない)のでWebのデータストリームを作成します。
上記のプロパティの作成から続けて行えますが、一度閉じてしまった場合は設定から開きましょう。
1. ウェブを選択する。
2. ウェブサイトのURLとストリーム名を入力します。URLはhttps://analytics-note.xyz ですが、 ストリーム名はどうするか悩みました。複数のストリームを同時に使う予定はなかったので、analytics-note としています。Webとアプリを両方分析する人はそれぞれ見分けられる名前が良いと思います。
3. ストリームを作成をクリックする。
4. 観測用のIDが生成されるのでメモしておきます。GTMで使います。
観測用のIDは G-{アルファベットと数字}の形式になっています。

以上で、GA側の設定は終わりです。あとはなんらかの方法で発行されたIDや、観測用のタグをブログの方に埋め込む必要があります。今回は導入済みのGTMを使いました。

手順3. GTMにGA4計測タグを追加

すでにGTMに作成済みのコンテナをそのまま使います。
1. GTM にアクセスする。
2. 既存のコンテナを選択する。
3. 左ペインでタグを選択し、新規をクリックする。
4. [タグの設定] をクリックして [GA4 設定] を選択する。
5. 先ほどの測定 ID「G-XXXXXXXXXX」を入力する。
6. トリガーをクリックする。
7. All Pagesを選択し、保存をクリックする。
8. デフォルトで、 Google アナリティクス GA4 設定 という名前が入ってたのでそのまま保存する。

これでタグが作成されたので、これを公開するための手順を続けていきます。
9. ワークスペースに戻ってプレピューをクリック。
10. Connect Tag Assistant to your site とメッセージが表示されたら、
https://analytics-note.xyz/ と対象サイトのURLを入力してConnectをクリックする。
11. そのブラウザでいくつかのページにアクセスすると、別のデバック用に開いていたブラウザのタブで開いていたページで発火したタグをみることができる。(昔のGTMは画面下部で確認していたので、この仕様が変わっていたようです。)
12. Google アナリティクス GA4 設定がFired(発火)になっているのを確認する。
13. ついでにWordpressの管理画面にもアクセスしてそこは発火しないことも確認する。
14. Tag Assistant の小さいウィンドウの Finish を押してプレビューを終了する。
16. 「公開」ボタンをクリックする。
17. バージョン名と説明を求められるので入力し、再度「公開」をクリックする。

以上で、GA4のが設定が完了し、データ収集が始まります。動作テストとして、リアルタイムビューを見てみるのがおすすめです。

追加で、最低限の設定として以下の設定を入れました。

データ保持期限を14ヶ月に伸ばす(デフォルトは2ヶ月)
こちらは、設定の、プロパティの データ設定 > データ保持 から設定できます。デフォルトの保持期間はかなり短いので伸ばしておいた方が良いでしょう。

また、Googleシグナルを有効にしました。
こちらも データ設定 > データ収集画面 から設定できます。

UAとGA4を並行してみていると、ユーザー数の集計値に差分が生まれていたり、なくなってしまった指標があったり、UAの方が用意されているレポートが多くて便利に感じたりと色々差があり、現時点ではまだUAの方が良いツールに感じることが多々あります。

ただ、Googleさんの方針として今後の開発はGA4の方に注力していくとのことですので、将来的に便利なツールになっていくことを期待しながら少しずつGA4に慣れていきたいと思います。