Plots#
[1]:
# Import to be able to import python package from src
import sys
sys.path.insert(0, '../src')
[2]:
import pandas as pd
import ontime as on
from darts.datasets import EnergyDataset
Load data#
[3]:
ts = EnergyDataset().load()
Complete TimeSeries
[4]:
df = ts.pd_dataframe()
df = df.interpolate()
cols = ['generation biomass', 'generation solar', 'generation nuclear']
df = df[cols]
[5]:
ts = on.TimeSeries.from_dataframe(df)
Prepare data
[6]:
ts_uni = ts['generation solar'].slice(pd.Timestamp('2015'), pd.Timestamp('2016'))
ts_multi = ts.slice(pd.Timestamp('2015'), pd.Timestamp('2016'))
Primitive Plots#
Line(s)#
With univariate TimeSeries
[7]:
on.Plot(ts_uni.head(400))\
.add(on.marks.line)\
.properties(width=600)\
.show()
[7]:
with multivariate TimeSeries
[8]:
on.Plot(ts_multi.head(400))\
.add(on.marks.line)\
.properties(width=600)\
.show()
[8]:
Dots#
With univariate TimeSeries
[9]:
on.Plot(ts_uni.head(400))\
.add(on.marks.dots)\
.properties(width=600)\
.show()
[9]:
with multivariate TimeSeries
[10]:
on.Plot(ts_multi.head(400))\
.add(on.marks.dots)\
.properties(width=600)\
.show()
[10]:
Areas#
With a single time series
[11]:
on.Plot(ts_uni.head(400))\
.add(on.marks.area)\
.properties(width=600)\
.show()
[11]:
With a multivariate time series it works with exactly two
[12]:
from darts import concatenate
[13]:
# First we create the series with two components
ts_ci = concatenate([
ts_multi.univariate_component(0),
ts_multi.univariate_component(1)
], axis=1)
ts_ci = on.TimeSeries.from_darts(ts_ci)
[14]:
# Then we plot it
on.Plot(ts_ci.head(200))\
.add(on.marks.area, title='Diff. between solar and biomass generation')\
.properties(width=600, height=200)\
.show()
[14]:
Heatmaps#
with univariate TimeSeries
[15]:
on.Plot(ts_uni.head(1000))\
.add(on.marks.heatmap)\
.properties(width=600, height=50)\
.show()
[15]:
with multivariate Heatmap
[16]:
on.Plot(ts_multi.head(1000))\
.add(on.marks.heatmap)\
.properties(width=600, height=150)\
.show()
[16]:
Combined Plots#
Most of the plots in onTime can be combined as they are based on Altair layered charts. For instance, you can do the following to have a dots on a line.
[17]:
on.Plot()\
.add(on.marks.dots, ts_multi.univariate_component(1).head(400))\
.add(on.marks.line, ts_multi.univariate_component(0).head(400))\
.properties(width=600, height=200)\
.show()
[17]:
Subplots#
A Plot is a single panel. To place several panels in one figure, use the on.rows and on.cols factories, which return a Figure.
The names describe the arguments, not the container: on.rows(a, b) means “a and b are rows”, so the panels are stacked vertically. on.cols(a, b) places them side by side. Both accept Plot and Figure panels, so they nest freely.
A few rules worth knowing:
share_x/share_yshare the scale domain across the panels of a group. They default toshare_x=True, share_y=Falseonrowsandshare_x=False, share_y=Falseoncols.Propagation: a flag passed explicitly propagates down into nested groups; a flag left unset lets each nested group use its own default. This is the most likely source of surprise.
When the x axis is shared in a vertical stack, the inner x axis labels are hidden and only drawn on the bottom panel.
Panel-level
.properties()wins over figure-level.properties(), which only fills the values a panel left unset.spacingsets the gap between panels in pixels, and the layout options below control how much room the axes get.sizesgives the extent of each child along the stacking axis (heights forrows, widths forcols), either as pixels or as fractions summing to 1.0.
Known constraint. In Vega-Lite 5,
selection_interval(bind="scales")does not reliably propagate across concatenated views, so synchronised pan/zoom across panels is not available. Sharing a scale domain works, interactive zoom does not.
Let’s prepare a few short series to plot.
[18]:
solar = ts_multi['generation solar'].head(400)
nuclear = ts_multi['generation nuclear'].head(400)
biomass = ts_multi['generation biomass'].head(400)
def line(series, title=None):
plot = on.Plot(series).add(on.marks.line)
return plot.properties(title=title) if title is not None else plot
Unequal heights in pixels#
A tall main panel with a thin heatmap strip below it.
[20]:
main = on.Plot(solar).add(on.marks.line)
strip = on.Plot(solar).add(on.marks.heatmap)
on.rows(main, strip, sizes=[240, 40])\
.properties(width=800)\
.show()
[20]:
Fractional sizes#
Fractions must sum to 1.0 and are taken along the stacking axis, here the total height of the figure.
[21]:
forecast = on.Plot(nuclear).add(on.marks.line)
residuals = on.Plot(nuclear.diff()).add(on.marks.line)
on.rows(forecast, residuals, sizes=[0.72, 0.28])\
.properties(width=700, height=340)\
.show()
[21]:
Side by side comparison#
A comparison inverts both defaults: the panels share the y scale so the magnitudes are comparable, but keep their own x scale because they cover different periods.
[22]:
ts_2015 = ts['generation solar'].slice(pd.Timestamp('2015-06-01'), pd.Timestamp('2015-06-08'))
ts_2016 = ts['generation solar'].slice(pd.Timestamp('2016-06-01'), pd.Timestamp('2016-06-08'))
on.cols(line(ts_2015, title='2015'), line(ts_2016, title='2016'), share_y=True, share_x=False)\
.properties(width=350, height=200)\
.show()
[22]:
Small multiples#
Groups nest, so a row of columns gives a grid of small multiples.
[23]:
panels = [line(ts_multi[c].head(400), title=c) for c in ts_multi.components]
on.rows(on.cols(*panels[:2]), on.cols(*panels[2:]), share_y=True)\
.properties(width=350, height=140)\
.show()
[23]:
Nesting#
Groups nest freely. Here the inner rows inherits nothing from the outer cols, so it uses its own default share_x=True, while the outer cols keeps share_x=False.
[24]:
profile = on.Plot(biomass).add(on.marks.line)
on.cols(on.rows(main, strip, sizes=[240, 50]), profile, sizes=[620, 180])\
.properties(height=290)\
.show()
[24]:
Adjusting the layout#
Four options of Figure.properties() control how the panels are placed.
spacingis the gap between panels in pixels (default4). The gap is measured between the full bounds of the panels, axes and titles included, so panels never overrun each other.boundsselects how a panel is measured:"full"(default) accounts for the axes and titles,"flush"only accounts for the plotting areas."flush"packs the panels tightly but lets axis labels and titles overlap their neighbours, so it is only useful when the inner axes are hidden.hide_shared_axes(defaultTrue) drops the redundant inner axes of a shared scale: the x axis is kept on the bottom row only, and the y axis on the first column only. Set it toFalseto draw every axis.axis_extent(default40) is the minimum width reserved for the y axis of every panel, which keeps the y axis titles aligned across panels. Use0to let each panel size its own axis.
[25]:
on.rows(line(solar), line(nuclear), line(biomass))\
.properties(width=800, height=140, spacing=24, axis_extent=60)\
.show()
[25]:
The layout of a figure is available as a symbolic tree, which is handy to check what was built without touching the data. It reads / for a vertical stack, | for a horizontal one, and annotates the panels with their size when they have one.
[26]:
figure = on.rows(line(solar, title='solar'), line(nuclear, title='nuclear'), sizes=[0.5, 0.5])
figure.layout
[26]:
solar:0.5 / nuclear:0.5
Thematic Plots#
Forecasts#
[27]:
ts_train, ts_test = ts_uni.split_before(0.9)
[28]:
from ontime.context import common
[29]:
model = common.GenericPredictor()
model.fit(ts_train)
[29]:
<ontime.context.common.generic_predictor.GenericPredictor at 0x32f869f90>
[30]:
ts_pred = model.predict(24 * 3)
[31]:
ts_train = ts_train.rename({'generation solar':'Training set'})
ts_test = ts_test.rename({'generation solar':'Test set'})
ts_pred = ts_pred.rename({'generation solar':'Forecast'})
Plot a prediction
[32]:
(
on.Plot()
.add(on.marks.line, ts_test.head(24 * 3), type='dashed')
.add(on.marks.line, ts_train.tail(24 * 4))
.add(on.marks.line, ts_pred)
.properties(width=600, height=200)
.show()
)
[32]:
Anomalies#
Create the mock data
[33]:
td_point = on.detectors.quantile(high_quantile=0.99)
td_collective = on.detectors.threshold(low_threshold=-30)
td_contextual = on.detectors.quantile(high_quantile=0.98)
Add anomalies
[34]:
import numpy as np
import random
def add_point_anomalies(ts, n, value):
df = ts.pd_dataframe()
random_indices = np.random.choice(df.index, size=n, replace=False)
df.loc[random_indices] = value
return on.TimeSeries.from_dataframe(df)
def add_collective_anomalies(ts, n, min_duration=10, max_duration=20):
df = ts.pd_dataframe()
for i in range(n+1):
block_duration = random.randint(min_duration, max_duration)
start_index = np.random.choice(df.index[:-block_duration])
end_index = start_index + pd.Timedelta(days=block_duration - 1)
df.loc[start_index:end_index] = -40
return on.TimeSeries.from_dataframe(df)
Select univariate component
[35]:
ts = ts.univariate_component(0)
[36]:
ts = add_point_anomalies(ts, 10, 30)
ts = add_collective_anomalies(ts, 4)
Create binary time series
[37]:
td_point.fit(ts)
td_contextual.fit(ts)
ts_ano_point = td_point.detect(ts)
ts_ano_collective = td_collective.detect(ts)
ts_ano_contextual = td_contextual.detect(ts)
[38]:
ts_ano_point = ts_ano_point.rename({'generation biomass': 'Ponctual anomalies'})
ts_ano_collective = ts_ano_collective.rename({'generation biomass': 'Collective anomalies'})
ts_ano_contextual = ts_ano_contextual.rename({'generation biomass': 'Contextual anomalies'})
Plot the time series with marked anomalies
[39]:
# Define windows for plotting
start = 24 * 7 * 54
duration = 24 * 7 * 15
end = start + duration
[40]:
# Actually plot
(
on.Plot(ts[start:end])
.add(on.marks.mark, data=ts_ano_contextual[start:end], type='highlight')
.add(on.marks.mark, data=ts_ano_collective[start:end], type='background')
.add(on.marks.line)
.add(on.marks.mark, data=ts_ano_point[start:end], type='dot')
.properties(width=800, height=200)
.show()
)
[40]:
Confidence Intervals#
[41]:
# Generate two time series
ts1 = on.generators.random_walk().generate(start=pd.Timestamp('2022-01-01'), end=pd.Timestamp('2022-12-31'))
ts2 = on.generators.random_walk().generate(start=pd.Timestamp('2022-01-01'), end=pd.Timestamp('2022-12-31'))
[42]:
# First we create the series with two components
ts1_abs = ts1.map(np.abs)
ts2_abs = ts2.map(np.abs)
ts_ci = concatenate([ts1_abs, ts2_abs], axis=1)
ts_ci = on.TimeSeries.from_darts(ts_ci)
ts_ci = ts_ci.rename({'random_walk': 'CI Upper bound', 'random_walk_1': 'CI Lower bound'})
# Then the hypothetical measurement
ts_mid = (ts1_abs + ts2_abs) / 2
ts_mid = ts_mid.rename({'random_walk': 'Measurement'})
[43]:
# Then we plot it
(
on.Plot() # main line
.add(on.marks.area, ts_ci.head(200), title='Confidence interval')
.add(on.marks.line, ts_mid.head(200))
.properties(width=600, height=200)
.show()
)
[43]: