100本ノック / 確率統計 / 確率・統計マーケティング応用100本ノック
製造業の顧客分析をPythonで実践|RFM・離反・CLV・Uplift 10本ノック
既存顧客を「売上」から「関係性」へ読み替える:産業部品メーカーの顧客戦略10本ノック
本記事では、架空の産業部品メーカー「数理精機」の2年間の受注履歴と販促実験を題材に、顧客の優先順位付け、継続率の把握、施策効果の検証、将来価値の予測、クロスセルを一つの意思決定プロセスとして扱います。No.051〜No.060を通じて、集計値を眺めるだけの営業管理から、顧客ごとに「次に何をするか」を選べる顧客戦略へ進む方法を確認します。
[!NOTE] 本資料は、数理工房 (もしくは代表である和山個人) が過去に企業研修において使用した notebook を企業様の許可を得て再構成・編集のうえ公開しています。
掲載データはすべて架空のものであり、実在する企業・工場・数値とは一切関係ありません。
はじめに:この記事で扱う製造業の実務課題
製造業のマーケティングは、広告だけを意味しません。代理店、装置メーカー、保全部門などとの長い取引を維持し、適切な時期に補修品や関連部材を提案する活動も重要です。しかし「売上上位だから訪問する」「失注してから離反に気づく」「キャンペーン反応者だけを成功と数える」という運用では、営業工数や値引き原資を有効に配分できません。
ここでは、受注履歴を顧客単位・獲得時期単位・注文単位へ読み替え、記述統計、確率モデル、機械学習、ランダム化比較試験を使って、営業・マーケティング・生産計画が共有できる判断材料を作ります。
現場でよくある状況
- 基幹システムには受注履歴があるが、顧客の優先順位が担当者の経験に依存している
- 新規顧客数は追っていても、獲得月別の定着率を見ていない
- 展示会後のメールや技術相談会を全顧客へ一斉配信している
- 受注が止まった顧客と、購買周期が長いだけの顧客を区別できない
- 製品別売上は把握しているが、同時購買や次に提案すべき製品が分からない
- 施策後の購入率が高くても、もともと買う顧客だった可能性を除けない
なぜこの問題は判断が難しいのか
法人顧客の購買間隔は不均一で、顧客ごとの規模や設備更新周期も異なります。このため、平均購入回数だけでは「休眠」と「通常の長い間隔」が混ざります。また、施策の対象選定と効果測定を同じデータで行うと、購買意欲の高い顧客を選んだ効果を施策効果と誤認しがちです。
必要なのは、目的に応じて分析単位を分けることです。顧客単位ではRFM・離反・CLV、獲得月単位ではコホート、注文単位ではバスケット、施策単位ではA/BテストとUpliftを使います。各指標は「高いほど良い」という評価ではなく、どの顧客へ、どの接点で、どの費用まで使うかを決めるために利用します。
今回扱うノックの全体像
| No. | テーマ | 主な意思決定 |
|---|---|---|
| 051 | RFM分析 | 営業フォローの優先順位 |
| 052 | コホート分析 | 獲得施策・オンボーディングの改善 |
| 053 | A/Bテスト | 施策を全社展開するか |
| 054 | 顧客離反分析 | 休眠予兆を誰に対応するか |
| 055 | Pareto/NBD | 顧客間の購買頻度差を確率で捉える |
| 056 | BG/NBD | 購買停止を含めて生存確率を推定する |
| 057 | CLV | 顧客ごとの投資上限を決める |
| 058 | マーケットバスケット分析 | クロスセルの組み合わせを発見する |
| 059 | レコメンドシステム | 顧客別の次の商品候補を作る |
| 060 | Uplift Modeling | 施策で行動が変わる顧客を選ぶ |
Python 環境の準備
numpy、pandas、scipy、scikit-learn、matplotlibを使用します。乱数生成器は固定 seed で初期化し、何度実行しても同じ架空データと結果が得られるようにします。
import itertools
import warnings
import japanize_matplotlib
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from scipy import stats
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import RocCurveDisplay, roc_auc_score
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.model_selection import train_test_split
warnings.filterwarnings("ignore")
pd.set_option("display.max_columns", 20)
pd.set_option("display.float_format", lambda x: f"{x:,.3f}")
SEED = 202507
rng = np.random.default_rng(SEED)
ANALYSIS_END = pd.Timestamp("2025-12-31")
print("分析基準日:", ANALYSIS_END.date(), " / seed:", SEED)
分析基準日: 2025-12-31 / seed: 202507
架空データの作成
600社の顧客マスタ、2024年1月〜2025年12月の受注ヘッダー、注文明細を生成します。顧客セグメントにより購買頻度・単価・休眠しやすさが異なり、注文内では一部の製品カテゴリが同時に選ばれやすい設定です。これは分析手順を説明するための架空データであり、モデル性能を保証するベンチマークではありません。
n_customers = 600
segments = np.array(["装置メーカー", "代理店", "保全部門", "研究開発"])
segment = rng.choice(segments, n_customers, p=[0.30, 0.25, 0.30, 0.15])
cohort_month = rng.integers(0, 12, n_customers)
customers = pd.DataFrame({
"customer_id": [f"C{i:04d}" for i in range(1, n_customers + 1)],
"segment": segment,
"cohort": pd.Timestamp("2024-01-01") + pd.to_timedelta(cohort_month * 30.44, unit="D"),
})
customers["cohort"] = customers["cohort"].dt.to_period("M").dt.to_timestamp()
rate_map = {"装置メーカー": 0.75, "代理店": 1.05, "保全部門": 0.52, "研究開発": 0.32}
amount_map = {"装置メーカー": 320_000, "代理店": 210_000, "保全部門": 145_000, "研究開発": 110_000}
product_names = np.array(["軸受", "シール", "潤滑剤", "センサー", "制御部品", "保全キット"])
base_product_p = np.array([0.23, 0.18, 0.14, 0.17, 0.14, 0.14])
orders_data, lines_data = [], []
order_no = 1
for row in customers.itertuples(index=False):
latent = rng.gamma(shape=1.8, scale=0.7)
dropout_month = rng.choice(np.arange(8, 30), p=np.repeat(1 / 22, 22))
for month in pd.date_range(row.cohort, ANALYSIS_END, freq="MS"):
age = (month.year - row.cohort.year) * 12 + month.month - row.cohort.month
if age >= dropout_month:
break
n_orders = rng.poisson(rate_map[row.segment] * latent)
for _ in range(n_orders):
order_id = f"O{order_no:06d}"
date = month + pd.Timedelta(days=int(rng.integers(0, 27)))
amount = max(25_000, rng.lognormal(np.log(amount_map[row.segment]), 0.45))
orders_data.append((order_id, row.customer_id, date, amount, amount * rng.uniform(0.24, 0.38)))
n_items = int(rng.integers(1, 4))
items = list(rng.choice(product_names, size=n_items, replace=False, p=base_product_p))
if "軸受" in items and "潤滑剤" not in items and rng.random() < 0.42:
items.append("潤滑剤")
if "センサー" in items and "制御部品" not in items and rng.random() < 0.38:
items.append("制御部品")
weights = rng.dirichlet(np.ones(len(items)))
for item, weight in zip(items, weights):
lines_data.append((order_id, row.customer_id, date, item, amount * weight))
order_no += 1
orders = pd.DataFrame(orders_data, columns=["order_id", "customer_id", "order_date", "sales", "gross_profit"])
order_lines = pd.DataFrame(lines_data, columns=["order_id", "customer_id", "order_date", "product", "line_sales"])
customers = customers.merge(orders.groupby("customer_id")["order_date"].min().rename("first_order"), on="customer_id", how="left")
print(f"顧客数: {len(customers):,}社 / 注文数: {len(orders):,}件 / 明細数: {len(order_lines):,}行")
display(orders.head())
display(order_lines.head())
顧客数: 600社 / 注文数: 8,440件 / 明細数: 18,942行
| order_id | customer_id | order_date | sales | gross_profit | |
|---|---|---|---|---|---|
| 0 | O000001 | C0001 | 2024-11-20 | 186,215.307 | 48,156.255 |
| 1 | O000002 | C0001 | 2024-11-22 | 271,079.760 | 100,379.747 |
| 2 | O000003 | C0001 | 2024-12-19 | 397,117.855 | 104,551.397 |
| 3 | O000004 | C0001 | 2025-01-24 | 288,861.018 | 88,361.531 |
| 4 | O000005 | C0001 | 2025-01-12 | 140,191.611 | 41,564.577 |
| order_id | customer_id | order_date | product | line_sales | |
|---|---|---|---|---|---|
| 0 | O000001 | C0001 | 2024-11-20 | センサー | 186,215.307 |
| 1 | O000002 | C0001 | 2024-11-22 | シール | 271,079.760 |
| 2 | O000003 | C0001 | 2024-12-19 | 潤滑剤 | 72,430.084 |
| 3 | O000003 | C0001 | 2024-12-19 | 軸受 | 224,157.931 |
| 4 | O000003 | C0001 | 2024-12-19 | シール | 100,529.840 |
No.051:RFM分析 — 営業フォローの優先順位を可視化する
実務での意味
RFMは、最終購買からの日数(Recency)、購買回数(Frequency)、購買金額(Monetary)で顧客を整理します。製造業では、重点顧客の抽出だけでなく、最近注文が止まった大口顧客を早期に見つける用途が実務的です。
分析・モデル化の考え方
顧客 (i) の基準日を (t_0) とすると、(R_i=t_0-\max(t_{ij}))、(F_i=\sum_j 1)、(M_i=\sum_j y_{ij}) です。各値を五分位スコアに変換します。Recencyは小さいほど高得点にする点に注意します。スコアは相対評価なので、事業部や商流が異なる場合は母集団を分けます。
Pythonで確認する
rfm = orders.groupby("customer_id").agg(
recency=("order_date", lambda s: (ANALYSIS_END - s.max()).days),
frequency=("order_id", "nunique"),
monetary=("sales", "sum"),
).reset_index()
rfm["R"] = pd.qcut(rfm["recency"].rank(method="first"), 5, labels=[5, 4, 3, 2, 1]).astype(int)
rfm["F"] = pd.qcut(rfm["frequency"].rank(method="first"), 5, labels=[1, 2, 3, 4, 5]).astype(int)
rfm["M"] = pd.qcut(rfm["monetary"].rank(method="first"), 5, labels=[1, 2, 3, 4, 5]).astype(int)
rfm["rfm_score"] = rfm[["R", "F", "M"]].sum(axis=1)
rfm["action"] = np.select(
[(rfm["R"] >= 4) & (rfm["F"] >= 4), (rfm["R"] <= 2) & (rfm["M"] >= 4)],
["関係維持・追加提案", "休眠防止の優先確認"], default="標準フォロー")
display(rfm.sort_values("rfm_score", ascending=False).head(8))
summary_rfm = rfm.groupby("action").agg(顧客数=("customer_id", "size"), 平均最終購買日数=("recency", "mean"), 売上合計=("monetary", "sum"))
display(summary_rfm)
fig, ax = plt.subplots(figsize=(8, 5))
for label, group in rfm.groupby("action"):
ax.scatter(group["recency"], group["monetary"] / 1e6, s=18 + group["frequency"] * 1.5, alpha=0.55, label=label)
ax.set_title("RFMによる顧客ポートフォリオ")
ax.set_xlabel("最終購買からの日数(小さいほど最近)")
ax.set_ylabel("累計売上(百万円)")
ax.grid(True, alpha=0.3)
ax.legend()
plt.tight_layout()
plt.show()
| customer_id | recency | frequency | monetary | R | F | M | rfm_score | action | |
|---|---|---|---|---|---|---|---|---|---|
| 419 | C0433 | 13 | 33 | 6,467,242.980 | 5 | 5 | 5 | 15 | 関係維持・追加提案 |
| 160 | C0164 | 9 | 27 | 5,765,718.503 | 5 | 5 | 5 | 15 | 関係維持・追加提案 |
| 32 | C0034 | 10 | 54 | 13,266,440.442 | 5 | 5 | 5 | 15 | 関係維持・追加提案 |
| 480 | C0496 | 8 | 92 | 20,833,182.841 | 5 | 5 | 5 | 15 | 関係維持・追加提案 |
| 229 | C0237 | 22 | 31 | 12,553,838.924 | 5 | 5 | 5 | 15 | 関係維持・追加提案 |
| 490 | C0506 | 8 | 34 | 11,936,063.720 | 5 | 5 | 5 | 15 | 関係維持・追加提案 |
| 207 | C0215 | 6 | 28 | 6,283,201.950 | 5 | 5 | 5 | 15 | 関係維持・追加提案 |
| 560 | C0581 | 6 | 44 | 10,243,495.304 | 5 | 5 | 5 | 15 | 関係維持・追加提案 |
| 顧客数 | 平均最終購買日数 | 売上合計 | |
|---|---|---|---|
| action | |||
| 休眠防止の優先確認 | 54 | 282.037 | 356,696,374.013 |
| 標準フォロー | 376 | 191.694 | 676,451,584.568 |
| 関係維持・追加提案 | 150 | 24.980 | 1,052,182,288.200 |

結果の読み取り
右上には「過去の売上は大きいが最近買っていない」顧客が現れます。この群は一律値引きではなく、設備停止、担当変更、競合切替など受注停止理由の確認を優先すべきです。一方、左側で頻度も高い顧客は、欠品防止や年間契約など関係維持策の対象になります。RFMだけで因果や将来価値は分からないため、以降の分析と組み合わせます。
No.052:コホート分析 — 獲得後の定着を同じ時間軸で比べる
実務での意味
月別売上が伸びていても、新規獲得の増加が既存顧客の離脱を覆い隠している場合があります。初回受注月をそろえて継続率を見ると、展示会、代理店紹介、Web問い合わせなど獲得施策の「長く取引につながる質」を評価できます。
分析・モデル化の考え方
顧客 (i) の初回購買月を (c_i)、購買月を (t) とし、経過月 (a=t-c_i) を計算します。コホート (c) の継続率は、(Retention_{c,a}=N_{c,a}/N_{c,0}) です。観測期間末に近いコホートは右側が未観測なので、ゼロで埋めず欠損として扱います。
Pythonで確認する
cohort_base = orders.assign(order_month=orders["order_date"].dt.to_period("M").dt.to_timestamp())
first_month = cohort_base.groupby("customer_id")["order_month"].min().rename("cohort")
cohort_base = cohort_base.join(first_month, on="customer_id")
cohort_base["age"] = ((cohort_base["order_month"].dt.year - cohort_base["cohort"].dt.year) * 12 + cohort_base["order_month"].dt.month - cohort_base["cohort"].dt.month)
cohort_counts = cohort_base.groupby(["cohort", "age"])["customer_id"].nunique().unstack()
retention = cohort_counts.div(cohort_counts[0], axis=0)
display((retention.iloc[:, :13] * 100).round(1))
fig, ax = plt.subplots(figsize=(9, 5))
for cohort, row in retention.iloc[:, :13].iterrows():
ax.plot(row.index, row.values * 100, marker="o", alpha=0.55, label=cohort.strftime("%Y-%m"))
ax.set_title("初回受注月別の顧客継続率")
ax.set_xlabel("初回受注からの経過月")
ax.set_ylabel("当月購入率(%)")
ax.grid(True, alpha=0.3)
ax.legend(ncol=3, fontsize=8)
plt.tight_layout()
plt.show()
| age | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| cohort | |||||||||||||
| 2024-01-01 | 100.000 | 72.500 | 68.600 | 66.700 | 68.600 | 74.500 | 62.700 | 68.600 | 68.600 | 56.900 | 64.700 | 51.000 | 51.000 |
| 2024-02-01 | 100.000 | 50.000 | 55.600 | 44.400 | 50.000 | 66.700 | 72.200 | 38.900 | 27.800 | 38.900 | 33.300 | 38.900 | 38.900 |
| 2024-03-01 | 100.000 | 63.200 | 60.500 | 65.800 | 63.200 | 57.900 | 60.500 | 57.900 | 63.200 | 60.500 | 42.100 | 34.200 | 34.200 |
| 2024-04-01 | 100.000 | 43.800 | 62.500 | 59.400 | 56.200 | 53.100 | 53.100 | 50.000 | 59.400 | 40.600 | 46.900 | 59.400 | 40.600 |
| 2024-05-01 | 100.000 | 58.300 | 61.100 | 69.400 | 52.800 | 55.600 | 69.400 | 66.700 | 63.900 | 69.400 | 58.300 | 55.600 | 55.600 |
| 2024-06-01 | 100.000 | 57.800 | 57.800 | 53.300 | 57.800 | 62.200 | 57.800 | 42.200 | 60.000 | 46.700 | 60.000 | 51.100 | 46.700 |
| 2024-07-01 | 100.000 | 58.500 | 39.000 | 51.200 | 51.200 | 58.500 | 46.300 | 46.300 | 31.700 | 48.800 | 29.300 | 31.700 | 34.100 |
| 2024-08-01 | 100.000 | 58.200 | 59.500 | 54.400 | 64.600 | 55.700 | 60.800 | 62.000 | 57.000 | 48.100 | 50.600 | 44.300 | 44.300 |
| 2024-09-01 | 100.000 | 42.900 | 44.400 | 52.400 | 38.100 | 47.600 | 49.200 | 49.200 | 44.400 | 34.900 | 41.300 | 27.000 | 33.300 |
| 2024-10-01 | 100.000 | 48.100 | 57.700 | 44.200 | 50.000 | 46.200 | 44.200 | 40.400 | 42.300 | 42.300 | 34.600 | 40.400 | 30.800 |
| 2024-11-01 | 100.000 | 41.500 | 47.200 | 50.900 | 41.500 | 47.200 | 45.300 | 49.100 | 37.700 | 35.800 | 35.800 | 35.800 | 28.300 |
| 2024-12-01 | 100.000 | 41.700 | 33.300 | 41.700 | 45.800 | 41.700 | 37.500 | 45.800 | 29.200 | 25.000 | 33.300 | 25.000 | 29.200 |
| 2025-01-01 | 100.000 | 41.200 | 29.400 | 52.900 | 23.500 | 29.400 | 17.600 | 23.500 | 29.400 | 35.300 | 23.500 | 29.400 | NaN |
| 2025-02-01 | 100.000 | NaN | 16.700 | 16.700 | 16.700 | 50.000 | 16.700 | 16.700 | NaN | 16.700 | NaN | NaN | NaN |
| 2025-03-01 | 100.000 | 18.200 | 9.100 | 27.300 | NaN | 9.100 | 27.300 | 18.200 | 18.200 | NaN | NaN | NaN | NaN |
| 2025-04-01 | 100.000 | NaN | NaN | NaN | 33.300 | NaN | 66.700 | 33.300 | 33.300 | NaN | NaN | NaN | NaN |
| 2025-05-01 | 100.000 | NaN | NaN | NaN | 50.000 | 50.000 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 2025-06-01 | 100.000 | NaN | 50.000 | NaN | 50.000 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 2025-07-01 | 100.000 | NaN | 50.000 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 2025-08-01 | 100.000 | NaN | NaN | 33.300 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 2025-09-01 | 100.000 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 2025-11-01 | 100.000 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |

結果の読み取り
初回月の100%から翌月に大きく低下するのは、毎月購買しないB2B商材では自然です。重要なのは同じ経過月でコホート間を比較することです。たとえば6か月目の定着が弱い獲得月があれば、その時期の獲得チャネル、初回購入製品、技術フォローの有無を追加で確認します。累積再購入率も併記すると、購買周期が長い商材で誤解しにくくなります。
No.053:A/Bテスト — 技術相談会の純粋な効果を測る
実務での意味
施策参加者の購入率だけを見ると、もともと関心が高い顧客を選んだ影響が混ざります。対象候補を無作為に介入群と対照群へ分けるA/Bテストは、技術相談会やメール文面を全社展開する前の投資判断に使えます。
分析・モデル化の考え方
介入群と対照群の購入率差 (hat{p}_1-hat{p}_0) を推定し、二標本比率の標準誤差から95%信頼区間を作ります。帰無仮説 (H_0:p_1=p_0) を検定します。ただし、統計的有意性だけでなく、粗利増分が施策費を上回るかを同時に評価します。
Pythonで確認する
ab_rng = np.random.default_rng(SEED + 1)
n_ab = 1200
ab = pd.DataFrame({"treatment": ab_rng.binomial(1, 0.5, n_ab)})
ab["purchase"] = ab_rng.binomial(1, 0.105 + 0.038 * ab["treatment"])
rates = ab.groupby("treatment")["purchase"].agg(["mean", "sum", "count"])
p0, p1 = rates.loc[0, "mean"], rates.loc[1, "mean"]
diff = p1 - p0
se = np.sqrt(p0 * (1 - p0) / rates.loc[0, "count"] + p1 * (1 - p1) / rates.loc[1, "count"])
ci = (diff - 1.96 * se, diff + 1.96 * se)
z = diff / se
p_value = 2 * stats.norm.sf(abs(z))
display(rates.rename(index={0: "対照群", 1: "介入群"}))
print(f"購入率差: {diff:.1%} / 95%CI: [{ci[0]:.1%}, {ci[1]:.1%}] / p値: {p_value:.4f}")
fig, ax = plt.subplots(figsize=(7, 4))
ax.bar(["対照群", "技術相談会案内群"], [p0 * 100, p1 * 100], color=["#7f8c8d", "#2874a6"])
ax.errorbar(1, p1 * 100, yerr=1.96 * np.sqrt(p1 * (1-p1)/rates.loc[1, "count"]) * 100, color="black", capsize=5)
ax.set_title("A/Bテストの購入率比較")
ax.set_xlabel("割付群")
ax.set_ylabel("購入率(%)")
ax.grid(True, axis="y", alpha=0.3)
plt.tight_layout()
plt.show()
| mean | sum | count | |
|---|---|---|---|
| treatment | |||
| 対照群 | 0.121 | 71 | 586 |
| 介入群 | 0.168 | 103 | 614 |
購入率差: 4.7% / 95%CI: [0.7%, 8.6%] / p値: 0.0212

結果の読み取り
点推定では介入群の購入率が高くても、信頼区間がゼロをまたぐ場合は「今回の標本だけでは増分を断定できない」と読みます。有意差の有無だけで打ち切らず、期待増分粗利、相談会運営費、必要標本数を確認し、追加実験か限定展開かを決めます。途中結果を見て都合よく終了すると第1種過誤が増えるため、判定時点は事前に決めます。
No.054:顧客離反分析 — 受注停止の予兆を優先順位に変える
実務での意味
離反分析は「誰が離反しそうか」を当てるだけでなく、営業が限られた時間で確認すべき顧客を絞る仕組みです。定義は商材周期に合わせ、ここでは基準日後120日間に注文がない状態を離反とします。
分析・モデル化の考え方
2025年8月末までの購買履歴からRecency、Frequency、平均単価、取引月数を作り、その後120日の離反を目的変数にします。ロジスティック回帰で確率を推定し、ROC-AUCで順位付け能力を確認します。実運用では時系列で訓練・検証を分け、未来情報の混入を避けます。
Pythonで確認する
cutoff = pd.Timestamp("2025-08-31")
hist = orders[orders["order_date"] <= cutoff]
future = orders[(orders["order_date"] > cutoff) & (orders["order_date"] <= cutoff + pd.Timedelta(days=120))]
churn = hist.groupby("customer_id").agg(
recency=("order_date", lambda s: (cutoff - s.max()).days),
frequency=("order_id", "nunique"),
avg_sales=("sales", "mean"),
active_months=("order_date", lambda s: s.dt.to_period("M").nunique()),
).reset_index()
churn["churn"] = (~churn["customer_id"].isin(future["customer_id"])).astype(int)
features = ["recency", "frequency", "avg_sales", "active_months"]
X_train, X_test, y_train, y_test = train_test_split(churn[features], churn["churn"], test_size=0.3, random_state=SEED, stratify=churn["churn"])
churn_model = LogisticRegression(max_iter=2000).fit(X_train, y_train)
pred = churn_model.predict_proba(X_test)[:, 1]
print(f"離反率: {churn['churn'].mean():.1%} / テストROC-AUC: {roc_auc_score(y_test, pred):.3f}")
coef = pd.Series(churn_model.coef_[0], index=features, name="係数")
display(coef.to_frame())
fig, ax = plt.subplots(figsize=(6, 5))
RocCurveDisplay.from_predictions(y_test, pred, ax=ax, name="離反モデル")
ax.set_title("顧客離反モデルのROC曲線")
ax.set_xlabel("偽陽性率")
ax.set_ylabel("真陽性率")
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
離反率: 47.4% / テストROC-AUC: 0.760
| 係数 | |
|---|---|
| recency | 0.017 |
| frequency | 0.014 |
| avg_sales | -0.000 |
| active_months | -0.038 |

