第8章 行列とベクトルの積:全結合層の正体

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

第7章で、多数のニューロンの計算をまとめたいという動機を確認しました。第8章では、そのまとめ方を具体的に扱います。行列とベクトルの積です。この連載の中心となる章であり、ここを理解すれば、ニューラルネットワークの計算の大半が読み解けるようになります。実は、計算そのものは新しくありません。第6章で学んだ内積を、繰り返すだけです。ただし、どの行とどのベクトルの内積なのか、結果がどの位置に入るのかという対応関係を、正確に押さえる必要があります。この章では、その手順を丁寧に確認したうえで、それが全結合層そのものであることを示します。

行列とベクトルの積の計算手順

基本の考え方

行列とベクトルの積は、次のように計算します。

行列の各行と、ベクトルの内積を取ります。その結果を、順に並べます。

行列が3行4列であれば、行が3本あるので、内積を3回計算します。結果は3次元のベクトルになります。

具体例で確認する

次の行列とベクトルの積を計算します。

W = \begin{pmatrix} 1 & 0 & 2 \\ 0 & 3 & 1 \\ 2 & 1 & 0 \end{pmatrix}, \quad \mathbf{x} = \begin{pmatrix} 2 \\ 5 \\ 1 \end{pmatrix}

1行目とベクトルの内積を計算します。

1 \times 2 + 0 \times 5 + 2 \times 1 = 2 + 0 + 2 = 4

2行目とベクトルの内積を計算します。

0 \times 2 + 3 \times 5 + 1 \times 1 = 0 + 15 + 1 = 16

3行目とベクトルの内積を計算します。

2 \times 2 + 1 \times 5 + 0 \times 1 = 4 + 5 + 0 = 9

結果を縦に並べます。

W\mathbf{x} = \begin{pmatrix} 4 \\ 16 \\ 9 \end{pmatrix}

これが答えです。

第7章で個別に計算した結果と、一致していることを確認してください。まったく同じ計算を、まとめて書いただけです。

手順を言葉でまとめる

計算手順を、あらためて整理します。

第一に、行列の1行目を取り出します。

第二に、その行とベクトルの内積を計算します。

第三に、結果を、答えのベクトルの1番目の要素とします。

第四に、これを行列のすべての行について繰り返します。

答えのi番目の要素は、行列のi行目とベクトルの内積である。この対応を、しっかり押さえてください。

計算が成立する条件

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

行列の列数と、ベクトルの次元数が、一致していなければなりません。

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

なぜでしょうか。

行列の1行には、列数と同じ個数の数値が並んでいます。この行とベクトルの内積を計算するには、両者の要素数が一致している必要があります。第6章で確認したとおり、要素数が違うベクトルの内積は定義できません。

したがって、列数とベクトルの次元数が一致していなければ、計算できないのです。

形の変化を追う

計算前後の形を整理します。

3行4列の行列と、4次元のベクトルを掛けると、3次元のベクトルになります。

(3, 4) \times (4,) \rightarrow (3,)

内側の4が一致し、消えるイメージです。外側の3が残ります。

この内側が一致して消え、外側が残るという感覚は、第9章の行列どうしの積でも同じです。

形が合わない場合

3行4列の行列と、5次元のベクトルは掛けられません。

プログラムで実行すると、エラーになります。

ValueError: matmul: Input operand 1 has a mismatch in its core dimension 0

このエラーは、実務で最も頻繁に遭遇するものの一つです。第10章で、対処法を詳しく扱います。

これが全結合層の正体である

ここで、この連載の中心となる対応関係を確認します。

全結合層の計算は、次の式で表されます。

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

この式の各要素が、何に対応するかを整理します。

\mathbf{x} は、入力ベクトルです。次元数は、入力の特徴量の個数です。

W は、重み行列です。行数はニューロンの個数、列数は入力の次元数です。

\mathbf{b} は、バイアスベクトルです。次元数は、ニューロンの個数です。

\mathbf{y} は、出力ベクトルです。次元数は、ニューロンの個数です。

そして、W\mathbf{x} という計算が、この章で学んだ行列とベクトルの積です。

コードとの対応

PyTorchで全結合層を作るコードは、次のように書きます。

layer = nn.Linear(768, 3072)

この一行が意味しているのは、次のことです。

入力は768次元です。

出力は3072次元、つまりニューロンが3072個あります。

内部には、重み行列とバイアスベクトルが保持されています。

このとき、重み行列の形は3072行768列です。

実行時に何が起きているか

このレイヤーに、768次元のベクトルを入力すると、次の計算が実行されます。

3072個の各ニューロンについて、768次元の重みベクトルと入力ベクトルの内積を計算します。

内積は、768回の掛け算と767回の足し算です。

これを3072回繰り返します。

掛け算の総回数は、次のようになります。

3072 \times 768 = 2359296

約236万回の掛け算が、レイヤーを1回通すだけで実行されています。

このコードの背後で行われている計算の量を、実感してください。

重み行列の各行が持つ意味

第6章で、重みベクトルはそのニューロンが反応したいパターンを表すと説明しました。

行列としてまとめた場合、この解釈はどうなるでしょうか。

重み行列の各行が、それぞれ1つのパターンを表しています。

3072行あれば、3072種類のパターンが記録されていることになります。

入力ベクトルが与えられると、3072種類すべてのパターンとの類似度が、一度に計算されます。

出力ベクトルの各要素は、入力がそのパターンにどれだけ合致しているかを表す数値です。

特徴抽出という見方

この見方をすると、全結合層は特徴抽出器として機能していることが分かります。

入力を、あらかじめ用意された多数のパターンと照合し、それぞれとの一致度を出力する。

次の層は、その一致度の組み合わせを、さらに別のパターンと照合する。

層を重ねるほど、より複雑で抽象的なパターンが検出される、という構造になっています。

縦ベクトルと横ベクトル

ここで、記法についての注意点を扱います。

数学の教科書では、ベクトルを縦に書き、次のように表します。

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

行列が左、ベクトルが右です。

一方、ディープラーニングの文献やコードでは、次の書き方も頻繁に登場します。

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

ベクトルが左、行列が右です。この場合、ベクトルは横向きに扱われ、重み行列の形も転置されています。

なぜ二つの流儀があるのか

理由は、実装上の都合です。

第9章で扱いますが、実際には複数のデータをまとめて処理します。このとき、データを行方向に並べるのが自然です。

1行が1件のデータ、という構造にすると、XW という書き方のほうが素直になります。

PyTorchの実装も、この形を採用しています。

混乱を避けるために

どちらの記法を見ても、やっていることは同じです。

重要なのは、記号の順序ではなく、形が合っているかどうかです。

W\mathbf{x} の場合、W の列数と \mathbf{x} の次元数が一致します。

\mathbf{x}W の場合、\mathbf{x} の次元数と W の行数が一致します。

コードを読むときは、shape を確認して、どちらの流儀かを判断してください。

なお、PyTorchの nn.Linear では、weight という属性が出力次元、入力次元の形で保持されており、内部で転置して使われています。この点は、第10章であらためて扱います。

Pythonで確認する

import numpy as np

print("=== 行列とベクトルの積の基本 ===")
W = np.array([
    [1.0, 0.0, 2.0],
    [0.0, 3.0, 1.0],
    [2.0, 1.0, 0.0]
])
x = np.array([2.0, 5.0, 1.0])

print(f"W(形{W.shape}):")
print(W)
print(f"x(形{x.shape}): {x}")

y = W @ x
print(f"\nW @ x = {y}")

print("\n=== 行ごとの内積として確認 ===")
for i in range(W.shape[0]):
    row = W[i]
    dot = row @ x
    print(f"{i+1}行目 {row} との内積: {dot}")

