TensorFlow 2.0深度學習算法實戰(一)

第一章 人工智能緒論

1.1 人工智能

信息技術是人類歷史上的第三次工業革命,計算機、互聯網、智能家居等技術的普及
極大地方便了人們的日常生活。通過編程的方式,人類可以將提前設計好的交互邏輯重複
且快速地執行,從而將人類從簡單枯燥的重複勞動任務中解脫出來。但是對於需要較高智
能的任務,如人臉識別,聊天機器人,自動駕駛等任務,很難設計明確的邏輯規則傳統
的編程方式顯得力不從心
,而人工智能技術是有望解決此問題的關鍵技術。

隨着深度學習算法的崛起,人工智能在部分任務上取得了類人甚至超人的水平,如圍
棋上 AlphaGo 智能程序已經擊敗人類最強圍棋專家柯潔,在 Dota2 遊戲上 OpenAI Five 智
能程序擊敗冠軍隊伍 OG,同時人臉識別,智能語音,機器翻譯等一項項實用的技術已經
進入到人們的日常生活中。現在我們的生活處處被人工智能環繞,儘管目前達到的智能水
平離通用人工智能(Artificial General Intelligence,簡稱 AGI)還有一段距離,我們仍堅定相
信人工智能時代即將來臨。
接下來我們將介紹人工智能,機器學習,深度學習的概念以及它們之間的聯繫與區
別。

1.1.1 人工智能

人工智能是指讓機器獲得像人類一樣的智能機制的技術,這一概念最早出現在 1956 年
召開的達特茅斯會議上。這是一項極具挑戰性的任務,人類目前尚無法對人腦的工作機制
有全面科學的認知,希望能製造達到人腦水平的智能機器無疑是難於上青天。即使如此,
在某個方面呈現出類似、接近甚至超越人類智能水平的機器被證明是可行的。

怎麼實現人工智能是一個非常廣袤的問題。人工智能的發展主要經歷過 3 種階段,每
個階段都代表了人類從不同的角度嘗試實現人工智能的探索足跡。最早期人類試圖通過總
結、歸納出一些邏輯規則,並將邏輯規則以計算機程序的方式來開發智能系統
。但是這種
顯式的規則往往過於簡單,很難表達複雜、抽象的規則。這一階段被稱爲推理期

1970 年代,科學家們嘗試通過知識庫+推理的方式解決人工智能,通過構建龐大複雜
的專家系統
來模擬人類專家的智能水平。這些明確指定規則的方式存在一個最大的難題,
就是很多複雜,抽象的概念無法用具體的代碼實現。比如人類對圖片的識別,對語言的理
解過程,根本無法通過既定規則模擬。爲了解決這類問題,一門通過讓機器自動從數據中
學習規則的研究學科誕生了,稱爲機器學習,並在 1980 年代成爲人工智能中的熱門學科。

在機器學習中,有一門通過神經網絡來學習複雜、抽象邏輯的方向,稱爲神經網絡
神經網絡方向的研究經歷了 2 起 2 落,並從 2012 年開始,由於效果極爲顯著,應用深層神
經網絡技術在計算機視覺、自然語言處理、機器人等領域取得了重大突破,部分任務上甚
至超越了人類智能水平,開啓了以深層神經網絡爲代表的人工智能的第 3 次復興。深層神
經網絡有了一個新名字,叫做深度學習
,一般來講,神經網絡和深度學習的本質區別並不
大,深度學習特指基於深層神經網絡實現的模型或算法。人工智能,機器學習,神經網
絡,深度學習的相互之間的關係如圖所示。
在這裏插入圖片描述

1.1.2 機器學習

機器學習可以分爲有監督學習(Supervised Learning)、無監督學習(Unsupervised
Learning)和強化學習(Reinforcement Learning),如圖 所示:
在這裏插入圖片描述
有監督學習 有監督學習的數據集包含了樣本𝒙與樣本的標籤𝒚,算法模型需要學習到映射
𝑓𝜃: 𝒙 → 𝒚,其中𝑓𝜃代表模型函數,𝜃爲模型的參數。在訓練時,通過計算模型的預測值
𝑓𝜃(𝒙)與真實標籤𝒚之間的誤差來優化網絡參數𝜃,使得網絡下一次能夠預測更精準。常見的
有監督學習有線性迴歸,邏輯迴歸,支持向量機,隨機森林等。

無監督學習 收集帶標籤的數據往往代價較爲昂貴,對於只有樣本𝒙的數據集,算法需要自
行發現數據的模態,這種方式叫做無監督學習。無監督學習中有一類算法將自身作爲監督
信號,即模型需要學習的映射爲𝑓𝜃: 𝒙 → 𝒙,稱爲自監督學習(Self-supervised Learning)。在
訓練時,通過計算模型的預測值𝑓𝜃(𝒙)與自身𝒙之間的誤差來優化網絡參數𝜃。常見的無監督
學習算法有自編碼器,生成對抗網絡等。

強化學習 也稱爲增強學習,通過與環境進行交互來學習解決問題的策略的一類算法。與有監督、無監督學習不同,強化學習問題並沒有明確的“正確的”動作監督信號,算法需要與環境進行交互,獲取環境反饋的滯後的獎勵信號,因此並不能通過計算動作與“正確動作”之間的誤差來優化網絡。常見的強化學習算法有 DQN,PPO 等。

1.1.3 神經網絡與深度學習

神經網絡算法是一類通過神經網絡從數據中學習的算法,它仍然屬於機器學習的範疇。受限於計算能力和數據量,早期的神經網絡層數較淺,一般在 1~4 層左右,網絡表達能力有限。隨着計算能力的提升和大數據時代的到來,高度並行化的 GPU 和海量數據讓大規模神經網絡的訓練成爲可能。

2006 年,Geoffrey Hinton(傑弗裏 希爾頓) 首次提出深度學習的概念2012 年,8 層的深層神經網絡 AlexNet 發佈,並在圖片識別競賽中取得了巨大的性能提升,此後數十層,數百層,甚至上千層的神經網絡模型相繼提出,展現出深層神經網絡強大的學習能力。我們一般將利用深層神經網絡實現的算法或模型稱作深度學習,本質上神經網絡和深度學習是相同的。

我們來比較一下深度學習算法與其他算法,如圖 1.3 所示。基於規則的系統一般會編寫顯示的規則邏輯,這些邏輯一般是針對特定的任務設計的,並不適合其他任務。傳統的機器學習算法一般會人爲設計具有一定通用性的特徵檢測方法,如 SIFT,HOG 特徵,這些特徵能夠適合某一類的任務,具有一定的通用性,但是如何設計特徵方法,特徵方法的好壞是問題的關鍵。神經網絡的出現,使得人爲設計特徵這一部分工作可以通過神經網絡讓機器自動學習,不需要人類干預。但是淺層的神經網絡的特徵提取能力較爲有限,而深層的神經網絡擅長提取深層,抽象的高層特徵,因此具有更好的性能表現。
在這裏插入圖片描述

1.2 神經網絡發展簡史

我們將神經網絡的發展歷程大致分爲淺層神經網絡階段深度學習階段,以 2006 年爲分割點。2006 年以前,深度學習以神經網絡和連接主義名義發展,歷經了 2 次興盛和 2 次寒冬;在 2006 年,Geoffrey Hinton 首次將深層神經網絡命名爲深度學習,開啓了深度學習
的第 3 次復興之路。

1.2.1 淺層神經網絡

1943 年,心理學家 Warren McCulloch 和邏輯學家 Walter Pitts 根據生物神經元(Neuron)結構,提出了最早的神經元數學模型,稱爲 MP 神經元模型。該模型的輸出𝑓(𝒙) =ℎ(𝑔(𝒙)),其中𝑔(𝒙) = ∑𝑖 𝑥𝑖, 𝑥𝑖 ∈ {0,1},模型通過𝑔(𝒙)的值來完成輸出值的預測,如果𝑔(𝒙) ≥ 𝟎,輸出爲 1;如果𝑔(𝒙) < 𝟎,輸出爲 0。可以看到,MP 神經元模型並沒有學習能力,只能完成固定邏輯的判定
在這裏插入圖片描述
1958 年,美國心理學家 Frank Rosenblatt 提出了第一個可以自動學習權重的神經元模型,稱爲感知機(Perceptron),如圖 1.5 所示,輸出值與真實值之間的誤差用於調整神經元的權重參數{w1,w2,...,wnw_{1},w_{2},...,w_{n})。感知機隨後基於“Mark 1 感知機”硬件實現,如圖 1.6 、1.7 所示,輸入爲 400 個單元的圖像傳感器,輸出爲 8 個節點端子,可以成功識別一些英文字母。我們一般認爲 1943 年~1969 年爲人工智能發展的第一次興盛期
在這裏插入圖片描述
1969 年,美國科學家 Marvin Minsky 等人在出版的《Perceptrons》一書中指出了感知機等線性模型的主要缺陷,即無法處理簡單的異或 XOR 等線性不可分問題。這直接導致了以感知機爲代表的神經網絡相關研究進入了低谷期,一般認爲 1969 年~1982 年爲人工智能發展的第一次寒冬。

儘管處於 AI 發展的低谷期,仍然有很多意義重大的研究相繼發表,這其中最重要的成果就是反向傳播算法(Backpropagation,簡稱 BP 算法)的提出,它依舊是現代深度學習的核心理論基礎。實際上,反向傳播的數學思想早在 1960 年代就已經被推導出了,但是並沒有應用在神經網絡上。直到 1974 年,美國科學家 Paul Werbos 在他的博士論文中第一次提出可以將 BP 算法應用到神經網絡上,遺憾的是,這一成果並沒有獲得足夠重視。直至1986 年,David Rumelhart 等人在 Nature 上發表了通過 BP 算法來表徵學習的論文,BP 算法才獲得了廣泛的關注。

1982 年 John Hopfild 的循環連接的 Hopfield 網絡的提出,開啓了 1982 年~1995 年的第二次人工智能復興的大潮,這段期間相繼提出了卷積神經網絡循環神經網絡反向傳播算法等算法模型。1986 年,David Rumelhart 和 Geoffrey Hinton 等人將 BP 算法應用在多層感知機上;1989 年Yann LeCun 等人將 BP 算法應用在手寫數字圖片識別上,取得了巨大成功,這套系統成功商用在郵政編碼識別、銀行支票識別等系統上;1997 年,應用最爲廣泛的循環神經網絡變種之一 LSTM 被 Jürgen Schmidhuber 提出;同年雙向循環神經網絡也被提出。

遺憾的是,神經網絡的研究隨着以支持向量機(Support Vector Machine,簡稱 SVM)爲
代表的傳統機器學習算法興起
而逐漸進入低谷,稱爲人工智能的第二次寒冬。支持向量機
擁有嚴格的理論基礎,需要的樣本數量較少,同時也具有良好的泛化能力,相比之下,神
經網絡理論基礎欠缺,可解釋性差,很難訓練深層網絡,性能也一般。圖 1.8 畫出了 1943
年~2006 年之間的重大時間節點。
在這裏插入圖片描述

1.2.2 深度學習

2006 年,Geoffrey Hinton 等人發現通過逐層預訓練的方式可以較好地訓練多層神經網絡,並在 MNIST 手寫數字圖片數據集上取得了優於 SVM 的錯誤率,開啓了第 3 次人工智能的復興。在論文中,Geoffrey Hinton 首次提出了 Deep Learning 的概念,這也是(深層)神經網絡被叫做深度學習的由來。2011 年,Xavier Glorot 提出了線性整流單元(Rectified Linear Unit, ReLU)激活函數,這是現在使用最爲廣泛的激活函數之一。2012 年,Alex Krizhevsky 提出了 8 層的深層神經網絡 AlexNet,它採用了 ReLU 激活函數,並使用 Dropout 技術防止過擬合,同時拋棄了逐層預訓練的方式,直接在 2 塊 GTX580 GPU 上訓練網絡。AlexNet 在 ILSVRC-2012 圖片識別比賽中獲得了第一名,比第二名在 Top-5 錯誤率上降低了驚人的 10.9%。

自 AlexNet 模型提出後,各種各樣的算法模型相繼被髮表,其中有 VGG 系列,GoogleNet,ResNet 系列,DenseNet 系列等等,其中 ResNet 系列網絡實現簡單,效果顯著,很快將網絡的層數提升至數百層,甚至上千層,同時保持性能不變甚至更好。

除了有監督學習領域取得了驚人的成果,在無監督學習和強化學習領域也取得了巨大的成績。2014 年,Ian Goodfellow 提出了生成對抗網絡,通過對抗訓練的方式學習樣本的真實分佈,從而生成逼近度較高的圖片。此後,大量的生成對抗網絡模型被提出,最新的圖片生成效果已經達到了肉眼難辨真僞的逼真度。2016 年,DeepMind 公司應用深度神經網絡到強化學習領域,提出了 DQN 算法,在 Atari 遊戲平臺中的 49 個遊戲取得了人類相當甚至超越人類的水平;在圍棋領域,DeepMind 提出的 AlphaGo 和 AlphaGo Zero 智能程序相繼打敗人類頂級圍棋專家李世石、柯潔等;在多智能體協作的 Dota2 遊戲平臺,OpenAI 開發的 OpenAI Five 智能程序在受限遊戲環境中打敗了 TI8 冠軍 OG 隊,展現出了大量專業級的高層智能的操作。圖 1.9 列出了 2006 年~2019 年之間重大的時間節點。
在這裏插入圖片描述

1.3 深度學習特點

與傳統的機器學習算法、淺層神經網絡相比,現代的深度學習算法通常具有如下特點。

1.3.1 數據量

早期的機器學習算法比較簡單,容易快速訓練,需要的數據集規模也比較小,如 1936年由英國統計學家 Ronald Fisher 收集整理的鳶尾花卉數據集 Iris 共包含 3 個類別花卉,每個類別 50 個樣本。隨着計算機技術的發展,設計的算法越來越複雜,對數據量的需求也隨之增大。1998 年由 Yann LeCun 收集整理的 MNIST 手寫數字圖片數據集共包含 0~9 共 10類數字,每個類別多達 7000 張圖片。隨着神經網絡的興起,尤其是深度學習,網絡層數較深,模型的參數量成百上千萬個,爲了防止過擬合,需要的數據集的規模通常也是巨大的。現代社交媒體的流行也讓收集海量數據成爲可能,如 2010 年的ImageNet 數據集收錄了 14,197,122 張圖片,整個數據集的壓縮文件大小就有 154GB。

儘管深度學習對數據集需求較高,收集數據,尤其是收集帶標籤的數據,往往是代價昂貴的。數據集的形成通常需要手動採集、爬取原始數據,並清洗掉無效樣本,再通過人類智能去標註數據樣本,因此不可避免地引入主觀偏差和隨機誤差。因此研究數據量需求較少的算法模型是非常有用的一個方向。
在這裏插入圖片描述
在這裏插入圖片描述

1.3.2 計算力

計算能力的提升是第三次人工智能復興的一個重要因素。實際上,目前深度學習的基礎理論在 1980 年代就已經被提出,但直到 2012 年基於 2 塊 GTX580 GPU 訓練的 AlexNet發佈後,深度學習的真正潛力才得以發揮。傳統的機器學習算法並不像神經網絡這樣對數據量和計算能力有嚴苛的要求,通常在 CPU 上串行訓練即可得到滿意結果。但是深度學習非常依賴並行加速計算設備,目前的大部分神經網絡均使用 NVIDIA GPU 和 Google TPU或其他神經網絡並行加速芯片訓練模型參數。如圍棋程序 AlphaGo Zero 在 64 塊 GPU 上從零開始訓練了 40 天才得以超越所有的 AlphaGo 歷史版本;自動網絡結構搜索算法使用了800 塊 GPU 同時訓練才能優化出較好的網絡結構。

目前普通消費者能夠使用的深度學習加速硬件設備主要來自 NVIDIA 的 GPU 顯卡,圖 1.12 例舉了從 2008 年到 2017 年 NVIDIA GPU 和 x86 CPU 的每秒 10 億次的浮點運算數(GFLOPS)的指標變換曲線。可以看到,x86 CPU 的曲線變化相對緩慢,而 NVIDIA GPU的浮點計算能力指數式增長,這主要是由日益增長的遊戲計算量和深度學習計算量等驅動的。
在這裏插入圖片描述

1.3.3 網絡規模

早期的感知機模型和多層神經網絡層數只有 1 層或者 2~4 層,網絡參數量也在數萬左右。隨着深度學習的興起和計算能力的提升,AlexNet(8 層),VGG16(16 層),GoogLeNet(22 層),ResNet50(50 層),DenseNet121(121 層)等模型相繼被提出,同時輸入圖片的大小也從 28x28 逐漸增大,變成 224x224,299x299 等,這些使得網絡的總參數量可達到千萬級別,如圖 1.13 所示。
在這裏插入圖片描述網絡規模的增大,使得神經網絡的容量相應增大,從而能夠學習到複雜的數據模態,模型的性能也會隨之提升;另一方面,網絡規模的增大,意味着更容易出現過擬合現象,訓練需要的數據集和計算代價也會變大。

1.3.4 通用智能

在過去,爲了提升某項任務上的算法性能,往往需要手動設計相應的特徵和先驗設定,以幫助算法更好地收斂到最優解。這類特徵或者先驗往往是與具體任務場景強相關的,一旦場景發生了變動,這些依靠人工設計的特徵或先驗無法自適應新場景,往往需要重新設計算法模型,模型的通用性不強。

設計一種像人腦一樣可以自動學習、自我調整的通用智能機制一直是人類的共同願景。深度學習從目前來看,是最接近通用智能的算法之一。在計算機視覺領域,過去需要針對具體的任務設計徵、添加先驗的做法,已經被深度學習完全拋棄了,目前在圖片識別、目標檢測、語義分割等方向,幾乎全是基於深度學習端到端地訓練,獲得的模型性能好,適應性強;在 Atria 遊戲平臺上,DeepMind 設計的 DQN 算法模型可以在相同的算法、模型結構和超參數的設定下,在 49 個遊戲上獲得人類相當的遊戲水平,呈現出一定程度的通用智能。圖 1.14 是 DQN 算法的網絡結構,它並不是針對於某個遊戲而設計的,而是可以運行在所有的 Atria 遊戲平臺上的 49 個遊戲。
在這裏插入圖片描述

1.4 深度學習應用

深度學習算法已經廣泛應用到人們生活的角角落落,例如手機中的語音助手,汽車上的智能輔助駕駛,人臉支付等等。我們將從計算機視覺、自然語言處理和強化學習 3 個領域入手,爲大家介紹深度學習的一些主流應用。

1.4.1 計算機視覺

圖片識別(Image Classification) 是常見的分類問題。神經網絡的輸入爲圖片數據,輸出值爲當前樣本屬於每個類別的概率,通常選取概率值最大的類別作爲樣本的預測類別。圖片識別是最早成功應用深度學習的任務之一,經典的網絡模型有 VGG 系列、Inception 系列、ResNet 系列等。

目標檢測(Object Detection) 是指通過算法自動檢測出圖片中常見物體的大致位置,通常用邊界框(Bounding box)表示,並分類出邊界框中物體的類別信息,如圖 1.15 所示。常見的目標檢測算法有 RCNN,Fast RCNN,Faster RCNN,Mask RCNN,SSD,YOLO 系列等。
在這裏插入圖片描述
語義分割(Semantic Segmentation) 是通過算法自動分割並識別出圖片中的內容,可以將語義分割理解爲每個像素點的分類問題,分析每個像素點屬於物體的類別,如圖 1.16 所示。常見的語義分割模型有 FCN,U-net,SegNet,DeepLab 系列等。
在這裏插入圖片描述
視頻理解(Video Understanding) 隨着深度學習在 2D 圖片的相關任務上取得較好的效果,具有時間維度信息的 3D 視頻理解任務受到越來越多的關注。常見的視頻理解任務有視頻分類,行爲檢測,視頻主體抽取等。常用的模型有 C3D,TSN,DOVF,TS_LSTM等。

圖片生成(Image Generation) 通過學習真實圖片的分佈,並從學習到的分佈中採樣而獲得逼真度較高的生成圖片。目前主要的生成模型有 VAE 系列,GAN 系列等。其中 GAN 系列算法近年來取得了巨大的進展,最新 GAN 模型產生的圖片樣本達到了肉眼難辨真僞的效果,如圖 1.17 爲 GAN 模型的生成圖片。

除了上述應用,深度學習還在其他方向上取得了不俗的效果,比如藝術風格遷移(圖1.18),超分辨率,圖片去燥/去霧,灰度圖片着色等等一系列非常實用酷炫的任務,限於篇幅,不再敖述。
在這裏插入圖片描述

1.4.2 自然語言處理

機器翻譯(Machine Translation) 過去的機器翻譯算法通常是基於統計機器翻譯模型,這也是 2016 年前 Google 翻譯系統採用的技術。2016 年 11 月,Google 基於 Seq2Seq 模型上線了 Google 神經機器翻譯系統(GNMT),首次實現了源語言到目標語言的直譯技術,在多項任務上實現了 50~90%的效果提升。常用的機器翻譯模型有 Seq2Seq,BERT,GPT,GPT-2 等,其中 OpenAI 提出的 GPT-2 模型參數量高達 15 億個,甚至發佈之初以技術安全考慮爲由拒絕開源 GPT-2 模型。

聊天機器人(Chatbot) 聊天機器人也是自然語言處理的一項主流任務,通過機器自動與人類對話,對於人類的簡單訴求提供滿意的自動回覆,提高客戶的服務效率和服務質量。常應用在諮詢系統、娛樂系統,智能家居等中。

1.4.3 強化學習

虛擬遊戲 相對於真實環境,虛擬遊戲平臺既可以訓練、測試強化學習算法,有可以避免無關干擾,同時也能將實驗代價降到最低。目前常用的虛擬遊戲平臺有 OpenAI Gym,OpenAI Universe,OpenAI Roboschool,DeepMind OpenSpiel,MuJoCo 等,常用的強化學習算法有 DQN,A3C,A2C,PPO 等。在圍棋領域,DeepMind AlaphGo 程序已經超越人類圍棋專家;在 Dota2 和星際爭霸遊戲上,OpenAI 和 DeepMind 開發的智能程序也在限制規則下戰勝了職業隊伍。

機器人(Robotics) 在真實環境中,機器人的控制也取得了一定的進展。如 UC Berkeley在機器人的 Imitation Learning,Meta Learning,Few-shot Learning 等方向取得了不少進展。美國波士頓動力公司在人工智能應用中取得喜人的成就,其製造的機器人在複雜地形行走,多智能體協作等任務上表現良好(圖 1.19)。

自動駕駛(Autonomous Driving) 被認爲是強化學習短期內能技術落地的一個應用方向,很多公司投入大量資源在自動駕駛上,如百度、Uber,Google 無人車等,其中百度的無人巴士“阿波龍”已經在北京、雄安、武漢等地展開試運營,圖 1.20 爲百度的自動駕駛汽車。
在這裏插入圖片描述

1.5 深度學習框架

工欲善其事,必先利其器。在瞭解了深度學習及其發展簡史後,我們來挑選一下深度學習要使用的工具吧。

1.5.1 主流框架

Theano 是最早的深度學習框架之一,由 Yoshua Bengio 和 Ian Goodfellow 等人開發,是一個基於 Python 語言、定位底層運算的計算庫,Theano 同時支持 GPU 和 CPU 運算。由於 Theano 開發效率較低,模型編譯時間較長,同時開發人員轉投 TensorFlow等原因,Theano 目前已經停止維護。

Scikit-learn 是一個完整的面向機器學習算法的計算庫,內建了常見的傳統機器學習算法支持,文檔和案例也較爲豐富,但是 Scikit-learn 並不是專門面向神經網絡而設計的,不支持 GPU 加速,對神經網絡相關層實現也較欠缺

Caffe 由華人博士賈揚清在 2013 年開發,主要面向使用卷積神經網絡的應用場合,並不適合其他類型的神經網絡的應用。Caffe 的主要開發語言是 C++,也提供 Python 語言等接口,支持 GPU 和 CPU。由於開發時間較早,在業界的知名度較高,2017 年Facebook 推出了 Caffe 的升級版本 Cafffe2,Caffe2 目前已經融入到 PyTorch 庫中

Torch 是一個非常優秀的科學計算庫,基於較冷門的編程語言 Lua 開發。Torch 靈活性較高,容易實現自定義網絡層,這也是 PyTorch 繼承獲得的優良基因。但是由於 Lua語言使用人羣較小,Torch 一直未能獲得主流應用。

MXNET 由華人博士陳天奇和李沐等人開發,已經是亞馬遜公司的官方深度學習框架。採用了命令式編程和符號式編程混合方式,靈活性高,運行速度快,文檔和案例也較爲豐富。

PyTorch 是 Facebook 基於原有的 Torch 框架推出的採用 Python 作爲主要開發語言的深度學習框架。PyTorch 借鑑了 Chainer 的設計風格,採用命令式編程,使得搭建網絡和調試網絡非常方便。儘管 PyTorch 在 2017 年才發佈,但是由於精良緊湊的接口設計,PyTorch 在學術界獲得了廣泛好評。在 PyTorch 1.0 版本後,原來的 PyTorch 與 Caffe2進行了合併,彌補了 PyTorch 在工業部署方面的不足。總的來說,PyTorch 是一個非常優秀的深度學習框架。

Keras 是一個基於 Theano 和 TensorFlow 等框架提供的底層運算而實現的高層框架,提供了大量方便快速訓練,測試的高層接口,對於常見應用來說,使用 Keras 開發效率非常高。但是由於沒有底層實現,需要對底層框架進行抽象,運行效率不高,靈活性一般

TensorFlow 是 Google 於 2015 年發佈的深度學習框架,最初版本只支持符號式編程。得益於發佈時間較早,以及 Google 在深度學習領域的影響力,TensorFlow 很快成爲最流行的深度學習框架。但是由於 TensorFlow 接口設計頻繁變動,功能設計重複冗餘,符號式編程開發和調試非常困難等問題,TensorFlow 1.x 版本一度被業界詬病。2019年,Google 推出 TensorFlow 2 正式版本,將以動態圖優先模式運行,從而能夠避免TensorFlow 1.x 版本的諸多缺陷,已獲得業界的廣泛認可。

目前來看,TensorFlow 和 PyTorch 框架是業界使用最爲廣泛的兩個深度學習框架,TensorFlow 在工業界擁有完備的解決方案和用戶基礎,PyTorch 得益於其精簡靈活的接口設計,可以快速設計調試網絡模型,在學術界獲得好評如潮。TensorFlow 2 發佈後,彌補了 TensorFlow 在上手難度方面的不足,使得用戶可以既能輕鬆上手 TensorFlow 框架,又能無縫部署網絡模型至工業系統。本書以 TensorFlow 2.0 版本作爲主要框架,實戰各種深度學習算法。

我們這裏特別介紹 TensorFlow 與 Keras 之間的聯繫與區別。Keras 可以理解爲一套高層 API 的設計規範,Keras 本身對這套規範有官方的實現,在 TensorFlow 中也實現了這套規範,稱爲 tf.keras 模塊,並且 tf.keras 將作爲 TensorFlow 2 版本的唯一高層接口,避免出現接口重複冗餘的問題。如無特別說明,本書中 Keras 均指代 tf.keras。

1.5.2 TensorFlow 2 與 1.x

TensorFlow 2 是一個與 TensorFlow 1.x 使用體驗完全不同的框架,TensorFlow 2 不兼容TensorFlow 1.x 的代碼,同時在編程風格、函數接口設計等上也大相徑庭,TensorFlow 1.x的代碼需要依賴人工的方式遷移,自動化遷移方式並不靠譜。Google 即將停止支持TensorFlow 1.x,不建議學習 TensorFlow 1.x 版本。

TensorFlow 2 支持動態圖優先模式,在計算時可以同時獲得計算圖與數值結果,可以代碼中調試實時打印數據,搭建網絡也像搭積木一樣,層層堆疊,非常符合軟件開發思維。

