OpenCV Tutorials 25 - SVM

362 阅读17分钟

支持向量机

一、引言

本小节我们主要学习使用OpenCV中的 cv::ml::SVM::train函数来建立一个基于SVM算法的分类器,并使用 cv::ml::SVM::predict来测试分类器性能。

1. SVM是什么?

支持向量机 (SVM) 是由分离超平面正式定义的判别分类器。 换句话说,给定标记的训练数据(监督学习),该算法输出一个对新示例进行分类的最佳超平面。

超平面在哪种意义上是最优的? 让我们考虑以下简单的问题:

对于属于两个类别之一的一组线性可分的 2D 点,找到一条分离直线。

download.png

  • 注意:在这个例子中,我们处理笛卡尔平面(xy)(x - y)中的线和点,而不是高维空间中的超平面和向量。 这是对问题的简化。重要的是要理解这样做只是因为我们的直觉更好地建立在易于想象的示例中。 但是,相同的概念适用于要分类的示例位于维度大于 2 的空间中的任务。

在上图中,您可以看到存在多行可以解决问题。 他们中的任何一个都比其他曲线更好吗? 我们可以直观地定义一个标准来估计线条的价值:如果线条太靠近样本点,则它是坏的,因为它会对噪声敏感并且无法正确概括样本分布。 因此,我们的目标应该是找到尽可能远离所有点的线。

然后,SVM 算法的操作基于找到给训练样例提供最大最小距离的超平面。 两次,这个距离在 SVM 理论中得到了重要的边际名称。 因此,最优分离超平面最大化了训练数据的边距。

download.png

2. 最优超平面是如何计算获得的?

让我们介绍用于正式定义超平面的符号表示:f(x)=β0+βTxf(x) = \beta_0+\beta^Tx,此处的β\beta是向量权重,β0\beta_0是偏移量,其实这里的β\beta就相当于我们常用的θ\theta表示法

  • 注意:您可以在本书的第 4.5 节(分离超平面)中找到对此和超平面的更深入描述:T. Hastie、R. Tibshirani 和 J.H. Friedman 的 Elements of Statistical Learning([252])。

最优超平面可以通过缩放 β 和 β0 以无数种不同的方式表示。 按照惯例,在超平面的所有可能表示中,选择的是:β0+βTx=1|\beta_0 + \beta^Tx| = 1,这里的x表示超平面的训练样本集。一般来说,与超平面极其相似的训练样本集被称为支持向量。这种表示被称为规范超平面。

现在,我们使用几何结果给出点 x 和超平面 (β,β0) 之间的距离:distance=β0+βTxβdistance = \frac{|\beta_0 + \beta^Tx|}{||\beta||}

特别注意,对于规范超平面,分子等于 1,到支持向量的距离为:distancesupportvectors=β0+βTxβ=1βdistance_{support vectors}= \frac{|\beta_0 + \beta^Tx|}{||\beta||} = \frac{1}{||\beta||}

回想一下上一节中介绍的边距,这里表示为 M,是到最近示例的距离的两倍:M=2βM = \frac{2}{||\beta||}

最后,最大化 M 的问题等价于最小化受某些约束的函数 L(β) 的问题。 约束对超平面正确分类所有训练示例 xix_i的要求进行建模。 规范讲,minβ,β0L(β)=12β2subjecttoyi(β)1,imin_{\beta,\beta_0}L(\beta) = \frac{1}{2}||\beta||^2subject to y_i(\beta) \ge 1, \forall i

这里的yiy_i表示了训练样本的每个标签(标准分类),这是一个拉格朗日优化问题,可以使用拉格朗日乘数求解,得到最优超平面的权重向量β和偏移量β0。

3. 源代码

import cv2 as cv
import numpy as np

# 设定训练数据集
labels = np.array([1, -1, -1, -1])
trainingData = np.matrix([[501, 10], [255, 10], [501, 255], [10, 501]], dtype=np.float32)

# 训练SVM
svm = cv.ml.SVM_create()
svm.setType(cv.ml.SVM_C_SVC)
svm.setKernel(cv.ml.SVM_LINEAR)
svm.setTermCriteria((cv.TERM_CRITERIA_MAX_ITER, 100, 1e-6))
svm.train(trainingData, cv.ml.ROW_SAMPLE, labels)

# 初始化纯黑画板
width = 512
height = 512
image = np.zeros((height, width, 3), dtype=np.uint8)

# 展示由SVM给出的决策区域
green = (0,255,0)
blue = (255,0,0)
# 遍历样本点并上色
for i in range(image.shape[0]):
    for j in range(image.shape[1]):
        # 将样本点所在坐标数据修改为np.float32便于之后预测
        sampleMat = np.matrix([[j,i]], dtype=np.float32)
        # 使用训练好的SVM对样本点进行分类预测,第一个返回值是预测精度,第二个返回值是预测结果
        response = svm.predict(sampleMat)[1]
        if response == 1:
            image[i,j] = green
        elif response == -1:
            image[i,j] = blue

# 展示训练集,对正样本绘制黑色,负样本绘制白色
thickness = -1
cv.circle(image, (501,  10), 5, (  0,   0,   0), thickness)
cv.circle(image, (255,  10), 5, (255, 255, 255), thickness)
cv.circle(image, (501, 255), 5, (255, 255, 255), thickness)
cv.circle(image, ( 10, 501), 5, (255, 255, 255), thickness)

# 展示支持向量
thickness = 2
sv = svm.getUncompressedSupportVectors()
# 遍历训练样本点,在样本点的周围绘制上灰色轮廓
for i in range(sv.shape[0]):
    cv.circle(image, (int(sv[i,0]), int(sv[i,1])), 6, (128, 128, 128), thickness)
cv.imwrite('result.png', image) # 保存图片
cv.imshow('SVM Simple Example', image) # 呈现
cv.waitKey()
-1

download.png

可以看出SVM得到的决策边界和正负样本之间采取了较大间距,右上角黑点是正样本,标签信息为1,其余的三个白点是负样本。

4. 解释

A. 设定训练数据

本练习的训练数据由一组标记的 2D 点组成,这些点属于两个不同类别之一(二分类问题); 其中一类由一个点组成,另一个由三个点组成,也就是一个正样本,三个负样本。

# 设定好训练点集以及其正负样本标签
labels = np.array([1, -1, -1, -1])
trainingData = np.matrix([[501, 10], [255, 10], [501, 255], [10, 501]], dtype=np.float32)

之后将使用的函数 cv::ml::SVM::train 要求将训练数据存储为浮点数的 cv::Mat 对象。 因此,我们从上面定义的数组中创建这些对象:

  • 用法如下:
  1. cv.ml.StatModel.train( trainData[, flags] ) -> retval
  2. cv.ml.StatModel.train( samples, layout, responses ) -> retval
# np.array的默认数据类型就是np.float32
labels = np.array([1, -1, -1, -1])
trainingData = np.matrix([[501, 10], [255, 10], [501, 255], [10, 501]], dtype=np.float32)

B. 设定SVM参数

在本教程中,我们在最简单的情况下介绍了 SVM 的理论,即训练样本被分成两个线性可分的类。 然而,SVM 可以用于各种各样的问题(例如,非线性可分数据的问题、使用核函数来提高示例维度的 SVM 等)。 因此,我们必须在训练 SVM 之前定义一些参数。 这些参数存储在 cv::ml::SVM 类的对象中。

svm = cv.ml.SVM_create()
svm.setType(cv.ml.SVM_C_SVC)
svm.setKernel(cv.ml.SVM_LINEAR)
svm.setTermCriteria((cv.TERM_CRITERIA_MAX_ITER, 100, 1e-6))
  • 参数含义:
  1. 支持向量机的类型。我们在这里选择可用于 n 类分类(n ≥ 2)的类型 C_SVC。这种类型的重要特征是它处理类的不完美分离(即当训练数据是非线性可分离的)。这个特性在这里并不重要,因为数据是线性可分的,我们选择这种 SVM 类型只是因为它是最常用的。
  2. SVM 内核的类型。我们没有讨论核函数,因为它们对我们正在处理的训练数据不感兴趣。不过,现在让我们简要解释一下核函数背后的主要思想。它是对训练数据进行的映射,以提高其与线性可分数据集的相似性。这种映射包括增加数据的维数,并使用核函数有效地完成。我们在这里选择类型 LINEAR,这意味着不进行任何映射。此参数使用 cv::ml::SVM::setKernel 定义。
  3. 算法的终止标准。 SVM 训练过程以迭代方式解决受约束的二次优化问题。在这里,我们指定了最大迭代次数和容差误差,因此即使尚未计算出最佳超平面,我们也允许算法以更少的步数完成。此参数在结构 cv::TermCriteria 中定义。
  4. 训练 SVM 我们调用方法 cv::ml::SVM::train 来构建 SVM 模型。
# 用法如下:
svm.train(trainingData, cv.ml.ROW_SAMPLE, labels)
True

C. 使用SVM对区域进行分类

方法 cv::ml::SVM::predict 用于使用经过训练的 SVM 对输入样本进行分类。 在此示例中,我们使用此方法根据 SVM 所做的预测为空间着色。 换句话说,遍历图像,将其像素解释为笛卡尔平面上的点。 每个点的颜色取决于 SVM 预测的类别; 如果是标签为 1 的类,则为绿色;如果是标签为 -1 的类,则为蓝色。

green = (0,255,0)
blue = (255,0,0)
for i in range(image.shape[0]):
    for j in range(image.shape[1]):
        sampleMat = np.matrix([[j,i]], dtype=np.float32)
        response = svm.predict(sampleMat)[1]
        if response == 1:
            image[i,j] = green
        elif response == -1:
            image[i,j] = blue
  • 用法如下: cv.ml.StatModel.predict( samples[, results[, flags]] ) -> retval, results

D. 支持向量

我们在这里使用了几种方法来获取有关支持向量的信息。 方法 cv::ml::SVM::getSupportVectors 获取所有支持向量。 我们在这里使用这种方法来查找作为支持向量的训练示例并突出显示它们。

thickness = 2
sv = svm.getUncompressedSupportVectors()
for i in range(sv.shape[0]):
    cv.circle(image, (int(sv[i,0]), int(sv[i,1])), 6, (128, 128, 128), thickness)
    
cv.imshow('Result', image)
  1. 该代码会打开一个图像并显示两个类的训练示例。 一类的点用白色圆圈表示,黑色的点用于另一类。
  2. SVM 被训练并用于对图像的所有像素进行分类。 这导致图像在蓝色区域和绿色区域中的划分。 两个区域之间的边界是最优分离超平面。
  3. 最后,支持向量在训练示例周围使用灰色环显示。

download.png

二、非线性可分数据的支持向量机

本小节中,我们主要解决当无法线性分离训练数据时,定义 SVM 的优化问题和如何配置参数以使您的 SVM 适应此类问题。

1. 动机

为什么扩展 SVM 优化问题以处理非线性可分训练数据很有趣? 在计算机视觉中使用 SVM 的大多数应用都需要比简单的线性分类器更强大的工具。 这源于这样一个事实,即在这些任务中,训练数据很少可以使用超平面分离。

考虑其中一项任务,例如人脸检测。 在这种情况下,训练数据由一组人脸图像和另一组非人脸图像(世界上除人脸之外的所有其他事物)组成。 该训练数据过于复杂,以至于无法找到每个样本(特征向量)的表示,该表示可以使整组人脸与整组非人脸线性分离。

2. 优化问题的扩展

请记住,使用 SVM,我们获得了一个分离的超平面。 因此,由于训练数据现在是非线性可分的,我们必须承认找到的超平面会对一些样本进行错误分类。 这种错误分类是优化中必须考虑的一个新变量。 新模型必须既包括找到提供最大余量的超平面的旧要求,又包括通过不允许太多分类错误正确概括训练数据的新要求。

我们从寻找最大化边际的超平面的优化问题的公式开始(这在上一个教程(支持向量机简介)中进行了解释:

minβ,β0L(β)=12β2subjecttoyi(β)1,imin_{\beta,\beta_0}L(\beta) = \frac{1}{2}||\beta||^2subject to y_i(\beta) \ge 1, \forall i

可以通过多种方式修改此模型,因此它考虑了错误分类错误。 例如,可以考虑最小化相同的数量加上训练数据中错误分类错误数量的常数倍,即:

minβ2+C(misclassificationerrors)min||\beta||^2+C(misclassification errors)然而,这不是一个很好的解决方案,因为除其他一些原因外,我们不区分错误分类的样本与它们适当的决策区域的距离很小的样本或没有的样本。 因此,更好的解决方案将考虑错误分类样本与其正确决策区域的距离,即:

minβ2+C(distanceofmisclassifiedsamplestotheircorrectregions)min||\beta||^2+C(distance of misclassified samples to their correct regions)

对于训练数据的每个样本,定义了一个新参数 εi\varepsilon _i。 这些参数中的每一个都包含从其对应的训练样本到其正确决策区域的距离。 下图显示了来自两个类的非线性可分训练数据,一个分离的超平面以及到错误分类样本的正确区域的距离。

download.png

  • 注意:图片中只显示了被错误分类的样本的距离。 其余样本的距离为零,因为它们已经位于正确的决策区域中。

图片上出现的红线和蓝线是每个决策区域的边距。 认识到每个 ξi 从一个错误分类的训练样本到其适当区域的边缘是非常重要的。

最后,优化问题的新公式为:

minβ,β0L(β)=β2+Ciξi subject to yi(βTxi+β0)1ξi and ξi0i\min _{\beta, \beta_{0}} L(\beta)=\|\beta\|^{2}+C \sum_{i} \xi_{i} \text { subject to } y_{i}\left(\beta^{T} x_{i}+\beta_{0}\right) \geq 1-\xi_{i} \text { and } \xi_{i} \geq 0 \forall i

应该如何选择参数 C? 很明显,这个问题的答案取决于训练数据的分布方式。 尽管没有通用答案,但考虑以下规则是有用的:

  • 较大的 C 值给出的解决方案具有较少的错误分类错误但较小的边际。 考虑到在这种情况下,错误- 分类错误的代价是昂贵的。 由于优化的目的是最小化参数,因此允许的错误分类错误很少。
  • 较小的 C 值给出具有较大边距和更多分类错误的解决方案。 在这种情况下,最小化并没有考虑太多的总和项,因此它更多地关注于找到一个具有大边距的超平面。

3. 源码

from __future__ import print_function
import cv2 as cv
import numpy as np
import random as rng
NTRAINING_SAMPLES = 100 # 每个分类的训练集样本个数
FRAC_LINEAR_SEP = 0.9   # 组成线性可分部分的样本占比
# 视窗大小以及层次
WIDTH = 512
HEIGHT = 512
I = np.zeros((HEIGHT, WIDTH, 3), dtype=np.uint8)
# --------------------- 1. 随机生成训练数据集 ---------------------------------------
# 二分类问题,故需要将初始点集和标签*2
trainData = np.empty((2*NTRAINING_SAMPLES, 2), dtype=np.float32)
labels = np.empty((2*NTRAINING_SAMPLES, 1), dtype=np.int32)
rng.seed(100) # 随机生成分类
# 为训练集生成线性可分部分——90个线性可分数据
nLinearSamples = int(FRAC_LINEAR_SEP * NTRAINING_SAMPLES)
trainClass = trainData[0:nLinearSamples,:]
# 分类1点集的 x 坐标在 [0, 0.4)
c = trainClass[:,0:1]
c[:] = np.random.uniform(0.0, 0.4 * WIDTH, c.shape)
# 分类1点集的 xy坐标在 [0, 1)
c = trainClass[:,1:2]
c[:] = np.random.uniform(0.0, HEIGHT, c.shape)
# 为分类2生成随机点集,下同
trainClass = trainData[2*NTRAINING_SAMPLES-nLinearSamples:2*NTRAINING_SAMPLES,:]
# The x coordinate of the points is in [0.6, 1]
c = trainClass[:,0:1]
c[:] = np.random.uniform(0.6*WIDTH, WIDTH, c.shape)
# The y coordinate of the points is in [0, 1)
c = trainClass[:,1:2]
c[:] = np.random.uniform(0.0, HEIGHT, c.shape)

#------------------ 设置训练集中的非线性可分数据 ---------------
trainClass = trainData[nLinearSamples:2*NTRAINING_SAMPLES-nLinearSamples,:]
# The x coordinate of the points is in [0.4, 0.6)
c = trainClass[:,0:1]
c[:] = np.random.uniform(0.4*WIDTH, 0.6*WIDTH, c.shape)
# The y coordinate of the points is in [0, 1)
c = trainClass[:,1:2]
c[:] = np.random.uniform(0.0, HEIGHT, c.shape)

#------------------------- 为各类设置标签 ---------------------------------
labels[0:NTRAINING_SAMPLES,:] = 1                   # Class 1
labels[NTRAINING_SAMPLES:2*NTRAINING_SAMPLES,:] = 2 # Class 2
#------------------------ 2.设置SVM参数 --------------------
print('Starting training process')
svm = cv.ml.SVM_create()
svm.setType(cv.ml.SVM_C_SVC)
svm.setC(0.1)
svm.setKernel(cv.ml.SVM_LINEAR)
svm.setTermCriteria((cv.TERM_CRITERIA_MAX_ITER, int(1e7), 1e-6))
#------------------------ 3. 训练SVM ----------------------------------------------------
svm.train(trainData, cv.ml.ROW_SAMPLE, labels)
print('Finished training process')
#------------------------ 4.展示决策区域 ----------------------------------------
# 255表示纯色,100则是混了点黑色
green = (0,100,0)
blue = (100,0,0)
for i in range(I.shape[0]):
    for j in range(I.shape[1]):
        sampleMat = np.matrix([[j,i]], dtype=np.float32)
        # 返回值同理,一个是精度一个是结果
        response = svm.predict(sampleMat)[1]
        if response == 1:
            I[i,j] = green
        elif response == 2:
            I[i,j] = blue
#----------------------- 5. 绘出训练数据集 --------------------------------------------
thick = -1
# Class 1
for i in range(NTRAINING_SAMPLES):
    px = trainData[i,0]
    py = trainData[i,1]
    cv.circle(I, (int(px), int(py)), 3, (0, 255, 0), thick)
# Class 2
for i in range(NTRAINING_SAMPLES, 2*NTRAINING_SAMPLES):
    px = trainData[i,0]
    py = trainData[i,1]
    cv.circle(I, (int(px), int(py)), 3, (255, 0, 0), thick)
#------------------------- 6. 使用灰色圆圈圈出支持向量 --------------------------------------------
thick = 2
sv = svm.getUncompressedSupportVectors()
for i in range(sv.shape[0]):
    cv.circle(I, (int(sv[i,0]), int(sv[i,1])), 6, (128, 128, 128), thick)
cv.imwrite('result.png', I)                      # save the Image
cv.imshow('SVM for Non-Linear Training Data', I) # show it to the user

# 补充一下结束时间,不然会卡死
if cv.waitKey(0)&0xff == 27:
    cv.destroyAllWindows()

download.png

4. 解释

A. 设定训练数据集

本练习的训练数据由一组标记的 2D 点组成,这些点属于两个不同类别之一。 为了使练习更具吸引力,训练数据是使用统一概率密度函数 (PDF) 随机生成的。

我们将训练数据的生成分为两个主要部分。

在第一部分,我们为两个类生成线性可分的数据。

# 为分类1生成随机点
trainClass = trainData[0:nLinearSamples,:]
# 将点集的x轴占比缩小到[0,0.4)
c = trainClass[:,0:1]
c[:] = np.random.uniform(0.0, 0.4 * WIDTH, c.shape)
# 将点集的x轴占比缩小到 [0, 1)
c = trainClass[:,1:2]
c[:] = np.random.uniform(0.0, HEIGHT, c.shape)

# 为分类2生成随机点
trainClass = trainData[2*NTRAINING_SAMPLES-nLinearSamples:2*NTRAINING_SAMPLES,:]
# The x coordinate of the points is in [0.6, 1]
c = trainClass[:,0:1]
c[:] = np.random.uniform(0.6*WIDTH, WIDTH, c.shape)
# The y coordinate of the points is in [0, 1)
c = trainClass[:,1:2]
c[:] = np.random.uniform(0.0, HEIGHT, c.shape)

在第二部分中,我们为两个类创建非线性可分的数据,即重叠的数据。

# Generate random points for the classes 1 and 2
trainClass = trainData[nLinearSamples:2*NTRAINING_SAMPLES-nLinearSamples,:]
# The x coordinate of the points is in [0.4, 0.6)
c = trainClass[:,0:1]
c[:] = np.random.uniform(0.4*WIDTH, 0.6*WIDTH, c.shape)
# The y coordinate of the points is in [0, 1)
c = trainClass[:,1:2]
c[:] = np.random.uniform(0.0, HEIGHT, c.shape)

B. 设定SVM参数

  • 注意:在之前的教程 Introduction to Support Vector Machines 中,对我们在训练 SVM 之前在此处配置的类 cv::ml::SVM 的属性进行了解释。
svm = cv.ml.SVM_create()
svm.setType(cv.ml.SVM_C_SVC)
svm.setC(0.1)
svm.setKernel(cv.ml.SVM_LINEAR)
svm.setTermCriteria((cv.TERM_CRITERIA_MAX_ITER, int(1e7), 1e-6))

我们在这里所做的配置与我们用作参考的上一教程(支持向量机简介)中所做的配置之间只有两个不同之处。

  • C. 我们在这里选择了这个参数的一个小值,以免在优化过程中过多地惩罚错误分类错误。这样做的想法源于获得接近直觉预期的解决方案的意愿。但是,我们建议通过调整此参数来更好地了解问题。

  • 注意: 在这种情况下,类之间的重叠区域中只有很少的点。通过为 FRAC_LINEAR_SEP 赋予较小的值,可以增加点的密度,并深入探索参数 C 的影响。

  • 算法的终止标准。为了正确解决具有非线性可分训练数据的问题,必须大大增加最大迭代次数。特别是,我们将这个值提高了五个数量级。

  • 训练 SVM

我们调用方法 cv::ml::SVM::train 来构建 SVM 模型。请注意,训练过程可能需要很长时间。运行程序时要有耐心。

svm.train(trainData, cv.ml.ROW_SAMPLE, labels)
True

C. 展示决策区域

方法 cv::ml::SVM::predict 用于使用经过训练的 SVM 对输入样本进行分类。 在此示例中,我们使用此方法根据 SVM 所做的预测为空间着色。 换句话说,遍历图像,将其像素解释为笛卡尔平面上的点。 每个点的颜色取决于 SVM 预测的类别; 如果是标签为 1 的类,则为深绿色;如果是标签为 2 的类,则为深蓝色。

green = (0,100,0)
blue = (100,0,0)
for i in range(I.shape[0]):
    for j in range(I.shape[1]):
        sampleMat = np.matrix([[j,i]], dtype=np.float32)
        response = svm.predict(sampleMat)[1]
        if response == 1:
            I[i,j] = green
        elif response == 2:
            I[i,j] = blue

D. 展示训练数据集

方法 cv::circle 用于显示构成训练数据的样本。 标记为 1 的类别的样本以浅绿色显示,而标记为 2 的类别的样本以浅蓝色显示。

thick = -1
# Class 1
for i in range(NTRAINING_SAMPLES):
    px = trainData[i,0]
    py = trainData[i,1]
    cv.circle(I, (int(px), int(py)), 3, (0, 255, 0), thick)
# Class 2
for i in range(NTRAINING_SAMPLES, 2*NTRAINING_SAMPLES):
    px = trainData[i,0]
    py = trainData[i,1]
    cv.circle(I, (int(px), int(py)), 3, (255, 0, 0), thick)

E. 支持向量

我们在这里使用了几种方法来获取有关支持向量的信息。 cv::ml::SVM::getSupportVectors 方法获取所有支持向量。 我们在这里使用这种方法来查找作为支持向量的训练示例并突出显示它们。

thick = 2
sv = svm.getUncompressedSupportVectors()
for i in range(sv.shape[0]):
    cv.circle(I, (int(sv[i,0]), int(sv[i,1])), 6, (128, 128, 128), thick)

5. 结果

download.png

  1. 该代码会打开一个图像并显示两个类的训练示例。 一类的点用浅绿色表示,浅蓝色的点用于另一类。
  2. SVM 被训练并用于对图像的所有像素进行分类。 这导致图像在蓝色区域和绿色区域中的划分。 两个区域之间的边界是分离超平面。 由于训练数据是非线性可分的,可以看出两个类的一些例子都被错误分类了; 一些绿点位于蓝色区域,一些蓝点位于绿色区域。
  3. 最后,支持向量在训练示例周围使用灰色环显示。