GBDT算法整理_算法简单题目

GBDT算法整理_算法简单题目最近重点学习了gbdt算法,看了较多的博客文章,整理了一下这些比较有用的内容,包括算法理论、算法分析、代码剖析、注意事项等各个方面。转载来源:http://www.cnblogs.com/rocketfan/p/4324605.htmlhttp://www.cnblogs.com/rocketfan/p/4365950.htmlhttp://www.cnblogs.com/

大家好,又见面了,我是你们的朋友全栈君。如果您正在找激活码,请点击查看最新教程,关注关注公众号 “全栈程序员社区” 获取激活教程,可能之前旧版本教程已经失效.最新Idea2022.1教程亲测有效,一键激活。

Jetbrains全系列IDE稳定放心使用

最近重点学习了gbdt算法,看了较多的博客文章,整理了一下这些比较有用的内容,包括算法理论、算法分析、代码剖析、注意事项等各个方面。

转载来源:

http://www.cnblogs.com/rocketfan/p/4324605.html

http://www.cnblogs.com/rocketfan/p/4365950.html

http://www.cnblogs.com/rocketfan/p/4366501.html

http://blog.163.com/zhoulili1987619@126/blog/static/3530820120159852041442/

http://blog.csdn.net/w28971023/article/details/43704775

http://www.cnblogs.com/leftnoteasy/archive/2011/03/07/random-forest-and-gbdt.html

GBDT的基本原理

这里以二元分类为例子,给出最基本原理的解释

   

GBDT 是多棵树的输出预测值的累加

GBDT的树都是 回归树 而不是分类树

   

  1. 分类树

GBDT算法整理_算法简单题目

   

分裂的时候选取使得误差下降最多的分裂

GBDT算法整理_算法简单题目

计算的技巧

GBDT算法整理_算法简单题目

最终分裂收益按照下面的方式计算,注意圆圈内的部分是固定值

GBDT算法整理_算法简单题目

  1. GBDT 二分类

GBDT在实现中可以完全复用上面的计算方法框架,只是我们的优化的目标函数不同。

这里使用的是 指数误差函数,不管是预测正确还是错误 误差值都存在,但是正确的预测 会使得误差值小于错误的预测 参考

AdaBoost and the Super Bowl of Classiers

A Tutorial Introduction to Adaptive Boosting

GBDT算法整理_算法简单题目

关于常用误差函数 参考 http://www.cnblogs.com/rocketfan/p/4083821.html

   

参考 Greedy Functon Approximation:A Gradient Boosting Machine

4.4节关于二分类情况误差函数的设计

GBDT算法整理_算法简单题目

这里其实和上面给出的一样,只是增加了 log(1 +, 另外多了一个2,2yF), 参考前面的LossFunction http://www.cnblogs.com/rocketfan/p/4083821.html

的推导,其实这个应该算作LogLoss或者说是logistic regression, cross entropy error,也就是从probablity出发的logloss推导到output F(x)的表示就是上面的

式子,而它看上去刚好就是一个指数误差函数。

严格意义上说是LogLoss不是指数误差 不过LogLoss和指数误差看上去比较相似。

GBDT算法整理_算法简单题目

   

这个F值其实就是逻辑回归的思路,类似 语音语言处理一书27页解释,线性加权的值(output)用来预测 p(true)和p(false)的比例的log值(回归值是实数范围取值不适合预测0-1,做了一个转换),越是接近true,那么F(x)越接近+无穷(对应最大可能性判断true), p(false)越大 那么越接近-无穷(对应最大可能性判断false)

   

GBDT算法整理_算法简单题目

F(X) 对应 feature X 当前的回归预测值也就是多棵树经过决策到达叶子节点的输出值output(x)的累加值。N个样本则F(x)N个维度,当开始没有分裂的时候所有样本在一个节点则所有F(x)对应一个相同的值,分裂一次后两个叶子节点则F(X)对应可能到不同的叶子节点从而可能有两个不同的值。

对误差函数计算关于F的梯度,误差函数是

GBDT算法整理_算法简单题目

变量是F(x)

GBDT算法整理_算法简单题目

   

考虑learning_rate之后是 (@TODO)

GBDT算法整理_算法简单题目

F(X) 对应 叶子节点中一个样本对应它的feature X 当前的预测值

参考 机器学习概率角度 一书的16

GBDT算法整理_算法简单题目

   

   

我们的分裂目标从上面回归树基本算法中的希望逼近变成了 逼近梯度值 r_im

也就是说当前树是预测负梯度值的。

F_m(x) = F_m-1(x) + learning_rate*(当前树的预测值(也就是预测负梯度..)) //@TODO check

   

再对比下ng课件最简单的梯度下降 针对regression的例子

GBDT算法整理_算法简单题目

GBDT算法整理_算法简单题目

   

GBDT算法整理_算法简单题目

我们采用的每颗树更新策略是针对F(x)的,而F(x)沿着梯度的方向的累加,目标是使得我们的

GBDT算法整理_算法简单题目

误差函数达到最小。

GBDT原理实例演示 1

考虑一个简单的例子来演示GBDT算法原理

下面是一个二分类问题,1表示可以考虑的相亲对象,0表示不考虑的相亲对象

特征维度有3个维度,分别对象 身高,金钱,颜值

GBDT算法整理_算法简单题目

   

cat dating.txt

#id,label,hight,money,face

_0,1,20,80,100

_1,1,60,90,25

_2,1,3,95,95

_3,1,66,95,60

_4,0,30,95,25

_5,0,20,12,55

_6,0,15,14,99

_7,0,10,99,2

   

这个例子仅仅为了试验,数据量很小没有更多统计意义。

   

0,1,2,3对应可以考虑的相亲对象

4,5,6,7 对应不考虑的相亲对象

   

先看一下gbdt训练的结果

mlt dating.txt -cl gbdt -ntree 2 -nl 3 -lr 0.1 -mil 1 -c train -vl 1 -mjson=1

设置2棵树,叶子节点最多3 也就是最多2次分裂,learning rate设置为0.1 叶子节点中的最少样本数设置为1(仅供试验,一般不会设置为1,避免过拟合)

为了打印二叉树设置输出json格式的模型

   

Per feature gain:

0:face 1

1:hight 0.730992

2:money 0.706716

Sigmoid/PlattCalibrator calibrating [ 8 ] (0.00011 s)100% |******************************************|