以簡單的2.0 + 4.0的相加運算爲例,在 TensorFlow 1.x 中,首先創建計算圖:

import tensorflow as tf
# 1.創建計算圖階段
# 2.創建2個輸入端子,指定類型和名字
a_ph=tf.placeholder(tf.float32,name='variable_a');
b_ph=tf.placeholder(tf.float32,name='variable_b');
# 創建輸出端子的運算操作,並命名
c_op=tf.add(a_ph,b_ph,name='variable_a')

創建計算圖的過程就類比通過符號建立公式𝑐 = 𝑎 + 𝑏的過程,僅僅是記錄了公式的計算步驟,並沒有實際計算公式的數值結果需要通過運行公式的輸出端子𝑐,並賦值𝑎 =2.0, 𝑏 = 4.0才能獲得𝑐的數值結果:

#2. 運行計算圖階段
# 創建運行環境
sess=tf.InteractiveSession()
# 初始化步驟也需要爲操作運行
init=tf.global_variables_initializer()
sess.run(init) #運行初始化操作,完成初始化
#運行端輸出端子,需要給輸入端子賦值
c_numpy=sess.run(c_op,feed_dict={a_ph:2.,b_ph:4.});
#運算完輸出端子才能得到數值類型的c_numpy
print('a+b=',c_numpy)

可以看到,在 TensorFlow 中完成簡單的2.0 + 4.0尚且如此繁瑣,更別說創建複雜的神經網絡算法有多艱難,這種先創建計算圖後運行的編程方式叫做符號式編程。接下來我們使用 TensorFlow 2 來完成2.0 + 4.0運算:

# 1.創建輸入張量
a=tf.constant(2.)
b=tf.constant(4.)
# 2.直接計算並打印
print('a+b=',a+b)

這種運算時同時創建計算圖𝑎 + 𝑏和計算數值結果2.0 + 4.0的方式叫做命令式編程,也稱爲動態圖優先模式。TensorFlow 2 和 PyTorch 都是採用動態圖(優先)模式開發,調試方便,所見即所得。一般來說,動態圖模型開發效率高,但是運行效率可能不如靜態圖模式,TensorFlow 2 也支持通過 tf.function 將動態圖優先模式的代碼轉化爲靜態圖模式,實現開發和運行效率的雙贏。

1.5.3 功能演示

深度學習的核心是算法的設計思想,深度學習框架只是我們實現算法的工具。下面我們將演示 TensorFlow 深度學習框架的 3 大核心功能,從而幫助我們理解框架在算法設計中扮演的角色。

a) 加速計算
神經網絡本質上由大量的矩陣相乘,矩陣相加等基本數學運算構成,TensorFlow 的重要功能就是利用 GPU 方便地實現並行計算加速功能。爲了演示 GPU 的加速效果,我們通過完成多次矩陣 A 和矩陣 B 的矩陣相乘運算的平均運算時間來驗證。其中矩陣 A 的 shape爲[1,𝑛],矩陣 B 的 shape 爲[𝑛, 1],通過調節 n 即可控制矩陣的大小。

首先我們分別創建使用 CPU 和 GPU 運算的 2 個矩陣:

# 創建在CPU上運行的2個矩陣
with tf.device('/cpu:0'):
  cpu_a=tf.random.random_normal([1,n])
  cpu_b=tf.random.random.normal([n,1])
  print(cpu_a.device,cpu_b.device)
# 創建使用GPU運算的2個矩陣
with tf.device('./gpu:0'):
  gpu_a=tf.random.normal([1,n])
  gpu_b=tf.random.normal([n,1])
  print(gpu_a.device,gpu_b.device)

並通過 timeit.timeit()函數來測量 2 個矩陣的運時間:

def cpu_run():
  with tf.device('./cpu:0'):
    c=tf.matmul(cpu_a,cpu_b)
  return c

def gpu_run():
  with tf.device('./gpu:0'):
    c=tf.matmul(gpu_a,gpu_b)
  return c

import timeit
# 第一次計算需要熱身,避免將初始化階段時間結算在內
cpu_time=timeit.timeit(cpu_run(),number=10)
gpu_time=timeit.timeit(gpu_run(),number=10)
print('Warmup:',cpu_time,gpu_time)
# 正式計算10次,取平均時間
cpu_time=timeit.timeit(cpu_run(),number=10)
gpu_time=timeit.timeit(gpu_run(),number=10)
print('rum time:',cpu_time,gpu_time)

我們將不同大小的 n 下的 CPU 和 GPU 的運算時間繪製爲曲線,如圖 1.21 所示。可以看到,在矩陣 A 和 B 較小時,CPU 和 GPU 時間幾乎一致,並不能體現出 GPU 並行計算的優勢;在矩陣較大時,CPU 的計算時間明顯上升,而 GPU 充分發揮並行計算優勢,運算時間幾乎不變。
在這裏插入圖片描述
b) 自動梯度
在使用 TensorFlow 構建前向計算過程的時候,除了能夠獲得數值結果,TensorFlow 還會自動構建計算圖,通過 TensorFlow 提供的自動求導的功能,可以不需要手動推導,即可計算出輸出對網絡的偏導數。
y=aw2+bw+cdydw=2aw+b\begin{array}{c} \mathrm{y}=\mathrm{a} * \mathrm{w}^{2}+\mathrm{b} * \mathrm{w}+\mathrm{c} \\ \frac{d y}{d w}=2 a w+b \end{array}
考慮在(a,b,c,w)=(1,2,3,4)處的導數,dydw=214+2=10\frac{d y}{d w}=2 * 1 * 4+2=10
通過Tensorflow實現如下

import tensorflow as tf
# 創建4個張量
a=tf.constant(1.)
b=tf.constant(2.)
c=tf.constant(3.)
w=tf.constant(4.)

with tf.GradientTape() as tape: #構建梯度環境
  tape.watch([w]) # 將w加入梯度跟蹤列表
  # 構建計算過程
  y=a*w**2+b*w+c
# 求導
[dy_dw]=tape.gradient(y,[w])
print(dy_dw) # 打印出導數
tf.Tensor(10.0, shape=(), dtype=float32)

c) 常用神經網絡接口
TensorFlow 除了提供底層的矩陣相乘,相加等運算函數,還內建了常用網絡運算函數,常用網絡層,網絡訓練,網絡保存與加載,網絡部署等一系列深度學習系統的便捷功能。使用 TensorFlow 開發網絡,可以方便地利用這些功能完成常用業務流程,高效穩定。

1.6 開發環境安裝

在領略完深度學習框架所帶來的的便利後,我們來着手在本地計算機環境安裝TensorFlow 最新版框架。TensorFlow 框架支持多種常見的操作系統,如 Windows 10,Ubuntu 18.04, Mac OS 等等,同時也支持運行在 NVIDIA 顯卡上的 GPU 版本和僅適用 CPU完成計算的 CPU 版本。我們以最爲常見的 Windows 10 系統,NVIDIA GPU,Python 語言環境爲例,介紹如何安裝 TensorFlow 框架及其他開發軟件等。

一般來說,開發環境安裝分爲 4 大步驟:安裝 Python 解釋器 Anaconda,安裝 CUDA加速庫,安裝 TensorFlow 框架,安裝常用編輯器。

1.6.1 Anaconda 安裝

Python 解釋器是讓 Python 語言編寫的代碼能夠被 CPU 執行的橋樑,是 Python 語言的核心。用戶可以從 官網下載最新版本(Python 3.8)的解釋器,像普通的應用軟件一樣安裝完成後,就可以調用 python.exe 程序執行 Python 語言編寫的源代碼文件(*.py)。

我們這裏選擇安裝集成了 Python 解釋器和虛擬環境等一系列輔助功能的 Anaconda 軟件,通過安裝 Anaconda 軟件,可以同時獲得 Python 解釋器,包管理,虛擬環境等一系列便捷功能,何樂而不爲呢。我們從 網址進入 Anaconda 下載頁面,選擇 Python 最新版本的下載鏈接即可下載,下載完成後安裝即可進入安裝程序。如圖 1.22 所示,勾選”Add Anaconda to my PATH environmentvariable”一項,這樣可以通過命令行方式調用 Anaconda 的程序。如圖 1.23 所示,安裝程序詢問是否連帶安裝 VS Code軟件,選擇skip即可。整個安裝流程持續5~10分鐘,具體時間需依據計算機性能而定。
在這裏插入圖片描述
安裝完成後,怎麼驗證 Anaconda 是否安裝成功呢?通過鍵盤上的 Windows 鍵+R 鍵,即可調出運行程序對話框,輸入 cmd 回車即打開 Windows 自帶的命令行程序 cmd.exe,或者點擊開始菜單,輸入 cmd 也可搜索到 cmd.exe 程序,打開即可。輸入 conda list 命令即可查看Python 環境已安裝的庫,如果是新安裝的 Python 環境,則列出的庫都是 Anaconda 自帶已默認安裝的軟件庫,如圖 1.24 所示。如果 conda list 能夠正常彈出一系列的庫列表信息,說明 Anaconda 軟件安裝成功,如果 conda 命名不能被識別,則說明安裝失敗,需要重新安裝。
在這裏插入圖片描述

1.6.2 CUDA 安裝

目前的深度學習框架大都基於 NVIDIA 的 GPU 顯卡進行加速運算,因此需要安裝NVIDIA 提供的 GPU 加速庫 CUDA 程序。在安裝 CUDA 之前,請確認本地計算機具有支持 CUDA 程序的 NVIDIA 顯卡設備,如果計算機沒有 NVIDIA 顯卡,如部分計算機顯卡生產商爲 AMD,以及部分 MacBook 筆記本電腦,則無法安裝 CUDA 程序,因此可以跳過這一步,直接進入 TensorFlow 安裝。CUDA 的安裝分爲 CUDA 軟件的安裝、cuDNN 深度神經網絡加速庫的安裝和環境變量配置三個步驟,安裝稍微繁瑣,請讀者在操作時思考每個步驟的原因,避免死記硬背流程。

CUDA 軟件安裝 打開 CUDA 程序的下載官網:https://developer.nvidia.com/cuda-10.0-download-archive,這裏我們使用 CUDA 10.0 版本,依次選擇 Windows 平臺,x86_64 架構,10 系統,exe(local)本地安裝包,再選擇 Download 即可下載 CUDA 安裝軟件。下載完成後,打開安裝軟件。如圖 1.25 所示,選擇”Custom”選項,點擊 NEXT 按鈕進入圖 1.26安裝程序選擇列表,在這裏選擇需要安裝和取消不需要安裝的程序。在 CUDA 節點下,取消”Visual Studio Integration”一項;在“Driver components”節點下,比對目前計算機已經安裝的顯卡驅動“Display Driver”的版本號“Current Version”和 CUDA 自帶的顯卡驅動版本號“New Version”,如果“Current Version”大於“New Version”,則需要取消“Display Driver”的勾,如果小於或等於,則默認勾選即可。設置完成後即可正常安裝完成。
在這裏插入圖片描述
安裝完成後,我們來測試 CUDA 軟件是否安裝成功。打開 cmd 命令行,輸入“nvcc -V”,即可打印當前 CUDA 的版本信息,如圖 1.29 所示,如果命令無法識別,則說明安裝失敗。同時我們也可從 CUDA 的安裝路徑“C:\Program Files\NVIDIA GPU ComputingToolkit\CUDA\v10.0\bin”下找到“nvcc.exe”程序,如圖 1.28 所示。
在這裏插入圖片描述
cuDNN 神經網絡加速庫安裝 CUDA 並不是針對於神經網絡設計的 GPU 加速庫,它面向各種需要並行計算的應用設計。如果希望針對於神經網絡應用加速,需要額外安裝cuDNN 庫。需要注意的是,cuDNN 庫並不是運行程序,只需要下載解壓 cuDNN 文件,並配置 Path 環境變量即可。

打開網址 https://developer.nvidia.com/cudnn,選擇“Download cuDNN”,由於 NVIDIA公司的規定,下載 cuDNN 需要先登錄,因此用戶需要登錄或創建新用戶後才能繼續下載。登錄後,進入 cuDNN 下載界面,勾選“I Agree To the Terms of the cuDNN SoftwareLicense Agreement”,即可彈出 cuDNN 版本下載選項。我們選擇 CUDA 10.0 匹配的 cuDNN版本,並點擊“cuDNN Library for Windows 10”鏈接即可下載 cuDNN 文件。需要注意的是,cuDNN 本身具有一個版本號,同時它還需要和 CUDA 的版本號對應上,不能下錯不匹配 CUDA 版本號的 cuDNN 文件。

在這裏插入圖片描述
下載完成 cuDNN 文件後,解壓並進入文件夾,我們將名爲“cuda”的文件夾重命名爲“cudnn765”,並複製此文件夾。進入 CUDA 的安裝路徑 C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v10.0,粘貼“cudnn765”文件夾即可,此處可能會彈出需要管理員權限的對話框,選擇繼續即可粘貼,如圖 1.31 所示。
在這裏插入圖片描述
環境變量 Path 配置 上述 cudnn 文件夾的複製即已完成 cuDNN 的安裝,但爲了讓系統能夠感知到 cuDNN 文件的位置,我們需要額外配置 Path 環境變量。打開文件瀏覽器,在“我的電腦”上右擊,選擇“屬性”,選擇“高級系統屬性”,選擇“環境變量”,如圖1.32。在“系統變量”一欄中選中“Path”環境變量,選擇“編輯”,如圖 1.33 所示。選擇“新建”,輸入我們 cuDNN 的安裝路徑“C:\Program Files\NVIDIA GPU ComputingToolkit\CUDA\v10.0\cudnn765\bin”,並通過“向上移動”按鈕將這一項上移置頂。
在這裏插入圖片描述
CUDA 安裝完成後,環境變量中應該包含“C:\Program Files\NVIDIA GPU ComputingToolkit\CUDA\v10.0\bin”,“C:\Program Files\NVIDIA GPU ComputingToolkit\CUDA\v10.0\libnvvp”和“C:\Program Files\NVIDIA GPU ComputingToolkit\CUDA\v10.0\cudnn765\bin”三項,具體的路徑可能依據實際路徑略有出入,如圖測
1.34 所示,確認無誤後依次點擊確定,關閉所有對話框。
在這裏插入圖片描述

1.6.3 TensorFlow 安裝

TensorFlow 和其他的 Python 庫一樣,使用Python 包管理工具 pip install 命令即可安裝。安裝 TensorFlow 時,需要根據電腦是否 NVIDIAGPU 顯卡來確定是安裝性能更強的GPU 版本還是性能一般的 CPU 版本。

國內使用 pip 命令安裝時,可能會出現下載速度緩慢甚至連接斷開的情況,需要配置國內的 pip 源,只需要在 pip install 命令後面帶上“-i 源地址”即可,例如使用清華源安裝numpy 包,首先打開 cmd 命令行程序,輸入:

# 使用國內清華源安裝 numpy
pip install numpy -i https://pypi.tuna.tsinghua.edu.cn/simple

即可自動下載並按着 numpy 庫,配置上國內源的 pip 下載速度會提升顯著。現在我們來 TensorFlow GPU 最新版本:

# 使用清華源安裝 TensorFlow GPU 版本
pip install -U tensorflow-gpu -i https://pypi.tuna.tsinghua.edu.cn/simple

上述命令自動下載 TensorFlow GPU 版本並安裝,目前是 TensorFlow 2.0.0 正式版,“-U”參數指定如果已安裝此包,則執行升級命令。

現在我們來測試 GPU 版本的 TensorFlow 是否安裝成功。在 cmd 命令行輸入 ipython 進入 ipython 交互式終端,輸入“import tensorflow as tf”命令,如果沒有錯誤產生,繼續輸入“tf.test.is_gpu_available()”測試 GPU 是否可用,此命令會打印出一系列以“I”開頭的信息(Information),其中包含了可用的 GPU 顯卡設備信息,最後會返回“True”或者“False”,代表了 GPU 設備是否可用,如圖 1.35 所示。如果爲 True,則 TensorFlow GPU版本安裝成功;如果爲 False,則安裝失敗,需要再次檢測 CUDA,cuDNN,環境變量等步驟,或者複製錯誤,從搜索引擎中尋求幫助。
在這裏插入圖片描述
如果不能安裝 TensorFlow GPU 版本,則可以安裝 CPU 版本暫時用作學習。CPU 版本無法利用 GPU 加速運算,計算速度相對緩慢,但是作爲學習介紹的算法模型一般不大,使用 CPU 版本也能勉強應付,待日後對深度學習有了一定了解再升級 NVIDIA GPU 設備也未嘗不可。亦或者,安裝 TensorFlow GPU 版本可能容易出現安裝失敗的情況,很多讀者朋友動手能力一般,如果折騰了很久還不能搞定,可以直接安裝 CPU 版本先使用着。

安裝 CPU 版本的命令爲:

# 使用國內清華源安裝 TensorFlow CPU 版本
pip install -U tensorflow -i https://pypi.tuna.tsinghua.edu.cn/simple

安裝完後,在 ipython 中輸入“import tensorflow as tf”命令即可驗證 CPU 版本是否安裝成功。

TensorFlow GPU/CPU 版本安裝完成後,可以通過“tf.version”查看本地安裝的TensorFlow 版本號,如圖 1.36所示
在這裏插入圖片描述
常用的 python 庫也可以順帶安裝:

# 使用清華源安裝常用 python 庫
pip install -U numpy matplotlib pillow pandas -i https://pypi.tuna.tsinghua.edu.cn/simple
1.6.4 常用編輯器安裝

使用 Python 語言編寫程序的方式非常多,可以使用 ipython 或者 ipython notebook 方式交互式編寫代碼,也可以利用 Sublime Text,PyCharm 和 VS Code 等綜合 IDE 開發中大型項目。本書推薦使用 PyCharm 編寫和調試,使用 VS Code 交互式開發,這兩者都可以免費使用,用戶自行下載安裝,並配置 Python 解釋器,限於篇幅,不再敖述。接下來,讓我們開啓深度學習之旅吧!

第2章 迴歸問題

有些人擔心人工智能會讓人類覺得自卑,但是實際上,即使是看到一朵花,我們也應該或多或少感到一些自愧不如。−艾倫·凱

2.1 神經元模型

成年人大腦中包含了約 1000 億個神經元,每個神經元通過樹突獲取輸入信號,通過軸突傳遞輸出信號,神經元之間相互連接構成了巨大的神經網絡,從而形成了人腦的感知和意識基礎,圖 2.1 是一種典型的生物神經元結構。1943 年,心理學家沃倫·麥卡洛克(Warren McCulloch)和數理邏輯學家沃爾特·皮茨(Walter Pitts)通過對生物神經元的研究,提出了模擬生物神經元機制的人工神經網絡的數學模型 (McCulloch & Pitts, 1943),這一成果被美國神經學家弗蘭克·羅森布拉特(Frank Rosenblatt)進一步發展成感知機(Perceptron)模型,這也是現代深度學習的基石。
在這裏插入圖片描述
我們將從生物神經元的結構出發,重溫科學先驅們的探索之路,逐步揭開自動學習機器的神祕面紗。

首先,我們把生物神經元(Neuron)的模型抽象爲如圖 2.2(a)所示的數學結構:神經元輸入向量𝒙 = [𝑥1,   𝑥2, 𝑥3, … , 𝑥𝑛]T,經過函數映射:𝑓𝜃: 𝒙 → 𝑦後得到輸出𝑦,其中𝜃爲函數𝑓自身的參數。考慮一種簡化的情況,即線性變換:𝑓(𝒙) = 𝒘T𝒙 + 𝑏,展開爲標量形式:
𝑓(𝒙) = 𝑤1𝑥1 + 𝑤2𝑥2 + 𝑤3𝑥3 + ⋯ + 𝑤𝑛𝑥𝑛 + 𝑏
上述計算邏輯可以通過圖 2.2(b)直觀地展現
在這裏插入圖片描述
參數𝜃 ∶= {𝑤1, 𝑤2, 𝑤3, . . . , 𝑤𝑛,𝑏}確定了神經元的狀態,通過固定𝜃參數即可確定此神經元的處理邏輯。當神經元輸入節點數𝑛 = 1(單輸入)時,神經元數學模型可進一步簡化爲:𝑦 = 𝑤𝑥 + 𝑏

此時我們可以繪製出神經元的輸出𝑦和輸入𝑥的變化趨勢,如圖 2.3 所示,隨着輸入信號𝑥的增加,輸出電平𝑦也隨之線性增加,其中𝑤參數可以理解爲直線的斜率(Slope),b 參數爲直線的偏置(Bias)
在這裏插入圖片描述
對於某個神經元來說,𝑥和𝑦的映射關係fw,bf_{w,b}是未知但確定的。兩點即可確定一條直線,爲了估計𝑤和𝑏的值,我們只需從圖 2.3 中直線上採樣任意 2 個數據點:
(𝑥^{1}, 𝑦(1)), (𝑥(2), 𝑦(2))即可,其中上標表示數據點編號:
𝑦(1) = 𝑤𝑥(1) + 𝑏
𝑦(2) = 𝑤𝑥(2) + 𝑏

當(𝑥(1),𝑦(1)) ≠ (𝑥(2),𝑦(2))時,通過求解上式便可計算出𝑤和𝑏的值。考慮某個具體的例子:𝑥(1) = 1, 𝑦
(1) = 1.56 , 𝑥(2) = 2, 𝑦(2) = 3. 3, 代入上式中可得:
1.56 = 𝑤 ∙ 1 + 𝑏
3. 3 = 𝑤 ∙ 2 + 𝑏

這就是我們初中時代學習過的二元一次方程組,通過消元法可以輕鬆計算出𝑤和𝑏的解析解:𝑤 = 1. 477, 𝑏 = 0.089 。

可以看到,只需要觀測兩個不同數據點,就可完美求解單輸入線性神經元模型的參數,對於𝑁輸入的現象神經元模型,只需要採樣𝑁 + 1組不同數據點即可,似乎線性神經元模型可以得到完美解決。那麼上述方法存在什麼問題呢?考慮對於任何採樣點,都有可能存在觀測誤差,我們假設觀測誤差變量𝜖屬於均值爲𝜇,方差爲𝜎2的正態分佈(NormalDistribution,或高斯分佈,Gaussian Distribution):𝒩(𝜇, 𝜎
2),則採樣到的樣本符合:
y=wx+b+ϵ,ϵN(μ,σ2)y=w x+b+\epsilon, \epsilon \sim \mathcal{N}\left(\mu, \sigma^{2}\right)

一旦引入觀測誤差後,即使簡單如線性模型,如果僅採樣兩個數據點,可能會帶來較大估計偏差。如圖 2.4 所示,圖中的數據點均帶有觀測誤差,如果基於藍色矩形塊的兩個數據點進行估計,則計算出的藍色虛線與真實橙色直線存在較大偏差。爲了減少觀測誤差引入的估計偏差,可以通過採樣多組數據樣本集合𝔻 ={(x(1),y(1)),(x(2),y(2)),,(x(n),y(n))}\left\{\left(x^{(1)}, y^{(1)}\right),\left(x^{(2)}, y^{(2)}\right), \ldots,\left(x^{(n)}, y^{(n)}\right)\right\},然後找出一條“最好”的直線,使得它儘可能地讓所有采樣點到該直線的誤差(Error,或損失 Loss)之和最小。
在這裏插入圖片描述
也就是說,由於觀測誤差𝜖的存在,當我們採集了多個數據點𝔻時,可能不存在一條直線完美的穿過所有采樣點。退而求其次,我們希望能找到一條比較“好”的位於採樣點中間的直線。那麼怎麼衡量“好”與“不好”呢?一個很自然的想法就是,求出當前模型的所有采樣點上的預測值𝑤𝑥(𝑖) + 𝑏與真實值𝑦(𝑖)之間的差的平方和作爲總誤差ℒ:
L=1ni=1n(wx(i)+by(i))2\mathcal{L}=\frac{1}{n} \sum_{i=1}^{n}\left(w x^{(i)}+b-y^{(i)}\right)^{2}
然後搜索一組參數𝑤∗, 𝑏∗使得ℒ最小,對應的直線就是我們要尋找的最優直線:
w,b=argminw,b1ni=1n(wx(i)+by(i))2w^{*}, b^{*}=\underset{w, b}{\operatorname{argmin}} \frac{1}{n} \sum_{i=1}^{n}\left(w x^{(i)}+b-y^{(i)}\right)^{2}
其中𝑛表示採樣點的個數。這種誤差計算方法稱爲均方誤差(Mean Squared Error,簡稱
MSE)。

2.2 優化方法

現在來小結一下上述方案:我們需要找出最優參數(Optimal Parameter)𝑤∗和𝑏∗,使得輸入和輸出滿足線性關係𝑦(𝑖) = 𝑤𝑥(𝑖) + 𝑏, 𝑖 ∈ [1, 𝑛]。但是由於觀測誤差𝜖的存在,需要通過採樣足夠多組的數據樣本組成的數據集(Dataset):𝔻 ={(𝑥(1),𝑦(1)), (𝑥(2),𝑦(2)),… , (𝑥(𝑛), 𝑦(𝑛))},找到一組最優的參數 𝑤∗, 𝑏∗使得均方差ℒ =1𝑛 (𝑤𝑥(𝑖) + 𝑏 − 𝑦(𝑖))2 𝑛𝑖=1 最小。

對於單輸入的神經元模型,只需要兩個樣本,能通過消元法求出方程組的精確解,這種通過嚴格的公式推導出的精確解稱爲解析解(Closed-form Solution)。但是對於多個數據點(𝑛 ≫ 2)的情況,這時很有可能不存在解析解,我們只能藉助數值方法去優化(Optimize)出一個近似的數值解(Numerical Solution)。爲什麼叫作優化?這是因爲計算機的計算速度非常快,我們可以藉助強大的計算能力去多次“搜索”和“試錯”,從而一步步降低誤差ℒ。最簡單的優化方法就是暴力搜索或隨機試驗,比如要找出最合適的w∗和𝑏∗,我們就可以從(部分)實數空間中隨機採樣任意的𝑤和𝑏,並計算出對應模型的誤差值ℒ,然後從測試過的{ℒ}中挑出最好的ℒ∗,它所對應的𝑤和𝑏就可以作爲我們要找的最優w∗和𝑏∗

這種算法固然簡單直接,但是面對大規模、高維度數據的優化問題時計算效率極低,基本不可行。梯度下降算法(Gradient Descent)是神經網絡訓練中最常用的優化算法,配合強大的圖形處理芯片 GPU(Graphics Processing Unit)的並行加速能力,非常適合優化海量數據的神經網絡模型,自然也適合優化我們這裏的神經元線性模型。這裏先簡單地應用梯度下降算法,用於解決神經元模型預測的問題。由於梯度下降算法是深度學習的核心算法,我們將在第 7 章非常詳盡地推導梯度下降算法在神經網絡中的應用,這裏先給讀者第一印象。

我們在高中時代學過導數(Derivative)的概念,如果要求解一個函數的極大、極小值,可以簡單地令導數函數爲 0,求出對應的自變量點(稱爲駐點),再檢驗駐點類型即可。以函數𝑓(𝑥) = x(2)x^{(2)}∙ 𝑠𝑖𝑛 (𝑥)爲例,我們繪製出函數及其導數在𝑥 ∈ [−1 ,1 ]區間曲線,其中藍色實線爲𝑓(𝑥),黃色虛線爲df(x)dx\frac{\mathrm{d} f(x)}{\mathrm{d} x},如圖 2.5 所示。可以看出,函數導數(虛線)爲 0 的點即爲𝑓(𝑥)的駐點,函數的極大值和極小值點均出現在駐點中
在這裏插入圖片描述
函數的梯度(Gradient)定義爲函數對各個自變量的偏導數(Partial Derivative)組成的向量。考慮 3 維函數𝑧 = 𝑓(𝑥, 𝑦),函數對自變量𝑥的偏導數記爲zx\frac{\partial z}{\partial x},函數對自變量y的偏導數記爲zy\frac{\partial z}{\partial y},則梯度∇𝑓爲向量(zx,zy)\left(\frac{\partial z}{\partial x}, \frac{\partial z}{\partial y}\right)。我們通過一個具體的函數來感受梯度的性質,如圖 2.6所示,f(x,y)=(cos2x+cos2y)2f(x, y)=-\left(\cos ^{2} x+\cos ^{2} y\right)^{2},圖中𝑥𝑦平面的紅色箭頭的長度表示梯度向量的模,箭頭的方向表示梯度向量的方向。可以看到,箭頭的方向總是指向當前位置函數值增速最大的方向,函數曲面越陡峭,箭頭的長度也就越長,梯度的模也越大。
在這裏插入圖片描述
通過上面的例子,我們能直觀地感受到,函數在各處的梯度方向∇𝑓總是指向函數值增大的方向,那麼梯度的反方向−∇𝑓應指向函數值減少的方向。利用這一性質,我們只需要按照
x=xηfx^{\prime}=x-\eta \cdot \nabla f
來迭代更新𝒙′,就能獲得越來越小的函數值,其中𝜂用來縮放梯度向量,一般設置爲某較小的值,如 0.01,0.001 等。特別地,對於一維函數,上述向量形式可以退化成標量形式:
x=xηdydxx^{\prime}=x-\eta \cdot \frac{\mathrm{d} y}{\mathrm{d} x}
通過上式迭代更新𝑥′若干次,這樣得到的𝑥′處的函數值𝑦′,總是更有可能比在𝑥處的函數值𝑦小。

通過上面公式優化參數的方法稱爲梯度下降算法,它通過循環計算函數的梯度∇𝑓並更新待優化參數𝜃,從而得到函數𝑓獲得極小值時參數𝜃的最優數值解。需要注意的是,在深度學習中,一般𝒙表示模型輸入,模型的待優化參數一般用𝜃、𝑤、𝑏等符號表示。

現在我們將應用速學的梯度下降算法來求解𝑤∗和𝑏∗參數。這裏要最小化的是均方差誤差函數ℒ:
L=1ni=0n(wx(i)+by(i))2\mathcal{L}=\frac{1}{n} \sum_{i=0}^{n}\left(w x^{(i)}+b-y^{(i)}\right)^{2}
需要優化的模型參數是𝑤和𝑏,因此我們按照
w=wηLwb=bηLb\begin{array}{l} w^{\prime}=w-\eta \frac{\partial \mathcal{L}}{\partial w} \\ b^{\prime}=b-\eta \frac{\partial \mathcal{L}}{\partial b} \end{array}
方式循環更新參數。

2.3 線性模型實戰

在介紹了用於優化𝑤和𝑏的梯度下降算法後,我們來實戰訓練單輸入神經元線性模型。首先我們需要採樣自真實模型的多組數據,對於已知真實模型的玩具樣例(Toy Example),我們直接從指定的𝑤 = 1.477 , 𝑏 = 0.089 的真實模型中直接採樣:
y=1.477x+0.089y=1.477 * x+0.089

1. 採樣數據

爲了能夠很好地模擬真實樣本的觀測誤差,我們給模型添加誤差自變量𝜖,它採樣自均值爲 0,方差爲 0.01 的高斯分佈:
y=1.477x+0.089+ϵ,ϵN(0,0.01)y=1.477 x+0.089+\epsilon, \epsilon \sim \mathcal{N}(0,0.01)
通過隨機採樣𝑛 = 100 次,我們獲得𝑛個樣本的訓練數據集𝔻train:

import numpy as np
data=[] #保存樣本集的列表
for i in range(100): #循環採樣100個點
  x=np.random.uniform(-10.,10.) #隨機採樣輸入x
  # 採樣高斯噪聲
  eps=np.random.normal(0.,0.1) # 均值和方差
  # 得到模型的輸出
  y=1.477*x+0.089+eps
  data.append([x,y]) #保存樣本點
data=np.array(data)# 轉換爲2D Numpy數組
print(data)

循環進行 100 次採樣,每次從區間[-10, 10]的均勻分佈U( ,1)中隨機採樣一個數據𝑥,同時從均值爲 0,方差爲 0.120.1^{2}的高斯分佈𝒩( 0, 0.120.1^{2})中隨機採樣噪聲𝜖,根據真實模型生成𝑦的數據,並保存爲 Numpy數組。

2. 計算誤差
循環計算在每個點(𝑥(𝑖), 𝑦(𝑖))處的預測值與真實值之間差的平方並累加,從而獲得訓練集上的均方差損失值.

def mse(b,w,points):
  totalError=0  # 根據當前的w,b參數計算均方差損失
  for i in range(0,len(points)): # 循環迭代所有點
    x=points[i,0] #獲得i號點的輸入x
    y=points[i,1]  #獲得i號點的輸出y
    # 計算差的平方,並累加
    totalError+=(y-(w*x+b))**2
  # 將累加的誤差求平均,得到均方誤差
  return totalError/float(len(points))

最後的誤差和除以數據樣本總數,從而得到每個樣本上的平均誤差。

3. 計算梯度

根據之前介紹的梯度下降算法,我們需要計算出函數在每一個點上的梯度信息:(Lw,Lb)\left(\frac{\partial \mathcal{L}}{\partial w}, \frac{\partial \mathcal{L}}{\partial b}\right)。我們來推導一下梯度的表達式,首先考慮Lw\frac{\partial \mathcal{L}}{\partial w},將均方差函數展開:
Lw=1ni=1n(wx(i)+by(i))2w=1ni=1n(wx(i)+by(i))2w\frac{\partial \mathcal{L}}{\partial w}=\frac{\partial \frac{1}{n} \sum_{i=1}^{n}\left(w x^{(i)}+b-y^{(i)}\right)^{2}}{\partial w}=\frac{1}{n} \sum_{i=1}^{n} \frac{\partial\left(w x^{(i)}+b-y^{(i)}\right)^{2}}{\partial w}
考慮到
g2w=2ggw\frac{\partial g^{2}}{\partial w}=2 \cdot g \cdot \frac{\partial g}{\partial w}
因此
在這裏插入圖片描述
如果難以理解上述推導,可以複習數學中函數的梯度相關課程,同時在本書第 7 章也會詳細介紹,我們可以記住Lw\frac{\partial \mathcal{L}}{\partial w}的最終表達式即可。用同樣的方法,我們可以推導偏導數Lb\frac{\partial \mathcal{L}}{\partial b}的表達式:
Lb=1ni=1n(wx(i)+by(i))2b=1ni=1n(wx(i)+by(i))2b=1ni=1n2(wx(i)+by(i))(wx(i)+by(i))b=1ni=1n2(wx(i)+by(i))1=2ni=1n(wx(i)+by(i))\begin{array}{c} \frac{\partial \mathcal{L}}{\partial b}=\frac{\partial \frac{1}{n} \sum_{i=1}^{n}\left(w x^{(i)}+b-y^{(i)}\right)^{2}}{\partial b}=\frac{1}{n} \sum_{i=1}^{n} \frac{\partial\left(w x^{(i)}+b-y^{(i)}\right)^{2}}{\partial b} \\ =\frac{1}{n} \sum_{i=1}^{n} 2\left(w x^{(i)}+b-y^{(i)}\right) \cdot \frac{\partial\left(w x^{(i)}+b-y^{(i)}\right)}{\partial b} \\ =\frac{1}{n} \sum_{i=1}^{n} 2\left(w x^{(i)}+b-y^{(i)}\right) \cdot 1 \\ =\frac{2}{n} \sum_{i=1}^{n}\left(w x^{(i)}+b-y^{(i)}\right) \end{array}
根據上面偏導數的表達式,我們只需要計算在每一個點上面的(𝑤𝑥(𝑖) + 𝑏 − 𝑦(𝑖)) ∙𝑥(𝑖)和(𝑤𝑥(𝑖) + 𝑏 − 𝑦(𝑖)
)值,平均後即可得到偏導數Lw\frac{\partial \mathcal{L}}{\partial w}Lb\frac{\partial \mathcal{L}}{\partial b}。實現如下:

def step_gradient(b_current,w_current,points,lr):
  # 計算誤差函數在所有點上的異數,並更新w,b
  b_gradirnt=0
  w_gradient=0
  M=float(len(points))# 總體樣本
  for i in range(0,len(points)):
    x=points[i,0]
    y=points[i,1]
    # 誤差函數對b的導數;grad_b=2(wx+b-y)
    b_gradirnt+=(2/M) *((w_current*x+b_current)-y)
    # 誤差函數對w的求導:grad_w=2(wx+b-y)*x
    w_gradient=w_gradient+(2/M)*x*((w_current*x+b_current)-y)
  # 根據梯度下降算法更新的 w',b',其中lr爲學習率
  new_b=b_current-(lr*b_gradirnt)
  new_w=w_current-(lr*w_gradient)
  return [new_b,new_w]

4. 梯度更新

在計算出誤差函數在𝑤和𝑏處的梯度後,我們可以根據公式來更新𝑤和𝑏的值。我們把對數據集的所有樣本訓練一次稱爲一個 Epoch,共循環迭代 num_iterations 個 Epoch。實現如下:

def gradient_descent(points,starting_b,starting_w,lr,num_iterations):
  # 循環更新w,b多次
  b=starting_b #b的初始值
  w=starting_w #w的初始值
  #根據梯度下降算法更新多次
  for step in range(num_iterations):
    # 計算梯度並跟新一次
    b,w=step_gradient(b,w,np.array(points),lr)
    loss=mse(b,w,points) #計算當前的均方誤差,用於監控訓練進度
    if step%50==0: #打印誤差和實時的w,b值
      print("iteration:{},loss:{},w:{},b:{}".format(step,loss,w,b))
  return [b,w] #返回最後一次的w,b

主訓練函數實現如下:

def main():
  # 加載訓練數據集,這些數據是通過真實模型添加觀測誤差採集的到的
  lr=0.01 # 學習率
  initial_b=0 # 初始化b爲0
  initial_w=0 # 初始化w爲0
  num_iteration=1000
  # 訓練優化1000次,返回最優 w*,b*和訓練Loss的下降過程
  [b,w],losses=gradient_descent(data,initial_b,initial_w,lr,num_iteration)
  loss=mse(b,w,data)# 計算最優數值w,b的均方誤差
  print('Final loss:{},w:{},b:{}'.format(loss,w,b))

經過 1000 的迭代更新後,保存最後的𝑤和𝑏值,此時的𝑤和𝑏的值就是我們要找的w∗和𝑏∗數值解。運行結果如下:
iteration:0, loss:11.437586448749, w:0.88955725981925, b:0.02661765516748428
iteration:50, loss:0.111323083882350, w:1.48132089048970, b:0.58389075913875
iteration:100, loss:0.02436449474995, w:1.479296279074, b:0.78524532356388

iteration:950, loss:0.01097700897880, w:1.478131231919, b:0.901113267769968
Final loss:0.010977008978805611, w:1.4781312318924746, b:0.901113270434582
可以看到,第 100 次迭代時,𝑤和𝑏的值就已經比較接近真實模型了,更新 1000 次後得到的𝑤∗和𝑏∗數值解與真實模型的非常接近,訓練過程的均方差變化曲線如圖 2.7 所示。
在這裏插入圖片描述
上述例子比較好地展示了梯度下降算法在求解模型參數上的強大之處。需要注意的是,對於複雜的非線性模型,通過梯度下降算法求解到的𝑤和𝑏可能是局部極小值而非全局最小值解,這是由模型函數的非凸性決定的。但是我們在實踐中發現,通過梯度下降算法求得的數值解,它的性能往往都能優化得很好,可以直接使用求解到的數值解𝑤和𝑏來近似作爲最優解。

2.4 線性迴歸

簡單回顧一下我們的探索之路:首先假設𝑛個輸入的生物神經元的數學模型爲線性模型之後,只採樣𝑛 + 1個數據點就可以估計線性模型的參數𝒘和𝑏。引入觀測誤差後,通過梯度下降算法,我們可以採樣多組數據點循環優化得到𝒘和𝑏的數值解。

如果我們換一個角度來看待這個問題,它其實可以理解爲一組連續值(向量)的預測問題。給定數據集𝔻,我們需要從𝔻中學習到數據的真實模型,從而預測未見過的樣本的輸出值。在假定模型的類型後,學習過程就變成了搜索模型參數的問題,比如我們假設神經元爲線性模型,那麼訓練過程即爲搜索線性模型的𝒘和𝑏參數的過程。訓練完成後,利用學到的模型,對於任意的新輸入𝒙,我們就可以使用學習模型輸出值作爲真實值的近似。從這個角度來看,它就是一個連續值的預測問題

在現實生活中,連續值預測問題是非常常見的,比如股價的走勢預測、天氣預報中溫度和溼度等的預測、年齡的預測、交通流量的預測等。對於預測值是連續的實數範圍,或者屬於某一段連續的實數區間,我們把這種問題稱爲迴歸(Regression)問題。特別地,如果使用線性模型去逼近真實模型,那麼我們把這一類方法叫做線性迴歸(Linear Regression,簡稱 LR),線性迴歸是迴歸問題中的一種具體的實現。

除了連續值預測問題以外,是不是還有離散值預測問題呢?比如說硬幣正反面的預測,它的預測值𝑦只可能有正面或反面兩種可能;再比如說給定一張圖片,這張圖片中物體的類別也只可能是像貓、狗、天空之類的離散類別值。對於這一類問題,我們把它稱爲分類(Classification)問題。接下來我們來挑戰分類問題吧!

第3章 分類問題

在人工智能上花一年時間,這足以讓人相信上帝的存在。−艾倫·佩利

前面已經介紹了用於連續值預測的線性迴歸模型,現在我們來挑戰分類問題。分類問題的一個典型應用就是教會機器如何去自動識別圖片中物體的種類。考慮圖片分類中最簡單的任務之一:0~9 數字圖片識別,它相對簡單,而且也具有非常廣泛的應用價值,比如郵政編碼、快遞單號、手機號碼等都屬於數字圖片識別範疇。我們將以數字圖片識別爲例,探索如何用機器學習的方法去解決這個問題。

3.1 手寫數字圖片數據集

機器學習需要從數據中間學習,首先我們需要採集大量的真實樣本數據。以手寫的數字圖片識別爲例,如圖 3.1 所示,我們需要收集大量的由真人書寫的 0~9的數字圖片,爲了便於存儲和計算,一般把收集的原始圖片縮放到某個固定的大小(Size 或 Shape),比如224 個像素的行和 224 個像素的列(224 × 224),或者 96 個像素的行和 96 個像素的列(96 × 96),這張圖片將作爲輸入數據 x。同時,我們需要給每一張圖片標註一個標籤(Label),它將作爲圖片的真實值𝑦,這個標籤表明這張圖片屬於哪一個具體的類別,一般通過映射方式將類別名一一對應到從 0 開始編號的數字,比如說硬幣的正反面,我們可以用0 來表示硬幣的反面,用 1 來表示硬幣的正面,當然也可以反過來 1 表示硬幣的反面,這種編碼方式叫作數字編碼(Number Encoding)。對於手寫數字圖片識別問題,編碼更爲直觀,我們用數字的 0~9 來表示類別名字爲 0~9 的圖片。
在這裏插入圖片描述
如果希望模型能夠在新樣本上也能具有良好的表現,即模型泛化能力(GeneralizationAbility)較好,那麼我們應該儘可能多地增加數據集的規模和多樣性(Variance),使得我們用於學習的訓練數據集真實的手寫數字圖片的分佈(Ground-truth Distribution)儘可能的逼近,這樣在訓練數據集上面學到了模型能夠很好的用於未見過的手寫數字圖片的預測。

爲了方便業界統一測試和評估算法, (Lecun, Bottou, Bengio, & Haffner, 1998)發佈了手寫數字圖片數據集,命名爲 MNIST,它包含了 0~9 共 10 種數字的手寫圖片,每種數字一共有 7000 張圖片,採集自不同書寫風格的真實手寫圖片,一共 70000 張圖片。其中 60000張圖片作爲訓練𝔻train(Training Set),用來訓練模型,剩下 10000 張圖片作爲測試集𝔻test(Test Set),用來預測或者測試,訓練集和測試集共同組成了整個 MNIST 數據集。

考慮到手寫數字圖片包含的信息比較簡單,每張圖片均被縮放到28 × 28的大小,同時只保留了灰度信息,如圖 3.2 所示。這些圖片由真人書寫,包含了如字體大小、書寫風格、粗細等豐富的樣式,確保這些圖片的分佈與真實的手寫數字圖片的分佈儘可能的接近,從而保證了模型的泛化能力。
在這裏插入圖片描述
現在我們來看下圖片的表示方法。一張圖片包含了ℎ行(Height/Row),𝑤列(Width/Column),每個位置保存了像素(Pixel)值,像素值一般使用 0~255 的整形數值來表達顏色強度信息,例如 0 表示強度最低,255 表示強度最高。如果是彩色圖片,則每個像素點包含了 R、G、B 三個通道的強度信息,分別代表紅色通道、綠色通道、藍色通道的顏色強度,所以與灰度圖片不同,它的每個像素點使用一個 1 維、長度爲 3 的向量(Vector)來表示,向量的 3 個元素依次代表了當前像素點上面的 R、G、B 顏色強值,因此彩色圖片需要保存爲形狀是[ℎ, 𝑤, 3]的張量(Tensor,可以通俗地理解爲 3 維數組)。如果是灰度圖片,則使用一個數值來表示灰度強度,例如 0 表示純黑,255 表示純白,因此它只需要一個形爲[ℎ, 𝑤]的二維矩陣(Matrix)來表示一張圖片信息(也可以保存爲[ℎ, 𝑤, 1]形狀的張量)。圖 3.3 演示了內容爲 8 的數字圖片的矩陣內容,可以看到,圖片中黑色的像素用 0 表示,灰度信息用 0~255 表示,圖片中灰度越白的像素點,對應矩陣位置中數值也就越大。
在這裏插入圖片描述
目前常用的深度學習框架,如 TensorFlow,PyTorch 等,都可以非常方便的通過數行代碼自動下載、管理和加載 MNIST 數據集,不需要我們額外編寫代碼,使用起來非常方便。我們這裏利用TensorFlow 自動在線下載 MNIST 數據集,並轉換爲 Numpy 數組格式:

import os
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers,optimizers,datasets

(x,y),(x_val,y_val)=datasets.mnist.load_data()#60000訓練集/10000測試集
x=2*tf.convert_to_tensor(x,dtype=tf.float32)/255-1 #轉換爲張量,縮放到-1~1 60000*28*28
y=tf.one_hot(y,depth=10)#one-hot編碼  60000,10
print(x.shape,y.shape)
train_dataset=tf.data.Dataset.from_tensor_slices((x,y))#構建數據集對象
train_dataset=train_dataset.batch(512)#批量訓練

load_data()函數返回兩個元組(tuple)對象,第一個是訓練集,第二個是測試集,每個 tuple的第一個元素是多個訓練圖片數據X,第二個元素是訓練圖片對應的類別數字Y。其中訓練集X的大小爲(60000,28,28),代表了 60000 個樣本,每個樣本由 28 行、28 列構成,由於是灰度圖片,故沒有 RGB 通道;訓練集Y的大小爲(60000, ),代表了這 60000 個樣本的標籤數字,每個樣本標籤用一個 0~9 的數字表示。測試集 X 的大小爲(10000,28,28),代表了10000 張測試圖片,Y 的大小爲(10000, )。

從 TensorFlow 中加載的 MNIST 數據圖片,數值的範圍在[0,255]之間。在機器學習中間,一般希望數據的範圍在 0 周圍小範圍內分佈。通過預處理步驟,我們把[0,255]像素範圍歸一化(Normalize)到[0,1.]區間,再縮放到[−1,1]區間,從而有利於模型的訓練。

每一張圖片的計算流程是通用的,我們在計算的過程中可以一次進行多張圖片的計算,充分利用 CPU 或 GPU 的並行計算能力。一張圖片我們用 shape 爲[h, w]的矩陣來表示,對於多張圖片來說,我們在前面添加一個數量維度(Dimension),使用 shape 爲[𝑏, ℎ, 𝑤]的張量來表示,其中的𝑏代表了 batch size(批量);多張彩色圖片可以使用 shape 爲[𝑏, ℎ, 𝑤, 𝑐]的張量來表示,其中的𝑐表示通道數量(Channel),彩色圖片𝑐 = 3。通過 TensorFlow 的Dataset 對象可以方便完成模型的批量訓練,只需要調用 batch()函數即可構建帶 batch 功能的數據集對象。

3.2 模型構建

回顧我們在迴歸問題討論的生物神經元結構。我們把一組長度爲dind_{in}的輸入向量𝒙 =[𝑥1, 𝑥2, … , 𝑥𝑑𝑖𝑛]𝑇簡化爲單輸入標量 x,模型可以表達成𝑦 = 𝑥 ∗ 𝑤 + 𝑏。如果是多輸入、單輸出的模型結構的話,我們需要藉助於向量形式:
y=wTx+b=[w1,w2,w3,,wdin][x1x2x3xdin]+by=\boldsymbol{w}^{T} \boldsymbol{x}+b=\left[w_{1}, w_{2}, w_{3}, \ldots, w_{d_{i n}}\right] \cdot\left[\begin{array}{c} x_{1} \\ x_{2} \\ x_{3} \\ \ldots \\ x_{d_{i n}} \end{array}\right]+b
更一般地,通過組合多個多輸入、單輸出的神經元模型,可以拼成一個多輸入、多輸出的模型:
y=Wx+by=W x+b
其中,xRdin,bRdout,yRdout,WRdout×din\boldsymbol{x} \in R^{d_{i n}}, \boldsymbol{b} \in R^{d_{o u t}}, \boldsymbol{y} \in R^{d_{o u t}}, W \in R^{d_{o u t} \times d_{i n}}

對於多輸出節點、批量訓練方式,我們將模型寫成張量形式:
Y=X@W+bY=X @ W+b
其中XRb×din,bRdout,YRb×dout,WRdin×doutX \in R^{b \times d_{i n}}, \boldsymbol{b} \in R^{d_{o u t}}, \quad Y \in R^{b \times d_{o u t}}, \quad W \in R^{d_{i n} \times d_{o u t}}dind_{in}表示輸入節點數,doutd_{out}表示輸出節點數;X shape 爲[𝑏, dind_{in}],表示𝑏個樣本的輸入數據,每個樣本的特徵長度爲𝑑𝑖𝑛;W的 shape 爲[dind_{in},doutd_{out}],共包含了dind_{in}*doutd_{out}個網絡參數;偏置向量𝒃 shape 爲doutd_{out},每個輸出節點上均添加一個偏置值;@符號表示矩陣相乘(Matrix Multiplication,matmul)。

考慮 2 個樣本,輸入特徵長度dind_{in}= 3,輸出特徵長度doutd_{out}= 2的模型,公式展開爲
[o11o21o12o22]=[x11x21x31x12x22x32][w11w12w21w22w31w32]+[b1b2]\left[\begin{array}{cc} o_{1}^{1} & o_{2}^{1} \\ o_{1}^{2} & o_{2}^{2} \end{array}\right]=\left[\begin{array}{ccc} x_{1}^{1} & x_{2}^{1} & x_{3}^{1} \\ x_{1}^{2} & x_{2}^{2} & x_{3}^{2} \end{array}\right]\left[\begin{array}{cc} w_{11} & w_{12} \\ w_{21} & w_{22} \\ w_{31} & w_{32} \end{array}\right]+\left[\begin{array}{c} b_{1} \\ b_{2} \end{array}\right]
其中x11x_{1}^{1}, o00o_{0}^{0}等符號的上標表示樣本索引號,下標表示樣本向量的元素。對應模型結構圖爲
在這裏插入圖片描述
可以看到,通過張量形式表達網絡結構,更加簡潔清晰,同時也可充分利用張量計算的並行加速能力。那麼怎麼將圖片識別任務的輸入和輸出轉變爲滿足格式要求的張量形式呢

考慮輸入格式,一張圖片𝒙使用矩陣方式存儲,shape 爲:[ℎ, 𝑤],𝑏張圖片使用 shape爲[𝑏, ℎ, 𝑤]的張量 X 存儲。而我們模型只能接受向量形式的輸入特徵向量,因此需要將[ℎ, 𝑤]的矩陣形式圖片特徵平鋪成[ℎ ∗ 𝑤]長度的向量,如圖 3.5 所示,其中輸入特徵的長度dind_{in} = ℎ ∗ 𝑤。
在這裏插入圖片描述
對於輸出標籤,前面我們已經介紹了數字編碼,它可以用一個數字來表示便籤信息,例如數字 1 表示貓,數字 3 表示魚等。但是數字編碼一個最大的問題是,數字之間存在天然的大小關係,比如1 < 2 < 3,如果 1、2、3 分別對應的標籤是貓、狗、魚,他們之間並沒有大小關係,所以採用數字編碼的時候會迫使模型去學習到這種不必要的約束。

那麼怎麼解決這個問題呢?可以將輸出設置爲doutd_{out}個輸出節點的向量,doutd_{out}與類別數相同,讓第𝑖 ∈ [1, doutd_{out}]個輸出值表示當前樣本屬於類別𝑖的概率𝑃(𝑥屬於類別𝑖|𝑥)。我們只考慮輸入圖片只輸入一個類別的情況,此時輸入圖片的真實的標註已經明確:如果物體屬於第𝑖類的話,那麼索引爲𝑖的位置上設置爲 1,其他位置設置爲 0,我們把這種編碼方式叫做 one-hot 編碼(獨熱編碼)。以圖 3.6 中的“貓狗魚鳥”識別系統爲例,所有的樣本只屬於“貓狗魚鳥”4 個類別中其一,我們將第1,2,3,4號索引位置分別表示貓狗魚鳥的類別,對於所有貓的圖片,它的數字編碼爲 0,One-hot 編碼爲[1,0,0,0];對於所有狗的圖片,它的數字編碼爲 1,One-hot 編碼爲[0,1,0,0],以此類推。
在這裏插入圖片描述
手寫數字圖片的總類別數有 10 種,即輸出節點數doutd_{out} = 10,那麼對於某個樣本,假設它屬於類別𝑖,即圖片的中數字爲𝑖,只需要一個長度爲 10 的向量𝐲,向量𝐲的索引號爲𝑖的元素設置爲 1,其他位爲 0。比如圖片 0 的 One-hot 編碼爲[1,0,0,… ,0],圖片 2 的 Onehot 編碼爲[0,0,1,… ,0],圖片 9 的One-hot 編碼爲[0,0,0, … ,1]。One-hot 編碼是非常稀疏(Sparse)的,相對於數字編碼來說,佔用較多的存儲空間,所以一般在存儲時還是採用數字編碼,在計算時,根據需要來把數字編碼轉換成 One-hot 編碼,通過 tf.one_hot即可實現

y=tf.constant([0,1,2,3])#數字編碼
y=tf.one_hot(y,depth=10)#one-hot編碼
print(y)

