Add MSE + covariance penalty term custom objective function#21
Conversation
| 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?)
| small_h <- hess < zero_grad_tol | ||
| if (any(small_h)) hess[small_h] <- zero_grad_tol | ||
|
|
||
| list(grad = grad, hess = hess) |
There was a problem hiding this comment.
This list is passed and parsed as gpair in lightbm source code
| # - whether to construct the mse_cov objective callback | ||
| mse_cov_rho_val <- NULL | ||
|
|
||
| if (!is.null(others$objective) && identical(others$objective, "mse_cov")) { |
There was a problem hiding this comment.
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
| y_mean = mean(y[trn_index]) | ||
| ) | ||
| } | ||
|
|
There was a problem hiding this comment.
y[train_index] will either call the full training set or just a portion of it if we are running a hyperparam search:
n <- nrow(x)
if (validation > 0) {
m <- min(floor(n * (1 - validation)), n - 1)
if (sample_type == "random") {
m <- min(floor(n * (1 - validation)), n - 1)
trn_index <- sample(1:n, size = max(m, 2))
val_index <- setdiff(1:n, trn_index)
} The reason we hold out some data for hyperparam search validation is that we are evaluating the per model selection based on a small hold out set
| # 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) | ||
| } |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
That's really clever! Just making a note (more for myself than anything else) - as to where lgbm grabs the gradient and hessian
| # 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) |
There was a problem hiding this comment.
The math here is completely borrowed from the source implementation and has been reviewed by the code authors
There was a problem hiding this comment.
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.
wrridgeway
left a comment
There was a problem hiding this comment.
Seems like the objective function itself made its way through the pipeline just fine. Thanks for all the work on this you guys. If the math has been checked by original authors of this method, that's good by me.
|
|
||
| if (!is.null(others$objective) && identical(others$objective, "mse_cov")) { | ||
| mse_cov_rho_val <- | ||
| if (is.null(mse_cov_rho)) 1e-3 else as.numeric(mse_cov_rho) |
There was a problem hiding this comment.
Is this default value noted anywhere?
There was a problem hiding this comment.
This made me realize that the default value here doesn't really make sense, I just ported it over from the original python repo. I switched it here: e2c760d
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #21 +/- ##
===========================================
- Coverage 81.20% 67.25% -13.96%
===========================================
Files 3 4 +1
Lines 133 171 +38
===========================================
+ Hits 108 115 +7
- Misses 25 56 +31 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| obj <- make_objective_mse_cov(rho = 0, y_mean = mean(y)) | ||
| out <- obj(preds, dtrain) | ||
|
|
||
| expect_equal(out$grad, 2 * (preds - y)) |
There was a problem hiding this comment.
This is testing the assumption that if we supply a rho of 0 it will behave exactly like MSE would. It tests that the gradients are equal to the first derivative of MSE in per-observation squared error
Since the whole covariance term project is simply appending another mathematical term to MSE, this is a check that makes sure nothing else happens along the way with the whole lightsnip lgb carry through
| cov_val <- mean((preds - y) * y_centered) | ||
| a <- y_centered / n | ||
|
|
||
| expect_equal(out$grad, 2 * (preds - y) + rho * n * cov_val * a) |
There was a problem hiding this comment.
Tests that the gradient is the MSE derivative plus the covariance term derivative
|
|
||
| expect_equal(nrow(pred), nrow(mtcars)) | ||
| expect_true(all(is.finite(pred$.pred))) | ||
| expect_not_constant_predictions(pred$.pred) |
There was a problem hiding this comment.
basic model output checking
- one pred per input
- no NaNs/infinities
- model trains correctly (no constant predictions)
| # The penalty weight should reach the objective: different rho values | ||
| # must produce different fits | ||
| pred_plain <- suppressWarnings(predict(fit_with_rho(0), mtcars[, -1])) | ||
| expect_all_preds_differ(list(pred$.pred, pred_plain$.pred)) |
There was a problem hiding this comment.
predictions differ between a rho of 0 (plain MSE) and a non-zero rho (0.5)
| parsnip::fit(mpg ~ ., data = mtcars), | ||
| regexp = "requires `mse_cov_rho`" | ||
| ) | ||
| }) |
There was a problem hiding this comment.
tests that no rho supplied throws an error
jeancochrane
left a comment
There was a problem hiding this comment.
Awesome work! No issues with the code or the tests. I think the docs need a bit more formatting, but that should be pretty fast once you've got an environment up and running for generating the docs.
| #' 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 y_centered = y_true - mean(y_true)): | ||
| #' \deqn{cov = (1/n) * sum_i r_i * y_centered_i} | ||
| #' \deqn{penalty = 0.5 * rho * n * cov^2} | ||
| #' Diagonal Hessian approximation is used (matches the reference Python | ||
| #' implementation). |
There was a problem hiding this comment.
[Nitpick, required] These docs don't seem to be rendering super well. A few issues:
- The math in the title is neither LaTeX-formatted nor code-formatted, so it looks awkward
- Link text is not getting formatted as a hyperlink
- The math in the last paragraph is not being formatted properly
I tested the docs by running renv::install(".", dependencies = TRUE) and then running pkgdown::build_site(preview = TRUE). Let me know if you want help figuring out how to fix these formatting issues!
| \name{make_objective_mse_cov} | ||
| \alias{make_objective_mse_cov} | ||
| \title{Custom LightGBM objective: MSE + rho * Cov(r, y)^2} | ||
| \title{Custom LightGBM objective: MSE with a squared covariance penalty} |
There was a problem hiding this comment.
Seems like title doesn't support LaTeX so I just used english
| RoxygenNote: 7.2.3 | ||
| Roxygen: list(markdown = TRUE) | ||
| Config/testthat/edition: 3 | ||
| Config/roxygen2/version: 8.0.0 |
There was a problem hiding this comment.
Used roxygen2::roxygenise() locally which auto-added this
| workflows, | ||
| yardstick | ||
| RoxygenNote: 7.2.3 | ||
| Roxygen: list(markdown = TRUE) |
There was a problem hiding this comment.
This fixed the hyperlink among other things
jeancochrane
left a comment
There was a problem hiding this comment.
Nice work fixing up the docs!
| #' @param verbose Integer. Verbosity level: < 0 = Fatal, 0 = Error (Warning), | ||
| #' 1 = Info, > 1 = Debug. |
There was a problem hiding this comment.
[Question, non-blocking] What's the motivation for this change?
There was a problem hiding this comment.
The previous > was attempted to be parsed by roxygen as markdown before being translated to Rd format, but it errored out because apparently Rd files have no blockquote equivalent. It was because the previous line started with >
https://github.com/r-lib/roxygen2/blob/aaacc1322cffadfaa556968f4fb5a363482b086d/R/markdown.R#L109
…475) The main contribution of this PR is to wire in logic to take advantage of lightgbm's support of custom objective functions to add the custom objective function option that the MIT research team has developed with the aim of improving vertical equity. Based on our own internal investigation, it seems to be quite a promising specification. There is EDA that show specific figures in the EI 408 output folder. This PR adds `params.yaml` file edits which include 1. The ability to select the custom obj function from `params.yaml` (`mse_cov`) 2. A toggle to set the model to log the training data. This is necessary for our implementation of the custom objective function. Adding the ability to log the outcome variable is also a generally good option to have in terms of ML modeling strategies. With `select * from model.metadata where run_id = '2026-05-20-infallible-dan'` you can see the persistence of `log_transform_enable` With `SELECT * FROM "model"."parameter_final" where run_id = '2026-05-20-infallible-dan'` you can see `objective = mse_cov` and `mse_cov_rho=1` Supported by lightbgm PR: ccao-data/lightsnip#21 ## Other approaches tried We tried using tailor and building the transforms into the tidymodels workflow model res - #520. We also tried smuggling the transforms into lightsnip itself - model res PR: #524 - lightsnip PR: ccao-data/lightsnip#27 Ultimately we decided against these other strategies due to a combination of increased complexity and recommendations against doing these types of transforms from the tidymodels maintainers. --------- Co-authored-by: William Ridgeway <10358980+wrridgeway@users.noreply.github.com> Co-authored-by: TimCookCountyDS <Timothy.Sparer@cookcountyil.gov>
Adding custom objective function (MSE + an additional penalty term)
The main contribution of this PR is to wire in logic to take advantage of lightgbm's support of custom objective functions to add the custom objective function that the MIT research team has developed with the aim of improving vertical equity. Based on our own internal investigation, it seems to be quite a promising result. There is EDA that show specific figures in the EI 408 output folder.
MIT has reviewed the math/ML theory portion of this PR and signed off on its correctness.
Implementation details
The main complication here is that, although supported, adding a custom objective function to lightgbm is a little bit finicky.
Here are the main complications:
lgb.trainerrors if it seesobjective = "mse_cov"(it's not a built-in name)lgb.trainerrors if it sees anmse_cov_rhoparameter (it's not a LightGBM param at all, we're tunneling it through the parsnipset_engine(...)dotlgb.trainerrors / behaves weirdly if both objective and obj are setSupports model PR: ccao-data/model-res-avm#475
Other approaches tried
We tried using tailor and building the transforms into the tidymodels workflow
model res - ccao-data/model-res-avm#520.
We also tried smuggling the transforms into lightsnip itself
Ultimately we decided against these other strategies due to a combination of increased complexity and recommendations against doing these types of transforms from the tidymodels maintainers.