Share via


本文章是由機器翻譯。

測試運行

深入瞭解神經網路

James McCaffrey

下載代碼示例

James McCaffrey
人工神經網路 (通常只是稱為神經網路) 是效仿生物神經元和神經突觸的抽象。雖然神經網路已經研究了數十年,但在我看來,Internet 上的很多神經網路代碼實現方案至今沒有提供很好的說明。在本月的專欄中,我將解釋什麼是神經網路並提供實現神經網路的 C# 代碼。

要想瞭解我在此講述的內容,最好是看一下圖 1圖 2。思考神經網路的一種方式是將其視為數位輸入/輸出機制。圖 1 所示的神經網路有三個輸入,分別標記為 x0、x1 和 x2,並且分別具有值 1.0、2.0 和 3.0。該神經網路有兩個輸出,分別標記為 y0 何 y1,並且分別具有值 0.72 和 -0.88。圖 1 所示的神經網路有一個所謂的隱藏神經元層,並且可被描述成一個包含三個輸入、兩個輸出和四個隱藏神經元的完全連接的三層前饋網路。可惜的是,神經網路術語改變了不少。在本文中,我使用的術語一般和 bit.ly/wfikTI 上“優秀的神經網路常見問題解答”中所述的術語保持一致,但也有例外。


圖 1 神經網路的結構

圖 2 神經網路演示計畫

圖 2 顯示的是本文仲介紹的演示程式產生的輸出。神經網路採用 S 形啟動函數和雙曲正切啟動函數。在圖 1 中,這兩個函數分別表示為一個含有希臘字母 phi 的方程式。神經網路產生的輸出取決於一組數位權重和偏移的值。在本例中,共有 26 個權重和偏移,值分別為 0.10、0.20 ...-5.00.當權重和偏移值載入到神經網路中後,演示程式會載入這三個輸入值(1.0、2.0 和 3.0),然後執行一系列計算,如消息提示的“輸入到隱藏”運算和“隱藏到輸出”運算。演示程式最後會顯示兩個輸出值(0.72 和 -0.88)。

我將向您詳細介紹產生圖 2 所示輸出的程式。本專欄假定您具備中級程式設計技能,但不了解神經網路。該演示程式使用 C# 語言進行編碼,但您應該能夠輕鬆使用其他語言重構演示代碼,如 Visual Basic .NET 或 Python。本文中提供的程式本質上屬於教程和實驗平臺;並不能直接解決任何實際問題,因此我還將介紹如何擴展該代碼以解決具有實際意義的問題。我相信,您會發現本文非常有趣,而且部分程式設計技巧對您的編碼技能不無裨益。

為神經網路建模

從概念上說,人工神經網路類比了真正的生物神經網路的行為。在圖 1 中,圓圈表示執行處理的神經元,箭頭則表示資訊流以及稱為權重的數值。在很多情況下,輸入值不含任何權重,而是直接複製到輸入神經元中,並且不進行任何處理便直接發出,因此,最早的實際操作發生在隱藏層神經元中。假設輸入值 1.0、2.0 和 3.0 已從輸入神經元發出。如果您仔細查看圖 1,您會看到一個箭頭,表示三個輸入神經元與四個隱藏神經元之間一一對應的權重值。假設顯示的三個指向頂部隱藏神經元的權重箭頭分別命名為 w00、w10 和 w20。在這種記法中,第一個索引表示源輸入神經元的索引,第二個索引則表示目標隱藏神經元的索引。神經元處理共有三個步驟。第一步,計算出加權和。假設 w00 = 0.1、w10 = 0.5 且 w20 = 0.9。則頂部隱藏神經元的加權和則為 (1.0)(0.1) + (2.0)(0.5) + (3.0)(0.9) = 3.8。第二個處理步驟是添加的偏差值。假設偏移值為 -2.0;則調整後的加權和變為 3.8 + (-2.0) = 1.8。第三步是向調整後的加權和應用一個啟動函數。假設啟動函數是由 1.0 / (1.0 * Exp(-x)) 定義的 Sigmoid 函數,其中 Exp 表示指數函數。隱藏神經元的輸出則變為 1.0 / (1.0 * Exp(-1.8)) = 0.86。此輸出將成為輸入到每個輸出層神經元中的加權和的一部分。在圖 1 中,這三個步驟通過含有希臘字母 phi 的方程式表示:計算出加權和 (xw)、添加偏移 (b) 並應用啟動函數 (phi)。

當所有隱藏神經元的值都計算出後,將以同樣的方式計算輸出層神經元的值。用來計算輸出神經元值的啟動函數可以跟用來計算隱藏神經元值的函數完全相同,也可以與其不同。圖 2 中運行的演示程式使用雙曲正切函數作為“隱藏到輸出”啟動函數。當計算出所有的輸出層神經元值後,在大多數情況下,不會對這些值進行加權或處理,而只是簡單地作為神經網路的最終輸出值發出。

內部結構

理解本文介紹的神經網路實現方案的關鍵是仔細研究圖 3,乍一看,您可能會覺得非常複雜。但是請先忍一下,這個圖形其實沒那麼複雜,產生這種感覺的原因只是因為它才第一次出現。圖 3 展示了全部八個數組和兩個矩陣。第一個陣列標記為 this.inputs。此陣列存放了神經網路輸入值,在本示例中,這些值為 1.0、2.0 和 3.0。接下來是用於在所謂的“隱藏層”計算值的一組權重值。這些權重存儲在 3 x 4 的矩陣中,並標記為 i-h weights,其中 i-h 表示“輸入到隱藏”。請注意,圖 1 中的演示神經網路有四個隱藏神經元。i-h weights 矩陣中的行數與輸入的數量相同,列數則與隱藏神經元的數量相同。


圖 3 神經網路的內部結構**

標記為 i-h sums 的陣列是一個用於計算的臨時陣列。請注意,i-h sums 陣列的長度總是與隱藏神經元的數量保持一致(在本例中為四個)。接下來是標記為 i-h biases 的陣列。神經網路偏移是用於計算隱藏和輸出層神經元的額外權重。i-h biases 陣列的長度與 i-h sums 陣列的長度相同,因此也和隱藏神經元的數量相同。

標記為 i-h outputs 的陣列是一個中間結果,此陣列中的值將用作下一層的輸入。i-h sums 陣列的長度與隱藏神經元的數量相同。

接下來是標記為 h-o weights 的矩陣,其中 h-o 表示“隱藏到輸出”。在這裡,h-o weights 矩陣的大小為 4 x 2,因為一共有四個隱藏神經元和兩個輸出。h-o sums 陣列、h-o biases 陣列和 this.outputs 陣列的長度均與輸出的數量相同(在本例中為兩個)。

圖 3 底部標記為 weights 的陣列存放了“輸入到隱藏”和“隱藏到輸出”的所有權重和偏移。在此示例中,重量陣列的長度是 (3 * 4) + 4 + (4 * 2) + 2 = 26。一般情況下,如果倪輸入值的數目,Nh 是隱藏的神經元數目的並不是產出,數目然後權重陣列的長度將會 Nw = (倪 * Nh) + Nh + (Nh * 無) + 號

計算輸出

當創建好上述八個數組和兩個矩陣後,神經網路即可根據輸入、權重和偏移計算輸出。第一步是將輸入值複製到 this.inputs 陣列中。接下來是為 weights 陣列賦值。在本演示中,您可以使用任何權重值。然後,weights 陣列中的值將會複製到 i-h weights 矩陣、i-h biases 陣列、h-o weights 矩陣和 h-o biases 陣列。圖 3 清晰顯示了這一關係。

計算 i-h sums 陣列中的值包括兩個步驟。第一步,將 inputs 陣列中的值乘以 i-h weights 矩陣中適當列的值,計算出加權和。例如,隱藏神經元的加權和 [3](這裡我使用的是零基索引)就是將每個輸入值乘以 i-h weights 矩陣中第 [3] 列的值所得的結果:(1.0)(0.4) + (2.0)(0.8) + (3.0)(1.2) = 5.6.計算 i-h sum 值的第二步是將每個偏移值添加到當前的 i-h sum 值。例如,由於 i-h biases [3] 的一個值為 -7.0,則 i-h sums [3] 的值就變成 5.6 + (-7.0) = -1.4。

當 i-h sums 陣列中的所有值均計算出後,再將“輸入到隱藏”啟動函數應用到這些和,產出“輸入到隱藏”的輸出值。在這裡,您可以使用多種不同的啟動函數。其中最簡單的啟動函數是階躍函數,該函數只是簡單地對所有大於零的輸入值返回 1.0,而對小於或等於零的輸入值返回 0.0。另一種常見的並且也是本文中所使用的啟動函數是 Sigmoid 函數,定義為 f(x) = 1.0 / (1.0 * Exp(-x))。Sigmoid 函數的圖形如圖 4 所示。


圖 4 乙狀結腸功能

請注意,Sigmoid 函數返回的值嚴格限制在 0 到 1 之間(不含 0 和 1)。在本例中,如果 i-h sums [3] 的值在添加偏移值後變成 -1.4,則 i-h outputs [3] 的值將變為 1.0 / (1.0 * Exp(-(-1.4))) = 0.20。

當所有“輸入到隱藏”輸出神經元值均計算出後,這些值將用作“隱藏到輸出”層神經元計算的輸入。這些計算過程與“輸入到隱藏”計算過程相同:先計算出加權和,然後添加偏移,最後應用某個啟動函數。在本例中,我使用雙曲正切函數(簡稱為 tanh)作為“隱藏到輸出”啟動函數。tanh 函數與 Sigmoid 函數的關係十分密切。tanh 函數的圖形是一個與 Sigmoid 函數頗為相似的 S 形曲線,但 tanh 函數的返回值範圍是 (-1,1),而不是 (0,1)。

組合權重和偏移

我在 Internet 上看到的所有神經網路實現方案都沒有維護單獨的權重和偏移陣列,而是將權重和偏移組合到權重矩陣中。這是如何實現的?回想一下“輸入到隱藏”神經元 [3] 的值的計算過程:(i0 * w03) + (i1 * w13) + (i2 * w23) + b3,其中 i0 是輸入值 [0],w03 是輸入 [0] 和神經元 [3] 的權重,而 b3 是隱藏神經元 [3] 的偏移值。如果您創建另一個假的輸入 [4] 並且包含一個虛擬值 1.0,以及另一個包含偏移值的權重行,則剛剛講到的計算就變為:(i0 * w03) + (i1 * w13) + (i2 * w23) + (i3 * w33),其中 i3 是虛擬的 1.0 輸入值,w33 是偏移。有人認為,這種方法簡化了神經網路模型。我不同意這種說法。在我看來,組合權重和偏移使得神經網路模型更加難以理解,並且在實現過程中更容易出錯。但是,很顯然,只有我是這麼想的,因此您應該自行做出設計決策。

實現

我實施中所示的神經網路數位 123 使用 Visual Studio 2010。創建 C# 主控台應用程式命名為神經網路。在解決方案資源管理器視窗中,我按右鍵 Program.cs 檔並將其重命名為 NeuralNetworksProgram.cs,這也將範本生成的類名改成了 NeuralNetworksProgram。刪除了大部分 WriteLine 語句的程式整體結構如圖 5 所示。

圖 5 神經網路程式結構

 

using System;
namespace NeuralNetworks
{
  class NeuralNetworksProgram
  {
    static void Main(string[] args)
    {
      try
      {
        Console.WriteLine("\nBegin Neural Network demo\n");
        NeuralNetwork nn = new NeuralNetwork(3, 4, 2);
        double[] weights = new double[] {
          0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2,
          -2.0, -6.0, -1.0, -7.0,
          1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.0,
          -2.5, -5.0 };
        nn.SetWeights(weights);
        double[] xValues = new double[] { 1.0, 2.0, 3.0 };
        double[] yValues = nn.ComputeOutputs(xValues);
        Helpers.ShowVector(yValues);
        Console.WriteLine("End Neural Network demo\n");
      }
      catch (Exception ex)
      {
        Console.WriteLine("Fatal: " + ex.Message);
      }
    }
  }
  class NeuralNetwork
  {
    // Class members here
    public NeuralNetwork(int numInput, int numHidden, int numOutput) { ... }
    public void SetWeights(double[] weights) { ... }
    public double[] ComputeOutputs(double[] xValues) { ... }
    private static double SigmoidFunction(double x) { ... }
    private static double HyperTanFunction(double x) { ... }
  }
  public class Helpers
  {
    public static double[][] MakeMatrix(int rows, int cols) { ... }
    public static void ShowVector(double[] vector) { ... }
    public static void ShowMatrix(double[][] matrix, int numRows) { ... }
  }
} // ns

我刪除了所有範本生成的 using 語句,除了引用 System 命名空間的一條語句。 在 Main 函數中,顯示了開始消息後,我使用三個輸入、四個隱藏神經元和兩個輸出產生實體了一個名為 nn 的 NeuralNetwork 物件。 接著,我向一個名為 weights 的陣列分配了 26 個任意權重和偏移。 然後,我使用 SetWeights 方法將這些權重載入到神經網路物件中。 我將值 1.0、2.0 和 3.0 分配到 xValues 陣列中。 我使用 ComputeOutputs 方法將輸入值載入到神經網路中,並確定所產生的輸出,然後將這些輸出提取到 yValues 陣列中。 演示程式最後會顯示輸出值。

NeuralNetwork 類

NeuralNetwork 類定義開始為:

class NeuralNetwork
{
  private int numInput;
  private int numHidden;
  private int numOutput;
...

如同前面幾部分所介紹的那樣,神經網路的結構取決於輸入值、隱藏層神經元和輸出值的數量。 接著,類定義如下:

private double[] inputs;
private double[][] ihWeights; // input-to-hidden
private double[] ihSums;
private double[] ihBiases;
private double[] ihOutputs;
private double[][] hoWeights;  // hidden-to-output
private double[] hoSums;
private double[] hoBiases;
private double[] outputs;
...

這七個數組和兩個矩陣對應到圖 3 所示的陣列和矩陣。 我為“輸入到隱藏”資料增加了一個 ih 首碼,為“隱藏到輸出”資料增加了一個 ho 首碼。 回想一下 ihOutputs 陣列中用作輸出層計算的輸入的值,所以為此陣列命名有點麻煩。

圖 6 顯示了如何定義 NeuralNetwork 類的構造函數。

圖 6 神經網路類的構造函數

public NeuralNetwork(int numInput, int numHidden, int numOutput)
{
  this.numInput = numInput;
  this.numHidden = numHidden;
  this.numOutput = numOutput;
  inputs = new double[numInput];
  ihWeights = Helpers.MakeMatrix(numInput, numHidden);
  ihSums = new double[numHidden];
  ihBiases = new double[numHidden];
  ihOutputs = new double[numHidden];
  hoWeights = Helpers.MakeMatrix(numHidden, numOutput);
  hoSums = new double[numOutput];
  hoBiases = new double[numOutput];
  outputs = new double[numOutput];
}

將輸入參數值 numInput、numHidden 和 numOutput 複製到它們各自對應的類欄位中後,全部九個成員陣列和矩陣都將分配到我前面所說的大小。 我將矩陣實現為包含陣列的陣列,而沒有使用 C# 的多維陣列類型,這樣您就更容易將我的代碼重構為不支援多維陣列類型的語言。 由於我的矩陣的每一行都必須分配,因此使用説明器方法會比較方便,如 MakeMatrix。

SetWeights 方法接受一組權重和偏移值,並且填充 ihWeights、ihBiases、hoWeights 和 hoBiases。 該方法的開頭如下所示:

public void SetWeights(double[] weights)
{
  int numWeights = (numInput * numHidden) +
    (numHidden * numOutput) + numHidden + numOutput;
  if (weights.Length != numWeights)
    throw new Exception("xxxxxx");
  int k = 0;
...

正如較早前,解釋的重量和偏見,Nw,總數在完全連接的前饋神經網路是 (倪 * Nh) + (Nh * 無) + Nh + 號 我做了一個簡單的判斷權值陣列參數是否具有正確的長度。 此處,“xxxxxx”表示描述性錯誤消息。 接著,我將索引變數 k 初始化為 weights 陣列參數的開頭。 SetWeights 方法最後為:

for (int i = 0; i < numInput; ++i)
  for (int j = 0; j < numHidden; ++j)
    ihWeights[i][j] = weights[k++];
for (int i = 0; i < numHidden; ++i)
  ihBiases[i] = weights[k++];
for (int i = 0; i < numHidden; ++i)
  for (int j = 0; j < numOutput; ++j)
    hoWeights[i][j] = weights[k++];
for (int i = 0; i < numOutput; ++i)
  hoBiases[i] = weights[k++]
}

weights 陣列參數中的每個值將依次複製到 ihWeights、ihBiases、hoWeights 和 hoBiases 中。 請注意,不會有任何值複製到 ihSums 或 hoSums 中,因為這兩個從頭開始的陣列將用於計算。

計算輸出

NeuralNetwork 類的核心是 ComputeOutputs 方法。 此方法異常簡短,開始為:

public double[] ComputeOutputs(double[] xValues)
{
  if (xValues.Length != numInput)
    throw new Exception("xxxxxx");
  for (int i = 0; i < numHidden; ++i)
    ihSums[i] = 0.0;
  for (int i = 0; i < numOutput; ++i)
    hoSums[i] = 0.0;
...

首先,我將檢查一下輸入 xValues 陣列的長度是否適合 NeuralNetwork 物件。 然後將 ihSums 和 hoSums 陣列清零。 如果僅調用一次 ComputeOutputs,則不需要進行此顯式初始化;但如果多次調用 ComputeOutputs(因為 ihSums 和 hoSums 為計算值),則必須進行顯式初始化。 您也可以使用替代設計方法,不聲明 ihSums 和 hoSums 並且不將它們分配為類成員,而是將它們包含在 ComputeOutputs 方法內部。 接著,ComputeOutputs 方法如下:

for (int i = 0; i < xValues.Length; ++i)
  this.inputs[i] = xValues[i];
for (int j = 0; j < numHidden; ++j)
  for (int i = 0; i < numInput; ++i)
    ihSums[j] += this.inputs[i] * ihWeights[i][j];
...

xValues 陣列參數中的值將複製到類輸入陣列成員中。 在某些神經網路方案中,輸入參數值進行了標準化,例如,通過執行線性轉換,使所有輸入都在 -1.0 到 +1.0 之間,但是本例中沒有進行標準化。 接著,嵌套的迴圈計算出加權和,如圖 1圖 3 所示。 請注意,為了以標準格式為 ihWeights 編制索引(其中 i 是行索引,j 是列索引),必須將 j 放在外層迴圈。 接著,ComputeOutputs 方法如下:

for (int i = 0; i < numHidden; ++i)
  ihSums[i] += ihBiases[i];
for (int i = 0; i < numHidden; ++i)
  ihOutputs[i] = SigmoidFunction(ihSums[i]);
...

每個加權和都會通過添加適當的偏移值進行修改。 此時,為產生圖 2 所示的輸出,我使用 Helpers.ShowVector 方法來顯示 ihSums 陣列中的當前值。 接著,我將 Sigmoid 函數應用到 ihSums 中的每個值,並將結果分配到陣列 ihOutputs 中。 稍後我將簡單介紹一下 SigmoidFunction 方法的代碼。 接著,ComputeOutputs 方法如下:

for (int j = 0; j < numOutput; ++j)
  for (int i = 0; i < numHidden; ++i)
    hoSums[j] += ihOutputs[i] * hoWeights[i][j];
for (int i = 0; i < numOutput; ++i)
  hoSums[i] += hoBiases[i];
...

我使用 ihOutputs 中剛剛計算出的值和 hoWeights 中的權重來計算 hoSums 中的值,然後加上合適的“隱藏到輸出”偏移值。 同樣,為了產生圖 2 所示的輸出,我調用了 Helpers.ShowVector。 ComputeOutputs 方法最後如下:

for (int i = 0; i < numOutput; ++i)
    this.outputs[i] = HyperTanFunction(hoSums[i]);
  double[] result = new double[numOutput];
  this.outputs.CopyTo(result, 0);
  return result;
}

我將 HyperTanFunction 方法應用到 hoSums,以將最終輸出生成到類陣列私有成員輸出中。 我將這些輸出複製到本地結果陣列中,並將該陣列用作返回值。 您也可以選擇不實現不含返回值的 ComputeOutputs,而實現公共方法 GetOutputs,這樣便可以檢索神經網路物件的輸出。

啟動函數和説明器方法

下麵是用來計算“輸入到隱藏”輸出的 Sigmoid 函數的代碼:

private static double SigmoidFunction(double x)
{
  if (x < -45.0) return 0.0;
  else if (x > 45.0) return 1.0;
  else return 1.0 / (1.0 + Math.Exp(-x));
}

由於 Math.Exp 函數的部分實現可能會產生算術溢出,因此通常需要檢查輸入參數的值。 用來計算“隱藏到輸出”結果的 tanh 函數的代碼如下:

private static double HyperTanFunction(double x)
{
  if (x < -10.0) return -1.0;
  else if (x > 10.0) return 1.0;
  else return Math.Tanh(x);
}

雙曲正切函數的返回值在 -1 到 +1 之間,因此不會出現算術溢出的問題。 此處,檢查輸入值僅僅是為了提高性能。

Helpers 類中的靜態實用工具方法只是為了編碼方便。 NeuralNetwork 構造函數中用來分配矩陣的 MakeMatrix 方法將矩陣的每一行都實現為包含陣列的陣列:

public static double[][] MakeMatrix(int rows, int cols)
{
  double[][] result = new double[rows][];
  for (int i = 0; i < rows; ++i)
    result[i] = new double[cols];
  return result;
}

 

ShowVector 和 ShowMatrix 方法將陣列或矩陣中的值顯示到主控台上。您可以在隨本文提供的代碼下載中查看這兩個方法的代碼(請訪問 archive.msdn.microsoft.com/mag201205TestRun)。

後續步驟

此處介紹的代碼為您理解和實驗神經網路奠定了堅實的基礎。您可能希望深入瞭解使用不同的啟動函數以及不同數量的輸入、輸出和隱藏層神經元的效果。您可以修改神經網路使其部分連接,這樣,其中的部分神經元在邏輯上就不會連接到下一層。本文介紹的神經網路具有一個隱藏層。更為複雜的神經網路可能包含兩個甚至更多的隱藏層,而您可能還想擴展此處介紹的代碼以實現此類神經網路。

神經網路可用於解決各種實際問題,包括分類問題。要解決這些問題,還存在一些挑戰。例如,您必須懂得如何編碼非數位資料,以及如何訓練神經網路使其找到一組最優的權重和偏移。在以後的文章中,我將介紹使用神經網路進行分類的方法。

Dr.James McCaffrey 為伏資訊科學 Inc.,凡他管理技術的培訓,在微軟的華盛頓州雷德蒙德,校園工作的軟體工程師的工作。他參與過多項 Microsoft 產品的研發工作,其中包括 Internet Explorer 和 MSN Search。他是《.NET Test Automation Recipes》(Apress, 2006) 的作者,您可以通過以下電子郵箱位址與他聯繫:jammc@microsoft.com

衷心感謝以下 Microsoft 技術專家對本文的審閱:Dan LieblingAnne Loomis Thompson