車輪を再発明

プログラミングは趣味です。PythonとかGoogle App Engineとか触りますが、映画とかゲームとかの感想も書くかもしれません。。。

「Pythonによるデータ分析入門」を読む NumPy編

Pythonによるデータ分析入門」を読んでいます。
pandasとNumpyについて書かれた本で、pandas目当てで購入したのですが、案外NumPy周りで知らないことが多く勉強になったのでメモです。
ここで挙げたもの以外にも、線形代数関連の関数や、ユニバーサル関数などがありますが、数が多いのでこの記事には書きません。

ぶっちゃけpandas使っているひとにとっては不要な情報かも。
なお、pandas編の予定は未定。

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

NumPyのデータ形式「ndarray」

NumPyのデータ形式の基本は、ndarrayです。
ndarrayは、「同じ型」のデータを連続するメモリ上に配置することで、効率的なデータアクセスを可能にします。

ndarrayの構成要素

ndarrayを構成する要素は、以下の4つです。

  • データへのポインタ
  • データ型(dtype)
  • データの形状(shape)
  • ストライド(stride)

以下の例では、2x10x5次元の1のみからなる3次元配列のdtype, shape, strideを見ています。

In [8]: arr = np.ones((2, 10, 5))

In [9]: arr
Out[9]: 
array([[[ 1.,  1.,  1.,  1.,  1.],
        [ 1.,  1.,  1.,  1.,  1.],
        [ 1.,  1.,  1.,  1.,  1.],
        [ 1.,  1.,  1.,  1.,  1.],
        [ 1.,  1.,  1.,  1.,  1.],
        [ 1.,  1.,  1.,  1.,  1.],
        [ 1.,  1.,  1.,  1.,  1.],
        [ 1.,  1.,  1.,  1.,  1.],
        [ 1.,  1.,  1.,  1.,  1.],
        [ 1.,  1.,  1.,  1.,  1.]],

       [[ 1.,  1.,  1.,  1.,  1.],
        [ 1.,  1.,  1.,  1.,  1.],
        [ 1.,  1.,  1.,  1.,  1.],
        [ 1.,  1.,  1.,  1.,  1.],
        [ 1.,  1.,  1.,  1.,  1.],
        [ 1.,  1.,  1.,  1.,  1.],
        [ 1.,  1.,  1.,  1.,  1.],
        [ 1.,  1.,  1.,  1.,  1.],
        [ 1.,  1.,  1.,  1.,  1.],
        [ 1.,  1.,  1.,  1.,  1.]]])

In [10]: arr.dtype
Out[10]: dtype('float64')

In [11]: arr.shape
Out[11]: (2, 10, 5)

In [12]: arr.strides
Out[12]: (400, 40, 8)

ストライドについては、少し解説が必要かもしれません。
ストライドとは、「『隣』の要素にアクセスするために、ポインタをいくら動かせばいいか」を表す数値だと思えばいいと思います。

float64型は8バイトですので、配列上の隣接要素はメモリ空間では以下のような隔たりがあります。

  • arr[0][0][0]とarr[1][0][0]の間の距離はfloat64が10 x 5要素あるため、8 x 10 x 5 = 400バイト
  • arr[0][0][0]とarr[0][1][0]の間の距離はfloat64が5要素分あるため、8 x 5 = 40バイト
  • arr[0][0][0]とarr[0][0][1]の間の距離はfloat64が1要素分あるため、8バイト

C型配列とF型配列

上記の例では、arr[0][0][0]とarr[0][0][1]がメモリ上では隣接する要素でしたが、逆の場合も考えられます。つまり、arr[0][0][0]とarr[1][0][0]がメモリ上で隣接しているという形です。

前者のようなデータの保持方法を、C型順序といい、行優先ともいいます。「行優先」の由来は、2次元配列の場合だと、行ごとのデータがメモリ上に固まって格納されているためです。

一方で、後者のようなデータの保持方法を、F型順序といい、列優先ともいいます。

C型、F型という名称の由来は、C型がC言語で使われていた方式で、F型がFortranで使われていた方式だからだそうです。

In [61]: f_arr = np.ones((2,10,5), order='F')

In [62]: f_arr.strides
Out[62]: (8, 16, 160)

F型では、ストライドがC型とは異なるということがわかりますね。

C型とF型のパフォーマンス比較?

C型とF型の違いがもたらすパフォーマンス上の違いとは何でしょうか?

C型では、特定の行のデータはメモリ空間上のまとまった場所に格納されていますが、F型では、特定の行のデータはメモリ空間上に(規則的にではありますが、)分散しているということになります。

ということは、「各行の集計を行う」という動作は、分散している要素をかき集める分、F型のほうが処理に時間がかかりそうですね。

In [79]: c_arr = np.ones((1000, 1000), order='C')

In [80]: f_arr = np.ones((1000, 1000), order='F')

In [81]: %timeit c_arr.sum(1)  # sum(1)は各行で総和をとる関数です
1000 loops, best of 3: 477 µs per loop

In [82]: %timeit f_arr.sum(1)
1000 loops, best of 3: 453 µs per loop

あれ、全然変わんないや。。。

ちなみに本では明確にC型のほうが早い結果が出ています。(P.433)

本書に記載されている範囲では、メモリ上の連続した空間へのアクセスを高速にできる要因の一つとして、CPUのキャッシュ階層が挙げられています。(空間的局所性というやつですかね)

実験環境が、仮想マシンなのですが、なにか関係あるんでしょうかね。

配列の型

基本的な型

ndarrayを生成するタイミングでdtypeを指定してあげると、型を指定できます。

In [1]: import numpy as np

In [2]: arr1 = np.array([1,2,3], dtype=np.float64)

In [3]: arr1
Out[3]: array([ 1.,  2.,  3.])

In [4]: arr1.dtype
Out[4]: dtype('float64')

In [5]: arr2 = np.array([1,2,3], dtype=np.int32)

In [6]: arr2
Out[6]: array([1, 2, 3], dtype=int32)

In [7]: arr2.dtype
Out[7]: dtype('int32')

上記では、float64とint32を試していますが、文字列や真偽値なんかも使えます。文字列の場合は固定長です。

型のキャスト

astype関数で型を変更できます。返り値として新しいndarrayオブジェクトが返ってきます。元のndarrayには変更が加えられません。
ndarrayはメモリ上に密に配置されているデータですので、型を変えるためには新しくメモリの領域を確保する必要があるため、新しいオブジェクトが返ってくるということですね。

In [8]: arr_a = np.array([True, False, True, False])

In [9]: arr_a
Out[9]: array([ True, False,  True, False], dtype=bool)

In [10]: arr_b = arr_a.astype(np.float64)

In [11]: arr_b
Out[11]: array([ 1.,  0.,  1.,  0.])

構造化配列

ndarrayの生成時に指定するdtypeに、「構造」をもたせることができます。

In [53]: dtype = [('name', 'U32'), ('age', 'u1')]

In [54]: arr = np.array([(u'井上喜久子', 17), (u'武内駿輔', 17)], dtype=dtype)

In [55]: arr
Out[55]: 
array([('井上喜久子', 17), ('武内駿輔', 17)], 
      dtype=[('name', '<U32'), ('age', 'u1')])

上記の例では、dtypeとしてname(U32型、つまりUnicode文字列で32文字)とage(u1型、つまりunsignedの1バイトInteger型)からなる構造を指定しています。

これによって、以下のようなアクセス方法が可能になります。

In [57]: arr[0]['name']
Out[57]: '井上喜久子'

In [58]: arr['name'][0]
Out[58]: '井上喜久子'

In [59]: arr['name']
Out[59]: 
array(['井上喜久子', '武内駿輔'], 
      dtype='<U32')

In [60]: arr['age']
Out[60]: array([17, 17], dtype=uint8)

また、dtypeとして、以下のような階層を持った形も指定出来ます。

In [62]:# xは3つのint64からなり、yは1つのint32からなる

In [63]: dtype = [('x', np.int64, 3), ('y', np.int32)]

In [64]: arr = np.zeros(4, dtype=dtype)

In [65]: arr
Out[65]: 
array([([0, 0, 0], 0), ([0, 0, 0], 0), ([0, 0, 0], 0), ([0, 0, 0], 0)], 
      dtype=[('x', '<i8', (3,)), ('y', '<i4')])

In [66]: # xはaとbからなり、aは倍精度浮動小数点数、bは単精度浮動小数点数、yはint32

In [67]: dtype = [('x', [('a', 'f8'), ('b', 'f4')]),('y', np.int)]

In [68]: arr = np.array([((1, 2), 5), ((3, 4), 6)], dtype=dtype)

In [69]: arr
Out[69]: 
array([((1.0, 2.0), 5), ((3.0, 4.0), 6)], 
      dtype=[('x', [('a', '<f8'), ('b', '<f4')]), ('y', '<i8')])

ソート

直接ソート(in-place)

ndarrayのソートは、既存のndarray内のデータが置換される形で行われます。新しいndarrayを作ったりはしません。

In [80]: values = np.array([5, 0, 1, 3, 2])

In [81]: values.sort()

In [82]: values
Out[82]: array([0, 1, 2, 3, 5])

多次元配列の場合は、sortの引数で軸を指定することができます。

In [108]: arr0 = np.random.rand(5, 3)

In [109]: arr1 = arr0.copy()

In [110]: arr0
Out[110]: 
array([[ 0.7135679 ,  0.6001063 ,  0.96356225],
       [ 0.94294085,  0.09982734,  0.11977476],
       [ 0.37965608,  0.46243769,  0.08483628],
       [ 0.04989755,  0.07350422,  0.2098485 ],
       [ 0.16695968,  0.00744647,  0.89886347]])

