机器学习算法GBDT的面试要点

http://www.cnblogs.com/ModifyRong/p/7744987.html

1.简介

  gbdt全称梯度下降树,在传统机器学习算法里面是对真实分布拟合的最好的几种算法之一,在前几年深度学习还没有大行其道之前,gbdt在各种竞赛是大放异彩。原因大概有几个,一是效果确实挺不错。二是即可以用于分类也可以用于回归。三是可以筛选特征。这三点实在是太吸引人了,导致在面试的时候大家也非常喜欢问这个算法。 gbdt的面试考核点,大致有下面几个:

gbdt 的算法的流程?

gbdt 如何选择特征 ?

gbdt 如何构建特征 ?

gbdt 如何用于分类?

gbdt 通过什么方式减少误差 ?

gbdt的效果相比于传统的LR,SVM效果为什么好一些 ?

gbdt 如何加速训练?

gbdt的参数有哪些,如何调参 ?

gbdt 实战当中遇到的一些问题 ?

gbdt的优缺点 ?

2. 正式介绍

 首先gbdt 是通过采用加法模型(即基函数的线性组合),以及不断减小训练过程产生的残差来达到将数据分类或者回归的算法。

gbdt的训练过程

 我们通过一张图片,图片来源来说明gbdt的训练过程:

                      图 1:GBDT 的训练过程

gbdt通过多轮迭代,每轮迭代产生一个弱分类器,每个分类器在上一轮分类器的残差基础上进行训练。对弱分类器的要求一般是足够简单,并且是低方差和高偏差的。因为训练的过程是通过降低偏差来不断提高最终分类器的精度,(此处是可以证明的)。

弱分类器一般会选择为CART TREE(也就是分类回归树)。由于上述高偏差和简单的要求 每个分类回归树的深度不会很深。最终的总分类器 是将每轮训练得到的弱分类器加权求和得到的(也就是加法模型)。

        模型最终可以描述为:

Fm(x)=∑m=1MT(x;θm)Fm(x)=∑m=1MT(x;θm)

模型一共训练M轮,每轮产生一个弱分类器 T(x;θm)T(x;θm)。弱分类器的损失函数

θ^m=argminθm∑i=1NL(yi,Fm−1(xi)+T(xi;θm))θ^m=arg⁡minθm⁡∑i=1NL(yi,Fm−1(xi)+T(xi;θm))

 Fm−1(x)Fm−1(x)为当前的模型,gbdt 通过经验风险极小化来确定下一个弱分类器的参数。具体到损失函数本身的选择也就是L的选择,有平方损失函数,0-1损失函数,对数损失函数等等。如果我们选择平方损失函数,那么这个差值其实就是我们平常所说的残差。

但是其实我们真正关注的,1.是希望损失函数能够不断的减小,2.是希望损失函数能够尽可能快的减小。所以如何尽可能快的减小呢?

让损失函数沿着梯度方向的下降。这个就是gbdt 的 gb的核心了。 利用损失函数的负梯度在当前模型的值作为回归问题提升树算法中的残差的近似值去拟合一个回归树。gbdt 每轮迭代的时候,都去拟合损失函数在当前模型下的负梯度。

这样每轮训练的时候都能够让损失函数尽可能快的减小,尽快的收敛达到局部最优解或者全局最优解。

gbdt如何选择特征?

        gbdt选择特征的细节其实是想问你CART Tree生成的过程。这里有一个前提,gbdt的弱分类器默认选择的是CART TREE。其实也可以选择其他弱分类器的,选择的前提是低方差和高偏差。框架服从boosting 框架即可。

        下面我们具体来说CART TREE(是一种二叉树) 如何生成。CART TREE 生成的过程其实就是一个选择特征的过程。假设我们目前总共有 M 个特征。第一步我们需要从中选择出一个特征 j,做为二叉树的第一个节点。然后对特征 j 的值选择一个切分点 m. 一个 样本的特征j的值 如果小于m,则分为一类,如果大于m,则分为另外一类。如此便构建了CART 树的一个节点。其他节点的生成过程和这个是一样的。现在的问题是在每轮迭代的时候,如何选择这个特征 j,以及如何选择特征 j 的切分点 m:

原始的gbdt的做法非常的暴力,首先遍历每个特征,然后对每个特征遍历它所有可能的切分点,找到最优特征 m 的最优切分点 j。

如何衡量我们找到的特征 m和切分点 j 是最优的呢? 我们用定义一个函数 FindLossAndSplit 来展示一下求解过程:


1def findLossAndSplit(x,y): 2# 我们用 x 来表示训练数据 3# 我们用 y 来表示训练数据的label 4# x[i]表示训练数据的第i个特征 5# x_i 表示第i个训练样本 6 7# minLoss 表示最小的损失 8minLoss = Integet.max_value 9# feature 表示是训练的数据第几纬度的特征10feature = 011# split 表示切分点的个数12split = 01314# M 表示 样本x的特征个数15forjin range(0,M):16# 该维特征下,特征值的每个切分点,这里具体的切分方式可以自己定义17forcin range(0,x[j]):18L = 019# 第一类20R1 = {x|x[j] <= c}21# 第二类22R2 = {x|x[j] > c}23# 属于第一类样本的y值的平均值24y1 = ave{y|x 属于 R1}25# 属于第二类样本的y值的平均值26y2 = ave{y| x 属于 R2}27# 遍历所有的样本,找到 loss funtion 的值28forx_1in all x29if x_1 属于 R1: 30L += (y_1 - y1)^231else:32L += (y_1 - y2)^233ifL < minLoss:34minLoss = L35feature  = i36split = c37returnminLoss,feature ,split


如果对这段代码不是很了解的,可以先去看看李航第五章中对CART TREE 算法的叙述。在这里,我们先遍历训练样本的所有的特征,对于特征 j,我们遍历特征 j 所有特征值的切分点 c。找到可以让下面这个式子最小的特征 j 以及切分点c.

gbdt 如何构建特征 ?

其实说gbdt 能够构建特征并非很准确,gbdt 本身是不能产生特征的,但是我们可以利用gbdt去产生特征的组合。在CTR预估中,工业界一般会采用逻辑回归去进行处理,在我的上一篇博文当中已经说过,逻辑回归本身是适合处理线性可分的数据,如果我们想让逻辑回归处理非线性的数据,其中一种方式便是组合不同特征,增强逻辑回归对非线性分布的拟合能力。

        长久以来,我们都是通过人工的先验知识或者实验来获得有效的组合特征,但是很多时候,使用人工经验知识来组合特征过于耗费人力,造成了机器学习当中一个很奇特的现象:有多少人工就有多少智能。关键是这样通过人工去组合特征并不一定能够提升模型的效果。所以我们的从业者或者学界一直都有一个趋势便是通过算法自动,高效的寻找到有效的特征组合。Facebook 在2014年 发表的一篇论文便是这种尝试下的产物,利用gbdt去产生有效的特征组合,以便用于逻辑回归的训练,提升模型最终的效果。

             图 2:用GBDT 构造特征

        如图 2所示,我们 使用 GBDT 生成了两棵树,两颗树一共有五个叶子节点。我们将样本 X 输入到两颗树当中去,样本X 落在了第一棵树的第二个叶子节点,第二颗树的第一个叶子节点,于是我们便可以依次构建一个五纬的特征向量,每一个纬度代表了一个叶子节点,样本落在这个叶子节点上面的话那么值为1,没有落在该叶子节点的话,那么值为 0.

        于是对于该样本,我们可以得到一个向量[0,1,0,1,0] 作为该样本的组合特征,和原来的特征一起输入到逻辑回归当中进行训练。实验证明这样会得到比较显著的效果提升。

GBDT 如何用于分类 ?

  首先明确一点,gbdt 无论用于分类还是回归一直都是使用的CART 回归树。不会因为我们所选择的任务是分类任务就选用分类树,这里面的核心是因为gbdt 每轮的训练是在上一轮的训练的残差基础之上进行训练的。这里的残差就是当前模型的负梯度值 。这个要求每轮迭代的时候,弱分类器的输出的结果相减是有意义的。残差相减是有意义的。

   如果选用的弱分类器是分类树,类别相减是没有意义的。上一轮输出的是样本 x 属于 A类,本一轮训练输出的是样本 x 属于 B类。 A 和 B 很多时候甚至都没有比较的意义,A 类- B类是没有意义的。

 我们具体到分类这个任务上面来,我们假设样本 X 总共有 K类。来了一个样本 x,我们需要使用gbdt来判断 x 属于样本的哪一类。

图三 gbdt 多分类算法流程

第一步 我们在训练的时候,是针对样本 X 每个可能的类都训练一个分类回归树。举例说明,目前样本有三类,也就是 K = 3。样本 x 属于 第二类。那么针对该样本 x 的分类结果,其实我们可以用一个 三维向量 [0,1,0] 来表示。0表示样本不属于该类,1表示样本属于该类。由于样本已经属于第二类了,所以第二类对应的向量维度为1,其他位置为0。

针对样本有 三类的情况,我们实质上是在每轮的训练的时候是同时训练三颗树。第一颗树针对样本x的第一类,输入为(x,0)(x,0)。第二颗树输入针对 样本x 的第二类,输入为(x,1)(x,1)。第三颗树针对样本x 的第三类,输入为(x,0)(x,0)

在这里每颗树的训练过程其实就是就是我们之前已经提到过的CATR TREE 的生成过程。在此处我们参照之前的生成树的程序 即可以就解出三颗树,以及三颗树对x 类别的预测值f1(x),f2(x),f3(x)f1(x),f2(x),f3(x)。那么在此类训练中,我们仿照多分类的逻辑回归 ,使用softmax 来产生概率,则属于类别 1 的概率

p1=exp(f1(x))/∑k=13exp(fk(x))p1=exp(f1(x))/∑k=13exp(fk(x))

并且我们我们可以针对类别1 求出 残差f11(x)=0−f1(x)f11(x)=0−f1(x);类别2 求出残差f22(x)=1−f2(x)f22(x)=1−f2(x);类别3 求出残差f33(x)=0−f3(x)f33(x)=0−f3(x).

然后开始第二轮训练 针对第一类 输入为(x,f11(x)f11(x)), 针对第二类输入为(x,f22(x))f22(x)), 针对 第三类输入为 (x,f33(x)f33(x)).继续训练出三颗树。一直迭代M轮。每轮构建 3颗树。

        所以当K =3。我们其实应该有三个式子

F1M(x)=∑m=1MC1m^I(xϵR1m)F1M(x)=∑m=1MC1m^I(xϵR1m)

F2M(x)=∑m=1MC2m^I(xϵR2m)F2M(x)=∑m=1MC2m^I(xϵR2m)

F3M(x)=∑m=1MC3m^I(xϵR3m)F3M(x)=∑m=1MC3m^I(xϵR3m)

当训练完毕以后,新来一个样本 x1 ,我们需要预测该样本的类别的时候,便可以有这三个式子产生三个值,f1(x),f2(x),f3(x)f1(x),f2(x),f3(x)。样本属于 某个类别c的概率为

pc=exp(fc(x))/∑k=13exp(fk(x))pc=exp(fc(x))/∑k=13exp(fk(x))

GBDT 多分类举例说明

 上面的理论阐述可能仍旧过于难懂,我们下面将拿Iris 数据集中的六个数据作为例子,来展示gbdt 多分类的过程。

样本编号花萼长度(cm)花萼宽度(cm)花瓣长度(cm)花瓣宽度花的种类

15.13.51.40.2山鸢尾

24.93.01.40.2山鸢尾

37.03.24.71.4杂色鸢尾

46.43.24.51.5杂色鸢尾

56.33.36.02.5维吉尼亚鸢尾

65.82.75.11.9维吉尼亚鸢尾

图四 Iris 数据集

        这是一个有6个样本的三分类问题。我们需要根据这个花的花萼长度,花萼宽度,花瓣长度,花瓣宽度来判断这个花属于山鸢尾,杂色鸢尾,还是维吉尼亚鸢尾。具体应用到gbdt多分类算法上面。我们用一个三维向量来标志样本的label。[1,0,0] 表示样本属于山鸢尾,[0,1,0] 表示样本属于杂色鸢尾,[0,0,1] 表示属于维吉尼亚鸢尾。

        gbdt 的多分类是针对每个类都独立训练一个 CART Tree。所以这里,我们将针对山鸢尾类别训练一个 CART Tree 1。杂色鸢尾训练一个 CART Tree 2 。维吉尼亚鸢尾训练一个CART Tree 3,这三个树相互独立。

我们以样本 1 为例。针对 CART Tree1 的训练样本是[5.1,3.5,1.4,0.2][5.1,3.5,1.4,0.2],label 是 1,最终输入到模型当中的为[5.1,3.5,1.4,0.2,1][5.1,3.5,1.4,0.2,1]。针对 CART Tree2 的训练样本也是[5.1,3.5,1.4,0.2][5.1,3.5,1.4,0.2],但是label 为 0,最终输入模型的为[5.1,3.5,1.4,0.2,0][5.1,3.5,1.4,0.2,0]. 针对 CART Tree 3的训练样本也是[5.1,3.5,1.4,0.2][5.1,3.5,1.4,0.2],label 也为0,最终输入模型当中的为[5.1,3.5,1.4,0.2,0][5.1,3.5,1.4,0.2,0].

下面我们来看 CART Tree1 是如何生成的,其他树 CART Tree2 , CART Tree 3的生成方式是一样的。CART Tree的生成过程是从这四个特征中找一个特征做为CART Tree1 的节点。比如花萼长度做为节点。6个样本当中花萼长度 大于5.1 cm的就是 A类,小于等于 5.1 cm 的是B类。生成的过程其实非常简单,问题 1.是哪个特征最合适? 2.是这个特征的什么特征值作为切分点? 即使我们已经确定了花萼长度做为节点。花萼长度本身也有很多值。在这里我们的方式是遍历所有的可能性,找到一个最好的特征和它对应的最优特征值可以让当前式子的值最小。

         我们以第一个特征的第一个特征值为例。R1 为所有样本中花萼长度小于 5.1 cm 的样本集合,R2 为所有样本当中花萼长度大于等于 5.1cm 的样本集合。所以 R1={2}R1={2},R2={1,3,4,5,6}R2={1,3,4,5,6}.

图 5 节点分裂示意图

y1 为 R1 所有样本的label 的均值 1/1=11/1=1。y2 为 R2 所有样本的label 的均值 (1+0+0+0+0)/5=0.2(1+0+0+0+0)/5=0.2。

        下面便开始针对所有的样本计算这个式子的值。样本1 属于 R2 计算的值为(1−0.2)2(1−0.2)2, 样本2 属于R1 计算的值为(1−1)2(1−1)2, 样本 3,4,5,6同理都是 属于 R2的 所以值是(0−0.2)2(0−0.2)2. 把这六个值加起来,便是 山鸢尾类型在特征1 的第一个特征值的损失值。这里算出来(1-0.2)^2+ (1-1)^2 + (0-0.2)^2+(0-0.2)^2+(0-0.2)^2 = 0.8.

接着我们计算第一个特征的第二个特征值,计算方式同上,R1 为所有样本中 花萼长度小于 4.9 cm 的样本集合,R2 为所有样本当中 花萼长度大于等于 4.9 cm 的样本集合.所以 R1={}R1={},R1={1,2,3,4,5,6}R1={1,2,3,4,5,6}. y1 为 R1 所有样本的label 的均值 = 0。y2 为 R2 所有样本的label 的均值 (1+1+0+0+0+0)/6=0.3333(1+1+0+0+0+0)/6=0.3333。

图 6 第一个特征的第二个特侦值的节点分裂情况        

我们需要针对所有的样本,样本1 属于 R2, 计算的值为(1−0.333)2(1−0.333)2, 样本2 属于R2 ,计算的值为(1−0.333)2(1−0.333)2, 样本 3,4,5,6同理都是 属于 R2的, 所以值是(0−0.333)2(0−0.333)2. 把这六个值加起来山鸢尾类型在特征1 的第二个特征值的损失值。这里算出来 (1-0.333)^2+ (1-0.333)^2 + (0-0.333)^2+(0-0.333)^2+(0-0.333)^2 = 2.1333. 这里的损失值大于 特征一的第一个特征值的损失值,所以我们不取这个特征的特征值。

图 7 所有情况说明  

        这样我们可以遍历所有特征的所有特征值,找到让这个式子最小的特征以及其对应的特征值,一共有24种情况,4个特征*每个特征有6个特征值。在这里我们算出来让这个式子最小的特征花萼长度,特征值为5.1 cm。这个时候损失函数最小为 0.8。

        于是我们的预测函数此时也可以得到:

f(x)=∑xϵR1y1∗I(xϵR1)+∑xϵR2y2∗I(xϵR2)f(x)=∑xϵR1y1∗I(xϵR1)+∑xϵR2y2∗I(xϵR2)

        此处 R1 = {2},R2 = {1,3,4,5,6},y1 = 1,y2 = 0.2。训练完以后的最终式子为

f1(x)=∑xϵR11∗I(xϵR1)+∑xϵR20.2∗I(xϵR2)f1(x)=∑xϵR11∗I(xϵR1)+∑xϵR20.2∗I(xϵR2)

借由这个式子,我们得到对样本属于类别1 的预测值 f1(x)=1+0.2∗5=2f1(x)=1+0.2∗5=2。同理我们可以得到对样本属于类别2,3的预测值f2(x)f2(x),f3(x)f3(x).样本属于类别1的概率 即为

p1=exp(f1(x))/∑k=13exp(fk(x))p1=exp(f1(x))/∑k=13exp(fk(x))

        下面我们用代码来实现整个找特征的过程,大家可以自己再对照代码看看。

1# 定义训练数据 2train_data = [[5.1,3.5,1.4,0.2],[4.9,3.0,1.4,0.2],[7.0,3.2,4.7,1.4],[6.4,3.2,4.5,1.5],[6.3,3.3,6.0,2.5],[5.8,2.7,5.1,1.9]] 3 4# 定义label 5label_data = [[1,0,0],[1,0,0],[0,1,0],[0,1,0],[0,0,1],[0,0,1]] 6# index 表示的第几类 7def findBestLossAndSplit(train_data,label_data,index): 8sample_numbers = len(label_data) 9feature_numbers = len(train_data[0])10current_label = []1112# define the minLoss13minLoss = 100000001415# feature represents the dimensions of the feature16feature = 01718# split represents the detail split value19split = 02021# get current label22forlabel_indexin range(0,len(label_data)):23            current_label.append(label_data[label_index][index])2425# trans all features26forfeature_indexin range(0,feature_numbers):27## current feature value28current_value = []2930forsample_indexin range(0,sample_numbers):31                current_value.append(train_data[sample_index][feature_index])32L = 033## different split value34print current_value35forindexin range(0,len(current_value)):36R1 = []37R2 = []38y1 = 039y2 = 04041forindex_1in range(0,len(current_value)):42ifcurrent_value[index_1] < current_value[index]:43                        R1.append(index_1)44else:45                        R2.append(index_1)4647## calculate the samples for first class48sum_y = 049forindex_R1in R1:50sum_y += current_label[index_R1]51iflen(R1) != 0:52y1 = float(sum_y) / float(len(R1))53else:54y1 = 05556## calculate the samples for second class57sum_y = 058forindex_R2in R2:59sum_y += current_label[index_R2]60iflen(R2) != 0:61y2 = float(sum_y) / float(len(R2))62else:63y2 = 06465## trans all samples to find minium loss and best split66forindex_2in range(0,len(current_value)):67ifindex_2in R1:68L += float((current_label[index_2]-y1))*float((current_label[index_2]-y1))69else:70L += float((current_label[index_2]-y2))*float((current_label[index_2]-y2))7172ifL < minLoss:73feature = feature_index74split = current_value[index]75minLoss = L76print"minLoss"77print minLoss78print"split"79print split80print"feature"81print feature82return minLoss,split,feature8384findBestLossAndSplit(train_data,label_data,0)


3 总结

 目前,我们总结了 gbdt 的算法的流程,gbdt如何选择特征,如何产生特征的组合,以及gbdt 如何用于分类,这个目前可以认为是gbdt 最经常问到的四个部分。至于剩余的问题,因为篇幅的问题,我们准备再开一个篇幅来进行总结。

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

推荐阅读更多精彩内容

  • 机器学习是做NLP和计算机视觉这类应用算法的基础,虽然现在深度学习模型大行其道,但是懂一些传统算法的原理和它们之间...
    在河之简阅读 20,500评论 4 65
  • sklearn、XGBoost、LightGBM的文档阅读小记 文章导航 目录 1.sklearn集成方法 1.1...
    nightwish夜愿阅读 12,623评论 1 49
  • 1. RF, GBDT 的区别; GBDT,XGboost 的区别 GBDT在训练每棵树时候只能串行,不能并行,在...
    sylvainwang阅读 3,276评论 0 50
  • 三年级 牛铭逸 风筝,风筝, 飞呀飞。 飞过小桥, 穿过云朵。 风筝,风筝, 等风一停, 它就像降落伞一样, 降落...
    快乐的八戒阅读 461评论 0 3
  • 序章 这是一所在卡布罗集市区再普通不过的小平房。 在日落后天空擦黑的这一小段时间里,集市区歪歪斜斜又犬牙交错的平房...
    缦姐姐阅读 406评论 0 0