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

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

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

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

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

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

import numpy as np

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

データ生成。

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

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

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

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

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

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

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

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

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

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

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

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

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

import numpy as np


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

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

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

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

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

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

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

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

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

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

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

動かしてみます。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

原因調査編

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

対応編

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

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

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

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

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

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

python -m unidic download

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

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

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

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

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

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

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

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

感想と今後の方針

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

done!

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

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

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

pipでライブラリをインストールする前に依存ライブラリを確認する

僕はAnacondaで環境を構築してcondaで運用しているのですが、どうしてもcondaでは入れられないライブラリがある時など、やむを得ずpipを使うことがあります。その場合、condaで入れられる限りの依存ライブラリを入れた後に必要最小限のライブラリをpipで入れるようにしているのですが、依存ライブラリの確認漏れ等があり、想定外のライブラリがpipで入ってしまうことがありました。(この運用もそろそろ限界を感じていて、次に環境を作り直す機会があったらpipで統一したいと思っています。)

問題の一つはpipでインストールする前に依存ライブラリを調べる方法が分かりにくかったことだと思っていたのですが、ようやく事前に調べかたがわかったのでそれを紹介します。

どうやら、PyPI の特定のURLでアクセスできるJSONファイルに、必要な情報が載っているようです。ここに書いてありました。
参考: PyPIJSON – Python Wiki

バージョンを指定しない場合は、
https://pypi.python.org/pypi/<package_name>/json
バージョンを指定する場合は、
https://pypi.python.org/pypi/<package_name>/<version>/json
というURLにアクセスすると、そのパッケージ(ライブラリ)の情報が取得できます。

試しに jupyter notebook (pip install notebook でインストールするので、ライブラリ名はnotebook)の情報ページである
https://pypi.python.org/pypi/notebook/json
にアクセスしていただくと分かりますが、かなりでかいJSONが得られます。

ここからテキストエディターで必要な情報を得るのは骨が折れるので、Python使って欲しい情報を探しましょう。

偶然見つけたのですが、 pprint というメソッドのドキュメントでの使用例がこのJSONの表示だったりします。そこでは urllibを使っていますがこれは若干使いにくいので僕はrequestsを使います。
参考: requestsを使って、Webサイトのソースコードを取得する

では、試しに notebook の 情報をとってみましょう。

import requests


package_name = "notebook"
url = f"https://pypi.org/pypi/{package_name}/json"
json = requests.get(url).json()

# このJSONはかなりでかい
print(len(str(json)))
# 113699

# JSONのkeys。 この中の info が必要な情報を含んでいる。
print(json.keys())
# dict_keys(['info', 'last_serial', 'releases', 'urls', 'vulnerabilities'])

# infoの下に、多くの情報がある。
print(json["info"].keys())
"""
dict_keys(['author', 'author_email', 'bugtrack_url', 'classifiers',
        'description', 'description_content_type', 'docs_url', 'download_url',
        'downloads', 'home_page', 'keywords', 'license', 'maintainer',
        'maintainer_email', 'name', 'package_url', 'platform', 'project_url',
        'project_urls', 'release_url', 'requires_dist', 'requires_python',
        'summary', 'version', 'yanked', 'yanked_reason'])
"""

# requires_dist が依存ライブラリの情報。リスト形式なので、順番に表示する
for requires_dist_text in json["info"]["requires_dist"]:
    print(requires_dist_text)

"""
jinja2
tornado (>=6.1)
pyzmq (>=17)
argon2-cffi
ipython-genutils
traitlets (>=4.2.1)
jupyter-core (>=4.6.1)
jupyter-client (>=5.3.4)
nbformat
nbconvert
nest-asyncio (>=1.5)
ipykernel
Send2Trash (>=1.8.0)
terminado (>=0.8.3)
prometheus-client
sphinx ; extra == 'docs'
nbsphinx ; extra == 'docs'
sphinxcontrib-github-alt ; extra == 'docs'
sphinx-rtd-theme ; extra == 'docs'
myst-parser ; extra == 'docs'
json-logging ; extra == 'json-logging'
pytest ; extra == 'test'
coverage ; extra == 'test'
requests ; extra == 'test'
nbval ; extra == 'test'
selenium ; extra == 'test'
pytest-cov ; extra == 'test'
requests-unixsocket ; (sys_platform != "win32") and extra == 'test'
"""

# requires_python で Pythonのバージョンの指定も見れる
print(json["info"]["requires_python"])
# >=3.6

