Article

解約率を下げるリターゲティング戦略:既存顧客に再アプローチ

高田晃太郎
解約率を下げるリターゲティング戦略:既存顧客に再アプローチ

顧客維持率を5%高めるだけで利益が25〜95%増えるというBain & Companyの分析は、いまだに示唆的です¹。さらにHarvard Business Reviewでしばしば引用される調査では、新規獲得のコストは既存顧客維持の5〜25倍に達しうるとされます²。業界の公開事例でも、休眠直前の既存顧客に対するリターゲティング(既存顧客への再アプローチ)は、獲得広告よりも低CPAでLTVを押し上げやすい傾向が報告されています。鍵は、解約の前兆シグナルを正しく捕捉し、適切なタイミング・頻度・チャネルで再アプローチすることです。表層的な「追いかけ配信」ではなく、データ基盤からモデル、媒体API、効果測定までを一貫して設計すると、解約率は構造的に低下します。この記事では、戦略の全体像を示しつつ、実務で使える実装例まで具体的に解説します。

解約率を動かすシグナルとタイミングの科学

解約はある日突然起きるわけではありません。プロダクトの利用頻度が鈍化し、重要機能へのタッチが減り、決済失敗が増え、サポートへのネガティブ接触が増加するなど、複数の前兆が重なった末に発生します。サブスクリプションでは、最終アクティブから7〜14日が危険域になるケースが多く、ECではカゴ落ち後の24〜72時間が再活性の勝負所になりがちです。これらのタイミングは業態ごとに最適値が異なるため、まずは計測基盤を共通化し、リード指標(将来の解約を先取りする指標)を時系列で評価できるようにします。以下のようなイベントスキーマを整備すると、チャネル横断のシグナル集約が容易になります。

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "event",
  "type": "object",
  "properties": {
    "user_id": {"type": "string"},
    "event_name": {"type": "string"},
    "ts": {"type": "string", "format": "date-time"},
    "plan": {"type": "string"},
    "value": {"type": "number"},
    "currency": {"type": "string"},
    "device_id": {"type": "string"},
    "email": {"type": "string"}
  },
  "required": ["user_id", "event_name", "ts"]
}

イベントはストリーミングで取り込み、レイテンシのSLO(Service Level Objective:サービスレベル目標)を定義します。たとえばPub/Subからの到達からDWH(データウェアハウス)整備まで10分以内、オーディエンス生成までさらに5分以内といった設計にすると、危険域に入った直後のユーザーへ鮮度の高い配信が可能になります。BigQueryのオンデマンド料金は1TBあたり数USD台の水準で³、数十GB規模の日次クエリであれば、解約抑止のインパクトに対して費用対効果は現実的です。

シグナルからセグメントを生成するクエリ

休眠予備軍の抽出は、単純な最終アクティブ日だけでは不十分です。機能利用の多様性、決済の異常、サポート接触などを統合し、重み付きスコアを作成します。以下はBigQueryでのセグメント生成例です(BigQueryはGoogleのクラウドDWHで、SQLで機械学習やETLを実行できます)。

CREATE OR REPLACE TABLE mart.audience_churn_risk AS
WITH last_activity AS (
  SELECT user_id,
         MAX(ts) AS last_ts,
         DATE_DIFF(CURRENT_DATE(), DATE(MAX(ts)), DAY) AS inactivity_days
  FROM raw.events
  WHERE event_name IN ('login','purchase','feature_use')
  GROUP BY user_id
), features AS (
  SELECT e.user_id,
         COUNTIF(e.event_name='purchase' AND e.ts > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)) AS purchases_30d,
         COUNTIF(e.event_name='payment_failed' AND e.ts > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)) AS payfail_30d,
         APPROX_COUNT_DISTINCT(IF(e.event_name='feature_use', e.properties.feature_key, NULL)) AS feature_diversity,
         ANY_VALUE(p.plan) AS plan
  FROM raw.events e
  LEFT JOIN raw.user_profile p USING(user_id)
  GROUP BY user_id
)
SELECT f.user_id,
       f.plan,
       l.inactivity_days,
       f.purchases_30d,
       f.payfail_30d,
       f.feature_diversity,
       SAFE_DIVIDE(f.purchases_30d, NULLIF(l.inactivity_days,0)) AS freq_ratio,
       (l.inactivity_days >= 7)
         OR (f.payfail_30d >= 1)
         OR (f.feature_diversity < 2) AS rule_based_risk
FROM features f
JOIN last_activity l USING(user_id);

この規則ベースのリスクに加えて、サバイバル分析(時間経過に伴う離脱確率の推定)で離脱のハザード(瞬間的な発生率)を推定すると、配信タイミングの最適化に役立ちます。Pythonのlifelinesを用いた分析は次のように実装できます。

import os
import pandas as pd
from google.cloud import bigquery
from lifelines import CoxPHFitter
from lifelines.exceptions import ConvergenceError

os.environ.setdefault("GOOGLE_CLOUD_PROJECT", "your-project")

def load_training_frame() -> pd.DataFrame:
    client = bigquery.Client()
    query = """
      SELECT user_id,
             inactivity_days AS duration,
             CAST(churned AS INT64) AS event,
             purchases_30d,
             payfail_30d,
             feature_diversity
      FROM mart.training_churn_survival
    """
    return client.query(query, job_config=bigquery.QueryJobConfig(priority=bigquery.QueryPriority.INTERACTIVE)).result().to_dataframe()

def fit_model(df: pd.DataFrame) -> CoxPHFitter:
    cph = CoxPHFitter()
    try:
        cph.fit(df, duration_col="duration", event_col="event", show_progress=False)
    except ConvergenceError as e:
        raise RuntimeError(f"Model failed to converge: {e}")
    return cph

if __name__ == "__main__":
    try:
        frame = load_training_frame()
        model = fit_model(frame)
        model.print_summary()
    except Exception as ex:
        # 運用ではStackdriver/Cloud Loggingに送る
        print(f"pipeline_error: {ex}")

このモデルから得られる時点別ハザードをリスト化し、例えばハザードが前日比で一定以上増加したユーザーにだけ配信するなどの制御を加えると、コストを抑えつつ高確率でコンバージョン(CVR)を得られます。

データ基盤とモデル化:セグメントから予測へ

規則とサバイバルに加えて、スコアリングのためにBigQuery MLで予測モデルを常時学習する運用は、エンジニアリング負荷が低く保守しやすいのが利点です。学習、評価、推論をSQLで完結でき、変化点への追従も容易です。

CREATE OR REPLACE MODEL mart.m_churn_xgb
OPTIONS(
  MODEL_TYPE='BOOSTED_TREE_CLASSIFIER',
  INPUT_LABEL_COLS=['churned'],
  DATA_SPLIT_METHOD='AUTO_SPLIT',
  MAX_ITERATIONS=30,
  EARLY_STOP=True
) AS
SELECT churned,
       inactivity_days,
       purchases_30d,
       payfail_30d,
       feature_diversity,
       SAFE_DIVIDE(purchases_30d, NULLIF(inactivity_days,0)) AS freq_ratio,
       plan
FROM mart.training_churn;

SELECT * FROM ML.EVALUATE(MODEL mart.m_churn_xgb);

CREATE OR REPLACE TABLE mart.scored_churn AS
SELECT user_id,
       predicted_churn_probs[OFFSET(1)] AS p_churn
FROM ML.PREDICT(MODEL mart.m_churn_xgb,
                TABLE mart.scoring_candidates);

スコアテーブルに対して配信対象しきい値をA/Bで最適化し、しきい値を高めにすると配信ボリュームを抑えつつCVRが上がる傾向が見られます。配信対象の生成はジョブ化して、障害時のリトライや部分的な再実行を安全にするために増分処理を採用します。dbtを使う場合のインクリメンタルモデルは次のように書けます。

{{ config(materialized='incremental', unique_key='user_id', incremental_strategy='merge') }}
WITH base AS (
  SELECT user_id, p_churn, CURRENT_TIMESTAMP() AS as_of FROM mart.scored_churn
)
SELECT * FROM base
{% if is_incremental() %}
WHERE as_of > (SELECT MAX(as_of) FROM {{ this }})
{% endif %}

