はじめに
前回の記事では単回帰で直線をフィッティングする問題を、TensorFlow Eager Executionで実行しました。 割と低レベルなTensorFlowの機能を使ったため、数式とほとんど直結した形で機械学習の動作を確認できたのではないかと思います。
実は先のような単回帰をしっかりマスターすると、基本的にどんなモデルを仮定するのかというのがちょっと変わるだけで、いろいろな手法を扱うことができることが分かってくると思います。
今回はそのことを見るために、前回の記事内容とコードを少し改変して多項式回帰を行ってみます。
実行環境
Google colabで実行しました。
tensorflow 1.9.0rc1 matplotlib 2.1.2
理屈編
問題設定
ここでは最も単純な線形回帰である単回帰を、少し発展させた多項式回帰について説明します。 これは、入力変数がスカラーで $x$、出力変数もスカラーで $y$ のときに、 $x$ と $y$ の関係を多項式で表そうというものです。 データセット $D={(x_1, y_1), (x_2, y_2), \cdots, (x_N, y_N)}$がある時に( $x_i, y_i$ はそれぞれスカラー)、多項式回帰を用いるというのは、入力 $x$ と出力 $y$ の間に $$ y = a_1x + a_2x^2 + a_3x^3 + \cdots + a_K x^K + b = \sum_{k=1}^K a_k x^k + b $$ の関係があるとして$a_1, a_2, a_3, \cdots, a_K$と$b$を求めようというものになります。ここで$K$は設計者が決める自然数です。 言うまでもなく、この$K$の設定次第で、表しうる多項式の複雑さが変わってきます($k$は飛び飛びの自然数でも構わない。とにかく多項式で表して回帰をするんだということ)。
この場合には、これらのデータは、どのデータを見ても概ね $y_i = \sum_{k=1}^K a_k x_i^k + b$ となっていると考えるわけですね。これを全てのデータでそれ相応に成り立たせるような$a_1, a_2, a_3, \cdots, a_K, b$ を求めることにします。 ここで「それ相応」という意味については、単回帰のときと同様の設定で行きます。
損失関数
さて、 $(x_i, y_i)$ というデータのズレ具合は $l_i = \{ y_i - (\sum_{k=1}^K a_k x_i^k + b)\}^2$ と表すことにします。仮にズレが全く無かったのであれば $l_i=0$ となることに注意してください(なぜなら私達の頭の中では、 $y_i = \sum_{k=1}^K a_k x_i^k + b$と期待しているのですから )。 私達がこれから獲得したい多項式というのは、このようなズレがデータ全体で見た時に少ないことが望まれます。
そこで、これらのデータのズレの総和を取りましょう。 $$ L(a_1,\cdots, a_K,b) = \sum_i^N l_i = \sum_i^N \{y_i - (\sum_{k=1}^K a_k x_i^k + b)\}^2 $$ これが小さくなれば、データ全体考慮した上でそれ相応の線を引けた( $a_1, a_2, a_3, \cdots, a_K, b$ を決められた)ことになります。 これを損失関数と呼び、$L(a_1, \cdots, a_K,b)$ が大きいほど損失が大きく、うまく一次式が求められていないという話になります。
損失を小さくする勾配法
損失関数 $L(a_1, \cdots, a_K,b)$ を小さくするような $a_1, a_2, a_3, \cdots, a_K, b$ をどのように求めればいいのでしょうか? 微分積分学を知っている人ならば、 「$a_1$ で微分して $0$ とおく、…$a_K$で微分して$0$とおく、 $b$ で微分して $0$ とおく」をやって連立方程式を解けば良さそうだとわかるかもしれません。
いずれのパラメータを使おうと、パラメータ$a_k$について統一的に、 $$ a_k \leftarrow a_k - \epsilon \frac{\partial L(a_1,\cdots, a_K,b)}{\partial a_k} $$ と表すことができます。
実践編
必要なライブラリの準備
今回は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 = x^3 + 2x^2 -4x -1 + 2 \epsilon $$ $$ \epsilon \sim \mathcal N(0, 1) $$ というデータを観測したことにします。 実際には$(x, y)$には$y = x^3 + 2x^2 -4x -1 + 2 \epsilon$の関係があるのだが、$y$を観測するときに平均$0$分散$1$のガウスノイズ$\epsilon$が乗って更にノイズが2倍になってしまっているようなケースを想定していることになります。
もちろん、今回は「このデータが本来は$y = x^3 + 2x^2 -4x -1 + 2$なのだ!と判っている」前提で話を進めていますが、現実は「データだけが手元にあり、本来はどのような関係なのかわからない」状況であることは注意してください。 わからないからこそ、機械学習や統計解析によって関係性を知ろうということ行うのです。
def toy_polynomial_data(): x = np.linspace(-3, 3, 50) y = x**3 + 2*x**2 - 4*x + 2*np.random.randn(50) - 1 return x, y x, y = toy_polynomial_data() plt.scatter(x, y) plt.show()
モデルの設計と損失関数
さて、上記のデータの関係性を明らかにするためのモデルの設計を行います。 ここではEagerモードでのモデルの書き方を簡単に説明しながら進めていきましょう。
全体像としては以下のようになります。先に答えを見ておきましょう。次に1つ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.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])
tf.keras.Modelクラスでモデルの雛形を作る
基本的に、tf.keras.Model
クラスを継承して使うことになります。
実際には今回の問題はかなり原始的な話題なので、このクラスを使わなくてもいいのですが、
基本的な雛形はこのような形式になるのではないかと思われるので、慣習に習っておきます。
まず、def __init__(self)
には今回のモデルで用いるパラメータ(やニューラルネットワークの層)を準備しておきます。
今回必要は $a_1x^1 + a_2x^2 + a_3x^3 + b$を表すために $a_1 ,a_2,a_3, b $ 4つのパラメータを準備しておきます。
tf.contrib.eager.Variable()
を利用し、初期値initial_value
を適当に設定しておきます(乱数を使ってもいいですが、今回は敢えて、答えの数値と明らかに違う数値にしておきます)。
次に、call()
メソッドは、モデルの実際の計算を書き下します。今回は$a_1x^1 + a_2x^2 + a_3x^3 + b$を計算するようにしておくだけなので簡単です。
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
損失関数
次は損失関数の部分です。
$$
L(a,b) = \frac{1}{2}\sum_i^N \{ y_i - (\sum_{k=1}^K a_k x_i^k + b)\}^2
$$
というのを書きましょう。y_pre = self(x)
で予測である$a_1x^1 + a_2x^2 + a_3x^3 + 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_1 \leftarrow a_1 - \epsilon\frac{\partial L(a_1,a_2,a_3, b)}{\partial a_1} $$ というものでした。これを実行するには勾配(微分)の計算が必要になります。 今回の問題ならば微分を手で解くことも難しくありませんが、ニューラルネットワークなどのパラメータの微分を、全部て計算で書き下しておくなどやっていられません。 TensorFlowなどの深層学習フレームワークには、指定したパラメータによる微分を自動で計算してくれる仕組みが備わっています。その理論的な背景は「バックプロパゲーション」と呼ばれています。
今回はその理論的な背景には踏み込まず、その機能を有りがたく使わせていただくことにしましょう。 そのコードは以下のようになります。
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])
あとは、このgrads
の中身(パラメータの順番)に注意して、更新のコードを書くだけです。
ただし、
self.a -= lr * grads[0]
のような書き方はできません。少し数式の書き方とは離れてしまいますが、assign_sub
メソッドを使ってください。
ここまでで一通りモデルを書き終えました。再度モデルの全体像を載せておきます。
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])
実験
初期状態のモデル
早速作ったモデルをmodel=Model()
でインスタンス化して、実験してみましょう。
まずは学習を全く行わずにデータをぶち込んでみます。
model = Model()
y_init = model(x)
plt.scatter(x, t)
plt.plot(x, y_init, c='r')
plt.show()
学習後のモデル
400回ほどパラメータの更新を行ってみましょう。
損失の変化を記録するためにloss=[]
と、モデルの予測の記録を取るためにreg=[]
を準備しておきます。
あとはfor
文の中でmodel.update(x, y)
により学習を進めます。(y_pre=model(x)
は予測の記録を取るためだけのコードです)
reg = [] loss = [] for _ in range(400): y_pre = model(x) reg.append((x, y_pre)) model.update(x, y, 2e-4) loss.append(model.loss_fn(x, y))
図示すると、赤破線が初期のモデルの予測、緑が学習後の予測で、青の点が実データです。
本当に学習は上手く行ったのか
損失の変化に関しては以下のようになっており、ほとんど収束しているように見えます。
plt.plot(range(400), loss) plt.show()
しかし、これだけでは学習が上手く行ったのかは本当はわかりません。 同様のデータをもう1セット準備しておいて、テストをする必要があります。 今回の手法は、手持ちのデータに対して損失関数を最小化するに過ぎません。 手持ちの計測データがもしも完全にその計測を代表できるものではないとすれば、 そのデータにフィットした今回の予測も使えるとは保証できません。
通常はデータをトレーニングデータとテストデータに分けなければならないということを覚えておきましょう。 (また、深層学習ではハイパーパラメータが大量に出てくるため、それらの調整に使うためのバリデーションデータも別途必要になります。)
更に今回大事なことがもう1つあります。 私達はデータが3次の多項式であることを知っていたため、モデルも同様に3次の多項式として表現しました。 しかし普段手に入るデータが何次の多項式で表せそうなのか、それをひと目で判断する術はありません。つまり、今回は単なる出来レースなのです。
もしも3次の多項式にノイズが加わったデータを、一目見て3次とわからなかった場合には、もっと高次の多項式でモデルを考えることになるかもしれません。 そうなると、果たして今回のようにパッと見で上手く学習できたということが言えるのでしょうか。
補足
今回のコードはパラメータもデータもすべてスカラーとして表現しました。 $$ y = \sum_{k=1}^K a_k x^k + b $$ について、$ \mathbf a = (b, a_1, a_2, a_3, \cdots, a_K)^T$ と $\mathbf x = (1, x_1,x_2,x_3, \cdots, x_K)^T$ と表しておけば、ベクトルの内積を使って $$ y = \mathbf a^T \mathbf x $$ と表せることに注意してください。これを使えば、もう少し楽にコードを書く方法があります。
コード全体
(最後のセルのコードは、学習時に予測の直線がどのようにフィットしていくかをアニメーションで表示するためのコードです)
関連記事
前回記事
今回の数式が難しく感じた方は、以下の記事を参考にしてください。(ほとんど、以下の簡単な問題を書き換えただけに過ぎません)
続きの記事
今回の記事は数式ベースでコードを書くことを意識しました。 もっと抽象的な書き方で、現実的なEager Executionの使い方に触れていきたい場合は以下を参考にしてください。