🎉

[Python]Numpyの可読性が高い理由

2024/03/07に公開

はじめに

Numpyを使う理由の1つに可読性の高さがあるのでそれについて理解する。スライシングなどのpythonのlistの良い点は採用し、その上で多次元のarrayに対してufuncsを用いたone-linerでの処理を提供しているのが大きい。

参考資料
Python Data Science Handbookの第2章を参考にした。

検証環境はGoogle Colab
Numpyのバージョンは1.25.2

In[0]:

import numpy as np
np.__version__

Out[0]:

1.25.2

本題

Numpyのスライシング

Numpy arrayはPython listと同様に:(コロン)を用いたスライスで要素にアクセスでき、xをarrayとするとスライスは下のように書ける。
x[start:stop:step]

indexをstartの位置からstepの数だけ増やしてstopに達するまでのindexのリストに合う新しいarrayが返される。
start, stop, stepはそれぞれ省略可能で、省略した場合はそれぞれstart=0,stop=要素数,step=1となる。

In[1]:

import numpy as np
x = np.arange(12)

print(x[::])
print(x[0:12:1])

Out[1]:

[ 0  1  2  3  4  5  6  7  8  9 10 11]
[ 0  1  2  3  4  5  6  7  8  9 10 11]

stepはマイナスの値を取ることができてその場合はarrayの右から左に向かってスライスが行われる。よく使われるものとして、[::-1]があり、これは元の配列の順番を反転させる。コードIn[2]ではstopにNoneを用いているが、スライスにおいて値の省略とNoneは同じ働きをする。

In[2]:

import numpy as np
x = np.arange(12)

print(x[::-1])
print(x[12:None:-1])

Out[2]:

[11 10  9  8  7  6  5  4  3  2  1  0]
[11 10  9  8  7  6  5  4  3  2  1  0]

stopにNone以外の整数を指定するとそのIndexの値は無視される。この挙動は、1つのarrayをあるIndexを境に分割する時のことを考えると分かりやすい。コードIn[3]では、xがx[:5]でスライシングされた場合に5は結果に含まれていないが、x[5:]でスライシングすると5を含んでいる。もし1つ目のarrayが5を含んでしまうと、両方のarrayが5を含んでしまうのでそれを回避するためにstopの値は除外されると理解すると納得しやすいかもしれない。

In[3]:

import numpy as np
x = np.arange(12)

print(x[:5])
print(x[5:])

Out[3]:

[0 1 2 3 4]
[ 5  6  7  8  9 10 11]

スライスされたarrayはviewかcopyのどっちだ?

Numpy arrayとPython listのスライスの違いの1つは、Numpy arrayからスライスされたarrayはコピーではなく元のarrayの一部を表すviewであること。つまり、スライスされたarrayを書き換えると元のarrayを書き換えることができる。viewが作成される理由は大きなデータを扱う際のメモリ使用量などを抑えるためで、おそらく機械学習などで画像や動画などの多次元の大規模なデータを扱う際のことを考えてのことだと思われる。

In[4]:

import numpy as np

py_list = list(range(3)) # =[0,1,2]
np_array = np.arange(3) # =[0 1 2]

sliced_list = py_list[:] # =py_list[0:3]
sliced_array = np_array[:] # =np_array[0:3]

for i in range(len(sliced_list)):
  sliced_list[i] = 3

sliced_array[:] = 3

print(f"py_list: {py_list}") # <- not changed
print(f"np_array: {np_array}") # <- changed

Out[4]:

py_list: [0, 1, 2]
np_array: [3 3 3]

多次元arrayとaxisについて

ここからはlistとの大きな違いである2次元以上のarrayへの処理を見ていく。多次元のarrayに対しての操作のしやすさもNumpyが使われる理由の1つと考えられる。listの場合はfor loopでごちゃごちゃしないとダメだがNumpyにはufuncsがいい感じにやってくれる。

先ほどまで扱っていた1次元array[0 1 2 3 4 5 6 7 8 9 10 11]をreshapeメソッドで2次元(3,4)の配列に変換して、おまけに2次元arrayへのスライスの練習としてstep=2のスライスもしてみる。reshapeは変換前と変換後のサイズが同じ12であれば、他にも2次元配列 (1,12), (2,6), (4,3), (6,2), (12,1)や3次元配列 (2,2,3)などにも変換できる。

In[5]:

import numpy as np
x = np.arange(12).reshape(3,4)
print(f"x:\n {x}")
print(f"sliced:\n {x[0:3:2,1:4:2]}")

Out[5]:

