引言
这一段时间研究生生涯已经走进了尾声,也一直忙于论文没有关注前端方面的工作。偶然的机会,在知乎上看到了一篇文章前端人工智能?TensorFlow.js 学会游戏通关。我的内心是很激动的,终于,也能在前端直接搭神经网络,跑分类了。不过该文章,对于tensorflowjs的介绍太少,直观的游戏ai应用确实很好,但是对于初次接触的人,还是从最基础的框架使用开始更好,于是就有了这篇文章。本文抛开了复杂的实际问题,选择曲线拟合这个最简单易懂的情景,使用3种逐步深入的方法来完成目标问题的解决。从最基础的数学方法,到借助底层api构建神经网络,再到最终借助高层次api构建神经网络,一步步熟悉框架的使用,希望能够帮助后来者快速上手。 demo地址(目测手机版后面两个方法训练不出来,建议pc版访问)
目标问题介绍
本文的目标是实现曲线拟合。直观上,曲线可看成空间质点运动的轨迹,用数学表达式来就是y=f(x),举个例子,y=x,y=x^2+5......等等,就是曲线。而曲线拟合,用简单的话来说,就是知道一条曲线的某些点,来预测要 y=f(x) 的表达式是什么。如下图:
image.png
图中,蓝色点点就是已知曲线中的某些点(本例中数目为100),红色曲线就是拟合出的结果,也就是本文要实现的曲线拟合。
工具,环境说明
用到的前端相关的库有:
tensorflow.js,用来搭建神经网络,训练等。tensorflow的文档写的很好,第一篇讲了核心概念,第二篇就讲到了如何拟合一条曲线,不过它只使用了线性模型的方法进行拟合,没有通过神经网络,这也是本文存在的理由,笔者在看了该文档之后,一方面,将文档中提到的方法采用面向对象的方法重构,另一方面,通过学习其api,搭建神经网络来实现相同的功能。
vega-embed,这个就是用来绘制曲线和散点的工具,用escharts等可视化工具也行,这里不是关键。
另外由于在代码中使用了class,async等等es6,es7的用法,故在实际使用的时候需要用到babel。本文使用的打包工具是parcel,号称是零配置的 Web 应用程序打包器,体验了一下,确实好用。
源码放在了github上,使用说明见README.md。demo更加直观。
样本点数产生方法
在这个问题中,首先需要产生样点。本文设定曲线方程为 y = ax^3 + bx^2 + c*x + d,首先随机产生100个x值,再带入方程计算y值。最终产生本文的测试样点,核心代码如下:其中涉及到一些tensorflow的api,会穿插在注释中简单介绍。
//导入tensorflowimport * as tf from '@tensorflow/tfjs';/* *输入参数: *num:样点数目 *coeff:参数对象{a: , b: , c: , d: }; *sigma:样点偏移原曲线范围 *输出参数: { x: 样点横坐标值 y: 样点纵坐标值 } */export function generateData(num, coeff, sigma = 0.04) { //将代码用tf.tidy包裹,可以清除执行过程中的tensor变量 return tf.tidy(() => { //tf.scalar: 产生一个tensor变量,其值为输入的参数 const [a, b, c, d] = [ tf.scalar(coeff.a), tf.scalar(coeff.b), tf.scalar(coeff.c), tf.scalar(coeff.d) ] //tf.randomUniform([num],-1,1): 产生-1,1之间的均匀分布的值组成的[num] //矩阵,此处就是1*100的矩阵, [2,3]表示2*3的矩阵(二维数组) const x = tf.randomUniform([num], -1, 1); //计算a*x^3 + b*x^2 + c*x + d+(0~sigma之间服从正太分布的随机值) const y = a.mul(x.pow(tf.scalar(3))) .add(b.mul(x.square())) .add(c.mul(x)) .add(d) .add(tf.randomNormal([num], 0, sigma)); //对输出值进行归一化 const ymin = y.min(); const ymax = y.max(); const yrange = ymax.sub(ymin); const yNormalized = y.sub(ymin).div(yrange); return { x, yNormalized }; }) }
其中,api仅仅介绍了当前情景的功能,其还有一些可选参数没有进行介绍,具体可以查看官方文档,基本上看名字能猜出大概,结合官方文档不难理解,此处不再过多介绍。
从上述代码可以看出,TensorFlow 提供了很好的api,包括服从均匀分布,正态分布参数的产生,tensor类型带来的矩阵加减乘除运算,最大最小值计算等功能,以及自带的缓存清理函数tidy。这些方法构成了整个运算的基础,大大方便了使用者。
这一小节中,通过上述代码,产生num个x,y,构成了本文所述的样本点,为后文曲线拟合做好了样本准备。后续将借助TensorFlow 通过3种不同方法实现对该曲线的拟合。
线性模型方法实现曲线拟合
首先介绍第一种方法,也就是tensorflow文档第二篇中提到的方法---构建线性模型进行拟合。这个方法需要有一个已知条件,即已经知道预测的模型为 y = a*x^3 + b*x^2 + c*x + d。
知道算法模型之后,原理如下:
初始化a,b,c,d,取随机值即可
根据随机的参数a,b,c,d按照模型 y = a*x^3 + b*x^2 + c*x + d对100个点进行计算,根据得到的结果,采取一定的手段(原理是偏导,但是这里不需要自己计算,tensorflow会解决这里的调整问题)调整a,b,c,d。使计算出来的y与原来的y差值最小。
经过多次步骤二,y与原本的y值差值足够小,就可以认为a,b,c,d就是要求的最终参数。此时,根据该曲线计算出结果并绘制出来,就实现了曲线的拟合。
将上述过程进行抽象,可以得到以下几个过程:
predict(inputXs),根据已知的样点,计算该样点对应的y值
loss(predectedYs,inputYs),计算y与输入的y的差值
train(inputXs,inputYs),进行一次训练,通过调整参数来使得loss更小
fit(inputXs,inputYs,iterations),进行曲线拟合,多次调用train来完成训练
根据上述方法,构建了一个简单的线性模型类,代码如下:
import {Model} from './model';import * as tf from '@tensorflow/tfjs';function random(){ return (Math.random()-0.5)*2; }export class Linear_Model extends Model{ constructor(){ super(); this.init(); } init(){ this.weights = []; this.weights[0] = tf.variable(tf.scalar(random()));//对应参数a this.weights[1] = tf.variable(tf.scalar(random()));//对应参数b this.weights[2] = tf.variable(tf.scalar(random()));//对应参数c this.bias = tf.variable(tf.scalar(random()));//对应参数d this.learningRate = 0.5;//设置优化器,自动调整参数 this.optimizer = tf.train.sgd(0.5); }//根据输入样点计算输出 predict(inputXs){ return tf.tidy(()=>{//y = weight[0]*x^3+weight[1]*x^2+weight[2]*x+biases return this.weights[0].mul(inputXs.pow(tf.scalar(3))) .add(this.weights[1].mul(inputXs.square())) .add(this.weights[2].mul(inputXs)) .add(this.bias); }) } train(inputXs,inputYs){//通过优化器的minimize方法来实现对参数的减少 this.optimizer.minimize(()=>{//根据输入预测输出 const predictedYs = this.predict(inputXs);//计算预测输出与原本的输出差值 return this.loss(predictedYs,inputYs); }) }//计算差值,此处采用均方误差,就是差值平方再取平均值 loss(predictedYs,inputYs){ return predictedYs.sub(inputYs).square().mean(); }//多次调用train来调整参数 fit(inputXs,inputYs,iterationCount = 100){ for(let i = 0;i<iterationCount;i++){ this.train(inputXs,inputYs); } } }
在上述代码中,用到了tensorflow的tf.train.sgd();方法,这个方法定义了一个优化器,就是通过调整参数来实现loss的不断降低,sgn是梯度下降法,类似的还有adam等。其优化的变量涉及到了inputXs,inputYs,weights(对应之前说的a,b,c,d),那么它是如何判断哪些参数可以调整,哪些不能调整的呢?答案是tf.variable,在优化器优化的过程中,只能调整涉及到的通过tf.variable定义过的变量,在这个例子中,就只有this.weights。当执行train方法的时候,优化器会根据loss的计算过程,调整variable参数,使得loss往小的方向去走(严格来讲,不一定,和学习率等很多因素有关,但是这里问题比较简单,故不讨论,感兴趣可以看看coursera上吴恩达的机器学习课程)。经过多次train之后,就可以得到合适的参数,此时loss只要足够低,那么使用这些参数得到的结果就与愿结果无限趋近,可以认为实现了曲线的拟合。
最终调用代码如下:
import { Linear_Model } from './linear_model';import { generateData } from './data';import { plotData, plotCoeff, plotDataAndPredictions } from './ui'import * as tf from '@tensorflow/tfjs';async function liner_method() {//新建线性预测模型 const linear_model = new Linear_Model(); const trueCoefficients = { a: -.8, b: -.2, c: .9, d: .5 };//调用数据产生函数,产生测试样本 const trainingData = generateData(100, trueCoefficients);//调用ui层的方法进行样点的绘制,此处ui层不做详细介绍 await plotData('#data .plot', trainingData.x, trainingData.yNormalized);//先做一次预测,看看初始参数拟合的曲线形状 const predictionsBefore = linear_model.predict(trainingData.x);//绘制样点和曲线 await plotDataAndPredictions('#random .plot', trainingData.x, trainingData.yNormalized, predictionsBefore);//调用fit方法进行训练 linear_model.fit(trainingData.x, trainingData.yNormalized);//再次计算曲线,此时参数已经经过训练 const predictionsAfter = linear_model.predict(trainingData.x);//绘制曲线 await plotDataAndPredictions('#trained .plot', trainingData.x, trainingData.yNormalized, predictionsAfter); }
上述代码就是对本文定义的Linear_Model的一个使用方法,最终完成了曲线拟合这个目标。
这种方法的训练速度快,但是缺点在于需要事先知道模型形状(y = ax^3 + bx^2 + c*x + d),不然不好进行预测。到这里,其实还没有涉及到神经网络的使用,但是所谓神经网络本质上也是参数的不断调整,只是更加复杂一些。加下来将使用底层api构建一个包含一层隐含层的神经网络来解决这个问题。不需要事先知道模型形状也能完成曲线的拟合。
底层api构建神经网络实现曲线拟合
神经网络的原理这里就不介绍了,感兴趣可以看coursera上吴恩达或者ufldl的介绍,都比较详细。
在这个问题中,由于是线性函数,所以拟合起来并不困难,这里采取1-6-1的结构,第一个1是输入层,这里是一维,所以输入层为1,隐含层选择6,这里其实5,4,7都可以,只是需要训练的次数不同而已。最后一层输出层为1,因为输出就是y值,也是一维的。
按照上一节抽象出来的过程,也需要手动实现predict,loss等函数。神经网络,与上述不同的地方就在与参数的构建,predict计算方式不同,其它地方其实基本一样。代码如下:
import { Model } from './model';import * as tf from '@tensorflow/tfjs';export default class NNModel extends Model { constructor({ inputSize = 3, hiddenLayerSize = inputSize * 2, outputSize = 2, learningRate = 0.1 } = {}) { super();//定义隐藏层,输入层,输出层,优化器函数 this.hiddenLayerSize = hiddenLayerSize; this.inputSize = inputSize; this.outputSize = outputSize; this.optimizer = tf.train.adam(learningRate); this.init(); }//初始化神经网络参数 init() { this.weights = []; this.biases = [];//第一层参数为1*6的矩阵 this.weights[0] = tf.variable( tf.randomNormal([this.inputSize, this.hiddenLayerSize]) );//第一层偏置 this.biases[0] = tf.variable(tf.scalar(Math.random())); // Output layer//第二层参数为6*1的矩阵 this.weights[1] = tf.variable( tf.randomNormal([this.hiddenLayerSize, this.outputSize]) ); this.biases[1] = tf.variable(tf.scalar(Math.random())); }//预测函数,激活函数选择sigmoid,matMux表示矩阵乘法 predict(inputXs) { const x = tensor(inputXs); return tf.tidy(()=>{ const hiddenLayer = tf.sigmoid(x.matMul(this.weights[0]).add(this.biases[0])); const outputLayer = tf.sigmoid(hiddenLayer.matMul(this.weights[1]).add(this.biases[1])); return outputLayer; }) } train(inputXs,inputYs){ this.optimizer.minimize(()=>{ const predictedYs = this.predict(inputXs); return this.loss(predictedYs,inputYs); }) } loss(predictedYs, inputYs) { const meanSquareError = predictedYs .sub(tensor(inputYs)) .square() .mean(); return meanSquareError; } }
上述代码中,涉及到了sigmoid函数,也就是神经网络的激活函数,很基础的概念,不多介绍。另外一个就是matMux,相当于矩阵乘法。
在实际使用时,方法和线性模型几乎一致,此处不贴代码,最终需要进行500多次的训练,才能达到和上述线性模型同样的效果。但是在不知道模型的情况下,还能拟合该曲线,这就是神经网络方法最大的优势。不需要人为构建模型,也能解决问题。
但是上述的写法存在一个问题,就是现在是1个隐含层,计算可以通过predict中两三行的代码搞定,但是层数多了之后,手动的一次次编写中间代码,实在也是一个体力活,而且容易出错。为了解决这个问题,tensorflow提供了一种更高层次的构建方法,就是下一节要介绍的方法。
高层次api构建神经网络
在tensorflow中,有一个高层次的api,tf.sequential(),其用法直接通过实例来解释:
const model = tf.sequential(); model.add(tf.layers.dense({ units: 6, inputShape: [1], activation:'sigmoid' })); model.add(tf.layers.dense({ units:1, activation:'sigmoid' })); model.compile({ optimizer:tf.train.adam(0.1), loss:'meanSquaredError' })
通过上述的代码,就构建了一个神经网络,该网络有3层,一个是输入层,1维,隐藏层,6维,最后输出层,1维。上述代码中,model.add了两次,这是因为输入层其实就是输入样本,不需要计算,所以不需要添加,只需要在后续层添加的时候指定inputShape即可。其中,activation就是激活函数,这里直接选择signoid,而compile,就是完成模型的构建,需要指定优化器和loss计算方法(可以用字符串也可以传入一个自定义计算的函数)。此时,就完成了一个神经网络的搭建。用法如下:
//预测样本对应的值const predictionsBefore = model.predict(trainingData_nn.x);//绘制结果await plotDataAndPredictions('#random3 .plot', trainingData.x, trainingData.yNormalized, predictionsBefore);//进行训练const h = await model.fit(trainingData_nn.x,trainingData_nn.yNormalized,{ epochs:200, batchSize:100})//训练结束后再次计算曲线y值const predictionsAfter = model.predict(trainingData_nn.x);//绘制结果await plotDataAndPredictions('#trained3 .plot', trainingData.x, trainingData.yNormalized, predictionsAfter);
可以看出,tensorflow提供的model,可以直接使用fit,predict,同时不需要手动指定weights,可以说是很方便了。
小结
本文算是对官方文档的一个深入,选择最简单的曲线拟合问题入手,从最简单的线性模型到手动搭建神经网络,再到利用高层api来搭建神经网络,解决了曲线拟合的问题。
总体来说,最好的搭建姿势还是借助高层api,可以很方便快捷的搭建想要的神经网络,十分好用。希望能让后来者少走一些弯路。当然,可能文中也会有些错误,如有发现,还请指出,谢谢。
作者:small_a
链接:https://www.jianshu.com/p/67cd2d2e0bab