株式会社ホクソエムのブログ

R, Python, データ分析, 機械学習

gganimateでバーチャートレースを作って競争心を煽る

ホクソエムサポーターの輿石です。普段はデータ分析会社で分析業務や社内Rパッケージ開発をはじめ分析環境を整備する仕事をしています。

最近WEB系のメディアで「バーチャートレース(bar chart race )」と呼ばれるぬるぬる動く棒グラフを見ることが増えてきました。興味を惹くという点で優れた面白い可視化だと思います。Rではgganimateパッケージを使うことで簡単にggplot2のグラフをアニメーションにできたので、作成方法を細かい部分含めて紹介します。

なお、本記事ではggplot2の詳細には触れていませんが、詳細は「Rグラフィックスクックブック」がオススメです。なんと本記事が投稿された2019年11月21日は約6年ぶりの改版となる第2版の発売日なようです!6年前は少し面倒だったことも今ではより簡単にできるようになっていたりするので、すでにggplot2が使えるあなたも是非ッ。

Rグラフィックスクックブック ―ggplot2によるグラフ作成のレシピ集

Rグラフィックスクックブック ―ggplot2によるグラフ作成のレシピ集

1.可視化のテーマ【qiitaのイイネ数を競う】

可視化にしろレースにしろ、自分の興味・関心のある事柄の方が面白いと思います。自分自身と関わりのあるテーマが良いなと思っていたところ、所属組織のqiitaのイイネ数をバーチャートレースで可視化するという記事が面白かったので、同じことをRからやってみます。
この記事では筆者が所属するorganizationをピックアップしてアニメーションを作成します。qiitaからデータを取得するコードも公開するので、是非ご自身の組織のデータで可視化してみてください。

最終的に作成したいアニメーションはこちらです。

2.データの取得

qiitaではデータ取得のためのAPIが提供されています。主にRからAPIを叩くqiitrパッケージを使い、一部APIが対応していない部分はスクレイピングでデータを取得します。API利用のために事前にユーザの管理画面からtokenを取得してください。
なお、qiitaのAPIには1時間に1000回のリクエスト制限があります。コード自体は放っておけば良い感じに休止してデータを最後まで取ってくれるよう記述していますが、イイネ数が多い組織で試す場合は時間に余裕をもってお試しください。参考までに、イイネ累計獲得数1位のメルカリさんの場合は2000強のリクエストだったので、2時間ちょっとかかります。

まずはこの記事で必要になるパッケージをまとめて読み込みます。

library(tidyverse)
library(gganimate)
library(gifski)
library(qiitr)
library(rvest)

定数を定義します。ぜひ自分の所属組織に変えて実行してみてください!

target_organization <- "valuesccg"
qiitr::qiita_set_accesstoken()

一部の処理を関数にまとめていきます。

qiita_get_organization_member <- function(organization) {
  target_url <- str_glue("https://qiita.com/organizations/{organization}/members")
  sess <- html_session(target_url)
  
  member_ids <- c()
  while (!is.null(sess)) {
    ids <- sess %>% 
      html_nodes(xpath = "//*[@class='od-MemberCardHeaderIdentities_userid']") %>%
      html_text() %>%
      str_remove("@")

    member_ids <- append(member_ids, ids)
    
    sess <- tryCatch(
      sess %>% follow_link(xpath = "//*[@class='st-Pager_next']/a"), 
      error = function(e) return(NULL)
    )
  }
  
  member_ids
}


qiita_get_likes_date <- function(item_id, page_limit = 100){  
  res <- try(qiita_api("GET", path = sprintf("/api/v2/items/%s/likes", item_id), page_limit = page_limit))
  while(is.null(res) | (class(res) == "try-error" && str_detect(res, "Rate limit exceeded"))){
    Sys.sleep(600)
    res <- try(qiita_api("GET", path = sprintf("/api/v2/items/%s/likes", item_id), page_limit = page_limit))
  }
  
  lubridate::ymd_hms(map_chr(res, "created_at"))
}

実際にデータを取得し、tidyに整形していきます。

# organization所属メンバー一覧の取得
organization_members <- qiita_get_organization_member(organization = target_organization)

# メンバーの投稿記事一覧の取得
possibly_qiita_get_items <- possibly(qiita_get_items, otherwise = NULL)
item_data <-
  organization_members %>%
  tibble(user_id = .) %>%
  mutate(res = map(user_id, ~possibly_qiita_get_items(user_id = ., page_limit = 100L)),
         item_id = map(res, pluck, "id"), 
         likes = map(res, pluck, "likes_count")) %>%
  select(-res) %>%
  unnest(cols = c(item_id, likes)) %>%
  unnest(cols = c(item_id, likes)) %>%
  filter(likes > 0) %>%
  mutate(query_num = ceiling(likes/100))

sum(item_data$query_num)

# 記事ごとにイイネが付いた日時を取得
likes_data <-
  item_data %>%
  mutate(likes_date = map(item_id, qiita_get_likes_date)) %>%
  unnest(cols = likes_date)

3.可視化のためのデータ加工

データを取得できたので、gganimateでの可視化に適した形にデータフレームを加工していきましょう。アニメーションの1コマ1コマを作成するためのデータフレームを作成し、1コマを識別できるidを付けてすべてを1つにまとめたデータフレームを作成します。
ここでは、直近1年のデータを対象にイイネ累計獲得数のTOP10を週ごとに集計し、52週間分をまとめたデータフレームを作成していきます。

likes_data_sum <-
  likes_data %>%
  filter(likes_date >= lubridate::today() - lubridate::dyears(1)) %>%
  mutate(weeks = paste0(lubridate::year(likes_date), "_w", str_pad(lubridate::week(likes_date), width = 2, pad = "0"))) %>%
  arrange(user_id, weeks) %>%
  group_by(user_id, weeks) %>%
  summarise(likes = n()) %>%
  mutate(score_sum = cumsum(likes)) %>%
  ungroup()

likes_data_sum <-
  likes_data_sum %>%
  select(-likes) %>%
  pivot_wider(names_from = weeks, values_from = score_sum) %>%
  pivot_longer(-user_id, names_to = "weeks", values_to = "score_sum") %>%
  arrange(user_id, weeks) %>%
  group_by(user_id) %>%
  fill(score_sum) %>%
  ungroup() %>%
  mutate(score_sum = if_else(is.na(score_sum), 0L, score_sum)) %>%
  group_by(weeks) %>%
  mutate(ranking = row_number(-score_sum)) %>%
  filter(ranking <= 10) %>%
  ungroup()

下記の形のデータフレームができました。

head(likes_data_sum) 
# A tibble: 6 x 4
  user_id weeks    score_sum ranking
  <chr>   <chr>        <int>   <int>
1 accakr  2018_w47         0       3
2 accakr  2018_w48         1       4
3 accakr  2018_w49         1       4
4 accakr  2018_w50         1       5
5 accakr  2018_w51         1       5
6 accakr  2018_w52         1       5

4.アニメーションの作成①

バーチャートレースを作成する場合は、geom_bar()ではなく、geom_tile()を使うことがポイントです。
1つのコマに絞ってデータを可視化する要領で記述したコードに、transition_states()関数を追加しコマを識別する列を指定するだけでアニメーションが作成できます。(facet_wrap()の代わりにtransition_states()を使うイメージ。)

p <-
  likes_data_sum %>%
  ggplot(aes(x = ranking, group = user_id)) +
  geom_tile(aes(y = score_sum/2,
                height = score_sum,
                fill = user_id,
                width = 0.9)) +
  geom_text(aes(y = 0, label = paste(user_id, " ")), vjust = -1, hjust = -0.1) +
  geom_text(aes(y = score_sum, label = paste0(" ", score_sum), vjust = 1, hjust = 0)) +
  scale_x_reverse() +
  coord_flip() +
  theme_light()

p +
  transition_states(weeks)

tmp_race_animation

(なんだかカクカクしていますが、)ひとまずアニメーションを作ることができました!

5.アニメーションのカスタマイズ

ここではgganimateパッケージの関数について詳細をまとめます。アニメーションに関する設定を理解し、より質の高いアニメーションを作りましょう。

i. transition_xxx()

「transition_」から始まる関数で1コマ1コマの移り変わりの軸を何にするか、移り変わりの大まかな関係をどうするか、を決めます。アニメーション作成に最低限必要な関数です。沢山ありますが、以下の3つを覚えておけばいいでしょう。

a. transition_states()

カテゴリ変数・離散変数間の移り変わりをアニメーションにしたいときに使います。
引数transition_lengthとstate_lengthで1回のコマ遷移でアニメーションが動く時間と止まる時間の割合を決めることができます。
また、wrap=T(デフォルト)の場合はアニメーションが最後のコマになった際に、最初のコマに戻るところまでがアニメーションになるようです。

b. transition_time()

日時を表す変数を軸に、時間の移り変わりをアニメーションにしたいときに使います。
時間の間隔とアニメーションの移り変わりを合わせてくれるようで、例えばc(2015, 2016, 2018, 2019)のように一部期間のデータがかけていても時間間隔が合うように補完されたアニメーションが出力されます。実際の時間間隔に合うようになっているので、transition_state()のようにアニメーションが止まる時間を作ることはできず、常にぬるぬると動くアニメーションになります。

#2017年がないデータ
test_data <- data_frame(timing = c(2015, 2016, 2018, 2019), id = "1", y = c(0, 1, 2, 3))

test_data %>%
  ggplot() +
  geom_bar(aes(x = id, y = y), stat = "identity", width = 0.5) +
  coord_flip() +
  transition_time(timing) +
  labs(title = "[{as.integer(frame_time)}] : {frame_time}")

c. transition_reveal()

主に折れ線グラフを作成する際など、徐々にデータを追加しながらplotしなければならないケースで使うようです。月ごとの折れ線グラフの場合は、1コマ目は1月目のデータ、2コマ目は1~~2ヶ月目までのデータ、という形でplotされ、アニメーションになります。

なお、各transitionで「今何を描画しているか」などを表す変数が用意されており、glueの形式でタイトルなどテキストに挿入することが可能です。transition_states()ならば{closest_state}や{previous_state}、transition_time()ならば{frame_time}と、transitionごとに異なるので関数のヘルプで確認するようにしましょう。

ii. view_follow()

軸のメモリ範囲をコマごとのデータ範囲によって可変にしたいときに使います。
ただし、今回のgeom_tile()を使ったチャートでは可変にするとaxis.titleやaxis.textなど軸系のラベルがうまく表示できないようです。themaの設定で非表示にしています。

iii. ease_aes()

コマ間の移り変わりの際に、「最初ゆっくり動いて早くなってまたゆっくり動く」など変化に抑揚を付けたいときに使います。"cubic-in-out", "sine-in-out"などの文字列を指定するのですが、これらはCSSやjQueryのアニメーションに緩急をつけるプロパティ「イージング」の用語なようです。このサイトを見ると"cubic"や"sine"の違いや他の選択肢が分かります。ease_aes()を指定しない場合と、"cubic-in-out"を指定した場合の動きの違いを見てみましょう。

test_data <- data_frame(timing = c(1, 2, 3), id = "1", y = c(0, 1, 2))
p <-
  test_data %>%
  ggplot() +
  geom_bar(aes(x = id, y = y), stat = "identity", width = 0.5) +
  coord_flip() +
  transition_states(timing, transition_length = 6, state_length = 1, wrap = FALSE)

animate(p, nframes = 100, fps = 10, width = 800, height = 100)  
animate(p + ease_aes("cubic-in-out"), nframes = 100, fps = 10, width = 800, height = 100)  

ease_aesを指定しない場合(一定の速度で増加) ease_aes_normal ease_aesに"cubic-in-out"を指定(増加に緩急が付く) ease_aes_cubic

iv. enter_xxx/fade_xxx

新しいオブジェクト(棒や点など)の描画のされ方や、プロット内のオブジェクトが画面から消える際の消え方を指定できます。enter_fade()やexit_shrink()などがあります。

v. animate()

アニメーションはanimate()関数でレンダリングすることで最終的なアウトプットとなります。ここでは主に下記の項目を決めます。

  • アニメーションの時間の長さとフレーム数
  • 出力形式
  • アニメーションの縦横のサイズ

a. アニメーションの時間の長さとフレーム数

引数3つでduration = nframe ÷ fpsの関係が成り立ちます。

  • nframe:フレームの数(default = 100)
  • fps:フレームレート。1秒当たりのフレーム数(default = 10)
  • duration:アニメーション全体の時間の長さ(デフォルトは100/10で10秒)

一般にfpsが高い方がデータが重くなり、映像が滑らかになります。データのプロットは10fpsもあれば十分なケースが多いでしょう。(テレビアニメは24fpsです)
アニメーションがカクカクしている!もっと滑らかにしたい!という場合はfpsはそのままで、durationを長くしてみることをオススメします。fpsよりもコマ(今回のケースでは52週間分52コマ)を何秒に収めるかの方が全体的な滑らかさを決めるポイントになるように思います。

b. 出力形式

xxx_renderer()関数で出力形式を指定できます。デフォルトはgifski_renderer()でgifが出力されます。

c. アニメーションの縦横のサイズ

widthとheightで指定しましょう。

d. (おまけ)start_pause、end_pause引数でアニメーションを最初か最後で静止させる

start_pauseもしくはend_pause引数で最初か最後のフレームでアニメーションを一時停止させることができます。指定しないと最後に到達した瞬間に最初に戻るので、「最後どうなった!?」とツッコみたくなります。指定でちょっとハマったのでまとめました。

  • frame数で指定する。
    「1秒間静止させるframe数=fps」です。値をあらかじめ確認して指定しましょう。
  • 静止させた分duration(アニメーションの時間)を長くする。
    静止させた分も含めてdurationなので、例えば5秒で最後まで動かして1秒静止させたいという場合はdurationは6にしましょう。
  • transition_states()を使い最後で静止させる場合は、wrap=Fにする。
    デフォルトのwrap=Tの場合最後のコマから最初のコマに戻るところまでがアニメーションになります。最後のframeは最初のコマに戻る寸前のframeであり、中途半端ななんとも言えないタイミングで静止してしまいます。transition_states(wrap=F)で最後でぴったり終わるようにしましょう。

6.アニメーションの作成②

それでは最終的なアニメーションを出力したいと思います。

p <-
  p +
  theme(axis.line=element_blank(),
        axis.title.x=element_blank(),
        axis.title.y=element_blank(),
        axis.text.x=element_blank(),
        axis.text.y=element_blank(),
        legend.position="none",
        panel.border=element_blank(),
        panel.grid.major.y=element_blank(),
        panel.grid.minor.y=element_blank(),
        plot.title=element_text(size=25, hjust=0.5, face="bold", vjust=-1, lineheight = 1),
        plot.subtitle=element_text(size=18, hjust=0.5)) +
  labs(title = "直近1年間のqiita累計イイネ獲得数ランキング : {closest_state}",
       subtitle = paste0("organization:", target_organization)) + 
  transition_states(weeks, transition_length = 6, state_length = 1, wrap = F) +
  view_follow() +
  enter_fade() +
  ease_aes("cubic-in-out")

final_animation <- animate(p, fps = 10, duration = 32, end_pause = 20, width = 800, height = 400)

# 保存するときはanim_save()
dir.create("images")
anim_save("images/race_bar_chart.gif", final_animation)

良い感じにぬるぬる動くアニメーションになりました!
(私の順位は下から3番目なようです。 )

ggplot2が書ければアニメーションも作れてしまいますね。R凄い。

Enjoy!

参考文献