机器学习:神经网络构建(上)

简介

在本篇文章中,我们采用逻辑回归作为案例,探索神经网络的构建方式。文章详细阐述了神经网络中层结构的实现过程,并提供了线性层、激活函数以及损失函数的定义(实现方法)。

目录

  1. 背景介绍
  2. 网络框架构建
  • 层的定义
  • 线性层
  • 激活函数
  • 损失函数

背景介绍


在网络的实现过程中,往往设计大量层的计算,对于简单的网络(算法),其实现相对较容易,例如线性回归,但对于逻辑回归,从输入到激活值再到损失估计的过程整体已经较冗长,实现复杂,并且难以维护,因此,我们需要采用系统性的框架来实现网络(算法),以达到更好的性能、可维护性等。

以逻辑回归为例初步探究


逻辑回归的决策过程可以分为三步

  1. 对采样数据\(X\)进行评估

\[\begin{align*} \text{z} &= \text{X}\text{w}+b\\ \frac{\partial \text{z}}{\partial \text{w}} &= \text{X} \end{align*} \]

  1. 激活函数变换

\[\begin{align*} \hat{\text{y}}&=\sigma(\text{z})=\frac{1}{1+e^{-\text{z}}}\\ \frac{\partial \hat{\text{y}}}{\partial \text{z}}&=\sigma\circ \big(1-\sigma\big) \end{align*} \]

  1. 损失函数计算

\[\begin{align*} \text{LOSS}&=\text{y}\circ \log \hat{\text{y}}+(1-\text{y})\circ\log(1-\hat{\text{y}})\\ \frac{\partial \text{LOSS}}{\partial \text{z}}&=\frac{y}{\hat{\text{y}}}+\frac{1-\text{y}}{1-\hat{\text{y}}} \end{align*} \]

其中\(\circ\)表述逐元素相乘或称哈达玛积
上述过程中反应出了这些层的一些共性:

  1. 计算函数值的输入与计算梯度时的输入相同。
  2. 计算函数值时,每一步的输入都是上一步的输出。
  3. 计算梯度时,由链式法则,每一步的梯度累乘即为损失关于参数的梯度。

函数值的计算过程从输入开始直至输出结果,故称为前向传播(forward)
导数值的计算过程从输出开始反向直至第一层,故称为反向传播(backward)

由此,可以总结,并设计出层的基本代码如下:

class Layer {
private:
    MatrixXd para;  // 参数
    MatrixXd cache; // 缓存
public:
    MatrixXd forward(MatrixXd input);     // 前向传播
    MatrixXd backward(MatrixXd prevGrad); // 反向传播
    void update(double learning_rate);    // 参数更新
}

部分层是没有参数的,例如激活函数、损失函数。这里只有线性层是有参数的。

框架功能探索


基于上一小节提出的框架,我们可以构建三个层次,伪代码如下:

Layer linear;
Layer sigmoid;
Layer logit;

下面我们来详细讨论如何基于该框架进行计算。

前向传播

MatrixXd Layer::forward(MatrixXd input) {
    cache = input; // 保存输入
    return input * para; // 对输入做计算并返回;这里给了一个示例
}
  1. z=linear.forward(X):线性层对输入\(\text{X}\)进行评估,并在其内存cache中保存\(\text{X}\)

  2. hat_y=sigmoid.forward(z):激活函数对输入\(\text{z}\)进行变换,得到类别概率(预测)\(\hat{\text{y}}\),同时在其内存cache中保存\(\text{z}\)

  3. loss = logit.forward(y, hat_y):损失函数依据真实值\(\text{y}\)和预测值\(\hat{\text{y}}\)估计模型误差.

可以注意到,loss具有两个输入,一个是前向传播过程中的计算值,一个是真实结果。

多态性的角度,虽然行为上有大量的一致性,但是由于输入参数数量不一致,其很难和普通层以及激活函数一样从基本层类派生,而是需要另外定义基类。

反向传播


反向传播的示例代码如下:

MatrixXd Layer::backward(MatrixXd prevGrad) {
    MatrixXd grad = ...;      // 采用cache中内容计算梯度
    cache = grad * prevGrad;  // 梯度累乘,同时保存进内存
    return cache;             // 返回梯度,以供下一层进行计算
}
  1. grad = logit.backward(y, hat_y):依据公式计算损失的梯度\(\partial \text{LOSS}/\partial \hat{\text{y}}\)

  2. grad = sigmoid.backward(grad):首先计算梯度\(\partial{\hat{\text{y}}/\partial \text{z}}\),其中,计算所需的\(\text{z}\)从缓存中读取。最后,将梯度与输入的梯度相乘,得到梯度\(\partial \text{LOSS}/\partial \text{z}\)

  3. grad = linear.backward(grad):首先计算梯度\(\partial\text{z}/\partial \text{w}\),计算所需的\(X\)从缓存中读取。最后,将梯度与输入梯度相乘,得到梯度\(\partial \text{LOSS}/\partial \text{w}\)

参数更新


参数更新的示例代码如下:

MatrixXd Layer::update(double learning_rate) {
    w -= learning_rate * cache; // 采用缓存中存储的梯度进行参数更新
}

