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

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

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!

参考文献

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項目