ニューラルネットワークの基礎 — パーセプトロンから多層へ

人間の脳には約860億個の神経細胞(ニューロン)が存在し、それぞれが数千のシナプスを通じて互いに接続されています。ある神経細胞が十分な電気信号を受け取ると「発火」し、次の神経細胞に信号を伝えます。この単純な仕組みの繰り返しが、画像認識、言語理解、運動制御といった高度な情報処理を実現しています。

では、この原理をコンピュータで模倣できないか — そんな問いから生まれたのがニューラルネットワーク(Neural Network)です。1943年にマカロック(McCulloch)とピッツ(Pitts)が神経細胞の数理モデルを提案して以来、ニューラルネットワークは幾度かの冬の時代を経て、現在の深層学習ブームへと至りました。

ニューラルネットワークを理解すると、以下のような応用が開けます。

  • 画像認識: 医療画像の病変検出、自動運転における物体認識、製造業での外観検査
  • 自然言語処理: 機械翻訳、文書要約、チャットボット、感情分析
  • 音声処理: 音声認識、音声合成、話者識別
  • 制御・最適化: ロボット制御、ゲームAI、化学プロセスの最適化

本記事の内容

  • パーセプトロンの数学的定義と幾何学的解釈
  • 線形分離可能性の限界とXOR問題
  • 多層パーセプトロン(MLP)のアーキテクチャ
  • 万能近似定理の直感的理解
  • Pythonによるスクラッチ実装と可視化

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

パーセプトロンとは

生物学的ニューロンとの対応

まず、生物の神経細胞がどのように働くかをイメージしましょう。神経細胞は、樹状突起を通じて複数の信号を受け取ります。各信号の強さはシナプスの結合強度によって調節されます。受け取った信号の総和がある閾値を超えると、軸索を通じて次の神経細胞へ信号を送ります(発火)。超えなければ、何も送りません(抑制)。

この仕組みを数学的にモデル化したものがパーセプトロンです。生物との対応を整理すると次のようになります。

生物の神経細胞 パーセプトロン
樹状突起(入力) 入力 $x_1, x_2, \ldots, x_n$
シナプス結合の強さ 重み $w_1, w_2, \ldots, w_n$
細胞体での信号統合 重み付き和 $\sum_i w_i x_i + b$
発火の閾値 バイアス $b$(閾値の負値)
発火 or 抑制 活性化関数 $f(\cdot)$

パーセプトロンの数学的定義

パーセプトロンの出力は、入力ベクトル $\bm{x} = (x_1, x_2, \ldots, x_n)^\top$ に対して次のように定義されます。

$$ \begin{equation} y = f\left(\sum_{i=1}^{n} w_i x_i + b\right) = f(\bm{w}^\top \bm{x} + b) \end{equation} $$

ここで $\bm{w} = (w_1, w_2, \ldots, w_n)^\top$ は重みベクトル、$b$ はバイアス、$f$ は活性化関数です。

ローゼンブラット(Rosenblatt, 1958)が提案した元祖パーセプトロンでは、活性化関数としてステップ関数を使います。

$$ f(z) = \begin{cases} 1 & (z \geq 0) \\ 0 & (z < 0) \end{cases} $$

つまり、入力の重み付き和がバイアスを超えれば1(発火)、超えなければ0(抑制)を出力します。これは生物のニューロンの「全か無かの法則」に対応しています。

幾何学的解釈

パーセプトロンの判定境界は $\bm{w}^\top \bm{x} + b = 0$ という超平面です。2次元の場合、これは直線 $w_1 x_1 + w_2 x_2 + b = 0$ になります。

重みベクトル $\bm{w}$ は判定境界に直交し、バイアス $b$ は判定境界の原点からの距離を制御します。パーセプトロンは、この超平面によって入力空間を2つの領域に分割し、各領域にクラスラベルを割り当てます。

イメージとしては、2次元の平面に散らばったデータ点を一本の直線で二分する作業です。直線の傾きと位置を調整して、できるだけ正確にクラスを分離します。

パーセプトロンの学習則

パーセプトロンの学習は、誤分類されたサンプルに対して重みを更新する単純な規則に基づきます。

データ $(\bm{x}^{(k)}, t^{(k)})$ が誤分類されたとき($t^{(k)} \in \{0, 1\}$ は正解ラベル、$y^{(k)}$ は予測)、次のように更新します。

$$ \begin{align} \bm{w} &\leftarrow \bm{w} + \eta (t^{(k)} – y^{(k)}) \bm{x}^{(k)} \\ b &\leftarrow b + \eta (t^{(k)} – y^{(k)}) \end{align} $$

ここで $\eta > 0$ は学習率です。

この更新則の直感は明快です。正解が1なのに0と予測した場合($t = 1, y = 0$)、$\bm{w}$ に $\eta \bm{x}$ を加えることで、次回このデータ点に対する $\bm{w}^\top \bm{x} + b$ の値が大きくなり、発火しやすくなります。逆に、正解が0なのに1と予測した場合は $\bm{w}$ から $\eta \bm{x}$ を引くことで発火しにくくなります。

パーセプトロン収束定理により、データが線形分離可能であれば、この更新を繰り返すことで有限回のステップで収束することが保証されています。

パーセプトロンの仕組みは非常にシンプルですが、ここで自然な疑問が生まれます — この単純なモデルでどこまでの問題が解けるのでしょうか?実は、パーセプトロンには根本的な限界があります。

パーセプトロンの限界 — XOR問題

線形分離可能性

パーセプトロンが正しく分類できるのは、線形分離可能(linearly separable)な問題に限られます。つまり、2つのクラスを1本の直線(あるいは超平面)で完全に分離できる必要があります。

AND演算と OR演算は線形分離可能です。

  • AND: $(0,0) \to 0$, $(0,1) \to 0$, $(1,0) \to 0$, $(1,1) \to 1$ — 直線 $x_1 + x_2 – 1.5 = 0$ で分離可能
  • OR: $(0,0) \to 0$, $(0,1) \to 1$, $(1,0) \to 1$, $(1,1) \to 1$ — 直線 $x_1 + x_2 – 0.5 = 0$ で分離可能

XOR問題

ところが、XOR(排他的論理和)は線形分離可能ではありません。

  • XOR: $(0,0) \to 0$, $(0,1) \to 1$, $(1,0) \to 1$, $(1,1) \to 0$

XORでは、対角線上の点が同じクラスに属します。どのように直線を引いても、4つの点を正しく分離することは不可能です。これは幾何学的に明らかで、クラス0の点($(0,0)$ と $(1,1)$)とクラス1の点($(0,1)$ と $(1,0)$)が交互に配置されているためです。

1969年、ミンスキー(Minsky)とパパート(Papert)は著書『Perceptrons』でこの限界を指摘し、単純パーセプトロンでは非線形な問題を解けないことを数学的に証明しました。この発表はニューラルネットワーク研究の「第一次冬の時代」を引き起こしました。

しかし、この限界を突破する方法は実はシンプルです。パーセプトロンを層状に積み重ねることで、非線形な判定境界を実現できるのです。

import numpy as np
import matplotlib.pyplot as plt

# --- パーセプトロンの限界: AND, OR, XOR の可視化 ---
fig, axes = plt.subplots(1, 3, figsize=(15, 4.5))

# データ
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
gates = {
    "AND": np.array([0, 0, 0, 1]),
    "OR":  np.array([0, 1, 1, 1]),
    "XOR": np.array([0, 1, 1, 0]),
}
# 分離直線のパラメータ (w1, w2, b) → w1*x1 + w2*x2 + b = 0
boundaries = {
    "AND": (1, 1, -1.5),
    "OR":  (1, 1, -0.5),
    "XOR": None,
}

for ax, (name, y) in zip(axes, gates.items()):
    # データ点のプロット
    for label, marker, color in [(0, "o", "red"), (1, "^", "blue")]:
        mask = y == label
        ax.scatter(X[mask, 0], X[mask, 1], s=150, c=color, marker=marker,
                   edgecolors="black", linewidth=1.5, zorder=5,
                   label=f"Class {label}")

    # 分離直線
    boundary = boundaries[name]
    if boundary is not None:
        w1, w2, b = boundary
        x1_line = np.linspace(-0.3, 1.3, 100)
        x2_line = -(w1 * x1_line + b) / w2
        ax.plot(x1_line, x2_line, "g--", linewidth=2, alpha=0.8,
                label="Decision boundary")
        ax.fill_between(x1_line, x2_line, 1.5, alpha=0.1, color="blue")
        ax.fill_between(x1_line, x2_line, -0.5, alpha=0.1, color="red")
    else:
        ax.text(0.5, -0.3, "No linear boundary exists",
                ha="center", fontsize=10, color="red", fontweight="bold")

    ax.set_xlim(-0.4, 1.4)
    ax.set_ylim(-0.4, 1.4)
    ax.set_xlabel("$x_1$", fontsize=12)
    ax.set_ylabel("$x_2$", fontsize=12)
    ax.set_title(f"{name} Gate", fontsize=14, fontweight="bold")
    ax.legend(fontsize=9, loc="upper left")
    ax.grid(True, alpha=0.3)
    ax.set_aspect("equal")

plt.tight_layout()
plt.savefig("perceptron_limits.png", dpi=150, bbox_inches="tight")
plt.show()

上のグラフから、パーセプトロンの限界が視覚的に理解できます。

  1. AND(左図): 緑の破線で2つのクラスが完全に分離されています。クラス1の点 $(1,1)$ は直線の上側に、クラス0の3点は下側に位置しています。パーセプトロンで解ける問題です

  2. OR(中央図): こちらも緑の破線で完全に分離可能です。クラス0の点 $(0,0)$ だけが直線の下側に位置し、他の3点はクラス1として上側にあります

  3. XOR(右図): どのように直線を引いても、4つの点を正しく分離できません。クラス0の $(0,0)$ と $(1,1)$ が対角に、クラス1の $(0,1)$ と $(1,0)$ も対角に配置されているため、線形分離は不可能です。ここに「No linear boundary exists」と表示されています

この限界を克服するために、パーセプトロンを複数の層に積み重ねた多層パーセプトロンが必要になります。

