Count Buy-Online-Return-In-Store Events
Task type: RegressionTask
Industry: Retail / Omnichannel
Buy-online-return-in-store (BORIS) is one of the most costly omnichannel behaviors for retailers — it ties up logistics, creates inventory discrepancies, and increases staffing needs at physical locations. By predicting the volume of BORIS events per customer, operations teams can forecast return-desk staffing, plan reverse logistics, and identify customers who may benefit from better sizing tools or product descriptions.
What makes this advanced? Cross-channel order matching with fiscal quarter navigation — computes next quarter boundaries, joins online transactions with in-store returns by order_id.
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):
transactionswith anorder_idcolumn,returnswithorder_idandmethodcolumns
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 regression tasks, the function must return one of:
np.array([value], dtype=np.float32)— the predicted continuous value (count of BORIS events).None— exclude this entity (e.g., incomplete data).
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
from datetime import datetime, timezone
# === Configuration ===
TRANSACTIONS_DATA_SOURCE = "transactions"
RETURNS_DATA_SOURCE = "returns"
IN_STORE_METHOD = "in-store"
def get_next_quarter_bounds(split_ts: float):
"""Compute start and end timestamps for the next fiscal quarter."""
dt = datetime.fromtimestamp(split_ts, tz=timezone.utc)
quarter = (dt.month - 1) // 3 + 1
if quarter == 4:
next_q, year = 1, dt.year + 1
else:
next_q, year = quarter + 1, dt.year
start_month = {1: 1, 2: 4, 3: 7, 4: 10}[next_q]
end_month = start_month + 2
end_day = {1: 31, 2: 30, 3: 30, 4: 31}[next_q]
start = datetime(year, start_month, 1, tzinfo=timezone.utc)
end = datetime(year, end_month, end_day, tzinfo=timezone.utc)
return start.timestamp(), end.timestamp()
def buy_online_return_instore_target_fn(
history: Events,
future: Events,
attributes: Attributes,
ctx: Dict,
) -> np.ndarray | None:
"""Count buy-online-return-in-store events next quarter."""
split_ts = ctx[SPLIT_TIMESTAMP]
q_start, q_end = get_next_quarter_bounds(split_ts)
if has_incomplete_training_window(ctx, timedelta(seconds=q_end - split_ts)):
return None
q_duration = timedelta(seconds=q_end - q_start)
# Get online orders and in-store returns in the quarter
order_ids = set(
future[TRANSACTIONS_DATA_SOURCE]
.interval_from(q_start, q_duration)["order_id"].events
)
count = (
future[RETURNS_DATA_SOURCE]
.interval_from(q_start, q_duration)
.filter("method", lambda m: m == IN_STORE_METHOD)
.filter("order_id", lambda oid: oid in order_ids)
.count()
)
return np.array([count], dtype=np.float32)
Step-by-Step Breakdown
① Compute next fiscal quarter boundaries
def get_next_quarter_bounds(split_ts: float):
dt = datetime.fromtimestamp(split_ts, tz=timezone.utc)
quarter = (dt.month - 1) // 3 + 1
if quarter == 4:
next_q, year = 1, dt.year + 1
else:
next_q, year = quarter + 1, dt.year
...
Determines the current quarter from the split timestamp, then advances to the next one. Handles the Q4-to-Q1 year boundary. Returns start and end timestamps for the full quarter.
② Validate data availability
The required window extends from the split timestamp to the end of the next quarter. If the dataset does not cover this period, the sample is excluded.
③ Collect online order IDs in the quarter
order_ids = set(
future[TRANSACTIONS_DATA_SOURCE]
.interval_from(q_start, q_duration)["order_id"].events
)
Extracts all order IDs from transactions within the quarter. These represent the pool of orders that could potentially be returned in-store.
④ Count matching in-store returns
count = (
future[RETURNS_DATA_SOURCE]
.interval_from(q_start, q_duration)
.filter("method", lambda m: m == IN_STORE_METHOD)
.filter("order_id", lambda oid: oid in order_ids)
.count()
)
Returns within the quarter are filtered to in-store method only, then further filtered to match order IDs from online transactions. The resulting count represents BORIS events — products bought online and returned in physical stores.
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, RegressionTask
module = load_from_foundation_model(
checkpoint_path=Path("./foundation_model"),
downstream_task=RegressionTask(num_targets=1),
target_fn=buy_online_return_instore_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
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)
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 |
|---|---|
| MAE | Average absolute error — intuitive and robust to outliers. |
| RMSE | Penalises large errors more heavily than MAE. |
| R² | Proportion of variance explained by the model. |
| MAPE | Percentage-based error — useful for comparing across scales. |
Production Tips
- Distinguish between BORIS and regular returns. Not all in-store returns are BORIS. Ensure your
returnsdata source correctly flags the return method and links back to the original online order. - Consider the return window policy. Most retailers allow returns within 30-60 days. A quarterly window captures most returns, but late returns may be missed. Align the window with your return policy.
- Use predictions for staffing models. Aggregate customer-level BORIS predictions to store level for return-desk staffing forecasts.
- Watch for seasonal patterns. Post-holiday returns create massive BORIS spikes in January. Consider training separate models for holiday and non-holiday quarters.