LLM学习笔记(17)序列标注任务(训练模型阶段)

训练模型

这段代码的主要功能是 构建一个用于序列标注任务的模型,尤其是针对 命名实体识别 (NER, Named Entity Recognition) 的任务。通过利用 BERT 模型Transformers 库 提供的工具,快速构建一个可用于标注每个 token 的实体标签的分类器。

构建模型

具体功能

  1. AutoModelForTokenClassification 使用

    • 通过 AutoModelForTokenClassification.from_pretrained() 方法直接加载预训练的 BERT 模型,并传入标签映射(id2labellabel2id)来创建模型。这样可以快速实现一个基于预训练 BERT 的 token 分类模型。
  2. 手工构建模型

    • 继承 BertPreTrainedModel 类并手动定义模型架构,从而更灵活地调整模型的结构。
    • 使用 BertModel 来提取 token 的语义表示。
    • 添加 Dropout 层来防止过拟合。
    • 使用一个 线性分类器Linear 层)将 BERT 模型的输出映射到实体标签空间,输出每个 token 对应的标签概率。
  3. 模型前向传播 (forward)

    • 输入数据传入模型时,首先通过 BERT 模型获取每个 token 的表示(向量),然后通过 Dropout 层进行处理,再将处理后的输出通过一个线性分类器(全连接层)进行标注分类。
  4. 模型输出验证

    • 对于一个 batch 的输入数据,模型输出的尺寸为 [batch_size, sequence_length, num_labels],其中 num_labels 是标签类别的数量(例如 7 种实体标签),此处模型输出的维度符合预期。

具体代码

快速模型构建(AutoModelForTokenClassification)

from transformers import AutoModelForTokenClassification

model = AutoModelForTokenClassification.from_pretrained(
    model_checkpoint,
    id2label=id2label,
    label2id=label2id,
)

  • 使用 AutoModelForTokenClassification 直接构建一个基于 BERT 的序列标注模型。
  • 传入预训练模型检查点 model_checkpoint 和标签映射 id2labellabel2id,快速创建模型。
  • 这种方法简单、快速,但灵活性不足,不能自定义模型细节。

手工构建自定义模型(BertForNER)

from torch import nn
from transformers import AutoConfig, BertPreTrainedModel, BertModel

device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'Using {device} device')

class BertForNER(BertPreTrainedModel):
    def __init__(self, config):
        super().__init__(config)
        self.bert = BertModel(config, add_pooling_layer=False)
        self.dropout = nn.Dropout(config.hidden_dropout_prob)
        self.classifier = nn.Linear(768, len(id2label))  # 线性分类器,768 为 BERT 输出维度

        self.post_init()

    def forward(self, x):
        bert_output = self.bert(**x)
        sequence_output = bert_output.last_hidden_state
        sequence_output = self.dropout(sequence_output)
        logits = self.classifier(sequence_output)  # 输出每个 token 的分类结果
        return logits

config = AutoConfig.from_pretrained(checkpoint)
model = BertForNER.from_pretrained(checkpoint, config=config).to(device)
print(model)

导入的模块和类

AutoConfig (from transformers import AutoConfig):

  • AutoConfig 是 Hugging Face 的一个工具,专门用来加载预训练模型的配置。配置文件包含了模型的参数、结构等信息,通常在初始化模型时使用。

BertPreTrainedModel (from transformers import BertPreTrainedModel):

  • BertPreTrainedModel 是 Hugging Face 库中的一个基类,它为所有基于 BERT 的模型提供了预训练模型的功能(如加载预训练权重、保存模型等)。这个基类通常用于继承,以便创建一个自定义的 BERT 模型。

BertModel (from transformers import BertModel):

  • BertModel 是 Hugging Face 库中的 BERT 模型实现。它是预训练的 BERT 模型的基础,提供了对输入文本进行编码的能力,生成每个 token 的表示(即隐藏层输出)。BertModel 本身没有做下游任务(如分类),而是用于提取文本特征(如在这个例子中用于序列标注任务)。
device = 'cuda' if torch.cuda.is_available() else 'cpu'

判断当前环境中是否有可用的 GPU(通过 CUDA)来加速计算,如果有,则将计算设备设为 GPU (cuda),如果没有 GPU,则使用 CPU 进行计算。

具体来说:

  • torch.cuda.is_available():这是 PyTorch 提供的一个方法,用来检查当前是否有可用的 GPU。如果有可用的 GPU,它返回 True,否则返回 False

  • device = 'cuda' if torch.cuda.is_available() else 'cpu':这行代码使用 Python 的条件表达式(或三元表达式),根据 torch.cuda.is_available() 的返回值来决定使用 'cuda'(GPU)还是 'cpu'(CPU)。如果系统上有 GPU 可用,则 device 变量将被赋值为 'cuda',否则为 'cpu'

作用

  1. 动态选择设备:根据硬件配置(是否有 GPU)自动选择计算设备,确保在有 GPU 时利用 GPU 加速运算,在没有 GPU 时仍然可以使用 CPU 运行模型。
  2. 代码兼容性:使得模型代码在不同的计算环境中(有无 GPU)都能正常运行,无需修改代码,只需选择合适的设备。
定义类 BertForNER

BertForNER 是一个自定义的 命名实体识别 (NER) 模型类,继承了 BertPreTrainedModel。它基于预训练的 BERT 模型构建,并在其顶部添加了用于分类的线性层,能够对每个 token 进行分类。

  • __init__(self, config):初始化模型结构,定义模型的组成部分。
  • forward(self, x):定义模型的前向传播逻辑,指定输入如何通过模型的各部分计算输出。
初始化方法:__init__(self, config)

功能

初始化模型的各个部分,主要包括加载 BERT 模型、添加 Dropout 层和线性分类器。

def __init__(self, config):
    super().__init__(config)

  • super().__init__(config)
    • 调用父类 BertPreTrainedModel 的初始化方法。
    • 加载预训练的 BERT 模型权重和配置文件。
    • config 包含模型的所有超参数(例如隐藏层大小、分类标签数等)。

self.bert = BertModel(config, add_pooling_layer=False)

  • self.bert
    • 加载 BERT 模型部分,用于生成每个 token 的语义表示。
    • add_pooling_layer=False
      • 关闭池化层(默认 BERT 会在最后一层添加池化层用于句子级别任务)。
      • 在序列标注任务中(如 NER),需要对每个 token 进行分类,因此不需要句子级别的池化操作。

self.dropout = nn.Dropout(config.hidden_dropout_prob)

  • self.dropout
    • 添加 Dropout 层,防止模型过拟合。
    • Dropout 的概率由 config.hidden_dropout_prob 控制,通常为 0.1 或其他小值。

self.classifier = nn.Linear(768, len(id2label))

  • self.classifier
    • 定义一个全连接层(线性分类器)。
    • 输入维度:768(BERT 的隐藏层维度)。
    • 输出维度:len(id2label)(标签类别数,例如 7 个标签:O, B-LOC, I-LOC, 等)。
    • 作用:将 BERT 输出的每个 token 的向量映射到分类标签空间。

self.post_init()

  • self.post_init()
    • 继承自 BertPreTrainedModel 的方法,用于进一步初始化(如加载预训练权重)。
    • 在 Hugging Face 的实现中,这是一个可选的扩展。
前向传播方法:forward(self, x)

功能

定义模型的前向传播过程,具体实现输入如何经过模型处理后输出。
    bert_output = self.bert(**x)

bert_output

  • 将输入 x 传递给 self.bert(即 BERT 模型部分)。
  • 输入 x 是一个字典,包含 BERT 所需的输入(如 input_ids, attention_mask, token_type_ids)。
  • 输出
    • bert_output.last_hidden_state:每个 token 的语义表示(shape: [batch_size, seq_len, hidden_dim],其中 hidden_dim=768)。
    • bert_output.pooler_output(可选):句子级别表示(这里被禁用)。

sequence_output = bert_output.last_hidden_state

sequence_output

  • 提取 last_hidden_state,表示 BERT 对每个 token 的上下文语义表示(shape: [batch_size, seq_len, 768])。

sequence_output = self.dropout(sequence_output)

self.dropout(sequence_output)

  • 对 BERT 的输出添加 Dropout 层,随机置零部分神经元,防止过拟合。

logits = self.classifier(sequence_output)

logits

  • 将 Dropout 后的语义表示传入全连接分类器,输出每个 token 的分类 logits(shape: [batch_size, seq_len, num_labels])。
  • num_labels 是标签类别数(例如 7)。
  • logits 是未归一化的分数,可以通过 Softmax 转换为概率。

