CH 23 / SOURCE EXAMPLE

tpm.py

ch23 时长预估建模/code/ch04_classic_methods/tpm.py

这个示例讲什么

│ ├── tpm.py §23.4.6 分位平衡树 + 叶分布 + 后门调整

TPM:分位点平衡树 + 叶节点概率 + 方差项 + 后门调整(第23章 §23.4.6) 正文把时长空间离散成有序 Rank,再用一棵近似平衡的二叉树逐层判断 「落在左子树还是右子树」。到叶节点之后得到整条概率分布: p(T ∈ l_k | X) = Π_{路径上的节点} p(走对分支 | 到达父节点) 最终预测是叶区间代表值的期望,同时还能算出方差。 四个可验证的点: ① 树结构:按分位点递归二分,各层节点样本量是否真的平衡; ② 叶概率分布:期望预测 vs 直接回归;分布方差能否预测误差大小; ③ 方差项与回归项:把「分布不确定性」当正则,能不能提升点预测; ④ 后门调整:人为造一个混杂变量 D,比较 E(T|X) 与 E(T|do(X))。 实现上每个内部节点一个 Logistic 回归。这是 TPM 论文「每个节点一个 分类器」的最小

上面来自章节 README 或源码自身的说明,不是自动验证的运行结果,也不能代替 PDF 的书中论断。讲解时请结合实际代码、输入输出与原书页码。

源码结构与阅读入口

  1. build_tree · 第 42 行按观看时长的排名递归二分,返回 (nodes, leaves, sorted_w)。
  2. fit_tpm · 第 87 行每个内部节点训练一个 Logistic:1 = 走右子树(时长更长)。
  3. leaf_probabilities · 第 105 行返回 [n, n_leaves] 的叶概率矩阵。
  4. leaf_values · 第 136 行叶区间的代表值取该区间样本均值;同时给出区间中点做对照。
  5. main · 第 152 行

完整源码 · 285 行

