在1957年由Frank Rosenblatt於康乃爾航空實驗室(Cornell Aeronautical Laboratory)所發明,只含有一個神經元的感知器(perceptron),應該就是最簡單的類神經網路了。感知器含有一個或多個輸入、一個處理器,以及一個輸出;如下圖

感知器是一種前饋(feed-forward)模型;這種模型的特性是,資料在網路中的流動方向是單一的,資料從輸入端送入神經元,也就是處理器處理後,其結果就從輸出端輸出:
輸入 ⟶ 神經元 ⟶ 輸出所以,以上圖的感知器來說,資料的流動方向是從左到右:資料從輸入端進入,從輸出端離開。
知道感知器的運作方式之後,接下來就來看看它是怎麼處理資料,把輸入轉變成輸出的。不過在這之前,有兩個東西要先了解一下:一個是權重,另一個是啟動函數(activation function)。
關於權重,先前提到過,類神經網路是透過調整權重來進行學習的;這也是感知器的學習方式。建造感知器時,每個輸入都會被賦予一個權重;這個權重是隨機選取的,範圍介於-1~1。當感知器進行學習時,也就是在調整這些權重。
至於啟動函數,它的作用是將輸入轉換為輸出。啟動函數在設計時,主要的目的是要模仿大腦神經元的反應:大腦神經元只有在輸入的脈衝強度高於某個門檻值時,才會產生輸出脈衝。所以,感知器的啟動函數會設計成不管輸入的數值是多大,它就只會有兩種可能的輸出。在這裡,我們把啟動函數設計成用來取出輸入值的符號;當輸入值為正時,啟動函數會輸出1,當輸入值為負時,則輸出-1。
那0呢?如果輸入值是0,啟動函數產生的輸出值會是1還是-1?這必須根據實際的應用場景來決定。不過,因為我們只是要了解感知器是怎麼運作的而已,所以隨便挑一個就可以了;在這裡,當輸入值是0時,我們會讓啟動函數產生的輸出值是-1。
根據上面的說明,啟動函數的程式可以這樣寫:
def activate(input):
return 1 if input>0 else -1
在這裡要再額外提一下,啟動函數並不總是這麼單純,不同的類神經網路設計,所使用的啟動函數可能會非常不同;有些啟動函數還會牽涉到複雜的數學函數。
了解感知器的權重和啟動函數之後,現在就來看看感知器是怎麼把輸入轉成輸出的。
要把輸入轉成輸出,感知器的做法很簡單,就先把所有的輸入乘上對應的權重然後加總,接著利用啟動函數把加總後的輸入轉成輸出;就這樣而已。看個例子會更清楚一些。
假設我們有兩個輸入,其值分別是12和4;而其對應的權重分別是0.5及-1。在機器學習領域,大家習慣用xi來表示輸入,所以我們讓
x0 = 12
x1 = 4
至於權重,則以wi來表示,所以
w0 = 0.5
w1 = -1
將輸入乘上對應的權重然後加總,也就是
w0 x0 + w1 x1 = 0.5 × 12 + (-1)× 4 = 2
既然加總後的輸入值為2,那由啟動函數所計算出來的輸出值就會是1。
總結一下。感知器的演算法可以整理如下:
- 將每一個輸入乘上它的權重。
- 將乘上權重的所有輸入加總。
- 用啟動函數將加總後的數值轉成輸出值。
根據這個演算法,我們的例子可以寫成如下的程式:
inputs = [12, 4]
weights = [0.5, -1]
input_sum = sum([inputs[i]*weights[i] for i in range(len(inputs))])
output = activate(input_sum)
Simple Pattern Recognition Using a Perceptron
先前提到過,類神經網路可以用來進行模式識別。單一一個感知器雖然構造簡單,但好歹也是類神經網路的一種,複雜的不行,但像是資料集中的資料點只被區分成一組或二組這種簡單的模式識別工作,它還是辦得到的。接下來,就用個簡單的例子來看看,感知器是怎麼做到的。
假設資料集中有一些植物的資料。這些植物中,有些是能在少水、多日照環境中生存的旱生植物,有些則是能在光線較弱的水中環境生存的水生植物。如果我們想識別出哪些植物是水生的、那些是旱生的,那該怎麼做呢?
圖解法是個好方法!把資料集中的資料點畫在座標圖上,運氣好的話,可以很容易就看出哪些是水生的、哪些是旱生的。以植物每日會接受到的光線量為x軸,而每日會接收到的水量為y軸,畫出來的圖長這樣:

我們運氣很好,很容易就可以看出來,資料點可以用一條線區隔成兩群;這就告訴我們,在同一群中的植物,應該就是同一類的。從圖中可以看出來,在直線上方的植物,每日接收到的水量比較多,所以是水生的;而在直線下方的植物,每日接收到的水量比較少,所以是旱生的。
我們運氣真的那麼好嗎?其實,這資料集是我們自己造出來的,為了說明方便,所以刻意讓資料點很明顯地區分成兩群。真實世界的資料集可沒這麼單純,通常資料點的分佈會很雜亂,很難看出那條直線要畫在哪裡。
圖解法就是那樣做的,那如果要用感知器來解這個問題,該怎麼做呢?首先,我們先建造一個如下圖所示,有二個輸入的感知器:

圖中,x0和x1為感知器的輸入,而w0和w1則為其所對應的權重。
要判別一個資料點所代表的植物是水生或旱生時,先把資料點的x軸和y軸數值分別作為感知器的輸入x0及x1,接著乘上他們對應的權重後加總。根據先前設計的啟動函數,如果加總後得到的數值是正值,那感知器會輸出+1,代表這棵植物是水生的;如果得到的數值是負值,那感知器會輸出-1,代表這棵植物是旱生的。
這樣的做法看來可行,不過卻會有個問題:如果資料點是(0, 0)時,感知器會喪失學習的能力。這怎麼說呢?先前提到過,類神經網路的學習,是透過調整權重來進行的。然而,當碰到點(0, 0)時,很明顯的,不管感知器怎麼調整權重w0及w1都沒用,算出來的加總永遠都是0;這就意味著感知器沒辦法學到面對點(0, 0)這個怪咖時,到底該怎麼辦。
從圖解法來看,在2D平面上可以畫出各種走向的直線,而點(0, 0)可能位於這些直線的上方或下方,完全看你怎麼畫這條直線。然而,在點(0, 0)失去學習能力的感知器,就只會畫出經過(0, 0)的直線,完全學不會畫出不經過(0, 0)的直線。
那該怎麼辦呢?使用感知器的目的,就是希望利用它可以自動學習的特性來幫助我們解決問題;喪失學習能力的感知器可不是我們想要的。所幸,有個辦法可以處理這個問題,讓感知器在碰到點(0, 0)時,不會失去學習的能力;這個辦法就是加入偏置(bias)。
要讓感知器碰到點(0, 0)時,仍然能保有學習的能力,可以額外加入一個輸入,這個額外加入的輸入就稱為偏置。加入偏置後的感知器長這樣:

偏置的值設為1,至於它對應的權重,就跟其他輸入的權重一樣,一開始的時候也是隨機選取的,範圍同樣介於-1~1。
加入偏置之後,碰到點(0, 0)時,輸入的加總會是
w0 x0 + w1 x1 + bias × wbias = 0 + 0 + wbias = wbias
因此,從wbias的正負值,就可以知道(0, 0)是位於直線的上方或下方。如果wbias是正的,那(0, 0)就位於直線的上方;反之,如果wbias是負的,那(0, 0)就位於直線的下方。
既然wbias和w0、w1一樣,是能夠調整的,那感知器在碰到點(0, 0)時,也就能藉由調整wbias來學習該把直線畫在(0, 0)的上方或下方。
把bias這個字翻譯成「偏置」而不是常見的「偏差」,因為覺得這樣更能呈現它本意。加入bias的目的,是希望把直線置放在偏離點(0, 0)的地方,讓感知器可以知道點(0, 0)和直線之間的相對位置。所以囉,翻譯成「偏置」,應該更能從字面上看出它的本意。
The Perceptron Code
了解了感知器的運作原理及其演算法之後,接下來就來實作描述感知器的類別。
我們把描述感知器的類別叫做Perceptron。因為感知器只需追蹤輸入的權重,所以Perceptron類別初步設計成這樣:
class Perceptron:
def __init__(self, total_inputs):
# 含偏置在內的輸入數量,
self.total_inputs = total_inputs
# 輸入的權重
self.weights = [random.uniform(-1, 1) for _ in range(self.total_inputs)]
把輸入餵給感知器,它會產生輸出,這個過程就寫成feed_forward()方法:
def feed_forward(self, inputs):
input_sum = sum([inputs[i]*self.weights[i] for i in range(total_inputs)])
return self.activate(input_sum)
假設現在有個資料點(50, -12),要把這個資料點輸入感測器並取得輸出,程式可以這樣寫:
perceptron = Perceptron(3)
# 輸入包含資料點及偏置
inputs = [50, -12, 1]
guess = perceptron.feed_forward(inputs)
因為一開始的時候所有的權重值都是隨機產生的,所以這時候所得到的感知器輸出值,就只有50%的機會是正確的。想要讓感知器產生正確的輸出值,就必須加以訓練。
感知器要怎麼訓練呢?先前提到過,類神經網路是透過調整權重來學習的,所以訓練感知器,就是要告訴它怎麼去調整權重。在這裡,我們會使用監督式學習的方式來訓練感知器。
利用監督式學習的方式來訓練感知器時,除了給感知器輸入之外,還會把正確的答案給感知器,讓感知器可以知到自己的對、錯,並據此來調整權重;整個過程如下:
- 把答案已知的輸入餵給感知器。
- 要求感知器產生輸出。
- 計算誤差。
- 根據誤差來調整權重。
- 回步驟1。
這個過程會寫成Perceptron類別的方法,不過在寫程式之前,要先知道步驟3、4到底要怎麼做。
步驟3中的誤差,指的是我們想要的輸出,也就是正確解答與感知器輸出之差;計算公式為:
error = desired - guess
公式裡的desired是正確解答,而guess則是感知器的輸出。
在步驟4中要根據誤差來調整權重。類神經網路調整權重的方式稱為學習規則(learning rule)。學習規則有非常多種,原書提到的只是其中一種,其調整方式為:
wnew = wold + η Δw
其中,η是學習常數(learning constant),
Δw = error × input
為什麼學習規則會長這樣呢?其實,這個學習規則是經過複雜的數學推導才得到的;不過,只要知道η和Δw所代表的含意,就能大致理解為什麼它會長這樣了。接下來,就先來看看Δw的作用,然後再來看η。
從Δw的定義來看,它和誤差有關。因為誤差是正確解答與感知器輸出之差,所以Δw所代表的,就是權重的調整方向。當誤差大於0時,代表輸出值小於正確值。如果想讓誤差減少,那就應該讓輸出值增加。那怎樣才能讓輸出值增加呢?如果輸入值是正數,要讓輸出值增加,那就必須讓權重變大。這時候,因為誤差和輸入值都為正,所以Δw>0,調整後的權重會變大。反之,如果輸入值是負數,要讓輸出值增加,那就必須讓權重變小。這時候,因為輸入值為負,所以Δw<0$,調整後的權重會變小。當誤差小於0時,也可以這樣子來分析。總之,從這樣的分析,應該就可以看出Δw在調整權重時所扮演的角色了。
接下來來看學習常數η的作用。學習常數,查到的大部分的資料都稱之為學習速率(learning rate);從這個名稱應該就不難看出它的作用。既然類神經網路是靠調整權重來學習,那就不難想像,學習常數的作用應該就和調整權重這件事有關。
從前面提到的學習規則來看,學習常數越大,每次調整權重的幅度就會越大;相反的,學習常數越小,每次調整權重的幅度就會越小。那學習常數是不是設越大越好呢?這樣類神經網路是不是就會學得越快?
那可不一定!我們調整權重的目的是希望讓誤差變小;最好是變成0。可是,我們並不知道多大的權重會讓誤差變成0;如果知道的話,那就一次到位把權重設定成那個值就好了,哪還需要透過不斷調整來達到目的!所以,權重在調整的過程中,有可能會因為調整的幅度太大而衝過頭,反而讓誤差變大。那再調整回來不就好了?話是沒錯,可是調整幅度還是那麼大,難保不會又衝過頭。換句話說,學習常數設越大,雖然學習速度會變快,但也隱藏著會造成學習成果不到位的風險。
既然學習常數設很大會有學習成果不到位的風險,那設很小不就好了?這樣學習成果總該會比較好吧?是這樣沒錯,不過,學習常數很小,會讓權重每次調整的幅度都很小;這也就是說,類神經網路的學習速度會很慢。
總之,大的學習常數會讓類神經網路學得比較快,但可能會學得不到位;小的學習常數會讓類神經網路學得比較到位,但會學得比較慢。至於學習常數要設多大,原書沒提,不過看看〈學習率Learning Rate〉這篇文章的介紹就可以知道,這可不是件容易決定的事;既然如此,那就先不管那麼多了。
搞清楚步驟3、4要怎麼做,並在Perceptron類別中加入learning_constant屬性之後,就可以把上述訓練的步驟寫成Perceptron類別的方法:
def train(self, inputs, desired_answer):
guess = self.feed_forward(inputs)
error = desired_answer - guess
for i in range(self.total_inputs):
self.weights[i] += error*inputs[i]*self.learning_constant
這樣,Perceptron類別就算是完工了。完整的Perceptron類別程式碼如下:
class Perceptron:
def __init__(self, total_inputs, learning_rate):
# 含偏置在內的輸入數量,
self.total_inputs = total_inputs
self.learning_constant = learning_rate
# 輸入的權重
self.weights = [random.uniform(-1, 1) for _ in range(self.total_inputs)]
def activate(self, input_sum):
return 1 if input_sum>0 else -1
def feed_forward(self, inputs):
input_sum = sum([inputs[i]*self.weights[i] for i in range(self.total_inputs)])
return self.activate(input_sum)
def train(self, inputs, desired_answer):
guess = self.feed_forward(inputs)
error = desired_answer - guess
for i in range(self.total_inputs):
self.weights[i] += error*inputs[i]*self.learning_constant
訓練感知器時,需要一組訓練用的資料集;這組資料集是一組已知答案的輸入。如果有現成的資料集最好,但萬一沒有的話,那該怎麼辦呢?
在這裡,既然我們只是想知道感知器是怎麼學會判斷圖上的點是在直線的上方或下方而已,那任何一組由這樣的點所構成的資料集,都可以拿來訓練感知器。所以,如果沒有真實數據所構成的資料集可用,那就自己造一組來用。這種用人工生成的資料,就叫做合成資料(synthetic data)。
合成資料常用於機器學習中。因為合成資料可以用電腦模擬或用演算法來產生,所以在取得成本上,要比需要透過許多手段去蒐集的真實資料便宜。除了成本比較便宜之外,合成資料既然是我們自己製造的,那就可以依據我們想要的訓練或測試場景來製作。接下來,就來製作我們所需要的合成資料。
我們需要的合成資料包括一堆二維平面上的點,同時也要知道這些點是位於一條直線的上方或下方。直線的公式是
y = m x + b
其中m是斜率,而b是截距。如果現在有個點(x', y'),要判斷這個點是位於直線的上方或下方,方式很簡單:如果
y' > m x' + b
那點就是位於直線上方;如果
y' = m x' + b
那點就是位於直線上;如果
y' < m x' + b
那點就是位於直線下方。來看個實際的例子。假設直線的方程式是
y = 0.5 x - 1
而點是分佈在x軸從-100~100,y軸從-100~100的平面上,那取點並判斷該點是位於直線的上方或下方,程式可以這樣寫:
def f(x):
return 0.5*x - 1
x = random.uniform(-100, 100)
y = random.uniform(-100, 100)
desired_answer = 1 if y>f(x) else -1
在這裡,我們把位於直線上的點,歸類成是位於直線下方。
有了合成的資料點之後,就可以把資料點拿來訓練感知器。在把資料點餵給感知器時,要記得加入偏置。假設perceptron是Perceptron物件,程式可以這樣寫:
training_input = [x, y, 1]
perceptron.train(training_input, desired_answer)
這樣子,感知器就會利用資料點來進行學習。
在接下來的例子中,我們會造一組合成資料,並用這組資料來訓練感知器。不過,在寫程式之前,要先來看看怎麼把資料點畫在顯示畫面上,因為兩者所用的座標系統不一樣。
資料點所在的座標系統,原點是在中間,而顯示畫面所用的座標系統,原點是在左上角。所以,要把資料點畫在顯示畫面上時,要先把座標轉換成繪圖用的座標系統,這樣才能正確地把點畫在顯示畫面上。轉換方式很簡單,假設顯示畫面的大小為WIDTH×HEIGHT,則資料點(x, y)在顯示畫面的繪圖座標會是
x' = x + WIDTH/2
y' = -y + HEIGHT/2
Example 10.1: The Perceptron




畫圖時,感知器認為位於直線上方的資料點,以實心的圓來標註;感知器認為位於直線下方的資料點,則以空心的圓來標註。由圖可以看出,感知器逐漸學會正確判斷資料點是位於直線的上方或下方。
為了方便觀察感知器的學習過程,所以學習常數設定為0.0001,讓學習速度慢一些。主程式如下:
def f(x):
# 劃分不同類資料點之直線方程式
return 0.5*x - 1
# python version 3.10.9
import random
import sys
import pygame # version 2.3.0
pygame.init()
pygame.display.set_caption("Example 10.1: The Perceptron")
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()
total_inputs = 3
learning_rate = 0.0001
perceptron = Perceptron(total_inputs, learning_rate)
# 製作合成資料
n = 2000
training_data = [None]*n
desired_answers = [None]*n
for i in range(n):
x = random.uniform(-WIDTH/2, WIDTH/2)
y = random.uniform(-HEIGHT/2, HEIGHT/2)
training_data[i] = [x, y, 1]
desired_answers[i] = 1 if y>f(x) else -1
# 劃分不同類資料點直線的兩個端點
# 左下方端點
x1, y1 = -WIDTH/2, f(-WIDTH/2)
# 右上方端點
x2, y2 = WIDTH/2, f(WIDTH/2)
# 轉換至繪圖座標
pt1 = x1+WIDTH/2, -y1+HEIGHT/2
pt2 = x2+WIDTH/2, -y2+HEIGHT/2
count = 0
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
screen.fill(WHITE)
perceptron.train(training_data[count], desired_answers[count])
# 為呈現訓練過程,一次只訓練一個點
count = (count + 1) % n
# 畫出劃分不同類資料點的直線位置
pygame.draw.line(screen, BLACK, pt1, pt2, 3)
# 畫出資料點
for data_point in training_data:
guess = perceptron.feed_forward(data_point)
x, y = data_point[0:2]
# 轉換至繪圖座標
x, y = x+WIDTH/2, -y+HEIGHT/2
if guess > 0:
# 感知器認為資料點位於直線上方
pygame.draw.circle(screen, BLACK, (x, y), 4)
else:
# 感知器認為資料點位於直線下方
pygame.draw.circle(screen, BLACK, (x, y), 4, 1)
pygame.display.update()
frame_rate.tick(FPS)
感知器的權重和區分資料點類別的邊界之間有著密切的關聯。在這個例子中,資料點是位於2D的平面上,區分資料點類別的邊界是一條直線。由感知器權重所構成的直線,就是感知器學習到的區分資料點類別的直線;這條直線的方程式為
w0 x + w1 y + b wb = 0
其中w0、w1是對應於2D平面x、y軸的權重;b是偏置,而wb則為其權重。如果資料點位於3D的空間中,那由感知器權重所構成的,會是一個二維的平面;這個平面的方程式為
w0 x + w1 y + w2 z + b wb = 0
推而廣之,如果資料點位於n維的空間中,那由感知器權重所構成的,將會是一個(n-1)維的超平面(hyperplane)。
Exercise 10.1
def f_boundary(x, weights):
# 感知器所習得,劃分不同類資料點之直線方程式
w0, w1, w2 = weights
return (-w2-x*w0)/w1
# python version 3.10.9
import random
import sys
import pygame # version 2.3.0
pygame.init()
pygame.display.set_caption("Exercise 10.1")
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
RED = (255, 0, 0)
screen_size = WIDTH, HEIGHT = 640, 240
screen = pygame.display.set_mode(screen_size)
FPS = 60
frame_rate = pygame.time.Clock()
total_inputs = 3
learning_rate = 0.0001
perceptron = Perceptron(total_inputs, learning_rate)
# 製作合成資料
n = 2000
training_data = [None]*n
desired_answers = [None]*n
for i in range(n):
x = random.uniform(-WIDTH/2, WIDTH/2)
y = random.uniform(-HEIGHT/2, HEIGHT/2)
training_data[i] = [x, y, 1]
desired_answers[i] = 1 if y>f(x) else -1
# 劃分不同類資料點直線的兩個端點
# 左下方端點
x1, y1 = -WIDTH/2, f(-WIDTH/2)
# 右上方端點
x2, y2 = WIDTH/2, f(WIDTH/2)
# 轉換至繪圖座標
pt1 = x1+WIDTH/2, -y1+HEIGHT/2
pt2 = x2+WIDTH/2, -y2+HEIGHT/2
count = 0
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
screen.fill(WHITE)
perceptron.train(training_data[count], desired_answers[count])
# 為呈現訓練過程,一次只訓練一個點
count = (count + 1) % n
# 畫出劃分不同類資料點的直線
pygame.draw.line(screen, BLACK, pt1, pt2, 3)
# 畫出資料點
for data_point in training_data:
guess = perceptron.feed_forward(data_point)
x, y = data_point[0:2]
# 轉換至繪圖座標
x, y = x+WIDTH/2, -y+HEIGHT/2
if guess > 0:
# 感知器認為資料點位於直線上方
pygame.draw.circle(screen, BLACK, (x, y), 4)
else:
# 感知器認為資料點位於直線下方
pygame.draw.circle(screen, BLACK, (x, y), 4, 1)
# 畫出感知器目前學習到的直線位置
# 左下方端點
x1, y1 = -WIDTH/2, f_boundary(-WIDTH/2, perceptron.weights)
# 右上方端點
x2, y2 = WIDTH/2, f_boundary(WIDTH/2, perceptron.weights)
# 轉換至繪圖座標
boundary_pt1 = x1+WIDTH/2, -y1+HEIGHT/2
boundary_pt2 = x2+WIDTH/2, -y2+HEIGHT/2
pygame.draw.line(screen, RED, boundary_pt1, boundary_pt2, 2)
pygame.display.update()
frame_rate.tick(FPS)
到目前為止,我們用來訓練感知器的資料都很單純,訓練起來也沒遇到什麼困難。然而,真實世界的資料可複雜多了,不僅輸入的種類繁多,其動態範圍(dynamic range),也就是最大值和最小值的比例,也各有不同。在面對真實世界這麼複雜的資料時,同樣的做法還管用嗎?
先前在製作合成資料時,是讓資料點平均散佈在顯示螢幕上,也就是在640×240的範圍中。在訓練感知器時,雖然x的範圍比y的範圍大很多,但因為我們所使用的啟動函數並不是只能在某些特定的範圍內運作,而且感知器只是在做簡單的二元分類(binary classification)而已,所以一切順利,沒什麼問題。但是,如果輸入的是真實世界遠較合成資料複雜的資料時,那可就不一定會這麼順利了。
那怎麼辦呢?人們開發類神經網路,就是希望能用來處理真實世界的問題;只能處理漂漂亮亮的合成資料的類神經網路,可是沒太大用處的。所幸,透過資料正規化(data normalization),就可以大幅降低資料集的複雜性,讓類神經網路在處理時,能夠順利一些。
資料正規化就是把資料映射到一個特定的範圍;通常是0~1或者-1~1。這個步驟在做機器學習時很關鍵,因為可以增進訓練效率,以及避免單一的輸入主導整個學習過程。為什麼呢?看看感知器的學習過程就知道了。
感知器的學習是藉由調整權重來達成的。在調整權重時,每次調整的大小是Δw。對於同一個資料點來說,因為學習常數和誤差是一樣的,所以對於不同方向的$Δw大小,輸入值的大小是造成其差異的主要因素。看個實際的例子會比較清楚。
先從單一的一個點來看。對於點(640, 240)而言,其對應的x、y方向Δw大小比例為640:240≈2.7:1$,所以x、y方向權重的調整幅度差異達到2.7倍。這也就是說,在這個點上,感知器的學習結果受x方向的輸入值影響較大,x方向的輸入值會站在比較主導的地位。
接下來從整個資料集的觀點來看。如果資料集的資料點很平均的分佈在x方向介於0~640、y方向介於0~240範圍內的話,因為有些點x比y大,有些點x比y小,那就還好,x、y方向的權重調整速度不至於會有太大的差異。然而,如果資料點是集中在(640, 240)附近的話,那就不妙了。這時因為大部分的資料點都是x方向的值比較大,所以x方向的權重調整速度會比y方向的權重調整速度快很多,感知器的學習過程會變成是x方向的值在主導;這種情況非常不利於感知器的學習。所以,透過正規化的方式,把x、y方向的值都映射到同樣的範圍內,那不會有哪個方向的值在主導學習的問題了。
那資料正規化實際上要怎麼做呢?假設資料範圍介於xmin~xmax之間,則將資料點映射至0~1的公式為
x' = x - xmin / xmax - xmin
其中x為原資料點,而x'為映射後之資料點。映射公式的推導可參考0.6節的Noise Ranges小節。
Exercise 10.2
族群的元素是感知器。基因型是感知器的權重;表現型則是感知器。
class Population:
def __init__(self, population_size, mutation_rate):
self.population_size = population_size
self.mutation_rate = mutation_rate
self.dna_length = 3
self.population = [Perceptron(DNA(self.dna_length)) for _ in range(population_size)]
self.generations = 0
def calculate_fitness(self, training_data, desired_answers):
for perceptron in self.population:
perceptron.calculate_fitness(training_data, desired_answers)
def evolve(self):
weights = [perceptron.fitness for perceptron in self.population]
next_generation = []
for i in range(self.population_size):
[parentA, parentB] = random.choices(self.population, weights, k=2)
child = parentA.dna.crossover(parentB.dna)
child.mutate(self.mutation_rate)
next_generation.append(Perceptron(child))
self.population = next_generation
self.generations += 1
def get_generations(self):
return self.generations
class DNA:
def __init__(self, length):
self.length = length
self.genes = [random.uniform(-1, 1) for _ in range(self.length)]
def crossover(self, partner):
child = DNA(self.length)
crossover_point = random.randint(0, self.length-1)
# 在crossover_point之前的基因來自此DNA,之後的基因則來自partner這個DNA
for i in range(self.length):
if i < crossover_point:
child.genes[i] = self.genes[i]
else:
child.genes[i] = partner.genes[i]
return child
def mutate(self, mutation_rate):
for i in range(self.length):
if random.random() < mutation_rate:
self.genes[i] = random.uniform(-1, 1)
class Perceptron:
def __init__(self, dna):
self.dna = dna
# 含偏置在內的輸入數量及其權重
self.total_inputs = len(self.dna.genes)
self.weights = self.dna.genes
self.fitness = 0
def activate(self, input_sum):
return 1 if input_sum>0 else -1
def feed_forward(self, inputs):
input_sum = sum([inputs[i]*self.weights[i] for i in range(self.total_inputs)])
return self.activate(input_sum)
def calculate_fitness(self, training_data, desired_answers):
n = len(training_data)
self.fitness = 2*n
for i in range(n):
self.fitness -= abs(desired_answers[i] - self.feed_forward(training_data[i]))
def f(x):
# 劃分不同類資料點之直線方程式
return 0.5*x - 1
def f_boundary(x, weights):
# 感知器所習得,劃分不同類資料點之直線方程式
w0, w1, w2 = weights
return (-w2-x*w0)/w1
# python version 3.10.9
import random
import sys
import pygame # version 2.3.0
pygame.init()
pygame.display.set_caption("Exercise 10.2")
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
RED = (255, 0, 0)
screen_size = WIDTH, HEIGHT = 640, 240
screen = pygame.display.set_mode(screen_size)
FPS = 60
frame_rate = pygame.time.Clock()
# 製作合成資料
n = 2000
training_data = [None]*n
desired_answers = [None]*n
for i in range(n):
x = random.uniform(-WIDTH/2, WIDTH/2)
y = random.uniform(-HEIGHT/2, HEIGHT/2)
training_data[i] = [x, y, 1]
desired_answers[i] = 1 if y>f(x) else -1
# 劃分不同類資料點直線的兩個端點
# 左下方端點
x1, y1 = -WIDTH/2, f(-WIDTH/2)
# 右上方端點
x2, y2 = WIDTH/2, f(WIDTH/2)
# 轉換至繪圖座標
pt1 = x1+WIDTH/2, -y1+HEIGHT/2
pt2 = x2+WIDTH/2, -y2+HEIGHT/2
# 建立GA族群並計算其元素之適應度
population_size = 100
mutation_rate = 0.01
population = Population(population_size, mutation_rate)
population.calculate_fitness(training_data, desired_answers)
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
screen.fill(WHITE)
population.evolve()
population.calculate_fitness(training_data, desired_answers)
# 找出最佳感知器
scores = [perceptron.fitness for perceptron in population.population]
idx = scores.index(max(scores))
best_perceptron = population.population[idx]
# 畫出劃分不同類資料點的直線
pygame.draw.line(screen, BLACK, pt1, pt2, 3)
# 畫出資料點
for data_point in training_data:
guess = best_perceptron.feed_forward(data_point)
x, y = data_point[0:2]
# 轉換至繪圖座標
x, y = x+WIDTH/2, -y+HEIGHT/2
if guess > 0:
# 最佳感知器認為資料點位於直線上方
pygame.draw.circle(screen, BLACK, (x, y), 4)
else:
# 最佳感知器認為資料點位於直線下方
pygame.draw.circle(screen, BLACK, (x, y), 4, 1)
# 畫出最佳感知器認為的直線位置
# 左下方端點
x1, y1 = -WIDTH/2, f_boundary(-WIDTH/2, best_perceptron.weights)
# 右上方端點
x2, y2 = WIDTH/2, f_boundary(WIDTH/2, best_perceptron.weights)
# 轉換至繪圖座標
boundary_pt1 = x1+WIDTH/2, -y1+HEIGHT/2
boundary_pt2 = x2+WIDTH/2, -y2+HEIGHT/2
pygame.draw.line(screen, RED, boundary_pt1, boundary_pt2, 2)
pygame.display.update()
frame_rate.tick(FPS)
Exercise 10.3
分別使用正規化後之資料來訓練完全相同的兩個感知器,然後比較其總誤差,也就是所有資料點判斷結果之誤差加總。
所有訓練資料都訓練過一次,稱為一個訓練期(epoch)。測試個幾次,將總誤差對訓練期作圖,結果如下:




從這幾次的訓練過程可以看出,用正規化後的資料來訓練,感知器學得比較慢,但比較穩定;使用原始資料訓練時,感知器雖然學得快,但比較容易虎頭蛇尾,總誤差會持續震盪。
為什麼會這樣呢?其實從Δw的計算公式就可以看出來了。在相同的學習常數設定條件下,因為感知器針對某個資料點的判斷誤差,就只有+2、0、-2三個值,而在計算Δ時,會將誤差乘上輸入值,所以輸入值的大小,將會對Δw有著比較大的影響。沒有正規化的資料,x方向的範圍介於0~640、y方向的範圍介於0~240,比正規化後的資料範圍0~1大很多,因此其所計算出的Δw會大很多。
所以,用沒有正規化的資料來訓練時,因為Δw比較大,感知器的學習速度會比較快;但就是因為快,所以比較容易衝過頭,導致總誤差比較容易上下震盪。反觀用正規化後的資料來訓練時,因為Δw比較小,所以感知器學得比較慢;但也因為學得比較慢,反而比較不會有衝過頭的情況發生,總誤差相對來說平穩多了。
測試程式如下:
def f(x):
# 劃分不同類資料點之直線方程式
return 0.5*x - 1
# python version 3.10.9
import copy
import random
import sys
import matplotlib.pyplot as plt # version 3.10.0
WIDTH, HEIGHT = 640, 240
total_inputs = 3
learning_rate = 0.0001
perceptron1 = Perceptron(total_inputs, learning_rate)
perceptron2 = copy.deepcopy(perceptron1)
# 製作合成資料
dataset_size = 2000
training_data = [None]*dataset_size
desired_answers = [None]*dataset_size
for i in range(dataset_size):
x = random.uniform(-WIDTH/2, WIDTH/2)
y = random.uniform(-HEIGHT/2, HEIGHT/2)
training_data[i] = [x, y, 1]
desired_answers[i] = 1 if y>f(x) else -1
# 正規化訓練資料
xmin = min(data[0] for data in training_data)
xmax = max(data[0] for data in training_data)
ymin = min(data[1] for data in training_data)
ymax = max(data[1] for data in training_data)
training_data_normal = []
for i in range(dataset_size):
x = (training_data[i][0]-xmin)/(xmax-xmin)
y = (training_data[i][1]-ymin)/(ymax-ymin)
training_data_normal.append([x, y, 1])
# 訓練感知器並記錄訓練期總誤差
epochs = 100
err1 = [None]*(epochs+1)
err2 = [None]*(epochs+1)
# 計算訓練前的總誤差
err1[0] = 0
err2[0] = 0
for i in range(dataset_size):
err1[0] += abs(desired_answers[i] - perceptron1.feed_forward(training_data[i]))
err2[0] += abs(desired_answers[i] - perceptron2.feed_forward(training_data_normal[i]))
# 訓練感知器並計算總誤差
for epoch in range(1, epochs+1):
for i in range(dataset_size):
# 使用原始資料訓練
perceptron1.train(training_data[i], desired_answers[i])
# 使用正規化後的資料訓練
perceptron2.train(training_data_normal[i], desired_answers[i])
# 計算訓練期總誤差
err1[epoch] = 0
err2[epoch] = 0
for i in range(dataset_size):
err1[epoch] += abs(desired_answers[i] - perceptron1.feed_forward(training_data[i]))
err2[epoch] += abs(desired_answers[i] - perceptron2.feed_forward(training_data_normal[i]))
# 總誤差對訓練期作圖
fig, ax = plt.subplots()
ax.plot(err1, color='black', label='original data')
ax.plot(err2, color='red', label='normalized data')
ax.set_xlim(left=0)
ax.set_ylim(bottom=0)
ax.set_xlabel('epoch')
ax.set_ylabel('total error')
plt.legend()
plt.show()











