D ビジネス/PM — 販促メール「週2→週5・全員一律」を、リストという在庫を含む 2 年の増分粗利で評価し直す(2 週 +49.1% → 2 年 −14,772 万円・層別 H3/M2/L1 で +8,165 万円)

2026-10-15 木曜 木曜 D: ビジネス/PM ★★★★★ 在庫(Stock & Flow)/ 打ち切りの偏り 苦情率 0.10% / 0.30% / 段階展開

概要

📉

1 通の価値は逓減する

週5 にしても最初の 2 週は +49.1%。+150% の線形外挿は自分のテスト(+38%)とも合わない。

🛢️

リストは戻らない在庫

2 年で失う人数 35.1 万 → 87.8 万人。配信停止者には再同意なしに送れない。

✂️

打ち切りは食いつぶしを得に見せる

NPV だけなら週5 が +1,519 万円で勝ち。期末在庫を足すと −14,772 万円。

🧱

苦情率の崖は全員にかかる

週5 全員で苦情率 0.156%、ぶれると 3.8% で 0.30% 超え。層別 H3/M2/L1 は 0.052%。

問題

MOps 販促システムチームの新任マーケ担当が、販促メールの配信頻度を上げる提案を来週の部長会に出す。あなたはエンジニア側のレビューを頼まれた。配信基盤(ESP 連携・配信停止の受付・Postmaster Tools の監視)はあなたのチームが持っている。

制約・前提条件(すべて 仮定。会議前に ESP のログ・既存ホールドアウトの値に置換)

層定義人数a(円/人/週)k停止/通苦情/通格下げ/通格上げ/週
H直近 30 日開封20 万30.02.50.05%0.01%0.20%(→M)—
M31〜90 日30 万8.02.00.10%0.04%0.15%(→L)1.0%(→H)
L90 日以上未開封50 万1.51.50.20%0.15%—0.5%(→M)
  • 週 f 通の 1 人 1 週の増分粗利 = a ×(1 − e^(−f/k))。売上ではなくホールドアウト比較の粗利(2026-09-24 の ITT)
  • 1 通増えるごとに停止率・苦情率は 25% 増える(fatigue = 0.25、週2 で 1.0 倍)
  • 新規登録 週 3,000 人(H へ)、送信費 0.03 円/通、割引率 年 8%
  • ドメイン全体の苦情率が週 0.30% 超 → 翌週から 4 週、受信箱到達率 0.6 倍
  • 期末(104 週)に残った人の価値: H 600 円・M 200 円・L 20 円
  • 配信停止した人には特定電子メール法により再同意なしに送れない

期待する回答形式: 問題点 8 点/試算のコードとテスト/実行例/部長会に出す提案の書き直し/判断ルールと止め方/迷った案と前提が変わったときの判断/扱わなかった範囲

悪い案 (Before)

この提案に 8 つの問題 が隠れています。見つけてから先へ進んでください。
後輩の提案

件名: 販促メールを週2通 → 週5通へ(全会員一律、来月1日から)

  1. 1 通あたりの売上はほぼ一定なので、週5 にすればメール経由売上は 2.5 倍(+150%)
  2. 2 週間の A/B テスト(各群 5 万人)で受信者あたり売上 12.0 → 16.6 円(+38%)。実証済み
  3. 配信停止率は 1 通あたり約 0.1%。業界平均並みで問題なし
  4. 送信費は 1 通 0.03 円。増えるのは月 1,300 万通 ≒ 39 万円で誤差
  5. KPI は「メール経由売上(ラストクリック)」。来月から全会員 100 万人に適用
問題点サマリー(8点)
1-3測り方 — 線形外挿・ラストクリック売上・有意でない 2 週間
4-5在庫 — リストの減りを見ない・打ち切りの偏り
6-7対象と制約 — 全員一律・苦情率の崖を見ない
8止め方 — 一斉適用で停止条件なし

ヒント(段階的開示)

ヒント1 — 方向性
2 週間のテストで「見えるもの」と「見えないもの」を分ける。購読者リストは毎週使うと減る 在庫。在庫を早く使えば目先の売上は増える。それは得か、前借りか。
ヒント2 — アプローチ
  • 1 通目と 5 通目の価値は同じか(逓減)
  • 頻度を上げたときの損は 3 つ: 配信停止(戻らない)・格下げ(沈黙の離脱)・迷惑メール報告(ドメイン全体の到達率)
  • 期間を区切るなら、期末に残った在庫を数えないと食いつぶす方針ほど得に見える
  • 反応が 20 倍違う層に同じ数を送っていないか
  • +38% は、ばらつき(標準偏差 400 円)に対して偶然と区別できるか
ヒント3 — 誘導
抜ける = n × (1 − (1 − 停止率 − 苦情率)^f)     ← 戻らない
格下げ = 残った人 × 格下げ率 × f                  ← H→M→L
価値   = Σ 割引(週) × Σ層 n × (a(1−e^(−f/k)) − f×送信費) + 割引(104) × Σ層 期末人数 × 単価
苦情率 = Σ 苦情 / Σ 受信箱に届いた通数  → 0.30% 超で罰則
比べる: 週2/3/5 全員、層ごとの 7^3 = 343 通り(苦情 < 0.10%)、2,000 回ぶらして P10 と「現状より悪い確率」
名指す: 週5 と週2 が並ぶ fatigue(期末在庫を数える/数えない)

問題点分析(8点)

#問題点分類何が起きているか(出所つき)改善方法
1線形外挿測り方最初の 2 週は 1,069.6 → 1,595.2 万円で +49.1% <実測:試算>。自分のテストの +38% とも合わない頻度ごとの凹の反応曲線
2ラストクリック売上を KPI に測り方送らなくても買った人を含み、通数を増やすほど帰属が増える。粗利・値引きを引いていないホールドアウトとの増分粗利(ITT)
32 週間で「実証済み」統計sd 400 円・各群 5 万人で z = 1.82 < 1.96 <導出>。新奇性で初週は高く出る信頼区間、長く走るホールドアウト
4リストを在庫と見ない在庫2 年で失う 35.1 万 → 87.8 万人、期末 96.1 → 43.4 万人 <実測:試算>停止・格下げを毎週数える
5打ち切りの偏り在庫NPV だけなら週5 が +1,519 万円で勝ち、期末在庫込みで −14,772 万円。分岐 fatigue 0.288 → 0.017期末在庫の価値を足し、単価の感度を見る
6全員一律対象反応の上限 H 30 円 / L 1.5 円(20 倍)。343 通りの最適 H3/M2/L1 で +8,165 万円、悪い確率 0.2%層ごとに頻度を決める
7苦情率を見ない制約週5 全員で最大 0.156%(推奨 0.10% 超)、ぶれると 3.8% で 0.30% 超、罰則はドメイン全体へ苦情 < 0.10% を制約に置く
8一斉適用・停止条件なし止め方失った在庫は戻らない。週5 全員は 98.0% で現状より悪く、P10 −25,914 万円10% ホールドアウト+段階展開+停止条件

在庫の構造と、2 週と 2 年の逆転(SVG)

購読者リストは「在庫」: 送るたびに格下げされ、戻らずに抜ける(構造は Stock & Flow) 赤 = 頻度 f に比例して増える流れ(f が増えると fatigue で 1 通あたりの率も上がる)。緑 = 頻度によらない流れ。率はすべて仮定 新規登録3,000 人/週 H 20 万直近 30 日開封a = 30 円/週 M 30 万31〜90 日a = 8 円/週 L 50 万90 日以上未開封a = 1.5 円/週 格下げ 0.2%×f 格上げ 1% 格下げ 0.15%×f 格上げ 0.5% 停止 0.05%・苦情 0.01%停止 0.10%・苦情 0.04%停止 0.20%・苦情 0.15% 戻らない(配信停止者には再同意なしに送れない: 特定電子メール法) 苦情の合計 ÷ 受信箱に届いた通数 > 0.30% → 翌週から 4 週、全層の到達率 0.6 倍(崖) 期末単価H 600 円M 200 円L 20 円

設計パターン: Stock & Flow(在庫と流れ)。頻度 f は H の「収穫」を増やすが、同じ f が赤い流れ(格下げ・停止・苦情)を太くする。収穫だけを 2 週測ったのが後輩の提案。

2 週で一番の方針が、2 年では最下位(実測:試算、入力は仮定) 最初の 2 週の増分粗利(万円) 2 年の価値(NPV+期末リスト、万円) 期末リスト 週2全員(現状) 1,070 110,575 96.1 万人 週3全員 1,330 112,795 75.4 万人 週5全員(後輩案) 1,595 95,803 43.4 万人 H5/M3/L1 1,476 111,763 90.1 万人 H3/M2/L1(採用) 1,213 118,740 104.3 万人 90,000 から(軸を切っています)

左の列(2 週)で一番長い週5 全員が、右の列(2 年+期末リスト)では一番短い。層別 H3/M2/L1 は 2 週では最も控えめだが、2 年では最大で、期末のリストも始めより多い。

Bad → Good(評価の仕方の差分)

Bad
per_email_sales = 12.0 / 2           # 1 通の価値は一定とみなす(逓減なし)
uplift = 5 * per_email_sales / 12.0 - 1  # +150% と主張。テストの +38% とも合わない
kpi = "メール経由売上(ラストクリック)"  # 送らなくても買った人を含む
test_weeks = 2                         # 在庫の減りが見えない長さ。z=1.82 で有意でもない
unsub = 0.001                          # 全体平均。L の苦情 0.15%/通 を隠す
rollout = {"all_members": 5}            # 100 万人に一斉、停止条件なし
# 結果: 2 年で −14,772 万円(期末在庫込み)、悪い確率 98.0%、崖 3.8%
Good
g = a * (1 - exp(-f / k))                # 層ごとの凹の反応曲線(増分粗利)
r = simulate(policy, world)              # 104 週、停止・苦情・格下げで在庫を更新
value = r.npv_yen + terminal * disc      # 期末に残った在庫を数える(打ち切りの偏りを消す)
p, _ = best_policy(complaint_cap=0.001)  # 343 通り、苦情 0.10% 未満を制約に
mc = monte_carlo(policies)               # 2,000 回ぶらし、P10 と悪い確率を見る
be = breakeven_fatigue(uniform(5))       # 結論を決める仮定を名指し: 0.017
# 結果: H3/M2/L1 で +8,165 万円、悪い確率 0.2%、苦情率最大 0.052%

適用した考え方: Stock & Flow、逓減する反応曲線、期末価値(terminal value)による打ち切りの補正、制約つき全探索、モンテカルロによる頑健性、損益分岐による仮定の名指し、段階展開+ガードレール。

模範解答

試算(freq.py、295 行・標準ライブラリのみ)

"""販促メールの配信頻度を「2 週間の売上」ではなく「104 週の割引後の増分粗利」で決める試算。

Python 3.12+、標準ライブラリのみ。

出所ラベル:
    セグメント人数・反応曲線・配信停止率・苦情率・格下げ率・送信単価・罰則の強さ = 仮定
    (会議前に ESP のログ・ホールドアウトの値で置き換える)。
    苦情率 0.10% / 0.30% の線 = Google「Email sender guidelines」(support.google.com/a/answer/81126,
    参照 2026-10-15) の Postmaster Tools スパム率の目安。
    比較結果・確率 = この試算の実測(seed 固定)。

モデルの骨格:
    リストは「在庫」。1 通ごとに一部が配信停止・苦情で抜け、一部が反応の弱い層へ格下げされる。
    週ごとの増分粗利は頻度 f に対して凹(逓減): g(f) = a * (1 - exp(-f / k))。
    ドメイン全体の苦情率が 0.30% を超えた週の翌週から PENALTY_WEEKS 週、受信箱到達率が下がる。
"""

from __future__ import annotations

import itertools
import math
import random
import statistics
from dataclasses import dataclass, replace

HORIZON_WEEKS = 104             # 評価期間(2 年)。2 週間では在庫の減りが見えない
ANNUAL_DISCOUNT = 0.08          # 割引率(年)<仮定>
WEEKLY_DISCOUNT = (1 + ANNUAL_DISCOUNT) ** (1 / 52) - 1
NEW_SUBS_PER_WEEK = 3_000     # 新規登録(頻度によらない。新規は H に入る)<仮定>
# 期末に残った購読者 1 人の価値(円)<仮定>。H は再獲得費(CAC)相当、眠っている L はほぼ価値なし
TERMINAL_YEN = {"H": 600.0, "M": 200.0, "L": 20.0}
SEND_COST_YEN = 0.03            # 1 通あたりの ESP 送信費(円)<仮定>
BASE_FREQ = 2.0                 # 現状の週あたり通数
COMPLAINT_CLIFF = 0.003         # 0.30%: Google が「決して達しない」よう求める線
COMPLAINT_TARGET = 0.001        # 0.10%: Google が推奨する上限
PENALTY_WEEKS = 4               # 崖を越えた後に到達率が落ちる週数 <仮定>
PENALTY_INBOX = 0.6             # その間の受信箱到達率(1.0 = 平常)<仮定>
FREQ_GRID = (0.0, 0.5, 1.0, 2.0, 3.0, 4.0, 5.0)  # 0.5 = 隔週
TRIALS = 2_000
SEED = 15


@dataclass(frozen=True)
class Segment:
    """反応の強さで分けた購読者の層。

    Attributes:
        key: 層の名前(H=直近30日に開封、M=31〜90日、L=90日以上未開封)。
        n0: 開始時点の人数。
        a: 週あたり増分粗利の上限(円/人/週)。
        k: 逓減の速さ(小さいほど早く頭打ち)。
        unsub0: 週 2 通のときの 1 通あたり配信停止率。
        complaint0: 週 2 通のときの 1 通あたり苦情(迷惑メール報告)率。
        down: 1 通あたりの格下げ率(H→M、M→L)。L は 0。
        up: 頻度によらない週あたりの格上げ率(購入などで戻る)。
    """

    key: str
    n0: float
    a: float
    k: float
    unsub0: float
    complaint0: float
    down: float
    up: float


@dataclass(frozen=True)
class World:
    """不確かなパラメータの組。"""

    segments: tuple[Segment, Segment, Segment]
    fatigue: float = 0.25   # 1 通増えるごとに配信停止率・苦情率が何割増えるか <仮定>


