"""
🔔 Notification Bad-Timing Detector — Interactive Demo
Predicts the probability that NOW is a bad time to send a push notification.
Uses a LightGBM model trained on 100K samples with 21 contextual features.
"""
import math
import pickle
import numpy as np
import pandas as pd
import gradio as gr
from huggingface_hub import hf_hub_download
# ── Load model from HF Hub ──────────────────────────────────────────────────
MODEL_REPO = "alianassmaaa/notification-bad-timing-detector"
model_path = hf_hub_download(repo_id=MODEL_REPO, filename="calibrated_model.pkl")
with open(model_path, "rb") as f:
model = pickle.load(f)
# ── Feature order (must match training) ──────────────────────────────────────
FEATURES = [
"hour_of_day", "day_of_week", "hour_sin", "hour_cos",
"is_weekend", "is_night",
"battery_level", "is_charging", "battery_change_rate",
"screen_on", "screen_on_duration_30min", "app_opens_last_hour",
"session_length_current", "time_since_last_interaction",
"notif_shown_last_30min", "notif_clicked_last_30min",
"notif_dismissed_last_30min", "notif_ignored_last_30min",
"notif_shown_last_24h", "notif_ctr_last_7d",
"recent_notification_density",
]
DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
# ── Prediction function ──────────────────────────────────────────────────────
def predict(
hour_of_day, day_of_week,
battery_level, is_charging, battery_change_rate,
screen_on, screen_on_duration_30min, app_opens_last_hour,
session_length_current, time_since_last_interaction,
notif_shown_last_30min, notif_clicked_last_30min,
notif_dismissed_last_30min, notif_ignored_last_30min,
notif_shown_last_24h, notif_ctr_last_7d,
recent_notification_density,
):
# Derived features
day_idx = DAY_NAMES.index(day_of_week)
hour_sin = math.sin(2 * math.pi * hour_of_day / 24)
hour_cos = math.cos(2 * math.pi * hour_of_day / 24)
is_weekend = 1 if day_idx >= 5 else 0
is_night = 1 if (hour_of_day >= 22 or hour_of_day < 7) else 0
features = pd.DataFrame([[
hour_of_day, day_idx, hour_sin, hour_cos,
is_weekend, is_night,
battery_level, int(is_charging), battery_change_rate,
int(screen_on), screen_on_duration_30min, app_opens_last_hour,
session_length_current, time_since_last_interaction,
notif_shown_last_30min, notif_clicked_last_30min,
notif_dismissed_last_30min, notif_ignored_last_30min,
notif_shown_last_24h, notif_ctr_last_7d,
recent_notification_density,
]], columns=FEATURES)
prob = model.predict_proba(features)[:, 1][0]
# Build result
if prob < 0.3:
verdict = "✅ Good time to send!"
advice = "The user is likely receptive. Send the notification now."
elif prob < 0.5:
verdict = "⚠️ Consider priority"
advice = "Send only if the notification is important or time-sensitive."
elif prob < 0.8:
verdict = "🚫 Bad timing — delay"
advice = "The user is likely busy or disengaged. Schedule for later."
else:
verdict = "🔴 Definitely delay!"
advice = "Very bad timing. The notification will almost certainly be ignored or dismissed."
# Gauge-like HTML output
bar_width = int(prob * 100)
bar_color = f"hsl({int((1 - prob) * 120)}, 80%, 45%)"
html = f"""
{prob:.1%}
{verdict}
0% — Great time
100% — Terrible time
Recommendation: {advice}
"""
return html
# ── Preset scenarios ─────────────────────────────────────────────────────────
PRESETS = {
"🌅 Morning commute (good time)": {
"hour_of_day": 8, "day_of_week": "Tuesday",
"battery_level": 90, "is_charging": False, "battery_change_rate": -0.5,
"screen_on": True, "screen_on_duration_30min": 600, "app_opens_last_hour": 5,
"session_length_current": 120, "time_since_last_interaction": 10,
"notif_shown_last_30min": 1, "notif_clicked_last_30min": 1,
"notif_dismissed_last_30min": 0, "notif_ignored_last_30min": 0,
"notif_shown_last_24h": 15, "notif_ctr_last_7d": 0.45,
"recent_notification_density": 0.5,
},
"😴 Late night sleeping (bad time)": {
"hour_of_day": 3, "day_of_week": "Wednesday",
"battery_level": 65, "is_charging": True, "battery_change_rate": 1.5,
"screen_on": False, "screen_on_duration_30min": 0, "app_opens_last_hour": 0,
"session_length_current": 0, "time_since_last_interaction": 7200,
"notif_shown_last_30min": 0, "notif_clicked_last_30min": 0,
"notif_dismissed_last_30min": 0, "notif_ignored_last_30min": 0,
"notif_shown_last_24h": 20, "notif_ctr_last_7d": 0.15,
"recent_notification_density": 0.0,
},
"📱 Active browsing (good time)": {
"hour_of_day": 14, "day_of_week": "Saturday",
"battery_level": 72, "is_charging": False, "battery_change_rate": -1.2,
"screen_on": True, "screen_on_duration_30min": 1200, "app_opens_last_hour": 8,
"session_length_current": 300, "time_since_last_interaction": 5,
"notif_shown_last_30min": 2, "notif_clicked_last_30min": 2,
"notif_dismissed_last_30min": 0, "notif_ignored_last_30min": 0,
"notif_shown_last_24h": 18, "notif_ctr_last_7d": 0.52,
"recent_notification_density": 1.0,
},
"🔋 Low battery, ignoring notifs (bad time)": {
"hour_of_day": 19, "day_of_week": "Friday",
"battery_level": 8, "is_charging": False, "battery_change_rate": -3.0,
"screen_on": False, "screen_on_duration_30min": 60, "app_opens_last_hour": 1,
"session_length_current": 0, "time_since_last_interaction": 1800,
"notif_shown_last_30min": 5, "notif_clicked_last_30min": 0,
"notif_dismissed_last_30min": 3, "notif_ignored_last_30min": 2,
"notif_shown_last_24h": 45, "notif_ctr_last_7d": 0.08,
"recent_notification_density": 4.0,
},
"🏢 Work meeting (bad time)": {
"hour_of_day": 10, "day_of_week": "Monday",
"battery_level": 55, "is_charging": False, "battery_change_rate": -0.8,
"screen_on": False, "screen_on_duration_30min": 30, "app_opens_last_hour": 0,
"session_length_current": 0, "time_since_last_interaction": 3600,
"notif_shown_last_30min": 3, "notif_clicked_last_30min": 0,
"notif_dismissed_last_30min": 1, "notif_ignored_last_30min": 2,
"notif_shown_last_24h": 30, "notif_ctr_last_7d": 0.12,
"recent_notification_density": 2.5,
},
}
def load_preset(preset_name):
if not preset_name or preset_name not in PRESETS:
return [gr.update()] * 17
p = PRESETS[preset_name]
return [
p["hour_of_day"], p["day_of_week"],
p["battery_level"], p["is_charging"], p["battery_change_rate"],
p["screen_on"], p["screen_on_duration_30min"], p["app_opens_last_hour"],
p["session_length_current"], p["time_since_last_interaction"],
p["notif_shown_last_30min"], p["notif_clicked_last_30min"],
p["notif_dismissed_last_30min"], p["notif_ignored_last_30min"],
p["notif_shown_last_24h"], p["notif_ctr_last_7d"],
p["recent_notification_density"],
]
# ── Gradio UI ────────────────────────────────────────────────────────────────
with gr.Blocks(
title="🔔 Notification Timing Detector",
theme=gr.themes.Soft(),
) as demo:
gr.Markdown("""
# 🔔 Notification Bad-Timing Detector
**Should you send that push notification right now?** This model predicts the probability that
the current moment is a **bad time** to interrupt the user, based on their activity patterns,
battery status, and notification interaction history.
Built with LightGBM + isotonic calibration • Trained on 100K samples •
[Model](https://huggingface.co/alianassmaaa/notification-bad-timing-detector) •
[Dataset](https://huggingface.co/datasets/alianassmaaa/notification-timing-dataset)
""")
with gr.Row():
preset = gr.Dropdown(
choices=list(PRESETS.keys()),
label="⚡ Quick presets — try a scenario",
value=None,
interactive=True,
)
with gr.Row():
# Left column: inputs
with gr.Column(scale=3):
gr.Markdown("### ⏰ Time Context")
with gr.Row():
hour_of_day = gr.Slider(0, 23, value=14, step=1, label="Hour of day (0–23)")
day_of_week = gr.Dropdown(DAY_NAMES, value="Wednesday", label="Day of week")
gr.Markdown("### 🔋 Battery Status")
with gr.Row():
battery_level = gr.Slider(0, 100, value=75, step=1, label="Battery level (%)")
is_charging = gr.Checkbox(value=False, label="Charging?")
battery_change_rate = gr.Slider(-5, 5, value=-1.0, step=0.1, label="Battery drain rate (%/hr)")
gr.Markdown("### 📱 User Activity")
with gr.Row():
screen_on = gr.Checkbox(value=True, label="Screen on?")
screen_on_duration_30min = gr.Slider(0, 1800, value=600, step=10, label="Screen-on time last 30 min (sec)")
with gr.Row():
app_opens_last_hour = gr.Slider(0, 30, value=5, step=1, label="App opens last hour")
session_length_current = gr.Slider(0, 3600, value=120, step=10, label="Current session length (sec)")
time_since_last_interaction = gr.Slider(0, 14400, value=30, step=10, label="Time since last interaction (sec)")
gr.Markdown("### 🔔 Notification History")
with gr.Row():
notif_shown_last_30min = gr.Slider(0, 20, value=2, step=1, label="Shown (30 min)")
notif_clicked_last_30min = gr.Slider(0, 20, value=1, step=1, label="Clicked (30 min)")
with gr.Row():
notif_dismissed_last_30min = gr.Slider(0, 20, value=0, step=1, label="Dismissed (30 min)")
notif_ignored_last_30min = gr.Slider(0, 20, value=1, step=1, label="Ignored (30 min)")
with gr.Row():
notif_shown_last_24h = gr.Slider(0, 100, value=20, step=1, label="Shown (24h)")
notif_ctr_last_7d = gr.Slider(0, 1, value=0.35, step=0.01, label="7-day CTR")
recent_notification_density = gr.Slider(0, 10, value=1.0, step=0.1, label="Recent notification density")
# Right column: output
with gr.Column(scale=2):
predict_btn = gr.Button("🔮 Predict Bad-Timing Probability", variant="primary", size="lg")
output = gr.HTML(label="Prediction")
gr.Markdown("""
### 📊 Decision Thresholds
| Probability | Action |
|:-----------:|--------|
| **< 30%** | ✅ Send notification |
| **30–50%** | ⚠️ Only if important |
| **50–80%** | 🚫 Delay notification |
| **> 80%** | 🔴 Definitely delay |
---
### 🧠 How it works
The model uses **21 signals** across 4 categories:
- **Time**: hour, day, weekend/night flags
- **Battery**: level, charging, drain rate
- **Activity**: screen, app opens, session length
- **Notifications**: recent shown/clicked/dismissed/ignored, 7-day CTR
Built on research from the [C-3PO paper](https://arxiv.org/abs/1803.00458)
(Cheetah Mobile, 600M monthly active users).
""")
# All input components in order
all_inputs = [
hour_of_day, day_of_week,
battery_level, is_charging, battery_change_rate,
screen_on, screen_on_duration_30min, app_opens_last_hour,
session_length_current, time_since_last_interaction,
notif_shown_last_30min, notif_clicked_last_30min,
notif_dismissed_last_30min, notif_ignored_last_30min,
notif_shown_last_24h, notif_ctr_last_7d,
recent_notification_density,
]
# Wire up events
predict_btn.click(fn=predict, inputs=all_inputs, outputs=output)
preset.change(fn=load_preset, inputs=preset, outputs=all_inputs)
if __name__ == "__main__":
demo.launch()