Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,7 @@ if(BUILD_CPP_TEST)
tests/cpp_tests/test_arrow_deprecated.cpp
tests/cpp_tests/test_byte_buffer.cpp
tests/cpp_tests/test_chunked_array.cpp
tests/cpp_tests/test_col_sampler.cpp
tests/cpp_tests/test_common.cpp
tests/cpp_tests/test_main.cpp
tests/cpp_tests/test_serialize.cpp
Expand Down
5 changes: 4 additions & 1 deletion src/treelearner/col_sampler.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,11 @@ class ColSampler {
}

void SetConfig(const Config* config) {
fraction_bytree_ = config->feature_fraction;
fraction_bynode_ = config->feature_fraction_bynode;
if (fraction_bytree_ == config->feature_fraction && seed_ == config->feature_fraction_seed) {
return;
}
fraction_bytree_ = config->feature_fraction;
is_feature_used_.resize(train_data_->num_features(), 1);
// seed is changed
if (seed_ != config->feature_fraction_seed) {
Expand Down
72 changes: 72 additions & 0 deletions tests/cpp_tests/test_col_sampler.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*!
* Copyright (c) 2026 The LightGBM developers. All rights reserved.
* Licensed under the MIT License. See LICENSE file in the project root for license information.
*/

#include <gtest/gtest.h>

#include <LightGBM/c_api.h>
#include <LightGBM/tree.h>

#include <algorithm>
#include <memory>

#include "../../src/treelearner/col_sampler.hpp"

namespace {

class ColSamplerTest : public testing::Test {
protected:
void SetUp() override {
double features[20 * 8];
for (int row = 0; row < 20; ++row) {
for (int col = 0; col < 8; ++col) {
features[row * 8 + col] = row * (col + 1);
}
}
DatasetHandle handle;
ASSERT_EQ(0, LGBM_DatasetCreateFromMat(features, C_API_DTYPE_FLOAT64, 20, 8, 1,
"min_data_in_bin=1 min_data_in_leaf=1 num_threads=1", nullptr, &handle));
data_.reset(static_cast<LightGBM::Dataset*>(handle));
config_.feature_fraction = 0.5;
config_.feature_fraction_seed = 123;
}

LightGBM::Config config_;
std::unique_ptr<LightGBM::Dataset> data_;
};

TEST_F(ColSamplerTest, UnrelatedConfigPreservesSampling) {
LightGBM::ColSampler control(&config_);
LightGBM::ColSampler updated(&config_);
control.SetTrainingData(data_.get());
updated.SetTrainingData(data_.get());
config_.learning_rate = 0.2;
updated.SetConfig(&config_);
EXPECT_EQ(control.is_feature_used_bytree(), updated.is_feature_used_bytree());
control.ResetByTree();
updated.ResetByTree();
EXPECT_EQ(control.is_feature_used_bytree(), updated.is_feature_used_bytree());
}

TEST_F(ColSamplerTest, SamplingConfigStillUpdates) {
LightGBM::ColSampler sampler(&config_);
sampler.SetTrainingData(data_.get());
config_.feature_fraction = 0.25;
sampler.SetConfig(&config_);
const auto& mask = sampler.is_feature_used_bytree();
EXPECT_EQ(2, std::count(mask.begin(), mask.end(), 1));

config_.feature_fraction_bynode = 0.5;
sampler.SetConfig(&config_);
const auto node_mask = sampler.GetByNode(nullptr, 0);
EXPECT_EQ(1, std::count(node_mask.begin(), node_mask.end(), 1));

config_.feature_fraction_seed = 456;
sampler.SetConfig(&config_);
LightGBM::ColSampler fresh(&config_);
fresh.SetTrainingData(data_.get());
EXPECT_EQ(fresh.is_feature_used_bytree(), sampler.is_feature_used_bytree());
}

} // namespace
41 changes: 41 additions & 0 deletions tests/python_package_test/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -3147,6 +3147,47 @@ def test_default_objective_and_metric():
assert len(evals_result["valid_0"]["l2"]) == 5


@pytest.mark.parametrize("seed", [123, None], ids=["fixed_seed", "randomized"])
@pytest.mark.parametrize("update", ["custom_objective", "reset_parameter"])
def test_feature_sampling_is_unchanged_by_unrelated_updates(seed, update):
if seed is None:
seed = int(np.random.default_rng().integers(0, 1000000))
rng = np.random.RandomState(1)
X, y = rng.randn(300, 300), rng.randn(300)
params = {
"objective": "regression",
"max_depth": 1,
"learning_rate": 0.001,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
"max_depth": 1,
"learning_rate": 0.001,

I don't see a reason to override the default for learning_rate here, let's remove this.

And I think if you follow my other recommendations, you'll need to train deeper trees than max_depth: 1.

"feature_fraction": 0.5,
"seed": seed,
"deterministic": True,
"force_row_wise": True,
"num_threads": 1,
"verbosity": -1,
}
dataset = lgb.Dataset(X, label=y, init_score=np.zeros_like(y))
expected = lgb.train(params, dataset, num_boost_round=3)

def custom_l2(pred, data):
return pred - data.get_label(), np.ones_like(pred)

def reset_same_learning_rate(env):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Sorry but I am not understanding this... how is the learning rate involved in any of this? I don't understand what the reset_parameter tests are covering here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If the goal is just to set some parameter to a non-default value to be sure the initial SetConfig() call(s) do not exit early, that makes sense to me... but the tests aren't ever checking that learning_rate = 0.001 made it through into the Booster.

Either way, please do add some assertions here checking that other overrides (learning_rate is fine) do make it through into the Booster on the C++ side. The test code could call Booster._get_loaded_param() for help with that:

def _get_loaded_param(self) -> Dict[str, Any]:
buffer_len = 1 << 20
tmp_out_len = ctypes.c_int64(0)
string_buffer = ctypes.create_string_buffer(buffer_len)
ptr_string_buffer = ctypes.c_char_p(ctypes.addressof(string_buffer))
_safe_call(
_LIB.LGBM_BoosterGetLoadedParam(
self._handle,
ctypes.c_int64(buffer_len),
ctypes.byref(tmp_out_len),
ptr_string_buffer,
)
)

if env.iteration == 0:
env.model.reset_parameter({"learning_rate": params["learning_rate"]})

reset_same_learning_rate.before_iteration = True
if update == "custom_objective":
params["objective"] = custom_l2
callbacks = None
else:
callbacks = [reset_same_learning_rate]
actual = lgb.train(params, dataset, num_boost_round=3, callbacks=callbacks)
expected_features = [tree["tree_structure"]["split_feature"] for tree in expected.dump_model()["tree_info"]]
actual_features = [tree["tree_structure"]["split_feature"] for tree in actual.dump_model()["tree_info"]]
assert actual_features == expected_features, f"seed={seed}"
np.testing.assert_allclose(actual.predict(X), expected.predict(X), err_msg=f"seed={seed}")


@pytest.mark.parametrize("use_weight", [True, False])
def test_multiclass_custom_objective(use_weight):
def custom_obj(y_pred, ds):
Expand Down
Loading