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

scikit-learnで単語nグラム

自然言語処理の前処理に、nグラム(n-gram)という概念があります。
これは隣り合って出現したn単語のことです。nの値が小さいときは特別な名前がついていて、
n=1の場合をユニグラム(unigram)、n=2の場合をバイグラム(bigram)、n=3の場合をトライグラム(trigram)と呼びます。

ネットで検索すると、「n単語」のことではなく、隣り合って出現した「n文字」をnグラムと言うという説明も見かけた(例えば、Wikipediaもそうです)ので、誤解を避けるためこの記事のタイトルは単語nグラムとしましたが、面倒なので以下記事中でnグラムと書いたら単語nグラムを指すものとします。
「言語処理のための機械学習入門」などの書籍でも、単語nグラムのことをnグラムと呼んでいるのでおかしくはないと思っています。(P. 62)

自然言語処理を勉強し始めた駆け出しの頃、テキストをBoWでベクトル化する時に、ユニグラムだけでなく、バイグラムを加えて単語間のつながりを考慮するというアイデアを知って、これはいいアイデアだと思って試したりしました。しかし、バイグラムを使って機械学習の精度が上がった経験というのはほとんどなく、その後もたまに試すけど有効だった覚えがほぼ無くだんだん試さなくなってきていました。
しかし、最近機械学習とは少し違う目的でnグラムを使いたいことがあったので、この機会にsciki-learnでBoWを作る時のnグラム関連の引数の挙動をまとめておこうと思ったのでこの記事に整理していきます。

今回は、サンプルに使うテキストデータはあらかじめ分かち書きしたやつを用意しておきます。題材はいつもメロスなので今回は幸福の王子にしました。(青空文庫から拝借)

corpus = [
    "町 の 上 に 高い 柱 が そびえる 、 その 上 に 幸福 の 王子 の 像 が 立つ て いる ます た 。",
    "王子 の 像 は 全体 を 薄い 純金 で 覆う れる 、 目 は 二つ の 輝く サファイア で 、 王子 の 剣 の つ か に は 大きな 赤い ルビー が 光る て いる ます た 。",
    "王子 は 皆 の 自慢 です た 。",
    "「 風見鶏 と 同じ くらい に 美しい 」 と 、 芸術 的 だ センス が ある という 評判 を 得る たい がる て いる 一 人 の 市会 議員 が 言う ます た 。",
    "「 もっとも 風見鶏 ほど 便利 じゃ ない が ね 」 と 付け加える て 言う ます た 。",
    "これ は 夢想 家 だ と 思う れる ない よう に 、 と 心配 する た から です 。",
    "実際 に は 彼 は 夢想 家 なんか じゃ ない た の です が 。",
]

さて、早速やっていきましょう。
scikit-learnのテキストの前処理には、BoWを作るCountVectorizer と、 tf-idfを作るTfidfVectorizer がありますが、nグラムに関しては両方とも同じくngram_range という引数で設定することができます。(最小)何グラムから(最大)何グラムまでを学習に含めるかをタプルで指定するもので、デフォルトは、 ngram_range=(1, 1) です。(ユニグラムのみ)。

バイグラムを学習させたければ(2, 2)と指定すればよく、ユニグラムからトライグラムまで学習したいなら(1, 3)です。

とりあえず、(1, 2)でやってみます。1文字の単語も学習させるため、token_patternも指定します。

from sklearn.feature_extraction.text import CountVectorizer


# モデル作成
bow_model = CountVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    ngram_range=(1, 2),
)

# 学習
bow_model.fit(corpus)

# 学習した単語の先頭10個
print(bow_model.get_feature_names()[:10])
# ['ある', 'ある という', 'いる', 'いる ます', 'いる 一', 'か', 'か に', 'から', 'から です', 'が']

get_feature_names() で学習した単語を取得してみましたが、「ある」「いる」「か」などの1単語の語彙に混ざって、「ある という」「いる ます」などのバイグラムの語彙も混ざっていますね。

ユニグラムとバイグラムで合計、194の語彙を学習しているので、このモデルを使ってBoWを作ると、テキスト数(7) * 語彙数(194)の疎行列になります。

print(len(bow_model.get_feature_names()))
# 194

print(bow_model.transform(corpus).shape)
# (7, 194)

さて、もう少しライブラリの挙動を詳しくみていきましょう。次はstopwordとの関連です。
CountVectorizer は stop_wordsという引数で学習に含めない単語を明示的に指定できます。
試しにてにをは的な文字をいくつか入れていみます。(ここでは学習対象はバイグラムだけにします)

# モデル作成
bow_model = CountVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    ngram_range=(2, 2),
    stop_words=["て", "に", "を", "は", "が", "の", "た",]
)

# 学習
bow_model.fit(corpus)

# 学習した単語の一部
print(bow_model.get_feature_names()[50: 60])
# ['夢想 家', '大きな 赤い', '実際 彼', '家 だ', '家 なんか', '市会 議員', '幸福 王子', '彼 夢想', '得る たい', '心配 する']

学習した語彙の一部を表示していますが、わかりやすいのは「幸福 王子」というペアが含まれていることです。これはもちろん「幸福 の 王子」の部分から学習されたものです。
「の」が stopwordに含まれているので、まず、「の」が取り除かれて、「幸福 王子」になってからバイグラムの学習が行われたのだとわかります。
「幸福 の」と「の 王子」を学習してからstopwordを除くわけでは無いということが確認できました。

ちなみに、token_patternがデフォルトの場合、1文字の単語は学習されませんが、この時も似たような挙動になります。

# モデル作成
bow_model = CountVectorizer(
    ngram_range=(2, 2),
)

# 学習
bow_model.fit(corpus)

# 学習した単語の一部
print(bow_model.get_feature_names()[30: 40])
# ['同じ くらい', '夢想 なんか', '夢想 思う', '大きな 赤い', '実際 夢想', '市会 議員', '幸福 王子', '得る たい', '心配 する', '思う れる']

続いて、学習結果に含まれる単語を出現頻度で間引くmid_df/max_df の挙動を確認しておきます。min_dfの方がわかりやすいのでそちらを例に使います。
min_df=3(3テキスト以上に含まれる単語だけ学習する。3回以上では無いので注意)と指定してみます。

# モデル作成
bow_model = CountVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    ngram_range=(1, 2),
    min_df=3,
)

# 学習
bow_model.fit(corpus)

# 学習した単語
print(bow_model.get_feature_names())
# ['いる', 'が', 'た', 'て', 'て いる', 'です', 'と', 'ない', 'に', 'の', 'は', 'ます', 'ます た', '王子']

「て いる」は3テキストに含まれているので学習されていますね。その一方で「王子 は」は1テキストにしか含まれていないので学習結果に含まれていません。「王子」と「は」はそれぞれ3テキスト以上に含まれていますが、「王子 は」という並びで登場したのが1回だけだったので対象外になっているのです。

このことから、min_dfによる間引きはまずn-gramを学習してその後に行われていることがわかりますね。stop_wordsと実行タイミングが違うので注意しましょう。

例示はしませんがmax_dfも話は同様です。ちなみに、 max_df=0.7 とすると、 ユニグラムの「た」は学習結果らから除外されて、バイグラムの「ます た」などは含まれることが確認できます。

Pythonのリストをn個に分割する

めったに使わないのですが、前回の記事がリストをn個ずつに分割するだったので今回はリストをn個のリストに分割する方法を紹介します。
ちなみに、目的が機械学習のクロスバリデーションであれば、scikit-learnに専用のメソッドがあるのでそちらを使いましょう。
今回の記事はそれ以外の用途で、何かしらの事情があってリストをn分割する必要が発生した時に使います。

さて、まず簡単に思いつくのは前回の記事同様にリストのスライスを使う方法です。
元のデータのサイズをnで割って区切り位置を決め、その位置で区切ります。
コードにすると次のようになりますね。
例として、サイズが23のデータを5分割しています。
途中、スライスする位置をintで整数に丸めているのは、単にスライスの表記が整数しか受け付けないからです。

# サンプルのデータ生成
data = list(range(23))
print(data)
# [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22]
print(len(data))
# 23

# 5分割する
n = 5
size = len(data)

for i in range(n):
    start = int((size*i/n))
    end = int((size*(i+1)/n))
    print(data[start:end])
"""
[0, 1, 2, 3]
[4, 5, 6, 7, 8]
[9, 10, 11, 12]
[13, 14, 15, 16, 17]
[18, 19, 20, 21, 22]
"""

特に何も変哲のないコードですし、無事にリストが5分割されました。

ただ、一点気持悪いというか少なくとも僕の好みには合わない点があります。
それが分割結果の各リストのサイズです。数えてみると、4個、5個、4個、5個、5個、となっています。23が5で割り切れないので、数が不揃いになるのは仕方ないのですが、個人的には、4/4/5/5/5 か、 5/5/5/4/4 のどちらかで切りたいです。

しかし、これを実装するのはそこそこ手間がかかります。元のデータ長を分けたいグループ数で整数除算し、商とを余を求めて分割後の各グループに属する要素数を求め、その要素数から区切り位置を決め、その位置で切る手順をコードに起こす必要があるからです。
やってみたのが次のコードです。(確認用のprint文や説明のコメントのせいで余計に面倒なコードに見えてしまっていますね。)

import numpy as np


# data は上のコード例と同じものを使う。
data = list(range(23))
n = 5
size = len(data)

# データの件数を分けたいグループ数で割って商と余りを求める
quotient, remainder = divmod(size, n)
print("商:", quotient)
# 商: 4
print("余り:", remainder)
# 余り: 3

# [0] に続けて各グループの要素数を指定するリストを作る
section_sizes = ([0] + remainder * [quotient+1] + (n-remainder) * [quotient])
print(section_sizes)
# [0, 5, 5, 5, 4, 4]

# 累積和をとって、スライスする点のリストにする
slice_points = list(np.cumsum(section_sizes))
print(slice_points)
# [0, 5, 10, 15, 19, 23]

# 作成したスライス位置を使ってリストを切る
for i in range(n):
    start = slice_points[i]
    end = slice_points[i+1]
    print(data[start:end])

"""
[0, 1, 2, 3, 4]
[5, 6, 7, 8, 9]
[10, 11, 12, 13, 14]
[15, 16, 17, 18]
[19, 20, 21, 22]
"""

はい、これで、5個、5個、5個、4個、4個、に区切れましたね。

途中累積和を取るためにnumpyをインポートしてcumsumまで使っています。
ただ、どうせnumpyを使うことになるのであれば、実はnumpyに専用のメソッドが用意されているので断然そちらがお勧めです。

参考: numpy.array_split

numpyのarray用に実装されたメソッドだと思いますが、ただのlistに対しても動作してくれます。これを使うと、たったこれだけのコードになります。

data = list(range(23))
n = 5
print(np.array_split(data, n))
"""
[array([0, 1, 2, 3, 4]),
 array([5, 6, 7, 8, 9]),
 array([10, 11, 12, 13, 14]),
 array([15, 16, 17, 18]),
 array([19, 20, 21, 22])]
"""

めっちゃ簡単ですね。メソッドの戻り値はn分割した各グループのリストになります。
分割された各グループは array 型に変換されるのでその点だけ注意してください。
元のデータがarray型でなくても結果はarray型になります。

Pythonのリストをn個ずつに分割する

今回の記事はPythonのlistのスライスの小ネタです。リストを長さnのリストに分割する方法を紹介します。

これは次のようなコードで実現できます。
例として、長さが17のリストを生成し、n=5個ずつに分けてprintしています。

data = list(range(17))
print(data)
# [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
print(len(data))
# 17

n = 5
for i in range(0, len(data), n):
    print(data[i: i+n])

"""
[0, 1, 2, 3, 4]
[5, 6, 7, 8, 9]
[10, 11, 12, 13, 14]
[15, 16]
"""

range(0, len(data), n)によって、 0, 5, 10, 15 というn=5 ずつ増える数列を生成して、[i: i+5]というスライスで配列を切り出しているだけですね。

さっと書いて動くのを見ると何の変哲もないコードのように思えますが、実はPythonのlistのスライスの非常に便利な仕様を活用しています。
それは、スライスの範囲が元のリストのインデックスからはみ出していても問題なく動くと言うことです。

i=15の時、[i: i+5]は[15: 20]ですが、元のlist は長さが17なので、20番目の要素などありません。しかし、このスライスは、切り出せた分だけ切り出して動いてくれるのでこのコードがエラーにならずに動作するのです。

もしこの仕様がなければ、i+nが配列の長さを超えていないかどうかで分岐を一つ書く必要があるところでした。

ちなみに、スライスではなくインデックスで要素を取り出す場合は当然ですがインデックスの最大値を超えた値を入れるとエラーになります。

print(data[15: 20])  # スライスの範囲がlistの長さを超えていても動く
# [15, 16]

print(data[100: 200])  # これも動く
# []

try:
    print(data[20])  # これはエラーになる
except Exception as e:
    print(e)
# list index out of range

正直この技を使う機会はあまりないのですが、例えばAmazon Comprehendような1回に渡せるデータ数に上限があるAPIで大量のデータを処理するときなどに利用できます。
APIが受け入れてくれる上限のデータ数でリストを区切って順に実行したりできますね。