13

How can I use Plotly to produce a line plot with a shaded standard deviation? I am trying to achieve something similar to seaborn.tsplot. Any help is appreciated. enter image description here

vestland
  • 55,229
  • 37
  • 187
  • 305
Tyesh
  • 370
  • 2
  • 12

5 Answers5

8

The following approach is fully flexible with regards to the number of columns in a pandas dataframe and uses the default color cycle of plotly. If the number of lines exceed the number of colors, the colors will be re-used from the start. As of now px.colors.qualitative.Plotly can be replaced with any hex color sequence that you can find using px.colors.qualitative:

Alphabet = ['#AA0DFE', '#3283FE', '#85660D', '#782AB6', '#565656', '#1...
Alphabet_r = ['#FA0087', '#FBE426', '#B00068', '#FC1CBF', '#C075A6', '...
[...]

enter image description here

Complete code:

# imports
import plotly.graph_objs as go
import plotly.express as px
import pandas as pd
import numpy as np

# sample data in a pandas dataframe
np.random.seed(1)
df=pd.DataFrame(dict(A=np.random.uniform(low=-1, high=2, size=25).tolist(),
                    B=np.random.uniform(low=-4, high=3, size=25).tolist(),
                    C=np.random.uniform(low=-1, high=3, size=25).tolist(),
                    ))
df = df.cumsum()

# define colors as a list 
colors = px.colors.qualitative.Plotly

# convert plotly hex colors to rgba to enable transparency adjustments
def hex_rgba(hex, transparency):
    col_hex = hex.lstrip('#')
    col_rgb = list(int(col_hex[i:i+2], 16) for i in (0, 2, 4))
    col_rgb.extend([transparency])
    areacol = tuple(col_rgb)
    return areacol

rgba = [hex_rgba(c, transparency=0.2) for c in colors]
colCycle = ['rgba'+str(elem) for elem in rgba]

# Make sure the colors run in cycles if there are more lines than colors
def next_col(cols):
    while True:
        for col in cols:
            yield col
line_color=next_col(cols=colCycle)

# plotly  figure
fig = go.Figure()

# add line and shaded area for each series and standards deviation
for i, col in enumerate(df):
    new_col = next(line_color)
    x = list(df.index.values+1)
    y1 = df[col]
    y1_upper = [(y + np.std(df[col])) for y in df[col]]
    y1_lower = [(y - np.std(df[col])) for y in df[col]]
    y1_lower = y1_lower[::-1]

    # standard deviation area
    fig.add_traces(go.Scatter(x=x+x[::-1],
                                y=y1_upper+y1_lower,
                                fill='tozerox',
                                fillcolor=new_col,
                                line=dict(color='rgba(255,255,255,0)'),
                                showlegend=False,
                                name=col))

    # line trace
    fig.add_traces(go.Scatter(x=x,
                              y=y1,
                              line=dict(color=new_col, width=2.5),
                              mode='lines',
                              name=col)
                                )
# set x-axis
fig.update_layout(xaxis=dict(range=[1,len(df)]))

fig.show()
vestland
  • 55,229
  • 37
  • 187
  • 305
  • 2
    This looks great. Thanks – Tyesh Apr 29 '20 at 22:52
  • can you explain what is going on with the 'tozerox' fill mode? the effect this gives is nothing like the effect of what I thought tozerox does. – Jarrad Aug 30 '20 at 12:02
  • @Jarrad AFK. Remind me tomorrow. But first, what did you think `tozerox` would do? – vestland Aug 30 '20 at 12:16
  • Very smart! Thanks! – Bananin Sep 09 '20 at 04:41
  • @vestland here's your reminder :). I guess I assumed `tozerox` would fill from the line to zero, i.e. the x axis – Jake Stevens-Haas Sep 11 '20 at 15:46
  • 1
    With a numerical index, it works, but I can't get this to work with a datetime index; how to achieve this? – Thomas Oct 11 '20 at 17:42
  • @Thomas Not sure. Feel free to post another question linking to this post ans specify that you're trying to make this work for a datetime index. If you have a data sample, that would be great. Take a look [here]https://stackoverflow.com/questions/63163251/pandas-how-to-easily-share-a-sample-dataframe-using-df-to-dict/63163254#63163254() on how to easily share a pandas dataframe. – vestland Oct 11 '20 at 17:55
  • I posted a new question here: https://stackoverflow.com/questions/64307402/how-to-plot-a-range-with-a-line-in-the-center-with-plotly-using-a-datetime-index – Thomas Oct 11 '20 at 18:06
  • 1
    @Thomas Great! I'll take a look when I find the time – vestland Oct 11 '20 at 18:13
  • 1
    As an update, the current plotly docs now include a similar example "Filling within a single trace" https://plotly.com/python/continuous-error-bars/ – APaul Jun 12 '23 at 15:52
5

I was able to come up with something similar. I post the code here to be used by someone else or for any suggestions for improvements.

enter image description here

import matplotlib
import random
import plotly.graph_objects as go
import numpy as np


#random color generation in plotly
hex_colors_dic = {}
rgb_colors_dic = {}
hex_colors_only = []
for name, hex in matplotlib.colors.cnames.items():
    hex_colors_only.append(hex)
    hex_colors_dic[name] = hex
    rgb_colors_dic[name] = matplotlib.colors.to_rgb(hex)

data = [[1, 3, 5, 4],
        [2, 3, 5, 4],
        [1, 1, 4, 5],
        [2, 3, 5, 4]]
#calculating mean and standard deviation
mean=np.mean(data,axis=0)
std=np.std(data,axis=0)

#draw figure
fig = go.Figure()
c = random.choice(hex_colors_only)
fig.add_trace(go.Scatter(x=np.arange(4), y=mean+std,
                                     mode='lines',
                                     line=dict(color=c,width =0.1),
                                     name='upper bound'))
fig.add_trace(go.Scatter(x=np.arange(4), y=mean,
                         mode='lines',
                         line=dict(color=c),
                         fill='tonexty',
                         name='mean'))
fig.add_trace(go.Scatter(x=np.arange(4), y=mean-std,
                         mode='lines',
                         line=dict(color=c, width =0.1),
                         fill='tonexty',
                         name='lower bound'))
fig.show()
Muaz Usmani
  • 1,298
  • 6
  • 26
  • 48
Tyesh
  • 370
  • 2
  • 12
5

I wrote a function to extend plotly.express.line with the same high level interface of Plotly Express. The line function (source code below) is used in the same exact way as plotly.express.line but allows for continuous error bands with the flag argument error_y_mode which can be either 'band' or 'bar'. In the second case it produces the same result as the original plotly.express.line. Here is an usage example:

import plotly.express as px

df = px.data.gapminder().query('continent=="Americas"')
df = df[df['country'].isin({'Argentina','Brazil','Colombia'})]
df['lifeExp std'] = df['lifeExp']*.1 # Invent some error data...

for error_y_mode in {'band', 'bar'}:
    fig = line(
        data_frame = df,
        x = 'year',
        y = 'lifeExp',
        error_y = 'lifeExp std',
        error_y_mode = error_y_mode, # Here you say `band` or `bar`.
        color = 'country',
        title = f'Using error {error_y_mode}',
        markers = '.',
    )
    fig.show()

which produces the following two plots:

enter image description here enter image description here

The source code of the line function that extends plotly.express.line is this:

import plotly.express as px
import plotly.graph_objs as go

def line(error_y_mode=None, **kwargs):
    """Extension of `plotly.express.line` to use error bands."""
    ERROR_MODES = {'bar','band','bars','bands',None}
    if error_y_mode not in ERROR_MODES:
        raise ValueError(f"'error_y_mode' must be one of {ERROR_MODES}, received {repr(error_y_mode)}.")
    if error_y_mode in {'bar','bars',None}:
        fig = px.line(**kwargs)
    elif error_y_mode in {'band','bands'}:
        if 'error_y' not in kwargs:
            raise ValueError(f"If you provide argument 'error_y_mode' you must also provide 'error_y'.")
        figure_with_error_bars = px.line(**kwargs)
        fig = px.line(**{arg: val for arg,val in kwargs.items() if arg != 'error_y'})
        for data in figure_with_error_bars.data:
            x = list(data['x'])
            y_upper = list(data['y'] + data['error_y']['array'])
            y_lower = list(data['y'] - data['error_y']['array'] if data['error_y']['arrayminus'] is None else data['y'] - data['error_y']['arrayminus'])
            color = f"rgba({tuple(int(data['line']['color'].lstrip('#')[i:i+2], 16) for i in (0, 2, 4))},.3)".replace('((','(').replace('),',',').replace(' ','')
            fig.add_trace(
                go.Scatter(
                    x = x+x[::-1],
                    y = y_upper+y_lower[::-1],
                    fill = 'toself',
                    fillcolor = color,
                    line = dict(
                        color = 'rgba(255,255,255,0)'
                    ),
                    hoverinfo = "skip",
                    showlegend = False,
                    legendgroup = data['legendgroup'],
                    xaxis = data['xaxis'],
                    yaxis = data['yaxis'],
                )
            )
        # Reorder data as said here: https://stackoverflow.com/a/66854398/8849755
        reordered_data = []
        for i in range(int(len(fig.data)/2)):
            reordered_data.append(fig.data[i+int(len(fig.data)/2)])
            reordered_data.append(fig.data[i])
        fig.data = tuple(reordered_data)
    return fig
user171780
  • 2,243
  • 1
  • 20
  • 43
4

Great custom responses posted by others. In case someone is interested in code from the official plotly website, see here: https://plotly.com/python/continuous-error-bars/

Moritz
  • 2,835
  • 2
  • 6
  • 12
0

Here is a solution in case you want to mix time series with and without error bands. For that we are going to define two helper methods inspired by https://plotly.com/python/continuous-error-bars/

def line(x, y, name):
    return [go.Scatter(x=x, y=y, mode='lines', line=dict(color="rgb(0, 0, 255)"), name=name)]

def line_with_error_band(x, y, y_err, name):
    
    return [
        go.Scatter(x=x, y=y, mode='lines', line=dict(color="rgb(255, 0, 0)"), name=name),
        go.Scatter(x=x, y=y+y_err, mode='lines', line=dict(width=0), showlegend=False),
        go.Scatter(x=x, y=y-y_err, line=dict(width=0), mode='lines', fillcolor="rgba(255, 0, 0, 0.3)", fill='tonexty',
                   showlegend=False)
    ]

You can use them as shown in the following full code example

import pandas as pd
import numpy as np
import plotly.graph_objs as go

def line(x, y, name):
    return [go.Scatter(x=x, y=y, mode='lines', line=dict(color="rgb(0, 0, 255)"), name=name)]

def line_with_error_band(x, y, y_err, name):
    
    return [
        go.Scatter(x=x, y=y, mode='lines', line=dict(color="rgb(255, 0, 0)"), name=name),
        go.Scatter(x=x, y=y+y_err, mode='lines', line=dict(width=0), showlegend=False),
        go.Scatter(x=x, y=y-y_err, line=dict(width=0), mode='lines', fillcolor="rgba(255, 0, 0, 0.3)", fill='tonexty',
                   showlegend=False)
    ]

# Generate example data
# timeseries y_pred and y_pred_std are starting 5 days later than y_obs 
np.random.seed(42)
x_date = pd.to_datetime(list(range(10)), unit='D', origin=pd.Timestamp('2023-01-01'))
y_obs = np.random.uniform(low=0, high=3, size=10).tolist()
y_pred = np.append(([np.nan] * 5), (y_obs*np.random.uniform(low=0, high=2, size=10))[5:])
y_pred_std = np.append(([np.nan] * 5), [0.1,0.2,0.3, 0.4, 0.5])
df = pd.DataFrame({"x_date" : x_date, "y_obs" : y_obs, "y_pred" : y_pred, "y_pred_std": y_pred_std })

fig = go.Figure()
fig.add_traces(line(df.x_date, df.y_obs, name="observed"))
fig.add_traces(line_with_error_band(df.x_date, df.y_pred, df.y_pred_std, name="predicted"))

Lines with error bands

asmaier
  • 11,132
  • 11
  • 76
  • 103