I0324 16:57:53.240083 17630 time_util.h:113] Train! finished using: [1.486 ms] (0.001486 s)

I0324 16:57:53.240094 17630 time_util.h:102] Test itself! started

   

TEST POSITIVE RATIO:        0.5000 (4/(4+4))

   

Confusion table:

||===============================||

|| PREDICTED ||

TRUTH || positive | negative || RECALL

||===============================||

positive|| 4 | 0 || 1.0000 (4/4)

negative|| 0 | 4 || 1.0000 (4/4)

||===============================||

PRECISION 1.0000 (4/4) 1.0000(4/4)

LOG LOSS/instance:                0.2981

TEST-SET ENTROPY (prior LL/in):        1.0000

LOG-LOSS REDUCTION (RIG):        70.1854%

   

OVERALL 0/1 ACCURACY:        1.0000 (8/8)

POS.PRECISION:                1.0000

PPOS.RECALL:                1.0000

NEG.PRECISION:                1.0000

NEG.RECALL:                1.0000

F1.SCORE:                 1.0000

AUC: [1.0000]

   

对应这个例子,训练结果是perfect的,全部正确, 特征权重可以看出,对应这个例子训练结果颜值的重要度最大,看一下训练得到的树。

print-gbdt-tree.py –tree -1

Tree 0:

GBDT算法整理_算法简单题目

Tree 1:

GBDT算法整理_算法简单题目


GBDT原理实例演示 2

一开始我们设定F(x)也就是每个样本的预测值是0(也可以做一定的随机化)

Scores = { 0, 0, 0, 0, 0, 0, 0, 0}

   

那么我们先计算当前情况下的梯度值

GBDT算法整理_算法简单题目

   

GetGradientInOneQuery = [this](int query, const Fvec& scores)

{

//和实际代码稍有出入 简化版本

_gradient[query] = ((2.0 * label) * sigmoidParam) / (1.0 + std::exp(((2.0 * label) * sigmoidParam) * scores[query]));

};

   

考虑 0号样本 label1 , learningRate也就是sigmoidParam设置为0.1, scores[query] = 0 当前Scores全是0

2 * 1 * 0.1 / (1 + exp(2 * 1 * 0.1 * 0)) = 0.1

考虑 7号样本 label是-1

* -1 * 0.1 / (1 + exp(2 * -1 * 0.1 * 0)) = –0.1

   

因此当前计算的梯度值是

Gradient = { 0.1, 0.1, 0.1, 0.1, –0.1, –0.1, –0.1, –0.1}

   

于是我们要当前树的输出F(x)拟合的targets就是这个Grandient

Targets = { 0.1, 0.1, 0.1, 0.1, –0.1, –0.1, –0.1, –0.1}

RegressionTree tree = TreeLearner->FitTargets(activeFeatures, AdjustTargetsAndSetWeights());

   

virtual RegressionTree FitTargets(BitArray& activeFeatures, Fvec& targets) override

   

现在我们考虑拟合这个梯度

gdb ./test_fastrank_train

(gdb) r -in dating.txt -cl gbdt -ntree 2 -nl 3 -lr 0.1 -mil 1 -c train -vl 1 -mjson=1

p Partitioning

$3 = {_documents = std::vector of length 8, capacity 8 = {0, 1, 2, 3, 4, 5, 6, 7}, _initialDocuments = std::vector of length 0, capacity 0, _leafBegin = std::vector of length 3, capacity 3 = {0, 0,

0}, _leafCount = std::vector of length 3, capacity 3 = {8, 0, 0}, _tempDocuments = std::vector of length 0, capacity 0}

   

gbdt对应每个特征要做离散化分桶处理,比如分255个桶,这里样本数据比较少,对应height特征,

20, 60, 3, 66, 30, 20, 15, 10

分桶也就是变成

BinMedians = std::vector of length 7, capacity 7 = {3, 10, 15, 20, 30, 60, 66}

p *Feature

$11 = {_vptr.Feature = 0xce8650 <vtable for gezi::Feature+16>, Name = “hight”,

BinUpperBounds = std::vector of length 7, capacity 7 = {
6.5, 12.5, 17.5, 25, 45, 63, 1.7976931348623157e+308},

BinMedians = std::vector of length 7, capacity 7 = {
3, 10, 15, 20, 30, 60, 66},

Bins = {_vptr.TVector = 0xce8670 <vtable for gezi::TVector<int>+16>, indices = std::vector of length 0, capacity 0,

values = std::vector of length 8, capacity 8 = {
3, 5, 0, 6, 4, 3, 2, 1}, sparsityRatio = 0.29999999999999999, keepDense = false, keepSparse = false, normalized = false, numNonZeros = 7,

length = 8, _zeroValue = 0}, Trust = 1}

 

Bins对应分桶的结果,比如_0样本hight 20,那么分桶结果是编号3的桶(0开始index)

   

考虑Root节点的分裂,分裂前考虑是8个样本在一个节点,我们选取一个最佳的特征,以及对应该特征最佳的分裂点

考虑hight特征,我们要扫描所有可能的分裂点 这里也就是说 考虑6个不同的分裂点

for (int t = 0; t < (histogram.NumFeatureValues – 1); t += 1)

   

比如6.5这个分裂点

那么

就是左子树 1(_2样本) 右子树7个,考虑下面公式 收益是 0.1^2/1 + (-0.1)^2/7 – CONSTANT = 0.01142857142857143 – CONSTANT

GBDT算法整理_算法简单题目

   

类似的考虑分裂点12.5,17.5……….. 选取一个最佳分裂点

   

然后同样的考虑 money, face 特征 选取最优(特征,分裂点)组合,

这里最优组合是(hight, 45)

   

左侧得到

   

_0,_2,_4,_5,_6, _7 -> 0.1 + 0.1 – 0.1 – 0.1 – 0.1 -0.1

   

右侧得到

_1,_3 -> 0.1 + 0.1

收益是

(-0.2)^2 /6 + (0.2)^2 / 2 – CONSTANT = 0.026666666666666665 – CONSTANT

(gdb) p bestShiftedGain

$22 = 0.026666666666666675

   

对应>的子树输出应该是 0.2 / 2 = 0.1 下图对应展示output1,因为后续还有AdjustOutput,因为至少需要 F_m(x) = F_m-1(x) + learning_rate*(当前树的预测值(也就是预测负梯度..)) 黄色部分是最终该棵树的输出值

   

GBDT算法整理_算法简单题目

   

之后再选取两个分裂后的组 选一个最佳(特征,分裂)组合 -> (face, 57.5)

   

(gdb) p tree

$26 = {<gezi::OnlineRegressionTree> = {NumLeaves = 3, _gainPValue = std::vector of length 2, capacity 2 = {0.15304198078836101, 0.27523360741160119},

_lteChild = std::vector of length 2, capacity 2 = {1, -1}, _gtChild = std::vector of length 2, capacity 2 = {-2, -3}, _leafValue = std::vector of length 3, capacity 3 = {-0.10000000000000002,

0.10000000000000002, 0.033333333333333347}, _threshold = std::vector of length 2, capacity 2 = {4, 2}, _splitFeature = std::vector of length 2, capacity 2 = {0, 2},

_splitGain = std::vector of length 2, capacity 2 = {0.026666666666666675, 0.026666666666666679}, _maxOutput = 0.10000000000000002, _previousLeafValue = std::vector of length 2, capacity 2 = {0,

-0.033333333333333333}, _weight = 1, _featureNames = 0x6e6a5a <gezi::FastRank::GetActiveFeatures(std::vector<bool, std::allocator<bool> >&)+34>},

_parent = std::vector of length 3, capacity 3 = {1, -1, -2}}

   

调整一下Output

//GradientDecent.h

virtual RegressionTree& TrainingIteration(BitArray& activeFeatures) override

{

RegressionTree tree = TreeLearner->FitTargets(activeFeatures, AdjustTargetsAndSetWeights());

if (AdjustTreeOutputsOverride == nullptr)

{ //如果父类ObjectiveFunction里面没有虚函数 不能使用dynamic_pointer_cast… @TODO

(dynamic_pointer_cast<IStepSearch>(ObjectiveFunction))->AdjustTreeOutputs(tree, TreeLearner->Partitioning, *TrainingScores);

}

{

UpdateAllScores(tree);

}

Ensemble.AddTree(tree);

return Ensemble.Tree();

}

   

virtual void AdjustTreeOutputs(RegressionTree& tree, DocumentPartitioning& partitioning, ScoreTracker& trainingScores) override

{

//AutoTimer timer(“dynamic_pointer_cast<IStepSearch>(ObjectiveFunction))->AdjustTreeOutputs”);

for (int l = 0; l < tree.NumLeaves; l++)

{

Float output = 0.0;

if (_bestStepRankingRegressionTrees)

{

 * tree.GetOutput(l);

}

else

{
 //现在走这里

 * (tree.GetOutput(l) + 1.4E-45)) / (partitioning.Mean(_weights, Dataset.SampleWeights, l, false) + 1.4E-45);

}

if (output > _maxTreeOutput)

{

output = _maxTreeOutput;

}

else if (output < -_maxTreeOutput)

{

output = -_maxTreeOutput;

}

tree.SetOutput(l, output);

}

}

   

(gdb) p _weights

$33 = std::vector of length 8, capacity 8 = {0.010000000000000002, 0.010000000000000002, 0.010000000000000002, 0.010000000000000002, 0.010000000000000002, 0.010000000000000002,

0.010000000000000002, 0.010000000000000002}

   

_learningRate * tree.Getoutput(1) / partioning.Mean(_weights..) = 0.1 * 0.1 / 0.01 = 1

   

(gdb) p tree

$35 = (gezi::RegressionTree &) @0x7fffffffd480: {<gezi::OnlineRegressionTree> = {

NumLeaves = 3, _gainPValue = std::vector of length 2, capacity 2 = {0.15304198078836101, 0.27523360741160119},

_lteChild = std::vector of length 2, capacity 2 = {1, -1}, _gtChild = std::vector of length 2, capacity 2 = {-2, -3}, _leafValue = std::vector of length 3, capacity 3 = {-1, 1,

0.33333333333333343}, _threshold = std::vector of length 2, capacity 2 = {4, 2}, _splitFeature = std::vector of length 2, capacity 2 = {0, 2},

_splitGain = std::vector of length 2, capacity 2 = {0.026666666666666675, 0.026666666666666679}, _maxOutput = 0.10000000000000002, _previousLeafValue = std::vector of length 2, capacity 2 = {0, -0.033333333333333333}, _weight = 1, _featureNames = 0x6e6a5a <gezi::FastRank::GetActiveFeatures(std::vector<bool, std::allocator<bool> >&)+34>}, _parent = std::vector of length 3, capacity 3 = {1, -1, -2}}

   

之后UpdateAllScores(tree); 是用来更新scores的值,这里就是8个样本对应的scores值,也就是计算F(x),注意多棵树则是对应记录多棵树的输出的值累加

virtual void AddScores(RegressionTree& tree, DocumentPartitioning& partitioning, Float multiplier = 1)

{

for (int l = 0; l < tree.NumLeaves; l++)

{

int begin;

int count;

ivec& documents = partitioning.ReferenceLeafDocuments(l, begin, count);

Float output = tree.LeafValue(l) * multiplier;

int end = begin + count;

#pragma omp parallel for

for (int i = begin; i < end; i++)

{

Scores[documents[i]] += output;

}

SendScoresUpdatedMessage();

}

   

对应第一个棵树生成结束后

   

(gdb) p Scores

$7 = std::vector of length 8, capacity 8 = {
0.33333333333333343, 1, 0.33333333333333343, 1, -1, -1, 0.33333333333333343, -1}

   

这个时候再对应计算梯度:

for (int query = 0; query < Dataset.NumDocs; query++)

{

GetGradientInOneQuery(query, scores);

}

   

_gradient[0] =

2 * 1 * 0.1 / (1 + exp(2 * 1 * 0.1 * 0.33333333333333343))

: 0.2/(1.0 + math.exp(2*0.1/3))

Out[2]: 0.09666790068611772

   

这时候 我们需要拟合的梯度变为

(gdb) p _gradient

$9 = std::vector of length 8, capacity 8 = {0.096667900686117719, 0.090033200537504438,

0.096667900686117719, 0.090033200537504438, -0.090033200537504438, -0.090033200537504438,

-0.10333209931388229, -0.090033200537504438}

   

   

第二棵树

p tree

$10 = {<gezi::OnlineRegressionTree> = {NumLeaves = 3,

_gainPValue = std::vector of length 2, capacity 2 = {0.13944890100441296,

0.02357537149418417}, _lteChild = std::vector of length 2, capacity 2 = {-1, -2},

_gtChild = std::vector of length 2, capacity 2 = {1, -3},

_leafValue = std::vector of length 3, capacity 3 = {-0.9721949587186075,

-0.30312179217966367, 0.94840573799486361},

_threshold = std::vector of length 2, capacity 2 = {1, 1},

_splitFeature = std::vector of length 2, capacity 2 = {1, 2},

_splitGain = std::vector of length 2, capacity 2 = {0.024924858166579064,

0.023238200798742146}, _maxOutput = 0.094456333969913306,

_previousLeafValue = std::vector of length 2, capacity 2 = {0, 0.032222633562039242},

_weight = 1,

_featureNames = 0x6e6a5a <gezi::FastRank::GetActiveFeatures(std::vector<bool, std::allocator<bool> >&)+34>}, _parent = std::vector of length 3, capacity 3 = {0, 1, -2}}

   

累加第二棵树后的Scores,如果有第三棵树,那么在这个Scores的基础上再计算梯度值

(gdb) p Scores

$11 = std::vector of length 8, capacity 8 = {1.2817390713281971, 0.69687820782033638,

1.2817390713281971, 1.9484057379948636, -1.3031217921796636, -1.9721949587186076,

-0.63886162538527413, -1.3031217921796636}

GBDT(MART) 迭代决策树入门及源码解析 

GBDT(Gradient Boosting Decision Tree) 又叫 MART(Multiple Additive Regression Tree),是一种迭代的决策树算法,该算法由多棵决策树组成,所有树的结论累加起来做最终答案。它在被提出之初就和SVM一起被认为是泛化能力(generalization)较强的算法。近些年更因为被用于搜索排序的机器学习模型而引起大家关注。


GBDT主要由三个概念组成:Regression Decistion Tree(即DT),Gradient Boosting(即GB),Shrinkage (算法的一个重要演进分枝,目前大部分源码都按该版本实现)。搞定这三个概念后就能明白GBDT是如何工作的,要继续理解它如何用于搜索排序则需要额外理解RankNet概念,之后便功德圆满。下文将逐个碎片介绍,最终把整张图拼出来。

一、 DT:回归树 Regression Decision Tree

提起决策树(DT, Decision Tree) 绝大部分人首先想到的就是C4.5分类决策树。但如果一开始就把GBDT中的树想成分类树,那就是一条歪路走到黑,一路各种坑,最终摔得都要咯血了还是一头雾水说的就是LZ自己啊有木有。咳嗯,所以说千万不要以为GBDT是很多棵分类树。决策树分为两大类,回归树和分类树。前者用于预测实数值,如明天的温度、用户的年龄、网页的相关程度;后者用于分类标签值,如晴天/阴天/雾/雨、用户性别、网页是否是垃圾页面。这里要强调的是,前者的结果加减是有意义的,如10岁+5岁-3岁=12岁,后者则无意义,如男+男+女=到底是男是女? GBDT的核心在于累加所有树的结果作为最终结果,就像前面对年龄的累加(-3是加负3),而分类树的结果显然是没办法累加的,所以GBDT中的树都是回归树,不是分类树,这点对理解GBDT相当重要(尽管GBDT调整后也可用于分类但不代表GBDT的树是分类树)。那么回归树是如何工作的呢?

下面我们以对人的性别判别/年龄预测为例来说明,每个instance都是一个我们已知性别/年龄的人,而feature则包括这个人上网的时长、上网的时段、网购所花的金额等。

作为对比,先说分类树,我们知道C4.5分类树在每次分枝时,是穷举每一个feature的每一个阈值,找到使得按照feature<=阈值,和feature>阈值分成的两个分枝的熵最大的feature和阈值(熵最大的概念可理解成尽可能每个分枝的男女比例都远离1:1),按照该标准分枝得到两个新节点,用同样方法继续分枝直到所有人都被分入性别唯一的叶子节点,或达到预设的终止条件,若最终叶子节点中的性别不唯一,则以多数人的性别作为该叶子节点的性别。

回归树总体流程也是类似,不过在每个节点(不一定是叶子节点)都会得一个预测值,以年龄为例,该预测值等于属于这个节点的所有人年龄的平均值。分枝时穷举每一个feature的每个阈值找最好的分割点,但衡量最好的标准不再是最大熵,而是最小化均方差–即(每个人的年龄-预测年龄)^2 的总和 / N,或者说是每个人的预测误差平方和 除以 N。这很好理解,被预测出错的人数越多,错的越离谱,均方差就越大,通过最小化均方差能够找到最靠谱的分枝依据。分枝直到每个叶子节点上人的年龄都唯一(这太难了)或者达到预设的终止条件(如叶子个数上限),若最终叶子节点上人的年龄不唯一,则以该节点上所有人的平均年龄做为该叶子节点的预测年龄。若还不明白可以Google “Regression Tree”。

二、 GB:梯度迭代 Gradient Boosting

好吧,我起了一个很大的标题,但事实上我并不想多讲Gradient Boosting的原理,因为不明白原理并无碍于理解GBDT中的Gradient Boosting。喜欢打破砂锅问到底的同学可以阅读这篇英文wikihttp://en.wikipedia.org/wiki/Gradient_boosted_trees#Gradient_tree_boosting

Boosting,迭代,即通过迭代多棵树来共同决策。这怎么实现呢?难道是每棵树独立训练一遍,比如A这个人,第一棵树认为是10岁,第二棵树认为是0岁,第三棵树认为是20岁,我们就取平均值10岁做最终结论?–当然不是!且不说这是投票方法并不是GBDT,只要训练集不变,独立训练三次的三棵树必定完全相同,这样做完全没有意义。之前说过,GBDT是把所有树的结论累加起来做最终结论的,所以可以想到每棵树的结论并不是年龄本身,而是年龄的一个累加量。GBDT的核心就在于,每一棵树学的是之前所有树结论和的残差,这个残差就是一个加预测值后能得真实值的累加量。比如A的真实年龄是18岁,但第一棵树的预测年龄是12岁,差了6岁,即残差为6岁。那么在第二棵树里我们把A的年龄设为6岁去学习,如果第二棵树真的能把A分到6岁的叶子节点,那累加两棵树的结论就是A的真实年龄;如果第二棵树的结论是5岁,则A仍然存在1岁的残差,第三棵树里A的年龄就变成1岁,继续学。这就是Gradient Boosting在GBDT中的意义,简单吧。

三、 GBDT工作过程实例。

还是年龄预测,简单起见训练集只有4个人,A,B,C,D,他们的年龄分别是14,16,24,26。其中A、B分别是高一和高三学生;C,D分别是应届毕业生和工作两年的员工。如果是用一棵传统的回归决策树来训练,会得到如下图1所示结果:


现在我们使用GBDT来做这件事,由于数据太少,我们限定叶子节点做多有两个,即每棵树都只有一个分枝,并且限定只学两棵树。我们会得到如下图2所示结果:


在第一棵树分枝和图1一样,由于A,B年龄较为相近,C,D年龄较为相近,他们被分为两拨,每拨用平均年龄作为预测值。此时计算残差(残差的意思就是: A的预测值 + A的残差 = A的实际值),所以A的残差就是16-15=1(注意,A的预测值是指前面所有树累加的和,这里前面只有一棵树所以直接是15,如果还有树则需要都累加起来作为A的预测值)。进而得到A,B,C,D的残差分别为-1,1,-1,1。然后我们拿残差替代A,B,C,D的原值,到第二棵树去学习,如果我们的预测值和它们的残差相等,则只需把第二棵树的结论累加到第一棵树上就能得到真实年龄了。这里的数据显然是我可以做的,第二棵树只有两个值1和-1,直接分成两个节点。此时所有人的残差都是0,即每个人都得到了真实的预测值。

换句话说,现在A,B,C,D的预测值都和真实年龄一致了。Perfect!:

A: 14岁高一学生,购物较少,经常问学长问题;预测年龄A = 15 – 1 = 14

B: 16岁高三学生;购物较少,经常被学弟问问题;预测年龄B = 15 + 1 = 16

C: 24岁应届毕业生;购物较多,经常问师兄问题;预测年龄C = 25 – 1 = 24

D: 26岁工作两年员工;购物较多,经常被师弟问问题;预测年龄D = 25 + 1 = 26

那么哪里体现了Gradient呢?其实回到第一棵树结束时想一想,无论此时的cost function是什么,是均方差还是均差,只要它以误差作为衡量标准,残差向量(-1, 1, -1, 1)都是它的全局最优方向,这就是Gradient。

讲到这里我们已经把GBDT最核心的概念、运算过程讲完了!没错就是这么简单。不过讲到这里很容易发现三个问题:

1)既然图1和图2 最终效果相同,为何还需要GBDT呢?

答案是过拟合。过拟合是指为了让训练集精度更高,学到了很多”仅在训练集上成立的规律“,导致换一个数据集当前规律就不适用了。其实只要允许一棵树的叶子节点足够多,训练集总是能训练到100%准确率的(大不了最后一个叶子上只有一个instance)。在训练精度和实际精度(或测试精度)之间,后者才是我们想要真正得到的。


我们发现图1为了达到100%精度使用了3个feature(上网时长、时段、网购金额),其中分枝“上网时长>1.1h” 很显然已经过拟合了,这个数据集上A,B也许恰好A每天上网1.09h, B上网1.05小时,但用上网时间是不是>1.1小时来判断所有人的年龄很显然是有悖常识的;


相对来说图2的boosting虽然用了两棵树 ,但其实只用了2个feature就搞定了,后一个feature是问答比例,显然图2的依据更靠谱。(当然,这里是LZ故意做的数据,所以才能靠谱得如此狗血。实际中靠谱不靠谱总是相对的) Boosting的最大好处在于,每一步的残差计算其实变相地增大了分错instance的权重,而已经分对的instance则都趋向于0。这样后面的树就能越来越专注那些前面被分错的instance。就像我们做互联网,总是先解决60%用户的需求凑合着,再解决35%用户的需求,最后才关注那5%人的需求,这样就能逐渐把产品做好,因为不同类型用户需求可能完全不同,需要分别独立分析。如果反过来做,或者刚上来就一定要做到尽善尽美,往往最终会竹篮打水一场空。

2)Gradient呢?不是“G”BDT么?

到目前为止,我们的确没有用到求导的Gradient。在当前版本GBDT描述中,的确没有用到Gradient,该版本用残差作为全局最优的绝对方向,并不需要Gradient求解.



3)这不是boosting吧?Adaboost可不是这么定义的。

这是boosting,但不是Adaboost。GBDT不是Adaboost Decistion Tree。就像提到决策树大家会想起C4.5,提到boost多数人也会想到Adaboost。Adaboost是另一种boost方法,它按分类对错,分配不同的weight,计算cost function时使用这些weight,从而让“错分的样本权重越来越大,使它们更被重视”。Bootstrap也有类似思想,它在每一步迭代时不改变模型本身,也不计算残差,而是从N个instance训练集中按一定概率重新抽取N个instance出来(单个instance可以被重复sample),对着这N个新的instance再训练一轮。由于数据集变了迭代模型训练结果也不一样,而一个instance被前面分错的越厉害,它的概率就被设的越高,这样就能同样达到逐步关注被分错的instance,逐步完善的效果。Adaboost的方法被实践证明是一种很好的防止过拟合的方法,但至于为什么则至今没从理论上被证明。GBDT也可以在使用残差的同时引入Bootstrap re-sampling,GBDT多数实现版本中也增加的这个选项,但是否一定使用则有不同看法。re-sampling一个缺点是它的随机性,即同样的数据集合训练两遍结果是不一样的,也就是模型不可稳定复现,这对评估是很大挑战,比如很难说一个模型变好是因为你选用了更好的feature,还是由于这次sample的随机因素。

四、Shrinkage

Shrinkage(缩减)的思想认为,每次走一小步逐渐逼近结果的效果,要比每次迈一大步很快逼近结果的方式更容易避免过拟合。即它不完全信任每一个棵残差树,它认为每棵树只学到了真理的一小部分,累加的时候只累加一小部分,通过多学几棵树弥补不足。用方程来看更清晰,即

没用Shrinkage时:(yi表示第i棵树上y的预测值, y(1~i)表示前i棵树y的综合预测值)

y(i+1) = 残差(y1~yi), 其中: 残差(y1~yi) = y真实值 – y(1 ~ i)

y(1 ~ i) = SUM(y1, …, yi)

Shrinkage不改变第一个方程,只把第二个方程改为:

y(1 ~ i) = y(1 ~ i-1) + step * yi


即Shrinkage仍然以残差作为学习目标,但对于残差学习出来的结果,只累加一小部分(step*残差)逐步逼近目标,step一般都比较小,如0.01~0.001(注意该step非gradient的step),导致各个树的残差是渐变的而不是陡变的。直觉上这也很好理解,不像直接用残差一步修复误差,而是只修复一点点,其实就是把大步切成了很多小步。本质上,Shrinkage为每棵树设置了一个weight,累加时要乘以这个weight,但和Gradient并没有关系。这个weight就是step。就像Adaboost一样,Shrinkage能减少过拟合发生也是经验证明的,目前还没有看到从理论的证明。


五、 GBDT的适用范围

该版本GBDT几乎可用于所有回归问题(线性/非线性),相对logistic regression仅能用于线性回归,GBDT的适用面非常广。亦可用于二分类问题(设定阈值,大于阈值为正例,反之为负例)。

六、 搜索引擎排序应用 RankNet

搜索排序关注各个doc的顺序而不是绝对值,所以需要一个新的cost function,而RankNet基本就是在定义这个cost function,它可以兼容不同的算法(GBDT、神经网络…)。


实际的搜索排序使用的是LambdaMART算法,必须指出的是由于这里要使用排序需要的cost function,LambdaMART迭代用的并不是残差。Lambda在这里充当替代残差的计算方法,它使用了一种类似Gradient*步长模拟残差的方法。


就像所有的机器学习一样,搜索排序的学习也需要训练集,这里一般是用人工标注实现,即对每一个(query,doc) pair给定一个分值(如1,2,3,4),分值越高表示越相关,越应该排到前面。然而这些绝对的分值本身意义不大,例如你很难说1分和2分文档的相关程度差异是1分和3分文档差距的一半。相关度本身就是一个很主观的评判,标注人员无法做到这种定量标注,这种标准也无法制定。但标注人员很容易做到的是”AB都不错,但文档A比文档B更相关,所以A是4分,B是3分“。RankNet就是基于此制定了一个学习误差衡量方法,即cost function。具体而言,RankNet对任意两个文档A,B,通过它们的人工标注分差,用sigmoid函数估计两者顺序和逆序的概率P1。然后同理用机器学习到的分差计算概率P2(sigmoid的好处在于它允许机器学习得到的分值是任意实数值,只要它们的分差和标准分的分差一致,P2就趋近于P1)。这时利用P1和P2求的两者的交叉熵,该交叉熵就是cost function。它越低说明机器学得的当前排序越趋近于标注排序。为了体现NDCG的作用(NDCG是搜索排序业界最常用的评判标准),RankNet还在cost function中乘以了NDCG。


好,现在我们有了cost function,而且它是和各个文档的当前分值yi相关的,那么虽然我们不知道它的全局最优方向,但可以求导求Gradient,Gradient即每个文档得分的一个下降方向组成的N维向量,N为文档个数(应该说是query-doc pair个数)。这里仅仅是把”求残差“的逻辑替换为”求梯度“,可以这样想:梯度方向为每一步最优方向,累加的步数多了,总能走到局部最优点,若该点恰好为全局最优点,那和用残差的效果是一样的。这时套到之前讲的逻辑,GDBT就已经可以上了。那么最终排序怎么产生呢?很简单,每个样本通过Shrinkage累加都会得到一个最终得分,直接按分数从大到小排序就可以了(因为机器学习产生的是实数域的预测分,极少会出现在人工标注中常见的两文档分数相等的情况,几乎不同考虑同分文档的排序方式)


另外,如果feature个数太多,每一棵回归树都要耗费大量时间,这时每个分支时可以随机抽一部分feature来遍历求最优。



GBDT源码剖析


如今,GBDT被广泛运用于互联网行业,他的原理与优点这里就不细说了,网上google一大把。但是,我自认为自己不是一个理论牛人,对GBDT的理论理解之后也做不到从理论举一反三得到更深入的结果。但是学习一个算法,务必要深入细致才能领会到这个算法的精髓。因此,在了解了足够的GBDT理论之后,就需要通过去阅读其源码来深入学习GBDT了。但是,网上有关这类资料甚少,因此,我不得不自己亲自抄刀,索性自己从头学习了一下GBDT源码。幸好,这个算法在机器学习领域中的其它算法还是非常简单的。这里将心得简单分享,欢迎指正。回复本公众号“GBDT”可下载源码。


首先,这里需要介绍一下程序中用到的结构体,具体的每一个结构体的内容这里就不再赘述了,源码里面都有。这里只再细说一下每个结构体的作用,当然一些重要的结构体会详细解释。

struct gbdt_model_t:GBDT模型的结构体,也就是最终我们训练得到的由很多棵决策树组成的模型。


typedef struct {

int* nodestatus; //!< 
int* depth; // 
int* splitid; //!< 
double* splitvalue; //!< 
int* ndstart; //!< 节点对应于 Index 的开始位置
int* ndcount; //!< 节点内元素的个数
double* ndavg; //!< 节点内元素的均值 
//double* vpredict;
int* lson; //!< 左子树
int* rson; //!< 右子树
int nodesize; //!< 树的节点个数
}gbdt_tree_t;

struct gbdt_tree_t:


当然就代表模型中的一棵树的各种信息了。为了后面能理解,这里需要详细解释一下这个结构体。splitid[k]保存该棵树的第k个结点分裂的feature下标,splitvalue[k]保存该棵树第k个结点的分裂值,nodestatus[k]代表该棵树的第k个结点的状态,如果为GBDT_INTERIOR,代表该结点已分裂,如果为GBDT_TOSPLIT,代表该结点需分裂,如果为GBDT_TERMINAL表示该结点不需再分裂,一般是由于该结点的样本数ndcount[k]少于等于一阈值gbdt_min_node_size;depth[ncur+1]代表左子树的深度,depth[ncur+2]表示右子树的深度,其中ncur的增长步长为2,表示每次+2都相关于跳过当前结点的左子树和右子树,到达下一个结点。ndstart[ncur+1]代表划分到左子树开始样本的下标,ndstart[ncur+2]代表划分到右子树开始样本的下标,其中到底这个下标是代表第几个样本是由index的一个结构保存。ndcount[ncur + 1]代表划分到左子树的样本数量,ndcount[ncur + 2]代表划分到右子树的样本数量。ndavg[ncur+1]代表左子树样本的均值,同理是右子树样本的均值。nodestatus[ncur+1] = GBDT_TOSPLIT表示左子树可分裂。lson[k]=ncur+1表示第k个结点的左子树,同理表示第k个结点的右子树。


gbdt_info_t保存模型配置参数。

typedef struct 

int* fea_pool; //!< 随机 feature 候选池
double* fvalue_list; //!< 以feature i 为拉链的特征值 x_i
double* fv; //!< 特征值排序用的buffer版本
double* y_list; //!< 回归的y值集合
int* order_i; //!< 排序的标号
} bufset; //!< 训练数据池

bufset代表训练数据池,它保存了训练当前一棵树所用到的一些数据。fea_pool保存了训练数据的特征的下标,循环rand_fea_num(feature随机采样量)次,随机地从fea_pool中选取特征来计算分裂的损失函数(先过的feature不会再选)。fvalue_list保存在当前选择特征fid时,所有采样的样本特征fid对应的值。fv与favlue_list一样。y_list表示采样样本的y值。order_i保存左子树与右子树结点下标。

nodeinfo代表节点的信息。

typedef struct 

int bestid; //!< 分裂使用的Feature ID
double bestsplit; //!< 分裂边界的x值
int pivot; //!< 分裂边界的数据标号 
} splitinfo; //!< 分裂的信息

splitinfo代表分裂的信息。pivot代表分裂点在order_i中的下标。bestsplit表示分裂值。bestid表示分裂的feature。

好了,解释完关键的一些结构体,下面要看懂整个gbdt的流程就非常简单了。这里我就简单的从头至尾叙述一下整个训练的流程。


首先申请分配模型空间gbdt_model,并且计算所有样本在每一维特征上的平均值。假如我们需要训练infbox.tree_num棵树,每一棵的训练流程为:从x_fea_value中采样gbdt_inf.sample_num个样本,index[i]记录了第i个结点所对应的样本集合x_fea_value中的下标,其始终保存了训练本棵树的所有采样样本对应样本空间的下标值,同时,结点的顺序是按该棵树所有结点按广度优先遍历算法遍历的结果的。即当前树gbdt_single_tree只有一个根结点0,其中gbdt_single_tree->nodestatus为GBDT_TOSPLIT,ndstart[0]=0,ndcount[0]=sample_num,ndavg为所有采样样本的y的梯度值均值。下面就是对这个结点进行分裂的过程:首先nodeinfo ninf这个结构体保存了当前分裂结点的一些信息,比如结点中样本开始的下标(指相对于index的下标值,index指向的值才是样本空间中该样本的下标),样本结束下标(同上),样本结点数,样本结点的y的梯度之和等。循环rand_fea_num次,随机采样feature,来计算在该feature分裂的信息增益,计算方式为(左子树样子目标值和的平方均值+右子树目标值和的平方均值-父结点所有样本和的平方均值)。选过的feature就不会再选中来计算信息增益了。利用data_set来保存当前分裂过程所用到的一些信息,包括候选feature池,选中feature对应的采样样本的特征值及其y值。data_set->order_i保存了左右子树对应结点在样本集合中的下标。计算每个feature的信息增益,并取最大的,保存分点信息到spinf中,包括最优分裂值,最优分裂feature。然后,将该结点小于分裂值的结点样本下标与大于分裂值的结点样本下标都保存在data_set->order_i中,nl记录了order_i中右子树开始的位置。更新index数组,将order_i中copy到index中。将nl更新到spinf中。注意index数组从左至右保存了最终分裂的左子树与右子树样本对应在样本空间的下标。


至此,我们找到了这个结点的最优分裂点。gbdt_single_tree->ndstart[1]保存了左孩子的开始下标(指相对于index的下标值,index指向的值才是样本下标),gbdt_single_tree->ndstart[2]保存了右孩子的开始下标,即nl的值。同理,ndcount,depth等也是对就保存了左右孩子信息。gbdt_single_tree->lson[0]=1,gbdt_single_tree->lson[0]=2即表示当前结点0的左子树是1,右子树是2。当前结点分裂完了之后,下一次就同理广度优先算法,对该结点的孩子继续上述步骤。


该棵树分裂完成之后,对每一个样本,都用目前模型(加上分裂完成的这棵树)计算预测值,并且更新每一个样本的残差y_gradient。计算过程:选取当前结点的分裂feature以及分裂值,小于则走左子树,大于则走右子树,直到叶子结点。预测值为shrink*该叶子结点的样本目标值的均值。


训练第二棵树同理,只是训练的样本的目标值变成了前面模型预测结果的残差了。这点就体现在梯度下降的寻优过程。


好了,这里只是简单的对gbdt代码做了说明,当然如果没有看过本文引用的源码,是不怎么能看懂的,如果结合源码来看,就很容易看懂了。总之,个人感觉,只有结合原码来学习gbdt,才真正能体会到事个模型的学习以及树的生成过程。

GBDT理解二三事

一、要理解GBDT当然要从GB(Gradient Boosting)和DT(Decision Tree)两个角度来理解了;

二、GB其实是一种理念,他并不是这一个具体的算法,意思是说沿着梯度方向,构造一系列的弱分类器函数,并以一定权重组合起来,形成最终决策的强分类器;注意,这里的梯度下降法是在函数空间中通过梯度下降法寻找使得LOSS最小的一个函数,即L(y,f)对f求层,区别于传统的梯度下降法选择一个方向(对x求导);那么问题就来了,对函数求导?这也太难了吧。所以就有了一个近似的方法,根据经验风险最小化原则,我们认为在训练集上使得LOSS最小的函数,往往在测试集上表现会好,即在训练集上寻优;因此,把求导的函数理解成在训练集上该函数对应的离散的函数值,对函数求导就变成了对样本的函数值向量求导;因此就可以得到一个梯度向量,表示寻找到的最优函数, 这个函数就是一个新的弱分类器;

三、通过回归树来拟合这个梯度向量,就得到了DT,而每棵树就对应上面的函数,其预测值就是函数值;

四、当我们选择平方差损失函数时,函数向量就表示成前一棵回归树在样本空间上的预测值,则对函数向量求梯度就等于目标值减去预测值,即我们所说的残差向量;因此,下一棵回归树就是在拟合这个残差向量;

五、回归树拟合可以通过平均最小均方差来寻找分裂点,生成一个树;当然这棵树不可能完全拟合得好,因此,又会通过对损失函数求梯度,得到新的残差向量;

六、对初始分类器(函数)的选择就可以直接用0,通过平方差LOSS函数求得的残差当然就是样本本身了;也可以选择样本的均值;

七、一棵树的分裂过程只需要找到找到每个结点的分裂的特征id与特征值,而寻找的方法可以是平均最小均方差,也可以是使得(左子树样本目标值和的平方均值+右子树样本目标值和的平方均值-父结点所有样本目标值和的平方均值)最大的那个分裂点与分裂特征值等等方法;从而将样本分到左右子树中,继续上面过程;

八、用残差更新每个样本的目标值:叶子节点的均值作为落到该叶子节点的样本的预测值,使用目标值减去预测值,得到该样本的残差,作为下一棵树的训练目标;

九、对于使用logistic作为损失函数的多分类问题,下面单独进行推导说明:

1、多分类问题与回归问题不同,每棵树的样本的目标就不是一个数值了,而是每个样本在每个分类下面都有一个估值Fk(x);

2、同逻辑回归一样,假如有K类,每一个样本的估计值为F1(x)…Fk(x),对其作logistic变化之后得到属于每一类的概率是P1(x)…pk(x),则损失函数可以定义为负的log似然:GBDT算法整理_算法简单题目

可以看出对多分类问题,新的一棵树拟合的目标仍是残差向量;

3、训练过程如下:

GBDT算法整理_算法简单题目

对第一棵树,可以初始化每个样本在每个分类上的估计值Fk(x)都为0;计算logistic变换pk(x),计算残差向量,作为当前树的回归的目标,回归树的分裂过程仍可采用【左子树样本目标值(残差)和的平方均值+右子树样本目标值(残差)和的平方均值-父结点所有样本目标值(残差)和的平方均值】最大的那个分裂点与分裂特征值等方法;当回归树的叶子节点数目达到要求示,则该树建立完成;对每个叶子节点,利用落到该叶子节点的所有样本的残差向量,计算增益rjkm;更新每一个样本的估计值Fk(x);因此,又可以对估计进行logistic变化,利用样本的目标值计算残差向量,训练第二棵树了;

4、注意样本的估计值Fk(x)是前面所有树的估值之和,因此,计算残差时,用样本的目标值减去Fk(x)就可以得到残差了;

十、GBDT并行化:

1、按行并行化,将样本按行分成N份,分别在N个节点上做计算;

2、并行建立一棵的过程:

1>在0号节点上对特征随机采样,生成建立一棵树需要用到的特征,并分发到N个节点上;

2>在0号结点上维护每一维采样特征所有可能的特征值;

3>将每一维特征的每一个可能的特征值分发到N个节点上;

4>每一个节点并行计算该节点上所有样本与分发得到的特征值的比较结果,分割成左右子树,并计算增益;

5>归并所有节点的增益,在0号结点得到每一个特征在每一个特征值的增益(f,v,incr);

6>在0号结点上找出最大的(f,v,incr),并作为本次的最佳裂点,分发到N个节点上;

7>N个节点将样本分割成左右子树;

8>对左右子树继续上面过程,直到叶子节点数目满足要求;

3、并行建立第二棵树;

因此,GBDT并行化包括了样本并行化与特征分裂点计算的并行化;其中最耗时的仍然是需要遍历特征的所有可能的特征值,并计算增益寻找最优分裂点的过程;可以采用对特征值直方图采样,不用遍历所有特征值来优化。

这里参考了http://www.cnblogs.com/leftnoteasy/archive/2011/03/07/random-forest-and-gbdt.html对分类问题的解释,写得非常好,treelink里面的代码基本就是按照这个流程实现的。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

发布者:全栈程序员-用户IM,转载请注明出处:https://javaforall.cn/182715.html原文链接:https://javaforall.cn

【正版授权,激活自己账号】: Jetbrains全家桶Ide使用,1年售后保障,每天仅需1毛

【官方授权 正版激活】: 官方授权 正版激活 支持Jetbrains家族下所有IDE 使用个人JB账号...

(0)


相关推荐

  • usg6000_华为防火墙usg6000web登陆

    usg6000_华为防火墙usg6000web登陆USG6000密码恢复1.如果某个管理员遗忘了密码,可以使用其它高权限的管理员账号登录设备,然后修改密码。例如,管理员admin1的密码遗忘,此时可以由管理员admin登录设备,然后修改admin1

  • linux下增加路由_centos开启路由转发

    linux下增加路由_centos开启路由转发一、环境介绍1.linux版本:CentOS6.8,CentOS7.2两台主机分别加载两块网卡,分别作为路由器的两个端口2.实验在vmware虚拟机中完成3.另有两台CentOS6.8和CentOS7.2作为两个网段的主机4.实验图示:二、路由1(左)和路由2(右)的设置1.路由1配置信息eth0网卡:DEVICE=eth0IPADDR=10.0.0.1PREFIX=9eth1网卡:DEVICE=…

  • ribbon的负载均衡策略_f5负载均衡配置

    ribbon的负载均衡策略_f5负载均衡配置在这里吐槽一句:网上很多文章真是神坑,你不看还好,看了只会问题越来越多,就连之前的问题都没有解决!!!不多说了,Ribbon作为后端负载均衡器,比Nginx更注重的是请求分发而不是承担并发,可以直接感知后台动态变化来指定分发策略。它一共提供了7种负载均衡策略:策略名策略声明策略描述实现说明

    2022年10月13日
  • phpstorm激活码失效破解方法[通俗易懂]

    phpstorm激活码失效破解方法,https://javaforall.cn/100143.html。详细ieda激活码不妨到全栈程序员必看教程网一起来了解一下吧!

  • java 应用监控_java监控服务器运行状态

    java 应用监控_java监控服务器运行状态每天记录学习,每天会有好心情。*^_^*每天都要认真学习,才能更加进步。└(^o^)┘在工作和学习的过程中要善于思考,勤于学习。并做出适当的记录,才能最快速的学习并掌握一项知识。希望在这个平台和大家一起共同成长,和大家分享一个SSM(MYECLIPSE)项目,该项目名称为基于web的java舆情监测系统。采用当前非常流行的B/S体系结构,以JAVA作为开发技术,主要依赖SSM技术框架,mysql数…

  • 网络文件服务器_文件存储服务器

    网络文件服务器_文件存储服务器网络文件服务器:通过网络共享文件或文件夹,实现数据共享NAS(networkappendstorage)共享的是文件夹1.FTP文件服务器2.samba不同系统间的文件夹或设备共享不用系统

发表回复

您的电子邮箱地址不会被公开。

关注全栈程序员社区公众号