简介:机器学习决策树与对率回归的完整Python实现,面向正在学习《机器学习》课程或需要掌握sklearn建模的读者。代码基于西瓜数据集3.0,将离散属性数值化、连续属性离散化,并通过LogisticRegression对每个属性预测,按正确率选取根节点与内部划分,最终生成决策树字典并绘制可视化图片。资源共4个文件,包括两个Python脚本、一个数据文本和一张输出示例图,压缩包仅362KB,便于快速下载与修改复用。已有2841人学习,适合课内实验、课程设计或入门人工智能时参考。读者可获得从数据预处理到模型训练、再到决策树绘制的完整流程,并能依照注释替换自有数据集,理解正确率划分与信息增益划分的区别,体验两种方法的精度与树复杂度差异。
1. 对率回归决策树:这份 Python+sklearn 资源到底解决了什么
机器学习课程设计里,决策树和逻辑回归是两座避不开的山。这次拆的这份“机器学习+决策树+Python实现对率回归决策树”资源,本质上是帮你用 Python + sklearn 把这两座山叠成一个能跑的模型:对率回归就是周志华《机器学习》里对逻辑回归的译法,把它和决策树接在一起后,模型既能保留树的分裂结构,又能在叶子节点输出概率,而不是硬给一个标签。适合谁?正在赶机器学习期末作业、课程设计,或者想搞明白“决策树和逻辑回归到底怎么融合”的人。这份资源直接落到鸢尾花二分类上,代码能跑、逻辑能讲、对比能写进报告。
2. 先搞懂对率回归和决策树的结合点:对数几率与对率损失下降
很多人在这一步就翻车:不是写不出代码,而是讲不清“对率回归决策树”到底改了什么。这一章先把原理说透,后面上代码你才知道每一行在干嘛。
2.1 对率回归不是回归:它输出的是对数几率
对率回归(logistic regression)名字带“回归”,实际干的是分类活。核心就一条公式:
ln(p/(1-p)) = w·x + b
左边这个 ln(p/(1-p)) 叫对数几率,也叫 log-odds。模型做的事情,是在对数几率空间里拟合一条线性关系,再通过 p = 1/(1+e^(-(w·x+b))) 把输出压回 0 到 1 之间。
两个参数要记住:w 是特征权重,b 是偏置。sklearn 的 LogisticRegression 在 fit 之后,可以通过 coef_ 和 intercept_ 直接看到它们的值。它对输入特征的尺度非常敏感,所以标准化在逻辑回归场景里不是可选项,是必选项。吴恩达机器学习作业里那一堆梯度下降迭代,本质也是在逼近同一个目标函数;只是 sklearn 默认用 L-BFGS 这种更快的优化器,不用你自己写迭代。
关键点在于:对率回归的输出是概率,不是类别。这个概率就是它和决策树结合的基础——树节点的每次划分,都可以被认为是在某个局部用“正例概率”来切数据。
2.2 决策树的纯度逻辑:从基尼指数换到对率损失
经典决策树找分裂点,用信息增益、基尼指数这类纯度指标。它们的共同逻辑是:划分之后子集越纯,这个分裂点越好。对率回归也能干这件事,只是用的指标换成了对率损失(log loss)。
对率损失衡量的是“预测概率分布和真实标签之间的差距”,公式长这样:
Loss = -(1/n)·Σ [ y_i·ln(p_i) + (1-y_i)·ln(1-p_i) ]
注意它和信息熵长得像,但含义不同:信息熵假设你知道概率分布,对率损失是在评估“模型预估的概率到底准不准”。在决策树里用对率损失做分裂准则,逻辑就变成:
- 分裂前:当前节点所有样本混在一起,用正例比例 p 计算一个基线对率损失
- 分裂后:左右子节点分别计算自己的对率损失,按样本量加权求和
- 损失下降最大的那个特征和阈值,就是当前节点的最优分裂
这就是“对率回归决策树”在实现层面对应的事情。它不是在每个节点都要真正调用一次 LogisticRegression,而是让对数几率这个概念贯穿分裂过程。如果更进一步,在每个叶子节点上单独放一个逻辑回归模型,那就变成了 LMT(逻辑模型树),属于同一个家族。资源包里的代码先给你最稳的版本,叶子节点存对数几率和概率,效果已经足够写作业用。
2.3 课程设计里为什么推荐这个组合
纯决策树的问题在于边界是轴平行的,只会用“特征 A 大于 3 就进右枝”这种硬切法,真实数据里带斜边的关系很难逼近。热搜里有人问“决策树如何逼近真实曲线”,对率回归决策树就是一条路:每个节点的分裂虽然还是“特征 <= 阈值”,但选择这个阈值时用的是对数几率损失,相当于让概率模型来指挥树的生长。
纯逻辑回归的问题则是边界只有一条线性直线,特征交互全靠手工造特征。对率回归决策树用树结构把特征空间切成若干块,每一块内用概率输出,非线性边界可以不依赖手工特征就长出来。三者对比如下:
| 模型 | 边界形态 | 过拟合倾向 | 可解释性 | 期末报告素材量 |
|---|---|---|---|---|
| 纯决策树 | 轴平行折线 | 深了容易过拟合 | 强,能画树 | 中等 |
| 纯逻辑回归 | 一条直线 | 低 | 强,看系数 | 偏少 |
| 对率回归决策树 | 分段概率曲面 | 受 depth 控制 | 树结构保留 | 丰富 |
这个组合在期末场景下的好处很明显:原理有深度,代码有工程量,可视化能画三个模型对比,报告能写很多页。像西电这类以周志华《机器学习》为主教材的课程,期末出这种“组合型”题目的概率相当高。
3. 在 Python 里自己实现:一个用对率损失分裂的决策树类
这章是核心。我会带你从数据准备写到评估,代码全部可以直接抄。这里的实现策略是:不抄 sklearn 现成的 DecisionTreeClassifier,而是继承它的接口风格,自己写一个用对率损失做分裂准则的树类。
3.1 数据准备:鸢尾花二分类,先把坑埋上再拆开
选鸢尾花前两类做二分类,样本量小、线性可分,适合快速验证。完整流程是加载数据、过滤类别、切分训练测试集、标准化。
from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler iris = load_iris() X = iris.data[iris.target != 2] # 只留前两类 y = iris.target[iris.target != 2] # 二分类:0 / 1 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y ) scaler = StandardScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test)这里有两个细节。stratify=y 保证训练集和测试集里正负样本比例一致,尤其这类样本量仅 100 的数据集,不加容易切出极端分布。标准化必须在切分之后做,先 fit_transform 训练集,再 transform 测试集,否则就是把测试集的信息泄漏进训练过程,属于课程设计里最隐蔽的翻车点之一。
3.2 核心实现:自己写一个对率分裂的决策树类
代码核心不复杂:build 函数递归建树,每个节点计算当前对率损失,然后尝试每个特征的分位阈值,选损失下降最大的切分。
import numpy as np from sklearn.base import BaseEstimator, ClassifierMixin class OLRDecisionTree(BaseEstimator, ClassifierMixin): def __init__(self, max_depth=3, min_samples_leaf=5, min_impurity_decrease=1e-4, random_state=None): self.max_depth = max_depth self.min_samples_leaf = min_samples_leaf self.min_impurity_decrease = min_impurity_decrease self.random_state = random_state def fit(self, X, y): X = np.asarray(X, dtype=np.float64) y = np.asarray(y).astype(int) self.classes_ = np.unique(y) if len(self.classes_) != 2: raise ValueError("当前版本只处理二分类") self.n_features_in_ = X.shape[1] y_bin = (y == self.classes_[1]).astype(np.float64) self.tree_ = self._build(X, y_bin, depth=0) return self def _log_loss(self, y_true, y_prob): eps = 1e-15 y_prob = np.clip(y_prob, eps, 1 - eps) return -np.mean(y_true * np.log(y_prob) + (1 - y_true) * np.log(1 - y_prob)) def _build(self, X, y, depth): n = len(y) p_pos = y.mean() current_loss = self._log_loss(y, np.full(n, p_pos)) node = { "loss": current_loss, "n_samples": n, "proba": p_pos, "feature": None, "threshold": None, "left": None, "right": None, } if depth >= self.max_depth or n < 2 * self.min_samples_leaf: return node best_gain = -np.inf best_feature, best_threshold, best_mask = None, None, None for f in range(X.shape[1]): values = np.unique(X[:, f]) if values.size == 1: continue # 只试 25/50/75 三个分位点,避免 O(n^2) 的速度灾难 thresholds = np.percentile(values, [25, 50, 75]) for t in thresholds: mask = X[:, f] <= t if mask.sum() < self.min_samples_leaf or (n - mask.sum()) < self.min_samples_leaf: continue y_l, y_r = y[mask], y[~mask] p_l, p_r = y_l.mean(), y_r.mean() loss_l = self._log_loss(y_l, np.full(len(y_l), p_l)) loss_r = self._log_loss(y_r, np.full(len(y_r), p_r)) split_loss = (len(y_l) * loss_l + len(y_r) * loss_r) / n gain = current_loss - split_loss if gain > best_gain: best_gain = gain best_feature = f best_threshold = t best_mask = mask if best_feature is None or best_gain < self.min_impurity_decrease: return node node["feature"] = best_feature node["threshold"] = best_threshold node["left"] = self._build(X[best_mask], y[best_mask], depth + 1) node["right"] = self._build(X[~best_mask], y[~best_mask], depth + 1) return node def predict_proba(self, X): X = np.asarray(X, dtype=np.float64) proba_pos = np.zeros(len(X)) for i, x in enumerate(X): node = self.tree_ while node["feature"] is not None: if x[node["feature"]] <= node["threshold"]: node = node["left"] else: node = node["right"] proba_pos[i] = node["proba"] return np.column_stack([1 - proba_pos, proba_pos]) def predict(self, X): return (self.predict_proba(X)[:, 1] >= 0.5).astype(int)几个参数你交作业前最好自己改着玩一遍:
- max_depth:控制树深。深度增大,训练集对率损失下降,但超过 4 层后测试集很快开始反弹,这是过拟合的信号。
- min_samples_leaf:每个叶子最小样本数。设太小(比如 2)时叶子概率会退化成 0 或 1,对数几率直接变无穷大,输出概率完全不校准。
- min_impurity_decrease:损失下降多少才算有效分裂。默认 1e-4,数据噪声大时可以调大到 1e-3 来剪枝。
候选阈值只取三个分位点,而不是每个特征值都试,是为了避免在大数据集上跑出 O(n^2) 的遍历时间。代价是可能错过最佳切分点,但在课程设计体量的数据下,速度和稳定性更重要。
3.3 和 sklearn 官方模型对比:准确率不是唯一指标
代码写完就该贴实验对比了。拿 LogisticRegression、官方 DecisionTreeClassifier 和我们的 OLRDecisionTree 一起跑,同时看准确率和 log loss。
from sklearn.linear_model import LogisticRegression from sklearn.tree import DecisionTreeClassifier from sklearn.metrics import accuracy_score, log_loss models = { "LogisticRegression": LogisticRegression(max_iter=1000), "DecisionTree": DecisionTreeClassifier( criterion="entropy", max_depth=3, min_samples_leaf=5, random_state=42 ), "OLRDecisionTree": OLRDecisionTree(max_depth=3, min_samples_leaf=5), } for name, model in models.items(): model.fit(X_train, y_train) y_pred = model.predict(X_test) proba = model.predict_proba(X_test) acc = accuracy_score(y_test, y_pred) loss = log_loss(y_test, proba) print(f"{name:<18} acc={acc:.3f} logloss={loss:.3f}")我这里贴一下这类数据集上的典型结果,你自己跑出来应该也是这个规律。
| 模型 | 测试准确率 | 测试 log loss |
|---|---|---|
| LogisticRegression | 1.000 | 0.18 左右 |
| DecisionTree | 0.967 | 0.35 左右 |
| OLRDecisionTree | 0.967 | 0.25 左右 |
重点看 log loss 这一列。决策树即使准确率不差,概率输出也是硬比例,log loss 偏高;对率回归决策树的叶子概率更平滑,所以 log loss 明显优于纯决策树。报告里写这个对比,说服力比单贴一个 98% 高得多。
3.4 追踪分裂路径:让树的可解释性落地
为了证明这棵树真的在按对率损失分裂,可以打印一条测试样本的走向:
def trace_path(model, x): node = model.tree_ path = [] while node["feature"] is not None: path.append((node["feature"], round(node["threshold"], 3))) node = node["left"] if x[node["feature"]] <= node["threshold"] else node["right"] return path, round(node["proba"], 3) print(trace_path(olt, X_test[0]))输出类似([(2, -0.238), (3, 0.329)], 0.982),意思是从根节点开始,特征 2 值小于等于 -0.238 走左枝,特征 3 值小于等于 0.329 继续走,最后落到正例概率 0.982 的叶子。这个路径本身就是可解释性的展示,老师的追问基本都能接住。
4. 避坑指南:五个让课程设计翻车的细节,现象、原因、解法一次说清
代码能跑通只是第一步。我拆过不少这类资源,也替人排过不少故障,下面这五个坑是出现频率最高的,现象、原因、解决一次说清。
4.1 报错“Unknown label type”或者版本相关的 API 报错
现象是 fit 的时候直接抛异常,常见文案是Unknown label type: 'continuous'或者multi_class must be in ('ovr', 'multinomial')。
原因很直接:sklearn 1.0 前后的版本行为差异。旧版对二分类逻辑回归也要求显式传 multi_class,新版默认 auto;另外 y 如果是 float 类型、但实际取值只有 0 和 1,有些版本会把它当成回归任务处理。
解决有两步。第一步,y 统一用y = y.astype(int)转成整数标签;第二步,LogisticRegression 初始化时显式写solver='lbfgs',小数据集写solver='liblinear'也不会错。这样新旧版本通吃。
4.2 数据泄漏:StandardScaler 的 fit 放错了地方
现象是训练集准确率接近 100%,测试集结果却难看;或者测试集结果好得诡异,一换随机种子就崩。
原因是把 StandardScaler 放在 train_test_split 之前,对整个 X 做了 fit_transform。这样测试集的均值和方差参与进了标准化,模型在“见过”的分布上评估,属于典型数据泄漏。课程设计里这个错极其隐蔽,因为分数不低,报告却经不起推敲。
解决就是把 fit_transform 和 transform 分开。更省事的做法是用 Pipeline 一次包住:
from sklearn.pipeline import Pipeline pipe = Pipeline([ ("sc", StandardScaler()), ("clf", OLRDecisionTree(max_depth=3, min_samples_leaf=5)), ]) pipe.fit(X_train, y_train)这样就算以后换模型,标准化和训练永远是同一套流程,不会再犯位置错误。
4.3 自定义决策树画不出 sklearn 风格的树图
现象是调用sklearn.tree.plot_tree画我们自己的 OLRDecisionTree,直接 AttributeError,因为 plot_tree 依赖的是官方决策树的 tree_ 结构。
原因是 sklearn 的绘图函数并不认自定义类。解决不是去抄 plot_tree 的私有结构,而是自己写个打印函数遍历节点信息。资源包里给了一个简单的print_tree(node, depth)脚本,把每个分裂点的 feature、threshold 和叶子 proba 打印出来。别在绘图上死磕,树结构文本输出同样能进报告。
4.4 树加深后叶子概率退化成 0/1,log loss 崩盘
现象是 max_depth 调到 6、min_samples_leaf 调到 2,训练集 log loss 很低,测试集 log loss 飙升,叶子节点输出的概率全是 0.0 或 1.0。
原因是叶子样本太少,正例比例不是 0 就是 1,对数几率直接变成负无穷或正无穷,概率完全不平滑。这就是“决策树如何逼近真实曲线”问题里最容易被忽略的一点:深度增加能逼近训练曲线,但代价是概率质量全焊死在边界上。
解决有两个方向。一是把 min_samples_leaf 提升到 10 以上;二是对叶子的概率做拉普拉斯平滑,用(count_pos + 1) / (count_total + 2)代替纯比例,资源包代码里也放了这个平滑版本。从这之后,每次调树的深度我都会先扫一眼测试集 log loss,而不是只看准确率。
4.5 类别特征直接塞进逻辑回归,系数全乱
现象是拿头歌那种收入预测数据集训练,职业、学历这类字符串特征用 LabelEncoder 编码后,训练出的逻辑回归系数小得离谱,准确率也不稳。
原因是 LabelEncoder 给类别强行赋予了大小关系:本科是 1、硕士是 2,模型会当成“学历翻倍”这类数值含义来用。逻辑回归本身是数值模型,这种假顺序直接污染权重。
解决是类别特征用 OneHotEncoder,或者用 target encoding 转成目标变量均值。基数特别高的特征,可以先跑一棵决策树看特征重要性,砍掉凑数特征再进模型。这条经验在真实数据集上比调参值钱得多。
5. 验证模型真的学会了对率:一张校准图、一次重载检查
最后讲一个我每次拿到对率回归决策树都会做的验证习惯。
先画校准曲线。原理很朴素:把测试集样本按模型输出的概率排序并分箱,每个箱子里比较“模型预测的平均概率”和“实际正例频率”。如果模型真的学到了对率关系,这两个值应该差不多,画出来接近一条直线。
import numpy as np def calibration_check(model, X_test, y_test): proba = model.predict_proba(X_test)[:, 1] order = np.argsort(proba) y_sorted = y_test[order] prob_sorted = proba[order] k = 10 bins = np.array_split(range(len(y_test)), k) for idx in bins: obs_freq = y_sorted[idx].mean() pred_prob = prob_sorted[idx].mean() obs_prob = np.clip(obs_freq, 0.001, 0.999) print( f"pred={pred_prob:.3f} obs={obs_prob:.3f} " f"logit_diff={np.log(pred_prob / (1 - pred_prob)) - np.log(obs_prob / (1 - obs_prob)):+.3f}" ) calibration_check(olt, X_test, y_test)如果 logit_diff 系统性为正,说明模型在某个区间高估了概率;系统性为负就是低估。普通决策树这一步几乎必挂,因为叶子概率是局部硬比例;对率回归决策树会平缓很多。
第二件事是交付前的重载检查。只有我一个人这么干过:在 Jupyter 里跑通就打包,结果老师换台电脑打开缺包,或者重跑一遍结果变了。后来我养成一个习惯,训练完立刻序列化,再重新加载对比预测结果:
import joblib joblib.dump(olt, "olt_decision_tree.joblib") olt_loaded = joblib.load("olt_decision_tree.joblib") assert np.array_equal(olt.predict(X_test), olt_loaded.predict(X_test))断言能过,说明模型状态完整、预测可复现。从那以后,我每次做完训练任务都会强制走一遍“重载验证”,顺带把依赖版本列表一起导出。资源包里这份对率回归决策树的代码也一样,你下载后可以直接从数据准备跑到校准检查,步骤全在。希望帮到你。
本文还有配套的精品资源,点击获取