6
6

More than 3 years have passed since last update.

99%の確率で納期を守るには

Last updated at Posted at 2020-04-12

Python プログラムから理解する見積もりの落とし穴

なぜ書いた?

  • 統計学の初級セミナーに参加したので何か実務に生かせるアイデアをまとめたい
  • 記事として公開することであわよくば社内外からご指導ご鞭撻されたい
  • プロジェクトマネジメントに関して私と同様な課題をお持ちの方の参考になるかもしれない

なぜ納期を守れないのか

原因は様々でしょうがここでは次の2点に着目します。

  • 過少見積もりをしてしまう
  • 遅れの兆候に気がつくのが遅い

なぜ過少見積もりしてしまうのか

python を用いたシミュレーションでこの謎を解いてみよう。

前提

  • WBSを用いた見積もりを想定する
    • プロジェクトを100個のタスクに分解して各タスクを1人日と見積もればプロジェクト全体は100人日といった具合
    • 問題の簡略化のため各タスクの見積もりは均一とする
  • 見積もり精度は100~400%のブレ幅から適当に設定する
    • 見積もり精度が400%(非常に悪い)であれば見積もりコストに対して実際のコストは4分の1から4倍の範囲でブレる(微塵もブレないなら見積もり精度は100%)
    • 実際のコストは見積もりコストを中心に正規分布し、ブレ幅が±3σに収まるとする
    • 独自に仮定したモデルですがヒストグラムにすると参考文献2のリスク図に似るので悪くはないでしょう

シミュレーションしてみる

下記のコードで上記の前提をシミュレーションします。乱数を使っているので実行するたびに結果が変わりますから何度か実行してみましょう。合計は110前後になることが多いはずです。

import numpy as np
import numpy.random as rd
import matplotlib.pyplot as plt
import seaborn as sns


def simulate(tasksNum, accuracyWidth=4, accuracyLevel=3):
    """
    タスクの数と見積もり精度から
    個々のタスクの消化に要するコストをシミュレートする

    Parameters
    ----------
    tasksNum : int
        タスクの数
    accuracyWidth : int
        見積もり精度(のブレ幅)
        1 < accuracyWidth <= 4 程度
        デフォルトなら 400% のブレ幅
        1に近いほど見積もり精度が良い
    accuracyLevel
        見積もり精度(の確度)
        デフォルトなら400%が±3σの範囲に収まるものとする
        1や2にすると確度が低くなる

    Returns
    -------
    realCosts : list
        各タスクの消化にかかる実コストのリスト。
        実コストは見積もりコスト1に対する倍率。
    """

    # タスクの数だけ試行する
    accuracySpac = accuracyWidth ** (1 / accuracyLevel)
    realCosts = [accuracySpac ** n for n in rd.randn(tasksNum)]
    return realCosts


# タスクの数を指定してシミュレートする
realCosts = simulate(100)

# 統計量をプリントする
print("合計 : ", np.sum(realCosts))

# ヒストグラムを描画する
sns.distplot(realCosts)
plt.show()

結果

合計 :  110.15604841545104

Figure_1.png

シミュレーションの結果について着目すべきは下記の2点です。

  • 見積もり精度が悪い(400%)にもかかわらず結果が大きくは外れない
  • 何度か実行しても見積もりの合計値(100)以下になることは滅多にない

前者からはタスクを分解して見積もることの重要性が実感できますね。
良かったら上記のコードをタスクの数を減らして実行してみましょう。

後者の「滅多にない」とはどのくらい滅多にないのでしょうか?
先のコードの関数定義はそのままに、下記のコードでシミュレーションしてみましょう。
もしも...

  • 見積もりの合計値(100)の工数を確保していた場合
  • 実コストの合計値の平均(μ)の工数を確保していた場合
  • 実コストの合計値の平均(μ)+標準偏差(σ)の工数を確保していた場合
  • μ + 2σ の工数を確保していた場合
  • μ + 3σ の工数を確保していた場合

...の5つの場合において確保した工数が実コストの合計値を上回る確率を成功率と題して求めます。

import numpy as np
import numpy.random as rd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats


def simulate(tasksNum, accuracyWidth=4, accuracyLevel=3):
    """
    タスクの数と見積もり精度から
    個々のタスクの消化に要するコストをシミュレートする

    Parameters
    ----------
    tasksNum : int
        タスクの数
    accuracyWidth : int
        見積もり精度(のブレ幅)
        1 < accuracyWidth <= 4 程度
        デフォルトなら 400% のブレ幅
        1に近いほど見積もり精度が良い
    accuracyLevel
        見積もり精度(の確度)
        デフォルトなら400%が±3σの範囲に収まるものとする
        1や2にすると確度が低くなる

    Returns
    -------
    realCosts : list
        各タスクの消化にかかる実コストのリスト。
        実コストは見積もりコスト1に対する倍率。
    """

    # タスクの数だけ試行する
    accuracySpac = accuracyWidth ** (1 / accuracyLevel)
    realCosts = [accuracySpac ** n for n in rd.randn(tasksNum)]
    return realCosts


# タスクの数を指定してシミュレートする...を2000回試行する
tasksNum = 100  # タスクの数
trialsNum = 2000  # 試行回数
realCosts = [simulate(tasksNum) for i in range(trialsNum)]

# 統計量をプリントする
myMean = np.mean(realCosts) * tasksNum
myStd = (np.var(realCosts) * tasksNum) ** 0.5
print("平均(μ) : ", myMean)
print("標準偏差(σ) : ", myStd)

# 確保した工数ごとに納期に間に合った割合を求める
totalRealCosts = [np.sum(s) for s in realCosts]
resource = [tasksNum]
for i in range(4):
    resource.append(myMean + (i * myStd))
win = [len([n for n in totalRealCosts if n <= r]) /
       trialsNum for r in resource]
print(f'成功率(sum <= {resource[0]:.1f}       ): {win[0]: .2%}')
print(f'成功率(sum <= {resource[1]:.1f} = μ   ): {win[1]: .2%}')
print(f'成功率(sum <= {resource[2]:.1f} = μ+1σ): {win[2]: .2%}')
print(f'成功率(sum <= {resource[3]:.1f} = μ+2σ): {win[3]: .2%}')
print(f'成功率(sum <= {resource[4]:.1f} = μ+3σ): {win[4]: .2%}')

# ヒストグラムを描画する
sns.distplot(totalRealCosts, kde=False, fit=stats.norm)
plt.show()

結果

平均(μ) :  111.54986385877234
標準偏差(σ) :  5.4746810585456
成功率(sum <= 100.0       ):  1.70%
成功率(sum <= 111.5 = μ   ):  50.60%
成功率(sum <= 117.0 = μ+1σ):  84.40%
成功率(sum <= 122.5 = μ+2σ):  97.90%
成功率(sum <= 128.0 = μ+3σ):  99.85%

Figure_1.png

やはり乱数を使っているので実行するたびに結果は変わりますが「見積もりの合計値(100)の工数を確保していた場合」の成功率は1~2%になるはずです。
見積もり精度を200%(それなりに優秀)としても13%程度です。
99% の成功率を得ることができるのは μ + 3σ の工数を確保した場合です。

シミュレーションのまとめ

  • 個々のタスクの見積もりを単純に合計した工数を確保しても納期には間に合わない
    • あなたが見積もり精度 100% の超人なら別
    • あるいは 1~2% や 13% の奇跡を信じた博打がしたいなら別
  • 平均と分散は見積もり精度が悪いと悪化する
    • 分散とタスクの分解個数で標準偏差が決まります
    • 過去の実績データがあればそこから平均と分散を計算すると良いでしょう
    • 実績データがなければ本記事のシミュレーションで用いたようなモデルか業界平均のデータを使いましょう
  • 求める成功率に応じて平均に標準偏差の何倍かを加えた工数を確保すべき
    • マージン(バッファ)を減らすには見積もり精度を上げるか、低い成功率を許容するしかありません(許容するのもリスク管理の一つ?)
    • 平均の工数だけ確保しても成功率は50%です。半数のプロジェクトが失敗(炎上)したら、成功するはずのプロジェクトもリソースを奪われてほぼ全てのプロジェクトが失敗する...?

