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

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

法人としての価格設定問題からの、おじさんエンジニアの辛さと賃金の関係性

株式会社ホクソエム常務取締役のタカヤナギ=サンです、主にバックオフィス業務を担当しています。

自分メモに書き溜めていたポエムネタが溜まってきたので少しずつ放出していこうと思い筆をとりました。 「いや、そんなもん会社のBLOGに書くんじゃねーよ💢」という話があるかもしれないですが、ここは私の保有する会社なので何の問題もない、don't you?

これは何の話なの?

以前、社のお若い方が技術的に楽しそうな案件を持ってこられて、その価格設定をどうするかについて悩まれておられた時がありました。

その際に社内のSlackにいわゆる”おじさんの小言”のようなものをちらほら書いていたので、それを改めて文章にし、更に「あ、この話は私がちょいちょい感じているおじさんエンジニアの辛さと賃金の話にもつながってくるな」思い、そことも絡めて書いたものになります。

法人としての価格設定問題

既にご存じの方もいるかもしれないですが、弊社は全員が副業として回している会社で21世紀にふさわしい働き方改革ダイバージェンス2020を体現した会社となっております。 なので、OSSを開発するかの如く「うわぁ、この案件技術的に超楽しいナリ〜」と土日を潰してほぼ無料のご奉仕価格で開発しちゃうというのもありといえばあり、”ありよりのあり”になるのですがそれはやっていません。

なので、”ありよりのなし”、です。

「なんでじゃい、楽しければお安く引き受けてもいいんじゃないんかい💢(表現はもっとマイルドで低姿勢)」とお若い方がご本人の謙虚さからくる面もあってそう言われていたのですが、そこを諭す際によく引用させてもらっているのがこの「とんかつ屋の悲劇」という記事です。これを一部引用させていただくと

年金が形を変えた補助金に?  「何十年も変わらない値段と、チェーン店ではありえない品質の高さと格安さ」などとグルメサイトでも称賛されていることが多い。しかし、それを可能にしているのは、すでに減価償却の終わった古い設備、ローンを払い終えた自社店舗、そして年金をもらいながら夫婦で切り盛りしていることなどだ。  ある意味、年金が経営継続への補助金のようになっているわけだ。こうした経営を続けてきた場合、いよいよ世代交代の時期になると若い現役世代にはとても生活をしていけるだけの収入を得ることができない。  「そうなってから、急に値段を大幅に上げるなどはできないし、設備更新などに多額の費用がかかるので、後継者にとっては重荷になるでしょう。」商業関係の支援事業を行う行政職員は、そう話す。先の外食産業の社員も、「夫婦二人で一人分の給与しかなく、それでやっと可能になっているような低価格がウリでは、いくら有名でも、のれん代を出してまで買収する意味はあまりない」と言う。

news.yahoo.co.jp

という話です。

要するに「(ご本人らは年金で食えるので)善意から値上げをしていないのかもしれないが、結局はそれが過度な価格下落競争を生んで、近隣の似たような(この場合だととんかつ屋をやりたいお若い方の)店を強制的に廃業に追い込でいる」という話になるわけです。

これに絡めて「いいかい?俺たちはとんかつ屋の悲劇を起こしてはいけない。技術的に面白い(主にRの)話だからといってもめちゃ低価格で引き受けるのはやめよう!それは巡り巡ってお若いRユーザの芽🌱を摘むことになる(Rが儲からない言語になるのは悲しい😢)」という話をしていたのです。

うんうん、わかる、過度なダンピングよくない、それは強制的に海を赤く染める行為なわけです(しかもそこに流れる赤い血は自分のものではなく人のもの)。

おじさんエンジニアの辛さと賃金

これと同じ話が一個人のおじさんエンジニアの賃金にも当てはまってくるぞと、もうちょっと機械学習っぽくいうと”おじさんエンジニアの辛さと賃金”というトピックベクトルは良い感じに”法人の価格設定問題”空間に射影できるぞと、そういうことです。

え?何が関係あるのかって?ちょっと落ち着いて私の経験を聞いて欲しい。

私自身は社会人歴14年で転職を繰り替えしたりホクソエムったりで、合計6社ほどの経験があるのですが、転職の際に年収を2~300万円ほど下げて転職した経験が2度ほどあります。 その意図は「正直、自分を優秀な技術者と仮定するならば、そのうち成果出して給料もあがるだろうし昇給がだめなら転職/副業すればいいや〜」くらいの楽観論が根底にあります。 その一方で、更にガッツリ年収を下げる(年収300万円くらいになるイメージ)のはいくら副業で稼げてもNGです。

もともとの「法人としての価格設定問題」の考えてに至った原点も実はこのへんにあります。 以前、同僚(私の心の中のインフラクラウド師匠)だった方が

「某D社では技術的に凄いおじさんエンジニアがたくさんいるんだが、彼らは全然給料交渉をしない。そして僕らは彼ら技術的に凄いおじさんエンジニアと給料を比較されるので僕らが交渉しても給料がまるで上がらないんだ。だから転職した。」

という話をしていてこれが原点です、めっちゃ印象的でした(かれこれ4〜5年前です)。 それまでは社会を知らなすぎてこの視点が私にはまるでなかったのです(当時社会人8年目くらい)、 pip install 社会性 が実行された瞬間ですくぅぅ。

もちろん人事(あるいは採用コストを管理されてる方々)の側としては「いや、技術的にめっちゃすごいおじさんエンジニアXさんの年収がY円なのに、それより技術的に優れていない(私から見れば優れているんだけど&色々パクらせてもらった)君(インフラクラウド師匠)の給料を上げる必要なくね?」と主張できるわけです。 いや、人事殿らの意見わかる、実に筋が通っている。その通りや!

この場合、誰が(お若い方の給料を上げるという観点で)悪いのかというと私は技術的に凄いおじさんエンジニア氏らだと考えます。 じゃぁどうすべきなのか?犯人探しや批判は猿🐵でもできるので、私のこの状況における解決策とボヤキを書くと

「技術的に凄いおじさんエンジニアはお若い方のお賃金上昇に迷惑をかけないように、会社の売上を上げる(あるいはコストを下げる)ような成果を出して、ある程度の高給を交渉して取っていく必要がある。 はぁ、おじさんエンジニアは辛いな、技術的なことだけ考えていてぇ」

となるわけです。 技術的に凄いおじさんエンジニア、ヤッテイキたい。

そんなポエムでした。 ・・・次回もまた、見てくれよな!

【翻訳】機械学習の技術的負債の重箱の隅をつつく (後編)

ホクソエムサポーターの白井です。 今回は前回 【翻訳】機械学習の技術的負債の重箱の隅をつつく (前編) の続きを紹介します。

blog.hoxo-m.com

※この記事は、Matthew McAteer氏によるブログ記事Nitpicking Machine Learning Technical Debtの和訳です。原著者の許可取得済みです。

後編では、コードのアンチパターンなど、エンジニアには身近な話題で、前編と比較して実践しやすいコンテンツも多いと思います。

Nitpicking Machine Learning Technical Debt (機械学習の技術的負債の重箱の隅をつつく)

再注目されているNeurIPS2015の論文を再読する (そして、2020年のための、より関連した25のベストプラクティス)

Part5 MLコードにある共通のダメなパターン

技術的負債論文の 「アンチパターン」の章は前の章よりも実践可能でした。 このパートは前パート(フィードバックループ)よりもより高レベルな抽象的なパターンを扱います。

(これは実際は技術的負債論文の情報を表にしたもので、さらにその修正方法に関するアドバイスへのハイパーリンクをつけています。

この表は、著者らがML特有のコードの臭いとアンチパターンについて議論したもので、もしかしたらちょっとやりすぎかもしれませんが、 すべてはじめに着手すべき標準的なソフトウェアエンジニアリングのアンチパターンに相当しています。)

設計の失敗 種類
機能の横恋慕 (Feature Envy) コードの臭い
データクラス (Data Class) コードの臭い
長すぎるメソッド (Long Method) コードの臭い
長すぎるパラメーターリスト (Long Parameter List) コードの臭い
巨大なクラス (Large Class) コードの臭い
神オブジェクト (God Class) アンチパターン
マルチツールナイフ(Swiss Army Knife) アンチパターン
機能分解 (Functional Decomposition) アンチパターン
スパゲッティコード (Spaghetti Code) アンチパターン

(訳注: 日本語訳はwikipediaのアンチパターンコードの臭いソフトウェア開発 アンチパターンのまとめ を参考にした)

これらのパターンはその90%以上が、MLコードのモデル更新ではなく“モデルを維持している”部分に関連するものです。 これは、Kaggleコンペのほとんどの参加者は存在すると考えたことのないような配管 (plumbing) です。 細胞のセグメンテーションを解くのはずっと簡単です、モデルの入力をTacan Evo cameraとつなぐコードに大半の時間を浪費しない限りは。 (訳注: 上記Kaggleコンペのリンクは細胞のセグメンテーションコンペ)

Best practice #9 定期的なコードレビューを使おう (そして・またはコードチェックツールを使おう)

最初のMLのアンチパターンは 「グルーコード (glue code)」です。 グルーコードは汎用的な目的のパッケージから持ってきたデータやツールを、自分の持っているとても限定されたモデルにフィットしたい時に書いたコード全てを指します。 RDKitのようなパッケージでなにかしようとしたことのある人なら誰もが、私の言っている意味がわかるでしょう。

基本的に、 utils.py に押しやったものは大抵、これに該当します (誰もがやったことがあるはずです)。 これらは (できれば) もっと具体的なAPIエンドポイントに依存性をリパッケージして修正すべきです。

f:id:sh111h:20200705190057p:plain
私たちはみな、自慢できない utils.py を持っているものです。

Best practice #10 汎用的な目的の依存関係は特定のAPIにリパッケージしよう

「パイプラインジャングル (pipeline jungles)」は少し厄介で、グルーコードが大量に蓄積された場所を指します。 すべてのちょっとした新しいデータに対する変換が、醜い混合物 (amalgam) として積み重なった場所です。 グルーコードとは異なり、著者らは、スクラッチからのコードベースを手放して、再設計するように強く薦めています。

今や他の選択肢があると言いたいところですが、グルーコードがパイプラインジャングルに変貌した時には、Uber's Michelangeloのようなツールですらむしろ問題の一部になりえます。

もちろん、著者らのアドバイスのメリットは、変換したコードを興奮するようなかっこいい名前の新しいプロジェクトにみせられるということです。 トルーキンの参照が義務付けられている「Balrog」のように。 (プロジェクトの名前の不幸な暗喩を無視しているのはPalantirのドメインだけではありません。 あなたもまた自由です。)

(訳注: Barlog(バルログ) も Palantir(パランティーア)もトルーキンの指輪物語 に登場する。 Palantirは物語において「見る石」であり、危険な道具である。)

f:id:sh111h:20200706083622j:plain
ソフトウェアの形

Best practice #11 トップダウンの再設計・再実装によってパイプラインジャングルを取り除こう

考えたくない話題、試験的コード (experimental code)について見ていきましょう。 あなたは後々のために試験的コードを保存しようと当然思うはずです。 使っていない関数や参照していないファイルにおいておけば、問題ないと思ったでしょう。 あいにく、このようなものは後方互換性の管理が頭痛のタネになる理由のひとつになります。

Tensorflowを深く掘り下げたことのある人であれば覚えがあるでしょう、一部だけ吸収されたフレームワークや、試験的コードのちの日に気が付くエンジニアのために残された 未完了の「TODO」 コードに。 Tensorflowコードの謎の失敗をデバッグしようとした時、あなたは偶然これらを見つけたのではないでしょうか。 これは間違いなく、Tensorflow 1.Xと2.Xの間にある互換性の遅れを浮き彫りにしています。

自分自身のためにも、5年もコードベースの剪定から逃げるのはやめましょう。 実験をつづけても、他のコードから実験的コードを隔離する基準を定めましょう。

f:id:sh111h:20200705190150p:plain Google Researchの散らかった (少なくとも上位5位に入る) githubレポジトリ、しかも公開されているものです。 そして、READMEは同じぐらい詳細とは程遠く、実際は真逆です。

Best practice #12 定期的な点検を定めて、コードを取り除くための基準をつくろう、もしくはビジネスに重大な影響を及ぼすコードとは程遠いディレクトリやディスクにMLコードをおこう

古いコードといえば、ソフトウェアエンジニアリングが以前からなにをやってきたか知っていますか? それは本当に素晴らしい抽象化です! 関係データベースの概念からウェブページの表示まで、ありとあらゆることです。 応用カテゴリ理論には、コードを整理するためのベストなやり方を発見することに執念を燃やしている分野があります。

応用カテゴリ理論が、何にいまだ悩みつづけているか知っていますか? そう、機械学習のコード整理です。 ソフトウェアエンジニアリングは壁に抽象的なスパゲティーを投げて、なにが刺さるかを見る経験がありました。

機械学習? Map-Reduce (これは関係データベースのように印象的な概念ではない) や Async Parameter servers (どうやってすべきなのか誰も同意できない) や sync allreduce (多くの場合大失敗している) は別として、私たちが見せられるものはありません。

f:id:sh111h:20200705190240p:plain 先ほど言及した、Sync AllReduce serverのアーキテクチャの大まかな概要 (かなり簡略化したバージョン)

f:id:sh111h:20200705190302p:plain 先ほど言及した、Async parameter serverのアーキテクチャの大まかな概要 (少なくともそのバージョンのひとつ)

実際、ランダムネットワークの研究をしているグループとPytorchがニューラルネットワークのノードがいかに流動的であるかを宣伝している間に、 機械学習は抽象的なスパゲティーを窓から綺麗に投げ捨ててしまったのです! 著者らは、この問題が時間と共にとても悪くなっていることに気づいていたとは思いません。 私のおすすめ? 大まかな抽象化について、有名な文献を読みましょう。あと、プロダクションのコードにはPytorchを使わないほうがいいと思います。

Best practice #13 時間と共に強固になる抽象化を最新の状態にしておこう

私は今まで、お気に入りのフレームワークがあってほとんどの問題に対してそれをつかうのを好むシニアな機械学習エンジニアとたくさん会ってきました。 私はまた、彼らのお気に入りのフレームワークを新しい問題に適用するとき、そのフレームワークが破綻したり機能的に区別できない他のフレームワークに変更されたりするのを目の当たりにした同じエンジニアを見てきました。

これは特に、分散させて計算するタイプの機械学習処理を行うチームに多く見られます。 私にははっきりさせておきたいことがあります。

MapReduceは別として、なにかひとつのフレームワークに執着しすぎるのは避けるべきです。

あなたの “シニア(先輩)” 機械学習エンジニアが「Micheloangeloが最高で、全てを解決できる」と信じているならば、彼らはそれほどシニアではないかもしれません。

MLエンジニアリングは成熟してきた一方で、まだ相対的には早熟なのです。

本当の “シニア” なシニアMLエンジニアは、フレームワークにとらわれないワークフローを重要視するでしょう、なぜなら、フレームワークの多くは寿命が長くないと知っているからです。 ⚰️

さて、前のセクションで機械学習における技術的負債の区別できるシナリオや質について言及しましたが、技術負債論文では機械学習開発のための大まかなアンチパターンの例も提供しています。

これを読んでいる人の多くは、コードの臭い (code-smells) というフレーズを聞いたことがあるでしょう。 good-smellPep8-auto-checking (さらにはみんながPythonコードに使っている、新しい自動フォーマッタ Black) のようなツールを使っているかもしれません。 正直に言うと、 「コードの臭い」という用語が好きではありません。

「臭い」は常に微妙なものを暗に意味しているようにみえますが、次のセクションに書かれたパターンは逆にとても露骨なものです。 それにもかかわらず、著者らは負債を大まかに示すコードの臭いの種類を少しだけ並べています (普通のコードの臭いの種類を超えて)。 なぜか、“コードの臭い”のセクションの途中からコードの臭いを記載し始めています。

「プレーンなデータ」の臭い (The "Plain data" smell)

numpy float形式のデータを大量に扱うコードをもっているかもしれません。 RNAの読み込み回数がベールヌイ分布からのサンプルを表現しているのかどうか、floatが数値の対数かどうかといった、データの性質を保持する情報がほとんどないことがあるかもしれません。

技術的負債論文では言及されていませんが、これはPythonのtypingが助けられる領域のひとつです。 不必要なfloatや、正確すぎるfloatの使用を避けることが大いに役に立ちます。 繰り返しますが、組み込みの DecimalTyping パッケージを使うことはとても助けになります (コードを誘導するだけでなく、CPUのスピード向上にもなります)。

Best practice #14 TypingDeimal のようなパッケージを使って、すべてのオブジェクトに対して 'float32' を使うのはやめよう

f:id:sh111h:20200705190349p:plain 過半数のハッカソンコード、そして残念なことにベンチャーキャピタルに資金提供されている「AI」スタートアップのコードの中身はこんな感じです。

「試作品」の臭い(The "Prototyping" smell)

ハッカソンに参加した人なら知っているでしょうが、24時間以内に寄せ集めでつくられたコードはこんな形をしています。 これは、先ほど言及した実際には誰にも使われない試験的コードともつながっています。 生物学データのための PHATE次元削減ツールを試したくて興奮するかもしれませんが、コードをきれいにするか、投げ捨てるかしてください。

Best practice #15 進行中のものを同じディレクトリに置かない。片付けるか、きれいにしよう。

「多言語」の臭い (The "Muitl-Language" smell)

プログラミング言語の種類について言うと、多言語のコードベースは技術的負債が複数積み重なるように、その積み重なりがより早くなるよう振る舞います。 もちろん、すべての言語にはそれぞれ利点があります。 Pythonはアイデアを素早く構築するためには良いです。 Javascriptはインタフェースに向いています。 C++はグラフィックに最適で、計算もはやいです。 PHPは、うーん、特にこれといったものはないです。 GolangはKubernetesを使う (もしくはGoogleで働く) には便利です。

しかし、これらの言語でお互いに会話するとして、エンドポイントが壊れたり、メモリリークによって、うまくいかない点がたくさんあるでしょう。 少なくとも機械学習では、言語の間で似たセマンティクスを持つSparkやTensorflowのようなツールキットは少ないです。 もし複数の言語をどうしても使わなくてはいけなくても、少なくとも2015年以降はそれが可能になってはいます。

f:id:sh111h:20200705190432p:plain C++のプログラマーがPythonに転向したら。この人が書いたレポジトリ全体のコードを思い描いてみてください

Best practice #16 エンドポイントが説明されていることを確認し、言語間で似たような抽象度を持つフレームワークを使用しよう

(これをコードの臭いと呼ぶのは奇妙な選択です、普通のコードの臭いを基準としても、これはとても露骨なパターンなので。)

Part6 構成の負債 (退屈だけど修正は簡単)

技術的負債論文の「構成の負債」の章はおそらく楽しいものではないですが、そこに記述されている問題は簡単に修正できます。

基本的に、機械学習のパイプラインについて、すべての調整可能で構成可能な情報を一箇所にまとめて、確かめる習慣であり、これはいうなればあなたの2番目のLSTMレイヤーのユニット数がどう設定されているか調べるために複数の辞書を探す必要のない状況のことです。 設定ファイルをつくる習慣を持っていても、すべてのパッケージと技術があなたを悩ませないわけではありません。

一般的な方針は別として、技術的負債論文のこの部分は詳細を十分に深堀りしていません。 技術的負債論文の著者らはCaffeのようなパッケージを使うのに慣れていたのではないかと疑っています (Caffeのプロトコルバッファーで設定ファイルを設定することは客観的に見てバグだらけで恐ろしいものでした)。

