西瓜书第五章习题

image

答:使用线性函数作为激活函数时,无论是在隐藏层还是在输出层(无论传递几层),其单元值(在使用激活函数之前)都还是输入 xxx 的线性组合,这个时候的神经网络其实等价于逻辑回归(即原书中的对率回归,输出层仍然使用Sigmoid函数)的,若输出层也使用线性函数作为激活函数,那么就等价于线性回归 。

例如:

image

也就是说,如果激活函数是一个线性函数,那么神经网络模型本质上是一个多元线性回归模型。

一个多元线性回归模型只能在样本点之间画出一道平面,例如二维数据(x1,x2),一元线性回归就是画一条直线分开他们,如果实际分类函数是非线性的,那么显然分类效果是不好的。书上99—100提到过。

参考资料:

ML-ZhouZhihua-partial-answers

程序员管小亮 第5章 - 神经网络

image
image

使用Sigmoid激活函数,每个神经元几乎和对率回归相同,只不过对率回归在 sigmoid(x)>0.5sigmoid(x) > 0.5sigmoid(x)>0.5 时输出为1,而神经元直接输出 sigmoid(x)sigmoid(x)sigmoid(x)。

也就是说,隐层的输出结果就是对训练集作对率回归的结果,最终的输出结果就是对上一隐层的输出作对率回归的结果。

单隐层神经网络中,若取隐层神经元数量为1,输出层权值为1,其相当于对率回归。

PS:有点IVLS的感觉

image
image
image

参考资料:

ML-ZhouZhihua-partial-answers 机器学习(周志华)第五章练习答案.ipynb

image

若使用梯度下降法,取值过大收敛速度较慢,取值过小有可能在极值附近反复迭代。

如果学习率太低,每次下降的很慢,使得迭代次数非常多。如果学习率太高,在后面迭代时会出现震荡现在,在最小值附近来回波动。

image

简单说就是学习率太高会导致误差函数来回震荡,无法收敛;而学习率太低则会收敛太慢,影响训练效率,在原书p104也提到过。

学习率 η 控制着梯度下降法的搜索步长(相关内容可参考书p408-附录B.4的梯度下降法的内容):

从公式去看的话,如下:

image

对于固定的 η,参考书p109页: η 过大,收敛过程易振荡, η 过小,收敛速度过慢。

常把学习率 η 设置为随迭代次数变化的量,使其随着训练的要求变化而变化(一般是减小)。如刚开始 η 大以快速到达到目标值附近, 后期 η 小以保证收敛稳定。

详细的学习率衰减见博客——https://blog.csdn.net/TeFuirnever/article/details/90575969

image

标准 BP 算法和累积 BP 算法在原书(P105)中也提到过,就是对应标准梯度下降和随机梯度下降,差别就是后者每次迭代用全部数据计算梯度,前者用一个数据计算梯度。

代码如下:

import numpy as np
import copy
import pandas as pd
import bpnnUtil
from sklearn import datasets

class BpNN(object):
    def __init__(
            self,
            layer_dims_,
            learning_rate=0.1,
            seed=16,
            initializer='he',
            optimizer='gd'):

        self.layer_dims_ = layer_dims_
        self.learning_rate = learning_rate
        self.seed = seed
        self.initializer = initializer
        self.optimizer = optimizer

    def fit(self, X_, y_, num_epochs=100):
        m, n = X_.shape
        layer_dims_ = copy.deepcopy(self.layer_dims_)
        layer_dims_.insert(0, n)

        if y_.ndim == 1:
            y_ = y_.reshape(-1, 1)

        assert self.initializer in ('he', 'xavier')

        if self.initializer == 'he':
            self.parameters_ = bpnnUtil.xavier_initializer(
                layer_dims_, self.seed)
        elif self.initializer == 'xavier':
            self.parameters_ = bpnnUtil.xavier_initializer(
                layer_dims_, self.seed)

        assert self.optimizer in ('gd', 'sgd', 'adam', 'momentum')
        if self.optimizer == 'gd':
            parameters_, costs = self.optimizer_gd(
                X_, y_, self.parameters_, num_epochs, self.learning_rate)
        elif self.optimizer == 'sgd':
            parameters_, costs = self.optimizer_sgd(
                X_, y_, self.parameters_, num_epochs, self.learning_rate, self.seed)
        elif self.optimizer == 'momentum':
            parameters_, costs = self.optimizer_sgd_monment(
                X_, y_, self.parameters_, beta=0.9, num_epochs=num_epochs, learning_rate=self.learning_rate, seed=self.seed)
        elif self.optimizer == 'adam':
            parameters_, costs = self.optimizer_sgd_adam(X_, y_, self.parameters_, beta1=0.9, beta2=0.999, epsilon=1e-7,
                                                         num_epochs=num_epochs, learning_rate=self.learning_rate,
                                                         seed=self.seed)

        self.parameters_ = parameters_
        self.costs = costs

        return self

    def predict(self, X_):
        if not hasattr(self, "parameters_"):
            raise Exception('you have to fit first before predict.')

        a_last, _ = self.forward_L_layer(X_, self.parameters_)
        if a_last.shape[1] == 1:
            predict_ = np.zeros(a_last.shape)
            predict_[a_last >= 0.5] = 1
        else:
            predict_ = np.argmax(a_last, axis=1)
        return predict_

    def compute_cost(self, y_hat_, y_):
        if y_.ndim == 1:
            y_ = y_.reshape(-1, 1)
        if y_.shape[1] == 1:
            cost = bpnnUtil.cross_entry_sigmoid(y_hat_, y_)
        else:
            cost = bpnnUtil.cross_entry_softmax(y_hat_, y_)
        return cost

    def backward_one_layer(self, da_, cache_, activation_):
        # 在activation_ 为'softmax'时, da_实际上输入是y_, 并不是
        (a_pre_, w_, b_, z_) = cache_
        m = da_.shape[0]

        assert activation_ in ('sigmoid', 'relu', 'softmax')

        if activation_ == 'sigmoid':
            dz_ = bpnnUtil.sigmoid_backward(da_, z_)
        elif activation_ == 'relu':
            dz_ = bpnnUtil.relu_backward(da_, z_)
        else:
            dz_ = bpnnUtil.softmax_backward(da_, z_)

        dw = np.dot(dz_.T, a_pre_) / m
        db = np.sum(dz_, axis=0, keepdims=True) / m
        da_pre = np.dot(dz_, w_)

        assert dw.shape == w_.shape
        assert db.shape == b_.shape
        assert da_pre.shape == a_pre_.shape

        return da_pre, dw, db

    def backward_L_layer(self, a_last, y_, caches):

        grads = {}
        L = len(caches)

        if y_.ndim == 1:
            y_ = y_.reshape(-1, 1)

        if y_.shape[1] == 1:  # 目标值只有一列表示为二分类
            da_last = -(y_ / a_last - (1 - y_) / (1 - a_last))
            da_pre_L_1, dwL_, dbL_ = self.backward_one_layer(
                da_last, caches[L - 1], 'sigmoid')

        else:  # 经过one hot,表示为多分类

            # 在计算softmax的梯度时,可以直接用 dz = a - y可计算出交叉熵损失函数对z的偏导, 所以这里第一个参数输入直接为y_
            da_pre_L_1, dwL_, dbL_ = self.backward_one_layer(
                y_, caches[L - 1], 'softmax')

        grads['da' + str(L)] = da_pre_L_1
        grads['dW' + str(L)] = dwL_
        grads['db' + str(L)] = dbL_

        for i in range(L - 1, 0, -1):
            da_pre_, dw, db = self.backward_one_layer(
                grads['da' + str(i + 1)], caches[i - 1], 'relu')

            grads['da' + str(i)] = da_pre_
            grads['dW' + str(i)] = dw
            grads['db' + str(i)] = db

        return grads

    def forward_one_layer(self, a_pre_, w_, b_, activation_):
        z_ = np.dot(a_pre_, w_.T) + b_
        assert activation_ in ('sigmoid', 'relu', 'softmax')

        if activation_ == 'sigmoid':
            a_ = bpnnUtil.sigmoid(z_)
        elif activation_ == 'relu':
            a_ = bpnnUtil.relu(z_)
        else:
            a_ = bpnnUtil.softmax(z_)

        cache_ = (a_pre_, w_, b_, z_)  # 将向前传播过程中产生的数据保存下来,在向后传播过程计算梯度的时候要用上的。
        return a_, cache_

    def forward_L_layer(self, X_, parameters_):
        L_ = int(len(parameters_) / 2)
        caches = []
        a_ = X_
        for i in range(1, L_):
            w_ = parameters_['W' + str(i)]
            b_ = parameters_['b' + str(i)]
            a_pre_ = a_
            a_, cache_ = self.forward_one_layer(a_pre_, w_, b_, 'relu')
            caches.append(cache_)

        w_last = parameters_['W' + str(L_)]
        b_last = parameters_['b' + str(L_)]

        if w_last.shape[0] == 1:
            a_last, cache_ = self.forward_one_layer(
                a_, w_last, b_last, 'sigmoid')
        else:
            a_last, cache_ = self.forward_one_layer(
                a_, w_last, b_last, 'softmax')

        caches.append(cache_)
        return a_last, caches

    def optimizer_gd(self, X_, y_, parameters_, num_epochs, learning_rate):
        costs = []
        for i in range(num_epochs):
            a_last, caches = self.forward_L_layer(X_, parameters_)
            grads = self.backward_L_layer(a_last, y_, caches)

            parameters_ = bpnnUtil.update_parameters_with_gd(
                parameters_, grads, learning_rate)
            cost = self.compute_cost(a_last, y_)

            costs.append(cost)

        return parameters_, costs

    def optimizer_sgd(
            self,
            X_,
            y_,
            parameters_,
            num_epochs,
            learning_rate,
            seed):
        '''
        sgd中,更新参数步骤和gd是一致的,只不过在计算梯度的时候是用一个样本而已。
        '''
        np.random.seed(seed)
        costs = []
        m_ = X_.shape[0]
        for _ in range(num_epochs):
            random_index = np.random.randint(0, m_)

            a_last, caches = self.forward_L_layer(
                X_[[random_index], :], parameters_)
            grads = self.backward_L_layer(
                a_last, y_[[random_index], :], caches)

            parameters_ = bpnnUtil.update_parameters_with_sgd(
                parameters_, grads, learning_rate)

            a_last_cost, _ = self.forward_L_layer(X_, parameters_)

            cost = self.compute_cost(a_last_cost, y_)

            costs.append(cost)

        return parameters_, costs

    def optimizer_sgd_monment(
            self,
            X_,
            y_,
            parameters_,
            beta,
            num_epochs,
            learning_rate,
            seed):
        '''
        :param X_:
        :param y_:
        :param parameters_: 初始化的参数
        :param v_:          梯度的指数加权移动平均数
        :param beta:        冲量大小,
        :param num_epochs:
        :param learning_rate:
        :param seed:
        :return:
        '''
        np.random.seed(seed)
        costs = []
        m_ = X_.shape[0]
        velcoity = bpnnUtil.initialize_velcoity(parameters_)
        for _ in range(num_epochs):
            random_index = np.random.randint(0, m_)

            a_last, caches = self.forward_L_layer(
                X_[[random_index], :], parameters_)
            grads = self.backward_L_layer(
                a_last, y_[[random_index], :], caches)

            parameters_, v_ = bpnnUtil.update_parameters_with_sgd_momentum(
                parameters_, grads, velcoity, beta, learning_rate)
            a_last_cost, _ = self.forward_L_layer(X_, parameters_)
            cost = self.compute_cost(a_last_cost, y_)
            costs.append(cost)

        return parameters_, costs

    def optimizer_sgd_adam(
            self,
            X_,
            y_,
            parameters_,
            beta1,
            beta2,
            epsilon,
            num_epochs,
            learning_rate,
            seed):
        '''
        :param X_:
        :param y_:
        :param parameters_: 初始化的参数
        :param v_:          梯度的指数加权移动平均数
        :param beta:        冲量大小,
        :param num_epochs:
        :param learning_rate:
        :param seed:
        :return:
        '''
        np.random.seed(seed)
        costs = []
        m_ = X_.shape[0]
        velcoity, square_grad = bpnnUtil.initialize_adam(parameters_)
        for epoch in range(num_epochs):
            random_index = np.random.randint(0, m_)

            a_last, caches = self.forward_L_layer(
                X_[[random_index], :], parameters_)
            grads = self.backward_L_layer(
                a_last, y_[[random_index], :], caches)

            parameters_, velcoity, square_grad = bpnnUtil.update_parameters_with_sgd_adam(
                parameters_, grads, velcoity, square_grad, epoch + 1, learning_rate, beta1, beta2, epsilon)
            a_last_cost, _ = self.forward_L_layer(X_, parameters_)
            cost = self.compute_cost(a_last_cost, y_)
            costs.append(cost)

        return parameters_, costs


