Mímisbrunnr知恵の泉

← マーケティングサイエンス 一覧

🎓 レベル:標準 | 重要度:A(必須)

📎 関連:第6章 セグメンテーション 目次 | 前提:マーケティングデータとKPIの体系

要点(BLUF)

1. RFM分析とは:3つの問いで顧客を測る

RFM は、もともとカタログ通販・ダイレクトマーケティングで使われてきた古典的な顧客評価法です。発想はとても素朴で、1人の顧客について次の3つの問いを立てます。

なぜこの3軸かというと、経験的にこの3つが「次も買ってくれるか・どれだけ価値があるか」をよく説明するからです。直近に買った人ほど次も買いやすく(R)、何度も買う人ほどロイヤルで(F)、たくさん使う人ほど現に価値が高い(M)。とくに R は予測力が高いと言われます——半年前に1回きりの顧客より、先週も買った顧客のほうが、明らかに「生きている」のです。

RFM の魅力は、その単純さにあります。必要なのは「誰が・いつ・いくら買ったか」の取引ログだけ。回帰もベイズも要らず、出てきたセグメントは名前を付ければそのまま現場で使えます。「離反予兆の人にクーポンを送る」「新規を育成する」「優良客を特別扱いする」——施策に直結する解釈可能性が、この古い手法がいまも生き残っている理由です。

flowchart LR
  LOG["取引ログ(誰が・いつ・いくら)"] --> R["R:最終購入からの経過日数"]
  LOG --> F["F:購入回数"]
  LOG --> M["M:累計購入額"]
  R --> S["各軸を5分位スコア 1〜5(R は逆順)"]
  F --> S
  M --> S
  S --> SEG["3桁RFMスコア → ルールでセグメント命名"]

2. 5分位スコアとセグメント定義(数式)

各指標を分位点(quantile)で5等分し、1155 のスコアを割り当てます。F・M は大きいほど良いので、顧客全体を値の小さい順に並べて5等分し、下位20%群に 11、上位20%群に 55 を与えます。

six=q(指標 xi が下から第 q 五分位群に入るとき, q=1,,5s^x_i = q \quad \text{(指標 } x_i \text{ が下から第 } q \text{ 五分位群に入るとき, } q=1,\dots,5\text{)}

R だけは扱いが逆です。R は「経過日数」なので小さいほど良い——最近買った人を高く評価したい。そこで R は昇順5分位スコア riRr^R_i反転させます。

siR=6riRs^R_i = 6 - r^R_i

こうすると、最も最近(R 最小)の20%に 55、最も昔(R 最大)の20%に 11 が付きます。3軸のスコアを並べると3桁の RFM スコアになります。

RFMi=(siR,  siF,  siM),555=最優良,111=最低\text{RFM}_i = \big(s^R_i,\; s^F_i,\; s^M_i\big), \qquad 555 = \text{最優良}, \quad 111 = \text{最低}

555555 は「最近・高頻度・高額」のすべてが揃った理想客、111111 は「ずっと来ず・ほとんど買わず・少額」の最低評価です。53=1255^3=125 通りのスコアが出ますが、実務ではこれを意味のあるセグメントにまとめます。本ノートで使うルール(上から順に判定し、最初に当てはまったものを採用)はこうです。

ここで肝心なのは離反予兆の定義です。F・M(過去の買いっぷり)は優良客と同じくらい高いのに、R(最近の活動)だけが落ちている——これは「優良客だった人が離れかけている」危険信号で、RFM が3軸を分けて持つからこそ捉えられます。M だけ・F だけでは「優良客」と「離反予兆」は区別できません。

3. 取引ログからRFMセグメントを作る(コード)

顧客1000人の取引ログを合成し、R/F/MR/F/M を集計 → pandasqcut で5分位スコア(R は逆順)→ ルールでセグメント命名 → セグメント別の人数・平均 R/F/M・売上シェアを出します。数式との対応:sR,sF,sMs^R,s^F,s^MR_score, F_score, M_score、3桁スコアが RFM、§2のルールが segment_of 関数です。

import numpy as np
import pandas as pd

# === 顧客1000人の取引ログを合成 ===
# 顧客ごとに購入回数 n_tx、各取引の日付(基準日からの経過日数)と金額を乱数生成。
rng = np.random.default_rng(0)
n_customers = 1000
ref_date = pd.Timestamp("2026-07-01")        # 集計の基準日

rows = []
for cid in range(n_customers):
    n_tx = int(rng.integers(1, 26))           # この顧客の購入回数(1〜25回)
    recent = int(rng.integers(1, 365))        # 最終購入が基準日から何日前か(1〜364日)
    for _ in range(n_tx):
        days_ago = recent + int(rng.integers(0, 400))   # 各取引は最終購入と同時かそれ以前
        date = ref_date - pd.Timedelta(days=days_ago)
        amount = float(rng.lognormal(mean=8.5, sigma=0.6))   # 1取引の金額(円、中央値≒4915)
        rows.append((cid, date, amount))
log = pd.DataFrame(rows, columns=["customer_id", "date", "amount"])
print(f"取引ログ:{len(log):,} 行 / 顧客 {log['customer_id'].nunique()} 人")

# === 顧客別に R / F / M を集計 ===
# R=最終購入からの経過日数(小さいほど良い), F=購入回数, M=累計購入額
rfm = log.groupby("customer_id").agg(
    R=("date", lambda s: (ref_date - s.max()).days),
    F=("date", "size"),
    M=("amount", "sum"),
)

# === 各指標を5分位スコア(1〜5)に。R は小さいほど高スコア=逆順 ===
# 離散値の同点で分位境界が重ならないよう rank で順位化してから qcut
rfm["R_score"] = pd.qcut(rfm["R"].rank(method="first"), 5, labels=[5, 4, 3, 2, 1]).astype(int)
rfm["F_score"] = pd.qcut(rfm["F"].rank(method="first"), 5, labels=[1, 2, 3, 4, 5]).astype(int)
rfm["M_score"] = pd.qcut(rfm["M"].rank(method="first"), 5, labels=[1, 2, 3, 4, 5]).astype(int)
rfm["RFM"] = (rfm["R_score"].astype(str) + rfm["F_score"].astype(str) + rfm["M_score"].astype(str))

# === スコアの組み合わせルールでセグメント命名 ===
def segment_of(r, f, m):
    if r >= 4 and f >= 4 and m >= 4:   # 最近・高頻度・高額
        return "優良客"
    if r <= 2 and f >= 4 and m >= 4:   # FMは高いがしばらく来ていない
        return "離反予兆"
    if f >= 4 and m >= 4:              # よく買う(R は中位)
        return "常連"
    if r >= 4 and f <= 2:              # 最近来たが購入回数は少ない
        return "新規"
    if r <= 2 and f <= 2:              # 長く来ず購入も少ない
        return "休眠"
    return "一般"

rfm["segment"] = [segment_of(r, f, m) for r, f, m in
                  zip(rfm["R_score"], rfm["F_score"], rfm["M_score"])]

# === セグメント別の人数・平均R/F/M・売上シェア ===
seg_order = ["優良客", "常連", "離反予兆", "新規", "休眠", "一般"]
summary = rfm.groupby("segment").agg(
    人数=("R", "size"),
    平均R=("R", "mean"),
    平均F=("F", "mean"),
    平均M=("M", "mean"),
    売上=("M", "sum"),
)
summary["売上シェア"] = summary["売上"] / summary["売上"].sum()
summary = summary.reindex([s for s in seg_order if s in summary.index])

print("\n=== RFMスコア上位の例(RFM=555 が最優良)===")
print(rfm.sort_values(["R_score", "F_score", "M_score"], ascending=False)
      .head(5)[["R", "F", "M", "R_score", "F_score", "M_score", "RFM", "segment"]]
      .to_string(formatters={"M": "{:,.0f}".format}))

print("\n=== セグメント別サマリー(人数・平均R/F/M・売上シェア)===")
print(summary.to_string(formatters={
    "人数": "{:.0f}".format, "平均R": "{:.0f}".format, "平均F": "{:.1f}".format,
    "平均M": "{:,.0f}".format, "売上": "{:,.0f}".format, "売上シェア": "{:.1%}".format}))

# 全顧客の何割が上位2割(優良客)に売上を依存しているかの一言
top = summary.loc["優良客"]
print(f"\n優良客は {top['人数']:.0f} 人(全体の {top['人数']/len(rfm):.1%})で売上シェア {top['売上シェア']:.1%}")

出力:

取引ログ:12,691 行 / 顧客 1000 人

=== RFMスコア上位の例(RFM=555 が最優良)===
              R   F       M  R_score  F_score  M_score  RFM segment
customer_id                                                        
6            40  24 126,255        5        5        5  555     優良客
11           40  23 127,120        5        5        5  555     優良客
55           95  22 175,186        5        5        5  555     優良客
58           76  24 152,585        5        5        5  555     優良客
64           34  24 145,454        5        5        5  555     優良客

=== セグメント別サマリー(人数・平均R/F/M・売上シェア)===
         人数 平均R  平均F     平均M         売上 売上シェア
segment                                      
優良客     167 107 21.0 124,680 20,821,608 27.9%
常連       88 229 20.6 122,695 10,797,182 14.4%
離反予兆    110 329 20.8 124,124 13,653,638 18.3%
新規      126 125  5.9  35,714  4,499,967  6.0%
休眠      203 390  4.2  24,982  5,071,436  6.8%
一般      306 218 11.4  65,030 19,899,081 26.6%

優良客は 167 人(全体の 16.7%)で売上シェア 27.9%

出力の意味

RFM=555 の正体。スコア上位5人はいずれも 555555=優良客で、R が 34349595 日と最近、F が 22222424 回と高頻度、M が 1212万〜1717万円と高額——「最近・よく・たくさん」の三拍子です。555555 という3桁がそのまま顧客像を語るのが RFM の良さで、上司に「555555 の人を優遇します」と言えば話が通じます。

売上の集中。セグメント別サマリーが本題です。優良客は 167人(全体の 16.7%)にすぎないのに、売上の 27.9% を生んでいます。「上位2割弱が売上の3割弱」という、いわゆるパレート的な偏りです。さらに常連(14.4%)を足せば、255255人で売上の4割超。少数の顧客が売上を支える構造が一目で分かり、「全員に同じ施策」がいかに非効率かが見えます。

離反予兆という発見。表で最も注目すべきは離反予兆 110人です。F が 20.820.8・M が 124,124124{,}124 円と、優良客(F 21.021.0・M 124,680124{,}680 円)とほとんど同じ——過去の買いっぷりは優良客と区別がつきません。違いは R だけ:平均 R が**329329 日と全セグメント中で最悪です。つまり「かつて優良客だったのに、ここ1年近く来ていない」層で、彼らだけで売上シェアの 18.3% を握っています。これは最優先で取り戻すべき流出予備軍**です。M や F だけ眺めていたら「優良客」に紛れて見逃しますが、R を分けて持つ RFM はこれを名指しできます——RFM が古典なのに廃れない核心が、この1セグメントに凝縮しています。

残りの層と打ち手。新規 126人(R 良いが F 5.95.9・M 小)は育成対象、休眠 203人(R 390390 日・F 4.24.2・M 小)は深追いしないか低コストで再活性化、一般 306人は中間です。RFM の出口は常にこの**「誰に何をするか」**——離反予兆にはカムバック施策、新規にはオンボーディング、優良客にはロイヤルティ特典、休眠には控えめに、と打ち手がセグメントごとに自然に決まります。

この構造を1枚にまとめます(このブロックはデータ生成からセグメント命名までを内部で再実行し、上の表と同じ値を描きます)。

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import japanize_matplotlib

# データ生成 → R/F/M 集計 → スコア化 → セグメント命名 をこのブロック内で再実行し、
# (左) R-F 平面でのセグメント分離(点の大きさ=M)、(右) セグメント別の売上シェアを描く。
rng = np.random.default_rng(0)
n_customers = 1000
ref_date = pd.Timestamp("2026-07-01")
rows = []
for cid in range(n_customers):
    n_tx = int(rng.integers(1, 26))
    recent = int(rng.integers(1, 365))
    for _ in range(n_tx):
        days_ago = recent + int(rng.integers(0, 400))
        rows.append((cid, ref_date - pd.Timedelta(days=days_ago),
                     float(rng.lognormal(8.5, 0.6))))
log = pd.DataFrame(rows, columns=["customer_id", "date", "amount"])

rfm = log.groupby("customer_id").agg(
    R=("date", lambda s: (ref_date - s.max()).days),
    F=("date", "size"), M=("amount", "sum"))
rfm["R_score"] = pd.qcut(rfm["R"].rank(method="first"), 5, labels=[5, 4, 3, 2, 1]).astype(int)
rfm["F_score"] = pd.qcut(rfm["F"].rank(method="first"), 5, labels=[1, 2, 3, 4, 5]).astype(int)
rfm["M_score"] = pd.qcut(rfm["M"].rank(method="first"), 5, labels=[1, 2, 3, 4, 5]).astype(int)

def segment_of(r, f, m):
    if r >= 4 and f >= 4 and m >= 4: return "優良客"
    if r <= 2 and f >= 4 and m >= 4: return "離反予兆"
    if f >= 4 and m >= 4: return "常連"
    if r >= 4 and f <= 2: return "新規"
    if r <= 2 and f <= 2: return "休眠"
    return "一般"
rfm["segment"] = [segment_of(r, f, m) for r, f, m in
                  zip(rfm["R_score"], rfm["F_score"], rfm["M_score"])]

seg_order = ["優良客", "常連", "離反予兆", "新規", "休眠", "一般"]
colors = {"優良客": "C2", "常連": "C0", "離反予兆": "C3",
          "新規": "C1", "休眠": "C7", "一般": "C8"}

fig, ax = plt.subplots(1, 2, figsize=(11.5, 4.6))

# 左:R-F 平面でセグメントが象限に分かれる(点の大きさは M)
for s in seg_order:
    d = rfm[rfm["segment"] == s]
    ax[0].scatter(d["R"], d["F"], s=d["M"] / 4000, c=colors[s],
                  alpha=0.6, edgecolor="none", label=s)
ax[0].set_xlabel("R:最終購入からの経過日数(小さいほど良い)")
ax[0].set_ylabel("F:購入回数")
ax[0].set_title("RFMセグメントは R-F 平面で象限に分かれる(点の大きさ=M)")
ax[0].legend(loc="upper right", fontsize=8.5, ncol=2)
ax[0].grid(alpha=0.3)

# 右:セグメント別の売上シェア
share = (rfm.groupby("segment")["M"].sum() / rfm["M"].sum()).reindex(seg_order)
bars = ax[1].bar(range(len(seg_order)), share.values * 100,
                 color=[colors[s] for s in seg_order])
for b, v in zip(bars, share.values * 100):
    ax[1].text(b.get_x() + b.get_width() / 2, v + 0.4, f"{v:.1f}", ha="center", fontsize=9)
ax[1].set_xticks(range(len(seg_order)))
ax[1].set_xticklabels(seg_order, fontsize=9)
ax[1].set_ylabel("売上シェア(%)")
ax[1].set_title("セグメント別の売上シェア:少数の優良客に売上が集中")
ax[1].grid(alpha=0.3, axis="y")

fig.tight_layout()
plt.show()

左パネルは R-F 平面です。横軸 R(左ほど最近)、縦軸 F(上ほど高頻度)、点の大きさが M。色分けされたセグメントが象限ごとに分かれているのが分かります——左上に優良客(R 小・F 大)、右上に離反予兆(R 大なのに F 大)、右下に休眠(R 大・F 小)、左下に新規(R 小・F 小)。とくに右上の離反予兆は、F が高い(=過去は優良)のに右側(=最近来ていない)に居る点群で、まさに「取り戻すべき層」が視覚的に浮きます。右パネルは売上シェアの棒で、優良客と一般の2本が突出し、少数の優良客への集中と、人数は多いが薄く広がる一般層の対比が見えます。

⚠️ よくある誤解

関連ノート