結果の読み取り
ROC-AUCは、無作為に選んだ離反顧客を継続顧客より高リスクに並べられる確率として解釈できます。係数の符号からはRecency増加が離反方向に働く傾向を確認できます。ただし、予測確率が高くても介入で戻るとは限りません。上位顧客の粗利、対応可能件数、誤検知コストを使って連絡対象の閾値を決めます。
No.055:Pareto/NBD — 購買頻度のばらつきを確率分布で捉える
実務での意味
少数の顧客が何度も購入し、多数の顧客は少回数という形はB2Bでも一般的です。平均回数だけで予算を作ると、高頻度顧客の影響を全顧客へ誤って広げます。Pareto/NBDは購買発生と離脱を分け、顧客基盤全体の将来購買を考える枠組みです。
分析・モデル化の考え方
Pareto/NBDでは、活動中の購買が顧客固有率 (lambda) のPoisson過程、(lambda) がGamma分布、離脱時点が指数分布、離脱率も顧客間でGamma分布に従うと仮定します。ここでは第一段階としてPoisson-Gamma混合から得られる負の二項分布をモーメント法で当て、購買異質性を確認します。これは完全なPareto/NBD推定ではなく、その購買回数成分の診断です。
Pythonで確認する
purchase_counts = orders.groupby("customer_id")["order_id"].nunique().reindex(customers["customer_id"], fill_value=0)
mean_x, var_x = purchase_counts.mean(), purchase_counts.var()
shape_r = mean_x ** 2 / max(var_x - mean_x, 1e-9)
prob_p = shape_r / (shape_r + mean_x)
x = np.arange(0, int(purchase_counts.quantile(0.98)) + 1)
observed = purchase_counts.value_counts(normalize=True).reindex(x, fill_value=0)
fitted = stats.nbinom.pmf(x, shape_r, prob_p)
print(f"平均: {mean_x:.2f}回 / 分散: {var_x:.2f} / Gamma形状相当: {shape_r:.2f}")
display(pd.DataFrame({"購買回数": x[:10], "実測構成比": observed.values[:10], "NBD推定構成比": fitted[:10]}))
fig, ax = plt.subplots(figsize=(8, 5))
ax.bar(x, observed.values, alpha=0.55, label="実測")
ax.plot(x, fitted, color="#c0392b", marker="o", label="負の二項分布")
ax.set_title("顧客別購買回数分布とNBD近似")
ax.set_xlabel("2年間の購買回数")
ax.set_ylabel("顧客構成比")
ax.grid(True, axis="y", alpha=0.3)
ax.legend()
plt.tight_layout()
plt.show()
平均: 14.07回 / 分散: 177.36 / Gamma形状相当: 1.21
| 購買回数 | 実測構成比 | NBD推定構成比 | |
|---|---|---|---|
| 0 | 0 | 0.033 | 0.046 |
| 1 | 1 | 0.042 | 0.052 |
| 2 | 2 | 0.063 | 0.053 |
| 3 | 3 | 0.065 | 0.052 |
| 4 | 4 | 0.045 | 0.050 |
| 5 | 5 | 0.045 | 0.048 |
| 6 | 6 | 0.048 | 0.046 |
| 7 | 7 | 0.028 | 0.044 |
| 8 | 8 | 0.053 | 0.041 |
| 9 | 9 | 0.043 | 0.039 |

結果の読み取り
分散が平均を大きく上回る過分散は、顧客ごとの購買率が均一でないことを示します。負の二項分布が裾の長さを概ね表せれば、「平均顧客」だけで計画する危険を可視化できます。実務でPareto/NBDを採用する際は、観測窓の長さ、契約顧客の扱い、季節性、離脱定義を検証し、保留期間で将来購入数の再現性を確認します。
No.056:BG/NBD — 最近買っていない顧客の生存確率を推定する
実務での意味
同じ「6か月未購入」でも、過去に毎月買っていた顧客と年1回の顧客では意味が違います。BG/NBDは購買頻度、最終購買時点、観測期間を組み合わせ、まだ取引可能な状態である確率を表します。
分析・モデル化の考え方
BG/NBDでは購買率にGamma分布、各購買後の離脱確率にBeta分布を仮定します。反復購入回数を (x)、初回から最終購買までを (t_x)、初回から観測末までを (T) とすると、(x>0) の生存確率は次で表せます。
ここでは挙動を明確にするため例示パラメータを使います。本番では最尤推定し、時系列の保留期間で較正します。
Pythonで確認する
bg = orders.groupby("customer_id").agg(first=("order_date", "min"), last=("order_date", "max"), n=("order_id", "nunique")).reset_index()
bg["x"] = (bg["n"] - 1).clip(lower=0)
bg["t_x"] = (bg["last"] - bg["first"]).dt.days / 30.44
bg["T"] = (ANALYSIS_END - bg["first"]).dt.days / 30.44
r, alpha, a, b = 0.9, 5.0, 1.4, 3.8
ratio = (alpha + bg["T"]) / (alpha + bg["t_x"])
bg["p_alive"] = np.where(bg["x"] > 0, 1 / (1 + a / (b + bg["x"] - 1) * ratio ** (r + bg["x"])), 1.0)
display(bg.sort_values("p_alive").head(10)[["customer_id", "x", "t_x", "T", "p_alive"]])
fig, ax = plt.subplots(figsize=(8, 5))
scatter = ax.scatter((ANALYSIS_END - bg["last"]).dt.days, bg["p_alive"], c=bg["x"], cmap="viridis", alpha=0.65)
ax.set_title("最終購買からの日数とBG/NBD生存確率")
ax.set_xlabel("最終購買からの日数")
ax.set_ylabel("活動中である推定確率")
ax.grid(True, alpha=0.3)
fig.colorbar(scatter, ax=ax, label="反復購買回数 x")
plt.tight_layout()
plt.show()
| customer_id | x | t_x | T | p_alive | |
|---|---|---|---|---|---|
| 292 | C0301 | 73 | 15.637 | 23.982 | 0.000 |
| 272 | C0280 | 31 | 7.884 | 21.649 | 0.000 |
| 77 | C0080 | 39 | 10.710 | 21.813 | 0.000 |
| 27 | C0029 | 33 | 10.480 | 23.817 | 0.000 |
| 435 | C0449 | 62 | 15.670 | 23.982 | 0.000 |
| 391 | C0405 | 67 | 16.491 | 23.719 | 0.000 |
| 575 | C0596 | 28 | 9.823 | 23.259 | 0.000 |
| 502 | C0519 | 24 | 8.706 | 20.861 | 0.000 |
| 202 | C0210 | 22 | 9.658 | 23.949 | 0.000 |
| 374 | C0386 | 60 | 17.181 | 23.555 | 0.000 |

