10.2 里,需求对折扣的响应是一个假设(每降 1 个百分点,需求涨 30‰)。本节要做的,是把这个假设换成一个可以估计的量——价格弹性——并且把它带来的副作用算清楚。因为定价和折扣真正难的地方,从来不是「怎么算收入」,而是「降价之后,谁不高兴了」。
价格弹性回答一个很朴素的问题:价格变一点,买的人变多少。听起来是个经济学名词,但它直接决定运营每天要做的判断——「这次到底该不该打折」。
价格弹性是什么
价格弹性(price elasticity of demand)定义为需求量变化百分比 ÷ 价格变化百分比:
E = (需求量变化 / 原需求量) / (价格变化 / 原价格)
当价格变化是一个区间(比如从 3000 分降到 2700 分),用弧弹性——把「原需求量 / 原价格」当作分母的基准点:
E = (ΔQ × P₀) / (Q₀ × ΔP)
E 是负数(涨价需求降),比较时看绝对值 |E|。它有三种情形,直接对应收入的走向:
- |E| > 1(富有弹性):需求降得比价格快。降价,收入上升——薄利多销成立。
- |E| < 1(缺乏弹性):需求对价格不敏感。降价,收入反而下降——白让利。
- |E| = 1(单位弹性):降价的让利正好被增量抵消,收入最大。
记住一句话:收入最大的点,就在 |E| 从小于 1 跨到大于 1 的地方。这条规则把「定价」从一个凭感觉的决策,变成一个可定位的点。
需求表:把弹性算出来
需求表是本套的设计假设(非实测),新建在 config/n10_elasticity.json 里。下面这段是 D 层配置:
{
"version": 1,
"note": "第 10 章价格弹性补充参数:需求表为设计假设(非实测)。价格整数分,需求整数次。",
"referenceTierId": "t2",
"assumedElasticityPermille": 1000,
"demandPoints": [
{ "priceCents": 2400, "demandUnits": 1240 },
{ "priceCents": 2700, "demandUnits": 1120 },
{ "priceCents": 3000, "demandUnits": 1000 },
{ "priceCents": 3300, "demandUnits": 880 },
{ "priceCents": 3600, "demandUnits": 760 }
]
}
referenceTierId 指向 config/pricing.json 里的 t2(3000 分),也就是当前标价;assumedElasticityPermille = 1000 是误判场景里设计者以为的弹性(单位弹性)。需求表本身是 5 个价格点上的需求量——它怎么来的,是 X 层问题(真实需求曲线要靠投放数据回归),本节先假设它已知。
仿真:收入、弹性与收入最大价
下面这段是 S 层仿真脚本,读需求表与参照档价,算各价格点的收入、相邻价格点的弧弹性,定位收入最大价,并量化两个副作用:
# file: sim/n10_elasticity.py
# tier: S
# verify: python3 sim/n10_elasticity.py --config config/ --out out/
"""价格弹性仿真:读 config/n10_elasticity.json 的需求表(设计假设)与 config/pricing.json
的参照档价,算各价格点的收入、相邻价格点的弧弹性(千分比),定位收入最大价,
并量化两个副作用:原价购买者的价值差、以及弹性误判造成的收入损失。
产出 out/n10_elasticity.json + out/n10_elasticity.csv + out/n10_elasticity.svg。"""
from __future__ import annotations
import argparse
import csv
import json
import os
from svg_line import render
def load(cfg_dir: str, name: str) -> dict:
with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as fh:
return json.load(fh)
def analyze(cfg: dict) -> dict:
pts = cfg["demandPoints"]
rows = []
for i, p in enumerate(pts):
rev = p["priceCents"] * p["demandUnits"]
row = {"priceCents": p["priceCents"], "demandUnits": p["demandUnits"], "revenueCents": rev}
if i + 1 < len(pts):
q = pts[i + 1]
dp = q["priceCents"] - p["priceCents"]
dq = abs(q["demandUnits"] - p["demandUnits"])
row["arcElasticityPermille"] = dq * p["priceCents"] * 1000 // (p["demandUnits"] * dp)
else:
row["arcElasticityPermille"] = None
rows.append(row)
peak = max(rows, key=lambda r: r["revenueCents"])
return {"rows": rows, "peak": peak}
def main() -> int:
here = os.path.dirname(os.path.abspath(__file__))
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=os.path.join(here, "..", "config"))
ap.add_argument("--out", default=os.path.join(here, "..", "out"))
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
cfg = load(args.config, "n10_elasticity")
pricing = load(args.config, "pricing")
res = analyze(cfg)
rows, peak = res["rows"], res["peak"]
ref = next(t for t in pricing["tiers"] if t["id"] == cfg["referenceTierId"])
ref_row = next(r for r in rows if r["priceCents"] == ref["priceCents"])
forgone = peak["revenueCents"] - ref_row["revenueCents"]
unit_gap = ref["priceCents"] - peak["priceCents"]
anchor_erosion = unit_gap * ref_row["demandUnits"]
with open(os.path.join(args.out, "n10_elasticity.csv"), "w", encoding="utf-8", newline="") as fh:
w = csv.writer(fh)
w.writerow(["priceCents", "demandUnits", "revenueCents", "arcElasticityPermille"])
for r in rows:
w.writerow([r["priceCents"], r["demandUnits"], r["revenueCents"],
"" if r["arcElasticityPermille"] is None else r["arcElasticityPermille"]])
render([r["revenueCents"] for r in rows], os.path.join(args.out, "n10_elasticity.svg"),
"revenue vs price")
report = {
"version": 1, "rows": rows,
"peakPriceCents": peak["priceCents"], "peakRevenueCents": peak["revenueCents"],
"referencePriceCents": ref["priceCents"], "referenceRevenueCents": ref_row["revenueCents"],
"forgoneRevenueCents": forgone, "unitGapCents": unit_gap,
"anchorErosionCents": anchor_erosion, "assumedElasticityPermille": cfg["assumedElasticityPermille"],
"revenueNotMonotonic": not (all(rows[i]["revenueCents"] >= rows[i - 1]["revenueCents"] for i in range(1, len(rows)))
or all(rows[i]["revenueCents"] <= rows[i - 1]["revenueCents"] for i in range(1, len(rows)))),
}
with open(os.path.join(args.out, "n10_elasticity.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
print(json.dumps(report, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑产出的弹性与收入表 out/n10_elasticity.csv:
| 价格(分) | 需求量 | 收入(分) | 弧弹性 E(‰,取绝对值) |
|---|---|---|---|
| 2400 | 1240 | 2,976,000 | 774 |
| 2700 | 1120 | 3,024,000 | 964 |
| 3000 | 1000 | 3,000,000 | 1200 |
| 3300 | 880 | 2,904,000 | 1500 |
| 3600 | 760 | 2,736,000 | — |
读法:
- 收入先升后降,峰在 2700(3,024,000 分)。
revenueNotMonotonic = true——收入不是价格的单调函数,这是本节最重要的结论。 - 弹性从 774 一路升到 1500,正好在 2700 → 3000 这段跨过 1000:2400→2700 的 |E| 是 774(<1,缺乏弹性),2700→3000 是 964(<1),3000→3300 是 1200(>1,富有弹性)。收入最大价 2700,就落在 |E| 从小于 1 跨到大于 1 的附近——和理论完全对上。
- 当前标价 3000 不是最优:它比峰值 2700 少赚
3,024,000 − 3,000,000 = 24,000分(每 1000 份销量)。降价 10% 反而增收,因为在这个区间需求是富有弹性的。
基准:解析最优价 vs 离散峰值
S 层的峰值是在 5 个离散价格点上取的。如果需求曲线是线性的,最优价有解析解 p* = −a / (2b)。下面这段是 B 层基准脚本(软告警,不硬失败),用最小二乘拟合线性需求,给出解析最优价并与离散峰值对照:
# file: sim/n10_elasticity_bench.py
# tier: B
# verify: python3 sim/n10_elasticity_bench.py --config config/ --out out/
"""弹性解析基准(B 层,软告警不硬失败):用最小二乘拟合线性需求 Q = a + b·p,
给出解析最优价 p* = -a/(2b),与离散价目表的收入峰值对照,报告残差与价格差。
产出 out/n10_elasticity_bench.json。"""
from __future__ import annotations
import argparse
import json
import os
import numpy as np
from n10_elasticity import analyze, load
def main() -> int:
here = os.path.dirname(os.path.abspath(__file__))
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=os.path.join(here, "..", "config"))
ap.add_argument("--out", default=os.path.join(here, "..", "out"))
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
cfg = load(args.config, "n10_elasticity")
res = analyze(cfg)
pts = cfg["demandPoints"]
prices = np.array([p["priceCents"] for p in pts], dtype=float)
demand = np.array([p["demandUnits"] for p in pts], dtype=float)
b, a = np.polyfit(prices, demand, 1) # Q = a + b·p,b 为斜率(负)
pred = a + b * prices
rmse = float(np.sqrt(np.mean((pred - demand) ** 2)))
star = -a / (2.0 * b) # 解析收入最大价
star_round = int(round(star))
star_rev = int(round(star * (a + b * star)))
peak = res["peak"]
gap = abs(star_round - peak["priceCents"])
report = {
"version": 1, "kind": "baseline",
"fit": {"interceptA": round(a, 4), "slopeB": round(b, 6), "rmseUnits": round(rmse, 4)},
"analyticOptPriceCents": star_round, "analyticOptRevenueCents": star_rev,
"discretePeakPriceCents": peak["priceCents"], "discretePeakRevenueCents": peak["revenueCents"],
"priceGapCents": gap, "revenueGapCents": abs(star_rev - peak["revenueCents"]),
"pass": gap <= 100,
}
with open(os.path.join(args.out, "n10_elasticity_bench.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
print(json.dumps(report, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑输出:
{"version": 1, "kind": "baseline", "fit": {"interceptA": 2200.0, "slopeB": -0.4, "rmseUnits": 0.0}, "analyticOptPriceCents": 2750, "analyticOptRevenueCents": 3025000, "discretePeakPriceCents": 2700, "discretePeakRevenueCents": 3024000, "priceGapCents": 50, "revenueGapCents": 1000, "pass": true}
基准结论:线性拟合 Q = 2200 − 0.4p,残差为 0(需求表恰好是线性的);解析最优价 2750 分,离散峰值 2700 分,差 50 分,收入差仅 1000 分。pass = true。
这个 50 分的差不是误差,而是离散价目表的固有损失:解析最优价 2750 落在 2700 和 3000 两个标价点之间,而真实货架只能摆整数价位。基准脚本告诉我们:只要离散网格够密,峰值价就够接近理论最优;网格太稀,最优价会从缝里漏掉。这也是为什么很多游戏会给同一档位配「2700 / 2800 / 2900」的密集测试价——不是为了精确到分,而是为了让最优价落在网格上。
副作用一:原价购买者的价值差
弹性算出来的「降价增收」,是从总量看的。但降价会得罪一批人:那些在原价买过的人。如果价格从 3000 降到 2700,早买的玩家每份多花了 3000 − 2700 = 300 分。脚本把它算成 anchorErosionCents:
价值差 = 单位价差 × 原价购买量 = 300 分 × 1000 份 = 300,000 分
30 万分(3000 元)的价值差,就是降价给老用户造成的「心理落差」。它不会立刻变成退款,但会变成三种可观察的后果:等折扣(下次不再原价买)、差评(觉得被割韭菜)、流失(转去等下一款)。这正是 10.2 里「锚点击穿」的微观来源——每一个击穿的锚点,背后都是一批觉得自己买亏了的人。
所以「降价增收 24,000 分」和「老用户价值差 300,000 分」必须放在一起看:增量的收入,是用老用户的信任换的。运营的常见做法是给一个补偿(比如对近 7 天原价购买者返差价),把 300,000 的价值差压下来——补偿的额度,就是这次降价真正的成本。
副作用二:弹性误判的收入损失
第二个副作用来自估错弹性。假设设计者以为当前价格附近是单位弹性(assumedElasticityPermille = 1000),于是认为「3000 分已经是收入最大价,不用动」。但真实弹性在 2700→3000 这段是 964,在 3000→3300 段是 1200——真实的收入最大价是 2700,不是 3000。误判的代价就是脚本里的 forgoneRevenueCents:
误判损失 = 峰值收入 − 现价收入 = 3,024,000 − 3,000,000 = 24,000 分
24,000 分看起来不大,但它是每一批 1000 份销量的持续损失——按月累加,就是可观的缺口。更要命的是误判的方向:把「富有弹性」误当成「缺乏弹性」,会导致该降的价不敢降;反过来把「缺乏弹性」误当成「富有弹性」,会导致该稳的价乱降,把利润白白让掉。弹性估计的偏差,会系统性地把定价推离最优点,而且偏的方向和偏差的方向一致。
弹性的估计为什么难
前面两节的弹性表是假设的。真实弹性要从数据里估,而这一步有三道坎:
- 内生性:价格和销量是同时决定的。运营看到「打折时卖得好」就以为需求对价格敏感,其实可能是「打折时恰好有活动」。相关性不等于因果——要分离价格效应,得靠 A/B 实验(第 13 章的主题)。
- 样本量:弹性是一个百分比之比,估计它需要足够的价格变动和销量。价格点少、样本小,弹性的置信区间会很宽。
- 滞后:降价会改变玩家的预期(「等下次打折」),这种效应不会立刻体现在当次数据里,而是拖到未来几个周期。
这三道坎合起来,就是本节反复强调的边界:弹性是运营里最有用的数之一,也是最难估准的数之一。本节能做的,是给定需求表,算出收入最大价与两个副作用;需求表本身怎么来,属于 X 层——需真实业务环境,命令行不可复现。
小结
价格弹性 E = (ΔQ × P₀) / (Q₀ × ΔP) 把「降价赚不赚」变成一个可定位的点:收入最大价落在 |E| 跨过 1 的地方。本节用 S 层脚本在需求表上定位出峰值价 2700(当前标价 3000 少赚 24,000 分),用 B 基准对照解析最优价 2750(差 50 分,来自离散网格),并量化两个副作用:原价购买者 300,000 分的价值差、弹性误判 24,000 分的持续损失。
一句话收束第 10 章:定价看锚点(10.1)、折扣看稀缺与击穿(10.2)、弹性看收入最大价与副作用(10.3)。三个数都能跑,三个数都要一起看——只看收入,会把价格锚点卖掉。下一章离开「卖多少钱」,进入「一个玩家一辈子值多少」:漏斗、留存与 LTV。
阅读导航:上一节:10.2 折扣、限时与稀缺 · 下一节:11.1 漏斗与留存曲线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。