前言
神經網絡是一種很特別的解決問題的方法。本書將用最簡單易懂的方式與讀者一起從最簡單開始,一步一步深入了解神經網絡的基礎算法。本書將盡量避開讓人望而生畏的名詞和數學概念,通過構造可以運行的Java程序來實踐相關算法。
關注微信號“邏輯編程"來獲取本書的更多信息。
這一章我們來編寫一個最簡單的神經元來完成一個函數的功能。代碼一共幾十行。但是這幾十行是一個很重要的起點。我們通過本章將掌握神經網絡的最基本原理和訓練方法。
假設我們有這么一個問題:給出一個x值,需要程序給出對應的y值。我們知道x和y有一定的線性關系,但是我們不知道具體的參數。還好我們有一些已知的數據對(x,y)可供研究學習。我們現在通過一個簡單的單個神經元來解決這個問題。
這個最簡單的神經元計算這樣一個函數: y = w*x + b。 在幾何上它是一條直線。其中w表示斜率,b表示對原點的偏移量。這兩個參數決定了直線在坐標系的位置。也就是說這個函數和兩個參數決定了我們這個神經元的輸入與輸出的關系。我們把w叫做權重(weight),b叫做偏差(bias)。
我們把這個神經元按照面向對象的方法可以寫出如下代碼:
public class SingleNeuron {
double weight;
double bias;
public SingleNeuron(double weight, double bias){
this.weight=weight;
this.bias=bias;
}
double f(double x){
return x * weight +bias;
}}
只要用合適的參數就能構造出一個神經元,它能根據輸入的x值給出相應的y值。但是,我們不知道這兩個參數,我們需要通過一組已知的輸入和輸入來訓練獲取相應的參數。
通常我們提供一些已經知道結果的數據集,通過學習讓程序自動找到合適的參數。這里為了方便測試,我們先假設一個目標參數,並且根據這個目標參數生成測試數據。
protected SingleNeuron getTarget() {
return new SingleNeuron(3, 3);
}
public double[][] generateTrainingData(int size) {
Random rand = new Random(System.nanoTime());
double[][] data = new double[size][];
SingleNeuron target = getTarget();
for (int i = 0; i < data.length; i++) {
double x = rand.nextDouble() * 100;
double y = target.f(x);
data[i] = new double[]{x, y};
}
return data;
}
下面我們討論怎么使用這些訓練數據來做訓練。訓練這個詞看上去很神秘,但本質上的原理還是比較簡單的。
訓練時,我們希望我們的神經元在計算上述x時,能得出與上面y值最接近的值。也就是說如果我們計算的值是a = w*x + b, 我們希望 c = |a - y| 的值最小。也就是 c = |w*x + b - y| 的值最小。現在我們已經知道了一些x和y, 我們需要知道的是w和b。所以對每個輸入訓練參數,我們可以產生一個不同的c函數。我們要求這個函數 c(w,b)的極小值時的w和b。
這里我們衍生出了一個新的函數c,它完全不是我們神經元原本的函數。它在神經網絡里叫做成本函數(cost)。我們給神經元添加下面的函數。我們這里暫且不考慮c = |a - y|中絕對值的問題。直接返回可能是正也可能是負的值。
double cost(double x, double y){
return f(x)-y;
}
那么我們怎么求函數的極值呢?
先從一個低緯度的例子來看一看。如果是一元函數,也就是平面坐標系里的一條曲線(直線沒極值),這條曲線的y一般先隨x值增大而減小,然后到達極小值,再變為隨x 值增大再逐步增大。比如下面這條拋物線 y = x^2 在x=0處取到最小值。
假設我們隨意選擇一個x值,在上邊曲線上面像坐滑梯一樣向下滑,我們就能到達底部最小值處。用稍微數學一點的語言就是說,我們隨意選一個起始點,那我們就沿着斜率向下(與斜率相反)的方向移動x。y = x^2在任意一點的斜率是 2*x 。這個斜率在微積分里叫做導數或者微分。對 y = x^2 我們可以寫如下代碼來移動x到最低點:
double x = 1;
while ( 2*x > 0.01 ){
if ( 2*x > 0 ) x -= 0.005;
if ( 2*x < 0) x += 0.005;
}
return x;
其中0.005是我們的步長。步長太小,循環次數就會變多;步長太大,可能直接邁過了最低點,反而去不到最小值。這里我們用0.01>0.005作為循環條件就是避免步子邁太大。如果我們用 2*x==0作為跳出條件, 我們可能永遠也達不到,因為步子大小是固定的,可能總是邁過最小點。(並且double值不應該用等號判斷相等。)上面的例子里可以可以直接看出函數最小值的點,但我們只是以此演示更基本原理。有時候函數很復雜,不是這么容易找出最小值。
那么回到我們的c(w,b)函數,它是一個二元函數,如何求它取最小值(或者說足夠小的值)時的w和b呢?
二元函數在三維空間坐標系里上可以形成一個曲面,我們要找這個曲面的最低點。好比在一個山谷里, 我們要沿着一條線下到谷底(高度最低處)。跟上邊二維坐標里的曲線類似。但是我們現在有兩個變量,好比我們在山谷里有東西和南北兩個維度。沿着東西方向走,我們可以選擇東方和西方兩個方向中下降的方向;沿着南北方向,我們可以選擇南方或者北方。或許東西方向一樣高度,正南方向或者正北方向就是下山谷最快的方向;也或許向西是下降方向,向南也是下降方向,此時某個西南方向肯定是下降最快的方向,這個西南方向是西方和南方兩個下降速度的綜合,是兩個矢量,類似於物理里的兩個不同方向力的合力。這個下降最快的方向我們稱之為梯度。我們現在要按照這個梯度方向下降,所以我們邁開步子,朝這個西南方向出發。具體的方向取決於兩個方向下降的速度的比值。但是在程序里其實很好處理,我們有兩個變量,讓它們各自按照自己的下降速度(或者說斜率、偏導數、偏微分)下降就行了。
就像在山谷中找出東西和南北兩個方向的斜率一樣,我們可以從兩個變量各自的維度考慮c(w,b)這個二元函數的梯度。由於c = |w*x + b - y|中絕對值的存在,我們需要對函數c(w,b)的斜率分段考慮。我們先去掉絕對值符號。
只考慮w維度:c(w) = w*x 這個函數是一條直線,斜率是x。
只考慮b維度:c(b) = b 這個函數也是一條直線,斜率是1。
我們可以總結出求多元函數的在某個維度的斜率(偏導數)時僅僅需要將其它變量看作常數。
這兩個斜率在給定的某個訓練數據(x,y)時,都是常數。所以我們這座山非常簡單,就是從兩個坐標方向看都是固定斜率的斜坡。根本沒有谷底。這是因為我們忽略了絕對值符號。
如果考慮絕對值符號,當cost=w*x+b-y>0和cost<0時,其梯度方向是相反的。我們將會有一條谷底是直線,並非一個點。這也是因為,二元函數y=w*x+b在只給出一個(x,y)時是有無窮多個(w,b)的解的。這些解組成一條直線。當有兩組(x,y)時我們可以確定(w,b)的值。
下圖是二元函數c = |w*x + b - y|的圖像化表示,其中c值較大的顯示紅色,較小的顯示黑色。實際上黑色山谷的橫截面是一個V字型。而黑色最低處形成一條直線。當我們有兩個輸入數據時,我們就有兩條直線山谷,它們的交點就是我們的目的地。當有三條或者更多時,它們可能不相交於同一點,現實世界中的很多數據雖然接近某個模型,但是難免有誤差。這時我們找到一個接近幾個交點的地方就可以了。
上圖在工具中使用的變量名根據工具的要求,必須使用red, x, y來代替c, w, b。其中的(5,20)實際上相當於訓練時已知的(x,y)。
我們去掉c(w,b)的絕對值的話,山谷就消失了,變成了一個空間中的傾斜平面。即c(w,b) = w*x + b - y,它的的梯度是(x, 1)。
// c = w*x + b - y
double[] gradient(double x, double y){
return new double[]{x, 1};
}
我們需要根據cost()返回值的正負號來獲得帶絕對值的cost函數的方向,這樣才能靠近c接近零的點-也就是絕對值最小的點。我們這里干脆讓兩個偏導數乘以c獲得帶絕對值符號的cost函數的導數。除以較大數的絕對值是因為斜率雖然大,我們距離目的地或許不遠,步子太大就跨過最小點了。后面乘以-1,向梯度反方向移動。因此我們的成本函數梯度可以寫成這樣:
public double[] gradient(double x, double y) {
double c = cost(x, y);
double dw = x * c;
double db = 1 * c;
double d = Math.max(Math.abs(dw), Math.abs(db));
if (d == 0) { d = 1; }
return new double[]{-dw / d, -db / d};}
接下來,我們可以考慮開始訓練。對每一個輸入的訓練數據,我們按照上邊說的方法,分別在兩個變量上邁開步子往谷底走一小步。這就是梯度下降算法。
public void train(double[][] data, double rate) {
for (int i = 0; i < data.length; i++) {
double x = data[i][0];
double y = data[i][1];
double[] gradient = gradient(x, y);
weight += gradient[0] * rate;
bias += gradient[1] * rate;
}
}
上面的函數我們引入了rate參數來控制步子的大小。同時我們循環在每個輸入樣本上作。當我們有2個的訓練數據時,我們每次往1條山谷垂直方向邁小步,然后向另外1條山谷的垂直方向邁一小步,走出一條之字形折線。這樣最終我們能走到兩條山谷的交點附近。
從上圖中我們也可以看到,當接近終點時有可能在某個維度上搖擺或者先到達目標值附近。
最后,我們用一個main方法來實現一個訓練過程。先任意給出我們的(w,b)初始值,這里給了(0,0)。然后在這個程序里循環使用了這些樣本100次,因為我們的步子很小,不重復走,我們邁不到谷底。
public static void main(String... args) {
SingleNeuron n = new SingleNeuron(0, 0);
//target: y = 3*x + 3;
double rate = 0.1;
int epoch = 100;
int trainingSize = 20;
for (int i = 0; i < epoch; i++) {
double[][] data = n.generateTrainingData(trainingSize);
n.train(data, rate);
System.out.printf("Epoch: %3d, W: %f, B: %f \n", i, n.weight, n.bias);
}
}
下面是我們可以運行的完整程序。讀者可以試着運行它。
package com.luoxq.ann.single;
import java.util.Random;
public class SingleNeuron {
double weight;
double bias;
public SingleNeuron(double weight, double bias) {
this.weight = weight;
this.bias = bias;
}
public double f(double x) {
return x * weight + bias;
}
public double cost(double x, double y) {
return f(x) - y;
}
// c = w*x + b - y
public double[] gradient(double x, double y) {
double c = cost(x, y);
double dw = x * c;
double db = 1 * c;
double d = Math.max(Math.abs(dw), Math.abs(db));
return new double[]{-dw / d, -db / d};
}
public void train(double[][] data, double rate) {
for (int i = 0; i < data.length; i++) {
double x = data[i][0];
double y = data[i][1];
double[] gradient = gradient(x, y);
weight += gradient[0] * rate;
bias += gradient[1] * rate;
}
}
protected SingleNeuron getTarget() {
return new SingleNeuron(3, 3);
}
public double[][] generateTrainingData(int size) {
Random rand = new Random(System.nanoTime());
double[][] data = new double[size][];
SingleNeuron target = getTarget();
for (int i = 0; i < data.length; i++) {
double x = rand.nextDouble() * 100;
double y = target.f(x);
data[i] = new double[]{x, y};
}
return data;
}
public static void main(String... args) {
SingleNeuron n = new SingleNeuron(0, 0);
//target: y = 3*x + 3;
double rate = 0.1;
int epoch = 100;
int trainingSize = 20;
for (int i = 0; i < epoch; i++) {
double[][] data = n.generateTrainingData(trainingSize);
n.train(data, rate);
System.out.printf("Epoch: %3d, W: %f, B: %f \n", i, n.weight, n.bias);
}
}
}
思考
1. 請讀者改變rate、epoch或者trainingSize看對學習的速度和精度有哪些影響。
2. 如果c=|a-y|不用絕對值方法,而改用c=(a-y)*(a-y),如何求導,會有什么效果。它的梯度還是一個平面嗎。
3. 試對訓練數據作些修改,使之有一定偏移量,看效果如何。
關注微信號“邏輯編程"來獲取本書的更多信息。