「もっと経験を積む」は「型にはめる」に勝てるか。データ拡張と正則化を同じ15点で戦わせたら、正則化が0.895 vs 0.870で僅差の勝利だった
約24分で読めます

「もっと経験を積む」は「型にはめる」に勝てるか。データ拡張と正則化を同じ15点で戦わせたら、正則化が0.895 vs 0.870で僅差の勝利だった


過学習と正則化の記事では、訓練データが15点しかない状態でモデルの自由度を野放しにすると激しく過学習し、正則化という「外からの制約」を課すことでそれを抑えられることを確かめた。あの記事の結論は要するに「経験(データ)が少ないうちは、制約に頼った方がいい」という話だった。

ただ、経験が少ないときに取れる手はもう一つある。手持ちの経験を、少しだけ角度を変えて何度も練習することだ。同じ案件でも、担当者を変えて振り返る、時系列を逆から見直す、条件を少しだけ変えて再検討する——本質は変わらないはずの状況を、微妙に違う角度から繰り返し経験することで、パターン認識の解像度を上げる。機械学習で言えばデータ拡張(data augmentation)にあたる。

これは「制約をかける」正則化とはまったく違うアプローチだ。モデルの自由度は縛らず、その代わり手元のデータそのものを(本物のラベルを保ったまま)水増しする。今回はこの2つを同じ土俵——同じ15点の訓練データ——で戦わせて、データ拡張は正則化の代わりになるのか、あるいは両方を組み合わせるとさらに良くなるのかを実際に確かめた。

実験に使ったコードの全文はGitHubに置いています

そもそも何をしているのか: two moonsと「成功」の定義

具体的な実験に入る前に、土台になっているデータとタスクをはっきりさせておく。make_moonsは、2つの三日月(クレセント)型の点群が互い違いに重なった、2クラス分類用の人工データセットだ。密にサンプリングすると下の図の左のような形になる。ある点が「上の三日月(class 0)」と「下の三日月(class 1)」のどちらに属するかを、モデルに当てさせるのが今回のタスクになる。

左: two moonsを密に1000点サンプリングした場合の「正体」。上に伸びる青い三日月(class 0)と下に伸びる赤い三日月(class 1)が、互い違いに重なり合っている。右: 今回の実験で実際に使う訓練データ(たった15点、noise=0.25)を、薄いグレーで示した「真の形」に重ねた図。15点はまばらにしか三日月をなぞっておらず、noiseが密サンプル版(noise=0.05)より大きいため、一部の点は真の形から目に見えてずれている

右図をよく見ると、青い三日月の中に赤い点が1つ混ざっているのに気づくかもしれない。これはバグではない。two moonsは元々noiseゼロの状態でも2つの三日月の「先端」同士がすれ違うように絡み合う形をしていて(だから”interleaving moons”とも呼ばれる)、そこにnoise=0.25というそこそこ大きなノイズを乗せているので、class 1の点がclass 0の三日月の領域まで飛んでいくことが実際に起こる。ラベルは「ノイズを乗せる前にどちらの三日月から生成されたか」で決まっていて、最終的な座標そのものとは別物だからだ。つまりこのデータには、見た目の位置だけでは正しいクラスを判定しようがない点が最初から一定数混ざっている。これは後で出てくる「ベースラインでも0.834、一番良い条件でも0.895止まり」という頭打ちの一因でもある——15点という少なさだけでなく、データそのものに本質的な曖昧さが含まれている。

つまり「学習」とは、この2つの三日月を分ける境界線(決定境界)をモデルに引かせること。そして今回言う「成功」とは、学習には使っていない新しい点(テストデータ)を、モデルがどれだけ正しくclass 0/1に分類できるか——テスト精度が高いこと、と定義する。逆に「失敗(過学習)」は、訓練データの15点だけは完璧に覚えるのに、初めて見るテストデータではボロボロになる状態を指す。前回の記事で見たのもまさにこの失敗パターンで、今回はそれをデータ拡張という別の角度から抑え込めるかを試す。

実験設定: two moonsの15点、また出てきた「15」という数字

訓練データは前回の記事にならってあえて15点だけにした(継続性のために、同じ「15」という数字を再利用している)。モデルはReLU MLP(隠れ層30ユニット×2層)をscikit-learnMLPClassifier(solver=‘lbfgs’)で学習させる。

N_TRAIN = 15
N_TEST = 2000
NOISE = 0.25
HIDDEN = (30, 30)

def make_train(seed):
    return make_moons(n_samples=N_TRAIN, noise=NOISE, random_state=seed)

ここで「訓練15点なのにテストは2000点」という数字の非対称さが気になった方もいると思うので先に説明しておく。この2つはまったく別の役割だ。訓練データの15点は、今回わざと再現している「経験(データ)が少ない状況」そのもの——実験対象。一方テストデータの2000点は、モデルの「経験」には一切含まれず、できあがったモデルの成績を測るための、いわば試験問題だ。試験問題の数が少ない(例えば15問)と、正解率は1/15刻みでしかブレず、「正則化のみ0.895」と「両方0.883」のような1.2ポイントの差を検出することすらできない。2000点という大きなテストセットは、モデルに「見せた」データではなく、あくまで評価そのものを正確・安定させるための物差しだと考えてほしい(60回の独立試行で平均を取っているのも同じ理由——1回の運に左右されない数字にするため)。

比較する4条件は次の通り。

  • (a) ベースライン: 拡張なし・正則化なし(alphaをほぼ0に固定)。15点に過学習することを確認する出発点
  • (b) 正則化のみ: 拡張はせず、L2正則化の強さalphaを対数スケールでスイープ
  • (c) データ拡張のみ: alphaはベースラインと同じ(ほぼ0)のまま、合成データを追加
  • (d) 両方: 最良の拡張設定を固定した上で、さらにalphaをスイープ

データ拡張の中身: ジッター+回転はなぜラベルを壊さないのか

2次元の点群に対して、今回使ったのは次の2つの変換だ。

  • ガウスジッター: 各点の座標に小さな正規分布ノイズを加える
  • 中心まわりの小さな回転: 訓練データ全体の重心を軸に、点ごとにランダムな小角度だけ回転させる
def augment(X, y, k, jitter_sigma, rot_max_deg, rng):
    Xs, ys = [X.copy()], [y.copy()]
    center = X.mean(axis=0)
    for _ in range(k):
        angles = rng.uniform(-rot_max_deg, rot_max_deg, size=len(X)) * np.pi / 180.0
        cos_a, sin_a = np.cos(angles), np.sin(angles)
        Xc = X - center
        Xrot = np.stack([
            Xc[:, 0]*cos_a - Xc[:, 1]*sin_a,
            Xc[:, 0]*sin_a + Xc[:, 1]*cos_a,
        ], axis=1) + center
        jitter = rng.normal(0, jitter_sigma, size=X.shape)
        Xs.append(Xrot + jitter)
        ys.append(y.copy())
    return np.vstack(Xs), np.concatenate(ys)

コードだけだとイメージしづらいので、実際に元の15点がどう水増しされるかを図にした。

左: 拡張前の元の訓練データ15点。右: 強さm=0.4(ジッター標準偏差0.12、回転±8度)・倍率k=10で拡張した後、元15点+合成150点=165点。元の各点(黒縁の太い丸)のまわりに、同じクラス色の半透明な「合成コピー」の小さな雲ができており、点ごとに少しずつ違う方向にジッター+回転がかかっていることが分かる

元の1点1点が、その場で「少しだけ角度を変えた自分自身のコピー」を何個も生み出しているのが分かる。これが中心まわりの回転(点群全体を弧に沿ってわずかに回す)とガウスジッター(各点をランダムな方向に少しだけずらす)を組み合わせた結果だ。

なぜこれがラベルを保つと言えるのか。two moonsの真の境界は滑らかな弧状で、各クラスの点はその弧の内側にある程度のマージンを持って分布している。変換の大きさが「最寄りの境界までの距離」よりずっと小さい限り、点をわずかにずらしても正しいクラス領域からはみ出ない。ジッターは点をランダムな方向にちょっとだけ動かすだけ、回転は弧に沿ってちょっとだけ位置をずらすのと同じことなので、どちらも小さい範囲では「ラベル保存的」と言える。ただし逆に言えば、この変換を大きくしすぎれば当然この前提は崩れる——これは後半の結果でそのまま裏付けられることになる。

(a) ベースライン: 15点で普通に過学習する

拡張なし・正則化なしで学習させると、訓練データ15点は毎回100%正解できる一方、テスト精度は平均0.834まで落ちる。60回の独立試行(訓練データの引き直しごとに平均)で確認した数値は次の通り。

baseline: train_acc=1.0000  test_acc=0.8339 (std=0.0649)  gap=0.1661 (std=0.0649)

train_test間のギャップ(過学習の合図)は平均0.166。前回の記事ほど劇的な爆発(テスト誤差1,017万)ではないが、それは今回が回帰ではなく分類タスクで、精度の上限が1.0に頭打ちされる性質のせいだ。それでも「訓練は完璧、テストは8割程度」という過学習の型はそのまま出ている。

(b) 正則化のみ: alphaを振ると0.895まで改善する

L2正則化の強さalpha1e-8から100まで対数スケールで振ったところ、次のような曲線になった。細かい話だが、ここから出てくるスイープ系の数値はどれも訓練データを引き直した25回試行の平均で、最終的な4条件比較だけは回数を増やした60回試行で取り直している。だから同じベースラインでも、スイープの表では0.8356、60回平均では0.8339と末尾がわずかに違って見える——矛盾ではなく試行数の違いだ。

alpha=0.000000  test_acc=0.8356  gap=0.1644
alpha=0.000043  test_acc=0.8564  gap=0.1410
alpha=0.002848  test_acc=0.8700  gap=0.1300
alpha=0.187382  test_acc=0.8940  gap=0.0873   <- best
alpha=1.519911  test_acc=0.7673  gap=-0.0180
alpha=12.328467 test_acc=0.5000  gap=0.0333

alpha=0.187付近が最良で、テスト精度0.894・ギャップ0.087まで改善する。前回の記事と同じく、強めれば強めるほど良いわけではない。最良点のalpha=0.19から1桁強めたalpha=1.5では早くも0.767まで急落し、alpha=12以上ではコイン投げと変わらない0.5まで落ちる(モデルが単純すぎて表現力を失う、過小適合側の悪化)。この最良のalphaを使って60回の独立試行で再確認すると、テスト精度0.895(std=0.040)、ギャップ0.083(std=0.043)だった。

(c) データ拡張のみ: 強さを上げると、途中から悪化に転じる

まず拡張倍率k=10(1点あたり10個の合成コピーを追加)に固定して、拡張の「強さ」(ジッターの標準偏差と回転角度を同時にスケールする係数m)を0から1まで振った。

拡張の強さmと拡張倍率kをそれぞれ振ったときのテスト精度とtrain-testギャップを示す2x2のグラフ。左上: 拡張の強さm(横軸)に対するテスト精度は、m=0.4付近(ジッター標準偏差0.12・回転8度)で0.857のピークをつけたあと下降に転じ、m=1.0では0.821とベースライン(灰色破線、0.836)を下回る。右上: ギャップはmが大きいほど概ね縮むが、m=0.1付近で一度ベースラインを上回る山がある。左下: 合成コピー数k(対数軸)に対するテスト精度はk=0〜3ではベースライン付近で足踏みし、k=5以降右肩上がりでk=40で0.869に達する。右下: ギャップはkが大きいほど概ね縮小する

m=0.00  sigma=0.000  rot=0.0deg  test_acc=0.8356  gap=0.1644
m=0.20  sigma=0.060  rot=4.0deg  test_acc=0.8460  gap=0.1514
m=0.40  sigma=0.120  rot=8.0deg  test_acc=0.8571  gap=0.1349   <- peak
m=0.70  sigma=0.210  rot=14.0deg test_acc=0.8503  gap=0.1257
m=1.00  sigma=0.300  rot=20.0deg test_acc=0.8209  gap=0.1311

ここが今回いちばん意外だった結果だ。拡張の強さは単調にテスト精度を上げるわけではなく、m=0.4(ジッター標準偏差0.12、回転±8度)付近でピーク(0.857)をつけたあと、下降に転じる。 m=1.0(ジッター標準偏差0.3、回転±20度)まで強めると、テスト精度は0.821まで落ち、拡張なしのベースライン(0.836)を下回ってしまう。事前に立てた「ラベル保存的な変換の大きさには限界がある」という予想が、そのまま数値に出た形だ。ジッターと回転を強くしすぎると、本来のクラス境界をまたいで合成データが作られてしまい、モデルは間違ったノイズまで学習することになる。

次に、強さをm=0.4(このスイープでの最良点)に固定して、1点あたりの合成コピー数kを0から40まで振った。

k=  0  test_acc=0.8356  gap=0.1644
k=  1  test_acc=0.8344  gap=0.1603
k=  3  test_acc=0.8329  gap=0.1645
k=  5  test_acc=0.8539  gap=0.1354
k= 10  test_acc=0.8536  gap=0.1411
k= 20  test_acc=0.8622  gap=0.1324
k= 40  test_acc=0.8692  gap=0.1254   <- best (このスイープの範囲内)

こちらは強さのスイープとは対照的に、k=0〜3までは効果がほぼ横ばい(むしろ僅かに悪化する場合すらある)だが、k=5を超えたあたりから右肩上がりに改善していく。少なくとも今回試した範囲(k=40まで)では頭打ちの兆候は出ておらず、まだ伸びしろがあるようにも見える(計算コストの都合でk=40打ち切りにした点は素直に書いておく)。つまり「強さ」には明確な適正値があるのに対し、「量(倍率)」の方はもっと素直に増やすほど良い、という非対称な結果になった。

最良の設定(m=0.4、k=40)を使って60回の独立試行で再確認すると、テスト精度0.870(std=0.049)、ギャップ0.127(std=0.052)だった。ベースライン(0.834)よりは明確に良いが、正則化のみ(0.895)には届いていない。

4条件を並べる: 正則化がわずかに勝った

同じ60回の独立試行で、4条件をまとめて比較する。

4条件(拡張なし正則化なし・正則化のみ・データ拡張のみ・両方)について、60回の独立試行で平均したテスト精度とtrain-testギャップを誤差棒(標準誤差)付きの棒グラフで示す。テスト精度は拡張なし正則化なし0.834、正則化のみ0.895、データ拡張のみ0.870、両方0.883の順で、正則化のみが最も高い。ギャップは同じ順でそれぞれ0.166、0.083、0.127、0.113となり、正則化のみが最も小さい

条件            test_acc(±std)      gap(±std)
拡張なし正則化なし  0.8339 (±0.0649)   0.1661 (±0.0649)
正則化のみ         0.8948 (±0.0395)   0.0830 (±0.0429)
データ拡張のみ      0.8697 (±0.0493)   0.1269 (±0.0524)
両方              0.8828 (±0.0450)   0.1127 (±0.0489)

4条件とも同じ乱数seedで訓練データを引いているので(ペア比較)、条件間の勝率も見ておく。

reg_only  - baseline : 平均差 +0.0609  (reg_onlyが勝った割合 88%)
aug_only  - baseline : 平均差 +0.0358  (aug_onlyが勝った割合 77%)
combo     - baseline : 平均差 +0.0489  (comboが勝った割合 80%)
reg_only  - aug_only  : 平均差 +0.0250  (reg_onlyが勝った割合 73%)
reg_only  - combo     : 平均差 +0.0119  (reg_onlyが勝った割合 63%)
combo     - aug_only  : 平均差 +0.0131  (comboが勝った割合 72%)

正直に言うと、「拡張と正則化を組み合わせれば一番良くなる」という結果を予想していた。しかし実際には、正則化のみ(0.895)が、両方を組み合わせた条件(0.883)よりもわずかに良かった。差自体は大きくない(平均差0.012、正則化が勝つ割合63%)ので断定はできないが、少なくとも「拡張を足せば足すほど良くなる」というほど単純ではなく、この実験の範囲では拡張は正則化の上乗せとしてはっきりプラスに働いたとは言えなかった

一方で、データ拡張だけでもベースラインよりは明確に改善している(0.834→0.870、勝率77%)。「何もしないよりはマシ」という効果は確かにあるが、「制約をかける」ほどの強さはこの設定では出せなかった、というのが今回の実際の結果だ。

決定境界を1回分だけ見てみる

平均値だけでなく、同じ1回分の訓練データ(seed=3)に対する決定境界の違いも見ておく。

15点だけの2ムーン訓練データ(乱数seed=3を固定した1回の描画)に対し、拡張なし正則化なし(左端、test_acc=0.671)、正則化のみ(test_acc=0.833)、データ拡張のみ(test_acc=0.841)、両方(右端、test_acc=0.868)の4通りで学習した決定境界を並べた図。左端は、本来なら青いclass 0の三日月の左腕がある画面左上を、大きな赤いくさび状の領域が丸ごと覆ってしまっている。残り3枚は、青い領域に飛び込んだ赤い訓練点まで赤の帯を通り道のように伸ばしつつ、それ以外の場所では三日月の形に沿った境界を保っている

この1回だけを見ると、たまたま「両方」が一番良い(0.868)結果になっている——平均では正則化のみが最良だったのと矛盾するようだが、これは60回平均の方が信頼できる数値で、1回分の描画はあくまで「過学習した境界がどれだけ暴れるか」を視覚的に見るためのものだ。4枚を見比べると、どのモデルも、冒頭で触れた「青い三日月側に飛び込んだ赤い点」をなんとか赤側に取り込もうとしているのが分かる。違うのはその取り込み方だ。左端(拡張なし・正則化なし)は、その赤い点に境界を届かせるために、本来なら青い三日月の左腕があるはずの画面左上を丸ごと赤いくさびで塗りつぶしてしまっている——この1回のテスト精度が0.671まで落ちているのはそのせいだ。残り3条件も同じ赤い点まで赤の領域を伸ばしてはいるのだが、それを「通り道」程度の帯にとどめて、それ以外の場所では三日月の形に沿った境界を保っている。ただしよく見ると、データ拡張のみ(c)の通り道はギザギザに折れ曲がっていて、alphaをほぼ0のままにしている以上、細かい暴れ方までは消えていないことも見て取れる。

拡張の強さを動かしながら境界が変わる様子

拡張の強さmを0から1まで動かしながら、合成データと決定境界がどう変わっていくかをアニメーションにした。

拡張の強さmを0から1まで動かしながら、左パネルで元の15点の訓練データ(黒縁の丸)と合成された10倍のジッター/回転コピー(縁なし・半透明の点)がだんだん広がっていく様子、および決定境界が滑らかに変形していく様子を示すアニメーション。右パネルには先ほどの強さスイープのテスト精度(オレンジ)とtrain-testギャップ(紫)の曲線(25回試行の平均)が固定表示され、いま表示中の強さmの位置が縦の点線で、この1回分のテスト精度が白抜きのひし形で示される。曲線は、mが大きくなるにつれテスト精度が一度緩やかに上昇したあと下降に転じ、ギャップは緩やかに下降する

mが大きくなるほど、半透明の合成点群が元の点のまわりに広く「にじんで」いくのが見える。序盤(mが小さいうち)はこのにじみが決定境界を滑らかにするのに役立っているが、終盤(mが1に近づく)になると、にじみが2つの三日月の間の隙間を埋め始め、本来のクラス境界の情報がぼやけていくのが視覚的にも分かる。なお右パネルの曲線はさっきのスイープ(25回平均)をそのまま置いたもので、タイトルの数字と白抜きのひし形は、いま画面に出ている1回分の値だ。1回分は運でかなり上下するので、ひし形が曲線から離れる瞬間もあるが、それは矛盾ではなく「1回の試行がどれだけブレるか」がそのまま見えているだけ——この記事が何十回も平均を取ってから数字を語っている理由でもある。

手を動かして意外だったこと

一番の発見は、「拡張の強さ」と「拡張の量(倍率)」で、効果の形がまったく違ったことだ。強さは「強めるほど良い」ではなく、途中にピークがあって、そこを超えると拡張なしより悪化する。一方、量は今回試した範囲では素直に「多いほど良い」方向に伸び続けていた。データ拡張を語るとき「何倍に増やすか」と「どれくらい変形させるか」を一緒くたに「拡張を強める」と言ってしまいがちだが、この2つは実は性質の異なるノブだった。

もう一つは、正則化がデータ拡張よりも僅差ながら安定して強かったこと。「拡張は正則化の直接的な代替になる」という仮説は、この実験の範囲では支持されなかった。さらに「両方を組み合わせれば一番良い」という、もっと自然に予想していたシナリオも、実際には正則化単体にわずかに及ばなかった。63%という勝率は「決定的な差」というよりは「弱いが一貫した傾向」に近い数字で、この点は誇張せずそのまま書いておきたい。

「もっと経験を積む」と「型にはめる」、同じゴールへの2つの道

過学習と正則化の記事で見た「データが少ないうちは制約に頼った方がいい」という話に対して、今回試したのは「制約をかけずに、経験の見せ方を少しだけ変えて何度も練習する」というもう一つの道だった。

結果は、少なくともこの実験の設定では、制約(正則化)の方が僅かに分がよかった。手元の15点をどれだけうまく水増ししても、モデルの自由度そのものを縛る効果には届かなかった。ただし、拡張がまったくの無駄だったわけでもない。何もしないよりは明確にマシ(0.834→0.870)で、拡張と正則化を両方使った条件もベースラインよりはずっと良かった(0.883)。ただ、正則化単体を上回るところまでは行けなかった、というのが正確なところだ。

これは人生のスキル習得にも重なる話だと思う。同じ状況を少し違う角度から繰り返し経験する「練習の多様化」は、確かに何もしないよりは理解を広げてくれる。ただ、経験そのものが本質的に少ない局面では、「型」や「ルール」といった外からの強い制約の方が、もっと直接的に踏み外しを防いでくれることがある。しかも今回の結果が示すように、練習の変形はやりすぎると逆効果になる——同じ話を無理に角度を変えて何度も繰り返すうちに、本質からズレた「解釈」まで練習してしまうことがあるのかもしれない。型と多様な練習は排他的ではないが、少なくとも「型があるなら、多様な練習を積めばさらに良くなる」と単純には言えない、というのが今回、実際に手を動かして見えてきたことだった。

まとめ

  • two moonsの15点(前回の過学習の記事と同じ点数)の訓練データに対し、正則化とデータ拡張(ジッター+中心まわりの小回転)を比較した
  • ベースライン(拡張なし・正則化なし)はテスト精度0.834、ギャップ0.166と過学習した。正則化のみで精度0.895・ギャップ0.083まで改善し、4条件中もっとも良い結果になった
  • データ拡張のみは精度0.870・ギャップ0.127で、ベースラインよりは明確に改善したが、正則化には届かなかった。拡張と正則化を両方使った条件は精度0.883で、拡張のみより良いが正則化のみよりわずかに劣った(勝率63%、平均差0.012の弱い差)
  • 拡張の「強さ」(ジッター標準偏差・回転角度)は単調に効くわけではなく、m=0.4(標準偏差0.12・回転8度)でテスト精度0.857のピークをつけたあと下降し、m=1.0では0.821とベースラインを下回った。強すぎる拡張はクラス境界をぼかして悪化させる
  • 拡張の「量」(1点あたりの合成コピー数k)は、少なくとも今回試した0〜40の範囲では単調に改善し続け、頭打ちの兆候は見られなかった。「強さ」と「量」は性質の異なるノブだった