歡迎大家前往雲+社區,獲取更多騰訊海量技術實踐干貨哦~
- 下載Demo - 2.77 MB (原始地址):handwritten_character_recognition.zip
- 下載源碼 - 70.64 KB (原始地址) :nnhandwrittencharreccssource.zip
介紹
這是一篇基於Mike O'Neill 寫的一篇很棒的文章:神經網絡的手寫字符識別(Neural Network for Recognition of Handwritten Digits)而給出的一個人工神經網絡實現手寫字符識別的例子。盡管在過去幾年已經有許多系統和分類算法被提出,但是手寫識別任然是模式識別中的一項挑戰。Mike O'Neill的程序對想學習通過神經網絡算法實現一般手寫識別的程序員來說是一個極好的例子,尤其是在神經網絡的卷積部分。那個程序是用MFC/ C++編寫的,對於不熟悉的人來說有些困難。所以,我決定用C#重新寫一下我的一些程序。我的程序已經取得了良好的效果,但還並不優秀(在收斂速度,錯誤率等方面)。但這次僅僅是程序的基礎,目的是幫助理解神經網絡,所以它比較混亂,有重構的必要。我一直在試把它作為一個庫的方式重建,那將會很靈活,很簡單地通過一個INI文件來改變參數。希望有一天我能取得預期的效果。
字符檢測
模式檢測和字符候選檢測是我在程序中必須面對的最重要的問題之一。事實上,我不僅僅想利用另一種編程語言重新完成Mike的程序,而且我還想識別文檔圖片中的字符。有一些研究提出了我在互聯網上發現的非常好的目標檢測算法,但是對於像我這樣的業余項目來說,它們太復雜了。在教我女兒繪畫時發現的一個方法解決了這個問題。當然,它仍然有局限性,但在第一次測試中就超出了我的預期。在正常情況下,字符候選檢測分為行檢測,字檢測和字符檢測幾種,分別采用不同的算法。我的做法和這有一點點不同。檢測使用相同的算法:
public static Rectangle GetPatternRectangeBoundary (Bitmap original,int colorIndex, int hStep, int vStep, bool bTopStart)
以及:
public static List<Rectangle> PatternRectangeBoundaryList (Bitmap original, int colorIndex, int hStep, int vStep, bool bTopStart,int widthMin,int heightMin)
通過改變參數hStep
(水平步進)和vStep
(垂直步進)可以簡單地檢測行,字或字符。矩形邊界也可以通過更改bTopStart
為true
或false
實現從上到下和從左到右不同方式進行檢測。矩形被widthMin
和d限制。我的算法的最大優點是:它可以檢測不在同一行的字或字符串。


public void PatternRecognitionThread(Bitmap bitmap) { _originalBitmap = bitmap; if (_rowList == null) { _rowList = AForge.Imaging.Image.PatternRectangeBoundaryList (_originalBitmap,255, 30, 1, true, 5, 5); _irowIndex = 0; } foreach(Rectangle rowRect in _rowList) { _currentRow = AForge.Imaging.ImageResize.ImageCrop (_originalBitmap, rowRect); if (_iwordIndex == 0) { _currentWordsList = AForge.Imaging.Image.PatternRectangeBoundaryList (_currentRow, 255, 20, 10, false, 5, 5); } foreach (Rectangle wordRect in _currentWordsList) { _currentWord = AForge.Imaging.ImageResize.ImageCrop (_currentRow, wordRect); _iwordIndex++; if (_icharIndex == 0) { _currentCharsList = AForge.Imaging.Image.PatternRectangeBoundaryList (_currentWord, 255, 1, 1, false, 5, 5); } foreach (Rectangle charRect in _currentCharsList) { _currentChar = AForge.Imaging.ImageResize.ImageCrop (_currentWord, charRect); _icharIndex++; Bitmap bmptemp = AForge.Imaging.ImageResize.FixedSize (_currentChar, 21, 21); bmptemp = AForge.Imaging.Image.CreateColorPad (bmptemp,Color.White, 4, 4); bmptemp = AForge.Imaging.Image.CreateIndexedGrayScaleBitmap (bmptemp); byte[] graybytes = AForge.Imaging.Image.GrayscaletoBytes(bmptemp); PatternRecognitionThread(graybytes); m_bitmaps.Add(bmptemp); } string s = " \n"; _form.Invoke(_form._DelegateAddObject, new Object[] { 1, s }); If(_icharIndex ==_currentCharsList.Count) { _icharIndex =0; } } If(_iwordIndex==__currentWordsList.Count) { _iwordIndex=0; } }
字符識別
原程序中的卷積神經網絡(CNN)包括輸入層在內本質上是有五層。卷積體系結構的細節已經在Mike和Simard博士在他們的文章《應用於視覺文件分析的卷積神經網絡的最佳實踐》中描述過了。這種卷積網絡的總體方案是用較高的分辨率去提取簡單的特征,然后以較低的分辨率將它們轉換成復雜的特征。生成較低分辨的最簡單方法是對子層進行二倍二次采樣。這反過來又為卷積核的大小提供了參考。核的寬度以一個單位(奇數大小)為中心被選定,需要足夠的重疊從而不丟失信息(對於一個單位3重疊顯得過小),同時不至於冗余(7重疊將會過大,5重疊能實現超過70%的重疊)。因此,在這個網絡中我選擇大小為5的卷積核。填充輸入(調整到更大以實現特征單元居中在邊界上)並不能顯着提高性能。所以不填充,內核大小設定為5進行二次采樣,每個卷積層將特征尺寸從n減小到(n-3)/2。由於在MNIST的初始輸入的圖像大小為28x28,所以在二次卷積后產生整數大小的近似值是29x29。經過兩層卷積之后,5x5的特征尺寸對於第三層卷積而言太小。Simard博士還強調,如果第一層的特征少於五個,則會降低性能,然而使用超過5個並不能改善(Mike使用了6個)。類似地,在第二層上,少於50個特征會降低性能,而更多(100個特征)沒有改善。關於神經網絡的總結如下:
#0層:是MNIST數據庫中手寫字符的灰度圖像,填充到29x29像素。輸入層有29x29 = 841個神經元。
#1層:是一個具有6個特征映射的卷積層。從層#1到前一層有13×13×6 = 1014個神經元,(5×5 + 1)×6 = 156個權重,以及1014×26 = 26364個連接。
#2層:是一個具有五十(50)個特征映射的卷積層。從#2層到前一層有5x5x50 = 1250個神經元,(5x5 + 1)x6x50 = 7800個權重,以及1250x(5x5x6 + 1)= 188750個連接。(在Mike的文章中不是有32500個連接)。
#3層:是一個100個單元的完全連接層。有100個神經元,100x(1250 + 1)= 125100權重,和100x1251 = 125100連接。
#4層:是最后的,有10個神經元,10×(100 + 1)= 1010個權重,以及10×10 1 = 1010個連接。
反向傳播
反向傳播是更新每個層權重變化的過程,從最后一層開始,向前移動直到達到第一個層。
在標准的反向傳播中,每個權重根據以下公式更新:

其中eta是“學習率”,通常是類似0.0005這樣的小數字,在訓練過程中會逐漸減少。但是,由於收斂速度慢,標准的反向傳播在程序中不需要使用。相反,LeCun博士在他的文章《Efficient BackProp》中提出的稱為“隨機對角列文伯格-馬誇爾特法(Levenberg-Marquardt)”的二階技術已得到應用,盡管Mike說它與標准的反向傳播並不相同,理論應該幫助像我這樣的新人更容易理解代碼。
在Levenberg-Marquardt方法中,rw
計算如下:

假設平方代價函數是:

那么梯度是:

而Hessian遵循如下規則:

Hessian矩陣的簡化近似為Jacobian矩陣,它是一個維數為N×O的半矩陣。

用於計算神經網絡中的Hessian矩陣對角線的反向傳播過程是眾所周知的。假設網絡中的每一層都有:

使用Gaus-Neuton近似(刪除包含|'(y))的項,我們得到:


以及:
隨機對角列文伯格-馬誇爾特(Levenberg-Marquardt)法
事實上,使用完整Hessian矩陣信息(Levenberg-Marquardt,Gaus-Newton等)的技術只能應用於以批處理模式訓練的非常小的網絡,而不能用於隨機模式。為了獲得Levenberg- Marquardt算法的隨機模式,LeCun博士提出了通過關於每個參數的二階導數的運算估計來計算Hessian對角線的思想。瞬時二階導數可以通過反向傳播獲得,如公式(7,8,9)所示。只要我們利用這些運算估計,可以用它們來計算每個參數各自的學習率:
其中e是全局學習速率,並且

是關於h ki的對角線二階導數的運算估計。m是防止h ki在二階導數較小的情況下(即優化在誤差函數的平坦部分移動時)的參數。可以在訓練集的一個子集(500隨機化模式/ 60000訓練集的模式)中計算二階導數。由於它們變化非常緩慢,所以只需要每隔幾個周期重新估計一次。在原來的程序中,對角線Hessian是每個周期都重新估算的。
這里是C#中的二階導數計算函數:
public void BackpropagateSecondDerivatives(DErrorsList d2Err_wrt_dXn /* in */, DErrorsList d2Err_wrt_dXnm1 /* out */) { // 命名(從NeuralNetwork類繼承) // 注意:盡管我們正在處理二階導數(而不是一階), // 但是我們使用幾乎相同的符號,就好像有一階導數 // 一樣,否則ASCII的顯示會令人誤解。 我們添加一 // 個“2”而不是兩個“2”,比如“d2Err_wrt_dXn”,以簡 // 單地強調我們使用二階導數 // // Err是整個神經網絡的輸出誤差 // Xn是第n層上的輸出向量 // Xnm1是前一層的輸出向量 // Wn是第n層權重的向量 // Yn是第n層的激活值, // 即,應用擠壓功能之前的輸入的加權和 // F是擠壓函數:Xn = F(Yn) // F'是擠壓函數的導數 // 簡單說,對於F = tanh,則F'(Yn)= 1-Xn ^ 2,即, // 可以從輸出中計算出導數,而不需要知道輸入 int ii, jj; uint kk; int nIndex; double output; double dTemp; var d2Err_wrt_dYn = new DErrorsList(m_Neurons.Count); // // std::vector< double > d2Err_wrt_dWn( m_Weights.size(), 0.0 ); // important to initialize to zero ////////////////////////////////////////////////// // ///// 設計 TRADEOFF: REVIEW !! // // 請注意,此命名的方案與NNLayer :: Backpropagate() // 函數中的推理相同,即從該函數派生的 // BackpropagateSecondDerivatives()函數 // // 我們希望對數組“d2Err_wrt_dWn”使用STL向量(為了便於編碼) // ,這是圖層中當前模式的錯誤權重的二階微分。 但是,對於 // 具有許多權重的層(例如完全連接的層),也有許多權重。 分 // 配大內存塊時,STL向量類的分配器非常愚蠢,並導致大量的頁 // 面錯誤,從而導致應用程序總體執行時間減慢。 // 為了解決這個問題,我嘗試使用一個普通的C數組, // 並從堆中取出所需的空間,並在函數結尾處刪除[]。 // 但是,這會導致相同數量的頁面錯誤錯誤,並 // 且不會提高性能。 // 所以我試着在棧上分配一個普通的C數組(即不是堆)。 // 當然,我不能寫double d2Err_wrt_dWn [m_Weights.size()]; // 因為編譯器堅持一個編譯時間為數組大小的已知恆定值。 // 為了避免這個需求,我使用_alloca函數來分配堆棧上的內存。 // 這樣做的缺點是堆棧使用過多,可能會出現堆棧溢出問題。 // 這就是為什么將它命名為“Review” double[] d2Err_wrt_dWn = new double[m_Weights.Count]; for (ii = 0; ii < m_Weights.Count; ++ii) { d2Err_wrt_dWn[ii] = 0.0; } // 計算 d2Err_wrt_dYn = ( F'(Yn) )^2 * // dErr_wrt_Xn (其中dErr_wrt_Xn實際上是二階導數) for (ii = 0; ii < m_Neurons.Count; ++ii) { output = m_Neurons[ii].output; dTemp = m_sigmoid.DSIGMOID(output); d2Err_wrt_dYn.Add(d2Err_wrt_dXn[ii] * dTemp * dTemp); } // 計算d2Err_wrt_Wn =(Xnm1)^ 2 * d2Err_wrt_Yn // (其中dE2rr_wrt_Yn實際上是二階導數) // 對於這個層中的每個神經元,通過先前層的連接 // 列表,並更新相應權重的差分 ii = 0; foreach (NNNeuron nit in m_Neurons) { foreach (NNConnection cit in nit.m_Connections) { try { kk = (uint)cit.NeuronIndex; if (kk == 0xffffffff) { output = 1.0; // 這是隱含的聯系; 隱含的神經元輸出“1” } else { output = m_pPrevLayer.m_Neurons[(int)kk].output; } // ASSERT( (*cit).WeightIndex < d2Err_wrt_dWn.size() ); // 因為在將d2Err_wrt_dWn更改為C風格的 // 數組之后,size()函數將不起作用 d2Err_wrt_dWn[cit.WeightIndex] = d2Err_wrt_dYn[ii] * output * output; } catch (Exception ex) { } } ii++; } // 計算d2Err_wrt_Xnm1 =(Wn)^ 2 * d2Err_wrt_dYn // (其中d2Err_wrt_dYn是不是第一個二階導數)。 // 需要d2Err_wrt_Xnm1作為d2Err_wrt_Xn的 // 二階導數反向傳播的輸入值 // 對於下一個(即先前的空間)層 // 對於這個層中的每個神經元 ii = 0; foreach (NNNeuron nit in m_Neurons) { foreach (NNConnection cit in nit.m_Connections) { try { kk = cit.NeuronIndex; if (kk != 0xffffffff) { // 我們排除了ULONG_MAX,它表示具有恆定輸出“1”的 // 虛偏置神經元,因為我們不能正真訓練偏置神經元 nIndex = (int)kk; dTemp = m_Weights[(int)cit.WeightIndex].value; d2Err_wrt_dXnm1[nIndex] += d2Err_wrt_dYn[ii] * dTemp * dTemp; } } catch (Exception ex) { return; } } ii++; // ii 跟蹤神經元迭代器 } double oldValue, newValue; // 最后,使用dErr_wrt_dW更新對角線的層 // 神經元的權重。通過設計,這個函數 // 以及它對許多(約500個模式)的迭代被 // 調用,而單個線程已經鎖定了神經網絡, // 所以另一個線程不可能改變Hessian的值。 // 不過,由於這很容易做到,所以我們使用一 // 個原子比較交換操作,這意味着另一個線程 // 可能在二階導數的反向傳播過程中,而且Hessians // 可能會稍微移動 for (jj = 0; jj < m_Weights.Count; ++jj) { oldValue = m_Weights[jj].diagHessian; newValue = oldValue + d2Err_wrt_dWn[jj]; m_Weights[jj].diagHessian = newValue; } } //////////////////////////////////////////////////////////////////
訓練和實驗
盡管MFC / C ++和C#之間存不兼容,但是我的程序與原程序相似。使用MNIST數據庫,網絡在60,000個訓練集模式中執行后有291次錯誤識別。這意味着錯誤率只有0.485%。然而,在10000個模式中,有136個錯誤識別,錯誤率為1.36%。結果並不像基礎測試那么好,但對我來說,用我自己的手寫字符集做實驗已經足夠了。首先將輸入的圖像從上到下分為字符組,然后在每組中把字符從左到右進行檢測,調整到29x29像素,然后由神經網絡系統識別。該方案滿足我的基本要求,我自己的手寫數字是可以被正確識別的。在AForge.Net的圖像處理庫中添加了檢測功能,以便使用。但是,因為它只是在我的業余時間編程,我相信它有很多的缺陷需要修復。反向傳播時間就是一個例子。每個周期使用大約3800秒的訓練時間,但是只需要2400秒。(我的電腦使用了英特爾奔騰雙核E6500處理器)。與Mike的程序相比,速度相當慢。我也希望能有一個更好的手寫字符數據庫,或者與其他人合作,繼續我的實驗,使用我的算法開發一個真正的應用程序。


參考文獻
- Neural Network for Recognition of Handwritten Digits by Mike O'Neill
- List of publications by Dr. Yann LeCun
- Section of Dr. LeCun's website on "Learning and Visual Perception"
- Modified NIST ("MNIST") database (11,594 KB total)
- Y. LeCun, L. Bottou, Y. Bengio, and P. Haffner, "Gradient-Based Learning Applied to Document Recognition", Proceedings of the IEEE, vol. 86, no. 11, pp. 2278-2324, Nov. 1998. [46 pages].
- Y. LeCun, L. Bottou, G. Orr, and K. Muller, "Efficient BackProp", in Neural Networks: Tricks of the trade, (G. Orr and Muller K., eds.), 1998. [44 pages]
- Patrice Y. Simard, Dave Steinkraus, John Platt, "Best Practices for Convolutional Neural Networks Applied to Visual Document Analysis," International Conference on Document Analysis and Recognition (ICDAR), IEEE Computer Society, Los Alamitos, pp. 958-962, 2003.
- Fabien Lauer, Ching Y. Suen and Gerard Bloch, "A Trainable Feature Extractor for Handwritten Digit Recognition", Elsevier Science, February 2006
- 在我的程序中使用的CodeProject.com上的一些附加項目: