M3ngL

决策树模型部署到CPP项目

$Introduction$

决策树是基于已知各种情况(特征取值)的基础上,通过构建树型决策结构来进行分析的一种方式,是有监督的分类算法

  • 每个内部结点表示一个属性的测试
  • 每个分支表示一个测试输出
  • 每个叶结点代表一种类别

决策树图标

决策过程就是从根结点开始,测试待分类项中对应的特征属性,并按照其值选择输出分支,直到叶子结点,将叶子结点的存放的类别作为决策结果。

我们由 sklearnDecisionTreeClassifier 训练得到的决策树,默认且主要基于 CART (Classification and Regression Trees) 算法的实现。而 CART 算法构建的决策树始终是二叉树。

本文使用的是分类决策树模型训练以及部署。

JSON格式部署

该方法会利用到CPP第三方库来解析JSON格式,以及标准库来通过字符串查找键值对的值。

另存为JSON

先将模型转换为字典数据,为方便模型可视化,将特征名作为字典的键名

决策树模型训练完成后储存在模型中的是输入向量特征的索引,而不是字符串名称,因此通过索引将特征名按决策顺序写入列表。其中当索引为-2时,指代当前节点为叶子节点(约定俗成),当为其他索引时,则指代训练时传入的输入向量的特征名。

feature_names = [...]
feature_name = [
    feature_names[i] if i != -2 else "leaf" for i in tree_.feature
]

再通过递归实现决策树,这里选择前序遍历,之后的部署预测过程也需要同样的方式来遍历决策树实现预测分类

def tree_to_dict(tree, feature_names):
    # 获取决策树各节点的特征名
    tree_ = tree.tree_
    feature_name = [
        feature_names[i] if i != -2 else "leaf" for i in tree_.feature
    ]

    def recurse(node):
        # 叶子节点
        if tree_.children_left[node] == tree_.children_right[node]:
            return {"value": tree_.value[node].tolist(), "type": 0}
        # 内部节点
        else:
            return {
                "feature": feature_name[node],
                "threshold": tree_.threshold[node],
                "left": recurse(tree_.children_left[node]),
                "right": recurse(tree_.children_right[node]),
                # 保存整个value列表
                "value": tree_.value[node].tolist(), 
                # 仅保存value列表中值(概率)最大的索引
                # "value": tree_.value[node].tolist().index(max(tree_.value[node].tolist())) 
                "type": 1
            }
    return recurse(0)

这里为了部署过程方便比较,将type字段中的leafnode使用01来代替

也可以进一步减少部署预测过程中的开销,可以在保存为字典数据的过程中,使用数字代替这里所有出现的字符串,比如0代表feature字段等方式

再将字典数据解析缩进的情况下另存为JSON格式

import json
tree_dict = tree_to_dict(model, X_train.columns.tolist())

# 导出为 JSON 文件
with open("decision_tree_model.json", "w") as f:
    json.dump(tree_dict, f, indent=4)

生成效果

{
    "feature": "A",
    "threshold": 1.0153979063034058,
    "left": {
        "feature": "B",
        "threshold": 24.979429244995117,
        "left": {
            "feature": "C",
            "threshold": 0.19705434888601303,
            "left": {
                "feature": "D",
                "threshold": -30.24269676208496,
                "left": {
                    "value": [
                        [
                            0.0,
                            0.0,
                            1.0
                        ]
                    ],
                    "type": "0"
                },
        ...
}

部署加载

将json格式的模型拷贝到CPP项目中,调用第三方库 <nlohmann/json.hpp>

#include <fstream>
#include <nlohmann/json.hpp>
using json = nlohmann::json;

加载JSON格式的模型,这里的dt_model默认是根节点开始的决策树模型。

std::ifstream f("decision_tree_model.json");
dt_model = json::parse(f);

由于另存为json的时候保证可视性,将特征按照字符串名称存储,那么在部署预测过程中需要通过字符串找到模型训练完后的原始索引,这样才能在决策过程中,将输入数组和特征对应起来。因此在CPP项目中再定义一个能字符串与原始索引一一对应的字典数据node_dict

std::unordered_map<std::string, int> node_dict = {
    {"A", 0},
    {"B", 1},
    {"C", 2},
    {"D", 3},
    {"E", 4},
	...
    {"T", 19}
};

CPP实现分类决策树决策过程,input是模型预测的输入数组,输入数组的排序也是与训练时的输入训练向量一致。

float predict_node_json(const json& node, const float* input) const{
    // leaf节点处理
    if (node["type"] == 0) { 
        // value字段存储的是整个value列表
        std::vector<float> arr = node["value"][0];
        auto maxElement = std::max_element(arr.begin(), arr.end());
        return std::distance(arr.begin(), maxElement);
        // value字段存储的是概率最大的下标索引
        // return node["value"];
    }
    // node节点处理
    else { 
        // 通过字符串寻找到索引,再由索引定位输入数组的第i个特征量,进而与阈值比较
        std::string featureName = node["feature"].get<std::string>();
        int future_id = node_dict.find(featureName)->second;
        float threshold = node["threshold"].get<float>();

        if (input[future_id] < threshold) {
            return predict_node_json(node["left"], input);
        } else {
            return predict_node_json(node["right"], input);
        }
    }
}

CPP数组形式部署

该方法不需要第三方库以及标准库。此外,为了进一步缩减部署预测过程的开销,统一将字段值均换为数字代替。

另存为CPP数组

先将决策树模型的各个节点(每个节点保存为一个字典数据)存储进列表中,其中保存的叶子节点的值不是列表形式,而是最大概率类别索引,且存储在右孩子节点中。每个节点的字典键值对包括is_leaffeaturethresholdleftright

tree = dt_model.tree_
# 用于存储 C++ 数组形式的结构
cpp_nodes = []
def recurse(node_id):
    if tree.children_left[node_id] == tree.children_right[node_id]:
        # 叶子节点
        probs = tree.value[node_id][0]
        class_index = int(np.argmax(probs))  # 最大概率类别索引
        cpp_nodes.append({
            'is_leaf': 1,
            'feature': -1,
            'threshold': -1,
            'left': -1,
            'right': class_index
        })
    else:
        # 内部节点
        current_index = len(cpp_nodes)
        cpp_nodes.append({})
        cpp_nodes[current_index] = {
            'is_leaf': 0,
            'feature': int(tree.feature[node_id]),
            'threshold': float(tree.threshold[node_id]),
            'left': tree.children_left[node_id],
            'right': tree.children_right[node_id]
        }
        recurse(tree.children_left[node_id])
        recurse(tree.children_right[node_id])

# 开始另存为决策树模型
recurse(0)

逐个遍历列表数据cpp_nodes,按cpp数组的格式将字典数据写入.h文件

# 保存为 decision_tree_model.h
with open("decision_tree_model.h", "w") as f:
    f.write("#pragma once\n\n")
    f.write("TreeNode DecisionTree[] = {\n")
    for node in cpp_nodes:
        f.write("    {" +
                f"{node['is_leaf']}, {node['feature']}, {node['threshold']}, {node['left']}, {node['right']}" +
                "},\n")
    f.write("};\n")

生成效果

TreeNode DecisionTree[] = {
    {0, 8, 1.0153979063034058, 1, 106},
    {0, 16, 24.979429244995117, 2, 65},
    {0, 1, 0.19705434888601303, 3, 34},
    {0, 17, -30.24269676208496, 4, 19},
    {1, -1, -1, -1, 2},
    ...
}

部署加载

按照另存为时候的字典键值对,进行数据结构定义

struct TreeNode {
    int is_leaf;
    int feature;
    float threshold;
    int left;
    int right;
};

部署预测过程

int predict_node(TreeNode* tree, const float* features) {
    int node = 0;
    while (!tree[node].is_leaf) {
        int feature_index = tree[node].feature;
        float threshold = tree[node].threshold;

        if (features[feature_index] < threshold)
            node = tree[node].left;
        else
            node = tree[node].right;
    }
    return tree[node].right;  // 叶节点中存储的是最大概率类别的索引
}

可视化决策树模型

from sklearn.tree import export_text
tree_text = export_text(dt_model, feature_names=[...])
print(tree_text)

生成效果

|--- A <= 0.58
|   |--- B <= 26.18
|   |   |--- C <= -30.33
|   |   |   |--- D <= 7.49
|   |   |   |   |--- E <= 0.56
|   |   |   |   |   |--- F <= 0.04
|   |   |   |   |   |   |--- G <= 0.81
|   |   |   |   |   |   |   |--- class: 0
|   |   |   |   |   |   |--- G >  0.81
|   |   |   |   |   |   |   |--- class: 1
...

$Reference$

https://www.showmeai.tech/article-detail/190

https://blog.csdn.net/qq_35789269/article/details/131335427

📑
目录