HELLO CYBERNETICS

深層学習、機械学習、強化学習、信号処理、制御工学、量子計算などをテーマに扱っていきます

【機械学習を基本から丁寧に】TensorFlow Eager Executionで多項式回帰を実行2

 

 

follow us in feedly

https://cdn.blog.st-hatena.com/images/theme/og-image-1500.png

はじめに

以下の前回記事の続きです。前回は、勾配の計算、パラメータの更新に関して数式のことを随分意識しながらコードと対応づくように実装を行いました。しかし、もっと実用的なコードを書いていくためにはコレでは不十分です。 パラメータが膨大になったモデルを前回記事のような書き方で表現するのはかなり面倒だからです。

www.hellocybernetics.tech

今回はTensorFlowに備わる機能を色々使って、もっと便利に、もっと抽象的な方法でコードを書いていきます。 前提として前回の内容を理解しておいてください。いきなり抽象的な書き方をしても、一体何をしているのかがわからないという事になりかねません。

今回はかなり短いですがお付き合いください。

パラメータの一括取得 tf.keras.Model.variables

前回の記事では多項式回帰をEager Executionで実装しました。 この時、スカラーのパラメータを1つ1つ準備し1つ1つ更新のコードを書いたことに、若干の手間を感じた人はいるのではないでしょうか。

www.hellocybernetics.tech

振り返りのためにコードを載せます。 def __init__(self)でパラメータを定義しています。 微分計算をdef grads_fn(self, x, y)で書きますが、このとき、微分計算をしたいパラメータをリストで渡すことになっています。そして最後にdef update(self, x, y, lr=0.001)でパラメータ更新のコードを書いています。このときには、def grads_fn(self, x, y)の計算結果をリストで受け取るので、その値を使って更新式を書くという形になっています。

class Model(tf.keras.Model):
  def __init__(self):
    super(Model, self).__init__()
    self.a1 = tf.contrib.eager.Variable(dtype=tf.float32,
                                       initial_value=1)
    self.a2 = tf.contrib.eager.Variable(dtype=tf.float32,
                                       initial_value=-1)
    self.a3 = tf.contrib.eager.Variable(dtype=tf.float32,
                                       initial_value=1)
    self.b = tf.contrib.eager.Variable(dtype=tf.float32,
                                       initial_value=2)
  
  def call(self, x):
    return self.b + self.a1*x + self.a2*x**2 + self.a3*x**3
  
  def loss_fn(self, x, y):
    y_pre = self(x)
    mse = 0.5 * (y - y_pre) ** 2 
    return tf.reduce_sum(mse)
  
  def grads_fn(self, x, y):
    with tfe.GradientTape() as tape:
      loss = self.loss_fn(x, y)
      return tape.gradient(loss, [self.a1,
                                  self.a2,
                                  self.a3,
                                  self.b])
    
  def update(self, x, y, lr=0.001):
    grads = self.grads_fn(x, y)
    ## variable.assign_sub(value)
    ## variable -= value
    self.a1.assign_sub(lr * grads[0])
    self.a2.assign_sub(lr * grads[1])
    self.a3.assign_sub(lr * grads[2])
    self.b.assign_sub(lr * grads[3])

計算している内容が明確で、非常にわかりやすい一方、これはモデルが膨れ上がってくると大変な量のコードになることが容易に想定できます。 現在はパラメータが4つしかありませんから、この程度のコードを書くことは大した作業ではありません。 しかし、これ以上パラメータが増えたらどうすれば良いのでしょうか。ひたすら更新のコードをコピペで書くのでしょうか?

今のところ、tf.keras.Modelを継承してモデルを構築しているメリットをまだ活用できていません。実はtf.Keras.Modelで作られたモデルというのは自身が保有しているパラメータ(tf.contrib.eager.Variable)を認識してリストで保持しています(self.variablesで取り出せる)。コレを利用すると、もっとスッキリコードを描くことが可能になります。

class Model(tf.keras.Model):
  def __init__(self):
    super(Model, self).__init__()
    self.a1 = tf.contrib.eager.Variable(dtype=tf.float32,
                                       initial_value=1)
    self.a2 = tf.contrib.eager.Variable(dtype=tf.float32,
                                       initial_value=-1)
    self.a3 = tf.contrib.eager.Variable(dtype=tf.float32,
                                       initial_value=1)
    self.b = tf.contrib.eager.Variable(dtype=tf.float32,
                                       initial_value=2)
  
  def call(self, x):
    return self.b + self.a1*x + self.a2*x**2 + self.a3*x**3
  
  def loss_fn(self, x, y):
    y_pre = self(x)
    mse = 0.5 * (y - y_pre) ** 2 
    return tf.reduce_sum(mse)
  
  def grads_fn(self, x, y):
    with tfe.GradientTape() as tape:
      loss = self.loss_fn(x, y)
      return tape.gradient(loss, self.variables) # self.variablesでパラメータを一括で渡せる
    
  def update(self, x, y, lr=0.001):
    grads = self.grads_fn(x, y)
    ## variable.assign_sub(value)
    ## variable -= value
    self.a1.assign_sub(lr * grads[0])
    self.a2.assign_sub(lr * grads[1])
    self.a3.assign_sub(lr * grads[2])
    self.b.assign_sub(lr * grads[3])

ほんの少しですが、コードがましになりました。 微分の計算をする際には、パラメータを1つ1つ格納したリストを自前で作らなくとも、self.variablesを直接渡せば良いのです。 ただし、これではパラメータの渡し方の順番が明示的ではないことに注意してください。def __init__で宣言された順番に格納されているようです。

ともかく、これならばパラメータが増えたときにも微分の計算をほんの数行で書けることがわかりました。

パラメータ更新をもっと楽に

さて、まだ不満は残っているはずです。 微分の計算は幾分か楽になったものの、パラメータ更新の方は相変わらず手書きしています。パラメータが増えたときには、ここの部分もコードが膨大に膨れ上がる恐れがありますね。

  def update(self, x, y, lr=0.001):
    grads = self.grads_fn(x, y)
    ## variable.assign_sub(value)
    ## variable -= value
    self.a1.assign_sub(lr * grads[0])
    self.a2.assign_sub(lr * grads[1])
    self.a3.assign_sub(lr * grads[2])
    self.b.assign_sub(lr * grads[3])

このコードは実は下記のように書き換えることができます。

  def update(self, x, y, lr=0.001):
    grads = self.grads_fn(x, y)
    params_and_grads = zip(grads, self.variables)
    tf.train.GradientDescentOptimizer(lr).apply_gradients(params_and_grads)

さて、急に省略されすぎてなんのことだかわからないかもしれません。1つずつ説明します。

まず、パラメータの更新に必要なのは更新したいパラメータと勾配です。そしてこれらはいつでもセットでなければなりません。 パラメータ$a_1$を更新するためには勾配$\frac{\partial L}{\partial a_1}$が必要です。self.variablesにはパラメータのリストが、そしてgradsにはself.variablesと同じ順番で勾配が格納されています。こいつらをzip()関数で包んで対応が付くようにしてやります。 そして、tf.train.GradientDescentOptimizer(lr)は学習率lrの普通のパラメータ更新を行うインスタンスになります。このインスタンスのメソッドapply_gradients()に先程作ったparams_and_gradsを渡してやれば、パラメータと勾配の対応を考慮した上でパラメータの更新を行ってくれるのです。

tf.trainには他にも様々な方式のオプティマイザー(パラメータ更新してくれるクラス)が用意されています。

もしも、あとからupdateメソッドに対して引数でオプティマイザーを渡してあげたいと感じるならば、

  def update(self, x, y, optimizer):
    grads = self.grads_fn(x, y)
    params_and_grads = zip(grads, self.variables)
    optimizer.apply_gradients(params_and_grads)

などとして、学習のループを各段階でインスタンスを作成してから渡すという方式にしても良いでしょう。 いずれにしても、これでパラメータが膨大に増えてもなんの問題も無くコードを書くことができそうだとわかりました。

ここまでできると、パラメータがベクトルになろうが行列になろうが、基本的に書くコードが同じになってきます。 単にtf.contrib.eager.Variableの形をinitial_valueで調整しておき、def callの計算もloss_fnの計算も好きなように書き換えればいいのです。 いや、本当はもっと楽になってきます。tf.contrib.eager.Variableを使って行列演算などを実直に書く必要は実はほとんど無く、tf.keras.layersにある層を使ってもっと楽に書くこともできます(もちろん今回のような書き方で細かい計算を自分で書くこともできるのは魅力だ)。

もっと高レベルのAPIを使う方法は次回にやっていくことにしましょう。

多項式回帰で正弦波をフィッティング

正弦波の生成

以下のように適当な正弦波を含んだ波形を準備しましょう。

def toy_sin_data():
  x = np.linspace(-3, 3, 50)
  y = 5*np.sin(x) + x + np.random.randn(50) - 1
  return x, y

数式としては $$ y = 5\sin (x) + x -1 + \epsilon $$ $$ \epsilon \sim \mathcal N(0, 1) $$ と具合になっています。これを $$ y = a_1x + a_2x^2 + a_3 x^3 + b $$ で回帰してみようというのが今回の問題です。前回までに使ってきたコードをそのまま流用すればよく、与えるデータだけ代えれば問題ありません。(全体のコードは最後に付与することにします)

回帰の結果

回帰の結果を以下に添付します。 青色の点が訓練データ、赤色の破線がモデルの初期状態での多項式曲線、緑色が学習後のモデルの多項式曲線になります。

f:id:s0sem0y:20180628124420p:plain

何やらうまく行っていそうに見えるではありませんか。

もしも$x$の定義域がもっと広く、正弦波がたくさんグネグネしている状態ならば、こうもうまくフィットできなかったはずです。

なぜなら、今回モデルに仮定した3次の多項式では変曲点を1つしか持てないからです。それにもかかわらず、今回、サンプル点と回帰曲線を重ねた上では、うまく行っているように見えてしまいます。 これは、良いことなのでしょうか悪いことなのでしょうか…?

本来のデータは正弦波の形をしています。これは自分で作成したものだから知っているにすぎません。今後自分が出会うデータというのは、回帰がうまく行ったとして、大抵の場合、真の生成仮定を再現しているとは断言できません。見かけ上うまく行っているだけかもしれません。

ですから、通常はもっと多くデータを取ったりテスト用データを準備したりして、検証を行います。そして検証を行ったとして、それなりにうまく使えそうだと分かったとしても、やはり「真の生成過程を再現しているとは断言できません」。

機械学習のモデルというのはそのようなものなのです。 私達がデータに対して、何らかの仮定を置いて、そしてその仮定の中で最もデータにそれっぽく合うものを見つけているだけで、それが真に正しい保証など得られないのです。このことは常に念頭に置かなければなりません。

コード全体

モデル部分のコード

随分スリムになりましたね。

class Model(tf.keras.Model):
  def __init__(self):
    super(Model, self).__init__()
    self.a1 = tf.contrib.eager.Variable(dtype=tf.float32,
                                       initial_value=1)
    self.a2 = tf.contrib.eager.Variable(dtype=tf.float32,
                                       initial_value=-1)
    self.a3 = tf.contrib.eager.Variable(dtype=tf.float32,
                                       initial_value=1)
    self.b = tf.contrib.eager.Variable(dtype=tf.float32,
                                       initial_value=2)
  
  def call(self, x):
    return self.b + self.a1*x + self.a2*x**2 + self.a3*x**3
  
  def loss_fn(self, x, y):
    y_pre = self(x)
    mse = 0.5 * (y - y_pre) ** 2 
    return tf.reduce_sum(mse)
  
  def grads_fn(self, x, y):
    with tfe.GradientTape() as tape:
      loss = self.loss_fn(x, y)
      return tape.gradient(loss, self.variables)
    
  def update(self, x, y, lr=0.001):
    grads = self.grads_fn(x, y)
    tf.train.GradientDescentOptimizer(lr).apply_gradients(
        zip(grads, self.variables))

正弦波を多項式でフィットするコード