2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

小売CRMでクーポン施策を伸ばす:クラスタリング・Lifetimes・LightGBMをつなぐ顧客セグメント設計

2
Posted at

会員データは「誰に、何を、いつ届けるか」を決めるために使う

日本の大型商業施設では、アプリ会員証、ポイントカード、キャッシュレス決済、館内イベント予約などを通じて、来館と購買の履歴が日々蓄積されている。たとえば、首都圏に複数店舗を展開する架空の商業施設「さくらアーバンモール」では、アパレル、食品、レストラン、映画館、キッズスクール、雑貨店が同じ会員基盤につながっている。

このような施設で顧客インサイトを扱う目的は、単に「会員を分類する」ことではない。主な目的は次の二つに整理できる。

  1. クーポンやキャンペーンを、反応しやすい会員に絞って届け、クーポン利用率と販促効率を高めること。
  2. 休眠・離反しそうな会員を早めに見つけ、好みや生活シーンに合う商品、店舗、イベントを提案して継続利用につなげること。

重要なのは、分析結果を現場で使える形に落とすことである。「週末に家族で来館する層」「平日夕方に飲食利用が多い層」「高単価だが最近来館が減っている層」のように、施策の文脈で理解できるセグメントに変換して初めて、クーポン配信や商品推薦に使える。

日本の小売業では、人口減少、商圏内競争、ポイント施策の飽和、アプリ通知やLINE配信への慣れが同時に進んでいる。そのため、全員に同じクーポンを配るだけでは、利用率が伸びにくいだけでなく、値引きがなくても来店する会員にまで粗利を渡してしまう。これからの会員施策では、ファーストパーティデータをもとに、配る相手、配る内容、配るタイミング、配らない判断まで設計する必要がある。

分析に使う会員データの前提

本記事では、実データの代わりに、商業施設のCRMで一般的に用意される次のような項目を想定する。本文では日本語で業務上の意味を説明し、コード例ではそのまま実装に移しやすいように列名と変数名を英語のスネークケースに統一している。

データ 主な項目 使い道
会員マスタ customer_id, gender, age, area, signup_date 基本属性、会員歴、エリア別傾向を見る
購買明細 customer_id, purchase_datetime, sales_amount, category_group, store_name, coupon_redeemed RFM、カテゴリ嗜好、クーポン反応を作る
来館履歴 customer_id, visit_datetime, companion_type, visited_store 平日・休日、時間帯、家族連れなどの行動特徴を作る
施策履歴 customer_id, sent_date, coupon_type, campaign_group, coupon_redeemed, churn_30d, visit_30d_after_send, sales_30d_after_send, new_member_flag 効果検証、反応モデル、次回配信の最適化に使う

最初に、購買明細と来館履歴を会員単位に集約する。ここで作る特徴量が、後続のRFM分析、クラスタリング、離反予測、LightGBMによるデシル設計の土台になる。

import pandas as pd

purchase_df = pd.read_csv("purchases.csv", parse_dates=["purchase_datetime"])
visit_df = pd.read_csv("visits.csv", parse_dates=["visit_datetime"])
customer_df = pd.read_csv("customers.csv", parse_dates=["signup_date"])
campaign_df = pd.read_csv("campaigns.csv", parse_dates=["sent_date"])

as_of_date = pd.Timestamp("2026-07-31")

purchase_df["purchase_date"] = purchase_df["purchase_datetime"].dt.date
purchase_df["is_weekend"] = purchase_df["purchase_datetime"].dt.dayofweek >= 5
visit_df["is_weekend"] = visit_df["visit_datetime"].dt.dayofweek >= 5
visit_df["is_last_90d"] = visit_df["visit_datetime"] >= as_of_date - pd.Timedelta(days=90)

purchase_features = (
    purchase_df.groupby("customer_id")
    .agg(
        last_purchase_datetime=("purchase_datetime", "max"),
        purchase_days_12m=("purchase_date", "nunique"),
        sales_12m=("sales_amount", "sum"),
        avg_order_value=("sales_amount", "mean"),
        coupon_redeem_count=("coupon_redeemed", "sum"),
    )
    .reset_index()
)

visit_features = (
    visit_df.groupby("customer_id")
    .agg(
        visits_12m=("visit_datetime", "count"),
        visits_90d=("is_last_90d", "sum"),
        weekend_visits_12m=("is_weekend", "sum"),
        last_visit_datetime=("visit_datetime", "max"),
    )
    .reset_index()
)

customer_features = (
    customer_df.merge(purchase_features, on="customer_id", how="left")
    .merge(visit_features, on="customer_id", how="left")
)

customer_features["member_months"] = ((as_of_date - customer_features["signup_date"]).dt.days / 30.4375).clip(lower=0).round(1)
customer_features["days_since_last_visit"] = (as_of_date - customer_features["last_visit_datetime"]).dt.days
customer_features["weekend_visit_share"] = customer_features["weekend_visits_12m"] / customer_features["visits_12m"].clip(lower=1)
customer_features["coupon_redeem_rate"] = customer_features["coupon_redeem_count"] / customer_features["purchase_days_12m"].clip(lower=1)

RFMと嗜好タグで「使える会員像」を作る

会員施策の最初の一歩として使いやすいのがRFM分析である。RFMは、直近性、頻度、金額の三つで顧客の購買行動を要約する。

  • Recency: 最後に購買してから何日経っているか。
  • Frequency: 一定期間内に何回購買したか。
  • Monetary: 一定期間内にいくら購買したか。

ここでは、会員ID、最終購買日時、購買頻度、年間購買金額、カテゴリ、購買金額を使う。カテゴリ別の購買比率も加えると、「値引きに反応しやすい人」だけでなく、「食品クーポンがよい人」「映画館連動の特典がよい人」のように、配信内容まで決めやすくなる。

customer_features["days_since_last_purchase"] = (as_of_date - customer_features["last_purchase_datetime"]).dt.days

customer_features["r_score"] = pd.qcut(
    customer_features["days_since_last_purchase"].rank(method="first", ascending=True),
    5,
    labels=[5, 4, 3, 2, 1],
).astype(int)

customer_features["f_score"] = pd.qcut(
    customer_features["purchase_days_12m"].rank(method="first"),
    5,
    labels=[1, 2, 3, 4, 5],
).astype(int)

customer_features["m_score"] = pd.qcut(
    customer_features["sales_12m"].rank(method="first"),
    5,
    labels=[1, 2, 3, 4, 5],
).astype(int)

customer_features["rfm_score"] = customer_features[["r_score", "f_score", "m_score"]].sum(axis=1)

category_sales = pd.pivot_table(
    purchase_df,
    index="customer_id",
    columns="category_group",
    values="sales_amount",
    aggfunc="sum",
    fill_value=0,
)

category_share = category_sales.div(category_sales.sum(axis=1).replace(0, pd.NA), axis=0).add_suffix("_share")
customer_features = customer_features.merge(category_share.reset_index(), on="customer_id", how="left")

for col in ["apparel_share", "food_share", "entertainment_share", "kids_share"]:
    if col not in customer_features.columns:
        customer_features[col] = 0

def assign_rfm_rank(row):
    if row["rfm_score"] >= 13:
        return "champion"
    if row["r_score"] <= 2 and row["m_score"] >= 4:
        return "high_value_at_risk"
    if row["f_score"] >= 4:
        return "frequent_visitor"
    return "growth_target"

customer_features["rfm_rank"] = customer_features.apply(assign_rfm_rank, axis=1)

この段階で、たとえば次のような施策に落とし込める。

会員像 施策例 狙い
上位優良会員 限定イベント招待、先行セール案内 値引きよりも特別感で継続利用を促す
高価値休眠予備軍 期限短めの再来館クーポン 離反前に来館のきっかけを作る
高頻度来館会員 飲食・日用品の小額クーポン 日常利用の頻度を維持する
育成対象会員 初回利用カテゴリに近いクーポン 次の購買体験を作る

K-meansで生活シーン別の会員群を見つける

RFMは扱いやすい一方で、会員の生活シーンまでは十分に表現できない。そこで、来館曜日、同伴者、カテゴリ別購買、年齢、購買金額などを組み合わせ、K-meansで自然な会員群を探索する。

ここで使う項目は、性別、年齢、年間購買金額、来館回数、休日来館比率、カテゴリ別購買比率、クーポン利用率などである。金額や回数は尺度が大きく異なるため、クラスタリング前に標準化する。

from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler

cluster_features = [
    "age",
    "sales_12m",
    "purchase_days_12m",
    "visits_12m",
    "weekend_visit_share",
    "apparel_share",
    "food_share",
    "entertainment_share",
    "kids_share",
    "coupon_redeem_rate",
]

cluster_input = customer_features[cluster_features].fillna(0)
scaler = StandardScaler()
X_cluster = scaler.fit_transform(cluster_input)

kmeans = KMeans(n_clusters=8, random_state=42, n_init=20, max_iter=300)
customer_features["cluster_id"] = kmeans.fit_predict(X_cluster)

cluster_summary = (
    customer_features.groupby("cluster_id")
    .agg(
        customers=("customer_id", "nunique"),
        avg_age=("age", "mean"),
        avg_sales_12m=("sales_12m", "mean"),
        avg_visits_12m=("visits_12m", "mean"),
        avg_weekend_visit_share=("weekend_visit_share", "mean"),
        avg_coupon_redeem_rate=("coupon_redeem_rate", "mean"),
    )
    .round(2)
)

cluster_summary

分析後は、数値のクラスタIDをそのまま現場に渡さない。クラスタの特徴を読み、販促担当者が理解できる名前に変換する。

クラスタ名 典型的な特徴 有効な施策例
週末ファミリー層 休日来館比率とキッズカテゴリ比率が高い キッズ用品、フードコート、映画館のセットクーポン
平日ランチ利用層 平日来館が多く飲食比率が高い ランチタイム限定クーポン、回数券型特典
高感度アパレル層 アパレル比率と購買金額が高い 新作先行案内、ブランド横断クーポン
休眠予備軍 過去購買金額は高いが最終来館からの日数が長い 期限付き再来館クーポン、過去購入カテゴリに近い提案

このように、クラスタリングは「会員が何者か」を決めつけるものではなく、施策を組むための仮説を作る方法である。クーポン利用率を上げたい場合は、クラスタごとの反応率を追い、反応が弱いクラスタにはクーポン内容や配信タイミングを変える。

Lifetimesで休眠リスクを早めに見つける

会員離反を防ぐには、すでに休眠した人を探すだけでは遅い。購買頻度、最終購買からの日数、観察期間の長さを使い、「今も購買し続ける可能性がどれくらいあるか」を推定する。

ここでは、購買明細の会員ID、購買日時、購買金額を使う。Lifetimes系のモデルでは、顧客ごとに購買頻度、直近性、観察期間、購買金額を作り、将来の購買回数や生存確率を推定する。購買が一度だけの会員はモデルで安定して判断しにくいため、別途「新規育成」や「冷スタート」として扱うのが自然である。

from lifetimes import BetaGeoFitter, GammaGammaFitter
from lifetimes.utils import summary_data_from_transaction_data

tx_summary = summary_data_from_transaction_data(
    purchase_df,
    customer_id_col="customer_id",
    datetime_col="purchase_datetime",
    monetary_value_col="sales_amount",
    observation_period_end=as_of_date,
    freq="D",
)

bgf = BetaGeoFitter(penalizer_coef=0.001)
bgf.fit(
    tx_summary["frequency"],
    tx_summary["recency"],
    tx_summary["T"],
)

tx_summary["alive_probability"] = bgf.conditional_probability_alive(
    tx_summary["frequency"],
    tx_summary["recency"],
    tx_summary["T"],
)

tx_summary["expected_purchases_30d"] = bgf.conditional_expected_number_of_purchases_up_to_time(
    30,
    tx_summary["frequency"],
    tx_summary["recency"],
    tx_summary["T"],
)

monetary_model_input = tx_summary.query("frequency > 0 and monetary_value > 0").copy()
ggf = GammaGammaFitter(penalizer_coef=0.01)
ggf.fit(monetary_model_input["frequency"], monetary_model_input["monetary_value"])

monetary_model_input["expected_avg_order_value"] = ggf.conditional_expected_average_profit(
    monetary_model_input["frequency"],
    monetary_model_input["monetary_value"],
)

生存確率が低く、過去の購買金額が高い会員は、優先的に再来館施策の候補になる。ただし、値引きだけで引き戻すと粗利を削る可能性があるため、過去のカテゴリ嗜好や来館時間帯を合わせて、提案内容を変える。

休眠リスク 過去価値 推奨施策
高い 高い 過去購入カテゴリに近い再来館クーポン、短い有効期限
高い 低い 低コストのアプリ通知、館内イベント案内
低い 高い 値引きより限定体験、ポイントアップ
低い 低い 育成用の初回カテゴリ拡張クーポン

会員タグをCRMに戻し、配信対象をすぐ抽出できる形にする

分析は、ダッシュボードで眺めるだけでは施策にならない。RFMランク、クラスタ名、生存確率、カテゴリ嗜好を会員タグとしてCRMやDWHに戻し、販促担当者が条件指定で対象者を抽出できるようにする。

