入門 · レッスン 2 / 10

データがモデルである: データセット、分割、リーク

pandasで小さな合成データセットを作り、学習・検証・テストに分割し、リークする特徴量を仕込んで検証スコアが嘘をつく様子を観察します

入門35 分更新 2026年11月13日無料期間 2027年1月31日

このレッスンで学ぶこと

  • 生成ルールが既知の表形式データセットを作り、それが教材として優れている理由を説明できる
  • データを学習・検証・テストセットに分割し、それぞれの用途を述べられる
  • ターゲットリークを説明し、モデルを改善することなく検証スコアを水増しする様子をコードで示せる
  • リークのよくある4つの発生源と、それぞれを捕まえるチェックを挙げられる

前提レッスン: 機械学習とは何か

レッスン01は、持つ価値のある唯一のスコアはモデルが一度も見ていない事例でのものだ、という主張で終わりました。本レッスンは、そのスコアをどう得るか、そしてそれがどう狂うかについてです。pandasで小さな顧客データセットを作り、3つに切り分け、1つでモデルを学習し、残りで採点します。次に、答えをこっそり符号化した列を1つ加え、取り分けたスコアが0.78から0.97に跳ね上がるのを見て、そのスコアがモデルが現実に出会った瞬間に崩れ落ちるのを確かめます。入門トラックから習慣を1つだけ持ち帰るなら、本レッスンが教える疑いの目にしてください。

アルゴリズムよりデータが重要な理由

モデルは事例の中にあるものしか学べません。事例が間違っていたり、偏っていたり、古かったり、汚染されていたりすれば、どれほど洗練されたアルゴリズムもその間違いを忠実に学びます。事例がきれいで豊富なら、1805年の線形モデルで十分なことも多いのです。経験を積んだ実務者は時間の大半をデータに使い、モデル選びにはごく一部しか使いません。初心者はその逆をやります。本レッスンの題は、時間をどこに使うべきかを思い出させるためのものです。

データは上限も決めます。下のデータセットではラベルに意図的な乱数が入っているので、完璧なモデルでも100%は取れません。その上限が存在すること、そして実データでは誰もその位置を教えてくれないことを知るのが、スコアをまともに読む第一歩です。

自分で作ったから信頼できるデータセット

実データは生成ルールを隠しています。合成データならルールを書き下せるので、モデルが発見すべきものが正確に分かり、別のものを発見したときに気づけます。ここで使うのは小さな顧客離脱(チャーン)の表で、各顧客についての4つの特徴量と、離脱したかどうかのラベルからなります。

import numpy as np
import pandas as pd

rng = np.random.default_rng(0)
n = 1000

age = rng.integers(18, 70, size=n)
tenure_months = rng.integers(1, 120, size=n)
monthly_spend = np.round(rng.gamma(shape=2.0, scale=25.0, size=n), 2)
support_calls = rng.poisson(lam=1.2, size=n)

# The hidden rule that generates the label. The model has to rediscover it.
logit = (0.5
         - 0.03 * tenure_months
         + 1.0 * support_calls
         - 0.02 * monthly_spend
         + 0.01 * (age - 40))
p_churn = 1.0 / (1.0 + np.exp(-logit))
churned = (rng.random(n) < p_churn).astype(int)

df = pd.DataFrame({
    "age": age,
    "tenure_months": tenure_months,
    "monthly_spend": monthly_spend,
    "support_calls": support_calls,
    "churned": churned,
})
print(df.head())
print("rows:", len(df), " churn rate:", round(df["churned"].mean(), 3))
   age  tenure_months  monthly_spend  support_calls  churned
0   62             66          44.43              0        1
1   51             10         160.95              2        0
2   44             23          10.79              3        1
3   32            105          38.69              1        0
4   34             53          14.29              0        0
rows: 1000  churn rate: 0.346

ルールを読んでください。サポートへの問い合わせ1回ごとにスコアに1.0が加わり、契約月数1か月ごとに0.03が引かれ、月額支出1ポンドごとに0.02が引かれ、年齢がわずかに動かします。このスコアは押しつぶし関数を通って0と1の間の確率になり、その確率で重み付けしたコインがラベルを決めます。問い合わせ3回、契約23か月、支出約11の顧客なら、スコアは0.5−0.69+3.0−0.22+0.04で約2.6、離脱確率はおよそ0.93です。上の行2がその顧客で、コインは離脱と出ました。行0は、問い合わせなし、契約66か月で確率は0.25近くでしたが、それでも離脱しています。これが乱数であり、どんなモデルにも予測できません。

押しつぶし関数と重み付きスコアは、レッスン03のロジスティック回帰モデルそのものです。このデータセットは、レッスン03のモデルが正解になるように作られています。

3つの分割とそれぞれの用途

何かを当てはめる前に、行を3つのグループに分けます。

ラベル付きの全1,000行学習用、600行検証用200行テスト用200行パラメータの当てはめ何度も参照されるモデル、特徴量、設定を選ぶ最終スコア、1回だけ見る1つの行を複数のグループに入れてはならない
1つのデータセットを3つに切り分ける。学習行はパラメータを当てはめる。検証行はモデルについて下すすべての判断を導く。テスト行は最後に1回だけ採点し、何かを選ぶためには決して使わない。

学習セットは、モデルが当てはめる対象です。パラメータはこれらの行に対する損失が低くなるまで調整されるので、これらの行でのスコアは甘く、ほとんど何も教えてくれません。

検証セットは、判断を下すために使うものです。モデルは線形にすべきか木にすべきか。特徴量は4つか5つか。学習は10ラウンドか100ラウンドか。どの選択も検証スコアを比べて行われ、長いプロジェクトではこのセットを何百回も見ることになります。それが問題です。検証セットに対して下す判断の1つひとつが、そこから少しずつ情報をモデルに漏らし、十分な回数の判断を重ねると検証スコアも甘くなります。

テストセットはそれを解決するために存在します。一番最後に1回だけ採点し、その数値を報告します。見て、変更し、また見たなら、それは第2の検証セットになってしまい、もはや正直な数値はありません。

import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score

rng = np.random.default_rng(0)
n = 1000
age = rng.integers(18, 70, size=n)
tenure_months = rng.integers(1, 120, size=n)
monthly_spend = np.round(rng.gamma(shape=2.0, scale=25.0, size=n), 2)
support_calls = rng.poisson(lam=1.2, size=n)
logit = (0.5 - 0.03 * tenure_months + 1.0 * support_calls
         - 0.02 * monthly_spend + 0.01 * (age - 40))
churned = (rng.random(n) < 1.0 / (1.0 + np.exp(-logit))).astype(int)
df = pd.DataFrame({"age": age, "tenure_months": tenure_months,
                   "monthly_spend": monthly_spend,
                   "support_calls": support_calls, "churned": churned})

features = ["age", "tenure_months", "monthly_spend", "support_calls"]
X = df[features]
y = df["churned"]

# First cut: 80 % for fitting, 20 % held out as the test set.
X_rest, X_test, y_rest, y_test = train_test_split(
    X, y, test_size=0.2, random_state=0, stratify=y)
# Second cut: of the 80 %, keep a quarter (20 % overall) for validation.
X_train, X_val, y_train, y_val = train_test_split(
    X_rest, y_rest, test_size=0.25, random_state=0, stratify=y_rest)

print("train:", len(X_train), " val:", len(X_val), " test:", len(X_test))
print("churn rate per split:",
      round(y_train.mean(), 3), round(y_val.mean(), 3), round(y_test.mean(), 3))

model = LogisticRegression(max_iter=2000)
model.fit(X_train, y_train)
print("train accuracy:", round(accuracy_score(y_train, model.predict(X_train)), 3))
print("val accuracy:  ", round(accuracy_score(y_val, model.predict(X_val)), 3))
print("majority-class baseline on val:", round(max(y_val.mean(), 1 - y_val.mean()), 3))
train: 600  val: 200  test: 200
churn rate per split: 0.347 0.345 0.345
train accuracy: 0.755
val accuracy:   0.775
majority-class baseline on val: 0.655

このコードには、真似する価値のある習慣が3つあります。random_state=0は分割を再現可能にするので、同じコードを走らせた同僚は各グループに同じ行を得ます。stratify=yはすべての分割で離脱率を同じに保ちます。これは一方のクラスが稀なときに効いてきます。離脱率5%のデータセットから無作為に200行を切り出すと、4%や7%になることは容易にあり、スコアもそれに合わせて揺れます。そして最後のベースラインの行は、全員に「離脱しない」と予測したときに得られるスコアです。ベースラインに勝てないモデルは、精度がどれほど立派に聞こえても何も学んでいません。ここでは0.655に対して0.775で、本物の改善です。学習と検証の差も小さいので、モデルは暗記していません。

検証スコアが学習スコアを少し上回っているのは奇妙に見えますが、どちらも数百行からの推定値だと思い出せば納得できます。1、2ポイントの差はノイズです。レッスン06でこれらの数値に誤差範囲を付け、行をより注意深く再利用する交差検証を導入します。

