Pythonで日付や日時の差分を求める

前回の記事で日付の加算の話を書いたので今回は差分の話を書こうという記事です。
ドキュメントはこちらですかね。
参考: datetime — 基本的な日付型および時間型

Pythonの日付オブジェクト(datetime) は普通に引き算ができ、timedeltaオブジェクトを返してきます。

from datetime import datetime


dt1 = datetime(2021, 11, 25, 8, 0, 0)
print(dt1)
# 2021-11-25 08:00:00
dt2 = datetime(2021, 11, 10, 15, 0, 0)
print(dt2)
# 2021-11-10 15:00:00

# datetimeオブジェクトは引き算ができる
dt_delta = dt1 - dt2
# 引き算の戻り値はtimedeltaオブジェクト
print(type(dt_delta))
# <class 'datetime.timedelta'>

# 引き算の結果の表示
print(dt_delta)
# 14 days, 17:00:00

上の例で言えば二つの日付の差分は14日と17時間であることがわかりますね。
プログラムにおいて、この14日や17時間という情報を取り出したくなることがあります。

timedelta オブジェクトは、days (日数) と second (秒数) を属性として持っているのでそれが使えます。(実際はこの他にも microseconds というのも持ってます。元の時刻がマイクロ秒単位で計測してる場合はこれも使えます。)

print(dt_delta.days)
# 14
print(dt_delta.seconds)
# 61200
# 時間数が必要なら60*60=3600で割る
print(dt_delta.seconds/3600)
# 17.0

また、total_seconds() というメソッドもあり、これを使うと日付の差分の総秒数が得られます。こちらはマイクロ秒の情報も小数で得られます。整数部分は秒数です。

print(dt_delta.total_seconds())
# 1270800.0

# 以下の計算結果と同じ
dt_delta.days * 60 * 60 * 24 + dt_delta.seconds
# 1270800

日付の差分がマイナスになる場合は少し注意が必要です。
days は負の値になりますが、 secondsは正の値になります。
14日と17時間、の符号反転が -15日と7時間 になるわけですね。

print(dt1)
# 2021-11-25 08:00:00
print(dt2)
# 2021-11-10 15:00:00

# マイナスになる差分
dt_delta2 = dt2-dt1
print(dt_delta2)
# -15 days, 7:00:00
print(dt_delta2.days)
# -15
print(dt_delta2.seconds)
# 25200   (= 7*60*60)

このマイナス日付と正の時間という組み合わせが使いにくいと感じる場合、組み込み関数のabs(絶対値)で符合反転させることもできます。

print(abs(dt_delta2))
# 14 days, 17:00:00

ここまで datetime オブジェクト(日時)の話をしてきましたが、date(日付)オブジェクトでも話は同様です。普通に引き算ができ、timedelta オブジェクトが帰ってきます。
0しか入ってきませんがsecond属性も持ってます。

from datetime import date


date1 = date(2021, 11, 25)
print(date1)
# 2021-11-25
date2 = date(2021, 11, 10)
print(date2)
# 2021-11-10

# datetimeオブジェクトは引き算ができる
date_delta = date1 - date2
# 引き算の戻り値はtimedeltaオブジェクト
print(type(date_delta))
# <class 'datetime.timedelta'>

# 引き算の結果の表示
print(date_delta)
# 15 days, 0:00:00
print(date_delta.days)
# 15
print(date_delta.seconds)
# 0

datetimeライブラリには、datetmeやdateの他に、timeというオブジェクトもありますが、実はこのtimeについては差分が取れません。これは気をつけましょう。

from datetime import time


time1 = time(15, 30, 20)
print(time1)
# 15:30:20
time2 = time(8, 20, 14)
print(time2)
# 08:20:14

# timeオブジェクトは引き算できない
try:
    time_delta = time1 - time2
except Exception as e:
    print(e)
# unsupported operand type(s) for -: 'datetime.time' and 'datetime.time'

ちなみに、datetimeとdateを引き算することもできません。

date1 = date(2021, 11, 25)
print(date1)
# 2021-11-25
dt1 = datetime(2021, 11, 25, 10, 00, 00)
print(dt1)
# 2021-11-25 10:00:00 

try:
    dt1 - date1
except Exception as e:
    print(e)
# unsupported operand type(s) for -: 'datetime.datetime' and 'datetime.date'

時刻がからむ差分を取るときは、型をdatetimeに揃えて行うようにしましょう。

Pythonで日付の加算、特にnヶ月後やn年後の日付を求める方法

たまに必要になると、 dateutil の relativedelta の存在をど忘れしていて何度も調べているので記事にまとめておきます。1ヶ月後の日付が欲しければ relativedelta 使え、で終わる記事なのですがそれだけだとあんまりなので、datetimeモジュールのtimedelta オブジェクトなどの紹介も合わせてまとめていきます。

さて、日付データとかを扱うプログラムを書いていると、n時間後とかn日後の時刻が必要になることはよくあります。Pythonのdatetimeモジュールでは、datetime.datetime.timedelta オブジェクトを使うことで、それを計算することができます。
参考: timedelta オブジェクト

class datetime.timedelta(days=0, seconds=0, microseconds=0, milliseconds=0, minutes=0, hours=0, weeks=0)

クラス定義を見てわかる通り、n日後ならdaysにnを指定、n時間後ならhoursにnを指定するなどして使います。

from datetime import datetime
from datetime import timedelta


# 元の時刻
dt1 = datetime(2021, 11, 25, 8, 0, 0)
print(dt1)
# 2021-11-25 08:00:00

# 3日後を計算
dt2 = dt1 + timedelta(days=3)
print(dt2)
# 2021-11-28 08:00:00

# 10時間前を計算
dt3 = dt1 - timedelta(hours=10)
print(dt3)
# 2021-11-24 22:00:00

# 以下の書き方でも可能
dt4 = dt1 + timedelta(hours=-10)
print(dt4)
# 2021-11-24 22:00:00

簡単ですね。ちなみにこのtimedelta、datetimeオブジェクトだけでなく、PandasのDataFrameの datetime型の列やdatetime型のSeriesに対しても演算することができます。
Series でやってみます。元のデータに5時間足しています。

import pandas as pd


sr = pd.to_datetime(pd.Series(["2021-11-01", "2021-11-02", "2021-11-03"]))
print(sr)
"""
0   2021-11-01
1   2021-11-02
2   2021-11-03
dtype: datetime64[ns]
"""

print(sr + timedelta(hours=5))
"""
0   2021-11-01 05:00:00
1   2021-11-02 05:00:00
2   2021-11-03 05:00:00
dtype: datetime64[ns]
"""

weeks などの引数もあるので、3週間後、とか8週間前などの計算も可能です。ただ、僕はもうdays使って21日とか56日として計算してしまうことが多いです。

複数の引数を同時に指定することもでき、 timedelta(days=1, hours=12) とすると要するに36時間後などの計算もできます。 (単純なので実行例のコードは省略。)

前置きが長くなってきたので、続いてnヶ月後やn年後の日付を計算する方法に移ります。

先述の timedelta のclass 定義を見ていただくとわかる通り、これの引数には月や年を表す引数は用意されていません。そのため、timedelta で 1ヶ月後や1年後の日付を算出したい場合は、days=30やdays=365などで代用することになるのですが、当然月によって日数は違いますし、年についても閏年の問題があります。

そこで登場するのが、記事冒頭であげた dateutil のrelativedelta です。
ドキュメントはこちら。
参考: dateutil – powerful extensions to datetime — dateutil 2.8.2 documentation

timedelta と似たような感じの使用感で使え、nヶ月後やnヶ月前、やn年後やn年前の日付を簡単に算出できます。

from dateutil.relativedelta import relativedelta


# 元の時刻
dt1 = datetime(2021, 11, 25, 8, 0, 0)
print(dt1)
# 2021-11-25 08:00:00

# 1ヶ月後
dt2 = dt1 + relativedelta(months=1)
print(dt2)
# 2021-12-25 08:00:00

# 1年前
dt3 = dt1 - relativedelta(years=1)
print(dt3)
# 2020-11-25 08:00:00

1点注意があり、relativedelta は months や years の他に、month や year のような複数形のsがつかない引数も取ることができます。days に対する day なども同様です。このsがつかない方を指定すると、時刻を加算するのではなく、書き換えます。
relativedelta(months=1) は 1ヶ月後を計算しますが、 relativedelta(month=1)は 月を1月にするのです。間違えやすいので気をつけましょう。

# 元の時刻
dt1 = datetime(2021, 11, 25, 8, 0, 0)
print(dt1)
# 2021-11-25 08:00:00

# 2ヶ月後
dt2 = dt1 + relativedelta(months=2)
print(dt2)
# 2022-01-25 08:00:00

# 2月に書き換える
dt3 = dt1 + relativedelta(month=2)
print(dt3)
# 2021-02-25 08:00:00

以前、Presot(トレジャーデータ)で1ヶ月後の日付を算出するとき、月によって日数が違う(1月は31日、2月は28か29日など)ことによって、少し厄介な現象が起きるということを説明した記事を書いたことがあります。
参考: Prestoで1ヶ月後の時刻を求める時に気をつけること

要するにつぎの3点ですね。

  1. 異なる日付の±nヶ月後が同じ日付になることがある
  2. ある日付のnヶ月後のnヶ月前が元の日付と異なることがある
  3. 2つの時間のnヶ月後を計算すると時間の前後関係が入れ替わることがある

これらの現象は、relativedeltaを使っても全く同じように発生します。

dt1 = datetime(2021, 1, 29)
dt2 = datetime(2021, 1, 31)
print(dt1)
# 2021-01-29 00:00:00
print(dt2)
# 2021-01-31 00:00:00

# 1/29 と 1/31 の1ヶ月後はどちらも 2/28
print(dt1 + relativedelta(months=1))
# 2021-02-28 00:00:00
print(dt2 + relativedelta(months=1))
# 2021-02-28 00:00:00

# 1/29 の 1ヶ月後の1ヶ月前は 1/28で元に戻らない
print(dt1 + relativedelta(months=1) - relativedelta(months=1))
# 2021-01-28 00:00:00

dt3 = datetime(2021, 1, 29, 15, 0, 0)
dt4 = datetime(2021, 1, 31, 8, 0, 0)
# dt3 より dt4 の方が新しい時刻
print(dt3 < dt4)
# True

# dt3の1ヶ月後 より dt4の1ヶ月後の方が古い時刻
print(dt3 + relativedelta(months=1) < dt4 + relativedelta(months=1))
# False

月単位ほど頻繁にあることではありませんが、閏年の2/29が絡むと年単位の演算でも似たような現象が発生します。

利用するときは気をつけて使いましょう。機能や分析の要件によっては、1ヶ月後よりも30日後を使った方が安全な場面もあると思います。

relativedelta はもう1つデメリットがあります。これ、Pandas のSeriesに対しては使えないのです。さっきの timedeltaとは対照的ですね。
無理矢理実行すると例外が発生し、サポートされないって言われます。

sr = pd.to_datetime(pd.Series(["2021-11-01", "2021-11-02", "2021-11-03"]))
try:
    sr + relativedelta(months=1)
except Exception as e:
    print(e)
# unsupported operand type(s) for +: 'DatetimeArray' and 'relativedelta'

PandasのDataFrameの列や、Seriesに対して1ヶ月後の日付を求めたかったら、applyでlambda関数使って対応しましょう。

