Recipes
Recipes are a way of defining visualizations in your own packages and code, without having to depend on Plots. The functionality relies on RecipesBase, a super lightweight but powerful package which allows users to create advanced plotting logic without Plots. The @recipe
macro in RecipesBase will add a method definition for RecipesBase.apply_recipe
. Plots adds to and calls this same function, and so your package and Plots can communicate without ever knowing about the other. Magic!
Visualizing custom user types has always been a confusing problem. Should a package developer add a dependency on a plotting package (forcing the significant baggage that comes with that dependency)? Should they attempt conditional dependencies? Should they submit a PR to graphics packages to define their custom visualizations? It seems that every option had many cons for each pro, and the decision was tough. With recipes, these issues go away. One tiny package (RecipesBase) gives simple hooks into the visualization pipeline, allowing users and package developers to focus solely on the specifics of their visualization. Pick the shapes/lines/colors that will represent your data well, decide on custom defaults, and convert the inputs (if you need to). Everything else is handled by Plots. There are many examples of recipes both within Plots and in many external packages, including GraphRecipes.
Visualizing User Types
Examples are always best. Lets explore the implementation of creating visualization recipes for Distributions.
Custom treatment of input combinations
Want to do something special whenever the first input is a time series? Maybe you want to preprocess your data depending on keyword flags? This is all possible by making recipes with unique dispatch signatures. You can offload and use the pre and post processing of Plots, and just add the bits that are specific to you.
Type Recipes: Easy drop-in replacement of data types
Many times a data type is a simple wrapper of a Function or Array. For example:
mutable struct MyVec
v::Vector{Int}
end
If MyVec
was a subtype of AbstractVector, there would not be anything to do... it should "just work". However this isn't always desirable, and it would be nice if you could call plot(10:20, myvec)
without having to personally define every possible combination of inputs. It this case, you'll want to use a special type of recipe signature:
@recipe f(::Type{MyVec}, myvec::MyVec) = myvec.v
Afterwards, all plot commands which work for vectors will also work for your datatype.
Series Recipes
Lets quickly discuss a mainstay of data visualization: the histogram. Hadley Wickham has explored the nature of histograms as part of his Layered Grammar of Graphics. In it, he discusses how a histogram is really nothing more than a bar graph which has its data pre-binned. This is true, and it can be taken further. A bar-graph is really an extension of a step-graph, in which zeros are interwoven among the x-values. A step-graph is really nothing more than a path (line) which can travel only horizontally or vertically. Of course, a similar decomposition could be had by treating the bars as filled polygons.
The point to be had is that a graphics package need only be able to draw lines and polygons, and they can support drawing a histogram. The path from data to histogram is normally very complicated, but we can avoid the complexity and define a recipe to convert it to its subcomponents. In a few lines of readable code, we can implement a key statistical visualization. See the tutorial on series recipes for a better understanding of how you might use them.
Recipe Types
Above we described Type recipes
and Series Recipes
. In total there are four main types of recipes in Plots (listed in the order they are processed):
- User Recipes
- Type Recipes
- Plot Recipes
- Series Recipes
The recipe type is determined completely by the dispatch signature. Each recipe type is called from a different part of the plotting pipeline, so you will choose a type of recipe to match how much processing you want completed before your recipe is applied.
These are the dispatch signatures for each type (note that most of these can accept positional or keyword args, denoted by ...
):
User Recipes
@recipe function f(custom_arg_1::T, custom_arg_2::S, ...; ...) end
- Process a unique set of types early in the pipeline. Good for user-defined types or special combinations of Base types.
- The
@userplot
macro is a nice convenience which both defines a new type (to ensure correct dispatch) and exports shorthands. - See
graphplot
for an example.
Type Recipes
@recipe function f(::Type{T}, val::T) where{T} end
- For user-defined types which wrap or have a one-to-one mapping to something supported by Plots, simply define a conversion method.
- Note: this is effectively saying "when you see type T, replace it with ..."
- See
SymPy
for an example.
Plot Recipes
@recipe function f(::Type{Val{:myplotrecipename}}, plt::AbstractPlot; ...) end
- These are called after input data has been processed, but before the plot is created.
- Build layouts, add subplots, and other plot-wide attributes.
- See
marginalhist
in StatsPlots for an example.
Series Recipes
@recipe function f(::Type{Val{:myseriesrecipename}}, x, y, z; ...) end
- These are the last calls to happen. Each backend will support a short list of series types (
path
,shape
,histogram
, etc). If a series type is natively supported, processing is passed (delegated) to the backend. If a series type is not natively supported by the backend, we attempt to call a "series recipe". - Note: If there's no series recipe defined, and the backend doesn't support it, you'll see an error like:
ERROR: The backend must not support the series type Val{:hi}, and there isn't a series recipe defined.
- Note: You must have the
x, y, z
included in the signature, or it won't be processed as a series type!!
Recipe Syntax/Rules
Lets decompose what's happening inside the recipe macro, starting with a simple recipe:
mutable struct MyType end
@recipe function f(::MyType, n::Integer = 10; add_marker = false)
linecolor --> :blue
seriestype := :path
markershape --> (add_marker ? :circle : :none)
delete!(plotattributes, :add_marker)
rand(n)
end
We create a new type MyType
, which is empty, and used purely for dispatch. Our goal here is to create a random path of n
points.
There are a few important things to know, after which recipes boil down to updating an attribute dictionary and returning input data:
- A recipe signature
f(args...; kw...)
is converted into a definition ofapply_recipe(plotattributes::KW, args...)
where:plotattributes
is an attribute dictionary of typetypealias KW Dict{Symbol,Any}
- Your
args
must be distinct enough that dispatch will call your definition (and without masking an existing definition). Using a custom data type will ensure proper dispatch. - The function
f
is unused/meaningless... call it whatever you want.
- The special operator
-->
turnslinecolor --> :blue
intoget!(plotattributes, :linecolor, :blue)
, setting the attribute only when it doesn't already exist. (Tip: Wrap the right hand side in parentheses for complex expressions.) - The special operator
:=
turnsseriestype := :path
intoplotattributes[:seriestype] = :path
, forcing that attribute value. (Tip: Wrap the right hand side in parentheses for complex expressions.) - One cannot use aliases (such as
colour
oralpha
) in a recipe, only the full attribute name. - The return value of the recipe is the
args
of aRecipeData
object, which also has a reference to the attribute dictionary. - A recipe returns a Vector{RecipeData}. We'll see how to add to this list later with the
@series
macro.
Use of the return
keyword in a recipe requires at least RecipesBase 0.9.
Breaking down the example:
In the example above, we use MyType
for dispatch, with optional positional argument n::Integer
:
@recipe function f(::MyType, n::Integer = 10; add_marker = false)
With a call to plot(MyType())
or similar, this recipe will be invoked. If linecolor
has not been set, it is set to :blue
:
linecolor --> :blue
The seriestype
is forced to be :path
:
seriestype := :path
The markershape
is a little more complex; it checks the add_marker
custom keyword, but only if markershape
was not already set. (Note: the add_marker
key is redundant, as the user can just set the marker shape directly... I use it only for demonstration):
markershape --> (add_marker ? :circle : :none)
then return the data to be plotted.
rand(n)
end
Some example usages of our (mostly useless) recipe:
mt = MyType()
plot(
plot(mt),
plot(mt, 100, linecolor = :red),
plot(mt, marker = (:star,20), add_marker = false),
plot(mt, add_marker = true)
)
User Recipes
The example above is an example of a "user recipe", in which you define the full signature for dispatch. User recipes (like others) can be stacked and modular. The following is valid:
@recipe f(mt::MyType, n::Integer = 10) = (mt, rand(n))
@recipe f(mt::MyType, v::AbstractVector) = (seriestype := histogram; v)
Here a call to plot(MyType())
will apply these recipes in order; first mapping mt
to (mt, rand(10))
and then subsequently setting the seriestype to :histogram
.
plot(MyType())
Type Recipes
For some custom data types, they are essentially light wrappers around built-in containers. For example you may have a type:
mutable struct MyWrapper
v::Vector
end
In this case, you'd like your MyWrapper
objects to be treated just like Vectors, but do not wish to subtype AbstractArray. No worries! Just define a type recipe to do the conversion:
@recipe f(::Type{MyWrapper}, mw::MyWrapper) = mw.v
This signature is called on each input when dispatch did not find a suitable recipe for the full args...
. So plot(rand(10), MyWrapper(rand(10)))
will "just work".
Series Recipes
This is where the magic happens. You can create your own custom visualizations for arbitrary data. Quickly define violin plots, error bars, and even standard types like histograms and step plots. A histogram is a bar plot:
@recipe function f(::Type{Val{:histogram}}, x, y, z)
edges, counts = my_hist(y, plotattributes[:bins],
normed = plotattributes[:normalize],
weights = plotattributes[:weights])
x := edges
y := counts
seriestype := :bar
()
end
while a 2D histogram is really a heatmap:
@recipe function f(::Type{Val{:histogram2d}}, x, y, z)
xedges, yedges, counts = my_hist_2d(x, y, plotattributes[:bins],
normed = plotattributes[:normalize],
weights = plotattributes[:weights])
x := centers(xedges)
y := centers(yedges)
z := Surface(counts)
seriestype := :heatmap
()
end
The argument y
is always populated, the argument x
is populated with a call like plot(x,y, seriestype =: histogram2d)
and correspondingly for z
, plot(x,y,z, seriestype =: histogram2d)
See below where I go through a series recipe for creating boxplots. Many of these "standard" recipes are defined in Plots, though they can be defined anywhere without requiring the package to be dependent on Plots.
Case studies
Marginal Histograms
Here we show a user recipe version of the marginalhist
plot recipe for StatsPlots. This is a nice example because, although easy to understand, it utilizes some great Plots features.
Marginal histograms are a visualization comparing two variables. The main plot is a 2D histogram, where each rectangle is a (possibly normalized and weighted) count of data points in that bucket. Above the main plot is a smaller histogram of the first variable, and to the right of the main plot is a histogram of the second variable. The full recipe:
@userplot MarginalHist
@recipe function f(h::MarginalHist)
if length(h.args) != 2 || !(typeof(h.args[1]) <: AbstractVector) ||
!(typeof(h.args[2]) <: AbstractVector)
error("Marginal Histograms should be given two vectors. Got: $(typeof(h.args))")
end
x, y = h.args
# set up the subplots
legend := false
link := :both
framestyle := [:none :axes :none]
grid := false
layout := @layout [tophist _
hist2d{0.9w,0.9h} righthist]
# main histogram2d
@series begin
seriestype := :histogram2d
subplot := 2
x, y
end
# these are common to both marginal histograms
fillcolor := :black
fillalpha := 0.3
linealpha := 0.3
seriestype := :histogram
# upper histogram
@series begin
subplot := 1
x
end
# right histogram
@series begin
orientation := :h
subplot := 3
y
end
end
Usage:
using Distributions
n = 1000
x = rand(Gamma(2), n)
y = -0.5x + randn(n)
marginalhist(x, y, fc = :plasma, bins = 40)
Now I'll go through each section in detail:
The @userplot
macro is a nice convenience for creating a new wrapper for input arguments that can be distinct during dispatch. It also creates lowercase convenience methods (marginalhist
and marginalhist!
) and exports them.
@userplot MarginalHist
thus create a type MarginalHist
for dispatch. An object of type MarginalHist
has the field args
which is the tuple of arguments the plot function is invoked with, which can be either marginalhist(x,y,...)
or plot(x,y, seriestype = :marginalhist)
. The first syntax is a shorthand created by the @userplot
macro.
We dispatch only on the generated type, as the real inputs are wrapped inside it:
@recipe function f(h::MarginalHist)
Some error checking. Note that we're extracting the real inputs (like in a call to marginalhist(randn(100), randn(100))
) into x
and y
:
if length(h.args) != 2 || !(typeof(h.args[1]) <: AbstractVector) ||
!(typeof(h.args[2]) <: AbstractVector)
error("Marginal Histograms should be given two vectors. Got: $(typeof(h.args))")
end
x, y = h.args
Next we build the subplot layout and define some attributes. A few things to note:
- The layout creates three subplots (
_
is left blank) - Attributes are mapped to each subplot when passed in as a matrix (row-vector)
- The attribute
link := :both
means that the y-axes of each row (and x-axes of each column) will share data extrema. Other values include:x
,:y
,:all
, and:none
.
# set up the subplots
legend := false
link := :both
framestyle := [:none :axes :none]
grid := false
layout := @layout [tophist _
hist2d{0.9w,0.9h} righthist]
Define the series of the main plot. The @series
macro makes a local copy of the attribute dictionary plotattributes
using a "let block". The copied dictionary and the returned args are added to the Vector{RecipeData}
which is returned from the recipe. This block is similar to calling histogram2d!(x, y; subplot = 2, plotattributes...)
(but you wouldn't actually want to do that).
Note: this @series
block gets a "snapshot" of the attributes, so it contains anything that was set before this block, but nothing from after it. @series
blocks can be standalone, as these are, or they can be in a loop.
# main histogram2d
@series begin
seriestype := :histogram2d
subplot := 2
x, y
end
Next we move on to the marginal plots. We first set attributes which are shared by both:
# these are common to both marginal histograms
fillcolor := :black
fillalpha := 0.3
linealpha := 0.3
seriestype := :histogram
Now we create two more series, one for each histogram.
# upper histogram
@series begin
subplot := 1
x
end
# right histogram
@series begin
orientation := :h
subplot := 3
y
end
end
It's important to note: normally we would return arguments from a recipe, and those arguments would be added to a RecipeData
object and pushed onto our Vector{RecipeData}
. However, when creating series using the @series
macro, you have the option of returning nothing
, which will bypass that last step.
One can also have multiple series in a single subplot and repeat the same for multiple subplots if needed. This would require one to supply the correct subplot id/number.
mutable struct SeriesRange
range::UnitRange{Int64}
end
@recipe function f(m::SeriesRange)
range = m.range
layout := length(range)
for i in range
@series begin
subplot := i
seriestype := scatter
rand(10)
end
@series begin
subplot := i
rand(10)
end
end
end
Documenting plot functions
A documentation string added above the recipe definition will have no effect, just like the function name is meaningless. Since everything in Julia can be associated with a doc-string, the documentation can be added to the name of the plot function like this
"""
My docstring
"""
my_plotfunc
This can be put anywhere in the code and will appear on the call ?my_plotfunc
.
Troubleshooting
It can sometimes be helpful when debugging recipes to see the order of dispatch inside the apply_recipe
calls. Turn on debugging info with:
RecipesBase.debug()
You can also pass a Bool
to the debug
method to turn it on/off.
Here are some common errors, and what to look out for:
convertToAnyVector
ERROR: In convertToAnyVector, could not handle the argument types: <<some type>>
[inlined code] from ~/.julia/v0.4/Plots/src/series_new.jl:87
in apply_recipe at ~/.julia/v0.4/RecipesBase/src/RecipesBase.jl:237
in _plot! at ~/.julia/v0.4/Plots/src/plot.jl:312
in plot at ~/.julia/v0.4/Plots/src/plot.jl:52
This error occurs when the input types could not be handled by a recipe. The type <<some type>>
cannot be processed. Remember, there may be recursive calls to multiple recipes for a complicated plot.
MethodError: start
has no method matching start(::Void)
ERROR: MethodError: `start` has no method matching start(::Void)
in collect at ./array.jl:260
in collect at ./array.jl:272
in plotly_series at ~/.julia/v0.4/Plots/src/backends/plotly.jl:345
in _series_added at ~/.julia/v0.4/Plots/src/backends/plotlyjs.jl:36
in _apply_series_recipe at ~/.julia/v0.4/Plots/src/plot.jl:224
in _plot! at ~/.julia/v0.4/Plots/src/plot.jl:537
This error is commonly encountered when a series type expects data for x
, y
, or z
, but instead was passed nothing
(which is of type Void
). Check that you have a z
value defined for 3D plots, and likewise that you have valid values for x
and y
. This could also apply to attributes like fillrange
, marker_z
, or line_z
if they are expected to have non-void values.
MethodError: Cannot convert
an object of type Float64 to an object of type RecipeData
ERROR: MethodError: Cannot `convert` an object of type Float64 to an object of type RecipeData
Closest candidates are:
convert(::Type{T}, ::T) where T at essentials.jl:171
RecipeData(::Any, ::Any) at ~/.julia/packages/RecipesBase/G4s6f/src/RecipesBase.jl:57
Use of the return
keyword in recipes requires RecipesBase 0.9
This error is encountered if you use the return
keyword in a recipe, which is not supported in RecipesBase up to v0.8.