40行Python程式碼,實現卷積特徵視覺化

程式設計師小城發表於2019-03-17

最近在閱讀 Jeremy Rifkin 的書《The End of Work》時,我讀到一個有趣的關於 AI 的定義。Rifkin 寫到:「今天,當科學家們談論人工智慧時,他們通常是指『一門創造機器的藝術,該機器所執行的功能在人類執行時需要智慧』(Kurzweil, Raymond, The Age of Intelligent Machines (Cambridge, MA: MIT Press, 1990), p. 14.)」。我很喜歡這個定義,因為它避免了類似」在人類智力意義上 AI 是否真正達到智慧」的討論。

作為一名科學家,揭開大腦功能的基本原理並創造一個真正的智慧機器的想法確實讓我很興奮,但是我認為認識到深度學習模型並不是大腦模型這一點非常重要。深度學習研究的目的是從資料中學習到目前為止還沒有自動化的流程的規則並實現自動化。雖然這聽起來並不是那麼讓人興奮,但它確實是一件好事。舉個例子:深度卷積神經網路的出現徹底改變了計算機視覺和模式識別,這讓我們在醫療診斷中可以大量地引入自動化;人們可以加速為貧窮國家的人提供頂級醫療診斷,而不需要在本地培訓大量的醫生和專家。

儘管深度學習給人們帶來了許多振奮的訊息,但它如何看待和解釋世界仍然是一個黑匣子。更好地理解它們如何識別特定的模式和物件,以及為什麼它們能夠表現地如此良好,可以讓我們:1)進一步改進它們;2)解決法律問題——因為在許多情況下機器所做出的決定必須能夠被人類所理解。

有兩種方法可以嘗試理解神經網路如何識別某種模式。如果你想知道哪種模式可以顯著地啟用某個特徵圖,你可以:1)嘗試在資料集中查詢導致此特徵圖高於平均啟用的影象;2)嘗試通過優化隨機影象中的畫素值來生成這種模式。後者的想法是由 Erhan 等人提出的。

在本文中我將向你解釋如何僅用 40 行 Python 程式碼來實現隨機影象的畫素值優化(如下圖),從而生成卷積神經網路的特徵視覺化。

本文的結構如下:首先,我將展示 VGG-16 網路的幾個層次中的卷積特徵的視覺化;然後,嘗試理解其中一些視覺化,我將展示如何快速測試一個假設,即特定的濾波器會檢測到哪種模式;最後,我將解釋建立本文中提供的模式所需的程式碼。

特徵視覺化

神經網路學習將輸入資料(如影象)轉換為越來越有意義但表徵越來越複雜的連續層。

你可以將深度網路看做一個多階段資訊蒸餾操作,其中資訊通過連續的濾波器並不斷被「提純」。(François Chollet, Deep Learning with Python (Shelter Island, NY: Manning Publications, 2018), p. 9)

閱讀完他的文章後,你將瞭解如何生成模式,以最大化這些層次表徵的某個層中所選特徵圖的平均啟用,如何解釋其中一些視覺化,以及最終如何測試所選濾波器可能響應的模式或紋理的假設。你可以在下面找到 VGG-16 網路多個層中濾波器的特徵視覺化。在檢視它們時,希望你能觀察到生成模式的複雜性如何隨著進入網路的深度而增加。

Layer 7: Conv2d(64, 128)

濾波器 12, 16, 86, 110(左上到右下,逐行)

Layer 14: Conv2d(128, 256)

濾波器 1, 6, 31, 32, 54, 77, 83, 97, 125, 158, 162, 190(左上到右下,逐行)

Layer 20: Conv2d(256, 256)

濾波器 3, 34, 39, 55, 62, 105, 115, 181, 231(左上到右下,逐行)

Layer 30: Conv2d(512, 512)

濾波器 54, 62, 67, 92, 123, 141, 150, 172, 180, 213, 233, 266, 277, 293, 331, 350, 421, 427(左上到右下,逐行)

Layer 40: Conv2d(512, 512)—top of the network

濾波器 4, 9, 34, 35, 75, 123, 150, 158, 203, 234, 246, 253, 256, 261, 265, 277, 286, 462(左上到右下,逐行)

這些模式讓我覺得非常震撼!部分原因是,它們太漂亮了,我都想立馬將它們裱起來掛在牆上;但主要的原因是,它們僅僅是通過最大化由數千張影象訓練出的數學方程中的某個值得到的。在瀏覽通過最大化最後一層卷積層特徵圖的平均啟用得到的 512 個模式時,我經常發出感慨「哇,這是一隻雞」,或「這不是一根羽毛嘛」。

識別模式

我們來嘗試解釋幾個視覺化的特徵。從這個開始,有沒有讓你想起些什麼?

第 40 層第 286 個濾波器

這張照片立刻讓我想起了教堂拱形天花板的圓拱。

來,讓我們檢驗一下。人造的那張圖片是通過最大化第 40 層第 286 個特徵圖的平均啟用創造出來的。我們來看看當把拱門的照片輸入網路後,第 40 層特徵圖的平均啟用會是怎樣:

看到了什麼?正如期望的那樣,特徵圖上的 286 處有一個極強的尖峰。所以,這是否意味著第 40 層第 286 個濾波器是負責檢測拱形天花板的呢?這裡我們要小心一點,濾波器 286 顯然會響應影象中的拱形結構,但請記住,這樣的拱形結構可能會在幾個不同的類別中起到重要作用。

注意:雖然我使用第 40 層(卷積層)來生成我們當前正在檢視的影象,但我使用了第 42 層來生成顯示每個特徵圖的平均啟用的圖。第 41 和 42 層是 batch-norm 和 ReLU。ReLU 啟用函式刪除所有負值,選擇第 42 層而不是 40 的唯一原因是,後者將顯示大量負噪聲,這使得我們很難看到我們感興趣的正峰值。

再來一例。

第 40 層第 256 個濾波器

我敢保證,這些是雞頭(或者至少是鳥頭)!從圖中我們可以看到尖尖的喙和黑色的眼睛。我們可以用下面的這個圖片來檢驗:

類似地,特徵圖上的 256 處會出現強烈的尖峰:

再來:

第 40 層第 462 個濾波器

濾波器 462 會不會對羽毛作出反應呢?來,輸入一張羽毛圖片:

Yes!濾波器 462 果然反應強烈:

猜一猜濾波器 265 會對什麼產生響應?

第 40 層第 265 個濾波器

或許是鏈條吧?來,我們輸入一張試試:

Yes,看起來猜對了!

不過從上圖可以看到,除了最大的尖峰外,還有幾個較大的次尖峰。我們看看對應的第 95 和第 303 個特徵視覺化圖是什麼:

第 40 層第 95 和第 303 個濾波器

再來看張比較酷的:

第 40 層第 64 個濾波器

有許多看起來像羽毛一樣的結構,似乎還有鳥腿,左下方有個類似鳥頭的東西。腿和喙顯然比雞的長,所以可能是一隻鳥。我們將下面這幅圖輸入網路:

得到這樣的特徵圖:

好吧,?,在 64 處確實有個尖峰,但好像有許多比它更高的。讓我們再來看看其中四個特徵尖峰對應的濾波器生成的模式:

第 40 層第 172 和第 288 個濾波器

第 40 層第 437 和第 495 個濾波器

上面兩幅(172、288)圖看起來似乎有更多腿和眼睛/喙;不過下面兩幅(437、495)我實在看不出它表示了什麼。也許這些模式與影象的背景相關,或者只是代表網路檢測到了一些我所不能理解的鳥類的資訊。我想現在這個地方仍然是黑匣子的一部分。

再來最後一張,比較可愛,之後我們就直接進入程式碼部分。你能猜到這個是什麼嗎?

第 40 層第 277 個濾波器

我擼貓多年,所以我立馬看到了毛茸茸的貓耳。左上角那個較大的最為明顯。好,讓我們輸入一張貓圖:

Yes,特徵圖上 277 處確實有一個強烈的尖峰,但是旁邊更強烈的尖峰是怎麼回事?

我們快速看下特徵圖 281 對應的模式圖:

第 40 層第 281 個濾波器

也許是條紋貓的皮毛?

對於從網路中發現上述這樣的祕密,我簡直樂此不疲。但事實上,即使在最終的卷積層,大多數濾波器生成的模式對我來說還是非常抽象的。更為嚴格的方法應該是將網路應用於整個包含許多不同種類影象的資料集,並跟蹤在某一層中激發特定濾波器的影象。

程式碼詳解

思路大致如下:我們從包含隨機畫素的圖片開始,將它輸入到評估模式的網路中,計算特定層中某個特徵圖的平均啟用,然後計算輸入影象畫素值的梯度;知道畫素值的梯度後,我們繼續以最大化所選特徵圖的平均啟用的方式更新畫素值。

