Skip to content

Commit 855d364

Browse files
Add MSE + covariance penalty term custom objective function (#21)
* Add draft of cov penalty * Add roxygen rd file * Update pkgdown yml * Lint * Add nolint * Edit nolint * Attempt to fix data leakage * Edit comments * Add comments * Add spacing * Fix spacing * Lint * Decrease line length * Remove zero grad lines * Remove zero_grad code * Remove type=raw spec * Switch obj to objective * Swap yc to y_centered * Swap in for == * Point to actions branch * Point back to main * Switch default value check to null check * Remove nolint tag * Add y mean check * Version bump * Add new tests * Remove hessian test * Remove hessian tset * Update formatting
1 parent 91aaf7d commit 855d364

11 files changed

Lines changed: 296 additions & 16 deletions

DESCRIPTION

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Package: lightsnip
22
Type: Package
33
Title: LightGBM Model Engine for Parsnip
4-
Version: 0.0.6
4+
Version: 0.0.7
55
Authors@R: c(
66
person(given = "Dan", family = "Snow", email="daniel.snow@cookcountyil.gov", role=c("aut", "cre")),
77
person(given = "Nicole", family = "Jardine", email="nicole.jardine@cookcountyil.gov", role=c("aut", "ctb")),
@@ -48,5 +48,6 @@ Suggests:
4848
tune,
4949
workflows,
5050
yardstick
51-
RoxygenNote: 7.2.3
51+
Roxygen: list(markdown = TRUE)
5252
Config/testthat/edition: 3
53+
Config/roxygen2/version: 8.0.0

NAMESPACE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export(lambda_l2)
1616
export(learning_rate)
1717
export(lgbm_load)
1818
export(lgbm_save)
19+
export(make_objective_mse_cov)
1920
export(max_bin)
2021
export(max_cat_threshold)
2122
export(max_depth)

R/lightgbm.R

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,8 @@ add_boost_tree_lightgbm <- function() {
111111
#' but it means that the Dataset object cannot be changed after it has been
112112
#' constructed. If you'd prefer to be able to change the Dataset object after
113113
#' construction, set \code{free_raw_data = FALSE}. Useful for debugging.
114-
#' @param verbose Integer. < 0: Fatal, = 0: Error (Warning), = 1: Info,
115-
#' > 1: Debug.
114+
#' @param verbose Integer. Verbosity level: < 0 = Fatal, 0 = Error (Warning),
115+
#' 1 = Info, > 1 = Debug.
116116
#' @param save_tree_error Boolean. Whether or not to use the training set
117117
#' to compute errors for each tree that will be stored on the record_evals
118118
#' attribute. Note that this parameter is mutually exclusive with
@@ -146,8 +146,36 @@ train_lightgbm <- function(x,
146146
force(y)
147147
others <- list(...)
148148

149-
# Set training objective (always regression)
150-
if (!any(names(others) %in% c("objective"))) {
149+
# Custom objective handling. `mse_cov_rho` is a lightsnip-specific engine
150+
# arg used only when `objective == "mse_cov"`; pop it off so it is not
151+
# forwarded to lgb.train (which would error on an unknown parameter).
152+
mse_cov_rho <- others$mse_cov_rho
153+
others$mse_cov_rho <- NULL
154+
155+
custom_objective <- NULL
156+
157+
# Sentinel that gates two downstream branches:
158+
# - whether to fall back to the default "regression" objective
159+
# - whether to construct the mse_cov objective callback
160+
mse_cov_rho_val <- NULL
161+
162+
if (!is.null(others$objective) && identical(others$objective, "mse_cov")) {
163+
if (is.null(mse_cov_rho)) {
164+
rlang::abort(
165+
"`objective = \"mse_cov\"` requires `mse_cov_rho` to be set."
166+
)
167+
}
168+
mse_cov_rho_val <- as.numeric(mse_cov_rho)
169+
# Clear `objective`/`num_class` so lgb.train doesn't reject the unknown
170+
# name when we hand it the callback via `obj`.
171+
others$objective <- NULL
172+
others$num_class <- NULL
173+
}
174+
175+
# Set training objective default (always regression) when not specified.
176+
# Skipped when a custom `obj` callback is in use, since lgb.train will then
177+
# supply the gradient/hessian itself and `objective` must be unset.
178+
if (is.null(mse_cov_rho_val) && !any(names(others) == "objective")) {
151179
others$num_class <- 1
152180
others$objective <- "regression"
153181
}
@@ -235,6 +263,17 @@ train_lightgbm <- function(x,
235263
trn_index <- 1:n
236264
}
237265

266+
# Build the mse_cov callback against training rows only — `y[val_index]`
267+
# is held out for lgb.train's early stopping, so including those labels
268+
# in `y_mean` would leak the holdout's label mean into the centering
269+
# term used by the covariance penalty on every boosting iteration.
270+
if (!is.null(mse_cov_rho_val)) {
271+
custom_objective <- make_objective_mse_cov(
272+
rho = mse_cov_rho_val,
273+
y_mean = mean(y[trn_index])
274+
)
275+
}
276+
238277
d <- lightgbm::lgb.Dataset(
239278
data = as.matrix(x[trn_index, , drop = FALSE]),
240279
label = y[trn_index],
@@ -269,6 +308,10 @@ train_lightgbm <- function(x,
269308
if (!is.null(early_stop) && validation > 0) {
270309
main_args$early_stopping_rounds <- early_stop
271310
}
311+
# Wire in the custom objective callback (if any) under lgb.train's `obj` arg
312+
if (!is.null(custom_objective)) {
313+
main_args$obj <- quote(custom_objective)
314+
}
272315

273316
call <- parsnip::make_call(fun = "lgb.train", ns = "lightgbm", main_args)
274317
rlang::eval_tidy(call, env = rlang::current_env())

R/objectives.R

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#' Custom LightGBM objective: MSE with a squared covariance penalty
2+
#'
3+
#' @description Build a custom LightGBM objective callback that minimizes a
4+
#' standard squared-error loss plus a soft penalty on the covariance between
5+
#' the per-sample residual \code{r = y_pred - y_true} and the (centered) labels
6+
#' `y_true`. The penalty pushes the model toward "vertical equity" by
7+
#' discouraging residuals that systematically scale with `y`.
8+
#'
9+
#' This is an R port of the `LGBCovPenalty` objective from an active
10+
#' collaboration (see the
11+
# nolint start: line_length_linter.
12+
#' [soft-vertical-equity-constrained-mass-appraissal](https://github.com/nicacevedo/soft-vertical-equity-constrained-mass-appraissal)
13+
# nolint end
14+
#' repository). It is intended to be used when the model is trained in
15+
#' log-space (so the "diff" residual is equivalent to a log-ratio).
16+
#'
17+
#' Penalty (using mean-centered labels `y_centered = y_true - mean(y_true)`):
18+
#' \deqn{\mathrm{cov} = \frac{1}{n} \sum_{i=1}^{n} r_i \tilde{y}_i}{
19+
#' cov = (1/n) * sum_i r_i * y_centered_i}
20+
#' \deqn{\mathrm{penalty} = \frac{\rho}{2} \, n \, \mathrm{cov}^2}{
21+
#' penalty = 0.5 * rho * n * cov^2}
22+
#' where \eqn{\tilde{y}}{y_centered} is the vector of mean-centered labels.
23+
#' A diagonal Hessian approximation is used (matches the reference Python
24+
#' implementation).
25+
#'
26+
#' @param rho Numeric. Non-negative penalty weight. `rho = 0` recovers plain
27+
#' MSE.
28+
#' @param y_mean Numeric. Mean of the training labels. Should be computed once
29+
#' from the training set and captured here so the centering is stable across
30+
#' iterations.
31+
#'
32+
#' @return A function with signature `function(preds, dtrain)` suitable for
33+
#' passing as the `obj` argument of [lightgbm::lgb.train].
34+
#'
35+
#' @export
36+
make_objective_mse_cov <- function(rho, y_mean) {
37+
rho <- as.numeric(rho)
38+
y_mean <- as.numeric(y_mean)
39+
if (length(rho) != 1L || is.na(rho) || rho < 0) {
40+
rlang::abort("`rho` must be a single non-negative numeric value.")
41+
}
42+
if (length(y_mean) != 1L || is.na(y_mean)) {
43+
rlang::abort("`y_mean` must be a single non-missing numeric value.")
44+
}
45+
46+
function(preds, dtrain) {
47+
y_true <- lightgbm::get_field(dtrain, "label")
48+
y_pred <- as.numeric(preds)
49+
n <- length(y_pred)
50+
51+
# Centered labels (training-set mean is captured at construction time so
52+
# the penalty geometry stays stable across boosting iterations)
53+
y_centered <- y_true - y_mean
54+
55+
# Residual ("diff" mode); in log-space training this is the log-ratio
56+
r <- y_pred - y_true
57+
58+
# Covariance estimate (E[y_centered] is ~0 by construction)
59+
cov_val <- mean(r * y_centered)
60+
61+
# Base squared-error grad/hess
62+
grad_base <- 2.0 * (y_pred - y_true)
63+
hess_base <- rep(2.0, n)
64+
65+
# Penalty grad/hess (diagonal approximation)
66+
# dc/dy_pred_i = (1/n) * y_centered_i (since dr_i/dy_pred_i = 1)
67+
a <- y_centered / n
68+
grad_pen <- rho * n * cov_val * a
69+
hess_pen <- rho * n * (a^2)
70+
71+
grad <- grad_base + grad_pen
72+
hess <- hess_base + hess_pen
73+
74+
list(grad = grad, hess = hess)
75+
}
76+
}

_pkgdown.yml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ llm-docs: true
44

55
template:
66
bootstrap: 5
7+
math-rendering: katex
78

89
authors:
910
sidebar:
@@ -26,9 +27,15 @@ reference:
2627
- train_lightgbm
2728
- pred_lgb_reg_num
2829
- multi_predict._lgb.Booster
30+
- subtitle: Custom objectives
31+
desc: >
32+
Factory functions that return custom LightGBM objective callbacks for
33+
use as the `obj` argument of `lgb.train`.
34+
- contents:
35+
- make_objective_mse_cov
2936
- subtitle: LightGBM hyperparameters
30-
desc:: >
31-
LightGBM dials:: paramter functions that can be used with tune_ functions.
37+
desc: >
38+
LightGBM dials:: parameter functions that can be used with tune_ functions.
3239
Pass them to set_engine to use.
3340
- contents:
3441
- param_lgbm

man/axe_recipe.Rd

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/axe_tune_data.Rd

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/make_objective_mse_cov.Rd

Lines changed: 42 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/train_lightgbm.Rd

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/testthat/test-lightgbm.R

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,44 @@ test_that("lightgbm alternate objective", {
8080
expect_equal(info$objective, "huber")
8181
})
8282

83+
test_that("lightgbm mse_cov custom objective", {
84+
skip_if_not_installed("lightgbm")
85+
86+
fit_with_rho <- function(rho) {
87+
parsnip::boost_tree(trees = 50) %>%
88+
parsnip::set_engine(
89+
engine = "lightgbm",
90+
objective = "mse_cov", mse_cov_rho = rho, verbose = -1,
91+
max_depth = 15, feature_fraction = 1, min_data_in_leaf = 1
92+
) %>%
93+
parsnip::set_mode("regression") %>%
94+
parsnip::fit(mpg ~ ., data = mtcars)
95+
}
96+
97+
# predict.lgb.Booster warns that class/response prediction types are
98+
# unsupported for custom objectives; numeric predictions still work
99+
lgb_fit <- fit_with_rho(0.5)
100+
pred <- suppressWarnings(predict(lgb_fit, mtcars[, -1]))
101+
102+
expect_equal(nrow(pred), nrow(mtcars))
103+
expect_true(all(is.finite(pred$.pred)))
104+
expect_not_constant_predictions(pred$.pred)
105+
106+
# The penalty weight should reach the objective: different rho values
107+
# must produce different fits
108+
pred_plain <- suppressWarnings(predict(fit_with_rho(0), mtcars[, -1]))
109+
expect_all_preds_differ(list(pred$.pred, pred_plain$.pred))
110+
})
111+
112+
test_that("lightgbm mse_cov without mse_cov_rho throws error", {
113+
expect_error(
114+
parsnip::boost_tree(trees = 10, mode = "regression") %>%
115+
parsnip::set_engine("lightgbm", objective = "mse_cov", verbose = -1) %>%
116+
parsnip::fit(mpg ~ ., data = mtcars),
117+
regexp = "requires `mse_cov_rho`"
118+
)
119+
})
120+
83121
test_that("lightgbm with save_tree_error saves record_evals", {
84122
model_fit <- parsnip::boost_tree(trees = 50) %>%
85123
parsnip::set_engine(

0 commit comments

Comments
 (0)