Hayami Kaito Web
~/tech

特徴量エンジニアリングと前処理のしくみ:スケーリング・欠損値・カテゴリ変数・リーク

published

機械学習の解説では、モデルの選び方や学習の方法が中心になりがちです。しかし実務で表の形のデータを扱うと、成績を大きく左右するのは、むしろ データをどうモデルに渡すか です。単位がばらばらの列・抜けている値・数千種類ある ID の列・極端に偏った値など、生のデータにはモデルがそのままでは扱いにくいものがたくさんあります。

この記事では、よく出てくる 4 つの場面で、前処理と特徴の作り方によって成績がどれだけ変わるかを確かめます。最後に、評価の成績を実際より良く見せてしまう リーク を整理します。

結論を先に書くと、次のようになります。

場面やること計算例での変化
単位がばらばら標準化(平均 0・標準偏差 1)にそろえるk 近傍法の正解率が 0.623 → 0.816
欠損値がある平均などで埋め、「欠損していたか」の列を足すAUC が 0.792 → 0.841
種類の多いカテゴリ自分の行を含まないデータで、カテゴリごとの目的変数の平均に置き換えるそのまま平均すると評価用の AUC が 0.636 まで下がる。分けて計算すると 0.732
分布が偏っている対数を取る線形回帰の決定係数が 0.483 → 0.909

どの場面でも共通するルールは、前処理に使う値(平均・標準偏差・カテゴリごとの平均など)は、学習用のデータだけから計算する ことです。

説明の途中に出てくる計算例は、Python と NumPy で実際に動かした結果です。

環境

計算例を動かした環境です。

項目バージョン
Python3.13
NumPy2.5

計算例で使う道具

ロジスティック回帰・ROC-AUC・k 近傍法を用意します。ロジスティック回帰は、学習をきちんと収束させるためにニュートン法で解いています。AUC の意味は 機械学習の評価指標のしくみ で説明しました。

import numpy as np

def fit_logreg(X, y, steps=30, l2=1e-3):
    """ロジスティック回帰(ニュートン法、弱い L2 正則化つき)"""
    Xb = np.column_stack([np.ones(len(X)), X]); beta = np.zeros(Xb.shape[1])
    for _ in range(steps):
        p = 1 / (1 + np.exp(-Xb @ beta))
        grad = Xb.T @ (p - y) + l2 * beta
        hess = (Xb * (p * (1 - p))[:, None]).T @ Xb + l2 * np.eye(len(beta))
        beta -= np.linalg.solve(hess, grad)
    return beta

def predict(beta, X):
    return 1 / (1 + np.exp(-(beta[0] + X @ beta[1:])))

def auc(y, s):
    """ROC-AUC:陽性と陰性を 1 組選んだとき、陽性の方が高い点数になる確率"""
    r = np.argsort(np.argsort(s)) + 1
    n1 = y.sum(); n0 = len(y) - n1
    return (r[y == 1].sum() - n1 * (n1 + 1) / 2) / (n1 * n0)

def knn_acc(Xtr, ytr, Xte, yte, k=15):
    d = (Xte**2).sum(1)[:, None] - 2 * Xte @ Xtr.T + (Xtr**2).sum(1)[None]
    nn = np.argsort(d, 1)[:, :k]
    return np.mean((ytr[nn].mean(1) > 0.5) == yte)

スケーリング:単位をそろえる

距離を使うモデルは単位に引きずられる

ローンの審査を例に、年収(円)・年齢(歳)・勤続年数(年)の 3 つの特徴から、審査に通るかを当てる架空のデータを作ります。答えには 3 つの特徴がどれも効いています。

rng = np.random.default_rng(0)
def make_loan(rng, n):
    income = rng.normal(500, 150, n) * 10000  # 円
    age = rng.normal(40, 10, n)               # 歳
    years = rng.normal(10, 5, n)              # 年
    logit = 0.01 * (income / 10000 - 500) - 0.15 * (age - 40) + 0.3 * (years - 10)
    y = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
    return np.column_stack([income, age, years]), y
X, y = make_loan(rng, 3000); Xte, yte = make_loan(rng, 3000)
print("標準化なし k-NN", round(knn_acc(X, y, Xte, yte), 3))
mu, sd = X.mean(0), X.std(0)                  # 学習用のデータだけで平均と標準偏差を計算する
print("標準化あり k-NN", round(knn_acc((X - mu) / sd, y, (Xte - mu) / sd, yte), 3))
print("ロジスティック回帰(標準化なし)", round(np.mean((predict(fit_logreg(X, y), Xte) > 0.5) == yte), 3))
print("年収だけの k-NN", round(knn_acc(X[:, :1], y, Xte[:, :1], yte), 3))

実行結果:

標準化なし k-NN 0.623
標準化あり k-NN 0.816
ロジスティック回帰(標準化なし) 0.821
年収だけの k-NN 0.623

標準化しない k 近傍法の正解率 0.623 は、「年収だけの k 近傍法」とまったく同じです。年収は円で数百万の幅でばらつくのに、年齢や勤続年数のばらつきは数十なので、距離はほぼ年収の差だけで決まり、ほかの 2 つの特徴は無視されています。標準化して 3 つの特徴の幅をそろえると、正解率は 0.816 に上がりました。

スケーリングが必要なモデルと要らないモデル

モデルスケーリング理由
k 近傍法・k-means・SVM(RBF カーネル)・PCA必要距離や分散を使うので、幅の大きい特徴に引きずられる
ニューラルネットワーク・勾配降下法で学習するモデル必要特徴の幅がばらばらだと、損失の谷が細長くなり、学習が進みにくい(最適化手法のしくみ)
正則化つきの線形モデル必要同じ罰則でも、単位によって重みの大きさが変わり、効き方が変わる
決定木・ランダムフォレスト・勾配ブースティングほぼ不要「しきい値より大きいか」しか見ないので、単位を変えても分け方は同じ

ロジスティック回帰は、上の例では標準化しなくても 0.821 でした。ここではニュートン法で解き、正則化もごく弱いので、単位の影響を受けにくくなっています。

よく使うスケーリング

方法計算向いているとき
標準化(x − 平均) / 標準偏差ふつうはこれ
最小最大スケーリング(x − 最小) / (最大 − 最小)0〜1 の範囲に収めたいとき(画像の画素など)。外れ値に弱い
ロバストスケーリング(x − 中央値) / 四分位範囲外れ値が多いとき

どれも、平均や最大値は 学習用のデータだけ で計算し、その値を検証用・評価用・本番のデータにもそのまま使います。上のコードで mu と sd を X だけから計算しているのはそのためです。

欠損値:抜けていること自体が情報になる

欠損の 3 つの種類

値が抜けている理由によって、扱い方が変わります。

種類意味例
完全にランダムな欠損抜けるかどうかが何とも関係ないセンサーの通信がたまたま途切れた
ほかの列で説明できる欠損抜けやすさが、観測できるほかの列で決まる高齢の人ほど、ネットの質問に答えていない
値そのものに関係する欠損抜けやすさが、抜けた値や答えそのものに関係する年収が低い人ほど年収を答えない、解約しそうな人ほどアンケートに答えない

3 つ目の場合、欠損しているという事実が、答えの手がかりになります。

欠損フラグを足す

3 つの特徴から答えを当てる架空のアンケートのデータで、陽性の人は 40%、陰性の人は 10% が、1 つ目の質問に答えていない(欠損している)とします。

# 2. 欠損値:答えたくない人ほど欠損しやすい
def make_survey(rng, n):
    x = rng.normal(size=(n, 3))
    logit = x @ np.array([1.0, -1.0, 0.5])
    y = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
    miss = rng.random(n) < np.where(y == 1, 0.4, 0.1)   # 陽性の人は 40%、陰性の人は 10% が答えない
    x[miss, 0] = np.nan
    return x, y
rng = np.random.default_rng(0)
X, y = make_survey(rng, 5000); Xte, yte = make_survey(rng, 5000)
print("欠損の割合", round(float(np.isnan(X[:, 0]).mean()), 3))

fill = np.nanmean(X[:, 0])                               # 学習用のデータの平均で埋める
def impute(X, indicator):
    Z = X.copy(); m = np.isnan(Z[:, 0]); Z[m, 0] = fill
    return np.column_stack([Z, m]) if indicator else Z
ok = ~np.isnan(X[:, 0])
b = fit_logreg(X[ok], y[ok])
print("欠損した行を捨てて学習(評価も欠損なしの行だけ)", round(auc(yte[~np.isnan(Xte[:, 0])], predict(b, Xte[~np.isnan(Xte[:, 0])])), 3))
for ind in (False, True):
    b = fit_logreg(impute(X, ind), y)
    print("平均で埋める" + ("+欠損フラグ" if ind else ""), round(auc(yte, predict(b, impute(Xte, ind))), 3))

実行結果:

欠損の割合 0.25
欠損した行を捨てて学習(評価も欠損なしの行だけ) 0.822
平均で埋める 0.792
平均で埋める+欠損フラグ 0.841
  • 平均で埋めるだけでは、AUC が 0.792 にとどまる。埋めた行は「平均的な値を答えた人」と区別がつかなくなり、欠損していたという手がかりが消えてしまいます
  • 「欠損していたか」を 0/1 の列(欠損フラグ)として足すと、0.841 に上がった。モデルが「答えなかった人は陽性が多い」ことを学習できるからです
  • 欠損した行を捨てる と、学習に使える行が 4 分の 3 に減るうえ、本番で欠損したデータが来たときに予測できません。上の 0.822 は、欠損していない行だけで評価した値なので、ほかの 2 つとはそのまま比べられません

勾配ブースティングの LightGBM や XGBoost は、欠損値をそのまま受け取り、欠損した行を左右どちらに送ると良いかを分割ごとに学習します(勾配ブースティングのしくみ)。そのため、欠損フラグを作らなくても、欠損の情報を使えます。

カテゴリ変数:種類が多いときのターゲットエンコーディング

one-hot では足りないとき

「都道府県」のように種類が数十までなら、カテゴリごとに 0/1 の列を作る one-hot で十分です。しかし、「店舗 ID」「商品 ID」「郵便番号」のように種類が数千あると、列が増えすぎるうえ、1 つの種類あたりのデータが少なくなります。

そこでよく使われるのが、カテゴリを そのカテゴリでの目的変数の平均(「この店舗での購入率」)に置き換える ターゲットエンコーディング です。1 列で済み、木のモデルでも線形のモデルでも使えます。ただし、計算のしかたを間違えると、目的変数の情報が漏れます。

そのまま平均すると何が起きるか

3000 店舗の架空のデータを作ります。1 店舗あたりの学習用のデータは平均 3〜4 件です。答えは、店舗に関係のない特徴 x と、店舗ごとの小さな効き目で決まります。

# 3. 種類の多いカテゴリ:3000 種類の店舗 ID。店舗ごとの効き目は小さい
n_shop = 3000
shop_effect = np.random.default_rng(42).normal(0, 0.5, n_shop)
def make_shop(rng, n):
    shop = rng.integers(n_shop, size=n)
    x = rng.normal(size=n)
    logit = 1.0 * x + shop_effect[shop]
    y = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
    return shop, x, y
rng = np.random.default_rng(0)
s, x, y = make_shop(rng, 10000); s_te, x_te, y_te = make_shop(rng, 10000)

def target_mean(s_fit, y_fit, s_apply, m=0.0):
    """店舗ごとの陽性の割合。m > 0 なら、件数の少ない店舗を全体の割合に近づける"""
    prior = y_fit.mean()
    cnt = np.bincount(s_fit, minlength=n_shop); pos = np.bincount(s_fit, weights=y_fit, minlength=n_shop)
    return ((pos + m * prior) / (cnt + m) if m > 0 else np.where(cnt > 0, pos / np.maximum(cnt, 1), prior))[s_apply]

def evaluate(name, te_train, te_test):
    b = fit_logreg(np.column_stack([x, te_train]), y)
    tr = auc(y, predict(b, np.column_stack([x, te_train])))
    te = auc(y_te, predict(b, np.column_stack([x_te, te_test])))
    print(f"{name}: 学習用の AUC {tr:.3f}, 評価用の AUC {te:.3f}, 店舗の列の重み {b[2]:.2f}")

b = fit_logreg(x[:, None], y)
print(f"店舗を使わない: 学習用の AUC {auc(y, predict(b, x[:, None])):.3f}, 評価用の AUC {auc(y_te, predict(b, x_te[:, None])):.3f}")
evaluate("そのまま平均", target_mean(s, y, s), target_mean(s, y, s_te))

# 交差して計算:5 つに分けて、自分を含まない 4 つで平均を出す
fold = rng.integers(5, size=len(y)); oof = np.empty(len(y))
for k in range(5):
    oof[fold == k] = target_mean(s[fold != k], y[fold != k], s[fold == k], m=10)
evaluate("分割して平均(平滑化 m=10)", oof, target_mean(s, y, s_te, m=10))
evaluate("本当の店舗の効き目", shop_effect[s], shop_effect[s_te])

実行結果:

店舗を使わない: 学習用の AUC 0.739, 評価用の AUC 0.729
そのまま平均: 学習用の AUC 0.871, 評価用の AUC 0.636, 店舗の列の重み 5.80
分割して平均(平滑化 m=10): 学習用の AUC 0.740, 評価用の AUC 0.732, 店舗の列の重み 1.76
本当の店舗の効き目: 学習用の AUC 0.765, 評価用の AUC 0.756, 店舗の列の重み 0.99

そのまま平均すると、学習用の AUC は 0.871 に跳ね上がるのに、評価用の AUC は 0.636 まで下がりました。店舗を使わないとき(0.729)より悪くなっています。

理由は、1 店舗あたりのデータが 3〜4 件しかないことです。ある行の「店舗の平均」には、その行自身の答え が 3 分の 1 くらいの重さで入っています。学習用のデータでは、この列が答えをほぼ言い当てるので、モデルはこの列に 5.80 という大きな重みを付けました。ところが評価用のデータでは、その行の答えは平均に入っていないので、この列はほとんど当たりません。

自分を含まないデータで計算する

これを防ぐには、各行の平均を その行を含まないデータで 計算します。上のコードでは、学習用のデータを 5 つに分け、各部分の行の平均を、残りの 4 つの部分から計算しています(交差検証と同じ分け方なので、out-of-fold と呼びます)。あわせて、件数の少ない店舗の平均を全体の割合に近づける 平滑化 も入れています。m = 10 は「全体の割合を 10 件分のデータとして足す」という意味です。

平滑化した平均 = (その店舗の陽性の数 + m × 全体の陽性の割合) / (その店舗の件数 + m)

こうすると、学習用と評価用の AUC がほぼそろい(0.740 と 0.732)、評価用の AUC も店舗を使わないときより少し良くなりました。店舗あたりのデータが少ないので、本当の店舗の効き目を知っているとき(0.756)との差は残っています。勾配ブースティングのしくみ で紹介した CatBoost は、データを並べた順番で「前の行だけ」から平均を計算することで、同じ問題を防いでいます。

特徴を作る:対数変換と組み合わせ

偏った分布には対数を取る

金額・人数・アクセス数のような値は、ほとんどが小さく、ごく一部がとても大きい 右に裾の長い分布 になりがちです。広告費と売上の架空のデータで、広告費を倍にすると売上が一定額だけ伸びる(売上が広告費の対数に比例する)関係を作ります。

# 4. 偏った分布の特徴:広告費(万円)と売上(万円)。広告費は少数の大口がある
rng = np.random.default_rng(0)
def make_ads(rng, n):
    ad = np.exp(rng.normal(3, 1.2, n))             # 対数正規分布:ほとんどは小さく、たまにとても大きい
    sales = 100 + 40 * np.log(ad) + rng.normal(0, 15, n)   # 広告費を倍にしても、売上の伸びは一定
    return ad, sales
ad, sales = make_ads(rng, 500); ad_te, sales_te = make_ads(rng, 500)
print("広告費の中央値", round(float(np.median(ad)), 1), "最大値", round(float(ad.max()), 1))

def r2_linear(f_tr, f_te):
    A = np.column_stack([np.ones(len(f_tr)), f_tr]); w = np.linalg.lstsq(A, sales, rcond=None)[0]
    pred = w[0] + w[1] * f_te
    return 1 - ((sales_te - pred) ** 2).sum() / ((sales_te - sales_te.mean()) ** 2).sum()
print("そのまま", round(float(r2_linear(ad, ad_te)), 3))
print("対数を取る", round(float(r2_linear(np.log(ad), np.log(ad_te))), 3))

実行結果:

広告費の中央値 18.9 最大値 795.7
そのまま 0.483
対数を取る 0.909

中央値 18.9 に対して最大値は 795.7 で、40 倍以上の開きがあります。そのまま直線を当てはめると、少数の大口の広告費に直線が引っ張られ、決定係数は 0.483 にとどまりました。対数を取ると 0.909 です。値が 0 になりうるときは、log(1 + x) を使います。

決定木を使うモデルは、大小の順番しか見ないので、対数を取っても分け方は変わりません。対数変換が効くのは、線形のモデル・ニューラルネットワーク・距離を使うモデルです。

よく作る特徴

種類例何を表せるか
比・差借入額 ÷ 年収、今月の売上 − 先月の売上2 つの列の関係。木は比を表すのが苦手なので、作って渡すと効きやすい
集計顧客ごとの過去 30 日の購入回数・平均金額1 行のデータには入っていない、その人の傾向
日時の分解曜日・時刻・月・祝日かどうか・給料日からの日数周期的な傾向。時刻は sin と cos の 2 列にすると、23 時と 0 時が近くなる
経過時間最後の購入からの日数、登録からの日数新しさ・なじみの度合い
区間に分ける年齢を 10 歳ごとに分ける線形のモデルで、曲がった関係を表す

