- 筆者は NumPy への不満をテーマに、複数の例とともに問題点を説明している
- 簡単な配列演算は NumPy で容易に書けるが、次元が増えると複雑さと混乱が急激に増大する
- ブロードキャスト や高度なインデックス指定など、NumPy の設計は明確さと抽象化の面で不十分である
- 明示的に軸を指定する代わりに、推測と試行錯誤に頼るコードを書くことが不可欠になる
- 改善された配列言語に関するアイデアを提示し、具体的な代替案は次回の記事で紹介する予定である
序論: NumPyへの愛憎
- 筆者は長年 NumPy を使ってきたが、その限界に大きく失望してきたと述べている
- NumPy は Python における配列演算のための必須かつ影響力の大きいライブラリである
- PyTorch などの現代的な 機械学習ライブラリ にも、NumPy と似た問題が存在する
NumPyの簡単な点と難しい点
- 基本的な線形方程式の求解のような単純な演算は、明快でエレガントな文法 で記述できる
- しかし、配列の次元が高くなったり演算が複雑になったりすると、for ループ なしで一括処理する必要が出てくる
- ループを使えない環境(GPU 演算など)では、特殊なベクトル化文法 や特別な関数呼び出しの方法が必要になる
- しかし、こうした関数の 正確な使い方 は曖昧で、ドキュメントだけでは明確に理解しにくい
- 実際、NumPy の
linalg.solve 関数は、高次元配列の場合にどう使うのが正しいのか、誰も確信を持ちにくい
NumPyの問題点
- NumPy には、多次元配列 の一部や特定の軸に演算を適用するための一貫した理論が不足している
- 配列の次元が 2 以下のときは明快だが、3 次元以上では各配列ごとにどの軸が演算対象なのかが不明確になる
- 明示的に次元を合わせるために None の使用、ブロードキャスト、
np.tensordot など複雑な方法を強いられる
- こうしたやり方は、ミスを誘発し、コードの可読性を下げ、バグの可能性を高める
反復処理と明確さ
- 実際には、反復処理を許せば、より 簡潔で明確なコード を書くことができる
- 反復処理のコードは洗練されていないように見えるかもしれないが、明確さの面では大きな利点がある
- 一方で、配列の次元が変わると transpose や軸の順序を逐一考えなければならず、複雑さが増してしまう
np.einsum: 例外的に優れた関数
- np.einsum は、軸の名前を指定できる柔軟なドメイン特化言語を提供しており、非常に強力である
- einsum は演算の意図が明確で一般化もしやすく、複雑な軸演算を明示的に実装できる
- しかし、einsum に似た方式の演算サポートは 一部の演算 に限られており、たとえば
linalg.solve では使えない
ブロードキャストの問題点
- NumPy の中核的なトリックである ブロードキャスト は、次元が合わないときに自動的に合わせてくれる機能である
- 単純なケースでは便利だが、実際には次元を明確に把握しにくくし、誤りの原因になることが多い
- ブロードキャストは暗黙的であるため、コードを読むたびに演算がどう働くかを毎回確認しなければならない
インデックス指定の不明確さ
- NumPy の 高度なインデックス指定 は、配列の shape を予測するのが非常に難しく、不明確である
- インデックス指定の組み合わせによって結果配列の shape が変わるため、実際に扱った経験がなければ予測が困難である
- インデックス規則の説明文書も長く複雑で、習得に大きな時間を要する
- 単純なインデックス指定だけを使いたくても、特定の演算ではどうしても高度なインデックス指定を使わざるを得ない
NumPy関数設計の限界
- 多くの NumPy 関数は、特定の配列 shape にだけ最適化されている
- 高次元配列では追加の axes 引数、別の関数名、慣習などを使う必要があり、関数ごとに一貫性がない
- 抽象化と再利用を基本とするプログラミング原則に逆行する構造である
- 特定の問題を解く関数を使っても、さまざまな配列や軸に再適用するには、まったく別のコードとして書き直さなければならない
実例: self-attention の実装
- self-attention の実装を NumPy で書くとき、反復処理を使えば明快だが、ベクトル化を強制するとコードが複雑になる
- マルチヘッド attention のように高次元演算が必要な場合、einsum と軸変換を組み合わせて使う必要があり、コードが難解になる
結論と代替案
- 筆者は、NumPy は「他の配列言語より悪い点が多いにもかかわらず、市場で重要になった唯一の選択肢」だと述べている
- NumPy のさまざまな問題点(ブロードキャスト、インデックス指定の不明確さ、関数の非一貫性など)を克服するため、改良された配列言語 のプロトタイプを作ったことを予告している
- 具体的な改善案(新しい配列言語 API)は、今後別の記事で紹介する予定である
4件のコメント
Juliaがなぜ生まれたのかという話のようですね。ライブラリ群を学ぶ必要はありますが、NumPyの多くの問題を解決してくれるという点で、本当に魅力的な選択肢だと思います。
NumPy はベクトル化をうまく使えないと、性能は悲惨になりますよね。そういうことを考慮して書くのはストレスですし、難しいですよね。
少し古い Python ライブラリは、どれも似たような問題を抱えている気がします
Hacker News のコメント
da.sel(x=some_x).isel(t=-1).mean(["y", "z"])のようなインデックス指定が簡単で、次元名が尊重されるのでブロードキャストも明確。複数の CRS を持つ地理空間データの処理に強く、Arviz との相性も抜群なのでベイズ分析で追加次元を扱うのも簡単。複数の配列を 1 つの dataset にまとめて共通の座標も共有でき、ds.isel(t=-1)のように時間軸を持つすべての配列に対して簡単に操作できるarray[:, :, None]みたいな文法が不便だと感じていたのが自分だけではなかったと分かってうれしいnp.linalg.solveのようなものを最速だと思い込んで無条件にそれに合わせろという「ブラックボックス」的な発想は正しくないと思う。問題特化のカーネルを自分で書いた方がよい理由もいくつもある\\演算子のような完全に最適化されたブラックボックスだvmapを試してみる価値があるreshapeで処理できるPを右からz0で掛けると poly1d が返るが、左からz0*Pの形で掛けると配列しか返らず、型変換が静かに起きる。quadratic の leading coefficient もP.coef[0]とP[2]の 2 通りで取得でき、混乱しやすい。公式には poly1d は「古い」API で、新しいコードには Polynomial クラスが推奨されているが、実際には deprecated 警告すら出ない。こうした型変換やデータ型の不一致のような地雷がライブラリの至る所にあり、デバッグが悪夢になるto_numpy()くらいしか統一されていない。結局、問題を解く時間よりデータ形式の変換に時間を取られる。Julia にも長所しかないわけではないが、単位や不確かさなどさまざまなライブラリ間の連携がうまく、Python は常にボイラープレートコードを大量に必要とするarray-apiプロジェクトが Python 生態系全体で配列操作 API の標準化を進めようとしているeinsumに入れ、optimize="optimal"で matrix chain 積アルゴリズムを使って性能向上を試した。実際、一般的なベクトル化実装と比べて 2 倍程度速くはなったが、驚いたことにループ方式の素朴な実装の方がさらに速かった。理由が気になる人はコードを参照してほしい。einsum内部の cache coherency にはまだ改善の余地があるのではないかと推測している