Skip to content

Predict a New Category Purchase on Days 1-60 After the Cutoff

Task type: BinaryClassificationTask
Industry: Retail

Use this target when the question is whether a customer expands into any category they have never purchased from before. This example compares categories bought strictly before the cutoff with purchases on calendar days 1 through 60 after it.

What makes this advanced? Set comparison across the split — build the known set from history, then test future categories for a value outside that set.


Prerequisites

Before writing the target function you need:

  • A trained foundation model with a transactions event source.
  • A fitted category column on that event source.
  • A predeclared history rule. This example requires at least one historical transaction.
  • For date-only events with a date-aligned cutoff, an explicit split rule that assigns split-timestamp transactions to future. This keeps cutoff-day transactions out of the strictly pre-cutoff history:
YAML
data_params:
  history_future_split_overrides:
    split_point_inclusion_overrides:
      transactions: future

If the category comes from a joined attribute source, resolve its event key with get_qualified_column_name as shown in the target-function guide.


Target Function

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

  • np.array([1], dtype=np.float32) when the fixed window contains a category absent from history.
  • np.array([0], dtype=np.float32) when there is no purchase or every purchase is in a known category.
  • None only when the future window cannot be observed or the pre-split history rule fails.

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.batch import SPLIT_TIMESTAMP
from monad.ui.target_function import has_incomplete_training_window


# === Configuration ===
TRANSACTION_DATA_SOURCE = "transactions"
CATEGORY_COLUMN = "category"
TARGET_WINDOW = timedelta(days=60)
COMPLETE_WINDOW = timedelta(days=61)
FIRST_TARGET_DAY_SECONDS = 86400


def new_category_purchase_target_fn(
    history: Events,
    future: Events,
    attributes: Attributes,
    ctx: Dict,
) -> np.ndarray | None:
    """Label a new-category purchase on calendar days 1 through 60 after cutoff."""

    history_transactions = history[TRANSACTION_DATA_SOURCE]
    if history_transactions.count() == 0:
        return None
    if has_incomplete_training_window(ctx, COMPLETE_WINDOW):
        return None

    known_categories = set(history_transactions[CATEGORY_COLUMN].events)
    future_window = future.interval_from(
        ctx[SPLIT_TIMESTAMP] + FIRST_TARGET_DAY_SECONDS,
        TARGET_WINDOW,
    )
    future_categories = future_window[TRANSACTION_DATA_SOURCE][CATEGORY_COLUMN].events

    bought_new_category = any(
        category not in known_categories for category in future_categories
    )
    return np.array([int(bought_new_category)], dtype=np.float32)

When category belongs to a table joined to transactions, replace the direct key once:

Python
from monad.ui.target_function import get_qualified_column_name

CATEGORY_COLUMN = get_qualified_column_name(
    column_name="category",
    data_sources_path=["products"],
)

Use the returned key for both the history and future event access above.

Why empty future activity is zero

The outcome asks whether a new-category purchase happened in the fixed window. No purchase is therefore a valid negative outcome, not a reason to remove the entity. The same rule applies when all future purchases use categories already in history.

Window boundaries

ctx[SPLIT_TIMESTAMP] is a Unix timestamp in seconds, and interval_from(start, duration) is half-open. For date-only events with a date-aligned cutoff, adding 86,400 seconds starts this example at the next calendar day. Its interval is [cutoff + 1 day, cutoff + 61 days), so it includes all of day 60 but not the cutoff day. COMPLETE_WINDOW spans 61 days because the completeness check is measured from the cutoff, one day before the target interval starts.


Training

Python
from pathlib import Path
from monad.config import TrainingParams, MetricParams, MetricMonitoringMode
from monad.config.early_stopping import EarlyStopping

from monad.ui.module import BinaryClassificationTask, load_from_foundation_model

module = load_from_foundation_model(
    checkpoint_path=Path("./foundation_model"),
    downstream_task=BinaryClassificationTask(),
    target_fn=new_category_purchase_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

The shared example below supplies prediction_date for an entity-split test. If the checkpoint uses a time split, remove that argument: test() uses the checkpoint's configured test.start_date. To move a time-split test to another fixed cutoff, reload the checkpoint with a DataMode/TimeRange split override.

Python
from pathlib import Path
from datetime import datetime, timezone
from monad.ui.module import load_from_checkpoint
from monad.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", kwargs={"task": "binary"}),
        MetricParams(alias="auprc", metric_name="AveragePrecision", kwargs={"task": "binary"}),
        MetricParams(alias="recall", metric_name="Recall", kwargs={"task": "binary"}),
    ],
)

results = module.test(testing_params)

Use AUROC and AUPRC for ranking quality. Compare the same eligible population with a simple baseline and report the positive rate, denominator, and any excluded entities.