4.3 价格弹性与副作用

价格弹性回答上一节留下的问题:为什么收入打到 30% 折就掉头。本节用弧弹性定位「单位弹性」拐点,再把折扣的三项副作用——需求前置、参考价侵蚀、鲸鱼流失——从毛收入里扣掉,算出净收入最优折扣,看它比毛最优浅多少,并用敏感度基准检验结论是否稳健。

本节目标:用弹性解释「收入为什么在某个折扣掉头」,并把折扣的副作用从毛收入里扣掉,找出净收入最优折扣。

上一节找到了毛收入峰值折扣(30%),但没回答「为什么偏偏是 30%」。答案是价格弹性:弹性跨过单位弹性那一点,收入就掉头。而且折扣不是免费的——它把未来的原价单提前卖掉、侵蚀参考价、赶走高价值用户,这三项副作用让净收入最优折扣比毛收入最优更浅。本节和广告变现那篇《游戏广告变现设计 2026》 是分工关系:那篇给的是广告变现的清单,本节做的是可跑的价格弹性与折扣副作用模型。内购点结构是第 2 章、LTV 是第 3 章,本节不越界。

4.3 价格弹性与副作用

4.3.1 收入为什么会在某个折扣掉头

收入 R = 价格 × 数量。降价时价格线性下降,数量却按弹性上升:|E| > 1 时数量涨得比价格快,收入上升;|E| < 1 时数量涨得慢,收入下降。|E| 恰好等于 1 的那一点就是收入峰值。所以「峰值折扣」不是拍脑袋定的,而是弹性曲线的拐点。

4.3.2 D 层:价格-需求点与副作用系数表

下面这份是 D 层配置:9 个价格-需求点(和上一节同一张需求表)、以及三项副作用随折扣变化的系数表。futureUnits 是未来窗口的原价需求,pullForwardPct 是「被提前卖掉」的比例,erosionPct 是未来原价需求永久下降的比例,whaleChurnPermille 是高价值用户流失的千分比。所有系数都是示例假设,真实值需真实业务环境(X)。

{
  "version": 1,
  "game": "merge-island",
  "note": "价格弹性与折扣副作用口径(D 层)。价格-需求点与副作用系数均为示例假设;真实弹性、参考价侵蚀与鲸鱼流失需真实业务环境,命令行不可复现(X)。金额一律以「分」记账,比例一律整数百分比 / 千分比,无浮点。",
  "currency": "fen",
  "listPriceFen": 3000,
  "priceLadder": [
    {"priceFen": 3000, "units": 1000},
    {"priceFen": 2700, "units": 1350},
    {"priceFen": 2400, "units": 1750},
    {"priceFen": 2100, "units": 2150},
    {"priceFen": 1800, "units": 2450},
    {"priceFen": 1500, "units": 2620},
    {"priceFen": 1200, "units": 2720},
    {"priceFen": 900, "units": 2760},
    {"priceFen": 600, "units": 2780}
  ],
  "sideEffects": {
    "futureUnits": 1000,
    "whaleCount": 200,
    "whaleArppuFen": 20000,
    "table": [
      {"discountPct": 0, "pullForwardPct": 0, "erosionPct": 0, "whaleChurnPermille": 0},
      {"discountPct": 10, "pullForwardPct": 8, "erosionPct": 1, "whaleChurnPermille": 2},
      {"discountPct": 20, "pullForwardPct": 15, "erosionPct": 3, "whaleChurnPermille": 6},
      {"discountPct": 30, "pullForwardPct": 22, "erosionPct": 6, "whaleChurnPermille": 12},
      {"discountPct": 40, "pullForwardPct": 30, "erosionPct": 10, "whaleChurnPermille": 22},
      {"discountPct": 50, "pullForwardPct": 38, "erosionPct": 16, "whaleChurnPermille": 36},
      {"discountPct": 60, "pullForwardPct": 45, "erosionPct": 24, "whaleChurnPermille": 55},
      {"discountPct": 70, "pullForwardPct": 50, "erosionPct": 34, "whaleChurnPermille": 80},
      {"discountPct": 80, "pullForwardPct": 52, "erosionPct": 40, "whaleChurnPermille": 100}
    ]
  }
}

4.3.3 S 层:弧弹性 + 毛收入 + 副作用 + 净收入

下面这段是 S 层仿真脚本:算相邻价格点的弧弹性、找单位弹性拐点,再逐档算毛收入、三项副作用损失和净收入,最后对比毛最优与净最优。全程整数,金额以分记账。

#!/usr/bin/env python3
# file: sim/l04_elasticity.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l04_elasticity.py --config config/ --out out/
"""价格弹性与折扣副作用:毛收入最优 ≠ 净收入最优。

读 config/l04_elasticity.json:价格-需求点 + 副作用系数表。
1) 弧弹性:相邻点 |E| = (ΔQ·(P1+P2)) / ((Q1+Q2)·ΔP),千分比;|E|>1000 为富有弹性;
2) 毛收入 price × units,找毛收入峰值折扣;
3) 副作用三项:需求前置(把未来原价单提前卖掉)+ 参考价侵蚀(未来原价需求永久下降)
   + 鲸鱼流失(高价值用户因深折而流失);
4) 净收入 = 毛收入 - 三项损失,找净收入峰值折扣;对比毛最优与净最优的深度差与收入差。
全程整数(分)。产出 out/l04_elasticity.json + out/l04_elasticity.csv。
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import sys


def load(cfg_dir, name):
    with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as f:
        return json.load(f)


def arc_elasticity_milli(p1, q1, p2, q2):
    """弧弹性绝对值(千分比):(ΔQ·(P1+P2)) / ((Q1+Q2)·ΔP)。价格降、需求升时为正。"""
    dp, dq = p1 - p2, q2 - q1
    if dp <= 0 or (q1 + q2) == 0:
        return 0
    return dq * (p1 + p2) * 1000 // ((q1 + q2) * dp)


def discount_pct(list_price_fen, price_fen):
    return (list_price_fen - price_fen) * 100 // list_price_fen


def side_loss(list_price_fen, units, effect, se):
    """三项副作用损失(分):需求前置 + 参考价侵蚀 + 鲸鱼流失。"""
    pull_units = units * effect["pullForwardPct"] // 100
    pull_loss = pull_units * list_price_fen
    erosion_loss = se["futureUnits"] * list_price_fen * effect["erosionPct"] // 100
    churn = se["whaleCount"] * effect["whaleChurnPermille"] // 1000
    whale_loss = churn * se["whaleArppuFen"]
    return {"pullUnits": pull_units, "pullLossFen": pull_loss, "erosionLossFen": erosion_loss,
            "whaleChurn": churn, "whaleLossFen": whale_loss,
            "totalLossFen": pull_loss + erosion_loss + whale_loss}


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    cfg = load(args.config, "l04_elasticity")
    lp, ladder, se = cfg["listPriceFen"], cfg["priceLadder"], cfg["sideEffects"]
    eff_by_d = {e["discountPct"]: e for e in se["table"]}
    os.makedirs(args.out, exist_ok=True)

    elast = [{"fromDiscountPct": discount_pct(lp, a["priceFen"]),
              "toDiscountPct": discount_pct(lp, b["priceFen"]),
              "elasticityMilli": arc_elasticity_milli(a["priceFen"], a["units"], b["priceFen"], b["units"])}
             for a, b in zip(ladder, ladder[1:])]
    for e in elast:
        e["elastic"] = e["elasticityMilli"] > 1000

    rows = []
    for pt in ladder:
        d = discount_pct(lp, pt["priceFen"])
        gross = pt["priceFen"] * pt["units"]
        loss = side_loss(lp, pt["units"], eff_by_d[d], se)
        rows.append({"discountPct": d, "priceFen": pt["priceFen"], "units": pt["units"],
                     "grossFen": gross, **loss, "netFen": gross - loss["totalLossFen"]})

    gross_best = max(rows, key=lambda r: r["grossFen"])
    net_best = max(rows, key=lambda r: r["netFen"])
    unit_elastic_at = next((e["fromDiscountPct"] for e in elast if not e["elastic"]), None)

    with open(os.path.join(args.out, "l04_elasticity.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["discountPct", "priceFen", "units", "grossFen", "pullLossFen",
                    "erosionLossFen", "whaleLossFen", "totalLossFen", "netFen"])
        for r in rows:
            w.writerow([r["discountPct"], r["priceFen"], r["units"], r["grossFen"],
                        r["pullLossFen"], r["erosionLossFen"], r["whaleLossFen"],
                        r["totalLossFen"], r["netFen"]])

    report = {"source": "sim/l04_elasticity.py", "tier": "S", "game": cfg["game"],
              "note": cfg["note"], "listPriceFen": lp, "elasticity": elast, "rows": rows,
              "unitElasticFromDiscountPct": unit_elastic_at,
              "grossBestDiscountPct": gross_best["discountPct"], "grossBestFen": gross_best["grossFen"],
              "netBestDiscountPct": net_best["discountPct"], "netBestFen": net_best["netFen"],
              "netAtGrossBestFen": gross_best["netFen"],
              "gapFen": gross_best["grossFen"] - gross_best["netFen"],
              "csv": os.path.join(args.out, "l04_elasticity.csv")}
    with open(os.path.join(args.out, "l04_elasticity.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)

    for e in elast:
        print(f"discount {e['fromDiscountPct']:>2}%->{e['toDiscountPct']:>2}% "
              f"|E|={e['elasticityMilli']:>5}permille elastic={e['elastic']}")
    for r in rows:
        print(f"discount={r['discountPct']:>2}% gross={r['grossFen']:>8} loss={r['totalLossFen']:>8} "
              f"(pull={r['pullLossFen']} ero={r['erosionLossFen']} whale={r['whaleLossFen']}) "
              f"net={r['netFen']:>8}")
    print(f"unit-elastic from discount={unit_elastic_at}%")
    print(f"grossBest={gross_best['discountPct']}% ({gross_best['grossFen']}分) "
          f"netBest={net_best['discountPct']}% ({net_best['netFen']}分) "
          f"netAtGrossBest={gross_best['netFen']}分 gap={report['gapFen']}分")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑(金额单位「分」):弧弹性从 0→10% 的 |E|=2829‰ 一路降到 70→80% 的 18‰;20→30% 段 |E|=1538‰(仍富有弹性),30→40% 段掉到 847‰(转为缺乏弹性)——单位弹性拐点在 30%,正是上一节的毛收入峰值。净收入侧:毛最优 30%(4515000 分),净最优 20%(3304000 分),净最优比毛最优浅 10 个百分点。

4.3.4 逐条读输出

  • 单位弹性拐点 = 30%,和毛收入峰值重合。这不是巧合:R = P × Q,收入对价格的导数符号由 |E| 是否大于 1 决定。|E| 从富有(>1000‰)切到缺乏(<1000‰)的位置,就是收入从升转降的位置。弹性给了峰值一个因果解释,而不只是一个观测值。
  • 副作用随折扣非线性放大,需求前置是大头。30% 折时总损失 1639000 分,其中需求前置 1419000 分(约 87%)、参考价侵蚀 180000 分、鲸鱼流失 40000 分。深折把「本来会原价买」的未来需求提前折价卖掉,这一项最贵。
  • 净最优比毛最优浅:毛最优 30%、净最优 20%。在毛最优那个点上,净收入只有 2876000 分,比毛收入 4515000 分少了 1639000 分(gap)。按毛收入决策,会把折扣打得太深。
  • 净收入在 60% 起转负(-1348000 分):深折的副作用损失超过了毛收入。这解释了为什么「大促打到骨折」通常不可持续。数字是同机相对量级,绝对数值不可移植。

4.3.5 B 层:副作用强度敏感度基准

副作用系数是假设,结论稳不稳?下面这段是 B 层基准脚本:把三项系数整体乘以 0%~200% 的强度系数,复算净最优折扣。它是软告警——边界退化只打印 WARN,不硬失败。

#!/usr/bin/env python3
# file: sim/l04_elasticity_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/l04_elasticity_bench.py --config config/ --out out/
"""价格弹性副作用的敏感度基准(软告警,不硬失败):扫副作用强度,看净收入最优折扣怎么漂。

副作用系数(需求前置 + 参考价侵蚀 + 鲸鱼流失)都带主观性。本基准把它们整体乘以一个
强度系数(0% ~ 200%),复算净收入最优折扣,检验「净最优比毛最优更浅」这个结论是否稳健。
全程整数,复用 S 层的折算逻辑。产出 out/l04_elasticity_bench.json。
"""

from __future__ import annotations

import argparse
import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from l04_elasticity import discount_pct, load, side_loss  # noqa: E402

SCALES_PCT = (0, 50, 100, 150, 200)


def scale_effect(effect, scale_pct):
    return {"pullForwardPct": effect["pullForwardPct"] * scale_pct // 100,
            "erosionPct": effect["erosionPct"] * scale_pct // 100,
            "whaleChurnPermille": effect["whaleChurnPermille"] * scale_pct // 100}


def net_best_for(cfg, scale_pct):
    lp = cfg["listPriceFen"]
    se = cfg["sideEffects"]
    eff_by_d = {e["discountPct"]: e for e in se["table"]}
    best_d, best_net = 0, None
    for pt in cfg["priceLadder"]:
        d = discount_pct(lp, pt["priceFen"])
        gross = pt["priceFen"] * pt["units"]
        loss = side_loss(lp, pt["units"], scale_effect(eff_by_d[d], scale_pct), se)
        net = gross - loss["totalLossFen"]
        if best_net is None or net > best_net:
            best_d, best_net = d, net
    return best_d, best_net


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    cfg = load(args.config, "l04_elasticity")
    os.makedirs(args.out, exist_ok=True)

    deepest = max(discount_pct(cfg["listPriceFen"], p["priceFen"]) for p in cfg["priceLadder"])
    rows = []
    for s in SCALES_PCT:
        d, net = net_best_for(cfg, s)
        rows.append({"scalePct": s, "netBestDiscountPct": d, "netBestFen": net})

    # 软告警:净最优退到边界(0% 或最深档),说明副作用模型把折扣一棍子打死 / 或副作用失效
    warns = [f"强度 {r['scalePct']}% 时净最优折扣退到边界 {r['netBestDiscountPct']}%"
             for r in rows if r["netBestDiscountPct"] in (0, deepest)]
    discounts = [r["netBestDiscountPct"] for r in rows]
    report = {"source": "sim/l04_elasticity_bench.py", "tier": "B", "scalesPct": list(SCALES_PCT),
              "rows": rows, "netBestRangePct": [min(discounts), max(discounts)],
              "warnings": warns, "ok": not warns}
    with open(os.path.join(args.out, "l04_elasticity_bench.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)

    for r in rows:
        print(f"scale={r['scalePct']:>3}% netBestDiscount={r['netBestDiscountPct']:>2}% "
              f"net={r['netBestFen']}分")
    for w in warns:
        print(f"  WARN {w}")
    print(f"== 弹性副作用基准:{'OK' if report['ok'] else 'WARN'}(软告警不硬失败)"
          f" 净最优区间={report['netBestRangePct']}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑:强度 0% 时净最优 30%(与毛最优一致),50% 与 100% 时都是 20%,150% 时 10%,200% 时退到边界 0% 并打出 WARN;净最优区间 [0%, 30%]。结论是:「净最优比毛最优更浅」在所有非零强度下都成立(方向稳健),但「浅多少」随副作用系数量级剧烈漂移——所以这套模型给的是方向,不是精确折扣点。

4.3.6 三条可复用判断

  1. 用弹性找拐点,别靠试错。峰值折扣就是 |E| 跨过 1 的位置;算一遍弧弹性就知道,不用一轮轮上线试。
  2. 按净收入决策,不按毛收入。深折的副作用不是零:需求前置最贵。按毛收入选折扣会系统性打得太深。
  3. 系数是假设,用区间说话。副作用系数带主观性,跑一遍强度敏感度基准,给一个折扣区间而非一个点。

小结

  • 价格弹性解释收入为何在某个折扣掉头:|E| 跨过 1 的拐点即收入峰值。本机实跑拐点在 30%。
  • 折扣三项副作用(需求前置 / 参考价侵蚀 / 鲸鱼流失)从毛收入里扣掉,净最优从 30% 收到 20%。
  • 强度敏感度基准显示「净最优更浅」方向稳健,但深度随系数量级漂移,结论应给区间。
  • 所有数字为示例假设下的同机相对量级,绝对数值不可移植;真实弹性与副作用需真实业务环境(X)。

到这里,第 4 章的三块拼图齐了:档位锚点定静态价格(4.1),折扣 + 限时 + 稀缺做动态促销(4.2),弹性与副作用给出决策边界(4.3)。定价只决定「单个用户值多少」,接下来要解决「用户从哪来、花多少钱买」——下一章进入用户获取。

阅读导航:上一节:折扣、限时与稀缺 · 下一节:用户获取渠道机制 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制