return logits

  • 返回 logits,表示每个 token 对应每个标签的分类结果。
  • 输出的 shape 是 [batch_size, seq_len, num_labels]
总结

__init__(self, config) 的功能

  1. 加载预训练的 BERT 模型。
  2. 添加 Dropout 层,防止过拟合。
  3. 定义线性分类器,将 BERT 的输出映射到标签类别。

forward(self, x) 的功能

  1. 输入经过 BERT 提取每个 token 的语义表示。
  2. 通过 Dropout 层处理,防止过拟合。
  3. 使用线性分类器将语义表示映射到实体标签类别。
  4. 输出 logits,用于表示每个 token 的分类结果。
类的整体功能

BertForNER 是一个基于 BERT 的命名实体识别模型,用于对输入序列中的每个 token 进行分类,输出每个 token 的实体标签类别概率。

加载预训练的 BERT 模型配置,构建自定义的 NER 模型,并将其移动到指定的计算设备(如 GPU 或 CPU)
加载预训练模型配置

config = AutoConfig.from_pretrained(checkpoint)

  • 功能
    • 使用 Hugging Face 的 AutoConfig 类从指定的预训练模型检查点 (checkpoint) 中加载模型的配置文件。
    • checkpoint 是预训练模型的名称或路径,例如 "bert-base-chinese" 或本地的模型文件夹路径。
    • 配置文件包含模型结构和参数的信息(如隐藏层大小、标签类别数、Dropout 概率等),但不包括模型的权重。
  • 输出
    • config 是一个包含模型配置的对象。
    • 例如,config 中可能包含:
    • {
          "hidden_size": 768,       # BERT 隐藏层维度
          "num_hidden_layers": 12, # BERT 的 Transformer 层数
          "num_attention_heads": 12, # 自注意力头的数量
          "hidden_dropout_prob": 0.1, # Dropout 概率
          ...
      }
       
加载预训练权重,并构建自定义模型

model = BertForNER.from_pretrained(checkpoint, config=config).to(device)

  • BertForNER.from_pretrained()

    • 使用自定义的 BertForNER 类加载预训练模型的权重(from_pretrained 方法)。
    • 通过 checkpoint 提供的路径加载权重,并将权重与模型的结构(由 config 定义)匹配。
    • 此方法会加载预训练模型的参数(如 BERT 的词嵌入和 Transformer 层的权重),然后应用到自定义的 NER 模型中。
  • config=config

    • 明确指定模型使用的配置对象 config
    • 这一步确保加载的权重与模型结构兼容。
  • .to(device)

    • 将模型移动到指定的设备(如 GPU 或 CPU)。device 的值取决于前面的代码:
    • device = 'cuda' if torch.cuda.is_available() else 'cpu'
    • 如果有可用的 GPU,模型会加载到 GPU 上;否则,加载到 CPU 上。
  • 输出

    • model 是一个完整的 BERT 模型,经过自定义的 BertForNER 构造,适用于命名实体识别任务。
打印模型结构

print(model)

  • 打印模型的详细结构,包括各层的组成和参数。
  • 例如,输出可能如下:
  • BertForNER(
      (bert): BertModel(...)
      (dropout): Dropout(p=0.1, inplace=False)
      (classifier): Linear(in_features=768, out_features=7, bias=True)
    )
    • (bert): BertModel(...)
      • BERT 的主模型部分,负责生成每个 token 的语义表示。
      • 包括嵌入层、12 层 Transformer 层等。
    • (dropout): Dropout(p=0.1, inplace=False)
      • Dropout 层,用于防止过拟合。
      • Dropout 概率为 0.1(由 config.hidden_dropout_prob 指定)。
    • (classifier): Linear(in_features=768, out_features=7, bias=True)
      • 线性分类器,用于将每个 token 的 768 维表示映射到 7 个标签类别。
      • in_features=768:输入维度,来自 BERT 的隐藏层。
      • out_features=7:输出维度,对应实体标签数。
总结

这段代码的作用是:

  1. 加载配置

    • 使用 AutoConfig 从预训练检查点加载模型的配置参数。
    • 确保模型结构(如隐藏层大小、Dropout 概率)与预训练权重匹配。
  2. 加载模型

    • 使用自定义的 BertForNER 类,通过 from_pretrained 方法加载预训练的 BERT 权重,并结合配置构造一个自定义的命名实体识别模型。
    • 将模型移动到指定的计算设备(GPU 或 CPU)。
  3. 打印模型结构

    • 输出模型的各层结构,方便检查模型是否加载正确。

优化模型参数

训练流程中的关键概念和损失计算

我们首先阐述序列标注任务(例如命名实体识别 NER)中 损失函数的特殊性 和计算方式。注意:与文本分类任务不同,序列标注任务的每个样本会输出一个预测向量序列(每个 token 都对应一个预测向量)。

关键点

  • CrossEntropyLoss 要求输入的 logits 和目标标签的形状特定。
  • 模型输出 [batch_size, sequence_length, num_labels] 必须通过 permute 转换为 [batch_size, num_labels, sequence_length]

我们就在这里解释了这个调整的必要性和计算方式:

我们将每一轮 Epoch 分为“训练循环”和“验证/测试循环”,在训练循环中计算损失,优化模型参数,在验证/测试循环中评估模型性能。下面我们首先实现训练循环。

但是,与文本分类任务对于每个样本只输出一个预测向量不同,token 分类任务会输出一个预测向量序列(因为对每个 token 都进行了单独分类),因此在 CrossEntropyLoss 中计算损失时,不能像之前直接将模型的预测结果与标签送入到 CrossEntropyLoss 中进行计算。

对于高维输出(例如 2D 图像需要按像素计算交叉熵),CrossEntropyLoss 需要输入维度调整为:(batch, C, d1, d2, ..., dk)。其中 C 是类别个数,d1, d2, ..., dk 是输入的维度。对于 token 分类任务,就是在 token 序列维度上计算交叉熵(Keras 称为时间步),因此下面我们先通过 pred.permute(0, 2, 1) 交换后两维,将模型输出维度从:(batch, seq, 7) 调整为:(batch, 7, seq),然后计算损失。

训练循环的实现

from tqdm.auto import tqdm

def train_loop(dataloader, model, loss_fn, optimizer, lr_scheduler, epoch, total_loss):
    progress_bar = tqdm(range(len(dataloader)))
    progress_bar.set_description(f'loss: {0:7f}')
    finish_batch_num = (epoch - 1) * len(dataloader)

    model.train()
    for batch, (X, y) in enumerate(dataloader, start=1):
        X, y = X.to(device), y.to(device)

        pred = model(X)
        loss = loss_fn(pred.permute(0, 2, 1), y)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        lr_scheduler.step()

        total_loss += loss.item()
        progress_bar.set_description(f'loss: {total_loss / (finish_batch_num + batch):7f}')
        progress_bar.update(1)

    return total_loss

代码逐行功能解释

导入库

from tqdm.auto import tqdm

  • 功能:导入 tqdm 模块,用于在训练过程中显示进度条,直观地跟踪训练进度。

定义训练循环

def train_loop(dataloader, model, loss_fn, optimizer, lr_scheduler, epoch, total_loss):

  • 定义 train_loop 函数,用于执行一轮训练循环。
  • 参数:
    • dataloader:数据加载器,提供训练数据。
    • model:待训练的模型。
    • loss_fn:损失函数,用于计算模型输出与目标标签的误差。
    • optimizer:优化器,用于更新模型参数。
    • lr_scheduler:学习率调度器,动态调整学习率。
    • epoch:当前训练的轮次。
    • total_loss:累计的损失,用于统计和显示。

创建进度条

