深度学习系列教学01:利用逻辑回归进行二元分组

F@NAZOrip
F@NAZOrip 2018年04月26日
  • 在其它设备中阅读本文章

前言

    前些天littlebakas发布了自己设计的神经网络模型的demo成果。虽然似乎在圈子里并未掀起什么太大波澜,身边朋友们的讨论、包括帖子回复数量都寥寥,然而对于笔者来说却认为这是十分振奋人心的事,我们先来看一组样张:

    可以看到,起码在样张中,原先需要复杂操作才能完成的处理,通过神经网络可以得到不相伯仲的结果,这意味着优秀的前端处理也有飞入寻常百姓家的可能性,未来广大人民群众有更多的、接触更好压制作品的机会,这不能不说是一件令人颇开心的事情。

    有关卷积神经网络深度学习在图像处理中的应用在近些年颇有崛起的架势,早年可能受限于工具(诸如caffe1等)使用门槛较高,或受限于计算力不足,这些因素都在近些年(或者说近两年)随着人工智能的火热得到了长足解决,本文中要介绍的MXNet作为谷歌钦点的一款开源工具,在训练效率和结果准确性方面的表现都相当不错。我们需要注意到的是,神经网络在图形学中的潜力是巨大的,以我们压片为例,许多传统算法无法解决、或不擅长解决的问题(诸如烂线、抗锯齿、抗光环等),神经网络都为我们提供了一个崭新的思路,想必在未来几年内新的工具会如雨后春笋般破土而出。转念又想起av1也即将发表,21世纪头二十年对于ripper来说还真是紧张兴奋又刺激的一段时间呢。

    于是我们也顺应时代潮流,以制作出实际产品为目标,进行一系列深度学习的“科普”性教学。虽然你以前挖了那么多坑没填但人总要向前看不是。(讲道理,准工业级产品理所当然地不太可能在我们这种粗糙的教学之下由你独立完成,尽管在图像过滤的领域里获取训练素材往往要比其他应用简单许多——无法获得良好的训练素材几乎是当今工业产品性能差异的主要原因,包括笔者自身的产品也尚在摸索之中。不过我们抱着学习的目的互相交流,也许指不定就让你对这个领域产生了兴趣,或者只言片语间帮瓶颈期的你做了什么突破,这些也都是说不定的事。)所以本篇教学中,我们选择目前最容易上手?的MXNet框架,着手对其官方亲切友好的教学文档(以及被喷成狗的apidoc)进行翻译,并添加一些注释性说明,希望与大家共同交流,共同进步。

附注1:本文系从MXNet官网教学的第三章(官标第二章)开始翻译,在前两章中主要介绍了线性回归和使用简化工具后的线性回归,本章主要介绍分类问题,这些都是有监督式学习中最基础的内容。由于前两章中的概念过于基础,除非文中提到,大多数概念本章中不进行赘述,0基础的同学如果有不明白的地方应该可以很容易地在目前铺天盖地的深度学习教学中找到答案。

附注2:文中涉及公式部分,因为markdown不支持上下标,上传图片又过于麻烦,如有不清楚的地方请去原贴中对应位置查询。

附注3:有兴趣的(并且不是瞎几把有兴趣的小白的)同学欢迎加群讨论,然而请务必认真填写加群说明。我们三令五申加群说明很重要,但仍然有瞎几把填的人,遇到这种情况我们是坚决地拒绝的。


利用逻辑回归进行二元分组

原贴地址:http://gluon.mxnet.io/chapter02_supervised-learning/logistic-regression-gluon.html

    如上两个教程所示,在给大家展示如何使用线性回归模型的过程中,我们使用了scratch和gluon(译注:直译做胶子,胶水的胶,引述自量子力学概念,系MXNet在2017年公布的新接口,使工程量进一步下降。这里只是两个名称,与下文名词不同的是,这里的名称本身不含任何含义)两种方式将大部分的重复工作,诸如初始化和分配参数,定义(如何评估脚本表现的)函数,以及如何使用最佳化工具(optimizer,这里指的是使用SGD使lossfunction最低),这些全都自动化了。

    回归是一种当我们想要回答诸如“多少钱?”、“多少个?”这类问题的时候常想到要使用的工具,比如如果你想要估算一个房子卖出的加钱在多少合适,或者你想预测一个棒球队会赢多少分,又或者是你想估计一个患者在被放出来之前会在号子里蹲多少天(,那么你可能会想到使用回归模型。

    但是按照我们的经验,在工业生产中我们往往对(离散的)分类问题更感兴趣。比如面对“这封邮件究竟是不是垃圾邮件”,“一个消费者有多大机会花钱购买某种增值服务”等这类问题时。当我们希望将若干数据划分为某种或某几种类别,又或者我们希望判断出对某数据属于某类别的可能性有多大。当这类问题引起了我们的兴趣,我们就给这类问题起了个名字叫“分类问题”。

    分类问题中一个最简单模型是二元分类,也就是说只有两个类别,因为简单,我们就从这里开始讲起。于是也我们不妨分别称呼这两个类别为正类(positive class,yi=1)和负类(nagative class,yi=0)。但需要注意的是,即便是只有两个分类,并且即便我们将自己局限于只使用线性模型,能让我们入手解决这个问题的切入点也是非常多的。比方说举个简单的例子,我们有这样一种思路解决这类问题:画一条线,让所有点被分开的界限最分明。

    有一大套的算法,名叫“支持向量网络”(SVM),就都是用的这种办法。主体思想即是使“被划分的两边中最接近分割线的点,到分割线的余量(margin)”最大化。在这种方法中,只有那些离分割线最近的点(那些支持向量们,support vectors)会实际影响线性分离器做出何种选择。

    但是在神经网络的思维下,我们通常又有些别的方法。比如相比于单纯地将点分类,我们更倾向于训练出一个概率变量,使用这个变量来估测每个点在当前环境下属于正类(positive class)的可能性有多少。

    回想起当初我们做线性回归时候,我们用如下方式做预测:

y^=wTx+b.

    不过当我们想解决一个点属于正类的概率有多大的这类问题的时候,常规的线性模型是一个明显傻逼的选择,因为这个模型能给出大于1或小于0的结果(显然这与概率常识不符)。所以为了使我们的答案更加合理,我们倾向于把手头的线性方程做一点微小的改良,比如,通过跑一个Sigmoid函数来把结果限定在0和1之内,即:

y^=σ(wTx+b).

    Sigmoid函数,有些时候被称作压缩函数,或者逻辑函数(因此也就有了逻辑回归logistic regression的名字)(译注:高中生物学中用来评估某物种种群数量与环境容量用的就是这条曲线,这个说法可能对大家来说更亲切一些),这个函数他有这样一个特性,无论输入任何实数x,都会得到0和1之间的输出。它大概长这个样。

σ(z)=11+e−z

    废话少说,我们还是干脆直接开始import mxnet和matplotlib,用他们来看看这个函数究竟长什么样。

import mxnet as mx
from mxnet import nd, autograd, gluon
import matplotlib.pyplot as plt

def logistic(z):
    return 1. / (1. + nd.exp(-z))

x = nd.arange(-5, 5, .1)
y = logistic(x)

plt.plot(x.asnumpy(),y.asnumpy())
plt.show()

因为Sigmod输出的是0到1之间的数,这种处理能让经过它的数看起来更像一个通常意义上的概率。并且我们注意到输入x=0给出的y的结果是0.5,所以我们不妨做出如下定义:在通常情况下,我们假定当概率大于0.5的时候它就是一个正类,小于0.5的时候就是一个负类,我们姑且将其记做:wTx+b

二元交叉熵(cross-entropy)差值

    现在我们解决了输出概率的模型,是时候选择一个衡量差值(神经网络模型表现出的准确性如何)的公式了。之前当我们解决“数量有多少”的问题的时候我们用方差(y−y^)2来衡量我们的模型效率如何。(译注:交叉熵是常用的衡量概率预测与真实差值的工具)

    既然我们现在考虑的是概率问题,那么我们很自然的会产生的一个思路是:设计如下一种函数,使得一个点如果实际上属于正类,那就让他的计算结果越接近正1越好(同理,反之则越接近0越好),设计一个算法使其能按上述逻辑评估所有点

maxθPθ((y1,...,yn)|x1,...,xn)

    由于每个输入的范例都是独立的,并且每个结果Y都与、并只与他的输入值x相关,故我们可以把上式写成如下这种形式。

maxθPθ(y1|x1)Pθ(y2|x2)...P(yn|xn)

    上面这个函数会算出所有输入范例的值。但是总的来说因为我们希望用随机梯度下降的方法(SGD)来训练神经网络(以减小计算量),如果能有一个函数能把所有训练样本分解为若干组加总(sum)的形式那问题就会简化很多。

    因为我们使用了一个典型表达式来表示预估与实际的差值,无妨我们把正负号颠倒,得到一个负的log概率:

minθ(−∑i=1nlogPθ(yi|xi))

    如果我们将y^i解释为第i组样本属于正类的概率,那么1-i显然是它属于负类的概率。这相当于说:

Pθ(yi|xi)={y^i,1−y^i,if yi=1if yi=0

    上式可以写成一种更简洁(更容易让人懵逼)的形式

Pθ(yi|xi)=y^yii(1−y^i)1−yi

    故我们可以用一个方程来评估我们神经网络的表现:

ℓ(y,y^)=−∑(i=1→n)yilogy^i+(1−yi)log(1−y^i).

    如果你是初次接触机器学习,这个公式可能信息量太大,对你来说讲得太快了。我们再仔细看看这个差值公式,仔细看看到底各部分都是在干什么。

    首先这个函数由两部分构成,yilogy^i 和(1−yi)log(1−y^i)。因为yi(人为划分好的真实值)只有两种情况0或1,所以对于一个已经分类好的(用来训练的)数据点来说,这两项中的某项会被消掉。比如当yi==1,这个公式就告诉我们应当使logy^i(神经网络计算值)最大化,故应将该值往更大修正。比如yi==0,差值公式就变成了log(1−y^i),也就是说我们应该让1-y^i最大(根据上文,也就是让这个值对应的输入xi属于负类的可能性最大)。

    值得注意的是这个差值方程通常被叫做log差值(logloss),或者被称为二元相对熵(binary cross entropy),这是-log表示可能性的一种特殊情况,也是相对熵的一种特殊情况,籍此我们可以把相对熵推广到多元分类。

    虽然处理线性回归的时候我们展示了一些相互之间不太相同的操作比如scratch或者gluon,这里我们准备展示一下如何将两者优势互补。我们准备用gluon构建模型,但是我们会用scratch写差值方程。

数据

    一如既往地,我们不希望瞎编数据,我们希望在一些真实数据上实现上述概念。所以这次我们选择了来自加州大学欧文分校的“成人”数据组。这份数据库是Barry Becker 利用1994年的额人口普查构建的,在它的原始形式中它由14种特性组成,包括年龄、受教育程度、工作情况、性别、籍贯国家、以及其他种种。特别地,在本次使用的唯一指定版本中,我们使用台湾大学加工过的,将其变为由123个二元属性(每个属性代表原数据的位数)组成的数据库。

    每一个属性都是一个指标,显示每行所代表的这个人的年收入在1994年是否大于50000美元,我们用的这组数据包含30956个训练范例和1605个用来检验成果的范例。我们可以用如下方法把他们加载到主记忆体(CPU内存)当中.

data_ctx = mx.cpu()
# Change this to `mx.gpu(0) if you would like to train on an NVIDIA GPU
model_ctx = mx.cpu()

with open("../data/adult/a1a.train") as f:
    train_raw = f.read()

with open("../data/adult/a1a.test") as f:
    test_raw = f.read()

    这些训练数据每行都大概长这样:

-1 4:1 6:1 15:1 21:1 35:1 40:1 57:1 63:1 67:1 73:1 74:1 77:1 80:1 83:1 \n

    每行第一个数字是这组数据的正负值(上文中提到过)。接下来的若干数字表示的是所有非零特性的长度?(译注:时间有限没看测资,这里并没有太看懂作者想说什么)。每个数后面都跟了个1,这个1实际上并没什么特别的含义。不过因为我们往往不能总是精确地控制我们得到的训练数据的形式,所以我们最好也要熟悉处理庞杂形式的输入数据的方法。这里我们写个简单的脚本处理一下我们的输入数据。

def process_data(raw_data):
    train_lines = raw_data.splitlines()
    num_examples = len(train_lines)
    num_features = 123
    X = nd.zeros((num_examples, num_features), ctx=data_ctx)
    Y = nd.zeros((num_examples, 1), ctx=data_ctx)
    for i, line in enumerate(train_lines):
        tokens = line.split()
        label = (int(tokens[0]) + 1) / 2  # Change label from {-1,1} to {0,1}
        Y[i] = label
        for token in tokens[1:]:
            index = int(token[:-2]) - 1
            X[i, index] = 1
    return X, Y

Xtrain, Ytrain = process_data(train_raw)
Xtest, Ytest = process_data(test_raw)

    现在我们把数据阵列调整为mxnet能跑的正确格式了。

print(Xtrain.shape)
print(Ytrain.shape)
print(Xtest.shape)
print(Ytest.shape)

    同样地我们可以检查一下所有数据当中标记为正1的那部分,这个检查会给我们提供一个非常不错的(充分不必要)信号:我们的训练数据确实是按照相同形式分布的。

print(nd.sum(Ytrain)/len(Ytrain))
print(nd.sum(Ytest)/len(Ytest))

数据加载器(dataloader)的使用范例

batch_size = 64

train_data = gluon.data.DataLoader(gluon.data.ArrayDataset(Xtrain, Ytrain),batch_size=batch_size, shuffle=True)
test_data = gluon.data.DataLoader(gluon.data.ArrayDataset(Xtest, Ytest),batch_size=batch_size, shuffle=True)

定义模型

net = gluon.nn.Dense(1)
net.collect_params().initialize(mx.init.Normal(sigma=1.), ctx=model_ctx)

最佳化工具(optimizer)使用范例:

trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': 0.01})

定义log差值:

def log_loss(output, y):
    yhat = logistic(output)
    return  - nd.nansum(  y * nd.log(yhat) + (1-y) * nd.log(1-yhat))

使学习曲线可视化

# plot the convergence of the estimated loss function
# %matplotlib inline

import matplotlib
import matplotlib.pyplot as plt

plt.figure(num=None,figsize=(8, 6))
plt.plot(loss_sequence)

# Adding some bells and whistles to the plot
plt.grid(True, which="both")
plt.xlabel('epoch',fontsize=14)
plt.ylabel('average loss',fontsize=14)

计算精度

    虽然-log值能给我们一种关于“多大程度上我们的预测值贴近于认为划分好的原始标签”的“感觉”,但它并不是唯一衡量我们这个分离工具表现如何的方法。比如说,最后结尾的地方我们习惯于放一个阈值(threshold)上去来让我们的预测值更加硬(hard,区分能更加明确),举例来说,比如制作垃圾邮件过滤器的时候,我们必须决定是否把这封邮件放到垃圾箱里去,或者把它留在收件夹内(不存在中间选项)。在这种问题中我们通常就不太在意-log究竟多大,我们只注意按照我们目前的分法,究竟分错了多少封邮件。所以这里我们再写个简单脚本计算一下我们划分的准确性到底有多少。

num_correct = 0.0
num_total = len(Xtest)
for i, (data, label) in enumerate(test_data):
    data = data.as_in_context(model_ctx)
    label = label.as_in_context(model_ctx)
    output = net(data)
    prediction = (nd.sign(output) + 1) / 2
    num_correct += nd.sum(prediction == label)
print("Accuracy: %0.3f (%s/%s)" % (num_correct.asscalar()/num_total, num_correct.asscalar(), num_total))

    表现的不差不是么!一个naive的分离器可能会预测说没有人可以得到50k美元以上的年收入(对于普罗大众阶级来说),这样就大约是75%的划分正确率,作为对比我们设计的分离器的正确率是84%(结果可能有略微浮动,因为初始化和SGD载入的顺序都是随机的)(译注:然而译者跑了几遍结果都是73%)

    到这里为止你应该已经对“监督式学习”最基本的两种任务--回归和分类,有了一点点感觉了吧。在接下来的章节中我们会进一步深入这些内容,让你见识更复杂的模型、差值函数、最佳化工具和训练方案。我们也会见到更加有趣的训练数据。并且最后,我们在接下来的章节中也会见到(我们希望见到的)更加复杂的问题,比如说我们会面对更加结构化的对象。我们下期再见。

    233k3
    233k3  2018-04-29, 23:22

    也就是naobu.dll 快实现了-.- 还是回去学号高数吧

    fs
    fs  2018-04-26, 09:14

    不会AI的程序员不是一个好ripper233333