貌似這樣說不好理解,那我們換種方式:網路權重是固定的,網路也不會被訓練,我們試圖找到一個影象,通過在畫素值上執行梯度下降優化來最大化特定特徵圖的平均啟用。

這個技術也叫做神經風格遷移。

為了實現這一點,我們需要:

  1. 從隨機影象開始;
  2. 評估模式下的預訓練網路;
  3. 一種訪問我們感興趣的任何隱藏層啟用函式的方式;
  4. 用於計算漸梯度的損失函式和用於更新畫素值的優化器。

我們先來生成一張帶噪圖作為輸入。我們可以如下這樣做:

img = np.uint8(np.random.uniform(150, 180, (sz, sz, 3)))/255 

其中 sz 是影象的長、寬,3 是顏色通道數,我們除以 255 是因為 uint8 型別的變數最大值是 255。如果你想得到更多或更少的噪聲,可以修改 150 和 180。

然後我們用

 img_var = V(img[None], requires_grad=True) 

將之轉化為一個需要梯度的 PyTorch 變數。畫素值需要梯度,因為我們要使用反向傳播來優化它們。

接著,我們需要一個評估模式下(意味著其權重是不變的)的預訓練網路。這可以用如下程式碼:

model = vgg16(pre=True).eval()
set_trainable(model, False).

再然後,我們需要一種方式來獲取隱藏層的特徵。我們可以採用在我們感興趣的某個隱藏層後進行截斷的方式獲取,這樣該隱藏層就成了輸出層。不過在 PyTorch 中有一種更好的方法來解決這個問題,稱為」hook」,可以在 PyTorch 的 Module 或 Tensor 中說明。要理解這點,你需要知道:

  1. PyTorch Module 是所有神經網路模組的基本類;
  2. 我們的神經網路的每個層都是一個 Module ;
  3. 每個 Module 都有一個稱為 forward 的方法,當給 Module 一個輸入時它會計算輸出。

當我們將噪聲圖輸入到我們的網路中時,forward 方法就會計算出第一層的輸出結果;第二層的輸入是前一層 forward 方法的輸出結果;以此類推。當我們在某個層「register forward hook」時,在該層的 forward 方法被呼叫後將執行「hook」。

例如,當我們對層 i 的特徵對映感興趣時,我們在 i 層 register 一個「forword hook」;當層 i 的 forward 方法被呼叫後,層 i 的特徵就會儲存在一個變數裡。

儲存變數的類如下:

class SaveFeatures():
    def __init__(self, module):
        self.hook = module.register_forward_hook(self.hook_fn)
    def hook_fn(self, module, input, output):
        self.features = torch.tensor(output,requires_grad=True).cuda()
    def close(self):
        self.hook.remove()

當執行 hook 時,呼叫方法 hook_fn。hook_fn 方法會將層輸出儲存在 self.features。注意這個張量是需要梯度的,因為我們要在畫素值上執行反向傳播。

怎麼用 SaveFeatures 物件呢?

對層 i 的 hook 為:

activations = SaveFeatures(list(self.model.children())[i])

當你用 model(img_var) 將模型應用到影象上後,你可以通過 hook 儲存在 activations.features 來訪問特徵。注意別忘了一點,在訪問完畢後使用 close 釋放記憶體。

好了,現在我們已經能夠訪問層 i 的特徵圖了。特徵圖的形式為 [ 1, 512, 7, 7 ],其中 1 表示批維度,512 表示濾波器/特徵圖的個數,7 表示特徵圖的長和寬。我們的目標就是最大化選擇的某一特徵圖 j 的平均啟用值。因此我們定義如下損失函式:

loss = -activations.features[0, j].mean()

以及優化器:

optimizer = torch.optim.Adam([img_var], lr=lr, weight_decay=1e-6)

預設情況下,優化器可以最大限度地減少損失,因此我們只需將平均啟用乘以 -1 即可告知優化器最大化損失。使用 optimizer.zero_grad() 重置梯度,使用 loss.backward() 計算畫素值的梯度,並使用 optimizer.step() 更改畫素值。

我們現在已經有了所有需要的東西:從隨機影象開始,在評估模式下定義預先訓練的網路,執行前向傳播以獲取第 i 層的特徵,並定義了允許我們更改畫素值以最大化層 i 中特徵對映 j 的平均啟用的優化器和損失函式。

好,讓我們看一個例子:

第 40 層,第 265 個濾波器

