第7章 ニューロンをまとめるという考え方

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

第6章で、ニューロン1個の計算が内積であることを確認しました。しかし、実際のニューラルネットワークには、1つの層に何百、何千というニューロンが並んでいます。GPT-2という言語モデルでは、1つの層に3072個のニューロンが並ぶ箇所があります。3072個のニューロンがあれば、内積を3072回計算することになります。この3072回の計算を、いちいち書いていくのは現実的ではありません。第7章では、この多数のニューロンをどうやってまとめて扱うかという考え方を扱います。行列の掛け算という道具そのものは第8章で導入しますので、この章はその橋渡しです。なぜまとめる必要があるのか、まとめるとはどういうことかを、先に納得しておきましょう。

ニューロンが1個から複数になるとどうなるか

1個の場合の復習

第6章で確認したとおり、ニューロン1個の計算は次のとおりです。

y = \mathbf{w} \cdot \mathbf{x} + b

入力ベクトル \mathbf{x} と、重みベクトル \mathbf{w} の内積に、バイアス b を足します。

結果 y は、スカラー、つまり1個の数値です。

3個の場合

ニューロンを3個に増やしてみます。

重要な点は、各ニューロンが、それぞれ独自の重みベクトルとバイアスを持つということです。入力は共通ですが、重みは別々です。

y_1 = \mathbf{w}_1 \cdot \mathbf{x} + b_1

y_2 = \mathbf{w}_2 \cdot \mathbf{x} + b_2

y_3 = \mathbf{w}_3 \cdot \mathbf{x} + b_3

内積を3回、それぞれ別の重みベクトルで計算します。

結果として、3個の数値が得られます。これを1本のベクトルにまとめると、出力ベクトルになります。

\mathbf{y} = [y_1, y_2, y_3]

具体的な数値で確認する

入力が3次元だとします。

\mathbf{x} = [2, 5, 1]

三つのニューロンの重みが、次のとおりだとします。

\mathbf{w}_1 = [1, 0, 2]

\mathbf{w}_2 = [0, 3, 1]

\mathbf{w}_3 = [2, 1, 0]

バイアスは、それぞれ次のとおりとします。

b_1 = 1, \quad b_2 = -2, \quad b_3 = 0

計算します。

y_1 = (1 \times 2 + 0 \times 5 + 2 \times 1) + 1 = (2 + 0 + 2) + 1 = 5

y_2 = (0 \times 2 + 3 \times 5 + 1 \times 1) - 2 = (0 + 15 + 1) - 2 = 14

y_3 = (2 \times 2 + 1 \times 5 + 0 \times 1) + 0 = (4 + 5 + 0) + 0 = 9

出力ベクトルは、次のようになります。

\mathbf{y} = [5, 14, 9]

入力が3次元、出力が3次元です。この例ではたまたま同じ次元数ですが、一致している必要はありません。

入力の次元数と出力の次元数

ここで、重要な関係を整理します。

入力の次元数は、各ニューロンが受け取る数値の個数です。上の例では3です。

出力の次元数は、ニューロンの個数です。上の例では3です。

この二つは、独立に決められます。

入力が768次元で、ニューロンが3072個あれば、出力は3072次元になります。

入力が3072次元で、ニューロンが768個あれば、出力は768次元になります。

なぜ次元数を変えられるのか

各ニューロンは、入力のすべての要素を見て、1個の数値を出します。したがって、ニューロンの個数が、そのまま出力の次元数になります。

ニューロンを増やせば出力の次元が増え、減らせば減ります。この自由度が、ニューラルネットワークの設計における基本的な選択肢になります。

ディープラーニングでの実例

GPT-2のMLP層と呼ばれる部分では、次の変換が行われています。

768 \rightarrow 3072 \rightarrow 768

入力768次元を、3072個のニューロンで受けて3072次元にし、それを768個のニューロンで受けて768次元に戻します。

いったん拡大してから縮小する構造です。

なぜこのような構造にするのかについては、第12章で扱います。

重みベクトルをまとめて考える

3個のニューロンには、3本の重みベクトルがあります。

\mathbf{w}_1 = [1, 0, 2]

\mathbf{w}_2 = [0, 3, 1]

\mathbf{w}_3 = [2, 1, 0]

これらを、バラバラに管理するのは不便です。そこで、まとめて一つのものとして扱いたくなります。

縦に並べてみます。

W = \begin{pmatrix} 1 & 0 & 2 \\ 0 & 3 & 1 \\ 2 & 1 & 0 \end{pmatrix}

これは、第3章で学んだ行列そのものです。

3行3列の行列であり、各行が1つのニューロンの重みベクトルに対応しています。

行の意味と列の意味

この行列の読み方を、明確にしておきます。

行は、ニューロンに対応します。1行目は1番目のニューロン、2行目は2番目のニューロン、という具合です。

列は、入力の要素に対応します。1列目は入力の1番目の要素に掛かる重み、2列目は2番目の要素に掛かる重み、です。

したがって、この行列の大きさは次のようになります。

(output_dim, input_dim)

つまり、行数が出力次元、列数が入力次元です。

具体例で確認する

入力が768次元、ニューロンが3072個の層であれば、重み行列は3072行768列になります。

要素数を計算します。

3072 \times 768 = 2359296

約236万個のパラメータが、この1つの層に含まれていることになります。

第3章で学んだ、形から要素数を計算する方法が、ここで活きてきます。

バイアスもまとめる

バイアスも、ニューロンごとに1個ずつあります。

b_1 = 1, \quad b_2 = -2, \quad b_3 = 0

これらをまとめると、ベクトルになります。

\mathbf{b} = [1, -2, 0]

バイアスベクトルの次元数は、ニューロンの個数、つまり出力の次元数と一致します。

まとめて書くとどうなるか

ここまでの内容を、記号でまとめます。

重み行列を W 、入力ベクトルを \mathbf{x} 、バイアスベクトルを \mathbf{b} とすると、層全体の計算は次のように書けます。

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

第1章で示した式が、ここでようやく意味を持ちました。

この一行が、3072回の内積計算と、3072回のバイアス加算を、すべて含んでいます。

W\mathbf{x} という部分が、行列とベクトルの掛け算です。この計算の具体的な手順は、第8章で扱います。

この章では、なぜこう書きたくなるのかという動機の部分を、押さえておいてください。

なぜまとめる必要があるのか

3072回の内積を、3072行のプログラムで書いても、計算はできます。では、なぜまとめるのでしょうか。四つの理由があります。

理由1:書く量が減る

最も分かりやすい理由です。

ループを使えば、3072行を書く必要はありません。しかし、ループを使ったとしても、記述は次のようになります。

for i in range(3072):
    y[i] = 0
    for j in range(768):
        y[i] += W[i][j] * x[j]
    y[i] += b[i]

二重ループです。これが、行列積を使えば、次の一行になります。

y = W @ x + b

コードの読みやすさが、大きく変わります。

理由2:計算が速くなる

これが、実務上は最も重要な理由です。

Pythonのループは、実行速度が遅いことで知られています。二重ループで約236万回の計算を行うと、相当な時間がかかります。

一方、NumPyやPyTorchの行列積は、内部が高速な言語で実装されており、さらに複数の計算を同時に実行する仕組みが使われています。

同じ計算でも、実行時間が数十倍から数百倍違うことがあります。

演習で、実際にこの差を測定してみてください。

理由3:GPUで並列処理できる

第16章で詳しく扱いますが、GPUは多数の計算を同時に実行する装置です。

行列積という形にまとめられていれば、各要素の計算が独立しているため、GPUの多数の計算コアに割り振れます。

ループとして書かれた計算は、この並列化ができません。

現在のディープラーニングが実用的な速度で動くのは、計算が行列積の形に整理されているからです。

理由4:数式として読み書きできる

論文や技術文書では、モデルの構造が数式で説明されます。

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

この一行を見て、全結合層だな、と即座に判断できることが、技術文書を読むうえで必要になります。

ループとして書かれていたら、何をしているのか読み解くのに時間がかかります。

層を重ねるということ

ここまでは、1つの層の話でした。

ディープラーニングでは、この層を何層も重ねます。

\mathbf{h}_1 = W_1\mathbf{x} + \mathbf{b}_1

\mathbf{h}_2 = W_2\mathbf{h}_1 + \mathbf{b}_2

\mathbf{y} = W_3\mathbf{h}_2 + \mathbf{b}_3

1層目の出力が、2層目の入力になります。

このとき、次元数の対応が重要になります。

1層目の出力が128次元であれば、2層目の入力も128次元でなければなりません。したがって、2層目の重み行列は、列数が128でなければなりません。

この対応が合っていないと、計算ができません。第10章で扱う形が合わないエラーの、最も基本的なパターンです。

実際には活性化関数が挟まる

なお、実際のニューラルネットワークでは、層と層の間に活性化関数という処理が挟まります。

\mathbf{h}_1 = f(W_1\mathbf{x} + \mathbf{b}_1)

この f が活性化関数です。

なぜこれが必要なのかは、第13章で扱います。線形代数の限界に関わる、重要な話です。

現時点では、実際には間に何か挟まる、とだけ覚えておいてください。

Pythonで確認する

import numpy as np
import time

print("=== ニューロン1個の計算 ===")
x = np.array([2.0, 5.0, 1.0])
w1 = np.array([1.0, 0.0, 2.0])
b1 = 1.0

y1 = w1 @ x + b1
print(f"入力: {x}")
print(f"重み: {w1}, バイアス: {b1}")
print(f"出力: {y1}")

print("\n=== ニューロン3個を個別に計算 ===")
w2 = np.array([0.0, 3.0, 1.0])
w3 = np.array([2.0, 1.0, 0.0])
b2, b3 = -2.0, 0.0

y1 = w1 @ x + b1
y2 = w2 @ x + b2
y3 = w3 @ x + b3
print(f"ニューロン1の出力: {y1}")
print(f"ニューロン2の出力: {y2}")
print(f"ニューロン3の出力: {y3}")
print(f"出力ベクトル: {np.array([y1, y2, y3])}")

print("\n=== 重みを行列にまとめる ===")
W = np.array([
    [1.0, 0.0, 2.0],
    [0.0, 3.0, 1.0],
    [2.0, 1.0, 0.0]
])
b = np.array([1.0, -2.0, 0.0])

print(f"重み行列 W(形{W.shape}):")
print(W)
print(f"バイアスベクトル b(形{b.shape}): {b}")

y = W @ x + b
print(f"\nまとめて計算した出力: {y}")
print("個別に計算した結果と一致しています。")

print("\n=== 次元数の関係 ===")
input_dim = 768
n_neurons = 3072

W_large = np.random.randn(n_neurons, input_dim).astype(np.float32) * 0.02
b_large = np.zeros(n_neurons, dtype=np.float32)
x_large = np.random.randn(input_dim).astype(np.float32)

print(f"入力の次元数: {input_dim}")
print(f"ニューロンの個数: {n_neurons}")
print(f"重み行列の形: {W_large.shape}")
print(f"パラメータ数: {W_large.size:,}")
print(f"出力の次元数: {(W_large @ x_large).shape[0]}")

print("\n=== ループと行列積の速度比較 ===")

def loop_version(W, x, b):
    n_out, n_in = W.shape
    y = np.zeros(n_out)
    for i in range(n_out):
        total = 0.0
        for j in range(n_in):
            total += W[i, j] * x[j]
        y[i] = total + b[i]
    return y

def matrix_version(W, x, b):
    return W @ x + b

n_out, n_in = 500, 500
W_test = np.random.randn(n_out, n_in)
x_test = np.random.randn(n_in)
b_test = np.random.randn(n_out)

start = time.time()
result_loop = loop_version(W_test, x_test, b_test)
time_loop = time.time() - start

start = time.time()
result_matrix = matrix_version(W_test, x_test, b_test)
time_matrix = time.time() - start

print(f"行列サイズ: {n_out} x {n_in}")
print(f"ループ版:   {time_loop*1000:>10.2f} ミリ秒")
print(f"行列積版:   {time_matrix*1000:>10.2f} ミリ秒")
print(f"速度比:     {time_loop/time_matrix:>10.1f} 倍")
print(f"結果は一致しているか: {np.allclose(result_loop, result_matrix)}")

print("\n=== 層を重ねる ===")
np.random.seed(0)
W_layer1 = np.random.randn(128, 10) * 0.1
b_layer1 = np.zeros(128)
W_layer2 = np.random.randn(64, 128) * 0.1
b_layer2 = np.zeros(64)
W_layer3 = np.random.randn(3, 64) * 0.1
b_layer3 = np.zeros(3)

x_input = np.random.randn(10)

print(f"入力          : 形{x_input.shape}")
h1 = W_layer1 @ x_input + b_layer1
print(f"1層目通過後   : 形{h1.shape}  (重み行列{W_layer1.shape})")
h2 = W_layer2 @ h1 + b_layer2
print(f"2層目通過後   : 形{h2.shape}   (重み行列{W_layer2.shape})")
output = W_layer3 @ h2 + b_layer3
print(f"3層目通過後   : 形{output.shape}    (重み行列{W_layer3.shape})")

print("\n各層の重み行列は、前の層の出力次元を列数として持っています。")

print("\n=== 次元が合わない場合 ===")
W_wrong = np.random.randn(64, 100)
try:
    result = W_wrong @ h1
except ValueError as e:
    print(f"エラー: {e}")
    print(f"h1の次元は{h1.shape[0]}ですが、W_wrongの列数は{W_wrong.shape[1]}です。")

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

つまずき1:重み行列の行と列を取り違える

重み行列の形は、行数が出力次元、列数が入力次元です。

入力次元が先だと思い込むと、すべての計算が合わなくなります。

覚え方として、行がニューロン、列が入力と唱えるのが有効です。1つのニューロンが1行を占め、その行の中に、入力の各要素に掛ける重みが並んでいる、というイメージです。

なお、ライブラリによっては、内部的に転置した形で保持している場合があります。PyTorchの nn.Linear がその例です。この点は、第10章で扱います。

つまずき2:入力次元と出力次元を混同する

nn.Linear(768, 3072) というコードを見たとき、768が入力、3072が出力です。

この順序を逆に覚えると、モデルの構造がまったく違うものになります。

コードを書いたら、必ず出力の shape を確認する習慣をつけてください。

つまずき3:バイアスの次元数を間違える

バイアスベクトルの次元数は、出力次元と一致します。入力次元ではありません。

各ニューロンに1個ずつのバイアスがある、と考えれば間違えません。

演習

演習1:出力を手計算する

入力を \mathbf{x} = [1, 3, 2] とします。

次の重み行列とバイアスベクトルを使って、出力ベクトルを計算してください。

W = \begin{pmatrix} 2 & 0 & 1 \\ 1 & 1 & 0 \\ 0 & 2 & 3 \\ 1 & -1 & 1 \end{pmatrix}

\mathbf{b} = [1, 0, -2, 3]

また、この層のニューロンの個数、入力次元、出力次元をそれぞれ答えてください。

演習2:パラメータ数を計算する

次の構成のニューラルネットワークについて、全パラメータ数を計算してください。バイアスも含めてください。

入力が784次元、1層目が512個のニューロン、2層目が256個のニューロン、3層目が10個のニューロン。

演習3:速度差を測定する

import numpy as np
import time

def loop_version(W, x):
    n_out, n_in = W.shape
    y = np.zeros(n_out)
    for i in range(n_out):
        total = 0.0
        for j in range(n_in):
            total += W[i, j] * x[j]
        y[i] = total
    return y

print(f"{'サイズ':>10} | {'ループ版(ms)':>14} | {'行列積版(ms)':>14} | {'速度比':>10}")
print("-" * 56)

for size in [50, 100, 200, 400]:
    W = np.random.randn(size, size)
    x = np.random.randn(size)
    
    start = time.time()
    loop_version(W, x)
    t_loop = (time.time() - start) * 1000
    
    start = time.time()
    for _ in range(100):
        W @ x
    t_matrix = (time.time() - start) * 1000 / 100
    
    print(f"{size:>4}x{size:<5} | {t_loop:>14.3f} | {t_matrix:>14.5f} | {t_loop/t_matrix:>9.0f}倍")

print("\nサイズが大きくなるほど、速度差が広がることを確認してください。")

演習4:層の次元をつなげる

次の条件で、3層のネットワークを構成してください。

入力は100次元です。

1層目の出力は50次元です。

2層目の出力は20次元です。

3層目の出力は5次元です。

各層の重み行列の形と、バイアスベクトルの次元数を答えてください。

演習の解答例と解説

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

y_1 = (2 \times 1 + 0 \times 3 + 1 \times 2) + 1 = (2 + 0 + 2) + 1 = 5

y_2 = (1 \times 1 + 1 \times 3 + 0 \times 2) + 0 = (1 + 3 + 0) + 0 = 4

y_3 = (0 \times 1 + 2 \times 3 + 3 \times 2) - 2 = (0 + 6 + 6) - 2 = 10

y_4 = (1 \times 1 + (-1) \times 3 + 1 \times 2) + 3 = (1 - 3 + 2) + 3 = 3

出力ベクトルは [5, 4, 10, 3] です。

ニューロンの個数は4個、入力次元は3、出力次元は4です。

重み行列が4行3列であることが、そのまま4個のニューロンが3次元の入力を受け取ることを表しています。

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

1層目は、重みが次のようになります。

512 \times 784 = 401408

バイアスが512ですから、合計401920です。

2層目は、重みが次のようになります。

256 \times 512 = 131072

バイアスが256ですから、合計131328です。

3層目は、重みが次のようになります。

10 \times 256 = 2560

バイアスが10ですから、合計2570です。

全パラメータ数は、次のようになります。

401920 + 131328 + 2570 = 535818

約53万6千個です。

この計算から、入力に近い層ほどパラメータが多くなる傾向が読み取れます。784次元という大きな入力を受ける1層目が、全体の約75パーセントを占めています。

演習3では、サイズが大きくなるほど速度差が広がることが確認できます。行列積の実装が最適化されているため、規模が大きいほど有利になります。

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

1層目は、重み行列が50行100列、バイアスが50次元です。

2層目は、重み行列が20行50列、バイアスが20次元です。

3層目は、重み行列が5行20列、バイアスが5次元です。

各層の重み行列の列数が、前の層の出力次元と一致している点を確認してください。この連鎖が成立していないと、計算できません。

講師向けの補足

この章は、第6章と第8章をつなぐ橋渡しの回です。新しい計算技法は出てきませんので、時間配分は控えめでも構いません。

ただし、なぜまとめるのかという動機の部分は、必ず実感してもらってください。特に、演習3の速度比較は、強い印象を残します。

ループで書けば数秒かかる計算が、行列積なら一瞬で終わる。この差を目の当たりにすると、行列積として書くことの実務的な意味が、腑に落ちます。

また、重み行列の形が出力次元、入力次元の順である点は、繰り返し強調してください。この順序を間違えると、以降すべての計算が合わなくなります。

ホワイトボードに、ニューロンを丸で描き、それぞれから入力へ線を引く図を描くと、行と列の対応が視覚的に理解できます。1つの丸から出る線の束が、行列の1行に対応する、という説明が有効です。

理解度を確認する問い

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

ニューロンが3個ある層では、内積を何回計算することになるでしょうか。

重み行列の行数と列数は、それぞれ何に対応するでしょうか。

入力が768次元、ニューロンが3072個の層について、重み行列の形と、パラメータ数を答えてください。

多数のニューロンの計算をまとめて行列積として書く理由を、四つ挙げてください。

層を重ねる際、前の層と次の層の間で満たすべき条件は何でしょうか。

まとめ

ニューロンが複数ある層では、各ニューロンがそれぞれ独自の重みベクトルとバイアスを持ち、共通の入力に対して内積を計算します。ニューロンが3個あれば内積を3回、3072個あれば3072回計算することになります。

出力の次元数は、ニューロンの個数と一致します。入力の次元数とは独立に決められるため、層を通すことで次元数を変えられます。

複数の重みベクトルを縦に並べると、行列になります。この重み行列の形は、行数がニューロンの個数つまり出力次元、列数が入力次元です。バイアスも、出力次元と同じ長さのベクトルにまとめられます。

これらをまとめることで、層全体の計算が次の一行で書けるようになります。

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

まとめる理由は四つあります。書く量が減ること、計算が大幅に速くなること、GPUによる並列処理が可能になること、そして数式として読み書きできることです。

層を重ねる際は、前の層の出力次元と、次の層の重み行列の列数が一致していなければなりません。この対応が、実装時の形の合わせ方の基本になります。

次の第8章では、この W\mathbf{x} という計算を、具体的にどう実行するのかを扱います。行列とベクトルの積の計算手順と、それが全結合層そのものであることを、詳しく確認します。この連載の中心となる章です。

第8章に進む前に、演習3のコードを実行し、ループと行列積の速度差を自分の目で確認しておいてください。この差を体感しておくと、次章以降の内容が、単なる数学の話ではなく、実務に直結する話として受け取れるはずです。

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

投稿者プロフィール

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

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