以下情境为教学假设,人物与数字不代表经核实的真实案例。
预算偏差回答三个问题:差了多少、差得值不值得管、这个差会不会继续。第一个是算术,第二个是阈值,第三个是判断。很多预算表只做了第一个,于是每个月都在解释「为什么差了 0.8%」,却没有回答「下一季要不要改」。这一节把三件事拆开,并用三个案例走一遍季度校准。
偏差的方向不能只看正负。收入高于预算是好事,成本高于预算是坏事;同一个正数,在两张表里含义相反。先约定方向,再谈幅度,否则「偏差 +$2,550」会被误读成好消息。
偏差率与两道阈值
偏差的绝对额是实际减预算,偏差率是绝对额除以预算基数。收入与成本的方向约定如下:
| 方向 | 实际高于预算 | 实际低于预算 |
|---|---|---|
| 收入 | 有利 | 不利 |
| 成本 | 不利,超支 | 有利,节省 |
只有相对阈值不够。一个 2% 的偏差落在 $1,000,000 的收入上是 $20,000,落在 $10,000 的小科目上只有 $200;一个 20% 的偏差可能只值 $50,不值得开一次会。所以判定要同时看偏差率和绝对额,两条阈值取「或」:任意一条触发就升级。
| 级别 | 触发条件 | 默认动作 |
|---|---|---|
| ok | 偏差率低于 5% 且绝对额低于 $1,000 | 记录,不单独开会 |
| watch | 偏差率至少 5%,或年化绝对额至少 $1,000 | 解释原因,下一季复核 |
| act | 偏差率至少 10%,或绝对额至少 $2,500 | 改预算、改排期或改资源 |
5%、10%、$1,000、$2,500 都是教学参数,不是行业标准。它们的作用是让「什么值得管」有一个事先约定,而不是每次凭感觉。参数可以按公司规模调整,但要在年初写进预算说明,不能等偏差出现后再改。
季度偏差还要做年化。Q1 差 $468 看起来很小,如果同样的缺口每个季度都出现,全年就是 $1,872;年化把「一次性」和「会重复」区分开。年化不是预测,只是把当前速度外推,用来判断是否越过绝对额阈值。
LedgerLite:Q1 少签两家,年化后是 $1,872
LedgerLite 的 Q1 收入预算按当前运行率算是 3 × $20,020 = $60,060。下面假设 Q1 实际每月新签 8 家而不是预算的 10 家,其他桥接项——扩张 $310、收缩 $60、流失 $290、期初 MRR $19,280——都与事实表一致。这是一个单独的教学假设,不影响其他数字。
| 项 | 预算 | 实际 | 差额 | 偏差率 |
|---|---|---|---|---|
| 期初 MRR | $19,280 | $19,280 | $0 | 0.0% |
| 新签 | +$780(10 家 × $78) | +$624(8 家 × $78) | −$156 | −20.0% |
| 扩张 | +$310 | +$310 | $0 | 0.0% |
| 收缩 | −$60 | −$60 | $0 | 0.0% |
| 流失 | −$290 | −$290 | $0 | 0.0% |
| 期末 MRR | $20,020 | $19,864 | −$156 | −0.8% |
| Q1 收入(×3) | $60,060 | $59,592 | −$468 | −0.8% |
Q1 偏差率只有 0.78%,绝对额 $468,按季度看是 ok:既不到 5%,也不到 $1,000。但把同样的缺口年化,全年差额是 4 × $468 = $1,872,超过 $1,000 的 watch 线,仍低于 $2,500 的 act 线,所以判定是 watch。这个例子说明:季度 ok 不等于全年 ok,年化是第二道筛子。
把 Q1 收入偏差拆开,只有新签这一条线动了。按价格、数量、结构分解:价格仍是 $78,数量从 30 个「客户-月」降到 24 个,价格效应为 0,数量效应是 −2 家 × $78 × 3 个月 = −$468,结构效应为 0。偏差不是降价换来的,而是新签节奏慢了两家;校准应该问渠道和销售周期,而不是改价格。
滚动预测把 Q1 的学习带进后三个季度:
全年预测 = Q1 实际 + 剩余三季度预算 + 修正
= $59,592 + $180,180 − $1,404
= $238,368
与预算差额 = $238,368 − $240,240 = −$1,872(−0.78%)
修正 −$1,404 是「新签每月少两家」在剩余三个季度的累计影响:2 家 × $78 × 3 个月 × 3 个季度 = $1,404。加上 Q1 的 $468,正好是全年 $1,872。如果判断 Q1 只是销售周期推迟、Q2 会补回来,修正可以取 0,全年预测就是 $239,772;两种预测的差别不是算术,而是对「暂时还是结构」的判断。
Tidecast:开发延后一个月,机会成本多 $6,000
Tidecast 的项目预算按 14 个月开发、每月 $6,000 机会成本计算,开发成本 $84,000。如果实际用了 15 个月,开发机会成本变成 $90,000,差额 +$6,000,偏差率 +7.14%。
| 项 | 预算 | 实际 | 差额 | 偏差率 | 判定 |
|---|---|---|---|---|---|
| 开发月数 | 14 | 15 | +1 | +7.1% | |
| 开发机会成本 | $84,000 | $90,000 | +$6,000 | +7.1% | act |
偏差率 7.14% 只在 watch 区间,但绝对额 $6,000 超过 $2,500 的 act 线,所以判定是 act。成本偏差的方向是不利:多花的一个月不会带来收入,只会推迟上线。项目成本从 $101,000 升到 $107,000,扣掉开发机会成本后的结余从 $37,171 降到 $31,171;按单份净得 $14.39 算,项目保本销量从约 7,018 份升到约 7,434 份。
这里要区分「现金」和「机会成本」。延后一个月的 $6,000 不一定是账上流出的现金,可能是创始人少领的工资或少接的外包;但它在项目预算里是真实成本,因为那一个月本来可以用于别的收入。用现金流口径看,延期可能只表现为「个人账户少了一笔」,用项目口径看,它是结余减少 $6,000。两个口径都要保留,不能用一个否定另一个。
开发延期还是领先指标。它发生在收入之前,等上线后看到销量不及预期再回头找原因,已经晚了。项目制预算的校准频率应该跟里程碑走,而不是跟自然季度走;里程碑延期一周,就值得问一次「范围是否膨胀」。
Nord Studio:工时少 30 小时,偏差率 9.1% 仍触发行动
Nord Studio 的咨询线按 110 小时/月编预算,Q1 预算 330 小时 × $85 = $28,050。如果实际可计费工时只有 300 小时,收入 $25,500,差额 −$2,550,偏差率 −9.09%。
| 项 | 预算 | 实际 | 差额 | 偏差率 | 判定 |
|---|---|---|---|---|---|
| 可计费工时 | 330 | 300 | −30 | −9.1% | |
| 咨询收入 | $28,050 | $25,500 | −$2,550 | −9.1% | act |
偏差率 9.09% 没有到 10% 的 act 线,但绝对额 $2,550 超过 $2,500,仍然触发 act。这正是「或」阈值的意义:如果只看百分比,会漏掉一个已经超过行动金额的缺口;如果只看金额,又会放过一个小科目上的巨大比例偏差。
30 小时缺口对 Nord 的打击不只是 $2,550 收入。咨询线的贡献率约 83.96%,少 $2,550 收入大约少 $2,141 贡献;全年如果每季都少 30 小时,就是 $8,560 贡献,接近 SDE $34,320 的四分之一。工时是咨询线的产能上限,缺口通常来自需求不足、排期空档或合伙人被产品线占用;校准要落到「补哪一段排期」或「降哪一项固定成本」,而不是只改收入数字。
产品线不应该用同一个偏差率判断。产品收入由 90 家 × $39 驱动,月流失 3.0%,客户数的变化比单月收入更早暴露问题;把两条线合并成一个总收入偏差,会互相掩盖。双轨预算的控制点见 5.1 自上而下与自下而上的预算 。
季度校准:把一次偏差分成暂时与结构
季度校准不是把预算改成实际,而是回答「下一季的剩余预算要不要改」。同样一个缺口,至少有三类原因:
| 偏差来源 | 暂时因素 | 结构因素 | 校准动作 |
|---|---|---|---|
| 新签节奏 | 销售周期推迟、假期 | 渠道失效、价格过高 | 只修下一季,或改全年 |
| 可计费工时 | 客户排期空档 | 需求下降、合伙人被占用 | 补排期,或降固定成本 |
| 开发延期 | 外包排期、生病 | 范围膨胀、技术债 | 一次性追回,或改预算 |
判断顺序是:先看偏差是否越过阈值,再看它是否会在剩余季度重复,最后看重复的原因是暂时还是结构。暂时因素只修正下一季,结构因素才改全年;把一次性波动写进全年预算,会让预算失去约束力。
滚动预测是校准的落点:
全年预测 = 年初至今实际 + 剩余期间原预算 + 修正
修正可以按季度滚动更新,但不必每月重算。月度关账负责现金和账实一致,季度校准负责经营判断;两者频率不同,混在一起会让团队追着噪声跑。月度关账的流程见 月度关账 。
校准还要留出情景。LedgerLite 的 Q1 缺口如果 Q2 补回,全年接近预算;如果补不回,全年少 $1,872;如果新签继续降到 6 家,全年缺口会更大。把「补回」「持平」「继续恶化」三种情景写出来,比给一个点预测更有用;情景模拟的方法见 8.2 情景模拟 。现金层面的短期排程见 8.1 13 周现金流表 。
把偏差写成代码
下面的 variance.ts 把偏差测量、阈值判定和滚动预测固化成三个函数:measureVariance 计算绝对额、偏差率和方向,classifyVariance 用「比率或金额」两道阈值给出 ok / watch / act,rollingForecast 把实际、剩余预算和修正合成全年预测。
// file: src/fin/variance.ts
// tier: L1
// verify: node --test test/fin/variance.test.ts
import { type Cents } from "../core/money.ts";
import { type Bps, divide } from "../core/rate.ts";
import { type Result, fail, ok } from "../core/result.ts";
export type VarianceDirection = "revenue" | "cost";
export type Severity = "ok" | "watch" | "act";
export type VarianceLimits = {
watchBps: Bps;
actBps: Bps;
watchCents: Cents;
actCents: Cents;
};
export const DEFAULT_VARIANCE_LIMITS: VarianceLimits = {
watchBps: 500,
actBps: 1000,
watchCents: 100000,
actCents: 250000,
};
export type Variance = {
actual: Cents;
budget: Cents;
absolute: Cents;
rateBps: Bps;
favorable: boolean;
};
/**
* Measures actual against budget. For revenue a positive gap is favorable;
* for cost a positive gap is an overrun and therefore unfavorable. The rate is
* signed against the absolute budget, so -780 bps means 7.8% below budget.
*/
export function measureVariance(
actual: Cents,
budget: Cents,
direction: VarianceDirection,
): Result<Variance> {
if (!Number.isInteger(actual)) {
return fail("actual must be an integer number of cents");
}
if (!Number.isInteger(budget) || budget <= 0) {
return fail("budget must be a positive integer number of cents");
}
const absolute = actual - budget;
const favorable = direction === "revenue" ? absolute >= 0 : absolute <= 0;
return ok({
actual,
budget,
absolute,
rateBps: Math.round((absolute * 10000) / Math.abs(budget)),
favorable,
});
}
function checkLimits(limits: VarianceLimits): string | null {
const values = [
limits.watchBps,
limits.actBps,
limits.watchCents,
limits.actCents,
];
if (values.some((value) => !Number.isInteger(value) || value < 0)) {
return "variance limits must be non-negative integers";
}
if (limits.watchBps > limits.actBps) {
return "watch rate must not exceed act rate";
}
if (limits.watchCents > limits.actCents) {
return "watch amount must not exceed act amount";
}
return null;
}
/**
* Two independent triggers: a relative threshold and an absolute threshold.
* Either one can escalate. annualizeQuarters scales the absolute gap before
* the amount test, which is how a small quarterly miss becomes visible when it
* is expected to repeat.
*/
export function classifyVariance(
variance: Variance,
limits: VarianceLimits = DEFAULT_VARIANCE_LIMITS,
annualizeQuarters = 1,
): Result<Severity> {
const error = checkLimits(limits);
if (error) {
return fail(error);
}
if (!Number.isInteger(annualizeQuarters) || annualizeQuarters < 1) {
return fail("annualizeQuarters must be a positive integer");
}
const rate = Math.abs(variance.rateBps);
const amount = Math.abs(variance.absolute) * annualizeQuarters;
if (rate >= limits.actBps || amount >= limits.actCents) {
return ok("act");
}
if (rate >= limits.watchBps || amount >= limits.watchCents) {
return ok("watch");
}
return ok("ok");
}
export type RollingForecast = {
actualToDate: Cents;
remainingBudget: Cents;
correction: Cents;
fullYear: Cents;
changeVsBudget: Cents;
changeBps: Bps;
};
/**
* Rolling forecast after a quarter closes:
* full year = actual to date + remaining budget + correction
* The correction is where the quarter's learning enters; it can be negative.
*/
export function rollingForecast(
actualToDate: Cents,
remainingBudget: Cents,
correction: Cents,
annualBudget: Cents,
): Result<RollingForecast> {
if (!Number.isInteger(actualToDate) || actualToDate < 0) {
return fail("actual to date must be a non-negative integer");
}
if (!Number.isInteger(remainingBudget) || remainingBudget < 0) {
return fail("remaining budget must be a non-negative integer");
}
if (!Number.isInteger(correction)) {
return fail("correction must be an integer number of cents");
}
if (!Number.isInteger(annualBudget) || annualBudget <= 0) {
return fail("annual budget must be a positive integer");
}
const fullYear = actualToDate + remainingBudget + correction;
const changeVsBudget = fullYear - annualBudget;
return ok({
actualToDate,
remainingBudget,
correction,
fullYear,
changeVsBudget,
changeBps: divide(changeVsBudget, annualBudget),
});
}
代码有四个细节。第一,偏差率带符号:收入 −780 bps 表示低于预算 7.8%,成本 +714 bps 表示超支 7.14%,符号和方向分开表达。第二,classifyVariance 的比率与金额是「或」关系,任一越线就升级;如果改成「且」,Nord 的 $2,550 缺口会因为 9.09% 不到 10% 而被漏掉。第三,annualizeQuarters 只放大绝对额,不放大比率;年化的是「如果重复会是多少钱」,不是「比率会变成多少」。第四,rollingForecast 的修正允许为负,全年预测可以低于预算;它不替你做「暂时还是结构」的判断,只负责把判断结果算成数字。
把三个案例代入:LedgerLite 的 Q1 偏差 −$468、−78 bps,年化后判 watch;Tidecast 的开发超支 +$6,000、+714 bps,判 act;Nord 的工时缺口 −$2,550、−909 bps,判 act。三者都不是靠单看百分比得出的结论。
小结
- 偏差先看方向:收入高于预算有利,成本高于预算不利;同一个正数在两张表里含义相反。
- 判定要同时看偏差率和绝对额,两条阈值取「或」;只看一个会漏掉小科目的大比例或大科目的小比例。
- LedgerLite 的 Q1 缺口只有 −$468、−0.78%,按季是 ok;年化 $1,872 后变成 watch。
- Tidecast 开发延后一个月,机会成本多 $6,000,偏差率 7.14% 但绝对额触发 act;项目结余从 $37,171 降到 $31,171。
- Nord 的工时缺口 −$2,550、−9.09%,百分比不到 10% 仍因绝对额触发 act。
- 季度校准先分暂时与结构,再决定只修下一季还是改全年;滚动预测 = 实际 + 剩余预算 + 修正。
阅读导航:上一节:5.2 费用结构与固定/变动成本 · 下一节:6.1 涨价对收入与流失的净效应 。
常见错误与边界
- 只看偏差正负。 收入 +$2,550 是好事,成本 +$2,550 是超支;先约定方向再谈幅度。
- 只看百分比。 Nord 的 −9.09% 看起来不到 10%,但 −$2,550 已经超过行动金额;百分比和绝对额要一起看。
- 只看绝对额。 小科目上的 $300 可能是 30% 的偏差,说明分类或录入有问题;不能因为金额小就忽略。
- 把季度 ok 当全年 ok。 LedgerLite 的 Q1 −$468 年化后是 $1,872,越过 watch 线;季度阈值要配年化。
- 年化比率而不是金额。 年化放大的是「重复发生的钱」,不是把 0.78% 变成 3.12%;比率仍然按单期算。
- 把机会成本当现金。 Tidecast 延期一个月的 $6,000 可能没有现金流出,但项目结余确实少了 $6,000。
- 开发延期等上线再处理。 里程碑延期是领先指标,应该在项目预算里按里程碑校准,而不是等年度偏差。
- 把两条线合成一个偏差。 Nord 的咨询工时和产品客户数驱动不同,合并后会互相掩盖。
- 每次波动都改全年预算。 把一次性因素写进全年预算,会让预算失去约束力;先分暂时与结构。
- 修正没有记录。 滚动预测的修正值要写明依据和日期,否则下一季无法判断当时为什么这么改。
偏差校准的下一步是定价。预算告诉你「少签两家」值多少钱,定价告诉你「每个客户应该收多少」;下一章从涨价对收入与流失的净效应开始。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。