画像・音声・文章では、こうした特徴をニューラルネットワークが自分で学習します。表の形のデータでは、業務の知識から作った特徴が今でも大きな差を生みます。

リーク:評価を狂わせる情報の漏れ

リークとは

リーク(leakage)は、本番の予測のときには手に入らない情報が、学習や評価に入り込むこと です。リークがあると、検証の成績は良いのに、本番では成績がまったく出ない、ということが起きます。上のターゲットエンコーディングの失敗もリークの一種です。

よくあるリーク

種類例防ぎ方
未来の情報解約の予測に「解約手続きの日付」や「最後のログインからの日数(データを取り出した日に計算)」を使う予測する時点で、その値が手に入るかを列ごとに確かめる
答えから作った列ターゲットエンコーディングを、その行を含むデータで計算する自分を含まないデータで計算する
前処理の統計平均・標準偏差・欠損を埋める値を、検証用のデータも含めて計算する前処理の値は学習用のデータだけで計算する
水増しオーバーサンプリングを、分ける前のデータで行う(不均衡データのしくみ)分けてから、学習用だけを水増しする
同じものが両方に入る同じ患者の画像・同じ顧客の取引が、学習用と検証用の両方に入る患者・顧客の単位で分ける(グループ分割)
時間の順番時系列のデータをランダムに分ける時間で区切って、過去で学習し未来で評価する(時系列予測のしくみ)

パイプラインにまとめる

前処理の統計を学習用のデータだけで計算するには、前処理とモデルを 1 つの手順(パイプライン)にまとめ、交差検証の分割ごとに、前処理も学習し直す のが確実です。scikit-learn の Pipeline は、そのための道具です。

リークは、成績が「良すぎる」ときに疑うのがいちばんの手がかりです。1 つの列だけで成績が極端に上がるときは、その列が予測の時点で本当に手に入るかを確かめます。機械学習の判断を説明するしくみ の重要度で、効きすぎている列を探すのも有効です。

前処理と特徴量エンジニアリングの流れ

年手法・出来事ポイント
1964ボックス・コックス変換偏った分布を正規分布に近づける、対数変換を一般化した変換
1976欠損の種類の整理(Rubin)完全にランダム・ほかの列で説明できる・値そのものに関係する、の 3 種類
1987多重代入法(Rubin)欠損をいくつもの候補の値で埋め、ばらつきまで考える
2001ターゲットエンコーディングの平滑化(Micci-Barreca)種類の多いカテゴリを、平滑化した目的変数の平均で置き換える
2012リークの整理(Kaufman ら)データ分析で起きるリークの種類と防ぎ方をまとめた
2018CatBoost の順序つきのターゲット統計前の行だけで平均を計算し、リークなく種類の多いカテゴリを扱う

まとめ

  • 距離や勾配降下法を使うモデルでは、特徴の単位をそろえる。決定木を使うモデルでは、ほぼ要らない
  • 欠損に意味があるときは、埋めるだけでなく「欠損していたか」の列を足す。計算例では AUC が 0.792 から 0.841 に上がった
  • 種類の多いカテゴリをターゲットエンコーディングするときは、その行を含まないデータで計算し、件数の少ないカテゴリは平滑化する。そのまま平均すると、学習用の成績だけが上がり、評価用の成績は使わないときより悪くなった
  • 偏った分布の値は対数を取ると、線形のモデルで扱いやすくなる
  • 前処理の統計は学習用のデータだけで計算する。成績が良すぎるときは、予測の時点で手に入らない情報が混ざっていないかを疑う

参考

  • G. E. P. Box and D. R. Cox, "An Analysis of Transformations", Journal of the Royal Statistical Society Series B 1964
  • D. B. Rubin, "Inference and Missing Data", Biometrika 1976
  • D. B. Rubin, "Multiple Imputation for Nonresponse in Surveys", Wiley 1987
  • D. Micci-Barreca, "A Preprocessing Scheme for High-Cardinality Categorical Attributes in Classification and Prediction Problems", ACM SIGKDD Explorations 2001
  • S. Kaufman et al., "Leakage in Data Mining: Formulation, Detection, and Avoidance", ACM Transactions on Knowledge Discovery from Data 2012
  • L. Prokhorenkova et al., "CatBoost: unbiased boosting with categorical features", NeurIPS 2018(arXiv:1706.09516)
  • scikit-learn User Guide: Common pitfalls and recommended practices