[TorchText]使用

只是教程的搬運工-.-

Field的使用

Torchtext采用聲明式方法加載數據,需要先聲明一個Field對象,這個Field對象指定你想要怎么處理某個數據,each Field has its own Vocab class。

  • tokenize傳入一個函數,表示如何將文本str變成token
  • sequential表示是否切分數據,如果數據已經是序列化的了而且是數字類型的,則應該傳遞參數use_vocab = Falsesequential = False
    除了上面提到的關鍵字參數之外,Field類還允許用戶指定特殊標記(用于標記詞典外詞語的unk_token,用于填充的pad_token,用于句子結尾的eos_token以及用于句子開頭的可選的init_token)。設置將第一維是batch還是sequence(第一維默認是sequence),并選擇是否允許在運行時決定序列長度還是預先就決定好,Field類的文檔
from torchtext.data import Field
tokenize = lambda x: x.split()

TEXT = Field(sequential=True, tokenize=tokenize, lower=True)
LABEL = Field(sequential=False, use_vocab=False)

使用spacy進行tokenizer,

import spacy
spacy_en = spacy.load('en')

def tokenizer(text): # create a tokenizer function
    return [tok.text for tok in spacy_en.tokenizer(text)]

TEXT = data.Field(sequential=True, tokenize=tokenizer, lower=True)
LABEL = data.Field(sequential=False, use_vocab=False)

構建Dataset

Fields知道怎么處理原始數據,現在我們需要告訴Fields去處理哪些數據。這就是我們需要用到Dataset的地方。Torchtext中有各種內置Dataset,用于處理常見的數據格式。 對于csv/tsv文件,TabularDataset類很方便。 以下是我們如何使用TabularDataset從csv文件讀取數據的示例:

from torchtext.data import TabularDataset

tv_datafields = [("id", None), # 我們不會需要id,所以我們傳入的filed是None
                 ("comment_text", TEXT), ("toxic", LABEL),
                 ("severe_toxic", LABEL), ("threat", LABEL),
                 ("obscene", LABEL), ("insult", LABEL),
                 ("identity_hate", LABEL)]
trn, vld = TabularDataset.splits(
               path="data", # 數據存放的根目錄
               train='train.csv', validation="valid.csv",
               format='csv',
               skip_header=True, # 如果你的csv有表頭, 確保這個表頭不會作為數據處理
               fields=tv_datafields)

tst_datafields = [("id", None), # 我們不會需要id,所以我們傳入的filed是None
                  ("comment_text", TEXT)]
tst = TabularDataset(
           path="data/test.csv", # 文件路徑
           format='csv',
           skip_header=True, # 如果你的csv有表頭, 確保這個表頭不會作為數據處理
           fields=tst_datafields)
  • 我們傳入(name,field)對的列表作為fields參數。我們傳入的fields必須與列的順序相同。對于我們不使用的列,我們在fields的位置傳入一個None。
  • splits方法通過應用相同的處理為訓練數據和驗證數據創建Dataset。 它也可以處理測試數據,但由于測試數據與訓練數據和驗證數據有不同的格式,因此我們創建了不同的Dataset。
  • 數據集大多可以和list一樣去處理。 為了理解這一點,我們看看Dataset內部是怎么樣的。 數據集可以像list一樣進行索引和迭代,所以讓我們看看第一個元素是什么樣的:
>>> trn[0]
<torchtext.data.example.Example at 0x10d3ed3c8>

>>> trn[0].__dict__.keys()
dict_keys(['comment_text', 'toxic', 'severe_toxic', 'threat', 'obscene', 'insult', 'identity_hate'])

>>> trn[0].comment_text[:3]
['explanation', 'why', 'the']

在一個TabularDataset里面直接指定train,test,validation,好像更為方便一些。

from torchtext import data
train, val, test = data.TabularDataset.splits(
        path='./data/', train='train.tsv',
        validation='val.tsv', test='test.tsv', format='tsv',
        fields=[('Text', TEXT), ('Label', LABEL)])

詞表

Torchtext將單詞映射為整數,但必須告訴它應該處理的全部單詞。 在我們的例子中,我們可能只想在訓練集上建立詞匯表,所以我們運行代碼:TEXT.build_vocab(trn)。這使得torchtext遍歷訓練集中的所有元素,檢查TEXT字段的內容,并將其添加到其詞匯表中。Torchtext有自己的Vocab類來處理詞匯。Vocab類在stoi屬性中包含從word到id的映射,并在其itos屬性中包含反向映射。 除此之外,它可以為word2vec等預訓練的embedding自動構建embedding矩陣。Vocab類還可以使用像max_size和min_freq這樣的選項來表示詞匯表中有多少單詞或單詞出現的次數。未包含在詞匯表中的單詞將被轉換成<unk>。
TEXT.build_vocab(train, vectors="glove.6B.100d"),可以給vectors直接傳入一個字符串類型的,那么會自動下載你需要的詞向量,存放的位置是./.vector_cache 的文件夾下,或者可以使用類vocab.Vectors指定你自己的詞向量。

Note you can directly pass in a string and it will download pre-trained word vectors and load them for you. You can also use your own vectors by using this class vocab.Vectors. The downloaded word embeddings will stay at ./.vector_cache folder. I have not yet discovered a way to specify a custom location to store the downloaded vectors (there should be a way right?).

將預訓練的詞向量加載到模型中。

from torchtext import data
TEXT.build_vocab(train, vectors="glove.6B.100d")
vocab = TEXT.vocab
self.embed = nn.Embedding(len(vocab), emb_dim)
self.embed.weight.data.copy_(vocab.vectors)

從詞表變回單詞

先安裝工具,先從 GitHub上git clone代碼然后進入目錄安裝,因為pip安裝的不是最新版本

cd revtok/
python setup.py install

使用時候只需要使用可翻轉的Field代替原來的Field就可以創建雙向的轉換的Vocb了。

from torchtext import data
TEXT = data.ReversibleField(sequential=True, lower=True, include_lengths=True)
...
for data in valid_iter:
        (x, x_lengths), y = data.Text, data.Description
        orig_text = TEXT.reverse(x.data)

對于預訓練單詞表中沒有的單詞我們可以隨機進行初始化。

  • 在構建Field的時候設置include_lengths字段為True可以在返回minibatch的時候同時返回一個表示每個句子長度的list。

include_lengths: Whether to return a tuple of a padded minibatch and
a list containing the lengths of each examples, or just a padded
minibatch. Default: False.

隨機初始化未知單詞。

def init_emb(vocab, init="randn", num_special_toks=2):
    emb_vectors = vocab.vectors
    sweep_range = len(vocab)
    running_norm = 0.
    num_non_zero = 0
    total_words = 0
    for i in range(num_special_toks, sweep_range):
        if len(emb_vectors[i, :].nonzero()) == 0:
            # std = 0.05 is based on the norm of average GloVE 100-dim word vectors
            if init == "randn":
                torch.nn.init.normal(emb_vectors[i], mean=0, std=0.05)
        else:
            num_non_zero += 1
            running_norm += torch.norm(emb_vectors[i])
        total_words += 1
    logger.info("average GloVE norm is {}, number of known words are {}, total number of words are {}".format(
        running_norm / num_non_zero, num_non_zero, total_words))

構建迭代器

在torchvision和PyTorch中,數據的處理和批處理由DataLoaders處理。 出于某種原因,torchtext相同的東西又命名成了Iterators。 基本功能是一樣的,但我們將會看到,Iterators具有一些NLP特有的便捷功能。

  • 對于驗證集和訓練集合使用BucketIterator.splits(),目的是自動進行shuffle和padding,并且為了訓練效率期間,盡量把句子長度相似的shuffle在一起。
  • 對于測試集用Iterator,因為不用sort
  • sort 是對全體數據按照升序順序進行排序,而sort_within_batch僅僅對一個batch內部的數據進行排序。
  • sort_within_batch參數設置為True時,按照sort_key按降序對每個小批次內的數據進行降序排序。當你想對padded序列使用pack_padded_sequence轉換為PackedSequence對象時,這是必需的。
  • 注意sortshuffle默認只是對train=True字段進行的,但是train字段默認是True。所以測試集合可以這么寫testIter = Iterator(tst, batch_size = 64, device =-1, train=False)寫法等價于下面的一長串寫法。
  • repeat 是否連續的訓練無數個batch ,默認是False
  • device 可以是torch.device
from torchtext.data import Iterator, BucketIterator

train_iter, val_iter = BucketIterator.splits((trn, vld), 
                                             # 我們把Iterator希望抽取的Dataset傳遞進去
                                             batch_sizes=(25, 25),
                                             device=-1, 
                                             # 如果要用GPU,這里指定GPU的編號
                                             sort_key=lambda x: len(x.comment_text), 
                                             # BucketIterator 依據什么對數據分組
                                             sort_within_batch=False,
                                             repeat=False)
                                             # repeat設置為False,因為我們想要包裝這個迭代器層。
test_iter = Iterator(tst, batch_size=64, 
                     device=-1, 
                     sort=False, 
                     sort_within_batch=False, 
                     repeat=False)

BucketIterator是torchtext最強大的功能之一。它會自動將輸入序列進行shuffle并做bucket。這個功能強大的原因是——正如我前面提到的——我們需要填充輸入序列使得長度相同才能批處理。 例如,序列

[ [3, 15, 2, 7], 
  [4, 1], 
  [5, 5, 6, 8, 1] ]

會需要pad成

[ [3, 15, 2, 7, 0],
  [4, 1, 0, 0, 0],
  [5, 5, 6, 8, 1] ]

填充量由batch中最長的序列決定。因此,當序列長度相似時,填充效率最高。BucketIterator會在在后臺執行這些操作。需要注意的是,你需要告訴BucketIterator你想在哪個數據屬性上做bucket。在我們的例子中,我們希望根據comment_text字段的長度進行bucket處理,因此我們將其作為關鍵字參數傳入sort_key = lambda x: len(x.comment_text)

train_iter, val_iter, test_iter = data.BucketIterator.splits(
        (train, val, test), sort_key=lambda x: len(x.Text),
        batch_sizes=(32, 256, 1), device=-1)

BucketIteratorIterator的區別是,BucketIterator盡可能的把長度相似的句子放在一個batch里面。

Defines an iterator that batches examples of similar lengths together

封裝迭代器

目前,迭代器返回一個名為torchtext.data.Batch的自定義數據類型。Batch類具有與Example類相似的API,將來自每個字段的一批數據作為屬性。

>>> train_iter
[torchtext.data.batch.Batch of size 25]
    [.comment_text]:[torch.LongTensor of size 494x25]
    [.toxic]:[torch.LongTensor of size 25]
    [.severe_toxic]:[torch.LongTensor of size 25]
    [.threat]:[torch.LongTensor of size 25]
    [.obscene]:[torch.LongTensor of size 25]
    [.insult]:[torch.LongTensor of size 25]
    [.identity_hate]:[torch.LongTensor of size 25]
>>> train_iter.__dict__.keys()
dict_keys(['batch_size', 'dataset', 'fields', 'comment_text', 'toxic', 'severe_toxic', 'threat', 'obscene', 'insult', 'identity_hate'])
>>> train_iter.comment_text
tensor([[  15,  606,  280,  ...,   15,   63,   15],
        [ 360,  693,   18,  ...,   29,    4,    2],
        [  45,  584,   14,  ...,   21,  664,  645],
        ...,
        [   1,    1,    1,  ...,   84,    1,    1],
        [   1,    1,    1,  ...,  118,    1,    1],
        [   1,    1,    1,  ...,   15,    1,    1]])
>>> train_iter.toxic
tensor([ 0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,
         0,  0,  1,  0,  1,  0,  1,  0,  0,  0,  0])

不幸的是,這種自定義數據類型使得代碼重用變得困難(因為每次列名發生變化時,我們都需要修改代碼),并且使torchtext在某些情況(如torchsample和fastai)下很難與其他庫一起使用。
我希望這可以在未來得到優化(我正在考慮提交PR,如果我可以決定API應該是什么樣的話),但同時,我們使用簡單的封裝來使batch易于使用。

具體來說,我們將把batch轉換為形式為(x,y)的元組,其中x是自變量(模型的輸入),y是因變量(標簽數據)。 代碼如下:

class BatchWrapper:
    def __init__(self, dl, x_var, y_vars):
        self.dl, self.x_var, self.y_vars = dl, x_var, y_vars # 傳入自變量x列表和因變量y列表

    def __iter__(self):
        for batch in self.dl:
            x = getattr(batch, self.x_var) # 在這個封裝中只有一個自變量

            if self.y_vars is not None: # 把所有因變量cat成一個向量
                temp = [getattr(batch, feat).unsqueeze(1) for feat in self.y_vars]
                y = torch.cat(temp, dim=1).float()
            else:
                y = torch.zeros((1))

            yield (x, y)

    def __len__(self):
        return len(self.dl)

train_dl = BatchWrapper(train_iter, "comment_text", ["toxic", "severe_toxic", "obscene", "threat", "insult", "identity_hate"])
valid_dl = BatchWrapper(val_iter, "comment_text", ["toxic", "severe_toxic", "obscene", "threat", "insult", "identity_hate"])
test_dl = BatchWrapper(test_iter, "comment_text", None)

我們在這里所做的是將Batch對象轉換為輸入和輸出的元組。

>>> next(train_dl.__iter__())
(tensor([[  15,   15,   15,  ...,  375,  354,   44],
         [ 601,  657,  360,  ...,   27,   63,  739],
         [ 242,   22,   45,  ...,  526,    4,    3],
         ...,
         [   1,    1,    1,  ...,    1,    1,    1],
         [   1,    1,    1,  ...,    1,    1,    1],
         [   1,    1,    1,  ...,    1,    1,    1]]),
 tensor([[ 0.,  0.,  0.,  0.,  0.,  0.],
         [ 0.,  0.,  0.,  0.,  0.,  0.],
         [ 0.,  0.,  0.,  0.,  0.,  0.],
         [ 0.,  0.,  0.,  0.,  0.,  0.],
         [ 1.,  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.,  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.],
         [ 1.,  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.],
         [ 1.,  1.,  0.,  1.,  1.,  0.],
         [ 0.,  0.,  0.,  0.,  0.,  0.]]))

訓練模型

我們將使用一個簡單的LSTM來演示如何根據我們構建的數據來訓練文本分類器:

import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.autograd import Variable

class SimpleLSTMBaseline(nn.Module):
    def __init__(self, hidden_dim, emb_dim=300, num_linear=1):
        super().__init__() 
        # 詞匯量是 len(TEXT.vocab)
        self.embedding = nn.Embedding(len(TEXT.vocab), emb_dim)
        self.encoder = nn.LSTM(emb_dim, hidden_dim, num_layers=1)
        self.linear_layers = []
        # 中間fc層
        for _ in range(num_linear - 1):
            self.linear_layers.append(nn.Linear(hidden_dim, hidden_dim))
            self.linear_layers = nn.ModuleList(self.linear_layers)
        # 輸出層
        self.predictor = nn.Linear(hidden_dim, 6)

    def forward(self, seq):
        hdn, _ = self.encoder(self.embedding(seq))
        feature = hdn[-1, :, :]  # 選擇最后一個output
        for layer in self.linear_layers:
          feature = layer(feature)
        preds = self.predictor(feature)
        return preds

em_sz = 100
nh = 500
model = SimpleBiLSTMBaseline(nh, emb_dim=em_sz) 

現在,我們將編寫訓練循環。 多虧我們所有的預處理,讓這變得非常簡單非常簡單。我們可以使用我們包裝的Iterator進行迭代,并且數據在移動到GPU和適當數字化后將自動傳遞給我們。

import tqdm

opt = optim.Adam(model.parameters(), lr=1e-2)
loss_func = nn.BCEWithLogitsLoss()

epochs = 2

for epoch in range(1, epochs + 1):
    running_loss = 0.0
    running_corrects = 0
    model.train() # 訓練模式
    for x, y in tqdm.tqdm(train_dl): # 由于我們的封裝,我們可以直接對數據進行迭代
        opt.zero_grad()
        preds = model(x)
        loss = loss_func(y, preds)
        loss.backward()
        opt.step()

        running_loss += loss.data[0] * x.size(0)

    epoch_loss = running_loss / len(trn)

    # 計算驗證數據的誤差
    val_loss = 0.0
    model.eval() # 評估模式
    for x, y in valid_dl:
        preds = model(x)
        loss = loss_func(y, preds)
        val_loss += loss.data[0] * x.size(0)

    val_loss /= len(vld)
    print('Epoch: {}, Training Loss: {:.4f}, Validation Loss: {:.4f}'.format(epoch, epoch_loss, val_loss))

這就只是一個標準的訓練循環。 現在來產生我們的預測

test_preds = []
for x, y in tqdm.tqdm(test_dl):
    preds = model(x)
    preds = preds.data.numpy()
    # 模型的實際輸出是logit,所以再經過一個sigmoid函數
    preds = 1 / (1 + np.exp(-preds))
    test_preds.append(preds)
    test_preds = np.hstack(test_preds)

最后,我們可以將我們的預測寫入一個csv文件。

import pandas as pd
df = pd.read_csv("data/test.csv")
for i, col in enumerate(["toxic", "severe_toxic", "obscene", "threat", "insult", "identity_hate"]):
    df[col] = test_preds[:, i]

df.drop("comment_text", axis=1).to_csv("submission.csv", index=False)

對于mask的數據的支持

RNN需要將PyTorch 變量打包成一個padded 序列。

  • sort_within_batch=True ,如果想使用PyTorch里面的pack_padded sequence時候必須要設置為TRUE
  • sort_key 是對數據集合在一個batch的的排序函數。
  • repeat 是否使用多個epoch。

repeat – Whether to repeat the iterator for multiple epochs. Default: False.

TEXT = data.ReversibleField(sequential=True, lower=True, include_lengths=True)
train, val, test = data.TabularDataset.splits(
        path='./data/', train='train.tsv',
        validation='val.tsv', test='test.tsv', format='tsv',
        fields=[('Text', TEXT), ('Label', LABEL)])
        
train_iter, val_iter, test_iter = data.Iterator.splits(
        (train, val, test), sort_key=lambda x: len(x.Text), 
        batch_sizes=(32, 256, 256), device=args.gpu, 
        sort_within_batch=True, repeat=False)

在模型中這么使用

def forward(self, input, lengths=None):
        embed_input = self.embed(input)

        packed_emb = embed_input
        if lengths is not None:
            lengths = lengths.view(-1).tolist()
            packed_emb = nn.utils.rnn.pack_padded_sequence(embed_input, lengths)

        output, hidden = self.encoder(packed_emb)  # embed_input

        if lengths is not None:
            output,_=nn.utils.rnn.pad_packed_sequence(output)

在主循環中可以以這樣的方式傳遞數據,未打包之前。

# Note this loop will go on FOREVER
for val_i, data in enumerate(train_iter):
  (x, x_lengths), y = data.Text, data.Description
  output = model(x, x_lengths)
  
  # terminate condition, when loss converges or it reaches 50000 iterations
  if loss converges or val_i == 50000:
    break

如果我們想訓練一定的epoch,我們可以設置,repeat=False,這樣我們的數據會訓練10個epoch然后停止下來。

# Note this loop will stop when training data is traversed once
epochs = 10

for epoch in range(epochs):
  for data in train_iter:
    (x, x_lengths), y = data.Text, data.Description
    # model running...

如果我們提前不知道應該在多少個epoch的時候停止,比如當精度達到某個值的時候停止,我們就可以設置repeat = True,然后在代碼里面break.

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,739評論 6 534
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,634評論 3 419
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,653評論 0 377
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,063評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,835評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,235評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,315評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,459評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,000評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,819評論 3 355
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,004評論 1 370
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,560評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,257評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,676評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,937評論 1 288
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,717評論 3 393
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,003評論 2 374

推薦閱讀更多精彩內容