hyperparameter-and-formula-search.Rmd
library(mostlytidyMMM)First, let’s import all 3 tabs of a mostlytidyMMM config file.
suppressMessages(suppressWarnings(library(recipes)))
suppressMessages(suppressWarnings(library(tune)))
suppressMessages(suppressWarnings(library(dials)))
suppressMessages(suppressWarnings(library(tidyverse)))
suppressMessages(suppressWarnings(library(workflowsets)))
suppressMessages(suppressWarnings(library(tidymodels)))
suppressMessages(suppressWarnings(library(multilevelmod)))
suppressMessages(suppressWarnings(library(rethinking)))
suppressMessages(suppressWarnings(library(mostlytidyMMM)))
#get the control file:
control_file<-system.file('example model control.xlsx',package='mostlytidyMMM')
#get each relevant table of the control file:
variables<-readxl::read_xlsx(control_file,'variables')
role_controls<-readxl::read_xlsx(control_file,'role controls')
workflow_controls<-readxl::read_xlsx(control_file,"workflow") A key feature of the package is the ability to set a range of adstock and saturation parameter values by media channel.
This is handled by assigning role2 values to varnames in the ‘variables’ tab. Other details about each variable are also present.
variables
#> # A tibble: 12 x 10
#> start_name varname role role2 prior sign retention asymptote
#> <chr> <chr> <chr> <chr> <dbl> <chr> <lgl> <lgl>
#> 1 product product pred~ group NA NA NA NA
#> 2 place store pred~ group NA NA NA NA
#> 3 week week time~ time NA NA NA NA
#> 4 y sales outc~ NA NA NA NA NA
#> 5 price price pred~ price -1 <=0 NA NA
#> 6 TV1 TV1 pred~ tv 1 >=0 NA NA
#> 7 TV2 TV2 pred~ tv 1 >=0 NA NA
#> 8 ProgVideo1 ProgVideo1 pred~ prog~ 1 >=0 NA NA
#> 9 spend_TV1 spend_TV1 post~ tv NA NA NA NA
#> 10 spend_TV2 spend_TV2 post~ tv NA NA NA NA
#> 11 spend_ProgVideo1 spend_ProgVideo1 post~ prog~ NA NA NA NA
#> 12 trend trend pred~ trend NA NA NA NA
#> # i 2 more variables: saturation_speed <lgl>, prior_sd <dbl>The role2 assignment values should match the rows in the ‘role controls’ tab, which exists to define the range of retention and saturation parameters available to each channel/role2 value.
role_controls
#> # A tibble: 2 x 7
#> role asymptote_low asymptote_high saturation_speed_low saturation_speed_high
#> <chr> <dbl> <dbl> <dbl> <dbl>
#> 1 progr~ 100 900 0.0001 0.009
#> 2 tv 100 900 0.0001 0.006
#> # i 2 more variables: retention_low <dbl>, retention_high <dbl>If the variables table includes fixed values of asymptote, saturation_speed, or retention, then the recipe step for that hyper-parameter for that variable will not be set to a tuneable value. There is a known bug: If the variable table fixes any of the hyperparameters, the others are left to be searched over the default range, not the range set on the role_controls table.
And the ‘workflow’ tab has controls for the modeled variable, if hyperparameter tuning will happen, and if either the seasonality specification or the random effects searches will run.
workflow_controls
#> # A tibble: 9 x 3
#> desc Value R_name
#> <chr> <chr> <chr>
#> 1 Which variable are we modeling? sales Y
#> 2 Run a hyper parameter tuning process? If FALSE, will use saved ~ TRUE tune_~
#> 3 saved hyper parameter tibble NA saved~
#> 4 Find best seasonality spec? TRUE searc~
#> 5 seasonality interactions to test? store inter~
#> 6 number of fourier terms to use for seasonality (up to 5; 2 is ro~ 1 fft_t~
#> 7 should different random effects be tested? TRUE searc~
#> 8 random effects intercepts -- comma separated list of categorical~ (1|s~ list_~
#> 9 random slopes (TV1~ list_~To demonstrate how this works, we’ll import some data and prepare it. In the code block below, the add_fourier_vars(), add_groups_and_sort(), and rename_columns_per_controls are functions from the mostlytidyMMM package to use the configuration tables to prepare the data for model searching and final estimation.
data1<-read.csv(system.file('example2.csv',package='mostlytidyMMM'))|>
rename_columns_per_controls(variable_controls=variables)|> mutate(week=as.Date(week,"%m/%d/%Y"))|>
add_fourier_vars(vc=variables) |> add_groups_and_sort(vc=variables) Because mostlytidyMMM leverages the tidymodels’ packages for model and hyperparameter tuning, it include a function to produce a recipe (ie a pre-processor) from the configuration files.
recipe3<-create_recipe(data_to_use=data1,
vc=variables,
mc=role_controls,
wc=workflow_controls)In this case, with the workflow_controls table looking for hyperparameter tuning (ie R_name == ‘tune_this_time’ has Value==“TRUE”), so the created recipe uses the tune and dials packages, which can be seen by reading the printout or checking that it needs to be tuned with the provided function:
recipe3
#> Recipe
#>
#> Inputs:
#>
#> role #variables
#> group 2
#> outcome 1
#> postprocess 3
#> predictor 17
#> price 1
#> programmatic 2
#> time 11
#> time_id 1
#> trend 1
#> tv 4
#>
#> Operations:
#>
#> Adstock Transformation with retention tune on Adstock Transformation with retention programmatic_retention onhas_role("programmatic")
#> Saturation (asymptote= tune saturation_speed= tune Transformation on Saturation (asymptote= programmatic_asymptote saturation_speed= programmatic_saturation_speed Transformation onhas_role("programmatic")
#> Adstock Transformation with retention tune on Adstock Transformation with retention tv_retention onhas_role("tv")
#> Saturation (asymptote= tune saturation_speed= tune Transformation on Saturation (asymptote= tv_asymptote saturation_speed= tv_saturation_speed Transformation onhas_role("tv")
#> Variables selected -has_role("postprocess")
#> Novel factor level assignment for all_of(<chr: "product", "store">)
#> Variable mutation for all_of(<chr: "product", "store">)
check_if_needs_tune(recipe3)
#> [1] TRUEBecause seasonality and duration of adstock are very likely influence each other, or at least be confoundable, mostlytidyMMM searches for those specifcations together (if both are enabled). This is done by combining the recipe with hyperparameters set to tune() and a list of possible model formula.
Note that, for speed, model testing is done with lm or lmer() and not bayesian models. This may change over time.
The make_list_of_fft_formulae does what it’s name says – note this is 4 different models to test!
fft_formulae0<-make_list_of_fft_formulae(workflow_controls,recipe3)
#split the output into two lists, one for formula and one for workflow config tables:
#list must have names for workflowsets
formulae<-fft_formulae0[[1]]
names(formulae)<-as.character(1:length(formulae))
configs_fft_options<-fft_formulae0[[2]]
length(formulae)
#> [1] 3
formulae[[1]]
#> [1] "sales ~ price + TV1 + TV2 + ProgVideo1 + trend"
formulae[[2]]
#> [1] "sales ~ price + TV1 + TV2 + ProgVideo1 + trend + sin1 + cos1"
formulae[[3]]
#> [1] "sales ~ price + TV1 + TV2 + ProgVideo1 + trend + (sin1|store) + (cos1|store)"We create a workflow by combining the recipe and a model (which includes the formula). This is wrapped in the assemble_workflow function.
A list of workflows becomes a workflowset, and workflow_map runes a grid search on all of them using a time-dependent data split. remember, this is trying 4 models and building a hyper parameter grid (on few values, using grid=5 here).
For this example, let’s we’ll remove the most complicated and ignore the rank deficiency warnings. . .
list_of_flows<-lapply(formulae,assemble_workflow,recipe3)
tune_all_these<-as_workflow_set(!!!list_of_flows)
time_id_var = variables |>filter(role=='time_id')|>select(varname)|>unlist()
#noe use of sliding_period for 'resampling'
data_splits<-sliding_period(data1 |>ungroup()|> arrange(across(all_of(!!time_id_var))),!!time_id_var,period='week',
lookback=90,assess_stop=4,step=5)
#note that we can't say we are doing a good job detecting seasonality with 100 week training periods . .
fft_selecting_tune_results<-workflow_map(tune_all_these,grid=5,resamples=data_splits)
#> x Slice1: internal:
#> Error in `stop_bad_type()`:
#> ! Result 1 must be a single string, not a character vector o...
#> boundary (singular) fit: see help('isSingular')
#> boundary (singular) fit: see help('isSingular')
#> boundary (singular) fit: see help('isSingular')
#> boundary (singular) fit: see help('isSingular')
#> boundary (singular) fit: see help('isSingular')
#> boundary (singular) fit: see help('isSingular')
#> boundary (singular) fit: see help('isSingular')
#> boundary (singular) fit: see help('isSingular')
#> boundary (singular) fit: see help('isSingular')
#> boundary (singular) fit: see help('isSingular')The workflowsets package has a nice autoplot function, but for now we’ll take ‘the best’.
autoplot(fft_selecting_tune_results,metric='rmse')
id_of_best<-rank_results(fft_selecting_tune_results,rank_metric="rmse",select_best=F) |>
select(wflow_id) %>% slice_head(n=1) |> unlist()
if(check_if_needs_tune(recipe3)){
hyper_parms<-select_best(
extract_workflow_set_result(fft_selecting_tune_results,id=id_of_best),metric='rmse')
hyper_parms_finalized_recipe<-recipe3 %>% finalize_recipe(hyper_parms)
}else{
hyper_parms_finalized_recipe<-recipe3
}
best_seas_vc<-configs_fft_options[[as.numeric(id_of_best)]]
best_seas_formula<-formulae[[as.numeric(id_of_best)]]Now we repeat that process, holding the seasonality terms fixed per the ‘best’ model above, and add on the search for random effects
(list_of_formulae_rands<-make_list_of_rands_formula(
seasonality_formula = best_seas_formula,
vc = best_seas_vc
) )
#> [[1]]
#> [1] "sales ~ price + TV1 + TV2 + ProgVideo1 + trend + sin1 + cos1+(1|store)"
#>
#> [[2]]
#> [1] "sales ~ price + TV1 + TV2 + ProgVideo1 + trend + sin1 + cos1+(TV1|store)"
#>
#> [[3]]
#> [1] "sales ~ price + TV1 + TV2 + ProgVideo1 + trend + sin1 + cos1+(1|store) + (TV1|store)"
#>
#> [[4]]
#> [1] "sales ~ price + TV1 + TV2 + ProgVideo1 + trend + sin1 + cos1"
list_of_flows2<-lapply(list_of_formulae_rands,assemble_workflow,hyper_parms_finalized_recipe)
names(list_of_flows2)<-as.character(1:length(list_of_formulae_rands))
tune_all_these2<-as_workflow_set(!!!list_of_flows2)
data_splits<-sliding_period(data1 |>ungroup()|> arrange(across(all_of(!!time_id_var))),!!time_id_var,period='week',
lookback=90,assess_stop=4,step=5)
rands_selecting_tune<-workflow_map(tune_all_these2,grid=1,resamples=data_splits)
#> boundary (singular) fit: see help('isSingular')
#> boundary (singular) fit: see help('isSingular')
#> boundary (singular) fit: see help('isSingular')
#> boundary (singular) fit: see help('isSingular')
#> boundary (singular) fit: see help('isSingular')
#> boundary (singular) fit: see help('isSingular')
rank_results(rands_selecting_tune)
#> # A tibble: 8 x 9
#> wflow_id .config .metric mean std_err n preprocessor model rank
#> <chr> <chr> <chr> <dbl> <dbl> <int> <chr> <chr> <int>
#> 1 2 Preprocessor1_M~ rmse 7.26 1.87 2 recipe line~ 1
#> 2 2 Preprocessor1_M~ rsq 0.602 0.121 2 recipe line~ 1
#> 3 3 Preprocessor1_M~ rmse 7.26 1.87 2 recipe line~ 2
#> 4 3 Preprocessor1_M~ rsq 0.602 0.121 2 recipe line~ 2
#> 5 1 Preprocessor1_M~ rmse 7.26 1.87 2 recipe line~ 3
#> 6 1 Preprocessor1_M~ rsq 0.602 0.121 2 recipe line~ 3
#> 7 4 Preprocessor1_M~ rmse 7.26 1.87 2 recipe line~ 4
#> 8 4 Preprocessor1_M~ rsq 0.602 0.121 2 recipe line~ 4
id_of_best_rand<-rank_results(rands_selecting_tune,rank_metric="rmse",select_best=T) %>%
select(wflow_id) %>% slice_head(n=1) %>% unlist()
best_formula<-list_of_formulae_rands[[as.numeric(id_of_best_rand)]][1]So, finally, we have the best hyperparameters, seasonality, and random effects:
hyper_parms
#> # A tibble: 1 x 7
#> programmatic_retention programmatic_asymptote programmatic_saturation_speed
#> <dbl> <dbl> <dbl>
#> 1 0.476 219. 0.00282
#> # i 4 more variables: tv_retention <dbl>, tv_asymptote <dbl>,
#> # tv_saturation_speed <dbl>, .config <chr>
best_formula
#> [1] "sales ~ price + TV1 + TV2 + ProgVideo1 + trend + sin1 + cos1+(TV1|store)"And we can fit that with the full bayesian model, using a few additional functions from the package for setup, and pre-transforing the data with the finalized recipe
boundaries<-make_bound_statements(variable_controls=variables)
formula_list_for_final<-create_ulam_list(prior_controls=variables, model_formula=best_formula)
data_to_model<-bake(hyper_parms_finalized_recipe|>prep(),data1)
final_fit_model<-rethinking::ulam(formula_list_for_final,
constraints = boundaries,
chains=1,iter=100,
data=data_to_model,
sample = T,
cmdstan = T,
#file='final_fit_model',
cores=4,
declare_all_data=F,
messages=F
)
#> Running MCMC with 1 chain, with 1 thread(s) per chain...
#>
#> Chain 1 WARNING: There aren't enough warmup iterations to fit the
#> Chain 1 three stages of adaptation as currently configured.
#> Chain 1 Reducing each adaptation stage to 15%/75%/10% of
#> Chain 1 the given number of warmup iterations:
#> Chain 1 init_buffer = 7
#> Chain 1 adapt_window = 38
#> Chain 1 term_buffer = 5
#> Chain 1 Iteration: 1 / 100 [ 1%] (Warmup)
#> Chain 1 Informational Message: The current Metropolis proposal is about to be rejected because of the following issue:
#> Chain 1 Exception: normal_lpdf: Location parameter[1] is nan, but must be finite! (in 'C:/Users/loren/AppData/Local/Temp/RtmpuMSzQs/model-57902f9143a2.stan', line 47, column 4 to column 44)
#> Chain 1 If this warning occurs sporadically, such as for highly constrained variable types like covariance matrices, then the sampler is fine,
#> Chain 1 but if this warning occurs often then your model may be either severely ill-conditioned or misspecified.
#> Chain 1
#> Chain 1 Informational Message: The current Metropolis proposal is about to be rejected because of the following issue:
#> Chain 1 Exception: normal_lpdf: Location parameter[1] is nan, but must be finite! (in 'C:/Users/loren/AppData/Local/Temp/RtmpuMSzQs/model-57902f9143a2.stan', line 47, column 4 to column 44)
#> Chain 1 If this warning occurs sporadically, such as for highly constrained variable types like covariance matrices, then the sampler is fine,
#> Chain 1 but if this warning occurs often then your model may be either severely ill-conditioned or misspecified.
#> Chain 1
#> Chain 1 Informational Message: The current Metropolis proposal is about to be rejected because of the following issue:
#> Chain 1 Exception: normal_lpdf: Location parameter[1] is nan, but must be finite! (in 'C:/Users/loren/AppData/Local/Temp/RtmpuMSzQs/model-57902f9143a2.stan', line 47, column 4 to column 44)
#> Chain 1 If this warning occurs sporadically, such as for highly constrained variable types like covariance matrices, then the sampler is fine,
#> Chain 1 but if this warning occurs often then your model may be either severely ill-conditioned or misspecified.
#> Chain 1
#> Chain 1 Informational Message: The current Metropolis proposal is about to be rejected because of the following issue:
#> Chain 1 Exception: normal_lpdf: Location parameter[1] is nan, but must be finite! (in 'C:/Users/loren/AppData/Local/Temp/RtmpuMSzQs/model-57902f9143a2.stan', line 47, column 4 to column 44)
#> Chain 1 If this warning occurs sporadically, such as for highly constrained variable types like covariance matrices, then the sampler is fine,
#> Chain 1 but if this warning occurs often then your model may be either severely ill-conditioned or misspecified.
#> Chain 1
#> Chain 1 Informational Message: The current Metropolis proposal is about to be rejected because of the following issue:
#> Chain 1 Exception: normal_lpdf: Location parameter[1] is nan, but must be finite! (in 'C:/Users/loren/AppData/Local/Temp/RtmpuMSzQs/model-57902f9143a2.stan', line 47, column 4 to column 44)
#> Chain 1 If this warning occurs sporadically, such as for highly constrained variable types like covariance matrices, then the sampler is fine,
#> Chain 1 but if this warning occurs often then your model may be either severely ill-conditioned or misspecified.
#> Chain 1
#> Chain 1 Informational Message: The current Metropolis proposal is about to be rejected because of the following issue:
#> Chain 1 Exception: normal_lpdf: Location parameter[1] is nan, but must be finite! (in 'C:/Users/loren/AppData/Local/Temp/RtmpuMSzQs/model-57902f9143a2.stan', line 47, column 4 to column 44)
#> Chain 1 If this warning occurs sporadically, such as for highly constrained variable types like covariance matrices, then the sampler is fine,
#> Chain 1 but if this warning occurs often then your model may be either severely ill-conditioned or misspecified.
#> Chain 1
#> Chain 1 Informational Message: The current Metropolis proposal is about to be rejected because of the following issue:
#> Chain 1 Exception: normal_lpdf: Location parameter[1] is nan, but must be finite! (in 'C:/Users/loren/AppData/Local/Temp/RtmpuMSzQs/model-57902f9143a2.stan', line 47, column 4 to column 44)
#> Chain 1 If this warning occurs sporadically, such as for highly constrained variable types like covariance matrices, then the sampler is fine,
#> Chain 1 but if this warning occurs often then your model may be either severely ill-conditioned or misspecified.
#> Chain 1
#> Chain 1 Iteration: 51 / 100 [ 51%] (Sampling)
#> Chain 1 Iteration: 100 / 100 [100%] (Sampling)
#> Chain 1 finished in 5.2 seconds.
#> Warning: 40 of 50 (80.0%) transitions hit the maximum treedepth limit of 10.
#> See https://mc-stan.org/misc/warnings for details.With the fit done, we can make charts of residuals and decompose the historical data into the various drivers.
data_to_model$pred<-predict.ulam(final_fit_model,data_to_model)[,1]
this_rsq<-rsq(data_to_model|>ungroup(),truth=sales,estimate=pred)['.estimate'] %>% unlist()
this_mape<-mape(data_to_model|>ungroup(),truth=sales,estimate=pred)['.estimate'] %>% unlist()
ggplot(data_to_model ,aes(x=sales,y=pred,color=store_id))+
geom_point()+ geom_abline(slope=1,intercept=0)+ggthemes::theme_tufte()+
ggtitle("Predicted vs Actual",subtitle=paste0('Rsq is ',round(this_rsq,2)))
model_preds_long<-data_to_model %>% pivot_longer(c(pred,sales))
ggplot(model_preds_long,aes(x=week,y=value,color=name))+geom_line()+
ggtitle("Sales and Predicted Sales by Week",subtitle=paste('MAPE is',round(this_mape)))
decomps<-get_decomps_irregardless(data_to_model %>% ungroup(),recipe_to_use=hyper_parms_finalized_recipe,
model_obj=final_fit_model,
)Roll those up to total by week and plot them:
decomps_natl<-decomps %>% select(week,all_of(!!get_predictors_vector(hyper_parms_finalized_recipe))) %>% group_by(week) %>% summarise(across(where(is.numeric),sum))
decomps_natl<-decomps_natl %>% pivot_longer(cols=c(-week))
ggplot(data=decomps_natl,aes(x=week,y=value,fill=name)) + geom_area()+ggthemes::theme_tufte()+
ggtitle("Decomposition By Week")+
theme(legend.position = 'bottom')