D ビジネス/PM — 手作業の自動化「年 274 万円削減・回収 7 か月」を、点推定でなく分布と代替案で書き直す(B +192 万円 / C +16 万円・30% 未回収 / D −64 万円)

2026-10-01 木曜(10/1 のバックフィル分) 木曜 D: ビジネス/PM ★★★★★ ROI / NPV / Monte Carlo 能力と現金 / 3点見積もり / 代替案とゲート

概要

💴

時間 × 単価は現金ではない

人も給与も減らない。転換率を置くと C は −52.7〜+100.2 万円で動く。

📈

見積もりは右に長い

最頻 2.0 人月に対し平均 2.50。分布で見ると C の 30% は 3 年で回収できない。

⚖️

比べる案は 3 つ

小さな B が最良(+192 万円)、買う D が最悪(−64 万円)。並べて初めて分かる。

🚪

結論を決める仮定だけ実測

事故の年間件数。0 件で C の P(NPV>0) 1.0%、8 件で 97.7%。

問題

MOps 販促システムチームの新任リードが、販促メール配信の手動作業(毎日 30 分 × 3 名)を自動化する提案を、来期の開発枠の会議に出そうとしている。あなたはレビューを頼まれた。

制約・前提条件(すべて 仮定。自チームの実測に置換すること)

  • 配信のある日 年 240 日。1 人月 80 万円、5,000 円/h、割引率 年 8%、評価 3 年。開発の稼働は 50%(2 人月は 4 か月、その間効果ゼロ)
  • 事故(誤配信・配信漏れ・配信停止の反映漏れ)年 4 件、1 件 23 万円(対応 6 人時 + 機会損失 20 万円)。転換率 30〜80%(最頻 50%)
  • B 部分自動化: 開発 (0.3, 0.5, 1.0) 人月、削減 (40, 55, 70)%、事故 50% 減、運用 10%/年
  • C フル自動化: 開発 (1.5, 2.0, 4.0) 人月、削減 (60, 75, 90)%、事故 75% 減、運用 15%/年 + 月 1.5 万円
  • D 配信 SaaS: 開発 (0.3, 0.5, 1.0) 人月、削減 (70, 80, 90)%、事故 70% 減、運用 5%/年 + 月 12 万円(実在製品の価格ではない)

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

悪い提案 (Before)

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

件名: 販促メール配信の自動化で年 274 万円を削減(回収 7 か月、3 年 ROI 413%)

配信作業は 1 日 30 分 × 3 名。365 日で年 547.5 時間。単価 5,000 円/h で年 273.8 万円の人件費削減。開発は 2 人月(160 万円)、回収 7.0 か月、3 年 ROI 413%。フル自動化の承認をお願いします。

問題点サマリー(7点)
1-2測定 — 365日・削減100%
3-4現金と費用 — 時間を現金視・運用費ゼロ
5-6分布と時間 — 点推定・立ち上がり・割引なし
7判断 — 比べる案もゲートもない

ヒント(段階的開示)

ヒント1 — 方向性
「削減 274 万円」は誰の財布に入るのか。人は減らず給与も減らないとき、その時間は現金か。そして 2 人月は「最もありそうな値」か「平均」か。
ヒント2 — アプローチ
  • 時間 × 単価は能力の話。現金になるのは外注費・残業代が減るときだけ。転換率を置く
  • 入力を(最小・最頻・最大)で持ち、10 万回試して NPV の分布を見る
  • 完成まで効果ゼロ、運用保守は毎年、割引する
  • A(何もしない)だけでなく、小さく試す B と買う D も同じ物差しに載せる
ヒント3 — 誘導
価値/月 = (年間時間 × 削減率 × 転換率 × 5,000円 + 事故件数 × 23万円 × 事故減少率) ÷ 12
費用    = 人月 × 80万円 + 毎月(初期費 × 運用率 ÷ 12 + 月額)
効果の開始 = 人月 ÷ 0.5 か月後、各月を (1+8%)^(-月/12) で割り引いて 36 か月
見る数字 = NPV の中央値・P10・P90、NPV>0 の確率、未回収の確率、感度

問題点分析(7点)

#問題点分類何が起きているか(出所つき)改善方法
1365日で数えた測定配信のある日は年240日。547.5h→360h(−34%)<導出>作業が発生する日で数える
2削減率100%測定確認・例外対応が残る。C最頻75%、B 55% <仮定>案ごとに範囲で置く
3時間を現金とみなした意思決定転換率30%でC NPV中央値 −52.7万、80%で +100.2万 <実測:試算>能力と現金を分け転換率を持つ
4運用保守・クラウド費ゼロ費用C: 24万+18万=42万円/年 <導出>毎年の費用を入れる
5開発2人月を点で置いた分布最頻2.0に対し平均2.50・中央値2.42 <実測:試算>範囲で置き分布で見る
6立ち上がり・割引がない時間2人月は4か月効果ゼロ。最頻値のC NPV +61.3万→中央値 +16.3万 <実測:試算>完成月から数え、割り引く
7比較案・判断ルールがない判断B・Dを載せると順位が逆転。ゲートと撤退条件がない3案を同じ物差しで比べ、ゲートを書く

NPV の分布と判断のゲート(SVG)

① 3年 NPV(万円)— 点(中央値)と線(P10〜P90)各案 10 万回の試行(実測:試算、入力は仮定、seed 固定)。0 の線より右なら黒字0-100100200B 部分自動化中央値 +192.3 P(NPV>0) 100%C フル自動化中央値 +16.3 P(NPV>0) 58.0%D 配信 SaaS中央値 -63.8 P(NPV>0) 8.2%Bad 提案の主張+661(3年・点推定)⁄⁄ 軸を省略Bad の +661 = 年 273.8 万円 × 3 − 160 万円。365 日・削減100%・現金換算・運用費0・割引なしの合算(導出)。

点は NPV の中央値、線は P10〜P90(実測:試算)。Bad 提案の +661 万円だけが桁違いに右にある。

② 判断のゲート — 小さく買って、結論を決める仮定だけを実測する B を承認(0.5人月)約40万円・効果は約1か月後 3か月で実測作業時間・事故件数・実工数 ゲート判定数字で決める 事故が年8件以上 → C へC の P(NPV>0) 97.7%(事故8件) それ以外 → B のまま運用事故0件でも B は NPV +73万円 実工数 1.0人月超/削減40%未満→ 撤退して A に戻す 事故件数の感度は実測:試算(C: 0件1.0% / 4件58.2% / 8件97.7%)。ゲートの閾値は仮定の上での値で、実測後に再計算する。

Bad → Good(コード差分)

Bad
# Bad: 後輩案の試算(点推定・1 行の足し算)
hours = 0.5 * 3 * 365          # 365 日。作業がある日ではない
benefit = hours * 5000         # 時間を現金とみなす。削減率 100%
cost = 2.0 * 800_000           # 開発 2 人月を点で置く
payback = cost / (benefit / 12)   # 運用費ゼロ・立ち上がりなし・割引なし
# 結果: 7.0 か月 / 3 年 ROI 413%
Good
# Good: 分布 × 3 案 × 立ち上がり × 割引 × 感度
for k in "BCD":
    s = simulate(k)            # 三角分布で 10 万回
    # NPV 中央値・P10・P90・P(NPV>0)・未回収の確率
tornado("C")                   # どの入力で結論が変わるか
incident_sensitivity("C")      # 最も根拠の薄い仮定(事故件数)を動かす
# 結果: B +192.3 万 / C +16.3 万(30% 未回収)/ D −63.8 万

適用した考え方: 3 点見積もり(PERT)、NPV、Monte Carlo、感度分析(tornado)。書籍の章対応は無し(D の日のため)。

模範解答

試算(roi.py)

"""販促メール配信の手動作業を自動化する提案の ROI 試算(合成の仮定による Monte Carlo)。Python 3.12+、標準ライブラリのみ。

出所ラベル: 単価=社内の仮定(月80万円/160h) / 作業時間・件数=仮定(導入前に自チームの実測で置換する) /
分布・確率・NPV=この試算の実測(乱数 seed 固定)。外部の公式価格は使っていない。
"""
import random
import statistics
from dataclasses import dataclass

