NumPy で書く配列計算:ブロードキャスト・メモリ配置・縮約の順序
対象読者: 学部 1〜2 年の線形代数(行列の積)と Python の基本、本テーマ第 1 章の再現性の考え方、計算量と O 記法を学んだ人。NumPy の経験は仮定せず、メモリ配置の知識も仮定しません。
前提となる記事:再現できる数値実験:乱数種・環境・設定の記録と決定性、計算量と O 記法:アルゴリズムの速さを入力サイズの関数で測る
0. この記事の要点
Section titled “0. この記事の要点”NumPy で配列どうしを足したり、配列の一部を取り出したりするとき、中でどんなことが起きているかを知らなくても、たいていは動きます。けれども「形のちがう配列がなぜ足せるのか」「取り出した配列に書き込むと元が変わることがあるのはなぜか」「整数の計算結果がなぜ負になるのか」といった疑問には、仕組みを知らないと答えられません。この記事は、配列を「メモリ上の並びと、その読み方の規則」として書き下し、これらの疑問に規則から答えられるようにします。
- NumPy の配列は「データ領域・要素の型・shape・strides・offset」の組です。添字 の要素の番地は で、転置・スライス・逆順・軸の追加はデータを複製せず、この式の係数を書き換えるだけです(ビュー)。strides は、各軸で添字を 1 つ進めると番地がいくつ増えるかを軸ごとに並べた組です。
- ブロードキャストは、shape を右詰めにそろえ、各軸で「長さが等しいか一方が 1」のときに両立するという規則です。両立する shape の結合は可換・結合的・冪等(同じものどうしを結合しても変わらない)なので、何個の配列をどの順に組み合わせても結果の shape は同じです。
- 出力と入力が同じ番地を共有する更新(
a[1:] += a[:-1])は、要素を 1 つずつ書き換える実行と、いったん全部を計算してから書く実行で結果が違います。番地が交わらなければ、どちらでも同じです。 - 行列の鎖 の積は、括弧づけで乗算回数が大きく変わります。最小の回数は区間についての漸化式で 時間で求まります。一般のテンソルの縮約では、順序で演算量の指数が変わります。
- ベクトル化は要素ごとの演算の回数を変えません。変わるのは 1 回あたりの定数と、一時配列の大きさです。全対の距離行列は一時配列が 個になり、ブロック化で 個に抑えられます。
- 固定幅の整数の加減乗は を法とする演算です。真の値が表現範囲に入っていれば結果は正しく、出ていれば黙って別の数になります。
数値実験のコードの大半は、配列に対する演算です。NumPy では、要素ごとのループを自分で書く代わりに、配列全体に対する演算を 1 行で書きます。速く、短く、読みやすくなりますが、引き換えに「この 1 行が実際に何をしているか」が見えにくくなります。
たとえば次の疑問に、規則から答えられるでしょうか。
- shape が
(4, 1, 1),(2, 1),(3,)の 3 つの配列を足すと、結果の shape は何か。足す順序で変わらないか。 b = a.Tのあとでb[0, 1] = 99とすると、aは変わるか。a.T.reshape(-1)ではどうか。a[1:] += a[:-1]は累積和になるか。- 全対の距離を 1 行で計算したら、メモリが足りなくなった。演算量は同じはずなのに、なぜか。
int32の配列の内積が負になった。
どれも、配列の構造と演算の規則を定義として書き下せば、命題として答えが出ます。この記事ではそれを行います。計算量は一様コスト RAM モデル(定義 2.1[計算量と O 記法])で数え、 記法は 定義 3.1[計算量と O 記法] のものです。コード例の約束事(版と乱数種の明記)は再現できる数値実験に従います。
2. 配列の構造:番地の式
Section titled “2. 配列の構造:番地の式”2.1. 定義
Section titled “2.1. 定義”計算機の記憶装置は、番地で指定するセルの列です(定義 6.1[コンピュータアーキテクチャとCPUの構造]、定義 2.1[計算量と O 記法])。多次元の配列は、この 1 次元の列の上に、添字から番地への対応を決めることで作ります。
多次元配列( 次元)とは、次の組である。
- データ領域: 連続した番地 のバイトの列。
- dtype: 1 要素のバイト数 と、 バイトを値(整数・浮動小数点数など)として読む方法。
- shape: 以上の整数の組 。 を第 軸の長さという。
- strides: 整数の組 。
- offset: 整数 。
添字 ()の要素は、番地
から始まる バイトである(これがデータ領域に収まるものとする)。
のとき C 順(行優先。最後の添字が最も速く動く)、 のとき Fortran 順(列優先)であるといい、どちらかであるとき配列は連続であるという(このとき要素は、番地 から バイトずつ隙間なく並ぶ)。
配列 から作った配列 が、 と同じデータ領域を使い、shape・strides・offset だけが異なるとき、 を のビューという。新しいデータ領域を確保して値を書き写したものをコピーという。
たとえば 3×4 の int64()の C 順では、 です。行を 1 つ進むと 1 行分の 4 要素(32 バイト)飛び、列を 1 つ進むと 1 要素(8 バイト)進みます。下の図の a がこの配列です。
ビューを通じて書き込むと、元の配列の対応する要素も変わります。同じ番地だからです。
2.2. ビューは番地の式の変換
Section titled “2.2. ビューは番地の式の変換”命題 2.3(転置・スライス・逆順・軸の追加は複製なしで表せる)✓
配列 の shape を 、strides を 、offset を とする。次の各操作の結果 は、 と同じデータ領域の上で、右に示す shape・strides・offset をもつ配列として表せる。すなわち、 の各要素の番地は、対応する の要素の番地に等しい。
- 転置(軸の置換 。, ): shape 、strides 、offset 。
- スライス(第 軸で始点 、歩幅 、個数 。 で とし、): 第 軸の長さを 、stride を に変え、offset を にする。逆順は , , の場合である。
- 整数による添字(第 軸を に固定。 は 次元): 第 軸を shape と strides から取り除き、offset を にする。
- 軸の追加(長さ の軸を挿入。
Noneやnp.newaxis): その位置に長さ 、stride を挿入する。offset は 。 - 長さ 1 の軸の引き伸ばし(第 軸が のとき、長さ にして ): 第 軸の長さを 、stride を にする。offset は 。
証明(命題 2.3)
どれも、 の番地の式(定義 2.1)に右の値を代入して、対応する の要素の番地と一致することを確かめます。
- の添字 の番地は です。 を代入すると で、 は の置換なので、和の添字を に取り替えて です。
- の番地は で、これは の第 添字を とした要素の番地です。逆順では で、 のとき を満たします。
- の番地は で、 の第 添字を とした要素の番地です。
- 挿入した軸の添字は しかとらないので、その項 は和に寄与せず、番地は の対応する要素と同じです。
- の番地は で、 によりません。これは の第 添字を とした要素の番地 に等しいです。
5 は次の節のブロードキャストの実装そのものです。長さ 1 の軸を長さ に「複製」するとき、NumPy はデータを書き写さず、stride を にして同じ要素を 回読みます。
例 2.4(strides を表示してビューを確かめる)
3×4 の int64 配列(C 順、)について、いくつかの操作の結果の shape・strides・offset を表示します。np.shares_memory はデータ領域を共有しているかを厳密に調べます。
# Python 3.9.6 / NumPy 2.0.2(arm64)import numpy as np
a = np.arange(12, dtype=np.int64).reshape(3, 4) # C 順、1 要素 8 バイトbase = a.__array_interface__["data"][0]def info(name, v): view = np.shares_memory(a, v) off = v.__array_interface__["data"][0] - base if view else "-" print(f"{name:<11} shape={v.shape!s:<9} strides={v.strides!s:<10}" f" offset={off!s:>2} {'ビュー' if view else 'コピー'}")
info("a", a)info("a.T", a.T)info("a[1:, ::2]", a[1:, ::2])info("a[::-1]", a[::-1])info("a[:, None]", a[:, None])info("a[2]", a[2])info("a[[0, 2]]", a[[0, 2]]) # 整数配列による添字info("a[a > 5]", a[a > 5]) # マスクr1 = a.reshape(12) # 連続な配列の reshaper2 = a.T.reshape(12) # 連続でないビューの reshapeprint(np.shares_memory(a, r1), np.shares_memory(a, r2), r2.tolist())a shape=(3, 4) strides=(32, 8) offset= 0 ビューa.T shape=(4, 3) strides=(8, 32) offset= 0 ビューa[1:, ::2] shape=(2, 2) strides=(32, 16) offset=32 ビューa[::-1] shape=(3, 4) strides=(-32, 8) offset=64 ビューa[:, None] shape=(3, 1, 4) strides=(32, 0, 8) offset= 0 ビューa[2] shape=(4,) strides=(8,) offset=64 ビューa[[0, 2]] shape=(2, 4) strides=(32, 8) offset= - コピーa[a > 5] shape=(6,) strides=(8,) offset= - コピーTrue False [0, 4, 8, 1, 5, 9, 2, 6, 10, 3, 7, 11]検算. (a) 命題との照合: C 順の strides は 。転置は strides の入れ替え(命題 2.3 の 1)、a[1:, ::2] は第 1 軸が 、第 2 軸が で strides 、offset (同 2)、逆順は stride 、offset 、a[:, None] は stride の挿入(同 4)、a[2] は offset (同 3)で、すべて出力と一致します。(b) 別の確かめ方: np.shares_memory の結果は、命題が「複製なし」とした 6 つでビュー、整数配列とマスクによる添字でコピーです。後者は取り出す要素の番地が等間隔とは限らないので、1 組の strides では表せません。(c) reshape: 連続な a の reshape(12) はビュー、連続でない a.T の reshape(12) はコピーです。理由は次の例で示します。
例 2.5(reshape がコピーを要する場合)
上の例の a.T.reshape(12) は、要素を の順に並べた 1 次元配列です。これらの要素の番地(元のデータ領域での)は です。1 次元の配列の番地は ()という等差数列でなければなりません(定義 2.1)。ところが は、はじめの 2 つの差が 、3 番目と 4 番目の差が で等差数列ではないので、どんな を選んでも同じデータ領域の上のビューとしては表せません。NumPy は新しいデータ領域に書き写します。
実務上の帰結は、reshape の結果に書き込んだとき、元の配列が変わるかどうかが元の配列の strides によることです。書き込みを元に反映させたいのか、させたくないのかが大事な場面では、np.shares_memory で確かめるか、.copy() で明示的にコピーを作ります。
3. ブロードキャスト
Section titled “3. ブロードキャスト”3.1. 規則
Section titled “3.1. 規則”とする( は「両立しない」を表す記号)。 の結合 を
と定める。 以上の整数 が を満たす、すなわち または または のとき、長さ と は両立するという。
shape と整数 に対し、先頭に を 個補った を と書く(右詰め)。2 つの shape の結合を、 を両者の次元の大きい方として、 と の成分ごとの結合と定める。結合のどの成分も でないとき と は両立するといい、結合をブロードキャストした shape という。
両立する shape の配列どうしの要素ごとの演算では、それぞれの配列を、軸の追加と長さ 1 の軸の引き伸ばし(命題 2.3 の 4 と 5)によってブロードキャストした shape の配列とみなしてから、同じ添字の要素どうしを演算する。
長さが正のとき、両立する の結合は です。長さ の軸も許すので(空の配列)、「大きい方」ではなく「 でない方」と定義しています。 で、NumPy も同じ結果を返します。
3.2. 結合は順序によらない
Section titled “3.2. 結合は順序によらない”- 定義 3.1 の は の上で可換()、結合的()、冪等()で、 が単位元()である。
- shape を、どんな順序・どんな括弧づけで 2 つずつ結合していっても、結果( を含む場合も含めて)は同じである。したがって、複数の配列のブロードキャストが成功するかどうかも、成功したときの shape も、演算を適用する順序によらない。
- 両立するという関係は推移的でない。shape と 、 と はそれぞれ両立するが、 と は両立しない。
次の証明のねらいは、「結合が順序によらない」ことを、2 つずつの結合の性質(可換・結合的)に帰着させることです。そのために、まず に大小関係 を入れて、 が の上限(どちらよりも小さくない元のうち最小のもの)であることを確かめます。上限は という集合だけで決まるので、可換性と結合性はそこから出ます。反対称律・推移律の場合分けは が大小関係として振る舞うことの確認なので、細部を飛ばして「上限であること」以降から読んでも、2 の議論は追えます。
証明(命題 3.2)
1. の上の関係 を「 とは、 または または 」と定めます。これが半順序で、 が の上限(最小上界)であることを示します。
反射律は定義から成り立ちます。反対称律: , , とすると、「 または 」かつ「 または 」です。4 つの組合せ 、、、 は、どれも か に反します。よって です。推移律: とします。 か なら です。 なら から、 なら から です。残るのは , の場合で、 から です。すると は「 または または 」なので となり、 です。
上限であること: なら で、これは の上限です。 なら で、, から は上界、任意の上界 は を満たすので が最小です。 のときも同様に が上限です。それ以外(, , )では、上界 は から「 または 」、 から「 または 」を満たします。 なら となり に反するので、上界は だけで、上限は です。
上限は という集合で決まるので、可換性と冪等性が出ます。 は定義です。結合性: とおきます。 かつ なので、 は の上界です。 を の任意の上界とすると、 は の上界なので 、また なので、 は の上界で です。よって は の上限です。同じ議論で も の上限で、上限は反対称律からただ 1 つなので、両者は等しいです。
2. を の次元の最大値とします。2 つの shape の結合は、両者の次元の大きい方 にそろえて成分ごとにとりますが、これをさらに で補ったものは、はじめから でそろえて成分ごとに結合したものに等しいです(補った先頭の成分は だからです)。したがって、すべての shape を でそろえたうえでの成分ごとの演算として考えてよく、各成分で 1 の可換性と結合性が成り立つので、結果は順序と括弧づけによりません。
成功・失敗について。最終的な結合 がどの成分にも を含まないとします。途中に現れる結合 は、一部の shape の成分ごとの上限なので、成分ごとに です。 かつ なら、 の定義( または )から です。よって途中のどの段階でも失敗しません。逆に が を含むなら、どの順序で進めても最後の結果は同じ なので、どこかの段階で が現れて失敗します。
3. , はどちらも でなく、 です。
3 は、「 と を足せる、 と を足せる」から「 と を足せる」は出ないことを言っています。3 つを結合すると で、2 のとおりどの順に足しても失敗します。
例 3.3(ブロードキャストの shape を返す関数)
定義 3.1 をそのまま書いた関数を、NumPy の np.broadcast_shapes と照合します。長さ を含む 11 個の shape から、重複を許して 2 個または 3 個を選ぶすべての組( 通り)で比べます。
# Python 3.9.6 / NumPy 2.0.2(arm64)import itertoolsimport numpy as np
def broadcast_shape(*shapes): d = max(len(s) for s in shapes) out = [1] * d for s in shapes: s = (1,) * (d - len(s)) + tuple(s) # 右詰めにそろえる for k in range(d): if s[k] != 1: if out[k] not in (1, s[k]): raise ValueError(f"軸 {k}: {out[k]} と {s[k]} は両立しない") out[k] = s[k] return tuple(out)
pool = [(), (1,), (3,), (4,), (1, 3), (2, 1), (2, 3), (4, 1, 1), (4, 2, 3), (0,), (1, 0)]ok = bad = 0for k in (2, 3): for shapes in itertools.product(pool, repeat=k): try: mine = broadcast_shape(*shapes) except ValueError: mine = None try: ref = np.broadcast_shapes(*shapes) except ValueError: ref = None assert mine == ref, shapes ok += mine is not None bad += mine is Noneprint("照合した組", ok + bad, "両立", ok, "両立しない", bad)print(broadcast_shape((4, 1, 1), (2, 1), (3,)))print(broadcast_shape((0,), (1,)), np.broadcast_shapes((0,), (1,)))for s in [((2,), (1,)), ((1,), (3,)), ((2,), (3,))]: try: print(s, broadcast_shape(*s)) except ValueError as e: print(s, e)照合した組 1452 両立 818 両立しない 634(4, 2, 3)(0,) (0,)((2,), (1,)) (2,)((1,), (3,)) (3,)((2,), (3,)) 軸 0: 2 と 3 は両立しない検算. (a) 別実装: 1452 通りすべてで、成功・失敗と結果の shape が np.broadcast_shapes と一致しました。(b) 命題との対応: 3 個の組は順序を入れ替えたものもすべて含むので、順序によらないこと(命題 3.2 の 2)の確認にもなっています。動機で挙げた , , の結合は です。(c) 境界: で、長さ の軸は と両立して になります。最後の 3 行は命題の 3 の例です。
4. 要素ごとの演算と、重なりのある更新
Section titled “4. 要素ごとの演算と、重なりのある更新”スカラーの関数 を、ブロードキャストした配列の同じ添字の要素どうしに適用して、同じ shape の配列を返す関数を ufunc(universal function)という。np.add, np.multiply, np.exp などで、a + b は np.add(a, b) である。
2 変数の ufunc による、第 軸に沿った縮約(reduce)とは、shape の配列 から、第 軸を取り除いた shape の配列 を
で作る操作である( は取り除くことを表す)。np.add の縮約が sum、np.multiply の縮約が prod、np.maximum の縮約が max である。
浮動小数の和の縮約では、NumPy は上の式の括弧づけどおりではなく、対ごとの和に近い順序で足すことがあります。順序を変えたときの結果の差は、再現できる数値実験の 定理 3.4[再現できる数値実験] で抑えられます。
ufunc の出力先を、入力と同じデータ領域のビューにすることができます(a += b や out= の指定)。このとき、出力と入力の番地が重なると、書き込みの順序が結果に影響しえます。
メモリを、番地から値への写像 とみる(1 要素を 1 つの番地で代表させる)。有限集合 の各 に、出力の番地 、入力の番地 が定まっていて、 は単射とする。関数 による更新の 2 通りの実行を考える。
- 一時配列を使う実行: すべての について を先に計算し、そのあとで を に書き換える。
- 逐次実行: の元をある順序 に並べ、順に「その時点のメモリから を読み、 の値を に書く」を行う。
このとき次が成り立つ。
- ならばすべての で である(ある要素の出力先を、別の要素の計算が読まない)とき、逐次実行の結果は順序によらず、一時配列を使う実行の結果と一致する。とくに、出力の番地の集合と入力の番地の集合が交わらないとき、および出力と入力がまったく同じ配置( で、ほかに重なりがない)のときにそうである。
- 長さ の配列 に対する
a[1:] += a[:-1](, , , )は 1 の条件を満たさない。一時配列を使う実行の結果は 、添字の小さい順の逐次実行の結果は (累積和)で、 ならば で異なる。
直感的には、一時配列を使う実行は「全部読んでから書く」、逐次実行は「1 つ書くたびに、次の読みがその影響を受ける」実行です。a = [1, 1, 1, 1, 1] に a[1:] += a[:-1] を適用すると、前者は各要素が元の隣の値と足されて [1, 2, 2, 2, 2] に、後者は書き換えたばかりの a[1]=2 を a[2] に足し、a[2]=3 を a[3] に足し、と進んで [1, 2, 3, 4, 5] になります。証明の 1 は、読む番地と書く番地が重ならなければこの差が出ないこと、2 はこの例の一般化です。
証明(命題 4.2)
1. 逐次実行の 番目の操作の直前のメモリを ()とします。 についての帰納法で、「 は、番地 以外では に一致する」ことがわかります(書き込むのは だけです)。 番目の操作が読む番地 は、 について なので、仮定から のどれとも異なります。したがって読む値は で、書く値は です。 は単射なので、番地 はほかの操作では書き換えられず、最終的な値は です。これは一時配列を使う実行の結果で、順序 によりません。
出力と入力の番地の集合が交わらなければ、 はすべての で成り立ちます。同じ配置の場合は、 で、 なら の単射性から 、ほかの入力とは重ならないので、やはり条件を満たします。
2. なので、, が条件に反します。一時配列を使う実行は、元の値から を作って書くので です。添字の小さい順の逐次実行では、 についての帰納法で です。 は 。 まで成り立てば、 番目の操作は、すでに書き換えられた番地 の値 と、まだ書き換えられていない番地 の値 を読んで足すので、 です。 での差は です。
NumPy の ufunc は(1.13 以降)、入力と出力の重なりを検出すると、一時配列を使う実行と同じ結果になるように計算します。したがって a = np.array([1, 1, 1, 1, 1]); a[1:] += a[:-1] の結果は [1 2 2 2 2] で、累積和 [1 2 3 4 5] にはなりません(手元の NumPy 2.0.2 で確認)。同じ式を Python の for ループで添字の小さい順に書くと累積和になります。ループをベクトル化するときは、ループが「書き換えたばかりの値を読む」形になっていないかを確かめてください。累積和そのものが欲しいなら np.cumsum を使います。
もう 1 つ、出力の番地が重複する場合があります。idx = np.array([0, 0, 2]) に対する d[idx] += 1 は、d[idx] を取り出し(コピー)、 を足し、d[idx] に書き戻す、という一時配列を使う実行です。番地 には同じ値が 2 回書かれるだけなので、d が零ベクトルなら結果は [1 0 1] で、[2 0 1] にはなりません(命題 4.2 の仮定「 は単射」が破れている場合です)。重複する添字ごとに足し込みたいときは np.add.at(d, idx, 1) を使います。こちらは [2 0 1] を返します。
5. 縮約の順序
Section titled “5. 縮約の順序”5.1. Einstein の縮約記法
Section titled “5.1. Einstein の縮約記法”配列 の各軸に添字の名前(文字)を付け、同じ名前の軸は同じ長さをもつとする。出力に残す名前の列を指定したとき、縮約の結果は、出力の名前の値を固定し、残りのすべての名前について和をとった
である。np.einsum("ij,jk->ik", A, B) は行列の積 を表す。
のとき、配列を 2 つずつ縮約して中間結果に置き換えていく手順を縮約順序という。2 つの配列の縮約の費用を、定義の式どおりに計算したときのスカラーの乗算の回数、すなわち 2 つの配列に現れるすべての名前の長さの積とする。 行列と 行列の積の費用は である。
添字に名前を付ける書き方は、軸の順序を覚えなくてよい点でも有用です。np.einsum("bij,bjk->bik", A, B) は「先頭の軸 b ごとの行列の積」であることが式から読めます。
5.2. 行列の鎖の最適な括弧づけ
Section titled “5.2. 行列の鎖の最適な括弧づけ”行列の積は結合的なので、 はどう括弧を付けても同じ行列ですが、費用は括弧づけで変わります。
を 行列(。 は正の整数)とする。()の括弧づけを、 のときは そのもの、 のときはある ()と の括弧づけ 、 の括弧づけ の組 と再帰的に定め、その費用を
と定める。 の括弧づけの費用の最小値を とする。
- であり、 のとき
- すべての と、費用 を達成する括弧づけは、 時間・ 領域で求まる。
証明の方針は、最後の積を作る直前の分割点 で場合分けすることです。1 は、左右それぞれが最適な括弧づけであるほど全体の費用が小さいことから漸化式を導きます。2 は、区間の短いものから順に表を埋めれば、各 の計算に必要な値がすでに表にあることから、時間と領域を見積もります。
証明(定理 5.2)
費用の定義の第 3 項について。 は括弧づけによらず 行列、 は 行列なので、最後の積の費用は で、, の選び方によりません。
1. は定義です。 とし、右辺を とおきます。
()費用 を達成する括弧づけを 、その分割点を とします。 は の括弧づけの 1 つなので 、同様に です。よって です。
() を任意にとり、費用 の括弧づけ と費用 の括弧づけ をとります。 は の括弧づけで、費用は です。 は最小値なのでこれ以下で、 は任意だったので です。
この式は、区間 の最適値が、より短い区間の最適値だけから決まることを示しています。問題の族 は最適部分構造(定義 5.1[動的計画法])をもちます。
2. 、 とし、()の依存先を , ()とします。依存先の区間の長さ , はどちらも より小さいので、依存グラフの辺は区間の長さが真に増える向きだけに伸び、閉路はありません。よって は部分問題族(定義 3.1[動的計画法])です。 で、依存先の値が表にあるとき、1 の右辺の評価は 個の候補それぞれに定数回の演算なので ( は定数)です。動的計画法の計算量の定理(定理 3.2[動的計画法])から、全体は
時間(命題 3.4[計算量と O 記法])、 領域です。
括弧づけを得るには、1 の最小値を与える を として同じ表に記録しておきます(領域は のまま)。、()と再帰的に組み立てると、1 の()の証明のとおり費用 の括弧づけになります(区間の長さについての帰納法)。再帰の呼び出しは区間ごとに 1 回で、現れる区間は 個(葉 個と分割 個)なので、 時間で済みます。
例 5.3(漸化式の最適値と を照合する)
6 個の行列(大きさ , , , , , )の鎖について、漸化式で最小の乗算回数を求め、np.einsum_path が選ぶ縮約順序と比べます。
# Python 3.9.6 / NumPy 2.0.2(arm64)import numpy as np
def matrix_chain(p): # p[0..n]: A_i は p[i-1] × p[i] n = len(p) - 1 m = [[0] * (n + 1) for _ in range(n + 1)] cut = [[0] * (n + 1) for _ in range(n + 1)] for length in range(2, n + 1): # 区間の長さの小さい順 for i in range(1, n - length + 2): j = i + length - 1 m[i][j], cut[i][j] = min( (m[i][k] + m[k + 1][j] + p[i - 1] * p[k] * p[j], k) for k in range(i, j)) def paren(i, j): if i == j: return f"A{i}" k = cut[i][j] return f"({paren(i, k)}{paren(k + 1, j)})" return m[1][n], paren(1, n)
def left_to_right(p): # ((A1 A2) A3) ... の乗算回数 return sum(p[0] * p[k] * p[k + 1] for k in range(1, len(p) - 1))
p = [40, 8, 30, 5, 60, 12, 25]best, expr = matrix_chain(p)print("最小の乗算回数", best, expr)print("左から順に掛ける", left_to_right(p))
ops = [np.empty((p[i], p[i + 1])) for i in range(len(p) - 1)]path, text = np.einsum_path("ab,bc,cd,de,ef,fg->ag", *ops, optimize="optimal")print(path)for line in text.splitlines(): if "FLOP" in line or "intermediate" in line: print(line.strip())最小の乗算回数 12900 ((A1(A2A3))((A4A5)A6))左から順に掛ける 68400['einsum_path', (1, 2), (0, 4), (0, 1), (0, 2), (0, 1)]Naive FLOP count: 5.184e+09Optimized FLOP count: 2.580e+04Largest intermediate: 1.000e+03 elements検算. (a) 手計算: 得られた括弧づけの費用は、 が 、 が 、 が 、 が 、最後の積が で、合計 です。(b) 別手法: 6 個の行列の括弧づけは 42 通りで、全部を列挙して費用を計算すると最小は 、最大は でした(別のスクリプトで確認)。(c) NumPy との照合: einsum_path の順序は、被演算子の番号を読み解くと「、それと 、、それと 、最後に 2 つ」で、漸化式の括弧づけと同じです。NumPy の FLOP 数 は乗算と加算を 1 回ずつ数えるので、乗算回数 のちょうど 2 倍です。定義どおりの 7 重の和(Naive)は で、縮約順序を選ぶだけで 5 桁減ります。
5.3. 順序で指数が変わる
Section titled “5.3. 順序で指数が変わる”行列の鎖では、どの括弧づけでも中間結果は行列でした。一般のテンソルの縮約では、順序によって中間結果の軸の本数が増え、演算量の指数が変わります。
例 5.4(3 つの配列で n⁴ と n³)
行列 について を、2 つずつの縮約で計算します("ij,jk,kl->il")。
- と を先に縮約する。 の費用は、現れる名前 の長さの積で 。次に が 。合計 回の乗算で、中間結果の要素数は です。
- と を先に縮約する。 と は共通の名前をもたないので、縮約は外積 になり、費用は 。次に が 。合計 回の乗算で、中間結果の要素数は です。
なら、前者は 回・中間結果 要素、後者は 回・ 要素(binary64 で約 20 MB)です。手元の NumPy 2.0.2 の np.einsum_path に 2 つの順序を指定すると、中間結果の最大の要素数はそれぞれ , と表示されます。np.einsum は既定では縮約順序の最適化をしない(optimize=False)ので、3 個以上の配列を渡すときは optimize=True を付けるか、自分で 2 つずつに分けて書きます。
注意 5.5(一般の縮約順序の最適化は NP 困難(文献))
行列の鎖では最適な順序が 時間で求まりましたが、一般のテンソルネットワークについて費用が最小の縮約順序を求める問題は NP 困難であることが知られています(Lam, Sadayappan, Wenger の 1997 年の論文。参考文献)。実用上は、配列が少なければ全探索、多ければ貪欲法などの発見的方法が使われます(Pfeifer ほかの論文に探索法の整理があります)。この記事の命題と証明は、この事実を使いません。
6. ベクトル化の費用
Section titled “6. ベクトル化の費用”「ベクトル化すると速くなる」のは、要素ごとの演算の回数が減るからではありません。
命題 6.1(ベクトル化は演算の回数を変えず、作業領域を変える)✓
を の配列( 個の点の座標)とし、全対の距離の 2 乗 を次の 3 通りで計算する。
- (a) 二重ループ: についてのループの中で、 について差・2 乗・足し込みをスカラーで行う。
- (b) ブロードキャスト:
((X[:, None, :] - X[None, :, :]) ** 2).sum(axis=2)。 - (b′) ブロック化: 行を 個ずつ()に分け、各ブロック
X[s:s+b]について (b) と同じ式を計算する。
このとき、
- 3 通りとも、引き算を 回、乗算を 回、足し算を 回行う。RAM モデルでの計算時間はどれも である。
- 入力と出力 のほかに使う作業領域は、(a) が 、(b) が 要素の一時配列、(b′) が高々 要素の一時配列である。
証明(命題 6.1)
1. (a) は の 通りについて引き算と 2 乗(乗算)を 1 回ずつ行い、各 で 個の値を足し合わせるのに 回足します。(b) では、X[:, None, :] の shape は 、X[None, :, :] は で、結合は です(定義 3.1)。引き算の ufunc はこの shape の各要素について 1 回、2 乗もそれぞれ 1 回で 回ずつ、第 3 軸に沿った和の縮約は 個の出力それぞれに 回の足し算です。(b′) は、行数 ()のブロックごとに shape で同じ計算をするので、引き算と乗算は 回ずつ、足し算は 回です。
どの方法でも、基本演算(定義 2.1[計算量と O 記法])の回数は、上の算術演算と、それに比例する回数の読み書き・添字の計算の和です。これは の定数倍以上・定数倍以下なので です(定義 3.1[計算量と O 記法])。
2. (a) が使うのは、ループ変数と足し込み用のスカラーだけです。(b) の引き算の結果は shape の新しい配列で、 要素です。(b′) の一時配列は shape で、 から高々 要素です。ブロックの計算が終われば一時配列は不要になるので、同時に必要な量はこれで足ります。
ベクトル化で速くなるのは、演算 1 回あたりの定数が小さくなるからです。Python のループは 1 回の演算ごとにインタプリタの処理(型の判定、オブジェクトの生成)をはさみますが、ufunc はその部分をコンパイル済みのループで行います。次数は同じなので、 を 2 倍にしたときの時間の伸び方は変わりません。
一方、作業領域は次数が変わります。, の全対の差は 要素、binary64 で GB で、普通の計算機には載りません。(b′) で とすれば 要素( MB)で済み、演算の回数は 1 のとおり同じです。「1 行で書ける」ことと「メモリに載る」ことは別の問題です。ブロックの大きさは、キャッシュの大きさ(計算機アーキテクチャ)にも関係しますが、ここでは立ち入りません。
例 6.2(距離行列の 3 通りの計算と桁落ち)
全対の距離には、もう 1 つよく使われる式があります。 を使って、Gram 行列 から計算する方法 (c) です。行列の積は高度に最適化されたライブラリが担うので速く、一時配列も 要素で済みます。ところがこの式は、近い 2 点について大きな数どうしの引き算になります。原点から遠く(座標が 前後)、互いに近い 300 個の点で比べます。
# Python 3.9.6 / NumPy 2.0.2(arm64)/乱数種 20261008import numpy as np
rng = np.random.default_rng(20261008)n, d = 300, 3X = 1000.0 + rng.random((n, d)) # 原点から遠く、互いに近い点X[1] = X[0] + 1e-7 # 0 番と 1 番はとくに近い
def loops(X): # (a) 二重ループ D = np.empty((n, n)) for i in range(n): for j in range(n): D[i, j] = ((X[i] - X[j]) ** 2).sum() return D
def broadcast(X): # (b) ブロードキャスト(一時配列 n×n×d) return ((X[:, None, :] - X[None, :, :]) ** 2).sum(axis=2)
def blocked(X, b=64): # (b') 行を b 個ずつ(一時配列 b×n×d) D = np.empty((n, n)) for s in range(0, n, b): D[s:s + b] = ((X[s:s + b, None, :] - X[None, :, :]) ** 2).sum(axis=2) return D
def gram(X): # (c) |x|^2 + |y|^2 - 2 x·y sq = (X ** 2).sum(axis=1) return sq[:, None] + sq[None, :] - 2 * (X @ X.T)
Da, Db, Dbb, Dc = loops(X), broadcast(X), blocked(X), gram(X)print("(a)=(b):", np.array_equal(Da, Db), " (b)=(b'):", np.array_equal(Db, Dbb))print("(c) の負の成分の数", int((Dc < 0).sum()), " 最小値", f"{Dc.min():.3e}")print("(c) の対角の最大絶対値", f"{np.abs(np.diag(Dc)).max():.3e}")print("D[0,1]: (b)", f"{Db[0, 1]:.6e}", " (c)", f"{Dc[0, 1]:.6e}")u = 2.0 ** -53print("|(c)-(b)| の最大", f"{np.abs(Dc - Db).max():.3e}", " u·(|x|^2+|y|^2+2|x·y|) の目安", f"{u * 4 * (X**2).sum(axis=1).max():.3e}")print("一時配列の要素数: (b)", n * n * d, " (b')", 64 * n * d)(a)=(b): True (b)=(b'): True(c) の負の成分の数 30 最小値 -9.313e-10(c) の対角の最大絶対値 9.313e-10D[0,1]: (b) 2.999998e-14 (c) 0.000000e+00|(c)-(b)| の最大 1.737e-09 u·(|x|^2+|y|^2+2|x·y|) の目安 1.335e-09一時配列の要素数: (b) 270000 (b') 57600検算. (a) 別手法: 二重ループ・ブロードキャスト・ブロック化の 3 つは、同じ演算を同じ順序で行うのでビット単位で一致しました。(b) 既知値: 0 番と 1 番の点の距離の 2 乗は で、(b) の値はこれと 6 桁一致します(残りの違いは、 の近くで を足したときの丸めです)。(c) の値は で、1 桁も合っていません。(c) 誤差の大きさ: (c) では と の差をとります。それぞれが相対 程度の丸め誤差を含むので、桁落ちの評価(命題 4.2[浮動小数点数と丸め誤差])から、差の絶対誤差は 程度になりえます。実際の (b) との差の最大 はこの大きさで、距離の 2 乗が より小さい対では符号すら信用できず、負の値が 30 個出ています。このまま平方根をとると nan になります。
対策は、引き算の前に誤差の入らない形にすることです。差を直接とる (b) か (b′) を使うか、(c) を使うなら先に重心を引いて を小さくします。負の値を に切り上げるだけでは、符号が直るだけで桁は戻りません。
7. 固定幅の整数
Section titled “7. 固定幅の整数”NumPy の整数は Python の int(桁数に制限がない)と違い、決まったビット数で表されます。
を整数、 とする。整数 に対し、(定義 3.1[合同式とフェルマーの小定理])を満たす を と書く。符号つき ビット整数の加算・減算・乗算を、 に対し
と定める。int32 は 、int64 は である。
がただ 1 つ定まることは、次の命題の 1 で示します。 は剰余環 (定義 4.1[合同式とフェルマーの小定理])の各剰余類から代表を 1 つずつ選んだもので、 はこの環の演算を代表元で書いたものです。
命題 7.2(固定幅整数の演算は 2ʷ を法として閉じる)✓
- 任意の整数 に対し、 となる がただ 1 つ存在する。 となるのは のとき、そのときに限る。
- について、 となるのは真の和 が に属するとき、そのときに限る。 と 、 と についても同じである。
- の元を変数に代入し、 だけからなる式を で計算した結果は、同じ式を整数として計算した真の値 に対する に等しい。とくに、最終的な真の値が に属していれば、途中の値が を出ていても、結果は正しい。
証明(命題 7.2)
1. 存在: とおくと なので、 は を満たし、、 です。一意性: がともに と合同なら、 です。一方 なので です。後半: なら 自身が条件を満たす の元なので、一意性から です。逆に なら、 から です。
2. に 1 の後半を として適用します。, も , として同じです。
3. 式の構造についての帰納法で、「計算結果 は真の値 と を法として合同で、」を示します。変数では です。式が ()で、部分式の計算結果と真の値が ()を満たすとします。合同式の四則(命題 3.3[合同式とフェルマーの小定理])から です。計算結果は で、定義から かつ です。式全体について , なので、1 の一意性から です。 なら 1 の後半から です。
2 が言うのは、あふれは真の値が範囲を出たときにだけ起き、そのとき結果は誤差を含む近似値ではなく、 の整数倍だけずれたまったく別の数になる、ということです。浮動小数点数のあふれは inf になって目に見えますが、整数のあふれは見た目がもっともらしい数になるので気づきにくくなります。
例 7.3(21! と int32 の和)
なので、int64 の階乗は で初めて を出ます。
# Python 3.9.6 / NumPy 2.0.2(arm64)import mathimport numpy as np
def wrap(v, w): # 2^w を法とする代表元を [-2^(w-1), 2^(w-1)) にとる return (v + 2 ** (w - 1)) % 2 ** w - 2 ** (w - 1)
f = np.cumprod(np.arange(1, 23, dtype=np.int64)) # 1!, 2!, ..., 22!for k in (20, 21, 22): true = math.factorial(k) print(k, int(f[k - 1]), int(f[k - 1]) == true, int(f[k - 1]) == wrap(true, 64))
a = np.full(3, 2 * 10**9, dtype=np.int32) # 真の和は 6·10^9 > 2^31 - 1print(a.sum(dtype=np.int32), wrap(6 * 10**9, 32)) # int32 のまま足すprint(a.sum(), a.sum().dtype) # 既定では int64 に上げて足すprint(a + a, (a + a).dtype) # 要素ごとの和は int32 のままc = np.full(3, 50000, dtype=np.int32) # 真の内積は 7.5·10^9print(c @ c, wrap(75 * 10**8, 32)) # 内積も int32 のままprint(c.astype(np.int64) @ c.astype(np.int64)) # 先に int64 にするb = np.array([2**31 - 1, 1, -5], dtype=np.int32) # 途中であふれるが真の和は範囲内print(b.sum(dtype=np.int32), 2**31 - 1 + 1 - 5)20 2432902008176640000 True True21 -4249290049419214848 False True22 -1250660718674968576 False True1705032704 17050327046000000000 int64[-294967296 -294967296 -294967296] int32-1089934592 -108993459275000000002147483643 2147483643検算. (a) 既知値: math.factorial は Python の整数で計算した真の値です。 は一致し、, は一致しません。(b) 命題との照合: あふれた値は、真の値を または を法として に戻した wrap の値と、階乗・和・内積のすべてで一致します(命題 7.2 の 3)。最後の行は、途中の和 が を出ても、真の和 が範囲内なので正しい結果になる例です。(c) 既定の型: この環境の np.sum は、int32 の配列を既定で int64 に上げてから足すので を正しく返します。しかし要素ごとの和 a + a と内積 c @ c は int32 のまま計算し、警告なしに負の数を返します。和・積・内積の結果が を超えうるときは、先に astype(np.int64) で広げるか、dtype=np.int64 を指定します。int64 でも を超えれば同じことが起きるので、階乗や二項係数のように急に大きくなる量は Python の int か浮動小数点数で計算します。
8. 実務の注意
Section titled “8. 実務の注意”ここまでの命題にもとづく注意を、型の昇格とあわせてまとめます。
| 落とし穴 | 何が起きるか | 対処 |
|---|---|---|
float32 の混入 | float32 の配列だけからなる演算の結果は float32 のままで、単位丸めは 。ファイルから読んだ配列が float32 だと、以後の計算の精度が 7 桁になる | 読み込んだ直後に dtype を確かめ、必要なら astype(np.float64) |
| 型の昇格の規則 | 手元の NumPy 2.0.2 では、float32 の配列に Python の 2.0 を掛けると float32、np.float64(2.0) を掛けると float64、int32 や int64 の配列と float32 の配列の和は float64 になる(NumPy 2 の規則。1.x とは一部異なる) | 結果の dtype を仮定せず確かめる。版を記録する |
| ビューを通じた書き換え | スライス・転置はビューなので、書き込むと元の配列が変わる(命題 2.3) | 独立な配列が要るなら .copy() |
| マスク・整数配列による添字 | コピーを返す(例 2.4)。a[mask][0] = 1 は一時的なコピーに書くだけで a は変わらない。a[mask] = 1 は a に書く | 代入は 1 回の添字で書く |
| 重複する添字への足し込み | d[idx] += 1 は重複を 1 回しか数えない(4 節) | np.add.at か np.bincount |
| 書き換えたばかりの値を読むループのベクトル化 | 結果が変わる(命題 4.2) | np.cumsum などの専用の関数を使う |
| 一時配列 | ブロードキャストで 要素の配列ができる(命題 6.1) | ブロック化する |
| メモリ配置と走査の向き | C 順の配列を列方向に走査すると、番地が ずつ飛び、キャッシュに載りにくい | 最後の軸を内側のループにする。必要なら np.ascontiguousarray |
配列の型・shape・連続性は、コードの中で仮定している箇所に assert a.dtype == np.float64 のように書いておくと、仮定が破れたときにその場で止まります。数値の精度をどう選ぶかは、次の精度の選び方で扱います。
(1) shape が , , (0 次元)の 3 つの配列の和の shape を、定義 3.1 に従って求めよ。(2) shape と の配列は足せるか。足せない場合、片方をどう変形すれば足せるか。
解答
(1) 3 次元にそろえると , , です。成分ごとの結合は 、、 で、 です。
(2) 第 1 成分が なので両立しません。 の配列を転置すれば になり、足せます(要素の対応が意図どおりかは別に確かめます)。あるいは、すべての組合せの和(shape )が欲しいのなら、a[:, :, None, None] + b[None, None, :, :] のように長さ 1 の軸を足します。 と の結合は です。
演習 9.2標準
a = np.arange(24, dtype=np.int32).reshape(2, 3, 4)(C 順)とする。b = a[:, ::-1, 1::2].transpose(2, 0, 1) の shape・strides・offset を 命題 2.3 から求め、b[1, 0, 2] の値を番地の式から計算せよ。
解答
で、a の strides は 、offset は です。
第 2 軸の逆順(, , )で stride が 、offset が になります。第 3 軸の 1::2(, , )で stride が 、offset が になります。ここまでで shape 、strides 、offset です。transpose(2, 0, 1) は、新しい第 1, 2, 3 軸を元の第 3, 1, 2 軸にするので、shape 、strides 、offset です。
b[1, 0, 2] の番地は です。a は int32 の C 順で、番地 は先頭から 番目の要素、値は です。確かめると、b[1, 0, 2] はスライス後の配列の添字 、すなわち a[0, 2-2, 1+2·1] = a[0, 0, 3] = 3 です。NumPy でも同じ shape・strides・値が得られます。
演習 9.3標準
の大きさを , , , とする。定理 5.2 の漸化式で と最適な括弧づけを求め、左から順に掛けた場合の乗算回数と比べよ。
解答
です。長さ 2 の区間: 、、。
長さ 3 の区間: ()。()。
長さ 4: ()。
最適な括弧づけは で 回です。左から順に掛けると 回で、約 6 倍です。細い行列 ()を最後に掛けると、中間結果が小さいまま進むからです。
演習 9.4標準
int8(, )で次を計算した結果を 命題 7.2 から求めよ。(1) 。(2) 。(3) 絶対値が 未満の整数 個の和を int64 で計算するとき、あふれが起きないことを保証できる の範囲。
解答
(1) 真の和 は の外です。 なので です。
(2) 命題の 3 から、結果は真の値 に対する で、 なので です。途中の は誤っていますが、最終結果は正しくなります。実際 です。
(3) 和の絶対値は 未満です。、すなわち なら、和は より大きく より小さいので に属し、命題の 3 から(途中の部分和も同じ評価で範囲内ですが、仮に出たとしても)結果は正しいです。
- C. R. Harris ほか, “Array programming with NumPy”, Nature 585 (2020), 357–362. doi:10.1038/s41586-020-2649-2 — 配列の構造(データ・shape・strides)、ビュー、ブロードキャストの概説。
- S. van der Walt, S. C. Colbert, G. Varoquaux, “The NumPy array: a structure for efficient numerical computation”, Computing in Science & Engineering 13 (2011), 22–30. doi:10.1109/MCSE.2011.37
- NumPy 公式ドキュメント, Broadcasting、Copies and views、NEP 50 — Promotion rules for Python scalars。
- T. H. Cormen, C. E. Leiserson, R. L. Rivest, C. Stein, Introduction to Algorithms, 3rd ed., MIT Press, 2009 — 動的計画法の章(行列の鎖の積)。
- C.-C. Lam, P. Sadayappan, R. Wenger, “On optimizing a class of multi-dimensional loops with reductions for parallel execution”, Parallel Processing Letters 7 (1997) — 縮約順序の最適化の計算困難性。
- R. N. C. Pfeifer, J. Haegeman, F. Verstraete, “Faster identification of optimal contraction sequences for tensor networks”, Physical Review E 90 (2014), 033315. doi:10.1103/PhysRevE.90.033315
執筆: Claude Code(Opus 5.5)、Claude Code(Sonnet 5.5)・監査 2 回(Claude Code)・最終 2026-10-12
この記事の誤りを報告する ・運営: 夢現技研合同会社 ・料金 ・利用条件 ・特定商取引法に基づく表記
© 2026 夢現技研合同会社 ・コード例は MIT ライセンスです。