| """ |
| ๐ 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 |
|
|
| |
| 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) |
|
|
| |
| 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"] |
|
|
| |
| 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, |
| ): |
| |
| 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] |
|
|
| |
| 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." |
|
|
| |
| bar_width = int(prob * 100) |
| bar_color = f"hsl({int((1 - prob) * 120)}, 80%, 45%)" |
|
|
| html = f""" |
| <div style="font-family: sans-serif; max-width: 500px; margin: auto;"> |
| <div style="text-align: center; margin-bottom: 16px;"> |
| <span style="font-size: 3em; font-weight: bold; color: {bar_color};">{prob:.1%}</span> |
| <br/> |
| <span style="font-size: 1.3em; font-weight: 600;">{verdict}</span> |
| </div> |
| <div style="background: #e0e0e0; border-radius: 12px; height: 28px; overflow: hidden; margin-bottom: 12px;"> |
| <div style="background: {bar_color}; width: {bar_width}%; height: 100%; border-radius: 12px; transition: width 0.4s;"></div> |
| </div> |
| <div style="display: flex; justify-content: space-between; font-size: 0.85em; color: #666; margin-bottom: 16px;"> |
| <span>0% โ Great time</span> |
| <span>100% โ Terrible time</span> |
| </div> |
| <div style="background: #f8f9fa; border-left: 4px solid {bar_color}; padding: 12px 16px; border-radius: 4px;"> |
| <strong>Recommendation:</strong> {advice} |
| </div> |
| </div> |
| """ |
| return html |
|
|
|
|
| |
| 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"], |
| ] |
|
|
|
|
| |
| 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(): |
| |
| 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") |
|
|
| |
| 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_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, |
| ] |
|
|
| |
| 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() |
|
|