Skip to content

Commit 07c459a

Browse files
Merge pull request #849 from DashAISoftware/feat/time-series-explorer
Add time series plot explorer for numeric and date columns
2 parents 4daa44f + 0ba0569 commit 07c459a

3 files changed

Lines changed: 468 additions & 0 deletions

File tree

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
from typing import TYPE_CHECKING, Any, Dict, List
2+
3+
from DashAI.back.core.artifacts import Artifact, PlotlyArtifact
4+
from DashAI.back.core.schema_fields import bool_field, schema_field
5+
from DashAI.back.core.utils import MultilingualString
6+
from DashAI.back.dependencies.database.models import Explorer, Notebook
7+
from DashAI.back.exploration.base_explorer import BaseExplorerSchema
8+
from DashAI.back.exploration.relationship_explorer import RelationshipExplorer
9+
from DashAI.back.types.value_types import Date, Float, Integer
10+
11+
if TYPE_CHECKING:
12+
from pathlib import Path
13+
14+
from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset
15+
16+
# Semantic types that can be plotted as a series against time.
17+
_VALUE_TYPES = ("Float", "Integer")
18+
19+
20+
class TimeSeriesPlotSchema(BaseExplorerSchema):
21+
"""Schema for TimeSeriesPlotExplorer hyperparameters."""
22+
23+
markers: schema_field(
24+
bool_field(),
25+
False,
26+
description=MultilingualString(
27+
en=(
28+
"Draw a point at each observation as well as the line. Useful "
29+
"for short or irregular series, where the line alone hides "
30+
"how many readings there actually are."
31+
),
32+
es=(
33+
"Dibuja un punto en cada observacion ademas de la linea. Util "
34+
"para series cortas o irregulares, donde la linea por si sola "
35+
"oculta cuantas mediciones hay en realidad."
36+
),
37+
pt=(
38+
"Desenha um ponto em cada observacao alem da linha. Util para "
39+
"series curtas ou irregulares, nas quais a linha sozinha "
40+
"esconde quantas leituras existem de fato."
41+
),
42+
de=(
43+
"Zeichnet zusaetzlich zur Linie einen Punkt pro Beobachtung. "
44+
"Nuetzlich bei kurzen oder unregelmaessigen Reihen, wo die "
45+
"Linie allein verbirgt, wie viele Messwerte es wirklich gibt."
46+
),
47+
zh=(
48+
"除折线外,在每个观测点绘制一个标记。"
49+
"对于较短或不规则的序列很有用,"
50+
"因为仅有折线会掩盖实际的观测数量。"
51+
),
52+
),
53+
alias=MultilingualString(
54+
en="Show markers",
55+
es="Mostrar marcadores",
56+
pt="Mostrar marcadores",
57+
de="Markierungen anzeigen",
58+
zh="显示标记",
59+
),
60+
) # type: ignore
61+
62+
63+
class TimeSeriesPlotExplorer(RelationshipExplorer):
64+
"""Plot one or more numeric columns against a date column, over time.
65+
66+
Select the date column plus the series to look at. The dates are read with
67+
the format the column already carries, sorted, and handed to the plot as
68+
real datetimes, so the horizontal axis is a genuine time axis: gaps show
69+
up as gaps and irregular spacing is visible rather than flattened into
70+
evenly spaced categories.
71+
72+
This is the plot to look at before forecasting anything, since trend,
73+
seasonality, level shifts, missing stretches and outliers are all obvious
74+
here and nearly invisible in a summary table.
75+
76+
Several numeric columns can be selected at once and are drawn as separate
77+
lines sharing the time axis.
78+
"""
79+
80+
DISPLAY_NAME = MultilingualString(
81+
en="Time Series Plot",
82+
es="Grafico de Serie Temporal",
83+
pt="Grafico de Serie Temporal",
84+
de="Zeitreihendiagramm",
85+
zh="时间序列图",
86+
)
87+
DESCRIPTION = MultilingualString(
88+
en=(
89+
"Draws one or more numeric columns against a date column as lines "
90+
"over time. The dates are sorted and plotted on a real time axis, "
91+
"so gaps and uneven spacing are visible instead of being "
92+
"flattened into evenly spaced points. Select the date column and "
93+
"the columns to plot."
94+
),
95+
es=(
96+
"Dibuja una o mas columnas numericas frente a una columna de fecha "
97+
"como lineas en el tiempo. Las fechas se ordenan y se grafican en "
98+
"un eje temporal real, de modo que los huecos y el espaciado "
99+
"irregular quedan visibles en lugar de aplanarse en puntos "
100+
"equidistantes. Selecciona la columna de fecha y las columnas a "
101+
"graficar."
102+
),
103+
pt=(
104+
"Desenha uma ou mais colunas numericas em relacao a uma coluna de "
105+
"data como linhas ao longo do tempo. As datas sao ordenadas e "
106+
"plotadas em um eixo temporal real, de modo que lacunas e "
107+
"espacamento irregular ficam visiveis em vez de serem achatados em "
108+
"pontos equidistantes. Selecione a coluna de data e as colunas a "
109+
"plotar."
110+
),
111+
de=(
112+
"Zeichnet eine oder mehrere numerische Spalten gegen eine "
113+
"Datumsspalte als Linien ueber die Zeit. Die Daten werden sortiert "
114+
"und auf einer echten Zeitachse dargestellt, sodass Luecken und "
115+
"ungleichmaessige Abstaende sichtbar bleiben, statt zu "
116+
"gleichmaessigen Punkten zusammengedrueckt zu werden. Waehlen Sie "
117+
"die Datumsspalte und die zu zeichnenden Spalten aus."
118+
),
119+
zh=(
120+
"将一列或多列数值列相对于日期列绘制为随时间变化的折线。"
121+
"日期会被排序并绘制在真实的时间轴上,"
122+
"因此间隔和不规则的时间间距清晰可见,而不会被压缩为等距的点。"
123+
"请选择日期列以及要绘制的列。"
124+
),
125+
)
126+
127+
SCHEMA = TimeSeriesPlotSchema
128+
metadata: Dict[str, Any] = {
129+
"allowed_types": [Date, Float, Integer],
130+
"allowed_dtypes": [],
131+
"input_cardinality": {"min": 2},
132+
}
133+
134+
def __init__(self, **kwargs) -> None:
135+
"""Initialize the explorer.
136+
137+
Parameters
138+
----------
139+
**kwargs
140+
Configuration keyword arguments. Recognized keys:
141+
markers (bool, optional): Draw a point per observation in addition
142+
to the line. Defaults to False.
143+
"""
144+
self.markers = bool(kwargs.get("markers", False))
145+
super().__init__(**kwargs)
146+
147+
@classmethod
148+
def validate_columns(
149+
cls, explorer_info: Explorer, column_spec: Dict[str, Dict[str, str]]
150+
) -> bool:
151+
"""Check the selection is one date column plus at least one series.
152+
153+
The inherited check only asks that every column is of an allowed type,
154+
which would pass a selection of two numbers and no date, or of two
155+
dates and no series. Neither can be plotted, so both are refused here
156+
rather than failing later with an unhelpful error.
157+
158+
Parameters
159+
----------
160+
explorer_info : Explorer
161+
The database record for the explorer instance, including the
162+
selected columns.
163+
column_spec : Dict[str, Dict[str, str]]
164+
A mapping from column name to a dict with at least ``"type"`` and
165+
``"dtype"``.
166+
167+
Returns
168+
-------
169+
bool
170+
True if the selection holds exactly one Date column and at least
171+
one numeric column, and the inherited checks also pass.
172+
"""
173+
if not super().validate_columns(explorer_info, column_spec):
174+
return False
175+
176+
types = [
177+
column_spec.get(column["columnName"], {}).get("type", "")
178+
for column in explorer_info.columns
179+
]
180+
return types.count("Date") == 1 and any(t in _VALUE_TYPES for t in types)
181+
182+
def launch_exploration(self, dataset: "DashAIDataset", explorer_info: Explorer):
183+
"""Draw the selected series against the selected date column.
184+
185+
Parameters
186+
----------
187+
dataset : DashAIDataset
188+
The prepared dataset holding the selected columns.
189+
explorer_info : Explorer
190+
Explorer record with the column names and optional display name.
191+
192+
Returns
193+
-------
194+
plotly.graph_objects.Figure
195+
An interactive line plot with a time axis.
196+
197+
Raises
198+
------
199+
ValueError
200+
If no selected column is a Date, or if the date values do not
201+
match the format the column declares.
202+
"""
203+
import plotly.express as px
204+
205+
from DashAI.back.types.date_utils import DEFAULT_DATE_FORMAT, parse_date_column
206+
207+
columns = [column["columnName"] for column in explorer_info.columns]
208+
date_columns = [
209+
name for name in columns if isinstance(dataset.types.get(name), Date)
210+
]
211+
if not date_columns:
212+
raise ValueError(
213+
"TimeSeriesPlotExplorer needs a Date column among the selected "
214+
f"columns, got {', '.join(columns)}."
215+
)
216+
217+
date_column = date_columns[0]
218+
value_columns = [name for name in columns if name != date_column]
219+
220+
frame = dataset.to_pandas()
221+
# Read with the format the column already declares rather than
222+
# guessing, so the plot can never disagree with the stored type. A
223+
# column whose format is wrong raises here instead of drawing a
224+
# plausible but wrong picture.
225+
date_format = (
226+
getattr(dataset.types[date_column], "format", None) or DEFAULT_DATE_FORMAT
227+
)
228+
frame[date_column] = parse_date_column(frame[date_column], date_format)
229+
frame = frame.sort_values(date_column)
230+
231+
figure = px.line(
232+
frame,
233+
x=date_column,
234+
y=value_columns,
235+
markers=self.markers,
236+
title=f"{', '.join(value_columns)} over {date_column}",
237+
)
238+
figure.update_layout(xaxis_title=date_column, yaxis_title="")
239+
240+
if explorer_info.name is not None and explorer_info.name != "":
241+
figure.update_layout(title=f"{explorer_info.name}")
242+
243+
return figure
244+
245+
def save_notebook(
246+
self,
247+
__notebook_info__: Notebook,
248+
explorer_info: Explorer,
249+
save_path: "Path",
250+
result: Any,
251+
) -> str:
252+
"""Save the figure to disk (JSON content, ``.pickle`` extension).
253+
254+
Notes
255+
-----
256+
Despite the ``.pickle`` file extension, the file is written using
257+
``write_json`` and contains JSON-serialized Plotly figure data. This
258+
matches every other plot explorer.
259+
260+
Parameters
261+
----------
262+
__notebook_info__ : Notebook
263+
The notebook database record (unused).
264+
explorer_info : Explorer
265+
The explorer record used for filename generation.
266+
save_path : Path
267+
Directory where the file will be saved.
268+
result : Any
269+
The Plotly figure returned by ``launch_exploration``.
270+
271+
Returns
272+
-------
273+
str
274+
The path of the saved file as a POSIX string.
275+
"""
276+
import os
277+
from pathlib import Path
278+
279+
filename = f"{explorer_info.id}.pickle"
280+
path = Path(os.path.join(save_path, filename))
281+
282+
result.write_json(path.as_posix())
283+
return path.as_posix()
284+
285+
def get_results(
286+
self, exploration_path: str, options: Dict[str, Any]
287+
) -> List[Artifact]:
288+
"""Load and return the saved figure for the frontend.
289+
290+
Parameters
291+
----------
292+
exploration_path : str
293+
Path to the JSON file saved by ``save_notebook``.
294+
options : Dict[str, Any]
295+
Rendering options from the frontend (unused).
296+
297+
Returns
298+
-------
299+
List[Artifact]
300+
A single-element list with the plotly artifact of the saved figure.
301+
"""
302+
with open(exploration_path, "r", encoding="utf-8") as f:
303+
result = f.read()
304+
305+
return [PlotlyArtifact(payload=result)]

DashAI/back/initial_components.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@
140140
)
141141
from DashAI.back.exploration.explorers.scatter_matrix import ScatterMatrixExplorer
142142
from DashAI.back.exploration.explorers.scatter_plot import ScatterPlotExplorer
143+
from DashAI.back.exploration.explorers.time_series_plot import (
144+
TimeSeriesPlotExplorer,
145+
)
143146
from DashAI.back.exploration.explorers.wordcloud import WordcloudExplorer
144147

145148
# Jobs
@@ -644,6 +647,7 @@ def get_initial_components():
644647
ECDFPlotExplorer,
645648
HistogramPlotExplorer,
646649
ScatterMatrixExplorer,
650+
TimeSeriesPlotExplorer,
647651
ParallelCategoriesExplorer,
648652
ParallelCordinatesExplorer,
649653
# Converters

0 commit comments

Comments
 (0)