Skip to content

Commit 6c8f665

Browse files
committed
Add custom time range option
1 parent 25abb2a commit 6c8f665

File tree

5 files changed

+332
-35
lines changed

5 files changed

+332
-35
lines changed

examples/model-score/app.py

Lines changed: 98 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import sqlite3
2+
from datetime import datetime, timedelta, timezone
23

34
import pandas as pd
45
import plotly.express as px
@@ -43,13 +44,27 @@ def df():
4344
params=[150],
4445
)
4546
# Treat timestamp as a continuous variable
46-
tbl["timestamp"] = pd.to_datetime(tbl["timestamp"]).dt.strftime("%H:%M:%S")
47+
tbl["timestamp"] = pd.to_datetime(tbl["timestamp"], utc=True)
48+
tbl["time"] = tbl["timestamp"].dt.strftime("%H:%M:%S")
4749
# Reverse order of rows
4850
tbl = tbl.iloc[::-1]
4951

5052
return tbl
5153

5254

55+
def read_time_period(from_time, to_time):
56+
tbl = pd.read_sql(
57+
"select * from auc_scores where timestamp between ? and ? order by timestamp, model",
58+
con,
59+
params=[from_time, to_time],
60+
)
61+
# Treat timestamp as a continuous variable
62+
tbl["timestamp"] = pd.to_datetime(tbl["timestamp"], utc=True)
63+
tbl["time"] = tbl["timestamp"].dt.strftime("%H:%M:%S")
64+
65+
return tbl
66+
67+
5368
model_colors = {
5469
"model_1": "#7fc97f",
5570
"model_2": "#beaed4",
@@ -60,37 +75,79 @@ def df():
6075
model_names = list(model_colors.keys())
6176

6277

63-
app_ui = x.ui.page_sidebar(
64-
x.ui.sidebar(
65-
ui.input_selectize(
66-
"refresh",
67-
"Refresh interval",
68-
{
69-
0: "Realtime",
70-
5: "5 seconds",
71-
30: "30 seconds",
72-
60 * 5: "5 minutes",
73-
60 * 15: "15 minutes",
74-
},
78+
def app_ui(req):
79+
end_time = datetime.now(timezone.utc)
80+
start_time = end_time - timedelta(minutes=1)
81+
82+
return x.ui.page_sidebar(
83+
x.ui.sidebar(
84+
ui.input_checkbox_group(
85+
"models", "Models", model_names, selected=model_names
86+
),
87+
ui.input_radio_buttons(
88+
"timeframe",
89+
"Timeframe",
90+
["Latest", "Specific timeframe"],
91+
selected="Latest",
92+
),
93+
ui.panel_conditional(
94+
"input.timeframe === 'Latest'",
95+
ui.input_selectize(
96+
"refresh",
97+
"Refresh interval",
98+
{
99+
0: "Realtime",
100+
5: "5 seconds",
101+
30: "30 seconds",
102+
60 * 5: "5 minutes",
103+
60 * 15: "15 minutes",
104+
},
105+
),
106+
),
107+
ui.panel_conditional(
108+
"input.timeframe !== 'Latest'",
109+
ui.input_slider(
110+
"timerange",
111+
"Time range",
112+
min=start_time,
113+
max=end_time,
114+
value=[start_time, end_time],
115+
step=timedelta(seconds=1),
116+
time_format="%H:%M:%S",
117+
),
118+
),
75119
),
76-
ui.input_checkbox_group("models", "Models", model_names, selected=model_names),
77-
),
78-
ui.div(
79-
ui.h1("Model monitoring dashboard"),
80-
ui.p(
81-
x.ui.output_ui("value_boxes"),
120+
ui.div(
121+
ui.h1("Model monitoring dashboard"),
122+
ui.p(
123+
x.ui.output_ui("value_boxes"),
124+
),
125+
x.ui.card(output_widget("plot_timeseries")),
126+
x.ui.card(output_widget("plot_dist")),
127+
style="max-width: 800px;",
82128
),
83-
x.ui.card(output_widget("plot_timeseries")),
84-
x.ui.card(output_widget("plot_dist")),
85-
style="max-width: 800px;",
86-
),
87-
fillable=False,
88-
)
129+
fillable=False,
130+
)
89131

90132

91133
def server(input: Inputs, output: Outputs, session: Session):
134+
@reactive.Effect
135+
def update_time_range():
136+
reactive.invalidate_later(5)
137+
min_time, max_time = pd.to_datetime(
138+
con.execute(
139+
"select min(timestamp), max(timestamp) from auc_scores"
140+
).fetchone(),
141+
utc=True,
142+
)
143+
ui.update_slider(
144+
"timerange",
145+
min=min_time.replace(tzinfo=timezone.utc),
146+
max=max_time.replace(tzinfo=timezone.utc),
147+
)
148+
92149
@reactive.Calc
93-
def throttled_df():
150+
def recent_df():
94151
refresh = int(input.refresh())
95152
if refresh == 0:
96153
return df()
@@ -99,12 +156,22 @@ def throttled_df():
99156
with reactive.isolate():
100157
return df()
101158

159+
@reactive.Calc
160+
def timeframe_df():
161+
start, end = input.timerange()
162+
return read_time_period(start, end)
163+
102164
@reactive.Calc
103165
def filtered_df():
104-
data = throttled_df()
166+
data = recent_df() if input.timeframe() == "Latest" else timeframe_df()
167+
105168
# Filter the rows so we only include the desired models
106169
return data[data["model"].isin(input.models())]
107170

171+
@reactive.Calc
172+
def filtered_model_names():
173+
return filtered_df()["model"].unique()
174+
108175
@output
109176
@render.ui
110177
def value_boxes():
@@ -134,11 +201,11 @@ def value_boxes():
134201
)
135202

136203
@output
137-
@render_plotly_streaming(recreate_when=input.models)
204+
@render_plotly_streaming(recreate_key=filtered_model_names)
138205
def plot_timeseries():
139206
fig = px.line(
140207
filtered_df(),
141-
x="timestamp",
208+
x="time",
142209
y="score",
143210
labels=dict(score="auc"),
144211
color="model",
@@ -162,7 +229,7 @@ def plot_timeseries():
162229
return fig
163230

164231
@output
165-
@render_plotly_streaming(recreate_when=input.models)
232+
@render_plotly_streaming(recreate_key=filtered_model_names)
166233
def plot_dist():
167234
fig = px.histogram(
168235
filtered_df(),
@@ -188,6 +255,7 @@ def plot_dist():
188255
# From https://plotly.com/python/facet-plots/#customizing-subplot-figure-titles
189256
fig.for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1]))
190257

258+
fig.update_yaxes(matches=None)
191259
fig.update_xaxes(range=[0, 1], fixedrange=True)
192260
fig.layout.height = 500
193261

examples/model-score/plotly_streaming.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,60 @@
11
import functools
2+
import json
23

34
import plotly.graph_objects as go
45
from shinywidgets import render_widget
56

67
from shiny import reactive
78

89

9-
def render_plotly_streaming(fn=None, *, recreate_when=lambda: None):
10+
# Return a hash of an arbitrary object, including nested dicts, lists, and numpy/pandas
11+
# data structures. Uses json.dumps() internally.
12+
def _hash_anything(obj):
13+
return hash(json.dumps(obj, sort_keys=True, default=_to_json_repr))
14+
15+
16+
def _to_json_repr(obj):
17+
# If has to_json(), use that; make sure it's callable
18+
if hasattr(obj, "to_json") and callable(obj.to_json):
19+
return json.loads(obj.to_json())
20+
if hasattr(obj, "to_list") and callable(obj.to_list):
21+
return obj.to_list()
22+
if hasattr(obj, "tolist") and callable(obj.tolist):
23+
return obj.tolist()
24+
if hasattr(obj, "to_dict") and callable(obj.to_dict):
25+
return obj.to_dict()
26+
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
27+
28+
29+
def render_plotly_streaming(fn=None, *, recreate_key=lambda: None):
1030
"""Custom decorator for Plotly streaming plots. This is similar to
1131
shinywidgets.render_widget, except:
1232
1333
1. You return simply a Figure, not FigureWidget.
1434
2. On reactive invalidation, the figure is updated in-place, rather than recreated
1535
from scratch.
36+
37+
Parameters
38+
----------
39+
recreate_key : callable, optional
40+
A function that returns a hashable object. If the value returned by this
41+
function changes, the plot will be recreated from scratch. This is useful for
42+
changes that render_plotly_streaming can't handle well, such as changing the
43+
number of traces in a plot.
1644
"""
1745

1846
if fn is not None:
19-
return render_plotly_streaming(recreate_when=recreate_when)(fn)
47+
return render_plotly_streaming(recreate_key=recreate_key)(fn)
2048

2149
def decorator(func):
50+
@deduplicate
51+
def recreate_trigger():
52+
return _hash_anything(recreate_key())
53+
2254
@render_widget
2355
@functools.wraps(func)
2456
def wrapper():
25-
recreate_when()
57+
recreate_trigger()
2658

2759
with reactive.isolate():
2860
fig = func()
@@ -43,3 +75,22 @@ def update_plotly_data():
4375
return wrapper
4476

4577
return decorator
78+
79+
80+
def deduplicate(func):
81+
with reactive.isolate():
82+
rv = reactive.Value(func())
83+
84+
@reactive.Effect
85+
def update():
86+
x = func()
87+
with reactive.isolate():
88+
if x != rv():
89+
rv.set(x)
90+
91+
@reactive.Calc
92+
@functools.wraps(func)
93+
def wrapper():
94+
return rv()
95+
96+
return wrapper

examples/model-score/requirements.in

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
pandas
2+
plotly
3+
shiny
4+
shinywidgets

0 commit comments

Comments
 (0)