THUCTC源码解读

Posted multiangle

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了THUCTC源码解读相关的知识,希望对你有一定的参考价值。


LiblinearTextClassifier

我认为LiblinearTextClassifier是整个分类器中最核心的模块,实现了训练,分类等功能。

LiblinearTextClassifier实现的是TextClassifier的接口,实现了TextClassifier中定义的
addTrainingText, train, saveModel, loadModel, classify, saveToString, loadFromString, setMaxFeatures, getLexicon方法。这些方法的用途在后面也会一一提到。

LiblinearTextClassifier类中的变量非常多,如下所示:

public  Lexicon lexicon; // 词典,初始为空,从文档中训练获得
private DocumentVector trainingVectorBuilder; // 用来构造训练特征向量
private DocumentVector testVectorBuilder; // 用来构造待分类文本的特征向量
private WordSegment seg;  // 分词器
//private svm_model model; // 训练好的模型
private de.bwaldvogel.liblinear.Model lmodel; //线性分类器
private int maxFeatures = 5000; // 默认的最大特征数
private int nclasses; // 类别数
private int longestDoc; // 最长的文档向量长度,决定读取临时文件时缓冲大小
private int ndocs; // 训练集的大小 (num of training docs)

public ArrayList<Integer> labelIndex = new ArrayList<Integer>(); // 类别标签
public File tsCacheFile; // 训练集的cache文件,存放在磁盘上
public DataOutputStream tsCache = null; // 训练集的cache输出流

可以看到,这里使用的是liblinear的线性分类器,默认的最大特征数是5000。
下面来看LiblinearTextClassifier的方法,这里只写了我认为比较重要的方法。那些查询,修改类内private变量的简单函数就不贴上来了。


[1]构造函数

该类的构造函数有两个,分别是

public LiblinearTextClassifier( int nclasses, WordSegment seg ) {
        init( nclasses, seg);
    }

以及

public LiblinearTextClassifier( int nclasses ) {
        init( nclasses, initWordSegment());
    }

可以看到两者的差异很小,区别只是如果没有指定分词器seg,就调用initWordSegment函数生成一个。该函数在此类中没有定义,需要继承类去实现。在获得了分词器之后,就调用Init方法进行初始化。


[2]public void init ( int nclasses, WordSegment seg)

init方法是实质上的构造函数,完成了各变量的初始化工作

public void init ( int nclasses, WordSegment seg) {
        lexicon = new Lexicon(); // 初始化词典
        // 训练文档向量构造器,注意这里的权重方法采用了TfOnly形式,即与词频成正比。
        //注意这里的词频与lexicon中的词频不同,这里指的是在该文档中出现的次数,
        //而lexicon中的词频指的是所有文档中出现该词的次数
        trainingVectorBuilder =
          new DocumentVector(lexicon, new TfOnlyTermWeighter()); 
        testVectorBuilder = null;
        //model = null;
        lmodel = null;  // 模型为空,需要后续导入或利用文档训练得到
        this.nclasses = nclasses;
        ndocs = 0;
        this.seg = seg;
    }

[3]public boolean loadModel(String filename)

顾名思义,从本地文件中读取之前训练好的模型。这样就可以直接进行分类工作,而不需进行训练。
要载入模型,需要读入lexicon和model两个文件,分别包含了词典信息和liblinear模型中的信息。读取完毕以后,锁住词典,不需加入新词,为接下来的分类做准备

与之相似的还有saveModel,流程与loadModel相反

public boolean loadModel(String filename) {
        // filename 是一个目录名称
        File modelPath = new File(filename);
        if ( ! modelPath.isDirectory() ) // 判断路径合法性
            return false;

        File lexiconFile = new File( modelPath, "lexicon"); // 读取词典
        File modelFile = new File( modelPath, "model"); // 读取model(liblinear库生成)

        System.out.println(lexiconFile.getAbsolutePath());

        try { 
            if ( lexiconFile.exists() ) {
                lexicon.loadFromFile(lexiconFile); // 调用词典中的loadFromFile方法
                System.out.println("lexicon exists!");
            } else {
                return false;
            }

            if ( modelFile.exists() ) {
                //this.model = svm.svm_load_model(modelFile.getAbsolutePath());
                //this.lmodel = de.bwaldvogel.liblinear.Linear.loadModel(new File(modelFile.getAbsolutePath()));
                System.out.println("model exists!");
                //调用liblinear中的loadModel方法
                this.lmodel = de.bwaldvogel.liblinear.Linear.loadModel(modelFile); 
            } else {
                return false;
            }
        } catch ( Exception e ) { // 只要载入2个文件中有一个出错,即返回false
            return false;
        }
        lexicon.setLock( true ); // 将词典锁定,不允许加入新词,为接下来的分类工作做准备
        trainingVectorBuilder = null; // trainingVectorBuilder清空(因为不需要训练了)
        testVectorBuilder =  //生成testVectorBuilder 准备为测试文本建立文档向量
          new DocumentVector(lexicon, new TfIdfTermWeighter(lexicon));
        return true; // 所有任务完成以后,返回true
    }

[4]public String saveToString()

这个方法需要跟loadFromString方法配对,原本想写loadFromString的,不过发现还是写saveToString比较好,如果写loadFromString的话不太好理解。

saveToString这个方法实现的功能很简单,就是将lexicon(词典)和lmodel( liblinear模型对象 )转化成字符串。但是这个字符串对于人来说是不可读的。字符串的生成过程是,将这两个对象通过ObjectOutputStream.writeObject转化成byte数组,经过Base64加密以后生成字符串。

同样的,loadFromString获取字符串,先按Base64解码成比特数组,然后利用ObjectOutputStream.writeObject读取内容,然后转型成lexicon和lmodel

public String saveToString() {
      ByteArrayOutputStream baos = new ByteArrayOutputStream();
      try {
        ObjectOutputStream oos = new ObjectOutputStream(baos); //注意ObjectOutputStream(ByteArrayOutputStream)的用法
        oos.writeObject(this.lexicon); // 写入lexicon
        //oos.writeObject(this.model);
        oos.writeObject(this.lmodel); // 写入lmodel
        oos.close();                
      } catch (IOException e) {
        e.printStackTrace();
        return "";  // Failed to serialize the model.
      }
      String base64 = new String(Base64.encodeBase64(baos.toByteArray()));
      return base64;
    }

[5]public boolean addTrainingText(String text, int label)

向模型中加入一篇训练文档。要求label是小于总类别数的整数,从0开始。text代表训练文本,label表示类别编号。若加入成功,则返回true

流程大概如下所示 :

  1. 文本预处理( 清除空行,去掉多余的空格 )
  2. 将文本进行分词,得到String数组
  3. 遍历一遍,将生词加入lexicon
  4. 再遍历一遍,将String数组根据lexicon转换成Word数组
  5. 再遍历一遍,将Word数组中id相同的合并,最后得到Term数组,其中包含了词的id和词频
    Term数组即为文档向量的稀疏化表示。
  6. 将Term数组遍历一遍,将其中值逐个写入缓存文件

可以看到这个流程总的来讲是有非常多的冗余的。。。比如说第一项的预处理,就需要遍历两次,步骤3,4,5其实可以合起来做,只遍历一次即可。只不过这样做的代价是会破坏封装性,增大模块间耦合度。如果项目大了以后可维护性是致命的。

下面上代码

public boolean addTrainingText(String text, int label) {
        if ( label >= nclasses || label < 0 ) {
            return false; // 进行label合法性检查
        }
        if ( tsCache == null ) {
            try {
                // tsCacheFile = File.createTempFile("tctscache", "data");
                // new File(".","adsfa") 前者表示为当前目录,后面为文件名
                tsCacheFile = new File(".", "tctscache" + Long.toString(System.currentTimeMillis()) + "data");
                tsCache = new DataOutputStream(
                                new BufferedOutputStream(
                                new FileOutputStream(tsCacheFile)));
                longestDoc = 0;
            } catch (IOException e) {
                return false;
            }
        }
        text = LangUtils.removeEmptyLines(text);
        text = LangUtils.removeExtraSpaces(text);
        String [] bigrams = seg.segment(text);  // bigrams 里面存放分词结果
        lexicon.addDocument(bigrams);
        Word [] words = lexicon.convertDocument(bigrams);
        bigrams = null;
        // 将words数组转化成文档向量,第i维的值即为词典中第i个词在该文档中出现次数
        Term [] terms = trainingVectorBuilder.build( words, false ); 
        try {
            // 这边可以学习DataOutputStream的一些用法
            tsCache.writeInt(label); // 首先向缓存中写入该文档的分类标签以及单词数
            tsCache.writeInt(terms.length);
            if ( terms.length > longestDoc ) {
                longestDoc = terms.length; // 更新longestDoc
            }
            for ( int i = 0 ; i < terms.length ; i++ ) { // 通过循环将文档向量逐个写入缓存文件
                tsCache.writeInt(terms[i].id);
                tsCache.writeDouble(terms[i].weight);
            }
        } catch (IOException e) {
            return false;
        }
        if ( ! labelIndex.contains(label) ) { //如果之前没碰到过该标签,则加入标签列表
            labelIndex.add(label);
        }
        ndocs++; // ndocs+1 (以后计算TF-IDF权重的时候会用到)
        return true;
    }

[6]selectFeaturesByChiSquare

用来从所有特征项中筛选出一部分。

所谓特征项,在这里就是指的单词。在读取文档阶段,所有生词都会加入词典成为一个特征项,而现在的任务就是从中选出一部分,比如说5000个。选择的标准有很多种,在这里是使用卡方检验(chi-square)来进行选择的。在该类中还有其他特征选择方式,比如说scalable term selection。

进行特征选择的大概流程是

  1. 遍历所有文档,得到一个矩阵featureStats和一个向量featureFreq. featureStats存储了每个分类下每个单词的出现次数,而featureFreq则存储了所有文档中每个单词总的出现次数。也就是说,featureFreq就是featureStats沿着分类方向求和的结果。
  2. 对每一个特征中的每一个分类,都求出相应的chisqr值,从这些chisqr中挑最大的作为chimax,是这个特征的一个值,作为与其他特征相比较的标准
  3. 保留前N个chimax值最大的特征项
  4. 生成选择后特征与原先特征的映射表。比如说,原先的特征数是20000,其中有一个特征的id是7000 ,那么就需要做一次映射,一边是原先的id,另一边是新特征集的序号。

具体的chi-square校验的原理就不说了,可以去其他地方搜索相关资料。
这边还是想说一下不必要的冗余。。步骤3中使用了PriorityQueue,本身就能保证有序性,而步骤4中又排了序。。。

