基于 Julia 的深度学习入门

2020-05-23 16:03:09 +08:00
 tczhangzhi

搬运自我的知乎: https://zhuanlan.zhihu.com/p/142667683

这段时间计算机视觉领域出现了一些使用 Julia 开源的相关工作,要科学合理地对比这些相关工作,储备新的炼丹技巧,笔者不得不开始熟悉 Julia。笔者从周一拿到 Julia 文档开始,这周的试验都是使用 Julia 完成的。这里,打算先说一说笔者的几个感受,帮助大家判断一下自己是否需要着手入坑这门语言:

实用性:★★★★☆

两三年前研究运筹学的时候用 Julia 做最优化问题,感觉比 Cplex 、Matlab 好用。近两年 Julia 开源的深度学习工作逐渐增多,研究的一般是基本问题,在 toy 数据集上跑试验。近期也出现了一些 CV 领域的项目。

生态:★★★☆☆

深度学习库 Flux 和 GPU 计算库 CuArray 基本稳定下来,周边项目更新迅速,比如常用的预训练模型也都可以在 Julia 社区中找到靠谱的库了(如 MetalHead )。当然,周边项目的快速迭代也会导致一些库动不动就报错(甚至在安装时都要费一番功夫)。另外比较有特点的是,大部分常用的 Python 库都有 PyCall 封装的跟进,实在不行自己用 PyCall 、JavaCall 、Clang 写个胶水层也能用。

易用性:★★★★★

Julia 的语法真的很简单,混合了 Python 和 Matlab,30 分钟入门后续查漏补缺即可。Julia 内置了大量的科学计算方法(符号),确实比 Python 直观和好写了很多。美中不足的是社区现有的代码和官方最佳实践比较少,笔者正在试图在这方面贡献一些工作。

运行速度:★★☆☆☆

运行速度比 PyThon 稍有提高,但是第一次运行需要编译因此调试时体验稍差于 Python。多线程跑崩过系统,GPU 的分布式框架还不太完善。

一、装机必备

在开始之前推荐一些装机必备。考虑到同学们比较熟悉 Python 因此使用 Python 中的 toolbox 进行类比,懒癌患者可以直接装推荐安装的部分:

二、炼丹示例

Julia 的语言 Feature 较多,但都比较通俗。因此笔者比较推荐同学们在使用过程中慢慢熟悉(就算你想先慢慢学一个月再去做实验老板也不同意是吧)。如果你实在想先浏览一下基础语法,笔者总结了一个 Notebook,帮助你在 15 分钟内看完并有一个大概印象。

