静かなる名辞

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

【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が低すぎるゴミ単語ばっかり引っかかる結果になる。注意しましょう

【python】io.StringIOは便利なので使いこなそう

はじめに

 io.StringIOというものがあります。標準モジュールのioに属します。

16.2. io --- ストリームを扱うコアツール — Python 3.6.6 ドキュメント

 これがどう便利かというと、「ファイルオブジェクトのように見えるオブジェクト」を作れます。

読み込みに使ってみる

 こんな感じですかね。

import io

txt = """hoge
fuga
piyo
"""

f = io.StringIO(txt)
for line in f:
    print(line, end="")

""" =>
hoge
fuga
piyo
"""

 何が嬉しいかって? たとえば、これを見てください。

import io
import pandas as pd

txt = """
number,name,score
1,hoge,100
2,fuga,200
3,piyo,300
"""

df = pd.read_csv(io.StringIO(txt), index_col="number")
print(df)
""" =>
        name  score
number             
1       hoge    100
2       fuga    200
3       piyo    300
"""

 CSVの文字列を読み込むのに使っています。pandas.read_csvの第一引数はファイルパスかストリームしか受け付けませんが、io.StringIOをかましてやることでstrを渡せている訳です。

pandas.read_csv — pandas 0.23.1 documentation

 「ファイルを作るまでもない、作るのが面倒くさい」ような軽い確認やデバッグ作業に重宝します。

書き込んでみる

 io.StringIOが読み込みモードのファイルオブジェクトの代用品として使えることはわかりました。では、書き込みはどうでしょう?

import io
f = io.StringIO()
f.write("hoge\n")
f.close()

 このコードはエラーなく終了しますが、特に副作用は得られません。

 中身を見たければ、こうします。

import io
f = io.StringIO()
f.write("hoge\n")
print(f.getvalue())  # => hoge
f.close()

 StringIO.getvalue()はバッファに書き込まれた内容をすべて吐き出します。closeすると呼べなくなるので注意が必要です。

 こちらもpandasと組み合わせて使ってみます。

import io
import pandas as pd

txt = """
number,name,score
1,hoge,100
2,fuga,200
3,piyo,300
"""

df = pd.read_csv(io.StringIO(txt), index_col="number")

with io.StringIO() as f:
    f.write(df.to_csv())
    print(f.getvalue())

""" =>
number,name,score
1,hoge,100
2,fuga,200
3,piyo,300
"""

 使えているようです。

仲間たち

 ドキュメントを見るとbytes版のBytesIOもあることがわかる。他にも色々あるようだが、あまり目を通していないのでコメントしない。

16.2. io --- ストリームを扱うコアツール — Python 3.6.6 ドキュメント

まとめ

 テキストファイルを相手にするとき、データが小さければソースコードに埋め込めます。そうするとソースコードだけで再現性を確保できて、けっこう良いです。

オブジェクト指向の教育にPythonが向いていると思うこれだけの理由

はじめに

 オブジェクト指向は今となっては常識である。常識であるがゆえに、いかに初心者にわかりやすく教えるかが課題になる。

 世の中でオブジェクト指向の「教材」として使われている言語は、

 の二択くらいだと思う。が、あえて僕はPythonを推してみるよ、という記事。ぶっちゃけポエム。

 内容は、Javaオブジェクト指向を理解するのはしんどいし、RubyPythonだと僅差でPythonが勝つんじゃないかなぁ、という主張。以下で理由を書いていくよ。

 目次

Pythonが向いていると思う理由

理由1:すべてがオブジェクト

 すべてがオブジェクト。これは重要なことである。

 オブジェクトとオブジェクト以外で異なる扱いをしなければならない、プリミティブ型のある言語でオブジェクト指向の教育をするなんて、正気の沙汰ではない。初心者はintと配列ばっかり使う訳だしさ。

 なので、Javaはぶっちゃけ論外だと思う。Ruby使いの人は「ならPythonRubyは互角だ」と言いたくなるかもしれないけど、Rubyには関数が第一級オブジェクトではないという弱点があり、「すべてがオブジェクト」ははっきり言って誇大広告である。

 関数オブジェクトが自然に使えないと困るのかって? まあ、そんなに困らないかもしれない。でも「関数オブジェクト」は理解しておいた方が、オブジェクト指向がよくわかるようになると思わない?(上級者向けすぎるか)