推論スケジューラはCloud ComposerやWorkflowsで管理し、失敗時は指数バックオフで再試行します。処理の終端で媒体側APIへ送る段でのボトルネックは、ハッシュ化とアップロード時間です。メールアドレスはSHA-256で正規化した上で送信し、プライバシーと媒体のマッチ率(媒体側での識別一致率)の両立を図ります。

Pythonでのオーケストレーションとエラー制御

ジョブ実行と配信の連携までを一つのパイプラインで扱うと、障害点を限定できます。下記はスコア抽出からGoogle Adsの顧客リスト更新までを行う例です(カスタマーマッチを用いたリターゲティング)。

import hashlib
import time
from typing import Iterable
from google.cloud import bigquery
from google.ads.googleads.client import GoogleAdsClient
from google.api_core import retry

PROJECT = "your-project"
CUSTOMER_ID = "1234567890"
USER_LIST_ID = "customers_list"

bq = bigquery.Client(project=PROJECT)

def sha256_email(email: str) -> str:
    norm = email.strip().lower()
    return hashlib.sha256(norm.encode("utf-8")).hexdigest()

@retry.Retry(deadline=60.0)
def fetch_candidates() -> Iterable[str]:
    sql = """
      SELECT email FROM mart.scored_churn sc
      JOIN raw.user_profile u USING(user_id)
      WHERE sc.p_churn >= 0.7 AND u.email IS NOT NULL
    """
    for row in bq.query(sql):
        yield row["email"]

def update_google_ads(emails: Iterable[str]) -> None:
    client = GoogleAdsClient.load_from_storage()
    user_data_service = client.get_service("UserDataService")
    operations = []
    for e in emails:
        try:
            hashed = sha256_email(e)
            user_identifier = client.get_type("UserIdentifier")
            user_identifier.hashed_email = hashed
            user_data = client.get_type("UserData")
            user_data.user_identifiers.append(user_identifier)
            op = client.get_type("UserDataOperation")
            op.create.CopyFrom(user_data)
            operations.append(op)
        except Exception:
            continue
    if not operations:
        return
    req = client.get_type("UploadUserDataRequest")
    req.customer_id = CUSTOMER_ID
    req.customer_match_user_list_metadata.user_list = client.get_service("UserListService").user_list_path(CUSTOMER_ID, USER_LIST_ID)
    req.operations.extend(operations)
    user_data_service.upload_user_data(request=req)

if __name__ == "__main__":
    try:
        emails = list(fetch_candidates())
        update_google_ads(emails)
    except Exception as e:
        print(f"dispatch_error: {e}")
        time.sleep(5)

マッチ率はチャネルや地域で大きく変わりますが、メールベースのカスタムオーディエンスでは40〜70%の幅に収まることが一般的です。なお、媒体側は特定のマッチ率を保証しておらず、データの正規化やフィールドの充足度に大きく依存します⁶。電話番号やモバイル広告IDを補足キーとして併用すると、到達率が改善します。

チャネル横断の配信実装:頻度・創造性・整合性

再アプローチの価値は、適切な頻度とクリエイティブで初めて解放されます。高リスクと判定されたユーザーに無制限に広告やプッシュを浴びせれば、短期的なCVRは伸びても、スパム判定とブランド毀損で長期のLTVを下げかねません。頻度キャップ(ユーザーごとの露出上限)をユーザー単位で一元管理する設計が有効です。広告、メール、プッシュ、アプリ内メッセージの露出ログを一つのテーブルにまとめ、チャネル横断で日次・週次の上限とクールダウン期間を適用します。次の簡易実装は、Redisを使って直近24時間の露出回数を制限する例です。

import time
import redis

r = redis.Redis(host='localhost', port=6379, db=0)
CAP = 3
WINDOW_SEC = 24 * 3600

def can_expose(user_id: str, channel: str) -> bool:
    key = f"exp:{user_id}:{channel}"
    now = int(time.time())
    with r.pipeline() as pipe:
        pipe.zremrangebyscore(key, 0, now - WINDOW_SEC)
        pipe.zcard(key)
        res = pipe.execute()
    count = res[-1]
    return count < CAP

def record_exposure(user_id: str, channel: str) -> None:
    key = f"exp:{user_id}:{channel}"
    now = int(time.time())
    r.zadd(key, {str(now): now})
    r.expire(key, WINDOW_SEC)

媒体APIの併用では、MetaのカスタムオーディエンスやGoogle Adsのカスタマーマッチに加え、CRM側のBrazeやCustomer.ioと連動させ、同一のハザード上昇イベントをトリガにマルチチャネルで同期させます。Metaへのアップロードをrequestsで行う簡易例は以下です。

import hashlib
import json
import os
import requests

ACCESS_TOKEN = os.getenv("META_TOKEN")
AUDIENCE_ID = os.getenv("META_AUDIENCE")

def normalize_email(e: str) -> str:
    return hashlib.sha256(e.strip().lower().encode("utf-8")).hexdigest()

def upload_emails(emails):
    payload = {
        "payload": {
            "schema": ["EMAIL_SHA256"],
            "data": [[normalize_email(e)] for e in emails]
        }
    }
    resp = requests.post(
        f"https://graph.facebook.com/v19.0/{AUDIENCE_ID}/users",
        params={"access_token": ACCESS_TOKEN},
        headers={"Content-Type": "application/json"},
        data=json.dumps(payload),
        timeout=30
    )
    if not resp.ok:
        raise RuntimeError(f"meta_upload_failed: {resp.status_code} {resp.text}")

クリエイティブは、休眠理由の仮説に合致したものを用意します。機能価値の再提示、料金プランの選び直し、未使用特典の可視化、カスタマーサクセスの伴走提案など、ユーザーの文脈を尊重したメッセージが効きます。価格インセンティブは最後のカードにし、まずはプロダクト価値の再発見を促す流れが長期のLTVを損なわないと考えられます。クリエイティブの設計論については、社内ナレッジ化を進めると同時に、関連する実装記事も参考になるはずです。たとえば 特徴量ストア設計クリエイティブA/BテストBigQueryコスト最適化 を併読すると、運用の精度が一段上がります。

ストリーム基盤での低レイテンシ運用

決定版は、ストリーム上のフィルタリングとバッファリングです。Pub/Subに到達したイベントに対し、Dataflowでハザード上昇ルールを適用し、5分間のバッファで重複を抑えつつ、キューに投入します。下流では上記の頻度キャップを適用し、超過分は破棄する設計にします。エンドツーエンドの遅延を15分以内に収めることを一つの指標にすると、カゴ落ちや決済失敗直後の再アプローチで体感的な鮮度を確保できます。

効果測定とROI:インクリメンタリティを証明する

既存顧客への配信は、自己選択バイアスが強く、単純な事後CVR比較では効果を誤認しがちです。地理ベースの差分検証やユーザーホールドアウトにより、インクリメンタリティ(施策がもたらした増分効果)を評価します。ユーザーホールドアウトの基本形は、対象のうち一定割合を意図的に非配信にし、配信群との差を測定するものです。バリアンスを下げるためにCUPED(Control Using Pre-Experiment Data)を併用すると、少ないサンプルでも検出力を確保できます⁴⁵。

import numpy as np
import pandas as pd

# df: user_id, group(0/1), y(post), x(pre) を含む

def cuped_adjust(df: pd.DataFrame) -> float:
    cov = np.cov(df["y"], df["x"], bias=True)[0,1]
    var = np.var(df["x"], ddof=0)
    theta = cov / var if var > 0 else 0.0
    df["y_adj"] = df["y"] - theta * df["x"]
    lift = df.loc[df.group==1, "y_adj"].mean() - df.loc[df.group==0, "y_adj"].mean()
    return float(lift)

媒体側のリフトテスト機能(Meta Conversion LiftやGoogle Geo Experiments)が使えるなら、オンサイト売上との突合せを行い、媒体計測のブラックボックスを補完します。BigQueryでの収益インクリメント集計は次のように書けます。

WITH agg AS (
  SELECT group, SUM(revenue) AS rev, COUNT(DISTINCT user_id) AS users
  FROM mart.holdout_result
  WHERE window BETWEEN DATE_SUB(CURRENT_DATE(), INTERVAL 14 DAY) AND CURRENT_DATE()
  GROUP BY group
)
SELECT (SELECT rev FROM agg WHERE group=1) - (SELECT rev FROM agg WHERE group=0) AS incr_revenue,
       (SELECT users FROM agg WHERE group=1) AS treated_users;

ROIの算式は単純です。増分収益から媒体費とオペレーションコストを差し引きます。仮に配信対象10万人のうち2ポイントの解約抑止が実現し、1ユーザーあたりの残存期間価値が1500円、媒体費が300万円、DWHと配信オペレーションで50万円かかるとします。増分収益は2000人×1500円で300万円、費用合計は350万円ですから、短期のROIは0.86になります。ここからクリエイティブの改善やしきい値最適化で上振れ余地があるほか、抑止ユーザーの紹介や追加購入といった二次効果を含めると、通期のLTV寄与でプラスに転じる可能性があります。こうした試算はデータ連携の初期設計とセットで行い、期待値がマイナスの配信は実施しないガードを設けるべきです。運用論の詳細は インクリメンタリティ検証ガイド も参照してください。

信頼できるダッシュボードと運用のSLO

意思決定の最後の一押しは、信頼できる可視化にあります。配信量、マッチ率、CVR、CPA、増分収益、在庫状況を一枚で見られるダッシュボードをLookerやData Studioで用意し、データ鮮度と欠損率のSLOを定義します。鮮度は15分以内、欠損率は0.5%未満など、実現可能で厳しすぎない指標にするのが現実的です。障害時はサーキットブレーカーで配信を自動停止し、誤配を未然に防ぎます。配信の停止・再開はワンクリックでできる運用レバーを用意し、夜間の当番負荷を下げる設計にすると、チームの持続性が高まります。さらに深掘りする場合は、リアルタイムCDP設計 も読み合わせると全体像が明瞭になります。

まとめ:既存顧客に敬意を払い、技術で支える

解約率を下げるリターゲティングは、アルゴリズムだけの勝負ではありません。ユーザーの文脈と都合に沿ったタイミング、過剰露出を避ける頻度制御、価値の再発見を促すクリエイティブ、そして効果を謙虚に見極める測定が揃って初めて、持続的な成果に結びつきます。ここまで見てきたように、イベントスキーマの整備、サバイバルと機械学習の併用、媒体API連携、頻度キャップ、CUPEDを含む測定設計を一本のパイプラインに通すことで、再現性のある運用が可能になります。まずは最終アクティブからの危険域を自社データで可視化し、ルールベースの最小構成で動かしてみる。次にスコアリングと頻度制御を追加し、最後にインクリメンタリティの検証で投資判断を固める。この小さな前進の積み重ねが、積み上げ式のLTV改善を生みます。あなたのプロダクトは、誰にどんな価値をもう一度伝えたいでしょうか。今日のユーザーに敬意を払いながら、技術で静かに背中を押す。その一歩が、解約率の曲線を確かに変えていきます。

参考文献

  1. Bain & Company. Prescription for Cutting Costs. 2001. https://www.bain.com/insights/prescription-for-cutting-costs-bain-brief/
  2. Harvard Business Review. The Value of Keeping the Right Customers. 2014. https://hbr.org/2014/10/the-value-of-keeping-the-right-customers
  3. Google Cloud. BigQuery pricing. https://cloud.google.com/bigquery/pricing
  4. Amplitude. CUPED explanation guide. https://amplitude.com/en-us/explore/experiment/cuped-explanation-guide
  5. Statsig. CUPED: A technique to reduce variance. https://statsig.com/blog/cuped
  6. Meta Business Help Center. Best practices to improve the match rate for a customer list. https://www.facebook.com/business/help/893401570414490