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

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!

参考文献

EMNLP2019の気になった論文を紹介

ホクソエムサポーターの白井です。

EMNLP-IJCNLP 2019 (以降 EMNLP) が先日、香港で開催されました。 EMNLPは Empirical Methods in Natural Language Processing の略称で、ACLやNAACLと並ぶ、計算機科学のTop conferenceと言われてます*1。 今年採択されたEMNLPの論文は682本 (+システム/デモ論文45本) です。 (年々増えています。)

今回は、EMNLP2019の論文から、いくつか気になったものを紹介します。 前回に引き続き、検証系の論文とデータ構築についての論文をメインに扱います。

以降、記載する図表は、明記しない限り、論文から引用しています。

1. ner and pos when nothing is capitalized

※タイトルはtypoではないです

英語において、大文字・小文字の使い分けにはルールがあります。例えば、文の最初や固有名詞の最初の文字は大文字を使います (e.g. "For these language, ..." , Hong Kong)。 これらの大文字の情報は、NER (Named Entity Recognition: 固有表現抽出) やPOS (Part of Speech Tagging: 品詞タグ付け) タスクにおいて重要な情報となり得ます。

ちなみに、NERは人物、場所、組織といった固有名詞を抽出するタスクです。下の例であれば、 AllenNLPPyTorchAllen Institute for Artificial IntelligenceORG (組織)、SeattleLOC (場所) だとモデルが予測しています。

from https://demo.allennlp.org/named-entity-recognition/ のelmo-nerによる予測結果(文はサイトの例文より引用)

一方、POSは単語に対して動詞や名詞などの品詞タグを付与するタスクです。下の Stanford CoreNLPによる結果では、 Penn Tree Bankの定義にしたがって単語にタグ付けされています。 例えば、 wePRP (Personal pronoun: 人称代名詞)、performVBP (Verb, non-3rd person singular present: 動詞, 三単現でない) のタグが付与されています。

from http://nlp.stanford.edu:8080/corenlp/process の予測結果 (例文は本論文のアブストラクトより引用)

これらのNERやPOSは、自然言語処理において、基礎となる重要な処理です。

しかしながら、SNSの投稿のようなくだけたテキスト、音声認識の出力などでは、ルール通りに大文字が使われていないこともあります。その場合、大文字を学習に利用したモデルでは、精度が落ちることが示されています。

(Casedが大文字のテキスト、Uncasedが小文字化したテキストでのタスクの精度。Uncasedのほうが精度が低いことがわかる。)

先行研究ではTruecasing (大文字を含む文に復元する方法) が試みられていますが、特有のcasingやtruecasingの間違いによってエラーが引き起こされるため、課題があるように思えます。

本論文では、NERとPOSタスクにおいて、cased text (大文字を含むテキスト) と lower-cased/uncased text (小文字のみのテキスト) での結果を比較し、最も適切な方法について検証しています。

検証した実験設定は以下のとおりです。

  1. Train on cased: 学習データもテストデータもそのまま
  2. Train on uncased: 学習データもテストデータも小文字化する
  3. Train on cased+uncased: そのままのデータと小文字化したデータどちらも学習データに利用
    • Half Mixed: 学習データの50%のみ小文字化して利用
  4. Train on cased, test on truecased: 学習データはそのまま使い、テストデータをtruecasingする
  5. Truecase train and test: 学習データもテストデータもtruecasingする

結果としては、3. Train on cased+uncased がいずれのタスクにおいても良いパフォーマンスであることが示されています。

学習に用いるデータ量が単純に2倍になっていることが要因の一つだと考えられます。

当たり前の結果といえばそうなのですが、汎用的に利用できるモデルを考える上では、小文字化したデータも利用して学習が必要な可能性を示唆していて興味深いと思いました。

日本語には英語における大文字小文字に対応するルールはありませんが、カタカナ・ひらがな・漢字のように複数の表記方法があるので、表記方法の違いについて検証する必要があるのかもしれません。

2. A Little Annotation does a Lot of Good: A Study in Bootstrapping Low-resource Named Entity Recognizers

上記論文の結果からも分かるとおり、ニューラルネットワークを用いたNER (固有表現抽出) モデルにおいて、重要なのはラベル付き学習データ (教師データ) です。英語では、そのようなデータが比較的豊富に存在しますが、他の言語の場合は、十分なデータ資源がないというのが現状です。

しかし、ラベルつき (アノテーション) データを作成するのは大変で、コストもかかるため、最低限の資源で実装したいところです。

本論文では、課題が多いLow-resourceな言語でのNERに対して、有効な "recipe" を紹介しています。流れとしては大きく3つ、cross-lingual transfer learning (言語を横断する転移学習)、active learning、fine-tunningです。

  • Cross-Lingual Transfer Learning
    • 2つの単語分散表現を用意し、bilingual word embeddings (BWE)を学習する
    • BWEを利用して、the cross-domain similarity local scaling (CSLS) metric (Github)によって単語間の辞書を作る
  • Entity-Targeted Active Learning
    • ラベルの付いていないsequenceから、最も有益なスパン (テキストの範囲) を選択する
    • アノテーターの作業は、entity typeを選ぶか、スパンの修正だけですむ
    • 図のETALが提案方法、SALが全てのシークエンスにアノテーションする方法
  • Fine-tunning
    • a BiLSTM-CNN-CRF model (Ma and Hovy (2016))を用いる
    • Active Learningではアノテーションが一部しかついていないため、Partical-CRFを利用

実験では、ドイツ語・スペイン語・オランダ語・ヒンディー語・インドネシア語について検証しています。

データ資源が少ないと、データを増やす方法を考えがちですが、必要最小限のリソースでタスクを解く工夫を提案している点が面白いと感じました。

3. Are We Modeling the Task or the Annotator? An Investigation of Annotator Bias in Natural Language Understanding Datasets

上記論文でも言及したように、人手によるアノテーションつきデータは自然言語処理の研究において重要であるものの、作業コストなどの課題が多く存在します。

膨大なテキストに対してアノテーションする場合、多くはAmazon Mechanical Turk (AMT) などのクラウドソーシングサービスを利用し、クラウドワーカー(アノテーター)に作成を依頼しています。近年の研究では、高い質のデータを作成できる少人数が膨大なデータを生成する方法を選んでいるようです。

しかしながら、Natural Language Understanding (NLU) タスクなどで利用されるフリー記述の場合、少人数が作成したデータは、データの多様性やモデルの生成能力に影響する可能性があります。

本論文では、NLUタスクにおける、アノテーターによる影響 annotator bias を調査しています。

  • 以下の、アノテーター情報が含まれるNLUデータセットについて調査
    • MNLI: テキストの含意を推論するデータセット
    • OpenBookQA: multi-hop (多段階で推論が必要) に着目した多項式のQAデータセット
    • CommonsenseQA: 常識に着目した多項式のQAデータセット
  • BERTモデルに、入力文 x 正解ラベル y に加えてアノテーター情報 z を含む、 ((z, x),y) を与えて学習を行う
    • 結果、アノテーター情報を含むとモデルの精度が向上した
    • With ID がアノテーター情報を含むモデル
  • 同様に、BERTをfine-tuningしてアノテーターの識別をしたところ、データを作成した割合が高いアノテーターほど、識別が容易であることがわかった

これらの結果から、アノテーターによって学習データとテストデータのアノテーターは分離させるべきであることが論文中で提案されています。

データを扱う側としても、アノテーションに何人関わっているのか、アノテーターによって偏りがないかどうか、確認する必要があると考えさせられました。

  • 参考

4. (Male, Bachelor) and (Female, Ph.D) have different connotations: Parallelly Annotated Stylistic Language Dataset with Multiple Personas

残りの2つはデータがメインの論文を紹介します。

先ほどの annotation bias のように、実際の人間の扱う言葉には、スタイル (≒文体) の違いがつきものです。性別・年齢による違いはもちろん、同じ人物でも状況に合わせて、言葉遣いが変化します。

このような、文章のスタイルの違いに着目し、文章を目的のスタイルに変換する研究は Style transfer と言います。Computer Visionでは、○○っぽい画風に変換する研究で有名かと思います。

f:id:sh111h:20191203222634p:plain from https://research.preferred.jp/2015/09/chainer-gogh/

NLPにおけるStyle transferも、文のスタイル変換や、著者のプロファイルなどに応用されています。 しかしながら、これらの研究ではパラレルコーパス (翻訳における複数の言語で文同士の対応が付いたコーパス *2 。Style transferにおいては 複数のスタイルで 同じ意味 のコーパス) を利用していないものが多いです。それにより、複数のスタイルのバリエーションを学習したり、評価することは困難です。

そこで、本論文は、スタイル、特にペルソナ情報についてのパラレルコーパス、PASTEL (the parallel and annotated stylistic language dataset) について紹介しています。

  • データ構築には Visual Story Tellingを利用
    • 元々の意味を保持しつつ、自身のスタイルで文書を作成してもらう
    • どの情報を与えればスタイルが多様になるか予備実験
    • 最も良かった Story (images + local keywords) (画像とキーワードを与える) 方法でアノテーションを行う

  • アノテーターにはペルソナ情報を入力してもらう
    • 性別・年齢・民族・出身・学歴・政治的立場

このパラレルコーパスによって、教師ありのスタイル変換が可能になります。

from Github

また、このパラレルコーパスを用いた応用方法として、controlled style classification を提案しています。 これは、他のスタイル情報を固定した上で、一つのスタイルについて分類するタスクです。例えば、与えられた文章の年齢や性別を当てることで、テキストにおける年齢・性別の違いを表現する特徴量がわかります。

画像とは異なり、言語には様々なスタイルが絡んでくるため、明示的にスタイル情報が示されているパラレルコーパスが提案されるのは画期的だと思いました。

5. Open Domain Web Keyphrase Extraction Beyond Language Modeling

Keyphrase Extraction (KPE: キーフレーズ抽出) は文書から特徴的なフレーズを抽出するタスクです。 先行研究では、学術論文のドメインにおいて、効果的なモデルが提案されていますが、これは、論文中に論文の著者が作成したキーワードが含まれていることがあり、ドメインの学習データが比較的豊富なことが要因です。

しかし、実応用においては、学術ドメインに限らず、スパースなデータ量の、様々なドメインにおいてキーフレーズ抽出ができることが求められます。

本論文では、オープンドメインのキーフレーズ抽出タスクに着目したデータセット OpenKP について紹介しています。

  • 文書はBing Search Engineからサンプリングされたウェブページ (詳細は http://www.msmarco.org)
    • ドメインや文書の種類で制限はしていないため、ニュース記事・動画ページ・リンクまとめページも含んでいる
    • トピックもばらついており、最も多いトピック healthcare でも全体の3.7%程度
  • 1つの文書につき1~3のキーフレーズを人手でアノテーション
    • 複数人の一致率は高くない
    • ランダムに選択した50文書において、5人の上位3つのキーフレーズで、完全一致したのは43%

また、このデータセットのタスクを解くためのモデル BLING-KPE (Beyond Language UnderstandING KeyPhrase Extraction) を提案しています。アーキテクチャの大きな枠組みはCNNとTransformerですが、タイトルの Beyond Language Understanding を目的とした2つの特徴があります。

  • visual feature (画像の特徴量)
    • データがウェブページであるため、画面の表示情報を特徴量として入力する
    • 具体的には、フォント、テキストサイズ、表示位置、DOM
  • weak supervision (弱い教師)
    • 検索クエリからそのドキュメントのページがクリックされたかどうかを識別する Query Prediction タスクを、モデルの事前学習に利用
    • クエリはBing search logから取得

OpenKPでの実験の結果、上記2つの特徴量は有意な特徴量であることが示されています。

赤枠がvisual featureなし、緑枠がvisual featureありの予測結果。 この結果から、フォントサイズやテキストサイズなどの visual feature が有効であることがわかる。

おわりに

EMNLP2019の論文を5つ紹介しました。

その他、本会議中開催されたTutorialの資料は多くの場合公開されています。 そのトピックについて包括的に知るのには良い資料だと思うので、気になるトピックがあれば読んでみるのもいいかもしれません。

参考: Tutorialのリンク集

ゼロから作るDeep Learning ❷ ―自然言語処理編

ゼロから作るDeep Learning ❷ ―自然言語処理編

  • 作者:斎藤 康毅
  • 出版社/メーカー: オライリージャパン
  • 発売日: 2018/07/21
  • メディア: 単行本(ソフトカバー)

今年読んだNLP系論文で面白かった5つ

ホクソエムサポーターの白井です。学生時代は自然言語処理の研究をしていました。

「今年読んだ論文、面白かった5つ」というテーマで、自然言語処理(NLP)の論文を紹介します。 主にACL anthologyに公開されている論文から選んでいます。

はじめに 今年のNLP界隈の概観

NLP界隈はELMoBERTが提案されたことによって、多くのタスクが高い精度、人間のパフォーマンスに近い精度を達成できるようになりました。去年から、"BERTをfine tuningしてSOTA" という主張の論文が散見される状況であると個人的には感じています。

その一方で、このモデルは本当に人間と同等の能力を持っていると言っていいのか?モデルのパフォーマンスをどう解釈すべきか?というような、 モデルの 解釈性 について疑問を問いかける、あるいは、モデルそのものを 検証 する研究も多く発表されている印象を受けています。

同じ流れで、新しいデータや、既存のデータを拡張したデータの公開も常に盛んに行われています。 解釈性の話とも被る部分がありますが、今のモデルでは解けないようなタスクを提案し、データを公開することで、研究領域全体として高めていこうという流れを感じます。

今回は、このような研究の流れを踏まえ、検証系の論文をメインに紹介したいと思います。

1. Text Processing Like Humans Do: Visually Attacking and Shielding NLP Systems

NAACL2019

Adversarial attack、もしくはAdversarial Perturbation (敵対的摂動) はComputer Vision (CV) 分野のパンダの例で有名な、ノイズを加えることで分類器に誤識別させるものです。

image

From https://openai.com/blog/adversarial-example-research/

この論文では、画像ではなく、文字のゆらぎを機械が扱うにはどうすればよいか検証しています。 adversarial attackがNLPにおいて重要である例のひとつとしてkaggleのtoxic-comment-classification-challengeをあげています。

toxic comment classificationは、wikipedia上のコメントが有害であるかどうかを分類するタスクです。このタスクの難しい点として、SNSなどweb上のコメントでは、有害な単語表現は、文字を置き換えて難読にしていることが挙げられます。

From https://www.aclweb.org/anthology/N19-1165/

上記例では、idiot という有害な単語を idiøţ と表記しています。 このように、人が見た目では読める単語 (idiøţ) も、分類器は文字コードでエンコードした分散表現を用いるため、idiøţidiot だと認識できません。これが、人間と機械の間にあるボトルネックです。

そこで、本論文では、文字を画像として扱う敵対機構を提案しています。

具体的には、以下の内容が述べられてます。

  • 敵対機構として VIPER (Visual Perturber) という、文字をその文字に似た文字に置き換える機構を提案し、Pos taggingやtoxic comment classificationなどの複数のタスクで 実験したところ、置き換える前より精度が下がった

    • 実際に置き換えた文の例
    • perturbed image
  • このような置き換えによって精度が下がるのを防ぐ、Shield方法を3つ提案し、実験を行なった。3つの詳細は以下の通り。

    • adversarial training: 学習データにVIPERで生成したデータを含める
    • character embeddings: 画像情報を利用した単語分散表現を入力に利用
    • rule-based recovery: 画像として近い文字にルールベースで置き換え
    • → これらの方法で精度の減少は防げるものの、まだ課題は多い

面白いと思った点

  • 中国語・韓国語・日本語のような文字レベルのcompositionality (構成性) がある言語とは異なり、ラテン語系は文字レベルで研究を行うことは多くないため、珍しい論文だと思いました
  • CVの考え方が、NLPに輸入されることは多いが、モチベーションとして文字レベルのadversarial exampleを生成するという提案は直感的で良い。言語関係なく、表層で単語を認識することは重要だと思います

2. Errudite: Scalable, Reproducible, and Testable Error Analysis

ACL2019

NLPに限らず、モデルの特性を理解する上でエラー分析は必須です。 この論文では、エラー分析に関する原則 (principle) を挙げ、それらをインタラクティブにサポートするツール Errudite を紹介しています。

論文では、Machine Comprehension(以下 MC)のモデルBiDAF(Seo et al.,2017)のSQuADデータにおける結果を具体例として用い、原則を記述しています。 ちなみにMCとは、システムに文章を読ませ、理解させるタスクです。また、SQuADは文章と質問を入力とし、質問の回答を文章から選択するタスクのデータセットです。

(参考:文章を読み、理解する機能の獲得に向けて-Machine Comprehensionの研究動向-)

提案されている原則の詳細は以下の3つです。

  • エラーの仮説は具体的な描写で正確に定義されるべきである
    • e.g. 「質問文が長いと精度が悪い」ではなく「質問文が 20token 以上だと精度が悪い」と書く
  • エラーのprevalence(分布率)は全体のデータセットで判断する
    • BiDAF is good at matching questions to entity types, but is often distracted by other spans with the same entity type (BiDAFは名詞などのエンテティを一致させるのに優れているが、多くの場合、同じエンテティの種類が同じ別の範囲を予測してしまう) という Distractor Hypothesis がある。
      • 例えば、上記図のように、ドクターフーの2005年のテーマを作った人物は、Murray Gold が正答だが、同じ種類 (人物) である John Debney と回答してしまう場合のこと
    • しかしながら、すべてのinstanceの正答率が68%である一方、その中で、答えがentityであるデータの正答率は80%であることがわかった。
  • エラーの仮説は直接的に調べるべきである
    • counterfactual questions (反事実的な質問) “If the predicted distractor was not there, would the model predict correctly? を実証するため、distractorに当たる単語を書き替えてモデルの出力結果が変わるか分析する。
      • 上記ドクターフーの例であれば、distractorである John Debney を他の単語( # )に置き換える。すると、モデルの出力はさらに異なる人物名である Ron Grainer と予測した。
      • このように、他のdistractorがモデルに誤った予測をさせてしまうケースが29%存在する一方、他の単語( # )に置き換えても予測が変わらないケースが23%存在することがわかった。

面白いと思った点

  • エラー分析が意外とおざなりになっていることに注目している点
    • appendixをみると、ACLのようなトップ会議に通っている論文でも、エラー分析のサンプルサイズが50程度のケースが存在するがわかります
    • 確かに、MCのような文章から答えとなる単語の範囲 (span) を当てるタスクにおいて、どのようにエラー分析すべきかは明文化されてなかったので、個人的に画期的だと思いました
  • エラー分析の提案に対して、ツールとして実装し、公開している点

3. Language Models as Knowledge Bases?

EMNLP2019 (to appear)

最初にも述べた、BERTやELMoは言語モデルです。言語モデルとは、尤もらしい文・文章を生成するモデルです。

具体的には、ある文字の並び \mathrm{w} = [w_1, w_2, ... . ,w_n ] が生成される確率 P(\mathrm{w})


P(\mathrm{w})=\prod_{t}{P( w_t | w_1, w_2, ..., w_{t-1})}

で表されます。

つまり、1からt-1番目まで、[w_1, w_2, ... . ,w_{t-1} ] の順番で単語が並んでいる時、その次の単語が w_t である確率の総乗で表現されます。

(これはシンプルな、一定方向の言語モデルの話です)

本論文では、このような言語モデルが知識ベース (Knowledge Base 以下 KB) としてどの程度扱うことができるか、検証する LAMA (LAnguage Model Analysis) を提案し、実際に検証を行っています。

本来KBは (Dante, born-in, X) のようなスキーマが定まった学習データを用いて X を予測するタスクです。 本論文では、 Dante was born in [Mask] in the year 1265. のように自然文で扱い、[Mask] を予測するタスクとして扱うことで、言語モデルで検証を行なっています。

具体的には、fairseq-fconv, Transformer-XL large, ELMo, BERTといった言語モデルについて、複数のデータセット (Google-RE, T-REx, ConceptNet, SQuAD) を用いて実験を行なっています。

結果として、BERTモデルが、Corpusと予測すべきrelationによっては既存のKBモデルよりも高い精度を達成していることを報告しています。特にT-REx (Wikipediaから抽出されたtripleを予測するタスク) において、1-to-1 relationでMean precision at one (P@1) が74.5という高いパフォーマンスになっています。

面白いと思った点

  • 言語モデルでKBを解こうと試みている点
  • 大規模コーパスを用いた学習済み言語モデルを用いている以上、既存のKBモデルと単純な比較はできないものの、既存のKBタスクを解くことで学習済み言語モデルが構造的な知識を持っているか調査できる点

余談

4. A Structural Probe for Finding Syntax in Word Representations

NAACL2019

言語モデルの検証を行っている論文をもう一つ紹介します。

この論文では、言語モデルがsyntactic (統語的) な構造を持っているか?を検証する structural probe を提案しています。BERT,ELMoのような言語モデルが出力する単語分散表現を、木構造として扱い、正しくparseできるかどうかを検証します。

image

具体的な検証方法は大きく2つです。

ひとつは、2つの単語ベクトルのL2距離の二乗を測るために空間を変換し、短い距離の単語同士をつなげることで構文木を作成する方法。もうひとつは、L2ノルムの二乗で単語の木の深さを測るための線形変換を行い、root (木の根) からの深さを検証する方法です。

Penn Treebankを用いた実験の結果、ELMo・BERTともに変換可能であり、構文木を構築することができることがわかりました。 また、実際の構文木の結果は論文中で可視化されています。

sample

面白いと思った点

  • stanfordが言語モデルが統語情報を持っていることを証明するための手法を提案している点
    • stanford core NLPなどを公開している、stanfordらしいアプローチだなと思いました

5. Emotion-Cause Pair Extraction: A New Task to Emotion Analysis in Texts

ACL2019

個人的にsentiment analysisをはじめとした感情分析系に興味があるため、最後にその系統の論文を紹介します。

文書からemotion (感情) とcause (原因) のペアを抽出するタスク emotion-cause pair extraction (ECPE) を新たに提案している論文です。

image From https://medium.com/dair-ai/a-deep-learning-approach-to-improve-emotion-cause-extraction-135bd9ea3899

先行研究であるEmotion cause extraction (ECE) はemotionを入力として、causeに該当するclauseを抽出するタスクです。しかし、これだとemotionがアノテーションされている前提のタスクになってしまい、実応用に結びつきません。また、causeとemotionも相互に結びつかないことも問題です。

そこで、emotion-cause pair extraction (ECPE) では、emotionとcauseのどちらも抽出するrelation extraction taskを提案しています。

実際にタスクを解くアプローチとしては、emotion一覧、cause一覧をそれぞれ取得、emotionとcauseのペアにして正解のフィルタリングを行うマルチタスク学習を提案しています。実験を行なった結果としてはemotionの抽出結果をcauseの結果に利用する手法の方がF値が高くなりました。

ちなみに図は英語ですが、論文中で利用しているデータセットは中国語です。

面白いと思った点

  • sentiment (ポジネガ) よりも粒度が細かいemotion、そしてその原因も同時に扱う、実応用に近いタスクを提案している点
    • より詳細なsentiment analysis (感情分析) を行うaspect-based sentiment analysisやtargeted sentiment analysisと近いが、sentimentをより詳細に扱えるのは面白い。
  • 個人的な意見ですが、sentiment、emotionを扱うNLPはレッドオーシャンにも関わらず、実応用として利用されることが多くなっているわけではないため、このようなタスクの精度が上がって実社会で扱えることを期待しています。

おわりに

検証系の論文をメインに紹介しました。

自然言語という人間が何気なく使っているモノに対して、計算機科学を用いて深い理解を得ようとするのがNLPの面白い点です。

また、NLP系の論文では他の研究分野の流れを汲み取ったり (1番目に紹介したAdversarial attack) 、言語の違い (英語・中国語・日本語) によってアプローチに違いがあったり、多角・多様であることも面白い点です。

その面白さが少しでも伝われば幸いです。

深層学習による自然言語処理 (機械学習プロフェッショナルシリーズ)

深層学習による自然言語処理 (機械学習プロフェッショナルシリーズ)

Python + AsyncSSH によるお手軽非同期SSH接続

1. はじめに

ホクソエムサポーターの藤岡です。普段はデータ分析会社で大規模データを抽出・加工する仕事をしています。

Python 3.4以降、asyncioが導入されたことで非同期処理の実装が簡単にできるようになりました。非同期処理を活用すると、大量のテキストを読み込んだり、通信のレスポンスを待つ時間に他の処理を行うことができるようになります。

ここでは、asyncioをベースに作成されたPythonライブラリ AsyncSSH を使って、簡単に非同期のSSH通信を実現する方法を紹介したいと思います。

AsyncSSHはSSH通信に必要な多くの機能を備えており、リファレンスを読むと色々と目移りしてしまうのですが、実際はある程度の処理ならお手軽に実装できるように作られています。なのでconnectコルーチンとSSHClientConnectionクラスのいくつかのメソッドだけを使って実現できる内容をここで紹介しようと思い立ち、本記事を執筆しました。

なお、ここに書いてある内容はあくまで自分の理解に基づいたものであり、誤った内容が含まれている可能性があります。もしお気付きの際には、ご指摘いただければ幸いです。

2. 導入

Python 3.4以上の入った環境で以下のコマンドを実行します。

$ pip install asyncssh

なお、別のパッケージをインストールすることでいくつかの拡張機能を使うことができるようになりますが、今回紹介する内容に限ればいずれの機能も不要なので省略します。詳しくはこちらをご覧ください。

3. 解説に入る前に

本記事は、以下の環境で検証しながら執筆しました。

  • Python 3.6.8
  • asyncssh 1.18.0

本記事中のサンプルコードは公式のサンプルコードを参考に作成しているため、それらと同様に以下の構文で成り立っています。

import asyncio, asyncssh, sys, getpass

async def run_client():
    << 接続処理(4章の内容) >>
    << ホスト上での処理(5章の内容) >>

try:
    asyncio.get_event_loop().run_until_complete(run_client())
except (OSError, asyncssh.Error) as exc:
    sys.exit('SSH connection failed: ' + str(exc))

各節では、冗長な内容を省いて最小限のコードのみを掲載しておりますが、 それらのコードをこのテンプレートに当てはめると実行できるようになっております。例えば、4.1節5.1節の内容を組み合わせて、パスワード認証 + lsコマンド実行のプログラムを作ると、以下のようになります。

import asyncio, asyncssh, sys, getpass

async def run_client():
    # 接続処理(4節の内容)
    pw = getpass.getpass()
    async with asyncssh.connect("localhost", password=pw) as conn: 
        # ホスト上で実行したい処理(5節の内容)
        conn.run("ls /path/to/the/folder")             

try:
    asyncio.get_event_loop().run_until_complete(run_client())
except (OSError, asyncssh.Error) as exc:
    sys.exit('SSH connection failed: ' + str(exc))

また、テスト用の環境として、Dockerを使ってSSHサーバコンテナを立てると安全に試すことができます。sshdサーバのイメージはdocker公式からこちらで提供されています。ただし、このイメージだとbcコマンドを使うサンプルと多段接続のサンプルが試せないのでご注意ください。

全てのサンプルを動かしたい場合は、こちらに筆者が使用したテスト環境を掲載しておりますので適宜参考にしてください。

4. 接続処理: connect()

シェル上でsshコマンドを叩くだけでホストへのSSH接続ができるのと同様に、asyncsshでもconnect()コルーチンを呼び出すだけでSSH接続が実現できます。async with句を使って通信の切断を内部的に処理させるために、基本的には以下の構文で呼び出します。

async with asyncssh.connect(*args, **kwargs) as conn:
    ...

例えば、

  • user: root
  • host: localhost
  • port: 2222

という設定で接続する場合、

async with asyncssh.connect("localhost", 2222, username="root") as conn:
    ...

とすればOKです。

なお、connect()の引数はhost, port以外全てkeyword-only引数なので注意してください。

このように、接続方法や認証方法などはすべてconnect()の引数で設定します。その種類は多岐にわたるのですが、本章ではその代表的なものを紹介していきます。

4.1 パスワード認証

パスワード認証は、パスワードの文字列をpassword引数に与えるだけで実現できます。

例えば、標準出力からパスワードを受け取って接続する場合は、以下のコードで実現できます。

pw = getpass.getpass()
async with asyncssh.connect("localhost", password=pw) as conn:
    ...

4.2 多段接続

connect()を使って接続する際に、tunnel引数に別の接続オブジェクトを渡すことでその接続を踏み台にすることができます。

例えば、

  1. ローカルからlocalhostに接続
  2. localhostから172.22.0.3に接続

ということを実現する場合、以下のようなコードになります。

async with asyncssh.connect('localhost') as tunnel_conn: #手順1
    async with asyncssh.connect('172.22.0.3', tunnel=tunnel_conn) as conn: #手順2
        ...

4.3 SSH Agent

asyncsshでは、デフォルトでSSH Agentから秘密鍵を取得する設定になっています。読み込むAgentは環境変数SSH_AUTH_SOCKに設定されているパスによって決定されます。この環境変数を使わずにAgentを指定する場合には、agent_path引数でagentのパスを指定します。Agentを使わない場合にはagent_path=Noneとしましょう。

また、agent_forwarding引数をTrueに設定することで、Agent Forwarding (SSH Agentの情報をホストへと転送すること) も可能です。

4.4 known_hosts

SSHで新しい接続先に接続する際には確認が行われますが、テスト時などにはこの機能が邪魔になることがあります。そんなときには、引数known_hostsNoneに指定することで、このステップをスキップすることができます。

ただし、この状態は中間者攻撃の被害に気づきにくくなるリスクがあるため、特に理由がなければNoneに指定しないようにしましょう。また、この引数を使って読み込むknown_hostsファイルのパスを指定することもできます。デフォルトでは~/.ssh/known_hostsに設定されています。

4.5 その他

ここまで紹介した以外にも、connect()の引数で設定できる項目は多く存在します。それらを調べる際に注意していただきたいのが、これらの引数はconnectのページだけでなくSSHClientConnectionOptionsのページにも記載されていることです。

なお、上記のように実装上は分けられているのですが、引数として渡す際にはどちらもconnect()のキーワード引数として与えることができます。

5. ホスト上での処理: SSHClientConnection

SSHClientConnectionとは、connect()で返される接続オブジェクトconnのクラスです。接続オブジェクトから種々のメソッドを呼び出すことで、接続先でコマンドを実行したりシグナルを送ったりできます。本章では、このクラスのメソッドを通じて、ホスト上で種々の処理を実現する方法を紹介します。

ここで紹介する3つのメソッドのうちconn.runconn.create_processはどちらもsubprocessモジュールと近いインターフェースで実装されているので、その辺りに慣れていれば使いやすいと思います。なお、conn.runsubprocess.run に似ていて、conn.create_processsubprocess.Popenに似ています。

5.1 コマンドの実行

コマンドを引数にとってconn.run()コルーチンを呼び出すだけです。例えば、lsコマンドを呼び出す場合には以下のように呼び出します。

await conn.run("ls /path/to/the/folder")

引数に渡すコマンドはトークンのリストでは無く単一の文字列なので注意が必要です。subprocess.run()同様にリストを渡したい場合には、subprocess.list2cmdline()を使って結合してから渡すのが良いでしょう。

一つの接続に対して複数回呼び出すこともできます。その場合、それぞれの呼び出しに対してホスト側でプロセスが発行されます。

subprocess.run()はプロセス実行結果オブジェクトSSHCompletedProcess を返します。このオブジェクトは実行結果の種々のパラメータをプロパティとして保持しています。各プロパティはこちらにリストアップされていますが、よく使うものを以下を列挙しておきます。

  • stdout: 標準出力
  • stderr: 標準エラー出力
  • exit_status: 終了ステータス (exit signalを受け取った場合は-1が入る)
  • returncode: 終了ステータス (exit signalを受け取った場合はその番号(負の数)が入る)

5.2. プロセス生成: SSHClientConnection.create_process()

インタラクティブなコマンドを実行する場合には、一つのプロセスとやり取りをする必要があるので、conn.run()ではなく、conn.create_process()を使います。例えば、以下はサンプルコードに載っている、対話的な処理の一部です。

async with conn.create_process('bc') as process:
    for op in ['2+2', '1*2*3*4', '2^32']:
        process.stdin.write(op + '\n')
        result = await process.stdout.readline()
        print(op, '=', result, end='')

1行目でbcコマンドを対話モードで呼び出し、そのプロセスオブジェクトprocessを受け取っています。次に、3行目でstdin.writeメソッドを使って数式を書き込み、4行目でreadlineコルーチンを使って計算結果を受け取っています。これはsubprocess.Popenstdin, stdout引数にsubprocess.PIPEを設定した場合と同様です。

command引数を省略すると、コマンドの代わりにホスト側のシェルが呼び出されます。例えば、上の例をシェルから実行すると、以下の通りです。

async with conn.create_process() as process:
    for op in ['2+2', '1*2*3*4', '2^32']:
        cmd = "echo {} | bc\n".format(op)
        process.stdin.write(cmd)
        result = await process.stdout.readline()
        print(op, '=', result, end='')

シェルを呼ぶ場合もコマンドを呼ぶ場合も、実行したい区切りで改行記号を入れることを忘れないようにしましょう。

5.3 I/Oのリダイレクト

ホスト側のプロセスのI/Oをローカルのプロセスや別のSSHClientProcessにリダイレクトすることができます。指定方法はsubprocess.Popen()とほぼ同じです。つまり、stdinstdoutstderr引数にそれぞれ特定の定数(e.g. asyncssh.PIPE)、もしくはファイルオブジェクトを与えます。

まず、これらの引数のデフォルト値は全てasyncssh.PIPEに設定されています。この設定では、ホスト側のプロセスの当該標準ストリームを対応するプロパティを通じて読み書きできます。前節でプロセスの標準ストリームがprocess.stdin, process.stdoutを通じて読み書き可能だったのは、デフォルト値がこのように設定されていたためです。

他には、asyncssh.STDOUTという定数があります。これをstderr引数に渡すことで、stderrに渡される出力をstdoutに流すこと(つまり、シェルでの2>&1)が可能です。asyncssh.STDERR定数をstdout引数に渡して、逆のこともできます。

それ以外のファイルオブジェクトに読み書きをさせたい場合には、そのファイルオブジェクトを直接渡します。例えば、以下のコードは、bcの対話プロセスをホストで開いて、それをローカルから操作する例です。

async with conn.create_process("bc", stdin=sys.stdin, stdout=sys.stdout) as process:
    await process.wait()

以下、上記のコードをテンプレートに当てはめたプログラム(remote_bc.py)の実行例です。

$ python remote_bc.py 
1+1
2
2+3
5
quit

1行目でローカルのstdin/stdoutをホストのstdin/stdoutへとリダイレクトするように設定し、2行目でホストのプロセスが終了するまで待機します。

ただし、stdin, stdout, stderr引数に渡したファイルオブジェクトはホスト側のプロセス終了時に強制的にcloseされます。例えば、上記コードだとstdoutが閉じられるのでprint関数が呼び出せなくなります。なので、リダイレクトするファイルオブジェクトは、閉じても構わないものにして、それ以外の場合はasyncssh.PIPEを指定しておくのがいいかと思います。

5.4 Send Signal

ホスト側に中断信号(SIG_INT)のようなシグナルを送る場合には conn.send_signalメソッドを使います。

async with conn.create_process() as process:
    process.send_signal("INT")
    await process.wait()

process.send_signal()は信号を送るだけなので、実際に信号が受け取られて処理されるまでprocess.wait()で待機します。SIG_TERMとSIG_KILLについてはそれぞれprocess.terminate(), process.kill()と関数が用意されているので、そちらを使うのが楽です。他の信号は全てsignalライブラリにあるものに対応していて、指定方法はSIG_以下の文字列(SIG_INTなら"INT")です。

ただし、send_signal()はホスト側の環境によっては動かないので注意が必要です。自分の知る限りでは、OpenSSHについてはv7.9p1以降でないと動きません。詳しくはこちらのissueを参照してください。どうしてもシグナルを送りたい場合の実現方法も載っています。

6. MISC

ここでは、本稿で扱いたい内容とは少しずれるものの重要な内容をさっくりと紹介します。いずれも、もし機会と需要があればしっかりと勉強して記事を書きたい内容です。

6.1 SSHサーバ

ここで扱った内容は全てSSHクライアントの話ですが、AsyncSSHではSSHサーバを実装することもできます。クライアントだけで実現できることには限界があるので、サーバも自前で実装すればできることの可能性が大きく広がります。

6.2 ssh_config

接続先の設定は基本的にssh_configファイル(e.g. ~/.ssh/config)に書きますが、 AsyncSSHにはこのファイルをパースする機能はありません。 ssh_configファイルから接続先の情報を取得したい場合は、Paramikoのパーサ を利用するのがいいかと思います。

6.3 コールバック

connect()を呼び出す際に、SSHClientもしくはその派生クラスをclient_factory引数に渡すことで、接続オブジェクトの挙動を変更できます。あるいは、conn.create_connection()の代わりにconn.create_sessionを使い、SSHClientProcessの派生クラスをそのclient_factory引数に渡すことで、プロセスの挙動を変更できます。

特に、接続完了時、通信遮断時など種々のフェーズで呼び出されるコールバックの挙動を弄れば、例えば通信を簡単にロギングやデバッグできます。

7. おわりに

PythonでSSHといえばParamiko一強なイメージもありますが、こちらのライブラリも非同期処理ができるという独自の強みを持っています。また、とても簡単に扱えるのでasyncioの事始めとしてもいい教材だと思います。なので、この機会にぜひ一度触ってみてはいかがでしょうか。

8. おまけ: テスト環境の構築例

上記サンプルコードを試すために筆者が使用した環境を簡単に紹介します。ユーザ、パスワードはそれぞれrootです。entranceからinternalへのipアドレスの確認方法はこちらの記事が参考になると思います。

8.1 環境

  • Docker 18.09.2
  • docker-compose 1.24.1

    8.2 ディレクトリ構成

test_server/
 ├── docker-compose.yml
 └── Dockerfile

8.2.1 docker-compose.yml

version: '3'
services:
  entrance:
    build: .
    ports:
      - "2222:22"
    networks:
      - sshtest
  internal:
    build: .
    networks:
      - sshtest
networks:
  sshtest:

8.2.2 Dockerfile

FROM rastasheep/ubuntu-sshd

RUN apt-get update && apt-get install -y \
    bc \
 && apt-get clean \
 && rm -rf /var/lib/apt/lists/*

8.3 起動方法

$ docker-compose up -d

8.4 終了方法

$ docker-compose down

9. References

Effective Python ―Pythonプログラムを改良する59項目

Effective Python ―Pythonプログラムを改良する59項目

スパコン上でRを並列実行する方法

アカデミアの大規模超並列クラスタ型スーパーコンピュータ上で、Rを並列実行する際の備忘録です。あまりスパコンに詳しくないので、用語を間違っていたら教えてください。僕が使っているスパコンは富士通のサーバでXeon Scalableプロセッサを積んでて、Intelのコンパイラがインストールされてて、pjsubコマンドでjobをsubmitします。

並列実行するために、試した方法は次の3種類です。順に説明します。

  1. バルクジョブを使う方法
  2. Rのパッケージを利用してMPIで実行する方法
  3. インテルのMPIライブラリのコマンドラインを使う方法

バルクジョブを使う方法

まず実行したいRファイル(calc.R)は以下のようなものとします。

.libPaths(c('/path/to/my_R_lib', .libPaths()))
library(rstan)

args <- commandArgs(trailingOnly=TRUE)
paraID <- as.integer(args[1])
f_in <- sprintf('input/param.%d.csv', paraID)
f_out <- sprintf('output/result.%d.RData', paraID)
# ... resultの計算 ...
save(result, file=f_out)
  • 1行目: Rのパッケージをユーザ権限で/home/以下などにインストールした場合には、このようにそのRパッケージへのパスを.libPaths()関数で加えておく必要があります。
  • 4行目: コマンドラインの引数をcommandArgs関数で受け取ります。
  • 5~7, 9行目: 引数の使用例です。数値の文字列(10とか)を渡して、その数値に対応するファイルを読み込み、計算結果を格納しています。

次にRコードをキックするためのバッチファイルを作ります。

#!/bin/sh

#PJM -g my_proj_name
#PJM -L rscgrp=my_resource
#PJM -L node=1
#PJM -L elapse=01:00:00
#PJM -j
#PJM -X

module load R/3.6.0
Rscript --vanilla /my_dir/calc.R $PJM_BULKNUM
  • 3~8, 10行目: この部分はsubmitするときに指定するオプションとRのPATHの読み込みで、必要かどうかはスパコンの環境に依存します。説明しません。
  • 11行目: ここで/my_dir/calc.Rを実行します。$PJM_BULKNUMpjsubで実行するときに渡されます。この値が先ほどのRコードのargs[1]になります。

最後にjobをsubmitするときのコマンドです。バルクジョブで実行します。

pjsub --bulk --sparam 1-50 run.sh

実行すると150の数値が一つずつ$PJM_BULKNUMに渡されて、サブジョブとしてRが実行されます。

しかしこの方法では、サブジョブ1つ1つが1ノードに割り振られるという扱いになるため、スパコンの使用チケットがノード×(サブ)ジョブ×計算時間の合計で定められている場合には、非常に速いスピードでチケットを消費してしまいます。この状況を打破するためには、MPIを使って1ジョブでマルチプロセスを利用できると良いです。

Rのパッケージを利用してMPIで実行する方法

手っ取り早いのはMPIを利用するRパッケージを利用することです。{foreach}, {doParallel}, {snow}, {Rmpi}パッケージをインストールする必要があります。その後、以下のようなRコードを準備します。

.libPaths(c('/path/to/my_R_lib', .libPaths()))
library(foreach)
library(doParallel)
library(snow)
library(Rmpi)

args <- commandArgs(trailingOnly=TRUE)
paraID_vec <- seq(from=as.integer(args[1]), to=as.integer(args[2]))

cl <- makeCluster(50, type='MPI')
registerDoParallel(cl)

memo <- foreach(paraID=paraID_vec, .packages='rstan', .export=ls(envir = globalenv())) %dopar% {
  f_in <- sprintf('input/param.%d.csv', paraID)
  f_out <- sprintf('output_mpi/result.%d.RData', paraID)
  # ... resultの計算 ...
  save(result, file=f_out)
}

stopImplicitCluster()
  • 7~8行目:ここでは並列実行したい数値のはじまりと終わりをargs変数に渡して、vectorを作っています。
  • 10~11行目:ここでMPIで実行するための準備をしています。50プロセスを使う指定をしています。{Rmpi}パッケージがないとmakeCluster関数でtype='MPI'引数が使えません。
  • 13行目:あとはいつものforeach関数と%dopar%演算子で並列実行します。

次にRコードをキックするためのバッチファイルを作ります。

#!/bin/sh

#PJM -g my_proj_name
#PJM -L rscgrp=my_resource
#PJM -L node=1
#PJM -L elapse=01:00:00
#PJM --mpi proc=50
#PJM -j
#PJM -X

module load R/3.6.0
Rscript --vanilla /my_dir/calc-with-Rmpi.R 1 50
  • 7行目: ここでmpi50プロセス使う指定をしています。Rコード内のプロセス数と合わせる必要があります。
  • 12行目: 上のRコードを実行します。

しかし、この方法はRパッケージ、特に{Rmpi}のインストールが環境によっては難しいという欠点があります。僕も失敗しました。install.packages関数のconfigure.argsオプションを使って、用意されているMPIのincludeディレクトリやlibpathを指定したのですが、エラーを完全に取り除くことができませんでした。だから上のコードも未テストでうまくいくか分かりません。ごめんなさい。

MPIライブラリのコマンドラインを使う方法

triadsouさんに教えてもらいました(URL)。ありがとうございました!

僕のスパコンの環境ではmpirunに相当するコマンドラインがIntelのmpiexec.hydraでした。そのため、以下のようなバッチファイルになります。

#!/bin/sh

#PJM -g my_proj_name
#PJM -L rscgrp=my_resource
#PJM -L node=1
#PJM -L elapse=01:00:00
#PJM --mpi proc=50
#PJM -j
#PJM -X

module load R/3.6.0
mpiexec.hydra \
-n 1 /path/to/bin/Rscript --vanilla /my_dir/calc.R 1 : \
-n 1 /path/to/bin/Rscript --vanilla /my_dir/calc.R 2 : \
-n 1 /path/to/bin/Rscript --vanilla /my_dir/calc.R 3 : \
# ... 50まで ... コマンド書くのが面倒なので別のプログラムで自動生成するとよい
-n 1 /path/to/bin/Rscript --vanilla /my_dir/calc.R 49 : \
-n 1 /path/to/bin/Rscript --vanilla /my_dir/calc.R 50 :
  • 12行目:mpiexec.hydraコマンドを使います。
  • 13~18行目: -n 1は1プロセス分使うという意味です。これを50プロセス分だけコロンでつなげて書きます。なお僕の環境ではRscriptを絶対パスにしないと動きませんでした。

この方法で、無事MPIで50プロセスを使って並列実行することができました。

Enjoy!

Rでのナウなデータ分割のやり方: rsampleパッケージによる交差検証

前処理大全の「分割」の章では、予測モデルの評価のためのデータセット分割方法が解説されています。基礎から時系列データへ適用する際の注意まで説明されているだけでなく、awesomeなコードの例がRおよびPythonで書かれており、実践的な側面もあります(お手元にぜひ!)。

しかし今回は、Awesome例とは異なる、より新しいやり方で・簡単にRでのデータ分割を行う方法を紹介したいと思います。前処理大全でも取り上げられているcaretパッケージですが、その開発者のMax Kuhnが開発するパッケージの中に rsample を使う方法です。ここでは前処理大全で書かれている一般的なデータと時系列データの交差検証による分割をrsampleの使い方を紹介しながらやっていきます。加えて、rsampleの層化サンプリングについても最後に触れます。

  • 1. レコードデータにおけるモデル検証用のデータ分割
    • zeallotによる代入
  • 2. 時系列データにおけるモデル検証用のデータ分割
  • おまけ: 層化抽出法
続きを読む

ggplot2 で facet ごとのヒストグラムに平均値の線を引く

ggplot2 で facet ごとのヒストグラムに平均値の線を引きたい。

例えば次のような感じ。

f:id:hoxo_m:20181108192139p:plain

Rコミュニティの Slack である r-wakalang で聞いたところ、即回答がもらえただけでなく、いろいろなやり方を教わったのでメモしておく。 みなさんありがとう。

基本作図

まずはデータを準備する。 ここでは有名な iris データから数値カラム x と facet を作るためのカテゴリカル変数 group を持つデータフレームを作成する。

data(iris) # iris データの読み込み(通常は必要ない)

library(dplyr)

# データの作成
data <- iris %>% select(x = Sepal.Length, group = Species)
# データ内容の確認
head(data)
    x  group
1 5.1 setosa
2 4.9 setosa
3 4.7 setosa
4 4.6 setosa
5 5.0 setosa
6 5.4 setosa

このデータに対して、グループごとにヒストグラムを作成する。

library(ggplot2)
ggplot(data, aes(x)) + 
    geom_histogram() +
    facet_wrap(~group, ncol=1)

f:id:hoxo_m:20181108192206p:plain

この3つのヒストグラムに対して、それぞれのグループの平均値を表す垂直線を追加したいというのが今回の目的。

facet ごとの集計値の扱い

ggplot2 で facet ごとに集計値を扱う場合は、別データとして集計済みのデータを用意する。

例えば今回は平均値の垂直線を引きたいので、次のように平均値を計算したデータフレームを用意する。

data_summary <- data %>%
    group_by(group) %>%
    summarise(mean_x = mean(x))
data_summary
  group      mean_x
1 setosa       5.01
2 versicolor   5.94
3 virginica    6.59

この集計データを用いて平均値をグラフに書き込むことができる(ura_hoxom さん)。

ggplot(data, aes(x)) + 
    geom_histogram() +
    facet_wrap(~group, ncol=1) +
    geom_vline(data = data_summary, aes(xintercept = mean_x), color = "red")

f:id:hoxo_m:20181108192139p:plain

目的は達成された。

他の方法

他の方法も教えてもらった。 ggplot2 では集計データを用意しなくても、集計用の関数を渡すと自動的にデータ集計してくれるらしい。

例えば、次のようにグループごとに平均値を集計する関数を用意する。

mean_by_group = function(d) {
    d %>% group_by(group) %>% summarize(mean_x = mean(x))
}

集計済みデータの代わりに、これを ggplot2 に渡すことができる(heavywatal さん)。

ggplot(data, aes(x)) + 
    geom_histogram() +
    facet_wrap(~group, ncol=1) +
    geom_vline(data = mean_by_group, aes(xintercept = mean_x), color = "red")

f:id:hoxo_m:20181108192139p:plain

また、集計する関数については次のように簡潔に作成できる(yutannihilation さん)*1

mean_by_group = . %>% group_by(group) %>% summarize(mean_x = mean(x))

まとめ

というわけで、みなさんいろいろ教えてくださってありがとうございました。

R-wakalang への参加の仕方については次の資料を参考にしてください。

Enjoy!

*1:これは magrittr の機能らしい。https://github.com/tidyverse/magrittr#building-unary-functions 参照。