上一节把渠道机制 算清楚了:钱怎么花、在哪一步折损、能不能回本。但那条漏斗里,出价、人群、归因这些变量都在渠道手里,你改不动。真正由你完全掌控、且直接决定 CTR的,只有一样东西——素材。这一节就讲素材:怎么设计、怎么批量产出、怎么用小额测试淘汰、怎么把预算集中到胜出者身上。
边界先说清:这一节讲的是付费买量里的素材。至于自然量、裂变、平台免费流量(比如小游戏中心的推荐位),那是下一章增长与裂变 的事,本节不越界。素材-玩法是否一致、会不会「骗点击」,是下一节单独处理的问题。
素材是买量里唯一自己掌控的变量
先把上一节的漏斗公式再看一眼:CPI = CPM × 1000 / (CTR × IPM)。CPM 由竞价决定,IPM 由「素材承诺」与「商店/落地页」共同决定,而你唯一能在几分钟内反复改、立刻看到反馈的,是 CTR——也就是素材的点击率。
这就是为什么成熟的买量团队把大部分精力砸在素材上:素材是可以量产、可以测试、可以迭代的。一条渠道参数你可能一周才调一次,但素材一天能上几十条。素材的质量不是玄学,它是一个可搜索的优化问题:产出足够多的候选、用足够小的成本测出哪个 CTR 高、把预算压到赢家身上。
素材的构成要素与矩阵
一条小游戏买量素材,无论多短,都在回答四个问题。把这四个要素当成设计清单:
| 要素 | 回答的问题 | 常见手法 |
|---|---|---|
| 钩子 | 前 3 秒凭什么让人停下 | 冲突、悬念、反常识、痛点直击 |
| 演示 | 这游戏怎么玩、爽在哪 | 真实合成连击、结果爆点、进度飞涨 |
| 结果 | 玩下去能得到什么 | 岛屿成型、装扮变化、数值成长 |
| 行动 | 下一步做什么 | 「点这里」「免费玩」「合成试试」 |
而素材矩阵是把「主题」和「形式」做笛卡尔积,保证每一轮测试覆盖不同的假设。主题是「讲什么」,形式是「怎么讲」:
| 竖版 9:16 · 15s | 方版 1:1 · 10s | 横版 16:9 · 30s | |
|---|---|---|---|
| 玩法展示 | 合成连击实录 | 单步合成特写 | 完整一局流程 |
| 结果爆点 | 岛屿瞬间成型 | 装扮前后对比 | 进度条冲刺 |
| 剧情钩子 | 「她只剩一块地」 | 悬念开场 | 三幕小故事 |
| 前后对比 | 第 1 天 vs 第 30 天 | 白板到满岛 | 数值对比 |
矩阵的价值不在于「都做」,而在于每轮测试只变一两个维度——如果这一轮同时换了主题和形式,你测出 CTR 变高了,也不知道是哪个变量起了作用。这是最朴素的 A/B 纪律,和第 8 章的实验设计 是同一套逻辑。
迭代流程:批量产出 → 小额测试 → 淘汰 → 放量
素材迭代是一条闭环,四步一循环:
- 批量产出:一轮做 N 条(小游戏常见 6~20 条),覆盖矩阵里的不同格子。
- 小额测试:每条给一个小预算 + 固定曝光量(比如每条 2 万次曝光),够统计、又不心疼。
- 淘汰:按 CTR 排序,只留头部(topK)进候选池,尾部直接砍掉。
- 放量:把主预算压到候选池里 CTR 最高的那条上,让它跑出规模。
关键在第三步的取舍:测出来的 CTR 是小样本估计,带噪声。一条素材在 2 万曝光里跑出高 CTR,可能是真的强,也可能只是运气。所以工程上会留 topK 而不是只留 top1,多轮迭代、把候选池做大,降低「被一次幸运测试骗了」的风险。
素材疲劳:同一个素材会「用旧」
还有一件事必须进模型:素材疲劳。同一条素材反复投给同一批人群,CTR 会逐轮衰减——观众看腻了、系统把它重复曝光给已经点过的人。所以「一条素材一直用」是慢性自杀:它上线第一天 CTR 最高,之后一路下滑。
迭代之所以重要,一部分原因就是用新素材对冲疲劳。但迭代本身也受疲劳影响:如果每一轮面对的还是同一批受众,整轮的平均 CTR 也会被压下去。这就是为什么「迭代」不是无脑多产,而是产出速度要跑赢衰减速度。
S 层仿真:迭代 vs 只用一条素材
下面这段是 S 层仿真脚本 sim/l05_creative_iter.py。它模拟四轮迭代:每轮产出 6 条素材、各给 2 万曝光做小测、保留 CTR 前 2 名进候选池,迭代结束后对候选池最优者放量;同时跑一个对照——只用第一轮第一条素材、在同样总预算下投放。整轮疲劳、质量抖动、小样本噪声都由可播种 LCG 生成,全整数。
#!/usr/bin/env python3
# file: sim/l05_creative_iter.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l05_creative_iter.py --config config/ --out out/
"""素材创意迭代仿真:批量小测 → 保留 topK → 迭代 → 放量,对照「只用一条素材」。
对 config/l05_creative.json:
每轮 r、每个素材 i:
theme = themes[(r*C + i) % len(themes)]
jitter = (900 + rng%200) / 1000 # 质量抖动 ±10%
fatigue = (1000 - r*fatiguePerRound) / 1000 # 整轮受众疲劳
trueCtr = theme.baseCtr * jitter // 1000 * fatigue // 1000
clicks = imp * trueCtr // 1000 + (rng%7 - 3) # 小样本噪声
obsCtr = clicks * 1000 // imp
installs = clicks * ipm // 1000
cost = imp * cpmFen // 1000
每轮保留 obsCtr 最高的 keepTopK 条进候选池;迭代结束后取池内最优者放量。
对照(baseline):只用第一轮第一条素材,在同样总预算下投放。
混合 CPI = 总花费 // 总安装;数值越小越好。
全程整数、地板除、可播种 LCG,无浮点。产出 out/l05_creative_iter.json + .csv + .svg。
素材 CTR 与投放结果是建模假设;真实数值需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def make_rng(seed: int, mult: int, inc: int):
state = seed & 0xFFFFFFFF
def nxt() -> int:
nonlocal state
state = (state * mult + inc) & 0xFFFFFFFF
return state
return nxt
def run(cfg: dict) -> dict:
nxt = make_rng(cfg["seed"], cfg["rngMultiplier"], cfg["rngIncrement"])
themes = cfg["themes"]
C = cfg["creativesPerRound"]
imp = cfg["testImpressionsPerCreative"]
cost_per = imp * cfg["cpmFen"] // 1000
pool: list[dict] = []
rounds_rows: list[dict] = []
test_spend = 0
test_installs = 0
first_creative = None
for r in range(cfg["rounds"]):
made = []
for i in range(C):
theme = themes[(r * C + i) % len(themes)]
jitter = (900 + nxt() % 200)
fatigue = 1000 - r * cfg["fatiguePermillePerRound"]
true_ctr = theme["baseCtrPermille"] * jitter // 1000 * fatigue // 1000
clicks = imp * true_ctr // 1000 + (nxt() % 7 - 3)
clicks = max(0, clicks)
obs_ctr = clicks * 1000 // imp
installs = clicks * cfg["ipmPermille"] // 1000
item = {"round": r, "idx": i, "theme": theme["id"], "obsCtrPermille": obs_ctr,
"clicks": clicks, "installs": installs, "costFen": cost_per}
made.append(item)
if first_creative is None:
first_creative = item
made.sort(key=lambda x: x["obsCtrPermille"], reverse=True)
kept = made[: cfg["keepTopK"]]
pool.extend(kept)
test_spend += cost_per * C
test_installs += sum(m["installs"] for m in made)
rounds_rows.append({"round": r, "bestCtrPermille": made[0]["obsCtrPermille"],
"meanCtrPermille": sum(m["obsCtrPermille"] for m in made) // C,
"testSpendFen": cost_per * C,
"testInstalls": sum(m["installs"] for m in made)})
winner = max(pool, key=lambda x: x["obsCtrPermille"])
win_cpi = winner["costFen"] // winner["installs"] if winner["installs"] else 10 ** 9
scale_installs = cfg["scaleBudgetFen"] // win_cpi if win_cpi else 0
total_budget = test_spend + cfg["scaleBudgetFen"]
iter_installs = test_installs + scale_installs
iter_cpi = total_budget // iter_installs if iter_installs else 0
base_cpi = (first_creative["costFen"] // first_creative["installs"]
if first_creative and first_creative["installs"] else 10 ** 9)
base_installs = total_budget // base_cpi if base_cpi else 0
base_cpi_eff = total_budget // base_installs if base_installs else 0
return {"rounds": rounds_rows, "winner": winner, "testSpendFen": test_spend,
"testInstalls": test_installs, "scaleInstalls": scale_installs,
"totalBudgetFen": total_budget, "iterInstalls": iter_installs, "iterCpiFen": iter_cpi,
"baseCpiFen": base_cpi_eff, "baseInstalls": base_installs,
"cpiDeltaPermille": (base_cpi_eff - iter_cpi) * 1000 // base_cpi_eff if base_cpi_eff else 0}
def draw_svg(path: str, rep: dict) -> int:
"""纯 Python SVG:每轮最佳 / 平均 CTR 柱,右侧标注迭代与单素材的混合 CPI。"""
rows = rep["rounds"]
W, H, PAD, ROW = 760, 70 + 56 * len(rows), 150, 56
root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(root, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(root, "text", {"x": "16", "y": "28", "font-size": "15",
"fill": "#111827"}).text = "素材迭代:每轮最佳 / 平均 CTR(千分比)"
span = W - PAD - 24
hi = max(r["bestCtrPermille"] for r in rows) or 1
for i, r in enumerate(rows):
y = 56 + i * ROW
ET.SubElement(root, "text", {"x": "16", "y": str(y + 20), "font-size": "12",
"fill": "#374151"}).text = f'第 {r["round"] + 1} 轮'
wb = span * r["bestCtrPermille"] // hi
wm = span * r["meanCtrPermille"] // hi
ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 4), "width": str(wb),
"height": "16", "rx": "3", "fill": "#2563eb"})
ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 24), "width": str(wm),
"height": "16", "rx": "3", "fill": "#93c5fd"})
ET.SubElement(root, "text", {"x": str(PAD + max(wb, wm) + 8), "y": str(y + 18),
"font-size": "11", "fill": "#6b7280"}).text = (
f'最佳 {r["bestCtrPermille"]}‰ · 均 {r["meanCtrPermille"]}‰')
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(root))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
with open(os.path.join(args.config, "l05_creative.json"), encoding="utf-8") as f:
cfg = json.load(f)
rep = run(cfg)
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "l05_creative_iter.svg"), rep)
report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"rounds": cfg["rounds"], "creativesPerRound": cfg["creativesPerRound"],
"keepTopK": cfg["keepTopK"], **rep,
"digest": (f'winner:{rep["winner"]["theme"]}:{rep["winner"]["obsCtrPermille"]}'
f'|iterCpi:{rep["iterCpiFen"]}|baseCpi:{rep["baseCpiFen"]}'
f'|deltaPermille:{rep["cpiDeltaPermille"]}'),
"svg": os.path.join(args.out, "l05_creative_iter.svg"), "svgBytes": svg_bytes}
with open(os.path.join(args.out, "l05_creative_iter.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "l05_creative_iter.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["round", "bestCtrPermille", "meanCtrPermille", "testSpendFen", "testInstalls"])
for r in rep["rounds"]:
w.writerow([r["round"], r["bestCtrPermille"], r["meanCtrPermille"],
r["testSpendFen"], r["testInstalls"]])
print(json.dumps({"rounds": rep["rounds"], "winner": rep["winner"],
"iterCpiFen": rep["iterCpiFen"], "iterInstalls": rep["iterInstalls"],
"baseCpiFen": rep["baseCpiFen"], "baseInstalls": rep["baseInstalls"],
"cpiDeltaPermille": rep["cpiDeltaPermille"],
"digest": report["digest"]}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
迭代参数放在 config/l05_creative.json:4 轮、每轮 6 条、保留前 2 名、整轮疲劳每轮扣 60‰。素材 CTR 与投放结果是建模假设;真实素材 CTR 需真实业务环境,命令行不可复现(X)。
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"seed": 20261012,
"rngMultiplier": 1103515245,
"rngIncrement": 12345,
"cpmFen": 1600,
"ipmPermille": 140,
"testImpressionsPerCreative": 20000,
"scaleBudgetFen": 3000000,
"rounds": 4,
"creativesPerRound": 6,
"keepTopK": 2,
"fatiguePermillePerRound": 60,
"note": "素材迭代模型参数(公开可观察机制口径的建模假设,非任何渠道后台实测)。baseCtrPermille=主题基线点击率千分比;每轮新素材质量抖动 ±10%、整轮受疲劳衰减 fatiguePermillePerRound 递减;小样本噪声由可播种 LCG 生成。真实素材 CTR 与真实投放结果需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"themes": [
{ "id": "gameplay", "name": "玩法展示", "baseCtrPermille": 26 },
{ "id": "result", "name": "结果爆点", "baseCtrPermille": 32 },
{ "id": "story", "name": "剧情钩子", "baseCtrPermille": 24 },
{ "id": "contrast", "name": "前后对比", "baseCtrPermille": 29 }
]
}
输出解读:迭代把混合 CPI 压下来了
在本机跑 for f in sim/l05_*.py; do /opt/homebrew/bin/python3 "$f" --config config/ --out out/; done,结果如下(同机相对量级,绝对数值不可移植):
| 轮次 | 最佳 CTR‰ | 平均 CTR‰ | 本轮测试花费(分) |
|---|---|---|---|
| 第 1 轮 | 32 | 26 | 192000 |
| 第 2 轮 | 30 | 25 | 192000 |
| 第 3 轮 | 27 | 23 | 192000 |
| 第 4 轮 | 27 | 21 | 192000 |
- 胜出素材来自第 1 轮的「结果爆点」主题,观测 CTR 32‰;
- 迭代组混合 CPI = 373 分,**对照组(只用第一条素材)**混合 CPI = 500 分,相对低约 25%(
cpiDeltaPermille=254); - 同样的总预算(测试花费 768000 分 + 放量 3000000 分)下,迭代组拿到 10079 个安装,对照组 7536 个。
两点值得注意。第一,最佳素材出现在第一轮——这不奇怪,因为疲劳让整轮 CTR 逐轮下降,越往后越难跑出高 CTR;迭代的价值不是「越迭代越好」,而是用多轮候选降低「押错一条」的风险。第二,平均 CTR 从 26‰ 一路掉到 21‰,正是疲劳曲线的形状:产出速度跑不赢衰减,整盘效率就往下走。这两点合起来说明:迭代要持续产出新素材,不能指望「测出赢家就一劳永逸」。
X 层:真实素材表现不可复现
同样划清边界。上面算的是模型侧:给定主题基线 CTR 与疲劳参数,迭代能带来多大改善。而下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的素材 CTR / IPM(同一素材在不同渠道、不同人群、不同时段差异极大);
- 真实的疲劳曲线(衰减速度取决于受众规模与投放强度,平台不公开);
- 真实的小样本噪声分布(2 万曝光估出的 CTR 置信区间有多宽,要真实数据标定);
- 平台的素材审核与合规(夸大承诺、诱导点击会被拒,这条下一节细讲)。
正确的做法和上一节一致:模型层做「如果…会怎样」,真实数据层反标定模型参数。素材测出来真实 CTR,填回 baseCtrPermille,模型立刻反映现实。
自检表
| 检查项 | 合格标准 |
|---|---|
| 素材矩阵 | 主题 × 形式有覆盖,每轮只变一两个维度 |
| 测试预算 | 每条固定小曝光,够统计、不心疼 |
| 淘汰规则 | 留 topK 而非只留 top1,防小样本噪声 |
| 疲劳监控 | 逐轮记录平均 CTR,识别衰减 |
| 产出节奏 | 新素材产出速度跑赢衰减速度 |
| 边界清晰 | 真实素材数据标 X,不写成「实测」 |
素材把 CTR 拉起来,但 CTR 高不等于能赚钱——点进来的人留不留得住,取决于素材承诺的东西游戏里到底有没有。这就是下一节要处理的核心问题:素材-玩法一致性。一条把「装扮」「爆装」吹上天、点进去却发现玩法根本不是那样的素材,CTR 也许最高,D1 留存却会崩,最终把 ROAS 拖垮。
阅读导航:上一节:买量渠道与投放机制 · 下一节:素材-玩法一致性与转化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。