From b7b0342c2d2bf0d8aee15ccfebd58e221a2d8b09 Mon Sep 17 00:00:00 2001 From: laresbernardo Date: Tue, 9 Jul 2024 14:04:12 +0200 Subject: [PATCH 1/3] feat: new target_depvar scenario for budget allocator (not ready) --- R/R/allocator.R | 81 +++++++++++++++++++++++++++++++++++++--- R/R/checks.R | 2 +- R/R/plots.R | 3 +- R/man/robyn_allocator.Rd | 8 +++- 4 files changed, 85 insertions(+), 9 deletions(-) diff --git a/R/R/allocator.R b/R/R/allocator.R index b7a52e6ae..bd95dca25 100644 --- a/R/R/allocator.R +++ b/R/R/allocator.R @@ -28,13 +28,17 @@ #' Quadratic Programming" and "Augmented Lagrangian". Alternatively, "\code{"MMA_AUGLAG"}, #' short for "Methods of Moving Asymptotes". More details see the documentation of #' NLopt \href{https://nlopt.readthedocs.io/en/latest/NLopt_Algorithms/}{here}. -#' @param scenario Character. Accepted options are: \code{"max_response"}, \code{"target_efficiency"}. -#' Scenario \code{"max_response"} answers the question: +#' @param scenario Character. Accepted options are: \code{"max_response"}, \code{"target_efficiency"}, +#' \code{"target_depvar"}. Scenario \code{"max_response"} answers the question: #' "What's the potential revenue/conversions lift with the same (or custom) spend level #' in \code{date_range} and what is the allocation and expected response mix?" #' Scenario \code{"target_efficiency"} optimizes ROAS or CPA and answers the question: #' "What's the potential revenue/conversions lift and spend levels based on a #' \code{target_value} for CPA/ROAS and what is the allocation and expected response mix?" +#' Scenario \code{"target_depvar"} optimizes ROAS or CPA, allowing the user to define a +#' target value for total revenue or total conversions. To account for total values, +#' this scenario calculates the baseline (which is fixed for the \code{date_range} provided), +#' and provides the required allocation and budget for paid media channels. #' Deprecated scenario: \code{"max_response_expected_spend"}. #' @param total_budget Numeric. Total marketing budget for all paid channels for the #' period in \code{date_range}. @@ -162,13 +166,15 @@ robyn_allocator <- function(robyn_object = NULL, if (is.null(channel_constr_low)) { channel_constr_low <- case_when( scenario == "max_response" ~ 0.5, - scenario == "target_efficiency" ~ 0.1 + scenario == "target_efficiency" ~ 0.1, + scenario == "target_depvar" ~ 0.5 ) } if (is.null(channel_constr_up)) { channel_constr_up <- case_when( scenario == "max_response" ~ 2, - scenario == "target_efficiency" ~ 10 + scenario == "target_efficiency" ~ 10, + scenario == "target_depvar" ~ 5 ) } if (length(channel_constr_low) == 1) channel_constr_low <- rep(channel_constr_low, length(paid_media_spends)) @@ -352,6 +358,33 @@ robyn_allocator <- function(robyn_object = NULL, target_value_ext <- 1 } } + if (scenario == "target_depvar") { + channelConstrLowSortedExt <- channelConstrLowSorted + channelConstrUpSortedExt <- channelConstrUpSorted + # Calculate baseline for date range to extract from target + temp <- lares::robyn_performance( + InputCollect, OutputCollect, + date_min, date_max, select_model) + baseline <- temp$response[temp$channel == "GRAND TOTAL"] - + ifelse(dep_var_type == "conversion", sum(initResponseUnit), sum(initSpendUnit)) + # Calculate default value if not provided + if (dep_var_type == "conversion") { + target_value_def <- sum(initResponseUnit) + baseline + } else { + target_value_def <- sum(initSpendUnit) + baseline + } + if (is.null(target_value)) { + target_value <- target_value_def + } + message(sprintf( + "Extracted total baseline from target %s: (%s - %s = %s)", + InputCollect$dep_var_type, + round(target_value), round(baseline), round(target_value - baseline) + )) + target_value <- target_value - baseline + target_value_ext <- target_value_def - baseline + } + temp_init <- temp_init_all <- initSpendUnit # if no spend within window as initial spend, use historical average if (length(zero_spend_channel) > 0) temp_init_all[zero_spend_channel] <- histSpendAllUnit[zero_spend_channel] @@ -511,6 +544,40 @@ robyn_allocator <- function(robyn_object = NULL, ) } + if (scenario == "target_depvar") { + ## bounded optimisation + nlsMod <- nloptr::nloptr( + x0 = x0, + eval_f = eval_f, + eval_g_eq = if (constr_mode == "eq") eval_g_eq_effi else NULL, + eval_g_ineq = if (constr_mode == "ineq") eval_g_eq_effi else NULL, + lb = lb, ub = ub, + opts = list( + "algorithm" = "NLOPT_LD_AUGLAG", + "xtol_rel" = 1.0e-10, + "maxeval" = maxeval, + "local_opts" = local_opts + ), + target_value = target_value + ) + ## unbounded optimisation + nlsModUnbound <- nloptr::nloptr( + x0 = x0_ext, + eval_f = eval_f, + eval_g_eq = if (constr_mode == "eq") eval_g_eq else NULL, + eval_g_ineq = if (constr_mode == "ineq") eval_g_ineq else NULL, + lb = lb, + ub = x0 * channel_constr_up[1], # Large enough, but not infinite (customizable) + opts = list( + "algorithm" = "NLOPT_LD_AUGLAG", + "xtol_rel" = 1.0e-10, + "maxeval" = maxeval, + "local_opts" = local_opts + ), + target_value = target_value_ext + ) + } + ## get marginal optmSpendUnit <- nlsMod$solution optmResponseUnit <- -eval_f(optmSpendUnit)[["objective.channel"]] @@ -645,7 +712,8 @@ robyn_allocator <- function(robyn_object = NULL, ## Calculate curves and main points for each channel if (scenario == "max_response") { levs1 <- c("Initial", "Bounded", paste0("Bounded x", channel_constr_multiplier)) - } else if (scenario == "target_efficiency") { + } + if (scenario == "target_efficiency") { if (dep_var_type == "revenue") { levs1 <- c( "Initial", paste0("Hit ROAS ", round(target_value, 2)), @@ -658,6 +726,9 @@ robyn_allocator <- function(robyn_object = NULL, ) } } + if (scenario == "target_depvar") { + levs1 <- c("Initial", paste("Hit", dep_var_type, "target"), "Default") + } eval_list$levs1 <- levs1 dt_optimOutScurve <- rbind( diff --git a/R/R/checks.R b/R/R/checks.R index c504793cf..d7872fdf3 100644 --- a/R/R/checks.R +++ b/R/R/checks.R @@ -870,7 +870,7 @@ check_allocator <- function(OutputCollect, select_model, paid_media_spends, scen ) } if ("max_historical_response" %in% scenario) scenario <- "max_response" - opts <- c("max_response", "target_efficiency") # Deprecated: max_response_expected_spend + opts <- c("max_response", "target_efficiency", "target_depvar") # Deprecated: max_response_expected_spend if (!(scenario %in% opts)) { stop("Input 'scenario' must be one of: ", paste(opts, collapse = ", ")) } diff --git a/R/R/plots.R b/R/R/plots.R index a8a253060..ad9d388a7 100644 --- a/R/R/plots.R +++ b/R/R/plots.R @@ -783,7 +783,8 @@ allocation_plots <- function( paste0("Bounded", ifelse(optm_topped_bounded, "^", "")), paste0("Bounded", ifelse(optm_topped_unbounded, "^", ""), " x", bound_mult) ) - } else if (scenario == "target_efficiency") { + } + if (scenario %in% c("target_efficiency", "target_depvar")) { levs2 <- levs1 } diff --git a/R/man/robyn_allocator.Rd b/R/man/robyn_allocator.Rd index df2c9aae2..eb686b810 100644 --- a/R/man/robyn_allocator.Rd +++ b/R/man/robyn_allocator.Rd @@ -60,13 +60,17 @@ recreate a model. To generate this file, use \code{robyn_write()}. If you didn't export your data in the json file as "raw_data", \code{dt_input} must be provided; \code{dt_holidays} input is optional.} -\item{scenario}{Character. Accepted options are: \code{"max_response"}, \code{"target_efficiency"}. -Scenario \code{"max_response"} answers the question: +\item{scenario}{Character. Accepted options are: \code{"max_response"}, \code{"target_efficiency"}, +\code{"target_depvar"}. Scenario \code{"max_response"} answers the question: "What's the potential revenue/conversions lift with the same (or custom) spend level in \code{date_range} and what is the allocation and expected response mix?" Scenario \code{"target_efficiency"} optimizes ROAS or CPA and answers the question: "What's the potential revenue/conversions lift and spend levels based on a \code{target_value} for CPA/ROAS and what is the allocation and expected response mix?" +Scenario \code{"target_depvar"} optimizes ROAS or CPA, allowing the user to define a +target value for total revenue or total conversions. To account for total values, +this scenario calculates the baseline (which is fixed for the \code{date_range} provided), +and provides the required allocation and budget for paid media channels. Deprecated scenario: \code{"max_response_expected_spend"}.} \item{total_budget}{Numeric. Total marketing budget for all paid channels for the From 089a123976a913daa3fde96e85395ce77e156ea3 Mon Sep 17 00:00:00 2001 From: laresbernardo Date: Tue, 9 Jul 2024 14:42:20 +0200 Subject: [PATCH 2/3] fix: logic on response vs spend --- R/R/allocator.R | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/R/R/allocator.R b/R/R/allocator.R index bd95dca25..004540085 100644 --- a/R/R/allocator.R +++ b/R/R/allocator.R @@ -365,22 +365,24 @@ robyn_allocator <- function(robyn_object = NULL, temp <- lares::robyn_performance( InputCollect, OutputCollect, date_min, date_max, select_model) - baseline <- temp$response[temp$channel == "GRAND TOTAL"] - - ifelse(dep_var_type == "conversion", sum(initResponseUnit), sum(initSpendUnit)) - # Calculate default value if not provided - if (dep_var_type == "conversion") { - target_value_def <- sum(initResponseUnit) + baseline - } else { - target_value_def <- sum(initSpendUnit) + baseline - } + target_value_def <- temp %>% + filter(.data$channel %in% InputCollect$paid_media_spends) %>% + pull(.data$response) %>% sum() + total_kpi <- temp$response[temp$channel == "GRAND TOTAL"] + baseline <- total_kpi - target_value_def if (is.null(target_value)) { - target_value <- target_value_def + target_value <- total_kpi } message(sprintf( "Extracted total baseline from target %s: (%s - %s = %s)", InputCollect$dep_var_type, - round(target_value), round(baseline), round(target_value - baseline) + formatNum(target_value, abbr = TRUE), + formatNum(baseline, abbr = TRUE), + formatNum(target_value - baseline, abbr = TRUE) )) + if (target_value - baseline < 0) { + stop("Calculated baseline is larger than target_value input. Please, increase target_value.") + } target_value <- target_value - baseline target_value_ext <- target_value_def - baseline } From 67d4837f015962ef8a35028fb5c05cbb73a9b5f7 Mon Sep 17 00:00:00 2001 From: laresbernardo Date: Tue, 9 Jul 2024 17:35:15 +0200 Subject: [PATCH 3/3] fix: replace target_value_ext --- R/R/allocator.R | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/R/R/allocator.R b/R/R/allocator.R index 004540085..6a61351bf 100644 --- a/R/R/allocator.R +++ b/R/R/allocator.R @@ -358,6 +358,7 @@ robyn_allocator <- function(robyn_object = NULL, target_value_ext <- 1 } } + if (scenario == "target_depvar") { channelConstrLowSortedExt <- channelConstrLowSorted channelConstrUpSortedExt <- channelConstrUpSorted @@ -365,16 +366,16 @@ robyn_allocator <- function(robyn_object = NULL, temp <- lares::robyn_performance( InputCollect, OutputCollect, date_min, date_max, select_model) - target_value_def <- temp %>% + target_value_ext <- temp %>% filter(.data$channel %in% InputCollect$paid_media_spends) %>% pull(.data$response) %>% sum() total_kpi <- temp$response[temp$channel == "GRAND TOTAL"] - baseline <- total_kpi - target_value_def + baseline <- total_kpi - target_value_ext if (is.null(target_value)) { target_value <- total_kpi } message(sprintf( - "Extracted total baseline from target %s: (%s - %s = %s)", + "Extracted total baseline from target %s (target_value input): %s - %s = %s", InputCollect$dep_var_type, formatNum(target_value, abbr = TRUE), formatNum(baseline, abbr = TRUE), @@ -384,7 +385,6 @@ robyn_allocator <- function(robyn_object = NULL, stop("Calculated baseline is larger than target_value input. Please, increase target_value.") } target_value <- target_value - baseline - target_value_ext <- target_value_def - baseline } temp_init <- temp_init_all <- initSpendUnit