SQLのみ算出する『生存曲線』

<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-W20HS30ZWQ"></script>
<meta name="google-site-verification" content="pPkugEbMfvBeRwyMBy51tS_iY8KCEKj7uMOFkpVPiPk" />
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());

  gtag('config', 'G-W20HS30ZWQ');
</script>


![SQL最強！.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1170793/b17d33d3-0f7d-92f3-0683-ac8b276e4d64.png)


:::note warn
コピペで利用して頂けることを目指しつつも、お手元のデータに適用するためには、当記事で紹介させて頂くサンプルクエリを参考に、お使い頂く環境に応じてSQLを一部手直し頂く必要があるため、『SQLを少し読める方』向けの内容です。
:::

:::note warn
SQLの実行環境は、『Redshift』を想定しています。
Redshift以外の環境ではクエリの手直しが必要になる場合がありますのでご了承ください。
:::

## はじめに
こんにちは、@N1_Data_Ninjya_U です。
普段はニフティ株式会社でData Ninjaとして、分析などのデータ活用の仕事をしています。

![niftylogo_4c.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1170793/815a083d-58ad-673f-8892-ac7bbf253fe3.png)

<details><summary>N1!Data Ninjaについて詳しくはこちら</summary>

>ニフティでは、ニフティで「いちばん」その分野に詳しい人、もしくは、いずれ「いちばん」になりうる人、という意味を込めて、N1!（NIFTY No.1）と命名し、職種を問わず、様々な分野で活躍している社員の活躍を後押しするための制度があります。
>
>私は、そのN1!制度の中で”データを使って色々分析や活用”という分野でN1!を頂き、
>N1!Data Ninjaという肩書で仕事をしております。
>
>※Data Ninjaとは、アメリカの一部で流行っている肩書で、
一般的には
・データアナリスト
・データサイエンティスト
・データエンジニア　
などを指す肩書ですが、弊社では
”データを使って色々分析や活用をする人”のような意味で使われています。
>
>[他のN1!メンバーのご紹介はこちら](https://recruit.nifty.co.jp/workplace/initiative/n1/)

</details>


本記事では、データ分析において、同じような悩みを抱える方の助けとなればと思い、
データ分析に関する小ネタテクニックをご紹介させて頂きます。

少しでもお悩み解決の助けとなれば幸いです。

また、ニフティでは一緒に働いて頂ける仲間を募集しております！
もし本記事をきっかけにニフティに興味を持って頂けましたら、
<font color="red">就職先・転職先の候補の1つとして、ニフティを選択肢に入れて頂ければ幸いです。</font>

https://recruit.nifty.co.jp/jobs/

---

## 『AとBどちらの属性の方が、どれくらいよりサービスを使い続けて頂けるのか？』にSQLだけて応える！


『AとBどちらの属性の方が、どれくらいよりサービスを使い続けて頂けるのか？』
サブスクリプション系のサービスを展開されている方ですと、
こんな問いを受ける機会が多いのではないでしょうか？

上記の疑問に対して回答を出すのに便利なのが『生存曲線』です。

![生存曲線イメージ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1170793/3eff22c6-84d6-814e-f1e1-6e4558680d32.png)



本記事では、下記のようなデータから、SQLのみで生存曲線を算出する方法をご紹介させて頂きます。

|id |class1| class2 |start_date | end_date |
|:-:|:-:   |:-:     |:-:        |:-:       |
|001|女性  |20代    |2020-01-03 |2022-09-12|
|002|男性  |30代    |1998-02-21 |2020-11-01|
|003|女性  |30代    |2023-03-15 |  null    |


<dl>※各カラムの説明
  <dt>id</dt>
  <dd>プライマリーキー</dd>
  <dt>class1</dt>
  <dd>属性1</dd>
  <dt>class2</dt>
  <dd>属性2</dd>
  <dt>start_date</dt>
  <dd>開始日</dd>
  <dt>end_date</dt>
  <dd>終了日</dd>
</dl>


## なぜSQLのみで算出する必要があるのか？
生存曲線を算出する方法はBIツール・Pythonを使うなどいくつもあります。

但し、データを扱う方の中で『**一番ポピュラーで使える方が多い**』『**実行環境の準備も比較的簡単**』なのがSQLかと思います。

そのSQLのみで完結して算出できることで、
『**便利**』かつ『**関係者への算出方法の共有が楽になる**』ことが期待できるため、そこにSQLのみで完結させることの価値があると考えます。


## 『生存曲線』と『平均月次解約率』・『想定平均利用月数』を算出するSQLクエリ

SQLのみで生存曲線を求めるクエリはこちらです。



:::note warn
『平均月次解約率』と『想定平均利用月数』は生存曲線が、生存曲線が『等比数列』で近似できる(減衰がほぼ一定のペース)場合を前提としてしています。
算出された生存曲線の形を見た上で、『平均月次解約率』と『想定平均利用月数』がそうか使えるか使えるか判断してください。
:::


:::note info
**お手元のデータの仕様に合わせて、サンプルクエリより下記の点を書き変えて頂く必要があります。**
- [ ] ```/*集計粒度(ここから)*/　/*集計粒度(ここまで)*/```　という記述が10箇所ありますのデータの仕様に合わせて書きかえてください。(※1)


- [ ] ```(120)/*サービス提供期間上限設定値(月数)*/```の120の部分を任意の値に変更してください。(※2)


- [ ] 必要に応じて　```サンプル数" >= 300```　の値を変更しください。
:::
>(※1)
サンプルで『class1×class2』としていますが、　データによって集計粒度が異なると思いますので、お手元のデータに合わせて書きかえて頂く必要があります。

>(※2)
例）20年にしたい場合は　```((240)/*サービス提供期間上限設定値(月数)*/```　としてください。
『想定平均利用月数』の計算に影響します。<font color="red">『想定平均利用月数』を使わない場合は変更不要です。</font>
解約率が低いデータの場合、100年以上など凄く長く続けてくださる飛び値的な方に引っ張られた想定平均値になってしまいます。その平均は最後の1人なるまでサービスを提供し続ける前提での値となってしまい現実的ではなく、過度長大き目な値になってしまうため、サービスを提供し続けたとしても長くて〇〇ヵ月までだろうという値を定める必要があります。



### <font color="red">**『生存曲線』と『平均月次解約率』・『想定平均利用月数』を算出するSQLクエリ**</font>
```ruby:SQL
WITH
base_tb01 AS 
(
	SELECT
		/*集計粒度(ここから)*/
		NVL(class1,'null')                                                                                                                                                   AS class1,
		NVL(class2,'null')                                                                                                                                                   AS class2,
		/*集計粒度(ここまで)*/
		
		DATEDIFF(month,start_date,
										CASE WHEN DATE_TRUNC('month',end_date)<DATE_TRUNC('month',CONVERT_TIMEZONE('Asia/Tokyo',GETDATE()))/*クエリ実行月*/ THEN end_date
										     ELSE DATE_TRUNC('month',DATEADD('month',-1,CONVERT_TIMEZONE('Asia/Tokyo',GETDATE()))) /*クエリ実行月前月*/ END)                 AS num_month,
		COUNT(id)                                                                                                                                                      AS cnt,
		COUNT(CASE WHEN DATE_TRUNC('month',end_date)<DATE_TRUNC('month',CONVERT_TIMEZONE('Asia/Tokyo',GETDATE()))/*クエリ実行月*/ THEN id ELSE null END)               AS cnt_end	
		
	FROM
		table /*←参照先のテーブル名に書き変えてください*/
	WHERE
		start_date IS NOT NULL /*利用開始したデータだけに限定*/
		AND
		DATE_TRUNC('month',start_date)<DATE_TRUNC('month',CONVERT_TIMEZONE('Asia/Tokyo',GETDATE()))/*クエリ実行月*/ /*利用開始から1ヵ月以上経過していないデータを除外*/
		AND
		start_date <= NVL(end_date,'9999-12-31')/*終了日が開始日より前になっているデータを除外*/
		
		/*以下任意に絞り込み条件を設定(※絞り込みが不要な場合は、下記2行を削除してください。)*/
		AND
		class1 IN ('男性','女性')
		
	GROUP BY
		/*集計粒度(ここから)*/
		class1,
		class2,
		/*集計粒度(ここまで)*/
		
		num_month
 )



SELECT
	*,
	-("累積生存率"^(1::FLOAT/("利用月数"+1)))+1                                                                         AS "月次平均解約率",
	CASE WHEN "月次平均解約率"=0 THEN 120/*サービス提供期間上限設定値(月数)*/
	     ELSE ((1 - "月次平均解約率" )^(120)/*サービス提供期間上限設定値(月数)*/-1)::FLOAT/((1-"月次平均解約率")-1) END AS "想定平均利用月数"
FROM
	/*累積生存率をWindow関数で計算--------------------------------------------------------------------------------STR*/
	(
	SELECT
		/*集計粒度(ここから)*/
		class1,
		class2,
		/*集計粒度(ここまで)*/
		
		num_month   AS "利用月数",
		cnt_r       AS "サンプル数",
		cnt_end_r   AS "その月利用月数での解約数",
		
		/*LN(0)対策*/CASE WHEN cnt_close_r = cnt_r THEN 0 ELSE
		/*総積の計算方法の参考ページ https://qiita.com/moriokumura/items/2ba7eed139b34636a939*/
		EXP(SUM(LN(1-(/*LN(0)対策*/CASE WHEN cnt_close_r = cnt_r THEN 0 ELSE cnt_close_r END::FLOAT/cnt_r))) OVER(PARTITION BY /*集計粒度(ここから)*/class1,class2/*集計粒度(ここまで)*/ ORDER BY num_month ROWS UNBOUNDED PRECEDING)) END AS "累積生存率"
	FROM
		(
			SELECT
				/*集計粒度(ここから)*/
				list.class1,
				list.class2,
				/*集計粒度(ここまで)*/
				
				list.num_month,
				
				SUM(cnt) OVER(PARTITION BY /*集計粒度(ここから)*/list.class1,list.class2/*集計粒度(ここまで)*/ ORDER BY list.num_month DESC ROWS UNBOUNDED PRECEDING) AS cnt_r,
				NVL(cnt_end,0) AS cnt_end_r
			FROM
				/*集計粒度×利用月数のリストを作成する--------------------------------------------------------------------------------STR*/
				(
					SELECT
						/*集計粒度(ここから)*/
						class1,
						class2,
						/*集計粒度(ここまで)*/
						
						num_month
					FROM
						(
							SELECT
								/*集計粒度(ここから)*/
								class1,
								class2,
								/*集計粒度(ここまで)*/
								
								MAX(num_month) AS max_num_month
							FROM
								base_tb01
							GROUP BY
								/*集計粒度(ここから)*/
								class1,
								class2
								/*集計粒度(ここまで)*/
						 )
					CROSS JOIN
						(SELECT ROW_NUMBER() OVER ()-1 as num_month FROM tb02 /*←1000レコード以上ある適当なテーブルを指定してください。*/ LIMIT 1000)
					WHERE
					    num_month <= max_num_month
				 ) AS list
				/*集計粒度×利用月数のリストを作成する--------------------------------------------------------------------------------END*/
			LEFT JOIN
				base_tb01
				ON  list.num_month = base_tb01.num_month
					AND				
					/*集計粒度(ここから)*/
					list.class1 = base_tb01.class1
					AND
					list.class2           = base_tb01.class2
					/*集計粒度(ここまで)*/
		 )

	 )
	/*累積生存率をWindow関数で計算--------------------------------------------------------------------------------END*/
	
WHERE
	/*サンプル数が一定数以上あるデータのみに限定 (※300程度以上のサンプル数に絞ることをオススメしますが、データ量などが少ない場合は、精度は下がりますが100など任意の値に変更して頂いて問題ありません。)*/ 
	"サンプル数" >= 300
```



出力結果は下記のようになります。
![結果イメージ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1170793/ff1ee851-79e1-ca82-258b-e58c6c841bc2.png)

利用月数をX軸，累積生存率をY軸とすると、このような生存曲線グラフが生存曲線が完成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1170793/1a133b34-5927-a037-ae75-9d0cc046224c.png)

## おわりに
ぜひお手元のデータで試してみてください。

## 筆者紹介
ニフティ株式会社でData Ninjaとして、分析などのデータ活用の仕事をしています。

当社についてご興味あれば、以下のリンクをご覧下さい。
- [ニフティの採用サイト](https://recruit.nifty.co.jp/jobs/)
- [ニフティの「N1!制度」とは](https://recruit.nifty.co.jp/workplace/initiative/n1/)	

![niftylogo_4c.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1170793/815a083d-58ad-673f-8892-ac7bbf253fe3.png)