📚 背景知識(読んでから問題へ)
| 用語 | 直感的な意味 |
|---|---|
| Huber Loss | 残差が小さいうちは二乗誤差、しきい値(epsilon)を超えたら絶対誤差に切り替える「ハイブリッド損失関数」 |
| HuberRegressor | sklearnのHuber Lossベース線形回帰。通常のLinearRegressionより外れ値に引っ張られにくい |
| 量子回帰(Quantile Regression) | 「平均」ではなく「特定のパーセンタイル(中央値・10%点・90%点など)」を予測する回帰 |
| Pinball Loss | 量子回帰が最小化する損失。予測が実際より低いか高いかで非対称に罰を与える |
🧩 外れ値対策の3つの視点
📈 二乗誤差はなぜ弱いか
残差が2倍 → 罰は4倍
残差を2乗するため、誤差が大きい点ほど罰が加速度的に増える。たった1件の桁違いな外れ値が損失関数全体を支配し、予測がそこに引っ張られる。
🛡️ Huber Lossの切り替え
小さな誤差は二乗、大きな誤差は線形
しきい値epsilon以下は滑らかな二乗誤差、超えたら傾き一定の線形誤差に切り替える。しきい値を大きくしすぎるとOLSに近づき、ロバスト性を失う。
🎯 量子回帰の発想
平均ではなく「順位」を予測
中央値は極端な値がどれだけ極端でも「順位」がほぼ動かないため外れ値に強い。P10〜P90を組み合わせれば点予測ではなく予測区間が作れる。
🎯 問題
中古マンション価格予測(回帰)を模した合成データ。学習データには5%の外れ値(データ入力ミス・超高級物件を想定し価格が3〜8倍に膨らんだ行)を混入させ、holdoutは完全にクリーン(本来知りたい「普通の物件」の集団)とする。
import numpy as np import pandas as pd def make_price_df(seed, n, outlier_frac=0.05): rng_state = np.random.get_state() np.random.seed(seed) size_sqm = np.random.normal(70, 20, n).clip(20, 200) age_years = np.random.exponential(15, n).clip(0, 80) distance_station_km = np.random.exponential(2, n).clip(0.1, 15) num_rooms = np.random.randint(1, 6, n) renovated = np.random.binomial(1, 0.3, n) base_price = ( 3000000 + 45000 * size_sqm - 20000 * age_years - 80000 * distance_station_km + 500000 * renovated + np.random.normal(0, 600000, n) ) price = base_price.clip(500000, None) n_outliers = int(n * outlier_frac) if n_outliers > 0: outlier_idx = np.random.choice(n, n_outliers, replace=False) price[outlier_idx] = price[outlier_idx] * np.random.uniform(3, 8, n_outliers) df = pd.DataFrame({'SizeSqm': size_sqm, 'AgeYears': age_years, 'DistanceStationKm': distance_station_km, 'NumRooms': num_rooms, 'Renovated': renovated, 'Price': price}) np.random.set_state(rng_state) return df FEATURES = ['SizeSqm', 'AgeYears', 'DistanceStationKm', 'NumRooms', 'Renovated'] df_train = make_price_df(seed=42, n=2000, outlier_frac=0.05) # 学習データ(外れ値5%混入) df_holdout = make_price_df(seed=999, n=1000, outlier_frac=0.0) # クリーンなholdout X, y = df_train[FEATURES].values, df_train['Price'].values X_hold, y_hold = df_holdout[FEATURES].values, df_holdout['Price'].values
| カラム名 | 意味 | 型 |
|---|---|---|
| SizeSqm | 専有面積(㎡) | float |
| AgeYears | 築年数 | float |
| DistanceStationKm | 最寄り駅までの距離(km) | float |
| NumRooms | 部屋数(1〜5)。価格生成には無関係(真の係数=0) | int |
| Renovated | リフォーム済みフラグ(1=済み) | int |
| Price | 価格(円、目的変数) | float |
タスク
epsilonの役割、量子回帰が「平均」でなく「パーセンタイル」を予測することで外れ値に強くなる理由を説明するLinearRegression・HuberRegressor(epsilon=1.35)・GradientBoostingRegressor(loss='quantile', alpha=0.5)を外れ値混入データX, yで学習し、クリーンなholdoutでMAE・RMSEを比較する。係数を真の生成係数と比較するepsilonを[1.1, 1.35, 1.5, 2.0, 5.0]で振ってholdout MAEを確認する。学習データのoutlier_fracを[0.0, 0.05, 0.15, 0.30]で振り、LinearRegressionとHuberRegressorの悪化度合いを比較する(holdoutは常にクリーン固定)alpha=0.1, 0.5, 0.9の量子回帰でP10〜P90の予測区間を作り、クリーンなholdoutでの経験的カバレッジ(約80%になるはず)を検証する📐 損失関数の形を比較
横軸は残差 r(実際値−予測値)、縦軸は損失の大きさ。同じ残差でも損失関数によって「罰の増え方」がまったく違うことを図解する。
📊 MAE・係数・カバレッジの比較(実行結果)
実際にコードを実行して得た値(乱数シード固定・環境により多少前後する)。バーはそれぞれのチャート内での相対値。
クリーンholdoutでのMAE比較
係数の歪み: Renovated(真の係数=500,000)
係数の歪み: NumRooms(真の係数=0・価格と無関係)
epsilonスイープ(HuberRegressor、holdout MAE)
outlier_frac スイープ(holdoutは常にクリーン固定)
P10-P90 予測区間の経験的カバレッジ
P10・P90それぞれ独立して学習した量子回帰モデルでも、狙った80%(=P90−P10)にほぼ一致するカバレッジが得られた(平均区間幅 2,012,673円)。
💡 ヒント
残差が10のとき二乗誤差は100、残差が100(10倍)のとき二乗誤差は10,000(100倍)になる。「誤差の大きさが2乗で効いてくる」という加速する罰の性質が、二乗誤差が外れ値に弱い理由の核心。Huber Lossはこの加速に上限を設け、量子回帰は「平均」ではなく「順位(パーセンタイル)」で予測するため、そもそも極端な値に位置が引っ張られにくい。
sklearn.linear_model.HuberRegressor(epsilon=...)とsklearn.ensemble.GradientBoostingRegressor(loss='quantile', alpha=...)を使う- 評価は必ず外れ値のないholdout(
X_hold, y_hold)に対して行う - 外れ値割合を変えるときは
make_price_df(seed=42, n=2000, outlier_frac=frac)を呼び直し、holdoutは固定のまま比較する - 予測区間のカバレッジは
(y_hold >= pred_q10) & (y_hold <= pred_q90)の平均で計算する
from sklearn.linear_model import LinearRegression, HuberRegressor from sklearn.ensemble import GradientBoostingRegressor from sklearn.metrics import mean_absolute_error, mean_squared_error lr = LinearRegression().fit(X, y) hub = HuberRegressor(epsilon=___, max_iter=1000).fit(X, y) gbr_q50 = GradientBoostingRegressor(loss=___, alpha=0.5, n_estimators=200, max_depth=3, random_state=42).fit(X, y) for name, model in [('LinearRegression', lr), ('Huber', hub), ('Quantile median', gbr_q50)]: pred = model.predict(___) # ← X_hold mae = mean_absolute_error(y_hold, pred) rmse = mean_squared_error(y_hold, pred) ** 0.5 print(name, mae, rmse) # 量子回帰で予測区間 gbr_q10 = GradientBoostingRegressor(loss='quantile', alpha=___, n_estimators=200, max_depth=3, random_state=42).fit(X, y) gbr_q90 = GradientBoostingRegressor(loss='quantile', alpha=___, n_estimators=200, max_depth=3, random_state=42).fit(X, y) coverage = np.mean((y_hold >= gbr_q10.predict(X_hold)) & (y_hold <= ___))
✅ 模範解答
import numpy as np import pandas as pd from sklearn.linear_model import LinearRegression, HuberRegressor from sklearn.ensemble import GradientBoostingRegressor from sklearn.metrics import mean_absolute_error, mean_squared_error # ====== データ準備(省略: 問題文のmake_price_dfをそのまま使用) ====== df_train = make_price_df(seed=42, n=2000, outlier_frac=0.05) df_holdout = make_price_df(seed=999, n=1000, outlier_frac=0.0) X, y = df_train[FEATURES].values, df_train['Price'].values X_hold, y_hold = df_holdout[FEATURES].values, df_holdout['Price'].values # ====== タスク2: 3モデルを学習しholdoutで比較 ====== lr = LinearRegression().fit(X, y) hub = HuberRegressor(epsilon=1.35, max_iter=1000).fit(X, y) gbr_q50 = GradientBoostingRegressor(loss='quantile', alpha=0.5, n_estimators=200, max_depth=3, random_state=42).fit(X, y) for name, model in [('LinearRegression', lr), ('HuberRegressor(eps=1.35)', hub), ('GBR quantile(alpha=0.5)', gbr_q50)]: pred = model.predict(X_hold) mae = mean_absolute_error(y_hold, pred) rmse = mean_squared_error(y_hold, pred) ** 0.5 print(f"{name:28s}: MAE={mae:,.0f} RMSE={rmse:,.0f}") print("\nLinearRegression coef:", np.round(lr.coef_, 1)) print("HuberRegressor coef: ", np.round(hub.coef_, 1)) print("真の係数: [45000.0 -20000.0 -80000.0 0.0 500000.0]") # ====== タスク3: epsilon・outlier_frac のスイープ ====== print("\n--- epsilon スイープ(HuberRegressor) ---") for eps in [1.1, 1.35, 1.5, 2.0, 5.0]: h = HuberRegressor(epsilon=eps, max_iter=1000).fit(X, y) pred = h.predict(X_hold) print(f"epsilon={eps:4.2f}: MAE={mean_absolute_error(y_hold, pred):,.0f}") print("\n--- outlier_frac スイープ(holdoutは常にクリーン固定) ---") for frac in [0.0, 0.05, 0.15, 0.30]: df_tr = make_price_df(seed=42, n=2000, outlier_frac=frac) Xf, yf = df_tr[FEATURES].values, df_tr['Price'].values lr_f = LinearRegression().fit(Xf, yf) hub_f = HuberRegressor(epsilon=1.35, max_iter=1000).fit(Xf, yf) mae_lr = mean_absolute_error(y_hold, lr_f.predict(X_hold)) mae_hub = mean_absolute_error(y_hold, hub_f.predict(X_hold)) print(f"outlier_frac={frac:.2f}: LinearRegression MAE={mae_lr:,.0f} | Huber MAE={mae_hub:,.0f}") # ====== タスク4: 量子回帰で予測区間を作りカバレッジを確認 ====== gbr_q10 = GradientBoostingRegressor(loss='quantile', alpha=0.1, n_estimators=200, max_depth=3, random_state=42).fit(X, y) gbr_q90 = GradientBoostingRegressor(loss='quantile', alpha=0.9, n_estimators=200, max_depth=3, random_state=42).fit(X, y) pred_q10, pred_q50, pred_q90 = gbr_q10.predict(X_hold), gbr_q50.predict(X_hold), gbr_q90.predict(X_hold) coverage = np.mean((y_hold >= pred_q10) & (y_hold <= pred_q90)) print(f"\nP10-P90 経験的カバレッジ: {coverage:.4f}(目標: 約0.80)") print(f"平均区間幅: {np.mean(pred_q90 - pred_q10):,.0f}")
出力イメージ(実際に実行して確認した値。乱数・環境により多少前後する):
LinearRegression : MAE=1,209,810 RMSE=1,393,665 HuberRegressor(eps=1.35) : MAE=472,350 RMSE=593,536 GBR quantile(alpha=0.5) : MAE=497,668 RMSE=626,191 LinearRegression coef: [ 63067.3 -20091.8 -67398.3 -81853.5 1152766.7] HuberRegressor coef: [ 45514.3 -17840.6 -78983.8 -20275.2 415242.2] 真の係数: [45000.0 -20000.0 -80000.0 0.0 500000.0] --- epsilon スイープ(HuberRegressor) --- epsilon=1.10: MAE=472,547 epsilon=1.35: MAE=472,350 epsilon=1.50: MAE=471,840 epsilon=2.00: MAE=471,080 epsilon=5.00: MAE=1,031,850 --- outlier_frac スイープ(holdoutは常にクリーン固定) --- outlier_frac=0.00: LinearRegression MAE=472,530 | Huber MAE=475,303 outlier_frac=0.05: LinearRegression MAE=1,209,810 | Huber MAE=472,350 outlier_frac=0.15: LinearRegression MAE=3,885,645 | Huber MAE=478,855 outlier_frac=0.30: LinearRegression MAE=7,764,981 | Huber MAE=651,325 P10-P90 経験的カバレッジ: 0.8020(目標: 約0.80) 平均区間幅: 2,012,673
rをr²として罰します。残差10なら罰は100、残差100(10倍)なら罰は10,000(100倍)——誤差の大きさが2乗で効いてくるため、数件の桁違いな外れ値が損失関数全体を支配します。Huber Lossはこの性質に上限を設け、残差がepsilon以下なら二乗誤差、超えたら絶対誤差(線形)に切り替えます。epsilonが小さいほど早く線形に切り替わり外れ値に鈍感になり、大きいほど二乗誤差の範囲が広がり外れ値の影響を受けやすくなります(実行結果ではepsilon=5.0でMAEが1,031,850まで悪化し、ほぼLinearRegression並みに戻った)。量子回帰は「平均」ではなく「中央値」など特定の順位に位置する値を予測するため、外れ値がどれだけ極端でも順位(=中央値の位置)はほとんど動かず、外れ値に強くなります。🪜 Step-by-Step 解説
13モデルを外れ値混入データで学習し、クリーンなholdoutで評価する
lr = LinearRegression().fit(X, y) hub = HuberRegressor(epsilon=1.35, max_iter=1000).fit(X, y) gbr_q50 = GradientBoostingRegressor(loss='quantile', alpha=0.5, n_estimators=200, max_depth=3, random_state=42).fit(X, y)
LinearRegressionのMAE=1,209,810に対し、HuberRegressorは472,350、量子回帰の中央値は497,668と、どちらも二乗誤差ベースの通常回帰の半分以下のMAEになりました。2係数を比較し、外れ値が生む「幻の相関」を確認する
print("LinearRegression coef:", lr.coef_) print("HuberRegressor coef: ", hub.coef_)
NumRoomsは価格の生成式に一切登場しない(真の係数は0)にもかかわらず、LinearRegressionは-81,853.5という大きな係数を割り当てました。外れ値が偶然NumRoomsの値と弱く相関していたために生じた「幻の相関」です。さらにRenovatedの真の係数500,000に対しLinearRegressionは1,152,766.7と2倍以上に過大評価していますが、HuberRegressorは415,242.2と、過小評価ではあるものの真の値にずっと近い推定になっています。外れ値は「精度が落ちる」だけでなく「無関係な特徴量に嘘の重要性を与える」ことがあります。3epsilon・outlier_fracを振って、ロバスト性の限界と代償を確認する
for eps in [1.1, 1.35, 1.5, 2.0, 5.0]: h = HuberRegressor(epsilon=eps, max_iter=1000).fit(X, y) ... for frac in [0.0, 0.05, 0.15, 0.30]: ...
epsilonスイープでは1.1〜2.0の範囲でMAEはほぼ横ばい(471,080〜472,547)でしたが、epsilon=5.0まで広げると1,031,850まで悪化しました。しきい値を広げすぎると外れ値まで二乗誤差の対象に含めてしまい、Huber LossはLinearRegressionの挙動に近づきます。outlier_fracスイープでは、外れ値0%のときLinearRegression(472,530)の方がHuberRegressor(475,303)よりわずかに良い結果でした——外れ値が無ければHuberはごくわずかな「保険料」を払っていることになります。しかし外れ値が30%まで増えるとLinearRegressionは7,764,981まで爆発的に悪化する一方、HuberRegressorは651,325に留まりました。4量子回帰で予測区間を作り、カバレッジを検証する
pred_q10, pred_q90 = gbr_q10.predict(X_hold), gbr_q90.predict(X_hold) coverage = np.mean((y_hold >= pred_q10) & (y_hold <= pred_q90))
alpha=0.1とalpha=0.9のモデルはそれぞれ「10パーセンタイル」「90パーセンタイル」を予測するように学習されているため、理論上は実際の価格の約80%(90%−10%)がこの区間に収まるはずです。実行結果ではカバレッジ0.8020と、狙った80%にほぼ一致しました。「1点の予測値」ではなく「ありうる範囲」を提示できるという量子回帰特有の強みで、点予測が外れ値に引きずられるリスクを、区間全体で吸収する発想です。🧮 数学・統計の補足(文系向け)
epsilon)を超えたら、それ以降は1分あたり定額の罰金に切り替える」という会社です。悪質な遅刻でも罰金が青天井にならないため、たった1人の特殊事情に振り回されにくくなります。数式を見たら
L_delta(r) = 0.5r²(|r|≤delta) / delta・(|r|-0.5delta)(|r|>delta)
rは残差、delta(sklearnではepsilon×scale相当)がしきい値。残差が小さい範囲では滑らかな二乗誤差、しきい値を超えたら傾き一定の線形関数に切り替わる「区分関数」であることを表している。
L_alpha(r) = alpha・r(r≥0) / (alpha-1)・r(r<0)
alphaは予測したいパーセンタイル(例: 中央値なら0.5)。r = y_実際 - y_予測が正(予測が低すぎた)か負(予測が高すぎた)かで、非対称に傾きの違う罰を与える「Pinball Loss」。alpha=0.5のときは正負で同じ傾き(絶対誤差=MAE)になり、alpha=0.1なら「予測が低すぎる方を強く罰する」形になる。
🏆 Kaggleでの実践的な使い方
よく使われるコンペカテゴリ: ☑ 表形式データ(Tabular) / ☐ 自然言語処理(NLP) / ☐ 画像認識(CV) / ☑ 時系列(Time Series)
- LightGBM/XGBoostのロバスト目的関数: LightGBMは
objective='huber'やobjective='quantile'、objective='fair'を直接指定できる。XGBoostにもreg:pseudohubererrorがある。保険金額・不動産価格・売上予測など外れ値の多い実データでは、前処理でクリッピングする代わりにこれらを試すのが定石の一つ - M5 Forecasting - Uncertainty(Kaggle過去コンペ): 点予測ではなく複数パーセンタイルの予測区間を提出させるコンペで、まさに今日学んだ量子回帰(Pinball Loss/WSPLでの評価)が中心的な手法だった
- 保険金請求額予測: 少数の巨大請求(火災・大事故)が学習データに混ざる業界では、二乗誤差ベースのモデルがその少数例に引っ張られやすい。Huber Lossや対数変換(Phase 3で学んだ目的変数変換)と組み合わせて使われる
- 前処理(クリッピング・対数変換)との使い分け: Phase 3 Day 072で学んだ「外れ値をクリップ/対数変換する」方法はデータ側を変える発想、今日のHuber/量子回帰は損失関数(モデル側)を変える発想。どちらか一方でなく併用されることも多い
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
Huber Lossはepsilonを大きくするほど常にロバストになると思う | 「しきい値を広げる=許容範囲が広がる=安全」という直感 | 実行結果ではepsilon=5.0のときMAEが1,031,850まで悪化し、LinearRegression(1,209,810)に近づいた。広げすぎると外れ値まで二乗誤差の対象になり、ロバスト性を失う |
| 量子回帰の中央値(alpha=0.5)予測は平均予測とほぼ同じ結果になると思う | 「中央値も平均も"真ん中"を表す指標」という混同 | 外れ値がある分布では中央値と平均は大きくズレる。今日の実行でも量子回帰の中央値MAE(497,668)はLinearRegression(1,209,810)よりずっと良く、両者が別物であることが分かる |
| 外れ値が全く無いデータでも念のためHuber Lossを使っておけば損はないと思う | 「ロバストな手法は常に安全側」という思い込み | 実行結果のoutlier_frac=0.00ではLinearRegression(472,530)の方がHuberRegressor(475,303)よりわずかに良かった。外れ値が無いときHuberは小さな代償(保険料)を払っている |
| 複数の量子回帰モデル(P10・P50・P90)を別々に学習すれば、必ずP10<P50<P90の順序が保たれると思う | 「同じデータで学習したのだから矛盾しないはず」という直感 | 各パーセンタイルは独立したモデルとして学習されるため、極端なケースではquantile crossing(分位点の交差)が起こりうる。今日のデータでは起きなかったが、実務では単調性を保証する後処理が必要になることがある |
| NumRoomsのような無関係な特徴量は外れ値があっても係数はほぼ0のままだと思う | 「関係ない変数には影響が及ばない」という思い込み | 実行結果では真の係数が0のNumRoomsに、LinearRegressionは-81,853.5という大きな係数を割り当てた。外れ値は無関係な特徴量にも「幻の相関」を生み、モデルの解釈を歪めうる |
🚀 次のステップ
- 発展: 3つの量子回帰モデル(P10・P50・P90)の予測値を全holdout行で比較し、
quantile crossing(P10がP50を上回るような矛盾)が実際に起きていないか確認してみる。もし起きていた場合、np.sortで3つの予測値を強制的に単調化する後処理を試す - 次回予告: Day 106「不均衡データ対策(SMOTE・class_weight・閾値調整)」 — 外れ値と並んで実務・Kaggle頻出の「データの偏り」問題として、少数派クラスへの対処法を学ぶ
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: