三、集成算法:RF、AdaBoost、GBDT、XGBoost、LightGBM、Stacking模型融合

1、集成算法的原理介绍

集成算法就是建立很多个弱评估器(也叫基评估器),然后以某种集成规则把这些弱评估的评估结果集成,从而达到比单个弱评估器更好的效果。核心思想就是三个臭皮匠顶个诸葛亮。

臭皮匠是谁?决策树、线性回归、逻辑回归、朴素贝叶斯、SVM、KNN等等都可以,就是这些单个评估器都可以当作基评估器。

三个臭皮匠能顶一个诸葛亮吗?假如有3个弱评估器,每个弱评估器的效果都是0.6,这里先简单的以少数服从多数的集成规则进行集成,集成后的准确率是0.6*0.6*0.6+3*0.6*0.6*0.4=0.6479999999999999。假如集成5个弱评估器,集成的准确率就是:0.6*0.6*0.6*0.6*0.6+5*0.6*0.6*0.6*0.6*0.4+10*0.6*0.6*0.6*0.4*0.4=0.68256。假如集成7个。。。不想算了,肯定集成后的准确率更高。
所以集成算法还是非常强大的,集成学习的代表算法有:随机森林、GBDT、Xgboost、LightGBM等,光听名字都觉得很强大。

目前都有哪些集成规则?主要有三种集成方法:

方法1:bagging法。
这个方法的典型代表就是随机森林,也是集成算法的入门算法。顾名思义,随机森林的基评估器是决策树,就是建立多棵决策树,如果是分类任务,就以少数服从多数的规则集成, 如果是回归任务,就用平均法集成。但是也没有这么简单,这里还有几个细节。

细节1,随机森林中的每棵树不是用所有的训练样本建立的,而是有放回的随机抽取部分训练样本fit成一棵树的。你反过来想,如果每个棵树都用的是全部样本,那每棵树不是长得一样了吗,所有树的预测结果都一样,那最后集成的结果不就是每棵树的结果嘛。所以生成每棵树的时候一定要只用部分样本来生成。

细节2,除了训练样本要抽样,训练特征也要抽样!就是每棵树的生成不仅要抽样样本还要抽样特征。这样做的目的就是生成各种不同的树。每棵树见的样本、特征都各异,那么每棵树就有自己不同的擅长点。所以,用bagging方法集成的时候一定要保证基分类器是相互独立的,是不相同的,才会有集成的效果。

细节3,这种随机有放回的抽样本方法,对于小样本量数据和少特征数据来说,每个样本、每个特征都有可能被抽到,被学习。但是对于训练集样本量很大的数据集来说,就有可能有的样本压根就抽不到!假如有n条样本,一条样本被抽到的概率是1/n,没被抽到的概率就是1-1/n,假如抽了m次,它都没被抽到的概率是(1-1/n)**m,当m、n都趋向很大时,这个概率就是趋向于1/e,也就是约有36.8%的样本一直没有被抽到。这些数据称为袋外数据out of bag data(oob),所以我们在使用随机森林时,我们可以不用划分测试集和训练集,只需要用袋外数据来测试我们的模型即可。当然前提是你数据量够多,有袋外数据。当你数据量不多时,就没有袋外数据,自然就没得用了。

小结:bagging法可以很大程度上减小单个模型选择不当的风险,Bagging可以获得一个方差比其单个基模型更小的强模型。所以当你使用bagging方法时,你的基模型最好是低偏差、高方差的基模型,此时集成模型会方差变小、模型更稳定。

方法2:boosting法。
boosting方法和bagging一样,其基模型都是同类的弱模型,但它不像bagging中各个弱模型都是独立的、并联的,boosting中的基模型都是严格有序的、串联的。
不同于bagging,boosting是着眼于生成一个偏差比其单个基模型更低的强模型,就是从前往后对一个个基模型进行筛选和打磨,让每个基模型都非常强大,并且还赋予各个基模型不同的权重来集成它们。通俗的说,就是使用各种手段,提高串联中的各个模型的效果,让每个弱模型都很强大,并且用加权的形式集成这些基模型,自然最后的结果就更加好了。基于这个目标和方向,boosting方法在基模型的样本选择、训练过程、集成规则等方面都有自己独特的地方。

boosting方法分自适应提升(adaboost)和梯度提升(gradient boosting)两种方式。

(1)adaboost 是自适应提升。什么是自适应提升呢?就是在一个一串的基模型中(基模型的顺序不能改变),每个基模型的训练集都是原训练集,只是训练集中每个样本的权重发生了变化,而这个权重是根据它上一个分类器的分类结果自动进行调整的。就是对上一个分类器分对的样本除以一个数,分错的样本乘以这个数,这样数据集的样本权重就变了,这也是为什么是自适应的原因了。然后把变了权重的训练集喂入下一个基模型。我们都知道,一个样本的权重越高,就意味着在损失函数中它对应的损失前面的系数也越大,那在损失函数的牵引下,这个样本会被重点学习,所以上一个分类器分错的样本,下一个分类器就会重点分这个样本。 除了基模型的训练样本权重不一样外,adaboost还使用加权规则来进行集成。如何加权集成呢?比如第一个基模型的训练结果先保存一下,开始训练第二个基模型时,除了给它调整样本权重外,还把它的训练结果也保存下,然后就再用比如线性回归,把两个预测结果和真实标签放一起线性回归一下,就得到这两个基模型的集成权重。开始训练第三个基模型时,同理除了调整样本权重外,还得把结果和前两个基模型的结果放一起和真实标签回归一下,如果第三个基模型对真实标签拟合没作用的话,那它的回归系数自然就是0了,就是说第三个基模型的权重是0,集成后的预测结果也没它啥事了,继续生成第四个基模型,以此类推,保证所有的添加的基模型都是对拟合标签是有贡献的。这也是boost的原因。自适应提升的典型代表算法是AdaBoost。鉴于不同的任务使用不同的集成规则,AdaBoost就有比如LogitBoost(分类)或L2Boost(回归)等变体。

(2)gradient boosting, 梯度提升,什么是梯度提升呢?梯度提升不是像adaboost那样变换样本权重,而是后面的基模型学习的都是前面模型的残差,当残差达到一定阈值时,就停止生成新的基模型,所以叫梯度提升。这种方法也不会牵扯什么集成规则,集成后的模型预测值是所有基模型的预测值之和。该方法效果非常好,和深度学习中残差网络的思路一样,都是直接拟合残差,效果超好。对应的典型算法是GBDT及其改进优化的工程版变体XGBoost,以及当前大火的微软开源的LightGBM,也是一个基于决策树算法的梯度提升框架。LightGBM以速度惊人、支持分布式、占用内存小等特点迅速成为当前机器学习算法中的新宠。

小结:当你使用boosting方法的时候,你的基模型一定是一些弱模型,就是欠拟合你的数据的模型。此时用boosting集成,效果就会大大提升。反之,如果你的基模型都是过拟合模型,还boosting啥呢,都没空间boosting了。其次boosting方法对噪声样本和异常样本比较敏感,当数据集不太好时,你的重点要先放在特征工程上,先在特征工程上做一些trick,然后再用boosting方法。

方法3:stacking法。
stacking则是使用不同的基评估器,然后集成的时候就按adaboost中的集成方法,把所有基评估器的结果再线性回归或者逻辑回归一下即可。

小结:bagging方法中的基评估器都是一样,而且地位也都一样,都是独立的,所以可以并联运行。boosting方法中的基评估器也是都是一样的,但它们是串联起来的,不能并行运行。stacking的基评估器也是可以并行训练的,只是最后要再回归一下。

2、准备数据:HiggsBoson数据集
(1)从kaggle上下载数据

(2)读取数据、简单探索数据、分训练集和测试集

import time
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score

data = pd.read_csv(r'training.csv')
features = data.iloc[:, 1:-2]    #250000 rows × 31 columns
label = data.iloc[:, -1]
xtrain, xtest, ytrain, ytest = train_test_split(features, label, test_size=0.3, random_state=123)

3、所有模型先跑一遍看看数据底线

(1)决策树

start = time.time()
from sklearn.tree import DecisionTreeClassifier
tree = DecisionTreeClassifier()
tree.fit(xtrain, ytrain)

ypred = tree.predict(xtest)
score_train = tree.score(xtrain, ytrain)
score_test = tree.score(xtest, ytest)
print('训练集:', score_train, '    ', '测试集:', score_test)
print(accuracy_score(ypred, ytest))
print(time.time()-start)
训练集: 1.0      测试集: 0.76444
0.76444
21.315803289413452

决策树不是集成算法,但是这里把它列出了是因为,后面的集成模型的基模型都是决策树,所以为了对比,我们看看单个树模型的效果。
从分值上看,单个树对训练集是全部预测正确,但是对测试的正确率只有0.76444,可见这个模型就是一个典型的过拟合模型。当然主要原因也是我们压根就没有调参,全部都是默认参数,如果好好调调超参数,消减一下过拟合,这个模型可能会好一点,但是模型极限也大概这个样子了。

(2)随机森林

start = time.time()
from sklearn.ensemble import RandomForestClassifier
clf = RandomForestClassifier(n_estimators=100)
clf.fit(xtrain, ytrain)

ypred = clf.predict(xtest)
score_train = clf.score(xtrain, ytrain)
score_test = clf.score(xtest, ytest)
print('训练集:', score_train, '    ', '测试集:', score_test)
print(accuracy_score(ypred, ytest))
print(time.time()-start)
训练集: 0.9999885714285714      测试集: 0.8369866666666667
0.8369866666666667
215.39269065856934

随机森林直接把分值从0.76提升到0.83,但是这个模型也是一个典型的过拟合模型,训练集几乎100%拟合。但是这个模型最主要的是速度太慢,用了3分多钟。

(3)AdaBoost

start = time.time()
from sklearn.ensemble import AdaBoostClassifier
ada = AdaBoostClassifier(n_estimators=100)
ada.fit(xtrain, ytrain)

ypred = ada.predict(xtest)
score_train = ada.score(xtrain, ytrain)
score_test = ada.score(xtest, ytest)
print('训练集:', score_train, '    ', '测试集:', score_test)
print(accuracy_score(ypred, ytest))
print(time.time()-start)
训练集: 0.8186114285714285      测试集: 0.8164266666666666
0.8164266666666666
158.35273146629333

adaboost和随机森林比起来不过拟合,训练集和测试集的分值差不多,测试集只比训练集稍稍差了一点点,非常正常。速度虽比随机森林有所提升,但也太慢。

(4)GBDT

start = time.time()
from sklearn.ensemble import GradientBoostingClassifier
gbdt = GradientBoostingClassifier(n_estimators=100)
gbdt.fit(xtrain, ytrain)

ypred = gbdt.predict(xtest)
score_train = gbdt.score(xtrain, ytrain)
score_test = gbdt.score(xtest, ytest)
print('训练集:', score_train, '    ', '测试集:', score_test)
print(accuracy_score(ypred, ytest))
print(time.time()-start)
训练集: 0.8342742857142857      测试集: 0.82964
0.82964
283.10672426223755

GBDT比ada的效果要好一点点,提升了一点点,而且也不过拟合,非常正常。但是速度实在是太慢太慢。慢的原因有很多,但效果好,所以后来的xgboost和lightGBM都是在它的基础上进行了原理层面以及工程层面的很大优化。

(5)XGBoost
XGBoost可以说是GBDT的工程化版本,得自己安装:pip install xgboost==1.0.1 推荐这个版本是因为这个版本稳定一些。

from xgboost import XGBClassifier
from sklearn.preprocessing import LabelBinarizer      #处理标签列

#读取源数据
data = pd.read_csv(r'training.csv')
features = data.iloc[:, 1:-2]    #250000 rows × 31 columns
label = data.iloc[:, -1]
xtrain, xtest, ytrain, ytest = train_test_split(features, label, test_size=0.3, random_state=123)  

#给标签列编码
binarized = LabelBinarizer()   
ytrain = binarized.fit_transform(ytrain).ravel()
ytest = binarized.fit_transform(ytest).ravel()

start = time.time()
#建模-训练-查看准确率
xgb = XGBClassifier(objective='binary:logistic')
xgb.fit(xtrain, ytrain)
ypred = xgb.predict(xtest)
score_train = xgb.score(xtrain, ytrain)
score_test = xgb.score(xtest, ytest)
print('训练集:', score_train, '    ', '测试集:', score_test)
print(accuracy_score(ypred, ytest))
print(time.time()-start)
训练集: 0.8675142857142857      测试集: 0.8398933333333334
0.8398933333333334
3.4247934818267822

可见,XGBoost比GBDT效果还要更好一点,而且也不过拟合。重点是速度提升了很多很多,速度几乎是GBDT的十分之一。

(6)LightGBM
目前的新宠LGB都不叫算法,叫框架。既然是框架,就表示更加复杂、更加庞大,所以要单独下载安装:pip install lightgbm
光LGB的参数说明都可以长篇大论写一篇博客了,网上也有很多,想抠细节的小伙伴自行百度其他博文吧,我这里侧重点是走通一个完整流程,了解大框架,所以其中的细节不再展开讲解。当然在你非常了解了决策树的生成和剪枝的细节、以及boost集成的操作思路后,相信在算法层面,你理解LGB应该已经不会太困难了,可能最大的障碍就是工程方面的优化会比较晦涩一些。

import time
import lightgbm
from sklearn.preprocessing import LabelBinarizer      #处理标签列
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
#读取源数据
data = pd.read_csv(r'training.csv')
features = data.iloc[:, 1:-2]    #250000 rows × 31 columns
label = data.iloc[:, -1]
xtrain, xtest, ytrain, ytest = train_test_split(features, label, test_size=0.3, random_state=123)  

#给标签列编码
binarized = LabelBinarizer()   
ytrain = binarized.fit_transform(ytrain).ravel()
ytest = binarized.fit_transform(ytest).ravel()

start = time.time()
# 将训练集和测试集整理成lgb要求的形式
lgb_train = lightgbm.Dataset(xtrain, ytrain) # 创建训练集,将数据保存到LightGBM二进制文件将使加载更快
lgb_test = lightgbm.Dataset(xtest, ytest, reference=lgb_train)  # 创建测试集

# 将参数写成字典下形式
params = {'task': 'train', 'boosting_type': 'gbdt', 'objective': 'binary', 'metric':'auc', 'is_unbalance':True, 'force_col_wise':True}

# 训练模型
gbm = lightgbm.train(params,lgb_train,valid_sets=[lgb_train, lgb_test])

#查看训练结果
score = gbm.best_score
print('训练集:', score['training'])
print('测试集:', score['valid_1'])

# 训练后保存模型到文件
gbm.save_model('model_gbm.txt') 
print(time.time()-start)
[LightGBM] [Info] Number of positive: 59977, number of negative: 115023
[LightGBM] [Info] Total Bins 7388
[LightGBM] [Info] Number of data points in the train set: 175000, number of used features: 30
[LightGBM] [Info] [binary:BoostFromScore]: pavg=0.342726 -> initscore=-0.651171
[LightGBM] [Info] Start training from score -0.651171
训练集: OrderedDict([('auc', 0.9174424819661972)])
测试集: OrderedDict([('auc', 0.9077815342580077)])
5.596917152404785

可见,强大的算法就是强大,只设置了几个必要的、通用的参数,细节完全没有调,就直接把分数拉到0.9以上了!而且速度也是可以接受的范围。

(7)Stacking模型融合
前面的这些集成算法,不管是bagging还是boosting,它们的基模型都是同类模型,就是都是树模型。但是stacking就不一样了,它是把几个异质的模型融合到一起,所以stacking方法是更加强大的方法,是数据竞赛中常用的模型融合方法。这里只简单跑通一个流程演示一下。

from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error

from sklearn.ensemble import StackingRegressor
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeRegressor

data = load_breast_cancer() 
features = data.data
target = data.target
xtrain, xtest, ytrain, ytest=train_test_split(features,target,test_size=0.2)

model1 = DecisionTreeRegressor()
model2 = LinearRegression()
stacking = StackingRegressor(estimators=[('dt', model1), ('lr', model2)], final_estimator=LinearRegression())
stacking.fit(xtrain, ytrain)
# 预测
y_pred = stacking.predict(xtest)
 
# 模型评价
print(stacking)
rmse = mean_squared_error(ytest, y_pred) ** 0.5
rmse
StackingRegressor(estimators=[('dt', DecisionTreeRegressor()),
                              ('lr', LinearRegression())],
                  final_estimator=LinearRegression())
0.2441514442707548

4、模型调优

 前面仅仅是模型跑通了,还得调优。调优就是组合一系列模型可能的参数取值,看哪组参数最适合我们的数据,就是哪组参数可以更好的学习我们的数据,就是哪组参数可以这个数据集上取得更高的分值,当然这个分值是要综合考虑训练集的分值和测试集的分值的,也就是要考虑过拟合问题。也所以模型调优的大前提就是,一是,你要对每个模型的背后原理和数学计算得非常非常清晰,你才有调优的方向。二是,你得知道一些调优工具,像我在上篇决策树文章最后给大家演示了两种调优方法,一个是学习曲线,一个是网格搜索。显然本篇将得这7个模型,用学校曲线都不行,因为学习曲线是针对单个参数调优,它不能联动调优。本篇的前6个模型推荐都用网格搜索,至于网格搜索的工具,如果你的数据量不是巨量的,电脑内存都可以,那你用sklearn中的GridSearchCV调调;如果你的数据是海量的,你不妨试试RandomizedSearchCV,如果你觉得RandomizedSearchCV效果不行,那你试试HalvingGridSearchCV。

本篇的前6个模型中,除了决策树,后面5个都是集成模型,尽管是集成模型,但它们都是单模型,但是第7个模型是模型融合,它的调优就更复杂,你可以试试HyperOPT优化,具体怎么使用你可以再继续百度,因为都不是几句话可以说明白的,你知道了这个方向,就可以自己探索了。  

写在最后,给大家简单通俗的介绍一下随机森林的参数,调优的前提是你对参数非常非常的了解嘛,其他模型也都是树模型的集成,相信你都可以自己猜的七七八八了,剩下的就是多看资料去印证。  

n_estimators  1、森林中要有几颗树模型,就是有几个基分类器  
criterion  不纯度指标的计算方法,这个指标是树模型进行分支的依据  A
splitter  默认是根据criterion的计算结果进行分支,这个参数也可以设置为随机划分,如果随机划分每棵树就会长得更深。B
max_depth  2、树的最大深度    C
min_samples_split  3、再分支的最小样本量   D
min_samples_leaf  4、叶节点的最少样本量   E
min_weight_fraction_leaf   叶节点的最小权重和   G
max_features  5、每次分支时要选择几个特征开始计算并分支   F
random_state  随机模式,和随机数种子类似,可以输入任何数字。当我们设定这个参数时,每次实例化后就会长出同一片森林,方便对比。  K
max_leaf_nodes  叶节点的最大个数,就是不能分太多的叶子节点   i
min_impurity_split  分支的最低不纯度,就是不纯度小于这个参数就不用再继续分支了  j
class_weight  各类标签的样本的权重,当样本不均衡时要考虑这个参数,增加少量标签的权重,让模型更加偏向少数类。H

前面表12345的这五个参数是调参频率最高的五个参数,就是我们一般都是调这五个参数,其他参数看情况调。  

ABFK:criterion 、splitter 、max_features、random_state  
当用一棵树来表示一个二维表格数据(行是一个个样本,列是一个个特征)的时候,也就是用一个树模型来拟合一个二维表格数据的时候,这3个参数就决定了你将生成一棵什么样的树。
criterion :我们在生成树的时候,最主要的就是按那个特征的哪个取值进行二分分支,也就是选取哪个特征作为节点,而且选取这个特征的哪个值进行分支。那这个依据就是不纯度指标,就是计算各个特征的不纯度,然后按照不纯度指标最低的那给特征进行分支,而根据这个特征的哪个值进行分支还是用这个指标来计算。
参数criterion就是不纯度的计算方法。如果是分类任务,不纯度可以用信息熵entropy或者基尼系数Gini Impurity(gini,也叫信息增益)两种方式来计算。如果是回归任务,不纯度可以用均方差mse或者平均绝对差friedman_mse来计算。所以这个参数是来控制一棵树如何生长的背后数学计算公式的。
参数splitter:有2种取值,当splitter='best'时,就是对所有特征进行不纯度计算后,找到不纯度值最低的那个指标,按照那个指标进行分支。但是当我们的特征有成千上万个后,这种方法计算量就很大,树模型的生成就很慢,此时我们可以用这个参数splitter='random',就是每次都随机选择几个特征进行计算并分支。但是这种情况随机性又太大。
所以,当我们的splitter='best'时,我们还可以通过调整参数max_features来控制每次计算的特征个数,以此来减小计算量。
默认max_features='None'表示计算所有特征;max_features='auto'或者“sqrt”都表示每次只计算根号N个特征的不纯度;max_features='log2'表示每次都只计算log2N个特征。
如果你的splitter=best,那你每次长的都是同一棵树。因为数据不变,计算不纯度是用的是全部特征,而全部特征又是不变的,所以每次按照哪个特征进行分支就会不变,最后生成的树结构也不变。但是如果你的splitter='random'或者max_features不是None,那你每次生成的树都是不一样的,那不是很难复现模型嘛,为了即使有随机也能复现,我们就用random_state参数来控制每次随机时的随机模式,这样每次随机的时候都是一样的模式,就可以复现了,方便你对比调参等工作。  

