静かなる名辞

pythonとプログラミングのこと

【python】関数内関数は動的に生成される

 わかっている人には当たり前のことですが、他の言語から来た人だと「んんん?」かもしれません。

こうなる

>>> def f():
...     def g():
...         pass
...     return g
... 
>>> a = f()
>>> b = f()
>>> a is b
False
>>> id(a)
139834257176640
>>> id(b)
139834231735568

解説

 わかりやすいように、disモジュール(標準)のバイトコードディスアセンブラで見てみましょうか。

>>> import dis
>>> dis.dis(f)
  2           0 LOAD_CONST               1 (<code object g at 0x7f2db33a4a50, file "<stdin>", line 2>)
              3 LOAD_CONST               2 ('f.<locals>.g')
              6 MAKE_FUNCTION            0
              9 STORE_FAST               0 (g)

  4          12 LOAD_FAST                0 (g)
             15 RETURN_VALUE

 中身については深く検討していないのですが*1、MAKE_FUNCTIONがあることはわかりますね。これが関数呼び出しのたびに実行され、新たな関数オブジェクトが生成されている訳です。

 名前空間だけ間借りしている訳ではありません。

なにか問題なのか

 これのせいで微妙に遅くなる可能性はあるので、測ります。

 コード内で使っているFizz Buzzのコードはこちらからお借りしました。

PythonでFizz Buzz書いてみた

import timeit

def fizzbuzz(n):
    for i in range(1, n):
        if i % 15 == 0:
            print("Fizz Buzz!")
        elif i % 3 == 0:
            print("Fizz!")
        elif i % 5 == 0:
            print("Buzz!")
        else:
            print(i)

def f():
    def fizzbuzz_infunc(n):
        for i in range(1, n):
            if i % 15 == 0:
                print("Fizz Buzz!")
            elif i % 3 == 0:
                print("Fizz!")
            elif i % 5 == 0:
                print("Buzz!")
            else:
                print(i)
    return fizzbuzz_infunc

def g():
    return fizzbuzz

print(timeit.timeit(f, number=10**6))
print(timeit.timeit(g, number=10**6))
""" 結果=>
0.13979517295956612
0.08669622003799304
"""

 まあ微妙に遅いけど、気にするほどではないかも。

 これを利用するとクロージャが作れるというのは有名な話ですね。

*1:そもそも私はpythonバイトコードなんか読めない

【python】内包表記をbreakする方法を考える

 リスト内包表記や辞書内包表記、ジェネレータ式などの内包表記は便利ですが、途中で止めたいときがあったとして(あるかどうかは知りませんが)どうしたら良いのでしょう?

カウンタを使う

 こういう方法を真っ先に思いつきます。

>>> [x for x in range(20) if x < 10]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

 enumerateを使うと任意のiterableに対して使えます。

>>> [x for i, x in enumerate("hoge"*100) if i < 10]
['h', 'o', 'g', 'e', 'h', 'o', 'g', 'e', 'h', 'o']

 欠点としては、内包表記のループ対象のiterableの計算コストが高い場合は無意味になることが挙げられると思います。また、コードが冗長な感があります。

StopIteration例外を投げる

 こちらで紹介されている方法です。

Python の内包表記の使い方まとめ - Life with Python

def end_of_loop():
    raise StopIteration

list(x if x < 10 else end_of_loop() for x in range(100))
# 次の書き方でもOK
# list(x if x < 10 else next(iter([])) for x in range(100))

 これ以上良い方法は簡単には思いつきません。だけど、ちょっと冗長でわかりづらい感じがします。

 残念ながらraiseは式ではなく文なので、普通には内包表記の子に入れられません。

 また、これで止めるとリスト内包表記、辞書内包表記ではそのまま例外が戻ってきます。ジェネレータ式だからできる芸当と言えるでしょう。

itertools.takewhile

 素直にitertools.takewhileを使うべきでは? という考え方もあります。

10.1. itertools — 効率的なループ実行のためのイテレータ生成関数 — Python 3.6.5 ドキュメント

>>> t = takewhile(lambda x:x < 10, (x for x in range(20)))
>>> t
<itertools.takewhile object at 0x7f2b68c049c8>
>>> list(t)
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

 すべてをジェネレータで作る前提でコードを書けば使えると思います。

その他

 黒魔術を使うとやり方は色々あると思いますが、ここでは触れません。

まとめ

 素直にfor文のループで書けばいいと思いました(小学生並みの感想)。

【python】execを使って変数名を動的に変える方法についての考察

 実用的には無意味というかやるべきではないんだけど、ちょっと気になったので。

やり方としてはexecでやれば良い

 まずやり方から。

exec("hoge = 'ほげ'")  # hoge = 'ほげ'と書いたのと同じ
print(hoge) # => ほげ

 stringを引数に渡せるので、なんでもし放題です。でも、こういうことをすると何かと大変なことになるので、やめておいた方が無難。

もっといいやり方

 もっとマシなやり方も、念の為紹介しておく。

d = dict()
d["hoge"] = "ほげ"
print(d["hoge"]) # => ほげ

 識別子がほしいときは辞書などのコレクション型などに入れよう。それで普通は事足りる。

素朴な疑問

 スコープどうなってるの?

hoge = 10
def f():
    exec("hoge = 'ほげ'")
    print(hoge)
f() # => 10

 理由がわからない。仕方ないのでドキュメントを見に行く。

exec(object[, globals[, locals]])(原文)
この関数は Python コードの動的な実行をサポートします。 object は文字列かコードオブジェクトでなければなりません。文字列なら、その文字列は一連の Python 文として解析され、そして (構文エラーが生じない限り) 実行されます。 [1] コードオブジェクトなら、それは単純に実行されます。どの場合でも、実行されるコードはファイル入力として有効であることが期待されます (リファレンスマニュアルの節 "file-input" を参照)。なお、 return および yield 文は、 exec() 関数に渡されたコードの文脈中においてさえ、関数定義の外では使えません。返り値は None です。

いずれの場合でも、オプションの部分が省略されると、コードは現在のスコープ内で実行されます。globals だけが与えられたなら、辞書でなくてはならず、グローバル変数とローカル変数の両方に使われます。globals と locals が与えられたなら、それぞれグローバル変数とローカル変数として使われます。locals を指定する場合は何らかのマップ型オブジェクトでなければなりません。モジュールレベルでは、グローバルとローカルは同じ辞書です。exec が globals と locals として別のオブジェクトを取った場合、コードはクラス定義に埋め込まれたかのように実行されます。

globals 辞書がキー __builtins__ に対する値を含まなければ、そのキーに対して、組み込みモジュール builtins の辞書への参照が挿入されます。ですから、実行されるコードを exec() に渡す前に、 globals に自作の __builtins__ 辞書を挿入することで、コードがどの組み込みを利用できるか制御できます。

注釈 組み込み関数 globals() および locals() は、それぞれ現在のグローバルおよびローカルの辞書を返すので、それらを exec() の第二、第三引数にそのまま渡して使うと便利なことがあります。
注釈 標準では locals は後に述べる関数 locals() のように動作します: 標準の locals 辞書に対する変更を試みてはいけません。 exec() の呼び出しが返る時にコードが locals に与える影響を知りたいなら、明示的に locals 辞書を渡してください。

2. 組み込み関数 — Python 3.6.5 ドキュメント


 ダメだ、よくわからん。

 落ち着いて考え直してみる。文が実行されていれば、少なくともglobals辞書かlocals辞書のどちらかには追加されるはずだ。

hoge = 10
def f():
    exec("hoge = 'ほげ'")
    print(globals())
    print(locals())
f()
""" =>
{'__cached__': None, '__package__': None, '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x7f5989241b00>, '__doc__': None, 'hoge': 10, '__builtins__': <module 'builtins' (built-in)>, '__name__': '__main__', 'f': <function f at 0x7f5989277f28>, '__spec__': None, '__file__': 'exec_scope_test.py'}
{'hoge': 'ほげ'}
"""

 しっかりローカル変数に追加されている。ということは、文は実行されている。

 そうか、わかった。スコープは関数定義時に決まるんだった。定義時にはexecは実行されないので、関数内から見えるhogeはグローバルに定義したhogeになる。あとで実行時にローカル変数が増えようが、そんなのはお構いなしにグローバルのhogeを見続けるということか。

 じゃあ、こうするとどうなるの?

def f():
    exec("hoge = 'ほげ'")
    print(hoge)
f()
Traceback (most recent call last):
  File "exec_scope_test.py", line 4, in <module>
    f()
  File "exec_scope_test.py", line 3, in f
    print(hoge)
NameError: name 'hoge' is not defined

 こちらも同様の事情である。関数定義時に、関数のスコープの中にはhogeが見つからないので、pythonインタプリタhogeグローバル変数だと*1勝手に認識する。あとはずっとそう認識しっぱなしなので、「hogeなんてグローバル変数は見つからなかった」という結果になってエラーを吐く。

 この例について考えてみると、わかりやすい。

>>> def f():
...     print(i)
... 
>>> f()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in f
NameError: name 'i' is not defined
>>> i = 0
>>> f()
0

 そしてこれをなんとか意図通り動かすのは、おそらく無理じゃないかな。ちょっと思いつかない。

 やりたいことからしたら無意味なんだろうけど、ローカル変数を先に作っておけばできるのだろうか。

def f():
    hoge = None
    exec("hoge = 'ほげ'")
    print(hoge)
    print(locals())
f()
""" =>
None
{'hoge': None}
"""

 できないのかよ。これは何事かと思ってググったら、出てきた。

python - How does exec work with locals? - Stack Overflow

 ややこしい話だから読みたい人は勝手に読んでいただけば良いと思う。とりあえず、こうすると回避はできる。

def f():
    hoge = None
    ldict = {}
    exec("hoge = 'ほげ'", globals(), ldict)
    print(ldict["hoge"])
f() # => ほげ

 この結果を喜んではいけない。「もっといいやり方」で紹介した辞書を使う方法を、とても回りくどく実行しているだけだからだ。

 あと、上のリンクにも書いてあるけど、python2だと普通(?)にできるんだってさ。

>>> def f():
...     hoge = None
...     exec "hoge = 'ほげ'"
...     print hoge
... 
>>> f()
ほげ

 嫌になっちゃうね。

まとめと結論

 execを使うとスコープの概念がぶっ壊れてほんとうにつらいことになる。

 すべてをグローバルな空間で完結させればこの記事に書いたような問題には引っかからないだろうけど、ある程度の規模のプログラムをまともな規模で書くことはできなくなるので、本当に使いみちがない。

 やはり、やらないでおいた方が良い。

*1:厳密には少なくともローカルスコープではないと認識して実行時に上位スコープをたどる・・・んだっけ?

【python】ランダムフォレストのOOBエラーが役に立つか確認

はじめに

 RandomForestではOOBエラー(Out-of-bag error、OOB estimate、OOB誤り率)を見ることができます。交差検証と同様に汎化性能を見れます。

 原理の説明とかは他に譲るのですが、これはちゃんと交差検証のように使えるのでしょうか? もちろん原理的には使えるのでしょうが、実際どうなるのかはやってみないとわかりません。

 もしかしたらもう他の人がやっているかもしれませんが*1、自分でやった方が納得感があります*2

 ということで、やってみました。

みたいこと

 とりあえずトイデータでやってみて、交差検証の場合とスコアを比べる。交差検証は分割のkを変えて様子を見る必要があるでしょう。

 また、モデルの性能がよくなったり悪くなったりしたとき、交差検証と同様のスコアの変化が見れるかも確認してみる必要がありそうです。

プログラム

 こんなプログラムを書きました。

import time

import numpy as np
from sklearn.datasets import load_iris, load_digits, load_wine
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import precision_recall_fscore_support as prf

def test_func(dataset):
    rfc = RandomForestClassifier(n_estimators=300, oob_score=True, n_jobs=-1)
    t1 = time.time()
    rfc.fit(dataset.data, dataset.target)
    t2 = time.time()
    oob_pred = rfc.oob_decision_function_.argmax(axis=1)
    print("{0:6} p:{2:.4f} r:{3:.4f} f1:{4:.4f}  time:{1:.4f}".format(
        "oob", t2-t1,
        *prf(dataset.target, oob_pred, average="macro")))

    rfc = RandomForestClassifier(n_estimators=300, oob_score=False, n_jobs=-1)
    for k in [2,4,6,8]:
        skf = StratifiedKFold(n_splits=k)
        trues = []
        preds = []
        t1 = time.time()
        for train_idx, test_idx in skf.split(dataset.data, dataset.target):
            rfc.fit(dataset.data[train_idx], dataset.target[train_idx])
            trues.append(dataset.target[test_idx])
            preds.append(rfc.predict(dataset.data[test_idx]))
        t2 = time.time()

        print("{0:6} p:{2:.4f} r:{3:.4f} f1:{4:.4f}  time:{1:.4f}".format(
            "CV k={}".format(k), t2-t1,
            *prf(np.hstack(trues), np.hstack(preds), average="macro")))        

def main():
    iris = load_iris()
    digits = load_digits()
    wine = load_wine()

    print("iris")
    test_func(iris)

    print("\ndigits")
    test_func(digits)

    print("\nwine")
    test_func(wine)

    print("\niris + noise")
    iris.data += np.random.randn(*iris.data.shape)*iris.data.std()
    test_func(iris)

    print("\ndigits + noise")
    digits.data += np.random.randn(*digits.data.shape)*digits.data.std()
    test_func(digits)

    print("\nwine + noise")
    wine.data += np.random.randn(*wine.data.shape)*wine.data.std()
    test_func(wine)

if __name__ == "__main__":
    main()

 注目ポイント。

  • iris, digits, wineでためしました
  • rfc.oob_decision_function_.argmax(axis=1)でOOBで推定されたラベルが得られるので、それを使って精度、再現率、F1値を計算しています(マクロ平均)。交差検証でも同様に計算することで、同じ指標で比較を可能にしています(accuracyだけだと寂しいので・・・)
  • 処理の所要時間も測った
  • 特徴量にノイズを付与して分類させることで、条件が悪いときのスコアも確認。ノイズは特徴量全体の標準偏差くらいの正規分布を付与しました。理論的な根拠は特にないです(だいたい軸によってスケールが違うのを無視しているのだし・・・)

 だいたいこんな感じで、あとは普通にやってます*3

結果

 テキスト出力をそのまんま。

iris
oob    p:0.9534 r:0.9533 f1:0.9533  time:0.5774
CV k=2 p:0.9534 r:0.9533 f1:0.9533  time:1.3196
CV k=4 p:0.9600 r:0.9600 f1:0.9600  time:2.7399
CV k=6 p:0.9600 r:0.9600 f1:0.9600  time:4.1367
CV k=8 p:0.9600 r:0.9600 f1:0.9600  time:5.9125

digits
oob    p:0.9795 r:0.9794 f1:0.9794  time:0.9511
CV k=2 p:0.9282 r:0.9271 f1:0.9272  time:1.9101
CV k=4 p:0.9429 r:0.9422 f1:0.9420  time:4.0144
CV k=6 p:0.9519 r:0.9515 f1:0.9515  time:5.9703
CV k=8 p:0.9500 r:0.9494 f1:0.9494  time:7.8814

wine
oob    p:0.9762 r:0.9803 f1:0.9780  time:0.6950
CV k=2 p:0.9748 r:0.9812 f1:0.9774  time:1.2872
CV k=4 p:0.9714 r:0.9746 f1:0.9728  time:3.2011
CV k=6 p:0.9603 r:0.9643 f1:0.9619  time:4.4640
CV k=8 p:0.9714 r:0.9746 f1:0.9728  time:6.1454

iris + noise
oob    p:0.4723 r:0.4800 f1:0.4759  time:0.6677
CV k=2 p:0.5298 r:0.5267 f1:0.5279  time:1.3729
CV k=4 p:0.5456 r:0.5533 f1:0.5489  time:3.1605
CV k=6 p:0.4776 r:0.4800 f1:0.4788  time:4.1999
CV k=8 p:0.5043 r:0.5000 f1:0.5009  time:6.0875

digits + noise
oob    p:0.7850 r:0.7853 f1:0.7834  time:1.4698
CV k=2 p:0.7365 r:0.7377 f1:0.7342  time:2.8328
CV k=4 p:0.7567 r:0.7568 f1:0.7543  time:5.4918
CV k=6 p:0.7682 r:0.7697 f1:0.7671  time:9.1825
CV k=8 p:0.7717 r:0.7714 f1:0.7692  time:12.1008

wine + noise
oob    p:0.5377 r:0.5483 f1:0.5348  time:0.7344
CV k=2 p:0.4911 r:0.5034 f1:0.4873  time:1.4287
CV k=4 p:0.4828 r:0.5058 f1:0.4855  time:3.1024
CV k=6 p:0.5375 r:0.5439 f1:0.5320  time:4.3943
CV k=8 p:0.4778 r:0.5129 f1:0.4840  time:5.9890

 これからわかることとしては、

  • 全体的にOOBエラーとCVで求めたスコアはそこそこ近いので、OOBエラーはそこそこ信頼できると思います
  • OOBは短い時間で済むので、お得です
  • CVの場合はkを大きくすると性能が上がりますが、これは学習に使うデータ量がk=2なら全体の1/2、k=4なら3/4、k=8なら7/8という風に増加していくからです
  • OOBエラーがCVのスコアを上回る場合、下回る場合ともにあるようです。OOBエラーは、学習しているデータ量はほぼleave one outに近いものの、木の本数が設定値の約1/3くらいになるという性質があります。学習データ量の有効性が高いデータセットではCVの場合より高いスコアに、木の本数の有効性が高いデータセットではCVの場合に対して低いスコアになるということでしょう

 まあ、とりあえず妥当に評価できるんじゃねえの? という感じがします。もちろん同じ条件下で計測したスコアではないので、OOBエラーと交差検証の結果を直接比較することはできませんが、OOBエラー同士の優劣で性能を見積もる分にはたぶん問題ないでしょう*4

 注意点としては、OOBエラーは全体の木の約1/3(厳密には36%くらい)を使って予測するので、実際の結果よりは悪めに出る可能性があります。木の本数を多めにおごってやると良いでしょう。

結論

 OOBでもいい。

参考

qiita.com

*1:というか論文は確実にあると思いますが

*2:し、簡単なことなら人の書いた論文を読み解くより自分でやった方が楽だったりします

*3:プログラムを書き上げて回してから記事にしているので説明が雑・・・

*4:逆に言えば、他の分類器との比較には使えないというけっこう致命的な欠点がある訳ですが・・・

MeCab+Pythonでunidicを使う

はじめに

 MeCabの辞書といえばipadicが定番ですが、unidicという辞書もあります。ちょっとこれを使いたくなったので、使うことにしました。

 なお、MeCabおよびmecab-pythonはすでにipadic等で使える状況になっているものとします。

 目次

unidicのいいところ

  • 語彙や単語の分割(これに関しては良し悪しの世界・・・ipadicにはmecab-ipadic-NEologdがあるし)
  • 話し言葉、古文版の辞書がある
  • 分類語彙表シソーラス)と組み合わせて使える。

unidicをmecabで使えるようにする

 とりあえず落としてきます。

「UniDic」国語研短単位自動解析用辞書

 最新版ダウンロードのページからダウンロードできます。ファイルサイズがでかいので、気長に落とします(相手サーバの回線は速いらしく、私が落としたときは十分程度で終わりました)。

 ダウンロードが終わったら、解凍してディレクトリを作ってください。場所はどこでも構いませんが、わかりやすい場所にしておくことをおすすめします*1

 できあがったディレクトリの中には、けっこう色々なファイルが入っています。が、その中身がすべて必要かというとそうでもなく、確認していませんが、

  • char.bin
  • matrix.bin
  • sys.dic
  • unk.dic
  • dicrc

 あたりがあればとりあえず動くはずです。この他には、ライセンスファイル等と、自分でビルドするときに必要になるファイルが同包されています。ビルドするのは大変なので、今回はバイナリ版を素直に使うことにします*2。なお、ビルド手順についてはこちらが参考になります。

MeCab で UniDic 辞書を使ってみる / 桃缶食べたい。

 なお、実行する前に、こちらを参考にしてunidicのdicrcを編集し、出力フォーマットを整えておきます。

UniDic - rmecab

 デフォルトの出力フォーマットは、何かうまくいっていないような感じだからです。

;以下の2行をコメントアウトする(連続しているとは限らない)
;bos-feature = BOS/EOS,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*
;node-format-unidic22 = %m\t%f[0],%f[1],%f[2],%f[3],%f[4],%f[5],%f[6],%f[7],%f[8],%f[9],%f[10],%f[11],%f[12],%f[13],%f[14],%f[15],%f[16],%f[17],%f[18],%f[19],%f[20],%f[21],%f[22],%f[23],%f[24],%f[25],%f[26],%f[27],%f[28]\n

;以下の2行を追加
bos-feature = BOS/EOS,*,*,*,*,*,*,*,*
node-format-unidic22 = %m\t%f[0],%f[1],%f[2],%f[3],%f[4],%f[5],%f[10],%f[9],%f[11]\n

 2018年7月現在では上記リンクとは微妙に設定ファイルの書式が変わっているようですが、そんなに困惑するほどの違いはないのでそのまま書き換えます。

 書き換えたら、コマンドラインで以下のように打ってみます。

$ mecab -d 解凍してできたディレクトリのパス
適当な日本語
適当	名詞,普通名詞,サ変形状詞可能,,,,適当,テキトー,テキトー
な	助動詞,,,,助動詞-ダ,連体形-一般,だ,ナ,ダ
日本	名詞,固有名詞,地名,国,,,日本,ニホン,ニホン
語	名詞,普通名詞,一般,,,,語,ゴ,ゴ
EOS

 上のように出力されれば成功です。

 unidicをデフォルトにしたければ、mecabrcなどを書き換えるという作業がこの後に続きますが、私はデフォルトはipadicでいいのでそのままにします。

mecab-pythonから呼ぶ

 コマンドライン引数をMeCab.Taggerの引数に渡すだけ。

>>> import MeCab
>>> tagger = MeCab.Tagger("-d 解凍してできたディレクトリのパス")
>>> print(tagger.parse("何らかの日本語"))
何	代名詞,,,,,,何,ナン,ナン
ら	接尾辞,名詞的,一般,,,,ら,ラ,ラ
か	助詞,副助詞,,,,,か,カ,カ
の	助詞,格助詞,,,,,の,ノ,ノ
日本	名詞,固有名詞,地名,国,,,日本,ニホン,ニホン
語	名詞,普通名詞,一般,,,,語,ゴ,ゴ
EOS

 簡単でいいですね。

*1:ホーム以下にディレクトリを掘って置いても良いし、正式にはmecabの辞書を置くディレクトリに入れるべきでしょうか

*2:linux環境、UTF-8で使う分には問題ないと思います。それ以外だと文字コードを変換して自分でビルドする作業が必要になるはずです

【python】多重リストを昇降混ぜてソート

 pythonでは多重リストのソートは次のように書ける。

import random
from pprint import pprint

data = [[random.randint(0, 20), 
         random.randint(0, 20)]
        for _ in range(10)]

print("data")
pprint(data)

print("\nsorted data")
pprint(sorted(data, key=lambda x:(x[0], x[1])))

 結果

data
[[6, 10],
 [11, 4],
 [3, 1],
 [17, 10],
 [8, 5],
 [3, 15],
 [13, 9],
 [8, 12],
 [11, 12],
 [20, 5]]

sorted data
[[3, 1],
 [3, 15],
 [6, 10],
 [8, 5],
 [8, 12],
 [11, 4],
 [11, 12],
 [13, 9],
 [17, 10],
 [20, 5]]

 参考:
【Python】ソート

 要するにkeyをこのような形式にして、各子リストの要素を桁のようにみなしてソートできる訳だ(いまいちうまく表現できないけど)。

 降順にしたければ、reversed=Trueを指定するだけ。

import random
from pprint import pprint

data = [[random.randint(0, 20), 
         random.randint(0, 20)]
        for _ in range(10)]

print("data")
pprint(data)

print("\nsorted data")
pprint(sorted(data, key=lambda x:(x[0], x[1]), reverse=True))

 結果

data
[[4, 19],
 [3, 8],
 [15, 9],
 [13, 9],
 [7, 10],
 [8, 12],
 [20, 2],
 [19, 17],
 [7, 11],
 [5, 11]]

sorted data
[[20, 2],
 [19, 17],
 [15, 9],
 [13, 9],
 [8, 12],
 [7, 11],
 [7, 10],
 [5, 11],
 [4, 19],
 [3, 8]]


 では、最初の要素は昇順、次の要素は降順にしたいときは、どうしたら良いのでしょう。

 reverse引数はbooleanのみ受け付ける。tupleが渡せたら良かったのだけど、渡せないので仕方ない。

 しかし、そんなに心配する必要はない。

ソート HOW TO — Python 3.6.5 ドキュメント

 によると、pythonのソートは安定ソートである。このことを利用して、こう書ける。

import random
from pprint import pprint

data = [[random.randint(0, 20), 
         random.randint(0, 20)]
        for _ in range(10)]

print("data")
pprint(data)

print("\nsorted data")
tmp = sorted(data, key=lambda x:x[1], reverse=True)
pprint(sorted(tmp, key=lambda x:x[0]))

 結果

data
[[0, 12],
 [13, 1],
 [15, 6],
 [14, 4],
 [11, 1],
 [7, 2],
 [17, 3],
 [13, 8],
 [0, 19],
 [19, 6]]

sorted data
[[0, 19],
 [0, 12],
 [7, 2],
 [11, 1],
 [13, 8],
 [13, 1],
 [14, 4],
 [15, 6],
 [17, 3],
 [19, 6]]
[0, 19],
 [0, 12],

 とか
>||
[13, 8],
[13, 1],
|

【python】TF-IDFで重要語を抽出してみる

概要

 すでに語り尽くされた感のあるネタですが、TF-IDFで文書の重要な単語(重要語、あるいは特徴語)を抽出してみます。

 numpyとsklearnを使うと、10行程度のコードで実現できるので簡単です。

コードの書き方

 とりあえず、対象データとしては20newsgroupsを使います。関数一つで読み込めて便利だからです。

 sklearn.datasets.fetch_20newsgroups — scikit-learn 0.19.1 documentation

 自然言語処理の技術紹介などの記事で、Webスクレイピングなどをしてデータを作っているケースをよく見かけますが、こちらの方が手間がかからなくて、再現性も高いです(sklearnが仕様変更しない限り再現できる)。使えるデータは使いましょう。

 関連記事:【python】sklearnのfetch_20newsgroupsで文書分類を試す(1) - 静かなる名辞

 あとはTfidfVectorizerに入れて、いきなりTF-IDFのベクトルに変換します。

 sklearn.feature_extraction.text.TfidfVectorizer — scikit-learn 0.19.1 documentation

 詳しい使い方は、ドキュメントやCountVectorizerの記事を読んでいただければ良いです(CountVectorizerと使い方はほぼ同じ)。

 使い方のコツとして

  • min_dfオプションを適当に指定してゴミ単語を削った方が良いこと
  • 基本的にtransformした返り値がsparse matrix型なのでtoarray()メソッドで密行列に変換して取り扱ってやる必要があること

 が挙げられます。それ以外は、とりあえず使うだけならそれほど気は配らなくても良いはず。

 ここまでの記述をコードにすると、こんな感じです。

import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.datasets import fetch_20newsgroups

news20 = fetch_20newsgroups()
vectorizer = TfidfVectorizer(min_df=0.03)
tfidf_X = vectorizer.fit_transform(news20.data[:1000]).toarray()  # ぜんぶで1万データくらいあるけど、そんなに要らないので1000件取っている

 ここからどうするんじゃい、ということですが、スマートに書くためには、ちょっとしたnumpy芸が要求されます。

index = tfidf_X.argsort(axis=1)[:,::-1]

 tfidf_X.argsort(axis=1)でソートした結果のindexを返します。[:,::-1]はreverseです。これによって、各文書のTF-IDF値にもとづいて降順ソートされたindexが得られます。

 次に、このindexに基づいて単語を復元することを考えます。TfidfVectorizer.get_feature_names()で、特徴抽出時に使ったindexの順に並んだ単語のリストが得られるのですが*1、リストだとnumpy芸が使えないのでnumpy配列にしておきます。あとは、一気に変換します。

