diff --git a/include/treelite/base.h b/include/treelite/base.h index 56539cb7..28ab2dc2 100644 --- a/include/treelite/base.h +++ b/include/treelite/base.h @@ -51,6 +51,20 @@ inline std::string OpName(Operator op) { } } +/*! + * \brief Get string representation of split type + * \param type Type of a split + * \return String representation + */ +inline std::string SplitFeatureTypeName(SplitFeatureType type) { + switch (type) { + case SplitFeatureType::kNone: return "none"; + case SplitFeatureType::kNumerical: return "numerical"; + case SplitFeatureType::kCategorical: return "categorical"; + default: return ""; + } +} + /*! * \brief perform comparison between two float's using a comparsion operator * The comparison will be in the form [lhs] [op] [rhs]. diff --git a/include/treelite/c_api.h b/include/treelite/c_api.h index 58babfa3..46c4d8b1 100644 --- a/include/treelite/c_api.h +++ b/include/treelite/c_api.h @@ -334,6 +334,16 @@ TREELITE_DLL int TreeliteSerializeModel(const char* filename, ModelHandle handle */ TREELITE_DLL int TreeliteDeserializeModel(const char* filename, ModelHandle* out); +/*! + * \brief Dump a model object as a JSON string + * \param handle The handle to the model object + * \param pretty_print Whether to pretty-print JSON string (0 for false, != 0 for true) + * \param out_json_str The JSON string + * \return 0 for success, -1 for failure + */ +TREELITE_DLL int TreeliteDumpAsJSON(ModelHandle handle, int pretty_print, + const char** out_json_str); + /*! * \brief delete model from memory * \param handle model to remove diff --git a/include/treelite/tree.h b/include/treelite/tree.h index bc224648..7fd37223 100644 --- a/include/treelite/tree.h +++ b/include/treelite/tree.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -30,6 +31,9 @@ namespace treelite { +template +class ModelImpl; + // Represent a frame in the Python buffer protocol (PEP 3118). We use a simplified representation // to hold only 1-D arrays with stride 1. struct PyBufferFrame { @@ -154,6 +158,16 @@ enum class TaskType : uint8_t { kMultiClfCategLeaf = 3 }; +inline std::string TaskTypeToString(TaskType type) { + switch (type) { + case TaskType::kBinaryClfRegr: return "BinaryClfRegr"; + case TaskType::kMultiClfGrovePerClass: return "MultiClfGrovePerClass"; + case TaskType::kMultiClfProbDistLeaf: return "MultiClfProbDistLeaf"; + case TaskType::kMultiClfCategLeaf: return "MultiClfCategLeaf"; + default: return ""; + } +} + /*! \brief Group of parameters that are dependent on the choice of the task type. */ struct TaskParam { enum class OutputType : uint8_t { kFloat = 0, kInt = 1 }; @@ -184,6 +198,14 @@ struct TaskParam { unsigned int leaf_vector_size; }; +inline std::string OutputTypeToString(TaskParam::OutputType type) { + switch (type) { + case TaskParam::OutputType::kFloat: return "float"; + case TaskParam::OutputType::kInt: return "int"; + default: return ""; + } +} + static_assert(std::is_pod::value, "TaskParameter must be POD type"); /*! \brief in-memory representation of a decision tree */ @@ -288,7 +310,9 @@ class Tree { ContiguousArray matching_categories_offset_; template - friend void SerializeTreeToJSON(WriterType& writer, const Tree& tree); + friend void DumpModelAsJSON(WriterType& writer, const ModelImpl& model); + template + friend void DumpTreeAsJSON(WriterType& writer, const Tree& tree); // allocate a new node inline int AllocNode(); @@ -427,14 +451,6 @@ class Tree { // Use unsafe access here, since we may need to take the address of one past the last // element, to follow with the range semantic of std::vector<>. } - /*! - * \brief tests whether the node has a non-empty list for matching categories. See - * MatchingCategories() for the definition of matching categories. - * \param nid ID of node being queried - */ - inline bool HasMatchingCategories(int nid) const { - return matching_categories_offset_.at(nid) != matching_categories_offset_.at(nid + 1); - } /*! * \brief get feature split type * \param nid ID of node being queried @@ -655,7 +671,13 @@ class Model { virtual std::size_t GetNumTree() const = 0; virtual void SetTreeLimit(std::size_t limit) = 0; - virtual void SerializeToJSON(std::ostream& fo) const = 0; + virtual void DumpAsJSON(std::ostream& fo, bool pretty_print) const = 0; + + inline std::string DumpAsJSON(bool pretty_print) const { + std::ostringstream oss; + DumpAsJSON(oss, pretty_print); + return oss.str(); + } /* In-memory serialization, zero-copy */ std::vector GetPyBuffer(); @@ -711,7 +733,7 @@ class ModelImpl : public Model { ModelImpl(ModelImpl&&) noexcept = default; ModelImpl& operator=(ModelImpl&&) noexcept = default; - void SerializeToJSON(std::ostream& fo) const override; + void DumpAsJSON(std::ostream& fo, bool pretty_print) const override; inline std::size_t GetNumTree() const override { return trees.size(); } diff --git a/python/treelite/frontend.py b/python/treelite/frontend.py index ccf62942..1d538e80 100644 --- a/python/treelite/frontend.py +++ b/python/treelite/frontend.py @@ -10,7 +10,7 @@ import numpy as np -from .util import c_str, TreeliteError, type_info_to_ctypes_type, type_info_to_numpy_type +from .util import c_str, py_str, TreeliteError, type_info_to_ctypes_type, type_info_to_numpy_type from .core import _LIB, c_array, _check_call from .contrib import create_shared, generate_makefile, generate_cmakelists, _toolchain_exist_check @@ -74,6 +74,29 @@ def serialize(self, filename): """ _check_call(_LIB.TreeliteSerializeModel(c_str(filename), self.handle)) + def dump_as_json(self, *, pretty_print=True): + """ + Dump the model as a JSON string. This is useful for inspecting details of the tree ensemble + model. + + Parameters + ---------- + pretty_print : :py:class:`bool `, optional + Whether to pretty-print the JSON string, set this to False to make the string compact + + Returns + ------- + json_str : :py:class:`str ` + JSON string representing the model + """ + json_str = ctypes.c_char_p() + _check_call(_LIB.TreeliteDumpAsJSON( + self.handle, + ctypes.c_int(1 if pretty_print else 0), + ctypes.byref(json_str) + )) + return py_str(json_str.value) + @classmethod def deserialize(cls, filename): """ diff --git a/src/c_api/c_api.cc b/src/c_api/c_api.cc index 07651543..7393b46f 100644 --- a/src/c_api/c_api.cc +++ b/src/c_api/c_api.cc @@ -20,10 +20,24 @@ #include #include #include +#include #include using namespace treelite; +namespace { + +/*! \brief entry to to easily hold returning information */ +struct TreeliteAPIThreadLocalEntry { + /*! \brief result holder for returning string */ + std::string ret_str; +}; + +// define threadlocal store for returning information +using TreeliteAPIThreadLocalStore = ThreadLocalStore; + +} // anonymous namespace + int TreeliteAnnotateBranch( ModelHandle model, DMatrixHandle dmat, int nthread, int verbose, AnnotationHandle* out) { API_BEGIN(); @@ -206,6 +220,15 @@ int TreeliteDeserializeModel(const char* filename, ModelHandle* out) { API_END(); } +int TreeliteDumpAsJSON(ModelHandle handle, int pretty_print, const char** out_json_str) { + API_BEGIN(); + auto* model_ = static_cast(handle); + std::string& ret_str = TreeliteAPIThreadLocalStore::Get()->ret_str; + ret_str = model_->DumpAsJSON(pretty_print != 0); + *out_json_str = ret_str.c_str(); + API_END(); +} + int TreeliteFreeModel(ModelHandle handle) { API_BEGIN(); delete static_cast(handle); diff --git a/src/c_api/c_api_common.cc b/src/c_api/c_api_common.cc index 7b359671..fe236429 100644 --- a/src/c_api/c_api_common.cc +++ b/src/c_api/c_api_common.cc @@ -13,15 +13,6 @@ using namespace treelite; -/*! \brief entry to to easily hold returning information */ -struct TreeliteAPIThreadLocalEntry { - /*! \brief result holder for returning string */ - std::string ret_str; -}; - -// define threadlocal store for returning information -using TreeliteAPIThreadLocalStore = ThreadLocalStore; - int TreeliteRegisterLogCallback(void (*callback)(const char*)) { API_BEGIN(); LogCallbackRegistry* registry = LogCallbackRegistryStore::Get(); diff --git a/src/c_api/c_api_error.cc b/src/c_api/c_api_error.cc index 7144dedc..599ab477 100644 --- a/src/c_api/c_api_error.cc +++ b/src/c_api/c_api_error.cc @@ -8,12 +8,16 @@ #include #include +namespace { + struct TreeliteAPIErrorEntry { std::string last_error; }; using TreeliteAPIErrorStore = treelite::ThreadLocalStore; +} // anonymous namespace + const char* TreeliteGetLastError() { return TreeliteAPIErrorStore::Get()->last_error.c_str(); } diff --git a/src/compiler/failsafe.cc b/src/compiler/failsafe.cc index 39d1d81b..c901934d 100644 --- a/src/compiler/failsafe.cc +++ b/src/compiler/failsafe.cc @@ -153,7 +153,7 @@ inline std::pair FormatNodesArray( "cright"_a = -1); } else { TREELITE_CHECK(tree.SplitType(nid) == treelite::SplitFeatureType::kNumerical - && !tree.HasMatchingCategories(nid)) + && tree.MatchingCategories(nid).empty()) << "categorical splits are not supported in FailSafeCompiler"; nodes << fmt::format("{{ 0x{sindex:X}, {info}, {cleft}, {cright} }}", "sindex"_a @@ -189,7 +189,7 @@ inline std::pair, std::string> FormatNodesArrayELF( val = {0, static_cast(tree.LeafValue(nid)), -1, -1}; } else { TREELITE_CHECK(tree.SplitType(nid) == treelite::SplitFeatureType::kNumerical - && !tree.HasMatchingCategories(nid)) + && tree.MatchingCategories(nid).empty()) << "categorical splits are not supported in FailSafeCompiler"; val = {(tree.SplitIndex(nid) | (static_cast(tree.DefaultLeft(nid)) << 31)), static_cast(tree.Threshold(nid)), tree.LeftChild(nid), tree.RightChild(nid)}; diff --git a/src/json_serializer.cc b/src/json_serializer.cc index 55f19d29..a7f0ac58 100644 --- a/src/json_serializer.cc +++ b/src/json_serializer.cc @@ -10,75 +10,100 @@ #include #include #include +#include #include +#include #include #include namespace { +template ::value, bool>::type = true> +void WriteElement(WriterType& writer, T e) { + writer.Uint64(static_cast(e)); +} + +template ::value, bool>::type = true> +void WriteElement(WriterType& writer, T e) { + writer.Double(static_cast(e)); +} + template -void WriteElement(WriterType& writer, double e) { - writer.Double(e); +void WriteString(WriterType& writer, const std::string& str) { + writer.String(str.data(), str.size()); } template void WriteNode(WriterType& writer, - const typename treelite::Tree::Node& node) { + const treelite::Tree& tree, + int node_id) { writer.StartObject(); - writer.Key("cleft"); - writer.Int(node.cleft_); - writer.Key("cright"); - writer.Int(node.cright_); - writer.Key("split_index"); - writer.Uint(node.sindex_ & ((1U << 31U) - 1U)); - writer.Key("default_left"); - writer.Bool((node.sindex_ >> 31U) != 0); - if (node.cleft_ == -1) { + writer.Key("node_id"); + writer.Int(node_id); + if (tree.IsLeaf(node_id)) { writer.Key("leaf_value"); - writer.Double(node.info_.leaf_value); + if (tree.HasLeafVector(node_id)) { + writer.StartArray(); + for (LeafOutputType e : tree.LeafVector(node_id)) { + WriteElement(writer, e); + } + writer.EndArray(); + } else { + WriteElement(writer, tree.LeafValue(node_id)); + } } else { - writer.Key("threshold"); - writer.Double(node.info_.threshold); + writer.Key("split_feature_id"); + writer.Uint(tree.SplitIndex(node_id)); + writer.Key("default_left"); + writer.Bool(tree.DefaultLeft(node_id)); + writer.Key("split_type"); + auto split_type = tree.SplitType(node_id); + WriteString(writer, treelite::SplitFeatureTypeName(split_type)); + if (split_type == treelite::SplitFeatureType::kNumerical) { + writer.Key("comparison_op"); + WriteString(writer, treelite::OpName(tree.ComparisonOp(node_id))); + writer.Key("threshold"); + writer.Double(tree.Threshold(node_id)); + } else if (split_type == treelite::SplitFeatureType::kCategorical) { + writer.Key("categories_list_right_child"); + writer.Bool(tree.CategoriesListRightChild(node_id)); + writer.Key("matching_categories"); + writer.StartArray(); + for (uint32_t e : tree.MatchingCategories(node_id)) { + writer.Uint(e); + } + writer.EndArray(); + } + writer.Key("left_child"); + writer.Int(tree.LeftChild(node_id)); + writer.Key("right_child"); + writer.Int(tree.RightChild(node_id)); } - if (node.data_count_present_) { + if (tree.HasDataCount(node_id)) { writer.Key("data_count"); - writer.Uint64(node.data_count_); + writer.Uint64(tree.DataCount(node_id)); } - if (node.sum_hess_present_) { + if (tree.HasSumHess(node_id)) { writer.Key("sum_hess"); - writer.Double(node.sum_hess_); + writer.Double(tree.SumHess(node_id)); } - if (node.gain_present_) { + if (tree.HasGain(node_id)) { writer.Key("gain"); - writer.Double(node.gain_); + writer.Double(tree.Gain(node_id)); } - writer.Key("split_type"); - writer.Int(static_cast(node.split_type_)); - writer.Key("cmp"); - writer.Int(static_cast(node.cmp_)); - writer.Key("categories_list_right_child"); - writer.Bool(node.categories_list_right_child_); writer.EndObject(); } -template -void WriteContiguousArray(WriterType& writer, - const treelite::ContiguousArray& array) { - writer.StartArray(); - for (std::size_t i = 0; i < array.Size(); ++i) { - WriteElement(writer, array[i]); - } - writer.EndArray(); -} - template void SerializeTaskParamToJSON(WriterType& writer, treelite::TaskParam task_param) { writer.StartObject(); writer.Key("output_type"); - writer.Uint(static_cast(task_param.output_type)); + WriteString(writer, treelite::OutputTypeToString(task_param.output_type)); writer.Key("grove_per_class"); writer.Bool(task_param.grove_per_class); writer.Key("num_class"); @@ -94,8 +119,7 @@ void SerializeModelParamToJSON(WriterType& writer, treelite::ModelParam model_pa writer.StartObject(); writer.Key("pred_transform"); - std::string pred_transform(model_param.pred_transform); - writer.String(pred_transform.data(), pred_transform.size()); + WriteString(writer, std::string(model_param.pred_transform)); writer.Key("sigmoid_alpha"); writer.Double(model_param.sigmoid_alpha); writer.Key("global_bias"); @@ -109,25 +133,15 @@ void SerializeModelParamToJSON(WriterType& writer, treelite::ModelParam model_pa namespace treelite { template -void SerializeTreeToJSON(WriterType& writer, const Tree& tree) { +void DumpTreeAsJSON(WriterType& writer, const Tree& tree) { writer.StartObject(); writer.Key("num_nodes"); writer.Int(tree.num_nodes); - writer.Key("leaf_vector"); - WriteContiguousArray(writer, tree.leaf_vector_); - writer.Key("leaf_vector_begin"); - WriteContiguousArray(writer, tree.leaf_vector_begin_); - writer.Key("leaf_vector_end"); - WriteContiguousArray(writer, tree.leaf_vector_end_); - writer.Key("matching_categories"); - WriteContiguousArray(writer, tree.matching_categories_); - writer.Key("matching_categories_offset"); - WriteContiguousArray(writer, tree.matching_categories_offset_); writer.Key("nodes"); writer.StartArray(); for (std::size_t i = 0; i < tree.nodes_.Size(); ++i) { - WriteNode(writer, tree.nodes_[i]); + WriteNode(writer, tree, i); } writer.EndArray(); @@ -139,36 +153,48 @@ void SerializeTreeToJSON(WriterType& writer, const Tree -void ModelImpl::SerializeToJSON(std::ostream& fo) const { - rapidjson::OStreamWrapper os(fo); - rapidjson::Writer writer(os); - +template +void DumpModelAsJSON(WriterType& writer, + const ModelImpl& model) { writer.StartObject(); writer.Key("num_feature"); - writer.Int(num_feature); + writer.Int(model.num_feature); writer.Key("task_type"); - writer.Uint(static_cast(task_type)); + WriteString(writer, TaskTypeToString(model.task_type)); writer.Key("average_tree_output"); - writer.Bool(average_tree_output); + writer.Bool(model.average_tree_output); writer.Key("task_param"); - SerializeTaskParamToJSON(writer, task_param); + SerializeTaskParamToJSON(writer, model.task_param); writer.Key("model_param"); - SerializeModelParamToJSON(writer, param); + SerializeModelParamToJSON(writer, model.param); writer.Key("trees"); writer.StartArray(); - for (const Tree& tree : trees) { - SerializeTreeToJSON(writer, tree); + for (const Tree& tree : model.trees) { + DumpTreeAsJSON(writer, tree); } writer.EndArray(); writer.EndObject(); } -template void ModelImpl::SerializeToJSON(std::ostream& fo) const; -template void ModelImpl::SerializeToJSON(std::ostream& fo) const; -template void ModelImpl::SerializeToJSON(std::ostream& fo) const; -template void ModelImpl::SerializeToJSON(std::ostream& fo) const; +template +void +ModelImpl::DumpAsJSON(std::ostream& fo, bool pretty_print) const { + rapidjson::OStreamWrapper os(fo); + if (pretty_print) { + rapidjson::PrettyWriter writer(os); + writer.SetFormatOptions(rapidjson::PrettyFormatOptions::kFormatSingleLineArray); + DumpModelAsJSON(writer, *this); + } else { + rapidjson::Writer writer(os); + DumpModelAsJSON(writer, *this); + } +} + +template void ModelImpl::DumpAsJSON(std::ostream& fo, bool pretty_print) const; +template void ModelImpl::DumpAsJSON(std::ostream& fo, bool pretty_print) const; +template void ModelImpl::DumpAsJSON(std::ostream& fo, bool pretty_print) const; +template void ModelImpl::DumpAsJSON(std::ostream& fo, bool pretty_print) const; } // namespace treelite diff --git a/tests/cpp/test_serializer.cc b/tests/cpp/test_serializer.cc index 19fe8526..e90d52a2 100644 --- a/tests/cpp/test_serializer.cc +++ b/tests/cpp/test_serializer.cc @@ -7,19 +7,18 @@ #include #include #include -#include +#include +#include +#include +#include #include #include #include #include -namespace { +using namespace fmt::literals; -inline std::string TreeliteToBytes(treelite::Model* model) { - std::ostringstream oss; - model->SerializeToJSON(oss); - return oss.str(); -} +namespace { inline void TestRoundTrip(treelite::Model* model) { for (int i = 0; i < 2; ++i) { @@ -29,7 +28,7 @@ inline void TestRoundTrip(treelite::Model* model) { // Use ASSERT_TRUE, since ASSERT_EQ will dump all the raw bytes into a string, potentially // causing an OOM error - ASSERT_TRUE(TreeliteToBytes(model) == TreeliteToBytes(received_model.get())); + ASSERT_TRUE(model->DumpAsJSON(false) == received_model->DumpAsJSON(false)); } for (int i = 0; i < 2; ++i) { @@ -46,7 +45,7 @@ inline void TestRoundTrip(treelite::Model* model) { // Use ASSERT_TRUE, since ASSERT_EQ will dump all the raw bytes into a string, potentially // causing an OOM error - ASSERT_TRUE(TreeliteToBytes(model) == TreeliteToBytes(received_model.get())); + ASSERT_TRUE(model->DumpAsJSON(false) == received_model->DumpAsJSON(false)); } } @@ -69,12 +68,62 @@ void PyBufferInterfaceRoundTrip_TreeStump() { tree->CreateNode(2); tree->SetNumericalTestNode(0, 0, "<", frontend::Value::Create(0), true, 1, 2); tree->SetRootNode(0); - tree->SetLeafNode(1, frontend::Value::Create(-1)); - tree->SetLeafNode(2, frontend::Value::Create(1)); + tree->SetLeafNode(1, frontend::Value::Create(1)); + tree->SetLeafNode(2, frontend::Value::Create(2)); builder->InsertTree(tree.get()); std::unique_ptr model = builder->CommitModel(); TestRoundTrip(model.get()); + + /* Test correctness of JSON dump */ + std::string expected_json_dump_str = fmt::format(R"JSON( + {{ + "num_feature": 2, + "task_type": "BinaryClfRegr", + "average_tree_output": false, + "task_param": {{ + "output_type": "float", + "grove_per_class": false, + "num_class": 1, + "leaf_vector_size": 1 + }}, + "model_param": {{ + "pred_transform": "identity", + "sigmoid_alpha": 1.0, + "global_bias": 0.0 + }}, + "trees": [{{ + "num_nodes": 3, + "nodes": [{{ + "node_id": 0, + "split_feature_id": 0, + "default_left": true, + "split_type": "numerical", + "comparison_op": "<", + "threshold": {threshold}, + "left_child": 1, + "right_child": 2 + }}, {{ + "node_id": 1, + "leaf_value": {leaf_value0} + }}, {{ + "node_id": 2, + "leaf_value": {leaf_value1} + }}] + }}] + }} + )JSON", + "threshold"_a = static_cast(0), + "leaf_value0"_a = static_cast(1), + "leaf_value1"_a = static_cast(2) + ); + + rapidjson::Document json_dump; + json_dump.Parse(model->DumpAsJSON(false).c_str()); + + rapidjson::Document expected_json_dump; + expected_json_dump.Parse(expected_json_dump_str.c_str()); + EXPECT_TRUE(json_dump == expected_json_dump); } TEST(PyBufferInterfaceRoundTrip, TreeStump) { @@ -104,14 +153,66 @@ void PyBufferInterfaceRoundTrip_TreeStumpLeafVec() { tree->CreateNode(2); tree->SetNumericalTestNode(0, 0, "<", frontend::Value::Create(0), true, 1, 2); tree->SetRootNode(0); - tree->SetLeafVectorNode(1, {frontend::Value::Create(-1), + tree->SetLeafVectorNode(1, {frontend::Value::Create(1), + frontend::Value::Create(2)}); + tree->SetLeafVectorNode(2, {frontend::Value::Create(2), frontend::Value::Create(1)}); - tree->SetLeafVectorNode(2, {frontend::Value::Create(1), - frontend::Value::Create(-1)}); builder->InsertTree(tree.get()); std::unique_ptr model = builder->CommitModel(); TestRoundTrip(model.get()); + + /* Test correctness of JSON dump */ + std::string expected_json_dump_str = fmt::format(R"JSON( + {{ + "num_feature": 2, + "task_type": "MultiClfProbDistLeaf", + "average_tree_output": true, + "task_param": {{ + "output_type": "float", + "grove_per_class": false, + "num_class": 2, + "leaf_vector_size": 2 + }}, + "model_param": {{ + "pred_transform": "identity", + "sigmoid_alpha": 1.0, + "global_bias": 0.0 + }}, + "trees": [{{ + "num_nodes": 3, + "nodes": [{{ + "node_id": 0, + "split_feature_id": 0, + "default_left": true, + "split_type": "numerical", + "comparison_op": "<", + "threshold": {threshold}, + "left_child": 1, + "right_child": 2 + }}, {{ + "node_id": 1, + "leaf_value": [{leaf_value0}, {leaf_value1}] + }}, {{ + "node_id": 2, + "leaf_value": [{leaf_value2}, {leaf_value3}] + }}] + }}] + }} + )JSON", + "threshold"_a = static_cast(0), + "leaf_value0"_a = static_cast(1), + "leaf_value1"_a = static_cast(2), + "leaf_value2"_a = static_cast(2), + "leaf_value3"_a = static_cast(1) + ); + + rapidjson::Document json_dump; + json_dump.Parse(model->DumpAsJSON(false).c_str()); + + rapidjson::Document expected_json_dump; + expected_json_dump.Parse(expected_json_dump_str.c_str()); + EXPECT_TRUE(json_dump == expected_json_dump); } TEST(PyBufferInterfaceRoundTrip, TreeStumpLeafVec) { @@ -145,14 +246,76 @@ void PyBufferInterfaceRoundTrip_TreeStumpCategoricalSplit( tree->CreateNode(0); tree->CreateNode(1); tree->CreateNode(2); - tree->SetCategoricalTestNode(0, 0, left_categories, true, 1, 2); + tree->SetCategoricalTestNode(0, 0, left_categories, false, 1, 2); tree->SetRootNode(0); - tree->SetLeafNode(1, frontend::Value::Create(-1)); - tree->SetLeafNode(2, frontend::Value::Create(1)); + tree->SetLeafNode(1, frontend::Value::Create(2)); + tree->SetLeafNode(2, frontend::Value::Create(3)); builder->InsertTree(tree.get()); std::unique_ptr model = builder->CommitModel(); TestRoundTrip(model.get()); + + /* Test correctness of JSON dump */ + std::string matching_categories_str; + { + std::ostringstream oss; + rapidjson::OStreamWrapper os_wrapper(oss); + rapidjson::Writer writer(os_wrapper); + writer.StartArray(); + for (auto e : left_categories) { + writer.Uint(static_cast(e)); + } + writer.EndArray(); + matching_categories_str = oss.str(); + } + std::string expected_json_dump_str = fmt::format(R"JSON( + {{ + "num_feature": 2, + "task_type": "BinaryClfRegr", + "average_tree_output": false, + "task_param": {{ + "output_type": "float", + "grove_per_class": false, + "num_class": 1, + "leaf_vector_size": 1 + }}, + "model_param": {{ + "pred_transform": "identity", + "sigmoid_alpha": 1.0, + "global_bias": 0.0 + }}, + "trees": [{{ + "num_nodes": 3, + "nodes": [{{ + "node_id": 0, + "split_feature_id": 0, + "default_left": false, + "split_type": "categorical", + "categories_list_right_child": false, + "matching_categories": {matching_categories}, + "left_child": 1, + "right_child": 2 + }}, {{ + "node_id": 1, + "leaf_value": {leaf_value0} + }}, {{ + "node_id": 2, + "leaf_value": {leaf_value1} + }}] + }}] + }} + )JSON", + "leaf_value0"_a = static_cast(2), + "leaf_value1"_a = static_cast(3), + "matching_categories"_a = matching_categories_str + ); + + rapidjson::Document json_dump; + json_dump.Parse(model->DumpAsJSON(false).c_str()); + + rapidjson::Document expected_json_dump; + expected_json_dump.Parse(expected_json_dump_str.c_str()); + EXPECT_TRUE(json_dump == expected_json_dump); } TEST(PyBufferInterfaceRoundTrip, TreeStumpCategoricalSplit) { @@ -208,6 +371,173 @@ void PyBufferInterfaceRoundTrip_TreeDepth2() { std::unique_ptr model = builder->CommitModel(); TestRoundTrip(model.get()); + + std::string expected_json_dump_str = fmt::format(R"JSON( + {{ + "num_feature": 2, + "task_type": "BinaryClfRegr", + "average_tree_output": false, + "task_param": {{ + "output_type": "float", + "grove_per_class": false, + "num_class": 1, + "leaf_vector_size": 1 + }}, + "model_param": {{ + "pred_transform": "sigmoid", + "sigmoid_alpha": 1.0, + "global_bias": 0.5 + }}, + "trees": [{{ + "num_nodes": 7, + "nodes": [{{ + "node_id": 0, + "split_feature_id": 0, + "default_left": true, + "split_type": "numerical", + "comparison_op": "<", + "threshold": {threshold}, + "left_child": 1, + "right_child": 2 + }}, {{ + "node_id": 1, + "split_feature_id": 0, + "default_left": true, + "split_type": "categorical", + "categories_list_right_child": false, + "matching_categories": [0, 1], + "left_child": 3, + "right_child": 4 + }}, {{ + "node_id": 2, + "split_feature_id": 1, + "default_left": true, + "split_type": "categorical", + "categories_list_right_child": false, + "matching_categories": [0], + "left_child": 5, + "right_child": 6 + }}, {{ + "node_id": 3, + "leaf_value": {tree0_leaf3} + }}, {{ + "node_id": 4, + "leaf_value": {tree0_leaf4} + }}, {{ + "node_id": 5, + "leaf_value": {tree0_leaf5} + }}, {{ + "node_id": 6, + "leaf_value": {tree0_leaf6} + }}] + }}, {{ + "num_nodes": 7, + "nodes": [{{ + "node_id": 0, + "split_feature_id": 0, + "default_left": true, + "split_type": "numerical", + "comparison_op": "<", + "threshold": {threshold}, + "left_child": 1, + "right_child": 2 + }}, {{ + "node_id": 1, + "split_feature_id": 0, + "default_left": true, + "split_type": "categorical", + "categories_list_right_child": false, + "matching_categories": [0, 1], + "left_child": 3, + "right_child": 4 + }}, {{ + "node_id": 2, + "split_feature_id": 1, + "default_left": true, + "split_type": "categorical", + "categories_list_right_child": false, + "matching_categories": [0], + "left_child": 5, + "right_child": 6 + }}, {{ + "node_id": 3, + "leaf_value": {tree1_leaf3} + }}, {{ + "node_id": 4, + "leaf_value": {tree1_leaf4} + }}, {{ + "node_id": 5, + "leaf_value": {tree1_leaf5} + }}, {{ + "node_id": 6, + "leaf_value": {tree1_leaf6} + }}] + }}, {{ + "num_nodes": 7, + "nodes": [{{ + "node_id": 0, + "split_feature_id": 0, + "default_left": true, + "split_type": "numerical", + "comparison_op": "<", + "threshold": {threshold}, + "left_child": 1, + "right_child": 2 + }}, {{ + "node_id": 1, + "split_feature_id": 0, + "default_left": true, + "split_type": "categorical", + "categories_list_right_child": false, + "matching_categories": [0, 1], + "left_child": 3, + "right_child": 4 + }}, {{ + "node_id": 2, + "split_feature_id": 1, + "default_left": true, + "split_type": "categorical", + "categories_list_right_child": false, + "matching_categories": [0], + "left_child": 5, + "right_child": 6 + }}, {{ + "node_id": 3, + "leaf_value": {tree2_leaf3} + }}, {{ + "node_id": 4, + "leaf_value": {tree2_leaf4} + }}, {{ + "node_id": 5, + "leaf_value": {tree2_leaf5} + }}, {{ + "node_id": 6, + "leaf_value": {tree2_leaf6} + }}] + }}] + }} + )JSON", + "threshold"_a = static_cast(0), + "tree0_leaf3"_a = static_cast(3), + "tree0_leaf4"_a = static_cast(1), + "tree0_leaf5"_a = static_cast(4), + "tree0_leaf6"_a = static_cast(2), + "tree1_leaf3"_a = static_cast(3 + 1), + "tree1_leaf4"_a = static_cast(1 + 1), + "tree1_leaf5"_a = static_cast(4 + 1), + "tree1_leaf6"_a = static_cast(2 + 1), + "tree2_leaf3"_a = static_cast(3 + 2), + "tree2_leaf4"_a = static_cast(1 + 2), + "tree2_leaf5"_a = static_cast(4 + 2), + "tree2_leaf6"_a = static_cast(2 + 2) + ); + + rapidjson::Document json_dump; + json_dump.Parse(model->DumpAsJSON(false).c_str()); + + rapidjson::Document expected_json_dump; + expected_json_dump.Parse(expected_json_dump_str.c_str()); + EXPECT_TRUE(json_dump == expected_json_dump); } TEST(PyBufferInterfaceRoundTrip, TreeDepth2) {