Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0 # Use the latest stable version
hooks:
- id: trailing-whitespace

42 changes: 21 additions & 21 deletions LICENSE

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
[![PyPI Downloads](https://img.shields.io/pypi/dm/tippingpt.svg?label=PyPI%20downloads)](
https://pypi.org/project/tippingpt/)

A lightweight, high-performance marketing intelligence module that uses machine learning and calculus to determine the exact inflection points of a media response curve.
A lightweight, high-performance marketing intelligence module that uses machine learning and calculus to determine the exact inflection points of a media response curve.

Growth marketers and media buyers constantly ask two questions: *"When are we out of the inefficient learning phase?"* and *"When should we stop scaling spend?"* By fitting historical performance data to a continuous mathematical curve, this tool identifies the **Minimal Marginal Cost Point** (where efficiency peaks) and the **Point of Diminishing Returns** (where scaling is no longer profitable), defining your exact **Optimal Scaling Zone**.

## 🧠 Methodology

This project leverages the mathematical foundations of modern Marketing Mix Modeling (MMM)—specifically the techniques popularized by [Google’s Meridian](https://github.com/google/meridian).
This project leverages the mathematical foundations of modern Marketing Mix Modeling (MMM)—specifically the techniques popularized by [Google’s Meridian](https://github.com/google/meridian).

Instead of basic linear or logarithmic approximations, this module natively models media saturation using the **Hill Function**.

Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ keywords = [
"tipping point",
"marketing",
"utility",
"cli"
"cli"
]

classifiers = [
Expand All @@ -16,7 +16,7 @@ classifiers = [
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Operating System :: OS Independent",
"Development Status :: 3 - Alpha",
"Development Status :: 3 - Alpha",
]
authors = [
{ name = "Ryan Duecker", email = "ryanduecker@gmail.com" },
Expand Down
22 changes: 11 additions & 11 deletions src/tippingpoint/curve.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,22 +65,22 @@ def log_posterior(params):
np.std(y) * 0.1
])
current_log_post = log_posterior(current_params)

samples = []
# Adaptive step size (simplified)
step_size = current_params * 0.05

for i in range(n_samples + burn_in):
proposal = current_params + np.random.normal(0, step_size)
proposal_log_post = log_posterior(proposal)

if proposal_log_post > current_log_post or np.random.rand() < np.exp(proposal_log_post - current_log_post):
current_params = proposal
current_log_post = proposal_log_post

if i >= burn_in:
samples.append(current_params.copy())

# Small adaptation during burn-in
if i < burn_in and i % 100 == 0 and i > 0:
# This is a very crude adaptation
Expand All @@ -95,12 +95,12 @@ def log_posterior(params):
'K': posterior[:, 2],
'sigma': posterior[:, 3]
}

# Point estimates (posterior mean)
beta_mean = np.mean(samples_dict['beta'])
alpha_mean = np.mean(samples_dict['alpha'])
K_mean = np.mean(samples_dict['K'])

print(f"[{channel_name}] Bayesian fit complete. Samples: {len(posterior)}")
return cls(beta_mean, alpha_mean, K_mean, channel_name, posterior_samples=samples_dict)

Expand Down Expand Up @@ -218,13 +218,13 @@ def plot_response_curve(self, target_mroas=1.0, current_spend=None, show_interva
plot_limit = max(plot_limit, current_spend * 1.2 if current_spend else 0)

x_vals = np.linspace(0, plot_limit, 500)

if show_intervals and self.posterior_samples:
y_returns_dist = self.predict_incremental_return(x_vals, use_samples=True)
y_return = np.mean(y_returns_dist, axis=0)
y_return_low = np.percentile(y_returns_dist, 5, axis=0)
y_return_high = np.percentile(y_returns_dist, 95, axis=0)

y_mroas_dist = self.predict_marginal_return(x_vals, use_samples=True)
y_mroas = np.mean(y_mroas_dist, axis=0)
y_mroas_low = np.percentile(y_mroas_dist, 5, axis=0)
Expand All @@ -238,18 +238,18 @@ def plot_response_curve(self, target_mroas=1.0, current_spend=None, show_interva
ax1.plot(x_vals, y_return, color='#2CA02C', linewidth=3, label="Incremental Return")
if show_intervals and self.posterior_samples:
ax1.fill_between(x_vals, y_return_low, y_return_high, color='#2CA02C', alpha=0.2, label="90% Credible Interval")

ax1.set_xlabel('Spend ($)', fontsize=12, fontweight='bold')
ax1.set_ylabel('Incremental Return', color='#2CA02C', fontsize=12, fontweight='bold')
ax1.tick_params(axis='y', labelcolor='#2CA02C')
ax1.grid(True, linestyle='--', alpha=0.5)

# Secondary Axis: Marginal Return
ax2 = ax1.twinx()
ax2.plot(x_vals, y_mroas, color='#1F77B4', linestyle='--', linewidth=2, label="Marginal ROAS")
if show_intervals and self.posterior_samples:
ax2.fill_between(x_vals, y_mroas_low, y_mroas_high, color='#1F77B4', alpha=0.1)

ax2.set_ylabel('Marginal ROAS (mROAS)', color='#1F77B4', fontsize=12, fontweight='bold')
ax2.tick_params(axis='y', labelcolor='#1F77B4')
ax2.axhline(target_mroas, color='gray', linestyle=':', label="Target mROAS Floor")
Expand Down
8 changes: 4 additions & 4 deletions tests/test_bayesian.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def synthetic_data():
def test_fit_bayesian_basic(synthetic_data):
x, y = synthetic_data
model = MarketingReturnCurve.fit_bayesian(x, y, n_samples=500, burn_in=100, chains=2)

assert model.beta > 0
assert model.alpha > 0
assert model.K > 0
Expand All @@ -33,7 +33,7 @@ def test_fit_bayesian_with_priors(synthetic_data):
'K': (np.log(20000), 0.1)
}
model = MarketingReturnCurve.fit_bayesian(x, y, priors=priors, n_samples=200, burn_in=50, chains=1)

# Check if results are close to true values due to tight priors
assert 90000 < model.beta < 110000
assert 1.3 < model.alpha < 1.7
Expand All @@ -42,11 +42,11 @@ def test_fit_bayesian_with_priors(synthetic_data):
def test_predict_with_samples(synthetic_data):
x, y = synthetic_data
model = MarketingReturnCurve.fit_bayesian(x, y, n_samples=100, burn_in=50, chains=1)

# Incremental return
preds = model.predict_incremental_return([1000, 2000], use_samples=True)
assert preds.shape == (100, 2)

# Marginal return
m_preds = model.predict_marginal_return([1000, 2000], use_samples=True)
assert m_preds.shape == (100, 2)
Expand Down
8 changes: 4 additions & 4 deletions tests/test_curve.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def setup_method(self):
"""Setup a baseline S-Curve for use in multiple tests."""
# Parameters: Max Return=10000, Shape=2.0 (S-Curve), Half-Saturation=500
self.s_curve = MarketingReturnCurve(beta=10000.0, alpha=2.0, half_saturation_k=500.0, channel_name="Test_S_Curve")

# Parameters: Shape=0.8 (C-Curve, no warm-up phase)
self.c_curve = MarketingReturnCurve(beta=10000.0, alpha=0.8, half_saturation_k=500.0, channel_name="Test_C_Curve")

Expand Down Expand Up @@ -45,7 +45,7 @@ def test_minimal_marginal_cost_point_s_curve(self):
"""Test f''(x) = 0 for an S-Curve (alpha > 1)."""
expected_inflection = 500.0 * np.sqrt(1.0 / 3.0) # For alpha = 2.0, inflection = K * sqrt((2-1)/(2+1)) = 500 * sqrt(1/3) ≈ 288.675
actual_inflection = self.s_curve.get_minimal_marginal_cost_point()

assert actual_inflection == pytest.approx(expected_inflection, rel=1e-3)

def test_minimal_marginal_cost_point_c_curve(self):
Expand All @@ -56,7 +56,7 @@ def test_diminishing_returns_point_valid(self):
"""Test finding the target spend level for a reachable mROAS."""
target_mroas = 5.0
spend_cap = self.s_curve.get_diminishing_returns_point(target_mroas)

assert spend_cap is not None
actual_mroas_at_cap = self.s_curve.predict_marginal_return(spend_cap)
assert actual_mroas_at_cap == pytest.approx(target_mroas, rel=1e-3)
Expand All @@ -75,7 +75,7 @@ def test_tinygrad_optimization_engine(self):
returns = (50000 * spends**1.5) / (4000**1.5 + spends**1.5) # Fake responses following roughly an S curve
# Run with very few epochs just to verify the math/graph builds and executes properly
model = MarketingReturnCurve.from_historical_data( spend_array=spends, return_array=returns, epochs=10, lr=0.1)# Fast execution for test suite

assert isinstance(model, MarketingReturnCurve)
assert model.beta > 0
assert model.alpha > 0
Expand Down
Loading