「パーフェクトPython[改訂2版]」の献本をいただきました

はじめに

お久しぶりです。ALBERT辞めてから全然ブログを更新していなかったのですが、ALBERTと共にブログを辞めた訳ではないです。すみません。

今回は6月1日発売の「パーフェクト Python [改訂2版]」という書籍を著者の露木さんから献本をいただきましたので、その内容について書いていきます。

f:id:gat-chin321:20200611213628j:plain

www.amazon.co.jp

こちらの書籍は株式会社技術評論社が出している色々な本が出ているあのパーフェクトシリーズのPythonについての書籍の改訂2版になります。
初心者だけでなく、私のようなノリと勢いでPythonを書いていた人間でもPythonの魅力が理解できるような良書でした。

どんな内容だった?

内容を簡単にまとめると、以下のような分かりやすい構成でした。

1. Pythonの特徴や思想を学ぶ
2. Pythonの言語仕様を網羅的に基礎を学ぶ
3. 最後に様々な実践的な内容も学ぶ

で、とにかく負担にならない程度に文量を抑えつつも網羅的に色々と書いてくれていてまさにパーフェクトPythonでした。

個人的にはPythonの拡張モジュール、ライブラリを配布する方法、初版では取り扱っていなかったtypehintやコルーチンも取り上げていて、丁寧に解説してくれていたのが特に良かったです。正直Python学び始めた時に読めてればなあと少し初版を買わなかったことを後悔しました。

また、多分Python中級者くらいの私レベルでも普段から利用している仕様はもちろんのこと標準ライブラリ一つとってもこんな機能もあったのかと新しい発見が結構ありました。

具体的には以下の内容をこの書籍で初めて知りました…。

  • 関数アノテーションに文字列が使える `def hogehoge(arg: '文字列使えたのかお前')`
  • 名前空間パッケージが使える(異なるディレクトリにあるモジュールを1つのパッケージにまとめられる)
  • typing.TypedDict, typing.NoReturn, typing.Callable, typing.Generic, typing.NewTypeなどのこと(typing系ほぼ知らない)
  • スタブファイルなんて物が…
  • 拡張モジュール (調べてもよくわからなかったので、必要に駆られたら勉強しようと放置していたが、この書籍だとめっちゃ分かりやすかった)
  • sysモジュールの色々な機能
  • ファインダー、ローダー
  • siteモジュールの存在
  • フォーマットの注意点([]で囲まれた内容がそのまま文字列として評価されること)
  • doctestというDocStringに書いた内容でテストできるツール
  • etc...


さらに良いなと思った点は実践的な内容が充実している点です。

TODOリストを作ってみようから始まり、チャットサーバー、Wikiアプリケーション、外部のライブラリを利用したデータ分析、Scrapyを使ったクローリング、画像、音声、blenderを用いた3D、ネットワーク、データストア(KVSやredisやsqlite3に加え、ORマッパーなど)、タスクランナーのInvokeリモートホストに接続するためのFabricを利用した運用、PySNMPを使ったサーバーの状態監視、ドキュメントを残すためのSphinxなどの初心者が開発するにあたって最低限必要なことを全て学ぶことができます。

それぞれの章で出てくる例のコード量が非常に少なく説明も長すぎず少なすぎずとコンパクトにまとまっています。

少なくとも、おかげで私は「非同期IOの挙動は概ね知っているもののそもそもこれって何に使うんだっけ?」と言うところがわかっていなかったのですが、
非同期IOを使ったチャットサーバーの章で具体例を手を動かしながら確認した後に解説を見て、
「なるほどね。awaitしている間に他の処理するものがある場合に非同期になるから待ち時間ある処理をさせてる間に別のことさせたい時とかに使うのね」と分かってるのか分かってないのかは謎ですが、腹落ちはしました。

また、後半の応用例についてはそれまで学んだ基礎の延長で、実際の開発現場で必要になってくる工程(作業?)を体験できるような例題の選定になっているので、
エンジニア経験のない人なら「ほえーこんな感じのことをエンジニアの人たちはやってるのかー」と思いながら読めるはずです。(データ分析の部分は別にエンジニアの人でしている人そんなにいない気はしますが)

まとめ

以上グダグダ書きましたが、この本は

  • プログラミング初心者でPythonを勉強したいと思っている人の助けになる本で
  • データ分析でPython使っているけど、Pythonを使いこなせていない人には読んでもらいたい本で
  • Pythonそこそこ書くから基礎は押さえている人でも知識の振り返りに使える本

です。初心者も中級者も上級者もみんなこの本を読んでPythonライフをより豊かにしていきましょう!

おわりに

人生初の献本ということであまり書評の経験はないのですが、もしこの投稿を見て面白そうと思っていただけたなら嬉しいです。

実は6月1日に家に届いてちょっとずつ読んでいて数日で出せればよかったのですが、悲しいことに時間がなくてあまり読めてませんでしたので、今更のブログ投稿となってしまいましたことは謝罪させてください。本当にすみませんでした。

最後にこのような素晴らしい本を書いてくださった著者の皆様、献本を送付してくれた技術評論社に感謝を述べてこの書評を終わりとさせてください。

株式会社ALBERTを退職しました

はじめに

2019年6月30日で2年と1ヶ月の間にお世話になった株式会社ALBERTを退職しました。
退職エントリを書く気はあまりなかったのですが、copypaste (@copypaste_ds) | Twitterに要求されたので、退職エントリを書くことにしました。
6月14日が最終出社日で、有給消化を経て7月1日より都内のスタートアップ勤務しています。ちなみに今日が初出社でした。

なぜALBERTに入社したのか

前職では、セキュリティエンジニアという肩書きで新卒で入社して、海外ベンダーのセキュリティ製品についてのメールや電話でのサポート対応や技術営業や脆弱性検査などをやっていました。
大学では研究でマルウェアAPIコールのログの分析をしていたこともあり、会社に入っても続きがやりたいなと思ってたら、運よく社内複業的な感じで今でも交流のある某お方たちとマルウェア検知の研究開発やらせてもらったり(だが、メンバー全員多忙のため途中で解散)、研究所の方々に貴重なお時間をいただいて研究をさせていただいたり、マルウェア解析をやらせてもらったり、上司も同僚も良い人ばかりでしたし、学ぶところも多く入社してよかったと思っています。

ただ、副業としてデータ分析をやるのもおもしろかったのですが、データ分析を主な業務としてやりたくなってきたのと、セキュリティ以外のデータも分析したいなと思ったので、転職先を探していたところで運よく内定をもらえたのがALBERTだったという感じです。本当に運がよかっただけで、今の選考基準だと即落ちしてる自信があります。

まあもっと色々なことを考えてはいましたし、転職活動中に研究所への異動が決まったので転職するかどうか本当に悩みましたが、どちらを選んでいても楽しかったと思うので、この選択をしたことに関してはそこまで大きな後悔はないです。ただ、自分のキャリアのためとはいえ、研究所に異動して即退職したのは多大な迷惑をかけてしまったと言う申し訳なさは感じています。

何をしたのか

開発と分析の両方を社内副業程度でしか経験のない状態だったので、入社してからがむしゃらにキャッチアップしながら、プロダクト作ったりしてましたが、途中から受託分析のプロジェクトをやることになり、分析からシステムのプロトタイプ開発までをPMと作業者1人のプロジェクトを中心に、2人〜6人+αくらい(人数はっきり覚えていない)でやってました。

ほどほどにフロントにも立ちつつ、エンジニアリングもデータ分析も何でもやります(やれるとは言っていない)的な感じで、クラウド使って分析周りのインフラを構築したり、前処理やったり、データ分析やったりを手広くやってました。ちょっとは貢献できたとは思っていますが、クソ雑魚だったので、足を引っ張ることもあったり、自分が満足できる成果は出せなかったのが悔やまれます。
唯一会社に貢献できた自信があるのは頑なにエラー文を読まない copypaste (@copypaste_ds) | Twitter に「デバッグの時はエラー文をちゃんと読め」と指導してエラー文を読むように改善させたことくらいです。
おかげでDBにデータを格納するだけで二週間使っていた彼はKaggle Expertになるまで成長しました。本当は勝手に成長しました。

ALBERTはどうだったのか

人は?

様々なバックグラウンドを持つ個性豊かなメンバーがいてこの2年1ヶ月間は本当に楽しく過ごせました。
現場は理系大学の研究室のような感じの雰囲気の人が多く、20代ぐらい(異常値は存在する)の社員で時々休日に集まって鍋パや流しそうめんやクソ映画鑑賞をしたり、それ以外の人ともランチや飲み会に出かけたりと仲も良かったと少なくとも私は思っています。どうでも良いですが、流しそうめんについては僕がその時何となく持ち込んだカリカリ梅が流されたことも記憶に残っています。
全体的に仲が良いけれど、適度な距離感を保っている感じだったのと、よくTwitterとかで話題になっていたしつこく遊びやお酒に誘ってくるような強引な上司はいない点もよかったです。
きつい物言いをする人もいなかったですし、怒鳴り声も聞こえないのでそういう人はそもそもいないのかもしれません。

技術は?

エンジニアやアナリストの両方に非常に優秀な人が社内にいるのに加え、勤務時間中にこんなに勉強会あっても良いのかというレベルで勉強会がいくつも開かれているだけあって、技術的な向上心も高い人が多く、良い意味で刺激になりました。ちなみに私は論文読み会やデータ分析の勉強会やエンジニアの勉強会に余力があるときに参加したり発表したりしてました。この2年1ヶ月の間は色々な企業が持っている様々な種類のデータに触れられましたし、自分の成長が日々感じられて楽しかったです。
あとプロジェクトで利用する技術に縛りはないので、結果さえ出せるのであれば、自由に自分の使いたい技術を使うことも可能なので、業務を通じた自己研鑽はしやすいかと思います。

仕事は?

基本的には受託分析ばかりやっていたので、やることは他の会社とはそこまで大きく変わらないと思います。多少その時の案件次第なところももちろんありますが、強く望めばエンジニアリングもデータ分析もやらせてもらいやすいですし、新卒だろうが何だろうが関係なくフラットに議論できるような雰囲気でしたので、個人的にはとても働きやすかったです。全くプロジェクトに関係ない人に質問しに行っても嫌な顔一つしないどころか一緒に考えて答えてくれますし、そういうストレスもありませんでした。また、会社にとってプラスになる提案さえすれば、色々自由にやれますし、その点も非常に働きやすい会社だったと思います。
常駐の方もいますが、可能なら常駐はしない方向である点も他の受託分析の会社と比較すると良いのかもしれません。

計算機のリソースは?

CPUを使う場合は手元のデスクトップPCかノートPCで分析するか、性能が足りない場合は何らかのクラウドのマシンを使っていることが多かったです。RedshiftとかSQL Dataware houseとかBigQueryとかも案件によっては使ったりしました。
GPUを使う場合はオンプレでそこそこの共有のGPUマシンを2台ありますが、ほとんどの案件ではクラウドGPUを使っていた印象です。
なので、社内のリソースはほとんどないようなものでしたが、それなりに良いPCで作業できるのでブラック企業でよくある中古の安物PCで作業するような苦しみはないです。

福利厚生などは?

前職でも一部似たようなものでしたが、他の会社と比べて以下が良いと思っています。(福利厚生なのか的な内容もありますが…)

  • 休日は比較的多いし、有給休暇もかなり取りやすい
  • 本も欲しいものがあれば本の売っているAmazonのURLと名前を書いて、「業務で必要なため」とか雑な申請理由をつけて申請をすれば、会社の図書館に仕入れてくれる
  • 私服OK・イヤホンOK・休憩スペースで寝転がりながら仕事していても特に怒られない(OKかどうかは謎)
  • コアタイム10時〜16時のフレックス勤務なので、9時出社よりは楽です(人によっては10時はきついですし、途中から私も堕落し始めてきつかったですが)
  • 残業時間については多い時にはメンバーの残業時間が40時間くらい(多い人はもうちょっと行ってたかも?)のプロジェクトもありましたが、タイミングによっては残業時間が0になったりするので、残業は全体的に多くはないはずです。(どんどん改善もされていってます)
  • 近隣住宅手当(会社から3km圏内であれば3万円補助)がある
  • 毎月飲み会の補助が3000円支給される

