機械学習モデルの精度は「特徴量の質」で決まる
「機械学習でチャーン(離脱)を予測したい」という要望は通販現場でよく出てくる。しかし多くのプロジェクトがモデルの選択や精度チューニングに注力しすぎて、最も重要な部分を疎かにする。
特徴量エンジニアリングだ。
データサイエンティストの経験則として「モデルの精度の80%は特徴量の質で決まり、モデルの種類による差は20%以下だ」とよく言われる。最新の勾配ブースティングアルゴリズムを使っても、「直近30日の購買金額」しか特徴量がなければ精度は出ない。
通販・ECのチャーン予測に使える特徴量は大きく分けて5つのカテゴリがある。
- 購買頻度・間隔系:どれだけ頻繁に買うか、間隔が変化しているか
- 購買金額系:いくら使うか、最近は増えているか減っているか
- 行動変化系:最近の行動が過去と比べて変わってきているか
- エンゲージメント系:メルマガを開くか、サイトに来ているか
- カテゴリ・商品系:何を買うか、カテゴリの幅が広がっているか狭まっているか
今回は SQL だけでこれらを網羅的に計算し、機械学習モデルに渡すための特徴量テーブルを一括生成する。
特徴量エンジニアリングの設計方針
基準日と観察期間の設定
基準日:2024-09-30(この日時点での特徴量を計算する)観察期間:基準日から遡って12ヶ月(2023-10-01〜2024-09-30)予測対象期間:基準日から90日後(2024-10-01〜2024-12-31) ↓ この期間に購買があればチャーンしていない(ラベル=0) ↓ 購買がなければチャーン(ラベル=1)
特徴量と予測ラベルを「時間的に分離する」のが重要だ。基準日以降のデータを特徴量に使ってしまうとデータリークが起き、本番では使えない過学習モデルができてしまう。
「最近」「中期」「長期」の3ウィンドウ
人間の行動は「最近の変化」が離脱の先行指標になることが多い。そのため特徴量を複数の時間ウィンドウで計算し、「最近(直近30日)」「中期(直近90日)」「長期(直近365日)」の3段階で見る。
使用するテーブル
-- orders : order_id, customer_id, order_date, total_amount, status
-- order_items : order_id, product_id, category, quantity, unit_price
-- mail_deliveries : delivery_id, customer_id, campaign_id, delivered_at, is_opened
-- customers : customer_id, registered_at, channel, rankSTEP 1 ― ラベルの生成(チャーンしたか否か)
-- STEP1: チャーンラベルの生成
WITH churn_label AS (
SELECT
c.customer_id,
c.registered_at,
c.channel,
c.rank,
-- 基準日時点での最終購買日
MAX(o.order_date) AS last_order_before_cutoff,
-- 予測対象期間に購買があるか(ラベル)
MAX(CASE
WHEN o2.order_date >= DATE '2024-10-01'
AND o2.order_date < DATE '2025-01-01'
AND o2.status = 'completed'
THEN 1 ELSE 0
END) AS has_future_purchase,
-- チャーンラベル(0=継続, 1=チャーン)
1 - MAX(CASE
WHEN o2.order_date >= DATE '2024-10-01'
AND o2.order_date < DATE '2025-01-01'
AND o2.status = 'completed'
THEN 1 ELSE 0
END) AS churn_label
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
AND o.status = 'completed'
AND o.order_date < DATE '2024-10-01' -- 基準日より前のみ
LEFT JOIN orders o2 ON c.customer_id = o2.customer_id -- ラベル用(未来)
-- 基準日から1年以内に購買歴がある顧客のみを対象
WHERE EXISTS (
SELECT 1 FROM orders oe
WHERE oe.customer_id = c.customer_id
AND oe.status = 'completed'
AND oe.order_date >= DATE '2023-10-01'
AND oe.order_date < DATE '2024-10-01'
)
GROUP BY c.customer_id, c.registered_at, c.channel, c.rank
)
SELECT *
FROM churn_label;STEP 2 ― 購買頻度・間隔系の特徴量(12本)
-- STEP2: 購買頻度・間隔系特徴量
WITH base_orders AS (
SELECT
customer_id,
order_date,
total_amount,
DATE_DIFF('day',
LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date),
order_date
) AS days_since_prev_order
FROM orders
WHERE status = 'completed'
AND order_date >= DATE '2023-10-01'
AND order_date < DATE '2024-10-01'
),
freq_features AS (
SELECT
customer_id,
-- 全期間(12ヶ月)
COUNT(*) AS orders_12m,
SUM(total_amount) AS revenue_12m,
-- 直近30日
SUM(CASE WHEN order_date >= DATE '2024-09-01' THEN 1 ELSE 0 END)
AS orders_30d,
SUM(CASE WHEN order_date >= DATE '2024-09-01' THEN total_amount ELSE 0 END)
AS revenue_30d,
-- 直近90日
SUM(CASE WHEN order_date >= DATE '2024-07-01' THEN 1 ELSE 0 END)
AS orders_90d,
SUM(CASE WHEN order_date >= DATE '2024-07-01' THEN total_amount ELSE 0 END)
AS revenue_90d,
-- 購買間隔の統計
ROUND(AVG(days_since_prev_order), 1) AS avg_purchase_interval_days,
ROUND(STDDEV(days_since_prev_order), 1) AS std_purchase_interval_days,
MIN(days_since_prev_order) AS min_purchase_interval_days,
MAX(days_since_prev_order) AS max_purchase_interval_days,
-- 基準日からの最終購買経過日数(Recency)
DATE_DIFF('day', MAX(order_date), DATE '2024-09-30')
AS days_since_last_order,
-- 在籍期間
DATE_DIFF('day', MIN(order_date), DATE '2024-09-30')
AS tenure_days
FROM base_orders
GROUP BY customer_id
)
SELECT * FROM freq_features;STEP 3 ― 行動変化系の特徴量(トレンドの変化を捉える・10本)
最近の行動が過去と比べて変化しているかどうかは離脱の重要なシグナルだ。「最近の購買頻度 ÷ 過去の購買頻度」が1より小さければ買う回数が減ってきている。
-- STEP3: 行動変化系特徴量(トレンド比)
WITH monthly_orders AS (
SELECT
customer_id,
DATE_TRUNC('month', order_date) AS order_month,
COUNT(*) AS monthly_orders,
SUM(total_amount) AS monthly_revenue
FROM orders
WHERE status = 'completed'
AND order_date >= DATE '2023-10-01'
AND order_date < DATE '2024-10-01'
GROUP BY customer_id, DATE_TRUNC('month', order_date)
),
trend_features AS (
SELECT
customer_id,
-- 直近3ヶ月 vs 前3ヶ月の購買頻度比
ROUND(
NULLIF(SUM(CASE WHEN order_month >= DATE '2024-07-01' THEN monthly_orders ELSE 0 END), 0)
/ NULLIF(SUM(CASE WHEN order_month >= DATE '2024-04-01'
AND order_month < DATE '2024-07-01' THEN monthly_orders ELSE 0 END), 0)
, 4) AS order_freq_ratio_3m, -- 1より小 = 頻度が落ちている
-- 直近3ヶ月 vs 前3ヶ月の購買金額比
ROUND(
NULLIF(SUM(CASE WHEN order_month >= DATE '2024-07-01' THEN monthly_revenue ELSE 0 END), 0)
/ NULLIF(SUM(CASE WHEN order_month >= DATE '2024-04-01'
AND order_month < DATE '2024-07-01' THEN monthly_revenue ELSE 0 END), 0)
, 4) AS revenue_ratio_3m,
-- 直近6ヶ月 vs 前6ヶ月の購買頻度比
ROUND(
NULLIF(SUM(CASE WHEN order_month >= DATE '2024-04-01' THEN monthly_orders ELSE 0 END), 0)
/ NULLIF(SUM(CASE WHEN order_month < DATE '2024-04-01' THEN monthly_orders ELSE 0 END), 0)
, 4) AS order_freq_ratio_6m,
-- 購買が存在した月の数(アクティブ月数)
COUNT(DISTINCT order_month) AS active_months_12m,
-- 連続購買月数(最近の連続性)
-- 直近3ヶ月に毎月購買したか
SUM(CASE WHEN order_month >= DATE '2024-07-01' THEN 1 ELSE 0 END)
AS active_months_last_3m,
-- 平均月次注文数
ROUND(SUM(monthly_orders) * 1.0 / 12, 3) AS avg_monthly_orders_12m,
-- 直近3ヶ月の平均 vs 12ヶ月の平均(活動水準の変化)
ROUND(
NULLIF(SUM(CASE WHEN order_month >= DATE '2024-07-01' THEN monthly_orders ELSE 0 END) / 3.0, 0)
/ NULLIF(SUM(monthly_orders) / 12.0, 0)
, 4) AS recent_vs_avg_ratio,
-- 購買ゼロの月の連続数(直近からの沈黙期間)
-- 直近何ヶ月連続でゼロか(12 - 最後に購買した月からの経過月数)
12 - DATE_DIFF('month',
MAX(order_month),
DATE '2024-09-01'
) AS months_since_last_active_month
FROM monthly_orders
GROUP BY customer_id
)
SELECT * FROM trend_features;STEP 4 ― カテゴリ・商品系の特徴量(8本)
購買するカテゴリが狭まってきたり、高単価商品から低単価商品へシフトしていたりする変化は離脱の先行指標になることがある。
-- STEP4: カテゴリ・商品系特徴量
WITH category_purchase AS (
SELECT
o.customer_id,
oi.category,
COUNT(DISTINCT o.order_id) AS category_orders,
SUM(oi.quantity * oi.unit_price) AS category_revenue,
MAX(o.order_date) AS last_category_order,
CASE WHEN o.order_date >= DATE '2024-07-01' THEN 1 ELSE 0 END AS is_recent
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
WHERE o.status = 'completed'
AND o.order_date >= DATE '2023-10-01'
AND o.order_date < DATE '2024-10-01'
GROUP BY o.customer_id, oi.category,
CASE WHEN o.order_date >= DATE '2024-07-01' THEN 1 ELSE 0 END
),
category_features AS (
SELECT
customer_id,
-- カテゴリの多様性(ユニークカテゴリ数)
COUNT(DISTINCT category) AS unique_categories_12m,
-- 直近3ヶ月のユニークカテゴリ数
COUNT(DISTINCT CASE WHEN is_recent = 1 THEN category END)
AS unique_categories_3m,
-- カテゴリ集中度(最多カテゴリの割合):1に近いほど1カテゴリに集中
ROUND(
MAX(category_orders) * 1.0
/ NULLIF(SUM(category_orders), 0)
, 4) AS category_concentration,
-- メインカテゴリ(最も多く購買したカテゴリ)
MAX(CASE WHEN category_orders = MAX(category_orders) OVER (PARTITION BY customer_id)
THEN category END) AS main_category,
-- 平均単価(ユニット単価で測る)
ROUND(
SUM(category_revenue) * 1.0
/ NULLIF(SUM(category_orders), 0)
, 0) AS avg_revenue_per_order,
-- カテゴリ幅の変化:直近3ヶ月のカテゴリ数 / 全体のカテゴリ数
ROUND(
COUNT(DISTINCT CASE WHEN is_recent = 1 THEN category END) * 1.0
/ NULLIF(COUNT(DISTINCT category), 0)
, 4) AS category_breadth_ratio -- 1より小 = カテゴリが狭まっている
FROM category_purchase
GROUP BY customer_id
)
SELECT * FROM category_features;STEP 5 ― エンゲージメント系の特徴量(6本)
-- STEP5: メルマガエンゲージメント特徴量
WITH email_engagement AS (
SELECT
customer_id,
-- 直近90日の配信数
SUM(CASE WHEN delivered_at >= TIMESTAMP '2024-07-01 00:00:00' THEN 1 ELSE 0 END)
AS email_delivered_90d,
-- 直近90日の開封数
SUM(CASE WHEN delivered_at >= TIMESTAMP '2024-07-01 00:00:00'
AND is_opened = 1 THEN 1 ELSE 0 END)
AS email_opened_90d,
-- 直近90日の開封率
ROUND(
SUM(CASE WHEN delivered_at >= TIMESTAMP '2024-07-01 00:00:00'
AND is_opened = 1 THEN 1 ELSE 0 END) * 1.0
/ NULLIF(SUM(CASE WHEN delivered_at >= TIMESTAMP '2024-07-01 00:00:00'
THEN 1 ELSE 0 END), 0)
, 4) AS email_open_rate_90d,
-- 12ヶ月の平均開封率
ROUND(
SUM(CASE WHEN is_opened = 1 THEN 1 ELSE 0 END) * 1.0
/ NULLIF(COUNT(*), 0)
, 4) AS email_open_rate_12m,
-- 最後の開封からの日数
DATE_DIFF('day',
MAX(CASE WHEN is_opened = 1 THEN CAST(delivered_at AS DATE) END),
DATE '2024-09-30'
) AS days_since_last_open,
-- 開封率のトレンド(直近90日 vs 前90日)
ROUND(
NULLIF(SUM(CASE WHEN delivered_at >= TIMESTAMP '2024-07-01 00:00:00'
AND is_opened = 1 THEN 1 ELSE 0 END) * 1.0
/ NULLIF(SUM(CASE WHEN delivered_at >= TIMESTAMP '2024-07-01 00:00:00'
THEN 1 ELSE 0 END), 0), 0)
/ NULLIF(SUM(CASE WHEN delivered_at >= TIMESTAMP '2024-04-01 00:00:00'
AND delivered_at < TIMESTAMP '2024-07-01 00:00:00'
AND is_opened = 1 THEN 1 ELSE 0 END) * 1.0
/ NULLIF(SUM(CASE WHEN delivered_at >= TIMESTAMP '2024-04-01 00:00:00'
AND delivered_at < TIMESTAMP '2024-07-01 00:00:00'
THEN 1 ELSE 0 END), 0), 0)
, 4) AS open_rate_trend -- 1より小 = 開封率が落ちている
FROM mail_deliveries
WHERE delivered_at >= TIMESTAMP '2023-10-01 00:00:00'
AND delivered_at < TIMESTAMP '2024-10-01 00:00:00'
GROUP BY customer_id
)
SELECT * FROM email_engagement;STEP 6 ― 全特徴量を結合して機械学習用テーブルを一括生成する(完成版)
-- STEP6: チャーン予測用特徴量テーブル(完成版)
CREATE TABLE churn_features AS
WITH
-- 基礎データ
base_orders AS (
SELECT customer_id, order_date, total_amount,
DATE_DIFF('day',
LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date),
order_date) AS days_since_prev
FROM orders
WHERE status = 'completed'
AND order_date >= DATE '2023-10-01'
AND order_date < DATE '2024-10-01'
),
-- 購買頻度・金額系
f1 AS (
SELECT
customer_id,
COUNT(*) AS orders_12m,
SUM(total_amount) AS revenue_12m,
SUM(CASE WHEN order_date >= DATE '2024-09-01' THEN 1 ELSE 0 END) AS orders_30d,
SUM(CASE WHEN order_date >= DATE '2024-07-01' THEN 1 ELSE 0 END) AS orders_90d,
SUM(CASE WHEN order_date >= DATE '2024-07-01' THEN total_amount ELSE 0 END) AS revenue_90d,
ROUND(AVG(total_amount), 0) AS avg_order_value,
ROUND(AVG(days_since_prev), 1) AS avg_interval_days,
ROUND(STDDEV(days_since_prev), 1) AS std_interval_days,
DATE_DIFF('day', MAX(order_date), DATE '2024-09-30') AS recency_days,
DATE_DIFF('day', MIN(order_date), DATE '2024-09-30') AS tenure_days
FROM base_orders GROUP BY customer_id
),
-- 月次トレンド系
monthly AS (
SELECT customer_id,
DATE_TRUNC('month', order_date) AS order_month,
COUNT(*) AS mo, SUM(total_amount) AS mr
FROM orders WHERE status='completed'
AND order_date >= DATE '2023-10-01' AND order_date < DATE '2024-10-01'
GROUP BY customer_id, DATE_TRUNC('month', order_date)
),
f2 AS (
SELECT customer_id,
COUNT(DISTINCT order_month) AS active_months_12m,
SUM(CASE WHEN order_month >= DATE '2024-07-01' THEN 1 ELSE 0 END) AS active_months_3m,
ROUND(
NULLIF(SUM(CASE WHEN order_month >= DATE '2024-07-01' THEN mo ELSE 0 END), 0)
/ NULLIF(SUM(CASE WHEN order_month >= DATE '2024-04-01'
AND order_month < DATE '2024-07-01' THEN mo ELSE 0 END), 0)
, 4) AS order_freq_ratio_3m,
ROUND(
NULLIF(SUM(CASE WHEN order_month >= DATE '2024-07-01' THEN mr ELSE 0 END), 0)
/ NULLIF(SUM(CASE WHEN order_month >= DATE '2024-04-01'
AND order_month < DATE '2024-07-01' THEN mr ELSE 0 END), 0)
, 4) AS revenue_ratio_3m
FROM monthly GROUP BY customer_id
),
-- カテゴリ系
cat AS (
SELECT o.customer_id, oi.category,
COUNT(DISTINCT o.order_id) AS co,
MAX(CASE WHEN o.order_date >= DATE '2024-07-01' THEN 1 ELSE 0 END) AS is_recent
FROM orders o JOIN order_items oi ON o.order_id = oi.order_id
WHERE o.status='completed'
AND o.order_date >= DATE '2023-10-01' AND o.order_date < DATE '2024-10-01'
GROUP BY o.customer_id, oi.category
),
f3 AS (
SELECT customer_id,
COUNT(DISTINCT category) AS unique_categories_12m,
COUNT(DISTINCT CASE WHEN is_recent=1 THEN category END) AS unique_categories_3m,
ROUND(MAX(co)*1.0/NULLIF(SUM(co),0),4) AS category_concentration,
ROUND(COUNT(DISTINCT CASE WHEN is_recent=1 THEN category END)*1.0
/NULLIF(COUNT(DISTINCT category),0),4) AS category_breadth_ratio
FROM cat GROUP BY customer_id
),
-- メールエンゲージメント系
f4 AS (
SELECT customer_id,
ROUND(SUM(CASE WHEN is_opened=1 THEN 1 ELSE 0 END)*1.0/NULLIF(COUNT(*),0),4)
AS email_open_rate_12m,
DATE_DIFF('day',
MAX(CASE WHEN is_opened=1 THEN CAST(delivered_at AS DATE) END),
DATE '2024-09-30') AS days_since_last_open,
SUM(CASE WHEN delivered_at >= TIMESTAMP '2024-07-01 00:00:00' THEN 1 ELSE 0 END)
AS email_delivered_90d
FROM mail_deliveries
WHERE delivered_at >= TIMESTAMP '2023-10-01 00:00:00'
AND delivered_at < TIMESTAMP '2024-10-01 00:00:00'
GROUP BY customer_id
),
-- チャーンラベル
labels AS (
SELECT customer_id,
1 - MAX(CASE WHEN order_date >= DATE '2024-10-01'
AND order_date < DATE '2025-01-01' THEN 1 ELSE 0 END) AS churn_label
FROM orders WHERE status='completed'
GROUP BY customer_id
)
-- 全テーブルを LEFT JOIN で結合
SELECT
c.customer_id,
c.registered_at,
c.channel,
c.rank,
-- ラベル
COALESCE(l.churn_label, 1) AS churn_label,
-- 購買頻度・金額系(12本)
COALESCE(f1.orders_12m, 0) AS orders_12m,
COALESCE(f1.revenue_12m, 0) AS revenue_12m,
COALESCE(f1.orders_30d, 0) AS orders_30d,
COALESCE(f1.orders_90d, 0) AS orders_90d,
COALESCE(f1.revenue_90d, 0) AS revenue_90d,
COALESCE(f1.avg_order_value, 0) AS avg_order_value,
f1.avg_interval_days,
f1.std_interval_days,
f1.recency_days,
f1.tenure_days,
-- トレンド系(4本)
COALESCE(f2.active_months_12m, 0) AS active_months_12m,
COALESCE(f2.active_months_3m, 0) AS active_months_3m,
f2.order_freq_ratio_3m,
f2.revenue_ratio_3m,
-- カテゴリ系(4本)
COALESCE(f3.unique_categories_12m, 0) AS unique_categories_12m,
COALESCE(f3.unique_categories_3m, 0) AS unique_categories_3m,
f3.category_concentration,
f3.category_breadth_ratio,
-- メール系(3本)
f4.email_open_rate_12m,
f4.days_since_last_open,
COALESCE(f4.email_delivered_90d, 0) AS email_delivered_90d,
-- 派生特徴量(計算で生成)
ROUND(COALESCE(f1.revenue_12m, 0) / NULLIF(f1.orders_12m, 0), 0)
AS avg_order_value_check, -- 検証用
COALESCE(f1.orders_90d, 0) - COALESCE(f1.orders_30d, 0)
AS orders_60_to_90d, -- 30〜90日前の注文数
-- 記録日
DATE '2024-09-30' AS feature_date
FROM customers c
LEFT JOIN labels l ON c.customer_id = l.customer_id
LEFT JOIN f1 ON c.customer_id = f1.customer_id
LEFT JOIN f2 ON c.customer_id = f2.customer_id
LEFT JOIN f3 ON c.customer_id = f3.customer_id
LEFT JOIN f4 ON c.customer_id = f4.customer_id
WHERE EXISTS (
-- 観察期間に購買歴がある顧客のみ
SELECT 1 FROM orders oe
WHERE oe.customer_id = c.customer_id
AND oe.status = 'completed'
AND oe.order_date >= DATE '2023-10-01'
AND oe.order_date < DATE '2024-10-01'
);生成された特徴量の全体像
| カテゴリ | 特徴量 | 本数 |
|---|---|---|
| 購買頻度・金額系 | orders_12m, revenue_12m, orders_30d, orders_90d, revenue_90d, avg_order_value, avg_interval_days, std_interval_days, recency_days, tenure_days | 10本 |
| トレンド系 | active_months_12m, active_months_3m, order_freq_ratio_3m, revenue_ratio_3m | 4本 |
| カテゴリ系 | unique_categories_12m, unique_categories_3m, category_concentration, category_breadth_ratio | 4本 |
| メール系 | email_open_rate_12m, days_since_last_open, email_delivered_90d | 3本 |
| 属性系 | channel, rank, tenure_days(在籍期間) | 3本 |
| 合計 | 24本 |
実務での運用ヒント
① データリークを絶対に防ぐ
最も重要な注意点だ。「予測対象期間(2024-10-01以降)」のデータを特徴量に含めると、本番では取得できないデータを使った過学習になる。基準日(2024-09-30)以前のデータのみで特徴量を計算するルールを徹底する。
② 欠損値の扱い
メールが配信されていない顧客の email_open_rate_12m は NULL になる。これをそのままモデルに渡すと多くのアルゴリズムがエラーになる。COALESCE(value, 0) でゼロ補完するか、COALESCE(value, median_value) で中央値補完するかを特徴量の性質に応じて決める。
③ 特徴量の重要度で絞り込む
XGBoost や LightGBM でモデルを訓練した後、feature_importance を見て重要度が低い特徴量を削除する。通常 recency_days(最終購買からの日数)と order_freq_ratio_3m(頻度トレンド)が上位に来ることが多い。
まとめ
チャーン予測の特徴量エンジニアリングのポイントをまとめる。
- 基準日を設定し、特徴量は基準日以前・ラベルは基準日以降のデータから作る(データリーク防止)
- 3ウィンドウ(30日・90日・12ヶ月)で同じ指標を計算し、時間的なトレンドを捉える
- 比率系特徴量(直近3ヶ月÷前3ヶ月)で絶対値ではなく「変化の方向」を特徴量化する
- 購買・カテゴリ・メールなど複数の視点を組み合わせる
- 全特徴量を1本のクエリで生成し、実行日を変えるだけで定期更新できる設計にする
SQLで特徴量テーブルを整備することで、データサイエンティストはモデリングに集中でき、分析エンジニアは特徴量の定義と更新に専念できる。この役割分担が、実運用に乗るチャーン予測システムを作る鍵だ。