iT邦幫忙

2023 iThome 鐵人賽

DAY 27
0
AI & Data

AI白話文運動系列之「A!給我那張Image!」系列 第 27

AI研究趨勢討論(一)--模型壓縮與加速(Model Compression and Acceleration)後篇

  • 分享至 

  • xImage
  •  

前言

  • 昨天我們稍微介紹了一下模型壓縮與加速這個研究領域,並討論了兩種常見的方法,今天會繼續介紹另外兩種方法,最後再利用這四種方法的實戰範例,讓大家親自嘗試一下模型壓縮與加速的感覺。

先備知識

  1. 矩陣在AI中的應用(https://ithelp.ithome.com.tw/articles/10322151)
  2. Python(至少對Python語法不陌生)
  3. Pytorch如何載入預訓練模型與資料集(https://ithelp.ithome.com.tw/articles/10323073https://ithelp.ithome.com.tw/articles/10322732)
  4. 捲積運算(可以回顧:https://ithelp.ithome.com.tw/articles/10323076 )
  5. 捲積神經網路(可以回顧:https://ithelp.ithome.com.tw/articles/10323077 )

看完今天的內容你可能會知道......

  • 甚麼是低秩分解(Low-rank Decomposition)
  • 甚麼是剪枝(Pruning)
  • 如何在Pytorch中利用量化、知識蒸餾、低秩分解與剪枝壓縮與加速指定模型

一、低秩分解(Low-rank Decomposition)

  • 現實中很多東西都有「主成分」的存在,我們可以「通過這些主成分得知全體大部分的資訊」,這麼說可能有點難懂,讓我們來看些例子:如果要你用最簡單的方式描述蛋炒飯的話,應該會說是「蛋+ 飯」,而不會說是「蛋+醬油+鹽+蔥花+飯」吧!為甚麼呢?因為對於蛋炒飯來說,但跟飯就是它最主要的兩種成分,其餘的是用來輔助的。同樣的道理,在我們準備考試的時候,一定有些觀念是不斷出現的,幾乎大部分的題目都需要使用到這些觀念,當然也有很多其他的觀念需要理解,可是這些觀念只會出現在少數題目中。再或者,我們都知道海水其實就是鹽水,所以你在跟別人閒聊的時候,不會把大海形容成是「水+鹽+鈣+鎂+硫+鉀+氯化鎂+硫酸鎂」吧!
  • 秩(Rank)就是數學中描述「主成分」概念的量,越高的秩數表示有越多獨立的「主成分」,相反的越低的秩數則表示獨立的「主成分越少」。如果對大學的線性代數有一些基礎,想回顧秩是甚麼的話可以參考:https://zh.wikipedia.org/zh-tw/%E7%A7%A9_(%E7%BA%BF%E6%80%A7%E4%BB%A3%E6%95%B0)
  • 我們之前有介紹過甚麼是矩陣,也聊過AI世界中矩陣出現在哪些地方(https://ithelp.ithome.com.tw/articles/10322151),同時我們也有介紹過甚麼是張量(https://ithelp.ithome.com.tw/articles/10319307),這個在Pytorch可以說是基礎單位的概念,這兩項東西,都可以透過數學的方式將其分解,找出對應的「主成分」,最後只需要使用這些主成分重新還原出一些矩陣/張量,就可以取代原先大而複雜的矩陣/張量。換句話說就是,「我們把一個複雜的運算,通過一系列簡單好處理的運算取代,以達到壓縮與加速的目的」。
  • 接下來要談的東西需要一些大學線性代數的基礎,如果不瞭解的人只需要看到上面,等有需要的時候再來回看即可。線性代數中,最經典分解方式莫過於SVD奇異值分解(https://zh.wikipedia.org/zh-tw/%E5%A5%87%E5%BC%82%E5%80%BC%E5%88%86%E8%A7%A3) ,我們通過找出奇異值與特徵向量,就可以把一個矩陣拆成三個矩陣,關鍵來了,這些矩陣有多大是取決於我們選定多少奇異值,因為我們都知道奇異值有大有小,在計算SVD分解的過程中,我們會將這些奇異值按照大小排列在對角線,形成其中一個矩陣,如果,我們只保留那些特別大的奇異值呢?這時候就是截斷式的SVD(可以參考:https://langvillea.people.cofc.edu/DISSECTION-LAB/Emmie'sLSI-SVDModule/p5module.html) 。總之,通過截斷式SVD的作法,我們就可以將原始矩陣分解為三個維度較小的矩陣,通過設計,可以讓這些小矩陣裡面的元素總素小於原始矩陣,且在計算上也因為維度關係,所以會比較快,這就是低秩分解可以做到壓縮與加速的原因。
  • 下面是使用SVD分解來處理捲積層的示意圖,雖然說到矩陣我們通常都會聯想到線性層,但是其實捲積層也可以利用這種方式來處理,這是因為捲積層的硬體實現方式是把捲機運算轉換成矩陣運算來做。
    https://ithelp.ithome.com.tw/upload/images/20231012/2016329931S6mAxU2v.png
  • 上面的說明只是以矩陣與SVD為例,當然也有其他的分解方式,或是也有針對張量的處理方式,這些如果真的對這個領域感興趣再深入探討。可以先嘗試一下這篇延伸閱讀的論文:https://arxiv.org/pdf/1710.09282.pdf ,內容不算難,很適合模型壓縮與加速的初學者

二、剪枝(Pruning)

  • 如果要對某樣東西進行壓縮與加速的話,有一個很直覺的想法就是「把不重要的東西丟掉」,如果有些東西丟掉了,造成的影響在我們可以接受的範圍,可卻又能讓模型變得又小又快,那我們當然可以接受這樣的方法。這就是我們要介紹的最後一種模型壓縮與加速的概念:剪枝(Pruning)。
  • 喜歡做園藝的人應該會知道,在植物的生長過程中,通過會長高、長大或開花結果,但是一棵植物的能量有限,沒辦法所有事情都做到,所以我們需要適時的引導,讓植物變成我們想要的樣子,如果想要植物往上長的話,就需要剪掉周圍的枝枒,如果需要往兩邊長的話,就需要剪掉上面的枝枒,這個動作就是「剪枝」。通過剪去我們認為不重要的部分,讓整體可以朝我們希望的樣子邁進。
  • 既然已經知道甚麼是剪枝了,要怎麼用在AI模型中呢?要剪掉的東西是甚麼?又要怎麼衡量誰重要誰不重要呢?我要怎麼知道經過修剪之後,模型可以變得又小又快,又不會影響太多準確度呢?
  • 在神經網路中,有許多單位,例如:神經元、層、捲積核......等等的,這些所有單位都可以是剪枝的「標的」,也就是可以被我們拿來衡量重要性再決定要不要捨棄的東西。剪枝的整體部分如下圖所示,在確定目標之後,我們需要思考什麼樣的評估方式可以正確的衡量「重要性」,最後再根據重要性決定要捨棄多少比例的單位。
    https://ithelp.ithome.com.tw/upload/images/20231012/20163299bnzudFVnpH.png
  • 以比較早期的例子來說,那時候的重要性判斷比較直覺,是看數字絕對值的大小,越大表示越重要,越小表示越不重要,很明顯這樣的判斷依據似乎有些攏統,因此後續的研究都著重在上圖的第二項與第三項,思考怎麼判斷重要性是比較合理、合乎需求的,以及要捨棄多少比例,造成的影響才是我們可以接受的。

三、模型壓縮與加速實戰

  • 到目前為止,我們基本上談完了四種經典哦模型壓縮與加速方法。在這個章節中,我們將會用簡單的方式帶大家嘗試一下這四種不同的模型壓縮與加速方法,並且延伸討論每個方法在研究上的方向或難處。

    1. 量化(Quantization)

    import torchvision
    import os
    import torch
    
    model = torchvision.models.vgg16(pretrained=True)
    
    # 先將模型保存起來,接著讀取檔案大小來判斷模型大小
    def print_model_size(mdl):
      torch.save(mdl.state_dict(), "tmp.pt")
      print("%.2f MB" %(os.path.getsize("tmp.pt")/1e6))
      os.remove('tmp.pt')
    
    # 設定量化參數
    backend = "qnnpack"
    model.qconfig = torch.quantization.get_default_qconfig(backend)
    torch.backends.quantized.engine = backend
    model_static_quantized = torch.quantization.prepare(model, inplace=False)
    model_static_quantized = torch.quantization.convert(model_static_quantized, inplace=False)
    
    # 比較量化前後的模型大小
    print_model_size(model)
    print_model_size(model_static_quantized)
    
    • 這是最簡單的量化方式,可以直接量化一個預訓練模型,因為Pytorch本身有支援這項功能的關係,所以使用起來較為便捷,其中backend = "qnnpack"是表示我們的模型需要應用在哪種裝置上,如果是x86架構上的話就是fbgemm,如果是ARM架構的話就是qnnpack。通過上面這樣的方式,可以將預訓練模型從需要32個單位儲存的Float32型態換成只需要8個單位儲存的int8型態,模型大小也會大幅縮減:
    553.44 MB # 量化前
    138.42 MB # 量化後
    
    • 這種在訓練好模型之後才進行量化的方式是「Post Training Quantization」,與之相對的是「Quantization Aware Training」,因為量化需要將參數從大範圍對應到小範圍,所以要如何對應就是非常關鍵的步驟,如果是訓練後才進行量化的話,雖然比較方便,但是也容易導致較大的模型表現下降,而在訓練中促進量化的話則是通過特殊設計的損失函數或是正則化器,讓模型自己找到適合的對應關係。

    2. 知識蒸餾(Knowledge Distillation)

    import torch
    import torch.nn as nn
    import torch.optim as optim
    import torchvision.transforms as transforms
    import torchvision.datasets as datasets
    import torchvision
    
    # Check if GPU is available, and if not, use the CPU
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    
    # Below we are preprocessing data for CIFAR-10. We use an arbitrary batch size of 128.
    transforms_cifar = transforms.Compose([
        transforms.ToTensor(),
        transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
    ])
    
    # 載入與包裝資料集
    train_dataset = datasets.CIFAR10(root='./data', train=True, download=True, transform=transforms_cifar)
    test_dataset = datasets.CIFAR10(root='./data', train=False, download=True, transform=transforms_cifar)
    train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=128, shuffle=True, num_workers=2)
    test_loader = torch.utils.data.DataLoader(test_dataset, batch_size=128, shuffle=False, num_workers=2)
    
    # 定義學生模型
    class LightNN(nn.Module):
        def __init__(self, num_classes=10):
            super(LightNN, self).__init__()
            self.features = nn.Sequential(
                nn.Conv2d(3, 16, kernel_size=3, padding=1),
                nn.ReLU(),
                nn.MaxPool2d(kernel_size=2, stride=2),
                nn.Conv2d(16, 16, kernel_size=3, padding=1),
                nn.ReLU(),
                nn.MaxPool2d(kernel_size=2, stride=2),
            )
            self.classifier = nn.Sequential(
                nn.Linear(1024, 256),
                nn.ReLU(),
                nn.Dropout(0.1),
                nn.Linear(256, num_classes)
            )
    
        def forward(self, x):
            x = self.features(x)
            x = torch.flatten(x, 1)
            x = self.classifier(x)
            return x
    
    # 定義一般訓練用的函數
    def train(model, train_loader, epochs, learning_rate, device):
        criterion = nn.CrossEntropyLoss()
        optimizer = optim.Adam(model.parameters(), lr=learning_rate)
    
        model.train()
    
        for epoch in range(epochs):
            running_loss = 0.0
            for inputs, labels in train_loader:
                inputs, labels = inputs.to(device), labels.to(device)
    
                optimizer.zero_grad()
                outputs = model(inputs)
    
                loss = criterion(outputs, labels)
                loss.backward()
                optimizer.step()
    
                running_loss += loss.item()
    
            print(f"Epoch {epoch+1}/{epochs}, Loss: {running_loss / len(train_loader)}")
    
    # 定義模型評估函數
    def test(model, test_loader, device):
        model.to(device)
        model.eval()
    
        correct = 0
        total = 0
    
        with torch.no_grad():
            for inputs, labels in test_loader:
                inputs, labels = inputs.to(device), labels.to(device)
    
                outputs = model(inputs)
                _, predicted = torch.max(outputs.data, 1)
    
                total += labels.size(0)
                correct += (predicted == labels).sum().item()
    
        accuracy = 100 * correct / total
        print(f"Test Accuracy: {accuracy:.2f}%")
        return accuracy
    
    # 微調教授模型
    vgg = torchvision.models.vgg16(pretrained=True)
    vgg.classifier[-1] = nn.Linear(4096, 10)
    train(vgg.to(device), train_loader, epochs=5, learning_rate=0.0001, device=device)
    test_accuracy_deep = test(vgg, test_loader, device)
    
    # 定義學生模型的訓練函數
    def train_knowledge_distillation(teacher, student, train_loader, epochs, learning_rate, T, soft_target_loss_weight, ce_loss_weight, device):
        ce_loss = nn.CrossEntropyLoss()
        optimizer = optim.Adam(student.parameters(), lr=learning_rate)
    
        teacher.eval()  # Teacher set to evaluation mode
        student.train() # Student to train mode
    
        for epoch in range(epochs):
            running_loss = 0.0
            for inputs, labels in train_loader:
                inputs, labels = inputs.to(device), labels.to(device)
    
                optimizer.zero_grad()
    
                with torch.no_grad():
                    teacher_logits = teacher(inputs)
    
                student_logits = student(inputs)
                soft_targets = nn.functional.softmax(teacher_logits / T, dim=-1)
                soft_prob = nn.functional.log_softmax(student_logits / T, dim=-1)
    
                soft_targets_loss = -torch.sum(soft_targets * soft_prob) / soft_prob.size()[0] * (T**2)
                label_loss = ce_loss(student_logits, labels)
                loss = soft_target_loss_weight * soft_targets_loss + ce_loss_weight * label_loss
    
                loss.backward()
                optimizer.step()
    
                running_loss += loss.item()
    
            print(f"Epoch {epoch+1}/{epochs}, Loss: {running_loss / len(train_loader)}")
    
    # 初始化學生模型後開始訓練
    nn_light = LightNN(num_classes=10).to(device)
    train_knowledge_distillation(teacher=vgg, student=nn_light, train_loader=train_loader, epochs=10, learning_rate=0.001, T=2, soft_target_loss_weight=0.25, ce_loss_weight=0.75, device=device)
    test_accuracy_light_ce_and_kd = test(nn_light, test_loader, device)
    
    print(f"Teacher accuracy: {test_accuracy_deep:.2f}%")
    print(f"Student accuracy with CE + KD: {test_accuracy_light_ce_and_kd:.2f}%")
    
    • 整個程式碼可以分成幾的部分:資料前處理(包含載入與包裝訓練資料)、教授模型微調(因為預訓練模型是訓練在1000個類別的資料集上,而我們現在要使用的CIFAR10只有10個類別),定義學生模型與訓練目標(train_knowledge_distillation),最後評估模型表現。
    • 從程式碼可以發現一件事,學生不是只有學習教授給他的知識,他自己也會根據訓練資料集學習,所以這樣學習的效果才會又快又好(相較於從頭開始訓練學生模型,可以比較快收斂),能夠在比較短的時間達到還不錯的效果。
    • 當然,除了我們昨天介紹的那三種方式以外,也有其他不同的知識蒸餾方式,這也是目前研究的主要方向。

    3. 低秩分解(Low-rank Decomposition)

    • 在一個AI模型中,最直接使用矩陣的地方就是線性層,所以我們接著就使用SVD分解對線性層進行壓縮與加速吧!
    • 不過,正式開始前,我們可以先觀察一下沒有經過處理前的狀態:
    from torchvision import models
    from torchsummary import summary
    
    summary(models.vgg16().to('cuda'), (3, 224, 224))
    
    • 接著我們使用SVD對VGG16中的三個線性層進行壓縮與加速:
    import torch
    import torch.nn as nn
    import torchvision.models as models
    
    # Load a pretrained VGG16 model
    vgg16 = models.vgg16(pretrained=True)
    # Define the compression ratio (e.g., retain the top 50% singular values)
    compression_ratio = 0.5
    
    new_linear_layer = []
    
    # Iterate through the layers of the model and apply SVD to weight matrices
    for layer in list(vgg16.classifier):
      if isinstance(layer, nn.Linear):
        # Retrieve the weight tensor
        weight_tensor = layer.weight.data
    
        # Perform SVD on the weight matrix
        U, S, V = torch.svd(weight_tensor, some=False)
        # Calculate the number of singular values to retain
        k = int(S.size(0) * compression_ratio)
        # Truncate U, S, and V matrices
        U = U[:, :k]
        S = S[:k]
        V = V.t()[:k, :]
        SV = torch.mm(torch.diag(S), V)
        new_linear_layer.append(nn.Sequential(
              nn.Linear(SV.shape[1], SV.shape[0]),
              nn.Linear(U.shape[1], U.shape[0]),
          ))
    
    # Create a new model with the compressed weights
    compressed_vgg16 = models.vgg16()
    flag = 0
    temp = 0
    for layer in list(vgg16.classifier):
      if isinstance(layer, nn.Linear):
        compressed_vgg16.classifier[temp] = new_linear_layer[flag]
        flag+=1
        temp +=3
    
    from torchsummary import summary
    print(compressed_vgg16)
    summary(compressed_vgg16.to('cuda'), (3, 224, 224))
    
    • 作法基本上跟我們上面敘述的相同,將一個大的線性層運算拆成兩個小的線性層運算,最後利用torchsummary這個工具幫我們統計模型的參數,藉此觀察壓縮前後的變化。
    • 值得注意的地方是,如果實際去跑過這段程式碼的話,會發現壓縮前後的模型大小似乎沒有改變太多?這是為甚麼?這是因為我們只壓縮與加速所有的線性層,可是線性層在整個模型中只有一小部分,正常情況下,多數參數量的來源是捲積層,而多數計算量的來源才是線性層。
    • 這邊有個小技巧可以協助估算k值要設多少,才可有壓縮與加速的效果,我們知道,如果一個A個輸入B個輸出的線性層的參數量是A*B,所以如果我們想要把這樣的步驟拆成兩個小的線性層的話,參數量就會是(A*k)+(k*B)=(A+B)*k,只需要稍微把數字帶入計算一下就可以知道k最多可以是多少了。
    • 不過這邊沒有討論到的一點是,我要怎麼衡量壓縮比例?也就是我要拿掉多少才讓模型表現的下降在我可以接受的範圍?這就是這個方向在研究的地方,當然也可以結合多種分解方法一起使用 ,有很多可以嘗試的方向。

    4. 剪枝(Pruning)

    • Pytorch本身也有提供剪枝的方法,不過,這邊我要介紹的是一個我認為比較方便入門的工具:https://github.com/VainF/Torch-Pruning
    • 基本上跟著裡面的說明走就可以嘗試壓縮與加速你的模型了,下面我們來試試看!
    import os
    import torch
    import torchvision
    import torch_pruning as tp
    
    def print_model_size(mdl):
      torch.save(mdl.state_dict(), "tmp.pt")
      print("%.2f MB" %(os.path.getsize("tmp.pt")/1e6))
      os.remove('tmp.pt')
    
    model = models.vgg16(pretrained=True)
    example_inputs = torch.randn(1, 3, 224, 224)
    print_model_size(model)
    
    # 1. Importance criterion
    imp = tp.importance.GroupTaylorImportance() # or GroupNormImportance(p=2), GroupHessianImportance(), etc.
    
    # 2. Initialize a pruner with the model and the importance criterion
    ignored_layers = []
    for m in model.modules():
        if isinstance(m, torch.nn.Linear) and m.out_features == 1000:
            ignored_layers.append(m) # DO NOT prune the final classifier!
    
    pruner = tp.pruner.MetaPruner( # We can always choose MetaPruner if sparse training is not required.
        model,
        example_inputs,
        importance=imp,
        ch_sparsity=0.5,
        ignored_layers=ignored_layers,
    )
    
    # 3. Prune & finetune the model
    base_macs, base_nparams = tp.utils.count_ops_and_params(model, example_inputs)
    if isinstance(imp, tp.importance.GroupTaylorImportance):
        loss = model(example_inputs).sum() 
        loss.backward() # before pruner.step()
    
    pruner.step()
    print_model_size(model)
    
    • 作者的範例是使用ResNet18,我們按照上面的慣例使用VGG16試試看,經過這樣的操作之後,可以得到如下的結果:
    553.44 MB # 剪枝前
    142.48 MB # 剪枝後
    
    • 正如我們上面有提到的,剪枝有三個主要步驟,分別是選定目標、決定重要性衡量指標,以及設定剪枝比例,這個方法的剪枝目標是捲積核中的特定通道,而他們所使用的重要性衡量指標在上面程式碼中的步驟1有列出(GroupTaylorImportance),在設定剪枝工具的步驟2則是可以看到我們設定的剪枝比例是0.5。
    • 這個工具是基於:https://openaccess.thecvf.com/content/CVPR2023/html/Fang_DepGraph_Towards_Any_Structural_Pruning_CVPR_2023_paper.html 這篇論文的,內容淺顯易懂且方法簡潔有力,感興趣的人可以嘗試閱讀一下。

四、總結

  • 今天我們介紹完了另外兩種模型壓縮與加速的方法,同時還利用Pytorch實戰帶大家實際嘗試如何壓縮與加速一個模型,並且在實戰討論中,我們也可以看到每個研究方向具體需要解決哪些問題,以及有哪些可以嘗試的方式。明天我們又會開始討論另一個新的研究領域了,還請各位坐穩,讓我們一起走完接下來的旅程!

上一篇
AI研究趨勢討論(一)--模型壓縮與加速(Model Compression and Acceleration)前篇
下一篇
AI研究趨勢討論(二)--遷移學習、領域自適應與領域泛化
系列文
AI白話文運動系列之「A!給我那張Image!」30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言