"""
=============================================================================
第 4 讲 · 代码 1：决策树（CART decision trees from scratch）
=============================================================================

对应讲义：lectures/lecture04_trees_ensembles_nn.md 第 2 节

-----------------------------------------------------------------------------
【这个文件要回答的问题】
-----------------------------------------------------------------------------
第 1 讲的模型都是“线性模型 + 手工特征”。决策树换了一条路：不选特征，直接把输入空间切成一块一块的长方形，
每块预测一个常数。它怎样选“切哪里”？为什么这样选？它擅长什么、不擅长什么？

    演示 1：划分标准。为什么用 Gini / entropy，而不用我们真正关心的误分类率？
            (a) 三种不纯度的曲线；(b) 仿照 CS229 notes 的例子：两刀都不改变误分类数，但 Gini / entropy 都看到了进步；
            (c) 这在实践中意味着什么：“口袋”数据上，用误分类率建树，第一刀就卡住了。
    演示 2：深度是复杂度旋钮。深度越大，训练准确率越高（最后到 100%），测试准确率先升后降 —— 单棵树是高方差的模型。
            顺便打印一棵深度 3 的树：这就是“可解释”的含义。
    演示 3：轴对齐的弱点。对角线边界：逻辑回归一条线搞定；树要用很多台阶去逼近，需要多得多的数据。
            把数据旋转 45° 之后，树一刀就够了 —— 树不是旋转不变的。
    演示 4：回归树。叶子预测平均值，切分标准是平方误差；在第 1 讲的正弦数据上，预测是分段常数的台阶函数。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 01_decision_trees.py          # 不到 1 秒

依赖本目录的 tree_models.py、nonlinear_data.py，以及第 1 讲的 linear_models.py、datasets.py。
"""

import numpy as np

from nonlinear_data import (make_diagonal, make_pocket, make_sine, make_wave, rotate45, sine_target,
                            wave_boundary)
from tree_models import IMPURITY, DecisionTree, node_impurity, split_gain
from linear_models import accuracy, add_intercept, fit_logistic_newton, mse, print_header


# =============================================================================
# 演示 1：划分标准 —— misclassification vs Gini vs entropy
# =============================================================================
def demo_split_criteria():
    print_header("演示 1：划分标准 —— 为什么不用误分类率（misclassification）来选切点？")

    print("(a) 二分类时三种不纯度随 p = P(y = 1 | 节点) 的变化（对 p = 0.5 对称，只列 p ≥ 0.5）：\n")
    ps = [0.5, 0.6, 0.7, 0.8, 0.875, 0.9, 1.0]
    print(f"{'p':<12}" + "".join(f"{p:>8.3f}" for p in ps))
    for name in ("misclass", "gini", "entropy"):
        vals = [float(IMPURITY[name](np.array([p, 1.0 - p]))) for p in ps]
        print(f"{name:<12}" + "".join(f"{v:>8.3f}" for v in vals))

    print("\n(b) 父节点 900 个正类、100 个负类。两个候选切分（[正, 负] 计数）：")
    print("    split A：左 [700, 100]、右 [200, 0]        split B：左 [400, 100]、右 [500, 0]")
    print("    表中是 N·I（样本数 × 不纯度）。gain = 父节点的 N·I − 两个孩子的 N·I 之和。\n")
    parent = np.array([900.0, 100.0])
    splits = {"A": np.array([700.0, 100.0]), "B": np.array([400.0, 100.0])}
    print(f"{'criterion':<11}{'parent':>9}{'A: left':>10}{'A: right':>10}{'gain A':>9}"
          f"{'B: left':>10}{'B: right':>10}{'gain B':>9}")
    gains = {}
    for c in ("misclass", "gini", "entropy"):
        row = f"{c:<11}{1000 * float(node_impurity(parent, c)):>9.2f}"
        for s, left in splits.items():
            right = parent - left
            gl = left.sum() * float(node_impurity(left, c))
            gr = right.sum() * float(node_impurity(right, c))
            gains[c, s] = split_gain(parent, left, c)
            row += f"{gl:>10.2f}{gr:>10.2f}{gains[c, s]:>9.2f}"
        print(row)

    print("\n(c) “口袋”数据：x ~ U[0,1]²，正类集中在中心 [0.35, 0.65]² 的小正方形里（里面 90% 是正类，外面 3%）。")
    print("    训练 1000 个点，测试 5000 个点；三种准则都不限制深度，min_samples_leaf = 5。\n")
    rng = np.random.default_rng(11)
    X, y = make_pocket(1000, rng)
    Xt, yt = make_pocket(5000, rng)
    print(f"    正类比例：训练 {y.mean():.3f}，测试 {yt.mean():.3f}；“全部预测负类”的测试准确率 = {1 - yt.mean():.3f}\n")
    print(f"{'criterion':<11}{'leaves':>8}{'depth':>7}{'train acc':>11}{'test acc':>10}{'test recall (y=1)':>19}")
    res = {}
    for c in ("misclass", "gini", "entropy"):
        tree = DecisionTree("classification", c, min_samples_leaf=5).fit(X, y)
        pt = tree.predict(Xt)
        res[c] = (tree.n_leaves, accuracy(yt, pt), float(np.mean(pt[yt == 1])))
        print(f"{c:<11}{tree.n_leaves:>8}{tree.depth:>7}{accuracy(y, tree.predict(X)):>11.3f}{res[c][1]:>10.3f}"
              f"{res[c][2]:>19.3f}")

    print(f"""
观察 Observations：
  · (a) 误分类率在 p ∈ [0.5, 1] 上是一条**直线**（1 − p）；Gini 和 entropy 是严格凹的曲线，在 p = 0.5 附近“鼓起来”。
  · (b) 两个切分的误分类 gain 分别是 {gains['misclass', 'A']:.0f} 和 {gains['misclass', 'B']:.0f}：父节点和所有孩子的多数类都是正类，
    “预测多数类”犯的错永远是那 100 个负类，切不切都一样。但 split B 把一大块纯的正类（500 个）分了出来，
    显然更有用：Gini 的 gain 是 {gains['gini', 'A']:.0f} vs {gains['gini', 'B']:.0f}，entropy 是 {gains['entropy', 'A']:.1f} vs {gains['entropy', 'B']:.1f}。
  · 为什么？孩子的类别比例按样本数加权平均正好是父节点的比例（0.8 × 0.875 + 0.2 × 1.0 = 0.9）。
    对严格凹的 I，Jensen 不等式给出 I(父) > 加权平均的 I(孩子)，只要孩子的比例不完全相同 —— gain 严格为正；
    对分段线性的误分类率，只要孩子们落在直线的同一段上（多数类相同），等号成立，gain = 0。
  · (c) 后果：用误分类率建树，根节点上**每一刀**的 gain 都是 0（任何一刀切出的半边，正类都不是多数），
    树只有 {res['misclass'][0]} 个叶子，把所有点预测成负类：测试准确率 {res['misclass'][1]:.3f}，正类一个都没找到（recall {res['misclass'][2]:.3f}）。
    Gini / entropy 看到“正类在某一半里更集中了”，于是继续切，最终把口袋挖出来：准确率 {res['gini'][1]:.3f} / {res['entropy'][1]:.3f}，
    recall {res['gini'][2]:.3f} / {res['entropy'][2]:.3f}。贪心算法只看一步，所以需要一个“对进步敏感”的准则。
  · In practice Gini and entropy almost always pick the same splits (the results here are close too); Gini avoids
    the log and is the CART / sklearn default. Misclassification is still the right tool for **pruning** and evaluation,
    where the final error rate is exactly what we care about.""")


# =============================================================================
# 演示 2：深度 = 复杂度旋钮
# =============================================================================
def demo_depth():
    print_header("演示 2：树的深度是复杂度旋钮 —— 训练准确率一路涨到 100%，测试准确率先升后降")
    rng = np.random.default_rng(12)
    X, y = make_wave(300, rng, flip=0.1)
    Xt, yt = make_wave(5000, rng, flip=0.1)
    bayes = accuracy(yt, (Xt[:, 1] > wave_boundary(Xt[:, 0])).astype(int))
    print("边界 x₂ = 0.5 + 0.3·sin(2πx₁)，随机翻转 10% 的标签。训练 300 个点，测试 5000 个点。")
    print(f"真实边界本身在测试集上的准确率（Bayes classifier，任何模型的上限）= {bayes:.3f}\n")
    print(f"{'max_depth':>10}{'leaves':>8}{'train acc':>11}{'test acc':>10}")
    rows = []
    for depth in [1, 2, 3, 4, 5, 6, 8, 10, 12, None]:
        tree = DecisionTree("classification", max_depth=depth).fit(X, y)
        tr, te = accuracy(y, tree.predict(X)), accuracy(yt, tree.predict(Xt))
        rows.append((depth, tree.n_leaves, tr, te))
        print(f"{str(depth):>10}{tree.n_leaves:>8}{tr:>11.3f}{te:>10.3f}")
    best = max(rows, key=lambda r: r[3])
    full = rows[-1]

    print(f"\n另一个旋钮 min_samples_leaf（不限深度）：\n")
    print(f"{'min_leaf':>10}{'leaves':>8}{'train acc':>11}{'test acc':>10}")
    for L in [1, 2, 5, 10, 20, 40]:
        tree = DecisionTree("classification", min_samples_leaf=L).fit(X, y)
        print(f"{L:>10}{tree.n_leaves:>8}{accuracy(y, tree.predict(X)):>11.3f}{accuracy(yt, tree.predict(Xt)):>10.3f}")

    tree3 = DecisionTree("classification", max_depth=3).fit(X, y)
    print("\n深度 3 的树（整个模型就是下面这几条规则）：")
    for line in tree3.describe():
        print("    " + line)
    print(f"""
观察 Observations：
  · 训练准确率随深度单调不减，不限深度时达到 {full[2]:.3f}（{full[1]} 个叶子，平均每个叶子 {len(y) / full[1]:.1f} 个点，而且全都是纯的）——
    它把 10% 的翻转标签也“背”了下来，测试准确率只有 {full[3]:.3f}。
  · 测试准确率在 max_depth = {best[0]}（{best[1]} 个叶子）时最高，{best[3]:.3f}，离 Bayes 上限 {bayes:.3f} 还有距离：
    浅的树偏差大（台阶太粗，画不出正弦曲线），深的树方差大（台阶跟着噪声走）。这是第 3 讲 bias–variance 的标准图景。
  · 曲线并不平滑（深度 1 和 2 一样，深度 4~8 几乎持平）：贪心建树是离散的、不稳定的过程，训练集稍有不同，
    选出的切点就可能完全不同。这种“高方差”正是 §3 bagging 要解决的问题。
  · The depth-3 tree can be printed in full and checked rule by rule — which is why trees are popular in medicine
    and finance. But interpretability and accuracy conflict here: the most accurate trees already have dozens of rules.""")


# =============================================================================
# 演示 3：轴对齐的弱点
# =============================================================================
def demo_axis_aligned():
    print_header("演示 3：轴对齐（axis-aligned）的弱点 —— 对角线边界 vs 逻辑回归")
    rng = np.random.default_rng(13)
    Xt, yt = make_diagonal(5000, rng)
    At = add_intercept(Xt)
    print("y = 1{x₂ > x₁}，无噪声。树不限深度（训练集上 100% 准确）；逻辑回归加一点 L2（λ = 1e−4），因为数据线性可分。")
    print("测试集 5000 个点。最后两列：把所有点旋转 45° 之后再训练树（对角线变成竖直线）。\n")
    print(f"{'n_train':>8}{'tree leaves':>13}{'tree acc':>10}{'logistic acc':>14}{'rotated: leaves':>17}{'rotated acc':>13}")
    rows = []
    for n in [30, 100, 300, 1000, 3000]:
        X, y = make_diagonal(n, rng)
        tree = DecisionTree("classification").fit(X, y)
        th, _ = fit_logistic_newton(add_intercept(X), y, l2=1e-4)
        rot = DecisionTree("classification").fit(rotate45(X), y)
        r = (n, tree.n_leaves, accuracy(yt, tree.predict(Xt)), accuracy(yt, (At @ th >= 0).astype(int)),
             rot.n_leaves, accuracy(yt, rot.predict(rotate45(Xt))))
        rows.append(r)
        print(f"{r[0]:>8}{r[1]:>13}{r[2]:>10.3f}{r[3]:>14.3f}{r[4]:>17}{r[5]:>13.3f}")
    print(f"""
观察 Observations：
  · 逻辑回归 30 个点就有 {rows[0][3]:.3f}；树需要 {rows[-1][0]} 个点、{rows[-1][1]} 个叶子才到 {rows[-1][2]:.3f}，仍然不如前者。
    每一刀都必须平行于坐标轴，所以一条斜线只能用台阶去逼近；台阶要细，就要很多叶子，每个叶子又要有数据支撑。
  · 叶子数随 n 增长（{rows[0][1]} → {rows[-1][1]}）：树是 non-parametric 的，数据越多模型越复杂（和第 1 讲的 LWR 一样）。
  · 旋转 45° 后，同样的树只要 {rows[-1][4]} 个叶子（一刀）就到 {rows[-1][5]:.3f}。困难不在于边界复杂，而在于它和坐标轴不对齐：
    树对特征的旋转**不是**不变的。实践中的对策：加入特征的线性组合（比如 x₁ − x₂）、用 oblique trees，
    或者干脆交给能学习特征的模型（§5 的神经网络）。
  · 反过来，当真实边界确实是“阈值规则”（年龄 > 65 且 血压 > 140 ……）、特征又是不同单位的混合时，
    轴对齐恰恰是优点：树不需要 feature scaling，对单调变换（取 log、换单位）完全不变。""")


# =============================================================================
# 演示 4：回归树
# =============================================================================
def demo_regression_tree():
    print_header("演示 4：回归树 —— 叶子预测平均值，切分标准是平方误差（第 1 讲的正弦数据）")
    rng = np.random.default_rng(14)
    x, y = make_sine(50, rng)
    xt, yt = make_sine(2000, rng)
    Xc, Xtc = x[:, None], xt[:, None]                     # 树需要二维输入 (n, 1)
    print("y = sin(2πx) + N(0, 0.3²)，训练 50 个点（和第 1 讲 LWR 的演示一样多），测试 2000 个点。")
    print("“MSE vs f”是预测和真实函数 sin(2πx) 的差距（去掉了噪声方差 0.09）。\n")
    print(f"{'max_depth':>10}{'leaves':>8}{'train MSE':>11}{'test MSE':>10}{'MSE vs f':>10}")
    rows = []
    for depth in [1, 2, 3, 4, 5, 6, 8, None]:
        tree = DecisionTree("regression", max_depth=depth).fit(Xc, y)
        pt = tree.predict(Xtc)
        r = (depth, tree.n_leaves, mse(y, tree.predict(Xc)), mse(yt, pt), mse(sine_target(xt), pt))
        rows.append(r)
        print(f"{str(r[0]):>10}{r[1]:>8}{r[2]:>11.3f}{r[3]:>10.3f}{r[4]:>10.3f}")
    best = min(rows, key=lambda r: r[3])

    tree2 = DecisionTree("regression", max_depth=2).fit(Xc, y)
    print("\n深度 2 的回归树（4 个台阶）：")
    for line in tree2.describe(["x"]):
        print("    " + line)
    print(f"""
观察 Observations：
  · 回归树的每个叶子预测落进来的 y 的平均值，所以模型是**分段常数**（piecewise constant）的台阶函数。
    选切点的准则从 Gini 换成了平方误差和（SSE）：每一刀让“两边各自减去自己的均值后的平方和”最小。
  · 深度 {best[0]}（{best[1]} 个台阶）时测试 MSE 最低（{best[3]:.3f}）；不限深度时每个叶子只有一个点，训练 MSE = {rows[-1][2]:.3f}，
    测试 MSE 回升到 {rows[-1][3]:.3f}。和演示 2 一样的过拟合曲线。
  · 和第 1 讲比较（不同的随机样本，只能粗略比较）：同样 50 个点，LWR 的最好测试 MSE 是 0.099。
    台阶函数去逼近一条光滑曲线，天生吃亏：要么台阶粗（偏差），要么每级台阶只有很少的数据（方差）。
    单棵树很少是最好的模型；它的价值在于做集成的“零件”（§3、§4）。""")


def main():
    demo_split_criteria()
    demo_depth()
    demo_axis_aligned()
    demo_regression_tree()


if __name__ == "__main__":
    main()
