📊

【BTYDモデル】阿部(2020)の解説

2024/02/03に公開

0. この記事の趣旨

この記事では BTYD モデルの一種として提案された 周期的な購買行動に対応した顧客の 生涯価値の導出と顧客維持介入戦略への応用(阿部 2020) について、その数理構造を解説することを目的としています。一応 python を用いた推定の実装についても触れるつもりではありますが、私は数値最適化や計算の高速化については十分な知見がないので、ご利用の際はご注意ください。ソースコードは以下にあります。

https://github.com/BlackJack2021/Zenn_abe_2020_description

1. モデルの概要

本章では、以下の 2 点について解説したいと思います。

  • シンプルな BTYD モデルが何をしようとしているのか
  • 阿部(2020) ではどのような提案がなされているのか

本章を通じて、まずはこれらのモデルのアイデアについて理解していただければ幸いです。

1.1. BTYD モデルとは

おそらくこの記事を見ている人に BTYD モデルを全く知らない方はいらっしゃらないと思いますが、一応説明しておこうと思います。BTYD モデルとは、Buy-Till-You-Die の頭文字を取った名称で、顧客の購買行動をモデル化するために利用されるものです。最もシンプルなモデルは以下のように記述されます。

  1. 顧客には生存状態と死亡状態の 2 つがある。全ての顧客は最初の購買時点で生存状態となる。
  2. 生存状態である限り、顧客は毎日 p の確率で購入を行う。死亡状態の場合、一切購入が発生しない。
  3. 生存状態である限り、顧客は毎日 r の確率で死亡状態に移行する。一度死亡状態に入ると、その後一度も復活しない。

ただし、上記の説明は離散時間的な表現であり、実際はこれを連続時間でモデル化することが多いです。その場合、以下のように表現されます。

  1. 顧客には生存状態と死亡状態の 2 つがある。全ての顧客は最初の購買時点で生存状態となる。
  2. 生存状態において、顧客の購買の発生回数はパラメータを \lambda とするポアソン分布に従う。
  3. 生存状態が継続する期間はパラメータを \mu とする指数分布に従う。

顧客が生存状態にあるか死亡状態にあるかは、契約を伴うデータであれば明示的に観察することができます。例えばコストコを考えてみると非常にわかりやすいです。コストコは会員のみが利用できるスーパーです。会員であれば購入が発生する可能性がありますが、退会してしまった場合は購入が発生しません。そのため、会員登録されているかどうかを生存状態か死亡状態の判断に利用できます。

しかし契約を伴う購買データはそれほど一般的ではありません。そうした状況に対応するため、BTYD モデルは非契約のデータに利用することができるように設計されています。BTYD モデルでは、その時々に顧客が生存状態にあるか死亡状態にあるかを確率的に評価しながら推論を行います。

1.2. 阿部(2020) モデルについて

上記で提案したシンプルなモデルでは顧客の購買は完全にランダムに発生しています。そのため周期的に購買が発生することが予想されるデータに対しては適切に購買行動がモデル化できない可能性があります。例えばヘアサロンの利用者は完全にランダムにヘアサロンへ行くかどうかを決めているというより、おそらく髪が伸びてくる時期に合わせて、例えば概ね 1 カ月に 1 回利用するということが考えられます。そのためこうしたデータに対してこのシンプルなモデルを当てることは少し良くないと言えるでしょう。

そこで、阿部(2020)ではこうした購買の周期性を捉えるためのモデルが提案されました。このモデルでは、前回の購買発生から t 期間経過するまでに購買が発生する確率 p(t) が、パラメータを a \in \mathbb{R}, b \in \mathbb{R}^+ とするロジスティック関数により定められると仮定します。

\begin{align} p(t) = \frac{e^{-a+bt}}{1 + e^{-a+bt}} \end{align}

このようにモデル化することで、時間が経過するにしたがって購買が発生する確率が高まるモデルを構築することができます。以上が阿部モデルのシンプルなアイデアです。それではこの阿部モデルについて、より詳細にその数学構造を示します。

2. 阿部モデルの仮定

それではモデルの仮定を確認します。

2.1. 生存時間

生存時間 \tau の密度関数は単位時間の離脱率を \mu \geq 0 とする指数分布に従うと仮定します。つまり、その確率密度関数は以下のように表現できます。

\begin{align} f(\tau | \mu) = \mu e^{-\mu \tau} \end{align}

\mu = 0 の時、離脱が発生しないモデルとなります。

2.2. 購買金額

顧客が購入を意思決定した場合の購買金額 s は、対数正規分布に従うとします。すなわちパラメータを m \in \mathbb{R}^+, \omega^2 \in \mathbb{R}^+ とすればその確率密度関数は以下のように表現できます。

