BatchNorm位置比較を5シードで再検証|Conv→BN→ReLU vs Conv→ReLU→BNの逆転は本物か?【Keras×CIFAR-10実験】

投稿日:2026年7月28日火曜日 最終更新日:

BatchNormalization CIFAR-10 CNN Conv2D Google Colab Keras 過学習 乱数シード

X f B! L
BatchNorm位置比較を5シードで再検証|Conv→BN→ReLU vs Conv→ReLU→BNの逆転は本物か?【Keras×CIFAR-10実験】 アイキャッチ画像

前回の記事「BatchNorm、Conv前vs後どちらが有利?位置による精度差を実験」では、理論上の定石とされるConv→BN→ReLUを、Conv→ReLU→BNが約5.0pt上回るという意外な結果になりました。

ただしこの結果は単一シード(SEED=42)・1回のみの実行によるものでした。CNNの学習は初期値や学習の揺らぎによって数ポイント単位で結果が変動することが珍しくありません。今回は同じ2パターンを5つの異なるシードでそれぞれ実行し、平均・標準偏差、そして対応のあるt検定を使って「本当にConv→ReLU→BNが有利と言えるのか」を統計的に確かめます。

📘 この記事でわかること
  • 単一シードの結果が複数シードでも再現するかどうか
  • BatchNorm位置比較を統計的に検証する方法(対応のあるt検定)
  • 前回記事の実験設計の問題点(validation_dataにtest_setを流用していた点)の修正版
  • 「予想外の結果」が出たときにどう再検証すべきかの具体例

なぜ再検証が必要か

前回の実験で使ったモデルは30エポックでtrain_accuracyが90%を超える一方、val_accuracyは60〜70%台で大きく揺れ動いていました。このように学習が不安定な条件では、1回の実行結果だけで優劣を判断するのは危険です。

前回の実験の弱点今回の修正
SEED=42の1回のみ5シード(42, 123, 456, 789, 2024)で実行し平均・標準偏差を算出
validation_dataにtest_setをそのまま使用訓練データ内でvalidation_split=0.2、test_setは最終評価のみに使用
差が「有意」かどうかの検証なし対応のあるt検定(paired t-test)で統計的に検証

5シードのtest_accuracyの標準偏差を s、シード数を n としたとき、平均の標準誤差は次の式で表せます。

\[ SE = \frac{s}{\sqrt{n}} \]

2パターンの差がこの標準誤差に対して十分大きければ、「たまたまこのシードで出ただけ」とは言いにくくなります。今回は感覚的な比較にとどめず、対応のあるt検定のp値も算出して判断します。


実験コード

使用環境はGoogle Colab(GPU:T4)、データセットはCIFAR-10です。モデル構造(Conv2D×3・GAP・Dense(128)・Dropout(0.3))は前回記事と同一にし、シード数と検証データの扱いだけを変更しています。

① 環境準備(最初に一度だけ実行)

!apt-get -y install fonts-ipafont-gothic
!rm -rf /root/.cache/matplotlib
!pip install -q japanize_matplotlib
print("環境準備完了")
実行結果をクリックして内容を開く
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
The following additional packages will be installed:
  fonts-ipafont-mincho
The following NEW packages will be installed:
  fonts-ipafont-gothic fonts-ipafont-mincho
0 upgraded, 2 newly installed, 0 to remove and 53 not upgraded.
Need to get 8,237 kB of archives.
After this operation, 28.7 MB of additional disk space will be used.
Get:1 http://archive.ubuntu.com/ubuntu jammy/universe amd64 fonts-ipafont-gothic all 00303-21ubuntu1 [3,513 kB]
Get:2 http://archive.ubuntu.com/ubuntu jammy/universe amd64 fonts-ipafont-mincho all 00303-21ubuntu1 [4,724 kB]
Fetched 8,237 kB in 3s (3,122 kB/s)
Selecting previously unselected package fonts-ipafont-gothic.
(Reading database ... 122403 files and directories currently installed.)
Preparing to unpack .../fonts-ipafont-gothic_00303-21ubuntu1_all.deb ...
Unpacking fonts-ipafont-gothic (00303-21ubuntu1) ...
Selecting previously unselected package fonts-ipafont-mincho.
Preparing to unpack .../fonts-ipafont-mincho_00303-21ubuntu1_all.deb ...
Unpacking fonts-ipafont-mincho (00303-21ubuntu1) ...
Setting up fonts-ipafont-mincho (00303-21ubuntu1) ...
update-alternatives: using /usr/share/fonts/opentype/ipafont-mincho/ipam.ttf to provide /usr/share/fonts/truetype/fonts-japanese-mincho.ttf (fonts-japanese-mincho.ttf) in auto mode
Setting up fonts-ipafont-gothic (00303-21ubuntu1) ...
update-alternatives: using /usr/share/fonts/opentype/ipafont-gothic/ipag.ttf to provide /usr/share/fonts/truetype/fonts-japanese-gothic.ttf (fonts-japanese-gothic.ttf) in auto mode
Processing triggers for fontconfig (2.13.1-4.2ubuntu5) ...
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 4.1/4.1 MB 42.9 MB/s eta 0:00:00
  Preparing metadata (setup.py) ... done
  Building wheel for japanize_matplotlib (setup.py) ... done
環境準備完了

② import・データ準備・モデル構築関数

import numpy as np
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
import matplotlib.pyplot as plt
import japanize_matplotlib
import time

# データ読み込み・正規化
(x_train_full, y_train_full), (x_test, y_test) = keras.datasets.cifar10.load_data()
x_train_full = x_train_full.astype("float32") / 255.0
x_test = x_test.astype("float32") / 255.0
y_train_full = y_train_full.flatten()
y_test = y_test.flatten()

EPOCHS = 30
BATCH_SIZE = 64
SEEDS = [42, 123, 456, 789, 2024]  # 5シードで再検証

def build_model(bn_position="before"):
    """
    bn_position:
      "before" -> Conv→BN→ReLU(従来型)
      "after"  -> Conv→ReLU→BN
    """
    inputs = keras.Input(shape=(32, 32, 3))
    x = inputs
    for filters in [32, 64, 128]:
        x = layers.Conv2D(filters, 3, padding="same")(x)
        if bn_position == "before":
            x = layers.BatchNormalization()(x)
            x = layers.Activation("relu")(x)
        else:
            x = layers.Activation("relu")(x)
            x = layers.BatchNormalization()(x)
        x = layers.MaxPooling2D()(x)

    x = layers.GlobalAveragePooling2D()(x)
    x = layers.Dense(128, activation="relu")(x)
    x = layers.Dropout(0.3)(x)
    outputs = layers.Dense(10, activation="softmax")(x)

    model = keras.Model(inputs, outputs)
    model.compile(
        optimizer="adam",
        loss="sparse_categorical_crossentropy",
        metrics=["accuracy"],
    )
    return model
実行結果をクリックして内容を開く
Downloading data from https://www.cs.toronto.edu/~kriz/cifar-10-python.tar.gz
170498071/170498071 ━━━━━━━━━━━━━━━━━━━━ 2217s 13us/step

③ 2パターン×5シードの学習実行

1パターンあたり5回、合計10回の学習を行いました(実測で1回あたり約115〜121秒、合計約20分)。

patterns = [
    ("before", "Conv→BN→ReLU"),
    ("after",  "Conv→ReLU→BN"),
]

results = []          # 各実行(パターン×シード)の記録
histories_all = {}    # 代表シード(先頭のSEEDS[0])の学習曲線(グラフ用)

for key, label in patterns:
    for seed in SEEDS:
        print(f"\n=== {label}|seed={seed} ===")
        tf.random.set_seed(seed)
        np.random.seed(seed)
        model = build_model(key)
        start = time.time()
        history = model.fit(
            x_train_full, y_train_full,
            validation_split=0.2,
            epochs=EPOCHS,
            batch_size=BATCH_SIZE,
            verbose=0,
        )
        elapsed = time.time() - start
        score = model.evaluate(x_test, y_test, verbose=0)
        results.append({
            "pattern": label,
            "seed": seed,
            "test_accuracy": score[1],
            "test_loss": score[0],
            "val_accuracy_final": history.history["val_accuracy"][-1],
            "time": elapsed,
        })
        print(f"test_accuracy: {score[1]:.4f} 学習時間: {elapsed:.1f}秒")
        if seed == SEEDS[0]:
            histories_all[label] = history
実行結果をクリックして内容を開く
=== Conv→BN→ReLU|seed=42 ===
test_accuracy: 0.6706 学習時間: 117.3秒

=== Conv→BN→ReLU|seed=123 ===
test_accuracy: 0.5976 学習時間: 118.2秒

=== Conv→BN→ReLU|seed=456 ===
test_accuracy: 0.5550 学習時間: 116.7秒

=== Conv→BN→ReLU|seed=789 ===
test_accuracy: 0.5437 学習時間: 121.2秒

=== Conv→BN→ReLU|seed=2024 ===
test_accuracy: 0.6212 学習時間: 120.1秒

=== Conv→ReLU→BN|seed=42 ===
test_accuracy: 0.6784 学習時間: 119.8秒

=== Conv→ReLU→BN|seed=123 ===
test_accuracy: 0.6899 学習時間: 114.8秒

=== Conv→ReLU→BN|seed=456 ===
test_accuracy: 0.6819 学習時間: 116.6秒

=== Conv→ReLU→BN|seed=789 ===
test_accuracy: 0.6885 学習時間: 117.7秒

=== Conv→ReLU→BN|seed=2024 ===
test_accuracy: 0.6945 学習時間: 117.4秒

④ 統計サマリー+グラフ

import pandas as pd
from scipy import stats

df = pd.DataFrame(results)

# ── パターン別サマリー(平均・標準偏差・最小最大)──────────
summary = df.groupby("pattern")["test_accuracy"].agg(["mean", "std", "min", "max"]).round(4)
print("===== パターン別サマリー(5シード) =====")
print(summary)

# ── 対応のあるt検定 ─────────────────────────────
pivot = df.pivot(index="seed", columns="pattern", values="test_accuracy")
t_stat, p_value = stats.ttest_rel(pivot["Conv→ReLU→BN"], pivot["Conv→BN→ReLU"])
print(f"\n対応のあるt検定(Conv→ReLU→BN − Conv→BN→ReLU): t={t_stat:.3f}, p={p_value:.4f}")

# ── 箱ひげ図:test_accuracyの分布 ───────────────────
fig, ax = plt.subplots(figsize=(6, 5))
df.boxplot(column="test_accuracy", by="pattern", ax=ax)
ax.set_title("test_accuracyの分布(5シード)")
plt.suptitle("")
ax.set_ylabel("test_accuracy")
plt.tight_layout()
plt.savefig("bn_position_multiseed_boxplot.png", dpi=150)
plt.show()

# ── シードごとの折れ線比較(対応関係を可視化)───────────
fig2, ax2 = plt.subplots(figsize=(7, 5))
for pattern in pivot.columns:
    ax2.plot(pivot.index.astype(str), pivot[pattern], marker="o", label=pattern)
ax2.set_xlabel("seed")
ax2.set_ylabel("test_accuracy")
ax2.set_title("シードごとのtest_accuracy比較")
ax2.legend()
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("bn_position_multiseed_seedwise.png", dpi=150)
plt.show()

print(df.sort_values(["pattern", "seed"]).to_string(index=False))

パターン別サマリー

===== パターン別サマリー(5シード) =====
                  mean       std     min     max
pattern
Conv→BN→ReLU  0.59762  0.051493  0.5437  0.6706
Conv→ReLU→BN  0.68664  0.006447  0.6784  0.6945

対応のあるt検定(Conv→ReLU→BN − Conv→BN→ReLU): t=3.728, p=0.0203

      pattern  seed  test_accuracy  test_loss  val_accuracy_final       time
 Conv→BN→ReLU    42         0.6706   1.343619              0.6734 117.304262
 Conv→BN→ReLU   123         0.5976   1.802407              0.6077 118.224711
 Conv→BN→ReLU   456         0.5550   2.541358              0.5606 116.668791
 Conv→BN→ReLU   789         0.5437   2.911247              0.5575 121.239430
 Conv→BN→ReLU  2024         0.6212   2.085468              0.6212 120.085776
 Conv→ReLU→BN    42         0.6784   1.993356              0.6787 119.831223
 Conv→ReLU→BN   123         0.6899   1.770837              0.7035 114.820019
 Conv→ReLU→BN   456         0.6819   1.692096              0.6917 116.555773
 Conv→ReLU→BN   789         0.6885   1.775803              0.7040 117.677714
 Conv→ReLU→BN  2024         0.6945   1.923287              0.7120 117.384641

実験結果

test_accuracyの分布(箱ひげ図)

Conv→ReLU→BNは67.8〜69.5%の狭い範囲に収まっているのに対し、Conv→BN→ReLUは54.4〜67.1%と大きく散らばっており、箱の高さ自体が全く異なる点が視覚的にも分かります。

test_accuracyの分布(箱ひげ図)

シードごとのtest_accuracy比較(折れ線)

Conv→ReLU→BN(オレンジ)はほぼ一直線に近い横ばいなのに対し、Conv→BN→ReLU(青)は5点ともギザギザに大きく上下し、特にseed=789で急落しています。

シードごとのtest_accuracy比較(折れ線)
パターン平均 test_accuracy標準偏差最小最大
Conv→BN→ReLU(従来型)59.76%0.0515(5.15pt)54.37%67.06%
Conv→ReLU→BN68.66%0.0064(0.64pt)67.84%69.45%

平均の差:+8.90pt(Conv→ReLU→BNが優位/前回の単一シードでの差は5.0ptだったため、むしろ差は拡大)

