Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pr3 monotone constraints splits penalization #2939

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/Parameters.rst
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,14 @@ Learning Control Parameters

- ``intermediate``, a `more advanced method <https://github.com/microsoft/LightGBM/files/3457826/PR-monotone-constraints-report.pdf>`__, which may slow the library very slightly. However, this method is much less constraining than the basic method and should significantly improve the results

- ``monotone_penalty`` :raw-html:`<a id="monotone_penalty" title="Permalink to this parameter" href="#monotone_penalty">&#x1F517;&#xFE0E;</a>`, default = ``0.0``, type = double, aliases: ``monotone_splits_penalty``, ``ms_penalty``, ``mc_penalty``, constraints: ``monotone_penalty >= 0.0``

- used only if ``monotone_constraints`` is set

- `monotone penalty <https://github.com/microsoft/LightGBM/files/3457826/PR-monotone-constraints-report.pdf>`__: a penalization parameter X forbids any monotone splits on the first X (rounded down) level(s) of the tree. The penalty applied to monotone splits on a given depth is a continuous, increasing function the penalization parameter

- if ``0.0`` (the default), no penalization is applied

- ``feature_contri`` :raw-html:`<a id="feature_contri" title="Permalink to this parameter" href="#feature_contri">&#x1F517;&#xFE0E;</a>`, default = ``None``, type = multi-double, aliases: ``feature_contrib``, ``fc``, ``fp``, ``feature_penalty``

- used to control feature's split gain, will use ``gain[i] = max(0, feature_contri[i]) * gain[i]`` to replace the split gain of i-th feature
Expand Down
7 changes: 7 additions & 0 deletions include/LightGBM/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,13 @@ struct Config {
// descl2 = ``intermediate``, a `more advanced method <https://github.com/microsoft/LightGBM/files/3457826/PR-monotone-constraints-report.pdf>`__, which may slow the library very slightly. However, this method is much less constraining than the basic method and should significantly improve the results
std::string monotone_constraints_method = "basic";

// alias = monotone_splits_penalty, ms_penalty, mc_penalty
// check = >=0.0
// desc = used only if ``monotone_constraints`` is set
// desc = `monotone penalty <https://github.com/microsoft/LightGBM/files/3457826/PR-monotone-constraints-report.pdf>`__: a penalization parameter X forbids any monotone splits on the first X (rounded down) level(s) of the tree. The penalty applied to monotone splits on a given depth is a continuous, increasing function the penalization parameter
// desc = if ``0.0`` (the default), no penalization is applied
double monotone_penalty = 0.0;

// type = multi-double
// alias = feature_contrib, fc, fp, feature_penalty
// default = None
Expand Down
3 changes: 3 additions & 0 deletions src/io/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,9 @@ void Config::CheckParamConflict() {
Log::Warning("Cannot use \"intermediate\" monotone constraints with feature fraction different from 1, auto set monotone constraints to \"basic\" method.");
monotone_constraints_method = "basic";
}
if (max_depth > 0 && monotone_penalty >= max_depth) {
Log::Warning("Monotone penalty greater than tree depth. Monotone features won't be used.");
}
}