下面笔者总结了 Julia 版的常用 Pipeline,可以帮助同学们理解如何像用 Python + PyTorch 一样简单地使用 Julia 完成深度学习项目。在做实验的时候同学们可以简单复制粘贴,修修改改先跑上。(逃

1. MLP + MNIST 实现一个最小用例

首先,我们先完成一个最小用例,实现在 GPU 上训练一个多层感知器拟合 MNIST,了解基本操作。由于篇幅限制,完整代码请参考并运行 MLP+MNIST

Flux 是 Julia 中的深度学习库,其完全由 Julia 实现,结构轻量化,是 Julia 中的 PyTorch 。因此首先导入 Flux 备用模型定义和反向传播(训练)。

# 从 Flux 中引入所需组件
using Flux, Flux.Data.MNIST, Statistics
using Flux: onehotbatch, onecold, crossentropy, throttle, params

尽管 Flux 中目前已经实现了 gpu() 方法,但功能有限。所幸 Flux 在 GPU 上的功能基于 CuArrays 实现,可以使用 CUDAapi, CUDAdrv, CUDAnative 来设置 Flux 使用哪个 GPU,或是只使用 CPU 。

using CUDAapi, CUDAdrv, CUDAnative
gpu_id = 1  ## set < 0 for no cuda, >= 0 for using a specific device (if available)

if has_cuda_gpu() && gpu_id >=0
    device!(gpu_id)
    device = Flux.gpu
    @info "Training on GPU-$(gpu_id)"
else
    device = Flux.cpu
    @info "Training on CPU"
end

另外,Flux 目前仍不支持分布式 GPU 训练,要想实现该功能也需要利用上述库写 scatter 和 gather 手动实现。

与 PyTorch 相同,Flux 定义了一个开箱即用的数据集 MNIST 。这里我们调用 MNIST.images() 和 MNIST.labels() 加载数据集和对应的 label,并使用 Flux 中提供的 onehotbatch 对 label 进行 onehot 编码。

imgs = MNIST.images()
labels = onehotbatch(MNIST.labels(), 0:9)

目前,Flux 没有提供数据集切分的函数,因此我们需要手动进行该过程。具体而言,我们使用 partition 对加载进来的数据集进行切分,将每 1000 张图像分为一个 batch,并使用 |> device (遍历每个元素分别执行上文中定义的 device())全部图像迁移到 GPU 中。

train = [(cat(float.(imgs[i])..., dims = 4), labels[:,i])
         for i in partition(1:60_000, 1000)] |> device

同样,我们选择数据集中前 1000 张图片作为测试数据集,也迁移到 GPU 中。

test_X = cat(float.(MNIST.images(:test)[1:1000])..., dims = 4) |> device
test_y = onehotbatch(MNIST.labels(:test)[1:1000], 0:9) |> device

Flux 中的模型定义与 PyTorch 相似,Chain 取代了 nn.Sequential,Conv/MaxPool/Dense 等 layer 也已经封装好(封装的 cuDNN )可以直接调用。如下所示,定义模型、损失函数和评估方法只需要三段代码。

model = Chain(
  Conv((2,2), 1=>16, relu),
  MaxPool((2, 2)),
  Conv((2,2), 16=>8, relu),
  MaxPool((2, 2)),
  x -> reshape(x, :, size(x, 4)),
  Dense(288, 10), softmax
) |> device


loss(x, y) = crossentropy(model(x), y)
accuracy(x, y) = mean(onecold(model(x)) .== onecold(y))

Flux 为使用者提供了 Adam 优化器,相比于 PyTorch 的版本,该 Adam 优化器似乎对学习旅更为敏感。如果遇到不收敛的情况可以尝试降低 LR 。后续打算对其 FLux 和 PyTorch 的优化器。和 PyTorch 相似,我们直接使用 ADAM(LR),定义优化器,使用 train!() 进行训练。

opt = ADAM(0.01)
evalcb() = @show(accuracy(test_X, test_y))

epochs = 5

for i = 1:epochs
    Flux.train!(loss, Flux.params(model), train, opt)
end

值得注意的是 Flux 中构建的图也为动态图,无需考虑计算图的构建,直接定义所需的计算操作就可以了。

进行推断时也如同 Pytorch,可以直接调用模型。如下,从测试集中选择一张图片放入模型,预测所属类别。

using Colors, FileIO, ImageShow

img = test_X[:, :, 1:1, 7:7]

println("Predicted: ", Flux.onecold(model(img |> device)) .- 1)
save("outputs.jpg", collect(test_X[:, :, 1, 7]))

2. VGG + Cifar 封装常用方法 Finetune 模型

在试验和竞赛中,我们通常要对读入图像进行增广;模型也通常是基于某个 pretrained 的模型 Finetune 的,因此接下来我们看如何对这些内容进行封装。由于篇幅限制,这里只说明重要部分,完整代码请参考并运行 VGG+Cifar10

目前 Flex 和周边的生态还不太完善,图像增强部分的实现实属有限。这里我们参照 pytorch 实现最基本的图像增广的预处理过程。更为丰富的预处理恐怕只能自己编写或是等待官方更新,当然,这也是重新造轮子的好机会~

function resize_smallest_dimension(im, len)
  reduction_factor = len/minimum(size(im)[1:2])
  new_size = size(im)
  new_size = (
      round(Int, size(im,1)*reduction_factor),
      round(Int, size(im,2)*reduction_factor),
  )
  if reduction_factor < 1.0
    # Images.jl's imresize() needs to first lowpass the image, it won't do it for us
    im = imfilter(im, KernelFactors.gaussian(0.75/reduction_factor), Inner())
  end
  return imresize(im, new_size)
end

# Take the len-by-len square of pixels at the center of image `im`
function center_crop(im, len)
  l2 = div(len,2)
  adjust = len % 2 == 0 ? 1 : 0
  return im[div(end,2)-l2:div(end,2)+l2-adjust,div(end,2)-l2:div(end,2)+l2-adjust]
end

function preprocess(im)
  # Resize such that smallest edge is 256 pixels long
  im = resize_smallest_dimension(im, 256)

  # Center-crop to 224x224
  im = center_crop(im, 224)

  # Convert to channel view and normalize (these coefficients taken
  # from PyTorch's ImageNet normalization code)
  μ = [0.485, 0.456, 0.406]
  # the sigma numbers are suspect: they cause the image to go outside of 0..1
  # 1/0.225 = 4.4 effective scale
  σ = [0.229, 0.224, 0.225]
  #im = (channelview(im) .- μ)./σ
  im = (channelview(im) .- μ)

  # Convert from CHW (Image.jl's channel ordering) to WHCN (Flux.jl's ordering)
  # and enforce Float32, as that seems important to Flux
  # result is (224, 224, 3, 1)
  #return Float32.(permutedims(im, (3, 2, 1))[:,:,:,:].*255)  # why
  return Float32.(permutedims(im, (3, 2, 1))[:,:,:,:])
end

这里将 MNIST 的数据集切分方法进行封装,使用 get_processed_data 和 get_test_data 构建训练集合、验证集合和测试集合。

using Metalhead: trainimgs
using Images, ImageMagick

function get_processed_data(args)
    # Fetching the train and validation data and getting them into proper shape	
    X = trainimgs(CIFAR10)
    imgs = [preprocess(X[i].img) for i in 1:40000]
    #onehot encode labels of batch
   
    labels = onehotbatch([X[i].ground_truth.class for i in 1:40000],1:10)

    train_pop = Int((1-args.splitr_)* 40000)
    train = device.([(cat(imgs[i]..., dims = 4), labels[:,i]) for i in partition(1:train_pop, args.batchsize)])
    valset = collect(train_pop+1:40000)
    valX = cat(imgs[valset]..., dims = 4) |> device
    valY = labels[:, valset] |> device

    val = (valX,valY)
    return train, val
end

function get_test_data()
    # Fetch the test data from Metalhead and get it into proper shape.
    test = valimgs(CIFAR10)

    # CIFAR-10 does not specify a validation set so valimgs fetch the testdata instead of testimgs
    testimgs = [preprocess(test[i].img) for i in 1:1000]
    testY = onehotbatch([test[i].ground_truth.class for i in 1:1000], 1:10) |> device
    testX = cat(testimgs..., dims = 4) |> device

    test = (testX,testY)
    return test
end

Julia 中预训练模型库正蓬勃发展,比较成熟的有 Metalhead (类似于 Torchvision )等。这里我们使用 Metalhead 中提供的模型结构和预训练参数构建 VGG19,并替换后面的层完成当前任务。值得一提的是,目前 EfficientNet 还没有较为优雅的 Julia 封装,实属一大遗憾。

using Metalhead

vgg = VGG19()
model = Chain(vgg.layers[1:end-6],
              Dense(512, 4096, relu),
              Dropout(0.5),
              Dense(4096, 4096, relu),
              Dropout(0.5),
              Dense(4096, 10)) |> device
Flux.trainmode!(model, true)

为了方便试验和记录,我们参照官方实现封装超参数和训练过程。在训练过程中,我们可以定义一个回调函数打印验证集的损失函数:throttle(() -> @show(loss(val...)), args.throttle)。

using Parameters: @with_kw
@with_kw mutable struct Args
    batchsize::Int = 128
    throttle::Int = 10
    lr::Float64 = 5e-5
    epochs::Int = 10
    splitr_::Float64 = 0.1
end

function train(model; kws...)
    # Initialize the hyperparameters
    args = Args(; kws...)
    
    # Load the train, validation data 
    train, val = get_processed_data(args)

    @info("Constructing Model")
    # Defining the loss and accuracy functions

    loss(x, y) = logitcrossentropy(model(x), y)

    ## Training
    # Defining the callback and the optimizer
    evalcb = throttle(() -> @show(loss(val...)), args.throttle)
    opt = ADAM(args.lr)
    @info("Training....")
    # Starting to train models
    Flux.@epochs args.epochs Flux.train!(loss, params(model), train, opt, cb=evalcb)
end

3. ResNet + ImageNet 大型数据集上的标准训练过程

在学会在中小型数据集上完成试验后,我们往往要将试验迁移到大型数据集上。训练过程也会增加很多读取、存储、日志等内容。由于篇幅限制,这里只说明重要部分,完整代码请参考并运行 ResNet+ImageNet

不同于 PyTorch,目前 Flux 对 Dataset 和 Dataloader 的支持十分有限。官方目前正着力于添加相关功能,不久后可能有相关实现。这里我们模仿 PyTorch 多线程读取数据集并生成 Dataloader 。

struct ImagenetDataset
    # Data we're initialized with
    dataset_root::String
    batch_size::Int
    data_loader::Function

    # Data we calculate once, at startup
    filenames::Vector{String}
    queue_pool::QueuePool

    function ImagenetDataset(dataset_root::String, num_workers::Int, batch_size::Int,
                             data_loader::Function = imagenet_val_data_loader)
        # Scan dataset_root for files
        filenames = filter(f -> endswith(f, ".JPEG"), recursive_readdir(dataset_root))

        @assert !isempty(filenames) "Empty dataset folder!"
        @assert num_workers >= 1 "Must have nonnegative integer number of workers!"
        @assert batch_size >= 1 "Must have nonnegative integer batch size!"

        # Start our worker pool
        @info("Adding $(num_workers) new data workers...")
        queue_pool = QueuePool(num_workers, data_loader, quote
            # The workers need to be able to load images and preprocess them via Metalhead
            using Flux, Images, Metalhead
            include($(@__FILE__))
        end)

        return new(dataset_root, batch_size, data_loader, filenames, queue_pool)
    end
end

# Serialize the arguments needed to recreate this ImagenetDataset
function freeze_args(id::ImagenetDataset)
    return (id.dataset_root, length(id.queue_pool.workers), id.batch_size, id.data_loader)
end
Base.length(id::ImagenetDataset) = div(length(id.filenames),id.batch_size)

mutable struct ImagenetIteratorState
    batch_idx::Int
    job_offset::Int
    
    function ImagenetIteratorState(id::ImagenetDataset)
        @info("Creating IIS with $(length(id.filenames)) images")

        # Build permutation for this iteration
        permutation = shuffle(1:length(id.filenames))

        # Push first job, save value to get job_offset (we know that all jobs
        # within this iteration will be consequtive, so we only save the offset
        # of the first one, and can use that to determine the job ids of every
        # subsequent job:
        filename = joinpath(id.dataset_root, id.filenames[permutation[1]])
        job_offset = push_job!(id.queue_pool, filename)

        # Next, push every other job
        for pidx in permutation[2:end]
            filename = joinpath(id.dataset_root, id.filenames[pidx])
            push_job!(id.queue_pool, filename)
        end
        return new(
            0,
            job_offset,
        )
    end
end

function Base.iterate(id::ImagenetDataset, state=ImagenetIteratorState(id))
    # If we're at the end of this epoch, give up the ghost
    if state.batch_idx > length(id)
        return nothing
    end

    # Otherwise, wait for the next batch worth of jobs to finish on our queue pool
    next_batch_job_ids = state.job_offset .+ (0:(id.batch_size-1)) .+ id.batch_size*state.batch_idx
    # Next, wait for the currently-being-worked-on batch to be done.
    pairs = fetch_result.(Ref(id.queue_pool), next_batch_job_ids)
    state.batch_idx += 1

    # Collate X's and Y's into big tensors:
    X = cat((p[1] for p in pairs)...; dims=ndims(pairs[1][1]))
    Y = cat((p[2] for p in pairs)...; dims=ndims(pairs[1][2]))

    # Return the fruit of our labor
    return (X, Y), state
end

Julia 使用 BSON 实现模型的持久化和读取,速度令人满意。对模型保存和读取进行封装的相关实现如下:

using BSON
using Tracker
using Statistics, Printf
using Flux.Optimise

function save_model(model, filename)
    model_state = Dict(
        :weights => Tracker.data.(params(model))
    )
    open(filename, "w") do io
        BSON.bson(io, model_state)
    end
end

function load_model!(model, filename)
    weights = BSON.load(filename)[:weights]
    Flux.loadparams!(model, weights)
    return model
end

4. DCGAN+Fashion/GCN+Cora 其他网络结构与数据集

近年来 GAN 和 GCN 方兴未艾,只实用 Julia 完成图像分类任务还远远不够。因此笔者正尽可能复现多种类的网络结构和任务。以 GAN 和 GCN 为例,Julia 已经能很好地完成试验目标了。由于篇幅限制,这里只说明重要部分,完整代码请参考并运行 DCGAN+FashionGCN+Cora

与 CNN 相同,使用 Flux 可以轻松实现对 DCGAN 的定义。

function Discriminator()
    return Chain(
            Conv((4, 4), 1 => 64; stride = 2, pad = 1),
            x->leakyrelu.(x, 0.2f0),
            Dropout(0.25),
            Conv((4, 4), 64 => 128; stride = 2, pad = 1),
            x->leakyrelu.(x, 0.2f0),
            Dropout(0.25), 
            x->reshape(x, 7 * 7 * 128, :),
            Dense(7 * 7 * 128, 1))
end

function Generator(latent_dim)
    return Chain(
            Dense(latent_dim, 7 * 7 * 256),
            BatchNorm(7 * 7 * 256, relu),
            x->reshape(x, 7, 7, 256, :),
            ConvTranspose((5, 5), 256 => 128; stride = 1, pad = 2),
            BatchNorm(128, relu),
            ConvTranspose((4, 4), 128 => 64; stride = 2, pad = 1),
            BatchNorm(64, relu),
            ConvTranspose((4, 4), 64 => 1, tanh; stride = 2, pad = 1),
            )
end

遵循动态图的反向更新策略,我们只需要像 PyTorch 一样定义对抗损失和对抗训练过程,也较为简单。

function discriminator_loss(real_output, fake_output)
    real_loss = mean(logitbinarycrossentropy.(real_output, 1f0))
    fake_loss = mean(logitbinarycrossentropy.(fake_output, 0f0))
    return real_loss + fake_loss
end

generator_loss(fake_output) = mean(logitbinarycrossentropy.(fake_output, 1f0))

function train_discriminator!(gen, dscr, x, opt_dscr, args)
    noise = randn!(similar(x, (args.latent_dim, args.batch_size))) 
    fake_input = gen(noise)
    ps = Flux.params(dscr)
    # Taking gradient
    loss, back = Flux.pullback(ps) do
        discriminator_loss(dscr(x), dscr(fake_input))
    end
    grad = back(1f0)
    update!(opt_dscr, ps, grad)
    return loss
end

function train_generator!(gen, dscr, x, opt_gen, args)
    noise = randn!(similar(x, (args.latent_dim, args.batch_size))) 
    ps = Flux.params(gen)
    # Taking gradient
    loss, back = Flux.pullback(ps) do
        generator_loss(dscr(gen(noise)))
    end
    grad = back(1f0)
    update!(opt_gen, ps, grad)
    return loss
end

for ep in 1:args.epochs
    @info "Epoch $ep"
    for x in data
        loss_dscr = train_discriminator!(g_model, d_model, x, opt_dscr, args)
        loss_gen = train_generator!(g_model, d_model, x, opt_gen, args)
    end
    train_steps += 1
end

对于其他较为复杂的 CNN 模型,例如 UNet,用户也可以自定义模块的调用过程(类似于 PyTorch 中的 forward ):

function UNet()
    conv_block = (block1(1, 32), block2(32, 32*2), block2(32*2, 32*4), block2(32*4, 32*8))
    conv_block2 = (block1(32*16, 32*8), block1(32*8, 32*4), block1(32*4, 32*2), block1(32*2, 32))
    bottle = block2(32*8, 32*16)
    upconv_block = (upconv(32*16, 32*8), upconv(32*8, 32*4), upconv(32*4, 32*2), upconv(32*2, 32))
    conv_ = conv(32, 1)
    UNet(conv_block, conv_block2, bottle, upconv_block, conv_)
end

function (u::UNet)(x)
    enc1 = u.conv_block[1](x)
    enc2 = u.conv_block[2](enc1)
    enc3 = u.conv_block[3](enc2)
    enc4 = u.conv_block[4](enc3)
    
    bn = u.bottle(enc4)
    
    dec4 = u.upconv_block[1](bn)
    dec4 = cat(dims=3, dec4, enc4)
    dec4 = u.conv_block2[1](dec4)
    dec3 = u.upconv_block[2](dec4)
    dec3 = cat(dims=3, dec3, enc3)
    dec3 = u.conv_block2[2](dec3)
    dec2 = u.upconv_block[3](dec3)
    dec2 = cat(dims=3, dec2, enc2)
    dec2 = u.conv_block2[3](dec2)
    dec1 = u.upconv_block[4](dec2)
    dec1 = cat(dims=3, dec1, enc1)
    dec1 = u.conv_block2[4](dec1)
    dec1 = u.conv_(dec1)
end

model = UNet()

在 GNN 模型方面,目前较为流行的 GNN 库是 GeometricFlux,但是由于刚刚开源不久,数据读取方面的支持有限。实现应当是参考了 DGL,较为优雅且易于扩展。笔者目前也正在试图基于 LightGraphs 开发一个 GNN 库,主要着力于图的构建和分布式训练部分。

using GeometricFlux

model = Chain(GCNConv(adj_mat, num_features=>hidden, relu),
              Dropout(0.5),
              GCNConv(adj_mat, hidden=>target_catg),
              softmax) |> gpu

三、后记

上述示例代码和讲解均来源于笔者的开源项目 Julia-Deeplearning,目前已有的最佳实践包括:

由于笔者近期试验较多,因此只能在试验之余偶尔更新。如果同学们有相关工作欢迎 PR 和提 Issue,衷心希望能够抛砖引玉对大家有所帮助~

2957 次点击
所在节点    机器学习
1 条回复
formaxin
2020-06-03 23:03:58 +08:00
从 1 开始有点小难受

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/674695

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX