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 &#43;&#61; 0.005;
}
return x;
其中0.005是我们的步长。步长太小&#xff0c;循环次数就会变多&#xff1b;步长太大&#xff0c;可能直接迈过了最低点&#xff0c;反而去不到最小值。这里我们用0.01>0.005作为循环条件就是避免步子迈太大。如果我们用 2*x&#xff1d;&#xff1d;0作为跳出条件&#xff0c; 我们可能永远也达不到&#xff0c;因为步子大小是固定的&#xff0c;可能总是迈过最小点。(并且double值不应该用等号判断相等。)上面的例子里可以可以直接看出函数最小值的点&#xff0c;但我们只是以此演示更基本原理。有时候函数很复杂&#xff0c;不是这么容易找出最小值。
那么回到我们的c(w,b)函数&#xff0c;它是一个二元函数&#xff0c;如何求它取最小值(或者说足够小的值)时的w和b呢&#xff1f;
二元函数在三维空间坐标系里上可以形成一个曲面&#xff0c;我们要找这个曲面的最低点。好比在一个山谷里&#xff0c; 我们要沿着一条线下到谷底(高度最低处)。跟上边二维坐标里的曲线类似。但是我们现在有两个变量&#xff0c;好比我们在山谷里有东西和南北两个维度。沿着东西方向走&#xff0c;我们可以选择东方和西方两个方向中下降的方向&#xff1b;沿着南北方向&#xff0c;我们可以选择南方或者北方。或许东西方向一样高度&#xff0c;正南方向或者正北方向就是下山谷最快的方向&#xff1b;也或许向西是下降方向&#xff0c;向南也是下降方向&#xff0c;此时某个西南方向肯定是下降最快的方向&#xff0c;这个西南方向是西方和南方两个下降速度的综合&#xff0c;是两个矢量&#xff0c;类似于物理里的两个不同方向力的合力。这个下降最快的方向我们称之为梯度。我们现在要按照这个梯度方向下降&#xff0c;所以我们迈开步子&#xff0c;朝这个西南方向出发。具体的方向取决于两个方向下降的速度的比值。但是在程序里其实很好处理&#xff0c;我们有两个变量&#xff0c;让它们各自按照自己的下降速度(或者说斜率、偏导数、偏微分)下降就行了。
就像在山谷中找出东西和南北两个方向的斜率一样&#xff0c;我们可以从两个变量各自的维度考虑c(w,b)这个二元函数的梯度。由于c &#61; |w*x &#43; b - y|中绝对值的存在&#xff0c;我们需要对函数c(w,b)的斜率分段考虑。我们先去掉绝对值符号。
只考虑w维度&#xff1a;c(w) &#61; w*x 这个函数是一条直线&#xff0c;斜率是x。
只考虑b维度&#xff1a;c(b) &#61; b 这个函数也是一条直线&#xff0c;斜率是1。
我们可以总结出求多元函数的在某个维度的斜率(偏导数)时仅仅需要将其它变量看作常数。
这两个斜率在给定的某个训练数据(x,y)时&#xff0c;都是常数。所以我们这座山非常简单&#xff0c;就是从两个坐标方向看都是固定斜率的斜坡。根本没有谷底。这是因为我们忽略了绝对值符号。
如果考虑绝对值符号&#xff0c;当cost&#61;w*x&#43;b-y>0和cost<0时&#xff0c;其梯度方向是相反的。我们将会有一条谷底是直线&#xff0c;并非一个点。这也是因为&#xff0c;二元函数y&#61;w*x&#43;b在只给出一个(x,y)时是有无穷多个(w,b)的解的。这些解组成一条直线。当有两组(x,y)时我们可以确定(w,b)的值。
下图是二元函数c &#61; |w*x &#43; b - y|的图像化表示&#xff0c;其中c值较大的显示红色&#xff0c;较小的显示黑色。实际上黑色山谷的横截面是一个V字型。而黑色最低处形成一条直线。当我们有两个输入数据时&#xff0c;我们就有两条直线山谷&#xff0c;它们的交点就是我们的目的地。当有三条或者更多时&#xff0c;它们可能不相交于同一点&#xff0c;现实世界中的很多数据虽然接近某个模型&#xff0c;但是难免有误差。这时我们找到一个接近几个交点的地方就可以了。
上图在工具中使用的变量名根据工具的要求&#xff0c;必须使用red, x, y来代替c, w, b。其中的(5,20)实际上相当于训练时已知的(x,y)。
我们去掉c(w,b)的绝对值的话&#xff0c;山谷就消失了&#xff0c;变成了一个空间中的倾斜平面。即c(w,b) &#61; w*x &#43; b - y&#xff0c;它的的梯度是(x, 1)。
// c &#61; w*x &#43; b - y
double[] gradient(double x, double y){
return new double[]{x, 1};
}
我们需要根据cost()返回值的正负号来获得带绝对值的cost函数的方向&#xff0c;这样才能靠近c接近零的点-也就是绝对值最小的点。我们这里干脆让两个偏导数乘以c获得带绝对值符号的cost函数的导数。除以较大数的绝对值是因为斜率虽然大&#xff0c;我们距离目的地或许不远&#xff0c;步子太大就跨过最小点了。后面乘以-1&#xff0c;向梯度反方向移动。因此我们的成本函数梯度可以写成这样&#xff1a;
public double[] gradient(double x, double y) {
double c &#61; cost(x, y);
double dw &#61; x * c;
double db &#61; 1 * c;
double d &#61; Math.max(Math.abs(dw), Math.abs(db));
if (d &#61;&#61; 0) { d &#61; 1; }
return new double[]{-dw / d, -db / d};
}
接下来&#xff0c;我们可以考虑开始训练。对每一个输入的训练数据&#xff0c;我们按照上边说的方法&#xff0c;分别在两个变量上迈开步子往谷底走一小步。这就是梯度下降算法。
public void train(double[][] data, double rate) {
for (int i &#61; 0; i double x &#61; data[i][0];
double y &#61; data[i][1];
double[] gradient &#61; gradient(x, y);
weight &#43;&#61; gradient[0] * rate;
bias &#43;&#61; gradient[1] * rate;
}
}
上面的函数我们引入了rate参数来控制步子的大小。同时我们循环在每个输入样本上作。当我们有2个的训练数据时&#xff0c;我们每次往1条山谷垂直方向迈小步&#xff0c;然后向另外1条山谷的垂直方向迈一小步&#xff0c;走出一条之字形折线。这样最终我们能走到两条山谷的交点附近。
从上图中我们也可以看到&#xff0c;当接近终点时有可能在某个维度上摇摆或者先到达目标值附近。
最后&#xff0c;我们用一个main方法来实现一个训练过程。先任意给出我们的(w,b)初始值&#xff0c;这里给了(0,0)。然后在这个程序里循环使用了这些样本100次&#xff0c;因为我们的步子很小&#xff0c;不重复走&#xff0c;我们迈不到谷底。
public static void main(String... args) {
SingleNeuron n &#61; new SingleNeuron(0, 0);
//target: y &#61; 3*x &#43; 3;
double rate &#61; 0.1;
int epoch &#61; 100;
int trainingSize &#61; 20;
for (int i &#61; 0; i double[][] data &#61; 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 &#61; weight;
this.bias &#61; bias;
}
public double f(double x) {
return x * weight &#43; bias;
}
public double cost(double x, double y) {
return f(x) - y;
}
// c &#61; w*x &#43; b - y
public double[] gradient(double x, double y) {
double c &#61; cost(x, y);
double dw &#61; x * c;
double db &#61; 1 * c;
double d &#61; 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 &#61; 0; i double x &#61; data[i][0];
double y &#61; data[i][1];
double[] gradient &#61; gradient(x, y);
weight &#43;&#61; gradient[0] * rate;
bias &#43;&#61; gradient[1] * rate;
}
}
protected SingleNeuron getTarget() {
return new SingleNeuron(3, 3);
}
public double[][] generateTrainingData(int size) {
Random rand &#61; new Random(System.nanoTime());
double[][] data &#61; new double[size][];
SingleNeuron target &#61; getTarget();
for (int i &#61; 0; i double x &#61; rand.nextDouble() * 100;
double y &#61; target.f(x);
data[i] &#61; new double[]{x, y};
}
return data;
}
public static void main(String... args) {
SingleNeuron n &#61; new SingleNeuron(0, 0);
//target: y &#61; 3*x &#43; 3;
double rate &#61; 0.1;
int epoch &#61; 100;
int trainingSize &#61; 20;
for (int i &#61; 0; i double[][] data &#61; 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&#61;|a-y|不用绝对值方法&#xff0c;而改用c&#61;(a-y)*(a-y)&#xff0c;如何求导&#xff0c;会有什么效果。它的梯度还是一个平面吗。
3. 试对训练数据作些修改&#xff0c;使之有一定偏移量&#xff0c;看效果如何。
关注微信号“逻辑编程"来获取本书的更多信息。