身長(cm)と体重(kg)から健康スコアを予測するモデルを訓練するとします。身長は150〜190、体重は40〜100という範囲です。このデータをそのまま勾配降下法で学習すると、うまく収束しないことがあります。なぜでしょうか。
身長の値は体重の2〜3倍大きいため、損失関数の等高線は非常に細長い楕円になり、勾配の方向が最適解への方向と大きくずれてしまいます。また、正則化を適用した場合、数値が大きい特徴量の重みが過度にペナルティを受けることになります。
これらの問題を解決するのが特徴量スケーリング(feature scaling)です。特徴量スケーリングは単純な前処理ですが、モデルの学習効率と性能に大きな影響を与えます。
特徴量スケーリングを理解すると、以下の判断ができるようになります。
- どの手法でスケーリングすべきか: 標準化・Min-Max・ロバストの使い分け
- どのモデルでスケーリングが必要か: 距離ベース・勾配ベースのモデルには必須
- スケーリングと正則化の相互作用: L1/L2正則化が適切に機能するための前提条件
- スケーリングの適用タイミング: データリークを防ぐ正しい手順
本記事の内容
- なぜスケーリングが必要かの理論的根拠
- 標準化・Min-Max正規化・ロバストスケーリングの定義と比較
- 勾配降下法の収束への影響
- 正則化との相互作用
- Pythonでの実装と可視化
前提知識
この記事を読む前に、以下の記事を読んでおくと理解が深まります。
なぜスケーリングが必要か
条件数と収束速度
勾配降下法の収束速度は、損失関数のヘシアン行列の条件数 $\kappa = \lambda_{\max}/\lambda_{\min}$ に大きく依存します。条件数が大きいほど収束が遅くなります。
線形回帰の場合、ヘシアンは $\bm{H} = \bm{X}^T\bm{X}/n$ です。特徴量のスケールが大きく異なると、$\bm{X}^T\bm{X}$ の固有値の範囲が広がり、条件数が増大します。
例えば、特徴量1が $[0, 1000]$、特徴量2が $[0, 1]$ の範囲をとる場合、$\bm{X}^T\bm{X}$ の固有値は概ね $10^6$ 倍も異なります。これは勾配降下法で細長い谷を横切りながら進む状況を生み出し、収束を著しく遅くします。
スケーリングによって各特徴量の分散をそろえると、$\bm{X}^T\bm{X}$ の固有値が均等に近づき、条件数が改善されます。
距離ベースのアルゴリズムへの影響
k-NN(k近傍法)やSVM、k-meansなどの距離ベースのアルゴリズムでは、特徴量間のスケールの違いが距離計算を支配します。
ユークリッド距離 $d(\bm{x}, \bm{x}’) = \sqrt{\sum_j (x_j – x_j’)^2}$ において、スケールの大きい特徴量が距離を支配してしまい、スケールの小さい特徴量の情報がほぼ無視されます。
正則化への影響
L1/L2正則化では $\sum_j |w_j|$ や $\sum_j w_j^2$ をペナルティとして加えますが、特徴量のスケールが異なると各 $w_j$ の意味が異なります。スケールの大きい特徴量は小さな重みで大きな寄与をするため、正則化で不当に有利になります。
スケーリングにより特徴量のスケールをそろえることで、正則化が全ての特徴量に均等に適用されます。
これらの問題を解決するスケーリング手法を、次のセクションで見ていきましょう。
標準化(Standardization / Z-score Normalization)
定義
標準化は各特徴量から平均を引き、標準偏差で割ることで、平均0・分散1に変換します。
$$ \begin{equation} x_j^{\text{std}} = \frac{x_j – \mu_j}{\sigma_j} \end{equation} $$
ここで $\mu_j = \frac{1}{n}\sum_{i=1}^n x_{ij}$ は特徴量 $j$ の平均、$\sigma_j = \sqrt{\frac{1}{n}\sum_{i=1}^n (x_{ij} – \mu_j)^2}$ は標準偏差です。
性質
- 変換後の各特徴量は平均0、分散1になる
- 外れ値の影響で $\mu_j, \sigma_j$ がずれる可能性がある
- データの分布の形は変わらない(正規分布でなければ、変換後も正規分布にならない)
- 値域は固定されない(例: 外れ値があると $|x_j^{\text{std}}| \gg 1$ になりうる)
適用場面
- 勾配降下法を使うモデル全般
- SVMのRBFカーネルなど距離ベースの手法
- PCA(主成分分析)
- 正則化付きの線形モデル
Min-Max正規化
定義
Min-Max正規化は特徴量を $[0, 1]$ の範囲にスケーリングします。
$$ \begin{equation} x_j^{\text{mm}} = \frac{x_j – \min_j}{\max_j – \min_j} \end{equation} $$
一般的に $[a, b]$ の範囲にスケーリングする場合は
$$ x_j^{\text{mm}} = a + (b – a) \cdot \frac{x_j – \min_j}{\max_j – \min_j} $$
性質
- 値域が $[0, 1]$(または指定範囲)に固定される
- 外れ値に非常に敏感(外れ値が $\max$ や $\min$ を決めるため)
- データの分布の形は変わらない
- 画像のピクセル値など、もともと有界なデータに適する
ロバストスケーリング
定義
外れ値に頑健なスケーリングとして、中央値とIQR(四分位範囲)を使う方法があります。
$$ \begin{equation} x_j^{\text{robust}} = \frac{x_j – \text{median}_j}{\text{IQR}_j} \end{equation} $$
ここで $\text{IQR}_j = Q_{75,j} – Q_{25,j}$ は第3四分位数と第1四分位数の差です。
性質
- 中央値とIQRは外れ値の影響を受けにくい
- データに外れ値が多い場合に標準化より適切
- 変換後の分布は平均0・分散1にはならない
import numpy as np
import matplotlib.pyplot as plt
from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler
np.random.seed(42)
# 外れ値を含むデータの生成
n = 200
x_normal = np.random.normal(50, 10, n)
# 外れ値を追加
x_with_outliers = np.append(x_normal, [120, 130, 150, -20, -30])
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
# (a) 元のデータ
ax = axes[0, 0]
ax.hist(x_with_outliers, bins=30, color="steelblue", alpha=0.8, edgecolor="gray")
ax.set_title(f"Original Data (mean={np.mean(x_with_outliers):.1f}, std={np.std(x_with_outliers):.1f})", fontsize=12)
ax.set_xlabel("Value", fontsize=11)
ax.grid(True, alpha=0.3)
# (b) 標準化
ax = axes[0, 1]
x_std = (x_with_outliers - np.mean(x_with_outliers)) / np.std(x_with_outliers)
ax.hist(x_std, bins=30, color="coral", alpha=0.8, edgecolor="gray")
ax.set_title(f"Standardized (mean={np.mean(x_std):.2f}, std={np.std(x_std):.2f})", fontsize=12)
ax.set_xlabel("Value", fontsize=11)
ax.grid(True, alpha=0.3)
# (c) Min-Max
ax = axes[1, 0]
x_mm = (x_with_outliers - np.min(x_with_outliers)) / (np.max(x_with_outliers) - np.min(x_with_outliers))
ax.hist(x_mm, bins=30, color="lightgreen", alpha=0.8, edgecolor="gray")
ax.set_title(f"Min-Max Normalized (range=[{np.min(x_mm):.2f}, {np.max(x_mm):.2f}])", fontsize=12)
ax.set_xlabel("Value", fontsize=11)
ax.grid(True, alpha=0.3)
# (d) ロバストスケーリング
ax = axes[1, 1]
median = np.median(x_with_outliers)
q25, q75 = np.percentile(x_with_outliers, [25, 75])
x_robust = (x_with_outliers - median) / (q75 - q25)
ax.hist(x_robust, bins=30, color="plum", alpha=0.8, edgecolor="gray")
ax.set_title(f"Robust Scaled (median={np.median(x_robust):.2f}, IQR={np.percentile(x_robust, 75)-np.percentile(x_robust, 25):.2f})", fontsize=12)
ax.set_xlabel("Value", fontsize=11)
ax.grid(True, alpha=0.3)
plt.suptitle("Feature Scaling Methods with Outliers", fontsize=14, y=1.02)
plt.tight_layout()
plt.savefig("scaling_methods.png", dpi=150, bbox_inches="tight")
plt.show()
このグラフから、各スケーリング手法の外れ値に対する振る舞いが読み取れます。
-
元のデータ(左上): 平均50、標準偏差10の正規分布に外れ値(120, 130, 150, -20, -30)が加わっています
-
標準化(右上): 外れ値の影響で平均と標準偏差がずれ、大部分のデータが $[-1, 1]$ 付近に圧縮されています。外れ値は $|z| > 3$ の位置に来ていますが、主要なデータの分離は維持されています
-
Min-Max(左下): 外れ値がminとmaxを決めるため、大部分のデータが $[0.3, 0.6]$ の狭い範囲に圧縮されています。データの解像度が大幅に低下しており、外れ値がある場合にMin-Maxが不適切であることがわかります
-
ロバスト(右下): 中央値とIQRは外れ値の影響を受けにくいため、大部分のデータが $[-1, 1]$ 付近に適切にスケーリングされています。外れ値は離れた位置に来ますが、主要なデータの分布が保持されています
外れ値がある場合にはロバストスケーリングが最も適切であることがわかります。
勾配降下法の収束への影響
スケーリングが勾配降下法の収束にどれだけ影響するかを、具体例で確認しましょう。
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(42)
# スケールの異なる2次元データ
n = 100
x1 = np.random.randn(n) * 100 # スケール大
x2 = np.random.randn(n) * 0.1 # スケール小
X_raw = np.column_stack([x1, x2])
w_true = np.array([0.5, 30.0])
y = X_raw @ w_true + np.random.randn(n) * 5
# 標準化
X_scaled = (X_raw - X_raw.mean(axis=0)) / X_raw.std(axis=0)
y_centered = y - y.mean()
def gd_linear(X, y, lr, n_steps):
"""勾配降下法による線形回帰"""
w = np.zeros(X.shape[1])
losses = []
trajectory = [w.copy()]
for _ in range(n_steps):
pred = X @ w
loss = 0.5 * np.mean((pred - y)**2)
losses.append(loss)
grad = X.T @ (pred - y) / len(y)
w = w - lr * grad
trajectory.append(w.copy())
return np.array(trajectory), np.array(losses)
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
# (a) スケーリングなしの等高線と軌跡
ax = axes[0, 0]
w1_range = np.linspace(-0.5, 1.5, 100)
w2_range = np.linspace(-10, 70, 100)
W1, W2 = np.meshgrid(w1_range, w2_range)
Z = np.zeros_like(W1)
for i in range(W1.shape[0]):
for j in range(W1.shape[1]):
w = np.array([W1[i,j], W2[i,j]])
Z[i,j] = 0.5 * np.mean((X_raw @ w - y)**2)
ax.contour(W1, W2, np.log10(Z+1), levels=20, cmap="Blues", alpha=0.7)
traj_raw, losses_raw = gd_linear(X_raw, y, 1e-6, 200)
ax.plot(traj_raw[:, 0], traj_raw[:, 1], "r-o", markersize=2, linewidth=1)
ax.plot(*w_true, "g*", markersize=15, label="True $w$")
ax.set_xlabel("$w_1$", fontsize=12)
ax.set_ylabel("$w_2$", fontsize=12)
ax.set_title("Without Scaling (GD trajectory)", fontsize=13)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)
# (b) スケーリング後の等高線と軌跡
ax = axes[0, 1]
w1_range_s = np.linspace(-80, 80, 100)
w2_range_s = np.linspace(-20, 20, 100)
W1s, W2s = np.meshgrid(w1_range_s, w2_range_s)
Zs = np.zeros_like(W1s)
for i in range(W1s.shape[0]):
for j in range(W1s.shape[1]):
w = np.array([W1s[i,j], W2s[i,j]])
Zs[i,j] = 0.5 * np.mean((X_scaled @ w - y_centered)**2)
ax.contour(W1s, W2s, np.log10(Zs+1), levels=20, cmap="Blues", alpha=0.7)
traj_scaled, losses_scaled = gd_linear(X_scaled, y_centered, 0.1, 200)
ax.plot(traj_scaled[:, 0], traj_scaled[:, 1], "r-o", markersize=2, linewidth=1)
ax.set_xlabel("$w_1$ (scaled)", fontsize=12)
ax.set_ylabel("$w_2$ (scaled)", fontsize=12)
ax.set_title("With Scaling (GD trajectory)", fontsize=13)
ax.grid(True, alpha=0.3)
# (c) 損失曲線の比較
ax = axes[1, 0]
ax.plot(losses_raw / losses_raw[0], "r-", linewidth=2, label="Without scaling")
ax.plot(losses_scaled / losses_scaled[0], "b-", linewidth=2, label="With scaling")
ax.set_xlabel("Iteration", fontsize=12)
ax.set_ylabel("Normalized Loss", fontsize=12)
ax.set_title("Convergence Comparison", fontsize=13)
ax.legend(fontsize=11)
ax.grid(True, alpha=0.3)
ax.set_yscale("log")
# (d) 条件数の比較
ax = axes[1, 1]
cond_raw = np.linalg.cond(X_raw.T @ X_raw)
cond_scaled = np.linalg.cond(X_scaled.T @ X_scaled)
bars = ax.bar(["Without Scaling", "With Scaling"],
[cond_raw, cond_scaled],
color=["coral", "steelblue"], alpha=0.8, edgecolor="gray")
ax.set_ylabel("Condition Number $\\kappa$", fontsize=12)
ax.set_title("Condition Number of $X^TX$", fontsize=13)
ax.grid(True, alpha=0.3, axis="y")
for bar, val in zip(bars, [cond_raw, cond_scaled]):
ax.text(bar.get_x() + bar.get_width()/2., bar.get_height() + max(cond_raw, cond_scaled)*0.02,
f"{val:.0f}", ha="center", fontsize=11, fontweight="bold")
plt.tight_layout()
plt.savefig("scaling_convergence.png", dpi=150, bbox_inches="tight")
plt.show()
このグラフから、スケーリングが勾配降下法の収束に劇的な影響を与えることが確認できます。
-
左上(スケーリングなしの軌跡): 等高線が非常に細長い楕円で、勾配降下法の軌跡(赤)が谷の壁を左右に振動しながらゆっくりと最適解に向かっています。$w_1$(スケール大の特徴量)方向の動きが微小で、$w_2$(スケール小の特徴量)方向で激しく振動しています
-
右上(スケーリング後の軌跡): 等高線がほぼ円形に近くなり、勾配降下法の軌跡がほぼ直線的に最適解に向かっています。振動がほとんどなく、効率的に収束しています
-
左下(収束速度の比較): スケーリングあり(青)は20ステップ程度でほぼ収束していますが、スケーリングなし(赤)は200ステップでも十分に収束していません。収束速度に10倍以上の差があります
-
右下(条件数の比較): スケーリングなしでは条件数が非常に大きく、スケーリング後には大幅に改善されています。条件数の改善が収束速度の改善に直結していることが確認できます
スケーリングが必要なモデル・不要なモデル
スケーリングが必要なモデル
| モデル | 理由 |
|---|---|
| 線形回帰(勾配降下法) | 条件数の改善 |
| ロジスティック回帰 | 勾配降下法の収束 |
| SVM | 距離ベース + 正則化 |
| k-NN | ユークリッド距離の計算 |
| k-means | 距離ベースのクラスタリング |
| PCA | 分散に基づく手法 |
| ニューラルネットワーク | 勾配降下法 + 活性化関数の飽和防止 |
| Lasso/Ridge回帰 | 正則化の公平性 |
スケーリングが不要なモデル
| モデル | 理由 |
|---|---|
| 決定木 | 分割条件は各特徴量を独立に評価 |
| ランダムフォレスト | 決定木のアンサンブル |
| 勾配ブースティング(GBDT) | 決定木ベース |
| ナイーブベイズ | 確率ベース |
決定木ベースのモデルは、各ノードで「$x_j \leq t$」という条件分岐を行うため、特徴量のスケールは分割の基準に影響しません。
データリークを防ぐ正しい手順
よくある間違い
スケーリングのパラメータ(平均、標準偏差など)を全データから計算してしまうと、テストデータの情報が訓練に漏れるデータリーク(data leakage)が発生します。
import numpy as np
import matplotlib.pyplot as plt
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.linear_model import Ridge
np.random.seed(42)
# データ生成
n = 200
d = 10
X = np.random.randn(n, d) * np.random.uniform(1, 100, d)
y = X @ np.random.randn(d) + np.random.randn(n) * 5
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3)
# 間違った方法: 全データでスケーリング → 分割
scaler_wrong = StandardScaler()
X_all_scaled = scaler_wrong.fit_transform(X)
X_train_wrong = X_all_scaled[:len(X_train)]
X_test_wrong = X_all_scaled[len(X_train):]
# 正しい方法: 訓練データでfit → テストデータにtransform
scaler_correct = StandardScaler()
X_train_correct = scaler_correct.fit_transform(X_train)
X_test_correct = scaler_correct.transform(X_test)
fig, axes = plt.subplots(1, 2, figsize=(14, 5.5))
# (a) 手順の図示
ax = axes[0]
steps_correct = [
"1. Train/Test Split",
"2. Fit scaler on Train",
"3. Transform Train",
"4. Transform Test\n (using Train stats)",
"5. Train Model",
"6. Evaluate on Test"
]
steps_wrong = [
"1. Fit scaler on ALL data",
"2. Transform ALL data",
"3. Train/Test Split",
"4. Train Model",
"5. Evaluate on Test",
""
]
y_pos = np.arange(len(steps_correct))[::-1]
ax.barh(y_pos + 0.2, [1]*6, height=0.35, color="green", alpha=0.3, label="Correct")
ax.barh(y_pos - 0.2, [1]*6, height=0.35, color="red", alpha=0.3, label="Wrong (data leak)")
for i, (sc, sw) in enumerate(zip(steps_correct, steps_wrong)):
ax.text(0.05, y_pos[i] + 0.2, sc, va="center", fontsize=9, color="darkgreen")
ax.text(0.05, y_pos[i] - 0.2, sw, va="center", fontsize=9, color="darkred")
ax.set_xlim(0, 1)
ax.set_yticks([])
ax.set_xticks([])
ax.set_title("Correct vs Wrong Scaling Procedure", fontsize=13)
ax.legend(fontsize=10, loc="lower right")
# (b) テストデータでの特徴量の平均の比較
ax = axes[1]
# 正しい方法: テストデータの平均は0からずれる
test_means_correct = X_test_correct.mean(axis=0)
test_means_wrong = X_test_wrong.mean(axis=0)
x_pos = np.arange(d)
width = 0.35
ax.bar(x_pos - width/2, test_means_correct, width, color="green", alpha=0.7,
label="Correct (train-fit)")
ax.bar(x_pos + width/2, test_means_wrong, width, color="red", alpha=0.7,
label="Wrong (all-fit)")
ax.axhline(0, color="gray", linewidth=1)
ax.set_xlabel("Feature index", fontsize=12)
ax.set_ylabel("Test data mean (after scaling)", fontsize=12)
ax.set_title("Test Data Mean: Correct vs Wrong Scaling", fontsize=13)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3, axis="y")
ax.set_xticks(x_pos)
plt.tight_layout()
plt.savefig("scaling_data_leak.png", dpi=150, bbox_inches="tight")
plt.show()
このグラフから、スケーリングの手順の違いが確認できます。
-
左図(手順の比較): 正しい手順(緑)では、まずデータを分割してからスケーラーを訓練データのみでフィットし、同じパラメータでテストデータを変換します。間違った手順(赤)では全データでスケーラーをフィットしてから分割するため、テストデータの統計量が訓練時に漏洩しています
-
右図(テストデータの平均): 正しい方法(緑)ではテストデータの平均が0からずれています。これは訓練データの平均で中心化しているため正常な挙動です。間違った方法(赤)ではテストデータの平均がほぼ0ですが、これはテストデータの情報が漏洩している証拠です
scikit-learnの Pipeline を使えば、このような間違いを自動的に防ぐことができます。
まとめ
本記事では、特徴量スケーリングの理論と実践について解説しました。
- 特徴量スケーリングが必要な理由は条件数の改善(勾配降下法の収束)、距離計算の公平性、正則化の公平性の3つ
- 標準化は平均0・分散1に変換し、最も汎用的なスケーリング手法
- Min-Max正規化は値域を $[0, 1]$ に固定するが、外れ値に敏感
- ロバストスケーリングは中央値とIQRを使い、外れ値に頑健
- 決定木ベースのモデルはスケーリング不要だが、距離・勾配ベースのモデルには必須
- スケーリングは訓練データでフィット→テストデータにトランスフォームの手順を守り、データリークを防ぐ
次のステップとして、以下の記事も参考にしてください。
- 正則化の理論 — スケーリングとの相互作用
- 勾配降下法の基礎 — 収束条件の理論
- 訓練・検証・テスト分割 — データリークを防ぐ分割法