逻辑回归决策树:融合决策树与逻辑回归优势的Python实现
2026/9/14 2:59:36 网站建设 项目流程

简介:本资源是一份面向机器学习初学者与课程实践者的决策树构建教学实现,聚焦于融合对率回归(Logistic Regression)原理的决策树构造方法,解决传统ID3/C4.5依赖信息增益导致理论抽象、实操门槛高的问题。压缩包共4个文件(2个Python脚本、1个数据文本、1张生成的决策树PNG图),总大小362KB;其中核心脚本调用sklearn.linear_model.LogisticRegression对西瓜数据集3.0进行属性级二分类预测,通过正确率指标递归选取最优划分属性,并完成连续属性离散化与字符串编码预处理;createPlot.py负责将结构化决策树数组转为可视化字典并绘图保存。已有2841人学习下载,读者可直接运行代码复现完整流程,快速掌握基于统计判别准则构建决策树的思路,灵活适配其他离散化数据集,具备课程实验、课设开发与算法对比研究的实用价值。

1. 项目缘起:当决策树遇上逻辑回归

最近在复盘一个分类项目时,我遇到了一个挺有意思的困境。手头的数据集特征既有连续数值,比如用户的消费金额、浏览时长,也有一些是离散的分类标签,比如用户性别、设备类型。直接用传统的CART或者ID3决策树吧,对于连续特征的分裂点选择,虽然信息增益或基尼系数能算,但总感觉在处理那些与分类概率边界强相关的连续特征时,有点“硬切”的意思,不够细腻。特别是当两类数据的边界并非垂直或水平,而是一个斜线甚至曲线时,单靠一个阈值进行二元分裂,往往需要很深的树才能拟合,容易过拟合,可解释性也随之下降。

而另一边,逻辑回归(也就是标题里的“对率回归”)在处理这类问题上是把好手。它通过Sigmoid函数直接输出一个样本属于正类的概率,这个概率值本身就是连续且光滑的,对于刻画“可能性”的渐变过程非常自然。但逻辑回归是个线性模型(在特征空间),对于特征间复杂的交互关系和非线性决策边界,它又显得力不从心,除非手动构造大量交叉特征,这无疑增加了工程和过拟合的风险。

于是,一个很自然的想法冒了出来:能不能把这两者的优点结合起来?让决策树的每个节点,不再只是简单地根据某个特征的阈值做“是/否”的判断,而是利用逻辑回归模型来计算一个更精细的“分叉概率”?比如,在一个节点上,我们不是问“年龄是否大于30?”,而是问“基于年龄、收入这几个特征,你属于A类的概率是多少?”,然后根据这个概率值是否超过0.5,或者落入某个更复杂的区间,来决定样本的走向。这其实就是“逻辑回归决策树”或者说“Logistic Model Trees”的核心思想。它不是用常数(多数类标签)作为叶子节点的预测值,也不是用简单的线性回归(适用于回归任务),而是用逻辑回归模型来给出概率预测,在树的内部节点则用逻辑回归模型来指导分支。这次,我就想用Python亲手实现一下这个 hybrid 模型,从最基础的部分开始,看看它究竟如何工作,以及在实际数据上能带来哪些不同。

2. 核心原理拆解:逻辑回归如何“嵌入”决策树

在动手写代码之前,我们必须先厘清几个关键概念。传统的决策树(如CART用于分类)在构建过程中,每个节点都会选择一个特征和一个分裂点,旨在最大化子节点的“纯度”。纯度通常用基尼指数或信息熵来衡量。分裂后,数据被硬性地划分到左子树或右子树。最终,叶子节点存储的是该节点内样本的类别分布(如类别比例),预测时则输出多数类或概率分布。

而我们要实现的逻辑回归决策树,其核心变化在于节点模型分裂准则

2.1 节点模型:从常数到概率函数

在传统决策树的叶子节点,模型是一个常数(对于分类,是类标签或类概率)。在逻辑回归决策树中,每个叶子节点都关联一个逻辑回归模型。这个模型是在到达该叶子节点的训练样本子集上训练得到的。当一个新的样本到达这个叶子节点时,我们不直接看样本的类别分布,而是将这个样本的特征输入该节点独有的逻辑回归模型,由模型输出一个属于正类的概率p = σ(w^T * x + b),其中σ是Sigmoid函数。这个p就是最终的预测值(对于二分类)。

那么,内部节点呢?一种常见的LMT(Logistic Model Tree)实现中,内部节点同样可以包含一个逻辑回归模型。但这个模型的作用不是做最终预测,而是作为分裂规则。具体来说:

  1. 在内部节点训练一个逻辑回归模型:使用当前节点上的所有训练样本。
  2. 利用模型输出进行分裂:对于每个样本,计算其逻辑回归模型的预测值p。然后,不再像传统树那样寻找一个原始特征的最佳分裂点,而是寻找预测概率p的最佳分裂点。例如,找到某个阈值t,使得p <= t的样本去左子树,p > t的样本去右子树。这个阈值t的选择,依然可以通过优化纯度指标(如基尼指数)来实现。

这样做的直观理解是:逻辑回归模型已经学习了一个关于当前数据子集的“最优”线性概率边界。我们根据这个模型输出的置信度(概率)来对样本进行细分,置信度低的样本(概率接近0.5,模型觉得很难判断)可能需要进一步用更复杂的规则(更深层的树)去区分,而置信度很高的样本(概率接近0或1)则可以较快地到达一个叶子节点。

2.2 分裂准则与树生长策略

既然分裂是基于逻辑回归模型的输出概率,那么树生长的停止条件也需要相应调整。除了传统决策树的最大深度、最小样本数等条件外,逻辑回归决策树还需要考虑:

  • 逻辑回归模型的显著性:在一个节点上,是否值得训练一个逻辑回归模型?如果当前节点的数据已经完全可以被一个简单的常数(比如所有样本都属于同一类)很好地解释,那么增加一个复杂的逻辑回归模型可能带来过拟合。我们可以通过似然比检验等方法来评估是否需要在当前节点引入逻辑回归模型。
  • 分裂带来的增益:即使训练了逻辑回归模型,基于其概率输出的分裂是否带来了足够的纯度提升?这可以通过计算分裂前后的损失函数(如逻辑损失)的减少量来判断。

在实际的简化实现中,我们可能会采用一种更 pragmatic 的方式:先像传统决策树一样生长一棵完整的树,然后在每个叶子节点上“嫁接”一个逻辑回归模型。这种方法构建起来更简单,但理论上的解释性不如在内部节点使用逻辑回归进行分裂的LMT。

我决定先实现这个“简化版”:即构建一棵标准的CART分类树,但在每个叶子节点,我们不记录类别比例,而是存储一个在该叶子节点样本上训练好的逻辑回归模型实例。预测时,样本从根节点路由到某个叶子节点,然后调用该叶子节点的逻辑回归模型进行预测。这种方式,我们可以更专注于理解“决策树框架”与“逻辑回归叶子模型”的协同,后续再考虑更复杂的内部节点逻辑回归分裂。

2.3 与梯度提升树(如XGBoost, LightGBM)的区别

这里必须澄清一个常见的疑惑:这听起来有点像梯度提升决策树(GBDT)?区别很大。

  • GBDT:是集成学习模型,由多棵回归树(CART)串联而成。每一棵树学习的是之前所有树预测结果的残差(对于分类任务,通常是梯度)。最终预测是所有树输出的加权和,再通过链接函数(如Sigmoid)得到概率。树本身是简单的回归树。
  • 逻辑回归决策树:是单个模型,一棵树。它的叶子节点是复杂的逻辑回归模型,而不是简单的数值。可以看作是决策树模型族的一个扩展,增强了叶子节点的表达能力。

前者是“许多简单模型的组合”,后者是“一个复杂模型的树状结构”。

3. 环境准备与数据故事

为了有真实的体感,我不用现成的玩具数据集,而是构造一个能体现逻辑回归决策树优势的场景。假设我们要预测用户是否会购买某款高端产品(二分类,1=购买,0=不购买)。我们有两个关键特征:

  • income(收入):连续值,范围从3万到15万。
  • engagement(互动得分):连续值,综合了浏览时长、点赞、评论等行为,范围0-100。

潜在的数据规律是:只有高收入且高互动的用户才会购买,但其中存在一个非线性的边界。例如,可能收入 > 10万且互动 > 70的用户购买概率很高,而收入在8-10万之间时,需要互动分 > 80才可能购买。单纯用线性逻辑回归很难拟合这个拐角区域,而用普通决策树则需要多次分裂来近似这个边界。

我们先来搭建环境和制造数据。

import numpy as np import matplotlib.pyplot as plt from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression from sklearn.tree import DecisionTreeClassifier, plot_tree from sklearn.metrics import accuracy_score, log_loss, roc_auc_score import warnings warnings.filterwarnings('ignore') # 1. 生成模拟数据 np.random.seed(42) n_samples = 2000 # 生成特征 income = np.random.uniform(30, 150, n_samples) # 单位:千元 engagement = np.random.uniform(0, 100, n_samples) # 定义复杂的购买规则(非线性决策边界) def complex_purchase_rule(i, e): # 规则1:高收入高互动 if i > 100 and e > 70: return 1 # 规则2:中等收入但超高互动 if 80 <= i <= 100 and e > 80: return 1 # 规则3:收入一般,但互动极高,且收入超过某个阈值 if 60 <= i < 80 and e > 90: return 1 # 其余情况不购买 return 0 # 根据规则生成标签,并加入一些噪声 y = np.array([complex_purchase_rule(income[i], engagement[i]) for i in range(n_samples)]) # 加入10%的随机噪声 noise_mask = np.random.rand(n_samples) < 0.1 y[noise_mask] = 1 - y[noise_mask] X = np.column_stack((income, engagement)) # 2. 划分训练集和测试集 X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y) print(f"训练集样本数: {X_train.shape[0]}, 测试集样本数: {X_test.shape[0]}") print(f"训练集正样本比例: {y_train.mean():.3f}, 测试集正样本比例: {y_test.mean():.3f}") # 3. 可视化数据分布 plt.figure(figsize=(10, 6)) plt.scatter(X_train[y_train==0, 0], X_train[y_train==0, 1], c='blue', alpha=0.6, label='Not Purchase (0)', s=20) plt.scatter(X_train[y_train==1, 0], X_train[y_train==1, 1], c='red', alpha=0.6, label='Purchase (1)', s=20) plt.xlabel('Income (k)') plt.ylabel('Engagement Score') plt.title('Training Data Distribution') plt.legend() plt.grid(True, alpha=0.3) plt.show()

运行这段代码,我们会得到一份可视化数据。图中红色和蓝色的点混杂在右上角区域,清晰地展示了一个非线性的分类边界。这就是我们模型的“考场”。

4. 从零实现逻辑回归决策树(简化版)

接下来是核心部分:实现一个LogisticRegressionTree类。我们的策略是:

  1. 继承与组合:我们不会从头写一棵树,而是利用sklearnDecisionTreeClassifier作为基础分裂器。但我们需要拦截其构建过程,或者在构建后替换其叶子节点的预测逻辑。
  2. 后处理嫁接:更简单的方法是,先让DecisionTreeClassifier自由生长,然后我们遍历每一个叶子节点,找到落到该节点的训练样本,在这些样本上独立训练一个LogisticRegression模型,并将这个模型存入该叶子节点。
  3. 预测路由:预测时,先用决策树的apply方法找到样本所属的叶子节点索引,然后调用该索引对应的逻辑回归模型进行预测。

这里有个关键点:sklearn的决策树在预测时,默认返回的是类别或类别概率(如果设置predict_proba)。我们需要禁用这个默认行为,让它只负责“路由”,不负责“判决”。

