概要
「いつ得るか」で並べる
年間効果順は最適より +90.0 万円。並べ替えで総額は変わらない。
同時着手は全部を遅らせる
5 件均等割りで 1,079.0 万円(+72%)。最初の完成が 5 週目。
締切で消える案件は別扱い
年末セールは 7 週を過ぎると 200 万円が消える。CD3 だけでは最後に回す。
一番効くのは割ること
カゴ落ちを 2 つに割って 629.0 → 457.0 万円。結論を決めるのは L(分岐 215.3)。
問題
MOps 販促システムチーム(エンジニア 4 名)は、次の 26 週で 5 件の案件を抱えている。後輩 PdM 見習いが着手順の案を作り、来週の優先度会議に出す。別のシニアからは「SAFe の WSJF で点数を付ければよい」という意見も出ている。あなたはレビューを頼まれた。
制約・前提条件(すべて 仮定。会議前にチームの値に置換)
| 案件 | CoD(万円/週) | 期間(週)(最小, 最頻, 最大) | 備考 |
|---|---|---|---|
| CART カゴ落ちリマインド配信 | 30 | (4, 6, 10) | 年間効果 1,560 = 30 × 52 |
| XMAS 年末セールの多段クーポン | 2 | (3, 4, 8) | 7 週目の終わりまでに出せないと 200 万円を一度に失う(固定日型) |
| OPTOUT 配信停止の即時反映 | 6 | (1.5, 2, 3) | 反映漏れの苦情・対応費の期待値 |
| BQ スキャン削減 | 3 | (0.5, 1, 2) | |
| ADMIN 管理画面リニューアル | 10 | (6, 8, 14) |
- 直列なら上の期間で終わる。5 件同時なら稼働を均等割り(1/5 の速さ)
- カゴ落ちは 1 段目(固定文面・1 通、CoD 20、(1.5, 2, 4))と 2 段目(出し分け・3 通、CoD 10、(3, 4, 7))に割れる。CoD 合計は 30 のまま
- 遅延コスト = Σ(完成週 × CoD)+ 締切を過ぎた固定日型の損失。これを小さくする順を選ぶ
期待する回答形式: 問題点 8 点/試算のコードとテスト/実行例/会議に出す着手順/判断ルールと見直す条件/迷った案と前提が変わったときの判断/扱わなかった範囲
悪い案 (Before)
件名: 来四半期の着手順(年間効果の大きい順)
1. カゴ落ちリマインド 1,560 万円 / 2. 管理画面 520 万円 / 3. 配信停止 312 万円 / 4. 年末セール 304 万円 / 5. BQ 削減 156 万円
どれも大事なので、来週から 5 件とも同時に着手します(4 名で少しずつ並行)。
| 案件 | 価値 | 緊急度 | リスク | 規模 | WSJF |
|---|---|---|---|---|---|
| カゴ落ち | 13 | 5 | 1 | 8 | 2.38 |
| 年末 | 8 | 13 | 1 | 5 | 4.40 |
| 配信停止 | 2 | 3 | 13 | 2 | 9.00 |
| BQ | 2 | 1 | 1 | 1 | 4.00 |
| 管理画面 | 8 | 2 | 2 | 13 | 0.92 |
ヒント(段階的開示)
ヒント1 — 方向性
ヒント2 — アプローチ
- 年間効果(ストック)を 1 週あたりの遅延コスト(フロー)に直す
- 線形の遅延コストだけなら CoD ÷ 期間(CD3)の大きい順が最適(Smith の規則、1956)
- 締切で一度に価値が消える案件は CD3 では扱えない。5 件なら 120 通りなので全順列で確かめる
- 同時並行はどれだけ全体を遅らせるか計算する
- 期間を最頻値で決めてよいか。ぶれたら順位はどうなるか
ヒント3 — 誘導
delay_cost(順) = Σ CoD_i × 完成週_i + Σ(完成週_i > 締切_i) × 損失_i
CD3_i = CoD_i ÷ 期間_i → 大きい順(隣り合う 2 件の入れ替えで最適性を示せる)
同時並行(n 件均等割り) → 残作業最小の件が終わるまで 残作業 × n 週
見る数字 = 最頻値の遅延コスト、2 万回ぶらした平均と P90、年末に間に合う確率、L の損益分岐問題点分析(8点)
| # | 問題点 | 分類 | 何が起きているか(出所つき) | 改善方法 |
|---|---|---|---|---|
| 1 | 年間効果で並べた | 並べ方 | 年間効果順は 719.0 万円。最適 629.0 より +90.0 <実測:試算> | 1 週遅れると失う額(CoD)に直す |
| 2 | 期間を見ていない | 並べ方 | 管理画面(10÷8=1.25)を配信停止(6÷2=3.0)より前に置いた | CoD ÷ 期間(CD3)で並べる |
| 3 | 5 件を同時に着手 | WIP | 最初の完成は 5 週目。1,079.0 万円(+450.0、+72%)。ぶれても平均 +495.4 <実測:試算> | 1 件ずつ終わらせる |
| 4 | 年末セールを普通の案件扱い | 締切 | 7 週を過ぎると 200 万円が一度に消える。完成 20 週目で間に合う確率 0.0% | 固定日型を別に扱う |
| 5 | 相対点の比(シニア案) | 尺度 | WSJF 順は最頻値で +16.0、ぶれると平均 +143.4・P90 +292.0、年末 49.6% <実測:試算> | 万円/週と週で置く |
| 6 | 期間を最頻値で置いた | 分布 | 年末 4 → 平均 5.0 週 <導出>。最頻値では年末先頭が最適(629.0 vs 667.0)、期待値では逆(740.2 vs 724.6) | 範囲で置き平均と P90 で比べる |
| 7 | 大きい案件を割らない | 分割 | カゴ落ちを割るだけで 629.0 → 457.0(−27%)、期待値 724.6 → 648.0 | 価値の大半を先に出す切り方 |
| 8 | 結論を決める仮定を名指ししない | 判断 | 年末の損失 L の損益分岐は 215.3 万円。仮置き 200 はその 7% 手前 | L を測り、見直す条件を書く |
並べ方と結論を決める仮定(SVG)
同じ 5 件・同じ人数でも、並べ方と割り方だけで遅延コストが半分になる(最頻値)。同時着手は全件が細い帯で長く伸びる。
2 本の線は L ≈ 215 万円で交わる。仮置きの 200 万円はその手前にあり、L を測らずにどちらかに決めるのは賭けになる。L が分からないなら、最悪の後悔が小さい「年末を先頭」(109.9 < 175.9)。
Bad → Good(コード差分)
# Bad: 年間効果の大きい順に並べ、全部に同時着手する
backlog = {"CART": 1560, "ADMIN": 520, "OPTOUT": 312, "XMAS": 304, "BQ": 156} # 年間効果(万円)
order = sorted(backlog, key=lambda k: -backlog[k]) # いくら得るか(総額)で並べる。期間を見ない
for k in order:
start(k) # 4 名で 5 件を同時に少しずつ。最初の完成は 5 週目
# 年末セールは「年 304 万円の普通の案件」。7 週のフリーズは表に無い
# 期間は最頻値の 1 点。結論を変える仮定(年末の損失)を名指ししない
# 結果: 遅延コスト 1,079.0 万円(最適 629.0 の +72%)、年末に間に合う 0%# Good: 1 週あたりの遅延コストと期間で並べ、締切と分割と不確かさを入れる
cod_w = {"CART1": 20, "CART2": 10, "XMAS": 2, "OPTOUT": 6, "BQ": 3, "ADMIN": 10} # 万円/週
order = order_cd3(items=SPLIT_ITEMS) # CoD ÷ 期間の大きい順(Smith の規則)
# 固定日型(XMAS)は CD3 の外で扱う。入れるなら先頭(最頻値で詰めると 38.7% しか間に合わない)
mean, p90 = expected_cost(order, SPLIT_ITEMS) # 期間を三角分布で 2 万回ぶらす
table, be = split_decision() # 年末の損失 L ごとに 2 案を比べ、損益分岐を出す
# 結果: 分割+CD3 648.0 / 分割+年末先頭 663.4(年末 95.0%)、L の損益分岐 215.3 万円適用した考え方: Cost of Delay、CD3(WSPT / Smith の規則)、固定日型の別扱い、WIP 制限、三角分布での Monte Carlo、損益分岐と最悪の後悔(minimax regret)。D の日のため書籍の章対応は無し。
模範解答
試算(cod.py)
"""販促システムチームの次の 26 週の着手順を Cost of Delay で決める試算。Python 3.12+、標準ライブラリのみ。
出所ラベル: CoD・期間・締切・年間効果・相対点=仮定(導入前にチームの値で置換する) /
順位・遅延コスト・確率=この試算の実測(seed 固定)。
CD3 = Cost of Delay ÷ Duration(Reinertsen 2009)。線形の CoD だけなら Smith の規則(1956, WSPT)で最適。
固定日型(締切を過ぎると一度に失う)が混ざると CD3 は最適を保証しない。
"""
import itertools
import random
import statistics
from dataclasses import dataclass
TRIALS = 20_000 # 期間の不確かさを試す回数
SEED = 7
@dataclass(frozen=True)
class Item:
"""バックログの 1 件。"""
key: str
name: str
cod_w: float # 線形の遅延コスト(万円/週): 完成が 1 週遅れるたびに失う額
dur: tuple[float, float, float] # 期間(週)(最小, 最頻, 最大) の三角分布
cliff_week: float | None = None # この週までに出せないと一度に失う(固定日型)
cliff_loss: float = 0.0 # 締切を過ぎたときに失う額(万円)
annual_value: float = 0.0 # 後輩案が並べた「年間効果」(万円)
fib_value: int = 0 # SAFe 風の相対点(フィボナッチ): 事業価値
fib_time: int = 0 # 時間的緊急度
fib_rr: int = 0 # リスク低減・機会創出
fib_size: int = 0 # 規模
ITEMS = [
Item("CART", "カゴ落ちリマインド配信", 30, (4, 6, 10), annual_value=1560,
fib_value=13, fib_time=5, fib_rr=1, fib_size=8),
Item("XMAS", "年末セールの多段クーポン配布", 2, (3, 4, 8), cliff_week=7, cliff_loss=200,
annual_value=304, fib_value=8, fib_time=13, fib_rr=1, fib_size=5),
Item("OPTOUT", "配信停止の即時反映", 6, (1.5, 2, 3), annual_value=312,
fib_value=2, fib_time=3, fib_rr=13, fib_size=2),
Item("BQ", "BigQuery スキャン削減", 3, (0.5, 1, 2), annual_value=156,
fib_value=2, fib_time=1, fib_rr=1, fib_size=1),
Item("ADMIN", "管理画面リニューアル", 10, (6, 8, 14), annual_value=520,
fib_value=8, fib_time=2, fib_rr=2, fib_size=13),
]
# カゴ落ちを「1段目(固定文面・1通)」と「2段目(出し分け・3通)」に割る案。
# 仮定: CoD 30 万円/週のうち 20 は 1段目だけで取れる。分割の手間で合計期間は 6 → 6 週(最頻値)のまま。
SPLIT_ITEMS = [i for i in ITEMS if i.key != "CART"] + [
Item("CART1", "カゴ落ち 1段目(固定文面・1通)", 20, (1.5, 2, 4)),
Item("CART2", "カゴ落ち 2段目(出し分け・3通)", 10, (3, 4, 7)),
]
def mode_durs(items: list[Item]) -> dict[str, float]:
return {i.key: i.dur[1] for i in items}
def delay_cost(order: list[str], durs: dict[str, float], items: list[Item] = ITEMS) -> float:
"""1 件ずつ直列に進めたときの遅延コスト合計(万円)。
Args:
order: 着手順(key の並び)。
durs: 各 key の実際の期間(週)。
items: 対象のバックログ。
Returns:
Σ(完成週 × CoD) + 締切を過ぎた固定日型の損失。
"""
by = {i.key: i for i in items}
t = total = 0.0
for k in order:
i = by[k]
t += durs[k] # この件の完成週
total += i.cod_w * t
if i.cliff_week is not None and t > i.cliff_week:
total += i.cliff_loss # 締切当日の完成は間に合った扱い
return total
def parallel_cost(durs: dict[str, float], items: list[Item] = ITEMS) -> float:
"""全件に同時着手し、稼働を残り件数で均等割りしたとき(processor sharing)の遅延コスト。"""
by = {i.key: i for i in items}
remaining = dict(durs)
t = total = 0.0
while remaining:
n = len(remaining)
k_min = min(remaining, key=remaining.get)
done = remaining.pop(k_min)
t += done * n # 1/n の速さなので暦では n 倍かかる
for k in remaining:
remaining[k] -= done
i = by[k_min]
total += i.cod_w * t
if i.cliff_week is not None and t > i.cliff_week:
total += i.cliff_loss
return total
# ---- 並べ方(後輩案と代替案)----
def order_by_annual_value(items: list[Item] = ITEMS) -> list[str]:
return [i.key for i in sorted(items, key=lambda i: -i.annual_value)]
def order_easy_first(items: list[Item] = ITEMS) -> list[str]:
return [i.key for i in sorted(items, key=lambda i: i.dur[1])]
def order_safe_wsjf(items: list[Item] = ITEMS) -> list[str]:
"""SAFe 風 WSJF =(事業価値+時間的緊急度+リスク低減)÷ 規模。すべて相対点。"""
return [i.key for i in sorted(items, key=lambda i: -(i.fib_value + i.fib_time + i.fib_rr) / i.fib_size)]
def order_cd3(durs: dict[str, float] | None = None, items: list[Item] = ITEMS) -> list[str]:
d = durs or mode_durs(items)
return [i.key for i in sorted(items, key=lambda i: -i.cod_w / d[i.key])]
def order_cd3_with_deadline(durs: dict[str, float] | None = None, items: list[Item] = ITEMS) -> list[str]:
"""CD3 の順に並べ、固定日型だけを「締切に間に合う最も後ろの位置」へ動かす。"""
d = durs or mode_durs(items)
order = order_cd3(d, items)
for i in items:
if i.cliff_week is None:
continue
order.remove(i.key)
best = 0 # どこにも入らなければ先頭(それでも遅れるなら守れない)
for pos in range(len(order) + 1):
if sum(d[k] for k in order[:pos]) + d[i.key] > i.cliff_week:
break
best = pos
order.insert(best, i.key)
return order
def brute_force(durs: dict[str, float], items: list[Item] = ITEMS) -> tuple[list[str], float]:
"""全順列を試す(5 件で 120 通り、7 件で 5,040 通り)。10 件を超えたら使わない(10! = 362 万)。"""
best = min(itertools.permutations([i.key for i in items]), key=lambda o: delay_cost(list(o), durs, items))
return list(best), delay_cost(list(best), durs, items)
def robustness(seed: int = SEED, n: int = TRIALS) -> dict[str, dict]:
"""計画時(最頻値)に決めた順のまま走り、実際の期間がぶれたとき、事後の最適からどれだけ外れるか。"""
rng = random.Random(seed)
plans = {"年間効果順": order_by_annual_value(), "楽な順": order_easy_first(),
"SAFe WSJF": order_safe_wsjf(), "CD3": order_cd3(), "CD3+締切": order_cd3_with_deadline()}
regrets: dict[str, list[float]] = {k: [] for k in [*plans, "同時並行"]}
xmas_ok = dict.fromkeys(plans, 0)
for _ in range(n):
d = {i.key: rng.triangular(i.dur[0], i.dur[2], i.dur[1]) for i in ITEMS}
_, opt = brute_force(d)
for name, o in plans.items():
regrets[name].append(delay_cost(o, d) - opt)
finish = sum(d[k] for k in o[: o.index("XMAS") + 1])
xmas_ok[name] += finish <= 7
regrets["同時並行"].append(parallel_cost(d) - opt)
out = {}
for name, r in regrets.items():
r.sort()
out[name] = {"mean": statistics.fmean(r), "p90": r[int(n * 0.9)],
"p_opt": sum(x < 1e-9 for x in r) / n,
"xmas": xmas_ok[name] / n if name in xmas_ok else None}
return out
def expected_cost(order: list[str], items: list[Item] = ITEMS, seed: int = SEED, n: int = TRIALS) -> tuple[float, float]:
"""期間がぶれるときの遅延コストの平均と P90(同じ seed なら案どうしで同じ乱数列を使う)。"""
rng = random.Random(seed)
cs = sorted(delay_cost(order, {i.key: rng.triangular(i.dur[0], i.dur[2], i.dur[1]) for i in items}, items)
for _ in range(n))
return statistics.fmean(cs), cs[int(n * 0.9)]
def on_time(order: list[str], items: list[Item] = ITEMS, seed: int = SEED, n: int = TRIALS) -> float:
"""年末セール(XMAS)が締切 7 週目までに出る確率。"""
rng = random.Random(seed)
hit = 0
for _ in range(n):
d = {i.key: rng.triangular(i.dur[0], i.dur[2], i.dur[1]) for i in items}
hit += sum(d[k] for k in order[: order.index("XMAS") + 1]) <= 7
return hit / n
def cliff_sensitivity(losses=(100, 150, 200, 300, 400), n: int = 5_000) -> list[tuple[float, float, float]]:
"""年末セールの損失額(最も根拠の薄い仮定)を動かし、「年末を先に」と「CD3 のまま」の期待コストを比べる。"""
out = []
for loss in losses:
items = [Item(**{**i.__dict__, "cliff_loss": loss}) if i.key == "XMAS" else i for i in ITEMS]
out.append((loss, expected_cost(order_cd3_with_deadline(items=items), items, n=n)[0],
expected_cost(order_cd3(items=items), items, n=n)[0]))
return out
def split_decision(losses=(100, 200, 300, 400)) -> tuple[list[tuple[float, float, float]], float]:
"""分割したうえで「年末を先頭」と「CD3(年末は諦める)」を年末損失 L ごとに比べ、損益分岐の L を二分探索で出す。"""
so = order_cd3(items=SPLIT_ITEMS)
xf = ["XMAS", *[k for k in so if k != "XMAS"]]
def costs(loss: float) -> tuple[float, float]:
its = [Item(**{**i.__dict__, "cliff_loss": loss}) if i.key == "XMAS" else i for i in SPLIT_ITEMS]
return expected_cost(xf, its, n=5_000)[0], expected_cost(so, its, n=5_000)[0]
table = [(loss, *costs(loss)) for loss in losses]
lo, hi = 0.0, 1_000.0
for _ in range(30):
mid = (lo + hi) / 2
a, b = costs(mid)
lo, hi = (lo, mid) if a < b else (mid, hi)
return table, lo
if __name__ == "__main__":
d0 = mode_durs(ITEMS)
best, opt = brute_force(d0)
rows = [("年間効果順", order_by_annual_value()), ("楽な順", order_easy_first()),
("SAFe WSJF", order_safe_wsjf()), ("CD3", order_cd3()), ("CD3+締切", order_cd3_with_deadline()),
("全順列の最適", best)]
for name, o in rows:
c = delay_cost(o, d0)
print(f"{name:10s} {' → '.join(o):38s} 遅延コスト {c:6.1f} 万円 最適との差 {c - opt:5.1f}")
pc = parallel_cost(d0)
print(f"{'同時並行':10s} {'5 件を均等割り':38s} 遅延コスト {pc:6.1f} 万円 最適との差 {pc - opt:5.1f}")
ds = mode_durs(SPLIT_ITEMS)
so = order_cd3_with_deadline(ds, SPLIT_ITEMS)
sb, sopt = brute_force(ds, SPLIT_ITEMS)
print(f"{'分割+CD3+締切':10s} {' → '.join(so):38s} 遅延コスト {delay_cost(so, ds, SPLIT_ITEMS):6.1f} 万円 分割後の全順列最適 {sopt:.1f}({' → '.join(sb)})")
print()
for name, o, its in (("CD3", order_cd3(), ITEMS), ("CD3+締切", order_cd3_with_deadline(), ITEMS),
("分割+CD3+締切", so, SPLIT_ITEMS),
("分割+年末を先頭", ["XMAS", *[k for k in so if k != "XMAS"]], SPLIT_ITEMS),
("分割+CD3", order_cd3(items=SPLIT_ITEMS), SPLIT_ITEMS)):
m, p90 = expected_cost(o, its)
print(f"[期待値] {name:10s} {' → '.join(o):38s} 遅延コスト 平均 {m:6.1f} 万円 P90 {p90:6.1f} 年末に間に合う {on_time(o, its):5.1%}")
for loss, dl, cd in cliff_sensitivity():
print(f"[感度] 年末の損失 {loss:3.0f} 万円: 年末を先に {dl:6.1f} / CD3 のまま {cd:6.1f} → {'年末を先に' if dl < cd else 'CD3 のまま'}")
table, be = split_decision()
for loss, xf_c, cd_c in table:
print(f"[分割後の判断] 年末の損失 {loss:3.0f} 万円: 年末を先頭 {xf_c:6.1f} / CD3(年末を諦める) {cd_c:6.1f} 差 {xf_c - cd_c:+6.1f}")
print(f"[分割後の判断] 損益分岐の年末損失 {be:.1f} 万円(これ以上なら年末を先頭)")
print()
for name, s in robustness().items():
x = "" if s["xmas"] is None else f" 年末に間に合う {s['xmas']:.1%}"
print(f"[期間が揺れる] {name:10s} 最適との差 平均 {s['mean']:6.1f} 万円 P90 {s['p90']:6.1f} 最適と一致 {s['p_opt']:5.1%}{x}")
テスト(test_cod.py、12 件)
import random
import unittest
import cod as C
class T(unittest.TestCase):
def test_bad_order_reproduces_719(self):
d = C.mode_durs(C.ITEMS)
self.assertEqual(C.order_by_annual_value(), ["CART", "ADMIN", "OPTOUT", "XMAS", "BQ"])
self.assertAlmostEqual(C.delay_cost(C.order_by_annual_value(), d), 719.0)
def test_cd3_is_optimal_when_all_linear(self):
"""締切を外すと CD3 は全順列の最適と一致する(Smith の規則)。乱数 200 例。"""
rng = random.Random(1)
for _ in range(200):
items = [C.Item(f"I{j}", "", rng.uniform(1, 30), (0, 1, 2)) for j in range(5)]
d = {i.key: rng.uniform(0.5, 10) for i in items}
_, opt = C.brute_force(d, items)
self.assertAlmostEqual(C.delay_cost(C.order_cd3(d, items), d, items), opt, places=6)
def test_cd3_alone_misses_cliff(self):
d = C.mode_durs(C.ITEMS)
o = C.order_cd3()
self.assertGreater(sum(d[k] for k in o[: o.index("XMAS") + 1]), 7)
def test_deadline_order_meets_cliff_and_is_optimal_at_mode(self):
d = C.mode_durs(C.ITEMS)
o = C.order_cd3_with_deadline()
self.assertLessEqual(sum(d[k] for k in o[: o.index("XMAS") + 1]), 7)
self.assertAlmostEqual(C.delay_cost(o, d), C.brute_force(d)[1])
def test_cliff_on_the_day_is_in_time(self):
items = [C.Item("X", "", 0, (1, 7, 8), cliff_week=7, cliff_loss=200)]
self.assertEqual(C.delay_cost(["X"], {"X": 7.0}, items), 0)
self.assertEqual(C.delay_cost(["X"], {"X": 7.01}, items), 200)
def test_parallel_never_beats_best_serial(self):
rng = random.Random(3)
for _ in range(300):
d = {i.key: rng.triangular(i.dur[0], i.dur[2], i.dur[1]) for i in C.ITEMS}
self.assertGreaterEqual(C.parallel_cost(d) + 1e-9, C.brute_force(d)[1])
def test_parallel_finish_time_of_last_equals_total_work(self):
items = [C.Item("A", "", 1, (0, 1, 2)), C.Item("B", "", 0, (0, 1, 2))]
# A=2 週, B=2 週を半分ずつ → 2 件とも 4 週目に完成。A の CoD 1 × 4 = 4
self.assertAlmostEqual(C.parallel_cost({"A": 2, "B": 2}, items), 4.0)
def test_split_reduces_cost(self):
d, ds = C.mode_durs(C.ITEMS), C.mode_durs(C.SPLIT_ITEMS)
self.assertLess(C.brute_force(ds, C.SPLIT_ITEMS)[1], C.brute_force(d)[1])
def test_same_seed_same_result(self):
self.assertEqual(C.robustness(n=300), C.robustness(n=300))
def test_deadline_item_goes_as_late_as_possible(self):
"""締切に余裕があるなら前に出さない(不要に他を遅らせない)。"""
items = [C.Item("A", "", 10, (0, 1, 2)), C.Item("X", "", 1, (0, 1, 2), cliff_week=100, cliff_loss=50)]
self.assertEqual(C.order_cd3_with_deadline({"A": 1, "X": 1}, items), ["A", "X"])
def test_parallel_counts_cliff(self):
items = [C.Item("A", "", 0, (0, 1, 2)), C.Item("X", "", 0, (0, 1, 2), cliff_week=3, cliff_loss=50)]
# 単独なら 2 週で間に合うが、半分ずつだと 4 週目 → 締切超え
self.assertEqual(C.parallel_cost({"A": 2, "X": 2}, items), 50)
def test_split_keeps_total_cod(self):
"""分割で CoD を増やして見せない(合計は元の 30 万円/週のまま)。"""
cart = next(i for i in C.ITEMS if i.key == "CART")
self.assertEqual(sum(i.cod_w for i in C.SPLIT_ITEMS if i.key.startswith("CART")), cart.cod_w)
if __name__ == "__main__":
unittest.main()
実行例
$ python3 cod.py
年間効果順 CART → ADMIN → OPTOUT → XMAS → BQ 遅延コスト 719.0 万円 最適との差 90.0
楽な順 BQ → OPTOUT → XMAS → CART → ADMIN 遅延コスト 635.0 万円 最適との差 6.0
SAFe WSJF OPTOUT → XMAS → BQ → CART → ADMIN 遅延コスト 645.0 万円 最適との差 16.0
CD3 CART → OPTOUT → BQ → ADMIN → XMAS 遅延コスト 667.0 万円 最適との差 38.0
CD3+締切 XMAS → CART → OPTOUT → BQ → ADMIN 遅延コスト 629.0 万円 最適との差 0.0
全順列の最適 XMAS → CART → OPTOUT → BQ → ADMIN 遅延コスト 629.0 万円 最適との差 0.0
同時並行 5 件を均等割り 遅延コスト 1079.0 万円 最適との差 450.0
分割+CD3+締切 CART1 → XMAS → OPTOUT → BQ → CART2 → ADMIN 遅延コスト 467.0 万円 分割後の全順列最適 457.0(CART1 → BQ → XMAS → OPTOUT → CART2 → ADMIN)
[期待値] CD3 CART → OPTOUT → BQ → ADMIN → XMAS 遅延コスト 平均 724.6 万円 P90 819.9 年末に間に合う 0.0%
[期待値] CD3+締切 XMAS → CART → OPTOUT → BQ → ADMIN 遅延コスト 平均 740.2 万円 P90 859.3 年末に間に合う 95.1%
[期待値] 分割+CD3+締切 CART1 → XMAS → OPTOUT → BQ → CART2 → ADMIN 遅延コスト 平均 680.9 万円 P90 826.0 年末に間に合う 38.7%
[期待値] 分割+年末を先頭 XMAS → CART1 → OPTOUT → BQ → CART2 → ADMIN 遅延コスト 平均 663.4 万円 P90 752.9 年末に間に合う 95.0%
[期待値] 分割+CD3 CART1 → OPTOUT → BQ → CART2 → ADMIN → XMAS 遅延コスト 平均 648.0 万円 P90 702.1 年末に間に合う 0.0%
[感度] 年末の損失 100 万円: 年末を先に 734.5 / CD3 のまま 624.1 → CD3 のまま
[感度] 年末の損失 150 万円: 年末を先に 736.9 / CD3 のまま 674.1 → CD3 のまま
[感度] 年末の損失 200 万円: 年末を先に 739.3 / CD3 のまま 724.1 → CD3 のまま
[感度] 年末の損失 300 万円: 年末を先に 744.1 / CD3 のまま 824.1 → 年末を先に
[感度] 年末の損失 400 万円: 年末を先に 748.8 / CD3 のまま 924.1 → 年末を先に
[分割後の判断] 年末の損失 100 万円: 年末を先頭 657.2 / CD3(年末を諦める) 547.3 差 +109.9
[分割後の判断] 年末の損失 200 万円: 年末を先頭 661.9 / CD3(年末を諦める) 647.3 差 +14.6
[分割後の判断] 年末の損失 300 万円: 年末を先頭 666.6 / CD3(年末を諦める) 747.3 差 -80.7
[分割後の判断] 年末の損失 400 万円: 年末を先頭 671.4 / CD3(年末を諦める) 847.3 差 -175.9
[分割後の判断] 損益分岐の年末損失 215.3 万円(これ以上なら年末を先頭)
[期間が揺れる] 年間効果順 最適との差 平均 85.9 万円 P90 128.5 最適と一致 0.0% 年末に間に合う 0.0%
[期間が揺れる] 楽な順 最適との差 平均 205.7 万円 P90 276.0 最適と一致 2.3% 年末に間に合う 12.4%
[期間が揺れる] SAFe WSJF 最適との差 平均 143.4 万円 P90 292.0 最適と一致 0.3% 年末に間に合う 49.6%
[期間が揺れる] CD3 最適との差 平均 22.5 万円 P90 63.4 最適と一致 26.1% 年末に間に合う 0.0%
[期間が揺れる] CD3+締切 最適との差 平均 38.1 万円 P90 83.8 最適と一致 16.1% 年末に間に合う 95.1%
[期間が揺れる] 同時並行 最適との差 平均 495.4 万円 P90 550.9 最適と一致 0.0%
$ python3 -m unittest test_cod
Ran 12 tests in 0.6s
OK
- 最頻値: 年間効果順 719.0、同時並行 1,079.0、最適 629.0。CD3 だけは年末を最後に回して 667.0
- 期間がぶれると逆転: 年末先頭 740.2 vs CD3 724.6。年末は最頻 4 週でも平均 5.0 週で、その間ほかの 4 件(CoD 計 49 万円/週)を待たせる
- 最頻値で締切に詰めると落ちる: 「カゴ落ち 1 段目 → 年末」(2+4=6 ≤ 7)は間に合う確率 38.7%。年末を先頭なら 95.0%
- 分割が一番効く: 分割+CD3 648.0(P90 702.1)、分割+年末先頭 663.4(P90 752.9)。分割前の最良 724.6 より 61〜77 万円少ない
会議に出す着手順の書き直し
件名: 来四半期の着手順 — カゴ落ちを 2 つに割り、年末セールは「失う額」を 10/10 までに測ってから決める
結論
1. カゴ落ちを 1 段目(固定文面・1 通、約 2 週)と 2 段目(出し分け・3 通)に割る
2. 着手順は次のどちらか。決め手は「年末セールを出せなかったときに失う額 L」
L ≥ 約 215 万円 → 年末セール → カゴ落ち 1 段目 → 配信停止 → BQ → カゴ落ち 2 段目 → 管理画面
L < 約 215 万円 → カゴ落ち 1 段目 → 配信停止 → BQ → カゴ落ち 2 段目 → 管理画面(年末は既存の単段クーポンで代替)
3. 同時着手はしない。1 件ずつ終わらせる(WIP 1。レビュー待ちの間だけ次に手を付けてよい)
根拠(試算。入力はすべて仮置き、10/10 に差し替え)
- 年間効果順+同時着手: 遅延コスト 1,079 万円。最良の並べ方(457〜629 万円)より 450〜620 万円多い
- 年末セールは 7 週目のフリーズを過ぎると 200 万円(仮置き)を一度に失う。今の案では間に合う確率 0%
- 期間は見積もりより長くなりがち。年末を「ぎりぎり間に合う位置」に入れると 6 割の確率で落ちる → 入れるなら先頭
10/10 までに測るもの
- L: 昨年の年末多段クーポンの増分利益(9/24 の効果報告と同じ、10% ホールドアウトとの差で出す)
- 各案件の期間の幅: 過去 1 年の同規模案件の予実(見積もり÷実績)
決めた後に見直す条件
- 年末セールの残作業が 5 週目の終わりで 2 週を超えている → 単段クーポンに切り替え、カゴ落ち 1 段目に移る
- 新しい案件が来たら、その CoD と期間を同じ表に足して再計算(10 件までは全順列で確かめる)迷った案と採らなかった理由
| 案 | 期待値の遅延コスト(万円) | 判断 | 前提が変わったら |
|---|---|---|---|
| 年間効果順+同時着手(後輩案) | 最頻値で 1,079.0 | 却下。同時着手だけで +450 | — |
| SAFe WSJF(シニア案) | 最適との差 平均 +143.4・P90 +292.0 | 却下。点の比に意味が無く、年末 49.6% | 金額が全く置けない初期なら会話の道具には使える |
| CD3 だけ | 724.6(年末 0%) | 単体では採らない。固定日型を見落とす | 固定日型が無いなら最適(Smith の規則。テスト 200 例) |
| CD3+締切(最頻値で挿入) | 740.2(年末 95.1%) | 採らない。分割の方が良い | — |
| 採用: 分割+L で分岐 | 648.0(諦める)/663.4(先頭・95.0%) | L が分かるまでは年末を先頭で準備。L が 100〜400 のどこか分からないなら最悪の後悔は先頭 109.9・諦める 175.9 | L < 215 万円と分かれば分割+CD3 へ |
| 全順列で毎回最適 | 事後の最適 | 計画には使えない。期間は着手前に分からない | 予実が ±10% 以内なら最頻値の最適で十分 |
| 年末を縮小版(約 1.5 週)に | 未計算 | 有望。縮小版で残る価値の割合が未測定 | 縮小版で L の 6 割以上取れるなら最有力 |
判断ルールと止め方
- 並べ替えの議論は「1 週遅れると失う額(万円/週)」と「何週かかるか(範囲)」の 2 列だけで行う。相対点は使わない
- 固定日型は CD3 の表から外し、「先頭に置いたときに締切に間に合う確率」を別に出す。7 割を切るなら範囲を削るか諦める
- 入力を置き換えたら
python3 cod.pyを再実行(約 14 秒)し、結論が入れ替わる値だけを会議に出す - この試算をやめる条件: 案件が 10 件を超える(全順列 362 万通り)、または CoD が置けない案件が半数を超える(その場合は「固定日型か・線形か・いつ価値が消えるか」の 3 区分で並べる)
ポイント解説
- 並べ替えで変わるのは「いつ得るか」: 総額でなく 1 週あたりの遅延コストで比べる。年間効果順は 90 万円、同時着手は 450 万円を、何も作らずに失う
- CD3 には理論の裏付けがある: 「重み × 完成時刻」の和を最小にするのは、重み ÷ 処理時間の大きい順(Smith 1956、WSPT)。ただし締切で価値が消える案件が入ると保証は無い
- 期間は右に長い: 年末先頭は最頻値では最適、期待値では 15.6 万円負ける。締切に「ぎりぎり入る」計画は 6 割で落ちる
- 一番効くのは割り方: カゴ落ちの価値の 2/3 を 2 週で出すだけで −172 万円(最頻値)。並べ替えの差(6〜90 万円)より大きい
- 結論を決める仮定を 1 つに絞って測る: 年末の損失 L は損益分岐 215 万円のすぐ近く。議論すべきはこの数字だけで、測り方(ホールドアウト)も既にある
実務への応用
- 優先度会議の資料は「案件・CoD(万円/週)・期間(最小・最頻・最大)・締切と失う額」の 4 列に固定する
- MOps の年間カレンダー(年末・新春・GW・夏・ブラックフライデー)は固定日型の宝庫。毎年同じフリーズに詰まるなら、期間の予実を貯めて幅を狭める
- CoD の根拠には 9/24 の問題で使ったホールドアウトの増分利益をそのまま使える
- Jira・Linear の優先度欄の代わりに CoD と期間の 2 欄を足すだけで、この試算を毎週回せる
扱わなかった範囲(止め方)
- 入力はすべて仮定。CoD の推定方法そのもの(増分利益・リスクの期待損失)は扱っていない
- チームは 1 列に並ぶ前提。2 つの流れに分ける(並列機械)、人ごとの得意不得意、案件間の依存関係は扱っていない
- CoD は線形と固定日型の 2 種だけ。urgency profile の「価値が逓減する/後から上がる」は扱っていない
- 割引率・機会費用・文脈切替のコストは入れていない。同時並行の 1,079 万円は切替コストを含まないので、実際はもっと悪い
- 外部資料(いずれも未参照・考え方の対応のみ): Reinertsen『The Principles of Product Development Flow』(2009)、Smith(1956, WSPT)、SAFe の WSJF、Black Swan Farming の urgency profiles
- 世界の実物との対応: CoD・CD3・WSPT・固定日型・WIP 制限・期間の分布・損益分岐の 7 概念を扱った。urgency profile は 4 種中 2 種、並列機械・依存関係は 0
次のステップ(発展問題): チームを 2 つの流れ(2 名 × 2)に分けられるとき、5 件をどう割り振るか。並列機械では CD3 の最適性が崩れる。全探索と CD3 貪欲法の差を測り、「流れを分ける」こと自体の価値を万円で出す。
今日のまとめ
並べ替えは「いくら得るか」ではなく「1 週遅れると失う額 ÷ 何週かかるか」で決める。締切で価値が消える案件は別に扱い、期間は範囲で見る。一番効くのは割ることで、結論を決める仮定(年末の損失 L)だけを測る。