\begin{align} \log(s) \sim N(\log(m), \omega^2) \end{align}

2.3. 購買確率

阿部モデルの概要で説明したように、前回の購買発生から t 期間経過するまでに購買が発生する確率 p(t) が、パラメータを a \in \mathbb{R}, b \in \mathbb{R}^+ とするロジスティック関数により定められると仮定します。

\begin{align} p(t) = \frac{e^{-a+bt}}{1 + e^{-a+bt}} \equiv G(t) \end{align}

G(t) と定義しているのは、g(t) を「購買間隔 t で購買が発生する確率密度関数」と定義した場合、その累積型分布関数として解釈できるためです。g(t) は累積型分布関数の定義により以下のように求めることができます。

\begin{align} g(t) \equiv \frac{dG(t)}{dt} = b \bigl[1-G(t)\bigr]G(t) \end{align}

各個人のモデルの数理構造としては以上となります。また重要な前提として、これらの 3 つの確率変数は全て独立であると仮定します。

2.4. 各顧客の異質性について

阿部モデルではこの 3 つの確率的構造を前提として、各個人のパラメータを各個人のデータから推定します。これは自然なアプローチに思われると思いますが、実は BTYD モデルに関する先行研究では、各顧客のパラメータは全顧客に共通の何らかの確率密度関数に従って現れると仮定することも多いです。例えば、BG/NBD モデル では、各顧客の離脱率 \mu は全顧客共通のガンマ分布に従って現れると仮定します。その上で、顧客全体のデータからこのガンマ分布の推定を行うことになります。

こうした「個人のパラメータが確率分布に従って決定される」という考え方は直感的に感じられないかもしれませんが、このように設定する一つの理由は「ある顧客のパラメータの推定を、他の顧客のデータも参考にしながら行うことができる」ことにあります。実際阿部モデルのような顧客単位でデータを分けて各個人のパラメータを完全に別々に推定するアプローチは、顧客単位で十分なデータが得られない場合も多いため、注意が必要です。

3. 阿部モデルにおける尤度関数の導出

3.1. 観察変数の定義

さて、次に尤度関数の導出を行います。直前で述べたように阿部モデルでは個人のパラメータに全顧客に共通する確率分布を仮定しないため、ここで求めるべき尤度は各個人の購買履歴データからのみ計算できます。ある顧客が観察期間の打ち切りまでに n 回購買を行っており、その購買時点の情報が (y_1, y_2,...y_n) \equiv \bm{y} であり、購買金額の情報が (s_1, s_2,..., s_n) \equiv \bm{s} と表現することにします。また、阿部モデルにおいては観測が始まってから y_1 が発生するまでの情報は特に意味をなさないため、以下では y_1 = 0 と基準化します。さらに y_n の時点から観測期間の打ち切りまでに経過した時間を r とします。これはいわゆる Recency であり、最終購買時点からどれだけ時間が経過したかを表す指標です。これらの 3 つの情報 \bm{y}, \bm{s}, r の情報があればすべての顧客情報を網羅していますが、後の尤度関数の記述のために、顧客が最初に購買を行った時点から観測打ち切りまでの経過期間を T と表すことにします。すなわち T \equiv y_n + r です。

また、各個人は指数分布に従って死亡状態に移行します。この変数は観察できませんが、\tau 期間経過した時に死亡状態に移行したと表現することにします。観察期間の打ち切りまでに死亡状態に移行しない場合も考えられますが、これは \tau>T で考えればよいです。

ここからはここで用いた変数を使って、確率密度関数を計算します。詳細な導出に興味のない方は 3.4.まとめ まで飛ばしてください。

3.2. 購入金額の分離

尤度関数を記述するために、これらのデータ (\bm{y}, \bm{s}, r, T, \tau) のデータが生成される確率密度関数 f(\bm{y}, \bm{s}, r, T, \tau) を計算します。まず、条件付き確率の公式より

\begin{align} f(\bm{y}, \bm{s}, r, T, \tau) = f_{\bm{y}, r, T, \tau}(\bm{y}, r, T, \tau) \times f_{\bm{s}}(\bm{s}| \bm{y}, r, T, \tau) \end{align}

と表現できます。さてここで、すべての確率変数は独立であったことを思い出しましょう。したがって購入金額とそれ以外の情報(=購買時点の情報)は独立に決定されるため

\begin{align} f_{\bm{s}}(\bm{s}| \bm{y}, r, T, \tau) = f_{\bm{s}}(\bm{s}) \end{align}

したがって、以下のように f(\bm{y}, \bm{s}, r, T, \tau) を記述できます。