理由2:仕様がスリムで綺麗。書きやすい

 Javaにはまず、publicやらprivateやらある。interfaceというわかりづらい概念もある。静的型付けなのも相まって、メソッドの宣言なんかカオス。public static void mainはどう考えても初心者向けではない*1

 Rubyは、なんかブロックとかいうよくわからないものがあるね。あと、メソッド呼び出しのカッコを省略できるとか。教育上あんまりよくないと思います。

 Pythonにはそういう問題はない。

理由3:self

 Pythonのselfはよく批判されるけど、なんだかんだでわかりやすい。

a.hoge("fuga")

 は

type(a).hoge(a, "fuga")

 と実質的に等価というルールがあることを理解すれば、後は不自然な点はなにもない。インスタンスの外側ではaとして見えているものは、内側ではselfとして見えていると考えれば良いということで、自然な発想でコードを書いていくことができる。

 selfを省略する言語はこれがないので、クラス変数とインスタンス変数の区別を付けるだけでも一苦労だし、ローカル変数まで混ざってくると本格的に訳がわからなくなる(から、EclipseJavaを書くとぜんぶ違う色で見せてくれる)。

理由4:普通にプログラミングしているだけでオブジェクト指向への理解が深まる

 Python初心者はlistをよく使う。そうするとappendやextendが出てくる。これはもうメソッドだ。

 関数の引数に渡したリストにappendするとリストの中身が変わるけど、intだと足し算しても変わらない。mutableなオブジェクトとimmutableなオブジェクトの違いを理解するだろうし、オブジェクトは変数に束縛されているだけというオブジェクト指向の基本的なモデル*2への理解も深まる。

 list.sort()とsorted(list)の違いを理解すれば、破壊的なメソッドには注意しないといけないこともわかる。

 だから、「オブジェクト指向の勉強のためにJavaを半年学んだ人」と「ただ単にプログラミングの勉強として半年Pythonを学んだ人」だと、オブジェクト指向に対する理解度は同程度か、ヘタしたら後者のほうが勝るくらいになっているかもしれない。

 まあ、これに関してはたぶんRubyも互角。

理由5:書いてて楽しい

 タイプ数も少ないし、なんかPythonはパズルみたいな技巧的な面があるし、書いてて楽しい。

向いていない理由も一応書く

 ダメな理由もなんか色々あるといえばあるような気もする。

罠が多い

 Pythonはシンプルなクセに罠が多い言語だと思う(「文字コード」とか「test.py作っただけでまともに動かない」とか「IndentationError」とか)。

 エラーメッセージも、わからないときはとことんわからないのが出るし。

 初心者のうちは、よくわからないところで詰む。そして初心者は自己解決できない。

 罠は回避するように教育していくとかで軽減は可能。ただ、本質的じゃない問題で初心者を悩ませるのもなんだかなぁという気がする。

そういうコンセプトの解説記事とかが少ない。あっても古い

 困るよね。

しょせんスクリプト言語

 カプセル化はないし、ポリモーフィズムの実現方法もすごく簡単(メソッド作るだけ!)。ので、Pythonやってから他の言語のオブジェクト指向を理解しようとすると、追加の学習コストがかかる。

独自の風習

 避けては通れないけど避けなかったところで得るものの少ないデコレータとか、

 初心者を惑わす内包表記とかジェネレータとか、

 やればやるほど実感するtuple周りのキモさとか*3

 これどうなんだ、と思う側面はたくさんあります。

windowsとそんなに相性がよくない

 最近はマシにはなりましたが、まだまだハマると思います。linux環境でやれとなるといきなり敷居が高くなります。

でもまあ、

 なんだかんだで学習コストの低さ、とっつきやすさでは、総合的には初心者向けのわかりやすい言語と言っても別に構わないくらいだとは思うよ(震え声)。

まとめ

 Pythonオブジェクト指向はわかりやすいよね、最初からこれで教えてもらえたらなぁ。Python良いよね! って気持ちの記事です。

 特に内容に責任は持たないが、意見等はご自由にどうぞ。

*1:「そんなのIDEが補完してくれるから良いんだよ!」という意見が当然あると思うが、それをやると馬鹿の一つ覚えのようにIDEの補完と修正任せでコードを書こうとする奴が続出するのでダメだ

*2:異論はあるだろうけど

*3:tupleは丸括弧によって作られるのではない、カンマによって作られるのである

共有渡しと参照の値渡しと

はじめに

 関数やメソッドに引数を渡す方法は、一般的には

  • 値渡し
  • 参照渡し

 の2通りがあると認知されている。

 ところで、『参照の値渡し』という言葉も(ほぼ日本語Web圏限定で)存在する。これは「いわゆる『参照渡し』は参照自体を書き換えるんじゃなくて、参照する対象を変えるだけだから、そう呼んだ方が適当だよ!」という思想に基づくもの、だと思う。このページを見るとわかりやすい。

キミの言語は参照渡しできる?

 つまり、こういうことができたら『参照渡し』で

a = "hoge"
b = "fuga"
swap(a, b)
print(a, b)  # => fuga, hoge

 できなかったら「キミの言語は『参照渡し』できないよ、キミが『参照渡し』だと思っているのは『参照の値渡し』だよ!」ということか。言いたいことはわかる。

 上のリンクにはC言語で同様のことをやる方法としてこういう例が載っているが、

#include <stdio.h>

void swap(char *a, char *b){
  char tmp;
  tmp = *a;
  *a = *b;
  *b = tmp;
}

int main(void){
  char x = 'A', y = 'B';
  swap(&x, &y);
  printf("%c\n", x);
  printf("%c\n", y);
  return 0;
}

 参照する相手を書き換えて実現するのはなんか違う感じがする(それこそ『参照の値渡し』じゃん)。やるならこっちではないか。

#include <stdio.h>

void swap(char **a, char **b){
  char *tmp;
  tmp = *a;
  *a = *b;
  *b = tmp;
}

int main(void){
  char x = 'A', y = 'B';
  char *xptr, *yptr;
  xptr = &x;
  yptr = &y;
  swap(&xptr, &yptr);
  printf("%c\n", *xptr);
  printf("%c\n", *yptr);
  return 0;
}

 このコードを見ていると「あれ、じゃあ『参照渡し』の本当の名前は『参照の参照の値渡し』なの?」という疑問が生まれてくるが、本題から逸れるので追求はしない*1

共有渡しについて考える

 さて、『参照の値渡し』とよく似た概念として、『共有渡し』がある。

 ちなみに、日本語wikipediaの「引数」ページには『共有渡し』は存在しない代わり参照の値渡しがあり、英語版wikipediaの「Evaluation strategy」ページには『Call by sharing』*2はあって参照の値渡しはない。なんだかなぁ。

引数 - Wikipedia
Evaluation strategy - Wikipedia

 『共有渡し』が何者かというと、これは英語版wikipediaのページを読むのがわかりやすいのだが、一行引用してくると

also referred to as call by object or call by object-sharing

 ということであり、つまりは関数(メソッド)の呼び出し元と呼び出し先で同じオブジェクトを「共有」する方法である。

 僕のようなpython使いにとっては「それ普通じゃね?」なのだが、確かに値渡しとも『参照渡し』とも(何を示す言葉かは不問として)異なる概念と言われればそんな気はする。この言葉は、1974年、CLUという初期のオブジェクト指向言語とともに生み出された言葉だそうな。

 これに関連して、こんな議論もある。comp.lang.python *3の昔の議論らしい。pythonの呼び出しモデルは『Call by sharing』だと結論が着いている感じ。

Call By Object

 なんか、同じものを呼ぶ名前がいっぱいある。

The most accurate description is CLU’s “call by object” or “call by sharing“. Or, if you prefer, “call by object reference“.

 こんなに呼び方が多いのはちょっと酷いんだが、“call by object reference“あたりだと言いたいことはよく伝わってくる。

どっちが良いのか

 べつに『参照の値渡し』≒『共有渡し』とみなしても良いのだが、言葉のニュアンスは違うし、他にも考えるべきことがあって「どっちを使うか、あるいはどっちでも良いのか」という問題には答えを出しづらいと思うのだ・・・。

 C言語に対して『共有渡し』を使うのには躊躇する。まあ、メモリ領域を「共有」するのは同じという考え方もあるだろうけど。

  • あくまでも値渡し+参照渡し的な世界観で説明しようとする『参照の値渡し』と、オブジェクトが呼び出し元・先で「共有」されるという現象を重視する『共有渡し』

 同じ現象でも見方が違うのだと思う。
 この違いは意外と効いてきて、前者の立場を取るとJavaは「基本的に値渡し。プリミティブ型はそのまま値で渡るが、それ以外はアドレスの値が渡る」と値渡し的な世界観で説明できるが、後者の立場を取ると「プリミティブ型の値渡し、配列の『参照の値渡し』、オブジェクトの『共有渡し』の折衷」という苦しい説明にせざるを得ないと思う(配列に対して『共有渡し』の言葉を使うことを許すなら、真ん中は削れるけど)。Java使いの人たちが「Javaは値渡し!」と主張したがるのは、つまるところそういうことだろう*4
 逆にpythonrubyのような「すべてがオブジェクト」な言語では、プリミティブ型やら何やらのことは考える必要はなく、また言語仕様の表面で参照の値(要するにアドレス)が見えてくる訳でもないので、『共有渡し』の方がすっきりすると思う。

  • 認知度とか言葉としての良し悪し

 なんか、どっちもどっちという気がする。
 単純な好みの問題だと思うけど強いて選ぶとすれば、国際的に(一応は)通用するであろうこと、言葉の良し悪しについて議論の余地が少ない*5ことから、共有渡しの方が筋は良さそう

 まあ、個人的には『共有渡し』の方がスッキリするし、好きです。でも、『参照の値渡し』が絶対に駄目かというと別にそんなことはないと思うので、TPOをわきまえて使い分けるということで、どうですか。

まとめ

 ややこしくない(動作としてはよくわかる)けど、ややこしい(名前がうまく決められない)話だよね。

*1:けど、こういう動作を無条件に参照渡しと呼んでしまうのは、必ずしも良い考え方ではないと思う。だいたいこれの実装方法なんて、いくらでもあるんだしさ

*2:念のため書いておくと、「渡し」に対応するのは「Pass by」、「Call by」は「呼び出し」に対応するのだが、どちらにせよ意味は大して変わらないのでどちらで訳しても問題はない。この記事では日本語圏で一般的な「渡し」で統一している

*3:筆者はこれが何なのかはよくわからないが

*4:個人的には「Cみたいに明示的にアドレス渡す訳でもないのに値渡しって呼ぶのは逆説的でわかりづらいよ」と思うのだが、そのコストに勝るメリットがあるとする考えなのだろう

*5:というか議論になっていない程度に流行っていないだけかも・・・

【python】immutableなオブジェクトは1つしか存在しないという迷信

 たまに誤解している人がいるので、書いておく。

 pythonのオブジェクトにはimmutableという概念がある。これはオブジェクトが変更不可能であるということを示す。intやstr, tupleなどが代表的なimmutableなオブジェクトである。

 オブジェクトがimmutableであるかどうかは、オブジェクトがいわゆるシングルトンであるかどうかとはまったく関係ない

 確かに、そんな風に振る舞っているように見える状況もある。

>>> a = 1
>>> b = 1
>>> a is b
True
>>> a = "a"
>>> b = "a"
>>> a is b
True

 しかし、tupleを作ると、これが見せかけでしかないことがすぐわかる。

>>> a = (1,2,3)
>>> b = (1,2,3)
>>> a is b
False

 もう少しいろいろ見てみる。

>>> all([a is b for a,b in zip(range(100), range(100))])
True
>>> all([a is b for a,b in zip(range(1000), range(1000))])
False

 不可解だって? まあ、不可解なんだけど、これはpythonの実装の問題である。

 pythonGCを持つオブジェクト指向言語である。つまり、どんなオブジェクトであろうと生成にはインスタンス生成分のコストがかかるし、参照されなくなったオブジェクトはGCが連れて行く。それはそれで良いのだが、「小さめの数字」とか「短めの文字列」みたいなしょっちゅう使われるオブジェクトを必要になるたびに生成し、不要になるたびにGCを呼ぶのは無駄である。よって、pythonは内部にキャッシュを持ち、オブジェクトのインスタンスを残しておく。いわゆるインターン*1だと思えば良い。

 ここのページでその辺のことには触れられている。
Cool Python Tips: is演算子はimmutable変数に使わないこと

 結論を言うと、immutableだからといってシングルトンだと思いこんではいけないし、ましてやis比較などしてはいけない。

*1:python インターンで検索すると「インターンシップ」ばっかり出てきて、有益な情報が出てこない・・・

【python】GridSearchCV『の』パラメータ・チューニング

はじめに

 機械学習でパラメータ・チューニングをしたい場合、グリッドサーチを行うのが定石とされています。sklearnではグリッドサーチはGridSearchCVで行うことができます。

sklearn.model_selection.GridSearchCV — scikit-learn 0.19.1 documentation

 それで何の問題もないかというと、さにあらず。グリッドサーチは計算コストの高い処理ですから*1、素直に書くとデータとアルゴリズム次第ではとんでもない処理時間がかかります。

 もちろん「寝ている間、出かけている間に回すから良い」と割り切るという方法もありますが、可能なら速くしたいですよね。

 そうすると、パラメータ・チューニングのために使うGridSearchCV『の』パラメータを弄り回すという本末転倒気味な目に遭います。そういうとき、どうしたら良いのかを、この記事で書きます。

 先に結論を言ってしまうと、本質的に計算コストの高い処理なので、劇的に速くすることは不可能です。それでも、ちょっとの工夫で2倍程度なら速くすることができます。その2倍で救われる人も結構いると思うので*2、単純なことですがまとめておきます。

 目次

下準備とベースライン

 とりあえず、何も考えずに「GridSearchCVをデフォルトパラメタで使ってみた場合」の時間を測ります。

 そのためには適当なタスクを回してやる必要がありますが、今回はPCA+SVMでdigitsの分類でもやってみることにします。

 コードはこんな感じです。

import timeit
import pandas as pd
from sklearn.datasets import load_digits
from sklearn.decomposition import PCA
from sklearn.svm import SVC
from sklearn.pipeline import Pipeline
from sklearn.model_selection import GridSearchCV

digits = load_digits()
svm = SVC()
pca = PCA(svd_solver="randomized")
pl = Pipeline([("pca", pca), ("svm", svm)])

params = {"pca__n_components":[10, 15, 30, 45],
          "svm__C":[1, 5, 10, 20], 
          "svm__gamma":[0.0001, 0.0005, 0.001, 0.01]}

def print_df(df):
    print(df[["param_pca__n_components",
              "param_svm__C", "param_svm__gamma", 
              "mean_score_time", 
              "mean_test_score"]])

def main1():
    clf = GridSearchCV(pl, params, n_jobs=-1)
    clf.fit(digits.data, digits.target)
    df = pd.DataFrame(clf.cv_results_)
    print_df(df)
    
if __name__ == "__main__":
    print(timeit.timeit(main1, number=1))

 色々なテクニックを使っているコードなので多少解説すると、とりあえずPipelineを使っています。また、GridSearchCV.cv_results_はそのままpandas.DataFrameとして扱えることも利用しています。

hayataka2049.hatenablog.jp

 digits, svm, pca, pl, paramsの変数はmain関数の外でグローバル変数として作っていますが、これはあとでmain2とかmain3を作って使い回すための処置です。

 あと、速くするために必要と思われる常識的なこと(PCAのsvd_solver="randomized"とか、GridSearchCVのn_jobs=-1とか)はすでに実施しています。

 そんなことより本当にやりたいことは、この処理にどれだけ時間がかかるかを知ることです。そのために、timeitを使って時間を計測しています。

27.5. timeit — 小さなコード断片の実行時間計測 — Python 3.6.5 ドキュメント

 さて、私の環境(しょぼいノートパソコン)ではこのプログラムの実行には42.2秒かかりました。

 これをベースラインにします。ここからどれだけ高速化できるかが今回のテーマです。

cvを指定する(効果:大)

 さて、GridSearchCVにはcvというパラメータがあります。default=3であり、この設定だと3分割交差検証になります。交差検証について理解していれば、特に不自然なところはないと思います。

 これを2にしてみます。交差検証できる最低の数字です。こうすると、

  • 交差検証のループ回数が3回→2回になり、それだけで1.5倍速くなる
  • チューニング対象のモデルの計算量が学習データサイズnに対してO(n)以上なら、それ(nが小さくなること)によるご利益もある。なお予測データサイズmに対する予測時間は普通O(m)なので、影響はない

 この相乗効果で、高速化が期待できます。

 この方法のデメリットは学習データを減らしたことで性能が低めになることですが、チューニングのときはパラメータの良し悪し(スコアの大小関係)がわかれば良いので、あまり問題になりません。とにかくやってみます。

def main2():
    clf = GridSearchCV(pl, params, cv=2, n_jobs=-1)
    clf.fit(digits.data, digits.target)
    df = pd.DataFrame(clf.cv_results_)
    print_df(df)

if __name__ == "__main__":
    # print(timeit.timeit(main1, number=1))
    print(timeit.timeit(main2, number=1))

 上のコードと重複する部分は削ってあります。見比べると、ほとんど変わっていないことが、おわかりいただけるかと思います。

 この処置で、処理時間は28.0秒に改善しました。ちょっといじっただけで、2/3くらいに改善してしまった訳です。そして「mean_test_score」はやはり全体的に低くなりましたが、傾向は同じでした。よってパラメータチューニングには使えます。

return_train_score=Falseする(効果:それなり)

 さて、GridSearchCVはデフォルトの設定ではreturn_train_score='warn'になっています。「'warn'って何さ?」というと、こんな警告を出します。

FutureWarning: You are accessing a training score ('std_train_score'), which will not be available by default any more in 0.21. If you need training scores, please set return_train_score=True

 return_train_scoreは要するに学習データに対するスコアを計算するかどうかを指定できる引数です。この警告は割とくだらないことが書いてあるのですが、将来的にはこれがdefault=Falseにされるという警告です。

 基本的に、パラメータチューニングで見たいのはテストデータに対するスコアであるはずです。なのに、現在のデフォルト設定では学習データに対する評価指標も計算されてしまいます。

 これは無駄なので、return_train_score=Falseすると学習データに対する評価指標の計算分の計算コストをケチれます。予測時間なんてたかが知れていますが、それでも一応やってみましょう。

def main3():
    clf = GridSearchCV(pl, params, cv=2,
                       return_train_score=False,
                       n_jobs=-1)
    clf.fit(digits.data, digits.target)
    df = pd.DataFrame(clf.cv_results_)
    print_df(df)
    
if __name__ == "__main__":
    # print(timeit.timeit(main1, number=1))
    # print(timeit.timeit(main2, number=1))
    print(timeit.timeit(main3, number=1))

 この措置によって、処理時間は22.1秒まで短縮されました。ベースラインと比較すると1/2強の時間で済んでいる訳です。

まとめ

 この記事では

  • cv=2にする
  • return_train_score=Falseにする

 という方法で、パラメータチューニングの機能を損なわないまま2倍弱の速度の改善を実現しました。

工夫なし cv=2 cv=2&return_train_score=False
42.2秒 28.0秒 22.1秒

 このテクニックはきっと皆さんの役に立つと思います。

それでも時間がかかりすぎるときは

 そもそもグリッドサーチしようとしているパラメータ候補が多すぎる可能性があります。

 たとえば、3つのパラメータでそれぞれ10個の候補を調べるとなると、10*10*10=1000回の交差検証が行われます。いつまで経っても終わらない訳です。

 今回の記事では4*4*4=64回としましたが、これでもけっこう多い方だと思います。それでも解こうとしている問題が単純なので、デフォルトパラメータでも1分以内には処理できていますが、ちょっと重いモデルにちょっと多量のデータを突っ込んだりすると、もうダメです。何十分とか何時間もかかってしまいます。

 そういう場合、まずは粗いステップ(少ないパラメータ候補数)でざっくりパラメータチューニングしてしまい、どの辺りのパラメータが良いのかわかったら、その周辺に絞ってもう一回パラメータチューニングを行います。こういうのを二段グリッドサーチと言ったりします。

 あるいはベイズ最適化とか、他のアルゴリズムに走るのも一つの手かもしれません。

*1:なにせすべての組み合わせを計算する

*2:たとえば「今から回して、朝までにデータを出さないと教授への報告が間に合わないんだ!」みたいな状況を想定しています