コンテンツにスキップ

NumPy で書く配列計算:ブロードキャスト・メモリ配置・縮約の順序

対象読者: 学部 1〜2 年の線形代数(行列の積)と Python の基本、本テーマ第 1 章の再現性の考え方、計算量と O 記法を学んだ人。NumPy の経験は仮定せず、メモリ配置の知識も仮定しません。

前提となる記事:再現できる数値実験:乱数種・環境・設定の記録と決定性、計算量と O 記法:アルゴリズムの速さを入力サイズの関数で測る

生 Markdown

NumPy で配列どうしを足したり、配列の一部を取り出したりするとき、中でどんなことが起きているかを知らなくても、たいていは動きます。けれども「形のちがう配列がなぜ足せるのか」「取り出した配列に書き込むと元が変わることがあるのはなぜか」「整数の計算結果がなぜ負になるのか」といった疑問には、仕組みを知らないと答えられません。この記事は、配列を「メモリ上の並びと、その読み方の規則」として書き下し、これらの疑問に規則から答えられるようにします。

  • NumPy の配列は「データ領域・要素の型・shape・strides・offset」の組です。添字 (i1,…,id)(i_1,\ldots,i_d) の要素の番地は offset+∑kik stridesk\text{offset}+\sum_ki_k\,\text{strides}_k で、転置・スライス・逆順・軸の追加はデータを複製せず、この式の係数を書き換えるだけです(ビュー)。strides は、各軸で添字を 1 つ進めると番地がいくつ増えるかを軸ごとに並べた組です。
  • ブロードキャストは、shape を右詰めにそろえ、各軸で「長さが等しいか一方が 1」のときに両立するという規則です。両立する shape の結合は可換・結合的・冪等(同じものどうしを結合しても変わらない)なので、何個の配列をどの順に組み合わせても結果の shape は同じです。
  • 出力と入力が同じ番地を共有する更新(a[1:] += a[:-1])は、要素を 1 つずつ書き換える実行と、いったん全部を計算してから書く実行で結果が違います。番地が交わらなければ、どちらでも同じです。
  • 行列の鎖 A1⋯AnA_1\cdots A_n の積は、括弧づけで乗算回数が大きく変わります。最小の回数は区間についての漸化式で O(n3)O(n^3) 時間で求まります。一般のテンソルの縮約では、順序で演算量の指数が変わります。
  • ベクトル化は要素ごとの演算の回数を変えません。変わるのは 1 回あたりの定数と、一時配列の大きさです。全対の距離行列は一時配列が n2dn^2d 個になり、ブロック化で nbdnbd 個に抑えられます。
  • 固定幅の整数の加減乗は 2w2^w を法とする演算です。真の値が表現範囲に入っていれば結果は正しく、出ていれば黙って別の数になります。

数値実験のコードの大半は、配列に対する演算です。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 記法])で数え、OO 記法は 定義 3.1[計算量と O 記法] のものです。コード例の約束事(版と乱数種の明記)は再現できる数値実験に従います。

計算機の記憶装置は、番地で指定するセルの列です(定義 6.1[コンピュータアーキテクチャとCPUの構造]、定義 2.1[計算量と O 記法])。多次元の配列は、この 1 次元の列の上に、添字から番地への対応を決めることで作ります。

定義 2.1(多次元配列)↗

多次元配列(dd 次元)とは、次の組である。

  • データ領域: 連続した番地 0,1,…,B−10,1,\ldots,B-1 のバイトの列。
  • dtype: 1 要素のバイト数 ww と、ww バイトを値(整数・浮動小数点数など)として読む方法。
  • shape: 00 以上の整数の組 (n1,…,nd)(n_1,\ldots,n_d)。nkn_k を第 kk 軸の長さという。
  • strides: 整数の組 (s1,…,sd)(s_1,\ldots,s_d)。
  • offset: 整数 oo。

添字 (i1,…,id)(i_1,\ldots,i_d)(0≤ik<nk0\le i_k\lt n_k)の要素は、番地

addr(i1,…,id)=o+∑k=1dik sk\mathrm{addr}(i_1,\ldots,i_d)=o+\sum_{k=1}^di_k\,s_k

から始まる ww バイトである(これがデータ領域に収まるものとする)。

sk=w∏l>knls_k=w\prod_{l\gt k}n_l のとき C 順(行優先。最後の添字が最も速く動く)、sk=w∏l<knls_k=w\prod_{l\lt k}n_l のとき Fortran 順(列優先)であるといい、どちらかであるとき配列は連続であるという(このとき要素は、番地 oo から ww バイトずつ隙間なく並ぶ)。

定義 2.2(ビューとコピー)↗

配列 aa から作った配列 bb が、aa と同じデータ領域を使い、shape・strides・offset だけが異なるとき、bb を aa のビューという。新しいデータ領域を確保して値を書き写したものをコピーという。

たとえば 3×4 の int64(w=8w=8)の C 順では、s=(8⋅4, 8)=(32,8)s=(8\cdot4,\,8)=(32,8) です。行を 1 つ進むと 1 行分の 4 要素(32 バイト)飛び、列を 1 つ進むと 1 要素(8 バイト)進みます。下の図の a がこの配列です。

ビューを通じて書き込むと、元の配列の対応する要素も変わります。同じ番地だからです。

01234567891011番地 032648a[0,0] → a[0,1]:+8  a.T[0,0] → a.T[1,0]:+8a[0,0] → a[1,0]:+32  a.T[0,0] → a.T[0,1]:+32
3×4 の int64 配列(1 要素 8 バイト)のデータ領域と、2 つの読み方。a は strides (32, 8) で、列を 1 つ進むと番地が 8、行を 1 つ進むと 32 増える。転置 a.T は同じデータ領域を strides (8, 32) で読む。

命題 2.3(転置・スライス・逆順・軸の追加は複製なしで表せる)✓

配列 aa の shape を (n1,…,nd)(n_1,\ldots,n_d)、strides を (s1,…,sd)(s_1,\ldots,s_d)、offset を oo とする。次の各操作の結果 bb は、aa と同じデータ領域の上で、右に示す shape・strides・offset をもつ配列として表せる。すなわち、bb の各要素の番地は、対応する aa の要素の番地に等しい。

  1. 転置(軸の置換 π\pi。b[j1,…,jd]=a[i1,…,id]b[j_1,\ldots,j_d]=a[i_1,\ldots,i_d], iπ(k)=jki_{\pi(k)}=j_k): shape (nπ(1),…,nπ(d))(n_{\pi(1)},\ldots,n_{\pi(d)})、strides (sπ(1),…,sπ(d))(s_{\pi(1)},\ldots,s_{\pi(d)})、offset oo。
  2. スライス(第 kk 軸で始点 α\alpha、歩幅 t≠0t\ne0、個数 mm。0≤j<m0\le j\lt m で 0≤α+jt<nk0\le\alpha+jt\lt n_k とし、b[…,j,…]=a[…,α+jt,…]b[\ldots,j,\ldots]=a[\ldots,\alpha+jt,\ldots]): 第 kk 軸の長さを mm、stride を tskts_k に変え、offset を o+αsko+\alpha s_k にする。逆順は α=nk−1\alpha=n_k-1, t=−1t=-1, m=nkm=n_k の場合である。
  3. 整数による添字(第 kk 軸を α\alpha に固定。bb は d−1d-1 次元): 第 kk 軸を shape と strides から取り除き、offset を o+αsko+\alpha s_k にする。
  4. 軸の追加(長さ 11 の軸を挿入。None や np.newaxis): その位置に長さ 11、stride 00 を挿入する。offset は oo。
  5. 長さ 1 の軸の引き伸ばし(第 kk 軸が nk=1n_k=1 のとき、長さ mm にして b[…,j,…]=a[…,0,…]b[\ldots,j,\ldots]=a[\ldots,0,\ldots]): 第 kk 軸の長さを mm、stride を 00 にする。offset は oo。
証明(命題 2.3)

どれも、bb の番地の式(定義 2.1)に右の値を代入して、対応する aa の要素の番地と一致することを確かめます。

  1. bb の添字 (j1,…,jd)(j_1,\ldots,j_d) の番地は o+∑kjk sπ(k)o+\sum_kj_k\,s_{\pi(k)} です。jk=iπ(k)j_k=i_{\pi(k)} を代入すると o+∑kiπ(k)sπ(k)o+\sum_ki_{\pi(k)}s_{\pi(k)} で、π\pi は {1,…,d}\{1,\ldots,d\} の置換なので、和の添字を l=π(k)l=\pi(k) に取り替えて o+∑lilsl=addra(i1,…,id)o+\sum_li_ls_l=\mathrm{addr}_a(i_1,\ldots,i_d) です。
  2. bb の番地は (o+αsk)+j⋅tsk+∑l≠kilsl=o+(α+jt)sk+∑l≠kilsl(o+\alpha s_k)+j\cdot ts_k+\sum_{l\ne k}i_ls_l=o+(\alpha+jt)s_k+\sum_{l\ne k}i_ls_l で、これは aa の第 kk 添字を α+jt\alpha+jt とした要素の番地です。逆順では α+jt=nk−1−j\alpha+jt=n_k-1-j で、j=0,…,nk−1j=0,\ldots,n_k-1 のとき 0≤nk−1−j<nk0\le n_k-1-j\lt n_k を満たします。
  3. bb の番地は (o+αsk)+∑l≠kilsl(o+\alpha s_k)+\sum_{l\ne k}i_ls_l で、aa の第 kk 添字を α\alpha とした要素の番地です。
  4. 挿入した軸の添字は 00 しかとらないので、その項 0⋅00\cdot0 は和に寄与せず、番地は aa の対応する要素と同じです。
  5. bb の番地は o+j⋅0+∑l≠kilslo+j\cdot0+\sum_{l\ne k}i_ls_l で、jj によりません。これは aa の第 kk 添字を 00 とした要素の番地 o+0⋅sk+∑l≠kilslo+0\cdot s_k+\sum_{l\ne k}i_ls_l に等しいです。

5 は次の節のブロードキャストの実装そのものです。長さ 1 の軸を長さ mm に「複製」するとき、NumPy はデータを書き写さず、stride を 00 にして同じ要素を mm 回読みます。

例 2.4(strides を表示してビューを確かめる)

3×4 の int64 配列(C 順、w=8w=8)について、いくつかの操作の結果の 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) # 連続な配列の reshape
r2 = a.T.reshape(12) # 連続でないビューの reshape
print(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 は (w⋅4,w)=(32,8)(w\cdot4,w)=(32,8)。転置は strides の入れ替え(命題 2.3 の 1)、a[1:, ::2] は第 1 軸が α=1,t=1\alpha=1,t=1、第 2 軸が α=0,t=2\alpha=0,t=2 で strides (32,16)(32,16)、offset 1⋅32=321\cdot32=32(同 2)、逆順は stride −32-32、offset (3−1)⋅32=64(3-1)\cdot32=64、a[:, None] は stride 00 の挿入(同 4)、a[2] は offset 2⋅32=642\cdot32=64(同 3)で、すべて出力と一致します。(b) 別の確かめ方: np.shares_memory の結果は、命題が「複製なし」とした 6 つでビュー、整数配列とマスクによる添字でコピーです。後者は取り出す要素の番地が等間隔とは限らないので、1 組の strides では表せません。(c) reshape: 連続な a の reshape(12) はビュー、連続でない a.T の reshape(12) はコピーです。理由は次の例で示します。

例 2.5(reshape がコピーを要する場合)

上の例の a.T.reshape(12) は、要素を 0,4,8,1,5,9,…0,4,8,1,5,9,\ldots の順に並べた 1 次元配列です。これらの要素の番地(元のデータ領域での)は 0,32,64,8,40,72,…0,32,64,8,40,72,\ldots です。1 次元の配列の番地は o′+js′o'+js'(j=0,1,2,…j=0,1,2,\ldots)という等差数列でなければなりません(定義 2.1)。ところが 0,32,64,80,32,64,8 は、はじめの 2 つの差が 3232、3 番目と 4 番目の差が −56-56 で等差数列ではないので、どんな (o′,s′)(o',s') を選んでも同じデータ領域の上のビューとしては表せません。NumPy は新しいデータ領域に書き写します。

実務上の帰結は、reshape の結果に書き込んだとき、元の配列が変わるかどうかが元の配列の strides によることです。書き込みを元に反映させたいのか、させたくないのかが大事な場面では、np.shares_memory で確かめるか、.copy() で明示的にコピーを作ります。

定義 3.1(ブロードキャスト)↗

L={0,1,2,…}∪{⊤}L=\{0,1,2,\ldots\}\cup\{\top\} とする(⊤\top は「両立しない」を表す記号)。a,b∈La,b\in L の結合 a∨b∈La\vee b\in L を

a∨b={a(a=b または b=1)b(a=1)⊤(それ以外)a\vee b=\begin{cases}a&(a=b\ \text{または}\ b=1)\\ b&(a=1)\\ \top&(\text{それ以外})\end{cases}

と定める。00 以上の整数 a,ba,b が a∨b≠⊤a\vee b\ne\top を満たす、すなわち a=ba=b または a=1a=1 または b=1b=1 のとき、長さ aa と bb は両立するという。

shape n=(n1,…,nd)n=(n_1,\ldots,n_d) と整数 D≥dD\ge d に対し、先頭に 11 を D−dD-d 個補った (1,…,1,n1,…,nd)(1,\ldots,1,n_1,\ldots,n_d) を padD(n)\mathrm{pad}_D(n) と書く(右詰め)。2 つの shape n,n′n,n' の結合を、DD を両者の次元の大きい方として、padD(n)\mathrm{pad}_D(n) と padD(n′)\mathrm{pad}_D(n') の成分ごとの結合と定める。結合のどの成分も ⊤\top でないとき nn と n′n' は両立するといい、結合をブロードキャストした shape という。

両立する shape の配列どうしの要素ごとの演算では、それぞれの配列を、軸の追加と長さ 1 の軸の引き伸ばし(命題 2.3 の 4 と 5)によってブロードキャストした shape の配列とみなしてから、同じ添字の要素どうしを演算する。

長さが正のとき、両立する a,ba,b の結合は max⁡(a,b)\max(a,b) です。長さ 00 の軸も許すので(空の配列)、「大きい方」ではなく「11 でない方」と定義しています。0∨1=00\vee1=0 で、NumPy も同じ結果を返します。

命題 3.2(ブロードキャストの結合は半束をなす)✓

  1. 定義 3.1 の ∨\vee は LL の上で可換(a∨b=b∨aa\vee b=b\vee a)、結合的((a∨b)∨c=a∨(b∨c)(a\vee b)\vee c=a\vee(b\vee c))、冪等(a∨a=aa\vee a=a)で、11 が単位元(a∨1=aa\vee1=a)である。
  2. shape n(1),…,n(m)n^{(1)},\ldots,n^{(m)} を、どんな順序・どんな括弧づけで 2 つずつ結合していっても、結果(⊤\top を含む場合も含めて)は同じである。したがって、複数の配列のブロードキャストが成功するかどうかも、成功したときの shape も、演算を適用する順序によらない。
  3. 両立するという関係は推移的でない。shape (2,)(2,) と (1,)(1,)、(1,)(1,) と (3,)(3,) はそれぞれ両立するが、(2,)(2,) と (3,)(3,) は両立しない。

次の証明のねらいは、「結合が順序によらない」ことを、2 つずつの結合の性質(可換・結合的)に帰着させることです。そのために、まず LL に大小関係 ⪯\preceq を入れて、a∨ba\vee b が a,ba,b の上限(どちらよりも小さくない元のうち最小のもの)であることを確かめます。上限は {a,b}\{a,b\} という集合だけで決まるので、可換性と結合性はそこから出ます。反対称律・推移律の場合分けは ⪯\preceq が大小関係として振る舞うことの確認なので、細部を飛ばして「上限であること」以降から読んでも、2 の議論は追えます。

証明(命題 3.2)

1. LL の上の関係 ⪯\preceq を「a⪯ba\preceq b とは、a=ba=b または a=1a=1 または b=⊤b=\top」と定めます。これが半順序で、a∨ba\vee b が {a,b}\{a,b\} の上限(最小上界)であることを示します。

反射律は定義から成り立ちます。反対称律: a⪯ba\preceq b, b⪯ab\preceq a, a≠ba\ne b とすると、「a=1a=1 または b=⊤b=\top」かつ「b=1b=1 または a=⊤a=\top」です。4 つの組合せ a=1=ba=1=b、a=1,a=⊤a=1,a=\top、b=⊤,b=1b=\top,b=1、b=⊤=ab=\top=a は、どれも a≠ba\ne b か 1≠⊤1\ne\top に反します。よって a=ba=b です。推移律: a⪯b⪯ca\preceq b\preceq c とします。a=1a=1 か c=⊤c=\top なら a⪯ca\preceq c です。a=ba=b なら b⪯cb\preceq c から、b=cb=c なら a⪯ba\preceq b から a⪯ca\preceq c です。残るのは a≠1a\ne1, a≠ba\ne b の場合で、a⪯ba\preceq b から b=⊤b=\top です。すると b⪯cb\preceq c は「⊤=c\top=c または ⊤=1\top=1 または c=⊤c=\top」なので c=⊤c=\top となり、a⪯ca\preceq c です。

上限であること: a=ba=b なら a∨b=aa\vee b=a で、これは {a}\{a\} の上限です。a=1a=1 なら a∨b=ba\vee b=b で、1⪯b1\preceq b, b⪯bb\preceq b から bb は上界、任意の上界 cc は b⪯cb\preceq c を満たすので bb が最小です。b=1b=1 のときも同様に aa が上限です。それ以外(a≠ba\ne b, a≠1a\ne1, b≠1b\ne1)では、上界 cc は a⪯ca\preceq c から「a=ca=c または c=⊤c=\top」、b⪯cb\preceq c から「b=cb=c または c=⊤c=\top」を満たします。c≠⊤c\ne\top なら a=c=ba=c=b となり a≠ba\ne b に反するので、上界は ⊤\top だけで、上限は ⊤=a∨b\top=a\vee b です。

上限は {a,b}\{a,b\} という集合で決まるので、可換性と冪等性が出ます。a∨1=aa\vee1=a は定義です。結合性: x=(a∨b)∨cx=(a\vee b)\vee c とおきます。x⪰a∨b⪰a,bx\succeq a\vee b\succeq a,b かつ x⪰cx\succeq c なので、xx は {a,b,c}\{a,b,c\} の上界です。yy を {a,b,c}\{a,b,c\} の任意の上界とすると、yy は {a,b}\{a,b\} の上界なので y⪰a∨by\succeq a\vee b、また y⪰cy\succeq c なので、yy は {a∨b,c}\{a\vee b,c\} の上界で y⪰xy\succeq x です。よって xx は {a,b,c}\{a,b,c\} の上限です。同じ議論で a∨(b∨c)a\vee(b\vee c) も {a,b,c}\{a,b,c\} の上限で、上限は反対称律からただ 1 つなので、両者は等しいです。

2. DD を n(1),…,n(m)n^{(1)},\ldots,n^{(m)} の次元の最大値とします。2 つの shape の結合は、両者の次元の大きい方 D′≤DD'\le D にそろえて成分ごとにとりますが、これをさらに padD\mathrm{pad}_D で補ったものは、はじめから padD\mathrm{pad}_D でそろえて成分ごとに結合したものに等しいです(補った先頭の成分は 1∨1=11\vee1=1 だからです)。したがって、すべての shape を padD\mathrm{pad}_D でそろえたうえでの成分ごとの演算として考えてよく、各成分で 1 の可換性と結合性が成り立つので、結果は順序と括弧づけによりません。

成功・失敗について。最終的な結合 NN がどの成分にも ⊤\top を含まないとします。途中に現れる結合 N′N' は、一部の shape の成分ごとの上限なので、成分ごとに Nk′⪯NkN'_k\preceq N_k です。Nk≠⊤N_k\ne\top かつ Nk′⪯NkN'_k\preceq N_k なら、⪯\preceq の定義(Nk′=NkN'_k=N_k または Nk′=1N'_k=1)から Nk′≠⊤N'_k\ne\top です。よって途中のどの段階でも失敗しません。逆に NN が ⊤\top を含むなら、どの順序で進めても最後の結果は同じ NN なので、どこかの段階で ⊤\top が現れて失敗します。

3. 2∨1=22\vee1=2, 1∨3=31\vee3=3 はどちらも ⊤\top でなく、2∨3=⊤2\vee3=\top です。

3 は、「aa と bb を足せる、bb と cc を足せる」から「aa と cc を足せる」は出ないことを言っています。3 つを結合すると 2∨1∨3=⊤2\vee1\vee3=\top で、2 のとおりどの順に足しても失敗します。

例 3.3(ブロードキャストの shape を返す関数)

定義 3.1 をそのまま書いた関数を、NumPy の np.broadcast_shapes と照合します。長さ 00 を含む 11 個の shape から、重複を許して 2 個または 3 個を選ぶすべての組(112+113=145211^2+11^3=1452 通り)で比べます。

# Python 3.9.6 / NumPy 2.0.2(arm64)
import itertools
import 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 = 0
for 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 None
print("照合した組", 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)の確認にもなっています。動機で挙げた (4,1,1)(4,1,1), (2,1)(2,1), (3,)(3,) の結合は (4,2,3)(4,2,3) です。(c) 境界: 0∨1=00\vee1=0 で、長さ 00 の軸は 11 と両立して 00 になります。最後の 3 行は命題の 3 の例です。

4. 要素ごとの演算と、重なりのある更新

Section titled “4. 要素ごとの演算と、重なりのある更新”

定義 4.1(ufunc と軸に沿った縮約)↗

スカラーの関数 ff を、ブロードキャストした配列の同じ添字の要素どうしに適用して、同じ shape の配列を返す関数を ufunc(universal function)という。np.add, np.multiply, np.exp などで、a + b は np.add(a, b) である。

2 変数の ufunc ff による、第 kk 軸に沿った縮約(reduce)とは、shape (n1,…,nd)(n_1,\ldots,n_d) の配列 aa から、第 kk 軸を取り除いた shape の配列 bb を

b[i1,…,ik^,…,id]=f(⋯f(f(a[…,0,…],a[…,1,…]),a[…,2,…])⋯ )b[i_1,\ldots,\widehat{i_k},\ldots,i_d]=f(\cdots f(f(a[\ldots,0,\ldots],a[\ldots,1,\ldots]),a[\ldots,2,\ldots])\cdots)

で作る操作である( ^\widehat{\ } は取り除くことを表す)。np.add の縮約が sum、np.multiply の縮約が prod、np.maximum の縮約が max である。

浮動小数の和の縮約では、NumPy は上の式の括弧づけどおりではなく、対ごとの和に近い順序で足すことがあります。順序を変えたときの結果の差は、再現できる数値実験の 定理 3.4[再現できる数値実験] で抑えられます。

ufunc の出力先を、入力と同じデータ領域のビューにすることができます(a += b や out= の指定)。このとき、出力と入力の番地が重なると、書き込みの順序が結果に影響しえます。

命題 4.2(重なりのある更新)✓

メモリを、番地から値への写像 μ\mu とみる(1 要素を 1 つの番地で代表させる)。有限集合 JJ の各 jj に、出力の番地 ω(j)\omega(j)、入力の番地 ρ1(j),…,ρm(j)\rho_1(j),\ldots,\rho_m(j) が定まっていて、ω\omega は単射とする。関数 ff による更新の 2 通りの実行を考える。

  • 一時配列を使う実行: すべての jj について τj=f(μ(ρ1(j)),…,μ(ρm(j)))\tau_j=f(\mu(\rho_1(j)),\ldots,\mu(\rho_m(j))) を先に計算し、そのあとで μ(ω(j))\mu(\omega(j)) を τj\tau_j に書き換える。
  • 逐次実行: JJ の元をある順序 j1,j2,…j_1,j_2,\ldots に並べ、順に「その時点のメモリから ρ1(jt),…,ρm(jt)\rho_1(j_t),\ldots,\rho_m(j_t) を読み、ff の値を ω(jt)\omega(j_t) に書く」を行う。

このとき次が成り立つ。

  1. j≠j′j\ne j' ならばすべての ll で ω(j)≠ρl(j′)\omega(j)\ne\rho_l(j') である(ある要素の出力先を、別の要素の計算が読まない)とき、逐次実行の結果は順序によらず、一時配列を使う実行の結果と一致する。とくに、出力の番地の集合と入力の番地の集合が交わらないとき、および出力と入力がまったく同じ配置(ω(j)=ρ1(j)\omega(j)=\rho_1(j) で、ほかに重なりがない)のときにそうである。
  2. 長さ n≥3n\ge3 の配列 aa に対する a[1:] += a[:-1](J={1,…,n−1}J=\{1,\ldots,n-1\}, ω(j)=ρ1(j)=j\omega(j)=\rho_1(j)=j, ρ2(j)=j−1\rho_2(j)=j-1, f(x,y)=x+yf(x,y)=x+y)は 1 の条件を満たさない。一時配列を使う実行の結果は aj′=aj+aj−1a'_j=a_j+a_{j-1}、添字の小さい順の逐次実行の結果は aj′=a0+a1+⋯+aja'_j=a_0+a_1+\cdots+a_j(累積和)で、a0≠0a_0\ne0 ならば j=2j=2 で異なる。

直感的には、一時配列を使う実行は「全部読んでから書く」、逐次実行は「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. 逐次実行の tt 番目の操作の直前のメモリを μt−1\mu_{t-1}(μ0=μ\mu_0=\mu)とします。tt についての帰納法で、「μt−1\mu_{t-1} は、番地 ω(j1),…,ω(jt−1)\omega(j_1),\ldots,\omega(j_{t-1}) 以外では μ\mu に一致する」ことがわかります(書き込むのは ω(js)\omega(j_s) だけです)。tt 番目の操作が読む番地 ρl(jt)\rho_l(j_t) は、s<ts\lt t について js≠jtj_s\ne j_t なので、仮定から ω(js)\omega(j_s) のどれとも異なります。したがって読む値は μ(ρl(jt))\mu(\rho_l(j_t)) で、書く値は f(μ(ρ1(jt)),…,μ(ρm(jt)))=τjtf(\mu(\rho_1(j_t)),\ldots,\mu(\rho_m(j_t)))=\tau_{j_t} です。ω\omega は単射なので、番地 ω(jt)\omega(j_t) はほかの操作では書き換えられず、最終的な値は τjt\tau_{j_t} です。これは一時配列を使う実行の結果で、順序 j1,j2,…j_1,j_2,\ldots によりません。

出力と入力の番地の集合が交わらなければ、ω(j)≠ρl(j′)\omega(j)\ne\rho_l(j') はすべての j,j′j,j' で成り立ちます。同じ配置の場合は、ω(j)=ρ1(j)\omega(j)=\rho_1(j) で、j≠j′j\ne j' なら ω\omega の単射性から ω(j)≠ω(j′)=ρ1(j′)\omega(j)\ne\omega(j')=\rho_1(j')、ほかの入力とは重ならないので、やはり条件を満たします。

2. ω(1)=1=ρ2(2)\omega(1)=1=\rho_2(2) なので、j=1j=1, j′=2j'=2 が条件に反します。一時配列を使う実行は、元の値から τj=aj+aj−1\tau_j=a_j+a_{j-1} を作って書くので aj′=aj+aj−1a'_j=a_j+a_{j-1} です。添字の小さい順の逐次実行では、jj についての帰納法で aj′=a0+⋯+aja'_j=a_0+\cdots+a_j です。j=1j=1 は a1+a0a_1+a_0。j−1j-1 まで成り立てば、jj 番目の操作は、すでに書き換えられた番地 j−1j-1 の値 a0+⋯+aj−1a_0+\cdots+a_{j-1} と、まだ書き換えられていない番地 jj の値 aja_j を読んで足すので、aj′=a0+⋯+aja'_j=a_0+\cdots+a_j です。j=2j=2 での差は (a0+a1+a2)−(a2+a1)=a0(a_0+a_1+a_2)-(a_2+a_1)=a_0 です。

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] を取り出し(コピー)、11 を足し、d[idx] に書き戻す、という一時配列を使う実行です。番地 00 には同じ値が 2 回書かれるだけなので、d が零ベクトルなら結果は [1 0 1] で、[2 0 1] にはなりません(命題 4.2 の仮定「ω\omega は単射」が破れている場合です)。重複する添字ごとに足し込みたいときは np.add.at(d, idx, 1) を使います。こちらは [2 0 1] を返します。

定義 5.1(添字による縮約)↗

配列 T(1),…,T(m)T^{(1)},\ldots,T^{(m)} の各軸に添字の名前(文字)を付け、同じ名前の軸は同じ長さをもつとする。出力に残す名前の列を指定したとき、縮約の結果は、出力の名前の値を固定し、残りのすべての名前について和をとった

R[出力の添字]=∑出力にない添字 ∏r=1mT(r)[その配列の添字]R[\text{出力の添字}]=\sum_{\text{出力にない添字}}\ \prod_{r=1}^mT^{(r)}[\text{その配列の添字}]

である。np.einsum("ij,jk->ik", A, B) は行列の積 Rik=∑jAijBjkR_{ik}=\sum_jA_{ij}B_{jk} を表す。

m≥3m\ge3 のとき、配列を 2 つずつ縮約して中間結果に置き換えていく手順を縮約順序という。2 つの配列の縮約の費用を、定義の式どおりに計算したときのスカラーの乗算の回数、すなわち 2 つの配列に現れるすべての名前の長さの積とする。p×qp\times q 行列と q×rq\times r 行列の積の費用は pqrpqr である。

添字に名前を付ける書き方は、軸の順序を覚えなくてよい点でも有用です。np.einsum("bij,bjk->bik", A, B) は「先頭の軸 b ごとの行列の積」であることが式から読めます。

行列の積は結合的なので、A1A2⋯AnA_1A_2\cdots A_n はどう括弧を付けても同じ行列ですが、費用は括弧づけで変わります。

定理 5.2(行列の鎖の最小の乗算回数)✓

AiA_i を pi−1×pip_{i-1}\times p_i 行列(i=1,…,ni=1,\ldots,n。p0,…,pnp_0,\ldots,p_n は正の整数)とする。Ai⋯AjA_i\cdots A_j(i≤ji\le j)の括弧づけを、i=ji=j のときは AiA_i そのもの、i<ji\lt j のときはある kk(i≤k<ji\le k\lt j)と Ai⋯AkA_i\cdots A_k の括弧づけ L\mathcal{L}、Ak+1⋯AjA_{k+1}\cdots A_j の括弧づけ R\mathcal{R} の組 (L R)(\mathcal{L}\,\mathcal{R}) と再帰的に定め、その費用を

cost(Ai)=0,cost((L R))=cost(L)+cost(R)+pi−1pkpj\mathrm{cost}(A_i)=0,\qquad\mathrm{cost}\bigl((\mathcal{L}\,\mathcal{R})\bigr)=\mathrm{cost}(\mathcal{L})+\mathrm{cost}(\mathcal{R})+p_{i-1}p_kp_j

と定める。Ai⋯AjA_i\cdots A_j の括弧づけの費用の最小値を m(i,j)m(i,j) とする。

  1. m(i,i)=0m(i,i)=0 であり、i<ji\lt j のとき m(i,j)=min⁡i≤k<j{m(i,k)+m(k+1,j)+pi−1pkpj}.m(i,j)=\min_{i\le k\lt j}\bigl\{m(i,k)+m(k+1,j)+p_{i-1}p_kp_j\bigr\}.
  2. すべての m(i,j)m(i,j) と、費用 m(1,n)m(1,n) を達成する括弧づけは、O(n3)O(n^3) 時間・O(n2)O(n^2) 領域で求まる。

証明の方針は、最後の積を作る直前の分割点 kk で場合分けすることです。1 は、左右それぞれが最適な括弧づけであるほど全体の費用が小さいことから漸化式を導きます。2 は、区間の短いものから順に表を埋めれば、各 m(i,j)m(i,j) の計算に必要な値がすでに表にあることから、時間と領域を見積もります。

証明(定理 5.2)

費用の定義の第 3 項について。Ai⋯AkA_i\cdots A_k は括弧づけによらず pi−1×pkp_{i-1}\times p_k 行列、Ak+1⋯AjA_{k+1}\cdots A_j は pk×pjp_k\times p_j 行列なので、最後の積の費用は pi−1pkpjp_{i-1}p_kp_j で、L\mathcal{L}, R\mathcal{R} の選び方によりません。

1. m(i,i)=0m(i,i)=0 は定義です。i<ji\lt j とし、右辺を MM とおきます。

(m(i,j)≥Mm(i,j)\ge M)費用 m(i,j)m(i,j) を達成する括弧づけを (L R)(\mathcal{L}\,\mathcal{R})、その分割点を kk とします。L\mathcal{L} は Ai⋯AkA_i\cdots A_k の括弧づけの 1 つなので cost(L)≥m(i,k)\mathrm{cost}(\mathcal{L})\ge m(i,k)、同様に cost(R)≥m(k+1,j)\mathrm{cost}(\mathcal{R})\ge m(k+1,j) です。よって m(i,j)≥m(i,k)+m(k+1,j)+pi−1pkpj≥Mm(i,j)\ge m(i,k)+m(k+1,j)+p_{i-1}p_kp_j\ge M です。

(m(i,j)≤Mm(i,j)\le M)i≤k<ji\le k\lt j を任意にとり、費用 m(i,k)m(i,k) の括弧づけ L\mathcal{L} と費用 m(k+1,j)m(k+1,j) の括弧づけ R\mathcal{R} をとります。(L R)(\mathcal{L}\,\mathcal{R}) は Ai⋯AjA_i\cdots A_j の括弧づけで、費用は m(i,k)+m(k+1,j)+pi−1pkpjm(i,k)+m(k+1,j)+p_{i-1}p_kp_j です。m(i,j)m(i,j) は最小値なのでこれ以下で、kk は任意だったので m(i,j)≤Mm(i,j)\le M です。

この式は、区間 (i,j)(i,j) の最適値が、より短い区間の最適値だけから決まることを示しています。問題の族 {(i,j)}\{(i,j)\} は最適部分構造(定義 5.1[動的計画法])をもちます。

2. S={(i,j):1≤i≤j≤n}S=\{(i,j):1\le i\le j\le n\}、V(i,j)=m(i,j)V(i,j)=m(i,j) とし、(i,j)(i,j)(i<ji\lt j)の依存先を (i,k)(i,k), (k+1,j)(k+1,j)(i≤k<ji\le k\lt j)とします。依存先の区間の長さ k−ik-i, j−k−1j-k-1 はどちらも j−ij-i より小さいので、依存グラフの辺は区間の長さが真に増える向きだけに伸び、閉路はありません。よって (S,D,g)(S,D,g) は部分問題族(定義 3.1[動的計画法])です。∣S∣=n(n+1)/2|S|=n(n+1)/2 で、依存先の値が表にあるとき、1 の右辺の評価は j−i≤nj-i\le n 個の候補それぞれに定数回の演算なので c(i,j)≤Cnc(i,j)\le Cn(CC は定数)です。動的計画法の計算量の定理(定理 3.2[動的計画法])から、全体は

O(∣S∣+∑(i,j)∈Sc(i,j))=O(n(n+1)2+n(n+1)2⋅Cn)=O(n3)O\Bigl(|S|+\sum_{(i,j)\in S}c(i,j)\Bigr)=O\Bigl(\frac{n(n+1)}{2}+\frac{n(n+1)}{2}\cdot Cn\Bigr)=O(n^3)

時間(命題 3.4[計算量と O 記法])、O(∣S∣)=O(n2)O(|S|)=O(n^2) 領域です。

括弧づけを得るには、1 の最小値を与える kk を κ(i,j)\kappa(i,j) として同じ表に記録しておきます(領域は O(n2)O(n^2) のまま)。paren(i,i)=Ai\mathrm{paren}(i,i)=A_i、paren(i,j)=(paren(i,κ) paren(κ+1,j))\mathrm{paren}(i,j)=(\mathrm{paren}(i,\kappa)\,\mathrm{paren}(\kappa+1,j))(κ=κ(i,j)\kappa=\kappa(i,j))と再帰的に組み立てると、1 の(≤\le)の証明のとおり費用 m(i,j)m(i,j) の括弧づけになります(区間の長さについての帰納法)。再帰の呼び出しは区間ごとに 1 回で、現れる区間は 2n−12n-1 個(葉 nn 個と分割 n−1n-1 個)なので、O(n)O(n) 時間で済みます。

例 5.3(漸化式の最適値と np.einsumpathnp.einsum_path を照合する)

6 個の行列(大きさ 40×840\times8, 8×308\times30, 30×530\times5, 5×605\times60, 60×1260\times12, 12×2512\times25)の鎖について、漸化式で最小の乗算回数を求め、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+09
Optimized FLOP count: 2.580e+04
Largest intermediate: 1.000e+03 elements

検算. (a) 手計算: 得られた括弧づけの費用は、A2A3A_2A_3 が 8⋅30⋅5=12008\cdot30\cdot5=1200、A1(A2A3)A_1(A_2A_3) が 40⋅8⋅5=160040\cdot8\cdot5=1600、A4A5A_4A_5 が 5⋅60⋅12=36005\cdot60\cdot12=3600、(A4A5)A6(A_4A_5)A_6 が 5⋅12⋅25=15005\cdot12\cdot25=1500、最後の積が 40⋅5⋅25=500040\cdot5\cdot25=5000 で、合計 1290012900 です。(b) 別手法: 6 個の行列の括弧づけは 42 通りで、全部を列挙して費用を計算すると最小は 1290012900、最大は 168600168600 でした(別のスクリプトで確認)。(c) NumPy との照合: einsum_path の順序は、被演算子の番号を読み解くと「A2A3A_2A_3、それと A1A_1、A4A5A_4A_5、それと A6A_6、最後に 2 つ」で、漸化式の括弧づけと同じです。NumPy の FLOP 数 2.580×1042.580\times10^4 は乗算と加算を 1 回ずつ数えるので、乗算回数 1290012900 のちょうど 2 倍です。定義どおりの 7 重の和(Naive)は 5.184×1095.184\times10^9 で、縮約順序を選ぶだけで 5 桁減ります。

行列の鎖では、どの括弧づけでも中間結果は行列でした。一般のテンソルの縮約では、順序によって中間結果の軸の本数が増え、演算量の指数が変わります。

例 5.4(3 つの配列で n⁴ と n³)

