Философия ggplot2 (A Layered Grammar of Graphics)

Идея, воплолщенная в ggplot2, восходит к работе L. Wilkinson «The Grammar of Graphics». Базируясь на идеях, изложенных в этой работе, Hadley Wickham рахзработал концепцию Layered Grammar of Graphics и создал мощный пакет для визуализации, ради которого мы все здесь собрались. Автором по этому пакету написана целая книга, но мы сосредоточимся на основных смысловых и ключевых моментах, которые необходимы, чтобы сделать что-то крутое.

Часто возникает вопрос: почему 2? Ответ примерно такой: был и первый ggplot, но попытка не задалась от слова совсем, и пришлось все переделать.

Собственно к философии построения графиков

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

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

Но — хватит слов! Поехали уже рисовать уже!

Подгружаем данные

Работать мы будем в пакете tidyverse, частью которого, собственно, и является ggplot2. Чтобы в этом приуспеть, надо его себе на машину поставить:

install.packages('tidyverse', dependencies = TRUE)

Он поставит кучу зависимых — это ок, так нам и надо. Подгружаем к сессии:

library(tidyverse)

Подгружаем данные:

share <- read_csv('https://raw.githubusercontent.com/angelgardt/mk_ggplot2/master/sharexp_data.csv')
## Parsed with column specification:
## cols(
##   .default = col_double(),
##   trialtype = col_character(),
##   platform = col_character()
## )
## See spec(...) for full column specifications.
str(share)
## Classes 'spec_tbl_df', 'tbl_df', 'tbl' and 'data.frame': 16200 obs. of  22 variables:
##  $ trialtype: chr  "tray" "tray" "tray" "tray" ...
##  $ setsize  : num  8 8 8 8 8 8 8 8 8 8 ...
##  $ time1    : num  1.67 1.13 2.6 2.61 1.59 ...
##  $ click1x  : num  -227 -69 60 199 -241 -51 99 213 -201 -70 ...
##  $ click1y  : num  202 231 195 213 43 59 62 46 -123 -82 ...
##  $ time2    : num  1.28 1.061 0.963 0.863 0.931 ...
##  $ click2x  : num  14 -44 17 -26 -25 10 -29 -27 -25 -19 ...
##  $ click2y  : num  -351 -392 -361 -356 -397 -383 -372 -353 -385 -394 ...
##  $ id       : num  1 1 1 1 1 1 1 1 1 1 ...
##  $ platform : chr  "ios" "ios" "ios" "ios" ...
##  $ posx1    : num  -238 -63 66 203 -243 -60 73 213 -229 -84 ...
##  $ posy1    : num  202 226 217 218 59 90 66 52 -93 -79 ...
##  $ posxmin1 : num  -313 -138 -9 128 -318 -135 -2 138 -304 -159 ...
##  $ posxmax1 : num  -163 12 141 278 -168 15 148 288 -154 -9 ...
##  $ posymin1 : num  127 151 142 143 -16 15 -9 -23 -168 -154 ...
##  $ posymax1 : num  277 301 292 293 134 165 141 127 -18 -4 ...
##  $ posx2    : num  450 450 450 450 450 450 450 450 450 450 ...
##  $ posy2    : num  -350 -350 -350 -350 -350 -350 -350 -350 -350 -350 ...
##  $ posxmin2 : num  350 350 350 350 350 350 350 350 350 350 ...
##  $ posxmax2 : num  550 550 550 550 550 550 550 550 550 550 ...
##  $ posymin2 : num  -425 -425 -425 -425 -425 -425 -425 -425 -425 -425 ...
##  $ posymax2 : num  -275 -275 -275 -275 -275 -275 -275 -275 -275 -275 ...
##  - attr(*, "spec")=
##   .. cols(
##   ..   trialtype = col_character(),
##   ..   setsize = col_double(),
##   ..   time1 = col_double(),
##   ..   click1x = col_double(),
##   ..   click1y = col_double(),
##   ..   time2 = col_double(),
##   ..   click2x = col_double(),
##   ..   click2y = col_double(),
##   ..   id = col_double(),
##   ..   platform = col_character(),
##   ..   posx1 = col_double(),
##   ..   posy1 = col_double(),
##   ..   posxmin1 = col_double(),
##   ..   posxmax1 = col_double(),
##   ..   posymin1 = col_double(),
##   ..   posymax1 = col_double(),
##   ..   posx2 = col_double(),
##   ..   posy2 = col_double(),
##   ..   posxmin2 = col_double(),
##   ..   posxmax2 = col_double(),
##   ..   posymin2 = col_double(),
##   ..   posymax2 = col_double()
##   .. )

Это данные поведенческого эксперимента, в котором пользователи Android и iOS искали иконки «share» обеих платформ среди универсальных иконок. Короче, зрительный поиск.

Нас будут интересовать следующие переменные:

Строим базовый график

Базовый слой

Первое, что мы делаем, когда собираемся что-либо рисовать — берем холст.

Аналогично, когда мы собираем рисовать график с использованием ggplot2, первое, что мы делаем — говорим «Дай мне холст!». На языке ggplot2 это делается с помощью команды ggplot().

ggplot()

И, о Боже, ggplot2 дал нам холст! Иначе говоря, мы построили базовый слой, на который в дальнейшем будем набрасывать элементы нашего графика.

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

ggplot(data = share)

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

Разметка осей и переменные

Важнейшие элементы любого графика — это оси. Мы строим двумерные графики, поэтому и оси у нас две — как учили в школе, x (горизонтальная ось, ось абсцисс) и y (вертикальная ось, ось ординат).

Чтобы задать оси графика потребуется отдельная функция. Она называется aes(), и в общем задает эстетики графика.

Эстетики

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

Вот список эстетик, которые используются чаще всего: x, y, color, fill, shape, size.

Несложно догадаться, что переменные по осям задаются параметрами x и y. Что ж, зададим.

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

ggplot(data = share, aes(x = time1, y = time2))

Так, ну, допустим… А где картинка?

Картинки нет, но ggplot2 честно отработал свою работу. Мы задали только оси — и он нам разметил их в соответствии с имеющимися в векторах значениях. Больше мы ему ничего не написали. Чтобы всё-таки получить картинку, необходимо указать, как мы хотим отборазим наши переменные.

Геомы

За то, каким образом будут отображены переменными, а конкретно, какими «геометрическими объектами», отвечает семество функций geom_*. Когда мы переходим к этой функции, мы переходим на новый слой. Чтобы это обозначить используется плюсик +.

ggplot(data = share, aes(x = time1, y = time2)) +
  geom_point()

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

Но, вообще-то, можно и получше отобразить закономерность. Как минимум, добавить линию тренда в помощью специального геома:

ggplot(data = share, aes(x = time1, y = time2)) +
  geom_point() +
  geom_smooth()
## `geom_smooth()` using method = 'gam' and formula 'y ~ s(x, bs = "cs")'

Как видите, при добавлении нового «геометрического» способа отображения данных мы добавляем новый слой.

Сейчас мы гораздо отчетливее видим, что тренда собственно никакого и нет. Но мы можем визуализировать ещё более явно. Так как geom_smooth() подразумевает «склаживание», оно может происходить с помощью разных методов (используемый метод нам написали в консоль). Мы можем эскплицинто указать метод. Например, использовать линейную модель:

ggplot(data = share, aes(x = time1, y = time2)) +
  geom_point() +
  geom_smooth(method = 'lm')

И — ха! — какая-то минимальная связь вроде как есть! Хотя возможно это влияние выбросов (помним, что мы не чистили данные)…

Группировка по переменной

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

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

ggplot(data = share, aes(x = time1, y = time2, color = platform)) +
  geom_point() +
  geom_smooth(method = 'lm')

Стало видно, что, оказывается, зависимость в двух группах разная (обратите внимание на доверительные зоны линии тренда). Но в левом нижнем углу графика происходит какой-то ад — все смешалось, наложилось, ничего не разобрать. Это можно поправить с помощью аргумента, задающего прозрачность (alpha).

ggplot(data = share, aes(x = time1, y = time2, color = platform)) +
  geom_point(alpha = .3) +
  geom_smooth(method = 'lm')

Это не сильно помогло, потому что точек там охренеть как сколько, но как прицнипиальный способ борьбы с «непонятностью» может хорошо работать.

Для группировки можно задать и другую эстетику, например форму точек:

ggplot(data = share, aes(x = time1, y = time2, shape = platform)) +
  geom_point(alpha = .3) +
  geom_smooth(method = 'lm')

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

Графики со встроенной статистической обработкой

Часто бывает так, что мы хотим отобразить не сырые данные, а какие-то посчитанные статистики. Поэтому необходимо сначала предобрадотать данные, получить наобходимые значения и затем на их основе построить график.

Но зачем? Если можно сразу в коде построения графика рассчитать все, что нам нужно! В ggplot2 уже встроены инструменты простейшей статистический обработки!

Еще раз посмотрим на датасет. У нас есть время первого клика, а также три условия сетсайзов. Наверняка, среднее время реакции (первого клика) будет различаться в этих трех условиях. Давайте это проверим! Но перед этим нам необходимо познакомиться с новым семейством функций stat_*.

Статы (stat_)

Статы и есть те самые встроенные инструенты статистической обработки. Они позволяют прямо в коде графика обсчитать данные и сразу же визуализировать результаты.

На самом деле, мы уже сталкивались со встроенными инструментами статистической обработки, ведь что делает geom_smooth(method = 'lm')? Не что иное, как визуализирует линейную регрессию, построенную на выбранных данных!

Наиболее популярная функция из рассматриваемого семейста — stat_summary(). С помощью неё мы и будем визуализировать наши средние.

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

str(share$setsize)
##  num [1:16200] 8 8 8 8 8 8 8 8 8 8 ...

Видим, что это вектор типа numeric и в нем 16200 наблюдений. Нас такая ситуация не устраивает: нам нужен тип factor. Чтобы не перезававать при каждом построении графика, перекодируем переменную (и другие, которые нам пригодятся далее) в требуемый вид (используем пакет dplyr, встроенный в tidyverse).

share %>% mutate(setsize = as.factor(setsize), # перекодирование в фактор
                 platform = as.factor(platform), # перекодирование в фактор
                 trialtype = as.factor(trialtype), # перекодирование в фактор
                 id = as.factor(id), # перекодирование в фактор
                 time1 = time1 * 1000) -> share # перевод в миллисекунды и запись в датафрейм

Теперь можем приступить к построению графика. Начнем с базового слоя:

ggplot(share, aes(setsize, time1))

Взяли холст, расчертили. По оси x у нас будет идти факторная переменная. Теперь добавим средние. Как и полагается, на новый слой.

ggplot(share, aes(setsize, time1)) +
  stat_summary(fun.y = mean, geom = 'point')

Разберемся, что тут написано. Первый аргумент (fun.y) принимает функцию, результат которой будет отложен по оси y. В нашем случае это среднее (mean). Она будет применена к переменой time1, причем наблюдения будут автоматически сгруппированы по интересующим нас группам. Второй аргумент — это уже знакомый нам geom, которые отвечает за то, как «геометрически» будут отрисованы знаечния на графике. Наш выбор — точки. Как результат мы наблюдаем то, то хотели.

Однако как мы знаем из статистики, чтобы узнать, есть ли различия между условиями, нам недостаточно только средних значений — необходимы доверительные интервалы. Что ж, отобразим и их.

Добавим новый слой с помощью всё той же функции stat_summary, но на этот раз она будет выглядеть немного по-другому:

ggplot(share, aes(setsize, time1)) +
  stat_summary(fun.y = mean, geom = 'point') +
  stat_summary(fun.data = mean_cl_boot, geom = 'errorbar')

Как мы видим, немного изменился первый аргумент. Это связано с изменением геома. Для отображения доверительных интервалов нам нужен геом errorbar, который требует не одно значение, а два — верхнюю и нижнюю границу доверительного интервала. То есть fun.data принимает как аргумент что-то типа мини-датафрейма — как раз в таком формате и возвращается результат функции mean_cl_boot. Можно посмотреть на её работу отдельно:

mean_cl_boot(share$time1)
##          y     ymin     ymax
## 1 1599.066 1586.018 1613.213

Собственно, вот он датафрейм из одной строки. Здесь три значения, но errorbar игронирует первое (оно и его среднее значение) и использует только второе и третье, строя по ним «усы».

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

ggplot(share, aes(setsize, time1)) +
  stat_summary(fun.y = mean, geom = 'line') +
  stat_summary(fun.y = mean, geom = 'point') +
  stat_summary(fun.data = mean_cl_boot, geom = 'errorbar')
## geom_path: Each group consists of only one observation. Do you need to
## adjust the group aesthetic?

Ага, вроде слой добавили, но ничего не изменилось. Еще и warning вылетел. Надо почитать!

ggplot2 на говорит, что каждая группа у нас содержит одно наблюдение — и он категорически прав, ведь у нас в каждой группе отображается только среднее значение. Из-за этого он не понимает, как ему нужно соединять точки. Надо ему подсказать, что с точки зрения соединения точек у нас всего одна группа, так как мы хотим, чтобы наши среднии были последовательно соединены. Так и запишем (используя аргумент group):

