1.1 账户、计划、广告组与创意层级

投放后台的四层层级——账户、计划、广告组、创意——决定了预算沿树往下摊到哪一层、摊到哪个粒度就不够进学习期。本节把层级结构讲清,用可跑的 Python 仿真把账户日预算按树整除分摊,再配 TypeScript 内核跨语言对拍,让「结构」也能算账。

卷三的目录 已经把「怎么把用户买进来」这件事拆过一遍:渠道机制、素材设计框架、增长预算分配都在那 51 篇里。本节不重做那套框架,只做一件卷三没有落到后台细节的事——投放后台本身的四层层级结构。卷三告诉你「该买什么样的量」,本节告诉你「这些钱在后台是怎么被一层层挂上去、又怎么沿树往下摊的」。两者是「策略」与「结构」的分工,不重复。

一个新手打开投放后台,看到的第一样东西往往不是数据,而是一棵树:最上面是账户,往下是计划,再往下是广告组,最底下是创意。这四个词听起来像行政层级,但它们真正的含义是预算与定向的作用域:钱挂在哪一层,定向设在哪一层,素材换在哪一层,各自能改什么、不能改什么,全由这棵树决定。把树理解错,后面所有优化都会踩空。

站内还有一篇小游戏行业与团队的观察 ,那是从组织与协作角度看这门生意。本节是纯工程视角:不聊团队怎么分工,只聊投放后台这棵树的每个节点各自承担什么、预算怎么往下分。

四层层级各管什么

把四个层级摆在一起,每一层的作用域和颗粒度就清楚了。关键认知是:预算的挂载点与定向的挂载点常常不在同一层,这正是新手最容易混淆的地方。

层级管什么颗粒度预算通常挂在哪改动代价
账户资质、结算、总预算上限、投放主体全局日预算上限最高,动它等于动整盘
计划(Campaign)投放目标、优化目标、总预算、排期目标维度计划日预算高,改目标会重置学习
广告组(Ad Group)定向、出价、版位、单组预算人群/出价维度广告组日预算中,改定向/出价触发重新学习
创意(Creative)素材本身:视频/图片/文案/落地页素材维度不单独挂预算低,换素材最安全

一个常见的误解是「创意也能单独设预算」。绝大多数后台里创意不挂预算——它只是广告组下可替换的素材槽位,预算在广告组层面统一花。所以想让某个素材多花钱,靠的不是给素材加预算,而是把它放进一个预算更充足的广告组。这就是为什么「素材测试」在结构上其实是「广告组测试」。

另一条规律:越往上层动,代价越大。改账户资质要重新审核;改计划目标会让系统重新进入学习期;改广告组定向同样重置学习;唯独换创意代价最低,随时可换。层级结构本质上是一张「改动代价表」,它告诉你优化应该优先在哪一层动手。

预算沿树怎么摊

既然预算挂在计划与广告组两层,那么「账户的总预算如何落到每个广告组」就是一道可算的题。用整数公式表达,一切都清楚了(预算单位统一用「分」,避免浮点):

每广告组日预算 = 计划日预算 // 广告组数
每创意日预算   = 每广告组日预算 // 创意数
计划残差       = 计划日预算 - 每广告组日预算 × 广告组数
账户残差       = 账户日预算 - Σ 各计划日预算
是否进学习期   = 每广告组日预算 ≥ 学习期门槛

注意这里用的是地板除:整除不尽的部分不会凭空消失,它变成「残差」留在上一层,而不会自动补给下一层。这看起来是个小细节,但它解释了一个真实现象——为什么「平均分预算」常常让某些广告组差一点点就够不到学习期门槛:残差被截断了。

S 层仿真:把账户预算沿树摊下去

下面这段是 S 层仿真脚本 sim/o01_hierarchy.py,是完整可运行文件。它读 config/o01_hierarchy.json 的三层计划结构,按整数公式把账户日预算一路摊到广告组与创意,标出哪些广告组够得到学习期门槛,并产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/o01_hierarchy.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o01_hierarchy.py --config config/ --out out/
"""投放账户四层层级(账户 → 计划 → 广告组 → 创意)的预算分摊仿真。

对 config/o01_hierarchy.json 的每个计划:

    perAdGroupFen  = campaign.dailyBudgetFen // nAdGroups     # 每广告组日预算(分)
    perCreativeFen = perAdGroupFen // nCreatives              # 每创意日预算(分)
    aboveFloor     = perAdGroupFen >= minAdGroupBudgetFen      # 是否够进学习期

层级本身是结构(D),但「预算怎么沿树往下摊、摊到哪一层就不够进学习期」是
可算的量。全程整数、地板除,无浮点。产出 out/o01_hierarchy.json + .csv + .svg。

层级字段名、平台限额与学习期门槛属「公开可观察机制」口径的建模假设;真实后台
字段与限额需真实业务环境,命令行不可复现(X)。
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET


def evaluate(cfg: dict):
    floor = cfg["minAdGroupBudgetFen"]
    campaigns, adgroups = [], []
    for c in cfg["campaigns"]:
        ags = c["adGroups"]
        n_ag = len(ags)
        per_ag = c["dailyBudgetFen"] // n_ag
        n_cr_total = 0
        for g in ags:
            n_cr = len(g["creatives"])
            n_cr_total += n_cr
            per_cr = per_ag // n_cr
            adgroups.append({
                "campaignId": c["id"], "id": g["id"], "name": g["name"],
                "budgetFen": per_ag, "nCreatives": n_cr, "perCreativeFen": per_cr,
                "aboveFloor": per_ag >= floor,
            })
        campaigns.append({
            "id": c["id"], "name": c["name"], "budgetFen": c["dailyBudgetFen"],
            "nAdGroups": n_ag, "nCreatives": n_cr_total, "perAdGroupFen": per_ag,
            "allocatedFen": per_ag * n_ag,
            "residualFen": c["dailyBudgetFen"] - per_ag * n_ag,
        })
    return campaigns, adgroups


def digest(adgroups: list[dict]) -> str:
    return ";".join(
        f'{g["id"]}:{g["budgetFen"]}:{g["nCreatives"]}:{g["perCreativeFen"]}'
        f':{1 if g["aboveFloor"] else 0}' for g in adgroups)


def draw_svg(path: str, adgroups: list[dict], floor: int) -> int:
    """纯 Python SVG:每个广告组一根横条(日预算分),画一条学习期门槛虚线。"""
    W, PAD, ROW = 760, 210, 34
    H = 64 + ROW * len(adgroups)
    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 = (
        "《合成小岛》各广告组日预算分摊(分)· 虚线=学习期门槛")
    span = W - PAD - 24
    hi = max([g["budgetFen"] for g in adgroups] + [floor]) or 1
    xf = PAD + span * floor // hi
    ET.SubElement(root, "line", {"x1": str(xf), "y1": "44", "x2": str(xf), "y2": str(H - 16),
                                 "stroke": "#ef4444", "stroke-dasharray": "4 4"})
    ET.SubElement(root, "text", {"x": str(xf + 4), "y": "58", "font-size": "11",
                                 "fill": "#ef4444"}).text = f"门槛 {floor}"
    for i, g in enumerate(adgroups):
        y = 56 + i * ROW
        w = span * g["budgetFen"] // hi
        color = "#2563eb" if g["aboveFloor"] else "#f59e0b"
        ET.SubElement(root, "text", {"x": "16", "y": str(y + 16), "font-size": "11",
                                     "fill": "#374151"}).text = g["id"]
        ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 4), "width": str(w),
                                     "height": "18", "rx": "3", "fill": color})
        ET.SubElement(root, "text", {"x": str(PAD + w + 6), "y": str(y + 17), "font-size": "11",
                                     "fill": "#6b7280"}).text = (
            f'{g["budgetFen"]} 分 · {g["nCreatives"]} 创意 · 每创意 {g["perCreativeFen"]} 分')
    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, "o01_hierarchy.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    campaigns, adgroups = evaluate(cfg)

    total_campaign_fen = sum(c["budgetFen"] for c in campaigns)
    total_allocated_fen = sum(c["allocatedFen"] for c in campaigns)
    account_residual_fen = cfg["accountDailyBudgetFen"] - total_campaign_fen
    above = sum(1 for g in adgroups if g["aboveFloor"])
    dg = digest(adgroups)

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "o01_hierarchy.svg"), adgroups,
                         cfg["minAdGroupBudgetFen"])
    report = {
        "version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
        "accountName": cfg["accountName"], "accountDailyBudgetFen": cfg["accountDailyBudgetFen"],
        "minAdGroupBudgetFen": cfg["minAdGroupBudgetFen"],
        "campaignCount": len(campaigns), "adGroupCount": len(adgroups),
        "creativeCount": sum(g["nCreatives"] for g in adgroups),
        "adGroupsAboveFloor": above,
        "totalCampaignFen": total_campaign_fen, "totalAllocatedFen": total_allocated_fen,
        "accountResidualFen": account_residual_fen,
        "campaigns": campaigns, "adGroups": adgroups, "digest": dg,
        "svg": os.path.join(args.out, "o01_hierarchy.svg"), "svgBytes": svg_bytes,
    }
    with open(os.path.join(args.out, "o01_hierarchy.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "o01_hierarchy.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(("campaignId", "id", "name", "budgetFen", "nCreatives",
                    "perCreativeFen", "aboveFloor"))
        for g in adgroups:
            w.writerow([g["campaignId"], g["id"], g["name"], g["budgetFen"],
                        g["nCreatives"], g["perCreativeFen"], g["aboveFloor"]])

    print(json.dumps({
        "campaignCount": report["campaignCount"], "adGroupCount": report["adGroupCount"],
        "creativeCount": report["creativeCount"], "adGroupsAboveFloor": above,
        "accountResidualFen": account_residual_fen,
        "perAdGroupFen": [g["budgetFen"] for g in adgroups], "digest": dg,
    }, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

层级结构与门槛参数放在 config/o01_hierarchy.json——注意这里的数字是「公开可观察机制」口径的建模假设,不是任何投放后台的实测:真实字段名、真实限额与真实学习期规则属于 X 层,需真实业务环境,命令行不可复现。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "accountName": "merge-island-main",
  "accountDailyBudgetFen": 300000,
  "minAdGroupBudgetFen": 20000,
  "note": "投放账户四层层级(账户/计划/广告组/创意)的预算分摊参数,口径是「公开可观察机制」的建模假设,不是任何投放后台实测。accountDailyBudgetFen=账户日预算(分);minAdGroupBudgetFen=单广告组进学习期的日预算下限(分)。真实后台字段名、限额与学习期规则由平台定义,需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "campaigns": [
    { "id": "c1", "name": "合成-信息流-冷启动", "dailyBudgetFen": 120000, "adGroups": [
      { "id": "g11", "name": "合并爽感-视频", "creatives": [ { "id": "cr11", "name": "连锁合并15s" }, { "id": "cr12", "name": "失败重试8s" } ] },
      { "id": "g12", "name": "装扮解锁-视频", "creatives": [ { "id": "cr13", "name": "装扮解锁10s" } ] } ] },
    { "id": "c2", "name": "合成-激励视频-放量", "dailyBudgetFen": 100000, "adGroups": [
      { "id": "g21", "name": "离线收益-视频", "creatives": [ { "id": "cr21", "name": "离线翻倍12s" }, { "id": "cr22", "name": "挂机爆产6s" } ] },
      { "id": "g22", "name": "关卡挑战-视频", "creatives": [ { "id": "cr23", "name": "限时关卡9s" }, { "id": "cr24", "name": "三星通关7s" } ] },
      { "id": "g23", "name": "节日皮肤-图片", "creatives": [ { "id": "cr25", "name": "节日皮肤静态" } ] } ] },
    { "id": "c3", "name": "合成-联盟-补充", "dailyBudgetFen": 50000, "adGroups": [
      { "id": "g31", "name": "通用-图片", "creatives": [ { "id": "cr31", "name": "主视觉A" }, { "id": "cr32", "name": "主视觉B" }, { "id": "cr33", "name": "主视觉C" } ] } ] }
  ]
}

在本机跑 /opt/homebrew/bin/python3 sim/o01_hierarchy.py --config config/ --out out/,摊下来的结果(同机相对量级,绝对数值不可移植):

广告组所属计划日预算(分)创意数每创意(分)过门槛
g11 合并爽感c160000230000是
g12 装扮解锁c160000160000是
g21 离线收益c233333216666是
g22 关卡挑战c233333216666是
g23 节日皮肤c233333133333是
g31 通用c350000316666是

这张表暴露了两个结构问题。第一,计划的广告组数直接稀释了单组预算:c1 只有 2 个组,每组能拿 60000;c2 有 3 个组,每组只剩 33333——同样的计划预算,组越多每组越薄。第二,账户残差 30000 分被闲置:账户日预算 300000,三个计划预算之和只有 270000,差额 30000 既没挂到计划也没进学习,是一笔沉默的空转预算。模型不替你做这个决定,它只把残差如实报出来。

P 层内核:跨语言对拍,证明整除口径没写错

光看一张表,你没法确认「是结构对」还是「碰巧算对」。本节用一份 P 层 TypeScript 内核 core/src/o01/hierarchy.ts 复算同一棵树:同样的地板除、同样的门槛判定、同样的残差口径。它读同一份 config/o01_hierarchy.json,产出和 Python 逐字符一致的摘要。

// file: core/src/o01/hierarchy.ts
// tier: P
// verify: node --test 'core/test/o01_hierarchy.test.ts'
//
// 账户四层层级预算分摊内核:与 sim/o01_hierarchy.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/o01_hierarchy.json;全整数、地板除、无浮点;只用可擦除语法(禁 enum / namespace)。

export interface Creative { readonly id: string; readonly name: string; }
export interface AdGroup { readonly id: string; readonly name: string; readonly creatives: readonly Creative[]; }
export interface Campaign { readonly id: string; readonly name: string; readonly dailyBudgetFen: number; readonly adGroups: readonly AdGroup[]; }
export interface HierarchyConfig {
  readonly version: number; readonly game: string; readonly accountDailyBudgetFen: number;
  readonly minAdGroupBudgetFen: number; readonly campaigns: readonly Campaign[];
}
export interface CampaignResult {
  readonly id: string; readonly name: string; readonly budgetFen: number;
  readonly nAdGroups: number; readonly nCreatives: number; readonly perAdGroupFen: number;
  readonly allocatedFen: number; readonly residualFen: number;
}
export interface AdGroupResult {
  readonly campaignId: string; readonly id: string; readonly name: string;
  readonly budgetFen: number; readonly nCreatives: number; readonly perCreativeFen: number;
  readonly aboveFloor: boolean;
}

export function evaluate(cfg: HierarchyConfig): { campaigns: CampaignResult[]; adGroups: AdGroupResult[] } {
  const floor = cfg.minAdGroupBudgetFen;
  const campaigns: CampaignResult[] = [];
  const adGroups: AdGroupResult[] = [];
  for (const c of cfg.campaigns) {
    const nAg = c.adGroups.length;
    const perAg = Math.floor(c.dailyBudgetFen / nAg);
    let nCrTotal = 0;
    for (const g of c.adGroups) {
      const nCr = g.creatives.length;
      nCrTotal += nCr;
      adGroups.push({
        campaignId: c.id, id: g.id, name: g.name, budgetFen: perAg, nCreatives: nCr,
        perCreativeFen: Math.floor(perAg / nCr), aboveFloor: perAg >= floor,
      });
    }
    campaigns.push({
      id: c.id, name: c.name, budgetFen: c.dailyBudgetFen, nAdGroups: nAg, nCreatives: nCrTotal,
      perAdGroupFen: perAg, allocatedFen: perAg * nAg, residualFen: c.dailyBudgetFen - perAg * nAg,
    });
  }
  return { campaigns, adGroups };
}

/** 跨语言对拍摘要:与 sim/o01_hierarchy.py 的 digest 逐字符一致。 */
export function digest(adGroups: readonly AdGroupResult[]): string {
  return adGroups.map((g) => `${g.id}:${g.budgetFen}:${g.nCreatives}:${g.perCreativeFen}:`
    + `${g.aboveFloor ? 1 : 0}`).join(";");
}

配套测试 core/test/o01_hierarchy.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等,并顺手验整除、门槛与残差。在本机跑 node --test 'core/test/o01_hierarchy.test.ts',4 个用例全过;两套独立实现给出同一个摘要:

digest = g11:60000:2:30000:1;g12:60000:1:60000:1;g21:33333:2:16666:1;g22:33333:2:16666:1;g23:33333:1:33333:1;g31:50000:3:16666:1

只要有一边的整数语义、门槛方向或残差口径写错,摘要立刻分叉。这和卷二「一份逻辑、两种宿主」 是同一个信念:同一个数,在 Python 仿真与 TS 内核里必须长得一模一样。

X 层:真实后台结构不可复现

必须把边界划清。上面算的是模型侧:给定一棵树和一组预算,钱怎么摊、谁够门槛。而下面这些属于 X 层,需真实业务环境,命令行不可复现:真实后台的字段命名与层级深度(不同平台层数与命名各异);真实的账户/计划/广告组限额(单账户日预算上限、单计划最小预算、单组最大预算);真实的学习期门槛(需要多少次转化才「跑稳」,由平台算法定义且常变);真实的审核与资质约束(改账户级配置要重审)。

工程上正确的做法是:把可复现的「结构模型」与不可复现的「平台规则」分开——结构模型用来做「如果这样分预算会怎样」的推演,平台规则靠真实后台去核对。这也是为什么下一节要把「出价方式」单独拎出来:出价挂在广告组层,是这棵树上改动代价中等、但对成本影响最直接的一层。

自检表

检查项合格标准
层级清晰账户/计划/广告组/创意四层各自的作用域写在案上
预算挂载点明确知道预算挂在计划与广告组,创意不单独挂预算
整除口径统一每层预算用地板除,残差如实报出,不偷偷补下传
门槛可判定每个广告组都标了是否够到学习期门槛
改动代价可见越上层改动代价越大,优化优先从低代价层动手
边界清晰真实后台字段与限额标 X,不写成「实测」

层级是「静态结构」,但结构决定了钱怎么花。真正让成本动起来的是下一层的选择:同一批广告组,用 oCPM 还是 CPA,钱会流向完全不同的组。下一节我们把出价方式折叠成同一把尺子,看预算该分给谁。

阅读导航:上一节:目录 · 下一节:1.2 出价方式与预算分配 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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