\begin{align} f(\bm{y}, \bm{s}, r, T, \tau) = f_{\bm{y}, r, T, \tau}(\bm{y}, r, T, \tau) \times f_{\bm{s}}(\bm{s}) \end{align}

この分離により、購買金額に関するパラメータ m, \omega^2 は購買金額の情報に関する確率密度 f_{\bm{s}}(\bm{s}) のみ意識して推定すればよいということが分かります。小難しいことを言っているように聞こえるかもしれませんが、購買が「いつ、どれくらいの頻度で行われたのか」ということと、その時「いくら購入されたか」ということは分けて考えてよいということです。

購入金額については対数正規分布に従うことが仮定されているため、\bm{s} = (s_1, ..., s_n) が従う同時確率密度関数は以下のように表現できます。

\begin{align} f_{\bm{s}}(\bm{s}) = \prod_{i=1}^{n} \frac{1}{\sqrt{2 \pi} \sigma s_i} \exp{ \biggl( - \frac{(\log s_i - \log(m))^2}{2 \sigma^2} \biggr) } \end{align}

3.3. 購買時点、頻度について

したがって一旦 f_{\bm{s}}(\bm{s}) は無視して f_{\bm{y}, r, T, \tau}(\bm{y}, r, T, \tau) がどのように表現されるかについて考えてみましょう。しかし \tau は観察できないため、こちらについては全確率の公式を利用し、確率密度関数から消去(つまり \tau が観察できない元での確率密度関数を計算)する必要があります。

つまり、\tau \in [0, \infty) で積分することにより \bm{y}, r, T に関する確率密度関数 f_{\bm{y}, r, T}(\bm{y}, r, T) を計算できます。これを利用して、計算を進めていきましょう。

\begin{align*} f_{\bm{y}, r, T}(\bm{y}, r, T) &= \int_{0}^{\infty}f_{\bm{y}, r, T, \tau}(\bm{y}, r, T, \tau) d\tau \\ &= \underbrace{ \int_{0}^{y_n}f_{\bm{y}, r, T, \tau}(\bm{y}, r, T, \tau) d\tau }_{\mathrm{死亡後の購買は起こりえないので\ 0}} + \int_{y_n}^{y_n + r}f_{\bm{y}, r, T, \tau}(\bm{y}, r, T, \tau) d\tau +\int_{y_n + r}^{\infty}f_{\bm{y}, r, T, \tau}(\bm{y}, r, T, \tau) d\tau \\ &= \underbrace{ \int_{y_n}^{T}f_{\bm{y}, r, T, \tau}(\bm{y}, r, T, \tau) d\tau }_{(A)} + \underbrace{ \int_{T}^{\infty}f_{\bm{y}, r, T, \tau}(\bm{y}, r, T, \tau) d\tau }_{(B)} \end{align*}

第 1 項 (A) は最後の購買時点から観測期間の打ち切りまでに死亡状態に移行した場合であり、第 2 項 (B) は観測期間の打ち切り後に死亡状態に移行した(つまり、観測期間の打ち切りまで死亡が確認されなかった)ケースです。それぞれの項について分けて計算を行いましょう。

最後まで生存しているケース

まず (B) の方から計算を行いましょう。このパターンが発生する場合、観測期間中はずっと生存していることになります。すなわち、\bm{y}, r, T が観測されるのは、以下のように記述をまとめることができます。

購買発生間隔について
y_{i+1} - y_{i} = t_i, (i=1,2,...,n-1) と定義します。この時 t_i は確率密度関数 (5) に従う確率変数です。すなわち、t_1, ..., t_{n-1} \equiv \bm{t} が従う確率密度関数 f_{\bm{t}}(\bm{t}) は以下のように表現できます。

\begin{align*} f_{\bm{t}}(\bm{t}) = g(t_1)g(t_2) \cdots g(t_{n-1}) = \prod_{i=1}^{n-1}g(t_{i}) \end{align*}

また最後の購買が発生してから観測期間の打ち切りまでの期間である r の間は購買が発生しません。ただし現在考えているケースではこの期間中ずっと生存状態にあることが仮定されているため、この期間中は「生存状態であるにも関わらず、一度も購買が発生しなかった」ことを意味します。従って (5) を用いてその確率を 1-G(r) と表現できます。

期間中の状態推移について
ここでは期間の最後まで生存していることを仮定しているので、この状態遷移が発生する確率を考えます。生存時間の確率密度関数 (2) 及びを利用すればこの確率は以下のように評価できます。

\begin{align*} \int_{T}^{\infty} \mu e^{-\mu \tau} d\tau = e^{-\mu T} \end{align*}

以上のことから、(B) について

\begin{align} (B) = \int_{T}^{\infty}f_{\bm{y}, r, T, \tau}(\bm{y}, r, T, \tau) d\tau = \biggl(\prod_{i=1}^{n-1}g(t_i)\biggr) [1-G(r)] e^{-\mu T} \end{align}

と記述できます。

観測期間の打ち切りまでのどこかで死亡状態に移行するケース

それでは次に (A) について考えてみます。

購買発生間隔について
先ほどと同じように y_{i+1} - y_{i} = t_i, (i=1,2,...,n-1) と定義します。この時 t_i は確率密度関数 (5) に従う確率変数です。すなわち、t_1, ..., t_{n-1} \equiv \bm{t} が従う確率密度関数 f_{\bm{t}}(\bm{t}) は以下のように表現できます。

\begin{align*} f_{\bm{t}}(\bm{t}) = g(t_1)g(t_2) \cdots g(t_{n-1}) = \prod_{i=1}^{n-1}g(t_{i}) \end{align*}

最終購買から死亡状態に移行する時点(\tau)まで
y_{n} から死亡状態に移行する時点 (\tau) までは、生存状態ではあるが、購入が発生しなかったことを意味します。つまりこの確率は以下のように表現できます。

\begin{align*} 1 - G(\tau - y_n) \end{align*}

状態遷移について
ちょうど \tau 期間経過した時点で死亡状態に移行することが仮定されているため、この事象はそのまま生存時間の確率密度関数を用いて \mu e^{-\mu \tau} と表現できます。

これらの全ての結果を組み合わせると、(A) は以下のように記述できます。

\begin{align*} \int_{y_n}^{T}f_{\bm{y}, r, T, \tau}(\bm{y}, r, T, \tau) d\tau &= \int_{y_n}^{T} \biggl(\prod_{i=1}^{n-1}g(t_i)\biggr) \bigl[1-G(\tau - y_n)\bigr] \mu e^{-\mu \tau} d\tau \\ &= \biggl(\prod_{i=1}^{n-1}g(t_i)\biggr) \int_{y_n}^{T}\bigl[1-G(\tau - y_n)\bigr] \mu e^{-\mu \tau} d\tau \end{align*}

ここで、x = \tau - y_n = \tau - T + r と変数変換します。すると積分区間 y_n \rightarrow T0 \rightarrow r と変換されます。また、 dx /d\tau = 1 ですので

\begin{align*} \int_{y_n}^{T}f_{\bm{y}, r, T, \tau}(\bm{y}, r, T, \tau) d\tau &= \biggl(\prod_{i=1}^{n-1}g(t_i)\biggr) \int_{0}^{r}\bigl[1-G(x)\bigr] \mu e^{-\mu (x + T - r)} dx \end{align*}

となります。最後に (4) をこの式に代入すれば (A) について

\begin{align*} (A) = \int_{y_n}^{T}f_{\bm{y}, r, T, \tau}(\bm{y}, r, T, \tau) d\tau &= \biggl(\prod_{i=1}^{n-1}g(t_i)\biggr) \int_{0}^{r} \frac{ \mu e^{-\mu (x + T - r)} }{ 1 + e^{-a + bx} } dx \\ &= \biggl(\prod_{i=1}^{n-1}g(t_i)\biggr) e^{-\mu(T-r)} \int_{0}^{r} \frac{ \mu e^{-\mu x} }{ 1 + e^{-a + bx} } dx \end{align*}

したがって

\begin{align} (A) = \int_{y_n}^{T}f_{\bm{y}, r, T, \tau}(\bm{y}, r, T, \tau) d\tau &= \biggl(\prod_{i=1}^{n-1}g(t_i)\biggr) e^{-\mu(T-r)} \int_{0}^{r} \frac{ \mu e^{-\mu x} }{ 1 + e^{-a + bx} } dx \end{align}

最終的な導出

ここまでで、(A), (B) について具体的な確率密度関数に関する表現が得られたため、購買時点、頻度に関する確率密度関数を具体的に示します。

\begin{align} f_{\bm{y}, r, T}(\bm{y}, r, T) &= \biggl(\prod_{i=1}^{n-1}g(t_i)\biggr) \biggl( [1-G(r)] e^{-\mu T} +e^{-\mu(T-r)} \int_{0}^{r} \frac{ \mu e^{-\mu x} }{ 1 + e^{-a + bx} } dx \biggr) \\ &= \biggl(\prod_{i=1}^{n-1}g(t_i)\biggr) e^{-\mu T} \biggl( [1-G(r)] + e^{\mu r} \int_{0}^{r} \frac{ \mu e^{-\mu x} }{ 1 + e^{-a + bx} } dx \biggr) \end{align}

ただし、t_i \equiv y_{i+1} - y_i,\ (i=1,2,...,n-1) です。

3.4. まとめ

以上の議論で得られた結果を簡単にまとめておきます。確率密度関数を尤度関数を適宜読み替えていることに注意してください。

4. 推定方法

阿部モデルではこの尤度関数を用いて、(一部例外もありますが)最尤推定を行います。さてまずは扱いが簡単な購買金額に関する推定を考えましょう。

4.1. 購入金額に関するパラメータの最尤推定

この関数に対する最尤推定量は解析的に解を導くことができ、以下のようになります。

\begin{align*} \widehat{\log m} = \frac{1}{n}\sum_{i=1}^{n}\log(s_i) \\ \hat{\omega}^2 = \frac{1}{n}\sum_{i=1}^{n}\biggl[ \log(s_i) - \widehat{\log m}\biggr]^2 \end{align*}

対数正規分布とは確率変数に対数を取った値が正規分布に従う分布なので、確率変数に対数を取って正規分布の最尤推定量と同じ形で表現できます。

ただし、分散の最尤推定量には一致性があるものの、不偏性がないことに注意しましょう。このことはサンプル数が十分に多い場合には良い推定量として機能するものの、サンプル数が少ない場合には推定に平均的なズレが発生してしまうことを意味します。今回は個人ごとにモデルを推定するためデータの量に限りがあるので、分散の推定量については最尤推定量ではなく不偏推定量を用いることにしましょう。

\begin{align} \widehat{\log m} = \frac{1}{n}\sum_{i=1}^{n}\log(s_i) \\ \hat{\omega}^2 = \frac{1}{n}\sum_{i=1}^{n}\biggl[ \log(s_i) - \widehat{\log m}\biggr]^2 \end{align}

4.2. 残りのパラメータ (a,b,\mu) に関する最尤推定

(a, b, \mu) に関する尤度関数 (13) を再掲します。

\begin{align*} L(a, b, \mu)= \biggl(\prod_{i=1}^{n-1}g(t_i)\biggr) e^{-\mu T} \biggl( [1-G(r)] + e^{\mu r} \int_{0}^{r} \frac{ \mu e^{-\mu x} }{ 1 + e^{-a + bx} } dx \biggr) \end{align*}

先ほどのシンプルな対数正規分布を仮定して記述された尤度関数における最尤推定とは異なり、解析的に解を解くことができません。したがって数値解析の手法を用いて近似的に解を導く必要があります。そのための準備として、まずは対数尤度を計算しましょう。

\begin{align} \log(L) = \sum_{i=1}^{n-1}\log(g(t_i)) - \mu T + \log{\biggl( [1-G(r)] + e^{\mu r} \int_{0}^{r} \frac{ \mu e^{-\mu x} }{ 1 + e^{-a + bx} } dx \biggr)} \end{align}

論文では具体的な数値解析の手法には触れられていないため、どのような手法で実施されたかを知ることはできませんでした。そのためこの記事では、良く知られた数値解析の手法を用いて推定することにします。

5. Python を用いた実装

以下の実装は Google Colaboratory を用いて行うことを想定しています。ソースコードは以下にありますので、適宜ご利用ください。

https://github.com/BlackJack2021/Zenn_abe_2020_description

5.1. データのダウンロード

論文ではヘアサロンのデータを用いて推定が実施されていましたが、このデータは取得することができなかったためここでは CDNOW データセットを利用します。CDNOW データセットは BTYD モデルの代表的な研究である、Fader et al (2005) にて利用されたデータセットです。

以下のコマンドでデータを取得・解凍しましょう。

!wget https://www.brucehardie.com/datasets/CDNOW_master.zip
!unzip CDNOW_master.zip

5.2. データの確認と前処理

続いてデータフレームの形式で読み込み、どのような情報が含まれているか確認します。

import pandas as pd
import warnings

warnings.simplefilter('ignore')

# テキストファイルを読み込む
# 区切り文字をスペースとして指定し、列名を明示的に設定
raw_df = pd.read_csv('CDNOW_master.txt', sep='\s+', names=['customer_id', 'date', 'quantity', 'price'])
df = raw_df.copy()

# 日付データを datetime 型に変更
df['date'] = pd.to_datetime(df['date'], format='%Y%m%d')

# 売上 (total_amount) 変数を作成
df['total_amount'] = df['quantity'] * df['price']

print('各データの型')
print(df.dtypes)
print('\nサンプル数')
print(len(df))
print('\n例')
print(df.head())
各データの型
customer_id              int64
date            datetime64[ns]
quantity                 int64
price                  float64
total_amount           float64
dtype: object

サンプル数
69659