from sklearn.base import BaseEstimator, ClassifierMixin from sklearn.utils.validation import check_X_y, check_array, check_is_fitted from sklearn.utils.multiclass import unique_labels class LogisticRegressionTree(BaseEstimator, ClassifierMixin): """ 简化版逻辑回归决策树。 使用决策树进行样本路由,在每个叶子节点上训练一个独立的逻辑回归模型。 """ def __init__(self, max_depth=5, min_samples_split=2, min_samples_leaf=1, logistic_regression_params=None, random_state=None): """ 初始化参数。 Args: max_depth: 决策树最大深度。 min_samples_split: 内部节点分裂所需最小样本数。 min_samples_leaf: 叶子节点所需最小样本数。 logistic_regression_params: 传递给每个叶子节点逻辑回归模型的参数字典。 random_state: 随机种子。 """ self.max_depth = max_depth self.min_samples_split = min_samples_split self.min_samples_leaf = min_samples_leaf self.logistic_regression_params = logistic_regression_params or {} self.random_state = random_state # 初始化决策树作为路由器 self.tree_ = DecisionTreeClassifier( max_depth=max_depth, min_samples_split=min_samples_split, min_samples_leaf=min_samples_leaf, random_state=random_state ) # 用于存储叶子节点逻辑回归模型的字典 self.leaf_models_ = {} # 存储叶子节点索引到模型映射的数组(更高效) self.leaf_model_array_ = None # 存储训练时每个叶子节点的样本索引 self.leaf_samples_indices_ = {} def fit(self, X, y): """ 训练模型。 1. 用决策树拟合数据,得到树结构。 2. 对于每个叶子节点,用落到该节点的样本训练一个逻辑回归模型。 """ X, y = check_X_y(X, y) self.classes_ = unique_labels(y) self.n_features_in_ = X.shape[1] # 1. 训练决策树(仅用于学习分裂结构) self.tree_.fit(X, y) # 2. 获取每个训练样本最终落入的叶子节点索引 # apply方法返回每个样本所在的叶子节点索引 leaf_indices = self.tree_.apply(X) # shape: (n_samples,) unique_leaves = np.unique(leaf_indices) # 初始化存储结构和模型列表 self.leaf_samples_indices_ = {leaf: [] for leaf in unique_leaves} self.leaf_model_array_ = [None] * (max(unique_leaves) + 1) # 简单列表,索引为叶子节点ID # 3. 为每个叶子节点收集样本并训练逻辑回归模型 for sample_idx, leaf_idx in enumerate(leaf_indices): self.leaf_samples_indices_[leaf_idx].append(sample_idx) for leaf_idx in unique_leaves: sample_indices = self.leaf_samples_indices_[leaf_idx] X_leaf = X[sample_indices] y_leaf = y[sample_indices] # 检查叶子节点样本的类别情况 # 如果叶子节点内所有样本都属于同一类,逻辑回归可能无法收敛或没必要。 # 我们做一个简单处理:如果纯度高,则创建一个“虚拟”模型,始终预测该类概率为1。 unique_classes_leaf = np.unique(y_leaf) if len(unique_classes_leaf) == 1: # 纯叶子节点 class_val = unique_classes_leaf[0] # 创建一个“退化”的逻辑回归模型:权重全零,偏置使得sigmoid输出为目标类。 # 对于二分类,我们希望 P(y=1) ≈ 1 if class_val==1 else ≈ 0。 # 可以通过设置一个极大的偏置来实现。这里我们用一个简单的字典模拟。 class DummyModel: def predict_proba(self, X): n_samples = X.shape[0] if class_val == 1: return np.hstack([np.zeros((n_samples, 1)), np.ones((n_samples, 1))]) else: return np.hstack([np.ones((n_samples, 1)), np.zeros((n_samples, 1))]) model = DummyModel() else: # 不纯的叶子节点,训练真正的逻辑回归 # 注意:如果叶子节点样本数很少或特征线性不可分,逻辑回归可能报警告。 # 我们增加一些默认参数以提高稳定性,并允许外部覆盖。 lr_params = { 'penalty': 'l2', # 默认L2正则化,防止过拟合 'C': 1.0, 'solver': 'lbfgs', 'max_iter': 1000, 'random_state': self.random_state } lr_params.update(self.logistic_regression_params) # 用户自定义参数优先 model = LogisticRegression(**lr_params) try: model.fit(X_leaf, y_leaf) except Exception as e: # 如果训练失败(例如,数据奇异),回退到多数类预测 print(f"Warning: Logistic regression failed at leaf {leaf_idx}. Fallback to majority class. Error: {e}") majority_class = np.bincount(y_leaf.astype(int)).argmax() class DummyModelMajority: def __init__(self, maj_class): self.maj_class = maj_class def predict_proba(self, X): n_samples = X.shape[0] if self.maj_class == 1: return np.hstack([np.zeros((n_samples, 1)), np.ones((n_samples, 1))]) else: return np.hstack([np.ones((n_samples, 1)), np.zeros((n_samples, 1))]) model = DummyModelMajority(majority_class) self.leaf_model_array_[leaf_idx] = model self.leaf_models_[leaf_idx] = model # 保留字典引用方便查看 self.is_fitted_ = True return self def predict_proba(self, X): """ 预测概率。 1. 用决策树确定每个样本的叶子节点。 2. 用对应叶子节点的逻辑回归模型预测概率。 """ check_is_fitted(self, 'is_fitted_') X = check_array(X) leaf_indices = self.tree_.apply(X) n_samples = X.shape[0] probas = np.zeros((n_samples, 2)) # 二分类,两列 for i in range(n_samples): leaf_idx = leaf_indices[i] model = self.leaf_model_array_[leaf_idx] # 有些叶子节点可能没有模型(理论上不会发生,因为训练时都创建了),做个防御 if model is None: # 极端情况:回退到全局多数类?这里简单赋值为[0.5, 0.5] probas[i] = [0.5, 0.5] else: prob_i = model.predict_proba(X[i:i+1, :]) # 保持二维输入 probas[i] = prob_i[0] return probas def predict(self, X): """ 预测类别。 默认阈值0.5。 """ probas = self.predict_proba(X) return (probas[:, 1] >= 0.5).astype(int) def get_leaf_models_info(self): """返回叶子节点模型的信息,用于调试和分析。""" check_is_fitted(self, 'is_fitted_') info = [] for leaf_idx, model in self.leaf_models_.items(): if hasattr(model, 'coef_'): info.append({ 'leaf_index': leaf_idx, 'samples_count': len(self.leaf_samples_indices_[leaf_idx]), 'model_type': 'LogisticRegression', 'coef': model.coef_, 'intercept': model.intercept_ }) else: info.append({ 'leaf_index': leaf_idx, 'samples_count': len(self.leaf_samples_indices_[leaf_idx]), 'model_type': 'DummyModel (Pure/Majority)' }) return info

这个实现有几个需要注意的细节和技巧:

  1. 纯叶子节点的处理:如果一个叶子节点里的所有训练样本都属于同一类,那么训练一个逻辑回归模型要么会失败(因为y值没有变化),要么是过度的。我们创建了一个DummyModel,它直接返回该类概率为1。这既保证了预测的一致性,也避免了不必要的计算和警告。
  2. 逻辑回归训练稳定性:在叶子节点上训练逻辑回归可能遇到问题,比如样本数少于特征数,或者特征共线性严重。我们默认添加了penalty='l2'正则化,并使用solver='lbfgs'来增强稳定性。同时用try-except包裹了训练过程,一旦失败就回退到多数类预测,保证模型的健壮性。
  3. 预测效率:在predict_proba中,我们循环每个样本调用对应的模型。对于大型数据集,这可能成为瓶颈。一个优化方案是,将到达同一叶子节点的样本批量处理。但为了代码清晰,我们先保持循环。在实际生产环境中,需要对此进行优化。
  4. 模型存储:我们同时使用了字典leaf_models_和列表leaf_model_array_。字典便于人类查看,列表则便于通过整数索引快速访问,这是空间换时间的一种权衡。

5. 模型训练与初步评估

现在,让我们在模拟数据上训练三个模型进行对比:1) 标准逻辑回归;2) 标准决策树;3) 我们刚实现的逻辑回归决策树。

