使用SVM/k-NN模型实现手写数字多分类 – 清华大学《机器学习实践与应用》22春-周作业

1.1 多分类SVM主要思想

SVM模型处理分类问题建立在使用一个超平面分割两类数据,根据几何位态分别赋予标签%2B1-1以区分不同样本。目前较为通用的基于SVM的多分类方法一般采用集成学习思想,包括:一对一分类(One vs. One, OvO)和一对多分类(One vs. Rest, OvR)。

1.1.1 一对一SVM分类(OvO-SVM)

假设输入数据%5C%7B%28x_i%2Cy_i%29%5C%7D_%7Bi%3D1%7D%5E%7BN%7Dy_i%20%5Cin%20%5C%7B1%2C%5Ccdot%2CM%5C%7D个不同分类。OvO-SVM对任何无序类别组合(a,b)都训练且仅训练一个二分类SVM(共M%28M-1%29/2个子分类器),并求解如下优化问题:

%5Cmin_%7Bw%5E%7B%28ab%29%7D%2Cb%5E%7B%28ab%29%7D%2C%5Cxi%5E%7B%28ab%29%7D%7D%20%5Cfrac%7B1%7D%7B2%7D%7Bw%5E%7B%28ab%29%7D%7D%5ETw%5E%7B%28ab%29%7D%20%2B%20C%20%5Csum_i%5EN%20%5Cxi%5E%7B%28ab%29%7D_i%2C%5C%20s.t.%20%5Cmathbb%20I_%7Ba%7D%5C%7By_i%5C%7D%28%7Bw%5E%7B%28ab%29%7D%7D%5ETx_i%2Bb%5E%7B%28ab%29%7D%29%20%5Cgeq%201%20-%20%5Cxi%5E%7B%28ab%29%7D_i%2C%5C%20%5Cxi%5E%7B%28ab%29%7D_i%20%5Cgeq%200%2C%5C%20%E2%88%80%20i%3D1%2C%5Cdots%2CN

其中,%5Cmathbb%20I_a%5C%7By_i%5C%7D为双极指示函数,当y_i%3Da(正类)时,取值为%2B1,否则当y_i%20%5Cneq%20a(负类,y_i%3Db)时,取值为-1

最后,对于样本标签的预测,我们可以统计所有分类器的预测结果中预测到每类标签中的标签数量,并根据投票策略得到综合结果。

当然也可以先统计每个标签下样本的经典概率P%28Y%3Dm_t%7CX%3Dx_i%29,根据贝叶斯决策求解样本的后验分布:

%5Chat%20y_i%20%3D%20%5Carg%5Cmax_t%20P%28Y%3Dm_t%7CX%3Dx_i%29%20%3D%20%5Carg%5Cmax_t%20%5Cfrac%7BP%28Y%3Dm_t%29P%28X%3Dx_i%20%7C%20Y%3Dm_t%29%7D%7B%5Csum_qP%28Y%3Dm_q%29P%28X%3Dx_i%20%7C%20Y%3Dm_q%29%7D%2C%5C%20m_t%3D1%2C%5Cdots%2CM

本题的代码设计中使用了投票策略。

1.1.2 一对多SVM分类(OvR-SVM)

基本符号定义同1.1.1节。和OvO-SVM相比,OvR-SVM对任何类别a训练一个二分类SVM,并求解:

%5Cmin_%7Bw%5E%7B%28a%29%7D%2Cb%5E%7B%28a%29%7D%2C%5Cxi%5E%7B%28a%29%7D%7D%20%5Cfrac%7B1%7D%7B2%7D%7Bw%5E%7B%28a%29%7D%7D%5ETw%5E%7B%28a%29%7D%20%2B%20C%20%5Csum_i%5EN%20%5Cxi%5E%7B%28a%29%7D_i%2C%5C%20s.t.%20%5Cmathbb%20I_%7Ba%7D%5C%7By_i%5C%7D%28%7Bw%5E%7B%28a%29%7D%7D%5ETx_i%2Bb%5E%7B%28a%29%7D%29%20%5Cgeq%201%20-%20%5Cxi%5E%7B%28a%29%7D_i%2C%5C%20%5Cxi%5E%7Ba%7D_i%20%5Cgeq%200%2C%5C%20%E2%88%80%20i%3D1%2C%5Cdots%2CN

此时,通过预测每个类别标签对应的分类器上的样本,可以得到N分类结果。

  • 理想情况下,只有一个分类器应该预测一个正类,其余的是负类。此时样本标签就是正类对应的标签。
  • 如果有多个标签被预测为正例,可以从中选择置信最高的那个类别,其置信可以从其数值强度/到超平面距离来判断,如预测类别a为%2B1.37,而预测类别b%2B3.5。那么显然预测为b是更加合理的选择。
  • 如果没有类别被预测为正例,则意味着该样本不被判断为任何类别。这时候仍然可以考虑置信度的想法,计算每个类别的置信度负分,选择惩罚最小的类别作为当前预测的正例标签。

本题代码设计只处理理想情况的逻辑,对异常预测结果随机选择预测类别。

1.2 实验设计及伪代码

1.2.1 实验目的概述

  1. 实现多分类SVM的主要逻辑,包括OvR-SVM和OvO-SVM.
  2. 比较k-NN和SVM模型在分类任务上的7项指标。
  3. 型号参数选择:
  4. k-NN:测试k=1,3,10时对应的模型表现
  5. SVM:测试线性核、RBF核(0.1,5,10,50,100)
  6. 测试指标
  7. 训练时间(s)
  8. 训练过程占用内存(MB)
  9. 训练集上的分类准确率(%)
  10. 测试时间(s)
  11. 测试过程占用内存(MB)
  12. 测试集上的分类准确率 (%)
  13. 模型参数规模(MB)

1.2.2 实验模型整体设计

我们使用软件设计模式中的UML类图描述实验中模型的封装与组织关系,如下。

使用SVM/k-NN模型实现手写数字多分类 - 清华大学《机器学习实践与应用》22春-周作业

在,

  • ClsModel定义为分类模型的抽象超类,描述分类模型具备的基本逻辑接口,并支持对参数规模统计。
  • KNN:向下封装课程脚本KNN.py相关逻辑,向上提供统一的分类接口实现。
  • SVM:向下封装课程脚本svmMLiA.py相关逻辑,向上提供统一的分类接口实现。
  • MultiSVM:基于SVM构造的多分类SVM抽象类,内部动态构造多个子SVM实体,描述子分类器构造规则(gen_keys)和类别判断规则(sel_pos)的抽象接口。
  • OVRSVM:实现一对多SVM分类的实体类
  • OVOSVM:实现一对一SVM分类的实体类

1.2.3 多分类伪代码及解释

根据上节对功能模块的职责划分,我们进一步解释对MultiSVM、OVRSVM、OVOSVM的伪代码实现逻辑。

  • MultiSVM
def fit(data,labels):
  svm_dict = {}
  for key,complete_data in gen_key(data,labels):
    shuffle complete_data.
    construct svm by complete data.
    svm_dict[k] = svm
def predict(data):
  votes = []
  for each label in label_set:
    for key,inferred_label in sel_pos(label):
      svm_dict[k].predict(data)
      prediction is positive or negative? choose the corresponding inferred label.
      append the chosen label to votes
  finally predict by votes

其中,fit方法根据gen_key确定的分类器的输入参数(包括某一策略下标志键和对应的完整数据),打乱数据,并执行SVM的训练。predict方法对每种标签可能性遍历,并根据sel_pos方法选择的每类标签能够充分预测所需的标志键key和基于预测结果最终应当被推断的标签二元组inferred_label(即positive class和negative class对应的标签构成的二元组),选择对应的子SVM执行预测,并将结果投入到votes中去。最后基于全体投票得出准确的预测标签。

