注意:本篇为50天后的Java自学笔记扩充,内容不再是基础数据结构内容而是机器学习中的各种经典算法。这部分博客更侧重于笔记以方便自己的理解,自我知识的输出明显减少,若有错误欢迎指正!
前言
上个博客已经说了BP神经网络的特征, 并且根据Sigmoid激活函数完成了固定的BP神经网络代码. 但是需要明白的一个事实就是...BP神经网络并不是一成不变的, 它的设置是非常灵活的, 这不单单体现在隐层层数可以自定义, 层的深度可以自定义, 甚至每一层使用的激活函数也可以存在差异. 例如在隐层我们一般喜欢用ReLU激活函数, 用于分类目的输出层常采用Sigmoid函数.
因此, 后续的博客计划强化面向对象的代码设计, 展现BP神经网络的扩展性, 将分两天从激活函数封装, 单层神经网络封装, 总体组合与测试三个步骤再去重写BP的代码, 增强易用性与可重用性.
*相关文章目录*
*本篇目录*
1. 激活函数与求导式
激活函数是改变BP神经网络线性特征的转换函数, 是用于forward进行预测的关键一步. 而求导是通过激活函数得到的最终结果与目标值的偏差的偏导, 这个求导目标是边权, 但是通过链式法则, 最终会影响到对激活函数的求导.
之前博客的代码中我只用Sigmoid完成了神经网络的搭建, 而今天将扩展一些可用的神经网络. 具体来说, 我将创建的一个激活函数类, 然后将某些激活函数本身以及自身的求导操作封装在一起.
public class Activator {
//...
}// Of class Activator
1.1 Sigmod函数
/**
* Sigmoid.
*/
public final char SIGMOID = 's';
\[\operatorname{Sigmoid}(x)=\frac{1}{1+e^{-x}} \tag{1}\] Sigmoid的导函数:\[f^{\prime}(x)=f(x)(1-f(x)) \tag{2}\]
定义域为\((-\infty,+\infty)\), 值域为\((0,1)\), 随着定义域取值的变大, 函数图像不断趋近于1, 而随着定义域的缩小逐步趋近于0. 函数图像在靠近定义域中心0时速度变快, 但是分散到两端相对平滑.
Sigmoid是比较常见于许多介绍BP神经网络文章中的激活函数(可能是算式简单?), 也是我的上个介绍BP的博客中所使用的函数. 但是通过简单的查阅, 得知Sigmoid并不是一个最好的激活函数.
- 首先Sigmoid在函数值接近饱和时候, 因为曲线减缓, 导致导数接近0, 通过若干链式组合后的数学式子中得到的最终值可能会被导数过小值特性干扰.---- 梯度消失(gradient vanishing)现象
- Sigmoid存在方向的捆绑, 难以跳出局部的解. 因此Sigmoid的值域并不沿着0对称(zero-centered), 所以后层的神经元的输入是非0均值, 这对于梯度来说有一个基本的定式逻辑: 以\(f=\operatorname{Sigmoid}(wx+b)\)为例, 对\(w\)求导后的结果与输入值是同正负的. 这样效果直接的效果就是梯度下降存在捆绑, 反向传播过程中要么都往正方向更新,要么都往负方向更新. 这会减缓收敛, 出现锯齿形下降.
- 幂运算耗时
上文提到的zero-centered问题可以详见这篇文章:关于Sigmoid数据输出不是zero-centered的理解_弋墨尘的博客-CSDN博客_zero-centered讲到Sigmoid函数时,有一个缺点是Sigmoid函数的输出不是零中心的,那么为什么我们更需要一个零中心的激活函数呢?在上cs231n的时候,对老师关于这部分内容的讲解云里雾里,在查阅一些资料后,放上自己的理解:假设我们现在有两个函数,分别是一个线性加权函数和一个激活函数,我们知道在一个神经网络中这样的函数将会一层层重叠,例如 ( f -> L ) * n。对于sigmoid函数来...https://blog.csdn.net/weixin_43835911/article/details/89294613 激活函数(函数activate)的代码部分:
switch (activator) {
//...
case SIGMOID:
resultValue = 1 / (1 + Math.exp(-paraValue));
break;
default:
System.out.println("Unsupported activator: " + activator);
System.exit(0);
}// Of switch
求导部分封装(函数derive)
switch (activator) {
//...
case SIGMOID:
resultValue = paraActivatedValue * (1 - paraActivatedValue);
break;
default:
System.out.println("Unsupported activator: " + activator);
System.exit(0);
}// Of switch
1.2 Tanh函数
/**
* Tanh.
*/
public final char TANH = 't';
\[ \operatorname{Tanh}(x)=\frac{e^{x}-e^{-x}}{e^{x}+e^{-x}} \tag{3}\] 有导数\[f^{\prime}(x) = 1-f^{2}(x) \tag{4}\]
定义域为\((-\infty,+\infty)\), 值域为\((-1,1)\), 随着定义域取值的变大, 函数图像不断趋近于1, 而随着定义域的缩小逐步趋近于-1. 函数图像在靠近定义域中心0时速度变快, 但是分散到两端相对平滑. (这段描述和Sigmoid基本一样啊!)
就函数来看, 这个图像非常像Sigmoid, 只不过在值域设置上相比Sigmoid, 定义域左半轴的函数图像落入了负区域, 这样使得结点取值的变化范围极大地提高了, 这是相比Sigmoid的优点, 即zero-centered中心化数据. 让均值接近于0, 而不是0.5, 这几乎让tanh可以胜任许多除了输出层以外的各种场合. 输出层的激活函数设置要视情况而定, 若我们希望的目标\(\mathbf{y}\)位于\((0,1)\), 那么就选Sigmoid.
本质来说, tanh其实可以通过Sigmoid变换得到: \(\tanh (x)=2 \operatorname{sigmoid}(2 x)-1\)
所以很多曲线层面的缺陷tanh也有哟. tanh函数值接近饱和时候的照样会曲线减缓, 导数接近0, 影响链式组合后的数学式子, 存在显而易见的梯度消失(gradient vanishing)现象. 而且tanh的幂运算依旧很多.
激活函数(函数activate)的代码部分(这里对式3上下同除\(e^x\)就行了, 高中数学):
switch (activator) {
//...
case TANH:
resultValue = 2 / (1 + Math.exp(-2 * paraValue)) - 1;
break;
default:
System.out.println("Unsupported activator: " + activator);
System.exit(0);
}// Of switch
求导部分封装(函数derive)
switch (activator) {
//...
case TANH:
resultValue = 1 - paraActivatedValue * paraActivatedValue;
break;
default:
System.out.println("Unsupported activator: " + activator);
System.exit(0);
}// Of switch
1.3 ReLU函数
/**
* Relu.
*/
public final char RELU = 'r';
\[\operatorname{ReLU}(x) = max(0,x) \tag{5}\] 有导数\[f^{\prime}(x)=\left\{\begin{array}{l} 1, x>0 \\ 0, x \leqslant 0 \end{array}\right. \tag{6}\]
定义域为\((-\infty,+\infty)\), 值域为\((0,1)\), 随着定义域取值的变大, 函数图像不断趋近于\(+\infty\), 而定义域进入负数域, 值域保持0不变.
ReLU(Rectified Linear Units)函数的全称为修正线性单元, 是一种分段线性函数, 其弥补了sigmoid函数以及tanh函数的梯度消失问题(很明显, 后面都成正比例函数了)。而且计算复杂度非常低, 没有开销大的幂指数运算. 并且适合backPropagation(他的导数是一个类sgn函数) 此外因为她很简单, 而且分段呈现线性, 因此ReLU也容易学习和优化(一大堆基于ReLU的扩展版本)
但是ReLU也有些比较出名的缺陷:
- 首先输出不是zero-centered. 这一点只要之前看懂了1.1与1.2那便不难得出.
- 可能会因为参数的不合理初始化, 或者梯度下降因子设置过大导致下降幅度过大导致的神经元坏死现象(Dead ReLU Problem). 即某些神经元可能会被置0, 后续学习过程中它将永远无法被激活, 有关边权也永远不会更新(这很自然, 因为定义域一旦不小心变成0你就给人家一棒子敲死了) 可以采用如下办法调节
- Xavier初始化方法
- 别把梯度下降因子设置得太大
- adagrad自动调节梯度下降因子
- ReLU并不满足形如Sigmoid与Tanh等 这样的" 挤压函数(squashing function) ", 挤压函数可以把较大范围变化的数据挤压到一个有界的值域空间, 即幅度压缩. 幅度压缩应当是限制数据大小的一个很有效的方法, ReLU显然并不具备这样特性.
这里提一句, 关于Dead ReLU问题也许可以辩证看待, 当部分神经元输出为0时, 直观造成网络的稀疏性, 减少了参数相互依存的关系, 某种意义上也缓解了过拟合的发生. 这也是为什么有些改进后的ReLU在削减了负半轴一味取零的特性后还是存在比不过原算法的情况.
激活函数(函数activate)的代码部分:
switch (activator) {
//...
case RELU:
if (paraValue >= 0) {
resultValue = paraValue;
} else {
resultValue = 0;
} // Of if
break;
default:
System.out.println("Unsupported activator: " + activator);
System.exit(0);
}// Of switch
求导部分封装(函数derive)
switch (activator) {
//...
case RELU:
if (paraValue >= 0) {
resultValue = 1;
} else {
resultValue = 0;
} // Of if
break;
default:
System.out.println("Unsupported activator: " + activator);
System.exit(0);
}// Of switch
1.4 ReLU函数变体
1.4.1 Leakly ReLU
/**
* Leaky relu, also known as parametric relu.
*/
public final char LEAKY_RELU = 'l';
\[\operatorname{L-ReLU}(x) = max(\alpha x, x) ,\alpha > 0\tag{7}\] 求导后:\[f^{\prime}(x)=\left\{\begin{array}{l}
1, x>0\\
\alpha, x \leqslant 0, \alpha>0
\end{array}\right.\tag{8}\]
L-ReLU函数的负半轴不再是固定的0值, 而是存在一个基于\(\alpha\)斜率控制的线性变化, 往往来说\(\alpha\)都设置一些相对较小的值(\(0<\alpha<1\)), 保证了负半轴的速率要弱于正半轴.
因为左半部分不再一味设0, 故可以基本解决Dead ReLU问题, 但是本身的不对称性让其无法有效保证zero-centered特性. Leakly ReLU不一定优于ReLU.
激活函数(函数activate)的代码部分:
switch (activator) {
//...
case LEAKY_RELU:
if (paraValue >= 0) {
resultValue = paraValue;
} else {
resultValue = alpha * paraValue;
} // Of if
break;
default:
System.out.println("Unsupported activator: " + activator);
System.exit(0);
}// Of switch
求导部分封装(函数derive)
switch (activator) {
//...
case LEAKY_RELU:
if (paraValue >= 0) {
resultValue = 1;
} else {
resultValue = alpha;
} // Of if
break;
default:
System.out.println("Unsupported activator: " + activator);
System.exit(0);
}// Of switch
1.4.2 ELU
/**
* Elu.
*/
public final char ELU = 'e';
\[\operatorname{ELU}(x)=\left\{\begin{array}{l}
x, x>0\\
\alpha(x^{e}-1), x \leqslant 0, \alpha>0
\end{array}\right.\tag{9}\] 求导后:\[f^{\prime}(x)=\left\{\begin{array}{l}
1, x>0\\
\alpha x^{e}, x \leqslant 0, \alpha>0
\end{array}\right.\tag{10}\]
ELU(The exponential Linear Units)函数的全称为指数化线性单元, 继承与L-ReLU, 因此不会有Dead ReLU 问题, 而且均值可以保证足够接近0(zero-centered). 但是出现了指数部分, 有一定的计算量, 表现也不一定优于ReLU.
激活函数(函数activate)的代码部分:
switch (activator) {
//...
case ELU:
if (paraValue >= 0) {
resultValue = paraValue;
} else {
resultValue = alpha * (Math.exp(paraValue) - 1);
} // Of if
break;
default:
System.out.println("Unsupported activator: " + activator);
System.exit(0);
}// Of switch
求导部分封装(函数derive)
switch (activator) {
//...
case ELU:
if (paraValue >= 0) {
resultValue = 1;
} else {
resultValue = alpha * Math.exp(paraValue);;
} // Of if
break;
default:
System.out.println("Unsupported activator: " + activator);
System.exit(0);
}// Of switch
1.4.3 GeLU
/**
* Gelu.
*/
public final char GELU = 'g';
\[\operatorname{GeLU}(x)=0.5 x\left(1+\operatorname{Tanh}(x) \left[\sqrt{2 / \pi}\left(x+0.044715 x^{3}\right)\right]\right) \tag{11}\]
本代码未实现此方法, 了解即可. 有专门需要时再通过此文来实现吧.
在神经网络的建模过程中,模型很重要的性质就是非线性,同时为了模型泛化能力,需要加入随机正则,例如dropout(随机置一些输出为0,其实也是一种变相的随机非线性激活), 而随机正则与非线性激活是分开的两个事情, 而其实模型的输入是由非线性激活与随机正则两者共同决定的。
GELU正是在激活中引入了随机正则的思想,是一种对神经元输入的概率描述,直观上更符合自然的认识,同时实验效果要比Relu与ELU都要好。
1.5 Softplus函数
/**
* Soft plus.
*/
public final char SOFT_PLUS = 'u';
\[\operatorname{Softplus}(x)= \log(1+e^x)\tag{12}\] 求导后为\[f^{\prime}(x)= \frac{1}{1+e^{-x}}\tag{13}\]
定义域为\((-\infty,+\infty)\), 值域为\((0,1)\), 随着定义域取值的变大, 函数图像不断趋近于\(+\infty\), 定义域进入负数域, 取值不断趋近于0.
直观可见, Softplus似乎宛如ReLU的平滑版本, 而且负半轴并没有一味的0取值也能很好避免Dead ReLU问题. 但是引入了指数和对数, 而且求导式中有除法, 所以计算的开销仍然是存在的.
激活函数(函数activate)的代码部分:
switch (activator) {
//...
case SOFT_PLUS:
resultValue = Math.log(1 + Math.exp(paraValue));
break;
default:
System.out.println("Unsupported activator: " + activator);
System.exit(0);
}// Of switch
求导部分封装(函数derive)
switch (activator) {
//...
case SOFT_PLUS:
resultValue = 1 / (1 + Math.exp(-paraValue));
break;
default:
System.out.println("Unsupported activator: " + activator);
System.exit(0);
}// Of switch
1.6 Softsign函数
/**
* Soft sign.
*/
public final char SOFT_SIGN = 'o';
\[\operatorname{Softsign}(x)= \frac{x}{1+|x|}\tag{14}\] 求导结果为\[f^{\prime}(x)= \frac{1}{1+|x|^{2}}\tag{15}\]
定义域为\((-\infty,+\infty)\), 值域为\((-1,1)\), 随着定义域取值的变大, 函数图像不断趋近于1, 而随着定义域的缩小逐步趋近于-1. 函数图像在靠近定义域中心0时速度变快, 但是分散到两端相对平滑.很显然, Softsign是基于tanh激活函数的改造, 最明显的区别就是在当函数饱和时, Softsign就像他的名字那样, 显得更加" Soft ". 能在一定程度上避免两端因为导数过于趋近0导致的梯度消失(gradient vanishing)现象.
总之, 是Tanh的不错的代替选项.
激活函数(函数activate)的代码部分:
switch (activator) {
//...
case SOFT_SIGN:
if (paraValue >= 0) {
resultValue = paraValue / (1 + paraValue);
} else {
resultValue = paraValue / (1 - paraValue);
} // Of if
break;
default:
System.out.println("Unsupported activator: " + activator);
System.exit(0);
}// Of switch
求导部分封装(函数derive)
switch (activator) {
//...
case SOFT_SIGN:
if (paraValue >= 0) {
resultValue = 1 / (1 + paraValue) / (1 + paraValue);
} else {
resultValue = 1 / (1 - paraValue) / (1 - paraValue);
} // Of if
default:
System.out.println("Unsupported activator: " + activator);
System.exit(0);
}// Of switch
2. 激活函数代码封装一览
暂时就整理如此多的函数吧, 此外还模仿老师额外添加的\(y=arctan(x)\)与\(y=x\)这两个激活函数, 因为他俩特性都相对简单, 而且图像和求导都不言而喻, 因此就不再赘述. 这里我列出的还是一些比较容易实现于本系列中的函数, 一些麻烦的还是没有去实现, 更多复杂的激活函数可以参考:机器学习中的数学——激活函数:基础知识_von Neumann的博客-CSDN博客
因为部分函数可能会涉及一些参数, 因此额外引入一些成员变量
/**
* Alpha for elu, relu, leakly relu.
*/
double alpha;
/**
*********************
* The first constructor.
*
* @param paraActivator
* The activator.
*********************
*/
public Activator(char paraActivator) {
activator = paraActivator;
}// Of the first constructor
/**
*********************
* Setter.
*********************
*/
public void setActivator(char paraActivator) {
activator = paraActivator;
}// Of setActivator
/**
*********************
* Getter.
*********************
*/
public char getActivator() {
return activator;
}// Of getActivator
/**
*********************
* Setter.
*********************
*/
void setAlpha(double paraAlpha) {
alpha = paraAlpha;
}// Of setAlpha
其余代码部分
/**
*********************
* Activate according to the activation function.
*********************
*/
public double activate(double paraValue) {
double resultValue = 0;
switch (activator) {
case ARC_TAN:
resultValue = Math.atan(paraValue);
break;
case ELU:
if (paraValue >= 0) {
resultValue = paraValue;
} else {
resultValue = alpha * (Math.exp(paraValue) - 1);
} // Of if
break;
case IDENTITY:
resultValue = paraValue;
break;
case LEAKY_RELU:
if (paraValue >= 0) {
resultValue = paraValue;
} else {
resultValue = alpha * paraValue;
} // Of if
break;
case SOFT_SIGN:
if (paraValue >= 0) {
resultValue = paraValue / (1 + paraValue);
} else {
resultValue = paraValue / (1 - paraValue);
} // Of if
break;
case SOFT_PLUS:
resultValue = Math.log(1 + Math.exp(paraValue));
break;
case RELU:
if (paraValue >= 0) {
resultValue = paraValue;
} else {
resultValue = 0;
} // Of if
break;
case SIGMOID:
resultValue = 1 / (1 + Math.exp(-paraValue));
break;
case TANH:
resultValue = 2 / (1 + Math.exp(-2 * paraValue)) - 1;
break;
default:
System.out.println("Unsupported activator: " + activator);
System.exit(0);
}// Of switch
return resultValue;
}// Of activate
/**
*********************
* Derive according to the activation function. Some use x while others use
* f(x).
*
* @param paraValue
* The original value x.
* @param paraActivatedValue
* f(x).
*********************
*/
public double derive(double paraValue, double paraActivatedValue) {
double resultValue = 0;
switch (activator) {
case ARC_TAN:
resultValue = 1 / (paraValue * paraValue + 1);
break;
case ELU:
if (paraValue >= 0) {
resultValue = 1;
} else {
resultValue = alpha * Math.exp(paraValue);
} // Of if
break;
case IDENTITY:
resultValue = 1;
break;
case LEAKY_RELU:
if (paraValue >= 0) {
resultValue = 1;
} else {
resultValue = alpha;
} // Of if
break;
case SOFT_SIGN:
if (paraValue >= 0) {
resultValue = 1 / (1 + paraValue) / (1 + paraValue);
} else {
resultValue = 1 / (1 - paraValue) / (1 - paraValue);
} // Of if
break;
case SOFT_PLUS:
resultValue = 1 / (1 + Math.exp(-paraValue));
break;
case RELU: // Updated
if (paraValue >= 0) {
resultValue = 1;
} else {
resultValue = 0;
} // Of if
break;
case SIGMOID: // Updated
resultValue = paraActivatedValue * (1 - paraActivatedValue);
break;
case TANH: // Updated
resultValue = 1 - paraActivatedValue * paraActivatedValue;
break;
// case SWISH:
// resultValue = ?;
// break;
default:
System.out.println("Unsupported activator: " + activator);
System.exit(0);
}// Of switch
return resultValue;
}// Of derive
/**
*********************
* Overrides the method claimed in Object.
*********************
*/
public String toString() {
String resultString = "Activator with function '" + activator + "'";
resultString += "\r\n alpha = " + alpha + ", beta = " + beta + ", gamma = " + gamma;
return resultString;
}// Of toString
/**
********************
* Test the class.
********************
*/
public static void main(String[] args) {
Activator tempActivator = new Activator('s');
double tempValue = 0.6;
double tempNewValue;
tempNewValue = tempActivator.activate(tempValue);
System.out.println("After activation: " + tempNewValue);
tempNewValue = tempActivator.derive(tempValue, tempNewValue);
System.out.println("After derive: " + tempNewValue);
}// Of main
}// Of class Activator
这里注意, 求导的函数derive设置了两个参数, 这是因为有些函数的求导式并不是显式的, 包含了自变量和因变量(例如Sigmoid).
main函数测试结果如下:
附: 这里有Python的画图代码, 有需求的可以使用:
import numpy as np
from matplotlib import pyplot as plt
# Activator
def tanh(z):
return (np.exp(z)-np.exp(-z))/(np.exp(z)+np.exp(-z))
def sigmoid(z):
return 1/(1+np.exp(-z))
def relu(z):
y = []
for i in z:
if i <= 0:
y.append(0)
else:
y.append(i)
return y
def leaklyrule(z, k):
y = []
for i in z:
if i <= 0:
y.append(k * i)
else:
y.append(i)
return y
def elu(z, k):
y = []
for i in z:
if i <= 0:
y.append(k * (np.exp(i) - 1))
else:
y.append(i)
return y
def gelu(z):
return 0.5 * z * (1 + tanh(np.sqrt(2/np.pi) * (z + 0.044715 * z * z * z) ))
def softplus(z):
return np.log(1+np.exp(z))
def softsign(z):
y = []
for i in z:
if i <= 0:
y.append(i / (1 - i))
else:
y.append(i / (1 + i))
return y
x=np.arange(-5,5,0.1)
y1=tanh(x)
y2=sigmoid(x)
y3=relu(x)
y31=leaklyrule(x, 0.1)
y32=elu(x,0.5)
y33=gelu(x)
y4=softplus(x)
y5=softsign(x)
# plt.plot(x,y1,'b-',label='tanh(x)')
# plt.plot(x,y2,'b-',label='sigmoid(x)')
# plt.plot(x,y3,'g-',label='relu(x)')
# plt.plot(x,y31,'b-.',label=r'leakly relu(x), $\alpha$ = 0.1')
# plt.plot(x,y32,'b-.',label=r'elu(x), $\alpha$ = 0.5')
# plt.plot(x,y33,'b-.',label='gelu(x)')
# plt.plot(x,y4,'r-.',label='softplus(x)')
# plt.plot(x,y5,'r-.',label='softsign(x)')
plt.legend()
plt.grid()
plt.show()