如何用C++在TensorFlow中訓練深度神經網路

李澤南發表於2017-12-29
目前流行的深度學習框架 TensorFlow 是以 C++為底層構建的,但絕大多數人都在 Python 上使用 TensorFlow 來開發自己的模型。隨著 C++ API 的完善,直接使用 C++來搭建神經網路已經成為可能,本文將向你介紹一種簡單的實現方法。

很多人都知道 TensorFlow 的核心是構建在 C++之上的,但是這種深度學習框架的大多數功能只在 Python API 上才方便使用。

當我寫上一篇文章的時候,我的目標是僅使用 TensorFlow 中的 C++ API 和 CuDNN 來實現基本的深度神經網路(DNN)。在實踐中,我意識到在這個過程中我們忽略了很多東西。

注意,使用外部操作(exotic operations)訓練神經網路是不可能的,你面臨的錯誤最有可能就是缺少梯度運算。目前我正在試圖將 Python 上的梯度運算遷移到 C++上。

在本文中,我將展示如何使用 TensorFlow 在 C++上構建深度神經網路,並通過車齡、公里數和使用油品等條件為寶馬 1 系汽車進行估價。目前,我們還沒有可用的 C++優化器,所以你會看到訓練程式碼看起來不那麼吸引人,但是我們會在未來加入的。

  • 本文章遵從 TensorFlow 1.4 C++ API 官方指南:https://www.tensorflow.org/api_guides/cc/guide
  • 程式碼 GitHub:https://github.com/theflofly/dnn_tensorflow_cpp

安裝

我們會在 C++中執行 TensorFlow 框架,我們需要嘗試使用已編譯的庫,但肯定有些人會因為環境的特殊性而遇到麻煩。從頭開始構建 TensorFlow 將避免這些問題,同時確保使用的是最新版本的 API。

首先,你需要安裝 bazel 構建工具,這裡有安裝方法:https://docs.bazel.build/versions/master/install.html

在 OSX 上 brew 就足夠了:

  1. brew install bazel

你需要從 TensorFlow 原始檔開始構建:

  1. mkdir /path/tensorflow

  2. cd /path/tensorflow

  3. git clone https://github.com/tensorflow/tensorflow.git

隨後你需要進行配置,如選擇是否使用 GPU,你需要這樣執行配置指令碼:

  1. cd /path/tensorflow

  2. ./configure

現在我們要建立接收 TensorFlow 模型程式碼的檔案。請注意,第一次構建需要花費很長一段時間(10-15 分鐘)。非核心的 C++ TF 程式碼在/tensorflow/cc 中,這是我們建立模型檔案的位置,我們也需要 BUILD 檔案讓 bazel 可以構建模型。

  1. mkdir /path/tensorflow/model

  2. cd /path/tensorflow/model

  3. touch model.cc

  4. touch BUILD

我們在 BUILD 檔案中加入 bazel 指令:

  1. load("//tensorflow:tensorflow.bzl", "tf_cc_binary")

  2. tf_cc_binary(

  3.    name = "model",

  4.    srcs = [

  5.        "model.cc",

  6.    ],

  7.    deps = [

  8.        "//tensorflow/cc:gradients",

  9.        "//tensorflow/cc:grad_ops",

  10.        "//tensorflow/cc:cc_ops",

  11.        "//tensorflow/cc:client_session",

  12.        "//tensorflow/core:tensorflow"

  13.    ],

  14. )

基本上,它會使用 model.cc 構建一個二進位制檔案。現在,我們可以開始編寫自己的模型了。

讀取資料

這些資料從法國網站 leboncoin.fr 上摘取,隨後被清理和歸一化,並被儲存於 CSV 檔案中。我們的目標是讀取這些資料。經歸一化的源資料被儲存在 CSV 檔案的第一行,我們需要使用它們重構神經網路輸出的價格。所以,我們建立 data_set.h 和 data_set.cc 檔案來保持程式碼清潔。它們從 CSV 檔案中生成一個浮點型的二維陣列,並用於饋送到神經網路。

  1. using namespace std;

  2. // Meta data used to normalize the data set. Useful to

  3. // go back and forth between normalized data.

  4. class DataSetMetaData {

  5. friend class DataSet;

  6. private:

  7.  float mean_km;

  8.  float std_km;

  9.  float mean_age;

  10.  float std_age;

  11.  float min_price;

  12.  float max_price;

  13. };

  14. enum class Fuel {

  15.    DIESEL,

  16.    GAZOLINE

  17. };

  18. class DataSet {

  19. public:

  20.  // Construct a data set from the given csv file path.

  21.  DataSet(string path) {

  22.    ReadCSVFile(path);

  23.  }

  24.  // getters

  25.  vector<float>& x() { return x_; }

  26.  vector<float>& y() { return y_; }

  27.  // read the given csv file and complete x_ and y_

  28.  void ReadCSVFile(string path);

  29.  // convert one csv line to a vector of float

  30.  vector<float> ReadCSVLine(string line);

  31.  // normalize a human input using the data set metadata

  32.  initializer_list<float> input(float km, Fuel fuel, float age);

  33.  // convert a price outputted by the DNN to a human price

  34.  float output(float price);

  35. private:

  36.  DataSetMetaData data_set_metadata;

  37.  vector<float> x_;

  38.  vector<float> y_;

  39. };

data_set.cc

  1. #include <vector>

  2. #include <fstream>

  3. #include <sstream>

  4. #include <iostream>

  5. #include "data_set.h"

  6. using namespace std;

  7. void DataSet::ReadCSVFile(string path) {

  8.  ifstream file(path);

  9.  stringstream buffer;

  10.  buffer << file.rdbuf();

  11.  string line;

  12.  vector<string> lines;

  13.  while(getline(buffer, line, '\n')) {

  14.    lines.push_back(line);

  15.  }

  16.  // the first line contains the metadata

  17.  vector<float> metadata = ReadCSVLine(lines[0]);

  18.  data_set_metadata.mean_km = metadata[0];

  19.  data_set_metadata.std_km = metadata[1];

  20.  data_set_metadata.mean_age = metadata[2];

  21.  data_set_metadata.std_age = metadata[3];

  22.  data_set_metadata.min_price = metadata[4];

  23.  data_set_metadata.max_price = metadata[5];

  24.  // the other lines contain the features for each car

  25.  for (int i = 2; i < lines.size(); ++i) {

  26.    vector<float> features = ReadCSVLine(lines[i]);

  27.    x_.insert(x_.end(), features.begin(), features.begin() + 3);

  28.    y_.push_back(features[3]);

  29.  }

  30. }

  31. vector<float> DataSet::ReadCSVLine(string line) {

  32.  vector<float> line_data;

  33.  std::stringstream lineStream(line);

  34.  std::string cell;

  35.  while(std::getline(lineStream, cell, ','))

  36.  {

  37.    line_data.push_back(stod(cell));

  38.  }

  39.  return line_data;

  40. }

  41. initializer_list<float> DataSet::input(float km, Fuel fuel, float age) {

  42.  km = (km - data_set_metadata.mean_km) / data_set_metadata.std_km;

  43.  age = (age - data_set_metadata.mean_age) / data_set_metadata.std_age;

  44.  float f = fuel == Fuel::DIESEL ? -1.f : 1.f;

  45.  return {km, f, age};

  46. }

  47. float DataSet::output(float price) {

  48.  return price * (data_set_metadata.max_price - data_set_metadata.min_price) + data_set_metadata.min_price;

  49. }

我們必須在 bazel BUILD 檔案中新增這兩個檔案。

  1. load("//tensorflow:tensorflow.bzl", "tf_cc_binary")

  2. tf_cc_binary(

  3.    name = "model",

  4.    srcs = [

  5.        "model.cc",

  6.        "data_set.h",

  7.        "data_set.cc"

  8.    ],

  9.    deps = [

  10.        "//tensorflow/cc:gradients",

  11.        "//tensorflow/cc:grad_ops",

  12.        "//tensorflow/cc:cc_ops",

  13.        "//tensorflow/cc:client_session",

  14.        "//tensorflow/core:tensorflow"

  15.    ],

  16. )

構建模型

第一步是讀取 CSV 檔案,並提取出兩個張量,其中 x 是輸入,y 為預期的真實結果。我們使用之前定義的 DataSet 類。

CSV 資料集下載連結:https://github.com/theflofly/dnn_tensorflow_cpp/blob/master/normalized_car_features.csv

  1. DataSet data_set("/path/normalized_car_features.csv");

  2. Tensor x_data(DataTypeToEnum<float>::v(),

  3.              TensorShape{static_cast<int>(data_set.x().size())/3, 3});

  4. copy_n(data_set.x().begin(), data_set.x().size(),

  5.       x_data.flat<float>().data());

  6. Tensor y_data(DataTypeToEnum<float>::v(),

  7.              TensorShape{static_cast<int>(data_set.y().size()), 1});

  8. copy_n(data_set.y().begin(), data_set.y().size(),

  9.       y_data.flat<float>().data());

要定義一個張量,我們需要知道它的型別和形狀。在 data_set 物件中,x 資料以向量的方式儲存,所以我們將尺寸縮減為 3(每個儲存三個特徵)。隨後我們使用 std::copy_n 來從 data_set 物件中複製資料到 Tensor(一個 Eigen::TensorMap)的底層資料結構中。現在,我們有了資料和 TensorFlow 資料結構,是時候構建模型了。

你可以輕易地 debug 一個張量:

  1. LOG(INFO) << x_data.DebugString();

C ++ API 的獨特之處在於,您需要一個 Scope 物件來保持構建靜態計算圖的狀態,並將該物件傳遞給每個操作。

  1. Scope scope = Scope::NewRootScope();

我們需要兩個佔位符,x 包含特徵,y 代表每輛車相應的價格。

  1. auto x = Placeholder(scope, DT_FLOAT);

  2. auto y = Placeholder(scope, DT_FLOAT);

我們的網路有兩個隱藏層,因此我們會有三個權重矩陣和三個偏置項向量。在 Python 中,它是由底層直接完成的,在 C++中你必須定義一個變數,隨後定義一個 Assign 節點以為該變數分配一個預設值。我們使用 RandomNormal 來初始化我們的變數,這會給我們一個服從正態分佈的隨機值。

  1. // weights init

  2. auto w1 = Variable(scope, {3, 3}, DT_FLOAT);

  3. auto assign_w1 = Assign(scope, w1, RandomNormal(scope, {3, 3}, DT_FLOAT));

  4. auto w2 = Variable(scope, {3, 2}, DT_FLOAT);

  5. auto assign_w2 = Assign(scope, w2, RandomNormal(scope, {3, 2}, DT_FLOAT));

  6. auto w3 = Variable(scope, {2, 1}, DT_FLOAT);

  7. auto assign_w3 = Assign(scope, w3, RandomNormal(scope, {2, 1}, DT_FLOAT));

  8. // bias init

  9. auto b1 = Variable(scope, {1, 3}, DT_FLOAT);

  10. auto assign_b1 = Assign(scope, b1, RandomNormal(scope, {1, 3}, DT_FLOAT));

  11. auto b2 = Variable(scope, {1, 2}, DT_FLOAT);

  12. auto assign_b2 = Assign(scope, b2, RandomNormal(scope, {1, 2}, DT_FLOAT));

  13. auto b3 = Variable(scope, {1, 1}, DT_FLOAT);

  14. auto assign_b3 = Assign(scope, b3, RandomNormal(scope, {1, 1}, DT_FLOAT));

隨後我們使用 Tanh 作為啟用函式來構建三個層。

  1. // layers

  2. auto layer_1 = Tanh(scope, Add(scope, MatMul(scope, x, w1), b1));

  3. auto layer_2 = Tanh(scope, Add(scope, MatMul(scope, layer_1, w2), b2));

  4. auto layer_3 = Tanh(scope, Add(scope, MatMul(scope, layer_2, w3), b3));

加入 L2 正則化。

  1. // regularization

  2. auto regularization = AddN(scope,

  3.                         initializer_list<Input>{L2Loss(scope, w1),

  4.                                                 L2Loss(scope, w2),

  5.                                                 L2Loss(scope, w3)});

最後計算損失函式,即計算預測價格和實際價格 y 之間的差異,並增加正則化到損失函式中。

  1. // loss calculation

  2. auto loss = Add(scope,

  3.                ReduceMean(scope, Square(scope, Sub(scope, layer_3, y)), {0, 1}),

  4.                Mul(scope, Cast(scope, 0.01,  DT_FLOAT), regularization));

在這裡,我們完成了前向傳播,現在該進行反向傳播了。第一步是使用函式呼叫,以在前向傳播操作的圖中加入梯度運算。

  1. // add the gradients operations to the graph

  2. std::vector<Output> grad_outputs;

  3. TF_CHECK_OK(AddSymbolicGradients(scope, {loss}, {w1, w2, w3, b1, b2, b3}, &grad_outputs));

所有的運算都需要計算損失函式對每一個變數的導數並新增到計算圖中,我們初始化 grad_outputs 空向量,它在 TensorFlow 會話被使用時將梯度傳入節點,grad_outputs[0] 會提供損失函式對 w1 的導數,grad_outputs[1] 提供損失函式對 w2 的導數,這一過程會根據 {w1, w2, w3, b1,b2, b3} 的順序,也是變數被傳遞到 AddSymbolicGradients 的順序進行。

現在我們在 grad_outputs 有一系列節點,當在 TensorFlow 會話中使用時,每個節點計算損失函式對一個變數的梯度。我們需要使用它來更新變數。所以,我們在每行放一個變數,使用梯度下降這個最簡單的方法來更新。

  1. // update the weights and bias using gradient descent

  2. auto apply_w1 = ApplyGradientDescent(scope, w1, Cast(scope, 0.01,  DT_FLOAT), {grad_outputs[0]});

  3. auto apply_w2 = ApplyGradientDescent(scope, w2, Cast(scope, 0.01,  DT_FLOAT), {grad_outputs[1]});

  4. auto apply_w3 = ApplyGradientDescent(scope, w3, Cast(scope, 0.01,  DT_FLOAT), {grad_outputs[2]});

  5. auto apply_b1 = ApplyGradientDescent(scope, b1, Cast(scope, 0.01,  DT_FLOAT), {grad_outputs[3]});

  6. auto apply_b2 = ApplyGradientDescent(scope, b2, Cast(scope, 0.01,  DT_FLOAT), {grad_outputs[4]});

  7. auto apply_b3 = ApplyGradientDescent(scope, b3, Cast(scope, 0.01,  DT_FLOAT), {grad_outputs[5]});

Cast 操作實際上是學習速率的引數,在這裡是 0.01。

我們的網路已經準備好在會話中啟動,基於 Python 的 Optimizers API 基本封裝了計算和應用過程中的損失函式最小化方法。當 Optimizer API 可以接入 C++時我們就可以在這裡使用它了。

我們初始化一個以 ClientSession 和一個以 Tensor 命名的輸出向量,用來接收網路的輸出。

  1. ClientSession session(scope);

  2. std::vector<Tensor> outputs;

隨後初始化變數,在 Python 中呼叫 tf.global_variables_initializer() 就可以,因為在構建計算圖時,所有變數的列表都是保留的。在 C++中,我們必須列出變數。每個 RandomNormal 輸出會分配給 Assign 節點中定義的變數。

  1. // init the weights and biases by running the assigns nodes once

  2. TF_CHECK_OK(session.Run({assign_w1, assign_w2, assign_w3, assign_b1, assign_b2, assign_b3}, nullptr));

在這一點上,我們可以在訓練步驟的數量內迴圈地更新引數,在我們的例子中是 5000 步。第一步是使用 loss 節點執行前向傳播部分,輸出是網路的損失。每 100 步我們都會記錄一次損失值,損失的減少是網路成功執行的標誌。隨後我們必須計算梯度節點並更新變數。我們的梯度節點是 ApplyGradientDescent 節點的輸入,所以執行 apply_nodes 會首先計算梯度,隨後將其應用到正確的變數上。

  1. // training steps

  2. for (int i = 0; i < 5000; ++i) {

  3.  TF_CHECK_OK(session.Run({{x, x_data}, {y, y_data}}, {loss}, &outputs));

  4.  if (i % 100 == 0) {

  5.    std::cout << "Loss after " << i << " steps " << outputs[0].scalar<float>() << std::endl;

  6.  }

  7.  // nullptr because the output from the run is useless

  8.  TF_CHECK_OK(session.Run({{x, x_data}, {y, y_data}}, {apply_w1, apply_w2, apply_w3, apply_b1, apply_b2, apply_b3, layer_3}, nullptr));

  9. }

