機器學習:神經網路構建(下)

SXWisON發表於2024-12-04

簡介

在上一篇文章《機器學習:神經網路構建(上)》中討論了線性層、啟用函式以及損失函式層的構建方式,本節中將進一步討論網路構建方式,並完整的搭建一個簡單的分類器網路。

目錄

  1. 網路Network
  2. 資料集管理器 DatasetManager
  3. 最佳化器 Optimizer
  4. 程式碼測試

網路Network

網路定義


在設計神經網路時,其基本結構是由一層層的神經元組成的,這些層可以是輸入層、隱藏層和輸出層。為了實現這一結構,通常會使用向量(vector)容器來儲存這些層,因為層的數量是可變的,可能根據具體任務的需求而變化。

即使在網路已經進行了預訓練並具有一定的引數的情況下,對於特定的任務,通常還是需要進行模型微調。這是因為不同的任務可能有不同的資料分佈和要求,因此訓練是構建高效能神經網路模型的重要步驟。

在訓練過程中,有三個關鍵元件:

  1. 損失函式:神經網路的學習目標,透過最小化損失函式來最佳化模型引數。選擇合適的損失函式對於確保模型能夠學習到有效的特徵表示至關重要。

  2. 最佳化器:最佳化器負責調整模型的引數以最小化損失函式。除了基本的引數更新功能外,最佳化器還可以提供更高階的功能,如學習率調整和引數凍結,這些功能有助於提高訓練效率和模型效能。

  3. 資料集管理器:負責在訓練過程中有效地管理和提供資料,包括資料的載入、預處理和批處理,以確保資料被充分利用。

對於網路的外部介面(公有方法),主要有以下幾類:

  1. 網路設定:新增網路層、設定損失函式、最佳化器和資料集等操作,用於配置網路的結構和訓練引數。
  2. 網路推理:前向傳播和反向傳播方法,用於在訓練和測試過程中進行預測和引數更新。
  3. 網路訓練:使用配置好的資料集和訓練方法,執行指定次數的訓練迭代,以最佳化網路引數。

以下是程式碼示例:

class Network {
private:
    vector<shared_ptr<Layer>> layers;

    shared_ptr<LossFunction> lossFunction;
    shared_ptr<Optimizer> optimizer;
    shared_ptr<DatasetManager> datasetManager;

public:
    void addLayer(shared_ptr<Layer> layer);

    void setLossFunction(shared_ptr<LossFunction> lossFunc);
    void setOptimizer(shared_ptr<Optimizer> opt);
    void setDatasetManager(shared_ptr<DatasetManager> manager);

    MatrixXd forward(const MatrixXd& input);
    void backward(const MatrixXd& outputGrad);

    double train(size_t epochs, size_t batchSize);
};

使用shared_ptr的好處:
儲存方式vector<shared_ptr>和vector相比,如果直接儲存 Layer 物件,需要手動管理記憶體,包括分配和釋放記憶體,這不僅容易出錯,還可能導致記憶體洩漏或懸掛指標的問題。而使用 std::shared_ptr 可以大大簡化記憶體管理,提高程式碼的健壯性和可維護性。

網路訓練


網路的訓練函式通常包含兩個輸入引數,訓練的集數和批尺寸:

  • 集數epochs:指訓練集被完整的迭代的次數。在每一個epoch中,網路會使用訓練集中的所有樣本進行引數更新。

  • 批尺寸batchSize:指在一次迭代中用於更新模型引數的樣本數量。在每次迭代中,模型會計算這些樣本的總梯度,並據此調整模型的引數。

因此,網路的訓練函式由兩層迴圈結構組成,外層迴圈結構表示完整迭代的次數,直至完成所有迭代時停止。內層迴圈表示訓練集中樣本被網路調取的進度,直至訓練集中的所有資料被呼叫時停止。

網路的訓練過程是由多次的引數迭代(更新)完成的。而引數的的迭代是以批(Batch)為單位的。具體來說,一次迭代包含如下步驟:

  1. 獲取資料:從資料集管理器中獲取一批的資料(包含輸入和輸出)
  2. 前向傳播:採用網路對資料進行推理,得到預測結果,依據預測結果評估損失。
  3. 反向傳播:計算損失函式關於各層引數的梯度。
  4. 引數更新:依據損失、梯度等資訊,更新各層梯度。
  5. 日誌更新:計算並輸出每個epoch的累積誤差。

程式碼設計如下:

double Network::train(size_t epochs, size_t batchSize) {
    double totalLoss = 0.0;
    size_t sampleCount = datasetManager->getTrainSampleCount();

    for (size_t epoch = 0; epoch < epochs; ++epoch) {
        datasetManager->shuffleTrainSet();
        totalLoss = 0.0;
        for (size_t i = 0; i < sampleCount; i += batchSize) {
            // 獲取一個小批次樣本
            auto batch = datasetManager->getTrainBatch(batchSize, i / batchSize);
            MatrixXd batchInput = batch.first;
            MatrixXd batchLabel = batch.second;

            // 前向傳播
            MatrixXd predicted = forward(batchInput);
            double loss = lossFunction->computeLoss(predicted, batchLabel);

            // 反向傳播
            MatrixXd outputGrad = lossFunction->computeGradient(predicted, batchLabel);
            backward(outputGrad);

            // 引數更新
            optimizer->update(layers);

            // 累計損失
            totalLoss += loss;
        }
        totalLoss /= datasetManager->getTrainSampleCount();
        // 輸出每個epoch的損失等資訊
        std::cout << "Epoch " << epoch << ", totalLoss = " << totalLoss << "\n";
    }
    return totalLoss / (epochs * (sampleCount / batchSize)); // 返回平均損失(簡化示例)
}

網路的其它公有方法


下面的程式碼給出了網路的其它公有方法的程式碼實現:

void Network::addLayer(std::shared_ptr<Layer> layer) {
    layers.push_back(layer);
}

void Network::setLossFunction(std::shared_ptr<LossFunction> lossFunc) {
    lossFunction = lossFunc;
}

void Network::setOptimizer(std::shared_ptr<Optimizer> opt) {
    optimizer = opt;
}

void Network::setDatasetManager(std::shared_ptr<DatasetManager> manager) {
    datasetManager = manager;
}

MatrixXd Network::forward(const MatrixXd& input) {
    MatrixXd currentInput = input;
    for (const auto& layer : layers) {
        currentInput = layer->forward(currentInput);
    }
    return currentInput;
}

void Network::backward(const MatrixXd& outputGrad) {
    MatrixXd currentGrad = outputGrad;
    for (auto it = layers.rbegin(); it != layers.rend(); ++it) {
        currentGrad = (*it)->backward(currentGrad);
    }
}

forward方法除了作為訓練時的步驟之一,還經常用於網路推理(預測),因此宣告為公有方法

backward方法只在訓練時使用,在正常的使用用途中,不會被外部呼叫,因此,其可以宣告為私有方法。

資料集管理器 DatasetManager


資料集管理器本質目的是提高網路對資料的利用率,其主要職能有:

  1. 儲存資料:提供更為安全可靠的資料管理。
  2. 資料打亂:以避免順序偏差,同時提升模型的泛化能力。
  3. 資料集劃分:講資料劃分為訓練集、驗證集和測試集。
  4. 資料介面:使得外部可以輕鬆的獲取批次資料。
    class DatasetManager {
    private:
        MatrixXd input;
        MatrixXd label;
        std::vector<int> trainIndices;
        std::vector<int> valIndices;
        std::vector<int> testIndices;

    public:
        // 設定資料集的方法
        void setDataset(const MatrixXd& inputData, const MatrixXd& labelData);

        // 劃分資料集為訓練集、驗證集和測試集
        void splitDataset(double trainRatio = 0.8, double valRatio = 0.1, double testRatio = 0.1);

        // 獲取訓練集、驗證集和測試集的小批次資料
        std::pair<MatrixXd, MatrixXd> getBatch(std::vector<int>& indices, size_t batchSize, size_t offset = 0);

        // 隨機打亂訓練集
        void shuffleTrainSet();

        // 獲取批次資料
        std::pair<MatrixXd, MatrixXd> getTrainBatch(size_t batchSize, size_t offset = 0);
        std::pair<MatrixXd, MatrixXd> getValidationBatch(size_t batchSize, size_t offset = 0);
        std::pair<MatrixXd, MatrixXd> getTestBatch(size_t batchSize, size_t offset = 0);

        // 獲取樣本數量的方法
        size_t getSampleCount() const;
        size_t getTrainSampleCount() const;
        size_t getValidationSampleCount() const;
        size_t getTestSampleCount() const;
    };

資料集初始化


資料集初始化分為三步:資料集設定、資料集劃分、資料集打亂。

// 設定資料集
void  ML::DatasetManager::setDataset(const MatrixXd& inputData, const MatrixXd& labelData) {
    input = inputData;
    label = labelData;

    trainIndices.resize(input.rows());
    std::iota(trainIndices.begin(), trainIndices.end(), 0);
    valIndices.clear();
    testIndices.clear();
}

// 打亂訓練集
void ML::DatasetManager::shuffleTrainSet() {
    std::shuffle(trainIndices.begin(), trainIndices.end(), std::mt19937{ std::random_device{}() });
}

// 劃分資料集為訓練集、驗證集和測試集
void ML::DatasetManager::splitDataset(double trainRatio, double valRatio, double testRatio) {
    size_t totalSamples = input.rows();
    size_t trainSize = static_cast<size_t>(totalSamples * trainRatio);
    size_t valSize = static_cast<size_t>(totalSamples * valRatio);
    size_t testSize = totalSamples - trainSize - valSize;

    shuffleTrainSet();

    valIndices.assign(trainIndices.begin() + trainSize, trainIndices.begin() + trainSize + valSize);
    testIndices.assign(trainIndices.begin() + trainSize + valSize, trainIndices.end());
    trainIndices.resize(trainSize);
}

對於打亂操作較頻繁的場景,打亂索引是更為高效的操作;而對於不經常打亂的場景,直接在資料集上打亂更為高效。本例中僅給出打亂索引的程式碼示例。

資料獲取


在獲取資料時,首先明確所需資料集的型別(訓練集或驗證集)。然後,根據預設的批次大小(Batchsize),從索引列表中提取相應數量的索引,並將這些索引對應的資料儲存到臨時矩陣中。最後,匯出資料,完成讀取操作。

// 獲取訓練集、驗證集和測試集的小批次資料
std::pair<MatrixXd, MatrixXd> ML::DatasetManager::getBatch(std::vector<int>& indices, size_t batchSize, size_t offset) {
    size_t start = offset * batchSize;
    size_t end = std::min(start + batchSize, indices.size());
    MatrixXd batchInput = MatrixXd::Zero(end - start, input.cols());
    MatrixXd batchLabel = MatrixXd::Zero(end - start, label.cols());

    for (size_t i = start; i < end; ++i) {
        batchInput.row(i - start) = input.row(indices[i]);
        batchLabel.row(i - start) = label.row(indices[i]);
    }

    return std::make_pair(batchInput, batchLabel);
}

// 獲取訓練集的批次資料
std::pair<MatrixXd, MatrixXd> ML::DatasetManager::getTrainBatch(size_t batchSize, size_t offset) {
    return getBatch(trainIndices, batchSize, offset);
}

// 獲取驗證集的批次資料
std::pair<MatrixXd, MatrixXd> ML::DatasetManager::getValidationBatch(size_t batchSize, size_t offset) {
    return getBatch(valIndices, batchSize, offset);
}

// 獲取測試集的批次資料
std::pair<MatrixXd, MatrixXd> ML::DatasetManager::getTestBatch(size_t batchSize, size_t offset) {
    return getBatch(testIndices, batchSize, offset);
}

資料集尺寸的外部介面


為便於程式碼開發,需要為資料集管理器設計外部介面,以便於外部可以獲取各個資料集的尺寸。

size_t ML::DatasetManager::getSampleCount() const {
    return input.rows();
}

size_t ML::DatasetManager::getTrainSampleCount() const {
    return trainIndices.size();
}

size_t ML::DatasetManager::getValidationSampleCount() const {
    return valIndices.size();
}

size_t ML::DatasetManager::getTestSampleCount() const {
    return testIndices.size();
}

最佳化器 Optimizer


隨機梯度下降是一種最佳化演算法,用於最小化損失函式以訓練模型引數。與批次梯度下降(Batch Gradient Descent)不同,SGD在每次更新引數時只使用一個樣本(或一個小批次的樣本),而不是整個訓練集。這使得SGD在計算上更高效,且能夠更快地收斂,尤其是在處理大規模資料時。以下為隨機梯度下降的程式碼示例:

class Optimizer {
public:
    virtual void update(std::vector<std::shared_ptr<Layer>>& layers) = 0;
    virtual ~Optimizer() {}
};

class SGDOptimizer : public Optimizer {
private:
    double learningRate;
public:
    SGDOptimizer(double learningRate) : learningRate(learningRate) {}
    void update(std::vector<std::shared_ptr<Layer>>& layers) override;
};

void SGDOptimizer::update(std::vector<std::shared_ptr<Layer>>& layers) {
    for (auto& layer : layers) {
        layer->update(learningRate);
    }
}

