本篇示範如何使用 Google 的 TensorFlow、softmax 迴歸模型、CNN,實作一套手寫辨識系統。

如果您沒學過 Google 的 TensorFlow,建議先閱讀 TensorFlow 機器學習軟體工具入門教學

本篇教學會以 MNIST 手寫影像資料作為範例,先以最簡單的 softmax 迴歸模型建立一個手寫辨識系統,準確率大約是 92%,接著再改用較為複雜的 CNN 模型,建立另外一個準確率更高的手寫辨識系統,最後的模型準確率可達 99%。

MNIST 手寫影像資料

MNIST 是一個手寫影像的測試資料集,包含了 60,000 筆訓練用資料,以及 10,000 筆測試用資料。

因為 TensorFlow 本身的內建範例就已經把資料的下載與讀取程式包裝好了,如果您只是想要嘗試跑一下 TensorFlow 的範例程式,可以直接跳過這段下載資料的步驟,這裡是敘述比較底層的 MNIST 資料格式,對於需要自己開發手寫程式的人才會比較需要了解。

下載 MNIST 手寫圖檔資料,總共有四的壓縮檔,分別為訓練用(training set)與測試用(test set)的影像與標示(label)檔:

# training set images (9912422 bytes)
wget http://yann.lecun.com/exdb/mnist/train-images-idx3-ubyte.gz
# training set labels (28881 bytes)
wget http://yann.lecun.com/exdb/mnist/train-labels-idx1-ubyte.gz
# test set images (1648877 bytes)
wget http://yann.lecun.com/exdb/mnist/t10k-images-idx3-ubyte.gz
# test set labels (4542 bytes)
wget http://yann.lecun.com/exdb/mnist/t10k-labels-idx1-ubyte.gz

MNIST 的原始圖檔資料是 20x20 像素的手寫圖,計算重心之後,放在 28x28 的圖檔中心(重心對準新圖檔中心),而 training set 標示檔的檔案格式如下:

Offset資料類型資料值說明
000032 bits integer0x00000801(2049)magic number (MSB first)
000432 bits integer60000資料筆數
0008unsigned byte0 ~ 9label 資料
0009unsigned byte0 ~ 9label 資料
...unsigned byte0 ~ 9label 資料

標示資料的值皆為 09 的數值。

training set 圖檔的檔案格式如下:

Offset資料類型資料值說明
000032 bits integer0x00000803(2051)magic number
000432 bits integer60000資料筆數
000832 bits integer28rows 數目
0001232 bits integer28columns 數目
0016unsigned byte像素資料
0017unsigned byte像素資料
...unsigned byte像素資料

像素資料是採用 row-wise 的方式儲存的,其值為 02550 代表背景(白色),255 代表前景(黑色)。

測試用資料集的資料格式也是類似,詳細的說明請參考 MNIST 的官方網頁說明

下載四個壓縮檔之後,我們使用 gzip 解壓縮:

gzip -d *.gz

再用 od 查看一下這些二進位的檔案內容,例如:

od -t x1 -N 64 train-labels-idx1-ubyte
0000000 00 00 08 01 00 00 ea 60 05 00 04 01 09 02 01 03
0000020 01 04 03 05 03 06 01 07 02 08 06 09 04 00 09 01
0000040 01 02 04 03 02 07 03 08 06 09 00 05 06 00 07 06
0000060 01 08 07 09 03 09 08 05 09 03 03 00 07 04 09 08

準備 MNIST 資料

使用 TensorFlow 內建的教學程式碼,自動下載與讀取 MNIST 的資料:

from tensorflow.examples.tutorials.mnist import input_data
mnist = input_data.read_data_sets("MNIST_data/", one_hot=True)

將 MNIST 資料讀取進來之後,會分成三部份:

  • mnist.train:訓練用資料 55,000 筆。
  • mnist.validation:驗證用資料 5,000 筆。
  • mnist.test:測試用資料 10,000 筆。

每部份的資料都會有影像(例如 mnist.train.images)與標示(例如 mnist.train.labels),在這個手寫辨識的問題上,我們會將影像當作模型輸入的 x,而標示則當作模型輸出的 y。

