""" 🔔 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()