例
   customer_id       date  quantity  price  total_amount
0            1 1997-01-01         1  11.77         11.77
1            2 1997-01-12         1  12.00         12.00
2            2 1997-01-12         5  77.00        385.00
3            3 1997-01-02         2  20.76         41.52
4            3 1997-03-30         2  20.76         41.52

顧客 ID, 日付、購入数量、購入価格、記述されているデータであることが分かります。データを確認すると、同一の顧客で同一の日付の購買データが複数レコード登場しているものが見受けられます。これはおそらく違う商品が購入された場合にレコードを分けて記述しているのだと推測されますが、私たちの分析で必要なのは各日付に合計いくら支払われたかという情報です。その情報を取得するための操作を行います。

agg_df = df.groupby(['customer_id', 'date'])['total_amount'].sum().reset_index()

print('各データの型')
print(agg_df.dtypes)
print('\nサンプル数')
print(len(agg_df))
print('\n例')
print(agg_df.head())
各データの型
customer_id              int64
date            datetime64[ns]
total_amount           float64
dtype: object

サンプル数
67591

例
   customer_id       date  total_amount
0            1 1997-01-01         11.77
1            2 1997-01-12        397.00
2            3 1997-01-02         41.52
3            3 1997-03-30         41.52
4            3 1997-04-02         39.08

5.3. データの選別

阿部モデルと同じようにここでは「5 回以上の購買がみられる顧客」にデータを限定します。実装は以下です。

customer_count_df = agg_df.groupby('customer_id').size().reset_index(name='count')
candidate_customers = customer_count_df[customer_count_df['count'] >= 5]['customer_id'].tolist()
print('元々のユーザー数', len(customer_count_df))
print('5回以上の購買ユーザー数', len(candidate_customers))

filtered_df = agg_df[agg_df['customer_id'].isin(candidate_customers)]
元々のユーザー数 23570
5回以上の購買ユーザー数 3835

つまり今回の推定には 23570 人のデータのうち 3835 人のデータが用いられます。

5.4. 尤度関数の記述に向けた変数の抽出

尤度関数の記述に向けて必要な変数は以下です。データセットの記述にもありますが、今回の CDNOW データセットにおける観測打ち切り時点は 1998 年 6 月 30 日です。

  1. 購買間隔日数 (t_1, t_2,...,t_{n-1})
    • ただし、t_1 \equiv y_2 - y_1, t_2 \equiv y_3 - y_2, ..., t_{n-1} \equiv y_{n} - y_{n-1}
  2. 最終購買時点から観測打ち切り時点までの経過日数(r): いわゆる Recency
    • 1998年6月30日 - y_n で計算できる
  3. 各購買における支払い金額 (s_1, s_2,..., s_n)
  4. 観測期間開始から打ち切りまでの経過時間: T

従って各顧客のデータを与えた際に、これらのデータを自動抽出するような関数を定義しましょう。

from typing import List, TypedDict

class Features(TypedDict):
    '''尤度関数の記述に必要な変数を格納する辞書の型を定義

    Args:
        intervals(List[float]): 購買間隔日数のリスト
        recency(float): 最終購買時点から観測打ち切り時点までの経過日数
        expenditures(List[float]) 各購買における支払い金額
        T(float): 観測期間開始から打ち切りまでの経過時間
    '''
    intervals: List[int]
    recency: float
    expenditures: List[float]
    T: float

class CustomerFeatures(TypedDict):
    customer_id: int
    features: Features

def extract_features(customer_id: int, customer_df: pd.DataFrame) -> CustomerFeatures:
    '''分析対象のユーザーのデータから、尤度の記述に必要な変数を取得します

    Args:
        df(pd.DataFrame): 以下の列が含まれるものです。
            customer_id: int64
            date: datetime64[ns]
            total_amount: float64
    '''

    def _extract_intervals(customer_df: pd.DataFrame) -> List[int]:
        '''各購買の間隔を計算'''
        intervals = customer_df['date'].diff().dt.days.tolist()
        # 先頭が nan になるので取り除く
        return intervals[1:]

    def _extract_recency(customer_df: pd.DataFrame) -> int:
        '''最終購買時点から観測打ち切り時点までの経過日数を計算'''
        observation_end = pd.to_datetime('1998-06-30')
        last_purchase = customer_df['date'].max()
        return (observation_end - last_purchase).days

    def _extract_expenditures(customer_df: pd.DataFrame) -> List[float]:
        '''各時点の購買金額を取得'''
        return customer_df['total_amount'].tolist()

    def _extract_T(customer_df: pd.DataFrame) -> int:
        '''最初の購買が発生してから打ち切りまでの経過時間'''
        observation_end = pd.to_datetime('1998-06-30')
        observation_start = customer_df['date'].min()
        return (observation_end - observation_start).days

    intervals = _extract_intervals(customer_df)
    recency = _extract_recency(customer_df)
    expenditures = _extract_expenditures(customer_df)
    T = _extract_T(customer_df)

    result: CustomerFeatures = {
        'customer_id': customer_id,
        'features': {
            'intervals': intervals ,
            'recency': recency,
            'expenditures': expenditures,
            'T': T
        }
    }

    return result

上記で定義した関数を利用して、顧客一人一人の特徴量を計算し、リスト形式で格納します。

from tqdm import tqdm
features_of_customers: List[CustomerFeatures] = []
for customer_id, customer_df in tqdm(filtered_df.groupby('customer_id')):
    features = extract_features(customer_id, customer_df)
    features_of_customers.append(features)

念のため、どのようなデータが取れているかいくつか確認しておきましょう。

from pprint import pprint
pprint(features_of_customers[:3])
[{'customer_id': 3,
  'features': {'T': 544,
               'expenditures': [41.52, 41.52, 39.08, 287.25, 83.84, 16.99],
               'intervals': [87.0, 3.0, 227.0, 10.0, 184.0],
               'recency': 33}},
 {'customer_id': 5,
  'features': {'T': 545,
               'expenditures': [58.66,
                                13.97,
                                116.69999999999999,
                                136.64999999999998,
                                116.13,
                                52.28,
                                56.28,
                                121.41,
                                185.84,
                                121.41,
                                112.41],
               'intervals': [13.0,
                             21.0,
                             66.0,
                             50.0,
                             16.0,
                             36.0,
                             55.0,
                             84.0,
                             4.0,
                             22.0],
               'recency': 178}},
 {'customer_id': 8,
  'features': {'T': 545,
               'expenditures': [9.77,
                                13.97,
                                135.87,
                                73.52,
                                356.0,
                                13.99,
                                48.92],
               'intervals': [43.0, 124.0, 16.0, 136.0, 39.0, 94.0],
               'recency': 93}}]

5.5. 推定の実施

さて、それでは推定を行うためのクラスを定義します。(a, b, \mu) の推定においては L-BFGS-B アルゴリズムを利用しています。L-BFGS-B アルゴリズムを用いると、パラメータの制約を考慮しながら最適化問題を解くことができます。今回のケースでは b>0, \mu \geq 0 であることが求められるため、パラメータに制約を課すことのできるこのアルゴリズムが適しています。

L-BFGS-B アルゴリズムを簡単に説明

おそらく数値最適化として最もなじみのある方法は勾配降下法かと思います。勾配降下法は一階微分の値に対し、分析者が与える学習率を掛け合わせて得られたルールに基づきパラメータを更新し続け、最適化問題を解くアプローチです。しかしこの手法では学習率の選択が非常に重要であり、不適切な設定は収束速度の低下や収束しないことにつながります。
 この問題を解決するために開発された手法が ニュートン法 です。ニュートン法では一階微分の値に加えて 2 階微分(ヘッセ行列)の値まで用いることで、勾配降下法に比べてより適切にパラメータの更新が決定されます。しかしヘッセ行列の計算は計算コストが高くなるため、近似的に計算することにより計算コストを削減するアプローチが 準ニュートン法 といいます。この準ニュートン法の一つに BFGS アルゴリズム があります。
 しかし、この BFGS アルゴリズムにおいて必要な近似ヘッセ行列は更新したいパラメータの数が増えれば増えるほど、メモリ要求量が大きくなることが問題になります。その問題を解決するためにヘッセ行列の近似の仕方を工夫したものが L-BFGS アルゴリズム です。
 最後になりますがこの L-BFGS アルゴリズム をパラメータに制約のある最適化問題に適用できる形にしたものが L-BFGS-B アルゴリズム です。

import numpy as np
from scipy.integrate import quad
from typing import Union
from scipy.optimize import minimize, Bounds

class InitialGuess(TypedDict):
    a: float
    b: float
    mu: float

class Estimation(TypedDict):
    customer_id: int
    log_m: float
    omega_sq: float
    a: Optional[float]
    b: Optional[float]
    mu: Optional[float]
    success: bool
    message: Optional[str]

class AbeModel:
    def __init__(self):
        '''各パラメータを保存するためのプロパティを作成'''
        self.a: float = 1.0
        self.b: float = 1.0
        self.mu: float = 0.1
        self.log_m: float = 1.0
        self.omega_sq: float = 1.0


    def _fit_log_m(self, expenditures: np.ndarray) -> float:
        '''log_m の最尤推定を実施'''
        return np.mean(np.log(expenditures))

    def _fit_omega_sq(self, expenditures: np.ndarray, log_m: float) -> float:
        '''omega^2 の不偏推定量を計算'''
        degree_of_freedom = len(expenditures) - 1
        return np.sum(np.square(np.log(expenditures) - log_m)) / degree_of_freedom

    def _calc_G(self, intervals: Union[np.ndarray, float]) -> Union[np.ndarray, float]:
        '''Gを計算'''
        return np.exp(-self.a + self.b * intervals) / (1 + np.exp(-self.a + self.b * intervals))

    def _calc_g(self, intervals: Union[np.ndarray, float]) -> Union[np.ndarray, float]:
        '''gを計算'''
        G = self._calc_G(intervals)
        # gの計算で0になるのを防ぐためにepsilonを追加。ここが0になると log(g) の計算が不適切になる。
        epsilon = 1e-10
        g = self.b * (1 - G) * G
        # gが非常に小さい値(epsilon以下)の場合は、epsilonを返す
        g_with_min_value = np.maximum(g, epsilon)
        return g_with_min_value

    def _llh_first_term(self, intervals: np.ndarray) -> float:
        '''$\sum_{i=1}^{n-1}\log{(g(t_i))}$ を計算'''
        log_gs = np.log(self._calc_g(intervals))
        return np.sum(log_gs)

    def _llh_second_term(self, T: float) -> float:
        return - self.mu * T

    def _integral_term(self, r: float) -> float:
        '''$\int_{0}^{r}\frac{\mu e^{-\mu x}}{1 + e^{-a+bx}}dx$ を近似計算'''
        def integrand(x: float):
            return (self.mu * np.exp(-self.mu * x)) / (1 + np.exp(-self.a + self.b * x))
        approx_value, _ = quad(integrand, 0, r)
        return approx_value

    def _llh_third_term(self, r: float) -> float:
        '''対数尤度の第三項を計算'''
        value = 1 - self._calc_G(r) + np.exp(self.mu * r) * self._integral_term(r)
        return np.log(value)

    def _calc_llh(self, intervals: np.ndarray, T: float, r: float) -> float:
        '''(a, b, mu) に関する対数尤度を計算'''
        log_likelihood = (
            self._llh_first_term(intervals) + self._llh_second_term(T) + self._llh_third_term(r)
        )
        return log_likelihood

    def _neg_llh(self, params: np.ndarray, intervals: np.ndarray, T: float, r: float) -> float:
        '''負の対数尤度関数を計算する'''
        self.a, self.b, self.mu = params  # パラメータを更新
        return -self._calc_llh(intervals, T, r)  # 対数尤度の負の値

    def fit(self, intervals: np.ndarray, expenditures: np.ndarray, T: float, r: float, initial_guess: InitialGuess) -> Estimation:
        '''最尤推定によるパラメータの推定'''
        # まずは解析解が得られるものを計算
        self.log_m = self._fit_log_m(expenditures)
        self.omega_sq = self._fit_omega_sq(expenditures, self.log_m)

        # 続いて数値解析でパラメータを推定
        initial_guess_list = [initial_guess['a'], initial_guess['b'], initial_guess['mu']]  # 初期値
        bounds = Bounds([-np.inf, 1e-10, 0], [np.inf, np.inf, np.inf])  # パラメータの最小値と最大値を指定

        result = minimize(self._neg_llh, initial_guess_list, args=(intervals, T, r), method='L-BFGS-B', bounds=bounds)
        self.a, self.b, self.mu = result.x
        return {
            'log_m': self.log_m,
            'omega_sq': self.omega_sq,
            'a': self.a,
            'b': self.b,
            'mu': self.mu,
            'success': result.success,
            'message': result.message
        }

このクラスを用いて各顧客のパラメータの推定を行いましょう。

from typing import Optional

estimations: List[Estimation] = []
for i in tqdm(range(len(features_of_customers))):
    sample = features_of_customers[i]
    customer_id = sample['customer_id']
    intervals = np.array(sample['features']['intervals'])
    expenditures = np.array(sample['features']['expenditures'])
    T = sample['features']['T']
    r = sample['features']['recency']
    initial_guess = {'a': 1, 'b': 1, 'mu': 0.1}
    model = AbeModel()
    estimation = model.fit(intervals, expenditures, T, r, initial_guess)
    estimation['customer_id'] = customer_id
    estimations.append(estimation)

estimation_df = pd.DataFrame(estimations)

これにて推定が完了しました。お疲れさまでした。

6. まとめ

ここまで非常に長かったですが、最後までお読みくださった方は有難うございます。記述に誤りがある点などございましたら教えていただけますと幸いです。

Discussion