feature_names = np.array(vectorizer.get_feature_names())
feature_words = feature_names[index]

 numpyのこの機能を使っているコードはあまり見かけないのですが、実は

>>> import numpy as np
>>> a = np.array(["hoge","fuga","piyo"])
>>> b = np.array([[0,0,0],[2,1,0],[0,2,0]])
>>> a[b]
array([['hoge', 'hoge', 'hoge'],
       ['piyo', 'fuga', 'hoge'],
       ['hoge', 'piyo', 'hoge']], dtype='<U4')

 こういう仕様になっておりまして、意図した通りの変換が一発でできています。知らないと戸惑いますね。

 あとは配列から適当に取り出せばオッケーです。各文書ベクトル(というか単語の順列)の先頭n次元を取ると、それがそのままn個目までの重要語になっています。

やってみた

 コード全文を以下に示します。

import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.datasets import fetch_20newsgroups

news20 = fetch_20newsgroups()
vectorizer = TfidfVectorizer(min_df=0.03)
tfidf_X = vectorizer.fit_transform(news20.data[:1000]).toarray()

index = tfidf_X.argsort(axis=1)[:,::-1]
feature_names = np.array(vectorizer.get_feature_names())
feature_words = feature_names[index]

n = 5  # top何単語取るか
m = 15  # 何記事サンプルとして抽出するか
for fwords, target in zip(feature_words[:m,:n], news20.target):
    # 各文書ごとにtarget(ラベル)とtop nの重要語を表示
    print(news20.target_names[target])
    print(fwords)

 結果は、

rec.autos
['car' 'was' 'this' 'the' 'where']
comp.sys.mac.hardware
['washington' 'add' 'guy' 'speed' 'call']
comp.sys.mac.hardware
['the' 'display' 'anybody' 'heard' 'disk']
comp.graphics
['division' 'chip' 'systems' 'computer' 'four']
sci.space
['error' 'known' 'tom' 'memory' 'the']
talk.politics.guns
['of' 'the' 'com' 'to' 'says']
sci.med
['thanks' 'couldn' 'instead' 'file' 'everyone']
comp.sys.ibm.pc.hardware
['chip' 'is' 'fast' 'ibm' 'bit']
comp.os.ms-windows.misc
['win' 'help' 'please' 'appreciated' 'figure']
comp.sys.mac.hardware
['the' 'file' 'lost' 've' 'it']
rec.motorcycles
['00' 'org' 'the' 'out' 'and']
talk.religion.misc
['the' 'that' 'may' 'to' 'is']
comp.sys.mac.hardware
['hp' 'co' 'com' 'tin' 'newsreader']
sci.space
['the' 'power' 'and' 'space' 'nasa']
misc.forsale
['10' 'very' 'and' 'reasonable' 'sale']

 まあ、それなりにうまくいってるんじゃね? という結果が得られました*2

 車のカテゴリやコンピュータのカテゴリ、宇宙のカテゴリなんかは割とわかりやすいですが、talk.religion.misc(宗教に関する話題?)だと['the' 'that' 'may' 'to' 'is']になっていたりするのは面白いです。この文書だけがたまたまとても抽象的だったのか、このカテゴリ自体こんな感じなのかはよくわかりません。

 ということで、文書ごとにやってうまく結果が出るのはわかったので、次は各カテゴリ(ラベル)ごとに特徴的な単語を出してみようと思ったのですが、これはちょっとめんどいのでとりあえずパス。そのうち気が向いたら追記します。

まとめ

 特徴抽出とTF-IDFの計算を自分で書いて、重要語への変換も自分で書いてという感じでやるとかなり手間がかかるのですが、sklearnとnumpyのちからに頼ると簡潔に書けて嬉しいですね。

 TF-IDFの上位数件くらいは、それなりに文書の特徴を反映するような単語と言って良いと思うので、ざっくり内容を把握したいとか、ざっくり特徴抽出したいというときはこういう方法も良いと思います。

*1:つまりindexと特徴ベクトルの次元が対応

*2:それなりに「まとも」な結果になっているのはTfidfVectorizerのオプションでmin_df=0.03を指定しているからで、これをやらないと見事にdfが低すぎるゴミ単語ばっかり引っかかる結果になる。注意しましょう