ターゲットリーク: 答えを知っている特徴量

いよいよ実演です。請求システムにfinal_bill_issuedという列があるとします。顧客が解約すると、システムは最終請求書を発行し、このフラグを立てます。請求ミスで、残留している顧客の一部にもフラグが立っています。見た目は普通の特徴量で、表の中で他の列の隣に並んでおり、モデルはこれを大いに気に入るでしょう。

import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score

rng = np.random.default_rng(0)
n = 1000
age = rng.integers(18, 70, size=n)
tenure_months = rng.integers(1, 120, size=n)
monthly_spend = np.round(rng.gamma(shape=2.0, scale=25.0, size=n), 2)
support_calls = rng.poisson(lam=1.2, size=n)
logit = (0.5 - 0.03 * tenure_months + 1.0 * support_calls
         - 0.02 * monthly_spend + 0.01 * (age - 40))
churned = (rng.random(n) < 1.0 / (1.0 + np.exp(-logit))).astype(int)
df = pd.DataFrame({"age": age, "tenure_months": tenure_months,
                   "monthly_spend": monthly_spend,
                   "support_calls": support_calls, "churned": churned})

# The leak: a column filled in by the billing system AFTER a customer leaves.
# Churned customers get a final-bill flag 95 % of the time, others 3 %.
final_bill_issued = np.where(
    df["churned"] == 1,
    (rng.random(n) < 0.95).astype(int),
    (rng.random(n) < 0.03).astype(int),
)
df["final_bill_issued"] = final_bill_issued

clean = ["age", "tenure_months", "monthly_spend", "support_calls"]
leaky = clean + ["final_bill_issued"]
y = df["churned"]

def fit_and_score(cols):
    X_rest, X_test, y_rest, y_test = train_test_split(
        df[cols], y, test_size=0.2, random_state=0, stratify=y)
    X_train, X_val, y_train, y_val = train_test_split(
        X_rest, y_rest, test_size=0.25, random_state=0, stratify=y_rest)
    m = LogisticRegression(max_iter=2000).fit(X_train, y_train)
    return (accuracy_score(y_val, m.predict(X_val)),
            accuracy_score(y_test, m.predict(X_test)))

for name, cols in [("clean", clean), ("leaky", leaky)]:
    val_acc, test_acc = fit_and_score(cols)
    print(f"{name:5s}  val {val_acc:.3f}  test {test_acc:.3f}")

# What the leaky model would score at the moment you actually need it:
# before the customer has left, when final_bill_issued is always 0.
X_rest, X_test, y_rest, y_test = train_test_split(
    df[leaky], y, test_size=0.2, random_state=0, stratify=y)
X_train, X_val, y_train, y_val = train_test_split(
    X_rest, y_rest, test_size=0.25, random_state=0, stratify=y_rest)
m = LogisticRegression(max_iter=2000).fit(X_train, y_train)
X_deploy = X_test.copy()
X_deploy["final_bill_issued"] = 0
print("leaky model in deployment:", round(accuracy_score(y_test, m.predict(X_deploy)), 3))
print("majority baseline:        ", round(max(y_test.mean(), 1 - y_test.mean()), 3))
clean  val 0.775  test 0.765
leaky  val 0.965  test 0.965
leaky model in deployment: 0.665
majority baseline:         0.655

2行目を見てください。リークした列を入れると、検証精度は0.965で、テスト精度も同じです。正直なチェックはすべて通っています。分割は無作為で、層化されていて、再現可能で、テストセットには1回しか触れておらず、数値は見事です。この報告を読んだ人は、モデルを出荷するでしょう。

最後の2行が、その後に起こることです。離脱モデルの目的は、誰が離脱するかを離脱前に予測し、誰かが介入できるようにすることです。その時点では誰にも最終請求書は発行されていないので、モデルが問われるすべての顧客についてこの列はゼロです。その現実で採点すると、リークしたモデルは0.665で、全員に「残留」と答えるベースラインを1ポイント上回るだけです。0.765を出したクリーンなモデルより悪く、報告された0.965は嘘でした。

分割がこれを捕まえられなかったのは、捕まえられないからです。分割が守ってくれるのは、行を暗記するモデルからです。ラベルを分割の両側に運び込む列に対しては何もしません。フラグはラベルが分かった後に立てられたので、特徴量の格好をしたラベルの帰結です。これがターゲットリークであり、唯一の防御は、すべての列についてこう問うことです。予測を行う時点で、この値は存在するか、そして答えを知らずに計算されたものか。

