Skip to content

Campaign Uplift Modeling

Task type: BinaryClassificationTask Industry: General / Campaign Marketing

A propensity model answers who will convert. An uplift model answers a different question: who will convert because of the treatment — the offer, retention call, or campaign email. Uplift (also called incremental or treatment-effect modeling) estimates, per customer,

uplift = P(outcome | treated) − P(outcome | not treated)

so the contact budget goes to the persuadables — customers the treatment actually moves — instead of customers who would have converted anyway, or customers the treatment pushes away. The recipe works in any vertical where customers receive a campaign treatment and can purchase: retail and e-commerce promotions, telecom or subscription retention, banking cross-sell, collections outreach — any action with a per-contact cost.

What makes this advanced? Label transformation — the target function reads two signals from the future event stream (a treatment event and an outcome event) and combines them into a single transformed label. A standard binary model trained on that label ranks customers by uplift, with no custom infrastructure.


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 (outcome events)

Required data: a treatment signal and an outcome

The event stream must contain both:

  • a treatment event recording that a customer received the campaign (e.g. offer_sent), logged reliably for every treated customer, and
  • an outcome event (e.g. a purchase or conversion) observable in the same window.

The treatment should ideally come from a randomized (A/B) assignment. If treatment was targeted rather than randomized, the labels below are biased and the model learns the old targeting policy as much as the treatment effect — see Production Tips.


Target Function

This recipe uses the class-variable transformation method: instead of modeling treatment and control separately, the target function emits a single transformed label

z = 1  if (treated AND converted) OR (not treated AND NOT converted)
z = 0  otherwise

A binary classifier trained on z scores customers such that a higher score means higher uplift — see How to Read the Score.

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([1], dtype=np.float32)positive case
  • np.array([0], dtype=np.float32)negative case
  • 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"    # conversion events

def uplift_target_fn(
    history: Events,
    future: Events,
    attributes: Attributes,
    ctx: Dict,
) -> np.ndarray | None:
    """Rank customers by incremental (uplift) response to a campaign.

    Expects a randomized (A/B) treatment: an offer event for treated
    customers and a conversion event as the outcome. Emits the
    class-transformed label z = 1 if (treated & converted) or
    (untreated & not converted), else 0.
    """

    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 outcome from the future event stream
    treated = future[TREATMENT_DATA_SOURCE].count() > 0
    converted = future[OUTCOME_DATA_SOURCE].count() > 0

    # 3. Class-variable transformation
    z = 1 if treated == converted else 0
    return np.array([z], dtype=np.float32)

Step-by-Step Breakdown

① Trim the future window

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

The future events are restricted to a fixed response window from the split timestamp. Both the treatment event and the outcome must fall inside this window, so choose it to cover your campaign's send date plus the expected response time.

② Read treatment and outcome

Python
treated = future[TREATMENT_DATA_SOURCE].count() > 0
converted = future[OUTCOME_DATA_SOURCE].count() > 0

A customer counts as treated if any offer event appears in the window, and as converted if any outcome event does. Customers with no offer event form the control group — which is why complete treatment logging is a hard requirement.

③ Emit the transformed label

Python
z = 1 if treated == converted else 0
return np.array([z], dtype=np.float32)

treated == converted is exactly "(treated & converted) or (untreated & not converted)". Treated converters and untreated non-converters — the combinations consistent with a positive treatment effect — become the positive class; the other two combinations become the negative class.


How to Read the Score

The model's score is P(z = 1) — not a conversion probability. With a 50/50 randomized treatment split, it maps to uplift as:

uplift ≈ 2 · P(z = 1) − 1
  • Score near 1 — persuadables: the treatment likely causes conversion.
  • Score near 0.5 — the treatment likely makes no difference (sure things and lost causes).
  • Score near 0 — "sleeping dogs": the treatment likely prevents conversion (e.g. a retention email that reminds a customer to cancel).

In practice: rank customers by score and target from the top until the per-contact cost outweighs the expected incremental gain. Do not read the score as "probability of buying".


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, BinaryClassificationTask

module = load_from_foundation_model(
    checkpoint_path=Path("./foundation_model"),
    downstream_task=BinaryClassificationTask(),
    target_fn=uplift_target_fn,
)

training_params = TrainingParams(
    checkpoint_dir=Path("./<this_model>"),
    learning_rate=1e-4,
    epochs=20,
    devices=[0],
    metrics=[
        MetricParams(alias="auroc", metric_name="AUROC", kwargs={"task": "binary"}),
        MetricParams(alias="auprc", metric_name="AveragePrecision", kwargs={"task": "binary"}),
        MetricParams(alias="recall", metric_name="Recall", kwargs={"task": "binary"}),
        MetricParams(alias="precision", metric_name="Precision", kwargs={"task": "binary"}),
    ],
    metric_to_monitor="val_auroc_0",
    metric_monitoring_mode=MetricMonitoringMode.MAX,
    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="auroc", metric_name="AUROC"),
        MetricParams(alias="auprc", metric_name="AveragePrecision"),
        MetricParams(alias="recall", metric_name="Recall"),
    ],
)

results = module.test(testing_params)

Standard classification metrics do not measure uplift

AUROC, AUPRC, and similar metrics computed during evaluation describe how well the model predicts the transformed label z — they are a sanity check, not a measure of incremental impact. Uplift models are properly evaluated with Qini curves, AUUC (area under the uplift curve), or uplift@k, computed on a holdout that preserves the treatment/control split. These metrics are not built into BaseModel — compute them from the prediction output with an external library or your own analysis.


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
AUROC Sanity check on the transformed-label task (not an uplift measure).
AUPRC More informative when one transformed class is rare.
Qini / AUUC (external) The actual uplift ranking quality — compute from predictions on a treatment/control holdout.
Uplift@k (external) Incremental conversions among the top-k scored customers — matches how the scores are used.

Alternative Methods

The class-variable transformation above is the recommended recipe because it needs exactly one target function and one model. Two alternatives are worth knowing:

  1. Two-model approach (T-learner). Train two separate downstream binary models — one on the treated cohort, one on the control cohort, splitting on the treatment flag inside each target function (return None for entities in the other cohort). At inference, score every customer with both models and take the difference of the two conversion probabilities as the uplift estimate. This models each cohort's conversion behavior directly and does not require a balanced treatment split, at the cost of two training runs and a two-pass prediction step you combine yourself.
  2. Single model with a treatment feature (S-learner). One conversion model that takes the treatment as an input feature, scored twice at inference (treatment on / treatment off); uplift is the difference. This requires the treatment flag to be available as a model input feature and is not covered by this recipe.

Estimating revenue instead of conversions

If the business question is how much money the campaign adds rather than who converts because of it, see the Campaign Revenue Uplift recipe — same data requirements, with a regression model trained on a transformed revenue label.


Production Tips

  1. Verify the randomization before trusting the labels. The class-variable transformation assumes treatment assignment is independent of customer features, with a roughly 50/50 treated/control split. Check the actual split in your training window; if it is unbalanced, the 2·P(z=1)−1 reading no longer holds — rebalance (e.g. subsample the larger group) or use the two-model approach.
  2. If the historical campaign was targeted, not randomized, say so out loud. Labels built from a targeted campaign confound "who was selected" with "who was persuaded". Propensity-score weighting can partially correct this, but the clean fix is to run a randomized holdout campaign and train on that data.
  3. Align the window with the campaign mechanics. The treatment and the outcome must both fall inside the target window. If offers go out on day 0 and conversions take up to 10 days, a 14-day window fits; a window much longer than the campaign cycle mixes multiple campaigns into one label.
  4. Check treatment-event completeness. Any treated customer whose offer event is missing from the stream is silently counted as control, diluting the signal in both groups. Reconcile event counts against the campaign tool's send log.
  5. Measure realized uplift, not model accuracy. Keep a random control group out of the targeted campaign and compare conversion rates between targeted and control — the incremental conversions per contact are the number the model exists to improve.