📚 背景知識(読んでから問題へ)
| 用語 | 直感的な意味 |
|---|---|
| Lag特徴量(ラグ) | shift(n)で「n個前の行の値」を取得したもの。「1つ前の注文額はいくらだったか」など |
| Rolling特徴量(移動集約) | rolling(window)で「直近window件の統計量」を計算したもの。全期間平均ではなく“最近の傾向”を表す |
shift() | 系列を1行分(またはn行分)ずらす関数。shift(1)は「1つ前の行の値」を今の行に持ってくる |
| リーク防止の鉄則 | rolling(3).mean()は現在の行自身も含んでしまう。shift(1)してからrolling()し「過去だけ」にする |
| 時系列の整列(sort) | groupbyでshift/rollingを使う前に必ずsort_values(["customer_id","order_date"])で時間順に並べる |
🧩 Rolling・Lag特徴量を作る2つのテクニック
⏮️ shift(n)
prev_amount_yen / days_since_last_order
「前回どうだったか」を今の行に持ってくる。差分・間隔の計算の起点になる。
📈 rolling(window)
rolling_mean_amount_last3
全期間ではなく直近N件だけを集約し、“今の勢い”を表現する。
🛡️ shift(1) → rolling()
リーク防止の鉄則
先に1行ずらしてから集約することで、答え(今回の値)を混ぜずに過去だけを見る。
🎯 問題
Day 098 と同じECサイトの注文明細データ(customer_idごとに複数の注文が時系列で並ぶ)を使います。今日は「顧客の全期間平均」ではなく、「その顧客の直近の動き」を表す特徴量を作ります。
import numpy as np import pandas as pd np.random.seed(42) n_orders = 3000 n_customers = 400 customer_id = np.random.choice( [f"cust_{i:04d}" for i in range(n_customers)], n_orders ) category = np.random.choice( ["食品", "家電", "衣料", "書籍", "日用品"], n_orders, p=[0.35, 0.15, 0.20, 0.10, 0.20] ) order_date = pd.to_datetime("2026-01-01") + pd.to_timedelta( np.random.randint(0, 180, n_orders), unit="D" ) amount_yen = (np.random.gamma(shape=2.0, scale=1500, size=n_orders) + 300).round(-1) orders = pd.DataFrame({ "order_id": np.arange(n_orders), "customer_id": customer_id, "category": category, "order_date": order_date, "amount_yen": amount_yen, })
| カラム名 | 意味 | 型 |
|---|---|---|
| order_id | 注文ID(1行1注文) | int |
| customer_id | 顧客ID(400人が繰り返し登場) | category |
| category | 購入カテゴリ(食品/家電/衣料/書籍/日用品) | category |
| order_date | 注文日 | datetime |
| amount_yen | 注文金額(円) | float |
タスク
["customer_id","order_date"]でソートし、shift(1)でprev_amount_yen・days_since_last_orderを作るshift(1)してからrolling(3)する「現在を含まない直近3件平均」rolling_mean_amount_last3を作るNaNにis_first_orderフラグを立て、欠損の埋め方を比較・採用する⚠️ なぜ rolling(3).mean() だけだとリークするのか
pandasのrolling(window)は現在の行を含む直近window件が対象。「今回の注文額」を使って「今回の注文額を説明する特徴量」を作ってしまうと、実質的に答えを見ていることになる。
📊 今日作る特徴量の一覧
Lag(1点の過去)とRolling(複数点の過去の集約)で、それぞれ違う角度から“今の勢い”を表現する。
1つの注文行に付与する時系列特徴量(例)
💡 ヒント
Day 098の「全期間の平均との差分・比率」は時間を無視した1つの代表値だった。今日はそこに「時間の前後関係」を持ち込む。①「1つ前の行はどうだったか」を取得する(Lag)、②「直近数件だけ」の平均を取る(Rolling)。どちらも必ず時間順に並んでいることが大前提で、さらに「現在の行の情報を混ぜない」よう1つ分ずらしてから計算する点に注意。
- まず
orders = orders.sort_values(["customer_id", "order_date"]).reset_index(drop=True)で時系列順に並べ替える orders.groupby("customer_id")["amount_yen"].shift(1)で「同じ顧客の中で1つ前の注文額」が取れる(顧客が変わる境目では自動的にNaN)days_since_last_orderは(orders["order_date"] - orders["prev_order_date"]).dt.daysで計算- Rollingは
groupby("customer_id")["amount_yen"].transform(lambda s: s.shift(1).rolling(3, min_periods=1).mean())のように、transformの中でshiftしてからrollingをかける - 最初の注文かどうかは
orders["prev_amount_yen"].isna()で判定できる
# タスク2: ソート & lag特徴量 orders = orders.sort_values(___).reset_index(drop=True) orders["prev_amount_yen"] = orders.groupby("customer_id")["amount_yen"].shift(___) orders["prev_order_date"] = orders.groupby("customer_id")["order_date"].shift(___) orders["days_since_last_order"] = (orders["order_date"] - orders[___]).dt.days # タスク3: rolling特徴量(現在の行を含まない直近3件平均) orders["rolling_mean_amount_last3"] = orders.groupby("customer_id")["amount_yen"].transform( lambda s: s.shift(___).rolling(___, min_periods=1).mean() ) # タスク4: 初回注文フラグと欠損対処 orders["is_first_order"] = orders[___].isna().astype(int) cust_overall_mean = orders.groupby("customer_id")["amount_yen"].transform("mean") orders["rolling_mean_amount_last3_filled"] = orders["rolling_mean_amount_last3"].fillna(___)
✅ 模範解答
import numpy as np import pandas as pd # ====== データ準備(Day098と同一) ====== np.random.seed(42) n_orders = 3000 n_customers = 400 customer_id = np.random.choice( [f"cust_{i:04d}" for i in range(n_customers)], n_orders ) category = np.random.choice( ["食品", "家電", "衣料", "書籍", "日用品"], n_orders, p=[0.35, 0.15, 0.20, 0.10, 0.20] ) order_date = pd.to_datetime("2026-01-01") + pd.to_timedelta( np.random.randint(0, 180, n_orders), unit="D" ) amount_yen = (np.random.gamma(shape=2.0, scale=1500, size=n_orders) + 300).round(-1) orders = pd.DataFrame({ "order_id": np.arange(n_orders), "customer_id": customer_id, "category": category, "order_date": order_date, "amount_yen": amount_yen, }) # ====== タスク2: ソート & Lag特徴量 ====== orders = orders.sort_values(["customer_id", "order_date"]).reset_index(drop=True) orders["prev_amount_yen"] = orders.groupby("customer_id")["amount_yen"].shift(1) orders["prev_order_date"] = orders.groupby("customer_id")["order_date"].shift(1) orders["days_since_last_order"] = ( orders["order_date"] - orders["prev_order_date"] ).dt.days print("--- タスク2: Lag特徴量 ---") print(orders[["customer_id", "order_date", "amount_yen", "prev_amount_yen", "days_since_last_order"]].head(8)) # ====== タスク3: Rolling特徴量(現在の行を含まない直近3件平均) ====== orders["rolling_mean_amount_last3"] = orders.groupby("customer_id")["amount_yen"].transform( lambda s: s.shift(1).rolling(3, min_periods=1).mean() ) print("\n--- タスク3: Rolling特徴量 ---") print(orders[["customer_id", "order_date", "amount_yen", "prev_amount_yen", "rolling_mean_amount_last3"]].head(8)) # ====== タスク4: 初回注文フラグと欠損対処 ====== orders["is_first_order"] = orders["prev_amount_yen"].isna().astype(int) cust_overall_mean = orders.groupby("customer_id")["amount_yen"].transform("mean") overall_mean = orders["amount_yen"].mean() # 採用: 初回注文には「その顧客の全期間平均」で埋める(0埋めは「普段0円」という # 誤った情報になり、全体平均埋めは個人差を無視してしまうため) orders["prev_amount_yen_filled"] = orders["prev_amount_yen"].fillna(cust_overall_mean) orders["rolling_mean_amount_last3_filled"] = orders["rolling_mean_amount_last3"].fillna(cust_overall_mean) orders["days_since_last_order_filled"] = orders["days_since_last_order"].fillna(-1) # 「初回」の番兵値 print("\n--- タスク4: 初回注文の欠損対処 ---") print(f"初回注文の件数(顧客数と一致するはず): {orders['is_first_order'].sum()} / 顧客数: {orders['customer_id'].nunique()}") print(orders[orders["is_first_order"] == 1][ ["customer_id", "amount_yen", "prev_amount_yen", "prev_amount_yen_filled", "rolling_mean_amount_last3_filled", "days_since_last_order_filled"] ].head(5))
sort_valuesを忘れて元のorder_id順(ランダムな時間順)のままgroupby().shift()を計算すると、「1つ前の行」が時間的に本当に1つ前の注文とは限らなくなる。極端な場合、未来の注文が「1つ前」として現在の行に混入し、未来の情報を使って学習するリークになる。CV上はスコアが不自然に良く見えるが、実運用(未来のデータしかない状況)では再現できない。Train/Testを時系列で分割する際も、①集約・Lag・Rolling特徴量は「予測対象の時点より前」のデータだけで計算する、②Testの特徴量作成時にTest内の未来の行やTrainより後の情報を混ぜない、という2点を徹底する必要がある。🪜 Step-by-Step 解説
1sort_values()で時系列順に並べ替える
orders = orders.sort_values(["customer_id", "order_date"]).reset_index(drop=True)
groupby().shift()/rolling()は「今の並び順における1つ前の行」を見る。データがorder_id(生成時のランダムな順序)のまま並んでいると「1つ前の行」が時間的に本当に前とは限らない。["customer_id", "order_date"]でソートすることで、各顧客の中で時間の早い順に並ぶようになり、初めてshift/rollingが「過去の情報」として意味を持つ。2shift(1)で「1つ前の値」を取得する
orders["prev_amount_yen"] = orders.groupby("customer_id")["amount_yen"].shift(1) orders["prev_order_date"] = orders.groupby("customer_id")["order_date"].shift(1)
groupby("customer_id")[...].shift(1)は同じ顧客の中でだけ1行分ずらす。顧客が変わる境目(=そのグループの最初の行)では比較対象の「1つ前」が存在しないため自動的にNaNになる。これは「その顧客にとって初めての注文だから直前の情報がない」という正しい状態を表している。3日付の差分から「間隔」を特徴量にする
orders["days_since_last_order"] = (orders["order_date"] - orders["prev_order_date"]).dt.days
datetime同士の引き算はTimedelta型になり、.dt.daysで扱いやすい数値に変換できる。値が小さいほど「頻繁に買っている」、大きいほど「久しぶりの注文(離脱気味)」という解釈ができる。4shift(1)してからrolling()し「現在を含まない」移動平均にする
orders["rolling_mean_amount_last3"] = orders.groupby("customer_id")["amount_yen"].transform( lambda s: s.shift(1).rolling(3, min_periods=1).mean() )
s.rolling(3).mean()を先にやってしまうと、現在の行のamount_yen自身がその移動平均の計算に含まれてしまう。これは「今回いくら使ったか」を使って「今回いくら使うと予想されるか」を予測するのと同じで実質的にリーク。先にshift(1)して「1つ前までの系列」を作ってからrolling(3)をかけることで、あくまで過去3件だけを見た移動平均になる。min_periods=1は「まだ3件貯まっていなくても計算できる分だけ返す」設定。5欠損(初回注文)の扱いを設計する
orders["is_first_order"] = orders["prev_amount_yen"].isna().astype(int) orders["prev_amount_yen_filled"] = orders["prev_amount_yen"].fillna(cust_overall_mean)
prev_amount_yenなどは必然的にNaNになる。単純に0埋めすると「普段0円しか使わない人」という誤った情報になるため、「その顧客の全期間平均」で埋めるほうが自然。加えてis_first_orderフラグを立てることで、モデルに「この行の値は実測ではなく代替値である」ことを明示できる。🧮 数学・統計の補足(文系向け)
shift(1)はまさにこの「1行分だけ視線をずらして、前の行を今の行に持ってくる」操作。ずらす行数(shift(2)なら2つ前)を変えれば「2回前の注文」なども自由に取得できる。rolling(3).mean()は同じ発想で、「直近3件の注文額の平均」を見ることで、1回だけ特別に高額だった注文などのノイズをならし、その顧客の最近の“地力”を表現する。数式を見たら
rolling_mean_t = (1/3) × (x_(t-1) + x_(t-2) + x_(t-3))
「今の注文(x_t)は使わず、1つ前・2つ前・3つ前の注文額を平均する」という意味。x_tを式に含めない(現在の行を除く)ことが、リークを防ぐ上で最も重要なポイント。
🏆 Kaggleでの実践的な使い方
よく使われるコンペカテゴリ: ☑ 表形式データ(Tabular) / ☐ 自然言語処理(NLP) / ☐ 画像認識(CV) / ☑ 時系列(Time Series)
- 需要予測コンペ(例: M5 Forecasting, Store Sales): 「前日・前週・前年同日の売上(Lag)」「直近7日・28日移動平均(Rolling)」は上位解法でほぼ必ず作られる、時系列コンペにおける最重要特徴量群
- 与信・金融系コンペ(例: Home Credit, American Express Default Prediction): 「直近3ヶ月の平均利用額」「前回請求からの経過月数」のようなLag/Rolling特徴量で「最近の行動変化」を捉え延滞リスクを予測する
- 不正検知コンペ(例: IEEE-CIS Fraud Detection): 「同じカードでの直前の取引からの経過時間」「直近N件の取引金額の変動」など、Lag/Rolling特徴量は“普段と違う急な変化”の検出に極めて強力
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
sort_valuesをせずにgroupby().shift()/rolling()を使う | データが既に時間順だと思い込んでいる | ソートを忘れると「1つ前の行」が時間的に無関係な行になり、意図しないリークや誤った特徴量が生まれる。必ず["group_key","time_key"]でソートしてから使う |
rolling(3).mean()をそのまま使い現在の行を含めてしまう | rollingが「現在を含む窓」であることを知らない | pandasのrolling(window)はデフォルトで現在の行を含む直近window件が対象。現在を除外したい場合は必ずshift(1)してからrolling()をかける |
初回注文のNaNを単純に0埋めしてしまう | 欠損=0という思い込み | 金額系の特徴量を0埋めすると「普段0円」という誤った意味になりがち。顧客平均や全体平均で埋める、あるいはフラグ列で明示するほうが適切な場合が多い |
| Testデータの特徴量作成時にTest内の未来の行やTrainより後の情報まで使ってしまう | Train/Testをまとめて前処理する習慣がある | 時系列特徴量は「予測時点で実際に手に入る情報」だけを使うべき。未来のデータを混ぜるとCVスコアだけが不自然に良くなり本番で性能が出ない |
| Lag/Rollingの窓サイズ(3件・7日など)を1種類しか試さない | 「とりあえず3でいいか」と決め打ちする | 短い窓(急な変化)と長い窓(安定したトレンド)は異なる情報を持つため、複数の窓サイズで作りモデルに選ばせるのが定石 |
🚀 次のステップ
- 発展:
rolling(window="7D")のように日数ベースの窓(件数ではなく期間で区切る)や、ewm(span=3).mean()(指数加重移動平均、直近ほど重みを大きくする)を試してみる - 次回予告: Day 100「高度なTargetEncoding」 — LeaveOneOut・Smoothing係数最適化。TargetEncodingの基礎を発展させ、リークをさらに厳密に防ぎながら精度を高める手法に進みます
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: