Campaign Revenue Uplift
Task type: RegressionTask
Industry: General / Campaign Marketing
Campaign Uplift Modeling answers who converts because of the treatment. This recipe answers the money version of that question: how much additional revenue does the treatment generate per customer,
A plain spend model predicts how much a customer will spend; the difference between two spend predictions is what the campaign adds. Estimating that difference directly lets you target offers where the incremental revenue exceeds the cost of the contact and the discount — and skip customers who would have spent the same amount anyway.
What makes this advanced? Transformed-outcome label — the target function encodes the causal question into the training signal itself. It reweights each customer's observed revenue by their treatment assignment (positive for treated, negative for control) so that a standard regression head, trained on the transformed values, predicts incremental revenue directly.
Prerequisites
Before writing a target function you need:
- A trained foundation model built on event data that includes the relevant data sources.
- The monad library installed in your environment.
- Data source(s):
offers(treatment events),transactions(revenue events with anamountcolumn)
Required data: randomized treatment with a known treatment share
Like the conversion-uplift recipe, this method needs a treatment event logged for every treated customer and an outcome observable in the same window. In addition, the transformed-outcome formula needs the treatment probability — the share of eligible customers who received the campaign — as a constant. This is only well-defined when the assignment was randomized (A/B); measure the actual share from your campaign data rather than assuming it.
Target Function
This recipe uses the transformed-outcome method. For each customer, the
observed revenue y and treatment indicator t are combined into
With a 50/50 split (p = 0.5) this simplifies to y* = +2y for treated and
y* = −2y for control customers. Under randomized assignment, the expected
value of y* for a given customer profile equals the revenue uplift itself —
so a regression model trained on y* predicts incremental revenue.
The target function tells monad how to label each entity for training. It receives four arguments:
| Argument | Type | Description |
|---|---|---|
history |
Events |
All events before the temporal split. |
future |
Events |
All events after the temporal split. |
attributes |
Attributes |
Static entity attributes. |
ctx |
Dict |
Context dictionary containing SPLIT_TIMESTAMP, data mode, etc. |
The function must return one of:
np.array([value], dtype=np.float32)— the transformed revenue for this entityNone— exclude this entity from training
Full Example
import numpy as np
from datetime import timedelta
from typing import Dict
from monad.ui.target_function import Events, Attributes
from monad.ui.target_function import SPLIT_TIMESTAMP
from monad.ui.target_function import has_incomplete_training_window
# === Configuration ===
TARGET_WINDOW_DAYS = 14
TREATMENT_DATA_SOURCE = "offers" # campaign / offer-sent events
OUTCOME_DATA_SOURCE = "transactions" # revenue events
REVENUE_COLUMN = "amount"
TREATMENT_PROBABILITY = 0.5 # measured share of treated customers
def revenue_uplift_target_fn(
history: Events,
future: Events,
attributes: Attributes,
ctx: Dict,
) -> np.ndarray | None:
"""Estimate incremental campaign revenue via the transformed outcome.
Expects a randomized (A/B) treatment with a known treatment share.
Emits y* = y * (t - p) / (p * (1 - p)); a regression model trained
on y* predicts the revenue uplift per customer.
"""
split_ts = ctx[SPLIT_TIMESTAMP]
if has_incomplete_training_window(ctx, timedelta(days=TARGET_WINDOW_DAYS)):
return None
# 1. Trim future to the campaign response window
future = future.interval_from(split_ts, timedelta(days=TARGET_WINDOW_DAYS))
# 2. Read treatment and revenue from the future event stream
t = 1.0 if future[TREATMENT_DATA_SOURCE].count() > 0 else 0.0
revenue = future[OUTCOME_DATA_SOURCE].sum(column=REVENUE_COLUMN)
# 3. Transformed outcome: E[y* | customer] = revenue uplift
p = TREATMENT_PROBABILITY
y_star = revenue * (t - p) / (p * (1 - p))
return np.array([y_star], dtype=np.float32)
Step-by-Step Breakdown
① Trim the future window
Both the treatment event and the revenue must fall inside a fixed response window, so the label compares like with like across treated and control customers.
② Read treatment and revenue
t = 1.0 if future[TREATMENT_DATA_SOURCE].count() > 0 else 0.0
revenue = future[OUTCOME_DATA_SOURCE].sum(column=REVENUE_COLUMN)
The treatment indicator comes from the presence of an offer event; the outcome
is the summed transaction amount in the window. Define "revenue" precisely for
your data — if amount mixes purchases and refunds, filter by sign first (see
Production Tips).
③ Emit the transformed outcome
p = TREATMENT_PROBABILITY
y_star = revenue * (t - p) / (p * (1 - p))
return np.array([y_star], dtype=np.float32)
Treated customers contribute their revenue with a positive weight, control customers with a negative weight. Averaged over many similar customers, the positive and negative contributions cancel out to exactly the difference treatment makes — which is what the regression head learns to predict.
How to Read the Score
The model's prediction is an estimate of incremental revenue per customer,
in the currency of your amount column — not a spend forecast.
- Large positive — the campaign is expected to add that much revenue for this customer.
- Near zero — the campaign likely doesn't change what this customer spends.
- Negative — the campaign is expected to reduce revenue for this customer (the revenue equivalent of "sleeping dogs").
In practice: rank customers by predicted uplift and target from the top while
predicted uplift > cost per contact (offer discount included). The sum of
predictions over the targeted group is a rough planning estimate of the
campaign's total incremental revenue.
Training
Once the target function is defined, fine-tune a downstream model:
from pathlib import Path
from monad.ui.config import TrainingParams, MetricParams, MetricMonitoringMode
from monad.config.early_stopping import EarlyStopping
from monad.ui.module import load_from_foundation_model, RegressionTask
module = load_from_foundation_model(
checkpoint_path=Path("./foundation_model"),
downstream_task=RegressionTask(num_targets=1),
target_fn=revenue_uplift_target_fn,
)
training_params = TrainingParams(
checkpoint_dir=Path("./<this_model>"),
learning_rate=1e-4,
epochs=20,
devices=[0],
metrics=[
MetricParams(alias="mae", metric_name="MeanAbsoluteError"),
MetricParams(alias="mse", metric_name="MeanSquaredError"),
MetricParams(alias="r2", metric_name="R2Score"),
],
metric_to_monitor="val_mae_0",
metric_monitoring_mode=MetricMonitoringMode.MIN,
early_stopping=EarlyStopping(min_delta=1e-4, patience=5),
)
module.fit(training_params, seed=42)
Evaluation
from pathlib import Path
from datetime import datetime, timezone
from monad.ui.module import load_from_checkpoint
from monad.ui.config import TestingParams, MetricParams, OutputType
module = load_from_checkpoint(Path("./<this_model>"))
testing_params = TestingParams(
prediction_date=datetime(2024, 5, 1, tzinfo=timezone.utc),
output_type=OutputType.DECODED,
devices=[0],
metrics=[
MetricParams(alias="mae", metric_name="MeanAbsoluteError"),
MetricParams(alias="mse", metric_name="MeanSquaredError"),
MetricParams(alias="r2", metric_name="R2Score"),
],
)
results = module.test(testing_params)
Regression metrics on the transformed outcome are noisy
The transformed outcome y* is an intentionally noisy training signal:
it is unbiased on average, but individual values swing between large
positive and large negative numbers. MAE / MSE / R² against y* are
therefore weak sanity checks, and R² in particular can look alarmingly low
on a model that ranks uplift well. The meaningful evaluation is causal:
on a holdout that preserves the treatment/control split, compare realized
revenue between treated and control customers within each predicted-uplift
decile (a cumulative incremental-revenue or Qini-style curve). This
analysis is not built into BaseModel — compute it from the prediction
output.
Prediction
from pathlib import Path
from datetime import datetime, timezone
from monad.ui.module import load_from_checkpoint
from monad.ui.config import TestingParams, OutputType
module = load_from_checkpoint(Path("./<this_model>"))
testing_params = TestingParams(
local_save_location=Path("./predictions.tsv"),
output_type=OutputType.DECODED,
prediction_date=datetime(2024, 6, 1, tzinfo=timezone.utc),
devices=[0],
)
predictions = module.predict(testing_params)
Recommended Metrics
| Metric | Why it matters |
|---|---|
| MAE / MSE | Sanity check on the transformed-outcome task (expect large values — see above). |
| Incremental revenue by decile (external) | Realized treated-vs-control revenue gap per predicted-uplift decile — the true quality measure. |
| Cumulative uplift curve / Qini (external) | Total incremental revenue captured as you extend targeting down the ranking. |
Alternative Method: Two Regression Models
Train two separate RegressionTask spend models — one on treated customers,
one on control customers (each target function returns None for the other
cohort and the plain summed revenue otherwise). At inference, score every
customer with both models; the difference of the two spend predictions is the
revenue-uplift estimate. This avoids the high-variance transformed label and
does not require a known treatment share, at the cost of two training runs and
a two-pass prediction step you combine yourself. With heavy-tailed revenue
data, this variant is often the more stable choice.
See also
If the business question is who converts because of the campaign rather than how much revenue it adds, use the Campaign Uplift Modeling recipe — same data requirements, single binary model.
Production Tips
- Measure
pfrom the campaign data, don't assume it. The transformed outcome is only unbiased whenTREATMENT_PROBABILITYmatches the real share of treated customers among the eligible population in the training window. Compute it from the send log and revisit it whenever the campaign design changes. - Tame revenue outliers. A single very large transaction produces an enormous transformed label and can dominate training. Consider capping (winsorizing) revenue at a high percentile inside the target function, and state the cap when reporting predicted uplift.
- Define revenue precisely. Decide whether refunds, fees, and transfers
belong in the outcome. If
amountcarries signed values, filter to the sign you mean (the same pattern as the credit-card spend recipe) before summing. - Prefer the two-model variant when the split is unknown or unbalanced. If the campaign wasn't randomized, or the treatment share is far from constant across the training window, the transformed outcome inherits that bias — the two-model difference approach degrades more gracefully.
- Validate with money, not metrics. Keep a random control group out of the targeted campaign and compare realized revenue per customer between targeted and control. Incremental revenue per contact is the number this model exists to move.