Skip to content

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,

revenue uplift = E[revenue | treated] − E[revenue | not treated]

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 an amount column)

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

y* = y · (t − p) / (p · (1 − p))        where p = P(treated)

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 entity
  • Noneexclude this entity from training

Full Example

Python
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

Python
future = future.interval_from(split_ts, timedelta(days=TARGET_WINDOW_DAYS))

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

Python
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

Python
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:

Python
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

Python
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

Python
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)

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

  1. Measure p from the campaign data, don't assume it. The transformed outcome is only unbiased when TREATMENT_PROBABILITY matches 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.
  2. 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.
  3. Define revenue precisely. Decide whether refunds, fees, and transfers belong in the outcome. If amount carries signed values, filter to the sign you mean (the same pattern as the credit-card spend recipe) before summing.
  4. 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.
  5. 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.