从故事点到概率分布:用贝叶斯模型重塑项目工期估算的决策质量

从故事点到概率分布:用贝叶斯模型重塑项目工期估算的决策质量

一、为什么三点估算法不够用:传统PERT方法在软件项目中的系统性缺陷

"这个功能大概要多久?"——项目经理和工程师之间永恒的话题。PERT三点估算法(乐观/最可能/悲观)给出了一个优雅的数学框架:期望工期 = (O + 4M + P) / 6,标准差 = (P - O) / 6。但它在软件项目中面临三个无法回避的问题。

第一个问题是输入数据的主观偏移。不同工程师对"乐观"的定义差异极大——有人理解为"一切顺利",有人理解为"没有任何意外"。在同一个团队中,资深工程师的"悲观估计"可能比新人的"乐观估计"还短。三点估算法假设这三个数字是独立无偏的,实际中它们受同一批人的经验和偏见影响。

第二个问题是忽略了历史数据的可用性。一个运行了一年以上的Scrum团队,积累了大量Sprint数据——每个Story的实际完成时间、团队的Velocity趋势、各类型任务的完成率偏差。传统PERT方法完全抛弃了这些历史信息,每个估算都是从零开始的人肉猜测。

第三个问题是输出形式本身:单点估计。即使PERT给出了一个标准差,管理层最终还是会追问"所以到底是3周还是4周"。概率化的输出(P50/P80/P95)不仅提供了更丰富的信息,更重要的是改变了决策对话的结构——从"给出一个数字"到"接受一个区间"。

flowchart TB subgraph 传统估算流程[传统PERT估算] A1[产品经理分解Story] --> A2[工程师给出O/M/P三点] A2 --> A3[计算期望工期和标准差] A3 --> A4[输出: 单点估计 + 方差] A4 --> A5[管理层追问: 到底几周?] A5 --> A6[回到A2, 调整M值] end subgraph 概率化估算流程[贝叶斯概率估算] B1[产品经理分解Story] --> B2[工程师给出O/M/P三点] B2 --> B3[从历史Sprint提取先验分布] B3 --> B4[贝叶斯更新: 先验×观测→后验] B4 --> B5[蒙特卡洛模拟10000次] B5 --> B6[输出: P50/P80/P95三个工期] B6 --> B7[管理层对话: 80%概率在4周内完成] end B3 --> C1[历史数据源] C1 --> C2[团队Velocity趋势] C1 --> C3[按Story类别的完成率分布] C1 --> C4[各Sprint的计划/实际偏差]

二、贝叶斯Velocity模型的工程实现:如何用历史Sprint数据构建先验分布

贝叶斯方法的核心思想是:将历史Sprint数据作为"先验知识",用当前项目的Story估算作为"新观测",通过贝叶斯公式更新概率分布得到"后验工期分布"。这比从零开始的PERT估算在数学上更稳健——因为你并不是完全依赖当前Sprint的猜测。

# velocity_model.py — 贝叶斯Velocity预测引擎 import numpy as np import pymc as pm import arviz as az from dataclasses import dataclass from typing import List, Optional @dataclass class SprintRecord: sprint_id: int planned_points: float # 计划故事点 completed_points: float # 实际完成故事点 capacity_pct: float # 团队产能利用率 (0.5-1.0) frontend_ratio: float # 前端任务占比 backend_ratio: float # 后端任务占比 ai_ml_ratio: float # AI/ML任务占比 class BayesianVelocityPredictor: """基于贝叶斯层次模型的Velocity预测器""" def __init__(self, history: List[SprintRecord]): self.history = history self.trace = None self._extract_features() def _extract_features(self): """从历史数据提取特征矩阵""" self.n_sprints = len(self.history) self.planned = np.array([s.planned_points for s in self.history]) self.completed = np.array([s.completed_points for s in self.history]) self.capacity = np.array([s.capacity_pct for s in self.history]) # 任务类型特征:前端/后端/AI的占比向量 self.task_mix = np.array([ [s.frontend_ratio, s.backend_ratio, s.ai_ml_ratio] for s in self.history ]) def fit(self, draws: int = 4000, tune: int = 2000): """MCMC采样拟合贝叶斯模型""" with pm.Model() as model: # 全局参数:团队基础Velocity(先验) base_velocity = pm.HalfNormal('base_velocity', sigma=20) # 任务类型的影响系数 beta_frontend = pm.Normal('beta_frontend', mu=0, sigma=5) beta_backend = pm.Normal('beta_backend', mu=0, sigma=5) beta_ai_ml = pm.Normal('beta_ai_ml', mu=0, sigma=8) # 产能利用率系数 beta_capacity = pm.Normal('beta_capacity', mu=0, sigma=3) # 期望完成点数(线性组合 + 产能调整) mu = pm.Deterministic('mu', base_velocity + beta_frontend * self.task_mix[:, 0] + beta_backend * self.task_mix[:, 1] + beta_ai_ml * self.task_mix[:, 2] + beta_capacity * (self.capacity - 0.8) ) # 离散参数(负二项的散布度) dispersion = pm.HalfNormal('dispersion', sigma=5) # 似然:实际完成点数服从负二项分布 pm.NegativeBinomial('completed', mu=pm.math.clip(mu, 0, None), alpha=dispersion, observed=self.completed) self.trace = pm.sample( draws=draws, tune=tune, target_accept=0.95, random_seed=42 ) return self def predict_sprints_needed(self, total_points: float, task_mix: List[float], capacity: float = 0.8, sprint_length_days: int = 10) -> dict: """预测完成指定Story点数所需的Sprint数量分布""" if self.trace is None: raise RuntimeError("Model not fit — call fit() first") posterior = self.trace.posterior # 后验参数采样 base_vel = posterior['base_velocity'].values.flatten() beta_f = posterior['beta_frontend'].values.flatten() beta_b = posterior['beta_backend'].values.flatten() beta_ai = posterior['beta_ai_ml'].values.flatten() beta_cap = posterior['beta_capacity'].values.flatten() disp = posterior['dispersion'].values.flatten() # 对每组参数采样预测Velocity n_samples = len(base_vel) fixture_vel = ( base_vel + beta_f * task_mix[0] + beta_b * task_mix[1] + beta_ai * task_mix[2] + beta_cap * (capacity - 0.8) ) fixture_vel = np.clip(fixture_vel, 0, None) # 从负二项分布采样实际Velocity vel_samples = np.random.negative_binomial( n=1 / (disp + 1e-6), p=1 / (1 + np.abs(fixture_vel) * disp + 1e-6) ) vel_samples = np.clip(vel_samples, 0.1, None) # 计算所需Sprint数 sprints_needed = total_points / vel_samples return { 'p50_sprints': round(np.percentile(sprints_needed, 50), 1), 'p80_sprints': round(np.percentile(sprints_needed, 80), 1), 'p95_sprints': round(np.percentile(sprints_needed, 95), 1), 'p50_calendar_weeks': round( np.percentile(sprints_needed, 50) * sprint_length_days / 5, 1), 'p80_calendar_weeks': round( np.percentile(sprints_needed, 80) * sprint_length_days / 5, 1), 'p95_calendar_weeks': round( np.percentile(sprints_needed, 95) * sprint_length_days / 5, 1), 'mean_velocity': round(float(np.mean(vel_samples)), 1), 'velocity_std': round(float(np.std(vel_samples)), 1), }

贝叶斯方法的核心优势在于不确定性量化。PERT给出一个"期望工期+标准差"的数字对,但标准差无法直观转化为"80%置信度下的工期是多少"。贝叶斯后验分布天然给出了分位数——P80意味着80%的概率在这么多Sprint内完成——这个数字可以直接作为合同条款或向管理层汇报的"合理估计"。

三、蒙特卡洛模拟的互补价值:当不同任务类型的耗时分布差异巨大时

贝叶斯Velocity模型在团队层面预测总体推进速度,但实际项目中不同Story的单点耗时差异可能巨大。前端页面Story的耗时分布与AI模型训练Story的分布完全不同——你不能假设所有9个Story都服从同一个分布。

蒙特卡洛模拟通过在Story级别建模、聚合到整个项目,解决了这个粒度不匹配的问题:

# monte_carlo.py — Story级蒙特卡洛工期模拟 import scipy.stats as stats from dataclasses import dataclass @dataclass class StoryEstimate: story_id: str category: str # 'frontend' | 'backend' | 'ai_ml' story_points: int # 三点估算(小时/点) optimistic_hpp: float # 乐观 小时/点 most_likely_hpp: float pessimistic_hpp: float def monte_carlo_project(estimates: List[StoryEstimate], team_hours_per_sprint: float, n_simulations: int = 10000) -> dict: """对项目中的所有Story进行蒙特卡洛模拟""" # 为每个Story构建PERT分布参数 story_dists = [] for est in estimates: # PERT分布的alpha/beta参数 mu = (est.optimistic_hpp + 4 * est.most_likely_hpp + est.pessimistic_hpp) / 6 sigma = (est.pessimistic_hpp - est.optimistic_hpp) / 6 # 使用Beta-PERT分布的近似(裁剪的对数正态) if est.category == 'frontend': base_scale = 3.5 elif est.category == 'backend': base_scale = 4.5 else: # ai_ml base_scale = 7.0 # 用对数正态分布建模耗时/点 dist = stats.lognorm( s=sigma / mu, # 变异系数作为shape参数 scale=mu * base_scale / 3.5 # 按类别基准校准 ) story_dists.append({ 'id': est.story_id, 'points': est.story_points, 'dist': dist, }) # 运行模拟 total_hours = np.zeros(n_simulations) for sim in range(n_simulations): sim_total = 0.0 for sd in story_dists: # 每个Story的耗时 = 每点耗时分布 × 故事点数 hours_per_point = sd['dist'].rvs() sim_total += hours_per_point * sd['points'] total_hours[sim] = sim_total sprints_needed = total_hours / team_hours_per_sprint return { 'p50_sprints': round(np.percentile(sprints_needed, 50), 1), 'p80_sprints': round(np.percentile(sprints_needed, 80), 1), 'p95_sprints': round(np.percentile(sprints_needed, 95), 1), 'completion_prob_by_sprint': { i: round(float(np.mean(sprints_needed <= i)) * 100, 1) for i in range(1, 21) }, }
graph LR subgraph 前端Story[前端类型: lognormal σ=0.3, μ=4h/pt] F1[登录页重构 3pt] --> FDist F2[列表页无限滚动 5pt] --> FDist end subgraph 后端Story[后端类型: lognormal σ=0.4, μ=5h/pt] B1[API限流中间件 8pt] --> BDist B2[数据库迁移脚本 3pt] --> BDist end subgraph AI_ML_Story[AI类型: lognormal σ=0.6, μ=8h/pt] A1[模型微调Pipeline 13pt] --> ADist A2[RLHF数据清洗 8pt] --> ADist end FDist --> Agg[10000次模拟聚合] BDist --> Agg ADist --> Agg Agg --> Result[P50: 4.3 Sprint<br/>P80: 5.8 Sprint<br/>P95: 7.2 Sprint]

四、估算偏差的反馈闭环:校准系数是持续改进的核心机制

没有任何估算模型是完美的。贝叶斯模型和蒙特卡洛模拟都依赖历史数据来校准分布参数——如果不对偏差进行追踪和修正,模型会逐渐偏离实际。

反馈闭环的设计思路极其简单:每个Sprint结束后,记录预测Sprint数与实际Sprint数的偏差,计算一个滚动校准系数,应用在下一轮预测中:

class EstimationCalibrator: """估算偏差追踪与校准器""" def __init__(self, window_size: int = 8): self.window_size = window_size self.records = [] def record(self, sprint_id: int, predicted_pts: float, actual_pts: float): """记录一次Sprint的预测与实际偏差""" error_pct = (actual_pts - predicted_pts) / (predicted_pts + 1e-6) self.records.append({ 'sprint_id': sprint_id, 'predicted': predicted_pts, 'actual': actual_pts, 'error_pct': round(float(error_pct) * 100, 1), }) def calibration_factor(self) -> float: """基于最近N个Sprint的滚动校准系数""" if len(self.records) < 3: return 1.0 recent = self.records[-self.window_size:] # 取偏差的中位数(比均值更鲁棒) median_error = np.median([r['error_pct'] for r in recent]) # 如果系统性低估(正偏差),校准系数 > 1 # 如果系统性高估(负偏差),校准系数 < 1 calibration = 1.0 + median_error / 100.0 return max(0.7, min(1.3, calibration))

对于连续3个Sprint的校准系数持续大于1.1(系统性低估)的情况,应该触发一次团队估算复盘会议,而不是简单地乘以校准系数。校准系数解决的是短期波动,复盘解决的是结构性问题(如新同事的"理想化估算倾向"或某种Story类型在梳理阶段遗漏了隐藏工作量)。

五、总结

  1. 传统PERT在软件项目中的三大缺陷:输入数据受经验偏见影响、忽略了历史Sprint积累的先验信息、输出单点估计难以辅助概率化决策。贝叶斯方法通过"先验+观测→后验"的数学框架系统性地解决了这三个问题。

  2. 贝叶斯Velocity模型使用历史Sprint数据构建先验:用MCMC采样估计团队基础速率和任务类型影响系数的后验分布,对新的Story规划给出带置信区间的Sprint数预测。P50/P80/P95三个分位数替代了传统的"期望工期"。

  3. 蒙特卡洛模拟在粒度上更灵活:当不同Story类别(前端、后端、AI)的耗时分布差异巨大时,Story级模拟比Sprint级Velocity模型更能捕获异构性。两者可以互补:Velocity模型做Sprint级顶层预测,蒙特卡洛做项目内Story级的细化验证。

  4. 反馈闭环是模型的生命线:每个Sprint结束后记录预测vs实际偏差,计算滚动校准系数,当系统性偏差持续存在时触发复盘。校准系数解决短期波动,复盘解决结构性问题。

  5. 概率化输出的核心价值是改变对话方式:不是"这个项目大概4周",而是"根据团队历史数据和当前Story组合,P80置信度下完成需要5.8个Sprint"。这不仅仅是信息量的增加,而是从"逼出一个数字"变成了"共同理解不确定性"。