Для начала небольшая вводная информация. Меня зовут Владислав и моё знакомство с R состоялось в августе прошлого года. Изучать язык программирования я решил по причине прикладного характера. Мне с детства нравилось вести спортивную статистику. С возрастом это увлечение трансформировалось в желание как-то анализировать эти цифры и на основе анализа данных выдавать, по возможности, умные мысли. Проблема в том, что спорт в последние годы захлестнула волна данных, десятки компаний соревнуются между собой, пытаясь посчитать, описать и запихнуть в нейронку любое действие футболиста, баскетболиста, бейсболиста на площадке. И Excel для анализа не подходит категорически. Так что я решил изучать R, чтобы простейший анализ не занимал полдня. Уже в ходе изучения добавился интерес к программированию как таковому, но это уже лирика.
Хочу сразу заметить, что многое из того, что я напишу в дальнейшем уже было в Симпсонах было на Хабре в статье Создаем анимированные гистограммы при помощи R. Эта статья, в свою очередь, является переводом статьи Create Trending Animated Bar Charts using R с Medium. Поэтому, чтобы как-то отличаться от вышеуказанных статей я постараюсь более полно описывать, чтоя делаюа, а также те моменты, которых нет в оригинальной статье. Например, для заливки столбцов я использовал цвета команд НБА, а не стандартную палитру ggplot2
, а в обработке данных пакет data.table
, а не dplyr
. Всё это дело у меня сделано в виде функции, так что теперь достаточно просто написать название команды и годы, за которые нужно количество побед посчитать.
Данные
Для построения графика я использовал данные о количестве побед каждой из 30 команд НБА в последних 15 сезонах. Собраны они были с сайта stats.nba.com с помощью расширения NBA Data Retriever, которое через использование NBA API выдаёт csv-файлы с необходимой статистикой. Вот полные данные из моего проекта на Github.
Используемые библиотеки
library(data.table)
library(tidyverse)
library(gganimate)
Для обработки данных я использую data.table
(просто потому, что познакомился с этим пакетом раньше). Также я загружаю набор пакетов tidyverse
, а не отдельный ggplot2
чтобы не переживать, если вдруг в ходе анализа появиться какая-то идея, требующая дополнительную загрузку пакета из этого набора. В данном конкретном случае можно обойтись и ggplot2
, другие пакеты набора не участвуют. Ну и gganimate
"придаёт" приводит графики в движение.
Работа с данными
Для начала нужно привести данные в порядок. В принципе, для построения графиков нам нужно 2 из 79 столбцов таблицы с "сырыми" данными. Можно сначала выбрать необходимые столбцы, можно сначала заменить некоторые значения. Я пошёл по второму пути.
Таблица в data.table
имеет вид dt[i, j, by]
, где by "ответственно" за группировку элементов. Группировать я буду по столбцу TeamName. И здесь есть загвоздка. В этом столбце отображаются названия команд: Lakers, Celtics, Heat и т.д. Но за рассматриваемый период (с сезона 2004/05) несколько команд поменяли названия: New Orleans Hornets стали New Orleans Pelicans, Charlotte Bobcats вернули историческое название Charlotte Hornets, а Seattle Supersonics стали Oklahoma City Thunder. Из-за этого может возникнуть путаница. Следующие преобразования помогают этого избежать:
table1 <- table[TeamCity == "New Orleans" & TeamName == "Hornets",
TeamName := "Pelicans"][
TeamCity == "New Orleans/Oklahoma City" & TeamName == "Hornets",
TeamName := "Pelicans"][
TeamName == "Bobcats", TeamName := "Hornets"][
TeamName == "SuperSonics", TeamName := "Thunder"]
Для данного временного отрезка изменения минимальны, но если его расширить, то по TeamName группировать станет очень сложно и надо будет пользоваться более надёжным столбцом. В этих данных это TeamID.
Для начала избавляемся от "лишней" информации, оставляя только те столбцы, которые понадобятся нам для работы:
table1 <- table1[ , .(TeamName, WINS)]
В data.table
конструкция .() заменяет собой функцию list
. Более "классический" вариант выбора столбцов это table1 <- table1[, c("TeamName", "WINS")]
. После этого таблица приобретает следующий вид:
TeamName | WINS |
---|---|
Suns | 62 |
Heat | 59 |
Spurs | 59 |
Pistons | 54 |
Для анимации по каждому сезону в отдельности этого достаточно, а вот для подсчёта общего количества побед за выбранный период нужно посчитать кумулятивную сумму побед.
table1 <- table1[, CumWins := cumsum(WINS), by = "TeamName"]
С помощью функции cumsum
мы получаем нужные нам числа. Использование := вместо = позволяет добавить новый столбец к таблице, я не перезаписать её с одни столбцом CumWins. by = "TeamName"
группирует данные по имени команды и кумулятивная сумма считается для каждой из 30 команд в отдельности.
Далее я добавляю столбец с годом, когда начинался каждый сезон. Сезон в НБА идёт с октября по май, так что попадает на два календарных года. В обозначении сезона год его начала, т.е. Season: 2018 на графике это сезон 2018/19 в реальности.
В изначальной таблице есть эти данные. В столбце SeasonID представлены цифра в виде 2(год начала сезона), например, 22004. Можно убрать первую двойку с помощью пакета stringr
или базовых функций R, но я пошёл немного другим путём. У меня получилось, что я сначала использую этот столбец для указания необходимых сезонов, потом удаляю и создаю столбец с датами вновь. Лишние действия.
Я сделал это следующим образом:
table1 <- table1[,year := rep(seq(2004, 2018), each = length(unique(table1$TeamName)))]
Мне "повезло", что за выбранный временной промежуток количество команд в НБА не менялось, поэтому я просто повторил цифры от 2004 до 2018 30 раз. Опять-таки если уходить в историю, то такой способ будет неудобен из-за того, что количество команд в каждом сезоне будет разным, поэтому предпочтительнее использовать вариант с очисткой столбца SeasonID.
Затем добавляем столбец cumrank.
table1 <- table1[, cumrank := frank(-CumWins, ties.method = "random"), by = "year"]
Он представляет собой ранжирование команд в каждом сезоне по количеству побед и будет использоваться как значения оси X. frank
более быстрый data.table
аналог базового rank
, минус означает ранжирование в порядке убывания (также это можно сделать с помощью аргумента decreasing = TRUE
. Мне неважно в каком порядке будут идти команды с одинаковым числом побед, поэтому ties.method = "random"
. Ну и всё это группируется в рамках одного года.
И последнее преобразование таблицы — это добавление столбца value_rel
.
table1 <- table1[, value_rel := CumWins/CumWins[cumrank==1], by = "year"]
В данном столбце считается отношение числа побед каждой команды к наибольшему показателю за год. У лучшей команды этот показатель равен 1, у остальных меньше, в зависимости от успешности сезона.
После всех добавлений таблица имеет следующий вид:
TeamName | WINS | CumWins | year | cumrank | value_rel |
---|---|---|---|---|---|
Spurs | 59 | 59 | 2004 | 3 | 0.9516129 |
Spurs | 63 | 122 | 2005 | 1 | 1.0000000 |
Spurs | 58 | 180 | 2006 | 2 | 0.9729730 |
Spurs | 56 | 236 | 2007 | 1 | 1.0000000 |
В таблице представлена только одна команда, чтобы наглядно показать кумулятивность. Все эти действия делаются, как и в изменении названий, каскадом квадратных скобок
table1 <- table1[
,.(TeamName, WINS)][
, CumWins := cumsum(WINS), by = "TeamName"][
,year := rep(seq(2004, 2018), each = length(unique(table1$TeamName)))][
, cumrank := frank(-CumWins, ties.method = "random"), by = "year"][
, value_rel := CumWins/CumWins[cumrank==1], by = "year"]
Изменение заливки столбцов со стандартной на цвета команд.
Можно сразу перейти к построению графиков, но есть ещё, как мне кажется, один важный момент: цвет столбцов на графике. Можно оставить стандартную палитру ggplot2
, но это плохой вариант. Во-первых, как мне кажется, она некрасива. А во-вторых, затрудняет поиск команды на графике. У поклонников НБА каждая из команд ассоциируется с определенным цветом: Бостон — это зелёный, Чикаго — красный, Сакраменто — фиолетовый и т.д. Поэтому использование цвета команды в заливке столбцов помогает быстрее её идентифицировать, несмотря на обилие синего и красного.
Для этого создаём таблицу table_color
с названием команды и главным её цветом. Цвета взяты с сайта teamcolorcodes.com.
TeamName | TEAM_color |
---|---|
Hawks | #E03A3E |
Celtics | #007A33 |
Nets | #000000 |
С таблицей цветов нужно сделать ещё одну манипуляцию. Т.к. при построении графика используются факторы, то порядок команд измениться. Первой в списке будет идти Филадельфия 76, как единственный обладатель "цифрового" имени, а далее согласно алфавиту. Так что нам и цвета нужно расположить в том же порядке, а затем извлечь из таблицы вектор, их содержащий. Я сделал это следующим образом:
table_color <- table_color[order(TeamName)]
cols <- table_color[, "TEAM_color"]
Построение графика
Мы действительно строим всего один график, который содержит все 450 (15 сезонов * 30 команд) показателей побед, а затем "разделяем" его по необходимой переменной (в нашем случае по годам) с помощью функций из пакета gganimate
.
gg <- ggplot(table1, aes(cumrank, group = TeamName, fill = as.factor(TeamName),
color = as.factor(TeamName))) +
geom_tile(aes(y = CumWins/2,
height = CumWins,
width = 0.7), color = NA, alpha = 0.8)
Сначала мы создаём графический объект с помощью функции ggplot
. В аргументе aes
указываем, как переменные из таблицы будут отображаться на графике. Мы их группируем по TeamName, fill
и color
будут отвечать за цвет столбцов.
Правда столбцами называть это не совсем верно. С помощью geom_tile
мы "разделяем" данные на графике на прямоугольники. Вот пример диаграммы такого типа:
Видно, как график "поделён" на квадраты (они получаются из прямоугольников при использовании слоя coord_equal()
), по три в каждом столбце. Но благодаря аргументу width
меньше единицы наша плитка принимает вид столбиков.
geom_text(aes(y = 0, label = paste(TeamName, " ")), vjust = 0.2,
hjust = 1, size = 6) +
geom_text(aes(y = CumWins, label = paste0(" ",round(CumWins))),
hjust = 0, size = 7) +
coord_flip(clip = "off", expand = FALSE) +
scale_fill_manual(values = cols) +
scale_color_manual(values = cols) +
scale_y_continuous(labels = scales::comma) +
scale_x_reverse() +
guides(color = FALSE, fill = FALSE) +
Далее я добавляю две подписи с помощью geom_text
: название команды и число побед. coord_flip
меняет оси местами, scale_fill_manual
и scale_color_manual
меняют цвет столбцов, scale_x_reverse
"разворачивает" ось Х. Заметьте, ч то цвета мы берём из ранее созданного вектора cols
.
В слое theme
указываются параметры для настройки отображения графика. Здесь указано, как должны отображаться заголовки и подписи осей (никак, о чём нам говорит element_blank
в правой части равенства). Мы убираем легенду, фон, рамку, линии сетки по оси Y. Аргументами plot.title
, plot.subtitle
, plot.caption
мы задаём параметры отображения заголовка, подзаголовка и подписи графика. Более подробно значение всех параметров можно посмотреть на сайте gglot2
theme(axis.line=element_blank(),
axis.text.x=element_blank(),
axis.text.y=element_blank(),
axis.ticks=element_blank(),
axis.title.x=element_blank(),
axis.title.y=element_blank(),
legend.position="none",
panel.background=element_blank(),
panel.border=element_blank(),
panel.grid.major=element_blank(),
panel.grid.minor=element_blank(),
panel.grid.major.x = element_line( size=.1, color="grey" ),
panel.grid.minor.x = element_line( size=.1, color="grey" ),
plot.title=element_text(size=25, hjust=0.5, face="bold",
colour="black", vjust=-1),
plot.subtitle = element_text(size = 15),
plot.caption =element_text(size=15, hjust=0.5, color="black"),
plot.background=element_blank(),
plot.margin = margin(2,2, 2, 4, "cm"))
Создание анимации
На использовании функции transition_states
я останавливаться не буду, эта часть у меня идентична более ранней публикации на Хабре. Что касается labs
то он создаёт заголовок, подзаголовок и подпись графика. Использование {closest_state}
позволяет отображать на графике каждый конкретный год, столбцы из которого мы видим в данный момент.
anim <- gg + transition_states(year, transition_length = 4, state_length = 1) +
view_follow(fixed_x = TRUE) +
labs(title = "Cumulative Wins by teams in seasons",
subtitle = "Season: {closest_state}",
caption = "Telegram: @NBAatlantic, Twitter: @vshufiskiyn
Data sourse: stats.nba.com")
Функция nba_cumulative_wins
для создания графиков.
Написание функций упрощает и ускоряет процесс получения результата, если Вам нужно использовать код больше одного раза. Обычно, функция в R имеет следующий вид:
имя_функции <- function(аргументы функции) {
тело_функции
}
Для начала стоит понять, какие параметры Вы хотите менять с помощью функции, от этого будут зависеть её аргументы. Первый аргумент — это имя таблицы с данными, которая подаётся на вход. Это позволяет переименовать её, если возникнет такое желание, при этом ничего не меняя в самой функции. Также я хочу, чтобы на графике могло отображаться любое число команд: от одной (что бессмысленно) до 30 (больше просто нет). Так же мне хочется иметь возможность рассматривать любые временные периоды в рамках тех 15 лет, по которым у меня есть данные. Всё это реализовывается в таком виде функции:
nba_cumulative_wins <- function(table, elements, first_season, last_season){
...
}
где table
— имя таблицы с входными данными,
elements
— названия тех команд, которые должны отображаться на графике
first_season
— первый сезон, который будет отображаться на графике
last_season
— последний сезон, который будет отображаться на графике.
Если аргумент очень часто используется с каким-то определённым значением, то можно задать его по умолчанию. Тогда, если среди аргументов функции он пропущен, то будет подставляться это значение. Например, если прописать
nba_cumulative_wins <- function(table, elements, first_season, last_season = 2018)
то графики будут строиться вплоть до сезона 2018/19, если не указано иное.
Работа с аргументами elements
, first_season
, last_season
С помощью аргумента elements
мы можем указать название тех команд, которые мы хотим видеть на графике. Это очень удобно, когда таких команд 2 или 3, но если мы хотим отобразить всю лигу нам придётся написать elements = c()
и в скобочках название всех 30 команд.
Поэтому я решил "разделить" входные значения для аргумента elements
на несколько групп.
Функция nba_cumulative_wins
может строить графики для отдельных команд, дивизионов, конференций или НБА в целом. Для этого мною была использована следующая конструкция:
select_teams <- unique(table1$TeamName)
select_div <- unique(table1$Division)
select_conf <- unique(table1$Conference)
select_nba <- "NBA"
table1 <- if(elements %in% select_teams){
table1[TeamName %in% elements]
} else if (elements %in% select_div){
table1[Division %in% elements]
} else if(elements %in% select_conf){
table1[Conference %in% elements]
} else if(elements == "NBA"){
table1
} else {
NULL
}
Символьные вектора select_
содержат в себе названия всех 30 команд, 6 дивизионов, 2 конференций и НБА, а функция unique
оставляет только одно уникальное название, вместо 15 (по количеству лет в данных).
Дальше с помощью конструкции if...else
проверяется принадлежность введённого аргумента elements
к одному из классов (%in%
используется для определения принадлежности элемента вектору), и в соответствии с этим видоизменяется таблица с данными. Теперь, если я хочу посмотреть результаты команд, играющих в Юго-западном дивизионе вместо
elements = c("Mavericks", "Spurs", "Rockets", "Grillies", "Pelicans")
мне достаточно ввести
elements = "Southwest"
, что гораздо быстрее и удобнее.
Из-за возможности выбора сезонов изменяется и работа с датами. В самом начале добавляется строка:
table1 <- table1[SeasonID >= as.numeric(paste(2, first_season, sep = ""))
& SeasonID <= as.numeric(paste(2, last_season, sep = ""))]
Так я оставляю в таблице только те строки, которые попадают в выбранный нами временной интервал. Также изменяется и код для создания столбца year
. Теперь он выглядит так:
table1 <- table1[ ,year := rep(seq(first_season, last_season),
each = length(unique(table1$TeamName)))]
В связи с группировкой элементов усложняется процедура получения нужных цветов. Дело в том, что в таблице table_color
только названия команд. Поэтому нам нужно "развернуть" наши сокращения обратно. Для этого снова используем конструкцию if...else
.
elements1 <- if (elements == "NBA"){
c("Hawks", "Celtics", "Nets", "Hornets",
"Bulls", "Cavaliers", "Mavericks",
"Nuggets", "Pistons", "Warriors", "Rockets",
"Pacers", "Clippers", "Lakers", "Grizzlies",
"Heat", "Bucks", "Timberwolves", "Pelicans",
"Knicks", "Thunder", "Magic", "76ers", "Suns",
"Trail Blazers","Kings", "Spurs", "Raptors",
"Jazz", "Wizards")
} else if (elements == "West") {
c("Mavericks","Nuggets", "Warriors", "Rockets",
"Clippers", "Lakers", "Grizzlies","Timberwolves",
"Pelicans", "Thunder", "Suns", "Trail Blazers","Kings", "Spurs",
"Jazz")
} else if (elements == "East") {
c("Hawks", "Celtics", "Nets", "Hornets",
"Bulls", "Cavaliers","Pistons", "Pacers",
"Heat", "Bucks", "Knicks", "Magic", "76ers",
"Raptors", "Wizards")
} else if (elements == "Pacific") {
c("Warriors", "Clippers", "Lakers", "Suns", "Kings")
} else if (elements == "Southeast") {
c("Magic", "Hornets", "Heat", "Hawks", "Wizards")
} else if (elements == "Southwest") {
c("Mavericks", "Grizzlies", "Pelicans", "Rockets", "Spurs")
} else if (elements == "Central") {
c("Bucks", "Pacers", "Pistons", "Bulls", "Cavaliers")
} else if (elements == "Atlantic") {
c("Knicks", "Nets", "Celtics", "Raptors", "76ers")
} else if (elements == "Northwest") {
c("Nuggets", "Trail Blazers", "Jazz", "Thunder", "Suns")
} else {
elements
}
Далее создаём таблицу с названиями команд, которые нам необходимы, соединяем эту таблицу с table_color
с помощью функции inner_join
из пакета dplyr
. inner_join
включает только наблюдения, которые совпадают в обеих таблицах.
table_elements1 <- data.table(TeamName = elements1)
table_color <- table_color[order(TeamName)]
inner_table_color <- inner_join(table_color, table_elements1)
cols <- inner_table_color[, "TEAM_color"]
В функции изменяется написание заголовка и подзаголовка. Они приминают такой вид:
anim <- gg + transition_states(year, transition_length = 4, state_length = 1) +
view_follow(fixed_x = TRUE) +
labs(title = paste("Cumulative Wins by teams in seasons",
first_season, "-", last_season, sep = " "),
subtitle = paste(if (elements %in% select_div ){
paste(elements, "Division", sep = " ")
} else if (elements %in% select_conf ){
paste("Conference", elements, sep = " ")
}, "Season: {closest_state}", sep = " "),
caption = "Telegram: @NBAatlantic, Twitter: @vshufiskiynData sourse: stats.nba.com")
Рендеринг
Далее всё это визуализируется.
animate(anim,
nframes = (last_season - first_season + 1) *
(length(unique(table1$TeamName)) + 20),
fps = 20, width = 1200, height = 1000,
renderer =
gifski_renderer(paste(elements[1], "cumwins.gif", sep = "_")))
число в nframes
я подобрал опытным путём, чтобы в зависимости от количества выбранных команд увеличивалась/уменьшалась скорость.
График
Надеюсь мой пост получился интересным. Код проекта на Github.
Если Вам интересна спортивная составляющая данных визуализаций, то можете посетить мой блог на sports.ru "По обе стороны Атлантики"
Автор: Gers1972