NumPyだけで2層ニューラルネットを作る:逆伝播を勾配チェックしてDigitsを96.44%分類

実験日: 2026-08-24 / Python 3.14.4 / NumPy 2.5.2
自動微分・深層学習フレームワーク不使用

PyTorchなら数行でニューラルネットを学習できます。しかし、loss.backward()の内側で何が起きているか分からないままでは、学習が壊れたときに原因を追いにくくなります。

自動微分は便利ですが、中身を追えないままだと、学習が崩れたときに困ります。

今回はNumPyだけで、全結合層、ReLU、Softmax、交差エントロピー、逆伝播、モメンタム付きミニバッチSGDを実装しました。さらに「それらしい精度が出た」だけで終わらせず、解析的に求めた勾配を有限差分の数値勾配と比較しています。

作って確かめた結果

  • 構造:64入力 → 64 ReLU → 10 Softmax
  • パラメータ数:4,810
  • 6種類の重み初期値に対するテスト平均:96.44%
  • 標本SD:1.34ポイント
  • 最小~最大:93.89~97.78%
  • seed=42:96.39%、誤分類13/360枚
  • 勾配チェック最大相対誤差:1.08×10⁻⁹
  • pytest:2件合格

前記事の同じDigitsでは、ロジスティック回帰97.04%、RBF-SVM 98.06%でした。つまり、小さなニューラルネットを書いたから古典手法より高精度になるわけではありません。一方、順伝播と逆伝播を自分で追える実装ができ、次のPyTorch比較に使える基準が得られました。

NumPy製2層ニューラルネットの交差エントロピー損失と学習・検証正解率
NumPy製2層ニューラルネットの交差エントロピー損失と学習・検証正解率

同じデータ分割を使う

古典ML記事との比較を崩さないため、Digits 1,797枚のうち20%をseed=42でテストへ固定しました。残る学習側の10%をvalidationへ分けます。

用途 枚数 学習に使うか
training 1,293 重み更新に使用
validation 144 epoch選択に使用、重み更新には不使用
test 360 最後の性能報告だけに使用

テスト精度を見ながらepochを選ぶと、テストセットへ間接的に過適合します。今回はvalidation正解率が最高になった重みを保存し、testは最後に一度評価する設計です。

画素値0~16は16で割って0~1へ変換しました。この変換はデータ全体から統計量を学習しないので、平均や標準偏差を使う標準化とは違い、テスト情報の漏洩はありません。

2層ネットワークの順伝播

入力をX、1層目の重みとバイアスをW1, b1、2層目をW2, b2とします。

Z1 = X W1 + b1
H  = ReLU(Z1)
Z2 = H W2 + b2
P  = Softmax(Z2)

shapeも一緒に追うと、行列積の意味が見えます。

shape 意味
X (batch, 64) 8×8画像を平坦化
W1 (64, 64) 入力から隠れ層への重み
H (batch, 64) ReLU後の中間表現
W2 (64, 10) 隠れ層から10クラスへの重み
P (batch, 10) 各クラスの予測確率

NumPyコードは次の中心部分だけです。

hidden_pre = x @ w1 + b1
hidden = np.maximum(hidden_pre, 0)
logits = hidden @ w2 + b2

shifted = logits - logits.max(axis=1, keepdims=True)
exp_values = np.exp(shifted)
probabilities = exp_values / exp_values.sum(axis=1, keepdims=True)

Softmaxの前に各行の最大値を引くのは、exp(大きな値)のオーバーフローを避けるためです。最大値を引いてもSoftmaxの比率は変わりません。

交差エントロピー損失

正解クラスの予測確率をpとすると、1サンプルの損失は-log(p)です。

loss = -np.log(
    probabilities[np.arange(sample_count), y] + 1e-12
).mean()

正解に0.9を割り当てれば損失は小さく、0.01なら大きくなります。1e-12log(0)を避ける安全策です。

逆伝播をshapeと一緒に追う

Softmaxと交差エントロピーを組み合わせると、logitsに対する勾配は簡潔になります。

d_logits = probabilities.copy()
d_logits[np.arange(sample_count), y] -= 1
d_logits /= sample_count

d_w2 = hidden.T @ d_logits
d_b2 = d_logits.sum(axis=0)

d_hidden = d_logits @ w2.T
d_hidden[hidden_pre <= 0] = 0

d_w1 = x.T @ d_hidden
d_b1 = d_hidden.sum(axis=0)

逆向きに見ると、出力の誤差をW2.Tで隠れ層へ戻し、ReLUで0以下だった要素の勾配を0にし、さらに入力側へ伝えています。

ここで転置を一つ間違えても、shapeエラーが出れば気づけます。しかしshapeが偶然合う誤りもあります。そこで勾配チェックが必要です。

勾配チェックで逆伝播の式を検証する

あるパラメータθの数値勾配は、中心差分で近似できます。

dL/dθ ≈ {L(θ + ε) - L(θ - ε)} / (2ε)

今回はε=1e-5として24個のパラメータ要素をランダムに選び、手計算した解析勾配と比較しました。

