机器学习源码分析-逻辑回归

发布于:2022-11-28 ⋅ 阅读:(459) ⋅ 点赞:(0)

现在网上已经有很多高质量的关于机器学习算法理论的文章,但只有理论部分很难让人理解透彻,软件人对代码更熟悉,接下来我就从源码的角度去介绍各种机器学习算法,理论部分这里不做重点介绍,今天来分析一下逻辑回归。
逻辑回归是用于解决二分类问题的机器学习方法,其数学表达式如下:
图片

图片

其中x是输入,可以是标量也可能是多个维度的向量,w和b是我们要求的参数,其中g称为激活函数,能将输入映射到0-1范围内,表示样本属于正例概率。
图片

图片

如上图,对于输入特征是二维的,逻辑回归就是找到正负样本的分解线,特征维度超过2维时,就是找到一个分界面,同理,一维数据,就是找一个分界点。
源码分析,那首先就要找到实现算法的开源库,一提到机器学习库,应用最为广泛的莫过于scikit-learn,其实OpenCV中的ml模块也提供了对于机器学习算法的实现,所有机器学习库都是依据相同的数学原理来实现的,只是使用的语言不同而已,为了调试方便,我最终决定使用OpenCV。
接下来我们看OpenCV中一个逻辑回归的例子:手写数字分类
训练数据:
训练数据包含20幅只包含0或者1手写数字的图像,图像大小2828,展开成向量的形式:1784,逻辑回归分类器就是在784维度空间找一个分界面将数字0和数字1尽量分开,而这个分界面是由w和b来确定,输出大于一定阈值(默认0.5)代表该图像上的数字是1,否则代表该图像上的数字是0。
该训练数据保存成了xml格式:
图像:
在这里插入图片描述

标签:
图片

通过Opencv FileStorage类将图像和标签读取到Mat中。

Mat data, labels;
FileStorage f;
if(f.open(filename, FileStorage::READ))
{
f["datamat"] >> data;
f["labelsmat"] >> labels;
f.release();
}

读取完成后data是20784维,labels是201维。
将训练数据20幅小图像打印到一个大图像上:
图片

设置参数:

Ptr<LogisticRegression> lr1 = LogisticRegression::create();
lr1->setLearningRate(0.001);学习率
lr1->setIterations(10);训练迭代轮数
lr1->setRegularization(LogisticRegression::REG_L2);L2正则化
lr1->setTrainMethod(LogisticRegression::BATCH);批梯度下降

训练:
lr1->train(data_train, ROW_SAMPLE, labels_train);
接下来我们看看训练过程中都干了什么:
根据逻辑回归的数学表达式,前向计算第一步就是要计算上面的公式,输入x是784维向量,所以w也是784维, b是一维标量,通常为了计算效率,可以将上面的公式转换成矩阵的运算:
图片

上面我们设置的训练方法为BATCH方式,也就是说每一次梯度更新要用到所有的训练数据,那所有训练数据作为输入转换成矩阵的运算表达式为:
图片

其中X矩阵水平方向为特征维度,垂直方向为训练数据个数,矩阵乘法的输出120,代表20个训练数据wx+b的结果,Opencv中代码如下:
将输入矩阵加一列,且值都是1,也就是构造上图右边的20
785维的X矩阵。

Mat data_t;
hconcat( cv::Mat::ones( _data_i.rows, 1, CV_32F ), _data_i, data_t );
接下来构造上图左边w和b组成的矩阵,1*785维:
Mat thetas;
Mat init_theta = Mat::zeros(data_t.cols, 1, CV_32F);
接下来根据标签计算出类别数,如果是多分类问题,那么训练多个二分类器:
set_label_map(_labels_i);
Matlabels_l = remap_labels(_labels_i, this->forward_mapper);
int num_classes = (int) this->forward_mapper.size();
//2分类
if(num_classes == 2)
{
labels_l.convertTo(labels, CV_32F);
if(this->params.train_method == LogisticRegression::BATCH)
new_theta = batch_gradient_descent(data_t, labels, init_theta);
else
new_theta = mini_batch_gradient_descent(data_t, labels, init_theta);
thetas = new_theta.t();
}
//多分类
else
{
/* take each class and rename classes you will get a theta per class
as in multi class class scenario, we will have n thetas for n classes */
thetas.create(num_classes, data_t.cols, CV_32F);
Matlabels_binary;
int ii = 0;
for(map<int,int>::iterator it = this->forward_mapper.begin(); it != this->forward_mapper.end();++it)
{
// one-vs-rest (OvR) scheme
labels_binary = (labels_l == it->second)/255;
labels_binary.convertTo(labels, CV_32F);
if(this->params.train_method == LogisticRegression::BATCH)
new_theta =batch_gradient_descent(data_t, labels, init_theta);
      else
new_theta =mini_batch_gradient_descent(data_t, labels, init_theta);
hconcat(new_theta.t(), thetas.row(ii));
ii += 1;
}
}
多分类是以二分类为基础的,所以我们以二分类为例来看看下面代码都干了什么:
if(num_classes == 2)
{
labels_l.convertTo(labels, CV_32F);
//BATCH代表每次迭代全部训练数据都参与计算梯度
if(this->params.train_method == LogisticRegression::BATCH)
new_theta = batch_gradient_descent(data_t, labels, init_theta);
else
   //mini_batch代表每次迭代只有batch_size个训练数据参与计算梯度
new_theta = mini_batch_gradient_descent(data_t, labels, init_theta);
thetas = new_theta.t();
}

