Lesson 20: Styling Bokeh plots
[1]:
import numpy as np
import pandas as pd
import iqplot
import colorcet
import bokeh.io
import bokeh.plotting
import bokeh.models
import bokeh.themes
bokeh.io.output_notebook()
We have seen how to use Bokeh (and the higher-level plotting package iqplot) to make interactive plots. We have seen how to adjust plot size, axis labels, glyph color, etc., but we have just started to touch the surface of how we might customize plots. In this lesson, we investigate ways to stylize Bokeh plots to our visual preferences.
Before we get into the lower-level styling, we will first go over some of the styling available in iqplot. We will again use the frog strike data set, so let’s load it in.
[2]:
df = pd.read_csv('data/frog_tongue_adhesion.csv', comment='#')
Customization with iqplot
All of iqplot
’s high-level plotting functions take data
, q
, and cats
arguments. Additionally, each of the plotting functions also has the following additional optional keyword arguments, as described above.
q_axis
: Along which axis, x or y that the quantitative variable varies. The default is'x'
.palette
: A list of hex colors to use for coloring the markers for each category. By default, it uses the Glasbey Category 10 color palette from colorcet.order
: If specified, the ordering of the categories to use on the categorical axis and legend (if applicable). Otherwise, the order of the inputted data frame is used.p
: If specified, thebokeh.plotting.Figure
object to use for the plot. If not specified, a new figure is created.
The respective plotting functions also have kwargs that are specific to each (such as style
for iqplot.ecdf()
. Examples highlighting some, but not all, customizations follow. You can find out what kwargs are available for each function by reading their doc strings, e.g., with
iqplot.box?
Any kwargs not in the function call signature are passed to bokeh.plotting.figure()
when the figure is instantiated.
Customizing box plots
We can also have vertical box plots.
[3]:
p = iqplot.box(
data=df,
q="impact force (mN)",
cats="ID",
q_axis='y',
)
bokeh.io.show(p)
We can independently specify properties of the glyphs using box_kwargs
, whisker_kwargs
, median_kwargs
, and outlier_kwargs
. For example, say we wanted our colors to be Betancourt red, and that we wanted the outliers to also be that color and use diamond glyphs.
[4]:
p = iqplot.box(
data=df,
q="impact force (mN)",
cats="ID",
whisker_caps=True,
outlier_marker='diamond',
box_kwargs=dict(fill_color='#7C0000'),
whisker_kwargs=dict(line_color='#7C0000', line_width=2),
)
bokeh.io.show(p)
Custominzing strip plots
To help alleviate the overlap problem, we can make a strip plot with dash markers and add some transparency.
[5]:
p = iqplot.strip(
data=df,
q="impact force (mN)",
cats="ID",
marker='dash',
marker_kwargs=dict(alpha=0.5)
)
bokeh.io.show(p)
The problem with strip plots is that they can have trouble with overlapping data points. A common approach to deal with this is to “jitter,” or place the glyphs with small random displacements along the categorical axis. This is accomplished with the spread='jitter'
keyword argument. I do that here, allowing for hover tools that give more information about the respective data points.
[6]:
p = iqplot.strip(
data=df,
q="impact force (mN)",
cats="ID",
spread="jitter",
tooltips=[
('trial', '@{trial number}'),
('adh force', '@{adhesive force (mN)}')
],
)
bokeh.io.show(p)
Alternatively, we can spread the data points as in a beeswarm plot, also known as a swarm plot, in which the points are nudged up or down only as far as necessary to avoid a clash with neighboring data points. This is not always possible to construct for large data sets. We do this using the spread='swarm'
keyword argument, choosing a smaller plot area to accentuate the swarm style of spreading.
[7]:
p = iqplot.strip(
data=df,
q="impact force (mN)",
cats="ID",
spread="swarm",
tooltips=[
('trial', '@{trial number}'),
('adh force', '@{adhesive force (mN)}')
],
frame_width=200,
frame_height=200,
)
bokeh.io.show(p)
With any of the plots, you can have more than one categorical column, and the categorical axes are nicely spaced and formatted. Here, we’ll categorize by frog ID and by trial number.
[8]:
p = iqplot.strip(
data=df,
q="impact force (mN)",
cats=['ID', 'trial number'],
color_column='trial number',
width=550,
)
bokeh.io.show(p)
Customizing histograms
We could plot normalized histograms using the density kwarg. To generate data to plot, we will draw random numbers, which we will learn how to do in a future lesson.
[9]:
# Generate normally distributed data
x = np.random.standard_normal(size=100000)
# Plot the histogram
p = iqplot.histogram(x, bins=50, density=True, rug=False)
bokeh.io.show(p)
Customizing ECDFs
Instead of plotting a separate ECDF for each category, we can put all of the categories together on one ECDF and color the points by the categorical variable by using the kind='colored'
kwarg. Note that if we do this, we can only have the “dot” style ECDF, not the staircase.
[10]:
p = iqplot.ecdf(
data=df,
q="impact force (mN)",
cats="ID",
kind='colored',
tooltips=[
('trial', '@{trial number}'),
('adh force', '@{adhesive force (mN)}')
],
)
bokeh.io.show(p)
Styling Bokeh plots as they are built
We now move into lower-level styling of Bokeh plots in general. Bokeh figures and renderers (which are essentially the glyphs) have a plethora of attributes pertaining to visual appearance that may be adjusted at instantiation and after making a plot. As an example, let us again make a plot from the face-matching data set. We’ll naturally start by loading the data set.
[11]:
df = pd.read_csv("data/gfmt_sleep.csv", na_values="*")
df["insomnia"] = df["sci"] <= 16
A color palette an ordering of colors that are used for glyphs, usually corresponding to categorical data. Colorcet’s Glasbey Category 10 provides a good palette for categorical data, and we store this as our categorical colors for plotting.
[12]:
cat_colors = colorcet.b_glasbey_category10
Now we can build the plot. Since the data are percentages, we will set the axes to go from zero to 100 and enforce that the figure is square. We will also include a title as well so we can style that.
[13]:
p = bokeh.plotting.figure(
frame_width=300,
frame_height=300,
x_axis_label="confidence when correct",
y_axis_label="condifence when incorrect",
title="GMFT with sleep conditions",
x_range=[0, 100],
y_range=[0, 100],
)
In styling this plot, we will also put the legend outside of the plot area. This is a bit trickier than what we have been doing using the legend_label
kwarg in p.circle()
. To get a legend outside of the plot area, we need to:
Assign each glyph to a variable.
Instantiate a
bokeh.models.Legend
object using the stored variables containing the glyphs. This is instantiated asbokeh.models.Legend(items=legend_items)
, wherelegend_items
is a list of 2-tuples. In each 2-tuple, the first entry is a string with the text used to label the glyph. The second entry is a list of glyphs that have the label.Add the legend to the figure using the
add_layout()
method.
Now, we add the glyphs, storing them as variables normal_glyph
and insom_glyph
.
[14]:
# Set up sources
source_insomnia = bokeh.models.ColumnDataSource(df.loc[df['insomnia'], :])
source_normal = bokeh.models.ColumnDataSource(df.loc[~df['insomnia'], :])
normal_glyph = p.circle(
source=source_normal,
x="confidence when correct",
y="confidence when incorrect",
color=cat_colors[0],
)
insom_glyph = p.circle(
source=source_insomnia,
x="confidence when correct",
y="confidence when incorrect",
color=cat_colors[1],
)
Now we can construct and add the legend.
[15]:
# Construct legend items
legend_items = [('normal', [normal_glyph]), ('insomnia', [insom_glyph])]
# Instantiate legend
legend = bokeh.models.Legend(items=legend_items, click_policy='hide')
# Add the legend to the right of the plot
p.add_layout(legend, 'right')
Now, let’s take a look at this beauty!
[16]:
bokeh.io.show(p)
Styling Bokeh plots after they are built
After building a plot, we sometimes want to adjust styling. To do so, we need to change attributes of the object p
. For example, let’s look at the font of the x-axis label.
[17]:
p.xaxis.axis_label_text_font
[17]:
'helvetica'
We can also look at the style and size of the font.
[18]:
p.xaxis.axis_label_text_font_style, p.xaxis.axis_label_text_font_size
[18]:
('italic', '13px')
So, the default axis labels for Bokeh are italicized 13 pt Helvetica. I personally think this choice if fine, but we may have other preferences.
To find out all of the available options to tweak, I usually type something like p.
and hit tab to see what the options are. Finding p.xaxis
is an option, then type p.xaxis.
and hit tab again to see the styling option there.
Using this technique, we can set some obnoxious styling for this plot. I will make all of the fonts non-italicized, large papyrus. I can also set the background and grid colors. Note that in Bokeh, any named CSS color or any valid HEX code, entered as a string, is a valid color.
Before we do the obnoxious styling, we will do one adjustment that is useful. Note in the above plot that the glyphs at the end of the plot are cropped. We would like the whole glyph to show. To do that, we set the level of the glyphs to be 'overlay'
. To do that, we extract the first two elements of the list of renderers, which contains the glyphs, and set the level
attribute.
[19]:
p.renderers[0].level = 'overlay'
p.renderers[1].level = 'overlay'
Now we can proceed to make our obnoxious styling.
[20]:
# Obnoxious fonts
p.xaxis.major_label_text_font = 'papyrus'
p.xaxis.major_label_text_font_size = '14pt'
p.xaxis.axis_label_text_font = 'papyrus'
p.xaxis.axis_label_text_font_style = 'normal'
p.xaxis.axis_label_text_font_size = '20pt'
p.yaxis.major_label_text_font = 'papyrus'
p.yaxis.major_label_text_font_size = '14pt'
p.yaxis.axis_label_text_font = 'papyrus'
p.yaxis.axis_label_text_font_style = 'normal'
p.yaxis.axis_label_text_font_size = '20pt'
p.title.text_font = 'papyrus'
p.title.text_font_size = '18pt'
p.legend.label_text_font = 'papyrus'
# Align the title center
p.title.align = 'center'
# Set background and grid color
p.background_fill_color = 'blanchedalmond'
p.legend.background_fill_color = 'chartreuse'
p.xgrid.grid_line_color = 'azure'
p.ygrid.grid_line_color = 'azure'
# Make the ticks point inward (I *hate* this!)
# Units are pixels that the ticks extend in and out of plot
p.xaxis.major_tick_out = 0
p.xaxis.major_tick_in = 10
p.xaxis.minor_tick_out = 0
p.xaxis.minor_tick_in = 5
p.yaxis.major_tick_out = 0
p.yaxis.major_tick_in = 10
p.yaxis.minor_tick_out = 0
p.yaxis.minor_tick_in = 5
bokeh.io.show(p)
This is truly hideous, but it demonstrates how we can go about styling plots after they are made.
Bokeh themes
Bokeh has several built-in themes which you can apply to all plots in a given document (e.g., in a notebook). Please see the documentation for details about the built-in themes. I personally prefer the default styling to all of their themes, but your opinion may differ.
You may also specify custom themes using JSON or YAML. As an example, we can specify a theme such that plots are styled like the default style of the excellent plotting packages Vega-Altair/Vega-Lite/Vega. If we use JSON formatting, we can specify a theme as a dictionary of dictionaries, as below.
[21]:
altair_theme_dict = {
"attrs": {
"Axis": {
"axis_line_color": "dimgray",
"minor_tick_out": 0,
"major_tick_in": 0,
"major_tick_line_color": "dimgray",
"major_label_text_font_size": "7.5pt",
"axis_label_text_font_size": "8pt",
"axis_label_text_font_style": "bold",
},
"Circle": {
"fill_alpha": 0,
"line_width": 2,
"size": 5,
"line_alpha": 0.7,
},
"ContinuousTicker": {
"desired_num_ticks": 10
},
"figure": {
"frame_width": 350,
"frame_height": 300,
},
"Grid": {
"grid_line_color": "lightgray",
"level": "underlay",
},
"Legend": {
"border_line_color": None,
"background_fill_color": None,
"label_text_font_size": "7.5pt",
"title_text_font_size": "8pt",
"title_text_font_style": "bold",
},
"Renderer": {
"level": "overlay"
},
"Title": {
"align": "center",
},
}
}
To activate the theme, we convert it to a Bokeh theme and then add it to the curdoc()
, or the current document.
[22]:
altair_theme = bokeh.themes.Theme(json=altair_theme_dict)
bokeh.io.curdoc().theme = altair_theme
Now the theme is activated, and future plots will have this theme by default. Let’s remake our plot using this theme. For convenience later on, I will write a function to generate this scatter plot that we will use to test various styles.
[23]:
def gfmt_plot():
"""Make a plot for testing out styles in this notebook."""
p = bokeh.plotting.figure(
frame_width=300,
frame_height=300,
x_axis_label="confidence when correct",
y_axis_label="condifence when incorrect",
title="GMFT with sleep conditions",
x_range=[0, 100],
y_range=[0, 100],
)
normal_glyph = p.circle(
source=source_normal,
x="confidence when correct",
y="confidence when incorrect",
color=cat_colors[0],
)
insom_glyph = p.circle(
source=source_insomnia,
x="confidence when correct",
y="confidence when incorrect",
color=cat_colors[1],
)
# Construct legend items
legend_items = [('normal', [normal_glyph]), ('insomnia', [insom_glyph])]
# Instantiate legend
legend = bokeh.models.Legend(items=legend_items, click_policy='hide')
# Add the legend to the right of the plot
p.add_layout(legend, 'right')
return p
bokeh.io.show(gfmt_plot())
We could also style our plots to resemble the default “dark” styling of Seaborn.
[24]:
seaborn_theme_dict = {
"attrs": {
"figure": {
"background_fill_color": "#eaeaf2",
"frame_height": 300,
"frame_width": 350,
},
"Axis": {
"axis_line_color": None,
"minor_tick_out": 0,
"major_tick_in": 0,
"major_tick_out": 0,
"major_label_text_font_size": "7.5pt",
"axis_label_text_font_size": "7.5pt",
"axis_label_text_font_style": "normal",
},
"Legend": {
"border_line_color": "darkgray",
"background_fill_color": "#eaeaf2",
"border_line_width": 0.75,
"label_text_font_size": "7.5pt",
},
"Grid": {
"grid_line_color": "#FFFFFF",
"grid_line_width": 0.75,
},
"Title": {
"align": "center",
'text_font_style': 'normal',
'text_font_size': "8pt",
},
}
}
seaborn_theme = bokeh.themes.Theme(json=seaborn_theme_dict)
bokeh.io.curdoc().theme = seaborn_theme
Let’s make the plot, yet again, with this new styling.
[25]:
bokeh.io.show(gfmt_plot())
Finally, we can specify a style I like. Note that I do not specify that the glyphs are at an overlay level, since by default Bokeh will scale the axes such that the glyphs are fully contained in the plot area. I also put the toolbar above the plot, which is usually not a problem because I generally prefer not to title my plots, opting instead for good textual description in captions or in surrounding text.
[26]:
jb_theme_dict = {
"attrs": {
"Axis": {
"axis_line_color": "dimgray",
"major_tick_line_color": "dimgray",
"major_label_text_font_size": "7.5pt",
"axis_label_text_font_size": "9pt",
"axis_label_text_font_style": "bold",
},
"Circle": {
"size": 5,
"fill_alpha": 0.8,
"line_width": 0,
},
"figure": {
"frame_height": 300,
"frame_width": 350,
"toolbar_location": "above",
},
"Grid": {
"grid_line_color": "lightgray",
"level": "underlay",
},
"Legend": {
"border_line_color": "darkgray",
"border_line_width": 0.75,
"background_fill_color": "#ffffff",
"background_fill_alpha": 0.7,
"label_text_font": "helvetica",
"label_text_font_size": "7.5pt",
"title_text_font": "helvetica",
"title_text_font_size": "8pt",
"title_text_font_style": "bold",
},
"Renderer": {
"level": "overlay"
},
"Title": {
"text_font": "helvetica",
"text_font_size": "10pt",
'text_font_style': 'bold',
},
}
}
jb_theme = bokeh.themes.Theme(json=jb_theme_dict)
bokeh.io.curdoc().theme = jb_theme
bokeh.io.show(gfmt_plot())
Finally, if I were to make this particular plot, I would do it without a title and with axes leaving a little buffer.
[27]:
p = bokeh.plotting.figure(
frame_width=300,
frame_height=300,
x_axis_label="confidence when correct",
y_axis_label="condifence when incorrect",
x_range=[-2.5, 102.5],
y_range=[-2.5, 102.5],
)
normal_glyph = p.circle(
source=source_normal,
x="confidence when correct",
y="confidence when incorrect",
color=cat_colors[0],
)
insom_glyph = p.circle(
source=source_insomnia,
x="confidence when correct",
y="confidence when incorrect",
color=cat_colors[1],
)
# Construct legend items
legend_items = [('normal', [normal_glyph]), ('insomnia', [insom_glyph])]
# Instantiate legend
legend = bokeh.models.Legend(items=legend_items, click_policy='hide')
# Add the legend to the right of the plot
p.add_layout(legend, 'right')
bokeh.io.show(p)
You can play with these themes and develop your own style as you see fit. As you can see, Bokeh is highly configurable, and you can really make the plots your own!
Computing environment
[28]:
%load_ext watermark
%watermark -v -p numpy,pandas,bokeh,iqplot,jupyterlab
Python implementation: CPython
Python version : 3.11.4
IPython version : 8.12.2
numpy : 1.24.3
pandas : 2.0.3
bokeh : 3.2.1
iqplot : 0.3.4
jupyterlab: 4.0.5