yiskw note

機械学習やプログラミングについて気まぐれで書きます

【Python】list / array / numpy.array を比較してみた


概要

最近、Pythonの標準ライブラリにarrayというデータ型があるのを知りました。 今回は、このarrayの勉強も兼ねて、Pythonlistarraynumpy.arrayの3つを速度、メモリの観点から比較し、それぞれの使用用途について検討してみました。 その結果を備忘録としてこちらに残しておきます。もし間違い等ございましたら、コメントにてご指摘いただけると幸いです。

先に検証の結果を簡潔にまとめると、それぞれの使い道は以下のようになるのかなと考えております。

  • list ... 配列の各要素に高速にアクセスしたい場合。複数の型のデータを保持したい場合。
  • array ... 配列の要素が単一のデータ型で、配列の各要素へアクセスする必要があり、かつメモリ使用量をメモリ使用量を抑えたい場合。
  • numpy.array ... 配列の各要素にアクセスする必要がなく、メモリ使用量を抑えたい場合。配列の計算を行いたい場合。

arrayとは

docs.python.org

Pythonlistnumpy.arrayは馴染みがあるかと思いますが、arrayについてはあまり知られていないかもしれません。
arrayPythonの標準ライブラリで提供されているデータ型で、listのように配列として複数のデータを保持することができます。
listと異なる点は、arrayには、文字、整数、浮動小数点数しか追加することができず、また一つのarrayには単一の型しか含めることができないという点です。

import array


# 要素を整数型に限定した配列を作成
arr = array.array("l", range(10))
print(arr) # array('l', [0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

# リストと同じように要素の追加もできる
arr.append(10)

# 異なる型のデータを追加しようとするとエラーが起きる
arr.append(1.1) # TypeError

公式ドキュメントでは、このarrayは効率の良い配列と述べられていますが、具体的にどういった点で効率が良いのかは述べられていませんでした。 そこで、速度、メモリの二つの観点から、list / array / numpy.arrayを比較してみました。

検証環境

以下の環境で比較を実施しています

  • macOS Big Sur version 11.3.1
  • python 3.9.9
  • numpy == 1.21.1
  • memory-profiler == 0.60.0

速度面での比較

オブジェクトの生成にかかる時間と、各要素へのアクセスにかかる時間を比較しました。

オブジェクトの生成

jupyterを用いて以下の3つの関数の速度を比較しました

import array
import numpy as np

n_elements = 10_000_000


def create_list(n_elements: int) -> list:
    return list(range(n_elements))


def create_array(n_elements: int) -> array.array:
    return array.array("l", range(n_elements))


def create_numpy_array(n_elements: int) -> np.ndarray:
    return np.arange(n_elements)

listオブジェクトの生成速度

%%timeit -n 10
list_ = create_list(n_elements)
# 150 ms ± 1.54 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

arrayオブジェクトの生成速度

%%timeit -n 10
array_ = create_array(n_elements)
# 411 ms ± 760 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

numpy.arrayオブジェクトの生成速度

%%timeit -n 10
numpy_array = create_numpy_array(n_elements)
# 8.94 ms ± 848 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

オブジェクトの生成速度は、numpy.arraylistarrayの順にオブジェクトの生成が速いことがわかりました。
arrayの生成速度は非常に遅く、numpy.arrayと比べて、40倍以上の速度差があるようでした。

各要素へのアクセスの速度

同様にJupyterを用いて要素のアクセス速度の比較を実施しました。

list_ = create_list(n_elements)
array_ = create_array(n_elements)
numpy_array = create_numpy_array(n_elements)

listの各要素へのアクセス速度

%%timeit -n 10
for i in list_:
    pass
# 45.6 ms ± 511 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

arrayの各要素へのアクセス速度

%%timeit -n 10
for i in numpy_array:
    pass
# 115 ms ± 3.58 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

numpy.arrayの各要素へのアクセス速度

%%timeit -n 10
for i in numpy_array:
    pass
# 220 ms ± 3.26 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

listの各要素へのアクセス速度が最も速く、numpy.arrayに比べて5倍近い差があることがわかりました。
listに比べるとarrayの速度も速くはありませんが、numpy.arrayと比べると優位性はありそうです。

使用メモリの比較

続いて使用メモリ量を比較します。 比較にはmemory_profilerを使用しております。
memory_profilerは関数にmemory_profiler.profileデコレータを追加するだけで、関数ごとのメモリの使用量を簡単に出力してくれます。

import argparse
import array

import numpy as np
from memory_profiler import profile


@profile
def create(n_elements: int) -> None:
    list_ = list(range(n_elements))
    array_ = array.array("l", range(n_elements))
    numpy_array = np.arange(n_elements)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("n_elements", type=int)
    args = parser.parse_args()

    create(args.n_elements)


if __name__ == "__main__":
    main()

検証結果

$ python sample.py 10_000_000

Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
     8     54.7 MiB     54.7 MiB           1   @profile
     9                                         def create(n_elements: int) -> None:
    10    441.0 MiB    386.3 MiB           1       list_ = list(range(n_elements))
    11    517.4 MiB     76.4 MiB           1       array_ = array.array("l", range(n_elements))
    12    593.7 MiB     76.3 MiB           1       numpy_array = np.arange(n_elements)

こちらを見るとarraynumpy.arrayはほとんど同じメモリ使用量で、listだけその5倍程度のメモリを使用していることがわかりました。

ただし同じコードを何度か実行して見ると、以下のようにarrayの使用メモリが低くなっている結果が得られることがあります。

$ python sample.py 10_000_000

Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
     8     55.0 MiB     55.0 MiB           1   @profile
     9                                         def create(n_elements: int) -> None:
    10    441.3 MiB    386.3 MiB           1       list_ = list(range(n_elements))
    11    489.9 MiB     48.6 MiB           1       array_ = array.array("l", range(n_elements))
    12    566.2 MiB     76.3 MiB           1       numpy_array = np.arange(n_elements)

こちらについては原因がよくわかっていないのですが、array.itemsizeを使用してarrayの要素のバイト長を確認すると、8バイトで、 要素数が107であることより、オブジェクトのサイズはおおよそ8 * 107バイト ≒ 76.3MBであると考えられるので、おそらくmemory_profile側のバグだと考えられます。
ちなみにlistnumpy.arrayのメモリ使用量に大きくばらつきが出ることはありませんでした。

まとめ

上記の検証結果をまとめたのが以下です。

データ型 生成 要素へのアクセス メモリ使用量
list ×
array ×
numpy.array ×

これらをもとに考えると、それぞれの使い道は以下のようになりそうです。

  • list ... 配列の各要素に高速にアクセスしたい場合。複数の型のデータを保持したい場合。
  • array ... 配列の要素が単一のデータ型で、配列の各要素へアクセスする必要があり、かつメモリ使用量をメモリ使用量を抑えたい場合。
  • numpy.array ... 配列の各要素にアクセスする必要がなく、メモリ使用量を抑えたい場合。配列の計算を行いたい場合。

現状すぐにarrayの具体的な用途が思いつくわけではないですが、使える場面はありそうなので、覚えておいて損はなさそうだと感じました。

参考