個人的には、もし設定ファイルを設定したいのであれば、tf.KerasChainer のようなフレームワークを使うことを提案します。 クラウドサービスはバージョンの構成管理機能をもっていますが、それ以外では、config.jsonか パラメーターフラグを使う準備をする必要があります。

Best practice #17 ファイルパスやハイパーパラメーター、レイヤーの種類、レイヤーの順番や他の設定が一つの場所から設定できるか確かめよう

f:id:sh111h:20200705190510p:plain 設定ファイルの素晴らしい例。 技術的負債論文はもっと例をあげたり、当時存在した設定ファイルのガイドについて指摘して欲しかったと切に思います。

もしコマンドラインをつかってこれらの設定を調整したいなら、ArgparseのかわりにClickのようなパッケージをつかってみましょう。

Part7 解決への夢を打ち砕く実世界

7章は、技術的負債の管理の多くが、現実世界の絶えない変化を扱っているという事実のために準備することであると認めています。 例えば、モデルの出力を分類に変換するために閾値決定が必要なモデルや、あるいは真(True)または偽(False)のBool値を直接選ぶことができるモデルがあるかもしれません。

生物学的もしくは健康データを扱うグループや企業は、診断の基準が急速に変化することをよく知っています。 特にベイズ機械学習をしているときは、あなたの扱っている閾値が永遠に続くとは仮定してはいけません。

f:id:sh111h:20200705190555j:plain オンライン学習アルゴリズムによる決定境界……デプロイから12分後

Best practice #18 モデルの現実世界でのパフォーマンスと、決定境界を常に監視しよう

このセクションではリアルタイムの監視の重要性を強調しています、私は間違いなくこれの良さがわかります。 どのようなことを監視するかについて、この論文は包括的なガイドではありませんが、いくつかの例を与えています。

ひとつは観測した観測したラベルの要約統計量と、予測ラベルの要約統計量を比較することです。 完璧ではありませんが、小さな動物の体重をチェックするようなものです。 何かがすごく間違っていれば、その問題をすぐに警告できます。

f:id:sh111h:20200705190849p:plain 数えきれないほどたくさんのツールがあります。 近頃はだれもが、その母親さえもが、監視ツールのスタートアップを作っています。 ここでは、他に比べてバグが少ないSageMakerとWeights & Biasesを例に挙げてみました。

(訳注: それぞれのリンクは、Amazon SageMaker Model MonitorWeights & Biases)

Best practice #19 予測ラベルの分布が、観測ラベルの分布と似ているか確認しよう

あなたのシステムが現実世界のなにかを意思決定しているなら、レート制限(何某かの呼び出し回数制限)をしたいと思うでしょう。 たとえシステムが株の入札のための数百万ドルを任せられているものではなく、細胞培養インキュベーターのなにかが正しくないと警告を出すだけのシステムであっても、 単位時間あたりの行動制限を設定していないことでのちのち後悔することになります。

f:id:sh111h:20200705190909p:plain ML製品の初期の頭痛のタネのいくつかは、レート制限をしていないシステムで起こるでしょう。

Best practice #20 機械学習システムによってもたらされる意思決定に制限を設けよう

MLパイプラインが消費している、データの上位生産者(データを提供してくれている人・会社たち)によるどんな変化も、心に留めておきたいところです。 例えば、人間の血液やDNAサンプルの機械学習を走らせているどんな企業も、当然ながら、それらのサンプルがすべて標準化された手順で収集されていることを確認したいと考えています。 もしサンプルの多くが特定の集団からきたものであれば、企業は分析がゆがんでいないか確かめなければいけません。 もし培養された人間の細胞のシングルセルシーケンシングを扱っているのであれば、薬剤が作用したために死滅したがん細胞と、インターンが誤って培養した細胞を脱水させてしまったために死滅したがん細胞を混同していないことを確認する必要があります。

著者らは、理想的には、人間が対応可能でない時も、これらの変化に反応できるシステム (例えばログをつける、調節を自ら止める、決定閾値を変更する、技術者や修復可能な人に警告する) が必要だと言っています。

f:id:sh111h:20200705190927p:plain 今は笑えるが、このようなミスは、思った以上に危険にさらされているのかもしれません。

(訳注: DNA採取で用いる綿棒が汚染されていたことで、複数の事件で同一のDNAが検出され、架空の犯人ハイルブロンの怪人として騒がれた事件のこと。 上記画像の記事リンク。)

Best practice #21 入力データの背後にある仮説を確かめよう

Part8 奇妙なメタセクション

技術的負債論文の最後から2番目の章は他の領域について言及しています。 著者らは以前、技術的負債の種類として抽象化の失敗について言及してました、そしてどうも、論文の最初の7章の中に技術的負債の種類に収められなかったことにまで及んでいるようです。

サニティーチェック (Sanity Checks)

データのサニティーチェック(訳注: プログラムのソースコードの整合性・正当性を検査すること)をすることは非常に重要です。 新しいモデルを学習しているとき、モデルが少なくともデータのカテゴリーの一つの種類に過学習する可能性があることを確かめたいでしょう。 もし学習が収束しないなら、ハイパーパラメーターを調整する前に、データはランダムなノイズでないか確認した方がいいかもしれません。 著者らはこのように具体的ではなかったですが、言及するのに良いテストだと考えました。

Best practice #22 モデルが過学習する可能性があることを確認し、データの全てがノイズであったり、無信号でないことを確かめよう

再現性 (Reproducibility)

再現性。研究チームにいるあなた方の多くがこれに遭遇すると思います。 seedが設定されていないのコードや、故障中のノートブック、パッケージバージョンなしのレポジトリをみたことがあるでしょう。 技術的負債論文以降、何人かが、再現性チェックリストをつくっています。 ここに、4ヶ月前 hacker newsが特集したかなり良いリストがあります。

f:id:sh111h:20200705190945p:plain これはとても素晴らしいチェックリストの一つで、私も使っており、共に働くチームにも使うよう勧めています。

(訳注: このチェックリストのpdfのリンクはここ)

Best practice #23 研究のコードを公開するときは、再現性に関するチェックリストを使おう

f:id:sh111h:20200705191004p:plain アカデミアからのコードを再現したことのある、産業側の人間なら、私の言っている意味がわかると思います。

プロセス管理 (Process management)

今まで議論してきた技術的負債の種類の多くは単一の機械学習モデルについて言及してきましたが、プロセス管理負債はおなじ時間にたくさんのモデルを実行した時に起こります、 1つの遅れたモデルが終わるのを待っている間に、すべてを止めようとは考えないでしょう。 システムレベルの臭いを無視しないのが重要で、また、モデルの実行時間をチェックすることが極めて重要です。 技術的負債論文が書かれて以降、機械学習エンジニアリングは少なくても大まかなシステム設計について考えて改良されてきています。

f:id:sh111h:20200705191020p:plain ボトルネックの同定に使われるチャートの一例

Best practice #24 機械学習モデルのために実行時間を確認・比較する習慣をつけよう

文化的負債 (Cultural debt)

文化的負債はとても厄介な種類の負債です。 著者らは、研究とエンジニアリングの間には時として格差があり、異種混合チームでは負債の返却行為は促しやすいと指摘しています。

個人的には、最後のこの部分は好きではありません。 私は、エンジニアディレクターとリサーチディレクターの両方に、最終的に報告する個人がいるチームをたくさん目撃しました。 エンジニアたちに、負債の修正に必要な変更のための権限のない異なる2つの部門にレポーティングさせることは技術的負債の解決策ではありません。 一部のエンジニアが、この技術的負債の矛先になるという意味では、これは解決策です。 そのようなエンジニアはNo Authority Gauntlet Syndrome (NAGS) (訳注: 権力なき挑戦症候群) になり、燃え尽き、最も同情的なマネージャーがバーニングマンに出ている間に、そのエンジニアが達成すべき作業を目標として持っていたマネージャーによって解雇されてしまうのです。 もし異種混合が助けになるなら、チームの 全体 に渡ることが必要です。

(訳注: バーニングマン は8~9月にアメリカで開催される大規模なイベントのこと)

それに加え、著者らは、チームや企業文化を語る時に、多くの人と同じ間違いをしていると思います。 特に、文化と価値観をごちゃまぜにしています。 企業やチームにとって向上心のある規則を列挙し、それを文化と呼ぶのは簡単です。 実行するのにMBAを持つ必要はないですが、それは実際の文化というより価値観です。 文化とは、二つの重みのある価値観のどちらかを選ぶよう要求した状況で、最終的に人々が実行することです。

これはUberが大きな問題に陥った原因です。 競争力と誠実さの両方が企業の価値観の一部でしたが、最終的には、文化は競争力がなによりも強調されることを要求しました、 例え人事部が絶対的に嫌なやつを会社に留めるために法律を破ることを意味しても。

(訳注: Uberの話はおそらく Reflecting on one very, very strange year at Uber で述べられている話)

f:id:sh111h:20200705191037p:plain 悪循環

技術的負債についてのこのイシューは似たような状況でも起こります。 どれぐらい“メインテイナブル(保守可能な)”コードが必要かについて話すのは簡単です。 しかし、誰もが締め切りで追われ、ドキュメントの執筆がJIRAボードの優先度の中で下がっていくなら、最善の努力をしていても負債は積み重なっていきます。

f:id:sh111h:20200705191051p:plain 技術的負債の様々な理由

(訳注: 「無鉄砲/慎重」と「意図的/不注意」の2つの基準を使った技術的負債の四象限)

Best practice #25 (それがどのような形であっても)技術的負債に対処するために、定期的に、交渉の余地のない時間を確保しておこう

Part9 技術的負債のリトマス試験

「負債」の一部は単なるメタファーだということを思い出してください。 どれだけ著者らがより厳格に見せようとしても、それにすぎません。

多くの負債と違って、機械学習の技術的負債は測るのが難しいものです。 与えられた時間であなたのチームがどれぐらいはやく動くかはどれぐらい負債をもっているかの指標にはなりません (新卒のプロダクトマネージャーが主張しているようにみえるにもかかわらず)。 指標にかかわらず、著者らは自身に問うための5つの質問を提案しています(ここではわかりやすく言い換えています)

  • 最大のデータ資源を実行する、任意のNeurIPS論文からアルゴリズムを取得するのにどれぐらいかかるか?
  • どのデータ依存性がコードの中で最も(もしくは全く)関わっているか?
  • システムの一部を変更した場合、結果の変化をどれぐらい予測できるか?
  • MLモデルの改良システムはゼロサムかpositive-sumか?
  • ドキュメントはあるか?新人のための立ち上げプロセスではとっかかりになるものがあるか?

もちろん、2015年以降、他の記事や論文がより正確なスコアリングメカニズムを考えようとしています (scoring rubicsのように)。 ツールのいくつかは、不正確であっても、一眼でわかるスコアリングメカニズムを作成可能で、技術的負債を追跡する助けになります。

また、技術的負債のいくつかの解決策になると称賛されている解釈可能なMLツールは多くの進歩を遂げています。 それを心に留めたうえで、改めて “Interpretable Machine Learning” by Christoph Molnar (オンラインで利用可能) をおすすめします。

The 25 Best Practices in one place

これまでに言及したベストプラクティスを以下にまとめます。 これよりたくさんあるでしょうが、技術的負債の解消のツールはパレートの法則に従います: 技術的な負債の救済策の20%は、あなたの問題の80%を修正することができます。

  1. 解釈可能・説明可能なツールを使おう
  2. 可能であれば、説明可能な種類のモデルを使おう
  3. 常に、順番に下流モデルを再学習しよう
  4. アクセス鍵、ディレクトリの権限、サービス水準合意をセットアップしよう
  5. データのバージョン管理ツールを使おう
  6. 使っていないファイル、膨大な関係する特徴量を削除し、因果関係推論ツールを使おう
  7. データの依存関係を追跡する、無数にあるDevOpsツールのいくつかを使おう
  8. モデルの背後にある独立性の仮定を確認しよう (セキュリティーエンジニアの近くで働こう)
  9. 定期的なコードレビューを使おう (そして・またはコードチェックツールを使おう)
  10. 汎用的な目的の依存関係は特定のAPIにリパッケージしよう
  11. トップダウンの再設計・再実装によってパイプラインジャングルを取り除こう
  12. 定期的な点検を定めて、コードを取り除くための基準をつくろう、もしくはディレクトリかビジネスの物から遠い場所にコードをおこう
  13. 時間と共に強固になる抽象化を最新の状態にしておこう
  14. TypingDeimal のようなパッケージを使って、すべてのオブジェクトに対して 'float32' を使うのはやめよう
  15. 進行中のものを同じディレクトリに置かない。片付けるか、きれいにしよう。
  16. エンドポイントが説明されていることを確認し、言語間で似たような抽象度を持つフレームワークを使用しよう
  17. ファイルパスやハイパーパラメーター、レイヤーの種類、レイヤーの順番や他の設定が一つの場所から設定できるか確かめよう
  18. モデルの現実世界でのパフォーマンスと、決定境界を常に監視しよう
  19. 予測ラベルの分布が、観測ラベルの分布と似ているか確認しよう
  20. 機械学習システムによってもたらされる意思決定に制限を設けよう
  21. 入力データの背後にある仮説を確かめよう
  22. モデルが過学習する可能性があることを確認し、データの全てがノイズであったり、無信号でないことを確かめよう
  23. 研究のコードを公開するときは、再現性に関するチェックリストを使おう
  24. 機械学習モデルのために実行時間を確認・比較する習慣をつけよう
  25. (それがどのような形であっても)技術的負債に対処するために、定期的に、交渉の余地のない時間を確保しておこう