対応のあるt検定:t = 3.728、p = 0.0203(p < 0.05 で統計的に有意

参考:test_lossの平均も、Conv→BN→ReLUが2.137±0.614、Conv→ReLU→BNが1.831±0.123と、accuracyだけでなくlossの安定性でもConv→ReLU→BNが上回っています。

⚠️ ハマりポイント:「有意差あり」の中身を見ないと誤読する
今回はp=0.0203でp<0.05を満たしましたが、これは「Conv→ReLU→BNが常に高い」ことだけでなく、「Conv→BN→ReLU側の分散が非常に大きい(標準偏差5.15pt)」ことにも支えられています。標準偏差が大きい側は、たまたま条件の良いシードに当たれば逆転する可能性もゼロではありません(実際、今回最も差が小さかったseed=42では0.78ptまで縮んでいます)。「平均で有意差あり」と「常にどのシードでも上回る」は別の主張である点に注意してください。

考察

① 前回の結果は5シードでも再現したか

再現しました。それどころか、前回の単一シード(SEED=42)での差は+5.0ptでしたが、5シード平均では+8.90ptとむしろ差が拡大しました。実はSEED=42は、Conv→BN→ReLU側にとって5シード中もっとも好条件のシード(test_accuracy 67.06%、5シード中の最大値)だったことが分かります。つまり前回記事は、たまたまConv→BN→ReLUが「一番調子の良かった」シードで比較していたため、両者の差を実際より小さく見積もっていたことになります。

② シード間のばらつきの大きさ

今回もっとも大きな発見は、精度差そのものよりConv→BN→ReLUの不安定さです。標準偏差はConv→BN→ReLUが5.15pt、Conv→ReLU→BNが0.64ptと、実に8倍近い差があります。Conv→BN→ReLUはseed=789で54.37%まで落ち込む一方、seed=42では67.06%まで到達しており、同じアーキテクチャ・同じハイパーパラメータでも初期値次第で12pt以上ブレることになります。一方Conv→ReLU→BNは67.84〜69.45%とほぼ一定の範囲に収まっており、学習の再現性という観点でも優れています。

③ 統計的有意性の評価

対応のあるt検定でp=0.0203となり、5%水準で統計的に有意です。ただし前述の通り、この有意差はConv→BN→ReLU側の分散の大きさに強く影響されています。実務的には「Conv→ReLU→BNの方が平均的に優れているだけでなく、結果が安定している」という2点セットで評価するのが適切です。分散が大きい手法は、たとえ平均で有意差があっても「毎回信頼できる」とは言えない点は覚えておく必要があります。

④ 前回記事との実験設計の違いによる影響

今回はvalidation_dataをtest_setから切り離し、訓練データ内のvalidation_split=0.2を使いました。各シードのval_accuracy_finalとtest_accuracyを比較すると、両者は概ね近い値(例:Conv→ReLU→BNのseed=123ではval_accuracy 70.35% vs test_accuracy 68.99%)で、極端な乖離は見られませんでした。前回のようにvalidation=test_setだった場合との数値上の違いは大きくありませんでしたが、「検証データと評価データを分離する」という設計自体は、今後の記事でも標準にすべきだと考えられます。

実務での推奨

状況推奨理由
BN位置で精度を追い込みたい場合Conv→ReLU→BNを基本形として採用平均精度で8.9pt優位、かつ標準偏差が約1/8と再現性も高いため
Conv→BN→ReLU(教科書的な定石)を使う場合複数シードでの平均を必ず確認する単一シードでは最大12pt以上のブレが生じうるため、1回の実行結果を鵜呑みにしない
限られた時間で1回だけ検証する場合単一シードの結果を過信しない今回の再検証で示す通り、シードによるブレが結論を左右しうる
ブログ・記事で比較結果を発表する場合可能な限り複数シードで検証する単一シードの結果は再現性の観点で説得力が弱い
まとめ
  • 5シード平均でConv→ReLU→BN(68.66%)がConv→BN→ReLU(59.76%)を+8.90pt上回り、前回の単一シード結果(+5.0pt)よりもむしろ差は拡大した
  • 対応のあるt検定はt=3.728、p=0.0203となり、5%水準で統計的に有意
  • ただしConv→BN→ReLUは標準偏差5.15pt(Conv→ReLU→BNは0.64pt)と非常に不安定で、単一シードでの前回の結果はConv→BN→ReLU側にとって好条件のシードだったことが判明した
  • 1回の実験結果、特に「定石を覆す」ような意外な結果が出た場合は、複数シードでの再検証が望ましい。今回のケースでは再検証によって結論がより強固になっただけでなく、「なぜ前回はもっと差が小さく見えたのか」という新しい知見も得られた

関連記事