開発工数と機能数の数理モデル
自分たちはどれくらい新規の開発工数にかけられているのかを、単純な数理モデルを素直に書き下してみた。
新規機能開発工数 = 開発工数 - 既存機能保守工数 - バグ修正工数
いわゆる人月と呼ばれるチームの開発工数があり、そこから、既存機能の保守やバグ修正工数を除いたものが、新規機能開発工数と呼ばれるものだろう。これをもう少し精緻に整理すると、以下のようになる。
\begin{align}
&新規機能開発工数 = 開発工数 - 既存機能保守工数 - バグ修正工数 \\
&新規機能開発工数 = 開発工数 - (保守率 \times 既存機能数) - (バグ修正効率 \times バグ発生確率 \times 既存機能数)
\end{align}
既存の機能に対して、セキュリティアップデートや回帰テストを行う必要があるだろう。既存機能数に比例して工数が増えていくと仮定したとき、その工数への転嫁の割合を保守率としている。バグ発生確率は、既存機能に対し、どれだけバグが発生するかの割合を示し、バグ修正効率は1つのバグ数に対し、どれだけ工数がかかるかを表す。
これらを実際の数式に落とすと以下のようになる。
\begin{align}
&W^f_i = W_d - p_o N^f_i - p_d p_b N^f_i \\
&N^f_{i+1} = N^f_i + p_f W^f_i
\end{align}
\begin{align}
&W^f_i: i時点での新規機能開発工数[人月] \\
&N^f_i: i時点での機能数[個] \\
&W_d: 開発工数[人月] \\
&p_f: 開発工数から機能数への変換効率[個/人月] \\
&p_o: 既存機能に対する保守率[工数/個] \\
&p_d: バグ修正効率(デバッグ効率)[工数/バグ] \\
&p_b: 既存機能に対するバグ発生確率 \\
\end{align}
ここから、$W^f_i$, $N^f_i$の一般項を求めると
\begin{align}
&W^f_i = W_{d} \left(- p_{b} p_{d} p_{f} - p_{f} p_{o} + 1\right)^{i} \\
&N^f_i = \frac{W_{d} \left(1 - \left(- p_{b} p_{d} p_{f} - p_{f} p_{o} + 1\right)^{i}\right)}{p_{b} p_{d} + p_{o}}
\end{align}
$W^f_i$に関してグラフの概形を描くと以下のようになる。
$N^f_i$に関してグラフの概形を描くと以下のようになる。
ここで、$i \rightarrow \infty$とすると、興味深い事実が分かる。
\begin{align}
&\lim_{i \to \infty} W^f_i = 0 \\
&\lim_{i \to \infty} N^f_i = \frac{W_{d}}{p_{b} p_{d} + p_{o}}
\end{align}
$W^f_i$は、$i$時点での新規機能開発工数を表していた。無限の時間が経過すると、新規機能開発工数は0になり新規開発は止まる。 また、$N^f_i$は$i$時点での機能数であった。無限の時間が経過すると、機能数は$\frac{W_{d}}{p_{b} p_{d} + p_{o}}$ 個で上限になり、それ以上に機能は増えない。そして、その上限は、新規機能の開発能力$p_f$ は関係ない。
開発を進め、機能を追加していくと、最終的に新規開発ができなくなる。にわかには信じがたいかもしれないが、数理モデル上はありうる。このような現象がなぜありうるか。というと、「開発工数が固定で、機能が増える」からである。「機能が増える」と、機能数に比例して、「バグの修正工数」や「回帰テストなどの運用工数」がかかる。しかし、「開発工数」は固定である。したがって、機能が増えたことによる「バグの修正工数」や「回帰テストなどの運用工数」で埋め尽くされ、新規機能開発ができなくなる。ということである。
質とスピード
この状況化で、さらに機能を追加開発するにはどうすればよいだろうか?数理モデルから得られる簡単な方法が何種類かある。まず $W_d$を増やすことである。いわゆる、チームメンバーを増やせば人月が増える。人を2倍にすれば、工数も2倍になると考える流派の人にとっては福音だろう。もう1つは $p_o$ を減らす。既存機能の保守運用コストを下げることである。とても簡単な方法をするのであれば、一度作った機能は完璧であると認識し、テストを一切せず、ライブラリのセキュリティアップデートもしなければ、保守運用コストは0になり、上限機能数は上昇する。同様に、$p_b$を0にするというアプローチもある。バグ発生確率が0.01でもあると、機能数の上限が来てしまう。では、テストしなければ、バグ発生確率は観測不能になり、見かけ上バグ発生確率を0にできるので、機能数は上昇するだろう。また、バグが見つかっても、直さない。というアプローチを採れば、$ p_d $を0にすることができる。そうすることによって、とてもうれしいことが理論上起こる。エンジニアの採用はとても大変で、お金がとてもかかる。しかし、$p_o$, $p_b$, $p_d$ を0にする方法は、なんとエンジニアの工数を固定したまま、機能数を無限大にまで増やすことができる。機能が多ければ多いほど収益が大きくなると考えている派閥の方にはとてもうれしいアプローチだろう。
長々と改善に対しバカみたいな手法を無駄に書き連ねたが、これが冗談ではなく現実だったりするのが笑えなかったりする。ここでは、健全なアプローチを考えてみる。2つのパラメータを導入する。
\begin{align}
&\alpha:テスト駆動により改善されたバグの割合 \\
&\beta:テスト駆動により割り増しされた工数の係数
\end{align}
組織にテストを書く文化を根付かせる戦略と戦術(2020秋バージョン)に以下のような表がある。
テスト駆動には、開発の工数が増える代わりにバグが削減の効果が観測されている。これを数式に織り込むと以下のようになる。
\begin{align}
&\tilde{W^f_i} = W_{d} \left(- \alpha \beta p_{b} p_{d} p_{f} - \beta p_{f} p_{o} + 1\right)^{i} \\
&\tilde{N^f_{i+1}} = \frac{W_{d} \left(1 - \left(- \alpha \beta p_{b} p_{d} p_{f} - \beta p_{f} p_{o} + 1\right)^{i}\right)}{\alpha p_{b}
p_{d} + p_{o}}
\end{align}
単純に$ p_b $ を $ \alpha p_b $、$ p_f $を$ \beta p_f $に置き換えただけである。
同様に極限を取ると、
\begin{align}
&\lim_{i \to \infty} \tilde{W^f_i} = 0 \\
&\lim_{i \to \infty} \tilde{N^f_i} = \frac{W_{d}}{\alpha p_{b} p_{d} + p_{o}}
\end{align}
当たり前だが、$ \tilde{N^f_i} $ の分母が、 $ \alpha p_{b} p_{d} + p_{o} $となり、$\alpha$の影響分だけ分母が小さくなり、機能数の上限が上昇する。
ここで、以下のようにパラメーターを振ってみる。
\begin{align}
&p_b= 0.01 \\
&p_f= 0.8 \\
&p_o= 0.001 \\
&p_d= 3 \\
&w_d= 10 \\
&\alpha = 0.7 \\
&\beta = 1/1.4
\end{align}
$\alpha$は「TDDを採用していない類似プロジェクトの欠陥密度を1としたときの欠陥密度」の最大値が0.61だったので大きめに0.7、$\beta$は「TDD採用により増加したコード実装時間」が最大35%増だったので、大きく見積もって40%増。その分、工数の機能への変換が遅くなると仮定して、1/1.4としている。それ以外の値は、それっぽいものを入れてるだけ。
オレンジのグラフがもともとの $ N^f_{i+1} $ で、青のグラフがテスト駆動による改善を加えた $ \tilde{N^f_{i+1}} $ である。
こう見ると、テスト駆動をした時間経過による機能数は、最初は、テスト駆動しない場合より機能数が少ないが、途中で機能数が逆転し、全体としては、テスト駆動を行った場合の機能数が大きくなる。
なにが言いたいかというと、これはマーチンファウラーのIs High Quality Software Worth the Cost?にある、「低品質なソフトウェアは時間経過により機能数の伸びが悪くなるが、高品質なソフトウェアは初期段階では機能数の伸びが悪いが、中長期で見れば機能数が増える」を再現できたのではないか?ということである。
感想
マーチンファウラーの「Is High Quality Software Worth the Cost?」の主張は、ある程度、プログラマーであれば納得のいくものだと思います。しかし、それは直感的なものであり、数理モデル的に説明してはいませんでした。たまたま休日に「なぜ開発チームは運用に追い回され、新規機能を開発できなくなるのか」ということを思っていました。それで、数式を立ててみたところ、変数も少ない解ける数式で、しかもそこそこ納得感のある結論になりました。そのあと、テスト駆動の話を含めて、プログラムの内部の質へのコスト増とバグの削減率の事例もあったので、そこも組み込むと、先ほどの議論のマーチンファウラーのグラフっぽいものが出力できたので、かなり納得感がありました。割とざっくりと考えていたので、論理も甘いと思いますが、とりあえずまとめておきました。
解析プログラム
from sympy import *
from sympy.series.sequences import RecursiveSeq
# https://jasst.jp/symposium/jasst20niigata/pdf/S1.pdf
i = symbols("i", integer=True,positive=True) #index
pf = symbols("p_f",positive=True) #人月当たりの機能開個数
wd = symbols("W_d",positive=True) #開発工数
po = symbols("p_o",positive=True) #既存機能運用負荷効率
pd = symbols("p_d",positive=True) #バグ修正効率
pb = symbols("p_b",positive=True) #機能あたりのバグ発生確率
Nff = Function("Nff")
def Nf(i):
if i.equals(S.Zero):
return S.Zero
return Nff(i-1) + pf * Wf(i-1)
def Wf(i):
return wd - po * Nff(i) - pb * pd * Nff(i)
init_printing()
gNf = rsolve(Nf(i)-Nff(i),Nff(i),[0]).expand().simplify()
gWf = Wf(i).subs(Nff(i),gNf).expand().simplify()
a = symbols("alpha",positive=True) #バグ密度係数
b = symbols("beta",positive=True) #開発速度鈍化率
gNf2 = gNf.subs({pf:pf*b,pb:pb*a})
gWf2 = gWf.subs({pf:pf*b,pb:pb*a})
d = {pb:0.01,pf:0.8,po:0.01,pd:3,wd:10,a:0.7,b:1/1.4}
print(str(gNf.subs(d)))
print(str(gNf2.subs(d)))