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 的书中论断。讲解时请结合实际代码、输入输出与原书页码。
源码结构与阅读入口
build_tree· 第 42 行按观看时长的排名递归二分,返回 (nodes, leaves, sorted_w)。fit_tpm· 第 87 行每个内部节点训练一个 Logistic:1 = 走右子树(时长更长)。leaf_probabilities· 第 105 行返回 [n, n_leaves] 的叶概率矩阵。leaf_values· 第 136 行叶区间的代表值取该区间样本均值;同时给出区间中点做对照。main· 第 152 行
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()如何用于学习或编写新代码
- 先看章节 README 的依赖和运行成本,定位这个例子的输入、输出及关键函数。
- 在原书 PDF 中找到对应概念的具体页;代码演示与书中论断分开标注。
- 只在依赖和耗时可控时运行单个示例,修改一个参数并记录真实输出与失败情形。
- 参考实现新代码时说明复用、改动、测试与尚未完成的生产依赖;教学示例并非生产系统。