NumPyで使われる多次元配列のデータ構造「ndarray」とは?|翔泳社の本

NumPyで使われる多次元配列のデータ構造「ndarray」とは?

2018/11/26 07:00

 Pythonで数値計算を行なうためのライブラリであるNumPyでは、多次元配列を基本的なデータ構造として操作します。この独自のデータ構造を「ndarray」といい、知っておくことでデータ処理の際に高速化や省メモリ化したコードを書けるようになります。今回は『現場で使える!NumPyデータ処理入門』(翔泳社)からndarrayの基礎を紹介します。

本記事は『現場で使える!NumPyデータ処理入門 機械学習・データサイエンスで役立つ高速処理手法』の一部を抜粋し、掲載にあたって編集したものです。

NumPyとは

 NumPyは、Numerical Pythonの略称で、Pythonの数値計算のためのライブラリです。高速に数値計算ができることが特徴です。

 NumPyで使われる主なクラスはnp.ndarrayと呼ばれる多次元を扱う配列です。NumPy配列は、公式ドキュメントでは単に配列と称されることが多いです。

 Pythonは動的型付き言語(データ型の扱いが容易)で、柔軟に素早く書くことができる言語として知られていますが、その一方で「処理速度がJavaやCに比べて一般に遅い」というデメリットがあります。そこで、C言語やFORTRANで書かれた型を固定して演算を行うことができるコードをPythonから呼び出せるようにしたライブラリがNumPyです。NumPyを導入することでPython上においても高速な数値演算ができます。

 このNumPyの特殊なクラスnp.ndarrayは、最初は慣れない概念かと思います。「Pythonのリストで扱わせてほしい」という初学者の意見も理解できますが、最初のうちはndarrayクラスは型や整列のされ方が統一されている数値の容れ物であるという程度の理解で問題ありません。

 ndarrayクラスについては本書の第2章で詳しく解説します。

 このライブラリがあることで、コンピュータサイエンスの分野でPythonが頻繁に使われるようになっていると言っても過言ではありません。

1.2 多次元データ構造ndarrayの基礎

 NumPyは、多次元配列を基本的なデータ構造として操作するライブラリです。そのため、NumPyではPythonのリストではなく、効率性などからndarrayという独自のデータ構造を演算に使います。

 NumPyを勉強するにあたって、ndarrayについて知っておくことでコードの高速化や省メモリを意識したコードを書くことができるようになります。一般に、NumPyは科学技術計算のために作られたライブラリということもあって、大量のデータや高速に演算したい時に使いたいことは多いはずです。本節では、基礎の基礎となる部分である、ndarrayについて一から解説したいと思います。他の書籍やWebページでは、ndarrayのことを配列と日本語で表記することが多いです。

1.2.1 ndarray

 まずは公式サイト、The N-dimensional arrayに載っているndarrayの定義から見ていきましょう。

 上記の公式サイトには以下のように書いてあります。

An ndarray is a (usually fixed-size) multidimensional container of items of the same type and size.

 これを日本語にしてみると、次のようになります。

ndarrayは、同じ型やサイズで構成された複数要素の、たいてい一定の大きさを持つ多次元の容れ物である。

 ざっくり言うと、同じ属性や大きさを持った要素を持つ多次元配列を扱うためのクラスの1つということです。そもそもndarrayとはN-dimensional arrayの略となっており、N 次元配列を略したものです。

 この同じ属性や大きさを持った要素の部分は意外と重要です。これはndarrayインスタンスの中に格納される要素はすべて同じ種類のデータ型やサイズでないといけないことを意味します。

 Pythonのリストのように要素ごとにデータ型を柔軟に変えることはできず、サイズも可変ではありません。

 このクラスndarrayの特徴は以下の通りです。

  • 同じ型を持つ要素しか格納することができない
  • 各次元ごとの(2次元なら列ごとや行ごと)の要素数は必ず一定
  • C言語を元に、最適化された行列演算を行うため効率的な処理をすることができる

 ndarrayにすることで、多次元データを扱うための便利な属性を使用することができるようになります。

1.2.2 属性(attributes)

 ここではndarrayが持つ属性を紹介していきます。(インスタンス変数名).(属性)とすればインスタンスとなっているndarrayの情報を取得することができます(表1.1)。

表1.1 属性
属性 説明
T いわゆる転置を返す。ndim<2の時は元の配列が返される
data 配列のデータがどこから始まっているのかを示すPythonのバッファオブジェクト
dtype ndarrayに含まれる要素が持つデータ型
flags メモリ上におけるndarrayのデータの格納の仕方(Memory Layout)についての情報
flat ndarrayを1次元配列に変換するイテレータ
imag ndarrayにおける虚数部分(imaginary part)
real ndarrayにおける実数部分(real part)
size ndarrayに含まれる要素の数
itemsize バイト単位での1つ1つの要素のメモリ消費量
nbytes ndarrayの要素によって占められるバイト単位におけるメモリ消費量
ndim ndarrayに含まれる次元の数
shape ndarrayの形状(shape)をタプルで表したもの
strides 各次元方向に1つ隣の要素に移動するために必要なバイト数をタプルで表示し たもの
ctypes ctypesモジュールで扱うためのイテレータ
base ndarrayのベースとなるオブジェクト(どのメモリを参照しているのか)

 情報にアクセスしても配列(ndarray)の中身や情報はいっさい変わることはありません。例えば、.Tで転置させたものを表示させたとしても元のデータが変更になることはありません。

 属性がどのように表示されるのか、実際のコードで見ていきましょう。

 まずはTやdata、dtypeについてです。dataはPythonのバッファオブジェクトで、どこから配列のデータが始まっているのかを示しています。dtypeはndarrayのデータ型になります。

 dtypeについては本書の1.7節「要素のデータ型(dtype)の種類と指定方法」で紹介します。

In [1]: import numpy as np # NumPyモジュールをインポートする

In [2]: a = np.array([1, 2, 3]) # ndarrayインスタンスを生成する

In [3]: type(a) # クラスを確認する
Out[3]: numpy.ndarray

In [4]: b = np.array([[1, 2, 3], [4, 5, 6]]) # これで2-dimensional array(2次元配列)

In [5]: a
Out[5]: array([1, 2, 3])

In [6]: b # 各々表示させると次のようになる
Out[6]:
array([[1, 2, 3],
       [4, 5, 6]])

In [7]: b.T # 転置させる
Out[7]:
array([[1, 4],
       [2, 5],
       [3, 6]])

In [8]: a.T # a.ndim < 2なので変化はなし
Out[8]: array([1, 2, 3])

In [9]: a.data # メモリの位置を表示する
Out[9]: <memory at 0x106f54888 >

In [10]: a.dtype # データ型を表示する
Out[10]: dtype('int64')

 次は、メモリレイアウトについての情報を表示させる.flagsと、1次元配列へと変換するイテレータとなる.flatです。.flat[n]で、ndarrayを1次元に変換した際にn番目にくる要素を表示します。

In [11]: a.flags
Out[11]:
  C_CONTIGUOUS : True
  F_CONTIGUOUS : True
  OWNDATA : True
  WRITEABLE : True
  ALIGNED : True
  UPDATEIFCOPY : False

In [12]: b.flags # いろいろな情報が表示される
Out[12]:
  C_CONTIGUOUS : True
  F_CONTIGUOUS : False
  OWNDATA : True
  WRITEABLE : True
  ALIGNED : True
  UPDATEIFCOPY : False

In [13]: a.flat[1] # aを1次元配列にした時の1番目の要素を表示する
Out[13]: 2

In [14]: b.flat[4] # bを1次元配列にした時の4番目の要素を表示する
Out[14]: 5

 次は複素数(complex)の要素に対して.realと.imagを使って実部と虚部を表示させます。

In [15]: c = np.array([1.-2.6j,2.1+3.j, 4.-3.2j]) # 複素数を要素とするndarrayインスタンスを生成する

In [16]: c.real # 実部を表示する
Out[16]: array([ 1. , 2.1, 4. ])

In [17]: c.imag # 虚部を表示する
Out[17]: array([-2.6, 3. , -3.2])

 次は要素の数を表示する.sizeと、バイトオーダーでの要素1つ1つがメモリで占める容量を表示する.itemsize、これらの積となっており配列の要素すべての容量を表示する.nbytesです。

In [18]: a.size # 要素の数
Out[18]: 3

In [19]: b.size
Out[19]: 6

In [20]]: a.itemsize # バイトオーダーでの要素1つ1つの長さ、環境によっては4になる
Out[20]: 8

In [21]: b.itemsize # 環境によっては4になる
Out[21]: 8

In [22]: c.size, c.itemsize
Out[22]: (3, 16)

In [23]: a.nbytes # バイトオーダーでの配列の長さ。環境によっては12になる
Out[23]: 24

In [24]: b.nbytes # 環境によっては24になる
Out[24]: 48

In [25]: c.nbytes
Out[25]: 48

In [26]: a.size * a.itemsize == a.nbytes # この等式が成り立つ
Out[26]: True

 次は次元数や形状(shape)について表示させる.ndimと.shapeについてです。次元については本書の1.5節「軸(axis)と次元数(ndim)について」で紹介し、shapeについては1.6節「ndarrayの属性(attribute)shape」で紹介します。

In [27]: a.ndim # 次元数を表示する
Out[27]: 1

In [28]: b.ndim
Out[28]: 2

In [29]: a.shape # 形状を表示する
Out[29]: (3,)

In [30]: b.shape # 形状を表示する
Out[30]: (2, 3)

 次は.stridesです。このプロパティは各次元方向に1つ要素を移動するためにはメモリ上で何バイト動かす必要があるのかを示したものです。詳しくは1.2.3項「Memory Layout」の部分で解説します。

In [31]: d = np.array([[[2,3,2],[2,2,2]],[[4,3,2],[5,7,1]]]) # 3次元配列を生成する

In [32]: d.shape, d.ndim # 形状と次元数を表示する
Out[32]: ((2, 2, 3), 3)

In [33]: a.strides # 各次元方向(axis=ndim,axis=ndim-1,…,axis=1,axis=0)における1つの要素に移動するためのバイトオーダー。環境によっては(4,)となる
Out[33]: (8,)

In [34]: b.strides # .ndim = 2 環境によっては(12, 4)となる
Out[34]: (24, 8)

In [35]: c.strides # .ndim = 3
Out[35]: (16,)

In [36]: d.strides # .ndim = 3 環境によっては(24, 12, 4)となる
Out[36]: (48, 24, 8)

 次は.ctypesと.baseです。.ctypesはctypesモジュールを使った操作を行うためのイテレータとなります。.baseはこの配列がviewであるならviewをしている元の配列を示します。

 なお、copyとviewについては、本書の1.8節「 コピー(copy)とビュー(view)の違い」で詳しく解説します。

In [37]: a.ctypes.data # ctypesモジュールを使った操作
Out[37]: 140421253863024

In [38]: a.base # aのベースとなる配列はどこか
In [39]: e = a[:2]

In [40]: e.base
Out[40]: array([1, 2, 3])

In [41]: e.base is a
Out[41]: True

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

1.2.3 Memory Layout

 NumPyにおける行列計算のパフォーマンスをより向上させるためにはメモリ上でndarrayの要素がどのように格納されているかを知る必要があります。内部メモリでどのように配列が保存されているかを意識しておくだけで、随分と理解が進みます。

 ndarrayクラスで生成されたインスタンスはメモリ上では1次元で保存されます。ここに登録される情報で、データ型や形状(shape)といった1次元で並べられた要素のデータの読み取り方を指定した部分をメタデータと呼びます。

 このメタデータに続き、要素をデータ化したものが並んでいきます。データの格納の仕方は大きく分けて2つ存在します。

 1つはローメジャー(row-major)オーダーで、もう1つはカラムメジャー(column-major)オーダーです。前者はC言語で使われている並べ方で、後者はFORTRANやMATLABで使われている並べ方です。

 先ほど紹介した属性で.flagsというものがありましたが、その中に次のような部分がありました。

C_CONTIGUOUS : True
F_CONTIGUOUS : False

 このC_CONTIGUOUSというのがローメジャーで読み込むことが可能かどうかといったもので、F_CONTIGUOUSがカラムメジャーで読み込むことが可能かどうかを示しています。NumPyの引数でorderというものがありますが、基本的には'C'とするとローメジャーに、'F'とするとカラムメジャーでデータが格納されていきます。

 この2つの違いはどの次元方向からデータを格納していくかにあります。ローメジャーでは低い次元から(axisの番号が小さい順)格納していき、カラムメジャーでは高い次元(axisの番号が大きい順)から格納していきます。

 2次元配列の例で詳しく解説します。例えば、次のような2×3の2次元配列があったとします。

2×3の2次元配列

 2次元でいくとローメジャー(order='C')では列方向から順に要素が格納されていきます(図1.1)。

図1.1 ローメジャー(order='C')
図1.1 ローメジャー(order='C')

 一方でカラムメジャー(order='F')では行方向から順に要素が格納されていきます(図1.2)。