ggplot(share, aes(setsize, time1, group = 1)) +
  stat_summary(fun.y = mean, geom = 'point') +
  stat_summary(fun.data = mean_cl_boot, geom = 'errorbar') +
  stat_summary(fun.y = mean, geom = 'line')

Вот такая у нас классная линейная закономерность получилась! Ну, а чего мы ждали — зрительный поиск же…

Настраиваем график

Как упоминалось выше, ggplot2 имеет широчайшие возможности кастомизации, в нем можно настроить чуть более, чем всё. Сейчас наш график выглядит хотя и содержательно верно, но с точки зрения дизайна — ну такое. Давайте подправим.

Первое, что бросается в глаза — несообразно огромной ширины errorbar’ы. Значит, надо уменьшить ширину. За их ширину отвечает параметр width. Ширина задается долями единицы.

ggplot(share, aes(setsize, time1, group = 1)) +
  stat_summary(fun.y = mean, geom = 'point') +
  stat_summary(fun.data = mean_cl_boot, geom = 'errorbar', width = .1) +
  stat_summary(fun.y = mean, geom = 'line')

Вот так вроде хорошо, линии errorbar’ов не пересекаются с линией, отображающей общую закономерность.

Что ты еще хотелось подправить? Наверное, сделать акцент на главных смысловых элементах графика. В нашем случае это точки. Давайте сделаем их побольше.

ggplot(share, aes(setsize, time1, group = 1)) +
  stat_summary(fun.y = mean, geom = 'point', size = 3) +
  stat_summary(fun.data = mean_cl_boot, geom = 'errorbar', width = .1) +
  stat_summary(fun.y = mean, geom = 'line')

Вот, почти совсем хорошо. Но все-таки «линия тренда» (назовем её так, хотя это не совсем корректно) отвлекает внимание. Она здесь не самая важная. Поработаем с ней. Изменим начертание (linetype) и покрасим её в темносерый.

ggplot(share, aes(setsize, time1, group = 1)) +
  stat_summary(fun.y = mean, geom = 'point', size = 3) +
  stat_summary(fun.data = mean_cl_boot, geom = 'errorbar', width = .1) +
  stat_summary(fun.y = mean, geom = 'line', linetype = 'dashed', color = 'gray30')

Заметьте, мы задали цвет внутри функции stat_summary, хотя ранее указывали его внтури функции aes(). Еще раз напомним, что внутри aes() задается форматирование, связанное с данными — поэтому когда мы группировали наблюдения с помощью цвета, мы задавали через функцию эстетик. В данном же примере мы задали цвет как бы «извне», он не связан с данными, поэтому мы задаем его вне функции aes().

