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 casenp.array([0], dtype=np.float32)— negative caseNone— 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
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
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
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
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
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
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
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:
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
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
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 |
|---|---|
| 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
- 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.
- 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.
- 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.
- 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.
- 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.