Day 099 — Rolling・Lag特徴量 — 時系列データへの応用

2026-07-20 青 / Phase 4 コーディング shift / rolling / リーク防止

📚 背景知識(読んでから問題へ)

🔵
Day 098 では「顧客の全期間平均との差分・比率」というAggregation特徴量を学びました。しかし「全期間の平均」は過去も未来も混ぜた1つの数字であり、その顧客が“今”どういう状態にあるかという時間の流れを無視しています。
💡
最近になって注文頻度が急に増えている顧客(のめり込み期)と、最近ぱったり注文が途絶えた顧客(離脱の兆候)は、全期間平均だけでは見分けがつきません。「1つ前はどうだったか」「直近数件の傾向はどうか」という過去だけを見た情報をモデルに渡すのが、今日学ぶLag特徴量とRolling特徴量です。
用語直感的な意味
Lag特徴量(ラグ)shift(n)で「n個前の行の値」を取得したもの。「1つ前の注文額はいくらだったか」など
Rolling特徴量(移動集約)rolling(window)で「直近window件の統計量」を計算したもの。全期間平均ではなく“最近の傾向”を表す
shift()系列を1行分(またはn行分)ずらす関数。shift(1)は「1つ前の行の値」を今の行に持ってくる
リーク防止の鉄則rolling(3).mean()は現在の行自身も含んでしまう。shift(1)してからrolling()し「過去だけ」にする
時系列の整列(sort)groupbyshift/rollingを使う前に必ずsort_values(["customer_id","order_date"])で時間順に並べる

🧩 Rolling・Lag特徴量を作る2つのテクニック

1つ前の値を取る

⏮️ 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ごとに複数の注文が時系列で並ぶ)を使います。今日は「顧客の全期間平均」ではなく、「その顧客の直近の動き」を表す特徴量を作ります。

使用データDay098と同じ注文明細 n=3000(400顧客)
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

タスク

1
【理論】「直近の動き」を見ることが予測に効く理由を、急増顧客と離脱気味の顧客の例で説明する
2
【実装】["customer_id","order_date"]でソートし、shift(1)prev_amount_yendays_since_last_orderを作る
3
【実装】shift(1)してからrolling(3)する「現在を含まない直近3件平均」rolling_mean_amount_last3を作る
4
【実装】初回注文のNaNis_first_orderフラグを立て、欠損の埋め方を比較・採用する
5
【分析/戦略】ソート忘れのリスクと、Train/Test分割時に未来の情報を混ぜないための注意点を説明する

⚠️ なぜ rolling(3).mean() だけだとリークするのか

pandasのrolling(window)現在の行を含む直近window件が対象。「今回の注文額」を使って「今回の注文額を説明する特徴量」を作ってしまうと、実質的に答えを見ていることになる。

注文の並び(時系列順) → 4件目を予測したい ①1000円 ②1400円 ③1200円 ④9000円? ✗ rolling(3).mean() をそのまま使うと… ②③④の平均に④自身(9000円)が混入 → ④を予測するのに④の答えを使っている ✓ shift(1).rolling(3).mean() なら… ①②③だけの平均(④は含まない)→ ④を予測する材料として正しい 先に1行ずらす(shift)ことで「現在」を集計対象から外すのが鉄則

📊 今日作る特徴量の一覧

Lag(1点の過去)とRolling(複数点の過去の集約)で、それぞれ違う角度から“今の勢い”を表現する。

1つの注文行に付与する時系列特徴量(例)

前回注文額
prev_amount_yen
直前1件の実績
前回からの間隔
days_since_last_order
頻度・離脱の兆候
直近3件の移動平均
rolling_mean_amount_last3
最近の“地力”
初回注文フラグ
is_first_order
欠損の理由を明示

💡 ヒント

ヒント1方向性

Day 098の「全期間の平均との差分・比率」は時間を無視した1つの代表値だった。今日はそこに「時間の前後関係」を持ち込む。①「1つ前の行はどうだったか」を取得する(Lag)、②「直近数件だけ」の平均を取る(Rolling)。どちらも必ず時間順に並んでいることが大前提で、さらに「現在の行の情報を混ぜない」よう1つ分ずらしてから計算する点に注意。

ヒント2アプローチ
  • まず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()で判定できる
ヒント3コード骨格
# タスク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))
🧠
理論問題(タスク1): 「全期間の平均」は、顧客の行動が180日間ずっと一定だったという前提のもとでは意味のある代表値だが、実際には行動は時間とともに変化する。最近になって注文頻度・注文額が急増している顧客は「今まさにのめり込んでいる」状態かもしれず、逆に最近ぱったり注文が止まっている顧客は「離脱しかけている」状態かもしれない。どちらも全期間平均という数字1つには埋もれて見えなくなる。Lag特徴量(1つ前はどうだったか)やRolling特徴量(直近3件の平均・トレンド)は「今この瞬間、この顧客はどちらに動いているか」という“勢い”をモデルに渡す手段になる。
🧠
理論問題(タスク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)
📋
なぜこうするか: 各顧客の最初の注文には「1つ前の注文」が存在しないためprev_amount_yenなどは必然的にNaNになる。単純に0埋めすると「普段0円しか使わない人」という誤った情報になるため、「その顧客の全期間平均」で埋めるほうが自然。加えてis_first_orderフラグを立てることで、モデルに「この行の値は実測ではなく代替値である」ことを明示できる。

🧮 数学・統計の補足(文系向け)

📐
Lag(ラグ)を直感で: 家計簿を1行ずつスクロールしていくところを想像してほしい。ある行の「1つ上の行」を見れば「前回の支出はいくらだったか」がわかる。shift(1)はまさにこの「1行分だけ視線をずらして、前の行を今の行に持ってくる」操作。ずらす行数(shift(2)なら2つ前)を変えれば「2回前の注文」なども自由に取得できる。
📏
Rolling(移動平均)を直感で: 体重計に毎日乗って記録するとき、「今日の体重」だけを見るより「直近7日間の平均」を見るほうが日々の誤差に振り回されず「トレンド」がわかる。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の基礎を発展させ、リークをさらに厳密に防ぎながら精度を高める手法に進みます

📝 自己評価(解いた後に記入)

自分の回答・気づき・メモ: