DEV Community

Yunus Emre Vurgun
Yunus Emre Vurgun

Posted on

Monte Carlo bid estimating: price a construction bid at P80, not a single number

Most bids are built the same way: price each line item at its most likely number, add them up, put overhead and profit on top. The problem is that adding up "most likely" numbers doesn't give you the most likely total. It gives you a number the job will usually go over.

This article shows why, and walks through a small Monte Carlo estimate: three-point ranges, a choice of distribution, correlation between labor-driven trades, and a confidence level turned into contingency. The figures are made up. Every result comes from the Python script below, run with a fixed seed so you'll get the same numbers.

Why a single-point estimate is biased low

Think about one line item, say framing and drywall. Your most likely figure is $95k. How much better could it go? Maybe the crew is quick and the layout is simple, and it comes in at $86k. How much worse? Out-of-square walls, a slow GC, a late change to the ceiling heights, and it's $128k. You can save $9k or lose $33k.

Most construction costs look like that: a floor on how cheaply the work can be done, no real ceiling on how badly it can go. When the downside is longer than the upside, the average outcome sits above the most likely one, and across a dozen skewed items the gaps add up. On top of that, estimators anchor on the version of the job that goes to plan, because that's the version you can price. So the single number carries two biases: skew, and optimism.

Three-point ranges per line item

The fix starts at the line-item level. For each item, write three numbers instead of one:

  • Min (optimistic): what it costs if things go well. Not a miracle, just a good run.
  • Most likely: the number you'd have written anyway.
  • Max (pessimistic): what it costs if the realistic problems happen. Not the end of the world, but the bad job you've seen before.

Give each range a sentence of reasoning ("max assumes two weeks of winter conditions"). Leave firm amounts, like a signed sub quote, as single numbers. Put the uncertainty where it really is: labor productivity, unmeasured quantities, unknown conditions, and duration-driven costs like general conditions.

Here's the example job, a small commercial build-out, in thousands of dollars:

Line item Min Most likely Max Labor-driven?
Demo & sitework 42 48 68 no
Concrete 118 130 165 no
Structural steel 205 215 250 no
Framing & drywall 86 95 128 yes
MEP rough-in & trim 240 262 330 yes
Finishes 74 82 110 yes
General conditions 58 64 85 yes
Total 896

The single-point estimate is $896k. Every range is skewed to the right, as real ones usually are.

Triangular vs. PERT: what shape goes between the three numbers

Three numbers don't define a distribution by themselves. You have to choose a shape. The two common choices are:

Triangular. A straight line up from min to most likely and down to max. Its mean is (min + most likely + max) / 3, so the extremes carry as much weight as the most likely value.

Beta-PERT. A smooth curve over the same range that bunches more of its weight near the most likely value. With the standard setting (λ = 4) its mean is (min + 4 × most likely + max) / 6, the classic PERT weighted average.

The choice matters more than people expect. In the example, the sum of the PERT means is $923.8k and the sum of the triangular means is $951.7k. That's $28k of difference from the shape alone, with the same inputs.

Neither is "the conservative one"; they describe different beliefs. Use PERT when your most likely number is well grounded, such as tracked production rates. Use triangular when the most likely figure is itself a guess. Pick one per estimate, write down which, and don't switch to whichever gives the answer you hoped for.

Correlation: the step most spreadsheets skip

A basic Monte Carlo draws each line item independently. In one run framing comes in high and MEP comes in low, so they partly cancel out. Across thousands of runs that cancelling makes the total look tighter than it is.

Real jobs don't work like that. If productivity on this site is poor (access, weather, the superintendent, a tight labor market), it's poor for the framers, the electricians and the finishers at the same time. Slow work stretches the schedule, and general conditions grow with it. Treating those items as independent promises a diversification the job site won't deliver.

In the example, the four labor-driven items get a correlation of 0.6 with each other. The script uses a simple rank-reordering method (in the style of Iman-Conover): it draws each item's costs from its own PERT distribution, then reorders them to follow the ranks of correlated normal draws. The shape of each line item stays exactly the same, and only the way the items pair up within a run changes. The correlation it actually achieved was 0.59.

Here are the results from 100,000 runs:

Model Mean P50 P80 P90 Runs above $896k
Triangular, independent 951.8 950.5 975.5 988.5 98.6%
PERT, independent 923.9 922.9 943.3 954.2 89.1%
PERT, labor correlated 0.6 923.9 921.5 950.9 966.6 79.7%

Three things stand out:

  1. The single-point estimate is unlikely to hold. Even in the most favourable model, 79.7% of runs cost more than $896k.
  2. Correlation doesn't change the mean, but it does change the tail. The mean stays at $923.9k. The spread from P50 to P90 grows from $31.3k to $45.1k, about 44% wider, and P90 moves up by $12.4k. That's the risk an independent model leaves out.
  3. The share of runs over $896k drops with correlation, from 89.1% to 79.7%. Not good news: a wider distribution has more good runs and more bad ones.

Histogram of 100,000 simulated totals with the single-point estimate, P50 and P80 marked

Where does 0.6 come from? Ideally from your job-cost history: how often did the labor trades overrun together? Without that data, run 0.3, 0.6 and 0.8 and watch the P80. If the bid only works at zero correlation, it doesn't work.

Choosing a confidence level and turning it into contingency

A percentile is a statement about your model. P80 is the cost that 80% of the simulated runs come in at or below.

  • P50 is a coin flip. Bidding the P50 cost plus your normal markup means you expect to eat into that markup about half the time.
  • P80 is a common commercial reference: you cover the realistic bad outcomes, but not the worst ones.
  • P90 suits work where an overrun is expensive to absorb: a small company with one large job, liquidated damages, or a fixed-price contract with no change mechanism.

The gap between your chosen percentile and the base estimate is your contingency. Show it as its own line, not hidden inside the unit prices:

Confidence Cost Contingency over $896k % of base
P50 921.5 25.5 2.8%
P80 950.9 54.9 6.1%
P90 966.6 70.6 7.9%

Then apply overhead and profit to the risk-adjusted cost, keeping markup and contingency separate. Contingency is there to pay for the risk you modeled. Markup is what you're paid for doing the work. With a 10% markup:

  • Bid at single point: 896 × 1.10 = $985.6k. In 3.48% of runs the job loses money outright, and in 28.85% of runs you keep less than half your planned markup.
  • Bid at P80: 950.9 × 1.10 = $1,045.9k. None of the 100,000 runs lost money, and 1.45% kept less than half the markup.

That second bid is $60k higher and may not win. The method doesn't decide for you; it shows the trade. If the market won't bear the P80 number you can still bid lower, knowing that in roughly three runs out of ten you keep less than half your markup. That's a business decision, not an estimating error you discover at closeout.

One more thing to check: the sum of the line items' own P80s is $972.4k, while the P80 of the total is $950.9k. Padding each line to its own P80 and adding them up overstates the total, because not every item goes bad on the same job. Simulate the total and set contingency once, at the project level.

The script

This is the whole example. It needs numpy, plus matplotlib if you want the histogram. Change the items table to your own line items and rerun.

"""Monte Carlo bid estimate: single number vs. P50/P80/P90.

Worked example for the article "Monte Carlo bid estimating".
Requires numpy (matplotlib optional, for the histogram).
All figures are illustrative, in thousands of dollars ($k).
"""
import numpy as np

SEED = 20261012
N = 100_000
rng = np.random.default_rng(SEED)

# line item: (min, most likely, max) direct cost in $k, labor-driven?
items = {
    "Demo & sitework":       (42, 48, 68, False),
    "Concrete":              (118, 130, 165, False),
    "Structural steel":      (205, 215, 250, False),
    "Framing & drywall":     (86, 95, 128, True),
    "MEP rough-in & trim":   (240, 262, 330, True),
    "Finishes":              (74, 82, 110, True),
    "General conditions":    (58, 64, 85, True),
}
names = list(items)
lo, ml, hi = (np.array([items[k][i] for k in names], float) for i in range(3))
labor = np.array([items[k][3] for k in names])
k = len(names)

def pert(a, m, b, size, lam=4.0):
    """Beta-PERT sample: mean = (a + 4m + b) / 6."""
    alpha = 1 + lam * (m - a) / (b - a)
    beta = 1 + lam * (b - m) / (b - a)
    return a + rng.beta(alpha, beta, size) * (b - a)

def tri(a, m, b, size):
    return rng.triangular(a, m, b, size)

def correlate(samples, corr):
    """Reorder each column to follow the ranks of correlated normals
    (Iman-Conover style). Marginals are unchanged; only pairing changes."""
    z = rng.multivariate_normal(np.zeros(k), corr, size=samples.shape[0])
    out = np.empty_like(samples)
    for j in range(k):
        ranks = z[:, j].argsort().argsort()
        out[:, j] = np.sort(samples[:, j])[ranks]
    return out

def summary(label, total):
    p50, p80, p90 = np.percentile(total, [50, 80, 90])
    p_over = (total > ml.sum()).mean()
    print(f"{label:<28} mean {total.mean():7.1f}  P50 {p50:7.1f}  "
          f"P80 {p80:7.1f}  P90 {p90:7.1f}  P(cost > single-point) {p_over:5.1%}")
    return p50, p80, p90

single_point = ml.sum()
pert_mean = ((lo + 4 * ml + hi) / 6).sum()
print(f"Single-point estimate (sum of most likely): {single_point:.1f}")
print(f"Sum of PERT means (O + 4M + P)/6:           {pert_mean:.1f}")
print(f"Sum of triangular means (O + M + P)/3:      {((lo + ml + hi) / 3).sum():.1f}")
print()

tri_ind = tri(lo, ml, hi, (N, k))
pert_ind = np.column_stack([pert(lo[j], ml[j], hi[j], N) for j in range(k)])

# labor-driven items share one productivity driver: rho = 0.6 among them
rho = 0.6
corr = np.eye(k)
for i in range(k):
    for j in range(k):
        if i != j and labor[i] and labor[j]:
            corr[i, j] = rho
pert_cor = correlate(pert_ind, corr)

summary("Triangular, independent", tri_ind.sum(1))
summary("PERT, independent", pert_ind.sum(1))
p50, p80, p90 = summary("PERT, labor correlated 0.6", pert_cor.sum(1))

lab_idx = np.where(labor)[0]
achieved = np.corrcoef(pert_cor[:, lab_idx], rowvar=False)
print(f"\nAchieved correlation between labor items: "
      f"{achieved[np.triu_indices(len(lab_idx), 1)].mean():.2f} (target {rho})")

item_p80 = np.percentile(pert_cor, 80, axis=0).sum()
print(f"Sum of the line items' own P80s:            {item_p80:.1f}  (vs P80 of the total {p80:.1f})")

print("\nContingency above the single-point estimate (correlated PERT):")
for name, p in (("P50", p50), ("P80", p80), ("P90", p90)):
    c = p - single_point
    print(f"  {name}: {c:6.1f}  ({c / single_point:.1%} of base)")

markup = 0.10  # overhead & profit on cost
total = pert_cor.sum(1)
for label, cost_basis in (("single point", single_point), ("P80", p80)):
    bid = cost_basis * (1 + markup)
    print(f"Bid = {label} cost x {1 + markup:.2f} = {bid:7.1f}   "
          f"P(cost > bid, a loss) {(total > bid).mean():6.2%}   "
          f"P(profit < half the markup) {(bid - total < bid - cost_basis * (1 + markup / 2)).mean():6.2%}")

try:
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    fig, ax = plt.subplots(figsize=(9, 4.8), dpi=120)
    ax.hist(total, bins=120, color="#9db7d5", edgecolor="none")
    for x, lab, col in ((single_point, "Single point", "#555555"),
                        (p50, "P50", "#1f4e79"), (p80, "P80", "#c0392b")):
        ax.axvline(x, color=col, lw=2, ls="--" if lab == "Single point" else "-")
        left = lab == "Single point"
        ax.text(x, ax.get_ylim()[1] * 0.97,
                f"{lab} \n{x:,.0f} " if left else f" {lab}\n {x:,.0f}",
                color=col, va="top", ha="right" if left else "left", fontsize=9)
    ax.set_xlabel("Total direct cost ($k)")
    ax.set_ylabel("Simulated runs")
    ax.set_title(f"{N:,} runs, Beta-PERT line items, labor items correlated {rho}")
    fig.tight_layout()
    fig.savefig("bidvariance_mc_hist.png")
    print("\nSaved bidvariance_mc_hist.png")
except ImportError:
    print("\nmatplotlib not installed; skipped histogram")
Enter fullscreen mode Exit fullscreen mode

Output:

Single-point estimate (sum of most likely): 896.0
Sum of PERT means (O + 4M + P)/6:           923.8
Sum of triangular means (O + M + P)/3:      951.7

