Source code for dxpoint.fitting.gradient
from contextlib import contextmanager
import numpy as np
from tinygrad.tensor import Tensor
from tinygrad.nn.optim import Adam
from tinygrad import dtypes
@contextmanager
def _training_context():
try:
from tinygrad.helpers import Context
with Context(TRAINING=1):
yield
except Exception:
if hasattr(Tensor, "train"):
with Tensor.train():
yield
else:
yield
[docs]
def tinygrad_geometric_adstock(spend, theta):
"""Applies geometric adstock decay in Tinygrad (vectorized Toeplitz weights)."""
N = spend.shape[0]
grid = Tensor.arange(N)
diff = grid.unsqueeze(1) - grid.unsqueeze(0)
mask = (diff >= 0).cast(dtypes.float32)
diff_safe = diff * mask
weights = (theta ** diff_safe) * mask
return weights.matmul(spend)
[docs]
def fit_mle_gradient(spend_array, return_array, epochs=5000, lr=0.05, adstock_type="none", adstock_bounds=None, adstock_fixed_days=None, fit_baseline=False):
"""Fits a Hill Curve to historical data using MLE (Adam optimizer), with optional adstock and baseline."""
spend_arr = np.array(spend_array, dtype=float)
return_arr = np.array(return_array, dtype=float)
max_x = float(np.max(spend_arr)) if np.any(spend_arr > 0) else 1.0
if max_x <= 0:
max_x = 1.0
max_y = float(np.max(return_arr)) if np.any(return_arr > 0) else 1.0
if max_y <= 0:
max_y = 1.0
spend_scaled = spend_arr / max_x
return_scaled = return_arr / max_y
median_x_scaled = float(np.median(spend_scaled[spend_scaled > 0])) if np.any(spend_scaled > 0) else 0.5
x = Tensor(spend_scaled, dtype=dtypes.float32)
x.requires_grad = False
y = Tensor(return_scaled, dtype=dtypes.float32)
y.requires_grad = False
log_beta = Tensor([np.log(1.2)], dtype=dtypes.float32)
log_beta.requires_grad = True
log_k = Tensor([np.log(median_x_scaled + 1e-5)], dtype=dtypes.float32)
log_k.requires_grad = True
log_alpha = Tensor([0.0], dtype=dtypes.float32)
log_alpha.requires_grad = True
optimizable_params = [log_beta, log_k, log_alpha]
log_baseline = None
if fit_baseline:
log_baseline = Tensor([np.log(0.1)], dtype=dtypes.float32)
log_baseline.requires_grad = True
optimizable_params.append(log_baseline)
theta_tensor = None
theta_min, theta_max = 0.0, 0.999
if adstock_type == "none":
pass
elif adstock_type == "fixed":
if adstock_fixed_days is not None and adstock_fixed_days > 0:
theta_val = 0.5 ** (1.0 / adstock_fixed_days)
else:
theta_val = 0.0
theta_tensor = Tensor([theta_val], dtype=dtypes.float32)
elif adstock_type == "free":
adstock_w = Tensor([0.0], dtype=dtypes.float32)
adstock_w.requires_grad = True
optimizable_params.append(adstock_w)
elif adstock_type == "bounded":
if adstock_bounds is not None:
min_days, max_days = adstock_bounds
theta_min = 0.5 ** (1.0 / min_days) if min_days > 0 else 0.0
theta_max = 0.5 ** (1.0 / max_days) if max_days > 0 else 0.0
if theta_min > theta_max:
theta_min, theta_max = theta_max, theta_min
adstock_w = Tensor([0.0], dtype=dtypes.float32)
adstock_w.requires_grad = True
optimizable_params.append(adstock_w)
optimizer = Adam(optimizable_params, lr=lr)
prev_loss = float('inf')
with _training_context():
for epoch in range(epochs):
optimizer.zero_grad()
beta = log_beta.exp()
k = log_k.exp()
alpha = log_alpha.exp()
base = log_baseline.exp() if fit_baseline else Tensor([0.0], dtype=dtypes.float32)
# Apply adstock transformation
if adstock_type == "none":
x_adstocked = x
elif adstock_type == "fixed":
x_adstocked = tinygrad_geometric_adstock(x, theta_tensor)
elif adstock_type == "free":
theta = adstock_w.sigmoid() * 0.999
x_adstocked = tinygrad_geometric_adstock(x, theta)
elif adstock_type == "bounded":
theta = theta_min + (theta_max - theta_min) * adstock_w.sigmoid()
x_adstocked = tinygrad_geometric_adstock(x, theta)
ratio = (x_adstocked + 1e-5) / k
ratio_alpha = ratio ** alpha
y_pred = base + (beta * ratio_alpha) / (1.0 + ratio_alpha)
loss = ((y_pred - y) ** 2).mean()
loss.backward()
optimizer.step()
if epochs >= 500 and epoch % 100 == 0:
curr_loss = loss.numpy().item()
if abs(prev_loss - curr_loss) < 1e-8:
break
prev_loss = curr_loss
beta_val = float(log_beta.exp().numpy().item() * max_y)
alpha_val = float(log_alpha.exp().numpy().item())
k_val = float(log_k.exp().numpy().item() * max_x)
final_loss = float(loss.numpy().item() * (max_y ** 2))
baseline_val = float(log_baseline.exp().numpy().item() * max_y) if fit_baseline else 0.0
if adstock_type == "none":
theta_val = 0.0
elif adstock_type == "fixed":
theta_val = float(theta_tensor.numpy().item())
elif adstock_type == "free":
theta_val = float((adstock_w.sigmoid() * 0.999).numpy().item())
elif adstock_type == "bounded":
theta_val = float((theta_min + (theta_max - theta_min) * adstock_w.sigmoid()).numpy().item())
if fit_baseline:
return beta_val, alpha_val, k_val, theta_val, final_loss, baseline_val
return beta_val, alpha_val, k_val, theta_val, final_loss