「何人が残っているか」より「いつ離脱するか」を知る
コホート分析では「M+1に何%が購買したか」という月次継続率を追った。これは強力な手法だが、1つの情報を失っている。
「離脱したのは何ヶ月目か」という時間の情報だ。
たとえば「6ヶ月後の継続率20%」という数字があるとき、残り80%は均等に1〜6ヶ月目に離脱したのか、それとも大半が1ヶ月目に離脱してその後は緩やかなのか。この2つは全く異なる顧客像を示すが、継続率の数字だけでは区別できない。
サバイバル分析(生存時間分析)は医学・工学で発展した統計手法で、「イベント(死亡・故障・離脱)が起きるまでの時間」の分布を分析する。通販に応用すると「顧客が離脱するまでの時間の分布」を正確に把握できる。
代表的な手法が Kaplan-Meier 推定量だ。ノンパラメトリック(分布を仮定しない)なため、どんな形の離脱パターンにも対応できる。今回はこれをSQLで実装する。
サバイバル分析の2つのキーコンセプト
生存関数 S(t)
S(t) = P(T > t) = 時点 t より後まで「生存」している確率通販では「入会からt日後もまだアクティブである確率」だ。S(0) = 1.0(全員スタート時にアクティブ)で、時間とともに単調減少する。
打ち切り(Censoring)
サバイバル分析の最大の特徴は「打ち切り」を扱えることだ。
分析の基準日時点でまだ離脱していない顧客は「まだ生存している」ことは分かるが、いつ離脱するかはまだ不明だ。これを「右打ち切り」という。この顧客を「分析終了時点で離脱した」と誤分類すると生存率を過小評価し、「分析から除外する」とバイアスが生じる。打ち切りデータを正しく扱うのが Kaplan-Meier の強みだ。
「離脱」の定義
通販で「離脱」をどう定義するかは業務要件次第だ。今回は次の定義を使う。
「最終購買から180日(約6ヶ月)以上、購買が確認されない顧客を離脱とみなす」
基準日時点でまだ180日経っていない顧客は「打ち切り」として扱う。
使用するテーブル
-- orders テーブル
-- order_id : 注文ID
-- customer_id : 顧客ID
-- order_date : 注文日
-- status : 'completed' / 'cancelled'STEP 1 ― 顧客ごとの「生存時間」と「イベント発生フラグ」を計算する
Kaplan-Meier に必要な入力は各顧客について2つだ。
duration:観察した期間(登録から離脱または打ち切りまでの日数)event:その期間中に離脱イベントが発生したか(1=発生、0=打ち切り)
-- STEP1: 生存時間とイベントフラグの計算
WITH customer_orders AS (
SELECT
customer_id,
MIN(order_date) AS first_order_date,
MAX(order_date) AS last_order_date,
COUNT(order_id) AS purchase_count
FROM orders
WHERE status = 'completed'
GROUP BY customer_id
),
survival_data AS (
SELECT
customer_id,
first_order_date,
last_order_date,
purchase_count,
-- 基準日
DATE '2024-12-31' AS reference_date,
-- 最終購買からの経過日数
DATE_DIFF('day', last_order_date, DATE '2024-12-31')
AS days_since_last_order,
-- イベント(離脱)の発生判定
-- 180日以上沈黙 = 離脱イベント発生
CASE
WHEN DATE_DIFF('day', last_order_date, DATE '2024-12-31') >= 180
THEN 1 ELSE 0
END AS event_occurred,
-- 生存時間(duration)
-- イベント発生の場合:初回購買から離脱判定日まで
-- 打ち切りの場合:初回購買から基準日まで
CASE
WHEN DATE_DIFF('day', last_order_date, DATE '2024-12-31') >= 180
-- 離脱した場合、「最終購買日 + 180日」が離脱判定日
THEN DATE_DIFF('day', first_order_date,
DATE_ADD('day', 180, last_order_date))
-- 打ち切りの場合、基準日までの全期間
ELSE DATE_DIFF('day', first_order_date, DATE '2024-12-31')
END AS duration_days
FROM customer_orders
)
SELECT
customer_id,
first_order_date,
last_order_date,
purchase_count,
days_since_last_order,
event_occurred,
duration_days,
CASE WHEN event_occurred = 1 THEN '離脱' ELSE '打ち切り(継続中)' END AS status_label
FROM survival_data
ORDER BY duration_days ASC;出力イメージ
| customer_id | first_order_date | last_order_date | event_occurred | duration_days | status_label |
|---|---|---|---|---|---|
| C089 | 2024-11-15 | 2024-11-15 | 0 | 46 | 打ち切り(継続中) |
| C234 | 2024-09-01 | 2024-09-01 | 0 | 121 | 打ち切り(継続中) |
| C112 | 2022-03-10 | 2022-08-01 | 1 | 331 | 離脱 |
| C001 | 2022-08-05 | 2024-10-20 | 0 | 878 | 打ち切り(継続中) |
STEP 2 ― Kaplan-Meier 推定量をSQLで計算する
Kaplan-Meier の計算ステップは次の通りだ。
各時点 t ごとに:
n_t = その時点で「生存中」だった顧客数(リスク集合)
d_t = その時点で「離脱」した顧客数
S(t) = S(t-1) × (1 - d_t / n_t)「生存中」とは「その時点までまだ離脱していない、かつ打ち切られていない顧客」だ。
-- STEP2: Kaplan-Meier 推定量の計算
WITH customer_orders AS (
SELECT
customer_id,
MIN(order_date) AS first_order_date,
MAX(order_date) AS last_order_date
FROM orders
WHERE status = 'completed'
GROUP BY customer_id
),
survival_data AS (
SELECT
customer_id,
CASE
WHEN DATE_DIFF('day', last_order_date, DATE '2024-12-31') >= 180 THEN 1
ELSE 0
END AS event_occurred,
CASE
WHEN DATE_DIFF('day', last_order_date, DATE '2024-12-31') >= 180
THEN DATE_DIFF('day', first_order_date, DATE_ADD('day', 180, last_order_date))
ELSE DATE_DIFF('day', first_order_date, DATE '2024-12-31')
END AS duration_days
FROM customer_orders
),
-- 時点ごとのイベント数・打ち切り数を集計
-- 30日単位のビンに丸める(日次だと行数が多すぎるため)
event_table AS (
SELECT
-- 30日単位のビン
FLOOR(duration_days / 30) * 30 AS time_bin,
SUM(event_occurred) AS events, -- その期間の離脱数
COUNT(*) - SUM(event_occurred) AS censored -- その期間の打ち切り数
FROM survival_data
GROUP BY FLOOR(duration_days / 30) * 30
),
-- リスク集合の計算(各時点での「生存中」の人数)
risk_set AS (
SELECT
time_bin,
events,
censored,
-- リスク集合 = 全体 - その時点より前に離脱・打ち切りになった人数
SUM(events + censored) OVER (
ORDER BY time_bin DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS at_risk
FROM event_table
),
-- Kaplan-Meier 推定量の計算
km_estimate AS (
SELECT
time_bin,
at_risk,
events,
censored,
-- 各時点の生存確率の積(KM推定の核心)
EXP(SUM(LN(CASE WHEN at_risk > 0
THEN 1.0 - events * 1.0 / at_risk
ELSE 1.0 END))
OVER (ORDER BY time_bin ASC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
) AS survival_prob,
-- Greenwood の公式による95%信頼区間の近似
-- Var(log S(t)) ≈ Σ d_i / [n_i(n_i - d_i)]
SUM(
CASE WHEN at_risk > 1 AND events > 0
THEN events * 1.0 / (at_risk * (at_risk - events))
ELSE 0 END
) OVER (ORDER BY time_bin ASC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS greenwood_var
FROM risk_set
)
SELECT
time_bin AS days_since_first_order,
at_risk,
events AS churn_events,
censored,
ROUND(survival_prob, 4) AS survival_rate,
ROUND((1 - survival_prob) * 100, 1) AS churn_rate_pct,
-- 95%信頼区間(Greenwood公式 + 対数変換)
ROUND(GREATEST(0, survival_prob * EXP(-1.96 * SQRT(greenwood_var))), 4) AS ci_lower,
ROUND(LEAST(1, survival_prob * EXP( 1.96 * SQRT(greenwood_var))), 4) AS ci_upper
FROM km_estimate
ORDER BY time_bin;出力イメージ
| days_since_first_order | at_risk | churn_events | survival_rate | churn_rate_pct | ci_lower | ci_upper |
|---|---|---|---|---|---|---|
| 0 | 14,218 | 0 | 1.0000 | 0.0 | 1.0000 | 1.0000 |
| 30 | 14,218 | 892 | 0.9373 | 6.3 | 0.9318 | 0.9428 |
| 60 | 13,326 | 1,241 | 0.8492 | 15.1 | 0.8421 | 0.8563 |
| 90 | 12,085 | 984 | 0.7801 | 22.0 | 0.7722 | 0.7880 |
| 180 | 9,412 | 1,184 | 0.6284 | 37.2 | 0.6192 | 0.6376 |
| 365 | 5,841 | 892 | 0.5012 | 49.9 | 0.4912 | 0.5112 |
| 540 | 3,284 | 512 | 0.4198 | 58.0 | 0.4091 | 0.4305 |
| 730 | 1,841 | 284 | 0.3684 | 63.2 | 0.3568 | 0.3800 |
中央生存時間(S(t) = 0.5 になる時点)は365日前後だ。 つまり「初回購買から1年で約半数の顧客が離脱する」という実態が定量化された。
STEP 3 ― コホート別に生存曲線を比較する
「どの流入チャネルからの顧客が長く残るか」「どの初回購買カテゴリが定着率が高いか」をチャネル別に比較する。
-- STEP3: チャネル別 Kaplan-Meier 比較
WITH customer_base AS (
SELECT
o.customer_id,
c.channel,
MIN(o.order_date) AS first_order_date,
MAX(o.order_date) AS last_order_date
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
WHERE o.status = 'completed'
GROUP BY o.customer_id, c.channel
),
survival_data AS (
SELECT
customer_id,
channel,
CASE
WHEN DATE_DIFF('day', last_order_date, DATE '2024-12-31') >= 180 THEN 1
ELSE 0
END AS event_occurred,
CASE
WHEN DATE_DIFF('day', last_order_date, DATE '2024-12-31') >= 180
THEN DATE_DIFF('day', first_order_date, DATE_ADD('day', 180, last_order_date))
ELSE DATE_DIFF('day', first_order_date, DATE '2024-12-31')
END AS duration_days
FROM customer_base
),
event_table AS (
SELECT
channel,
FLOOR(duration_days / 90) * 90 AS time_bin, -- 90日単位
SUM(event_occurred) AS events,
COUNT(*) - SUM(event_occurred) AS censored
FROM survival_data
GROUP BY channel, FLOOR(duration_days / 90) * 90
),
risk_set AS (
SELECT
channel,
time_bin,
events,
SUM(events + censored) OVER (
PARTITION BY channel
ORDER BY time_bin DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS at_risk
FROM event_table
)
SELECT
channel,
time_bin AS days,
at_risk,
events,
ROUND(
EXP(SUM(LN(CASE WHEN at_risk > 0
THEN 1.0 - events * 1.0 / at_risk
ELSE 1.0 END))
OVER (PARTITION BY channel
ORDER BY time_bin ASC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW))
, 4) AS survival_rate
FROM risk_set
ORDER BY channel, time_bin;出力イメージ(180日・365日の生存率)
| channel | days=180 survival | days=365 survival | 中央生存時間の目安 |
|---|---|---|---|
| referral | 0.7412 | 0.5892 | 390日以上 |
| organic | 0.6841 | 0.5124 | 370日前後 |
| paid | 0.5921 | 0.4012 | 290日前後 |
| social | 0.5124 | 0.3412 | 240日前後 |
紹介(referral)経由の顧客は180日後も74%が生存しており、有料広告(paid)の59%・SNS(social)の51%と比べて明らかに離脱が遅い。この差は顧客獲得コスト(CPA)の評価に直結する。
STEP 4 ― ハザード率(離脱の瞬間的な確率)を計算する
生存曲線の「傾き」にあたるのがハザード率だ。「この時点で生存している顧客が、次の期間に離脱する確率」を示す。
-- STEP4: ハザード率の計算(どの時点で最も離脱リスクが高いか)
WITH km_data AS (
-- STEP2の集計済みテーブルを使う(ここでは簡略化)
SELECT
time_bin,
at_risk,
events,
ROUND(
EXP(SUM(LN(CASE WHEN at_risk > 0
THEN 1.0 - events * 1.0 / at_risk
ELSE 1.0 END))
OVER (ORDER BY time_bin ASC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW))
, 4) AS survival_rate
FROM (
-- STEP2のrisk_set相当の集計
SELECT
FLOOR(duration_days / 30) * 30 AS time_bin,
SUM(events + censored) OVER (ORDER BY FLOOR(duration_days / 30) * 30 DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS at_risk,
SUM(event_occurred) AS events,
COUNT(*) - SUM(event_occurred) AS censored
FROM (
SELECT
CASE WHEN DATE_DIFF('day', MAX(order_date), DATE '2024-12-31') >= 180 THEN 1 ELSE 0 END AS event_occurred,
CASE WHEN DATE_DIFF('day', MAX(order_date), DATE '2024-12-31') >= 180
THEN DATE_DIFF('day', MIN(order_date), DATE_ADD('day', 180, MAX(order_date)))
ELSE DATE_DIFF('day', MIN(order_date), DATE '2024-12-31') END AS duration_days
FROM orders WHERE status='completed' GROUP BY customer_id
)
GROUP BY FLOOR(duration_days / 30) * 30
)
)
SELECT
time_bin AS days,
at_risk,
events,
survival_rate,
-- ハザード率 = 各時点での離脱数 / リスク集合
ROUND(events * 1.0 / NULLIF(at_risk, 0), 4) AS hazard_rate,
-- 前の期間との比較
LAG(events * 1.0 / NULLIF(at_risk, 0)) OVER (ORDER BY time_bin) AS prev_hazard
FROM km_data
ORDER BY time_bin;出力イメージ
| days | hazard_rate | 解釈 |
|---|---|---|
| 0〜30 | 0.0628 | 最初の1ヶ月:初期離脱が多い |
| 30〜60 | 0.0931 | 1〜2ヶ月:ハザードがピーク |
| 60〜90 | 0.0815 | 2〜3ヶ月:やや落ち着く |
| 90〜180 | 0.0312 | 3〜6ヶ月:安定化 |
| 180〜365 | 0.0198 | 6ヶ月〜1年:ベースライン |
| 365以上 | 0.0124 | 1年超:長期顧客は安定 |
ハザード率が最も高いのは「1〜2ヶ月目」だ。2回目購買を促すフォローアップ施策をこの時期に集中させることが、最大のリテンション効果をもたらす。
実務での運用ヒント
① 「離脱」の定義は慎重に決める
180日という閾値は一例だ。商材の購買サイクルが短い(週次・月次)なら30〜60日、長い(季節商材・年一回)なら365日を使うケースもある。No.2(購買間隔の分布分析)でその顧客基盤の標準的な購買間隔を先に把握したうえで、p90(90パーセンタイル)の間隔を「離脱の閾値」として使う設計が理論的に整合している。
② 30日単位のビンへの丸め
厳密な KM 推定は「イベントが発生した正確な日付」ごとに計算するが、それでは出力が数百行になり解釈が難しい。月次(30日)や四半期(90日)でビン化することで可読性を保つ。精度よりも「形の把握」を優先する実務では十分だ。
③ ログランク検定でコホートの差を統計的に検証する
STEP3でチャネル別の生存曲線を比較したが、「この差は統計的に有意か」をSQLだけで検証するのは難しい。Python の lifelines ライブラリの logrank_test 関数を使うと、2つのコホートの生存曲線の差が偶然かどうかを検定できる。SQL で集計したデータを Python に渡して検定するのが現実的なフローだ。
まとめ
サバイバル分析の SQL 実装をまとめる。
- 顧客ごとに「生存時間(duration_days)」と「イベントフラグ(event_occurred)」を計算する
- 打ち切り(基準日時点でまだ離脱していない顧客)を正しく区別して扱う
- 時点ごとの「リスク集合(at_risk)」を
SUM() OVER (ORDER BY time DESC)の累積和で計算する EXP(SUM(LN(...)) OVER (...))で Kaplan-Meier 推定量(積の累積)を計算する- Greenwood 公式で 95% 信頼区間を追加し、結果の信頼性を示す
コホート分析が「どの月に何%が購買したか」を追うのに対し、サバイバル分析は「どの時点で何%が離脱したか、その傾きはどうか」を追う。ハザード率の山が「初回購買後1〜2ヶ月目」にあると分かれば、施策を投入すべき時期が自ずと決まる。これがデータドリブンなリテンション戦略の出発点だ。