sr = pd.to_datetime(pd.Series(["2021-11-01", "2021-11-02", "2021-11-03"]))
print(sr.apply(lambda x: x + relativedelta(months=1)))
"""
0   2021-12-01
1   2021-12-02
2   2021-12-03
dtype: datetime64[ns]
"""

MeCabでユーザー辞書を作って単語を追加する

最近ずっとMeCabの話が続いていますがまたMeCabの話です。今回の記事では辞書に登録されていない単語を辞書に追加する方法を紹介します。
今日時点の情報ですが、neologdの更新が2020年9月14日を最後に止まってしまっている今となっては非常に重要なテクニックだと思います。

MeCab辞書への単語登録については、こちらのページに記載されています。
参考: 単語の追加方法
ただし、主に文字コード周りの問題もありこちらのドキュメントをただそのまま実行してもうまくいきませんでした。入手したファイルの文字コード変換などドキュメントにない手順も実行しているので、この記事を参考に作業される方は慎重に自己責任で行ってください。

単語の追加は、「システム辞書」へ追加する方法と「ユーザ辞書」へ追加する方法の二つがあります。速度等の面で「システム辞書」への追加の方がメリットがあるようなのです。しかし、まだ慣れないうちにそちらを書き換えてしまうのは怖いのでシステム辞書は初期設定のまま残したいのと、登録内容を変えながら色々試したいので、まずはユーザ辞書を使う方法を試してみます。

サンプルのテキストは、前回の制約付き解析機能の記事で適当に作った
「このブログはクラウド環境で動いています」を使いましょう。
IPA辞書では、「ブログ」と「クラウド」が未知語です。%sで形態素種別を出すと、ブログが1(未知語)で、クラウドも辞書にないのでクラとウドに分かれてるのがわかりますね。

$ mecab -F %m\\t%s\\t%H\\n
このブログはクラウド環境で動いています
この	0	連体詞,*,*,*,*,*,この,コノ,コノ
ブログ	1	名詞,一般,*,*,*,*,*
は	0	助詞,係助詞,*,*,*,*,は,ハ,ワ
クラ	0	名詞,固有名詞,一般,*,*,*,クラ,クラ,クラ
ウド	0	名詞,一般,*,*,*,*,ウド,ウド,ウド
環境	0	名詞,一般,*,*,*,*,環境,カンキョウ,カンキョー
で	0	助詞,格助詞,一般,*,*,*,で,デ,デ
動い	0	動詞,自立,*,*,五段・カ行イ音便,連用タ接続,動く,ウゴイ,ウゴイ
て	0	助詞,接続助詞,*,*,*,*,て,テ,テ
い	0	動詞,非自立,*,*,一段,連用形,いる,イ,イ
ます	0	助動詞,*,*,*,特殊・マス,基本形,ます,マス,マス
EOS

さて、単語の追加方法の記事に従って、辞書データを作っていきましょう。ついでなのですが、このブログのタイトルの「分析ノート」も登録しておきます。

辞書のもとになる語彙ファイル名は sample.csv としました。作業場所は任意のディレクトリで良いようです。

辞書のフォーマットは、
表層形,左文脈ID,右文脈ID,コスト,品詞,品詞細分類1,品詞細分類2,品詞細分類3,活用型,活用形,原形,読み,発音
ですが、コスト値や文脈IDが空のCSVファイルをもとにそれらを埋メタファイルを生成してくれる -a オプションというのがあるようなので、それを使う前提で左右文脈IDとコストの3列は空にします。

作ったCSVファイルをcatしたのが以下です。IPA辞書のデータを見る限りでは、最後に改行をつけないようにし、ファイル末尾に空行ができないようにした方が良さそうです。

$ cat sample.csv
ブログ,,,,名詞,一般,*,*,*,*,ブログ,ブログ,ブログ
クラウド,,,,名詞,一般,*,*,*,*,クラウド,クラウド,クラウド
分析ノート,,,,名詞,固有名詞,一般,*,*,*,分析ノート,ブンセキノート,ブンセキノート

次に、コストの自動推定の準備をしていきます。モデルファイルと辞書データが必要です。

まずモデルファイルというのが必要なので、ドキュメントにあるリンクから入手してください。直接ダウンロードす場合はここ。mecab-ipadic-2.7.0-20070801.model.bz2 というファイル名で手に入り、bz2という拡張子でわかる通り、bzip2という方法で圧縮されているので解凍しておきます。また、文字コードがEUC-JPなので、UTF-8に変換しておきます。(この種の手順がドキュメントにない手順です。)

$ bunzip2 mecab-ipadic-2.7.0-20070801.model.bz2
# 解凍したファイルがあることを見る
$ ls
mecab-ipadic-2.7.0-20070801.model
# 文字コード確認
$ nkf -g mecab-ipadic-2.7.0-20070801.model
EUC-JP
# 文字コード変換
$ nkf -w --overwrite mecab-ipadic-2.7.0-20070801.model

また、このモデルファイルの中(6行目)に、文字コードがeuc-jpだと設定されている部分があるので、utf-8に書き換えておきます。

$ vim mecab-ipadic-2.7.0-20070801.model
# 元の6行目
charset: euc-jp
# 以下の内容に変更して保存
charset: utf-8

次に、システム辞書のデータを入手しておきます。実はいつも使っているHomebrewで入れたコンパイル済みのIPA辞書のデータを使えると思っていたのですが、後に紹介するコスト推定コマンドで、以下のエラーが出ました。

no such file or directory: /usr/local/lib/mecab/dic/ipadic/feature.def

IPA辞書をHomebrewで入れると、feature.def ファイルが辞書フォルダにないみたいですね。

コンパイル前の辞書データについては、以前の記事で書きました通り入手できます。記事の通り文字コードの変換も必要なのでやっておきましょう。
参考: MeCabのIPA辞書の中身を確認する

こちらのページから、mecab-ipadic-2.7.0-20070801.tar.gzをダウンロードして展開しておきます。(Macはダブルクリックで展開可能)
参考: MeCab: Yet Another Part-of-Speech and Morphological Analyzer

文字コードを変換して、辞書フォルダにおいておきます。
(コンパイル前の辞書データ一式だけあればいいのかと思っていたのですが、コンパイル済みの各ファイル群はそれはそれで必要らしいので、不足していたfeature.defをそこに持っていきます)

$ cd mecab-ipadic-2.7.0-20070801
$ nkf -w --overwrite feature.def
$ cp feature.def /usr/local/lib/mecab/dic/ipadic/
$ ls /usr/local/lib/mecab/dic/ipadic/

{辞書ディレクトリ}/ipadic/配下の他のファイル群は、実体がCellarディレクトリ配下にあり、リンクになっているのですが、feature.defだけ実態がここに置かれることになり嫌な感じです。しかし、今回は一旦これで進めます。

これで、 CSVファイル、モデルファイル、辞書データの3つが揃いました。

さて、ここからコマンドで作業していきます。
ドキュメントでは、
/usr/local/libexec/mecab/mecab-dict-index
というコマンドが叩かれていますが、MacにHomeBrewでインストールした場合はパスが変わります。
こちらの記事に探し方書いてますので、コマンドのパスはこちらから探してください。
参考: MeCabの設定ファイルや辞書、ツールの配置場所をコマンドで取得する

僕の環境では、以下のパスでした。
/usr/local/Cellar/mecab/0.996/libexec/mecab/mecab-dict-index

一応コマンドのヘルプも見ておきます。

$ /usr/local/Cellar/mecab/0.996/libexec/mecab/mecab-dict-index -h
MeCab: Yet Another Part-of-Speech and Morphological Analyzer

Copyright(C) 2001-2012 Taku Kudo
Copyright(C) 2004-2008 Nippon Telegraph and Telephone Corporation

Usage: /usr/local/Cellar/mecab/0.996/libexec/mecab/mecab-dict-index [options] files
 -d, --dicdir=DIR                    set DIR as dic dir (default ".")
 -o, --outdir=DIR                    set DIR as output dir (default ".")
 -m, --model=FILE                    use FILE as model file
 -u, --userdic=FILE                  build user dictionary
 -a, --assign-user-dictionary-costs  only assign costs/ids to user dictionary
 -U, --build-unknown                 build parameters for unknown words
 -M, --build-model                   build model file
 -C, --build-charcategory            build character category maps
 -s, --build-sysdic                  build system dictionary
 -m, --build-matrix                  build connection matrix
 -c, --charset=ENC                   make charset of binary dictionary ENC (default EUC-JP)
 -t, --charset=ENC                   alias of -c
 -f, --dictionary-charset=ENC        assume charset of input CSVs as ENC (default EUC-JP)
 -w, --wakati                        build wakati-gaki only dictionary
 -p, --posid                         assign Part-of-speech id
 -F, --node-format=STR               use STR as the user defined node format
 -v, --version                       show the version and exit.
 -h, --help                          show this help and exit.

それでは、先ほど作った辞書のcsvファイルに、文脈IDとコストを埋めていきましょう。
以下のようにファイルやディレクトリがあるのを確認してコマンドを組み立てていきます。
(本当は他にも作業中に出たフィルがありますが省略。)

$ ls
mecab-ipadic-2.7.0-20070801.model
sample.csv 

出来上がったコマンドが以下です。sample2.csv というファイルにコストや品詞IDが埋まったデータを出力します。(ブログ幅の影響で見た目が複数行になってしまっていますが1行で打ち込むか、改行をエスケープするかしてください。)

$ /usr/local/Cellar/mecab/0.996/libexec/mecab/mecab-dict-index -m mecab-ipadic-2.7.0-20070801.model -d /usr/local/lib/mecab/dic/ipadic -u sample2.csv -f utf-8 -t utf-8 -a sample.csv

以下のように出力され、 sample2.csvファイルが出来上がります。 中を見ると、抜けてたデータが補完されているのがわかります。

mecab-ipadic-2.7.0-20070801.model is not a binary model. reopen it as text mode...
reading sample.csv ...
done!

# 出来上がったファイルの中身を見る
$ cat sample2.csv
ブログ,1285,1285,3285,名詞,一般,*,*,*,*,ブログ,ブログ,ブログ
クラウド,1285,1285,3285,名詞,一般,*,*,*,*,クラウド,クラウド,クラウド
分析ノート,1288,1288,8019,名詞,固有名詞,一般,*,*,*,分析ノート,ブンセキノート,ブンセキノート

いよいよこの左右品詞idと正規コストのそろったデータからユーザ辞書を作ります。
コマンドは以下の通りです。

$ /usr/local/Cellar/mecab/0.996/libexec/mecab/mecab-dict-index -d /usr/local/lib/mecab/dic/ipadic -u sample.dic -f utf-8 -t utf-8 sample2.csv

# 以下出力
reading sample2.csv ... 3
emitting double-array: 100% |###########################################|

done!

sample.dic ってファイルが出来上がります。

出来上がったユーザ辞書を利用するときは、 -u オプションで辞書ファイルを指定します。
システム辞書を指定する -d は 辞書があるディレクトリを指定、ユーザ辞書を指定する -u は辞書ファイルを指定という違いがあるので注意してください。
念の為helpを抜粋しておきます。

$ mecab --help
MeCab: Yet Another Part-of-Speech and Morphological Analyzer

Copyright(C) 2001-2012 Taku Kudo
Copyright(C) 2004-2008 Nippon Telegraph and Telephone Corporation

Usage: mecab [options] files
 -d, --dicdir=DIR               set DIR  as a system dicdir
 -u, --userdic=FILE             use FILE as a user dictionary

では使ってみましょう。未知語で無くなっているのを見るために形態素種別(%s)も表示します。

$ mecab -F %m\\t%s\\t%H\\n -u sample.dic
このブログはクラウド環境で動いています
この	0	連体詞,*,*,*,*,*,この,コノ,コノ
ブログ	0	名詞,一般,*,*,*,*,ブログ,ブログ,ブログ
は	0	助詞,係助詞,*,*,*,*,は,ハ,ワ
クラウド	0	名詞,一般,*,*,*,*,クラウド,クラウド,クラウド
環境	0	名詞,一般,*,*,*,*,環境,カンキョウ,カンキョー
で	0	助詞,格助詞,一般,*,*,*,で,デ,デ
動い	0	動詞,自立,*,*,五段・カ行イ音便,連用タ接続,動く,ウゴイ,ウゴイ
て	0	助詞,接続助詞,*,*,*,*,て,テ,テ
い	0	動詞,非自立,*,*,一段,連用形,いる,イ,イ
ます	0	助動詞,*,*,*,特殊・マス,基本形,ます,マス,マス
EOS

想定通りきちんと動作しましたね。

MeCabの制約付き解析機能を試す

MeCabに制約付き解析(部分解析)という機能があるのでそのメモです。
参考: 制約付き解析(部分解析)

入力文の一部の形態素情報が既知である、あるいは境界がわかっているときに、 それを満たすように解析する機能です。

と書いてある通り、一部の形態素情報がわかっているときにそれを満たすように解析します。例えば、IPA辞書には「クラウド」という単語が無いので、IPA辞書を使って「クラウド」を含むテキストを分解すると「クラ」と「ウド」という2単語に分かれて出てきます。(実はN-Best解を表示していくとその中には未知語の「クラウド」として出力してくれるものも出てくるのですが。)

import MeCab


tagger = MeCab.Tagger()
print(tagger.parse("このブログはクラウド環境で動いています"))
"""
この	連体詞,*,*,*,*,*,この,コノ,コノ
ブログ	名詞,一般,*,*,*,*,*
は	助詞,係助詞,*,*,*,*,は,ハ,ワ
クラ	名詞,固有名詞,一般,*,*,*,クラ,クラ,クラ
ウド	名詞,一般,*,*,*,*,ウド,ウド,ウド
環境	名詞,一般,*,*,*,*,環境,カンキョウ,カンキョー
で	助詞,格助詞,一般,*,*,*,で,デ,デ
動い	動詞,自立,*,*,五段・カ行イ音便,連用タ接続,動く,ウゴイ,ウゴイ
て	助詞,接続助詞,*,*,*,*,て,テ,テ
い	動詞,非自立,*,*,一段,連用形,いる,イ,イ
ます	助動詞,*,*,*,特殊・マス,基本形,ます,マス,マス
EOS
"""

本来は、「クラウド」を辞書登録するべきなのですが、応急処置として、今回の文書では「クラウド」は1単語だよ、と明示できるのが制約付き解析です。

使い方は簡単で、次の二つを変更するだけです。
– MeCab起動時に -p (–partial) オプションをつける。(Pythonの場合はTagger生成時に”-p”を指定)
– 解析結果を指定したい部分は、「表層\t素性パターン」という形式で記入する。

「表層\t素性パターン」になっていない行は文断片とみなされ、普通に形態素解析されます。

素性パターン はワイルドカードとして * (アスタリスク)を使っても良いですし、名詞などとしても良いようです。「*,非自立」のようにして品詞の第二分類が非自立のものといった指定もできます。また、実は任意の文字列を設定することもでき、その場合は設定した文字列がそのまま出てきます。動詞や形容詞など品詞の一種であっても未知語処理の都合上、その文字列に当てはまらない品詞だった場合はそのまま表示されるようです。

とりあえずワイルドカードで「クラウド」だけ指定してやってみます。

tagger = MeCab.Tagger("-p")
print(tagger.parse(
    """
このブログは
クラウド\t*
環境で動いています。
    """
))
"""
この	連体詞,*,*,*,*,*,この,コノ,コノ
ブログ	名詞,一般,*,*,*,*,*
は	助詞,係助詞,*,*,*,*,は,ハ,ワ
クラウド	名詞,一般,*,*,*,*,*
環境	名詞,一般,*,*,*,*,環境,カンキョウ,カンキョー
で	助詞,格助詞,一般,*,*,*,で,デ,デ
動い	動詞,自立,*,*,五段・カ行イ音便,連用タ接続,動く,ウゴイ,ウゴイ
て	助詞,接続助詞,*,*,*,*,て,テ,テ
い	動詞,非自立,*,*,一段,連用形,いる,イ,イ
ます	助動詞,*,*,*,特殊・マス,基本形,ます,マス,マス
。	記号,句点,*,*,*,*,。,。,。
EOS
"""

「クラウド」が一単語としてみなされましたね。今回指定した「クラウド」は未知語なので読みなどは出てきませんが、制約付き解析で指定した単語が辞書にある場合はちゃんと読みも出てきます。

さて、ここからが応用です。前回の記事でN-Best解表示時のコスト計算にバグがある、という話を書いていますが実はこの制約付き解析を使うと、正しいコストが出力できます。

この先は前回の記事と見比べながら読んでください。
参考: MeCabのN-Best解のコストがどこで間違っているのか確認した

前回の記事では「すもももももももものうち」の4つ目の解析結果を取り上げました。

$ mecab -N 4 -E EOS\\t%pc\\n
すもももももももものうち
#####################################
#1~3個目の結果は省略します。以下は4番目の解。#
#####################################
すもも	名詞,一般,*,*,*,*,すもも,スモモ,スモモ
もも	名詞,一般,*,*,*,*,もも,モモ,モモ
も	助詞,係助詞,*,*,*,*,も,モ,モ
も	助詞,係助詞,*,*,*,*,も,モ,モ
もも	名詞,一般,*,*,*,*,もも,モモ,モモ
の	助詞,連体化,*,*,*,*,の,ノ,ノ
うち	名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ
EOS	21245

この、 21245は1個目のN-Best解の累積コストであって、4番目の解の累積コストでは無いのですが、そのずれの原因が、連接コストの1箇所が本当は 478 なのに、-4203 として計算されてしまっていることを確認したのでした。(詳細は前回の記事。)

ということは、本当のこの解のコストの総和は、 $21245 + (478-(-4203)) = 25926$ のはずです。

これが実は制約付き解析を使うと出力できます。

やり方は簡単で、コストの表示と制約付きを指定して起動したMeCabに、N-Best解で表示された文字列を渡すだけです。

tagger = MeCab.Tagger(
    "-p" +
    " -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")
print(tagger.parse(
    """すもも\t名詞,一般,*,*,*,*,すもも,スモモ,スモモ
もも\t名詞,一般,*,*,*,*,もも,モモ,モモ
も\t助詞,係助詞,*,*,*,*,も,モ,モ
も\t助詞,係助詞,*,*,*,*,も,モ,モ
もも\t名詞,一般,*,*,*,*,もも,モモ,モモ
の\t助詞,連体化,*,*,*,*,の,ノ,ノ
うち\t名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ"""
))

# 以下出力結果
"""
すもも	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	478	5147	20202	助詞,係助詞,*,*,*,*,も,モ,モ
もも	0	1285	1285	7219	17	7236	27438	名詞,一般,*,*,*,*,もも,モモ,モモ
の	0	368	368	4816	-4442	374	27812	助詞,連体化,*,*,*,*,の,ノ,ノ
うち	0	1313	1313	5796	-5198	598	28410	名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ
EOS	3	0	0	0	-2484	-2484	25926	EOS
"""

前回の記事に倣って、それぞれの単語の生起コストや各単語間の連接コストも全部表示したので、かえって見にくくなってて申し訳ないのですが、最終的な累積コストとして25926が出てきましたね。

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
"""