if __name__ == '__main__':
    # 5.5
    # data_path = r'C:\Users\hanmi\Documents\xiguabook\watermelon3_0_Ch.csv'
    # data3 = pd.read_csv(data_path, index_col=0)
    # data = pd.get_dummies(data3, columns=['色泽', '根蒂', '敲声', '纹理', '脐部', '触感'])
    # data['好瓜'].replace(['是', '否'], [1, 0], inplace=True)
    # X_test = data.drop('好瓜', axis=1)
    # y_test = data['好瓜']
    #
    # bp = BpNN([3, 1], learning_rate=0.1, optimizer='gd')
    # bp.fit(X_test.values, y_test.values, num_epochs=200)

    # bp1 = BpNN([3, 1], learning_rate=0.1, optimizer='sgd')
    # bp1.fit(X_test.values, y_test.values, num_epochs=200)
    #
    # bpnnUtil.plot_costs([bp.costs, bp1.costs], ['gd_cost', 'sgd_cost'])

    # 5.6
    iris = datasets.load_iris()
    X = pd.DataFrame(iris['data'], columns=iris['feature_names'])
    X = (X - np.mean(X, axis=0)) / np.var(X, axis=0)

    y = pd.Series(iris['target_names'][iris['target']])
    y = pd.get_dummies(y)

    bp = BpNN([3, 3], learning_rate=0.003, optimizer='adam')
    bp.fit(X.values, y.values, num_epochs=2000)

    bp1 = BpNN([3, 3], learning_rate=0.003, optimizer='sgd')
    bp1.fit(X.values, y.values, num_epochs=2000)

    bpnnUtil.plot_costs([bp.costs, bp1.costs], ['adam_cost', 'sgd_cost'])

具体两种情况的结果如下图:可以看出来gd的成本函数收敛过程更加稳定,而sgd每次迭代并不一定向最优方向前进,但总体方向是收敛的,且同样是迭代200次,最后结果相差不大,但由于sgd每次迭代只使用一个样本,计算量大幅度下降,显然sgd的速度会更快。

ps.关于随机梯度下降的实现,好像有两种方式,一种是每次将样本打乱,然后遍历所有样本,而后再次打乱、遍历;另一种是每次迭代随机抽取样本。这里采取的是后一种方式,貌似两种方式都可以。

此外,BP神经网络代码在以前学吴恩达老师深度学习课程的时候就写过,这次整理了一下正好放上来,所以很多代码和课程代码类似,添加了应用多分类的情况的代码。下面的5.6题也一并在这里实现。

image

代码片段及实现过程参考:机器学习(周志华) 参考答案 第五章 神经网络 5.5

image

动态调整学习率有很多现成的算法,RMSProp、Adam、NAdam等等。也可以手动实现一个简单指数式衰减

image

,rrr 是一个超参。这里代码实现了Adam,代码和5.5一同实现,在上面。

这里只尝试了sklearn 中自带的iris数据集试了一下。同样学习率下,两者训练时损失函数如下:

image

可以明显看出adam的速度更快的。

书中给出了两篇文献供参考:

Neural Networks: Tricks of the Trade

Neural Smithing:Supervised learning in Feedforward Artificial Neural Networks

image

这里可以使用X = array([[1, 0], [0, 1], [0, 0], [1, 1]]),y = array([[1], [1], [0], [0]])作为数据,训练一个RBF神经网络。

这里使用均方根误差作为损失函数;输出层和书上一致,为隐藏层的线性组合,且另外加上了一个偏置项(这是书上没有)。

代码如下:

'''
这里使用均方根误差作为损失函数的RBF神经网络。
'''
import numpy as np
import matplotlib.pyplot as plt

def RBF_forward(X_, parameters_):
    m, n = X_.shape
    beta = parameters_['beta']
    W = parameters_['W']
    c = parameters_['c']
    b = parameters_['b']

    t_ = c.shape[0]
    p = np.zeros((m, t_))  # 中间隐藏层的激活值     对应书上5.19式
    x_c = np.zeros((m, t_))  # 5.19式中 x - c_{i}
    for i in range(t_):
        x_c[:, i] = np.linalg.norm(X_ - c[[i], ], axis=1) ** 2

        p[:, i] = np.exp(-beta[0, i] * x_c[:, i])

    a = np.dot(p, W.T) + b
    return a, p, x_c

def RBF_backward(a_, y_, x_c, p_, parameters_):
    m, n = a_.shape
    grad = {}
    beta = parameters_['beta']
    W = parameters_['W']

    da = (a_ - y_)      # 损失函数对输出层的偏导 ,这里的a其实对应着  输出层的y_hat

    dw = np.dot(da.T, p_) / m
    db = np.sum(da, axis=0, keepdims=True) / m
    dp = np.dot(da, W)   # dp即损失函数对隐藏层激活值的偏导

    dbeta = np.sum(dp * p_ * (-x_c), axis=0, keepdims=True) / m

    assert dbeta.shape == beta.shape
    assert dw.shape == W.shape
    grad['dw'] = dw
    grad['dbeta'] = dbeta
    grad['db'] = db

    return grad

def compute_cost(y_hat_, y_):
    m = y_.shape[0]
    loss = np.sum((y_hat_ - y) ** 2) / (2 * m)
    return np.squeeze(loss)

def RBF_model(X_, y_, learning_rate, num_epochs, t):
    '''
    :param X_:
    :param y_:
    :param learning_rate:  学习率
    :param num_epochs:     迭代次数
    :param t:   隐藏层节点数量
    :return:
    '''
    parameters = {}
    np.random.seed(16)
    # 定义中心点,本来这里的中心点应该由随机采用或者聚类等非监督学习来获得的,这里为了简单就直接定义好了

    parameters['beta'] = np.random.randn(1, t)  # 初始化径向基的方差
    parameters['W'] = np.zeros((1, t))  # 初始化
    parameters['c'] = np.random.rand(t, 2)
    parameters['b'] = np.zeros([1, 1])
    costs = []

    for i in range(num_epochs):
        a, p, x_c = RBF_forward(X_, parameters)
        cost = compute_cost(a, y_)
        costs.append(cost)
        grad = RBF_backward(a, y_, x_c, p, parameters)

        parameters['beta'] -= learning_rate * grad['dbeta']
        parameters['W'] -= learning_rate * grad['dw']
        parameters['b'] -= learning_rate * grad['db']

    return parameters, costs

def predict(X_, parameters_):
    a, p, x_c = RBF_forward(X_, parameters_)

    return a

X = np.array([[1, 0], [0, 1], [0, 0], [1, 1]])
y = np.array([[1], [1], [0], [0]])
#

parameters, costs = RBF_model(X, y, 0.003, 10000, 8)

plt.plot(costs)
plt.show()

print(predict(X, parameters))

# 梯度检验
# parameters = {}
# parameters['beta'] = np.random.randn(1, 2)  # 初始化径向基的方差
# parameters['W'] = np.random.randn(1, 2)  # 初始化
# parameters['c'] = np.array([[0.1, 0.1], [0.8, 0.8]])
# parameters['b'] = np.zeros([1, 1])
# a, p, x_c = RBF_forward(X, parameters)
#
# cost = compute_cost(a, y)
# grad = RBF_backward(a, y, x_c, p, parameters)
#
#
# parameters['b'][0, 0] += 1e-6
#
# a1, p1, x_c1 = RBF_forward(X, parameters)
# cost1 = compute_cost(a1, y)
# print(grad['db'])
#
# print((cost1 - cost) / 1e-6)

image

最后输出是:

[[ 9.99944968e-01]

[ 9.99881045e-01]

[ 8.72381056e-05]

[ 1.26478454e-04]]

PS:分类的时候在输出层使用sigmoid作为激活函数也可以。

image

参考资料----------->

周志华《机器学习》课后习题解答系列(六):Ch5.8 - SOM网络实验

这里查看完整代码和数据集

image

Elman比正常网络多了个反馈,把前一次的bhbh作为隐层的输入来调节隐层。

假设用uihuih来表示反馈输入与隐层连接的参数,由于前一次计算的bhbh作为常数输入,uijuij与vijvij的计算方法一样,Δuih=ηehbhΔuih=ηehbh,其中e_h书上5.15给出。就是相当于多了几个输入会变的输入层神经元。

1、零基础入门深度学习(5) - 循环神经网络网上大神写了。

另外关于循环神经网络也可以看看吴恩达老师的深度学习课程“序列模型”那部分。

参考资料------------->

四去六进一 机器学习(周志华) 参考答案 第五章

image

(程序员管小亮):正好前段时间做过Kaggle上手写数字识别的题目。这里正好放上来,CNN是用Tensorflow实现的,之前看吴恩达老师深度学习课程的时候也拿numpy实现过(课程作业)

https://github.com/han1057578619/kaggle_competition/tree/master/Digit_Recogniz

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