From 0aa3ee3e6ae67256afe44b2b0c8fbb35d333fc03 Mon Sep 17 00:00:00 2001 From: James Lamb Date: Sun, 19 Apr 2026 22:34:32 -0500 Subject: [PATCH 1/4] [ci] [R-package] drop 'icc' test job, update clang and GCC r-hub container jobs (#7222) --- .github/workflows/r_package.yml | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/.github/workflows/r_package.yml b/.github/workflows/r_package.yml index 6d65df268105..5f2b6ad8930d 100644 --- a/.github/workflows/r_package.yml +++ b/.github/workflows/r_package.yml @@ -273,15 +273,16 @@ jobs: # references: # * CRAN "additional checks": https://cran.r-project.org/web/checks/check_issue_kinds.html # * images: https://r-hub.github.io/containers/containers.html + # + # Generally, only images that are still supported, listed at + # https://r-hub.github.io/containers/#available-containers, should be used. image: - - clang16 - - clang17 - - clang18 - - clang19 - - clang20 - - gcc14 - - intel + - clang21 + - clang22 + - gcc16 - rchk + - ubuntu-clang + - ubuntu-gcc12 runs-on: ubuntu-latest container: ghcr.io/r-hub/containers/${{ matrix.image }}:latest steps: @@ -302,6 +303,7 @@ jobs: apt-get install --no-install-recommends -y \ cmake \ devscripts \ + libcurl4-openssl-dev \ libuv1-dev \ texinfo \ texlive-latex-extra \ @@ -315,10 +317,15 @@ jobs: yum install -y \ cmake \ devscripts \ + libcurl-devel \ libuv-devel \ qpdf \ texinfo \ texinfo-tex \ + texlive-amsmath \ + texlive-amsfonts \ + texlive-collection-fontsrecommended \ + texlive-collection-latexrecommended \ texlive-latex \ tidy fi @@ -348,8 +355,16 @@ jobs: fi fi - # 'testthat' is not needed by 'rchk', so avoid installing it until here - Rscript -e "install.packages('testthat', repos = 'https://cran.rstudio.com', Ncpus = parallel::detectCores())" + # why these packages? + # + # - 'curl' is not a dependency of {lightgbm}, but it's needed for 'R CMD check' URL checks + # - 'testthat' is not needed by 'rchk', so avoid installing it until here + # + Rscript -e " + install.packages(c('curl', 'testthat'), + repos = 'https://cran.rstudio.com', + Ncpus = parallel::detectCores()) + " if [[ "${{ matrix.image }}" =~ "clang" ]]; then # allowing the following NOTEs (produced by default in the clang images): From 6895edbf65dff6459473b0c3c94b97ebc01b0ec6 Mon Sep 17 00:00:00 2001 From: Francisco Romaldo Mendes <19298622+FranciscoRMendes@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:51:55 -0400 Subject: [PATCH 2/4] [c++] raise a clear error for unknown metric types (#3481) Previously, passing an unrecognised metric name (e.g. "nonsense") to lgb.train() or lgb.cv() caused a confusing "subscript out of bounds" error deep inside the R callback machinery, with no indication of what went wrong. Root cause: Metric::CreateMetric() returned nullptr for any metric name it didn't recognise. All call sites treated nullptr as a silent no-op ("continue"), so training proceeded with zero evaluation metrics. In the R package this produced an empty eval_list, and callbacks that expected at least one entry crashed with a cryptic subscript error. Fix: call Log::Fatal("Unknown metric type: %s") before returning nullptr for any unrecognised type. The only legitimate nullptr path is the "custom" sentinel value, which ParseMetricAlias produces for "none" / "na" / "null" / "custom" inputs and which intentionally means "no built-in metric". That path is preserved unchanged. The fatal error is raised during Booster initialisation, long before any callback runs, and propagates as a normal error in Python and R. --- R-package/tests/testthat/test_basic.R | 46 ++++++++++++++++++++++++ src/metric/metric.cpp | 6 ++++ tests/python_package_test/test_engine.py | 9 +++++ 3 files changed, 61 insertions(+) diff --git a/R-package/tests/testthat/test_basic.R b/R-package/tests/testthat/test_basic.R index 4029b7bdb0b2..9b032efa68b6 100644 --- a/R-package/tests/testthat/test_basic.R +++ b/R-package/tests/testthat/test_basic.R @@ -765,6 +765,52 @@ test_that("lgb.train() raises an informative error for unrecognized objectives", }, regexp = "Unknown objective type name: not_a_real_objective") }) +test_that("lgb.train() raises an informative error for unrecognized metrics", { + dtrain <- lgb.Dataset( + data = train$data + , label = train$label + , params = list(num_threads = .LGB_MAX_THREADS) + ) + expect_error({ + capture.output({ + bst <- lgb.train( + data = dtrain + , valids = list(valid = dtrain) + , params = list( + objective = "binary" + , metric = "nonsense" + , verbosity = .LGB_VERBOSITY + , num_threads = .LGB_MAX_THREADS + , num_iterations = 2L + ) + ) + }, type = "message") + }, regexp = "Unknown metric type: nonsense") +}) + +test_that("lgb.cv() raises an informative error for unrecognized metrics", { + dtrain <- lgb.Dataset( + data = train$data + , label = train$label + , params = list(num_threads = .LGB_MAX_THREADS) + ) + expect_error({ + capture.output({ + bst <- lgb.cv( + data = dtrain + , params = list( + objective = "binary" + , metric = "nonsense" + , verbosity = .LGB_VERBOSITY + , num_threads = .LGB_MAX_THREADS + ) + , nfold = 2L + , nrounds = 2L + ) + }, type = "message") + }, regexp = "Unknown metric type: nonsense") +}) + test_that("lgb.train() respects parameter aliases for objective", { nrounds <- 3L dtrain <- lgb.Dataset( diff --git a/src/metric/metric.cpp b/src/metric/metric.cpp index 1b1857602d9b..5fbdc91d9f19 100644 --- a/src/metric/metric.cpp +++ b/src/metric/metric.cpp @@ -136,6 +136,12 @@ Metric* Metric::CreateMetric(const std::string& type, const Config& config) { #ifdef USE_CUDA } #endif // USE_CUDA + // "custom" is a sentinel value produced by ParseMetricAlias for "none" / "na" / "null" / "custom"; + // it means the user deliberately requested no built-in metric, so returning nullptr is intentional. + // Any other unrecognised name is a user mistake and deserves a clear error. + if (type != std::string("custom")) { + Log::Fatal("Unknown metric type: %s", type.c_str()); + } return nullptr; } diff --git a/tests/python_package_test/test_engine.py b/tests/python_package_test/test_engine.py index 7db16c7ff176..070b5d484393 100644 --- a/tests/python_package_test/test_engine.py +++ b/tests/python_package_test/test_engine.py @@ -4811,6 +4811,15 @@ def test_train_raises_informative_error_for_params_of_wrong_type(): lgb.train(params, dtrain) +@pytest.mark.parametrize("unknown_metric", ["nonsense", "not_a_metric", "msa"]) +def test_train_raises_informative_error_for_unknown_metric(unknown_metric): + X, y = load_breast_cancer(return_X_y=True) + dtrain = lgb.Dataset(X, label=y) + params = {"objective": "binary", "metric": unknown_metric, "verbose": -1} + with pytest.raises(lgb.basic.LightGBMError, match=f"Unknown metric type: {unknown_metric}"): + lgb.train(params, dtrain, num_boost_round=1, valid_sets=[dtrain]) + + def test_quantized_training(): X, y = make_synthetic_regression() ds = lgb.Dataset(X, label=y) From 32483e672aeeed0b59f98bddbcf47d369e3877c3 Mon Sep 17 00:00:00 2001 From: James Lamb Date: Wed, 22 Apr 2026 23:13:23 -0500 Subject: [PATCH 3/4] [ci] adapt to scikit-learn ClassifierChain changes, fix {fs} install, work around pyarrow type-checking issues (#7236) --- .ci/test-r-package.sh | 1 + python-package/lightgbm/basic.py | 3 ++- tests/python_package_test/test_sklearn.py | 21 +++++++++++++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.ci/test-r-package.sh b/.ci/test-r-package.sh index ef6ffe591351..24d3991d8504 100755 --- a/.ci/test-r-package.sh +++ b/.ci/test-r-package.sh @@ -49,6 +49,7 @@ if [[ $OS_NAME == "linux" ]]; then --no-install-recommends \ -y \ devscripts \ + libuv1-dev \ r-base-core=${R_LINUX_VERSION} \ r-base-dev=${R_LINUX_VERSION} \ texinfo \ diff --git a/python-package/lightgbm/basic.py b/python-package/lightgbm/basic.py index e1d32b3002cd..3f63e14182c1 100644 --- a/python-package/lightgbm/basic.py +++ b/python-package/lightgbm/basic.py @@ -3115,7 +3115,8 @@ def set_weight( # Check if the weight contains values other than one if weight is not None: if _is_pyarrow_array(weight): - if pa_compute.all(pa_compute.equal(weight, 1)).as_py(): + # TODO: remove 'type: ignore[attr-defined]' when https://github.com/apache/arrow/issues/49831 is resolved. + if pa_compute.all(pa_compute.equal(weight, 1)).as_py(): # type: ignore[attr-defined] weight = None elif np.all(weight == 1): weight = None diff --git a/tests/python_package_test/test_sklearn.py b/tests/python_package_test/test_sklearn.py index b312b6f8d1c2..2834a779a49e 100644 --- a/tests/python_package_test/test_sklearn.py +++ b/tests/python_package_test/test_sklearn.py @@ -52,6 +52,7 @@ SKLEARN_MAJOR, SKLEARN_MINOR, *_ = _sklearn_version.split(".") SKLEARN_VERSION_GTE_1_6 = (int(SKLEARN_MAJOR), int(SKLEARN_MINOR)) >= (1, 6) +SKLEARN_VERSION_GTE_1_7 = (int(SKLEARN_MAJOR), int(SKLEARN_MINOR)) >= (1, 7) decreasing_generator = itertools.count(0, -1) estimator_classes = (lgb.LGBMModel, lgb.LGBMClassifier, lgb.LGBMRegressor, lgb.LGBMRanker) @@ -453,7 +454,15 @@ def test_classifier_chain(): X, y = make_multilabel_classification(n_samples=100, n_features=20, n_classes=n_outputs, random_state=0) X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.1, random_state=42) order = [2, 0, 1] - clf = ClassifierChain(base_estimator=lgb.LGBMClassifier(n_estimators=10), order=order, random_state=42) + # 'base_estimator' parameter was deprecated in scikit-learn 1.7 and removed in 1.9 + # + # * https://github.com/scikit-learn/scikit-learn/pull/30152 + # * https://github.com/scikit-learn/scikit-learn/pull/33750 + # + if SKLEARN_VERSION_GTE_1_7: + clf = ClassifierChain(estimator=lgb.LGBMClassifier(n_estimators=10), order=order, random_state=42) + else: + clf = ClassifierChain(base_estimator=lgb.LGBMClassifier(n_estimators=10), order=order, random_state=42) clf.fit(X_train, y_train) score = clf.score(X_test, y_test) assert score >= 0.2 @@ -470,7 +479,15 @@ def test_regressor_chain(): X, y = bunch["data"], bunch["target"] X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.1, random_state=42) order = [2, 0, 1] - reg = RegressorChain(base_estimator=lgb.LGBMRegressor(n_estimators=10), order=order, random_state=42) + # 'base_estimator' parameter was deprecated in scikit-learn 1.7 and removed in 1.9 + # + # * https://github.com/scikit-learn/scikit-learn/pull/30152 + # * https://github.com/scikit-learn/scikit-learn/pull/33750 + # + if SKLEARN_VERSION_GTE_1_7: + reg = RegressorChain(estimator=lgb.LGBMRegressor(n_estimators=10), order=order, random_state=42) + else: + reg = RegressorChain(base_estimator=lgb.LGBMRegressor(n_estimators=10), order=order, random_state=42) reg.fit(X_train, y_train) y_pred = reg.predict(X_test) _, score, _ = mse(y_test, y_pred) From 10f25740248b2d900a37ecf9f2831c542004b643 Mon Sep 17 00:00:00 2001 From: FranciscoRMendes <19298622+FranciscoRMendes@users.noreply.github.com> Date: Mon, 18 May 2026 11:28:49 -0400 Subject: [PATCH 4/4] address reviewer feedback: update error message and expand metric tests - error message now uses single quotes around the metric name and includes a hint about metric='none' for disabling built-in metrics - drop 3-value parametrize on unknown metric test; add separate tests for sentinel string values and Python None --- R-package/tests/testthat/test_basic.R | 4 +-- src/metric/metric.cpp | 2 +- tests/python_package_test/test_engine.py | 35 +++++++++++++++++++++--- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/R-package/tests/testthat/test_basic.R b/R-package/tests/testthat/test_basic.R index 9b032efa68b6..a18839397822 100644 --- a/R-package/tests/testthat/test_basic.R +++ b/R-package/tests/testthat/test_basic.R @@ -785,7 +785,7 @@ test_that("lgb.train() raises an informative error for unrecognized metrics", { ) ) }, type = "message") - }, regexp = "Unknown metric type: nonsense") + }, regexp = "Unknown metric 'nonsense'") }) test_that("lgb.cv() raises an informative error for unrecognized metrics", { @@ -808,7 +808,7 @@ test_that("lgb.cv() raises an informative error for unrecognized metrics", { , nrounds = 2L ) }, type = "message") - }, regexp = "Unknown metric type: nonsense") + }, regexp = "Unknown metric 'nonsense'") }) test_that("lgb.train() respects parameter aliases for objective", { diff --git a/src/metric/metric.cpp b/src/metric/metric.cpp index 5fbdc91d9f19..ac022c87d270 100644 --- a/src/metric/metric.cpp +++ b/src/metric/metric.cpp @@ -140,7 +140,7 @@ Metric* Metric::CreateMetric(const std::string& type, const Config& config) { // it means the user deliberately requested no built-in metric, so returning nullptr is intentional. // Any other unrecognised name is a user mistake and deserves a clear error. if (type != std::string("custom")) { - Log::Fatal("Unknown metric type: %s", type.c_str()); + Log::Fatal("Unknown metric '%s'. To avoid enabling LightGBM built-in metrics, pass metric='none'. Otherwise, ensure 'metric' contains only supported values.", type.c_str()); } return nullptr; } diff --git a/tests/python_package_test/test_engine.py b/tests/python_package_test/test_engine.py index 070b5d484393..cdbbd6287e2d 100644 --- a/tests/python_package_test/test_engine.py +++ b/tests/python_package_test/test_engine.py @@ -4811,15 +4811,42 @@ def test_train_raises_informative_error_for_params_of_wrong_type(): lgb.train(params, dtrain) -@pytest.mark.parametrize("unknown_metric", ["nonsense", "not_a_metric", "msa"]) -def test_train_raises_informative_error_for_unknown_metric(unknown_metric): +def test_train_raises_informative_error_for_unknown_metric(): X, y = load_breast_cancer(return_X_y=True) dtrain = lgb.Dataset(X, label=y) - params = {"objective": "binary", "metric": unknown_metric, "verbose": -1} - with pytest.raises(lgb.basic.LightGBMError, match=f"Unknown metric type: {unknown_metric}"): + params = {"objective": "binary", "metric": "nonsense", "verbose": -1} + with pytest.raises(lgb.basic.LightGBMError, match="Unknown metric 'nonsense'"): lgb.train(params, dtrain, num_boost_round=1, valid_sets=[dtrain]) +@pytest.mark.parametrize( + "metric", + [ + "none", + ["none", "null"], + ["None"], + ["NULL"], + ], +) +def test_train_sentinel_metric_disables_builtin_metrics(metric): + X, y = load_breast_cancer(return_X_y=True) + dtrain = lgb.Dataset(X, label=y) + evals_result = {} + params = {"objective": "binary", "metric": metric, "verbose": -1} + lgb.train(params, dtrain, num_boost_round=1, valid_sets=[dtrain], callbacks=[lgb.record_evaluation(evals_result)]) + assert evals_result == {} + + +def test_train_with_metric_python_none_uses_default_metric(): + X, y = load_breast_cancer(return_X_y=True) + dtrain = lgb.Dataset(X, label=y) + dvalid = lgb.Dataset(X, label=y, reference=dtrain) + evals_result = {} + params = {"objective": "binary", "metric": None, "verbose": -1} + lgb.train(params, dtrain, num_boost_round=1, valid_sets=[dvalid], callbacks=[lgb.record_evaluation(evals_result)]) + assert "binary_logloss" in evals_result["valid_0"] + + def test_quantized_training(): X, y = make_synthetic_regression() ds = lgb.Dataset(X, label=y)