Transformer 编码器实战:Trae 实现 BERT 核心模块

85 阅读17分钟

引言

在自然语言处理(NLP)领域,Transformer 架构宛如一颗划破夜空的巨星,自诞生以来便以其强大的并行计算能力和对长距离依赖关系的精准捕捉,重塑了语言模型的格局。BERT(Bidirectional Encoder Representations from Transformers)作为 Transformer 编码器的深度应用,更是将语言理解任务推向新高度,从文本分类、情感分析到问答系统、文本生成,BERT 的身影无处不在。本篇博客将带您深入 Transformer 编码器的神秘腹地,手把手教您利用 Trae(假设为深度学习框架或工具库,用于实现 Transformer 架构)实现 BERT 的核心模块,全程理论与代码交织,实战与部署并重,让每一位读者都能在这场技术盛宴中满载而归。

image.png

I. Transformer 编码器理论基石

自注意力机制的灵魂剖析

自注意力机制(Self-Attention)是 Transformer 的灵魂所在,它赋予模型在不同单词(或文本单元)之间动态捕捉相关性的能力。以一个简单的句子 “The animal didn’t cross the street because it was too tired” 为例,模型需要理解 “it” 到底指代 “animal” 还是 “street”,自注意力机制通过计算查询向量(Query)、键向量(Key)和值向量(Value)之间的点积注意力,精准定位单词之间的关联强度。公式层面,注意力权重矩阵 ( \text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^\top}{\sqrt{d_k}}\right)V ),其中 ( Q )、( K )、( V ) 分别是查询、键、值矩阵,( d_k ) 是键向量维度,softmax 操作将注意力得分转换为概率分布,最终输出加权后的值向量,实现信息的动态融合。

多头注意力的智慧集成

多头注意力机制(Multi-Head Attention)宛如给自注意力机制装上了多副眼镜,从多个不同的视角同时观察单词之间的关系。每个注意力头都拥有独立的查询、键、值投影矩阵,对输入进行独立的注意力计算,然后将多个头的输出拼接起来,通过线性变换整合信息。这种设计不仅能捕捉更丰富的语义关系,还能增强模型的表达能力。以 8 个注意力头为例,每个头聚焦于输入的不同维度特征,有的关注语法结构,有的捕捉语义相似性,最终融合后的输出犹如一幅细腻的语义拼图,全方位呈现文本内涵。

前馈神经网络的深度加工

Transformer 编码器中的前馈神经网络(Feed-Forward Neural Network, FFNN)承担着对自注意力输出进行非线性变换的重任。它由两个线性变换层夹着一个 ReLU 激活函数组成,公式表示为 ( \text{FFNN}(x) = \text{ReLU}(xW_1 + b_1)W_2 + b_2 )。不同于自注意力机制的序列间交互,FFNN 对序列中的每个位置独立进行相同的操作,相当于对每个单词的语义进行深度雕琢,挖掘其内在特征,为后续的编码层提供更丰富的表征。

编码器层的精妙架构

Transformer 编码器由多个编码器层堆叠而成,每层包含两个核心子层:多头自注意力机制和前馈神经网络。这两个子层都采用残差连接(Residual Connection),即将输入直接加到子层输出上,帮助梯度在深层网络中顺畅流动,缓解梯度消失问题;同时,每个子层后都紧跟层归一化(Layer Normalization),对输入数据进行归一化处理,稳定训练过程。信息在编码器层中逐层沉淀,从原始词嵌入逐步转变为富含全局语义的深度表征。

Mermaid 图形总结

graph TD
    A[Transformer 编码器架构] --> B[多头自注意力机制]
    B --> C[多个独立注意力头]
    B --> D[头输出拼接与线性变换]
    A --> E[前馈神经网络]
    E --> F[两个线性层与 ReLU 激活]
    A --> G[残差连接与层归一化]
    G --> H[多头自注意力后残差归一化]
    G --> I[前馈网络后残差归一化]
    A --> J[编码器层堆叠]

编码器层对比表格

模型架构Transformer 编码器传统 RNNCNN
并行计算能力高(无序并行)低(序列依赖)中(局部并行)
长距离依赖捕捉强(自注意力全局关联)弱(随序列长度衰减)弱(依赖卷积核大小)
参数量较多(多头注意力与 FFNN)
序列间信息交互全局(自注意力矩阵)局部(逐时间步传递)局部(卷积核滑动窗口)

II. Trae 环境搭建与基础认知

Trae 的强大愿景

Trae 是一个专注于 Transformer 架构实现与优化的深度学习工具库,它承载着让 Transformer 模型开发、训练与部署流程化繁为简的使命。对于 BERT 这样的复杂模型,Trae 提供了高度模块化的编码器实现,开发者可轻松定制多头注意力头数、隐藏层维度等超参数;同时,它内置了高效的并行计算策略,自动利用多 GPU 资源加速模型训练,让大规模语言模型训练不再是资源雄厚团队的专属领地;在部署环节,Trae 能将训练好的模型编译成轻量级推理引擎,适配服务器、移动端等多样设备,实现毫秒级推理响应。

环境搭建实战攻略

在 Ubuntu 20.04 系统上搭建 Trae 环境,仿若开启一场技术寻宝之旅。首先确保系统更新至最新状态,安装 Python 3.8+、pip 等基础工具,这是搭建环境的 “通行证”。接着在终端执行 pip install trae,让 Trae 库入驻您的 Python 环境;为实现 GPU 加速,需额外安装 NVIDIA 显卡驱动、CUDA Toolkit 11.1 及 cuDNN 8.0,然后运行 pip install trae[cuda],使 Trae 能驭驾 GPU 算力。验证环境是否搭建成功,可通过运行 Trae 官方示例代码,观察是否能正常初始化编码器模型并执行前向传播。

# Trae 环境验证代码
import trae

# 初始化一个基础 Transformer 编码器
encoder = trae.TransformerEncoder(
    vocab_size=30522,  # BERT 基础版词汇表大小
    max_seq_length=512,  # 最大序列长度
    hidden_size=768,  # 隐藏层维度
    num_attention_heads=12  # 注意力头数
)

# 随机生成输入数据(模拟一批次文本输入)
import torch
input_ids = torch.randint(0, 30522, (32, 512))  # 32 个序列,每个序列 512 个 token

# 执行前向传播
outputs = encoder(input_ids)
print("编码器输出形状:", outputs.shape)  # 应输出 torch.Size([32, 512, 768])

Trae 编码器核心组件

深入了解 Trae 中的 Transformer 编码器实现,其核心组件环环相扣。词嵌入层(Embedding Layer)将文本中的单词或子词单元映射到高维向量空间,每个单词对应一个可学习的嵌入向量,维度通常为 128 至 1024;位置嵌入层(Positional Encoding)负责为序列中的单词注入位置信息,采用正弦、余弦函数组合或可学习的位置向量,使模型辨别单词顺序;多头自注意力层(Multi-Head Attention Layer)基于上述公式实现并行的多头注意力计算,是捕捉单词间关系的枢纽;前馈网络层(Feed-Forward Network Layer)对每个位置的表征进行非线性变换,提升模型表达力;残差与归一化层(Residual & Norm Layer)在多头注意力和前馈网络后分别施加残差连接与层归一化,保障训练稳定。

Mermaid 图形总结

graph TD
    A[Trae Transformer 编码器组件] --> B[词嵌入层]
    B --> C[词汇表映射到向量空间]
    A --> D[位置嵌入层]
    D --> E[正弦/余弦或可学习位置编码]
    A --> F[多头自注意力层]
    F --> G[并行多注意力头计算]
    A --> H[前馈网络层]
    H --> I[非线性特征变换]
    A --> J[残差与归一化层]
    J --> K[残差连接保障梯度流通]
    J --> L[层归一化稳定训练]

III. BERT 核心模块 Trae 实现

BERT 模型架构精要

BERT 模型架构基于多层 Transformer 编码器堆叠,以 BERT 基础版(BERT-Base)为例,它包含 12 层 Transformer 编码器,每层拥有 12 个自注意力头,隐藏层维度为 768,总参数量约 1.1 亿。输入序列由特殊符号 [CLS](用于分类任务)、单词或子词 token、特殊符号 [SEP](分隔不同片段)组成,并配以 segment embedding 标识不同文本片段。在预训练阶段,BERT 通过掩码语言模型(Masked Language Model, MLM)和下一句预测(Next Sentence Prediction, NSP)两种任务联合训练,MLM 随机掩码输入序列中 15% 的 token,让模型预测被掩码的词;NSP 判断两段文本是否为连续的下一句,使模型学习文本间连贯性。

