📑

[Python]Numpyの数値計算が高速である理由

2024/03/07に公開

はじめに

Numpyを使う理由の1つが数値計算が高速であることなのでそれについて理解する。Numpyを活用するとlistで処理を行う場合と比べて数百倍効率的に計算できることも見る。

参考資料
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 arrayへの処理がpython listへの処理よりも早い理由について見ていく。

Python listのデータ型
手始めにpythonのlistの要素のデータ型について確認する。
Python listは色々な型の値を格納することができ、listを作成した後もインデックスを指定して好きな値を格納することができる。後でみるがこの性質がPythonの処理を遅くする原因となっている。

コードIn[1]の1行目ではlistを色々な型の値を使って定義した。そして2行目ではリスト内包表記の平坦化を用いてデータ型のクラスを持つ1次元のlistを生成した(その生成されたlistも色々なデータ型を持っている)。平坦化は外側のlistから順に内包表記の左からforを繋げる書き方でできる。
In[1]:

python_list = [[True, "2"],[3.0, 4, lambda x: x**2]]
[type(elem) for inner_list in python_list for elem in inner_list]

Out[1]:

[bool, str, float, int, function]

Numpy arrayのデータ型
一方、Numpy arrayは1つの型しか受け入れない。例えば、int64のarrayとして定義されたら以後int64の値しか受け付けない。この性質がデータの格納と操作の効率を上げる。ちなみに、array生成時に可能な場合は型のupcastが自動的に行われ1つの型に定まる。(int64とfloat64が混じっているとfloat64のarrayになる)また、2次元以上のarrayを定義した際にはその構造も保持される(それ以後変形しない限りその次元のarrayとして扱われる)。

In[2]:

import numpy as np
numpy_array = np.array([
    [1,2,3,True, False],
    [1,2,3,4,5.1]
])
print(numpy_array.shape)
print(numpy_array.dtype)
numpy_array

Out[2]:

(2, 5)
float64
array([[1. , 2. , 3. , 1. , 0. ],
       [1. , 2. , 3. , 4. , 5.1]])

実はPython listの処理が遅い
Numpyの処理が高速というよりもPython listの処理が遅いというのが実のところ。前節でPython listは色々な型の値を格納することができることを見たがこれが原因。

Pythonの処理は大まかにみると下の3つの流れで行われる。

  1. 処理対象の変数のデータ型を調べる (例として変数がint64とする)
  2. 変数のデータ型から実行可能な関数を調べる (int64に対して適用できる関数を特定する)
  3. 変数に対して処理を行う (その変数に処理を実行する)

1と2に関しては、配列要素のデータ型が確定していれば必要のない処理でオーバーヘッドになってしまう。(Numpy arrayの場合は生成時に1つの型に定まる)。そしてたいていの場合listに対してはfor loopを用いた処理が行われるが、このloopのサイクルの数だけオーバーヘッドが積み重なっていく。100万行の要素があれば100万回積み重なる。Numpyがこの問題をどのように解決するのだろうか?

Numpy arrayで高速な計算を実現するために

Python listを使うのを止めてNumpy arrayを使うだけで処理が速くなる訳ではないことに注意。Numpy arrayに対してfor loopを行なっていては結局オーバーヘッドは発生してしまう。

Numpy arrayは、オーバーヘッドを減らすためにvectorized operationsという処理方法を提供している。vectorized operationsはuniversal functions(通称ufuncs)を用いた要素への繰り返し操作を効率的に行うための関数群で実現されており、その種類は四則演算、三角関数、指数関数、対数関数など色々揃っている。

今回は100万個の要素を持つPython listとNumpy arrayの全要素に2の冪乗の計算を適用する場合で処理速度を測って比べてみる。コードのIn[3]を見ると、2の冪乗をするためにarrayに対してそのまま ** 2を行っているが、これが直感的で面白い。このようにarrayに対して ** 2をするだけでvectorized operationが行われて全要素に2の冪乗が実行される。このような処理はブロードキャストと呼ばれている。

処理時間の計測にはGoogle colabで%timeitを用いた。Numpy arrayでufuncを用いた冪乗の計算時間を測るとだいたい1ms~3msで計算を終えた。

In[3]:

import numpy as np
np.random.seed(0)
        
million_array = np.random.randint(1, 100, size=1000000)
%timeit million_array ** 2

Out[3]:

2.1 ms ± 1.08 ms per loop (mean ± std. dev. of 7 runs, 100 loops each)

次に、Numpy arrayにfor loopで計算して処理速度を測ってみると結果はだいたい250ms~450ms だったので、vectorized operationsを使う場合と比べて200倍ほど時間がかかったようだ。

In[4]:

import numpy as np
np.random.seed(0)

def double_array(values):
    return [values[i] ** 2 for i in range(len(values))]
        
million_array = np.random.randint(1, 100, size=1000000)
%timeit double_array(million_array)

Out[4]:

355 ms ± 107 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

最後にPython listに対してもfor loopで計算しておく。結果は120ms~200msほどで計算を終えたが、この結果だけ見るとNumpy arrayへのfor loopよりは速く処理を終えているのでNumpy arrayに対してfor loopを行うのはあまり好ましく無いように見える。

In[5]:

import numpy as np
np.random.seed(0)

def double_array(values):
    return [values[i] ** 2 for i in range(len(values))]
        
million_array = list(np.random.randint(1, 100, size=1000000))
%timeit double_array(million_array)

Out[5]:

167 ms ± 42.3 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

締め

Numpyは処理速度を損なわないためにvectorized operationsを備えており、それを活かすためにはufuncsを使うことの重要性をみた。ufuncsは様々な便利な関数が用意されており公式ドキュメントにまとめられている。

Discussion