Mímisbrunnr知恵の泉

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

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

📎 関連:第6章 セグメンテーション 目次 | 前提:RFM分析(リセンシー・フリークエンシー・マネタリー)

要点(BLUF)

1. ルールから「自動グループ化」へ:教師なしセグメンテーション

RFM分析(リセンシー・フリークエンシー・マネタリー) は強力ですが、2つの制約がありました。軸が R/F/M の3つに固定されていること、そしてセグメントの境界(「sR4s^R\ge4 かつ…なら優良客」)を人が決めることです。実際の顧客は、年間購入回数・平均単価・来店間隔・カテゴリ嗜好・割引感応度…と多変量で、しかも「自然な顧客の塊」がどの変数のどのあたりにあるかは、ふつう事前には分かりません。人が決めた格子で切ると、本当の塊を分断したり、空っぽのセグメントを作ったりします。

そこで発想を逆転します。ルールを与えるのをやめ、データに塊を見つけさせる——これが教師なし学習(unsupervised learning)であり、その代表がクラスタリングです。「正解の所属ラベル」を一切使わず、特徴空間上で近い点どうしをまとめることだけを頼りに、顧客を kk 個のグループに分けます。

最も広く使われるのが k-means です。アイデアは驚くほど単純で、次を繰り返すだけです。

  1. kk 個の中心を置く
  2. 各顧客を最も近い中心のグループに割り当てる(割り当てステップ)
  3. 各グループの中心を、所属する点の平均に更新する(更新ステップ)
  4. 割り当てが変わらなくなるまで 2〜3 を繰り返す
flowchart LR
  X["顧客の特徴量(多変量)"] --> Z["標準化 z化(各次元を平均0・分散1へ)"]
  Z --> KM["k-means:中心を置く→最寄りに割当→中心を更新…"]
  KM --> EL["エルボー法でクラスタ数 k を選ぶ"]
  EL --> PROF["プロファイリング:各クラスタの平均特徴を見て命名(人が解釈)"]

RFM とクラスタリングは好対照です。RFM は人が軸とルールを決めるので解釈は楽ですが、3軸より柔軟にはなれません。クラスタリングはデータに塊を見つけさせるので多変量で柔軟ですが、出てきた塊が「何者か」は後から人が解釈しなければなりません。どちらが上ということはなく、目的次第の使い分けです(§⚠️)。

2. k-means とエルボー法(数式)

k-means が最小化するのは級内平方和(within-cluster sum of squares, WCSS)、すなわち各点と自分のクラスタ中心との距離の二乗和です。クラスタ CkC_k の中心 μk\mu_k はそのクラスタに属する点の平均で定義されます。

WCSS=k=1KiCkxiμk2,μk=1CkiCkxi\text{WCSS} = \sum_{k=1}^{K} \sum_{i \in C_k} \lVert x_i - \mu_k \rVert^2, \qquad \mu_k = \frac{1}{|C_k|}\sum_{i\in C_k} x_i

上の割り当て/更新の反復(Lloyd のアルゴリズム)は、この WCSS を単調に減らしながら局所最適へ収束します。割り当てステップは「中心を固定して各点を最寄りへ」=WCSS を下げ、更新ステップは「割り当てを固定して中心を平均へ」=同じく WCSS を下げる(平均は二乗距離和を最小化する点)からです。WCSS(実装上は inertia と呼ぶ)が、クラスタの「まとまりの良さ」の指標になります。

ここで標準化(z化)が決定的に効きます。WCSS は距離 xiμk\lVert x_i-\mu_k\rVert に基づきますが、距離は変数のスケールに敏感です。単価(数千円)と購入回数(数十回)をそのまま使うと、二乗距離は単価の項がほぼすべてを決め、購入回数は無視同然になります。そこで各変数を平均 xˉj\bar x_j・標準偏差 sjs_j で割って無次元化します。

zij=xijxˉjsjz_{ij} = \frac{x_{ij} - \bar x_j}{s_j}

これで全次元が平均 00・分散 11 に揃い、どの変数も距離に対等に効くようになります(どの変数をどれだけ重視するか別の重みを付けたい場合は別途設計しますが、まずは対等が出発点です)。

最後にクラスタ数 kk の選択。k-means は kk与えなければ動きませんが、kk を増やせば inertia は必ず減ります(極端には k=Nk=N で各点が自分だけのクラスタになり inertia =0=0)。だから「inertia 最小」で選ぶと無意味に細かくなります。代わりに**エルボー法(elbow method)**を使います。kk1,2,3,1,2,3,\dots と増やしながら inertia をプロットし、**減り方が急に鈍る「肘(elbow)」**を最適 kk とみなすのです。肘より手前では「クラスタを1つ増やすと大きくまとまりが改善」、肘より先では「増やしてもほとんど改善しない(過剰分割)」——その境目が、データが持つ自然な塊の数だと考えます。

3. 顧客特徴をクラスタリングする(コード)

3つの真の潜在クラスタ(ライト層・高単価層・ヘビー層)を持つ顧客特徴を合成し、z化 → scipy.cluster.vq.kmeans2(k-means++ 初期化・seed 固定)で k=3k=3 → エルボーで kk を確認 → クラスタ別プロファイル → 真の中心と推定中心の照合を行います。数式との対応:inertia 関数が WCSS、Z が z化後の特徴、kmeans2 が Lloyd の反復です。

import numpy as np
import pandas as pd
from scipy.cluster.vq import kmeans2
from scipy.spatial.distance import cdist

# === 3つの真の潜在クラスタを持つ顧客特徴を合成(2次元:年間購入回数・平均単価)===
rng = np.random.default_rng(1)
N = 600
centers_true = np.array([[ 5.0, 2000.0],   # ライト:低頻度・低単価
                         [12.0, 8000.0],   # 高単価:中頻度・高単価
                         [30.0, 4000.0]])  # ヘビー:高頻度・中単価
sizes   = [240, 180, 180]                  # 各クラスタの人数(合計600)
spreads = np.array([[2.0,  500.0],
                    [3.0, 1200.0],
                    [5.0,  900.0]])
parts, labels_true = [], []
for k, (c, n, sp) in enumerate(zip(centers_true, sizes, spreads)):
    parts.append(rng.normal(c, sp, size=(n, 2)))
    labels_true += [k] * n
X = np.vstack(parts)
X[:, 0] = np.clip(X[:, 0], 1.0, None)       # 購入回数は1以上
X[:, 1] = np.clip(X[:, 1], 200.0, None)     # 単価は200円以上
labels_true = np.array(labels_true)
print(f"顧客 N={N}、特徴量2次元(年間購入回数・平均単価)、真のクラスタ数=3")

# === 標準化(z化):z=(x-平均)/標準偏差。スケールの大きい単価が距離を支配しないように ===
mu, sd = X.mean(axis=0), X.std(axis=0)
Z = (X - mu) / sd
print(f"z化前のスケール差:購入回数 sd={X[:,0].std():.1f} / 単価 sd={X[:,1].std():,.0f}(約{X[:,1].std()/X[:,0].std():.0f}倍)")

# === k-means(k=3):scipy.cluster.vq.kmeans2、k-means++ 初期化・seed固定 ===
centroids_z, labels_km = kmeans2(Z, 3, seed=0, minit="++")
centroids_raw = centroids_z * sd + mu       # z空間の中心を元のスケールに戻す

# === エルボー法:k=1..6 で級内平方和 inertia を計算 ===
def inertia(Z, k):
    cz, lab = kmeans2(Z, k, seed=0, minit="++")
    return float(((Z - cz[lab]) ** 2).sum())
ks = list(range(1, 7))
inertias = [inertia(Z, k) for k in ks]
print("\n=== エルボー法:クラスタ数 k と級内平方和 inertia ===")
for k, v in zip(ks, inertias):
    drop = "" if k == 1 else f"  (前kから減少 {inertias[k-2]-v:.0f})"
    print(f"  k={k}: inertia={v:8.1f}{drop}")
print("  → k=3 までは大きく減り、k=4 以降は減りが鈍る(折れ曲がり=3が妥当)")

# === 推定クラスタを真のクラスタに対応づけ(中心が最も近いもの同士)===
match = cdist(centroids_raw, centers_true).argmin(axis=1)   # 推定i → 真のmatch[i]

# === クラスタ別プロファイル(人数・平均特徴)と中心の対応 ===
prof = pd.DataFrame({
    "推定クラスタ": np.arange(3),
    "対応する真クラスタ": match,
    "人数": [int((labels_km == i).sum()) for i in range(3)],
    "平均_購入回数": [X[labels_km == i, 0].mean() for i in range(3)],
    "平均_単価": [X[labels_km == i, 1].mean() for i in range(3)],
}).sort_values("対応する真クラスタ").reset_index(drop=True)
print("\n=== クラスタ別プロファイル(推定)===")
print(prof.to_string(index=False, formatters={
    "平均_購入回数": "{:.1f}".format, "平均_単価": "{:,.0f}".format}))