checks: 24
max relative error: 1.081566e-09
mean relative error: 1.020951e-10
passed under 1e-5: True

相対誤差は次で計算しています。

|numerical - analytical|
---------------------------------
max(1e-12, |numerical| + |analytical|)

pytestではSoftmaxの各行が1へ合計されることと、別の乱数入力で40要素の勾配が基準内に入ることを自動確認しました。

.. [100%]
2 passed in 1.67s

勾配チェックは非常に遅いため、全パラメータ4,810個へ毎epoch実行するものではありません。小さな入力と一部要素で、実装変更時の検査に使います。

He初期化とReLU

重みをすべて0にすると、同じ層のユニットが同じ出力・同じ勾配になり、役割を分担できません。今回はReLU向けに標準偏差sqrt(2 / fan_in)の正規乱数で初期化しました。

w1 = rng.normal(0, np.sqrt(2 / input_size), (input_size, hidden_size))

この初期値seedを0、1、2、3、4、42へ変えました。データ分割は固定なので、重み初期値とミニバッチ順序の違いを観測しています。

モメンタム付きミニバッチSGD

初回は単純SGD、学習率0.12で実験し、6初期値平均95.09%でした。勾配チェックは合格しているため、逆伝播の式ではなく最適化条件の影響を疑いました。

そこでテスト値を調整基準にせず、validationの収束を見てモメンタム0.9、学習率0.05へ変更しました。

velocity[key] = 0.9 * velocity[key] - 0.05 * gradient[key]
parameter[key] += velocity[key]

モメンタムは過去の更新方向を蓄え、勾配が同じ方向へ続くと進みやすく、往復する方向では揺れを抑えます。変更後の平均は96.44%へ上がりました。

early stoppingとvalidationの弱点

最大240 epoch、validationが40 epoch更新されなければ停止し、最良validation時点の重みを復元しました。

seed=42ではepoch 23が最良で、validation 99.31%、test 96.39%でした。グラフではtrainingがほぼ100%へ上がる一方、validationは97~99%の間で揺れています。

さらにseed=3はvalidation 99%でもtest 93.89%でした。validationが144枚しかないため、1枚の正誤で約0.69ポイント変わります。validationが高いことは、未知データ性能の保証ではありません

本格的な比較では、交差検証、複数のデータ分割、より大きな検証集合などが必要です。今回は初期値依存と小標本の揺らぎを観測できたこと自体が結果です。

6初期値の実測結果

seed best epoch validation test 誤分類
0 39 99.31% 97.22% 10
1 11 97.92% 96.67% 12
2 26 99.31% 97.78% 8
3 4 98.61% 93.89% 22
4 26 99.31% 96.67% 12
42 23 99.31% 96.39% 13

最高のseed=2だけを採用して「97.78%出た」と書くと、初期値依存を隠します。平均96.44%、標本SD 1.34ポイント、範囲93.89~97.78%をセットで報告します。

古典MLとの比較

モデル テスト条件 平均正解率
ロジスティック回帰 6種類のデータ分割 97.04%
RBF-SVM 6種類のデータ分割 98.06%
NumPy 2層NN 固定データ分割・6初期値 96.44%

乱数を変えた対象が違うため、標準偏差を直接比較して優劣は決められません。古典ML記事はデータ分割の揺らぎ、本記事は重み初期値の揺らぎです。ただし固定seed=42の同じtest 360枚では、ロジスティック97.0%、SVM 98.06%、NumPy NN 96.39%でした。

このサイズではSVMが強く、ニューラルネットは精度面の必然ではありません。より大きな画像や、空間構造を使うCNNで状況がどう変わるかが次の問いです。

この結果から言えること/言えないこと

言えること

  • NumPyだけで順伝播・逆伝播・SGDを実装し、有限差分と一致した
  • 固定testで6初期値平均96.44%を得た
  • 初期値により最大3.89ポイントの範囲が生じた
  • validation 99%でもtestが94%未満になる例を観測した

言えないこと

  • ニューラルネットは古典MLより常に優れる
  • seed=2だけを代表値としてよい
  • validation最高のepochが真の汎化性能最高点である
  • NumPy実装の速度がPyTorchやGPUより実用的である

同じ構造をPyTorchで書くと何が変わるか

64→64→10という同じ構造、同じデータ分割、同じ評価方法をPyTorchで実装すると、nn.LinearCrossEntropyLossloss.backward()が、本記事のどの行列と勾配を担当するのかを対応付けられます。

古典MLからの流れは「scikit-learnのDigitsでロジスティック回帰とSVMを比較する:正解率だけで決めない機械学習入門」と、全体ロードマップ「機械学習からディープラーニングまでをちゃんと理解して、FPGAに持ち込みたい」へ接続します。

再現性メモ

  • 実装:work/ai_lab/experiment_digits_numpy_nn.py
  • テスト:work/ai_lab/test_numpy_nn.py
  • 生データ:work/ai_lab/results/digits_numpy_nn.json
  • 図:outputs/digits-numpy-nn-training.png
  • 実行環境:Python 3.14.4、NumPy 2.5.2
  • pytestと学習は/opt/ai-lab/venvで実行

関連記事