這裡我們將每一個手寫數字的影像都視為長度為 28 * 28 = 784 的向量(其降維的研究可參考 colah’s blog),因此 mnist.train.images 就是一個 shape 為 [55000, 784] 的 tensor,其中每個數值都是一個介於 01 的浮點數。

在標示的資料(mnist.train.label)上,我們使用長度為 10 的向量來表示 09 的數字,這種向量之中只有一個元素是 1,其餘都是 0,當第 n 個向量元素是 1 時就代表第 n 個數字,例如 [0, 0, 0, 1, 0, 0, 0, 0, 0, 0] 就代表 3,像這種向量就稱為 one-hot 向量。

Softmax 迴歸分析

在手寫數字辨識系統中,由於數字只有 09 這十種可能,當我們拿到一個手寫數字的影像時,我們的模型可能會認為這個影像有 80% 的機率是 9,而有 5% 的機率是 8,然後還有一些更小的機率是其他的數字。

Softmax 迴歸模型可以產生每一個結果的發生機率,而所有的發生機率總和為 1,像這種數字辨識問題就很適合使用 softmax 迴歸模型來處理,事實上在更複雜的分析模型中,也很常在最後的分析步驟上加上 softmax 迴歸模型來輸出機率值。

Softmax 迴歸分析有兩個步驟,第一步是針對某一個類別,加總所有的可能是此類別的表徵特性(evidence),第二步則是將這些表徵特性轉換為機率值。

給定一個手寫影像 \(x\),其會屬於第 \(i\) 個類別的表徵特性模型為:

\[ \text{evidence}_i = \sum_j W_{i,~ j} x_j + b_i \]

其中 \(W_{i,~ j}\) 是權重(weight),\(b_i\) 是偏差值(bias),而 \(x_j\) 就是 \(x\) 的第 \(j\) 個像素值。

接著再將表徵特性經過 softmax 函數轉換為機率值 \(y\):

\[ y = \text{softmax}(\text{evidence}) \]

而 softmax 函數可以想像成一個標準化的函數:

\[ \text{softmax}(x) = \text{normalize}(\exp(x)) \]

寫成實際的數學式就會像這樣:

\[ \text{softmax}(x)_i = \frac{\exp(x_i)}{\sum_j \exp(x_j)} \]

softmax 函數的指數函數會將 \(x\) 的線性差異轉為指數差異,關於這個函數的性質可參考 Michael Nielsen 的網頁

下圖是 softmax 回歸模型的示意圖,這裡以三個輸入的 \(x_i\)(三個像素)與三個輸出 \(y\)(三個數字的機率)作為示範:

若將此模型用方程式的寫法則為:

我們可以將上面的方程式,改為矩陣與向量運算的寫法:

最後用標準的數學式表示就是:

\[ y = \text{softmax}(Wx + b) \]

TensorFlow 實作

匯入 tensorflow 模組:

import tensorflow as tf

定義模型

定義模型,首先定義輸入的資料 x,:

x = tf.placeholder(tf.float32, [None, 784])

這裡的 x 就是多張 MNIST 的影像資料,每一張影像就是一個 784 維的向量,此處的 None 代表該維度的長度可以是任意值。

接著定義權重與偏差值:

W = tf.Variable(tf.zeros([784, 10]))
b = tf.Variable(tf.zeros([10]))

由於 W 要乘上 784 維的向量(輸入影像),然後產生 10 維的表徵特性向量(代表一個數字),所以 W 的 shape 會是 [784, 10],而 b 只是單純加在 10 維的表徵特性向量上的常數而已,所以 shape 為 [10]

建立主要的模型:

y = tf.nn.softmax(tf.matmul(x, W) + b)

此處我們使用 tf.matmul 處理矩陣乘法,再用 tf.nn.softmax 處理 softmax 函數。因為這裡的 x 從原本的一張影像變成了多張影像,也就是說 x 從向量變成了矩陣,所以為了計算方便,我們改成這樣寫,雖然它跟上面理論推導的數學式子不太一樣,但概念是不變的。

訓練模型

這裡我們使用 cross-entropy 的方式來衡量模型的表現,cross-entropy 函數的定義如下:

\[ H_{y'}(y) = -\sum_i y'_i log(y_i) \]

其中 \(y\) 是模型預測出來的機率分佈,而 \(y'\) 則是真實的分佈(對應的 one-hot 向量),這個值越小則代表模型表現越好,也就是模型預測結果越接近真實的分佈。關於 cross-entropy 的理論請參考 colah’s blog 的文章

在實作 cross-entropy 時,我們要增加一個 placeholder 來輸入真實的分佈(正確答案):

y_ = tf.placeholder(tf.float32, [None, 10])

接著實作 cross-entropy 函數:

cross_entropy = tf.reduce_mean(-tf.reduce_sum(y_ * tf.log(y), reduction_indices=[1]))

這裡先用 tf.log 計算每個 (y) 元素的 (log) 轉換,再以元素對元素(一對一對應)的方式乘以 y_,然後使用 tf.reduce_sum 配合 reduction_indices=[1] 參數,將這個矩陣沿著第二維度加總,最後再使用 tf.reduce_mean 計算整個向量的平均值。

由於這行程式碼在數值計算上不穩定,在實際使用上,我們會改用 tf.nn.softmax_cross_entropy_with_logits 這個函數來計算。

cross_entropy = tf.reduce_mean(
  tf.nn.softmax_cross_entropy_with_logits(labels=y_, logits=y))

接著使用 backpropagation 演算法尋找最佳解:

train_step = tf.train.GradientDescentOptimizer(0.5).minimize(cross_entropy)

這裡我們使用 gradient descent 演算法,learning rate 取 0.5。TensorFlow 本身還有提供其他許多的 optimizer 可以使用,請參考 TensorFlow 的文件。

建立一個 InteractiveSession,並初始化 variables:

sess = tf.InteractiveSession()
tf.global_variables_initializer().run()

進行 1000 次的訓練:

for _ in range(1000):
  batch_xs, batch_ys = mnist.train.next_batch(100)
  sess.run(train_step, feed_dict={x: batch_xs, y_: batch_ys})

我們使用 stochastic gradient descent 來加速訓練的過程,在每一次的迭代中,我們從訓練用資料中隨機取出 100 筆資料來使用(稱為一個 batch)。

驗證模型

tf.argmax 可將向量中最大值的索引取出來,我們可以利用這個函數來檢查模型預測值與實際值是否相符合:

correct_prediction = tf.equal(tf.argmax(y,1), tf.argmax(y_,1))

再將這些布林值轉換為浮點數,計算整體模型的準確度:

accuracy = tf.reduce_mean(tf.cast(correct_prediction, tf.float32))

最後一步就是實際執行驗證:

print(sess.run(accuracy, feed_dict={x: mnist.test.images, y_: mnist.test.labels}))
0.9074

準確率大約在 90% 左右,這個值尚未達到收斂的值,如果多增加訓練的步驟,大概可以增加到 92% 以上。

實作 CNN 的部分由於內容與理論牽涉相當廣,我實在沒時間詳細寫,只記錄關鍵重點,閱讀時請同時參考 TensorFlow 的官方文件以及 API 手冊。

Multilayer Convolutional Network

實作 CNN(Convolutional Neural Network)之前,請先熟悉 CNN 的理論

權重初始化

由於使用 ReLU neurons,所以在初始化權重時,最好再加上一點正的偏差值(bias),避免產生 dead neurons。

我們定義兩個初始化權重與偏差值用的函數:

def weight_variable(shape):
  initial = tf.truncated_normal(shape, stddev=0.1)
  return tf.Variable(initial)

def bias_variable(shape):
  initial = tf.constant(0.1, shape=shape)
  return tf.Variable(initial)

Convolution 與 Pooling

在 TensorFlow 中提供了許多的 convolution 與 pooling 運算,這裡我們使用 stride 為 1 的 convolution,邊界使用補零(zero padded)的方式處理,所以輸入與輸出的資料大小是相同的。而 pooling 則是普通的 2x2 max pooling。

def conv2d(x, W):
  return tf.nn.conv2d(x, W, strides=[1, 1, 1, 1], padding='SAME')

def max_pool_2x2(x):
  return tf.nn.max_pool(x, ksize=[1, 2, 2, 1],
                        strides=[1, 2, 2, 1], padding='SAME')

關於 convolution 與 pooling 的詳細說明,請參考 TensorFlow 的 API 文件,另外也要參考 conv2d 與 max_pool 的文件。

第一層 Convolutional Layer

第一層包含了一個 5×5 的 convolution,輸出 32 個特徵值(features),後面接著一個 max pooling,首先初始化權重與偏差值:

W_conv1 = weight_variable([5, 5, 1, 32])
b_conv1 = bias_variable([32])

接著將 x 轉為四維的 tensor,第二維與第三維分別對應影像的寬度與高度,第四維則是影像的顏色 channel。

x_image = tf.reshape(x, [-1, 28, 28, 1])

x_image 拿來與 conv1 做 convolution,再加上一個偏差值後,經過 ReLU 函數轉換,最後經過 max pooling 將影像轉為 14×14 的輸出。

h_conv1 = tf.nn.relu(conv2d(x_image, W_conv1) + b_conv1)
h_pool1 = max_pool_2x2(h_conv1)

第二層 Convolutional Layer

第一層的結構與第一層類似,不過我們在這一層會輸出 64 個特徵值。

W_conv2 = weight_variable([5, 5, 32, 64])
b_conv2 = bias_variable([64])

h_conv2 = tf.nn.relu(conv2d(h_pool1, W_conv2) + b_conv2)
h_pool2 = max_pool_2x2(h_conv2)

第三層 Fully-Connected Layer

這一層我們放了 1024 個 neurons,連接 7 * 7 * 64 的特徵值,在運算時我們將之前 max pooling 的輸出轉為向量,乘以權重矩陣,加上偏差值,再套用 ReLU 函數。

W_fc1 = weight_variable([7 * 7 * 64, 1024])
b_fc1 = bias_variable([1024])

h_pool2_flat = tf.reshape(h_pool2, [-1, 7*7*64])
h_fc1 = tf.nn.relu(tf.matmul(h_pool2_flat, W_fc1) + b_fc1)

Dropout

為了避免 overfitting,我們再加上一個 dropout 函數。

keep_prob = tf.placeholder(tf.float32)
h_fc1_drop = tf.nn.dropout(h_fc1, keep_prob)

輸出層

最後一叢就是輸出數字用的一層,將 1024 個特徵轉為 10 個輸出。

W_fc2 = weight_variable([1024, 10])
b_fc2 = bias_variable([10])

y_conv = tf.matmul(h_fc1_drop, W_fc2) + b_fc2

訓練模型

訓練模型的過程跟之前差不多,只是這裡我們使用 ADAM optimizer,並且在輸入的參數上多加了一個 dropout 用的 keep_prob,以及在每 100 次的迭代時輸出一些模型的狀態資訊。

cross_entropy = tf.reduce_mean(
    tf.nn.softmax_cross_entropy_with_logits(labels=y_, logits=y_conv))
train_step = tf.train.AdamOptimizer(1e-4).minimize(cross_entropy)
correct_prediction = tf.equal(tf.argmax(y_conv, 1), tf.argmax(y_, 1))
accuracy = tf.reduce_mean(tf.cast(correct_prediction, tf.float32))

with tf.Session() as sess:
  sess.run(tf.global_variables_initializer())
  for i in range(20000):
    batch = mnist.train.next_batch(50)
    if i % 100 == 0:
      train_accuracy = accuracy.eval(feed_dict={
          x: batch[0], y_: batch[1], keep_prob: 1.0})
      print('step %d, training accuracy %g' % (i, train_accuracy))
    train_step.run(feed_dict={x: batch[], y_: batch[1], keep_prob: 0.5})
  print('test accuracy %g' % accuracy.eval(feed_dict={
    x: mnist.test.images, y_: mnist.test.labels, keep_prob: 1.0}))

模型經過訓練之後,準確率大約可以達到 99.24% 左右。

執行結果