Day 098 — Aggregation特徴量 — groupby統計量・差分・比率特徴量

2026-07-19 青 / Phase 4 理論→コーディング groupby / transform / agg / 比率特徴量

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

🔵
Day 097 まではパラメータチューニング(Optuna)に集中していました。今日からはモデルに渡す「材料」そのものを強くする特徴量エンジニアリングの本丸に戻ります。最初のテーマは Kaggle の表形式コンペで最も汎用的かつ強力な武器の一つ、Aggregation特徴量(集約特徴量)です。
💡
「1回の注文額が3,000円」という数字だけでは高いか安いか分かりません。しかし「この顧客の平均注文額は2,000円なのに今回は3,000円」と分かれば、その顧客にとって“いつもより奮発した注文”だと解釈できます。同じキー(顧客ID・店舗IDなど)が繰り返し登場するデータがあれば、このテクニックはほぼ必ず効果を発揮します。
用語直感的な意味
groupby統計量同じキー(顧客IDなど)でグループ化し、グループ内の平均・合計・件数・標準偏差などを計算したもの
差分特徴量(Deviation)個々の値からグループの代表値(平均など)を引き算した値。絶対的なズレの大きさを表す
比率特徴量(Ratio)個々の値をグループの代表値で割り算した値。金額の絶対的な大小に影響されにくい
transform()groupby().transform()は集約結果を元の行数のまま返す。mergeせずに各行へ集約値を貼り付けられる
キー粒度(granularity)「誰について集約するか」=顧客単位・カテゴリ単位・店舗単位など。同じデータでも複数粒度で作れる

🧩 Aggregation特徴量を作る3つのテクニック

グループの代表値を求める

📐 agg()

count / mean / std / max / nunique

顧客・カテゴリ単位でデータを縮約し、行動の「型」を数値化する。

元の行数のまま貼り付ける

🔁 transform()

merge不要で1行合成

集約結果を各行にそのまま割り当て、行レベルの相対特徴量の材料にする。

相対値に変換する

➖➗ 差分・比率

amount - mean / amount ÷ mean

「普段通りか、逸脱しているか」という文脈をモデルに直接渡す。

🎯 問題

あるECサイトの注文明細データ(1行=1注文)があります。顧客ごとの「普段の購買行動」を特徴量として取り出し、後続のモデル(不正注文検知・離脱予測など)に渡せる形に整えるタスクです。

使用データ注文明細 n=3000(400顧客 × 5カテゴリ)
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単位でgroupby().agg()し、注文回数・平均額・標準偏差・最大額・カテゴリ数を持つcustomer_aggを作る
3
【実装】transform()で各注文行に顧客平均額を付与し、差分特徴量と比率特徴量を作成する
4
【実装】同様にcategory単位でも比率特徴量を作り、「顧客内での立ち位置」と「カテゴリ内での立ち位置」を両方確認する
5
【分析/戦略】注文1回だけの顧客でamount_stdNaNになる理由と対処法を、Train/Testのリークにも触れながら説明する

⚖️ 差分特徴量 と 比率特徴量 はどう違うか

「平均より+1000円」という差分は、顧客ごとの金銭感覚の違いを無視してしまう。比率にすると、平均額のレベルが違っても相対的な立ち位置を揃えて比較できる。

同じ「+1000円」の差分が、顧客によって意味が変わる 顧客A(平均500円) 500円 今回1500円(+1000) 比率 = 3.0倍 顧客B(平均5000円) 5000円 今回6000円(+1000) 比率 = 1.2倍 差分(+1000円)は同じでも、比率で見ると「異常度」がまったく違う 顧客A: 普段の3倍も使った → 目立つ行動 顧客B: 普段の1.2倍程度 → ほぼ通常の範囲内

📊 集約の「粒度」を変えると特徴量が広がる

同じ「集約→比率」というテクニックでも、groupbyのキーを変えるだけで意味の異なる特徴量が量産できる。

1つの注文行から作れる相対特徴量(例)

顧客単位の比率
amount_ratio_to_cust_mean
その人にしては高額か
カテゴリ単位の比率
amount_ratio_to_cat_mean
そのジャンルとして高額か
顧客単位の差分
amount_diff_from_cust_mean
円単位のズレ
顧客の購買多様性
category_nunique
何ジャンル買うか

💡 ヒント

ヒント1方向性

「注文額そのもの」は行ごとにバラバラだが、「その顧客にとって普段よりどれだけ高いか/安いか」という相対的な情報に変換すると、モデルが学習しやすい特徴量になる。今日はその変換を、①グループごとの代表値を求める(agg)、②その代表値を元の行数のまま各行に貼り付ける(transform)、③引き算・割り算で相対値に変換する、という3ステップで行う。

ヒント2アプローチ
  • orders.groupby("customer_id")["amount_yen"].agg(["count","mean","std","max"])のようにリストで複数統計量を渡せる
  • 列名を分かりやすくしたい場合は agg(order_count=("amount_yen","count"), ...)named aggregationが便利
  • nunique()はユニーク数を数える関数。groupby("customer_id")["category"].nunique()で購買の多様性が分かる
  • transform()agg()と違い元の行数と同じ行数を返すので、そのまま新しい列として代入できる
  • 差分は引き算、比率は割り算。今回amount_yenは常に正の値なのでゼロ割りは起きない
ヒント3コード骨格
# タスク2: 顧客単位の集約
customer_agg = orders.groupby("customer_id").agg(
    order_count=("amount_yen", "count"),
    amount_mean=("amount_yen", "mean"),
    amount_std=("amount_yen", "std"),
    amount_max=("amount_yen", "max"),
    category_nunique=("category", "nunique"),
).reset_index()

# タスク3: transformで行ごとに顧客平均を付与 → 差分・比率
orders["cust_amount_mean"] = orders.groupby("customer_id")["amount_yen"].transform(___)
orders["amount_diff_from_cust_mean"] = orders["amount_yen"] - orders[___]
orders["amount_ratio_to_cust_mean"] = orders["amount_yen"] / orders[___]

# タスク4: カテゴリ単位も同様に
orders["cat_amount_mean"] = orders.groupby(___)["amount_yen"].transform("mean")
orders["amount_ratio_to_cat_mean"] = orders["amount_yen"] / orders[___]

# タスク5: 注文1回だけの顧客のstdを確認
customer_agg[customer_agg["order_count"] == 1][["customer_id", "order_count", "amount_std"]].head()

模範解答

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,
})

# ====== タスク2: 顧客単位の集約特徴量(customer_agg) ======
customer_agg = orders.groupby("customer_id").agg(
    order_count=("amount_yen", "count"),
    amount_mean=("amount_yen", "mean"),
    amount_std=("amount_yen", "std"),
    amount_max=("amount_yen", "max"),
    category_nunique=("category", "nunique"),
).reset_index()

print("--- タスク2: customer_agg ---")
print(customer_agg.head())
print(f"顧客数: {customer_agg.shape[0]}")

# ====== タスク3: transformで顧客平均を付与 → 差分・比率特徴量 ======
orders["cust_amount_mean"] = orders.groupby("customer_id")["amount_yen"].transform("mean")
orders["amount_diff_from_cust_mean"] = orders["amount_yen"] - orders["cust_amount_mean"]
orders["amount_ratio_to_cust_mean"] = orders["amount_yen"] / orders["cust_amount_mean"]

# ====== タスク4: カテゴリ単位の比率特徴量 ======
orders["cat_amount_mean"] = orders.groupby("category")["amount_yen"].transform("mean")
orders["amount_ratio_to_cat_mean"] = orders["amount_yen"] / orders["cat_amount_mean"]

print("\n--- タスク3・4: 行ごとの相対特徴量 ---")
cols = ["customer_id", "category", "amount_yen", "cust_amount_mean",
        "amount_diff_from_cust_mean", "amount_ratio_to_cust_mean",
        "cat_amount_mean", "amount_ratio_to_cat_mean"]
print(orders[cols].head(8).round(2))

# ====== タスク5: 注文1回だけの顧客のstdを確認 ======
single_order_customers = customer_agg[customer_agg["order_count"] == 1]
print(f"\n--- タスク5: 注文1回だけの顧客数: {len(single_order_customers)} ---")
print(single_order_customers[["customer_id", "order_count", "amount_std"]].head())