print("\n=== 形の変化 ===")
test_cases = [
    (3, 4, 4),
    (5, 10, 10),
    (3072, 768, 768)
]

for rows, cols, vec_dim in test_cases:
    W_test = np.zeros((rows, cols))
    x_test = np.zeros(vec_dim)
    y_test = W_test @ x_test
    print(f"({rows}, {cols}) × ({vec_dim},) → {y_test.shape}")

print("\n=== 形が合わない場合 ===")
W_bad = np.zeros((3, 4))
x_bad = np.zeros(5)
try:
    W_bad @ x_bad
except ValueError as e:
    print(f"エラー: {e}")
    print("行列の列数(4)とベクトルの次元数(5)が一致していません。")

print("\n=== 全結合層の実装 ===")
np.random.seed(0)

class FullyConnectedLayer:
    def __init__(self, input_dim, output_dim):
        self.W = np.random.randn(output_dim, input_dim) * 0.1
        self.b = np.zeros(output_dim)
        self.input_dim = input_dim
        self.output_dim = output_dim
    
    def forward(self, x):
        return self.W @ x + self.b
    
    def num_parameters(self):
        return self.W.size + self.b.size

layer = FullyConnectedLayer(768, 3072)
print(f"入力次元: {layer.input_dim}")
print(f"出力次元(ニューロン数): {layer.output_dim}")
print(f"重み行列の形: {layer.W.shape}")
print(f"バイアスの形: {layer.b.shape}")
print(f"パラメータ数: {layer.num_parameters():,}")
print(f"掛け算の回数: {layer.W.size:,}")

x_input = np.random.randn(768)
y_output = layer.forward(x_input)
print(f"\n入力の形: {x_input.shape}")
print(f"出力の形: {y_output.shape}")

print("\n=== 重み行列の各行がパターンを表す ===")
W_pattern = np.array([
    [1.0, 0.0, 0.0, 0.0],     # 1番目の特徴だけを見る
    [0.0, 1.0, 0.0, 0.0],     # 2番目の特徴だけを見る
    [1.0, 1.0, 0.0, 0.0],     # 1番目と2番目の合計
    [1.0, -1.0, 0.0, 0.0],    # 1番目と2番目の差
    [0.25, 0.25, 0.25, 0.25]  # すべての平均
])
names = ["1番目のみ", "2番目のみ", "1+2の和", "1-2の差", "全体の平均"]

x_sample = np.array([10.0, 4.0, 6.0, 8.0])
y_sample = W_pattern @ x_sample

print(f"入力: {x_sample}")
print(f"\n{'ニューロン':>12} | {'重み':>26} | {'出力':>8}")
print("-" * 52)
for i, name in enumerate(names):
    print(f"{name:>12} | {str(W_pattern[i]):>26} | {y_sample[i]:>8.2f}")

print("\n=== PyTorchでの確認 ===")
try:
    import torch
    import torch.nn as nn
    
    torch.manual_seed(0)
    linear = nn.Linear(768, 3072)
    
    print(f"nn.Linear(768, 3072) の内部:")
    print(f"  weight の形: {linear.weight.shape}")
    print(f"  bias の形: {linear.bias.shape}")
    print(f"  パラメータ数: {sum(p.numel() for p in linear.parameters()):,}")
    
    x_torch = torch.randn(768)
    y_torch = linear(x_torch)
    print(f"\n入力の形: {x_torch.shape}")
    print(f"出力の形: {y_torch.shape}")
    
    manual = linear.weight @ x_torch + linear.bias
    print(f"手動計算と一致するか: {torch.allclose(y_torch, manual, atol=1e-6)}")
except ImportError:
    print("PyTorchがインストールされていないため、この部分はスキップします。")

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

つまずき1:行と列のどちらと内積を取るのか迷う

行列とベクトルの積では、行列の行とベクトルの内積を取ります。列ではありません。

覚え方として、行列の行を、ベクトルの上に横倒しにして重ねるというイメージが有効です。

紙の上で、行列の1行目を指でなぞりながら、ベクトルの各要素と対応させてみてください。一度手を動かすと、迷わなくなります。

つまずき2:結果の次元数を間違える

結果の次元数は、行列の行数です。列数ではありません。

3行4列の行列と4次元のベクトルを掛けると、3次元のベクトルになります。

行の数だけ内積を計算するから、行の数だけ結果が出る、と考えれば間違えません。

つまずき3:転置が必要かどうかで混乱する

W\mathbf{x}\mathbf{x}W のどちらを使うべきか、迷うことがあります。

判断基準は、形が合うかどうかです。合わなければ、転置が必要です。

コードを書くときは、まず shape を確認し、内側の次元が一致するように配置してください。

第10章で、この判断を体系的に扱います。

演習

演習1:手計算する

次の行列とベクトルの積を計算してください。

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

また、結果の次元数が、なぜその値になるのかを説明してください。

演習2:全結合層の計算を手計算する

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

W = \begin{pmatrix} 1 & 2 & 0 \\ 0 & 1 & 3 \\ 2 & 0 & 1 \end{pmatrix}, \quad \mathbf{b} = [1, -1, 2]

\mathbf{y} = W\mathbf{x} + \mathbf{b} を計算してください。

演習3:形が合うかを判定する

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

(3, 5) の行列と、5次元のベクトル

(4, 4) の行列と、4次元のベクトル

(2, 7) の行列と、3次元のベクトル

(768, 3072) の行列と、3072次元のベクトル

演習4:計算回数を数える

次の全結合層について、1回の計算で行われる掛け算の回数と、足し算の回数を求めてください。バイアスの加算も含めてください。

入力512次元、出力256次元の層

演習5:多層ネットワークを実装する

import numpy as np

np.random.seed(42)

class Layer:
    def __init__(self, input_dim, output_dim, name):
        self.W = np.random.randn(output_dim, input_dim) * 0.1
        self.b = np.zeros(output_dim)
        self.name = name
    
    def forward(self, x):
        y = self.W @ x + self.b
        print(f"{self.name:>8}: 入力{x.shape} × 重み{self.W.shape} → 出力{y.shape}")
        return y
    
    def num_params(self):
        return self.W.size + self.b.size

layers = [
    Layer(784, 128, "1層目"),
    Layer(128, 64, "2層目"),
    Layer(64, 10, "3層目")
]

print("=== 各層の構成 ===")
total_params = 0
for layer in layers:
    params = layer.num_params()
    total_params += params
    print(f"{layer.name}: 重み{layer.W.shape}, バイアス{layer.b.shape}, パラメータ{params:,}個")
print(f"\n全パラメータ数: {total_params:,}")

print("\n=== 順伝播の実行 ===")
x = np.random.randn(784)
print(f"{'入力':>8}: {x.shape}")

h = x
for layer in layers:
    h = layer.forward(h)

print(f"\n最終出力: {h.round(4)}")

print("\n=== 掛け算の総回数 ===")
total_mults = sum(layer.W.size for layer in layers)
print(f"1回の順伝播で行われる掛け算: {total_mults:,}回")

演習の解答例と解説

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

1行目:2 \times 4 + 1 \times 2 = 8 + 2 = 10

2行目:0 \times 4 + 3 \times 2 = 0 + 6 = 6

3行目:1 \times 4 + (-1) \times 2 = 4 - 2 = 2

結果は [10, 6, 2] です。

結果が3次元になる理由は、行列の行数が3だからです。行の数だけ内積を計算するため、結果の要素数も3になります。

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

まず W\mathbf{x} を計算します。

1行目:1 \times 3 + 2 \times 1 + 0 \times 2 = 3 + 2 + 0 = 5

2行目:0 \times 3 + 1 \times 1 + 3 \times 2 = 0 + 1 + 6 = 7

3行目:2 \times 3 + 0 \times 1 + 1 \times 2 = 6 + 0 + 2 = 8

W\mathbf{x} = [5, 7, 8]

次にバイアスを足します。

[5, 7, 8] + [1, -1, 2] = [6, 6, 10]

答えは [6, 6, 10] です。

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

(3, 5) と5次元は、計算可能です。列数5とベクトルの次元5が一致しています。結果は3次元です。

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

(2, 7) と3次元は、計算できません。列数7とベクトルの次元3が一致していません。

(768, 3072) と3072次元は、計算可能です。結果は768次元です。

四つ目は、GPT-2のMLP層の後半部分に相当します。3072次元を768次元に戻す変換です。

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

掛け算の回数は、重み行列の要素数と一致します。

256 \times 512 = 131072

約13万1千回です。

足し算の回数は、次のように計算されます。

各ニューロンの内積で、512個の項を足すため、511回の足し算が必要です。これが256個のニューロン分あります。

511 \times 256 = 130816

さらに、バイアスの加算が256回あります。

130816 + 256 = 131072

足し算も約13万1千回です。

掛け算と足し算が、ほぼ同数になることが分かります。実務では、この二つを合わせて計算量を見積もることが多くあります。

演習5では、各層で形がどう変化するかが表示されます。784次元の入力が、128次元、64次元、10次元と段階的に縮小していく様子を確認してください。

講師向けの補足

この章は、連載全体の中心です。時間配分を最も厚くしてください。

計算手順の説明では、必ず紙の上で手を動かしてもらってください。3行3列程度の小さな例で構いません。指で行をなぞりながら計算する経験が、記憶に残ります。

そのうえで、nn.Linear(768, 3072) と書いたとき、内部で236万回の掛け算が行われているという事実を示してください。コードの一行が持つ重みが実感されます。

また、演習5のように、実際に多層ネットワークを構成して、各層で形が変わっていく様子を表示させると、理解が定着します。形の変化を追う習慣が、第10章のエラー対処にもつながります。

受講者から、なぜ行と内積を取るのか、列ではだめなのか、という質問が出ることがあります。これは良い問いです。1つのニューロンが1行を占めているから、という第7章の内容に立ち返って説明してください。

理解度を確認する問い

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

行列とベクトルの積の計算手順を、自分の言葉で説明してみてください。

m行n列の行列と、k次元のベクトルが計算できるための条件と、結果の次元数を答えてください。

\mathbf{y} = W\mathbf{x} + \mathbf{b} という式の各記号が、全結合層の何に対応するかを説明してください。

重み行列の各行は、何を表していると解釈できるでしょうか。

nn.Linear(512, 256) と書いたとき、内部では何回の掛け算が行われるでしょうか。

まとめ

行列とベクトルの積は、行列の各行とベクトルの内積を計算し、その結果を順に並べたものです。答えのi番目の要素は、行列のi行目とベクトルの内積になります。

計算が成立する条件は、行列の列数とベクトルの次元数が一致することです。結果の次元数は、行列の行数と一致します。

そして、この計算こそが、全結合層の正体です。

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

重み行列の行数がニューロンの個数、列数が入力の次元数です。バイアスベクトルの次元数は、ニューロンの個数と一致します。

重み行列の各行は、そのニューロンが反応したいパターンを表しています。入力ベクトルが与えられると、すべてのパターンとの類似度が一度に計算され、その結果が出力ベクトルになります。

記法には、W\mathbf{x}\mathbf{x}W の二つの流儀があります。どちらも同じ計算を表しており、重要なのは記号の順序ではなく、形が合っているかどうかです。

次の第9章では、行列と行列の積を扱います。この章では入力が1件のベクトルでしたが、実際には複数のデータをまとめて処理します。入力を行列にすると何が起きるのか、なぜまとめて処理するほうが効率的なのかを解説します。

第9章に進む前に、演習1と演習2を、必ず紙の上で手計算してみてください。行と内積を取るという手順を、指でなぞって確認しておくことが、以降の章の理解を支えます。

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

投稿者プロフィール

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

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