MeCabのN-Best解のコストがどこで間違っているのか確認した

前回の記事で、MeCabのN-Best解について紹介し、そのコスト計算にバグがあることについて触れました。
参考: MeCabのN-Best解を出力する
2番目以降の解の(生起と連接)コストの総和が1番目の解と同じになってしまうのでしたね。

このバグについて、具体的にどこでどう計算がずれてしまっているのか確認したのでそれを記事にまとめておきます。これを調べる前までは、生起コストと連接コストは正しく取得できて、合計結果だけ上書きされてるんじゃないかなぁと予想していたのですが、実際は連接コストの値が書き換えられていました。ここが正しくて合計が合わないだけなら自分で足し合わせるだけでよかったのでちょっと残念な結果でした。

それでは検証した内容を順番に書いていきます。
まず、いつもの”すもももももももものうち”で、N-Best解を出力し検証用のサンプルを取得します。特に深い理由はないのですが、 1番目の解(=正しくコストが計算される)と、4番目の解(=コスト計算が誤っている)を見ていきます。

まずは単純に形態素解析していきます。また、コスト計算の結果を検証するために、コストに関する各種情報を出力する設定にして、4番目までのN-Best解を出力します。

import MeCab
import pandas as pd


text = "すもももももももものうち"

# 表層系(%m), 形態素種類(%s), 左文脈id(%phl), 右文脈id(%phr), 単語生起コスト(%c),
# 連接コスト(%pC), 生起コスト+連接コスト(%pn), 生起コスト+連接コストの累積(%pc), 素性(%H)
# を順番に出力する設定でTaggerを生成
tagger = MeCab.Tagger(
    " -F %m\\t%s\\t%phl\\t%phr\\t%c\\t%pC\\t%pn\\t%pc\\t%H\\n" +
    " -E EOS\\t%s\\t%phl\\t%phr\\t%c\\t%pC\\t%pn\\t%pc\\tEOS\\n"
)
parse_result = tagger.parseNBest(4, text)
print(parse_result)
"""
すもも	0	1285	1285	7546	-283	7263	7263	名詞,一般,*,*,*,*,すもも,スモモ,スモモ
も	0	262	262	4669	-4158	511	7774	助詞,係助詞,*,*,*,*,も,モ,モ
もも	0	1285	1285	7219	17	7236	15010	名詞,一般,*,*,*,*,もも,モモ,モモ
も	0	262	262	4669	-4158	511	15521	助詞,係助詞,*,*,*,*,も,モ,モ
もも	0	1285	1285	7219	17	7236	22757	名詞,一般,*,*,*,*,もも,モモ,モモ
の	0	368	368	4816	-4442	374	23131	助詞,連体化,*,*,*,*,の,ノ,ノ
うち	0	1313	1313	5796	-5198	598	23729	名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ
EOS	3	0	0	0	-2484	-2484	21245	EOS
すもも	0	1285	1285	7546	-283	7263	7263	名詞,一般,*,*,*,*,すもも,スモモ,スモモ
も	0	262	262	4669	-4158	511	7774	助詞,係助詞,*,*,*,*,も,モ,モ
もも	0	1285	1285	7219	17	7236	15010	名詞,一般,*,*,*,*,もも,モモ,モモ
もも	0	1285	1285	7219	62	7281	22291	名詞,一般,*,*,*,*,もも,モモ,モモ
も	0	262	262	4669	-4158	511	22802	助詞,係助詞,*,*,*,*,も,モ,モ
の	0	368	368	4816	-4487	329	23131	助詞,連体化,*,*,*,*,の,ノ,ノ
うち	0	1313	1313	5796	-5198	598	23729	名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ
EOS	3	0	0	0	-2484	-2484	21245	EOS
すもも	0	1285	1285	7546	-283	7263	7263	名詞,一般,*,*,*,*,すもも,スモモ,スモモ
もも	0	1285	1285	7219	62	7281	14544	名詞,一般,*,*,*,*,もも,モモ,モモ
も	0	262	262	4669	-4158	511	15055	助詞,係助詞,*,*,*,*,も,モ,モ
もも	0	1285	1285	7219	17	7236	22291	名詞,一般,*,*,*,*,もも,モモ,モモ
も	0	262	262	4669	-4158	511	22802	助詞,係助詞,*,*,*,*,も,モ,モ
の	0	368	368	4816	-4487	329	23131	助詞,連体化,*,*,*,*,の,ノ,ノ
うち	0	1313	1313	5796	-5198	598	23729	名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ
EOS	3	0	0	0	-2484	-2484	21245	EOS
すもも	0	1285	1285	7546	-283	7263	7263	名詞,一般,*,*,*,*,すもも,スモモ,スモモ
もも	0	1285	1285	7219	62	7281	14544	名詞,一般,*,*,*,*,もも,モモ,モモ
も	0	262	262	4669	-4158	511	15055	助詞,係助詞,*,*,*,*,も,モ,モ
も	0	262	262	4669	-4203	466	15521	助詞,係助詞,*,*,*,*,も,モ,モ
もも	0	1285	1285	7219	17	7236	22757	名詞,一般,*,*,*,*,もも,モモ,モモ
の	0	368	368	4816	-4442	374	23131	助詞,連体化,*,*,*,*,の,ノ,ノ
うち	0	1313	1313	5796	-5198	598	23729	名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ
EOS	3	0	0	0	-2484	-2484	21245	EOS
"""

コストの総和が4パターンとも全部 21245 になってしまっていますね。
この計算がどこで間違っているのか見ていきましょう。
このままだと非常に扱いにくいので、PandasのDataFrameにします。

# 改行、Tabで順に区切ってDataFrameにする
df = pd.DataFrame(
    data=[r.split() for r in parse_result.splitlines()],
    columns=[
        "表層系", "形態素種類", "左文脈id", "右文脈id", "単語生起コスト",
        "連接コスト", "生起コスト+連接コスト", "生起コスト+連接コストの累積", "素性",
    ]
)
# 数値で取得できた値はint型にしておく
for c in ["形態素種類", "左文脈id", "右文脈id", "単語生起コスト",
          "連接コスト", "生起コスト+連接コスト", "生起コスト+連接コストの累積", ]:
    df[c] = df[c].astype(int)

# 1番目の解と4番目の解をそれぞれ個別のデータフレームに取り出す
nb1_df = df.iloc[0: 8].copy()
nb4_df = df.iloc[24: 32].copy()
nb4_df.reset_index(inplace=True, drop=True)

これで、nb4_df に今回注目する4番目のN-Best解の情報が入りました。nb1_dfは検証用です。

最初に予想していたのは、”生起コスト+連接コストの累積”だけ書き換えられていて、”生起コスト+連接コスト”の総和は本当は正しい値になっているのではないか?ということです。ただ、この予想は早速外れました。

以下の通り、生起コスト+連接コスト列を足すと21245になってしまいます。
そのためこの累積計算には誤りはなく、正しく足し算されているようです。

print(nb4_df["生起コスト+連接コスト"].sum())
# 21245

次に疑ったのが、”生起コスト+連接コスト” が “生起コスト”と”連接コスト”の和になってないのではないか?ということです。しかしこの点についても問題ありませんでした。
次のコードの通り値を計算してみても、何も矛盾なく全部0になります。

print(nb4_df["生起コスト+連接コスト"]-nb4_df["単語生起コスト"]-nb4_df["連接コスト"])
"""
0    0
1    0
2    0
3    0
4    0
5    0
6    0
7    0
dtype: int64
"""

となると、単語生起コストがそもそも違う?という懸念が出てくるのですが、これも1つ目の解と4つ目の解を見比べてみると一致しており問題ないことがわかります。

print(nb1_df[["表層系", "単語生起コスト", "素性"]])
"""
   表層系  単語生起コスト                          素性
0  すもも     7546   名詞,一般,*,*,*,*,すもも,スモモ,スモモ
1    も     4669        助詞,係助詞,*,*,*,*,も,モ,モ
2   もも     7219      名詞,一般,*,*,*,*,もも,モモ,モモ
3    も     4669        助詞,係助詞,*,*,*,*,も,モ,モ
4   もも     7219      名詞,一般,*,*,*,*,もも,モモ,モモ
5    の     4816        助詞,連体化,*,*,*,*,の,ノ,ノ
6   うち     5796  名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ
7  EOS        0                         EOS
"""

print(nb4_df[["表層系", "単語生起コスト", "素性"]])
"""
   表層系  単語生起コスト                          素性
0  すもも     7546   名詞,一般,*,*,*,*,すもも,スモモ,スモモ
1   もも     7219      名詞,一般,*,*,*,*,もも,モモ,モモ
2    も     4669        助詞,係助詞,*,*,*,*,も,モ,モ
3    も     4669        助詞,係助詞,*,*,*,*,も,モ,モ
4   もも     7219      名詞,一般,*,*,*,*,もも,モモ,モモ
5    の     4816        助詞,連体化,*,*,*,*,の,ノ,ノ
6   うち     5796  名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ
7  EOS        0                         EOS
"""

順番は違いますが、名詞一般のすももは7546で、助詞系助詞のもは4669みたいにみていくと何も問題ないことがわかりますね。

ということで最後に残ったのが連接コストです。正直、これの正否を調べるのは面倒だったので後回しにしていたのですがここしか疑うところがなくなってしまいました。
IPA辞書データに含まれている matrix.def から正しいデータを拾ってきて比較検証するしかありません。

比較を効率にするために、次のようなコードを書いてみました。

dic_data_dir = "{コンパイル前の辞書データが含まれているパスを指定}"
# スペース区切りで読み込み
matrix_df = pd.read_csv(f"{dic_data_dir}/matrix.def", delimiter=" ")
# 1列目がindex扱いされてしまっているので再設定
matrix_df.reset_index(inplace=True,)
# 列名修正
matrix_df.columns = ["前の単語の右文脈id", "左文脈id", "辞書から取得した連接コスト"]

# 前の単語の右文脈idの列を作成
nb4_df["前の単語の右文脈id"] = nb4_df["右文脈id"].shift(1)
# 1つ目の単語の前はBOSで、その文脈idは0なのでそれで埋める
nb4_df.loc[0, "前の単語の右文脈id"] = 0

# 結合する
print(pd.merge(
    nb4_df,
    matrix_df,
    left_on=["前の単語の右文脈id", "左文脈id"],
    right_on=["前の単語の右文脈id", "左文脈id"],
)[["表層系", "左文脈id", "右文脈id", "連接コスト", "辞書から取得した連接コスト"]])
"""
   表層系  左文脈id  右文脈id  連接コスト  辞書から取得した連接コスト
0  すもも   1285   1285   -283           -283
1   もも   1285   1285     62             62
2    も    262    262  -4158          -4158
3    も    262    262  -4203            478
4   もも   1285   1285     17             17
5    の    368    368  -4442          -4442
6   うち   1313   1313  -5198          -5198
7  EOS      0      0  -2484          -2484
"""

これでN-Best解で出力された連接コストと、辞書から取得した連接コストが出力されした。

上から順番にみていくと、4行目(index3の行)で、出力された連接コストが-4203なのに、辞書から取得した連接コストが478になっているところがあります。

これのせいで結果(総和)が本来のコストよりずっと小さくなり、1番目の解と同じになってしまっていたのですね。

さて、今回のサンプルでは連接コストが1カ所誤った値になり総和がズレるということがわかりました。実はより長文で実験したりすると、このように値がズレるところが2カ所以上出てくることもわかっています。ズレる部分が何カ所発生するのか、また何単語目がズレるのかといったことに関する法則性はまだわかっていません。

もしかしたら生起コストの方がずれることもあるのかな?とは思ったのですが今のところそういう例は見つかっていないので一旦は大丈夫そうです。

とはいえ、合計する元データになる連接コストの値自体がずれてしまっているとなると自分で足し合わせて正しい値を得るということは少し面倒ですね。

上のコードみたいに、自分で辞書から連接コストを持ってきてそれを使って足し合わせる必要があります。

MeCabのN-Best解を出力する

今回もMeCabの話です。MeCabにはN-Best解という機能がありますので、その紹介と、コマンドラインおよびPythonライブラリで利用する方法をまとめます。

公式ドキュメントではこちらですね。
参考: N-Best 解の出力

この間の最小コスト法の記事で、MeCabの仕組みを紹介しましたがざっくりいうと、生起コストと連接コストの総和が最小になるような分解方法を見つけてそれを返すというものでした。
そして、コストの総和が最小になるものがあれば、2番目に小さいもの、3番目に小さいもの、4番目に小さいもの、がそれぞれ存在します。それらをN個出力してくれるというシンプルな機能です。

まずコマンドラインの場合、使い方は非常に簡単で 引数に -N {数値} を追加するだけです。
いつもの「すもももももももものうち」で 3つ出してみましょう。
以下の通り、3パターンの解析結果が出力されました。

$ mecab -N 3
すもももももももものうち
すもも	名詞,一般,*,*,*,*,すもも,スモモ,スモモ
も	助詞,係助詞,*,*,*,*,も,モ,モ
もも	名詞,一般,*,*,*,*,もも,モモ,モモ
も	助詞,係助詞,*,*,*,*,も,モ,モ
もも	名詞,一般,*,*,*,*,もも,モモ,モモ
の	助詞,連体化,*,*,*,*,の,ノ,ノ
うち	名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ
EOS
すもも	名詞,一般,*,*,*,*,すもも,スモモ,スモモ
も	助詞,係助詞,*,*,*,*,も,モ,モ
もも	名詞,一般,*,*,*,*,もも,モモ,モモ
もも	名詞,一般,*,*,*,*,もも,モモ,モモ
も	助詞,係助詞,*,*,*,*,も,モ,モ
の	助詞,連体化,*,*,*,*,の,ノ,ノ
うち	名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ
EOS
すもも	名詞,一般,*,*,*,*,すもも,スモモ,スモモ
もも	名詞,一般,*,*,*,*,もも,モモ,モモ
も	助詞,係助詞,*,*,*,*,も,モ,モ
もも	名詞,一般,*,*,*,*,もも,モモ,モモ
も	助詞,係助詞,*,*,*,*,も,モ,モ
の	助詞,連体化,*,*,*,*,の,ノ,ノ
うち	名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ
EOS

ドキュメントにありますが、 -N の最大値は 512だそうです。513以上を設定すると「invalid N value」というメッセージが返ってきます。

$ mecab -N 513
invalid N value

-N 512 とすると必ず512通り返ってくるというわけではなく、分解のパターンが指定した数より少なければ見つかった分しか返ってきません。文章が短いとそうなりがちですね。
たとえば、次のように1単語の場合はNがなんであっても1つしか結果は得られません。

$ mecab -N 512
岐阜
岐阜	名詞,固有名詞,地域,一般,*,*,岐阜,ギフ,ギフ
EOS

さて、コマンドラインで使う方法がわかったので、次はPythonでやってみましょう。

僕はてっきり、Tagger オブジェクトを生成するときに引数の文字列の中に -N {数値} を含めておけばいいんだと勘違いしていました。しかしそれでは動作しません。
「すももももも」でやってみます。

import MeCab


tagger = MeCab.Tagger("-N 512")
print(tagger.parse("すももももも"))
"""
すもも	名詞,一般,*,*,*,*,すもも,スモモ,スモモ
も	助詞,係助詞,*,*,*,*,も,モ,モ
もも	名詞,一般,*,*,*,*,もも,モモ,モモ
EOS
"""

エラー等は出ませんが、解は1個しか得られませんでしたね。

よくよく調べてみると、Taggerオブジェクトが、parseNBest というメソッドを持っており、これを使わないといけないようです。 また、 Taggerオブジェクトを生成する時点では、 -N の指定は不要でした。(指定しても動くが、出力に全く影響しません。)

parseNBest メソッドには、N-Best会を表示したい個数の数値と、解析したいテキストを渡します。

tagger = MeCab.Tagger()
print(tagger.parseNBest(3, "すももももも"))
"""
すもも	名詞,一般,*,*,*,*,すもも,スモモ,スモモ
も	助詞,係助詞,*,*,*,*,も,モ,モ
もも	名詞,一般,*,*,*,*,もも,モモ,モモ
EOS
すもも	名詞,一般,*,*,*,*,すもも,スモモ,スモモ
もも	名詞,一般,*,*,*,*,もも,モモ,モモ
も	助詞,係助詞,*,*,*,*,も,モ,モ
EOS
すもも	名詞,一般,*,*,*,*,すもも,スモモ,スモモ
も	助詞,係助詞,*,*,*,*,も,モ,モ
もも	動詞,自立,*,*,五段・マ行,未然ウ接続,もむ,モモ,モモ
EOS
"""

さて、基本的な使い方の説明は以上になります。

最後に一点、MeCabのN-Best解の出力には一つバグがあるらしいのでその情報を共有しておきます。通常のフォーマットで出力する場合は何も問題ないのですが、出力フォーマットをカスタマイズして生起コスト、連接コストとその総和を表示するとどうやらN個の出力結果の総コストが全部同じ値になってしまいます。
コストの総和が小さい順に出力されているので、2番目の出力のコストは何なのかな?とか1番目とどのくらい差分があるんだろうか?とか気になるのですが、正確な値が出力されないということでちょっと不便ですね。一応見ておきます。
参考: MeCabの出力形式を変更する

%pc が文頭からの 連接コスト + 単語生起コスト の累積で、EOSと共に出力されたやつが、その解析結果全体のコストの総和です。

tagger = MeCab.Tagger("-F %M\\t%pc\\t%H\\n -E EOS\\t%pc\\n")
print(tagger.parseNBest(3, "すももももも"))
"""
すもも	7263	名詞,一般,*,*,*,*,すもも,スモモ,スモモ
も	7774	助詞,係助詞,*,*,*,*,も,モ,モ
もも	15010	名詞,一般,*,*,*,*,もも,モモ,モモ
EOS	14437
すもも	7263	名詞,一般,*,*,*,*,すもも,スモモ,スモモ
もも	14544	名詞,一般,*,*,*,*,もも,モモ,モモ
も	15055	助詞,係助詞,*,*,*,*,も,モ,モ
EOS	14437
すもも	7263	名詞,一般,*,*,*,*,すもも,スモモ,スモモ
も	7774	助詞,係助詞,*,*,*,*,も,モ,モ
もも	16749	動詞,自立,*,*,五段・マ行,未然ウ接続,もむ,モモ,モモ
EOS	14437
"""

ご覧の通り、3パターンとも全部 14437 になってしまっていますね。
利用する場合は注意が必要です。

どうやら開発者の方(@taku910)も認識されているバグのようですが、事情があって修正されてていないようです。(ツイートされています)

N-best解でコストが変更されないのは、今のところ仕様でTODOになっています。修正は厄介で、前方からの最適コストはA*探索で使うため、書き換えられないのです。

https://twitter.com/taku910/status/383126956893413376

最小コスト法の記事で掲載した画像を生成するコードの紹介

前回の記事で、MeCabの最小コスト法について説明しました。
参考: MeCabに実装されている最小コスト法について

その記事は形態素解析のアルゴリズムの話がメインで、説明用に作った図のコードなどは本筋とズレるので掲載しませんでしたが、せっかく書いたプログラムがもったいないのでこの記事で紹介します。

まず、この図を作ります。

最初に、入力テキスト「すもももももももものうち」を区切れる位置全部で区切って、単語候補のデータを作ります。(あくまでも作図用の実装です。MeCab自体は実はもっと効率の良い方法をとっています。また、このプログラムでは開始位置や終了位置を文字数基準で数えていますが、MeCabの内部ではバイト単位で数えてるようです。)

import glob
import pandas as pd
from graphviz import Digraph


# 元のテキスト
text = "すもももももももものうち"
start_list = []  # 単語開始位置
end_list = []  # 単語終了位置
token_list = []  # 単語

# i文字目からj文字目までを切り出す
for i in range(len(text)):
    for j in range(i+1, len(text)+1):
        start_list.append(i)
        end_list.append(j)
        token_list.append(text[i: j])

# データフレームにまとめる
df = pd.DataFrame(
    {
        "start": start_list,
        "end": end_list,
        "token": token_list,
    }
)

print(len(df))
# 78
print(df.sample(5))
"""
    start  end  token
33      3    4      も
66      7   11   もものう
45      4    8   もももも
3       0    4   すももも
54      5   10  ももももの
"""

これで、全78個 (78 = 13*12/2) の単語候補と、その位置のデータが揃いました。

続いて、これらの単語の中からIPA辞書に含まれている単語を選びます。
dic_data_dir 変数に、ダウンロードしてきた辞書のパスを指定しておいてください。
参考: MeCabのIPA辞書の中身を確認する

また、この段階で文頭文末記号(BOS/EOS)も追加しておきます。

# コンパイル前の辞書データ (csvファイルたちが保存されているディレクトリを指定)
dic_data_dir = "{辞書データのディレクトリパス}"
ipa_df = pd.DataFrame()
# .csvフィアルを全て読み込みDataFrameに格納する
for csv_path in glob.glob(f"{dic_data_dir}/*.csv"):
    tmp_df = pd.read_csv(csv_path, header=None)
    ipa_df = ipa_df.append(tmp_df)
    
print(len(ipa_df))
# 392126

# 辞書に含まれていた単語だけを残す
df = df[df["token"].isin(ipa_df[0])].copy()
print(len(df))
# 25

# BOS, EOSの情報を追加
df = df.append(
    pd.DataFrame(
        {
            "start": [0, len(text)],
            "end": [0, len(text)],
            "token": ["BOS", "EOS"]
        }
    )
).copy()
df.reset_index(inplace=True, drop=True)
print(df.tail())
"""
    start  end token
22     10   11     う
23     10   12    うち
24     11   12     ち
25      0    0   BOS
26     12   12   EOS
"""

ここまでで、図中に含まれるノード(単語)の情報は揃いました。次にエッジのデータを作ります。for文を2重に回してもいいのですが、先ほどのDataFrameを二つ用意して、終了位置と開始位置が一致するものを結合するとスマートです。

# 開始位置と終了位置が等しい単語を結合することで、エッジ情報を作成する
df1 = df.rename(columns={"start": "start1", "end": "end1", "token": "token1"})
df2 = df.rename(columns={"start": "start2", "end": "end2", "token": "token2"})
edge_df = pd.merge(left=df1, right=df2, left_on="end1", right_on="start2")

# end2 が 0 のレコードは BOS -> BOSなので消す
edge_df = edge_df[edge_df.end2 > 0].copy()
# start1 が テキスト長に等しいレコードは EOS -> EOSなので消す
edge_df = edge_df[edge_df.start1 < len(text)].copy()

print(edge_df.sample(5))
"""
    start1  end1 token1  start2  end2 token2
10       2     3      も       3     4      も
45      10    12     うち      12    12    EOS
19       4     5      も       5     7     もも
25       5     7     もも       7     9     もも
20       4     6     もも       6     7      も
"""

これでノードとエッジの情報が揃ったので、graphvizの有向グラフオブジェクトに順番に渡せばOKです。

graph = Digraph(format="png")  # グラフオブジェクト生成
graph.attr(rankdir="LR")  # 左から右

# ノードを生成する
for i, row in df.iterrows():
    # 開始位置が異なる単語を別ノードとして扱うため、nameは{開始位置}-{単語}とする。
    # ラベルは{単語}をそのまま使う
    graph.node(f"{row['start']}-{row['token']}", row['token'])

# 連続する単語間にエッジを生成する
for i, row in edge_df.iterrows():
    graph.edge(
        f"{row['start1']}-{row['token1']}",
        f"{row['start2']}-{row['token2']}",
    )

graph  # jupyter notebookに結果を表示する場合はこの行を実行
# graph.render("mecab-lattice")  # ファイルに保存する場合はこの行を実行

以上で、上のラティスのグラフが生成されます。

前の記事ではもう一個、一部にフォーカスして生起コストと連接コストを掲載した図も作りました。

これは構成要素がそんなに多くないので、IPA辞書のデータを逐一参照して生起コストと連接コストの値を拾ってきて、ハードコーディングしました。

graph = Digraph(format="png")
graph.attr(rankdir="LR") 

# ノードを生成
graph.node("BOS")
graph.node("EOS")
graph.node("すもも", "すもも\n(名詞,一般)\n7546")
graph.node("も1", "も\n(助詞,係助詞)\n4669")
graph.node("もも1", "もも\n(名詞,一般)\n7219")
graph.node("もも2", "もも\n(名詞,一般)\n7219")
graph.node("も2", "も\n(助詞,係助詞)\n4669")

# エッジを生成
graph.edge("BOS", "すもも", label="-283")
graph.edge("すもも", "も1", label="-4158")
graph.edge("も1", "もも1", label="17")
graph.edge("すもも", "もも2", label="62")
graph.edge("もも2", "も2", label="-4158")
graph.edge("もも1", "EOS", label="-573")
graph.edge("も2", "EOS", label="26")

graph  # jupyter notebookに結果を表示する場合はこの行を実行
# graph.render("connection-cost") # ファイルに保存する場合はこの行を実行

MeCabに実装されている最小コスト法について

このブログでもよく形態素解析ソフトのMeCabを利用していますが、今回の記事ではこのMeCabがどのようにして形態素解析を実現しているのかを紹介します。

ざくっと言うと、MeCabはあらかじめ辞書で定義された単語の情報をもとに、その単語の発生しにくさを示す「生起コスト」とそれぞれの単語間の繋がりにくさを示す「連接コスト」を計算し、これらの合計が最小になるように文を分割します。

辞書にない単語(未知語)がある場合はその部分についてまた別のロジックが実装されているのですが、今回はこの生起コストと連接コストの概念について説明するために辞書にある単語だけでできている文をサンプルに使います。使うのは定番の「すもももももももものうち」です。

実際のMeCabには速度改善のための工夫なども取り込まれていて、実際にこの記事の通りに実装されているわけではないのですが、大まかな流れはおそらくあってると思います。

それでは早速やっていきましょう。

最初にMeCabが行うのは文をくぎれる位置で全部区切り、辞書にある単語を洗い出します。

i と j を順番に動かして、元の文のi文字目からj文字目までを全部取るような処理なので比較的簡単ですね。例えば、元の文が「すもも」だけであれば、す/すも/すもも/も/もも などが候補になり、これらを全部辞書の中から探します。「すもももももももものうち」だと、78通りも切り出せる単語があり大変です。MeCabでは「すももも」ではじまる単語がないことを検知すると、「すもももも」以降は試さないなどの工夫がされているそうです。

文から取り出した単語の一覧ができたら、それらを辞書と付き合わせます。表記が同じでも違う品詞の組み合わせなども非常に多くあります。

IPA辞書の場合、「すもももももももものうち」から切り出せる文字列のうち、
[‘う’, ‘うち’, ‘す’, ‘すも’, ‘すもも’, ‘ち’, ‘の’, ‘のう’, ‘も’, ‘もの’, ‘ものう’, ‘もも’]
の13語が辞書に含まれていますが、「す」は7単語、「うち」は6単語も登録されています。

す,1285,1285,10036,名詞,一般,*,*,*,*,す,ス,ス
す,11,11,9609,形容詞,自立,*,*,形容詞・アウオ段,ガル接続,すい,ス,ス
す,879,879,11484,動詞,接尾,*,*,五段・サ行,基本形,す,ス,ス
す,777,777,9683,動詞,自立,*,*,五段・ラ行,体言接続特殊2,する,ス,ス
す,602,602,9683,動詞,自立,*,*,サ変・スル,文語基本形,する,ス,ス
す,601,601,9683,動詞,自立,*,*,サ変・スル,体言接続特殊2,する,ス,ス
す,560,560,10247,接頭詞,名詞接続,*,*,*,*,す,ス,ス

うち,1285,1285,7990,名詞,一般,*,*,*,*,うち,ウチ,ウチ
うち,1313,1313,5796,名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ
うち,1310,1310,8911,名詞,非自立,一般,*,*,*,うち,ウチ,ウチ
うち,743,743,10640,動詞,自立,*,*,五段・タ行,連用形,うつ,ウチ,ウチ
うち,1306,1306,11865,名詞,代名詞,一般,*,*,*,うち,ウチ,ウチ
うち,1314,1314,8571,名詞,副詞可能,*,*,*,*,うち,ウチ,ウチ

さて、辞書を引いたら、元の文章におけるその文字列の登場位置と文字数を考慮し、どのような結合がありうるのかを列挙し、グラフにします。ここで言うグラフとは折れ線グラフや棒グラフではなく、ノードやエッジがあるグラフ理論のグラフの方です。要するに、「すもも」と「うち」は両方文中に登場しますが登場位置が離れているのでつながったりしないのです。

イメージとしては、次の図のようなグラフが生成されます。
あくまでもイメージです。下の図では、品詞など無視して、表層形が同じ単語を区別せずに描いています。実際は上で見た通り、同じ表層型でも異なる単語が多く存在するので実際にMeCab内部で処理されるグラフ(これをラティスというそうです)は遥かに複雑になります。

さて、ここまでで、元の文をどのように分解するのかの候補が出揃いました。あとはこれらの分解の候補の中から、最終的な形態素解析結果を決める必要があります。

そこで用いられる情報の一つが「生起コスト」です。これは辞書で各単語ごとに指定されていおり、その単語の文中での「表れにくさ」を示しています。つまり数値が小さいほど出現しやすいということです。

例えば、ももであれば一般名詞のももと、動詞もむが活用したももがあり得ますが、一般名詞のももの方がコストが低く設定されています。

もも,1285,1285,7219,名詞,一般,*,*,*,*,もも,モモ,モモ
もも,763,763,9357,動詞,自立,*,*,五段・マ行,未然ウ接続,もむ,モモ,モモ

わかりにくいですが、
表層形,左文脈ID,右文脈ID,コスト,品詞,品詞細分類1,品詞細分類2,品詞細分類3,活用型,活用形,原形,読み,発音
の順に並んでおり、4列目の 7219や9357がその単語の生起コストです。

一見すると、この生起コストだけでも形態素解析ができそうな気がしてきますね。
しかし、実際は単語の特定はその単語の出現しやすさだけでできるものではありません。
例えば、もし生起コストだけで形態素解析を行おうとすれば、名詞のももが動詞のもむが活用したももより出やすいという判断のもと、どんな文脈であってもももが出てきたら名詞(果物の)ももと判断してしまうことになり、これはおかしいです。

また、「すももももも」という文を考えると、生起コストだけでは「すもも/も/もも」なのか「すもも/もも/もも」なのか判断することができません。

これらの問題を解決するために、MeCabでは「連接コスト」という概念が導入されています。これは、品詞(と活用系)の組み合わせごとに、その「繋がりにくさ」を指定したものです。そのため、生起コスト同様に数値が低い方がよく現れやすいものとして扱われます。

連接コストは、辞書の matrix.def というファイルに入っています。辞書ディレクトリのmatrix.binはコンパイルされてバイナリになっちゃってますので、中身を確認したい場合は、コンパイル前のファイルを見てください。

中身はこんな感じです。

$ head matrix.def
1316 1316
0 0 -434
0 1 1
0 2 -1630
0 3 -1671

一番上の 1316 1316 はヘッダーみたいなのもので、おそらく品詞の種類数を意味してると思います。2行目からがデータで、(おそらくですが、)左の単語の右文脈ID、右の単語の左文脈ID、連接コスト、と入っています。 0 は BOS/EOS の文脈IDで、これによって文頭や文末にきやすい品詞なども定義されています。

文脈IDと品詞の情報は、left-id.def / right-id.def に入っています。(両ファイルの中身は同じです。)

$ head left-id.def
0 BOS/EOS,*,*,*,*,*,BOS/EOS
1 その他,間投,*,*,*,*,*
2 フィラー,*,*,*,*,*,*
3 感動詞,*,*,*,*,*,*
4 記号,アルファベット,*,*,*,*,*
5 記号,一般,*,*,*,*,*
6 記号,括弧開,*,*,*,*,BOS/EOS
7 記号,括弧閉,*,*,*,*,BOS/EOS
8 記号,句点,*,*,*,*,BOS/EOS
9 記号,空白,*,*,*,*,*

動詞は活用系も全部入ってるので、1316種類も品詞があります。そして、全ての組み合わせに対して、連接コストが定義されているので、matrix.def は 100万行を超えるファイルになっています。

例えば「名詞,一般のすもも」と、「助詞,係助詞のも」の連接コストは次のようにか確認できます。まずそれぞれの文脈IDを確認します。

すもも,1285,1285,7546,名詞,一般,*,*,*,*,すもも,スモモ,スモモ
も,262,262,4669,助詞,係助詞,*,*,*,*,も,モ,モ

1285と262ですね。matrix.def で探すと、「1285 262 -4158」と出てきます。コストがマイナスですからこれらは非常に連接しやすいということがわかります。

先ほど例に挙げた「すももももも」の2通りの分解について生起コストと連接コストをグラフにまとめたのが次の図です。ノード中の数字が生起コスト、エッジのラベルが連接コストです。

これを見ると、「すもも/も/もも」 の方が、合計14437(=-283+7546-4158+4669+17+7219-573)でコストが小さいことがわかります。ちなみに「すもも/もも/も」のコストは14721(=-283+7546+62+7219-4518+4669+26) です。

「すもももももももものうち」のラティスの方にこの連接コストのラベルまで書き込むと非常にごちゃごちゃする(そもそも上の図は品詞で区別してないので、連接コストを書き込むには品詞ごとにノードを分ける必要もある)ので、図は掲載しませんが、MeCabの内部では、ありうる全ルートの中から最もコストが小さくなるパスを探索して、形態素解析結果として出してくれています。

以上のようにして、MeCabでは形態素解析を実現しています。MeCab本体には日本語の文法などの情報は定義されていないそうで、あくまでも辞書によって定義された2種類のコスト情報だけでこれほどの精度の形態素解析を実現しているというのはすごいですね。

MeCabの出力形式を変更する

今回もMeCabの話です。
今回はMeCabの出力の形式をカスタマイズする方法を紹介します。

何もオプションを指定しなかった場合の出力形式については以前記事にしたことがあります。
参考: MeCabの出力フォーマット

MeCabを起動する時に引数で指定するか、mecabrc 特定の記法で記載することで、この出力形式を変更することができます。ドキュメントはこちらです。
参考: 出力フォーマット

正直、ドキュメントを読んでいただければ大体わかると思うのですが、この記事では上記のドキュメントに書いてないことや、実際に動かした場合の挙動などを中心に紹介したいと思います。

コマンドラインの引数で指定できるオプションは以下の4種類です。
–node-format ・・・ 形態素の出力
–bos-format ・・・形態素の前に出力される文頭記号(ヘッダー)
–eos-format・・・ 形態素の後に出力される文末記号(フッター)
–unk-format・・・ 未知後の出力。指定しないと形態素と同じ形式

ドキュメント上は、 –eon-format という、 N-best出力で, N-Bestの出力が終了したときに出力する形式を指定するオプションも掲載されているのですが、これを指定してN-Best解を出力させても何も変化がありませんでした。実装されていないのかな。(version は mecab of 0.996 で確認しました。) => (追記)未実装の機能だと思っていたのですが、 –eos-format と一緒に指定すると、 –eno-format も動作しました。–eno-format単体だと動かない理由は謎です。(追記ここまで)

このまま名前では長いですが、それぞれ一文字のエイリアスがふられています。これは mecabコマンドのヘルプを見るとわかります。該当部分だけ抜粋したのが以下です。

$ mecab --help | grep STR
 -F, --node-format=STR          use STR as the user-defined node format
 -U, --unk-format=STR           use STR as the user-defined unknown node format
 -B, --bos-format=STR           use STR as the user-defined beginning-of-sentence format
 -E, --eos-format=STR           use STR as the user-defined end-of-sentence format
 -S, --eon-format=STR           use STR as the user-defined end-of-NBest format
 -x, --unk-feature=STR          use STR as the feature for unknown word

-F や -E などの方が短くて使いやすそうですね。 また、 –unk-feature という ドキュメントになかったオプションがあることもわかります。

-U (–unk-format) と -x (–unk-feature) はどちらも未知後(辞書にない単語)の出力形式を指定するものですが、 -U は出力全体を指定し、 -x の方は表層形はそのまま出力し、品詞情報部分をSTRで指定した文字列で出力するという違いがあります。
「メロス」(メロスが未知後)で実験すると次のようになります。

$ mecab -U '未知語\n'
走れメロス
走れ	動詞,自立,*,*,一段,連用形,走れる,ハシレ,ハシレ
未知語
EOS

$ mecab -x '未知語'
走れメロス
走れ	動詞,自立,*,*,一段,連用形,走れる,ハシレ,ハシレ
メロス	未知語
EOS

-U では ‘未知語\n’ と改行コード付きで指定しました。そうしないと次の単語や文末の場合はEOSとの間が開業されず連結されてしまいます。一方で、 -x の方は 表層形の後に、通常品詞などが出てくる部分のみを書き換えます。また、デフォルトで出力後に改行をしてくれるので、-x で指定する文字列の末尾に改行コードをつける必要はありません.

さて、STRで指定する文字列ですが、上の例でお見せしたような固定文字列だけではなく、さまざまな変数が使えます。上のドキュメントに全部載っているのですが、個人的に使いそうなのだけ選んで紹介します。

