10.3 价格弹性与副作用

折扣的需求响应不能靠猜。本节用 config/n10_elasticity.json 的需求表算出各价格点的收入与弧弹性,定位收入最大价,用 B 基准对照解析最优价,并量化两个副作用:原价购买者的价值差、弹性误判造成的收入损失,回答「降价到底赚不赚」。

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(‰,取绝对值)
240012402,976,000774
270011203,024,000964
300010003,000,0001200
33008802,904,0001500
36007602,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 漏斗与留存曲线 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引