用PyTorch實現圖像聚類

作者|Anders Ohrn
編譯|VK
來源|Towards Data Science

利用深度卷積神經網絡(DCNN)進行監督圖像分類是一個成熟的過程。通過預訓練模板模型加上微調優化,可以在許多有意義的應用中獲得非常高的準確率——比如最近在醫學圖像上的這項研究,在日常物體圖像上預訓練的模板Inception v3模型對前列腺癌診斷的準確率達到了99.7%。

對於無監督的圖像機器學習,目前的研究現狀遠沒有定論。

聚類是無監督機器學習的一種形式,其中數據(本例中的圖像)根據數據收集本身的某種結構進行分簇。在同一個簇中結束的圖像應該比不同簇中的圖像更相似。

圖像數據可能是複雜的-變化的背景,視圖中的多個對象-因此一對圖像比另一對圖像更相似意味着什麼並不明顯。如果沒有基本的真實性標籤,通常不清楚是什麼使一種聚類方法優於另一種聚類方法。

一方面,無監督的問題因此比有監督的問題更加模糊。沒有現成的正確答案可供優化。另一方面,從模糊的問題、假設的產生、問題的發現和修補中,最有趣的東西出現了。

我將描述一種最新的圖像聚類方法的實現(https://arxiv.org/abs/1903.12355)。這是近年來發表的許多先進的DCNN聚類技術之一。

我使用PyTorch庫來演示如何實現這個方法,並在整個文本中提供了幾個詳細的代碼片段。倉庫中提供完整的代碼:https://github.com/anderzzz/monkey_caput

在標準庫中沒有無監督版本的聚類方法,這點不像有監督版本,它可以很容易獲得圖像聚類方法,但PyTorch仍然能夠平穩地實現實際上非常複雜的方法。因此,我能夠探索、測試和輕微地探究DCNNs應用於聚類任務時可以做什麼。

我的目標是展示如何從一些概念和方程開始,你可以使用PyTorch來得到一些可以在計算機上運行的非常具體的東西,並指導進一步的創新和修改你所擁有的任何任務

我將把這個應用到真菌的圖像上。爲什麼是真菌?你待會兒再看。

但首先…實現VGG自編碼器

在討論聚類方法之前,我將實現一個自動編碼器(AE)。AEs有各種各樣的應用,包括降維,並且本身很有趣。它們在圖像聚類中的作用將在以後變得更加清楚。

用PyTorch庫實現基本的ae並不是那麼困難(請看這兩個例子)。我將實現特定的AE架構,它是SegNet方法的一部分,它建立在VGG模板卷積網絡上。VGG定義了一種體系結構,最初是爲監督圖像分類而開發的。

AE的架構如下圖所示。

圖像自編碼的步驟如下:

  1. 準備輸入圖像(左上角)

  2. 將圖像輸入編碼器,由具有標準CNN和ReLU激活的卷積層(綠色)和最大池層(紫色)組成

  3. 得到一個低維的編碼

  4. 將編碼輸入譯碼器,它由轉置的卷積層(帶歸一化和ReLU激活)(淺綠色)和解池化層(淺紫色)加上一個沒有歸一化或激活的最終卷積層(黃色)

  5. 獲得與輸入尺寸相同的輸出圖像。

是時候把這個設計變成代碼了。

我從創建一個編碼器模塊開始。第一行,包括初始化方法,如下所示:

import torch
from torch import nn
from torchvision import models

class EncoderVGG(nn.Module):
    '''
    基於vgg16體系結構的圖像編碼器,具有batch normalization。
    Args:
        預訓練的params (bool,可選):是否應該用預訓練的vGG參數填充網絡,默認值爲True
    '''
    channels_in = 3
    channels_code = 512

    def __init__(self, pretrained_params=True):
        super(EncoderVGG, self).__init__()

        vgg = models.vgg16_bn(pretrained=pretrained_params)
        del vgg.classifier
        del vgg.avgpool

        self.encoder = self._encodify_(vgg)

編碼器的結構與VGG-16卷積網絡的特徵提取層結構相同。因此,PyTorch庫中很容易找到該部分—PyTorch models.vgg16_bn,請參閱代碼片段中的第19行。

與VGG的規範應用程序不同,編碼不會被輸入到分類層中。最後兩層vgg.classifier以及vgg.avgpool被丟棄。

編碼器的層需要一次調整。在解碼器的解池層中,編碼器的最大池層中的池索引必須可用,在前面的圖像中虛線箭頭表示。VGG -16的模板版本不生成這些索引。然而,池化層可以重新初始化。這就是EncoderVGG模塊的_encodify方法完成的工作。

    def _encodify_(self, encoder):
        '''
        基於VGG模板的架構創建編碼器模塊列表。在編碼器-解碼器體系結構中,解碼器中的解池操作需要來自編碼器中相應池操作的池索引。在VGG模板中,這些索引不返回。因此需要使用此方法擴展池操作。
        參數:
            編碼器:模板VGG模型
        返回:
            模塊:定義與VGG模型對應的編碼器的模塊列表
        '''
        modules = nn.ModuleList()
        for module in encoder.features:
            if isinstance(module, nn.MaxPool2d):
                module_add = nn.MaxPool2d(kernel_size=module.kernel_size,
                                          stride=module.stride,
                                          padding=module.padding,
                                          return_indices=True)
                modules.append(module_add)
            else:
                modules.append(module)

        return modules

因爲這是一個PyTorch模塊(nn.Module),通過EncoderVGG實例實現小批量圖像數據的前向傳播需要一個forward方法:

    def forward(self, x):
        '''將圖像輸入encoder
        Args:
            x (Tensor): 圖片tensor
        Returns:
            x_code (Tensor): 編碼 tensor
            pool_indices (list): 池索引張量
        '''
        pool_indices = []
        x_current = x
        for module_encode in self.encoder:
            output = module_encode(x_current)

            # 如果模塊是池,有兩個輸出,第二個是池索引
            if isinstance(output, tuple) and len(output) == 2:
                x_current = output[0]
                pool_indices.append(output[1])
            else:
                x_current = output

        return x_current, pool_indices

該方法按順序執行編碼器中的每個層,並在創建池索引時收集它們。在執行編碼器模塊之後,代碼與池索引的有序集合一起返回。

接下來是解碼器。

它是VGG-16網絡的“轉置”版本。我使用引號是因爲解碼器層看起來很像反向的編碼器,但嚴格地說,它不是反轉或轉置。

譯碼器模塊的初始化:

class DecoderVGG(nn.Module):
    '''譯碼器的代碼基於vgg16體系結構與batch normalization。
    Args:
        encoder: ' EncoderVGG '的編碼器實例,它將被轉換成一個解碼器
    '''
    channels_in = EncoderVGG.channels_code
    channels_out = 3

    def __init__(self, encoder):
        super(DecoderVGG, self).__init__()

        self.decoder = self._invert_(encoder)
        
    def _invert_(self, encoder):
        '''將編碼器反轉,以將譯碼器創建爲編碼器的鏡像
        譯碼器由兩種主要類型組成:二維轉置卷積和二維解池,2D卷積之後是批處理歸一化和激活。
        譯碼器是反向的,編碼器中的卷積變成了轉置卷積加上歸一化和激活,編碼器中的maxpooling變成了unpooling。
        Args:
            encoder (ModuleList): 編碼器
        Returns:
            decoder (ModuleList): 通過編碼器的“反轉”獲得的譯碼器
        '''
        modules_transpose = []
        for module in reversed(encoder):

            if isinstance(module, nn.Conv2d):
                kwargs = {'in_channels' : module.out_channels, 'out_channels' : module.in_channels,
                          'kernel_size' : module.kernel_size, 'stride' : module.stride,
                          'padding' : module.padding}
                module_transpose = nn.ConvTranspose2d(**kwargs)
                module_norm = nn.BatchNorm2d(module.in_channels)
                module_act = nn.ReLU(inplace=True)
                modules_transpose += [module_transpose, module_norm, module_act]

            elif isinstance(module, nn.MaxPool2d):
                kwargs = {'kernel_size' : module.kernel_size, 'stride' : module.stride,
                          'padding' : module.padding}
                module_transpose = nn.MaxUnpool2d(**kwargs)
                modules_transpose += [module_transpose]

        # 放棄最後的歸一化和激活函數
        modules_transpose = modules_transpose[:-2]

        return nn.ModuleList(modules_transpose)

_invert_方法反向遍歷編碼器的各個層。

編碼器中的卷積(圖像中爲綠色)替換爲解碼器中相應的轉置卷積(圖像中爲淺綠色)。這個nn.ConvTranspose2d是PyTorch中的模塊,它對數據進行上採樣,而不是像衆所周知的卷積操作那樣進行下采樣。如需進一步解釋,請參閱此處:https://naokishibuya.medium.com/up-sampling-with-transposed-convolution-9ae4f2df52d0

編碼器中的最大池(紫色)替換爲相應的解池層(淺紫色),或nn.MaxUnpool2d,參考PyTorch庫模塊。

解碼器forward爲:

    def forward(self, x, pool_indices):
        '''執行解碼器
        Args:
            x (Tensor): 從編碼器得到的編碼張量
            pool_indices (list): 池索引
        Returns:
            x (Tensor): 解碼後的圖像張量
        '''
        x_current = x

        k_pool = 0
        reversed_pool_indices = list(reversed(pool_indices))
        for module_decode in self.decoder:

            # 如果模塊正在解池,收集適當的池索引
            if isinstance(module_decode, nn.MaxUnpool2d):
                x_current = module_decode(x_current, indices=reversed_pool_indices[k_pool])
                k_pool += 1
            else:
                x_current = module_decode(x_current)

        return x_current

編碼以及編碼器創建的池索引列表是輸入。每當執行一個解池層時,反向地,每次取一個池索引。這樣,關於編碼器如何執行最大池的信息被轉移到解碼器。

因此,在鏡像編碼器層的轉置層之後,forward的輸出張量形狀是與輸入到編碼器的圖像張量形狀相同。

完整的自編碼器模塊實現爲編碼器和解碼器實例的組合:

class AutoEncoderVGG(nn.Module):
    '''基於vgg16的batch normalization的自編碼器。該類由編碼器和解碼器組成。
    Args:
        pretrained_params (bool, optional): 是否應該用先訓練好的VGG參數填充網絡。
            默認值爲True。
    '''
    channels_in = EncoderVGG.channels_in
    channels_code = EncoderVGG.channels_code
    channels_out = DecoderVGG.channels_out

    def __init__(self, pretrained_params=True):
        super(AutoEncoderVGG, self).__init__()

        self.encoder = EncoderVGG(pretrained_params=pretrained_params)
        self.decoder = DecoderVGG(self.encoder.encoder)
        
    def forward(self, x):
        '''自編碼器前向傳播
        Args:
            x (Tensor): 圖像張量
        Returns:
            x_prime (Tensor): 編碼和解碼後的圖像張量
        '''
        code, pool_indices = self.encoder(x)
        x_prime = self.decoder(code, pool_indices)

        return x_prime

AE的一組參數可以產生與相應輸入非常相似的輸出,這是一組很好的參數。我使用AE輸入和輸出之間每個像素的均方誤差來作爲一個目標函數量化它,也就是PyTorch庫的nn.MSELoss。

通過定義AE模型和一個可微目標函數,利用PyTorch強大的工具進行反向傳播,得到一個梯度,然後進行網絡參數優化。我不會詳細介紹訓練是如何實施的(好奇的讀者可以看看在倉庫中的ae_learner.py,https://github.com/anderzzz/monkey_caput)。

編碼器通過特徵壓縮圖像,是聚類的起點

在訓練AE之後,它包含一個編碼器,它可以在較低的維度上近似地表示圖像數據集重複出現的高層特徵。對於真菌的圖像數據集,這些特徵可以是形狀、邊界和顏色,這些特徵在幾幅蘑菇圖像中是共享的。換句話說,編碼器體現了蘑菇樣式加上典型背景的簡潔表示。

因此,兩個與這些高級特徵非常相似的圖像對應的編碼應該比任何一對隨機編碼更接近——例如通過歐幾里得距離或餘弦相似度來衡量。

另一方面,圖像的低維壓縮是高度非線性的。因此,如果兩個編碼之間的距離大於某個相當小的閾值,就不能說明是互相對應的圖像。這對於創建定義良好、清晰的簇並不理想。

編碼器是一個起點。下一步將對編碼器進行改進,利用已學的蘑菇特徵將圖像壓縮成編碼,這些編碼也會形成固有的良好簇。

關於局部聚集損失的幾個字和方程

局部聚集(LA)方法定義了一個目標函數來量化一組代碼的聚類效果(https://arxiv.org/abs/1903.12355)。目標函數不像有監督的機器學習方法那樣直接引用圖像內容的真實標籤。相反,目標函數量化編碼圖像數據本質上對定義良好的簇的適應程度。

用這種方法得到的定義是否可以創建有意義的聚類,這一點並不明顯。這就是爲什麼需要實現和測試。

首先從LA的幾個定義中說明要實現什麼。

LA的簇目標是:

方程中的xᵢ是圖像張量,θ表示編碼器的參數。右側的vᵢ是與xᵢ相對應的編碼。這兩個集合Cᵢ和Bᵢ由集合中其他圖像的編碼組成,它們分別被命名爲vᵢ的近鄰和背景鄰居。

一組編碼A的概率P定義爲:

換句話說,指數定義了概率,其中如果概率密度越大,vᵢ與其他成員的點積越大。因此,集合a由與vᵢ相似,vᵢ可能是其簇的成員。

標量τ被稱爲溫度,它定義了點積相似性的尺度。

對於給定的真菌圖像集合{xᵢ},目標是找到使集合的聚類目標最小化的參數θ。LA論文的作者提出了一個論點,爲什麼這個目標是有意義的。我在這裏不再重複這個論點。簡單地說,分配給一個簇的編碼越清晰,與該簇的補集的編碼相比,簇的目標函數值就越低。

如何將LA目標作爲自定義損失函數來實現

在上面關於AE的部分中,描述了定製編碼器模塊。缺少的是LA的目標函數,因爲它不是PyTorch中庫損失函數的一部分。

需要實現自定義損失模塊。

loss函數模塊的初始化初始化了許多scikit-learn函數,這些函數是在forward方法中定義背景集和近鄰集中很有用。

import torch
from torch import nn
import torch.nn.functional as F

import numpy as np

from sklearn.neighbors import NearestNeighbors
from sklearn.cluster import KMeans
from sklearn.preprocessing import normalize
from scipy.spatial.distance import cosine as cosine_distance

class LocalAggregationLoss(nn.Module):
    '''Local Aggregation Loss module from "Local Aggregation for Unsupervised Learning of Visual Embeddings" by
    Zhuang, Zhai and Yamins (2019), arXiv:1903.12355v2
    '''
    def __init__(self, temperature,
                 k_nearest_neighbours, clustering_repeats, number_of_centroids,
                 memory_bank,
                 kmeans_n_init=1, nn_metric=cosine_distance, nn_metric_params={}):
        super(LocalAggregationLoss, self).__init__()

        self.temperature = temperature
        self.memory_bank = memory_bank

        self.neighbour_finder = NearestNeighbors(n_neighbors=k_nearest_neighbours + 1,
                                                 algorithm='ball_tree',
                                                 metric=nn_metric, metric_params=nn_metric_params)
        self.clusterer = []
        for k_clusterer in range(clustering_repeats):
            self.clusterer.append(KMeans(n_clusters=number_of_centroids,
                                         init='random', n_init=kmeans_n_init))

NearestNeighbors實例提供了一種有效的方法來計算數據點的最近鄰。這將用於定義集合B。KMeans實例提供了一種有效的方法來計算數據點的簇。這些將用於定義集合C。

其中:LocalAggregationLoss所需的forward方法爲

    def forward(self, codes, indices):
        '''local aggregation loss 模塊的forward方法'''
        assert codes.shape[0] == len(indices)

        codes = codes.type(torch.DoubleTensor)
        code_data = normalize(codes.detach().numpy(), axis=1)

        # 計算和收集定義損失函數中的常量的索引數組。請注意,這些數據值在反向傳播時不計算梯度
        self.memory_bank.update_memory(code_data, indices)
        
        background_neighbours = self._nearest_neighbours(code_data, indices)
        close_neighbours = self._close_grouper(indices)
        neighbour_intersect = self._intersecter(background_neighbours, close_neighbours)

        # 計算給定記憶庫常數的編碼的概率密度
        v = F.normalize(codes, p=2, dim=1)
        d1 = self._prob_density(v, background_neighbours)
        d2 = self._prob_density(v, neighbour_intersect)
        
        return torch.sum(torch.log(d1) - torch.log(d2)) / codes.shape[0]

forward方法接受當前版本的編碼器生成的一小批編碼,以及完整數據集中所述編碼的索引。由於在創建小批量時通常會對數據進行無序處理,因此索引可以是一個非連續整數的列表。

forward有兩個主要部分。首先計算相鄰集B,C及其交集。其次,對給定的一批編碼和集合計算概率密度,然後將其計算LA目標函數。

“記憶庫”是什麼?

LA的創造者採用了一種記憶庫的技巧,他們將其歸因於吳等人的另一篇論文(https://arxiv.org/pdf/1808.04699.pdf)。這是一種處理LA目標函數的梯度依賴於數據集所有編碼的梯度的方法。

所述函數的適當梯度必須計算如下所示:

右邊所有編碼的和意味着需要計算大量的張量並且在反向傳播時一直保留下來。在小批圖像上迭代不會提高效率,因爲必須計算與解碼器參數有關編碼的梯度。

因爲聚類的質量將一個圖像與數據集的所有其他圖像相關聯,而不是一個固定的真實標籤,這種糾纏是可以理解的。

記憶庫技巧相當於將當前小批量中的編碼以外的其他編碼視爲常量。因此,與其他編碼的導數的糾纏就消失了。只要近似的梯度足夠好地引導優化朝最小值方向發展,這是一個有用的方法。

記憶庫類實現爲:

class MemoryBank(object):
    '''Memory bank
    Args:
        n_vectors (int): 記憶庫應該持有的向量數量
        dim_vector (int): 記憶庫應該持有的向量的維度
        memory_mixing_rate (float, optional): 要添加到當前存儲向量的新向量的一部分。值應該在0.0到1.0之間,值越大更新越快。混合速率可以在調用' update_memory '時設置。.
    '''
    def __init__(self, n_vectors, dim_vector, memory_mixing_rate):

        self.dim_vector = dim_vector
        self.vectors = np.array([marsaglia(dim_vector) for _ in range(n_vectors)])
        self.memory_mixing_rate = memory_mixing_rate
        self.mask_init = np.array([False] * n_vectors)

    def update_memory(self, vectors, index):
        '''用新的向量更新'''
        if isinstance(index, int):
            self.vectors[index] = self._update_(vectors, self.vectors[index])

        elif isinstance(index, np.ndarray):
            for ind, vector in zip(index, vectors):
                self.vectors[ind] = self._update_(vector, self.vectors[ind])

    def mask(self, inds_int):
        '''給定整數索引構造一個布爾掩碼'''
        ret_mask = []
        for row in inds_int:
            row_mask = np.full(self.vectors.shape[0], False)
            row_mask[row.astype(int)] = True
            ret_mask.append(row_mask)

        return np.array(ret_mask)

    def _update_(self, vector_new, vector_recall):
        return vector_new * self.memory_mixing_rate + vector_recall * (1.0 - self.memory_mixing_rate)

它由與待聚類數據集維數相同、個數相同的單位數據向量組成(在超球面上用Marsaglia的方法統一初始化)。

因此,一個用編碼生成尺寸爲512的1000幅圖像的編碼器任務,意味着在尺寸爲512的真實座標向量空間中有1000個單位向量的記憶庫。一旦向記憶庫提供了一組新的向量以及相應的索引,記憶就會用某種混合速率memory_mixing_rate更新。該類還包含一個方便的方法,用於將整數索引集合轉換爲整個數據集的布爾掩碼。

注意,記憶庫只處理數字。記憶庫無法連接到PyTorch張量的反向傳播機制。記憶庫是更新的,而不是直接作爲反向傳播的一部分。

它是MemoryBank的一個實例,存儲在LocalAggregationLoss的memory_bank屬性中。

如何創建背景鄰居集和近鄰集

再次回到LocalAggregationLoss的forward方法。我使用先前初始化的scikit-learn實現鄰居集的創建。

    def _nearest_neighbours(self, codes_data, indices):
        '''確定記憶庫中給定編碼的k個最近鄰的索引
        
        Returns:
            indices_nearest (numpy.ndarray): 這批編碼的k個最近鄰的布爾數組
        '''
        self.neighbour_finder.fit(self.memory_bank.vectors)
        indices_nearest = self.neighbour_finder.kneighbors(codes_data, return_distance=False)

        return self.memory_bank.mask(indices_nearest)

    def _close_grouper(self, indices):
        '''確定與給定索引的向量在同一簇中的向量在記憶庫中的索引
        Returns:
            indices_close (numpy.ndarray): 批代碼相鄰的布爾數組
        '''
        memberships = [[]] * len(indices)
        for clusterer in self.clusterer:
            clusterer.fit(self.memory_bank.vectors)
            for k_index, cluster_index in enumerate(clusterer.labels_[indices]):
                other_members = np.where(clusterer.labels_ == cluster_index)[0]
                other_members_union = np.union1d(memberships[k_index], other_members)
                memberships[k_index] = other_members_union.astype(int)

        return self.memory_bank.mask(np.array(memberships, dtype=object))

    def _intersecter(self, n1, n2):
        '''兩個布爾數組的交集計算'''
        return np.array([[v1 and v2 for v1, v2 in zip(n1_x, n2_x)] for n1_x, n2_x in zip(n1, n2)])

_nearest_neighbours_intersecter都很簡單。前者依賴於尋找最近鄰居的方法。它考慮記憶庫中的所有數據點。

_close_grouper在記憶庫中執行多個數據點聚類。與關注點vᵢ屬於同一簇的那些數據點定義了這個近鄰集Cᵢ。LA論文的作者鼓勵使用多個聚類運行,因爲聚類包含一個隨機成分,所以通過執行多個聚類,可以消除噪聲。

爲了說明這一點,下圖中的紅點是其他編碼海洋中感興趣的編碼。記憶庫當前狀態的聚類將感興趣的點放在其他點的簇中(中間圖像中的綠色)。最近鄰定義了另一組相關數據點(右側圖像中爲紫色)。“_nearest_neighbours”和“_close_grouper爲小批量中的每個編碼創建這兩個集合,並將這些集合表示爲布爾掩碼。

計算概率密度,以便PyTorch反向傳播能夠計算梯度

對於批處理中每個代碼vᵢ的兩個集合(Bᵢ和Bᵢ與Cᵢ相交),是時候計算概率密度了。這個密度也可以用PyTorch方法來區分。

其實現方式爲:

    def _prob_density(self, codes, indices):
        '''計算由指標定義的集合中編碼的非歸一化概率密度
        
        Returns:
            prob_dens (Tensor): 給定編碼的向量的非歸一化概率密度
                
        '''
        ragged = len(set([np.count_nonzero(ind) for ind in indices])) != 1

        # 在該情況下,所有的向量子集都是相同的大小,可以簡潔地使用廣播和批處理。
        if not ragged:
            vals = torch.tensor([np.compress(ind, self.memory_bank.vectors, axis=0) for ind in indices],
                                requires_grad=False)
            v_dots = torch.matmul(vals, codes.unsqueeze(-1))
            exp_values = torch.exp(torch.div(v_dots, self.temperature))
            pdensity = torch.sum(exp_values, dim=1).squeeze(-1)

        #如果向量子集是不同的大小, 廣播是不可能的,所以手動循環
        else:
            xx_container = []
            for k_item in range(codes.size(0)):
                vals = torch.tensor(np.compress(indices[k_item], self.memory_bank.vectors, axis=0),
                                    requires_grad=False)
                v_dots_prime = torch.mv(vals, codes[k_item])
                exp_values_prime = torch.exp(torch.div(v_dots_prime, self.temperature))
                xx_prime = torch.sum(exp_values_prime, dim=0)
                xx_container.append(xx_prime)
            pdensity = torch.stack(xx_container, dim=0)

        return pdensity

在第14-16行中,所有不同的點積都是在小批量編碼和記憶庫子集之間計算的。這個np.compress將掩碼應用於記憶庫向量。

這個torch.matmul計算所有點積。還請注意,張量codes包含編碼器的數學運算記錄。因此,當這使PyTorch的反向傳播機制autograd能夠評估關於編碼器所有參數的損耗準則的梯度。

概念上相同的操作發生在第25-27行,但是在這個子句中,mini-batch維度被顯式地迭代。當numpy數組不能被廣播時,這是必需的,對於參差不齊的數組(至少目前是這樣)。

把模型和損失放在一起

總而言之,下面的代碼可以爲特定的數據集VGG編碼器和LA提供訓練。

from torch.optim import SGD
from torch.utils.data import DataLoader

from sklearn.preprocessing import normalize

import fungidata
from ae_deep import EncoderVGGMerged
from cluster_utils import MemoryBank, LocalAggregationLoss

# 創建真菌數據集
dataset = fungidata.factory.create('grid basic idx', ...)
dataloader = DataLoader(dataset, ...)

# 實例化定製的模型和初始預訓練的vgg編碼器
model = EncoderVGGMerged(merger_type='mean')
memory_bank = MemoryBank(n_vectors=5400, dim_vector=model.channels_code, memory_mixing_rate=0.5)
memory_bank.vectors = normalize(model.eval_codes_for_(dataloader), axis=1)
criterion = LocalAggregationLoss(memory_bank=memory_bank,
                                 temperature=0.07, k_nearest_neighbours=500, clustering_repeats=6, number_of_centroids=100)

# 實例化一個隨機梯度下降優化器
optimizer = SGD(model.parameters())

# 基本訓練循環
for epoch in range(20):
    for inputs in dataloader:
        optimizer.zero_grad()
        output = model(inputs['image'])
        loss = criterion(output, inputs['idx'])
        loss.backward()
        optimizer.step()

我在討論中省略了數據是如何準備的(我放在fungidata文件中的操作)。詳細信息可以在倉庫中找(https://github.com/anderzzz/monkey_caput

對於這個討論,將dataloader看作它可以返回真菌圖像的小批量數據,inputs['image'],以及它們在更大數據集中的相應索引,inputs['idx']。

訓練循環是函數式的,雖然很簡短,但詳細信息請參閱la_learner文件,不過沒有使用任何不同尋常的東西。

我使用稍微修改過的編碼器EncoderVGGMerged版本。它是EncoderVGG的子類。

class EncoderVGGMerged(EncoderVGG):
    '''VGG編碼器的特殊情況,其中代碼是沿着高度/寬度維度合併的。這是' EncoderVGG '的一個瘦子類。
    Args:
        merger_type (str, optional): 定義如何合併代碼. 
        
    '''
    def __init__(self, merger_type='mean', pretrained_params=True):
        super(EncoderVGGMerged, self).__init__(pretrained_params=pretrained_params)

        if merger_type is None:
            self.code_post_process = lambda x: x
            self.code_post_process_kwargs = {}
        elif merger_type == 'mean':
            self.code_post_process = torch.mean
            self.code_post_process_kwargs = {'dim' : (-2, -1)}
        elif merger_type == 'flatten':
            self.code_post_process = torch.flatten
            self.code_post_process_kwargs = {'start_dim' : 1, 'end_dim' : -1}
        else:
            raise ValueError('Unknown merger type for the encoder code: {}'.format(merger_type))

    def forward(self, x):
        '''圖像輸入到編碼器
        Args:
            x (Tensor): 圖片張量
        Returns:
            x_code (Tensor): 合併
        '''
        x_current, _ = super().forward(x)
        x_code = self.code_post_process(x_current, **self.code_post_process_kwargs)

        return x_code

這個類在編碼器的結果中附加一個應用於代碼的合併層,因此它是一個一維的向量。

我將演示用於聚類的編碼器模型,該模型應用於一個RGB 64x64圖像作爲輸入。

接下來,我將演示創建輸出和損失變量的模型的一小批圖像的前向過程。

圖中的LALoss模塊與記憶庫交互,考慮到大小爲N的總數據集中的小批量圖像的索引。它構建記憶庫當前狀態的簇和最近鄰,並將小批量代碼與這些子集關聯起來。

backward執行反向傳播,從LA準則的損失輸出開始,然後遵循涉及代碼的數學運算,並通過鏈式規則獲得LA目標函數相對於編碼器參數的近似梯度。

關於真菌圖像

我將把這個方法應用到真菌圖像中。我的理由:

  1. 我使用的軟件庫不是爲這個特定任務開發或預先訓練的。我希望測試使用通用庫工具處理特殊圖像任務的場景。

  2. 真菌的外觀在形狀、顏色、大小、光澤、結構細節以及它們典型的背景(秋葉、青苔、土壤、採摘者的手)等方面各不相同。信號和噪聲都是不同的。

  3. 真菌圖像位於人類憑直覺識別的明顯物體(例如狗、貓和汽車)與需要深層專業知識才能掌握的圖像之間的最佳位置。我相信這有助於理解方法。

  4. 丹麥真菌學協會(2016)提供了非常好的註釋衆包公開數據。(https://svampe.databasen.org/).

以下是由真菌照片創建的圖像數據,數據庫中的三幅圖像如下所示。

說明性測試運行和探索

LA的一個缺點是它涉及多個超參數。可悲的是,我沒有足夠的gpu來支持,所以我必須限制自己在超參數和真菌圖像選擇的許多可能變化中的很少一部分。

我在這篇文章中的重點是從概念和方程實現(外加一個真菌圖像數據的插件)。因此,我在這裏尋求說明和啓發,並將繼續對高層次的觀察得出進一步的結論。

我訓練AE的香腸菌和木耳蘑菇壓縮到224x224。在隨機梯度下降優化器下,AE最終收斂,但對於某些優化參數,訓練陷入次優。下面顯示了一個經過訓練的AE的輸入和輸出示例。

這是一個明顯的損失保真度,特別是在周圍的草地。

以AE的編碼器爲起始點,進一步對編碼器進行LA目標優化。使用相同的一組蘑菇圖像,溫度爲0.07,混合速率爲0.5(如原始論文中所述),聚類的數量約爲待聚類圖像數量的十分之一。由於我的圖像數據集比較小,所以我設置了背景鄰居,將所有的圖像都包含在數據集中。

一組圖像說明如下:

很明顯,蒼蠅瓊脂簇有明顯的白色斑點。然而,在簇中所包含的圖像也是相當不同的。觀察其他簇,在其他簇中偶爾會出現白點蒼耳帽。

另一個說明性簇如下所示。

這些圖像有一些共同點,使它們與典型的圖像有所不同:顏色較深,大部分來自背景中的褐色葉子。

但是,同樣的,滿足這個粗略標準的圖像也出現在其他的聚類中,說明編碼中還有額外的非線性關係,這使得上面的圖像對應的編碼相對緊密和不同,而其他的則不是,較難解釋。

我還注意到許多簇只包含一個圖像。改變進入k-means聚類的簇質心的數量會影響到這一點,但是隨後會出現非常大的圖像簇,因此很難提供對共享特徵的直觀解釋。

這些是其他運行所生成的結果的說明。我在這裏進行的有限的幾次運行中最小化了LA的場景,創造出了一組圖像,至少在我看來是一組自然的圖像。

考慮到深度神經網絡的靈活性,我希望有很多方法可以將圖像壓縮成清晰的簇,但就我所知,這些方法並不一定包含有用的含義。與實際情況標籤不同的是,神經網絡的靈活性被引導到一個我們在優化之前定義爲有用的目標,優化器在這裏可以自由地尋找特徵來利用,以提高簇質量。

也許需要一個不同的歸納偏差來更好地限制靈活性的部署,以最小化LA目標函數?就我的視覺認知而言,也許LA目標函數應該與一個附加目標相結合,以防止它偏離某個合理的範圍?也許我應該使用標準化的圖像,例如某些醫學圖像、護照照片或固定透視相機,將圖像的變化限制爲較少的高級特徵,而這些特徵可以在聚類中使用?或者,我擔心的真正答案是在這個問題上投入更多的gpu,然後找出超參數的完美組合?

當然都是猜測。多虧了PyTorch,從概念和方程式到原型設計和創建模板解決方案的障礙降低了。

結尾

常規警告:我對LA的實現與最初的論文一樣,所以有出現誤解或bug的可能性。

我沒有花任何精力來優化實現。很可能我忽略了PyTorch和/或NumPy技巧,它們可以加快CPU或GPU的速度。

原文鏈接:https://towardsdatascience.com/image-clustering-implementation-with-pytorch-587af1d14123

歡迎關注磐創AI博客站:
http://panchuang.net/

sklearn機器學習中文官方文檔:
http://sklearn123.com/

歡迎關注磐創博客資源彙總站:
http://docs.panchuang.net/

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