Skip to content

User Silence Detection

Task type: BinaryClassificationTask Industry: Digital / SaaS

Subscriber churn rarely happens overnight. Before a user cancels, they typically go through a period of silence — no purchases, no logins, no interactions. By detecting these silence windows early, product and growth teams can trigger re-engagement campaigns (discounts, personalized emails, in-app nudges) while the user is still reachable.

What makes this advanced? Multi-source gap analysis — the target function combines timestamps from 3 different data sources (purchases, logins, interactions), sorts them into a single timeline, and detects inactivity gaps using np.diff.


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): purchases, logins, interactions

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

import pandas as pd

# === Configuration ===
SILENCE_DAYS = 45
PURCHASE_DATA_SOURCE = "purchases"
LOGINS_DATA_SOURCE = "logins"
INTERACTIONS_DATA_SOURCE = "interactions"

def user_silence_target_fn(
    history: Events,
    future: Events,
    attributes: Attributes,
    ctx: Dict,
) -> np.ndarray | None:
    """Detect if user goes silent for 45+ consecutive days."""

    silence_seconds = SILENCE_DAYS * 86400
    split_ts = ctx[SPLIT_TIMESTAMP]

    # 1. Define target window (next month start to end of following 2 quarters)
    start_date = (pd.to_datetime(split_ts, unit="s") + pd.offsets.MonthBegin(1)).normalize()
    end_date = (start_date + pd.offsets.QuarterEnd(2)).normalize()
    window_days = (end_date - start_date).days + 1

    if has_incomplete_training_window(ctx, required_length=timedelta(window_days)):
        return None

    # 2. Exclude users with no purchase history
    if history[PURCHASE_DATA_SOURCE].count() == 0:
        return None

    # 3. Trim future events to the target window
    future = future.interval_from(start_date.timestamp(), timedelta(days=window_days))

    purchases = future[PURCHASE_DATA_SOURCE]
    logins = future[LOGINS_DATA_SOURCE]
    interactions = future[INTERACTIONS_DATA_SOURCE]

    # 4. No activity at all = silent
    if purchases.count() == 0 and logins.count() == 0 and interactions.count() == 0:
        return np.array([1], dtype=np.float32)

    # 5. Merge all activity timestamps and sort
    all_times = np.concatenate([
        purchases.timestamps,
        logins.timestamps,
        interactions.timestamps,
        np.array([start_date.timestamp(), end_date.timestamp()], dtype=np.float64),
    ])
    all_times = np.sort(all_times)

    # 6. Check for gaps >= silence threshold
    gaps = np.diff(all_times)
    if np.any(gaps >= silence_seconds):
        return np.array([1], dtype=np.float32)

    return np.array([0], dtype=np.float32)

Step-by-Step Breakdown

① Define target window using calendar offsets

Python
start_date = (pd.to_datetime(split_ts, unit="s") + pd.offsets.MonthBegin(1)).normalize()
end_date = (start_date + pd.offsets.QuarterEnd(2)).normalize()
window_days = (end_date - start_date).days + 1

The target window starts at the beginning of the next month and extends through two quarter-ends. Using pandas calendar offsets ensures consistent, business-aligned windows regardless of when the split timestamp falls.

② Exclude inactive users

Python
if history[PURCHASE_DATA_SOURCE].count() == 0:
    return None

Users who have never purchased are excluded from training — they were never active subscribers, so labeling them as "silent" would add noise.

③ Trim future events

Python
future = future.interval_from(start_date.timestamp(), timedelta(days=window_days))

purchases = future[PURCHASE_DATA_SOURCE]
logins = future[LOGINS_DATA_SOURCE]
interactions = future[INTERACTIONS_DATA_SOURCE]

All three data sources are trimmed to the same target window so that gap analysis operates on a consistent time range.

④ Handle zero-activity case

Python
if purchases.count() == 0 and logins.count() == 0 and interactions.count() == 0:
    return np.array([1], dtype=np.float32)

If the user had absolutely no activity across all three sources during the window, they are labeled as silent immediately — no need to check for gaps.

⑤ Merge and sort all timestamps

Python
all_times = np.concatenate([
    purchases.timestamps,
    logins.timestamps,
    interactions.timestamps,
    np.array([start_date.timestamp(), end_date.timestamp()], dtype=np.float64),
])
all_times = np.sort(all_times)

Timestamps from all sources are merged into one sorted array. The window boundaries are included as sentinel values so gaps at the start and end of the window are also detected.

⑥ Detect gaps via np.diff

Python
gaps = np.diff(all_times)
if np.any(gaps >= silence_seconds):
    return np.array([1], dtype=np.float32)

np.diff computes the time delta between consecutive sorted timestamps. If any gap meets or exceeds the silence threshold (45 days), the user is labeled positive.


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=user_silence_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 silence threshold carefully. 45 days is a starting point — analyze historical churn data to find the gap length that best separates churners from retained users in your specific product.
  2. Normalise timestamps across data sources. Purchases, logins, and interactions may originate from systems in different time zones. Convert everything to UTC before merging to avoid false gaps.
  3. Validate gap detection with known churners. Before training, run the target function on a sample of users who actually churned and verify the silence pattern appears before their cancellation date.
  4. Consider weighting data sources differently. A login is a weaker engagement signal than a purchase. You may want to build separate models for "purchase silence" vs "total silence" depending on your re-engagement strategy.
  5. Monitor label distribution over time. Seasonal effects (holidays, summer breaks) can inflate silence labels. Use stratified splits or add calendar features to account for this.