%s ・・・形態素種類 (0: 通常, 1: 未知語, 2:文頭, 3:文末)
%m ・・・形態素の表層文字列
%c ・・・単語生起コスト
%H ・・・素性 (品詞, 活用, 読み) 等を CSV で表現したもの
%pC ・・・1つ前の形態素との連接コスト
%pc ・・・連接コスト + 単語生起コスト (文頭から累積)
%pn ・・・連接コスト + 単語生起コスト (その形態素単独, %pw + %pC)
%phl ・・・左文脈 id
%phr ・・・右文脈 id
%f[N] ・・・csv で表記された素性の N番目の要素
%f[N1,N2,N3…] ・・・N1,N2,N3番目の素性をタブ区切りで表示

%f[N1, N2, N3…] は素性を ‘,’をデリミタとして表示、とドキュメントに書いてありますが、実際は上の一覧に書いた通り、タブ区切りで出力されます。

$ mecab -F '%m\t%f[0,1,6]\n'
すもももももももものうち
すもも	名詞	一般	すもも
も	助詞	係助詞	も
もも	名詞	一般	もも
も	助詞	係助詞	も
もも	名詞	一般	もも
の	助詞	連体化	の
うち	名詞	非自立	うち
EOS

%c で出力される単語生起コストや、%pCで表示される連接コスト、その他、 %phl / %phr の文脈 IDなどの情報は、MeCabの仕様を理解する上で非常に役に立ちました。
この辺はまた改めて記事にまとめたいと思います。

%s を使うと、辞書にあった単語と、なかった未知語を見分けることができます。未知後を特定したい場合は -Uで指定するよりも、%s でフラグをつけた方が有効そうです。こんなふうに出力すると、「メロス」が未知語だったことがわかりますね。%Hのうしろに’,’区切りでつけてしまうと、何番目の要素が%s相当なのかわかりにくいので、タブ区切りにすとか、%Hの前に出力するのがおすすめです。

$ mecab -F '%m\t%s\t%H\n'
メロスは激怒した
メロス	1	名詞,一般,*,*,*,*,*
は	0	助詞,係助詞,*,*,*,*,は,ハ,ワ
激怒	0	名詞,サ変接続,*,*,*,*,激怒,ゲキド,ゲキド
し	0	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
た	0	助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
EOS

-F や -U だけでなく、 -E (–eos-format)などでも上記の変数群は使うことができます。

ただし、 -B (–bos-format) は文頭のオプションなので、1つ前の形態素との連接コストなど、前の単語が存在する前提の情報を出力しようとするとエラーになります。気をつけましょう。

さて、僕はこのようなオプションを見るとデフォルト(未指定の場合)の指定はどのようになっているのかが気になるタイプの人です。

それで、ドキュメントを見ると次のように書いてあります。

わかち書き出力 (-Owakati), デフォルトの出力, 出力ダンプ (-Odump) は 高速性を犠牲にしたく ないために, ハードコーディングされています.

ハードコーディングされてるって情報しかなく、結局どう設定されているのかわかりませんね。ということで、ソースコード内のハードコーディングされている部分を探しました。

どうやら、 writer.cppファイルのこの部分のようです。

    // default values
    std::string node_format = "%m\\t%H\\n";
    std::string unk_format  = "%m\\t%H\\n";
    std::string bos_format  = "";
    std::string eos_format  = "EOS\\n";
    std::string eon_format  = "";

\\\\は\のエスケープなので、要するに、 “%m\t%H\n” ですね。僕はてっきり、
表層形\t品詞,品詞細分類1,品詞細分類2,品詞細分類3,活用型,活用形,原形,読み,発音
のような形式が指定されていると思っていたのですが、そんなことはなかったです。

この %H は素性等をCSVで指定したもの、と定義されていますが、実際は辞書内の文字列をそのまま表示しています。

ここまでコマンドラインでMeCabを使う場合の例を見てきましたが、もちろんこれらの設定はPythonライブラリからMeCabを使う場合も利用できます。
単純にTaggerオブジェクトを生成するときに辞書を指定すると同じように文字列で引数を渡すだけです。

試しに単語の生起コストも一緒に表示してみました。

import MeCab


# 生起コスト(%c)も表示する
tagger = MeCab.Tagger("-F %m\\t%c\\t%H\\n")
print(tagger.parse("すもももももももものうち"))
"""
すもも	7546	名詞,一般,*,*,*,*,すもも,スモモ,スモモ
も	4669	助詞,係助詞,*,*,*,*,も,モ,モ
もも	7219	名詞,一般,*,*,*,*,もも,モモ,モモ
も	4669	助詞,係助詞,*,*,*,*,も,モ,モ
もも	7219	名詞,一般,*,*,*,*,もも,モモ,モモ
の	4816	助詞,連体化,*,*,*,*,の,ノ,ノ
うち	5796	名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ
EOS
"""

MeCabの設定ファイルや辞書、ツールの配置場所をコマンドで取得する

発端としては、MeCabをより理解するために、自分でユーザー辞書を作ってみようとしました。そのためドキュメントの単語の追加方法のページを参照しました。
参考: MeCab: 単語の追加方法

そこには以下のコマンドが載っていました。

% /usr/local/libexec/mecab/mecab-dict-index -d/usr/local/lib/mecab/dic/ipadic \
-u foo.dic -f euc-jp -t euc-jp foo.csv

早速これを試そうと思ったのですが、自分の環境に /usr/local/libexec/mecab/ ディレクトリがありませんでした。

色々探し回った結果、偶然、
/usr/local/Cellar/mecab/0.996/libexec/mecab/mecab-dict-index
というパスにこのコマンドがあるのを見つけたのですが、本来はどういう手順でこれを見つけられるのかな?と思って調べた結果が今回の記事です。

結果としてわかったのは、 mecab-config というコマンドで、設定ファイルや辞書ファイル、そして今探してた辞書のコンパイルコマンドなどのコマンドの配置場所がわかるということです。

mecab-config のドキュメントを探したのですが、公式サイトではこれに関連する記述は見つかりませんでした。

そのため、とりあえず –help をつけてみたのですが、使えるオプションが羅列されただけでした。なので、それらしいものを試します。

$ mecab-config --help
Usage: mecab-config [OPTIONS]

Options:
  [--cxx]
  [--prefix[=DIR]]
  [--exec-prefix[=DIR]]
  [--libs]
  [--cflags]
  [--dicdir]
  [--libexecdir]
  [--sysconfdir]
  [--libs-only-L]
  [--libs-only-l]
  [--inc-dir]
  [--help]
  [--version]

実際にこれらのオプションをつけて動かした所、とくに dir と付いてるオプションをつけると必要なディレクトリの場所がわかることが確認できました。
例えば以下の通りです。

# 設定ファイル(つまり mecabrc) のある場所
$ mecab-config --sysconfdir
/usr/local/etc

# 辞書が配置されている場所
$ mecab-config --dicdir
/usr/local/lib/mecab/dic

# 辞書コンパイル等のコマンドの配置場所
$ mecab-config --libexecdir
/usr/local/Cellar/mecab/0.996/libexec/mecab

最後の、–libexecdir 引数をつけて実行した結果の場所に目当ての mecab-dict-index などのファイルがあります。

$ ls /usr/local/Cellar/mecab/0.996/libexec/mecab
mecab-cost-train  mecab-dict-index  mecab-test-gen
mecab-dict-gen    mecab-system-eval

この他にも辞書の配置場所がコマンドで取得できるというのは非常に便利ですね。実はローカル(Mac)とサーバー(AWS EC2)など、辞書の配置場所が違うケースがあります。デフォルトの辞書を使う場合は問題無いのですが、NEologdなどを使う場合は、辞書の配置場所によってコマンドを変える必要がありました。それが次のように統一したコマンドで呼び出せるようになります。

$ mecab -d `mecab-config --dicdir`/mecab-ipadic-neologd
# 次の書き方でも可
$ mecab -d $(mecab-config --dicdir)/mecab-ipadic-neologd

これは非常に便利だ、と思ってPythonでも同様の書き方をしようとすると、こちらはエラーになります。次のコードは動きません。

import MeCab

# 次のコードは動かない
MeCab.Tagger("-d $(mecab-config --dicdir)/mecab-ipadic-neologd")
# 以下のコードも動かない
MeCab.Tagger("-d `mecab-config --dicdir`/mecab-ipadic-neologd")

どうしてもPythonコード中で mecab-config –dicdir を使って自動的に辞書のパスを取得したい場合は、 os か subprocess などのモジュールを使ってコマンドを走らせ、その結果を取得して Taggerの引数の文字列に埋め込む必要があるようです。例えば次のようにするとできます。

import subprocess
import MeCab


dicdir = subprocess.getoutput("mecab-config --dicdir")
tagger = MeCab.Tagger(f"-d {dicdir}/mecab-ipadic-neologd")

これでローカルで開発して、辞書の配置場所が違うようなサーバーで動かすプログラムを作る場合も、共通のコードで動くようになります。

MeCabのIPA辞書の中身を確認する

前々から形態素解析の仕組みやMeCabの細かい挙動に興味があり、最近色々調べたりいじったりしています。その中で、普段使っているIPA辞書にどんな単語がどんな設定で登録されているのか見てみたくなったのでその時のメモです。主に文字コードの影響でダウンロードして開いたらみれました、とはいかなかったので記事にしました。そのため、この記事の内容は実質にテキストファイルの文字コードを変換する話です。