extra がついているのはオプション付きでインストールする時に必要になる物なので、基本的に、次のライブラリが必要であることがわかりますね。
jinja2
tornado (>=6.1)
pyzmq (>=17)
argon2-cffi
ipython-genutils
traitlets (>=4.2.1)
jupyter-core (>=4.6.1)
jupyter-client (>=5.3.4)
nbformat
nbconvert
nest-asyncio (>=1.5)
ipykernel
Send2Trash (>=1.8.0)
terminado (>=0.8.3)
prometheus-client

ちょっとテストしてみましょう。 pyenv で新しい環境作って、notebook入れてみます。
(version 3.8.7と微妙に古いバージョン入れていますがこれは適当です。

# 新しい仮想環境を構築
$ pyenv install 3.8.7
# 環境切り替え
$ pyenv global 3.8.7
# ライブラリが何も入ってないことを確認(出力がない)
$ pip freeze
# notebook インストール
$ pip install notebook
# 依存ライブラリと共にインストールされたことを確認
$ pip freeze
appnope==0.1.2
argon2-cffi==21.3.0
argon2-cffi-bindings==21.2.0
attrs==21.2.0
backcall==0.2.0
bleach==4.1.0
cffi==1.15.0
debugpy==1.5.1
decorator==5.1.0
defusedxml==0.7.1
entrypoints==0.3
importlib-resources==5.4.0
ipykernel==6.6.0
ipython==7.30.1
ipython-genutils==0.2.0
jedi==0.18.1
Jinja2==3.0.3
jsonschema==4.3.2
jupyter-client==7.1.0
jupyter-core==4.9.1
jupyterlab-pygments==0.1.2
MarkupSafe==2.0.1
matplotlib-inline==0.1.3
mistune==0.8.4
nbclient==0.5.9
nbconvert==6.3.0
nbformat==5.1.3
nest-asyncio==1.5.4
notebook==6.4.6
packaging==21.3
pandocfilters==1.5.0
parso==0.8.3
pexpect==4.8.0
pickleshare==0.7.5
prometheus-client==0.12.0
prompt-toolkit==3.0.24
ptyprocess==0.7.0
pycparser==2.21
Pygments==2.10.0
pyparsing==3.0.6
pyrsistent==0.18.0
python-dateutil==2.8.2
pyzmq==22.3.0
Send2Trash==1.8.0
six==1.16.0
terminado==0.12.1
testpath==0.5.0
tornado==6.1
traitlets==5.1.1
wcwidth==0.2.5
webencodings==0.5.1
zipp==3.6.0

予想してたよりずっと多くのライブラリがインストールされましたね。どうやら依存ライブラリたちの依存ライブラリ、もちろんそれらの依存ライブラリも順次インストールされたようです。ただ、一つずつ確認したところ、JSONから取得した依存ライブラリは全て入ったことがわかります。

これは実験しておいてよかったです。必ずしも、JSONから得られたライブラリだけが入るわけではないことがわかりました。

もう一点補足しておくと、requires_dist には必ず値が入っているわけではありません。当然ですが依存ライブラリがないライブラリもあります。その場合は空配列になっているのかな、と思ったのですが、 null になるようですね。 NumPyなどがそうです。

package_name = "numpy"
url = f"https://pypi.org/pypi/{package_name}/json"
json = requests.get(url).json()
print(json["info"]["requires_dist"])
# None

以上で、pipインストール前にライブラリの依存ライブラリを調べられるようになりました。

ここで取得したJSONは他にも様々な情報を持っているようなので、それらも調べておこうと思います。

pandas.DataFrameのgroupby関数で計算した結果を各行に展開する

なんとなくドキュメントを眺めていたら、groupby().transform()っていう便利そうな関数を見つけたのでその紹介です。

DataFrameのgroupbyといえば、指定した列をキーとしてグループごとの合計や平均、分散、個数などの集計を行うことができる関数です。

通常は、集計したキーの数=グループの数の行数のDataFrameを戻り値として返してきます。

import pandas as pd


df = pd.DataFrame(
    {
        "category": ["A", "A", "A", "B", "B"],
        "amount": [100, 300, 100, 200, 200],
    }
)
print(df)
"""
  category amount
0        A    100
1        A    300
2        A    100
3        B    200
4        B    200
"""

print(df.groupby("category").sum())
"""
category        
A            500
B            400
"""

ここで、この groupby して得られた集計値を、元のDataFrameの各業に展開したいことがあります。
そのような場合、僕はpd.mergeでデータフレームを結合するか、辞書形式に変換して結合することが多かったです。
例えば以下のようなコードになります。

# mergeで結合する場合
group_df = df.groupby("category").sum()
group_df.reset_index(inplace=True)
group_df.rename(columns={"amount": "category_amount"}, inplace=True)
print(pd.merge(df, group_df, on="category", how="left"))
"""
  category  amount  category_amount
0        A     100              500
1        A     300              500
2        A     100              500
3        B     200              400
4        B     200              400
"""

# 辞書を作ってマッピングする場合
group_df = df.groupby("category").sum()
sum_dict = group_df.to_dict()["amount"]
print(sum_dict)
# {'A': 500, 'B': 400}
df["category_amount"] = df["category"].apply(sum_dict.get)
print(df)
"""
  category  amount  category_amount
0        A     100              500
1        A     300              500
2        A     100              500
3        B     200              400
4        B     200              400
"""

書いてみるとこれらの手順を踏んでもそんなに複雑ではないのですが、やっぱり一発でできるともっと便利です。

そこで使えるのが、冒頭で紹介した、transformです。
参考: pandas.core.groupby.DataFrameGroupBy.transform

これは元のデータフレームと同じインデックスを持つデータフレームとして、GroupByの結果を返してくれます。ちょっとやってみます。

df = pd.DataFrame(
    {
        "category": ["A", "A", "B", "B", "B"],
        "amount": [100, 300, 100, 200, 200],
    }
)

# 元のDataFrameと同じ行数で、対応する行の"category"列の値が含まれるグループの合計を返す
print(df.groupby("category").transform("sum"))
"""
   amount
0     400
1     400
2     500
3     500
4     500
"""

# 元のDataFrameに合計値を付与したい場合は次のようにできる
df["category_amount"] = df.groupby("category").transform("sum")["amount"]
print(df)
"""
  category  amount  category_amount
0        A     100              400
1        A     300              400
2        B     100              500
3        B     200              500
4        B     200              500
"""

1行で済みましたね。

この新しく作った列を使えば、一定件数以下しか存在しないカテゴリの行を削除するとか、カテゴリごとにそれぞれの要素のカテゴリ内で占めてる割合を計算するとか、それぞれの要素のカテゴリごとの平均との差異を求めるとかそういった計算が非常に容易にできるようになります。

そしてさらに、このtransform とlambda関数を組み合わせて使うと、カテゴリの平均との差を一発で出す、といったこともできます。

df = pd.DataFrame(
    {
        "category": ["A", "A", "B", "B", "B"],
        "amount": [100, 300, 100, 200, 200],
    }
)
print(df.groupby("category").transform(lambda x: x-x.mean()))
"""
       amount
0 -100.000000
1  100.000000
2  -66.666667
3   33.333333
4   33.333333
"""

lambda 関数に渡されている x はそれぞれの行の値のように振る舞ってくれるにもかかわらず、同時に x.mean() でグループごとの平均を出すこともでき、その差分を元のDataFrameとインデックスを揃えて返してくれています。

これは使いこなせば相当便利なメソッドになりそうです。

MeCabで分かち書き済みの単語に対して品詞を判定する

MeCabで形態素解析してテキストを単語に分解するとき、分かち書きしたテキストと、品詞情報が得られます。その単語の出現頻度等を集計した後で、この単語はこの品詞、という情報を付与して絞り込み等をやりたくなったのでその方法をメモしておきます。

実は以前ワードクラウドを作った時に品詞別に色を塗るために似たようなコードを作っています。今回の記事はその改良版です。
参考: WordCloudの文字の色を明示的に指定する

この記事では次のようなコードを使いました。(参照した記事は先行するコードでMeCabのTaggerインスタンスを作ってる前提なのでその辺ちょっと補って書きます。)

import MeCab


tagger = MeCab.Tagger()
def get_pos(word):
    parsed_lines = tagger.parse(word).split("\n")[:-2]
    features = [l.split('\t')[1] for l in parsed_lines]
    pos = [f.split(',')[0] for f in features]
    pos1 = [f.split(',')[1] for f in features]

    # 名詞の場合は、 品詞細分類1まで返す
    if pos[0] == "名詞":
        return f"{pos[0]}-{pos1[0]}"

    # 名詞以外の場合は 品詞のみ返す
    else:
        return pos[0]

参照した記事で補足説明書いてますとおり、このコードは単語をもう一回MeCabにかけて品詞を取得しています。その時に万が一単語がさらに複数の形態素に分割されてしまった場合、1つ目の形態素の品詞を返すようになっています。

このコードを書いた時、単語がさらに分解されるってことは理論上はありうるけど、滅多にないだろう、と楽観的に考えていました。ところが、色々検証していると実はそんな例が山ほどあることがわかってきました。

例えば、「中国語」という単語がありますが、これ単体でMeCabに食わせると「中国」と「語」に分かれます。以下が実行例です。

# 形態素解析結果に「中国語」が出る例
$ echo "彼は中国語を話す" | mecab
彼	名詞,代名詞,一般,*,*,*,彼,カレ,カレ
は	助詞,係助詞,*,*,*,*,は,ハ,ワ
中国語	名詞,一般,*,*,*,*,中国語,チュウゴクゴ,チューゴクゴ
を	助詞,格助詞,一般,*,*,*,を,ヲ,ヲ
話す	動詞,自立,*,*,五段・サ行,基本形,話す,ハナス,ハナス
EOS

# 「中国語」がさらに「中国」 と「語」に分かれる
$ echo "中国語" | mecab
中国	名詞,固有名詞,地域,国,*,*,中国,チュウゴク,チューゴク
語	名詞,接尾,一般,*,*,*,語,ゴ,ゴ
EOS

「中国語」が固有名詞、地域、国と判定されるとちょっと厄介ですね。

他にも、「サバサバ」は「サバ」「サバ」に割れます。

$ echo "ワタシってサバサバしてるから" | mecab
ワタシ	名詞,固有名詞,組織,*,*,*,*
って	助詞,格助詞,連語,*,*,*,って,ッテ,ッテ
サバサバ	名詞,サ変接続,*,*,*,*,サバサバ,サバサバ,サバサバ
し	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
てる	動詞,非自立,*,*,一段,基本形,てる,テル,テル
から	助詞,接続助詞,*,*,*,*,から,カラ,カラ
EOS
$ echo "サバサバ" | mecab
サバ	名詞,一般,*,*,*,*,サバ,サバ,サバ
サバ	名詞,一般,*,*,*,*,サバ,サバ,サバ
EOS

他にも「ありえる」が「あり」「える」とか、「無責任」が「無」「責任」とか「ビュッフェ」が「ビュッ」「フェ」など、かなりの種類の単語が再度分解されます。

ということで、冒頭にあげた get_pos メソッドは思っていたよりもずっと誤判定しやすいということがわかってきました。

前置きが長くなってきましたが、このことを踏まえて、単語を再度分割することのないようにその単語としての品詞情報を取得できないかを考えました。

結局、制約付き解析機能を使って実現できそうだということがわかりました。
参考: MeCabの制約付き解析機能を試す

要するに、MeCabに渡された単語はそれで1単語だ、という制約を課せば良いわけです。

そのためには、-pオプション付きでTaggerを生成し、「{単語}{タブ}*(アスタリスク)」という形式のテキストに変換してTaggerでparseすれば大丈夫です。

Pythonのコードで書くと次のようになりますね。

import MeCab
tagger = MeCab.Tagger("-p")


def get_pos(word):
    # 制約付き解析の形態素断片形式にする
    p_token = f"{word}\t*"
    # 出力のEOS部分を捨てる
    parsed_line = tagger.parse(p_token).splitlines()[0]
    feature = parsed_line.split("\t")[1]
    # ,(カンマ)で区切り、品詞,品詞細分類1,品詞細分類2,品詞細分類3 の4項目残す
    pos_list = feature.split(",")[:4]
    # もう一度 ,(カンマ) で結合して返す
    return ",".join(pos_list)


# 利用例
print(get_pos("中国語"))
# 名詞,一般,*,*

上のコードは、品詞を再分類3まで取得するようにしましたが、最初の品詞だけ取得するとか、*(アスタリスク)の部分は省略するといった改修はお好みに合わせて容易にできると思います。

これで一旦今回の記事の目的は果たされました。

ただ、元の文中でその単語が登場したときの品詞が取得されているか、という観点で見るとこのコードも完璧ではありません。

表層系や原型が等しいが品詞が異なる単語が複数存在する場合、通常のMeCabの最小コスト法に則って品詞の一つが選ばれることになります。BOS/EOSへの連接コストとその品詞の単語の生起コストが考慮されて最小になるものが選ばれる感じですね。

分かち書き前のテキストで使われていたときの品詞が欲しいんだ、となると後からそれを付与するのは困難というより不可能なので、分かち書きした時点でちゃんと保存してどこかに取っておくようにしましょう。

あとおまけで、このコードを書いてる時に気づいたMeCabの制約付き解析機能の注意点を書いておきます。MeCabを制約付き解析モードで使っている時に、「表層\t素性パターン」”ではない”テキスト、つまり文断片と呼ばれている文字列を改行コード付けずに渡すとクラッシュするようです。
-p 付きで起動したときは、「表層\t素性パターン」形式の形態素断片か改行コードを必ず含むテキストで使うようにしましょう。

jupyter notebookでやると カーネルごとお亡くなりになりますので特に要注意です。

ちょっとコンソールでやってみますね。

$ python
>>> import MeCab
>>> tagger = MeCab.Tagger("-p")
>>> tagger.parse("中国語")
Segmentation fault: 11
# これでPythonが強制終了になる
$

改行コードつければ大丈夫であることは以下のようにして確認できます。

$ python
>>> import MeCab
>>> tagger = MeCab.Tagger("-p")
>>> tagger.parse("中国語\n")
'中国\t名詞,固有名詞,地域,国,*,*,中国,チュウゴク,チューゴク\n語\t名詞,接尾,一般,*,*,*,語,ゴ,ゴ\nEOS\n'

-p をつけてないときは別に改行コードなしのテキストも読み込んでくれるのでこれはちょっと意外でした。

制約付き解析(-p付き)でMeCabを使っている時に、「Segmentation fault: 11」が出たらこのことを思い出してください。

jupyter notebookのセルの出力をコードでクリアする

諸事情ありまして、jupyter notebookのセルの出力をクリアする方法を知りたくなったので調べました。
通常、jupyterではテキストを複数回にわたってprintしたり、matplotlibの図をいくつも出力するコードを1つのセルに書くと、出力したテキストなり図なりがダーっと続けて出てきます。
ちょっとこれを逐一クリアして新しいものだけ残すようにしたかったのです。
(こんなことする必要があることは滅多にないのですが。)

実は、クリアしたい対象がprintした1行以内のテキストの場合、それを実装する方法は過去に紹介したことがあります。それはprintメソッドのend引数を使ってprint後に改行コードを出力しないようにし、キャリッジリターン(“\r”)で出力位置を行頭に戻して空白で上書きしてしまうというものです。
これ使ってプログレスバーを作った記事が過去にありますね。
参考: printでお手軽プログレスバー

例えば、jupyterで次のコードを動かすと0~49まで数字がカウントアップします。
\r でカーソルを先頭にもどして、空白で埋めて、最後に次のprintのためにもう一回カーソルを先頭に戻しています。 end=”” はprint後に改行させない設定です。
sleep() は入れておかないと一瞬すぎて何も見えないのでウェイトとして入れています。

import time

for i in range(50):
    print("\r          \r", end="")
    print(i, end="")
    time.sleep(0.5)

ただ、さっきも書きましたがこの方法だと1行のテキストしか消せません。

複数行の出力だったらどうやって消すのかなと思って調べた結果見つかったのが、IPython モジュールにあった、 clear_output というメソッドです。
正確には、IPython.display.clear_output として実装されています。
ドキュメントはこちらです。
参考: Module: display — IPython 7.30.1 documentation

Clear the output of the current cell receiving output. とある通り、これが実行されるとそのステップが含まれたセルの出力だけを消してくれます。他のセルの出力は残してくれるので安心ですね。

wait (デフォルトはFalse)という便利な引数も持っています。これは、Falseにしておくと即座に出力を消すのに対して、Trueを渡すと、次の出力がくるのを待って消してくれます。連続して何かを出力するようなコードの場合、Trueにしておくと出力をスムーズに入れ替えるような動きになるのです。 Falseだと一瞬何も出力がない状態になるので次のセルとの間が詰まって 以降のセルがガクガク動きます。

以下のようにして、1秒ごとに現在時刻を表示する時計のような出力も出せます。

from IPython.display import clear_output
from datetime import datetime
import time


for i in range(10):
    print("現在時刻\n", datetime.now())
    clear_output(True)
    time.sleep(1)

"""
現在時刻
 2021-12-14 23:58:34.942141

上のような出力が1秒ごとに更新されて書き換えられる
"""

clear_outputはテキストだけではなく、図もクリアしてくれます。これを応用すると、パラパラ漫画のようにして手軽にアニメーションを作ることができます。

徐々にデータが増えて延びる折れ線グラフを描いてみたのが次のコードです。

import matplotlib.pyplot as plt
import numpy as np

# プロットする点を格納する配列
X = []
Y = []

for i in range(100):
    # 新しい点を追加する
    X.append(i)
    Y.append(np.random.randn())  # y座標には乱数入れる

    clear_output(True)  # それまでの出力をクリアする

    # グラフ作図
    fig = plt.figure(facecolor="w")  # 出力をクリアしたら改めてfigreオブジェクトが必要らしい
    ax = fig.add_subplot(111)
    ax.plot(X, Y)
    # グラフ表示
    plt.show()
    time.sleep(0.1)

このコードで jupyter 上にはアニメーションが表示できます。

実質的には clear_output(True) を差し込んでるだけなので、かなり手軽ですね。
ただ、これには一つ欠点もあって、jpyter上で簡易的に図を書いたり消したりしてアニメーションっぽく見せているだけなのでこのまま動画として保存することはできません。
(そのためこの記事にも結果の画像を貼っていません)

もし、gif形式などで保存したい場合は、少々面倒になるのですが、 ArtistAnimation などを使いましょう。過去の記事で取り上げています。
参考: matplotlibでgif動画生成

subprocessでパイプラインの実装

前回に続いてsubprocessの話です。予告していた通り、PythonでOSコマンドをパイプラインで繋いで実行する方法を紹介します。

まず前提ですが、subprocess.run にパイプラインを含むOSコマンドを渡してもそのままでは動きません。例えば実行中のプロセスから jupyter の文字を含む次のようなコマンドを考えます。

$ ps aux | grep jupyter
yutaro             762   0.0  0.8  4315736  67452 s000  S    11:55PM   0:03.71 {Pythonのパス} {pyenvのパス}/versions/anaconda3-2019.10/bin/jupyter-notebook
yutaro             910   0.0  0.0  4278648    712 s000  S+   12:04AM   0:00.00 grep jupyter

このコマンドをそのまま subprocess に渡しても動かないわけです。

import subprocess


cp = subprocess.run(
    ["ps", "aux", "|", "grep", "jupyter"],
    capture_output=True,
    text=True
)
# リターンコードが0ではない
print(cp.returncode)
# 1

# 標準出力は空っぽ
print(cp.stdout)
# 
# 標準エラー出力にはエラーが出ている
print(cp.stderr)
"""
ps: illegal argument: |
usage: ps [-AaCcEefhjlMmrSTvwXx] [-O fmt | -o fmt] [-G gid[,gid...]]
          [-u]
          [-p pid[,pid...]] [-t tty[,tty...]] [-U user[,user...]]
       ps [-L]
"""

実は、パイプラインを含むコマンドを簡単に動かす方法はあります。それがshell引数にTrueを渡すことです。これは渡されたコマンドをシェルによって実行するオプションです。この場合、コマンドは空白で区切った配列ではなく一つの文字列で渡します。

cp = subprocess.run(
    "ps aux | grep jupyter",
    capture_output=True,
    text=True,
    shell=True
)

# リターンコードは0
print(cp.returncode)
# 0
# 標準出力に結果が入る
print(cp.stdout)
# 結果略。
# 標準エラー出力は空
print(cp.stderr)
# 

ただし、ドキュメントに「注釈 shell=True を使う前に セキュリティで考慮すべき点 を読んでください。」という注釈がついてるように、これはセキュリティ面で問題がある方法のようです。
参考: セキュリティで考慮すべき点
シェルインジェクションを避けるのはアプリ側の責任だって書いてありますね。この点気をつけて使いましょう。

さて、色々検証してみたのですが、 shell=True を使わなくてもパイプラインを実装する方法はあるようです。それは単純に標準入力を使う方法で、1個目のコマンドの標準出力を2個目のコマンドの標準入力に渡してあげます。

とりあえず、パイプラインではなく単一のコマンドで標準入力を使ってみましょう。macabコマンドに、いつもの「すもももももももものうち」を渡してみます。

runメソッドに標準入力を渡すには、 input という引数を使います。これで注意しないといけないのは、inputには”バイト列”でデータを渡す必要があることです。str型だとエラーになるので、encode() してから渡します。ただ、text=True も指定するときは逆にstrで渡さないといけないようですね。

text = "すもももももももものうち"  # 入力するテキスト
text_byte = text.encode()  # byte型にエンコード

cp = subprocess.run(
    "mecab",
    capture_output=True,
    input=text_byte  # 通常はbyte型で標準入力を渡す
)

# byte型でデータが返ってきているので、decode()して表示
print(cp.stdout.decode())
"""
すもも	名詞,一般,*,*,*,*,すもも,スモモ,スモモ
も	助詞,係助詞,*,*,*,*,も,モ,モ
もも	名詞,一般,*,*,*,*,もも,モモ,モモ
も	助詞,係助詞,*,*,*,*,も,モ,モ
もも	名詞,一般,*,*,*,*,もも,モモ,モモ
の	助詞,連体化,*,*,*,*,の,ノ,ノ
うち	名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ
EOS
"""

# text=True を指定するときは str型で標準入力を渡す
cp = subprocess.run(
    "mecab",
    capture_output=True,
    text=True,
    input=text  # text=True を指定するときは str型で標準入力を渡す
)
# str型で格納されているのでそのままprintできる
print(cp.stdout)
"""
結果は同じなので略
"""

さて、標準入力の渡し方がわかったら、あとは先行するコマンドの標準出力を次のコマンドの標準入力に渡すだけです。

最初の ps aux | grep jupyter でやってみましょう。

cp1 = subprocess.run(
    ["ps", "aux"],
    capture_output=True,
    text=True,
)

cp2 = subprocess.run(
    ["grep", "jupyter"],
    capture_output=True,
    text=True,
    input=cp1.stdout  # 一つ目のコマンドの標準出力を渡す
)
print(cp2.stdout)
"""
yutaro             762   0.0  0.8  4315736  67720 s000  S    11:55PM   0:05.04 {Pythonのパス} {pyenvのパス} /versions/anaconda3-2019.10/bin/jupyter-notebook
"""

この記事の先頭のコマンドの結果と微妙に異なりますね。 grep jupyter のプロセスが出てきません。これは、ps aux だけ先行して動かし、その結果をもとにgrepしているので、厳密にはシェルでパイプラインしたのとは異なるからそうなっているのでしょう。

ただ、通常の用途であればほぼ同じ結果が得られると思います。
どうしても差分が気になるのであれば shell=Trueの方の方法を使うことも検討が必要でしょうね。

サンプルとして選んだコマンドがイマイチだったので、厳密にいうと再現できてないサンプルを提示してしまったのですが、このようにして、PythonでOSコマンドのパイプラインが再現できます。

subprocessでPythonからOSのコマンドを実行する

このブログの過去記事でもすでに使ったことがあるのですが、改めてsubprocessの使い方をまとめておきます。
ドキュメントはこちら。
参考: subprocess — サブプロセス管理 — Python 3.10.0b2 ドキュメント

subprocessは os.system を置き換えるために作られた新し目のモジュールらしいので、僕も新しい方法としてこれを使っていたのですが、Python 3.5 から subprocess に run() というメソッドが実装され、僕が書いていた方法はいつの間にか古い方法になってしまっていたようです。ドキュメントを少し引用します。

サブプロセスを起動するために推奨される方法は、すべての用法を扱える run() 関数を使用することです。より高度な用法では下層の Popen インターフェースを直接使用することもできます。
run() 関数は Python 3.5 で追加されました; 過去のバージョンとの互換性の維持が必要な場合は、古い高水準 API 節をご覧ください。

subprocess — サブプロセス管理 — Python 3.10.0b2 ドキュメント

ちなみに、古い方法では、コマンドを実行したいだけなら call 、出力を得たかったら getoutput を使っていました。

import subprocess


# mkdir sample_dir を実行。 空白を含むコマンドは空白で区切って配列で渡す
subprocess.call(["mkdir", "sample_dir"])  # 成功すれば戻り値 として 0が帰ってくる

# 標準出力の結果が欲しい場合は getoutput メソッドを使う
output_str = subprocess.getoutput("ls -la")
print(output_str)

さて、本題の新しい方法の run の説明に入りましょう。
このメソッドはどうやら非常に多くの種類の引数をとるそうで、ドキュメントでも、「上記の引数は、もっともよく使われるものだけ示しており、後述の よく使われる引数 で説明されています」とある通り一部の引数しか掲載されていません。それでもこれだけ書かれています。

subprocess.run(
    args, *, stdin=None, input=None, stdout=None,
    stderr=None, capture_output=False, shell=False, cwd=None,
    timeout=None, check=False, encoding=None, errors=None,
    text=None, env=None, universal_newlines=None,
    **other_popen_kwargs)

基本的には、コマンドをスペースで区切って配列にし、callの時と同じように渡せば良いようです。touchでファイルを作ってみます。

subprocess.run(["touch", "sample_dir/sample1.txt"])
# CompletedProcess(args=['touch', 'sample_dir/sample1.txt'], returncode=0)

上のコード例は jupyter notebookで動かした時のイメージなので、勝手に最後のメソッドの戻り値がnotebookに表示されたのですが、これでわかる通り、 CompletedProcess というクラスのインスタンスを返してくれます。lsなどの標準出力を取りたい場合は、 capture_output を Trueにしておきます。

cp = subprocess.run(["ls", "-la", "sample_dir"])
print(cp.stdout)  # capture_output を指定しないと、stdoutに結果が入ってない
# None

cp = subprocess.run(["ls", "-la", "sample_dir"], capture_output=True)
print(type(cp.stdout))  # 結果はバイト型で入ってくる
# <class 'bytes'>
print(cp.stdout.decode())  # 文字列に変換したい場合はdecodeする
"""
total 0
drwxr-xr-x  3 {ユーザー名}  {グループ名}   96 12  8 00:41 .
drwxr-xr-x  7 {ユーザー名}  {グループ名}  224 12  8 00:52 ..
-rw-r--r--  1 {ユーザー名}  {グループ名}    0 12  8 00:41 sample1.txt
"""

cp = subprocess.run(["ls", "-la", "sample_dir"], capture_output=True, text=True)
print(type(cp.stdout))  # text=True も指定しておくと、str型で得られるのでdecodeがいらない。
# <class 'str'>
print(cp.stdout)
# (上のと同じなので) 出力略 

この、capture_output は 3.7 で追加されたそうで runメソッド本体より新しいオプションになります。 capture_output を使わない場合、 stdout と stderr にそれぞれ標準出力と標準エラー出力を指定することになります。ドキュメントでは PIPE とか STDOUT とかを指定するよう書かれていますがこれらは、 subprocess.PIPE, subprocess.STDOUT のことです。
両引数にそれぞれsubprocess.PIPE を指定すると、capture_output=Trueにしたのと同じ動きになります。stdout=subprocess.PIPE と stderr=subprocess.STDOUT の組み合わせで指定すると、標準出力と標準エラー出力を両方ともstdoutに格納してくれます。

ちょっと tarコマンドあたりでやってみます。出力先ファイルを – (ハイフン) にしておくと tar は結果のアーカイブをファイルを作らずに結果を標準出力に出力します。
また、 v をつけておくと標準エラー出力に処理したファイル情報を出すので subprocess の挙動確認にちょうど良さそうです。

# capture_output=True, と stdout=subprocess.PIPE, stderr=subprocess.PIPE は同じ動き
cp = subprocess.run(["tar", "cvf", "-", "sample_dir"],
                    stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
print(cp.stdout)
"""
{tarファイルの中身}
"""
print(cp.stderr)
"""
a sample_dir
a sample_dir/sample1.txt
"""

# stderr=subprocess.STDOUT とすると、標準エラー出力も標準出力に追記される
cp = subprocess.run(["tar", "cvf", "-", "sample_dir"],
                    stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
# 標準エラー出力に出るはずだったアーカイブ対象情報もこちらに出る
print(cp.stdout)
"""
a sample_dir
a sample_dir/sample1.txt
{tarファイルの中身}
"""
# stderrは空
print(cp.stderr)
# None

最後に、コマンドがエラーになった時の処理です。
基本的には、 CompletedProcess が returncode という要素を持っているので、これで判定すれば良いと思います。 たとえば、 sample_dir というディレクトリは上のサンプルコードで作ったのが既にあるので、もう一度作ろうとすると失敗し、returncode が1になります。

cp = subprocess.run(["mkdir", "sample_dir"])
print(cp.returncode)
# 1

逆にいうと、コマンドが失敗してもPythonとしては特にエラーにならず、それ以降もコードがあるのであればプログラムは走り続けるということです。コマンドを実行したらreturncodeを確認して失敗してたら止めるような処理を明示的に作っておかないと予期せぬバグに繋がることもあるので気をつけましょう。

returncodeを確認するのではなく、コマンドが失敗したら例外を上げて欲しい、という場合は check=Trueを指定しておきましょう。

try:
    cp = subprocess.run(["mkdir", "sample_dir"], check=True)
except Exception as e:
    print(e)
    # Command '['mkdir', 'sample_dir']' returned non-zero exit status 1.

ちなみにですが、存在しないコマンドを渡すと check=True を指定していなくても例外が上がります。コマンドが存在しないのと、コマンドの結果がエラーになったのは明確に違う扱いになっているようですね。

try:
    cp = subprocess.run(["abcdefg", "aaaa"])
except Exception as e:
    print(e)
    # [Errno 2] No such file or directory: 'abcdefg': 'abcdefg'

これで簡単なコマンドであれば subprocess.run を使って実行できると思います。

あと、パイプラインを使うようなやり方について現在調べて検証しているので次の記事で紹介したいと思っています。