這一節的標題是
10.5 Building a Gesture Classifier
因為方格子標題字數限制,所以沒完整顯現
這一節要依循機器學習生命週期的步驟來實作一個手勢分類器。透過這個過程,就可以很清楚知道,機器學習生命週期的每一個步驟究竟要做些什麼,以及程式要怎麼寫。
機器學習生命週期的第一步,也就是步驟0,是識別問題,把想做的事、想解決的問題描述清楚。現在,假設我們想製作一套互動系統,使用者可以透過手勢來和這套系統互動。基於這個目的,很顯然的,系統要能識別使用者的手勢,才能知道該怎麼回應使用者。所以,製作一個可以分辨使用者不同手勢的手勢分類器,就成為首要解決的問題。畢竟這只是個用來教學的例子而已,所以不需要搞得太複雜,就假設這個手勢分類器要分辨的手勢只有簡單的上、下、左、右四種。除了簡化成只需分辨四種手勢之外,我們也以偵測滑鼠的動作來代替偵測手部的動作,這樣就不需要用到其他額外的硬體設備,只要電腦就可以完成這個例子。
那怎麼把滑鼠的動作對應成手勢的方向呢?這其實挺簡單的,透過偵測滑鼠按下、拖曳、放開這三個連續動作,我們可以得到一個向量,而這個向量的方向,就可以當成是手勢的方向。
這裡要注意一下:既然都知道向量的方向了,那就直接判斷這個方向是屬於上、下、左、右哪個方向不就得了,幹嘛還要動用類神經網路來判斷呢?!直接判斷的效率,絕對會高於用類神經網路判斷的呀!
是這樣沒錯啦!不過,我們的目的是想要了解怎麼訓練機器學習模型,越簡單的例子反而越適合,因為越容易確認到底做對了沒有。等到真的都知道怎麼訓練模型之後,再來挑戰複雜的例子也不遲。
Collecting and Preparing the Data
想做的事、想解決的問題都搞清楚之後,接下來是步驟1、2,也就是蒐集、準備資料。
通常蒐集、準備資料的過程會很繁雜。畢竟我們的主要目的是要學習怎麼訓練類神經網路模型,所以就不真正去蒐集資料,而改用如下的合成資料來訓練模型:
dataset = [
{'x': 0.99, 'y': 0.02, 'label': 'right'},
{'x': 0.76, 'y': -0.1, 'label': 'right'},
{'x': -1.0, 'y': 0.12, 'label': 'left'},
{'x': -0.9, 'y': -0.1, 'label': 'left'},
{'x': 0.02, 'y': 0.98, 'label': 'down'},
{'x': -0.2, 'y': 0.75, 'label': 'down'},
{'x': 0.01, 'y': -0.9, 'label': 'up'},
{'x': -0.1, 'y': -0.8, 'label': 'up'}
]
這幾筆寫成dictionary形式的資料,是向量的x、y分量,以及該向量的標籤。向量分量都經過正規化,範圍在-1~1之間。把這些向量及其標籤畫成圖,會長這樣:

