訂閱
糾錯
加入自媒體

使用Flux.jl進行圖像分類

在PyTorch從事一個項目,這個項目創(chuàng)建一個深度學習模型,可以檢測未知物種的疾病。

最近,決定在Julia中重建這個項目,并將其用作學習Flux.jl[1]的練習,這是Julia最流行的深度學習包(至少在GitHub上按星級排名)。

但在這樣做的過程中,遇到了一些挑戰(zhàn),這些挑戰(zhàn)在網(wǎng)上或文檔中找不到好的例子。因此,決定寫這篇文章,作為其他任何想在Flux做類似事情的人的參考資料。

這是給誰的?

因為Flux.jl(以下簡稱為“Flux”)是一個深度學習包,所以我主要為熟悉深度學習概念(如遷移學習)的讀者編寫這篇文章。

雖然在寫這篇文章時也考慮到了Flux的一個半新手(比如我自己),但其他人可能會覺得這很有價值。只是要知道,寫這篇文章并不是對Julia或通量的全面介紹或指導。為此,將分別參考其他資源,如官方的Julia和Flux文檔。

最后,對PyTorch做了幾個比較。了解本文觀點并不需要有PyTorch的經(jīng)驗,但有PyTorch經(jīng)驗的人可能會覺得它特別有趣。

為什么是Julia?為什么選擇Flux.jl?

如果你已經(jīng)使用了Julia和/或Flux,你可能可以跳過本節(jié)。此外,許多其他人已經(jīng)寫了很多關于這個問題的帖子,所以我將簡短介紹。

歸根結底,我喜歡Julia。它在數(shù)值計算方面很出色,編程時真的很開心,而且速度很快。原生快速:不需要NumPy或其他底層C++代碼的包裝器。

至于為什么選擇Flux,是因為它是Julia中最流行的深度學習框架,用純Julia編寫,可與Julia生態(tài)系統(tǒng)組合。

項目本身

好吧,既然我已經(jīng)無恥地說服了Julia,現(xiàn)在是時候了解項目本身的信息了。

我使用了三個數(shù)據(jù)集——PlantVillage[2]、PlantLeaves[3]和PlantaeK[4]——涵蓋了許多不同的物種。

我使用PlantVillage作為訓練集,其他兩個組合作為測試集。這意味著模型必須學習一些可以推廣到未知物種的知識,因為測試集將包含未經(jīng)訓練的物種。

了解到這一點,我創(chuàng)建了三個模型:

使用ResNet遷移學習的基線

具有自定義CNN架構的孿生(又名暹羅)神經(jīng)網(wǎng)絡

具有遷移學習的孿生神經(jīng)網(wǎng)絡

本文的大部分內容將詳細介紹處理數(shù)據(jù)、創(chuàng)建和訓練模型的一些挑戰(zhàn)和痛點。

處理數(shù)據(jù)

第一個挑戰(zhàn)是數(shù)據(jù)集的格式錯誤。我不會在這里詳細介紹如何對它們進行預處理,但最重要的是我創(chuàng)建了兩個圖像目錄,即訓練和測試。

這兩個文件都填充了一長串圖像,分別命名為img0.jpg、img1.jpg、imm2.jpg等。我還創(chuàng)建了兩個CSV,一個用于訓練集,一個為測試集,其中一列包含文件名,一列包含標簽。

上述結構很關鍵,因為數(shù)據(jù)集的總容量超過10 GB,我電腦的內存肯定無法容納,更不用說GPU的內存了。因此,我們需要使用DataLoader。(如果你曾經(jīng)使用過PyTorch,你會很熟悉;這里的概念與PyTorch基本相同。)

為了在Flux中實現(xiàn)這一點,我們需要創(chuàng)建一個自定義結構來包裝我們的數(shù)據(jù)集,以允許它批量加載數(shù)據(jù)。

為了讓我們的自定義結構能夠構造數(shù)據(jù)加載器,我們需要做的就是為類型定義兩個方法:length和getindex。下面是我們將用于數(shù)據(jù)集的實現(xiàn):