C:max_depth  :如果说ABFK合起来就可以生成一个完整的树,那么max_depth就是对这棵树进行暴力剪枝的参数。这个参数是限制树的最大深度。限制深度可以有效抑制过拟合。比如很极端,我生成的树模型细分到一个样本一个结果,那模型在训练集的准确率肯定是100%,但是这么深这么细的分支的树是一个过拟合模型,在测试集上肯定不行的,所以我们要抑制模型的过拟合现象。那这个参数就是最常用的一个参数。  

ij:max_leaf_nodes 、min_impurity_split:这两个参数也是从不同的角度对树模型进行微暴力剪枝的。目前自然也是为了抑制模型过拟合。一个是从叶子节点数方面进行暴力剪枝的;一个是从节点的不纯度角度进行的剪枝,当一个节点再进行分支后的样本的不纯度小于这个参数的值,就不要再分支了,这个节点自己就当作一个叶子节点吧。这两种剪枝方法我们一般不用,max_leaf_nodes 默认是None, min_impurity_split默认值是0。就是不用这两个参数。原因是这两个参数我们初次建模很难拿捏呀,除非你对这个数据集已经研究了很久,也已经建过很多个树模型了,你对最大叶子节点数量和最小不纯度有很高的把握了,你才知道怎么调整这两个参数。  

DE:min_samples_split 、min_samples_leaf 。如果说max_depth参数是暴力剪枝,max_leaf_nodes 、min_impurity_split是高手剪枝,那这两个参数就是我们普通人的精修操作了。
比如min_samples_split =5,表示如果当前的节点中的样本量小于5个,即使这5个样本的target不一样,也不用继续分支了。就是停止分支,就这样吧。
比如min_samples_leaf=3表示如果当前叶子节点中的样本量小于3个,这个叶子节点和它的兄弟节点都要删掉,只留下父节点即可。
D和E经常搭配一起,比如有个节点的样本有6个,那它是满足继续分支的条件的,但是如果这6个样本中有2个样本target是1,4个样本的target是2,那就不要分了,因为左分支样本量是2个,小于3了,就不要分了,还是让这6个样本的节点当作叶子节点吧。不要再继续分了。
这两个参数也是防止过拟合的,如果设置得太大,就阻止了模型的继续学习,如果参数设置得太小,就会过拟合。这两个参数和C搭配起来使用,有很好的抗过拟合效果。

GH:min_weight_fraction_leaf、class_weight  这两个参数是搭配使用的,主要是针对分类问题中的样本不均衡的情况。class_weight的默认值是None,就是不考虑样本均衡问题,就是样本基本均衡的情况。当class_weight=“balanced”时,算法会自动计算各类标签的样本权重的。此时各类样本是有不同的权重值的。而参数min_weight_fraction_leaf 的默认值是0就是不考虑权重问题,就是不管叶子节点里面的样本的权重值,是多少都行。如果设置了min_weight_fraction_leaf =0.01,就表示叶子节点中所有样本权重(各个样本的权重值是由class_weight提供的)的和,如果小于0.01,这个叶子节点就和它的兄弟节点一起被剪枝。
 

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