NIPS2017論文読みメモ: Inverse Reward Design(その3)

NIPS2017論文読み会に参加するので論文を読みたいと思います。今回読むのは以下です。

Dylan Hadfield-Menell, Smitha Milli, Pieter Abbeel, Stuart Russell and Anca Dragan. Inverse Reward Design. arXiv: 1711:02827, 2017. https://arxiv.org/abs/1711.02827
※ 以下、キャラクターが会話します。それぞれの原作とは関係ありません。論文内容の解釈誤りは本ブログ筆者に帰属します。
前回:その2 / 次回:その4
f:id:cookie-box:20180101155919p:plain:w60

前回の最後で、 P(w=w^{\ast} | \tilde{w}, \tilde{M}) つまり環境モデルとプロキシ報酬所与の下での真の報酬の分布の式まで導出したねー。でも、この式に出てくる分母の計算が問題なんだ。

f:id:cookie-box:20180101155951p:plain:w60

分母?  \int _{\tilde{w}} \exp(\beta w^{\top} \tilde{\phi} ) d \tilde{w} でしたね。積分する対象は、「あるプロキシ報酬  \tilde{w} に基づいた方策で行動する下で、報酬関数  w に基づいて得られる報酬の期待値」の  \beta 倍のエクスポネンシャルですね。積分区間は…ありうるプロキシ報酬全て…。

f:id:cookie-box:20180101155919p:plain:w60

うん。前回、報酬になりうる関数がなす空間って  \mathbb{R}^d なんじゃない?ってつぶやいちゃったけど、もしそうだったら積分は発散しちゃうかもね。もっと積分領域が絞れたとしても、フツウに大変な積分なんだよね。だから、まともに計算するのはあきらめて、近似的に計算するよ。この論文では2つの近似計算法が挙げられていて、1つ目は、この積分を有限個のサンプル  \{w_i \} に対する和で代替する。

f:id:cookie-box:20180101155951p:plain:w60

結構単純ですね。

f:id:cookie-box:20180101155919p:plain:w60

ちょっと工夫するみたいだけどねー。それで、2つ目の方法は…その前に、逆強化学習に触れておかないとにゃー。瑞希ちゃん、逆強化学習(Inverse Reinforcement Learning; IRL)は知ってるかにゃ?

f:id:cookie-box:20180101155951p:plain:w60

知りません。逆強化学習…逆…何が逆?

f:id:cookie-box:20180101155919p:plain:w60

逆強化学習は、この論文の直接的な先行研究になるね。報酬関数って実はちゃんとわからないってゆー問題意識はこの論文と同じ。それで、逆強化学習では、理想的な行動を与えて、そこから報酬関数を推定しようとするんだよね。

  • この論文(逆報酬デザイン;IRD)でも、真の報酬を推定する過程で、プロキシ報酬に基づいた方策がどんな行動系列を選択するかを考えるから、逆強化学習と逆報酬デザインはゴールが一緒でスタート地点が違うみたいな感じだねー。
  • IRL(逆強化学習)概ねよい行動→ 真の報酬   
    IRD(逆報酬デザイン)概ねよい報酬 →概ねよい行動→ 真の報酬   

f:id:cookie-box:20180101155951p:plain:w60

行動のお手本を示して、報酬を逆算しようとするのですか。強化学習は報酬を与えて行動させる、逆強化学習は行動を与えて報酬を求める、確かに、逆です。…でも、理想的な行動がわかっているなら、そもそも強化学習が必要なのかって気もします。もちろん、理想的な行動のデータセットはあらゆる状況を網羅していないかもしれないので、一度報酬を逆算しなければどんな状況でも自動的に行動するエージェントをつくれないのかもしれません。でも、逆算した報酬はあくまで用意したデータセット上の行動を再現するだけなので、データセットに現れないような状況には、弱そう。そうなると、逆算した報酬で学習したエージェントをデプロイして、好ましくない行動をするようだったらそれを修正する行動をお手本に追加して報酬を逆算し直す、というような、反復が必要そう。

f:id:cookie-box:20180101155919p:plain:w60

んー、その辺はなんか工夫していると思うんだけど、逆強化学習がどうやって報酬関数の精度を上げているかは、あたしもよく知らないんだよねー。でも、そーゆー、「報酬関数ってわからない、だから、理想的な行動を与えて報酬を逆算しちゃえ、でも、理想的な行動って与えきれる?」みたいな流れがあってこの論文があると思うんだよね。この論文のイントロはあくまで「報酬って設計ミスっちゃうよね」って感じなんだけど。でも、3節では先行研究に触れられていて、「究極的には IRL(逆強化学習)と IRD(逆報酬デザイン=この論文)は相互補完的につかいたい」ってあるんだ。

f:id:cookie-box:20180101155951p:plain:w60

相互補完的に?

f:id:cookie-box:20180101155919p:plain:w60

…上手い喩えじゃないかもしれないけど、ママの料理に、注文つけるとするじゃん。どうしてほしいのか明らかなときは「もっと塩を入れてよー」とか、具体的な行動の仕方を伝えればいいよね。それのが確実だし。でも、具体的にどうするべきかは明らかじゃないけど、おいしくないってゆーときもあるよね。そのときは「微妙」っていう評価を伝えるしかない。だから、ママっていうお料理エージェントには、行動の仕方を伝えるだけとか、評価を伝えるだけとかより、どっちも組み合せて伝えられた方がいい。ってゆーことじゃないかな。

f:id:cookie-box:20180101155951p:plain:w60

なるほど、そうですね。人間相手でも、後輩に何か学んでもらうときなど、具体的なやり方を教えることも、評価を伝えることもありますね。

f:id:cookie-box:20180101155919p:plain:w60

話を戻そうかな。いま、ありうるプロキシ報酬上での積分をどうしよっかってゆー話をしてたよね。でもさ、そんないかにも膨大そーなプロキシ報酬上で積分なんてしたくない。だから、プロキシ報酬上で積分しない道を探す。

f:id:cookie-box:20180101155951p:plain:w60

プロキシ報酬上で積分しない? いえ、でも、「あるプロキシ報酬  \tilde{w} に基づいた方策で行動する下で、報酬関数  w に基づいて得られる報酬の期待値」をプロキシ報酬上で積分しなくてはという話だったのでは…あれ…「行動する下で」?…プロキシ報酬上での積分を行動系列上の積分にできればよい?…行動系列のパターンも膨大かもしれませんが、プロキシ報酬関数の空間などよりはまだましなはず。そうか、いま一ノ瀬さんが逆強化学習の話に触れなければと言ったのは、「概ねよい行動」から出発する逆強化学習がここで利用できるという意味なのでは?

f:id:cookie-box:20180101155919p:plain:w60

当ったり~♪ まさにここから逆強化学習にしちゃうんだ。でも、あるプロキシ報酬に基づいた方策から行動のお手本データをたくさん取るんじゃなくて、あくまで行動系列の特徴量の期待値に丸めちゃうから、近似解なんだけどねー。それで、逆強化学習(のベイズ的解法)では、いま言ってくれたように、行動系列上で積分するよー。この解法では、プロキシ報酬をどれくらい上手く設計できるかのパラメータ  \beta が、あるプロキシ報酬に基づいた方策から取り出すお手本データの個数に相当するって解釈ができるんだ。プロキシ報酬が上手く設計できるって信じているときほど、プロキシ報酬に基づいた方策からたくさんのお手本を取り出すから、そこから推定される真の報酬関数もプロキシ報酬をかなり再現したものになるってわけだねー。ここまで IRD の定義と解法までみてきたから、次回は、実際に IRD で強化学習タスク(実験的なタスクだけどね)を解いたら従来の解法に比べてどんな風によかったかってゆー検証のセクションをみていこう。

f:id:cookie-box:20180101155951p:plain:w60

おー。

つづく

NIPS2017論文読みメモ: Inverse Reward Design(その2)

NIPS2017論文読み会に参加するので論文を読みたいと思います。今回読むのは以下です。

Dylan Hadfield-Menell, Smitha Milli, Pieter Abbeel, Stuart Russell and Anca Dragan. Inverse Reward Design. arXiv: 1711:02827, 2017. https://arxiv.org/abs/1711.02827
※ 以下、キャラクターが会話します。それぞれの原作とは関係ありません。論文内容の解釈誤りは本ブログ筆者に帰属します。
前回:その1 / 次回: その3
f:id:cookie-box:20180101155951p:plain:w60

前回は、強化学習の報酬関数を正しくデザインするのは難しい。だから、真の報酬関数がわからないことを認めて、真の報酬関数を推定しようというところまででした。次は、その推定方法を具体的に数式で追っていく、ですね。

f:id:cookie-box:20180101155919p:plain:w60

そうだったねー。推定方法の前に、まず問題の設定ってゆーか定義を確認していこう。まず、環境モデル(原著: world model を意訳)を以下で定義するにゃー。

  •  \tilde{M} = \langle \mathcal{S}, \mathcal{A}, T, H \rangle … 環境モデル
 \mathcal{S} は状態集合、 \mathcal{A} は行動集合、 T=T(s_{t+1}|s_t, a) はある状態である行動をとったときに次の状態がどうなるかの確率分布、 H \in \mathbb{Z}_{+} はエピソード終了までのステップ数だね。この環境モデルに報酬  r: \mathcal{S} \to \mathbb{R} も加えるとマルコフ決定過程  M = \langle \mathcal{S}, \mathcal{A}, r, T, H \rangle になるねー(あ、この論文では、報酬を状態行動系列  \xi の特徴ベクトル   \phi(\xi) w で重み付けした線形和として  r(\xi; w) = w^{\top} \phi(\xi) とかけるものと仮定するよー)。

f:id:cookie-box:20180101155951p:plain:w60

マルコフ決定過程  M を与えられたときに、それぞれの時間ステップでどの行動を選択していくべきか=方策を求めるのが通常の強化学習ですね。でも、今回は報酬  r がわかっていないという立場を取るのですよね。

f:id:cookie-box:20180101155919p:plain:w60

うん。でも真の報酬に結構近いプロキシ(代理)報酬  \tilde{r} は特定できることにするよ(結構近い、の意味は後でねー)。このとき、「逆報酬デザイン(Inverse Reward Design: IRD)」を  \langle \mathcal{R}, \tilde{M}, \tilde{\mathcal{R}}, \pi(\cdot | \tilde{r}, \tilde{M}), \tilde{r} \rangle と定義する。ここで、 \mathcal{R} は真の報酬になりうる関数がなす空間で、 \tilde{\mathcal{R}} はプロキシ報酬になりうる関数がなす空間ね(報酬関数がなす空間といっても、今回は報酬関数が  r(\xi; w) = w^{\top} \phi(\xi) とかけるものと仮定しているから、重みベクトルの次元のユークリッド空間  \mathbb{R}^d と考えればいいと思うんだけどねー)。 \pi(\cdot | \tilde{r}, \tilde{M}) はエージェント。方策と捉えていいんじゃないかなー。この状況を説明するとー、

  •  \tilde{M} … 環境モデルは特定できていて、
  •  \tilde{r} … プロキシ報酬もどーにか特定できていて、
  •  \pi(\cdot | \tilde{r}, \tilde{M}) … その環境モデルとプロキシ報酬の下での方策も特定できている。
こんな状況だね。この状況で、真の報酬  r^{\ast} の分布を求めるのがゴールになるよ。

f:id:cookie-box:20180101155951p:plain:w60

報酬関数がわからない立場をとるというのは他で聞いたことのない設定なので慣れませんが、状況は何となくわかりました。では、解法を教えてください。…わくわく。

f:id:cookie-box:20180101155919p:plain:w60

その前に、瑞希ちゃん、ベイズの定理は覚えてるかにゃー?

f:id:cookie-box:20180101155951p:plain:w60

ベイズの定理ですか。はい。よく、病気の検査を受けて陽性だった人が本当に病気である確率は?などという例で説明されますね。知りたいのは  P(病気 |陽性) なのですが、これは以下がわかれば求まります。

  • その病気である周辺確率:  P(病気)
  • 病気のときに陽性と診断される確率:  P(陽性 |病気)
  • 病気でないのに陽性と診断される確率:  P(陽性 |病気 ^C)
つまり、 P(陽性)=P(病気)P(陽性 |病気)+P(病気 ^C)P(陽性 |病気 ^C) と陽性と診断される確率に周辺化でき、あとは  P(病気 |陽性)=P(陽性 \cap病気) / P(陽性 )=P(陽性 |病気)P(病気) / P(陽性 ) とするだけです。このような式変形をベイズの定理といいます。…式の右辺と左辺で、縦棒 "given" の前と後ろが入れ替わるんだな。

f:id:cookie-box:20180101155919p:plain:w60

ふふ、今回も  P(\tilde{r} | r^{\ast}) を入れ替えて  P(r^{\ast} | \tilde{r}) を求めちゃうよー。これ以降、真の報酬とプロキシ報酬に対応する重みベクトルをそれぞれ  w^{\ast} \tilde{w} とするね。だから言い直すと  P(\tilde{w} | w^{\ast}) を入れ替えて  P(w^{\ast} | \tilde{w}) を求めるってことだねー。

f:id:cookie-box:20180101155951p:plain:w60

入れ替える?…そうか、私たちは真の報酬の下で学習したいのですが、真の報酬はわからない。代わりに利用できるのは、なるべく真の報酬に似せた、プロキシ報酬。それはまるで、なるべく正しく病気かどうかを診断しようとする検査みたいです。と考えれば、さっきのベイズの定理と同じ。だとしたら必要なのは、ある報酬関数  w になる周辺確率  P(w) と、ある報酬関数  w のときにプロキシ報酬  \tilde{w} が選択される確率  P(\tilde{w} | w) だぞ。…一ノ瀬さん、 P(\tilde{w} | w) がわかりません。

f:id:cookie-box:20180101155919p:plain:w60

あ、うん。そこは仮定を置くからねー。まず、方策所与の下で、状態行動系列  \xi が選択される確率を  \pi(\xi | \tilde{w}, \tilde{M}) \propto \exp (\tilde{w}^{\top} \phi(\xi)) とするんだ。最大報酬が得られる状態行動系列のみ選択されるってほど最適化はされていないけど、各状態行動系列が報酬の大きさに指数関数的に比例するように選択されるんだから、ほぼほぼ最適化されてるって状態だね。それで、プロキシ報酬  \tilde{w} は、真の報酬の期待値  E[ w^{\ast \top} \phi(\xi) | \tilde{w}, \tilde{M} ] を訓練環境  \tilde{M} で最大化するべく設計されたって考えるよ。それで、真の報酬に結構近い設計ができることにするんだ。つまり、 P(\tilde{w} | w^{\ast}, \tilde{M}) \propto \exp \bigl( \beta E[ w^{\ast \top} \phi(\xi) | \tilde{w}, \tilde{M} ] \bigr) が成り立つことにする。 \beta はどれくらいよい設計ができるかのパラメータだけど、もし  \beta \to \infty だったら、訓練環境  \tilde{M} で真の報酬の期待値を最大にするプロキシ報酬  \tilde{w} が必ず設計できる極限になるね。

f:id:cookie-box:20180101155951p:plain:w60

あ、 P(\tilde{w} | w) だ。

f:id:cookie-box:20180101155919p:plain:w60

そーだね。ここで、 w^{\ast} は期待値の外に出すことができるから、 \tilde{\phi} = E[ \phi(\xi) | \tilde{w}, \tilde{M} ] と定義するよー。すると、求める真の報酬の分布は、ベイズの定理より以下のようにかける。

 \displaystyle P(w=w^{\ast} | \tilde{w}, \tilde{M}) \propto P(\tilde{w} | w, \tilde{M}) P(w) = \frac{\exp (\beta w^{\top} \tilde{\phi})}{\int_{\tilde{w}} \exp (\beta w^{\top} \tilde{\phi} ) d \tilde{w} } P(w)
ここで、さっき瑞希ちゃんが説明してくれたときのように、 P(\tilde{w} | \tilde{M}) での規格化はしてないよ。ただ、 P(\tilde{w} | w, \tilde{M}) に対しては規格化が必要で、上の式の分母の積分がそれだね。この積分が厄介なんだ。これにどう対処しようかって話を、次回以降していくねー。

つづく

NIPS2017論文読みメモ: Inverse Reward Design(その1)

お正月ですがNIPS2017論文読み会に参加するので論文を読みたいと思います。今回読むのは以下です。

Dylan Hadfield-Menell, Smitha Milli, Pieter Abbeel, Stuart Russell and Anca Dragan. Inverse Reward Design. arXiv: 1711:02827, 2017. https://arxiv.org/abs/1711.02827
※ 以下、キャラクターが会話します。それぞれの原作とは関係ありません。論文内容の解釈誤りは本ブログ筆者に帰属します。
次回: その2
f:id:cookie-box:20180101155919p:plain:w60

強化学習エージェントを学習させるときの報酬関数って実はちゃんとわからないから、「報酬関数は『真の報酬関数』のある観測結果に過ぎない」という前提で学習させようという話みたいだねー。共著者に連なっている Russel や Abbeel は「逆強化学習」の定式化やその解法である「見習い学習」を提唱した人だにゃー。「これからの強化学習」の2.3節に載ってるね。

f:id:cookie-box:20180101155951p:plain:w60

強化学習の報酬の話ですか。Sutton の強化学習本には、「報酬を最大化することでエージェントが我々の目的を達成してくれるように報酬を与える必要がある」とありました。チェスをプレイするエージェントは勝負に勝ったときのみに報酬を得るべきであって、敵の駒を取ったときなどに報酬を与えると勝ちにつながらない駒取りばかりするおかしなエージェントになりかねないとか…。

f:id:cookie-box:20180101155919p:plain:w60