In [111]: arr0.sort(0) # 各列内でソート

In [112]: arr0
Out[112]: 
array([[ 0.04989755,  0.00744647,  0.08483628],
       [ 0.16695968,  0.07350422,  0.11977476],
       [ 0.37965608,  0.09982734,  0.2098485 ],
       [ 0.7135679 ,  0.46243769,  0.89886347],
       [ 0.94294085,  0.6001063 ,  0.96356225]])

In [113]: arr1.sort(1) # 各行内でソート

In [114]: arr1
Out[114]: 
array([[ 0.6001063 ,  0.7135679 ,  0.96356225],
       [ 0.09982734,  0.11977476,  0.94294085],
       [ 0.08483628,  0.37965608,  0.46243769],
       [ 0.04989755,  0.07350422,  0.2098485 ],
       [ 0.00744647,  0.16695968,  0.89886347]])

間接ソート

内部のデータの順番には影響を与えないまま、ソートしたいことがよくありますが、それを実現するのが間接ソートです。

argsort

argsortを用いると、ソートした時のインデクサー(arrayの各要素への参照)が得られますので、それを用いることで、ソートの結果を得ることができます。

In [117]: values = np.array([5, 0, 1, 3, 2])

In [118]: indexer = values.argsort()  # 間接ソート

In [119]: indexer
Out[119]: array([1, 2, 4, 3, 0])

In [120]: values[indexer]  # インデックスによるndarray参照
Out[120]: array([0, 1, 2, 3, 5])

多次元配列の時は、インデクサーの使い方に注意が必要です。1次元の時のようなわかりやすい参照を返してくれるわけではないのです。

In [145]: arr
Out[145]: 
array([[ 0.43923064,  0.68340278,  0.58572889],
       [ 0.45396293,  0.61659209,  0.25095145],
       [ 0.58301305,  0.66421161,  0.44026581],
       [ 0.47286063,  0.65877653,  0.73912894],
       [ 0.06365072,  0.08286274,  0.38831605]])

In [146]: indexer0 = arr.argsort(0)

In [147]: indexer0  # 各列内でソートされたインデックスがndarrayで返ってくる。
Out[147]: 
array([[4, 4, 1],
       [0, 1, 4],
       [1, 3, 2],
       [3, 2, 0],
       [2, 0, 3]])

In [148]: arr[indexer0]  #そのまま使っても意味がない。
Out[148]: 
array([[[ 0.06365072,  0.08286274,  0.38831605],
        [ 0.06365072,  0.08286274,  0.38831605],
        [ 0.45396293,  0.61659209,  0.25095145]],

       [[ 0.43923064,  0.68340278,  0.58572889],
        [ 0.45396293,  0.61659209,  0.25095145],
        [ 0.06365072,  0.08286274,  0.38831605]],

       [[ 0.45396293,  0.61659209,  0.25095145],
        [ 0.47286063,  0.65877653,  0.73912894],
        [ 0.58301305,  0.66421161,  0.44026581]],

       [[ 0.47286063,  0.65877653,  0.73912894],
        [ 0.58301305,  0.66421161,  0.44026581],
        [ 0.43923064,  0.68340278,  0.58572889]],

       [[ 0.58301305,  0.66421161,  0.44026581],
        [ 0.43923064,  0.68340278,  0.58572889],
        [ 0.47286063,  0.65877653,  0.73912894]]])

In [153]: arr[:,1]  #arrの2列目のみ抜粋
Out[153]: array([ 0.68340278,  0.61659209,  0.66421161,  0.65877653,  0.08286274])

In [154]: arr[:, 1][indexer0[:, 1]]  # 2列目のみのソート結果を確認する
Out[154]: array([ 0.08286274,  0.61659209,  0.65877653,  0.66421161,  0.68340278])
lexsort

あと、間接ソートにはlexsortなんてのもあります。
姓名のデータがあるときに、姓で優先的に並べ替えた後に、各姓の内部で名の並べ替えを行う、なんていう時に使えます。

In [165]: first_name = np.array(['Bob', 'Jane', 'Steve', 'Bill', 'Barbara'])

In [166]: last_name = np.array(['Jones', 'Arnold', 'Arnold', 'Jones', 'Walters'])

In [168]: sorter = np.lexsort((first_name, last_name))

In [173]: for name in zip(first_name[sorter], last_name[sorter]):
   .....:     print (name)
   .....:     
('Jane', 'Arnold')
('Steve', 'Arnold')
('Bill', 'Jones')
('Bob', 'Jones')
('Barbara', 'Walters')

last_nameでのソート結果は保持したまま、first_nameでもソートするという動作が実現できています。lexsortでは、後の方に渡したndarrayのほうが優先的にソートされます。

