世界を動かす技術を、日本語で。

NumPyが好きではありません

概要

  • NumPy は配列計算に便利だが、複雑な操作になると扱いが難しくなる問題を抱えていることを論じる内容。
  • ブロードキャストインデクシング の不透明さが、コードの可読性や予測性を損なう要因として指摘されている。
  • np.einsum のような例外的に明快な機能もあるが、全体的には直感的な設計になっていないと批判。
  • 関数の挙動 や多次元配列の扱いが一貫していないことが、学習コストやバグの温床になっている。
  • 理想的な配列言語 への要望と、NumPyの設計上の「原罪」について考察されている。

NumPyへの愛憎とその課題

NumPyの魅力と限界

  • NumPy はPythonで配列計算を行うためのソフトウェアであり、 PyTorch などの機械学習ライブラリにも大きな影響を与えていることを確認。
  • 2次元までの配列計算は直感的かつ簡単に記述できるが、 高次元配列 や複雑な操作になると急激に分かりづらくなることを実感。
  • 例えば、 複数の5×5行列 (100個)と 複数の長さ5ベクトル (100個)を同時に解く場合、ループを使えば明快だが、NumPy流の「ベクトル化」ではどの記法が正しいか分からなくなる現象を指摘。
  • np.linalg.solve のような関数の使い方が直感的でなく、ドキュメントを読んでも正確な方法が分かりづらいことを問題視。

配列操作の複雑さ

  • 3次元以上の配列同士の演算では、 次元合わせ(dimension alignment)ブロードキャスト のために、Noneやreshape、transposeを多用する必要があり、可読性が著しく低下することを批判。
    • 例:A(K×L×M)、B(L×N)、C(K×M)の場合にDkn = 1/(LM) × ∑lm Aklm Bln Ckmを計算するには、複雑な次元操作やtensordot、einsumなどを駆使する必要があることを確認。
  • np.einsum は例外的に明快で強力だが、独自のインデックス記法によるドメイン固有言語を導入しているため、NumPyの標準的な設計とは異なることを強調。

ブロードキャストの功罪

  • ブロードキャスト は一見便利だが、次元合わせのための冗長な記述や、演算の意味がコードから直感的に読み取れない問題を引き起こすことを指摘。
  • A*Bのような単純な記述でも、内部でどのブロードキャスト規則が適用されているかを常に意識する必要があり、可読性・予測性が損なわれることを強調。

インデクシングの罠

  • NumPyの高度な インデクシング は挙動が複雑で、配列の形状(shape)が直感的に予測できないことを多くの例で示す。
  • 実際にAIモデルに形状を答えさせても、正答率は低く、学習コストが高いことを証明。
  • シンプルなインデクシングを心掛けても、どこで複雑な挙動が紛れ込むか分からず、コードの安全性・可読性が低下することを指摘。

NumPy関数設計の一貫性欠如

  • np.linalg.solve などの関数設計が一貫しておらず、axesパラメータやバリエーション関数、独自の内部規則(Conventions)などが混在していることを批判。
    • どの関数がどの次元に対して動作するか、ユーザーが都度調べて合わせる必要があることを問題視。
  • 新たな関数を自作した場合、NumPyが持つ「Einstein記法」的な柔軟性を持たせることができず、コードの再利用性や拡張性が低いことを指摘。

理想的な配列言語への提案

明快さと直感性への要求

  • 理想的な配列言語とは、「やりたいことが 明らか に書けて、コードを読んだ時に 何をしているか明白 であること」が重要であると提案。
  • NumPyは「インデックス」を隠し、 ブロードキャスト に頼った設計が根本的な問題(原罪)であると考察。

まとめと提案

  • NumPyは配列計算の標準として普及したが、 高次元配列複雑な操作 になると、設計の一貫性や直感性が失われることを再確認。
  • np.einsum のようなインデックスベースの記法が、より明快で強力な設計思想のヒントになることを示唆。
  • 配列言語の今後の発展には、「明快さ」「直感性」「一貫性」を重視した設計が不可欠であると提案。

Hackerたちの意見

そうそう!この不満のいくつかはnumpysaneで解決されてるよね: https://github.com/dkogan/numpysane/ 。numpysaneとgnuplotlibのおかげで、今はnumpyが使いやすくなって、いろんなことにガンガン使ってるよ。でも、これがなかったら使えないよね。

リンクありがとう!私もこの問題について文句を言ってたけど、numpysaneみたいなワークアラウンドライブラリを探そうとは思わなかったな…

numpysaneのほとんどはPythonでループを回してるみたいだね。本当のベクトル化じゃない。

著者が言ってることは確かに一理あるね。MatlabからNumpyに移行する時、いろいろ小さな不満があった気がする。データのスライスはNumpyの方がMatlabやJuliaよりもやりにくい感じがするけど、それがMatlabの統計や信号処理ツールボックスのライセンス料を正当化するわけじゃないよね。この記事で取り上げられている問題は、主にランクが2より大きいテンソルに関するものだね。Numpyは元々行列だから、この領域で問題があるのは驚くことじゃないよね。Torchみたいな専用ライブラリの方が確実に良いけど、Torchにも独自の難しさがあるし。うーん、著者の結論「numpyは他の配列言語を除けば最悪の配列言語」ってのは正しい気がする。もしかしたら、私の想像力不足かも。

Numpyは最初からN次元配列に対応してたんだ。これはnumarrayの続きで、Ndだったんだよね。

Matlab(とある程度Julia)と比べると、私のNumpyに対する不満はこの2つの段落にまとめられるかな:> いくつかの関数には軸の引数がある。いくつかは異なる名前の異なるバージョンがある。いくつかには慣例がある。いくつかには慣例と軸の引数がある。そして、いくつかは全くベクトル化されたバージョンを提供しない。> でも、NumPyの最大の欠点はこれだ:ある形の配列で問題を解決する関数を作ったとする。じゃあ、その関数を大きな配列の特定の次元にどう適用するの?答えは:もっと複雑な方法で関数を一から書き直すこと。プログラミングの基本原則は抽象化—単純な問題を解決して、その解決策を使ってより複雑な問題を解決することなんだけど、NumPyはそれを許してくれない。通常、Matlabのコードを書くと、ベクトル化されたバージョンはそのまま動くし、必要な変更もかなり小さくて直感的なんだけど、numpysaneだと、どの関数でもドキュメントを調べなきゃいけない気がするし、配列をその関数が期待する形に転置したりリシェイプしたりしなきゃいけない。あまり一貫性がないよね。

2つ目の問題について、私が正しく理解していれば、jaxのvmapを試してみるといいかもしれないよ。

Matlabの配列における2次元以上のサポートはひどくて、TFAで嘆かれている状況に出くわすことはめったにないよ。

reshape

さて、どうやって大きな配列の特定の次元に適用するの?ここでの不満は具体的に何?2x2の配列に関数を書いたら、3x2x2の配列のタイルに適用する方法がないってこと?でも、スライスして絞ればできるよ。何が問題なの?編集: もし問題が「なぜ任意のインデックスのセットに関数を適用できないのか」っていう変なバージョンなら(2x2x2の配列の0,1みたいな)、言葉が出てこないよ(だってそれは全く違う形だから - 実際には全体の配列だし)。

Numpyに対する私の主な問題は、どの操作がベクトル化されるのか、またはどのようにベクトル化されるのかがしばしば不明確で、Juliaのドット構文のように明示的にできないことだね。さまざまな関数や操作から返される型に関する落とし穴もたくさんあるし。特にひどい例として、長い間、単変数多項式オブジェクトのクラスはnp.poly1dだった。多項式に対する通常の操作を行うための便利な機能がたくさんあったんだ。もしpoly1dオブジェクトPを右から複素数スカラーz0で掛けると、予想通りの結果が得られる:z0でスケーリングされた係数のpoly1dが得られる。でも、左からz0で掛けると、スケーリングされた係数の配列が返ってくる—ここで静かな型変換が起こっているんだ。だから、Pz0 # はpoly1dを返し、z0P # は配列を返す。これはPythonの結合則とデータ型に対する放任主義のせいだと分かってるけど、こういうのをデバッグするのは本当に厄介だよ!poly1dのもう一つの面白い落とし穴:二次の先頭係数にアクセスしたい場合、P.coef[0]でもP[2]でもできる。誰もこれを混同しないよね?これらの具体的な例はあまり公平じゃないけど、numpysaneのドキュメントはpoly1dを「古い」多項式APIの一部として説明していて、新しいコードはPolynomial APIで書くように勧めてるんだ—ただし、公式に非推奨になっているわけではなく、poly1dを使っても警告は出ないみたい。とにかく、ライブラリ全体に似たような欠点があるよ。静かな型変換や、同じメソッドから返される不一致なデータ型に関する落とし穴がたくさんあって、デバッグするのが本当に悪夢みたい。

これは、ダンダーオペレーターのメソッドに関するディスパッチセマンティクスのせいなんだよね。Pythonのドキュメントはそれをうまく伝えてないし。君が説明した通りの型が返ってくるのは、まさに俺が期待してた通りで、Pythonらしいよ。これはnumpyの問題じゃないね。

配列が2次元以上の場合は、Xarray [1] を使うことを検討してみて。これを使うと、NumPy配列に次元名を追加できるから、転置したりダミーの軸を追加したりしなくても、自動的にブロードキャストや整列ができるようになるよ。これだけで、この記事の不満のほとんどは解決できると思う。NumPyと比べると、Xarrayは線形代数のような特定の分野ではちょっと薄いけど、XarrayからNumPyに戻るのはとても簡単だから、私が過去にやってきたのは、必要なNumPyの特定の機能のために小さなヘルパー関数を追加することなんだ。それで、そのヘルパー関数とそのテストを書くために、NumPyのAPIのバージョンを一度だけしっかり理解すればいいからね。(ただし、NumPyの大多数のufuncはそのままサポートされてるよ。)最後に、著者とは対照的に、私はNumPyが嫌いなわけじゃないけど、真の多次元データに対してはAPIとデータモデルが不十分だと思ってる。私にとって、三次元がXarrayを使う価値がある境界なんだ。[1] https://xarray.dev

同感。Xarrayはほとんど裸のNumPyを置き換えてくれて、すごく生産性が上がった。

Tensorflow、Keras、Pytorchみたいなものに似たものってある?最近はあんまり使ってないけど、過去には君が言ったことをデバッグが大変な方法でやらなきゃいけなかったんだ。

このライブラリを共有してくれてありがとう。試してみるよ。しばらくの間、‘array[:, :, None]’みたいな使い方が本当に嫌いなのは自分だけかなってちょっとおかしいんじゃないかと思ってたんだ。

その流れで言うと、生体信号処理のために、NeuroPype[0]もnumpyを基にしていて、n次元テンソルのために名前付き軸を実装してるんだ。各軸に対して要素ごとのデータ(チャンネル名、位置など)を保存できる能力もあるよ。[0] https://www.neuropype.io/docs/

Xarrayは最高だね。PandasとNumpyのいいとこ取りしてる。da.sel(x=some_x).isel(t=-1).mean(["y", "z"])みたいなインデクシングのおかげで、コードがすごく書きやすくて理解しやすい。ブロードキャスティングも曖昧さがなくて、次元名がちゃんと尊重されてるから安心。地理空間データにもすごく良くて、同じ基礎データで複数のCRSで作業できるのが便利。BayesianモデリングにもArvizを通じてよく使うんだけど、ポスターリオをサンプリングしたときに得られる余分な次元を扱いやすくしてくれるんだ。最後に、いろんな配列をデータセットにまとめられて、共通の座標を配列間で共有できるのがいいね。これで、時間次元を持つすべての配列に対してds.isel(t=-1)を選択できるんだ。[1] https://www.arviz.org/en/latest/

xarrayいいね

人生って時々フルサークルになるよね。NumpyがNumericとNumarrayライブラリの統合から生まれたことを思い出すな。Numarrayの人たちがこの20年間ずっと、自分たちが正しい解決策だって証明しようと戦ってて、ある時イーロン・マスクからお金をもらってXarrayに名前を変えて、ついにNumpyを追い抜いたって想像したいな。[0] 上のほとんどはフィクションだよ

Numpyに対する私の主な問題は、構文が冗長なことだね。プログラミング言語の観点からは欠点とは見なされないかもしれないけど(むしろ強みかもしれない)、実際にはMatlabやJuliaと比べると、構文が面倒なんだ。後者のコードは読みやすくて、理解しやすく、数学の表記法とも一致してるし。

実際の配列言語の構文は、美しく簡潔で表現力豊かだよ。一行を読んだだけで理解できる数学の式を表現できるし、記法や元々直感的でない quirks(プログラミングの観点から見た)に慣れれば、見た目が悪くて読みにくいコードを何行も書く代わりに、ほんの数行で書けるようになるんだ。私の考えでは、python+numpyは実際には配列言語じゃない。Numpyは速度を助けるためにpythonにベクトル化操作を追加するライブラリなんだ。これは違うよ。配列言語の構文が持つ利点を(意図的に)もたらすわけじゃないし、もう少し一貫性があったとしてもね。

Juliaを使い始めた理由の一つは、NumPyの構文がすごく難しかったからなんだ。MATLABからNumPyに移ったとき、急に平凡なプログラマーになった気がして、数学にかける時間が減って、NumPyを正しく使うための「パフォーマンスエンジニアリング」にもっと時間を使うようになった。Juliaに移ったときは、気持ちよくベクトル化できるときはベクトル化して、ループが気持ちいいときはループを書くのが理にかなってた。どちらも速いから、コードを読みやすく理解しやすくすることに集中すればいいんだ。このブログ記事はまさにその体験と感覚をまとめてる。あと、np.linalg.solveみたいなものを、世界で一番速いブラックボックスとして扱って、正しく呼び出すためにコードをいじりまくるのは、ただ間違ってるよ。問題特有の線形代数カーネルを作る理由はたくさんあって、それはもっと深く掘り下げないとアクセスできないことなんだ。でも、それはまた別の話。

「Juliaに移ったときは、気持ちよくベクトル化できるときはベクトル化して、ループが気持ちいいときはループを書くのが理にかなってた。どちらも速いから、コードを読みやすく理解しやすくすることに集中すればいいんだ。」これは現代のFortranにも当てはまるね。

MATLABからNumPyに移ったとき、急に平凡なプログラマーになった気がして、数学にかける時間が減って、NumPyを正しく使うための「パフォーマンスエンジニアリング」にもっと時間を使うようになった。MATLABは、簡単にベクトル化された操作がないとPythonと同じくらい遅い。Pythonの遅さは大きな痛点で、Juliaにはここで明確な利点がある。残念ながら、Juliaはかなりニッチな目的を超えると実質的に使えない。Pythonには、ベクトル化のトリックから逃れるためのかなり使えるJITハックがあるけど、それでもハックだし、パフォーマンスの良いPythonの代替があれば大歓迎なんだけど、残念ながらそれはないんだ。

理由はとてもシンプルだよ。Juliaは科学計算のために設計された言語なんだ。Numpyは科学計算のために本当に設計されていない言語にフランケンシュタインのようにくっつけられたライブラリなんだ。Juliaが何とか勝って、ネットワーク効果のためにpythonで働かざるを得ない人たちが解放されることを願うばかりだよ。

MATLABって実際に違うの?ループが遅いんだけど(どれくらい遅いかはバージョンによる)、世界で一番速いのは「\」って呼ばれるブラックボックスの完璧さだね。

私の問題は、インプレース操作を使うのを忘れると、あちこちでメモリを割り当てるのが簡単すぎることなんだ。cupyだとさらにひどくて、データに一連の操作を適用して別のデータを生成する代わりに、各操作ごとにデータのセットを生成しちゃう。確かに回避策はあるけど、あまり使いやすくない(cupy.fuse()はほぼ正しいことをするけど、使うことを覚えておかなきゃいけないステップだし、複数の形状の配列が必要なものにはあまり機能しない)。

私の視点から見ると、Pythonのデータサイエンスエコシステムの主な問題は、何においても標準化がまったくないことだ。10個の異なるライブラリが4つの他の言語のように振る舞おうとしていて、唯一の標準化のポイントは、通常は.to_numpy()関数のようなものがあることだけ。つまり、大半の時間はデータ処理に関する特定の問題を解決しているわけではなく、あるライブラリが理解できる形式から別のライブラリが理解できる形式にデータを変換しているだけなんだ。Juliaでは(もちろん自分自身の問題もあるけど)、ほとんどのことがうまくいく。不確実性を計算するライブラリは、単位を扱うライブラリとうまく連携していて、これがパイロットライブラリや微分方程式を解くライブラリとも問題なく動く。Pythonでは、これにはかなりのボイラープレートが必要だった。

Rの4(?)クラスシステムが登場。

誰もarray-api(もっと一般的にはdata-apis)について言及してないけど、これはPythonエコシステム全体で配列とやり取りする方法を標準化しようとしてるんだよ。 https://github.com/data-apis/array-api https://data-apis.org/blog/announcing_the_consortium/

ちょっと賢いことをしようと思って、記事のベクトル化されたマルチヘッドアテンション実装のeinsumにすべての行列の掛け算をインライン化して、最適な行列チェーン掛け算アルゴリズムを使うためにoptimize="optimal"を設定したんだ。これでパフォーマンスが向上するはずだった。def multi_head_attention_golfed(X, W_q, W_k, W_v, W_o, optimize="optimal"): scores = np.einsum('si,hij,tm,hmj->hst', X, W_q, X, W_k, optimize=optimize) weights = softmax(W_k.shape[-1]-0.5 * scores, axis=-1) projected = np.einsum('hst,ti,hiv,hvd->shd', weights, X, W_v, W_o, optimize=optimize) return projected.reshape(X.shape[0], W_v.shape[2]) これが確かにベクトル化された実装の2倍速いんだけど、残念ながらループを使ったナイーブな実装の方がさらに速い。誰かがパフォーマンスがそうなっている理由を調べたいなら、ここにコードがあるよ:https://pastebin.com/raw/peptFyCw 私の予想では、einsumは合計を評価するときにキャッシュの整合性を考慮するのがもっと上手くできるはず。

確かに、これはベクトル化された実装の2倍速いけど、残念ながらループを使ったナイーブな実装の方がさらに速いね。CPUそれともGPU?