文本分类学习 (十)构造机器学习Libsvm 的C# wrapper(调用c/c++动态链接库)

前言: 对于SVM的了解,看前辈写的博客加上读论文对于SVM的皮毛知识总算有点了解,比如线性分类器,和求凸二次规划中用到的高等数学知识。然而SVM最核心的地方应该在于核函数和求关于α函数的极值的方法:SMO算法(当然还有很多别的算法。libsvm使用的是SMO,SMO算法也是最高效和简单的),还有松弛变量。。毕设答辩在即,这两个难点只能拖到后面慢慢去研究了。 

   于是我便是用了LibSvm,也就是台湾大学某某教授写的一个专门用于svm的工具包,其中有java语言的,python语言的,c语言的。我只拿了其中的两个文件svm.cpp 和svm.h ,这两个c语言的头文件和源文件已经可以直接拿来训练模型和预判分类了。这篇博客也只是照葫芦画瓢,利用已经写好的libsvm,做一个基于.net core的api接口,对于libsvm的内部实现都不甚了解。所在我是站在巨人的肩膀上学习,什么都是现成的。如果需要你自己去开发和创新,那么就意味着你已经站在芸芸众生的上面了。

 

目录: 

 

一,LibSvm的简单介绍

这里只介绍libSvm中的C语言版本,也就是前言中说的svm.cpp和svm.h。 

1.结构体介绍

svm.h 文件包含了svm中所有的结构体和函数声明。

首先是 结构体svm_node

struct svm_node
{
       int index;
       double value
};

 svm_node 是用来储存单个文本向量的单个特征,结构体只有两个属性一个是下标,一个是值。很显然如果一个文本向量的表示肯定是一个svm_node[] 数组。值得注意的是libsvm中,对于特征值为0,也就是value为0的特征,可以不用放到svm_node[]数组里这样会简化计算。此外,svm_node[]数组的最后一个元素index的值必须是-1且value值为null,是一个文本向量的结束标志。


然后是 结构体svm_problem

struct svm_problem
{
int l;
double *y;
struct svm_node **x; 
};

前面的svm_node是表示单个文本向量,那么svm_problem便表示的是整个训练集了。其中l是训练集的个数,y是一个数组表示训练集的标签,x是一个二维数组自然表示训练集的文本向量。注意在二分类问题中y数组的值应该是+1或者-1。

接下来是 结构体svm_parameter

struct svm_parameter
{
    int svm_type;
    int kernel_type;
    int degree;    /* for poly */
    double gamma;    /* for poly/rbf/sigmoid */
    double coef0;    /* for poly/sigmoid */

    /* these are for training only */
    double cache_size; /* in MB */
    double eps;    /* stopping criteria */
    double C;    /* for C_SVC, EPSILON_SVR and NU_SVR */
    int nr_weight;        /* for C_SVC */
    int *weight_label;    /* for C_SVC */
    double* weight;        /* for C_SVC */
    double nu;    /* for NU_SVC, ONE_CLASS, and NU_SVR */
    double p;    /* for EPSILON_SVR */
    int shrinking;    /* use the shrinking heuristics */
    int probability; /* do probability estimates */
};

这个结构表示的是svm分类器的参数,介绍几个重要的参数:

svm_type是选用的svm类型有:{ C_SVC, NU_SVC, ONE_CLASS, EPSILON_SVR, NU_SVR } ,对于二分类选择C_SVC

kernel_type 就是大名鼎鼎的核函数,类型有 { LINEAR, POLY, RBF, SIGMOID } 对于二分类可以选择LINEAR 或者RBF 。根据作者的描述,一个效果十分优秀的svm分类器应该是选择RBF核函数,或者叫做高斯核函数。至于原因呢,那就要研究关于RBF核函数映射到高维空间的问题。选择RBF核函数然后交叉验证选择最优的C和 gamma参数。 我选择的RBF核函数,也在不断调整gamma参数来达到最优的效果,后面再提吧。

C 惩罚因子 就是松弛变量,越大表示你越关心分错的点,如果C选的越大,那么对于svm来说就需要更多的时间去不断迭代寻找一个几乎不会误判训练集的分类器(因为你很关心分错的点)。这样训练的时间会很长。而如果你的训练集不是那么纯的(就是有些许误差啥的)所以C不宜选大。我选择的是35.

