機器學習:神經網路構建方式

SXWisON發表於2024-12-03

簡介

在本篇文章中,我們採用邏輯迴歸作為案例,探索神經網路的構建方式。文章詳細闡述了神經網路中層結構的實現過程,並提供了線性層、啟用函式以及損失函式的定義(實現方法)。

目錄

  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);
}

相關文章