BASE_WORLD = World(
    segments=(
        Segment("H", 200_000, a=30.0, k=2.5, unsub0=0.0005, complaint0=0.0001, down=0.002, up=0.0),
        Segment("M", 300_000, a=8.0, k=2.0, unsub0=0.0010, complaint0=0.0004, down=0.0015, up=0.010),
        Segment("L", 500_000, a=1.5, k=1.5, unsub0=0.0020, complaint0=0.0015, down=0.0, up=0.005),
    )
)


def weekly_profit_per_person(seg: Segment, f: float) -> float:
    """1 人 1 週あたりの増分粗利(円)。頻度に対して逓減する。"""
    return seg.a * (1 - math.exp(-f / seg.k))


def fatigue_mult(world: World, f: float) -> float:
    """週 f 通のときの配信停止率・苦情率の倍率(週 2 通で 1.0)。下限 0.5。"""
    return max(0.5, 1 + world.fatigue * (f - BASE_FREQ))


@dataclass(frozen=True)
class Result:
    """1 つの配信方針を HORIZON_WEEKS 週回した結果。"""

    npv_yen: float              # 割引後の増分粗利 − 送信費
    first2w_yen: float          # 最初の 2 週の増分粗利(後輩が見た数字)
    end_list: float             # 最終週の購読者数
    end_by_seg: tuple[float, float, float]  # 最終週の H・M・L の人数
    lost_subscribers: float     # 配信停止・苦情で失った人数
    max_complaint: float        # 週ごとのドメイン苦情率の最大
    cliff_weeks: int            # 0.30% を超えた週の数

    @property
    def value_yen(self) -> float:
        """NPV + 期末に残ったリストの価値(層ごとの単価で評価し、期末時点から割り引く)。

        104 週で打ち切ると、リストを食いつぶす方針ほど得に見える。その偏りを消すための項。
        """
        disc = (1 + WEEKLY_DISCOUNT) ** -HORIZON_WEEKS
        terminal = sum(n * TERMINAL_YEN[k] for k, n in zip(("H", "M", "L"), self.end_by_seg))
        return self.npv_yen + terminal * disc


def simulate(policy: dict[str, float], world: World = BASE_WORLD) -> Result:
    """配信方針(層ごとの週あたり通数)を HORIZON_WEEKS 週シミュレーションする。

    Args:
        policy: {"H": 5, "M": 3, "L": 1} のような層ごとの週あたり通数。
        world: パラメータの組。

    Returns:
        Result。
    """
    n = {s.key: s.n0 for s in world.segments}
    seg = {s.key: s for s in world.segments}
    npv = first2w = lost = 0.0
    max_c = 0.0
    cliff_weeks = 0
    penalty_left = 0
    for week in range(HORIZON_WEEKS):
        inbox = PENALTY_INBOX if penalty_left > 0 else 1.0
        penalty_left = max(0, penalty_left - 1)
        disc = (1 + WEEKLY_DISCOUNT) ** -week
        sent = complaints = 0.0
        week_profit = 0.0
        flows: dict[str, float] = {}
        for key in ("H", "M", "L"):
            s, f = seg[key], policy[key]
            mult = fatigue_mult(world, f)
            week_profit += n[key] * (weekly_profit_per_person(s, f) * inbox - f * SEND_COST_YEN)
            c_rate = s.complaint0 * mult
            u_rate = s.unsub0 * mult
            # 1 通ごとに独立に抜けるとみなす(苦情を入れた人も配信対象から外す)
            leave = n[key] * (1 - (1 - u_rate - c_rate * inbox) ** f)
            sent += n[key] * f * inbox
            complaints += n[key] * f * c_rate * inbox
            flows[key + "_leave"] = leave
            flows[key + "_down"] = (n[key] - leave) * min(1.0, s.down * f)
            flows[key + "_up"] = (n[key] - leave) * s.up
        npv += week_profit * disc
        if week < 2:
            first2w += week_profit
        # 在庫の更新: 抜ける → 格下げ(H→M→L)→ 格上げ(L→M→H)
        for key in ("H", "M", "L"):
            n[key] -= flows[key + "_leave"]
            lost += flows[key + "_leave"]
        n["H"] += -flows["H_down"] + flows["M_up"] + NEW_SUBS_PER_WEEK
        n["M"] += flows["H_down"] - flows["M_down"] - flows["M_up"] + flows["L_up"]
        n["L"] += flows["M_down"] - flows["L_up"]
        rate = complaints / sent if sent else 0.0
        max_c = max(max_c, rate)
        if rate > COMPLAINT_CLIFF:
            cliff_weeks += 1
            penalty_left = PENALTY_WEEKS
    return Result(npv, first2w, sum(n.values()), (n["H"], n["M"], n["L"]), lost, max_c, cliff_weeks)


def uniform(f: float) -> dict[str, float]:
    """全員に同じ頻度で送る方針。"""
    return {"H": f, "M": f, "L": f}


def best_policy(world: World = BASE_WORLD, complaint_cap: float = COMPLAINT_TARGET
                ) -> tuple[dict[str, float], Result]:
    """層ごとの頻度を FREQ_GRID の全組(7^3=343 通り)で調べ、苦情率の上限を守る中で価値(NPV+期末リスト)最大を返す。"""
    best: tuple[dict[str, float], Result] | None = None
    for h, m, l in itertools.product(FREQ_GRID, repeat=3):
        p = {"H": h, "M": m, "L": l}
        r = simulate(p, world)
        if r.max_complaint > complaint_cap:
            continue
        if best is None or r.value_yen > best[1].value_yen:
            best = (p, r)
    assert best is not None  # 全員 0 通は必ず上限を満たす
    return best


def perturb(world: World, rng: random.Random) -> World:
    """反応・離脱・苦情・格下げを対数正規でぶらした世界を返す(σ=0.35、中央値は元の値)。"""
    def lf() -> float:
        return math.exp(rng.gauss(0, 0.35))
    segs = tuple(
        replace(s, a=s.a * lf(), unsub0=s.unsub0 * lf(), complaint0=s.complaint0 * lf(),
                down=s.down * lf())
        for s in world.segments
    )
    return World(segments=segs, fatigue=world.fatigue * lf())  # type: ignore[arg-type]


def monte_carlo(policies: dict[str, dict[str, float]], trials: int = TRIALS, seed: int = SEED
                ) -> dict[str, dict[str, float]]:
    """各方針の価値(NPV+期末リスト)差(対 週2全員)の平均・P10、現状より悪い確率、崖を越える確率を返す。"""
    rng = random.Random(seed)
    diffs: dict[str, list[float]] = {k: [] for k in policies}
    cliffs: dict[str, int] = {k: 0 for k in policies}
    for _ in range(trials):
        w = perturb(BASE_WORLD, rng)
        base = simulate(uniform(BASE_FREQ), w).value_yen
        for name, p in policies.items():
            r = simulate(p, w)
            diffs[name].append(r.value_yen - base)
            cliffs[name] += r.cliff_weeks > 0
    out = {}
    for name, d in diffs.items():
        d.sort()
        out[name] = {
            "mean": statistics.fmean(d),
            "p10": d[int(0.10 * len(d))],
            "p_worse": sum(x < 0 for x in d) / len(d),
            "p_cliff": cliffs[name] / trials,
        }
    return out


def two_week_test_z(mean_a: float, mean_b: float, sd: float, n_per_arm: int) -> float:
    """2 群の受信者あたり売上の差の z 値(正規近似、等分散)。"""
    se = sd * math.sqrt(2 / n_per_arm)
    return (mean_b - mean_a) / se


def breakeven_fatigue(policy: dict[str, float], lo: float = 0.0, hi: float = 2.0,
                      terminal: bool = True) -> float:
    """方針の価値(terminal=False なら NPV だけ)が 週2全員 と等しくなる fatigue(1 通増えるごとの離脱率の増え方)を二分法で求める。"""
    def gap(fat: float) -> float:
        w = replace(BASE_WORLD, fatigue=fat)
        a, b = simulate(policy, w), simulate(uniform(BASE_FREQ), w)
        return (a.value_yen - b.value_yen) if terminal else (a.npv_yen - b.npv_yen)
    if gap(lo) < 0 or gap(hi) > 0:
        return float("nan")
    for _ in range(60):
        mid = (lo + hi) / 2
        lo, hi = (mid, hi) if gap(mid) > 0 else (lo, mid)
    return (lo + hi) / 2


def main() -> None:
    """会議資料に載せる数字を出す。"""
    man = 1e4  # 万円
    print("== 後輩の主張の再現 ==")
    r2, r5 = simulate(uniform(2)), simulate(uniform(5))
    print(f"最初の2週の増分粗利: 週2 {r2.first2w_yen/man:,.1f}万円 / 週5 {r5.first2w_yen/man:,.1f}万円"
          f" (+{(r5.first2w_yen/r2.first2w_yen-1)*100:.1f}%)  線形外挿の主張は +150%")
    z = two_week_test_z(12.0, 16.6, 400.0, 50_000)
    print(f"2週テスト(各群5万人, 受信者あたり売上 12.0→16.6円, sd 400円) の z = {z:.2f}")
    print()
    print("== 104週・割引後(最頻値の世界) ==")
    policies = {
        "週2全員(現状)": uniform(2), "週3全員": uniform(3), "週5全員(後輩案)": uniform(5),
        "H5/M3/L1": {"H": 5, "M": 3, "L": 1},
    }
    opt_p, _ = best_policy()
    policies[f"最適(苦情<0.10%) H{opt_p['H']:g}/M{opt_p['M']:g}/L{opt_p['L']:g}"] = opt_p
    for name, p in policies.items():
        r = simulate(p)
        print(f"{name:<28} NPV {r.npv_yen/man:>7,.0f}  +期末 {r.value_yen/man:>7,.0f}万円  2週 {r.first2w_yen/man:>6,.0f}万円"
              f"  最終リスト {r.end_list/1e4:>5.1f}万人  失った {r.lost_subscribers/1e4:>5.1f}万人"
              f"  苦情率最大 {r.max_complaint*100:.3f}%  崖 {r.cliff_weeks}週")
    print()
    print(f"== 不確かさ({TRIALS}回, seed={SEED}): 週2全員との差 ==")
    for name, s in monte_carlo(policies).items():
        print(f"{name:<28} 平均 {s['mean']/man:>+8,.0f}万円  P10 {s['p10']/man:>+8,.0f}万円"
              f"  現状より悪い {s['p_worse']*100:5.1f}%  崖を越える {s['p_cliff']*100:5.1f}%")
    print()
    print("== 期末リストの単価を変えたときの最適(苦情<0.10%) ==")
    original = dict(TERMINAL_YEN)
    for scale in (0.0, 0.5, 1.0, 2.0):
        TERMINAL_YEN.update({k: v * scale for k, v in original.items()})
        p, r = best_policy()
        print(f"単価×{scale:g}: H{p['H']:g}/M{p['M']:g}/L{p['L']:g}  価値 {r.value_yen/man:,.0f}万円")
    TERMINAL_YEN.update(original)
    p, r = best_policy(complaint_cap=COMPLAINT_CLIFF)
    print(f"苦情の上限を 0.30% に緩めた最適: H{p['H']:g}/M{p['M']:g}/L{p['L']:g}  価値 {r.value_yen/man:,.0f}万円"
          f"  苦情率最大 {r.max_complaint*100:.3f}%")
    print()
    be_npv = breakeven_fatigue(uniform(5), terminal=False)
    be_val = breakeven_fatigue(uniform(5), terminal=True)
    print(f"週5全員が週2全員と並ぶ fatigue: NPVだけ {be_npv:.3f} / 期末リスト込み {be_val:.3f}(仮定 0.25)")


if __name__ == "__main__":
    main()

テスト(test_freq.py、13 件)と変異テスト

テスト守る性質
profit_is_concave1 通増やした上乗せが通数とともに小さくなる
people_are_conserved期末 = 期初 + 新規 − 抜けた人
two_weeks_look_good_but_two_years_do_not2 週では週5 が勝ち、2 年+期末在庫では負ける
horizon_truncation_flatters_list_burning期末在庫を数えないと週5 が得に見える
cliff_triggers_penalty苦情 4 倍で崖を越え、罰則が価値を下げる
cap_binds_when_complaints_are_high苦情 3 倍で上限が効く(変異 M6 の生存を受けて追加)
ほか 7 件送らなければ 0・通数と離脱の単調性・最適の制約・z 値・再現性・損益分岐・倍率の下限
変異テスト 8 種。1 回目は M6「苦情上限を無視」が生存(基準の世界では上限が効いていないため)。上限が効く世界のテストを足して 2 回目は全部赤。
M1 逓減を消し線形に                  赤(検出)
M2 格下げの行き先を消す                赤(検出)
M3 新規登録を数えない                 赤(検出)
M4 期末価値を割り引かない/数えない          赤(検出)
M5 崖の罰則を付けない                 赤(検出)
M6 苦情上限を無視                   緑(生存)
M7 疲労の倍率を固定                  赤(検出)
M8 z の分母を √n に               赤(検出)
生存 1/8
--- テスト追加後(2 回目) ---
M1 逓減を消し線形に                  赤(検出)
M2 格下げの行き先を消す                赤(検出)
M3 新規登録を数えない                 赤(検出)
M4 期末価値を割り引かない/数えない          赤(検出)
M5 崖の罰則を付けない                 赤(検出)
M6 苦情上限を無視                   赤(検出)
M7 疲労の倍率を固定                  赤(検出)
M8 z の分母を √n に               赤(検出)
生存 0/8

実行例(input → output)

# 試算を実行する(seed 固定・約 5 秒)
python3 freq.py
# テストを実行する(13 件)
python3 -m unittest test_freq -v
# コードを 1 か所ずつ壊してテストが見つけるか確かめる
python3 mutate.py
== 後輩の主張の再現 ==
最初の2週の増分粗利: 週2 1,069.6万円 / 週5 1,595.2万円 (+49.1%)  線形外挿の主張は +150%
2週テスト(各群5万人, 受信者あたり売上 12.0→16.6円, sd 400円) の z = 1.82

== 104週・割引後(最頻値の世界) ==
週2全員(現状)                     NPV  77,923  +期末 110,575万円  2週  1,070万円  最終リスト  96.1万人  失った  35.1万人  苦情率最大 0.089%  崖 0週
週3全員                         NPV  86,365  +期末 112,795万円  2週  1,330万円  最終リスト  75.4万人  失った  55.8万人  苦情率最大 0.111%  崖 0週
週5全員(後輩案)                    NPV  79,442  +期末  95,803万円  2週  1,595万円  最終リスト  43.4万人  失った  87.8万人  苦情率最大 0.156%  崖 0週
H5/M3/L1                     NPV  89,612  +期末 111,763万円  2週  1,476万円  最終リスト  90.1万人  失った  41.1万人  苦情率最大 0.049%  崖 0週
最適(苦情<0.10%) H3/M2/L1        NPV  88,571  +期末 118,740万円  2週  1,213万円  最終リスト 104.3万人  失った  26.9万人  苦情率最大 0.052%  崖 0週

== 不確かさ(2000回, seed=15): 週2全員との差 ==
週2全員(現状)                     平均       +0万円  P10       +0万円  現状より悪い   0.0%  崖を越える   0.0%
週3全員                         平均   +1,956万円  P10   -2,684万円  現状より悪い  32.8%  崖を越える   0.1%
週5全員(後輩案)                    平均  -16,283万円  P10  -25,914万円  現状より悪い  98.0%  崖を越える   3.8%
H5/M3/L1                     平均     +548万円  P10   -7,805万円  現状より悪い  51.5%  崖を越える   0.0%
最適(苦情<0.10%) H3/M2/L1        平均   +8,421万円  P10   +3,504万円  現状より悪い   0.2%  崖を越える   0.0%

== 期末リストの単価を変えたときの最適(苦情<0.10%) ==
単価×0: H4/M2/L1  価値 91,672万円
単価×0.5: H4/M2/L1  価値 105,181万円
単価×1: H3/M2/L1  価値 118,740万円
単価×2: H3/M1/L0.5  価値 150,860万円
苦情の上限を 0.30% に緩めた最適: H3/M2/L1  価値 118,740万円  苦情率最大 0.052%

週5全員が週2全員と並ぶ fatigue: NPVだけ 0.288 / 期末リスト込み 0.017(仮定 0.25)

最小の環境(env -i PATH=/usr/bin:/bin /usr/bin/python3 -I freq.py、macOS 同梱 Python 3.9.6)でも同じ出力。苦情 3 倍の世界では上限ありの最適は H3/M1/L0(苦情率最大 0.055%)、現状の週2 全員でも 0.267%。

部長会に出す提案の書き直し

件名: 販促メールの配信頻度を「層ごと」に変える(段階展開・止める条件つき)

結論
  全会員一律の週5 は採りません。2 年で見ると現状より 1.5 億円悪く(期末に残るリストを含む)、
  不確かさを入れても 98% の確率で現状を下回ります。代わりに、反応の強い人に多く、
  眠っている人に少なく送ります: 直近30日開封 週3 / 31〜90日 週2 / 90日以上 週1。

なぜ
  - 1 通の価値は通数とともに下がります。週5 にしても最初の 2 週の増分粗利は +49%(+150% ではない)
  - 送るたびにリストが減ります。配信停止した人には法律上、再同意なしに送れません。
    週5 全員だと 2 年でリストが 96 万人 → 43 万人になります(現状比)
  - 迷惑メール報告がドメイン全体で 0.30% を超えると、Gmail で他の施策のメールまで届きにくくなります。
    週5 全員は 0.156%(Google の推奨 0.10% 超え)、ぶれると 3.8% の確率で 0.30% を超えます

期待効果(すべて試算、入力は仮定)
  - 2 年の増分粗利+期末リストの価値: 現状比 +8,165 万円(2,000 回の平均 +8,421、P10 +3,504)
  - 現状より悪くなる確率 0.2%、苦情率の最大 0.052%

進め方
  1. 全会員の 10% を「現状の週2」のまま残す(ホールドアウト、12 週以上)
  2. 新方針を 10% → 30% → 90% と 4 週ずつ広げる
  3. 比べる指標: 受信者あたりの 12 週増分粗利 + 期末リストの価値
     ガードレール: 苦情率(Postmaster Tools)、1 通あたり配信停止率、H 層の人数の純増

止める条件(どれか 1 つで前の段階に戻す)
  - 苦情率が 2 日続けて 0.08% を超える
  - 新方針群の 1 通あたり配信停止率がホールドアウトの 1.5 倍を超える
  - 4 週時点で H 層の人数がホールドアウトより 2% 以上少ない

確かめること(決定前に、2 週間で)
  - 層ごとの反応 a と逓減 k / 1 通増えたときの配信停止の増え方(fatigue)/ 期末リストの単価(CAC)

判断ルールと止め方

  • 判断ルール: 「2 年の割引後増分粗利+期末リストの価値」が最大で、苦情率の最大が 0.10% 未満の方針。2,000 回で「現状より悪い確率」が 10% 超なら、期待値が高くても最初の 10% 展開まで
  • 止め方: 上の 3 条件。止めたら前の段階に戻し、ホールドアウトは崩さない
  • 見直す条件: fatigue の実測が 0.017 未満なら H 層だけ週5 を再検討/期末単価が 2 倍以上なら H3/M1/L0.5/現状でも苦情 0.2% 台なら先に L を止める(苦情 3 倍の世界の最適 H3/M1/L0)

迷った案と採らなかった理由

案結果(期末在庫込み、現状比)採らなかった理由前提が変わったら
週5 全員(後輩案)−14,772 万円、悪い確率 98.0%、崖 3.8%在庫を食いつぶす。期末在庫を数えないと +1,519 万円で勝って見えるfatigue ≈ 0(0.017 未満)なら並ぶ
週3 全員+2,220 万円、平均 +1,956、悪い確率 32.8%3 回に 1 回は悪い。苦情率 0.111% で推奨超えL の苦情が半分なら候補
H5/M3/L1+1,188 万円、平均 +548、悪い確率 51.5%H に週5 は格下げで H を減らす。五分五分格下げ率が実測で半分以下なら再検討
L を止める(sunset)単価×2 で最適 L0.5、苦情 3 倍で L0基準の仮定では L1 が上(格上げで戻る人がいる)苦情・期末単価が高いと止める側へ
1 人ずつ最適化(uplift・バンディット)試算していない頻度をばらした学習データが無い層別で効果が出てログが半年たまったら
H3/M2/L1(採用)+8,165 万円、平均 +8,421、P10 +3,504、悪い確率 0.2%—期末単価 ×0〜×0.5 なら H4/M2/L1

期末単価の感度: ×0 → H4/M2/L1、×0.5 → H4/M2/L1、×1 → H3/M2/L1、×2 → H3/M1/L0.5。どの単価でも週5 全員・週3 全員は選ばれない。

ポイント解説

  1. リストは在庫、配信は在庫を使う行為: 2 週間のテストは使う速さしか測れない。Hohnhold, O'Brien, Tang(KDD 2015「Focusing on the Long-term」)は、広告を増やすと短期収益は上がるが利用者の「学習」で長期に削られることを、長く走らせるホールドアウトで測った
  2. 打ち切った評価は食いつぶす方針を有利に見せる: NPV だけなら週5 が勝ち、期末在庫を足すと逆転。分岐 fatigue は 0.288 → 0.017
  3. 頻度は全員一律で決めない: Google の送信者ガイドライン(support.google.com/a/answer/81126、参照 2026-10-15)は 1 日 5,000 通超の送信者にワンクリック配信停止を求め、スパム率を 0.10% 未満に保ち "avoid ever reaching a spam rate of 0.30% or higher" としている。売上最適化の外側の制約として置く
  4. 結論を決める仮定を名指す: 期末単価(感度表)と fatigue(損益分岐)。どちらも既存ログから 2 週間で測れる

実務への応用

  • 配信基盤で持つ計測: ESP のイベント(delivered / unsubscribe / spamreport)を dbt で層別に集計し、Postmaster Tools のスパム率と並べて DataDog のモニタに(止める条件そのもの)
  • ホールドアウトを基盤の機能に: 会員 ID のハッシュで 10% を現状頻度に固定。2026-09-24 の「眠っていた 10% ホールドアウト」と同じ資産
  • トランザクションメールを守る: 注文確認・パスワード再設定は別サブドメイン・別 IP で送り、販促の罰則を波及させない(本試算はドメイン 1 つ)
  • 証券会社の経験: 接触頻度が多すぎると口座が休眠する構図と同じ。短期の手数料と顧客の残存価値を両方見る

扱わなかった範囲(止め方)

  • 入力値はすべて仮定。+8,165 万円などはモデル内の比較で、予測ではない
  • 最適は基準の世界で選び、同じモデルをぶらして評価した。モデルの形(逓減の形・格下げの頻度比例)は検証していない
  • パラメータのぶれは独立(相関なし)
  • Postmaster Tools のスパム率は Gmail 受信箱が分母・日次。本試算はリスト全体・週次で近似。罰則の大きさ(0.6 倍・4 週)は仮定で Google は公開していない
  • 送信時刻・件名・中身、LINE・プッシュとの食い合い、preference center は扱っていない
  • 特定電子メール法の表示義務・同意記録は満たしている前提。法務レビューなし。Yahoo の要件は原文未確認
  • 新規登録数は頻度に依存しないと置いた

まとめ

メールの購読者リストは使えば減る在庫で、2 週間のテストは「在庫を使う速さ」しか測れない。期末に残る在庫を数え、層ごとに頻度を決め、苦情率の崖を制約に置くと、週5 全員(2 週で +49%)は 2 年で −1.5 億円、層別(H3/M2/L1)は +8,165 万円になる。

次のステップ: 1 人ずつ頻度を決める。過去ログの自然実験(傾向スコア)と新しい頻度実験(バンディット)のどちらを先にやるかを、学習期間と在庫の損で比べる。参考: Kohavi, Tang, Xu『Trustworthy Online Controlled Experiments』、RFC 8058、特定電子メール法。

自己評価(あとで記入)