第9章 行列と行列の積:バッチ処理の仕組み

こんにちは。ゆうせいです。

第8章では、行列とベクトルの積が全結合層そのものであることを確認しました。ただし、そこで扱ったのは入力が1件だけの場合です。実際の学習では、データを1件ずつ処理することは、まずありません。32件、64件といったまとまりで、一度に処理します。このまとまりを、バッチと呼びます。では、複数のデータをまとめると、計算はどう変わるのでしょうか。答えは、入力がベクトルから行列に変わるだけです。式の形は、ほとんど変わりません。第9章では、行列と行列の積を扱い、なぜまとめて処理するほうが効率的なのかを解説します。

行列と行列の積の計算手順

基本の考え方

行列と行列の積も、内積の繰り返しです。

左の行列の各行と、右の行列の各列の内積を計算します。その結果を、対応する位置に配置します。

結果のi行j列の要素は、左の行列のi行目と、右の行列のj列目の内積です。

具体例で確認する

次の二つの行列の積を計算します。

A = \begin{pmatrix} 1 & 2 \\ 3 & 4 \end{pmatrix}, \quad B = \begin{pmatrix} 5 & 6 \\ 7 & 8 \end{pmatrix}

1行1列目の要素を計算します。Aの1行目とBの1列目の内積です。

1 \times 5 + 2 \times 7 = 5 + 14 = 19

1行2列目の要素を計算します。Aの1行目とBの2列目の内積です。

1 \times 6 + 2 \times 8 = 6 + 16 = 22

2行1列目の要素を計算します。Aの2行目とBの1列目の内積です。

3 \times 5 + 4 \times 7 = 15 + 28 = 43

2行2列目の要素を計算します。Aの2行目とBの2列目の内積です。

3 \times 6 + 4 \times 8 = 18 + 32 = 50

結果は次のとおりです。

AB = \begin{pmatrix} 19 & 22 \\ 43 & 50 \end{pmatrix}

手順を整理する

計算の手順を、あらためて言葉でまとめます。

結果の行列の各位置について、次を行います。

その位置の行番号に対応する、左の行列の行を取り出します。

その位置の列番号に対応する、右の行列の列を取り出します。

両者の内積を計算します。

その値を、その位置に入れます。

左の行が結果の行を決め、右の列が結果の列を決める、という対応です。

計算が成立する条件

行列と行列の積には、明確な条件があります。

左の行列の列数と、右の行列の行数が、一致していなければなりません。

(m, n) \times (n, p) \rightarrow (m, p)

内側のnが一致し、消えます。外側のmとpが残ります。

第8章で確認した、行列とベクトルの積と同じ構造です。ベクトルは、列数が1の行列だと考えれば、同じ規則に含まれます。

形の変化を確認する

いくつかの例で確認します。

(3, 4) と (4, 5) を掛けると、(3, 5) になります。

(2, 6) と (6, 2) を掛けると、(2, 2) になります。

(3, 4) と (5, 6) は、掛けられません。内側の4と5が一致していないためです。

順序を入れ替えられない

数値の掛け算では、2掛ける3と3掛ける2は同じ結果になります。

行列の積では、そうなりません。

AB \neq BA

これを、交換法則が成り立たないと言います。

そもそも、形が合わずに計算できない場合が多くあります。(3, 4) と (4, 5) は掛けられますが、順序を入れ替えた (4, 5) と (3, 4) は掛けられません。

形が合う場合でも、結果は一般に異なります。先ほどの例で確認してみます。

BA = \begin{pmatrix} 5 & 6 \\ 7 & 8 \end{pmatrix}\begin{pmatrix} 1 & 2 \\ 3 & 4 \end{pmatrix} = \begin{pmatrix} 23 & 34 \\ 31 & 46 \end{pmatrix}

先ほどの AB とは、まったく違う結果です。

この性質は、実装で順序を間違えたときに、エラーにならないまま誤った結果が出る原因になります。注意してください。

バッチ処理への応用

1件のデータの場合

第8章で扱った計算を復習します。

\mathbf{y} = W\mathbf{x} + \mathbf{b}

入力 \mathbf{x} は、768次元のベクトルです。

重み W は、3072行768列の行列です。

出力 \mathbf{y} は、3072次元のベクトルです。

複数のデータをまとめる

32件のデータをまとめて処理したいとします。

32本の入力ベクトルを、縦に並べて行列にします。

形は、32行768列です。1行が1件のデータに対応します。

このとき、計算は次のように書けます。

Y = XW^T + \mathbf{b}

W^T は、重み行列の転置です。形は768行3072列になります。

形の対応を確認します。

(32, 768) \times (768, 3072) \rightarrow (32, 3072)

出力 Y は、32行3072列の行列です。1行が1件のデータに対する出力になっています。

なぜ転置が必要なのか

第8章では W\mathbf{x} と書き、重み行列が左でした。

ここでは XW^T と書き、データが左になっています。

理由は、データを行方向に並べたためです。

1行が1件のデータという構造にすると、左からデータ行列を掛けるほうが自然になります。そのとき、形を合わせるために、重み行列を転置する必要が生じます。

なお、実装によっては、重み行列を最初から入力次元、出力次元の形で保持し、転置なしで XW と書く場合もあります。

どちらの流儀でも、行っている計算は同じです。形が合っているかを確認することが、判断の基準になります。

バイアスの加算について

+ \mathbf{b} の部分にも、注意が必要です。

XW^T の結果は、32行3072列の行列です。

一方、バイアス \mathbf{b} は、3072次元のベクトルです。

形が違うのに、なぜ足せるのでしょうか。

これは、ブロードキャストという仕組みが働いているためです。バイアスベクトルが、32行すべてに対して、同じように加算されます。

この仕組みは、第10章で詳しく扱います。

なぜまとめて処理するのか

1件ずつ処理しても、計算結果は同じです。では、なぜまとめるのでしょうか。四つの理由があります。

理由1:計算が速くなる

最も直接的な理由です。

1件ずつ32回計算するより、32件をまとめて1回計算するほうが、はるかに速くなります。

理由は、計算の準備にかかる時間が、1回で済むためです。関数呼び出しのたびに発生するオーバーヘッドが、32分の1になります。

また、行列積の実装は、大きな行列ほど効率よく処理できるよう最適化されています。

理由2:GPUの性能を引き出せる

GPUは、多数の計算コアを持ちます。数千個の演算を同時に実行できます。

1件のデータだけを処理すると、コアの大半が遊んでしまいます。

バッチサイズを大きくすることで、すべてのコアに仕事が割り振られ、性能を引き出せます。

第16章で詳しく扱いますが、バッチ処理は、GPUを活かすための前提条件です。

理由3:学習が安定する

これは、計算効率とは別の理由です。

学習では、データから勾配を計算し、パラメータを更新します。

1件のデータだけから勾配を計算すると、そのデータの特殊性に強く影響されます。たまたま例外的なデータであれば、誤った方向にパラメータが動きます。

複数のデータの勾配を平均すると、個々のばらつきが打ち消され、より安定した更新方向が得られます。

理由4:メモリ効率が良い

重み行列は、バッチサイズにかかわらず、1つだけ保持されます。

32件をまとめて処理する場合も、重み行列を32回読み込む必要はありません。1回読み込めば、すべてのデータに使えます。

メモリからの読み込みは、計算そのものより時間がかかることがあります。読み込み回数を減らせることは、実務上、大きな利点です。

バッチサイズの選び方

バッチサイズは、実務で調整する重要なパラメータです。

大きくする利点

計算効率が上がります。

勾配が安定します。

大きくする欠点

メモリを多く消費します。バッチサイズに比例して、必要なメモリが増えます。

勾配が安定しすぎると、局所的な解から抜け出しにくくなる場合があります。適度なばらつきが、学習に有利に働くこともあります。

実務での目安

多くの場合、32、64、128といった値が使われます。

2の累乗が選ばれることが多いのは、メモリの管理やハードウェアの構造と相性が良いためです。

メモリ不足のエラーが出た場合、まずバッチサイズを半分にする、というのが定石です。第3章で学んだメモリ量の計算が、ここで役立ちます。

計算量を見積もる

行列積の計算量を確認します。

(m, n) \times (n, p) の行列積では、結果が m \times p 個の要素を持ちます。

各要素の計算に、n回の掛け算が必要です。

したがって、掛け算の総回数は次のようになります。

m \times n \times p

具体例

バッチサイズ32、入力768次元、出力3072次元の全結合層では、次のようになります。

32 \times 768 \times 3072 = 75497472

約7550万回の掛け算です。

これが、1つの層を1回通すだけの計算量です。層が12個あれば、その12倍になります。さらに、学習では逆方向の計算も必要になるため、実際にはこの数倍の計算が行われます。

大規模なモデルの学習に、膨大な計算資源が必要とされる理由が、この数値から実感できます。

Pythonで確認する

import numpy as np
import time

print("=== 行列と行列の積の基本 ===")
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])

print(f"A(形{A.shape}):")
print(A)
print(f"B(形{B.shape}):")
print(B)

C = A @ B
print(f"\nA @ B:")
print(C)

print("\n=== 各要素を内積として確認 ===")
for i in range(2):
    for j in range(2):
        row = A[i]
        col = B[:, j]
        print(f"結果[{i},{j}] = Aの{i+1}行目{row} ・ Bの{j+1}列目{col} = {row @ col}")

print("\n=== 交換法則が成り立たない ===")
print("A @ B:")
print(A @ B)
print("B @ A:")
print(B @ A)
print(f"一致するか: {np.array_equal(A @ B, B @ A)}")

print("\n=== 形の変化 ===")
cases = [
    ((3, 4), (4, 5)),
    ((2, 6), (6, 2)),
    ((32, 768), (768, 3072)),
]
for shape_a, shape_b in cases:
    a = np.zeros(shape_a)
    b = np.zeros(shape_b)
    c = a @ b
    print(f"{shape_a} × {shape_b} → {c.shape}")

print("\n=== 形が合わない場合 ===")
try:
    np.zeros((3, 4)) @ np.zeros((5, 6))
except ValueError as e:
    print(f"エラー: {e}")

print("\n=== バッチ処理の実装 ===")
np.random.seed(0)

batch_size = 32
input_dim = 768
output_dim = 3072

X = np.random.randn(batch_size, input_dim).astype(np.float32)
W = np.random.randn(output_dim, input_dim).astype(np.float32) * 0.02
b = np.zeros(output_dim, dtype=np.float32)

print(f"入力X の形: {X.shape}  ({batch_size}件のデータ、各{input_dim}次元)")
print(f"重みW の形: {W.shape}")
print(f"W転置の形: {W.T.shape}")

Y = X @ W.T + b
print(f"出力Y の形: {Y.shape}  ({batch_size}件の出力、各{output_dim}次元)")

print("\n=== 1件ずつ処理した結果と一致するか ===")
Y_individual = np.zeros((batch_size, output_dim), dtype=np.float32)
for i in range(batch_size):
    Y_individual[i] = W @ X[i] + b

print(f"一致するか: {np.allclose(Y, Y_individual, atol=1e-4)}")

print("\n=== 速度比較:1件ずつ vs まとめて ===")

def process_one_by_one(X, W, b):
    results = []
    for i in range(X.shape[0]):
        results.append(W @ X[i] + b)
    return np.array(results)

def process_batch(X, W, b):
    return X @ W.T + b

for bs in [1, 8, 32, 128]:
    X_test = np.random.randn(bs, 512).astype(np.float32)
    W_test = np.random.randn(1024, 512).astype(np.float32)
    b_test = np.zeros(1024, dtype=np.float32)
    
    start = time.time()
    for _ in range(10):
        process_one_by_one(X_test, W_test, b_test)
    t_individual = (time.time() - start) / 10 * 1000
    
    start = time.time()
    for _ in range(10):
        process_batch(X_test, W_test, b_test)
    t_batch = (time.time() - start) / 10 * 1000
    
    print(f"バッチサイズ{bs:>4}: 1件ずつ {t_individual:>8.3f}ms, "
          f"まとめて {t_batch:>8.3f}ms, 速度比 {t_individual/t_batch:>5.1f}倍")

print("\n=== 計算量の見積もり ===")
configs = [
    (32, 768, 3072, "GPT-2のMLP層(拡大)"),
    (32, 3072, 768, "GPT-2のMLP層(縮小)"),
    (64, 784, 128, "手書き数字認識の1層目"),
]

for bs, in_d, out_d, name in configs:
    mults = bs * in_d * out_d
    print(f"{name}")
    print(f"  バッチ{bs} × 入力{in_d} × 出力{out_d} = {mults:,}回の掛け算")

print("\n=== メモリ量の確認 ===")
for bs in [8, 32, 128, 512]:
    X_mem = bs * 768 * 4 / 1024 / 1024
    Y_mem = bs * 3072 * 4 / 1024 / 1024
    print(f"バッチサイズ{bs:>4}: 入力{X_mem:>7.2f}MB, 出力{Y_mem:>7.2f}MB, "
          f"合計{X_mem+Y_mem:>7.2f}MB")

初学者がつまずきやすい点

つまずき1:どちらの行を、どちらの列と掛けるのか迷う

左の行列の行と、右の行列の列です。

覚え方として、左は横、右は縦と唱える方法があります。

紙の上で、左の行列の1行目を指でなぞりながら、右の行列の1列目を別の指でなぞる。この動作を一度やってみると、体で覚えられます。

つまずき2:順序を入れ替えても大丈夫だと思ってしまう

行列の積では、順序を入れ替えると結果が変わります。

ABBA は、別のものです。

コードで順序を間違えると、形が合わずにエラーになる場合と、エラーにならないまま誤った結果が出る場合があります。後者のほうが厄介です。

つまずき3:バッチ軸がどこにあるか見失う

X の形が (32, 768) のとき、32がバッチサイズ、768が特徴量の次元です。

これを (768, 32) と取り違えると、まったく違う計算になります。

コードの要所で shape を表示し、1軸目がバッチであることを確認する習慣をつけてください。

演習

演習1:手計算する

次の行列の積を計算してください。

\begin{pmatrix} 2 & 1 \\ 0 & 3 \end{pmatrix}\begin{pmatrix} 1 & 4 \\ 2 & 0 \end{pmatrix}

また、順序を入れ替えた積も計算し、結果が異なることを確認してください。

演習2:形を判定する

次の組み合わせについて、計算が可能かどうかを判定し、可能な場合は結果の形を答えてください。

(4, 3) と (3, 6)

(5, 5) と (5, 2)

(2, 8) と (4, 8)

(64, 512) と (512, 256)

演習3:バッチ処理の形を答える

バッチサイズ16、入力次元256、出力次元512の全結合層について、次を答えてください。

入力行列 X の形

重み行列 W の形。出力次元が行の場合とします。

計算式における W^T の形

出力行列 Y の形

掛け算の総回数

演習4:バッチサイズと速度の関係を測定する

import numpy as np
import time

input_dim = 512
output_dim = 1024
W = np.random.randn(output_dim, input_dim).astype(np.float32)

print(f"{'バッチサイズ':>12} | {'処理時間(ms)':>14} | {'1件あたり(ms)':>16}")
print("-" * 48)

for bs in [1, 2, 4, 8, 16, 32, 64, 128, 256]:
    X = np.random.randn(bs, input_dim).astype(np.float32)
    
    # ウォームアップ
    for _ in range(3):
        X @ W.T
    
    start = time.time()
    n_trials = 50
    for _ in range(n_trials):
        X @ W.T
    elapsed = (time.time() - start) / n_trials * 1000
    
    print(f"{bs:>12} | {elapsed:>14.4f} | {elapsed/bs:>16.5f}")

print("\n1件あたりの処理時間が、バッチサイズを大きくするほど短くなることを確認してください。")

演習の解答例と解説

演習1の解答は、次のとおりです。

\begin{pmatrix} 2 & 1 \\ 0 & 3 \end{pmatrix}\begin{pmatrix} 1 & 4 \\ 2 & 0 \end{pmatrix}

1行1列:2 \times 1 + 1 \times 2 = 2 + 2 = 4

1行2列:2 \times 4 + 1 \times 0 = 8 + 0 = 8

2行1列:0 \times 1 + 3 \times 2 = 0 + 6 = 6

2行2列:0 \times 4 + 3 \times 0 = 0 + 0 = 0

結果は次のようになります。

\begin{pmatrix} 4 & 8 \\ 6 & 0 \end{pmatrix}

順序を入れ替えた場合。

1行1列:1 \times 2 + 4 \times 0 = 2

1行2列:1 \times 1 + 4 \times 3 = 1 + 12 = 13

2行1列:2 \times 2 + 0 \times 0 = 4

2行2列:2 \times 1 + 0 \times 3 = 2

結果は次のようになります。

\begin{pmatrix} 2 & 13 \\ 4 & 2 \end{pmatrix}

まったく異なる結果になることが確認できます。

演習2の解答は、次のとおりです。

(4, 3) と (3, 6) は計算可能で、結果は (4, 6) です。

(5, 5) と (5, 2) は計算可能で、結果は (5, 2) です。

(2, 8) と (4, 8) は計算できません。内側の8と4が一致していません。

(64, 512) と (512, 256) は計算可能で、結果は (64, 256) です。

四つ目は、バッチサイズ64、入力512次元、出力256次元の全結合層に相当します。

演習3の解答は、次のとおりです。

入力行列 X の形は (16, 256) です。

重み行列 W の形は (512, 256) です。

W^T の形は (256, 512) です。

出力行列 Y の形は (16, 512) です。

掛け算の総回数は、次のように計算されます。

16 \times 256 \times 512 = 2097152

約210万回です。

演習4では、バッチサイズを大きくすると、1件あたりの処理時間が短くなることが確認できます。

バッチサイズ1のときと、バッチサイズ128のときで、1件あたりの時間が数十倍違うこともあります。

これが、バッチ処理が使われる最も実務的な理由です。

講師向けの補足

この章では、演習4の測定を必ず実施してください。

バッチサイズを変えたときの、1件あたりの処理時間の変化は、数値で見ると強い説得力があります。まとめて処理すると速いという説明を、体感として裏付けます。

計算手順の説明では、第8章と同様、紙の上で手を動かしてもらってください。2行2列の小さな例で十分です。

交換法則が成り立たないことは、必ず実例で示してください。数値の掛け算との違いは、意外に見落とされます。

また、バッチ軸の位置については、繰り返し確認してください。(32, 768) と (768, 32) を取り違えるミスは、実務でも起こります。形を表示する習慣が、最も確実な予防策です。

理解度を確認する問い

第9章の内容を、次の問いで確認してみてください。

行列と行列の積で、結果のi行j列の要素は、どのように計算されるでしょうか。

(m, n) と (p, q) の行列が掛けられるための条件と、結果の形を答えてください。

ABBA が異なる理由を説明してください。

バッチ処理を行う理由を、四つ挙げてください。

メモリ不足のエラーが出たとき、なぜバッチサイズを減らすことが有効なのでしょうか。

まとめ

行列と行列の積は、左の行列の各行と、右の行列の各列の内積を計算し、対応する位置に配置したものです。結果のi行j列の要素は、左のi行目と右のj列目の内積になります。

計算が成立する条件は、左の行列の列数と右の行列の行数が一致することです。(m, n) と (n, p) を掛けると、(m, p) になります。

行列の積では、交換法則が成り立ちません。順序を入れ替えると、計算できないか、あるいは異なる結果になります。

バッチ処理では、複数の入力ベクトルを縦に並べて行列にします。1行が1件のデータに対応する構造です。計算式は、ベクトル1件の場合とほとんど変わりません。

Y = XW^T + \mathbf{b}

まとめて処理する理由は四つあります。計算のオーバーヘッドが減って速くなること、GPUの多数のコアを活用できること、勾配が平均されて学習が安定すること、そして重み行列の読み込み回数が減ってメモリ効率が良くなることです。

行列積の計算量は、m \times n \times p 回の掛け算です。バッチサイズ32、入力768次元、出力3072次元の層では、約7550万回になります。

次の第10章では、形を合わせる技術を扱います。転置、reshape、ブロードキャスト。実務で最も頻繁に遭遇する形が合わないというエラーに、体系的に対処できるようになることが目標です。この連載の中で、最も即効性のある章になります。

第10章に進む前に、演習4のコードを実行し、バッチサイズと処理時間の関係を確認しておいてください。この結果が、バッチ処理の意義を最も直接的に示しています。

セイ・コンサルティング・グループでは新人エンジニア研修のアシスタント講師を募集しています。

投稿者プロフィール

山崎講師
山崎講師代表取締役
セイ・コンサルティング・グループ株式会社代表取締役。
岐阜県出身。
海外放浪の末、2000年創業、2004年会社設立。
IT企業向け人材育成研修歴業界歴20年以上。
すべての無駄を省いた費用対効果の高い「筋肉質」な研修を提供します!
この記事に間違い等ありましたらぜひお知らせください。

学生時代は趣味と実益を兼ねてリゾートバイトにいそしむ。長野県白馬村に始まり、志賀高原でのスキーインストラクター、沖縄石垣島、北海道トマム。高じてオーストラリアのゴールドコーストでツアーガイドなど。現在は野菜作りにはまっている。