Triangular, independent      mean   951.8  P50   950.5  P80   975.5  P90   988.5  P(cost > single-point) 98.6%
PERT, independent            mean   923.9  P50   922.9  P80   943.3  P90   954.2  P(cost > single-point) 89.1%
PERT, labor correlated 0.6   mean   923.9  P50   921.5  P80   950.9  P90   966.6  P(cost > single-point) 79.7%

Achieved correlation between labor items: 0.59 (target 0.6)
Sum of the line items' own P80s:            972.4  (vs P80 of the total 950.9)

Contingency above the single-point estimate (correlated PERT):
  P50:   25.5  (2.8% of base)
  P80:   54.9  (6.1% of base)
  P90:   70.6  (7.9% of base)
Bid = single point cost x 1.10 =   985.6   P(cost > bid, a loss)  3.48%   P(profit < half the markup) 28.85%
Bid = P80 cost x 1.10 =  1045.9   P(cost > bid, a loss)  0.00%   P(profit < half the markup)  1.45%

Saved bidvariance_mc_hist.png
Enter fullscreen mode Exit fullscreen mode

Common mistakes

  • Ranges that are too narrow. If your max is 5% above most likely, ask what would have to go wrong to hit it, and whether it has before. A tight range is the single-point problem with extra steps.
  • Double-counting risk. Use ranges for variation in normal work and a short risk register (probability × impact) for discrete events like a permit delay. If the concrete max already allows for rock, don't add a rock allowance too.
  • Ignoring correlation. Covered above. It mainly understates the tail, which is the part that hurts.
  • Summing line-item percentiles. P80s don't add. Simulate the total.
  • Splitting items too finely. Fifty small independent items cancel each other out and shrink the spread. If you break the estimate down further, correlate the pieces that share a driver.
  • Comparing runs without a fixed seed. Otherwise part of the difference is sampling noise.
  • Trusting the decimals. More iterations reduce noise; they don't make a guessed max more accurate.
  • Mixing up markup and margin. A 10% markup on cost is a 9.1% margin on price. Know which one your pricing rule is using.

How BidVariance does this

Disclosure again: this is the tool I built, a browser-based Monte Carlo tool for fixed-price bids. Here's what it does, per bidvariance.com, its methodology page and its docs:

  • Three-point inputs and Beta-PERT. You enter optimistic, likely and pessimistic values for each task. Each is sampled from Beta-PERT with λ = 4, as in the script above.
  • Cost model. Each run's cost is the task's fixed cost plus duration × daily burn rate, plus risk costs, project fixed costs and overhead. It's built around tasks, not dollar line items, so a labor package becomes crew-days × daily crew cost. Tasks take Finish-to-Start dependencies; risk events take a probability plus cost and delay ranges.
  • Runs and reproducibility. 5k, 25k or 100k runs, with a seeded generator so a number shown in a meeting can be reproduced exactly.
  • Outputs. P50/P80/P90 cost and finish dates, probability of loss and of hitting the target margin, and the contingency each confidence level needs over a reference you choose (the single-number plan at likely durations, the expected cost, or the budget your margin allows). The minimum quote is the cost percentile ÷ (1 − target margin). It also has a tornado/sensitivity view and a bid / no-bid label at thresholds you set.
  • Local and one-time. Simulations run in your browser; projects stay on your device. Licenses are one-time, via Gumroad: Pro, $129 for one user, Business, $249 for up to five internal users with scenario comparison, and Consultant, $399 for client advisory work.

And the honest limitation, since it's the topic of half this article: the methodology page says task durations are sampled independently, and there's no general correlation model. The closest thing is the What-if scope-creep test, which applies one uncertain growth factor, drawn once per run, to the tasks you select. That's a reasonable way to stress a shared productivity hit across your labor packages, but it isn't a correlation setting. If correlation drives your risk, check the result with a script like the one above. It also isn't a takeoff or cost-database tool. You bring the estimate, and it handles the probability side.

If you just want to check one range first, the free calculators include a PERT estimate calculator and a project contingency calculator, with no license or signup.

Whichever tool you use, the habit is what matters: three numbers instead of one, a decision about which items move together, and a confidence level you chose rather than one the sum of most-likely values chose for you.


Published on behalf of Yunus Emre Vurgun, the developer of BidVariance, by his assistant. The project figures above are fictional and illustrative.

Top comments (0)