ここでは、会員ID、RFMランク、クラスタ名、生存確率、主カテゴリ、クーポン利用率、最終来館からの日数を使う。

cluster_name_map = {
    0: "weekend_family",
    1: "weekday_lunch",
    2: "fashion_high_interest",
    3: "at_risk_high_value",
    4: "entertainment_user",
    5: "grocery_bulk_buyer",
    6: "new_member_growth",
    7: "daily_frequent_user",
}

customer_features["cluster_name"] = customer_features["cluster_id"].map(cluster_name_map)

category_cols = ["apparel_share", "food_share", "entertainment_share", "kids_share"]
customer_features["primary_category"] = customer_features[category_cols].idxmax(axis=1).str.replace("_share", "", regex=False)

customer_tags = customer_features[[
    "customer_id",
    "rfm_rank",
    "cluster_name",
    "primary_category",
    "coupon_redeem_rate",
    "days_since_last_visit",
]].merge(
    tx_summary[["alive_probability", "expected_purchases_30d"]].reset_index(),
    on="customer_id",
    how="left",
)

customer_tags["churn_risk_tag"] = pd.cut(
    1 - customer_tags["alive_probability"],
    bins=[-0.01, 0.3, 0.6, 1.0],
    labels=["low", "middle", "high"],
)

customer_tags["coupon_sensitivity_tag"] = pd.cut(
    customer_tags["coupon_redeem_rate"],
    bins=[-0.01, 0.1, 0.4, 1.0],
    labels=["low", "middle", "high"],
)

customer_tags.to_csv("customer_tags_for_campaign.csv", index=False, encoding="utf-8-sig")

DWHやCRMに戻す場合は、Pythonで作ったモデルタグだけでなく、SQLで毎日更新できる事実タグと統計タグも持たせる。下の例はBigQuery Standard SQLを想定している。実務では、mart_customer_featuresのような会員単位の特徴量テーブルを日次で作り、その上に配信用タグテーブルを重ねると運用しやすい。

CREATE OR REPLACE TABLE crm.customer_tags AS
SELECT
    customer_id,
    CASE
        WHEN last_visit_date >= DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 30 DAY) THEN 'active_30d'
        WHEN last_visit_date >= DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 90 DAY) THEN 'active_90d'
        WHEN last_visit_date >= DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 180 DAY) THEN 'at_risk'
        ELSE 'dormant'
    END AS visit_status_tag,
    CASE
        WHEN age BETWEEN 18 AND 24 THEN 'young'
        WHEN age BETWEEN 25 AND 39 THEN 'working_age'
        WHEN age BETWEEN 40 AND 59 THEN 'family_middle'
        WHEN age >= 60 THEN 'senior'
        ELSE 'unknown_age'
    END AS life_stage_tag,
    CASE
        WHEN SAFE_DIVIDE(weekend_visits_12m, visits_12m) >= 0.6 THEN 'weekend_main'
        WHEN SAFE_DIVIDE(weekday_visits_12m, visits_12m) >= 0.6 THEN 'weekday_main'
        ELSE 'balanced_weekday'
    END AS weekday_preference_tag,
    CASE
        WHEN SAFE_DIVIDE(lunch_visits_12m, visits_12m) >= 0.5 THEN 'lunch_main'
        WHEN SAFE_DIVIDE(evening_visits_12m, visits_12m) >= 0.5 THEN 'evening_main'
        ELSE 'balanced_daypart'
    END AS time_preference_tag,
    CASE
        WHEN apparel_share >= 0.5 THEN 'apparel_high'
        WHEN food_share >= 0.5 THEN 'food_high'
        WHEN entertainment_share >= 0.5 THEN 'entertainment_high'
        WHEN kids_share >= 0.5 THEN 'kids_high'
        ELSE 'cross_category'
    END AS category_preference_tag,
    CASE
        WHEN coupon_redeem_rate >= 0.4 THEN 'high'
        WHEN coupon_redeem_rate >= 0.1 THEN 'middle'
        ELSE 'low'
    END AS coupon_sensitivity_tag,
    rfm_rank,
    cluster_name,
    alive_probability,
    churn_risk_tag,
    updated_at
FROM mart.customer_features_daily;

さらに、SQLでそのまま配信候補を抽出できるようにしておくと、販促担当者が「高リスクだが反応しやすい会員」や「新規会員の二回目来館候補」をすぐ確認できる。

SELECT
    customer_id,
    visit_status_tag,
    life_stage_tag,
    category_preference_tag,
    coupon_sensitivity_tag,
    cluster_name
FROM crm.customer_tags
WHERE visit_status_tag IN ('at_risk', 'dormant')
    AND coupon_sensitivity_tag IN ('high', 'middle')
    AND category_preference_tag IN ('food_high', 'kids_high')
    AND alive_probability < 0.6;

配信条件は、たとえば次のように作れる。

target_customers = customer_tags.query(
    "churn_risk_tag == 'high' and cluster_name in ['weekend_family', 'at_risk_high_value']"
).copy()

target_customers["recommended_offer"] = target_customers["primary_category"].map({
    "apparel": "apparel_10pct_off",
    "food": "weekend_food_coupon",
    "entertainment": "movie_food_bundle",
    "kids": "kids_trial_coupon",
}).fillna("mall_point_boost")

target_customers[["customer_id", "cluster_name", "churn_risk_tag", "primary_category", "recommended_offer"]].head()

この設計にすると、販促担当者は「誰に配るか」だけでなく、「なぜその人にそのクーポンを配るのか」まで説明できる。説明できる施策は、検証もしやすい。

LightGBMで予測、デシル、細分セグメントを組み合わせる

LightGBMを使うからといって、RFMやLifetimesが不要になるわけではない。役割が違う。RFMは過去の購買行動を表す特徴量であり、Lifetimesは非契約型の小売で「まだ買い続ける可能性」を見る確率的な基準線である。LightGBMは、それらに会員属性、来館行動、カテゴリ嗜好、施策反応を加えて予測精度を上げるモデルであり、デシルはモデルの連続スコアを配信リストに変換する運用上の道具である。

要素 役割 施策上の使い方
RFM 過去の購買履歴を要約する 基本特徴量、会員ランク、説明材料にする
Lifetimes 将来購買回数や生存確率の基準線を作る 離反ラベルが曖昧な会員の補助指標にする
LightGBM 離反、反応、価値を予測する 多数の特徴量を使って優先度を推定する
デシル スコアを10段階に変換する 予算に合わせて上位層から配信する
細分セグメント スコアと行動特徴を組み合わせる 配信内容、割引率、チャネルを変える

ここでは、単に「上位何%に配る」だけでなく、離反リスク、クーポン反応確率、予測購買金額、推定粗利を組み合わせる。高リスク会員でも、反応確率や将来価値が低ければ強い値引きを出す優先度は下がる。逆に、離反リスクが中程度でも、反応確率と将来価値が高い会員は、早めのパーソナライズ配信で継続率を上げやすい。

使うデータは、会員単位に集約した基本特徴量、前段で作った会員タグ、K-meansのクラスタ名、Lifetimesの生存確率と将来購買回数、施策履歴から作る目的変数である。つまり、LightGBMだけを単独で置くのではなく、ここまで作ってきたRFM、クラスタリング、Lifetimes、会員タグをすべて説明可能な入力として使う。時系列の前後関係を壊さないため、検証はランダム分割ではなく、配信日で学習期間と検証期間を分ける。

import lightgbm as lgb
import numpy as np
from sklearn.metrics import mean_absolute_error, roc_auc_score

lifetime_features = tx_summary[["alive_probability", "expected_purchases_30d"]].reset_index()
tag_features = customer_tags[[
    "customer_id",
    "rfm_rank",
    "cluster_name",
    "primary_category",
    "churn_risk_tag",
    "coupon_sensitivity_tag",
]].drop_duplicates("customer_id")

base_customer_features = customer_features.drop(
    columns=["rfm_rank", "cluster_name", "primary_category"],
    errors="ignore",
)

model_df = (
    base_customer_features.merge(tag_features, on="customer_id", how="left")
    .merge(lifetime_features, on="customer_id", how="left")
    .merge(
        campaign_df[[
            "customer_id",
            "sent_date",
            "coupon_redeemed",
            "churn_30d",
            "sales_30d_after_send",
        ]],
        on="customer_id",
        how="inner",
    )
)

category_share_cols = ["apparel_share", "food_share", "entertainment_share", "kids_share"]
model_df["category_interest_drop_flag"] = (
    (model_df[category_share_cols].max(axis=1).fillna(0) < 0.2)
    & (model_df["days_since_last_purchase"] > 60)
).astype(int)