等等,這不正是我們想要的嗎?和前面的鏈條模式很相似;如果你眯著眼睛看,就可以看到鏈條。但可以肯定的是,我們獲得的特徵圖的區域性性必定非常差,因此我們必須找到一種方法來指導我們的優化器以獲得最小化模式或者或「更好看」的模式。與我前面展示的模式相反,這張圖由高頻模式佔主導,類似於對抗樣本。那麼我們該怎麼解決這個問題呢?我嘗試了不同的優化器、學習速率以及正則化,但似乎沒有任何東西可以減少高頻模式。

接下來,我變化了一下輸入噪聲影象的尺寸:

影象大小分別為 200x200、300x300、400x400

在這三幅圖中,有沒有發現「鏈狀圖案」的頻率隨著影象尺寸的增加而增加?由於卷積濾波器具有固定的尺寸,因此當影象解析度增大時,濾波器的相對尺寸就會變小。換句話說:假設建立的模式都是以畫素為單位,當我們增加影象尺寸,則生成圖案的相對尺寸將減小,而圖案的頻率將增加。

如果我的假設是正確的,那麼我們想要的是低解析度樣本(甚至比上面顯示的還低)但高分辨的低頻模式。這有意義嗎?以及怎麼做?

我的解決方案是:首先從非常低解析度的影象開始,即 56×56 畫素,優化畫素值幾步,然後將影象尺寸增加一定的係數;在對影象放大後,再將畫素值優化幾步;然後再次對影象進行放大...

這樣獲得的結果出奇的好,一個低頻、高解析度、且沒有太多噪音的模式圖:

第 40 層,第 265 個濾波器

我們現在有了一個解析度好得多的低頻模式,並且沒有太多的噪音。為什麼這樣做會有用呢?我的想法是:當我們從低解析度開始時,我們會得到低頻模式。放大後,放大後的模式圖相比直接用大尺度影象優化生成的模式圖有較低的頻率。因此,在下一次迭代中優化畫素值時,我們處於一個更好的起點,看起來避免了區域性最小值。這有意義嗎?為了進一步減少高頻模式,我在放大後稍微模糊了影象。

我發現以 1.2 的倍數放大 12 次之後得到的結果不錯。

看看下面的程式碼。你會發現我們已經將重點資訊都講清了,例如建立隨機影象、register hook、定義優化器和損失函式,以及優化畫素值。唯一重要的新內容是:1)將程式碼封裝到一個類中;2)我們在優化畫素值後將影象放大了幾次。

class FilterVisualizer():
    def __init__(self, size=56, upscaling_steps=12, upscaling_factor=1.2):
        self.size, self.upscaling_steps, self.upscaling_factor = size, upscaling_steps, upscaling_factor
        self.model = vgg16(pre=True).cuda().eval()
        set_trainable(self.model, False)

    def visualize(self, layer, filter, lr=0.1, opt_steps=20, blur=None):
        sz = self.size
        img = np.uint8(np.random.uniform(150, 180, (sz, sz, 3)))/255  # generate random image
        activations = SaveFeatures(list(self.model.children())[layer])  # register hook

        for _ in range(self.upscaling_steps):  # scale the image up upscaling_steps times
            train_tfms, val_tfms = tfms_from_model(vgg16, sz)
            img_var = V(val_tfms(img)[None], requires_grad=True)  # convert image to Variable that requires grad
            optimizer = torch.optim.Adam([img_var], lr=lr, weight_decay=1e-6)
            for n in range(opt_steps):  # optimize pixel values for opt_steps times
                optimizer.zero_grad()
                self.model(img_var)
                loss = -activations.features[0, filter].mean()
                loss.backward()
                optimizer.step()
            img = val_tfms.denorm(img_var.data.cpu().numpy()[0].transpose(1,2,0))
            self.output = img
            sz = int(self.upscaling_factor * sz)  # calculate new image size
            img = cv2.resize(img, (sz, sz), interpolation = cv2.INTER_CUBIC)  # scale image up
            if blur is not None: img = cv2.blur(img,(blur,blur))  # blur image to reduce high frequency patterns
        self.save(layer, filter)
        activations.close()

    def save(self, layer, filter):
        plt.imsave("layer_"+str(layer)+"_filter_"+str(filter)+".jpg", np.clip(self.output, 0, 1))

使用 FilterVisualizer :

layer = 40
filter = 265
FV = FilterVisualizer(size=56, upscaling_steps=12, upscaling_factor=1.2)
FV.visualize(layer, filter, blur=5)

程式碼預設使用英偉達的 GPU,如果沒有,可以在 google colab 上測試。

相關文章