Replies: 4 comments 2 replies
-
I've made a simple example import numpy as np
import pandas as pd
from scipy.optimize import minimize
# Taken from https://towardsdatascience.com/carryover-and-shape-effects-in-media-mix-modeling-paper-review-fd699b509e2d
def geoDecay(alpha, L):
return alpha**(np.ones(L).cumsum()-1)
def delayed_adstock(alpha, theta, L):
return alpha**((np.ones(L).cumsum()-1)-theta)**2
def carryover(x, alpha, L, theta = None, func='geo'):
transformed_x = []
if func=='geo':
weights = geoDecay(alpha, L)
elif func=='delayed':
weights = delayed_adstock(alpha, theta, L)
for t in range(x.shape[0]):
upper_window = t+1
lower_window = max(0,upper_window-L)
current_window_x = x[:upper_window]
t_in_window = len(current_window_x)
if t < L:
new_x = (current_window_x*np.flip(weights[:t_in_window], axis=0)).sum()
transformed_x.append(new_x/weights[:t_in_window].sum())
elif t >= L:
current_window_x = x[upper_window-L:upper_window]
ext_weights = np.flip(weights, axis=0)
new_x = (current_window_x*ext_weights).sum()
transformed_x.append(new_x/ext_weights.sum())
return np.array(transformed_x)
def beta_hill(x, S, K, beta):
return beta - (K**S*beta)/(x**S+K**S)
# Define given information
historical_buget = pd.DataFrame({
"channel_1": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 300, 500, 0, 0, 0, 0],
"channel_2": [20, 60, 0, 30, 30, 30, 50, 50, 50, 50, 100, 100, 50, 50, 50, 50],
"channel_3": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 100, 100]
})
fitted_params = {
"channel_1": {
"beta_hill": [3, 100, 400],
"carryover": [0.95, 15, 3, 'delayed']
},
"channel_2": {
"beta_hill": [2, 40, 200],
"carryover": [0.1, 5, 1, 'delayed']
},
"channel_3": {
"beta_hill": [1, 9.5, 250],
"carryover": [0.8, 10, 3, 'delayed']
}
}
# Calculate baseline state
max_window = 16
full_baseline_carryover_state = {}
full_baseline_hill_state = {}
for channel in ["channel_1", "channel_2", "channel_3"]:
expand = np.concatenate([
historical_buget[channel].to_numpy()[-max_window:],
np.zeros(max_window)
]) * np.eye(2 * max_window)
agg = []
for i in range(len(expand)):
agg.append(carryover(expand[i, :], *fitted_params[channel]["carryover"]))
full_baseline_carryover_state[channel] = np.sum(agg, axis=0)
full_baseline_hill_state[channel] = beta_hill(full_baseline_carryover_state[channel], *fitted_params[channel]["beta_hill"])
full_baseline_carryover_state = pd.DataFrame(full_baseline_carryover_state)
full_baseline_hill_state = pd.DataFrame(full_baseline_hill_state)
baseline_carryover_state = full_baseline_carryover_state.iloc[-max_window:].reset_index(drop=True)
baseline_hill_state = full_baseline_hill_state.iloc[-max_window:].reset_index(drop=True)
# Define function to optimize
def cumulative_reach(budget, baseline_carryover_state, beta_hill_params, carryover_params):
predictive_window = np.zeros(max_window)
predictive_window[0] = budget
expand = predictive_window * np.eye(max_window)
agg = []
for i in range(len(expand)):
agg.append(carryover(expand[i, :], *carryover_params))
predictive_state = beta_hill(np.sum(agg, axis=0) + baseline_carryover_state, *beta_hill_params)
return predictive_state.sum()
def predictive_outcome(x):
budget_ch1, budget_ch2, budget_ch3 = x
return (
cumulative_reach(budget_ch1, baseline_carryover_state["channel_1"], fitted_params["channel_1"]["beta_hill"], fitted_params["channel_1"]["carryover"]) +
cumulative_reach(budget_ch2, baseline_carryover_state["channel_2"], fitted_params["channel_2"]["beta_hill"], fitted_params["channel_2"]["carryover"]) +
cumulative_reach(budget_ch3, baseline_carryover_state["channel_3"], fitted_params["channel_3"]["beta_hill"], fitted_params["channel_3"]["carryover"])
)
def minimize_inv_predictive_outcome(*args, **kwargs):
return -1 * predictive_outcome(*args, **kwargs) Use case 1Given maximum budget, maximize reach max_budget = 200
max_cac = 0.1
res = minimize(
minimize_inv_predictive_outcome, np.zeros(3),
bounds=[(0, max_budget), (0, max_budget), (0, max_budget)],
constraints=(
{'type': 'ineq', 'fun': lambda x: max_budget -1*(x[0] + x[1] + x[2])},
{'type': 'ineq', 'fun': lambda x: max_cac - x.sum()/(predictive_outcome(x) + 1e-3)}
)
)
print(res)
print(f"Total budget used: {res.x.sum():.3f}; Reach: {-1 * res.fun:.3f}; CAC: {(-1 * res.x.sum() / res.fun):.3f}; New CAC Only: {res.x.sum() / (-1 * res.fun - baseline_hill_state.sum().sum()):.3f}") Output
Use case 2Given minimum reach, minimize budget minimum_reach = 5000
max_cac = 0.1
res = minimize(
predictive_outcome, np.zeros(3),
bounds=[(0, np.inf), (0, np.inf), (0, np.inf)],
constraints=(
{ 'type': 'ineq', 'fun': lambda x: predictive_outcome(x) - minimum_reach },
{ 'type': 'ineq', 'fun': lambda x: max_cac - x.sum()/(predictive_outcome(x) + 1e-3) })
)
print(res)
print(f"Total budget used: {res.x.sum():.3f}; Reach: {res.fun:.3f}; CAC: {(res.x.sum() / res.fun):.3f}; New CAC Only: {res.x.sum() / (res.fun - baseline_hill_state.sum().sum()):.3f}") Output
If the model parameters has uncertainty, you can just sample a bunch of times and solve for those specific case. Then there should be a sample distribution of budget solutions. Those should be able to estimate the solution uncertainty, and quantify upper, and lower bound (probably) |
Beta Was this translation helpful? Give feedback.
-
@thipokKub Thank you very much for putting this together! I will take the time (the next few days) to review the details! What I can tell you now is that, in practice, there is no unique optimization path, as different companies have different constraints. I agree with your point and we want to provide alternatives. See, for example #358 In the meantime, if you feel like opening a Pull Request, please go for it :) Thank you very much for your feedback! It is much appreciated! 🤗 |
Beta Was this translation helpful? Give feedback.
-
@thipokKub thanks for all the work here! You can check #632 There we are modifying the Budget Optimizer. We are aware of the missing pieces you mentioned and now, the optimizer should:
I would love your input there, maybe something is missing and it's a low-hanging fruit to integrate! |
Beta Was this translation helpful? Give feedback.
-
Thank You, @thipokKub and @cetagostini, for your valuable input! Your insights have greatly contributed to our discussion. Here's a proposal to advance our work on iterations.
Does that sound like a plan? |
Beta Was this translation helpful? Give feedback.
-
Hello, @juanitorduz
I've seen that MMM now support out of sample prediction, lift test, and Budget Allocation
But I've been thinking about the last one, and the current method did not make total sense to me, and I think it is still missing a few key points
Consider this, I think my sketch solution would be as follows
Term definitions
k
timesteps into the future on a givenk
timesteps into the future if there is no additional ads-spending on that channel (apply only carryover effect), which denote this asi
is denoted aswhere it return the number of reach given that we inject budget of
t
, when evaluate at timestepThis will also incorporate the adstock effect by changing
Note$\text{CAC} = \frac{\text{investment}}{\text{reach}}$
Optimization
The budget allocation problem can have in
2
aspectsSo given these condition, it should now be able to optimize for both case
Also, additionally for the first case, it is possible to find an optimize budget policy using greedy algorithm by solving for$C^\text{max}_t$ in each timestep, such that the over all allocation policy does not exceed $C^\text{max}_t$ . Formally as
Assume that we can inject budget within into
n
steps into the futureGiven all of this, I'm not sure if I have the correct formulation, so does anyone has any ideas how to improve this? If not, then it should be possible to create an example notebook demonstrate the budget allocation
Beta Was this translation helpful? Give feedback.
All reactions