progress_bar = tqdm(range(len(dataloader)))
progress_bar.set_description(f'loss: {0:7f}')
finish_batch_num = (epoch - 1) * len(dataloader)

  • progress_bar = tqdm(range(len(dataloader))):创建训练进度条,显示数据加载器的迭代进度。
    • 使用 tqdm 的方法:
    • tqdm(iterable)tqdm 是一个 Python 库,用于快速显示迭代任务的进度。
    • 参数:
      • iterable:一个可迭代对象(例如列表、生成器等)。在这里,range(len(dataloader)) 表示迭代 dataloader 中的批次数。
        • len(dataloader):返回数据加载器(dataloader)中的批次数。
        • range(len(dataloader)):生成一个从 0len(dataloader) - 1 的序列。
    • 作用:
      • 这个进度条会随着每次迭代更新,帮助用户了解训练的实时状态和完成进度。
  • progress_bar.set_description(f'loss: {0:7f}'):为进度条添加描述信息,这里是当前损失值。
    • 功能:
      • 为进度条添加描述信息(description),通常用于显示额外的上下文信息,例如当前的损失值。
    • 方法:
      • set_description()tqdm 提供的方法,用于设置进度条的描述。
        • 参数:
          • f'loss: {0:7f}'
            • f'':格式化字符串(f-string),允许在字符串中插入变量或表达式。
            • loss: {0:7f}:格式化输出,表示初始的损失值为 0,使用浮点数的格式显示,总宽度为 7。
    • 作用:
      • 在训练的最开始阶段,将进度条描述设置为 "损失(loss)的初始值为 0"。
  • finish_batch_num = (epoch - 1) * len(dataloader):计算前几轮训练完成的批次数,用于计算累计损失。
    • 功能:
      • 计算当前轮次(epoch)开始时,已经完成的总批次数。
    • 变量含义:
      • epoch:当前训练的轮次,从 1 开始计数。
      • len(dataloader):每一轮训练中,数据加载器所需的批次数。
    • 公式解释:
      • finish_batch_num = (epoch - 1) * len(dataloader)
        • epoch - 1:已经完成的轮次数。
        • len(dataloader):每一轮所需的批次数。
        • 这两者相乘,得出当前轮次开始之前已完成的批次数。
    • 作用:
      • 这个变量用于计算训练的平均损失值时,将当前轮次的批次数与之前轮次完成的批次数进行累计。

模型进入训练模式

model.train()

  • 将模型切换到训练模式(train mode),启用 Dropout 等训练专用机制。

遍历数据加载器

for batch, (X, y) in enumerate(dataloader, start=1):
    X, y = X.to(device), y.to(device)

  • enumerate(dataloader, start=1):从数据加载器中逐批读取数据,X 是输入(通常是一个张量),y 是目标标签(label)。
  • .to(device):是 PyTorch 中的方法,用于将张量移动到指定的设备上(如 GPU)。
    • 参数 device

      • device 是一个变量,通常在代码中通过以下方式定义:
      • device = 'cuda' if torch.cuda.is_available() else 'cpu'
        • 如果有 GPU 可用(torch.cuda.is_available() 返回 True),device 的值为 'cuda',表示使用 GPU。
        • 如果没有 GPU,可用 device'cpu',表示使用 CPU。
    • 移动的作用

      • PyTorch 的计算设备需要一致,模型参数和输入数据必须在同一设备上(例如,不能一个在 GPU 上,另一个在 CPU 上)。
      • 这行代码确保 Xy 都移动到了指定的设备上(GPU 或 CPU),以便后续的模型计算。
  • 作用总结

    这行代码:

    • 确保模型输入(X)和目标标签(y)被移动到指定设备(device,例如 GPU 或 CPU)。
    • 避免设备不一致导致的运行错误。
    • 保证计算效率(如果有 GPU,加速计算;如果没有 GPU,默认使用 CPU)。

前向传播和损失计算

pred = model(X)
loss = loss_fn(pred.permute(0, 2, 1), y)

  • model(X):将输入 X 传入模型,得到预测结果 pred,形状为 [batch_size, sequence_length, num_labels]
  • permute(0, 2, 1):调整预测结果的形状为 [batch_size, num_labels, sequence_length],以适配损失函数 loss_fn
    • permute 是 PyTorch 张量(Tensor)的一个函数,不是参数。它的功能是用于重新排列维度顺序
  • pred是模型的输出,通常是一个多维张量。
    • permute 是 PyTorch 提供的张量方法,作用是根据参数重新排列维度。
    • 参数 (0, 2, 1) 指定了新维度的顺序:
      • 0:保持第一个维度(batch_size)。
      • 2:将原来的第三个维度(num_labels)调整到第二个位置。
      • 1:将原来的第二个维度(sequence_length)调整到第三个位置。
    • 经过调用后,pred 的形状从 [batch_size, sequence_length, num_labels] 转换为 [batch_size, num_labels, sequence_length]
  • loss_fn(pred, y):计算预测值 pred 与目标值 y 之间的损失。

反向传播与优化

optimizer.zero_grad()
loss.backward()
optimizer.step()
lr_scheduler.step()

  • optimizer.zero_grad():清零优化器中所有参数的梯度,以防止梯度累加。
  • loss.backward():对损失值 loss 进行反向传播,计算每个参数的梯度。
  • optimizer.step():根据梯度更新模型的参数。
  • lr_scheduler.step():更新学习率。

累计损失并更新进度条

total_loss += loss.item()
progress_bar.set_description(f'loss: {total_loss / (finish_batch_num + batch):7f}')
progress_bar.update(1)

  • total_loss += loss.item():将当前批次的损失累加到总损失中。
  • set_description:更新进度条的描述信息为当前的平均损失。
  • progress_bar.update(1):进度条前进一格。

返回总损失

return total_loss

  • 将累计的总损失返回,以便用于日志记录或进一步处理。

总结

这段代码的功能是实现一个完整的训练循环,核心包括以下步骤:

  1. 创建进度条:用于显示训练进度和实时损失信息。
  2. 数据加载与设备切换:将数据加载到 GPU 或 CPU。
  3. 前向传播:通过模型得到预测结果。
  4. 损失计算:调整预测结果的形状并计算损失。
  5. 反向传播与参数优化:更新模型参数。
  6. 累积损失与进度更新:实时记录损失值并更新进度条。

模型训练和验证

验证环节的评价标准

如何使用 seqeval 库对命名实体识别(NER)的模型进行评估,对模型的预测结果进行量化评估,明确其分类性能。

from seqeval.metrics import classification_report
from seqeval.scheme import IOB2

y_true = [['O', 'O', 'B-LOC', 'I-LOC', 'O', 'B-PER', 'I-PER', 'O']]
y_pred = [['O', 'O', 'B-LOC', 'I-LOC', 'O', 'B-PER', 'I-PER', 'O']]

print(classification_report(y_true, y_pred, mode='strict', scheme=IOB2))

导入
  • seqeval 是一个专门用于序列标注任务(如 NER)的 Python 库,支持多种标注格式(如 IOB, IOB2)。
  • 通过 classification_report 函数,计算模型的预测结果与真实标签的性能指标(如 Precision、Recall、F1-score)。
内容
  • 列表的内容采用了 IOB2 标注格式:
    • O:非实体(Outside)。
    • B-LOC:实体类型为 LOC 的开始位置(Begin)。
    • I-LOC:实体类型为 LOC 的后续部分(Inside)。
    • B-PERI-PER:实体类型为 PER 的开始和后续部分。
数据结构
  • [['O', 'O', 'B-LOC', 'I-LOC', 'O', 'B-PER', 'I-PER', 'O']]
    • 外层列表表示一个样本(句子)。
    • 内层列表表示该样本中的每个 token 的标签。
生成分类报告
print(classification_report(y_true, y_pred, mode='strict', scheme=IOB2))
作用
  • 使用 seqevalclassification_report 函数,生成一份分类报告,评估预测结果(y_pred)与真实标签(y_true)的差异。
  • 输出的报告包括以下指标:
    • Precision(精确率):模型预测的实体中有多少是真正正确的。
    • Recall(召回率):真实存在的实体中有多少被模型正确预测。
    • F1-score:精确率和召回率的调和平均值,综合衡量模型性能。
参数
  • y_true:真实标签。
  • y_pred:模型预测的标签。
  • mode='strict'
    • 指定评估模式为严格模式,只有在实体的类型和位置都完全匹配时,才认为预测正确。
  • scheme=IOB2
    • 指定标签格式为 IOB2(常见的序列标注格式)。
    • 例如,B-LOC 表示一个位置实体的开始,I-LOC 表示其后续部分。
核心任务

评估模型在命名实体识别任务中的性能:

  • 定义真实标签 y_true
  • 定义预测标签 y_pred
  • 使用 seqevalclassification_report 生成分类性能报告。
输出指标含义
  • Precision:预测为某实体类型的标签中,有多少是正确的。
  • Recall:真实存在的某实体类型的标签中,有多少被正确预测。
  • F1-score:精确率和召回率的调和平均值。
  • Support:每个标签类别的真实样本数量。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值