# 欠損への対処例: 「新規/少数注文フラグ」を立てた上でstdは0埋め
customer_agg["is_single_order"] = (customer_agg["order_count"] == 1).astype(int)
customer_agg["amount_std_filled"] = customer_agg["amount_std"].fillna(0)
🧠
理論問題(タスク1): 同じ「3,000円の注文」でも、普段500円しか使わない顧客にとっては“異常なほど大きな注文”、普段5,000円使う顧客にとっては“いつもより控えめな注文”というように意味が正反対になる。生のamount_yenだけを見ているモデルはこの違いを区別できないが、顧客ごとの平均との差分・比率に変換すれば、同じ数値の裏にある文脈をモデルに直接渡せる。人間が無意識にやっている「あの人にしては珍しいね」という判断を、数値として機械学習モデルに教える作業だと言える。
🧠
理論問題(タスク5): stdは「バラつき」の指標であり、データが2件以上ないと計算できない(1件だけでは「平均からのズレ」を評価する対象が無い)。対処法は①fillna(0)(バラつきが無い=安定とみなす)、②fillna(全体平均std)(過度に安定扱いしない保守的処置)、③is_single_orderのような欠損理由自体をフラグ化した列の3つが定番。また、Testに含まれる顧客の注文がTrain期間の後にしか無い場合、Trainの集約統計だけをTestに適用することが重要で、これを怠ると未来の情報がリークしCVスコアだけ不自然に良くなる典型的な失敗パターンになる。

🪜 Step-by-Step 解説

1agg()で「グループの代表値」を求める

customer_agg = orders.groupby("customer_id").agg(
    order_count=("amount_yen", "count"),
    amount_mean=("amount_yen", "mean"),
    amount_std=("amount_yen", "std"),
    amount_max=("amount_yen", "max"),
    category_nunique=("category", "nunique"),
).reset_index()
🔑
なぜこうするか: agg(新列名=(元の列, 関数名))というnamed aggregationを使うと集約後の列名が最初から分かりやすくなる(単に.agg(["mean","std"])だとMultiIndexの列名になり扱いづらい)。countは「行数」、mean/std/maxは「金額の分布の形」、nuniqueは「購買の多様性」を表す、それぞれ異なる切り口の特徴量。

2transform()で集約結果を「元の行数のまま」貼り付ける

orders["cust_amount_mean"] = orders.groupby("customer_id")["amount_yen"].transform("mean")
🔑
なぜこうするか: agg()は「顧客数(400行)」に縮約された結果を返すが、transform()入力と同じ行数(3000行)を保ったまま、各行に「その行が属するグループの統計量」を割り当てる。これによりcustomer_aggを作ってmerge()する2ステップを踏まずに、1行で集約値を貼り付けられる。

3差分(引き算)と比率(割り算)で「相対値」に変換する

orders["amount_diff_from_cust_mean"] = orders["amount_yen"] - orders["cust_amount_mean"]
orders["amount_ratio_to_cust_mean"] = orders["amount_yen"] / orders["cust_amount_mean"]
🧪
なぜこうするか: 差分は「絶対的にどれだけズレているか(円単位)」、比率は「何倍か(スケールに依存しない)」を表す。「平均500円の顧客が+1000円の差分」と「平均10000円の顧客が+1000円の差分」は差分では同じ扱いになるが、比率にすると前者は3倍・後者は1.1倍と顧客ごとの金銭感覚の違いを吸収した形で表現できる。実務・Kaggleどちらでも両方作っておき、モデルにどちらが効くかを判断させるのが定石。

4集約の「キー(粒度)」を変えると別の切り口の特徴量になる

orders["cat_amount_mean"] = orders.groupby("category")["amount_yen"].transform("mean")
orders["amount_ratio_to_cat_mean"] = orders["amount_yen"] / orders["cat_amount_mean"]
📋
なぜこうするか: 同じ「集約→比率」というテクニックでも、groupbyのキーをcustomer_idからcategoryに変えるだけで、「顧客の中での立ち位置」から「カテゴリの中での立ち位置」というまったく別の意味を持つ特徴量が作れる。「この注文はこの顧客にとっては普段通りだが、家電カテゴリとしては非常に高額」というように、1つの行に複数粒度の“文脈”を同時に持たせるのがAggregation特徴量の本領。

5集約件数が少ないグループの扱いを決める

