分かち書くなら… – Python の textwrap の話?

先日のを書く際にわかってなかったわけではない。

分かち書くなら… – Python の textwrap の話?

長い長い前置き

当たり前だが「日本語を分かち書きに変換する」ための有名なインフラとして、日本が誇る(?)京都大学(と NTT)による MeCab がある。分かち書きに変換、というか「形態要素分析」を行うプログラム・ライブラリね。

単独のプログラムとしても使えるが、「ライブラリ」。Python バインディングが「あることはある」。けれども少なくとも私は Windows での動作に成功出来てない:

  • natto-py (FFIで libmecab にインターフェイスするヤツ) が Windows で動かないみたい。(access violation かな?)
  • mecab-python、mecab-python3 ともに Windows 版で pip インストールに失敗する。何が足りてない?

natto は使いやすそうなんだけどねぇ。残念だ。

で、結果 Python から使おうと思うならプロセス呼び出し経由が今の私には唯一の選択肢となるわけなんだけれど。

いまさら気付いたんだけどね、「プロセスを開きっぱなしにしたまま随時標準入力を流し込んで、その都度標準出力をキャプチャする」のって、今の Python でやり方変わってる? 昔、記憶だと Python 2.6 以前では subprocess だけで出来たんだけれど、今は asyncio なしには(少なくとも Windows では)出来なくなってる。つまり(死にかけの) Python 2.7 では出来ない。

subprocess.Popencommunicate のソースを読むと「stdin を close() して stdout を read()」としてるんだけれど、まさにこれをしないとハングアップしてしまう。いわゆるブロッキングモードでの読み込みだから、かと思うんだけど、制御の仕方がわからん。たぶん Unix では今でも出来ちゃうんではないかと思うんだけど、ともあれ Windows では「書き込むべき stdin を閉じるまでは stdout はブロックしてしまう」。

以前に紹介した ffmpy3 がまさに asyncio で ffmpeg の stdout をキャプチャしてるんで、asyncio が追加されたあとの Python 3 前提にしてそれを真似れば、まぁそういうのは書ける。(簡単だよ、ソースを是非読んでみて欲しい。)

よって、Python 2.7 を切り捨てた情報にとりあえずしたくないので、subprocess 利用の中でも最もやりたくないパターン、つまり「必要になるたびにプロセスを起こす」で。それこそソケット通信の口でもあれば少しはマシだったはずだけど、 mecab は「ライブラリ or プログラムの標準入出力」という二択なのでやむを得ない。


じゃぁさっそく Python から「分かち書きのために」…:

 1 # -*- coding: utf-8 -*-
 2 from __future__ import unicode_literals
 3 
 4 import subprocess
 5 
 6 
 7 _mecab_path = "c:/Program Files (x86)/MeCab/bin/mecab"
 8 
 9 def wakati(self, s):
10     p = subprocess.Popen(
11         [_mecab_path, "-O", "wakati"],
12         stdin=subprocess.PIPE, stdout=subprocess.PIPE)
13     return p.communicate(s.encode("utf-8"))[0].decode("utf-8")

と言いたいところだけれど、これをするなら何も Python から呼び出す必要はないと思うぞ。元のドキュメント相手にシェルスクリプトでこうすりゃよかろ:

wakati.sh
1 #! /bin/sh
2 _mecab_path=${_mecab_path:-"c:/Program Files (x86)/MeCab/bin/mecab"}
3 
4 "${_mecab_path}" -O wakati
1 [me@host: ~]$ ./wakati.sh < input.txt > output.txt

実際問題としては「なにゆえに Python から呼び出してまで使いたいのか」はたぶん大きく二つの理由だと思う:

  1. html のテキストノード以外には適用したくない、など「ドキュメント全体相手」を許容出来ない場合
  2. -O wakati の結果に不満がある

後者はやってみればわかる:

-O wakati の結果例
1 “ 【 オリコン 】 Perfume 、 武道館 ライブ が BD 総合 首位 歴代 1 位 タイ の 5 作 目 ” . ORICON STYLE ( oricon ME ) 
2 “ 2 / 14 ( 土 ) ファンクラブ 会員 限定 ! USTREAM 、 「 P . T . A . TV 」 決定 !!”. Perfume Official Site NEWS . Amuse Inc 
3 “ Perfume / STAR TRAIN ” . CDjournal . 株式会社 音楽 出版 社 
4 “ Perfume / 不自然 な ガール / ナチュラル に 恋し て ” . CDjournal . 株式会社 音楽 出版 社 
5 広島 県 広島 市 における 土砂 災害 に対する 被災 地 支援 募金 の ご 報告 Perfume Official Site 
6 “ Perfume 、 初 の アリーナ ツアー に 追加 公演 決定 ”. 音楽 ナタリー . ナターシャ 
7 Perfume× 伊勢丹 コラボ ! 新 PV 制作 、 ハイヒール 販売 、 館内 マップ も ナタリー 
8 『 bridge 』 vol . 56 p . 154 のっ ち 第 4 発言 