結果の読み取り
最終購買からの日数が同じでも、反復購買回数と観測期間により生存確率が異なります。営業リストでは、RFMの休眠候補にこの確率を加え、「過去価値は高いが生存確率が急低下した顧客」を優先できます。契約終了が明示される事業や定期購入では仮定が合わないことがあるため、ルールベースや別の生存時間モデルと比較します。
No.057:CLV — 将来粗利から顧客投資の上限を考える
実務での意味
売上が同じでも、粗利率、継続確率、対応コストが違えば顧客価値は異なります。CLV(Customer Lifetime Value)は、訪問、技術支援、値引きなどの投資額を将来粗利と整合させるための指標です。
分析・モデル化の考え方
簡易的に今後 (H) 期間の期待粗利を (CLV_i=\sum_{h=1}^{H} E[N_{ih}]\bar{m}_i/(1+d)^h-C_i) とします。ここではBG/NBD生存確率、過去の年率購買回数、平均注文粗利を組み合わせ、1年間の期待粗利を算出します。税、固定費、獲得費は含めないため「粗利ベースの暫定CLV」です。
Pythonで確認する
value = orders.groupby("customer_id").agg(total_gp=("gross_profit", "sum"), avg_gp=("gross_profit", "mean"), orders=("order_id", "nunique"), first=("order_date", "min"))
value["observed_years"] = ((ANALYSIS_END - value["first"]).dt.days / 365.25).clip(lower=0.25)
value["annual_rate"] = value["orders"] / value["observed_years"]
value = value.join(bg.set_index("customer_id")["p_alive"])
value["expected_orders_12m"] = value["annual_rate"] * value["p_alive"]
value["clv_12m"] = value["expected_orders_12m"] * value["avg_gp"] / 1.05
display(value.sort_values("clv_12m", ascending=False).head(10)[["p_alive", "annual_rate", "avg_gp", "expected_orders_12m", "clv_12m"]])
fig, ax = plt.subplots(figsize=(8, 5))
ax.scatter(value["total_gp"] / 1e6, value["clv_12m"] / 1e6, alpha=0.5)
ax.set_title("過去累計粗利と今後12か月CLV")
ax.set_xlabel("過去累計粗利(百万円)")
ax.set_ylabel("今後12か月の期待粗利(百万円)")
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
| p_alive | annual_rate | avg_gp | expected_orders_12m | clv_12m | |
|---|---|---|---|---|---|
| customer_id | |||||
| C0496 | 0.956 | 66.541 | 70,636.072 | 63.614 | 4,279,497.524 |
| C0285 | 0.968 | 48.737 | 71,746.488 | 47.177 | 3,223,577.292 |
| C0264 | 0.851 | 33.652 | 105,616.127 | 28.638 | 2,880,606.246 |
| C0464 | 0.967 | 38.614 | 76,228.042 | 37.348 | 2,711,395.895 |
| C0016 | 0.946 | 40.264 | 72,934.324 | 38.098 | 2,646,327.135 |
| C0155 | 0.873 | 26.256 | 119,761.607 | 22.910 | 2,613,120.129 |
| C0488 | 0.950 | 40.526 | 71,052.960 | 38.515 | 2,606,289.993 |
| C0163 | 0.906 | 25.483 | 116,543.478 | 23.075 | 2,561,135.158 |
| C0506 | 0.943 | 25.605 | 109,558.505 | 24.155 | 2,520,416.992 |
| C0591 | 0.949 | 24.532 | 110,053.506 | 23.287 | 2,440,755.352 |

結果の読み取り
過去粗利が大きくても生存確率が低ければ、将来CLVは低くなります。逆に取引歴が短くても高頻度・高粗利なら育成対象です。CLVは顧客を切り捨てる点数ではなく、サービス水準や獲得費の上限を設計する情報です。本番では製品別粗利、返品、営業工数、契約更新、予測不確実性を反映し、単一値ではなく範囲で共有します。
No.058:マーケットバスケット分析 — 同時購買から提案の組み合わせを見つける
実務での意味
注文内で一緒に買われる部品を把握すると、見積時の付属品提案、保全キットの設計、EC画面の導線改善に使えます。単純な同時件数だけでは人気商品同士が上位になるため、liftで偶然以上の結び付きを見ます。
分析・モデル化の考え方
商品 (A,B) に対し、supportは (P(A\cap B))、confidenceは (P(B|A))、liftは (P(A\cap B)/(P(A)P(B))) です。liftが1を超えると独立の場合より同時購入が多いことを示します。ただし、因果関係や提案による増分を表す指標ではありません。
Pythonで確認する
basket = pd.crosstab(order_lines["order_id"], order_lines["product"]).gt(0)
rules = []
for a, b in itertools.permutations(basket.columns, 2):
support_a = basket[a].mean()
support_b = basket[b].mean()
support_ab = (basket[a] & basket[b]).mean()
confidence = support_ab / support_a
lift = confidence / support_b
rules.append((a, b, support_ab, confidence, lift))
rules = pd.DataFrame(rules, columns=["前件", "後件", "support", "confidence", "lift"])
top_rules = rules.query("support >= 0.03").sort_values(["lift", "support"], ascending=False).head(10)
display(top_rules)
fig, ax = plt.subplots(figsize=(8, 5))
plot_rules = top_rules.sort_values("lift")
labels = plot_rules["前件"] + " → " + plot_rules["後件"]
ax.barh(labels, plot_rules["lift"], color="#2e86c1")
ax.axvline(1.0, color="black", linestyle="--", linewidth=1)
ax.set_title("同時購買ルールのLift")
ax.set_xlabel("Lift(1より大きいほど偶然以上の併買)")
ax.set_ylabel("関連ルール")
ax.grid(True, axis="x", alpha=0.3)
plt.tight_layout()
plt.show()
| 前件 | 後件 | support | confidence | lift | |
|---|---|---|---|---|---|
| 7 | センサー | 制御部品 | 0.177 | 0.526 | 1.334 |
| 16 | 制御部品 | センサー | 0.177 | 0.448 | 1.334 |
| 29 | 軸受 | 潤滑剤 | 0.247 | 0.572 | 1.315 |
| 24 | 潤滑剤 | 軸受 | 0.247 | 0.568 | 1.315 |
| 4 | シール | 軸受 | 0.133 | 0.373 | 0.862 |
| 25 | 軸受 | シール | 0.133 | 0.307 | 0.862 |
| 8 | センサー | 潤滑剤 | 0.124 | 0.369 | 0.848 |
| 21 | 潤滑剤 | センサー | 0.124 | 0.285 | 0.848 |
| 19 | 制御部品 | 軸受 | 0.143 | 0.363 | 0.841 |
| 28 | 軸受 | 制御部品 | 0.143 | 0.332 | 0.841 |

結果の読み取り
「軸受→潤滑剤」「センサー→制御部品」のような高liftルールは、見積チェックやセット提案の仮説になります。confidenceには方向性があり、AからBとBからAで値が異なります。低supportの偶然や、製品ライン・季節・代理店在庫による見せかけを避けるため、期間別の再現性を確認し、小規模な提案実験で増分粗利を測ります。
No.059:レコメンドシステム — 顧客別に次の商品候補を作る
実務での意味
バスケット分析は全体の組み合わせを示しますが、レコメンドは顧客の購買履歴に応じて候補を変えます。営業支援では自動送信よりも、担当者が提案理由を確認できる「次善の商品候補」として使うと導入しやすくなります。
分析・モデル化の考え方
顧客×商品の購買回数行列 (X) を作り、商品ベクトル間のコサイン類似度 (s_{jk}=x_j^Tx_k/(|x_j||x_k|)) を計算します。顧客が購入済みの商品との類似度を足し上げ、未購入商品を順位付けします。人気偏重、コールドスタート、説明可能性が主要な運用課題です。
Pythonで確認する
customer_item = pd.crosstab(order_lines["customer_id"], order_lines["product"])
similarity = pd.DataFrame(cosine_similarity(customer_item.T), index=customer_item.columns, columns=customer_item.columns)
similarity = similarity.mask(np.eye(len(similarity), dtype=bool), 0.0)
def recommend(customer_id, n=3):
history = customer_item.loc[customer_id]
scores = similarity.dot(np.log1p(history))
scores[history > 0] = -np.inf
return scores.nlargest(n)
product_variety = customer_item.gt(0).sum(axis=1)
candidate_customers = product_variety[product_variety.between(1, 3)].index
target_customer = candidate_customers[len(candidate_customers) // 2]
history = customer_item.loc[target_customer]
result = pd.DataFrame({"購入回数": history[history > 0], "区分": "購入済み"})
recs = recommend(target_customer)
print("対象顧客:", target_customer)
display(result)
display(recs.rename("推薦スコア").to_frame())
fig, ax = plt.subplots(figsize=(7, 5))
im = ax.imshow(similarity, cmap="Blues", vmin=0, vmax=1)
ax.set_xticks(range(len(similarity)), similarity.columns, rotation=45, ha="right")
ax.set_yticks(range(len(similarity)), similarity.index)
ax.set_title("商品間コサイン類似度")
ax.set_xlabel("商品カテゴリ")
ax.set_ylabel("商品カテゴリ")
ax.grid(False)
fig.colorbar(im, ax=ax, label="類似度")
plt.tight_layout()
plt.show()
対象顧客: C0250
| 購入回数 | 区分 | |
|---|---|---|
| product | ||
| シール | 1 | 購入済み |
| センサー | 2 | 購入済み |
| 保全キット | 1 | 購入済み |
| 推薦スコア | |
|---|---|
| product | |
| 制御部品 | 2.336 |
| 軸受 | 2.327 |
| 潤滑剤 | 2.320 |

結果の読み取り
推薦結果は「似た購買行動の顧客が併用している未購入カテゴリ」を示す候補です。互換性、既設設備、代理店契約、在庫、法規制を無視して自動提案してはいけません。担当者の採否理由を記録すれば、推薦精度だけでなく、商談化率・増分粗利・不適切提案率を改善する学習データになります。
No.060:Uplift Modeling — 施策で行動が変わる顧客を見つける
実務での意味
購入確率が高い顧客へ施策を集中しても、その顧客は施策なしで買ったかもしれません。Uplift Modelingは、介入した場合としない場合の反応差を顧客ごとに推定し、限られた営業工数を「働きかけで行動が変わる層」へ配分します。
分析・モデル化の考え方
顧客特徴量 (X) に対し、個別効果を ( au(X)=P(Y=1|T=1,X)-P(Y=1|T=0,X)) と定義します。ここではランダム割付データを介入群・対照群別の2つのロジスティック回帰(T-learner)で学習します。個人単位の因果効果は観測できないため、順位別の実測増分で妥当性を評価します。
Pythonで確認する
up_rng = np.random.default_rng(SEED + 2)
n_up = 5000
uplift_data = pd.DataFrame({
"recency": up_rng.gamma(2.2, 55, n_up),
"frequency": up_rng.poisson(5, n_up),
"margin": up_rng.lognormal(np.log(60_000), 0.55, n_up),
"treatment": up_rng.binomial(1, 0.5, n_up),
})
base_logit = -2.4 - 0.006 * uplift_data["recency"] + 0.10 * uplift_data["frequency"]
true_uplift_logit = 0.9 * ((uplift_data["recency"] > 45) & (uplift_data["recency"] < 170)) - 0.35 * (uplift_data["frequency"] >= 8)
p = 1 / (1 + np.exp(-(base_logit + uplift_data["treatment"] * true_uplift_logit)))
uplift_data["purchase"] = up_rng.binomial(1, p)
features_up = ["recency", "frequency", "margin"]
model_t = LogisticRegression(max_iter=2000).fit(uplift_data.loc[uplift_data.treatment == 1, features_up], uplift_data.loc[uplift_data.treatment == 1, "purchase"])
model_c = LogisticRegression(max_iter=2000).fit(uplift_data.loc[uplift_data.treatment == 0, features_up], uplift_data.loc[uplift_data.treatment == 0, "purchase"])
uplift_data["uplift_score"] = model_t.predict_proba(uplift_data[features_up])[:, 1] - model_c.predict_proba(uplift_data[features_up])[:, 1]
uplift_data["decile"] = pd.qcut(uplift_data["uplift_score"].rank(method="first"), 10, labels=False)
evaluation = uplift_data.groupby(["decile", "treatment"])["purchase"].mean().unstack()
evaluation["observed_uplift"] = evaluation[1] - evaluation[0]
evaluation = evaluation.sort_index(ascending=False)
display(evaluation.rename(columns={0: "対照群購入率", 1: "介入群購入率", "observed_uplift": "実測Uplift"}))
fig, ax = plt.subplots(figsize=(8, 5))
ax.bar(range(1, 11), evaluation["observed_uplift"] * 100, color=np.where(evaluation["observed_uplift"] >= 0, "#2874a6", "#c0392b"))
ax.axhline(0, color="black", linewidth=1)
ax.set_title("予測Uplift順位別の実測購入率差")
ax.set_xlabel("Uplift順位グループ(1が高スコア)")
ax.set_ylabel("介入群-対照群の購入率差(ポイント)")
ax.grid(True, axis="y", alpha=0.3)
plt.tight_layout()
plt.show()
| treatment | 対照群購入率 | 介入群購入率 | 実測Uplift |
|---|---|---|---|
| decile | |||
| 9 | 0.074 | 0.189 | 0.115 |
| 8 | 0.048 | 0.152 | 0.104 |
| 7 | 0.055 | 0.137 | 0.083 |
| 6 | 0.094 | 0.125 | 0.031 |
| 5 | 0.061 | 0.117 | 0.055 |
| 4 | 0.060 | 0.098 | 0.038 |
| 3 | 0.096 | 0.125 | 0.029 |
| 2 | 0.076 | 0.134 | 0.058 |
| 1 | 0.048 | 0.111 | 0.063 |
| 0 | 0.017 | 0.041 | 0.024 |

結果の読み取り
高スコア群で実測購入率差が大きければ、全顧客配信より対象を絞る余地があります。負のUplift群は施策が逆効果になり得るため、対象外候補です。ただし同じデータで学習・評価した結果は楽観的です。本番ではランダム化を維持し、時期または顧客で分けた検証データを使い、Qini曲線、増分粗利、接触コストまで評価します。
対象ノックを通して見える実務上の示唆
10テーマは独立した分析ではなく、次のようにつながります。RFMとコホートで現状を整理し、Pareto/NBD・BG/NBD・離反モデルで将来の購買と休眠リスクを見積もります。CLVで対応費の上限を置き、バスケット分析と推薦で提案内容を作ります。最後にA/BテストとUplift Modelingで、提案が本当に行動を変えたかを検証します。
製造業では予測精度だけでなく、供給能力との接続が重要です。高Uplift顧客へ特定製品を一斉提案しても、在庫や生産能力が不足すれば顧客体験と利益を損ないます。施策対象数、期待需要、粗利、供給制約を同じ会議で確認できるKPI設計が必要です。
実務導入する場合に必要なこと
- IDと時点の整備:顧客統合ID、受注・返品・見積・接触履歴の時刻をそろえる
- 定義の合意:購買、離反、粗利、施策費、顧客単位を営業・経理・ITで統一する
- 検証設計:予測期間より前の情報だけを使い、保留期間やランダム化対照群を確保する
- 業務制約の実装:設備互換性、在庫、担当可能件数、代理店契約を推薦・対象選定へ反映する
- 意思決定KPI:AUCや精度に加え、増分粗利、商談化率、不適切提案率、供給充足率を追う
- 小規模運用から改善:担当者が理由を確認できる形で試し、採否と結果を次の学習へ戻す
モデルは一度作って終わりではありません。購買周期、製品構成、価格、営業運用が変われば、特徴量と閾値も変わります。月次のデータ品質監視と四半期ごとの効果検証を、担当業務として設計することが重要です。
まとめ
No.051〜No.060では、産業部品メーカーの架空データを使い、顧客を「過去売上」だけでなく、定着、離反、生存確率、将来粗利、併買、施策による行動変化から捉え直しました。
- RFM・コホートは現状と顧客関係の変化を共通言語にする
- Pareto/NBD・BG/NBD・離反分析は、購買頻度の差と休眠を確率的に扱う
- CLVは営業・サービス投資を将来粗利と結び付ける
- バスケット分析・推薦は提案候補を作り、業務制約と担当者判断で仕上げる
- A/Bテスト・Uplift Modelingは「反応した顧客」ではなく「施策で変化した顧客」を評価する
最終的な目的は高度なモデルを導入することではなく、誰に何を提案し、その結果をどう学習するかという意思決定サイクルを構築することです。
法人向けのご相談
数理工房では、製造業における顧客データ基盤の設計、離反・CLV・需要予測モデルの構築、営業施策の実験設計、推薦結果と生産・在庫制約をつなぐ意思決定支援まで、課題整理から伴走します。受注履歴はあるが施策に結び付かない、分析結果を営業現場へ定着させたい、といった段階からご相談いただけます。
📩 お問い合わせ: surikobo.co.jp/contact
まずはお気軽にご相談ください。