Skip to content

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): transactions with an order_id column, returns with order_id and method columns

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).
  • Noneexclude this entity (e.g., incomplete data).

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

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

Python
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

Python
if has_incomplete_training_window(ctx, timedelta(seconds=q_end - split_ts)):
    return None

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

Python
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

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

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

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="mae", metric_name="MeanAbsoluteError"),
        MetricParams(alias="mse", metric_name="MeanSquaredError"),
        MetricParams(alias="r2", metric_name="R2Score"),
    ],
)

results = module.test(testing_params)

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
MAE Average absolute error — intuitive and robust to outliers.
RMSE Penalises large errors more heavily than MAE.
Proportion of variance explained by the model.
MAPE Percentage-based error — useful for comparing across scales.

Production Tips

  1. Distinguish between BORIS and regular returns. Not all in-store returns are BORIS. Ensure your returns data source correctly flags the return method and links back to the original online order.
  2. 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.
  3. Use predictions for staffing models. Aggregate customer-level BORIS predictions to store level for return-desk staffing forecasts.
  4. Watch for seasonal patterns. Post-holiday returns create massive BORIS spikes in January. Consider training separate models for holiday and non-holiday quarters.