当前位置:   article > 正文

深度学习 - 53.Bert 简介与 Keras-Bert 常用示例

keras-bert

目录

一.引言

二.Bert 简介

1.Embedding Layer

2.Encoder layer

3.Pre-training 与 Fine-Tuning

三.Keras-Bert 常用 Demo

1.获取预训练模型

2.加载预训练模型

3.Fill Text 

4.IsCorrelation

5.Get Embedding

6.完整代码

四. Fine-Tuning

五.Bert VS OpenAI GPT

六.总结


一.引言

Bert 是 Google 在 18 年发布的 <Pre-training of Deep Bidirectional Transformers for Language Understanding> 中提到的预训练模型,其适用于 NLP 场景且可以基于自己的文本数据进行微调,下面简单介绍下其原理以及 Keras-Bert 的一些基本应用。

二.Bert 简介

Bert 主要由一个 Embedding 层和多个 Encoder 层堆叠而成,下面做简单介绍。

1.Embedding Layer

在构建 bert 输入时,会在每个 Text 的最前面增加一个特殊符号 '[CLS]',除此之外,不同 Sentence 之间还会加入 '[SEP]' 分隔符,标识一个句子的结束。

以上图的句子为例:

  1. sentence_1 = "my dog is cute"
  2. sentence_2 = "he likes playing"
  3. print('Tokens:', tokenizer.tokenize(first=sentence_1, second=sentence_2))
  4. indices, segments = tokenizer.encode(first=sentence_1, second=sentence_2)
  5. print('Indices:', indices)
  6. print('Segments:', segments)

Token 可以认为是分词以及增加特殊符号标识后的句子,通过 tokenizer 转换得到其 index。

Segment 代表每个字符属于第几个句子,上面第一个句子对应索引为0,第二个句子为1。

Position 代表每个字符在整个 Text 的位置信息索引。

Bert 实际使用中输入层使用了 Token Embedding、Segment Embedding 与 Position Embedding 三类向量,将每个类型的 Embedding concat 后再将对应位置的 Embedding 相加就得到原始的 Input。这里 Bert 预训练模型的 Embedding 维度为 768。

2.Encoder layer

Bert 在模型结构上主要使用了 Encoder 的堆叠,其中应用了 Multi-Head Attention 与 Scaled Dot-Product,关于 Multi-Head Attention 与 Scaled Dot Product 我们前面 Attention 的文章已经做了介绍。其可以看作是只使用了 Transformer 的 Encoder 结构,在 Input Embedding 的基础上 Add 了 Position Embedding,随后经过一层 Multi-Head Attention 并且将 Input 与 Attention 的结果相加,避免信息丢失,随后经过一层前馈网络,再执行一次 Add & Norm 进入下一个 Encoder。

  

3.Pre-training 与 Fine-Tuning

BERT 的全面预训练模型和微调模型。预训练和微调中除了输出层外其余部分使用了相同的架构。相同的预训练模型参数用于初始化不同下游任务的模型。在微调过程中,对所有参数进行微调。根据任务场景不同,Bert 可以完成不同的任务:

• Sentence Pair Classification Tasks 文本对分类任务

• Single Sentence Classification Tasks 单句分类任务

• Question Answering Task 问答任务

• Single Sentence Tagging Task 单句标记任务

上面不同类型任务图下给出了不同的数据集,可用于 Bert 模型的微调,大家也可以使用自己场景的文本问答、新闻报刊等数据进行训练。

 

三.Keras-Bert 常用 Demo

下述代码运行使用的环境为:

  1. import tensorflow as tf
  2. import platform
  3. print("TF: %s Keras: %s Python: %s" % (tf.__version__, tf.keras.__version__, platform.python_version()))
TF: 2.4.0-rc0 Keras: 2.4.0 Python: 3.8.6

1.获取预训练模型

一般情况下,我们主要使用预训练好的 Bert 进行一些基本的 NLP 任务或者通过 Fine-Tuning 对 Bert 进行一些微调,这里我们加载预训练模型 Bert-Base uncased_L-12_H-768_A-12,其模型名称代表 12-Layer、768-Hidden, 12-Heads 共计约 110M parameters,可以通过 wget 获取该预训练模型:

 wget -q https://storage.googleapis.com/bert_models/2018_10_18/uncased_L-12_H-768_A-12.zip

除了 Bert-Base 外,还有 Bert-Large,其包含 340M 个参数的模型,包含 24-Layer 以及 1024-Hidden 以及 16-Heads。一些基础的 Bert 模型可以到 Bert 的 github 选择下载:

 

2.加载预训练模型

  1. import numpy as np
  2. from keras_bert import load_vocabulary, load_trained_model_from_checkpoint, Tokenizer, get_checkpoint_paths
  3. from keras_bert.datasets import get_pretrained, PretrainedList
  4. class Bert:
  5. _token_dict = None # word ->index
  6. _token_dict_inv = None # index -> word
  7. _model = None
  8. _export_model = None
  9. _tokenizer = None
  10. def __init__(self, _model_path):
  11. self._model_path = model_path
  12. self.loadBertModel(self._model_path)
  13. self._tokenizer = Tokenizer(self._token_dict)
  14. def loadBertModel(self, path):
  15. paths = get_checkpoint_paths(path)
  16. # 加载预训练模型
  17. self._model = load_trained_model_from_checkpoint(paths.config, paths.checkpoint, training=True, seq_len=None)
  18. self._export_model = load_trained_model_from_checkpoint(paths.config, paths.checkpoint, seq_len=10)
  19. self._model.summary(line_length=120)
  20. # 字典映射与反映射
  21. self._token_dict = load_vocabulary(paths.vocab)
  22. self._token_dict_inv = {v: k for k, v in self._token_dict.items()}

通过 load_trained_model_from_checkpoint 读取预训练模型,seq_len = None 的模型主要处理不定长的 sentence 问题,seq_len 固定的 export 模型主要用于 Embedding 获取。token_dict 和 token_dict_inv 为 kv 相反的 map,token_dict 形式如下,共包含不同的符号 21128 个:

顺便调用 model.summary() 看下模型结构:

•  Embedding

包含 Token、Segment 与 Position Embedding,最后经过 Dropout 与 Norm 进入后续逻辑。

•  Encoder

将 Embedding 层得到的 Embedding-Norm 进行 MultiHeadSelfAttention,以及 Dropout 以及 Norm 随后接 FeedForward 前馈网络接着继续 Dropout。注意两个红框位置,MultiHeadAttention 与 FeedForward 后得到的向量斗鱼原始输入的向量进行了 Add 操作:

这里 Add 操作可以避免随着 Encoder 堆叠层数的增加,原始信息的不断衰减。

• Output

最后的输出层 MLM (masked) 代表 Masked Language Model 可以进行序列重建,NSP 代表 Next Sentence Prediction 可以进行句子间的二分类任务,例如判断后一句话与前一句话是否有关联。由于加载的是 Bert-Base,所以最后的 Layer 是 Encoder-12,且模型的 Total params 约为 102882442 即 100m。

3.Fill Text 

句子填字可以实现句子补全与填空,让 bert 填入最合适 [概率最大的词]。

  1. # 填字
  2. def fillText(self, _text, _st, _end):
  3. tokens = self._tokenizer.tokenize(_text)
  4. for i in range(_st, _end + 1):
  5. tokens[i] = '[MASK]'
  6. print('Tokens:', tokens)
  7. # 映射后的索引、代表句子、代表Mask掩码
  8. indices = np.array([[self._token_dict[token] for token in tokens]])
  9. segments = np.array([[0] * len(tokens)])
  10. masks = np.array([[0] * len(tokens)])
  11. for i in range(_st, _end + 1):
  12. masks[0][i] = 1
  13. # 填字预测
  14. predicts = self._model.predict([indices, segments, masks])[0].argmax(axis=-1).tolist()
  15. print('Fill with: ', list(map(lambda x: self._token_dict_inv[x], predicts[0][_st:_end + 1])))

text 为对应填词的文本,st 与 end 可以指定 mask 掩码的位置然后让 bert 在这个范围内进行填充,注意由于 bert Input Embedding 时会在开头添加 '[CLS]',所有对应位置的索引需要 +1,所以下面示例中 1、2 对应的是 '数学': 

  1. # 填充字符
  2. text = '数学是利用符号语言研究数量、结构、变化以及空间等概念的一门学科'
  3. st, end = 1, 2
  4. bert.fillText(text, st, end)
  5. st, end = 4, 5
  6. bert.fillText(text, st, end)

 原文的前五个字为 '数学是利用',我们分别将 '数学' 和 '利用' 进行 MASK 处理,这里分别填充了 '数学' 和 '使用',基本匹配:

 

4.IsCorrelation

是否相关判断 sentence_1 和 sentence_2 在语料训练后的文本场景下,前后语句是否有关联。

  1. # 两个句子是否有关联
  2. def hasCorrelation(self, _sentence_1, _sentence_2):
  3. indices, segments = self._tokenizer.encode(first=_sentence_1, second=_sentence_2)
  4. masks = np.array([[0] * len(indices)])
  5. predicts = self._model.predict([np.array([indices]), np.array([segments]), masks])[1]
  6. is_correlation = bool(np.argmax(predicts, axis=-1)[0])
  7. print('%s && %s is has Correlation: ' % (_sentence_1, _sentence_2), is_correlation)
  8. return is_correlation

 给定三个学科相关的语句,让 Bert-Base 判断前后语句是否存在关联:

  1. # 判断前后逻辑是否一致
  2. sentence_1 = '数学是利用符号语言研究數量、结构、变化以及空间等概念的一門学科。'
  3. sentence_2 = '从某种角度看屬於形式科學的一種。'
  4. sentence_3 = '任何一个希尔伯特空间都有一族标准正交基。'
  5. bert.hasCorrelation(sentence_1, sentence_2)
  6. bert.hasCorrelation(sentence_1, sentence_3)

sentence_1 与 sentence_2 无关,sentenct_1 与 sentenct_3 相关:

 

5.Get Embedding

上篇介绍多样性时介绍了 MMR 算法,其通过预训练向量计算不同 item 之间的 sim(i,j),这里 bert 就可以作为预训练 Embedding 获取的模型。

• 获取词向量

获取向量时需要用  expert_model,且 encode 的 max_len 参数要与模型的 max_len 参数对应。这里实际获取的向量维度为 768,大家也可以不使用 [:5] 截断。

  1. def getCharEmbedding(self, _text):
  2. tokens = self._tokenizer.tokenize(_text)
  3. print('Tokens:', tokens)
  4. indices, segments = self._tokenizer.encode(first=_text, max_len=10)
  5. predicts = self._export_model.predict([np.array([indices]), np.array([segments])])[0]
  6. for i, token in enumerate(tokens):
  7. print(token, predicts[i].tolist()[:5])

 

• 获取文本向量

  1. def getTextEmbedding(self, _text):
  2. tokens = self._tokenizer.tokenize(text)
  3. print('Tokens:', tokens)
  4. indices, segments = self._tokenizer.encode(first=text, max_len=10)
  5. predicts = self._export_model.predict([np.array([indices]), np.array([segments])])[0]
  6. pooled = np.array(np.sum(predicts, axis=0))
  7. print('Pooled:', pooled.shape)
  8. return pooled

虽然传入的 text 只有 6 个字符,但是默认了 max_len = 10, 所以 tokenizer 对原句进行了 Padding,最后 pooling 的方式不局限于 sum pooling ,也可以 mean_pooling、max_pooling 等,输出的向量维度为 max_len x 768,由于后四位是 padding 得来的,如果想要更准确的向量,可以通过 mask 擦除对应 padding 的向量。

MMR 时我们可以通过传入文本的内容再 getTextEmbedding 获取文本 Embedding 然后计算向量相似度,不过这里是 Base-Bert,如果有自己文本的训练微调,效果会更好。 

6.完整代码

  1. import numpy as np
  2. from keras_bert import load_vocabulary, load_trained_model_from_checkpoint, Tokenizer, get_checkpoint_paths
  3. from keras_bert.datasets import get_pretrained, PretrainedList
  4. class Bert:
  5. _token_dict = None # word ->index
  6. _token_dict_inv = None # index -> word
  7. _model = None
  8. _export_model = None
  9. _tokenizer = None
  10. def __init__(self, _model_path):
  11. self._model_path = model_path
  12. self.loadBertModel(self._model_path)
  13. self._tokenizer = Tokenizer(self._token_dict)
  14. def loadBertModel(self, path):
  15. paths = get_checkpoint_paths(path)
  16. # 加载预训练模型
  17. self._model = load_trained_model_from_checkpoint(paths.config, paths.checkpoint, training=True, seq_len=None)
  18. self._export_model = load_trained_model_from_checkpoint(paths.config, paths.checkpoint, seq_len=10)
  19. self._model.summary(line_length=120)
  20. # 字典映射与反映射
  21. self._token_dict = load_vocabulary(paths.vocab)
  22. self._token_dict_inv = {v: k for k, v in self._token_dict.items()}
  23. # 填字
  24. def fillText(self, _text, _st, _end):
  25. tokens = self._tokenizer.tokenize(_text)
  26. for i in range(_st, _end + 1):
  27. tokens[i] = '[MASK]'
  28. print('Tokens:', tokens)
  29. # 映射后的索引、代表句子、代表Mask掩码
  30. indices = np.array([[self._token_dict[token] for token in tokens]])
  31. segments = np.array([[0] * len(tokens)])
  32. masks = np.array([[0] * len(tokens)])
  33. for i in range(_st, _end + 1):
  34. masks[0][i] = 1
  35. # 填字预测
  36. predicts = self._model.predict([indices, segments, masks])[0].argmax(axis=-1).tolist()
  37. print('Fill with: ', list(map(lambda x: self._token_dict_inv[x], predicts[0][_st:_end + 1])))
  38. # 两个句子是否有关联
  39. def hasCorrelation(self, _sentence_1, _sentence_2):
  40. indices, segments = self._tokenizer.encode(first=_sentence_1, second=_sentence_2)
  41. masks = np.array([[0] * len(indices)])
  42. predicts = self._model.predict([np.array([indices]), np.array([segments]), masks])[1]
  43. is_correlation = bool(np.argmax(predicts, axis=-1)[0])
  44. print('%s && %s is has Correlation: ' % (_sentence_1, _sentence_2), is_correlation)
  45. return is_correlation
  46. def getCharEmbedding(self, _text):
  47. tokens = self._tokenizer.tokenize(_text)
  48. print('Tokens:', tokens)
  49. indices, segments = self._tokenizer.encode(first=_text, max_len=10)
  50. predicts = self._export_model.predict([np.array([indices]), np.array([segments])])[0]
  51. for i, token in enumerate(tokens):
  52. print(token, predicts[i].tolist()[:5])
  53. def getTextEmbedding(self, _text):
  54. tokens = self._tokenizer.tokenize(text)
  55. print('Tokens:', tokens)
  56. indices, segments = self._tokenizer.encode(first=text, max_len=10)
  57. print('Indices:', indices)
  58. print('Segments:', segments)
  59. predicts = self._export_model.predict([np.array([indices]), np.array([segments])])[0]
  60. pooled = np.array(np.sum(predicts, axis=0))
  61. print('Pooled:', pooled.shape)
  62. return pooled
  63. if __name__ == '__main__':
  64. # 获取模型路径
  65. model_path = get_pretrained(PretrainedList.chinese_base)
  66. # 加载模型
  67. bert = Bert(model_path)
  68. # 填充字符
  69. text = '数学是利用符号语言研究数量、结构、变化以及空间等概念的一门学科'
  70. st, end = 1, 2
  71. bert.fillText(text, st, end)
  72. st, end = 4, 5
  73. bert.fillText(text, st, end)
  74. # 判断前后逻辑是否一致
  75. sentence_1 = '数学是利用符号语言研究數量、结构、变化以及空间等概念的一門学科。'
  76. sentence_2 = '从某种角度看屬於形式科學的一種。'
  77. sentence_3 = '任何一个希尔伯特空间都有一族标准正交基。'
  78. bert.hasCorrelation(sentence_1, sentence_2)
  79. bert.hasCorrelation(sentence_1, sentence_3)
  80. # 获取字符 Embedding
  81. text = '语言模型'
  82. bert.getCharEmbedding(text)
  83. # 获取 Text Pooling 后的 Embedding
  84. bert.getTextEmbedding(text)

代码中给出了模型加载以及上面几个常用方法示例。

四. Fine-Tuning

Kreas-Bert 给出了基于 TPU 的 Imdb 数据的 Fine-Tuning 示例,由于原代码版本为 TF1,博主环境为 TF2,博主对代码进行了修改实现 TF2 的兼容,但是训练第一个 Epoch 时会无报错退出,这里给出代码,如果有可以跑通的同学可以评论区交流 ~

  1. import codecs
  2. from keras_bert import load_trained_model_from_checkpoint
  3. import os
  4. import numpy as np
  5. from tqdm import tqdm
  6. from keras_bert import Tokenizer
  7. import tensorflow as tf
  8. tf.compat.v1.disable_eager_execution()
  9. SEQ_LEN = 128
  10. BATCH_SIZE = 128
  11. EPOCHS = 5
  12. LR = 1e-4
  13. if __name__ == '__main__':
  14. pretrained_path = './uncased_L-12_H-768_A-12'
  15. config_path = os.path.join(pretrained_path, 'bert_config.json')
  16. checkpoint_path = os.path.join(pretrained_path, 'bert_model.ckpt')
  17. vocab_path = os.path.join(pretrained_path, 'vocab.txt')
  18. # TF_KERAS must be added to environment variables in order to use TPU
  19. os.environ['TF_KERAS'] = '1'
  20. token_dict = {}
  21. with codecs.open(vocab_path, 'r', 'utf8') as reader:
  22. for line in reader:
  23. token = line.strip()
  24. token_dict[token] = len(token_dict)
  25. # with strategy.scope():
  26. model = load_trained_model_from_checkpoint(
  27. config_path,
  28. checkpoint_path,
  29. training=True,
  30. trainable=True,
  31. seq_len=SEQ_LEN,
  32. )
  33. dataset = tf.keras.utils.get_file(
  34. fname="aclImdb.tar.gz",
  35. origin="http://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz",
  36. extract=True,
  37. )
  38. tokenizer = Tokenizer(token_dict)
  39. def load_data(path):
  40. global tokenizer
  41. indices, sentiments = [], []
  42. for folder, sentiment in (('neg', 0), ('pos', 1)):
  43. folder = os.path.join(path, folder)
  44. for name in tqdm(os.listdir(folder)):
  45. with open(os.path.join(folder, name), 'r') as reader:
  46. text = reader.read()
  47. ids, segments = tokenizer.encode(text, max_len=SEQ_LEN)
  48. indices.append(ids)
  49. sentiments.append(sentiment)
  50. items = list(zip(indices, sentiments))
  51. np.random.shuffle(items)
  52. indices, sentiments = zip(*items)
  53. indices = np.array(indices)
  54. mod = indices.shape[0] % BATCH_SIZE
  55. if mod > 0:
  56. indices, sentiments = indices[:-mod], sentiments[:-mod]
  57. return [indices, np.zeros_like(indices)], np.array(sentiments)
  58. train_path = os.path.join(os.path.dirname(dataset), 'aclImdb', 'train')
  59. test_path = os.path.join(os.path.dirname(dataset), 'aclImdb', 'test')
  60. train_x, train_y = load_data(train_path)
  61. test_x, test_y = load_data(test_path)
  62. from tensorflow.python import keras
  63. from keras_radam import RAdam
  64. inputs = model.inputs[:2]
  65. dense = model.get_layer('NSP-Dense').output
  66. outputs = keras.layers.Dense(units=2, activation='softmax')(dense)
  67. model = keras.models.Model(inputs, outputs)
  68. model.compile(
  69. RAdam(lr=LR),
  70. loss='sparse_categorical_crossentropy',
  71. metrics=['sparse_categorical_accuracy'],
  72. )
  73. sess = tf.compat.v1.keras.backend.get_session()
  74. uninitialized_variables = set([i.decode('ascii') for i in sess.run(tf.compat.v1.report_uninitialized_variables())])
  75. init_op = tf.compat.v1.variables_initializer(
  76. [v for v in tf.compat.v1.global_variables() if v.name.split(':')[0] in uninitialized_variables]
  77. )
  78. sess.run(init_op)
  79. model.fit(
  80. train_x,
  81. train_y,
  82. epochs=EPOCHS,
  83. batch_size=BATCH_SIZE,
  84. )
  85. predicts = model.predict(test_x, verbose=True).argmax(axis=-1)
  86. print(np.sum(test_y == predicts) / test_y.shape[0])

五.Bert VS OpenAI GPT

论文中给出了 Bert、OpenAI GPT 以及 ELMo 的预训练模型架构差异。 BERT 使用双向 Transformer、OpenAI GPT使用从左到右的 Transformer、ELMo 使用独立训练的从左到右和从右到左的 lstm 的连接来为下游任务生成特征。在这三种表示中,只有 BERT 同时以所有层中的左右上下文为条件。除了架构差异之外,BERT 和 OpenAI GPT 是一种微调方法,而 ELMo 是一种基于特性的方法。当时对标的 GPT 其实是 GPT-2,这里提供一个简单的 demo 供大家参考:

  1. #!/usr/bin/env Python
  2. # coding=utf-8
  3. from transformers import GPT2LMHeadModel, GPT2Tokenizer
  4. import torch
  5. # 初始化GPT2模型的Tokenizer类.
  6. tokenizer = GPT2Tokenizer.from_pretrained("gpt2")
  7. # 初始化GPT2模型, 此处以初始化GPT2LMHeadModel()类的方式调用GPT2模型.
  8. model = GPT2LMHeadModel.from_pretrained('gpt2')
  9. # GPT模型第一次迭代的输入的上下文内容, 将其编码以序列化.同时, generated也用来存储GPT2模型所有迭代生成的token索引.
  10. generated = tokenizer.encode("The Tower bridge") # generated: [11708] => [11708, 13]
  11. # 将序列化后的第一次迭代的上下文内容转化为pytorch中的tensor形式.
  12. context = torch.tensor([generated]) # context: tensor([[11708]])
  13. # 第一次迭代时还无past_key_values元组.
  14. past_key_values = None
  15. iter_num = 30
  16. for i in range(iter_num):
  17. '''
  18. 此时模型model返回的output为CausalLMOutputWithPastAndCrossAttentions类,
  19. 模型返回的logits以及past_key_values对象为其中的属性,
  20. CausalLMOutputWithPastAndCrossAttentions(
  21. loss=loss, # tensor([[[-34.2719, -33.7875, -36.4804, ..., -40.9417, -40.9629, -33.9127]]], grad_fn=<UnsafeViewBackward0>)
  22. logits=lm_logits,
  23. past_key_values=transformer_outputs.past_key_values, #
  24. hidden_states=transformer_outputs.hidden_states,
  25. attentions=transformer_outputs.attentions,
  26. cross_attentions=transformer_outputs.cross_attentions,
  27. )
  28. '''
  29. output = model(context, past_key_values=past_key_values)
  30. past_key_values = output.past_key_values
  31. # 此时获取GPT2模型计算的输出结果hidden_states张量中第二维度最后一个元素的argmax值, 得出的argmax值即为此次GPT2模型迭代
  32. # 计算生成的下一个token. 注意, 此时若是第一次迭代, 输出结果hidden_states张量的形状为(batch_size, sel_len, n_state);
  33. # 此时若是第二次及之后的迭代, 输出结果hidden_states张量的形状为(batch_size, 1, n_state), all_head_size=n_state=nx=768.
  34. logits = output.logits
  35. v = logits[..., -1, :]
  36. token = torch.argmax(output.logits[..., -1, :]) # token: tensor(13)
  37. # 将本次迭代生成的token的张量变为二维张量, 以作为下一次GPT2模型迭代计算的上下文context.
  38. context = token.unsqueeze(0)
  39. # 将本次迭代计算生成的token的序列索引变为列表存入generated
  40. generated += [token.tolist()]
  41. # 将generated中所有的token的索引转化为token字符.
  42. sequence = tokenizer.decode(generated)
  43. sequence = sequence.split(".")[:-1]
  44. print(sequence)

给定 'The Tower Bridge' 和迭代次数,GPT-2 会不断迭代并通过 argmax 获取概率最大的词并 += 添加至 generated 中,最后将 generated decode 得到文本:

['The Tower bridge is a great place to get to know the city', " It's a great place to get to know the city"]

六.总结

昨天 MMR 算法中提到了 Bert 生成文本向量,今天趁热打铁简单介绍下 Bert。工业场景下,可以利用 Bert 和 GPT 进行业务场景的微调并应用于文案、文案 Embedding 生成等任务。

参考:

google-research - bert

Pre-training of Deep Bidirectional Transformers for Language Understanding

CyberZHG / keras-bert Public

更多推荐算法相关深度学习:深度学习导读专栏 

声明:本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:【wpsshop博客】
推荐阅读
相关标签
  

闽ICP备14008679号