public Map<Integer, Integer> selectFeaturesByChiSquare( 
            File cacheFile, 
            int featureSize,
            int kept, 
            int ndocs,
            int nclasses,
            int longestDoc,
            double [] chimaxValues ) {

        System.out.println("selectFeatureBySTS : " +
                "featureSize = " + featureSize + 
                "; kept = " + kept + 
                "; ndocs = " + ndocs + 
                "; nclasses = " + nclasses + 
                "; longestDoc = " + longestDoc);

        int [][] featureStats = new int[featureSize][nclasses]; //某词在某类出现次数
        int [] featureFreq = new int[featureSize];//某词词频
        PriorityQueue<Term> selectedFeatures; //优先队列,会自动保持从小到大顺序
        int [] classSize = new int[nclasses];//每类多少篇文章

        // 统计chi-square需要的计数
        // 每篇文档都是 label num-of-terms [id-weight] 形式, 如此往复,所有预处理后的都放在cacheFile里面
        //该部分统计出每个标签下各个特征(单词)的出现次数,每个单词总的出现次数
        int label = 0;
        int nterms = 0;
        Term [] terms = new Term[longestDoc + 1];
        for ( int i = 0 ; i < terms.length ; i++ ) {
            terms[i] = new Term();
        }
        int ndocsread = 0;
        try {
            DataInputStream dis = new DataInputStream(new BufferedInputStream(
                    new FileInputStream(cacheFile)));
            while ( true ) {
                try {
                    label = dis.readInt();
                    nterms = dis.readInt();
                    for ( int i = 0 ; i < nterms ; i++ ) { 
                        terms[i].id = dis.readInt();
                        terms[i].weight = dis.readDouble();
                    }
                } catch ( EOFException e ) {
                    break;
                }

                classSize[label] ++;
                for ( int i = 0 ; i < nterms ; i++ ) { 
                    Term t = terms[i];
                    featureStats[t.id][label] ++;
                    featureFreq[t.id] ++;
                }
                if ( ndocsread++ % 10000 == 0) { // 每处理1W篇文档输出一个信号
                    System.err.println("scanned " + ndocsread);
                }
            }
            dis.close();
        } catch ( IOException e ) {
            return null;
        }


        System.err.println("start chi-square calculation");

        // 计算chi^2_avg(t),这里利用一个优先级队列来选择chi^2最高的特征
        selectedFeatures = new PriorityQueue<Term>( kept + 1,  //使用了优先级队列
                new Comparator<Term>() {    //这边有一个匿名类,用于实现term项之间的比较
                    public int compare(Term t0, Term t1) {
                        return Double.compare(t0.weight, t1.weight);
                    }           
        });

        long A, B, C, D;

        for ( int i = 0 ; i < featureSize ; i++ ) {
            Word w = lexicon.getWord(i);
            if (w != null) {
              if ( w.getDocumentFrequency() == 1 || w.getName().length() > 50 ) 
                continue;// 剔除掉了单词过长而出现次数极少的情况(一般长度大于50的都是一些命名实体)
            }
            double chisqr = -1;
            double chimax = -1;
            for ( int j = 0 ; j < nclasses ; j++ ) {
                A = featureStats[i][j];
                B = featureFreq[i] - A;
                C = classSize[j] - A;
                D = ndocs - A - B - C;

                //System.out.println("A:"+A+" B:"+B+" C:"+C+" D:"+D);
                double fractorBase = (double)( (A+C) * (B+D) * (A+B) * (C+D) );
                if ( Double.compare(fractorBase, 0.0 ) == 0 ) {
                    chisqr = 0;
                } else {
                    // 我们不用ndocs,因为所有特征的ndocs都一样
                    //chisqr = ndocs * ( A*D -B*C) * (A*D - B*C) / fractorBase  ;
                    chisqr = ( A*D -B*C) / fractorBase * (A*D - B*C)   ;
                }
                if ( chisqr > chimax ) {
                    chimax = chisqr;
                }

//              被注释的方法是计算chi^2_avg即概率加权平均的卡方值。我们实际用的是chimax
//              chisqr += (classSize[j] / (double) ndocs) * 
//                      ndocs * ( A*D -B*C) * (A*D - B*C) 
//                      / (double)( (A+C) * (B+D) * (A+B) * (C+D) ) ;
            }
            if ( chimaxValues != null ) {
                chimaxValues[i] = chimax;
            }
            // 更新第i个特征值的权重值
            Term t = new Term();
            t.id = i;
            t.weight = chimax;
            selectedFeatures.add(t);
            if ( selectedFeatures.size() > kept ) {
                // poll用于检索并移除此队列的头。在此处则就是去掉队列中weight最小的值
                selectedFeatures.poll();
            }
        }
        outputSecletedFeatures(selectedFeatures);
        System.err.println("generating feature map");

        // 生成旧id和新选择的id的对应表
        // 将优先队列中的元素 放入 featuresToSort 队列中
        // 这边明显能看出来前后程序不是一人所写
        // PriorityQueue 本身就是从小到大排序的 没必要再排序一遍
        Map<Integer, Integer> fidmap = new Hashtable<Integer, Integer>(kept);
        Term [] featuresToSort = new Term[selectedFeatures.size()];
        int n = 0;
        while ( selectedFeatures.size() > 0 ) {
            Term t = selectedFeatures.poll();
            featuresToSort[n] = t;
            n++;
        }
        Arrays.sort(featuresToSort, new Term.TermIdComparator());
        for ( int i = 0 ; i < featuresToSort.length ; i++ ) {
            fidmap.put(featuresToSort[i].id, i);
        }
        return fidmap;
    }

createLiblinearProblem

这是一个与liblinear库对接的接口。通过这个方法,能够从缓存文件和选定的特征生成liblinear能够使用的数据格式。

使用liblinear需要建立一个de.bwaldvogel.liblinear.Problem()类的对象,其中需要设置如下变量:

  • n: 节点的向量长度
  • l:节点数目
  • x: 所有节点的向量,是一个矩阵,长为节点数,宽为特征数
  • y:是一个数组,存储每个节点的标签,标签值从1开始

这个方法做的工作其实挺简单,就是读取每个文档中各个单词的数目,然后使用tf-idf计算权重,然后将特征向量和标签值存入Problem对象中。

令我有点疑惑的就是代码中先从缓存将数据读入一个中间量中,再从中间量读入Problem对象。而这个中间量的唯一用途就是根据标签值从大到小对各特征向量进行排序,感觉没什么必要,反而占用大量内存,也需要消耗计算量。

代码如下:

private de.bwaldvogel.liblinear.Problem createLiblinearProblem( File cacheFile, 
            Map<Integer, Integer> selectedFeatures){
        Vector<Double> vy = new Vector<Double>();
        Vector<svm_node[]> vx = new Vector<svm_node[]>();

        //DataNode [] datanodes = new DataNode[this.ndocs];
        // 长度等同ndocs, 一个文档一个LdataNode节点
        LdataNode [] ldatanodes = new LdataNode[this.ndocs]; 
        //FeatureNode[][] lfeatureNodes;                      
        // LdataNode是自定义节点,包含标签和FeatureNode数组

        int label, nterms;
        Term [] terms = new Term[longestDoc + 1];
        for ( int i = 0 ; i < terms.length ; i++ ) {
            terms[i] = new Term();
        }
        int ndocsread = 0;
        //add------------------------
        int maxIndex=0;
        try {
            DataInputStream dis = new DataInputStream(new BufferedInputStream(
                    new FileInputStream(cacheFile)));
            while ( true ) {
                int n = 0;
                try {
                    label = dis.readInt();  // 读取各个文档,与之前selectFeaturesByChiSquare类似
                    nterms = dis.readInt();
                    for ( int i = 0 ; i < nterms ; i++ ) { 
                        int tid = dis.readInt();
                        double tweight = dis.readDouble();
                        Integer id = selectedFeatures.get(tid);
                        if ( id != null ) {
                            terms[n].id = id;
                            //add
                            maxIndex=Math.max(maxIndex, id+1);

                            Word w = lexicon.getWord(tid);
                            int df = w.getDocumentFrequency();
                            terms[n].weight = Math.log( tweight + 1 ) //使用了tf-idf权重计算方法
                                * ( Math.log( (double) ( ndocs + 1 ) / df ) );
                            n++;
                            //System.err.println("doc " + id + " " + w);
                        }
                    }
                } catch ( EOFException e ) {
                    break;
                }                                                               
                // 归一化向量
                double normalizer = 0;
                for ( int i = 0 ; i < n ; i++ ) { 
                    normalizer += terms[i].weight * terms[i].weight;
                }
                normalizer = Math.sqrt(normalizer);
                for ( int i = 0 ; i < n ; i++ ) { 
                    terms[i].weight /= normalizer;
                }

                //datanodes[ndocsread] = new DataNode();

                // 放入svm problem中
                ldatanodes[ndocsread] = new LdataNode();
                ldatanodes[ndocsread].llabel= label; // 往LdataNode中写入label

                FeatureNode[] lx = new FeatureNode[n]; //向量是一个FeatureNode数组
                for ( int i = 0; i < n ; i++ ) {
                    // FeatureNode与Term类似,id和权重
                    //id是从1开始的
                    lx[i] = new FeatureNode(terms[i].id + 1,terms[i].weight); 
                }                                    
                ldatanodes[ndocsread].lnodes = lx;   // 将FeatureNode[] 存入LdataNode之中
                if ( ndocsread++ % 10000 == 0) {
                    System.err.println("scanned " + ndocsread);
                }
            }
            dis.close();
        } catch ( IOException e ) {
            return null;
        }

        assert( this.ndocs == ndocsread );

        Arrays.sort( ldatanodes ); // 按照类别排序(有必要吗?)

        //建立liblinear problem
        de.bwaldvogel.liblinear.Problem lprob = new de.bwaldvogel.liblinear.Problem(); 

        System.out.println("max index: -------------------------------------: " + maxIndex);
        lprob.n = maxIndex; // n 记录的是存储的最大id,即最大特征数
        lprob.l = ldatanodes.length; // l 记录的是节点数
        // x记录所有节点的向量。node_num * feature_size
        lprob.x = new de.bwaldvogel.liblinear.FeatureNode[lprob.l][]; 
        for( int i = 0 ; i < lprob.l ; i++ )
            //lprob.x[i] = datanodes[i].nodes;
            lprob.x[i]=ldatanodes[i].lnodes; // 存入节点向量
        lprob.y = new int[lprob.l];   // y记录的是节点的标签
        for(int i = 0 ; i < lprob.l ; i++ )
            lprob.y[i] = ldatanodes[i].llabel;

        return lprob;
    }

[8]public boolean train()

在介绍完了各个模块以后,终于可以开始介绍最重要的train和classify方法了。train方法用于训练模型,而classify方法用于对新的文本进行分类。

虽然train和classify是两个最重要的方法,但是长度却并不长,主要有两个原因,一是之前对一些功能已经进行了比较完善的封装,二是直接调用了现成的模块,使用liblinear进行训练和分类。

训练的大概流程是:

  • 根据卡方校验筛选特征项(selectFeaturesByChiSquare)
  • 创建liblinear项目(createLiblinearProblem)
  • 将词典中非特征项去掉,只保留特征项并重新排序
  • 使用Liblinear进行训练
public boolean train() {
        // 训练之前把训练集的cache输出流关闭
        try {
            tsCache.close();
        } catch (IOException e) {
            return false;
        }
        //根据卡方校验筛选特征项
        Map<Integer, Integer> selectedFeatures = selectFeaturesByChiSquare(
                tsCacheFile, lexicon.getSize(), maxFeatures);

        if ( selectedFeatures == null ) {
            return false;
        }
        System.err.println("feature selection complete");
        ///////////////////
        // 创建liblinear项目
        de.bwaldvogel.liblinear.Problem lproblem = createLiblinearProblem(tsCacheFile, selectedFeatures);
        System.err.println("liblinear problem created");
        //将词典中非特征项去掉,只保留特征项并重新排序
        lexicon = lexicon.map( selectedFeatures );
        lexicon.setLock( true );  //锁定词典,此时无法加入新词
        tsCacheFile.delete();
        trainingVectorBuilder = null;
        testVectorBuilder = new DocumentVector(lexicon, new TfIdfTermWeighter(lexicon));

        de.bwaldvogel.liblinear.Parameter lparam = new Parameter(SolverType.L1R_LR, 500, 0.01);

        de.bwaldvogel.liblinear.Model tempModel = de.bwaldvogel.liblinear.Linear.train(lproblem, lparam);
        System.err.println("TRAINING COMPLETE=========================================================================================");
        this.lmodel = tempModel;
        return true;
    }

[9]classify

以上是关于THUCTC源码解读的主要内容,如果未能解决你的问题,请参考以下文章

ArrayList源码解读

vueJs源码解读0-2

并发阻塞队列BlockingQueue解读

#yyds干货盘点#three.js源码解读-EventDispatcher

源码解读:ArrayList源码解析(JDK8)

javajava 锁 ReentrantReadWriteLock 读写锁 源码解读