BERT使用手冊

transformers是huggingface提供的預(yù)訓(xùn)練模型庫挪鹏,可以輕松調(diào)用API來得到你的詞向量圾旨。transformers的前身有pytorch-pretrained-bert妙黍,pytorch-transformers稚铣,原理基本都一致衰伯。本文以bert為例,主要介紹如何調(diào)用transformers庫以及下游任務(wù)的使用方法积蔚。

1. transformers相關(guān)配置

  • 在正式使用之前意鲸,首先要安裝transformers包,此以python3.7為例:

    python == 3.7.3
    tensorflow == 2.0.0
    pytorch == 1.5.1
    transformers == 3.0.2
    

    若準(zhǔn)備采用GPU加速尽爆,需自于Pytorch怎顾、Tensorflow官網(wǎng)上進行CUDA、CuDNN版本遴選配置

2. 整理架構(gòu)

  • transformers新版本中使用每個模型只需要三個標(biāo)準(zhǔn)類

    • configuration:configuration是模型具體的結(jié)構(gòu)配置漱贱,例如可以配置多頭的數(shù)量等槐雾,這里配置需要注意的地方就是,如果自定義配置不改變核心網(wǎng)絡(luò)結(jié)構(gòu)的則仍舊可以使用預(yù)訓(xùn)練模型權(quán)重幅狮,如果配置涉及到核心結(jié)構(gòu)的修改募强,例如前饋網(wǎng)絡(luò)的隱層神經(jīng)元的個數(shù),則無法使用預(yù)訓(xùn)練模型權(quán)重崇摄,這個時候transformers會默認你要重新自己預(yù)訓(xùn)練一個模型從而隨機初始化整個模型的權(quán)重擎值,這是是一種半靈活性的設(shè)計

      # configuration.json
      {
        "architectures": [
          "BertForMaskedLM"
        ],
        "attention_probs_dropout_prob": 0.1,
        "hidden_act": "gelu",
        "hidden_dropout_prob": 0.1,
        "hidden_size": 768,
        "initializer_range": 0.02,
        "intermediate_size": 3072,
        "max_position_embeddings": 512,
        "num_attention_heads": 12,
        "num_hidden_layers": 12,
        "type_vocab_size": 2,
        "vocab_size": 30522
      }
      

      將配置好的configuration.json配置加載如下:

      config = BertConfig.from_json_file(os.path.join(PATH, "config.json"))
      model = BertModel.from_pretrained(PATH, config=config)
      
      #Tensorflow2版本
      from transformers import TFBertModel
      import tensorflow as tf
      config = BertConfig.from_json_file(os.path.join(PATH, "config.json"))
      model = TFBertModel.from_pretrained(PATH, config=config)
      
    • models:models用于指定使用哪一種模型,例如model為bert逐抑,則相應(yīng)的網(wǎng)絡(luò)結(jié)構(gòu)為bert的網(wǎng)絡(luò)結(jié)構(gòu)

      # Pytorch版本
      import torch
      from transformers import BertModel, BertConfig, BertTokenizer
      model = BertModel.from_pretrained("bert-base-uncased")
      
      # Tensorflow2版本
      import tensorflow as tf 
      from transformers import TFBertModel, BertConfig, BertTokenizer
      model = TFBertModel.from_pretrained("bert-base-uncased")
      
    • tokenizer

      # Pytorch版本
      import torch
      from transformers import BertModel, BertConfig, BertTokenizer
      tokenizer = BertTokenizer.from_pretrained("bert-base-uncased")
      
      # Tensorflow2版本
      import tensorflow as tf 
      from transformers import TFBertModel, BertConfig, BertTokenizer
      tokenizer = BertTokenizer.from_pretrained("bert-base-uncased")
      

      所有這些類都可以使用通用的from_pretrained()實例化方法鸠儿,以簡單統(tǒng)一的方式從受過訓(xùn)練的實例中初始化

3. 參數(shù)詳解

3.1 配置 Bert 模型 transformers.PretrainedConfig

  • transformers.BertConfig 可以自定義 Bert 模型的結(jié)構(gòu),以下參數(shù)都是可選的:

    • 通用參數(shù)
      • vocab_size:詞匯數(shù)厕氨,默認30522
      • hidden_size:編碼器內(nèi)隱藏層神經(jīng)元數(shù)量进每,默認768
      • num_hidden_layers:編碼器內(nèi)隱藏層層數(shù),默認12
      • num_attention_heads:編碼器內(nèi)注意力頭數(shù)命斧,默認12
    • 其他參數(shù)
      • intermediate_size:編碼器內(nèi)全連接層的輸入維度田晚,默認3072
      • hidden_act:編碼器內(nèi)激活函數(shù),默認"gelu"国葬,還可為"relu"肉瓦、"swish"或 "gelu_new"
      • hidden_dropout_prob:詞嵌入層或編碼器的 dropout,默認為0.1
      • attention_probs_dropout_prob:注意力機制的 dropout胃惜,默認為0.1
      • max_position_embeddings:模型使用的最大序列長度泞莉,默認為512
      • type_vocab_size:詞匯表類別,默認為2
      • initializer_range:神經(jīng)元權(quán)重的標(biāo)準(zhǔn)差船殉,默認為0.02
      • layer_norm_eps:layer normalization 的 epsilon 值鲫趁,默認為 1e-12
  • transformers.BertConfig.from_json_file可以從json文件中進行配置參數(shù)的修改:

    transformers.BertConfig.from_json_file(json_file)
    
  • transformers.BertConfig.from_dict可以從字典中進行配置參數(shù)的修改

  • transformers.BertConfig.from_pretrained可以從預(yù)訓(xùn)練模型中進行配置參數(shù)的配置

    transformers.BertConfig.from_pretrained(pretrained_model_name_or_path)
    
  • Examples:

    config = BertConfig.from_pretrained('bert-base-uncased')
    config = BertConfig.from_pretrained('./test/saved_model/')
    config = BertConfig.from_pretrained('./test/saved_model/my_configuration.json')
    config = BertConfig.from_pretrained('bert-base-uncased', output_attentions=True, foo=False)
    

3.2 加載 Bert 模型 transformers.PreTrainedModel

  • transformers.BertModel.from_pretrained 從預(yù)訓(xùn)練的模型配置實例化預(yù)訓(xùn)練的 pytorch 模型

  • BertModel 主要為transformer encoder結(jié)構(gòu),包含三個部分:

    • embeddings利虫,即BertEmbeddings類的實體挨厚,根據(jù)單詞符號獲取對應(yīng)的向量表示堡僻;

    • encoder,即BertEncoder類的實體疫剃;

    • pooler钉疫,即BertPooler類的實體,這一部分是可選的巢价。

      transformers.BertModel.from_pretrained(pretrained_model_name_or_path)
      
      #Tensorflow2版本
      transformers.TFBertModel.from_pretrained(pretrained_model_name_or_path)
      
  • BertModel前向傳播過程中各個參數(shù)的含義以及返回值:

    • input_ids:經(jīng)過 tokenizer 分詞后的 subword 對應(yīng)的下標(biāo)列表牲阁;
    • attention_mask:在 self-attention 過程中,這一塊 mask 用于標(biāo)記 subword 所處句子和
      padding 的區(qū)別壤躲,將 padding 部分填充為 0城菊;
    • token_type_ids:標(biāo)記 subword 當(dāng)前所處句子(第一句/第二句/ padding);
    • position_ids:標(biāo)記當(dāng)前詞所在句子的位置下標(biāo)碉克;
    • head_mask:用于將某些層的某些注意力計算無效化凌唬;
    • inputs_embeds:如果提供了,那就不需要input_ids漏麦,跨過 embedding lookup 過程直接作為 Embedding 進入 Encoder 計算客税;
    • encoder_hidden_states:這一部分在 BertModel 配置為 decoder 時起作用,將執(zhí)行 cross-attention 而不是 self-attention撕贞;
    • encoder_attention_mask:同上霎挟,在 cross-attention 中用于標(biāo)記 encoder 端輸入的 padding;
    • past_key_values:這個參數(shù)貌似是把預(yù)先計算好的 K-V 乘積傳入麻掸,以降低 cross-attention 的開銷(因為原本這部分是重復(fù)計算)酥夭;
    • use_cache:將保存上一個參數(shù)并傳回,加速 decoding脊奋;
    • output_attentions:是否返回中間每層的 attention 輸出熬北;
    • output_hidden_states:是否返回中間每層的輸出;
    • return_dict:是否按鍵值對的形式(ModelOutput 類诚隙,也可以當(dāng)作 tuple 用)返回輸出讶隐,默認為真。
      注意久又,這里的 head_mask 對注意力計算的無效化巫延,和下文提到的注意力頭剪枝不同,而僅僅把某些注意力的計算結(jié)果給乘以這一系數(shù)地消。
  • Examples:

from transformers import BertConfig, BertModel
# 從huggingface.co 下載模型和配置并緩存炉峰。
model = BertModel.from_pretrained('bert-base-uncased')
model = BertModel.from_pretrained('./test/saved_model/')
# 在加載期間更新配置
model = BertModel.from_pretrained('bert-base-uncased', output_attentions=True)
assert model.config.output_attentions == True
config = BertConfig.from_json_file('./tf_model/my_tf_model_config.json')
model = BertModel.from_pretrained('./tf_model/my_tf_checkpoint.ckpt.index', from_tf=True, config=config)
# Loading from a Flax checkpoint file instead of a PyTorch model (slower)
model = BertModel.from_pretrained('bert-base-uncased', from_flax=True)

3.3 優(yōu)化器 Optimization

3.3.1transformers.AdamW
  • AdamW:transformers 庫實現(xiàn)了基于權(quán)重衰減的優(yōu)化器,這個優(yōu)化器初始化時有6個參數(shù)脉执,第一個是params疼阔,可以是torch的Parameter,也可以是一個grouped參數(shù)。betas是Adam的beta參數(shù)婆廊,b1和b2迅细。eps也是Adam為了數(shù)值穩(wěn)定的參數(shù)。correct_bias淘邻,如果應(yīng)用到tf的模型上時需要設(shè)置為False茵典。具體參數(shù)配置如下:
    • params ( Iterable[nn.parameter.Parameter]) :可迭代的參數(shù)以優(yōu)化或定義參數(shù)組的字典
    • lr ( float, optional , defaults to 1e-3) :要使用的學(xué)習(xí)率
    • betas ( Tuple[float,float], optional , defaults to (0.9, 0.999)) :Adam 的 betas 參數(shù) (b1, b2)
    • eps ( float, optional , defaults to 1e-6) :Adam's epsilon 數(shù)值穩(wěn)定性
    • weight_decay ( float, optional , defaults to 0) :要應(yīng)用的解耦權(quán)重衰減
    • Correct_bias ( bool, optional , defaults to True ) :是否糾正 Adam 中的偏差
3.3.2 transformers.Adafactor
  • Adafactor:具有次線性記憶代價的自適應(yīng)學(xué)習(xí)率 。請注意宾舅,此優(yōu)化器根據(jù)scale參數(shù)统阿、相對步長和warmup_init選項在內(nèi)部調(diào)整學(xué)習(xí)速率。要使用手動(外部)學(xué)習(xí)速率計劃贴浙,您應(yīng)將scale參數(shù)設(shè)置為False,相對步驟設(shè)置為False署恍。具體參數(shù)配置如下:
    • params (Iterable[nn.parameter.Parameter]):用于優(yōu)化的參數(shù)的Iterable或定義參數(shù)組的字典崎溃。
    • lr (float, optional) :要使用的學(xué)習(xí)率
    • eps (Tuple[float, float], optional, defaults to (1e-30, 1e-3)) :平方梯度和參數(shù)比例的正則化常數(shù)
    • clip_threshold (float, optional, defaults 1.0):最終梯度更新的均方根閾值
    • decay_rate (float, optional, defaults to -0.8):用于計算平方的運行平均值的系數(shù)
    • beta1 (float, optional) :用于計算梯度運行平均值的系數(shù)
    • weight_decay (float, optional, defaults to 0):權(quán)重衰減(L2懲罰)
    • scale_parameter (bool, optional, defaults to True):如果為True,則學(xué)習(xí)率按均方根進行縮放
    • relative_step (bool, optional, defaults to True):如果為True盯质,則計算與時間相關(guān)的學(xué)習(xí)率袁串,而不是外部學(xué)習(xí)率
    • warmup_init (bool, optional, defaults to False) :時間相關(guān)的學(xué)習(xí)速率計算取決于是否使用預(yù)熱初始化
3.3.3transformers.AdamWeightDecay(Tensorflow2)
  • AdamWeightDecay:transformers 庫實現(xiàn)的對于TF2模型的基于權(quán)重衰減的優(yōu)化器,這個優(yōu)化器初始化時有10個參數(shù)呼巷,第一個是learning_rate囱修,用于設(shè)置基礎(chǔ)學(xué)習(xí)率。接下來是Adam的衰減穩(wěn)定設(shè)置 beta_1,beta_2與epsilon王悍。amsgrad決定是否應(yīng)用Amsgrad破镰,weight_decay_rate決定了衰減率, include_in_weight_decay決定了應(yīng)用權(quán)重衰減的參數(shù)名字压储,exclude_from_weight_decay決定了不參與權(quán)重衰減的參數(shù)名字鲜漩,name和kwargs是tensorflow優(yōu)化器的常規(guī)參數(shù)。具體參數(shù)配置如下:
    • learning_rate ( float, optional, default to 1e-3 ) :基礎(chǔ)學(xué)習(xí)率
    • beta_1, beta_2, epsilon ( float, optional , defaults to 0.9, 0.999, 1e-7 ) :Adam設(shè)置參數(shù)
    • amsgrad ( bool, optional, default to False ) :決定是否應(yīng)用Amsgrad
    • weight_decay_rate ( float, optional , defaults to 0 ) :權(quán)重衰減率
    • include_in_decay ( List[str], optional) :應(yīng)用權(quán)重衰減的參數(shù)名字
    • exclude_from_weight_deacy ( List[str],optional ) :不參與權(quán)重衰減的參數(shù)名字
    • name ( str, optional, defaults to ‘AdamWeightDecay’ ) :名稱
3.3.4 Examples:
from transformers.optimization import Adafactor, AdafactorSchedule
optimizer = Adafactor(model.parameters(), scale_parameter=True, relative_step=True, warmup_init=True, lr=None)
lr_scheduler = AdafactorSchedule(optimizer)
***training step***
    optimizer.step()
    scheduler.step()

3.4 加載分詞模型 transformers.PreTrainedTokenizer

  • 所有分詞器的基類:處理tokenization和special tokens的所有共享方法集惋,以及下載/緩存/加載預(yù)訓(xùn)練標(biāo)記器的方法以及向詞匯表中添加標(biāo)記的方法孕似。

    • BasicTokenizer負責(zé)處理的第一步——按標(biāo)點、空格等分割句子刮刑,并處理是否統(tǒng)一小寫喉祭,以及清理非法字符
    • 對于中文字符,通過預(yù)處理(加空格)來按字分割雷绢;同時可以通過never_split指定對某些詞不進行分割泛烙;這一步是可選的(默認執(zhí)行)
    • Word_Piece_Tokenizer在詞的基礎(chǔ)上,進一步將詞分解為子詞(subword)翘紊。subword 介于 char 和 word 之間胶惰,既在一定程度保留了詞的含義,又能夠照顧到英文中單復(fù)數(shù)霞溪、時態(tài)導(dǎo)致的詞表爆炸和未登錄詞的 OOV(Out-Of-Vocabulary)問題孵滞,將詞根與時態(tài)詞綴等分割出來中捆,從而減小詞表,也降低了訓(xùn)練難度
    • 例如坊饶,tokenizer 這個詞就可以拆解為"token"和"##izer"兩部分泄伪,注意后面一個詞的"##"表示接在前一個詞后面。
  • BertTokenizer有以下常用方法:

    • vocab_file (str):包含詞匯表的文件
    • do_lower_case (bool, optional, defaults to True):標(biāo)記化時是否將輸入小寫
    • do_basic_tokenize (bool, optional, defaults to True):是否在WordPiece之前進行基本分詞化
    • never_split (Iterable, optional):在標(biāo)記化過程中永遠不會分割的標(biāo)記集合匿级。僅當(dāng)do_basic_tokenize=True時才有效
    • unk_token (str, optional, defaults to "[UNK]"):未知標(biāo)記蟋滴。不在詞匯表中的令牌無法轉(zhuǎn)換為ID,而是設(shè)置為此標(biāo)記
    • sep_token (str, optional, defaults to "[SEP]"):分隔符標(biāo)記痘绎,用于從多個序列構(gòu)建序列津函,例如用于序列分類的兩個序列或用于文本和用于問答的問題。它還用作使用特殊令牌構(gòu)建的序列的最后一個標(biāo)記
    • pad_token (str, optional, defaults to "[PAD]"):用于填充的標(biāo)記孤页,例如在批處理不同長度的序列時補充
    • cls_token (str, optional, defaults to "[CLS]"):進行序列分類時使用的分類器標(biāo)記(對整個序列進行分類尔苦,而不是按令牌分類)。當(dāng)使用特殊標(biāo)記構(gòu)建時行施,它是序列的第一個標(biāo)記允坚。
    • mask_token (str, optional, defaults to "[MASK]"):用于MASK值的標(biāo)記。這是使用掩碼語言建模訓(xùn)練此模型時使用的標(biāo)記蛾号,作為模型將嘗試預(yù)測的標(biāo)記
  • Example:

    # 獲取最后一層隱層的embedding
    from transformers import BertTokenizer, BertModel
    import torch
    
    tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
    model = BertModel.from_pretrained('bert-base-uncased')
    
    inputs = tokenizer("Hello, my dog is cute", return_tensors="pt")
    inputs = torch.tensor([inputs])
    outputs = model(**inputs)
    
    last_hidden_states = outputs.last_hidden_state
    

3.5 BERT的應(yīng)用微調(diào)

  • 將數(shù)據(jù)集轉(zhuǎn)換為可以訓(xùn)練BERT的格式

    • BERT令牌生成器

      • 要將文本提供給BERT稠项,必須將其拆分為令牌,然后將這些令牌映射到令牌生成器詞匯表中的索引

        tokenizer = BertTokenizer.from_pretrained('bert-base-uncased', do_lower_case=True)
        sentence = "Hello, my son is cuting."
        input_ids_method1 = torch.tensor(
            tokenizer.encode(sentence, add_special_tokens=True))  # Batch size 1
            # tensor([ 101, 7592, 1010, 2026, 2365, 2003, 3013, 2075, 1012,  102])
        input_token2 = tokenizer.tokenize(sentence)
        # ['hello', ',', 'my', 'son', 'is', 'cut', '##ing', '.']
        input_ids_method2 = tokenizer.convert_tokens_to_ids(input_token2)
        # tensor([7592, 1010, 2026, 2365, 2003, 3013, 2075, 1012])
        # 并沒有開頭和結(jié)尾的標(biāo)記:[cls]鲜结、[sep]
        
    • 特殊令牌添加

      • 在每個句子的末尾展运,我們需要附加特殊[SEP]標(biāo)記,該令牌是兩句任務(wù)的產(chǎn)物精刷,其中給BERT兩個單獨的句子并要求確定某些內(nèi)容
      • 對于分類任務(wù)乐疆,必須[CLS]每個句子的開頭添加特殊標(biāo)記。此令牌具有特殊意義贬养。BERT由12個Transformer層組成挤土。每個轉(zhuǎn)換器接收一個令牌嵌入列表,并在輸出上產(chǎn)生相同數(shù)量的嵌入
      • 句子長度與掩碼,BERT有兩個制約因素
        • 所有句子都必須填充或截斷為單個固定長度
        • 句子的最大長度為512個令牌
        • 使用特殊[PAD]令牌完成填充,該令牌在BERT詞匯表中的索引為0處
    • examples:

      # Tokenize all of the sentences and map the tokens to thier word IDs.
      input_ids = []
      attention_masks = []
      
      # For every sentence...
      for sent in sentences:
          # `encode_plus` will:
          #   (1) Tokenize the sentence.
          #   (2) Prepend the `[CLS]` token to the start.
          #   (3) Append the `[SEP]` token to the end.
          #   (4) Map tokens to their IDs.
          #   (5) Pad or truncate the sentence to `max_length`
          #   (6) Create attention masks for [PAD] tokens.
          encoded_dict = tokenizer.encode_plus(
                              sent,                      # Sentence to encode.
                              add_special_tokens = True, # Add '[CLS]' and '[SEP]'
                              max_length = 64,           # Pad & truncate all sentences.
                              pad_to_max_length = True,
                              return_attention_mask = True,   # Construct attn. masks.
                              return_tensors = 'pt',     # Return pytorch tensors.
                         )
          # Add the encoded sentence to the list.    
          input_ids.append(encoded_dict['input_ids'])
      
          # And its attention mask (simply differentiates padding from non-padding).
          attention_masks.append(encoded_dict['attention_mask'])
      
      # Convert the lists into tensors.
      input_ids = torch.cat(input_ids, dim=0)
      attention_masks = torch.cat(attention_masks, dim=0)
      labels = torch.tensor(labels)
      
      # Print sentence 0, now as a list of IDs.
      print('Original: ', sentences[0])
      print('Token IDs:', input_ids[0])
      '''
      Original : Our friends won't buy this analysis, let alone the next one we propose.
      Token IDs: 
                  tensor([  101,  2256,  2814,  2180,  1005,  1056,  4965,  2023,  4106,  1010,
                           2292,  2894,  1996,  2279,  2028,  2057, 16599,  1012,   102,     0,
                              0,     0,     0,     0,     0,     0,     0,     0,     0,     0,
                              0,     0,     0,     0,     0,     0,     0,     0,     0,     0,
                              0,     0,     0,     0,     0,     0,     0,     0,     0,     0,
                              0,     0,     0,     0,     0,     0,     0,     0,     0,     0,
                              0,     0,     0,     0])
      '''
      

      后續(xù)直接將BERT進行模型訓(xùn)練被丧、模型存儲即可

4.1 模型應(yīng)用案例

BertForSequenceClassification(文本分類)

本文將在這一節(jié)重點介紹Bert的全系使用方法,包括工具引入咖杂、數(shù)據(jù)處理、計算資源選擇蚊夫、模型訓(xùn)練與測試使用诉字。由于Bert不同任務(wù)存在重疊性,后續(xù)講不再贅述重合部分,而是專注于網(wǎng)絡(luò)結(jié)構(gòu)壤圃。

  • 所需引入工具包

    import random
    import torch
    from torch.utils.data import TensorDataset, DataLoader, random_split
    from transformers import BertTokenizer
    from transformers import BertForSequenceClassification, AdamW
    from transformers import get_linear_schedule_with_warmup
    
  • 使用計算資源為GPU還是CPU(Bert建議使用GPU啟動加載陵霉,否則訓(xùn)練過程過慢),以及所用seed確定

    # device可選擇cuda | cpu
    device = torch.device('cuda')
    # 隨機種子數(shù)值確定
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)
    torch.backends.cudnn.deterministic = True
    
  • 加載數(shù)據(jù)

    # pandas讀取數(shù)據(jù)[sentence, type]
    data = pd.read_pickle("XXX.csv")
    # sentences
    text_values = list(df(['sentence']))
    # label
    label_sample = list(df['type'])
    
  • 加載bert模型

    # 加載預(yù)訓(xùn)練分詞模型
    tokenizer = BertTokenizer.from_pretrained('bert-base-chinese', do_lower_case=False)
    # 加載預(yù)訓(xùn)練Bert模型
    model = BertForSequenceClassification.from_pretrained(
        'bert-base-uncased', 
        num_labels=num_labels, 
        output_attentions=False, 
        output_hidden_states=False
    )
    # 決定是否將模型推送到GPU
    model.cuda()
    
  • 訓(xùn)練數(shù)據(jù)準(zhǔn)備

    # 函數(shù)獲取文本列表的令牌ID
    def encode_fn(text_list):
        all_input_ids = []    
        for text in text_list:
            input_ids = tokenizer.encode(
                            text,                      
                            add_special_tokens = True,  # 添加special tokens伍绳, 也就是CLS和SEP
                            max_length = 160,           # 設(shè)定最大文本長度
                            pad_to_max_length = True,   # pad到最大的長度  
                            return_tensors = 'pt'       # 返回的類型為pytorch tensor
                       )
            all_input_ids.append(input_ids)    
        all_input_ids = torch.cat(all_input_ids, dim=0)
        return all_input_ids
    all_input_ids = encode_fn(text_values)
    
  • 講數(shù)據(jù)分為訓(xùn)練集與驗證集踊挠,并構(gòu)建dataloader

    epochs = 4
    batch_size = 32
    
    # 將數(shù)據(jù)拆分為訓(xùn)練集和驗證集
    dataset = TensorDataset(all_input_ids, labels)
    # 此處采用9:1的數(shù)據(jù)集構(gòu)建
    train_size = int(0.90 * len(dataset))
    val_size = len(dataset) - train_size
    train_dataset, val_dataset = random_split(dataset, [train_size, val_size])
    
    # 創(chuàng)建訓(xùn)練和驗證數(shù)據(jù)集的DataLoader
    train_dataloader = DataLoader(train_dataset, batch_size = batch_size, shuffle = True)
    val_dataloader = DataLoader(val_dataset, batch_size = batch_size, shuffle = False)
    
  • 定義Bert訓(xùn)練所需optimizer與learning rate scheduler

    # 創(chuàng)建優(yōu)化器和學(xué)習(xí)率計劃
    optimizer = AdamW(model.parameters(), lr=2e-5)
    total_steps = len(train_dataloader) * epochs
    scheduler = get_linear_schedule_with_warmup(optimizer, num_warmup_steps=0, num_training_steps=total_steps)
    
  • 定義一個可視化指標(biāo)計算的方法,此處以sklearn的accuracy為例

    from sklearn.metrics import f1_score, accuracy_score
    def flat_accuracy(preds, labels):   
        pred_flat = np.argmax(preds, axis=1).flatten()
        labels_flat = labels.flatten()
        return accuracy_score(labels_flat, pred_flat)
    
  • Bert的訓(xùn)練與驗證

    for epoch in range(epochs):
        # 訓(xùn)練集過程
        model.train()
        total_loss, total_val_loss = 0, 0
        total_eval_accuracy = 0
        for step, batch in enumerate(train_dataloader):
            model.zero_grad()
            loss, logits = model(batch[0].to(device), token_type_ids=None, attention_mask=(batch[0]>0).to(device), labels=batch[1].to(device))
            total_loss += loss.item()
            loss.backward()
            torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
            optimizer.step() 
            scheduler.step()
        # 測試集過程
        model.eval()
        for i, batch in enumerate(val_dataloader):
            with torch.no_grad():
                loss, logits = model(batch[0].to(device), token_type_ids=None, attention_mask=(batch[0]>0).to(device), labels=batch[1].to(device))
                total_val_loss += loss.item()
                logits = logits.detach().cpu().numpy()
                label_ids = batch[1].to('cpu').numpy()
                total_eval_accuracy += flat_accuracy(logits, label_ids)
        avg_train_loss = total_loss / len(train_dataloader)
        avg_val_loss = total_val_loss / len(val_dataloader)
        avg_val_accuracy = total_eval_accuracy / len(val_dataloader)
        print(f'Train loss     : {avg_train_loss}')
        print(f'Validation loss: {avg_val_loss}')
        print(f'Accuracy: {avg_val_accuracy:.2f}')
        print('\n')
    '''
    輸出格式
    Train loss     : 0.3275374324204257
    Validation loss: 0.3286557973672946
    Accuracy: 0.88
    '''
    
  • 模型預(yù)測過程冲杀,數(shù)據(jù)集構(gòu)建參考訓(xùn)練集dataloader

    model.eval()
    preds = []
    for i, (batch,) in enumerate(pred_dataloader):
        with torch.no_grad():
            outputs = model(batch.to(device), token_type_ids=None, attention_mask=(batch>0).to(device))
            logits = outputs[0]
            logits = logits.detach().cpu().numpy()
            preds.append(logits)
    final_preds = np.concatenate(preds, axis=0)
    final_preds = np.argmax(final_preds, axis=1)
    '''
    輸出格式如下效床,后續(xù)按照標(biāo)簽編號回推即可
    1 0 0 1 0
    '''
    

BertForSequenceClassification(文本分類 Tensorflow2)

Tensorflow2上應(yīng)用預(yù)訓(xùn)練模型進行文本分類的一個簡單例子。

  • 準(zhǔn)備過程权谁,加載模型和數(shù)據(jù)

    import tensorflow as tf
    import tensorflow_datasets
    from transformers import *
    tokenizer = BertTokenizer.from_pretrained('bert-base-cased')
    model = TFBertForSequenceClassification.from_pretrained('bert-base-cased')
    data = tensorflow_datasets.load('glue/mrpc')
    
  • 訓(xùn)練數(shù)據(jù)準(zhǔn)備

    train_dataset = glue_convert_examples_to_features(data['train'], tokenizer, 128, 'mrpc')
    valid_dataset = glue_convert_examples_to_features(data['validation'], tokenizer, 128, 'mrpc')
    train_dataset = train_dataset.shuffle(100).batch(32).repeat(2)
    valid_dataset = valid_dataset.batch(64)
    
  • 優(yōu)化器設(shè)置以及訓(xùn)練

    optimizer = tf.keras.optimizer.Adam(learning_rate=3e-5, epsilon=1e-8, clipnorm=1.0)
    metric = tf.keras.metrics.SparseCategoricalAccuracy('accuracy')
    loss = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)
    model.compile(optimizer=optimizer, loss=loss, metrics=[metric])
    
    model.fit(train_dataset, epochs=2, steps_per_epoch=115, validation_data=valid_dataset, validation_step=7)
    
  • 模型預(yù)測

    sentence_0 = "This research was consistent with his findings"
    sentence_1 = "his findings were compatible with this research"
    sentence_2 = "his findings were not compatible with this research"
    inputs1 = tokenizer.encoder_plus(sentence_0, sentence_1, add_special_tokens=True, return_tensors='pt')
    inputs2 = tokenizer.encoder_plus(sentence_0, sentence_2, add_special_tokens=True, return_tensors='pt')
    
    pred1 = tf.argmax(model.predict(**inputs1)[0])
    pred2 = tf.argmax(model.predict(**inputs2)[0])
    

BertForTokenClassification(token分類)

以NER任務(wù)為例剩檀,命名實體識別任務(wù)是NLP中的一個基礎(chǔ)任務(wù)。主要是從一句話中識別出命名實體旺芽,下為Bert+CRF的模型結(jié)構(gòu)沪猴,訓(xùn)練過程與數(shù)據(jù)預(yù)處理部分參考上章內(nèi)容,后續(xù)不再贅述甥绿,此處展示兩種BERT使用方法

  • 常規(guī)方案:直接使用Bert作為底層embedding字币,后續(xù)拼接其他網(wǎng)絡(luò)则披,其網(wǎng)絡(luò)結(jié)構(gòu)如下:

    import torch
    from transformers import BertModel, BertPreTrainedModel
    import torch.nn as nn
    from torchcrf import CRF
    
    class BertNER(BertPreTrainedModel):
        def __init__(self, config):
            super(BertNER, self).__init__(config)
            self.num_labels = config.num_labels
            self.bert = BertModel(config)
            self.dropout = nn.Dropout(config.hidden_dropout_prob)
            self.classifier = nn.Linear(config.hidden_size, config.num_labels)
            self.crf = CRF(config.num_labels, batch_first=True)
            self.init_weights()
    
        def forward(self, input_ids, token_type_ids=None, attention_mask=None, labels=None,
                    position_ids=None, inputs_embeds=None, head_mask=None):
            outputs = self.bert(input_ids=input_ids,
                                attention_mask=attention_mask,
                                token_type_ids=token_type_ids,
                                position_ids=position_ids,
                                head_mask=head_mask,
                                inputs_embeds=inputs_embeds)
            sequence_output = outputs[0]
            sequence_output = self.dropout(sequence_output)
            # 得到判別值
            logits = self.classifier(sequence_output)
            outputs = (logits,)
            if labels is not None:
                if attention_mask is not None:
                    active_loss = attention_mask.view(-1) == 1
                    active_logits = logits.view(-1, self.num_labels)
                    active_labels = torch.where(
                        active_loss, labels.view(-1), torch.tensor(-100).type_as(labels)
                    )  # [-100, -100, 1, 0...]
                else:
                    active_logits = logits.view(-1, self.num_labels)
                    active_labels = labels.view(-1)
                select_index = []
                final_labels = []
                for index, label in enumerate(active_labels):
                    if label != -100:
                        final_labels.append(label)
                        select_index.append(index)
                device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
                active_logits.to(device)
                select_index = torch.tensor(select_index, device=active_logits.device)
                final_labels = torch.tensor(final_labels, device=active_logits.device).unsqueeze(0)
                final_logits = active_logits.index_select(0, select_index).unsqueeze(0)
                loss = self.crf(final_logits, final_labels) * (-1)
                outputs = (loss,) + outputs
            # contain: (loss), scores
            return outputs
    
  • 直接使用BertForTokenClassification類進行序列標(biāo)注共缕,可對其模型進行修改拼接,其模型結(jié)構(gòu)如下

    import torch
    from transformers import BertForTokenClassification
    
    class TokenClassifier(BertForTokenClassification):
        def forward(self, input_ids, attention_mask=None, token_type_ids=None,
                    labels=None, valid_ids=None, attention_mask_label=None):
            sequence_output = self.bert(input_ids, attention_mask, token_type_ids,
                                        head_mask=None)[0]
            batch_size, max_len, feat_dim = sequence_output.shape
            valid_output = torch.zeros(
                batch_size, max_len, feat_dim, dtype=torch.float32,
                device=next(self.parameters()).device
            )
            for i in range(batch_size):
                jj = -1
                for j in range(max_len):
                    if valid_ids[i][j].item() == 1:
                        jj += 1
                        valid_output[i][jj] = sequence_output[i][j]
            sequence_output = self.dropout(valid_output)
            logits = self.classifier(sequence_output)
    
            if labels is not None:
                loss_fct = torch.nn.CrossEntropyLoss(ignore_index=0)
                attention_mask_label = None
                if attention_mask_label is not None:
                    active_loss = attention_mask_label.view(-1) == 1
                    active_logits = logits.view(-1, self.num_labels)[active_loss]
                    active_labels = labels.view(-1)[active_loss]
                    loss = loss_fct(active_logits, active_labels)
                else:
                    loss = loss_fct(logits.view(-1, self.num_labels),
                                    labels.view(-1))
                return (loss, logits)
            else:
                return (logits,)
    

BertForTokenClassification(QA得分)

此類適用于問答系統(tǒng)士复,輸入中將上面幾個模型中的label改成了start_position和end_position图谷,即答案在原文中起始和結(jié)束位置,輸出是將預(yù)測分?jǐn)?shù)改成了對答案起始位置和結(jié)束位置的預(yù)測分?jǐn)?shù)阱洪。下面為直接使用預(yù)訓(xùn)練模型的代碼參考

  • 準(zhǔn)備過程便贵,加載模型

    from transformers import BertTokenizer, BertForQuestionAnswering
    model = BertForQuestionAnswering.from_pretrained(MODEL_PATH)
    tokenizer = BertTokenizer.from_pretrained(VOCAB_PATH)
    
  • 保留概率最大的答案

    with torch.no_grad():
        model.eval()
        pred_results = {}
        for batch in tqdm(test_dataloader):
            q_ids, raw_sentence, input_ids, segment_ids = batch
            input_ids, segment_ids = \
            input_ids.to(device), segment_ids.to(device)
            input_mask = (input_ids > 0).to(device)
            start_prob, end_prob = model(input_ids.to(device),
                                         token_type_ids=segment_ids.to(device),
                                         attention_mask=input_mask.to(device)
                                        )
            # start_prob = start_prob.squeeze(0)
            # end_prob = end_prob.squeeze(0)
            for i in range(len(batch[0])):
                try:
                    (best_start, best_end), max_prob = find_best_answer_for_passage(start_prob[i], end_prob[i])
                    if type(max_prob) == int:
                        max_prob = 0
                    else:
                        max_prob = max_prob.cpu().numpy()[0]
                except:
                    pass
                if q_ids[i] in pred_results:
                    pred_results[q_ids[i]].append(
                        (raw_sentence[i][best_start.cpu().numpy()[0]:best_end.cpu().numpy()[0]], max_prob))
                else:
                    pred_results[q_ids[i]] = [(raw_sentence[i][best_start.cpu().numpy()[0]:best_end.cpu().numpy()[0]], max_prob)]
        # 保留最大概率的答案
        for id in pred_results:
            pred_results[id] = sorted(pred_results[id], key=lambda x: x[1], reverse=True)[0]
    
        submit = {}
        for item in test_data:
            q_id = item[0]
            question = item[2]
            if q_id not in pred_results:continue
            submit[q_id] = pred_results[q_id][0].strip()
            print(question, pred_results[q_id][0].strip())
    

5.1 Bert原理簡介

BERT的全稱為Bidirectional Encoder Representation from Transformers,是一個預(yù)訓(xùn)練的語言表征模型冗荸。它強調(diào)了不再像以往一樣采用傳統(tǒng)的單向語言模型或者把兩個單向語言模型進行淺層拼接的方法進行預(yù)訓(xùn)練承璃,而是采用新的masked language model(MLM),以致能生成深度的雙向語言表征蚌本。BERT論文發(fā)表時提及在11個NLP(Natural Language Processing盔粹,自然語言處理)任務(wù)中獲得了新的state-of-the-art的結(jié)果,令人目瞪口呆程癌。

該模型有以下主要優(yōu)點:

  • 采用MLM對雙向的Transformers進行預(yù)訓(xùn)練舷嗡,以生成深層的雙向語言表征
  • 預(yù)訓(xùn)練后,只需要添加一個額外的輸出層進行fine-tune嵌莉,就可以在各種各樣的下游任務(wù)中取得state-of-the-art的表現(xiàn)进萄。在這過程中并不需要對BERT進行任務(wù)特定的結(jié)構(gòu)修改。

5.1.1 Bert結(jié)構(gòu)

以往的預(yù)訓(xùn)練模型的結(jié)構(gòu)會受到單向語言模型(從左到右或者從右到左)的限制,因而也限制了模型的表征能力中鼠,使其只能獲取單方向的上下文信息可婶。而BERT利用MLM進行預(yù)訓(xùn)練并且采用深層的雙向Transformer組件(單向的Transformer一般被稱為Transformer decoder,其每一個token(符號)只會attend到目前往左的token兜蠕。而雙向的Transformer則被稱為Transformer encoder扰肌,其每一個token會attend到所有的token)來構(gòu)建整個模型,因此最終生成能融合左右上下文信息的深層雙向語言表征熊杨。關(guān)于Transformer的詳細解釋可以參見Attention Is All You Need 或者 The Illustrated Transformer曙旭,經(jīng)過多層Transformer結(jié)構(gòu)的堆疊后,形成BERT的主體結(jié)構(gòu)

img

5.1.2 模型輸入

BERT的輸入為每一個token對應(yīng)的表征(圖中的粉紅色塊就是token晶府,黃色塊就是token對應(yīng)的表征)桂躏,并且單詞字典是采用WordPiece算法來進行構(gòu)建的。為了完成具體的分類任務(wù)川陆,除了單詞的token之外剂习,作者還在輸入的每一個序列開頭都插入特定的分類token([CLS]),該分類token對應(yīng)的最后一個Transformer層輸出被用來起到聚集整個序列表征信息的作用较沪。

由于BERT是一個預(yù)訓(xùn)練模型鳞绕,其必須要適應(yīng)各種各樣的自然語言任務(wù),因此模型所輸入的序列必須有能力包含一句話(文本情感分類尸曼,序列標(biāo)注任務(wù))或者兩句話以上(文本摘要们何,自然語言推斷,問答任務(wù))控轿。那么如何令模型有能力去分辨哪個范圍是屬于句子A冤竹,哪個范圍是屬于句子B呢?BERT采用了兩種方法去解決:

  • 在序列tokens中把分割token([SEP])插入到每個句子后茬射,以分開不同的句子tokens
  • 為每一個token表征都添加一個可學(xué)習(xí)的分割embedding來指示其屬于句子A還是句子B

上面提到了BERT的輸入為每一個token對應(yīng)的表征鹦蠕,實際上該表征是由三部分組成的,分別是對應(yīng)的token在抛,分割位置 embeddings(位置embeddings的詳細解釋可參見Attention Is All You NeedThe Illustrated Transformer)钟病,如下圖:

preview

5.1.3 模型輸出

C為分類token([CLS])對應(yīng)最后一個Transformer的輸出,T_i則代表其他token對應(yīng)最后一個Transformer的輸出刚梭。對于一些token級別的任務(wù)(如肠阱,序列標(biāo)注和問答任務(wù)),就把T_i輸入到額外的輸出層中進行預(yù)測望浩。對于一些句子級別的任務(wù)(如辖所,自然語言推斷和情感分類任務(wù)),就把C輸入到額外的輸出層中磨德,這里也就解釋了為什么要在每一個token序列前都要插入特定的分類token缘回。

5.1.4 Bert的預(yù)訓(xùn)練任務(wù)

  • Masked Language Model(MLM):是BERT能夠不受單向語言模型所限制的原因吆视。簡單來說就是以15%的概率用[MASK]隨機地對每一個訓(xùn)練序列中的token進行替換,然后預(yù)測出[MASK]位置原有的單詞酥宴。然而啦吧,由于[MASK]并不會出現(xiàn)在下游任務(wù)的微調(diào)(fine-tuning)階段,因此預(yù)訓(xùn)練階段和微調(diào)階段之間產(chǎn)生了不匹配拙寡。因此BERT采用了以下策略來解決這個問題:

    首先在每一個訓(xùn)練序列中以15%的概率隨機地選中某個token位置用于預(yù)測授滓,假如是第i個token被選中,則會被替換成以下三個token之一

    • 80%的時候是[MASK]肆糕,如my dog is hairy——>my dog is [MASK]
    • 10%的時候是隨機的其他token般堆,如my dog is hairy——>my dog is apple
    • 10%的時候是原來的token,如my dog is hairy——>my dog is hairy
  • Next Sentence Prediction(NSP):MLM任務(wù)傾向于抽取token層次的表征诚啃,因此不能直接獲取句子層次的表征淮摔。為了使模型能夠有能力理解句子間的關(guān)系,BERT使用了NSP任務(wù)來預(yù)訓(xùn)練始赎,簡單來說就是預(yù)測兩個句子是否連在一起和橙。具體的做法是:對于每一個訓(xùn)練樣例,我們在語料庫中挑選出句子A和句子B來組成造垛,50%的時候句子B就是句子A的下一句魔招,剩下50%的時候句子B是語料庫中的隨機句子。接下來把訓(xùn)練樣例輸入到BERT模型中五辽,用[CLS]對應(yīng)的C信息去進行二分類的預(yù)測办斑。

5.2 Bert的優(yōu)缺點

5.2.1 BERT存在哪些優(yōu)缺點?

  • 優(yōu)點:

    • 能夠獲取上下文相關(guān)的雙向特征表示
  • 缺點:

    • 生成任務(wù)表現(xiàn)不佳:預(yù)訓(xùn)練過程和生成過程的不一致奔脐,導(dǎo)致在生成任務(wù)上效果不佳
    • 采取獨立性假設(shè):沒有考慮預(yù)測[MASK]之間的相關(guān)性俄周,是對語言模型聯(lián)合概率的有偏估計(不是密度估計)
    • 輸入噪聲[MASK]吁讨,造成預(yù)訓(xùn)練-精調(diào)兩階段之間的差異
    • 無法文檔級別的NLP任務(wù)髓迎,只適合于句子和段落級別的任務(wù)

ALBERT:提出了兩種參數(shù)縮減技術(shù),以降低內(nèi)存消耗并提高BERT的訓(xùn)練速度

XLNet:XLnet是Transformer XL模型的一個擴展建丧,該模型使用自回歸方法預(yù)先訓(xùn)練排龄,通過最大化輸入序列分解順序的所有排列的期望似然來學(xué)習(xí)雙向上下文

Roberta:建立在BERT的基礎(chǔ)上,修改關(guān)鍵的超參數(shù)翎朱,刪除next-sentence pretraining的預(yù)訓(xùn)練目標(biāo)橄维,并以更大的mini-batches和learning rates進行訓(xùn)練

GPT2:超大規(guī)模語料上通過transformer結(jié)構(gòu)進行的無監(jiān)督訓(xùn)練的語言模型,適用于各項生成任務(wù)

img
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末拴曲,一起剝皮案震驚了整個濱河市争舞,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌澈灼,老刑警劉巖竞川,帶你破解...
    沈念sama閱讀 216,402評論 6 499
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件店溢,死亡現(xiàn)場離奇詭異,居然都是意外死亡委乌,警方通過查閱死者的電腦和手機床牧,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,377評論 3 392
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來遭贸,“玉大人戈咳,你說我怎么就攤上這事『敬担” “怎么了著蛙?”我有些...
    開封第一講書人閱讀 162,483評論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長耳贬。 經(jīng)常有香客問我册踩,道長,這世上最難降的妖魔是什么效拭? 我笑而不...
    開封第一講書人閱讀 58,165評論 1 292
  • 正文 為了忘掉前任暂吉,我火速辦了婚禮,結(jié)果婚禮上缎患,老公的妹妹穿的比我還像新娘慕的。我一直安慰自己,他們只是感情好挤渔,可當(dāng)我...
    茶點故事閱讀 67,176評論 6 388
  • 文/花漫 我一把揭開白布肮街。 她就那樣靜靜地躺著,像睡著了一般判导。 火紅的嫁衣襯著肌膚如雪嫉父。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,146評論 1 297
  • 那天眼刃,我揣著相機與錄音绕辖,去河邊找鬼。 笑死擂红,一個胖子當(dāng)著我的面吹牛仪际,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播昵骤,決...
    沈念sama閱讀 40,032評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼树碱,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了变秦?” 一聲冷哼從身側(cè)響起成榜,我...
    開封第一講書人閱讀 38,896評論 0 274
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎蹦玫,沒想到半個月后赎婚,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體雨饺,經(jīng)...
    沈念sama閱讀 45,311評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,536評論 2 332
  • 正文 我和宋清朗相戀三年惑淳,在試婚紗的時候發(fā)現(xiàn)自己被綠了额港。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 39,696評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡歧焦,死狀恐怖移斩,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情绢馍,我是刑警寧澤向瓷,帶...
    沈念sama閱讀 35,413評論 5 343
  • 正文 年R本政府宣布,位于F島的核電站舰涌,受9級特大地震影響猖任,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜瓷耙,卻給世界環(huán)境...
    茶點故事閱讀 41,008評論 3 325
  • 文/蒙蒙 一朱躺、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧搁痛,春花似錦长搀、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至彻况,卻和暖如春谁尸,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背纽甘。 一陣腳步聲響...
    開封第一講書人閱讀 32,815評論 1 269
  • 我被黑心中介騙來泰國打工良蛮, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人贷腕。 一個月前我還...
    沈念sama閱讀 47,698評論 2 368
  • 正文 我出身青樓背镇,卻偏偏與公主長得像咬展,于是被迫代替她去往敵國和親泽裳。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,592評論 2 353

推薦閱讀更多精彩內(nèi)容