Predict Evening Brand Purchases
Task type: MultilabelClassificationTask
Industry: Retail
Evening shopping behavior differs markedly from daytime patterns — customers tend to browse more, make impulse purchases, and favor certain brands. By predicting which brands a customer will repeatedly buy during evening hours, marketing teams can schedule push notifications, flash sales, and personalized emails to coincide with peak evening engagement windows.
What makes this advanced? Time-of-day filtering — extracts hours from unix timestamps using modular arithmetic, applies evening mask to isolate purchases between 18:00 and 23:00.
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):
purchaseswith abrandcolumn
Target Function
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. |
For multilabel classification, the function must return one of:
- A 1-D
float32array of sizenum_labels— binary indicators (0or1) per brand. None— 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 = 90
PURCHASE_DATA_SOURCE = "purchases"
MIN_EVENING_PURCHASES = 2
EVENING_START_HOUR = 18
EVENING_END_HOUR = 23
TARGET_BRANDS = ["BrandA", "BrandB", "BrandC"]
def evening_brand_purchases_target_fn(
history: Events,
future: Events,
attributes: Attributes,
ctx: Dict,
) -> np.ndarray | None:
"""Predict which brands the customer buys 2+ times in the evening."""
if has_incomplete_training_window(ctx, timedelta(days=TARGET_WINDOW_DAYS)):
return None
split_ts = ctx[SPLIT_TIMESTAMP]
future = future.interval_from(split_ts, timedelta(days=TARGET_WINDOW_DAYS))
purchases = future[PURCHASE_DATA_SOURCE]
if purchases.count() == 0:
return None
# 1. Extract hour from timestamps and apply evening mask
hours = ((purchases.timestamps % 86400) // 3600).astype(int)
evening_mask = (hours >= EVENING_START_HOUR) & (hours < EVENING_END_HOUR)
brands = purchases["brand"]
evening_brands = brands[evening_mask]
if len(evening_brands) == 0:
return None
# 2. Count purchases per brand
unique, counts = np.unique(evening_brands, return_counts=True)
frequent_brands = unique[counts >= MIN_EVENING_PURCHASES]
if len(frequent_brands) == 0:
return None
# 3. Build target vector
target = np.isin(np.array(TARGET_BRANDS), frequent_brands).astype(np.float32)
return target
Step-by-Step Breakdown
① Extract hour from unix timestamps
hours = ((purchases.timestamps % 86400) // 3600).astype(int)
evening_mask = (hours >= EVENING_START_HOUR) & (hours < EVENING_END_HOUR)
Unix timestamps are converted to hour-of-day using modular arithmetic: % 86400 extracts seconds within the day, // 3600 converts to hours. This avoids the overhead of full datetime parsing and works efficiently on numpy arrays. The boolean mask isolates transactions between 18:00 and 23:00.
② Filter to evening purchases and count per brand
brands = purchases["brand"]
evening_brands = brands[evening_mask]
unique, counts = np.unique(evening_brands, return_counts=True)
frequent_brands = unique[counts >= MIN_EVENING_PURCHASES]
The evening mask is applied to the brand column. np.unique with return_counts=True efficiently tallies purchases per brand. Only brands with 2 or more evening purchases qualify — a single purchase may be coincidental rather than a genuine preference.
③ Build the binary target vector
np.isin checks which target brands appear in the frequent set, producing a binary vector aligned with TARGET_BRANDS. This is the format MultilabelClassificationTask expects.
④ Handle edge cases
if purchases.count() == 0:
return None
if len(evening_brands) == 0:
return None
if len(frequent_brands) == 0:
return None
Three levels of filtering (no purchases, no evening purchases, no frequent evening brands) progressively exclude entities that cannot provide meaningful labels.
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, MultilabelClassificationTask
module = load_from_foundation_model(
checkpoint_path=Path("./foundation_model"),
downstream_task=MultilabelClassificationTask(class_names=TARGET_BRANDS),
target_fn=evening_brand_purchases_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": "multilabel", "num_labels": <num_labels>}),
MetricParams(alias="auprc", metric_name="AveragePrecision", kwargs={"task": "multilabel", "num_labels": <num_labels>}),
MetricParams(alias="f1", metric_name="F1Score", kwargs={"task": "multilabel", "num_labels": <num_labels>}),
],
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
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="f1", metric_name="F1Score"),
],
)
results = module.test(testing_params)
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 |
|---|---|
| Hamming Loss | Fraction of labels that are incorrectly predicted. |
| Subset Accuracy | Proportion of samples with all labels correct. |
| Macro AUROC | Average ranking quality across all labels. |
| Per-label F1 | Identifies which labels the model struggles with. |
Production Tips
- Adjust evening hours for your time zone. The 18:00-23:00 window assumes UTC-aligned timestamps. If your customer base spans multiple time zones, normalise timestamps to local time before filtering.
- Tune the minimum purchase threshold. Requiring 2+ purchases filters noise but may be too strict for low-frequency brands. Consider lowering to 1 for premium or niche brands.
- Expand the brand list dynamically. Rather than hardcoding
TARGET_BRANDS, consider deriving the list from the top N brands by evening transaction volume in your training data. - Combine with time-of-day features. Evening purchase propensity can vary by day of week. Consider adding a weekday-vs-weekend split for more granular targeting.