第三章 神经网络基础--续

上接第三章 神经网络基础

6.3.2 向量化

从文本数据集到向量化小批量的第二个阶段是迭代输入数据点的标记并将每个标记转换为整数形式。此迭代的结果应该是向量。因为这个向量将与来自其他数据点的向量组合,所以要求Vectorizer中的向量应该总是具有相同的长度。

为了实现这些目标,Vectorizer该类封装了评论Vocabulary,该评论将评论中的单词映射到整数。在例3-15中,Vectorizer使用Python的classmethod装饰器装饰方法from_dataframe,用于指示实例化的入口点Vectorizer。该方法from_dataframe以两个目标迭代Pandas数据帧的行。第一个目标是计算数据集中存在的所有令牌的频率。第二个目标是仅关键字参数相同的频率的令牌cutoff创建一个Vocabulary。这种方法是找到至少出现的所有单词cutof``f并将它们添加到Vocabulary。因为UNK令牌也被添加到了Vocabulary,未添加任何字将具有unk_index当所述Vocabularylookup_token方法被调用。

该方法vectorize封装了核心功能Vectorizer。它将表示输入的字符串作为参数,并返回的向量化表示。在此示例中,我们使用第1章中介绍的独热编码表示。这种表示创建了一个二进制向量 - 一个1s和0s 的向量- 其长度等于的大小Vocabulary。二进制向量具有1与评论中的单词对应的位置。请注意,此表示具有一些限制。首先它是稀疏的 - 评论中唯一单词的数量总是远远小于单词中的唯一单词数。第二是它丢弃了评论中出现的单词的顺序(“一袋单词”)。在随后的章节中,您将看到其他方法可以规避这些问题。

class ReviewDataset(Dataset):
    def __init__(self, review_df, vectorizer):
        """
        Args:
            review_df (pandas.DataFrame): the dataset
            vectorizer (ReviewVectorizer): vectorizer instantiated from dataset
        """
        self.review_df = review_df
        self._vectorizer = vectorizer

        self.train_df = self.review_df[self.review_df.split=='train']
        self.train_size = len(self.train_df)

        self.val_df = self.review_df[self.review_df.split=='val']
        self.validation_size = len(self.val_df)

        self.test_df = self.review_df[self.review_df.split=='test']
        self.test_size = len(self.test_df)

        self._lookup_dict = {'train': (self.train_df, self.train_size),
                             'val': (self.val_df, self.validation_size),
                             'test': (self.test_df, self.test_size)}

        self.set_split('train')

    @classmethod
    def load_dataset_and_make_vectorizer(cls, review_csv):
        """Load dataset and make a new vectorizer from scratch
        
        Args:
            review_csv (str): location of the dataset
        Returns:
            an instance of ReviewDataset
        """
        review_df = pd.read_csv(review_csv)
        train_review_df = review_df[review_df.split=='train']
        return cls(review_df, ReviewVectorizer.from_dataframe(train_review_df))

    @classmethod
    def load_dataset_and_load_vectorizer(cls, review_csv, vectorizer_filepath):
        """Load dataset and the corresponding vectorizer. 
        Used in the case in the vectorizer has been cached for re-use
        
        Args:
            review_csv (str): location of the dataset
            vectorizer_filepath (str): location of the saved vectorizer
        Returns:
            an instance of ReviewDataset
        """
        review_df = pd.read_csv(review_csv)
        vectorizer = cls.load_vectorizer_only(vectorizer_filepath)
        return cls(review_df, vectorizer)

    @staticmethod
    def load_vectorizer_only(vectorizer_filepath):
        """a static method for loading the vectorizer from file
        
        Args:
            vectorizer_filepath (str): the location of the serialized vectorizer
        Returns:
            an instance of ReviewVectorizer
        """
        with open(vectorizer_filepath) as fp:
            return ReviewVectorizer.from_serializable(json.load(fp))

    def save_vectorizer(self, vectorizer_filepath):
        """saves the vectorizer to disk using json
        
        Args:
            vectorizer_filepath (str): the location to save the vectorizer
        """
        with open(vectorizer_filepath, "w") as fp:
            json.dump(self._vectorizer.to_serializable(), fp)

    def get_vectorizer(self):
        """ returns the vectorizer """
        return self._vectorizer

    def set_split(self, split="train"):
        """selects the splits in the dataset using a column in the dataframe"""
        self._target_split = split
        self._target_df, self._target_size = self._lookup_dict[split]

    def __len__(self):
        return self._target_size

    def __getitem__(self, index):
        """the primary entry point method for PyTorch datasets
        
        Args:
            index (int): the index to the data point 
        Returns:
            a dict of the data point's features (x_data) and label (y_target)
        """
        row = self._target_df.iloc[index]

        review_vector = \
            self._vectorizer.vectorize(row.review)

        rating_index = \
            self._vectorizer.rating_vocab.lookup_token(row.rating)

        return {'x_data': review_vector,
                'y_target': rating_index}

    def get_num_batches(self, batch_size):
        """Given a batch size, return the number of batches in the dataset
        
        Args:
            batch_size (int)
        Returns:
            number of batches in the dataset
        """
        return len(self) // batch_size

6.3.3 DATALOADER

文本到矢量化到批次化的最后阶段是分组矢量化数据。因为分批是训练神经网络的重要部分,PyTorch提供了一个称为DataLoader类,用于处理这个过程。DataLoader类是通过提供PyTorch实例化Dataset(例如,ReviewDataset在这个例子中所定义的),一个batch_size和其他关键字参数。生成的对象是一个Python迭代器,用于对提供的数据点进行分组和整理Dataset。在例3-16中,我们将函数包装DataLoader在一个generate_batches()函数中,该函数是一个生成器,可以方便地在CPU和GPU之间切换数据。

def generate_batches(dataset, batch_size, shuffle=True,
                     drop_last=True, device="cpu"):
    """
    A generator function which wraps the PyTorch DataLoader. It will 
      ensure each tensor is on the write device location.
    """
    dataloader = DataLoader(dataset=dataset, batch_size=batch_size,
                            shuffle=shuffle, drop_last=drop_last)

    for data_dict in dataloader:
        out_data_dict = {}
        for name, tensor in data_dict.items():
            out_data_dict[name] = data_dict[name].to(device)
        yield out_data_dict

6.4 感知器分类器

我们在这里使用的模型需要对Perceptron进行改造。在ReviewClassifier从PyTorch的模块继承和创建单个Linear的输出层。因为这是二进制分类设置(负面或正面评论),所以这是一个合适的设置。sigmoid函数主要用于非线性。

我们对forward方法进行参数化以允许可选地应用sigmoid函数。主要原因是,首先指出在二进制分类任务中,二元交叉熵损失(torch.nn.BCELoss)是最合适的损失函数。它在数学上适用于二元概率。然而,应用sigmoid模型使用该损失函数存在数值稳定性问题。为了向用户提供更稳定的方式,PyTorch提供了这些快捷方式BCEWithLogitsLoss。要使用此损失函数,输出不应该应用sigmoid函数。因此,默认情况下,我们不应用sigmoid。然而,在分类器的用户想要概率值的情况下,需要sigmoid,并将其留作选项。我们在例3-17的结果部分中看到了以这种方式使用它的示例。

import torch.nn as nn
import torch.nn.functional as F

class ReviewClassifier(nn.Module):
    """ a simple perceptron based classifier """
    def __init__(self, num_features):
        """
        Args:
            num_features (int): the size of the input feature vector
        """
        super(ReviewClassifier, self).__init__()
        self.fc1 = nn.Linear(in_features=num_features, 
                             out_features=1)

    def forward(self, x_in, apply_sigmoid=False):
        """The forward pass of the classifier
        
        Args:
            x_in (torch.Tensor): an input data tensor. 
                x_in.shape should be (batch, num_features)
            apply_sigmoid (bool): a flag for the sigmoid activation
                should be false if used with the Cross Entropy losses
        Returns:
            the resulting tensor. tensor.shape should be (batch,)
        """
        y_out = self.fc1(x_in).squeeze()
        if apply_sigmoid:
            y_out = F.sigmoid(y_out)
        return y_out

6.5 训练程序

在本节中,我们将描述训练的组成部分以及它们如何与数据集和模型结合在一起,以调整模型参数并提高其性能。从本质上讲,训练程序负责实例化模型,迭代数据集,在给定数据作为输入时计算模型的输出,计算损失(模型有多错),并更新与模型成比例的模型。表面看起来管理很多细节,但是需要改变的地方并不多,因此这段代码后面经常使用。为了帮助管理更多的变量,我们使用一个args对象来集中保存所有超参数,您可以在例3-18中看到

from argparse import Namespace