從圖可以看出來,這幾個向量的方向,很容易就可以辨識出是指向上、下、左、右等方向中的哪個方向。
訓練模型用的資料集,通常都會儲存在檔案中,要用的時候再載入,並不會直接寫在程式裡頭。一般常見,用來儲存資料集的格式有JSON和CSV兩種。JSON是JavaScript Object Notation的簡寫,資料儲存方式看起來和dictionary的寫法很像。在python中有個json模組,裡頭提供了各種用來操作JSON檔案的方法。JSON的檔案格式及json模組的詳細用法,可以參考下面這兩篇文章:
至於CSV,則是comma-separated values的簡寫,在python中也有個csv模組,可以用來處理CSV檔案。CSV的檔案格式及csv模組的詳細用法,可以參考下面這兩篇文章:
蒐集資料時有個重點,那就是要蒐集各式各樣具有代表性的例子。真實世界中,人的手勢會有各式各樣不同的變化,在蒐集到的資料中,應該要有能夠充分代表這些變化的例子,這樣訓練出來的模型,面對各種不同變化的手勢時,才有辦法認出那是屬於哪一類的手勢。
Exercise 10.4
不使用人工標註的方式,而直接用極座標的角度來標註手勢方向。
儲存資料時,因為我們只在意向量的方向,所以不儲存原始的向量,而是儲存正規化後的向量。這樣做的好處是,x、y分量的數值都會介於-1~1之間,不會有數值差距很大的情況出現。
# python version 3.13.9
import json
import sys
import pygame # version 2.6.1
def gesture_label(direction):
r, theta = direction.as_polar()
if -45 < theta <= 45:
label = 'right'
elif 45 < theta <= 135:
label = 'down'
elif -135 < theta <= 0:
label = 'up'
else:
label = 'left'
return label
pygame.init()
pygame.display.set_caption("Exercise 10.4")
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
screen_size = WIDTH, HEIGHT = 640, 240
screen = pygame.display.set_mode(screen_size)
FPS = 30
frame_rate = pygame.time.Clock()
mouse_pressed = False
save_data = False
start_pt, end_pt = None, None
data = []
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
elif event.type == pygame.MOUSEBUTTONDOWN:
mouse_pressed = True
start_pt = pygame.Vector2(pygame.mouse.get_pos())
end_pt = None
elif event.type == pygame.MOUSEMOTION:
if mouse_pressed:
end_pt = pygame.Vector2(pygame.mouse.get_pos())
elif event.type == pygame.MOUSEBUTTONUP:
mouse_pressed = False
save_data = True
screen.fill(WHITE)
if start_pt is not None and end_pt is not None:
pygame.draw.line(screen, BLACK, start_pt, end_pt, 3)
if save_data:
direction = end_pt - start_pt
if direction.length() > 1.e-6:
# 我們只在意向量的方向,儲存正規化後的向量,各分量的數值
# 不至於有很大的差距
direction.normalize_ip()
data.append({'x': direction.x, 'y': direction.y,
'label': gesture_label(direction)})
try:
with open('./exercise10.4data.json', 'w') as f:
json.dump(data, f)
except Exception as e:
print(f'資料無法存檔: {e}')
save_data = False
pygame.display.update()
frame_rate.tick(FPS)
Choosing a Model
機器學習生命週期的第3個步驟是選擇模型。因為我們要製做的是手勢的分類器,所以就依據設計分類用模型的訣竅來設計模型。
把dataset中的資料製作成表格,會得到一個有三個欄位的表格:
x分量 y分量 標籤
0.99 0.02 right
0.76 -0.1 right
: : :
在輸入層部分,扣除標籤,也就是種類欄位,還有兩個欄位,所以,輸入層需要二個神經元。
在隱藏層部分,因為沒什麼可依循的設計訣竅,所以層數和神經元數量就用和原書一樣的,也就是層數是一層,神經元數量是16個。至於啟動函數,原書沒提到,那就用常用的ReLU。
在輸出層部分,因為有右、上、左、下共四個種類的方向,所以需要四個神經元。另外,因為是分類用的模型,所以輸出層的啟動函數用softmax。
根據上述的設計,Keras程式如下:
model = keras.models.Sequential()
model.add(keras.Input(shape=(2,)))
model.add(keras.layers.Dense(units=16, activation='relu'))
model.add(keras.layers.Dense(4, activation='softmax'))
Training the Model
模型設計好之後,再來就是生命週期的第4個步驟,也就是訓練模型。
要訓練模型,就得把資料餵給模型。不過,這模型有點挑嘴,不是什麼樣的資料都願意吃,只有符合特定格式的資料才對它的胃口。所以,在把資料餵給模型之前,得先把資料處理成它願意吃的形式才可以。
要餵給模型的資料有兩部分:輸入資料及目標資料(target data)。目標資料指的是,當餵給模型輸入資料後,我們希望模型能夠輸出的資料。以dataset內的資料所製作成的表格來說,輸入資料就是x分量和y分量這兩個欄位內的資料,而目標資料則是標籤欄位內的資料。
那什麼樣形式的資料模型會吃呢?首先,不管是輸入資料或目標資料,他們的資料型態都必須是Numpy的陣列,或者是元素為Numpy陣列的list。
除了資料型態之外,以dataset中的資料而言,模型願意吃的輸入資料長這樣:
[[ 0.99, 0.02],
[ 0.76, -0.1 ],
[-1. , 0.12],
[-0.9 , -0.1 ],
[ 0.02, 0.98],
[-0.2 , 0.75],
[ 0.01, -0.9 ],
[-0.1 , -0.8 ]]
也就是說,放置輸入資料的陣列或list,其元素必須是一筆一筆資料所構成的陣列。
既然模型很挑嘴,只願意吃特定長相的輸入資料,那我們只好把輸入資料轉換成模型願意吃的長相;程式這樣寫:
x = [[data['x'], data['y']] for data in dataset]
x = numpy.array(x)
輸入資料的處理到這裡就算完成了。
咦?!正規化呢?不是說資料的正規化很重要?怎麼沒看到這個步驟?
資料的正規化可以自己做,也可以用Keras所提供的工具來做。使用Keras所提供的工具當然比較省事,不過也要有正確的觀念,才能讓工具發揮效用。
Keras用來正規化資料的工具稱為「正規化層」(normalization layer)。雖然名稱中有個「層」字,但和隱藏層、輸出層等由神經元所組成的層不同,正規化層並不是由神經元所組成,Keras只是用「層」這個概念來描述、操作它而已。在Keras中,有許多用來預處理(preprocessing)資料的工具都稱為「層」,也都是基於同樣的道理。
要使用Keras的正規化層,首先要先定義它:
normalization_layer = keras.layers.Normalization()
接著要用adapt()方法調整它,把它調成適合用來處理我們的資料的樣子;程式這樣寫:
normalization_layer.adapt(data)
正規化層會去計算data的平均數(mean)和變異數(variance),並認定之後要它正規化的資料,全都具有這樣的統計性質。當有新的資料,假設叫new_data,要正規化時,正規化層就會利用由data處所得到的平均數和變異數,從new_data計算出以0為中心,標準差(standard deviation)為1的方式分佈的資料;這就是new_data正規化後的資料。要將new_data正規化,程式這樣寫:
normalized_data = normalization_layer(new_data)
這樣所得到的normalized_data,就是new_data正規化後的資料。
正規化層可以放在模型外面,也可以整合進模型中。整合進模型中的優點是,正規化層在進行資料正規化時所使用的計算邏輯及參數,全都會成為模型的一部分;換句話說,用adapt()方法調整正規化層時所得到的平均數和變異數,也會成為模型的一部分。因此,當要移植模型到不同的地方時,就不需要再去管正規化層要怎麼正規化資料,就直接把資料餵給模型就可以了。
那要怎麼把正規化層整合進模型中呢?這其實挺簡單的。既然正規化層和輸入層、隱藏層、輸出層一樣,都是「層」,用來操作這些層的做法,也同樣適用於正規化層。例如,要把正規化層加入模型中,可以使用add()方法;程式這樣寫:
add(normalization_layer)
在把正規化層整合進模型中時,有一點要注意,那就是必須把它放在第一層,取代掉輸入層。所以,建造模型的程式會變這樣:
model = keras.models.Sequential()
model.add(normalization_layer)
:
:
正規化層更詳細的使用方法,可以參考官網的說明文件:〈Normalization layer〉。
處理好輸入資料之後,接著再來處理目標資料。
目標資料是標籤欄位內的資料,不過這些資料要利用先前提到的獨熱編碼法來改變長相,這樣模型才願意吃。
要針對標籤欄位內的資料進行獨熱編碼,程式可以全部自己寫,不過藉助Keras中的to_categorical()函數來做,會比較省事一些。
在使用to_categorical()函數前,要先把標籤欄位內的資料轉為整數。假設我們設定0、1、2、3分別代表right、up、left、right這些標籤,這樣標籤欄位內的資料就會變成0、0、2、2、3、3、1、1;實作時程式可以這樣寫:
labels = ['right', 'up', 'left', 'down']
y = [data['label'] for data in dataset]
for index, label in enumerate(labels):
y = [index if direction == label else direction for direction in y]
把標籤欄位內的資料轉為整數之後,就可以用to_categorical()函數將其轉成獨熱編碼的形式了;程式這樣寫:
y_one_hot = keras.utils.to_categorical(y, num_classes=4)
因為to_categorical()函數所傳回來的是Numpy的陣列,所以y_one_hot可以直接拿來餵給模型,不需要再進行資料型態的轉換。
到目前為止,我們都是在幫資料整形,讓資料的長相能被模型接受。在機器學習界,有個專門的術語用來指稱資料的長相:形狀(shape)。形狀這個術語,指的是資料的維度和結構,也就是資料要如何按照行、列,甚至更深層的其他維度來排列。機器學習中的資料,通常都會存放在稱為張量(tensor)的資料結構中。簡單來說,張量就是多維的向量,而張量的形狀,就是各個維度的大小。資料要按照正確的方式放進符合模型所要求形狀的張量中,這樣模型才能正確地解讀這些資料的含意。
處理好要餵給模型的資料後,就可以開始訓練模型了。不過,就和老師上課會有個教學計畫一樣,我們也需要擬定模型的訓練計畫。教學計畫裡頭擬定了諸如教學目標、教材教法、評量方式等;模型的訓練計畫裡頭,也會有諸如訓練方法、評量標準等東西。
在Keras中,用來設定模型訓練計畫的是compile()方法;程式這樣寫:
model.compile(optimizer='adam',
loss='categorical_crossentropy',
metrics=['accuracy']
)
簡單來說,參數optimizer設定的是訓練方法,或者也可以說是模型的學習方法;說得有學問一點,就是最佳化所使用的演算法。參數loss設定的是損失函數,也就是用來衡量距離學習目標有多遠的函數;參數optimizer所設定的最佳化方法,就是要用來找出一組能讓損失函數值達到最小的權重。至於參數metrics所設定的,則是要用哪種計算方式來顯示學習成果
那這些參數在設定時,有沒有什麼訣竅呢?
在損失函數方面,要用categorical_crossentropy。通常如果有用to_categorical()將目標資料轉成獨熱編碼形式,那損失函數用categorical_crossentropy就對了。
在metrics參數方面,通常會設定成metrics=['accuracy'],也就是在訓練過程中,顯示資料有被正確分類的比例。要注意的是,這個值並不會被用於訓練模型,單純就只是參考用,這是和損失函數值最大的不同之處。
那如果我們想要設定學習速率,要在哪裡設定呢?要設定學習速率,程式可以這樣寫:
opt = keras.optimizers.Adam(learning_rate=0.005)
model.compile(optimizer=opt,
loss='categorical_crossentropy',
metrics=['accuracy']
)
如果沒有特別設定的話,那就會用預設值。
訓練計畫設定好之後,就可以用fit()方法開始訓練模型了;程式這樣寫:
history = model.fit(x, y_one_hot, epochs=200, verbose=2)
參數epochs設定的是要用多少個訓練期來訓練模型;訓練期和學習速率一樣,都是超參數。參數verbose則是用來設定要怎麼顯示訓練過程;0代表不顯示、1代表顯示資料中包含進度條、2則代表顯示資料中不包含進度條。
fit()方法回傳的是History物件,該物件的history屬性是個dictionary,裡頭記錄了在訓練過程中,模型在每個訓練期結束時之準確度、損失函數值等資料;這些資料在後續評估模型的訓練效果及調校模型的訓練參數時,具有相當重要的參考價值。
到目前為止,一切都很順利,設定好各個參數之後,啟動fit()訓練模型,然後就等結果出來,整個訓練過程都自動完成,完全不需要我們介入。這種全自動的方式雖然挺不賴的,但是卻也有些讓人不滿足。這怎麼說呢?例如,如果訓練到一半程式當掉了,那不就全做白工了?!有沒有可能在訓練時,每隔一段時間就把模型的狀態存起來?如果可以的話,即使程式當掉要重新執行,也可以從中斷的地方繼續訓練下去,不用從頭訓練起。又或者,如果訓練到一半,發現模型已經學得很好了,難道非得要繼續訓練下去,直到整個訓練過程全部結束為止嗎?如果這時能提早結束訓練,不就可以省下很多時間和資源嗎?凡此種種,都是訓練模型時會遇到的狀況,而利用fit()方法中的callbacks選用參數將回呼函數(callback function)傳進fit()中,就能讓我們有效地處理這些狀況。
回呼函數,看著好像是很神秘的存在,但它其實也就是函數而已。回呼函數和一般的函數有個最大的不同點,那就是它會被當成引數傳遞進另一個函數中,等待特定的時機被呼叫執行。在Keras中,有許多現成的回呼函數可以使用,當然也可以依實際上的需要自己製作回呼函數。現成的回呼函數,可以參考官網〈Callbacks API〉這份文件;自己製作回呼函數的方法,則可以參考官網〈Writing your own callbacks〉。
原書使用回呼函數的主要目的,是為了要在等待模型訓練完成期間,依然能夠顯示動畫或進行其他操作;這是種非同步作業的方式。然而,對於pygame來說,因為它主迴圈的作業方式本質上是同步的,所以除非利用如多行程(multi-process)、多執行緒(multi-thread)、非同步I/O (asyncio)等技術,否則是無法在pygame中進行非同步作業的。
既然在pygame中很難進行非同步作業,而我們的模型又簡單到很容易就可以訓練好,回呼函數在這裡實在是英雄無用武之地。所以,Keras回呼函數的部分,就此打住。
Evaluating the Model
模型訓練好之後,接下來是生命週期的第5個步驟,也就是評估模型。
先前提到,fit()方法回傳的物件中,記錄了模型在訓練過程中的準確度、損失函數值等資料。現在就來看看,到底在模型的訓練過程中,準確度和損失函數值是怎麼變化的。
fit()方法所傳回物件的history屬性是個dictionary,這個dictionary有'accuracy'和'loss'兩個key;這兩個key的value皆為1D的list,而list的元素,就是每個訓練期結束時之準確度、損失函數值。用200個訓練期以及0.005的學習速率來訓練模型,將準確度、損失函數值對訓練期作圖,可以得到下列圖形:

先來看損失函數值的部分。從圖可以看出來,在剛開始訓練時,模型的損失函數值很大,因為這時模型還沒學到什麼東西。隨著訓練期的增加,損失函數值快速降低,而在超過100個訓練期後,下降的速度減慢很多,代表模型已經學到很多了,所以進步有限;這就好比很容易可以從0分進步到50分,但要從98分進步到99分,那可就沒那麼容易了。在理想狀況下,越多的訓練期會讓損失函數值降得越低,也就是模型會學得越好;這在訓練模型時,是個好現象。
在這裡,我們用了200個訓練期來訓練模型,這數量其實有點多,不過那是因為我們的資料量相當少的緣故。在實務上,用來訓練模型的資料量通常都不少,所以使用的訓練期數量不會這麼多。
現在來看準確度的部分。從圖可以看出來,訓練不到25個訓練期,模型的準確度就已經達到1.0了;這還真是成效斐然啊!
先別高興得太早,這訓練結果看起來相當好,但卻也潛藏著問題。什麼樣的問題呢?先前在介紹機器學習的生命週期時提到,在準備資料時,會將資料切分成三個部分:
- 訓練資料:用來訓練模型的主要資料。
- 驗證資料:不用於訓練模型,只在訓練期間用於檢查模型;檢查通常會在每個訓練期結束時進行。
- 測試資料:訓練過程中完全沒用到的資料;當訓練完成之後,用來確認模型之最終表現。
但現在我們並沒有這麼做,而是把所有資料都當作訓練資料,一股腦兒地拿來訓練模型;誰叫我們就只有八筆資料,這麼少的資料量,實在是沒辦法切啊!
所以,雖然訓練結果看起來相當好,但由於沒有經過驗證、測試,當模型碰到完全沒有見過的資料時,它的表現是不是會像訓練時這麼好,實在是不無疑問;因為模型有可能過度擬合(overfitting)。
過度擬合也有翻譯成過擬合的,它指的是,模型在面對訓練資料時表現得非常好,但面對完全沒有見過的資料時,卻表現得非常差。之所以會出現過度擬合的現象,用通俗一點的方式來說,就是模型只是死記、死背訓練資料,並不是真的從訓練資料學到可以活用的東西。所以當面對從沒看過的東西時,表現自然不會好到哪裡去。
在訓練模型時,應該要避免過度擬合。那我們怎麼知道模型過度擬合了呢?這時驗證資料就可以派上用場了。使用驗證資料的主要目的,就是要在訓練過程中監測模型的學習成效。當在訓練過程中,模型在訓練資料方面的準確度上升,但在驗證資料方面的準確度卻下降,這種不一致的情況,極有可能就是過度擬合所造成的。
關於過度擬合,可以參考下面這篇文章,裡頭有非常詳細又淺顯易懂的說明:
知道驗證資料在訓練模型時所擔任的角色之後,現在就來看看,在寫Keras程式訓練模型時,要怎麼加入驗證資料。
在Keras中,可以透過設定fit()方法的參數來加入驗證資料。設定方式有兩種,一種是從訓練資料中切分一定比例的資料當作驗證資料,另一種則是直接輸入驗證資料。如果要從訓練資料中切分出驗證資料,可以透過參數validation_split來設定。validation_split是個0~1之間的浮點數,代表要切分多少比例的訓練資料作為驗證資料。如果想直接輸入驗證資料,則可以透過參數validation_data來輸入;資料的格式詳見官網的〈Model training APIs〉這份說明文件。這兩個參數在使用上要注意的是,一次只能用一種,如果兩種都設定了,那就只有validation_data會有作用。
先前提到過,fit()方法會傳回History物件,其history屬性內存有模型在訓練過程中的準確度、損失函數值等資料。當有驗證資料存在時,這個history屬性內會多出'val_accuracy'和'val_loss'這兩個value皆為1D list的key,而list的元素,就是每個訓練期結束時,使用驗證資料所得到的模型準確度及損失函數值。
訓練資料和驗證資料都用上了,那怎麼用測試資料來評估模型的訓練成果呢?
要用測試資料來憑估模型的訓練成果,方式很簡單,就是把測試資料丟給evaluate()方法,讓它去計算模型使用測試資料時之準確度及損失函數值;程式這樣寫:
result = model.evaluate(x_test, y_test, verbose=2)
程式中的x_test、y_test是測試資料,其格式和訓練資料x、y_one_hot的格式是相同的。傳回值,也就是result,是個list,其元素就是準確度及損失函數值。
Tuning the Parameters
模型訓練完之後,通常會因為評估結果不盡理想而需要調整超參數並重新訓練,藉此來讓模型能有最好的效能;這個過程可能需要重複多次才能得到滿意的結果。
在Keras中也有用來協助調校參數的工具,詳細的使用方式可以參考官網的說明:
那我們的模型還需要調校嗎?從先前的模型訓練過程圖形可以看到:準確度已達到1.0,而損失函數值也一路下降到低於0.1;所以,模型的表現已經夠好了,不需要再調校了。
Deploying the Model
模型訓練好了,接下來就是部署模型。
部署模型通常會涉及將模型整合到其他應用程式中,以便用來針對新的、從未見過的資料進行預測或決策。在Keras中,可以使用save()方法來將模型存檔,如果要載入存在檔案中的模型,則使用load_model()函數;利用這兩個工具,就可以將訓練好的模型移植到不同的地方,不需要每移植到一個地方就要從頭訓練起。
模型部署好之後,就可以用來針對輸入的資料進行預測。在Keras中,可以用predict()方法來預測輸入的資料會得到怎樣的輸出。使用predict()方法時要注意的是,輸入資料的形狀必須和訓練資料的形狀一樣;至於輸出的形狀,則會和目標資料的形狀一樣。所以,如果要讓模型預測(1, 0)這個向量的輸出,那程式要這樣寫:
input_data = numpy.array([[1, 0]])
output = model.predict(input_data)
因為訓練模型用的目標資料是經過獨熱編碼法編碼所得到的numpy陣列,所以用predict()方法所得到的輸出,也會是和目標資料有相同形狀的numpy陣列,而不是真正的標籤。例如,使用我們訓練好的模型來預測向量(1, 0)的輸出,會得到像這樣的numpy陣列:
array([[9.9227953e-01, 2.7113236e-03, 5.2673812e-04, 4.4823857e-03]], dtype=float32)
陣列的元素是對應於各個標籤的機率值,代表模型認為輸入資料會對應到該標籤的機率;數值越高,代表機率越大。先前提到過,設計分類用的模型時,輸出層神經元的啟動函數要用softmax,從上述的輸出就可以看出道理何在。上述輸出所有元素值的總和是1,而其中有個元素值特別大,其餘的則特別小。所以,只要找出數值最大的那個元素所對應的標籤,就會是模型所預測的標籤。要達到這個目的,程式可以這樣寫:
labels = ['right', 'up', 'left', 'down']
index = numpy.argmax(output)
label = labels[index]
這裡要注意的是,在labels這個list裡頭標籤的排列順序,必須要和目標資料進行獨熱編碼法時所用的順序一致;原先是怎麼個排列法,現在也要是那麼個排列法。
接下來,就來部署這個訓練好的手勢分類器模型,用它來辨識使用者手勢的方向。
通常模型都會部署在訓練環境之外的其他的地方,不過在這裡,我們就直接把訓練好的模型部署在訓練環境所在的地方。這樣的說法看起來很有學問,不過說穿了,就只是把所有的程式都寫在同一個檔案中而已啦!
Example 10.2: Gesture Classifier
訓練過程中,各個訓練期之準確度、損失函數值之變化如下圖所示:

訓練完成,可以開始識別使用者手勢的方向。

滑鼠由左下方拖曳至右上方,模型正確辨識出手勢方向為「right」。

程式如下:
# python version 3.13.9
import sys
import keras # version 3.11.2
import matplotlib.pyplot as plt # version 3.10.0
import numpy # version 2.1.3
import pygame # version 2.6.1
# 要使用的訓練期數量
epochs = 200
# 訓練用的資料集
dataset = [
{'x': 0.99, 'y': 0.02, 'label': 'right'},
{'x': 0.76, 'y': -0.1, 'label': 'right'},
{'x': -1.0, 'y': 0.12, 'label': 'left'},
{'x': -0.9, 'y': -0.1, 'label': 'left'},
{'x': 0.02, 'y': 0.98, 'label': 'down'},
{'x': -0.2, 'y': 0.75, 'label': 'down'},
{'x': 0.01, 'y': -0.9, 'label': 'up'},
{'x': -0.1, 'y': -0.8, 'label': 'up'}
]
# 輸入資料
x = [[data['x'], data['y']] for data in dataset]
x = numpy.array(x)
# 目標資料
# 0: right, 1: up, 2: left, 3: right
labels = ['right', 'up', 'left', 'down']
y = [data['label'] for data in dataset]
for index, label in enumerate(labels):
y = [index if direction == label else direction for direction in y]
y_one_hot = keras.utils.to_categorical(y, num_classes=4)
# 建立並訓練模型
# 重置Keras狀態,避免先前的狀態影響此次的訓練
keras.backend.clear_session()
normalization_layer = keras.layers.Normalization()
normalization_layer.adapt(x)
model = keras.models.Sequential()
model.add(normalization_layer)
model.add(keras.layers.Dense(units=16, activation='relu'))
model.add(keras.layers.Dense(4, activation='softmax'))
opt = keras.optimizers.Adam(learning_rate=0.005)
model.compile(optimizer=opt,
loss='categorical_crossentropy',
metrics=['accuracy']
)
history = model.fit(x, y_one_hot, epochs=epochs, verbose=2)
# 繪製各個訓練期之準確度、損失函數值變化圖
fig, ax = plt.subplots()
ax.plot(history.history['accuracy'], linewidth=1, label='accuracy')
ax.plot(history.history['loss'], linewidth=1, label='loss')
ax.set_xlim(left=0, right=epochs)
ax.set_ylim(bottom=0)
ax.set_xlabel('epoch')
ax.set_ylabel('loss')
plt.legend()
plt.show()
# 將訓練好的模型整合至使用環境中
pygame.init()
pygame.display.set_caption("Example 10.2: Gesture Classifier")
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
screen_size = WIDTH, HEIGHT = 640, 240
screen = pygame.display.set_mode(screen_size)
FPS = 30
frame_rate = pygame.time.Clock()
font = pygame.font.SysFont('courier', 32)
string = 'ready'
mouse_pressed = False
generate_prediction = False
start_pt, end_pt = None, None
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
elif event.type == pygame.MOUSEBUTTONDOWN:
mouse_pressed = True
start_pt = pygame.Vector2(pygame.mouse.get_pos())
end_pt = None
elif event.type == pygame.MOUSEMOTION:
if mouse_pressed:
end_pt = pygame.Vector2(pygame.mouse.get_pos())
elif event.type == pygame.MOUSEBUTTONUP:
mouse_pressed = False
generate_prediction = True
screen.fill(WHITE)
if start_pt is not None and end_pt is not None:
pygame.draw.line(screen, BLACK, start_pt, end_pt, 3)
# 辨識使用者手勢的方向
if generate_prediction:
direction = end_pt - start_pt
if direction.length() > 1.e-6:
direction.normalize_ip()
input_data = numpy.array([[direction.x, direction.y]])
output = model.predict(input_data, verbose=0)
index = numpy.argmax(output)
string = labels[index]
generate_prediction = False
text = font.render(string, True, (0, 0, 0))
text_rect = text.get_rect(center=(WIDTH/2, HEIGHT/2))
screen.blit(text, text_rect)
pygame.display.update()
frame_rate.tick(FPS)
Exercise 10.5
蒐集資料部分,見Exercise 10.4。
訓練模型部分,程式如下:
# python version 3.13.9
import json
import keras # version 3.11.2
import matplotlib.pyplot as plt # version 3.10.0
import numpy # version 2.6.1
# 要使用的訓練期數量
epochs = 200
# 載入資料集
try:
with open('./exercise10.4data.json', 'r') as f:
dataset = json.load(f)
except Exception as e:
print(f'資料無法讀取: {e}')
# 輸入資料
x = [[data['x'], data['y']] for data in dataset]
x = numpy.array(x)
# 目標資料
# 0: right, 1: up, 2: left, 3: right
labels = ['right', 'up', 'left', 'down']
y = [data['label'] for data in dataset]
for index, label in enumerate(labels):
y = [index if direction == label else direction for direction in y]
y_one_hot = keras.utils.to_categorical(y, num_classes=4)
# 建立並訓練模型
# 重置Keras狀態,避免先前的狀態影響此次的訓練
keras.backend.clear_session()
normalization_layer = keras.layers.Normalization()
normalization_layer.adapt(x)
model = keras.models.Sequential()
model.add(normalization_layer)
model.add(keras.layers.Dense(units=16, activation='relu'))
model.add(keras.layers.Dense(4, activation='softmax'))
opt = keras.optimizers.Adam(learning_rate=0.005)
model.compile(optimizer=opt,
loss='categorical_crossentropy',
metrics=['accuracy']
)
history = model.fit(x, y_one_hot, epochs=epochs, verbose=2)
# 繪製各個訓練期之準確度、損失函數值變化圖
fig, ax = plt.subplots()
ax.plot(history.history['accuracy'], linewidth=1, label='accuracy')
ax.plot(history.history['loss'], linewidth=1, label='loss')
ax.set_xlim(left=0, right=epochs)
ax.set_ylim(bottom=0)
ax.set_xlabel('epoch')
ax.set_ylabel('loss')
plt.legend()
plt.show()
# 將訓練好的模型存檔
try:
model.save('./exercise10.5model.keras')
except Exception as e:
print(f'模型存檔失敗:{e}')
部署模型部分,程式如下:
# python version 3.13.9
import sys
import keras # version 3.11.2
import numpy # version 2.1.3
import pygame # version 2.6.1
def get_label(index):
labels = ['right', 'up', 'left', 'down']
return labels[index]
# 重置Keras狀態,避免先前的狀態影響目前的工作
keras.backend.clear_session()
# 載入模型
model = keras.saving.load_model('./exercise10.5model.keras')
# 將模型整合至使用環境中
pygame.init()
pygame.display.set_caption("Exercise 10.5-3")
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
screen_size = WIDTH, HEIGHT = 640, 240
screen = pygame.display.set_mode(screen_size)
FPS = 30
frame_rate = pygame.time.Clock()
font = pygame.font.SysFont('courier', 32)
string = 'ready'
mouse_pressed = False
generate_prediction = False
start_pt, end_pt = None, None
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
elif event.type == pygame.MOUSEBUTTONDOWN:
mouse_pressed = True
start_pt = pygame.Vector2(pygame.mouse.get_pos())
end_pt = None
elif event.type == pygame.MOUSEMOTION:
if mouse_pressed:
end_pt = pygame.Vector2(pygame.mouse.get_pos())
elif event.type == pygame.MOUSEBUTTONUP:
mouse_pressed = False
generate_prediction = True
screen.fill(WHITE)
if start_pt is not None and end_pt is not None:
pygame.draw.line(screen, BLACK, start_pt, end_pt, 3)
# 辨識使用者手勢的方向
if generate_prediction:
direction = end_pt - start_pt
if direction.length() > 1.e-6:
direction.normalize_ip()
input_data = numpy.array([[direction.x, direction.y]])
output = model.predict(input_data, verbose=0)
index = numpy.argmax(output)
string = get_label(index)
generate_prediction = False
text = font.render(string, True, (0, 0, 0))
text_rect = text.get_rect(center=(WIDTH/2, HEIGHT/2))
screen.blit(text, text_rect)
pygame.display.update()
frame_rate.tick(FPS)
Exercise 10.6
偵測滑鼠移動的軌跡,並將其切分成三段。由每段軌跡的頭、尾兩點可以算出一個向量,所以每條軌跡可以得到三個向量;這就是輸入資料。至於目標資料,也就是軌跡的標籤,不用人工標註的方式取得,而直接使用由軌跡頭、尾兩點所形成的向量之方向。
偵測滑鼠移動軌跡時,座標點的數量要超過10點才算是有效的軌跡。所以,pygame的FPS不要設得太低,免得滑鼠移動得比較快時,抓到的點數數量太少。


蒐集資料部分,程式如下:
# python version 3.13.9
import json
import sys
import pygame # version 2.6.1
def gesture_label(points):
direction = points[-1] - points[0]
r, theta = direction.as_polar()
if -45 < theta <= 45:
label = 'right'
elif 45 < theta <= 135:
label = 'down'
elif -135 < theta <= 0:
label = 'up'
else:
label = 'left'
return label
pygame.init()
pygame.display.set_caption("Exercise 10.6-1")
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
screen_size = WIDTH, HEIGHT = 640, 240
screen = pygame.display.set_mode(screen_size)
FPS = 60
frame_rate = pygame.time.Clock()
mouse_pressed = False
mouse_dragged = False
save_data = False
data = []
points = []
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
elif event.type == pygame.MOUSEBUTTONDOWN:
mouse_pressed = True
points = []
elif event.type == pygame.MOUSEMOTION:
if mouse_pressed:
mouse_dragged = True
elif event.type == pygame.MOUSEBUTTONUP:
mouse_pressed = False
mouse_dragged = False
save_data = True
screen.fill(WHITE)
if mouse_dragged:
points.append(pygame.Vector2(pygame.mouse.get_pos()))
if points:
for point in points:
pygame.draw.circle(screen, BLACK, point, 1)
if save_data:
if len(points) > 10:
n = len(points)//3
vec1 = points[n] - points[0]
vec2 = points[2*n] - points[n]
vec3 = points[-1] - points[2*n]
vecs = [vec1, vec2, vec3]
for vec in vecs:
if vec.length() > 1.e-6:
vec.normalize_ip()
# pygame向量無法存入json中,需轉成list
vecs_json = [list(vec) for vec in vecs]
data.append({'vectors': vecs_json, 'label': gesture_label(points)})
try:
with open('./exercise10.6data.json', 'w') as f:
json.dump(data, f)
except Exception as e:
print(f'資料存檔失敗:{e}')
save_data = False
pygame.display.update()
frame_rate.tick(FPS)
訓練模型部分,程式如下:
# python version 3.13.9
import json
import keras # version 3.11.2
import matplotlib.pyplot as plt # version 3.10.0
import numpy # version 2.1.3
# 要使用的訓練期數量
epochs = 200
# 載入資料集
try:
with open('./exercise10.6data.json', 'r') as f:
dataset = json.load(f)
except Exception as e:
print(f'資料無法讀取: {e}')
# 輸入資料
x = []
for item in dataset:
vecs = item['vectors']
components = [component for vec in vecs for component in vec]
x.append(components)
x = numpy.array(x)
# 目標資料
# 0: right, 1: up, 2: left, 3: right
labels = ['right', 'up', 'left', 'down']
y = [data['label'] for data in dataset]
for index, label in enumerate(labels):
y = [index if direction == label else direction for direction in y]
y_one_hot = keras.utils.to_categorical(y, num_classes=4)
# 建立並訓練模型
# 重置Keras狀態,避免先前的狀態影響此次的訓練
keras.backend.clear_session()
normalization_layer = keras.layers.Normalization()
normalization_layer.adapt(x)
model = keras.models.Sequential()
model.add(normalization_layer)
model.add(keras.layers.Dense(units=16, activation='relu'))
model.add(keras.layers.Dense(4, activation='softmax'))
opt = keras.optimizers.Adam(learning_rate=0.005)
model.compile(optimizer=opt,
loss='categorical_crossentropy',
metrics=['accuracy']
)
history = model.fit(x, y_one_hot, epochs=epochs, verbose=2)
# 繪製各個訓練期之準確度、損失函數值變化圖
fig, ax = plt.subplots()
ax.plot(history.history['accuracy'], linewidth=1, label='accuracy')
ax.plot(history.history['loss'], linewidth=1, label='loss')
ax.set_xlim(left=0, right=epochs)
ax.set_ylim(bottom=0)
ax.set_xlabel('epoch')
ax.set_ylabel('loss')
plt.legend()
plt.show()
# 將訓練好的模型存檔
try:
model.save('./exercise10.6model.keras')
except Exception as e:
print(f'模型存檔失敗:{e}')
部署模型部分,程式如下:
# python version 3.13.9
import sys
import keras # version 3.11.2
import numpy # version 2.1.3
import pygame # version 2.6.1
def get_label(index):
labels = ['right', 'up', 'left', 'down']
return labels[index]
# 重置Keras狀態,避免先前的狀態影響目前的工作
keras.backend.clear_session()
# 載入模型
model = keras.saving.load_model('./exercise10.6model.keras')
# 將模型整合至使用環境中
pygame.init()
pygame.display.set_caption("Exercise 10.6-3")
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
screen_size = WIDTH, HEIGHT = 640, 240
screen = pygame.display.set_mode(screen_size)
FPS = 60
frame_rate = pygame.time.Clock()
font = pygame.font.SysFont('courier', 32)
string = 'ready'
mouse_pressed = False
mouse_dragged = False
generate_prediction = False
data = []
points = []
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
elif event.type == pygame.MOUSEBUTTONDOWN:
mouse_pressed = True
points = []
elif event.type == pygame.MOUSEMOTION:
if mouse_pressed:
mouse_dragged = True
elif event.type == pygame.MOUSEBUTTONUP:
mouse_pressed = False
mouse_dragged = False
generate_prediction = True
screen.fill(WHITE)
if mouse_dragged:
points.append(pygame.Vector2(pygame.mouse.get_pos()))
if points:
for point in points:
pygame.draw.circle(screen, BLACK, point, 1)
# 辨識使用者手勢的方向
if generate_prediction:
if len(points) > 10:
n = len(points)//3
vec1 = points[n] - points[0]
vec2 = points[2*n] - points[n]
vec3 = points[-1] - points[2*n]
vecs = [vec1, vec2, vec3]
for vec in vecs:
if vec.length() > 1.e-6:
vec.normalize_ip()
x = [component for vec in vecs for component in vec]
input_data = numpy.array([x])
output = model.predict(input_data, verbose=0)
index = numpy.argmax(output)
string = get_label(index)
else:
string = 'Not enough data points.'
generate_prediction = False
text = font.render(string, True, (0, 0, 0))
text_rect = text.get_rect(center=(WIDTH/2, HEIGHT/2))
screen.blit(text, text_rect)
pygame.display.update()
frame_rate.tick(FPS)
Exercise 10.7
略


















