Tensorflow系列3:多层神经网络--解决非线性问题

用线性单分逻辑回归解决二分类问题

这里拿医院的数据做一个简单的线性分类任务,任务特征是病人的年龄和肿瘤大小,任务目标是病人的肿瘤是良性的还是恶性的。

1、生成样本集

def generate(sample_size, mean, cov, diff, regression):
    num_classes = 2  # len(diff)
    samples_per_class = int(sample_size / 2)

    X0 = np.random.multivariate_normal(mean, cov, samples_per_class)
    Y0 = np.zeros(samples_per_class)

    for ci, d in enumerate(diff):
        # ci=0 d=3
        X1 = np.random.multivariate_normal(mean + d, cov, samples_per_class)
        Y1 = (ci + 1) * np.ones(samples_per_class)

        X0 = np.concatenate((X0, X1))
        Y0 = np.concatenate((Y0, Y1))

    if regression == False:  # one-hot  0 into the vector "1 0
        class_ind = [Y == class_number for class_number in range(num_classes)]
        Y = np.asarray(np.hstack(class_ind), dtype=np.float32)
    X, Y = shuffle(X0, Y0)

    return X, Y

#定义种子值,这样可以保证每次生成的随机值都一样
np.random.seed(10)
#生成类的个数
num_classes = 2
mean = np.random.randn(num_classes)
cov = np.eye(num_classes)
X, Y = generate(1000, mean, cov, [3.0],True)
colors = ['r' if l == 0 else 'b' for l in Y[:]]
plt.scatter(X[:,0], X[:,1], c=colors)
plt.xlabel("Scaled age (in yrs)")
plt.ylabel("Tumor size (in cm)")
plt.show()
  • 函数numpy.random.randn()返回一个或一组样本,具有标准正态分布,传入的参数是一个元组,代表产生的array的维度。

  • 传入的最后一个参数regression=True表明使用非one-hot编码标签。

  • 传入的参数[3.0]表示两类数据的x和y差距3.0。

  • 以上产生样本集的函数返回的y是0或1两类

补充知识:

  • numpy.random.randint(low,high=None,size=None,dtype='l')返回随机整数,范围区间为[low,high),size为数组维度大小

  • matplotlib.pyplot.scatter(x,y,s=20,c='b')各个参数的作用

参数 作用
x , y 输入数据
c 色彩或颜色序列,可以是一个RGB或RGBA二维行数组,比如:['r','b']

2、构建网络结构

lab_dim = 1
input_dim = 2
#定义输入、输出两个占位符
input_features = tf.placeholder(tf.float32,[None,input_dim])
input_lables = tf.placeholder(tf.float32,[None,lab_dim])
#定义学习参数
W = tf.Variable(tf.random_normal([input_dim,lab_dim]),name="weight")
b = tf.Variable(tf.zeros([lab_dim]), name="bias")

output = tf.nn.sigmoid(tf.matmul(input_features,W) + b)
cross_entropy = -(input_lables * tf.log(output) + (1 - input_lables) * tf.log(1 - output))
ser = tf.square(input_lables- output)
loss = tf.reduce_mean(cross_entropy)
err = tf.reduce_mean(ser)
optimizer = tf.train.AdamOptimizer(0.04) #尽量用这个--收敛快,会动态调节梯度  
train = optimizer.minimize(loss)  # let the optimizer train

补充知识:

  • 损失函数一般有两种,一是均值平方差(MSE),二是交叉熵(cross entropy)

MSE的公式为:
MSE=\frac{1}{n}\sum_{t=1}^{100}(observde_t-predicted_t)^2

cross entropy一般用于分类问题,表达的意思是样本属于某一类的概率,公式为:
c=-\frac{1}{n}\sum_{x}[ylna+(1-y)ln(1-a)]
这里用于计算的a也是经过分布统一化处理的(或者是经过Sigmoid函数激活的结果),取值范围在0~1之间。

在tensorflow中常见的交叉熵函数有:Sgimoid交叉熵,softmax交叉熵,Sparse交叉熵,加权Sgimoid交叉熵
MSE的预测值和真实值要控制在同样的数据分布内,假设预测值经过Sigmoid激活函数得到取值范围时候01之间,那么真实值也要归一化成01之间。
在tensorflow中没有单独的MSE函数,可以自己组合:
MSE=tf.reduce_mean(tf.square(logits-outputs))

`tf.nn.sigmoid_cross_entropy_with_logits(logits,targets,name=None)`

`tf.nn.softmax_cross_entropy_with_logits(logits,targets,name=None)`

`tf.nn.sparse_cross_entropy_with_logits(logits,targets,name=None)`

`tf.nn.weighted_cross_entropy_with_logits(logits,targets,name=None)`

以上函数中的targets是未经softmax后的,对于已经softmax转换过的scaled,在计算loss时就不能使用在Tensorflow中定义的 

softmax_cross_entropy_with_logits函数,必须要自己定义,比如:
loss = tf.reduce_mean(-tf.reduce_sum(labels*tf.log(logits_scaled),1))
损失函数的选取取决于输入标签数据的类型:如果输入是实数、无界的值,多使用MSE;如果输入标签是位矢量(分类标志),使用cross entropy比较合适

3、设置参数进行训练

# 训练
maxEpochs = 50
minibatchSize = 25
# 启动session
with tf.Session() as sess:
    sess.run(tf.global_variables_initializer())

    #向模型输送数据
    for epoch in range(maxEpochs):
        sumerr = 0
        for i in range(np.int32(len(Y)/minibatchSize)):
            x1 = X[i*minibatchSize:(i+1)*minibatchSize,:]
            y1 = np.reshape(Y[i*minibatchSize:(i+1)*minibatchSize] , [-1,1])
            tf.reshape(y1, [-1, 1])
            _, lossval, outputval, errval = sess.run([train, loss, output, err],
                                                     feed_dict={input_features: x1, input_lables: y1})
            sumerr = sumerr + errval

        print("Epoch:", '%04d' % (epoch + 1), "cost=", "{:.9f}".format(lossval), "err=", sumerr / minibatchSize)

补充知识点:

reshape()函数接受-1时,该行(列)数可以为任意值。[-1,1]代表行数随意,列数变成1。

4、数据可视化

# 启动session
with tf.Session() as sess:
    sess.run(tf.global_variables_initializer())

    #向模型输送数据
    for epoch in range(maxEpochs):
        sumerr = 0
        for i in range(np.int32(len(Y)/minibatchSize)):
            x1 = X[i*minibatchSize:(i+1)*minibatchSize,:]
            y1 = np.reshape(Y[i*minibatchSize:(i+1)*minibatchSize] , [-1,1])
            tf.reshape(y1, [-1, 1])
            _, lossval, outputval, errval = sess.run([train, loss, output, err],
                                                     feed_dict={input_features: x1, input_lables: y1})
            sumerr = sumerr + errval

        print("Epoch:", '%04d' % (epoch + 1), "cost=", "{:.9f}".format(lossval), "err=", sumerr / minibatchSize)


    x = np.linspace(-1,8,200)
    #    x1w1+x2*w2+b=0
    #    x2=-x1* w1/w2-b/w2
    y=-x*(sess.run(W)[0]/sess.run(W)[1])-sess.run(b)/sess.run(W)[1]
    plt.plot(x,y,label="fitted line")
    plt.show()

模型生成的z用公式可以表示成z=x1w1+x2w2+b,如果将x1和x2映射到直角坐标系中的x和y坐标,那么z就可以被分为小于0和大于0两部分。当z=0时,就代表直线本身。

用线性逻辑回归处理多分类问题

这次再在刚刚的二分类基础上再增加一类,变成三类,可以使用多条直线将数据分成多类。

1、生成样本集

def onehot(y,start,end):
    ohe = OneHotEncoder()
    a = np.linspace(start,end-1,end-start)
    b =np.reshape(a,[-1,1]).astype(np.int32)
    ohe.fit(b)
    c=ohe.transform(y).toarray()
    return c
# 模拟数据点
def generate(sample_size, num_classes, diff, regression=False):
    np.random.seed(10)
    mean = np.random.randn(2)
    cov = np.eye(2)

    # len(diff)
    samples_per_class = int(sample_size / num_classes)

    X0 = np.random.multivariate_normal(mean, cov, samples_per_class)
    Y0 = np.zeros(samples_per_class)

    for ci, d in enumerate(diff):
        X1 = np.random.multivariate_normal(mean + d, cov, samples_per_class)
        Y1 = (ci + 1) * np.ones(samples_per_class)

        X0 = np.concatenate((X0, X1))
        Y0 = np.concatenate((Y0, Y1))
        # print(X0, Y0)

    if regression == False:  # one-hot  0 into the vector "1 0
        Y0 = np.reshape(Y0, [-1, 1])
        # print(Y0.astype(np.int32))
        Y0 = onehot(Y0.astype(np.int32), 0, num_classes)
        # print(Y0)
    X, Y = shuffle(X0, Y0)
    # print(X, Y)
    return X, Y

np.random.seed(10)
input_dim = 2 #输入数据特征的维度
num_classes = 3 #分类的类别数
X, Y = generate(2000,num_classes, [[3.0],[3.0,0]],False)
print(X)
print(Y)
aa = [np.argmax(l) for l in Y]
colors = ['r' if l == 0 else 'b' if l == 1 else 'y' for l in aa[:]]

plt.scatter(X[:, 0], X[:, 1], c=colors)
plt.xlabel("Scaled age (in yrs)")
plt.ylabel("Tumor size (in cm)")
plt.show()
三分类.png

生成的X,Y的数据样本如下内容:

X
[[ 1.83322304 -0.21208312]
 [ 0.76327133  1.72447501]
 [ 1.36199426 -1.06355419]
 ...
 [ 2.21595411 -0.67154356]
 [ 6.68974365  4.75529356]
 [ 5.10517359 -0.50115783]]
 
Y
[[1. 0. 0.]
 [1. 0. 0.]
 [1. 0. 0.]
 ...
 [1. 0. 0.]
 [0. 1. 0.]
 [0. 0. 1.]]

2、构建网络结构

常用的激活函数比如sigmoid,relu,tanh输出值只有两种,面对以上多分类问题,就需要使用softmax算法。该算法的主要应用就是多分类,而且是互斥的,即只能属于某一类。(对于不是互斥的分类问题,一般使用多个二分类来组成)

lab_dim = num_classes
# tf Graph Input
input_features = tf.placeholder(tf.float32, [None, input_dim])
input_lables = tf.placeholder(tf.float32, [None, lab_dim])
# Set model weights
W = tf.Variable(tf.random_normal([input_dim,lab_dim]), name="weight")
b = tf.Variable(tf.zeros([lab_dim]), name="bias")
output = tf.matmul(input_features, W) + b

z= tf.nn.softmax(output)

a1 = tf.argmax(z,axis=1)
b1 = tf.argmax(input_lables,axis=1)
err = tf.count_nonzero(a1-b1) #两个数组相减,不为0的就是错误个数

cross_entropy = tf.nn.softmax_cross_entropy_with_logits(labels=input_lables,logits=output)
loss = tf.reduce_mean(cross_entropy) #对交叉熵取均值很有必要

optimizer = tf.train.AdamOptimizer(0.04) #尽量用这个--收敛快,会动态调节梯度
train = optimizer.minimize(loss)  # let the optimizer train

补充知识:

  • 在实际使用softmax算法的过程中,分类标签都是one_hot编码。

  • 参数axis=1,表示沿着每一行或者列标签执行响应的方法;axis=0,表示沿着每一列或者行标签执行响应的方法。

axis参数的含义.jpg

函数tf.nn.softmax_cross_entropy_with_logits()只代表了
\sum_{x}[ylna+(1-y)ln(1-a)]
所以还需要对交叉熵再次求均值。

3、设置参数进行训练

with tf.Session() as sess:
    sess.run(tf.global_variables_initializer())

    for epoch in range(maxEpochs):
        sumerr = 0
        for i in range(np.int32(len(Y) / minibatchSize)):
            x1 = X[i * minibatchSize:(i + 1) * minibatchSize, :]
            y1 = Y[i * minibatchSize:(i + 1) * minibatchSize, :]

            _, lossval, outputval, errval = sess.run([train, loss, output, err],
                                                     feed_dict={input_features: x1, input_lables: y1})
            sumerr = sumerr + (errval / minibatchSize)

        print("Epoch:", '%04d' % (epoch + 1), "cost=", "{:.9f}".format(lossval), "err=", sumerr / minibatchSize)

4、数据可视化

train_X, train_Y = generate(200, num_classes, [[3.0], [3.0, 0]], False)
aa = [np.argmax(l) for l in train_Y]
colors = ['r' if l == 0 else 'b' if l == 1 else 'y' for l in aa[:]]
plt.scatter(train_X[:, 0], train_X[:, 1], c=colors)

x = np.linspace(-1, 8, 200)

y = -x * (sess.run(W)[0][0] / sess.run(W)[1][0]) - sess.run(b)[0] / sess.run(W)[1][0]
plt.plot(x, y, label='first line', lw=3)

y = -x * (sess.run(W)[0][1] / sess.run(W)[1][1]) - sess.run(b)[1] / sess.run(W)[1][1]
plt.plot(x, y, label='second line', lw=2)

y = -x * (sess.run(W)[0][2] / sess.run(W)[1][2]) - sess.run(b)[2] / sess.run(W)[1][2]
plt.plot(x, y, label='third line', lw=1)

plt.legend()
plt.show()
print(sess.run(W), sess.run(b))
  • train_X和train_Y分别是取的200个测试点。

  • 根据softmax算法的定义,输出结果z应该是一组数组,某个类别的值(概率)越大,那么此样本属于某一类。

    因为输出端是三个值,所以相当于是3条直线。根据直线公式:
    根据直线公式:
    w_1x_1+w_2x_2+b=0
    得出:
    x_2=-x_1\frac{w_1}{w_2}-\frac{b}{w_2}

    三分类可视化结果.png

也可以画出更直观的图示:

nb_of_xs = 200
    xs1 = np.linspace(-1, 8, num=nb_of_xs)
    xs2 = np.linspace(-1, 8, num=nb_of_xs)
    xx, yy = np.meshgrid(xs1, xs2)  # create the grid
    # Initialize and fill the classification plane
    classification_plane = np.zeros((nb_of_xs, nb_of_xs))
    for i in range(nb_of_xs):
        for j in range(nb_of_xs):
            # classification_plane[i,j] = nn_predict(xx[i,j], yy[i,j])
            classification_plane[i, j] = sess.run(a1, feed_dict={input_features: [[xx[i, j], yy[i, j]]]})

            # Create a color map to show the classification colors of each grid point
    cmap = ListedColormap([
        colorConverter.to_rgba('r', alpha=0.30),
        colorConverter.to_rgba('b', alpha=0.30),
        colorConverter.to_rgba('y', alpha=0.30)])
    # Plot the classification plane with decision boundary and input samples
    plt.contourf(xx, yy, classification_plane, cmap=cmap)
    plt.show()
  • 函数plt.contourf()是绘制等高线的函数。contour和contourf都是画三维等高线图的,不同点在于contour() 是绘制轮廓线,contourf()会填充轮廓。除非另有说明,否则两个版本的函数是相同的。
coutourf([X, Y,] Z,[levels], **kwargs)

例如:

# 计算x,y坐标对应的高度值
def f(x, y):
 return (1-x/2+x**3+y**5) * np.exp(-x**2-y**2)
 
# 生成x,y的数据
n = 256
x = np.linspace(-3, 3, n)
y = np.linspace(-3, 3, n)
 
# 把x,y数据生成mesh网格状的数据,因为等高线的显示是在网格的基础上添加上高度值
X, Y = np.meshgrid(x, y)
 
# 填充等高线
plt.contourf(X, Y, f(X, Y))
# 显示图表
plt.show()
等高线图示例.png

使用隐藏层解决非线性问题

对于线性不可分的数据样本,可以使用多层神经网络来解决,也就是在输入层和输出层中间多加一些神经元,每一层可以加多个,也可以加多层。

隐藏层网络.png
import tensorflow as tf
import numpy as np

learning_rate = 1e-4
n_input  = 2 #两个数
n_label  = 1 #代表最终结果
n_hidden = 2 #隐藏层里有两个节点

x = tf.placeholder(tf.float32,[None,n_input])
y = tf.placeholder(tf.float32,[None,n_label])

weights = {
    'h1':tf.Variable(tf.truncated_normal([n_input,n_hidden],stddev=0.1)),
    'h2':tf.Variable(tf.truncated_normal([n_hidden,n_label],stddev=0.1))
}
biases = {
    'h1':tf.Variable(tf.zeros([n_hidden])),
    'h2':tf.Variable(tf.zeros([n_label]))
}

# 定义网络模型,第一层用relu激活函数,第二层用tanh函数
layer_1 = tf.nn.relu(tf.add(tf.matmul(x, weights['h1']), biases['h1']))
# y_pred = tf.nn.tanh(tf.add(tf.matmul(weights['h2'],layer_1),biases['h2']))
y_pred = tf.nn.tanh(tf.add(tf.matmul(layer_1, weights['h2']),biases['h2']))