RATE_YEN_H = 800_000 / 160       # 仮定: 月80万円 ÷ 160h = 5,000 円/h
HOURS_PER_DAY_PER_PERSON = 0.5   # 仮定: 配信作業 30 分/日/人
PEOPLE = 3                       # 仮定
WORK_DAYS = 240                  # 仮定: 配信のある平日(365 ではない)
MAN_MONTH_YEN = 800_000          # 仮定: 1 人月
HORIZON_M = 36                   # 評価期間 3 年
DISCOUNT_ANNUAL = 0.08           # 仮定: 割引率
INCIDENTS_PER_YEAR = 4           # 仮定: 誤配信・配信漏れ(過去1年の障害票の件数に置換)
INCIDENT_YEN = 6 * RATE_YEN_H + 200_000   # 仮定: 対応 6 人時 + 機会損失 20 万円
DEV_FTE = 0.5                    # 仮定: 開発に割ける稼働率(カレンダー期間 = 人月 ÷ 0.5)
TRIALS = 100_000

@dataclass(frozen=True)
class Option:
    name: str
    dev_mm: tuple[float, float, float]     # 開発の人月 (最小, 最頻, 最大) 三角分布
    cut: tuple[float, float, float]        # 作業時間の削減率 (最小, 最頻, 最大)
    incident_cut: float                    # 事故の減少率
    ops_rate: float                        # 年間の運用保守費 = 初期費 × この率
    monthly_run_yen: float                 # 月額(クラウド/SaaS)

OPTIONS = {
    "B": Option("部分自動化(抽出と宛先検証のスクリプト)", (0.3, 0.5, 1.0), (0.4, 0.55, 0.7), 0.5, 0.10, 0),
    "C": Option("フル自動化(Argo Workflows + Cloud Run)", (1.5, 2.0, 4.0), (0.6, 0.75, 0.9), 0.75, 0.15, 15_000),
    "D": Option("配信 SaaS を導入", (0.3, 0.5, 1.0), (0.7, 0.8, 0.9), 0.70, 0.05, 120_000),
}
CONVERT = (0.3, 0.5, 0.8)    # 仮定: 空いた時間のうち別の価値ある仕事に変わる割合(三角分布)

def pv_factor(month: int) -> float:
    return (1 + DISCOUNT_ANNUAL) ** (-month / 12)

def bad_memo() -> dict:
    """後輩案: 365 日・削減 100%・運用費 0・点推定・割引なし。"""
    hours = HOURS_PER_DAY_PER_PERSON * PEOPLE * 365
    benefit = hours * RATE_YEN_H
    cost = 2.0 * MAN_MONTH_YEN
    return {"hours": hours, "benefit_year": benefit, "cost": cost,
            "payback_m": cost / (benefit / 12), "roi3": (benefit * 3 - cost) / cost}

def one_trial(o: Option, rng: random.Random, fixed: dict | None = None) -> dict:
    """1 回の試行。fixed で入力を固定すると感度分析(1 つずつ動かす)に使える。"""
    f = fixed or {}
    dev_mm = f.get("dev_mm", rng.triangular(*o.dev_mm[::2], o.dev_mm[1]))
    cut = f.get("cut", rng.triangular(*o.cut[::2], o.cut[1]))
    conv = f.get("conv", rng.triangular(*CONVERT[::2], CONVERT[1]))
    initial = dev_mm * MAN_MONTH_YEN
    build_m = max(1, round(dev_mm / DEV_FTE))             # 完成まで効果はゼロ
    hours_year = HOURS_PER_DAY_PER_PERSON * PEOPLE * WORK_DAYS
    value_month = (hours_year * cut * conv * RATE_YEN_H + INCIDENTS_PER_YEAR * INCIDENT_YEN * o.incident_cut) / 12
    run_month = initial * o.ops_rate / 12 + o.monthly_run_yen
    npv = -initial
    cum = -initial
    payback = None
    for m in range(build_m + 1, HORIZON_M + 1):
        net = (value_month - run_month) * pv_factor(m)
        npv += net
        cum += value_month - run_month
        if payback is None and cum >= 0:
            payback = m
    return {"npv": npv, "payback": payback, "initial": initial}

def simulate(key: str, seed: int = 11, n: int = TRIALS) -> dict:
    rng = random.Random(seed)
    o = OPTIONS[key]
    rs = [one_trial(o, rng) for _ in range(n)]
    npvs = sorted(r["npv"] for r in rs)
    pb = [r["payback"] for r in rs if r["payback"] is not None]
    return {"p_pos": sum(v > 0 for v in npvs) / n, "median": statistics.median(npvs),
            "p10": npvs[n // 10], "p90": npvs[n * 9 // 10],
            "median_payback": statistics.median(pb) if pb else None,
            "p_no_payback": 1 - len(pb) / n}

def tornado(key: str = "C") -> list[tuple[str, float, float]]:
    """1 つずつ最小/最大に固定して NPV の中央値の振れを見る。"""
    o = OPTIONS[key]
    out = []
    for name, rng_ in (("dev_mm", o.dev_mm), ("cut", o.cut), ("conv", CONVERT)):
        vals = []
        for v in (rng_[0], rng_[2]):
            r = random.Random(5)
            vals.append(statistics.median(one_trial(o, r, {name: v})["npv"] for _ in range(8_000)))
        out.append((name, vals[0], vals[1]))
    return out

def incident_sensitivity(key: str, counts=(0, 2, 4, 8)) -> list[tuple[int, float, float]]:
    """事故の年間件数(最も根拠の薄い仮定)を動かしたときの P(NPV>0) と NPV 中央値。"""
    global INCIDENTS_PER_YEAR
    saved, out = INCIDENTS_PER_YEAR, []
    try:
        for c in counts:
            INCIDENTS_PER_YEAR = c
            s = simulate(key, n=20_000)
            out.append((c, s["p_pos"], s["median"]))
    finally:
        INCIDENTS_PER_YEAR = saved
    return out

if __name__ == "__main__":
    b = bad_memo()
    print(f"bad : 削減 {b['hours']:.1f}h/年 → {b['benefit_year']/1e4:.1f} 万円/年, 回収 {b['payback_m']:.1f} か月, 3年ROI {b['roi3']:.0%}")
    for k in OPTIONS:
        s = simulate(k)
        pb = "回収せず" if s["median_payback"] is None else f"{s['median_payback']:.0f}か月"
        print(f"{k} {OPTIONS[k].name}: NPV中央値 {s['median']/1e4:7.1f}万  P10 {s['p10']/1e4:7.1f}万  P90 {s['p90']/1e4:7.1f}万  P(NPV>0) {s['p_pos']:.1%}  回収中央値 {pb}  未回収 {s['p_no_payback']:.1%}")
    for name, lo, hi in tornado("C"):
        print(f"tornado C {name:7s} 最小で {lo/1e4:7.1f}万 / 最大で {hi/1e4:7.1f}万")
    for k in ("B", "C"):
        for c, p, m in incident_sensitivity(k):
            print(f"事故 年{c}件 {k}: P(NPV>0) {p:.1%}  NPV中央値 {m/1e4:7.1f}万")

テスト(test_roi.py、7件)

import random
import unittest
import roi as R

class T(unittest.TestCase):
    def test_bad_memo_reproduces_headline(self):
        b = R.bad_memo()
        self.assertAlmostEqual(b["hours"], 547.5)
        self.assertAlmostEqual(b["benefit_year"] / 1e4, 273.75, places=2)
        self.assertAlmostEqual(b["payback_m"], 7.0, places=1)

    def test_no_benefit_before_build_done(self):
        o = R.OPTIONS["C"]
        r = R.one_trial(o, random.Random(1), {"dev_mm": 2.0, "cut": 0.75, "conv": 0.5})
        self.assertGreaterEqual(r["payback"], round(2.0 / R.DEV_FTE) + 1)

    def test_zero_conversion_means_no_cash_value_from_hours(self):
        o = R.OPTIONS["B"]
        a = R.one_trial(o, random.Random(1), {"dev_mm": 0.5, "cut": 0.55, "conv": 0.0})["npv"]
        b = R.one_trial(o, random.Random(1), {"dev_mm": 0.5, "cut": 0.55, "conv": 0.8})["npv"]
        self.assertLess(a, b)

    def test_overrun_hurts_full_automation_more(self):
        c, b = R.OPTIONS["C"], R.OPTIONS["B"]
        dc = R.one_trial(c, random.Random(1), {"dev_mm": 1.5, "cut": 0.75, "conv": 0.5})["npv"] - \
             R.one_trial(c, random.Random(1), {"dev_mm": 4.0, "cut": 0.75, "conv": 0.5})["npv"]
        db = R.one_trial(b, random.Random(1), {"dev_mm": 0.3, "cut": 0.55, "conv": 0.5})["npv"] - \
             R.one_trial(b, random.Random(1), {"dev_mm": 1.0, "cut": 0.55, "conv": 0.5})["npv"]
        self.assertGreater(dc, db)

    def test_same_seed_same_result(self):
        self.assertEqual(R.simulate("C", n=2000), R.simulate("C", n=2000))

    def test_ranking_B_over_C_over_D(self):
        m = {k: R.simulate(k, n=20_000)["median"] for k in "BCD"}
        self.assertGreater(m["B"], m["C"]); self.assertGreater(m["C"], m["D"])

    def test_discount_lowers_value(self):
        self.assertLess(R.pv_factor(36), 1.0)

if __name__ == "__main__":
    unittest.main()

実行例

bad : 削減 547.5h/年 → 273.8 万円/年, 回収 7.0 か月, 3年ROI 413%
B 部分自動化(抽出と宛先検証のスクリプト): NPV中央値   192.3万  P10   149.8万  P90   239.9万  P(NPV>0) 100.0%  回収中央値 8か月  未回収 0.0%
C フル自動化(Argo Workflows + Cloud Run): NPV中央値    16.3万  P10   -91.8万  P90   103.4万  P(NPV>0) 58.0%  回収中央値 27か月  未回収 30.0%
D 配信 SaaS を導入: NPV中央値   -63.8万  P10  -114.2万  P90    -4.9万  P(NPV>0) 8.2%  回収中央値 26か月  未回収 89.9%
tornado C dev_mm  最小で   132.2万 / 最大で  -170.2万
tornado C cut     最小で   -15.2万 / 最大で    48.7万
tornado C conv    最小で   -52.7万 / 最大で   100.2万
事故 年0件 B: P(NPV>0) 99.4%  NPV中央値    73.4万
事故 年2件 B: P(NPV>0) 100.0%  NPV中央値   132.6万
事故 年4件 B: P(NPV>0) 100.0%  NPV中央値   192.0万
事故 年8件 B: P(NPV>0) 100.0%  NPV中央値   310.5万
事故 年0件 C: P(NPV>0) 1.0%  NPV中央値  -140.6万
事故 年2件 C: P(NPV>0) 18.1%  NPV中央値   -61.9万
事故 年4件 C: P(NPV>0) 58.2%  NPV中央値    16.8万
事故 年8件 C: P(NPV>0) 97.7%  NPV中央値   174.0万

$ python3 -m unittest test_roi
Ran 7 tests in 0.6s
OK
  • C は NPV 中央値 +16.3 万円、回収の中央値 27 か月、30% は 36 か月で回収できない。P(NPV>0) 58%
  • B は P10 でも +149.8 万円、P(NPV>0) 100%、回収中央値 8 か月。事故 0 件でも +73.4 万円
  • D(月 12 万円)は P(NPV>0) 8.2%。損益分岐の月額は 未算出

提案書の書き直し

件名: 配信作業の自動化は「まず B(抽出と宛先検証のスクリプト)」を承認ください。C は 3 か月後の実測で判断

現状: 年 360h(240 日 × 0.5h × 3 名)。事故は年 4 件(過去 1 年の障害票。仮置き、確認後に差し替え)。

提案: B 0.5 人月(約 40 万円)。3 年 NPV の中央値 +192 万円(P10 +150 万円)、回収中央値 8 か月。事故 0 件でも +73 万円。時間削減は現金ではなく、転換率 50% を置いた。

比較: C は NPV 中央値 +16 万円・P(NPV>0) 58%・30% は 36 か月で回収できない。D は −64 万円。

確認: 承認後 3 か月で (a) 実作業時間 (b) 事故件数 (c) 実際の開発工数 を測る。

C に進む条件: 事故が年 8 件以上、または実測の転換率 80% 以上。止める条件: B の実績が 1.0 人月超、または削減 40% 未満。

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

案3年 NPV 中央値判断前提が変わったら
A: 何もしない0比較の基準。事故4件のコストを払い続ける事故0件で作業時間も小さければ最善になりうる
採用: B 部分自動化+192.3万(P(NPV>0) 100%)小さく速く、事故0件でも黒字。実測が取れる実工数1.0人月超なら撤退
C: フル自動化(Bad提案)+16.3万(58%、30%未回収)今は却下。事故が年8件以上なら採用側(97.7%)開発が1.5人月に収まる見込み、または転換率80%
D: 配信 SaaS−63.8万(8.2%)却下。月額12万円(仮定)が重い月額が大きく下がる/配信件数が増えて自作の運用費が上がる
E: B から段階的に C へ未計算採用案の延長(Cに進む条件)—
F: 5年で評価未計算3年で固定。5年はCの回収が増えるが要件が変わる前提が弱い5年使うと決まった基盤なら再計算
G: 点推定に感度分析だけ足す近い結論採らない。分布なら「30%は回収できない」と言える—

ポイント解説

  1. 時間 × 単価は能力の話: 現金になるのは外注費・残業代が減るとき。転換率で C は −52.7〜+100.2 万円。
  2. 見積もりは右に長い: 最頻 2.0 に対し平均 2.50。点なら C は +61.3 万、分布の中央値は +16.3 万、30% は未回収。
  3. 効果の開始・運用費・割引は 3 点セット: 一つでも抜くと回収が早く見える(7.0 か月と 27 か月の差)。
  4. 代替案は A だけでは足りない: 安い B が最良、高い D が最悪という順位は並べて初めて見える。
  5. 結論を決める仮定を名指しする: 事故の年間件数。会議ではそこだけを議論する。

実務への応用

BQ のスキャン削減や Argo のバッチ整理でも、「時間 × 単価」が出たら転換率・運用費・立ち上がりを問う。事故票(誤配信・配信漏れ)を月次で数える。配信停止の反映漏れは法令(特定電子メール法)のリスクにもなる。条文は 未参照。

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

  • 入力はすべて仮定で、実データ(作業ログ・障害票・見積もり実績)では測っていない。順位は仮定に依存する。導入前に自チームの値で roi.py を再実行すること
  • 三角分布は説明用の単純な形。自チームの過去案件の予実から当てるのが本筋
  • 為替・給与改定・SaaS の値引きと段階課金、稼働の取り合い、機会費用は扱っていない。SaaS の価格は仮置きで実在製品ではない
  • 世界の実物との対応: 3 点見積もり(PERT)、NPV、感度分析、リアルオプションのうち最初の 3 つを扱った。リアルオプション(B で情報を買ってから C を決める価値)の計算は扱っていない。出典は書名・用語のみで一次資料は 未参照

発展問題: B で得た実測(作業時間・事故件数・実工数)で分布を更新し、C に進む条件を「事故 8 件」の閾値でなく確率で書き直すには、どの分布を何で更新するか。

今日のまとめ

「時間 × 単価」の削減額は現金でなく能力。範囲で置いて分布で見て、小さな案を先に試し、結論を決める仮定(事故件数)だけを実測してから大きな案へ進む。

自己評価(あとで記入)