Все хорошо — да не очень! Теперь серая линия лежит поверх точек. Некрасиво… :( Почему так произошло? Потому что ggplot2 выполняет команды последовательно как написали: базовый слой → точки → errorbar’ы → линия. Чтобы поправить этот баг, надо просто переставить строчки местами: базовый слой → линия → errorbar’ы → точки

ggplot(share, aes(setsize, time1, group = 1)) +
    stat_summary(fun.y = mean, geom = 'line', linetype = 'dashed', color = 'gray30') +
  stat_summary(fun.data = mean_cl_boot, geom = 'errorbar', width = .1) +
  stat_summary(fun.y = mean, geom = 'point', size = 3)

Вот теперь кайф!

Темы

Но не совсем. Фон какой-то не очень… Дефолтная серая тема в какое-то давнее время была популярна, все выдели что ты крутой и умеешь в ггплот и ваще. Однако со временем это стало #немодно, и лучше серой темы избегать, да и требования журналов обычно более строгие.

В ggplot2 есть ряд встроенных тем, которые задаются через функции семейства theme_*(). Наиболее популярные theme_classic() и theme_bw(). Последнюю мы и будем использовать.

ggplot(share, aes(setsize, time1, group = 1)) +
    stat_summary(fun.y = mean, geom = 'line', linetype = 'dashed', color = 'gray30') +
  stat_summary(fun.data = mean_cl_boot, geom = 'errorbar', width = .1) +
  stat_summary(fun.y = mean, geom = 'point', size = 3) +
  theme_bw()

Ах, красота!

Кастомизация шкал (scale_)

Однако какой-то у нас слишком простой график. Добавим-ка группировку по категориальным переменным. А чтобы график получился осмысленный, предвалительно сабсетнем данные.

share %>% 
  filter(trialtype != 'both') %>% 
  ggplot(aes(setsize, time1, color = platform, shape = trialtype)) +
    stat_summary(fun.y = mean, geom = 'line', linetype = 'dashed', color = 'gray30') +
  stat_summary(fun.data = mean_cl_boot, geom = 'errorbar', width = .1) +
  stat_summary(fun.y = mean, geom = 'point', size = 3) +
  theme_bw()
## geom_path: Each group consists of only one observation. Do you need to
## adjust the group aesthetic?

Уф… Ну, групировка прошла успешно, уже хорошо, однако отображение хромает. Надо немного раздвинуть точки относительно друг друга с отдельных категория, так как сейчас опи явно друг на друга налезают. Для этого есть аргумент position, который принимает результат выполнения функции position_dodge() (если и другие position_*(), но мы не будем на них останавливаться).

Чтобы упростить код, заведем переменную pd, в которую сохраним результаты выполнения функции position_dodge() и далее будем передавать эту переменную в аргумент position.

pd <- position_dodge(.3)
share %>% 
  filter(trialtype != 'both') %>% 
  ggplot(aes(setsize, time1, color = platform, shape = trialtype)) +
    stat_summary(fun.y = mean, geom = 'line', linetype = 'dashed', color = 'gray30', position = pd) +
  stat_summary(fun.data = mean_cl_boot, geom = 'errorbar', width = .2, position = pd) +
  stat_summary(fun.y = mean, geom = 'point', size = 3, position = pd) +
  theme_bw()
## geom_path: Each group consists of only one observation. Do you need to
## adjust the group aesthetic?

Точки раздвинулись — круто! Но у нас есть строчка про линии, но они не отрисовываются. С такой проблемой мы уже сталкивались и знаем, как её принципиально решить — задать аргумент group. Но как? Ведь теперь у нас не одна группа, а два фактора, в каждой из которых две субгруппы — итого четыре.

Для такой ситуации есть функция interaction(), которая возвращает все уровни взаимодействия факторов.

interaction(share$trialtype, share$platform)

Результат её работы можно передать в аргумент group, и все заработает.

share %>% 
  filter(trialtype != 'both') %>% 
  ggplot(aes(setsize, time1, group = interaction(trialtype, platform), color = platform, shape = trialtype)) +
    stat_summary(fun.y = mean, geom = 'line', linetype = 'dashed', position = pd, alpha = .5) +
  stat_summary(fun.data = mean_cl_boot, geom = 'errorbar', width = .2, position = pd) +
  stat_summary(fun.y = mean, geom = 'point', size = 3, position = pd) +
  theme_bw()

Сразу уберем аргумент color из строки про линии и добавим прозрачность (alpha).

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

Для того, чтобы кастомизировать используемые шкалы, есть ряд функций семейства scale_*(). Мы познакомимся с некоторыми из них. Для начала изменим цвета.

В R можно задавать цвета через названия или HEX кодировку. Будем использовать названия.

share %>% 
  filter(trialtype != 'both') %>% 
  ggplot(aes(setsize, time1, group = interaction(trialtype, platform), color = platform, shape = trialtype)) +
    stat_summary(fun.y = mean, geom = 'line', linetype = 'dashed', position = pd, alpha = .5) +
  stat_summary(fun.data = mean_cl_boot, geom = 'errorbar', width = .2, position = pd) +
  stat_summary(fun.y = mean, geom = 'point', size = 3, position = pd) +
  theme_bw() +
  scale_color_manual(values = c('royalblue4', 'brown3'))

Здесь мы используем функцию scale_color_manual(), чтобы задать значения цвета вручную. Используя обязательный аргумент values мы передаем вектор названий цветов, которые хотим использовать. Вуаля! Красuво!

Теперь — форма. У нас круг и треугольник. Допустим, мы хотим использовать только «угловатые» фигуры и заменить круг на квадрат. Для этого есть scale_shape_manual(), которая работает аналогично предыдущей.

share %>% 
  filter(trialtype != 'both') %>% 
  ggplot(aes(setsize, time1, group = interaction(trialtype, platform), color = platform, shape = trialtype)) +
    stat_summary(fun.y = mean, geom = 'line', linetype = 'dashed', position = pd, alpha = .5) +
  stat_summary(fun.data = mean_cl_boot, geom = 'errorbar', width = .2, position = pd) +
  stat_summary(fun.y = mean, geom = 'point', size = 3, position = pd) +
  theme_bw() +
  scale_color_manual(values = c('royalblue4', 'brown3')) +
  scale_shape_manual(values = c(15, 17))

Форма задается через числовой вектор. Какая цифра какую форму обозначает можно найти тут.

Вот теперь во истину круто!

Последние штрихи

Но настройка графика на этом не закончена. Раз уж мы в России, то надо и подписи по-русски задать. Для этого также есть отдельная функция. Она называется labs(). Зададим названия осей:

share %>% 
  filter(trialtype != 'both') %>% 
  ggplot(aes(setsize, time1, group = interaction(trialtype, platform), color = platform, shape = trialtype)) +
    stat_summary(fun.y = mean, geom = 'line', linetype = 'dashed', position = pd, alpha = .5) +
  stat_summary(fun.data = mean_cl_boot, geom = 'errorbar', width = .2, position = pd) +
  stat_summary(fun.y = mean, geom = 'point', size = 3, position = pd) +
  theme_bw() +
  scale_color_manual(values = c('royalblue4', 'brown3')) +
  scale_shape_manual(values = c(15, 17)) +
  labs(x = 'Количество стимулов в пробе', y = 'Время реакции, мс')

Стало получше, но еще остались эстетики, который подписаны названиями переменных из датасета — цвет и форма. Это легко поправить.

share %>% 
  filter(trialtype != 'both') %>% 
  ggplot(aes(setsize, time1, group = interaction(trialtype, platform), color = platform, shape = trialtype)) +
    stat_summary(fun.y = mean, geom = 'line', linetype = 'dashed', position = pd, alpha = .5) +
  stat_summary(fun.data = mean_cl_boot, geom = 'errorbar', width = .2, position = pd) +
  stat_summary(fun.y = mean, geom = 'point', size = 3, position = pd) +
  theme_bw() +
  scale_color_manual(values = c('royalblue4', 'brown3')) +
  scale_shape_manual(values = c(15, 17)) +
  labs(x = 'Количество стимулов в пробе', y = 'Время реакции, мс',
       color = 'Платформа', shape = 'Тип пробы')

Но и это ещё не всё. Остаётся подписи категорий эстетик, которые хотя мы и не можем подписать по-русски (по понятным причинам), мы можем оформить подписи более корректно. Для этого в уже использованных нами функциях scale_*() есть агрумент labels, который принимает вектор подписей.

share %>% 
  filter(trialtype != 'both') %>% 
  ggplot(aes(setsize, time1, group = interaction(trialtype, platform), color = platform, shape = trialtype)) +
    stat_summary(fun.y = mean, geom = 'line', linetype = 'dashed', position = pd, alpha = .5) +
  stat_summary(fun.data = mean_cl_boot, geom = 'errorbar', width = .2, position = pd) +
  stat_summary(fun.y = mean, geom = 'point', size = 3, position = pd) +
  theme_bw() +
  scale_color_manual(values = c('royalblue4', 'brown3'), labels = c('Android','iOS')) +
  scale_shape_manual(values = c(15, 17), labels = c('Three Dots', 'Outgoing Tray')) +
  labs(x = 'Количество стимулов в пробе', y = 'Время реакции, мс',
       color = 'Платформа', shape = 'Тип пробы')

Теперь было бы хорошо добавить название, а то как-то непонятно, что тут вообще нарисовано. Используем аргументы title и subtitle функции labs().

share %>% 
  filter(trialtype != 'both') %>% 
  ggplot(aes(setsize, time1, group = interaction(trialtype, platform), color = platform, shape = trialtype)) +
    stat_summary(fun.y = mean, geom = 'line', linetype = 'dashed', position = pd, alpha = .5) +
  stat_summary(fun.data = mean_cl_boot, geom = 'errorbar', width = .2, position = pd) +
  stat_summary(fun.y = mean, geom = 'point', size = 3, position = pd) +
  theme_bw() +
  scale_color_manual(values = c('royalblue4', 'brown3'), labels = c('Android','iOS')) +
  scale_shape_manual(values = c(15, 17), labels = c('Three Dots', 'Outgoing Tray')) +
  labs(x = 'Количество стимулов в пробе', y = 'Время реакции, мс',
       color = 'Платформа', shape = 'Тип пробы',
       title = 'Время реакции при взаимодействии факторов',
       subtitle = 'Тип пробы × Платформа × Количество стимулов в пробе')

Почти идеально! Но осталось пара моментов. Во-первых, непонятно, какая метрика отображена с помощью «усов», а во-вторых, легенда занимает много места. У labs() есть ещё один аргумент — caption, иначе говоря «подпись». В ней и можно указать метрику.

share %>% 
  filter(trialtype != 'both') %>% 
  ggplot(aes(setsize, time1, group = interaction(trialtype, platform), color = platform, shape = trialtype)) +
    stat_summary(fun.y = mean, geom = 'line', linetype = 'dashed', position = pd, alpha = .5) +
  stat_summary(fun.data = mean_cl_boot, geom = 'errorbar', width = .2, position = pd) +
  stat_summary(fun.y = mean, geom = 'point', size = 3, position = pd) +
  theme_bw() +
  scale_color_manual(values = c('royalblue4', 'brown3'), labels = c('Android','iOS')) +
  scale_shape_manual(values = c(15, 17), labels = c('Three Dots', 'Outgoing Tray')) +
  labs(x = 'Количество стимулов в пробе', y = 'Время реакции, мс',
       color = 'Платформа', shape = 'Тип пробы',
       title = 'Время реакции при взаимодействии факторов',
       subtitle = 'Тип пробы × Платформа × Количество стимулов в пробе',
       caption = 'отображен 95% доверительный интервал')

Осталось подвинуть легенду, например, вниз. Вообще положение легенды определяется темой, и мы её ужа задали. Однако можно и подправить дефолтные параметры с помощью функции theme().

share %>% 
  filter(trialtype != 'both') %>% 
  ggplot(aes(setsize, time1, group = interaction(trialtype, platform), color = platform, shape = trialtype)) +
    stat_summary(fun.y = mean, geom = 'line', linetype = 'dashed', position = pd, alpha = .5) +
  stat_summary(fun.data = mean_cl_boot, geom = 'errorbar', width = .2, position = pd) +
  stat_summary(fun.y = mean, geom = 'point', size = 3, position = pd) +
  theme_bw() +
  scale_color_manual(values = c('royalblue4', 'brown3'), labels = c('Android','iOS')) +
  scale_shape_manual(values = c(15, 17), labels = c('Three Dots', 'Outgoing Tray')) +
  labs(x = 'Количество стимулов в пробе', y = 'Время реакции, мс',
       color = 'Платформа', shape = 'Тип пробы',
       title = 'Время реакции при взаимодействии факторов',
       subtitle = 'Тип пробы × Платформа × Количество стимулов в пробе',
       caption = 'отображен 95% доверительный интервал') +
  theme(legend.position = 'bottom')

Ну, вот — наконец-то мы получили итоговый варинат, который и опубликовать можно, и на презентации показать и вообще хоть куда!

Сохранение графиков

Для того, чтобы опубликовать график в статье или даже просто вставить в презентацию нужно его как-то выгрузить. Скриншоты нам не подходят, потому что качество их совершенно никуда не годится. На наше счастье есть фукнция для выгрузки картинок из R и называется она ggsave().

Она принимает следующие аргументы:

Функция позволяет сохранить изображения большинства форматов (JPEG, PNG, SVG, TIFF, PDF).

ggsave('graph1.png', width = 20, height = 20, units = 'cm')

Выполнив эту функцию мы получим в рабочей директории файл с графиком, который мы только что нарисовали.

Больше о возможностях ggplot2: геомы, фасеты

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

Часто перед исследователем стоит задача визуализировать распределение переменной. Допустим, мы хотим посмотреть, какое распределение имеет переменная времени. Для этого есть два геома: geom_histogram() и geom_density(). Попробуем оба.

theme_set(theme_bw()) # ещё один способ задать тему на всю сессию сразу

ggplot(share, aes(time1)) +
  geom_histogram()
## `stat_bin()` using `bins = 30`. Pick better value with `binwidth`.

Получилась вот такая гистограмма. R выдал нам сообщение, что можно использовать аргумент binwidth, чтобы получить лучшее картинку. Этот аргумент задает ширину столбца. Попробуем.

ggplot(share, aes(time1)) +
  geom_histogram(binwidth = 100)

Задав более мелкое разбиение, мы получили более четкую картинку распределения.

Теперь попробуем второй геом.

ggplot(share, aes(time1)) +
  geom_density()

Хм, выглядит похоже. В чем же разница? В то время как по оси y в случае гистограммы отложено количество значение, поподающее в диапазон соответствющего столбца, у графика плотности по вертикально оси отложена — внезапно — плотность вероятности. В целом, гистограмма над дает более детальную картинку, особенно при достаточно мелких разбиениях. График плотности отражает, скорее, общую закономерность, тренд.

Итак, у нас есть гистограмма для времени по всему датасету. Но, как мы помним, у нас есть группирующие переменные, и мы можем ими воспользоваться! Сгруппируем наши наблюдения по типу платформы, используем цвет.

ggplot(share, aes(time1, fill = platform)) +
  geom_histogram(binwidth = 100)

Заметьте, мы использовали эстетику fill (заливка). Что будет, если задать color, можете попробовать самостоятельно.

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

ggplot(share, aes(time1, fill = platform)) +
  geom_histogram(binwidth = 100, position = position_dodge())

Наши гистограммы как бы наложились друг на друга. Вообще для наложения распределение друг на друга лучше подойдут как раз графики плостности. Это будет выглядеть как-то так:

ggplot(share, aes(time1, fill = platform)) +
  geom_density(alpha = .3)

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

Фасетирование — это разбиение график на несколько подграфиков по какой-либо перменной. В нашем случе сделаем по платформе. Для фасетирования есть функции facet_grid() и facet_wrap(). Воспользуемся последней.

ggplot(share, aes(time1, fill = platform)) +
  geom_histogram(binwidth = 100, position = position_dodge()) +
  facet_wrap(~ platform)

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

Однако с этим графиком что-то не так… На нем представлены избыточность кодирования — платформа задается и разбиением и цветом. Необходимо оставить что-то одно. Вернее, можно оставить и то и другое, но необходимо убрать легенду, чтобы она не вводила в заблуждение. Это делается так.

ggplot(share, aes(time1, fill = platform)) +
  geom_histogram(binwidth = 100, position = position_dodge()) +
  facet_wrap(~ platform) +
  guides(fill = FALSE)

Теперь цвет строго говоря ничего не кодирует, а является лишь элементом дизайна.

В отличие от facet_wrap(), используя facet_grid() можно разбить наблюдения сразу по двум переменным (вообще в facet_wrap() тоже можно, но попробуйте и посмотрите, какой способ более наглядный). Разбиение также датается через формулу. До тильды указываются «строки», после — «столбцы».

ggplot(share, aes(time1, fill = platform)) +
  geom_histogram(binwidth = 100, position = position_dodge()) +
  facet_grid(setsize ~ platform) +
  guides(fill = FALSE)

Смотреть на распределения по группам — это полезно, однако информативнее смотреть на такие «субраспределения» в контексте общего распределения. То есть мы хотим, чтобы в каждой клеточке отображалось, помимо того распределения, которое там сейчас уже есть, и общее распределение без разделения на группы. Это можно сделать следующим образом:

ggplot(share, aes(time1)) +
  geom_histogram(data = share[, -c(which(colnames(share) == 'platform'), which(colnames(share) == 'setsize'))], binwidth = 100, position = position_dodge()) +
  geom_histogram(aes(fill = platform), binwidth = 100, position = position_dodge()) +
  facet_grid(setsize ~ platform) +
  guides(fill = FALSE)

Ох, боже… Что это вообще такое?..

Разберемся по порядку. Во-первых, чтобы отобразить общее распределение, нам нужен новый слой — мы его завели с помощью geom_histogram(). Во-вторых, он должен находится на картинке под гистограммами, которые у нас уже были, поэтому мы написали строчку кода выше старого geom_histogram(). В-третьих, мы задавали разбиение по платформе и сетсайзу с помощью facet_grid(), а теперь над надо это разбиение убрать, но только для нового geom_histogram(). Поэтому мы эксплицитно перезадаем данные для нового геома. Из данных мы удаляем переменные platform и setsize, чтобы ggplot2 не смог по ним сгруппировать данные.

Для удаления переменных в данном случае используется функция which(), которая возвращает позицию элемента в векторе. Выполните отдельно (which(colnames(share) == 'platform') и share[, -c(which(colnames(share) == 'platform'), which(colnames(share) == 'setsize'))] и посмотрите, как это работает.

Остается вопрос с эстетиками. Первоначально в функции ggplot(..., aes()) у нас была указана эстетика fill, которая отвечала за раскрашвание по типу платформы. Эстетики указанные через ggplot(..., aes()) автоматически применяются ко всем слоям далее. Но в нашем случае это вызвало бы ошибку, потому что в добавленном слое нет переменной platform. Для того, чтобы все сработало, нужно перенести задать aes() только для старого слоя с гистограммой.

Вот такими (не)хитрыми малипуляциями мы добились итогового результата.

Здесь рассмотрены далеко не все возможности ggplot2, но, уверенно освоив представленный материал и Google, можно разобраться во всех деталях и тонкостях это мощной системы. Успехов в покорении новых горизонтов визуализации!