StatsFragments

Python, R, Rust, 統計, 機械学習とか

NumPy でつくる俺々データ構造

はじめに

Python での数値計算の基盤をなす NumPy 、直感的なスライスやブロードキャスト、関数のベクトル適用など大変便利だ。

import numpy as np
np.__version__
# '1.9.2'

np.array([1, 2, 3])
# array([1, 2, 3])

np.array([1, 2, 3])[:2]
# array([1, 2])

np.array([1, 2, 3]) + 1
# array([2, 3, 4])

が、用途によっては NumPy 標準ではその機能を実現できない場合がある。例えば、

  • 配列とメタデータをひとつのクラスで扱いたい
  • 配列への入力や型を制約/検証したい
  • 自作クラスを NumPy の Universal Functions (ufunc) に対応させたい
  • 新しい型 ( dtype ) を作りたい

こういったとき、NumPy の動作を継承した自作のクラスが作れるとうれしい。方法として、大きく二つがある。

  1. NumPy ndarray を継承する
  2. NumPy Array Interface を実装する

どちらを使うべきかは目的による。サブクラス化しないほうが柔軟な処理ができるが、互換をとるための実装は多くなる。pandas の主要データ構造では 柔軟性を重視して ndarray の継承をやめ、現在は 2 の方法を取っている。

ndarray のサブクラスを作成する方法は以下のドキュメントに記載されている。きちんとやりたい場合はこちらを読むのがよい。

このエントリでは 2. Array Interface を使って最低限の動作をさせる方法を書く。

クラスの定義

サンプルとして、名前をもつ配列クラス NamedArray を考える。__init__での初期化時に、名前をあらわす name と配列の値 values を渡すことにする。

class NamedArray(object):
    
    def __init__(self, name, values):
        self.name = name
        self.values = np.array(values)
        
    def __repr__(self):
        return 'NamedArray: {0}: {1}'.format(self.name, self.values)
    
    def __len__(self):
        return len(self.values)
        
n = NamedArray('x', [1, 2, 3])
n
# NamedArray: x: [1 2 3]

len(n)
# 3

このクラスに ndarray と互換の挙動をさせたい。まず、NamedArray インスタンスから ndarray が直接作成できるとうれしい。が、NamedArraynp.array に渡すと shape のない ndarray (0-dimmentional array) が作成されてしまう。

np.array(n)
# array(NamedArray: x: [1 2 3], dtype=object)

np.array(n).shape
# ()

理由は np.arrayNamedArrayスカラーとして扱うため。期待の動作をさせるためには、np.arrayNamedArray クラスが配列であることを教える必要がある。

具体的には NamedArray に Array Interface ( __array__ ) を定義すればよい。以下のように __array__ メソッドを追加したクラス NPNamedArray を作成すると、期待通り values の値から ndarray が作成されていることがわかる。

class NPNamedArray(NamedArray):
    
    def __array__(self):
        return self.values
    
n = NPNamedArray('x', [1, 2, 3])
np.array(n)
# array([1, 2, 3])

np.array(n).shape
# (3,)

ufunc 処理のオーバーライド

また、Array Interface をもつインスタンスには ufunc が適用できるようになる。引数は ufunc 内で ndarray に変換されるため、返り値は ndarray になる。

np.sin(n)
# array([ 0.84147098,  0.90929743,  0.14112001])

このときの内部の動きをみてみる。ufunc 適用後の処理は __array_wrap__ メソッドによって制御することができる。

__array_wrap__ メソッドには out_arrcontext 2 つの引数を定義する必要がある。これらに何が渡されているのか、print して確認する。

class NPNamedArray(NamedArray):
    
    def __array__(self):
        return self.values
    
    def __array_wrap__(self, out_arr, context=None):
        print('out_arr: ', out_arr)
        print('context: ', context)
        return out_arr
    
n = NPNamedArray('x', [1, 2, 3])
np.sin(n)
# ('out_arr: ', array([ 0.84147098,  0.90929743,  0.14112001]))
# ('context: ', (<ufunc 'sin'>, (NamedArray: x: [1 2 3],), 0))
# array([ 0.84147098,  0.90929743,  0.14112001])

out_arr には ufunc が適用された結果の ndarray が、 context には ufunc を呼び出した際のシグネチャ ( ufunc 自体, ufunc の引数, ufunc のドメイン ) が tuple で渡ってきている。__array_wrap__ の返り値を NPNamedArray に書き換えてやれば、期待する動作ができそうだ。

補足 np.isrealnp.ones_like など、Universal Functions に記載されていても __array_wrap__ を通らないものもある。

補足 ufunc のドメインは NumPy の場合 (おそらく) 全部 0。

class NPNamedArray(NamedArray):
    
    def __array__(self):
        return self.values
    
    def __array_wrap__(self, out_arr, context=None):
        return NPNamedArray(self.name, out_arr)
    
n = NPNamedArray('x', [1, 2, 3])
np.sin(n) 
# NamedArray: x: [ 0.84147098  0.90929743  0.14112001]

ここまでの時点で、NPNamedArray には __add__ メソッドを定義していないため、通常の加算はエラーになる。

n + 1
# TypeError: unsupported operand type(s) for +: 'NPNamedArray' and 'int'

が、ufunc である np.addndarray を含む演算は利用できるようになる。これは先日のエントリに記載した通り ndarray 側に Array Interface をもつクラスとの演算の定義があるため。

np.add(n, 1)
# NamedArray: x: [2 3 4]

n + np.array([5, 5, 5])
# NamedArray: x: [6 7 8]

また、__array_wrap__context 引数の値によって、特定の ufunc の処理をオーバーライドすることができる。例えば np.sin を適用したときに任意の値を返すこともできる。

class NPNamedArray(NamedArray):
    
    def __array__(self):
        return self.values
    
    def __array_wrap__(self, out_arr, context=None):
        if context[0] is np.sin:
            return u'返したい値'
        return NPNamedArray(self.name, out_arr)
    
n = NPNamedArray('x', [1, 2, 3])
np.sin(n)
# u'ひまわり'

とはいえ、上のように個々の関数について条件分岐を作成するのは手間だ。こんなとき、 ufunc 自体のプロパティを参照することで より簡単に書ける場合がある。

例えば、特定の型への処理をサポートする ufunc のみを利用する場合は .types プロパティを参照すればよい。このプロパティは ufunc がどのような引数/返り値の型を取るかを示す Array-protocol Type String を返す。

参考

np.sin.types
# ['e->e', 'f->f', 'd->d', 'g->g', 'F->F', 'D->D', 'G->G', 'O->O']

np.cos.types
# ['e->e', 'f->f', 'd->d', 'g->g', 'F->F', 'D->D', 'G->G', 'O->O']

np.add.types
# ['??->?', 'bb->b', 'BB->B', 'hh->h', 'HH->H', 'ii->i', 'II->I', 'll->l', 'LL->L',
#  'qq->q', 'QQ->Q', 'ee->e', 'ff->f', 'dd->d', 'gg->g', 'FF->F', 'DD->D', 'GG->G',
#  'Mm->M', 'mm->m', 'mM->M', 'OO->O']

まとめ

NumPy 互換の動作を実現する方法のうち、Array Interface を利用した方法を記載した。自分で一から実装するよりは簡単だと思う。

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理