代码改变世界

神经网络入门 第2章 编写第一个神经元

2017-04-19 12:48  JavaDaddy  阅读(1408)  评论(6编辑  收藏  举报

 

    前言

    神经网络是一种很特别的解决问题的方法。本书将用最简单易懂的方式与读者一起从最简单开始,一步一步深入了解神经网络的基础算法。本书将尽量避开让人望而生畏的名词和数学概念,通过构造可以运行的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. 试对训练数据作些修改,使之有一定偏移量,看效果如何。

 

 

关注微信号“逻辑编程"来获取本书的更多信息。

Logical Programming