NumPyの配列要素を複製した際のオブジェクトとして、copyとviewがあります。この2つについての理解が深まると、メモリ効率や速度を意識したコードが書けるようになります。

本記事を通して、copyとviewについての理解を深めましょう。

そもそもcopyとviewとは何なのか

copyviewというのはオブジェクトであり、元となる配列との関係性で定まります。両者の特徴をまとめると以下のようになります。

  • copy:元の配列と違うメモリを使用しているが要素が同一の意味を持つもの
  • view:元の配列と同じメモリを参照しているもの

と呼びます。viewは同じメモリを参照しているためviewにあたる配列の要素が変更されると元の配列にも変更が反映されることになります。

In [1]: import numpy as np

In [2]: a = np.array([1,2,3])

In [3]: d = a.view() # viewを作成する

In [4]: d[0] = 100 # viewの値を1つ変更する。

In [5]: d
Out[5]: array([100,   2,   3])

In [6]: a # 元の配列であるaにも変更が反映される。
Out[6]: array([100,   2,   3])

copyだと変更は反映されません。

In [7]: c = a.copy()

In [8]: c[1] = 25

In [9]: c
Out[9]: array([100,  25,   3])

In [10]: a
Out[10]: array([100,   2,   3])

メモリ効率という観点では大きな配列になればなるほど、viewをなるべく使った方が良いことになります。しかし、元の配列のデータを変えてしまう可能性が非常に高く、元のデータを保持しておきたいという場合にはcopyを使いましょう。

操作による違い

NumPyの操作ではcopyviewが使い分けられています。内部操作がcopyとなるのかviewを生成するのかを知っていることで、さらに効率を意識したコードを書くことができるのでまとめます。

代入

まずは変数に配列そのものを格納する場合についてです。

=を使って代入していくわけですが、Pythonにおける変数への代入というのはオブジェクトへの参照を格納するということになります。つまり、代入された変数がオブジェクトになるのではなく、変数はあくまでオブジェクトがある場所を示すものとなっています。

In [11]: a = np.array([1, 2, 3])

In [12]: b = a # bにaを代入

In [13]: id(a) == id(b) # 参照元が同じかどうかを確かめる
Out[13]: True

id()はオブジェクトの’識別値’を返す関数です。

ここで、変数へ代入するときにaa[:]と表記を変えてみます。PythonのlistだとcopyになりますがNumPyのndarrayではviewとなります。

In [20]: a = np.array([1,2,3])

In [21]: c = a[:] # 表記を変えてみる

In [22]: id(a) == id(c) # 違うオブジェクトを参照しているのがわかる。
Out[22]: False

In [23]: c[1] = 22 # cの一部を変更

In [24]: a # 変更がaにも反映されている。
Out[24]: array([ 1, 22,  3])

このように、スライス表記ですと元の配列と異なるオブジェクトが生成されますがそのオブジェクトの要素は元の配列のviewとなります。もちろん、スライス表記なので一部の配列を抜き出すようにしてもviewが生成されます。

In [25]: d = a[:1]

In [26]: id(d) == id(a)
Out[26]: False

In [27]: d[0] = 11

In [28]: a
Out[28]: array([11,  2,  3])

In [29]: d
Out[29]: array([11])

代入でコピーを作成するには元の配列のコピーを代入します。

In [36]: e = a.copy() # aのコピーを代入

In [37]: e.base is a
Out[37]: False

In [38]: e[2] = 234

In [39]: e
Out[39]: array([  1,  22, 234])

In [40]: a
Out[40]: array([ 1, 22,  3])

ndarray.baseはベースとなるオブジェクトが存在した場合そのベースとなるオブジェクトを表示するものです。オブジェクトがビューでない限りこれの値はNoneとなります。

In [41]: print(a.base)
None

コピーを作成したい場合は、numpy.ndarray.copy()を使いましょう。

In [42]: f = a.copy()

In [43]: f.base is a
Out[43]: False

演算

演算結果について、表記の仕方でコピーが作られる場合と作られない場合が存在します。

例えば、足し算をする場合にはcopyが生成されます。a = a + 1とするとaのcopyが生成され、各要素に1を加算した新たな配列がaに代入されます。

In [45]: a = np.array([1,2,3])

In [46]: c = a # viewとなるcをつくる。

In [47]: a = a + 1 # 1を加算する

In [48]: c # cに変更が反映されない。
Out[48]: array([1, 2, 3])

In [49]: a
Out[49]: array([2, 3, 4])

aのviewとなるcに変更が反映されていません。aの参照しているオブジェクトが新しく生成されたため、acのviewの参照先が異なることを意味します。

a += 1の形にするとどうなるでしょうか。この場合はaのコピーが作られることなく、配列の値が1ずつ加算される形で更新されます。

In [50]: a = np.array([1,2,3])

In [51]: c = a

In [52]: a += 1

In [53]: c
Out[53]: array([2, 3, 4])

In [54]: a
Out[54]: array([2, 3, 4])

これは四則演算子である+,-,*,/や累乗を計算する**においても同様のことが言えます。

コピーを作らずに演算を行う方法として演算を行う関数を使うこともできます。加算ならnp.add()、減算ならnp.subtract()が使えます。

数学的関数については以下の記事で色々紹介しているので参考にしてみてください。

NumPyの数学関数・定数まとめ - DeepAge /features/numpy-math.html

これらの演算関数の引数にはoutというものがあり、ここに元となる配列を指定すると配列の値を上書きすることができ、メモリ効率をあげることができます。コピーを作らないだけで計算パフォーマンスも向上します。

In [66]: a = np.array([1,2,3])

In [67]: c = a

In [68]: np.add(a,1, out=a)
Out[68]: array([2, 3, 4])

In [69]: a
Out[69]: array([2, 3, 4])

In [70]: c
Out[70]: array([2, 3, 4])

では、計算速度を比較してみましょう。2つの配列XYを作り以下のように表されるAの値を計算するとします。

A = X*4 + Y*3

様々なやり方で計算してみましょう。以下のような関数test()を使って計測します。

def test():
    import time
    X = np.ones(100000000, dtype='int8')
    Y = np.ones(100000000, dtype='int8')
    a = time.time()
    for _ in range(100):
        X = X*4 + Y*3
        X = np.ones(100000000, dtype='int8')
    b = time.time()
    print('X = X*4 + Y*3: {} sec'.format((b-a)/100))
    a = time.time()
    for _ in range(100):
        X *= 4
        X += Y*3
        X = np.ones(100000000, dtype='int8')
    b = time.time()
    print('X *= 4; X += Y*3: {} sec'.format((b-a)/100))
    a = time.time()
    for _ in range(100):
        np.multiply(X, 4, out=X)
        np.multiply(Y, 3, out=Y)
        np.add(X, Y, out=X)
        X = np.ones(100000000, dtype='int8')
    b = time.time()
    print("using functions: {} sec".format((b-a)/100))

これを実行します。

In [111]: test()
X = X*4 + Y*3: 0.15649971961975098 sec
X *= 4; X += Y*3: 0.11750322103500366 sec
using functions: 0.08618597984313965 sec

関数を使って計算した方が早く計算ができることがわかりますね。

配列の一次元化

配列を一次元化する関数としてflatten関数とravel関数が存在します。flatten関数では配列の一次元化したcopyを作成してそれへの参照が返されます。一方、ravel関数では読み込むメモリーの場所は元の配列と同じビュー(view)を返します。

ravel関数はコピーを作成しないため、コピーを作成するflatten関数より処理速度が速い特徴があります。詳しい解説は以下の記事で行なっているので使い方などが気になる方は参照してみてください。

配列を1次元に変換するNumPyのflatten関数の使い方 - DeepAge /features/numpy-flatten.html

flattenよりも高速に配列を一次元化するnumpy.ravel関数の使い方 - DeepAge /features/numpy-ravel.html

In [1]: import numpy as np

In [2]: a = np.random.randn(2,3,9)

In [3]: b = a.ravel()

In [4]: c = a.flatten()

In [5]: a
Out[5]:
array([[[-0.08174386,  0.26572468,  0.567573  , -1.38605284,  0.38670771,
          1.18548385,  0.60952909, -0.73427919,  0.1827606 ],
        [ 1.56150254,  0.78448663,  0.79470278, -1.68160908,  1.65006098,
          0.30753166,  0.26845956, -0.61851187,  0.43305801],
        [ 0.46361119, -1.8501969 ,  0.67521371,  1.5689528 ,  0.76623727,
          0.61117491,  2.10937054, -0.11104958,  0.0083282 ]],

       [[ 0.20375639,  1.95139355, -0.68546597,  1.08584989,  1.69015733,
         -0.48769387,  2.2704154 , -0.70044442, -0.3769095 ],
        [ 0.12435112, -0.58086285,  1.35984034,  0.38650113, -0.86224261,
          0.21651296,  1.46182258, -1.05273576, -0.65269428],
        [-0.82453928, -0.47707194,  1.57473802, -0.40136323,  2.25313411,
          0.31413172, -0.37410255, -0.38898   , -0.99284404]]])

In [6]: a[0,0,0] = 129

In [7]: a[0,0,0], b[0], c[0]
Out[7]: (129.0, 129.0, -0.081743863020672075)

fancy indexing

特殊なインデックス指定を用いると基本的にコピーが生成されます。

In [1]: import numpy as np

In [3]: a = np.random.randint(10,size=100)

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

In [5]: n = a%3==0 # 3の倍数であるもののところがTrueになる。

In [6]: n
Out[6]:
array([False, False, False, False, False,  True, False,  True, False,
       False, False, False, False, False, False, False,  True, False,
       False, False, False, False,  True, False, False, False, False,
       False, False,  True,  True,  True,  True, False,  True, False,
        True, False,  True,  True,  True,  True, False,  True, False,
        True, False, False, False, False, False,  True, False,  True,
       False,  True, False,  True,  True, False, False,  True,  True,
       False, False, False,  True, False,  True,  True,  True, False,
       False,  True,  True,  True, False, False, False,  True,  True,
       False, False, False, False, False,  True, False, False, False,
       False, False,  True,  True, False, False, False, False,  True, False], dtype=bool)

In [7]: a[n] # 3の倍数の要素を抽出
Out[7]:
array([3, 6, 9, 9, 0, 9, 6, 9, 9, 6, 9, 6, 0, 9, 6, 9, 0, 0, 6, 9, 9, 0, 3,
       6, 6, 3, 0, 6, 0, 0, 3, 3, 9, 3, 0, 3])

In [8]: k = a[n]

In [9]: np.may_share_memory(a,k)
Out[9]: False

In [10]: f = a[np.arange(0,10,2)]

In [11]: np.may_share_memory(a,f)
Out[11]: False

copyかviewか確かめる方法

値を毎回代入してcopyかどうかを確かめるのは大変です。そうしなくても確かめる方法がいくつかあります。

may_share_memory関数を使う方法

一番簡単な方法として、NumPyにあるmay_share_memory関数で確かめる方法があります。

この関数は、引数として指定された2つの配列が同じメモリーを参照しているかどうかを確かめるものです。この判定は実は厳密なものではなく、Trueが返ってきても必ずしもメモリを共有しているとは限りませんが基本的に信用して大丈夫です。

Falseが出た場合、その結果は信頼できます(間違ったTrue判定を出すときはあるが間違ったFalse判定を出すことがない)。このようになっているのは恐らく、本来ビューのものをコピーと間違って扱う方が致命的なミスにつながりかねないが、逆にビューだと間違って扱う方が元のデータ構造を破壊するなどのミスをすることはないからだと思われます。

より厳密に知りたいかたはshare_memory関数が実装されているのでそちらを使うことをオススメします。デメリットとしてmay_share_memory関数より処理時間がかかるというものがありますが。

In [1]: import numpy as np

In [2]: a = np.array([1,2,3])

In [4]: b = a

In [5]: c = b

In [6]: d = a.copy()

In [8]: np.may_share_memory(a,b) # Trueならbはaのビューだということになる。
Out[8]: True

In [9]: np.may_share_memory(a,c) # こちらもTrue
Out[9]: True

In [10]: np.may_share_memory(a, d) # dはaのコピーなのでFalse
Out[10]: False


In [12]: np.shares_memory(a,b) # より厳密な測定にはshare_memory関数を使う
Out[12]: True

In [13]: np.shares_memory(a,d)
Out[13]: False

numpy.ndarray.baseを使う方法

他にも、その配列がviewであるなら参照元の配列を返すnumpy.ndarray.baseというものがあります。これは1つ上の配列を返すだけなのでviewのviewのbaseはオリジナルの配列と一致せず、1つ前のビューを返すことになってしまいます。

そのため、若干使い勝手が悪くなります。

copyかviewかを確かめるためだけであったらbaseを使って確かめる方法はあまりオススメできません。

参考