tf.Tensor(
[[1. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
 [0. 1. 0. 0. 0. 0. 0. 0. 0. 0.]
 [0. 0. 1. 0. 0. 0. 0. 0. 0. 0.]
 [0. 0. 0. 1. 0. 0. 0. 0. 0. 0.]], shape=(4, 10), dtype=float32)

現在我們回到手寫數字圖片識別任務,輸入是一張打平後的圖片向量𝒙 ∈ R2828^{28∗28},輸出
是一個長度爲 10 的向量 ∈ R10R^{10},圖片的真實標籤 y 經過 one-hot 編碼後變成長度爲 10 的非 0 即 1 的稀疏向量y{0,1}10y \in\{0,1\}^{10}。預測模型採用多輸入、多輸出的線性模型 = 𝑊𝑻𝒙 + 𝒃,其中模型的輸出記爲輸入的預測值 ,我們希望 越接近真實標籤𝒚越好。我們一般把輸入經過一次(線性)變換叫做一層網絡

3.3 誤差計算

對於分類問題來說,我們的目標是最大化某個性能指標,比如準確度 acc,但是把準確度當做損失函數去優化時會發現accθ\frac{\partial a c c}{\partial \theta}是不可導的,無法利用梯度下降算法優化網絡參數𝜃。一般的做法是,設立一個平滑可導的代理目標函數,比如優化模型的輸出 與 Onehot 編碼後的真實標籤𝒚之間的距離(Distance),通過優化代理目標函數得到的模型,一般在測試性能上也能有良好的表現。因此,相對迴歸問題而言,分類問題的優化目標函數和評價目標函數是不一致的。模型的訓練目標是通過優化損失函數ℒ來找到最優數值解W∗, 𝒃∗:
W,b=argminW,bL(o,y)\mathrm{W}^{*}, \boldsymbol{b}^{*}=\underbrace{\underset{W, \boldsymbol{b}}{\operatorname{argmin}} \mathcal{L}(\boldsymbol{o}, \boldsymbol{y})}
對於分類問題的誤差計算來說,更常見的是採用****交叉熵(Cross entropy)損失函數,而不是採用迴歸問題中介紹的均方差損失函數。我們將在後續章節介紹交叉熵損失函數,這裏還是採用 MSE 損失函數來求解手寫數字識別問題。對於𝑁個樣本的均方差損失函數可以表達爲:
L(o,y)=1Ni=1Nj=110(ojiyji)2\mathcal{L}(\boldsymbol{o}, \boldsymbol{y})=\frac{1}{N} \sum_{i=1}^{N} \sum_{j=1}^{10}\left(o_{j}^{i}-y_{j}^{i}\right)^{2}
現在我們只需要採用梯度下降算法來優化損失函數得到W, 𝒃的最優解,利用求得的模型去預測未知的手寫數字圖片𝒙 ∈ 𝔻𝑡𝑒𝑠𝑡。

3.4 真的解決了嗎

按照上面的方案,手寫數字圖片識別問題真的得到了完美的解決嗎?目前來看,至少存在兩大問題:

線性模型 線性模型是機器學習中間最簡單的數學模型之一,參數量少,計算簡單,但是隻能表達線性關係。即使是簡單如數字圖片識別任務,它也是屬於圖片識別的範疇,人類目前對於複雜大腦的感知和決策的研究尚處於初步探索階段,如果只使用一個簡單的線性模型去逼近複雜的人腦圖片識別模型,很顯然不能勝任

表達能力 上面的解決方案只使用了少量神經元組成的一層網絡模型,相對於人腦中千億級別的神經元互聯結構,它的表達能力明顯偏弱,其中表達能力體現爲逼近複雜分佈的能力

模型的表達能力與數據模態之間的示意圖如圖 3.7 所示,圖中繪製了帶觀測誤差的採樣點的分佈,人爲推測數據的真實分佈可能是某 2 次拋物線模型。如圖 3.7(a)所示,如果使用表達能力偏弱的線性模型去學習,很難學習到比較好的模型;如果使用合適的多項式函數模型去學習,則能學到比較合適的模型,如圖 3.7(b);但模型過於複雜,表達能力過強時,則很有可能會過擬合,傷害模型的泛化能力,如圖 3.7©。
在這裏插入圖片描述
目前我們所採用的多神經元模型仍是線性模型,表達能力偏弱,接下來我們嘗試解決這 2個問題。

3.5 非線性模型

既然線性模型不可行,我們可以給線性模型嵌套一個非線性函數,即可將其轉換爲非線性模型。我們把這個非線性函數稱爲激活函數(Activation function),用𝜎表示:
o=σ(Wx+b)\boldsymbol{o}=\sigma(\boldsymbol{W} \boldsymbol{x}+\boldsymbol{b})
這裏的𝜎代表了某個具體的非線性激活函數,比如 Sigmoid 函數(圖 3.8(a)),ReLU 函數(圖3.8(b))。
在這裏插入圖片描述
ReLU 函數非常簡單,僅僅是在𝑦 = 𝑥在基礎上面截去了𝑥 < 0的部分,可以直觀地理解爲 ReLU 函數僅僅保留正的輸入部份,清零負的輸入。雖然簡單,ReLU 函數卻有優良的非線性特性,而且梯度計算簡單,訓練穩定,是深度學習模型使用最廣泛的激活函數之一。我們這裏通過嵌套 ReLU 函數將模型轉換爲非線性模型:
o=ReLU(Wx+b)\boldsymbol{o}=\operatorname{ReLU}(\boldsymbol{W} \boldsymbol{x}+\boldsymbol{b})

3.6 表達能力

針對於模型的表達能力偏弱的問題,可以通過重複堆疊多次變換來增加其表達能力:
𝒉𝟏 = 𝑅𝑒𝐿𝑈(𝑾𝟏𝒙 + 𝒃𝟏)
𝒉𝟐 = 𝑅𝑒𝐿𝑈(𝑾𝟐𝒉𝟏 + 𝒃𝟐)
o\boldsymbol{o} = 𝑾𝟑𝒉𝟐 + 𝒃𝟑

把第一層神經元的輸出值𝒉𝟏作爲第二層神經元模型的輸入,把第二層神經元的輸出𝒉𝟐作爲第三層神經元的輸入,最後一層神經元的輸出作爲模型的輸出 。

從網絡結構上看,如圖 3.9 所示,函數的嵌套表現爲網絡層的前後相連,每堆疊一個(非)線性環節,網絡層數增加一層。我們把數據節點所在的層叫做輸入層,每一個非線性模塊的輸出𝒉𝒊連同它的網絡層參數𝑾𝒊和𝒃𝒊稱爲一層網絡層,特別地,對於網絡中間的層,叫做隱藏層,最後一層叫做輸出層。這種由大量神經元模型連接形成的網絡結構稱爲(前饋)神經網絡(Neural Network)。
在這裏插入圖片描述
現在我們的網絡模型已經升級爲爲 3 層的神經網絡,具有較好的非線性表達能力,接下來我們討論怎麼優化網絡。

3.7 優化方法

對於僅一層的網絡模型,如線性迴歸的模型,我們可以直接推導出Lw\frac{\partial L}{\partial w}Lb\frac{\partial L}{\partial b}的表達式,然後直接計算每一步的梯度,根據梯度更新法則循環更新𝑤, 𝑏參數即可。但是,當網絡層數增加數據特徵長度增大添加複雜的非線性函數之後,模型的表達式將變得非常複雜,很難手動推導出梯度的計算公式;而且一旦網絡結構發生變動,網絡的函數模型也隨之發生改變,依賴人工去計算梯度的方式顯然不可行。

這個時候就是深度學習框架發明的意義所在,藉助於自動求導(Autograd)技術,深度學習框架在計算函數的損失函數的過程中,會記錄模型的計算圖模型,並自動完成任意參數𝜃的偏導分Lθ\frac{\partial L}{\partial \theta}的計算,用戶只需要搭建出網絡結構,梯度將自動完成計算和更新,使用起來非常便捷高效。

3.8 手寫數字圖片識別體驗

本節我們將在未介紹 TensorFlow 的情況下,先帶大家體驗一下神經網絡的樂趣。本節的主要目的並不是教會每個細節,而是讓讀者對神經網絡算法有全面、直觀的感受,爲接下來介紹 TensorFlow 基礎和深度學習理論打下基礎。讓我們開始體驗神奇的圖片識別算法吧!

網絡搭建 對於第一層模型來說,他接受的輸入𝒙 ∈ R784R^{784},輸出𝒉𝟏 ∈ R256R^{256}設計爲長度爲 256的向量,我們不需要顯式地編寫𝒉𝟏 = 𝑅𝑒𝐿𝑈(𝑾𝟏𝒙 + 𝒃𝟏)的計算邏輯,在 TensorFlow 中通過一行代碼即可實現:

layers.Dense(256, activation='relu')

使用 TensorFlow 的 Sequential 容器可以非常方便地搭建多層的網絡。對於 3 層網絡,我們可以通過

keras.sequential([
    layers.Dense(256,activation='relu'),
    layers.Dense(128,activation='relu'),
    layers.Dense(10)])

快速完成 3 層網絡的搭建,第 1 層的輸出節點數設計爲 256,第 2 層設計爲 128,輸出層節點數設計爲 10。直接調用這個模型對象 model(x)就可以返回模型最後一層的輸出 。

模型訓練 得到模型輸出o\boldsymbol{o}後,通過 MSE 損失函數計算當前的誤差ℒ:

with tf.GradientTape() as tape:#構建梯度記錄環境
    #打平,[b,28,28] =>[b,784]
    x=tf.reshape(x,(-1,28*28))
    #step1. 得到模型輸出 output
    # [b,784] =>[b,10]
    out=model(x)

再利用 TensorFlow 提供的自動求導函數 tape.gradient(loss, model.trainable_variables)求出模型中所有的梯度信息Lθ\frac{\partial L}{\partial \theta} , 𝜃 ∈ {𝑊1, 𝒃𝟏,𝑊2, 𝒃𝟐,𝑊3, 𝒃𝟑}:

# Step3. 計算參數的梯度 w1, w2, w3, b1, b2, b3
 grads = tape.gradient(loss, model.trainable_variables)

計算獲得的梯度結果使用 grads 變量保存。再使用 optimizers 對象自動按着梯度更新法則
θ=θηLθ\theta^{\prime}=\theta-\eta * \frac{\partial \mathcal{L}}{\partial \theta}
去更新模型的參數𝜃。

grads = tape.gradient(loss, model.trainable_variables)
 # w' = w - lr * grad,更新網絡參數
optimizer.apply_gradients(zip(grads, model.trainable_variables))

循環迭代多次後,就可以利用學好的模型𝑓𝜃去預測未知的圖片的類別概率分佈。模型的測試部分暫不討論。

手寫數字圖片 MNIST 數據集的訓練誤差曲線如圖 3.10 所示,由於 3 層的神經網絡表達能力較強,手寫數字圖片識別任務簡單,誤差值可以較快速、穩定地下降,其中對數據集的所有圖片迭代一遍叫做一個 Epoch,我們可以在間隔數個 Epoch 後測試模型的準確率等指標,方便監控模型的訓練效果。
在這裏插入圖片描述

3.9 小結

本章我們通過將一層的線性迴歸模型類推到分類問題,提出了表達能力更強的三層非線性神經網絡,去解決手寫數字圖片識別的問題。本章的內容以感受爲主,學習完大家其實已經瞭解了(淺層)的神經網絡算法,接下來我們將學習 TensorFlow 的一些基礎知識,爲後續正式學習、實現深度學習算法打下夯實的基石。

第4章 TensorFlow 基礎

TensorFlow 是一個面向於深度學習算法的科學計算庫,內部數據保存在張量(Tensor)對象上,所有的運算操作(Operation, OP)也都是基於張量對象進行複雜的神經網絡算法本質上就是各種張量相乘、相加等基本運算操作的組合,在深入學習深度學習算法之前,熟練掌握 TensorFlow 張量的基礎操作方法十分重要。

4.1 數據類型

首先我們來介紹 TensorFlow 中的基本數據類型,它包含了數值型、字符串型和布爾型

4.1.1 數值類型

數值類型的張量是 TensorFlow 的主要數據載體,分爲:

標量(Scalar) 單個的實數,如 1.2, 3.4 等,維度數(Dimension,也叫秩)爲 0,shape 爲[]

向量(Vector) n 個實數的有序集合,通過中括號包裹,如[1.2],[1.2,3.4]等,維度數爲1,長度不定,shape 爲[𝑛]

❑ 矩陣(Matrix) n 行 m 列實數的有序集合,如[[1,2],[3,4]],也可以寫成
[1234]\left[\begin{array}{ll} 1 & 2 \\ 3 & 4 \end{array}\right]
維度數爲 2,每個維度上的長度不定,shape 爲[𝑛, 𝑚]

張量(Tensor) 所有維度數dim > 2的數組統稱爲張量。張量的每個維度也做軸(Axis),一般維度代表了具體的物理含義,比如 Shape 爲[2,32,32,3]的張量共有 4 維,如果表示圖片數據的話,每個維度/軸代表的含義分別是:圖片數量、圖片高度、圖片寬度、圖片通道數,其中 2 代表了 2 張圖片,32 代表了高寬均爲 32,3 代表了 RGB 3 個通道。張量的維度數以及每個維度所代表的具體物理含義需要由用戶自行定義

在 TensorFlow 中間,爲了表達方便,一般把標量、向量、矩陣也統稱爲張量不作區分,需要根據張量的維度數和形狀自行判斷。

首先來看標量在 TensorFlow 是如何創建的:

a=1.2
aa=tf.constant(1.2)# 創建標量
print(type(a))
print(type(aa))
print(tf.is_tensor(aa))

output:
<class 'float'>
<class 'tensorflow.python.framework.ops.EagerTensor'>
True

必須通過 TensorFlow 規定的方式去創建張量,而不能使用 Python 語言的標準變量創建方式。
通過 print(x)或 x 可以打印出張量 x 的相關信息:

x = tf.constant([1,2.,3.3])

<tf.Tensor: id=165, shape=(3,), dtype=float32, numpy=array([1. , 2. , 3.3],dtype=float32)>

其中 id 是 TensorFlow 中內部索引對象的編號,shape 表示張量的形狀,dtype 表示張量的數值精度,張量 numpy()方法可以返回 Numpy.array 類型的數據,方便導出數據到系統的其他模塊:

Ix.numpy()

array([1. , 2. , 3.3], dtype=float32)

與標量不同,向量的定義須通過 List 類型傳給 tf.constant()。創建一個元素的向量

a = tf.constant([1.2])
a, a.shape

(<tf.Tensor: id=8, shape=(1,), dtype=float32, numpy=array([1.2],dtype=float32)>,TensorShape([1]))

創建 2 個元素的向量:

a = tf.constant([1,2, 3.])
a, a.shape

(<tf.Tensor: id=11, shape=(3,), dtype=float32, numpy=array([1., 2., 3.],dtype=float32)>,TensorShape([3]))

同樣的方法定義矩陣:

a = tf.constant([[1,2],[3,4]])
a, a.shape

(<tf.Tensor: id=13, shape=(2, 2), dtype=int32, numpy=array([[1, 2],[3, 4]])>, TensorShape([2, 2]))

3 維張量可以定義爲:

a = tf.constant([
[
[1,2],[3,4]],[[5,6],[7,8]
]
])

<tf.Tensor: id=15, shape=(2, 2, 2), dtype=int32, numpy=array([[[1, 2],[3, 4]],[[5, 6],[7, 8]]])>
4.1.2 字符串類型

除了豐富的數值類型外,TensorFlow 還支持字符串(String)類型的數據,例如在表示圖片數據時,可以先記錄圖片的路徑,再通過預處理函數根據路徑讀取圖片張量。通過傳入字符串對象即可創建字符串類型的張量:

a = tf.constant('Hello, Deep Learning.')

<tf.Tensor: id=17, shape=(), dtype=string, numpy=b'Hello, Deep Learning.'>

tf.strings 模塊中,提供了常見的字符串型的工具函數,如拼接 join(),長度 length(),切分 split()等等:

tf.strings.lower(a)

<tf.Tensor: id=19, shape=(), dtype=string, numpy=b'hello, deep learning.'>

深度學習算法主要還是以數值類型張量運算爲主,字符串類型的數據使用頻率較低,我們不做過多闡述。

4.1.3 布爾類型

爲了方便表達比較運算操作的結果,TensorFlow 還支持布爾類型(Boolean, bool)的張量。布爾類型的張量只需要傳入 Python 語言的布爾類型數據,轉換成 TensorFlow 內部布爾型即可:

a = tf.constant(True)

<tf.Tensor: id=22, shape=(), dtype=bool, numpy=True>

傳入布爾類型的向量:

a = tf.constant([True, False])

<tf.Tensor: id=25, shape=(2,), dtype=bool, numpy=array([ True, False])>

需要注意的是,TensorFlow 的布爾類型和 Python 語言的布爾類型並不對等,不能通用:

a = tf.constant(True) # 創建布爾張量
a == True

False
4.2 數值精度

對於數值類型的張量,可以保持爲不同字節長度的精度,如浮點數 3.14 既可以保存爲16-bit 長度,也可以保存爲 32-bit 甚至 64-bit 的精度。Bit 位越長,精度越高,同時佔用的內存空間也就越大。常用的精度類型有 tf.int16, tf.int32, tf.int64, tf.float16, tf.float32,tf.float64,其中 tf.float64 即爲 tf.double。在創建張量時,可以指定張量的保存精度:

tf.constant(123456789, dtype=tf.int16)
tf.constant(123456789, dtype=tf.int32)

<tf.Tensor: id=33, shape=(), dtype=int16, numpy=-13035>
<tf.Tensor: id=35, shape=(), dtype=int32, numpy=123456789>

可以看到,保存精度過低時,數據 123456789 發生了溢出,得到了錯誤的結果,一般使用tf.int32, tf.int64 精度。對於浮點數,高精度的張量可以表示更精準的數據,例如採用tf.float32 精度保存𝜋時:

import numpy as np
np.pi
tf.constant(np.pi, dtype=tf.float32)

<tf.Tensor: id=29, shape=(), dtype=float32, numpy=3.1415927>

如果採用 tf.float32 精度保存𝜋,則能獲得更高的精度:

tf.constant(np.pi, dtype=tf.float64)

<tf.Tensor: id=31, shape=(), dtype=float64, numpy=3.141592653589793>

對於大部分深度學習算法,一般使用 tf.int32, tf.float32 可滿足運算精度要求,部分對精度要求較高的算法,如強化學習,可以選擇使用 tf.int64, tf.float64 精度保存張量。

4.2.1 讀取精度

通過訪問張量的 dtype 成員屬性可以判斷張量的保存精度:

print('before:',a.dtype)
if a.dtype != tf.float32:
    a = tf.cast(a,tf.float32) # 轉換精度
print('after :',a.dtype)

before: <dtype: 'float16'>
after : <dtype: 'float32'>

對於某些只能處理指定精度類型的運算操作,需要提前檢驗輸入張量的精度類型,並將不符合要求的張量進行類型轉換。

4.2.2 類型轉換

系統的每個模塊使用的數據類型、數值精度可能各不相同,對於不符合要求的張量的類型及精度,需要通過 tf.cast 函數進行轉換:

a = tf.constant(np.pi, dtype=tf.float16)
tf.cast(a, tf.double)

<tf.Tensor: id=44, shape=(), dtype=float64, numpy=3.140625>

進行類型轉換時,需要保證轉換操作的合法性,例如將高精度的張量轉換爲低精度的張量
時,可能發生數據溢出隱患:

a = tf.constant(123456789, dtype=tf.int32)
tf.cast(a, tf.int16)

<tf.Tensor: id=38, shape=(), dtype=int16, numpy=-13035>

布爾型與整形之間相互轉換也是合法的,是比較常見的操作:

a = tf.constant([True, False])
tf.cast(a, tf.int32)

<tf.Tensor: id=48, shape=(2,), dtype=int32, numpy=array([1, 0])>

一般默認 0 表示 False,1 表示 True,在 TensorFlow 中,將非 0 數字都視爲 True:

a = tf.constant([-1, 0, 1, 2])
tf.cast(a, tf.bool)

<tf.Tensor: id=51, shape=(4,), dtype=bool, numpy=array([ True, False, True,True])>
4.3 待優化張量

爲了區分需要計算梯度信息的張量與不需要計算梯度信息的張量,TensorFlow 增加了一種專門的數據類型來支持梯度信息的記錄:tf.Variabletf.Variable 類型在普通的張量類型基礎上添加了 nametrainable 等屬性來支持計算圖的構建。由於梯度運算會消耗大量的計算資源,而且會自動更新相關參數,對於不需要的優化的張量,如神經網絡的輸入 X,不需要通過 tf.Variable 封裝;相反,對於需要計算梯度並優化的張量,如神經網絡層的W和𝒃,需要通過 tf.Variable 包裹以便 TensorFlow 跟蹤相關梯度信息。

通過 tf.Variable()函數可以將普通張量轉換爲待優化張量:

a = tf.constant([-1, 0, 1, 2])
aa = tf.Variable(a)
aa.name, aa.trainable

('Variable:0', True)

其中張量的 name 和 trainable 屬性是 Variable 特有的屬性,name 屬性用於命名計算圖中的變量,這套命名體系是 TensorFlow 內部維護的,一般不需要用戶關注 name 屬性;trainable表徵當前張量是否需要被優化,創建 Variable 對象是默認啓用優化標誌,可以設置trainable=False 來設置張量不需要優化。

除了通過普通張量方式創建 Variable,也可以直接創建:

a = tf.Variable([[1,2],[3,4]])

<tf.Variable 'Variable:0' shape=(2, 2) dtype=int32, numpy=array([[1, 2],[3, 4]])>

待優化張量可看做普通張量的特殊類型,普通張量也可以通過 GradientTape.watch()方法臨時加入跟蹤梯度信息的列表。

4.4 創建張量

在 TensorFlow 中,可以通過多種方式創建張量,如從 Python List 對象創建,從Numpy 數組創建,或者創建採樣自某種已知分佈的張量等。

4.4.1 從 Numpy, List 對象創建

Numpy Array 數組和 Python List 是 Python 程序中間非常重要的數據載體容器,很多數據都是通過 Python 語言將數據加載至 Array 或者 List 容器,再轉換到 Tensor 類型,通過TensorFlow 運算處理後導出到 Array 或者 List 容器,方便其他模塊調用。

通過 tf.convert_to_tensor 可以創建新 Tensor,並將保存在 Python List 對象或者 Numpy Array 對象中的數據導入到新 Tensor 中:

tf.convert_to_tensor([1,2.])

<tf.Tensor: id=86, shape=(2,), dtype=float32, numpy=array([1., 2.],dtype=float32)>

tf.convert_to_tensor(np.array([[1,2.],[3,4]]))

<tf.Tensor: id=88, shape=(2, 2), dtype=float64, numpy=array([[1., 2.], [3., 4.]])>

需要注意的是,Numpy 中浮點數數組默認使用 64-Bit 精度保存數據,轉換到 Tensor 類型時精度爲 tf.float64,可以在需要的時候轉換爲 tf.float32 類型。

實際上,tf.constant()tf.convert_to_tensor()都能夠自動的把 Numpy 數組或者 PythonList 數據類型轉化爲 Tensor 類型,這兩個 API 命名來自 TensorFlow 1.x 的命名習慣,在TensorFlow 2 中函數的名字並不是很貼切,使用其一即可.

4.4.2 創建全 0,全 1 張量

將張量創建爲全 0 或者全 1 數據是非常常見的張量初始化手段。考慮線性變換𝒚 = 𝑊𝒙 + 𝒃,將權值矩陣 W 初始化爲全 1 矩陣,偏置 b 初始化爲全 0 向量,此時線性變化層輸出𝒚 = 𝒙,是一種比較好的層初始化狀態。

通過 tf.zeros()tf.ones()即可創建任意形狀全 0 或全 1 的張量。例如,創建爲 0 和爲 1 的標量張量:

tf.zeros([]),tf.ones([])

(<tf.Tensor: id=90, shape=(), dtype=float32, numpy=0.0>,
<tf.Tensor: id=91, shape=(), dtype=float32, numpy=1.0>)

創建全 0 和全 1 的向量:

tf.zeros([1]),tf.ones([1])

(<tf.Tensor: id=96, shape=(1,), dtype=float32, numpy=array([0.],dtype=float32)>,
<tf.Tensor: id=99, shape=(1,), dtype=float32, numpy=array([1.],dtype=float32)>)

創建全 0 的矩陣:

tf.zeros([2,2])

<tf.Tensor: id=104, shape=(2, 2), dtype=float32, numpy=array([[0., 0.],[0., 0.]],dtype=float32)>

創建全 1 的矩陣:

tf.ones([3,2])

<tf.Tensor: id=108, shape=(3, 2), dtype=float32, numpy=array([[1., 1.],[1., 1.],[1., 1.]], dtype=float32)>

通過 tf.zeros_like, tf.ones_like 可以方便地新建與某個張量 shape 一致,內容全 0 或全 1
的張量。例如,創建與張量 a 形狀一樣的全 0 張量:

a = tf.ones([2,3])
tf.zeros_like(a)

<tf.Tensor: id=113, shape=(2, 3), dtype=float32, numpy=array([[0., 0., 0.],[0., 0., 0.]], dtype=float32)>

創建與張量 a 形狀一樣的全 1 張量:

a = tf.zeros([3,2])
tf.ones_like(a)

<tf.Tensor: id=120, shape=(3, 2), dtype=float32, numpy=array([[1., 1.],[1., 1.],[1., 1.]], dtype=float32)>

tf.*_like 是一個便捷函數,可以通過 tf.zeros(a.shape)等方式實現。

4.4.3 創建自定義數值張量

除了初始化爲全 0,或全 1 的張量之外,有時也需要全部初始化爲某個自定義數值的張量,比如將張量的數值全部初始化爲-1 等。

通過tf.fill(shape, value)可以創建全爲自定義數值 value 的張量。例如,創建元素爲-1
的標量:

tf.fill([], -1)

<tf.Tensor: id=124, shape=(), dtype=int32, numpy=-1>

創建所有元素爲-1 的向量:

tf.fill([1], -1)

<tf.Tensor: id=128, shape=(1,), dtype=int32, numpy=array([-1])>

創建所有元素爲 99 的矩陣:

tf.fill([2,2], 99)

<tf.Tensor: id=136, shape=(2, 2), dtype=int32, numpy=array([[99, 99],[99, 99]])>
4.4.4 創建已知分佈的張量

正態分佈(Normal Distribution,或 Gaussian Distribution)和均勻分佈(UniformDistribution)是最常見的分佈之一,創建採樣自這 2 種分佈的張量非常有用,比如在卷積神經網絡中,卷積核張量 W 初始化爲正態分佈有利於網絡的訓練;在對抗生成網絡中,隱藏變量 z 一般採樣自均勻分佈

通過 tf.random.normal(shape, mean=0.0, stddev=1.0)可以創建形狀爲 shape,均值爲mean,標準差爲 stddev 的正態分佈𝒩(𝑚𝑒𝑎𝑛, 𝑠𝑡𝑑𝑑𝑒𝑣2)。例如,創建均值爲 0,標準差爲 1的正太分佈:

tf.random.normal([2,2])

<tf.Tensor: id=143, shape=(2, 2), dtype=float32, numpy=array([[-0.4307344 , 0.44147003],[-0.6563149 , -0.30100572]], dtype=float32)>

創建均值爲 1,標準差爲 2 的正太分佈:

tf.random.normal([2,2], mean=1,stddev=2)

<tf.Tensor: id=150, shape=(2, 2), dtype=float32, numpy=array([[-2.2687864, -0.7248812],[ 1.2752185, 2.8625617]], dtype=float32)>

通過 tf.random.uniform(shape, minval=0, maxval=None, dtype=tf.float32)可以創建採樣自
[𝑚𝑖𝑛𝑣𝑎𝑙, 𝑚𝑎𝑥𝑣𝑎𝑙]區間的均勻分佈的張量。例如創建採樣自區間[0,1],shape 爲[2,2]的矩
陣:

tf.random.uniform([2,2])

<tf.Tensor: id=158, shape=(2, 2), dtype=float32, numpy=array([[0.65483284, 0.63064325],[0.008816 , 0.81437767]], dtype=float32)>

創建採樣自區間[0,10],shape 爲[2,2]的矩陣:

tf.random.uniform([2,2],maxval=10)

<tf.Tensor: id=166, shape=(2, 2), dtype=float32, numpy=array([[4.541913 , 0.26521802],[2.578913 , 5.126876 ]], dtype=float32)>

如果需要均勻採樣整形類型的數據,必須指定採樣區間的最大值 maxval 參數,同時制定數據類型爲 tf.int*型:

tf.random.uniform([2,2],maxval=100,dtype=tf.int32)

<tf.Tensor: id=171, shape=(2, 2), dtype=int32, numpy=array([[61, 21],[95, 75]])>
4.4.5 創建序列

在循環計算或者對張量進行索引時,經常需要創建一段連續的整形序列,可以通過tf.range()函數實現。tf.range(limit, delta=1)可以創建[0,𝑙𝑖𝑚𝑖𝑡)之間,步長爲 delta 的整形序列,不包含 limit 本身。例如,創建 0~9,步長爲 1 的整形序列:

tf.range(10)

<tf.Tensor: id=180, shape=(10,), dtype=int32, numpy=array([0, 1, 2, 3, 4, 5,6, 7, 8, 9])>

創建 0~9,步長爲 2 的整形序列:

tf.range(10,delta=2)

<tf.Tensor: id=185, shape=(5,), dtype=int32, numpy=array([0, 2, 4, 6, 8])>

通過 tf.range(start, limit, delta=1)可以創建[𝑠𝑡𝑎𝑟𝑡, 𝑙𝑖𝑚𝑖𝑡),步長爲 delta 的序列,不包含 limit
本身:

tf.range(1,10,delta=2)

<tf.Tensor: id=190, shape=(5,), dtype=int32, numpy=array([1, 3, 5, 7, 9])>
4.5 張量的典型應用

在介紹完張量的相關屬性和創建方式後,我們將介紹每種維度下張量的典型應用,讓讀者在看到每種張量時,能夠直觀地聯想到它主要的物理意義和用途,對後續張量的維度變換等一系列抽象操作的學習打下基礎。本節在介紹典型應用時不可避免地會提及後續將要學習的網絡模型或算法,學習時不需要完全理解,有初步印象即可。

4.5.1 標量

在 TensorFlow 中,標量最容易理解,它就是一個簡單的數字,維度數爲 0,shape 爲[]。標量的典型用途之一是誤差值的表示、各種測量指標的表示,比如準確度(Accuracy,acc),精度(Precision)和召回率(Recall)等。

考慮某個模型的訓練曲線,如圖 4.1 所示,橫座標爲訓練 Batch 步數 Step,縱座標分別爲誤差變化趨勢(圖 4.1(a))和準確度變化趨勢曲線(圖 4.1(b)),其中損失值 loss 和準確度均由張量計算產生,類型爲標量。
在這裏插入圖片描述
以均方差誤函數爲例,經過 tf.keras.losses.mse(或 tf.keras.losses.MSE)返回每個樣本上的誤差值,最後取誤差的均值作爲當前 batch 的誤差,它是一個標量:

out = tf.random.uniform([4,10]) #隨機模擬網絡輸出
y = tf.constant([2,3,2,0]) # 隨機構造樣本真實標籤
y = tf.one_hot(y, depth=10) # one-hot 編碼
loss = tf.keras.losses.mse(y, out) # 計算每個樣本的 MSE
loss = tf.reduce_mean(loss) # 平均 MSE
print(loss)

tf.Tensor(0.19950335, shape=(), dtype=float32)
4.5.2 向量

向量是一種非常常見的數據載體,如在全連接層和卷積神經網絡層中,偏置張量𝒃就使用向量來表示。如圖 4.2 所示,每個全連接層的輸出節點都添加了一個偏置值,把所有輸出節點的偏置表示成向量形式:𝒃 = [𝑏1, 𝑏2]
在這裏插入圖片描述

考慮 2 個輸出節點的網絡層,我們創建長度爲 2 的偏置向量𝒃,並累加在每個輸出節點上:
In [42]:

# z=wx,模擬獲得激活函數的輸入 z
z = tf.random.normal([4,2])
b = tf.zeros([2]) # 模擬偏置向量
z = z + b # 累加偏置

<tf.Tensor: id=245, shape=(4, 2), dtype=float32, numpy=
array([[ 0.6941646 , 0.4764454 ],
 [-0.34862405, -0.26460952],
 [ 1.5081744 , -0.6493869 ],
 [-0.26224667, -0.78742725]], dtype=float32)>

注意到這裏 shape 爲[4,2]的𝒛和 shape 爲[2]的𝒃張量可以直接相加,這是爲什麼呢?讓我們在 Broadcasting 一節爲大家揭祕。

通過高層接口類 Dense()方式創建的網絡層,張量 W 和𝒃存儲在類的內部,由類自動創建並管理。可以通過全連接層的 bias 成員變量查看偏置變量𝒃,例如創建輸入節點數爲 4,輸出節點數爲 3 的線性層網絡,那麼它的偏置向量 b 的長度應爲 3:

fc = layers.Dense(3) # 創建一層 Wx+b,輸出節點爲 3
# 通過 build 函數創建 W,b 張量,輸入節點爲 4
fc.build(input_shape=(2,4))
fc.bias # 查看偏置

<tf.Variable 'bias:0' shape=(3,) dtype=float32, numpy=array([0., 0., 0.],
dtype=float32)>

可以看到,類的偏置成員 bias 初始化爲全 0,這也是偏置𝒃的默認初始化方案。

4.5.3 矩陣

矩陣也是非常常見的張量類型,比如全連接層的批量輸入𝑋 = [𝑏, dind_{in}],其中𝑏表示輸入樣本的個數,即 batch size, dind_{in}表示輸入特徵的長度。比如特徵長度爲 4,一共包含 2 個樣本的輸入可以表示爲矩陣:

x = tf.random.normal([2,4])

令全連接層的輸出節點數爲 3,則它的權值張量 W 的 shape 爲[4,3]:

w=tf.ones([4,3])
b=tf.zeros([3])
o =tf.matmul(x, w)+b
print(o)

<tf.Tensor: id=291, shape=(2, 3), dtype=float32, numpy=
array([[ 2.3506963, 2.3506963, 2.3506963],
 [-1.1724043, -1.1724043, -1.1724043]], dtype=float32)>

其中 X,W 張量均是矩陣。x*w+b 網絡層稱爲線性層,在 TensorFlow 中可以通過 Dense類直接實現,Dense 層也稱爲全連接層。我們通過 Dense 類創建輸入 4 個節點,輸出 3 個節點的網絡層,可以通過全連接層的 kernel 成員名查看其權值矩陣 W

fc = layers.Dense(3) # 定義全連接層的輸出節點爲 3
fc.build(input_shape=(2,4)) # 定義全連接層的輸入節點爲 4
fc.kernel

<tf.Variable 'kernel:0' shape=(4, 3) dtype=float32, numpy=
array([[ 0.06468129, -0.5146048 , -0.12036425],
 [ 0.71618867, -0.01442951, -0.5891943 ],
 [-0.03011459, 0.578704 , 0.7245046 ],
 [ 0.73894167, -0.21171576, 0.4820758 ]], dtype=float32)>
4.5.4 三維張量

三維的張量一個典型應用是表示序列信號,它的格式是
𝑋 = [𝑏, 𝑠𝑒𝑞𝑢𝑒𝑛𝑐𝑒 𝑙𝑒𝑛, 𝑓𝑒𝑎𝑡𝑢𝑟𝑒 𝑙𝑒𝑛]

其中𝑏表示序列信號的數量,sequence len 表示序列信號在時間維度上的採樣點數,featurelen 表示每個點的特徵長度。

考慮自然語言處理中句子的表示,如評價句子的是否爲正面情緒的情感分類任務網絡,如圖 4.3 所示。爲了能夠方便字符串被神經網絡處理,一般將單詞通過嵌入層(Embedding Layer)編碼爲固定長度的向量,比如“a”編碼爲某個長度 3 的向量,那麼 2 個等長(單詞數爲 5)的句子序列可以表示爲 shape 爲[2,5,3]的 3 維張量,其中 2 表示句子個數,5 表示單詞數量,3 表示單詞向量的長度:
在這裏插入圖片描述

# 自動加載 IMDB 電影評價數據集
(x_train,y_train),(x_test,y_test)=keras.datasets.imdb.load_data(num_words=10000)
# 將句子填充、截斷爲等長 80 個單詞的句子
x_train = keras.preprocessing.sequence.pad_sequences(x_train,maxlen=80)
x_train.shape

可以看到 x_train 張量的 shape 爲[25000,80],其中 25000 表示句子個數,80 表示每個句子共 80 個單詞,每個單詞使用數字編碼方式。我們通過 layers.Embedding 層將數字編碼的單詞轉換爲長度爲 100 個詞向量:

# 創建詞向量 Embedding 層類
embedding=layers.Embedding(10000, 100)
# 將數字編碼的單詞轉換爲詞向量
out = embedding(x_train)
out.shape

TensorShape([25000, 80, 100])

可以看到,經過 Embedding 層編碼後,句子張量的 shape 變爲[25000,80,100],其中 100 表示每個單詞編碼爲長度 100 的向量。

對於特徵長度爲 1 的序列信號,比如商品價格在 60 天內的變化曲線,只需要一個標量即可表示商品的價格,因此 2 件商品的價格變化趨勢可以使用 shape 爲[2,60]的張量表示。爲了方便統一格式,也將價格變化趨勢表達爲 shape 爲 [2,60,1]的張量,其中的 1 表示特徵長度爲 1。

4.5.5 4維張量

我們這裏只討論 3/4 維張量,大於 4 維的張量一般應用的比較少,如在元學習(metalearning)中會採用 5 維的張量表示方法,理解方法與 3/4 維張量類似。

4維張量在卷積神經網絡中應用的非常廣泛,它用於保存特徵圖(Feature maps)數據,格式一般定義爲
[b,h,w,c][b, h, w, c]

其中𝑏表示輸入的數量,h/w分佈表示特徵圖的高寬,𝑐表示特徵圖的通道數,部分深度學習框架也會使用[𝑏, 𝑐, ℎ, ]格式的特徵圖張量,例如 PyTorch。圖片數據是特徵圖的一種,對於含有 RGB 3 個通道的彩色圖片,每張圖片包含了 h 行 w 列像素點,每個點需要 3 個數值表示 RGB 通道的顏色強度,因此一張圖片可以表示爲[h,w, 3]。如圖 4.4 所示,最上層的圖片表示原圖,它包含了下面 3 個通道的強度信息。
在這裏插入圖片描述
神經網絡中一般並行計算多個輸入以提高計算效率,故𝑏張圖片的張量可表示爲[𝑏, ℎ, w, 3]。

# 創建 32x32 的彩色圖片輸入,個數爲 4
x = tf.random.normal([4,32,32,3])
# 創建卷積神經網絡
layer = layers.Conv2D(16,kernel_size=3)
out = layer(x) # 前向計算
out.shape # 輸出大小

TensorShape([4, 30, 30, 16])

其中卷積核張量也是 4 維張量,可以通過 kernel 成員變量訪問:

layer.kernel.shape
Out[49]: TensorShape([3, 3, 3, 16])
4.6 索引與切片

通過索引與切片操作可以提取張量的部分數據,使用頻率非常高

4.6.1 索引

在 TensorFlow 中,支持基本的[𝑖][𝑗]…標準索引方式,也支持通過逗號分隔索引號的索引方式。考慮輸入 X 爲 4 張 32x32 大小的彩色圖片(爲了方便演示,大部分張量都使用隨機分佈模擬產生,後文同),shape 爲[4,32,32,3],首先創建張量:

x = tf.random.normal([4,32,32,3])

接下來我們使用索引方式讀取張量的部分數據。

❑ 取第 1 張圖片的數據:

x[0]

<tf.Tensor: id=379, shape=(32, 32, 3), dtype=float32, numpy=
array([[[ 1.3005302 , 1.5301839 , -0.32005513],
 [-1.3020388 , 1.7837263 , -1.0747638 ], ...
 [-1.1092019 , -1.045254 , -0.4980363 ],
 [-0.9099222 , 0.3947732 , -0.10433522]]], dtype=float32)>

❑ 取第 1 張圖片的第 2 行:

x[0][1]

<tf.Tensor: id=388, shape=(32, 3), dtype=float32, numpy=
array([[ 4.2904025e-01, 1.0574218e+00, 3.1540772e-01],
 [ 1.5800388e+00, -8.1637271e-02, 6.3147342e-01], ...,
 [ 2.8893018e-01, 5.8003378e-01, -1.1444757e+00],
 [ 9.6100050e-01, -1.0985689e+00, 1.0827581e+00]], dtype=float32)>

❑ 取第 1 張圖片,第 2 行,第 3 列的像素

In [53]: x[0][1][2]
Out[53]:
<tf.Tensor: id=401, shape=(3,), dtype=float32, numpy=array([-0.55954427,
0.14497331, 0.46424514], dtype=float32)>

❑ 取第 3 張圖片,第 2 行,第 1 列的像素,B 通道(第 2 個通道)顏色強度值

x[2][1][0][1]
Out[54]:
<tf.Tensor: id=418, shape=(), dtype=float32, numpy=-0.84922135>

當張量的維度數較高時,使用[𝑖][𝑗]. . .[𝑘]的方式書寫不方便,可以採用[𝑖,𝑗, … , 𝑘]的方式索引,它們是等價的。

❑ 取第 2 張圖片,第 10 行,第 3 列:

x[1,9,2]

<tf.Tensor: id=436, shape=(3,), dtype=float32, numpy=array([ 1.7487534 , -
0.41491988, -0.2944692 ], dtype=float32)>
4.6.2 切片

通過𝑠𝑡𝑎𝑟𝑡: 𝑒𝑛𝑑: 𝑠𝑡𝑒𝑝切片方式可以方便地提取一段數據,其中 start 爲開始讀取位置的索引,end 爲結束讀取位置的索引(不包含 end 位),step 爲讀取步長。

以 shape 爲[4,32,32,3]的圖片張量爲例:

❑ 讀取第 2和第3 張圖片:

x[1:3]

<tf.Tensor: id=441, shape=(2, 32, 32, 3), dtype=float32, numpy=
array([[[[ 0.6920027 , 0.18658352, 0.0568333 ],
 [ 0.31422952, 0.75933754, 0.26853144],
 [ 2.7898 , -0.4284912 , -0.26247284],...

start: end: step切片方式有很多簡寫方式,其中 start、end、step 3 個參數可以根據需要選擇性地省略,全部省略時即::,表示從最開始讀取到最末尾,步長爲 1,即不跳過任何元素。如 x[0,::]表示讀取第 1 張圖片的所有行,其中::表示在行維度上讀取所有行,它等於x[0]的寫法:

x[0,::]

<tf.Tensor: id=446, shape=(32, 32, 3), dtype=float32, numpy=
array([[[ 1.3005302 , 1.5301839 , -0.32005513],
 [-1.3020388 , 1.7837263 , -1.0747638 ],
 [-1.1230233 , -0.35004002, 0.01514002],

爲了更加簡潔,::可以簡寫爲單個冒號:,如

x[:,0:28:2,0:28:2,:]

<tf.Tensor: id=451, shape=(4, 14, 14, 3), dtype=float32, numpy=
array([[[[ 1.3005302 , 1.5301839 , -0.32005513],
 [-1.1230233 , -0.35004002, 0.01514002],
 [ 1.3474811 , 0.639334 , -1.0826371 ],

表示取所有圖片,隔行採樣,隔列採樣,所有通道信息,相當於在圖片的高寬各縮放至原來的 50%。

我們來總結start: end: step切片的簡寫方式,其中從第一個元素讀取時 start 可以省略,即 start=0 是可以省略,取到最後一個元素時 end 可以省略,步長爲 1 時 step 可以省略,簡寫方式總結如表格 4.1:
在這裏插入圖片描述
特別地,step 可以爲負數,考慮最特殊的一種例子,step = −1時,start: end: −1表示從 start 開始,逆序讀取至 end 結束(不包含 end),索引號𝑒𝑛𝑑 ≤ 𝑠𝑡𝑎𝑟𝑡。考慮一 0~9 簡單序列,逆序取到第 1 號元素,不包含第 1 號:

x = tf.range(9)
x[8:0:-1]

<tf.Tensor: id=466, shape=(8,), dtype=int32, numpy=array([8, 7, 6, 5, 4, 3,2, 1])>

逆序取全部元素:

x[::-1]

<tf.Tensor: id=471, shape=(9,), dtype=int32, numpy=array([8, 7, 6, 5, 4, 3,2, 1, 0])>

逆序間隔採樣:

x[::-2]

<tf.Tensor: id=476, shape=(5,), dtype=int32, numpy=array([8, 6, 4, 2, 0])>

讀取每張圖片的所有通道,其中行按着逆序隔行採樣,列按着逆序隔行採樣:

x = tf.random.normal([4,32,32,3])
x[0,::-2,::-2]

<tf.Tensor: id=487, shape=(16, 16, 3), dtype=float32, numpy=
array([[[ 0.63320625, 0.0655185 , 0.19056146],
 [-1.0078577 , -0.61400175, 0.61183935],
 [ 0.9230892 , -0.6860094 , -0.01580668],

當張量的維度數量較多時,不需要採樣的維度一般用單冒號:表示採樣所有元素,此時有可能出現大量的:出現。繼續考慮[4,32,32,3]的圖片張量,當需要讀取 G 通道上的數據時,前面所有維度全部提取,此時需要寫爲:

x[:,:,:,1]

<tf.Tensor: id=492, shape=(4, 32, 32), dtype=float32, numpy=
array([[[ 0.575703 , 0.11028383, -0.9950867 , ..., 0.38083118,
 -0.11705163, -0.13746642],
 ...

爲了避免出現像𝑥[: , : , : ,1]這樣出現過多冒號的情況,可以使用⋯符號表示取多個維度上所有的數據,其中維度的數量需根據規則自動推斷:當切片方式出現⋯符號時,⋯符號左邊的維度將自動對齊到最左邊,⋯符號右邊的維度將自動對齊到最右邊,此時系統再自動推斷⋯符號代表的維度數量,它的切片方式總結如表格 4.2:
在這裏插入圖片描述
考慮如下例子:

❑ 讀取第 1-2 張圖片的 G/B 通道數據:

In [64]: x[0:2,...,1:]
Out[64]:
<tf.Tensor: id=497, shape=(2, 32, 32, 2), dtype=float32, numpy=
array([[[[ 0.575703 , 0.8872789 ],
 [ 0.11028383, -0.27128693],
 [-0.9950867 , -1.7737272 ],
 ...

❑ 讀取最後 2 張圖片:

x[2:,...]

<tf.Tensor: id=502, shape=(2, 32, 32, 3), dtype=float32, numpy=
array([[[[-8.10753584e-01, 1.10984087e+00, 2.71821529e-01],
 [-6.10031188e-01, -6.47952318e-01, -4.07003373e-01],
 [ 4.62206364e-01, -1.03655539e-01, -1.18086267e+00],
 ...

❑ 讀取 R/G 通道數據:

x[...,:2]

<tf.Tensor: id=507, shape=(4, 32, 32, 2), dtype=float32, numpy=
array([[[[-1.26881 , 0.575703 ],
 [ 0.98697686, 0.11028383],
 [-0.66420585, -0.9950867 ],
 ...
4.6.3 小結

張量的索引與切片方式多種多樣,尤其是切片操作,初學者容易犯迷糊。但其實本質上切片操作只有𝑠𝑡𝑎𝑟𝑡: 𝑒𝑛𝑑: 𝑠𝑡𝑒𝑝這一種基本形式,通過這種基本形式有目的地省略掉默認參數,從而衍生出多種簡寫方法,這也是很好理解的。它衍生的簡寫形式熟練後一看就能推測出省略掉的信息,書寫起來也更方便快捷。由於深度學習一般處理的維度數在 4 維以內,⋯操作符完全可以用:符號代替,因此理解了這些就會發現張量切片操作並不複雜.

4.7 維度變換

在神經網絡運算過程中,維度變換是最核心的張量操作,通過維度變換可以將數據任意地切換形式,滿足不同場合的運算需求。

那麼爲什麼需要維度變換呢?考慮線性層的批量形式:
Y=X@W+bY=X @ W+b
其中 X 包含了 2 個樣本,每個樣本的特徵長度爲 4,X 的 shape 爲[2,4]。線性層的輸出爲 3個節點,即 W 的 shape 定義爲[4,3],偏置𝒃的 shape 定義爲[3]。那麼X@W的運算張量shape 爲[2,3],需要疊加上 shape 爲[3]的偏置𝒃。不同 shape 的 2 個張量怎麼直接相加呢?

回到我們設計偏置的初衷,我們給每個層的每個輸出節點添加一個偏置,這個偏置數據是對所有的樣本都是共享的,換言之,每個樣本都應該累加上同樣的偏置向量𝒃,如圖4.5 所示:
在這裏插入圖片描述
因此,對於 2 個樣本的輸入 X,我們需要將 shape 爲[3]的偏置𝒃
b=[b0b1b2]\boldsymbol{b}=\left[\begin{array}{l} b_{0} \\ b_{1} \\ b_{2} \end{array}\right]
按樣本數量複製 1 份,變成矩陣形式𝐵
B=[b0b1b2b0b1b2]B^{\prime}=\left[\begin{array}{lll} b_{0} & b_{1} & b_{2} \\ b_{0} & b_{1} & b_{2} \end{array}\right]
通過與X′ = X@W
X=[x00x01x02x10x11x12]\mathrm{X}^{\prime}=\left[\begin{array}{lll} x_{00}^{\prime} & x_{01}^{\prime} & x_{02}^{\prime} \\ x_{10}^{\prime} & x_{11}^{\prime} & x_{12}^{\prime} \end{array}\right]
相加,此時X′與𝐵 shape 相同,滿足矩陣相加的數學條件:
Y=X+B=[x00x01x02x10x11x12]+[b0b1b2b0b1b2]\mathrm{Y}=\mathrm{X}^{\prime}+\mathrm{B}^{\prime}=\left[\begin{array}{lll} x_{00}^{\prime} & x_{01}^{\prime} & x_{02}^{\prime} \\ x_{10}^{\prime} & x_{11}^{\prime} & x_{12}^{\prime} \end{array}\right]+\left[\begin{array}{lll} b_{0} & b_{1} & b_{2} \\ b_{0} & b_{1} & b_{2} \end{array}\right]
通過這種方式,既滿足了數學上矩陣相加需要 shape 一致的條件,又達到了給每個輸入樣本的輸出節共享偏置的邏輯。爲了實現這種運算方式,我們將𝒃插入一個新的維度,並把它定義爲 batch 維度,然後在 batch 維度將數據複製 1 份,得到變換後的B′,新的 shape 爲[2,3]。

算法的每個模塊對於數據張量的格式有不同的邏輯要求,當現有的數據格式不滿足算法要求時,需要通過維度變換將數據調整爲正確的格式。這就是維度變換的功能。

基本的維度變換包含了改變視圖 reshape,插入新維度 expand_dims,刪除維度squeeze,交換維度 transpose,複製數據 tile

4.7.1 Reshape

在介紹改變視圖操作之前,我們先來認識一下張量的存儲和視圖(View)的概念。張量的視圖就是我們理解張量的方式,比如 shape 爲[2,4,4,3]的張量 A,我們從邏輯上可以理解爲 2 張圖片,每張圖片 4 行 4 列,每個位置有 RGB 3 個通道的數據;張量的存儲體現在張量在內存上保存爲一段連續的內存區域,對於同樣的存儲,我們可以有不同的理解方式,比如上述 A,我們可以在不改變張量的存儲下,將張量 A 理解爲 2 個樣本,每個樣本的特徵爲長度 48 的向量。這就是存儲與視圖的關係。

我們通過 tf.range()模擬生成 x 的數據:

x=tf.range(96)
x=tf.reshape(x,[2,4,4,3])

<tf.Tensor: id=11, shape=(2, 4, 4, 3), dtype=int32, numpy=
array([[[[ 0, 1, 2],
 [ 3, 4, 5],
 [ 6, 7, 8],
 [ 9, 10, 11]],

在存儲數據時,內存並不支持這個維度層級概念,只能以平鋪方式按序寫入內存,因此這種層級關係需要人爲管理,也就是說,每個張量的存儲順序需要人爲跟蹤。爲了方便表達,我們把張量 shape 中相對靠左側的維度叫做大維度,shape 中相對靠右側的維度叫做小維度,比如[2,4,4,3]的張量中,圖片數量維度與通道數量相比,圖片數量叫做大維度,通道數叫做小維度。在優先寫入小維度的設定下,上述張量的內存佈局爲
在這裏插入圖片描述
數據在創建時按着初始的維度順序寫入,改變張量的視圖僅僅是改變了張量的理解方式,並不會改變張量的存儲順序,這在一定程度上是從計算效率考慮的,大量數據的寫入操作會消耗較多的計算資源。改變視圖操作在提供便捷性的同時,也會帶來很多邏輯隱患,這主要的原因是張量的視圖與存儲不同步造成的。我們先介紹合法的視圖變換操作,再介紹不合法的視圖變換。

比如張量按着初始視圖[𝑏, ℎ, w, 𝑐]寫入的內存佈局,我們改變初始視圖[𝑏, ℎ, w, 𝑐]的理解方式,它可以有多種合法理解方式:

❑ [𝑏, ℎ ∗w , 𝑐] 張量理解爲 b 張圖片,hw 個像素點,c 個通道
❑ [𝑏, ℎ, w∗ 𝑐] 張量理解爲 b 張圖片,h 行,每行的特徵長度爲 w
c
❑ [𝑏, ℎ ∗ w∗ 𝑐] 張量理解爲 b 張圖片,每張圖片的特徵長度爲 hwc

從語法上來說,視圖變換隻需要滿足新視圖的元素總量與內存區域大小相等即可,即新視圖的元素數量等於
bhwcb∗ h ∗w ∗ c

正是由於視圖的設計約束很少,完全由用戶定義,使得在改變視圖時容易出現邏輯隱患。

現在我們來考慮不合法的視圖變換。例如,如果定義新視圖爲[𝑏,w , ℎ, 𝑐],[𝑏, 𝑐, ℎ ∗w ]或者[𝑏, 𝑐, ℎ, w]等時,與張量的存儲順序相悖,如果不同步更新張量的存儲順序,那麼恢復出的數據將與新視圖不一致,從而導致數據錯亂。

爲了能夠正確恢復出數據,必須保證張量的存儲順序與新視圖的維度順序一致,例如根據圖片數量-行-列-通道初始視圖保存的張量,按照圖片數量-行-列-通道(𝑏 − ℎ −w − 𝑐)的順序可以獲得合法數據。如果按着圖片數量-像素-通道( b− h ∗ w − c)的方式恢復視圖,也能得到合法的數據。但是如果按着圖片數量-通道-像素( b− c − h ∗ w)的方式恢復數據,由於內存佈局是按着圖片數量-行-列-通道的順序,視圖維度與存儲維度順序相悖,提取的數據將是錯亂的

改變視圖是神經網絡中非常常見的操作,可以通過串聯多個 Reshape 操作來實現複雜邏輯,但是在通過 Reshape 改變視圖時,必須始終記住張量的存儲順序新視圖的維度順序不能與存儲順序相悖,否則需要通過交換維度操作將存儲順序同步過來

舉個例子,對於 shape 爲[4,32,32,3]的圖片數據,通過 Reshape 操作將 shape 調整爲[4,1024,3],此時視圖的維度順序爲𝑏 − 𝑝𝑖𝑥𝑒𝑙 − 𝑐,張量的存儲順序爲[𝑏, ℎ, w, 𝑐]。可以將[4,1024,3]恢復爲

❑ [𝑏, ℎ, w, 𝑐] = [4,32,32,3]時,新視圖的維度順序與存儲順序無衝突,可以恢復出無邏輯問題的數據
❑ [𝑏, w, ℎ, 𝑐] = [4,32,32,3]時,新視圖的維度順序與存儲順序衝突
❑ [ℎ ∗w ∗ 𝑐, 𝑏] = [3072,4]時,新視圖的維度順序與存儲順序衝突

在 TensorFlow 中,可以通過張量的 ndimshape 成員屬性獲得張量的維度數和形狀:

x.ndim,x.shape
(4, TensorShape([2, 4, 4, 3]))

通過 tf.reshape(x, new_shape),可以將張量的視圖任意的合法改變:

tf.reshape(x,[2,-1])
<tf.Tensor: id=520, shape=(2, 48), dtype=int32, numpy=
array([[ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31,80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95]])>

其中的參數-1 表示當前軸上長度需要根據視圖總元素不變的法則自動推導,從而方便用戶書寫。比如,上面的-1 可以推導爲
24432=48\frac{2 * 4 * 4 * 3}{2}=48
再次改變數據的視圖爲[2,4,12]:

tf.reshape(x,[2,4,12])

<tf.Tensor: id=523, shape=(2, 4, 12), dtype=int32, numpy=
array([[[ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],[36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47]],
 [[48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59],[84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95]]])>

再次改變數據的視圖爲[2,16,3]:

tf.reshape(x,[2,-1,3])

<tf.Tensor: id=526, shape=(2, 16, 3), dtype=int32, numpy=
array([[[ 0, 1, 2],[45, 46, 47]],
 [[48, 49, 50],[93, 94, 95]]])>

通過上述的一系列連續變換視圖操作時需要意識到,張量的存儲順序始終沒有改變,數據在內存中仍然是按着初始寫入的順序0,1,2, … ,95保存的

4.7.2 增刪維度

增加維度 增加一個長度爲 1 的維度相當於給原有的數據增加一個新維度的概念,維度長度爲 1,故數據並不需要改變,僅僅是改變數據的理解方式,因此它其實可以理解爲改變視圖的一種特殊方式。

考慮一個具體例子,一張 28x28 灰度圖片的數據保存爲 shape 爲[28,28]的張量,在末尾給張量增加一新維度,定義爲爲通道數維度,此時張量的 shape 變爲[28,28,1]:

x = tf.random.uniform([28,28],maxval=10,dtype=tf.int32)

<tf.Tensor: id=552, shape=(28, 28), dtype=int32, numpy=
array([[4, 5, 7, 6, 3, 0, 3, 1, 1, 9, 7, 7, 3, 1, 2, 4, 1, 1, 9, 8, 6, 6,
 4, 9, 9, 4, 6, 0],

通過 tf.expand_dims(x, axis)可在指定的 axis 軸前可以插入一個新的維度:

x = tf.expand_dims(x,axis=2)

<tf.Tensor: id=555, shape=(28, 28, 1), dtype=int32, numpy=
array([[[4],
 [5],
 [7],
 [6],
 [3],

可以看到,插入一個新維度後,數據的存儲順序並沒有改變,依然按着 4,5,7,6,3,0,…的順序保存,僅僅是在插入一個新的維度後,改變了數據的視圖。

同樣的方法,我們可以在最前面插入一個新的維度,並命名爲圖片數量維度,長度爲1,此時張量的 shape 變爲[1,28,28,1]。

x = tf.expand_dims(x,axis=0)

<tf.Tensor: id=558, shape=(1, 28, 28), dtype=int32, numpy=
array([[[4, 5, 7, 6, 3, 0, 3, 1, 1, 9, 7, 7, 3, 1, 2, 4, 1, 1, 9, 8, 6,
 6, 4, 9, 9, 4, 6, 0],
 [5, 8, 6, 3, 6, 4, 3, 0, 5, 9, 0, 5, 4, 6, 4, 9, 4, 4, 3, 0, 6,
 9, 3, 7, 4, 2, 8, 9],

需要注意的是,tf.expand_dims 的 axis 爲正時,表示在當前維度之前插入一個新維度;爲負時,表示當前維度之後插入一個新的維度。以[𝑏, ℎ, w, 𝑐]張量爲例,不同 axis 參數的實際插入位置如下圖 4.6 所示:
在這裏插入圖片描述
刪除維度 是增加維度的逆操作,與增加維度一樣,刪除維度只能刪除長度爲 1 的維度,也不會改變張量的存儲。繼續考慮增加維度後 shape 爲[1,28,28,1]的例子,如果希望將圖片數量維度刪除,可以通過 tf.squeeze(x, axis)函數,axis 參數爲待刪除的維度的索引號,圖片數量的維度軸 axis=0:

x = tf.squeeze(x, axis=0)

<tf.Tensor: id=586, shape=(28, 28, 1), dtype=int32, numpy=
array([[[8],
 [2],
 [2],
 [0],

繼續刪除通道數維度,由於已經刪除了圖片數量維度,此時的 x 的 shape 爲[28,28,1],因此刪除通道數維度時指定 axis=2:

x = tf.squeeze(x, axis=2)

<tf.Tensor: id=588, shape=(28, 28), dtype=int32, numpy=
array([[8, 2, 2, 0, 7, 0, 1, 4, 9, 1, 7, 4, 8, 2, 7, 4, 8, 2, 9, 8, 8, 0,
 9, 9, 7, 5, 9, 7],
 [3, 4, 9, 9, 0, 6, 5, 7, 1, 9, 9, 1, 2, 7, 2, 7, 5, 3, 3, 7, 2, 4,
 5, 2, 7, 3, 8, 0],

如果不指定維度參數 axis,即 ·tf.squeeze(x)·,那麼他會默認刪除所有長度爲 1 的維度

x = tf.random.uniform([1,28,28,1],maxval=10,dtype=tf.int32)
tf.squeeze(x)

<tf.Tensor: id=594, shape=(28, 28), dtype=int32, numpy=
array([[9, 1, 4, 6, 4, 9, 0, 0, 1, 4, 0, 8, 5, 2, 5, 0, 0, 8, 9, 4, 5, 0,
 1, 1, 4, 3, 9, 9],
4.7.3 交換維度

改變視圖、增刪維度都不會影響張量的存儲。在實現算法邏輯時,在保持維度順序不變的條件下,僅僅改變張量的理解方式是不夠的,有時需要直接調整的存儲順序,即交換維度(Transpose)通過交換維度,改變了張量的存儲順序,同時也改變了張量的視圖

交換維度操作是非常常見的,比如在 TensorFlow 中,圖片張量的默認存儲格式是通道後行格式:[𝑏, ℎ, w, 𝑐],但是部分庫的圖片格式是通道先行:[𝑏, 𝑐, ℎ, w],因此需要完成[𝑏, ℎ, w, 𝑐]到[𝑏, 𝑐, ℎ,w ]維度交換運算。

我們以[𝑏, ℎ, w, 𝑐]轉換到[𝑏, 𝑐, ℎ,w ]爲例,介紹如何使用 tf.transpose(x, perm)函數完成維度交換操作,其中 perm 表示新維度的順序 List。考慮圖片張量 shape 爲[2,32,32,3],圖片數量、行、列、通道數的維度索引分別爲 0,1,2,3,如果需要交換爲[𝑏, 𝑐, ℎ, w]格式,則新維度的排序爲圖片數量、通道數、行、列,對應的索引號爲[0,3,1,2],實現如下:

x = tf.random.normal([2,32,32,3])
tf.transpose(x,perm=[0,3,1,2])

<tf.Tensor: id=603, shape=(2, 3, 32, 32), dtype=float32, numpy=
array([[[[-1.93072677e+00, -4.80163872e-01, -8.85614634e-01, ...,
 1.49124235e-01, 1.16427064e+00, -1.47740364e+00],
 [-1.94761145e+00, 7.26879001e-01, -4.41877693e-01, ...

如果希望將[𝑏, ℎ, w, 𝑐]交換爲[𝑏, w, ℎ, 𝑐],即將行列維度互換,則新維度索引爲[0,2,1,3]:

x = tf.random.normal([2,32,32,3])
tf.transpose(x,perm=[0,2,1,3])

<tf.Tensor: id=612, shape=(2, 32, 32, 3), dtype=float32, numpy=
array([[[[ 2.1266546 , -0.64206547, 0.01311932],
 [ 0.918484 , 0.9528751 , 1.1346699 ],
 ...,

需要注意的是,通過 tf.transpose 完成維度交換後,張量的存儲順序已經改變,視圖也隨之改變,後續的所有操作必須基於新的存續順序進行

4.7.4 數據複製

當通過增加維度操作插入新維度後,可能希望在新的維度上面複製若干份數據,滿足後續算法的格式要求。

考慮𝑌 = 𝑋@𝑊 + 𝒃的例子,偏置𝒃插入新維度後,需要在新維度上覆制 batch size 份數據,將 shape 變爲與𝑋@𝑊一致後,才能完成張量相加運算。可以通過tf.tile(x, multiples)函數完成數據在指定維度上的複製操作,multiples 分別指定了每個維度上面的複製倍數,對應位置爲 1 表明不復制,爲 2 表明新長度爲原來的長度的 2 倍,即數據複製一份,以此類推。

以輸入爲[2,4],輸出爲 3 個節點線性變換層爲例,偏置𝒃定義爲:
b=[b0b1b2]\boldsymbol{b}=\left[\begin{array}{l} b_{0} \\ b_{1} \\ b_{2} \end{array}\right]
通過 tf.expand_dims(b,axis=0)插入新維度:樣本數量維度
b=[b0b1b2]]\left.\boldsymbol{b}=\left[\begin{array}{lll} b_{0} & b_{1} & b_{2} \end{array}\right]\right]
此時𝒃的 shape 變爲[1,3],我們需要在 axis=0 圖片數量維度上根據輸入樣本的數量複製若干次,這裏的 batch size 爲 2,𝒃變爲矩陣 B:
B=[b0b1b2b0b1b2]B=\left[\begin{array}{lll} b_{0} & b_{1} & b_{2} \\ b_{0} & b_{1} & b_{2} \end{array}\right]
通過 tf.tile(b, multiples=[2,1])即可在 axis=0 維度複製 1 次,在 axis=1 維度不復制。首先插入新的維度:

b = tf.constant([1,2])
b = tf.expand_dims(b, axis=0)
b

<tf.Tensor: id=645, shape=(1, 2), dtype=int32, numpy=array([[1, 2]])>

在 batch 維度上覆制數據 1 份:

b = tf.tile(b, multiples=[2,1])

<tf.Tensor: id=648, shape=(2, 2), dtype=int32, numpy=
array([[1, 2],
 [1, 2]])>

此時 B 的 shape 變爲[2,3],可以直接與X@W進行相加運算。

考慮另一個例子,輸入 x 爲 2 行 2 列的矩陣:

x = tf.range(4)
x=tf.reshape(x,[2,2])

<tf.Tensor: id=655, shape=(2, 2), dtype=int32, numpy=
array([[0, 1],
 [2, 3]])>

首先在列維度複製 1 份數據:

x = tf.tile(x,multiples=[1,2])

<tf.Tensor: id=658, shape=(2, 4), dtype=int32, numpy=
array([[0, 1, 0, 1],
 [2, 3, 2, 3]])>

然後在行維度複製 1 份數據:

x = tf.tile(x,multiples=[2,1])

<tf.Tensor: id=672, shape=(4, 4), dtype=int32, numpy=
array([[0, 1, 0, 1],
 [2, 3, 2, 3],
 [0, 1, 0, 1],
 [2, 3, 2, 3]])>

經過 2 個維度上的複製運算後,可以看到數據的變化過程,shape 也變爲原來的 2 倍。

需要注意的是,tf.tile 會創建一個新的張量來保存複製後的張量,由於複製操作涉及到大量數據的讀寫 IO 運算,計算代價相對較高。神經網絡中不同 shape 之間的運算操作十分頻繁,那麼有沒有輕量級的複製操作呢?這就是接下來要介紹的 Broadcasting 操作。

4.8 Broadcasting

Broadcasting 也叫廣播機制(自動擴展也許更合適),它是一種輕量級張量複製的手段,在邏輯上擴展張量數據的形狀,但是隻要在需要時纔會執行實際存儲複製操作。對於大部分場景,Broadcasting 機制都能通過優化手段避免實際複製數據而完成邏輯運算,從而相對於 tf.tile 函數,減少了大量計算代價。

對於所有長度爲 1 的維度,Broadcasting 的效果和 tf.tile 一樣,都能在此維度上邏輯複製數據若干份,區別在於 tf.tile 會創建一個新的張量,執行復制 IO 操作,並保存複製後的張量數據,Broadcasting 並不會立即複製數據,它會邏輯上改變張量的形狀,使得視圖上變成了複製後的形狀。

Broadcasting 會通過深度學習框架的優化手段避免實際複製數據而完成邏輯運算,至於怎麼實現的用戶不必關係,對於用戶來說,Broadcasting 和 tf.tile 複製的最終效果是一樣的,操作對用戶透明,但是 Broadcasting 機制節省了大量計算資源,建議在運算過程中儘可能地利用 Broadcasting 提高計算效率。

繼續考慮上述的Y = X@W + 𝒃的例子,X@W的 shape 爲[2,3],𝒃的 shape 爲[3],我們可以通過結合 tf.expand_dimstf.tile 完成實際複製數據運算,將𝒃變換爲[2,3],然後與X@W完成相加。但實際上,我們直接將 shape 爲[2,3]與[3]的𝒃相加:

x = tf.random.normal([2,4])
w = tf.random.normal([4,3])
b = tf.random.normal([3])
y = x@w+b

上述加法並沒有發生邏輯錯誤,那麼它是怎麼實現的呢?這是因爲它自動調用 Broadcasting函數 tf.broadcast_to(x, new_shape),將 2 者 shape 擴張爲相同的[2,3],即上式可以等效爲:

y = x@w + tf.broadcast_to(b,[2,3])

也就是說,操作符+在遇到 shape 不一致的 2 個張量時,會自動考慮將 2 個張量Broadcasting 到一致的 shape,然後再調用 tf.add 完成張量相加運算,這也就解釋了我們之前一直存在的困惑。通過自動調用 tf.broadcast_to(b, [2,3])的 Broadcasting 機制,既實現了增加維度、複製數據的目的,又避免實際複製數據的昂貴計算代價,同時書寫更加簡潔高效

那麼有了Broadcasting 機制後,所有 shape 不一致的張量是不是都可以直接完成運算?很明顯,所有的運算都需要在正確邏輯下進行,Broadcasting 機制並不會擾亂正常的計算邏輯,它只會針對於最常見的場景自動完成增加維度並複製數據的功能,提高開發效率和運行效率。這種最常見的場景是什麼呢?這就要說到 Broadcasting 設計的核心思想。

Broadcasting 機制的核心思想是普適性,即同一份數據能普遍適合於其他位置。在驗證普適性之前,需要將張量 shape 靠右對齊,然後進行普適性判斷:對於長度爲 1 的維度,默認這個數據普遍適合於當前維度的其他位置;對於不存在的維度,則在增加新維度後默認當前數據也是普適性於新維度的,從而可以擴展爲更多維度數、其他長度的張量形狀。

考慮 shape 爲[ , 1]的張量 A,需要擴展爲 shape:[𝑏, ℎ, w, 𝑐],如圖 4.7 所示,上行爲欲擴展的 shape,下面爲現有 shape:
在這裏插入圖片描述
首先將 2 個 shape 靠右對齊,對於通道維度 c,張量的現長度爲 1,則默認此數據同樣適合當前維度的其他位置,將數據邏輯上覆制𝑐 − 1份,長度變爲 c;對於不存在的 b 和 h 維度,則自動插入新維度,新維度長度爲 1,同時默認當前的數據普適於新維度的其他位置,即對於其它的圖片、其他的行來說,與當前的這一行的數據完全一致。這樣將數據b,h 維度的長度自動擴展爲 b,h,如圖 4.8 所示:
在這裏插入圖片描述
通過 tf.broadcast_to(x, new_shape)可以顯式將現有 shape 擴張爲 new_shape:

A = tf.random.normal([32,1])
tf.broadcast_to(A, [2,32,32,3])

<tf.Tensor: id=13, shape=(2, 32, 32, 3), dtype=float32, numpy=
array([[[[-1.7571245 , -1.7571245 , -1.7571245 ],
 [ 1.580159 , 1.580159 , 1.580159 ],
 [-1.5324328 , -1.5324328 , -1.5324328 ],...

可以看到,在普適性原則的指導下,Broadcasting 機制變得直觀好理解,它的設計是非常符合人的思維模式。

我們來考慮不滿足普適性原則的例子,如下圖 4.9 所示:
在這裏插入圖片描述
在 c 維度上,張量已經有 2 個特徵數據,新 shape 對應維度長度爲 c(𝑐 ≠ 2,比如 c=3),那麼當前維度上的這 2 個特徵無法普適到其他長度,故不滿足普適性原則,無法應用Broadcasting 機制,將會觸發錯誤:

A = tf.random.normal([32,2])
tf.broadcast_to(A, [2,32,32,4])

InvalidArgumentError: Incompatible shapes: [32,2] vs. [2,32,32,4]
[Op:BroadcastTo]

在進行張量運算時,有些運算可以在處理不同 shape 的張量時,會隱式自動調用Broadcasting 機制,如+,-,*,/等運算等,將參與運算的張量 Broadcasting 成一個公共shape,再進行相應的計算,如圖 4.10 所示,演示了 3 種不同 shape 下的張量 A,B 相加的例子
在這裏插入圖片描述
簡單測試一下基本運算符的自動 Broadcasting 機制:

a = tf.random.normal([2,32,32,1])
b = tf.random.normal([32,32])
a+b,a-b,a*b,a/b

這些運算都能 Broadcasting 成[2,32,32,32]的公共 shape,再進行運算。熟練掌握並運用Broadcasting 機制可以讓代碼更簡潔,計算效率更高。

4.9 數學運算

前面的章節我們已經使用了基本的加減乘除等數學運算函數,本節我們將系統地介紹TensorFlow 中常見的數學運算函數。

4.9.1 加減乘除

加減乘除是最基本的數學運算,分別通過 tf.add, tf.subtract, tf.multiply, tf.divide 函數實現,TensorFlow 已經重載了+ −∗/運算符,一般推薦直接使用運算符來完成加減乘除運算。

整除和餘除也是常見的運算之一,分別通過//和%運算符實現。我們來演示整除運算:

a = tf.range(5)
b = tf.constant(2)
a//b
<tf.Tensor: id=115, shape=(5,), dtype=int32, numpy=array([0, 0, 1, 1, 2])>

餘除運算:

a%b

<tf.Tensor: id=117, shape=(5,), dtype=int32, numpy=array([0, 1, 0, 1, 0])>
4.9.2 乘方

通過 tf.pow(x, a)可以方便地完成𝑦 = xax^{a}乘方運算,也可以通過運算符**實現𝑥 ∗∗ 𝑎運算,實現如下:

x = tf.range(4)
tf.pow(x,3)

<tf.Tensor: id=124, shape=(4,), dtype=int32, numpy=array([ 0, 1, 8, 27])>

x**2

<tf.Tensor: id=127, shape=(4,), dtype=int32, numpy=array([0, 1, 4, 9])>

設置指數爲1a\frac{1}{a}形式即可實現根號運算:xa\sqrt[a]{x}:

x=tf.constant([1.,4.,9.])
x**(0.5)

<tf.Tensor: id=139, shape=(3,), dtype=float32, numpy=array([1., 2., 3.],
dtype=float32)>

特別地,對於常見的平方和平方根運算,可以使用 tf.square(x)tf.sqrt(x)實現。平方運算實現如下:

x = tf.range(5)
x = tf.cast(x, dtype=tf.float32)
x = tf.square(x)

<tf.Tensor: id=159, shape=(5,), dtype=float32, numpy=array([ 0., 1., 4.,
9., 16.], dtype=float32)>

平方根運算實現如下:

tf.sqrt(x)

<tf.Tensor: id=161, shape=(5,), dtype=float32, numpy=array([0., 1., 2., 3.,
4.], dtype=float32)>
4.9.3 指數、對數

通過 tf.pow(a, x)或者**運算符可以方便實現指數運算axa^{x}

x = tf.constant([1.,2.,3.])
2**x

<tf.Tensor: id=179, shape=(3,), dtype=float32, numpy=array([2., 4., 8.],
dtype=float32)>

特別地,對於自然指數exe^{x},可以通過 tf.exp(x)實現:

tf.exp(1.)

<tf.Tensor: id=182, shape=(), dtype=float32, numpy=2.7182817>

在 TensorFlow 中,自然對數logex\log _{e} x可以通過 tf.math.log(x)實現:

x=tf.exp(3.)
tf.math.log(x)

<tf.Tensor: id=186, shape=(), dtype=float32, numpy=3.0>

如果希望計算其他底數的對數,可以根據對數的換底公式:
logax=logexlogea\log _{a} x=\frac{\log _{e} x}{\log _{e} a}
間接的通過 tf.math.log(x)實現。如計算log10x\log _{10} x可以通過logexloge10\frac{\log _{e} x}{\log _{e} 10}實現如下:

x = tf.constant([1.,2.])
x = 10**x
tf.math.log(x)/tf.math.log(10.)

<tf.Tensor: id=222, shape=(2,), dtype=float32, numpy=array([0. ,
2.3025851], dtype=float32)>

實現起來相對繁瑣,也許 TensorFlow 以後會推出任意底數的 log 函數.

4.9.4 矩陣相乘

神經網絡中間包含了大量的矩陣相乘運算,前面我們已經介紹了通過@運算符可以方便的實現矩陣相乘,還可以通過 tf.matmul(a, b)實現。需要注意的是,TensorFlow 中的矩陣相乘可以使用批量方式,也就是張量 a,b 的維度數可以大於 2。當張量 a,b 維度數大於 2時,TensorFlow 會選擇 a,b 的最後兩個維度進行矩陣相乘,前面所有的維度都視作 Batch 維度。

根據矩陣相乘的定義,a 和 b 能夠矩陣相乘的條件是,a 的倒數第一個維度長度(列)和b 的倒數第二個維度長度(行)必須相等。比如張量 a shape:[4,3,28,32]可以與張量 bshape:[4,3,32,2]進行矩陣相乘:

a = tf.random.normal([4,3,23,32])
b = tf.random.normal([4,3,32,2])
a@b

<tf.Tensor: id=236, shape=(4, 3, 28, 2), dtype=float32, numpy=
array([[[[-1.66706240e+00, -8.32602978e+00],
 [ 9.83304405e+00, 8.15909767e+00],
 [ 6.31014729e+00, 9.26124632e-01],

得到 shape 爲[4,3,28,2]的結果。

矩陣相乘函數支持自動 Broadcasting機制:

a = tf.random.normal([4,28,32])
b = tf.random.normal([32,16])
tf.matmul(a,b)

<tf.Tensor: id=264, shape=(4, 28, 16), dtype=float32, numpy=
array([[[-1.11323869e+00, -9.48194981e+00, 6.48123884e+00, ...,
 6.53280640e+00, -3.10894990e+00, 1.53050375e+00],
 [ 4.35898495e+00, -1.03704405e+01, 8.90656471e+00, ...,
4.10 前向傳播實戰

到現在爲止,我們已經介紹瞭如何創建張量,對張量進行索引切片,維度變換和常見的數學運算等操作。本節我們將利用我們已經學到的知識去完成三層神經網絡的實現:
out=relu{relu{relu[X@W1+b1]@W2+b2}@W3+b3}\left.\text {out}\left.=\text {relu\{relu\{relu}\left[X @ W_{1}+b_{1}\right] @ W_{2}+b_{2}\right\} @ W_{3}+b_{3}\right\}

我們採用的數據集是 MNIST 手寫數字圖片集,輸入節點數爲 784,第一層的輸出節點數是256,第二層的輸出節點數是 128,第三層的輸出節點是 10,也就是當前樣本屬於 10 類別的概率。

首先創建每個非線性函數的 w,b 參數張量:

w1 = tf.Variable(tf.random.truncated_normal([784, 256], stddev=0.1))
b1 = tf.Variable(tf.zeros([256]))
w2 = tf.Variable(tf.random.truncated_normal([256, 128], stddev=0.1))
b2 = tf.Variable(tf.zeros([128]))
w3 = tf.Variable(tf.random.truncated_normal([128, 10], stddev=0.1))
b3 = tf.Variable(tf.zeros([10]))

在前向計算時,首先將 shape 爲[𝑏, 28,28]的輸入數據 Reshape 爲[𝑏, 784]:

 # [b, 28, 28] => [b, 28*28]
 x = tf.reshape(x, [-1, 28*28])

完成第一個非線性函數的計算,我們這裏顯示地進行 Broadcasting:

 # [b, 784]@[784, 256] + [256] => [b, 256] + [256] => [b, 256] +[b, 256]
 h1 = x@w1 + tf.broadcast_to(b1, [x.shape[0], 256])
 h1 = tf.nn.relu(h1)

同樣的方法完成第二個和第三個非線性函數的前向計算,輸出層可以不使用 ReLU 激活函數:

 # [b, 256] => [b, 128]
 h2 = h1@w2 + b2
 h2 = tf.nn.relu(h2)
 # [b, 128] => [b, 10]
 out = h2@w3 + b3

將真實的標註張量 y 轉變爲 one-hot 編碼,並計算與 out 的均方差:

 # mse = mean(sum(y-out)^2)
 # [b, 10]
 loss = tf.square(y_onehot - out)
 # mean: scalar
 loss = tf.reduce_mean(loss)

上述的前向計算過程都需要包裹在 with tf.GradientTape() as tape 上下文中,使得前向計算時能夠保存計算圖信息,方便反向求導運算。
通過 tape.gradient()函數求得網絡參數到梯度信息:

 # compute gradients
 grads = tape.gradient(loss, [w1, b1, w2, b2, w3, b3])

並按照
θ=θηLθ\theta^{\prime}=\theta-\eta * \frac{\partial \mathcal{L}}{\partial \theta}
來更新網絡參數:

# w1 = w1 - lr * w1_grad
 w1.assign_sub(lr * grads[0])
 b1.assign_sub(lr * grads[1])
 w2.assign_sub(lr * grads[2])
 b2.assign_sub(lr * grads[3])
 w3.assign_sub(lr * grads[4])
 b3.assign_sub(lr * grads[5])

其中 assign_sub()將原地(In-place)減去給定的參數值,實現參數的自我更新操作。網絡訓練
誤差值的變化曲線如圖 4.11 所示。
在這裏插入圖片描述

第5章 TensorFlow 進階

在介紹完張量的基本操作後,我們來進一步學習張量的進階操作,如張量的合併與分割,範數統計,張量填充,限幅等,並用過 MNIST 數據集的測試實戰加深讀者對TensorFlow 張量操作的立即。

5.1 合併與分割
5.1.1 合併

合併是指將多個張量在某個維度上合併爲一個張量。以某學校班級成績冊數據爲例,設張量 A 保存了某學校 1-4 號班級的成績冊,每個班級 35 個學生,共 8 門科目,則張量 A的 shape 爲:[4,35,8];同樣的方式,張量 B 保存了剩下的 6 個班級的成績冊,shape 爲[6,35,8]。通過合併 2 個成績冊,便可得到學校所有班級的成績冊張量 C,shape 應爲[10,35,8]。這就是張量合併的意義所在。張量的合併可以使用拼接(Concatenate)和堆疊(Stack)操作實現,拼接並不會產生新的維度,而堆疊會創建新維度。選擇使用拼接還是堆疊操作來合併張量,取決於具體的場景是否需要創建新維度。

拼接 在 TensorFlow 中,可以通過 tf.concat(tensors, axis),其中 tensors 保存了所有需要合併的張量 List,axis 指定需要合併的維度。回到上面的例子,這裏班級維度索引號爲 0,即 axis=0,合併張量 A,B 如下:

a = tf.random.normal([4,35,8]) # 模擬成績冊 A
b = tf.random.normal([6,35,8]) # 模擬成績冊 B
tf.concat([a,b],axis=0) # 合併成績冊

<tf.Tensor: id=13, shape=(10, 35, 8), dtype=float32, numpy=
array([[[ 1.95299834e-01, 6.87859178e-01, -5.80048323e-01, ...,
 1.29430830e+00, 2.56610274e-01, -1.27798581e+00],
 [ 4.29753691e-01, 9.11329567e-01, -4.47975427e-01, ...,

除了可以在班級維度上進行合併,還可以在其他維度上合併張量。考慮張量 A 保存了所有班級所有學生的前 4 門科目成績,shape 爲[10,35,4],張量 B 保存了剩下的 4 門科目成績,shape 爲[10,35,4],則可以合併 shape 爲[10,35,8]的總成績冊張量:

a = tf.random.normal([10,35,4])
b = tf.random.normal([10,35,4])
tf.concat([a,b],axis=2) # 在科目維度拼接

<tf.Tensor: id=28, shape=(10, 35, 8), dtype=float32, numpy=
array([[[-5.13509691e-01, -1.79707789e+00, 6.50747120e-01, ...,
 2.58447856e-01, 8.47878829e-02, 4.13468748e-01],
 [-1.17108583e+00, 1.93961406e+00, 1.27830813e-02, ...,

合併操作可以在任意的維度上進行,唯一的約束是非合併維度的長度必須一致。比如 shape爲[4,32,8]和 shape 爲[6,35,8]的張量則不能直接在班級維度上進行合併,因爲學生數維度的長度並不一致,一個爲 32,另一個爲 35:

a = tf.random.normal([4,32,8])
b = tf.random.normal([6,35,8])
tf.concat([a,b],axis=0) # 非法拼接

InvalidArgumentError: ConcatOp : Dimensions of inputs should match: shape[0]
= [4,32,8] vs. shape[1] = [6,35,8] [Op:ConcatV2] name: concat

堆疊 tf.concat 直接在現有維度上面合併數據,並不會創建新的維度。如果在合併數據時,希望創建一個新的維度,則需要使用 tf.stack 操作。考慮張量 A 保存了某個班級的成績冊,shape 爲[35,8],張量 B 保存了另一個班級的成績冊,shape 爲[35,8]。合併這 2 個班級的數據時,需要創建一個新維度,定義爲班級維度,新維度可以選擇放置在任意位置,一般根據大小維度的經驗法則,將較大概念的班級維度放置在學生維度之前,則合併後的張量的新 shape 應爲[2,35,8]。

使用 tf.stack(tensors, axis)可以合併多個張量 tensors,其中 axis 指定插入新維度的位置,axis 的用法與 tf.expand_dims 的一致,當axis ≥ 0時,在 axis 之前插入;當axis < 0時,在 axis 之後插入新維度例如 shape 爲[𝑏, 𝑐, ℎ, 𝑤]的張量,在不同位置通過 stack 操作插入新維度,axis 參數對應的插入位置設置如圖 5.1 所示:
在這裏插入圖片描述
堆疊方式合併這 2 個班級成績冊如下:

a = tf.random.normal([35,8])
b = tf.random.normal([35,8])
tf.stack([a,b],axis=0) # 堆疊合併爲 2 個班級

<tf.Tensor: id=55, shape=(2, 35, 8), dtype=float32, numpy=
array([[[ 3.68728966e-01, -8.54765773e-01, -4.77824420e-01,
 -3.83714020e-01, -1.73216307e+00, 2.03872994e-02,
 2.63810277e+00, -1.12998331e+00],

同樣可以選擇在其他位置插入新維度,如在最末尾插入:

a = tf.random.normal([35,8])
b = tf.random.normal([35,8])
tf.stack([a,b],axis=-1) # 在末尾插入班級維度

<tf.Tensor: id=69, shape=(35, 8, 2), dtype=float32, numpy=
array([[[ 0.3456724 , -1.7037214 ],
 [ 0.41140947, -1.1554345 ],
 [ 1.8998919 , 0.56994915],

此時班級的維度在 axis=2 軸上面,理解時也需要按着最新的維度順序去理解數據。若選擇使用 tf.concat 上述成績單,則可以合併爲:

a = tf.random.normal([35,8])
b = tf.random.normal([35,8])
tf.concat([a,b],axis=0) # 拼接方式合併,沒有 2 個班級的概念

<tf.Tensor: id=108, shape=(70, 8), dtype=float32, numpy=
array([[-0.5516891 , -1.5031327 , -0.35369992, 0.31304857, 0.13965549,
 0.6696881 , -0.50115544, 0.15550546],
 [ 0.8622069 , 1.0188094 , 0.18977325, 0.6353301 , 0.05809061,

tf.concat 也可以順利合併數據,但是在理解時,需要按着前 35 個學生來自第一個班級,後35 個學生來自第二個班級的方式。在這裏,明顯通過 tf.stack 方式創建新維度的方式更合理,得到的 shape 爲[2,35,8]的張量也更容易理解。

tf.stack 也需要滿足張量堆疊合並條件,它需要所有合併的張量 shape 完全一致纔可合併。我們來看張量 shape 不一致時進行堆疊合並會發生的錯誤:

a = tf.random.normal([35,4])
b = tf.random.normal([35,8])
tf.stack([a,b],axis=-1) # 非法堆疊操作

InvalidArgumentError: Shapes of all inputs must match: values[0].shape =
[35,4] != values[1].shape = [35,8] [Op:Pack] name: stack

上述操作嘗試合併 shape 爲[35,4]和[35,8]的 2 個張量,由於 2 者形狀不一致,無法完成合並操作。

5.1.2 分割

合併操作的逆過程就是分割,將一個張量分拆爲多個張量。繼續考慮成績冊的例子,我們得到整個學校的成績冊張量,shape 爲[10,35,8],現在需要將數據在班級維度切割爲10 個張量,每個張量保存了對應班級的成績冊。

通過 tf.split(x, axis, num_or_size_splits)可以完成張量的分割操作,其中

❑ x:待分割張量
❑ axis:分割的維度索引號
❑ num_or_size_splits:切割方案。當 num_or_size_splits 爲單個數值時,如 10,表示切割爲 10 份;當 num_or_size_splits 爲 List 時,每個元素表示每份的長度,如[2,4,2,2]表示切割爲 4 份,每份的長度分別爲 2,4,2,2

現在我們將總成績冊張量切割爲 10 份:

x = tf.random.normal([10,35,8])
# 等長切割
result = tf.split(x,axis=0,num_or_size_splits=10)
len(result)  #[1,35,8]

10

可以查看切割後的某個張量的形狀,它應是某個班級的所有成績冊數據,shape 爲[35,8]之類:

result[0]

Out[9]: <tf.Tensor: id=136, shape=(1, 35, 8), dtype=float32, numpy=
array([[[-1.7786729 , 0.2970506 , 0.02983334, 1.3970423 ,
 1.315918 , -0.79110134, -0.8501629 , -1.5549672 ],
 [ 0.5398711 , 0.21478991, -0.08685189, 0.7730989 ,

可以看到,切割後的班級 shape 爲[1,35,8],保留了班級維度,這一點需要注意。我們進行不等長的切割:將數據切割爲 4份,每份長度分別爲[4,2,2,2]:

x = tf.random.normal([10,35,8])
# 自定義長度的切割
result = tf.split(x,axis=0,num_or_size_splits=[4,2,2,2])
len(result)

4

查看第一個張量的 shape,根據我們的切割方案,它應該包含了 4 個班級的成績冊:

result[0]

<tf.Tensor: id=155, shape=(4, 35, 8), dtype=float32, numpy=
array([[[-6.95693314e-01, 3.01393479e-01, 1.33964568e-01, ...,

特別地,如果希望在某個維度上全部按長度爲 1 的方式分割,還可以直接使用 tf.unstack(x,axis)。這種方式是 tf.split 的一種特殊情況,切割長度固定爲 1,只需要指定切割維度即可。例如,將總成績冊張量在班級維度進行 unstack:

x = tf.random.normal([10,35,8])
result = tf.unstack(x,axis=0) # Unstack 爲長度爲 1
len(result)

10

查看切割後的張量的形狀:

result[0]

<tf.Tensor: id=166, shape=(35, 8), dtype=float32, numpy=
array([[-0.2034383 , 1.1851563 , 0.25327438, -0.10160723, 2.094969 ,
 -0.8571669 , -0.48985648, 0.55798006],

可以看到,通過 tf.unstack 切割後,shape 變爲[35,8],即班級維度消失了,這也是與 tf.split
區別之處。

5.2 數據統計

在神經網絡的計算過程中,經常需要統計數據的各種屬性,如最大值,均值,範數等等。由於張量通常 shape 較大,直接觀察數據很難獲得有用信息,通過觀察這些張量統計信息可以較輕鬆地推測張量數值的分佈。

5.2.1 向量範數

向量範數(Vector norm)是表徵向量“長度”的一種度量方法,在神經網絡中,常用來表示張量的權值大小,梯度大小等。常用的向量範數有:

❑ L1 範數,定義爲向量𝒙的所有元素絕對值之和
x1=ixi\|x\|_{1}=\sum_{i}\left|x_{i}\right|
❑ L2 範數,定義爲向量𝒙的所有元素的平方和,再開根號
x2=ixi2\|x\|_{2}=\sqrt{\sum_{i}\left|x_{i}\right|^{2}}
❑ ∞ −範數,定義爲向量𝒙的所有元素絕對值的最大值:
x=maxi(xi)\|x\|_{\infty}=\max _{i}\left(\left|x_{i}\right|\right)

對於矩陣、張量,同樣可以利用向量範數的計算公式,等價於將矩陣、張量打平成向量後計算。

在 TensorFlow 中,可以通過 tf.norm(x, ord)求解張量的 L1, L2, ∞等範數,其中參數 ord指定爲 1,2 時計算 L1, L2 範數,指定爲 np.inf 時計算∞ −範數:

x = tf.ones([2,2])
tf.norm(x,ord=1) # 計算 L1 範數

<tf.Tensor: id=183, shape=(), dtype=float32, numpy=4.0>

tf.norm(x,ord=2) # 計算 L2 範數

Out[14]: <tf.Tensor: id=189, shape=(), dtype=float32, numpy=2.0>

import numpy as np
tf.norm(x,ord=np.inf) # 計算∞範數
Out[15]: <tf.Tensor: id=194, shape=(), dtype=float32, numpy=1.0>
5.2.2 最大最小值、均值、和

通過 tf.reduce_max, tf.reduce_min, tf.reduce_mean, tf.reduce_sum 可以求解張量在某個維度上的最大、最小、均值、和,也可以求全局最大、最小、均值、和信息。

考慮 shape 爲[4,10]的張量,其中第一個維度代表樣本數量,第二個維度代表了當前樣本分別屬於 10 個類別的概率,需要求出每個樣本的概率最大值爲:

x = tf.random.normal([4,10])
tf.reduce_max(x,axis=1) # 統計概率維度上的最大值

Out[16]:<tf.Tensor: id=203, shape=(4,), dtype=float32,
numpy=array([1.2410722 , 0.88495886, 1.4170984 , 0.9550192 ],
dtype=float32)>

同樣求出每個樣本概率的最小值:

tf.reduce_min(x,axis=1) # 統計概率維度上的最小值

Out[17]:<tf.Tensor: id=206, shape=(4,), dtype=float32, numpy=array([-
0.27862206, -2.4480672 , -1.9983795 , -1.5287997 ], dtype=float32)>

求出每個樣本的概率的均值:

tf.reduce_mean(x,axis=1) # 統計概率維度上的均值

Out[18]:<tf.Tensor: id=209, shape=(4,), dtype=float32,
numpy=array([ 0.39526337, -0.17684573, -0.148988 , -0.43544054],
dtype=float32)>

當不指定 axis 參數時,tf.reduce_*函數會求解出全局元素的最大、最小、均值、和:

x = tf.random.normal([4,10])
# 統計全局的最大、最小、均值、和
tf.reduce_max(x),tf.reduce_min(x),tf.reduce_mean(x)

Out [19]: (<tf.Tensor: id=218, shape=(), dtype=float32, numpy=1.8653786>,
<tf.Tensor: id=220, shape=(), dtype=float32, numpy=-1.9751656>,
<tf.Tensor: id=222, shape=(), dtype=float32, numpy=0.014772797>)

在求解誤差函數時,通過 TensorFlow 的 MSE 誤差函數可以求得每個樣本的誤差,需要計算樣本的平均誤差,此時可以通過 tf.reduce_mean 在樣本數維度上計算均值:

out = tf.random.normal([4,10]) # 網絡預測輸出
y = tf.constant([1,2,2,0]) # 真實標籤
y = tf.one_hot(y,depth=10) # one-hot 編碼
loss = keras.losses.mse(y,out) # 計算每個樣本的誤差
loss = tf.reduce_mean(loss) # 平均誤差
loss

<tf.Tensor: id=241, shape=(), dtype=float32, numpy=1.1921183>

與均值函數相似的是求和函數 tf.reduce_sum(x,axis),它可以求解張量在 axis 軸上所有特徵的和:

out = tf.random.normal([4,10])
tf.reduce_sum(out,axis=-1) # 求和

Out[21]:<tf.Tensor: id=303, shape=(4,), dtype=float32, numpy=array([-
0.588144 , 2.2382064, 2.1582587, 4.962141 ], dtype=float32)>

除了希望獲取張量的最值信息,還希望獲得最值所在的索引號,例如分類任務的標籤預測。

考慮 10 分類問題,我們得到神經網絡的輸出張量 out,shape 爲[2,10],代表了 2 個樣本屬於 10 個類別的概率,由於元素的位置索引代表了當前樣本屬於此類別的概率,預測時往往會選擇概率值最大的元素所在的索引號作爲樣本類別的預測值:

out = tf.random.normal([2,10])
out = tf.nn.softmax(out, axis=1) # 通過 softmax 轉換爲概率值
out

Out[22]:<tf.Tensor: id=257, shape=(2, 10), dtype=float32, numpy=
array([
[0.18773547, 0.1510464 , 0.09431915, 0.13652141, 0.06579739,0.02033597, 0.06067333, 0.0666793 , 0.14594753, 0.07094406],
[0.5092072 , 0.03887136, 0.0390687 , 0.01911005, 0.03850609,0.03442522, 0.08060656, 0.10171875, 0.08244187, 0.05604421]],
 dtype=float32)>

以第一個樣本爲例,可以看到,它概率最大的索引爲𝑖 = 0,最大概率值爲 0.1877。由於每個索引號上的概率值代表了樣本屬於此索引號的類別的概率,因此第一個樣本屬於 0 類的概率最大,在預測時考慮第一個樣本應該最有可能屬於類別 0。這就是需要求解最大值的索引號的一個典型應用。通過 tf.argmax(x, axis)tf.argmin(x, axis)可以求解在 axis 軸上,x 的最大值、最小值所在的索引號:

pred = tf.argmax(out, axis=1) # 選取概率最大的位置
pred

Out[23]:<tf.Tensor: id=262, shape=(2,), dtype=int64, numpy=array([0, 0],
dtype=int64)>

可以看到,這 2 個樣本概率最大值都出現在索引 0 上,因此最有可能都是類別 0,我們將類別 0 作爲這 2 個樣本的預測類別。

5.3 張量比較

爲了計算分類任務的準確率等指標,一般需要將預測結果和真實標籤比較,統計比較結果中正確的數量來就是計算準確率。考慮 100 個樣本的預測結果:

out = tf.random.normal([100,10])
out = tf.nn.softmax(out, axis=1) # 輸出轉換爲概率
pred = tf.argmax(out, axis=1) # 選取預測值
Out[24]:<tf.Tensor: id=272, shape=(100,), dtype=int64, numpy=
array([0, 6, 4, 3, 6, 8, 6, 3, 7, 9, 5, 7, 3, 7, 1, 5, 6, 1, 2, 9, 0, 6,
 5, 4, 9, 5, 6, 4, 6, 0, 8, 4, 7, 3, 4, 7, 4, 1, 2, 4, 9, 4,

可以看到我們模擬的 100 個樣本的預測值,我們與這 100 樣本的真實值比較:

# 真實標籤
y = tf.random.uniform([100],dtype=tf.int64,maxval=10)

Out[25]:<tf.Tensor: id=281, shape=(100,), dtype=int64, numpy=
array([0, 9, 8, 4, 9, 7, 2, 7, 6, 7, 3, 4, 2, 6, 5, 0, 9, 4, 5, 8, 4, 2,
 5, 5, 5, 3, 8, 5, 2, 0, 3, 6, 0, 7, 1, 1, 7, 0, 6, 1, 2, 1, 3,

即可獲得每個樣本是否預測正確。通過 tf.equal(a, b)(或 tf.math.equal(a, b))函數可以比較這 2個張量是否相等:

out = tf.equal(pred,y) # 預測值與真實值比較

Out[26]:<tf.Tensor: id=288, shape=(100,), dtype=bool, numpy=
array([False, False, False, False, True, False, False, False, False,
 False, False, False, False, False, True, False, False, True,

tf.equal()函數返回布爾型的張量比較結果,只需要統計張量中 True 元素的個數,即可知道預測正確的個數。爲了達到這個目的,我們先將布爾型轉換爲整形張量,再求和其中 1 的個數,可以得到比較結果中 True 元素的個數:

out = tf.cast(out, dtype=tf.float32) # 布爾型轉 int 型
correct = tf.reduce_sum(out) # 統計 True 的個數

Out[27]:<tf.Tensor: id=293, shape=(), dtype=float32, numpy=12.0>

可以看到,我們隨機產生的預測數據的準確度是
 accuracy =12100=12%\text { accuracy }=\frac{12}{100}=12 \%
這也是隨機預測模型的正常水平。

除了比較相等的 tf.equal(a, b)函數,其他的比較函數用法類似,如表格 5.1 所示:
在這裏插入圖片描述

5.4 填充與複製
5.4.1 填充

對於圖片數據的高和寬、序列信號的長度,維度長度可能各不相同。爲了方便網絡的並行計算,需要將不同長度的數據擴張爲相同長度,之前我們介紹了通過複製的方式可以增加數據的長度,但是重複複製數據會破壞原有的數據結構,並不適合於此處。通常的做法是,在需要補充長度的信號開始或結束處填充足夠數量的特定數值,如 0,使得填充後的長度滿足系統要求。那麼這種操作就叫做填充(Padding)。

考慮 2 個句子張量,每個單詞使用數字編碼的方式,如 1 代表 I,2 代表 like 等。第一個句子爲:

“I like the weather today.”

我們假設句子編碼爲:[1,2,3,4,5,6],第二個句子爲:

“So do I.”

它的編碼爲:[7,8,1,6]。爲了能夠保存在同一個張量中,我們需要將這兩個句子的長度保持一致,也就是說,需要將第二個句子的長度擴充爲 6。常見的填充方案是在句子末尾填充若干數量的 0,變成:

[7,8,1,6,0,0]

此時這兩個句子堆疊合並 shape 爲[2,6]的張量。

填充操作可以通過 tf.pad(x, paddings)函數實現,paddings 是包含了多個[𝐿𝑒𝑓𝑡 𝑃𝑎𝑑𝑑𝑖𝑛𝑔, 𝑅𝑖𝑔ℎ𝑡 𝑃𝑎𝑑𝑑𝑖𝑛𝑔]的嵌套方案 List.

如[[0,0],[2,1],[1,2]]表示第一個維度不填充,第二個維度左邊(起始處)填充兩個單元,右邊(結束處)填充一個單元,第三個維度左邊填充一個單元,右邊填充兩個單元。

考慮上述 2 個句子的例子,需要在第二個句子的第一個維度的右邊填充 2 個單元,則 paddings 方案爲[[0,2]]:

a = tf.constant([1,2,3,4,5,6])
b = tf.constant([7,8,1,6])
b = tf.pad(b, [[0,2]]) # 填充
b

Out[28]:<tf.Tensor: id=3, shape=(6,), dtype=int32, numpy=array([7, 8, 1, 6,
0, 0])>

填充後句子張量形狀一致,再將這 2 句子 Stack 在一起:

tf.stack([a,b],axis=0) # 合併

Out[29]:<tf.Tensor: id=5, shape=(2, 6), dtype=int32, numpy=
array([[1, 2, 3, 4, 5, 6],
 [7, 8, 1, 6, 0, 0]])>

在自然語言處理中,需要加載不同句子長度的數據集,有些句子長度較小,如 10 個單詞左右,部份句子長度較長,如超過 100 個單詞。爲了能夠保存在同一張量中,一般會選取能夠覆蓋大部分句子長度的閾值,如 80 個單詞:對於小於 80 個單詞的句子,在末尾填充相應數量的 0;對大於 80 個單詞的句子,截斷超過規定長度的部分單詞。以 IMDB 數據集的加載爲例:

total_words = 10000 # 設定詞彙量大小
max_review_len = 80 # 最大句子長度
embedding_len = 100 # 詞向量長度
# 加載 IMDB 數據集
(x_train, y_train), (x_test, y_test) =keras.datasets.imdb.load_data(num_words=total_words)
# 將句子填充或截斷到相同長度,設置爲末尾填充和末尾截斷方式
x_train = keras.preprocessing.sequence.pad_sequences(
x_train,maxlen=max_review_len,truncating='post',padding='post')

x_test = keras.preprocessing.sequence.pad_sequences(
x_test,maxlen=max_review_len,truncating='post',padding='post')

print(x_train.shape, x_test.shape)

Out[30]: (25000, 80) (25000, 80)

上述代碼中,我們將句子的最大長度 max_review_len 設置爲 80 個單詞,通過keras.preprocessing.sequence.pad_sequences 可以快速完成句子的填充和截斷工作,以其中某個句子爲例:

[ 1 778 128 74 12 630 163 15 4 1766 7982 1051 2 32
 85 156 45 40 148 139 121 664 665 10 10 1361 173 4
 749 2 16 3804 8 4 226 65 12 43 127 24 2 10
 10 0 0 0 0 0 0 0 0 0 0 0 0 0
 0 0 0 0 0 0 0 0 0 0 0 0 0 0
 0 0 0 0 0 0 0 0 0 0]

可以看到在句子末尾填充了若干數量的 0,使得句子的長度剛好 80。實際上,也可以選擇句子長度不夠時,在句子前面填充 0;句子長度過長時,截斷句首的單詞。經過處理後,所有的句子長度都變爲 80,從而訓練集可以保存 shape 爲[25000,80]的張量,測試集可以保存 shape 爲[25000,80]的張量。

我們介紹對多個維度進行填充的例子。考慮對圖片的高寬維度進行填充。以 28x28 大小的圖片數據爲例,如果網絡層所接受的數據高寬爲 32x32,則必須將 28x28 大小填充到32x32,可以在上、下、左、右方向各填充 2 個單元,如下圖 5.2 所示:
在這裏插入圖片描述
上述填充方案可以表達爲[[0,0],[2,2],[2,2],[0,0]],實現爲:

x = tf.random.normal([4,28,28,1])
# 圖片上下、左右各填充 2 個單元
tf.pad(x,[[0,0],[2,2],[2,2],[0,0]])

<tf.Tensor: id=16, shape=(4, 32, 32, 1), dtype=float32, numpy=
array([[[[ 0. ],
 [ 0. ],
 [ 0. ],

通過填充操作後,圖片的大小變爲 32x32,滿足神經網絡的輸入要求。

5.4.2 複製

在維度變換一節,我們就介紹了通過 tf.tile()函數實現長度爲 1 的維度複製的功能。tf.tile 函數除了可以對長度爲 1 的維度進行復制若干份,還可以對任意長度的維度進行復制若干份,進行復制時會根據原來的數據次序重複複製。由於已經介紹過,此處僅作簡單回顧。

通過 tf.tile 函數可以在任意維度將數據重複複製多份,如 shape 爲[4,32,32,3]的數據,複製方案 multiples=[2,3,3,1],即通道數據不復制,高寬方向分別複製 2 份,圖片數再複製1 份:

x = tf.random.normal([4,32,32,3])
tf.tile(x,[2,3,3,1]) # 數據複製

Out[32]:<tf.Tensor: id=25, shape=(8, 96, 96, 3), dtype=float32, numpy=
array([[[[ 1.20957184e+00, 2.82766962e+00, 1.65782201e+00],
 [ 3.85402292e-01, 2.00732923e+00, -2.79068202e-01],
 [-2.52583921e-01, 7.82584965e-01, 7.56870627e-01],...
5.5 數據限幅

考慮怎麼實現非線性激活函數 ReLU 的問題。它其實可以通過簡單的數據限幅運算實現,限制數據的範圍𝑥 ∈ [0, +∞)即可。

在 TensorFlow 中,可以通過 tf.maximum(x, a)實現數據的下限幅:𝑥 ∈ [𝑎, +∞);可以通過 tf.minimum(x, a)實現數據的上限幅:𝑥 ∈ (−∞,𝑎],舉例如下:

x = tf.range(9)
tf.maximum(x,2) # 下限幅 2

Out[33]:<tf.Tensor: id=48, shape=(9,), dtype=int32, numpy=array([2, 2, 2, 3,
4, 5, 6, 7, 8])>

In [34]:tf.minimum(x,7) # 上限幅 7
Out[34]:<tf.Tensor: id=41, shape=(9,), dtype=int32, numpy=array([0, 1, 2, 3,
4, 5, 6, 7, 7])>

那麼 ReLU 函數可以實現爲:

def relu(x):
 return tf.minimum(x,0.) # 下限幅爲 0 即可

通過組合 tf.maximum(x, a)tf.minimum(x, b)可以實現同時對數據的上下邊界限幅:𝑥 ∈ [𝑎, 𝑏]:

x = tf.range(9)
tf.minimum(tf.maximum(x,2),7) # 限幅爲 2~7

Out[35]:<tf.Tensor: id=57, shape=(9,), dtype=int32, numpy=array([2, 2, 2, 3,
4, 5, 6, 7, 7])>

更方便地,我們可以使用 tf.clip_by_value 實現上下限幅:

x = tf.range(9)
tf.clip_by_value(x,2,7) # 限幅爲 2~7

Out[36]:<tf.Tensor: id=66, shape=(9,), dtype=int32, numpy=array([2, 2, 2, 3,
4, 5, 6, 7, 7])>
5.6 高級操作

上述介紹的操作函數大部分都是常有並且容易理解的,接下來我們將介紹部分常用,但是稍複雜的功能函數。

5.6.1 tf.gather

tf.gather 可以實現根據索引號收集數據的目的。考慮班級成績冊的例子,共有 4 個班級,每個班級 35 個學生,8 門科目,保存成績冊的張量 shape 爲[4,35,8]。

x = tf.random.uniform([4,35,8],maxval=100,dtype=tf.int32)

現在需要收集第 1-2 個班級的成績冊,可以給定需要收集班級的索引號:[0,1],班級的維度 axis=0:

tf.gather(x,[0,1],axis=0) # 在班級維度收集第 1-2 號班級成績冊

Out[38]:<tf.Tensor: id=83, shape=(2, 35, 8), dtype=int32, numpy=
array([[[43, 10, 93, 85, 75, 87, 28, 19],
 [52, 17, 44, 88, 82, 54, 16, 65],
 [98, 26, 1, 47, 59, 3, 59, 70],

實際上,對於上述需求,通過切片𝑥[: 2]可以更加方便地實現。但是對於不規則的索引方式,比如,需要抽查所有班級的第 1,4,9,12,13,27 號同學的成績,則切片方式實現起來非常麻煩,而 tf.gather 則是針對於此需求設計的,使用起來非常方便:

# 收集第 1,4,9,12,13,27 號同學成績
tf.gather(x,[0,3,8,11,12,26],axis=1)

Out[39]:<tf.Tensor: id=87, shape=(4, 6, 8), dtype=int32, numpy=
array([[[43, 10, 93, 85, 75, 87, 28, 19],
 [74, 11, 25, 64, 84, 89, 79, 85],

如果需要收集所有同學的第 3,5 等科目的成績,則可以:

tf.gather(x,[2,4],axis=2) # 第 3,5 科目的成績

Out[40]:<tf.Tensor: id=91, shape=(4, 35, 2), dtype=int32, numpy=
array([[[93, 75],
 [44, 82],
 [ 1, 59],

可以看到,tf.gather 非常適合索引沒有規則的場合,其中索引號可以亂序排列,此時收集的數據也是對應順序:

a=tf.range(8)
a=tf.reshape(a,[4,2]) # 生成張量 a

Out[41]:<tf.Tensor: id=115, shape=(4, 2), dtype=int32, numpy=
array([[0, 1],
 [2, 3],
 [4, 5],
 [6, 7]])>
 
tf.gather(a,[3,1,0,2],axis=0) # 收集第 4,2,1,3 號元素
Out[42]:<tf.Tensor: id=119, shape=(4, 2), dtype=int32, numpy=
array([[6, 7],
 [2, 3],
 [0, 1],
 [4, 5]])>

我們將問題變得複雜一點:如果希望抽查第[2,3]班級的第[3,4,6,27]號同學的科目成績,則可以通過組合多個 tf.gather 實現。首先抽出第[2,3]班級:

students=tf.gather(x,[1,2],axis=0) # 收集第 2,3 號班級

<tf.Tensor: id=227, shape=(2, 35, 8), dtype=int32, numpy=
array([[[ 0, 62, 99, 7, 66, 56, 95, 98],

再從這 2 個班級的同學中提取對應學生成績:

tf.gather(students,[2,3,5,26],axis=1) # 收集第 3,4,6,27 號同學

Out[44]:<tf.Tensor: id=231, shape=(2, 4, 8), dtype=int32, numpy=
array([[[69, 67, 93, 2, 31, 5, 66, 65],

此時得到這 2 個班級 4 個同學的成績,shape 爲[2,4,8]。

我們再將問題進一步複雜:這次我們希望抽查第 2 個班級的第 2 個同學的所有科目,第 3 個班級的第 3 個同學的所有科目,第 4 個班級的第 4 個同學的所有科目。那麼怎麼實現呢?

可以通過笨方式一個一個的手動提取:首先提取第一個採樣點的數據:𝑥[1,1],可得到8 門科目的數據向量:

x[1,1] # 收集第 2 個班級的第 2 個同學

Out[45]:<tf.Tensor: id=236, shape=(8,), dtype=int32, numpy=array([45, 34,
99, 17, 3, 1, 43, 86])>

再串行提取第二個採樣點的數據:𝑥[2,2],和第三個採樣點的數據𝑥[3,3],最後通過 stack
方式合併採樣結果:

In [46]: tf.stack([x[1,1],x[2,2],x[3,3]],axis=0)
Out[46]:<tf.Tensor: id=250, shape=(3, 8), dtype=int32, numpy=
array([[45, 34, 99, 17, 3, 1, 43, 86],
 [11, 25, 84, 95, 97, 95, 69, 69],
 [ 0, 89, 52, 29, 76, 7, 2, 98]])>

這種方法也能正確的得到 shape 爲[3,8]的結果,其中 3 表示採樣點的個數,4 表示每個採樣點的數據。但是它最大的問題在於手動串行方式執行採樣,計算效率極低。有沒有更好的方式實現呢?這就是下一節要介紹的 tf.gather_nd 的功能。

5.6.2 tf.gather_nd

通過 tf.gather_nd,可以通過指定每次採樣的座標來實現採樣多個點的目的。

回到上面的挑戰,我們希望抽查第 2 個班級的第 2 個同學的所有科目,第 3 個班級的第 3 個同學的所有科目,第 4 個班級的第 4 個同學的所有科目。
那麼這 3 個採樣點的索引座標可以記爲:[1,1],[2,2],[3,3],我們將這個採樣方案合併爲一個 List 參數:[[1,1],[2,2],[3,3]],通過tf.gather_nd 實現如下:

# 根據多維度座標收集數據
tf.gather_nd(x,[[1,1],[2,2],[3,3]])

Out[47]:<tf.Tensor: id=256, shape=(3, 8), dtype=int32, numpy=
array([[45, 34, 99, 17, 3, 1, 43, 86],
 [11, 25, 84, 95, 97, 95, 69, 69],
 [ 0, 89, 52, 29, 76, 7, 2, 98]])>

可以看到,結果與串行採樣完全一致,實現更加簡潔,計算效率大大提升。

一般地,在使用 tf.gather_nd 採樣多個樣本時,如果希望採樣第 i 號班級,第 j 個學生,第 k 門科目的成績,則可以表達爲[. . . ,[𝑖,𝑗, 𝑘], . . .],外層的括號長度爲採樣樣本的個數,內層列表包含了每個採樣點的索引座標:

# 根據多維度座標收集數據
tf.gather_nd(x,[[1,1,2],[2,2,3],[3,3,4]])

Out[48]:<tf.Tensor: id=259, shape=(3,), dtype=int32, numpy=array([99, 95,
76])>

上述代碼中,我們抽出了班級 1,學生 1 的科目 2;班級 2,學生 2 的科目 3;班級 3,學生 3 的科目 4 的成績,共有 3 個成績數據,結果彙總爲一個 shape 爲[3]的張量。

5.6.3 tf.boolean_mask

除了可以通過給定索引號的方式採樣,還可以通過給定掩碼(mask)的方式採樣。繼續以 shape 爲[4,35,8]的成績冊爲例,這次我們以掩碼方式進行數據提取。考慮在班級維度上進行採樣,對這 4 個班級的採樣方案的掩碼爲

𝑚𝑎𝑠𝑘 = [𝑇𝑟𝑢𝑒, 𝐹𝑎𝑙𝑠𝑒, 𝐹𝑎𝑙𝑠𝑒, 𝑇𝑟𝑢𝑒]

即採樣第 1 和第 4 個班級,通過 tf.boolean_mask(x, mask, axis)可以在 axis 軸上根據 mask 方案進行採樣,實現爲:

# 根據掩碼方式採樣班級
tf.boolean_mask(x,mask=[True, False,False,True],axis=0)

<tf.Tensor: id=288, shape=(2, 35, 8), dtype=int32, numpy=
array([[[43, 10, 93, 85, 75, 87, 28, 19],

注意掩碼的長度必須與對應維度的長度一致,如在班級維度上採樣,則必須對這 4 個班級是否採樣的掩碼全部指定,掩碼長度爲 4。

如果對 8 門科目進行掩碼採樣,設掩碼採樣方案爲:

𝑚𝑎𝑠𝑘 = [𝑇𝑟𝑢𝑒, 𝐹𝑎𝑙𝑠𝑒, 𝐹𝑎𝑙𝑠𝑒, 𝑇𝑟𝑢𝑒, 𝑇𝑟𝑢𝑒, 𝐹𝑎𝑙𝑠𝑒, 𝐹𝑎𝑙𝑠𝑒, 𝑇𝑟𝑢𝑒]

則可以實現爲:

# 根據掩碼方式採樣科目
tf.boolean_mask(x,mask=[True,False,False,True,True,False,False,True],axis=2)

<tf.Tensor: id=318, shape=(4, 35, 4), dtype=int32, numpy=
array([[[43, 85, 75, 19],

不難發現,這裏的 tf.boolean_mask 的用法其實與 tf.gather 非常類似,只不過一個通過掩碼方式採樣,一個直接給出索引號採樣。

現在我們來考慮與 tf.gather_nd 類似方式的多維掩碼採樣方式。爲了方便演示,我們將班級數量減少到 2 個,學生的數量減少到 3 個,即一個班級只有 3 個學生,shape 爲[2,3,8]。

如果希望採樣第 1 個班級的第 1-2 號學生,第 2 個班級的第 2-3 號學生,通過tf.gather_nd 可以實現爲:

x = tf.random.uniform([2,3,8],maxval=100,dtype=tf.int32)
tf.gather_nd(x,[[0,0],[0,1],[1,1],[1,2]]) # 多維座標採集

<tf.Tensor: id=325, shape=(4, 8), dtype=int32, numpy=
array([[52, 81, 78, 21, 50, 6, 68, 19],
 [53, 70, 62, 12, 7, 68, 36, 84],
 [62, 30, 52, 60, 10, 93, 33, 6],
 [97, 92, 59, 87, 86, 49, 47, 11]])>

共採樣 4 個學生的成績,shape 爲[4,8]。

如果用掩碼方式,怎麼表達呢?如下表格 5.2 所示,行爲每個班級,列爲對應學生,表中數據表達了對應位置的採樣情況:
在這裏插入圖片描述
因此,通過這張表,就能很好地表徵利用掩碼方式的採樣方案:

# 多維掩碼採樣
tf.boolean_mask(x,[[True,True,False],[False,True,True]])

<tf.Tensor: id=354, shape=(4, 8), dtype=int32, numpy=
array([[52, 81, 78, 21, 50, 6, 68, 19],
 [53, 70, 62, 12, 7, 68, 36, 84],
 [62, 30, 52, 60, 10, 93, 33, 6],
 [97, 92, 59, 87, 86, 49, 47, 11]])>

採樣結果與 tf.gather_nd 完全一致。可見 tf.boolean_mask既可以實現了tf.gather 方式的一維掩碼採樣,又可以實現 tf.gather_nd 方式的多維掩碼採樣。

上面的 3 個操作比較常用,尤其是 tf.gathertf.gather_nd 出現的頻率較高,必須掌握。下面再補充 3 個高階操作。

5.6.4 tf.where

通過 tf.where(cond, a, b)操作可以根據 cond 條件的真假從 a 或 b 中讀取數據,條件判定規則如下:
在這裏插入圖片描述
其中 i 爲張量的索引,返回張量大小與 a,b 張量一致,當對應位置中condicond_{i}爲 True,oio_{i}位置從aia_{i}中複製數據;當對應位置中condicond_{i}爲 False,oio_{i}位置從bib_{i}中複製數據。

考慮從 2 個全 1、全 0 的 3x3 大小的張量 a,b 中提取數據,其中 cond 爲 True 的位置從 a 中對應位置提取,cond 爲 False 的位置從 b 對應位置提取:

a = tf.ones([3,3]) # 構造 a 爲全 1
b = tf.zeros([3,3]) # 構造 b 爲全 0
# 構造採樣條件
cond =tf.constant([[True,False,False],[False,True,False],[True,True,False]])
tf.where(cond,a,b) # 根據條件從 a,b 中採樣

Out[53]:<tf.Tensor: id=384, shape=(3, 3), dtype=float32, numpy=
array([[1., 0., 0.],
 [0., 1., 0.],
 [1., 1., 0.]], dtype=float32)>

可以看到,返回的張量中爲 1 的位置來自張量 a,返回的張量中爲 0 的位置來自張量 b。當 a=b=None 即 a,b 參數不指定時,tf.where 會返回 cond 張量中所有 True 的元素的索引座標。考慮如下 cond 張量:

cond # 構造 cond
Out[54]:<tf.Tensor: id=383, shape=(3, 3), dtype=bool, numpy=
array([[ True, False, False],
 [False, True, False],
 [ True, True, False]])>

其中 True 共出現 4 次,每個 True 位置處的索引分佈爲[0,0],[1,1],[2,0],[2,1],可以直接通過 tf.where(cond)來獲得這些索引座標:

tf.where(cond) # 獲取 cond 中爲 True 的元素索引

Out[55]:<tf.Tensor: id=387, shape=(4, 2), dtype=int64, numpy=
array([[0, 0],
 [1, 1],
 [2, 0],
 [2, 1]], dtype=int64)>

那麼這有什麼用途呢?考慮一個例子,我們需要提取張量中所有正數的數據和索引。首先構造張量 a,並通過比較運算得到所有正數的位置掩碼:

x = tf.random.normal([3,3]) # 構造 a

Out[56]:<tf.Tensor: id=403, shape=(3, 3), dtype=float32, numpy=
array([[-2.2946844 , 0.6708417 , -0.5222212 ],
 [-0.6919401 , -1.9418817 , 0.3559235 ],
 [-0.8005251 , 1.0603906 , -0.68819374]], dtype=float32)>

通過比較運算,得到正數的掩碼:

mask=x>0 # 比較操作,等同於 tf.equal()
mask

Out[57]:<tf.Tensor: id=405, shape=(3, 3), dtype=bool, numpy=
array([[False, True, False],
 [False, False, True],
 [False, True, False]])>

通過 tf.where 提取此掩碼處 True 元素的索引:

indices=tf.where(mask) # 提取所有大於 0 的元素索引
Out[58]:<tf.Tensor: id=407, shape=(3, 2), dtype=int64, numpy=
array([[0, 1],
 [1, 2],
 [2, 1]], dtype=int64)>

拿到索引後,通過 tf.gather_nd 即可恢復出所有正數的元素:

tf.gather_nd(x,indices) # 提取正數的元素值

Out[59]:<tf.Tensor: id=410, shape=(3,), dtype=float32,
numpy=array([0.6708417, 0.3559235, 1.0603906], dtype=float32)>

實際上,當我們得到掩碼 mask 之後,也可以直接通過 tf.boolean_mask 獲取對於元素:

tf.boolean_mask(x,mask) # 通過掩碼提取正數的元素值

<tf.Tensor: id=439, shape=(3,), dtype=float32,
numpy=array([0.6708417, 0.3559235, 1.0603906], dtype=float32)>

結果也是一致的。

5.6.5 scatter_nd

通過 tf.scatter_nd(indices, updates, shape)可以高效地刷新張量的部分數據,但是只能在全 0 張量的白板上面刷新,因此可能需要結合其他操作來實現現有張量的數據刷新功能。
如下圖 5.3 所示,演示了一維張量白板的刷新運算,白板的形狀表示爲 shape 參數,需要刷新的數據索引爲 indices,新數據爲 updates,其中每個需要刷新的數據對應在白板中的位置,根據 indices 給出的索引位置將 updates 中新的數據依次寫入白板中,並返回更新後的白板張量。
在這裏插入圖片描述
我們實現一個圖 5.3 中向量的刷新實例:

# 構造需要刷新數據的位置
indices = tf.constant([[4], [3], [1], [7]])
# 構造需要寫入的數據
updates = tf.constant([4.4, 3.3, 1.1, 7.7])
# 在長度爲 8 的全 0 向量上根據 indices 寫入 updates
tf.scatter_nd(indices, updates, [8])
Out[61]:<tf.Tensor: id=467, shape=(8,), dtype=float32, numpy=array([0. ,
1.1, 0. , 3.3, 4.4, 0. , 0. , 7.7], dtype=float32)>

可以看到,在長度爲 8 的白板上,寫入了對應位置的數據,一個 4 個新數據被刷新。考慮 3 維張量的刷新例子,如下圖 5.4 所示,白板 shape 爲[4,4,4],共有 4 個通道的特徵圖,現有需 2 個通道的新數據 updates:[2,4,4],需要寫入索引爲[1,3]的通道上:
在這裏插入圖片描述
我們將新的特徵圖寫入現有白板張量,實現如下:

# 構造寫入位置
indices = tf.constant([[1],[3]])
updates = tf.constant([# 構造寫入數據
 [[5,5,5,5],[6,6,6,6],[7,7,7,7],[8,8,8,8]],
 [[1,1,1,1],[2,2,2,2],[3,3,3,3],[4,4,4,4]]
])
# 在 shape 爲[4,4,4]白板上根據 indices 寫入 updates
tf.scatter_nd(indices,updates,[4,4,4])

Out[62]:<tf.Tensor: id=477, shape=(4, 4, 4), dtype=int32, numpy=
array([[[0, 0, 0, 0],
 [0, 0, 0, 0],
 [0, 0, 0, 0],
 [0, 0, 0, 0]],
 [[5, 5, 5, 5], # 寫入的新數據 1
 [6, 6, 6, 6],
 [7, 7, 7, 7],
 [8, 8, 8, 8]],
 [[0, 0, 0, 0],
 [0, 0, 0, 0],
 [0, 0, 0, 0],
 [0, 0, 0, 0]],
 [[1, 1, 1, 1], # 寫入的新數據 2
 [2, 2, 2, 2],
 [3, 3, 3, 3],
 [4, 4, 4, 4]]])>

可以看到,數據被刷新到第 2,4 個通道特徵圖上。

5.6.6 meshgrid

通過 tf.meshgrid 可以方便地生成二維網格採樣點座標,方便可視化等應用場合。考慮2 個自變量 x,y 的 Sinc 函數表達式爲:
z=sin(x2+y2)x2+y2z=\frac{\sin \left(x^{2}+y^{2}\right)}{x^{2}+y^{2}}
如果需要繪製函數在𝑥 ∈ [−8,8],𝑦 ∈ [−8,8]區間的 Sinc 函數的 3D 曲面,如圖 5.5 所示,則首先需要生成 x,y 的網格點座標{(𝑥, 𝑦)},這樣才能通過 Sinc 函數的表達式計算函數在每個(𝑥, 𝑦)位置的輸出值 z。可以通過如下方式生成 1 萬個座標採樣點:

points = []
for x in range(-8,8,100): # 循環生成 x 座標
    for y in range(-8,8,100): # 循環生成 y 座標
        z = sinc(x,y) # 計算 sinc 函數值
       points.append([x,y,z]) # 保存採樣點

很明顯這種方式串行計算效率極低,那麼有沒有簡潔高效地方式生成網格座標呢?答案是
肯定的,tf.meshgrid 函數即可實現。
在這裏插入圖片描述
通過在 x 軸上進行採樣 100 個數據點,y 軸上採樣 100 個數據點,然後通過tf.meshgrid(x, y)即可返回這 10000 個數據點的張量數據,shape 爲[100,100,2]。爲了方便計算,tf.meshgrid 會返回在 axis=2 維度切割後的 2 個張量 a,b,其中張量 a 包含了所有點的 x座標,b 包含了所有點的 y 座標,shape 都爲[100,100]:

x = tf.linspace(-8.,8,100) # 設置 x 座標的間隔
y = tf.linspace(-8.,8,100) # 設置 y 座標的間隔
x,y = tf.meshgrid(x,y) # 生成網格點,並拆分後返回
x.shape,y.shape # 打印拆分後的所有點的 x,y 座標張量 shape

(TensorShape([100, 100]), TensorShape([100, 100]))

Sinc 函數在 TensorFlow 中實現如下:

z = tf.sqrt(x**2+y**2)
z = tf.sin(z)/z # sinc 函數實現

通過 matplotlib 即可繪製出函數在𝑥 ∈ [−8,8], 𝑦 ∈ [−8,8]區間的 3D 曲面,如圖 5.5 中所示:

fig = plt.figure()
ax = Axes3D(fig)
# 根據網格點繪製 sinc 函數 3D 曲面
ax.contour3D(x.numpy(), y.numpy(), z.numpy(), 50)
plt.show()
5.7 經典數據集加載

到現在爲止,我們已經學習完張量的所有常用操作,已具備實現大部分深度網絡的技術儲備。最後我們將以一個完整的張量方式實現的分類網絡模型收尾 TensorFlow 框架章節。在進入實戰之前,我們先正式介紹對於常用的數據集,如何利用 TensorFlow 提供的工具便捷地加載數據集。對於自定義的數據集的加載,我們會在後續章節介紹。

在 TensorFlow 中,keras.datasets 模塊提供了常用經典數據集的自動下載、管理、加載與轉換功能,並且提供了 tf.data.Dataset 數據集對象,方便實現多線程(Multi-thread),預處理(Preprocess),隨機打散(Shuffle)和批訓練(Train on batch)等常用數據集功能。

對於常用的數據集,如:
❑ Boston Housing 波士頓房價趨勢數據集,用於迴歸模型訓練與測試
❑ CIFAR10/100 真實圖片數據集,用於圖片分類任務
❑ MNIST/Fashion_MNIST 手寫數字圖片數據集,用於圖片分類任務
❑ IMDB 情感分類任務數據集

這些數據集在機器學習、深度學習的研究和學習中使用的非常頻繁。對於新提出的算法,一般優先在簡單的數據集上面測試,再嘗試遷移到更大規模、更復雜的數據集上。

通過 datasets.xxx.load_data()即可實現經典數據集的自動加載,其中 xxx 代表具體的數據集名稱。TensorFlow 會默認將數據緩存在用戶目錄下的.keras/datasets 文件夾,如圖 5.6所示,用戶不需要關心數據集是如何保存的。如果當前數據集不在緩存中,則會自動從網站下載和解壓,加載;如果已經在緩存中,自動完成加載:
在這裏插入圖片描述

import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import datasets # 導入經典數據集加載模塊

# 加載 MNIST 數據集
(x, y), (x_test, y_test) = datasets.mnist.load_data()

print('x:', x.shape, 'y:', y.shape, 'x test:', x_test.shape, 'y test:',
y_test)

x: (60000, 28, 28) y: (60000,) x test: (10000, 28, 28) y test: [7 2 1 ... 4 5 6]

通過 load_data()會返回相應格式的數據,對於圖片數據集 MNIST, CIFAR10 等,會返回 2個 tuple,第一個 tuple 保存了用於訓練的數據 x,y 訓練集對象;第 2 個 tuple 則保存了用於測試的數據 x_test,y_test 測試集對象,所有的數據都用 Numpy.array容器承載。

數據加載進入內存後,需要轉換成 Dataset 對象,以利用 TensorFlow 提供的各種便捷功能。通過 Dataset.from_tensor_slices 可以將訓練部分的數據圖片 x 和標籤 y 都轉換成Dataset 對象:

train_db = tf.data.Dataset.from_tensor_slices((x, y))

將數據轉換成 Dataset 對象後,一般需要再添加一系列的數據集標準處理步驟,如隨機打散,預處理,按批披載等。

5.7.1 隨機打散

通過 Dataset.shuffle(buffer_size)工具可以設置 Dataset 對象隨機打散數據之間的順序,防止每次訓練時數據按固定順序產生,從而使得模型嘗試“記憶”住標籤信息:

train_db = train_db.shuffle(10000)

其中 buffer_size 指定緩衝池的大小,一般設置爲一個較大的參數即可。通過 Dataset 提供的這些工具函數會返回新的 Dataset 對象,可以通過

db = db. shuffle(). step2(). step3. ()

方式完成所有的數據處理步驟,實現起來非常方便。

5.7.2 批訓練

爲了利用顯卡的並行計算能力,一般在網絡的計算過程中會同時計算多個樣本,我們把這種訓練方式叫做批訓練,其中樣本的數量叫做 batch size。爲了一次能夠從 Dataset 中產生 batch size 數量的樣本,需要設置 Dataset 爲批訓練方式:

train_db = train_db.batch(128)

其中 128 爲 batch size 參數,即一次並行計算 128 個樣本的數據。Batch size 一般根據用戶的 GPU 顯存資源來設置,當顯存不足時,可以適量減少 batch size 來減少算法的顯存使用量。

5.7.3 預處理

keras.datasets 中加載的數據集的格式大部分情況都不能滿足模型的輸入要求,因此需要根據用戶的邏輯自己實現預處理函數。Dataset 對象通過提供 map(func)工具函數可以非常方便地調用用戶自定義的預處理邏輯,它實現在 func 函數裏:

# 預處理函數實現在 preprocess 函數中,傳入函數引用即可
train_db = train_db.map(preprocess)

考慮 MNIST 手寫數字圖片,從 keras.datasets 中經.batch()後加載的圖片 x shape 爲[𝑏, 28,28],像素使用 0~255 的整形表示;標註 shape 爲[𝑏],即採樣的數字編碼方式。實際的神經網絡輸入,一般需要將圖片數據標準化到[0,1]或[−1,1]等 0 附近區間,同時根據網絡的設置,需要將 shape [28,28] 的輸入 Reshape 爲合法的格式;對於標註信息,可以選擇在預處理時進行 one-hot 編碼,也可以在計算誤差時進行 one-hot 編碼。

根據下一節的實戰設定,我們將 MNIST 圖片數據映射到𝑥 ∈ [0,1]區間,視圖調整爲[𝑏, 28 ∗ 28];對於標註 y,我們選擇在預處理函數裏面進行 one-hot 編碼:

def preprocess(x, y): # 自定義的預處理函數
# 調用此函數時會自動傳入 x,y 對象,shape 爲[b, 28, 28], [b]
 # 標準化到 0~1
    x = tf.cast(x, dtype=tf.float32) / 255.
    x = tf.reshape(x, [-1, 28*28]) # 打平
    y = tf.cast(y, dtype=tf.int32) # 轉成整形張量
    y = tf.one_hot(y, depth=10) # one-hot 編碼
    # 返回的 x,y 將替換傳入的 x,y 參數,從而實現數據的預處理功能
    return x,y
5.7.4 循環訓練

對於 Dataset 對象,在使用時可以通過

for step, (x,y) in enumerate(train_db): # 迭代數據集對象,帶 step 參數

for x,y in train_db: # 迭代數據集對象

方式進行迭代,每次返回的 x,y 對象即爲批量樣本和標籤,當對 train_db 的所有樣本完成一次迭代後,for 循環終止退出。我們一般把完成一個 batch 的數據訓練,叫做一個 step通過多個 step 來完成整個訓練集的一次迭代,叫做一個 epoch。在實際訓練時,通常需要對數據集迭代多個 epoch 才能取得較好地訓練效果:

for epoch in range(20): # 訓練 Epoch 數
    for step, (x,y) in enumerate(train_db): # 迭代 Step 數
        # training...

此外,也可以通過設置:

train_db = train_db.repeat(20) # 數據集跌打 20 遍才終止

使得 for x,y in train_db 循環迭代 20 個 epoch 纔會退出。不管使用上述哪種方式,都能取得一樣的效果。

5.8 MNIST 測試實戰

前面已經介紹並實現了前向傳播和數據集的加載部分,現在我們來完成剩下的分類任務邏輯。在訓練的過程中,通過間隔數個 step 打印誤差數據,可以有效監督模型的訓練進度:

 # 間隔 100 個 step 打印一次訓練誤差
 if step % 100 == 0:
 print(step, 'loss:', float(loss))

在若干個 step 或者若干個 epoch 訓練後,可以進行一次測試(驗證),以獲得模型的當前性能:

 if step % 500 == 0: # 每 500 個 batch 後進行一次測試(驗證)
     # evaluate/test

現在我們來利用學習到的 TensorFlow 張量操作函數,完成準確度的計算。首先考慮一個 batch 的樣本 x,通過前向計算可以獲得網絡的預測值:

 for x, y in test_db: # 對測驗集迭代一遍
 h1 = x @ w1 + b1 # 第一層
 h1 = tf.nn.relu(h1)
 h2 = h1 @ w2 + b2 # 第二層
 h2 = tf.nn.relu(h2)
 out = h2 @ w3 + b3 # 輸出層

預測值 out 的 shape 爲[𝑏, 10],分別代表了樣本屬於每個類別的概率,我們根據 tf.argmax函數選出概率最大值出現的索引號,也即樣本最有可能的類別號:

 pred = tf.argmax(out, axis=1) # 選取概率最大的類別

由於我們的標註 y 已經在預處理中完成了 one-hot 編碼,這在測試時其實是不需要的,因此通過tf.argmax 可以得到數字編碼的標註 y:

 y = tf.argmax(y, axis=1) # one-hot 編碼逆過程

通過 tf.equal可以比較這 2 者的結果是否相等:

correct = tf.equal(pred, y) # 比較預測值與真實值

並求和比較結果中所有 True(轉換爲 1)的數量,即爲預測正確的數量:

 total_correct += 
 tf.reduce_sum(tf.cast(correct,dtype=tf.int32)).numpy() # 統計預測正確的樣本個數

通過預測的數量除以總測試數量即可得到準確度:

 # 計算正確率
 print(step, 'Evaluate Acc:', total_correct/total)

通過簡單的 3 層神經網絡,訓練 20 個 Epoch 後,我們在測試集上獲得了 87.25%的準確率,如果使用複雜的神經網絡模型,增加數據增強,精調網絡超參數等技巧,可以獲得更高的模型性能。模型的訓練誤差曲線如圖 5.7 所示,測試準確率曲線如圖 5.8 所示。
在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章