時点・個体・群の分離|機械学習の問題設定と評価分割

このページはプロモーション(広告)を含みます。

機械学習の問題設定と評価分割(中級・読む30分+演習40分)

この授業のゴール

仕事でどう使うか

訓練・検証・テスト では「新しい人には人単位で分ける」ことを学びました。この授業では、分ける単位を時点・個体・群の3つに広げ、実際の道具で分割を作ります。評価が本番より甘くなるかどうかは、モデルの種類よりも分け方で決まることが多いからです。

路線 この授業の内容が使われる成果物・意思決定
DS 需要予測や解約予測の評価設計書。「2025年7月以降を評価」「利用者単位」などの分け方と、その理由を書く
ML 学習パイプラインの分割処理。分割IDを保存し、交差検証にも同じ単位(groups・時間順)を使う
DX 新店舗・新拠点への展開判断。既存店で作ったモデルが、まだデータのない店でも使えるかを群単位の評価で確かめる
RS 被験者・病院・地域をまたぐ研究。同じ被験者の繰り返し測定を両側に入れない評価を論文に明記する

前提

次の言葉を使います。

この授業の例はすべて乱数の種を固定した架空のデータです。値を変えて実行し、結論が変わるかを確かめてください。

身近な状況:3つの予測、3つの「本番」

ある会社の会議で、3つの予測モデルの評価報告がありました。どれも「データを無作為に7:3に分けて評価した」と書かれています。

依頼 本番で使う相手 無作為分割の成績
A:来月入会する人の解約予測 まだ一度も見たことのない人 正解率93%
B:来月の日別販売数の予測 まだ来ていない未来の日 誤差(MAE)4.7個
C:来年出店する店の売上予測 まだデータのない店 誤差(MAE)8.1万円

どれも、本番で出会うのは「訓練で見ていないもの」です。ところが無作為に分けると、同じ人の別の月、テスト日の前日と翌日、同じ店の別の日が訓練に入ります。モデルは「似たものを覚えて写す」だけで、テストを当てられてしまいます。

この授業の例題で実際に分け直すと、Aは正解率67%(何もしない基準の80%より低い)、Bは誤差23.7個、Cは誤差14.5万円になります。報告の数字は、本番の難しさを測っていなかったのです。

直感的な説明:本番で初めて出会うものを、評価側に置く

分け方を決める質問は1つです。「本番で、モデルが初めて出会うものは何か」。それを評価側(検証・テスト)にだけ置き、訓練には入れません。

本番で初めて出会うもの 分ける単位 具体的な分け方
未来の期間 時点 ある日付より前を訓練、後を評価
新しい人・新しい商品 個体 人(商品)ごとに、どちらか一方にだけ入れる
新しい店・地域・病院・世帯 群 店(地域)ごとに、どちらか一方にだけ入れる

群は「個体の集まり」です。同じ店の客、同じ世帯の家族、同じ病院の患者は、互いに似ています。新しい店に使うモデルなら、客単位ではなく店単位で分けないと、「その店らしさ」を覚えたモデルが有利になります。

複数が当てはまることもあります。「来月、新しく入会する人」なら、時点と個体の両方で分けます。

正確な定義:3つの分け方と gap

時点分割では、gap(訓練と評価の間にあける間隔)も決めます。記号を定義します。

訓練のラベルが評価期間の出来事を使わないための条件は

$$t_0 + h \le c$$

です。数値で確かめます。$c=7$(7月から評価)、$h=1$ なら $t_0 \le 6$ で、6月のスナップショットまで訓練に使えます。$h=3$(3か月以内の解約)なら $t_0 \le 4$ です。5月のスナップショットのラベル窓は5〜7月で、評価期間の7月に入り込むからです。

scikit-learn の道具は次のとおりです。どれも split(X, y, groups) が行番号の配列の組(訓練の行番号、評価の行番号)を返します。

道具 主な引数 用途
GroupShuffleSplit n_splits(分割の回数), test_size(評価に回す群の割合), random_state 群(個体)単位で1回分ける
GroupKFold n_splits 群単位の交差検証。各群はちょうど1回だけ評価側に入る
TimeSeriesSplit n_splits, test_size(評価の行数), gap(あける行数) 行が時間順に並んでいる前提で、過去→未来の交差検証

GroupShuffleSplit の test_size=0.3 は行ではなく群の30%です。群ごとの行数が違うと、評価の行数はちょうど30%になりません。