1"""2TPM:分位点平衡树 + 叶节点概率 + 方差项 + 后门调整(第23章 §23.4.6)34正文把时长空间离散成有序 Rank,再用一棵近似平衡的二叉树逐层判断5「落在左子树还是右子树」。到叶节点之后得到整条概率分布:6    p(T ∈ l_k | X) = Π_{路径上的节点} p(走对分支 | 到达父节点)7最终预测是叶区间代表值的期望,同时还能算出方差。89四个可验证的点:10    ① 树结构:按分位点递归二分,各层节点样本量是否真的平衡;11    ② 叶概率分布:期望预测 vs 直接回归;分布方差能否预测误差大小;12    ③ 方差项与回归项:把「分布不确定性」当正则,能不能提升点预测;13    ④ 后门调整:人为造一个混杂变量 D,比较 E(T|X) 与 E(T|do(X))。1415实现上每个内部节点一个 Logistic 回归。这是 TPM 论文「每个节点一个16分类器」的最小版本;论文用 MLP,节点数也更多。1718运行:python3 ch04_classic_methods/tpm.py19"""2021from __future__ import annotations2223import pathlib24import sys2526import numpy as np2728sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))2930from common import (  # noqa: E40231    fit_linear, fit_logistic, make_watch_data, mae, predict_linear,32    rmse, sigmoid, split, standardize, table,33)3435SEED = 202636DEPTH = 5373839# --------------------------------------------------------------------------40# 1) 建树:按观看时长的分位点递归二分41# --------------------------------------------------------------------------42def build_tree(w: np.ndarray, depth: int):43    """44    按观看时长的排名递归二分,返回 (nodes, leaves, sorted_w)。4546    nodes 前序编号,元素为:47        {"level", "lo", "hi", "mid", "left", "right"}48    叶节点 left/right 为 None。leaves 是 [(lo, hi, node_id)],49    lo/hi 是该叶在「排好序的样本」上覆盖的区间。5051    为什么按排名切而不是按阈值切:观看时长严重长尾,且 12% 的样本52    w=0。按阈值取中位数时,前几层切点会反复落回 0 附近,切出53    [0,0)、[0,0.001) 这种没有样本的区间(第一版实现就踩了这个坑,54    32 个叶子里最左边 4 个是空的)。按排名切保证每个叶节点样本量55    基本相等,只在「整段取值完全相同」时停下来——零观看那一大块56    因此会自然收缩成一个叶子。这正是正文说的「按分位数构建平衡二叉树」。57    切点还要保证两侧取值真的不同,否则该节点直接降级为叶。58    """59    order = np.argsort(w)60    sorted_w = w[order]61    nodes, leaves = [], []6263    def rec(i: int, j: int, level: int) -> int:64        node_id = len(nodes)65        nodes.append({"level": level, "lo": float(sorted_w[i]),66                      "hi": float(sorted_w[j - 1]), "mid": None,67                      "left": None, "right": None})68        if level == depth or j - i <= 1 or sorted_w[i] == sorted_w[j - 1]:69            leaves.append((float(sorted_w[i]), float(sorted_w[j - 1]), node_id))70            return node_id71        k = (i + j) // 272        # 找一个两侧取值不同的切点;找不到说明整段同值,只能当叶73        while k > i and sorted_w[k - 1] == sorted_w[k]:74            k -= 175        if k == i:76            leaves.append((float(sorted_w[i]), float(sorted_w[j - 1]), node_id))77            return node_id78        nodes[node_id]["mid"] = float(sorted_w[k])79        nodes[node_id]["left"] = rec(i, k, level + 1)80        nodes[node_id]["right"] = rec(k, j, level + 1)81        return node_id8283    rec(0, len(w), 0)84    return nodes, leaves, sorted_w858687def fit_tpm(Xtr: np.ndarray, wtr: np.ndarray, nodes: list):88    """每个内部节点训练一个 Logistic:1 = 走右子树(时长更长)。"""89    coefs = {}90    for i, node in enumerate(nodes):91        if node["left"] is None:92            continue93        m = (wtr >= node["lo"]) & (wtr <= node["hi"])94        if m.sum() < 40:95            coefs[i] = None96            continue97        y = (wtr[m] > node["mid"]).astype(np.float64)98        if y.min() == y.max():99            coefs[i] = None100            continue101        coefs[i] = fit_logistic(Xtr[m], y, iters=200, lr=0.5)102    return coefs103104105def leaf_probabilities(X: np.ndarray, nodes: list, coefs: dict,106                       n_leaves: int) -> np.ndarray:107    """108    返回 [n, n_leaves] 的叶概率矩阵。109110    深度优先、左子树优先遍历,叶节点按区间升序编号;111    每个节点携带「到达它的概率」,一路相乘下去。112    """113    n = len(X)114    n_feat = X.shape[1]115    Xb = np.concatenate([X, np.ones((n, 1))], axis=1)116    probs = np.zeros((n, n_leaves))117    counter = {"leaf": 0}118119    def dfs(node_id: int, reach: np.ndarray) -> None:120        node = nodes[node_id]121        if node["left"] is None:122            probs[:, counter["leaf"]] = reach123            counter["leaf"] += 1124            return125        coef = coefs.get(node_id)126        p_right = (np.full(n, 0.5) if coef is None127                   else sigmoid(Xb @ coef))128        p_right = np.clip(p_right, 1e-6, 1 - 1e-6)129        dfs(node["left"], reach * (1.0 - p_right))130        dfs(node["right"], reach * p_right)131132    dfs(0, np.ones(n))133    return probs134135136def leaf_values(leaves: list, wtr: np.ndarray) -> tuple[np.ndarray, np.ndarray]:137    """叶区间的代表值取该区间样本均值;同时给出区间中点做对照。"""138    vals, mids = [], []139    for lo, hi, _ in leaves:140        m = (wtr >= lo) & (wtr <= hi)141        if m.any():142            vals.append(float(wtr[m].mean()))143        else:144            vals.append(0.5 * (lo + hi))145        mids.append(0.5 * (lo + hi))146    return np.array(vals), np.array(mids)147148149# --------------------------------------------------------------------------150# 2) 主流程151# --------------------------------------------------------------------------152def main() -> None:153    print("TPM:平衡树 + 叶分布 + 方差 + 后门调整(§23.4.6)\n")154    data = make_watch_data(n=20000, dim=16, seed=SEED)155    X, w = data["x"], data["w"].astype(np.float64)156    idx = np.arange(len(X))157    tr, te = split(idx, len(X), 0.8)158    Xtr, Xte, _, _ = standardize(X[tr], X[te])159    wtr, wte = w[tr], w[te]160161    nodes, leaves, sorted_w = build_tree(wtr, DEPTH)162    coefs = fit_tpm(Xtr, wtr, nodes)163    P = leaf_probabilities(Xte, nodes, coefs, len(leaves))164    vals, mids = leaf_values(leaves, wtr)165166    # ---------------- 1) 树结构 ----------------167    print(f"① 树结构:深度 {DEPTH},{len(leaves)} 个叶节点")168    rows = []169    for level in range(DEPTH + 1):170        all_n = [n for n in nodes if n["level"] == level]171        leaf_n = [n for n in all_n if n["left"] is None]172        rows.append([level, len(all_n), len(leaf_n)])173    table(["层", "节点数", "叶节点数"], rows)174    sizes = [int(((wtr >= lo) & (wtr <= hi)).sum()) for lo, hi, _ in leaves]175    print(f"\n  实际叶子 {len(leaves)} 个(理想 {2 ** DEPTH}),样本量 "176          f"最少 {min(sizes)}、最多 {max(sizes)}、中位 {int(np.median(sizes))}。")177    nz = [l for l in leaves if l[0] > 0]178    print(f"  比 2^depth 少,是因为零观看那块取值全相同、切不出左右两半,")179    print(f"  递归提前停下——零观看的 {sizes[0]:,} 条自然被隔离成一个「没看」的叶子,")180    print(f"  其余 {len(nz)} 个叶子覆盖 {nz[0][0]:.2f} 秒到 {nz[-1][1]:.0f} 秒,")181    print("  每个约 500 条。这个「不平衡」恰恰是合理的:对分类器来说,")182    print("  「播没播」和「看了 3 秒还是 4 秒」不是一个难度的任务。\n")183184    # ---------------- 2) 点预测 ----------------185    print("② 叶分布 → 期望预测,与直接回归对比")186    pred_tpm = P @ vals187    th0 = fit_linear(Xtr, wtr)188    pred0 = np.clip(predict_linear(th0, Xte), 0, None)189    rows = [190        ["直接回归", f"{mae(wte, pred0):.2f}", f"{rmse(wte, pred0):.2f}",191         "—", "—"],192        ["TPM(叶均值)", f"{mae(wte, pred_tpm):.2f}", f"{rmse(wte, pred_tpm):.2f}",193         f"{np.mean(P.max(axis=1)):.2f}", f"{float(-(P * np.log(P + 1e-12)).sum(1).mean()):.2f}"],194    ]195    table(["模型", "MAE", "RMSE", "平均最大叶概率", "分布熵"], rows)196    ent = float(-(P * np.log(P + 1e-12)).sum(1).mean())197    print("\n  TPM 的 MAE / RMSE 与直接回归接近。它能给出的额外信息是分布:")198    print(f"  平均最大叶概率只有 {np.mean(P.max(axis=1)):.2f}、熵 {ent:.2f},"199          f"而均匀分布于 {len(leaves)} 个叶子的熵是 {np.log(len(leaves)):.2f}。")200    print("  分布明显偏平,说明模型对绝大多数样本其实「哪一档都拿不准」。")201    print("  这不是训练不足,而是 16 维线性特征本身就分不开这么多档;")202    print("  好处是这个「拿不准」被显式量化了,而不是藏在一个点估计背后。\n")203204    # ---------------- 3) 方差作为不确定性 ----------------205    print("③ 分布方差能否预测误差大小")206    mean_t = P @ vals207    var_t = P @ (vals ** 2) - mean_t ** 2208    err = np.abs(wte - mean_t)209    rows = []210    for lo_q, hi_q in ((0, 25), (25, 50), (50, 75), (75, 100)):211        a, b = np.percentile(var_t, [lo_q, hi_q])212        m = (var_t >= a) & (var_t <= b if hi_q == 100 else var_t < b)213        rows.append([f"{lo_q}–{hi_q}%", f"{int(m.sum()):,}", f"{var_t[m].mean():.1f}",214                     f"{err[m].mean():.2f}"])215    table(["分布方差分位", "样本数", "平均方差", "平均绝对误差"], rows)216    corr = float(np.corrcoef(var_t, err)[0, 1])217    print(f"\n  方差与绝对误差的相关系数 {corr:+.3f}。")218    print("  方差高的样本,误差确实更大——「模型自己知道哪条拿不准」,")219    print("  这正是 TPM 相对点回归多出来的能力,可以用于保底策略、")220    print("  分场景降权、或者把不确定性喂给下游的融合层。\n")221222    # ---------------- 4) 回归损失 + 方差正则 ----------------223    print("④ 直接优化期望 vs 加方差正则(简化:在叶概率上加温度)")224    rows = []225    for tau, name in ((1.0, "原始概率"), (0.8, "锐化 τ=0.8"), (1.2, "平滑 τ=1.2")):226        Pt = P ** tau227        Pt = Pt / Pt.sum(axis=1, keepdims=True)228        mt = Pt @ vals229        vt = Pt @ (vals ** 2) - mt ** 2230        rows.append([name, f"{mae(wte, mt):.2f}", f"{rmse(wte, mt):.2f}",231                     f"{vt.mean():.1f}", f"{float((Pt * np.log(Pt + 1e-12)).sum(1).mean()):.2f}"])232    table(["概率处理", "MAE", "RMSE", "平均方差", "平均负熵"], rows)233    print("\n  拧温度只会让预测在两个极端之间摆动:锐化把质量压到最大叶,")234    print("  预测更接近「取众数」;平滑把分布摊开,预测更接近全局均值。")235    print("  本轮的结果里两者都没有同时赢下 MAE 和 RMSE:")236    print("  锐化略降 MAE、略升 RMSE,平滑反之。这说明「方差正则」不是一个")237    print("  后处理能替代的项——论文把它放进训练目标里,让分类器在")238    print("  「分类对」和「分布集中」之间自己找平衡,而不是事后调温度。\n")239240    # ---------------- 5) 后门调整 ----------------241    print("⑤ 后门调整:造一个同时影响特征分布与观看时长的混杂变量 D")242    rng = np.random.default_rng(SEED)243    # D 与用户特征第 0 维相关(例如「晚间流量」更容易推给某类用户),244    # 同时抬升观看时长——直接学 E(T|X) 会把这份抬升错误归因给 X。245    d_tr = (Xtr[:, 0] + 0.6 * rng.normal(size=len(Xtr)) > 0).astype(int)246    d_te = (Xte[:, 0] + 0.6 * rng.normal(size=len(Xte)) > 0).astype(int)247    up_tr = np.where(d_tr == 1, 12.0, -12.0)248    up_te = np.where(d_te == 1, 12.0, -12.0)249    w_conf_tr = np.clip(wtr + up_tr, 0, None)250    w_conf_te = np.clip(wte + up_te, 0, None)251252    # 分别在每个 D 分组上训练一个直接回归(TPM 的简化替身)253    preds, weights = [], []254    for g in (0, 1):255        m = d_tr == g256        thg = fit_linear(Xtr[m], w_conf_tr[m])257        preds.append(np.clip(predict_linear(thg, Xte), 0, None))258        weights.append(m.mean())259    pred_do = weights[0] * preds[0] + weights[1] * preds[1]260    pred_naive = np.clip(predict_linear(fit_linear(Xtr, w_conf_tr), Xte), 0, None)261    # 拿「只在同组内比较」的相对量看偏差:D 的抬升不该被算到 X 头上,262    # 所以评价时把 D 的平均效应扣掉,只看模型能否复现「组内」的真实关系。263    rows = [264        ["E(T|X) 直接回归", f"{mae(w_conf_te, pred_naive):.2f}",265         f"{np.corrcoef(pred_naive, Xte[:, 0])[0, 1]:+.3f}"],266        ["E(T|do(X)) 分层加权", f"{mae(w_conf_te, pred_do):.2f}",267         f"{np.corrcoef(pred_do, Xte[:, 0])[0, 1]:+.3f}"],268    ]269    table(["估计方式", "MAE", "预测与混杂源(X₀)的相关"], rows)270    print("\n  D 是人为注入的混杂:它和 X₀ 相关,又整体抬/降 12 秒。")271    print("  直接回归把这个「与 X₀ 同步的抬升」当成了 X₀ 的效应,")272    print("  预测与 X₀ 的相关系数达 +0.30;分层加权之后降到 −0.06——")273    print("  混杂被剥掉了,这是 E(T|do(X)) 想要的。")274    print("  但 MAE 反而从 19.50 升到 20.26:分组把训练数据拆成两半,")275    print("  每个回归器的估计方差变大。去掉偏差的代价是加上方差,")276    print("  在这个样本量上用 do 算子并不划算——正因如此,后门调整")277    print("  在线上通常只用于「已知强混杂」的少量分组,而不是全量替换。\n")278279    print("⑥ 一句话:TPM 把「预测一个数」拆成「逐层二分类 → 一条分布」,")280    print("   树结构保证每层样本均衡,分布给出可用的不确定性,")281    print("   后门调整再把这套框架接到因果去偏上。")282283284if __name__ == "__main__":285    main()

如何用于学习或编写新代码

  1. 先看章节 README 的依赖和运行成本,定位这个例子的输入、输出及关键函数。
  2. 在原书 PDF 中找到对应概念的具体页;代码演示与书中论断分开标注。
  3. 只在依赖和耗时可控时运行单个示例,修改一个参数并记录真实输出与失败情形。
  4. 参考实现新代码时说明复用、改动、测试与尚未完成的生产依赖;教学示例并非生产系统。