x:
 [[ 0  1  2  3]
 [ 4  5  6  7]
 [ 8  9 10 11]]
sliced:
 [[ 1  3]
 [ 9 11]]

コードIn[5]では何も考えずに(3,4)の配列に変換したが、一般的にはnp.arrayの各行や列は意味のある単位で分けられており、その中でまとめて処理を行いたいことがある。そこで使われるのがaxisを指定したarrayの操作。

コードIn[6]ではufuncsのshuffleとsortを使ってaxisを指定して処理している。

  1. axis=0を指定して列方向に4つの列をそれぞれシャッフル
  2. axis=1を指定して行方向に3つの行をそれぞれシャッフル
  3. axis=0を指定して列方向に4つの列をそれぞれソート
  4. axis=1を指定して行方向に3つの行をそれぞれソート

axis=0とaxis=1で指定しているのはどの次元に対して処理を行うかを示している。例えば、(3,4)の配列に対するufuncsでaxis=0を指定すると、(3,4)の3のある1つ目の次元方向、つまり列方向に対して処理を行う。axis=1を指定すると(3,4)の4のある2つ目の次元方向、つまり行方向に対して処理を行う。

もしlistに対してこれらの処理を行おうとすると、おそらく1~4の処理全てに対してfor loopのスパゲッティが出来上がり可読性はかなり低くなるであろう。特にaxis=0に対応する列方向の操作は複数のlistが関連し合っていてあまり実装したくなるような処理では無い。

In[6]:

import numpy as np

x = np.arange(12).reshape(3,4)
print("original_array\n", x)

np.random.seed(1)
np.apply_along_axis(np.random.shuffle, axis=0, arr=x)
print("shuffle by axis=0 (column)\n", x)
np.apply_along_axis(np.random.shuffle, axis=1, arr=x)
print("shuffle by axis=1 (row)\n", x)
x = np.sort(x, axis=0)
print("sort by axis=0 (column)\n", x)
x = np.sort(x, axis=1)
print("sort by axis=1 (row)\n", x)

Out[6]:

original_array
 [[ 0  1  2  3]
 [ 4  5  6  7]
 [ 8  9 10 11]]
shuffle by axis=0 (column)
 [[ 0  5  2  3]
 [ 8  9 10 11]
 [ 4  1  6  7]]
shuffle by axis=1 (row)
 [[ 2  5  3  0]
 [10 11  9  8]
 [ 7  4  1  6]]
sort by axis=0 (column)
 [[ 2  4  1  0]
 [ 7  5  3  6]
 [10 11  9  8]]
sort by axis=1 (row)
 [[ 0  1  2  4]
 [ 3  5  6  7]
 [ 8  9 10 11]]
``

使い所があるかわからないがたまに見かけるaxis=-1などについても見ておく。これはスライシングの時と似ていてマイナスの値が指定されると逆順にaxisを指定できる。2次元arrayの場合だと `axis=-1`と`axis=1`は同じで、`axis=-2`と`axis=0`は同じ。コードIn[7]ではコードIn[6]でaxis=0をaxis=-2に、axis=1をaxis=-1にしたものだが出力結果は同じであることが確認できる。

**In[7]:**
```python
import numpy as np

x = np.arange(12).reshape(3,4)
print("original_array\n", x)

np.random.seed(1)
np.apply_along_axis(np.random.shuffle, axis=-2, arr=x)
print("shuffle by axis=-2 (column)\n", x)
np.apply_along_axis(np.random.shuffle, axis=-1, arr=x)
print("shuffle by axis=-1 (row)\n", x)
x = np.sort(x, axis=-2)
print("sort by axis=-2 (column)\n", x)
x = np.sort(x, axis=-1)
print("sort by axis=-1 (row)\n", x)

Out[7]:

original_array
 [[ 0  1  2  3]
 [ 4  5  6  7]
 [ 8  9 10 11]]
shuffle by axis=0 (column)
 [[ 0  5  2  3]
 [ 8  9 10 11]
 [ 4  1  6  7]]
shuffle by axis=1 (row)
 [[ 2  5  3  0]
 [10 11  9  8]
 [ 7  4  1  6]]
sort by axis=0 (column)
 [[ 2  4  1  0]
 [ 7  5  3  6]
 [10 11  9  8]]
sort by axis=1 (row)
 [[ 0  1  2  4]
 [ 3  5  6  7]
 [ 8  9 10 11]]

締め

Numpyのコードの可読性についてみてきた。多次元のarrayへのスライシングや、listでは大変な実装になることが予想される列方向の処理もaxis=0を用いたone-linerで簡単に行えることを見た。

Discussion