# 利用 Trae 构建 BERT 基础版模型
class BertModel(nn.Module):
    def __init__(self):
        super(BertModel, self).__init__()
        # 配置 BERT 基础版参数
        self.config = trae.BertConfig(
            vocab_size=30522,
            hidden_size=768,
            num_hidden_layers=12,
            num_attention_heads=12,
            max_position_embeddings=512,
            type_vocab_size=2  # 用于 segment embedding
        )
        # 初始化 Transformer 编码器
        self.encoder = trae.TransformerEncoder(self.config)
        # 初始化嵌入层
        self.embeddings = trae.BertEmbeddings(self.config)

    def forward(self, input_ids, token_type_ids=None, attention_mask=None):
        # 获取嵌入向量
        embedding_output = self.embeddings(input_ids, token_type_ids)
        # 编码器前向传播
        sequence_output = self.encoder(embedding_output, attention_mask)
        # 提取 [CLS] 位置的表征用于分类任务
        cls_output = sequence_output[:, 0, :]
        return sequence_output, cls_output

MLM 与 NSP 任务实现

掩码语言模型(MLM)任务实现的关键在于构建掩码输入与计算预测损失。首先随机选择输入序列中 15% 的 token 进行掩码,其中 80% 替换为 [MASK] 符号,10% 替换为随机词,10% 保持原词,这种策略防止模型对 [MASK] 过度拟合。利用 BERT 输出的每个位置的隐藏状态,通过全连接层映射回词汇表大小的维度,计算交叉熵损失衡量预测准确性。

下一句预测(NSP)任务则基于 BERT 输出的 [CLS] 位置表征,通过全连接层加 sigmoid 激活函数,输出二分类概率,判断输入的两段文本是否为连续的下一句,损失函数采用二元交叉熵损失。

# MLM 任务实现
class BertForMaskedLM(nn.Module):
    def __init__(self):
        super(BertForMaskedLM, self).__init__()
        self.bert = BertModel()
        self.mlm_head = trae.MLMHead(self.bert.config)

    def forward(self, input_ids, masked_lm_labels=None):
        sequence_output, _ = self.bert(input_ids)
        prediction_scores = self.mlm_head(sequence_output)
        # 计算 MLM 损失
        if masked_lm_labels is not None:
            loss_fn = nn.CrossEntropyLoss()
            masked_lm_loss = loss_fn(
                prediction_scores.view(-1, self.bert.config.vocab_size),
                masked_lm_labels.view(-1)
            )
            return masked_lm_loss
        return prediction_scores

# NSP 任务实现
class BertForNextSentencePrediction(nn.Module):
    def __init__(self):
        super(BertForNextSentencePrediction, self).__init__()
        self.bert = BertModel()
        self.nsp_head = trae.NSPHead(self.bert.config)

    def forward(self, input_ids, token_type_ids, next_sentence_label=None):
        _, cls_output = self.bert(input_ids, token_type_ids)
        logits = self.nsp_head(cls_output)
        # 计算 NSP 损失
        if next_sentence_label is not None:
            loss_fn = nn.CrossEntropyLoss()
            nsp_loss = loss_fn(logits, next_sentence_label)
            return nsp_loss
        return logits

预训练过程深度解析

BERT 的预训练是一场在海量文本上挖掘知识的漫长征程。以英语维基百科(约 2500 万句话)和书籍语料库(BooksCorpus,约 7000 万句话)为数据基础,数据预处理环节首先将文本切分为单词或子词单元,采用 WordPiece 分词算法,将词汇表规模控制在 3 万至 3.5 万;构建训练样本时,对于 NSP 任务,50% 的样本为真正的连续下一句对,50% 为随机拼接的不连续句对。训练采用 Adam 优化器,初始学习率 1e-4,训练批大小 256(可根据 GPU 内存调整),在 4 个 Cloud TPUv3 芯片上训练 100 万个步骤,约 4 天完成。随着训练进程,模型逐渐学会捕捉词与词、句与句之间的复杂关系,词嵌入空间中语义相近的词彼此靠近,句法结构相似的短语形成簇团。

预训练损失曲线观察

观察预训练过程中的损失曲线,宛如聆听模型学习的脉搏。MLM 损失曲线初始阶段陡峭下降,表明模型快速学习常见词汇的预测模式;中期下降速度减缓,模型开始攻克语境依赖强、多义性的词汇预测;后期趋于平稳,损失值稳定在较低水平,说明模型已充分学习词汇分布规律。NSP 损失曲线同样呈现先快后慢的下降趋势,初期模型学会基本的文本连贯性判断,如问答对的连续性;中期能处理更复杂的语义关联,如同一话题下不同表述的衔接;后期损失稳定,模型对文本连贯性的判断精准度较高,能区分微妙的语义关联差异。

Mermaid 图形总结

graph TD
    A[BERT 核心模块实现流程] --> B[BERT 模型架构搭建]
    B --> C[12 层 Transformer 编码器]
    B --> D[特殊符号与嵌入层]
    A --> E[MLM 任务实现]
    E --> F[掩码策略与损失计算]
    A --> G[NSP 任务实现]
    G --> H[CLS 表征与二分类]
    A --> I[预训练过程]
    I --> J[数据预处理与样本构建]
    I --> K[优化器与训练配置]
    I --> L[损失曲线观察与分析]

BERT 版本特性对比

BERT 版本BERT-BaseBERT-Large
编码器层数1224
隐藏层维度7681024
注意力头数1216
总参数量1.1 亿3.4 亿
预训练时间(4xTPUv3)4 天约 7 天
推理速度(单序列)较快较慢
下游任务效果良好更优

IV. BERT 预训练实战

数据准备与预处理

数据是 BERT 预训练的基石,文本数据来源广泛,涵盖维基百科、书籍、新闻、网页等多种类型。以维基百科数据为例,数据收集环节通过维基百科 API 或快照下载工具批量获取页面内容;清洗过程移除 XML 标签、引用、特殊字符等噪声,保留纯文本;分句采用 NLTK 或 SpaCy 等工具,将长文本切分为句子;分词则使用 Hugging Face 的 tokenizers 库,基于 WordPiece 算法构建词汇表并切分句子为子词单元。最终将处理后的数据保存为 HDF5 或 Arrow 格式,便于批量加载训练。

# BERT 数据预处理示例代码
from tokenizers import BertWordPieceTokenizer

# 初始化分词器并训练词汇表
tokenizer = BertWordPieceTokenizer()
tokenizer.train(
    files=["wiki_text.txt"],  # 维基百科文本文件
    vocab_size=30522,
    min_frequency=2,
    special_tokens=["[PAD]", "[CLS]", "[SEP]", "[MASK]", "[UNK]"]
)

# 保存词汇表和分词器配置
tokenizer.save("bert-vocab.json")

# 将文本编码为 BERT 输入格式
encoded = tokenizer.encode(
    "Hello, this is an example sentence for BERT pretraining.",
    add_special_tokens=True,  # 添加 [CLS] 和 [SEP]
    max_length=512,
    padding="max_length",
    truncation=True
)
print("编码结果:", encoded.ids)

预训练代码架构搭建

构建 BERT 预训练代码架构,需整合模型、数据加载器、优化器与训练循环。模型选用上述实现的 BertForMaskedLMBertForNextSentencePrediction,通过继承或组合方式构建联合训练模型;数据加载器负责将预处理好的数据分批次加载,动态生成掩码和构建 NSP 样本;优化器采用 AdamW(Adam 的权重衰变变种),初始学习率 1e-4,采用线性学习率热身与衰减策略;训练循环中,每个批次计算 MLM 和 NSP 损失,反向传播更新参数,定期保存检查点以便后续恢复训练或部署。

# BERT 预训练联合模型
class BertForPretraining(nn.Module):
    def __init__(self):
        super(BertForPretraining, self).__init__()
        self.bert = BertModel()
        self.mlm_head = trae.MLMHead(self.bert.config)
        self.nsp_head = trae.NSPHead(self.bert.config)

    def forward(self, input_ids, token_type_ids, attention_mask, masked_lm_labels, next_sentence_label):
        sequence_output, cls_output = self.bert(input_ids, token_type_ids, attention_mask)
        prediction_scores = self.mlm_head(sequence_output)
        logits = self.nsp_head(cls_output)
        return prediction_scores, logits

# 训练循环伪代码
model = BertForPretraining()
optimizer = AdamW(model.parameters(), lr=1e-4, weight_decay=0.01)
scheduler = get_linear_schedule_with_warmup(
    optimizer,
    num_warmup_steps=10000,
    num_training_steps=1000000
)

model.train()
for epoch in range(num_epochs):
    for batch in dataloader:
        input_ids, token_type_ids, attention_mask, masked_lm_labels, next_sentence_label = batch
        optimizer.zero_grad()
        prediction_scores, logits = model(
            input_ids,
            token_type_ids,
            attention_mask,
            masked_lm_labels,
            next_sentence_label
        )
        # 计算 MLM 损失
        mlm_loss = loss_fn(prediction_scores.view(-1, vocab_size), masked_lm_labels.view(-1))
        # 计算 NSP 损失
        nsp_loss = loss_fn(logits, next_sentence_label)
        total_loss = mlm_loss + nsp_loss
        total_loss.backward()
        optimizer.step()
        scheduler.step()
    # 保存检查点
    if epoch % save_interval == 0:
        torch.save(model.state_dict(), f"bert_pretrain_epoch_{epoch}.pt")

训练优化技巧与资源调优

BERT 预训练耗时长、资源消耗大,掌握训练优化技巧至关重要。混合精度训练通过使用 fp16 与 fp32 混合数据类型,减少内存占用约 50%,加速计算近 2 倍,但需关注数值溢出问题,可通过动态损失缩放解决;分布式训练采用数据并行策略,在多 GPU 上分割数据批次,同步梯度更新,几乎线性加速训练过程;梯度累积模拟大批次效果,在小 GPU 内存场景下,累积多个小批次梯度后更新参数,既保证模型收敛质量,又适应硬件限制。

在资源调优方面,合理设置序列长度,将序列长度从 512 降至 256 可使显存占用减半,适合显存有限场景;灵活调整批次大小,若单 GPU 显存不足,可减小批次大小并通过梯度累积弥补;优化数据加载流程,使用多线程数据预取、内存映射读取等技术,减少数据加载等待时间,提升 GPU 利用率。

# 混合精度训练代码示例
from torch.cuda.amp import GradScaler, autocast

scaler = GradScaler()

model.train()
for batch in dataloader:
    input_ids, token_type_ids, attention_mask, masked_lm_labels, next_sentence_label = batch
    optimizer.zero_grad()
    # 启用混合精度
    with autocast():
        prediction_scores, logits = model(
            input_ids,
            token_type_ids,
            attention_mask,
            masked_lm_labels,
            next_sentence_label
        )
        mlm_loss = loss_fn(prediction_scores.view(-1, vocab_size), masked_lm_labels.view(-1))
        nsp_loss = loss_fn(logits, next_sentence_label)
        total_loss = mlm_loss + nsp_loss
    # 缩放损失以防止 fp16 溢出
    scaler.scale(total_loss).backward()
    scaler.step(optimizer)
    scaler.update()

Mermaid 图形总结

graph TD
    A[BERT 预训练实战流程] --> B[数据准备与预处理]
    B --> C[文本收集与清洗]
    B --> D[分句与分词]
    A --> E[预训练代码架构]
    E --> F[BERT 联合模型构建]
    E --> G[数据加载与优化器配置]
    E --> H[训练循环与检查点保存]
    A --> I[训练优化策略]
    I --> J[混合精度训练]
    I --> K[分布式训练]
    I --> L[梯度累积]
    A --> M[资源调优技巧]
    M --> N[序列长度调整]
    M --> O[批次大小优化]
    M --> P[数据加载加速]

V. BERT 部署应用实战

下游任务微调策略

BERT 在下游任务的应用基于微调(Fine-Tuning)策略,即在预训练模型基础上,针对具体任务添加任务特定层,如文本分类任务在 BERT 输出的 [CLS] 表征后接全连接分类层;问答任务则在序列输出上分别预测答案起始与结束位置。以情感分析任务为例,微调步骤如下:首先加载预训练的 BERT 模型权重;添加分类层并随机初始化;将整个模型迁移到 GPU;设置较小的学习率(如 2e-5)微调 BERT 参数,同时用稍大的学习率(如 1e-3)训练分类层,平衡预训练知识保留与任务特定学习;利用标注数据训练数个 epoch 后,模型即可精准判断文本情感倾向。

# 情感分析微调模型
class BertForSequenceClassification(nn.Module):
    def __init__(self, num_labels):
        super(BertForSequenceClassification, self).__init__()
        self.bert = BertModel()
        self.classifier = nn.Linear(self.bert.config.hidden_size, num_labels)

    def forward(self, input_ids, token_type_ids, attention_mask):
        _, cls_output = self.bert(input_ids, token_type_ids, attention_mask)
        logits = self.classifier(cls_output)
        return logits

# 微调训练代码示例
model = BertForSequenceClassification(num_labels=2)  # 二分类情感分析
optimizer = AdamW([
    {'params': model.bert.parameters(), 'lr': 2e-5},
    {'params': model.classifier.parameters(), 'lr': 1e-3}
])
criterion = nn.CrossEntropyLoss()

model.train()
for epoch in range(num_epochs):
    for batch in train_dataloader:
        input_ids, token_type_ids, attention_mask, labels = batch
        input_ids, token_type_ids, attention_mask, labels = (
            input_ids.to(device),
            token_type_ids.to(device),
            attention_mask.to(device),
            labels.to(device)
        )
        optimizer.zero_grad()
        logits = model(input_ids, token_type_ids, attention_mask)
        loss = criterion(logits, labels)
        loss.backward()
        optimizer.step()

模型量化与压缩

部署场景中,模型量化与压缩技术是缓解资源压力的利器。量化将 BERT 模型中的 32 位浮点数权重转换为 8 位整数,存储空间压缩至约 1/4,推理速度提升 2-4 倍,适合在移动设备或边缘设备部署;剪枝技术则通过分析权重重要性,剪除不重要的权重连接,减少模型参数量 30%-50% 不等,推理时跳过剪除部分计算,加速模型运行;蒸馏技术更进一步,训练一个小型学生模型模仿 BERT 输出,学生模型参数量可压缩至原模型的 10%-20%,在资源极受限设备上实现近似性能。

# BERT 模型量化伪代码(使用 PyTorch 量化工具)
# 定义量化配置
qconfig = torch.quantization.get_default_qconfig('fbgemm')
torch.quantization.prepare(model, inplace=True)

# 校准量化参数(需运行校准数据集)
with torch.no_grad():
    for batch in calibration_dataloader:
        model(batch[0])

torch.quantization.convert(model, inplace=True)
print("量化后的模型:", model)

部署流程与性能优化

部署 BERT 模型至生产环境,需遵循标准化流程。在服务器端,完成模型量化或剪枝后,利用 Trae 提供的模型编译工具将模型转换为高效推理格式,如 ONNX 或 TVM 中间表示;集成到 Web 服务中,使用 Flask 或 FastAPI 构建 RESTful API,接收客户端发送的文本数据;调用推理引擎执行预测,将结果返回客户端;部署时采用负载均衡分发请求,确保服务高可用。在移动端,将量化后的 BERT 模型集成进安卓或 iOS 应用,借助平台原生的神经网络 API(如 Android NNAPI 或 iOS Core ML)加速推理;优化模型加载流程,减少初始化时间;处理离线场景,将词汇表、分词器一同打包进应用。

性能优化方面,服务器端关注推理延迟与吞吐量,可通过批处理请求、模型分片等技术提升;移动端聚焦应用启动时间与内存占用,利用模型懒加载、内存映射等策略优化。

Mermaid 图形总结

graph TD
    A[BERT 部署应用流程] --> B[下游任务微调]
    B --> C[任务特定层添加]
    B --> D[微调训练与优化]
    A --> E[模型量化与压缩]
    E --> F[权重量化]
    E --> G[模型剪枝]
    E --> H[知识蒸馏]
    A --> I[部署流程标准化]
    I --> J[服务器端 Web 服务]
    I --> K[移动端应用集成]
    I --> L[性能优化策略]

下游任务性能对比

下游任务原始 BERT量化 BERT蒸馏小模型
情感分析准确率93.6%92.8% (-0.8%)91.5% (-2.1%)
问答 F1 分数87.286.1 (-1.1)84.3 (-2.9)
推理延迟(MS)450120 (-73%)80 (-82%)
模型大小(MB)1100280 (-74%)220 (-80%)