# 1. 训练标准逻辑回归模型 lr = LogisticRegression(penalty='l2', C=1.0, solver='lbfgs', max_iter=1000, random_state=42) lr.fit(X_train, y_train) # 2. 训练标准决策树模型(使用与我们的树相同的参数以便公平比较) dt = DecisionTreeClassifier(max_depth=5, min_samples_split=2, min_samples_leaf=1, random_state=42) dt.fit(X_train, y_train) # 3. 训练我们的逻辑回归决策树模型 lrt = LogisticRegressionTree(max_depth=5, min_samples_split=2, min_samples_leaf=1, logistic_regression_params={'C': 1.0, 'max_iter': 1000}, random_state=42) lrt.fit(X_train, y_train) # 4. 在测试集上进行预测 y_pred_lr = lr.predict(X_test) y_pred_proba_lr = lr.predict_proba(X_test)[:, 1] y_pred_dt = dt.predict(X_test) y_pred_proba_dt = dt.predict_proba(X_test)[:, 1] y_pred_lrt = lrt.predict(X_test) y_pred_proba_lrt = lrt.predict_proba(X_test)[:, 1] # 5. 评估指标 print("=== 模型性能对比 (测试集) ===") print(f"{'模型':<25} {'准确率':<10} {'对数损失':<12} {'AUC':<10}") print("-" * 60) print(f"{'逻辑回归 (LR)':<25} {accuracy_score(y_test, y_pred_lr):.4f} {log_loss(y_test, y_pred_proba_lr):.4f} {roc_auc_score(y_test, y_pred_proba_lr):.4f}") print(f"{'决策树 (DT)':<25} {accuracy_score(y_test, y_pred_dt):.4f} {log_loss(y_test, y_pred_proba_dt):.4f} {roc_auc_score(y_test, y_pred_proba_dt):.4f}") print(f"{'逻辑回归决策树 (LRT)':<25} {accuracy_score(y_test, y_pred_lrt):.4f} {log_loss(y_test, y_pred_proba_lrt):.4f} {roc_auc_score(y_test, y_pred_proba_lrt):.4f}") # 6. 查看LRT叶子节点信息 print("\n=== 逻辑回归决策树叶子节点信息 ===") leaf_info = lrt.get_leaf_models_info() for info in leaf_info[:5]: # 只看前5个叶子节点 print(f"叶子节点 {info['leaf_index']}: 样本数={info['samples_count']}, 模型类型={info['model_type']}") if info['model_type'] == 'LogisticRegression': print(f" 权重系数: {info['coef'][0]}, 截距: {info['intercept'][0]}")

运行这段代码,观察输出结果。从准确率、对数损失和AUC三个指标来看,逻辑回归决策树(LRT)的表现很可能介于逻辑回归和决策树之间,或者在某些指标上优于两者。对数损失(Log Loss)特别值得关注,因为它直接衡量预测概率的校准程度。逻辑回归通常能产生校准良好的概率,而决策树的概率估计可能比较“跳跃”。我们的LRT目标之一就是结合前者的概率平滑性和后者的非线性能力。

6. 决策边界可视化与模型行为解读

数字指标不够直观,我们通过绘制决策边界来感受三个模型的差异。

# 绘制决策边界函数 def plot_decision_boundary(model, X, y, title, ax): """绘制二分类模型的决策边界。""" # 创建网格 x_min, x_max = X[:, 0].min() - 1, X[:, 0].max() + 1 y_min, y_max = X[:, 1].min() - 1, X[:, 1].max() + 1 xx, yy = np.meshgrid(np.arange(x_min, x_max, 0.5), np.arange(y_min, y_max, 0.5)) # 预测整个网格 if hasattr(model, 'predict_proba'): Z = model.predict_proba(np.c_[xx.ravel(), yy.ravel()])[:, 1] else: Z = model.predict(np.c_[xx.ravel(), yy.ravel()]) Z = Z.reshape(xx.shape) # 绘制等高线(决策边界)和散点 contour = ax.contourf(xx, yy, Z, alpha=0.8, cmap=plt.cm.RdBu, levels=np.linspace(0, 1, 11)) ax.scatter(X[y==0, 0], X[y==0, 1], c='blue', edgecolors='k', alpha=0.6, s=30, label='Class 0') ax.scatter(X[y==1, 0], X[y==1, 1], c='red', edgecolors='k', alpha=0.6, s=30, label='Class 1') ax.set_xlabel('Income (k)') ax.set_ylabel('Engagement Score') ax.set_title(title) ax.legend() ax.grid(True, alpha=0.3) return contour # 绘制三个模型的决策边界 fig, axes = plt.subplots(1, 3, figsize=(18, 5)) models = [lr, dt, lrt] model_names = ['逻辑回归 (线性边界)', '决策树 (矩形边界)', '逻辑回归决策树 (混合边界)'] for ax, model, name in zip(axes, models, model_names): plot_decision_boundary(model, X_train, y_train, name, ax) plt.tight_layout() plt.show()

生成的图像会非常有意思:

  • 逻辑回归:决策边界是一条直线。它试图用一条斜线来分割右上角的红色区域和左下角的蓝色区域,但对于那个“拐角”形状,它无能为力,会导致对角区域的大量误分类。
  • 标准决策树:决策边界是由多条垂直于坐标轴的直线组成的阶梯状或矩形区域。它可以较好地捕捉那个“拐角”,但边界显得非常生硬、不连续,在边界附近概率变化是突变的。
  • 逻辑回归决策树:它的决策边界应该是分片线性的。整体上,它继承了决策树将空间矩形划分的能力。但在每个矩形(叶子节点)内部,边界不再是垂直/水平的硬切割,而是该叶子节点上逻辑回归模型所定义的一条斜线。因此,你可能会看到在一个大的矩形区域内,有一条斜的等高线。这比纯决策树更平滑,比纯逻辑回归更灵活。

注意:这里有一个非常重要的实现细节会影响可视化。我们的简化版LRT,其决策边界本质上是“先矩形划分,再在每个矩形内画一条最优斜线”。这意味着,两个相邻的叶子节点,其内部的逻辑回归模型是独立训练的,它们的斜线边界在矩形交界处可能不连续,甚至可能冲突。这会导致在整体决策边界上出现“缝隙”或“跳跃”。这是简化版模型的一个理论缺陷。完整的LMT通过使用逻辑回归模型来指导分裂(而不是事后嫁接),能在一定程度上保证边界的连续性。

7. 深入分析:叶子节点模型与过拟合控制

让我们更深入地查看一下LRT内部发生了什么。通过get_leaf_models_info方法,我们可以看到哪些叶子节点用了真正的逻辑回归,哪些用了虚拟模型。

# 更详细地分析叶子节点 leaf_info = lrt.get_leaf_models_info() pure_leaves = [info for info in leaf_info if info['model_type'].startswith('DummyModel')] lr_leaves = [info for info in leaf_info if info['model_type'] == 'LogisticRegression'] print(f"总叶子节点数: {len(leaf_info)}") print(f"纯叶子节点数 (使用虚拟模型): {len(pure_leaves)}") print(f"非纯叶子节点数 (使用逻辑回归): {len(lr_leaves)}") print("\n非纯叶子节点中,逻辑回归模型的权重示例:") for info in lr_leaves[:3]: # 展示前3个 print(f" 叶子节点 {info['leaf_index']}: coef={info['coef'][0]}, intercept={info['intercept'][0]}")

你可能会发现,很多叶子节点其实是“纯”的,特别是当树生长得足够深时。在这些节点上,逻辑回归是多余的。这正是我们可以进行剪枝提前停止的信号。一个改进思路是:在决定是否为一个叶子节点训练逻辑回归模型之前,先进行统计检验(如卡方检验),判断当前节点的样本分布是否显著偏离了纯类。如果不显著,就直接用多数类作为预测,避免增加模型复杂度和过拟合风险。

过拟合控制是我们实现中另一个关键点。逻辑回归本身在叶子节点上就可能过拟合,尤其是当叶子节点样本量很少的时候。我们的代码通过以下方式缓解:

  1. L2正则化:在初始化叶子节点逻辑回归时默认使用了penalty='l2'。这相当于对权重施加约束,防止它们变得过大来拟合噪声。
  2. 决策树参数:通过控制max_depth,min_samples_split,min_samples_leaf,我们首先限制了树的复杂度,从而间接控制了叶子节点的数量和每个节点的最小样本量。一个叶子节点至少要有min_samples_leaf个样本,这为训练一个稳定的逻辑回归模型提供了基础数据量。
  3. 回退机制:当逻辑回归训练失败时,我们回退到多数类预测,这是一种稳健性设计。

然而,这还不够。一个更严谨的实现应该考虑:

  • 特征选择:在叶子节点训练逻辑回归时,可能不需要使用所有特征。可以使用L1正则化(LASSO)自动进行特征选择,或者基于特征重要性进行筛选。
  • 复杂度惩罚:在树生长或剪枝的准则中,不仅要考虑纯度提升,还要考虑因为引入逻辑回归模型而增加的复杂度。这可以纳入到最小描述长度(MDL)或信息准则(如AIC/BIC)中。

8. 实战中的调参技巧与避坑指南

基于这次实现和实验,我总结了几条在应用逻辑回归决策树或类似混合模型时的实战经验:

  1. 先从简单的基准模型开始:不要一上来就使用复杂的混合模型。先用一个标准的逻辑回归和一个标准的决策树(或随机森林)在问题上跑一遍。了解它们的性能、优缺点和决策边界。这能帮你判断是否有必要引入更复杂的模型。如果逻辑回归已经表现很好,可能问题本质是接近线性的;如果决策树深度很浅就能拟合,可能问题本身并不复杂。

  2. 谨慎控制树的深度:这是最重要的超参数之一。树太深,会导致叶子节点过多、样本过少,每个叶子节点的逻辑回归模型极易过拟合,且计算量巨大。树太浅,则模型无法捕捉足够的非线性交互,逻辑回归的“局部拟合”优势无法发挥。建议从较小的深度(如3-5)开始,通过验证集性能进行调整。

  3. 关注叶子节点样本量min_samples_leaf这个参数对于LRT比对于普通决策树更重要。它直接决定了叶子节点逻辑回归模型训练数据的多少。一个经验法则是,确保min_samples_leaf至少是特征数量的5-10倍,以保证逻辑回归模型能可靠估计。如果特征很多,可能需要更大的值,或者必须在叶子节点进行特征选择。

  4. 逻辑回归的稳定性配置:在叶子节点训练逻辑回归时,务必使用正则化(penalty='l2''l1')。solver可以选择'lbfgs''liblinear''saga'。对于小数据集,'liblinear'可能更稳定。设置足够的max_iter(例如1000或更多),并考虑设置tol(容忍度)以避免不收敛的警告。

  5. 处理类别不平衡:如果原始数据类别不平衡,决策树的结构可能会偏向多数类。这会导致少数类样本被分散到各个叶子节点,使得某些叶子节点内的逻辑回归模型面临严重的样本不平衡问题。可以考虑在决策树层面设置class_weight='balanced',或者在每个叶子节点训练逻辑回归时也传入class_weight参数。

  6. 预测效率的优化:我们当前的循环预测实现效率不高。对于需要高速预测的场景,可以考虑以下优化:

    • 批量预测:在predict_proba中,先通过self.tree_.apply(X)得到所有样本的叶子节点索引,然后按索引分组,将属于同一叶子节点的样本批量输入对应的逻辑回归模型进行预测。
    • 模型编译:如果使用像scikit-learnPipeline或自定义元估计器,确保了解其预测路径是否高效。
    • 考虑替代方案:如果性能是关键,也许梯度提升树(如LightGBM, XGBoost)是更成熟、更高效的选择,它们通过加法模型的方式隐式地学习了复杂的非线性关系,且经过高度优化。
  7. 与集成方法的对比思考:逻辑回归决策树可以看作是一种“条件模型”,即模型的形式(逻辑回归)依赖于输入区域(由决策树决定)。而随机森林或梯度提升树是“模型平均”或“模型叠加”。前者可能具有更好的局部解释性(在某个区域,规则就是这条斜线),但后者通常在预测精度和鲁棒性上更胜一筹。选择哪种,取决于你是更看重可解释性,还是绝对的预测性能。

通过这个从零实现的练习,我深刻体会到,将不同模型优势结合的思路非常强大,但实现细节决定了成败。每一处设计,如对纯叶子节点的处理、对训练失败的回退、正则化的引入,都是模型能否在实际中稳健工作的关键。虽然我们这个简化版LRT在理论优美性上不如原版LMT,但它提供了一个非常直观的起点,让我们理解了如何让决策树承载更复杂的局部模型。下次当你遇到既有全局非线性结构、又希望局部有关联性预测的场景时,不妨试试这种思路,或许它能带来意想不到的效果。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询