args = Namespace(
    # Data and Path information
    frequency_cutoff=25,
    model_state_file='model.pth',
    review_csv='data/yelp/reviews_with_splits_lite.csv',
    save_dir='model_storage/ch3/yelp/',
    vectorizer_file='vectorizer.json',
    # No Model hyper parameters
    # Training hyper parameters
    batch_size=128,
    early_stopping_criteria=5,
    learning_rate=0.001,
    num_epochs=100,
    seed=1337,
    # ...  runtime options omitted for space
)

在本节的其余部分,我们首先描述训练状态,我们使用字典来跟踪培训过程信息。当您跟踪有关训练程序的更多详细信息时,此词典将会增长,下一个示例中提供的词典是您在模型训练期间将要跟踪的基本信息集。在描述训练状态之后,我们将简要描述为模型训练而实例化的对象集。这包括模型本身,数据集,优化程序和损失函数。在其他示例和补充材料中,我们包含其他组件,但为简单起见,我们不在文本中列出它们。最后,我们最后介绍训练循环,并演示标准的PyTorch优化程序。

6.5.1 训练的基础

例3-19显示了我们为此示例实例化的训练组件。第一项是初始训练状态。该函数接受args对象作为参数,以便训练状态可以处理复杂的信息,但在本书的文本中,我们没有显示任何这些复杂性。此处显示的最小集包括epoch索引以及训练损失,训练准确性,验证损失和验证准确性的列表。它还包括两个测试损失和测试精度的字段。

接下来要实例化的两个实例是数据集和模型。在本例中,以及本书其余部分的示例中,我们设计了负责实例化矢量化器的数据集。在补充材料中,数据集实例化嵌套在if-statement中,该-statement允许加载先前实例化的矢量化器或新的实例化,该实例化也将矢量化器保存到磁盘。通过cuda(通过args.cuda)检查GPU设备是否可用,模型可以自适应的移动到相应的设备上工作。核心训练循环中的函数调用generate_batches,分批获得数据。

初始实例化中的最后两项是损失函数和优化程序。此示例中使用的损失函数是BCEWithLogitsLoss。二进制分类最合适的损失函数是二进制交叉熵(BCE)损失,BCEWithLogitsLoss不需要用sigmoid函数的模型与输出结果配对比在配对中更具数值稳定性。BCELoss使用将sigmoid函数应用于输出的模型。我们使用的优化器是Adam优化器。一般来说,Adam与其他优化器,截至本文撰写时,没有Adam表现的效果还是最好的。我们鼓励您通过尝试其他优化器并注意性能来自行验证。

import torch.optim as optim 

def make_train_state(args):
    return {'epoch_index': 0,
            'train_loss': [],
            'train_acc': [],
            'val_loss': [],
            'val_acc': [],
            'test_loss': -1,
            'test_acc': -1}
train_state = make_train_state(args)

if not torch.cuda.is_available():
    args.cuda = False
args.device = torch.device("cuda" if args.cuda else "cpu")

# dataset and vectorizer
dataset = ReviewDataset.load_dataset_and_make_vectorizer(args.review_csv)
vectorizer = dataset.get_vectorizer()

# model
classifier = ReviewClassifier(num_features=len(vectorizer.review_vocab))
classifier = classifier.to(args.device)

# loss and optimizer
loss_func = nn.BCEWithLogitsLoss()
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate)

6.5.2 训练循环

训练循环使用初始实例化中的对象来更新模型参数,以便随着时间的推移而改进。更具体地说,训练循环由两个循环组成:数据集中的内循环和外循环,重复内循环多次。在内循环中,计算每个小批量的损失,优化程序用于更新模型参数。

要了解详细情况,让我们从下面的代码片段的顶部开始。在第一行中,我们使用for循环,遍历每一个epoch。epoch是您可以设置的超参数。它控制训练程序使用多少次数据集。在实际中,您应该使用一些早期停止标准的东西,在它到达结束之前终止此循环。在补充材料中,我们将展示如何做到这一点。

for循环的顶部,是几个程序定义和实例化。首先,记录训练状态的epoch索引。然后,选择字数据集('train''val'``'test')。最好在generate_batches()调用之前设置拆分。batch_generator被创建之后,两个浮点数初始化损失和准确性。最后,使用分类器.train()指示模型处于“训练模式”并且模型参数是可变的。

训练循环的第二部分是迭代训练批次batch_generator的内循环,主要执行更新模型参数的基本操作。在每次批处理迭代中,首先使用optimizer.zero_grad()方法清零梯度。然后,从模型计算输出。接下来,损失函数用于计算模型输出和监督目标(真实类标签)之间的损失。调用loss.backward()损失对象(而不是损失函数对象)方法,梯度将传播到每个参数。最后,优化器使用这些传播的梯度执行参数更新optimizer.step()方法。这五个步骤是梯度下降的基本步骤。除此之外,还有一些额外的操作。具体而言,计算损失和准确度值(存储为常规Python变量),然后用于更新运行损失和运行准确性变量。

在训练拆分批次的内循环之后,有几个记录操作。具体而言,首先使用最终损失和准确度值更新训练状态。然后,创建新的批生产器,运行损失和运行精度。循环验证数据几乎与训练数据相同,因此重复使用相同的变量。这里有一个主要区别:分类器调用.eval()方法,它对分类器的.train()方法执行逆操作。该.eval()方法使得模型参数不可变并禁用Dropout。eval模式禁止计算梯度的损失和传播回参数。这很重要,因为我们不希望在验证数据模型中调整参数。用这些数据可以作为模型的衡量标准。如果训练数据的测量性能与验证数据的测量性能之间存在较大差异,则模型可能过度拟合训练数据,需要对模型或训练程序进行调整。

外部for循环完成在迭代验证数据,然后保存得到的验证损失和准确度值。我们设计了一个模型,在本书中每个训练程序符合这个设计模式。实际上,所有梯度下降算法都遵循类似的设计模式。在你已经习惯于从头开始编写这个循环之后,你将学会用它来实现渐变下降!例3-20给出了代码。

for epoch_index in range(args.num_epochs):
    train_state['epoch_index'] = epoch_index

    # Iterate over training dataset

    # setup: batch generator, set loss and acc to 0, set train mode on
    dataset.set_split('train')
    batch_generator = generate_batches(dataset, 
                                       batch_size=args.batch_size, 
                                       device=args.device)
    running_loss = 0.0
    running_acc = 0.0
    classifier.train()
    
    for batch_index, batch_dict in enumerate(batch_generator):
        # the training routine is 5 steps:

        # step 1. zero the gradients
        optimizer.zero_grad()

        # step 2. compute the output
        y_pred = classifier(x_in=batch_dict['x_data'].float())

        # step 3. compute the loss
        loss = loss_func(y_pred, batch_dict['y_target'].float())
        loss_batch = loss.item()
        running_loss += (loss_batch - running_loss) / (batch_index + 1)

        # step 4. use loss to produce gradients
        loss.backward()

        # step 5. use optimizer to take gradient step
        optimizer.step()

        # -----------------------------------------
        # compute the accuracy
        acc_batch = compute_accuracy(y_pred, batch_dict['y_target'])
        running_acc += (acc_batch - running_acc) / (batch_index + 1)

    train_state['train_loss'].append(running_loss)
    train_state['train_acc'].append(running_acc)

    # Iterate over val dataset

    # setup: batch generator, set loss and acc to 0; set eval mode on
    dataset.set_split('val')
    batch_generator = generate_batches(dataset, 
                                       batch_size=args.batch_size, 
                                       device=args.device)
    running_loss = 0.
    running_acc = 0.
    classifier.eval()

    for batch_index, batch_dict in enumerate(batch_generator):

        # step 1. compute the output
        y_pred = classifier(x_in=batch_dict['x_data'].float())

        # step 2. compute the loss
        loss = loss_func(y_pred, batch_dict['y_target'].float())
        loss_batch = loss.item()
        running_loss += (loss_batch - running_loss) / (batch_index + 1)

        # step 3. compute the accuracy
        acc_batch = compute_accuracy(y_pred, batch_dict['y_target'])
        running_acc += (acc_batch - running_acc) / (batch_index + 1)

    train_state['val_loss'].append(running_loss)
    train_state['val_acc'].append(running_acc)

6.6 评估,推理和检查

在获得训练有素的模型之后,接下来的步骤是评估它对一些数据的影响,用它来推断新数据,或者检查模型权重以查看它的学习内容。在本节中,我们将向您展示这三个步骤。

6.6.1 评估测试数据

为了评估保持测试集上的数据,代码的改动很小,与我们在前一个示例中看到的训练例程中的验证循环完全相同,只有一个小的区别:分割设置为'test'而不是'val'。数据集的两个分区之间的差异是测试集应该尽可能少地运行。每次在测试集上运行训练模型时,做出新的模型决策(例如更改图层的大小),并在测试集上重新测量新的重新训练模型,就会使建模决策偏向于测试数据。换句话说,如果你经常重复这个过程,测试集也变得毫无意义,因此测试集要保持数据的准确度量。例3-21更仔细地研究了这一点。

