从Image Caption Generation理解深度学习
作者:李理
from: http://geek.csdn.net/news/detail/97193
Part I 從Image Caption Generation理解深度學習(1)
0. 前面的話
建丁讓我寫一篇深度學習相關小文章,目標讀者是國內的開發者。剛接到這個任務時我是頗為忐忑的,寫文章要講究厚積薄發,如果“水之積也不厚”,“則其負大舟也無力”。因為我自知水平很有限,又不是在學校和科研機構做研究,只不過因為工作和個人的興趣,對深度學習有一點點粗淺的了解,所以擔心寫出來的東西不但于人無益,甚至還讓人誤入歧途。但后來又一想,如果把自己作為一個深度學習的學習者,和對它感興趣的普通開發者分享一些學習的經歷,包括學習過程中遇到的問題,可能也是有一些意義的。畢竟讀論文或者聽學術大牛的講座只能看到“成功”的經驗,而且大部分開發者相對來說沒有太多的背景知識,而很多圈內的人都是假設讀者擁有這些知識的。但是對于普通的開發者來說,很多基礎知識比如線性代數和微積分在上完大學后估計就還給老師了,因此可能理解起來難度更大。而從另外一個角度來說,工程師(開發者)和科學家(科研工作者)關注的點也是不一樣的。科學家更關注理論的東西,比如一個模型是怎么提出來的,為什么要這么設計模型,這樣的模型怎么轉化成一個優化問題。而工程師則更關注這個東西能夠做什么,具體這個優化問題怎么求解更高效。學術界每年有大量的論文發表,大量的idea被提出,其中有好有壞,有的工作可能看起來理論很漂亮,但實際應用起來很難;有些工作可能不被太多人關注,但卻是某些工業界非常需要的。
另外從人工智能的發展來說,我個人覺得在傳統行業的普及也是非常重要的。現在很多人工智能創業公司,很多想用人工智能創造一個全新的產品,比如早期類似Siri的語音助手到現在火熱的機器人。但我個人覺得目前的人工智能的水平還很難做出達到用戶預期的產品,尤其是很多初創公司吹牛吹得有些過分,導致用戶期望過高,而真正使用產品后則形成巨大的反差。我覺得目前階段人工智能更大的用處是提升現有系統,用我自己的話來說就是目前的人工智能只是錦上添花而不是雪中送碳。也就是說光靠人工智能是不能吸引用戶來購買你的產品的。
比如現在國外很火的Amazon的智能音箱產品Echo,如果我不想買一個音箱,估計你很難這樣說服我購買Echo——我們的Echo有非常智能的語音交互功能,可以問天氣,可以設置鬧鐘,可以Uber打車,可以控制家里的智能冰箱。但是如果我想購買一個音箱,現在面臨兩個選擇:一個是傳統的音箱,另一個是Echo。那么你對我說Echo有多么牛逼的智能可能會打動我,反正也差不了多少錢,能有這么多聽起來很酷炫的功能也挺不錯的。
由于Echo的成功,國內很多人也想“山寨”一個類似的產品,不過可能很多人忽略了美國和中國的一些細小差異,那就是音箱似乎不是大城市居民的必備品。就我個人的朋友圈來說,每個家庭肯定都有個電視,但是有音箱寥寥無幾。為什么會這樣呢,因為中國的大城市居民大都是住樓房,很多老破小隔音效果都很差,你整個音箱弄家里還沒high兩分鐘,估計鄰居就該敲門了。倒是耳機,屌絲們擠公交地鐵時的必備利器,也許會更好賣。
說了這么多,想表達的就是目前人工智能應該更多的提高現有產品。比如提到Google,大家可能會想到它收購的Deepmind的AlphaGo,但是我們可能沒有意識到日常使用的很多產品中都使用了深度學習。比如搜索引擎的排序,郵件的智能回復生成,都大量使用了深度學習。而AlphaGo的作用則更多的是一種市場PR,一種宣傳作用,讓大家知道人工智能目前的一些進展,而現在AlphaGo團隊則是想將其技術用到醫療行業幫助醫生診斷疾病。
也就是說人工智能在未來也許就像計算機,互聯網,云計算一樣是一個非常基礎的設施,在任何需要用機器來替代或者減少人力的場景都是有用武之地的。目前不論是國內還是國外,人工智能的人才都是非常稀缺的,而且都是集中在少數學校的實驗室和大公司的研究院里。因此向普通開發者傳播相關的知識就顯得尤為重要。基于這樣的考慮,雖然自己的能力不夠,但還是愿意把自己學習的一些經驗和問題向大家分享。
1. 為什么分享Image Caption Generation這個話題?
這篇小文章并沒有限定什么范圍,只要是深度學習相關的就行。這反倒讓人煩惱,就和人生一樣,選擇太多了也是一種煩惱。因為最近工作有空之余正在學習斯坦福的課程CS231N,Convolutional Neural Networks for Visual Recognition。這個課程非常好,除了詳盡的slides和notes,最值得一提的就是它的作業。每個作業包含完整的模型,比如CNN、LSTM,所有的模型的代碼都只是用最簡單的python代碼實現,而不是用現成的庫比如TensorFlow/Theano/Caffe。紙上得來終覺淺,絕知此事要躬行。很多理論,光聽課看slides,似乎覺得自己懂了,其實還是一知半解,真正要掌握,就得自己動手,最好是全部自己實現。但是全部自己實現需要花的時間太多,而且從實際工作的角度來說,大部分開發者肯定都是用TensorFlow這樣的工具。而這個課程的好處就是:把一些瑣碎的與核心代碼不相關的部分包括學習的框架都已經實現了,然后用IPython notebook把關鍵的代碼的函數的輸入和輸出都描述的非常清楚,學習者只需要實現一個一個這樣的函數就行了,而且每個函數都會有類似單元測試的檢測代碼正確性的數據,從而保證我們的每一步都是在朝著正確的方向前進。
因此這篇小文章打算講一講其中的Assignment3的Image Caption Generation部分。目的是想通過一個具體的任務來給大家介紹深度學習的一些知識,讓大家對深度學習有一些概念和興趣。選擇Image Caption Generation的原因,一來這個任務挺有意思的;第二就是它涉及到很多深度學習流行的模型如CNN,RNN/LSTM,Attention。
首先來介紹一下什么叫做Image Caption Generation。
對于計算機視覺相關的任務,圖片分類和定位大家可能比較熟悉。圖片分類就是給定一張圖片,讓計算機告訴我們它是一只貓還是一只狗;而圖片定位除了告訴我們這是一張狗的圖片,還需要用用一個矩形框把狗的位置標識出來。當然還有要求更高的Image Segmentation,需要告訴我們哪一些像素屬于狗,而另外一些屬于背景。
圖1就是這些任務的例子:
圖1:常見機器視覺任務 圖片來自 http://cs231n.stanford.edu/slides/winter1516_lecture8.pdf
而Image Caption Generation任務是給定一張圖片,需要讓計算機用一句話來描述這張圖片。
如圖2所示:
圖2:Caption Generation任務示例 圖片來自 http://mscoco.org/dataset/#captions-challenge2015
從實際的應用來說,這個任務也是很有用處的。比如一個手機拍完照片之后,我們可以用這個方法生成一句話來描述這個圖片,方便分享和以后查找。
而從理論研究的角度來說,Caption Generation相對于之前的task來說需要更加深入“理解”圖片中物體之間的關系,甚至包括一些抽象的概念。它把一幅信息量極大的圖片壓縮成短短一句話。
我是做自然語言處理(NLP)相關工作的,之前對計算機視覺有一些錯誤的看法。認為視覺信號是更底層和原始的信號,除了人類,動物也有很強的視覺能力,也能分辨不同物體。而語言是人類創造的符號系統,屬于更高層的抽象,因而屬于更高級的人工智能問題,似乎不少人會有類似的觀點。
但是現在我有了一些不同的看法,人類的大腦并沒有什么特殊之處。一個小孩在一歲之前一般不會說話,他認識世界的主要方式就是主要通過視覺系統來區分物體,也許和神經網絡類似,通過復雜的神經元的連接來“理解”世界。這些不同層次的網絡就是不同層次的特征,就像神經網絡的“黑盒”,我們自己也很難用精確的語言描述我們大腦到底學習到了什么樣的特征。而且很可能每個人學到的特征,尤其是底層的特征都是不相同的。
比如下圖的一個汽車,最底層的特征可能是不同方向的線條,而中間層的特征可能是各種基本的形狀,而更上層的特征就是車輪這樣的更上層概念。
圖片來自 http://cs231n.stanford.edu/slides/winter1516_lecture7.pdf
一個復雜的概念由一些簡單的概念組合而成,而簡單的概念可能由最基本的原子概念組合而成。語言就是對這些概念的描述,或者說就是一個標簽,一種命名。但是語言有一個特點就是它是用來溝通和交流的,所以語言的使用者需要達成一定程度的共識。那怎么達成共識呢,比如我們在教小孩語言時是怎么與他達成共識的呢?比如一個桌子,我們通過手指這一個條狗狗,反復對小孩說“狗狗”這個詞(其實是聲音,為了簡化,我們暫且當成文字),這樣我們就和小孩達成了共識,“狗狗”就是指這樣一個動物,然后又指著另外一條狗狗,也說“狗狗”,小孩就學到這一“類”物體都是狗狗。所以他需要調整他的神經元連接,使得那些符合某種特征的物體都被識別成狗狗。至于具體這個識別狗狗的神經網絡的參數是什么樣的,我們很難知道,也許剛開始他需要分類的物體很少,比如只有“爸爸”,“媽媽”和“狗狗”,那么它可能需要不是那么“本質”的特征來區分,比如他可能認為四條腿走的是“狗狗”,兩條腿直立行走的就是“爸爸”和“媽媽”。當隨著需要識別的類別的增多,比如有了“貓貓”,那他一上來可能認為也是“狗狗”,但父母告訴他分類錯誤,這不是“狗狗”而是“貓貓”。那么他可能需要別的特征來區分貓貓和狗狗,也許他學到的是:四條腿走并且嘴很長的是狗狗,而四條腿圓臉的是貓貓。
那為了能夠區分貓貓和狗狗,小孩的中層的特征可能需要抽取類似“臉”的特征,或者說概念。我們也會告訴他這是狗狗的臉,這是貓貓的臉,這是爸爸的臉。這樣他需要學習出臉的共性的特征。
從上面的過程我們可以發現,概念本身只是一種“特征”的指代,是我們的感覺系統(視覺)對一個物體的反應。而語言是一部分相似的生物對同一個/類物體達成共識的一種指代。但每個人的感覺系統和神經網絡結構都是不一樣的,所以也只能在非常粗糙的程度達成比較一致的共識,而在非常精細的概念層次是很難達成廣泛共識的。因此我們會把周圍的人打上各種標簽,分成各種類別,由此各種概念也就產生——膚色,語言,宗教,性別,階級。每個人也只能和同一個標簽的人在某個方面達成共識,所以要找到一個完全“了解”自己的人是如此之難,而不同的物種的共識可能就更難了。所以就像《莊子·齊物論》里說的“毛嬙、麗姬,人之所美也;魚見之深入,鳥見之高飛,麋鹿見之決驟。四者孰知天下之正色哉?自我觀之,仁義之端,是非之涂,樊然殽亂,吾惡能知其辯!”毛嬙、麗姬是我們人類眼中的美,但是在魚和雁看來只是可怕的敵人。可笑的是自戀的人類卻還要曲解莊子的愿意,認為它們是因為驚異于她們的美麗才沉魚落雁閉月羞花的。不說動物,即使是人類而言,美也是很難達成共識的,那些黑人國家的美女,我們中國人是很少會認為她們是美女的。
因此從這個意義上來說,語言也許并沒有我們想像中的那么高大上。 就目前人工智能或者深度學習的水平來說,也許研究小孩在建立復雜概念之前的行為更有用處。
Part II 從Image Caption Generation理解深度學習(2)
2. 機器學習基本概念和前饋神經網絡
2.1 機器學習基本概念
大家可能平時都寫過很多程序,寫程序和機器學習的思路可能有一些不同。寫程序時,我們是“上帝”,我們規定計算機的每一個步驟,第一步做什么第二步做什么,我們稱之為算法。我們能夠控制所有的情況,如果出了任何問題,肯定都是程序員的責任。而在機器學習的時候,我們只是“老師”。我們告訴學生(計算機)輸入是什么,輸出是什么,然后期望它能夠學到和我們類似的知識。比如我們跟小孩說這是狗,那是貓,我們沒有辦法像上帝那樣拿著“納米手術刀”去操作人腦神 經元的連接方式。我們只能不斷的給小孩“訓練數據”,然后期望他能夠學會什么是貓,即使我們覺得他“學會”了識別貓,我們也沒有辦法知道他是“怎么”學會 的,而且同樣的訓練過程可能換一個人就不好使。
機器學習和人類的學習是類似的——我們也是給它訓練數據,然后期望它能學會。我們會給機器建一個模型,從數學的角度來說一個模型就是一個函數,它的輸入一般是一個向量【當然可以是二維的矩陣如圖片或者三維的張量比如視頻】,輸出可以是有限的離散的標簽如“貓”,“狗”,這類問題我們稱之為分類;而如果輸出 是連續的值比如用這個模型來預測氣溫,那么我們就稱之為回歸。其實人類的很多科學活動和日常生活,都是在“學習”模型和“應用”模型。比如開普勒通過觀測 大量天文數據“歸納”出行星的運動規律。從本質上講,智能就是從“過去”學習,然后根據“現在”來預測可能的將來并根據自己的目標選擇有利于自己行為。只不過之前,似乎只有人類能夠從數據中“學習”出規律,而人工智能的目標就是讓機器也有類似的學習能力。
模型用數學來說就是一個函數,我們人腦的函數由神經元的連接構成,它可能是一個很復雜的函數,我們現在還很難徹底研究清楚。神經網絡就是試圖通過計算機來 模擬和借鑒人腦這個模型,除了我們這里要講的神經網絡之外,機器學習領域還有各種各樣的模型,它們各有特點。但不管形式怎么變化,本質都是一個函數。一個(或者更準確的是一種)模型一般都是一種函數形式,它有一些“參數”可以改變。而學習的過程就是不斷調整這些參數,使得輸出(盡量)接近“正確”的答案。 但是一般情況下很難所有的數據我們都能預測正確,所以一般我們會定義一個loss function,可以理解為“錯誤”的程度,錯的越“離譜”,loss就越大。而我們的目標就是調整參數使得loss最小。
但是我們是在“訓練”數據上調整的參數,那么它能在“測試”數據上也表現的好嗎?這個就是模型的“泛化”能力了。就和人在學校學習一樣,有的同學做過的一 模一樣的題就會,但是考試時稍微改變一下就不會了,這就是“泛化”能力太差,學到的不是最本質的東西。所以平時會定期有一些“模擬考試”,來檢驗學生是不 是真的學會了,如果考得不好,那就打回去重新訓練模型調整參數。這在機器學習里對應的就是validation的階段。最后到最終的考試了,就是最終檢驗 的時候了,這個試卷里的題目是不能提前讓人看到的,只能拿出來用一次,否則就是作弊了。對應到機器學習里就是test階段。
當然這里用通俗的話描述了機器學習,主要是有監督的學習。其實機器學習還有無監督的學習和強化學習。前者就是不給答案,只給數據,讓人總結規律;而后者會有答案,但是答案不是現在就告訴你。我個人覺得人類社會里更多的是監督學習和強化學習。從人類社會總體來說,強化學習是獲取新知識的唯一途徑,也就是向自 然學習,我們做了一個決策,其好壞可能要很長一段時間才能顯現出來。而學習出來的這些知識通過監督的方式,通過家庭和學校的教育教給下一代。
另外輸出除了簡單的分為離散和連續,還可以是序列(時序)的,比如自然語言(文本)是一個字符串的序列 ,對于我們的Image Caption Generation就是生成一個單詞序列。另外還有更復雜的輸出,比如parsing,輸出是一棵語法樹。
2.2 多層神經網絡
前面介紹了機器學習的基本概念,接下來我們就來學習一下神經網絡。現在流行的說法“深度學習”,其實大多指的就是“深度神經網絡”,那么首先我們先了解一下“淺度神經網絡”,也就是傳統的神經網絡。這里的內容主要來自http://neuralnetworksanddeeplearning.com的前兩章。
2.2.1 手寫數字識別問題
我們在學習一門新的語言時會寫一個hello world程序,而mnist數據的手寫數字識別就是一個很好的學習機器學習(包括深度學習)的一個hello world任務。
計算機和人類大腦似乎有很大的不同,很多人類認為復雜的工作計算機可能認為很簡單,而人類認為很簡單的事情計算機可能非常難處理。比如數字的計算,記憶,人類的準確度和速度都遠遠不如計算機。但是識別0-9的手寫數字,我們覺得很輕而易舉的事情,讓計算機程序來處理卻異常困難。經過數百萬年進化的人類視覺系統在我們大腦沒有意識到的時候就已經幫我們完成了數字的識別,把那些復雜的視覺處理過程深深的掩藏了起來。但當我們想自己寫一個程序來識別數字的時候,這些困難才能體現出來。首先,對于計算機來說,它“看到”的不是數字,甚至不是筆畫。它“看到”的只是一個二位的矩陣(數組),每個點都是一個數字。比如下圖,我們“看到”的是左邊的“貓”,其實計算機“看到”的是右邊的像素灰度值。當然我們視覺系統的視網膜看到的也是類似的一些“數值”,只不過我們的視覺系統已經處理了這些信息并且把它識別成了“貓”(甚至和語言還做了映射)。
MNIST數據介紹:MNIST的每個圖片經過縮放和居中等預處理之后,大小是28*28,每個點都是0-255的灰度值,下圖是一些樣例。總共有60,000個訓練數據(0-9共10個類別,每個類別6,000個)和10,000個測試數據。一般會拿60000個中的50000個來做訓練集,而剩下的10000個用來做驗證集(用來選擇一些超參數)。
mnist樣例數據如果我們自己來寫一個“算法”識別數字“9”,我們可能會這么定義:9在上面有個圓圈,在這個圓圈的右下部分有一個豎直的筆畫。說起來很簡單,如果用算法 來實現就很麻煩了:什么是圓圈?每個人畫的圓圈都不同,同樣豎直的筆畫怎么識別,圓圈和豎直筆畫連接處怎么尋找,右下是哪?大家如果有興趣可以嘗試一下用 上面的方法,其實最早做數字識別就是這樣的思路。
機器學習的思路則不同,它不需要這么細節的“指示”計算機應該怎么做。而是給計算機足夠的“訓練”樣本,讓它“看”不同的10個數字,然后讓它“學”出 來。前面我們也講了,現在的機器學習一般是一個參數化的模型。比如最簡單的一個線性模型:f(w;x)=w0+ w1*x1+w2*x2。如果我們的輸入有兩個“特征”x1和x2,那么這個模型有3個參數w0,w1和w2,機器學習的過程就是選擇“最優”的參數。對 于上面的mnist數據,輸入就是28*28=784維的向量。
如果用“原始”的輸入作為“特征”,線性的模型很可能學到一些簡單的特征,比如它看到1一般是分布在從上到下居中的一些位置,那么對于這些位置一旦發現有比較大的灰度值,那么就傾向于判斷成1。如果一個像素點2也經常出現,但3不出現,那么它就能學到如果這個像素出現,那么這個數字是2和3的可能性就大一些。
但是這樣的“特征”可能不是“本質”的,因為我寫字的時候筆稍微平移一點,那么你之前“學到”的參數就可能有問題。而更“本質”的特征是什么呢?可能還是像之前我們總結的——9在上面有個圓圈,在這個圓圈的右下部分有一個豎直的筆畫。我們把識別一個數字的問題轉化成圓圈和豎直筆畫的問題。傳統的機器學習需要方法來提取“類似”(但不完全是)基本筆畫這樣的“特征”,這些特征相對于像素的特征會更加“本質”。但是要“提取”這些特征需要很多的“領域”知識,比如圖像處理的技術。所以使用傳統的機器學習方法來解決問題,我們不但需要很多機器學習的知識,而且也需要很多“領域”的知識,同時擁有這兩方面的知識是比較難的。
而“深度學習”最近之所以火熱,其中很重要的一個原因就是對于很多問題,我們只需要輸入最原始的信號,比如圖片的像素值,通過“多層”的網絡,讓底層的網絡學習出“底層”的特征,比如基本的形狀,而中間的層學習出抽象一點的特征,比如眼睛鼻子耳朵。而更上的層次識別出這是一個貓還是一個狗。所有這些都是機器學習出來的,所以基本不需要領域的知識。
上面的圖就說明了這一點,而且我們發現越是底層的特征就越“通用”,不管是貓鼻子還是狗眼睛,可能用到的都是一些基本的形狀,因此我們可以把這些知識(特征)transfer到別的任務,也就是transfer learning,后面我們講到CNN的時候還會提及。
2.2.2 單個神經元和多層神經網絡(MLP)
神經網絡從名字來看是和人類的大腦有些關系的,而且即使到現在,很多有用的東西如CNN和Attention,都有很多借鑒神經科學研究人腦的結果的。不過這里我就不介紹這些東西了,有興趣的讀者可以找一些資料來了解。
一個神經元如下圖的結構:
它的輸入是一個向量,(x1,x2,x3),輸出是一個標量,一個實數。z=w0+ w1*x1 + w2*x2 + w3*x3。z是輸入的加權累加,權值是w1,w2,w3,w0是bias,輸出 output = f(z)。函數f一般叫做激活函數。最早流行的激活函數是Sigmoid函數,當然現在更流行Relu和它的改進版本。Sigmoid函數的公式和圖形如下:
當z=0時,sigmoid(z)=0.5 z趨于無窮大時,sigmoid(z)趨近于1,z趨于負無窮,值趨于0。為什么選擇這樣的激活函數呢?因為是模擬人腦的神經元。人腦的神經元也是把輸入的信號做加權累加,然后看累加和是否超過一個“閾值”。如果超過,繼續向下一個神經元發送信號,否則就不發送。因此人腦的神經元更像是一個階躍函數:
最早的感知機(Perception)其實用的就是這個激活函數。但是它有一個缺點就是0之外的所有點的導數都是0,在0點的導數是無窮大,所以很難用梯度的方法優化。而Sigmoid函數是處處可導。下面我手工推導了一下,如果大家不熟悉可以試著推導一下Sigmoid函數的導數,我們后面也會用到。
我們把許多的單個神經元按照層次組織起來就是多層的神經網絡。
比如我們的手寫數字識別,輸入層是784維,就是神經網絡的地一層,然后中間有15個hidden(因為我們不知道它的值)神經元,然后輸出層是10個神經元。中間隱層的每個神經元的輸入都是784個原始像素通過上面的公式加權累加然后用sigmoid激活。而輸出層的每一個神經元也是中間15個神經元的累加然后激活。上面的圖就是一個3層的神經網絡。
輸入一個28*28的圖像,我們得到一個10維的輸出,那么怎么分類呢?最直接的想法就是把認為最大的那個輸出,比如輸出是(10,11,12,13,14,15,16,17,18,19),那么我們認為輸出是9。
當然,更常見的做法是最后一次經過線性累加之后并不用Sigmoid函數激活,而是加一個softmax的函數,讓10個輸出加起來等于1,這樣更像一個 概率。而我們上面的情況,雖然訓練數據的輸出加起來是1,但是實際給一個其它輸入,輸出加起來很可能不是1。不過為了與Nielsen的文章一致,我們還 是先用這種方法。
因此,假設我們有了這些參數【總共是784*15 + 15(w0或者叫bias) + 15*10 + 10】,我們很容易通過上面的公式一個一個的計算出10維的輸出。然后選擇最大的那個作為我們識別的結果。問題的難點就在怎么 選擇這么多參數,然后使得我們分類的錯誤最少。
而我們怎么訓練呢?對于一張圖片,假設它是數字“1”,那么我們期望它的輸出是(0,1,0,0,0,0,0,0,0,0),所以我們可以簡單的用最小平方錯誤作為損失函數。不過你可能會有些疑問,我們關注的指標應該是分類的“正確率”(或者錯誤率),那么我們為什么不直接把分類的錯誤率作為損失函數呢?這樣神經網絡學習出來的參數就是最小化錯誤率。
主要的原因就是錯誤率不是參數的連續函數。因為一個訓練數據如果分類正確那么就是1,否則就是0,這樣就不是一個連續的函數。比如最簡單的兩類線性分類器,f(x)=w0+w1*x1+w2*x2。如果f(x)>0我們分類成類別1;否則我們分類成類別2。如果當前的w0+w1*x1+w2*x2<0,我們很小的調整w0(或者w1,w2),w0+w1*x1+w2*x2仍然小于0,【事實上對于這個例子,只要是w0變小,他們的累加都是小于0的】所以f(x)的值不會變化,而w0一直增大到使累加和等于0之前都不會變化,只有大于0時突然變成1了,然后一直就是1。因此之前的錯誤率都是1,然后就突然是0。所以它不是個連續的函數。
因為我們使用的優化算法一般是(隨機)梯度下降的算法,在每次迭代的時候都是試圖做一個微小的參數調整使得損失變小,但是不連續的函數顯然也不可導,也就沒法用這個算法來優化參數。
因此我們使用了最小平方誤差(MSE)損失函數。
y(x)就是神經網絡的輸出,可能寫成f(x)大家會習慣一點。a是目標的輸出,比如當前分類是數字1,那么我們期望的輸出就是(0,1,0,0,0,0,0,0,0,0)。
首先這個損失函數是參數w的連續函數,因為y(x)就是神經網絡的輸出,每個神經元都是它的輸入的線性加權累加,然后使用sigmoid激活函數【如果使用最早的階躍函數就不連續了,所以后來使用了Sigmoid函數】,然后每一層的神經元都是用上一層的神經元通過這樣的方式計算的(只不過每個神經元的參數也就是權重是不同的數值而已),所以這些連續函數的復合函數也是連續的。
其次這個損失函數和我們的最終優化目標是“大致”一致的。比如C(w,b)趨于0時,它就要求y(x)趨于a,那么我們的分類也就趨于正確。當然可能存在一種極端的情況,比如有3個訓練數據,第一組參數,它分類正確了2個訓練數據,但是錯的那1個錯的很“離譜”,也就是y(x)和a差距極大;而第二組參數,他正確分類了1個訓練數據,但是錯的那兩個都還不算太差。那么這種情況下MSE和正確率并不一致。
2.2.3 隨機梯度下降(Stochastic Gradient Descent)和自動求梯度(Automatic Derivatives)
上面說了,我們有了一個參數化的模型,訓練的過程就是根據訓練數據和loss function,選擇“最優”的參數,使得loss“最小”,這從數學上來講就是一個優化問題。這看起來似乎不是什么值得一提的問題,也許你還記得微積 分里的知識,極值點的各種充分必要條件,比如必要條件是導數是0,然后直接把參數解出來。但在現實生活中的函數遠比教科書里學到的復雜,很多模型都無法用 解析的方式求出最優解。所以現實的方法就是求“數值”解,一般最常見的方法就是迭代的方法,根據現在的參數,我們很小幅度的調整參數,使得loss變小一 點點。然后一步一步的最終能夠達到一個最優解(一般是局部最優解)。那怎么小幅調整呢?像悶頭蒼蠅那樣隨機亂試顯然效率極低。因此我們要朝著一個能使函數 值變小的方向前進。而在一個點能使函數值變小的方向有無窮多個,但有一個方向是下降速度最快的,那就是梯度。因此更常見的方法就是在當前點求函數的梯度, 然后朝著梯度的方向下降。朝梯度的方向走多遠呢?一般走一個比較小的值是比較安全的,這個值就是“步長”。一般剛開始隨機的初始化參數,loss比較大, 所以多走一些也沒關系,但是到了后面,就不能走太快,否則很容易錯過最優的點。
因為loss是所有訓練數據的函數,所以求loss的梯度需要計算所有的訓練數據,對于很多task來說,訓練數據可能上百萬,計算一次代價太大,所以一 般會“隨機”的采樣少部分數據,比如128個數據,求它的梯度。雖然128個點的梯度和一百萬個的是不一樣的,但是從概率來講至少是一致的方向而不會是相 反的方向,所以也能使loss變小。當然這個128是可以調整的,它一般被叫做batch size,最極端的就是batch是1和一百萬,那么分別就是online learning和退化到梯度下降。batch size越大,計算一次梯度的時間就越久【當然由于GPU和各種類似SSE的指令,一次計算128個可能并不比計算1個慢多少】,隨機梯度和真正梯度一致 的概率就越大,走的方向就更“正確”;batch size越小,計算一次的時間就越短,但可能方向偏離最優的方向就更遠,會在不是“冤枉路”。但實際的情況也很難說哪個值是最優的,一般的經驗取值都是幾 十到一兩百的范圍,另外因為計算機都是字節對齊,32,64,128這樣的值也許能稍微加快矩陣運算的速度。但是實際也很多人選擇10,50,100這樣 的值。
除了常見的隨機梯度下降,還有不少改進的方法,如Momentum,Adagrad等等,有興趣的可以看看http://cs231n.github.io/neural-networks-3/#update ,里面還有個動畫,比較了不同方法的收斂速度的比較。
通過上面的分析,我們把問題變成了怎么求loss對參數W的梯度。
求梯度有如下4種方法:
手工求解析解
比如 f(x)=x^2, df/dx=2*x。然后我們要求f(x)在x=1.5的值,代進去就2*1.5=3
數值解
使用極限的定義:
機器符號計算
讓機器做符號運算,實現1的方法,但是機器如果優化的不好的話可能會有一些不必要的運算。
比如 x^2 + 2*x*y + y^2,直接對x求導數變成了 2*x + 2*y,兩次乘法一次加分,但是我們可以合并一下變成2*(x+y),一次乘法一次加分。
自動梯度
下面我會在稍微細講一下,所以這里暫時跳過。
這些方法的優缺點:
手工求解“數學”要求高,有可能水平不夠求不對,但效率應該是能最優的。
沒任何函數,甚至沒有解析導數的情況下都能使用,缺點是計算量太大,而且只是近似解【因為極限的定義】,在某些特別不“連續”的地方可能誤差較大。所以實際使用是很少,只是用它來驗證其它方法是否正確。
機器符號計算,前面說的,依賴于這個庫的好壞。
實際的框架,如TensorFlow就是自動梯度,而Theano就是符號梯度。
2.2.4 編程實戰
通過上面的介紹,我們其實就可以實現一個經典的前饋(feed forward)神經網絡了,這種網絡結構很簡單,每一層的輸入是前一層的輸出。輸入層沒有輸入,它就是原始的信號輸入。而且上一層的所有神經元都會連接到下一層的所有神經元,就像我們剛才的例子,輸入是784,中間層是15,那么就有785*15個連接【再加上每個中間節點有一個bias】。所以這種網絡有時候也加做全連接的網絡(full connected),用來和CNN這種不是全連接的網絡有所區別,另外就是信號是從前往后傳遞,沒有反饋,所以也叫前潰神經網絡,這是為了和RNN這種有反饋的區別。
當然,我們還沒有講怎么計算梯度,也就是損失函數相對于每一個參數的偏導數。在下一部分我們會詳細討論介紹,這里我們先把它當成一個黑盒的函數就好了。
代碼
我們這里學習一下Nielsen提供的代碼。代碼非常簡潔,只有不到100行代碼。
https://github.com/mnielsen/neural-networks-and-deep-learning
git clone https://github.com/mnielsen/neural-networks-and-deep-learning.git
運行
創建一個 test_network1.py,輸入如下代碼:
保存后直接運行 Python test_network1.py。這里我們讓他進行了30次迭代,最終在測試數據上的準確率大概在95%左右(當然因為隨機初始化參數不同,最終的結果可能有所不同)
Epoch 0: 8250 / 10000 Epoch 1: 8371 / 10000 Epoch 2: 9300 / 10000 ...... Epoch 28: 9552 / 10000 Epoch 29: 9555 / 100003. 代碼閱讀
Python代碼很容易閱讀,即使之前沒有用過,稍微學習兩天也就可以上手,而且大部分機器學習相關的代碼不會用到太復雜的語言特性,基本就是一些數學的線性代數的運算。而Python的numpy這個庫是用的最多的,后面閱讀代碼的時候我會把用到的函數做一些介紹,繼續下面的閱讀之前建議花十分鐘閱讀一下http://cs231n.github.io/python-numpy-tutorial/。
3.1 mnist_loader.load_data_wrapper函數
這個函數用來讀取mnist數據,數據是放在data/mnist.pkl.gz。首先這是個gzip的壓縮文件,是Pickle工具序列化到磁盤的格式。不熟悉也沒有關系,反正我們知道這個函數的返回值就行了。
這個函數返回三個對象,分別代表training_data,validation_data和test_data。
training_data是一個50,000的list,然后其中的每一個元素是一個tuple。tuple的第一個元素是一個784維的numpy一維數組。第二個元素是10維的數組,也就是one-hot的表示方法——如果正確的答案是數字0,那么這個10維數組就是(1, 0, 0, …)。
而validation_data是一個10,000的list,每個元素也是一個tuple。tuple的第一個元素也是784維的numpy一維數組。第二個元素是一個0-9的數字,代表正確答案是那個數字。
test_data的格式和validation_data一樣。
為什么training_data要是這樣的格式呢?因為這樣的格式計算loss更方便一些。
3.2 Network類的構造函數
我們在調用net = network.Network([784, 30, 10])時就到了init函數。為了減少篇幅,代碼里的注釋我都去掉了,重要的地方我會根據自己的理解說明,但是有空還是值得閱讀代碼里的注釋。
class Network(object):def __init__(self, sizes):self.num_layers = len(sizes)self.sizes = sizesself.biases = [np.random.randn(y, 1) for y in sizes[1:]]self.weights = [np.random.randn(y, x)for x, y in zip(sizes[:-1], sizes[1:])]比如上面的參數,我們保存下來的self.num_layers=3,也就是3層的網絡。每一層的神經元的個數保存到self.sizes里。接下來就是構造biases數組并隨機初始化。因為輸入層是沒有參數的,所以是for y in sizes[1:],我們使用了numpy的random.randn生成正態分布的隨機數用來作為參數的初始值。注意這里生成了2維的隨機變量。回憶一下,如果我們有30個hidden unit,那么bias的個數也是30,那就生成一個30維的1維數組就行了,為什么要是30*1的二維數組呢?其實用1維也可以,不過為了和weights一致,后面代碼方便,就用二維數組了。另外weights也是一樣的初始化方法,不過注意randn(y,x)而不是randn(x,y)。比如對于我們輸入的[784,30,10],weights分別是30*784和10*30的。當然其實weights矩陣轉置一下也可以,就是計算矩陣乘法的時候也需要有一個轉置。不同的文獻可能有不同的記法,但是我們在實現代碼的時候只需要隨時注意矩陣的大小,檢查矩陣乘法滿足乘法的約束就行了,矩陣AB能相乘,必須滿足的條件是B的列數等于A的函數就行。
對于Nielsen的記法,矩陣的每一行就是一個神經元的784個參數,那么weights(30*784) * input(784*1)就得到30個hidden unit的加權累加。
3.3 feedforward函數
給點輸入a(784維),計算最終神經網絡的輸出(10維)。
def feedforward(self, a):"""Return the output of the network if ``a`` is input."""for b, w in zip(self.biases, self.weights):a = sigmoid(np.dot(w, a)+b)return a代碼非常簡單,這里用到了np.dot,也就是矩陣向量的乘法,此外這里有一個Sigmoid函數,這個函數的輸入是numpy的ndarray,輸出也是同樣大小的數組,不過對于每個元素都進行了sigmoid的計算。用numpy的術語就是universal function,很多文獻里一般都叫elementwise的function。我覺得后面這個名字更直接。
#### Miscellaneous functionsdef sigmoid(z):"""The sigmoid function."""return 1.0/(1.0+np.exp(-z))def sigmoid_prime(z):"""Derivative of the sigmoid function."""return sigmoid(z)*(1-sigmoid(z))上面就是Sigmoid函數,另外也把sigmoid_prime,也就是Sigmoid的導數放在了一起【不記得的話看前面Sigmoid的導數的推導】。
3.4 SGD函數
這個函數是訓練的入口,比如我們之前的訓練代碼:
net.SGD(training_data, 30, 10, 3.0, test_data=test_data)def SGD(self, training_data, epochs, mini_batch_size, eta,test_data=None): if test_data: n_test = len(test_data)n = len(training_data)for j in xrange(epochs):random.shuffle(training_data)mini_batches = [training_data[k:k+mini_batch_size]for k in xrange(0, n, mini_batch_size)]for mini_batch in mini_batches:self.update_mini_batch(mini_batch, eta)if test_data:print "Epoch {0}: {1} / {2}".format(j, self.evaluate(test_data), n_test)else:print "Epoch {0} complete".format(j)第一個參數就是training_data。
第二個參數就是epochs,也就是總共對訓練數據迭代多少次,我們這里是30次迭代。
第三個參數是batch大小,我們這里是10,最后一個參數是eta,也就是步長,這里是3.0。除了網絡結構(比如總共多少個hidden layer,每個hidder layer多少個hidden unit),另外一個非常重要的參數就是步長。前面我們也討論過了,步長太小,收斂速度過慢,步長太大,可能不收斂。實際的情況是沒有一個萬能的準則,更多的是根據數據,不停的嘗試合適的步長。如果發現收斂太慢,就適當調大,反之則調小。所以要訓練好一個神經網絡,還是有很多tricky的技巧,包括參數怎么初始化,激活函數怎么選擇,比SGD更好的優化算法等等。
第四個參數test_data是可選的,如果有(我們的例子是穿了進來的),則每次epoch之后都測試一下。
代碼的大致解釋我用注釋的形式嵌在代碼里了:
for j in xrange(epochs): ## 一共進行 epochs=30 輪迭代random.shuffle(training_data) ## 訓練數據隨機打散mini_batches = [training_data[k:k+mini_batch_size]for k in xrange(0, n, mini_batch_size)] ## 把50,000個訓練數據分成5,000個batch,每個batch包含10個訓練數據。for mini_batch in mini_batches: ## 對于每個batchself.update_mini_batch(mini_batch, eta) ## 使用梯度下降更新參數if test_data: ## 如果提供了測試數據print "Epoch {0}: {1} / {2}".format(j, self.evaluate(test_data), n_test) ## 評價在測試數據上的準確率else:print "Epoch {0} complete".format(j)下面是evaluate函數:
def evaluate(self, test_data):test_results = [(np.argmax(self.feedforward(x)), y)for (x, y) in test_data]return sum(int(x == y) for (x, y) in test_results)對于test_data里的每一組(x,y),y是0-9之間的正確答案。而self.feedforward(x)返回的是10維的數組,我們選擇得分最高的那個值作為模型的預測結果np.argmax就是返回最大值的下標。比如x=[0.3, 0.6, 0.1, 0, ….],那么argmax(x) = 1。
因此test_results這個列表的每一個元素是一個tuple,tuple的第一個是模型預測的數字,而第二個是正確答案。
所以最后一行返回的是模型預測正確的個數。
3.5 update_mini_batch函數
def update_mini_batch(self, mini_batch, eta): nabla_b = [np.zeros(b.shape) for b in self.biases]nabla_w = [np.zeros(w.shape) for w in self.weights]for x, y in mini_batch:delta_nabla_b, delta_nabla_w = self.backprop(x, y)nabla_b = [nb+dnb for nb, dnb in zip(nabla_b, delta_nabla_b)]nabla_w = [nw+dnw for nw, dnw in zip(nabla_w, delta_nabla_w)]self.weights = [w-(eta/len(mini_batch))*nwfor w, nw in zip(self.weights, nabla_w)]self.biases = [b-(eta/len(mini_batch))*nbfor b, nb in zip(self.biases, nabla_b)]它的輸入參數是mini_batch【size=10的tuple(x,y)】和eta【3.0】。
def update_mini_batch(self, mini_batch, eta): nabla_b = [np.zeros(b.shape) for b in self.biases]## 回憶一下__init__,biases是一個列表,包含兩個矩陣,分別是30*1和10*1## 我們先構造一個和self.biases一樣大小的列表,用來存放累加的梯度(偏導數) nabla_w = [np.zeros(w.shape) for w in self.weights]## 同上, weights包含兩個矩陣,大小分別是30*784和10*30 for x, y in mini_batch:delta_nabla_b, delta_nabla_w = self.backprop(x, y)## 對于一個訓練數據(x,y)計算loss相對于所有參數的偏導數## 因此delta_nabla_b和self.biases, nabla_b是一樣大小(shape)## 同樣delta_nabla_w和self.weights,nabla_w一樣大小nabla_b = [nb+dnb for nb, dnb in zip(nabla_b, delta_nabla_b)]## 把bias的梯度累加到nabla_b里nabla_w = [nw+dnw for nw, dnw in zip(nabla_w, delta_nabla_w)]## 把weight的梯度累加到nable_w里self.weights = [w-(eta/len(mini_batch))*nwfor w, nw in zip(self.weights, nabla_w)]## 使用這個batch的梯度和eta(步長)更新參數weightsself.biases = [b-(eta/len(mini_batch))*nbfor b, nb in zip(self.biases, nabla_b)]## 更新biases## 這里更新參數是除了batch的大小(10),有的人實現時不除,其實沒有什么區別,因為超參數eta會有所不同,如果不除,那么eta相當于是0.3(在eta那里就除了batch的大小了)。3.6 backprop函數
這個函數就是求loss相對于所有參數的偏導數,這里先不仔細講解,等下次我們學習梯度的求解方法我們再回來討論,這里可以先了解一下這個函數的輸入和輸出,把它當成一個黑盒就行,其實它的代碼也很少,但是如果不知道梯度的公式,也很難明白。
def backprop(self, x, y): nabla_b = [np.zeros(b.shape) for b in self.biases]nabla_w = [np.zeros(w.shape) for w in self.weights]# feedforwardactivation = xactivations = [x] # list to store all the activations, layer by layerzs = [] # list to store all the z vectors, layer by layerfor b, w in zip(self.biases, self.weights):z = np.dot(w, activation)+bzs.append(z)activation = sigmoid(z)activations.append(activation)# backward passdelta = self.cost_derivative(activations[-1], y) * \sigmoid_prime(zs[-1])nabla_b[-1] = deltanabla_w[-1] = np.dot(delta, activations[-2].transpose()) for l in xrange(2, self.num_layers):z = zs[-l]sp = sigmoid_prime(z)delta = np.dot(self.weights[-l+1].transpose(), delta) * spnabla_b[-l] = deltanabla_w[-l] = np.dot(delta, activations[-l-1].transpose())return (nabla_b, nabla_w)它的輸入就是一個訓練樣本(x,y)分別是784*1和10*1。輸出就是和self.biases,self.weights一樣大小的列表,然后列表中的每一個數組的大小也是一樣。具體到上面的例子,輸出nabla_b包含兩個矩陣,大小分別是30*1和10*1;nabla_w也包含兩個矩陣,大小分別是30*784和10*30。
Part III 從Image Caption Generation理解深度學習 (3)
2.2.5 反向傳播算法的推導
前面我們用很簡單的幾十行python代碼基本上完成了一個多層神經網絡。但是還差最重要的部分,那就是計算loss function對參數的偏導數,也就是反向傳播算法。下面我們來仔細的完成公式的推導,以及接下來會講怎么用代碼來實現。這一部分數學公式多一些,可能很多讀者會希望跳過去,不過我還是建議大家仔細的閱讀,其實神經網絡用到的數學相比svm,bayes network等機器學習算法,已經非常簡單了。請讀者閱讀的時候最好準備一支筆和幾張白紙,每一個公式都能推導一下。如果堅持下來,你會覺得其實挺簡單的。
(1) feedforward階段的矩陣參數表示和計算
之前我們討論的是一個神經元的計算,而在代碼里用到的卻是矩陣向量乘法。而且細心的讀者會發現我們在構造參數矩陣weights的時候,行數和列數分別是后一層的節點數和前一層的節點數。這似乎有點不自然,為什么不反過來呢?看過下面這一部分就會明白了。
首先我們熟悉一下第L(因為小寫的L和1太像,所以我用大寫的L)層的參數w_jk。它表示第L-1層的第k個神經元到第L層的第j個神經元的權重。比如第3層的w_24,參考上面的圖,它表示的是第2層的第4個神經元到第3層的第二個神經元。
對bias和激活函數后的結果a也采用類似的記號,如下圖所示。
b_32表示第2層的第3個神經元的bias,而a_13第3層的第1個神經元的激活。
使用上面的記號,我們就可以計算第L層的第j個神經元的輸出a_jl:
第L層的第j個神經元的輸入是L-1層的a_1,a_2,...;對應的權值是w_j1,w_j2,...;bias是b_jL。所以a_jL就是上面的公式,k的范圍是從1到第L-1層的神經元的個數。
為了用矩陣向量乘法來一次計算第L層的所有神經元的輸出,我們需要定義第L層的參數矩陣w_l,它的大小是m*n,其中m是第L層的神經元個數;而n則是第L-1層的個數。它的第i行第j列就是我們上面定義的w_jk。此外我們還要定義向量b_l,它的大小是m(也就是第L層神經元的個數),它的第j個元素就是我們上面定義的b_j。
最后,我們定義element-wise的函數,比如f(x) = x^2,如果輸入是一個向量,那么結果是和輸入一樣大小的向量,它的每個元素是對輸入向量的每一個元素應用這個函數的結果。
有了上面的定義,我們就可以一次計算出第L層的輸出(一個長度為m的向量)
下面是對上面這個公式的詳細證明(說明):
我們需要證明的是向量aL的第j個元素就是前面的a_jL
此外,為了方便后面的求解,我們把加權累加和也用一個符號z_l來表示。
其中,它的第j個元素就是第L層的第j個神經元的加權累加和:
這樣a_l就可以簡單的對z_l的每個元素計算激活函數
現在我們再回顧一下feedforward的代碼就非常直觀了:
def feedforward(self, a): """Return the output of the network if a is input.""" for b, w in zip(self.biases, self.weights): a = sigmoid(np.dot(w, a)+b) return a傳給函數feedforward的參數a就是輸入向量x,第一層就是x,第二層就是第一個隱層,每一層的計算就是非常簡單的參數矩陣w_l乘以上一層的激活a_l-1在加上b_l,然后用激活函數計算。
初始化的時候w的大小是 (后一層的神經元個數) * (前一層的神經元個數),再回顧一下初始化參數的代碼:
# sizes = [784, 30, 10] def __init__(self, sizes): self.num_layers = len(sizes) self.sizes = sizes self.biases = [np.random.randn(y, 1) for y in sizes[1:]] self.weights = [np.random.randn(y, x)for x, y in zip(sizes[:-1], sizes[1:])]x, y in zip(sizes[:-1], sizes[1:]) x是第一層到最后倒數第二層,y是第二層到最后一層,比如上面的sizes=[784, 30, 10]
x是[784, 30], y是[30, 10],注意隨機的矩陣是(y,x),所以self.weights是兩個矩陣,大小分別是30*784和10*30
(2) 關于損失函數C的兩個假設
1. 損失函數是每個訓練數據的損失的平均
也就是C是這樣的形式:
對于之前我們使用的MSE損失函數,這是滿足的。我們使用batch的梯度下降的時候需要求C對參數w的偏導數,因為損失函數是每個訓練數據的損失的平均,所以我們只需要求每個數據的偏導數,然后加起來平均就行。這個假設幾乎所有的損失函數都是滿足的【我是沒見過損失函數不滿足這個條件】
這個條件幾乎常見的損失函數都是這樣的,我們之前時候的MSE就是計算最后一層的輸出aL和正確的y(one-hot)的均方誤差,顯然是滿足的。
(3) Hadamard product
這個名字看起來很復雜,其實很簡單,就是兩個向量elementwise的乘法。看一個例子就清楚了:
(4) 反向傳播算法(back propagation)的4個公式
回顧一下,我們之前說了,梯度下降其實最核心的問題就是求損失函數對每一個參數的偏導數。那我們就直接一個一個求好了,為什么又要搞出一個反向傳播算法呢?其實這個算法在不同的領域被不同的人重復“發現”過很多次,有過很多不同的名字,最本質的應該就是逆向求導(reverse-mode differentiation)或者叫做自動求導(automatic differentiation)。自動求導(AD)是非常通用的一種求偏導數的方法,很早就在流體力學和大氣物理等領域使用,反向傳播算法可以認為是AD在神經網絡中的應用。不過最早發現這個算法的人(是誰最早好像還有點爭議)并不是先知道AD可以直接用于神經網絡,他發現這個算法是基于錯誤的反向傳播而得到的,所有命名為(錯誤的)反向傳播算法。后面我們會講到AD,這是一個強大的算法,任何一個函數,你能把它分解成有向無環圖的計算圖【函數一般都能分解成一些無依賴的最基礎的變量的復合函數,因此肯定可以表示成這樣一個有向無環圖】,然后每個節點都表示一個函數。只要你能求出這個函數在特定點的梯度【也就是這個函數對所以自變量的偏導數】(不需要求解析的偏導數,當然很多情況,這些函數都是能直接求出解析解,然后代入這個特定點就行,但理論上我們是可以用其他方法,比如數值梯度近似來求的),就能自動的計算損失函數對每一個參數的偏導數(也是在這個點的),而且只要反向根據拓撲排序遍歷這個圖一次就行,非常高效和簡單。后面我們會詳細的介紹AD。這個方法非常通用,TensorFlow的核心就是AD。使用AD的框架就比較靈活,我想“創造”一種新的網絡結構,我又不想【其實更可能是不會】推導出梯度的公式,那么我只需要把我的網絡能用這樣一個有向無環圖表示就行。當然節點必須要能夠求出梯度來,一般我們的函數比如矩陣的運算,卷積等等TensorFlow都封裝好了——它把它叫做一個op。我們只需要搭積木一樣把這個計算圖定義出來,TensorFlow就自動的能根據AD計算出損失函數對所有參數的梯度來了。當然如果你要用到一個TensorFlow沒有的op,那你就需要根據它的規范實現這個op,一個op最核心的接口就是兩個,一個是輸入x,求f(x);另一個就是求f在某個x0點的梯度。
不過這里,我們還是沿著神經網絡的發展歷史,從錯誤的反向傳播角度來理解和推導這個算法。
首先,我們會對每一個神經元比如第L層的第j個,都定義一個錯誤δ_jL
也就是損失函數對z也就是線性累加和的偏導數。為什么定義這樣一個東西呢?我們假設在第L層的第j個神經元上有一個精靈(Daemon)
當這個神經元得到來自上一次的輸入累加計算出z_jL的時候,它會惡作劇的給一點很小的干擾Δz_jL。原來它應該輸出的是σ(z_jL),現在變成了σ(z_jL +Δz_jL)。這個微小的變化逐層傳播,最終導致損失函數C也發生如下的變化:
這個其實就是導數的直覺定義:微小的Δx引起微小的Δy,Δy/Δx約等于導數。
不過這個精靈是個好精靈,它想幫助我們減少損失。 當
大于0的時候,它讓Δz_jL小于0,反之當它小于0的時候它讓Δz_jL大于0。這樣
總是小于0
因此我們的loss就會變小。而其絕對值越大,我們的損失減少的越多。
當然你會說為什么不能讓Δz_jL非常大,這樣我們的損失總是減少很多?可惜這個精靈是個數學家,它說如果Δx太大,那么Δy=df/dx *Δx就不準確了。
所以我們可以這樣認為:它就是第L層的第j個神經元“引起”的“錯誤”。如果絕對值大,則它的“責任”也大,它就得多做出一些調整;反之如果它趨近于0,說明它沒有什么“責任”,也就不需要做出什么改變。
因此通過上面的啟發,我們定義出δ_jL來。
接下來我們逐個介紹反向傳播算法的4個公式。
公式1. 第L層(最后一層) 的錯誤
這個公式的第一項,就是損失C對a_jL的導數,它越大,說明C受a_jL的影響也就越大,如果有了錯誤,第a_jL的“責任”也就越大,錯誤也就越大。第二項是a_jL受z_jL的影響。兩者乘起來就是z_jL對最終損失的影響,也就是它的“責任”的大小。
這個公式很好計算,首先第二項就是把z_jL的值(這個在feedforward節點就算出來并存儲下來了)代入σ'(x)。如果σ是sigmoid函數,我們前面也推導過它的導數:σ’(x)=σ(x)*(1-σ(x))。第一項當然依賴于損失函數的定義,一般也很好求。比如我們的MSE損失:
具體的推導我在紙上寫了一下,雖然很簡單,我們也可以練練手,尤其是對于求和公式的展開,希望大家能熟悉它,以后的推導我可能就不展開求和公式了,你需要知道求和公式里哪些項是和外面的自變量無關的。
公式BP1是elementwise的,我們需要變量j來計算每一個δ_jL。我們也可以把它寫成向量的形式,以方便利用線性代數庫,它們可以一次計算向量或者矩陣,可以用很多技術利用硬件特性來優化(包括GPU,SSE等)速度。
右邊δ'(z_L)很容易理解,左邊的記號可能有些費解,其實我們把?aC當成一個整體就好了,它是一個向量,第一個元素是?C/?a_1L,第二個就是?C/?a_2L,…
如果算上函數C是MSE的話,上面的公式就可以簡化成:
公式2. 第l層(非最后一層) 的錯誤
等下我們會證明這個公式,不過首先我們來熟悉一下公式。如果我們想“背”下這個公式的話,似乎看起來比第一個BP1要復雜很多 。我們先檢查一下矩陣和向量的維度,假設l+1層有m個元素,l層n個。則w_l+1的大小是m*n,轉置之后是n*m,δ_l+1的大小是n*1,所以矩陣相乘后是m*1,這和δ_l是一樣的,沒有問題。
接下來我們仔細觀察一下BP2這個公式,首先第二項σ'(z_l)和前面的含義一樣,代表a_l對于z_l的變化率。
而第一項復雜一點,我們知道第l層的第j個神經元會影響第l+1層的所有神經元,從而也影響最終的損失C。這個公式直接給了一個矩陣向量的形式,看起來不清楚,所以我在草稿紙上展開了:
最終第L層的第j個神經元的損失就是如下公式:
這下應該就比較清楚了,第l層的第j個神經元的損失,就是把l+1層的損失“反向傳播”回來,當然要帶上權重,權重越大,“責任”也就越大。
如果要“背”出這個公式也沒有那么復雜了,先不看σ'(z_l),第一項應該是矩陣w_l+1乘以δ_l+1。由于矩陣是m*n,而
向量δ_l+1是m*1,為了能讓矩陣乘法成立,那么就只能把w轉置一下,變成n*m,然后就很容易記住這個公式了。
注意,BP2的計算是從后往前的,首先根據BP1,最后一層的δ_L我們已經算出來了,因此可以向前計算L-1層的δ_L-1,
有了δ_L-1就能計算δ_L-2,…,最終能算出第一個隱層(也就是第2層)δ_1來。
公式3. 損失函數對偏置b的梯度
這前面費了大力氣求δ_l,不要忘了我們的最終目標是求損失函數對參數w和b的偏導數,而不是求對中間變量z的偏導數。
因此這個公式就是對b的偏導數。
或者寫成向量的形式:
?C/?b就是δ!
公式4. 損失函數對w的梯度
或者參考下圖寫成好記的形式:
也就是說對于一條邊w_jkL,?C/?w_ij就是這條邊射出的點的錯誤δ乘以進入點的激活。非常好記。
我們把這四個公式再總結一下:
(5) 這四個公式的證明
首先是BP1,請參考下圖:
然后是BP2:
這里用到了chain rule,其實也非常簡單和直觀,就是復合函數層層組合。最簡單的方法就是用圖畫出來,比如y最終
是x的函數,我們要求?y/?x,如果y是u,v的函數,然后u,v才是x的函數,那么我們把變量x,y,u,v都畫成圖上的點,y是u,v的函數,那么我們畫上從u和v到y的邊,同樣,我們畫上從x到u和v的邊,然后從y到x的每條路徑,我們經過的邊都是一個偏導數,我們把它累加起來就行【這其實就是后面我們會講的AD】。因此?y/?x=?y/?u * ?u/?x +?y/?v * ?v/?x。
剩下的BP3和BP4也非常類似,我就不證明了。
反向傳播算法
1. a_1 = 輸入向量x
2. Feedforward 根據公式
和
計算z_l和a_l并存儲下來(反向傳播時要用的)
3. 計算最后一層的錯誤
2.2.6 代碼實現反向傳播算法
我們已經把公式推導出來了,那怎么用代碼實現呢?我們先把代碼復制一下,然后說明部分都是作為代碼的注釋了,
請仔細閱讀。
2.2.7 為什么反向傳播算法是一個高效的算法?
分析完代碼,我們發現一次backprop函數調用需要feedforward一次,網絡有多少邊,就有多少次乘法,有多少個點就有多少次加分和激活函數計算(不算第一層輸入層)。反向計算也是一樣,不過是從后往前。也就是說這是時間復雜度為O(n)的算法。
如果我們不用反向傳播算法,假設我們用梯度的定義計算數值梯度。對于每一個參數wj,
我們都用公式 limit (f(w1, w2, …, wj+Δ wj, …) - f(w1, w2, …, wj, …)/Δwj
f(w1, w2, wj, …)只需要feedforward一次,但是對于每個參數wj,都需要feedforward一層來計算f(w1, w2, …, wj+Δ wj, …),它的時間復雜度是O(n),那么對所有的參數的計算需要O(n^2)的時間復雜度。
假設神經網絡有1百萬個參數,那么每次需要10^12這個數量級的運算,而反向傳播算法只需要10^6,因此這個方法比反向傳播算法要慢1百萬倍。
Part IV? 自動梯度求解 反向傳播算法的另外一種視角
前面我們講過了反向傳播算法的詳細推導過程,大家可能會覺得有些復雜。事實上其實就是鏈式求導法則的應用。今天我們將會繼續討論這個問題,不過是從Computational Graphs的角度,也就是我們之前說過的自動求導(Automatic Differentiation or Reverse-mode Differentiation)。并且通過CS231n的Assignment2來學習使用這種方法,通過這種方法來實現一個多層的神經網絡。
Calculus on Computational Graphs: Backpropagation
首先我們介紹一篇博客文章: https://colah.github.io/posts/2015-08-Backprop/ 基本是翻譯過來,不過部分地方是我自己的理解,建議讀者結合這篇文章一起閱讀。
簡介
反向傳播算法是神經網絡的核心算法,不過這個算法在不同的領域被多次”發現“過,因此有不同的名稱。
計算圖(Computational Graphs)
考慮一個簡單的函數 e=(a+b)?(b+1)e=(a+b)?(b+1) 。這個函數有兩個操作(函數),加法和乘法。為了指代方便,我們引入兩個中間變量,c和d。
- c=a+b
- d=b+1
- e=c?d
下面我們把它畫成一個計算圖,每一個操作是圖中一個節點,最基本的變量a和b也是一個節點。每個節點和它的輸入變量直接有一條邊。比如d的輸入變量是b,那么d和b直接就有一條邊。
任何一個顯示定義的函數(隱函數不行,不過我們定義的神經網絡肯定不會通過隱函數來定義)都可以分解為一個有向無環圖(樹),其中葉子節點是最基本的無依賴的自變量,而中間節點是我們引入的中間變量,而樹根就是我們的函數。比如上面的例子,計算圖如下所示:
給定每一個自變量的值,我們可以計算最終的函數值,對應與神經網絡就是feedforward計算。具體用”算法“怎么計算呢?首先因為計算圖是一個有向無環圖,因此我們可以拓撲排序,先是葉子節點a和b,他們的值已經給定,然后刪除a和b出發的邊,然后c和d沒有任何未知依賴,可以計算,最后計算e。計算過程如下圖:
計算圖的導數計算
首先我們可以計算每條邊上的導數,也就是邊的終點對起點的導數,而且導數是在起點的取前向計算值時的導數,具體過程如圖所示:
有些邊的導數不依賴于輸入的值,比如:
但是還有很多邊的導數是依賴于輸入值的,比如:
因為在“前向”計算的過程中,每個節點的值都計算出來了,所以邊的計算很簡單,也不需要按照什么的順序。
不過我們一般比較感興趣的是最終函數對某個自變量的導數,比如
根據鏈式法則,只要找到這兩個節點的所有路徑,然后把路徑的邊乘起來就得到這條邊的值,然后把所有邊加起來就可以了。
比如上面的例子b到e有兩條路徑:b->c->e和b->d->e,所以
如果用“鏈式”法則來寫就是
路徑反過來而已。
使用上面的方法,我們可以計算任何一個點(上面的變量)對另外一個點(上面的變量)的導數。不過我們一般的情況是計算樹根對所有葉子的導數,當然我們可以使用上面的算法一個一個計算,但是這樣會有很多重復的計算。
比如a->e的路徑是 a->c->e,b->e有一條邊是b->c->e,其中c->e是重復的【這個例子不太好,我們可以想像c->e是一條很長的路徑】,每次都重復計算c->e這個“子”路徑是多余的。我們可以從后往前計算,也就是每個節點都是存放樹根變量(這個例子是e)對當前節點的導數(其實也就是樹根到當前節點的所有路徑的和)。
反向導數計算
計算流程文字描述如下:
首先還是對這個圖進行拓撲排序,不過是反過來。
首先是
這個沒什么好說的。
然后計算
然后計算
然后計算
計算
前向導數計算
如果我們需要計算每一個變量對某一個變量的導數,就可以使用前向計算的方法。不過我們的神經網絡都是相反——計算某個一個變量(一般是損失函數)對所有變量的導數,所以這里就不詳細介紹了。
Part V 自動梯度求解——cs231n的notes
Optimization
這一部分內容來自:CS231n Convolutional Neural Networks for Visual Recognition
簡介
我們的目標:x是一個向量,f(x)是一個函數,它的輸入是一個向量(或者認為是多變量的函數,這個輸入向量就是自變量),輸出是一個實數值。我們需要計算的是f對每一個自變量的導數,然后把它們排成一個向量,也就是梯度。
為什么要求這個呢?前面我們也講了,我們的神經網絡的損失函數最終可以看成是權重weights和bias的函數,我們的目標就是調整這些參數,使得損失函數最小。
簡單的表達式和梯度的解釋
首先我們看一個很簡單的函數 f(x,y)=xy,求f對x和y的偏導數很簡單:
首先來看導數的定義:
函數在某個點的導數就是函數曲線在這個點的斜率,也就是f(x)隨x的變化率。
比如上面的例子,當x=4,y=?3時 f(x,y)=?12,f對x的偏導數
也就是說,如果我們固定y=4,然后給x一個很小的變化h,那么f(x,y)的變化大約是-3*h。
因此乘法的梯度就是
同樣,加法的梯度更簡單:
最后一個簡單函數是max函數:
這個導數是ReLU(x)=max(x,0)的導數,其實也簡單,如果 x>=y,那么 max(x,y)=x,則導數是1,否則 max(x,y)=0,那么對x求導就是0。
復雜表達式的鏈式法則
接下來看一個稍微復雜一點的函數 f(x,y,z)=(x+y)z。我們引入一個中間變量q,f=qz,q=x+y,我們可以使用鏈式法則求f對x和y的導數。
對y的求導也是類似的。
下面是用python代碼來求f對x和y的導數在某一個點的值。
# 設置自變量的值 x = -2; y = 5; z = -4# “前向”計算f q = x + y # q becomes 3 f = q * z # f becomes -12# 從“后”往前“反向”計算 # 首先是 f = q * z dfdz = q # 因為df/dz = q, 所以f對z的梯度是 3 dfdq = z # 因為df/dq = z, 所以f對q的梯度是 -4 # 然后 q = x + y dfdx = 1.0 * dfdq # 因為dq/dx = 1,所以使用鏈式法則計算dfdx=-4 dfdy = 1.0 * dfdq # 因為dq/dy = 1,所以使用鏈式法則計算dfdy=-4我們也可以用計算圖來表示和計算:
綠色的值是feed forward的結果,而紅色的值是backprop的結果。
不過我覺得cs231n課程的這個圖沒有上面blog的清晰,原因是雖然它標示出來了最終的梯度,但是沒有標示出local gradient,我在下面會畫出完整的計算過程。
反向傳播算法的直覺解釋
我們如果把計算圖的每一個點看成一個“門”(或者一個模塊),或者說一個函數。它有一個輸入(向量),也有一個輸出(標量)。對于一個門來說有兩個計算,首先是根據輸入,計算輸出,這個一般很容易。還有一種計算就是求輸出對每一個輸入的偏導數,或者說輸出對輸入向量的”局部“梯度(local gradient)。一個復雜計算圖(神經網絡)的計算首先就是前向計算,然后反向計算,反向計算公式可能看起來很復雜,但是如果在計算圖上其實就是簡單的用local gradient乘以從后面傳過來的gradient,然后加起來。
Sigmoid模塊的例子
接下來我們看一個更復雜的例子:
這個函數是一個比較復雜的復合函數,但是構成它的基本函數是如下4個簡單函數:
下面是用計算圖畫出這個計算過程:
這個圖有4種gate,加法,乘法,指數和倒數。加法有加一個常數和兩個變量相加,乘法也是一樣。
上圖綠色的值是前向計算的結果,而紅色的值是反向計算的結果,local graident并沒有標示出來,所以看起來可能有些跳躍,下面我在紙上詳細的分解了其中的步驟,請讀者跟著下圖自己動手計算一遍。
上圖就是前向計算的過程,比較簡單。
第二個圖是計算local gradient,對于兩個輸入的乘法和加法,local gradient也是兩個值,local gradient的值我是放到圖的節點上了。
第三個圖是具體計算一個乘法的local gradient的過程,因為上圖可能看不清,所以單獨放大了這一步。
最后計算真正的梯度,是把local gradient乘以來自上一步的gradient。不過這個例子一個節點只有一個輸出,如果有多個的話,梯度是加起來的,可以參考1.4的
上面我們看到把
分解成最基本的加法,乘法,導數和指數函數,但是我們也可以不分解這么細。之前我們也學習過了sigmoid函數,那么我們可以這樣分解:
σ(x)σ(x) 的導數我們之前已經推導過一次了,這里再列一下:
因此我們可以把后面一長串的gate”壓縮“成一個gate:
我們來比較一下,之前前向計算 σ(x)σ(x) 需要一次乘法,一次exp,一次加法導數;而反向計算需要分別計算這4個gate的導數。
而壓縮后前向計算是一樣的,但是反向計算可以”利用“前向計算的結果
這只需要一次減法和一次乘法!當然如果不能利用前向的結果,我們如果需要重新計算 σ(x)σ(x) ,那么壓縮其實沒有什么用處。能壓縮的原因在于σ函數導數的特殊形式。而神經網絡的關鍵問題是在訓練,訓練性能就取決于這些細節。如果是我們自己來實現反向傳播算法,我們就需要利用這樣的特性。而如果是使用工具,那么就依賴于工具的優化水平了。
下面我們用代碼來實現一下:
w = [2,-3,-3] # assume some random weights and data x = [-1, -2]# forward pass dot = w[0]*x[0] + w[1]*x[1] + w[2] f = 1.0 / (1 + math.exp(-dot)) # sigmoid function# backward pass through the neuron (backpropagation) ddot = (1 - f) * f # gradient on dot variable, using the sigmoid gradient derivation dx = [w[0] * ddot, w[1] * ddot] # backprop into x dw = [x[0] * ddot, x[1] * ddot, 1.0 * ddot] # backprop into w # we're done! we have the gradients on the inputs to the circuit上面的例子用了一個小技巧,就是所謂的staged backpropagation,說白了就是給中間的計算節點起一個名字。比如dot。為了讓大家熟悉這種技巧,下面有一個例子。
Staged computation練習
我們用代碼來計算這個函數對x和y的梯度在某一點的值
前向計算
x = 3 # example values y = -4# forward pass sigy = 1.0 / (1 + math.exp(-y)) # 分子上的sigmoid #(1) num = x + sigy # 分子 #(2) sigx = 1.0 / (1 + math.exp(-x)) # 分母上的sigmoid #(3) xpy = x + y #(4) xpysqr = xpy**2 #(5) den = sigx + xpysqr # 分母 #(6) invden = 1.0 / den #(7) f = num * invden # done! #(8)反向計算
# backprop f = num * invden dnum = invden # gradient on numerator #(8) dinvden = num #(8) # backprop invden = 1.0 / den dden = (-1.0 / (den**2)) * dinvden #(7) # backprop den = sigx + xpysqr dsigx = (1) * dden #(6) dxpysqr = (1) * dden #(6) # backprop xpysqr = xpy**2 dxpy = (2 * xpy) * dxpysqr #(5) # backprop xpy = x + y dx = (1) * dxpy #(4) dy = (1) * dxpy #(4) # backprop sigx = 1.0 / (1 + math.exp(-x)) dx += ((1 - sigx) * sigx) * dsigx # Notice += !! See notes below #(3) # backprop num = x + sigy dx += (1) * dnum #(2) dsigy = (1) * dnum #(2) # backprop sigy = 1.0 / (1 + math.exp(-y)) dy += ((1 - sigy) * sigy) * dsigy #(1) # done! phew需要注意的兩點:1. 前向的結果都要保存下來,反向的時候要用的。2. 如果某個變量有多個出去的邊,第一次是等于,第二次就是+=,因為我們要把不同出去點的梯度加起來。
下面我們來逐行分析反向計算:
(8) f = num * invden
local gradient
而上面傳過來的梯度是1,所以 dnum=1?invden。注意變量的命名規則, df/dnum就命名為dnum【省略了df,因為默認我們是求f對所有變量的偏導數】
同理: dinvden=num
(7) invden = 1.0 / den
local gradient是 (?1.0/(den??2)) ,然后乘以上面來的dinvden
(6) den = sigx + xpysqr
這個函數有兩個變量sigx和xpysqr,所以需要計算兩個local梯度,然后乘以dden
加法的local梯度是1,所以就是(1)*dden
(5) xpysqr = xpy**2
local gradient是2*xpy,再乘以dxpysqr
(4) xpy = x + y
還是一個加法,local gradient是1,所以dx和dy都是dxpy乘1
(3) sigx = 1.0 / (1 + math.exp(-x))
這是sigmoid函數,local gradient是 (1-sigx)*sigx,再乘以dsigx。
不過需要注意的是這是dx的第二次出現,所以是+=,表示來自不同路徑反向傳播過來給x的梯度值
(2) num = x + sigy
還是個很簡單的加法,local gradient是1。需要注意的是dx是+=,理由同上。
(1) sigy = 1.0 / (1 + math.exp(-y))
最后是sigmoid(y)和前面(3)一樣的。
請仔細閱讀上面反向計算的每一步代碼,確保自己理解了之后再往下閱讀。
梯度的矩陣運算
前面都是對一個標量的計算,在實際實現時用矩陣運算一次計算一層的所有梯度會更加高效。因為矩陣乘以向量和向量乘以向量都可以看出矩陣乘以矩陣的特殊形式,所以下面我們介紹矩陣乘法怎么求梯度。
首先我們得定義什么叫矩陣對矩陣的梯度!
我查閱了很多資料,也沒找到哪里有矩陣對矩陣的梯度的定義,如果哪位讀者知道,請告訴我,謝謝!唯一比較接近的是Andrew Ng的課程cs294的背景知識介紹的slides linalg的4.1節定義了gradient of Matrix,關于矩陣對矩陣的梯度我會有一個猜測性的解釋,可能會有問題。
首先介紹graident of matrix
假設 f:Rm×n→R是一個函數,輸入是一個m×n的實數值矩陣,輸出是一個實數。那么f對A的梯度是如下定義的:
看起來定義很復雜?其實很簡單,我們把f看成一個mn個自變量的函數,因此我們可以求f對這mn個自變量的偏導數,然后把它們排列成m*n的矩陣就行了。為什么要多此一舉把變量拍成矩陣把他們的偏導數也排成矩陣?想想我們之前的神經網絡的weights矩陣,這是很自然的定義,同時我們需要計算loss對weights矩陣的每一個變量的偏導數,寫出這樣的形式計算起來比較方便。
那么什么是矩陣對矩陣的梯度呢?我們先看實際神經網絡的一個計算情況。對于全連接的神經網絡,我們有一個矩陣乘以向量 D=WxD=Wx 【我們這里把向量x看成矩陣】。現在我們需要計算loss對某一個 WijWij 的偏導數,根據我們之前的計算圖, WijWij 有多少條出邊,那么就有多少個要累加的梯度乘以local梯度。
假設W是m×n的矩陣,x是n×p的矩陣,則D是m×p的矩陣
根據矩陣乘法的定義
我們可以計算:
請仔細理解上面這一步,如果 k≠i,則不論s是什么,Wks跟Wij不是同一個變量,所以導數就是0;如果k=i,∑sWisxsl=xjl,也就求和的下標s取j的時候有WijWij。
因此
上面計算了loss對一個Wij的偏導數,如果把它寫成矩陣形式就是:
前面我們推導出了對Wij的偏導數的計算公式,下面我們把它寫成矩陣乘法的形式并驗證【證明】它。
為什么可以寫成這樣的形式呢?
上面的推導似乎很復雜,但是我們只要能記住就行,記法也很簡單——把矩陣都變成最特殊的1 1的矩陣(也就是標量,一個實數)。D=w x,這個導數很容易吧,對w求導就是local gradient x,然后乘以得到dW=dD x;同理dx=dD W。
但是等等,剛才那個公式里還有矩陣的轉置,這個怎么記?這里有一個小技巧,就是矩陣乘法的條件,兩個矩陣能相乘他們的大小必須匹配,比如D=Wx,W是m n,x是n p,也就是第二個矩陣的行數等于第一個的列數。
現在我們已經知道dW是dD”乘以“x了,dW的大小和W一樣是m n,而dD和D一樣是m p,而x是n p,那么為了得到一個m n的矩陣,唯一的辦法就是 dD?xT
同理dx是n p,dD是m p,W是m*n,唯一的乘法就是 WT?dD
下面是用python代碼來演示,numpy的dot就是矩陣乘法,可以用numpy.dot(A,B),也可以直接調用ndarray的dot函數——A.dot(B):
至此,本系列文章的第5部分告一段落。在接下來的文章中,作者將為大家詳細講述關于常見的深度學習框架/工具的使用方法、使用自動求導來實現多層神經網絡等內容,敬請期待。
總結
以上是生活随笔為你收集整理的从Image Caption Generation理解深度学习的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: OpenCV实现图像金字塔
- 下一篇: OpenCV基本的阈值操作