模型压缩

介绍模型量化、模型蒸馏与模型剪枝三种压缩方式的原理与代码实现。

介绍

目的:在对模型的效果影响不大的前提下,尽可能减小模型的占用空间,减少模型参数量,让模型变得简单,提升运行速度

分类:

  • 模型量化
  • 模型蒸馏
  • 模型剪枝
  • 低秩分解

四种方式简介

  • Pruning 剪枝:深度神经网络中许多参数在训练期间贡献不大、属于冗余参数,训练后可将这些参数从网络中删除,对准确性影响很小。
  • Quantization 量化:DNN 权重通常存储为 32 位浮点数(fp32),量化通过减少每个权重所需的位数(如 fp16、int8、int4)来压缩原始网络,可显著减小模型大小并加速推理。
  • Knowledge distillation 知识蒸馏:在大型数据集上训练好大型复杂模型(教师模型)使其具备泛化与推理能力后,将其知识转移到较小的网络(学生模型)。
  • Low-rank factorization 低秩因式分解:通过矩阵分解识别深度神经网络的冗余参数,将大型矩阵分解为较小的矩阵,从而减小模型大小。

方式对比

image-20260118165801714

模型量化

image-20260118171922411
QAT、DQ、PTQ它们三个都是量化的具体实现,也就是它们的核心目的一样:在对模型效果影响不大的情况下,通过降低参数的精度也就是数据类型,来达到减小模型体积和提高运行速度。

image-20260118173723012

量化代码实现

# 注意:DQ推荐在CPU环境下运行
from config import Config
from bert_model import BertClassifierModel
import torch
from data_preprocessing import build_dataloader
from bert_train_and_eval import eval_model

if __name__ == '__main__':
    # 1- 【了解】查看支持的量化引擎
    """
        量化引擎:
            none:没有硬件加速
            onednn:英特尔深度学习
            x86:x86架构的服务器进行优化
            fbgemm:FaceBook的量化计算引擎
    """
    print("支持的量化引擎:",torch.backends.quantized.supported_engines)

    # 2- 【了解】显示设置量化引擎:onednn
    # 上面两行代码了解的原因是:代码会自动根据你的环境自动的决定用什么量化引擎
    torch.backends.quantized.engine = "onednn"

    # 3- 基础配置
    config = Config()

    # 4- 量化前
    # 加载模型
    model = BertClassifierModel()
    # map_location:加载到CPU上
    model.load_state_dict(torch.load(config.save_model,map_location="cpu"))
    model.eval()
    f1score, accuracy, precision, recall = eval_model(model)
    print(f"量化前的指标{f1score}{accuracy}{precision}{recall}")

    # 5- 量化后
    # 【DQ量化核心代码】
    """
        参数解释:
            model:要量化的模型对象
            qconfig_spec:要对模型中的什么网络层进行量化
            dtype:将网络层中的参数缩小到什么数据类型
    """
    quantization_model = torch.quantization.quantize_dynamic(model,qconfig_spec={torch.nn.Linear},dtype=torch.qint8)
    quantization_model.eval()
    f1score2, accuracy2, precision2, recall2 = eval_model(quantization_model)
    print(f"量化后的指标{f1score2}{accuracy2}{precision2}{recall2}")

    # 保存量化后的模型
    torch.save(quantization_model,"save_model/quantization_model.pkl")

image-20260118181737574

image-20260118181806747

模型蒸馏

五个概念

教师模型:是一个大模型,参数量大,预测精确,但是运行速度慢,模型体积大
学生模型:是一个小模型,参数量少,预测相对精确,但是运行速度快,模型体积小

硬标签:模型预测结果的概率值,非0即1。学生模型只能学到有限的内容,只能学到一个最终的答案
软标签:模型预测结果的概率值,取值区间在[0,1]之间。学生模型能够学到预测结果的概率分布
中间层:模型的思考过程
image-20260120092056081

整体原理

image-20260120101012533

KL散度

KL散度介绍【面试问题】:
	1- 作用:衡量两个预测概率分布之间的差异性
	2- 规律:该值越小越好。最小为0,那么说明学生模型和教师模型的结果完全一样,蒸馏效果最好
	3- 扩展:硬标签中 KL散度值=交叉熵,因为信息熵带入预测分布概率进去H(P)结果是0
展开查看详细原理

一、KL 散度的定义

KL散度(Kullback–Leibler divergence)是一种用于衡量两个概率分布之间差异的非对称度量,在机器学习中常作为损失函数使用。

简单来说:KL 散度就是“我们用一个分布去模仿另一个分布,结果差多少”的衡量方式。

KL散度定义两个概率分布 $P(x)$ 和 $Q(x)$ 之间的“差距”为:

$$D_{K L}(P \| Q)=\sum_{x} P(x) log \frac{P(x)}{Q(x)}$$

KL 散度越小,说明 Q 越接近 $P(x)$;当且仅当 $P=Q$ 时,KL散度为0。

二、KL 散度的解释

$$D_{KL}(P \| Q)=H(P, Q)-H(P)$$
1、信息熵 H(P)

是从真实分布 $P(x)$ 的角度,衡量分布本身的“混乱程度”:

$$H(P)=-\sum_{x} P(x) log P(x)$$

它表示在 $P(x)$ 分布下,编码一个样本所需的最小信息量(期望比特数)。

2、交叉熵 $H(P, Q)$

是用分布 $Q(x)$ 来编码样本,但样本真实分布为 $P(x)$ 时,实际消耗的信息量:

$$H(P, Q)=-\sum_{x} P(x) log Q(x)$$
3、KL 散度的直观含义

用分布 Q 去模仿 P,额外付出的代价为:

$$\begin{aligned} D_{KL}(P \| Q) & =\sum_{x} P(x) log \frac{P(x)}{Q(x)} \\ & =\sum_{x} P(x)[log P(x)-log Q(x)] \\ & =-\sum_{x} P(x) log Q(x)+\sum_{x} P(x) log P(x) \\ & =H(P, Q)-H(P) \end{aligned}$$

KL 散度的本质是:使用非最优编码 Q 时,相比最优编码(信息熵 H(P))多浪费的信息量。

为什么分类任务中通常只计算交叉熵? 因为真实分布的信息熵 H(P) 是固定常数,优化交叉熵等价于优化KL散度。

三、举例理解:盲猜彩票数字(预测 vs 现实)

场景:

  • 彩票真实中奖概率:$P = [红\ 70\%,\ 蓝\ 30\%]$(现实分布)
  • 主观猜测概率:$Q = [红\ 50\%,\ 蓝\ 50\%]$(预测分布)
  • 长期按 Q 猜测,会因偏离真实分布而频繁猜错。
指标 对应含义 补充说明
信息熵 H(P) 最小需要的信息量 理论最优编码的开销
交叉熵 H(P,Q) 实际花掉的信息量 未知真相、用Q猜测时产生的信息浪费
KL散度 额外浪费的信息量 因不了解真实分布P而多付出的信息代价

四、KL散度损失计算举例

假设存在两个概率分布:

  • 真实分布(P):目标分布
    • $P=[0.7,\ 0.2,\ 0.1]$
  • 预测分布(Q):模型输出分布
    • $Q=[0.6,\ 0.3,\ 0.1]$

按公式计算KL散度:

$$D_{K L}(P \| Q)=\sum_{i} P(i) log \frac{P(i)}{Q(i)}$$

1、逐项计算 (1) 第一项

$$0.7 \cdot log \frac{0.7}{0.6}=0.7 \cdot log (1.1667) \approx 0.7 \cdot 0.154=0.1078$$

(2) 第二项

$$0.2 \cdot log \frac{0.2}{0.3}=0.2 \cdot log (0.6667) \approx 0.2 \cdot(-0.176)=-0.0352$$

(3) 第三项

$$0.1 \cdot log \frac{0.1}{0.1}=0.1 \cdot log (1)=0$$
2、逐项求和

