Шаблоны

Шаблоны (Recipes) позволяют определять визуализации в ваших пакетах и коде независимо от Plots. Функциональность основана на RecipesBase, упрощенном, но мощном пакете для создания расширенной логики построения графиков без использования Plots. Макрос @recipe в RecipesBase добавит определение метода для RecipesBase.apply_recipe. Plots добавляет в функцию и вызывает эту же функцию, и, таким образом, ваш пакет и Plots могут взаимодействовать, не зная друг о друге. Чудо!

Визуализация пользовательских типов всегда была сложной задачей. Должен ли разработчик пакета добавлять зависимость от пакета построения графиков (навязывая значительный багаж, связанный с этой зависимостью)? Должен ли он пытаться использовать условные зависимости? Должен ли он отправлять запросы на вытягивание в графические пакеты для определения пользовательских визуализаций? Похоже, что на каждый вариант «за» приходилось много «против», и решение было непростым. С появлением шаблонов эти проблемы исчезают. Один небольшой пакет (RecipesBase) предоставляет простые процедуры для работы с конвейером визуализации, позволяя пользователям и разработчикам пакетов сосредоточиться исключительно на специфике своей визуализации. Выберите фигуры/линии/цвета, которые будут хорошо представлять ваши данные, определите пользовательские значения по умолчанию и преобразуйте входные данные (если это необходимо). Всем остальным займется Plots. Доступно множество примеров шаблонов как внутри Plots, так и во многих внешних пакетах, включая GraphRecipes.

Визуализация пользовательских типов

Примеры всегда лучше всего. Рассмотрим реализацию создания шаблонов визуализации для распределений.

Пользовательская обработка комбинаций входных данных

Хотите сделать что-то особенное, когда первыми входными данными является временной ряд? Может быть, вы хотите предварительно обработать данные в зависимости от флагов ключевых слов? Все это возможно за счет создания шаблонов с уникальными сигнатурами диспетчеризации. Вы можете выгружать данные и использовать предварительную и последующую обработку Plots, а также добавлять только те элементы, которые необходимы именно вам.

Шаблоны типов: простая замена типов данных

Часто тип данных представляет собой простую оболочку функции или массива. Например:

mutable struct MyVec
    v::Vector{Int}
end

Если бы MyVec был подтипом AbstractVector, ничего бы делать не пришлось. Он должен был бы «просто работать». Однако это не всегда желательно, и было бы неплохо, если бы можно было вызывать plot(10:20, myvec) без необходимости лично определять все возможные комбинации входных данных. В этом случае необходимо использовать специальный тип сигнатуры шаблона:

@recipe f(::Type{MyVec}, myvec::MyVec) = myvec.v

После этого все команды построения графиков, работающие для векторов, будут работать и для вашего типа данных.

Шаблоны рядов

Кратко остановимся на одном из основных элементов визуализации данных — гистограмме. Хэдли Уикхэм (Hadley Wickham) исследовал природу гистограмм в рамках своей работы Layered Grammar of Graphics (Многоуровневая грамматика графики). В ней он рассказывает о том, что гистограмма — это не что иное, как столбчатый график, данные в котором предварительно сгруппированы. Это правда, и этот момент можно развивать дальше. Столбчатый график является расширением ступенчатого графика, в котором нули переплетаются между значениями x. Ступенчатый график — это не что иное, как путь (линия), который может проходить только по горизонтали или вертикали. Конечно, аналогичное разложение можно получить, рассматривая столбцы как заполненные многоугольники.

Смысл в том, что графический пакет должен уметь строить только линии и многоугольники, а они могут поддерживать построение гистограммы. Путь от данных к гистограмме обычно очень сложен, но этих сложностей можно избежать и определить шаблон преобразования гистограммы в составные части. В нескольких строках читаемого кода можно реализовать ключевую статистическую визуализацию. Чтобы лучше понять, как можно использовать шаблоны, см. руководство по шаблонам рядов.

Типы шаблонов

Выше были описаны Type recipes и Series Recipes. Всего в Plots существует четыре основных типа шаблонов (перечислены в порядке их обработки):

  • Пользовательские шаблоны

  • Шаблоны типов

  • Шаблоны графиков

  • Шаблоны рядов

Тип шаблона полностью определяется сигнатурой диспетчеризации. Каждый тип шаблона вызывается из разной части конвейера построения графиков, поэтому вы будете выбирать тип шаблона в соответствии с тем, какой объем обработки должен быть завершен перед применением шаблона.

Это сигнатуры диспетчеризации для каждого типа (обратите внимание, что большинство из них могут принимать позиционные или именованные аргументы, обозначаемые ...):

Пользовательские шаблоны

@recipe function f(custom_arg_1::T, custom_arg_2::S, ...; ...) end
  • Используются для обработки уникального набора типов на ранних этапах в конвейере. Хорошо подходят для определяемых пользователем типов или специальных комбинаций типов Base.

  • Макрос @userplot представляет собой полезное средство, которое и определяет новый тип (для обеспечения корректной диспетчеризации), и экспортирует сокращения.

  • См. пример graphplot.

Шаблоны типов

@recipe function f(::Type{T}, val::T) where{T} end
  • Для определяемых пользователем типов, которые являются оболочкой или имеют взаимно-однозначное сопоставление типов, поддерживаемых Plots, достаточно определить метод преобразования.

  • Примечание: это фактически означает, что «когда вы видите тип T, замените его этим…​».

  • См. пример SymPy.

Шаблоны графиков

@recipe function f(::Type{Val{:myplotrecipename}}, plt::AbstractPlot; ...) end
  • Они вызываются после обработки входных данных, но до построения графика.

  • Используются для создания макетов, добавления подграфиков и других атрибутов графиков.

  • См. пример marginalhist в StatsPlots.

Шаблоны рядов

@recipe function f(::Type{Val{:myseriesrecipename}}, x, y, z; ...) end
  • Они вызываются последними. Каждый бэкенд будет поддерживать короткий список типов рядов (path, shape, histogram и т. д.). Если тип ряда имеет встроенную поддержку, обработка передается (делегируется) бэкенду. Если тип ряда не имеет встроенной поддержки бэкендом, мы пытаемся вызвать «шаблон ряда».

  • Примечание. Если шаблон ряда не определен и бэкенд его не поддерживает, будет выдана ошибка: ERROR: The backend must not support the series type Val{:hi}, and there isn’t a series recipe defined.

  • Примечание. В сигнатуре обязательно должен присутствовать x, y, z, иначе она не будет обработана как тип ряда.

Синтаксис и правила для шаблонов

Давайте разберем, что происходит внутри макроса шаблона, начав с простого шаблона:

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

Мы создаем новый тип MyType, который является пустым и используется исключительно для диспетчеризации. Наша цель — построить случайный путь из n точек.

Необходимо знать несколько важных моментов, после чего шаблоны сводятся к обновлению словаря атрибутов и возврату входных данных.

  • Сигнатура шаблона f(args...; kw...) преобразуется в определение apply_recipe(plotattributes::KW, args...), где:

    • plotattributes — это словарь атрибутов типа typealias KW Dict{Symbol,Any}

    • args должны быть различными, чтобы диспетчеризация вызывала определение (и без маскировки существующего определения). Использование пользовательского типа данных обеспечит правильную диспетчеризацию.

    • Функция f является неиспользуемой и бессмысленной. Вызывайте ее как хотите.

  • Специальный оператор --> преобразует linecolor --> :blue в get!(plotattributes, :linecolor, :blue), задавая атрибут только в том случае, если он еще не существует. (Совет: Для сложных выражений заключите правую часть в круглые скобки.)

  • Специальный оператор := преобразует seriestype := :path в plotattributes[:seriestype] = :path, принудительно задавая это значение атрибута. (Совет: Для сложных выражений заключите правую часть в круглые скобки.)

  • В шаблоне нельзя использовать псевдонимы (например, colour или alpha), только полное имя атрибута.

  • Возвращаемым значением шаблона являются аргументы (args) объекта RecipeData, который также имеет ссылку на словарь атрибутов.

  • Шаблон возвращает Vector{RecipeData}. Позже мы рассмотрим, как дополнить этот список с помощью макроса @series.

Совместимость: RecipesBase 0.9

Для использования ключевого слова return в шаблоне требуется версия не ниже RecipesBase 0.9.

Разбираем пример.

В приведенном примере для диспетчеризации используется MyType с необязательным позиционным аргументом n::Integer:

@recipe function f(::MyType, n::Integer = 10; add_marker = false)

При вызове функции plot(MyType()) или аналогичной ею будет получен этот шаблон. Если для linecolor не было указано значение, задается :blue:

    linecolor   --> :blue

Для seriestype задается значение :path:

    seriestype  :=  :path

markershape немного сложнее; он проверяет пользовательское ключевое слово add_marker, но только в том случае, если markershape еще не был задан. (Примечание: Ключ add_marker является лишним, так как пользователь может просто задать форму маркера напрямую. Он используется только в демонстрационных целях.)

    markershape --> (add_marker ? :circle : :none)

Затем возвращаются данные для построения графика.

    rand(n)
end

Далее приводятся некоторые примеры использования нашего (в основном бесполезного) шаблона.

mt = MyType()
plot(
    plot(mt),
    plot(mt, 100, linecolor = :red),
    plot(mt, marker = (:star,20), add_marker = false),
    plot(mt, add_marker = true)
)


Пользовательские шаблоны

Приведенный пример является примером пользовательского шаблона, в котором определяется полная сигнатура для диспетчеризации. Пользовательские шаблоны (как и другие) могут быть модульными или многоуровневыми. Ниже приведен допустимый вариант.

@recipe f(mt::MyType, n::Integer = 10) = (mt, rand(n))
@recipe f(mt::MyType, v::AbstractVector) = (seriestype := histogram; v)

Здесь вызов plot(MyType()) будет применять эти шаблоны по порядку: сначала сопоставляя mt с (mt, rand(10)), а затем задавая для типа ряда значение :histogram.

plot(MyType())


Шаблоны типов

Для некоторых пользовательских типов данных они, по сути, являются простыми оболочками вокруг встроенных контейнеров. Например, у вас может быть тип:

mutable struct MyWrapper
    v::Vector
end

В этом случае вы хотели бы, чтобы ваши объекты MyWrapper рассматривались так же, как векторы, но не как подтип AbstractArray. Не беспокойтесь! Просто определите шаблон типа, который будет выполнять преобразование:

@recipe f(::Type{MyWrapper}, mw::MyWrapper) = mw.v

Эта сигнатура вызывается для каждых входных данных, когда при диспетчеризации не удалось найти подходящего шаблона для полного набора аргументов args.... Поэтому plot(rand(10), MyWrapper(rand(10))) будет просто работать.


Шаблоны рядов

Здесь происходит что-то особенное. Вы можете создавать собственные визуализации для произвольных данных. Быстро определяйте графики типа «Скрипка», полосы погрешностей и даже стандартные типы, такие как гистограммы и ступенчатые графики. Гистограмма — это график со столбцами:

@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

тогда как двухмерная гистограмма на самом деле является тепловой картой:

@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

Аргумент y заполняется всегда, аргумент x заполняется при вызове типа plot(x,y, seriestype =: histogram2d) и, соответственно, для z, plot(x,y,z, seriestype =: histogram2d).

Ниже рассматривается шаблон рядов для создания коробчатых диаграмм. Многие из этих стандартных шаблонов определены в Plots, хотя они могут быть определены где угодно, не требуя зависимости пакета от Plots.


Практические примеры

Граничные гистограммы

Здесь представлена версия пользовательского шаблона построения графика marginalhist для StatsPlots. Этот пример хорош тем, что, несмотря на простоту понимания, в нем используются некоторые замечательные возможности Plots.

Граничные гистограммы представляют собой визуализацию, сравнивающую две переменные. Основной график имеет вид двухмерной гистограммы, где каждый прямоугольник является (возможно, нормированным и взвешенным) количеством точек данных в данном контейнере. Над основным графиком находится небольшая гистограмма первой переменной, а справа от основного графика — гистограмма второй переменной. Полный шаблон имеет следующий вид:

@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

    # настройка подграфиков
    legend := false
    link := :both
    framestyle := [:none :axes :none]
    grid := false
    layout := @layout [tophist           _
                       hist2d{0.9w,0.9h} righthist]

    # основная двухмерная гистограмма
    @series begin
        seriestype := :histogram2d
        subplot := 2
        x, y
    end

    # они являются общими для обеих граничных гистограмм
    fillcolor := :black
    fillalpha := 0.3
    linealpha := 0.3
    seriestype := :histogram

    # верхняя гистограмма
    @series begin
        subplot := 1
        x
    end

    # правая гистограмма
    @series begin
        orientation := :h
        subplot := 3
        y
    end
end

Использование:

using Distributions
n = 1000
x = rand(Gamma(2), n)
y = -0.5x + randn(n)
marginalhist(x, y, fc = :plasma, bins = 40)


Рассмотрим каждый раздел более подробно.

Макрос @userplot удобно использовать для создания новой оболочки для входных аргументов, которые могут быть разными при диспетчеризации. Он также создает вспомогательные методы в нижнем регистре (marginalhist и marginalhist!) и экспортирует их.

@userplot MarginalHist

таким образом создается тип MarginalHist для диспетчеризации. Объект типа MarginalHist имеет поле args, представляющее собой кортеж аргументов, с которыми вызывается функция построения графиков — либо marginalhist(x,y,...), либо plot(x,y, seriestype = :marginalhist). Первый синтаксис представляет собой сокращение, созданное макросом @userplot.

Мы диспетчеризируем только сгенерированный тип, поскольку он является оболочкой реальных входных данных:

@recipe function f(h::MarginalHist)

Проверка ошибок. Обратите внимание, что мы извлекаем реальные входные данные (как при вызове marginalhist(randn(100), randn(100))) в x и 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

Далее мы строим макет подграфика и определяем некоторые атрибуты. Обратите внимание на несколько приведенных ниже моментов.

  • Макет создает три подграфика (_ остается пустым).

  • Атрибуты сопоставляются с каждым подграфиком при передаче в виде матрицы (вектор-строка).

  • Атрибут link := :both означает, что оси y каждой строки (и оси x каждого столбца) будут иметь общие экстремумы данных. Другие значения: :x, :y, :all и :none.

    # настройка подграфиков
    legend := false
    link := :both
    framestyle := [:none :axes :none]
    grid := false
    layout := @layout [tophist           _
                       hist2d{0.9w,0.9h} righthist]

Определим ряды основного графика. Макрос @series создает локальную копию словаря атрибутов plotattributes с помощью «блока let». Скопированный словарь и возвращаемые аргументы добавляются в Vector{RecipeData}, который возвращается из шаблона. Этот блок аналогичен вызову histogram2d!(x, y; subplot = 2, plotattributes...) (но на самом деле это делать не нужно).

Примечание: этот блок @series получает «снимок» атрибутов, поэтому он содержит все, что было задано до этого блока, но ничего из того, что было задано после него. Блоки @series могут быть автономными, как эти, или находиться в цикле.

    # основная двухмерная гистограмма
    @series begin
        seriestype := :histogram2d
        subplot := 2
        x, y
    end

Далее переходим к граничным графикам. Сначала задаем атрибуты, которые являются общими для обоих:

    # они являются общими для обеих граничных гистограмм
    fillcolor := :black
    fillalpha := 0.3
    linealpha := 0.3
    seriestype := :histogram

Теперь создадим еще два ряда, по одному для каждой гистограммы.

    # верхняя гистограмма
    @series begin
        subplot := 1
        x
    end

    # правая гистограмма
    @series begin
        orientation := :h
        subplot := 3
        y
    end
end

Важно отметить, что обычно аргументы возвращаются из шаблона, и эти аргументы добавляются в объект RecipeData и отправляются в Vector{RecipeData}. Однако при создании рядов с помощью макроса @series есть возможность возвратить nothing, что позволит обойти этот последний шаг.

Можно также разместить несколько рядов в одном подграфике и при необходимости повторить то же самое для нескольких подграфиков. Для этого потребуется указать правильный идентификатор или номер подграфика.

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

Документирование функций построения графиков

Строка документации, добавленная над определением шаблона, не будет иметь никакого эффекта, так же как и имя функции не имеет смысла. Поскольку в Julia все может быть связано с docstring, документацию можно добавить к имени функции построения графика следующим образом:

"""
My docstring
"""
my_plotfunc

Этот фрагмент можно поместить в любое место кода, и он появится при вызове ?my_plotfunc.


Устранение неполадок

При отладке шаблонов иногда бывает полезно просмотреть порядок диспетчеризации внутри вызовов apply_recipe. Включите отладочную информацию:

RecipesBase.debug()

Можно также передать значение Bool методу debug для включения или отключения сведений.

Вот некоторые распространенные ошибки и то, на что следует обратить внимание.

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

Эта ошибка возникает, когда типы входных данных не могут быть обработаны шаблоном. Тип [some type] не может быть обработан. Помните, что в сложном графике могут быть рекурсивные обращения к нескольким шаблонам.

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

Эта ошибка часто возникает, когда тип ряда ожидает данные для x, y или z, но вместо этого было передано значение nothing (которое имеет тип Void). Убедитесь, что для трехмерных графиков задано значение z, а также что для x и y заданы допустимые значения. Это также может относиться к таким атрибутам, как fillrange, marker_z или line_z, если ожидается, что они будут иметь непустые значения.

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
Совместимость: RecipesBase 0.9

Для использования ключевого слова return в шаблонах требуется версия не ниже RecipesBase 0.9.

Эта ошибка возникает при использовании в шаблоне ключевого слова return, которое не поддерживается в версиях RecipesBase, предшествующих 0.8.