Add covariance penalty option and configurable logging of sale price#475
Conversation
|
@jeancochrane Would love your feedback re:
Currently the logging of price (and exponentiating) is hard-coded, I'm thinking I can just build in something like What I'm imagining as the more complicated piece is persisting the custom obj function metadata. The I'm trying to think of a solution here that would work for other custom obj functions in the future that could have any number of additional parameters to tune. Currently the A low effort solution could be to dump them all in a json obj in a new column, but I think there is probably something better. We could also start a condition in the code like What do you think? Tagging @TimCookCountyDS, to make sure my understanding of potential future custom objective functions lines up with his, and for visibility |
Sounds great to me!
This is a good concern to raise. I wonder whether we are likely to add lots of new objective functions with their own specific parameters, and if so, how many (ballpark) we expect to add? If we think we will add lots of new objective functions in the future (e.g. more than, say, 5) then I think the flexible JSON-based schema you suggest may be worth it. If we only expect a small set of new objective functions, however, then I think the additional complexity of mixing JSON fields with regular fields won't be worth it. |
Turns out due to how the pipeline right parameters we already have I think for now it may make sense to build it without an extensible version in mind, as I don't think we will be adding more objective functions any time soon. But I could be wrong, I suspect a migration given the future need for it would be relatively painless. Let me know what you think @TimCookCountyDS @jeancochrane |
I think if the objective function is currently captured in the model.parameter final table we should be ok for now? I think the only additional function we were considering adding was the smooth penalty (which iirc had some tighter theoretical properties?), but even that is entirely optional - (That said, the json schema is good to keep in mind should things get more complicated). |
| "RemoteRef": "master", | ||
| "RemoteSha": "2720ad973c990ad64ac7e6bb632389ff9f2a2675", | ||
| "RemoteRef": "test-new-obj-function", | ||
| "RemoteSha": "f228237f4595cb15c8ff5482d06d116595e4ec16", |
There was a problem hiding this comment.
Reminder to revert this to master after merging in lightsnip PR
| if (log_transform_enable) { | ||
| preds <- preds %>% | ||
| mutate( | ||
| pred_card_initial_fmv = exp(pred_card_initial_fmv), |
There was a problem hiding this comment.
Revert log scale for interpretable results and downstream values
| # Note included with each run. Use this to summarize what changed about the run | ||
| # or add context | ||
| run_note: Potential residential baseline with SHAP values, revert DVC path | ||
| run_note: Test cov term |
There was a problem hiding this comment.
Should I leave this as it was? Does it also make sense to revert the log_transform_enable and the objective to the prior state here? For me I don't have a good sense of which things we should keep stable on the master branch of our models.
I also haven't yet edited the dvc.lock file, should I go ahead dvc repro to do that?
I can run repro on the server tomorrow (I won't be taking up resources at the same time as anyone else), or we would take it as an opportunity to see if someone else can replicate these changes/a model run.
There was a problem hiding this comment.
[Suggestion, non-blocking] Personally, I think we should leave run_type and run_note set to the final values for the 2026 model until we're ready to start next year's modeling session. It's a purely aesthetic preference, however, so I'm fine changing them now if other folks would prefer to do that.
There was a problem hiding this comment.
I'll follow Jean's lead here - and will add - as long as we're saving which objective function we use, and the log transform in the params- (so that we can track experiments/tests) - I don't have strong feelings on run_type/run_note here.
jeancochrane
left a comment
There was a problem hiding this comment.
Great start here! I have a few big-picture questions about the design, happy to chat them out if it would be helpful.
| # Note included with each run. Use this to summarize what changed about the run | ||
| # or add context | ||
| run_note: Potential residential baseline with SHAP values, revert DVC path | ||
| run_note: Test cov term |
There was a problem hiding this comment.
[Suggestion, non-blocking] Personally, I think we should leave run_type and run_note set to the final values for the 2026 model until we're ready to start next year's modeling session. It's a purely aesthetic preference, however, so I'm fine changing them now if other folks would prefer to do that.
| # Log-transform `meta_sale_price` for LightGBM; predictions are | ||
| # exp()-scaled back. Linear baseline always uses log(price). | ||
| log_transform_enable: true |
There was a problem hiding this comment.
[Thought, non-blocking] I'm wondering if it makes sense for this param to be a toggle, or if we should move it somewhere else in this file? All of the other toggles are implemented that way because they are features that we frequently want to switch on and off during the course of normal model development and operation. The log transformation strikes me as being more similar to a parameter like model.objective in that we will likely choose one approach that works best for us and stick with it. As evidence of this likelihood, it doesn't seem like we plan to expose a workflow option for log_transform_enable, which is the primary interface through which we use toggles. What do you think about moving this param to something like model.log_sale_price instead? I'm curious what other reviewers think, too.
There was a problem hiding this comment.
" As evidence of this likelihood, it doesn't seem like we plan to expose a workflow option for log_transform_enable" - good point :)
"The log transformation strikes me as being more similar to a parameter like model.objective in that we will likely choose one approach that works best for us and stick with it." <- this tracks for me : I imagine we'll want to be able to change it from time to time, based on new data, but throwing it under the model heading works for me.
There was a problem hiding this comment.
Great point on the workflow toggle, I've changed the param to model.log_sale_price, the references in the pipeline are still log_transform_enable, I think it makes sense to switch those to log_sale_price as well starting in setup.R, then after finalizing, delete the testing runs and re-crawl (not sure if we actually need to re-crawl) to replace the column in our tables?
Does that sound right to you?
| training_data_full | ||
| } else { | ||
| training_data_full %>% mutate(meta_sale_price = log(meta_sale_price)) | ||
| }, |
There was a problem hiding this comment.
Factored this out into recipes because if we were moving the lgbm log config there, it seemed like it made sense for this to go there too
| # therefore come back on the log scale -- see to_dollars() for the inverse | ||
| if (log_outcome) { | ||
| main_recipe <- main_recipe %>% | ||
| step_log(meta_sale_price, skip = TRUE) |
There was a problem hiding this comment.
I don't think there is an easy way to build in exponentiation at predict time into the bake/predict workflow, but step_log is supported for the training with the skip arg. Not sure if you had other ideas
There was a problem hiding this comment.
[Suggestion, optional] Hmm, it would definitely be a bummer if we couldn't include the exponentiation step in the workflow. There will be a lot of room for error if we have to remember to call the to_dollars() or exp() functions on our predictions, especially if we have to call them differently for the tree vs. linear models.
I did a little digging and it seems like the tailor package is the tidymodels-supported way of adding postprocessing steps. It's still experimental but seems like the recommended way of solving this problem; see this blog post with a bit more background, and a demonstration of the way in which the workflows package has native support for tailor postprocessors via the add_tailor() function. I found the docs a bit confusing because the post-processing steps they include in their examples seem complex to me as a lay reader, but the adjust_predictions_custom() postprocessor seems like it would work out of the box with a simple exp() call like adjust_predictions_custom(.pred = exp(.pred)). Our proposed postprocessing step is a pure mathematical function, no learning required, so I don't think we need to worry about data leakage as described in the "Data-dependent transformations" and "Data Usage" sections of the docs.
Does this all make sense, and does it seem like a promising path forward to you too? If so, I say we go ahead and try it out before we decide to move forward with to_dollars()/exp(). I'm also happy to chat this out tomorrow if it would make things clearer.
There was a problem hiding this comment.
This makes sense to me! I think the cost to remember to exp the outcome, if tied to a single param key, would be quite low, particularly because even if the exp gets called on non-logged data it will be extremely obvious looking at any sort of metric. Perhaps I'm missing something, though.
All of that to say I still think using a supported post-processing step makes much more sense from an organizational/readability/LoC perspective. Nice find, I'm going to dig in and see if I can work it in
There was a problem hiding this comment.
Attempting to formalize ideas on potential problems with implementation of the tailor package as a log/exp solution
Hurdle 1 - serialization
Essentially the idea is that when the model gets saved, we don't currently persist post-processing information, so we would have to find a way to have the tailor exp transform persist through to 02-assess.r
Consider the model persist code in 01-train.R:
# Save the finalized model object to file so it can be used elsewhere. Note the
# lgbm_save() function, which uses lgb.save() rather than saveRDS(), since
# lightgbm is picky about how its model objects are stored on disk
lgbm_wflow_final_full_fit %>%
workflows::extract_fit_parsnip() %>%
lightsnip::lgbm_save(paths$output$workflow_fit$local)
# Save the finalized recipe object to file so it can be used to preprocess
# new data. This is critical since it saves the factor levels used to integer-
# encode any categorical columns
lgbm_wflow_final_full_fit %>%
workflows::extract_recipe() %>%
lightsnip::axe_recipe() %>%
saveRDS(paths$output$workflow_recipe$local)In the first chunk, we only persist the model fit itself. In the second chunk, extract_recipe pulls from workflow$pre and the tailor code would live in workflow$post. I believe there is an extract_postprocessor() (shipped with the whole tailor ecosystem bump) function that could possibly be used for this use case, but then we are saving another object in 01-train.R and re-attaching in 02.asses.R, is that an option worth pursuing?
Hurdle 2 - existing CV problem with step_log()
With the addition of step_log the CV code is training in log space but doing per-model evaluation in dollar space.
In CV to score a fold, tune processes the assessment (held-out) rows through the recipe to get both predictors and the truth column, then hands (truth, estimate) to yardstick. That processing is a bake() call and because step_log has skip = TRUE, it is skipped here so the assessment meta_sale_price (the truth yardstick compares against) is left in raw dollars, while .pred is in log.
So even with the recent to_dollars proof-of-concept commit I introduced a high-impact CV bug.
General thoughts
My emerging opinion is that the whole recipe workflow works well enough for feature transformations, but for the target, sale price, it is clunkier and riskier to make changes to.
See in the (admittedly outdated) tidymodels book: https://www.tmwr.org/recipes
For simple transformations of the outcome column(s), we strongly suggest that those operations be conducted outside of the recipe.
Which makes the prior verbose and unclean if statements a more attractive option, though I'm sure those could be cleaned up in some way.
There was a problem hiding this comment.
"My emerging opinion is that the whole recipe workflow works well enough for feature transformations, but for the target, sale price, it is clunkier and riskier to make changes to."
- My thoughts (largely, at this moment, subjective, from past projects with tidymodels) generally align with this. Sometimes trying to shoe-horn things into a recipe leads to more complication than necessary.
re:
"Which makes the prior verbose and unclean if statements a more attractive option, though I'm sure those could be cleaned up in some way."
For our pipeline we have a minimum number of transforms to make, correct? (log transform the training inputs, exponentiate the predictions)- Is there any bigger concern about any processes that (later) use the model, or consume it's outputs?
(If so we may just want to flag the need to log transform/exponentiate as appropriate).
There was a problem hiding this comment.
[Update]: working on Jean's custom post processor proposition
Sorry I missed this comment and had fired off a model run -goofy-tayun- - I didn't mean to create more work, apologies |
This reverts commit eb935b0.
|
A few thoughts for next steps:
|
| "RemoteRepo": "lightsnip", | ||
| "RemoteRef": "master", | ||
| "RemoteSha": "2720ad973c990ad64ac7e6bb632389ff9f2a2675", | ||
| "RemoteRef": "test-new-obj-function", |
There was a problem hiding this comment.
Todo: Switch this before merging
jeancochrane
left a comment
There was a problem hiding this comment.
Awesome work! I'm sorry I led us on a wild goose chase to try to simplify this... reviewing it again, I'm seeing that it's actually not that bad 😅
I do think we should preserve these logged outcomes, but I'm happy to save that for a follow-up PR, so I opened up #528 to track the task.
…vm into test-log-transform Merge conflict fix
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.yamlfile edits which includeparams.yaml(mse_cov)With
select * from model.metadata where run_id = '2026-05-20-infallible-dan'you can see the persistence oflog_transform_enableWith
SELECT * FROM "model"."parameter_final" where run_id = '2026-05-20-infallible-dan'you can seeobjective = mse_covandmse_cov_rho=1Supported 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
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.