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

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

sqlparse 入門 - 狭義の構文解析編 -

1. はじめに

こんにちは。ホクソエムサポーター(名称審議中)の藤岡です。 字句解析を紹介した前回の記事に続き、今回もsqlparseを中心に据えつつ狭義の構文解析について紹介・解説していきたいと思います。 また、狭義の構文解析で得られる構文木を解析するためのいくつかのメソッドについても解説します。

2. 注意

  • 本記事に書かれた内容は自分の理解に基づいたものであり、誤りが含まれている可能性がありますが、ご了承ください。
  • もしそういった不備にお気付きの際には、コメントでご指摘いただければ幸いです。
  • また、以下の解説ではSQLが何度か登場しますが、すべてHiveQLです。
  • 今回のサンプルプログラムは説明用に作成したものであり、不具合等が含まれる可能性が多分にあります。
  • 本記事の理解には木構造と字句解析についての簡単な知識が必要です。後者については、前回の記事を読んでいれば問題ないかと思います。
  • 前回の記事で予告していた内容は尺不足諸般の事情により次回に持ち越しになりました。

3. 狭義の構文解析

狭義の構文解析とは、字句解析をして得られたトークン列に対して、ある文法規則に基づいた構文構造を与える処理です。 この部分を厳密に扱うと難解になってしまうので、字句解析同様sqlparseにフォーカスして解説していきます。

3.1 構文木とは

sqlparseの場合*1、SQLクエリを字句解析して得られたトークン列(Statement, DML, Comment ... からなる系列)を入力として、構文木(parse tree) を返します。 これはその名の通り木構造データで、その各要素は以下の通りです。

  • 根ノード:  入力クエリ全体を表すトークン。
  • 内部ノード: 複数のトークンを束ねたトークン。
  • 葉ノード:  字句解析の結果得られた最小単位のトークン。

まず、以下のような簡単な例から作られる構文木を見ていきましょう。

SELECT
    id,
    age,
    CAST(age / 10 as integer) as generation
FROM ages

私たち人間がこのクエリを読むとき、ある程度のまとまりをもって解釈していきます。 つまり、id, age, generationの三つのカラムをSELECTして、そのうちgenerationCAST(age / 10 as integer) の結果で、他の二つはそのままagesテーブルから......といった具合です。 また、generationを解釈するためには、まずageカラムがあり (age) 、それを10で割った値を作り (age / 10) 、 最後にそれをIntegerにキャストする (CAST(age / 10 as integer)) といったように、意味的なまとまりで括りながら全体像をくみ上げるように理解していきます。

一方、上のクエリをたんに字句解析して空白を取り除いただけだと、以下のような結果が返ってきます。

tokens = [t for t in sqlparse.parse(sql_1)[0].flatten() if not t.is_whitespace]
tokens

>[<DML 'SELECT' at 0x112C43F48>,
 <Name 'id' at 0x1117190A8>,
 <Punctuation ',' at 0x111719108>,
 <Name 'age' at 0x111719348>,
 <Punctuation ',' at 0x1117193A8>,
 <Name 'CAST' at 0x1117195E8>,
 <Punctuation '(' at 0x111719648>,
 <Name 'age' at 0x1117196A8>,
 <Operator '/' at 0x111719768>,
 <Integer '10' at 0x111719828>,
 <Keyword 'as' at 0x1117198E8>,
 <Builtin 'integer' at 0x1117199A8>,
 <Punctuation ')' at 0x111719A08>,
 <Keyword 'as' at 0x111719AC8>,
 <Name 'genera...' at 0x111719B88>,
 <Keyword 'FROM' at 0x111719C48>,
 <Name 'ages' at 0x111719D08>]

この情報だけでは、先頭から順にSELECTがきて、idがきて、カンマがきて......という、人間の解釈方法とはかけ離れた読み方になってしまいます。 このギャップを埋めるために必要なのは、トークン列中のとある部分列を意味や役割に照らし合わせながら適切なまとまりとして抜き出すこと、その操作を再帰的に繰り返し、最後に一つの意味を構築することの二つが必要です。

この操作こそが狭義の構文解析であり、その結果として得られるのが構文木です。 age / 10の構文木を図で表すと図1のようになります。

f:id:kazuya_fujioka:20200211011756j:plain
`age / 10`の構文木

四角で囲われた部分がトークンです。この図では、トークンと対応する文字列が四角の中に表記されています。 このように、字句解析の結果では元のクエリのうち狭い範囲と対応するトークンがたくさん並んでいる状態だったのが、 狭義の構文解析によって一つに統合されているのが分かります。 そして、この図だと少し分かりにくいですが、きちんと木構造になっているのが分かります。

では、今度はトークンの表記を少し変えてみます。

f:id:kazuya_fujioka:20200211011932j:plain
`age / 10` の構文木(トークンでの表記)

この図から、構文木の各ノードはその対応する部分文字列の意味を表していることが見てとれます。つまり、構文木は様々なレベルにおけるクエリの意味を格納したデータであると言い換えることができます。

それでは、上の例における構文木をsqlparseを使って見てみましょう。

# 1. 除算の演算子を取り出す。
t = tokens[8]
> <Operator '/' at 0x111719768>

# 2. 除算の演算を取り出す。
str(t.parent), t
> 'age / 10'
('age / 10', <Operation 'age / ...' at 0x1121180C0>)

/は演算子を表すOperatorトークンで表現されています。 この演算は2つの項age10とを合わせたage / 10というまとまりとして解釈されるのが自然です。 実際、その親(parent属性)にはage / 10と対応するトークンオブジェクトが格納されていることが分かります。 加えて、このトークンオブジェクトは演算を表すOperationトークンとなっています。

この結果から、age, /, 10の3つの文字列に対応するトークンは、age / 10に対応するOperationトークンの子孫 とみなすことができます。

当然、age, 10に対応するトークンからもそれぞれage / 10に対応するトークンへと遡ることができます。

#1. `age`のgrandparent
tokens[7].parent.parent
> <Operation 'age / ...' at 0x1121180C0>

#2. `10`のparent
tokens[9].parent
> <Operation 'age / ...' at 0x1121180C0>

では、このOperationトークンの先祖をさらに辿っていくとどうなるでしょうか?

str(t.parent.parent), t.parent.parent
> ('age / 10 as integer', <Identifier 'age / ...' at 0x112118138>)

str(t.parent.parent.parent), t.parent.parent.parent
> ('(age / 10 as integer)', <Parenthesis '(age /...' at 0x111DC2C00>)

str(t.parent.parent.parent.parent), t.parent.parent.parent.parent
> ('CAST(age / 10 as integer)', <Function 'CAST(a...' at 0x111DC2CF0>)

と、このように意味的なまとまりを段階的に構成しながら、どんどんとトークンどうしが統合されていっていることが見て取れます。

図で表すと以下の通りです。

f:id:kazuya_fujioka:20200211012852j:plain
`CAST(age / 10 as integer)`の構文木

もっとも、age / 10 as integerがIdentifierトークンとして解釈されてしまっている(=型を表すintegerがエイリアスだと判定されている)ように、sqlparse.parseの結果には意味的に誤ったトークンも時折見られます。

プログラムに組み込む際はきちんとテストケースを作るか、それが難しい場合は実際の挙動を確認しつつ組み込むのが確実です。

3.2 構文木の利点

クエリをトークンの木として扱えることの真価は、クエリが複雑である場合に発揮されます。 例えば、以下のクエリを考えてみます。

SELECT
    id,
    a.age,
    a.generation,
    g.gender
FROM (
    SELECT
        id,
        age,
        CAST(age / 10 as integer) as generation
    FROM ages
) a
INNER JOIN genders g
ON a.id = g.id

前の例との大きな違いはサブクエリの存在です。 SQLではサブクエリを再帰的にもつことができる、 つまりサブクエリの中に別のサブクエリ、ということが理論上は制限なく繰り返されるため、 これをトークンの列を使って解析するのは骨が折れる作業です。

一方、木構造ではこうした再帰構造を部分木として表現することができるので、部分問題に分割して解くことができます。 また、よく使われるデータ構造であり、その解析をより一般的な問題に落とし込んで解くことができます。 例えば、サブクエリを全部探索する問題は、ただの部分木探索問題へと落とし込むことができます。 sqlparseを使えば、実装も簡単です。

from sqlparse.tokens import DML
from sqlparse.sql import TokenList, Token

def iter_subqueries(token: Token):
    if token.ttype == sqlparse.tokens.DML:
        yield token.parent
    if not isinstance(token, TokenList): # リーフノードの場合は子を探索しない
        return
    for t in token:
        yield from iter_subqueries(t)

実行してみると、うまく動くことがわかります。

sql_2 = """
SELECT
    id,
    a.age,
    a.generation,
    g.gender
FROM (
    SELECT
        id,
        age,
        CAST(age / 10 as integer) as generation
    FROM ages
) a
INNER JOIN genders g
ON a.id = g.id
"""
list(iter_subqueries(sqlparse.parse(sql_2)[0]))

> [<Statement ' SELEC...' at 0x11287C8B8>, <Parenthesis '( ...' at 0x11287C840>]

構文木の部分木として表すことのできるのは、サブクエリだけではありません。 例えば、3.1節のサンプルのような関数やその引数、SELECT節の中のカラムリスト、JOINの条件節など、 様々な部分を部分木として取り出すことができます。

4. 構文木の走査

構文木を解析するときには、3節のように親や子、兄弟を渡り歩いていくように進めていきます。 このように木のノードを辿っていくことを走査(traverse)と言います。 sqlparseの構文木を構成するトークンオブジェクトには、構文木の走査を簡単に実装するための種々のメソッドや属性が定義されています。 本節ではそれらを簡単に紹介します。

4.1 親子の参照

sqlparseのトークンオブジェクト(sqlparse.sql.Token)はそれぞれが構文木における親への参照情報を持っています。 加えて、葉ノード以外のトークンオブジェクト(sqlparse.sql.TokenList)は子への参照情報も持っています。

親へはparent属性、子へはtokens属性から参照できます。 後者については、トークンオブジェクトに対して直接インデックスを指定してもOKです。 例えば、token.tokens[0]token[0]と同じ結果を返します。

4.2 子の走査・探索

基本的に構文木を走査する場合には根から葉へと進んでいきます。 つまり、あるトークンtが与えらたとき、その子t.tokens[0], t.tokens[1], ..., t.tokens[i], ..., t.tokens[N]を再帰的に解析していくプロセスです。 0-Nのインデックスiを直接人手で扱うのもいいですが、sqlparseのトークンオブジェクトにはそれをサポートする種々のメソッドが定義されているので、 それらを活用した方が確実です。 ここでは、それらの機能を紹介します。

4.2.1 先頭トークンの取得

まず、一番シンプルに走査するなら先頭の子トークンを取り出すことになると思います。 先頭トークンはtoken_firstメソッドから参照できます。 必須の引数はなく、t.token_first()とするだけでもOKです。

なお、単に先頭を取り出すだけならばt[0]でいいと思われるかもしれませんが、 このメソッドの真骨頂は、状況に応じてコメントと空白をスキップすることができる点です。 コメントをスキップするにはskip_cm引数をTrueに、空白をスキップするにはskip_ws引数をTrueにします。

ただし、戻り値はトークンのインデックスなので、トークンオブジェクト自体を取り出す場合にはt.tokens[t.token_first()]としましょう。 該当するトークンがない場合にはインデックスの代わりにNoneが返ってきます。

4.2.2 前後のトークンの取得

あるノードからその前後の兄弟トークンへと辿っていくときには、 token_next, token_prevの2つのメソッドを使います。 例えば、tとその子トークンuのインデックスi_uが与えられたとき、 t.token_next(i_u)とすれば、その次のトークンが得られます。token_prevも同じ使い方で1つ前のトークンを取得できます。

ただし、戻り値はタプル(u, i_u)なので、間違えないように注意してください。 また、token_firstと同様の方法でコメントや空白のスキップも可能です。 該当するトークンがない場合には(None, None)が返ってきます。

4.2.3 マッチングによる探索(ちょっと発展)

コールバック関数による条件指定*2をすることで、 その条件にマッチした最初のトークンを探し出すことができます。

使うのはtoken_matchingメソッドです。第一引数にコールバック(もしくはそのリストないしタプル)を渡し、 第二引数に探索範囲の先頭となるトークンのインデックスを渡します。

コールバックの戻り値は、マッチした場合にif文の中で真と判定され、それ以外の場合に偽だと判定されるような定義とします。 第一引数で複数のコールバックを渡した場合、それらのいずれかを満たす(OR)を満たすトークンが返ってきます。

例えば、文字列が"foo"となっている最初のトークンを探索する場合、

t.token_matching(lambda t: t.value=="foo", 0)

とします。 他にも、IFとELSEのいずれかのKeywordトークンとマッチさせる場合には、

t.token_matching(lambda t: t.match(Keyword, "IF") or t.match(Keyword, "ELSE"), 0)

もしくは

t.token_matching([lambda t: t.match(Keyword, "IF"), lambda t: t.match(Keyword, "ELSE")], 0)

とすればOKです。

戻り値はトークンのインデックスです。 マッチしなかった場合にはNoneが返ってきます。

4.2.4 トークンインデックスの取得

トークンオブジェクトが得られているけれど、そのインデックスが不明という場合にはtoken_indexメソッドを使います。 tとその子トークンuが与えられたとき、uのインデックスi_ut.token_index(u)で得られます。 utの子ではない場合にはValueErrorがraiseされるので注意しましょう。

4.3 値の解析

基本的にはvalue属性を正規表現等で解析するだけなのですが、 いくつかのケースはsqlparseに定義されている機能を使った方が確実なので、それらも簡単に紹介します。

4.3.1 名前の取得

テーブルやサブクエリ、変数等はユーザーが定義した名前やエイリアスを持っている場合があり、それらは以下の4メソッドで取得できます。

  • get_real_name: 名前を返す。
  • get_alias: エイリアスを返す。
  • get_name: 呼び出し名を返す。エイリアスと名前が両方定義されている場合は、そのトークンが存在する場所での名称を返す。
  • get_parent_name: parent_name (table_foo.col_barであればtable_foo) を返す。

いずれも、値が取得できなかった場合にはNoneを返します。

4.3.2 引数の取得

Functionクラスのトークンオブジェクトは、その対応する関数の引数をget_parametersメソッドで取り出すことができます。

戻り値は各引数を表現したトークンです。

4.3.3 CASE文の各条件の取得

CASE文で指定される複数の条件は、Caseクラスのトークンオブジェクトに定義されているget_casesメソッドで取り出すことができます。

戻り値は(条件, 値)という形式のタプルのリストです。 ELSEの場合は条件の値がNoneになります。

4.3.4 値の正規化

value属性に入っている値は解析対象のクエリの文字列そのままなので、 SELECTが"Select"、" select"として格納されている可能性があります。 一方でnormalize属性には正規化済みの値(上の例では共通して"SELECT")が入っているので、 各種キーワードを解析したい場合にはこちらを使いましょう。

5. おわりに

ここまでの2回の記事でsqlparseを使うための前提知識をあらかた押さえることはできると思います。

とはいえ、sqlparseは「どんな機能があるのか」が分かった上で「それをどう活用するのか」という部分を理解するのが重要なライブラリだと思います。 そこで、次回の記事では(今度こそ)実際にsqlparseを本格的に活用するサンプルを紹介したいと思います。

一応、次回解説するサンプルコードは記事の公開に先立って公開します。 プログラムを動かして遊べるように簡単なインターフェースも実装しているので、よければ遊んでみてください。

github.com

Pythonプログラミングという観点からも、これまでより高度な内容になりますが、 次回もぜひ読んでいただければ幸いです。

Python実践入門 ── 言語の力を引き出し、開発効率を高める (WEB+DB PRESS plusシリーズ)

Python実践入門 ── 言語の力を引き出し、開発効率を高める (WEB+DB PRESS plusシリーズ)

  • 作者:陶山 嶺
  • 出版社/メーカー: 技術評論社
  • 発売日: 2020/01/24
  • メディア: 単行本(ソフトカバー)

*1:このように書いていますが、自分の知る限りでは狭義の構文解析の入出力はたいていの場合で sqlparse と同様です。

*2:Pythonだとsorted関数におけるkey引数が代表例です。

学習済み日本語word2vecとその評価について

ホクソエムサポーターの白井です。 今回は日本語の word2vec に着目し、日本語の学習済み word2vec の評価方法について紹介します。

自然言語は非構造化データであるため、単語や文章を計算機で扱いやすい表現に変換する必要があります。 そのための方法の1つに word2vec があり、Bag of Words (BoW)tf-idf とならんでよく用いられます。

一般に、word2vec は Mikolovが提案した手法 (CBOW, Skip-gram) をはじめ、 GloVefastText など、単語をベクトルで表現する単語分散表現のことを指します。

word2vec は教師なし学習のため、コーパスさえ準備できれば誰でも新しい単語分散表現を学習することができます。 しかし、実際に word2vec を使う際に、どのように評価すれば良いのかがよく分からず、配布されている学習済みモデルを適当に選んで使ってしまうことも多いかと思います。

そこで、本記事では、日本語 word2vec の評価方法を紹介し、実際に日本語の学習済み word2vec を評価してみます。 基本的な評価方法は英語の場合と同様ですが、日本語では前処理として分かち書きが必要となるため、単語の分割単位を考慮する必要があります。

今回評価するにあたって書いたコードはGithubで公開しています。

github.com

1. word2vec の評価方法

ここでは、学習済み word2vec モデルの評価方法を2つ紹介します。 その他にも、固有表現抽出や文書分類などの、実際の解きたいタスクに適用した結果で word2vec を評価する方法もありますが、今回は word2vec そのものを評価する方法にフォーカスします。

1つ目の評価方法は、単語同士の 類似度・関連性 を測る方法です。2つの単語が意味的に似ているかどうかを判定することで、学習された表現を評価します。

これを行うためのデータセットとして、英語の場合、WordSim353 が有名です。 このデータセットは353の単語ペアと、その単語ペアの類似度(1~10)で構成されています。


実際のデータ

データセットにおける単語ペアの類似度と、学習済みモデルにおける単語ペアの類似度のスピアマンの順位相関係数を算出し、評価指標とします。

2つ目の評価方法は、Mikolovの論文 で紹介されている、単語を類推する アナロジータスク です。

アナロジータスクとは、例えば、king - man + woman = queen のように、king と man の関係が queen における woman であることを予測するタスクです。

これを行うためのデータセットは、 Google_analogy_test_set_(State_of_the_art) から入手できます (論文に記載されているリンクは切れているので注意してください)。 このデータセットには次の表のようなデータが含まれています。

table1 from Efficient Estimation of Word Representations in Vector Space

2. 日本語の評価データセット

上記では英語の評価用データセットを紹介しましたが、日本語の word2vec モデルを評価するには、日本語のデータセットを使う必要があります。

ここでは、日本語 word2vec の評価に使えるデータセットを紹介します。

日本語類似度・関連度データセット (JWSAN)

  • Data: http://www.utm.inf.uec.ac.jp/JWSAN/
  • 名詞・動詞・形容詞の 類似度・関連度 データセット
  • 類似度と関連度がそれぞれ1~7の間の値で付与されている
  • すべての単語ペア2145組のデータセット (jwsan-2145) と、分散表現に適したデータに厳選した1400組のデータセット (jwsan-1400) が存在

日本語単語類似度データセット (JapaneseWordSimilarityDataset)

The Revised Similarity Dataset for Japanese (jSIM)

  • Data: https://vecto.space/projects/jSIM/
  • 上記JapaneseWordSimilarityDatasetを追加・修正した 類似度 データセット
    • full version: 品詞カテゴリを修正
    • tokenized version: full versionをmecabで分かち書き
    • Unambiguous: tokenized versionから分かち書きに曖昧性がない単語のみ選出

The Japanese Bigger Analogy Test Set (jBATS)

3. 日本語学習済み word2vec

上記で紹介した評価用データセットを使って、日本語の学習済み word2vec を評価することが本記事の目的です。 本記事では、新たに word2vec を学習することは避け、Web上から入手可能な日本語の学習済み word2vec で評価を行います。 ここでは、誰でも利用可能な日本語学習済み word2vec をまとめます。

日本語学習済み word2vec には、エンティティベクトルや白ヤギコーポレーション(以下白ヤギ)のように、日本語だけ作成・公開している場合と、 fastTextのように多言語対応を目的として日本語のモデルを公開している場合があります。

これらのモデルはそれぞれ、学習に利用するデータ・ツール・学習方法が異なります。 特に日本語の場合、文字を分かち書きする前処理が必要なため、どの方法・どの辞書を用いて分かち書きを行ったかが、モデルの大きな違いになってきます。

エンティティベクトル (WikiEntVec)

Wikipediaで学習されたモデルです。分かち書きにはmecab (neologd) が利用されています。

Wikipediaが日々更新されているためか、定期的に新しいモデルがリリースされているようです。 https://github.com/singletongue/WikiEntVec/releases で最新のモデルが確認可能です。

白ヤギ (Japanese Word2Vec Model Builder)

こちらもmecab (neologd) による分かち書きで、Wikipediaで学習されたモデルです。 50次元のみ公開されています。

chiVe

Sudachiの開発元であるWorks applications が公開しているモデルです。 こちらは 国立国語研究所の日本語ウェブコーパス(NWJC)で学習されたモデルです。また、分かち書きにはSudachiを使用しています。

fastText

fastText実装で学習されたモデルです。多言語モデルの実装中に日本語が含まれています。分かち書きにはmecabが用いられているという説明があります。 どの辞書を利用したかの記載がないのですが、おそらくデフォルトのipadicであると考えられます。

3.1 日本語学習済み word2vec まとめ

上記モデルを含め、公開されているword2vecについて、個人的に見つけた結果を以下にまとめました。

Name Model Data Dim Tokenizer Dict
WikiEntVec Skip-gram? Wikipedia 100,200,300 mecab mecab-ipadic-NEologd
白ヤギ CBOW? Wikipedia 50 mecab mecab-ipadic-NEologd
chiVe Skip-gram NWJC 300 Sudachi
bizreach Skip-gram 求人データ 100, 200 mecab ipadic
dependency-based-japanese-word-embeddings Dependency-Based Word Embeddings Wikipedia 100, 200, 300 Ginza
hottoSNS-w2v
(※要問い合わせ)
CBOW ブログ, Twitter 200 Juman, mecab mecab-ipadic-NEologd
朝日新聞単語ベクトル
(※要問い合わせ)
Skip-gram, CBOW, Glove 朝日新聞 300 mecab ipadic
fastText CBOW Common Crawl, Wikipedia 300 mecab ?
wikipedia2vec Skip-gram Wikipedia 100, 300 mecab ?
wordvectors Skip-gram?, fastText Wikipedia 300 mecab ?

学習方法 (Skip-gram or CBOW) について、READMEなどのドキュメントに明記されておらず、学習コードのパラメーターから判断したモデルに関しては ? をつけています。 (具体的にはgensim.models.word2vec.Word2Vecのパラメータ sg で判断しています)

4. 日本語 word2vec の評価

学習済み word2vec に対して、単語類似度の評価データセットを使ってスコアを算出し、比較してみます。

今回比較に利用するモデルとデータセットは以下の通りです。モデルの次元数が一致していないことには注意が必要です。 (次元数が大きいほど表現力が高くなるため)

word2vecモデル (カッコ内は 次元数 × 語彙数 )

  • WikiEntVec (200 × 1,015,474)
    • HPで公開されている2017年のモデル
  • 白ヤギ (50 × 335,476)
  • chiVe (300 × 3,644,628)
  • fastText (300 × 2,000,000)
    • gensimで読み込むためtxt のデータ

評価データセット

  • JWSAN
    • 2145 (jwsan-2145)・ 1400 (jwsan-1400)
  • JapaneseWordSimilarityDataset
    • adv (副詞)・verb (動詞)・noun (名詞) ・adj (形容詞)

また、JWSANに評価用スクリプトがなかったため、評価コードを実装し、公開しています (Github)。 複数のデータセットで評価するにあたって、スピアマンの順位相関係数はSciPyで実装されているspearmanr で統一しました。 未知語については評価から取り除いています。

実験結果 (スピアマンの順位相関係数) は以下のとおりです。太字がデータセットごとで最も良いスコアを示しています。 chiVeとfastTextが比較的良いスコアを出していることがわかります。

JapaneseWordSimilarityDataset JWSAN (similarity)
adv verb noun adj 2145 1400
WikiEntVec 0.250 0.334 0.292 0.231 0.643 0.499
白ヤギ 0.214 0.299 0.243 0.231 0.581 0.416
chiVe 0.394 0.326 0.361 0.475 0.701 0.541
fastText 0.350 0.386 0.357 0.459 0.737 0.610

また、実験における、未知語の割合は以下のとおりです。JWSDはJapaneseWordSimilarityDatasetを示しています。

oov

4.1 分かち書き

上記の結果で、未知語の割合が非常に高いのが気になります。 未知語とは辞書中に存在しない単語1 ですが、 word2vec においてはモデルの語彙に含まれない単語のことを意味します。しかしながら、膨大なデータから学習したモデルが、評価データセットに含まれる単語をほとんど学習していないというのはあり得ない気がします。

未知語扱いになってしまう原因として、分かち書きが考えられます。 jSIMの紹介でも述べましたが、評価データセットには、分かち書きすると複数の単語に分かれるタイプの単語 (複合語・派生語) が含まれています。

例えば、JapaneseWordSimilarityDatasetに含まれる動詞「掴んだ」「寂れた」はmecabの解析結果で以下のように、動詞と助動詞に分割されます。

掴んだ
掴ん  動詞,自立,*,*,五段・マ行,連用タ接続,掴む,ツカン,ツカン
だ 助動詞,*,*,*,特殊・タ,基本形,だ,ダ,ダ

寂れた
寂れ  動詞,自立,*,*,一段,連用形,寂れる,サビレ,サビレ
た 助動詞,*,*,*,特殊・タ,基本形,た,タ,タ

また、「議論した」「ディスカッションする」のような単語は、以下のように、名詞と動詞(と助動詞)に分割されます。

議論した
議論  名詞,サ変接続,*,*,*,*,議論,ギロン,ギロン
し 動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
た 助動詞,*,*,*,特殊・タ,基本形,た,タ,タ

ディスカッションする
ディスカッション    名詞,サ変接続,*,*,*,*,ディスカッション,ディスカッション,ディスカッション
する  動詞,自立,*,*,サ変・スル,基本形,する,スル,スル

このような、評価データとモデルの分割単位の不一致の問題点を解消するため、今回は分かち書きに対応した類似度も算出しました。 具体的には、モデルが未知語だった単語について、分かち書きをし、複数単語の和をその単語のベクトルとして扱います。 「議論した」であれば、「議論」「し」「た」のそれぞれのベクトルの和を「議論した」ベクトルとみなします。 (評価コードの get_divided_wv がその実装に当たります)

学習済みword2vecの分かち書き手法に合わせた未知語の割合は以下グラフのとおりです。どのモデルについても、未知語が減ったことがわかります。 特にchiVeは全てのデータセットにおいて未知語が0になりました。語彙数が4つの中で最も多く、また、Sudachiの3つの分割方法を考慮したモデルのため、柔軟に対応できていることが理由として考えられます。

compare

分かち書きを利用した場合のスピアマンの順位相関係数は以下のようになりました。 評価するデータが増えていることもあり、スコア自体は下がっています。

JapaneseWordSimilarityDataset JWSAN (similarity)
adv verb noun adj 2145 1400
WikiEntVec 0.182 0.149 0.248 0.158 0.733 0.610
白ヤギ 0.155 0.223 0.202 0.257 0.580 0.416
chiVe 0.255 0.260 0.310 0.404 0.701 0.541
fasttext 0.301 0.181 0.293 0.336 0.733 0.610

(参考) 分かち書きありなしのスコアの比較表 compare_table

4.2 ケーススタディ

実際に算出された類似度をみてみます。

未知語が0になったchiVeモデルの、jwsan-1400の結果に注目します。

類似度が高いと出力した単語ペアをみると、 「高校」「中学」や「裁判」「訴訟」のように、 意味的に似ている単語が上位になっています。 しかし、「動詞」と「主語」のような (どちらも文の構成要素の一つではあるものの) 対義的な意味である単語ペアも似ている扱いになっていました。

word1 word2 正解 予測
高校 中学 2.54 0.851
書店 本屋 5.45 0.836
裁判 訴訟 3.5 0.814
出版 刊行 4.01 0.792
動詞 主語 1.38 0.791

一方で、以下の「写し」「複製」のように、 正解データは類似度が高いにも関わらず、モデルは類似度が低いと出力したペアもありました。

word1 word2 正解 予測
写し 複製 4.73 0.283
支度 用意 4.71 0.339
決まり 規律 4.64 0.167
焼く 燃やす 4.36 0.363

5. まとめ

日本語学習済み word2vec とその評価方法について紹介しました。

今回全体的に精度が良かった chiVe ですが、モデルサイズが12.5GB程度あるので、実際に利用する場合はメモリ等の環境を気にする必要がありそうです。 (fastTextが4.5GB、WikiEntVecが2.0GB程度ということも考慮すると、かなり大きいことがわかるかと思います。)

白ヤギは未知語が多く精度が低かったものの、今回扱った4つのモデルの中では最もモデルサイズが小さい (24MB程度) です。

今回紹介した類似度の精度だけでなく、環境や状況に応じて学習済み word2vec を使い分けることが必要だと思います。

形態素解析の理論と実装 (実践・自然言語処理シリーズ)

形態素解析の理論と実装 (実践・自然言語処理シリーズ)

  • 作者:工藤 拓
  • 出版社/メーカー: 近代科学社
  • 発売日: 2018/10/04
  • メディア: 単行本

GitHub Actions実行時に依存するRパッケージのインストールをキャッシュ化する

ホクソエムの u_ribo です。漫画「ブリーチ」の石田雨竜に親近感を感じます。仕事はシュッと終わらせて趣味の時間を増やしたいですよね。

要約

  • GitHub Actionsに対してrenvを使ったキャッシュ機能を活用。依存するRパッケージのインストール時間を短縮する
    • パッケージのインストールに要する時間を1/25に短縮
    • renvのキャッシュはOSによりパスが異なるため、GitHub Actionsを実行するOSに応じて変更が必要になる
    • キャッシュ機能はpipでも使えるため、Pythonによる処理を適用するときも便利
  • GitHub Actionsでrenvのキャッシュを利用するサンプル

はじめに

GitHub上でビルド、テスト、デプロイ等の作業を自動的に行えるGitHub Actionsが便利ですね。RやPythonも実行可能なため、データ分析の作業を補助する機能を持たせることもできるかと思います。例えば、リポジトリ上のデータが更新されたタイミングで分析コードを走らせてレポートを作成するといった具合です。このブログでも id:shinichi-takayanagi さんが記事を書かれています。

blog.hoxo-m.com

そんなGitHub Actionsですが、RやPythonのコードを実行する際にパッケージのインストールが必要になる場合があります。パッケージの追加もコマンドで行えるため、それ自体は問題になりません。しかし処理時間に関してはどうでしょう。パッケージのインストールはGitHub Actionsが動作するたびに実行されます。依存パッケージが多い・頻繁に実行されるジョブでは、ここでの作業がジョブ全体に要する時間の大部分を占める恐れがあります。

そこで、ジョブの過程で取得した依存パッケージを次回の実行時に再利用できるようにする、キャッシュ機能を活用します。これにより実行時間の短縮が期待できます。

公式のExampleを見ると、Pythonであればpip、Nodeはnpmやyarnを利用したパッケージ管理ツールを利用する方法が書かれています。ではRの場合はどうするの?が本記事の話題です。ここではRパッケージのインストール結果をキャッシュ化する例として、Rのパッケージ管理にrenvを利用して、pkgdownでのウェブサイトの構築を行うワークフローに導入することとします。

pkgdownでのウェブサイトの構築を行うワークフローについては id:yutannihilation さんの下記の記事をご覧ください。

notchained.hatenablog.com

この記事を参考に、まずはキャッシュ機能を使わないGitHub Actionsの設定を済ませます。本記事では、ここで用意したyamlファイルを編集します。

renvでパッケージ管理

renvはRStudioにより開発されているパッケージ管理のためのパッケージです。プロジェクトで利用されるパッケージの依存関係を明らかにし、再現可能な形で環境を構築します。具体的にはプロジェクトで使われるRパッケージとそのバージョン、インストール元の情報等を renv.lock ファイルに記録します。

speakerdeck.com

用意したプロジェクトに対してrenvによる管理を有効化しましょう。renv::init() を実行すると renv/フォルダ、renv.lockファイルが生成されます(.Rprofileがない場合はこれも)。