将三项结果相加,最终得到模型预测分布 Q 与真实分布 P 之间的 KL 散度损失值。

软标签的两个超参数

T超参数:
	1- 如果 T = 1:就是普通 softmax;
	2- 如果 T 趋近于 0:softmax 趋近 one-hot,更像硬标签。
	3- 如果 T 趋近于⽆穷⼤:softmax 趋近于均匀的分布。
	4- ⼀般 T选2到5之间,太⾼或太低都可能让学⽣难以学习。设置到2


α超参数:
	1- α 趋近 1:更重视 teacher 的软标签(偏向模仿⽼师)
	2- α 趋近 0:更重视 ground truth 的硬标签(偏向传统训练)
	3- ⼀般α设置在0.5到0.9之间。0.7

代码开发

代码结构

image-20260121085715708

配置文件

import torch

class Config:
    def __init__(self):
        # 1- 设备
        self.device = ("cuda" if torch.cuda.is_available() else "cpu")

        # 2- 原始文件路径
        self.train_datapath = "data/train.txt"
        self.dev_datapath = "data/dev.txt"
        self.test_datapath = "data/test.txt"
        self.class_datapath = "data/class.txt"

        # 3- 数据加载器参数
        self.batch_size = 64    # 64条新闻
        self.max_length = 32    # 句子中词的个数最多是32个词

        # 4- Bert预训练模型的路径
        self.bert_path = r"D:\soft\PretrainedModel\bert-base-chinese"

        # 5- 目标值文件解析
        self.classname_list = [line.strip() for line in open(self.class_datapath,mode="r",encoding="UTF-8")]
        self.classname_len = len(self.classname_list)

        # 6- 训练好的【教师模型】的保存路径
        self.teacher_save_model = "save_model/teacher_bert.pkl"

        # 7- 训练好的【学生模型】的保存路径
        self.student_save_model = "save_model/student_bert.pkl"

if __name__ == '__main__':
    config = Config()
    print(config.classname_list)
    print(config.classname_len)

教师模型

image-20260120112025114
# 教师模型:Bert预训练模型
import torch
import torch.nn as nn
from config import Config
from transformers import BertModel
from transformers import BertConfig

config = Config()

class BertTeacherModel(nn.Module):
    def __init__(self):
        # 1- 初始化父类
        super().__init__()

        # 2- 搭建网络结构
        # 2.1- 先定义Bert模型
        self.bert_model = BertModel.from_pretrained(config.bert_path)

        # 2.2- 再定义我们自己的网络结构
        in_features = BertConfig.from_pretrained(config.bert_path).hidden_size  # 768
        self.linear = nn.Linear(in_features=in_features,out_features=config.classname_len)

    def forward(self,input_ids,attention_mask):
        # torch.no_grad()冻结bert的反向传播。如果放开,训练耗时大量增加
        # 1- 教师模型的:嵌入层(词嵌入层、片段编码、位置编码)、Encoder编码器
        with torch.no_grad():
            bert_output = self.bert_model(input_ids=input_ids,attention_mask=attention_mask)

        # 2- 教师模型的:池化层,实际就是nn.Linear+激活函数。不用额外定义
        """
            1- last_hidden_state[:,0]和pooler_output,实际是类似的东西,都表示[CLS]的隐藏状态。
            区别:需要对last_hidden_state[:,0]经过nn.Linear和激活函数处理后,才能得到pooler_output
            对应源代码位置:BertModel文件的697行
            
            2- 获得池化层后的结果有两种方式:
                2.1- 方式一:推荐。通过实例属性获得 bert_output.pooler_output
                2.2- 方式二:通过实例属性索引获得 bert_output[1]。1的原因是pooler_output是类中的第2个实例属性
                            对应源代码位置:BertModel文件的1017行
        """
        # 下面两个代码的作用完全相同
        pooler_output = bert_output.pooler_output
        # pooler_output = bert_output[1]

        # 3- 教师模型的:线性层
        return self.linear(pooler_output)

学生模型

import torch
from torch import Tensor

from config import Config
import torch.nn as nn
from transformers import BertConfig

config = Config()
bert_config = BertConfig.from_pretrained(config.bert_path)

class BiLSTMStudentModel(nn.Module):
    def __init__(self):
        # 1- 初始化父类
        super().__init__()

        # 2- 设置属性值
        self.vocab_size = bert_config.vocab_size
        self.embedding_dim = 128
        self.hidden_size = 256
        self.num_layers = 3

        # 3- 搭建网络结构
        # 3.1- 词嵌入层
        # embedding_dim:由我们自己设置,与教师模型没有任何关系
        self.embedding = nn.Embedding(num_embeddings=self.vocab_size,embedding_dim=self.embedding_dim)

        # 3.2- 循环神经网络层:BiLSTM
        """
            参数解释:
                input_size:输入的词向量维度。必须和上面的embedding_dim相同
                hidden_size:隐藏层向量维度。大小自定义
                num_layers:隐藏层的层数。大小自定义,与是否是双向无关
                batch_first:输入和输出的数据中,batch_size是否在张量的第一个位置上
                bidirectional:是否是双向LSTM
        """
        self.lstm = nn.LSTM(
            input_size=self.embedding_dim,
            hidden_size=self.hidden_size,
            num_layers=self.num_layers,
            batch_first=True,
            bidirectional=True
        )

        # 3.3- 随机失活层
        self.dropout = nn.Dropout(p=0.3)

        # 3.4- 线性层
        """
            为什么是self.hidden_size*2?
            答:因为是双向的LSTM。双向的LSTM执行完以后,会将两个方向的隐藏状态信息进行concat拼接,形状变成两倍了
        """
        self.linear = nn.Linear(in_features=self.hidden_size*2,out_features=config.classname_len)

    def forward(self,input_ids, attentition_mask):
        """
        :param input_ids: 句子中词索引
        :param attentition_mask: 输入句子的掩码
        :return:
        """

        # 1- 调用词嵌入层,得到词向量
        # 张量形状:[batch_size, seq_len, embedding_dim]
        ebd = self.embedding(input_ids)

        # 2- 对词向量进行掩码操作:最终目的是只对正常词进行处理,句子开头、句子结尾、padding填充的0这三者不关心
        cls_token_index = 101   # 句子开头
        sep_token_index = 102   # 句子结尾
        ebd_mask = (input_ids!=cls_token_index) & (input_ids!=sep_token_index)
        ebd_mask:Tensor = ebd_mask & attentition_mask

        # 对词向量进行掩码操作
        # [batch_size, seq_len] 升维至 [batch_size, seq_len, 1]
        ebd_mask = ebd_mask.unsqueeze(-1)
        ebd = ebd * ebd_mask

        # 3- 调用循环神经网络BiLSTM
        # 为什么调用lstm的时候,没有手动传递初始的细胞状态和隐藏状态:LSTM内部会自动的进行全0初始化。源代码在1056行
        output,(hidden,c) = self.lstm(ebd)

        # 4- 计算平均池化值
        """
            下面两个地方都是sum(dim=1)的原因:
                1- output的张量形状[batch_size,seq_len,hidden_size]
                2- 我们的业务需求是对句子进行分类,因此主体是句子
                3- sum(dim=1)得到的结果形状[batch_size,hidden_size]。形状的含义是把一条句子中所有词的词向量加起来,
                    得到句子级别的向量总和
                    
        """
        # 分子:所有有效的词的向量之和
        output_sum = output.sum(dim=1)
        # 分母:所有有效的词总数。1e-6为了防止分母为0
        token_count = ebd_mask.sum(dim=1) + 1e-6
        new_output = output_sum/token_count

        # 5- 调用线性层,得到预测结果,并返回
        return self.linear(self.dropout(new_output))

if __name__ == '__main__':
    # 编写测试数据
    input_ids = torch.LongTensor([[101, 2, 3, 4, 5], [4, 102, 6, 7, 8]])
    attention_mask = torch.LongTensor([[1, 1, 1, 1, 0], [1, 1, 0, 0, 0]])
    model = BiLSTMStudentModel()
    print(model(input_ids, attention_mask))
  • forward中掩码测试代码
import torch

if __name__ == '__main__':
    cls_token_index = 3  # 句子开头
    sep_token_index = 4  # 句子结尾

    input_ids = torch.randint(low=1,high=6,size=(2,5))
    attentition_mask = torch.randint(low=0,high=2,size=(2,5))

    print(f"输入的句子词索引input_ids:\n{input_ids}")
    print(f"输入的句子掩码attentition_mask:\n{attentition_mask}")

    ebd_mask = (input_ids != cls_token_index) & (input_ids != sep_token_index)
    print(f"输入的句子处理后的掩码ebd_mask_1:\n{ebd_mask}")

    ebd_mask = ebd_mask & attentition_mask
    print(f"输入的句子处理后的掩码ebd_mask_2:\n{ebd_mask}")

    print("-"*30)

    ebd_mask = ebd_mask.unsqueeze(-1)
    ebd = torch.randint(low=1,high=10,size=(2,5,4))
    print(f"词向量的掩码ebd_mask:\n{ebd_mask}")
    print(f"前_词向量ebd:\n{ebd}")

    ebd = ebd * ebd_mask
    print(f"后_词向量ebd:\n{ebd}")

    print("1   ", ebd.sum(dim=1))
    print("1   ", ebd.sum(dim=1).shape)
    print("-1  ", ebd.sum(dim=-1).shape)

模型蒸馏

from config import Config
from data_preprocessing import build_dataloader
from teacher_bert_model import BertTeacherModel # 教师模型
from student_bilstm_model import BiLSTMStudentModel # 学生模型
import torch
import torch.nn as nn
from tqdm import tqdm
from sklearn.metrics import accuracy_score,precision_score,recall_score,f1_score

config = Config()

def eval_model(model):
    # 1- 加载验证集数据
    dev_dataloader = build_dataloader(datapath=config.dev_datapath,shuffle=False)

    # 2- 模型评估
    # 2.1- 定义用来计算准确率的变量
    all_pred_result = []    # 预测结果列表
    all_true_result = []    # 真实结果列表

    model.eval()
    with torch.no_grad():
        for i,batch in enumerate(tqdm(dev_dataloader), start=1):
            # 2.2- 将数据发送到对应设备
            input_ids, attention_mask, labels = batch
            input_ids = input_ids.to(config.device)
            attention_mask = attention_mask.to(config.device)
            labels = labels.to(config.device)

            # 2.3- 前向传播:预测
            pred_output = model(input_ids,attention_mask)
            pred_index = torch.argmax(pred_output,dim=-1)
            # cpu():因为不涉及张量的计算,因此为了节约GPU资源,可以将数据转到CPU上再处理
            all_pred_result.extend(pred_index.cpu().tolist())
            all_true_result.extend(labels.cpu().tolist())

    # 3- 计算评估指标
    f1score = f1_score(all_true_result,all_pred_result,average="macro")
    # 准确率
    accuracy = accuracy_score(all_true_result,all_pred_result)
    precision = precision_score(all_true_result,all_pred_result,average="macro")
    recall = recall_score(all_true_result,all_pred_result,average="macro")

    return f1score, accuracy, precision, recall

def train_and_eval():
    # 1- 加载数据
    train_dataloader = build_dataloader(datapath=config.train_datapath,shuffle=True)

    # 2- 创建模型
    # 2.1- 加载训练好的教师模型
    teacher_model = BertTeacherModel().to(config.device)
    teacher_model.load_state_dict(torch.load(config.teacher_save_model))
    # 2.2- 新建学生模型
    student_model = BiLSTMStudentModel().to(config.device)

    # 3- 损失函数对象
    loss = nn.CrossEntropyLoss()

    # 4- 优化器对象
    optimizer = torch.optim.AdamW(params=student_model.parameters(),lr=5e-5)

    # 5- 其他变量
    epochs = 1
    best_f1score = 0.0  # f1历史最高分
    T = 2   # 超参数:温度
    alpha = 0.7 # 超参数:软标签权重

    # 6- 训练
    # 6.1- 模型模式设置
    teacher_model.eval()    # 注意:教师模型是已经训练好了,不允许更新参数
    student_model.train()   # 注意:学生模型还未训练

    for epoch in range(epochs):
        for i,batch in enumerate(tqdm(train_dataloader),start=1):
            # 6.2- 将训练数据发送到设备
            input_ids, attention_mask, labels = batch
            input_ids = input_ids.to(config.device)
            attention_mask = attention_mask.to(config.device)
            labels = labels.to(config.device)

            # 6.3- 教师模型_前向传播
            with torch.no_grad():
                teacher_pred = teacher_model(input_ids,attention_mask)
                teacher_pred_labels = torch.argmax(teacher_pred,dim=-1)

            # 6.4- 学生模型_前向传播
            student_pred = student_model(input_ids, attention_mask)
            student_pred_labels = torch.argmax(student_pred, dim=-1)

            # 6.5- 计算KL散度值
            p = torch.log_softmax(teacher_pred/T, dim=-1)
            q = torch.log_softmax(student_pred/T, dim=-1)
            # KL散度值,也就是软标签的值
            """
                注意:kl_div的包不要导错了!!!
                参数解释:
                    input:是【学生模型】输出的结果
                    target:预测结果参考值。也就是【教师模型】输出的结果
                    reduction:上面两个值的计算方式。
                    log_target:是否对计算结果求log对数
            """
            kl_value = torch.nn.functional.kl_div(
                input=q,
                target=p,
                reduction="batchmean",
                log_target=True
            )

            # 6.6- 硬标签损失值
            # 注意:是学生模型的预测概率,与样本的目标值算损失
            hard_label_loss = loss(student_pred,labels)

            # 6.7- 蒸馏的总损失值
            # l = (1-α) * 硬标签损失值 + α * T² * KL散度值
            distll_loss = (1 - alpha) * hard_label_loss + alpha * (T**2) * kl_value

            # 6.8- 固定代码
            optimizer.zero_grad()
            distll_loss.sum().backward()
            optimizer.step()

            # 6.9- 每隔100个批次或最后一个批次,对学生模型进行验证
            if i%100==0 or i==len(train_dataloader):
                # 6.9.1- 调用评估函数
                f1score, accuracy, precision, recall = eval_model(student_model)
                print(f"第{i}批次,f1score={f1score},accuracy={accuracy},precision={precision},recall={recall}")

                # 6.9.2- 如果验证后发现模型效果有提升(也就是f1score比上次的要大),那就保存模型
                if f1score>best_f1score:
                    torch.save(student_model.state_dict(),config.student_save_model)
                    best_f1score = f1score  # 更新历史最高分

                # 6.9.3- 将模型的模式切回为训练模式
                student_model.train()

if __name__ == '__main__':
    train_and_eval()

蒸馏结果:

image-20260120172524276

image-20260120172621302

模型预测

from config import Config
from student_bilstm_model import BiLSTMStudentModel
from transformers import BertTokenizer
import torch

# 1- 加载训练好的模型
config = Config()

model = BiLSTMStudentModel().to(config.device)
# 注意:不要用变量接受返回结果
model.load_state_dict(torch.load(config.student_save_model))
tokenizer = BertTokenizer.from_pretrained(config.bert_path)

model.eval()

# 2- 预测函数
def predict(news_data):
    # 数据预处理
    title = news_data["title"]
    # 注意:这里用的是batch_encode_plus,而title只有1条新闻,因此下面的参数需要使用中括号包起来
    data_tensor = tokenizer.batch_encode_plus(
        [title],
        padding="max_length",
        truncation=True,
        max_length=config.max_length
    )

    input_ids = torch.tensor(data_tensor.input_ids).to(config.device)
    attention_mask = torch.tensor(data_tensor.attention_mask).to(config.device)

    # 模型预测
    with torch.no_grad():
        # 前向传播:预测
        pred_output = model(input_ids,attention_mask)
        # 获得概率最高的分类索引
        pred_index = torch.argmax(pred_output,dim=-1).item()
        # 索引转成类别名称
        pred_class_name = config.classname_list[pred_index]

    # 返回结果
    news_data["pred_class"] = pred_class_name

    return news_data

