概要
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.0 | 2.5 | 0.05% | 0.01% | 0.20%(→M) | — |
| M | 31〜90 日 | 30 万 | 8.0 | 2.0 | 0.10% | 0.04% | 0.15%(→L) | 1.0%(→H) |
| L | 90 日以上未開封 | 50 万 | 1.5 | 1.5 | 0.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)
件名: 販促メールを週2通 → 週5通へ(全会員一律、来月1日から)
- 1 通あたりの売上はほぼ一定なので、週5 にすればメール経由売上は 2.5 倍(+150%)
- 2 週間の A/B テスト(各群 5 万人)で受信者あたり売上 12.0 → 16.6 円(+38%)。実証済み
- 配信停止率は 1 通あたり約 0.1%。業界平均並みで問題なし
- 送信費は 1 通 0.03 円。増えるのは月 1,300 万通 ≒ 39 万円で誤差
- KPI は「メール経由売上(ラストクリック)」。来月から全会員 100 万人に適用
ヒント(段階的開示)
ヒント1 — 方向性
ヒント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) |
| 3 | 2 週間で「実証済み」 | 統計 | 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 は H の「収穫」を増やすが、同じ f が赤い流れ(格下げ・停止・苦情)を太くする。収穫だけを 2 週測ったのが後輩の提案。
左の列(2 週)で一番長い週5 全員が、右の列(2 年+期末リスト)では一番短い。層別 H3/M2/L1 は 2 週では最も控えめだが、2 年では最大で、期末のリストも始めより多い。
Bad → Good(評価の仕方の差分)
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%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_concave | 1 通増やした上乗せが通数とともに小さくなる |
| people_are_conserved | 期末 = 期初 + 新規 − 抜けた人 |
| two_weeks_look_good_but_two_years_do_not | 2 週では週5 が勝ち、2 年+期末在庫では負ける |
| horizon_truncation_flatters_list_burning | 期末在庫を数えないと週5 が得に見える |
| cliff_triggers_penalty | 苦情 4 倍で崖を越え、罰則が価値を下げる |
| cap_binds_when_complaints_are_high | 苦情 3 倍で上限が効く(変異 M6 の生存を受けて追加) |
| ほか 7 件 | 送らなければ 0・通数と離脱の単調性・最適の制約・z 値・再現性・損益分岐・倍率の下限 |
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 全員は選ばれない。
ポイント解説
- リストは在庫、配信は在庫を使う行為: 2 週間のテストは使う速さしか測れない。Hohnhold, O'Brien, Tang(KDD 2015「Focusing on the Long-term」)は、広告を増やすと短期収益は上がるが利用者の「学習」で長期に削られることを、長く走らせるホールドアウトで測った
- 打ち切った評価は食いつぶす方針を有利に見せる: NPV だけなら週5 が勝ち、期末在庫を足すと逆転。分岐 fatigue は 0.288 → 0.017
- 頻度は全員一律で決めない: 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" としている。売上最適化の外側の制約として置く
- 結論を決める仮定を名指す: 期末単価(感度表)と 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 の要件は原文未確認
- 新規登録数は頻度に依存しないと置いた
まとめ
次のステップ: 1 人ずつ頻度を決める。過去ログの自然実験(傾向スコア)と新しい頻度実験(バンディット)のどちらを先にやるかを、学習期間と在庫の損で比べる。参考: Kohavi, Tang, Xu『Trustworthy Online Controlled Experiments』、RFC 8058、特定電子メール法。