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

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

MLflowのデータストアを覗いてみる

はじめに

こんにちは、ホクソエムサポーターの藤岡です。 最近、MLflowを分析業務で使用しているのですが、お手軽に機械学習のモデルや結果が管理できて重宝しています。 また、特定のライブラリに依存しないなど、使い方の自由度も非常に高いところが魅力的です。

ただ、ザ・分析用のPythonライブラリという感じでとにかく色々なものが隠蔽されており、 サーバにつなぐクライアントさえもプログラマあまりは意識する必要がないという徹底っぷりです。 もちろんマニュアル通りに使う分には問題ないですが、 ちゃんと中身を知っておくと自由度の高さも相まって色々と応用が効くようになり、 様々なシチュエーションで最適な使い方をすることができるようになります。

というわけで、今回はMLflowの記録部分を担う、 Experiment, Run, Artifactについてその正体に迫ってみます。

なお、MLflow自体についての機能や使い方についての解説はすでに良記事がたくさんあり、 ググればすぐにヒットするのでここでは割愛します。

データストアの3要素

MLflowのデータ管理はRunExperimentArtifactの三つの要素から構成されています。 大まかには、それぞれの役割は以下の通りです。

  • Run: 一回の試行(e.g. 実験, 学習, ... etc.)
  • Experiment: Runを束ねるグループ
  • Artifact: Runで得られた出力や中間生成物の保管先

これらの関係は、図のようになっています。

f:id:kazuya_fujioka:20200412001402p:plain

なお、Artifact LocationはArtifactの格納先のことであり、 あるExperimentに対して一つのArtifact Locationが紐づいています。 逆に、あるArtifact Locationが複数のExperimentと結びつくようにすることも可能ですが、 経験的には管理の点からしてそのような設計は避けるべきだと思います。

では、これらの三要素について、その役割と実装について説明していきます・

Run

概要

まず、全ての基本となるのはRunです。 文字通り、一回の試行を表すものです。 例えば、データセットをXGBoostに投げ込んで学習させる実験を一回やったとすれば、それに対応するのが一つのRunです。

MLflowを使うときはmlflow.start_run関数から始まることがほとんどだと思いますが、ここでRunオブジェクトが生成されています。

Runの基本要素は、Parameter、Tag、Metricsの三つです。 XGBoostの例で言えば、

  • Parameter: ハイパーパラメータ
  • Tag: モデルの識別名
  • Metrics: テストデータでのAUC

みたいな感じでしょうか。 いずれも、複数のキーバリューを取ることができます。

上では一例を挙げてみましたが、ParameterもTagもMetricsも自由に定義できるので、 個々人の裁量で最適な設計をしてあげるのがいいと思います。 特に、TagにするかParameterにするかはその後のMLflow UIのユーザビリティを大きく左右するので、 よく考えるのがいいと思います。 また、そもそもParameterのうち実験で扱わないようなものは敢えて記録しないなど、 スリム化することも念頭に置いておくといいかと思います。

実装

Runはいくつかのコンポーネントに分かれて定義されています。 低レベルAPIを触る際にキーとなる部分なので、それぞれ細かく解説していきます。

Runオブジェクト

まず、RunオブジェクトはTag、 Parameter、 MetricsとRunに関するメタデータを記録したデータコンテナです*1。メタデータは以下のように階層的に格納されています*2

Run
 ┝ data
 │  ┝ params
 │  ┝ tags
 │  ┗ metrics
 ┗ info
    ┝ run_id
    ┝ experiment_id
    ┝ user_id
    ┝ status
    ┝ start_time
    ┝ end_time
    ┝ artifact_uri
    ┗ lifecycle_stage

なお、これらの要素は全てイミュータブルオブジェクトとして定義されています。 言い換えると、直接の書き換えは非推奨です。

実際にインスタンス化する場合には、MlflowClientcreate_runメソッドを使います。これはstart_run関数の内部でも呼ばれていて、実はこのオブジェクト*3が返されています。 Runを記録するときに直接呼び出すことは無いので、サンプルプログラム等ではたいてい捨てられてしまっていますが。

一方、サーバから読み出す場合には、MlflowClient.get_runメソッドを使います。 start_runの場合と違ってrun_idが必要になるので注意しましょう。run_idはMLflow UIからも取得できますが、スクリプト中ではExperimentを通じて取得するのが楽です。 その取得方法についてはExperimentの節で扱います。

Runオブジェクトの内部構造と呼び出し方さえ分かってしまえば、実験結果の検索や操作もスクリプトから思いのままに実現可能です。 MLflow UIはリッチなUIを提供してくれている反面、小回りが利かないこともあるので、困ったらRunを自分で直接触るのがいいと思います。

RunDataオブジェクト

RundataプロパティにはRunの基本要素となる3要素が格納されています。このプロパティはRunDataというデータコンテナとして定義されており、Parameterがparamsに、Tagがtagsに、Metricsがmetricsにそれぞれ辞書オブジェクトとして格納されています。

RunInfoオブジェクト

RuninfoプロパティにはRunのメタデータが格納されています。このメタデータには以下の情報が入っています。

run_id

RunのIDです。 あるrun_idは任意のExperimentに対して一意になります*4

experiment_id

そのRunの属するExperimentのIDです。

user_id

そのRunを実行したUserのIDです。

status

Runの状態です。以下の5ステータスが定義されています。

  • RUNNING: Runを実行中
  • SCHEDULED: Runの実行がスケジュールされている状態 *5
  • FINISHED: Runが正常終了した状態
  • FAILED: Runが失敗して終了した状態
  • KILLED: RunがKillされて終了した状態
start_time / end_time

Runが開始 / 終了した時間です。

artifact_uri

ArtifactのURIです。

lifecycle_stage

Runの削除判定用フラグです。Runが削除されていればDELETED、そうでなければACTIVEが設定されています。 あるRunをAPI経由で削除した場合、データ自体が削除されるのではなくこのフラグがDELETEDに更新されます。

Experiment

概要

Runを束ねるのがExperimentの役割です。 モデルをたくさん投げ込んだときでもExperimentごとに整理しておけばアクセスしにくくなるのを防げますし、 MLflow UIの可視化機能やMetrics比較等を十全に活かすためにも、適切な単位ごとにExperimentで分けることは重要です。

例えば、あるKaggleコンペでExperimentを作って、その中に作成したモデルをRunとして記録するのはもちろん、 コンペによってはXGBoostやRFのようにモデルの種別で複数のExperimentを作ることも有効かもしれません。

Experimentの実現形式はメタデータの格納方式に依存します。 利用可能な格納方式は

  • ファイル (ローカル)
  • RDB (MySQL, MSSQL, SQLite, PostgreSQL)
  • HTTP サーバー (MLflow Tracking Server)
  • Databricks workspace

のいずれかです。

例えばファイルストア(Run, Experimentをファイルとして保管する形式)の場合、 以下のようなディレクトリがExperimentの実態となります。

<experiment-id>
 ┝ meta.yaml
 ┝ <run-1-id>/
 │  ┝ meta.yaml
 │  ┝ metrics/
 │  ┝ params/
 │  ┗ tags/
 ┝ <run-2-id>/
  ...
 ┗ <run-N-id>/
    ┝ meta.yaml
    ┝ metrics/
    ┝ params/
    ┗ tags/

experimentとrunの親子関係がディレクトリ構成で表現され、各種メタデータがmeta.yamlに記録されています。 metrics, params, tagsの中には各種データがファイルで格納されています。 なお、artifact_uriが指定されなければ、tags等と同じフォルダにartifactsというフォルダが作成されてその中に格納されます。

実装

Experimentオブジェクトの扱いはRunオブジェクトとよく似ているので、相違点だけ述べて詳細な解説はスキップします。

主な相違点として、以下のものが挙げられます。

  • 高レベルAPI経由での作成はmlflow.start_runではなくmlflow.create_experiment
  • クライアント経由での作成はMlflowClient.create_runではなくMlflowClient.create_experiment
  • 取得方法はid経由 (get_experiment関数) だけではなく name経由 (get_experiment_by_name関数) も可能(1つのサーバ内で名前がユニークであるという制約のため)
  • メタデータが以下の4つのみ
    • name: Experimentの名前
    • experiment_id: ID
    • artifact_location: Artifact LocationのURI
    • lifecycle_stage: Runと同様のACTIVE/DELETED
    • tags: Runと同様

Artifact

概要

Artifactとは、Runの結果や途中経過で生じたファイルを格納するためのストレージです。 Artifactを置く先のファイルストレージが扱えるファイルであればなんでも格納が可能です。 もちろん、csv化ができるpandas DataFrame や pickle化ができるPythonオブジェクトも同様です。

Artifactは以下のファイルシステムに対応しています。

  • Amazon S3
  • Azure Blob Storage
  • Google Cloud Storage
  • FTP server
  • SFTP Server
  • NFS
  • HDFS

上記のファイルシステムであればRunの場所に関わらず任意の格納場所 (Artifact Location) をURIで指定できますが、図で示したようにExperiment単位で指定しなければならないことに注意してください。 このURI以下にRunごとのフォルダが切られ、そこにRunの中間生成物等が入ります。

Artifactのもっとも重要な機能としては、モデルオブジェクトの格納が挙げられます。 モデルの格納の場合、単にファイルにして格納するだけでなく、それをあとで読み込んでデプロイできるようにしなければ、あまり意味がありません。 MLflowは多くの外部ライブラリに対してこのデプロイ機能を、それも環境の変化に対しても頑健な形で実装しています。

実装

Artifactに格納されるモデルファイルは、どのような形でセーブ/ロードが実現されているのでしょうか。 答えはシンプルで、セーブの際にはローカルに一回保存してからファイルシステムごとに定められたプロトコルで指定したURIへと転送し、ロードはその逆のプロセスです。

ただし、モデルファイルについては概要の節で述べたとおりのデプロイ機能を実現するために複数のファイルが生成・格納されます。 この格納方法は扱うモデルの実装されたモジュールごとに異なります。 MLflowでは、あるモジュールで生成したモデルを格納・呼び出しするための格納方法をflavorと読んでいます*6。 例えば、sklearn flavor, xgb flavorといった具合です。

各flavorはmlflow以下に.pyファイルとしてそれぞれ実装されています。 例えば、XGBoostのflavorを見てみると、中には呼び出し可能な関数として、

  • get_default_conda_env : デフォルトのconda envを生成
  • save_model : ローカルへのモデルのセーブ
  • log_model : Artifactへのモデルの保存
  • load_model : ローカル/Artifactからのモデルの読み込み

の4つの関数が定義されています*7。 これは他のflavorでも同様です。 とはいっても、多くの場合ではこの中で直接使うのはlog_modelload_modelだけであり、それ以外は内部的に呼ばれるだけかと思います。

これらの関数を使い、モデル本体のファイル(xgbの場合はmodel.pkl) conda env (conda.env) , MLmodelの三つが入ったフォルダを作成 / 読み込みします。 このフォルダが、MLflowにおけるモデルオブジェクトになります。

MLmodelファイルはモデル作成に使ったflavorの情報等が格納されたコンフィグファイルです。 この内容を元に読み込み方法を決定し、実行します。 また、モデルを実行(mlflow runコマンド)する場合にはconda envファイルを元に実行環境を作成します。

flavorは上述の四つの関数がカギであり、これらを理解することができればオリジナルのflavorを作ることもできます。

終わりに

MLflowは本当に便利で知名度も高くて少しずつ記事も増えてきているのですが、Run, Experiment, Artifactの三つについての解説が物足りなかったので本記事を書いてみました。 話題を絞ったぶん少し深いところまで掘り下げてみましたが、いかがだったでしょうか?

MLflowの実装は、flavorのあたりはちょっと無理矢理感がある気もしますが、低レベルのAPIのあたりは実装も参考になるし使いやすいしでぜひ読んで欲しいライブラリの一つです。 これを機に利用はもちろん、実装に興味を持っていただければ幸いです。

では、よきPythonライフを!

おまけ

Runの名は?

MLflowのWeb UIを触ると、Runにも名前 (Run Nameの項目) が設定できるようになっているのが分かります。 しかし、ここまでの説明の通り、RunのメタデータとしてRunの名前に該当する項目はありません。

実は、Run Nameはtagsに"mlflow.runName"というタグ名で記録することで表示させることができます。 他にも、このような特殊なタグはmlflow.utils.mlflow_tags内で定義されています。

ところで、"Artifact"って?

Artifactを英和辞典で調べると、遺物とか人工物とかそんなふんわりとした意味しか出てこなくて、使い始めた当初は具体的に何を入れるものなのかがよく分かりませんでした。

本記事を書くにあたって改めて辞書で調べてみると以下のような定義となっていました。

Something observed in a scientific investigation or experiment that is not naturally present but occurs as a result of the preparative or investigative procedure.

ざっくりと訳すと、科学的調査や実験で生じた人工物、というような感じです。 機械学習の実験で使うことを考えると、やっぱり、中間生成物やモデル等なんでも入れていいというような意図を感じます。

以前に業務で使用していたときには学習に使うデータマートとかもMLflowで管理してみたのですが、案外できてしまった(しかも割と便利だった)ので、本当に何を入れてもいいんだと思います。 もっとも、環境に特別な制約がなくマート管理に特化したものを採用できるのであれば、そちらの方がいいと思いますが。

NoneはTagに入りますか?

入ります。Parameterも受けつけます。 ただし、Metricsだけはエラーを吐きます。

データ活用のための数理モデリング入門

データ活用のための数理モデリング入門

*1:実際にはもう少し別の機能が定義されていますが、コミッターでもない限り使わないと思うので省略しています

*2:v1.7.2時点でdeprecateされたものは省略しています

*3:正確にはその子クラスのActiveRunオブジェクトなのですが、コンテキストマネージャ化されていること以外は同じなのでここでは同一のオブジェクトとして扱います

*4:例えば、ファイル形式のストアの実装を見てみると、uuid.uuid4を使っているので、重複はまず無いです。REST形式のストアの方は不明ですが、sqlalchemyのストアでも同様です

*5:使ったことがないので詳細は不明ですが、おそらくDatabricksやKubernetesと連携した際に使用されるパラメータ

*6:flavorという名前の由来はこの辺りかと思います。

*7:autologはexperimentalかつoptionalなので省略しました

深層学習系のトップ会議ICLR2020のNLP系論文についてざっくり紹介

ホクソエムサポーターの白井です。今回はICLR2020 の論文を紹介します。

The International Conference on Learning Representations (ICLR) は機械学習の中でも特に深層学習 を専門とした国際会議です。 OpenReview.net によるopen peer reviewを採用しているので、submitされた論文はだれでも閲覧可能です。(ICLR2020 open review)

2020年はエチオピアで開催予定でしたが、COVID-19の影響でvirtual conferenceとなりました。

今回はNLP系の論文について5本紹介します。 すでに日本語ブログ記事で紹介されているような論文もありますが、自分が興味を持った部分を中心としてざっくりと紹介したいと思います。

以降、とくに記載がない場合、図は論文またはブログからの引用です。

1. Reformer: The Efficient Transformer

タイトル通り、Transformerを効率的にした Reformer (Trax Transformer) という手法を提案する論文です。

Transformer (Vaswani et al., 2017) は学習に膨大なリソースが必要という欠点がありますが、提案手法ではこの欠点を Locality-Sensitive Hashing Attention (LSH attention)Reversible Transformer の2つによって解決します。

まずは LSH attention について説明します。

  • Attenntion の計算  \mathrm{Attention}(Q, K, V) = \mathrm{softmax}\left(\frac{QK^\top}{\sqrt{d_{k} }} \right) V において、 QK^\top の計算に着目します。
  • この行列計算は、サイズが大きくてメモリが必要な割に、最終的にsoftmax関数を適用するため、無駄が多いと考えられます。
  • そこで、 Q=K として計算する shared-QK Transformer を提案します。
  • 次に、  Q=K としたときの  \mathrm{softmax}(QK^\top) に着目します。
  • softmax は値の大きい要素ほど重要であり、値の小さい要素は無視できます。
  • そこで、query q_i に最も近い key の部分集合だけを取り出して計算すれば、QK^\top のうち値の大きくなる要素だけを計算することができ、行列演算が簡略化できます。
  • この近傍を探索する問題を解決するために locality-sensitive hashing (LSH, 局所性鋭敏型ハッシュ)を利用します。
  • これにより、Attention の計算効率を上げることができます。

次に、Reversible Transformer について説明します。

  • 必要なメモリは少なくとも層 (layer) の数  n_l だけ増えるため、大きいTransfomerだと16GBという膨大なメモリが必要になります。
  • RevNet (Gomez et al., 2017) を参考に、n_l を減らすことを考えます。
  • RevNet はモデルのパラメータのみで、続く層の活性化から層の活性化を復元する画像分類モデルです。
  • 典型的な残差ネットワークでは x \mapsto yy = x + F(x) のように1入力1出力 (図の (a) )となります。
  • RevNetでは入力・出力のペア (x_1, x_2) \mapsto (y_1, y_2) で、以下が成立します。
    • y_1 = x_1 + F(x_2), y_2 = x_2 + G(y_1)
    • 残差を足し引きすることで層を反転できます x_2 = y_2 - G(y_2), x_1 = y_1 - G(x_2)
    • (図の (b), (c) )
  • Fを Attention layer、 GをFeed-forword layerとみなすのが Reversible Transformer です。

これら2つのテクニックによって、計算コストとメモリコストを効率化することで、長文も一度に扱えるようになります。

ちなみに、この論文を紹介しているいくつかのブログでは、「罪と罰」全文を理解できてすごいのがウリっぽい見出しになっていますが、あくまで、モデルが小説のような長いテキストを 一度 に読み込めるだけです。 そこから要約するのか、翻訳するのか、読解タスクを解くのかは学習データ次第だと私は考えます。

ブログでは画像生成タスクの結果が記載されています。(下の図) 効率化によって、断片的な画像から復元するような large-context data も扱えるようになったといえます。

2. GENERALIZATION THROUGH MEMORIZATION: NEAREST NEIGHBOR LANGUAGE MODELS

この論文では、例えば Dickens is the author ofDickens wrote は次の単語 (作品名・書籍名) に対して同じ分布を持つことが理解できるように、予測問題よりも表現学習 (representation learning) のほうが簡単であるという仮説を立てた上で、既存の言語モデルの類似度 (similarity) によって補完する手法を提案しています。

具体的には、学習済みの言語モデルに対して、kNN (K近傍法) を用いた拡張を行う kNN-LMs を提案します。

式で表すと下記のようになります。

 
p(y|x) = \lambda p_ {kNN}(y|x) + (1-\lambda) p_ {LM}(y|x)

 p _ {LM} は学習済み言語モデル (この論文ではTransformerLM)、  p _ {kNN} は図の上部分を表しています

kNN-LMsの図

  • p _ {kNN} は学習済み言語モデルである p _ {LM} のcontextを利用するため、( p _ {LM}の)追加の学習が必要ない
  • 同じドメインのみならず、他のドメインにおいても パープレキシティ が低下

既存のニューラル言語モデルをkNNという単純な手法によって拡張することでパフォーマンスが向上しているのが興味深いです。

3. Neural Machine Translation with Universal Visual Representation

NMT (ニューラル機械翻訳) において、画像情報を利用する論文です。

画像情報はNMTによっても有用な情報ですが、複数の言語について対応した画像+文のコーパスは少ないため、 画像+単一言語のアノテーションデータを利用して補う手法を提案しています。

  • 文と画像のペアから トピック単語に対応する画像のルックアップテーブル を作成
    • トピック単語の抽出にはtfidfを使う
  • 学習時にはsource sentenceと似たトピックの画像グループを抽出し、ResNet(He et al., 2016) の画像表現にエンコード

English-to-German+画像コーパスMulti30K の画像情報を利用したEnglish-to-Romanian、English-to-German、English-to-Frenchの翻訳タスクにおいて、いずれも画像情報を用いた方が精度が上がる結果となっています。

アイデアとしてはシンプルですが、テキスト以外の情報を利用するマルチモーダルな手法かつ、複数言語への対応というアプローチが新しいと感じました。

4. ELECTRA: PRE-TRAINING TEXT ENCODERS AS DISCRIMINATORS RATHER THAN GENERATORS

ELECTRA (Efficiently Learning an Encoder that Classifies Token Replacements Accurately) は最初に紹介した Reformer と同じく、効率的であることがウリの手法です。

系統としてはNNを用いた言語モデルですが、 BERTのような masked language model (MLM) と大きく異なるのは、GeneratorとDiscriminatorというGANのような構造を用いて、replaced token detection(RTD) で事前学習をする点です。(正確にはGANモデルでありません)

ELECTRAの図

  • GeneratorはGANのような敵対的モデルではなく、最大尤度を学習
    • 実質 MLM
  • Discriminator は Generator の渡した token が "real" かどうか区別する2値分類を学習
  • Generator と Discriminator の lossを最小化するよう学習
  • この2つを事前学習したのち、Discriminatorをdown stream taskに利用 (ELECTRA)

興味深い点は、効率的に学習するために以下のような拡張 (MODEL EXTENSIONS) が述べられている点です。

  • GeneratorはDiscriminatorより小さいサイズ
    • 究極的には "unigram" generatorでも可能
  • GeneratorとDiscriminatorは重みを共有
    • token and positional embeddings
  • Generatorの学習を最適化させる学習アルゴリズムtwo-stage training procedure
    • うまくいかなかったのでこれは採用されず

特に最後の学習アルゴリズムについては、敵対的学習よりも提案手法であるMLE (最尤推定) によるGeneratorの方がGLUE scoreが優れていたことが述べられています。 テキストとGANsの相性については次の論文 (LANGUAGE GANS FALLING SHORT) で紹介します。

実験結果として、ELECTRAは従来のSOTAモデルよりも小さいモデル・単一GPUでの学習であっても、優れたGLUE scoreを達成しています。

ちなみに論文中では計算量をfloating point operations(FLOPs) (FLOPSではない)で表しています。 (FLOPsについて参考: Chainerで書いたニューラルネットの理論計算量を推定するchainer_computational_cost)

5. LANGUAGE GANS FALLING SHORT

前述のELECTRAが引用している論文です。(今年のICLRに採択されていますが、論文自体はarXivに2018年からアップロードされていた模様。)

従来の自然言語生成 (NLG)において、MLE(最尤推定)を学習に利用した手法では、学習と生成の入力が異なっていました。学習時には常に正解データを与えられますが、生成時には前のステップで予測した値のみ与えられます。

from Sequence Level Training with Recurrent Neural Networks (関東CV勉強会 強化学習論文読み会)

このようなサンプルの品質の低下の問題 exposure bias の解決案として、 generative adversarial networks (GANs) が提案されています。しかしながらGANsのモデルは品質についての検証が多く、多様性については無視されていました。

この論文では自然言語生成の評価において、temperature sweep によって質と多様性のトレードオフ(quality-diversity trade-off) を特徴付ける方法を提案しています。 ここでいう temperature は ボルツマンマシン における温度の意味で使われています。

Generator G と temperature  \alpha の関係を式で表すと下記のようになります。


G_\theta (x _ t | x _ {1:t-1}) = \mathrm{softmax}(o _ t \cdot W / \alpha)
  • generator's pre-logit activation o _ t
    • 詳しく記述されていないがLSTMの output gate?
  • word embedding W
  • Boltzmann temperature parameter  \alpha (動かしてチューニングする)
    •  \alpha を1より小さくすると  o _ t が増大し、Gの条件確率エントロピーが低下する

実際に \alpha を変更した MLE(最尤推定) での結果が以下の表のようになります。

 \alpha=1 の例は統語的には合っていますが、文章全体の一貫性に欠けています。  \alphaを大きくすると統語的には間違った文が生成され、 \alpha を小さくすると生成される文が一意になります。

temperature sweepを利用し、seqGAN (Yu et al., 2017) ベースのGANモデル RL-GANとそこから敵対的学習を取り除いたMLEモデルを比較したところ、MLEモデルの方が質と多様性のトレードオフの側面において、優れた結果であることがわかりました。

つまり、MLEのexposure bias はGANsモデルの最適化よりも問題が少ないということです。 また、MLEによる学習は質と多様性に応じて良いpolicyのGeneratorに改善する一方で、GANsの学習では学習した分布からエントロピー下げることで高品質なサンプルが得られると解釈することもできます。

パラメーターによって自然言語生成の出力を変化させて評価するアプローチを提案していると同時に、自然言語におけるGANsの扱いについて、疑問を提示するような論文にもなっています。

自然言語とGANsはなにかしらのボトルネックがあるのかもしれません。

おわりに

TransformerやBERTがメジャーである一方で、効率的な手法や拡張手法の提案がされている印象をうけました。

モデルサイズが小さく・学習時間が短くなることで、機械学習の活用が、より手軽になっていく気がします。

参考資料

ICLRについて

LSH

GANs

exposure bias

NLG

GLUE benchmark

簡単な"さんすう"で見積もる施策効果の要因分解

日々、最先端で高度なテクノロジーに基づくビジネス改善”施策”を実施されている読者諸氏の皆さんこんばんわ、株式会社ホクソエム・常務取締役(博士(統計科学))の高柳です。

"XXXというKPI(売上とか)を向上させるために、XXXを構成するYYYという要因(PVとか広告単価とか1人あたりの売上とか)を向上させれそうな施策を試してみたんだけど、ZZZというまた別の売上を構成する要因(Impressionとか来店客数)も増えてたおかげで、結局、施策が売上全体にどのくらいのインパクトがあったのかよくわからないんだ〜助けて〜” ・・・という状況、あると思います。

この記事ではこういった複数の要因が混み入った状況でも ”各要因ごとに施策効果を分解して「PV要因で売上X円UP!」などと評価することができますよ、という話を紹介したい。

あまりやってる人見たことないからメジャーじゃないとは思うんだけど、「引いて掛ける」という簡単なさんすうで計算することができるので覚えておきましょうという話でもある。

状況設定

まず、スーパーの店長になった気持ちで一日あたりの ”売上(円)” というKPIが以下のように分解されるとしておこう。 え?私の手元のKPIツリーではこんな簡単な算数で綺麗に分解できていない?その場合は下記の一般論のケース「結局なんなの?・・・もっと一般論をプログラマティックにやりたい貴方へ」を見てもらいたい。

  • 売上(円) = 1人あたりの売上(円/人)× 来店客数(人)

字面通りに式を読み解くと、これは"売上(円)"というKPIを

  • ”1人あたりの売上(円/人)”
  • "来店客数(人)"

という2つの要因に分けているということだ。 そして今、店長としてのあなたは店の売上をあげるために ”1人あたりの売上(円/人)”を向上させようと躍起になっている、そんな状況を想像してもらいたい。

それでは早速本題に入ろう。 まず現状、各々の要因が

  • 1人あたりの売上(円/人)= 1,000(円/人)
  • 来店客数(人)= 100(人)

だったとしよう。このときの売上(円)はもちろん単純に掛け算をして 1,000(円/人)× 100(人)= 10万円となる。 これはさすがに簡単だ。小学3年生だって暗算でできちゃう。

さて、次にあなたは店長として”1人あたりの売上(円/人)”を向上しようと何某かの単価向上キャンペーンを打ったと思おう。 なんかこう一回来店したときにたくさん買ってくれるような施策だ。牛肉がお買い得!マスクまとめ買いチャンス!トイレットペーパー無制限購入可!などなど何でも良い。

さて、そのおかげもあって"1人あたりの売上(円/人)"が以下のように改善したとしよう(一方、天気が悪かったのか来店客数がしれっと減ってる点に注意)。

  • 1人あたりの売上(円/人)= 1,200(円/人)
  • 来店客数(人)= 90(人)

やったー! ”1人あたりの売上(円/人)”は確かに 1,000(円/人)から 1,200(円/人)に向上している! 今回の”単価向上キャンペーン”は効果があったと言えそうだ!

あった、効果はあった、あったはいいが、その効果は”いかほどの金額”だったのだろうか? 定量にうるさいおじさんたちはきっとこれを要求してくるだろう。これを見積もりたい。 だって貴方は店長だもの。エリア長が褒めてくれなくたっていい。自分だけでも自分を褒めてあげたい。 売上はすべてを癒す、そういうことです。

さて、雑に考えると、単純に売上の増加分の8,000円((1,200(円/人)× 90(円) - 100,000円)だけ効果があったと見積っていいのだろうか?

ここで示す要因分解はこの問題に答えを出してくれる。

要因分解の方法

答えを先に言っちゃうと、各要因による売上増の効果を以下のように計算すればよい。

  • "1人あたりの売上(円/人)"要因 = (1,200 - 1,000) (円/人)* 100 (人)= 20,000円
  • "来店客数(人)"要因 = (90 - 100)(人)* 1,000 (円/人) = -10,000円
  • 両要因の混合(相互作用)要因 = (1,200 - 1,000) (円/人)* (100 - 90)(人) = -2,000円

上の3つの要因を足してもらうと元の売上増加分の8,000円に等しくなっていることがわかると思う。 つまりこういう分解が行われているのだ。

  • 全体の売上増(8,000円)= "1人あたりの売上(円/人)"要因(20,000円) + "来店客数(人)"要因(-10,000円) + 両要因の混合要因(-2,000円)

したがって、この計算方法でいうと "今回の単価向上キャンペーンによって1人あたりの売上は200円向上した。その売上への効果は20,000円程度あったと見込まれる"ということができる(一方、悪天候効果により来店客数が減少したことによる売上への影響は -10,000円あったと言える、また謎の”両要因の混合要因”とやらは、他の2つの要因と比べて1桁小さい点にも注目だ)。 どういう計算をしているのかを日本語で書くとこういうことだ。

  • "1人あたりの売上(円/人)"要因 = (1人あたりの売上(施策後) - 1人あたりの売上(施策前)× 来店客数(施策前)
  • "来店客数(人)"要因 = (来店客数(施策後) - 来店客数(施策前)×1人あたりの売上(施策前)
  • 両要因の混合(相互作用)要因 = (1人あたりの売上(施策後) - 1人あたりの売上(施策前))×(来店客数(施策後) - 来店客数(施策前)

要するに「要因ごとに施策前後での差分を計算し、それに”施策前”のもう一方の要因を掛けてやる」ということだ。 「引いて掛ける」という簡単な”さんすう”で施策効果の要因分解ができましたね?

この計算をGoogle SpreadSheetにしたものを用意したので自分で数式を確かめたい人は参考にしてほしい

あぁ、よかった。これでちゃんと単価向上キャンペーンの効果を見積もれるようになったぞ。 店長としての貴方は枕を高くして眠ることができるわけです。

結局なんなの?・・・もっと一般論をプログラマティックにやりたい貴方へ

ここでやってる分解は要するに「 ”施策や確率的なノイズによってもたらされる各要因の向上度合い” を微小量だと思ってそいつで一次近似してる」だけです。 ここでは2要因の場合を紹介したけど、N要因ある場合には(未定義な変数は心の目👀で補間してください)以下のように考えればよい。

まず売上の増分はN個の要因(x)の変化(Δで書いてるやつ)を用いて以下のようにかける。 f:id:shinichi-takayanagi:20200420195825p:plain

この右辺をΔ(向上度合い)が小さいと思って頑張って展開をしてやると f:id:shinichi-takayanagi:20200420201034p:plain

と書ける。

2要因の場合、この第一項が上記の"1人あたりの売上(円/人)"要因(x_1)と"来店客数(人)"要因(x_2)に相当しています。 ちゃんと導出したければ Sales(x_1, x_2) = x_1 * x_2 と定義して、これをx_1やx_2で微分してみると見通しよく分かると思います。

また最後に残った”両要因の混合(相互作用)要因”と称しているものは上の数式の第二項、要するに高次のオーダーからの寄与ってことになります。

なんで、N要因への拡張も簡単にできるし、Codeで計算しようと思えば簡単にできるわけです(売上関数をちゃんと構築できれば!)。

結論

いろんな要因がごちゃごちゃ同時に変化してしまったとしても、意外と施策効果って簡単な算数で測れるもんだな〜ってのがわかっていただけると幸甚です。

そういえば、こういう”数理を用いたモデリング”(今回は要因分解のモデリング)に詳しくなりたい人にぴったりの本が最近出たようですよ。 (この記事は本書の1章に刺激を受けたのでスッと書きました)

sqlparse 入門 - 応用編 -

1. はじめに

こんにちは、ホクソエムサポーターの藤岡です。 初稿では一回で終わらせる予定だったはずの本記事もついに第三回。 ついに最後です。 ここまででsqlparseと構文解析の基本的な部分を解説したので、 いよいよ本格的に構文解析の結果をしっかりと使うプログラムを作っていきます。

今回はsqlparseの紹介というよりは、構文規則をどうやってPythonプログラムに落とし込むか、 という問題に対する自分なりの一解答例です。 もっと賢いやり方はあると思いますし、もしご存知の方がいたら、ぜひコメントでご教示いただければ幸いです。

2. 注意

  • 本記事に書かれた内容は自分の理解に基づいたものであり、誤りが含まれている可能性がありますが、ご了承ください。
  • もしそういった不備にお気付きの際には、コメントでご指摘いただければ幸いです。
  • また、以下の解説ではSQLが何度か登場しますが、すべてHiveQLです。
  • 今回のサンプルプログラムは説明用に作成したものであり、不具合等が含まれる可能性が多分にあります。
  • リポジトリに入っているコードとはコメントの内容等を一部改変している部分があります。

3 サンプルプログラム: TableGraph

今回作成するのは、構文木を走査しながらテーブル/サブクエリ間の依存関係をグラフとして生成するプログラムです。

例えば、

SELECT t3.col
FROM (
  SELECT a+b AS col
  FROM t1
  UNION ALL
  SELECT c+d AS col
  FROM t2
) t3

というSQLクエリから、

クエリ
└─ t3
   ├─ t1
   └─ t2

というグラフを書きます。

実装は以下のリポジトリにあります。

github.com

ただ、今回のサンプルプログラムは行数が前回よりも少しだけ多いため、重要な箇所のみの解説とさせていただきました。 代わりに、プログラムを動かして遊べるように簡単なインターフェースを実装したので、 適当にprint文を差し込みながら動きを見るなどして、色々と学んでいただければ幸いです。

3.1 概要

要件は以下の通りです。

  • 入力は1つのDMLクエリ。
  • 入力にはCTE (With節) は含まれない。*1
  • 出力はエッジを表すタプル(始点、終点)の集合。
  • エッジの始点・終点はテーブル/サブクエリ名の文字列。
  • クエリ全体は"__root__"という文字列で表す。
  • 無名のサブクエリは識別できるようにIDを振る。

上の例では、3つのタプル ("__root__", "t3"), ("t3", "t1"), ("t3", "t2") からなる集合*2が得られればOKです。

グラフィカルにしたい場合はnetworkx等を使うのがいいかと思います。

3.2 実装方針

今回のプログラム作成においてポイントとなるのが、 トークンとHiveQLの構文規則とをどう結びつけるか、という点です。

サンプルプログラムの主な処理はテーブル名の探索ですが、 その達成には現在走査している部分がSELECT節なのかFROM節なのか、といった情報の読み取りが必要です。

こうした情報は非終端記号と呼ばれる記号で表記されます。 これは以下の構文規則における、 table_reference や table_factor のことです。

table_reference:
    table_factor
  | join_table

一方、これらの非終端記号とsqlparseのトークンとは1対1で対応するものではありません。 そもそもsqlparseを用いて得られる構文木は、あるSQL方言の構文規則を完全に表現したものというより、 対応している各方言をだいたい全部包含したような、どっちつかずな構文木です*3

なので、この構文木に対してさらに解析を加える必要があります。 このタスクに対するアプローチは自分の思いつく限りでは以下の二つです。

  1. 構文木(もしくはその一部)をHiveQLの構文規則と対応するものに書き換える。
  2. 構文木を走査して必要な情報を探索し、集約する。

今回は、1のアプローチに最初気づかなかったため諸事情により2のアプローチを採用しました*4。 基本的にHiveQLの構文規則にある各種非終端記号をクラスを使って表現し、 そのクラスを用いて根トークンから走査していく方法で実装を進めます。

例えば、table_referenceをTableReferenceクラス、table_factorをTableFactorクラスによって表現していきます。

これらのクラスは、以下のラッパクラスを基底としたクラスです。

class HQLTokenWrapper:
    """
    HiveQLの構文ルールを適用するためのトークンラッパの基底クラス。
    あるトークンオブジェクトを対応する構文規則でラップしている。
    """

    def __init__(self, token: TokenType):
        if token is None:
            raise ValueError(token)
        self.token = token

    def traverse(self) -> Generator["HQLTokenWrapper", None, None]:
        """構文ルールを適用して得られるトークンをyieldするメソッド"""
        yield from []

    def nexts(self) -> List["HQLTokenWrapper"]:
        """1回のtraverseの結果得られる全てのトークンのリスト"""
        return list(self.traverse())

    @property
    def text(self) -> str:
        """トークンの文字列"""
        return str(self.token)

    def __str__(self):
        """オブジェクト情報(主にデバッグ用)"""
        clsname = self.__class__.__name__
        statement = re.sub("\n", " ", str(self.token).strip())
        if len(statement) > 10:
            return "<{} \"{}...\">".format(clsname, statement[:10])
        return "<{} \"{}\">".format(clsname, statement)

基本的には、traverseメソッドでtoken属性にあるトークンを解析し、 その子孫のトークンをトークンラッパでラップして、そのtraverseをまた呼ぶ......ということを繰り返します。

といってもイメージしづらいと思うので、まずは簡単な例から順に実装を見ていきましょう。

3.3 実装解説

3.3-a. Statementトークンの中から、SELECTトークンを全て抜き出す。

クエリのルートに当たるQueryオブジェクトについて見ていきます。

QUERY_IDENTIFIER = "__root__"

class Query(HQLTokenWrapper):
    """クエリと対応するトークンのラッパ"""

    def yield_edges(self) -> Generator[Tuple, None, None]:
        """エッジを生成する"""
        token_stack = self.nexts()
        ident_stack = [(self.get_identifier(), 0)]
        while len(token_stack):
            token = token_stack.pop()
            if len(token_stack) < ident_stack[-1][1]:
                ident_stack.pop()
            if isinstance(token, (TblName, Query)):
                yield ident_stack[-1][0], token.get_identifier()
            if isinstance(token, Query):
                ident_stack.append((token.get_identifier(), len(token_stack)))
            token_stack.extend(token.nexts())

    def get_identifier(self) -> str:
        """クエリ全体に対する識別子として便宜的にQUERY_IDENTIFIERを割り当てる"""
        return QUERY_IDENTIFIER

    def traverse(self):
        """全てのSELECTトークンを抜き出す"""
        for t in self.token:
            if t.match(DML, "SELECT"):
                yield Select(t)

traverseメソッドの中身は非常にシンプルで、self.tokenの子トークンの中からSELECTトークンを取り出して、 トークンラッパSelect(定義は後ほど紹介します)でラップしてyieldしているだけです。 このように、構文木を部分的に走査しながら、トークンに非終端記号を当てはめていく処理です。

3.3-b. SELECTトークンの兄弟からtable_referenceとwhere_conditionを探す。

実装に入る前に、まずはSELECT節の構文規則のうち今回関係する部分について見ていきます。

SELECT [ALL | DISTINCT] select_expr, select_expr, ...
FROM table_reference
[WHERE where_condition]
[GROUP BY col_list]
[ORDER BY col_list]
[CLUSTER BY col_list
| [DISTRIBUTE BY col_list] [SORT BY col_list]
]
[LIMIT [offset,] rows]

今回の探索で重要となるテーブル名はtable_reference, where_conditionの二箇所に含まれます。 これら2つを抽出するルールを書いていくのですが、ロジック自体はものすごく単純です。

  1. SELECTトークンの兄弟を走査してFROM節の位置を特定し、そのうちのFROMトークンより後の部分を抜きだす。
  2. WHEREトークンを抜き出す。

2については、WHERE節自体がWHEREトークンとしてまとまるように実装されているため、これだけで取り出すことができます。 1については範囲の特定が必要ですが、FROM節は始点も終点も簡単に判定できます。

以下、実装です。

class Select(HQLTokenWrapper):
    FROM_END_KEYWORD = [
        "GROUP",
        "ORDER",
        "CLUSTER",
        "DISTRIBUTE",
        "SORT",
        "LIMIT",
        "^UNION"
    ]

    @classmethod
    def is_from_end_keyword(cls, token):
        if isinstance(token, Where):
            return True
        return any(token.match(Keyword, kw, regex=True) for kw in
                   cls.FROM_END_KEYWORD)

    def traverse(self):
        """
        以下のルールに従い、table_referenceとwhere_conditionを抜き出す。
        [WITH CommonTableExpression (, CommonTableExpression)*]
        SELECT [ALL | DISTINCT] select_expr, select_expr, ...
          FROM table_reference
          [WHERE where_condition]
          [GROUP BY col_list]
          [ORDER BY col_list]
          [CLUSTER BY col_list
            | [DISTRIBUTE BY col_list] [SORT BY col_list]
          ]
         [LIMIT [offset,] rows]
        """
        token = self.token
        # UNION以降は別のSELECT節に当たるので探索範囲から外す。
        while token and not token.match(Keyword, "^UNION", regex=True):
            if token.match(Keyword, "FROM"):
                token_first_id = self.token.parent.token_index(token) + 1
                token = get_token_next(self.token.parent, token)
                # Select.FROM_END_KEYWORDでFROM節の終わりを判定する
                token = self.token.parent.token_matching(
                    self.is_from_end_keyword,
                    self.token.parent.token_index(token)
                )
                if token is None:
                    token_last = self.token.parent.tokens[-1]
                    yield TableReference.from_grouping(
                        self.token.parent,
                        token_first_id,
                        self.token.parent.token_index(token_last)
                    )
                    return
                else:
                    yield TableReference.from_grouping(
                        self.token.parent,
                        token_first_id,
                        self.token.parent.token_index(token) - 1
                    )
                    continue
            if isinstance(token, Where):
                yield WhereCondition(token)
                return
            token = get_token_next(self.token.parent, token)

traverseメソッドの他に、クラスメソッドSelect.is_from_end_keywordが定義されていますが、これはFROM節の終端を特定するためのものです。token.matchメソッドを呼び出すのが大まかな処理内容です。 ただし、ここではマッチングに正規表現を使い、"^UNION"パターンでUNION, UNION ALLの両方とマッチするようにしています。

また、この方法ではマッチできないWHEREだけは別でマッチさせています。 WHEREトークン以外のトークンについては、ttype属性による判定が可能なのですが、WHEREについてはttype属性による判定ができないオブジェクト(sqlparse.sql.Whereオブジェクト)なので、matchメソッドが使えません。 これはWhere以外のいくつかのトークンについても同様なのですが、どちらのケースなのかは基本的にはインポート元から判別できます。

  1. sqlparse.sql: isinstanceによる判定
  2. sqlparse.tokens, sqlparse.keywords: token.ttype を用いた判定もしくは token.matchを呼び出して判定

という認識で問題ないはずです。 念のため、Whereのようなケースについては該当するトークンを本記事の末尾に掲載しておきます。

では、traverseメソッドについても見ていきます。 まず、以下の部分はFROM節の終端に当たるトークンを探索しているコードです。

token_first_id = self.token.parent.token_index(token) + 1
token = get_token_next(self.token.parent, token)
# Select.FROM_END_KEYWORDでFROM節の終わりを判定する
token = self.token.parent.token_matching(
    self.is_from_end_keyword,
    self.token.parent.token_index(token)
)

get_token_next関数は、第一引数で渡したトークンの子の中から第二引数で渡したトークンから見て、コメントや空白をスキップした上で次のトークン(無ければNone)を返します。 実装の解説は省略しますが、気になる方はこちらをどうぞ。

あとは、token.token_matchingメソッドの第一引数にis_from_end_keywordを、 第二引数に探索開始地点のインデックスを渡せば目的のトークンが探索できます。

3.3-c. SELECTトークンの兄弟からtable_reference部分を抜き出す。

さて、ここまででtable_referenceは簡単に抜き出すことができましたが、ここからは少し複雑なことをしていく必要があります。

実は、3.3-bのようなシンプルな方法は構文規則に循環が存在するとうまく動作せず無限ループに入る場合があります。循環しているとは、例えば、table_referenceの構文規則をたどっていく途中のどこかでtable_referenceが左辺に出現した、というような状況です。この循環は左再帰と呼ばれます。

今回はこの左再帰が発生しているので、アプローチを変えます。 自分が思いついたのは2つのアプローチです。 どちらの方法も共通して、table_referenceに当たる複数のトークンを束ねる新しいトークンを定義します。

一つ目のアプローチでは、token.group_tokensメソッドを使います。

これは、複数のトークンを束ねて、grp_cls引数で指定したトークンクラスをそれらトークンの親としてインスタンス化するというメソッドです。 束ねるトークンはあるトークン列の部分列でなくてはいけません。言い換えると、兄弟関係にないトークンどうしや、隣どうしでないトークンどうしを束ねることはできません。 束ねる対象は、始点と終点のインデックスで指定します。

というわけで、インデックスは既に取得できる状態なので、grp_clsを用意します。 複数のトークンを束ねたトークンなので、sqlparse.sql.TokenListを継承して作成します。

from sqlparse.sql import TokenList


class TableReferenceToken(TokenList):
    pass

振る舞いを追加しないので作る意味がなさそうですが、今後の拡張性や、エラートラッキングのやりやすさ、 可読性を考えるとこのように定義しておいたほうがいいと思います。

話を戻すと、トークンを実際に束ねるのが以下のコードです。

token_last = self.token.parent.group_tokens(
    TableReferenceToken, 
    token_first_id, 
    self.token.parent.token_index(token) - 1
)
yield TableReference(token_last)

tokenには3.3-bで探索したトークンが入っています。 実際はtokenがNullの場合も考慮しなければいけませんが、簡単なので説明は省略します。

さて、このアプローチの最大のメリットは、定義済みのメソッド(group_tokens)を使うことで実装をシンプルに済ませられることです。 デメリットは、パース結果を書き換えてしまうため冪等性がなくなる可能性があったり、後続の処理に影響してしまうなどの点です。

というわけで、ここからはもう一つのアプローチ、パース結果を変更しない事例を紹介します。 サンプルプログラムでもこちらの方法を採用しています。

使うのは以下の関数です。この関数はtoken.group_tokensメソッドから、 元のパースツリーのトークンオブジェクトを変更する処理(+α)をごっそり削ったものです。

def group_tokens(token, grp_cls, start_idx, end_idx, include_end=True):
    """
    tokenのサブグループをgrp_clsによってまとめる。
    sqlparse純正のものから機能を大幅に少なくし、さらに元のパースツリーを書き換えないよう
    変更したもの。
    """
    end_idx = end_idx + include_end
    subtokens = token.tokens[start_idx:end_idx]
    grp = grp_cls(subtokens)
    grp.parent = token
    return grp

つまり、元のパースツリーからは繋がっていないトークンを作って、そのトークンを起点にパースツリーを葉へと掘り下げていくという方法です。

データ構造としては、グループ化するトークンとその子孫だけを切り出して有向部分木を新たに作るイメージに近いです*5。 この方法の注意点は、作成したトークンの子だけは親への参照が正しくないため、 token.parent属性やそれを参照する関数等を使う場合には気をつける必要があります。

サンプルプログラムの当該箇所とは異なりますが、以下のような実装になるかと思います。

from .misc import group_tokens
token_last = group_tokens(
    token,
    TableReferenceToken, 
    token_first_id, 
    self.token.parent.token_index(token) - 1
)
yield TableReference(token_last)

3.3-d. テーブル名を取得する。

最後に、テーブル名取得についてちょっと細かい話を紹介します。 なお、取得方法自体は前回の記事をご覧ください。

まず、get_aliasがWHERE IN構文に対して変な挙動を見せる点です。 具体的にはWHERE col_foo IN sub_queryという構文が出現した際に、Whereオブジェクトのget_aliasメソッドを使うとcol_fooをaliasとして引っ張ってきてしまいます。

元のget_aliasメソッドの実装を読むと分かるのですが、厳密なパーサーを書いているというよりは様々な方言に広く使えるものをゆるく書いているような印象なので、バグとは言い切れないです。

なお、サンプルコードでは以下のようにsqlparseの関数をコピーしてきて少しだけ書き換えたものを実装して使っています。

class WhereCondition(HQLTokenWrapper):
    def get_subquery_alias(self):
        """
        WHERE IN 対応版のget_aliasメソッド
        """
        from sqlparse.tokens import Whitespace
        # "name AS alias"
        kw_idx, kw = self.token.token_next_by(m=(Keyword, 'AS'))
        if kw is not None:
            return self.token._get_first_name(kw_idx + 1, keywords=True)
        # "name alias" or "complicated column expression alias"
        _, ws = self.token.token_next_by(t=Whitespace)
        if len(self.token.tokens) > 2 and ws is not None:
            kw_in_idx, _ = self.token.token_next_by(m=(Keyword, "IN"))
            return self.token._get_first_name(idx=kw_in_idx, reverse=True)

次に、get_real_nameの呼び出し元についてです。 テーブル名を表す最小単位のトークンはNameトークンです。 しかし、Nameトークンからget_real_nameトークンを直接呼び出すとNoneが返ってきてしまいます。 必ず、Identifier (Nameの場合はその親がIdentifierになっているはずです) から呼び出すようにしましょう。

サンプルプログラムの実装は以下の通りです。 なお、定義されているのはTblNameというトークンラッパクラス中です。

def get_identifier(self):
    """テーブル名を取得"""
    if self.token.ttype == Name:
        return self.token.parent.get_real_name()
    if self.token.__class__ == Identifier:
        return self.token.get_real_name()

4. おわりに

これまで全3回の記事を通してsqlparseを紹介してきました。 インターネット上で調べた感じだと、どうやらフォーマッタツールとして知られているようですが、 その内部に定義されている種々の機能もとてもパワフルで、 色々な可能性を秘めた「ライブラリ」でもあることが伝わっていれば幸いです。

ここまで色々と書いてきましたが、なんだかんだSQLは好きじゃないです。 データ分析の現場では読み書きしなければいけないケースが多く、仕方なく使っているというような状態です。 でも、これさえあればSQLばかりの現場でもPythonで立ち向かえるはずです。多分。

それでは、よきPythonライフを。

5. おまけ: isinstanceで判定するトークンリスト

  • Statement
  • Identifier
  • IdentifierList
  • TypedLiteral
  • Parenthesis
  • SquareBrackets
  • Assignment
  • If
  • For
  • Comparison
  • Comment
  • Where
  • Having
  • Case
  • Function
  • Begin
  • Operation
  • Values
  • Command

実践 Python 3

実践 Python 3

  • 作者:Mark Summerfield
  • 発売日: 2015/12/01
  • メディア: 単行本(ソフトカバー)

*1:現実に即しているとは言い難いですが、簡易化のためにこの前提を置きました

*2:サンプルコードでは可視化結果を分かりやすくするために結果の得られた順序も保持したかったので、要素が重複しないリストを返しています

*3:3章のように、そもそも間違っている例があったり、非対応のルールもあったりするのでこのように表記しました。

*4:group_tokens等のメソッドが実装されているあたりを見ると、1のアプローチの方がsqlparseの正しい使い方なのかもしれません。

*5:厳密には、これらのトークンオブジェクトの持つ親への参照を無視すれば有向部分木になります

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!