図1.2 カラムメジャー(order='F')
図1.2 カラムメジャー(order='F')

 列方向は2次元においてはaxis=1、行方向はaxis=0となり、軸の番号は変わりはするものの同じ大小関係が成り立ちます。このように配列のデータはメモリ上に格納されています。

1.2.4 ストライド

 列方向に処理を展開していきたい場合、ローメジャーのほうが1つ1つの要素のメモリ上における距離が小さくてすみます。一方、カラムメジャーで同じ処理を行うと行方向に収納されているので列ごとに移動するためのバイト数が大きくなり、効率的に計算を行うことができません。

 この1つ1つの要素にアクセスするためにメモリ上での移動距離をバイト数で表したものをストライド(strides)と呼びます。

 これはndarrayの属性(attributes)の1つとなっており、この情報を見ることで要素の距離をつかむことができます。カラムメジャーとローメジャーでそれぞれ同じ配列を格納してみます。

In [43]: a = np.random.randn(100,100)

In [44]: b = np.array(a, order='C') # row-major

In [45]: c = np.array(a, order='F') # column-major

In [46]: b.strides, c.strides # ストライドを見ると、距離が逆転している
Out[46]: ((800, 8), (8, 800))

In [47]: np.allclose(b, c) # 配列の要素が全部一致しているかを確認する
Out[47]: True

 次に、スライス表記を使って要素を100個飛ばしに読み込むように指定して、計算速度の違いを確かめてみます。このようにすると、メモリ上の値を読み込む時、隣の要素を読み込むためにジャンプする必要のあるバイト数が増えるため計算速度が低下してしまいます。

In [48]: x = np.ones((100000,)) # すべての要素を1で初期化する

In [49]: y = np.ones((100000*100,))[::100] # 100個飛ばしに読み込む

In [50]: x.strides # 1つ隣の要素にたどり着くために8バイト分ジャンプするだけで良い
Out[50]: (8,)

In [51]: y.strides # 1つ隣の要素にたどり着くために800バイト分ジャンプする必要がある
Out[51]: (800,)

In [52]: x.shape, y.shape
Out[52]: ((100000,), (100000,))

In [53]: %timeit x.sum() # こちらのほうが断然早い
51.9 μs ± 2.28 μs per loop (mean ± std. dev. of 7 runs, 10000 loops each)

In [54]: %timeit y.sum()
1.24 ms ± 201 μs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

 のちに何度も計算する必要がある場合、速度の低下を防ぐためにはviewで計算するのではなく、copyを作成して、計算したほうが速くなる時があります。

In [55]: y_copy = np.copy(np.ones((100000*100,))[::100])

In [56]: y_copy.strides
Out[56]: (8,)

In [57]: %timeit y_copy.sum()
60.9 μs ± 16 μs per loop (mean ± std. dev. of 7 runs, 10000 loops each)

1.2.5 ブロードキャスト

 ndarrayの特徴として、ブロードキャストを使用できる点があります。ブロードキャストを使いこなすことで、コード量を格段に減らすことができるようになります。詳細な解説は本書の1.3節で行っています。

 ブロードキャストは計算処理を行う際に、適宜配列を拡張してくれる便利な機能です。例えば、配列aの全要素に対して1を加算したい時、

a += 1

とするだけですべての要素に対して1が加算されます。この時、ブロードキャストが適用されています(aの次元はいくつでもかまいません)。2次元配列に対して1次元配列を足し合わせることも可能です。

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

In [59]: b = np.array([[1, 1, 1],[2, 4, 1]]) # 2次元配列

In [60]: b + a # ブロードキャストの適用
Out[60]:
array([[2, 3, 4],
       [3, 6, 4]])

参考資料

続きは本書で

 このほか本書ではNumPyのインストール手順を始め、NumPy配列を操作する関数、数学関数の使い方、そして機械学習を実装する手法について解説していきます。データ処理や機械学習に携わる方はぜひ参考にしてみてください。

現場で使える!NumPyデータ処理入門

Amazon SEshop その他


現場で使える!NumPyデータ処理入門
機械学習・データサイエンスで使える高速処理手法

著者:吉田拓真、尾原颯
発売日:2018年11月19日(月)
価格:4,104円(税込)

本書について

本書では機械学習におけるデータ処理に役立つNumPyの基本関数の説明から始まり、現場で利用することの多い実践的なデータ処理手法について解説します。