Contents

Pythonのデータクラスのようにxarrayのデータ構造を定義する

TL;DR 🌻

xarrayは多次元配列にメタデータ(軸のラベルなど)がくっついたデータを扱うためのツールとして、NumPypandasと同様にデータ解析で使われるPythonパッケージですが、様々なデータをxarray(のDataArray)で扱っていく中で以下のように感じることが増えてきました。

  • 多次元配列の軸(dimensions)や型(dtype)を指定した配列生成関数がほしい
  • 同様にメタデータ(coordinates)にも軸・型・デフォルトの値を指定したい
  • 上2つを満たしたNumPyones()のような特別な配列生成をしたい
  • データ独自の処理(メソッド)を定義したい

これらをかなえる方法は色々考えられますが、Python 3.7から標準ライブラリに登場したデータクラス(dataclasses)が同じような悩み?をシンプルな書き方で解決していることに気づきました。そこで、データクラスと同様の書き方でユーザ定義のDataArrayクラスを作成するためのパッケージ「xarray-custom」を公開しましたのでご紹介します(pipでインストールできます)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from xarray_custom import ctype, dataarrayclass

@dataarrayclass(accessor='img')
class Image:
    """DataArray class to represent images."""

    dims = 'x', 'y'
    dtype = float
    x: ctype('x', int) = 0
    y: ctype('y', int) = 0

    def normalize(self):
        return self / self.max()

以下ではこのコードの仕組みを、PythonのデータクラスやDataAraryに触れながら解説していきます。

Python’s dataclass

まず、Pythonのデータクラスとはユーザ定義のデータ構造を簡単に作成するための機能(クラスデコレータ)です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from dataclasses import dataclass

@dataclass
class Coordinates:
    x: float
    y: float

    @classmethod
    def origin(cls):
        return cls(0.0, 0.0)

    def norm(self):
        return (self.x ** 2 + self.y **2) ** 0.5

このようにクラスを定義すると、

1
coord = Coordinates(x=3, y=4)

のようにデータを格納するクラスを作成してくれます(本来は__init__()など諸々の特殊メソッドの実装が必要)。このようにデータクラスを定義することの利点としては以下が考えられるかと思います。

  • 格納する値に名前・型(型ヒントのみ)・デフォルト値を持たせることができる
  • 特別なデータ生成(上の例ではorigin())をクラスメソッドで実現できる
  • データ独自の処理(上の例ではnorm())をインスタンスメソッドで実現できる

xarray’s DataArray

次に、xarray(DataArray)のデータ構造を見ていきます。DataArrayはNumPyの多次元配列(data)、軸(dimensions; メタデータの一種)、メタデータ(coordinates)からなるデータ構造を取ります。以下の例は、xyの2軸からなる単色画像をデータをDataArrayで表現しているつもりです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from xarray import DataArray

image = DataArray(data=[[0, 1], [2, 3]], dims=('x', 'y'),
                  coords={'x': [0, 1], 'y': [0, 1]})
print(image)

# <xarray.DataArray (x: 2, y: 2)>
# array([[0., 1.],
#        [2., 3.]])
# Coordinates:
#   * x        (x) int64 0 1
#   * y        (y) int64 0 1

DataArrayはクラスなので、ユーザ定義のDataArrayを定義するための一般的な方法はDataArrayをサブクラスとした新しいクラスを作成することです。しかし、__init__()などに定義をハードコードしてしまうと使い回しが効かないという問題があります。また、xarraypandasではそもそもサブクラス化を積極的に推奨しておらず、アクセサ(accessor)という特別なオブジェクトを介してユーザ定義の処理などを実装するのが良いとしています。

One standard solution to this problem is to subclass Dataset and/or DataArray to add domain specific functionality. However, inheritance is not very robust. It’s easy to inadvertently use internal APIs when subclassing, which means that your code may break when xarray upgrades. Furthermore, many builtin methods will only return native xarray objects. (Extending xarray - xarray 0.15.0 documentation)

xarray’s dataarrayclass

ようやく本題です。冒頭に書いたような要望を実現するには、xarray(DataArray)の事情も考慮すると、

  • (メタ)データの定義をハードコードせずに配列生成する方法を提供する
  • アクセサを介したユーザ定義の処理を提供する
  • これらをシンプルな書き方でできるようにする