基于上述抽象的多分类框架设计,考虑OvR和OVO具体策略的差异,分别实现对应的gen_key和sel_pos。

  • OVRSVM
def gen_keys(data,labels):
	for each label in label_set:
    assign label to +1, other labels to -1
  	yield label, transformed data
def sel_pos(label):
  return [(label,(label,None))]

根据OVRSVM的思路,gen_key需要给出每个标签作为正例,其余标签作为负例的M中情形,并对完整数据进行双极性标注返回。

sel_pos则只赋予正类对应真实的标签,负类不执行标签推断。

  • OVOSVM
def _gen_keys(data,labels):
  for (label_a,label_b) in label_set:
    assign label_a to +1, label_b to -1
  	yield 'label_a - label_b', transformed data
def sel_pos(label):
  return ['label - label_x',(label,label_x) for x in other_labels]

OVOSVM在gen_key中对每一个无序的标签二元组(label_a,label_b)进行标注,其中label_a及其对应数据标注为正类,label_b为负类

sel_pos则分别对应正负类都推断真实的标签a和b。

1.2.4 完整代码实现

本实验的完整模型实现如下。

'''
implemented classes of classifiers.
'''
class ClsModel:
    def __init__(self,model_name='anonymous',num_cls=10):
        self.model_name = model_name
        self.num_cls = num_cls
        self.param_lst = []

    def fit(self,data,labels):
        '''
            fit cls model with train data.
        :param data: ndarray[n × m], n - samples, m - dimension
        :param labels: ndarray[1 × m], m - dimension
        :return:
        '''
        raise NotImplementedError

    def predict(self,data):
        '''
            predict test samples of their labels.
        :param data: ndarray[n × m], n - samples, m - dimension
        :return: ndarray[1 × m] - labels
        '''
        raise NotImplementedError

    def get_size_of(self):
        return sum([sys.getsizeof(ele) for ele in self.param_lst])

    def __str__(self):
        return 'ClsModel-' + self.model_name if self.model_name == 'anonymous' else 'ClsModel'

class KNN(ClsModel):
    def __init__(self,k=3,**kwargs):
        super(KNN, self).__init__(**kwargs)
        self.k = k

    def fit(self,data,labels):
        self.data = copy.deepcopy(data) # copy since we require data NOT be destroyed, mem anal as well.
        self.labels = copy.deepcopy(labels)
        self.param_lst.extend([self.data,self.labels])

    def predict(self,data):
        return np.array([m_knn.classify0(inX=row_data,dataSet=self.data,labels=self.labels,k=self.k) for row_data in data])

    def __str__(self):
        return 'KNN-' + self.model_name

class SVM(ClsModel):
    def __init__(self,C=200,toler=1e-4,maxIter=40,kTup = ('lin',0),**kwargs):
        super(SVM, self).__init__(**kwargs)
        self.C = C
        self.toler =toler
        self.maxIter = maxIter
        self.kTup = kTup

        self.param_lst.extend([self.C,self.toler,self.maxIter,self.kTup])

    def fit(self,data,labels):
        self.b, alphas = m_svm.smoPK(data, labels, self.C, self.toler, self.maxIter, self.kTup)
        datMat = np.mat(data)
        labelMat = np.mat(labels).transpose()
        svInd = np.nonzero(alphas.A > 0)[0]
        self.sVs = datMat[svInd]
        self.labelSV = labelMat[svInd]
        self.alphas = alphas[svInd]

        self.param_lst.extend([self.b,self.alphas,self.sVs,self.labelSV])

    def predict(self,data):
        return np.array([m_svm.kernelTrans(self.sVs, row_data, self.kTup).T * np.multiply(self.labelSV,self.alphas) + self.b for row_data in np.mat(data)])


    def __str__(self):
        return 'SVM-' + self.model_name if self.model_name == 'anonymous' else 'SVM'

class MultiSVM(SVM):
    def __init__(self,**kwargs):
        super(MultiSVM, self).__init__(**kwargs)
        self.param_dict = kwargs
        self.svm_dict = {}
    def fit(self,data,labels):
        # init sub-SVM model.
        for k,cdata in self._gen_keys(data,labels):
            rnd_idx = list(range(cdata.shape[0]))
            random.shuffle(rnd_idx)
            svm = SVM(**self.param_dict)
            svm.fit(cdata[rnd_idx][:,:-1],cdata[rnd_idx][:,-1].reshape(-1))
            self.svm_dict[k] = svm

    def predict(self,data):
        preds = []
        for row_data in data:
            lb2cnt = {}
            max_lb = None
            max_cnt = 0
            for label in range(self.num_cls):
                for k,inf_lb in zip(*self._sel_pos(label)):
                    pred = self.svm_dict[k].predict(row_data.reshape(1, -1))
                    pred = 0 if pred > 0 else 1 # lb2idx.
                    if inf_lb[pred] is not None:
                        lb2cnt[inf_lb[pred]] = lb2cnt.get(inf_lb[pred],0) + 1
                        if lb2cnt[inf_lb[pred]] > max_cnt:
                            max_cnt = lb2cnt[inf_lb[pred]]
                            max_lb = inf_lb[pred]
            # assert max_lb is not None
            preds.append(max_lb if max_lb is not None else random.choice(range(self.num_cls)))
        return preds

    def _gen_keys(self,data,labels):
        raise NotImplementedError

    def _sel_pos(self,label):
        raise NotImplementedError

    def get_size_of(self):
        return sum([v.get_size_of() for k,v in self.svm_dict.items()])

    def __str__(self):
        return 'MultiSVM-' + self.model_name if self.model_name == 'anonymous' else 'MultiSVM'

class OVRSVM(MultiSVM):
    def _gen_keys(self,data,labels):
        cdata = np.concatenate([data, labels.reshape(-1, 1)], axis=1)
        for pos_cls in range(self.num_cls):
            pos_data = cdata[cdata[:, -1] == pos_cls]
            neg_data = cdata[cdata[:, -1] != pos_cls]
            pos_data[:, -1] = 1
            neg_data[:, -1] = -1
            yield pos_cls, np.concatenate([pos_data, neg_data], axis=0)

    def _sel_pos(self,label):
        return [label],[(label,None)]

    def __str__(self):
        return 'OVRSVM-' + self.model_name if self.model_name == 'anonymous' else 'OVRSVM'


class OVOSVM(MultiSVM):
    def _gen_keys(self, data, labels):
        cdata = np.concatenate([data, labels.reshape(-1, 1)], axis=1)
        for pos_cls in range(self.num_cls):
            for neg_cls in range(pos_cls+1,self.num_cls):
                pos_data = cdata[cdata[:, -1] == pos_cls]
                neg_data = cdata[cdata[:, -1] == neg_cls]
                pos_data[:, -1] = 1
                neg_data[:, -1] = -1
                yield '{}-{}'.format(pos_cls,neg_cls), np.concatenate([pos_data, neg_data], axis=0)

    def _sel_pos(self, label):
        return ['{}-{}'.format(label,num) for num in range(label+1,self.num_cls)], [(label,num) for num in range(label+1,self.num_cls)]

    def __str__(self):
        return 'OVOSVM-' + self.model_name if self.model_name == 'anonymous' else 'OVOSVM'

1.3 测试结果及分析

实验中,我们对1.2.1节中提及的7个实验指标和11种实验模型进行完全测试,测试代码逻辑如下。