using Flux

using Images

using FileIO

using DataFrames

using Pipe

"""

    ImageDataContainer(labels_df, img_dir)

Implements the functions `length` and `getindex`, which are required to use ImageDataContainer

as an argument in a DataLoader for Flux.

"""

struct ImageDataContainer

    labels::AbstractVector

    filenames::AbstractVector{String}

    function ImageDataContainer(labels_df::DataFrame, img_dir::AbstractString)

        filenames = img_dir .* labels_df[!, 1] # first column should be the filenames

        labels = labels_df[!, 2] # second column should be the labels

        return new(labels, filenames)

    end

end

"Gets the number of observations for a given dataset."

function Base.length(dataset::ImageDataContainer)

    return length(dataset.labels)

end

"Gets the i-th to j-th observations (including labels) for a given dataset."

function Base.getindex(dataset::ImageDataContainer, idxs::Union{UnitRange,Vector})

    batch_imgs = map(idx -> load(dataset.filenames[idx]), idxs)

    batch_labels = map(idx -> dataset.labels[idx], idxs)

    "Applies necessary transforms and reshapings to batches and loads them onto GPU to be fed into a model."

    function transform_batch(imgs, labels)

        # convert imgs to 256×256×3×64 array (Height×Width×Color×Number) of floats (values between 0.0 and 1.0)

        # arrays need to be sent to gpu inside training loop for garbage collector to work properly

        batch_X = @pipe hcat(imgs...) |> reshape(_, (HEIGHT, WIDTH, length(labels))) |> channelview |> permutedims(_, (2, 3, 1, 4))

        batch_y = @pipe labels |> reshape(_, (1, length(labels)))

        return (batch_X, batch_y)

    end

    return transform_batch(batch_imgs, batch_labels)

end

本質上,當Flux試圖檢索一批圖像時,它會調用getindex(dataloader, i:i+batchsize),這在Julia中相當于dataloader[i:i+batchsize]。

因此,我們的自定義getindex函數(shù)獲取文件名列表,獲取適當?shù)奈募,加載這些圖像,然后將其處理并重新塑造為適當?shù)腍EIGHT × WIDTH × COLOR × NUMBER形狀。標簽也是如此。

然后,我們的訓練、驗證和測試數(shù)據(jù)加載器可以非常容易地完成:

using Flux: Data.DataLoader

using CSV

using DataFrames

using MLUtils

# dataframes containing filenames for images and corresponding labels

const train_df = DataFrame(CSV.File(dataset_dir * "train_labels.csv"))

const test_df = DataFrame(CSV.File(dataset_dir * "test_labels.csv"))

# ImageDataContainer wrappers for dataframes

# gives interface for getting the actual images and labels as tensors

const train_dataset = ImageDataContainer(train_df, train_dir)

const test_dataset = ImageDataContainer(test_df, test_dir)

# randomly sort train dataset into training and validation sets

const train_set, val_set = splitobs(train_dataset, at=0.7, shuffle=true)

const train_loader = DataLoader(train_set, batchsize=BATCH_SIZE, shuffle=true)

const val_loader = DataLoader(val_set, batchsize=BATCH_SIZE, shuffle=true)

const test_loader = DataLoader(test_dataset, batchsize=BATCH_SIZE)

制作模型

數(shù)據(jù)加載器準備就緒后,下一步是創(chuàng)建模型。首先是基于ResNet的遷移學習模型。事實證明,這項工作相對困難。

在Metalhead.jsl包中(包含用于遷移學習的計算機視覺Flux模型),創(chuàng)建具有預訓練權重的ResNet18模型應該與model = ResNet(18; pretrain = true)一樣簡單。

然而,至少在編寫本文時,創(chuàng)建預訓練的模型會導致錯誤。這很可能是因為Metalhead.jsl仍在添加預訓練的權重。

我終于在HuggingFace上找到了包含權重的.tar.gz文件:

https://huggingface.co/FluxML/resnet18

我們可以使用以下代碼加載權重,并創(chuàng)建我們自己的自定義Flux模型:

using Flux

using Metalhead

using Pipe

using BSON

# load in saved params from bson

resnet = ResNet(18)

@pipe joinpath(@__DIR__, "resnet18.bson") |> BSON.load(_)[:model] |> Flux.loadmodel!(resnet, _)

# last element of resnet18 is a chain

# since we're removing the last element, we just want to recreate it, but with different number of classes

# probably a more elegant, less hard-coded way to do this, but whatever

baseline_model = Chain(

    resnet.layers[1:end-1],

    Chain(

        AdaptiveMeanPool((1, 1)),

        Flux.flatten,

        Dense(512 => N_CLASSES)

    )

)

(注意:如果有比這更優(yōu)雅的方法來更改ResNet的最后一層,請告訴我。)

創(chuàng)建了預訓練的遷移學習模型后,這只剩下兩個孿生網(wǎng)絡模型。然而,與遷移學習不同,我們必須學習如何手動創(chuàng)建模型。(如果你習慣了PyTorch,這就是Flux與PyTorch的不同之處。)

使用Flux文檔和其他在線資源創(chuàng)建CNN相對容易。然而,F(xiàn)lux沒有內置層來表示具有參數(shù)共享的Twin網(wǎng)絡。它最接近的是平行層,它不使用參數(shù)共享。

然而,F(xiàn)lux在這里有關于如何創(chuàng)建自定義多個輸入或輸出層的文檔。在我們的例子中,我們可以用來創(chuàng)建自定義Twin層的代碼如下:

using Flux

"Custom Flux NN layer which will create twin network from `path` with shared parameters and combine their output with `combine`."

struct Twin{T,F}

    combine::F

    path::T

end

# define the forward pass of the Twin layer

# feeds both inputs, X, through the same path (i.e., shared parameters)

# and combines their outputs

Flux.@functor Twin

(m::Twin)(Xs::Tuple) = m.combine(map(X -> m.path(X), Xs)...)

首先請注意,它以一個簡單的結構Twin開頭,包含兩個字段:combine和path。path是我們的兩個圖像輸入將經(jīng)過的網(wǎng)絡,而combine是在最后將輸出組合在一起的函數(shù)。

使用Flux.@functor告訴Flux將我們的結構像一個常規(guī)的Flux層一樣對待。(m::Twin)(Xs::Tuple) = m.combine(map(X -> m.path(X), Xs)…)定義了前向傳遞,其中元組Xs中的所有輸入X都通過path饋送,然后所有輸出都通過combine。

要使用自定義CNN架構創(chuàng)建Twin網(wǎng)絡,我們可以執(zhí)行以下操作:

using Flux

twin_model = Twin(

    # this layer combines the outputs of the twin CNNs

    Flux.Bilinear((32,32) => 1),

    

    # this is the architecture that forms the path of the twin network

    Chain(

        # layer 1

        Conv((5,5), 3 => 18, relu),

        MaxPool((3,3), stride=3),

        

        # layer 2

        Conv((5,5), 18 => 36, relu),

        MaxPool((2,2), stride=2),

        

        # layer 3

        Conv((3,3), 36 => 72, relu),

        MaxPool((2,2), stride=2),

        Flux.flatten,

        

        # layer 4

        Dense(19 * 19 * 72 => 64, relu),

        

        # Dropout(0.1),

        # output layer

        Dense(64 => 32, relu)

    )

)

在本例中,我們實際上使用Flux.Biliner層作為組合,這實質上創(chuàng)建了一個連接到兩個獨立輸入的輸出層。上面,兩個輸入是路徑的輸出,即自定義CNN架構;蛘,我們可以以某種方式使用hcat或vcat作為組合,然后在最后添加一個Dense層,但這個解決方案似乎更適合這個問題。

現(xiàn)在,要使用ResNet創(chuàng)建Twin網(wǎng)絡,我們可以執(zhí)行以下操作:

using Flux

using Metalhead

using Pipe

using BSON

# load in saved params from bson

resnet = ResNet(18)