cmp = pd.DataFrame({
    "真_購入回数": centers_true[:, 0],
    "推定_購入回数": [centroids_raw[np.where(match == t)[0][0], 0] for t in range(3)],
    "真_単価": centers_true[:, 1],
    "推定_単価": [centroids_raw[np.where(match == t)[0][0], 1] for t in range(3)],
}, index=["真クラスタ0(ライト)", "真クラスタ1(高単価)", "真クラスタ2(ヘビー)"])
print("\n=== 真の中心 vs 推定中心(元スケール)===")
print(cmp.to_string(formatters={c: ("{:,.0f}".format if "単価" in c else "{:.1f}".format)
                                for c in cmp.columns}))

acc = (match[labels_km] == labels_true).mean()
print(f"\n真ラベルとの一致率(中心対応で相対評価): {acc:.1%}")

出力:

顧客 N=600、特徴量2次元(年間購入回数・平均単価)、真のクラスタ数=3
z化前のスケール差:購入回数 sd=11.3 / 単価 sd=2,644(約235倍)

=== エルボー法:クラスタ数 k と級内平方和 inertia ===
  k=1: inertia=  1200.0
  k=2: inertia=   684.7  (前kから減少 515)
  k=3: inertia=   130.1  (前kから減少 555)
  k=4: inertia=   106.3  (前kから減少 24)
  k=5: inertia=    76.0  (前kから減少 30)
  k=6: inertia=    65.3  (前kから減少 11)
  → k=3 までは大きく減り、k=4 以降は減りが鈍る(折れ曲がり=3が妥当)

=== クラスタ別プロファイル(推定)===
 推定クラスタ  対応する真クラスタ  人数 平均_購入回数 平均_単価
      2          0 242     4.9 2,014
      1          1 179    11.5 7,956
      0          2 179    30.4 3,943

=== 真の中心 vs 推定中心(元スケール)===
            真_購入回数 推定_購入回数  真_単価 推定_単価
真クラスタ0(ライト)    5.0     4.9 2,000 2,014
真クラスタ1(高単価)   12.0    11.5 8,000 7,956
真クラスタ2(ヘビー)   30.0    30.4 4,000 3,943

真ラベルとの一致率(中心対応で相対評価): 99.7%

出力の意味

z化が必須な理由が数字で出る。購入回数の標準偏差は 11.311.3、単価は 2,6442{,}644——約 235 倍の開きです。z化せずに距離を測ると、二乗距離は単価の項がほぼ全部を決め、購入回数はほとんど無視されます。これでは「単価だけでクラスタリング」しているのと同じ。z化で両者を分散 11 に揃えて初めて、購入回数と単価が対等に効きます。距離ベースの手法で標準化が「ほぼ必須」と言われる理由です。

エルボーが k=3k=3 を当てる。inertia は k=1k=11200.01200.0 から 684.7130.1106.376.065.3684.7\to130.1\to106.3\to76.0\to65.3 と減ります。注目は減り幅k=23k{=}2{\to}3555555 も減るのに、k=34k{=}3{\to}4 ではわずか 2424。つまり3つ目までは入れる価値が大きく、4つ目以降はほとんど無駄——「肘」が k=3k=3 にくっきり出ています。inertia は kk を増やせば必ず下がる(k=1k{=}112001200 は z化後の全分散 N×2=1200N\times2=1200 に一致)ので、最小値ではなくこの折れ曲がりkk を選ぶのがエルボー法です。みごとに真のクラスタ数 33 を言い当てました。

真の構造をほぼ完全に回復する。プロファイル表で、推定された3クラスタが真の3クラスタに1対1で対応し、人数は 242/179/179242/179/179(真は 240/180/180240/180/180)とほぼ一致。平均特徴は (4.9,2014)(4.9,\,2014)=ライト層、(11.5,7956)(11.5,\,7956)=高単価層、(30.4,3943)(30.4,\,3943)=ヘビー層と、生成時の中心 (5,2000)(12,8000)(30,4000)(5,2000)(12,8000)(30,4000)ほぼそのまま回復しています。真ラベルとの一致率は 99.7%——正解を一切与えていない教師なし手法が、隠れた3グループ構造を当てたわけです。