そーそー。そーいう報酬関数の設計ミスによる悪影響をこの論文では「(未特定の報酬による)ネガティブサイドエフェクト」と「報酬ハッキング」の2つ挙げているね。前者は考慮漏れにより望まないふるまいが引き起こされること、後者は報酬自体が望まないふるまいを引き起こすことっぽいかなー。いま瑞希ちゃんが言ってくれたのはまさに「報酬ハッキング」の例だねー。報酬ハッキングとして本文に挙げられている例は、

  • 「ゴミを吸引すること」に報酬を与えられたお掃除ロボが、ゴミをもっと吸引するために一度吸引したゴミを外に出してしまった。
  • 標的を撃ち落としながらゲームを周回するボートレースゲームで、標的を撃ち落とすことに報酬を与えたら、標的をずっと撃ち続けてコースを周回してくれなくなった。

f:id:cookie-box:20180101155951p:plain:w60

…なぜお掃除ロボが自らゴミを外に出せるような設計になっているのでしょうか。…そんな機能要らないぞ。

f:id:cookie-box:20180101155919p:plain:w60

…きっとロボで掃除したい場所がすごいごみだらけで、満杯になったごみをごみ箱に捨てるって行動も学習させたかったんじゃない? だから自分でゴミを外に出す機能も付けておいたんじゃないかなー。話を戻すと、もう1つの報酬による悪影響である「ネガティブサイドエフェクト」の例は、宝探しロボットに草むらを避けてほしくて草むらを進むことにマイナスの報酬を与えたら、草むらを避けて溶岩に突っ込んじゃったって(論文に挿絵が載ってるね)。溶岩に出くわすとわかっていたら溶岩には草むらよりもっと大きなマイナスの報酬を与えていたけど、想定していなかったってことだね。

f:id:cookie-box:20180101155951p:plain:w60

なるほど。でも、溶岩に出くわすレベルで想定外な事態は、チェスのエージェントには起こりえないと思います。ルールは、決まっているから。そもそも、「勝ったときに報酬を得る」というトリビアルな報酬デザインができるので、ネガティブサイドエフェクトや報酬ハッキングに悩まされる余地はありません。

f:id:cookie-box:20180101155919p:plain:w60

それはそーだよねー。でも、「これからの強化学習」の2.3節にあるように、一般に「目標状態や終端状態にだけ定義された報酬によって学習することは難しいことは多い」んだ。それは、状態空間が広い場合、まず初期方策からはじめて目標の終端状態にたどり着くのに時間がかかるのと、たどり着けてもだから序盤どう行動するべきかまでわかるにはまた時間がかかるんだよね。それに、もし終端状態に報酬を与えるだけでじゅうぶん学習できるだけの状態数だったとしても、一般的なタスクの終端状態は「勝ち or 負け」のようにシンプルじゃないよね。「まあまあよい終わり方」とか「もっとよい終わり方」とかあって、それらをどう評価するかはやっぱりトリビアルじゃないと思う。

f:id:cookie-box:20180101155951p:plain:w60

そうですね。一般的には報酬をどうデザインするべきかはトリビアルでなく、考慮漏れによるネガティブサイドエフェクトや、報酬の与えどころの誤りによる報酬ハッキングが起きかねないというのは理解できます。それで、この論文はどのようにしてこの問題に対処するというのでしょうか。

f:id:cookie-box:20180101155919p:plain:w60

それはねー、もう真の報酬がわからないことを認めるんだ。ヒトがデザインできるのはあくまでプロキシ報酬(代理報酬)で、真の報酬は推定しなければならない。マルコフ決定過程、プロキシ報酬関数、報酬関数候補の集合を所与として真の報酬関数を推定する問題をIRD: Inverse Reward Design(逆報酬デザイン)と定義するよ。

f:id:cookie-box:20180101155951p:plain:w60

待ってください。真の報酬がわからないというのはいいです。それはむしろ自然だと思います。でも、真の報酬がわからないのに真の報酬を推定するということができるのでしょうか。

f:id:cookie-box:20180101155919p:plain:w60

プロキシ報酬がある程度真の報酬に近いとか、エピソードが選択される確率が「最大エントロピー」の分布にしたがうとかの仮定は置くけど、事後分布を効果的に近似することができるみたいだよー。ここから先は式を追っていった方がいいかにゃー。

つづく

Keras で GAN の練習(その2)

前回の記事で GAN を動かしてみたのですが、実装があやしいのでまた別の記事を参考にしてみます。

参考文献

以下の記事を参考にします。やることは前回と同じで手書き数字の模造です。
MNIST Generative Adversarial Model in Keras

  • この記事ではディスクリミネータのことをアドバーサリアルモデルといっている。
    • 昨日の記事ではジェネレータ+ディスクリミネータのことをアドバーサリアルモデルといっていた。
  • 記事の初っ端から「freeze the weights in the adversarial part of the network, and train the generative network weights」、つまりディスクリミネータ部分のネットワークの重みを固定してジェネレータを訓練するといっているので、昨日の記事で気になった部分はこちらの記事では大丈夫そうです。
  • こちらの記事は Sequential モデルではなくて functional API をつかっています。
  • ディスクリミネータの出力層がの次元が2になっています。つまり、本物なら [0, 1] 、模造品なら [1, 0] を目標出力とします。
    • 2クラス分類では1次元にすることが多いと思っていました。どっちでもいいと思いますが。
    • ただ、2クラス分類で出力層を2次元にする場合、判定を誤ったときに「正しいクラスであると考えた度合いが小さかった」のか、「正しいクラスであると考えた度合いは大きかったが、それ以上に誤ったクラスだと考えた度合いが大きかった」のかの区別は付くと思います(最終出力がソフトマックスされる前をみれば)。それをみたい機会があるのかはわかりませんが。
実行結果

ジェネレータとディスクリミネータをがっちゃんこした状態が以下です。

_________________________________________________________________
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
input_3 (InputLayer)         (None, 100)               0
_________________________________________________________________
model_1 (Model)              (None, 28, 28, 1)         4341801
_________________________________________________________________
model_2 (Model)              (None, 2)                 9707266
=================================================================
Total params: 14,049,067
Trainable params: 13,970,367
Non-trainable params: 78,700
_________________________________________________________________

ただ肝心の訓練が上手くいっていないので、API はこのままに前回の記事のモデル構造を適用してみたいと思います。

スクリプト