そもそも、IPA辞書僕はIPA辞書はコンパイル済みのものをHomebrewで入れてたので、元ファイルを見たことがなったのです。
参考: MacにMeCabをインストールする

まず、IPA辞書の入手ですが、MeCabのドキュメントのこちらのページにダウンロード先のリンクが貼ってあります。
参考: MeCab: Yet Another Part-of-Speech and Morphological Analyzer (ダウンロードの所)
直接ダウンロードする場合はこちら: ダウンロード

ダウンロードすると mecab-ipadic-2.7.0-20070801.tar.gz というファイルが入手できます。
Windowsのかたは、Lhaplus などの解凍ソフトで展開しましょう。Macの場合はダブルクリックで解凍できます。

解凍したフォルダに多くのファイルが含まれていますが、この中の{品詞名の英語表記}.csv ファイルたちが目当ての語彙ファイルです。

これをvimなどのエディタで開くと何やら文字化けして読めめません。Macでは開けないのかと思ってWindowsのメモ帳やエクセル等でも試したのですが同様に文字化けしてしまって読めないファイルでした。

file コマンドに –mime オプションをつけると、ファイルの文字コードを調べられるのでやってみた所、文字コードは iso-8859-1 だと出てきました。

$ file --mime Adj.csv
Adj.csv: application/csv; charset=iso-8859-1

これが罠でした。iso-8859-1 を開けばいいのだと思って、vimを起動し、ファイル読み込み時の文字コードを iso-8859-1 に設定し、同じファイルを開いてみた所、結局文字化けしたままでした。どうやら、本当の文字コードは EUC-JP だそうです。(fileコマンドで、iso-8859-1 が返ってきた理由は今でも不明です。)

そこで、vimの読み込み時の文字コードに、EUC-JPを追加します。

# 例として形容詞の辞書をvimで開く(この段階では文字化け。)
$ vim Adj.csv
# vimのコマンドで現在の設定を確認
:set
# 僕の環境では、以下が設定されていました。
  fileencoding=utf-8
  fileencodings=ucs-bom,utf-8,default,latin1
# 読み込み時の判定は、fileencodings を順に試すので、EUC-JPを追加
: set fileencodings=EUC-JP,ucs-bom,utf-8,default,latin1
# ファイルを再読み込み
: e

これでcsvファイルの中身を読むことができました。
EUC-JPを追記する位置ですが、fileencodings の末尾に追記すると先にutf-8で開いてしまって結局読めないので、utf-8より前に書く必要があります。.vimrc に書く方法もあるのですが、正直、utf-8よりEUC-JPを優先する設定を恒久的に入れるのは弊害が大きそうなのでお勧めしません。setコマンドで都度入れるか、.vimrcに入れるとしても不要になったら消した方が良いでしょう。

ほぼ裏技的な方法なのですが、Chromeなどのウェブブラウザを使って読むこともできました。.csvのままだと、ブラウザにドラッグ&ドロップした時にそのファイルをダウンロードしてしまうのですが、拡張子を.txt に変えてChromeにドラッグ&ドロップすると、正常に読める形で開いてくれます。

さて、これでエディタやブラウザで開くことができたのですが、肝心のファイルがそのままだと、 grep などで検索することができず結構不便です。

そこで、 iconv というコマンドで変換しましょう。(他にも nkf というツールもあるそうです。MacにはHomebrewで導入が必要。)
一番基本的な使い方以下の通りです。

$ iconv -f ENCODING -t ENCODING INPUTFILE
# -f: 元の文字コード (今回は EUC-JP)
# -t: 出力する文字コード (今回は UTF-8)

ただ、このまま使うと変換結果を標準出力にダーっと出して終わってしまうので、別ファイルにリダイレクトします。(他サイトで -o オプションで出力先を指定できる、と書いてあったのですが、僕のMacのiconvのドキュメントには-o オプションの記載がなく試しても動作しませんでした。)

$ iconv -f EUC-JP -t UTF-8 Adj.csv > utf8_Adj.csv

元ファイルとリダイレクト先ファイルを同じにして実行すると中身が消えてしまったので、上記の通り一旦別ファイルに書き出す必要があります。

find . -f あたりでファイル名の一覧を取得して、 vimか何かでそれを加工して上記のコマンドを一通り作って走らせましょう。
元のファイル名を使いたい場合は、 utf8_hoge.csv たちを hoge.csv へ上書きmvさせればOKです。

と、ここまで書きましたが結構やってみると結構面倒でした。(全ては僕のMacのiconvコマンドが-oオプションを持ってないせいですが。)

やはりnkf コマンドを使う方法も紹介しておきます。
nkf は Macには標準で入ってないので Homebrewでインストールします。

# インストール
$ brew install nkf
# インストールできたのでバージョンの確認
$ nkf --version
Network Kanji Filter Version 2.1.5 (2018-12-15)
Copyright (C) 1987, FUJITSU LTD. (I.Ichikawa).
Copyright (C) 1996-2018, The nkf Project.

まず、 -g オプションでファイルの文字コードを調べられます。

$ nkf -g Adj.csv
EUC-JP

正しくEUC-JPが出力されました。これは頼りになります。

文字コードを変換する場合は、 -w で UTF-8 に変換してくれ、 –overwrite オプションがあると元のファイルをそのまま書き換えます。

$ nkf -w --overwrite Adj.csv

これで、Adj.csv がさっとvimで開けるようになりまししたし、grepで中を検索できるようになりました。

あとはこれを全ファイルに適応すれば良いのですが、以前紹介したfindを使ったテクニックがあるのでそれを使いましょう。
参考: ディレクトリやファイルの権限を一括で修正する

$ find . -type f -exec nkf -w --overwrite {} +

さて、これで IPA辞書の関連ファイル全部が UTF-8に変換され、中身が確認しやすくなりました。

最後に注意ですが、ここで文字コードを変換したファイル群はあくまでもそのまま中身を検索したり確認する目的で使いましょう。
というのもこれをコンパイルとして辞書として使う場合、コンパイル時に文字コードを指定するオプションがあり、それを意識せずに、EUC-JPのファイルが使われている前提で動かしてしまうと危険だからです。(Makefile.amや、Makefile.in といったファイル内に、 EUC-JPとハードコーディングされている部分があります。)
その辺まで理解して必要な変更を加えてコンパイルするするのであれば大丈夫だと思いますが、不要な手間とリスクだと思うので、コンパイルして使う場合は文字コードを変更する前の状態を使いましょう。

Treasure Data でハッシュ関数計算(MD5/SHA1)

諸事情ありまして、先日 Treasure Data (Presto) で 文字列のSHA1を計算する必要が発生しました。(滅多に無いことなのですが。)
その時に方法を検証したので紹介します。ついでにMD5のやり方も紹介します。PythonやMySQLでやる場合は、MD5もSHA1もたいして変わらないのですが、Treasure Data はこの二つは事情がかなり違います。その辺も書いていきたいと思います。

まず、他のDBではどうなっているかということで、MySQLの場合を見ていきます。(僕はAWS RDSのMySQL互換のインスタンスで試してます。)
MySQLには md5 と sha1 という関数が実装されており、これらが「文字列」を受け取り、結果を「16進法表記の文字列」で返してくれます。コメントでつけてるのは結果です。
ドキュメントはこちら: 12.13 暗号化関数と圧縮関数

SELECT
    MD5('abcde'), -- ab56b4d92b40713acc5af89985d4b786
    SHA1('abcde') -- 03de6c570bfe24bfc328ccd7ca46b76eadaf4334

文字列をそのまま受け取ってくれるし、結果も文字列なので簡単ですね。

Pythonで行う方法は昔記事にしました。
参考: pythonでMD5
文字列をバイト型に変換して、専用の関数を呼び出し、結果がバイト型なのでそれを文字列に変換するのでしたね。

さて、いよいよ本題のTreasure Dataです。まず、MD5の方なのですが、実はTD_MD5というUDFが実装されており、MySQLのMD5と同じように動作します。
参考: Supported Presto and TD Functions – Product Documentation

SELECT
    TD_MD5('abcde') -- ab56b4d92b40713acc5af89985d4b786

簡単に使えて結果もMySQLと同じですね。

さて、TD_MD5の存在を知っていたので僕は SHA1についても TD_SHA1が存在すると勘違いしていました。しかし、ドキュメントの先ほどのUDFのページを見ると TD_SHA1はありません。無理矢理書いて実行しても、「Function td_sha1 not registered」と言われます。

では、Treasure Data で SHA1は計算できないのかとも思ったのですが、実はPresto自体に、SHA1という関数が定義さてれいます。(もちろんMD5もあります。)
参考: Binary Functions and Operators — Presto 0.263.1 Documentation

しかし、これつかえばMySQLと同じように動かせる!と思って試したところエラーになりました。

SELECT
    SHA1('abcde')
-- 以下エラーメッセージ
-- Unexpected parameters (varchar(5)) for function sha1. Expected: sha1(varbinary) 

ドキュメントを見ていただけるとわかる通り、PrestoのMD5やSHA1はバイナリを受け取ってバイナリを返します。要するにPythonのハッシュ関数と似たような動きをするのです。

md5(binary) → varbinary
Computes the md5 hash of binary.

sha1(binary) → varbinary
Computes the sha1 hash of binary.

ということで、文字列は受け取ってくれないで事前にキャストしましょう。

SELECT
    SHA1(CAST('abcde' AS VARBINARY)) -- A95sVwv+JL/DKMzXyka3bq2vQzQ=

CASTしてから渡すと無事に動きました。でも結果が違いますね(実は表記方法が違うだけで中身は違わないのですが。) 結果がバイナリで戻り、(たぶん)BASE64表記で帰ってきたようです。

TO_HEXっていうバイナリを16進法表記の文字列に変換する関数もあるので、それも追加します。

SELECT
    TO_HEX(SHA1(CAST('abcde' AS VARBINARY))) -- 03DE6C570BFE24BFC328CCD7CA46B76EADAF4334

MySQLの結果に近づいてきました。ただ、A-Fのアルファベットが大文字になっていますね。LOWERで小文字にしましょう。

SELECT
    LOWER(TO_HEX(SHA1(CAST('abcde' AS VARBINARY)))) -- 03de6c570bfe24bfc328ccd7ca46b76eadaf4334

これでMySQLのSHA1と同じ結果が得られました。
ちなみに、MD5の方もこれと同じ流儀で計算できます。

SELECT
    LOWER(TO_HEX(MD5(CAST('abcde' AS VARBINARY)))) -- ab56b4d92b40713acc5af89985d4b786

どう考えても TD_MD5 一発で済ませる方が簡単ですね。
将来的にTD_SHA1も実装されることを期待したいです。

Pythonの文字列を分割するメソッドたちの紹介

このブログでMeCabを使った形態素解析をするとき、MeCabが出力する文字列を行単位に分割するときはいつもstr.split(“\n”)のような書き方をしていました。しかし、最近Pythonのstrオブジェクトが、splitlines() という行単位で区切る専用のメソッドを持っているのに気づいたのでその紹介です。といっても、splitlines()で改行コードで区切れるぜ、だけだと記事が一瞬で終わってしまうので、ついでにPythonのstr型が持っている3つの分割メソッド[split()/ rsplit()/ splitlines()]をそれぞれ紹介しようと思います。

ドキュメントはこちらのページにあります。
参考: 組み込み型 — Python 3.9.4 ドキュメント

まずはいつも使っている str.split() です。
呼び出しは、 str.split(sep=None, maxsplit=-1) のように定義されています。sepが区切り文字で、maxsplit が最大何箇所で分割るかの指定です。分割された結果は最大でmaxsplit+1個の要素になるので注意してください。 デフォルトの -1 の場合は、区切れる場所全部で区切ります。

# 文字列を, (カンマ) で区切る
print("a,b,c,d,e,f,g".split(","))
# ['a', 'b', 'c', 'd', 'e', 'f', 'g']

# スペースで区切る場合
print(" a b c  d e f g ".split(" "))  # cとdの間にはスペース2個入れた
# ['', 'a', 'b', 'c', '', 'd', 'e', 'f', 'g', '']

# maxsplit = 3 を指定。 3箇所で区切られ、長さ4の配列になる
print("a,b,c,d,e,f,g".split(",", 3))
# ['a', 'b', 'c', 'd,e,f,g']

# maxsplit が十分大きい時は指定しないのと同じ
print("a,b,c,d,e,f,g".split(",", 100))
# ['a', 'b', 'c', 'd', 'e', 'f', 'g']

一つ目のセパレーター引数(sep)ですが、これを省略することができます。省略すると、半角スペースやタブ、改行などの空白文字で区切られます。そして、さらに特殊な挙動として、連続する空白文字をひとつのセパレーターとして見なすようになります。また、文字列の先頭や末尾に空白があっても、結果の最初や最後に空文字列は含まれません。それぞれ見ていきましょう。

text = """
最初に改行がある複数行のテキストです。
この行には  連続した半角スペースがあります。

タブも\t入れてみました。
この下に空白行があります。


"""
print(text.split())
# ['最初に改行がある複数行のテキストです。', 'この行には', '連続した半角スペースがあります。', 'タブも', '入れてみました。', 'この下に空白行があります。']

split(” “)だと、スペースが連続してたらり、先頭/末尾にスペースがあると結果の配列に空白文字列が含まれていましたが、それがないのがわかりますね。また、複数の種類の文字(半角スペースやタブや改行)で、全部区切ってくれるのも便利です。

続いて、str.rsplit() を見ていきましょう。 使い方は str.rsplit(sep=None, maxsplit=-1) であり、splitと同じです。 挙動もほぼ同じなのですが、splitとの違いは右から順番に分割していくことです。maxsplit を指定しないと、分割できるところは全部分割するので、splitとrsplitの結果は同じになります。それでは、maxsplit に少し小さめの数値を設定して違いを見てみましょう。

text = "a,b,c,d,e,f,g"
print(text.split(",", 2))
# ['a', 'b', 'c,d,e,f,g']

print(text.rsplit(",", 2))
# ['a,b,c,d,e', 'f', 'g']

雰囲気伝わったでしょうか。

URLを / で分解して、右からn番目の要素を取るなどの使い方ができそうですね。
うちのブログだと、右から2番目がカテゴリを表す文字列です。

# このブログの記事URLの例
url ="https://analytics-note.xyz/machine-learning/scikit-learn-n-gram/"

# 右から4分割した結果。
print(url.rsplit("/", 3))
# ['https://analytics-note.xyz', 'machine-learning', 'scikit-learn-n-gram', '']

# インデックス0 にhttpsからドメイン名まで入る
print(url.rsplit("/", 3)[0])
# https://analytics-note.xyz

# インデックス1がカテゴリ名
print(url.rsplit("/", 3)[1])
# machine-learning

そして、最後が str.splitlines()です。 使い方は str.splitlines([keepends]) であり、セパレーターも分割回数も指定できません。keepends は 分割結果に末尾の改行コードを残すかどうかで、Trueと判定される何かを入れておくと改行コードが残ります。あまり必要ないので、何も入れなくていいでしょう。

import MeCab
tagger = MeCab.Tagger()
result = tagger.parse("すもももももももものうち")


# MeCabの結果を表示しておく
print(result)
"""
すもも	名詞,一般,*,*,*,*,すもも,スモモ,スモモ
も	助詞,係助詞,*,*,*,*,も,モ,モ
もも	名詞,一般,*,*,*,*,もも,モモ,モモ
も	助詞,係助詞,*,*,*,*,も,モ,モ
もも	名詞,一般,*,*,*,*,もも,モモ,モモ
の	助詞,連体化,*,*,*,*,の,ノ,ノ
うち	名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ
EOS
"""

# splitlines() で分割した配列
print(result.splitlines())
"""
[
'すもも\t名詞,一般,*,*,*,*,すもも,スモモ,スモモ',
'も\t助詞,係助詞,*,*,*,*,も,モ,モ',
'もも\t名詞,一般,*,*,*,*,もも,モモ,モモ',
'も\t助詞,係助詞,*,*,*,*,も,モ,モ',
'もも\t名詞,一般,*,*,*,*,もも,モモ,モモ',
'の\t助詞,連体化,*,*,*,*,の,ノ,ノ',
'うち\t名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ',
'EOS'
]
"""

splitlines() でサクッと行ごとに分離できていますね。いつも使っているsplit(“\n”)との違いも見ておきましょう。

print(result.split("\n"))
"""
[
'すもも\t名詞,一般,*,*,*,*,すもも,スモモ,スモモ',
'も\t助詞,係助詞,*,*,*,*,も,モ,モ',
'もも\t名詞,一般,*,*,*,*,もも,モモ,モモ',
'も\t助詞,係助詞,*,*,*,*,も,モ,モ',
'もも\t名詞,一般,*,*,*,*,もも,モモ,モモ',
'の\t助詞,連体化,*,*,*,*,の,ノ,ノ',
'うち\t名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ',
'EOS',
''
]
"""

一見同じ結果に見えますが、最後に”という空白文字列の要素が入ってます。不要な’EOS’部分を切り捨てるときに切り落とす長さがかわるのでこれは注意して使いましょう。

こうしてみると、 splitlines でも split でもどちらを使っても良さそうですが、splitlinesには明確なメリットがあります。ドキュメントを見ていただけると一覧表が載っているのですが、splitlinesはかなり多くの環境の様々な改行コードや垂直タブ、改ページなど多くの特殊文字で区切ってくれます。split(“\n”)だと、改行コードが違う別の環境ではちょっと動作が不安なので、splitlinesを使った方が汎用性の高いコードになると期待できます。

少し気になる挙動なのですが、「{改行コード}テキスト{改行コード}」という文字列を分解すると、先頭の{改行コード}では区切って1要素目に空白文字列を返してくるのに、末尾の{改行コード}は、その後ろに何もなければ{改行コード}を消して終わります。

print("""
先頭と末尾に改行コード
""".splitlines()
)
# ['', '先頭と末尾に改行コード']

なぜこのような挙動になるのか調べたのですが、結果的に、keepends を指定して動きを見ると理解できました。

"""
aaa
bbb
""".splitlines(True)
# ['\n', 'aaa\n', 'bbb\n']

上記のコードの通り、splitlinesは「改行コードの後ろ」で区切ってるんですね。そして、keependsがFalse(もしくは未指定)の場合は、この区切られた結果から末尾の改行コードを消しているようです。

"""
aaa
bbb
""".splitlines()
# ['', 'aaa', 'bbb']

split() (sep指定なし)は、連続する空白文字をまとめて一つのセパレーターとして扱っていましたが、splitlinesにはそのような機能はなく、普通に改行の数だけ区切って空白文字列だけの要素を返してきます。

print("""この下に連続した改行



この上に連続した改行""".splitlines()
)
# ['この下に連続した改行', '', '', '', 'この上に連続した改行']

今回の記事で紹介したメソッドたちは大変シンプルな機能なのですが、不注意に使うと思ってた結果と要素数やインデックス等がずれたりするので、慣れるまではよく確認しながら使いましょう。

Pythonを使ってよく連続する文字列を検索する

前回の記事で紹介したn-gram(といっても今回使うのはユニグラムとバイグラム)の応用です。
テキストデータの中から高確率で連続して登場する単語を探索する方法を紹介します。
参考: scikit-learnで単語nグラム

コーパスとして、昔作成したライブドアニュースコーパスをデータフレームにまとめたやつを使います。
参考: livedoorニュースコーパスのファイルをデータフレームにまとめる

今回の記事で使うライブラリの読み込み、データの読み込み、さらに分かち書きに使う関数の準備とそれを使った単語の形態素解析まで済ませておきます。

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

# データの読み込み
df = pd.read_csv("./livedoor_news_corpus.csv")
# ユニコード正規かとアルファベットの小文字統一
df.text = df.text.str.normalize("NFKC").str.lower()

# 分かち書きの中で使うオブジェクト生成
tagger = MeCab.Tagger("-d /usr/local/lib/mecab/dic/mecab-ipadic-neologd")


def mecab_tokenizer(text):
    # テキストを分かち書きする関数を準備する
    parsed_lines = tagger.parse(text).split("\n")[:-2]
    surfaces = [l.split('\t')[0] for l in parsed_lines]
    features = [l.split('\t')[1] for l in parsed_lines]
    # 原型を取得
    bases = [f.split(',')[6] for f in features]
    # 各単語を原型に変換する
    token_list = [b if b != '*' else s for s, b in zip(surfaces, bases)]
    return " ".join(token_list)

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

さて、データの準備が整ったので本題の処理の方に移っていきましょう。
やることは簡単で、ユニグラムモデル(単語単位のBoW)とバイグラムモデル(2単語連続で学習したBoW)を作成します。そして、「単語1」の出現回数で、「単語1 単語2」の出現回数を割ることによって、「単語1」の後に「単語2」が出現する確率を求めてそれがある程度より高かったらこの2単語は連続しやすいと判断します。また逆に、「単語2」の出現回数で、「単語1 単語2」の出現回数を割って同様の判定もかけます。

そのためにまず、バイグラムとユニグラムの単語の出現回数の辞書を作成します。
それぞれモデルを作ってBoWにし、語彙と出現回数のペアの辞書へと変換します。

# モデル作成
uni_model = CountVectorizer(
        token_pattern='(?u)\\b\\w+\\b',
        ngram_range=(1, 1),
        min_df=30,
    )
bi_model = CountVectorizer(
        token_pattern='(?u)\\b\\w+\\b',
        ngram_range=(2, 2),
        min_df=30,
    )

# BoWへ変換
uni_bow = uni_model.fit_transform(df["tokens"])
bi_bow = bi_model.fit_transform(df["tokens"])

# 学習した語彙数
print(len(uni_model.get_feature_names()), len(bi_model.get_feature_names()))
# 6921 12227

# 出現回数の辞書へ変換
uni_gram_count_dict = dict(zip(
        uni_model.get_feature_names(),
        uni_bow.toarray().sum(axis=0)
    ))
bi_gram_count_dict = dict(zip(
        bi_model.get_feature_names(),
        bi_bow.toarray().sum(axis=0)
    ))

これで計算に必要な情報が揃いました。min_df は少し大きめの30にしていますが、これは利用するコーパスの大きやさかける時間、求める精度などによって調整してください。(少し大きめの値にしておかないと、特にバイグラムの語彙数が膨れ上がり、次の処理が非常に時間がかかるようになります。)

さて、これで出現回数の情報が得られたのでこれを使って「単語1」の次に来やすい「単語2」を探してみましょう。余りたくさん出てきても困るので95%以上の確率で続くなら出力するようにしたのが次のコードです。

for uni_word, uni_count in uni_gram_count_dict.items():
    # uni_word: ユニグラムモデルでカウントした単語
    # uni_count: 上記単語が出現した回数

    # 対象の単語で始まる単語ペアにマッチする正規表現
    pattern = f"^{uni_word}\\b"
    target_bi_gram = {k: v for k, v in bi_gram_count_dict.items() if re.match(pattern, k)}
    for bi_words, bi_count in target_bi_gram.items():
        # bi_words: バイグラムモデルでカウントした単語ペア
        # bi_count: 上記単語ペアが出現した回数

        if bi_count / uni_count >= 0.95:
            print(bi_words, f"{bi_count}回/{uni_count}回")

# 以下出力の先頭の方の行
"""
1677 万 73回/73回
2106 bpm 107回/107回
75m bps 68回/70回
84回 アカデミー賞 94回/96回
888 毎日 41回/42回
angrybirds 風 41回/41回
bci 3 81回/81回
blu ray 339回/339回
deji 通 614回/619回
details id 160回/162回
digi 2 322回/326回
icecream sandwich 939回/941回
kamikura digi 89回/89回
katsuosh digi 56回/56回
let s 76回/79回
-- 以下省略 -- 
"""

途中正規表現を使っていますが、これは、選択中の「単語1」に対して「単語1 単語2」という文字列に一致させるものです。^は先頭、\\b(エスケープされて実際は\b)は単語区切りにマッチします。

出力はたくさん出ますので、先頭の方を上のコード中に例示しました。
84回 アカデミー賞 94回/96回 は 「84回」って単語が96回登場し、そのうち、94回は「84回 アカデミー賞」と続いたという意味です。
1677 万 などは 1677万画素って単語の一部ですね。

続いて、ある単語の後ろではなく前に登場しやすい単語も探してみましょう。
これは、正規表現のpatternが少し違うだけです、と行きたかったのですがもう1箇所違います。re.matchが先頭マッチの探索しかしてくれないので、正規表現でマッチさせるところのメソッドがre.searchになります。

for uni_word, uni_count in uni_gram_count_dict.items():

    # 対象の単語で終わる単語ペアにマッチする正規表現
    pattern = f"\\b{uni_word}$"
    target_bi_gram = {k: v for k, v in bi_gram_count_dict.items() if re.search(pattern, k)}
    for bi_words, bi_count in target_bi_gram.items():
        if bi_count / uni_count >= 0.95:
            print(bi_words, f"{bi_count}回/{uni_count}回")

# 以下出力の先頭の方の行
"""
iphone 3gs 38回/38回
第 84回 96回/96回
成長率 888 41回/42回
msm 8960 118回/118回
with amazlet 38回/38回
の angrybirds 41回/41回
パック bci 81回/81回
2106 bpm 107回/109回
apps details 160回/162回
after effects 63回/64回
パッケージ ffp 77回/77回
wi fi 869回/882回
モバイルwi fiルーター 136回/136回
-- 以下省略 -- 
"""

こちらもうまく出力されましたね。読み解き方はは先ほどと同じです。

そもそも、なぜこのような処理を作ろうと思ったかと言うと、MeCab等で分かち書きした時に、本当は1単語なのに複数単語に分かれてしまっているようなものを効率よく検索したかったためです。

この記事のコードでは新語辞書(mecab-ipadic-neologd)を使ってるので、あからさまなものは少ないですが、デフォルトのIPA辞書を使うと、 クラウド が 「クラ」と「ウド」に分かれている例などがポロポロ見つかります。

この記事の目的は上のコードで果たしたので以下は補足です。

さて、今回の結果を辞書の改善等に使うことを考えると、ここで出力された単語ペアを結合させて放り込んでいけば良さそうに見えます。しかし、実際は話はそう単純ではありません。
例えば、「wi fi」とか、「モバイルwi fiルーター」といった単語が出てきていますが、なるほど、辞書にWiFIやモバイルWiFiルーターが含まれてないんだな、と勘違いしそうになります。

しかし実際は、Neologdを使うと、どちらも正しく形態素解析できていて、「Wi-Fi」や「モバイルWi-Fiルーター」と言う単語で出力されるんですね。いつ分かれているかと言うと、sickit-learnが学習する時に-(ハイフン)を単語境界文字として扱っているのでここで切ってしまっています。

同様の例として、「ウォルト ディズニー」などもあります。これも実はMeCabは「ウォルト・ディズニー」と一単語にしているのに、scikit-learnが「・」で勝手に区切ってます。

このほかにも、出力を見ていくと「くだける 充電」と言うのが出てきますが、世の中に「くだける充電」という単語があるのか、と考えると間違えます。
これは元のテキストを見ると「おくだけ充電」という言葉があり、これが、「お」「くだけの原型のくだける」「充電」と形態素解析されて後半の2単語がくっついて出てきたものです。

このほか、「特有 の」とか「非常 に」のように、確かによく連続するんだろうけど、これは単語として分かれるのが正常だよね、って言う例も多く確認はかなり手間なようでした。

そもそも出力が意外に多かったです。(サンプルコードでは0.95以上としましたが、本気で探すならこの閾値はもっと下げた方が良さそうです。しかしそれをやるとどんどん出力が増えます。)

本当は1単語なのに間違って分かれてしまっている単語を探す、と言う目的に対しては、思ったよりノイズが多かったのですが、それでもかなり有効な方法だと思うので同様の課題をお持ちの方は試してみてください。細かくは書いていませんが、形態素解析する段階で品詞を絞っておく(例えば名詞のみする)とか、てにをは的なワードを除いておくなど改善の余地多いので色々試すのも楽しいです。