ただし「番号」に意味はなく、解釈は人がやる。注意すべきは、推定クラスタの番号(0,1,20,1,2)が真のクラスタ番号と一致していないことです(推定 22↔真 00、推定 00↔真 22)。k-means が返すのはただの通し番号で、「クラスタ0=ヘビー層」と名付けたのは、こちらがプロファイル(平均特徴)を見て解釈した結果にすぎません。本ノートでは答え(真ラベル)を持っていたから一致率まで計算できましたが、現実には真ラベルはなく、「このクラスタは何者か」を平均特徴から読み解いて命名する作業が必ず要ります。ここが RFM との最大の違いです。

この結果を1枚にまとめます(このブロックはデータ生成からエルボー計算までを内部で再実行し、上の表と同じ値を描きます)。

import numpy as np
import matplotlib.pyplot as plt
import japanize_matplotlib
from scipy.cluster.vq import kmeans2

# データ生成 → z化 → k-means → エルボー をこのブロック内で再実行し、
# (左) 特徴平面のクラスタ色分けと推定中心、(右) エルボー曲線を描く。
rng = np.random.default_rng(1)
N = 600
centers_true = np.array([[5.0, 2000.0], [12.0, 8000.0], [30.0, 4000.0]])
sizes = [240, 180, 180]
spreads = np.array([[2.0, 500.0], [3.0, 1200.0], [5.0, 900.0]])
parts = []
for c, n, sp in zip(centers_true, sizes, spreads):
    parts.append(rng.normal(c, sp, size=(n, 2)))
X = np.vstack(parts)
X[:, 0] = np.clip(X[:, 0], 1.0, None)
X[:, 1] = np.clip(X[:, 1], 200.0, None)

mu, sd = X.mean(axis=0), X.std(axis=0)
Z = (X - mu) / sd
centroids_z, labels_km = kmeans2(Z, 3, seed=0, minit="++")
centroids_raw = centroids_z * sd + mu

def inertia(Z, k):
    cz, lab = kmeans2(Z, k, seed=0, minit="++")
    return float(((Z - cz[lab]) ** 2).sum())
ks = list(range(1, 7))
inertias = [inertia(Z, k) for k in ks]

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

# 左:元スケールの特徴平面でクラスタを色分け+推定中心
for i in range(3):
    d = X[labels_km == i]
    ax[0].scatter(d[:, 0], d[:, 1], s=18, alpha=0.5, color=f"C{i}",
                  edgecolor="none", label=f"推定クラスタ{i}")
ax[0].scatter(centroids_raw[:, 0], centroids_raw[:, 1], s=300, marker="X",
              color="black", zorder=5, label="推定中心")
ax[0].set_xlabel("年間購入回数")
ax[0].set_ylabel("平均単価(円)")
ax[0].set_title("k-means(k=3):特徴平面のクラスタと推定中心")
ax[0].legend(loc="upper right", fontsize=8.5)
ax[0].grid(alpha=0.3)

# 右:エルボー曲線(k=3 で折れ曲がり)
ax[1].plot(ks, inertias, "o-", color="C3", lw=2)
ax[1].scatter([3], [inertias[2]], s=220, facecolor="none",
              edgecolor="C2", lw=2.5, zorder=5)
ax[1].annotate("エルボー(k=3)\nここから減りが鈍る",
               xy=(3, inertias[2]), xytext=(3.7, 600), fontsize=9,
               arrowprops=dict(arrowstyle="->", color="C2"))
ax[1].set_xlabel("クラスタ数 k")
ax[1].set_ylabel("級内平方和 inertia")
ax[1].set_title("エルボー法:inertia の折れ曲がりで k を選ぶ")
ax[1].grid(alpha=0.3)

fig.tight_layout()
plt.show()

左パネルは元スケールの特徴平面です。横軸が年間購入回数、縦軸が平均単価。3つの色分けされた点群がきれいに分かれ、黒い × が推定中心(ライト層 ≒ 左下、高単価層 ≒ 上、ヘビー層 ≒ 右)。中心が各塊の真ん中に乗っているのが、k-means がうまく当たった証拠です。右パネルはエルボー曲線k=13k=1\to3 で inertia が崖のように落ち、k=3k=3(緑丸)以降はほぼ水平——この折れ曲がりを読み取って k=3k=3 を選びます。左の「塊が3つ」と右の「肘が3」が一致しているのが、k=3k=3 が正しいことの裏付けです。

⚠️ よくある誤解

関連ノート