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
transactionsevent source. - A fitted
categorycolumn 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:
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.Noneonly when the future window cannot be observed or the pre-split history rule fails.
Full Example
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:
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
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.
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.