元の記事は Keras のバージョンが手元より古いので、一部サポートされなくなってしまっていた機能がありました(BatchNormalization の mode=2)。

TypeError: The `mode` argument of `BatchNormalization` no longer exists. `mode=1`  and  `mode=2` are no longer supported.

Normalization Layers - Keras 1.2.2 Documentation
元の記事に比べて色々関数にくくり出しています。また、TensorFlow バックエンドの Keras 2.0 で動くように全体的に変更してあります。

# -*- coding: utf-8 -*-
import numpy as np
from keras.models import Model
from keras.layers import Input
from keras.layers.core import Reshape, Dense, Dropout, Activation, Flatten
from keras.layers.convolutional import Conv2D, MaxPooling2D, ZeroPadding2D, UpSampling2D
from keras.layers.advanced_activations import LeakyReLU
from keras.layers.normalization import *
from keras.activations import *
from keras.optimizers import *
from keras.datasets import mnist
import matplotlib.pyplot as plt
from tqdm import tqdm

# ---------- ネットワークの訓練可能オンオフを制御する ----------
def make_trainable(net, trainable=False):
  net.trainable = trainable
  for l in net.layers:
    l.trainable = trainable

# ---------- ジェネレータの生成 ----------
def create_generator(opt):
  g_input = Input(shape=[100])
  H = Dense(14*14*200, init='glorot_normal')(g_input)
  H = BatchNormalization()(H)
  H = Activation('relu')(H)
  H = Reshape([14, 14, 200])(H)
  H = UpSampling2D(size=(2, 2))(H)
  H = Conv2D(100, (3, 3), padding='same', init='glorot_uniform')(H)
  H = BatchNormalization()(H)
  H = Activation('relu')(H)
  H = Conv2D(50, (3, 3), padding='same', init='glorot_uniform')(H)
  H = BatchNormalization()(H)
  H = Activation('relu')(H)
  H = Conv2D(1, (1, 1), padding='same', init='glorot_uniform')(H)
  g_V = Activation('sigmoid')(H)
  generator = Model(g_input, g_V)
  generator.compile(loss='binary_crossentropy', optimizer=opt)
  generator.summary()
  return generator

# ---------- ディスクリミネータの生成 ----------
def create_discriminator(shp, dropout_rate, dopt):
  d_input = Input(shape=shp)
  H = Conv2D(256, (5, 5), subsample=(2, 2), padding='same', activation='relu')(d_input)
  H = LeakyReLU(0.2)(H)
  H = Dropout(dropout_rate)(H)
  H = Conv2D(512, (5, 5), subsample=(2, 2), padding='same', activation='relu')(H)
  H = LeakyReLU(0.2)(H)
  H = Dropout(dropout_rate)(H)
  H = Flatten()(H)
  H = Dense(256)(H)
  H = LeakyReLU(0.2)(H)
  H = Dropout(dropout_rate)(H)
  d_V = Dense(2, activation='softmax')(H)
  discriminator = Model(d_input,d_V)
  discriminator.compile(loss='categorical_crossentropy', optimizer=dopt)
  discriminator.summary()
  return discriminator

# ---------- アドバーサリアルモデルの生成 ----------
def create_adversarial_model(generator, discriminator, opt):
  gan_input = Input(shape=[100])
  H = generator(gan_input)
  gan_V = discriminator(H)
  GAN = Model(gan_input, gan_V)
  GAN.compile(loss='categorical_crossentropy', optimizer=opt)
  GAN.summary()
  return GAN

# ---------- ディスクリミネータを1バッチ分トレーニングする ----------
def train_discriminator_1batch(discriminator, X_train, batch_size):
  image_batch = X_train[np.random.randint(0, X_train.shape[0], size=batch_size),:,:,:]
  noise_gen = np.random.uniform(0, 1, size=[batch_size, 100])
  generated_images = generator.predict(noise_gen)
  
  X = np.concatenate((image_batch, generated_images))
  y = np.zeros([2*batch_size, 2])
  y[:batch_size, 1] = 1 # 本物データのときのディスクリミネータの期待出力は y=[0, 1]
  y[batch_size:, 0] = 1 # 模造データのときのディスクリミネータの期待出力は y=[1, 0]
  
  make_trainable(discriminator, True)
  d_loss = discriminator.train_on_batch(X, y)
  return d_loss

# ---------- アドバーサリアルモデルを1バッチ分トレーニングする ----------
def train_GAN_1batch(discriminator, GAN, X_train, batch_size):
  noise_tr = np.random.uniform(0, 1, size=[batch_size, 100])
  y = np.zeros([batch_size, 2])
  y[:, 1] = 1 # 本物データと判断してほしいので期待出力は y=[0, 1]
  
  make_trainable(discriminator, False) # ディスクリミネータの重み係数更新は忘れずにオフ
  g_loss = GAN.train_on_batch(noise_tr, y)
  return g_loss

# ---------- 損失をプロットする ----------
def plot_loss(losses, filename='loss.png'):
  plt.figure(figsize=(10,8))
  plt.plot(losses["d"], label='discriminitive loss')
  plt.plot(losses["g"], label='generative loss')
  plt.legend()
  plt.savefig(filename)
  plt.close('all')

# ---------- ジェネレータが出力する模造データをプロットする ----------
def plot_gen(noise, generator, filename='result.png'):
  generated_images = generator.predict(noise)
  plt.figure(figsize=(10,10))
  for i in range(generated_images.shape[0]):
    plt.subplot(4, 4, i+1)
    img = generated_images[i,:,:,:]
    img = np.reshape(img, [28, 28])
    plt.imshow(img, cmap='gray')
    plt.axis('off')
  plt.tight_layout()
  plt.savefig(filename)
  plt.close('all')

# ---------- ネットワークをトレーニングする(メイン) ----------
def train_GAN(generator, discriminator, GAN, X_train, batch_size=32, steps=50):
  # ディスクリミネータの事前学習
  for i in range(5):
    train_discriminator_1batch(discriminator, X_train, batch_size=10)
  
  losses = {"d":[], "g":[]} # 損失の記録用
  noise_for_plot = np.random.uniform(0, 1, size=[16, 100]) # 途中経過出力用のノイズ
  for i in tqdm(range(steps)):
    # ディスクリミネータの学習
    d_loss = train_discriminator_1batch(discriminator, X_train, batch_size)
    losses["d"].append(d_loss)
    
    # アドバーサリアルモデルの学習
    g_loss = train_GAN_1batch(discriminator, GAN, X_train, batch_size)
    losses["g"].append(g_loss)
    
    # プロット
    if i%25 == 25-1:
      plot_loss(losses)
      plot_gen(noise_for_plot, generator, "result_%d.png" % i)

# ========================= メイン処理 =========================
if __name__ == '__main__':
  # データ読み込みとプレ処理
  img_rows, img_cols = 28, 28
  (X_train, y_train), (X_test, y_test) = mnist.load_data()
  X_train = X_train[np.where(y_train == 1)] # 「1」のみにしぼる
  X_train = X_train.reshape(X_train.shape[0], img_rows, img_cols, 1).astype('float32')
  X_train /= 255.0
  shp = X_train.shape[1:]
  
  # 訓練設定
  dropout_rate = 0.25
  dopt = Adam(lr=1e-3)
  opt = Adam(lr=1e-4)
  
  # ネットワークの生成
  generator = create_generator(opt)
  discriminator = create_discriminator(shp, dropout_rate, dopt)
  GAN = create_adversarial_model(generator, discriminator, opt)
  
  # ネットワークの訓練
  train_GAN(generator, discriminator, GAN, X_train)
その他

tqdm という進捗バーを表示してくれるパッケージをはじめて知りました。
Pythonで進捗バーを表示する(tqdm) - naritoブログ

Keras で GAN の練習

今週強化学習アーキテクチャ勉強会で GAN の話を聴いてきたので(勉強会自体は GAN ではなくて GAN の手法の強化学習への応用が主題ですが)、GAN を手元で動かしてみたいと思います。

参考文献

「keras gan example」と検索すると色々出てきますが、以下の記事を参考にしたいと思います。今回書いたスクリプトはほぼ以下の記事と同じです(ただし、訓練ルールがおかしい可能性があります;後述)。
GAN by Example using Keras on Tensorflow Backend – Towards Data Science

GAN(Generative Adversarial Networks)って何

※ これは参考文献の記述というより自分で適当に書いています。

  • 何かの模造品が出てくるジェネレータです。例えば「日本人の顔っぽい画像」を生成します。もちろん、このジェネレータをつくるには日本人の顔の画像のサンプルデータセットを用意して、どんな画像を出せば日本人の顔っぽいのかを訓練する必要があります。
  • このジェネレータを訓練する過程で、模造品と本物を識別するディスクリミネータというのを一緒に合わせて訓練するのがミソです。
    • ジェネレータは乱数から模造データへの写像です。
    • ディスクリミネータはデータ(模造でも本物でも)から本物らしい確率 0~1 への写像です。
    • つまり、ジェネレータ とディスクリミネータをがっちゃんこすると乱数から本物らしい確率 0~1 への写像です(以下、がっちゃんこしたのをアドバーサリアルモデルとよびます)。
    • 訓練は以下の繰り返しです。
      • まず n 個の乱数を用意して、ジェネレータに入れて n 個の模造データを生成します。
      • 次にディスクリミネータを訓練します; n 個の模造データと n 個の本物データを受け取って、模造品を入れたら0、本物を入れたら1が出てくるように訓練します。
      • 次にアドバーサリアルモデルを訓練します; また新しく n 個の乱数を用意して、どの乱数を入れても1が出てくるように訓練します。

※ ただし、アドバーサリアルモデルの訓練のときディスクリミネータ部分のネットワークの重みは固定しておかないとディスクリミネータまで更新されてしまいます。参考の記事のスクリプトはディスクリミネータが2回更新されているようにみえるので確認中です。

今回やること
  • MNIST から訓練して模造手書き数字を生成します。
    • ジェネレータは100次元の乱数から模造手書き数字を生成します。最後の活性化を除き活性化の直前に必ず Batch Normalization します(通常の訓練データからの訓練と違って、入力が一様乱数だから入念な安定化が必要なのですかね?)。
    • ディスクリミネータは普通の手書き数字分類と違って、maxプーリングしません。maxプーリングは手書き数字の「4」が少しずれたものでも「4」と識別てきるようにする効果がありますが、今回ディスクリミネータに求められるのはそういう能力じゃないからということなのですかね?
実行結果

ディスクリミネータ → アドバーサリアルモデルの順に何か訓練は進んでいるようです(?)。

0: [D loss: 0.693790, acc: 0.447266]  [A loss: 2.802749, acc: 0.000000]
1: [D loss: 0.605273, acc: 0.958984]  [A loss: 5.334227, acc: 0.000000]
2: [D loss: 0.443096, acc: 0.882812]  [A loss: 1.039907, acc: 0.058594]
3: [D loss: 1.566417, acc: 0.500000]  [A loss: 14.592390, acc: 0.000000]
4: [D loss: 0.264856, acc: 0.890625]  [A loss: 0.048600, acc: 1.000000]
5: [D loss: 0.341633, acc: 0.830078]  [A loss: 7.195857, acc: 0.000000]
6: [D loss: 0.095739, acc: 0.990234]  [A loss: 0.227625, acc: 0.953125]
7: [D loss: 0.088694, acc: 1.000000]  [A loss: 0.125637, acc: 0.992188]
8: [D loss: 0.090487, acc: 0.992188]  [A loss: 0.058483, acc: 1.000000]

41ステップ目からなんかディスクリミネータが識別しづらくなっているようです。41ステップ目や43ステップ目は D の acc が 0.5 で A の acc が 1.0 なのでディスクリミネータが全てのデータを本物と判定してしまっているようです。

36: [D loss: 0.008699, acc: 0.998047]  [A loss: 0.000055, acc: 1.000000]
37: [D loss: 0.009811, acc: 0.998047]  [A loss: 0.000191, acc: 1.000000]
38: [D loss: 0.012975, acc: 0.998047]  [A loss: 0.000129, acc: 1.000000]
39: [D loss: 0.010878, acc: 0.998047]  [A loss: 0.016210, acc: 0.992188]
40: [D loss: 0.130817, acc: 0.957031]  [A loss: 16.118101, acc: 0.000000]
41: [D loss: 5.751718, acc: 0.500000]  [A loss: 0.000000, acc: 1.000000]
42: [D loss: 1.771331, acc: 0.587891]  [A loss: 4.267523, acc: 0.250000]
43: [D loss: 7.854210, acc: 0.500000]  [A loss: 0.000000, acc: 1.000000]
44: [D loss: 7.210688, acc: 0.500000]  [A loss: 4.953451, acc: 0.253906]
45: [D loss: 6.669549, acc: 0.500000]  [A loss: 11.922536, acc: 0.015625]

50ステップ後のジェネレータ出力は以下です。なんかもにょもにょしていて全然手書き数字ではないです。

f:id:cookie-box:20171216161856p:plain:w380

参考文献には1000ステップくらい回して手書き数字っぽい出力を学習できている例が載っていますが、このまま学習を続けるとよくなるのでしょうか。いま作業しているマシンには GPU 積んでいないのでやるなら一晩かけてみないとよくわかりません。
しかし、GPU がなくても、もうちょっと学習が上手くいっているのかどうか知りたいものです。そこで、以下の強硬手段をとります。

  • 数字が10種類もあるのがよくない。ここはもう数字の「1」のみに絞る。棒くらい学んでほしい。
  • ディスクリミネータが4回も畳み込んでいる。畳み込みすぎ。時間がかかるので最後の畳込みを削る。

このようにして実行してみると着実に数字の「1」への道を歩んでいるように見えます。よかった。

25ステップ
f:id:cookie-box:20171216210234p:plain:w210
50ステップ
f:id:cookie-box:20171216210259p:plain:w210
75ステップ
f:id:cookie-box:20171216210320p:plain:w210
100ステップ
f:id:cookie-box:20171216210609p:plain:w210
125ステップ
f:id:cookie-box:20171216210648p:plain:w210
150ステップ
f:id:cookie-box:20171216210801p:plain:w210
175ステップ
f:id:cookie-box:20171216211527p:plain:w210
200ステップ
f:id:cookie-box:20171216211539p:plain:w210
225ステップ
f:id:cookie-box:20171216211550p:plain:w210

スクリプト

変数名と処理の順序を一部変更している以外参考文献のコードと同じです。以下注意書きです。

  • 参考文献の記事は後半以降 model という言葉を「ネットワーク構造 + 訓練ルール(損失関数と勾配法)」という意味合いでつかっているようです。スクリプトでもネットワーク構造のみ(self.D)とネットワーク構造+訓練ルール(self.DM)を別のメンバとして持っています。
    • 通常の訓練データの識別や回帰ではこれをわざわざ分けないですが、GAN の訓練では「ディスクリミネータ構造 + ディスクリミネータの訓練ルール」による訓練と「ジェネレータ構造 + ディスクリミネータ構造 + アドバーサリアルモデルの訓練ルール」による訓練の2種類の訓練をしなければならないので、構造は構造単体で持っておかなければならないのですね。
  • Windows などでユーザ名が日本語になっているなどすると(変えたいのですが…)一時ファイルのパスに日本語が交じりデータのダウンロードに失敗します。環境変数 TMP, TEMP を日本語のないパスに変更すると解決します(import - windows10環境でtensorflowを動かしたい(69477)|teratail)。
  • MNIST_DCGAN クラスに1種類の数字に絞るかどうかのコメントアウトがあるので適宜変更してください。さらに計算量を削りたい人は、上でやったようにディスクリミネータの最後の Conv2D と Dropout を削るとか、バッチサイズを小さくするとかするといいと思います。
# -*- coding: utf-8 -*-
import numpy as np
from tensorflow.examples.tutorials.mnist import input_data
from keras.layers import Conv2D, LeakyReLU, Dense, Flatten, Dropout
from keras.layers import BatchNormalization, Activation, Conv2DTranspose, UpSampling2D, Reshape
from keras.models import Sequential
from keras.optimizers import Adam, RMSprop
import matplotlib.pyplot as plt