dataset.set_split('test')
batch_generator = generate_batches(dataset, 
                                   batch_size=args.batch_size, 
                                   device=args.device)
running_loss = 0.
running_acc = 0.
classifier.eval() # 不污染参数

for batch_index, batch_dict in enumerate(batch_generator):
    # compute the output
    y_pred = classifier(x_in=batch_dict['x_data'].float())

    # compute the loss
    loss = loss_func(y_pred, batch_dict['y_target'].float())
    loss_batch = loss.item()
    running_loss += (loss_batch - running_loss) / (batch_index + 1)

    # compute the accuracy
    acc_batch = compute_accuracy(y_pred, batch_dict['y_target'])
    running_acc += (acc_batch - running_acc) / (batch_index + 1)

train_state['test_loss'] = running_loss
train_state['test_acc'] = running_acc

print("Test loss: {:.3f}".format(train_state['test_loss']))
print("Test Accuracy: {:.2f}".format(train_state['test_acc']))

Test loss: 0.297
Test Accuracy: 90.55

6.6.2 推断和分类新数据点

评估模型的另一种方法是对新数据进行推断,并对模型是否有效进行定性判断。我们可以在例3-22中看到这一点。

def predict_rating(review, classifier, vectorizer,
                   decision_threshold=0.5):
    """Predict the rating of a review

    Args:
        review (str): the text of the review
        classifier (ReviewClassifier): the trained model
        vectorizer (ReviewVectorizer): the corresponding vectorizer
        decision_threshold (float): The numerical boundary which
            separates the rating classes
    """

    review = preprocess_text(review)
    vectorized_review = torch.tensor(vectorizer.vectorize(review))
    result = classifier(vectorized_review.view(1, -1))

    probability_value = F.sigmoid(result).item()

    index =  1
    if probability_value < decision_threshold:
        index = 0

    return vectorizer.rating_vocab.lookup_index(index)

test_review = "this is a pretty awesome book"
prediction = predict_rating(test_review, classifier, vectorizer)
print("{} -> {}".format(test_review, prediction)

this is a pretty awesome book -> positive

6.6.3 检查模型重量

最后,了解模型在完成培训后是否表现良好的最后一种方法是检查权重并对其是否正确进行定性判断。如例3-23所示,使用Perceptron和折叠的单热编码,这是一种相当简单的方法,因为每个模型权重与我们词汇表中的单词完全对应。

# Sort weights
fc1_weights = classifier.fc1.weight.detach()[0] # 导数的存在,所以先detach
_, indices = torch.sort(fc1_weights, dim=0, descending=True)
indices = indices.numpy().tolist()

# Top 20 words
print("Influential words in Positive Reviews:")
print("--------------------------------------")
for i in range(20):
    print(vectorizer.review_vocab.lookup_index(indices[i]))
Influential words in Positive Reviews:
--------------------------------------
great
awesome
amazing
love
friendly
delicious
best
excellent
definitely
perfect
fantastic
wonderful
vegas
favorite
loved
yummy
fresh
reasonable
always
recommend
# Top 20 negative words
print("Influential words in Negative Reviews:")
print("--------------------------------------")
indices.reverse()
for i in range(20):
    print(vectorizer.review_vocab.lookup_index(indices[i]))
Influential words in Negative Reviews:
--------------------------------------
worst
horrible
mediocre
terrible
not
rude
bland
disgusting
dirty
awful
poor
disappointing
ok
no
overpriced
sorry
nothing
meh
manager
gross

摘要
在本章中,您学习了监督神经网络训练的一些基本概念:

  • 最简单的神经网络模型,Perceptrons
  • 基本概念,如激活函数,损失函数及其不同类型
  • 在玩具示例的上下文中,训练循环,批量大小和时期
  • 使用培训/测试/验证拆分来衡量泛化性能的示例
  • 提前停止和其他标准,以确定训练算法的终点或收敛
  • 超参数和它们的一些例子,如批量大小,学习率等
  • 如何使用PyTorch中实现的Perceptron模型对Yelp餐厅的英语评论进行分类,
  • 及如何通过检查其权重来解释模型
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,324评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,356评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,328评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,147评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,160评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,115评论 1 296
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,025评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,867评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,307评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,528评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,688评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,409评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,001评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,657评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,811评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,685评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,573评论 2 353

推荐阅读更多精彩内容