答えが入り込む他の経路

最終請求書フラグは最も分かりやすい種類のリークですが、もっと静かなものがあり、その大半は無作為分割を通り抜けます。

種類 起こり方 捕まえる方法
ターゲットリーク 特徴量がラベルから導出されるか、ラベルの後に書き込まれる 各列がいつ埋まるかを問う
前処理リーク スケーラ、欠損値補完器、エンコーダが分割前に全行で当てはめられ、テストの統計量が学習データを形作る すべての変換を学習行だけで当てはめ、それを強制するパイプラインを使う
時間的リーク 行がシャッフルされ、モデルが来月で学習して先月でテストされる 時間で分割し、学習を検証・テストより厳密に前にする
重複リーク 同じレコード、またはそのほぼ複製が学習とテストの両方に入る 分割前に重複を除去し、2つのセットを完全一致で比較する
グループリーク 1つの実体(患者、ユーザー、デバイス)に属する複数の行が分割をまたいで散らばり、モデルがパターンではなく実体を認識する グループで分割し、1つの実体の行をすべて同じ側にまとめる

前処理リークにはもう一文を割く価値があります。初心者が最初の週にやってしまうからです。ある列の平均と標準偏差を1,000行すべてで計算して標準化すると、学習行はテスト行を含む数値によって形作られています。影響はたいてい小さいものの、リークはリークであり、scikit-learnのPipelineが存在する理由の一つは、当てはめが正しい行で自動的に行われるようにするためです。レッスン10で使います。

時間的リークは予測問題で噛みついてくるものです。来週の需要を予測するのに、3年分の週次の行をシャッフルして無作為に分割すると、モデルはすべてのテスト週の両隣の週で学習し、その間を補間します。検証スコアは素晴らしく、本番スコアはそうなりません。データと特徴量のハブに、時間を考慮した分割についての詳しい説明があります。

疑わしいスコアの見た目

祝う前に立ち止まってデータを確認すべきパターンがいくつかあります。

スコアがベースラインよりはるかに良く、問題にふさわしいと思える水準よりはるかに良い。4つの弱い手がかりから0.965で離脱を予測するのはあり得ません。0.775ならあり得ます。

1つの特徴量が支配的である。木を当てはめ(レッスン05)、最初の分岐でほぼすべてが説明できてしまうなら、その列をよく調べてください。

学習、検証、テストが一致しすぎている。正直なモデルはふつう、学習スコアと他との間に小さな差を示します。どこでもほぼ完璧なモデルは、問題が簡単か、リークがあるかのどちらかです。

スコアは高いが、モデルにもっともらしい仕組みがない。「予約された経過観察の件数」から99%の精度で再入院を予測するモデルは、未来を予測しているのではなく退院書類を読んでいるのです。

いずれも証拠ではありません。すべては、その列を見つけ、出所をたどり、疑わしければ落としてスコアがどれだけ下がるかを見る理由です。その下がり幅がリークの大きさです。

演習

  1. リークを変更して、final_bill_issuedが離脱顧客の95%ではなく60%にだけ立つようにしてください。リークのブロックを再実行します。検証スコアはどれだけ下がりますか。読み手を欺くにはまだ十分ですか。
  2. 最初の500行で0、残りで1となるcustomer_id列を追加し、後半をより高い基本離脱率で生成してください。上と同じようにtrain_test_splitで分割し、次に最初の800行を学習、最後の200行をテストとする分割も行ってください。2つのテストスコアを比較し、前節の表を使って違いを説明してください。
  3. StandardScalerを1,000行すべてで当てはめて変換し、それから分割して学習してください。次に、スケーラを学習行だけで当てはめてください。両方の検証スコアを報告します。ここでは差はごくわずかでしょう。それでも正しく行う価値がある理由を一文で書いてください。

まとめ

  • モデルは事例の中にあるものしか学ばないので、データに使う時間はアルゴリズムに使う時間より報われます。
  • 学習行はパラメータを当てはめ、検証行はすべての判断を導き、テスト行は最後に1回だけ採点します。行は決して複数のグループに現れません。
  • 必ずベースラインを出力してください。多数派クラスを予測するだけのスコアに勝てないなら、何の意味もありません。
  • ターゲットリークとは、答えを運ぶ特徴量です。無作為分割では捕まえられないので、すべての列について、いつ、何によって書き込まれるかを問うてください。
  • リークは、全行で当てはめた前処理、シャッフルされた時系列、重複、セットをまたいで分かれたグループからも入り込みます。それぞれに特有のチェックがあります。

さらに読む