gammer RBF核函数宽度参数 此参数和C十分重要,需要你去不断的调试更改。一般来说gammer参数应该选择比较小的参数。有些博客说gammer参数默认是1/类别数 。二分类就是0.5 。但是选择了0.5你会发现训练出来的分类器如同一个智障一般。在我的测试发现,gammer越小分类器的准确率越高,然而它也有一个下限,超过了这个下限,分类器也是如同一个智障一般。我最终选择的是0.00001。

最后是 结构体 svm_model

struct svm_model
{
    struct svm_parameter param;    /* parameter */
    int nr_class;        /* number of classes, = 2 in regression/one class svm */
    int l;            /* total #SV */
    struct svm_node **SV;        /* SVs (SV[l]) */
    double **sv_coef;    /* coefficients for SVs in decision functions (sv_coef[k-1][l]) */
    double *rho;        /* constants in decision functions (rho[k*(k-1)/2]) */
    double *probA;        /* pariwise probability information */
    double *probB;
    int *sv_indices;        /* sv_indices[0,...,nSV-1] are values in [1,...,num_traning_data] to indicate SVs in the training set */

    /* for classification only */

    int *label;        /* label of each class (label[k]) */
    int *nSV;        /* number of SVs for each class (nSV[k]) */
                /* nSV[0] + nSV[1] + ... + nSV[k-1] = l */
    /* XXX */
    int free_sv;        /* 1 if svm_model is created by svm_load_model*/
                /* 0 if svm_model is created by svm_train */
};

svm_model 就是我们千呼万唤出来的分类器,这里只需要介绍一个重要的属性:

struct svm_node **SV  这就是支持向量,支持向量机中的支持向量 是它们帮我们撑出来一个分类超平面,这就是向量机的分类器。

 

2.函数介绍

这里仅仅介绍常用的五个函数,这些函数已经足够做出来一个垃圾识别文章的接口了。

struct svm_model *svm_train(const struct svm_problem *prob, const struct svm_parameter *param);

训练函数,传入参数是上面说过的svm_problem ,svm_parameter 得到的是一个分类器svm_model。

void svm_cross_validation(const struct svm_problem *prob, const struct svm_parameter *param, int nr_fold, double *target);

交叉验证函数,其中nr_fold 是交叉验证的折数。稍微提一下交叉验证,比如nr_fold=10 ,表示10折交叉验证。那么怎么做的呢?就是将训练集分成10份,9份作为真正的训练集去训练,剩下的一份作为测试集去验证效果如何。10折就是循环10次,每次都选一份(每次都不同的)作为测试集,剩下的作为训练集。

int svm_save_model(const char *model_file_name, const struct svm_model *model);

将训练出来的分类器,写到文件中文件名:model_file_name。是保存分类器的函数

struct svm_model *svm_load_model(const char *model_file_name);

顾名思义就是加载分类器

double svm_predict(const struct svm_model *model, const struct svm_node *x);

这才是我们最终需要的函数,预测函数,给定一个svm_node数组(代表普通的一个文本向量),svm会给出它的预测分类,对于二分类:+1或者-1。

 

 

 

二,构造main.cpp


有了svm.cpp 和svm.h 那我们就可以自己写一个控制台程序,去实现一个svm垃圾分类器程序。svm这么难的机器学习算法,但是站在巨人的肩膀上你会发现使用它是很简单的。更不用说现在微软发布了ML.NET 使得你可以随心所欲使用各种各样的机器学习算法。

我首先构造了自己的结构体,叫做MySvm ,对libsvm中的函数进行了又一次的封装,并且考虑到实际的训练集会放到一文件夹中,并且有各种的文件读写操作。我又额外构造了处理文件的结构体:FileHandle。这些结构体十分的简单和原始,如果有错误或者改进的地方,欢迎在评论区指出。

MySvm:

class MySvm
{
public:
    MySvm(){};
    ~MySvm(){};
    void train(std::string modelFileName);
    double predic(std::string targetFileName);
    void setParam();
    void setProb();
    void setFileName(string fileName);
    void setModel(char* modelName);
    svm_parameter* getParam(){return param;}
    svm_model* getModel(){return model;}
    svm_problem* readTrainData(std::string modelFileName);
    svm_node* readSingleData(string modelFileName);
    svm_node* readSingleDataFromText(string text);

private:
    svm_problem* prob;
    svm_parameter* param;
    svm_model* model;
    string fileName;
};

文件处理结构体:

class FileHandle
{
public:
    FileHandle(){};
    ~FileHandle(){};

    vector<string> file;
    void read();
    void setFileName(string FileName);

private:
    string FileName;
    struct dirent *ptr;
};

 

接下来,有必要把MySvm中的SetParam()函数贴出来。因为对于一个新手来说,参数的选择真的有点像无头苍蝇。我贴出来只是针对二分类问题做一个参考。毕竟每一个人的训练集都是不一样的,样本的特征分布也不一样。

    param->svm_type = C_SVC;
    param->kernel_type = RBF;
    param->degree = 3;
    param->gamma = 0.00001; /* 1/num_features */
    param->coef0 = 0;
    param->nu = 0.5;
    param->cache_size = 100;
    param->C = 32;
    param->eps = 1e-3;
    param->p = 0.1;
    param->shrinking = 1;
    param->probability = 0;
    param->nr_weight = 0;
    param->weight_label = NULL;
    param->weight = NULL;

于是我们可以获取训练集,训练分类器了 main函数的部分。

MySvm svm = MySvm();
    svm_problem* s;

s =svm.readTrainData("xxxx");
    
svm.setParam();

  model = svm_train(s, svm.getParam());


 svm_save_model("/xxxx/Model.txt",model);*/


经过训练,发现svm的分类器果然不是名不虚传。如果你选择了合适的C和gamme参数那么svm不会让你失望的。别的代码我就不贴出来了。因为没什么技术含量的东西,也不是这篇文章的主要内容。

经过控制台程序的测试,已经具备了分类测试的功能。那么接下来基于c,c++的程序来做一个C#的wrapper。

 

 

三,构造C#Wrapper


前面的c++程序,已经实现了读取训练集,训练分类器。加载分类器,预测类型。但是我想做的是一个API接口,一开始想用c++做一个web API. 但是想到团队里都是用.net 写网站和接口。所以只能放弃了。使用基于.net core2.0的web API 程序,然后调用c++的dll,便成为我的思路了。

大家都知道由于.net core2.0是跨平台的,所以.net 网站已经开始在Linux上跑起来了。我的亦不例外。

1. Linux 的c/c++ 动态链接库生成

Linux上的c++ 的动态链接库是.so 文件,而在Windows上的是.dll文件。生成.dll 文件很简单,你可以使用visual studio 来做(其中有些坑就不说了)。在Linux中生成.so 文件有什么工具呢?你当然可以用Xcode,或者Clion,但是在Mac下生成的是.dylib 文件,这是Mac下的动态链接库文件,不是我想要的。事实上Linux中生成.so很简单,因我们可以使用神器Cmake。

Cmake的定义:CMake is an open-source, cross-platform family of tools designed to build, test and package software

在Linux中先下载Cmake : apt-get install Cmake

然后将写好的svm.h , svm.cpp MySvm.h MySvm.cpp (将之前的main.cpp分成了MySvm.h 和MySvm.cpp),放到某个文件夹里,比如Svm/

然后编写CmakeLists.txt 这是Cmake 执行命令的文本,如下所示:

cmake_minimum_required(VERSION 3.5.1)

project(MySvm)

 

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11")

SET(detour_SRCS svm.cpp MySvm.cpp)

SET(detour_HDRS svm.h MySvm.h)

 

ADD_LIBRARY(MySvm SHARED ${detour_SRCS} ${detour_HDRS})

接下来执行 (仅仅第一次需要执行)

$ Cmake ..

你会发现文件夹里多了CmakeFiles/  CMakeCache.txt  cmake_install.cmake

接下来执行

$ make

于是千呼万唤始出来的libsvm.so 文件变跳出来了。它就是上面所说c/c++程序的动态链接库,可以在C#程序里直接调用的。整个过程没有什么坑点。

你可以执行

$ nm -D libsvm.so

查看这个动态链接库提供了哪些函数。于是坑点来了。发现c++里写的函数都会被换一个名字,而c语言写的函数都是正常的。那是因为c++支持函数名重载,所以编译器会根据自己的规则对函数名进行篡改,防止命名发生冲突。所以在调用函数的时候,会出现找到不该函数的错误,把那个长长的函数名复制进去把。或者在c++编写的函数前面加上_stdcall

 

2.C#调用c/c++的动态链接库

这个十分简单,但是也会有坑点!使用c#的 dllimport

    [DllImport("/svm/libMySvm.so")]
        public static extern double predic(string text)

坑点1:关于C#传入到c/c++函数的string参数问题

在c/c++程序中函数使用的参数是char *,那么在C#用什么参数对应呢?

 

C++数据类型

C#数据类型

WORD

ushort

DWORD

uint

UCHAR

int/byte

UCHAR*

string/InPtr

unsigned char*

[MarshalAs(UnmanagedType.LPArray)]byte[]/(IntPtr)

char*

string

LPCTSTR  

string

LPTSTR 

[MarshalAs(UnmanagedType.LPTStr)] string

long

int

ulong

uint

Handle  

IntPtr

HWND

IntPtr

void*

IntPtr

int  

int  

int* 

ref int

*int  

IntPtr

unsigned int

uint

COLORREF 

uint

 

以上是数据类型对应表。char * 对应的是string。有的博客里说应该使用IntPtr 指针,我认为也是可以的。但是能用string为啥还要用指针呢?

 

坑点2,c++用的字符编码是ansi ,而C#使用的字符编码默认是Unicode 所以用上面的的简单的dllimport是传不了正确数据的。所以最终正确的用法如下:

    [DllImport("/svm/libMySvm.so",CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
        public static extern double _Z5judgePc(string text);

于是我们最核心的调用c/c++动态链接库的工作就可以说顺利完成了。

 

3.构造API

然后就是简单的构造web api的工作。新建一个net core2.0的Web Api项目,在Controller里调用c/c++动态链接库。整个过程很简单。最终是这样一个接口:

对于上面这段文本,api给出的结果是-1,表示是垃圾文本。这个分类器是由2000篇正常文本和1689篇垃圾博文训练出来的。

还有一点就是时间,识别一篇垃圾文本的时间不能长,那样的话别人都不想用你的东西。上面是第一次4000ms,一般的时候是200ms,需要你在c/c++程序里要注意,svm_loadmodel()加载分类器函数是一个很耗时间的操作。这个函数第一次使用时加载一次就够了。

 

 

四,总结

搭建这样一个接口,是为了提供垃圾文本识别的。这对一个拥有一定用户量的网站是很有必要的。毕竟林子大了什么鸟都有。反垃圾反广告的工作始终都是一个消耗很大人力成本的工作。

这只是一个简单的接受文本,反馈结果的api。而对于一个站点来说,反垃圾显然不是一个api能做到的。你需要设计一个庞大的系统。你可以选择svm,贝叶斯算法,等机器学习算法,也可以选择深度学习的算法(更高大上一些,但效果也不一定比机器学习好。)这个系统需要有自我反馈和学习的机制。因为垃圾文本始终是在变化的。你的垃圾库也要随之发生变化。训练数据也是一个耗费时间和资源的事情,如何在适当的时候再次训练构造更强大的分类器。对于训练数据如何设计一个不断搜集垃圾文本的程序,以减少人工构造训练集的成本。

再提一点,你千辛万苦写的api可能没有微软发布的机器学习框架效果的十分之一好。但是如果你开发的十分符合你自己站点的民风民俗,那就很有效了。因为大佬们的框架是面向普罗大众的,他可能照顾不到你。

 

这篇博客没有介绍svm的什么知识,是介绍一下实际场景中svm的利用。一个算法的理论研究和实际使用还是很大的区别。怎么把机器学习在实际生产充分发挥它的作用,而不只是追求理论的东西如同八股一般。

 

 

 

 

posted @ 2018-06-04 16:00  Shendu.CC  阅读(968)  评论(0编辑  收藏  举报