class DCGAN:
  def __init__(self, img_rows=28, img_cols=28, channel=1):
    self.img_rows = img_rows
    self.img_cols = img_cols
    self.channel = channel
    self.D = None   # ディスクリミネータ(のネットワーク構造だけ)
    self.G = None   # ジェネレータ(のネットワーク構造だけ)
    self.DM = None  # ディスクリミネータモデル(ディスクリミネータ + 訓練ルール)
    self.AM = None  # アドバーサリアルモデル(ジェネレータ + ディスクリミネータ + 訓練ルール)

  def discriminator_network(self): # ディスクリミネータ: 畳み込み x 4回
    if self.D:
      return self.D
    self.D = Sequential()
    depth = 64
    dropout = 0.4
    input_shape = (self.img_rows, self.img_cols, self.channel)
    self.D.add(Conv2D(depth*1, 5, strides=2, padding='same', activation=LeakyReLU(alpha=0.2),
                      input_shape=input_shape))                 # 28 x 28 x 1 --> 14 x 14 x 64
    self.D.add(Dropout(dropout))
    self.D.add(Conv2D(depth*2, 5, strides=2, padding='same',
                      activation=LeakyReLU(alpha=0.2)))         # 14 x 14 x 64 --> 7 x 7 x 128
    self.D.add(Dropout(dropout))
    self.D.add(Conv2D(depth*4, 5, strides=2, padding='same', 
                      activation=LeakyReLU(alpha=0.2)))         # 7 x 7 x 128 --> 4 x 4 x 256
    self.D.add(Dropout(dropout))
    self.D.add(Conv2D(depth*8, 5, strides=1, padding='same', 
                      activation=LeakyReLU(alpha=0.2)))         # 4 x 4 x 256 --> 4 x 4 x 512
    self.D.add(Dropout(dropout))
    self.D.add(Flatten())                                       # 4 x 4 x 512 --> 8192
    self.D.add(Dense(1, activation='sigmoid'))                  # 8192 --> 1
    return self.D

  def generator_network(self): # ジェネレータ: 逆畳み込み x 4回
    if self.G:
      return self.G
    self.G = Sequential()
    dropout = 0.4
    depth = 64 + 64 + 64 + 64
    dim = 7
    self.G.add(Dense(dim*dim*depth, input_dim=100))             # 100 --> 12544
    self.G.add(BatchNormalization(momentum=0.9))
    self.G.add(Activation('relu'))
    self.G.add(Reshape((dim, dim, depth)))                       # 12544 --> 7 x 7 x 256
    self.G.add(Dropout(dropout))
    self.G.add(UpSampling2D())                                   # 7 x 7 x 256 --> 14 x 14 x 256
    self.G.add(Conv2DTranspose(int(depth/2), 5, padding='same')) # 14 x 14 x 256 --> 14 x 14 x 128
    self.G.add(BatchNormalization(momentum=0.9))
    self.G.add(Activation('relu'))
    self.G.add(UpSampling2D())                                   # 14 x 14 x 128 --> 28 x 28 x 128
    self.G.add(Conv2DTranspose(int(depth/4), 5, padding='same')) # 28 x 28 x 128 --> 28 x 28 x 64
    self.G.add(BatchNormalization(momentum=0.9))
    self.G.add(Activation('relu'))
    self.G.add(Conv2DTranspose(int(depth/8), 5, padding='same')) # 28 x 28 x 64 --> 28 x 28 x 32
    self.G.add(BatchNormalization(momentum=0.9))
    self.G.add(Activation('relu'))
    self.G.add(Conv2DTranspose(1, 5, padding='same', 
               activation='sigmoid'))                            # 28 x 28 x 32 --> 28 x 28 x 1
    return self.G

  def discriminator_model(self):
    if self.DM:
      return self.DM
    optimizer = RMSprop(lr=0.0002, decay=6e-8)
    self.DM = Sequential()
    self.DM.add(self.discriminator_network())
    self.DM.compile(loss='binary_crossentropy', optimizer=optimizer, metrics=['accuracy'])
    return self.DM

  def adversarial_model(self):
    if self.AM:
      return self.AM
    optimizer = RMSprop(lr=0.0001, decay=3e-8)
    self.AM = Sequential()
    self.AM.add(self.generator_network())
    self.AM.add(self.discriminator_network())
    self.AM.compile(loss='binary_crossentropy', optimizer=optimizer, metrics=['accuracy'])
    return self.AM

class MNIST_DCGAN(object):
  def __init__(self):
    self.img_rows = 28
    self.img_cols = 28
    self.channel = 1

    # データ読み込み
    # データをしぼらない場合
    #self.x_train = input_data.read_data_sets("mnist", one_hot=True).train.images
    # データをしぼる場合
    mnist = input_data.read_data_sets("mnist", one_hot=False)
    images = mnist.train.images
    labels = mnist.train.labels
    images = images[np.where(labels == 1)] # 「1」だけにしぼる場合
    
    self.x_train = images
    self.x_train = self.x_train.reshape(-1, self.img_rows, self.img_cols, 1).astype(np.float32)
    self.DCGAN = DCGAN()
    self.discriminator_model =  self.DCGAN.discriminator_model()
    self.adversarial_model = self.DCGAN.adversarial_model()
    self.generator_network = self.DCGAN.generator_network()

  def train(self, train_steps=2000, batch_size=256, save_interval=0):
    # 学習の途中でジェネレータ出力を吐き出す場合、それ用の乱数を確保しておく
    noise_input = None
    if save_interval > 0:
      noise_input = np.random.uniform(-1.0, 1.0, size=[16, 100])
    
    # GAN の訓練
    for i in range(train_steps):
      # (1) batch_size 個の乱数から batch_size 個の模造データ作成
      noise = np.random.uniform(-1.0, 1.0, size=[batch_size, 100])
      images_fake = self.generator_network.predict(noise)
      # (2) batch_size 個の本物データと batch_size 個の模造データでディスクリミネータを訓練
      images_train = self.x_train[np.random.randint(0, self.x_train.shape[0], size=batch_size), :, :, :]
      x = np.concatenate((images_train, images_fake))
      y = np.ones([2*batch_size, 1])
      y[batch_size:, :] = 0
      d_loss = self.discriminator_model.train_on_batch(x, y)
      # (3) batch_size 個の乱数でアドバーサリアルモデル(ジェネレータ+ディスクリミネータ)を訓練
      y = np.ones([batch_size, 1])
      noise = np.random.uniform(-1.0, 1.0, size=[batch_size, 100])
      a_loss = self.adversarial_model.train_on_batch(noise, y)
      
      log_mesg = "%d: [D loss: %f, acc: %f]" % (i, d_loss[0], d_loss[1])
      log_mesg = "%s  [A loss: %f, acc: %f]" % (log_mesg, a_loss[0], a_loss[1])
      print(log_mesg)
      
      if save_interval > 0:
        if (i+1) % save_interval == 0:
          self.plot_images(samples=noise_input.shape[0], noise=noise_input, step=(i+1))

  def plot_images(self, fake=True, samples=16, noise=None, step=0):
    filename = 'mnist.png'
    if fake:
      if noise is None:
        noise = np.random.uniform(-1.0, 1.0, size=[samples, 100])
      else:
        filename = "mnist_%d.png" % step
      images = self.generator_network.predict(noise)
    else:
      i = np.random.randint(0, self.x_train.shape[0], samples)
      images = self.x_train[i, :, :, :]
    plt.figure(figsize=(10,10))
    for i in range(images.shape[0]):
      plt.subplot(4, 4, i+1)
      image = images[i, :, :, :]
      image = np.reshape(image, [self.img_rows, self.img_cols])
      plt.imshow(image, cmap='gray')
      plt.axis('off')
    plt.tight_layout()
    plt.savefig(filename)
    plt.close('all')

if __name__ == '__main__':
  mnist_dcgan = MNIST_DCGAN()
  mnist_dcgan.train(train_steps=10000, batch_size=256, save_interval=25)
  mnist_dcgan.plot_images(fake=True)
  mnist_dcgan.plot_images(fake=False)