决策树的生成是个递归过程,CART算法采用基尼指数选择最优特征,建树过程如下:
递归对每个节点进行以下操作,构建二叉决策树:
算法停止条件是:节点中样本个数小于预定阈值,或样本集的基尼指数小于预定阈值(样本基本属于同一类),或者没有更多特征;
=== 3点考虑
基尼指数公式:
其中D表示给定样本集合, D k D_k Dk是D中属于第k类的样本子集,K是类的个数
当按照某个属性a进行划分后,此时的基尼系数为:
在选择合适的决策节点时,就按照使基尼系数最小的那个属性来划分。
参考文章
Adaboost算法是前向分步加法算法的特例。模型是由基本分类器组成的加法模型,损失函数是指数函数。
强分类器(即基学习器的线性组合)计算公式:
其中 x x x是输入向量, F ( x ) F(x) F(x)是强分类器, f t ( x ) f_t(x) ft(x)是弱分类器, a t a_t at 是弱分类器的权重值,是一个正数, T T T为弱分类器的数量。弱分类器的输出值为+1或-1,分别对应于正样本和负样本。分类时的判定规则为:
其中sgn是符号函数。强分类器的输出值也为+1或-1,同样对应于正样本和负样本。弱分类器和它们的权重值通过训练算法得到。之所以叫弱分类器是因为它们的精度不用太高。
AdaBoost的loss采用指数损失,基分类器最常见的是决策树(在很多情况下是决策树桩,深度为1的决策树)。在每一轮提升相应错分类点的权重可以被理解为调整错分类点的observation probability。
参考文章
分类器重要性α更新规则(对应西瓜书上的解释)
推导前先回顾一下算法的伪代码:
开始论证
在AdaBoost中,第一个分类器h1是通过基学习算法用于初始数据分不而得,此后迭代生成ht和αt,当基分类器ht基于Dt产生后,该基分类器权重αt应使得αt*ht最小化损失函数.
当前基学习器分类错误的概率:
对损失函数求导:
令导数为0得:
恰好对应伪代码的第六步.
参考文章
GBDT (Gradient Boosting Decision Tree) 是机器学习中一个长盛不衰的模型,其主要思想是利用弱分类器(决策树)迭代训练以得到最优模型,该模型具有训练效果好、不易过拟合等优点。GBDT不仅在工业界应用广泛,通常被用于多分类、点击率预测、搜索排序等任务;在各种数据挖掘竞赛中也是致命武器,据统计Kaggle上的比赛有一半以上的冠军方案都是基于GBDT。
GBDT(Gradient Boosting DecisionTree)梯度提升迭代决策树,是一个集成模型,基分类器采用CART,集成方式为Gradient Boosting。GBDT是通过采用加法模型(即基函数的线性组合),以及不断减小训练过程产生的残差来达到将数据分类或者回归的算法。
GBDT算法的直观理解是,每一轮预测和实际值有残差,下一轮根据残差再进行预测,最后将所有预测相加,得出结果。GBDT通过多轮迭代,每轮迭代产生一个弱分类器,每个分类器在上一轮分类器的残差基础上进行训练。对弱分类器的要求一般是足够简单,并且是低方差和高偏差的。因为训练的过程是通过降低偏差来不断提高最终分类器的精度。
需要注意的是,GBDT无论是用于分类和回归,采用的都是回归树,分类问题最终是将拟合值转换为概率来进行分类的。
在针对基学习器的不足上,AdaBoost算法是通过提升错分数据点的权重来定位模型的不足,而梯度提升算法是通过算梯度来定位模型的不足。
当GBDT的损失函数是平方损失时,即是 L ( y , f ( x ) ) = 1 2 ( y − f ( x ) ) 2 L(y,f(x))=\frac{1}{2}(y-f(x))^2 L(y,f(x))=21(y−f(x))2,则负梯度 − ∂ L ∂ f ( x ) = y − f ( x ) -\frac{\partial{L}}{\partial{f(x)}}=y-f(x) −∂f(x)∂L=y−f(x),而 y − f ( x ) y-f(x) y−f(x)即为我们所说的残差,而我们的GBDT的思想就是在每次迭代中拟合残差来学习一个弱学习器。而残差的方向即为我们全局最优的方向。但是当损失函数不为平方损失时,我们该如何拟合弱学习器呢?大牛Friedman提出使用损失函数负梯度的方向代替残差方向,我们称损失函数负梯度为伪残差。而伪残差的方向即为我们局部最优的方向。所以在GBDT中,当损失函数不为平方损失时,用每次迭代的局部最优方向代替全局最优方向(这种方法是不是很熟悉?)。
举个例子来看看GBDT是如何拟合残差来学习弱学习器的。可以证明,当损失函数为平方损失时,叶节点中使平方损失误差达到最小值的是叶节点中所有值的均值;而当损失函数为绝对值损失时,叶节点中使绝对损失误差达到最小值的是叶节点中所有值的中位数。相关证明参考这里。
训练集是4个人,A,B,C,D年龄分别是14,16,24,
从上图可以看出,第一棵树建立的时候使用的是原始数据,而后每一棵树建立使用的是前n-1次的残差来拟合弱学习器。
参考文章
通常说的GBDT去拟合残差是不全面的,是当损失函数是均方损失时,负梯度刚好是残差,残差只是特例。我们可以把 gbdt 的求解过程想象成线性模型优化的过程。 在线性模型优化的过程中。利用梯度下降我们总是让参数向负梯度的方向移动,使损失函数最小。现在来看 gbdt,假入我们现在有 t 课树,我们需要去学习是第 t+1 颗树,那么如何学习第 t+1 颗树才是最优的树呢? 这个时候我们参考梯度优化的思想。现在的 t 课树就是我们现在的状态使用这个状态我们可以计算出现在的损失。如何让损失更小呢?我们只需要让 t+1 颗树去拟合损失的负梯度。正是借用了梯度优化的思想。所以叫梯度提升树。所以不是残差用负梯度代替,而是这个算法就是根据梯度下降的思想设计出来的。
基本概念
Boost:就是让多个弱分类器,通过不同的集成方式,来让多个弱分类器变成一个强分类器。
gradient-boost: 梯度提升。简单的说,先训练一个弱分类器,然后弱分类器和目标值之间的残差,作为下一个弱分类器训练的目标值。
这里有一个非常简单的例子
梯度 or 残差 ?
对于GBDT,网上的很多文章都没有讲清楚,学习梯度还是学习残差?从上面的那个例子来看,是学习残差的。
其实,从来GBDT都是学习梯度的,学习残差只是学习梯度的一个特例!
如果我们是在做一个回归任务(就像是上面例子中预测年龄),采用平方损失: l o s s = 1 2 ∑ i n ( y i − y i ^ ) 2 loss=\frac{1}{2}\sum_i^n{(y_i−\hat{y_i})^2} loss=21∑in(yi−yi^)2
其中 y i y_i yi是真实数值, y i ^ \hat{y_i} yi^是模型预测的值。
然后想求取这个关于 y i ^ \hat{yi} yi^的梯度,那就是:
∂ l o s s y i ^ = − 1 ∗ ( y i − y i ^ ) \frac{\partial{loss}}{\hat{y_i}}=-1*(y_i-\hat{y_i}) yi^∂loss=−1∗(yi−yi^)
所以残差在平方损失的情况下,就是等于负梯度,所以两者一回事。
残差过于敏感
对于数据不干净,没有清晰掉异常值的数据样本。使用平方损失对异常值过于敏感了:
所以,这里在回归问题中,也可以考虑使用下面的两个损失函数:
Absolute loss:
l o s s = ∣ y − y ^ ∣ loss=|y−\hat{y}| loss=∣y−y^∣
Huber loss:
这个是设置一个阈值,当 ∣ y − y ^ ∣ |y−\hat{y}| ∣y−y^∣小于这个阈值的时候,采用平方损失,当 ∣ y − y ^ ∣ |y−\hat{y}| ∣y−y^∣大于这个阈值的时候,采用类似于绝对损失的线性损失:
这里看一下huber loss的函数图像:
就是一个平方损失,一个线性损失。
然后看一下平方损失,绝对损失,huber损失对于异常值的容忍程度:
相比平方误差损失,Huber损失对于数据中异常值的敏感性要差一些。在值为0时,它也是可微分的。它基本上是绝对值,在误差很小时会变为平方值。误差使其平方值的大小如何取决于一个超参数 δ \delta δ,该参数可以调整。当 δ \delta δ -> 0 0 0时,Huber损失会趋向于MAE;当 δ \delta δ -> ∞ \infty ∞(很大的数字),Huber损失会趋向于MSE。
参考文章
拟合残差只是考虑到损失函数为平方损失的特殊情况,负梯度是更加广义上的拟合项,更具普适性。
参考文章
基于树的集成算法还有一个很好的特性,就是模型训练结束后可以输出模型所使用的特征的相对重要度,便于我们选择特征,理解哪些因素是对预测有关键影响,这在某些领域(如生物信息学、神经系统科学等)特别重要。本文主要介绍基于树的集成算法如何计算各特征的相对重要度。
使用boosted tree作为学习算法的优势
特征重要度的计算
Friedman在GBM的论文中提出的方法:
特征的全局重要度通过特征在单颗树中的重要度的平均值来衡量:
J j 2 ^ = 1 m ∑ m = 1 M J j 2 ^ ( T m ) \hat{J_j^2}=\frac{1}{m}\sum_{m=1}^{M}\hat{J_j^2}(T_m) Jj2^=m1∑m=1MJj2^(Tm)
其中,M是树的数量。特征 j 在单颗树中的重要度的如下:
J j 2 ^ ( T ) = ∑ t = 1 L − 1 i t 2 ^ 1 ( v t = j ) \hat{J_j^2}(T)=\sum_{t=1}^{L-1}\hat{i_t^2}1(v_t=j) Jj2^(T)=∑t=1L−1it2^1(vt=j)
其中, L L L为树的叶子节点数量, L − 1 L−1 L−1即为树的非叶子节点数量(构建的树都是具有左右孩子的二叉树), v t v_t vt是和节点 t t t相关联的特征, i t 2 ^ \hat{i_t^2} it2^是节点t分裂之后平方损失的减少值。
具体计算可参考文章GBDT方法中feature importance的计算公式。
参考文章
随机森林是一个用随机方式建立的,包含多个决策树的集成分类器。其输出的类别由各个树投票而定(如果是回归树则取平均)。假设样本总数为n,每个样本的特征数为a,则随机森林的生成过程如下:
随机森林的随机性主要体现在两个方面:
以上两个随机性能够使得随机森林中的决策树都能够彼此不同,提升系统的多样性,从而提升分类性能。
随机森林的优点
随机森林的缺点
在建立每一棵决策树的过程中,有两点需要注意采样与完全分裂。
首先是两个随机采样的过程,random forest对输入的数据要进行行、列的采样。对于行采样,采用有放回的方式,也就是在采样得到的样本集合中,可能有重复的样本。假设输入样本为N个,那么采样的样本也为N个。这样使得在训练的时候,每一棵树的输入样本都不是全部的样本,使得相对不容易出现over-fitting。然后进行列采样,从M个feature中,选择m个(m << M)。同样也是用于防止过拟合,同时减少了计算量。
之后就是对采样之后的数据使用完全分裂的方式建立出决策树,这样决策树的某一个叶子节点要么是无法继续分裂的,要么里面的所有样本的都是指向的同一个分类。一般很多的决策树算法都一个重要的步骤——剪枝,但是这里不这样干,由于之前的两个随机采样的过程保证了随机性,所以就算不剪枝,也不会出现over-fitting。
通过分类,子集合的熵要小于未分类前的状态,这就带来了信息增益(information gain)
参考文章
自助法:bagging方法中Bootstrap每次约有1/3的样本不会出现在Bootstrap所采集的样本集合中,当然也就没有参加决策树的建立,把这1/3的数据称为袋外数据oob(out of bag),它可以用于取代测试集误差估计方法。
随机森林采样n次,n趋于无穷大,oob样本的概率接近于?
袋外数据概率的计算:对于n个样本来说,一个样本被抽中的概率为 1 n \frac{1}{n} n1 ,则不被抽中的概率为 1 − 1 n 1-\frac{1}{n} 1−n1,则n次不被抽中的概率为 ( 1 − 1 n ) n (1-\frac{1}{n})^n (1−n1)n,当训练样本足够大的时候,即当 n n n-> + ∞ +\infty +∞ 的时候,极限为 1 e \frac{1}{e} e1 ,则可以计算得到袋外数据的概率为36.8%。
优点:这已经经过证明是无偏估计的,所以在随机森林算法中不需要再进行交叉验证或者单独的测试集来获取测试集误差的无偏估计;
缺点:自助法产生的数据改变了初始数据集的分布,这会引入估计偏差。因此,在初始数据量足够时,留出法和交叉验证法更常用一些(西瓜书);
参考文章
根据随机森林创建和训练的特点,随机森林对缺失值的处理还是比较特殊的。
其实,该缺失值填补过程类似于推荐系统中采用协同过滤进行评分预测,先计算缺失特征与其他特征的相似度,再加权得到缺失值的估计,而随机森林中计算相似度的方法(数据在决策树中一步一步分类的路径)乃其独特之处。
参考文章
参考文章
XGBoost 是大规模并行 boosting tree 的工具,它是目前最快最好的开源 boosting tree 工具包,比常见的工具包快 10 倍以上。XGBoost的论文全称为《XGBoost: A Scalable Tree Boosting System》,由陈天奇于2016年发表,该算法在Kaggle等比赛中大放异彩,其最基础的算法是决策树模型。
Xgboost 和 GBDT 两者都是 boosting 方法,除了工程实现、解决问题上的一些差异外,最大的不同就是目标函数的定义。作为GBDT的高效实现,XGBoost是一个上限特别高的算法,因此在算法竞赛中比较受欢迎。简单来说,对比原算法GBDT,XGBoost主要从下面三个方面做了优化:
一是算法本身的优化:在算法的弱学习器模型选择上,对比GBDT只支持决策树,还可以直接很多其他的弱学习器。在算法的损失函数上,除了本身的损失,还加上了正则化部分。在算法的优化方式上,GBDT的损失函数只对误差部分做负梯度(一阶泰勒)展开,而XGBoost损失函数对误差部分做二阶泰勒展开,更加准确。算法本身的优化是我们后面讨论的重点。
二是算法运行效率的优化:对每个弱学习器,比如决策树建立的过程做并行选择,找到合适的子树分裂特征和特征值。在并行选择之前,先对所有的特征的值进行排序分组,方便前面说的并行选择。对分组的特征,选择合适的分组大小,使用CPU缓存进行读取加速。将各个分组保存到多个硬盘以提高IO速度。
三是算法健壮性的优化:对于缺失值的特征,通过枚举所有缺失值在当前节点是进入左子树还是右子树来决定缺失值的处理方式。算法本身加入了L1和L2正则化项,可以防止过拟合,泛化能力更强。
在上面三方面的优化中,第一部分算法本身的优化是重点也是难点。
关于XGBoost算法的理论推导可参考XGBoost算法的原理详析[文献阅读笔记]和【机器学习】决策树(下)——XGBoost、LightGBM(非常详细)这两篇文章。
大家知道,Boosting算法的弱学习器是没法并行迭代的,但是单个弱学习器里面最耗时的是决策树的分裂过程,XGBoost针对这个分裂做了比较大的并行优化。对于不同的特征的特征划分点,XGBoost分别在不同的线程中并行选择分裂的最大增益。
XGBoost工具支持并行。Boosting不是一种串行的结构吗?怎么并行的?注意XGBoost的并行不是tree粒度的并行,XGBoost也是一次迭代完才能进行下一次迭代的(第t次迭代的代价函数里包含了前面t-1次迭代的预测值)。XGBoost的并行是在特征粒度上的。
我们知道,决策树的学习最耗时的一个步骤就是对特征的值进行排序(因为要确定最佳分割点),XGBoost在训练之前,预先对数据进行了排序,然后保存为block结构,后面的迭代中重复地使用这个结构,大大减小计算量。这个block结构也使得并行成为了可能。
小结
参考文章
贪心选择最有分割点:从单个叶结点开始,迭代地向树添加分支的贪心算法。 假设 I L I_{L} IL和 I R I_{R} IR是拆分后左右节点的实例集。 I = I L ∪ I R I=I_L \cup I_R I=IL∪IR,拆分之后的损失减少为:
这个公式通常被用于评估分割候选集(选择最优分割点),其中前两项分别是切分后左右子树的的分支之和,第三项是未切分前该父节点的分数值,最后一项是引入额外的叶节点导致的复杂度。
精确算法,精确的贪心算法(exact greedy algorithm):
树学习中的关键问题之一是找到最佳分裂。为此,分裂查找算法枚举所有特征上的所有可能分割。我们称之为精确的贪心算法(exact greedy algorithm)。大多数现有的单机tree boosting实现,例如scikit-learn,R‘s gbm 以及XGBoost的单机版本都支持精确的贪心算法。 精确的贪心算法如下图所示,计算连续特征的所有可能分裂在计算上要求很高。 为了有效地执行此操作,算法必须首先分别根据每个特征值对所有数据进行排序,并按排序顺序访问数据,以便累积等式(7)中结构分数的梯度统计数据。
近似算法
采用一种近似算法来寻找每个特征的分割点,而不是遍历所有的分割点,以此来加速子树的生成。
近似算法提出动机
Exact greedy algorithm非常强大,因为它贪婪地枚举了所有可能的分裂点。 但是,当数据不能完全地送入内存中时,不可能有效地这样做。 在分布式设置中也会出现同样的问题。 为了在这两个设置中支持有效的gradient tree boosting,需要一个近似算法。
近似算法框架如下图所示。该算法首先根据特征分布的百分位数(percentiles)提出候选分裂点。然后,该算法将连续特征映射到由这些候选点分割的桶中,汇总统计信息并根据汇总的信息在提案中找到最佳解决方案。
参考文章
XGBoost模型的一个优点就是允许特征存在缺失值。对缺失值的处理方式如下:
在训练过程中,如果特征 f 0 f_0 f0 出现了缺失值,处理步骤如下:
1、首先对于 f 0 f_0 f0 非缺失的数据,计算出 L s p l i t L_{split} Lsplit 并比较大小,选出最大的 L s p l i t L_{split} Lsplit,确定其为分裂节点(即选取某个特征的某个阈值);
2、然后对于 f 0 f_0 f0 缺失的数据,将缺失值分别划分到左子树和右子树,分别计算出左子树和右子树的 L s p l i t L_{split} Lsplit,选出更大的 L s p l i t L_{split} Lsplit,将该方向作为缺失值的分裂方向(记录下来,预测阶段将会使用)。
在预测阶段,如果特征 f 0 f_0 f0 出现了缺失值,则可以分为以下两种情况:
1、如果训练过程中, f 0 f_0 f0 出现过缺失值,则按照训练过程中缺失值划分的方向(left or right),进行划分;
2、如果训练过程中, f 0 f_0 f0 没有出现过缺失值,将缺失值的划分到默认方向(左子树)。
论文伪代码解释如下:
具体逻辑需分析源码,可参考还有人不懂XGBoost的缺失值处理?(全面解析篇)这篇文章。
参考文章
行采样:即子采样,每轮计算不使用全部样本,减少过拟合
列抽样:即随机森林中的做法,每次节点分裂的待选特征集合不是剩下的全部特征,而是剩下特征的一个子集。是为了更好地对抗过拟合,还能减少计算开销。
参考文章
XGBoost如何寻找最优特征?是又放回还是无放回的呢?
XGBoost在训练的过程中给出各个特征的评分,从而表明每个特征对模型训练的重要性.。XGB属于boosting集成学习方法,样本是不放回的,每轮计算样本不重复。
XGBoost如何评价特征的重要性
采用三种方法来评判XGBoost模型中特征的重要程度
官方文档:
(1)weight - the number of times a feature is used to split the data across all trees.
(2)gain - the average gain of the feature when it is used in trees.
(3)cover - the average coverage of the feature when it is used in trees.
weight :该特征在所有树中被用作分割样本的特征的总次数。
gain :该特征在其出现过的所有树中产生的平均增益。
cover :该特征在其出现过的所有树中的平均覆盖范围。
注意:覆盖范围这里指的是一个特征用作分割点后,其影响的样本数量,即有多少样本经过该特征分割到两个子节点。
参考文章
XGBooost参数调优的一般步骤
首先需要初始化一些基本变量,例如:
max_depth = 5
min_child_weight = 1
gamma = 0
subsample, colsample_bytree = 0.8
scale_pos_weight = 1
参考文章
总结参考了百面机器学习+知乎:
与其他模型对比
参考文章
优点
缺点
参考文章
LightGBM 由微软提出,主要用于解决 GDBT 在海量数据中遇到的问题,以便其可以更好更快地用于工业实践中。LightGBM(Light Gradient Boosting Machine)是一个实现GBDT算法的框架,支持高效率的并行训练,并且具有更快的训练速度、更低的内存消耗、更好的准确率、支持分布式可以快速处理海量数据等优点。
从 LightGBM 名字我们可以看出其是轻量级(Light)的梯度提升机(GBM),其相对 XGBoost 具有训练速度快、内存占用低的特点。下图分别显示了 XGBoost、XGBoost_hist(利用梯度直方图的 XGBoost) 和 LightGBM 三者之间针对不同数据集情况下的内存和训练时间的对比:
LightGBM提出的动机
常用的机器学习算法,例如神经网络等算法,都可以以mini-batch的方式训练,训练数据的大小不会受到内存限制。而GBDT在每一次迭代的时候,都需要遍历整个训练数据多次。如果把整个训练数据装进内存则会限制训练数据的大小;如果不装进内存,反复地读写训练数据又会消耗非常大的时间。尤其面对工业级海量的数据,普通的GBDT算法是不能满足其需求的。
LightGBM提出的主要原因就是为了解决GBDT在海量数据遇到的问题,让GBDT可以更好更快地用于工业实践。
参考文章:
针对XGBoost存在的缺点,LightGBM 为解决这些问题提出了以下几点解决方案,使其做了到更快的训练速度和更低的内存开销:
具体解析可参考【机器学习】决策树(下)——XGBoost、LightGBM(非常详细)。
参考文章
联系:
两种方法都是把若干个分类器整合为一个分类器的方法,只是整合的方式不一样,最终得到不一样的效果,将不同的分类算法套入到此类算法框架中一定程度上会提高了原单一分类器的分类效果,同时也增加了计算量。
区别对比:
总结:
虽然已经证明bagging和boosting比单个分类器有更好的准确性,然而,我们必须考虑在什么情况下和怎么使用这些技术。
一句话总结:在实际应用中,Bagging通常都会有帮助,而Boosting是一把利剑,用好的情况下肯定会比Bagging出色,但是用不好很可能会伤到自己。
参考文章
偏差与方差
偏差 (bias) 定义为:
方差 (variance) 定义为:
方差通常衡量模型对不同数据集的敏感程度,也可以认为是衡量模型的不稳定性。若方差大,则表示数据的微小变动就能导致学习出的模型产生较大差异,即对应的模型结构风险更高。
有了偏差和方差的定义,我们就能推导出模型的期望泛化误差:
如果我们能在保持bias基本不变时,降低variance,则模型的期望泛化误差降低,从而降低模型过拟合风险。
bagging
随机森林是一种常用的Bagging模型,其通过对样本进行有放回的采样,构造n个样本集,同时对特征列进行采样后进行模型训练,即同时降低上述公式中的两项,来降低方差,从而降低过拟合。相关公式推导可参考Bagging为什么能降低过拟合这篇文章。
bagging是对许多强(甚至过强)的分类器求平均。在这里,每个单独的分类器的bias都是低的,平均之后bias依然低;而每个单独的分类器都强到可能产生overfitting的程度,也就是variance高,求平均的操作起到的作用就是降低这个variance。
boosting
boosting是把许多弱的分类器组合成一个强的分类器。弱的分类器bias高,而强的分类器bias低,所以说boosting起到了降低bias的作用。variance不是boosting的主要考虑因素。
参考文章
树模型的缺点
对于高维稀疏特征数据,使用 gbdt 很容易过拟合;这种情况下,可以采用线性模型,如LR,带正则化的线性模型比较不容易对稀疏特征过拟合。
为什么在高维稀疏特征的时候,线性模型会比非线性模型好
后来思考后发现原因是因为现在的模型普遍都会带着正则项,而 lr 等线性模型的正则项是对权重的惩罚,也就是 W1一旦过大,惩罚就会很大,进一步压缩 W1的值,使他不至于过大,而树模型则不一样,树模型的惩罚项通常为叶子节点数和深度等,而我们都知道,对于上面这种 case,树只需要一个节点就可以完美分割9990和10个样本,惩罚项极其之小。
xgboost之所以比传统gbdt好的原因,也是因为在惩罚项中既考虑了叶子节点数量,也考虑了L2正则项。xgb 里的 l2惩罚了叶子节点的分数,能够防止某个节点过大,有点像 Ridge Regression(参考知乎)。
参考文章