if __name__ == '__main__':
    print(predict({"title": "体验2D巅峰 倚天屠龙记十大创新概览"}))

前后端

模型剪枝

介绍

作用:主要是为了去除神经网络中不重要或者冗余的神经元、连接,甚至整个网络层
特点:对模型体积的减小和模型运行速度的提升,微乎其微
使用:剪枝后,一般会和其他的模型压缩手段配合使用

剪枝核心代码

# 模型剪枝:最核心代码
# 需求:BERT 全局非结构化剪枝:对所有 encoder 层注意力权重剪枝 30%,L1 范数
from data_preprocessing import build_dataloader
from model_eval import eval_model
from config import Config
from bert_model import BertClassifierModel
import torch
from torch.nn.utils import prune    # 模型剪枝方法

if __name__ == '__main__':
    config = Config()
    dev_dataloader = build_dataloader(config.dev_datapath,shuffle=False)
    num_layers = 12 # bert-base-chinese中编码器层有12层

    # 1- 剪枝前
    # 1.1- 加载训练好的模型
    model = BertClassifierModel().to(device=config.device)
    model.load_state_dict(torch.load(config.before_prune_path))
    # 1.2- 验证模型
    f1score, accuracy, precision, recall = eval_model(model)
    print(f"剪枝前,f1score={f1score},accuracy={accuracy},precision={precision},recall={recall}")

    # 2- 【理解】剪枝中
    # 2.1- 规定对Bert模型中什么层的什么参数进行剪枝
    """
        代码解释:
            1- before_prune_model.bert_model.encoder.layer[layer_index].attention.self:
                对模型中 12个编码器层 中的 多头自注意力子层 进行剪枝
            2- multi_self_atten.query、key、value 多头自注意力子层 中的 q、k、v 的 权重w 进行剪枝
    """
    parameters_to_prune = []
    for layer_index in range(num_layers):
        multi_self_atten = model.bert_model.encoder.layer[layer_index].attention.self
        parameters_to_prune.extend([
            (multi_self_atten.query,"weight"),
            (multi_self_atten.key,"weight"),
            (multi_self_atten.value,"weight")
        ])

    # 2.2- 进行全局非结构化剪枝
    """
        参数解释:
            parameters:剪枝范围。也就是规定对Bert模型中什么层的什么参数进行剪枝
            pruning_method:剪枝方式
            amount:剪枝的参数情况。有两种类的参数值,如下
                整数:表示具体对多少个参数剪枝
                小数:表示对Bert模型中多大比例的参数进行剪枝。推荐
    """
    prune.global_unstructured(
        parameters=parameters_to_prune,
        pruning_method=prune.L1Unstructured,
        amount=0.3
    )

    # 2.3- 固化剪枝后的模型:将剪枝后的权重持久化存储到模型结构中
    for param_name,param_type in parameters_to_prune:
        prune.remove(param_name,param_type)

    # 3- 剪枝后
    # 3.1- 验证模型
    f1score, accuracy, precision, recall = eval_model(model)
    print(f"剪枝后,f1score={f1score},accuracy={accuracy},precision={precision},recall={recall}")

    # 3.2- 保存剪枝后的模型
    torch.save(model.state_dict(),config.after_prune_path)

观察剪枝前后的参数变化

# 模型剪枝:最核心代码
# 需求:BERT 全局非结构化剪枝:对所有 encoder 层注意力权重剪枝 30%,L1 范数
from data_preprocessing import build_dataloader
from model_eval import eval_model
from config import Config
from bert_model import BertClassifierModel
import torch
from torch.nn.utils import prune    # 模型剪枝方法