おわりに・参考資料

今回翻訳する上で、ブログの元ネタとなる 技術的負債論文 と同じ著者で、技術的負債論文 が公開される1年前のNeurIPS2014で公開された論文 (Machine Learning:The High-Interest Credit Card of Technical Debt)について、日本語で解説している以下の情報を参考にさせていただきました。

ちなみに、日本語の情報としては「High-Interest Credit Card~」のほうが豊富ですが、前編で紹介した、機械学習の技術的負債を象徴する図 (下記) は技術的負債論文が初出です。

f:id:sh111h:20200622074908p:plain

改めて、翻訳の許可をしてくれた原著者のMatthew McAteer氏に感謝します。

仕事ではじめる機械学習

仕事ではじめる機械学習

【翻訳】機械学習の技術的負債の重箱の隅をつつく (前編)

ホクソエムサポーターの白井です。 今回は Matthew McAteer氏によるブログ記事Nitpicking Machine Learning Technical Debtの和訳を紹介します。

原著者の許可取得済みです。 Thank you!

アメリカの国内ネタも含んでいて、日本語だと理解しにくい箇所もありますが、機械学習の技術的負債をどう対処していくかについて、とても役に立つ記事だと思います。

Nitpicking Machine Learning Technical Debt (機械学習の技術的負債の重箱の隅をつつく)

再注目されているNeurIPS2015の論文を再読する (そして、2020年のための、より関連した25のベストプラクティス)

最近 Hidden Technical Debt in Machine Learning Systems (Sculley et al. 2015)を読み返しました。 (簡潔に、明確にするため、この投稿では技術的負債論文とします) この論文は NeurIPS 2015 で公開されましたが、 Ian GoodFellowによる 「Generative Adversarial Network」技術に皆が夢中になっていたので、そこまで注目されませんでした。

今日、技術的負債論文がまた盛り返してきています。 これを書いている間にも、直近75日のあいだに25の論文が引用しています。 これは、機械学習について私たちが技術的負債について心配しなければいけなレベルにまで来たという意味でしょう。

ですが、多くの人々がこの論文を引用しようとしているならば (「機械学習の技術的負債」というフレーズを持つ論文全てを引用しているだけでないならば)、少なくともどの箇所が本質的なもので、どこがそうでないのか認識すべきです。 そのことを念頭に置いて、どの部分が時代遅れなのかを紹介し、また取って代わる新しい方法を示すことは、機械学習に関係する人々全員の時間と手間を大幅に省くことができると考えました。 これが私がこのBLOG POSTを書いた理由です。 急成長中のスタートアップからグーグル (技術的負債論文の著者の企業) のような大企業で働いてきて、 同じような機械学習の技術的負債による失敗がどこでも行われているのをみているので、私はこの件について執筆する資格があると考えます。

f:id:sh111h:20200622074908p:plain

MLコードは小さくごくわずかなブラックボックスであることに注目してください。 この”小ささ”がこの論文の良い部分の一つですが、悲しいことにこの画像を参考に作られた画像はボックスの大きさの違いを考慮しておらず、この”小ささ”という要点を逃しています。

この記事は技術的負債論文と関連する点のいくつかを網羅しながら、5年前にはないアドバイスを付け加えています。 そのいくつかのアドバイスはその当時存在しなかったツールの形をとっています。 また、ツールや技術自体は間違いなく存在していたものの、当時の著者らが持ち出さなかったことで見逃してしまったアドバイスもあります。

イントロダクション

技術的負債は、エンジニアが他の何よりもデプロイの速い設計を選んだ時のコスト増加を例えたものです。 技術的負債を修正するのは骨の折れる仕事です。 「早く動いて、破壊せよ (Move fast and break things)」から「ああ大変だ、急ぎすぎたから片付けなきゃ (Oh no, we went too fast and gotta clean some of this up)」に方向転換です。

f:id:sh111h:20200622075510p:plain

キャッチーでなくとも、Mark Zuckerburgが二つ目のスローガンに口を挟まなかった理由が私にはわかります。

(訳注: “Move fast and break things” はFacebookのMotto。その後 “Move fast with stable infrastructure” に変更した。 (wikipediaより) )

ソフトウェアにおける技術的負債は悪いものですが、MLシステムの技術的負債は もっと悪い と論文の著者らは強く主張しています。 技術的負債論文はMLにおける技術的負債の種類と、いくつかの解決策を紹介しています (いろいろな種類のゴミ箱があるのと同じで、いろいろなクソコードに対し、いろいろな手法が必要です🚮) 技術的負債論文は、元は人々の注意を引きつけるための意見記事であったことを考えると、論文中のいくつかのアドバイスはもはや関係なかったり、現在ではよりよい解決策があることを覚えておくのが重要です。

Part1 技術的負債はあなたの予想以上に悪い

みなさんはもう、技術的負債についてご存知でしょう。 技術的負債論文ははじめに、技術的負債の解消というものがコードに新しい機能を追加することを意味しないことを明確にしています。 技術的負債の解消はユニットテストを書いたり、可読性を向上させたり、ドキュメントを追加したり、未使用の部分を取り除いたりする、華やかさにかけるタスクであり、将来の開発が楽になるためのタスクです。 まあ、標準的なソフトウェアエンジニアリングは機械学習エンジニアリングで必要なスキルの一部なので、より一般的なソフトウェアエンジニアリングの技術的負債は、MLの技術的負債の一領域にすぎません。

f:id:sh111h:20200622075741p:plain

言い換えてはいますが、(Sculley et al. 2015) が言っていることとほぼ同じです。

Part2 機械学習の漠然とした性質

技術的負債論文の、イントロダクションの後の章は、機械学習モデルの漠然とした性質が、どのように技術的負債の扱いを難しくしているかに続きます。 技術的負債を防いだり直したりするための大部分は、コードが適切に整理され、モジュールとして分離されているかを確かめることです。 正確なルールや、コーディングでは細かい指定が難しい場合に、私たちは機械学習を使います。 データを出力するためのルールをハードコーディングする代わりに、多くの場合、アルゴリズムにデータと出力を与えてルールを出力しようとしています(時にはそれさえしないことがあります)。 その際、私たちは分離や整理に必要なルールが何なのかすらわからないのです。

Best practice #1 解釈可能・説明可能なツールを使おう

ここで、もつれた (Entanglement) 問題が出てきます。 要するに、もしモデルのどこかを変更すると、全体のパフォーマンスを変えてしまう恐れがあるということです。 例えば、個人の健康記録について100の特徴量を持つモデルに対して、(例えば、大麻を吸ったことがあるかをヒアリングしてデータ化するといった話です)101個目の特徴量を加えることを考えます。 全てがつながっているのです! まるで、カオスシステムを扱っているようなものです。(皮肉なことに、何人かの数学者はニューラルネットワークを二重振り子や気象システムのようにカオスなアトラクターとして表現しようとしています

一本の紐(調整パラメータ)をチューニングしていくと(実際は何百本もの紐がありもつれています)、全体がつながっているのでうっかりブラインドを完全に消すこともできるわけです (the blinds can go OUT the window too)。 (訳注: go out the windowは「完全に消えてなくなる」という口語表現)

アンサンブルモデルや高次元可視化ツールを用いてこの問題を修正できる可能性について著者らは示していますが、もしアンサンブルモデルの出力が相関していたり、データが 高次元である場合は不十分です。 解釈可能なMLとして提案されるものの多くは少し曖昧です。 それを踏まえた上で、私はFacebookの高次元可視化ツールをおすすめします。 また、解釈可能な機械学習についての最高の資料“Interpretable Machine Learning” by Christoph Molnar (オンラインで利用可能)を読むこともおすすめします。

時には、決定木のような、より説明可能なモデルが、もつれた問題の手助けになるでしょうが、ニューラルネットワークで解決するベストプラクティスがあるので、確定したわけではありません。

Best practice #2 可能であれば、説明可能な種類のモデルを使おう

修正の伝播 (Correction Cascades) (訳注: Hidden Technical Debt in Machine Learning Systems参照)は漠然とした機械学習モデルへ入力の一部、それ自体が漠然とした機械学習モデルである場合に起こります。 エラーのドミノラリーを準備しているようなものです。

例えば、事前に存在しているモデルを新しいドメイン (もしくは多くの人が “startup pivot” と呼ぶもの) に適用する時、このようにモデルを繋げるのは非常に魅力的です。 ランダムフォレストの前に、教師なし次元削減ステップはさんだことがあるかもしれませんが、t-SNEのパラメーターを変化させると残りのモデルのパフォーマンスが急落します。 最悪の場合、全体のシステムのパフォーマンスを下げることなく、元のモデルや次元削減の部分などの “サブな部分” の精度を改善するのは不可能です。 機械学習パイプラインは、正の合算価値を生み出すものからゼロサムになります (これは技術的負債論文の用語ではないですが、載せるチャンスを逃しただけだと感じています)。

XKCDのRandal Munroe提供

(訳注:
Cueball「これを見て。必要な情報全てを集めて処理した完全自動化データパイプラインを作りました。」 Ponytail「行き当たりばったりのスクリプトで構成された巨大な (トランプの家のような) 不安定な構造で、変な入力があった瞬間に崩れませんか?」 Cueball「そう…ではないかもしれない」 Ponytail「私が推測するに、なにか」 Cueball「おっと、壊れた。ちょっと待って、修正します。」
Explain xkcdの書きおこしを参考に翻訳)

これを防ぐためには、greedy unsupervised layer-wise pretraining (or GULP) のvariantがよりよい技術の一つです。 なぜうまくいくかという数学的理由については意見がわかれているものの、基本的にはモデル初期段階やアンサンブルの最初部分を学習し、それらを固定してから、残りのシークエンスを順々に作業します (これまた、技術的負債論文で言及されていませんが、この技術自体は少なくとも2007年から存在しているので、機会を逃しています)。

Best practice #3 常に、順番に下流モデルを再学習しよう

他にも機械学習モデルの不都合な特徴があります。 あなたが認識している以上に、ただの機械学習モデルの域を超えて、その機械学習の出力を頼っている消費者がいるかもしれないということです。 著者らはこれを 宣言していない消費者 (Undeclared Consumers) として言及しています。 ここでの問題は、出力データが非構造だったり正しくフォーマットされていないことではなく、出力に依存しているシステムがどの程度あるかを把握できる人が誰もいないことです。

例えば、kaggleのようなサイトでは、たくさんのカスタムデータセットがあり、そのデータセットの多くは機械学習の出力です。 多くのプロジェクトやスタートアップは、初期の機械学習モデルの構築や学習を、内部のデータセットのかわりにこれらのデータを使うことがあります。 ほぼ予告なしでデータセットが変更される可能性があるにもかかわらずに、です!(しかもこれらに依存したスクリプトやタスクがその変更を検知できることは稀です)

また、データにアクセスするためサインインなどが必要ないAPIの場合、問題がより複雑になります。 アクセスキーやサービス水準合意のように、モデルにアクセスする入り口に障壁を作らない限り、扱いが難しくなります。 モデルの出力をファイルとして保存したら、共有ディレクトリにデータがあるからという理由で、チームのメンバーがその出力データを使うかもしれません。 試験的なコードだとしても、まだ確認できていないモデルの出力にアクセスできる人がいるかどうか、気をつけないといけません。 これはJupyterLabのようなtoolkitにとって大きな問題となる傾向があります (もし私が過去に遡って技術的負債論文に警告を付け加えるなら、JupyterLabについて警告するでしょう)。

基本的に、このような技術的負債の解決は、機械学習エンジニアとセキュリティーエンジニアの協力が必要です。

Best practice #4 アクセス鍵、ディレクトリの権限、サービス水準合意をセットアップしよう

f:id:sh111h:20200622075851p:plain

下流の消費者グラフはこのようにシンプルで包括的であればよいと思います。

Part3 (通常の依存関係の頂上にある) データ依存関係

3つめの章はデータ依存関係の問題について、深く掘り下げます。 ソフトウェアエンジニアリングにおける通常のコードに加え、機械学習システムは、開発者が認識する以上に不安定な、膨大なデータソースにも依存しています。 これは悪いニュースです。

例えば、入力データは、裏で変化するルックアップテーブルや、連続的なデータストリームであったり、所有していないAPIからのデータを用いているかもしれません。 MolNetデータセットのホストが、より正確な数値で更新することを決めたとしたらどうなるかを想像してみてください(どうやって更新するかは無視するとして)。 更新したデータはより正確に現実を反映するかもしれませんが、数えきれないモデルが古いデータで構築されており、先週は確実に動いたノートブックを再実行したとき、多くの製作者が、精度が急降下したことに気づくでしょう。

著者らによる提案のひとつはPhotonのようなデータ依存追跡ツールを使うことです。 (訳注: Photon: Fault-tolerant and Scalable Joining of Continuous Data Streams) 2020年においては、新しいツール DVC (文字通り「Data Version Control」の意味) があり、Photonを多くの点において時代遅れにしています。 このツールはgitと同じように振る舞い、データセット・データベースの変更の追跡を保持するDAGを保存します。 他に2つのバージョン管理のために一緒に使われるツールとして挙げられるのは、 Streamlit (実験とプロトタイプの追跡を保持) とNetflix’s Metaflowです。

どの程度バージョン管理をするかは、メモリ使用量の増加と、学習過程の大きなギャップ防止のトレードオフの関係にあります。 それでも、不十分ないし不適切なバージョン管理は、モデル学習のときに、過剰な生存バイアスが起こり(可能性を無駄にし)ます。

f:id:sh111h:20200622080023p:plain

DVCのページです。ダウンロードしましょう。今すぐ!

Best Practice #5 データのバージョン管理ツールを使おう

データ依存性の怖い話はまだ続きます。 不安定なデータ依存性と比較すれば、十分に活用していないデータは悪くないようにみえますが、そうやってハマるんです! つまり、使っていないデータ、一度使ったもののレガシー扱いになったデータ、他と関連しているため冗長になったデータに注意し続ける必要があるのです。 あなたがデータパイプラインを管理していて、全体のギガバイトが過剰だと気づいた時、それだけで開発コストが発生しています。

相互に関連したデータは特に扱いにくいです。 なぜならばどの変数が関連していて、どれが原因となるか、理解する必要があるからです。 これは生物学のデータにとって大きな問題です。 ANCOVA (訳注: 共分散分析)のようなツールはますます旧式となり、残念ながらANCOVAの仮定には間違いなく適用していないいくつかの事態で使われています。 いくつかのグループはONION (訳注: arXivリンク) やDomain Aware Neural Networksのような代替案を提案しようとしていますが、多くは特に印象的でない標準的な手法を改良しています。 MicrosoftやQuantumBlackのような企業は因果関係のもつれのためのパッケージを開発しました(それぞれ、DoWhyCasualNexです)。 私は特にDeepmindのベイジアン因果推論が気に入っています。

これらの多くは技術的負債論文の書かれた時期にはなく、多くのパッケージは独自の操作性という負債を抱えていますが、 ANCOVAはどんな場合でもうまくいく (one-size-fits-all) 解決策では ない ことを知ってもらうことが重要だと思っています。

Best practice #6 使っていないファイル、膨大な関係する特徴量を削除し、因果関係推論ツールを使おう

ところで、著者らはこれらの修正に対してあまり悲観的ではありませんでした。 クリック予測の中でGoogleが使っているものを例として、彼らはデータ依存性の静的な分析を提案しました。

技術的負債論文が出版されて以降、対処するための選択肢はとても発展しました。 例えば、Snorkelのような、データのスライスを、どの実験で使ったか追跡するツールがあります。 AWSやAzureのようなクラウドサービスは、DevOpsのためのデータ依存性追跡サービスを持っていて、Red Gate SQL dependency tracker のようなツールもあります。 著者らが楽観的だったのは正しかったようです。

Best practice #7 データの依存関係を追跡する、無数にあるDevOpsツールのいくつかを使おう

Part4 イライラさせるほど未定義なフィードバックループ

前のセクションで、希望が少し見えましたが、データ依存性に関する悪いニュースはまだ終わりではないです。 論文の4章では未チェックのフィードバックループが機械学習の開発サイクルに対して、どのような影響をもたらすかに踏み込みます。 半教師あり学習や強化学習のような直接のフィードバックループ、他の機械学習の出力からエンジニアが選択する直接でないループについても言及しています。

技術的負債論文において、イシューとして最低限しか定義されていないもののひとつですが、いまや数えきれないぐらいの組織がフィードバックループ問題に取り組んでいます、OpenAI全体がやっているようなものも含めて (少なくとも、彼らの宣言の「長期的安全性」の章ではそうなっています、「上限つき利益 (Capped Profit)」騒動の前から)。 なにが言いたいかというと、直接または間接的なフィードバックループの研究・調査をするのであれば、この論文よりも はるかに 優れた具体的な選択肢があるということです。

この章では、前のセクションよりも少し絶望的に見える解決策を掲げています。 彼らはバンディッドアルゴリズムを直接のフィードバックループに耐性がある例としてあげていますが、このアルゴリズムはスケールしないだけでなく、スケールしたシステムを構築 しようと するとき、技術的負債が最も蓄積されます。 役に立ちません。

間接的なフィードバックの修正はあまり良くないです。 実際、間接的なフィードバックループのシステムは同じ構造の一部ですらありません。 お互いにメタゲームをしようとする、異なる企業の交易アルゴリズムのようで、代わりにフラッシュ・クラッシュを起こすかもしれません。 もしくは、バイオテックでより関連した例として、様々な実験装置の誤り確率を予測するモデルを考えてみてください。 時がたつにつれ、実際の誤り率は、人がより練習することで下がったり、科学者が装置をより頻繁に使うことで下がっていきますが、この部分を補正するためのモデルのカリブレーション頻度は増えません。 (訳注: カリブレーション (較正)は、この文脈では確率の較正ではなくパラメータ再調整だと思えばよい) 究極的には、これを修正するために大事なのは、高いレベルでの設計上の決定と、モデルのデータの背後にある仮説(特に独立性の仮説)を可能な限り多く確認することです。

これはまた、セキュリティエンジニアリングの多くの原則と実践がとても有効になる領域です (例えば、システムを通るデータのフローを追跡し、悪質な関係者が利用する前にシステムが防ぐ方法を探す)。

f:id:sh111h:20200622080115p:plain

直接的なフィードバックループの一例です。 実際には、このダイアグラムのいくつかのブロックがMLモデルです。 これは間接的な相互作用をうわべだけ扱っているわけではありません。

Best practice #8 モデルの背後にある独立性の仮定を確認しよう (セキュリティーエンジニアの近くで働こう)

今や、特にANCOVAについてコメントした後であれば、あなたはおそらく仮定の検証というテーマに気づいているでしょう。 私は、著者らがこの話題に少なくとも1セクションを使えばよかったのにと思っています。

後編に続きます

元のブログ は1つの記事ですが、少々長いので、前後編に分けさせていただきました。

続きでは「コードのアンチパターン」や「構成の負債」、「実世界での活用」について紹介します。

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:厳密には、これらのトークンオブジェクトの持つ親への参照を無視すれば有向部分木になります