多層パーセプトロン(MLP)

なぜ「層を重ねる」と非線形問題が解けるのか

多層パーセプトロン(Multi-Layer Perceptron, MLP)の核心的なアイデアは、入力空間を非線形に変換して、変換後の空間で線形分離することです。

XOR問題を例に考えましょう。$(0,0)$ と $(1,1)$ が同じクラス、$(0,1)$ と $(1,0)$ が同じクラスですが、元の2次元空間では直線で分離できません。しかし、もし中間層で入力を別の空間に「写像」できれば、その新しい空間では線形分離可能かもしれません。

具体的に、中間層に2つのユニットを用意し、次の変換を行います。

$$ \begin{align} h_1 &= \text{step}(x_1 + x_2 – 0.5) \quad (\text{OR演算に相当}) \\ h_2 &= \text{step}(x_1 + x_2 – 1.5) \quad (\text{AND演算に相当}) \end{align} $$

この変換により、4つの入力パターンは次のように写像されます。

$(x_1, x_2)$ $h_1$ (OR) $h_2$ (AND) XOR
$(0, 0)$ 0 0 0
$(0, 1)$ 1 0 1
$(1, 0)$ 1 0 1
$(1, 1)$ 1 1 0

$(h_1, h_2)$ の空間を見ると、クラス0は $(0,0)$ と $(1,1)$、クラス1は $(1,0)$ です。驚くべきことに、この新しい空間では $h_1 – h_2 – 0.5 = 0$ という直線で分離可能になっています。

つまり、出力層は $y = \text{step}(h_1 – h_2 – 0.5)$ とすれば $\text{XOR}(x_1, x_2) = \text{OR}(x_1, x_2) \text{ AND NOT } \text{AND}(x_1, x_2)$ を実現できます。

MLPのアーキテクチャ

一般的なMLPは、入力層、1つ以上の隠れ層、出力層から構成されます。

入力層: $n$ 個の入力ユニット。入力ベクトル $\bm{x} \in \mathbb{R}^n$ をそのまま次の層に渡します。

隠れ層($l$ 番目): $n_l$ 個のユニットを持ち、前の層の出力に線形変換と非線形活性化関数を適用します。

$$ \begin{equation} \bm{h}^{(l)} = \sigma\left(\bm{W}^{(l)} \bm{h}^{(l-1)} + \bm{b}^{(l)}\right) \end{equation} $$

ここで $\bm{W}^{(l)} \in \mathbb{R}^{n_l \times n_{l-1}}$ は重み行列、$\bm{b}^{(l)} \in \mathbb{R}^{n_l}$ はバイアスベクトル、$\sigma$ は活性化関数、$\bm{h}^{(0)} = \bm{x}$ です。

出力層($L$ 番目): タスクに応じた活性化関数を使用します。

  • 回帰: 恒等関数($f(z) = z$)
  • 二値分類: シグモイド関数
  • 多クラス分類: ソフトマックス関数

活性化関数の役割

多層にする意味があるのは、活性化関数が非線形であるときに限ります。もし全ての層で恒等関数 $\sigma(z) = z$ を使うと、ネットワーク全体の出力は

$$ \bm{y} = \bm{W}^{(L)} \cdots \bm{W}^{(2)} \bm{W}^{(1)} \bm{x} + \text{(バイアス項)} $$

となり、行列の積はやはり行列なので、単なる1つの線形変換に帰着してしまいます。つまり、非線形活性化関数がなければ、何層重ねても単層パーセプトロンと同じ表現力しかありません。

代表的な活性化関数を紹介します。

シグモイド関数:

$$ \sigma(z) = \frac{1}{1 + e^{-z}} $$

出力を $(0, 1)$ の範囲に変換します。歴史的に最も広く使われましたが、勾配消失問題のため深いネットワークでは使われにくくなっています。

ReLU(Rectified Linear Unit):

$$ \text{ReLU}(z) = \max(0, z) $$

計算が単純で、正の領域では勾配が1なので勾配消失が起きにくいという利点があります。現在最も広く使われている活性化関数です。

tanh:

$$ \tanh(z) = \frac{e^z – e^{-z}}{e^z + e^{-z}} $$

出力が $(-1, 1)$ に正規化され、原点対称です。シグモイドより勾配消失が緩和されますが、ReLUほどではありません。

活性化関数は、ネットワークに非線形性を導入する要(かなめ)です。では、非線形活性化関数を持つ多層ネットワークは、理論的にどれだけの表現力を持つのでしょうか?

万能近似定理

定理の主張

多層パーセプトロンの表現力に関する最も重要な理論的結果が万能近似定理(Universal Approximation Theorem)です。1989年にホーニック(Hornik)、スティンクコンブ(Stinchcombe)、ホワイト(White)によって証明されました。

直感的に言えば、十分に幅の広い1層の隠れ層を持つネットワークは、任意の連続関数を望みの精度で近似できるという主張です。

定理: $\sigma$ がシグモイド関数のような非定数かつ有界で単調増加な連続関数であるとき、コンパクト集合 $K \subset \mathbb{R}^n$ 上の任意の連続関数 $f: K \to \mathbb{R}$ と任意の $\varepsilon > 0$ に対して、ある正の整数 $N$ と定数 $v_i, b_i \in \mathbb{R}$、ベクトル $\bm{w}_i \in \mathbb{R}^n$ が存在して

$$ \left| f(\bm{x}) – \sum_{i=1}^{N} v_i \sigma(\bm{w}_i^\top \bm{x} + b_i) \right| < \varepsilon \quad \text{for all } \bm{x} \in K $$

が成り立ちます。

直感的理解

万能近似定理を直感的に理解するために、1次元の場合を考えましょう。

シグモイド関数 $\sigma(wz + b)$ は、$w$ を大きくすると急激に立ち上がるステップ関数に近づきます。$b$ を調整すると、ステップの位置(立ち上がり点)を制御できます。

2つのシグモイド関数を組み合わせて「バンプ」(小さな山)を作れます。

$$ \text{bump}(z) = \sigma(w(z – a) + c) – \sigma(w(z – b) + c) $$

$w$ が十分大きければ、これは $[a, b]$ の区間で値が約1、それ以外で約0の矩形パルスになります。高さを $v_i$ で調整すれば、任意の高さの矩形を作れます。

任意の連続関数は、十分に細かい矩形の和で近似できます(リーマン和の考え方と同じ)。したがって、十分な数の隠れユニットを使えば、どんな連続関数でも近似可能なのです。

注意点

万能近似定理は「存在」を保証するだけであり、「学習で見つけられるか」は保証しません。

  • 必要なユニット数 $N$ が指数的に大きくなる場合がある
  • 適切な重みを勾配降下法で見つけられる保証はない
  • 深さ(層の数)を増やすと、幅(各層のユニット数)を指数的に削減できることが知られている

実用的には、幅より深さの方が効率的に複雑な関数を表現できることが多く、これが「深層学習」の理論的根拠の一つとなっています。

万能近似定理はニューラルネットワークの「表現力」を保証しますが、実際にそのような重みを見つけるためには、適切な学習アルゴリズムが必要です。次に、MLPの学習で中心的な役割を果たす損失関数と順伝播の計算を見ていきましょう。

順伝播(Forward Propagation)

計算の流れ

MLPの推論時に行う計算を順伝播(forward propagation)と呼びます。入力層から出力層に向かって、各層の出力を順番に計算していきます。

$L$ 層のネットワーク(隠れ層 $L-1$ 個 + 出力層1個)を考えます。$\bm{h}^{(0)} = \bm{x}$ として

$$ \begin{align} \bm{z}^{(l)} &= \bm{W}^{(l)} \bm{h}^{(l-1)} + \bm{b}^{(l)} \quad (l = 1, 2, \ldots, L) \\ \bm{h}^{(l)} &= \sigma_l(\bm{z}^{(l)}) \quad (l = 1, 2, \ldots, L) \end{align} $$

ここで $\bm{z}^{(l)}$ は $l$ 層目のプレアクティベーション(活性化関数適用前の値)、$\bm{h}^{(l)}$ は $l$ 層目のアクティベーション(活性化関数適用後の値)です。最終出力は $\hat{\bm{y}} = \bm{h}^{(L)}$ です。

損失関数

ネットワークの出力 $\hat{\bm{y}}$ と正解 $\bm{t}$ のズレを測る関数が損失関数(loss function)です。

回帰問題(平均二乗誤差):

$$ \mathcal{L}_{\text{MSE}} = \frac{1}{2}\|\hat{\bm{y}} – \bm{t}\|^2 = \frac{1}{2}\sum_{k=1}^{K}(\hat{y}_k – t_k)^2 $$

二値分類(二値交差エントロピー):

$$ \mathcal{L}_{\text{BCE}} = -[t \ln \hat{y} + (1-t) \ln(1-\hat{y})] $$

多クラス分類(交差エントロピー):

$$ \mathcal{L}_{\text{CE}} = -\sum_{k=1}^{K} t_k \ln \hat{y}_k $$

ここで $\hat{y}_k$ はソフトマックスの出力(クラス $k$ の予測確率)、$t_k$ は正解のone-hotベクトルの $k$ 成分です。

$N$ 個の訓練データ全体での損失は、各サンプルの損失の平均で定義されます。

$$ J(\bm{\Theta}) = \frac{1}{N}\sum_{n=1}^{N} \mathcal{L}(\hat{\bm{y}}^{(n)}, \bm{t}^{(n)}) $$

ここで $\bm{\Theta} = \{\bm{W}^{(1)}, \bm{b}^{(1)}, \ldots, \bm{W}^{(L)}, \bm{b}^{(L)}\}$ はネットワークの全パラメータです。

ソフトマックス関数

多クラス分類で使われるソフトマックス関数は、出力層の各ユニットの値を確率に変換します。

$$ \hat{y}_k = \text{softmax}(\bm{z})_k = \frac{e^{z_k}}{\sum_{j=1}^{K} e^{z_j}} $$

各出力は $0 < \hat{y}_k < 1$ を満たし、$\sum_k \hat{y}_k = 1$ なので確率分布として解釈できます。$z_k$ が大きいクラスほど高い確率が割り当てられます。

順伝播で損失を計算できるようになりました。次は、この損失を最小化するためにパラメータをどのように更新するか、つまり誤差逆伝播法(バックプロパゲーション)の仕組みを簡潔に見ておきましょう。

学習の仕組み — 勾配降下法の概要

パラメータ更新の基本

ニューラルネットワークの学習は、損失関数 $J(\bm{\Theta})$ を最小化するパラメータ $\bm{\Theta}$ を見つけることです。これには勾配降下法を使います。

$$ \bm{\Theta} \leftarrow \bm{\Theta} – \eta \frac{\partial J}{\partial \bm{\Theta}} $$

ここで $\eta$ は学習率です。各パラメータに対する勾配 $\partial J / \partial \bm{\Theta}$ を計算し、損失が減る方向にパラメータを更新します。

問題は、多層ネットワークではパラメータが数千〜数億個あり、全ての勾配を効率的に計算する必要があることです。これを解決するのが誤差逆伝播法です。

連鎖律の直感

誤差逆伝播法の核心は微分の連鎖律(chain rule)です。

合成関数 $f(g(x))$ の微分は $\frac{df}{dx} = \frac{df}{dg} \cdot \frac{dg}{dx}$ で計算できます。多層ネットワークは関数の合成なので、出力層から入力層に向かって連鎖律を適用すれば、全ての勾配を系統的に計算できます。

$l$ 層目の重み $\bm{W}^{(l)}$ に対する勾配は、出力層からの「誤差信号」$\bm{\delta}^{(l)}$ と前の層の出力 $\bm{h}^{(l-1)}$ の積で表せます。

$$ \frac{\partial J}{\partial \bm{W}^{(l)}} = \bm{\delta}^{(l)} (\bm{h}^{(l-1)})^\top $$

ここで $\bm{\delta}^{(l)} = \frac{\partial J}{\partial \bm{z}^{(l)}}$ は、$l$ 層目のプレアクティベーションに対する勾配です。誤差信号は出力層から入力層に向かって再帰的に計算されます。

$$ \bm{\delta}^{(l)} = \left((\bm{W}^{(l+1)})^\top \bm{\delta}^{(l+1)}\right) \odot \sigma'(\bm{z}^{(l)}) $$

ここで $\odot$ は要素ごとの積(アダマール積)、$\sigma’$ は活性化関数の導関数です。

この計算は「誤差が出力層から入力層へ逆方向に伝播する」ように見えるため、「逆伝播」と呼ばれます。詳細な導出は次の記事で解説します。

勾配の計算方法を概観したところで、ここまでの理論をPythonで実装して、MLPが実際に非線形な問題を学習できることを確認してみましょう。

Pythonでの実装

スクラッチでMLPを実装する

まず、2層MLP(隠れ層1つ + 出力層)をNumPyだけで実装し、XOR問題を解いてみます。活性化関数にはシグモイドを、損失関数には二値交差エントロピーを使用します。

import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)

# --- スクラッチMLP: XOR問題 ---

def sigmoid(z):
    """シグモイド活性化関数"""
    return 1 / (1 + np.exp(-np.clip(z, -500, 500)))

def sigmoid_derivative(a):
    """シグモイドの導関数(出力aから計算)"""
    return a * (1 - a)

class SimpleMLP:
    """2層MLP(隠れ層1つ + 出力層)"""

    def __init__(self, input_dim, hidden_dim, output_dim, lr=1.0):
        # 重みの初期化(小さなランダム値)
        self.W1 = np.random.randn(hidden_dim, input_dim) * 0.5
        self.b1 = np.zeros((hidden_dim, 1))
        self.W2 = np.random.randn(output_dim, hidden_dim) * 0.5
        self.b2 = np.zeros((output_dim, 1))
        self.lr = lr

    def forward(self, X):
        """順伝播"""
        self.z1 = self.W1 @ X + self.b1       # 隠れ層のプレアクティベーション
        self.h1 = sigmoid(self.z1)             # 隠れ層のアクティベーション
        self.z2 = self.W2 @ self.h1 + self.b2  # 出力層のプレアクティベーション
        self.h2 = sigmoid(self.z2)             # 出力層のアクティベーション
        return self.h2

    def backward(self, X, t):
        """逆伝播によるパラメータ更新"""
        m = X.shape[1]  # サンプル数

        # 出力層の誤差信号
        delta2 = self.h2 - t  # 交差エントロピー + シグモイドの場合

        # 出力層の勾配
        dW2 = (1 / m) * delta2 @ self.h1.T
        db2 = (1 / m) * np.sum(delta2, axis=1, keepdims=True)

        # 隠れ層の誤差信号
        delta1 = (self.W2.T @ delta2) * sigmoid_derivative(self.h1)

        # 隠れ層の勾配
        dW1 = (1 / m) * delta1 @ X.T
        db1 = (1 / m) * np.sum(delta1, axis=1, keepdims=True)

        # パラメータ更新
        self.W2 -= self.lr * dW2
        self.b2 -= self.lr * db2
        self.W1 -= self.lr * dW1
        self.b1 -= self.lr * db1

    def compute_loss(self, y_pred, t):
        """二値交差エントロピー損失"""
        eps = 1e-8
        return -np.mean(t * np.log(y_pred + eps)
                        + (1 - t) * np.log(1 - y_pred + eps))

# XORデータ
X = np.array([[0, 0, 1, 1],
              [0, 1, 0, 1]])  # (2, 4)
t = np.array([[0, 1, 1, 0]])  # (1, 4)

# モデルの学習
mlp = SimpleMLP(input_dim=2, hidden_dim=4, output_dim=1, lr=5.0)
losses = []
epochs = 5000

for epoch in range(epochs):
    y_pred = mlp.forward(X)
    loss = mlp.compute_loss(y_pred, t)
    mlp.backward(X, t)
    losses.append(loss)

# 学習結果の確認
y_final = mlp.forward(X)
print("XOR学習結果:")
for i in range(4):
    print(f"  入力: ({X[0,i]}, {X[1,i]}) → "
          f"予測: {y_final[0,i]:.4f}, 正解: {t[0,i]}")

このコードの出力を確認すると、MLPがXOR問題を正しく学習できていることがわかります。入力 $(0,0)$ と $(1,1)$ に対して0に近い値、$(0,1)$ と $(1,0)$ に対して1に近い値が出力されています。単層パーセプトロンでは解けなかったXOR問題が、隠れ層を1つ追加するだけで解決できました。

次に、学習の過程と判定境界を可視化しましょう。

import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)

def sigmoid(z):
    return 1 / (1 + np.exp(-np.clip(z, -500, 500)))

def sigmoid_derivative(a):
    return a * (1 - a)

class SimpleMLP:
    def __init__(self, input_dim, hidden_dim, output_dim, lr=1.0):
        self.W1 = np.random.randn(hidden_dim, input_dim) * 0.5
        self.b1 = np.zeros((hidden_dim, 1))
        self.W2 = np.random.randn(output_dim, hidden_dim) * 0.5
        self.b2 = np.zeros((output_dim, 1))
        self.lr = lr

    def forward(self, X):
        self.z1 = self.W1 @ X + self.b1
        self.h1 = sigmoid(self.z1)
        self.z2 = self.W2 @ self.h1 + self.b2
        self.h2 = sigmoid(self.z2)
        return self.h2

    def backward(self, X, t):
        m = X.shape[1]
        delta2 = self.h2 - t
        dW2 = (1 / m) * delta2 @ self.h1.T
        db2 = (1 / m) * np.sum(delta2, axis=1, keepdims=True)
        delta1 = (self.W2.T @ delta2) * sigmoid_derivative(self.h1)
        dW1 = (1 / m) * delta1 @ X.T
        db1 = (1 / m) * np.sum(delta1, axis=1, keepdims=True)
        self.W2 -= self.lr * dW2
        self.b2 -= self.lr * db2
        self.W1 -= self.lr * dW1
        self.b1 -= self.lr * db1

    def compute_loss(self, y_pred, t):
        eps = 1e-8
        return -np.mean(t * np.log(y_pred + eps)
                        + (1 - t) * np.log(1 - y_pred + eps))

# XORデータ
X = np.array([[0, 0, 1, 1], [0, 1, 0, 1]])
t = np.array([[0, 1, 1, 0]])

mlp = SimpleMLP(input_dim=2, hidden_dim=4, output_dim=1, lr=5.0)
losses = []
for epoch in range(5000):
    y_pred = mlp.forward(X)
    loss = mlp.compute_loss(y_pred, t)
    mlp.backward(X, t)
    losses.append(loss)

# --- 可視化 ---
fig, axes = plt.subplots(1, 2, figsize=(14, 5.5))

# (a) 損失の推移
ax = axes[0]
ax.plot(losses, linewidth=2, color="steelblue")
ax.set_xlabel("Epoch", fontsize=12)
ax.set_ylabel("Binary Cross-Entropy Loss", fontsize=12)
ax.set_title("Training Loss for XOR Problem", fontsize=13)
ax.set_yscale("log")
ax.grid(True, alpha=0.3)

# (b) 判定境界の可視化
ax = axes[1]
xx, yy = np.meshgrid(np.linspace(-0.5, 1.5, 200),
                      np.linspace(-0.5, 1.5, 200))
grid = np.c_[xx.ravel(), yy.ravel()].T  # (2, 40000)
zz = mlp.forward(grid).reshape(xx.shape)

ax.contourf(xx, yy, zz, levels=50, cmap="RdBu_r", alpha=0.8)
ax.contour(xx, yy, zz, levels=[0.5], colors="black", linewidths=2)

# データ点
X_plot = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
t_plot = np.array([0, 1, 1, 0])
for label, marker, color in [(0, "o", "red"), (1, "^", "blue")]:
    mask = t_plot == label
    ax.scatter(X_plot[mask, 0], X_plot[mask, 1], s=200, c=color,
               marker=marker, edgecolors="black", linewidth=2, zorder=5,
               label=f"Class {label}")

ax.set_xlabel("$x_1$", fontsize=12)
ax.set_ylabel("$x_2$", fontsize=12)
ax.set_title("Decision Boundary (MLP for XOR)", fontsize=13)
ax.legend(fontsize=11)
ax.set_xlim(-0.5, 1.5)
ax.set_ylim(-0.5, 1.5)
ax.set_aspect("equal")

plt.tight_layout()
plt.savefig("mlp_xor.png", dpi=150, bbox_inches="tight")
plt.show()

この可視化から、MLPの学習過程と判定能力がよくわかります。

  1. 左図(損失の推移): 学習初期に急速に損失が減少し、約1000エポック以降はほぼ収束しています。対数スケールで見ると、初期の急激な減少と後半の緩やかな収束が確認できます。XOR問題は4点しかないため、十分なエポック数で損失はほぼ0に近づきます

  2. 右図(判定境界): MLPが学習した判定境界は非線形な曲線になっています。黒い線(出力 = 0.5の等高線)が、単層パーセプトロンでは不可能だった非線形分離を実現しています。赤い領域(クラス0)が左下と右上に、青い領域(クラス1)が左上と右下に正しく割り当てられています。カラーマップの濃淡は予測の確信度を示し、データ点付近で最も濃くなっています

より複雑な問題:月型データセット

XORは4点だけの問題でした。より現実的な非線形分離問題として、scikit-learnの月型データセットを使ってみましょう。

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

np.random.seed(42)

def sigmoid(z):
    return 1 / (1 + np.exp(-np.clip(z, -500, 500)))

def relu(z):
    return np.maximum(0, z)

def relu_derivative(z):
    return (z > 0).astype(float)

class MLP_ReLU:
    """ReLU活性化関数を使った2層MLP"""

    def __init__(self, input_dim, hidden_dim, output_dim, lr=0.1):
        # He初期化
        self.W1 = np.random.randn(hidden_dim, input_dim) * np.sqrt(2.0 / input_dim)
        self.b1 = np.zeros((hidden_dim, 1))
        self.W2 = np.random.randn(output_dim, hidden_dim) * np.sqrt(2.0 / hidden_dim)
        self.b2 = np.zeros((output_dim, 1))
        self.lr = lr

    def forward(self, X):
        self.z1 = self.W1 @ X + self.b1
        self.h1 = relu(self.z1)
        self.z2 = self.W2 @ self.h1 + self.b2
        self.h2 = sigmoid(self.z2)
        return self.h2

    def backward(self, X, t):
        m = X.shape[1]
        delta2 = self.h2 - t
        dW2 = (1 / m) * delta2 @ self.h1.T
        db2 = (1 / m) * np.sum(delta2, axis=1, keepdims=True)
        delta1 = (self.W2.T @ delta2) * relu_derivative(self.z1)
        dW1 = (1 / m) * delta1 @ X.T
        db1 = (1 / m) * np.sum(delta1, axis=1, keepdims=True)
        self.W2 -= self.lr * dW2
        self.b2 -= self.lr * db2
        self.W1 -= self.lr * dW1
        self.b1 -= self.lr * db1

    def compute_loss(self, y_pred, t):
        eps = 1e-8
        return -np.mean(t * np.log(y_pred + eps)
                        + (1 - t) * np.log(1 - y_pred + eps))

# 月型データの生成
X_data, y_data = make_moons(n_samples=500, noise=0.2, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
    X_data, y_data, test_size=0.2, random_state=42)

# 標準化
scaler = StandardScaler()
X_train = scaler.fit_transform(X_train)
X_test = scaler.transform(X_test)

# 転置(列がサンプルになるように)
X_tr = X_train.T  # (2, 400)
t_tr = y_train.reshape(1, -1)  # (1, 400)
X_te = X_test.T
t_te = y_test.reshape(1, -1)

# 異なる隠れ層サイズで学習
hidden_sizes = [2, 8, 32]
models = {}

fig, axes = plt.subplots(1, 3, figsize=(16, 5))

for ax, h_size in zip(axes, hidden_sizes):
    model = MLP_ReLU(input_dim=2, hidden_dim=h_size, output_dim=1, lr=0.5)
    for epoch in range(3000):
        y_pred = model.forward(X_tr)
        model.backward(X_tr, t_tr)

    # 判定境界の可視化
    xx, yy = np.meshgrid(np.linspace(-3, 3, 200),
                          np.linspace(-3, 3, 200))
    grid = np.c_[xx.ravel(), yy.ravel()].T
    zz = model.forward(grid).reshape(xx.shape)

    ax.contourf(xx, yy, zz, levels=50, cmap="RdBu_r", alpha=0.7)
    ax.contour(xx, yy, zz, levels=[0.5], colors="black", linewidths=2)

    for label, marker, color in [(0, "o", "red"), (1, "^", "blue")]:
        mask = y_train == label
        ax.scatter(X_train[mask, 0], X_train[mask, 1], s=20, c=color,
                   marker=marker, alpha=0.6, edgecolors="none")

    # テスト精度
    y_test_pred = (model.forward(X_te) > 0.5).astype(int)
    acc = np.mean(y_test_pred == t_te)

    ax.set_xlabel("$x_1$", fontsize=12)
    ax.set_ylabel("$x_2$", fontsize=12)
    ax.set_title(f"Hidden units = {h_size}\nTest accuracy = {acc:.1%}",
                 fontsize=13)
    ax.set_xlim(-3, 3)
    ax.set_ylim(-3, 3)
    ax.set_aspect("equal")