ことが必要だということが分かりました。そこで、xarray-customではPythonのデータクラスに慣い、ユーザ定義のDataArrayクラスをクラスデコレータ(dataarrayclass)で動的に改変することで実現することにしました。xarray-customを使うと上の単色画像の例は以下のように定義できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from xarray_custom import ctype, dataarrayclass

@dataarrayclass(accessor='img')
class Image:
    """DataArray class to represent images."""

    dims = 'x', 'y'
    dtype = float
    x: ctype('x', int) = 0
    y: ctype('y', int) = 0

    def normalize(self):
        return self / self.max()

データクラスを知っていることで、このコードで何をしているのかは説明なしでも何となく理解していただけたのではないでしょうか。ここでのポイントは以下の3点です。

  • クラス変数でデータの軸('x', 'y')・型(float)を指定する
  • 特別な型付きのクラス変数で軸を含むメタデータの名前・軸・型・デフォルト値を指定する
  • ユーザ定義の処理(メソッド)はアクセサ(img)配下に自動的に移動させる

ここで、ctypeはメタデータを定義する特別な型(クラス)を生成するための関数です。それでは実際にユーザ定義の配列を生成してみましょう。

1
2
3
4
5
6
7
8
9
image = Image([[0, 1], [2, 3]], x=[0, 1], y=[0, 1])
print(image)

# <xarray.DataArray (x: 2, y: 2)>
# array([[0., 1.],
#        [2., 3.]])
# Coordinates:
#   * x        (x) int64 0 1
#   * y        (y) int64 0 1

軸とメタデータが予め定義されているので、上のDataArrayの例と比べてとても簡潔に書けることが分かります。データクラスと異なるのは、型の情報が配列の型を決めるのに実際に使われるという点です。上の例では、integerのリストがDataArray内ではfloatに型変換されています。(暗黙の)型変換ができない場合はValueErrorが送出されます。また、型を指定しないこともできます(その場合、任意の型のオブジェクトを受け付けます)。

Instance methods via an accessor

xarrayの方針に従い、クラスに定義したインスタンスメソッド(上の例ではnormalize())はアクセサを介して実行することができます。アクセサなしで実行するとAttributeErrorが送出されます。

1
2
3
4
5
6
7
8
9
normalized = image.img.normalize()
print(normalized)

# <xarray.DataArray (x: 2, y: 2)>
# array([[0.        , 0.33333333],
#        [0.66666667, 1.        ]])
# Coordinates:
#   * x        (x) int64 0 1
#   * y        (y) int64 0 1

Special functions as class methods

特別な配列生成として、NumPyに慣いzeros()ones()empty()full()がクラスメソッドとして自動的に追加されています。zeros_like()などはxarrayのトップレベル関数に定義されていますのでそちらを使いましょう。

1
2
3
4
5
6
7
8
9
uniform = Image.ones((2, 3))
print(uniform)

# <xarray.DataArray (x: 2, y: 3)>
# array([[1., 1., 1.],
#        [1., 1., 1.]])
# Coordinates:
#   * x        (x) int64 0 0
#   * y        (y) int64 0 0 0

Misc

ここまでの話で、dataarrayclassでもDataArrayのサブクラス化を結局行っているのでは?と思った方もいらっしゃるかもしれません。が、実際は生成されるDataArrayは本物のDataArrayインスタンスです。逆に言うと、上の例ではImageインスタンスではありません。

1
print(type(image)) # xarray.core.dataarray.DataArray

これは、dataarrayclass内部では__init__()ではなく__new__()を動的に生成しており、__new__()がDataArrayを返すようにしているためです。このため、普通のクラスから作られたインスタンスのように、クラス変数等にアクセスできないことに注意が必要です。

xarrayNumPypandasに比べると記事数も少なく知名度も低いのかなという印象ですが、多次元配列を扱う課題には間違いなく有用ですのでどんどん使っていきたいところです。xarray-customは開発初期で機能も少ないですが、こうした拡張機能の開発や記事を通して少しでもコミュニティに貢献できればと思っております。

References