この時、すでにRコードが存在する場合、利用するパッケージおよびその依存パッケージがrenvによりインストールされ、その情報がrenv.lockに記録されます。パッケージのインストール先は従来Rが利用する環境(/usr/local/lib/R/site-library//Library/Frameworks/R.framework/Versions/{Rバージョン}/Resources/library)とは異なる環境となります。それはホームディレクトリに近い場所とrenvを有効化したプロジェクトの中です。

最初の「ホームディレクトリに近い場所」はOSごとに異なります。具体的には以下のとおりです。

プラットフォーム 場所
Linix ~/.local/share/renv
macOS ~/Library/Application Support/renv
Windows %LOCALAPPDATA%/renv

renvを使ったプロジェクトでパッケージをインストールするとこのディレクトリにファイルが保存されます(renvのキャッシュを無効化した場合はプロジェクトの中に直接保存されます)。そのため、他のRプロジェクトでパッケージのアップデートを行ってもその影響を受けません。また、依存関係も記述されているので再インストールも安全に行えます。

renv.lockに書かれたパッケージを復元するにはrenv::restore()を実行します。一度インストールされたパッケージであればキャッシュからインストールが行われるため、ファイルのダウンロード、ビルドの作業が省略されます。

またプロジェクトで利用するパッケージに変更(追加や更新、削除)があった際は renv::status() で確認、必要に応じて renv::snapshot()renv::lock を更新しましょう。

詳しい利用方法はここでは省略します。興味のある方は

qiita.com

speakerdeck.com

をご覧ください。

GitHub Actionsにrenvを導入する

続いてGitHub Actionsにrenvを導入する方法です。pkgdownによるウェブサイトのビルドを行うActionsではmacOS上で動作します。そこでrenvのキャッシュもmacOS仕様にする必要があります。

.github/workflows/ にある pkgdownのウェブページをビルドするYAMLファイルにある以下の箇所を変更します。

      - name: Install dependencies
        run: |
          Rscript -e 'install.packages("remotes")' \
                  -e 'remotes::install_deps(dependencies = TRUE)' \
                  -e 'remotes::install_github("jimhester/pkgdown@github-actions-deploy")'
  1. renvをインストール
  2. renv::restore()で必要なパッケージを復元する
    - name: Install dependencies
      run: |-
        Rscript -e "install.packages('renv')" \
                -e "renv::restore(confirm = FALSE)"

YAMLを書き換えたら、手元のRコンソールでrenv::install("jimhester/pkgdown@github-actions-deploy")renv::snapshot()を実行してrenv.lockを更新します。これは元のYAMLに書かれているremotes::install_github("jimhester/pkgdown@github-actions-deploy")の代わりに必要な処理です。

続いてキャッシュの指定です。今回はmacOSで動作させているので、キャッシュのpathもmacOSの~/Library/Application Support/renvとします。

    - uses: actions/cache@v1
      if: startsWith(runner.os, 'macOS')
      with:
        path: ~/Library/Application Support/renv
        key: ${{ runner.os }}-renv-${{ hashFiles('**/renv.lock') }}
        restore-keys: |
          ${{ runner.os }}-renv-

こちらが編集後のyamlファイルです。

それではキャッシュ化の効果を見てみましょう。依存パッケージのインストールにかかった時間 (Install Package Dependenciesの部分)を見ます。最初の処理で4分38秒だったのに対し、キャッシュが働く2回目はわずか11秒で完了しています。1/25の差は大きいですね。出力を見ても、きちんとキャッシュを利用しているのがわかります。

f:id:u_ribo:20200123174147p:plain

任意のOS、Rコードの実行に必要な依存パッケージを扱う例

renvのキャッシュはOSごとに異なることは述べたとおりです。OSごとのキャッシュ先の指定方法は、PRを出してマージされたのでGitHub Actionsのcacheリポジトリに記載されています。

https://github.com/actions/cache/blob/master/examples.md#r---renv

また、今回解説したウェブサイト構築以外の用途で利用する際のサンプルとして、以下のリポジトリへのリンクを示しておきます。

GitHub - uribo/renv_ghaction: for demonstration

投げていたジョブが完了しました。お先に失礼します! Enjoy!

Rと3Dプリンターで八ヶ岳のミニチュアを作る。

この記事について

この記事はR Advent Calendar 2019の19日目の記事です。

はじめに

ホクソエムサポーターの輿石です。最近3Dプリンターを買いました。遠い世界のガジェットのように思っていましたが、家庭用であれば3万円前後で買えてしまうんですね。
3Dプリンターの使い方としてCADで自分の作りたいものを設計していくのが一般的かと思いますが、Rで3Dのプロットを作成することで、データから立体物を作ることが可能です。
この記事では、3Dのプロットを作成できるrayshaderパッケージと、基盤地図情報の地形図データをRに読み込むことができるfgdrパッケージを使って、故郷八ヶ岳周辺のミニチュアを作ってみます。

データの取得

国土地理院の基盤地図情報サイトからデータをダウンロードします。 今回は数値標高モデルの10mメッシュのデータを使います。八ヶ岳周辺のメッシュコードは533862、533863、533872、533873でした。地名検索機能が付いた地図のUIが用意されており、クリックだけで欲しい場所のデータを指定することができました。

データの読み込み

まずは今回必要になるパッケージを読み込みます。

library(fgdr)
library(rayshader)
library(raster)
library(tidyverse)

基盤地図情報からダウンロードしたxml形式のデータをfgdrパッケージのread_fgd_dem関数を使ってraster形式で読み込みます。広い範囲(複数のメッシュコードの範囲)を対象にしたい場合には複数のxmlファイル結合する必要があるので、読み込んでからraster::margeで結合します。

files <-
  list.files("data/", full.names = T, pattern = "dem10b")   
files
[1] "data/FG-GML-5338-62-dem10b-20161001.xml" "data/FG-GML-5338-63-dem10b-20161001.xml" "data/FG-GML-5338-72-dem10b-20161001.xml"
[4] "data/FG-GML-5338-73-dem10b-20161001.xml"
raster_list <-
  files %>%
  map(~fgdr::read_fgd_dem(., resolution = 10, return_class = "raster"))

r <- purrr::reduce(raster_list, raster::merge,  tolerance = 0.2)

rayshaderを使った3D出力

rayshaderは公式のドキュメントが充実しているので、詳細はこちらが役に立つと思います。
rayshaderで扱えるようにrasterをmatrixに変換してからプロットします。

elmat <- rayshader::raster_to_matrix(r)
elmat %>%
  rayshader::sphere_shade(texture = "desert") %>%
  rayshader::plot_map()

f:id:kosshi08:20191216015641p:plain:w500
基盤数値情報DEMデータを元に作成

elmat %>%
  rayshader::sphere_shade(texture = "desert") %>%
  rayshader::plot_3d(elmat, zscale = 10, fov = 0, theta = 135, zoom = 0.75, phi = 45, windowsize = c(1000, 800))

rayshader::render_snapshot(clear=FALSE)

f:id:kosshi08:20191216020508p:plain
基盤数値情報DEMデータを元に作成

3Dプリンターで印刷する

rayshaderパッケージのsave_3dprint関数を使うことで、3Dプリンターでは一般的なstlというフォーマットでデータを出力することができます。stlは造形物を三角形の集合体で表現するファイル形式なようです。
今回私が購入した熱溶解積層方式の3DプリンターはstlファイルをG-Codeというファイル形式に変換して読み込む必要があるので、3Dプリンターに付属しているソフトで変換します。(stlファイルがあればほとんどの3Dプリンターで何らかの方法で出力できるのではと思います。)

rayshader::save_3dprint("Yatsugatake.stl")
# このあと3Dプリンター付属のファイルでG-coede形式に変換

G-code形式のファイルを3Dプリンターに送って出力します。こんな感じで線を描き少しずつ重ねていって八ヶ岳を出力していきます。

f:id:kosshi08:20191218010817j:plain:w500

私が使っている3Dプリンターでは、15cm四方の大きさで8時間で出力できました。(7cm四方の大きさで約1時間弱、25cm四方だと6日間弱かかります!)

f:id:kosshi08:20191218011238j:plain:w500
基盤数値情報DEMデータを元に作成

八ヶ岳の裾野までプリントしたため山の部分が小さくなってしまいました。標高1600m以上の部分に絞ってプリントしてみます。

values(r)[values(r) < 1600] <- NA
elmat = rayshader::raster_to_matrix(r)
elmat %>%
  sphere_shade(texture = "desert") %>%
  plot_3d(elmat, zscale = 10, fov = 0, theta = 135, zoom = 0.5, phi = 45, windowsize = c(1000, 800))
save_3dprint("Yatsugatake_1600m.stl")

f:id:kosshi08:20191218010614j:plain:w500
基盤数値情報DEMデータを元に作成

山の稜線がはっきり認識できるミニチュアができました。

3Dプリンターについて

3Dプリンターにもいくつか種類があり、家庭用では主にプラスチックを熱で溶かして少しずつ積層していく熱溶解積層方式と、光をレジンに当てて硬化させていく光造形方式の二つが主流になっています。
私の購入した3DプリンターはANYCUBICというメーカーの熱溶解積層方式のエントリーモデルで、Amazonでもベストセラーになっている定番のものです。 光造形方式の方がきれいに出力できるようですが、レジンの取り扱いが難しかったり、造形時のレジンの臭いが気になる人もいるようなので、熱溶解積層方式を選びました。エントリーには熱溶解積層方式が良いのではと思います。

おわりに

この記事ではRとfgdrとrayshaderを使って八ヶ岳を3Dで可視化し、3Dプリンターで出力してみました。rayshaderではggplot2のオブジェクトも3Dで表示できるので、一般的なプロットも3Dプリンターで印刷することができます(使いどころは??)。3Dプリンターがあると可視化の幅が広がりますね。2020年、一家に1台3Dプリンターはいかがでしょうか。

Enjoy!

参考

GitHub Actions でRのパッケージの継続的インテグレーション(CI)を行う

本記事について

R Advent Calendar 2019 1103日目の記事です。 空きがなかったので適当に書きます。

1103->11月03日は”いいおっさん”の日です、各位、よろしくお願いいたします。

はじめに

株式会社ホクソエムの高柳です。

この記事ではGitHub ActionsとR、特にRのパッケージ開発と組み合わせて使う方法を書きたいと思います。 GitHub Actionsとは”コードをビルド、テスト、パッケージング、リリース、デプロイするためのプロセスの集合”であるワークフローを、GitHub リポジトリに直接作成することができる仕組みです。 詳しくは この辺なんかを読むとよいでしょう。

R言語への適用例としては、丁度R Advent Calendar 2019 8日目:GitHub Actions で R の環境ごとのベンチマークをとったにも記事があります。

なぜGitHub Actionsを使うのか?

R言語のパッケージのCIツールとしてはTravis CIがほぼデファクトスタンダードかなと思うのですが、これをPrivate repositoryや会社で使おうと思うと費用がかかってしまいます。 もちろん会社・個人として応援できる場合は全然課金すれば良いと思うのですが、そうではない場合には何某かの代替手段が欲しいところです。

そこでGitHub Actionsが使えるかどうか調べてみた、というお話です。

Rパッケージ開発での使い方

サッとググって調べたところ、

github.com

が見つかるのですが、これを素直に使うよりも usethis パッケージにスッと導入された関数を使う方が簡単そうなので、今回はこれを使うことにします。 もっといいやり方があったら教えてください!

まず、CIしたいパッケージはRStudioやusethisから適当に作っておくとして、usethisパッケージをgithubからインストールしておきます。 これは、上述した機能がまだCRAN版にないための対応です。

devtools::install_github("r-lib/usethis")

usethisをインストール or 更新したのち、Rのコンソールから

usethis::use_github_actions()

を叩くと、開発しているパッケージのディレクトリに .github/workflows/R-CMD-check.yaml というファイルができています。

$ cat .github/workflows/R-CMD-check.yaml
on: [push, pull_request]

name: R-CMD-check

jobs:
  R-CMD-check:
    runs-on: macOS-latest
    steps:
      - uses: actions/checkout@v1
      - uses: r-lib/actions/setup-r@master
      - name: Install dependencies
        run: Rscript -e "install.packages(c('remotes', 'rcmdcheck'))" -e "remotes::install_deps(dependencies = TRUE)"
      - name: Check
        run: Rscript -e "rcmdcheck::rcmdcheck(args = '--no-manual', error_on = 'error')"

このファイルをgithubにアップロードするだけです、簡単ですね???

なお、中で使われているr-lib/actionsやこの機能自体は、最近だと glueパッケージで(個人的にはよくお世話になる)有名なRStudioの Jim Hester 氏がメインで開発しているので安心・安全ですね!

README.mdにStatusバッジを張り付けたい!というオシャレさんは以下のようにするとバッチが張り付けられます。

[![R build status](https://github.com/shinichi-takayanagi/ghactiontest/workflows/R-CMD-check/badge.svg)](https://github.com/shinichi-takayanagi/ghactiontest)

この辺のもう少し詳しい使い方や解説については英語での解説記事があるので、こちらも参考にするとよいでしょう。 - Github actions with R

サンプルコード

ここでの手順を踏まえた上で、適当に作ったパッケージにPull Requestをこんな感じで出してみました。

Update README.md by shinichi-takayanagi · Pull Request #1 · shinichi-takayanagi/ghactiontest · GitHub

勝手に単体テスト(正確にはR CMD check相当の処理)を行ってくれます。 これで安心してRのパッケージ開発に専念できますね!

Enjoy!

sqlparse 入門 - 字句解析編 -

本記事はPythonその2 Advent Calendar 2019に参加しています。

1. はじめに

こんにちは。ホクソエムサポーターの藤岡です。 データアナリストらしいですが、分析そっちのけでPySparkと戯れてます。

メソッドチェインを積み上げていくスタイルで最初はちょっと使いづらいなと思ったのですが、 DataFrameが思いのほか使いやすくて、 気がつくとPySpark無しでは生きられない身体になってしまいました......。

さて、今回紹介するライブラリはsqlparseです。

sqlparseは、SQLエンジンを一切使わずにSQLを解析し、そこから種々の情報を得ることができる非常に頼もしいライブラリです。 例えば、SQLの山の中から欲しいテーブルのDDLを簡単に検索できるようにしたり、 さらにそこからカラムの情報を抜き出してきたり、業務で大変お世話になっております。

ただ、パーサー自体が情報分野にいないと馴染みがないものであり、どう使っていいのか分かりづらい一面があります。 加えて、トークン周りの実装にクセがあるため正直馴染みづらいです。

例えば、トークンの種別を表すsqlparse.tokens.Tokenというオブジェクトがあるのですが、実体はtupleのサブクラスのインスタンスです。 isinstance(token, Token)をやると怒られます*1。 ついでに、sqlparse.sql.Tokenというオブジェクトもあるんですが、こっちはクラスです。なんで?

というわけで、前置きもそこそこに解説に入りたいと思います! 色々と書いていたら内容が重くなってしまったので、字句解析編と狭義の構文解析編の二つに分けています。

2. 注意

  • 本記事に書かれた内容は自分の理解に基づいたものであり、誤りが含まれている可能性がありますが、ご了承ください。
  • もし本記事の不備にお気付きの際には、コメントでご指摘いただければ幸いです。
  • また、以下の解説ではSQLが何度か登場しますが、すべてHiveQLです。
  • 今回のサンプルプログラムは説明用に作成したものであり、実際の業務での使用は一切想定していないことをご留意ください。
  • 本記事のプログラムは全て以下の環境で動作させています。     - Python:   3.6.4     - sqlparse: 0.3.0 (2019/12/07現在の最新ver)

3. サンプルコードについて

本記事のサンプルプログラムはリポジトリに完成品がありますので、ぜひ遊んでみてください。

github.com

4. 導入

Python3.4以上*2がインストールされている環境で、$ pip install sqlparseしてください。

5. sqlparseについて

sqlparseは、作者によればA non-validating sql parser module for Python、つまり、Pythonの非検証のSQLパーサーモジュールです。 といっても、計算機科学でもやってなければ、なんのこっちゃと戸惑うかと思います。

そこで、本章ではパーサーと、その重要な機能の一つである字句解析について簡単に解説します。

少し小難しい話かもしれませんが、 sqlparseを使うときには、ただ使い方を知るだけでなく、 そのコアとなるパーサーについて簡単に知っておくことが重要です。 それにより機能を正しく、かつ十全に活用することができるので、 ぜひ本章を読んでみていただければと思います。

5.1. パーサー (parser) について

パーサーとは、テキストを一定の文法にしたがって解析するアルゴリズム、もしくはその実装のことです。

パーサーが行う処理を広義の構文解析と呼びます。

例えば、SQLやPythonのようなプログラミング言語で書かれた命令を実行するためには、 そこに書かれた個々の命令やその実行フローなどを計算機が読み取る必要があります。 それを実現するにはパーサーによるテキスト解析が欠かせません。

さらに、広義の構文解析は字句解析狭義の構文解析の2ステップに分けることができます。 直感的なイメージとしては、私たちが英語を読解する場合と照らし合わせると、

  • 字句解析: アルファベットの並びを単語・熟語として認識すること
  • 狭義の構文解析:  文法の適用

に近い処理です。

こうした解析の結果として得られる情報は計算機だけでなくそれを扱う人間にとっても有用であり、 私たちがsqlparseを活用する意味もそこにあります。

5.2. 非検証パーサーについて

パーサーが処理するテキストの妥当性をチェックすることを検証と言います。 そして、検証を行うパーサーを検証パーサー、検証を行わないパーサーを非検証パーサーと言います。

ただ、これは一般的にXMLパーサー等で使われる言葉であり、SQLパーサーの文脈ではあまり見ない言葉です。 なので、ここではエラーがあっても無視して解析を進める、くらいの認識でいいかと思います。

5.3. 字句解析についてもう少し詳しく

5.1節で軽く触れた字句解析について、sqlparseを使いながらもう少し詳細に見ていきます。 なお、ここで使うコードについては後ほど解説しますので、まずは結果に注目していただければと思います。

さて、5.1節の説明では字句解析について、アルファベットの並びを単語・熟語として認識することと表現しましたが、 より正確には、文字列を入力としてその文字列を表すトークン列を得る処理のことです。

例えば、以下のようなSQLをトークン列に変換していくことを考えます。​

SELECT
    id,
    age,
    gender
FROM
    hoge_table 
WHERE
    age > 10;

このsql (sql_1とします) を入力として以下のコードを実行してみます。

import sqlparse

sql_1 = "SELECT id, age, gender FROM hoge_table WHERE age > 10;"
parsed_queries = sqlparse.parse(sql_1)
parsed_query = parsed_queries[0]
list(parsed_query.flatten())

すると、以下のようなトークンオブジェクトのリストが得られます。

[<DML 'SELECT' at 0x107D69A08>,
 <Whitespace ' ' at 0x107D841C8>,
 <Name 'id' at 0x107D84348>,
 <Punctuation ',' at 0x107D84228>,
 <Whitespace ' ' at 0x107D842E8>,
 <Name 'age' at 0x107D843A8>,
...

一つ一つのトークンオブジェクトは、<トークン '値' at アドレス>という表記になっています。 ここで注意なのですが、構文解析におけるトークンとは"SELECT"のような文字列ではなく、 その種別の名称であるDMLの部分です。 また、空白があっても必ずしもそこを基準に分割される訳ではありません。 例えば、sqlparseは"LEFT JOIN"を"LEFT"と"JOIN"には分割しません。

最初のトークンはDML、次のトークンはWhitespace、さらにName,Punctuation,...と続いていっています。 それぞれのトークンは、sql_1の文字列の分割である、"SELECT", " ", "id", ", ", ...といった値 (文字列) と対応しています。

なお、上で紹介したトークンはsqlparseによるトークン分割で得られる最小単位のトークン列であり、より大きな単位でトークンに分割することもできます。 この部分には狭義の構文解析が深く関わってくるので、詳しくは次回の記事で触れます。

5.4. 基本的な使用方法

5.3節で使用したサンプルコードについて簡単に解説しながら、 sqlparseの使い方の基本について触れていきます。

まず、sqlparse.parse関数にSQLの文字列を渡すと広義の構文解析が行われ、その結果が返されます。

import sqlparse

sql_1 = "SELECT id, age, gender FROM hoge_table WHERE age > 10;"
parsed_queries = sqlparse.parse(sql_1)

結果はタプルとして返されるので、ここで渡すsqlにはクエリが複数含まれていても大丈夫です。 ただし、今回のように一つしかクエリが入っていない場合も結果はタプルとして返ってくるので、 クエリを取り出すにはインデックスを指定する必要があります。

parsed_query = parsed_queries[0]

sqlparse.parse()で得られる各パース結果は、 一つのクエリのパース結果が一つのトークンオブジェクト*3として得られるようになっています。

例えば、

-- hoge
SELECT hoge FROM t1;
-- fuga
SELECT fuga FROM t2;
-- piyo
SELECT piyo FROM t3;

を渡すと、

(<Statement ' -- ho...' at 0x107D86D68>,
 <Statement ' -- fu...' at 0x107D90048>,
 <Statement ' -- pi...' at 0x107D86DE0>)

が返ってきます。

最後に、今回は字句解析の結果(最小分割単位のトークン分割)だけを得たいので、flattenメソッドで狭義の構文解析の結果を捨てます。

list(parsed_query.flatten())

こうして、5.3節で得られたトークン列が得られます。

[<DML 'SELECT' at 0x107D69A08>,
 <Whitespace ' ' at 0x107D841C8>,
 <Name 'id' at 0x107D84348>,
 <Punctuation ',' at 0x107D84228>,
 <Whitespace ' ' at 0x107D842E8>,
 <Name 'age' at 0x107D843A8>,
...

以後、特に断りがない場合は、この最小分割単位のトークン列のことを、たんにトークン列と呼ぶこととします。

6. 使用例: CREATE TABLE DDL Finder

6.1 概要

ある案件で、テーブルの定義のDDLが書かれた大量のsqlファイルがフォルダに格納されている現場に遭遇しました。 その中からいちいち目視で必要なファイルを探すのは面倒だった上に、ファイル名が日本語だったり、コメント中に別のテーブル名が入っていたりして、findコマンドでも探せない......。 自分はそんなとき、テーブル名を入力としてそのDDLを実行してCREATE TABLE & MSCK REPAIRをする関数を作成しました。

そのキモとなるのがSQLの解析部分です。当時は正規表現を使ってサクッと作ったのですが *4、 今回はそれをsqlparseを使って実装してみます。

想定する要件は以下の通りです。

  • 多数の.sqlファイルが存在するフォルダが探索対象。
  • それぞれの.sqlファイルにはCREATE TABLE文だけでなく、MSCK REPAIR TABLE文のような別のクエリが書かれている。
  • テーブル名は探索範囲内でユニーク。ただし、当該ファイル以外のファイルのコメント等には出現しうる。
  • ファイル名はテーブル名とは無関係。
  • テーブル名はコメントやlocation等にも出現。
  • 入力はテーブル名とフォルダのパス、出力は当該SQLファイルのパス。
  • 見つからなければFileNotFoundErrorをraiseさせる。

6.2. 実装

まず、sqlparseが関わる部分以外をさっくりと作ると、以下のような感じになります。

from glob import iglob
from pathlib import Path

def find_table_definition(
        sql: str, table: str, db: Optional[str] = None) -> Optional[TokenType]:   
    """引数で指定したテーブルの定義を文字列`sql`中から探し出す関数。"""
    <<メイン部分>>

def find_hive_ddl(directory: str, table: str, db: Optional[str] = None):
    """引数で指定したテーブルの定義スクリプトをフォルダから探し出す関数"""
    for p in iglob(directory, recursive=True):
        path = Path(p)
        if not path.is_file():
            continue
        with p.open() as f:
            if find_table_definition(f.read(), table, db):
                return p
    raise FileNotFoundError(
        "DDL file of {table} not found.".format(table=table)
    )

次に、上記のメイン部分find_table_definitionを作成していきます。

処理の流れとしては以下の通りです。

  1. SQLテキストからCREATE TABLEクエリを抽出
  2. クエリ中からテーブル名のトークンオブジェクトを抽出。
  3. テーブル名を取得。
  4. 一致判定の結果を返す。

HiveQLの場合、以下の条件を満たすのならばCREATE TABLEのDDLです。

  • コメントを除けば、DDLトークン"CREATE"から始まる。
  • さらにTABLEトークンが続く。ただ、Keywordトークンの"TEMPORARY"と"EXTERNAL"を挟むことがある。

上記の条件を満足しているかを判定する部分のコードは以下の通りです。

from typing import Optional, List, Sequence
from sqlparse.tokens import Keyword, DDL, Token, Name, Punctuation
from sqlparse.sql import Statement
import sqlparse


TokenType = Token.__class__
DEFAULT_DB = "default"


def find_table_definition(
        sql: str, table: str, db: Optional[str] = None) -> Optional[TokenType]:
    """`table`で指定したテーブルの定義を文字列`sql`中から見つける"""
    db = db or DEFAULT_DB
    # 1. パースしてトークン列に分割
    for query in sqlparse.parse(sql):
        tokens = [
            t for t in query.flatten()
            if not (t.is_whitespace or is_comment(t))
        ]
        # 2. <DDL CREATE>を検証
        if not match_consume(tokens, (DDL, "CREATE")):
            continue
        # 3. <Keyword TEMPORARY> <Keyword EXTERNAL> をスキップ
        match_consume(tokens, (Keyword, "TEMPORARY"))
        match_consume(tokens, (Keyword, "EXTERNAL"))
        # 4. <Keyword TABLE>を検証
        if not match_consume(tokens, (Keyword, "TABLE")):
            continue
        # 5. <Keyword IF>, <Keyword NOT>, <Keyword EXISTS>をスキップ
        if match_consume(tokens, (Keyword, "IF")):
            if not match_consume(tokens, (Keyword, "NOT")):
                continue
            if not match_consume(tokens, (Keyword, "EXISTS")):
                continue
        # 6. テーブル名が一致したら返す。
        if db == DEFAULT_DB and match_consume(tokens, (Name, table)):
            return query
        if (match_consume(tokens, (Name, db))
                and match_consume(tokens, (Punctuation, "."))
                and match_consume(tokens, (Name, table))
        ):
            return query


def is_comment(token: TokenType) -> bool:
    return token.ttype in sqlparse.tokens.Comment


def match_consume(tokens: List[TokenType], match_args: Iterable) -> bool:
    if tokens[0].match(*match_args):
        tokens.pop(0)
        return True
    return False

試してみると、うまく動くことが確かめられます。 以下の例はサンプルコードリポジトリのSQLで試した場合です。

find_hive_ddl("./ddls/*.sql", "apachelog")

> './ddls/apachelog_ddl.sql'

6.3. 実装詳細

ここでは、find_table_definition()の実装について、ポイントとなる箇所を解説していきます。 まず、文字列sqlをパースして、そのflattenでトークン列に変換してから、空白文字とコメントを取り除いています。

for query in sqlparse.parse(sql):
    tokens = [t for t in query.flatten() if not (t.is_whitespace or is_comment(t))]

空白文字の除去は簡単で、is_whitespace属性で判定できます。

ですが、コメントは少し特殊で、 is_commentという以下の関数を呼び出して除いています。

def is_comment(token: TokenType) -> bool:
    return token.ttype in sqlparse.tokens.Comment

トークンの種別を表すオブジェクト(Comment, DDL, Statement等)はttype属性に格納されています。 ttypeはsqlparse.tokens*5に定義されているのですが、 少し特殊な実装になっていて、sqlparse内で定義されている関数やメソッドに頼らずに扱おうとすると予期しない動作を引き起こす可能性があります。

詳細については今回は省略しますが、面白い実装なのでソースコードを読んでみることをオススメします! そのうち自分のブログの方でも取り上げようと思います。

話を戻すと、コメントの判定でtoken.ttypesqlparse.tokens.Commentをinで比較しているのは、 コメントのttypeはCommentトークンだけではなく、 一行コメントトークンSingleや複数行コメントトークンMultilineとなる場合があるからです。

先ほどの条件文は「トークンオブジェクトtokenがCommentトークンもしくはそのサブグループに属するSingle, Multilineのいずれかに該当する場合にのみ真」というものなので、これらを全て扱うことができます。

邪魔なトークンを取り除けたら、CREATE TABLE文かどうかの判定を行います。 ここまできたら正規表現でもいい気がしますが、 トークンオブジェクトのmatchメソッドを使って、個々のトークンを調べていきます。

まず、先頭にCREATEトークンが来ていることを確認します。 もしもCREATEトークンが来ていれば、そのトークンの次のトークンの判定に移り、 そうでなければ、そのクエリの解析を終えます。 この処理をコード化すると以下の通りになります。

if not tokens[0].match(DDL, "CREATE"):
    continue
tokens.pop(0)

matchにttypeと値を渡すことでトークンのマッチングができます。 regex引数にTrueを渡すと、値を正規表現として処理することもできます。

このトークンマッチ->トークンを進めるという処理を繰り返していくことになるので、 match_consume関数にまとめてしまいます。

def match_consume(tokens: List[TokenType], match_args: Sequence) -> bool:
    if tokens[0].match(*match_args):
        tokens.pop(0)
        return True
    return False

この関数を作ることで、以下のように処理が簡略化されます。

if not match_consume(tokens, (DDL, "CREATE")):
    continue

これを使って、以降の処理も同様に書くことができます。

# 3. <Keyword TEMPORARY> <Keyword EXTERNAL> をスキップ
match_consume(tokens, (Keyword, "TEMPORARY"))
match_consume(tokens, (Keyword, "EXTERNAL"))
# 4. <Keyword TABLE>を検証
if not match_consume(tokens, (Keyword, "TABLE")):
    continue
# 5. <Keyword IF>, <Keyword NOT>, <Keyword EXISTS>をスキップ
if match_consume(tokens, (Keyword, "IF")):
    if not match_consume(tokens, (Keyword, "NOT")):
        continue
    if not match_consume(tokens, (Keyword, "EXISTS")):
        continue
# 6. テーブル名が一致したら返す。
if db == DEFAULT_DB and match_consume(tokens, (Name, table)):
    return query
if (match_consume(tokens, (Name, db))
        and match_consume(tokens, (Punctuation, "."))
        and match_consume(tokens, (Name, table))
    ):
    return query

実装は以上です。

もちろん、文法の細かいチェックなどは入っていないですが、 それでも着目している部分に関してはかなり正確なチェックをしつつ、 目的のDDL探索ができるようなスクリプトが簡単に書けることが分かります。

7. 次回予告

ここまで字句解析とその結果得られるトークン列の活用方法を紹介してきましたが、もっと高度なことをやろうと思うと色々と不便です。 例えば、SELECT文からサブクエリを抽出しようとすると、SELECT文の中の複雑な規則を条件分岐で表現することになり骨が折れます。

そこで、次回は今回紹介しなかった狭義の構文解析について簡単に解説し、 SELECT文中のテーブル間の依存関係抽出のスクリプト作成に挑戦しながら、 少しだけ高度なsqlparseの活用方法について紹介したいと思います。

8. おわりに

sqlparse.parseに色々と投げ込んでみると発見があって面白いので、ぜひぜひ試してみてください!

9. 補足

CREATE TABLE文の判定条件の詳細

HiveQLのCREATE TABLE文*6は、公式によると以下の通りです。

CREATE [TEMPORARY] [EXTERNAL] TABLE [IF NOT EXISTS] [db_name.]table_name    -- (Note: TEMPORARY available in Hive 0.14.0 and later)
  [(col_name data_type [column_constraint_specification] [COMMENT col_comment], ... [constraint_specification])]
  [COMMENT table_comment]
  [PARTITIONED BY (col_name data_type [COMMENT col_comment], ...)]
  [CLUSTERED BY (col_name, col_name, ...) [SORTED BY (col_name [ASC|DESC], ...)] INTO num_buckets BUCKETS]
  [SKEWED BY (col_name, col_name, ...)                  -- (Note: Available in Hive 0.10.0 and later)]
     ON ((col_value, col_value, ...), (col_value, col_value, ...), ...)
     [STORED AS DIRECTORIES]
  [
   [ROW FORMAT row_format] 
   [STORED AS file_format]
     | STORED BY 'storage.handler.class.name' [WITH SERDEPROPERTIES (...)]  -- (Note: Available in Hive 0.6.0 and later)
  ]
  [LOCATION hdfs_path]
  [TBLPROPERTIES (property_name=property_value, ...)]   -- (Note: Available in Hive 0.6.0 and later)
  [AS select_statement];   -- (Note: Available in Hive 0.5.0 and later; not supported for external tables)

大事なのは最初の1行CREATE [TEMPORARY] [EXTERNAL] TABLEです。 HiveQLには、CREATE DATABASE文のような<DDL CREATE>から始まる別のタイプのSQLがあるため、 それを弾くためにTABLEまでを解析させる必要があるわけです。

10. References

GitHub - andialbrecht/sqlparse: A non-validating SQL parser module for Python LanguageManual - Apache Hive - Apache Software Foundation

ビッグデータ分析・活用のためのSQLレシピ

ビッグデータ分析・活用のためのSQLレシピ

*1:余談ですが、queue.Queueオブジェクトなんかも、ぱっと見クラスなのに実はただのファクトリ関数です。継承しようとするまで気づきませんでした......。

*2:2.7にも対応していますが、すぐにサポート対象外になるそうです。

*3:構文木の根に当たるトークンオブジェクトです。

*4:DDLの書き方が全部統一されていたのでそれを満たす正規表現を採用したのですが、別のDDLファイル群に転用できなくて泣いた記憶があります。

*5:ここで定義されているのはトークンであり、トークンオブジェクトはsqlparse.sqlに定義されていますので、ご注意ください。

*6:LIKEによる定義コピーはここでは省略しています。

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!

参考文献