本次实验使用SVM对手写数字进行分类。代码使用Python3,代码依赖numpy库。实验数据在digits文件夹下。
- 涉及的算法包括:
- 实现简化版SMO算法
- 实现完整版SMO算法
- 对SMO加入核方法
本次实验中实现的SVM是二分类器,而手写数字分类是多分类问题(10分类)。因此,需要把多分类问题转化为二分类问题。本次实验中,实行的是 one-to-one 策略。具体而言,把10分类问题分成
设核函数为
原最优化问题为(其中C是一个大于0的超参)
其拉格朗日对偶形式的最优化问题是
原始约束问题是凸的二次规划问题,解满足KKT条件,即:
假设预测函数为$h(x)$, 则
所以可以不用计算
根据上面式子,我们可以得到KKT的等价条件(只有变量$a$和$b$的情况)。
而一旦已知所有的
说明,上面所有的
原问题转化为,结合KKT的等价条件,求解最优化问题:
该问题由SMO算法求解。SMO算法为一种启发算法,每次迭代过程中,选择两个变量
不失一般性,我们设所选定的两个变量为$a_1, a_2$。设
由于 $ y_1 a_1 + y_2 a_2 = - \sum_{i=3}^N y_i a_i$ 为一固定的值, 当 $ a_2 $ 确定时, $ a_1 $ 也确定了。所以目标函数是关于
每次迭代过程中,新的
然后按照等式 $y_1 a_1^{new} + y_2 a_2^{new} = y_1 a_1^{old} + y_2 a_2^{old} $ 更新
根据条件
当$a_1^{new}, a_2^{new}$ 中只有一个
当$a_1^{new}, a_2^{new}$ 都不满足$0 < a_i < C $时, 令
另外值得注意的是,公式可能出现 $ 2K(x_1, x_2) - K(x_1, x_1) - K(x_2, x_2)$ 为0的情况,这种情况下,$a_2$的迭代公式无意义,这时我们不更新变量,直接进入下一步迭代。
以上SMO算法中每一步的操作。
SMO算法每次选取两个变量
简单版SMO算法, 外循环从所有的
外循环先在序列
内循环选取使得
- 若
config.py的is_max_e_rnd为True,则从这些$a_{k}$ 中随机选取一个,作为$a_{k_2}$ 。 - 若
config.py的is_max_e_rnd为False, 则从这些$a_{k}$ 中选取下标最小的作为$a_{k_2}$。
进入到项目文件夹下,输入指令python main.py即可执行代码。代码共有config.py, load_data.py, my_svm.py, 和 main.py。
-
load_data.py主要负责从文件中读取数据。 -
my_svm.py是SVM分类器的实现(使用SMO算法)。其中inner_l()函数是实现SMO算法内循环的函数。smo()函数是实现完整版SMO算法外循环的函数。sim_smo()函数是实现简单版SMO算法的外循环。select_j()函数用于完整版SMO算法中选择第二个自有变量,其具体的选择策略前面已经介绍过了。predict()函数用于预测新样本(或者说测试样本)的类别。代码中使用一个矩阵维持着经过核函数计算的值,这样做有两个好处(1)方便调用numpy库,加速运算。(2)缓存了$K(x_i, x_j)$ 的值,避免了训练过程中的重复计算。 -
main.py把手写字识别的多分类问题转换为二分类问题,使用了 one-to-one 的策略。把一个10分类任务分成了45个二分类。然后综合45个二分类的结果,判断测试样本属于哪个类别。具体来说就是,45个分类结果中,属于哪个类别分类结果最多,测试数据就是哪个类。 -
config.py是配置文件。其中:-
is_simple = True,则代表使用简化版的SMO算法,is_simple=False则代表使用完整版的SMO算法。 -
C代表参数C的值。 -
k_tup代表核函数及其参数。k_tup是一个元组Tuple。k_tup[0]是字符串类型,代表核函数的类型。若k_tup[0] == 'lin'或k_tup[0] == 'line',代表不使用核函数。这时核函数的参数k_tup[1]没有被使用(因为这时候没有所谓的核函数的参数)。若k_tup[0]=='rbf',则使用径向基核函数,这时第二个参数k_tup[0]代表参数$\gamma$ , 其中$K(x_i, x_j) = exp(-\gamma || x_i - x_j ||^2)$。 -
max_iter代表外循环的最大次数。 -
log_level代表程序运行过程中输出信息的程度,log_level越大,输出的信息越多。当log_level== 0时,程序只输出运行结果。
-
下图是 log_level == 0时, 程序的运行结果。
关于实验过程,为了方便实验,我又写了一个脚本,遍历不同的参数组合下的程序运行时间、以及错误率(网状遍历)。
其关键部分代码如下(其中run()函数代表执行一种参数组合的实验):
t1 = time.time()
Cs = [1e-1, 1, 10]
gammas = [1e-3, 1e-2, 1e-1, 1, 10]
is_simples = [False, True]
# lin
for is_simple in is_simples:
for C in Cs:
# line
k_tup = ('lin', 1)
run(C, k_tup, is_simple)
# rbf
for gamma in gammas:
k_tup = ('rbf', gamma)
run(C, k_tup, is_simple)
t2 = time.time()
print('total time %.3fs' % (t2 - t1))实验电脑系统为Ubuntu 16.04, CPU为 8 Intel(R) Core(TM) i7-4820K CPU @ 3.70GHz,内存为32G。在项目文件夹下,执行python experiment.py即可进行实验。跑完所有$ 3 * 5 * 2 = 30$种参数组合所用的时间为7574.387,实验结果的文件expirement_result.txt随代码附上。
下面是使用 rbf 核函数
| C | 0.1 | 1 | 10 | 0.1 | 1 | 10 |
|---|---|---|---|---|---|---|
| train time | 59.946 | 71.454 | 43.671 | 29.333 | 38.599 | 22.469 |
| train set error | 0.032 | 0.001 | 0.0 | 0.038 | 0.023 | 0.0 |
| test set error | 0.049 | 0.10 | 0.008 | 0.067 | 0.045 | 0.014 |
| sim | sim | sim | complete | complete | somplete |
可以看出,完整版SMO速度更快,但错误率更高。
下面表格中,每个单元格,逗号左边代表训练集错误率,逗号右边代表测试集错误率。可以看出,当
| C\gamma | 0.001 | 0.01 | 0.1 | 1 | 10 |
|---|---|---|---|---|---|
| 0.1 | 0.157,0.159 | 0.038,0.067 | 0.0,0.753 | 0.001,0.220 | 0.025,0.587 |
| 1 | 0.161,0.153 | 0.023,0.045 | 0.0,0.889 | 0.0,0.504 | 0.024, 0.747 |
| 10 | 0.043,0.064 | 0.0,0.014 | 0.0,0.882 | 0.0, 0.905 | 0.0, 0.905 |