'''
eval routine.
'''
def eval_one_stop(X_train,y_train,X_test,y_test,cls_model):
    train_time = 0.
    test_time = 0.
    train_mem = 0.
    test_mem = 0.
    train_acc = 0.
    test_acc = 0.

    # train.
    train_mem = sum(memory_profiler.memory_usage())
    train_time = time.time()
    cls_model.fit(X_train,y_train)
    train_time = time.time() - train_time
    train_mem = sum(memory_profiler.memory_usage()) - train_mem
    train_acc = np.sum(cls_model.predict(X_train) == y_train) / X_train.shape[0]

    # test.
    test_mem = sum(memory_profiler.memory_usage())
    test_time = time.time()
    test_acc = np.sum(cls_model.predict(X_test) == y_test) / X_test.shape[0]
    test_time = time.time() - test_time
    test_mem = sum(memory_profiler.memory_usage()) - test_mem

    return {
        'train_time':train_time,
        'train_mem':train_mem if train_mem > 0 else 0.,
        'train_acc':train_acc,
        'test_time':test_time,
        'test_mem':test_mem if test_mem > 0 else 0.,
        'test_acc':test_acc,
        'storage':cls_model.get_size_of() / 1024 / 1024,
    }

'''
routine.
'''
def routine1():
    cls_knn_k1 = KNN(k=1)
    cls_knn_k3 = KNN(k=3)
    cls_knn_k10 = KNN(k=10)
    cls_knn_k20 = KNN(k=20)

    cls_svm_ovr_lin = OVRSVM(num_cls=10,kTup=('lin',0))
    cls_svm_ovr_rbf01 = OVRSVM(num_cls=10, kTup=('rbf', 0.1))
    cls_svm_ovr_rbf5 = OVRSVM(num_cls=10, kTup=('rbf', 5))
    cls_svm_ovr_rbf10 = OVRSVM(num_cls=10, kTup=('rbf', 10))
    cls_svm_ovr_rbf50 = OVRSVM(num_cls=10, kTup=('rbf', 50))
    cls_svm_ovr_rbf100 = OVRSVM(num_cls=10, kTup=('rbf', 100))

    cls_svm_ovo_lin = OVOSVM(num_cls=10,kTup=('lin',0))
    cls_svm_ovo_rbf01 = OVOSVM(num_cls=10, kTup=('rbf', 0.1))
    cls_svm_ovo_rbf5 = OVOSVM(num_cls=10, kTup=('rbf', 5))
    cls_svm_ovo_rbf10 = OVOSVM(num_cls=10, kTup=('rbf', 10))
    cls_svm_ovo_rbf50 = OVOSVM(num_cls=10, kTup=('rbf', 50))
    cls_svm_ovo_rbf100 = OVOSVM(num_cls=10, kTup=('rbf', 100))

    cls_models = [cls_knn_k1,cls_knn_k3,cls_knn_k10,cls_knn_k20,cls_svm_ovr_lin,cls_svm_ovo_lin,cls_svm_ovr_rbf01,cls_svm_ovo_rbf01,cls_svm_ovr_rbf5,cls_svm_ovo_rbf5,cls_svm_ovr_rbf10,cls_svm_ovo_rbf10,cls_svm_ovr_rbf50,cls_svm_ovo_rbf50,cls_svm_ovr_rbf100,cls_svm_ovo_rbf100]
    cls_model_names = ['KNN@1','KNN@3','KNN@10','KNN@20','OVR-SVM-lin','OVO-SVM-lin','OVR-SVM-rbf0.1','OVO-SVM-rbf0.1','OVR-SVM-rbf5','OVO-SVM-rbf5','OVR-SVM-rbf10','OVO-SVM-rbf10','OVR-SVM-rbf50','OVO-SVM-rbf50','OVR-SVM-rbf100','OVO-SVM-rbf100']

    metrics = ['train_time','train_mem','train_acc','test_time','test_mem','test_acc','storage']
    X_train, y_train, X_test, y_test = load_and_split()
    pddata = []
    for idx,(cls_model,cls_model_name) in enumerate(zip(cls_models,cls_model_names)):
        res_dict = eval_one_stop(X_train,y_train,X_test,y_test,cls_model)
        pddata.append([res_dict[k] for k in metrics]) # ordered stream.
        print('eval {} completed.'.format(cls_model))

        # display&save after one completion.
        anal_table = pd.DataFrame(data=np.array(pddata),columns=metrics,index=cls_model_names[:idx+1])
        print(anal_table)
        anal_table.to_csv('./fig/anal-all.csv')

相应的实验结果如下。
使用SVM/k-NN模型实现手写数字多分类 - 清华大学《机器学习实践与应用》22春-周作业
从以上实验数据可以得出各种结论,总结如下。

  • 训练时间上看,KNN方法具有极低的时间复杂度,这是因为其训练过程只是简单存储训练数据而没有显著的计算过程,相反,SVM方法则需要O%28N%5E2%29的复杂度对输入数据遍历,并优化寻找其支持向量,RBF核的SVM由于还涉及数据矩阵的空间映射,耗时更长。此外,OvR方法训练耗时都普遍长于OvO方法,因为前者训练过程每个分类器都需要全部数据进行预测,需要执行N%5E2次计算,而OvO则仅执行成对的正负例训练,且总执行次数为N%28N-1%29/2次。
  • 训练内存和测试内存上看,由于python内存申请的特性,导致各类方法的内存测定并不准确,但可以注意到KNN在训练和测试过程中几乎没有明显创建新的缓冲区,可以直接在数据空间上比较确定预测标签,效果较好,而采用OvO或OvR的SVM往往需要借助新的空间申请,并且新空间规模随模型参数变化。同时也可以看出,OvR虽然仅训练N个分类器却调用了更多的辅助空间,说明其空间占用更多来自于因完整数据规模较大造成特征向量规模成比例的增长。
  • 训练和测试正确率上看,首先KNN方法表现总体优于SVM方法,因为相同的数字在书写像素格上往往具有较大面积的公有区域,使得简单的逐像素对比具有明显优势。同时,从参数上看,随着参数k的增加,训练集表现有所下降,但测试集表现先上升后下降,说明了模型从欠拟合到过拟合的变化过程。此外,对于SVM的线性核与RBF核对比上可以看出,RBF的精度表现普遍更高,说明数据在一定程度上是线性不可分的,手写数字的空间分布特征造成以径向基方式定义相关性会有利于不同数字样本的聚类特性。最后,对比OvO和OvR可以发现,后者的正确率表现往往不及前者,因为其存在类别不平衡问题,负例样本过多使得真实标签往往很难被正确预测出来。
  • 测试时间上看,KNN具有相对稳定且较长的测试耗时,这与其较快的训练时间形成鲜明对比,而线性SVM由于前期已经对支持向量进行了筛选,该过程时间有明显下降,但另一方面,采用RBF核具有更坏的时间表现,说明主要的耗时更多发生在数据空间的映射上,这在需要训练O%28N%EF%BD%9EN%5E2%29个分类器的多分类场景下表现更加突出。
  • 模型参数规模上,KNN需要保存所有原始训练数据,占用参数最多,多分类SVM虽然需要保存所有子分类器的所有支持向量相关参数,但总体上参数规模依然相比KNN下降了2-3个量级。此外,OvR由于仅训练了N个分类器,参数规模相比OvO下降了近78%。

1.4 其他实验体会

  1. 良好的封装和代码规范。本实验中涉及较多的模型定义和逻辑调用,通过良好封装和抽象的类层次可以有效提高代码的可读性,减小冗余逻辑,便于错误调试和面向切面(AOP)的逻辑扩充。
  2. 基于toy data的预部署。本次实验耗时相对较长(上述完整数据在个人PC机跑完需要10h+),首先对在小规模数据上调试全部代码流程无误对实验效率很有帮助。例如实验中可以先设定num_cls=3仅对(0,1,2)三个数字的10个样本进行完整evaluation逻辑的执行,并通过断点调试和程序插桩对代码中可能存在的内存异常和逻辑错误及时纠正。

文章出处登录后可见!

已经登录?立即刷新

共计人评分,平均

到目前为止还没有投票!成为第一位评论此文章。

(0)
青葱年少的头像青葱年少普通用户
上一篇 2022年3月23日 下午8:22
下一篇 2022年3月23日 下午8:42

相关推荐