数据科学和机器学习(第 16 部分):全新面貌的决策树·进阶篇
🌳

数据科学和机器学习(第 16 部分):全新面貌的决策树·进阶篇

(2/3)·上篇代码被吐槽不够简洁,这回把节点、拆分、递归一次讲透,为下篇森林算法铺路

含代码示例实战向 第 2/3 篇
很多人在 MQL5 里照抄旧决策树代码,结果递归层级一多就乱套。其实只要把节点结构和 build_tree 拆清楚,用双精度变量存叶节点也能很顺。本篇给你一套更易维护的写法,少踩坑。

决策树里的子节点到底装了什么

内部节点在决策树里不是终点,它只负责抛出一个测试条件,真正分流靠的是挂在其下的子节点。每个子节点对应测试条件的一种结果,并承载满足条件后的那部分数据子集,递归下去直到叶子节点给出类标签。 拿一个极简的水果分类树举例:根节点测试「颜色是红色吗」,为真走左子节点、为假走右子节点,左右两边都是叶子——左边标苹果,右边标橙子。这种结构说明子节点本质是「条件分支后的数据切片容器」。 MQL5 里通常用指针把子节点链到父节点上,下面两行就是最基础的左右子节点声明,注释标明了指向关系。 外汇与贵金属行情用这类树做状态分流时,注意过拟合高风险:样本外品种可能让分支失效。

MQL5 / C++
Node *left_child;  class=class="str">"cmt">//left child Node
Node *right_child; class=class="str">"cmt">//right child Node

「三类决策树算法怎么选」

做价格行为分类,先得认清楚手里的树长什么样。CART 既能分类也能回归,分类时按基尼杂质切分节点,回归时换均方误差最小化,一条算法两条路。 ID3 只干分类的活,靠熵和信息增益决定每次拿哪个特征劈数据,逻辑直接但容易偏向取值多的字段。C4.5 是 ID3 的修正版,用增益比率压住了多级别属性的偏好,分类更稳。 我们后面要落地的分类模型,选的是 ID3 路线:以信息增益、杂质计算加分类特征为核心,不碰回归分支。外汇与贵金属波动受事件驱动明显,用历史 K 线形态训练这类树,信号失效概率不低,仅作辅助过滤。

◍ ID3 怎么挑特征:信息增益与两种杂质度量

ID3 在每个内部节点靠信息增益决定拆哪个特征。增益量化的是一次拆分后数据集熵(或无序度)的下降量:下降越多,子集类标签越同质,模型在该节点的判别越干净。 工程实现里常把熵和基尼指数都留作选项。两者都是杂质函数,基尼指数为 1 减各类概率平方和,熵为 -Σp·log2(p),数值越低代表纯度越高;在外汇或贵金属行情分类里,切换这两种度量可能改变树的分叉形态,但都不会凭空提高预测命中率,杠杆品种高风险依旧。 下面这段 MQL5 把两种模式写进同一个 information_gain 接口:先按子节点样本占比算权重,再用父节点杂质减加权子节点杂质得到增益。 double CDecisionTree::information_gain(vector &parent, vector &left_child, vector &right_child) 函数签名:输入父集与左右子集向量,返回本次拆分的增益值。 double weight_left = left_child.Size() / (double)parent.Size(), 算左子集占父集的比例权重。 weight_right = right_child.Size() / (double)parent.Size(); 算右子集占父集的比例权重。 double gain =0; 初始化增益为 0。 switch(m_mode) 按成员变量 m_mode 选择杂质度量模式。 case MODE_GINI: 若走基尼模式。 gain = gini_index(parent) - ( (weight_left*gini_index(left_child)) + (weight_right*gini_index(right_child)) ); 父基尼减左右加权基尼得增益。 break; case MODE_ENTROPY: 若走熵模式。 gain = entropy(parent) - ( (weight_left*entropy(left_child)) + (weight_right*entropy(right_child)) ); 父熵减左右加权熵得增益。 break; return gain; 返回计算出的增益。 double CDecisionTree::entropy(vector &y) 熵计算函数,输入标签向量 y。 vector class_labels = matrix_utils.Unique_count(y); 统计各分类出现频次。 vector p_cls = class_labels / double(y.Size()); 转成每类概率。 vector entropy = (-1 * p_cls) * log2(p_cls); 逐类算 -p·log2(p)。 return entropy.Sum(); 求和得总熵。 double CDecisionTree::gini_index(vector &y) 基尼函数,输入标签向量 y。 vector unique = matrix_utils.Unique_count(y); 各类频次。 vector probabilities = unique / (double)y.Size(); 各类概率。 return 1.0 - MathPow(probabilities, 2).Sum();

  • 减概率平方和后得基尼。

开 MT5 把这段塞进自己的树类,改 m_mode 跑同一批 K 线标签,能直接比对两种度量的分叉差异。

MQL5 / C++
class="type">class="kw">double CDecisionTree::information_gain(vector &parent, vector &left_child, vector &right_child)
 {
    class="type">class="kw">double weight_left = left_child.Size() / (class="type">class="kw">double)parent.Size(),
          weight_right = right_child.Size() / (class="type">class="kw">double)parent.Size();

    class="type">class="kw">double gain =class="num">0;   
    class="kw">switch(m_mode)
     {
     case  MODE_GINI:
        gain = gini_index(parent) - ( (weight_left*gini_index(left_child)) + (weight_right*gini_index(right_child)) );
        break;
     case MODE_ENTROPY:
        gain = entropy(parent) - ( (weight_left*entropy(left_child)) + (weight_right*entropy(right_child)) );
        break;
     }

   class="kw">return gain;
 }
class="type">class="kw">double CDecisionTree::entropy(vector &y)
 {    
   vector class_labels = matrix_utils.Unique_count(y);
    
   vector p_cls = class_labels / class="type">class="kw">double(y.Size());

   vector entropy = (-class="num">1 * p_cls) * log2(p_cls);

   class="kw">return entropy.Sum();
 }
class="type">class="kw">double CDecisionTree::gini_index(vector &y)
 {
   vector unique = matrix_utils.Unique_count(y);
   
   vector probabilities = unique / (class="type">class="kw">double)y.Size();
   
   class="kw">return class="num">1.0 - MathPow(probabilities, class="num">2).Sum();
 }

树怎么长出来:拆分与递归建树

决策树分类常用基尼杂质或熵做拆分准则,回归则多用均方误差。核心动作是用一个阈值把样本劈成左右两堆:特征值小于等于阈值的进 dataset_left,其余进 dataset_right,再回传含特征索引、阈值和信息增益的 split_info 结构。 算法并不会随便切一刀,而是遍历所有特征、取该特征下的唯一值作为候选阈值,逐个算信息增益,保留增益最大的那次拆分。下面这段 MQL5 结构和方法就是干这事的基础件。 [CODE] //A struct containing splitted data information struct split_info { uint feature_index; double threshold; matrix dataset_left, dataset_right; double info_gain; }; split_info CDecisionTree::split_data(const matrix &data, uint feature_index, double threshold=0.5) { int left_size=0, right_size =0; vector row = {}; split_info split; ulong cols = data.Cols(); split.dataset_left.Resize(0, cols); split.dataset_right.Resize(0, cols); for (ulong i=0; i<data.Rows(); i) { row = data.Row(i); if (row[feature_index] <= threshold) { left_size++; split.dataset_left.Resize(left_size, cols); split.dataset_left.Row(row, left_size-1); } else { right_size++; split.dataset_right.Resize(right_size, cols); split.dataset_right.Row(row, right_size-1); } } return split; } split_info CDecisionTree::get_best_split(matrix &data, uint num_features) { double max_info_gain = -DBL_MAX; vector feature_values = {}; vector left_v={}, right_v={}, y_v={}; //--- split_info best_split; split_info split; for (uint i=0; i<num_features; i) { feature_values = data.Col(i); vector possible_thresholds = matrix_utils.Unique(feature_values); //Find unique values in the feature, representing possible thresholds for splitting. for (uint j=0; j<possible_thresholds.Size(); j) { split = this.split_data(data, i, possible_thresholds[j]); if (split.dataset_left.Rows()>0 && split.dataset_right.Rows() > 0) { y_v = data.Col(data.Cols()-1); right_v = split.dataset_right.Col(split.dataset_right.Cols()-1); 代码逐行拆:split_info 结构存了特征列号、切分阈值、左右子矩阵和增益值;split_data 默认阈值 0.5,按行扫数据,满足 <=阈值 的塞进 left 矩阵,否则进 right 矩阵,两个矩阵都先 Resize 到 0 列宽再动态扩行。get_best_split 里 max_info_gain 初始化为 -DBL_MAX,保证任意真实增益都能覆盖它;用 matrix_utils.Unique 提唯一值当候选阈值,仅当左右子集都非空才计算增益,避免单边空树。 建树是递归的:如果 best_split.info_gain > 0 就继续,对左右子集分别 build_tree(curr_depth+1),否则生成叶节点并调 calculate_leaf_value 填值。把 build_tree 包在 fit 里更贴近 Python 习惯,预测时 make_predictions 递归比阈值,一路走到叶子返回结果。外汇和贵金属行情噪声大,直接拿原始价格训树容易过拟合,建议先算波动率特征再喂数据,回测胜率可能更稳。

MQL5 / C++
class=class="str">"cmt">//A class="kw">struct containing splitted data information
class="kw">struct split_info
  {
   class="type">uint feature_index;
   class="type">class="kw">double threshold;
   matrix dataset_left,
                dataset_right;
   class="type">class="kw">double info_gain;
   };
split_info CDecisionTree::split_data(const matrix &data, class="type">uint feature_index, class="type">class="kw">double threshold=class="num">0.5)
{
   class="type">int left_size=class="num">0, right_size =class="num">0;
   vector row = {};

   split_info split;

   class="type">ulong cols = data.Cols();

   split.dataset_left.Resize(class="num">0, cols);
   split.dataset_right.Resize(class="num">0, cols);

    for (class="type">ulong i=class="num">0; i<data.Rows(); i)
     {      
       row = data.Row(i);

       if (row[feature_index] <= threshold)
        {
         left_size++;
         split.dataset_left.Resize(left_size, cols);
         split.dataset_left.Row(row, left_size-class="num">1);
        }
       else
        {
         right_size++;
         split.dataset_right.Resize(right_size, cols);
         split.dataset_right.Row(row, right_size-class="num">1);         
        }
     }
   class="kw">return split;
}
split_info CDecisionTree::get_best_split(matrix &data, class="type">uint num_features)
  {

   class="type">class="kw">double max_info_gain = -DBL_MAX;
   vector feature_values = {};
   vector left_v={}, right_v={}, y_v={};

class=class="str">"cmt">//---

   split_info best_split;
   split_info split;

   for (class="type">uint i=class="num">0; i<num_features; i)
    {
     feature_values = data.Col(i);
     vector possible_thresholds = matrix_utils.Unique(feature_values); class=class="str">"cmt">//Find unique values in the feature, representing possible thresholds for splitting.

      for (class="type">uint j=class="num">0; j<possible_thresholds.Size(); j)
       {               
        split = this.split_data(data, i, possible_thresholds[j]);

        if (split.dataset_left.Rows()>class="num">0 && split.dataset_right.Rows() > class="num">0)
         {
          y_v = data.Col(data.Cols()-class="num">1);
          right_v = split.dataset_right.Col(split.dataset_right.Cols()-class="num">1);

「遍历切分点挑信息增益最大的节点」

决策树在生长时,核心动作是穷举每个特征的候选阈值,算出左右子集的信息增益,保留截至当前最大的那一次切分。下面这段逻辑里,left_v 取左子集的最后一列作为切分后的标签观测,curr_info_gain 由 information_gain 函数对 y_v、left_v、right_v 三者算出。 当 curr_info_gain 大于 max_info_gain 时,代码把本次的 feature_index、threshold、左右数据集和增益值全部写进 best_split,并更新 max_info_gain。DEBUG_MODE 下的 printf 会打出类似「split left: [120x5] split right: [80x5] curr_info_gain: 0.231044 max_info_gain: 0.198332」的日志,你可以直接开 MT5 定义 DEBUG_MODE 宏来核对每次切分的样本数与增益变化。 build_tree 负责把输入矩阵拆成特征矩阵 X 和目标向量 Y,用 X.Rows() 与 X.Cols() 拿到样本数 samples 和特征数 features。只有 samples >= m_min_samples_split 且 curr_depth <= m_max_depth 才允许继续调用 get_best_split 往下分;这两个条件直接决定树会不会过深,外汇与贵金属行情噪声大,过深容易过拟合,调参时建议先盯这两个值。

MQL5 / C++
left_v = split.dataset_left.Col(split.dataset_left.Cols()-class="num">1);

class="type">class="kw">double curr_info_gain = this.information_gain(y_v, left_v, right_v);

if (curr_info_gain > max_info_gain) class=class="str">"cmt">// Check if the current information gain is greater than the maximum observed so far.
  {
  class="macro">#ifdef DEBUG_MODE
  printf("split left: [%dx%d] split right: [%dx%d] curr_info_gain: %f max_info_gain: %f",split.dataset_left.Rows(),split.dataset_left.Cols(),split.dataset_right.Rows(),split.dataset_right.Cols(),curr_info_gain,max_info_gain);
  class="macro">#endif
  
  best_split.feature_index = i;
  best_split.threshold = possible_thresholds[j];
  best_split.dataset_left = split.dataset_left;
  best_split.dataset_right = split.dataset_right;
  best_split.info_gain = curr_info_gain;
  
  max_info_gain = curr_info_gain;
  }
   }
    }

class="kw">return best_split;
}
Node *CDecisionTree::build_tree(matrix &data, class="type">uint curr_depth=class="num">0)
{
  matrix X;
  vector Y;
  
  matrix_utils.XandYSplitMatrices(data,X,Y); class=class="str">"cmt">//Split the class="kw">input matrix into feature matrix X and target vector Y.

  class="type">ulong samples = X.Rows(), features = X.Cols(); class=class="str">"cmt">//Get the number of samples and features in the dataset.
    
  Node *node= NULL; class=class="str">"cmt">// Initialize node pointer
    
  if (samples >= m_min_samples_split && curr_depth<=m_max_depth)
    {
    split_info best_split = this.get_best_split(data, (class="type">uint)features);
    
    class="macro">#ifdef DEBUG_MODE
    Print("best_split left: [",best_split.dataset_left.Rows(),"x",best_split.dataset_left.Cols(),"]\nbest_split right: [",best_split.dataset_right.Rows(),"x",best_split.dataset_right.Cols(),"]\nfeature_index: ",best_split.feature_index,"\nInfo gain: ",best_split.info_gain,"\nThreshold: ",best_split.threshold);
    class="macro">#endif

◍ 递归建树与预测落点的代码骨架

决策树在信息增益大于 0 时才继续分裂,否则直接落成叶子节点并写入 calculate_leaf_value 算出的数值。build_tree 里左右子集各带 curr_depth+1 递归往下走,节点保存 feature_index、threshold 与 info_gain,这条链路决定了后续预测时走左还是走右。 fit 函数把特征矩阵 x 与标签 y 按列拼接成 data,再交給 build_tree 生成 root;predict 对矩阵逐行调单样本预测,返回长度等于 x.Rows() 的向量,你在 MT5 里跑完可直接比对信号序列。 make_predictions 是真正的落点逻辑:遇到 leaf_value 非 NULL 就返回该值,否则按 x[tree.feature_index] 与 threshold 的大小关系递归进左或右子树。DEBUG_MODE 下会 printf 出 threshold、feature_index 与 leaf_value,方便你抓某次 Split 异常。 外汇与贵金属行情受杠杆与跳空影响,树模型信号仅代表历史样本下的概率倾向,实盘前务必用策略测试器跑多品种验证。

MQL5 / C++
if (best_split.info_gain > class="num">0)
  {
    Node *left_child = this.build_tree(best_split.dataset_left, curr_depth+class="num">1);
    Node *right_child = this.build_tree(best_split.dataset_right, curr_depth+class="num">1);

    node = new Node(best_split.feature_index,best_split.threshold,left_child,right_child,best_split.info_gain);
    class="kw">return node;
  }
}

node = new Node();
node.leaf_value = this.calculate_leaf_value(Y);

class="kw">return node;
}
node = new Node(best_split.feature_index, best_split.threshold, left_child, right_child, best_split.info_gain);
node = new Node();
node.value = this.calculate_leaf_value(Y);
class="kw">return node;
class="type">void CDecisionTree::fit(matrix &x, vector &y)
{
  matrix data = matrix_utils.concatenate(x, y, class="num">1);
  this.root = this.build_tree(data);
}
vector CDecisionTree::predict(matrix &x)
{
   vector ret(x.Rows());
   for (class="type">ulong i=class="num">0; i<x.Rows(); i++)
      ret[i] = this.predict(x.Row(i));
   class="kw">return ret;
}
class="type">class="kw">double CDecisionTree::predict(vector &x)
{ 
  class="kw">return this.make_predictions(x, this.root);
}
class="type">class="kw">double CDecisionTree::make_predictions(vector &x, const Node &tree)
{
  if (tree.leaf_value != NULL) class=class="str">"cmt">// This is a leaf leaf_value
    class="kw">return tree.leaf_value;

  class="type">class="kw">double feature_value = x[tree.feature_index];
  class="type">class="kw">double pred = class="num">0;

  class="macro">#ifdef DEBUG_MODE
    printf("Tree.threshold %f tree.feature_index %d leaf_value %f",tree.threshold,tree.feature_index,tree.leaf_value);
  class="macro">#endif

  if (feature_value <= tree.threshold)
    {
     pred = this.make_predictions(x, tree.left_child); 
    }
  else
   {
     pred = this.make_predictions(x, tree.right_child);
   }
  class="kw">return pred;
}
if (feature_value <= tree.threshold):
pred = this.make_predictions(x, *tree.left_child);
pred = this.make_predictions(x, *tree.right_child);
class="kw">return pred;
把树结构诊断交给小布
这些节点拆分与递归逻辑,小布盯盘的 AIGC 已内置了结构可视化,打开对应品种页即可看到特征阈值走向,你只管调策略。

常见问题

决策树本身是自相似的树形结构,每个内部节点都要对子集再拆分,递归能自然映射这种层级;非递归写法在 MQL5 里容易把分支管理写乱。
ID3 多用信息增益做离散拆分,CART 常用基尼不纯度且支持回归;外汇特征多连续值,CART 倾向更顺手,但计算开销略高。
小布盯盘内置了特征阈值诊断模块,你可把本文的 build_tree 逻辑映射进去看盘口分类,不必自己从头搭递归框架。
分类任务里 double 存的是类别编码,回归任务存的是预测值;只要在节点里标记任务类型就不会冲突。
随机森林就是多棵树集成,单棵树的拆分质量和过拟合控制直接决定森林表现,基础不稳后面调参也白搭。