Mini_batch是每次更新梯度时需要从训练数据集中选出batch_size个数据,batch是用全部训练数据,除此之外没有本质区别,所以我们以batch为例看看batch_gradient_descent都干了什么。
训练的过程就是优化w和b的过程,既然是优化,那就需要有衡量w和b好坏的评价函数,在机器学习中我们称之为损失函数,或者目标函数,代价函数,三者的区别在这里不展开讲了,根据极大似然估计可得逻辑回归的损失函数:
在这里插入图片描述

Yi是第i个训练数据的标签值,p(xi)是前面讲的激活函数g(z)的输出, batch_gradient_descent函数中compute_gradient函数作用是计算梯度,参数更新,进而求得最优的w和b:

for(int i = 0;i<this->params.num_iters;i++)
{
//计算损失,训练过程中输出当前的损失值来评价当前网络的效果
compute_cost(_data, _labels, theta_p);
//计算梯度
compute_gradient( _data, _labels, theta_p, llambda, gradient );
//根据计算出来的梯度,进行参数更新
theta_p = theta_p - ( static_cast<double>(this->params.alpha)/m)*gradient;
}
再梯度更新前还调用了compute_cost函数,这个函数对于更新参数并没有起到直接作用,它主要用来评价当前网络的性能,下面看一下这个函数计算过程:
double LogisticRegressionImpl::compute_cost(const Mat& _data, const Mat& _labels, const Mat& _init_theta)
{
CV_TRACE_FUNCTION();
float llambda = 0;    
int m;
int n;
double cost = 0;
doublerparameter = 0;
Mattheta_b;
Mattheta_c;
Mat d_a;
Mat d_b;
m = _data.rows;
n = _data.cols;
theta_b = _init_theta(Range(1, n), Range::all());
if(params.norm != REG_DISABLE)
{
llambda = 1;
}
//通过在损失函数中加入L1正则化项,来防止过拟合
if(this->params.norm == LogisticRegression::REG_L1)
{
rparameter = (llambda/(2*m)) * sum(theta_b)[0];
}
else
{
//通过在损失函数中加入L2正则化项,来防止过拟合
multiply(theta_b, theta_b, theta_c, 1);
rparameter = (llambda/(2*m)) * sum(theta_c)[0];
}
//计算损失函数J(w)中的第一项:yilnP(xi)项
d_a = calc_sigmoid(_data * _init_theta);
log(d_a, d_a);
multiply(d_a, _labels, d_a);
//计算损失函数中的第二项 (1-yi)ln(1-p(xi)),其中ln(1-p(xi))可以转换为log(p(-xi))
d_b = calc_sigmoid(- _data * _init_theta);
log(d_b, d_b);
multiply(d_b, 1-_labels, d_b);
//求和,除以训练数据个数m求平均损失
cost = (-1.0/m) * (sum(d_a)[0] + sum(d_b)[0]);
//损失=误差+正则化项
cost = cost + rparameter;
return cost;
}

接下来就是求能使得J(w)最小的w和b的值,有两种方法:梯度下降法和牛顿法,我们是使用梯度下降法进行分析,下面公式是梯度下降法更新参数的数学原理:
图片

我们看看这部分OpenCV是如何实现的:

void LogisticRegressionImpl::compute_gradient(const Mat& _data, const Mat& _labels, const Mat &_theta, const double _lambda, Mat & _gradient )
{
CV_TRACE_FUNCTION();
const int m = _data.rows;
Mat pcal_a, pcal_b, pcal_ab;
const Mat z = _data * _theta;
CV_Assert( _gradient.rows == _theta.rows && _gradient.cols == _theta.cols );
//计算偏导数中(yi-P(xi))xi项
pcal_a = calc_sigmoid(z) - _labels;
pcal_b = _data(Range::all(), Range(0,1));
multiply(pcal_a, pcal_b, pcal_ab, 1);
//计算梯度
_gradient.row(0) = ((float)1/m) * sum(pcal_ab)[0];
//根据偏导数的原理可知,上面只是求W向量中的一个w分量,一共784个w和一个b,为了提升效率,并行计算
LogisticRegressionImpl_ComputeDradient_Impl invoker(_data, _theta, pcal_a, _lambda, _gradient);
cv::parallel_for_(cv::Range(1, _gradient.rows), invoker);
}

计算完梯度后,下面进行梯度更新,this->params.alpha是我们设置的学习率。
theta_p = theta_p - ( static_cast(this->params.alpha)/m)*gradient;
上面就是Opencv中关于逻辑回归的训练部分,除了训练,还包括推理过程predict,这里不展开讲了。

本文含有隐藏内容,请 开通VIP 后查看

网站公告

今日签到

点亮在社区的每一天
去签到