在本例中,只有linear是具有参数的层,因此在更新的时候只用调用linearupdate()方法即可。

总结


在本小节中,我们以逻辑回归为例,初步探索了神经网络的构建方法,即:定义层类型,用以表示网络的每一层(或组件),在计算过程中,分为前向传播(推理及评估模型误差),反向传播(计算梯度)以及参数更新三个步骤。

该实现方法很好的将复杂的计算拆分为了多个独立的单元,便于较大体量的算法的实现、并且提供了极好可维护性。同时,对内存cache的合理使用,也极大的简化了调用过程,并提升了算法效率。

网络框架构建


神经网络是由多个组件一层层组件起来的,或者说组建神经网络的基本单元是Layer,在本文中,将详细讲述怎么设计并实现单元。

层的定义


层通常包含三种基本方法(行为):前向传播(forward)反向传播(backward)参数更新(update)

对于每种行为,不同类型的层所对同一操作所执行的行为不同,例如,在前向传播过程中,线性层对输入进行线性映射,激活函数则将每一元素映射至\([0,1]\)

C++中,允许在基类中定义方法,在派生类中重载这些方法,并在运行时根据实际类型来调整调用的函数。这种方法称之为多态性(Polymorphism)

下面给出了层的定义(基类)。

class Layer {
public:
	virtual MatrixXd forward(const MatrixXd& input) = 0;
	virtual MatrixXd backward(const MatrixXd& prevGrad) = 0;
	virtual void update(double learning_rate) = 0;
	virtual ~Layer() {}
};

在层的声明中,定义了层的三种基本方法。

线性层


对于线性层,其包含两个参数:权重矩阵W,和偏置b。其包含三个内存,一个用于存储输入inputCache,另外两个用于存储参数的变化量dWdb(一般是损失函数关于参数的梯度)。下面给出代码:

class Linear : public Layer {
private:
    // para
    MatrixXd W;
    MatrixXd b;
    // cache
    MatrixXd inputCache;
    MatrixXd dW;
    MatrixXd db;

public:
    Linear(size_t input_D, size_t output_D);

    MatrixXd forward(const MatrixXd& input) override;
    MatrixXd backward(const MatrixXd& prevGrad) override;
    void update(double learning_rate) override;
};

构造函数
构造函数用于初始化线性层的参数和缓存变量,代码如下:

Linear::Linear(size_t input_D, size_t output_D) 
    : W(MatrixE::Random(input_D, output_D)), b(MatrixE::Random(1, output_D)) 
{
    inputCache = MatrixE::Constant(1, input_D, 0);
    dW = MatrixE::Constant(input_D, output_D, 0);
    db = MatrixE::Constant(1, output_D, 0);
};
  1. 构造函数的参数

    • input_D:输入特征的维度,即输入数据的特征数量。
    • output_D:输出特征的维度,即线性层输出的特征数量。
  2. 成员初始化列表
    将参数Wb初始化为指定尺寸的随机矩阵。随机矩阵中的元素通常从标准正态分布中采样。

  3. 缓存变量的初始化
    将缓存变量初始化为与其尺寸匹配的全零矩阵。对于输入缓存,由于输入尺寸未知,仅知其特征维度,故初始化时设定其尺寸为1。

前向传播
下述为线性层前向传播计算的代码实现。

MatrixXd ML::Linear::forward(const MatrixXd& input) {
    // 缓存输入
    this->inputCache = input;
    // 返回计算结果
    return (input * W) + b.replicate(input.rows(), 1);
}

在计算过程中,input * W表示矩阵乘法,其中inputm x n矩阵,Wn x o矩阵,结果为m x o矩阵,表示线性变换的结果。

b是一个 o x 1 的偏置向量,通过b.replicate(input.rows(), 1)将其沿行方向复制m次,生成一个m x o的矩阵,为每个样本添加相同的偏置项。

最终,将线性变换结果与偏置矩阵相加,得到输出矩阵。

反向传播
下述为线性层方向传播计算的代码实现。

MatrixXd ML::Linear::backward(const MatrixXd& prevGrad) {
    // 计算关于权重的梯度
    this->dW = inputCache.transpose() * prevGrad;
    // 计算关于偏置的梯度
    this->db = prevGrad.colwise().sum();

    // 计算并返回关于输入的梯度
    return prevGrad * W.transpose();
}

该函数接受前一层的梯度prevGrad,并根据缓存的输入矩阵inputCache计算损失关于本层参数的偏导(分别计算dWdb)并保存以用于参数更新。最后,返回关于输入的梯度矩阵。

激活函数


激活函数是比较特殊的层函数,其不包含任何的参数信息,因此,其无需进行参数更新,或者说参数更新函数不做任何操作;进一步的,激活函数也不需要在缓存中存储梯度信息,因为它无需更新参数。

下面给出较经典的三种激活函数的定义,它们都是从Layer中派生的:

ReLU激活函数

class ReLU : public Layer {
private:
    MatrixXd inputCache;

public:
    MatrixXd forward(const MatrixXd& input) override;
    MatrixXd backward(const MatrixXd& prevGrad) override;
    void update(double learning_rate) override {}
};

MatrixXd ReLU::forward(const MatrixXd& input) {
    inputCache = input;
    return input.unaryExpr([](double x) { return x > 0 ? x : 0; });
}

MatrixXd ReLU::backward(const MatrixXd& prevGrad) {
    MatrixXd derivative = inputCache.unaryExpr([](double x) { return x > 0 ? 1 : 0; }).cast<double>().matrix();
    return prevGrad.cwiseProduct(derivative);
}

Sigmoid激活函数

class Sigmoid : public Layer {
private:
    MatrixXd inputCache;

public:
    MatrixXd forward(const MatrixXd& input) override;
    MatrixXd backward(const MatrixXd& prevGrad) override;
    void update(double learning_rate) override {}
};

MatrixXd Sigmoid::forward(const MatrixXd& input) {
    inputCache = input;
    return input.unaryExpr([](double x) { return 1.0 / (1.0 + exp(-x)); });
}

MatrixXd Sigmoid::backward(const MatrixXd& prevGrad) {
    MatrixXd sigmoidOutput = inputCache.unaryExpr([](double x) { return 1.0 / (1.0 + exp(-x)); });
    return prevGrad.cwiseProduct(sigmoidOutput.cwiseProduct((1 - sigmoidOutput.array()).matrix()));
}

tanh激活函数

class Tanh : public Layer {
private:
    MatrixXd inputCache;

public:
    MatrixXd forward(const MatrixXd& input) override;
    MatrixXd backward(const MatrixXd& prevGrad) override;
    void update(double learning_rate) override {}
};

MatrixXd Tanh::forward(const MatrixXd& input) {
    inputCache = input;
    return input.unaryExpr([](double x) { return tanh(x); });
}

MatrixXd Tanh::backward(const MatrixXd& prevGrad) {
    MatrixXd tanhOutput = inputCache.unaryExpr([](double x) { return tanh(x); });
    return prevGrad.cwiseProduct((1 - tanhOutput.cwiseProduct(tanhOutput).array()).matrix());
}

损失函数

损失函数是一种特殊的层,其行为与普通的层相似,但在前向传播和反向传播过程中与普通的层存在较大差异:

  • 前向传播:从使用来看,如果仅需使用网络,而无需评价,或者训练网络,前向传播过程中,无需调用损失函数。从输入个数来看,普通层函数只需要传入上一层的输出即可,而损失函数需要上一层的输出(也即:网络的预测结果)和真实结果两个参数。
  • 反向传播:损失函数是反向传播的起点,其接受预测结果和真实结果作为其输入,返回梯度矩阵,而其它矩阵则是输入上一层的梯度矩阵,返回本层的梯度矩阵。

由于损失函数与普通层的差异,一般单独定义损失函数的调用接口(基类),代码如下:

class LossFunction {
public:
    virtual double computeLoss(const MatrixXd& predicted, const MatrixXd& actual) = 0;
    virtual MatrixXd computeGradient(const MatrixXd& predicted, const MatrixXd& actual) = 0;
    virtual ~LossFunction() {}
};

下文分别以均方根误差和对数损失为例,给出代码,供读者参考学习

MSE均方根误差

class MSELoss : public LossFunction {
public:
    double computeLoss(const MatrixXd& predicted, const MatrixXd& actual) override;
    MatrixXd computeGradient(const MatrixXd& predicted, const MatrixXd& actual) override;
};

double MSELoss::computeLoss(const MatrixXd& predicted, const MatrixXd& actual) {
	MatrixXd diff = predicted - actual;
	return diff.squaredNorm() / (2.0 * predicted.rows());
}

MatrixXd MSELoss::computeGradient(const MatrixXd& predicted, const MatrixXd& actual) {
	MatrixXd diff = predicted - actual;
	return diff / predicted.rows();
}

对数损失函数

class LogisticLoss : public LossFunction {
public:
    double computeLoss(const MatrixXd& predicted, const MatrixXd& actual) override;
    MatrixXd computeGradient(const MatrixXd& predicted, const MatrixXd& actual) override;
};

double LogisticLoss::computeLoss(const MatrixXd& predicted, const MatrixXd& actual) {
	MatrixXd log_predicted = predicted.unaryExpr([](double p) { return log(p); });
	MatrixXd log_1_minus_predicted = predicted.unaryExpr([](double p) { return log(1 - p); });

	MatrixXd term1 = actual.cwiseProduct(log_predicted);
	// MatrixXd term2 = (1 - actual).cwiseProduct(log_1_minus_predicted);
	MatrixXd term2 = (1 - actual.array()).matrix().cwiseProduct(log_1_minus_predicted);

	double loss = -(term1 + term2).mean();

	return loss;
}

MatrixXd LogisticLoss::computeGradient(const MatrixXd& predicted, const MatrixXd& actual) {
	MatrixXd temp1 = predicted - actual;
	MatrixXd temp2 = predicted.cwiseProduct((1 - predicted.array()).matrix());

	return (temp1).cwiseQuotient(temp2);
}
posted @ 2024-12-03 16:32  SXWisON  阅读(278)  评论(2编辑  收藏  举报