使用Flux.jl進行圖像分類
在PyTorch從事一個項目,這個項目創(chuàng)建一個深度學習模型,可以檢測未知物種的疾病。
最近,決定在Julia中重建這個項目,并將其用作學習Flux.jl[1]的練習,這是Julia最流行的深度學習包(至少在GitHub上按星級排名)。
但在這樣做的過程中,遇到了一些挑戰(zhàn),這些挑戰(zhàn)在網上或文檔中找不到好的例子。因此,決定寫這篇文章,作為其他任何想在Flux做類似事情的人的參考資料。
這是給誰的?
因為Flux.jl(以下簡稱為“Flux”)是一個深度學習包,所以我主要為熟悉深度學習概念(如遷移學習)的讀者編寫這篇文章。
雖然在寫這篇文章時也考慮到了Flux的一個半新手(比如我自己),但其他人可能會覺得這很有價值。只是要知道,寫這篇文章并不是對Julia或通量的全面介紹或指導。為此,將分別參考其他資源,如官方的Julia和Flux文檔。
最后,對PyTorch做了幾個比較。了解本文觀點并不需要有PyTorch的經驗,但有PyTorch經驗的人可能會覺得它特別有趣。
為什么是Julia?為什么選擇Flux.jl?
如果你已經使用了Julia和/或Flux,你可能可以跳過本節(jié)。此外,許多其他人已經寫了很多關于這個問題的帖子,所以我將簡短介紹。
歸根結底,我喜歡Julia。它在數值計算方面很出色,編程時真的很開心,而且速度很快。原生快速:不需要NumPy或其他底層C++代碼的包裝器。
至于為什么選擇Flux,是因為它是Julia中最流行的深度學習框架,用純Julia編寫,可與Julia生態(tài)系統(tǒng)組合。
項目本身
好吧,既然我已經無恥地說服了Julia,現(xiàn)在是時候了解項目本身的信息了。
我使用了三個數據集——PlantVillage[2]、PlantLeaves[3]和PlantaeK[4]——涵蓋了許多不同的物種。
我使用PlantVillage作為訓練集,其他兩個組合作為測試集。這意味著模型必須學習一些可以推廣到未知物種的知識,因為測試集將包含未經訓練的物種。
了解到這一點,我創(chuàng)建了三個模型:
使用ResNet遷移學習的基線
具有自定義CNN架構的孿生(又名暹羅)神經網絡
具有遷移學習的孿生神經網絡
本文的大部分內容將詳細介紹處理數據、創(chuàng)建和訓練模型的一些挑戰(zhàn)和痛點。
處理數據
第一個挑戰(zhàn)是數據集的格式錯誤。我不會在這里詳細介紹如何對它們進行預處理,但最重要的是我創(chuàng)建了兩個圖像目錄,即訓練和測試。
這兩個文件都填充了一長串圖像,分別命名為img0.jpg、img1.jpg、imm2.jpg等。我還創(chuàng)建了兩個CSV,一個用于訓練集,一個為測試集,其中一列包含文件名,一列包含標簽。
上述結構很關鍵,因為數據集的總容量超過10 GB,我電腦的內存肯定無法容納,更不用說GPU的內存了。因此,我們需要使用DataLoader。(如果你曾經使用過PyTorch,你會很熟悉;這里的概念與PyTorch基本相同。)
為了在Flux中實現(xiàn)這一點,我們需要創(chuàng)建一個自定義結構來包裝我們的數據集,以允許它批量加載數據。
為了讓我們的自定義結構能夠構造數據加載器,我們需要做的就是為類型定義兩個方法:length和getindex。下面是我們將用于數據集的實現(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函數獲取文件名列表,獲取適當的文件名,加載這些圖像,然后將其處理并重新塑造為適當的HEIGHT × WIDTH × COLOR × NUMBER形狀。標簽也是如此。
然后,我們的訓練、驗證和測試數據加載器可以非常容易地完成:
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)
制作模型
數據加載器準備就緒后,下一步是創(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)建了預訓練的遷移學習模型后,這只剩下兩個孿生網絡模型。然而,與遷移學習不同,我們必須學習如何手動創(chuàng)建模型。(如果你習慣了PyTorch,這就是Flux與PyTorch的不同之處。)
使用Flux文檔和其他在線資源創(chuàng)建CNN相對容易。然而,F(xiàn)lux沒有內置層來表示具有參數共享的Twin網絡。它最接近的是平行層,它不使用參數共享。
然而,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是我們的兩個圖像輸入將經過的網絡,而combine是在最后將輸出組合在一起的函數。
使用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網絡,我們可以執(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網絡,我們可以執(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)在我們的數據加載器和模型準備就緒,剩下的就是訓練了。通常,在Flux中,可以使用一個簡單的一行代碼,@epochs 2 Flux.train!(loss, ps, dataset, opt),但我們確實有一些定制的事情要做。
首先,非孿生網絡的訓練循環(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的損失和精度。
它只選擇要訓練的最后一層參數。如果我們愿意,我們可以訓練整個模型,但這在計算上會更費力。這是不必要的,因為我們使用的是預訓練的權重。
對于每個epoch,它都會遍歷要訓練的訓練集的所有批次。然后,它計算整個驗證集(當然是成批的)的準確性和損失。如果提高了epoch的驗證精度,則可以保存模型。如果沒有,它將繼續(xù)到下一個時代。
請注意,我們可以在這里做更多的工作,例如,提前停止,但以上內容足以了解大致情況。
接下來,Twin網絡的訓練循環(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
首先注意,我們使用了一個同名函數train!,但具有稍微不同的函數簽名。這允許Julia根據我們正在訓練的網絡類型來分配正確的功能。
還要注意,Twin ResNet模型凍結其預訓練的參數,而我們訓練所有Twin自定義CNN參數。
除此之外,訓練循環(huán)的其余部分基本相同,只是我們必須使用兩個訓練數據加載器和兩個驗證數據加載器。這些為我們提供了兩個輸入和每批兩組標簽,我們將其適當地輸入到Twin模型中。
最后,請注意,Twin模型預測兩個輸入圖像是否具有相同的標簽,而常規(guī)非Twin網絡僅直接預測標簽。
這樣,為所有三個模型的測試集構建測試循環(huán)應該不會太難。因為這篇文章的目的是要解決我在網上找不到例子的主要痛點,所以我將把測試部分作為練習留給讀者。
最后
最大的挑戰(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進行圖像分類
請輸入評論內容...
請輸入評論/評論長度6~500個字
最新活動更多
-
即日-10.29立即報名>> 2024德州儀器嵌入式技術創(chuàng)新發(fā)展研討會
-
10月31日立即下載>> 【限時免費下載】TE暖通空調系統(tǒng)高效可靠的組件解決方案
-
即日-11.13立即報名>>> 【在線會議】多物理場仿真助跑新能源汽車
-
11月14日立即報名>> 2024工程師系列—工業(yè)電子技術在線會議
-
12月19日立即報名>> 【線下會議】OFweek 2024(第九屆)物聯(lián)網產業(yè)大會
-
即日-12.26火熱報名中>> OFweek2024中國智造CIO在線峰會
推薦專題
- 高級軟件工程師 廣東省/深圳市
- 自動化高級工程師 廣東省/深圳市
- 光器件研發(fā)工程師 福建省/福州市
- 銷售總監(jiān)(光器件) 北京市/海淀區(qū)
- 激光器高級銷售經理 上海市/虹口區(qū)
- 光器件物理工程師 北京市/海淀區(qū)
- 激光研發(fā)工程師 北京市/昌平區(qū)
- 技術專家 廣東省/江門市
- 封裝工程師 北京市/海淀區(qū)
- 結構工程師 廣東省/深圳市