程式碼測試


如果你希望測試這些程式碼,首先可以從本篇文章,以及上一篇文章中複製程式碼,並參考下述圖片構建你的解決方案。
description
如果你有遇到問題,歡迎聯絡作者!

示例1:線性迴歸


下述程式碼為線性迴歸的測試樣例:

namespace LNR{
    // linear_regression
    void gen(MatrixXd& X, MatrixXd& y);
    void test();
}

void LNR::gen(MatrixXd& X, MatrixXd& y) {
    MatrixXd w(X.cols(), 1);

    X.setRandom();
    w.setRandom();

    X.rowwise() -= X.colwise().mean();
    X.array().rowwise() /= X.array().colwise().norm();

    y = X * w;
}

void LNR::test() {
    std::cout << std::fixed << std::setprecision(2);

    size_t input_dim = 10;
    size_t sample_num = 2000;

    MatrixXd X(sample_num, input_dim);
    MatrixXd y(sample_num, 1);

    gen(X, y);

    ML::DatasetManager dataset;
    dataset.setDataset(X, y);

    ML::Network net;

    net.addLayer(std::make_shared<ML::Linear>(input_dim, 1));

    net.setLossFunction(std::make_shared<ML::MSELoss>());
    net.setOptimizer(std::make_shared<ML::SGDOptimizer>(0.25));
    net.setDatasetManager(std::make_shared<ML::DatasetManager>(dataset));

    size_t epochs = 600;
    size_t batch_size = 50;
    net.train(epochs, batch_size);

    MatrixXd error(sample_num, 1);

    error = net.forward(X) - y;

    std::cout << "error=\n" << error << "\n";
}

詳細解釋

  1. gen函式:用以生成測試資料。
  2. 網路結構:本例的網路結構中只包含一個線性層,其中輸入尺寸為特徵維度,輸出尺寸為1。
  3. 損失函式:採用MSE均方根誤差作為損失函式。

輸出展示

完成訓練後,網路預測值與真實值的誤差如下圖;容易發現,網路具有較好的預測精度。

description

示例2:邏輯迴歸


下述程式碼為邏輯迴歸的測試樣例:

namespace LC {
    // Linear classification
    void gen(MatrixXd& X, MatrixXd& y);
    void test();
}

void LC::gen(MatrixXd& X, MatrixXd& y) {
    MatrixXd w(X.cols(), 1);

    X.setRandom();
    w.setRandom();

    X.rowwise() -= X.colwise().mean();
    X.array().rowwise() /= X.array().colwise().norm();

    y = X * w;

    y = y.unaryExpr([](double x) { return x > 0.0 ? 1.0 : 0.0; });
}

void LC::test() {
    std::cout << std::fixed << std::setprecision(3);

    size_t input_dim = 10;
    size_t sample_num = 2000;

    MatrixXd X(sample_num, input_dim);
    MatrixXd y(sample_num, 1);

    gen(X, y);

    ML::DatasetManager dataset;
    dataset.setDataset(X, y);

    ML::Network net;

    net.addLayer(std::make_shared<ML::Linear>(input_dim, 1));
    net.addLayer(std::make_shared<ML::Sigmoid>());

    net.setLossFunction(std::make_shared<ML::LogisticLoss>());
    net.setOptimizer(std::make_shared<ML::SGDOptimizer>(0.05));
    net.setDatasetManager(std::make_shared<ML::DatasetManager>(dataset));

    size_t epochs = 200;
    size_t batch_size = 25;
    net.train(epochs, batch_size);

    MatrixXd predict(sample_num, 1);

    predict = net.forward(X);

    predict = predict.unaryExpr([](double x) { return x > 0.5 ? 1.0 : 0.0; });

    MatrixXd error(sample_num, 1);

    error = y - predict;

    error = error.unaryExpr([](double x) {return (x < 0.01 && x>-0.01) ? 1.0 : 0.0; });

    std::cout << "正確率=\n" << error.sum() / sample_num << "\n";
}

詳細解釋

  1. gen函式:用以生成測試資料。
  2. 網路結構:本例的網路結構中包含一個線性層及一個啟用函式層,其中:線性層輸入尺寸為特徵維度,輸出尺寸為1。
  3. 損失函式:採用對數誤差作為損失函式。

輸出展示
下圖反映了網路預測過程中的損失變化,可以看到損失逐漸下降的趨勢。
description

完成訓練後,輸出網路的預測結果的正確率。可以發現,網路具有較好的預測精度。
description

相關文章