Skip to content

Detect App Channel Shift

Task type: BinaryClassificationTask Industry: Banking / Financial Services

Banks investing in digital channels need to understand which clients are naturally migrating toward app-based banking. By detecting clients who are about to cross the 80% app-usage threshold, marketing teams can accelerate the transition with targeted in-app feature promotions, while operations teams can plan branch capacity accordingly.

What makes this advanced? Proportional analysis with backward intervals — uses negative timedelta to look backward into history, computes channel ratios across time periods, and compares proportional thresholds between past and future windows.


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

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.

The function must return one of:

  • np.array([1], dtype=np.float32)positive case
  • np.array([0], dtype=np.float32)negative case
  • Noneexclude this entity from training

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


# === Configuration ===
CHANNEL_THRESHOLD = 0.8
QUARTER_DAYS = 90
TRANSACTION_DATA_SOURCE = "transactions"
APP_CHANNEL = "APP"

def app_channel_shift_target_fn(
    history: Events,
    future: Events,
    attributes: Attributes,
    ctx: Dict,
) -> np.ndarray | None:
    """Predict if client shifts to 80%+ app transactions next quarter."""

    if has_incomplete_training_window(ctx, timedelta(days=QUARTER_DAYS)):
        return None

    # 1. Compute app ratio in the previous quarter (backward interval)
    last_quarter = history.interval_from(
        start=ctx[SPLIT_TIMESTAMP], interval_length=-timedelta(days=QUARTER_DAYS)
    )
    total_last = last_quarter[TRANSACTION_DATA_SOURCE].count()
    if total_last == 0:
        return None

    app_last = last_quarter[TRANSACTION_DATA_SOURCE].filter(
        "channel", lambda x: x == APP_CHANNEL
    ).count()

    # Already above threshold — not interesting
    if app_last / total_last >= CHANNEL_THRESHOLD:
        return None

    # 2. Compute app ratio in the next quarter (forward interval)
    next_quarter = future.interval_from(
        start=ctx[SPLIT_TIMESTAMP], interval_length=timedelta(days=QUARTER_DAYS)
    )
    total_next = next_quarter[TRANSACTION_DATA_SOURCE].count()
    if total_next == 0:
        return np.array([0], dtype=np.float32)

    app_next = next_quarter[TRANSACTION_DATA_SOURCE].filter(
        "channel", lambda x: x == APP_CHANNEL
    ).count()

    # 3. Did the client shift?
    shifted = 1 if app_next / total_next >= CHANNEL_THRESHOLD else 0
    return np.array([shifted], dtype=np.float32)

Step-by-Step Breakdown

① Compute previous quarter app ratio (backward interval)

Python
last_quarter = history.interval_from(
    start=ctx[SPLIT_TIMESTAMP], interval_length=-timedelta(days=QUARTER_DAYS)
)
total_last = last_quarter[TRANSACTION_DATA_SOURCE].count()
if total_last == 0:
    return None

app_last = last_quarter[TRANSACTION_DATA_SOURCE].filter(
    "channel", lambda x: x == APP_CHANNEL
).count()

A negative timedelta is used to look backward 90 days from the split timestamp. This retrieves all transactions from the previous quarter. Clients with no transactions in that period are excluded since there is no baseline to compare against.

② Exclude already-high users

Python
if app_last / total_last >= CHANNEL_THRESHOLD:
    return None

If the client already uses the app for 80%+ of their transactions, they have already shifted — predicting a "shift" for these users would be meaningless and would add noise to the training set.

③ Compute next quarter ratio

Python
next_quarter = future.interval_from(
    start=ctx[SPLIT_TIMESTAMP], interval_length=timedelta(days=QUARTER_DAYS)
)
total_next = next_quarter[TRANSACTION_DATA_SOURCE].count()
if total_next == 0:
    return np.array([0], dtype=np.float32)

app_next = next_quarter[TRANSACTION_DATA_SOURCE].filter(
    "channel", lambda x: x == APP_CHANNEL
).count()

The forward interval captures the next 90 days of transactions. If no transactions occur, the client is labeled negative — no activity means no channel shift.

④ Label shift

Python
shifted = 1 if app_next / total_next >= CHANNEL_THRESHOLD else 0
return np.array([shifted], dtype=np.float32)

The final label is determined by whether the app channel ratio in the next quarter crosses the 80% threshold.


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

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

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="auroc", metric_name="AUROC"),
        MetricParams(alias="auprc", metric_name="AveragePrecision"),
        MetricParams(alias="recall", metric_name="Recall"),
    ],
)

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
AUROC Measures overall ranking quality.
AUPRC More informative when the positive class is rare.
Recall Proportion of actual positives caught.
Precision Proportion of predicted positives that are correct.
F1 Score Harmonic mean of precision and recall.

Production Tips

  1. Tune the 80% threshold to your business context. Different banks may define "app-first" differently. Analyze the distribution of app usage ratios across your client base to find a meaningful cutoff.
  2. Consider seasonal effects on channel usage. Holiday periods or branch closures may temporarily inflate app usage. Use multiple quarterly windows or add calendar features to reduce seasonal noise.
  3. Segment by customer type. Retail customers, small businesses, and corporate clients have very different channel adoption patterns. Consider training separate models or adding customer segment as a feature.
  4. Monitor for app outages. Periods of app downtime will artificially deflate app ratios. Filter out known outage windows or add them as contextual attributes.
  5. Validate against actual digital adoption outcomes. Cross-reference predictions with customers who later became fully digital to confirm the model captures genuine behavioral shifts.