在網路訓練到這種程度後,我們可以嘗試預測汽車的價格了——進行推斷。讓我們來嘗試預測一輛車齡為 7 年,里程 11 萬公里,柴油發動機的寶馬 1 系轎車。為了這樣做我們需要執行 layer_3 節點,將汽車的資料輸入 x,這是一個前向傳播的步驟。因為我們之前執行了 5000 步的訓練,權重已經得到了學習,所以輸出的結果將不是隨機的。

我們不能直接使用汽車的屬性,因為我們的神經網路是從歸一化屬性中學習的,所以資料必須經過同樣的歸一化過程。DataSet 類有一個 input 方法在 CSV 讀取器件處理資料集中的後設資料。

  1. // prediction using the trained neural net

  2. TF_CHECK_OK(session.Run({{x, {data_set.input(110000.f, Fuel::DIESEL, 7.f)}}}, {layer_3}, &outputs));

  3. cout << "DNN output: " << *outputs[0].scalar<float>().data() << endl;

  4. std::cout << "Price predicted " << data_set.output(*outputs[0].scalar<float>().data()) << " euros" << std::endl;

網路的輸出值在 0 到 1 之間,data_set 的 output 方法還負責將數值從後設資料轉換回人類可讀的數字。模型可以使用 bazel run -c opt //tensorflow/cc/models:model 命令來執行,如果 TensorFlow 剛剛被編譯,你可以看到這樣形式的輸出:

  1. Loss after 0 steps 0.317394

  2. Loss after 100 steps 0.0503757

  3. Loss after 200 steps 0.0487724

  4. Loss after 300 steps 0.047366

  5. Loss after 400 steps 0.0460944

  6. Loss after 500 steps 0.0449263

  7. Loss after 600 steps 0.0438395

  8. Loss after 700 steps 0.0428183

  9. Loss after 800 steps 0.041851

  10. Loss after 900 steps 0.040929

  11. Loss after 1000 steps 0.0400459

  12. Loss after 1100 steps 0.0391964

  13. Loss after 1200 steps 0.0383768

  14. Loss after 1300 steps 0.0375839

  15. Loss after 1400 steps 0.0368152

  16. Loss after 1500 steps 0.0360687

  17. Loss after 1600 steps 0.0353427

  18. Loss after 1700 steps 0.0346358

  19. Loss after 1800 steps 0.0339468

  20. Loss after 1900 steps 0.0332748

  21. Loss after 2000 steps 0.0326189

  22. Loss after 2100 steps 0.0319783

  23. Loss after 2200 steps 0.0313524

  24. Loss after 2300 steps 0.0307407

  25. Loss after 2400 steps 0.0301426

  26. Loss after 2500 steps 0.0295577

  27. Loss after 2600 steps 0.0289855

  28. Loss after 2700 steps 0.0284258

  29. Loss after 2800 steps 0.0278781

  30. Loss after 2900 steps 0.0273422

  31. Loss after 3000 steps 0.0268178

  32. Loss after 3100 steps 0.0263046

  33. Loss after 3200 steps 0.0258023

  34. Loss after 3300 steps 0.0253108

  35. Loss after 3400 steps 0.0248298

  36. Loss after 3500 steps 0.0243591

  37. Loss after 3600 steps 0.0238985

  38. Loss after 3700 steps 0.0234478

  39. Loss after 3800 steps 0.0230068

  40. Loss after 3900 steps 0.0225755

  41. Loss after 4000 steps 0.0221534

  42. Loss after 4100 steps 0.0217407

  43. Loss after 4200 steps 0.0213369

  44. Loss after 4300 steps 0.0209421

  45. Loss after 4400 steps 0.020556

  46. Loss after 4500 steps 0.0201784

  47. Loss after 4600 steps 0.0198093

  48. Loss after 4700 steps 0.0194484

  49. Loss after 4800 steps 0.0190956

  50. Loss after 4900 steps 0.0187508

  51. DNN output: 0.0969611

  52. Price predicted 13377.7 euros

這裡的預測車價是 13377.7 歐元。每次預測的到的車價都不相同,甚至會介於 8000-17000 之間。這是因為我們只使用了三個屬性來描述汽車,而我們的的模型架構也相對比較簡單。

正如之前所說的,C++ API 的開發仍在進行中,我們希望在不久的將來,更多的功能可以加入進來。

相關文章