段階付き例題:個体と時点で分け直す

例題1:個体(依頼A)。100人×6か月の600行です。各人の6行は特徴量がほぼ同じで、目的変数 y も同じです。モデルは1-NN(1-Nearest Neighbor:一番似た訓練行の答えをそのまま写す方法)で、「覚えて写す」力の強いモデルの代表です。train_test_split は行を無作為に分け、GroupShuffleSplit は groups=g(user_id)の単位で分けます。

import numpy as np, pandas as pd
from sklearn.model_selection import train_test_split, GroupShuffleSplit
from sklearn.neighbors import KNeighborsClassifier

rng = np.random.default_rng(0)
n = 100                                            # 100人 × 6か月 = 600行
usage = rng.uniform(1, 30, n)                      # 人ごとの普段の利用時間
calls = rng.uniform(0, 5, n)                       # 人ごとの普段の問い合わせ回数
y_user = (rng.random(n) < np.where(usage < 10, 0.5, 0.1)).astype(int)
df = pd.DataFrame({
    'user_id': np.repeat([f'u{i:03d}' for i in range(n)], 6),
    'usage_hours': np.repeat(usage, 6) + rng.normal(0, 0.3, n * 6),
    'support_calls': np.repeat(calls, 6) + rng.normal(0, 0.1, n * 6),
    'y': np.repeat(y_user, 6),
})
X, y, g = df[['usage_hours', 'support_calls']], df['y'], df['user_id']

def acc(tr, te):                                   # 1-NN:一番似た訓練行の答えを写す
    m = KNeighborsClassifier(n_neighbors=1).fit(X.iloc[tr], y.iloc[tr])
    return (m.predict(X.iloc[te]) == y.iloc[te].values).mean().round(3)

tr, te = train_test_split(np.arange(len(df)), test_size=0.3, random_state=0)
print('行で無作為:', acc(tr, te), ' 両側にいる人数:', len(set(g.iloc[tr]) & set(g.iloc[te])))
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.3, random_state=0).split(X, y, groups=g))
print('人ごと    :', acc(tr, te), ' 両側にいる人数:', len(set(g.iloc[tr]) & set(g.iloc[te])))
print('基準(全員を継続と予測):', (1 - y.iloc[te].mean()).round(3))

出力:

行で無作為: 0.933  両側にいる人数: 88
人ごと    : 0.667  両側にいる人数: 0
基準(全員を継続と予測): 0.8

意味:行で分けると100人中88人が訓練とテストの両方にいて、正解率は93.3%です。人ごとに分けると66.7%で、全員を継続と予測するだけの基準(80.0%)より低くなります。このモデルは新しい人には役に立たない、というのが正直な評価です。テストは30人(180行)しかないので、random_state を変えると人ごとの正解率は55〜80%程度動きます。小さな評価では、1回の値で決めつけないことも大切です。

例題2:時点(依頼B)。365日の販売数で、売れ行きの水準 level がゆっくり上下します。特徴量は通し日数 day と週末かどうか weekend です。

import numpy as np, pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeRegressor

rng = np.random.default_rng(0)
n = 365
level = 100 + np.cumsum(rng.normal(0, 2, n))          # ゆっくり動く「売れ行きの水準」
weekend = (np.arange(n) % 7 >= 5).astype(int)
d = pd.DataFrame({'day': np.arange(n), 'weekend': weekend,
                  'sales': level + 15 * weekend + rng.normal(0, 5, n)})
cols = ['day', 'weekend']

def mae(tr, te):
    m = DecisionTreeRegressor(min_samples_leaf=5, random_state=0)
    m.fit(d.loc[tr, cols], d.loc[tr, 'sales'])
    return np.abs(m.predict(d.loc[te, cols]) - d.loc[te, 'sales']).mean().round(1)

tr, te = train_test_split(d.index, test_size=0.25, random_state=0)
print('日を無作為に分割      MAE:', mae(tr, te))
tr, te = d.index[d['day'] < 274], d.index[d['day'] >= 274]       # 最後の91日をテスト
print('最後の91日をテスト    MAE:', mae(tr, te))
print('基準:直近28日の平均  MAE:', np.abs(d.loc[tr[-28:], 'sales'].mean() - d.loc[te, 'sales']).mean().round(1))

出力:

日を無作為に分割      MAE: 4.7
最後の91日をテスト    MAE: 23.7
基準:直近28日の平均  MAE: 21.7

意味:無作為に分けると、テスト日の前後の日が訓練にあり、木はその日あたりの水準を「間を埋めて」当てます(MAE 4.7個)。最後の91日をテストにすると、木は訓練期間の外側の水準を知らず、MAE は23.7個になります。直近28日の平均という単純な基準(21.7個)にも負けます。報告すべきは時点で分けた23.7個と、基準との比較です。

操作して理解する:分割の中身を表示する

道具がどの行をどちらに入れたかを、小さなデータで表示します。GroupKFold は6人(各2行)を3つに分け、TimeSeriesSplit は12か月を過去→未来の順に分けます。gap=1 は訓練とテストの間を1行(1か月)あけます。

import numpy as np
from sklearn.model_selection import GroupKFold, TimeSeriesSplit

users = np.array(['u1', 'u1', 'u2', 'u2', 'u3', 'u3', 'u4', 'u4', 'u5', 'u5', 'u6', 'u6'])
X = np.zeros((12, 1))                       # 列の中身は分割に使われないので0でよい
for k, (tr, te) in enumerate(GroupKFold(n_splits=3).split(X, groups=users)):
    test_users = sorted(set(users[te].tolist()))
    print(f'GroupKFold  fold{k} テストの人: {test_users}  重なり: {set(users[tr]) & set(users[te])}')

months = np.arange(1, 13)                   # 1〜12月を1行ずつ
for k, (tr, te) in enumerate(TimeSeriesSplit(n_splits=3, test_size=2, gap=1).split(months.reshape(-1, 1))):
    print(f'TimeSeries  fold{k} 訓練: {months[tr].min()}〜{months[tr].max()}月  テスト: {months[te].tolist()}月')

出力:

GroupKFold  fold0 テストの人: ['u3', 'u6']  重なり: set()
GroupKFold  fold1 テストの人: ['u2', 'u5']  重なり: set()
GroupKFold  fold2 テストの人: ['u1', 'u4']  重なり: set()
TimeSeries  fold0 訓練: 1〜5月  テスト: [7, 8]月
TimeSeries  fold1 訓練: 1〜7月  テスト: [9, 10]月
TimeSeries  fold2 訓練: 1〜9月  テスト: [11, 12]月

6人がちょうど1回ずつテストに入り、どのfoldでも重なりは空です。時系列のほうは、訓練の最後とテストの最初の間に1か月(6月、8月、10月)の空きがあり、訓練は回ごとに長くなります(拡張窓)。

次のように変えて、予想してから実行してください。

  1. gap=1 を gap=0 にすると? → 空きがなくなり、fold0 は訓練1〜6月・テスト7〜8月になります。予測期間 $h$ が2か月以上なら、gap 0 ではラベル窓がテスト期間に入り込みます。
  2. GroupKFold(n_splits=3) を n_splits=6 にすると? → 1人ずつテストに入る「1人抜き」の交差検証になります。
  3. 時系列で test_size=2 を 3 に、n_splits=3 のままにすると? → テストが3か月ずつになり、最初の訓練期間が短くなります。

発展:分け方の組み合わせと限界

よくある失敗

失敗1:1人複数行の表を、行で無作為に分ける

例題1のように train_test_split(df) で600行を分けると、88人が両側に入り、正解率93.3%という楽観的な値が出ます。人ごとに分けると66.7%で、基準80.0%より低い結果でした。

# 誤り:行で分ける
train_test_split(df, test_size=0.3, random_state=0)
# 正しい:人(groups)で分ける
GroupShuffleSplit(n_splits=1, test_size=0.3, random_state=0).split(X, y, groups=df['user_id'])

直し方:1人(1店)が複数行を持つ表では、まず「識別子あたりの行数」を df.groupby('user_id').size().describe() で確認し、2行以上あれば群単位の道具を使います。交差検証でも cross_val_score(model, X, y, groups=g, cv=GroupKFold(5)) のように同じ単位を渡します。

失敗2:時点で分けたが、gap をあけない

「3か月以内に解約するか」($h=3$)を予測し、7月以降を評価にしました。訓練に6月のスナップショットまで入れると、6月のラベル窓は6〜8月で、評価期間の7・8月の解約を答えとして使っています。

訓練のスナップショット $t_0$ ラベル窓 評価期間(7月〜)との重なり
4月 4〜6月 なし($4+3 \le 7$)
5月 5〜7月 7月が重なる
6月 6〜8月 7・8月が重なる

評価期間の「解約が多かった月」の情報が訓練に入り、評価が甘くなります。直し方:条件 $t_0 + h \le c$ を満たす行だけを訓練に使う(この例では4月まで)。TimeSeriesSplit なら、1行が1か月の表で gap=h-1 のように間をあけます。

失敗3:個体で分けたが、群が重なっている

世帯単位で同じ商品を買う傾向が強いデータで、人(person_id)で分けました。人は重なっていませんが、同じ世帯の家族が両側にいます。

import pandas as pd
people = pd.DataFrame({'person_id': ['p1', 'p2', 'p3', 'p4', 'p5', 'p6'],
                       'household': ['h1', 'h1', 'h2', 'h3', 'h3', 'h4'],
                       'set': ['train', 'test', 'train', 'train', 'test', 'test']})
by = lambda col: set(people.loc[people['set'] == 'train', col]) & set(people.loc[people['set'] == 'test', col])
print('人の重なり  :', by('person_id'))
print('世帯の重なり:', sorted(by('household')))

出力:

人の重なり  : set()
世帯の重なり: ['h1', 'h3']

h1・h3の家族の購入傾向は、訓練で覚えた家族とほぼ同じなので、テストが当たりやすくなります。直し方:本番で「新しい世帯」に使うなら、groups=household で分けます。人と世帯のように入れ子の単位があるときは、大きいほうで分ければ両方の重なりがなくなります。

まとめ

用語

群分割(group split)

店・世帯・地域など、個体の集まりを単位として、各群を訓練か評価のどちらか一方にだけ入れる分け方。

時点分割(temporal split)

区切りの時点より前を訓練、後を評価にする分け方。未来の予測を評価するときに使う。

gap(gap / embargo)

訓練の最後と評価の最初の間にあける間隔。訓練のラベル窓が評価期間に入らないようにする。

1-NN(1-nearest neighbor)

特徴量が一番近い訓練データの答えをそのまま予測とする方法。似たものを覚えて写す性質が強い。

拡張窓(expanding window)

時系列の交差検証で、回を進めるごとに訓練期間を過去から伸ばしていく方式。

よくある質問

なぜ無作為分割ではいけないのですか? 教科書ではよく使われています。

1行が独立した1つの対象(1人1行、時間の影響がない)なら、無作為分割で問題ありません。教科書のデータはそうなっていることが多いのです。実務の表は、1人複数行、日付の並び、店や地域のまとまりを持つことがほとんどで、そのときは無作為分割が評価を甘くします。

時点で分けるのと個体で分けるのは、何が違うのですか?

守るものが違います。時点分割は「未来の情報を使わない」こと、個体分割は「同じ人を覚えて当てない」ことを守ります。「来月の既存会員」なら時点だけ、「新規会員(時期は問わない)」なら個体だけ、「来月の新規会員」なら両方で分けます。

群で分けると評価の行数が偏ったり、陽性がほとんどなくなったりします。どうすればよいですか?

まず群の数を確認します。群が少ない(例:5店)と、どう分けても評価は不安定です。GroupKFold で全群を1回ずつ評価に回し、foldごとの値のばらつきも報告します。陽性の偏りには StratifiedGroupKFold が使えます。それでも群が少なすぎるなら、「新しい店への性能は確かめられない」と限界を書くことも大切です。

予測期間が変わると、分け方はどう変わりますか?

gap が変わります。条件 $t_0 + h \le c$ から、予測期間 $h$ が長いほど、評価の開始より前の多くの月を訓練から外す必要があります。「1か月以内の解約」から「6か月以内の解約」に変えたら、分割処理も必ず見直します。

仕事で、どの分け方にするかを誰がどう決めますか?

モデルを使う業務の担当者と一緒に、「本番でモデルが初めて出会うもの」を言葉で確認して決めます。評価設計書に「評価の単位:利用者、区切り:2025-07-01、gap:1か月、理由:新規会員に来月使うため」のように1行で書き、レビューで合意を取ってから実験を始めます。

交差検証で選んだあと、テストも同じ単位で分ける必要がありますか?

はい。テスト、検証、交差検証のすべてで同じ単位(と時間の向き)を守ります。交差検証だけ無作為に行うと、モデル選択が「覚えて写す」モデルに有利になり、テストで初めて性能の低さに気づくことになります。

確認問題

問1 来年出店する新しい店の売上を予測したい。既存20店の日別データ(1店に365行)がある。評価の分け方として最も適切なものはどれか。

  1. ① 日別の行を無作為に7:3で分ける
  2. ② 店ごとに訓練か評価のどちらか一方に入れる(群分割)
  3. ③ 各店の前半の日を訓練、後半の日を評価にする
  4. ④ 売上の多い店を訓練、少ない店を評価にする
解答と解説

正解:②

本番で初めて出会うのは「新しい店」なので、店単位で分け、評価には訓練で見ていない店だけを置きます。

問2 「3か月以内の解約」(h=3)を予測する。評価は10月のスナップショットから始める。訓練に使える最後のスナップショットの月はどれか。

  1. ① 9月
  2. ② 8月
  3. ③ 7月
  4. ④ 6月
解答と解説

正解:③

条件は $t_0 + h \le c$ で、$t_0 + 3 \le 10$ より $t_0 \le 7$。7月のラベル窓は7〜9月で、評価の10月に入りません。

問3 GroupShuffleSplit(n_splits=1, test_size=0.3) を利用者IDで使った。評価側の行数について正しいものはどれか。

  1. ① 必ず全行数のちょうど30%になる
  2. ② 利用者の30%が評価に入るので、利用者ごとの行数が違えば行数は30%からずれる
  3. ③ 行の30%を選んだあと、同じ利用者の行を訓練に戻す
  4. ④ 評価の行数は常に訓練の行数と等しい
解答と解説

正解:②

test_size は群(ここでは利用者)の割合です。行数の多い人が評価に入れば、行の割合は30%より大きくなります。

問4 1人6行の表で、行を無作為に分けた1-NNの正解率が93%、人ごとに分けた正解率が67%、全員を継続と予測する基準が80%だった。新しい人に使う予測としての結論はどれか。

  1. ① 93%なので導入してよい
  2. ② 2つの平均の80%が本当の性能である
  3. ③ 人ごとの67%が見積もりで、基準より悪いので、このままでは導入しない
  4. ④ 基準は単純すぎるので比較に使わない
解答と解説

正解:③

新しい人への性能は、人ごとに分けた評価で測ります。基準80%を下回るので、モデルは価値を示していません。

問5 TimeSeriesSplit を使うときの前提として正しいものはどれか。

  1. ① 行がどんな順番でも自動で日付順に並べ替える
  2. ② 行が時間順に並んでいることを前提に、前の行を訓練、後の行を評価にする
  3. ③ 利用者IDを groups に渡すと、時点と個体の両方で分ける
  4. ④ 評価期間は毎回同じ長さの訓練期間の直後にランダムに選ばれる
解答と解説

正解:②

TimeSeriesSplit は行の並び順だけを見ます。分割の前に日付で並べ替えておく必要があります。

問6 人(person_id)で分けて重なりがないことを確認したが、同じ世帯の家族が訓練と評価に分かれていた。新しい世帯に使うモデルの評価として、何が問題か。

  1. ① 問題はない。人が重なっていなければ十分である
  2. ② 家族の傾向が似ているため、評価が甘くなる。世帯単位で分けるべき
  3. ③ 世帯の情報は特徴量に入れていないので影響しない
  4. ④ 行数が減るので、世帯で分けるのは避けるべき
解答と解説

正解:②

群(世帯)の中の個体は互いに似ています。本番で新しい世帯に使うなら、大きいほうの単位(世帯)で分けます。

次に学ぶこと

次は 漏洩の検出 で、分け方を正しくしても残る漏洩(未来の特徴量、前処理、テストの使い回し)を見つける方法を学び、モジュールの総まとめをします。

交差検証の書き方は 「指標と交差検証」(有料版)、予測時点とラベル窓の定義は 目的変数と利用時点 へ戻ってください。

キャリア学習:データサイエンティスト・機械学習エンジニア・DX推進・研究者を目指す人の学習アプリ。164モジュール・984授業を、ブラウザで動くSQLとPythonの演習で。基礎と各分野の最初のモジュールは無料。全部は2,980円の買い切り。

アプリを無料で開く有料版と無料版の違い