今朝、広告の配信を始めるチケットが閉じました。10日前に書いた記事の最後で「残り13本」と数えたうちの1本です。
その記事(10-15 公開の WBS の記事)を書き終えたとき、自分でも答えを持っていない問いが1つ残っていました。残りの13本は、いつ終わるのか。
進捗率が当てにならないことは書けても、何日後に終わるのかと聞かれたら、私は「来週には」くらいしか言えなかったと思います。現場では、この「来週には」が一番危ないんですよね。口にした瞬間に日付として独り歩きし、外れたときには根拠がどこにも残っていません。
最近、見積もりを1つの日付ではなく確率で出す話をよく目にします。過去の実績から「50%ならこの日、85%ならこの日」と幅で示すやり方です。理屈はそうなんですが、うちのチケットで当たるのかは、やってみないと分かりません。
幸い、あの日から10日たっています。10-15 の夜の時点で手元にあったデータだけを使って予測を回し、その後の10日間の実際と突き合わせれば、当たり外れがそのまま見えます。当時は回していなかったので後出しの予測ではありますが、使うデータを 10-15 までに限れば、答え合わせとしては公平です。
1日ごとの完了数から1万回くじを引く
やり方は単純です。起票した 10-11 から 10-15 までの5日間に、1日ごとに何本閉じたかを数えます。結果は4本、0本、1本、14本、1本でした。
予測では、この5日のどれか1日をくじで引き、その日と同じ本数だけ閉じたことにします。残りの13本がなくなるまで引き続け、何日かかったかを記録する。これを1万回繰り返して、かかった日数を短い順に並べます。短いほうから50%の位置が P50、85%の位置が P85 です。
PMBOK でも、定量的なリスク分析の道具としてシミュレーションが挙がっています。ただ、フレームワークは目的ではなく道具です。教科書どおりに1本ずつ三点見積もり(楽観・最頻・悲観)を付けて合計する方法もありますが、今回は使いませんでした。残っているのは専門家への確認や本番の受け入れ確認のような作業で、作業そのものの時間より「いつ手が空くか」「いつ返事が来るか」で終わる日が決まります。1本ずつ所要時間を見積もっても、その数字に意味が乗らないと判断しました。
データは前回の記事と同じく Redmine から書き出した CSV で、列は id・作成日時・終了日時・担い手の4つです。担い手は dev(開発側)、people(人の作業)、review(開発側の作業だが外部の確認待ち)の3種類にしています。
import csv
import random
from datetime import date, datetime, timedelta
FIRST_DAY = date(2026, 10, 11) # 起票した日
AS_OF = date(2026, 10, 15) # 予測を出す日。この日の終わりまでのデータだけを使う
TRIALS = 10_000
def day(s):
return datetime.strptime(s, "%Y-%m-%d %H:%M").date() if s else None
tickets = [
{**r, "created": day(r["created"]), "closed": day(r["closed"])}
for r in csv.DictReader(open("tickets.csv", encoding="utf-8"))
]
known = [t for t in tickets if t["created"] <= AS_OF]
days = [FIRST_DAY + timedelta(n) for n in range((AS_OF - FIRST_DAY).days + 1)]
history = [sum(t["closed"] == d for t in known) for d in days]
left = sum(not t["closed"] or t["closed"] > AS_OF for t in known)
def days_to_finish(rng, samples, n):
elapsed = done = 0
while done < n:
done += rng.choice(samples) # 過去のどれか1日と同じ本数だけ閉じる
elapsed += 1
return elapsed
rng = random.Random(1015)
runs = sorted(days_to_finish(rng, history, left) for _ in range(TRIALS))
print("1日に閉じた本数:", history, " 残り:", left)
for p in (50, 85, 95):
n = runs[TRIALS * p // 100 - 1]
print(f"P{p}: {n}日({AS_OF + timedelta(n):%m-%d} まで)")
print(f"10日以内に終わる割合: {sum(r <= 10 for r in runs) / TRIALS:.0%}")
# 実行結果:
# 1日に閉じた本数: [4, 0, 1, 14, 1] 残り: 13
# P50: 4日(10-19 まで)
# P85: 8日(10-23 まで)
# P95: 10日(10-25 まで)
# 10日以内に終わる割合: 96%
85%の確率で 10-23 まで、1万回のうち96%が10日以内に終わる、という予測です。14本閉じた日が混ざっているので、最初のくじでその日を引けば、13本は1日で片付きます。
正直なところ、この数字を見た瞬間は「意外と近いな」と思いました。
10日後の答え合わせ
| 日付 | 予測の上での意味 | 残り13本のうち、その日までに閉じた本数 |
|---|---|---|
| 10-19 | P50(半分の確率でここまでに終わる) | 2 / 13 |
| 10-23 | P85(85%の確率でここまでに終わる) | 2 / 13 |
| 10-25(今日の昼) | P95(10日以内に終わる割合は96%) | 6 / 13 |
外れ方は「少し遅れた」ではありませんでした。P85 の 10-23 の時点で閉じたのは2本、今日まででも6本です。予測の側から見ると、1万回のうち4%しかなかった「10日では終わらない」側に、現実がすっぽり入っています。
チームの手が止まっていたわけではありません。10-16 から今日までの10日間で、チケットは合わせて17本閉じています。ただし、そのうち11本は、10-15 より後に足された20本の中身でした。残りの13本から閉じたのは6本だけ。しかも10日のうち6日は、閉じた本数が0本です。
予測に使った速さは、別の種類のチケットの速さだった
想定外だったのは、外れた理由が乱数のいたずらではなかったことです。
最初の5日間に閉じた20本のうち、18本は開発側のチケットでした。人の作業で閉じたのは2本だけです。一方、10-15 の時点で残っていた13本は、9本が人の作業、4本が外部の確認待ち。つまり私は、開発側の速さで、人と外部の作業の終わりを予測していたことになります。
ドキュメントにはこう書いてあるけど、という話でいうと、確率で見積もる解説はどれも「過去の実績を材料にする」と書いています。それ自体は正しい。ただ、どの実績を材料にするかまでは、こちらで決めるしかないんですよね。前回の記事で「閉じやすいチケットを足して閉じるたびに進捗率が上がる」と書いた、あの偏りが、予測の材料にもそのまま入り込んでいました。
そこで、残りと同じ種類(人の作業と外部の確認待ち)だけで閉じる速さを数え直し、もう一度回しました。前のコードの続きです。
# 答え合わせ: 10-15 より後の閉じた日付は、ここで初めて見る
rest = [t for t in known if not t["closed"] or t["closed"] > AS_OF]
for d in (date(2026, 10, 19), date(2026, 10, 23), date(2026, 10, 25)):
n = sum(bool(t["closed"]) and t["closed"] <= d for t in rest)
print(f"{d:%m-%d} までに閉じた本数: {n} / {len(rest)}")
# 残りと同じ種類(人の作業・外部の確認待ち)だけで、閉じる速さを数え直す
SAME_KIND = {"people", "review"}
history_same = [sum(t["closed"] == d and t["owner"] in SAME_KIND for t in known) for d in days]
left_same = sum(t["owner"] in SAME_KIND for t in rest)
rng = random.Random(1015)
runs = sorted(days_to_finish(rng, history_same, left_same) for _ in range(TRIALS))
print("同じ種類で1日に閉じた本数:", history_same, " 残り:", left_same)
for p in (50, 85, 95):
n = runs[TRIALS * p // 100 - 1]
print(f"P{p}: {n}日({AS_OF + timedelta(n):%m-%d} まで)")
print("最短:", runs[0], "日")
# 実行結果:
# 10-19 までに閉じた本数: 2 / 13
# 10-23 までに閉じた本数: 2 / 13
# 10-25 までに閉じた本数: 6 / 13
# 同じ種類で1日に閉じた本数: [1, 0, 1, 0, 0] 残り: 13
# P50: 32日(11-16 まで)
# P85: 40日(11-24 まで)
# P95: 45日(11-29 まで)
# 最短: 15 日
今度は1万回のうち1回も、10日以内に終わりません。最短でも15日です。実際の10日間で閉じたのは6本なので、「10日で13本は無理」という点では、こちらの予測のほうが現実に沿っていました。
ただ、このコードには改善の余地があります。同じ種類の実績は5日間で2本しかなく、くじの中身はほぼ「1本か0本か」です。実際のこの10日間は1日あたり0.6本で、最初の5日間の0.4本より速く進みました。材料が少なすぎて、幅の形はかなり当てずっぽうに近いと思っています。それでも、開発側の速さを混ぜた予測よりは、外れる向きが正直です。
日付が決まっている作業は、本数に混ぜない
もう1つ、どちらの予測も知らないことがありました。残りの13本には「4週間の検証配信を運用して評価する」というチケットが入っています。このチケットは、配信を始めてから4週間たたないと閉じようがありません。
配信を始めるチケットが閉じたのは今朝です。ということは、このチケットは早くても 11-22 まで閉じません。1日に何本閉じるかをいくらくじで引いても、この28日は縮まらないわけです。10-15 の時点では配信を始める日すら決まっていなかったので、言えたのは「配信を始めた日から28日より前には終わらない」ということだけでした。
試しに、10-15 の時点のデータで、この1本を外した12本だけを同じ種類の速さで回すと、P50 は 11-13、P85 は 11-21 です。1万回のうち87%は、12本が 11-22 より前に片付きます。全体の終わりを決めていたのは本数ではなく、4週間という日数のほうでした。
ここは好みが分かれるところですが、私はこの種のチケットを「本数で数えるもの」から外し、カレンダーに日付で置くことにしました。予測は2つに分けて出し、遅いほうを全体の終わりとして伝えます。
予測を出すときに添える5行
数字だけを伝えると、P85 の日付がそのまま約束として独り歩きします。今後は、予測の下に次の5行を必ず添えることにしました。今日の時点の値を入れるとこうなります。
予測: 人と外部の作業7本は 50% で 11-07、85% で 11-15 まで(同じ種類の実績15日分から1万回)
数えたもの: 人の作業と外部の確認待ちだけ。開発側のチケットの速さは使っていない
入れていないもの: これから足されるチケット(直近10日で20本足された)
日付で置いたもの: 4週の検証配信(配信開始 10-25 → 早くても 11-22)
答え合わせ: 毎週、P85 の日付までに閉じた本数を記録して、この5行の下に足す
7本は、残っていた13本のうち閉じていない7本から4週の配信を外し、10-19 に足された人の作業1本を加えた数です。日付で置いた 11-22 のほうが遅いので、全体の終わりとして伝えるのは「早くても 11-22」になります。
3行目の「入れていないもの」は、書いてみて初めて重さに気づきました。この10日間に足された20本のうち19本は開発側のチケットで、人の作業が増えたのは1本です。開発側の手がそちらに回っていたことは、閉じた17本の内訳を見れば分かります。予測の数字だけを見ていたら、この話はどこにも出てこなかったはずです。
おわりに
今思えば、最初の予測の計算そのものは間違っていませんでした。くじの引き方も、1万回という回数も、確率の読み方も、解説に書かれているとおりです。間違っていたのは「何を数えるか」のほうで、それは計算をいくら丁寧にしても見つかりません。答え合わせをして初めて見えました。
次は、残りを3つに分けてから予測を出すつもりです。開発側のチケットは開発側の速さで、人と外部の作業はその速さで回し、日付の決まった作業はカレンダーに置く。そのうえで、毎週月曜に P85 の日付と実際に閉じた本数を並べて記録していきます。これから足されるチケットまで予測に入れるかどうかは、まだ決めていません。20本のうち19本が開発側だった、というこの10日間の偏りが続くのかどうか、まずは来週の答え合わせで見てみたいと思っています。