辞書のメンテナンスでどうにかなる問題もあるはずだが、基本的にはこれは「読むための用途にとってはやり過ぎ」てこと。読めないことはないけれど、やはりかなり不自然だ。逆に言えば「読むためではなくテキストの解析だけが目的なら」これで良く、なのでやはり Python から都度呼び出す旨みは全然ないってこと。


じゃぁさっそく Python から「textwrap のために」…。

これも「ちょっと待て」。

今回の投稿のタイトルが「分かち書くなら… – Python の textwrap の話?」なのはまぁそういうことだわ。正直 Mecab で形態要素解析までするんだったら、もはや textwrap に頼らず「形態要素解析に基づいて自分で折り返し処理を書く」とこまでやっちゃった方が間違いなくハッピーになれる。

なので「それでも textwrap のために、のネタのまま続けるけれど、頑張りたい人は頑張れ、私はやらない」。

ここまでが長い長い前置き。

てわけであんまし価値のない本題

本題でないほうにしかオイシイ話はないと思うんだよね。けど懲りずに続ける。

まず、Python 絡ませずに mecab そのものを「-O wakati なしで」呼び出すとこんな具合ね:

 1 [me@host: ~]$ cat input.txt
 2 Perfume×伊勢丹コラボ!新PV制作、ハイヒール販売、館内マップも ナタリー
 3 『bridge』vol.56 p.154のっち第4発言
 4 Perfume×伊勢丹コラボ!新PV制作,ハイヒール販売,館内マップも ナタリー
 5 [me@host: ~]$ mecab < input.txt # mecab へのパスが通ってるとして
 6 Perfume×	名詞,一般,*,*,*,*,*
 7 伊勢丹	名詞,固有名詞,組織,*,*,*,伊勢丹,イセタン,イセタン
 8 コラボ	名詞,一般,*,*,*,*,*
 9 !	記号,一般,*,*,*,*,!,!,!
10 新	接頭詞,名詞接続,*,*,*,*,新,シン,シン
11 PV	名詞,一般,*,*,*,*,*
12 制作	名詞,サ変接続,*,*,*,*,制作,セイサク,セイサク
13 、	記号,読点,*,*,*,*,、,、,、
14 ハイヒール	名詞,一般,*,*,*,*,ハイヒール,ハイヒール,ハイヒール
15 販売	名詞,サ変接続,*,*,*,*,販売,ハンバイ,ハンバイ
16 、	記号,読点,*,*,*,*,、,、,、
17 館内	名詞,固有名詞,地域,一般,*,*,館内,タテウチ,タテウチ
18 マップ	名詞,一般,*,*,*,*,マップ,マップ,マップ
19 も	助詞,係助詞,*,*,*,*,も,モ,モ
20 ナタリー	名詞,固有名詞,人名,名,*,*,ナタリー,ナタリー,ナタリー
21 EOS
22 『	記号,括弧開,*,*,*,*,『,『,『
23 bridge	名詞,固有名詞,組織,*,*,*,*
24 』	記号,括弧閉,*,*,*,*,』,』,』
25 vol	名詞,固有名詞,組織,*,*,*,*
26 .	名詞,サ変接続,*,*,*,*,*
27 56	名詞,数,*,*,*,*,*
28 p	名詞,一般,*,*,*,*,*
29 .	名詞,サ変接続,*,*,*,*,*
30 154	名詞,数,*,*,*,*,*
31 のっ	動詞,自立,*,*,五段・ラ行,連用タ接続,のる,ノッ,ノッ
32 ち	動詞,自立,*,*,五段・ラ行,体言接続特殊2,ちる,チ,チ
33 第	接頭詞,数接続,*,*,*,*,第,ダイ,ダイ
34 4	名詞,数,*,*,*,*,*
35 発言	名詞,サ変接続,*,*,*,*,発言,ハツゲン,ハツゲン
36 EOS
37 Perfume×	名詞,一般,*,*,*,*,*
38 伊勢丹	名詞,固有名詞,組織,*,*,*,伊勢丹,イセタン,イセタン
39 コラボ	名詞,一般,*,*,*,*,*
40 !	記号,一般,*,*,*,*,!,!,!
41 新	接頭詞,名詞接続,*,*,*,*,新,シン,シン
42 PV	名詞,一般,*,*,*,*,*
43 制作	名詞,サ変接続,*,*,*,*,制作,セイサク,セイサク
44 ,	名詞,サ変接続,*,*,*,*,*
45 ハイヒール	名詞,一般,*,*,*,*,ハイヒール,ハイヒール,ハイヒール
46 販売	名詞,サ変接続,*,*,*,*,販売,ハンバイ,ハンバイ
47 ,	名詞,サ変接続,*,*,*,*,*
48 館内	名詞,一般,*,*,*,*,館内,カンナイ,カンナイ
49 マップ	名詞,一般,*,*,*,*,マップ,マップ,マップ
50 も	助詞,係助詞,*,*,*,*,も,モ,モ
51 ナタリー	名詞,固有名詞,人名,名,*,*,ナタリー,ナタリー,ナタリー
52 EOS

貼り付けた結果からは伝わりにくいが、「伊勢丹 名詞,」の「伊勢丹」の後のはこれはタブコードね。なおかつテキストにタブが元々含まれていてもこれは無視される。ゆえに、この出力の結果の解釈で混乱することはない。

さて、(価値のない)本題、は要は「このトークン全部を空白で join するのではなく」てことである。特定の種類のものだけ空白を挟む(もしくは挟まない)としたいわけである。ここまでやるからこそ「この価値のない本題は価値がある」てことね、よろしい?

色々考えなければならんことは多いけれど、「それほど煩雑にならずに、なおかつほどよく満足」というのが目指す水準であるならば、ホワイトリスト方式で「切りたくないとこだけ空白を入れない」のが良さそうだ(いわゆる禁則関係だけを扱うてこと)。

ひとまず最も単純に「句読点のとこだけ空白入れない」ということなら:

 1 # -*- coding: utf-8 -*-
 2 from __future__ import unicode_literals
 3 
 4 import re
 5 import subprocess
 6 
 7 
 8 _mecab_path = "c:/Program Files (x86)/MeCab/bin/mecab"
 9 
10 
11 #
12 def _call_mecab(s):
13     p = subprocess.Popen(
14         [_mecab_path],
15         stdin=subprocess.PIPE, stdout=subprocess.PIPE)
16     return p.communicate(s.encode("utf-8"))[0].decode("utf-8")
17 
18 
19 #
20 def _split_mecab_result(s):
21     paras_toks = [
22         x.rstrip() for x in re.split(r"^EOS\r?$", s, flags=re.M)
23         if x.rstrip()]
24     #
25     return [
26         [
27             [f.split(",") if i == 1 else f
28              for i, f in enumerate(line.split("\t"))]
29             for line in re.split("\r?\n", x) if line]
30         for x in paras_toks]
31 
32 
33 #
34 def wakati(s):
35     paras = _split_mecab_result(_call_mecab(s))
36     result = []
37     for para in paras:
38         tmp = []
39         for i, (sur, inf) in enumerate(para):
40             dont = False
41             if inf[1] in ("句点", "読点"):
42                 dont = True
43             if i > 0 and not dont:
44                 tmp.append(" " + sur)
45             else:
46                 tmp.append(sur)
47         result.append("".join(tmp))
48     return result
49 
50 
51 #
52 if __name__ == '__main__':
53     print("\n".join(
54         wakati(
55             """\
56 Perfume×伊勢丹コラボ!新PV制作、ハイヒール販売、館内マップも ナタリー
57 『bridge』vol.56 p.154のっち第4発言
58 """)))

結果はこうなる:

1 Perfume× 伊勢丹 コラボ ! 新 PV 制作、 ハイヒール 販売、 館内 マップ も ナタリー
2 『 bridge 』 vol . 56 p . 154 のっ ち 第 4 発言

if inf[1] in ("句点", "読点"): の部分を頑張れば、割と望みのものになると思う。私は今回はひとまずここまでしかやらないけど。

補足と蛇足

textwrap はどーした問題については言わんといて。前回のネタと大差ないんだもん。「分かち書けばマシな結果になる」てだけのこと。

そして、「ここまでやるなら」問題ね。textwrap いらんやんけ、ていずれはなってくるわけな、これを突き進めると。それはそれでいいと思うけれど、まぁ今の私の興味ではないので割愛。なんにせよ「Mecab 前提にしちゃってる時点でもう簡単」だよ。Mecab がやってくれてることが如何にデカいか、てこと。

最後にもうひとつだけ。「価値がない、価値がない、価値なんかあるはずない」と言い続けたけれど、本当はこれに近いことが本当に欲しくなることがある。

まさに「機械処理相手のテキスト」の場合なんだよ。Python 翻訳プロジェクトでまさにこれで困ったことが多いんだけれど、Sphinx (とそれが前提にしている docutils) が reStructuredText を処理する際に、まさにこの「分かち書きしてない」ことが問題を起こすことがある。たとえば

1 強調したければこのように**アスタリスク二つ**

は docutils は強調のマークアップとは判断しない(出来ない)。

1 強調したければこのように **アスタリスク二つ**

のように空白が必要。Python 公式ドキュメントで変な記号が残ってるときは、大抵翻訳者がこのミスをしてる。

なので、「Sphinx 用のドキュメントのチェック」なんてことを考えたいならば、今回やったことに似たことをする(か目と手で頑張って探して直す)ことになる。