iT邦幫忙

2023 iThome 鐵人賽

DAY 26
0
AI & Data

Rust 加 MLOps,你說有沒有搞頭?系列 第 26

[Day 26] - 模型開發 🧠 (下) | Rust x PyTorch 模型訓練與輸出 🦀

  • 分享至 

  • xImage
  •  

今日份 Ferris

昨天以 ML 系統設計來看模型開發的各個面向,今天我們用 MNIST 來示範 Rust 怎麼訓練與輸出模型。
所以今天的擬人化 Ferris 也來學學怎麼辨識手寫數字囉!
https://ithelp.ithome.com.tw/upload/images/20231011/20141304uxOXLOYhE8.jpg

Rust x PyTorch = 🔦

深度學習是機器學習領域最令人興奮的發展之一,而更讓人興奮的是 Rust 也可以使用 PyTorch 與 Hugging Face!

事實上,如果想要一個高性能的深度學習研究環境,那麼 Rust 或許最好的工具之ㄧ,因為它提供了比 Python 更好的可移植性、性能以及更加方便的 Packaging 能力 (例如可以利用 Cargo 將預訓練模型打包並提供給他人)。

在這次系列文的專案 [Day 10] - 鋼鐵草泥馬 🦙 LLM chatbot 🤖 (1/10)|專案簡介 中,我們已經示範過如何使用 Hugging Face 了。
今天就來示範如何在 Rust 中使用 PyTorch,直接升級我們的 ML 文明,從石器時代的火把 (Torch) 進化到現代的手電筒 (Electric Torch) 吧!

安裝 Libtorch 與環境設定

我們今天將使用 Laurent Mazare 所開發的 tch-rs crate,它將 Rust 與 PyTorch 進行綁定,可以視為 Rust 版的 PyTorch。

其實 tch-rs 是對 PyTorch 的 C++ API (又稱為 libtorch) 的封裝,因此使用上會與 PyTorch 有些許不同,但基本上大同小異,有需要時可以參考 官方文件

根據 tch-rs 的說明,我們首先要在系統上安裝 Libtorch (也支援使用 Python PyTorch),這裡使用 unbuntu 作為示範,若想在 Mac 上使用請參考這個 issue,而 Windows 則可以參考 這裡兒

而安裝主要有兩個步驟:

  1. 要安裝 Libtorch 的第一步就是到 官網下載,這裡為了說明方便,使用 CPU 的版本。
    https://ithelp.ithome.com.tw/upload/images/20231011/201413049hlTdT70WX.png

    可以看到官網上有兩個連結,兩者其實差別不大 (先爆雷一下,我們要選擇 cxx11 ABI),但如果環境中有 PyTorch 就得注意一下:

    • pre-cxx11 ABI:官方的 Python PyTorch 模組是連接到較舊的 pre-cxx11 ABI,如果想要與 Python PyTorch 程式碼互動,就得選這個。
    • cxx11 ABI:預設上 C++ 函式庫連接到這個,也與 C++ 更加匹配,而考慮到這個版本效率也較好 (加上我的 ubuntu gcc 版本為 11.4.0,上面那個也編譯不了哈哈哈,詳細可以參考這個 issue),所以使用選擇下載這個。

    知道要下載哪個之後,下載的部份就簡單了,用 wget 再解壓縮即可:

    >>> wget https://download.pytorch.org/libtorch/cpu/libtorch-cxx11-abi-shared-with-deps-2.1.0%2Bcpu.zip
    >>> unzip libtorch-shared-with-deps-2.1.0+cpu.zip
    

    若不想安裝在本機,可以參考 libtorch location

  2. 下載並解壓縮之後,得設定相關的環境變數 (這部分參考 Libtorch Manual InstallError loading shared libraries)。
    請把下面的程式碼加到 .bashrc.zshrc 中:

    export LIBTORCH=/home/ubuntu/libtorch
    export LIBTORCH_INCLUDE=/home/ubuntu/libtorch
    export LIBTORCH_LIB=/home/ubuntu/libtorch
    export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:"/home/ubuntu/libtorch/lib:$LD_LIBRARY_PATH"
    export PATH="/home/ubuntu/libtorch:$PATH"
    

Hello World!

安裝與設定好環境變數後,就可以來快速地測試看看了:

  1. cargo new 一個新的專案。
  2. 在 Cargo.toml 的 [dependencies] 區塊加上 tch="0.14.0"
  3. 在 src/main.rs 貼上以下把張量元素乘以 2 的簡單程式碼:
    use tch::Tensor;
    
    fn main() {
        let t = Tensor::from_slice(&[3, 1, 4, 1, 5]);
        let t = t * 2;
        t.print();
    }
    
  4. cargo run! 如果沒有問題應該可以看到終端機印出以下訊息:
    https://ithelp.ithome.com.tw/upload/images/20231011/20141304klclNdRYJk.png

Rust 訓練與輸出模型

🏮 今天完整的程式碼可以拉到底下 的 Step7: Put it together 區塊與最後的 Step 8:測試訓練好的模型 找到!

Rust 是一種效能極高的系統程式語言,非常適合用來訓練深度學習模型。
使用 PyTorch Rust 綁定最大的優點在於,它可以讓我們將 PyTorch 模型打包成可重現的模型。
因此我們可以將你的模型分發給其他人,而他們可以使用相同的工具來訓練和部署模型。

這裡參考官方的 MNIST 範例進行 CNN 模型訓練與將模型存為 TorchScript 格式。

使用 tch-rs 訓練模型跟使用 PyTorch 的概念是相同的,一樣要定義模型架構、優化器,以及實作前向傳播。

Step 1:下載資料

要訓練模型首先就是要下載資料,請到 Yann LeCun's MNIST 頁面下載以下四個檔案 (它會叫你登入,所以請直接用下面的超連結下載):

Step 2:載入資料

有了資料後,我們首先載入 MNIST 資料:

let m = vision::mnist::load_dir("data").unwrap();

這裡利用了 MNIST 的輔助模組,會自動處理上面下載到 data 資料夾的資料。
其中訓練圖片與標籤會在訓練模型時使用,而測試圖片和標籤則會用於估計驗證誤差。

Step 3:建立模型 (對應 class Model(nn.Module))

在 tch-rs 中定義模型的方式也和 PyTorch 一樣有兩種:

  • class Model(nn.Module) Rust 版: 先自行撰寫結構體 (struct) 定義模型的參數,接著實作其 new 建構子以初始化參數,最後為此結構體實作 nn::ModuleT trait 的 forward_t() 方法,這裡會使用此方法,請看下方範例。
  • nn.Sequential Rust 版: 使用 tch::nn::SequentialT 建立可訓練的序列式模型。

根據上述的流程,我們可以如下建立簡單的 CNN:

#[derive(Debug)]
struct Net {
    conv1: nn::Conv2D,
    conv2: nn::Conv2D,
    fc1: nn::Linear,
    fc2: nn::Linear,
}

impl Net {
    fn new(vs: &nn::Path) -> Net {
        let conv1 = nn::conv2d(vs, 1, 32, 5, Default::default());
        let conv2 = nn::conv2d(vs, 32, 64, 5, Default::default());
        let fc1 = nn::linear(vs, 1024, 1024, Default::default());
        let fc2 = nn::linear(vs, 1024, 10, Default::default());
        Net { conv1, conv2, fc1, fc2 }
    }
}

impl nn::ModuleT for Net {
    fn forward_t(&self, xs: &Tensor, train: bool) -> Tensor {
        xs.view([-1, 1, 28, 28])
            .apply(&self.conv1)
            .max_pool2d_default(2)
            .apply(&self.conv2)
            .max_pool2d_default(2)
            .view([-1, 1024])
            .apply(&self.fc1)
            .relu()
            .dropout(0.5, train)
            .apply(&self.fc2)
    }
}

Step 4:定義優化器

在建立擁有多個權重和 bias 參數的模型時,我們使用 tch::nn::VarStore 來追蹤這些變數並讓優化器知道它們的存在:

    let vs = nn::VarStore::new(Device::cuda_if_available());
    let net = Net::new(&vs.root());
    let opt = nn::Optimizer::adam(&vs, 1e-4, Default::default());

其中第一行建立了用以儲存模型結構與追蹤參數的 VarStore,而優化器則選用備受寵愛的 Adam

其中 Device::cuda_if_available(),跟使用 Python 時所遵循的最佳實踐 Device-agnostic code 是一樣的。

Step 5:訓練模型

訓練模型的流程就跟 PyTorch 一樣,請參考 The Unofficial PyTorch Optimization Loop Song
而這裡一樣使用小批次進行訓練,可以看到 Rust 的迭代器能很優雅地使用 Functional programming 的形式來走訪並洗牌訓練集:

    for epoch in 1..100 {
        for (bimages, blabels) in m.train_iter(256).shuffle().to_device(vs.device()) {
            let loss = net
                .forward_t(&bimages, true)
                .cross_entropy_for_logits(&blabels);
            opt.backward_step(&loss);
        }
        let test_accuracy =
            net.batch_accuracy_for_logits(&m.test_images, &m.test_labels, vs.device(), 1024);
        println!("epoch: {:4} test acc: {:5.2}%", epoch, 100. * test_accuracy,);

Step 6:儲存模型

以下程式碼可以將模型儲存成 Torchscript 的格式,注意我們使用 VarStore.freeze() 方法將參數凍結以輸出模型:

            vs.freeze();

            let mut closure = |input: &[Tensor]| vec![net.forward_t(&input[0], false)];
            let model = CModule::create_by_tracing(
                "MyModule",
                "forward",
                &[Tensor::zeros([784], FLOAT_CPU)],
                &mut closure,
            )?;
            model.save("model.pt")?;

其中要注意是 tch::jit::CModule::create_by_tracing 需要傳入模型的輸入尺寸,也就是 MNIST 的 784 維。

Step 7:Put it together

把上面所有的程式碼整理一下放進 src/main.rs 就可以組成一個簡單的訓練腳本,並且會將驗證準確率最高的權重輸出成 Torchscript 模型。
注意這裡因為只有使用 CPU 所以把輸出的數值型別寫死為 kind::FLOAT_CPU,若要使用 GPU 也可以透過額外撰寫邏輯判斷來給定型別即可:

use anyhow::Result;
use tch::{kind::FLOAT_CPU, nn, nn::ModuleT, nn::OptimizerConfig, CModule, Device, Tensor};

#[derive(Debug)]
struct Net {
    conv1: nn::Conv2D,
    conv2: nn::Conv2D,
    fc1: nn::Linear,
    fc2: nn::Linear,
}

impl Net {
    fn new(vs: &nn::Path) -> Net {
        let conv1 = nn::conv2d(vs, 1, 32, 5, Default::default());
        let conv2 = nn::conv2d(vs, 32, 64, 5, Default::default());
        let fc1 = nn::linear(vs, 1024, 1024, Default::default());
        let fc2 = nn::linear(vs, 1024, 10, Default::default());
        Net { conv1, conv2, fc1, fc2 }
    }
}

impl nn::ModuleT for Net {
    fn forward_t(&self, xs: &Tensor, train: bool) -> Tensor {
        xs.view([-1, 1, 28, 28])
            .apply(&self.conv1)
            .max_pool2d_default(2)
            .apply(&self.conv2)
            .max_pool2d_default(2)
            .view([-1, 1024])
            .apply(&self.fc1)
            .relu()
            .dropout(0.5, train)
            .apply(&self.fc2)
    }
}

fn main() -> Result<()> {
    let m = tch::vision::mnist::load_dir("data")?;
    let mut vs = nn::VarStore::new(Device::cuda_if_available());
    let net = Net::new(&vs.root());
    let mut opt = nn::Adam::default().build(&vs, 1e-4)?;

    let mut best_accuracy = 0.0;

    for epoch in 1..100 {
        vs.unfreeze();

        for (bimages, blabels) in m.train_iter(256).shuffle().to_device(vs.device()) {
            let loss = net.forward_t(&bimages, true).cross_entropy_for_logits(&blabels);
            opt.backward_step(&loss);
        }
        let test_accuracy =
            net.batch_accuracy_for_logits(&m.test_images, &m.test_labels, vs.device(), 1024);
        println!("epoch: {:4} test acc: {:5.2}%", epoch, 100. * test_accuracy,);

        if best_accuracy < test_accuracy {
            best_accuracy = test_accuracy;
            vs.freeze();

            let mut closure = |input: &[Tensor]| vec![net.forward_t(&input[0], false)];
            let model = CModule::create_by_tracing(
                "MyModule",
                "forward",
                &[Tensor::zeros([784], FLOAT_CPU)],
                &mut closure,
            )?;
            model.save("model.pt")?;
        }
    
    }
    Ok(())
}

執行 cargo run 之後就能看到模型開始訓練了:
Training
另外,這裡在每個 epoch 優化器更新權重之前使用了 .unfreeze() 將參數解凍,在更新權重之後再判斷其驗證準確率是否較之前還高,若準確率提高就把參數 .freeze() 凍結後輸出。

記得在 Cargo.toml 的 [dependencies] 區塊加上 anyhow = "1.0.75"

Step 8:測試訓練好的模型

為了展現這個範例的泛用性與可攜性,我們這裡使用 Python 來看看測試集的表現,以下是測試程式碼 (看到闊別已久的 Python 是不是腦袋都放鬆一點了哈哈哈):

# The following code is partially copied from the official example.
# https://github.com/pytorch/examples/blob/ca1bd9167f7216e087532160fc5b98643d53f87e/mnist/main.py

import torch
from torchvision import datasets, transforms
import torch.nn.functional as F


def test(model, device, test_loader):
    model.eval()
    test_loss = 0
    correct = 0
    with torch.no_grad():
        for data, target in test_loader:
            data, target = data.to(device), target.to(device)
            output = model(data)
            test_loss += F.nll_loss(
                output, target, reduction="sum"
            ).item()  # sum up batch loss
            pred = output.argmax(
                dim=1, keepdim=True
            )  # get the index of the max log-probability
            correct += pred.eq(target.view_as(pred)).sum().item()

    test_loss /= len(test_loader.dataset)

    print(
        "\nTest set: Average loss: {:.4f}, Accuracy: {}/{} ({:.0f}%)\n".format(
            test_loss,
            correct,
            len(test_loader.dataset),
            100.0 * correct / len(test_loader.dataset),
        )
    )


model = torch.jit.load("model.pt")
device = torch.device("cuda")
test_kwargs = {"batch_size": 100}
transform = transforms.Compose(
    [transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,))]
)
dataset = datasets.MNIST("./data", train=False, download=True, transform=transform)
test_loader = torch.utils.data.DataLoader(dataset, **test_kwargs)
test(model, device, test_loader)

下圖為在 Colab 中運行的結果 (記得要把模型與測試集傳上去):
https://ithelp.ithome.com.tw/upload/images/20231011/2014130467HiNoQ0Ji.png
可以看到使用 Rust 訓練的模型確實可以使用另一個平台來運行,展現出了可攜性!

當然也可以在 Rust 中使用其它人訓練好的模型,詳細請參考 Loading and Running a PyTorch Model in Rust

Rust 模型在 MLOps 的未來

通常在 ML 領域提到 Rust 都會有些許疑慮,舉例來說,有些人可能會認為它是一種較新的語言,所以讓人擔心它是否能用於實際生產環境中。

但經過今天的範例,我們知道可以輕鬆的用 Rust 版的 PyTorch 訓練模型,並且在硬體允許時也能毫不費力的使用 GPU 進行推論。
在 [官方範例] 中還有 GAN, min-gpt, stable diffusion, 強化學習等案例可以參考,應用的層面非常廣泛。

而最後在這裡我還想提一下 sonos/tract 這個專案,它讓我們可以用 ONNX 等可攜式格式來打包模型,並將其分發給其他人。
https://ithelp.ithome.com.tw/upload/images/20231011/20141304bsYtzY85DH.png
像是專案 AndreyGermanov/yolov8_onnx_rust 便是使用 ONNX 來將 YOLOv8 以 Rust 部署。

YOLOv8 也可以直接輸出預訓練的 Torchscript 格式:
https://ithelp.ithome.com.tw/upload/images/20231011/20141304wbH19PwtFh.png
因此也能用今天介紹的 tch-rs 來實作物件辨識應用。

我認為這是 MLOps 的未來之一,而我們也可以使用 Rust 等系統程式設計語言來開發 MLOps 工具。
隨著越來越多人的投入與討論,也能使更多人得力於 Rust 開發的 MLOps 工具。

好啦,今天就這樣囉,明天見!
/images/emoticon/emoticon49.gif

Bonus - diffusers-rs

如果對畫圖有興趣的孩子也可以參考另一個專案 diffusers-rs,它支援 Stable Diffusion v1.5 與 v2.1,很帶勁的:
https://ithelp.ithome.com.tw/upload/images/20231014/20141304WM8jHr6jZK.png


上一篇
[Day 25] - 模型開發 🧠 (上) | ML 系統設計 🏭
下一篇
[Day 27] - 預測服務 🚀 (上) | ML 系統設計 🏭
系列文
Rust 加 MLOps,你說有沒有搞頭?30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言