loss = tf.reduce_mean((y_pred - y) ** 2) # MSE损失函数
optimizer = tf.train.AdamOptimizer(learning_rate)
trian_step = optimizer.minimize(loss)

#构建模拟数据,数据为异或关系
X=[[0,0],[0,1],[1,0],[1,1]]
Y=[[0],[1],[1],[0]]
X = np.array(X).astype('float32')
Y = np.array(Y).astype('int16')

#加载session
sess = tf.InteractiveSession()
sess.run(tf.global_variables_initializer())

#训练
for i in range(10000):
    sess.run(trian_step,feed_dict={x:X,y:Y})

print(sess.run(y_pred,feed_dict={x:X}))
print(sess.run(layer_1,feed_dict={x:X}))
  • y_pred = tf.nn.tanh(tf.add(tf.matmul(layer_1, weights['h2']),biases['h2']))这句话不能像注释里写的那样,矩阵乘法不满足交换律。

  • tf.InteractiveSession()tf.Session()的区别在于,前者可以在构建session后再定义操作,后者必须在会话构建之前定义好全部的操作(operation)。

欠拟合和过拟合

在模型训练过程中会出现欠拟合和过拟合的问题,欠拟合的原因并不是模型不行,而是我们的学习方法无法更精准地学习到适合的模型参数。模型越薄弱,对训练的要求就越高,但是可以采用增加节点或者增加隐藏层的方式,让模型具有更高的拟合性,从而降低模型的训练难度。过拟合的表现在模型在训练集上的表现非常好,loss很小;但是在测试集上的表现却非常差。

避免过拟合的方法很多:常用的有early stopping、数据集扩增、正则化、dropout

  • early stopping:在发生过拟合之前提前结束,缺点是这个点不好把握

  • data augmentation:让模型接受更多的样本数据,在实际使用中对于未来事件的预测显得鞭长莫及。

  • regulatization:引入范数的概念,增强模型的泛化能力,包括L1和L2(weight dacay)

  • dropout:每次训练时舍去一些节点来增强泛化能力。

1、正则化

本质就是加入噪声,在计算loss时,在损失后面再加上意向,这样预测结果与标签间的误差就会受到干扰,导致学习参数W和b无法按照目标方向来调整,从而实现模型与训练数据无法完全拟合的效果,从而防止过拟合。

这个添加的干扰项必须具有如下特性:

  • 如果出现欠拟合,希望这个干扰项对模型误差的影响越小越好,以便让模型快速拟合实际。

  • 如果是过拟合,希望它对模型的影响越大越好。

这里有两个范数L1和L2:

  • L1 : 所有学习参数w的绝对值的和 L1范数是指向量中各个元素绝对值之和

tf.reduce_sum(tf.abs(w))

  • L2:所有学习参数w的平方和的平方根 L2范数是指向量各元素的平方和然后求平方根

tf.nn.l2_loss(t,name=None)

2、dropout----训练过程中,将部分神经单元暂时丢弃

拿上面的异或数据做举例,dropout方法就是在刚刚的layer_1层后面再添加一个dropout层。

# 定义网络模型,第一层用relu激活函数,第二层用tanh函数
layer_1 = tf.nn.relu(tf.add(tf.matmul(x, weights['h1']), biases['h1']))

keep_prob = tf.placeholder("float")
layer_1_drop = tf.nn.dropout(layer_1,keep_prob)
layer_2 = tf.add(tf.matmul(layer_1_drop,weights['h2']),biases['h2'])

y_pred = tf.nn.relu(tf.add(tf.matmul(layer_1, weights['h2']),biases['h2']))

实际训练时,将keep_prob设置成0.6,意味着每次训练将仅允许0.6的节点参与学习运算。由于学习速度这样就变慢了,可以将learning_rate调大,加快训练速度。注意:在测试时,需要将keep_prob设置为1。

全连接神经网络是一个通用的拟合数据的框架,只要有足够多的神经元,及时只有一层hidden layer,利用常见的Sigmoid,relu等激活函数,就可以无限逼近任何连续函数。在实际使用中,如果想利用浅层神经网络拟合复杂非线性函数,就需要你靠增加的神经元个数来实现,神经元过多会造成参数过多,从而增加网络的学习难度,并影响网络的泛化能力。因此,在实际构建网络结构时,一般倾向于使用更深的模型,开减少所需要的神经元数量。

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

推荐阅读更多精彩内容