Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
61da860
Add draft of cov penalty
wagnerlmichael Apr 10, 2026
799914f
Add roxygen rd file
wagnerlmichael Apr 10, 2026
f228237
Update pkgdown yml
wagnerlmichael Apr 10, 2026
f9b0565
Lint
wagnerlmichael Apr 15, 2026
2f91001
Add nolint
wagnerlmichael Apr 15, 2026
f8828a4
Edit nolint
wagnerlmichael Apr 15, 2026
ee68585
Attempt to fix data leakage
wagnerlmichael May 12, 2026
6b0dd91
Edit comments
wagnerlmichael May 12, 2026
6735cf8
Add comments
wagnerlmichael May 12, 2026
7c11ea8
Add spacing
wagnerlmichael May 12, 2026
76472b5
Fix spacing
wagnerlmichael May 12, 2026
ed3d09e
Lint
wagnerlmichael May 15, 2026
b44164f
Decrease line length
wagnerlmichael May 15, 2026
be61335
Remove zero grad lines
wagnerlmichael May 15, 2026
0c94694
Remove zero_grad code
wagnerlmichael May 21, 2026
61613fd
Remove type=raw spec
wagnerlmichael May 26, 2026
55cc691
Merge branch 'master' into test-new-obj-function
wagnerlmichael May 26, 2026
549f1a0
Switch obj to objective
wagnerlmichael Jun 1, 2026
67e1f4a
Swap yc to y_centered
wagnerlmichael Jul 8, 2026
446e4cb
Swap in for ==
wagnerlmichael Jul 8, 2026
1a883ab
Merge branch 'master' into test-new-obj-function
wagnerlmichael Jul 8, 2026
3d87b06
Point to actions branch
wagnerlmichael Jul 9, 2026
a4ac299
Point back to main
wagnerlmichael Jul 9, 2026
e2c760d
Switch default value check to null check
wagnerlmichael Jul 9, 2026
5c624fa
Remove nolint tag
wagnerlmichael Jul 9, 2026
490ba70
Add y mean check
wagnerlmichael Jul 9, 2026
aa9d7b0
Version bump
wagnerlmichael Jul 9, 2026
5caaf70
Add new tests
wagnerlmichael Jul 9, 2026
55f1408
Remove hessian test
wagnerlmichael Jul 10, 2026
7c2590d
Remove hessian tset
wagnerlmichael Jul 10, 2026
bf8f04e
Update formatting
wagnerlmichael Jul 13, 2026
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 NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export(feature_fraction_bynode)
export(lambda_l1)
export(lambda_l2)
export(learning_rate)
export(make_obj_mse_cov)
export(lgbm_load)
export(lgbm_save)
export(max_bin)
Expand Down
33 changes: 30 additions & 3 deletions R/lightgbm.R
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ add_boost_tree_lightgbm <- function() {
#' @return A fitted \code{lgb.Booster} object.
#' @keywords internal
#' @export
train_lightgbm <- function(x,
train_lightgbm <- function(x, # nolint
Comment thread
wagnerlmichael marked this conversation as resolved.
Outdated
y,
num_iterations = 10,
max_depth = 17,
Expand All @@ -146,8 +146,26 @@ train_lightgbm <- function(x,
force(y)
others <- list(...)

# Set training objective (always regression)
if (!any(names(others) %in% c("objective"))) {
# Custom objective handling. `mse_cov_rho` is a lightsnip-specific engine
# arg used only when `objective == "mse_cov"`; pop it off so it is not
# forwarded to lgb.train (which would error on an unknown parameter).
mse_cov_rho <- others$mse_cov_rho
others$mse_cov_rho <- NULL

custom_obj <- NULL

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.

[Nitpick, optional] obj is a common shortening of "object" in programming contexts, so I think it might be clearer if we use the full term "objective" in our symbol names (i.e. custom_objective). I know lightgbm uses obj for one of its param names, but I think we should avoid it unless absolutely necessary.

@wagnerlmichael wagnerlmichael Jun 1, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good call! 549f1a0

if (!is.null(others$objective) && identical(others$objective, "mse_cov")) {

@wagnerlmichael wagnerlmichael May 21, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

In case you're thinking "where does others$objective come from"?

In the corresponding model-res PR, we set this value within the set_engine() call in 01-train.R

objective = params$model$objective,

which grabs from our params.yaml definition

model:
  engine: "lightgbm"
  objective: "mse_cov"

objective isn't one of parsnip::boost_tree()'s modeled hyperparameters (those are things like trees, tree_depth, learn_rate, etc.) so parsnip captures it, along with anything else we pass to set_engine("lightgbm", ...), as the engine "dots." Those dots are forwarded to lightsnip::train_lightgbm(...), whose own ... is materialized into a named list on the first line of the body: others <- list(...). From that point on, others$objective == "mse_cov" and others$mse_cov_rho == 1, and those are the values the sentinel branch sniffs to decide whether to swap in the custom callback.

Even though others$objective isn't one of parsnips modeled hyperparameters, it is the canonical way to pass the objective functions, custom or supported out of the box.

The prior case with rmse

If we were to supply something the model knows like we have done historically for 'rmse', the rmse value would have floated all the way to C++ through a .h file where it basically says, if "rmse" then point to "regression" which is lands in the C++ file here

rho_val <- if (is.null(mse_cov_rho)) 1e-3 else as.numeric(mse_cov_rho)
custom_obj <- make_obj_mse_cov(rho = rho_val, y_mean = mean(y))
# When `obj` is a custom callback we must NOT also set `objective` in
# params, otherwise lgb.train will reject the unknown name.
others$objective <- NULL

@wagnerlmichael wagnerlmichael Apr 15, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

LightGBM takes either objective or obj args, code here, and ultimately collects one of the two as objective

More context on the comment here from the lightbgm source code. Shown here:

  # extract any function objects passed for objective or metric
  fobj <- NULL
  if (is.function(params$objective)) {
    fobj <- params$objective
    params$objective <- "none"
  }

Checks to see if if there is a custom function supplied, saves it as fobj to be passed to C later using gradient and hessian, and the "none" value lets lightgbm know that the custom math will be supplied later, rather than it using one of its built in C objective functions.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

More a clarification than anything " saves it as fobj to be passed to C later using gradient and hessian" these are the gradient and hessian as calculated by the new objection function- (yea?)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yep, that's right

others$num_class <- NULL
}

# Set training objective default (always regression) when not specified.
# Skipped when a custom `obj` callback is in use, since lgb.train will then
# supply the gradient/hessian itself and `objective` must be unset.
if (is.null(custom_obj) && !any(names(others) %in% c("objective"))) {
others$num_class <- 1
others$objective <- "regression"
}
Expand Down Expand Up @@ -270,6 +288,10 @@ train_lightgbm <- function(x,
if (!is.null(early_stop) && validation > 0) {
main_args$early_stopping_rounds <- early_stop
}
# Wire in the custom objective callback (if any) under lgb.train's `obj` arg
if (!is.null(custom_obj)) {
main_args$obj <- quote(custom_obj)
}

@wagnerlmichael wagnerlmichael May 26, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

custom_obj is created on line 267 drawing from objectives.R. Then we attach this function to main_args$obj here.

Which gets handed to lgb.Booster.R here where it is saved as fobj. And then ends up being called and producing the gradient (which direction to move) and hessian (how far to move) which are then consumed by C to fit the subsequent tree

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

That's really clever! Just making a note (more for myself than anything else) - as to where lgbm grabs the gradient and hessian


call <- parsnip::make_call(fun = "lgb.train", ns = "lightgbm", main_args)
rlang::eval_tidy(call, env = rlang::current_env())
Expand All @@ -288,9 +310,14 @@ train_lightgbm <- function(x,
#'
#' @export
pred_lgb_reg_num <- function(object, new_data, ...) {
# Use type = "raw" so the result is the unmodified booster score. For
# regression this is identical to type = "response" but, unlike "response",
# it does not warn when the booster was trained with a custom objective
# (e.g. lightsnip's `mse_cov`).
stats::predict(
object$fit,
as.matrix(new_data),
type = "raw",
params = list(predict_disable_shape_check = TRUE),
...
)
Expand Down
77 changes: 77 additions & 0 deletions R/objectives.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#' Custom LightGBM objective: MSE + rho * Cov(r, y)^2
#'
#' @description Build a custom LightGBM objective callback that minimizes a
#' standard squared-error loss plus a soft penalty on the covariance between
#' the per-sample residual `r = y_pred - y_true` and the (centered) labels
#' `y_true`. The penalty pushes the model toward "vertical equity" by
#' discouraging residuals that systematically scale with `y`.
#'
#' This is an R port of the `LGBCovPenalty` objective from
#' an active collabration. (https://github.com/nicacevedo/soft-vertical-equity-constrained-mass-appraissal) # nolint
#' It is intended to be used when the model is trained in log-space (so the
#' "diff" residual is equivalent to a log-ratio).
#'
#' Penalty (using mean-centered labels yc = y_true - mean(y_true)):
Comment thread
wagnerlmichael marked this conversation as resolved.
Outdated
#' \deqn{cov = (1/n) * sum_i r_i * yc_i}
#' \deqn{penalty = 0.5 * rho * n * cov^2}
#' Diagonal Hessian approximation is used (matches the reference Python
#' implementation).
#'
#' @param rho Numeric. Non-negative penalty weight. `rho = 0` recovers plain
#' MSE.
#' @param y_mean Numeric. Mean of the training labels. Should be computed once
#' from the training set and captured here so the centering is stable across
#' iterations.
#' @param zero_grad_tol Numeric. Floor applied to absolute gradients/Hessians
#' to avoid zero entries that confuse LightGBM. Matches the reference
#' implementation.
#'
#' @return A function with signature `function(preds, dtrain)` suitable for
#' passing as the `obj` argument of [lightgbm::lgb.train].
#'
#' @export
make_obj_mse_cov <- function(rho, y_mean, zero_grad_tol = 1e-6) {
rho <- as.numeric(rho)
y_mean <- as.numeric(y_mean)
zero_grad_tol <- as.numeric(zero_grad_tol)
if (length(rho) != 1L || is.na(rho) || rho < 0) {
rlang::abort("`rho` must be a single non-negative numeric value.")
}

Comment thread
wagnerlmichael marked this conversation as resolved.
function(preds, dtrain) {
y_true <- lightgbm::get_field(dtrain, "label")
y_pred <- as.numeric(preds)
n <- length(y_pred)

# Centered labels (training-set mean is captured at construction time so
# the penalty geometry stays stable across boosting iterations)
yc <- y_true - y_mean

# Residual ("diff" mode); in log-space training this is the log-ratio
r <- y_pred - y_true

# Covariance estimate (E[yc] is ~0 by construction)
cov_val <- mean(r * yc)

# Base squared-error grad/hess
grad_base <- 2.0 * (y_pred - y_true)
hess_base <- rep(2.0, n)

# Penalty grad/hess (diagonal approximation)
# dc/dy_pred_i = (1/n) * yc_i (since dr_i/dy_pred_i = 1)
a <- yc / n
grad_pen <- rho * n * cov_val * a
hess_pen <- rho * n * (a^2)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The math here is completely borrowed from the source implementation and has been reviewed by the code authors

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Very clean. Since we've empirically tested it with the diagonal approximation- no issues. Noting for future state that the (I believe) the "smooth" penalty is similar, without the diagonal assumption.


grad <- grad_base + grad_pen
hess <- hess_base + hess_pen

# Floor tiny values, mirroring the reference implementation
small_g <- abs(grad) < zero_grad_tol
if (any(small_g)) grad[small_g] <- zero_grad_tol
small_h <- hess < zero_grad_tol
if (any(small_h)) hess[small_h] <- zero_grad_tol

list(grad = grad, hess = hess)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This list is passed and parsed as gpair in lightbm source code

}
}
6 changes: 6 additions & 0 deletions _pkgdown.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ reference:
- train_lightgbm
- pred_lgb_reg_num
- multi_predict._lgb.Booster
- subtitle: Custom objectives
desc: >
Factory functions that return custom LightGBM objective callbacks for
use as the `obj` argument of `lgb.train`.
- contents:
- make_obj_mse_cov
- subtitle: LightGBM hyperparameters
desc:: >
LightGBM dials:: paramter functions that can be used with tune_ functions.
Expand Down
42 changes: 42 additions & 0 deletions man/make_obj_mse_cov.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading