Source code for tippingpoint.portfolio

import numpy as np
from scipy.optimize import minimize

[docs] class PortfolioAllocator: """Optimizes budget allocation across multiple MarketingReturnCurve models.""" def __init__(self, models): if not models: raise ValueError("At least one model must be provided.") self.models = models self.channel_names = [m.channel_name for m in models] # Ensure channel names are unique if len(set(self.channel_names)) != len(self.channel_names): raise ValueError("All models must have unique channel_names.")
[docs] def allocate_budget(self, total_budget, channel_bounds=None): """ Finds the optimal spend distribution to maximize total return. Args: total_budget (float): Total budget to allocate. channel_bounds (dict, optional): Dictionary of (min_spend, max_spend) bounds keyed by channel_name. Returns: dict: The optimal allocation, marginal ROAS, and expected return. """ n = len(self.models) # Determine bounds for each channel bounds = [] for model in self.models: b = (0.0, total_budget) if channel_bounds and model.channel_name in channel_bounds: provided_b = channel_bounds[model.channel_name] lb = provided_b[0] ub = min(provided_b[1], total_budget) if lb > ub: # Enforce that lower bound cannot exceed upper bound (total budget constraint) ub = lb b = (lb, ub) bounds.append(b) def objective(spends): total_return = 0.0 for i, model in enumerate(self.models): total_return += model.predict_incremental_return(spends[i]) return -total_return def constraint(spends): return np.sum(spends) - total_budget cons = {'type': 'eq', 'fun': constraint} # Global optimization or multi-start can be better for S-curves. # We will use SLSQP with a simple proportional start, but if it fails to find # a good optimum, we will try a few random starts. best_res = None best_return = float('inf') # We are minimizing negative return # Start points: proportional, and budget-heavy on individual channels start_points = [] # Proportional x0_prop = np.zeros(n) for i, b in enumerate(bounds): x0_prop[i] = b[0] # satisfy min bounds rem_budget = total_budget - np.sum(x0_prop) if rem_budget > 0: x0_prop += rem_budget / n start_points.append(x0_prop) # Random starts for _ in range(5): x_rand = np.random.rand(n) x_rand = x_rand / np.sum(x_rand) * total_budget # Clamping to bounds for i in range(n): x_rand[i] = np.clip(x_rand[i], bounds[i][0], bounds[i][1]) start_points.append(x_rand) for x0 in start_points: res = minimize( objective, x0, method='SLSQP', bounds=bounds, constraints=cons, options={'disp': False, 'ftol': 1e-6, 'maxiter': 500} ) # Strict budget check, sometimes SLSQP drifts slightly if np.abs(np.sum(res.x) - total_budget) < 1e-2 and res.fun < best_return: best_return = res.fun best_res = res # If all failed or drifted, just use the last one if best_res is None: best_res = res allocation = {self.models[i].channel_name: float(best_res.x[i]) for i in range(n)} mroas = {self.models[i].channel_name: float(self.models[i].predict_marginal_return(best_res.x[i])) for i in range(n)} expected_return = -float(best_res.fun) return { "total_budget": total_budget, "expected_total_return": expected_return, "overall_roas": expected_return / total_budget if total_budget > 0 else 0.0, "allocation": allocation, "marginal_roas_at_allocation": mroas, "success": best_res.success, "message": best_res.message }