ファイルの入出力

バイナリ形式

すごく簡単にndarrayの保存と読み込みができます。
注意スべき点としては、勝手に.npyという拡張子がつくので、読み込みの際は忘れないようにする、ということでしょうか。

In [174]: arr = np.arange(10)

In [175]: np.save('test', arr) # 保存

In [177]: new_arr = np.load('test.npy') # 読み込み

In [178]: new_arr
Out[178]: array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

テキスト形式

他のプログラムや言語とデータを共有して使いたいとき、csvファイルやタブ区切りファイルに一時保管して渡すという方法は、なんだかんだで一番使う手段だと思います。

In [183]: arr = np.random.rand(3, 5)

In [184]: arr
Out[184]: 
array([[ 0.75277139,  0.11091738,  0.68009413,  0.21621293,  0.72897406],
       [ 0.80775297,  0.39010844,  0.90030189,  0.25667751,  0.43382427],
       [ 0.64004754,  0.1824048 ,  0.95000589,  0.29878554,  0.6953434 ]])

In [185]: np.savetxt('test.csv', arr, delimiter=',')  # 保存

In [186]: %cat test.csv
7.527713922362844201e-01,1.109173805522273293e-01,6.800941327360305877e-01,2.162129304535805874e-01,7.289740567048890174e-01
8.077529740181056406e-01,3.901084449734447679e-01,9.003018943256323459e-01,2.566775088487044387e-01,4.338242701296414205e-01
6.400475420605206134e-01,1.824048033472692731e-01,9.500058880204274026e-01,2.987855382194802845e-01,6.953433979262562126e-01

In [187]: np.loadtxt('test.csv', delimiter=',') # 読み込み
Out[187]: 
array([[ 0.75277139,  0.11091738,  0.68009413,  0.21621293,  0.72897406],
       [ 0.80775297,  0.39010844,  0.90030189,  0.25667751,  0.43382427],
       [ 0.64004754,  0.1824048 ,  0.95000589,  0.29878554,  0.6953434 ]])

メモリマップファイル形式

非常に大きなデータファイルのために、メモリ上にすべてを展開できないとき用の形式です。
1TBとかのデータだとメモリに読み込めないので、普段はディスクにおいておいて、必要なときに必要な部分だけ取り出して処理をするということができるようです。

以下の例では、float64の3万 x 3万のndarrayとして扱えるメモリマップファイルを作成しています。

In [188]: mmap = np.memmap('mymmap', dtype='float64',
                            mode='w+', shape=(30000, 30000)) # 作成

In [189]: %ls mymmap -lh
-rw-rw-r-- 1 taromaru taromaru 6.8G  31 13:49 mymmap

In [190]: mmap
Out[190]: 
memmap([[ 0.,  0.,  0., ...,  0.,  0.,  0.],
       [ 0.,  0.,  0., ...,  0.,  0.,  0.],
       [ 0.,  0.,  0., ...,  0.,  0.,  0.],
       ..., 
       [ 0.,  0.,  0., ...,  0.,  0.,  0.],
       [ 0.,  0.,  0., ...,  0.,  0.,  0.],
       [ 0.,  0.,  0., ...,  0.,  0.,  0.]])

In [211]: mmap[:5] = 1

In [212]: mmap.flush()

In [213]: del mmap

# ipythonを再起動

In [3]: mmap = np.memmap('mymmap', dtype='float64', shape=(30000, 30000)) # 読み込み

In [4]: mmap
Out[4]: 
memmap([[ 1.,  1.,  1., ...,  1.,  1.,  1.],
       [ 1.,  1.,  1., ...,  1.,  1.,  1.],
       [ 1.,  1.,  1., ...,  1.,  1.,  1.],
       ..., 
       [ 0.,  0.,  0., ...,  0.,  0.,  0.],
       [ 0.,  0.,  0., ...,  0.,  0.,  0.],
       [ 0.,  0.,  0., ...,  0.,  0.,  0.]])

作成した時点で、6.8GBの巨大なファイルが作成されていることが確認できます。
dtypeで型が指定され、shapeも指定されているため、その分の領域をあらかじめ確保しているということです。

また、作成したmmapはflushで明示的にディスクへ書き込むことができます。
(裏でも、定期的に書き込みが発生しているようです。)

読み込みを行うときも、dtypeとshapeを指定しなくてはなりません。メモリマップファイルはただのバイナリファイルで、型や形状に関する情報を一切持っていません。

まとめ

ぼくがNumPyで普段使っているのは線形代数関連のほんの一部です。
ある程度体系的に勉強すると、こういうの学生時代に勉強したなーということが出てきたり、こんな便利な機能があったのかよ、などいろいろ発見があって、まあ楽しい。

NumPy Reference — NumPy v1.9 Manual