# 打印权重稀疏度
def compute_sparsity(model):
    """
    计算权重稀疏度
    :param model:
    :return: 稀疏度
    """
    total_params = 0    # Bert预训练模型中参数中个数
    zero_params = 0     # 剪枝后参数值为0的参数个数
    layer_num = len(model.bert_model.encoder.layer)

    for i in range(layer_num):
        weight = model.bert_model.encoder.layer[i].attention.self.query.weight
        total_params += weight.numel()  # 获得权重张量中的参数个数
        zero_params += (weight == 0).sum().item()
    return (zero_params*100.0 / total_params) if total_params > 0 else 0


def print_weights(weight, name, rows=5, cols=5):
    """
    打印权重前n行,前n列
    :param weight: 权重
    :param name: 名称
    :param rows: 前rows行
    :param cols: 前clos列
    :return:
    """
    print(f"\n{name}(前 {rows}x{cols}):")
    print(weight[:rows, :cols])

if __name__ == '__main__':
    config = Config()
    dev_dataloader = build_dataloader(config.dev_datapath,shuffle=False)
    num_layers = 12 # bert-base-chinese中编码器层有12层

    # 1- 剪枝前
    # 1.1- 加载训练好的模型
    model = BertClassifierModel().to(device=config.device)
    model.load_state_dict(torch.load(config.before_prune_path))
    # 1.2- 验证模型
    f1score, accuracy, precision, recall = eval_model(model)
    print(f"剪枝前,f1score={f1score},accuracy={accuracy},precision={precision},recall={recall}")
    # 1.3- 打印参数信息
    zero_param_rate = compute_sparsity(model)
    print(f"剪枝前 参数值为0的占比{zero_param_rate}")
    # 只对编码器层中第一层的query的权重进行抽样展示
    print_weights(weight=model.bert_model.encoder.layer[0].attention.self.query.weight,name="剪枝前:权重")


    # 2- 【理解】剪枝中
    # 2.1- 规定对Bert模型中什么层的什么参数进行剪枝
    """
        代码解释:
            1- before_prune_model.bert_model.encoder.layer[layer_index].attention.self:
                对模型中 12个编码器层 中的 多头自注意力子层 进行剪枝
            2- multi_self_atten.query、key、value 多头自注意力子层 中的 q、k、v 的 权重w 进行剪枝
    """
    parameters_to_prune = []
    for layer_index in range(num_layers):
        multi_self_atten = model.bert_model.encoder.layer[layer_index].attention.self
        parameters_to_prune.extend([
            (multi_self_atten.query,"weight"),
            (multi_self_atten.key,"weight"),
            (multi_self_atten.value,"weight")
        ])

    # 2.2- 进行全局非结构化剪枝
    """
        参数解释:
            parameters:剪枝范围。也就是规定对Bert模型中什么层的什么参数进行剪枝
            pruning_method:剪枝方式
            amount:剪枝的参数情况。有两种类的参数值,如下
                整数:表示具体对多少个参数剪枝
                小数:表示对Bert模型中多大比例的参数进行剪枝。推荐
    """
    prune.global_unstructured(
        parameters=parameters_to_prune,
        pruning_method=prune.L1Unstructured,
        amount=0.3
    )

    # 2.3- 固化剪枝后的模型:将剪枝后的权重持久化存储到模型结构中
    for param_name,param_type in parameters_to_prune:
        prune.remove(param_name,param_type)

    # 3- 剪枝后
    # 3.1- 验证模型
    f1score, accuracy, precision, recall = eval_model(model)
    print(f"剪枝后,f1score={f1score},accuracy={accuracy},precision={precision},recall={recall}")

    # 3.2- 保存剪枝后的模型
    torch.save(model.state_dict(),config.after_prune_path)

    # 3.3- 打印参数信息
    zero_param_rate = compute_sparsity(model)
    print(f"剪枝后 参数值为0的占比{zero_param_rate}")
    # 只对编码器层中第一层的query的权重进行抽样展示
    print_weights(weight=model.bert_model.encoder.layer[0].attention.self.query.weight, name="剪枝后:权重")

image-20260121105913523