NumPyのブロードキャスト(形状の自動変換) | note.nkmk.me (original) (raw)

NumPy配列ndarray同士の二項演算(四則演算など)ではブロードキャスト(broadcasting)という仕組みによりそれぞれの形状shapeが同じになるように自動的に変換される。

ここでは以下の内容について説明する。

公式ドキュメントのブロードキャストについての説明ページは以下。

任意の形状に変換したい場合はreshape()np.newaxisを使う。

NumPyのブロードキャストのルール

NumPyにおけるブロードキャストは以下の2つのルールによって実行される。

  1. 次元数を揃える
    • 2つの配列の次元数が異なる場合、次元数が少ない方の配列の先頭にサイズ(長さ)が1の新しい次元を追加して次元数を揃える。
  2. 各次元のサイズ(長さ)を揃える
    • 2つの配列の各次元のサイズが一致しない場合、サイズが1である次元は他方の配列の次元のサイズに引き伸ばされる(値が繰り返される)。
    • 2つの配列のどちらのサイズも1ではない次元が存在するとブロードキャストできずにエラーとなる。

なお、配列ndarrayの次元数はndim属性、形状はshape属性で取得できる。

ブロードキャストの具体例

二次元配列の例

二次元配列と一次元配列

二次元配列と一次元配列の2つのndarrayを例とする。ブロードキャストの結果が分かりやすいように片方はzeros()ですべての要素を0にしている。データ型dtypeを整数intにしているのは見た目のためで特に意味はない。

`import numpy as np

a = np.zeros((3, 3), np.int) print(a)

[[0 0 0]

[0 0 0]

[0 0 0]]

print(a.shape)

(3, 3)

b = np.arange(3) print(b)

[0 1 2]

print(b.shape)

(3,)

`

一次元配列の形状shape(3,)となっているのは「要素数が1のタプルは末尾にカンマが付く」というPythonの仕様のため。うしろに何かが省略されているわけではないので末尾のカンマは特に気にしなくてもよい。

この2つのndarrayの足し算(加算)の結果は以下のようになる。

`print(a + b)

[[0 1 2]

[0 1 2]

[0 1 2]]

`

上述のルールに則って次元数が少ない方(一次元配列b)を明示的に変換していく。

まず、ルール1で次元数が揃えられる。先頭にサイズ(長さ)が1の新しい次元が追加され、(1, 3)(1行3列)の二次元配列となる。ここではreshape()メソッドを使っている。

`b_1_3 = b.reshape(1, 3) print(b_1_3)

[[0 1 2]]

print(b_1_3.shape)

(1, 3)

`

次に、ルール2により各次元のサイズ(長さ)が揃えられる。(1, 3)(1行3列)が(3, 3)(3行3列)となる。引き伸ばされた部分は元の部分の繰り返し。ここではnp.tile()を使っている。

`print(np.tile(b_1_3, (3, 1)))

[[0 1 2]

[0 1 2]

[0 1 2]]

`

値が0の配列との加算結果(=ブロードキャストされた結果)と同じ配列が得られていることが分かる。

なお、ここでは説明のためreshape()np.tile()を使ったが、ブロードキャスト結果の配列ndarrayを取得したい場合は専用の関数np.broadcast_to(), np.broadcast_arrays()がある。後述。

二次元配列と二次元配列

片方の配列を(3, 1)(3行1列)の二次元配列とすると加算結果は以下の通り。

`b_3_1 = b.reshape(3, 1) print(b_3_1)

[[0]

[1]

[2]]

print(b_3_1.shape)

(3, 1)

print(a + b_3_1)

[[0 0 0]

[1 1 1]

[2 2 2]]

`

この場合、次元数はすでに揃っているので、ブロードキャストではルール2に従って(3, 1)(3行1列)が(3, 3)(3行3列)に引き伸ばされる処理のみが行われている。

`print(np.tile(b_3_1, (1, 3)))

[[0 0 0]

[1 1 1]

[2 2 2]]

`

これまでの例では片方の配列のみがブロードキャストで変換されていたが、二項演算で処理される2つの配列が両方ともブロードキャストで変換される場合もある。

(1, 3)(1行3列)と(3, 1)(3行1列)の配列を例とする。加算結果は以下の通り。

`print(b_1_3)

[[0 1 2]]

print(b_1_3.shape)

(1, 3)

print(b_3_1)

[[0]

[1]

[2]]

print(b_3_1.shape)

(3, 1)

print(b_1_3 + b_3_1)

[[0 1 2]

[1 2 3]

[2 3 4]]

`

ルール2は「2つの配列の各次元のサイズが一致しない場合、サイズが1である次元は他方の配列の次元のサイズに引き伸ばされる(値が繰り返される)」というもの。(1, 3)(3, 1)(3, 3)に変換される。

`print(np.tile(b_1_3, (3, 1)))

[[0 1 2]

[0 1 2]

[0 1 2]]

print(np.tile(b_3_1, (1, 3)))

[[0 0 0]

[1 1 1]

[2 2 2]]

print(np.tile(b_1_3, (3, 1)) + np.tile(b_3_1, (1, 3)))

[[0 1 2]

[1 2 3]

[2 3 4]]

`

片方が一次元配列でも同様。

`c = np.arange(4) print(c)

[0 1 2 3]

print(c.shape)

(4,)

print(b_3_1)

[[0]

[1]

[2]]

print(b_3_1.shape)

(3, 1)

print(c + b_3_1)

[[0 1 2 3]

[1 2 3 4]

[2 3 4 5]]

`

一次元配列は(4,)(1, 4)(3, 4)、二次元配列は(3, 1)(3, 4)というように変換が行われている。

`print(np.tile(c.reshape(1, 4), (3, 1)))

[[0 1 2 3]

[0 1 2 3]

[0 1 2 3]]

print(np.tile(b_3_1, (1, 4)))

[[0 0 0 0]

[1 1 1 1]

[2 2 2 2]]

print(np.tile(c.reshape(1, 4), (3, 1)) + np.tile(b_3_1, (1, 4)))

[[0 1 2 3]

[1 2 3 4]

[2 3 4 5]]

`

なお、次元のサイズを引き伸ばすルール2の処理の対象となるのは元のサイズが1の場合のみ。それ以外はブロードキャストできずにエラーとなる。後述。

三次元配列の例

ルール1は次元数の差が2以上でも適用される。

三次元配列と一次元配列を例とすると加算結果は以下の通り。

`a = np.zeros((2, 3, 4), dtype=np.int) print(a)

[[[0 0 0 0]

[0 0 0 0]

[0 0 0 0]]

[[0 0 0 0]

[0 0 0 0]

[0 0 0 0]]]

print(a.shape)

(2, 3, 4)

b = np.arange(4) print(b)

[0 1 2 3]

print(b.shape)

(4,)

print(a + b)

[[[0 1 2 3]

[0 1 2 3]

[0 1 2 3]]

[[0 1 2 3]

[0 1 2 3]

[0 1 2 3]]]

`

(4, )(1, 1, 4)(2, 3, 4)という流れで形状shapeが変換される。

`b_1_1_4 = b.reshape(1, 1, 4) print(b_1_1_4)

[[[0 1 2 3]]]

print(np.tile(b_1_1_4, (2, 3, 1)))

[[[0 1 2 3]

[0 1 2 3]

[0 1 2 3]]

[[0 1 2 3]

[0 1 2 3]

[0 1 2 3]]]

`

ブロードキャストできない(エラーになる)場合

上述のように、次元のサイズを引き伸ばすルール2の処理の対象となるのは元のサイズが1の場合のみ。次元のサイズが異なっていてどちらの配列のサイズも1でないとブロードキャストできずにエラーとなる。

`a = np.zeros((4, 3), dtype=np.int) print(a)

[[0 0 0]

[0 0 0]

[0 0 0]

[0 0 0]]

print(a.shape)

(4, 3)

b = np.arange(6).reshape(2, 3) print(b)

[[0 1 2]

[3 4 5]]

print(b.shape)

(2, 3)

print(a + b)

ValueError: operands could not be broadcast together with shapes (4,3) (2,3)

`

以下の場合も同様。

`a = np.zeros((2, 3, 4), dtype=np.int) print(a)

[[[0 0 0 0]

[0 0 0 0]

[0 0 0 0]]

[[0 0 0 0]

[0 0 0 0]

[0 0 0 0]]]

print(a.shape)

(2, 3, 4)

b = np.arange(3) print(b)

[0 1 2]

print(b.shape)

(3,)

print(a + b)

ValueError: operands could not be broadcast together with shapes (2,3,4) (3,)

`

この例では、後ろ側(形状shapeでいう右側)に新たな次元を追加するとブロードキャストされる。

`b_3_1 = b.reshape(3, 1) print(b_3_1)

[[0]

[1]

[2]]

print(b_3_1.shape)

(3, 1)

print(a + b_3_1)

[[[0 0 0 0]

[1 1 1 1]

[2 2 2 2]]

[[0 0 0 0]

[1 1 1 1]

[2 2 2 2]]]

`

ブロードキャストできるかどうかは形状shapeを右寄せして考えると分かりやすい。

`NG (2, 3, 4) ( 3)

OK (2, 3, 4) ( 3, 1) -> (1, 3, 1) -> (2, 3, 4) `

右寄せして縦に比べたときにサイズが異なっている場合は片方が1でないとブロードキャストできない。

具体的な例として、画像の配列の場合、カラー画像は形状が(高さ, 幅, 色)の三次元配列(は赤・緑・青の3)、単色画像は形状が(高さ, 幅)の二次元配列となる。

カラー画像の各色の値に対して単色画像の値を足したり引いたりしたい場合、そのままだと高さが同じでもブロードキャストできない。np.newaxisなどを使って単色画像の最後に次元を追加する必要がある。

以下のようなイメージ。

`NG (h, w, 3) ( h, w)

OK (h, w, 3) (h, w, 1) -> (h, w, 3) `

ブロードキャスト結果を取得する関数

配列を任意の形状にブロードキャスト: np.broadcast_to()

配列ndarrayを任意の形状shapeにブロードキャストしたい場合はnp.broadcast_to()を使う。

第一引数に元のndarray、第二引数に形状shapeを示すタプルやリストを指定する。ブロードキャストされたndarrayが返される。

`a = np.arange(3) print(a)

[0 1 2]

print(a.shape)

(3,)

print(np.broadcast_to(a, (3, 3)))

[[0 1 2]

[0 1 2]

[0 1 2]]

print(type(np.broadcast_to(a, (3, 3))))

<class 'numpy.ndarray'>

`

ブロードキャストできない形状を指定するとエラー。

`# print(np.broadcast_to(a, (2, 2)))

ValueError: operands could not be broadcast together with remapped shapes [original->remapped]: (3,) and requested shape (2,2)

`

複数の配列をブロードキャスト: np.broadcast_arrays()

複数の配列ndarrayをブロードキャストして形状を揃えたい場合はnp.broadcast_arrays()を使う。

可変長引数になっているので複数の配列をカンマ区切りで指定する。ndarrayのリストが返される。

`a = np.arange(3) print(a)

[0 1 2]

print(a.shape)

(3,)

b = np.arange(3).reshape(3, 1) print(b)

[[0]

[1]

[2]]

print(b.shape)

(3, 1)

arrays = np.broadcast_arrays(a, b)

print(type(arrays))

<class 'list'>

print(len(arrays))

2

print(arrays[0])

[[0 1 2]

[0 1 2]

[0 1 2]]

print(arrays[1])

[[0 0 0]

[1 1 1]

[2 2 2]]

print(type(arrays[0]))

<class 'numpy.ndarray'>

`

ブロードキャストできない配列の組み合わせを指定するとエラー。

`c = np.zeros((2, 2)) print(c)

[[0. 0.]

[0. 0.]]

print(c.shape)

(2, 2)

arrays = np.broadcast_arrays(a, c)

ValueError: shape mismatch: objects cannot be broadcast to a single shape

`

掛け算(乗算)のブロードキャスト

これまでの例は足し算(加算)だったが、引き算(減算)や割り算(除算)などでも同様にブロードキャストが行われる。

掛け算(乗算)でも要素積に対しては同様にブロードキャストされるが、行列積の場合は異なるので注意。

要素積(アダマール積)

*演算子あるいはnp.multiply()では同じ位置の要素同士が掛け算(乗算)される。

`a = b = np.arange(3) print(a)

[0 1 2]

print(a.shape)

(3,)

print(a * b)

[0 1 4]

print(np.multiply(a, b))

[0 1 4]

`

このような要素ごとの積はアダマール積と呼ばれる。

形状shapeが異なる場合、これまでの例のようにブロードキャストされる。

`a_1_3 = a.reshape(1, 3) print(a_1_3)

[[0 1 2]]

print(a_1_3.shape)

(1, 3)

b_3_1 = b.reshape(3, 1) print(b_3_1)

[[0]

[1]

[2]]

print(b_3_1.shape)

(3, 1)

print(a_1_3 * b_3_1)

[[0 0 0]

[0 1 2]

[0 2 4]]

print(np.multiply(a_1_3, b_3_1))

[[0 0 0]

[0 1 2]

[0 2 4]]

`

`print(a * b_3_1)

[[0 0 0]

[0 1 2]

[0 2 4]]

print(np.multiply(a, b_3_1))

[[0 0 0]

[0 1 2]

[0 2 4]]

`

行列積

要素ごとの積ではなくいわゆる行列の積(matrix product)を計算したい場合はnp.matmul(), @演算子, np.dot()を使う。

@演算子はnp.matmul()と等価だがPython3.5で追加されたものでそれより前のバージョンでは使えない。np.matmul()np.dot()は三次元以上の多次元配列の扱いが異なる。

二次元配列の場合はどれでも同じだが、公式ドキュメントではmatmul()または@演算子の使用が好ましいとされている。

If both a and b are 2-D arrays, it is matrix multiplication, but using matmul or a @ b is preferred.
numpy.dot — NumPy v1.16 Manual

ここでは二次元および一次元配列の行列積について述べる。

行列積の演算のルール

二次元および一次元配列の行列積の演算においては、

というルールが適用される。

形状shapeに着目すると以下のように一般化される。@演算子で行列積を算出しているものとする。

`2D @ 2D (n, m) @ (m, p) = (n, p)

1D @ 2D or 2D @ 1D (m, ) @ (m, p) -> (1, m) @ (m, p) = (1, p) -> (p, ) (n, m) @ (m, ) -> (n, m) @ (m, 1) = (n, 1) -> (n, )

1D @ 1D (m, ) @ (m, ) -> (1, m) @ (m, 1) = (1, 1) -> scalar `

以下、具体例を示す。

二次元配列と二次元配列の行列積

二次元配列同士の行列積の結果は二次元配列となる。

`print(a_1_3 @ b_3_1)

[[5]]

print(np.matmul(a_1_3, b_3_1))

[[5]]

print(np.dot(a_1_3, b_3_1))

[[5]]

print(type(a_1_3 @ b_3_1))

<class 'numpy.ndarray'>

print((a_1_3 @ b_3_1).shape)

(1, 1)

`

二次元配列と一次元配列の行列積

一次元配列は上述のように最初か最後に次元が追加されて行列積が算出される。結果からは追加された次元は削除されるので一次元配列となる。

`print(a_1_3 @ b)

[5]

print(np.matmul(a_1_3, b))

[5]

print(np.dot(a_1_3, b))

[5]

print(type(a_1_3 @ b))

<class 'numpy.ndarray'>

print((a_1_3 @ b).shape)

(1,)

`

一次元配列と一次元配列の行列積

一次元配列同士の行列積の結果はスカラーとなる。両方の配列に上述のルールが適用されている。

`print(a @ b)

5

print(np.matmul(a, b))

5

print(np.dot(a, b))

5

print(type(a @ b))

<class 'numpy.int64'>

`

行列積の注意点

行列積は1つ目の配列の列数と2つ目の配列の行数が一致していないと計算できないが、それを揃えるために自動的にブロードキャストされたりはしない。

`a = np.arange(6).reshape(2, 3) print(a)

[[0 1 2]

[3 4 5]]

b = np.arange(2).reshape(1, 2) print(b)

[[0 1]]

print(a @ b)

ValueError: shapes (2,3) and (1,2) not aligned: 3 (dim 1) != 1 (dim 0)

`

要素積やその他の四則演算のブロードキャストのようにサイズが足りない次元を引き伸ばしたい(値を繰り返したい)場合はnp.tile()を使う。

`print(np.tile(b, (3, 1)))

[[0 1]

[0 1]

[0 1]]

print(a @ np.tile(b, (3, 1)))

[[ 0 3]

[ 0 12]]

`