plt.tight_layout()
plt.savefig("mlp_moons.png", dpi=150, bbox_inches="tight")
plt.show()

この実験結果から、隠れ層のユニット数がモデルの表現力に与える影響が明確に読み取れます。

  1. 隠れユニット数 = 2(左図): 判定境界がほぼ直線的で、月型データの複雑な分離に対応できていません。2つのユニットでは、ReLUで作れる「折れ線」の自由度が不足しています。精度は低めです

  2. 隠れユニット数 = 8(中央図): 判定境界が滑らかな曲線になり、2つのクラスをうまく分離しています。8ユニットあれば、月型の曲線的な境界を十分に表現できます

  3. 隠れユニット数 = 32(右図): さらに複雑な判定境界が形成されています。データ点の分布に細かく適合していますが、過学習の兆候(データのノイズに合わせた複雑な境界)も見え始めています

この結果は万能近似定理と整合的です。隠れユニットを増やすことで、より複雑な関数を表現できるようになります。ただし、ユニット数が多すぎると過学習のリスクが高まるため、適切なモデルサイズの選択が重要です。

ニューラルネットワークの実践的なポイント

データの前処理

ニューラルネットワークは入力特徴量のスケールに敏感です。特徴量ごとにスケールが大きく異なると、勾配の方向が歪み、学習が遅くなったり不安定になったりします。

標準化(StandardScaler): 各特徴量の平均を0、分散を1にする

$$ x’ = \frac{x – \mu}{\sigma} $$

正規化(MinMaxScaler): 各特徴量を $[0, 1]$ の範囲にスケーリングする

$$ x’ = \frac{x – x_{\min}}{x_{\max} – x_{\min}} $$

前処理は必ず訓練データのみの統計量で行い、テストデータには訓練データの統計量を適用します。これは「未知のデータに対する汎化性能を評価する」という原則に基づいています。

ミニバッチ学習

全訓練データで勾配を計算するのは、データが大きいとき計算コストが高すぎます。実用的には、データをランダムに $B$ 個のサンプルからなるミニバッチに分割し、各ミニバッチで勾配を計算してパラメータを更新します。

$$ \bm{\Theta} \leftarrow \bm{\Theta} – \eta \frac{1}{B}\sum_{i \in \mathcal{B}} \nabla_{\bm{\Theta}} \mathcal{L}(\hat{\bm{y}}^{(i)}, \bm{t}^{(i)}) $$

ミニバッチサイズ $B$ は典型的に32, 64, 128, 256のいずれかが使われます。$B = 1$ は確率的勾配降下法(SGD)、$B = N$(全データ)はバッチ勾配降下法に対応します。

ハイパーパラメータの選択

ニューラルネットワークの性能に大きく影響するハイパーパラメータを整理します。

ハイパーパラメータ 典型的な範囲 影響
学習率 $\eta$ $10^{-4} \sim 10^{-1}$ 小さすぎると収束が遅く、大きすぎると発散する
隠れ層の数 1 〜 数十 多いほど表現力が上がるが、学習が難しくなる
隠れ層のユニット数 16 〜 数千 多いほど表現力が上がるが、過学習しやすくなる
ミニバッチサイズ $B$ 32 〜 256 小さいほどノイズが多いが汎化に良い場合がある
エポック数 数十 〜 数千 早期停止(Early Stopping)と組み合わせる

これらの選択にはクロスバリデーションやグリッドサーチが使われますが、実務的には学習率が最も重要です。Adam等の適応的学習率オプティマイザを使えば、学習率の設定が多少粗くても安定して学習できます。

ここまでで、ニューラルネットワークの理論的な全体像を把握できました。最後に、これまでの内容を整理しましょう。

まとめ

本記事では、ニューラルネットワークの基礎について、パーセプトロンから多層ネットワークまで段階的に解説しました。

  • パーセプトロンは生物の神経細胞を模倣した最も単純な計算モデルで、入力の重み付き和に活性化関数を適用して出力する。判定境界は超平面(直線)であり、線形分離可能な問題のみ解ける
  • XOR問題は線形分離不可能な問題の代表例で、単層パーセプトロンでは解けない。ミンスキーとパパートによるこの指摘は第一次AI冬の時代を引き起こした
  • 多層パーセプトロン(MLP) は隠れ層を追加することで非線形な判定境界を実現する。隠れ層は入力空間を非線形に変換し、変換後の空間で線形分離を行う
  • 万能近似定理により、十分な幅の1隠れ層ネットワークは任意の連続関数を近似できる。ただし実用的には、深さを増やす方が効率的であることが多い
  • 学習は損失関数の最小化として定式化され、勾配降下法と誤差逆伝播法によってパラメータが更新される
  • 実践的には、データの前処理(標準化)、ミニバッチ学習、適切なハイパーパラメータ選択が重要

ニューラルネットワークの「表現力」と「学習の仕組み」の全体像をつかめたら、次は各要素を深掘りしていきましょう。

次のステップとして、以下の記事も参考にしてください。