完全理解RNN(循环神经网络)

1.RNN基础概念和结构

  RNN(Recurrent Neural Network)即循环神经网络,用于解决训练样本输入是连续的序列,且序列的长短不一的问题,比如基于时间序列的问题。基础的神经网络只在层与层之间建立了权连接,RNN最大的不同之处就是在层之间的神经元之间也建立的权连接。RNN神经网络的结构如下:

循环神经网络结构

  即每一时刻的输出都跟当前时刻的输入和上一时刻的输出有关,说起来可能比较抽象,用一个简单的算例说明一下。
RNN简单案例

  我们来看输入,共分为两个时刻,其中t0=1,t1=2,输入是如何转换为输出的呢,下面逐步进行分解。

  • 初始时刻,没有上一个隐层的输出,因此初始化为[0,0]。
  • 将上一个隐层的输出与当前时刻输入进行拼接,得到第一个隐藏计算的输入为[0,0,1]。
  • 隐层内计算,将拼接后的输入值与初始权重W进行相乘,同时加上偏置b,得到一个基础值,值得注意的是这个W和b是一个更新的过程,需要不断迭代计算。
  • 第一次激活,这次激活是对s(t)进行激活,采用的激活函数为tanh函数,将上一步得到的基础值代入到tanh函数中,得到的输出即为s(t),这个s(t)将作为下一层的s(t-1)参与下一个隐层的计算。
  • 当前层输出基础值计算,将s(t)和新的权重V相乘加上偏置b,得到当前层输出基础值。
  • 当前层最终输出,加上激活函数以后就是当前层的输出啦,此时采用的激活函数一般为softmax()。

2.RNN参数个数计算

  第一部分介绍了一个神经元的计算过程,其实在真正的应用中,当前输入和上一层的输出分别要对应一个权重向量,我们案例中都假设都相同了,实际也是不同的。为了说明参数的计算方法,假设当前输入的权重为U、上一隐层输出的权重为W,当前层输出基础值对应的权重为V,三者共用一个偏置b。
  经过以上描述,我想大家都应该可以清楚的了解了RNN计算方法,但在实际应用中输入通常不是一维的,输出也不是一维的。下面我们来看一个新案例,以此说明RNN参数计算的问题,假设有一个包含8000个词向量的文本输入,每一个隐层包含100个节点,输入为一句话包含10个词,在t0时刻输入为1\times8000维,t1至t9时刻可以依此类推了。
  首先我们可以确定输入为1\times8000维,那么对应输入的权重U就是8000\times100维,因为每个隐层有100个神经元,所以每一层的输出为1\times100,对应上一层输出的权重W为100\times100维,二者可以做一个拼接就是(8000+100)\times100,如果考虑偏置b那么,参数个数可以确定为(8000+100)\times100+100,在经过激活函数时不会增加参数,唯一需要增加参数的地方就是输出了,由于输入是1\times8000维,因此输出也要是经过softmax函数处理的1\times8000维向量,每一个细胞单元的输出为1\times100维,要想转换成1\times8000维,需要的权重V应该是100\times8000维的,偏置b共用,最后输出计算需要的参数个数是100\times8000+100,同样经过softmax函数激活时不会增加参数,最终的参数个数为(8000+100+100)\times100+2*100

3.RNN前后向传播算法

3.1 前向传播算法

  RNN的前向传播其实和其他人工神经网络的前向传播是一样的都是乘法、加法操作以及集合操作的集合,笔者认为神经网络的前向操作只要弄懂网络的结构和参数的计算方法,其他一切就如同小学数学一样是十分简单的,所以大家不需要害怕去学习神经网络前向传播算法的原理,况且在tensorflow、keras等工具的支持下,你根本不需要自己去算,一个import,一个model.add就可以搞定,keras甚至可以自动计算出每一层的参数,这个对于广大人工智能、图像识别以及语义识别从业者来说是一个好事。目前人工智能的关键在于基础数据的标注以及训练过程中资源的耗费,其他的问题都很好解决的,想转行的同学不妨试着学习一下。说了那么多我们来看看RNN的前向传播算法是什么样的吧。
  从循环神经网络的原理,我们可以知道对于一个有序序列,假设有t个时刻,那么他会经过t次隐藏层的计算,以其中任意一次为案例进行说明。当前隐藏层状态h(t)由当前的输入x(t)和上一层的输出h(t-1)决定。
s(t)=Ux(t)+Wh(t-1)+b
h(t)=\sigma(s(t))=\sigma(Ux(t)+Wh(t-1)+b)
  其中,\sigma代表中激活函数,其实在RNN中就两种,一种是tanh函数用于将值转化到0-1之间,一种是softmax函数,用于输出概率值,在隐藏层中选择的是tanh函数。当前隐藏层的输出用如下函数计算:
o(t)=Vh(t)+c
  转换为最终的预测值输出就是:
y_p=\sigma(o(t))=\sigma(Vh(t)+c)
  这里的激活函数是softmax函数因为要做最终的分类用了。值得一提的是,损失函数就是y_p和y之间的差值,可以用对数损失函数,平方损失函数等等,根据问题的不同来选择。

3.2 后向传播算法

  相比之下,后向传播算法就是广大从业者的梦魇了,这个东西需要高数基础知识,最好还有点运筹学的知识,小学数学水平明显解决不了了,跨度为何如此之大,笔者也不知道,哈哈。后向传播算法的核心思想就是采用梯度下降算法进行一步步的迭代,直到得到最终需要的参数U、V、W、b、c,反向传播算法也被称为BPTT(back-propagation through time)。
  首先来定义RNN的损失函数,最常使用的是交叉熵损失函数。对于某一时刻t交叉熵损失函数可以表示为:
L(t)=-\sum_{j=1}^{|V|}y_{tj}\times\ln\hat{y_{tj}}
  其中|V|为输入样本的大小,上文中输入为1\times8000,即等于8000。
  那么,考虑到在所有的时刻T,损失函数就定义为:
L=\frac{1}{T}\sum_{t=1}^{|T|}L(t)
  下面开始进行求导,求导遵循了链式法则,什么是链式法则呢,就是说假设有一个函数,z=wx,还有另外一个函数,y=z^2,现在要求y对z的导数,根据链式法则,\frac{dy}{dw}=\frac{dy}{dz}\times\frac{dz}{dw},这个链式法则适用于所有神经网络的反向传播求导,一定要加强理解。首先对V和c求导,因为V和c是相对独立的,不与前一刻的值产生关系,相对比较简单,我们先来求一下找一下状态。
\sum\frac{dL}{dc}=\sum\frac{dL}{d\hat{y}}\times\frac{d\hat{y}}{do(t)}\times\frac{do(t)}{dc}
一步步来看:\frac{do(t)}{dc}=1

\sum\frac{dL}{dc}=\sum\frac{dL}{d\hat{y}}\times\frac{d\hat{y}}{do(t)}=\sum\hat{y}-y
  这是关于softmax的链式求导,关键是将\hat{y}当做一个值进行求解,公式推导过程比较长,我就不列计算步骤了,想看怎么推导的参考这篇文章softmax函数求导方法
  同样来对V进行求导,方法类似,直接给结果吧。
\sum\frac{dL}{dV}=\sum(\hat{y}-y)h(t)^T
  这个求和符号是对t个时刻的误差的一个加和。
  接下来考虑对U、W、b的求导推导了,这是比较复杂的因为这些值和之前的状态有关,由于是反向传播所以t时刻的误差损失由当前位置输出的误差损失和t+1时刻的误差损失共同确定。这部分参考了以下文章,U、W、b求导
根据反向传播算法,当前层的误差梯度和上一层(t+1,反向)是相关的,也是一个累加的过程,这部分过程,求导相对比较麻烦。我只说思路,首先,求一个公共部分导数,\frac{dL}{ds(t)},这个由两部分构成一部分是当前输出的误差,另一部分是反向传播传下来的误差,由此我们可以递推,首先求得最后一个时刻的\frac{dL}{ds(t)},这个只受当前输出影响,然后可以推出任意时刻的\frac{dL}{ds(t)},其实是个累加的过程就是从0累加到当前时刻,然后他的导数又是从t时刻累加到最后,是个双层累加的过程,有了这个以后我们再求对W和对V的倒数相对比较简单了。详细的推导过程见这篇文章bptt详细推导,这篇文章写得最明白,大家就看这一篇就够了,网上什么刘建平的写的太泛泛,这是笔者看了无数篇文章后得出的结论,笔者也是从这一篇中充分明白bptt算法的。

4.RNN的三种实现方式

  这部分是理解整篇文章的关键了,了解原理以后我们才能知道怎样才能实现这个算法,其实对于神经网络,数学知识只需要知道两个地方,一是矩阵求导,这个比较麻烦,但是有专门的参照表,各位看官看这里矩阵求导法则,其实是这个不需要了解原理,你可以直接用的;二是梯度下降,这个吧,其实就是参数沿导数方向下降最快,就好比你爬山找最陡峭的地方下山最快。
  本篇文章只讲关键部分如何实现,重点在于思路复现,对于一些小细节,不展开讲,分别用python纯手写,tensorflow和keras实现,本文尽量简化表达,对于一些代码准确性可能存在问题和疏忽之处,但保证总体思路是对的。

4.1 python纯手写

  纯手写要解决两个问题,一个是rnn的前向传播问题,一个是rnn的反向传播算法(bptt),好在前文我们都解释了怎么去计算,只需要按照思路码代码。

class rnn_python():

    def __init__(self, word_dim, hidden_dim=100, bptt_truncate=4 ):#
        #初始化,给定输入句子x的长度word_dim,每个细胞层的隐藏神经元个数hidden_dim,默认为100,这也是s(t)和h(t)的维度。
        self.word_dim = word_dim
        self.hidden_dim = hidden_dim
        self.bptt_truncate = 4
        #这一步的关键是理解UVW的维度,对于后面的计算有用
        self.U = np.random.uniform(-1 / np.sqrt(word_dim), 1 / np.sqrt(word_dim), (hidden_dim, word_dim))  
        self.V = np.random.uniform(-1/np.sqrt(hidden_dim), 1/np.sqrt(hidden_dim), (word_dim, hidden_dim))
        self.W = np.random.uniform(-1 / np.sqrt(hidden_dim), 1 / np.sqrt(hidden_dim), (hidden_dim, hidden_dim))

    def forward_propagation(self, x):
        T = len(x)
        s = np.zeros((T+1, self.hidden_dim))#最后一行是0,为了对应第一个时刻的前一时刻,全部为0
        s[-1] = np.zeros(self.hidden_dim) 
        o = np.zeros((T, self.word_dim))
        for t in range(T):
            s[t] = np.tanh(self.W.dot(s[t-1]) + self.U.dot(x(t)))#这个s(t)是推导中的h(t),注意转换思路
            o[t] = self.softmax(self.V.dot(s[t]))#softmax是自己定义的,比较简单我省略了。
        return [o, s]

    def predict(self, x):#根据输入预测下一个词的输出
        o = self.forward_propagation(x)[0]
        y_predict = np.argmax(o, axis=1)
        return y_predict

    def cross_entropy(self, x, y):#x0-1值与概率值相乘取对数,只关系1对应的概率就可以了
        L = 0 
        N = np.sum(len(y_i) for y_i in y)
        for i in range(len(y)):#先暂时理解y为词的下标
            o = self.forward_propagation(x[i])[0]
            correct_loss = o[:, y[i]]#每一行代表每一词的求和,但是要注意这里的y[i]是一个index区别于公式中y,公式中的y还是1
            L -= np.sum(np.log(correct_loss))
        return L/N

    def bptt(self, x, y):
        T = len(y)
        V = self.V
        W = self.W
        U = self.U
        o, s = self.forward_propagation(x)
        dLdV = np.zeros(self.V.shape)
        dLdW = np.zeros(self.W.shape)
        dLdU = np.zeros(self.U.shape)
        deltao = o
        deltao[:, y] -= 1#推导过程的y-o
        for t in range(T, -1, -1):#反向传播从第T个开始
            dLdV += np.outer(deltao[t], s(t).T)#一句话所有文字
            delta_t = self.V.T.dot(deltao[t]) * (1-s[t] ** 2)
            for bptt_step in np.range(t+1, max(0, t - self.bptt_truncate), -1):
                dLdW += np.outer(delta_t, s[bptt_step-1])
                dLdU += np.outer(delta_t, x[t])
                delta_t = self.W.T.dot(delta_t) * (1 - s[bptt_step - 1] ** 2)
        return [dLdW, dLdU, dLdV]

4.2 tensorflow实现

  tensorflow的基本用法,看我写的这篇文章tensorflow入门,思路就是导入rnn相关的包,设置每个细胞状态和最后的全连接层。
  我把tensorflow的使用过程概括为几个步骤

  • 1.导入必要的包,除了必须用的tensorflow,根据你的计算需要还导入别的包,这个不可能是一步完成的,随着代码的完善而完善
  • 2.定义参数,下面不论是定义常数还是变量,基本都离不开张量,决定张量的大小的各种维度都可以从参数处获得,此外还有一些模型必须要的参数,比如学习率、每一批次训练样本大小等。
  • 3.建立常量的占位符(placeholder)和变量的定义维度(Variable),这个也需要根据神经网络的结构自己设定。
  • 4.定义一些函数,这些函数将有助于建立神经网络模型,或者说这一部分就可以理解为建立神经网络模型,其实就是调用各种工具包,只要提供必需的参数就可以了,也要搞懂模型的输入输出以及他们之间的维度关系。
  • 5.参数训练,这一步你要定义好你的损失函数,其实大部分都是交叉熵损失函数,因为这个导数好求,用梯度下降的方法也比较方便,优化器大部分人都选择AdamOptimizer。
  • 6 tensorflow启动,姑且叫这个名字吧,就是起一个sess,开始你的操作,包括变量的初始化等等
    -7 开始训练,你这个参数得后向传播调优啊,所以根据你的样本总数和批次样本大小,算一算可以训练多少次,开始循环吧,当然了你也可以选择样本复用,这涉及到抽样方法也很简单,感兴趣自己百度一下。
    -8 保存模型,一顿操作之后你有了一个准确率比较高的模型了,把它保存下来吧,部署到你的线上环境上,然后每次调用就好啦,当然,如果你觉得你需要重复训练,那你还得拿着新数据去你的训练机器上训练,如果涉及到GPU这个非常耗资源,要注意。
      你要非常注意张量的操作特别绕脑,保持代码简洁是第一关键点,千万别转置过来转置过去的,这样的编码习惯特别不好。
import tensorflow as tf
from tensorflow.examples.tutorials.mnist import  input_data
mnist = input_data.read_data_sets("./data",one_hot=True)

##参数设置,这些参数在建立模型时候需要赋予,一般都是默认的,当然也可以进行参数优化
learning_rate = 0.01#学习率
train_step = 10000#训练步数
batch_size = 128#每次训练样本个数
display_step = 10#每几次打印一次结果
frame_size = 28#可以理解为一个时间序列的长度,图片一般为28*28,理解为每一行相互关联,时间t的长度T
sequence_length = 28#序列长度,注意输入长度
hidden_num = 5#细胞单元的神经元大小,也是每层的输出大小
n_classes = 10#分类个数,一般为2分类问题,此处案例为二分类任务

##定义输入、输出,注意这部分是固定的我们事先知道的,无需训练的,但是需要我们传给模型,
# 但在建立模型的时候我们并不知道传入什么、传入多少,因此建一个占位符,等到要用的时候好传入
#常数
x = tf.placeholder(dtype=tf.float64, shape=[None, frame_size * sequence_length], name='input_x')#None代表提前也不知道输入几个样本训练
y = tf.placeholder(dtype=tf.float64, shape=[None, n_classes], name='output_y')#这个y代表最终的输出,和rnn细胞的输出区分开
#变量
weights = tf.Variable(tf.truncated_normal(shape=[hidden_num, n_classes]))#截尾正态分布初始化
bias = tf.Variable(tf.zeros(shape=hidden_num))
#定义函数

def RNN(x, weights, bias):
    x = tf.reshape(x, shape=[-1, frame_size, sequence_length])#tf所有进模型的张量都是三维的,-1代表根据输入确定,缺省值根据计算获得
    rnn_cell = tf.nn.rnn_cell.BasicRNNCell(hidden_num)#只需传hidden_num
    init_state = tf.zeros(shape=[batch_size, rnn_cell.state_size])
    #注state是rnn和lstm的专用实际表示细胞层的输出,注意init_state的问题
    output, states = tf.nn.dynamic_rnn(rnn_cell, x, dtype=tf.float32)#call函数每次计算一步,tf.nn.dynamic_mn函数相当于调用n次call函数
    #output是每一层的output, states是最后一层的稳定输出。
    return tf.nn.softmax(tf.matmul(output[:, -1, :], weights) + bias, 1)#取0-1


predy = RNN(x, weights, bias)
lost = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits(logits=predy, y))
train = tf.train.AdamOptimizer(learning_rate).minimize(lost)
correct_predict = tf.equal(tf.argmax(predy, 1), tf.argmax(y, 1))
accuracy = tf.reduce_mean(tf.to_float(correct_predict))


sess = tf.Session()
sess.run(tf.initialize_all_variables())

step = 1
test_x, test_y = mnist.text.next_batch(batch_size)
while step < train_step:
    batch_x, batch_y = mnist.text.next_batch(batch_size)
    batch_x = tf.reshape(batch_x, shape=[batch_size, frame_size, sequence_length])#训练样本,时长,句子长度
    if step % train_step == 0:
        acc, loss = sess.run([accuracy, lost], feed_dict=(batch_x, batch_y))
        print(step, acc, loss)
    step += 1

4.3 keras实现

  keras是建立在tensorflow基础上,但相对于tensorflow更加简单易上手,但是想自定义比较困难,实际上keras和tensorflow是互相集成的,二者可以和谐共处。笔者觉得对于实际的应用场景,我们应该更加关注于keras,因为毕竟它比较简单,也集成了大部分的网络,先把这个学好可以解决大部分的深度学习任务,所以笔者推荐初学者先学keras。同tensorflow相同我先来解释一下keras的使用步骤。

  • 1.导入必要的包,这个是keras包的导入,有几个比较常用的比如Dense(全连接),Activation(激活函数)等,用啥导啥,陌生问题就去百度。
  • 2.读数据,写参数,前两部和tensorflow很相似有木有,其实你解决任何一个机器学习和深度学习问题,都得有这两步。
  • 3.搭建模型,为什么说keras简单呢,因为它的套路简单,首先初始化模型都是model = Sequential(),然后往上加网络层就行了,最终加一个model.compile对模型进行编译。
    -4.迭代训练,设置好的迭代代数,用model.fit进行迭代,直至训练好模型。
    -5.保存模型,将训练好的模型进行保存,方便下次直接调用而不用训练。
    -6.预测,输入x值,得到y值,用到model.predict函数。
import numpy as np
from keras.layers import Dense, Activation
from keras.layers.recurrent import SimpleRNN
from keras.models import Sequential
from keras.utils.vis_utils import plot_model #
#文本处理过程省略
#参数设定
HIDDEN_SIZE = 128
BATCH_SIZE = 128
NUM_INTERATIONS = 25
NUM_EPOCHS_PER_INTERATION = 1
NUM_PREDS_PER_EPOCHS = 100
#建立模型
model = Sequential()
model.add(SimpleRNN(HIDDEN_SIZE, return_sequences=False, input_shape=(SQLLEN, chars_count), unroll=True))
model.add(Dense(chars_count))
model.add(Activation('softmax'))
model.compile(loss='categorical_crossentropy', optimizer='rmsprop')

for itertion in range(NUM_INTERATIONS):
    print('Interation: %d' %itertion)
    model.fit(X, Y, batch_size=BATCH_SIZE, epochs=NUM_EPOCHS_PER_INTERATION)
    test_idx = np.random.randint(len(input_chars))
    test_chars = input_chars[test_idx]
    for i in range(NUM_PREDS_PER_EPOCHS):
        vec_test = np.zeros((1, SQLLEN, chars_count))
        for i, ch in enumerate(vec_test):
            vec_test[0, i, char2index[ch]] = 1
            pred = model.predict(vec_test, verbose=0)[0]
            pred_char = index2char[np.argmax(pred)]
            test_chars = test_chars[1:] + pred_char

  想必看完这个,还在犹豫keras和tensorflow的同学应该知道怎么选了吧,直接从keras入手吧。

5.RNN的各种变体

  由于激活函数的原因,偏导取值都小于1,在多个参数的情况下,值会超过计算机的处理范围,此即经常所说的梯度消失和梯度爆炸问题,产生的原因也很简单,由于是反向传播过程,所以求导过程当中产生了多个时刻导数的相乘,而我们知道tanh函数的导数在0-1之间当层数特别多,这个值难免会接近于0,这个时候我们其实只需要处理相乘这部分就可以了,方法很多,详细看这里梯度消失和爆炸解决思路
。4.1用了向前截断的方式来处理这个问题,但这难免要损失信息,因此关于RNN有很多变体,包括LSTM和GRU等,他们可以很好的解决梯度消失和梯度爆炸问题,这也是接下来文章中要重点讨论的内容。这篇文章也解释了为什么梯度消失和爆炸,和如何解决梯度消失和爆炸,梯度消失和爆炸问题

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

推荐阅读更多精彩内容