n×nn\times n 行列 A,B,CA,B,C について Ril=∑j,kAijBjkCklR_{il}=\sum_{j,k}A_{ij}B_{jk}C_{kl} を、2 つずつの縮約で計算します("ij,jk,kl->il")。

  • AA と BB を先に縮約する。 Xik=∑jAijBjkX_{ik}=\sum_jA_{ij}B_{jk} の費用は、現れる名前 i,j,ki,j,k の長さの積で n3n^3。次に Ril=∑kXikCklR_{il}=\sum_kX_{ik}C_{kl} が n3n^3。合計 2n32n^3 回の乗算で、中間結果の要素数は n2n^2 です。
  • AA と CC を先に縮約する。 AA と CC は共通の名前をもたないので、縮約は外積 Yijkl=AijCklY_{ijkl}=A_{ij}C_{kl} になり、費用は n4n^4。次に Ril=∑j,kYijklBjkR_{il}=\sum_{j,k}Y_{ijkl}B_{jk} が n4n^4。合計 2n42n^4 回の乗算で、中間結果の要素数は n4n^4 です。

n=40n=40 なら、前者は 1.28×1051.28\times10^5 回・中間結果 16001600 要素、後者は 5.12×1065.12\times10^6 回・2.56×1062.56\times10^6 要素(binary64 で約 20 MB)です。手元の NumPy 2.0.2 の np.einsum_path に 2 つの順序を指定すると、中間結果の最大の要素数はそれぞれ 1.600×1031.600\times10^3, 2.560×1062.560\times10^6 と表示されます。np.einsum は既定では縮約順序の最適化をしない(optimize=False)ので、3 個以上の配列を渡すときは optimize=True を付けるか、自分で 2 つずつに分けて書きます。

ABXACY→→X = AB:n² 要素(n³ 回の乗算)Y = A⊗C:n⁴ 要素(n⁴ 回の乗算)
縮約 R = ABC の 2 通りの順序と、中間結果の要素数。A と B を先にすれば n² 要素、A と C を先にすると共通の添字がなく n⁴ 要素になる。

注意 5.5(一般の縮約順序の最適化は NP 困難(文献))

行列の鎖では最適な順序が O(n3)O(n^3) 時間で求まりましたが、一般のテンソルネットワークについて費用が最小の縮約順序を求める問題は NP 困難であることが知られています(Lam, Sadayappan, Wenger の 1997 年の論文。参考文献)。実用上は、配列が少なければ全探索、多ければ貪欲法などの発見的方法が使われます(Pfeifer ほかの論文に探索法の整理があります)。この記事の命題と証明は、この事実を使いません。

「ベクトル化すると速くなる」のは、要素ごとの演算の回数が減るからではありません。

命題 6.1(ベクトル化は演算の回数を変えず、作業領域を変える)✓

XX を n×dn\times d の配列(nn 個の点の座標)とし、全対の距離の 2 乗 Dij=∑k=1d(Xik−Xjk)2D_{ij}=\sum_{k=1}^d(X_{ik}-X_{jk})^2 を次の 3 通りで計算する。

  • (a) 二重ループ: i,ji,j についてのループの中で、k=1,…,dk=1,\ldots,d について差・2 乗・足し込みをスカラーで行う。
  • (b) ブロードキャスト: ((X[:, None, :] - X[None, :, :]) ** 2).sum(axis=2)。
  • (b′) ブロック化: 行を bb 個ずつ(1≤b≤n1\le b\le n)に分け、各ブロック X[s:s+b] について (b) と同じ式を計算する。

このとき、

  1. 3 通りとも、引き算を n2dn^2d 回、乗算を n2dn^2d 回、足し算を n2(d−1)n^2(d-1) 回行う。RAM モデルでの計算時間はどれも Θ(n2d)\Theta(n^2d) である。
  2. 入力と出力 DD のほかに使う作業領域は、(a) が O(1)O(1)、(b) が n2dn^2d 要素の一時配列、(b′) が高々 bndbnd 要素の一時配列である。
証明(命題 6.1)

1. (a) は (i,j,k)(i,j,k) の n2dn^2d 通りについて引き算と 2 乗(乗算)を 1 回ずつ行い、各 (i,j)(i,j) で dd 個の値を足し合わせるのに d−1d-1 回足します。(b) では、X[:, None, :] の shape は (n,1,d)(n,1,d)、X[None, :, :] は (1,n,d)(1,n,d) で、結合は (n∨1,1∨n,d∨d)=(n,n,d)(n\vee1,1\vee n,d\vee d)=(n,n,d) です(定義 3.1)。引き算の ufunc はこの shape の各要素について 1 回、2 乗もそれぞれ 1 回で n2dn^2d 回ずつ、第 3 軸に沿った和の縮約は n2n^2 個の出力それぞれに d−1d-1 回の足し算です。(b′) は、行数 b1,b2,…b_1,b_2,\ldots(∑rbr=n\sum_rb_r=n)のブロックごとに shape (br,n,d)(b_r,n,d) で同じ計算をするので、引き算と乗算は ∑rbrnd=n2d\sum_rb_rnd=n^2d 回ずつ、足し算は ∑rbrn(d−1)=n2(d−1)\sum_rb_rn(d-1)=n^2(d-1) 回です。

どの方法でも、基本演算(定義 2.1[計算量と O 記法])の回数は、上の算術演算と、それに比例する回数の読み書き・添字の計算の和です。これは n2dn^2d の定数倍以上・定数倍以下なので Θ(n2d)\Theta(n^2d) です(定義 3.1[計算量と O 記法])。

2. (a) が使うのは、ループ変数と足し込み用のスカラーだけです。(b) の引き算の結果は shape (n,n,d)(n,n,d) の新しい配列で、n2dn^2d 要素です。(b′) の一時配列は shape (br,n,d)(b_r,n,d) で、br≤bb_r\le b から高々 bndbnd 要素です。ブロックの計算が終われば一時配列は不要になるので、同時に必要な量はこれで足ります。

ベクトル化で速くなるのは、演算 1 回あたりの定数が小さくなるからです。Python のループは 1 回の演算ごとにインタプリタの処理(型の判定、オブジェクトの生成)をはさみますが、ufunc はその部分をコンパイル済みのループで行います。次数は同じなので、nn を 2 倍にしたときの時間の伸び方は変わりません。

一方、作業領域は次数が変わります。n=105n=10^5, d=3d=3 の全対の差は 3×10103\times10^{10} 要素、binary64 で 240240 GB で、普通の計算機には載りません。(b′) で b=100b=100 とすれば 3×1073\times10^7 要素(240240 MB)で済み、演算の回数は 1 のとおり同じです。「1 行で書ける」ことと「メモリに載る」ことは別の問題です。ブロックの大きさは、キャッシュの大きさ(計算機アーキテクチャ)にも関係しますが、ここでは立ち入りません。

例 6.2(距離行列の 3 通りの計算と桁落ち)

全対の距離には、もう 1 つよく使われる式があります。∥x−y∥2=∥x∥2+∥y∥2−2⟨x,y⟩\|x-y\|^2=\|x\|^2+\|y\|^2-2\langle x,y\rangle を使って、Gram 行列 XXTXX^{\mathsf{T}} から計算する方法 (c) です。行列の積は高度に最適化されたライブラリが担うので速く、一時配列も n2n^2 要素で済みます。ところがこの式は、近い 2 点について大きな数どうしの引き算になります。原点から遠く(座標が 10001000 前後)、互いに近い 300 個の点で比べます。

# Python 3.9.6 / NumPy 2.0.2(arm64)/乱数種 20261008
import numpy as np
rng = np.random.default_rng(20261008)
n, d = 300, 3
X = 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 ** -53
print("|(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-10
D[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 乗は 3×(10−7)2=3×10−143\times(10^{-7})^2=3\times10^{-14} で、(b) の値はこれと 6 桁一致します(残りの違いは、10001000 の近くで 10−710^{-7} を足したときの丸めです)。(c) の値は 00 で、1 桁も合っていません。(c) 誤差の大きさ: (c) では a=∥x∥2+∥y∥2≈6×106a=\|x\|^2+\|y\|^2\approx6\times10^6 と b=2⟨x,y⟩≈6×106b=2\langle x,y\rangle\approx6\times10^6 の差をとります。それぞれが相対 uu 程度の丸め誤差を含むので、桁落ちの評価(命題 4.2[浮動小数点数と丸め誤差])から、差の絶対誤差は u(∣a∣+∣b∣)≈1.3×10−9u(|a|+|b|)\approx1.3\times10^{-9} 程度になりえます。実際の (b) との差の最大 1.7×10−91.7\times10^{-9} はこの大きさで、距離の 2 乗が 10−910^{-9} より小さい対では符号すら信用できず、負の値が 30 個出ています。このまま平方根をとると nan になります。

対策は、引き算の前に誤差の入らない形にすることです。差を直接とる (b) か (b′) を使うか、(c) を使うなら先に重心を引いて ∥x∥\|x\| を小さくします。負の値を 00 に切り上げるだけでは、符号が直るだけで桁は戻りません。

NumPy の整数は Python の int(桁数に制限がない)と違い、決まったビット数で表されます。

定義 7.1(符号つき w ビット整数の演算)↗

w≥1w\ge1 を整数、Iw={−2w−1,…,2w−1−1}I_w=\{-2^{w-1},\ldots,2^{w-1}-1\} とする。整数 vv に対し、v≡r(mod2w)v\equiv r\pmod{2^w}(定義 3.1[合同式とフェルマーの小定理])を満たす r∈Iwr\in I_w を wrapw(v)\mathrm{wrap}_w(v) と書く。符号つき ww ビット整数の加算・減算・乗算を、a,b∈Iwa,b\in I_w に対し

a⊕b=wrapw(a+b),a⊖b=wrapw(a−b),a⊗b=wrapw(ab)a\oplus b=\mathrm{wrap}_w(a+b),\qquad a\ominus b=\mathrm{wrap}_w(a-b),\qquad a\otimes b=\mathrm{wrap}_w(ab)

と定める。int32 は w=32w=32、int64 は w=64w=64 である。

wrapw(v)\mathrm{wrap}_w(v) がただ 1 つ定まることは、次の命題の 1 で示します。IwI_w は剰余環 Z/2wZ\mathbb{Z}/2^w\mathbb{Z}(定義 4.1[合同式とフェルマーの小定理])の各剰余類から代表を 1 つずつ選んだもので、⊕,⊗\oplus,\otimes はこの環の演算を代表元で書いたものです。

命題 7.2(固定幅整数の演算は 2ʷ を法として閉じる)✓

  1. 任意の整数 vv に対し、v≡r(mod2w)v\equiv r\pmod{2^w} となる r∈Iwr\in I_w がただ 1 つ存在する。wrapw(v)=v\mathrm{wrap}_w(v)=v となるのは v∈Iwv\in I_w のとき、そのときに限る。
  2. a,b∈Iwa,b\in I_w について、a⊕b=a+ba\oplus b=a+b となるのは真の和 a+ba+b が IwI_w に属するとき、そのときに限る。⊖\ominus と a−ba-b、⊗\otimes と abab についても同じである。
  3. IwI_w の元を変数に代入し、+,−,×+,-,\times だけからなる式を ⊕,⊖,⊗\oplus,\ominus,\otimes で計算した結果は、同じ式を整数として計算した真の値 vv に対する wrapw(v)\mathrm{wrap}_w(v) に等しい。とくに、最終的な真の値が IwI_w に属していれば、途中の値が IwI_w を出ていても、結果は正しい。
証明(命題 7.2)

1. 存在: q=⌊(v+2w−1)/2w⌋q=\lfloor(v+2^{w-1})/2^w\rfloor とおくと q≤(v+2w−1)/2w<q+1q\le(v+2^{w-1})/2^w\lt q+1 なので、r=v−2wqr=v-2^wq は −2w−1≤r<2w−1-2^{w-1}\le r\lt2^{w-1} を満たし、r∈Iwr\in I_w、2w∣(v−r)2^w\mid(v-r) です。一意性: r,r′∈Iwr,r'\in I_w がともに vv と合同なら、2w∣(r−r′)2^w\mid(r-r') です。一方 ∣r−r′∣≤(2w−1−1)−(−2w−1)=2w−1<2w|r-r'|\le(2^{w-1}-1)-(-2^{w-1})=2^w-1\lt2^w なので r−r′=0r-r'=0 です。後半: v∈Iwv\in I_w なら vv 自身が条件を満たす IwI_w の元なので、一意性から wrapw(v)=v\mathrm{wrap}_w(v)=v です。逆に wrapw(v)=v\mathrm{wrap}_w(v)=v なら、wrapw(v)∈Iw\mathrm{wrap}_w(v)\in I_w から v∈Iwv\in I_w です。

2. a⊕b=wrapw(a+b)a\oplus b=\mathrm{wrap}_w(a+b) に 1 の後半を v=a+bv=a+b として適用します。⊖\ominus, ⊗\otimes も v=a−bv=a-b, v=abv=ab として同じです。

3. 式の構造についての帰納法で、「計算結果 v^\hat v は真の値 vv と 2w2^w を法として合同で、v^∈Iw\hat v\in I_w」を示します。変数では v^=v∈Iw\hat v=v\in I_w です。式が E1∘E2E_1\circ E_2(∘∈{+,−,×}\circ\in\{+,-,\times\})で、部分式の計算結果と真の値が v^k≡vk\hat v_k\equiv v_k(k=1,2k=1,2)を満たすとします。合同式の四則(命題 3.3[合同式とフェルマーの小定理])から v^1∘v^2≡v1∘v2(mod2w)\hat v_1\circ\hat v_2\equiv v_1\circ v_2\pmod{2^w} です。計算結果は v^=wrapw(v^1∘v^2)\hat v=\mathrm{wrap}_w(\hat v_1\circ\hat v_2) で、定義から v^∈Iw\hat v\in I_w かつ v^≡v^1∘v^2≡v1∘v2=v\hat v\equiv\hat v_1\circ\hat v_2\equiv v_1\circ v_2=v です。式全体について v^∈Iw\hat v\in I_w, v^≡v\hat v\equiv v なので、1 の一意性から v^=wrapw(v)\hat v=\mathrm{wrap}_w(v) です。v∈Iwv\in I_w なら 1 の後半から v^=v\hat v=v です。

2 が言うのは、あふれは真の値が範囲を出たときにだけ起き、そのとき結果は誤差を含む近似値ではなく、2w2^w の整数倍だけずれたまったく別の数になる、ということです。浮動小数点数のあふれは inf になって目に見えますが、整数のあふれは見た目がもっともらしい数になるので気づきにくくなります。

例 7.3(21! と int32 の和)

20!=2432902008176640000<263=9223372036854775808<21!=5109094217170944000020!=2432902008176640000\lt2^{63}=9223372036854775808\lt21!=51090942171709440000 なので、int64 の階乗は 21!21! で初めて I64I_{64} を出ます。

# Python 3.9.6 / NumPy 2.0.2(arm64)
import math
import 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 - 1
print(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^9
print(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 True
21 -4249290049419214848 False True
22 -1250660718674968576 False True
1705032704 1705032704
6000000000 int64
[-294967296 -294967296 -294967296] int32
-1089934592 -1089934592
7500000000
2147483643 2147483643

検算. (a) 既知値: math.factorial は Python の整数で計算した真の値です。20!20! は一致し、21!21!, 22!22! は一致しません。(b) 命題との照合: あふれた値は、真の値を 2642^{64} または 2322^{32} を法として IwI_w に戻した wrap の値と、階乗・和・内積のすべてで一致します(命題 7.2 の 3)。最後の行は、途中の和 2312^{31} が I32I_{32} を出ても、真の和 21474836432147483643 が範囲内なので正しい結果になる例です。(c) 既定の型: この環境の np.sum は、int32 の配列を既定で int64 に上げてから足すので 6×1096\times10^9 を正しく返します。しかし要素ごとの和 a + a と内積 c @ c は int32 のまま計算し、警告なしに負の数を返します。和・積・内積の結果が 231≈2.1×1092^{31}\approx2.1\times10^9 を超えうるときは、先に astype(np.int64) で広げるか、dtype=np.int64 を指定します。int64 でも 263≈9.2×10182^{63}\approx9.2\times10^{18} を超えれば同じことが起きるので、階乗や二項係数のように急に大きくなる量は Python の int か浮動小数点数で計算します。

ここまでの命題にもとづく注意を、型の昇格とあわせてまとめます。

落とし穴何が起きるか対処
float32 の混入float32 の配列だけからなる演算の結果は float32 のままで、単位丸めは 2−24≈6×10−82^{-24}\approx6\times10^{-8}。ファイルから読んだ配列が 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 などの専用の関数を使う
一時配列ブロードキャストで n2dn^2d 要素の配列ができる(命題 6.1)ブロック化する
メモリ配置と走査の向きC 順の配列を列方向に走査すると、番地が s1s_1 ずつ飛び、キャッシュに載りにくい最後の軸を内側のループにする。必要なら np.ascontiguousarray

配列の型・shape・連続性は、コードの中で仮定している箇所に assert a.dtype == np.float64 のように書いておくと、仮定が破れたときにその場で止まります。数値の精度をどう選ぶかは、次の精度の選び方で扱います。

演習 9.1易

(1) shape が (5,1,4)(5,1,4), (3,1)(3,1), ()()(0 次元)の 3 つの配列の和の shape を、定義 3.1 に従って求めよ。(2) shape (2,3)(2,3) と (3,2)(3,2) の配列は足せるか。足せない場合、片方をどう変形すれば足せるか。

解答

(1) 3 次元にそろえると (5,1,4)(5,1,4), (1,3,1)(1,3,1), (1,1,1)(1,1,1) です。成分ごとの結合は 5∨1∨1=55\vee1\vee1=5、1∨3∨1=31\vee3\vee1=3、4∨1∨1=44\vee1\vee1=4 で、(5,3,4)(5,3,4) です。

(2) 第 1 成分が 2∨3=⊤2\vee3=\top なので両立しません。(3,2)(3,2) の配列を転置すれば (2,3)(2,3) になり、足せます(要素の対応が意図どおりかは別に確かめます)。あるいは、すべての組合せの和(shape (2,3,3,2)(2,3,3,2))が欲しいのなら、a[:, :, None, None] + b[None, None, :, :] のように長さ 1 の軸を足します。(2,3,1,1)(2,3,1,1) と (1,1,3,2)(1,1,3,2) の結合は (2,3,3,2)(2,3,3,2) です。

演習 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] の値を番地の式から計算せよ。

解答

w=4w=4 で、a の strides は (4⋅3⋅4, 4⋅4, 4)=(48,16,4)(4\cdot3\cdot4,\ 4\cdot4,\ 4)=(48,16,4)、offset は 00 です。

第 2 軸の逆順(α=2\alpha=2, t=−1t=-1, m=3m=3)で stride が −16-16、offset が 2⋅16=322\cdot16=32 になります。第 3 軸の 1::2(α=1\alpha=1, t=2t=2, m=2m=2)で stride が 88、offset が 32+1⋅4=3632+1\cdot4=36 になります。ここまでで shape (2,3,2)(2,3,2)、strides (48,−16,8)(48,-16,8)、offset 3636 です。transpose(2, 0, 1) は、新しい第 1, 2, 3 軸を元の第 3, 1, 2 軸にするので、shape (2,2,3)(2,2,3)、strides (8,48,−16)(8,48,-16)、offset 3636 です。

b[1, 0, 2] の番地は 36+1⋅8+0⋅48+2⋅(−16)=1236+1\cdot8+0\cdot48+2\cdot(-16)=12 です。a は int32 の C 順で、番地 1212 は先頭から 12/4=312/4=3 番目の要素、値は 33 です。確かめると、b[1, 0, 2] はスライス後の配列の添字 (0,2,1)(0,2,1)、すなわち a[0, 2-2, 1+2·1] = a[0, 0, 3] = 3 です。NumPy でも同じ shape・strides・値が得られます。

演習 9.3標準

A1,A2,A3,A4A_1,A_2,A_3,A_4 の大きさを 20×220\times2, 2×302\times30, 30×430\times4, 4×104\times10 とする。定理 5.2 の漸化式で m(1,4)m(1,4) と最適な括弧づけを求め、左から順に掛けた場合の乗算回数と比べよ。

解答

p=(20,2,30,4,10)p=(20,2,30,4,10) です。長さ 2 の区間: m(1,2)=20⋅2⋅30=1200m(1,2)=20\cdot2\cdot30=1200、m(2,3)=2⋅30⋅4=240m(2,3)=2\cdot30\cdot4=240、m(3,4)=30⋅4⋅10=1200m(3,4)=30\cdot4\cdot10=1200。

長さ 3 の区間: m(1,3)=min⁡{0+240+20⋅2⋅4, 1200+0+20⋅30⋅4}=min⁡{400,3600}=400m(1,3)=\min\{0+240+20\cdot2\cdot4,\ 1200+0+20\cdot30\cdot4\}=\min\{400,3600\}=400(k=1k=1)。m(2,4)=min⁡{0+1200+2⋅30⋅10, 240+0+2⋅4⋅10}=min⁡{1800,320}=320m(2,4)=\min\{0+1200+2\cdot30\cdot10,\ 240+0+2\cdot4\cdot10\}=\min\{1800,320\}=320(k=3k=3)。

長さ 4: m(1,4)=min⁡{0+320+20⋅2⋅10, 1200+1200+20⋅30⋅10, 400+0+20⋅4⋅10}=min⁡{720,8400,1200}=720m(1,4)=\min\{0+320+20\cdot2\cdot10,\ 1200+1200+20\cdot30\cdot10,\ 400+0+20\cdot4\cdot10\}=\min\{720,8400,1200\}=720(k=1k=1)。

最適な括弧づけは A1((A2A3)A4)A_1((A_2A_3)A_4) で 720720 回です。左から順に掛けると 1200+20⋅30⋅4+20⋅4⋅10=1200+2400+800=44001200+20\cdot30\cdot4+20\cdot4\cdot10=1200+2400+800=4400 回で、約 6 倍です。細い行列 A1A_1(20×220\times2)を最後に掛けると、中間結果が小さいまま進むからです。

演習 9.4標準

int8(w=8w=8, I8={−128,…,127}I_8=\{-128,\ldots,127\})で次を計算した結果を 命題 7.2 から求めよ。(1) 100⊕100100\oplus100。(2) (100⊕100)⊕(−120)(100\oplus100)\oplus(-120)。(3) 絶対値が 2312^{31} 未満の整数 NN 個の和を int64 で計算するとき、あふれが起きないことを保証できる NN の範囲。

解答

(1) 真の和 200200 は I8I_8 の外です。200−256=−56∈I8200-256=-56\in I_8 なので 100⊕100=−56100\oplus100=-56 です。

(2) 命題の 3 から、結果は真の値 100+100−120=80100+100-120=80 に対する wrap8(80)\mathrm{wrap}_8(80) で、80∈I880\in I_8 なので 8080 です。途中の −56-56 は誤っていますが、最終結果は正しくなります。実際 −56⊕(−120)=wrap8(−176)=−176+256=80-56\oplus(-120)=\mathrm{wrap}_8(-176)=-176+256=80 です。

(3) 和の絶対値は N⋅231N\cdot2^{31} 未満です。N⋅231≤263N\cdot2^{31}\le2^{63}、すなわち N≤232≈4.3×109N\le2^{32}\approx4.3\times10^9 なら、和は −263-2^{63} より大きく 2632^{63} より小さいので I64I_{64} に属し、命題の 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

このテーマの 100 本ノック(50 問)

執筆: Claude Code(Opus 5.5)、Claude Code(Sonnet 5.5)・監査 2 回(Claude Code)・最終 2026-10-12

この記事の誤りを報告する ・運営: 夢現技研合同会社 ・料金 ・利用条件 ・特定商取引法に基づく表記

© 2026 夢現技研合同会社 ・コード例は MIT ライセンスです。