09. 机器学习 - 逻辑回归

[TOC]

茶桁的 AI 秘籍 09

Hi,你好。我是茶桁。

上一节课,在结尾的时候咱们预约了这节课一开始对上一节课的内容进行一个回顾,并且预告了这节课内容主要是「逻辑回归」,那我们现在就开始吧。

线性回归回顾

在上一节课中,我们定义了 model,loss 函数以及求导函数。最后我们用 for 循环来完成了求导过程。本节课一开始,咱们先来对上一节课的代码做一次优化,优化后的代码也会上传到课程代码仓库内。

此部分代码依然在 08.ipynb 中。

首先,我们将之前的 model 重新更名为linear,以便知道我们这个函数是要做什么的。接着,我们把 for 循环内对 w 和 b 的偏导封装为一个函数,便于我们之后调用:

1
2
3
4
5
def optimize(w, b, x, y, yhat, pw, pb, learning_rate):
w = w + -1 * pw(x, y, yhat) * learning_rate
b = b + -1 * pb(x, y, yhat) * learning_rate

return w, b

然后我们将整个 for 循环封装一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def train(model_to_be_train, target, loss, pw, pb):
w = np.random.random_sample(size = (1, 2))# w normal
b = np.random.random()
learning_rate = 1e-5
epoch = 200

for i in range(epoch):
for batch in range(len(rm)):
# batch trainning
index = random.choice(range(len(rm)))
rm_x, lstat_x = rm[index], lstat[index]
x = np.array([rm_x, lstat_x])
y = target[index]

yhat = model_to_be_train(x, w, b)
loss_v = loss(yhat, y)

batch_loss.append(loss_v)

w, b = optimize(w, b, x, y, yhat, pw, pb, learning_rate)

if batch % 100 == 0:
print('Epoch: {} Batch: {}, loss: {}'.format(i, batch, loss_v))


return model_to_be_train, w, b

在最后呢,我们可以在调用函数之前,导入所需第三方库,然后将之前的数据处理在执行函数前获取并处理一遍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import matplotlib.pyplot as plt
import random
import pandas as pd
import numpy as np
from sklearn.datasets import fetch_openml
dataset = fetch_openml(name='boston', version=1, as_frame=True, return_X_y=False, parser='pandas')

data = dataset['data']
target = dataset['target']
columns = dataset['feature_names']
dataframe = pd.DataFrame(data)
dataframe['price'] = target

rm = dataframe['RM']
lstat = dataframe['LSTAT']

model, w, b = train(linear, target, loss, partial_w, partial_b)

为什么我们每次都要随机取一个数字呢?

你也可以把所有的 x 全部输入进去,所有的 y 全部输入进去。但是实际上在整个场景下,比方说我们有很多的训练数据,每个训练数据都有一个 x,有一个 y。

loss 函数本来写的是 i 属于所有的 N,y_i 减去 yhat_i 的平方。但是现在如果把所有的 x 和所有的 y 在真实的场景下输入进去的话,假设现在 x 有 100 万个或者 200 万个,输入进去之后整个求解过程可能 loss 函数这个程序加载都加载不出来,会非常非常慢。

所以在实际的工作中,假如说 i 属于 D:\(\sum_{i \in D}\),D 就是 distribution 的意思,就是随机取一些数据,然后再把随机取的一些数据求解。这样的话每一次就可以保证它可以运行。

但是这样的一个区别是什么?

每次把所有的 x 和 y 都输入进去,这种梯度下降方式中 loss 下降是一个很顺滑的样子,这个叫做 BGD。

还有一种情况就是咱们课上用的这种剃度下降方式,每次随机取了一个随机值,叫随机剃度下降,就随机取一个数字做梯度下降,简称 SGD。这个 loss 下降就会上下波动很厉害。如我们上面展示的图。

再下来呢还有一种,它是取这两者之间,每次不是取一个,是取了多个。我们把这个叫做 MBGD。

这是三种梯度下降方式。在实际的工作中 SGD 用的最多,因为可以快速的进行梯度下降学习。

我们可以将代码修改一下:

1
2
3
4
5
6
7
8
9
batch_size = 10
for i in range(epoch):
...
for batch in range(len(rm) // batch_size):
indices = np.random.choice(range(len(rm)))
rm_x, lstat_x = rm[indices], lstat[indices]
x = np.array([rm_x, lstat_x])
y = target[indices]
...

关于这一部分内容,这里仅仅是提一下,在后面的课程中,我们还会更详细的来讲解。

我们在循环中,将原来的次数 50 替换成了epoch, epoch在机械学习里边指的是运行了整整一遍。

在第二个循环内,里面是 rm 个东西,每次都是随机取,我们随机取了多少次呢?取了 rm 个。也就是说平均每个样本会被取样一次。

这就是数据量大的好处,当数据量很大的时候,有个别的点没有取到或者说有个别的点取了多次其实对最终的效果是不影响的。

也就是说因为数量很大,所以一两次的变化,一两个数值取的少了或者取的多了,其实不是非常影响。

我们把每次epoch的 batch 打出来,我们来看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def train(...):
...
losses = []
for i in range(epoch):
batch_loss = []
for batch in range(len(rm)):
...
batch_loss.append(loss_v)
...

losses.append(np.mean(batch_loss))

return model_to_be_train, w, b, losses

model, w, b, losses = train(linear, target, loss, partial_w, partial_b)
plt.plot(losses)
plt.show()

image-20231012120642561

那如果是上面我们更改的代码,使用了batch_size控制之后,图形就完全不一样。

image-20231012141443971

可以看到,这个 loss 下降还是挺明显的。

这个时候,我们假设知道一组的 rm 等于 19,lstat 等于 7。而此时其实已经有了 w 和 b,求到最终的 w 和 b,就能够有一个预测值了。

1
2
3
4
5
6
7
predicate = model(np.array([19, 7]), w, b)
print(predicate)

---
Epoch: 0 Batch: 0, loss: 46.17245060319155
...
Epoch: 199 Batch: 0, loss: 0.2053457975383563

我们在这个实例中,只用了两个最显著的特征,如果把 x 的维度变多一些,其实就会更加接近了。

好,这个线性回归的过程,其中包括线性函数的定义,为什么要用线性函数,loss 函数的意义,梯度下降的意义就都讲完了。

这个内容是我从斯坦福大学的参考书上弄过来的。

除了定义一个这样一个平方值的 loss,可以定一个绝对值 loss,都是一样的,都可以实现找到最优值。

只不过这个二次方的这个 loss 对于结果,它的惩罚会更大一些。

经过这一段代码的洗礼,对于之前的那个数学式子应该能看的更明白一些了。

逻辑回归

我们讲完了线性回归,下面再跟大家来讲一下逻辑回归。

逻辑回归是什么?假如还是如上那个问题,前面代码都没变。当然,库需要再导入一遍:

1
2
3
4
5
6
import random
import pandas as pd
import numpy as np
from sklearn.datasets import fetch_openml

dataset = fetch_openml(name='boston', version=1, as_frame=True, return_X_y=False, parser='pandas')

现在咱们要变一个问题场景,我们先打印一下np.percentile(),这是要求百分位,比方说我们填入一下target,其实我们数据预处理的时候知道,就是dataframe['price']:

1
print(np.percentile(target, 66))

我们写入一个target, 其实就是price,然后我们在后面写了一个 66,也就是说,我们将这里所有的 price,也就是房价,做了一次排序,然后,我取从 0 到 100 中的第 66% 个位置的数值,就是大于 2/3 的房价。同样的,如果我这里填了一个 50,那么就是取最中间的那个值。

输出的结果为 23.53, 是 23 万美金。还是比较便宜,23 万美金折合 100 多万。

好,现在我们来做一个判断:

1
2
3
4
greater_then_most = np.percentile(target, 66)
dataframe['expensive'] = dataframe['price'].apply(lambda p: int(p > greater_then_most))

print(dataframe[:20])

image-20231012161144568

我们定义了一个expensive,在 dataframe 中加入了这个特征。这个特征在房价大于 2/3 房价的时候 int 为 1,否则为 0。

做了这样一件事之后,就是问这个房子是不是贵房子,如果是 1,就是贵房子,0 就不是贵房子。根据我们添加的特征来进行判断。

那接着呢,问题发生了改变。我们不知道这个房子的price,现在需要进行预测这个房子是不是属于一个高档小区。在预测中,假如是 1,就表示是高档小区,0 就表示不是高档小区。现在要根据它的一些特征来猜测它是不是高档小区。

我们刚刚其实已经知道,所谓的高档小区其实是和价格有一定关系的。

假如说现在咱们有一个问题要求解,现在要有一个模型能够预测它到底是 1 还是 0,或者我们要预测是开心还是难过,咱们现在只要做一件事情就可以,就是把我们期望目标标成 1,把另外那个相对的目标标成 0。

如果我们能够拟合一个函数,这个函数的输出要么是 1,要么是 0,我们让这个模型的值越接近于实际的值就可以了。

image-20231012163700062

比方说刚刚回顾完的线性回归,给定的 (x, y) 里边,y 这个值它是一个实数。如果现在变成了 0、1。比方说 1 就是 happy,0 就是 sad。或者还是用咱们之前定义的:1 就是 expensive,0 就是 not expensive。

把 1 和 0 认为是概率,如果是概率的话,1 就是 100% 是,0 就 100% 不是。

那么咱们之前的 model 输出的是实数R, 这次需要的 model 就是输出的是 0~1。这个模型的任务就变成了如果 x 给定的是 1,那么 model 输出最后要尽可能的接近 1。

怎么样才能让我们的 model 输出是 0 到 1 之间呢?有一个方法,一个函数叫做 logistic 函数,logistic function: \[ \begin{align*} J(\theta)=-\sum_i(y^{(i)}log(h_{\theta}(x^{(i)}))+(1-y^{(i)})log(1-h_{\theta}(x^{(i)}))) \end{align*} \]

这个函数其实在复杂系统里面是一个很重要的函数,人们其实是期望获得一种导数,数学家们研究的是这个:y’ = y(1-y), 就是 y 的导数等于 y 乘以 1-y。研究完了之后发现有一种函数就满足这个特征: \[ \begin{align*} f(x) = \frac{1}{1+e^{-x}} \end{align*} \]

这个函数的值画出来,就是这个样子:

image-20231012173054474

值全部是从 0 到 1 之间,中间与 y 轴交点为 0.5。

给大家讲一下这个原理,逻辑函数原本是想研究 y’=y(1-y),求解出来有这样一个函数满足这样的特征:f(x) = 1/1+e^{-x}。那我们这里需要注意一下,这个特征以后会有大用。把它的图形画出来呢,就是如上图这样的一种函数,这个函数值就是在 0~1 之间。

为什么我们要用逻辑函数来做概率预测呢?首先第一个原因就是因为它的值本身输出就是 0~1 之间,天然的适合做概率这块,第二,他还处处可导,逻辑函数它是处处可导的。

所以我们就可以用这个函数来进行分类,可以把原来的模型 f(x)=wx+b, 这整个模型写成: \[ \begin{align*} f(x) = \frac{1}{1+e^{-(wx+b)}} \end{align*} \]

原来的 f(x) 拟合的是等于 wx+b, 现在把这个 f(x) 变成如上式的样子。这个输出的就变成 0-1 了,就能够让它的值在 0 到 1 之间变换。

这就是为什么我们把这种方法叫做逻辑回归的原因。就是它是在回归曲线上加了一个逻辑函数,所以我们称其为逻辑回归。

加上逻辑函数虽然输出的值是 0~1 之间,但其实是在做分类。越接近于 1 就越近于一类,越近于 0 就越近于另一类。逻辑回归本质上就是在做分类。

接着,咱们来上代码给大家详细的讲解一遍。

其实我们整个代码和之前实现的线性回归非常的像,唯一的区别是我们需要一个叫做 sigmoid 的函数,也就是逻辑函数。

1
2
def sigmoid(x):
return 1/(1+np.exp(-x))

我们来把这个函数画出来看看是什么样的:

1
2
plt.plot(sigmoid(np.linspace(-10, 10)))
plt.show()

image-20231012184251402

其实,机器学习是个很简单的问题。机器学习其实是计算机里面最简单的几个部分,哪些比这更复杂呢?第一个、编译器原理,还有程序设计语言与自动机,还有计算机图形学,复杂系统,还有计算复杂性,操作系统。其实这些都比 深度学习复杂的多。

为什么我们现在深度学习用的多,就是因为深度学习简单。所以我说这些题外话是想告诉大家,在学习这个的时候不要有什么顾虑和负担,放松一点,放开膀子撸起袖子干就完了。

好,我们现在把 model 写出来:

1
2
def model(x, w, b):
return sigmoid(np.dot(x, w.T) + b)

那么来看,我们现在如果要预测,给一个 rm 和 lstat 输入进去,一个 RM 和 LSTAT 的值输入进去。

我们先来看一下真实的值是怎样的:

1
2
3
4
5
6
7
8
9
10
11
12
rm = dataframe['RM']
lstat = dataframe['LSTAT']
target = dataframe['expensive']
epoch = 200
for i in range(epoch):
for b in range(len(rm)):
index = random.choice(range(len(rm)))

x = np.array([rm[index], lstat[index]])
y = target[index]

print(x, y)

这个呢就是我们在训练时候每个给的数据,每次给他给一组数据,然后给它的这个值到底是 0 还是 1。我们期望的是求解一组 (w,b),能够让它输入 x 的时候也能得到 0 或者 1。就它真实的时候是 0,期望的是这一组输入进去之后,根据 (w,b) 运行完了之后也是 0。这个就是我们的目标。

假如已经获得线性回归了,然后要通过线性回归加一些东西想实现 0 和 1 的分类。之前我们在线性回归那里是不是先定义了一个 loss 函数?把 loss 函数定义清楚之后再对 loss 求偏导就可以了。那这里也是一样,需要定义 loss,只要把 loss 定义出来之后给 loss 求偏导就可以了,和之前一模一样。

现在的问题就转变成,咱们怎么求 loss 呢?

我们的目标给定如果是 0 那么 yhat 也要是 0。y 是 1 的时候 yhat 也得是 1。如果 y 等于 1 的情况下,yhat 等于 0,就意味着错的很厉害啊。相对的,y 等于 1,yhat 等于 0 也同样是错的很厉害。

那么,如果 y 等于 1 的时候,yhat 等于 0.9, 错的就比较少。yhat 等于 1 的时候,错误就是 0,也就是没错误。

那么我们就可以写-log(yhat), 把这个写出来就是这样一个函数:当它越接近于 0 的时候,loss 值会接近于无穷大,当它接近于 1 的时候,loss 会接近于 0。

当 y 等于 1 的时候,loss 可以等于-log(yhat)。如果 y 等于 0,lose 值就越接近于无穷大。这个时候 loss 就可以写成-log(1-yhat)。

那么现在这里就出现一个问题,也是通常面试时候的一个高频题:为什么在逻辑回归里,loss 函数不直接写成 1-yhat?

就是,如果 y 等于 1 的情况下,loss 函数不直接写成 1-y, 当 y 等于 0 的时候,loss 不直接写成 y。

因为这样会导致这条线呈现出一个直线,所有的偏导结果都是一致没有发生变化。

就好比有一个孩子考试成绩特别差,假如现在的目标是等于 1,他的成绩特别特别差,0.001。现在的这个梯度还是比较小,他考特别好的时候这个梯度还是一样。

但是我们知道,梯度代表了接下来的变化方向和力度。这个就是我们为什么要用这个的原因。

当然,其实还有一些概率上的解释,这里就不继续延展着讲了。

对于上面讲的,当 y=1 和 y=0 的两个不同的 loss 函数,可以做一个归纳,写成一个 loss 函数:

\[ loss = - (ylog\hat y + (1-y) log(1-\hat y)) \]

为什么能够变成这样呢?我们来分析一下,如果 y=0 的时候,那 ylog(yhat) 就等于 0,也就是说仅剩下 (1-y)log(1-yhat),反过来,当 y=1 的时候,等式后面部分就等于 0,仅剩下 ylog(yhat)。

那下面,我们就来完成代码来实现:

1
2
def loss(yhat, y):
return -np.sum(y*np.log(yhat) + (1-y)*np.log(1-yhat))

lose 函数求解出来之后,对于 (w,b) 怎么求偏导呢?

那么其实式子就可以变成: \[ \begin{align*} & -(ylog \sigma (wx+b) + (1-y)log \sigma (1-(wx+b))) \\ & -(ylog \sigma (w_1x_1+w_2x_2+b) + (1-y)log \sigma (1-(w_1x_1+w_2x_2+b))) \end{align*} \] 那么对其求偏导,一系列推导完成后就可以变成: \[ \begin{align*} \frac{\partial loss}{\partial w_i} & = \sum(\hat y - y) x_i \end{align*} \] 我们来完成其函数代码,和线性部分一样,包含对 w 和 b 求导两部分:

1
2
3
4
5
def partial_w(x, y, yhat):
return np.array([np.sum((yhat-y) * x[0]), np.sum((yhat-y) * x[1])])

def partial_b(x, y, yhat):
return np.sum((yhat - y))

那接下来我们干嘛?上节课的内容还有印象吗?接下来我们要给 w,b 随机值对吧?

1
2
w = w = np.random.random_sample((1,2))
b = np.random.random()

接着我们修改上面实现过的对真实值的实现代码,删掉我们曾经打印的 (x,y),然后利用我们实现的 loss 函数和偏导函数来计算预测值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
rm = dataframe['RM']
lstat = dataframe['LSTAT']
target = dataframe['expensive']

learning_rate = 1e-5
epoch = 200
losses = []

for i in range(epoch):
batch_loss = []
for batch in range(len(rm)):
index = random.choice(range(len(rm)))

x = np.array([rm[index], lstat[index]])
y = target[index]

# print(x, y)

yhat = model(x, w, b)
loss_v = loss(yhat, y)

w = w + -1 * partial_w(x, y, yhat) * learning_rate
b = b + -1 * partial_b(x, y, yhat) * learning_rate

if batch % 100 == 0:
print('Epoch: {}, Batch: {}, loss:{}'.format(i, batch, loss_v))
losses.append(np.mean(batch_loss))

执行完之后,我们可以看到 loss 在慢慢的变小。

现在我们在数据中随机取一些数据,比如说我们去 100 个吧,用于去预测,检验我们的模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
random_test_indices = np.random.choice(range(len(rm)), size=100)

for i in random_test_indices:
print('RM:{}, STAT:{}, TARGET:{}, PRE:{}'.format(rm[i], lstat[i], target[i], model(np.array([rm[i], lstat[i]]), w, b)))

---
RM:6.425, STAT:12.03, TARGET:0, PRE:[0.15662289]
...
RM:5.0, STAT:31.99, TARGET:0, PRE:[4.87033539e-06]
...
RM:8.247, STAT:3.95, TARGET:1, PRE:[0.9623407]
...
RM:7.686, STAT:3.92, TARGET:1, PRE:[0.95077171]

我随机展示了一些数据,我们从这里能看到,预测值内有的值偏向 0,有的值甚至比 1 还要大。对比前面的 TARGET 真实值来看,预测的大部分还是准确的。

不过这个时候还是有问题,我们做这个预测的初衷是为了要做分类,也就是到底是 0 还是 1,那 PRE 值到底是什么,怎么分类呢?咱们就要牵扯到一个东西:dicision boundary

也就是决策的边界,咱们假定为 0.5,让我们拿到的预测值去和这个边界值做对比,大于它的就是 1,小于的就是 0:

1
2
dicision_boundary = 0.5
predicate_label = int(predicate > decision_boundary)

有了这个之后,我们需要更改下我们之前的代码:

1
2
3
4
5
6
7
8
9
random_test_indices = np.random.choice(range(len(rm)), size=100)
decision_boundary = 0.5

for i in random_test_indices:
x1, x2, y = rm[i], lstat[i], target[i]
predicate = model(np.array([x1, x2]), w, b)
predicate_label = int(predicate > decision_boundary)

print('RM:{}, LSTAT:{}, EXPENSIVE:{}, Predicated:{}'.format(x1, x2, y, predicate_label))

image-20231013142817740

更改完之后我们执行,和真实值进行对比,我们发现整个预测的还算事准确。当然也有部分预测错误的。

现在这个模型能够预测出来了,根据两个值能够预测出来它到底属于一个高档房子,还是不属于一个高档房子。但是我们会发现其实还有算错的地方。那么现在要问,如何衡量模型的好坏?以下就是我们要继续研究的问题:

  1. accuracy 准确度
  2. precision 精确度
  3. recall 召回率
  4. f1, f2 score
  5. AUC-ROC 曲线

这些就是我们用于衡量模型的一些指标,通过这个,我们要引出一个非常重要的概念,就是过拟合和欠拟合 (over-fitting and under-fitting)。我们可以说,整个机器学习的过程,就是在不断的进行过拟合和欠拟合的调整。那么这些呢,就是我们下面课程的内容了。

目前来讲,我们学习了监督学习里面最重要的线性回归和逻辑回归,接下来什么我们要去学的 LSTM,CNN 等等,其实都是为了提高这个准确度所要做的事情。

也就是,现在我们发现虽然模型还是稍微有一些错误,这个时候就需要一起来再研究一下如何衡量模型的好坏。只有知道了如何衡量模型的好坏,才知道怎么样去调整它,怎么去优化它。

好,那下节课记得不见不散。


关注「坍缩的奇点」,第一时间获取更多免费 AI 教程。

坍缩的奇点

作者

Hivan Du

发布于

2023-10-15

更新于

2024-01-16

许可协议

评论