numeric_features = [
    "age",
    "member_months",
    "days_since_last_visit",
    "days_since_last_purchase",
    "visits_90d",
    "sales_12m",
    "rfm_score",
    "coupon_redeem_rate",
    "weekend_visit_share",
    "alive_probability",
    "expected_purchases_30d",
    "category_interest_drop_flag",
]
categorical_features = ["gender", "rfm_rank", "cluster_name", "primary_category", "churn_risk_tag", "coupon_sensitivity_tag"]
model_features = numeric_features + categorical_features

for col in categorical_features:
    model_df[col] = model_df[col].astype("category")

model_df[numeric_features] = model_df[numeric_features].fillna(0)

validation_start_date = pd.Timestamp("2026-05-01")
train_df = model_df[model_df["sent_date"] < validation_start_date].copy()
valid_df = model_df[model_df["sent_date"] >= validation_start_date].copy()

train_features = train_df[model_features]
valid_features = valid_df[model_features]

次に、目的の異なる三つのモデルを分けて作る。離反モデルは「誰を放置すると危ないか」、反応モデルは「誰がクーポンに反応しやすいか」、購買金額モデルは「反応したときの売上インパクトがどの程度か」を見る。クーポン施策では、この三つを分けておくと、割引の出し過ぎを避けやすい。

churn_model = lgb.LGBMClassifier(
    objective="binary",
    n_estimators=1200,
    learning_rate=0.03,
    num_leaves=31,
    subsample=0.85,
    colsample_bytree=0.85,
    random_state=42,
)
churn_model.fit(
    train_features,
    train_df["churn_30d"],
    eval_set=[(valid_features, valid_df["churn_30d"])],
    eval_metric="auc",
    categorical_feature=categorical_features,
    callbacks=[lgb.early_stopping(50), lgb.log_evaluation(100)],
)

response_model = lgb.LGBMClassifier(
    objective="binary",
    n_estimators=1200,
    learning_rate=0.03,
    num_leaves=31,
    subsample=0.85,
    colsample_bytree=0.85,
    random_state=42,
)
response_model.fit(
    train_features,
    train_df["coupon_redeemed"],
    eval_set=[(valid_features, valid_df["coupon_redeemed"])],
    eval_metric="auc",
    categorical_feature=categorical_features,
    callbacks=[lgb.early_stopping(50), lgb.log_evaluation(100)],
)

sales_model = lgb.LGBMRegressor(
    objective="regression_l1",
    n_estimators=1200,
    learning_rate=0.03,
    num_leaves=31,
    subsample=0.85,
    colsample_bytree=0.85,
    random_state=42,
)
sales_model.fit(
    train_features,
    train_df["sales_30d_after_send"].clip(lower=0),
    eval_set=[(valid_features, valid_df["sales_30d_after_send"].clip(lower=0))],
    eval_metric="l1",
    categorical_feature=categorical_features,
    callbacks=[lgb.early_stopping(50), lgb.log_evaluation(100)],
)

valid_df["churn_risk"] = churn_model.predict_proba(valid_features)[:, 1]
valid_df["coupon_response_prob"] = response_model.predict_proba(valid_features)[:, 1]
valid_df["predicted_sales_30d"] = np.maximum(sales_model.predict(valid_features), 0)

print("churn_auc:", round(roc_auc_score(valid_df["churn_30d"], valid_df["churn_risk"]), 3))
print("response_auc:", round(roc_auc_score(valid_df["coupon_redeemed"], valid_df["coupon_response_prob"]), 3))
print("sales_mae:", round(mean_absolute_error(valid_df["sales_30d_after_send"], valid_df["predicted_sales_30d"]), 0))

モデルスコアは、デシルに変換してから現場に渡す。ここでは、離反、反応、価値、期待利益の四つのデシルを作る。期待利益スコアは簡易版であり、本当の増分利益はA/Bテストやアップリフトモデルで検証する必要がある。それでも、単純に「離反リスクが高い順」に配るよりは、粗利とクーポン原価を意識した配信に近づく。

def make_decile(score, top_is_one=True):
    ranking = score.rank(method="first", ascending=not top_is_one)
    return pd.qcut(ranking, 10, labels=list(range(1, 11))).astype(int)

all_features = model_df[model_features]
model_df["churn_risk"] = churn_model.predict_proba(all_features)[:, 1]
model_df["coupon_response_prob"] = response_model.predict_proba(all_features)[:, 1]
model_df["predicted_sales_30d"] = np.maximum(sales_model.predict(all_features), 0)

gross_margin_rate = 0.35
coupon_cost = 500
contact_cost = 8

model_df["predicted_gross_profit"] = model_df["predicted_sales_30d"] * gross_margin_rate
model_df["expected_profit_score"] = (
    model_df["coupon_response_prob"] * (model_df["predicted_gross_profit"] - coupon_cost)
    - contact_cost
)

model_df["churn_decile"] = make_decile(model_df["churn_risk"])
model_df["response_decile"] = make_decile(model_df["coupon_response_prob"])
model_df["value_decile"] = make_decile(model_df["predicted_sales_30d"])
model_df["profit_decile"] = make_decile(model_df["expected_profit_score"])

decile_summary = (
    model_df.groupby("profit_decile")
    .agg(
        customers=("customer_id", "nunique"),
        avg_churn_risk=("churn_risk", "mean"),
        avg_response_prob=("coupon_response_prob", "mean"),
        avg_predicted_sales=("predicted_sales_30d", "mean"),
        avg_expected_profit=("expected_profit_score", "mean"),
    )
    .round(3)
)

decile_summary

次に、デシルを掛け合わせて、より細かい施策グループを作る。小売の現場では、同じ「高リスク」でも、会員価値、値引き反応、カテゴリ嗜好によって打ち手が変わる。現場が理解しやすい顧客セグメントに整理したうえで、離反リスクが高い優良顧客、価格に敏感な顧客、特定カテゴリーへの関心が低下した顧客など、特徴に応じてクーポンやレコメンド内容を変える。

def assign_action_segment(row):
    if row["churn_decile"] <= 3 and row["value_decile"] <= 3:
        return "high_value_churn_risk"
    if row["response_decile"] <= 3 and row["coupon_sensitivity_tag"] == "high":
        return "price_sensitive"
    if row["category_interest_drop_flag"] == 1 and row["value_decile"] <= 5:
        return "category_interest_decline"
    if row["churn_decile"] >= 7 and row["value_decile"] <= 3:
        return "loyal_high_value"
    if row["profit_decile"] <= 3:
        return "profit_priority"
    return "regular_nurture"

model_df["action_segment"] = model_df.apply(assign_action_segment, axis=1)

action_segment_summary = (
    model_df.groupby("action_segment")
    .agg(
        customers=("customer_id", "nunique"),
        avg_churn_risk=("churn_risk", "mean"),
        avg_response_prob=("coupon_response_prob", "mean"),
        avg_expected_profit=("expected_profit_score", "mean"),
        main_category=("primary_category", lambda x: x.mode().iat[0]),
    )
    .round(3)
    .sort_values("avg_expected_profit", ascending=False)
)

action_segment_summary

さらに細かく分けたい場合は、LightGBMの葉番号を特徴量として使う。葉番号は、モデルが内部で作った「似た条件の会員」を表すため、単純なK-meansよりも、離反や反応に効く非線形な組み合わせを反映しやすい。これにRFMやカテゴリ嗜好を加えてクラスタリングすると、16前後の細かい運用セグメントを作れる。

from sklearn.cluster import KMeans
from sklearn.decomposition import TruncatedSVD
from sklearn.preprocessing import OneHotEncoder, StandardScaler

churn_leaf = churn_model.booster_.predict(all_features, pred_leaf=True)
response_leaf = response_model.booster_.predict(all_features, pred_leaf=True)
leaf_features = np.hstack([churn_leaf, response_leaf])

leaf_encoder = OneHotEncoder(handle_unknown="ignore")
leaf_sparse_matrix = leaf_encoder.fit_transform(leaf_features)
leaf_vector = TruncatedSVD(n_components=12, random_state=42).fit_transform(leaf_sparse_matrix)

score_features = StandardScaler().fit_transform(
    model_df[[
        "churn_risk",
        "coupon_response_prob",
        "predicted_sales_30d",
        "expected_profit_score",
        "rfm_score",
        "weekend_visit_share",
    ]].fillna(0)
)

micro_segment_input = np.hstack([leaf_vector, score_features])
micro_segment_model = KMeans(n_clusters=16, random_state=42, n_init=20)
model_df["micro_segment_id"] = micro_segment_model.fit_predict(micro_segment_input)

micro_segment_summary = (
    model_df.groupby("micro_segment_id")
    .agg(
        customers=("customer_id", "nunique"),
        avg_churn_risk=("churn_risk", "mean"),
        avg_response_prob=("coupon_response_prob", "mean"),
        avg_expected_profit=("expected_profit_score", "mean"),
        main_action_segment=("action_segment", lambda x: x.mode().iat[0]),
        main_category=("primary_category", lambda x: x.mode().iat[0]),
    )
    .round(3)
    .sort_values("avg_expected_profit", ascending=False)
)

micro_segment_summary

この設計では、LightGBMの役割は「最終的な顧客名簿を一発で出すこと」ではない。RFMとLifetimesから行動の土台を作り、LightGBMで離反、反応、価値を予測し、デシルで予算配分し、細分セグメントでクーポン内容やチャネルを変える。さらに、値引きしなくても買う会員に無駄なクーポンを配らないためには、次の効果検証で増分効果を確認する必要がある。

効果検証は配信後に必ず行う

会員分類は作って終わりではない。クーポン配信後に、利用率、来館率、購買金額、継続率を比較し、次回の配信条件を更新する必要がある。

ここでは、施策履歴の配信群、クーポン利用有無、配信後30日来館有無、配信後30日購買金額、新規会員フラグを使う。最低限、ターゲティング配信群と通常配信群を比較する。

campaign_result = (
    campaign_df.groupby("campaign_group")
    .agg(
        customers=("customer_id", "nunique"),
        coupon_redeem_rate=("coupon_redeemed", "mean"),
        visit_rate_30d=("visit_30d_after_send", "mean"),
        avg_sales_30d=("sales_30d_after_send", "mean"),
    )
    .round(3)
)

redeem_rate_lift = campaign_result.loc["targeted", "coupon_redeem_rate"] - campaign_result.loc["standard", "coupon_redeem_rate"]
visit_rate_lift = campaign_result.loc["targeted", "visit_rate_30d"] - campaign_result.loc["standard", "visit_rate_30d"]

print(f"coupon_redeem_rate_lift: {redeem_rate_lift:.1%}")
print(f"visit_rate_lift_30d: {visit_rate_lift:.1%}")

LightGBMを使った場合は、全体平均だけでなく、上位デシルで本当に反応が集中しているかを見る。利益デシル上位に配った会員ほど利用率、来館率、購買金額が高ければ、モデルが運用上使える順位を作れている可能性が高い。逆に上位デシルで差が出ない場合は、特徴量、ラベル期間、クーポン内容、配信チャネルを見直す。

campaign_scores = model_df[["customer_id", "profit_decile", "action_segment"]].drop_duplicates("customer_id")

decile_campaign_result = (
    campaign_df.merge(campaign_scores, on="customer_id", how="left")
    .groupby(["profit_decile", "campaign_group"])
    .agg(
        customers=("customer_id", "nunique"),
        coupon_redeem_rate=("coupon_redeemed", "mean"),
        visit_rate_30d=("visit_30d_after_send", "mean"),
        avg_sales_30d=("sales_30d_after_send", "mean"),
    )
    .round(3)
    .reset_index()
)

decile_campaign_result.head(20)

本格的には、ターゲティング配信群とホールドアウト群を同じデシル内で比較する。これにより、「もともと買う予定だった会員」ではなく、「配信したから動いた会員」を見つけやすくなる。

decile_lift = decile_campaign_result.pivot(
    index="profit_decile",
    columns="campaign_group",
    values="coupon_redeem_rate",
)

decile_lift["redeem_rate_lift"] = (
    decile_lift["targeted"]
    - decile_lift["holdout"]
)

decile_lift.sort_index().head(10)

とくに新規会員は、初回購買後の二回目利用までが重要である。新規会員だけを切り出し、初回カテゴリに近いクーポンと、館内回遊を促すクーポンのどちらが継続につながるかを検証すると、育成施策の精度が上がる。

new_member_result = (
    campaign_df.query("new_member_flag == 1")
    .groupby(["campaign_group", "coupon_type"])
    .agg(
        customers=("customer_id", "nunique"),
        coupon_redeem_rate=("coupon_redeemed", "mean"),
        retention_rate_30d=("visit_30d_after_send", "mean"),
        avg_sales_30d=("sales_30d_after_send", "mean"),
    )
    .round(3)
)

new_member_result

まとめ

小売業の会員分析では、まずRFMや統計タグで顧客の状態をわかりやすく整理し、K-meansで生活シーン別の会員像を作る。次に、Lifetimesで非契約型購買の生存確率を補助指標として持たせる。その上で、LightGBMで離反、クーポン反応、購買金額、期待利益を予測し、デシルと細分セグメントに変換する。最後にSQLでCRMに戻し、A/Bテストやホールドアウト比較で増分効果を確認すれば、配信対象、クーポン内容、配信タイミングを継続的に改善できる。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?