xgbboost & lightgbm
1. xgboost原理
1.1训练集样本信息
1.2 logistic 及其损失函数
- logistic表达式: 对上式做数学变换得到 当是一个累加和,如, 即由k个f(x)求和得到,上式变为
- logistic损失函数:极大似然估计,具体到每个样本上,实际是典型的二项分布概率:
转化为对数形式:
考虑到损失函数数值含义为最优点对应最小值,所以对数似然取负数(极大似然是正):
以上是一个样本,对于所有样本:
1.3 xgboost推导(结合logistic及其损失函数)
公式解释:第一个公式直观上可以理解,但是是什么呢,表达式如何?f本质是泛函,在这里仅表示一种映射关系。xgboost中一棵决策树中每个叶子结点都会赋一个值,具体是什么,后续会讲。f就是将一个样本经过f后(经过决策树划分进一个叶子结点)变成(即下图叶子结点对应的红框数字)。中q表示树的结构,即这棵决策树长什么样子。表示,样本x经过一棵结构为q的决策树后变成了,联系上述,这个q其实就是刚才的f,f或者q现在不是以前常见的数学解析书,而是一个树状的结构图。下图可帮助理解:
- 3.此外为防止过拟合,确定损失函数时需要加一个正则化项:
是损失函数的表达式,本文档即logistic损失函数表达式,i表示第i个样本,k表示第k棵树,T表示一棵决策树中叶子结点的个数。该式不仅对叶子结点进行了限制,还加入了回归中常用的L2范数。提醒一点,这里的是当前预测出的值,已经考虑了所有棵树的累加。
- 4.上文提到,xgboost核心思想是所有棵树的累加和,那么对于第t棵树(构造多棵树的过程是一棵一棵地构造,因此将目标放到当前这棵树上),预测值应该等于前t-1棵树的预测值加上当前这棵树预测出来的数值。因此上式可改写为:
其中i让然表示第i个样本,表示真实值与前t-1棵树预测值对应的损失(由损失函数确定),提醒:这里的已经包含了前t-1棵树每个数的正则化项值,因此后面的仅表示第t棵树,即当前树的正则化项。中间一项不再赘述 - 5.大学我们都学过泰勒公式:
此外,还需要插播一个知识点,即参数空间和函数空间
上式是梯度下降的表示, αα 表示步长, gtgt 表示参数的一阶导。
xgboost用的就是函数空间的概念,但是不影响泰勒公式,迭代形式的泰勒公式还是上述,只是里面的参数为函数空间表达形式。如果这里实在不能理解,可以先放弃,记住下面泰勒展开后的函数空间即可。 - 6.将刚才的xgboost损失函数用迭代形式的泰勒公式进行二阶展开:
其中表示前t-1棵树的损失函数对进行求导,对于logistic损失函数一阶导公式为,将参数带进去后,表达式为:
即假设初始值为0,所以第一棵树计算时,只需将带入上述公式。这样就可以求得,方法一样,不再赘述。再次提醒,这里是对求导,无需关注究竟是由前面的树怎么得来的,回来以前的数学解析式,虽然,但无需考虑对x求导,这里仅仅是对求导,xgboost中仅仅是对求导
- 7.对于第t棵树,前面t-1棵树的预测值是已知值,因此是常数,所以上式可进行简化如下:
- 8.观察上式,上面求和中i表示第i个样本,但是后面正则化项中t表示第t个叶子结点,那么怎么能将两者统一呢?思考,每个样本最终会落到一个叶子结点上,因此样本i是不是可以转化为t呢,即一个叶子结点是由m个样本构成,借助这种思路,上式可变为:
这里表示第j个叶子结点样本的集合,(也可以用表示)表示第j个结点上所有样本的和。下图可方便理解
- 9.好了,损失函数的表达式出来了,怎样达到最小值呢,很简单对求偏导,就知道当前树对应叶子结点的值和最小的损失函数了。计算得到:将其带回损失函数,得到最小的损失为
- 10.虽然最小的损失有了,但是树的结构还没确定,且这是一个NP难问题,那么怎样确定树的结构呢?回顾CART回归树的建立,也是一个NP难问题,所以建树的方法采取的是贪婪的方式,即遍历特征中所有可能的划分点计算回归树中定义的指标最小值,将指标达到最小值时划分点最为最终的特征选择标准。xgboost也是这样的思路,只是现在的指标如下
现以年龄为例,如果选择age为10岁做为划分点,则ID1为左边的子样本,2,3,4,5为右边的样本。第一棵树时样本的前t-1棵树(实际没有)预测值初始化为0,将标签为yes的样本(1,2,3号样本)带入一阶导公式,结果为 ,二阶导结果为,将标签为no的样本(4,5)带入一阶导公式为,二阶导公式为,因此选择age=10为划分点时Gain为如下结果:
按照此规则依次计算age=20……gender=male……,在此不再举例。
2.xgboost 优化
2.1步长(shrinkage)
同之前的GBDT一样,XGBoost也可以加入步长η(有的也叫收缩率Shrinkage),这也是防止过拟合的好方法:
通常步长 η 取值为0.1。当然GBDT也可以采用这个
2.2 行、列抽样
XGBoost借鉴随机森林也使用了列抽样(在每一次分裂中使用特征抽样),进一步防止过拟合,并加速训练和预测过程。
此外,在实现中还有行抽样(样本抽样)。
2.3 特征选择的优化
前面提到过,XGBoost每一步选能使分裂后增益最大的分裂点进行分裂。而分裂点的选取之前是枚举所有分割点,这称为精确的贪心法(Exact Greedy Algorithm).当数据量十分庞大,以致于不能全部放入内存时,Exact Greedy 算法就会很慢。因此XGBoost引入了近似的算法。即对每一个特征进行「值」采样。原来需要对每一个特征的每一个可能分割点进行尝试,采样之后只针对采样的点(分位数)进行分割尝试,这种方法很明显可以减少计算量,采样密度越小计算的越快,拟合程度也会越差,所以采样还可以防止过拟合。
如下图Algorithm1是精确的贪心法算法;2为近似的算法
那么,现在有两个问题:
- 1.如何选取候选切分点Sk={sk1,sk2,⋯skl}呢?(即选择特征的哪些分位数作为备选的分裂点呢?)
- 2.什么时候进行候选切分点的选取?
针对问题2:
第二个方法执行采样的方式有两个,一种是global模式,一种是local模式。global模式是执行树生成之前采一次样,后面不再更新( 学习每棵树前, 提出候选切分点);local模式是每次split之后都再次进行一次采样(每次分裂前, 重新提出候选切分点)。不同的模式和不同的采样密度带来的效果如下图:
桶的个数等于 1 / eps, 可以看出:
全局切分点的个数够多的时候,和Exact greedy算法性能相当。
局部切分点个数不需要那么多,因为每一次分裂都重新进行了选择。
2.4 切分点的选取 – Weighted Quantile Sketch
上一节中提出了两个问题,但仅只解决了第二个,下面对第一个问题进行解答,选择哪些候选切分点。
对于问题1,可以采用分位数,也可以直接构造梯度统计的近似直方图等。
简单的分位数就是先把数值进行排序,然后根据你采用的几分位数把数据分为几份即可。而XGBoost不单单是采用简单的分位数的方法,而是对分位数进行加权(使用二阶梯度h),称为:Weighted Quantile Sketch。PS:上面的那个例子采用的是没有使用二阶导加权的分位数。
对特征k构造multi-set 的数据集:, 其中 表示样本i的特征k的取值,而则为对应的二阶梯度。
式中分子是x小于z的所有样本对应的h之和(即小于z的样本加权和,权重为h),分母为所有样本的加权和。该式表达了第k个特征小于z的样本比例,和之前的分位数挺相似,不过这里是按照二阶梯度进行累计。
而候选切分点要求:
即让相邻两个候选分裂点代入中,相差不超过某个值ε,由此,最终会切分出1/ε个桶,如下面例子:
选取ε = 1/3,会得到三个桶,h总和1.8,因此
到这里似乎就讲完了,但是仍然有个问题,为什么选择h而不是g或者其他呢?证明如下:
2.5 稀疏矩阵(缺失值)处理
有很多种原因可能导致特征的稀疏(缺失),所以当遇到样本某个维度的特征缺失的时候,就不能知道这个样本会落在左孩子还是右孩子。xgboost处理缺失值的方法和其他树模型不同。根据作者TianqiChen在论文[1]中章节3.4的介绍,xgboost把缺失值当做稀疏矩阵来对待,本身在节点分裂时不考虑缺失值的数值,但确定分裂的特征后,缺失值数据会被分到左子树和右子树呢?本文的处理策略是落在哪个孩子得分高,就放到哪里。如果训练中没有数据缺失,预测时出现了数据缺失,那么默认被分类到右子树。具体的介绍可以参考:
2.6 列排序优化
注意到每一次Split Finding的时候是需要对当前节点中的样本按照特征值进行排序的,其实这个排序可以在初始化的时候进行一次就够了,比如有n个样本m维特征,只需要预先生成一个n*m的矩阵,第m列表示的是按照第m个特征从小到大对样本的排序索引。 通过指针存储,一个指针也就是四个字节,耗费的空间有限。
上面遍历排序索引的过程可以并行进行,并且执行Split Finding的时候,也可以同时多个维度并发寻找得分最高的切分点。
2.7 缓存优化
在approximate 算法中,对Block的大小进行了合理的设置。定义Block的大小为Block中最多的样本数。设置合适的大小是很重要的,设置过大则容易导致命中率低,过小则容易导致并行化效率不高。经过实验,发现2^16比较好。
3 xgboost 使用及参数
3.1 初识xgboost
3.1.1 xgboost使用方法
xgb有两种使用方式,一种是使用其自带的建模方式,另一种是为了迎合sklearn(因为很多人习惯了sklearn的fit、predict方式),创建了sklearn接口。
- ①使用xgboost自带的数据集格式 + xgboost自带的建模方式
- 把数据读取成xgb.DMatrix格式(libsvm/dataframe.values给定X和Y)
- 准备好一个watch_list(训练集和验证集,xgb在进行拟合的时候可以同时对训练集和验证集进行操作,得到训练集和验证集的error)
- xgb.train(dtrain)
- xgb.predict(dtest)
- ②使用pandas的DataFrame格式 + xgboost的sklearn接口
- estimator = xgb.XGBClassifier()/xgb.XGBRegressor()
- estimator.fit(df_train.values, df_target.values)
- estimator.predict(df_val.values, )
附:libsvm格式:
当某一特征为0时,不会显示,这就解决了稀疏矩阵的存储问题。
3.1.2 xgboost方法1代码
# 基本例子,从libsvm文件中读取数据,做二分类
# 数据是libsvm的格式
#1 3:1 10:1 11:1 21:1 30:1 34:1 36:1 40:1 41:1 53:1 58:1 65:1 69:1 77:1 86:1 88:1 92:1 95:1 102:1 105:1 117:1 124:1
#0 3:1 10:1 20:1 21:1 23:1 34:1 36:1 39:1 41:1 53:1 56:1 65:1 69:1 77:1 86:1 88:1 92:1 95:1 102:1 106:1 116:1 120:1
#0 1:1 10:1 19:1 21:1 24:1 34:1 36:1 39:1 42:1 53:1 56:1 65:1 69:1 77:1 86:1 88:1 92:1 95:1 102:1 106:1 116:1 122:1
# 数据存储格式本身就为libsvm,直接DMatrix读取
dtrain = xgb.DMatrix('./data/agaricus.txt.train') # 特征矩阵和标签在一起,下面拟合的时候直接传入dtrain,不用分别传入特征矩阵和标签。
dtest = xgb.DMatrix('./data/agaricus.txt.test')
#超参数设定
param = {'max_depth':2, 'eta':1, 'silent':1, 'objective':'binary:logistic' } # 之后会详细介绍参数
# 设定watchlist用于查看模型状态
watchlist = [(dtest,'eval'), (dtrain,'train')] # 'eval' 和'dtrain'都为注释,方法见参数讲解
num_round = 2 # 参数
# 模型拟合(训练)
bst = xgb.train(param, dtrain, num_round, watchlist)
# 使用模型预测
preds = bst.predict(dtest)
# 判断准确率
labels = dtest.get_label()
print ('错误类为%f' % \
(sum(1 for i in range(len(preds)) if int(preds[i]>0.5)!=labels[i]) /float(len(preds))))
# 模型存储
bst.save_model('./model/0001.model')
该代码运行结果:
3.1.3 xgboost方法1代码扩展
上一小节的数据存储格式本身为libsvm,如果不是libsvm,而是dataframe的格式呢?此时要先将数据进行转化,仍然使用DMatrix。代码如下:
# 基本例子,从csv文件中读取数据,做二分类
# 用pandas读入数据
data = pd.read_csv('./data/Pima-Indians-Diabetes.csv')
# 做数据切分
train, test = train_test_split(data)
# 转换成Dmatrix格式
feature_columns = ['Pregnancies', 'Glucose', 'BloodPressure', 'SkinThickness', 'Insulin', 'BMI', 'DiabetesPedigreeFunction', 'Age'] # 获取特征的名称
target_column = 'Outcome' # 获取标签的名称
xgtrain = xgb.DMatrix(train[feature_columns].values, train[target_column].values) # 将特征矩阵和标签的数据转化为libsvm。上述DMatrix特征矩阵与标签在一起,这样操作也放到了一起
xgtest = xgb.DMatrix(test[feature_columns].values, test[target_column].values)
# 上述数据格式转化成功后,下面拟合、预测的过程和上一节一样
#参数设定
param = {'max_depth':5, 'eta':0.1, 'silent':1, 'subsample':0.7, 'colsample_bytree':0.7, 'objective':'binary:logistic' }
# 设定watchlist用于查看模型状态
watchlist = [(xgtest,'eval'), (xgtrain,'train')]
num_round = 10
bst = xgb.train(param, xgtrain, num_round, watchlist)
# 使用模型预测
preds = bst.predict(xgtest)
# 判断准确率
labels = xgtest.get_label()
print ('错误类为%f' % \
(sum(1 for i in range(len(preds)) if int(preds[i]>0.5)!=labels[i]) /float(len(preds))))
# 模型存储
bst.save_model('./model/0002.model')
该代码运行结果:
3.1.4 xgboost 使用方法2代码
该方法和sklearn一样,数据格式为csv(dataframe),直接读取,然后fit、predict等
# 基本例子,从csv文件中读取数据,做二分类
# 用pandas读入数据
data = pd.read_csv('./data/Pima-Indians-Diabetes.csv')
# 做数据切分
train, test = train_test_split(data)
# 取出特征X和目标y的部分
feature_columns = ['Pregnancies', 'Glucose', 'BloodPressure', 'SkinThickness', 'Insulin', 'BMI', 'DiabetesPedigreeFunction', 'Age']
target_column = 'Outcome'
train_X = train[feature_columns].values
train_y = train[target_column].values
test_X = test[feature_columns].values
test_y = test[target_column].values
# 初始化模型
xgb_classifier = xgb.XGBClassifier(n_estimators=20,\
max_depth=4, \
learning_rate=0.1, \
subsample=0.7, \
colsample_bytree=0.7)
# 拟合模型
xgb_classifier.fit(train_X, train_y)
# 使用模型预测
preds = xgb_classifier.predict(test_X)
# 判断准确率
print ('错误类为%f' %((preds!=test_y).sum()/float(test_y.shape[0])))
# 模型存储
joblib.dump(xgb_classifier, './model/0003.model')
3.1.5 xgboost 的交叉验证
(1)xgb第一种使用方法下有自带的cv,代码如下:
# 计算正负样本比,调整样本权重
def fpreproc(dtrain, dtest, param):
label = dtrain.get_label()
ratio = float(np.sum(label == 0)) / np.sum(label==1)
param['scale_pos_weight'] = ratio
return (dtrain, dtest, param)
# 先做预处理,计算样本权重,再做交叉验证
xgb.cv(param, dtrain, num_round, nfold=5,
metrics={'auc'}, seed = 0, fpreproc = fpreproc)
结果如下:
该方式下不能直接得出最佳的参数,而是给出上述指标,可以判断出在第几轮的时候效果比较好,个人理解可以粗略知道轮数这个参数的大概取值。
(2)此外使用方法2是采用的sklearn的方式,因此可以结合sklearn中的Gridsearchcv等使用。
3.1.6 自定义损失函数与评估准则
xgb中有自带的损失函数和评估准则,同时也可以自己指定,但该操作仅限于第一种使用方法,且自定义损失函数需要写出损失函数的一阶导和二阶导。
# 自定义损失函数,需要提供损失函数的一阶导和二阶导
def logregobj(preds, dtrain):
labels = dtrain.get_label()
preds = 1.0 / (1.0 + np.exp(-preds))
grad = preds - labels
hess = preds * (1.0-preds)
return grad, hess
# 自定义评估准则,评估预估值和标准答案之间的差距
def evalerror(preds, dtrain):
labels = dtrain.get_label()
return 'error', float(sum(labels != (preds > 0.0))) / len(labels)
watchlist = [(dtest,'eval'), (dtrain,'train')]
param = {'max_depth':3, 'eta':0.1, 'silent':1}
num_round = 5
# 自定义损失函数训练
bst = xgb.train(param, dtrain, num_round, watchlist, logregobj, evalerror)
# 交叉验证
xgb.cv(param, dtrain, num_round, nfold = 5, seed = 0,
obj = logregobj, feval=evalerror)
3.1.7 早停
xgb因为可以同时传入训练集和验证集观察模型效果,因此有一个早停操作,即在训练集上学习模型,一颗一颗树添加,在验证集上看效果,当验证集效果不再提升,停止树的添加与生长。如当传入的轮数为100轮,early_stoping为10,当树生长到第10棵时验证集效果不提升,此后连续10棵树都没有提升,这时候停止拟合,即不会拟合100轮。下方代码为使用sklearn的方式(第一种方式在下方参数会讲到):
# 在训练集上学习模型,一颗一颗树添加,在验证集上看效果,当验证集效果不再提升,停止树的添加与生长
from sklearn.datasets import load_iris, load_digits, load_boston
from sklearn.model_selection import train_test_split
import xgboost as xgb
digits = load_digits()
X = digits['data']
y = digits['target']
X_train, X_val, y_train, y_val = train_test_split(X, y, random_state=0)
clf = xgb.XGBClassifier()
clf.fit(X_train, y_train, early_stopping_rounds=10, eval_metric="auc",
eval_set=[(X_val, y_val)])
前面提到cv,可以粗略估计轮数,配合现在的early_stoping,可以比较精准的得到模型要构建几棵树。
3.1.8使用前n棵树预测
之前我们使用early_stoping,假如树在第10棵的时候效果最好,11-20验证集效果就不再提升了(假设early_stoping为10),因此我们建模只需要前10棵树,预测的时候也只需要前10棵树。可以使用ntree_limit这个参数,代码如下:
# 基本例子,从csv文件中读取数据,做二分类
# 用pandas读入数据
data = pd.read_csv('./data/Pima-Indians-Diabetes.csv')
# 做数据切分
train, test = train_test_split(data)
# 转换成Dmatrix格式
feature_columns = ['Pregnancies', 'Glucose', 'BloodPressure', 'SkinThickness', 'Insulin', 'BMI', 'DiabetesPedigreeFunction', 'Age']
target_column = 'Outcome'
xgtrain = xgb.DMatrix(train[feature_columns].values, train[target_column].values)
xgtest = xgb.DMatrix(test[feature_columns].values, test[target_column].values)
#参数设定
param = {'max_depth':5, 'eta':0.1, 'silent':1, 'subsample':0.7, 'colsample_bytree':0.7, 'objective':'binary:logistic' }
# 设定watchlist用于查看模型状态
watchlist = [(xgtest,'eval'), (xgtrain,'train')]
num_round = 10
bst = xgb.train(param, xgtrain, num_round, watchlist)
# 只用第1颗树预测
ypred1 = bst.predict(xgtest, ntree_limit=1)
# 用前9颗树预测
ypred2 = bst.predict(xgtest, ntree_limit=9)
label = xgtest.get_label()
print ('用前1颗树预测的错误率为 %f' % (np.sum((ypred1>0.5)!=label) /float(len(label))))
print ('用前9颗树预测的错误率为 %f' % (np.sum((ypred2>0.5)!=label) /float(len(label))))
3.1.9 特征重要度
xgb也可以像决策树一样得到特征重要度,相关绘图代码如下
iris = load_iris()
y = iris['target']
X = iris['data']
xgb_model = xgb.XGBClassifier().fit(X,y)
print('特征排序:')
feature_names=['sepal_length', 'sepal_width', 'petal_length', 'petal_width']
feature_importances = xgb_model.feature_importances_
indices = np.argsort(feature_importances)[::-1]
for index in indices:
print("特征 %s 重要度为 %f" %(feature_names[index], feature_importances[index]))
%matplotlib inline
import matplotlib.pyplot as plt
plt.figure(figsize=(16,8))
plt.title("feature importances")
plt.bar(range(len(feature_importances)), feature_importances[indices], color='b')
plt.xticks(range(len(feature_importances)), np.array(feature_names)[indices], color='b')
结果:
3.2 xgb主要参数
4.xgboost 常见面试问题
- xgboost与GBDT区别
- 算法层面:
- 传统GBDT以CART作为基分类器,xgboost还支持线性分类器,这个时候xgboost相当于带L1和L2正则化项的逻辑斯蒂回归(分类问题)或者线性回归(回归问题)。
- 节点分裂的方式不同,gbdt是用gini系数,xgboost是经过优化推导后的。
- XGB加了正则项,普通GBDT没有。防止过拟合。
- xgboost损失函数是误差部分是二阶泰勒展开,GBDT 是一阶泰勒展开。所以损失函数定义的更精确。
- 对每颗子树增加一个参数,使得每颗子树的权重降低,防止过拟合,增加这个参数叫shrinkage方法。
- 对特征进行降采样(列抽样),灵感来源于随机森林,除了能降低计算量外,还能防止过拟合。
- 实现了利用分捅/分位数方法,实现了全局和局部的近似分裂点算法,降低了计算量,并且在eps参数设置合理的情况下,能达到穷举法几乎一样的性能。
- 提出并实现了特征带权重的分位数的方法
- 增加处理缺失值的方案(通过枚举所有缺失值在当前节点是进入左子树,还是进入右子树更优来决定一个处理缺失值默认的方向)。
- 系统层面:
- 对每个特征进行分块(block)并排序,使得在寻找最佳分裂点的时候能够并行化计算。这是xgboost比一般GBDT更快的一个重要原因。
- 通过设置合理的block的大小,充分利用了CPU缓存进行读取加速(cache-aware access)。使得数据读取的速度更快。因为太小的block的尺寸使得多线程中每个线程负载太小降低了并行效率。太大的block尺寸会导致CPU的缓存获取miss掉。
- out-of-core 通过将block压缩(block compressoin)并存储到硬盘上,并且通过将block分区到多个硬盘上(block Sharding)实现了更大的IO 读写速度,因此,因为加入了硬盘存储block读写的部分不仅仅使得xgboost处理大数据量的能力有所提升,并且通过提高IO的吞吐量使得xgboost相比一般实利用这种技术实现大数据计算的框架更快。
- 为什么xgboost要用泰勒展开,优势在哪里?
xgboost使用了一阶和二阶偏导,二阶导数有利于梯度下降的更快更准。使用泰勒展开取得函数做自变量得二阶导数形式,可以在不选定损失函数具体形式的情况下,仅仅依靠输入数据的值就可以进行叶子分裂优化计算,本质上也就把损失函数的选取和模型算法优化/参数选择分开了,这种去耦合增加了xgboost的适用性,使得它按需选取损失函数,可以用于分类,也可以用于回归 - XGBoost如何解决缺失值问题?
前面已经做过解答,自学习。
5.从xgboost到lightgbm
XGB、LGB 都是 GBDT 的方法。因此原理基本一样,不同点如下:
- 决策树算法:XGBoost使用的是pre-sorted算法,能够更精确的找到数据分隔点;LightGBM使用的是histogram算法,占用的内存更低,数据分隔的复杂度更低。
- 决策树生长策略:XGBoost采用的是level(depth)-wise生长策略,如Figure 1所示,能够同时分裂同一层的叶子,从而进行多线程优化,不容易过拟合;但不加区分的对待同一层的叶子,带来了很多没必要的开销。LightGBM采用leaf-wise生长策略,如Figure 2所示,每次从当前所有叶子中找到分裂增益最大(一般也是数据量最大)的一个叶子,然后分裂,如此循环;但会生长出比较深的决策树,产生过拟合
- 存储记忆优化:
其他方面的不同见下表:
参考链接:
陈天奇论文
陈天奇ppt
https://www.cnblogs.com/wkslearner/p/8672334.html
https://x-algo.cn/index.php/2016/07/24/xgboost-principle/
https://www.hrwhisper.me/machine-learning-xgboost/
https://blog.****.net/a819825294/article/details/51206410
https://www.zhihu.com/question/41354392
https://mp.weixin.qq.com/s?_
http://www.mamicode.com/info-detail-2391137.html
https://www.sohu.com/a/206208981_693397
https://blog.****.net/huacha__/article/details/81057150
https://mp.weixin.qq.com/s?https://mp.weixin.qq.com/s?https://mp.weixin.qq.com/s?
https://mp.weixin.qq.com/s__biz=MzU1NDA4NjU2MA==&mid=2247492699&idx=2&sn=5205702d3c0aa167cbb4f0f6709203cd&chksm=fbea5d94cc9dd482a111c1e3a4429f75a579988c7b53bd64f391733661a971e40385d3c79b11&mpshare=1&scene=1&srcid=0911enDVp4lYTQ7olVi7NKtn#rd