customer_agg["is_single_order"] = (customer_agg["order_count"] == 1).astype(int)
customer_agg["amount_std_filled"] = customer_agg["amount_std"].fillna(0)
⚖️
なぜこうするか: stdNaNをそのままモデルに渡すと、線形モデルではエラーになったり、GBDT系でも「欠損=特別な意味」として過度に学習してしまうことがある。単純に0埋めするのではなく、「なぜ欠損したか(=件数が少なく信頼度が低い)」という情報自体をis_single_orderのような列に切り出しておくことで、モデルが「この統計量は参考程度」と学習しやすくなる。

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

📐
差分と比率の直感的な理解: クラス全員のテストの平均点が60点だったとする。Aさんが80点を取ったとき「平均より+20点」(差分)とも「平均の1.33倍」(比率)とも言える。平均が60点のときと90点のときとで「+20点」の意味は変わるが、「1.33倍」という言い方は平均のレベルが変わっても比較的意味がブレにくい。金額データのように平均自体が人によって大きく異なる場合は、比率のほうが扱いやすいことが多い。
📏
標準偏差がなぜ1件だと計算できないか: 標準偏差は「平均からどれだけバラついているか」の指標。バラつきを測るには最低でも「比較できる2点」が必要。1つの点しかなければ「それが平均そのもの」であり、そこから動きようがない(バラつきようがない)ため、数学的に定義できずNaNになる。

数式を見たら

std = √( (1/(n-1)) × Σ(xᵢ - x̄)² )

分母に「n-1」があるため、n=1のとき分母が0になり計算不能(0で割ることになる)になる。これが「1件だけのグループのstdがNaNになる」理由を数式で説明したもの。

🏆 Kaggleでの実践的な使い方

よく使われるコンペカテゴリ: ☑ 表形式データ(Tabular) / ☐ 自然言語処理(NLP) / ☐ 画像認識(CV) / ☑ 時系列(Time Series)

  • 不正検知系コンペ(例: IEEE-CIS Fraud Detection): カード番号・デバイスID単位で「過去の平均取引額との比率」「同一デバイスからの取引回数」などのAggregation特徴量が上位解法で必ずと言っていいほど使われる
  • 需要予測・購買予測系コンペ(例: Instacart Market Basket Analysis): 「そのユーザーがそのカテゴリを買う頻度」「カート内の平均商品数との比較」など、ユーザー粒度・商品粒度・カテゴリ粒度で何十〜何百もの集約特徴量を作り込むのが定番戦略
  • 与信・金融系コンペ(例: Home Credit Default Risk): 顧客ごとの過去の申込・返済履歴をAggregationし、「過去の平均返済額に対して今回の申込額はどれくらい大きいか」といった比率特徴量が重要な予測因子になる

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
agg()の結果をそのまま元のDataFrameに代入しようとしてエラーになるagg()はグループ数に縮約された行数を返すことを知らない元の行数のまま各行に貼り付けたい場合はtransform()を使う。agg()は集約テーブル(マスタ)を作るときに使う
Test期間のデータも含めて集約統計を計算してしまうTrain/Testをまとめて前処理してしまう習慣がある未来の情報が漏れる(リーク)。集約統計は原則Trainのみから計算し、その値をTestに同じ値として適用する
amount_stdNaNを無条件にモデルに投げてしまう欠損値の扱いを後回しにするGBDT系はNaNをある程度扱えるが線形モデルではエラーになる。件数が少ないグループの統計量は信頼度が低いことも念頭に置く
差分特徴量と比率特徴量のどちらか一方しか作らない「似たようなものだから片方で十分」と判断してしまうスケールの違いに敏感かどうかでモデルへの効き方が変わるため、両方作ってモデルに選ばせるのが定石
集約のキー(粒度)を1種類しか試さない「顧客単位で集約すればOK」と決め打ちしてしまう顧客単位・カテゴリ単位・店舗単位・時間帯単位など複数粒度で繰り返すことで特徴量のバリエーションが大きく広がる

🚀 次のステップ

  • 発展: groupby(["customer_id", "category"])のように複数キーの組み合わせで集約し、「この顧客はこのカテゴリで普段どれくらい使うか」というより粒度の細かい特徴量を作ってみる
  • 次回予告: Day 099「Rolling・Lag特徴量」 — 時系列データへの応用。今日の「グループ全体の平均」から一歩進み、「直近N件だけの平均」「1つ前の注文額」など時間の前後関係を考慮した集約に挑戦します

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

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