@pipe joinpath(@__DIR__, "resnet18.bson") |> BSON.load(_)[:model] |> Flux.loadmodel!(resnet, _)

# create twin resnet model

twin_resnet = Twin(

    Flux.Bilinear((32,32) => 1),

    Chain(

        resnet.layers[1:end-1],

        Chain(

            AdaptiveMeanPool((1, 1)),

            Flux.flatten,

            Dense(512 => 32)

        )

    )

)

請注意,我們如何使用與之前相同的技巧,并使用Flux.雙線性層作為組合,并使用與之前類似的技巧來使用預訓練的ResNet作為路徑。

訓練時間

現(xiàn)在我們的數(shù)據(jù)加載器和模型準備就緒,剩下的就是訓練了。通常,在Flux中,可以使用一個簡單的一行代碼,@epochs 2 Flux.train!(loss, ps, dataset, opt),但我們確實有一些定制的事情要做。

首先,非孿生網(wǎng)絡的訓練循環(huán):

using Flux

using Flux: Losses.logitbinarycrossentropy

using CUDA

using ProgressLogging

using Pipe

using BSON

"Stores the history through all the epochs of key training/validation performance metrics."

mutable struct TrainingMetrics

    val_acc::Vector{AbstractFloat}

    val_loss::Vector{AbstractFloat}

    TrainingMetrics(n_epochs::Integer) = new(zeros(n_epochs), zeros(n_epochs))

end

"Trains given model for a given number of epochs and saves the model that performs best on the validation set."

function train!(model, n_epochs::Integer, filename::String)

    model = model |> gpu

    optimizer = ADAM()

    params = Flux.params(model[end]) # transfer learning, so only training last layers

    metrics = TrainingMetrics(n_epochs)

    # zero init performance measures for epoch

    epoch_acc = 0.0

    epoch_loss = 0.0

    # so we can automatically save the model with best val accuracy

    best_acc = 0.0

    # X and y are already in the right shape and on the gpu

    # if they weren't, Zygote.jl would throw a fit because it needs to be able to differentiate this function

    loss(X, y) = logitbinarycrossentropy(model(X), y)

    @info "Beginning training loop..."

    for epoch_idx ∈ 1:n_epochs

        @info "Training epoch $(epoch_idx)..."

        # train 1 epoch, record performance

        @withprogress for (batch_idx, (imgs, labels)) ∈ enumerate(train_loader)

            X = @pipe imgs |> gpu |> float32.(_)

            y = @pipe labels |> gpu |> float32.(_)

            gradients = gradient(() -> loss(X, y), params)

            Flux.Optimise.update!(optimizer, params, gradients)

            @logprogress batch_idx / length(enumerate(train_loader))

        end

        # reset variables

        epoch_acc = 0.0

        epoch_loss = 0.0

        @info "Validating epoch $(epoch_idx)..."

        # val 1 epoch, record performance

        @withprogress for (batch_idx, (imgs, labels)) ∈ enumerate(val_loader)

            X = @pipe imgs |> gpu |> float32.(_)

            y = @pipe labels |> gpu |> float32.(_)

            # feed through the model to create prediction

            y? = model(X)

            # calculate the loss and accuracy for this batch, add to accumulator for epoch results

            batch_acc = @pipe ((((σ.(y?) .> 0.5) .* 1.0) .== y) .* 1.0) |> cpu |> reduce(+, _)

            epoch_acc += batch_acc

            batch_loss = logitbinarycrossentropy(y?, y)

            epoch_loss += (batch_loss |> cpu)

            @logprogress batch_idx / length(enumerate(val_loader))

        end

        # add acc and loss to lists

        metrics.val_acc[epoch_idx] = epoch_acc / length(val_set)

        metrics.val_loss[epoch_idx] = epoch_loss / length(val_set)

        # automatically save the model every time it improves in val accuracy

        if metrics.val_acc[epoch_idx] >= best_acc

            @info "New best accuracy: $(metrics.val_acc[epoch_idx])! Saving model out to $(filename).bson"

            BSON.@save joinpath(@__DIR__, "$(filename).bson")

            best_acc = metrics.val_acc[epoch_idx]

        end

    end

    return model, metrics

end

這里有很多要解開的東西,但本質上這做了一些事情:

它創(chuàng)建了一個結構,用于跟蹤我們想要的任何驗證度量。在這種情況下是每個epoch的損失和精度。

它只選擇要訓練的最后一層參數(shù)。如果我們愿意,我們可以訓練整個模型,但這在計算上會更費力。這是不必要的,因為我們使用的是預訓練的權重。

對于每個epoch,它都會遍歷要訓練的訓練集的所有批次。然后,它計算整個驗證集(當然是成批的)的準確性和損失。如果提高了epoch的驗證精度,則可以保存模型。如果沒有,它將繼續(xù)到下一個時代。

請注意,我們可以在這里做更多的工作,例如,提前停止,但以上內容足以了解大致情況。

接下來,Twin網(wǎng)絡的訓練循環(huán)非常相似,但略有不同:

using Flux

using Flux: Losses.logitbinarycrossentropy

using CUDA

using ProgressLogging

using Pipe

using BSON

"Trains given twin model for a given number of epochs and saves the model that performs best on the validation set."

function train!(model::Twin, n_epochs::Integer, filename::String; is_resnet::Bool=false)

    model = model |> gpu

    optimizer = ADAM()

    params = is_resnet ? Flux.params(model.path[end:end], model.combine) : Flux.params(model) # if custom CNN, need to train all params

    metrics = TrainingMetrics(n_epochs)

    # zero init performance measures for epoch

    epoch_acc = 0.0

    epoch_loss = 0.0

    # so we can automatically save the model with best val accuracy

    best_acc = 0.0

    # X and y are already in the right shape and on the gpu

    # if they weren't, Zygote.jl would throw a fit because it needs to be able to differentiate this function

    loss(Xs, y) = logitbinarycrossentropy(model(Xs), y)

    @info "Beginning training loop..."

    for epoch_idx ∈ 1:n_epochs

        @info "Training epoch $(epoch_idx)..."

        # train 1 epoch, record performance

        @withprogress for (batch_idx, ((imgs?, labels?), (imgs?, labels?))) ∈ enumerate(zip(train_loader?, train_loader?))

            X? = @pipe imgs? |> gpu |> float32.(_)

            y? = @pipe labels? |> gpu |> float32.(_)

            X? = @pipe imgs? |> gpu |> float32.(_)

            y? = @pipe labels? |> gpu |> float32.(_)

            Xs = (X?, X?)

            y = ((y? .== y?) .* 1.0) # y represents if both images have the same label

            gradients = gradient(() -> loss(Xs, y), params)

            Flux.Optimise.update!(optimizer, params, gradients)

            @logprogress batch_idx / length(enumerate(train_loader?))

        end

        # reset variables

        epoch_acc = 0.0

        epoch_loss = 0.0

        @info "Validating epoch $(epoch_idx)..."

        # val 1 epoch, record performance

        @withprogress for (batch_idx, ((imgs?, labels?), (imgs?, labels?))) ∈ enumerate(zip(val_loader?, val_loader?))

            X? = @pipe imgs? |> gpu |> float32.(_)

            y? = @pipe labels? |> gpu |> float32.(_)

            X? = @pipe imgs? |> gpu |> float32.(_)

            y? = @pipe labels? |> gpu |> float32.(_)

            Xs = (X?, X?)

            y = ((y? .== y?) .* 1.0) # y represents if both images have the same label

            # feed through the model to create prediction

            y? = model(Xs)

            # calculate the loss and accuracy for this batch, add to accumulator for epoch results

            batch_acc = @pipe ((((σ.(y?) .> 0.5) .* 1.0) .== y) .* 1.0) |> cpu |> reduce(+, _)

            epoch_acc += batch_acc

            batch_loss = logitbinarycrossentropy(y?, y)

            epoch_loss += (batch_loss |> cpu)

            @logprogress batch_idx / length(enumerate(val_loader))

        end

        # add acc and loss to lists

        metrics.val_acc[epoch_idx] = epoch_acc / length(val_set)

        metrics.val_loss[epoch_idx] = epoch_loss / length(val_set)

        # automatically save the model every time it improves in val accuracy

        if metrics.val_acc[epoch_idx] >= best_acc

            @info "New best accuracy: $(metrics.val_acc[epoch_idx])! Saving model out to $(filename).bson"

            BSON.@save joinpath(@__DIR__, "$(filename).bson")

            best_acc = metrics.val_acc[epoch_idx]

        end

    end

    return model, metrics

end

首先注意,我們使用了一個同名函數(shù)train!,但具有稍微不同的函數(shù)簽名。這允許Julia根據(jù)我們正在訓練的網(wǎng)絡類型來分配正確的功能。

還要注意,Twin ResNet模型凍結其預訓練的參數(shù),而我們訓練所有Twin自定義CNN參數(shù)。

除此之外,訓練循環(huán)的其余部分基本相同,只是我們必須使用兩個訓練數(shù)據(jù)加載器和兩個驗證數(shù)據(jù)加載器。這些為我們提供了兩個輸入和每批兩組標簽,我們將其適當?shù)剌斎氲絋win模型中。

最后,請注意,Twin模型預測兩個輸入圖像是否具有相同的標簽,而常規(guī)非Twin網(wǎng)絡僅直接預測標簽。

這樣,為所有三個模型的測試集構建測試循環(huán)應該不會太難。因為這篇文章的目的是要解決我在網(wǎng)上找不到例子的主要痛點,所以我將把測試部分作為練習留給讀者。

最后

最大的挑戰(zhàn)是縮小從相對簡單的示例到更先進的技術之間的差距,而這些技術缺乏示例。但這也揭示了Julia的優(yōu)勢:因為它本身就很快,所以搜索包的源代碼以找到答案通常非常容易。

有幾次,我發(fā)現(xiàn)自己在瀏覽Flux源代碼,以了解一些東西是如何工作的。每一次我都能非常輕松快速地找到答案。我不確定我是否有勇氣為PyTorch嘗試類似的東西。

另一個挑戰(zhàn)是Metalhead.jsl的不成熟狀態(tài),這在Julia生態(tài)系統(tǒng)中肯定不是獨一無二的,因為它的功能不完整。

最后一個想法是,我發(fā)現(xiàn)Flux非常有趣和優(yōu)雅……一旦我掌握了它的竅門。我肯定會在未來與Flux一起進行更深入的學習。

感謝閱讀!

參考引用

[1] M. Innes, Flux: Elegant Machine Learning with Julia (2018), Journal of Open Source Software

[2] Arun Pandian J. and G. Gopal, Data for: Identification of Plant Leaf Diseases Using a 9-layer Deep Convolutional Neural Network (2019), Mendeley Data

[3] S. S. Chouhan, A. Kaul, and U. P. Singh, A Database of Leaf Images: Practice towards Plant Conservation with Plant Pathology (2019), Mendely Data

[4] V. P. Kour and S. Arora, PlantaeK: A leaf database of native plants of Jammu and Kashmir (2019), Mendeley Data

       原文標題 : 使用Flux.jl進行圖像分類

聲明: 本文由入駐維科號的作者撰寫,觀點僅代表作者本人,不代表OFweek立場。如有侵權或其他問題,請聯(lián)系舉報。

發(fā)表評論

0條評論,0人參與

請輸入評論內容...

請輸入評論/評論長度6~500個字

您提交的評論過于頻繁,請輸入驗證碼繼續(xù)

暫無評論

暫無評論

    人工智能 獵頭職位 更多
    掃碼關注公眾號
    OFweek人工智能網(wǎng)
    獲取更多精彩內容
    文章糾錯
    x
    *文字標題:
    *糾錯內容:
    聯(lián)系郵箱:
    *驗 證 碼:

    粵公網(wǎng)安備 44030502002758號