HELLO CYBERNETICS

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

【機械学習を基本から丁寧に】TensorFlow Eager Executionで単回帰の実行

 

 

follow us in feedly

f:id:s0sem0y:20180626131049p:plain

理屈編

問題設定

ここでは最も単純な線形回帰である単回帰について説明します。 これは、入力変数がスカラーで $x$ 1つ、出力変数もスカラーで $y$ 1つのときに、 $x$ と $y$ の関係を一次式で表そうというものです。 データセット $D={(x_1, y_1), (x_2, y_2), \cdots, (x_N, y_N)}$がある時に( $x_i, y_i$ はそれぞれスカラー)、単純な線形回帰を用いるというのは、入力 $x$ と出力 $y$ の間に $$ y = ax + b $$ の関係があるとして$a$と$b$を求めようというものになります。

この場合には、これらのデータは、どのデータを見ても概ね $y_i = ax_i + b$ となっていると考えるわけですね。これを全てのデータでそれ相応に成り立たせるような $a,b$ を求めることにします。 ここで「それ相応」という意味について説明します。 $y = ax + b$ というのが厳密に全てのデータで成り立つには、データが一直線上になければなりません。こんな都合の良いことは通常無いので、直線からデータがずれてしまうのは仕方がないわけです。しかしせめてそれが最小に抑えられてほしいということになります。

f:id:s0sem0y:20180620233929p:plain

損失関数

さて、 $(x_i, y_i)$ というデータのズレ具合は $l_i = \{y_i - (ax_i + b)\}^2$ と表すことにします。仮にズレが全く無かったのであれば $l_i=0$ となることに注意してください(なぜなら私達の頭の中では、 $y_i = ax_i + b$と期待しているのですから )。 私達がこれから獲得したい一次式というのは、このようなズレがデータ全体で見た時に少ないことが望まれます。

そこで、これらのデータのズレの総和を取りましょう。 $$ L(a,b) = \sum_i^N l_i = \sum_i^N \{y_i - (ax_i + b)\}^2 $$ これが小さくなれば、データ全体考慮した上でそれ相応の線を引けた( $a,b$ を決められた)ことになります。 これを損失関数と呼び、$L(a,b)$ が大きいほど損失が大きく、うまく一次式が求められていないという話になります。

損失を小さくする勾配法

損失関数 $L(a,b)$ を小さくするような $a, b$ をどのように求めればいいのでしょうか? 微分積分学を知っている人ならば、 「$a$ で微分して $0$ とおく、 $b$ で微分して $0$ とおく」をやって連立方程式を解けば良さそうだとわかるかもしれません。 今回の場合は「偶然にも」それでうまく行きます。しかし、今後、機械学習の手法に多く触れていくとそのような都合の良い「偶然」はなかなか訪れません。

そこで、割と広く使われている常套手段である、勾配法について抑えましょう。 まず $L$ を $a$ で微分した $$ \frac{\partial L(a,b)}{\partial a} $$ というのは、$a$を少し増やしたら$L$がどれくらい増えるかを表しています。つまりこの値を計算した結果、値が大きいほど、$a$に対して$L$は敏感に大きくなるということです。 ちなみにこの値が負の値の場合には、$a$が増えると$L$が減ることを表しています。

つまり、とりあえず$\frac{\partial L(a,b)}{\partial a}$を計算してみて正の値ならば、$L$を減少させるために(いま損失関数の値を小さくしたかったのだった)、$a$を減らせばいいということになります。 つまり、$a$の値を以下のように更新すればいいわけですね。 $$ a \leftarrow a - (正の値) $$ 一方で$\frac{\partial L(a,b)}{\partial a}$が負の値になっているのであれば、$L$を減少させるために$a$を増やせばいいということになります。 $$ a \leftarrow a + (正の値) $$ このように、微分の値を手がかりに値を更新する方法を勾配法と言います(いや、本当は後述する更新の仕方が名前の由来だと思われる)。

ところで上記の更新の仕方をそれぞれ下記のように書き換えてみます。

$\frac{\partial L(a,b)}{\partial a}$を計算してみて正の値ならば $$ a \leftarrow a - (正の値) $$ $\frac{\partial L(a,b)}{\partial a}$が負の値ならば $$ a \leftarrow a - (負の値) $$ と言っても同じなはずです。都合の良いことに、それぞれ(正の値)とか(負の値)とか書かれている部分に$\frac{\partial L(a,b)}{\partial a}$を入れてしまえるではありませんか。 よって上記の式は場合分けの必要なく統一的に、 $$ a \leftarrow a - \frac{\partial L(a,b)}{\partial a} $$ と表すことができます。 いつでも更新は$\frac{\partial L(a,b)}{\partial a}$を元に行えば良いということです。

また、更新の大きさを設計者が外から調整するために、実際には人手で調整するパラメータ(ハイパーパラメータとか言われる)$\epsilon$を準備して $$ a \leftarrow a - \epsilon\frac{\partial L(a,b)}{\partial a} $$ と更新します。これが勾配法です。これと全く同じ理屈で、$b$に対しても $$ b \leftarrow b - \epsilon\frac{\partial L(a,b)}{\partial b} $$ と更新してやることになります。

余談ですが、損失関数の微分を計算することを考慮して、 $$ L(a,b) = \sum_i^N \{y_i - (ax_i + b)\}^2 $$ の代わりに $$ L(a,b) = \frac{1}{2}\sum_i^N \{y_i - (ax_i + b)\}^2 $$ が使われることがしばしばあります。こうしておくと、微分で二乗が降りてきた時に、計算しやすいというメリットがあります。しかし、これでは微分を計算した時にその大きさが半分になってしまいます。 しかし、勾配の大きさでパラメータの更新の大きさを決めていたわけですが、更新の大きさ自体は結局、学習率をいじることで調整するので、特に影響はありません。

損失の減少はいつ終わるのか

ところで、損失を減少させるために$a,b$を少しずつ更新していく勾配法を使うのは良いとして、これはいつまで更新し続ければ良いのでしょうか。 基本的に、これに明確に答えることはできません。大抵は、損失$L(a, b)$を実際に計算してみては、下がっていっているかをモニタリングし、下がらなくなったら更新をやめます。

今回の場合は損失が二次関数であり、更新が止まる場所(つまり $\frac{\partial L(a,b)}{\partial a} = 0$となる場所)が損失の最小点であることがわかっていますが、多くの機械学習手法でこれは保証されません (保証されるような手法は、計算の高速性を求め、そのようにうまく設計された手法である)。

ところで、もしかしたら下記の式を見たとき $$ a \leftarrow a - \epsilon\frac{\partial L(a,b)}{\partial a} $$ で、$\epsilon$を大きくして$a$を一気にたくさん値を変化させてしまったほうが効率的なのでは?と考える方もいらっしゃるかもしれません。 「$\frac{\partial L(a,b)}{\partial a}$の値を見て、仮に正なら、$a$の値を小さくすれば$L$も小さくなるとわかっているのですから、ちまちまやる必要はないのではないか?」というアイデアです。

しかし、実際にはそううまく行きません。$\frac{\partial L(a,b)}{\partial a}$というのはあくまで、とある$a$という値では、そのような傾きになっているというだけだからです。進んだ先では(あるいは進みすぎたときには)勾配の正負が入れ替わっている可能性もあります。せっかく下がって行く方向を見つけても、それは、その場所から見たらという非常に局所的な話なのです。

学習がうまくいく更新の大きさ

もしもうまく学習率を選べたときには、更新が進まなくなる(すなわち勾配が$0$になる)場所が見つかります。 f:id:s0sem0y:20170517004704p:plain

学習がうまくいかない更新の大きさ

学習率を大きくして欲張りすぎると、更新を進めるごとに損失が大きくなっていくという明らかにおかしな挙動を見ることができます。 f:id:s0sem0y:20170517004417p:plain

実践編

実行環境

Google colabで実行しました。

tensorflow 1.9.0rc1 matplotlib 2.1.2

必要なライブラリの準備

今回はEager Executiuonを使って説明していきます。 import tensorflow.contrib.eager as tfeを忘れないようにしましょう。また、tf.enable_eager_execution()によって、以降のコードをすべてEagerモードで実行するようになります。

import numpy as np
import tensorflow as tf
import tensorflow.contrib.eager as tfe
import matplotlib.pyplot as plt
import seaborn as sns
tf.enable_eager_execution()

問題設定

今回は $$ y = 3x-4 + \epsilon $$ $$ \epsilon \sim \mathcal N(0, 1) $$ というデータを観測したことにします。 実際には$(x, y)$には$y = 3x - 4$の関係があるのだが、$y$を観測するときに平均$0$分散$1$のガウスノイズ$\epsilon$が乗ってしまっているようなケースを想定していることになります。

もちろん、今回は「このデータが本来は$y=3x-4$なのだ!と判っている」前提で話を進めていますが、現実は「データだけが手元にあり、本来はどのような関係なのかわからない」状況であることは注意してください。 わからないからこそ、機械学習や統計解析によって関係性を知ろうということ行うのです。

def toy_linear_data():
  x = np.linspace(-3, 3, 50)
  y = 3 * x + np.random.randn(50) - 4
  return x, y

x, y = toy_linear_data()
plt.scatter(x, y)
plt.show()

f:id:s0sem0y:20180626131049p:plain

モデルの設計と損失関数

さて、上記のデータの関係性を明らかにするためのモデルの設計を行います。 ここではEagerモードでのモデルの書き方を簡単に説明しながら進めていきましょう。

全体像としては以下のようになります。先に答えを見ておきましょう。次に1つ1つのメソッドを簡単に説明していきます。

class Model(tf.keras.Model):
  def __init__(self):
    super(Model, self).__init__()
    self.a = tf.contrib.eager.Variable(dtype=tf.float32,
                                       initial_value=-3)
    self.b = tf.contrib.eager.Variable(dtype=tf.float32,
                                       initial_value=2)
  
  def call(self, x):
    return self.a * x + self.b
  
  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.a, self.b])
    
  def update(self, x, y, lr=0.001):
    grads = self.grads_fn(x, y)
    ## variable.assign_sub(value)
    ## variable -= value
    self.a.assign_sub(lr * grads[0])
    self.b.assign_sub(lr * grads[1])
tf.keras.Modelクラスでモデルの雛形を作る

基本的に、tf.keras.Modelクラスを継承して使うことになります。 実際には今回の問題はかなり原始的な話題なので、このクラスを使わなくてもいいのですが、 基本的な雛形はこのような形式になるのではないかと思われるので、慣習に習っておきます。

まず、def __init__(self)には今回のモデルで用いるパラメータ(やニューラルネットワークの層)を準備しておきます。 今回必要は $ax + b$を表すために $a , b $ 2つのパラメータを準備しておきます。 tf.contrib.eager.Variable()を利用し、初期値initial_valueを適当に設定しておきます(乱数を使ってもいいですが、今回は敢えて、答えの数値と明らかに違う数値にしておきます)。

次に、call()メソッドは、モデルの実際の計算を書き下します。今回は$ax+b$を計算するようにしておくだけなので簡単です。

  def __init__(self):
    super(Model, self).__init__()
    self.a = tf.contrib.eager.Variable(dtype=tf.float32,
                                       initial_value=-3)
    self.b = tf.contrib.eager.Variable(dtype=tf.float32,
                                       initial_value=2)
  
  def call(self, x):
    return self.a * x + self.b
損失関数

次は損失関数の部分です。 $$ L(a,b) = \frac{1}{2}\sum_i^N \{y_i - (ax_i + b)\}^2 $$ というのを書きましょう。y_pre = self(x)で予測である$ax + b$を計算します。そして、手元にある実データの$y$との誤差を図るためにmse = 0.5 * (y - y_pre) ** 2を計算します。 そして、すべてのデータの和を取るためにtf.reduce_sum()を使います。

数式と直結していますので、書くのはそんなに難しくないですね(今は一次元なので簡単ですが、次元が増えていくと、どの次元で和を取るのか等を間違えないようにしなければならないです)。

  def loss_fn(self, x, y):
    y_pre = self(x)
    mse = 0.5 * (y - y_pre) ** 2 
    return tf.reduce_sum(mse)
勾配の計算

パラメータの更新は、微分を計算して、現在の値から引くという操作でしたね。 $$ a \leftarrow a - \epsilon\frac{\partial L(a,b)}{\partial a} $$ というものでした。これを実行するには勾配(微分)の計算が必要になります。 今回の問題ならば微分を手で解くことも難しくありませんが、ニューラルネットワークなどのパラメータの微分を、全部て計算で書き下しておくなどやっていられません。 TensorFlowなどの深層学習フレームワークには、指定したパラメータによる微分を自動で計算してくれる仕組みが備わっています。その理論的な背景は「バックプロパゲーション」と呼ばれています。

今回はその理論的な背景には踏み込まず、その機能を有りがたく使わせていただくことにしましょう。 そのコードは以下のようになります。

  def grads_fn(self, x, y):
    with tfe.GradientTape() as tape:
      loss = self.loss_fn(x, y)
      return tape.gradient(loss, [self.a, self.b])

勾配の計算はwith構文で書くことになっているようです。ChainerやPyTorchでは勾配計算の度に計算の初期化をするメソッドを利用するのですが、 Eagerではこのような形式になっていることに注意しておいてください(逆にEagerの書き方を覚えれば、勾配リセットなどの書き損じは起こりえません)。

tape.gradient(loss, [self.a, self.b])によってlossself.aで微分したものと、self.bで微分したものを一緒に返してくれます。 返してくれる順番は、引数で与えた順番に一致するので、ちゃんと意識しておきましょう。

パラメータの更新

いよいよパラメータの更新です。 $$ a \leftarrow a - \epsilon\frac{\partial L(a,b)}{\partial a} $$ $$ b \leftarrow b - \epsilon\frac{\partial L(a,b)}{\partial b} $$ を書き下すだけです。

  def update(self, x, y, lr=0.001):
    grads = self.grads_fn(x, y)
    self.a.assign_sub(lr * grads[0])
    self.b.assign_sub(lr * grads[1])

ここで、grads = self.grads_fn(x, y)ではgrads[0]の中にself.aによる微分値が、grad[1]による微分値が入っていることに注意しておきましょう(これは先程の自分の設定に依存しています)。 あとは、このgradsの中身に注意して、更新のコードを書くだけです。 ただし、

self.a -= lr * grads[0]

のような書き方はできません。少し数式の書き方とは離れてしまいますが、assign_subメソッドを使ってください。

ここまでで一通りモデルを書き終えました。再度モデルの全体像を載せておきます。

class Model(tf.keras.Model):
  def __init__(self):
    super(Model, self).__init__()
    self.a = tf.contrib.eager.Variable(dtype=tf.float32,
                                       initial_value=-3)
    self.b = tf.contrib.eager.Variable(dtype=tf.float32,
                                       initial_value=2)
  
  def call(self, x):
    return self.a * x + self.b
  
  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.a, self.b])
    
  def update(self, x, y, lr=0.001):
    grads = self.grads_fn(x, y)
    ## variable.assign_sub(value)
    ## variable -= value
    self.a.assign_sub(lr * grads[0])
    self.b.assign_sub(lr * grads[1])

実験

初期状態のモデル

早速作ったモデルをmodel=Model()でインスタンス化して、実験してみましょう。 まずは学習を全く行わずにデータをぶち込んでみます。

model = Model()
y_init = model(x)

plt.scatter(x, t)
plt.plot(x, y_init, c='r')
plt.show()

f:id:s0sem0y:20180626140256p:plain

学習後のモデル

100回ほどパラメータの更新を行ってみましょう。 損失の変化を記録するためにloss=[]と、モデルの予測の記録を取るためにreg=[]を準備しておきます。 あとはfor文の中でmodel.update(x, y)により学習を進めます。(y_pre=model(x)は予測の記録を取るためだけのコードです)

reg = []
loss = []
for _ in range(100):
    y_pre = model(x)
    reg.append((x, y_pre))
    model.update(x, y)
    loss.append(model.loss_fn(x, y))

学習が終わった後に、モデルの中のパラメータを見てみましょう。 僕の場合は以下の結果となりました。本来のデータ間に関係を完璧ではないにしても、捉えられているように思いますね。

print(model.a.numpy()) ## 3.2984533
print(model.b.numpy()) ## -4.083257

図示すると、赤破線が初期のモデルの予測、緑が学習後の予測で、青の点が実データです。 f:id:s0sem0y:20180626140923p:plain

本当に学習は上手く行ったのか

損失の変化に関しては以下のようになっており、ほとんど収束しているように見えます。

plt.plot(range(100), loss)
plt.show()

f:id:s0sem0y:20180626140851p:plain

しかし、これだけでは学習が上手く行ったのかは本当はわかりません。 同様のデータをもう1セット準備しておいて、テストをする必要があります。 今回の手法は、手持ちのデータに対して損失関数を最小化するに過ぎません。 手持ちの計測データがもしも完全にその計測を代表できるものではないとすれば、 そのデータにフィットした今回の予測も使えるとは保証できません。

通常はデータをトレーニングデータとテストデータに分けなければならないということを覚えておきましょう。 (また、深層学習ではハイパーパラメータが大量に出てくるため、それらの調整に使うためのバリデーションデータも別途必要になります。)

コード全体

(最後のセルのコードは、学習時に予測の直線がどのようにフィットしていくかをアニメーションで表示するためのコードです)

多項式フィッティングへの発展

以下の記事で今回の内容を踏まえて、少し改変するだけで多項式フィッティングへの拡張を行います。

www.hellocybernetics.tech