std::string Config::ToString() const {
Expand Down
8 changes: 8 additions & 0 deletions src/io/config_auto.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ const std::unordered_map<std::string, std::string>& Config::alias_table() {
{"monotone_constraint", "monotone_constraints"},
{"monotone_constraining_method", "monotone_constraints_method"},
{"mc_method", "monotone_constraints_method"},
{"monotone_splits_penalty", "monotone_penalty"},
{"ms_penalty", "monotone_penalty"},
{"mc_penalty", "monotone_penalty"},
{"feature_contrib", "feature_contri"},
{"fc", "feature_contri"},
{"fp", "feature_contri"},
Expand Down Expand Up @@ -218,6 +221,7 @@ const std::unordered_set<std::string>& Config::parameter_set() {
"top_k",
"monotone_constraints",
"monotone_constraints_method",
"monotone_penalty",
"feature_contri",
"forcedsplits_filename",
"refit_decay_rate",
Expand Down Expand Up @@ -419,6 +423,9 @@ void Config::GetMembersFromString(const std::unordered_map<std::string, std::str

GetString(params, "monotone_constraints_method", &monotone_constraints_method);

GetDouble(params, "monotone_penalty", &monotone_penalty);
CHECK_GE(monotone_penalty, 0.0);

if (GetString(params, "feature_contri", &tmp_str)) {
feature_contri = Common::StringToArray<double>(tmp_str, ',');
}
Expand Down Expand Up @@ -639,6 +646,7 @@ std::string Config::SaveMembersToString() const {
str_buf << "[top_k: " << top_k << "]\n";
str_buf << "[monotone_constraints: " << Common::Join(Common::ArrayCast<int8_t, int>(monotone_constraints), ",") << "]\n";
str_buf << "[monotone_constraints_method: " << monotone_constraints_method << "]\n";
str_buf << "[monotone_penalty: " << monotone_penalty << "]\n";
str_buf << "[feature_contri: " << Common::Join(feature_contri, ",") << "]\n";
str_buf << "[forcedsplits_filename: " << forcedsplits_filename << "]\n";
str_buf << "[refit_decay_rate: " << refit_decay_rate << "]\n";
Expand Down
18 changes: 18 additions & 0 deletions src/treelearner/monotone_constraints.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,24 @@ class LeafConstraintsBase {
const std::vector<SplitInfo>& best_split_per_leaf) = 0;

inline static LeafConstraintsBase* Create(const Config* config, int num_leaves);

double ComputeMonotoneSplitGainPenalty(int leaf_index, double penalization) {
int depth = tree_->leaf_depth(leaf_index);
if (penalization >= depth + 1.) {
return kEpsilon;
}
if (penalization <= 1.) {
return 1. - penalization / pow(2., depth) + kEpsilon;
}
return 1. - pow(2, penalization - 1. - depth) + kEpsilon;
}

void ShareTreePointer(const Tree* tree) {
tree_ = tree;
}

private:
const Tree* tree_;
};

class BasicLeafConstraints : public LeafConstraintsBase {
Expand Down
7 changes: 7 additions & 0 deletions src/treelearner/serial_tree_learner.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ Tree* SerialTreeLearner::Train(const score_t* gradients, const score_t *hessians

auto tree = std::unique_ptr<Tree>(new Tree(config_->num_leaves));
auto tree_prt = tree.get();
constraints_->ShareTreePointer(tree_prt);

// root leaf
int left_leaf = 0;
int cur_depth = 1;
Expand Down Expand Up @@ -692,6 +694,11 @@ void SerialTreeLearner::ComputeBestSplitForFeature(
cegb_->DetlaGain(feature_index, real_fidx, leaf_splits->leaf_index(),
num_data, new_split);
}
if (new_split.monotone_type != 0) {
double penalty = constraints_->ComputeMonotoneSplitGainPenalty(
leaf_splits->leaf_index(), config_->monotone_penalty);
new_split.gain *= penalty;
}
if (new_split > *best_split) {
*best_split = new_split;
}
Expand Down
74 changes: 72 additions & 2 deletions tests/python_package_test/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -1036,7 +1036,7 @@ def generate_trainset_for_monotone_constraints_tests(self, x3_to_category=True):
categorical_features = []
if x3_to_category:
categorical_features = [2]
trainset = lgb.Dataset(x, label=y, categorical_feature=categorical_features)
trainset = lgb.Dataset(x, label=y, categorical_feature=categorical_features, free_raw_data=False)
return trainset

def test_monotone_constraints(self):
Expand Down Expand Up @@ -1071,8 +1071,8 @@ def is_correctly_constrained(learner, x3_to_category=True):
return True

for test_with_categorical_variable in [True, False]:
trainset = self.generate_trainset_for_monotone_constraints_tests(test_with_categorical_variable)
for monotone_constraints_method in ["basic", "intermediate"]:
trainset = self.generate_trainset_for_monotone_constraints_tests(test_with_categorical_variable)
params = {
'min_data': 20,
'num_leaves': 20,
Expand All @@ -1083,6 +1083,76 @@ def is_correctly_constrained(learner, x3_to_category=True):
constrained_model = lgb.train(params, trainset)
self.assertTrue(is_correctly_constrained(constrained_model, test_with_categorical_variable))

def test_monotone_penalty(self):
def are_first_splits_non_monotone(tree, n, monotone_constraints):
if n <= 0:
return True
if "leaf_value" in tree:
return True
if monotone_constraints[tree["split_feature"]] != 0:
return False
return (are_first_splits_non_monotone(tree["left_child"], n - 1, monotone_constraints)
and are_first_splits_non_monotone(tree["right_child"], n - 1, monotone_constraints))

def are_there_monotone_splits(tree, monotone_constraints):
if "leaf_value" in tree:
return False
if monotone_constraints[tree["split_feature"]] != 0:
return True
return (are_there_monotone_splits(tree["left_child"], monotone_constraints)
or are_there_monotone_splits(tree["right_child"], monotone_constraints))

max_depth = 5
monotone_constraints = [1, -1, 0]
penalization_parameter = 2.0
trainset = self.generate_trainset_for_monotone_constraints_tests(x3_to_category=False)
for monotone_constraints_method in ["basic", "intermediate"]:
params = {
'max_depth': max_depth,
'monotone_constraints': monotone_constraints,
'monotone_penalty': penalization_parameter,
"monotone_constraints_method": monotone_constraints_method,
}
constrained_model = lgb.train(params, trainset, 10)
dumped_model = constrained_model.dump_model()["tree_info"]
for tree in dumped_model:
self.assertTrue(are_first_splits_non_monotone(tree["tree_structure"], int(penalization_parameter),
monotone_constraints))
self.assertTrue(are_there_monotone_splits(tree["tree_structure"], monotone_constraints))

# test if a penalty as high as the depth indeed prohibits all monotone splits
def test_monotone_penalty_max(self):
max_depth = 5
monotone_constraints = [1, -1, 0]
penalization_parameter = max_depth
trainset_constrained_model = self.generate_trainset_for_monotone_constraints_tests(x3_to_category=False)
x = trainset_constrained_model.data
y = trainset_constrained_model.label
x3_negatively_correlated_with_y = x[:, 2]
trainset_unconstrained_model = lgb.Dataset(x3_negatively_correlated_with_y.reshape(-1, 1), label=y)
params_constrained_model = {
'monotone_constraints': monotone_constraints,
'monotone_penalty': penalization_parameter,
"max_depth": max_depth,
"gpu_use_dp": True,
}
params_unconstrained_model = {
"max_depth": max_depth,
"gpu_use_dp": True,
}

unconstrained_model = lgb.train(params_unconstrained_model, trainset_unconstrained_model, 10)
unconstrained_model_predictions = unconstrained_model.\
predict(x3_negatively_correlated_with_y.reshape(-1, 1))

for monotone_constraints_method in ["basic", "intermediate"]:
params_constrained_model["monotone_constraints_method"] = monotone_constraints_method
# The penalization is so high that the first 2 features should not be used here
constrained_model = lgb.train(params_constrained_model, trainset_constrained_model, 10)

# Check that a very high penalization is the same as not using the features at all
np.testing.assert_array_equal(constrained_model.predict(x), unconstrained_model_predictions)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a special function for floats comparison. Or is it a case when we need exact equality like here?

# we need to check the consistency of model file here, so test for exact equal
np.testing.assert_array_equal(pred_from_matr, pred_from_model_file)

Suggested change
np.testing.assert_array_equal(constrained_model.predict(x), unconstrained_model_predictions)
np.testing.assert_allclose(constrained_model.predict(x), unconstrained_model_predictions)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am actually not sure here. I compare a model with 1 variable (the unconstrained one) to a model with 3 variables, with 2 of the 3 that should not be used at all because of penalization (this is the constrained model here). So in the end, the 2 resulting models should output exactly the same results I think, because a single variable is used in both to make splits. So I would say that assert_array_equal is what we want, but I am not entirely sure. Given my explanation which do you think is best?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with that one-feature model should output the same predictions. But I have a doubt that we won't have problems due to internal grad/hess float summation. However, I'm OK with the current code while CI is producing green status. At least, we are now aware about the place where to look if problems appear.


def test_max_bin_by_feature(self):
col1 = np.arange(0, 100)[:, np.newaxis]
col2 = np.zeros((100, 1))
Expand Down