遅れの兆候に気がつくのが遅い

はじめに適切なマージン(バッファ)を確保しても油断はできません。
遅れの兆候に早く気がつけるよう監視しましょう。
本当に遅れる前に対処すれば傷は浅いはずです。

「遅れているソフトウェアプロジェクトへの要員追加は、プロジェクトをさらに遅らせるだけである」
(引用:フレデリック・ブルックス『人月の神話』)

先のシミュレーションで使用した手法を応用して下記の観点でプロジェクトを監視することを考えてみます。

  • 平均と分散がプロジェクト開始時の想定と一致しているか
  • マージンを消費しすぎていないか

平均と分散がプロジェクト開始時の想定と一致しているか

確保したリソースの根拠が崩れていないか監視します。
想定より平均が上振れしていることには気が付きやすいかもしれませんが分散を気にしたことがありますか?
分散が想定より悪いと標準偏差も悪化してリスクが大きく(=必要なマージンが大きく)なります。

平均が一致していれば見方によっては「オンスケ」となりますが、実は成功率が低下しているかもしれないのです。

マージンを消費しすぎていないか

「標準偏差の何倍かを加えた工数」は必要なマージンの量を表しています。
プロジェクトが進むにつれて(=残りのタスクが減るにつれて)標準偏差は減りますが、その減少速度は緩やかです。なのでマージンは使うものでなく維持するものと考えなければなりません。

つまり必要なマージンと残っているマージンを常時観察すれば遅れの兆候に敏感になれます。
それらしくシミュレーションしてみましょう。

import numpy as np
import numpy.random as rd
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns


def simulate(tasksNum, accuracyWidth=4, accuracyLevel=3):
    """
    タスクの数と見積もり精度から
    個々のタスクの消化に要するコストをシミュレートする

    Parameters
    ----------
    tasksNum : int
        タスクの数
    accuracyWidth : int
        見積もり精度(のブレ幅)
        1 < accuracyWidth <= 4 程度
        デフォルトなら 400% のブレ幅
        1に近いほど見積もり精度が良い
    accuracyLevel
        見積もり精度(の確度)
        デフォルトなら400%が±3σの範囲に収まるものとする
        1や2にすると確度が低くなる

    Returns
    -------
    realCosts : list
        各タスクの消化にかかる実コストのリスト。
        実コストは見積もりコスト1に対する倍率。
    """

    # タスクの数だけ試行する
    accuracySpac = accuracyWidth ** (1 / accuracyLevel)
    realCosts = [accuracySpac ** n for n in rd.randn(tasksNum)]
    return realCosts


# タスクの数を指定してシミュレートする
import numpy as np
import numpy.random as rd
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns


def simulate(tasksNum, accuracyWidth=4, accuracyLevel=3):
    """
    タスクの数と見積もり精度から
    個々のタスクの消化に要するコストをシミュレートする

    Parameters
    ----------
    tasksNum : int
        タスクの数
    accuracyWidth : int
        見積もり精度(のブレ幅)
        1 < accuracyWidth <= 4 程度
        デフォルトなら 400% のブレ幅
        1に近いほど見積もり精度が良い
    accuracyLevel
        見積もり精度(の確度)
        デフォルトなら400%が±3σの範囲に収まるものとする
        1や2にすると確度が低くなる

    Returns
    -------
    realCosts : list
        各タスクの消化にかかる実コストのリスト。
        実コストは見積もりコスト1に対する倍率。
    """

    # タスクの数だけ試行する
    accuracySpac = accuracyWidth ** (1 / accuracyLevel)
    realCosts = [accuracySpac ** n for n in rd.randn(tasksNum)]
    return realCosts


# タスクの数を指定してシミュレートする
tasksNum = 100  # タスクの数
sampleTasksNum = 50  # サンプリングするタスクの数
prevRealCosts = simulate(tasksNum, 3, 3)  # 前回のプロジェクトの結果を参考に見積もりを行うが...
realCosts = simulate(tasksNum, 4, 3)  # ...今回のプロジェクトの見積もり精度は前回より悪い

# プロジェクト開始時に確保したリソース
totalResource = np.mean(prevRealCosts[sampleTasksNum:]) * tasksNum + \
    ((np.var(prevRealCosts[sampleTasksNum:]) * tasksNum) ** 0.5) * 3

# 統計量をプリントする
print("見積もり : ", totalResource)
print("結果 : ", np.sum(realCosts))

# タスクを消化するごとの統計量の推移を求める
data = []
for tasksNumL in range(tasksNum+1):  # 消化済みのタスク数が増大していくと...
    tasksNumR = len(realCosts) - tasksNumL  # ...残りのタスク数は減少していく
    realCostsL = realCosts[:tasksNumL]  # 消化済みのタスクに要したコストが分かっているとする

    # 序盤はサンプルが少ないので前回のプロジェクトの結果を加える
    realCostsSample = []
    realCostsSample.extend(prevRealCosts[tasksNumL:])
    realCostsSample.extend(realCostsL)

    # 残りのタスクに要するコストの平均の総和(期待値)
    totalMeanR = np.mean(realCostsSample[sampleTasksNum:]) * tasksNumR
    # 残りのタスクに要するコストの分散の総和(バラつき)
    totalVarR = np.var(realCostsSample[sampleTasksNum:]) * tasksNumR
    totalStdR = totalVarR ** 0.5  # 標準偏差(σ)
    totalResourceR = totalResource - np.sum(realCostsL)  # 残されたリソース

    data.append((tasksNumL, totalMeanR, totalStdR * 3, totalResourceR))

# グラフを描画する
df = pd.DataFrame(data, columns=("count", "mean", "std3", "res"))
df[['mean', 'std3']].plot.area()
sns.lineplot(x="count", y="res", data=df)
plt.show()

結果①

見積もり :  109.51446586170162
結果 :  111.11339031765625

Figure_1.png

グラフの横軸は消化したタスクの数です。
縦軸は青い面が残りのタスクを消化するために必要なリソース(工数)を予想した平均値(μ)、オレンジの面が予想に 3σ を加えた値を表しています(信頼区間?)。
そして緑の線は残されたリソースを表しています。

この結果はまるで中盤でマージンを使い切ったけど「平均には寄り添っているしまだいける...!」と思っていたけどやっぱり間に合わなかったように見えます。それとも平均に近くはあるので「オンスケです」と最後まで報告していたかもしれません。

平均を基準にすると遅れの兆候に鈍感になります。
+3σ のラインから離脱した時点で何らかの対策をすべきでした。

結果②

見積もり :  119.98274678937868
結果 :  111.27013716062785

Figure_1.png

そこそこ安定したプロジェクトだったようです。
序盤で一度 +3σ のラインから離脱していますが持ち直しているので何らかの対策をしたのかもしれません。

まとめ

マージン(バッファ)は消費するものでなく、遅れを検知するセンサーのように使いましょう。
マージンの需要と実情を常に観察すれば遅れの兆候に敏感になれるはずです(アジリティ向上?)

残された課題

  • 現実のWBSでは見積もりが均一でない
    • この記事で述べたより複雑な計算を強いられそうです
    • 例えば見積もりそのものでなく見積もり誤差を対象に計算すれば実現可能な気がします
  • プロジェクト序盤では計算結果が信頼できない
    • はじめは過去のプロジェクトのデータを入れておき徐々に代謝させてはどうかと思います
    • 「遅れの兆候に気がつくのが遅い」のシミュレーションではそうしています
  • あくまで一次元的な指標
    • 工数管理には良いのですが工期管理、工程管理はスコープ外です
    • 複雑な工程管理にはPERT図やガントチャートも必要です
      • だからこそ工数管理くらいはこういった指標で機械的に判断すべき?
      • 複雑な工程管理をなくす努力をすべき?

参考文献

この記事の内容が興味深かった方が楽しめそうな本を紹介しておきます。

  1. 熊とワルツを - リスクを愉しむプロジェクト管理
  2. ソフトウェア見積り 人月の暗黙知を解き明かす
  3. 人月の神話
6
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
6