DecisionTree决策树
配合 `q2_part1.py` 中的双路径浅层决策树代码食用
配合
q2_part1.py中的双路径浅层决策树代码,基于 CUMCM 2017 高血脂风险预测问题。
1. 这个模型要解决什么目的?
1.1 问题背景(我们的题目)
我们有 1000 名中老年研究对象的数据,每人都记录了:
| 数据维度 | 具体内容 | 变量名 |
|---|---|---|
| 血脂四项 | 总胆固醇(TC)、甘油三酯(TG)、低/高密度脂蛋白(LDL-C/HDL-C) | → 算成 S_L |
| 活动能力 | 10 项 ADL/IADL 日常活动能力评分(0~10分) | → 算成 S_A |
| 中医体质 | 痰湿质积分(0~65分) | → T |
| 基本信息 | 年龄、性别、吸烟、饮酒 | → 算成 S_B |
| 目标变量 | 是否患高血脂症(0=未患病207人, 1=患病793人) | → y |
我们的任务是:用这四个维度的信息,预测一个人的高血脂患病风险,并判断风险属于低/中/高哪一档。
、
1.2 为什么用”Ridge-Logistic + 决策树”双模型?
整个 q2_part1.py 的架构是两阶段互补:
阶段一:Ridge-Logistic 回归
→ 回答"哪些因素影响风险?影响多大?"
→ 输出:系数 β、优势比 OR、显著性
阶段二:双路径决策树
→ 回答"怎么把人分成低/中/高风险组?"
→ 输出:可解释的 if-else 规则、每层患病率
| 问题类型 | Ridge-Logistic | 决策树 |
|---|---|---|
| ”血脂升高一个单位,风险变多少?“ | 擅长(系数 β) | 不擅长 |
| ”什么样的人属于高风险?“ | 不直观 | 擅长(规则) |
| 统计检验(p值、置信区间) | 有 | 无 |
| 自动发现特征交互 | 需手动构造交互项 | 自动 |
| 非线性分段决策 | 需手动构造 | 天然支持 |
1.3 决策树在这个问题里的具体目标
目标:把 1000 个人按高血脂风险分成低/中/高三层,每层给出明确的划分规则和患病概率。
最终输出长这样:
低风险: S_L = 0 的人 → 平滑患病率 4.5%
中风险: 0 < S_L <= 0.15 → 平滑患病率 64.3%
高风险: S_L > 0.15 → 平滑患病率 89.8%
这样医生拿到一个新病人,算出 S_L 就知道他属于哪一档。
2. 分裂的标准和目标是什么?
2.1 一句话
每次分裂的目标:选一个 (特征, 切点),让分出来的左右两堆内部尽可能”纯”——同一堆里的人患病状态尽量一致。
2.2 什么叫”纯”?
假设一个节点里有 100 个人:
| 情况 | 患病(1) | 未患病(0) | 纯度 | Gini |
|---|---|---|---|---|
| A | 100 | 0 | 最纯(全是病人) | 0.00 |
| B | 0 | 100 | 最纯(全健康) | 0.00 |
| C | 50 | 50 | 最不纯(一半一半) | 0.50 |
| D | 79 | 21 | 较不纯(接近总体比例) | 0.33 |
我们题目的总体患病率是 79.3%,所以如果不做任何分裂,一个节点里自然就有 79% 的病人和 21% 的健康人,Gini ≈ 0.33。决策树的目标就是通过不断分裂,让某些叶子节点比 79.3% 更高(高风险组),某些更低(低风险组)。
2.3 数学公式
Gini 不纯度(sklearn 默认标准):
Gini=1−p12−p02=2p(1−p)其中 p 是该节点中患病的比例。p=0 或 p=1 时 Gini=0(最纯),p=0.5 时 Gini=0.5(最不纯)。
分裂的加权 Gini:
Ginisplit=nnL⋅GiniL+nnR⋅GiniR-
n = 当前节点总人数
-
n_L, n_R = 分到左边、右边的人数
-
Gini_L, Gini_R = 左边、右边的 Gini
算法做的事情:遍历所有特征、所有可能的切点,算 Gini_split,选最小的那个。
2.4 用我们题目数据举例
分裂前(根节点,n=1000):
患病 793 人, 未患病 207 人
p = 0.793
Gini = 2 × 0.793 × (1-0.793) = 0.328
候选切点 1: S_L <= 0.00(即血脂得分=0 vs >0)
左 (S_L=0, n=165):
病人比例 p = 4/165 = 0.024
Gini_L = 2 × 0.024 × 0.976 = 0.047 ← 很纯!
右 (S_L>0, n=835):
病人比例 p = 789/835 = 0.945
Gini_R = 2 × 0.945 × 0.055 = 0.104 ← 也很纯!
Gini_split = (165/1000)×0.047 + (835/1000)×0.104 = 0.095
候选切点 2: S_L <= 0.36(任意另一个值)
…(计算过程同上,Gini 会更大)
结论: S_L=0 是最佳切点,因为它把人群干净地分成了”几乎不患病”和”几乎都患病”两堆。
这就是我们的脚本搜到 c_L = 0.00 作为最优切点的原因——S_L=0 意味着四项血脂指标全都在正常范围内,这些人确实几乎不患高血脂。
3. 代码核心逻辑拆解
3.1 双路径树整体流程
# ===== 第1步:搜索最优入口切点 c_L =====
candidates = np.percentile(S_L_raw, np.arange(15, 86, 5))
# 在 S_L 的 15%~85% 分位数中尝试切点
for c_L in candidates:
mask_L = S_L_raw <= c_L # 血脂较低组
mask_H = ~mask_L # 血脂较高组
# 在两组内各自训练一棵 max_depth=2 的树
tree_L = DecisionTreeClassifier(max_depth=2, min_samples_leaf=30)
tree_L.fit(tree_X[mask_L, 1:], y[mask_L]) # 用 T, S_A, S_B
tree_H = DecisionTreeClassifier(max_depth=2, min_samples_leaf=30)
tree_H.fit(tree_X[mask_H], y[mask_H]) # 用 S_L, T, S_A, S_B
# 计算两棵树的加权平均 Gini
imp_total = (n_L * imp_L + n_H * imp_H) / (n_L + n_H)
# 选 Gini 最小的 c_L
if imp_total < best_impurity:
best_c_L = c_L
# ===== 第2步:用最优 c_L 训练最终树 =====
# c_L = 0.00 被选中(意味着"血脂完全正常"vs"有任何指标异常")
3.2 树是如何从数据中”学”到规则的
以我们跑出的 Path_H 树为例(n=835 人,S_L>0):
原始输出:
|--- S_L <= 0.36
| |--- S_L <= 0.15
| | |--- class: 1 ← 叶节点1: S_L在(0, 0.15]
| |--- S_L > 0.15
| | |--- class: 1 ← 叶节点2: S_L在(0.15, 0.36]
|--- S_L > 0.36
| |--- class: 1 ← 叶节点3: S_L > 0.36
用我们题目的语言翻译:
如果血脂得分 S_L > 0(即至少有一项血脂指标异常),那么:
├─ S_L ≤ 0.15(轻度异常) → 高血脂风险:高 (89%)
├─ 0.15 < S_L ≤ 0.36(中度异常)→ 高血脂风险:高 (89%)
└─ S_L > 0.36(重度异常) → 高血脂风险:高 (89%)
3.3 关键参数解释
tree_L = DecisionTreeClassifier(
max_depth=2, # 树最多2层深,防止过拟合
min_samples_leaf=30, # 每个叶子至少30人,防止过于细碎的规则
random_state=42 # 固定随机种子,保证结果可复现
)
| 参数 | 本代码取值 | 为什么这样设 |
|---|---|---|
max_depth | 2 | 我们要的是”粗粒度的分层规则”,不是高精度预测器。2层足够分出3~4组 |
min_samples_leaf | 30 | 每组至少30人,保证统计上有意义 |
criterion | 默认'gini' | Gini 计算比 Entropy 快,实践中差别很小 |
如果不限制深度,树会一直分下去变成这样(过拟合):
S_L <= 0.36
├─ S_A <= 12.5
│ ├─ T <= 8.0
│ │ ├─ S_B <= 72.3
│ │ │ ├─ ... 没完没了 ...
这样的树在训练集上完美,但在新病人身上毫无泛化能力。
3.4 叶子节点合并为低/中/高三层
树输出的是多个叶子节点(每片叶子有自己的患病率),我们还要把它们合并成有意义的三个风险等级:
# 每片叶子用"平滑患病率"(Laplace平滑, m=10)
r_smooth = (d_g + 10 * r_bar) / (n_g + 10)
# d_g = 叶子里的患病人数
# n_g = 叶子里的总人数
# r_bar = 总体患病率 79.3%
# 平滑的意义:叶子人数太少时,向总体均值"拉回",更稳健
# 然后遍历所有合并方式,找最优的两个切点
# 使低<中<高(单调性)且 LogLoss 最小
实际跑出的结果:
低风险: S_L = 0 → n=165, 平滑患病率 = 4.5%
中风险: S_L 很小 → n=77, 平滑患病率 = 64.3%
高风险: S_L > 0.15 → n=758, 平滑患病率 = 89.8%
3.5 完整特征工程管道(防泄漏)
决策树之前的特征构造也很关键,所有标准化参数只在训练集上算:
def build_features(train_idx, val_idx=None):
# 1. S_B 子模型:在训练集上拟合基本信息→患病概率
model_b = LogisticRegression(penalty=None, max_iter=5000)
model_b.fit(X_basic[train_idx], y[train_idx])
S_B = 100 * model_b.predict_proba(X_basic)[:, 1]
# 2. 四特征合并
raw = np.column_stack([S_L_raw, S_A_raw, T_raw, S_B])
# 3. S_L 上侧 99% 缩尾(防极端值)
upper = np.percentile(raw[train_idx, 0], 99)
raw[:, 0] = np.clip(raw[:, 0], None, upper)
# 4. Z-score 标准化(参数从 train 算)
scaler = StandardScaler()
scaler.fit(raw[train_idx])
Z = scaler.transform(raw)
# 5. 交互项
X = np.column_stack([Z, Z[:,0]*Z[:,2], Z[:,0]*Z[:,1], Z[:,2]*Z[:,1]])
return X
4. 决策树基础知识速查
4.1 树结构术语
[根节点: S_L <= 0?] ← depth=0
/ \
[节点A] [节点B] ← depth=1
/ \ / \
[叶子1] [叶子2] [叶子3] [叶子4] ← depth=2 (叶子=不再分裂)
-
根节点 (Root):整棵树第一个分裂条件
-
内部节点 (Internal Node):还有子节点的节点
-
叶子节点 (Leaf):不再分裂、给出最终预测的节点
-
深度 (Depth):从根到该节点的分裂次数
4.2 Gini vs Entropy
| 标准 | 公式 | 特点 |
|------|------|------|
| Gini | 2p(1-p) | sklearn默认,计算快 |
| Entropy | -p·log₂(p) - (1-p)·log₂(1-p) | 倾向更平衡的分裂,有log计算稍慢 |
两者在绝大多数情况下选出的树结构相同或相近。本代码用默认 Gini 即可。
5.3 连续特征如何找切点
以 S_L 为例,算法会:
-
把当前节点所有人的 S_L 值排序
-
在相邻两个值之间取中点作为候选切点
-
每个候选切点算一次加权 Gini
-
选 Gini 最小的那个
这解释了为什么 c_L = 0.00 而不是 0.01——因为 S_L=0 是一个自然的断层(正常 vs 异常的分界线)。
5.4 决策树 vs 其他模型
| 比较维度 | 决策树 | Logistic回归 | 随机森林 |
|----------|:---:|:---------:|:-----:|
| 可解释性 | 最好(if-else规则) | 好(系数+OR) | 差(黑箱) |
| 预测精度 | 一般 | 一般 | 高 |
| 过拟合风险 | 高(需限制深度) | 低 | 低 |
| 统计推断 | 无p值/CI | 有完整推断 | 无 |
| 数据预处理 | 几乎不需要 | 需标准化 | 几乎不需要 |