あとはめちゃくちゃ給料が高いかと言われると微妙ですが、新卒は学部であれば450万、修士であれば500万からスタートとややお高めですし、新人研修の内容が本当に妬ましいと思っていたくらい内容が良いので、新卒の方にもオススメできます。

【2020年卒】新卒採用【第4クール】 | 株式会社ALBERT

じゃあなんでやめるの?

会社に不満があったとか「会社でやれることをやりつくしてしまった」とか「ニートになるため」とかそう言うかっこいい理由でもなく、詳細は伏せますが、一身上の都合のためです。もっとALBERTで働きたい気持ちもありましたが、やむなしと言う感じです。理由が気になる方は直接会ったときにでも聞いてください。

次は何をするのか

具体的に何するかもあえて書きませんが、自社製品の研究開発をやる予定です。
試用期間を生き延びて成果を出せたら、何かしらの外部の勉強会に出て発表するなり、このブログで何をしているのかをアウトプットして行こうかなというお気持ちです。

おわりに

ALBERTでは色々な方々に助けられましたし、お世話になりました。本当に皆さんには頭が上がりませんし、感謝しかありません。
経験の浅い自分を受け入れていただいた上、仕事も環境も充実しているにも関わらず、タイミングも悪く転職することに決めて迷惑をかけてしまったことを申し訳なく思いつつも、快く送り出してくれたことに感謝します。
貯金なしの庶民に手を出せる金額じゃないので株を買うのは諦めますが、今後はALBERTの外からALBERTを応援していこうかと思っています。
ご飯・お酒などのお誘いを頂けるのであれば、お気軽にご連絡ください。
ALBERTではデータ分析する人だけでなく、エンジニアもPMも人事も募集していて、今ならベトナムで知らない人にホイホイ付いて行ってカツアゲされたなど数々の伝説を持つcopypaste (@copypaste_ds) | Twitterさんのような愉快な仲間たちとお仕事ができるので、我こそはという方は是非応募してください!

hrmos.co
hrmos.co
hrmos.co
hrmos.co

会社のことをすでに結構忘れてしまっているので、ALBERTの方はこのブログに書いている内容と実際の内容が違ったりしたら修正するので教えてください。

「ガラスに映った世界なんていらない」とニューラルネットに祈るには

はじめに

毎月1記事は頑張って書こうと決意表明するも、結局休日にブログを全然書かずに家でぼーっと無になりながらアニメを鑑賞してるだけで何もしてませんでした。

戒めのために有言不実行Tシャツでも作って着ようかなと思いましたが、それもきっと作らないので、本当に厄介ですね。

さて、話はそれましたが、今回取り上げるタスクはガラスの反射除去で、論文としては2018年1月31日にarxivに投稿された「Single Image Reflection Removal Using Deep Encoder-Decoder Network」を取りあげます。結構古いですが、この時点でのstate-of-the-artの2つのモデルを叩きのめした手法とのことで、実装が比較的簡単そうだったので、これにしました。ただ、実験し始めてから気づいたのですが、どこの学会にも通してなさそうなので、微妙だったかもしれません。

このタスクを選んだモチベーションは、結構前に水族館に行った際に以下のような感じで、ガラスに反射した無粋な誘導灯が写り込んでしまったせいで台無しになってしまっていたのをDeep Learningってやつでなんとかできませんかと思ったからです。

f:id:gat-chin321:20190511160943j:plain
水族館でとったペンギンの画像

概要

この論文での学習までの流れは以下の通りです。

1. 写したい風景の画像(透過画像)とガラスに反射させたい画像(反射画像)の2枚の画像を使って、ガラスに反射した物体の映る合成画像(反射合成画像)を擬似的に作成する(データの準備)
2. 反射合成画像を入力にして、反射合成画像から復元した反射画像を減算して合成に利用した透過画像を復元した時のlossでネットワークを学習させる

それではデータの準備方法から見ていきましょう。

データの準備(前処理?)

合成画像の作り方

最初にガラスの反射除去をニューラルネットに学習させるためのガラスの反射なし画像と反射あり画像のデータセットをどうやって用意するのかが多分気になると思います。

以下のパターンが方法としては考えられます。

  • カメラの位置を固定して対象の物体の前にガラスを設置したケースとしなかったケースで撮影する
  • 2枚の画像を合成して、擬似的にガラスに物体が写り込んだ画像を再現する

主流なのは後者で、理由としては前者だと映り込む物体の種類を揃えるのが大変なので圧倒的に少なくなってしまう点と日光のあたり具合による変化などのノイズが混ざる点と例えば動くもの(動物とか風に揺れる木とか)を対象にする方法ではそもそも写したい物体が移動してしまう点とそもそもデータセットの作成コストが高い点など色々な問題があるからです。

この記事で紹介する論文では、2枚の画像を合成した画像を生成することでガラスの反射なし画像と反射あり画像のデータセットを作成しています。

この後者の合成の数式で一般的に利用されている(とこの論文で主張している)のは以下になります。

 {
\displaystyle
\begin{eqnarray}
I = \alpha T + \beta R + n
\end{eqnarray}
}

 I: 入力用の合成画像
 \alpha: 写したい風景の画像(透過画像) Tの透過率
 T: 写したい風景の画像(透過画像)
 \beta: 反射させたい画像 Rの反射率
 R: 反射させたい画像(反射画像)
 n: ノイズ項

上記の式を変形すると、合成画像 Iとその反射のない画像 Tとの間の差は以下のようにノイズ項 nの一部として見ることができます。

 {
\displaystyle
\begin{eqnarray}
\beta R + n = I - \alpha T
\end{eqnarray}
}

そして、このように合成画像にノイズを加える方法だと、自然な画像と同様の特性を持つ(よくわかりませんが、普通に写真撮ったときに生じるノイズと変わらないということ?)なので、反射画像 Rからノイズ nを正確に区別することが困難になってしまうらしいです。

この論文でも、一般的な合成の式と同様に合成画像 Iを2つの無反射自然画像 T Rの線形結合として解釈します。

ただ、合成画像の合成を正確にシミュレートするために、反射におけるぼけ効果を考慮します。

どのように考慮するかというと、ほとんどの実際の写真では普通はガラスの背後にあるものが関心のある物体なので、カメラの焦点面は反射された物体ではなくガラスの背後にある物体上にあるはずです。

その結果、デフォーカス効果のために反射がぼやけていることがよくあります。合成画像においてこの効果をシミュレートするために、それを合成画像に重ね合わせる前に、反射画像 Rをランダム分散のガウシアンカーネル Gでぼかします。

 {
\displaystyle
\begin{eqnarray}
I = \alpha T + \beta R * G
\end{eqnarray}
}

 G: ランダムな分散を利用するガウシアンカーネル

また、この論文では以下の図のように、反射した対象が2倍に重なって見えるdouble reflection効果という現象も考慮しています。

f:id:gat-chin321:20190511184334p:plain
論文より引用したdouble reflection効果の例(例なのに合成画像だが…)

このdouble reflection効果は以下のような形で反射する物体がガラスの表面とガラスから光が出る面の2箇所で反射した際に発生する現象らしいです。

f:id:gat-chin321:20190511183953p:plain
double reflection効果の図(論文より引用)

これはどのように考慮するのかというと以下の式で表現するそうですが、振幅 1-\sqrt{\alpha}および \sqrt{\alpha}の2つのパルスを有するランダムカーネル Kで反射画像Rを畳み込むと書いていましたが、このランダムカーネル Kが全く理解できませんでした。

 {
\displaystyle
\begin{eqnarray}
I = \alpha T + \beta R * G * K
\end{eqnarray}
}

何をするのか理解できてないものについては実装できないという点とそもそも目的として掲げた水族館で撮った画像はdouble reflection効果が発生していないので、別に考慮する必要ないという点から今回は実装しません。

JPEGを使って学習させるための工夫

画像合成を行う場合、JPEG圧縮画像に直接適用することはできません。

その理由は人間の非線形光感度を利用するためには、JPEG画像はカメラに保存される前にガンマ補正されているので、JPEG画像の画素値は画像センサによって捕捉された光強度に対して線形ではありません。

その結果、2つのガンマ補正された画像の直接合計は式(1)によって要求されるような光の重ね合わせの物理学に一致せず、非現実的な反射画像を発生させてしまいます。

この問題を解決するには、生の画像のみを使用するか、収集したJPEG画像に以下の逆ガンマ補正を適用します。

 {
\displaystyle
\begin{eqnarray}
X = (X')^{\frac{1}{r}}
\end{eqnarray}
}

 X': ガンマ補正画像
 X: ガンマ補正された画像
 r: ガンマ補正の係数

各カラーチャンネルのガンマ補正係数は、各JPEG画像に添付されているEXIFのセグメントをみると利用できることが多いようです。

Deep Encoder-Decoder Network

ネットワークのアーキテクチャ

大まかなアーキテクチャとしては論文にある以下の図がわかりやすいかと思います。見た目的にはU-Netっぽい感じの構造をしています。

f:id:gat-chin321:20190511193327p:plain
Deep Encoder-Decoder Networkのアーキテクチャ(論文より引用)

この論文で提案されているネットワークは以下のように3つの段階に分かれています。

  • Feature extraction(特徴抽出)
    • 透過を復元するための層と反射を復元するための層の両方で利用するために、6つのConvolution層を使って特徴量を抽出します。
  • Reflection recovery and removal(反射の復元と除去)
    • この段階における最初の6個のConvolution層とその先の6個のDeconvolution層は反射を学習し復元するために追加しています。
    • 反射層の詳細を良い感じに保存する目的で、2つ目のskip connectionによって前の畳み込み層から学んだ特徴を継承させています。
    • 要素ごとの減算とそれに続くReLU( max(0, conv_6 - deconv_6))によって、復元された反射が deconv_7(7番目のDeconvolution層)の前に除去される仕組みになっています。
  • Transmission layer restoration(透過レイヤーの復元)
    • 反射は推定反射を単純に差し引くことによって、前の段階の後で完全には除去されない場合があります。
    • したがって、この段階は反射減算画像から視覚的に好ましい透過画像を復元するために、ターゲットシーンの特徴から透過層を6つのDeconvolution層を用います。

loss関数の設計

ノイズ除去、ピンボケ除去および超解像などの多くのニューラルネットワークに基づく画像復元技術では、ネットワークは一般に損失関数として出力とground truthとの間の平均二乗誤差(MSE)を用いて最適化することが多いため、この論文では平均二乗誤差を採用している。

 {
\displaystyle
\begin{eqnarray}
L_{l2} = || F(I) - \alpha T ||_{2}^{2}
\end{eqnarray}
}

 \alpha: 写したい風景の画像(透過画像) Tの透過率
 T: 写したい風景の画像(透過画像)
 F: 特徴を抽出するための関数(特徴抽出器)

ただし、L2ノルムのloss関数のみを使用して最適化されたモデルでは、高周波成分を保存できないことがよくあるそうです。

反射除去の場合、反射層と透過層はどちらも異なる特性を持つ自然画像となります。

そのため、最良の復元結果を得るには、ネットワークは透過層の特性を上手く学習する必要があります。

VGG lossでは事前に訓練された19層のVGGネットワ​​ークの特徴空間上における、復元された透過層表現 T'=F(I)と実際の透過画像 Tの差のL2ノルムとして以下のように計算します。この論文のモデルでは最初の5つの畳み込み層の特徴マップとして使用します(そのため、M=5)。

 {
\displaystyle
\begin{eqnarray}
L_{VGG} = \frac{1}{W_{i}H_{i}} \sum^{M}_{i=1}|| \phi_{i}(\alpha T) - \phi_{i}(F(I)) ||_{2}^{2}
\end{eqnarray}
}

 W_{i}: 特徴マップのi番目のw方向の次元数
 H_{i}: 特徴マップのi番目のh方向の次元数
 M: 使用されるConvolution層の数 (この論文のモデルではM=5)
 \phi_{i}: 訓練済みVGG19のネットワークの中の活性化関数の後のConvolution層


学習の最終的な損失は以下のように計算されます。

 {
\displaystyle
\begin{eqnarray}
L = L_{l2} + \lambda L_{VGG}
\end{eqnarray}
}
 \lambda: L2 lossとVGG lossのバランスをとるためのハイパーパラメータ

ネットワークの訓練方法

 {
\displaystyle
\begin{eqnarray}
\theta_{F} = argmin_{\theta_F} \frac{1}{N} \sum_{n=1}^{N} L(F(I_n), \alpha T_n)
\end{eqnarray}
}

 N: 訓練集合のサイズ
 I_n: n枚目の反射を合成した画像
 T_n: n枚目の写したい風景の画像(透過画像)

学習用のハイパーパラメータ

  • すべての畳み込み層および逆畳み込み層に対して64個のフィルタを設定。
  • カーネルサイズ: 5×5(ネットワークに画像の意味的文脈を学習させるため)
  • VGG lossの重みは \lambda = 0.001
  • 最適化アルゴリズム: Adam
    • 学習率:  10^4
    •  beta_1 = 0.9
  • 合成画像生成時の工夫
    • 透過率 \alphaの値域は0.75~0.8のランダム
    • Gaussian blur kernelの分散は1から5のランダム
    • 合成画像を生成する前に透過層は128×128にresizeする。
    • 合成画像を生成する前に反射された物体は通常ガラスから遠く離れていて、反射シーン内のより大きな物体は反射合成画像においては比較的小さく見える状況を再現するため、反射層は大きな反射画像からランダムに切り取ってから128×128にサイズ変更する。
    • ただし、入力画像に大きな視差の二重反射が含まれている場合のカーネルサイズは最初の畳み込み層(conv1とconv2)と最後の畳み込み層(deconv11とdecconv12)にはより大きい9×9のカーネルが良いらしい。
  • バッチサイズ: 64
  • epoch数: 150 epoch

実装

実装はこちらに置いてます。

https://github.com/pesuchin/deep-encoder-decoder-network-reflection-removal

できるだけ論文の内容を再現しましたが、そのままだとlossが安定しないどころか大きくなりすぎて、nanやinfになったりしたので、batchnormやreluを追加したところうまくいきました。

jpeg画像に対する工夫についてはそもそもpngを混ぜていないので、まあ良いかなと実装していません。
VGG lossについては使わなくてもある程度精度出るっぽかったのと実装が面倒臭かったので使っていないです。

実験結果

学習に利用したデータセットは以下のうち、airport_insideを透過画像、artstudioを反射画像に使いました。(本当はもっと色々使うべきではある)

Indoor Scene Recognition, CVPR 09

一旦学習データに対してであれば、以下のような出力を出すようにはなりました。(左から入力、予測結果、教師)

f:id:gat-chin321:20190526101727p:plainf:id:gat-chin321:20190526101730p:plainf:id:gat-chin321:20190526101734p:plainf:id:gat-chin321:20190526101738p:plain
学習データでの出力結果

実際に試したかった冒頭の画像のうち、反射している部分だけ切り出して試してみたところ以下のような感じになりました。

f:id:gat-chin321:20190526105554p:plain
冒頭の消したかった反射の画像部分

余計にくっきり出てしまいましたね。Deep Learning先生がペンギンとか誘導灯を見るのは初めてだからだろうか…。

(追記)

lossにReflection recovery and removalのところで反射を復元するタスクも同時に解かせるようにしたところ先頭のペンギンの例でも以下の通り、比較的上手くいきました。ただ、画像の色がやや変化してしまいました。

f:id:gat-chin321:20190526225831p:plain
lossに反射を復元させるタスクを追加した場合の結果(左から入力、predict、教師、反射画像の教師、反射画像のpredict)

反射を復元させるタスクの具体的な式としては、以下の感じになります。

 {
\displaystyle
\begin{eqnarray}
L_{l2 reflection} = || Rec(F(I)) - \beta R ||_{2}^{2}
\end{eqnarray}
}

 \beta: 反射画像 Rの反射率
 R: 反射画像
 F: 特徴を抽出するための関数(特徴抽出器)
 Rec: 抽出した特徴から反射画像の復元

これを使ってlossを以下のようにして学習させました。

 {
\displaystyle
\begin{eqnarray}
L = L_{l2} + 10 * L_{l2 reflection}
\end{eqnarray}
}

10倍している理由は反射率 \betaをかけた後の反射画像を復元しようとしているので、値は基本的に小さいです。そのため、ニューラルネットとしては黒(0)にしておけばlossの値は低く抑えられるので、真っ黒(0)を出力するように学習をサボってしまうため、とりあえず10倍してサボりを容認しないようにしてみました。(スパルタ教育だ……)

学習で使った画像とかだとこんな感じになりました。以下の反射画像の教師、反射画像のpredictについてはそのままだと薄くて見にくいため、5倍してプロットしてます。

f:id:gat-chin321:20190526231357p:plainf:id:gat-chin321:20190526231400p:plain
lossを変更した後の学習中の出力(左から入力、predict、教師、反射画像の教師、反射画像のpredict)

終わりに

余談ですが、カメラのハードウェアを開発してる友人にハードウェア的に反射除去する方法があるか聞いたら「偏光フィルタでできるよ」と言われましてやる意味がない気もしましたが、偏光フィルタを忘れて撮影した後でも取り除けたり、偏光フィルタがないような貧乏人でも使えるという優位性を主張していきたい。
Twitterで偏光フィルタで検索かけると偏光フィルタ使っても反射を防げないケースも見られたので、そういうケースでも使えるんじゃないだろうか。

まあ、失敗したので、どうでもいいですね。やはりブログネタの成功確率が低い…。

左を入れると右が出るDeep3Dで深度推定しちゃおう

はじめに

どうも、そこまでお久しぶりではないはずです。
今回は『Deep3D: Fully Automatic 2D-to-3D Video Conversion with Deep Convolutional Neural Networks』という論文を読んで、勉強がてらChainerで感謝の再実装したので、ブログにまとめていきます。年末年始の10連休のうちの3, 4日での成果になります。
最終的には『Single View Stereo Matching』を実装したいのですが、今回はその一部のDeep3Dを実装しました。

概要

ざっくり言ってしまえば、2D画像から3D画像を生成することを目的とした論文となります。(2016年の論文なのでそんなに新しいものではないです)

より具体的にはこの論文では左側の入力2D画像を用いて、ちょっとカメラ位置を横にずらした右側の入力2D画像を生成し、その二つを合成することで3D画像を作成しています。

「それなら普通にカメラ2個用意しちゃった方が早くない?」と思った方もいらっしゃるかとは思いますが、論文によれば、「コストの問題」や「強制された遠近法(実際には遠くにある大きな物体を、近くにある小さな物体のように見せかけたり、その逆をするトリック)のような特殊な撮影ができない」という点でカメラ2個を使うのは難しいそうです。

前者については、映画用のカメラともなるとかなり高額なものになるので、難しいのは想像しやすいかとは思います。

強制された遠近法については「forced perspective」でGoogle画像検索してみると色々と画像が出てくるので、それをみるとイメージが湧くかと思いますが、
後者が難しい理由はちょっと私にはよく分かりませんでした。

映画のワンシーン(?)で実際にDeep3Dを使って3D化した際のやってみた結果が公開されていて、これをみるとなんとなくどういう結果が出るのかわかるかと思います。

Deep3D_TF/frodo.gif at master · JustinTTL/Deep3D_TF · GitHub

論文曰く、応用先としては3D映画の作成やVR市場での利用を想定しているようです。

Deep3Dのアーキテクチャf:id:gat-chin321:20190109012618p:plain

Deep3Dの全体のアーキテクチャとしては以下の図の通りになっています。

f:id:gat-chin321:20190109012618p:plain
全体のアーキテクチャ(Deep3Dの原著論文より参照)

Deep3Dは雑に言ってしまうと以下の流れで推論を行います。

1. VGG16に左画像を通して、各Pooling層の出力をDeconvolutionしたものを取り出して、要素ごとに足し合わせたものをさらにDeconvolutionして特徴として利用する。
2. Selection LayerというLayerに放り込むと右画像が出力される。

それでは以降で、それぞれについて詳しく見て行きましょう。

VGG16

今回利用するのはニューラルネットをやっている人ならだいたい知っているであろうVGG16です。

論文: https://arxiv.org/pdf/1409.1556.pdf

詳細については省きますが、今回のアーキテクチャでは以下の部分に相当します。

f:id:gat-chin321:20190110224502p:plain
VGG16の部分の図

このVGG16はDeep3Dの論文では特徴抽出に利用します。

VGG16部分は具体的には以下の流れで処理して行きます。

1. VGG16のそれぞれのPooling層の出力をDeconvolution層に通します。(この時の最終層のfully connected layerはDeconvolutionを通した後に画像サイズ×5の手順で出す確率的Disparity Mapの枚数)
2. 1で出てきたものを足し合わせます。
3. もう一度Deconvolution層を通します。
4. そのあとにConvolution層を通してからSoftmaxを通して確率的Disparity Mapを作成します。

以上が以下の図の部分で行う処理になります。

f:id:gat-chin321:20190110230024p:plain
Deconvolutionの部分の図

初期化(Bilinear Interpolation by Deconvolution)

Bilinear Interpolationと等しくなるようにDeconvolutional layerを初期化すると学習が容易になるということが経験的にわかったらしいため、これにより初期化を行なっています。
初期化の式は下記の通りです。

 C = \frac{2S-1-(S mod 2)}{2S}

 w_{ij} = (1- |\frac{i}{S-C}|) (1- |\frac{j}{S-C}|)

 w_{ij}: i行目j列目のカーネルの重み

 S: スケール(定数)

正直なところ、なぜこの初期化が上手くいくのかは良くわからない。

Reconstruction with Selection Layer

この部分がDeep3Dの肝となるアーキテクチャっぽいです。
まずは一般的な2D to 3Dの変換の目的変数をいかに解いていたかをみていきましょう。

一般的な2D to 3Dの変換のタスク設計について

Disparity(図では D)を以下の式により求めていたようです。

 D = \frac{B(Z-f)}{Z}

 B: 2つのカメラ間の距離
 Z: Depth Map
 D: Disparity Map
 カメラから焦点面までの直線距離

この式はどこから出てきたのかは以下の図を使って解説していきます。

f:id:gat-chin321:20190203184149p:plain
一般的な2Dto3D変換のタスク設計の図解

この式は大抵の方が中学生のころ、やる意味もわからずにやらされた記憶のあるであろう相似の関係を利用して導出しています。

まず左側の「物体が焦点面よりも奥にある場合」についてですが、三角形abeと三角形acdを考えます。

この時、辺beと辺cdは平行なので、角abe=角acdかつ角aeb=角adcであることから、2つの角が等しくなり、これは相似条件を満たします。

そのため、三角形abeと三角形acdは相似なので、3組の辺の比が等しい関係と1組の辺の比と高さの比が等しくなる関係を利用すると、以下が言える。

 D: B = Z-f: f

これを Dについて解くと、

 D = \frac{B(Z-f)}{f}

となる。

次に右側の「物体が焦点面よりも手前にある場合」についても同様に辺beと辺cdは平行であることを利用すれば、相似となり、同様の式が出てきます。

ただ、Dの値はZ-fが負の値となるため、Dは負の方向に移動する点には注意が必要です。

この式を利用して色々やっていたのが従来の手法とこの論文では記載されていました。(と言ってもこの論文は2016年のものなのでそこそこ古いが)

Deep3Dでの変換のタスク設計について

では、ここでは実際にどういうタスク設計にして解いたかを解説していきます。

この論文では、右側のカメラの画像は左側のカメラの画像を水平方向にずらしたものであることを利用しています。

この右側のカメラの画像と左側のカメラの画像との関係は以下の式で表現できます。

 O_{i, j+D_{i, j}} = I_{i, j}

 O_{i, j}: 右側のカメラ画像の位置(i, j)のピクセル
 I_{i, j}: 左側のカメラ画像の位置(i, j)のピクセル
 D_{i, j}: 位置(i, j)のDisparity(ずれ量)

ただこの式では、 Dに関して微分可能でないため、ニューラルネットでは学習できません。

そこで、それぞれのピクセルごとにDisparityの値が dである確率の値 D^{d}_{i, j}を推定することにしました。

また、左側のカメラ画像を dの値だけ横にスライドさせたものを I^d_{i, j}とすると、

 I^d_{i, j} = I_{i, j-d}

と表現できます。

これらを用いると、右側のカメラ画像 O_{i, j} I^d_{i, j}とその確率マップである D^d_{i,j}をかけてそれぞれの dを加算したものとして以下のように表現できるようになります。

 O_{i, j} = \sum_{d} I^d_{i, j} D^d_{i, j}

この式では、 D^d_{i, j}に関して微分可能な式となり、ニューラルネットでも学習できるようになりました。

おそらくこの言葉だけだと何を言っているのかわからないかと思いますので、論文に記載されている図を利用して簡単に解説してみます。

f:id:gat-chin321:20190203193717p:plain
selection layer図解

まず、Disparityの値が-16~16までの確率マップ(Disparity Map)を33個作成します。

このDisparity Mapがやっていることは-16に注目した場合、-16ピクセルずらした画像とDisparityの値が-16である確率のピクセルごとの積をとると、Disparityの確率値が高いピクセルだけが残ります。

これを-15, -14…16と同じように33個全てに対して行なった後のそれぞれの結果に対して、ピクセルごとの和をとると右側画像になるように学習させています。

loss関数

Loss関数はL1ノルムとして、以下のlossにしています。

 L = |O - Y|
 O: 右側カメラ画像の予測データ
 Y: 右側カメラ画像の正解データ(実際の右側カメラ画像)

以下の論文で、ピクセル単位の予測タスクではL2 lossよりもL1 lossが優れていると言われていたのが、L1 lossにした理由らしいです。
[1511.05440] Deep multi-scale video prediction beyond mean square error

上記の論文を読んだわけではないのですが、今回のタスクだと画面の端っこ周りの予測が困難なので、その部分が異常値となって、L2 lossにすると不必要にlossが高くなってしまう気もするので、このlossの選択に違和感は感じなかったです。

学習方法やハイパーパラメータ

  • Batchsize: 64
  • 学習回数: 100,000 iteration
  • 学習率: 初期値0.002で20000 iterationごとに1/10
  • Weight Decay: なし
  • Dropout: 0.5でVGG16のfully connected layerの後
  • 左側の画像は432×180ピクセルにresizeして、サイズ384×160ピクセルにrandom crop

実装上の注意点(高解像度化)

元々この論文の提案手法の応用先は映画です。

当時の映画は少なくとも1920ピクセル×1080ピクセルの解像度の画像で学習しなければなりませんが、1920ピクセル×1080ピクセルのままやろうとするとかなり計算時間がかかり、GPUのメモリが厳しい感じになってしまいます。
論文では計算時間とメモリを節約するためにアスペクト比を保った432×180(と書いているけど多分実際は384×160)ピクセルに減らしているが、そのままだと映画への応用では許容できない解像度になっています。

この問題を解決するために、この論文では予測Disparity Mapを拡大して、元の高解像度の左画像とかけて足して、元の高解像度の右画像を生成するようにしています。
こうすると、単純に予測結果を4倍に単純に拡大した時の予測よりも良い画質を保ってくれるらしいです。

実装

以下が私が実装したコードになります。(もしかしたら実験の際に動かなくて修正してたりするかもしれないので、動かなかったらすみません)

GitHub - pesuchin/Deep3D-chainer

環境はColaboratoryで頑張りました。もう二度とやりたくないのでGPU欲しい。
論文では3D映画を利用したようですが、今回私が利用したデータセットはKITTIになります。フレームワークはChainerを使いました。
今回自分で実験したのはresizeせずに直接random crop使ってるので、本家の実装に忠実ではないです。(実験回し終わってから気づきました)。
あと前述の高解像度にも対応していないです。
100,000 iterationは回してられないので、lossがサチってそうなタイミングで止めています。

Disparityの範囲は-16〜16ピクセルと0〜64ピクセルで実験してみました。

0~64ピクセルにして実験してみた理由は『Single View Stereo Matching』を参考にしただけです。

実験結果

以下はDisparityを-16ピクセル〜16ピクセルまでを予測するようにした場合の結果となります。

f:id:gat-chin321:20190203193320p:plain
-16ピクセル〜16ピクセルのDisparityでの結果(左からDisparity Mapのargmax, 左側のカメラ画像, 右側のカメラ画像の予測結果, 実際の右側のカメラ画像)

f:id:gat-chin321:20190203193345p:plain
-16ピクセル〜16ピクセルのDisparityでの結果2(左からDisparity Mapのargmax, 左側のカメラ画像, 右側のカメラ画像の予測結果, 実際の右側のカメラ画像)

Disparity Mapのargmaxの図を見てみると比較的遠いピクセルのDisparityは上手く推定できているように見えますが、-16ピクセル〜16ピクセルのDisparityしか取れないので、至近距離の物体のDisparityが全く取れていない傾向があります。

論文で記載があった3D映画のデータでは問題なかったのかもしれませんが、KITTIでは至近距離のピクセルのDisparityが大きいので結構厳しいようです。

0ピクセル〜64ピクセルのDisparityをとるようにすると、それっぽいDisparity Mapのargmaxが取れているように見えます。

f:id:gat-chin321:20190203195725p:plain
0ピクセル〜64ピクセルのDisparityでの結果(左からDisparity Mapのargmax, 左側のカメラ画像, 右側のカメラ画像の予測結果, 実際の右側のカメラ画像)

f:id:gat-chin321:20190203195807p:plain
0ピクセル〜64ピクセルのDisparityでの結果(左からDisparity Mapのargmax, 左側のカメラ画像, 右側のカメラ画像の予測結果, 実際の右側のカメラ画像)

終わりに

深度推定は結果が目で見てわかりやすくて楽しいので割とおすすめの分野です。

今回のDeep3Dはカメラパラメータを全く意識しなくても実装できる比較的簡単なアルゴリズムになっているので、割と初心者向けだと思います。

最後にあまり期待はしていませんが、リモートワークで地方から働ける会社を探していますので、そういう会社があれば是非お声がけください。

今年一年の振り返りと来年の目標

はじめに

今年一年は特に職が変わった訳でもありませんし、業務内容に変化があった年でもありませんでしたが、
せっかくなので、ブログで今年一年の振り返りをしてみることにしました。
この記事は誰得なんだという感はありますが、私の成長のためだと思って許してください。(ただの私の備忘録?)

今年一年の振り返り

エンジニアリング面で色々なことをやり始めた

ソフトウェア工学周辺を学ぶようになった

通常のシステム開発とは違うとはいえ、システム開発をする機会が増えてくるだろうし、きちんと運用できて保守性の高いシステムを作れるようになりたいと思い、
リーダブルコードClean ArchitectureUNIXという考え方を読みました。

Clean Architectureに関しては私にはまだ早いという感しかなかったですが、何度も読み返してものにしていきたい。

テストコードを書くようになった

かなり非効率だけど今まで手動でテストをしていて、分析のコードに対してテストコード書くのめんどくさいなと思っていました。
しかし、何度も発生するバグに何度も苦しめられたり、4~5人程度のチームで開発することが増えてきたので、良い加減手動でテストしているのもな…と思い、テストコードを書くようになりました。
その結果、テスタブルなコードを書くように意識するようになったので、今のところ効率化された感はないが、良かったんじゃないかなという気持ちになりました。
ただ、テストコードの書き始めはかなり手間取って逆に非効率じゃね感があったりして、結構大変でした。

継続的インテグレーションツールを使うようになった

エンジニアの方に勧められたのと、テストコード書くようになったのと、ずっと導入してみたかったという理由でJenkinsをプロジェクトに導入することにしました。
最初はGitHubとの連携を調べるとMultibranch Pipelineを使う方法やらGitHub Pluginを使う方法やらGitHub webhookを使う方法やら色々な方法が出てきて、どれが良いのかを迷って手間取ってしました。
最終的にはMultibranch Pipelineにしましたが、ググり力不足なのかそもそもないのかは知りませんが、どう違うのかが分かるようなドキュメントがあまり見当たらなかったので、そういうのがまとまっているサイトがあれば教えていただきたい。

Goを勉強し始める

これは必要に迫られてというほどの動機ではないですが、ウォシュレットでうがいした経験のあるすごい前職の先輩と脆弱性を見つけてプギャーしてる姿をよくみる前職の先輩との共同開発プロジェクト的なもので使う言語がGoになったので、ちょこちょこ勉強し始めている感じです。リリースできると良いなあ。

Rustを勉強し始める

純粋にRustには興味があったので、チュートリアルをやってみたというところで最近は手をつけていないですね。来年には何か書きたい。

JavaScript(というかreact)を勉強し始める

Reactはフロントエンドさわさわしてみたいという理由で手を動かしつつ勉強してましたが、1週間くらいで放置しちゃっています。
共同開発プロジェクト的なものでフロントエンド書くときにまた勉強しようかなと思っています。

データ分析面でも色々やってみた

Kaggleをちょこちょこやり始める

まだ上位に行けてはいませんが、ちょこちょこkaggleをやり始めました。malwareコンペだけは勝ちたいという気持ちで毎日過ごしています。

HRED

ブログ記事にしていませんが、対話タスクのアーキテクチャでSeq2Seqの枠組みに過去のやり取りの情報を含む隠れ層を参照する仕組みを追加しただけの以下の論文の実装して手持ちのデータセットで検証してみましたが、結果はデータが悪いのもあり微妙で、VHREDの論文で指摘されているような相槌のような表現しか使わなくなる問題が起きたのも良い思い出です。
[1507.02221] A Hierarchical Recurrent Encoder-Decoder For Generative Context-Aware Query Suggestion

DeepCluster

先日記事を書いたばかりのDeepClusterです。強いGPUと膨大なメモリを得るだけの課金する覚悟ができたらImageNetで追試チャレンジしたいです。
GitHub - pesuchin/deepcluster-chainer

Deep3D

こちらはこの正月中にバグ取りをして、うまく動けば記事にしようかなと企んでいる深度推定(論文の主目的は深度推定ではなくて、左画像から右画像を生成してGIFにすれば3Dっぽく見えるよねというタスクですが、私がやりたいのは深度推定なので…)の論文です。
結構古い論文ではありますが、このアーキテクチャを使ったやや新しい深度推定の論文(Single View Stereo Matching)を実装したいので、実装を成功させていきたい。

来年の目標

エンジニアリングについての来年の目標

興味があるので実際の開発に適用してみたい。

ぼんやりとしか理解できていない気がするので、実際の開発で使えるレベルにはなりたい

おすすめされたので理解したい。

  • Clean Architectureをわかりたい

再チャレンジしたい

  • セキュアコーディングをできたい

前職はセキュリティ業界だったので、恥さらしめと言われないようなセキュアなコードが書けておきたい(油断すると思いっきり脆弱なコード書いてる時がある)

  • FPGA買って遊ぶ

一応コンピュータサイエンスの学部を卒業しているのでCPUくらいは作っておきたい。

  • バックエンドもフロントエンドも多少は書けるくらいまでは実装力をつけたい。
  • 並列処理とか高速化にも興味ある

データ分析についての来年の目標

  • 学習理論を理解したい

理論がすっぽり抜けてしまっているので、学習理論をやりたい。

  • 数学力をあげたい

ちょっときつめの数学が出てきた瞬間に即死させられてしまうのをなんとかしたい。算数しかできない小学生状態を卒業したい。

  • kaggleで勝ちたい

最強になりたい

  • LightGBMをちゃんと理解して使えるようになる

木系のモデルはちゃんと理解するためにも感謝の再実装していきたい気持ちがあります。

  • Transformerの感謝の再実装

Transformerで対話タスク解いてみたい。

  • 物体検出やってみる

色々大変そうだけどできると楽しそう。

何か面白そうなデータセットなので、前処理大変じゃなければ触っていきたい

おわりに

やりたいことが本当に多すぎてまた大学生になりたい。というか、いつか修士は取りたい。
まだまだ若輩者ですが、来年もみなさんよろしくお願いいたします。

DeepClusterでお前をクラスタリングしてやれなかった

はじめに

久しぶりの投稿となってしまいましたが、お久しぶりです。(毎回これを書いている気がします)

この記事は Chainer/CuPy Advent Calendar 2018 - Qiita の19日目の記事となります。

今回取り上げるのはしばらく前にTwitterで話題になっていたDeepClusterについてです。

他の参加者の方はChainerに関しての記事を書いていたのに対して、Chainerで実装したとはいえDeepClusterの記事を書いても良いものか悩みましたが、結局DeepClusterの記事になってしまいました。

話題になった頃あたりにDeepClusterの実装を試みたのですが、faissの使い方が分からないからなのか謎に発生するセグメンテーションフォールトに苦しめられて断念していました。

なぜ今なのかというと、実はあまり話題になっていないように思いますが、しれっと本家のpytouch実装がgithubに上がっていたので、それを参考に再チャレンジしたら loss がそれっぽく下がってくれて、それっぽくNMIが上がるようになったからです。(とは言うものの、残念ながらこの先は上手くクラスタリングできませんでしたブログになります)

DeepClusterとは

利点

DeepClusterを使う利点としては以下があります。

1. k-means のような標準的なクラスタリングアルゴリズムを使って、簡単にconvnet の end-to-end で学習できるので、実装が比較的簡単
2. 教師なし学習で使用される標準的な転移タスクで最高の性能
3. uncured image distribution(意味はよくわからなかった)で訓練した時に従来手法より上の性能

クラスタリングそのものではなく、どちらかといえば、「教師データのないデータに対して、高精度なニューラルネットワークのpretrained modelを作成できる」というところがこのアーキテクチャの魅力なのかなと思っています。

例えば、教師データの少ない上にあまり一般的でない種類の画像の巨大なデータセットが手元にあったとして、最善の方法としては全ての画像に対してアノテーションすることですが、アノテーションの人手が足りない、もしくは予算が降りないなどの理由で一部しかアノテーションできず、少量のデータを使ってモデル構築に挑まなければいけない場合があるかもしれません。
そのような場合には、このアーキテクチャを使ってpretrained modelを作成して使うと嬉しいのではないかなと思いました。

ネットワークのアーキテクチャ

さて、実際のネットワークのアーキテクチャは流行っていたのもあり、論文の図にちょっとアレンジを加えたものを載せてみます。
全体像としては以下の形になります。

f:id:gat-chin321:20181110214427p:plain

それでは、手順ごとに見ていきましょう。まず初めに行われるのは、k-meansによるPseudo-label(擬似ラベル)の生成になります。
正確な定義ではないですが、Pseudo-label(擬似ラベル)とは、何らかの方法で機械的に生成されたラベルのことを指していて、それを使って分類器に教師あり学習をさせるアプローチが多々あります。
Pseudo-labelの生成のプロセスはこうです。

1. Inputの画像をconvnetに通し、特徴量を作成します。(ここではfully-connectedレイヤーは通さないことに注意)
2. 作成した特徴量を主成分分析+L2正則化で次元圧縮します。
3. k-meansを実行して、その時に生成されたクラスタ番号をPseudo-labelとして利用します。
4. これを1 epochごとに実施。

f:id:gat-chin321:20181110214637p:plain

このステップで、画像の全データをメモリに格納しておく必要があるので、かなりのメモリ容量が必要になる点がややデメリットかなと思います。(上手くやれば結構節約はできる気もしますが…)

次にPseudo-labelを用いた分類器の学習を行なっていきます。

このフェーズでは以下の手順で分類器の学習を行います。

1. Inputの画像をconvnetに通し、fully-connectedレイヤーに通します
2. fully-connectedレイヤーを通った出力にsoftmax cross entropy lossを求める(ここで私の実装ではclass weightにPseudo-labelごとの数の逆数を渡しています)
3. 次のepochまで繰り返したあと、k-meansによるPseudo-labelの生成を再度行う

f:id:gat-chin321:20181110215448p:plain

というのがDeep Clusterによる学習の流れになります。
また、この時、大多数の画像がごく少数のクラスタに割り当てられている場合、どの画像に対しても1つのクラスに分類してしまうようになる可能性があります。
この対策として以下のどちらかを行います。(割と一般的な方法だとは思います)

  • Inputの画像を一様分布にしたがってサンプリングする(論文での実装)
  • クラスの画像のサンプル数の逆数をclass_weightに渡す(こちらの方が楽なので私の実装ではこちらにしています)

「なぜこれで学習できるのか?」という問いについては論文中にて

The good performance of random convnets is intimately tied to their convolutional structure which gives a strong prior on the input signal
(ランダムな convnet で良い性能が出せるのは入力信号に強い事前分布を与える畳み込み構造に密接に関係している)

というような記載がありますが、入力信号に強い事前分布を与えることでなぜうまく学習できるのかがあまりよくわかりません。
多少調べたところ、以下の資料などでconv+poolingが入力信号に無限に強い事前分布を与える性質については記載がありました。
無限に強い事前分布を与えることでアンダーフィットが起こる可能性があるみたいな指摘があるので、アンダーフィットしやすい性質 VS オーバーフィットしやすいk-meansによる擬似ラベルでなんかいい感じに学習できたとかでしょうか。(雑な解釈)

https://uwaterloo.ca/data-analytics/sites/ca.data-analytics/files/uploads/files/cnn1.pdf

以下の論文のconvnetの教師なし学習によるセグメンテーションに似ているような気がするが、こちらもなぜうまくいっているのかは謎な感じです。

https://kanezaki.github.io/pytorch-unsupervised-segmentation/ICASSP2018_kanezaki.pdf

実装

ソースコードは以下のgithubに公開しています。

GitHub - pesuchin/deepcluster-chainer

この実装ではPseudo-labelの生成についてはtrainer.extendを使って1 epochごとにPseudo-labelを生成する処理を行なっています。(github上のtrain.pyに記載)

また、AlexNetのLocal Response Normalizationは本家の実装の通り、Batch Normalizationに変えています。

そのうちリファクタリングもしたいのですが、今のところは気力の都合上、無駄の多い汚い実装になっているとは思います。

本家の実装と異なる点としてはUniformSampler(分類または疑似ラベルの一様分布に基づいて画像をサンプリングする)を使わずに、論文中で等価と言われている割り当てられたクラスタのサイズの逆数をclass weightに渡している点です。(見落としているだけで他にもあるかもですが…)

実験結果

実験で利用する評価指標について

評価指標は論文でクラスタリング結果を評価するのに利用している正規化相互情報量 (NMI; Normalized Mutual Information) を利用します。

NMIは以下の式で定義される評価指標です。

 NMI(A; B) = \frac{I(A;B)}{\sqrt{H(A)}\sqrt{H(B)}}

また、

 I(A;B) = \sum_{y \in Y} \sum_{x \in X} n_{x, y} log \frac{n_{x, y}}{n_{x}n_{y}}

 H(A) = \sum_{y \in Y} n_y \frac{n_y}{n}
 H(B) = \sum_{x \in X} n_x \frac{n_x}{n}

 n_{y}: 正解の集合 y の画像数
 n_{x}: クラスタ x の画像数
 n_{x, y} : クラスタ x 中で正解集合 y に属する画像数
 n: 全体の画像数

NMIはクラスタ x がわかった場合に、正解集合 y が必ずわかるような場合(つまり、それぞれのクラスタ x の画像と正解集合 y の画像が一致する場合)は1になり、そうでない場合に値は0に近づく。例えば、以下の場合はNMIは1になります。

  • 予測クラス: [2, 2, 2, 2, 1, 1, 1, 1]
  • 正解クラス1: [1, 1, 1, 1, 2, 2, 2, 2]

個人的にはもう少しきちんと理解しておきたいものですが、本筋ではないのでNMIの説明はこれくらいにしておきます。

CIFAR 10での実験結果

CIFAR 10の訓練用データセット60000件をクラスタリングした場合のNMIの推移がこちらです。

f:id:gat-chin321:20181213215718p:plain
cifar10のNMIの推移

上記の図を見ると、12 epochあたりからピタリとNMIが上がらなくなり、24 epoch目あたりがNMIのピークとなっていることがわかります。

NMIの値は同じデータで普通のk-meansでやってみた時は0.07程度のNMIが出たので、正直全く良くないです。

本家実装でも同じデータでコードを動くように多少修正して動かしてみたのですが、0.06程度のNMIだったので、実装はそんなに違わないはず…。

論文で使われていた一つ前の予測ラベルと現状の予測ラベルのNMIをとった結果をプロットしたものをみてみましょう。

f:id:gat-chin321:20181213220255p:plain
一つ前の予測ラベルと現状の予測ラベルのNMIをとった結果

こちらを見ても、12 epoch目からNMIが上がらなくなっていることがわかり、論文に記載している通り、概ねNMIが0.8を超えたあたりで収束していることが分かります。

では、24 epoch目で実際に分類した結果をいくつか貼っていきます。

f:id:gat-chin321:20181213220747p:plain
class 1: 馬っぽいのがそこそこある例

f:id:gat-chin321:20181213220922p:plain
class 0:ダメそうな例

f:id:gat-chin321:20181213221017p:plain
class 7: 車がそこそこ集まっている例

全体的に「そんな似ているか?」というものが色々入っちゃっている印象です。

終わりに

CIFAR10のデータを使って、NMIによるクラスタリングの評価を行なってみました。

ここでは結果は載せませんが、他にもMNIST・Fashion-MNIST・CIFAR 100などでも実験してみたり、AlexNetのネットワークを小さくしてみたりはしたのですが、どれも普通のk-meansに及ばないNMIでした。

もしかすると、論文で提案されているAlexNetを用いたDeep Clusteringでは画像サイズの小さいもしくは画像数の少ないデータは苦手なのかもしれません。もちろん実装ミスの可能性もなきにしもあらずですが…。

DeepClusterを使って作成したpretrained modelの精度も実験したかったのですが、今回はやりきれなかったので、一旦ここまでとしようかと思います。

あとImageNetでどうなるかを見て実装があっているのか確かめたかったのですが、ImageNetで学習させるだけのハードウェアを用意するほどの資金力がないのと実験を回すスクリプトの実装の時間がなかったので、実装が正しいのか確かめられていない状況です。

もし、「ここが間違っているよ」みたいなことがあればお気軽にご指摘ください。

投稿までに間に合わせたかったのですが、悔しいことに間に合いませんでした。自分の無力さを感じます。

Deep Learningは実装が正しいのかを確認するのが難しく、巨大なデータセットで実装されていると計算資源の問題で追実験が気楽にはできないですね。今日で私のGCPの300ドル使い放題期間が終わってしまったのが悔やまれます。

Paragraph Vector DBOWの憂鬱

はじめに

この記事は Deep Learningやっていき Advent Calendar 2017 - Adventar の24日目の記事となります。

メリークリスマス。お久しぶりの投稿となります。本日はクリスマスイブとなりますが、皆さんはいかがお過ごしでしょうか。

私は一人寂しくクリスマスパーティーの準備(寿司を握る)をしたり、自分で設定した締め切りに追われながらもブログを書いたりして過ごしておりました。

不慣れなDeep LearningでAdvent Calendarに参加してみるというやや無謀な挑戦をしてみたわけですが、今回は『Analysis of the Paragraph Vector Model for Information Retrieval』を読んだので、自らの勉強の為にブログに書きます。

この記事はコツコツ書きためていたものなのですが、途中で「Paragraph VectorってDeepではなくね?」と思い、木構造のLSTMで書き始めましたが間に合いそうにないので、比較的間に合いそうなこちらを投稿することにしました。浅いのにDeepを語ってしまってごめんなさい。

本記事の内容ですが、論文の内容をできるだけ自分の感想を交えて書いただけなので、真面目に勉強したい方は論文を読んだほうが良いと思います。

Paragraph Vector

概要

Paragraph Vectorは『Distributed Representations of Sentences and Documents』で提案されている文書の特徴ベクトルを表現できるようにword2vecを拡張したアルゴリズム

この論文ではParagraph VectorのBag of Wordsバージョン(PV-DBOW)とParagraph VectorのDistributed Memoryバージョン(PV-DM)が提案されている。

gensimのDoc2Vecで実装されているアルゴリズムであり、こちらの名前の方が有名になってしまっているような気がする。『Analysis of the Paragraph Vector Model for Information Retrieval』の論文で取り上げられているのはPV-DBOWなので、今回はPV-DBOWのみを取り上げて簡単に説明する。

PV-DBOW

Word2Vecのおさらい

Paragraph VectorはWord2Vecの拡張とのことなので、一旦Word2Vecについておさらいする。下記の図を見ていただこう。

f:id:gat-chin321:20171224131110p:plain
(図は『Efficient Estimation of Word Representations inVector Space』より引用)

これはSkip-gramと呼ばれる学習方法で、今回取り上げるDBOWの元となっているアルゴリズムである。

下記の記事が直感的にわかりやすいので参考にしてほしい。

絵で理解するWord2vecの仕組み - Qiita

Word2Vec のニューラルネットワーク学習過程を理解する · けんごのお屋敷

Word2Vecでは純粋に学習させると結構な時間がかかってしまう。この問題を解決するため、階層的ソフトマックスや負例サンプリングのどちらかが用いられる。

今回は負例サンプリングのみを使うため、そちらのみを簡単に説明する。

負例サンプリングは「周辺単語の入力から単語を予測する」というタスクから「単語が実データの確率分布かノイズの確率分布かのどちらに生成されたものなのかを予測する」ように置き換えて、ニューラルネットワークを学習させることで高速化する。

これらの具体的な実装については下記のChainerのexampleで公開されている。
chainer/train_word2vec.py at master · chainer/chainer · GitHub

tensorflowは使ったことがないので、よくわからないがあるっぽい。
tensorflow/word2vec_basic.py at r1.2 · tensorflow/tensorflow · GitHub

PV-DBOWの概要

PV-DBOWは下図のようにニューラルネットワークが設計されている。

f:id:gat-chin321:20171224131417p:plain

(図は『Distributed Representations of Sentences and Documents』]より引用)
先ほどのSkip-gramとネットワークの形が似ていることがわかるかと思う。何が変わったのかというと入力部分が文書IDとなった点だ。

f:id:gat-chin321:20171224131537p:plain

つまり、文書IDを入力として、文書内の共起の情報をスライドさせながら埋め込んでいくことで最大公約数的な文書全体の意味を重みに埋め込んでいっているっぽい。
これにより、word2vecによりベクトル化した単語ベクトルのような形で文書のベクトル表現が得られることがわかる。

公式ではないが、PV-DBOWのChainerでの実装は下記のURLで公開されている。
GitHub - monthly-hack/chainer-doc2vec: A Chainer implementation of doc2vec

PV-DBOWの弱点

簡単に説明したところで、PV-DBOWのソフトマックスを用いた出力部分の式をみていこう。
 \begin{equation}
P(w|d) = \frac{exp(\vec{w} \cdot \vec{d})}{\sum_{\acute{w} \in V_w} exp(\vec{\acute{w}} \cdot \vec{d})}
\end{equation}

 P(w|d): ドキュメント dを与えられた時に単語 wが出力される確率

 w: 単語

 d: 文書

 \vec{w}: 単語のベクトル

 \vec{d}: 文書のベクトル

 V_w: 語彙集合

これを負例サンプリングで学習させる場合、下記のような式となる。

  \begin{equation}
l = \sum_{w \in V_w} \sum_{d \in V_d} \#(w, d) log(\sigma(\vec{w} \cdot \vec{d})) + \sum_{w \in V_w} \sum_{d \in V_d} \#(w, d) (k \cdot E_{w_N \sim P_{V}} )
\end{equation}

 \vec{w}: 単語のベクトル

 \vec{d}: 文書のベクトル

 V_w: 語彙集合

 V_d: 文書集合

 k: 負例サンプリングの数

 E_{w_N \sim P_{V}} : ノイズ分布  P_{V} が与えられた時の  log \sigma(- \vec{w_N} \cdot \vec{d}) の期待値

 \#(w, d): ある単語  w の文書  d 内での出現回数

 \sigma(x): シグモイド関数  \sigma(x) = \frac{1}{1+e^{-x}}

文の長さの偏りによる過学習に対する改善

PV-DBOWを検索モデルに適用した場合、Robust04のタイトルをクエリにした際に検索された上位の文書内の語数の傾向として、比較的短い文書が検索結果として表示されやすいという特徴があることが下記の図からみて取れる。

f:id:gat-chin321:20171224131629p:plain
(図は『Analysis of the Paragraph Vector Model for Information Retrieval』より引用)

また、この図ではepoch数が増れば増える分だけその傾向が強くなっていることもわかると思う。
なぜこのような結果になったのかを考えていこう。
まずepoch数が増えれば増えるほど短い文書が表示されやすいのは、勾配計算が原因であると考えるのが自然だと思う。そこで勾配の計算式がどうなっているのか考えてみよう。ある文書dに関する目的関数の偏微分は下記の式で計算される。

 \begin{equation}
\frac{\partial l}{\partial d} = \sum_{w \in V_{w}} \# (w, d) log (\sigma (-\vec{w_N} \cdot \vec{d}))\vec{w} - \sum_{w \in V_w} \# (w, d)(k \cdot E_{w_N \sim P_{V}} \vec{w_N})
\end{equation}

 w_N: ノイズ分布に従ってランダムにサンプリングされる単語

 \vec{w_N}: サンプリングされた単語のベクトル

この数式からdの勾配は単語ベクトルの加重和により求められていることがわかる。そのため、単語数の少ない短い文書の場合、その勾配が全ての単語ベクトルから遠くないところに収束してしまう。したがって、短い文書同士も遠くない方向に収束してしまっており、短い文書が出やすくなると考えられる。

また、epochが増えれば増えるほどその傾向が強くなるのは、epochが増えた分、短い文書は単語ベクトルとの距離がやや離れるが、長い文書については単語ベクトルとの距離が大幅に離れることになる。つまり、短い文書は検索用のクエリーのように短い文書との距離が近く、逆に長い文書は短い文書との距離が遠くなりやすいと言える。

学習のepoch数をそれぞれ異なる値にして学習してみて、文書ベクトルのノルムと文書の長さの関係をグラフにしたものが下記の図である。

f:id:gat-chin321:20171224131754p:plain
(図は『Analysis of the Paragraph Vector Model for Information Retrieval』より引用)

この図はx軸が文書の長さ(つまり、単語数)で、y軸が文書ベクトルのノルムを表しており、青の点はepoch数が5の結果、灰色の点はepoch数が20の結果、黄色の点はepoch数が80の結果となっている。

1000語以上の文書の場合は特に文書ベクトルのノルムはepochの回数による違いは多少黄色がバラついている点がちらほらある程度で、全体的にはあまり違いがないことがわかるかと思う。

その一方で、1000語未満の文書ではepochの回数が増えるにつれて、文書ベクトルのノルムが急速に増加していることが見て取れる。

このことから、PV-DBOWは1000語未満の文書での過学習の問題を抱えていて、文書が短ければ短いほどこの問題が深刻であることがわかった。

この過学習の問題は下記の式のように文書ベクトルにL2正則化を追加することで改善される。

  l'(w, d) = l(w, d) − \frac{\gamma}{|d|}||\vec{d}||^2

 l(w, d): PV-DBOWのための局所的な目的関数

 \gamma: ハイパーパラメーター。論文の実験では10が一番いい結果だったらしい。

 |d|: 文書dの単語の数

 ||\vec{d}||: 文書ベクトル  \vec{d} のノルム

正則化項がマイナスなのが何故なのかよくわからないが、元々の損失関数を最小化しつつ、その最小化をさらに加速させるために文書ベクトルのL2ノルムを大きくするような動きになりそう。

L2正則化の効果は主に下記の2点があるとのこと。

  • epochの回数が増えるにつれて、短い文書と長い文書の両方の文書ベクトルのノルムがほぼ同じになるため、epochの多い学習では短い文書に過学習することはほぼなくなる。
  • 文書ベクトルのノルムに制約がかかることにより、式(1)の確率分布が滑らかになり、検索モデルを平滑化する効果がある。

ノイズ分布に存在する問題と対策

次はPV-DBOWの目的関数について議論する。

『Neural Word Embedding as Implicit Matrix Factorization』のSkip-gramモデルの解析では、式(2)から特定の単語-文書対の局所的な目的関数を以下のように導出する。

  l(w, d)=\#(w, d) log \sigma (\vec{w} \cdot \vec{d}) + k \#(d)P_V(w)log \sigma (-\vec{w} \cdot \vec{d})

 \#(d): 文書 d中の全ての単語数

 \#(w, d): ある単語  w の文書  d 内での出現回数

 x=\vec{w} \cdot \vec{d} と定義すると、 xの目的関数の偏微分は次のようになる。

  \frac{\partial l(w, d)}{\partial x} = \#(w, d) \cdot \sigma (-x) - k \cdot \#(d) \cdot P_V(w) \cdot \sigma (x)

この偏導関数を0と置くと、この式の解は下記になる。

  \vec{w} \cdot \vec{d} = log(\frac{ \#(w, d) }{\#(d)} \cdot \frac{1}{P_V}) - log k

文書中の用語の重み付けは負例サンプリングのノイズ分布 P_Vにより決定される。元々のモデルでは負例サンプリングは全体のコーパスにおける経験的な単語分布を下記のように定義している。

  P_V(w_N) = \frac{\#w_N}{|C|}

 \#(w_N):  w_Nコーパス頻度

 |C|: コーパスのサイズ

この式において、 \frac{\#w_N}{|C|} dにおける wの正規化TFであり、 \frac{1}{P_V(w)} (すなわち、 \frac{|C|}{\#w_N}) は wICF値とみなせる。

ちなみにICFは色々種類(Inverse Community Frequency, Inverse Category Frequency, Inverse Corpus Frequency, Inverse Collection Frequencyなど)があってややこしいが、引用文献を追って行く限りだとおそらくInverse Collection Frequencyのことをさしていて、ICFの式は下記の通りである。

  icf_{t, d} = log (\frac{\Sigma_d dl_d}{\Sigma_d tf_{t,d}})

 tf_{t,d}: 文書 dにおける単語 tの出現数

 \Sigma_d tf_{t,d}: コーパス全体での単語 tが存在する文書の頻度(単語tのコーパス頻度)

 \Sigma_d dl_d: 全文書の長さの総和を求めることでコーパスの全体の単語の数を算出している(コーパスのサイズ)

したがって、負例サンプリングを使うオリジナルのPV-DBOWはTF-ICF重み付け方式に対して最適化される。

しかし、TF-ICFは情報検索における一般的な重み付け方式ではなく、ICFベースの用語の重み付けはコーパス内の単語の頻度に応じてのみ単語の識別能力を計算する仕組みのため、文書構造情報の形式を考慮していない。

経験的には小さな文書グループにしか現れない場合でもコーパス内での出現頻度が高くなる単語が特徴的であると見なされることもあり得るので、PV-DBOWは一般的なNLPのタスクではうまく動作するが、情報検索タスクではうまく動作しないらしい。

このあたりの議論は『Understanding Inverse Document Frequency: On theoretical arguments for IDF』に載っているらしいのでそのうち読みたい。

そこで、この論文ではPV-DBOWの問題に対処するために文書頻度(DF)ベースの負例サンプリング戦略を適用している。

具体的には、負例サンプリングの P_Vを次のように新しいノイズ分布 P_D に置き換える。

  P_D(w_N) = \frac{\#D(w_N)}{|N|}

 \#D(w_N):  w_N の文書頻度

 |N|: 語 w' を含む文書の数 ( |N|=Σ_{w’∈V_w}\#D(w’))

 P_V と一緒に P_D \vec{w} \cdot \vec{d} = log(\frac{\#(w, d)}{\#(d)} \cdot \frac{1}{P_V}) - log k に代入した新たな最適解が下記となる。

  \vec{w} \cdot \vec{d} = log(\frac{\#(w, d)}{\#(d)} \cdot \frac{N}{\#D(w)}) - log k

こうすることで、  \frac{1}{P_V} の部分は wの逆文書頻度(IDF)の変形である  \frac{|N|}{\#D(w)} となる。これで、DFベースのネガティブサンプリングを使用するPV-DBOWはTF-ICFよりも情報検索タスクに向いている重み付けであるTF-IDFの重み付けに対して最適化されるようになった。

(補足) TF-IDFの計算式
  tfidf_{t,d} = tf_{t,d} \cdot idf_{t} \
tf_{t,d} = \frac{n_{t,d}}{\Sigma_{s \in d} n_{s,d}} \
idf_{t} = log \frac{|D|}{df(t)} + 1

 n_{t,d}: ある単語  t の文書  d 内での出現回数

 \Sigma_{s \in d} n_{s,d}: 文書 d内のすべての単語の出現回数の和(文書 d中の全ての単語数)

 |N|: 全文書数

 df(t): ある単語  t が出現する文書の数

従来手法であるコーパス頻度( P_V)と提案手法である文書頻度ベース( P_D)の分布をそれぞれプロットしてみると下記の通りになったようだ。

f:id:gat-chin321:20171224132142p:plain
(図は『Analysis of the Paragraph Vector Model for Information Retrieval』より引用)

ちなみに横軸は単語頻度のログの値(基数は10)を表している。

この図をみると、 P_Vコーパス中での単語の出現頻度が上昇すればするほど指数関数的に増加していて、 P_Dよりもはるかに高い確率を割り当ているような傾向が見られる。

この性質からコーパス頻度 P_Vを利用した場合は言語モデルの学習を行う際に、頻出単語に対しては上手くいかなくなることがあるらしい。

この論文では頻出単語に対しては上手くいかなくなる例として、Robust04のクエリ339(“alzheimers drug treatment”)をあげて説明している。

例えば、“drug(薬)”が“alzheimers(アルツハイマー)”の2倍以上の頻度で出現する場合でも、コーパス頻度ベースの負例サンプリングを用いたPV-DBOWでの予測確率が“alzheimers”(0.042)は“drug”(0.002)よりも高くなってしまう。

これは「単語が実データの確率分布から生成された正解単語かノイズの確率分布から生成された不正解単語かを予測する」ための目的関数の最小化を行う負例サンプリングでは、おそらく正解単語には高い確率が割り当てられ、不正解単語には低い確率が割り当てられるような動きになるので、頻繁にサンプリングされてきやすい単語である“drug”は不正解として判断されやすいため、結果として"drug"の確率が下がるのだろうか?よくわからない。

正直なところ何で“alzheimers"に比べて"drug"の予測確率が低すぎるとダメなのかいまいち良い理解ができない。

ちなみに提案手法の文書頻度ベースの負例サンプリングでは、用語の重み付けが緩和され、より合理的な言語推定が行われるとのこと。例で出た“alzheimers”は0.056、“drug”は0.069となったらしい。

また、最後にコーパス頻度ベースの負例サンプリングの場合と同様に
 \#D(w)^{\eta}(0 \leq \eta \leq 1)
を使用する文書頻度のべき乗バージョンを採用して最終的に下記の通りとなる。

  P_D(w_N) = \frac{\#D(w_N)^{\eta}}{Σ_{w’∈V_w}\#D(w’)^{\eta}}

単語の言い換え関係の捕捉

『Learning Word Representations by Jointly Modeling Syntagmatic and Paradigmatic Relations』に示されているように、用語-文書行列上で分散情報を活用するモデルは主に単語の統語的関係を捕捉するが、語形変化関係を無視するとのこと。

統語的関係の論文中の解説はあまり腑に落ちなかったが、要は同じテキスト領域で共起する単語に関連しているような傾向があること。

例えば、「NBA」は「バスケットボール」と同じ文書中で共起するため、関係があるとのこと。

語形変化のように類似した文脈をもつが、文書では共起しない単語と意味が似ているものもある。

例えば、"subway"と"underground"は同義語であり、同様の状況で発生することもあるが、アメリカ人は通常"subway"を使用するのに対して、英国の人々は"underground"を使用する傾向があるなど。

元々のPV-DBOWは共起数が多い単語は類似の表現を持つ傾向がある。

しかし、これでは同じ文脈では発生するが、同じ文書では起こらない単語間の意味的類似性をモデル化することができない。

クエリ用語は平均で関連ドキュメントの40〜50%が用語の不一致であるので、用語の不一致は情報検索のタスクではよくあることらしいので、こういった単語の語形変化や単語置換関係や用語の不一致などの問題を緩和することは情報検索の分野では重要らしい。

この論文では、例としてRobust04クエリ361(“clothing sweatshops”)を取り上げている。

このクエリと関連する文書では「garment(衣服)」が頻繁に使用されているが、「clothing(衣服)」は関連する文書では使用されていない。

下記の表は文書頻度ベースのネガティブサンプリングおよびL2正則化(EPV-DR)やEPV-DRに joint objectiveを導入したもの(EPV-DRJ)を用いたParagraph Vectorでの検索モデルにおける「clothing」、「garment」および4つの関連ドキュメント間のcos類似度*1を列挙したものである。

f:id:gat-chin321:20171224132412p:plain
(図は『Analysis of the Paragraph Vector Model for Information Retrieval』より引用)

直感的に考えれば、"clothing"は"garment"と同義語であるため、"garment"と同様の確率を受け取って欲しい。しかし、EPV-DRでの結果では"clothing"に比べて"garment"のcos類似度がはるかに低くなり、結果としてこれらの関連文書の"clothing"の確率を低下させてしまっている。

単語の置換関係をモデル化するためにPV-DBOWのjoint learning objectiveを適用する。

下記の図に示すように、モデルの第1の層は文書ベクトルを使用して観測された単語を予測する。

f:id:gat-chin321:20171224132503p:plain
(図は『Analysis of the Paragraph Vector Model for Information Retrieval』より引用)

次に、モデルの第2層は、観測された単語を使用してその文脈を予測する。

PV-DBOWの目的関数を以下のように表現できる。

  l = log(\sigma(\vec{w_i} \cdot \vec{d})) + k \cdot E_{w_N \sim P_D} + \sum_{j=i-L, j \neq i}^{i+L} log \sigma(\vec{w_i} \cdot \vec{c_j}) + k \cdot E_{c_N \sim P_D}

 \vec{c_j}: 単語  \vec{w_j} の文脈ベクトル

 c_N: サンプリングされたコンテキスト

 L:コンテキストウィンドウサイズ

学習の観点から言えば、単語と文脈の間に予測目的を追加すると実際にPV-DBOWの学習目標が正則化される。

この正則化により、joint objectiveをEPV-DRに組み込んだEPV-DRJでは、"clothing"と4つの関連文書との間のcos類似度は大幅に高くなることがわかる。

LA112889-0108("clothing"が出現しない文書)でも、"clothing"と"garment"に類似したcos類似度を持つようになった。

したがって、EPV-DRJベースの検索モデルの言語推定は"clothing"の確率を高くし、最終的な検索性能を向上させる働きがあることがわかった。

実装について

chainer-doc2vecを参考にして、chainerを使って実装してみましたが、実装が正しい自信がない。

chainer-doc2vecのsearch.pyを使えば、学習後に文書間の類似度を算出できます。

あと安月給のため、お家にGPUがないのでちゃんとしたデータセットとかで検証できていません。

クラウド使えとのことですが、業務では消し忘れの実績があることからGPUインスタンスを消し忘れそうで怖いのでビビってできません。

今回は間に合わなかったのでやりませんでしたが、そのうちnardtreeさんの

Google Cloud FunctionをPythonで使う
Google Cloud FunctionをPythonで使う - にほんごのれんしゅう

のような感じで、インスタンスをいい感じに操作できるようにしてから試してみようかなとか考えています。

pythonを強引にインストールするのだるいので、Azureで同じようなことやってみようかと思っています。

その場合は正則化周りの内積計算をnumpyでやってしまっているので、cupyでやるかchainerでやるかしようかとは思います。

というか、そもそもbatchの中のinverseとnormの計算がかけて足せればいいかなと思って内積にしてしまったが、これでいいのだろうか。

あと、サンプルデータで実験したあとに気がついたが、ハイパーパラメーターの gammaを10ではなく1で実験してしまっていたのも悲しい。

#!/usr/bin/env python
import argparse
import collections

import numpy as np
from numpy import linalg as LA
import six

import chainer
from chainer import cuda
import chainer.functions as F
import chainer.initializers as I
import chainer.links as L
import chainer.optimizers as O
from chainer import reporter
from chainer import training
from chainer.training import extensions


parser = argparse.ArgumentParser()
parser.add_argument('--gpu', '-g', default=-1, type=int,
                    help='GPU ID (negative value indicates CPU)')
parser.add_argument('--unit', '-u', default=200, type=int,
                    help='number of units')
parser.add_argument('--window', '-w', default=5, type=int,
                    help='window size')
parser.add_argument('--batchsize', '-b', type=int, default=50,
                    help='learning minibatch size')
parser.add_argument('--epoch', '-e', default=20, type=int,
                    help='number of epochs to learn')
parser.add_argument('--negative-size', default=5, type=int,
                    help='number of negative samples')
parser.add_argument('--out', default='result',
                    help='Directory to output the result')
args = parser.parse_args()

if args.gpu >= 0:
    chainer.cuda.get_device_from_id(args.gpu).use()
    cuda.check_cuda_available()

print('GPU: {}'.format(args.gpu))
print('# unit: {}'.format(args.unit))
print('Window: {}'.format(args.window))
print('Minibatch-size: {}'.format(args.batchsize))
print('# epoch: {}'.format(args.epoch))
print('')


class DistributedBoW(chainer.Chain):

    def __init__(self, n_vocab, n_docs, n_units, loss_func, doc2word_func):
        super(DistributedBoW, self).__init__()
        with self.init_scope():
            self.embed = L.EmbedID(
                n_vocab + n_docs, n_units, initialW=I.Uniform(1. / n_units))
            self.doc2word_func = doc2word_func
            self.loss_func = loss_func

    def __call__(self, x, doc, context, doc_length):
        window = context.shape
        shape = doc.shape
        d = F.broadcast_to(doc[:, None], (shape[0], window[1]))
        d = F.reshape(d, (shape[0] * window[1],))
        e = F.reshape(context, (shape[0] * window[1],))
        d = self.embed(d)

        x = F.broadcast_to(x[:, None], (shape[0], window[1]))
        x = F.reshape(x, (shape[0] * window[1],))

        gamma = 1
        inverse = F.broadcast_to(gamma / doc_length[:, None], (shape[0], window[1]))
        inverse = F.reshape(inverse, (shape[0] * window[1],))
        regularization = np.dot(inverse, LA.norm(d.data, axis=1))
        loss = self.doc2word_func(d, x) - regularization
        x = self.embed(x)
        loss += self.loss_func(x, e)

        reporter.report({'loss': loss}, self)
        return loss


class SoftmaxCrossEntropyLoss(chainer.Chain):

    def __init__(self, n_in, n_out):
        super(SoftmaxCrossEntropyLoss, self).__init__()
        with self.init_scope():
            self.out = L.Linear(n_in, n_out, initialW=0)

    def __call__(self, x, t):
        return F.softmax_cross_entropy(self.out(x), t)


class WindowIterator(chainer.dataset.Iterator):
    def __init__(self, text, label, window, batch_size, docid2length, repeat=True):
        self.text = np.array(text, np.int32)
        self.label = np.array(label, np.int32)
        self.batch_size = batch_size
        self.epoch = 0
        self.is_new_epoch = False
        self._repeat = repeat
        self.window = window
        self.order = np.random.permutation(
            len(text) - window * 2).astype(np.int32)
        self.order += window
        self.current_position = 0
        self.docid2length = docid2length

    def __next__(self):
        if not self._repeat and self.epoch > 0:
            raise StopIteration

        i = self.current_position
        i_end = i + self.batch_size
        position = self.order[i: i_end]
        w = np.random.randint(self.window - 1) + 1
        offset = np.concatenate([np.arange(-w, 0), np.arange(1, w + 1)])
        pos = position[:, None] + offset[None, :]
        context = self.text.take(pos)
        doc = self.label.take(position)
        doc_length = np.array([self.docid2length[did] for did in doc], dtype=np.float32)
        center = self.text.take(position)

        if i_end >= len(self.order):
            np.random.shuffle(self.order)
            self.epoch += 1
            self.is_new_epoch = True
            self.current_position = 0
        else:
            self.is_new_epoch = False
            self.current_position = i_end

        return center, doc, context, doc_length

    @property
    def epoch_detail(self):
        return self.epoch + float(self.current_position) / len(self.order)

    def serialize(self, serializer):
        self.current_position = serializer('current_position',
                                           self.current_position)
        self.epoch = serializer('epoch', self.epoch)
        self.is_new_epoch = serializer('is_new_epoch', self.is_new_epoch)
        if self._order is not None:
            serializer('_order', self._order)


def convert(batch, device):
    center, doc, context, docid2length = batch
    if device >= 0:
        center = cuda.to_gpu(center)
        doc = cuda.to_gpu(doc)
        context = cuda.to_gpu(context)
        docid2length = cuda.to_gpu(docid2length)
    return center, doc, context, docid2length


if args.gpu >= 0:
    cuda.get_device_from_id(args.gpu).use()

with open('./sample/sample_text.txt', 'r') as f:
    text = []
    l_text = []
    vocab = {}
    count = 0
    documents = []
    for item in f:
        item = item.rstrip()
        tmp = item.split(' ')
        # 1行の単語数を追加
        l_text.append(len(tmp))
        # 文書をループ
        for word in tmp:
            # 単語とそのIDを格納する辞書を作成
            if word not in vocab:
                vocab[word] = count
                count += 1
        # 文書をそれぞれ単語IDのリストに変換
        text += [vocab[word] for word in tmp]
        documents.append([vocab[word] for word in tmp])

with open('./sample/sample_title.txt', 'r') as f:
    title = []
    docs = {}
    for doc in f:
        doc = doc.rstrip()
        if doc not in docs:
            # 文書IDは単語IDと重複しないようにしている
            docs[doc] = count
            count += 1
        # 文書IDを格納
        title.append(docs[doc])

len_label = sum(l_text)

# 文書の単語数分だけタイトルラベルを用意する
# 例: 文書0が3個、文書1が4個、文書2が2個の場合
# [0, 0, 0, 1, 1, 1, 1, 2, 2]
label = np.zeros((len_label,))
cursor_label = 0
for i, j in enumerate(l_text):
    tmp = np.broadcast_to(title[i], (j,))
    label[cursor_label:cursor_label + j] = tmp
    cursor_label += j

# 単語IDから単語を復元するための辞書の用意
index2word = {wid: word for word, wid in six.iteritems(vocab)}
# タイトルIDからタイトルを復元するための辞書の用意
index2doc = {did: doc for doc, did in six.iteritems(docs)}
# タイトルIDから文書の長さを復元するための辞書の用意
docid2length = {}
for index, did in enumerate(sorted(list(docs.values()))):
    docid2length[did] = l_text[index]

# np.arrayに変換
text = np.asarray(text).astype(np.int32)
label = label.astype(np.int32)

# 単語ごとの出現文書数をNegativeSamplingに渡す
df = {}
for wid in index2word.keys():
    df[wid] = 0
    for i in range(len(documents)):
        if wid in documents[i]:
            df[wid] += 1
for wid in index2doc.keys():
    df[wid] = 1

n_vocab = len(vocab)
n_docs = len(docs)

print('n_vocab: %d' % n_vocab)
print('n_docs: %d' % n_docs)
print('data length: %d' % len(text))

cs = [df[w] for w in range(len(df))]
loss_func = L.NegativeSampling(args.unit, cs, args.negative_size)
loss_func.W.data[...] = 0
doc2word_func = L.NegativeSampling(args.unit, cs, 1)
doc2word_func.W.data[...] = 0

model = DistributedBoW(n_vocab, n_docs, args.unit, loss_func, doc2word_func)

if args.gpu >= 0:
    model.to_gpu()

optimizer = O.Adam()
optimizer.setup(model)

train_iter = WindowIterator(text, label, args.window, args.batchsize, docid2length)
updater = training.StandardUpdater(
    train_iter, optimizer, converter=convert, device=args.gpu)
trainer = training.Trainer(updater, (args.epoch, 'epoch'), out=args.out)
trainer.extend(extensions.LogReport())
trainer.extend(extensions.PrintReport(['epoch', 'main/loss']))
trainer.extend(extensions.ProgressBar())
trainer.run()

with open('word2vec.model', 'w') as f:
    f.write('%d %d\n' % (len(index2word), args.unit))
    w = cuda.to_cpu(model.embed.W.data[:n_vocab])
    for i, wi in enumerate(w):
        v = ','.join(map(str, wi))
        f.write('%s,%s\n' % (index2word[i], v))

with open('doc2vec.model', 'w') as f:
    f.write('%d %d\n' % (len(index2doc), args.unit))
    w = cuda.to_cpu(model.embed.W.data[n_vocab:])
    for i, wi in enumerate(w, start=n_vocab):
        v = ','.join(map(str, wi))
        f.write('%s,%s\n' % (index2doc[i], v))

サンプルテキストでの出力結果

chainer-doc2vecに置いているsample_text.txtとsample_title.txtで試してみた。

PV-DBOWでの学習結果

>> Anarchism
query: Anarchism
A: 0.23314246535301208
Autism: 0.22692044079303741
Aristotle: 0.21999907493591309
An American in Paris: 0.19898760318756104
Abraham Lincoln: 0.19616368412971497
>> A
query: A
Albedo: 0.32056179642677307
Aristotle: 0.3158291280269623
Autism: 0.3155941367149353
An American in Paris: 0.2853461503982544
Achilles: 0.2599610686302185
>> Albedo
query: Albedo
Autism: 0.33208969235420227
A: 0.32056179642677307
Aristotle: 0.2921576499938965
Alabama: 0.2647320032119751
An American in Paris: 0.24834167957305908

今回のEPV-DRJの結果

>> Anarchism
query: Anarchism
Autism: 0.15463200211524963
A: 0.08547578752040863
Albedo: 0.07183104753494263
An American in Paris: 0.005674820393323898
Aristotle: -0.011234864592552185
>> A
query: A
Albedo: 0.3267810344696045
An American in Paris: 0.3043128550052643
Aristotle: 0.1921233981847763
Achilles: 0.10536286234855652
Academy Award for Best Production Design: 0.09257571399211884
>> Albedo
query: Albedo
A: 0.3267810344696045
An American in Paris: 0.268435537815094
Autism: 0.14453238248825073
Achilles: 0.12083987146615982
Abraham Lincoln: 0.09255712479352951

正直サンプルデータをちゃんと読んでいないから微妙だが、Anarchismから見てAとAlbedoが同じくらいの類似度になっているのは良い感じがする。
PV-DBOWの場合はよくわからないが、AとAlbedoとの距離が近いのにAnarchismから見てAとAlbedoの類似度が全然違う点が違和感がある。
なので、比較的うまく行っているようには見える。

やはりもっといい感じのデータセットを使って実験しないと実装があってそうか微妙だ…。

あとgensimのように既存のドキュメント間の類似度だけではなく、文書のクエリを投げつけたらそれっぽい文書が返ってくるようにしないと検索システムとしては使い物にならなさそう。

終わりに

気力の都合上、論文中の実験結果などは省きました。ごめんなさい。

実装がネット上で見当たらなかったので、泣く泣く実装もどきをしてみました。

数学が全くわかっていないまま、算数力だけで論文を読むのきついなという気持ちになっているので、情報系の学生の方はちゃんと数学を勉強しないと私のようになってしまうので、数学は頑張ってほしい。

特に『Neural Word Embedding as Implicit Matrix Factorization』のImplicit Matrix Factorizationがなんのことをなのかわからず、この記事では省いてしまいました。

さらに自然言語処理言語学的な知識もないと苦しいのかもしれない。統語的関係がどうのとかこの記事に書いているがいまいちよくわかっていない。

読んでいるだけだと理解できた気になっていたが、説明しようとすると全く自分が理解できていないことに気づいてしまい、締め切りも近づいていることに気がついて悲しいことになりました。

正直あまり理解できないところが多いので、間違いがあれば誰か教えてください。

参考文献

Analysis of the Paragraph Vector Model for Information Retrieval
https://ciir-publications.cs.umass.edu/getpdf.php?id=1242

Learning Word Representations by Jointly Modeling Syntagmatic and Paradigmatic Relations
https://pdfs.semanticscholar.org/3393/fc9456087efbe7b5375e7ea98d9067c4cc75.pdf

Neural Word Embedding as Implicit Matrix Factorization
https://papers.nips.cc/paper/5477-neural-word-embedding-as-implicit-matrix-factorization.pdf

chainer-doc2vec
https://github.com/monthly-hack/chainer-doc2vec

Efficient Estimation of Word Representations inVector Space
https://arxiv.org/pdf/1301.3781v3.pdf

Distributed Representations of Sentences and Documents
https://cs.stanford.edu/~quocle/paragraph_vector.pdf

Understanding Inverse Document Frequency: On theoretical arguments for IDF
http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.438.2284&rep=rep1&type=pdf

Learning Word Representations by Jointly Modeling Syntagmatic and Paradigmatic Relations
https://pdfs.semanticscholar.org/3393/fc9456087efbe7b5375e7ea98d9067c4cc75.pdf

*1:完全に余談だが、頻繁に分散表現の類似度を測るのにcos類似度を使うことが多いが、cos類似度ってベクトルの角度しかみていないという理解なので、果たして角度だけの情報で本当に大丈夫なのだろうかと心配になる。