[译]第六章 深度学习(上中)

Neil Zhu,简书ID Not_GOD,University AI 创始人 & Chief Scientist,致力于推进世界人工智能化进程。制定并实施 UAI 中长期增长战略和目标,带领团队快速成长为人工智能领域最专业的力量。
作为行业领导者,他和UAI一起在2014年创建了TASA(中国最早的人工智能社团), DL Center(深度学习知识中心全球价值网络),AI growth(行业智库培训)等,为中国的人工智能人才建设输送了大量的血液和养分。此外,他还参与或者举办过各类国际性的人工智能峰会和活动,产生了巨大的影响力,书写了60万字的人工智能精品技术内容,生产翻译了全球第一本深度学习入门书《神经网络与深度学习》,生产的内容被大量的专业垂直公众号和媒体转载与连载。曾经受邀为国内顶尖大学制定人工智能学习规划和教授人工智能前沿课程,均受学生和老师好评。

实践中的卷积神经网络


我们现已看到卷积神经网络中核心思想。现在我们就来看看如何在实践中使用卷积神经网络,通过实现某些卷积网络,应用在 MNIST 数字分类问题上。我们使用的程序是 network3.py,这是network.pynetwork2.py 的改进版本。代码可以在GitHub 下载。注意我们会在下一节详细研究一下代码。本节,我们直接使用 network3.py 来构建卷积网络。

network.pynetwork2.py 是使用 python 和矩阵库 numpy 实现的。这些程序从最初的理论开始,并实现了 BP、随机梯度下降等技术。我们既然已经知道原理,对 network3.py,我们现在就使用 Theano 来构建神经网络。使用 Theano 可以更方便地实现卷积网络的 BP,因为它会自动计算所有包含的映射。Theano 也会比我们之前的代码(容易看懂,运行蛮)运行得快得多,这会更适合训练更加复杂的神经网络。特别的一点,Theano 支持 CPU 和 GPU,我们写出来的 Theano 代码可以运行在 GPU 上。这会大幅度提升学习的速度,这样就算是很复杂的网络也是可以用在实际的场景中的。

如果你要继续跟下去,就需要安装 Theano。跟随这些参考 就可以安装 Theano 了。后面的例子在 Theano 0.6 上运行。有些是在 Mac OS X Yosemite上,没有 GPU。有些是在 Ubuntu 14.4 上,有 NVIDIA GPU。还有一些在两种情况都有运行。为了让 network3.py 运行,你需要在 network3.py 的源码中将 GPU 置为 True 或者 False。除此之外,让 Theano 在 GPU 上运行,你可能要参考 the instructions here。网络上还有很多的教程,用 Google 很容易找到。如果没有 GPU,也可以使用 Amazon Web Services EC2 G2 spot instances。注意即使是 GPU,训练也可能花费很多时间。很多实验花了数分钟或者数小时才完成。在 CPU 上,则可能需要好多天才能运行完最复杂的实验。正如在前面章节中提到的那样,我建议你搭建环境,然后阅读,偶尔回头再检查代码的输出。如果你使用 CPU,可能要降低训练的次数,甚至跳过这些实验。

为了获得一个基准,我们将启用一个浅层的架构,仅仅使用单一的隐藏层,包含 100 个隐藏元。训练 60 次,使用学习率为 $$\eta = 0.1$$,mini-batch 大小为 10,无规范化。Let‘s go:

>>> import network3
>>> from network3 import Network
>>> from network3 import ConvPoolLayer, FullyConnectedLayer, SoftmaxLayer
>>> training_data, validation_data, test_data = network3.load_data_shared()
>>> mini_batch_size = 10
>>> net = Network([ FullyConnectedLayer(n_in=784, n_out=100), SoftmaxLayer(n_in=100, n_out=10)], mini_batch_size)
>>> net.SGD(training_data, 60, mini_batch_size, 0.1, validation_data, test_data)

我们再次有了性能提升:现在是 99.6% 的分类准确率!

那么这里就有就有两个自然的问题了。第一个问题是:应用第二个卷积pooling层的意义是什么?实际上,你可以将其看成是有了一个 12 * 12 的“图像”,其中“像素”表示了特定的局部特征在原来的输入图像中是否出现。所以你就可以将这一层当做有原始输入图像的版本的输入。这个版本是抽象和压缩的,但仍然有很多空间结构,所以使用第二个卷积pooling层是说得通的。

虽然这样是可让人信服的,但也引出了第二个问题。从前一层的输出包含 20 个不同的特征映射,所以总共有 20 * 12 * 12 个输入到第二个卷积 pooling 层。看起来,我们已经获得了 20 个分开的图像作为该层的输入,并不是像第一层输入的那样是一个单一的图像。那么第二个卷积 pooling 层中的神经元对这些多重输入图像怎么反馈呢?实际上,我们会让每个神经元从其局部感应区中所有 20 * 5 * 5 输入神经元中学习。不太严格地说:在第二个卷积 pooling 层特征检测元可以访问对来自前一层的所有特征,但仅仅是属于对应局部感应区的那部分。

问题

  • 使用 tanh 激活函数 前面好几次提到,tanh 函数可能是更好的激活函数。我们并没有进行实践验证,因为我们使用 sigmoid 也能够达到较好地效果了。但是现在,我们尝试用 tanh 函数替代 sigmoid。尝试训练使用 tanh 激活函数的卷积和全连接网络。使用同样地初始化参数,但是只训练 20 次而非之前的 60 次。现在你的网络表现如何?如果你训练 60 次呢?试着画出跟别使用 sigmoid 及 tanh 验证准确度随训练次数变化的曲线,从 0 到 60 次。如果你的结果和我的相似,你可以发现 tanh 的网络训练速度更快,但最后的准确度也都差不多。你能够解释为何 tanh 网络训练得更快么?你能不能在 sigmoid 网络下获得类似的训练速度,可能需要改变学习率或者改变尺度?进行 6 次超参数或者网络结构的迭代,找到 tanh 优于 sigmoid 的设置。注意:这是一个开放问题。我没有暴力搜索可行的设定,我个人并没能找到切换为 tanh 的优势,你也许能够找出一条可行的道路。目前,在切换成 RLU 函数时会有一定的优势,所以这里我们对 tanh 就不再赘述。

使用 RLU:这里我们发展出得网络实际上是在引入 MNIST 问题的开创性工作使用的网络的变体,也就是 LeNet-5。这是进行不断实验改进的基础,也能够提供理解和直觉上的帮助。尤其是,存在很多可以改变网络提升结果的途径。

作为开始,我们用 RLU 替代之前使用的 sigmoid 激活函数。我们现在的激活函数是 $$f(z) \equiv max(0,z)$$。我们会训练 60 词,学习率为 $$\eta = 0.03$$。同样我发现如果使用某种 l2 regularization 也有一点帮助,规范化参数为 $$\lambda = 0.1$$:

>>> net = Network([
        ConvPoolLayer(image_shape=(mini_batch_size, 1, 28, 28), 
                      filter_shape=(20, 1, 5, 5), 
                      poolsize=(2, 2), 
                      activation_fn=ReLU),
        ConvPoolLayer(image_shape=(mini_batch_size, 20, 12, 12), 
                      filter_shape=(40, 20, 5, 5), 
                      poolsize=(2, 2), 
                      activation_fn=ReLU),
        FullyConnectedLayer(n_in=40*4*4, n_out=100, activation_fn=ReLU),
        SoftmaxLayer(n_in=100, n_out=10)], mini_batch_size)
>>> net.SGD(training_data, 60, mini_batch_size, 0.03, 
            validation_data, test_data, lmbda=0.1)

这里我得到了 99.23% 的正确率。这是一个微小的提升。但是,从我所有进行的实验看来,基于 RLU 的网络会一致地优于 sigmoid 激活函数。看起来对这个问题,RLU 的确存在在一些优势。

什么使得 RLU 激活函数比 sigmoid 或者 tanh 函数更好?目前,我们并没有很好的理解。实际上,RLU 也是在近几年刚刚流行起来的。现在选用 RLU 的原因也只是实践角度的最优选择:少数几位研究人员根据直觉或者启发式规则尝试发现效果还不错。他们在分类问题上获得了好的结果,所以这样的实践就流传开来。在一个理想世界,我们希望有一个理论告诉我们哪个应用改用哪个激活函数。但现在,我们离这个世界还很遥远。我对于未来出现好的激活函数得到巨大的性能提升丝毫不会诧异。另外我也期待未来会出现关于激活函数的强大理论。今天,我们还是建立在粗浅理解规则的基础来根据经验来进行选择。

扩展训练数据:另一种期望能够提升结果的方式是通过算法对训练数据进行扩展。一种简单地方式就是将每个训练图像通过或上或下或左或右的像素来替换一个像素。我们可以使用下面代码,使用 shell 命令行完成:

$ python expand_mnist.py

这段程序取出 50,000 MNIST 训练图像,然后生成 250,000 个训练图像。我们接下来就可用这些训练图像来训练我们的网络。下面会使用和上面一样的采用 RLU 的网络。在我刚开始的实验中,我讲训练的次数调小了——因为我们数据量是原来的 5 倍。但是实际上,扩展数据会显著减轻过匹配的效果。因此,在一些试验后,我最后还是进行训练 60 次。好了,现在开始训练:

>>> expanded_training_data, _, _ = network3.load_data_shared(
        "../data/mnist_expanded.pkl.gz")
>>> net = Network([
        ConvPoolLayer(image_shape=(mini_batch_size, 1, 28, 28), 
                      filter_shape=(20, 1, 5, 5), 
                      poolsize=(2, 2), 
                      activation_fn=ReLU),
        ConvPoolLayer(image_shape=(mini_batch_size, 20, 12, 12), 
                      filter_shape=(40, 20, 5, 5), 
                      poolsize=(2, 2), 
                      activation_fn=ReLU),
        FullyConnectedLayer(n_in=40*4*4, n_out=100, activation_fn=ReLU),
        SoftmaxLayer(n_in=100, n_out=10)], mini_batch_size)
>>> net.SGD(expanded_training_data, 60, mini_batch_size, 0.03, 
            validation_data, test_data, lmbda=0.1)

使用扩展的训练数据,我得到了 99.37% 的准确率。所以这相当简易的变化也给出了一定的性能提升。因此,正如我们前面讨论的那样,算法扩展数据集可以获得更多益处。回想一下前面的讨论:在 2003 Simard, Steinkraus 和 Platt 使用非常像我们这里的神经网络的网络,两个卷积 pooling层,跟上一个100个神经元的全连接隐藏层。当然这里他们并没有使用 RLU——但是关键提升是由于扩展了训练数据。他们是通过旋转,转化和扭曲 MNIST 训练图像达成的。同样他们还发展出称作“弹性扭曲”的过程,一种模拟人类写字时候随机震荡的方式。通过组合这些过程,他们最终增加了训练数据的规模,也使得他们能够获得 99.6% 的准确度。

问题

  • 卷积层的想法是以不变的方式扫过整个图像。这看起来很奇怪,网络在仅仅转化输入数据就可以学到更多。你能够解释为何这样是很合理的么?

插入额外全连接层:我们能不能做得更好?一种可能方式是使用完全同样的过程,但是扩展全连接层的规模。我尝试 300 和 1,000 个神经元,获得了 99.46% 和 99.43% 的准确率。这个很有趣,但是不是一个具有说服力的提升。(原来是 99.37%)

那么增加额外的全连接层呢?让我们尝试插入一个全连接层,这样我们就有两个 100 个神经元的全连接层:

>>> net = Network([
        ConvPoolLayer(image_shape=(mini_batch_size, 1, 28, 28), 
                      filter_shape=(20, 1, 5, 5), 
                      poolsize=(2, 2), 
                      activation_fn=ReLU),
        ConvPoolLayer(image_shape=(mini_batch_size, 20, 12, 12), 
                      filter_shape=(40, 20, 5, 5), 
                      poolsize=(2, 2), 
                      activation_fn=ReLU),
        FullyConnectedLayer(n_in=40*4*4, n_out=100, activation_fn=ReLU),
        FullyConnectedLayer(n_in=100, n_out=100, activation_fn=ReLU),
        SoftmaxLayer(n_in=100, n_out=10)], mini_batch_size)
>>> net.SGD(expanded_training_data, 60, mini_batch_size, 0.03, 
            validation_data, test_data, lmbda=0.1)

这样做了后,得到测试集上的准确度为 99.43%。所有扩展网络并不是太有帮助。运行类似的实验得到了 99.48% 和 99.47%。虽然结果不错,但也与我们想要的结果相违背了。

所以,我们就想知道,到底为什么?扩展数据集或者增加额外全连接层真的不能帮助解决 MNIST 问题么?或者可能我们的网络能够做得更好,但是我们在错误的方向上做尝试?例如,可能我们可以使用更强的规范化技术来改变过匹配的倾向。一个可能性就是 dropout 技术。回想 dropout 的基本思想就是在训练时随机移除单独的激活值。这样使得模型对个体证据的丢失更加健壮,也会更难以依赖于训练数据某些特别的偏好。让我们试着应用 dropout 在最终的全连接层上:

>>> net = Network([
        ConvPoolLayer(image_shape=(mini_batch_size, 1, 28, 28), 
                      filter_shape=(20, 1, 5, 5), 
                      poolsize=(2, 2), 
                      activation_fn=ReLU),
        ConvPoolLayer(image_shape=(mini_batch_size, 20, 12, 12), 
                      filter_shape=(40, 20, 5, 5), 
                      poolsize=(2, 2), 
                      activation_fn=ReLU),
        FullyConnectedLayer(
            n_in=40*4*4, n_out=1000, activation_fn=ReLU, p_dropout=0.5),
        FullyConnectedLayer(
            n_in=1000, n_out=1000, activation_fn=ReLU, p_dropout=0.5),
        SoftmaxLayer(n_in=1000, n_out=10, p_dropout=0.5)], 
        mini_batch_size)
>>> net.SGD(expanded_training_data, 40, mini_batch_size, 0.03, 
            validation_data, test_data)

使用这个,我们能够得到 99.60% 的准确度,这是一个较大的提升,特别是相对于有 100 个隐藏元获得了 99.37% 准确度网络的基准性能。

这里有两点变化值得重视。

第一,我降低训练的次数到 40 次:dropout 降低了过匹配,所以学习也变快了。

第二,全连接隐藏层有 1,000 神经元,而不是 100 个。当然,dropout 在训练时有效丢弃了很多神经元,所以一些扩展就需要加入了。实际上,我尝试了 300 和 1,000 隐藏元,而 1000 个隐藏元会给出验证集上微弱的提升。

使用网络的集成:一种提升的简单方式是创建若干神经网络,让他们进行投票来确定最优的分类结果。假设,我们训练了 5 个不同的神经网络,然后每个神经网络都能够达到 99.6% 左右的准确度。即使网络都是类似的准确度,他们也非常可能由于不同的随机化初始化产生不同的错误。所以进行一个投票也很合理了,这样得到的分类结果优于任何一个单一的神经网络。

这听起来不大可信,但集成确实是一种通用的技术,在神经网络和其他机器学习技术中广泛使用。这样也确实给出了更好的效果:最后得到了 99.67% 的准确度。换言之,我们的网络集成分类器在 10,000 个测试图像上只错了 33 个。

最终错分的结果列举如下。右上角是正确的类标,右下角则是网络集成分类器给出的结果:

Paste_Image.png

这里值得仔细看看。前两个数字,6 和 5 是集成分类器的错误。然而,这也是可以理解的错误,因为正常人也会出错。这个 6 实际上非常像 0,5 也很像 3。第三个图像,标准给出是 8 ,但实际上看起来也很像 9。所以我站在集成网络分类器这边:因为我认为它比最初画这个数字的人要做得更好。不过,第四幅图,确实分类器是分错了。

多数情况,我们的分类器选择看起来都是合理的,在某些情形下甚至比原来的画者给出的要号。总之,网络给出了优越的性能,特别是你看到其余 9,967 幅图时更能体会。在那些情况下,很少明显错误也都是容易理解的。甚至一个仔细的人都会有偶然的错误。所以我们只能寄望于一个特别仔细和有条理的人类在这个任务上做得更好。网络已经接近人类的表现了。

为何我们只应用 dropout 在全连接层:如果你仔细看过上面的代码,你会注意到,我们只对全连接部分使用了 dropout,而没有在卷积层上使用。在理论上,我们可以在卷积层应用类似的过程。但是,在实际情况中,没有必要:卷积层有预定了内置对过匹配的防范。因为共享权重意味着卷积过滤器被强迫学习整个图像。这就使得他们不大可能过分依赖训练数据的局部特性。所以也没有太大的必要应用其他的规范化方法,如 dropout。

更进一步:还可能在 MNIST 上获得性能的提升。 Rodrigo Benenson 已经给出了一个informative summary page,展示了这些年的进展,包含研究成果的链接。很多论文都使用了深度卷积网络,类似于我们之前用到的网络。如果你挖掘得更深,就能够获得很多有趣的技术,你可能会想要实现其中一些技术。如果想这样的话,建议你从可以很快训练的简单的网络开始你自己的实现,这样能够帮助你更快地理解究竟发生了什么。

这本书中,我一般不会进行综述。但是这里有个例外。2010年 Cireșan, Meier, Gambardella, 和 Schmidhuber 的论文。这篇文章非常简单,这是我很喜欢的。这个网络是一个多层神经网络,全都使用全连接层。最为有效的网络隐藏层分别有 2,500, 2,000, 1,500, 1,000 和 500 个神经元。他们使用了类似于 Simard 等人的想法来扩展使用的训练数据。但是,除此之外,他们也使用了一些其他技巧,包含无卷积层:是一个平常的简单网络和 1980 年代类似的神经网络,如果有足够的耐心和足够的计算能力。他们达到了 99.65% 分类准确度,和我们现在这个差不多了。关键就是使用非常大和非常深的网络,使用 GPU 加速训练。这样可以进行多次的训练。同样也利用较长的训练时间把学习率从 $$10^{-3}$$ 下降到 $$10^{-6}$$。使用他们的架构来重现结果也是很有趣的练习。

为何能够进行训练?上一章提到在深度多层神经网络中进行训练存在着根本性的障碍。特别低,我们看到了梯度会变得非常不稳定:在我们从输出层反向传播到前面的层时,梯度会消失或者爆炸。因为梯度是我们用来训练的信号,这就会导致问题出现。

如何避免这些情况出现?

当然,答案是我们没有能够避免这些问题。事实上,我们做了一些调整使得可以进行训练。主要有:(1)使用卷积层降低了这些层上的参数的数量,让学习问题变得简单;(2)使用更加强大的规范化技术(dropout 和 卷积层)来降低过匹配的可能性,过匹配在一些更加复杂的网络更为严重;(3)使用 RLU 来加速训练,通常是 3-5 倍的提升;(4)使用 GPU 或者等待更长的训练时间。在我们最终的试验中,就是使用 5 倍于原数据集的数据上进行了 40 次的训练。本书前面我们都只是使用原始训练数据进行最多 30 次训练。将(3)和(4)进行组合,我们可能会需要比之前长 30 倍的时间。

你可能会好奇了:“就是这样子么?这些所有我们训练深度神经网络需要要做的?这些都表明了什么?”

当然,我们也会使用其他的想法:使用充分大的数据集;使用正确地代价函数(来避免训练减缓);使用好的初始化方法(同样来避免减缓,因为神经元的饱和);算法扩展训练数据。我们在前面的章节中讨论了这些和其他想法,并在本章对大部分内容进行了回顾。

这些其实还只是一小部分简单地想法。简单,但是强大,只要你正确地使用。开始进行深度学习也变得容易起来!

这些网络有多深?将卷积pooling层看做单一层,我们最终的架构有 4 个隐藏层。这样的网络能称得上是 深度 网络么?当然,4 隐藏层已经超过我们介绍的浅层网络。这些网络大部分只有一个隐藏层,偶尔是 2 个。另外,2015 最强的深度walk有十几层的隐藏层。我听说有人持有 deeper-than-thou 观点,认为只要你在隐藏层的数量上和别人一致,那你就没有在座深度学习。我并不同情这样的想法,因为他将深度学习的定义限制在了当前的结果上。实际上深度学习的突破是直到 00 年代 中期认识到超过浅层或者 2 个隐藏层的网络是可行的这点。这确实是巨大的突破,带来了更多强大的模型。但是除此之外,更深网络的使用是我们达到其他目标的工具——例如获得更好的分类准确度。

对过程的评价:本节中,我们已经从浅层网络转移到多层卷积网络上。看起来很简单!很多情况,都是我们做出了调整,得到了改进。如果你进行试验,我可以保证事情没有这么顺利。原因就是,我给出了修剪后的讲述,丢去了很多试验的介绍——很多失败的尝试。这样修改后的讲述希望能够给读者留下关于众多基本思想的清楚的认知。但是也不可避免地会带来一种不完全的印象。得到一个很好的,可用的网络,包含了大量的试错和偶尔的沮丧。实践中,你应当意识到需要进行大量的试验。为了加速过程,可能需要参考如何选择神经网络的超参数,同样还要看看那一章节给出来的更深入阅读推荐。

人工智能时代每个人都将面临挑战,想要了解更多相关知识和实践经验,请关注公众号“UniversityAI”。


UAI 人工智能
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容