SparkML使用逻辑回归算法对新闻多分类


一、准备数据

自从引进DataFrame之后,spark在ml方面,开始使用DataFrame作为RDD的上层封装,以屏蔽RDD层次的复杂操作,对应用开发者提供简单的DataFrame,以减少开发量。本文中使用的是spark 2.4 版本为基础 , 构建从数据预处理、特征转换、模型训练、数据测试到模型评估的一整套处理流程。另外,经过综合考虑,本文分词方法选用 HanLP 分词工具(文档丰富、算法公开、代码开源,并且经测试分词效果比较好),数据使用的是从新闻网站爬取的新闻分类数据,使用了4个分类的数据(文化、财经、军事和体育),每个分类使用了1000条数据,每行一条数据,有4个字段(分类、标题、日期和内容),使用“\u00EF”作分割符。

二、特征工程部分

2.1 数据清洗转换

数据预处理步骤主要进行数据清洗、转换操作。首先从文件加载数据到RDD,然后按照分隔符进行切分数据,因为分类字段爬下来时没有进行清洗,在这里我们需要将分类提取出来,并按照分类字段过滤掉未提取到分类或者分类不正确的脏数据,然后转换为DataFrame,接下来指定每个字段的字段名。

主要代码如下:

注意:这里必须要添加隐式转换 【 import spark.implicits._ 】,否则不能调用到SparkSQL的toDF方法!

 /**
  * 清洗步骤, 可根据具体数据结构和业务场景的不同进行重写. 注意: 输出必须要有标签字段"label"
  *
  * @param filePath 数据路径
  * @param spark    sparkSession
  * @return 清洗后的数据, 包含字段: "label", "title", "time", "content"
  */
def clean(filePath: String, spark: SparkSession): DataFrame = {
  import spark.implicits._
 
  val textRDD = spark.sparkContext.textFile(filePath)
 
  val textDF = textRDD.flatMap { line =>
    //分隔数据
    val fields = line.split("\u00EF")
 
    if (null != fields && fields.length > 3) {
      val categoryLine = fields(0)
      val categories = categoryLine.split("\\|")
      val category = categories.last
 
      var label = "其他"
      if (category.contains("文化")) label = "文化"
      else if (category.contains("财经")) label = "财经"
      else if (category.contains("军事")) label = "军事"
      else if (category.contains("体育")) label = "体育"
      else {}
 
      val (title, time, content) = (fields(1), fields(2), fields(3))
      if (!label.equals("其他")) {
        Some(label, title, time, content)
      } else {
        None
      }
    } else None
  }.toDF("label", "title", "time", "content")
  
  textDF
}

2.2 类别标签转换向量

因为在数据清洗之后取出来的标签是字符串类型,我们需要转换成Spark ML 逻辑回归算法能够识别的Double类型,在这里需要进行转换。

/**
    * 将数据中的中文标签转换为索引形式输出
    *
    * @param data 输入数据
    * @return 标签索引模型, 模型增加字段: "indexedLabel"
    */
  def indexrize(data: DataFrame): StringIndexerModel = {
    val labelIndexer = new StringIndexer()
      .setInputCol("label")
      .setOutputCol("indexedLabel")
      .fit(data)
 
    labelIndexer
  }

2.3 分词

(因为HanLP分词模型太大,未上传至项目中,需要的请从HanLP发布页下载,然后解压后将data目录整个放到SparkML\dictionaries\hanlp\目录下即可)

在经过数据预处理之后,我们已经将数据转换成需要的DataFrame格式,并且对数据进行了清洗。接下来我们需要进行分词的操作,将新闻内容切分成一个个词语的形式,以便后续进行停用词去除以及转换为特征向量

/**
    * 分词过程,包括"分词", "去除停用词"
    *
    * @param data   输入数据
    * @param params 配置参数
    * @return 预处理后的DataFrame, 增加字段: "tokens", "removed"
    */
  def segment(data: DataFrame, params: PreprocessParam): DataFrame = {
    val spark = data.sparkSession
 
    //=== 分词
    val segmenter = new Segmenter()
      .isDelEn(params.delEn) //是否去除英语单词
      .isDelNum(params.delNum) //是否去除数字
      .setSegmentType(params.segmentType) //分词方式
      .addNature(params.addNature) //是否添加词性
      .setMinTermLen(params.minTermLen) //最小词长度
      .setMinTermNum(params.minTermNum) //行最小词数
      .setInputCol("content")
      .setOutputCol("tokens")
 
    val segDF = segmenter.transform(data)
 
 
    //=== 去除停用词
    val stopwordArray = spark.sparkContext.textFile(params.stopwordFilePath).collect()
    val remover = new StopWordsRemover()
      .setStopWords(stopwordArray) //这是停用词集合
      .setInputCol(segmenter.getOutputCol)
      .setOutputCol("removed")
    val removedDF = remover.transform(segDF)
 
    removedDF
  }

这里我模仿spark ml包下的StopWordsRemover类创建了Segmenter类,用于对数据进行分词,其内部调用了HanLP分词工具。(由于spark自带的StopWordsRemover等使用的闭包仅限于ml包,自定义的类无法调用,故只是采用了与StopWordsRemover类似的使用形式,内部结构并不相同,并且由于以上原因,Segmenter类没有继承Transformer类,故无法进行pipeline管道操作,此缺陷有待解决)

Segmenter类具体实现如下:

package hnbian.sparkml.classnews.preprocess
 
import com.hankcs.hanlp.seg.Segment
import com.hankcs.hanlp.seg.common.Term
import com.hankcs.hanlp.tokenizer.{IndexTokenizer, NLPTokenizer, SpeedTokenizer, StandardTokenizer}
import hnbian.sparkml.classnews.utils.segment.{MyCRFSegment, MyNShortSegment}
import org.apache.spark.ml.util.Identifiable
import org.apache.spark.sql.{DataFrame, Row}
 
import scala.collection.JavaConversions._
 
 
/**
  * @author hnbian
  *         @ Description 基于hanLP的分词
  *         @ Date 2023/05/16 15:24
  **/
class Segmenter(val uid: String) extends Serializable {
  //英文字符正则
  private val enExpr = "[A-Za-z]+"
  //数值正则,可以匹配203,2.23,2/12
  private val numExpr = "\\d+(\\.\\d+)?(\\/\\d+)?"
  //匹配英文字母、数字、中文汉字之外的字符
  private val baseExpr =
    """[^\w-\s+\u4e00-\u9fa5]"""
 
  private var inputCol: String = ""
  private var outputCol: String = ""
  private var segmentType: String = "StandardTokenizer"
  private var addNature: Boolean = false
  private var delNum: Boolean = false
  private var delEn: Boolean = false
  private var minTermLen: Int = 1
  private var minTermNum: Int = 3
 
  def setInputCol(value: String): this.type = {
    this.inputCol = value
    this
  }
 
  def setOutputCol(value: String): this.type = {
    this.outputCol = value
    this
  }
 
  def setSegmentType(value: String): this.type = {
    this.segmentType = value
    this
  }
 
  def addNature(value: Boolean): this.type = {
    this.addNature = value
    this
  }
 
  def isDelNum(value: Boolean): this.type = {
    this.delNum = value
    this
  }
 
  def isDelEn(value: Boolean): this.type = {
    this.delEn = value
    this
  }
 
  def setMinTermLen(value: Int): this.type = {
    require(value > 0, "最小词长度必须大于0")
    this.minTermLen = value
    this
  }
 
  def setMinTermNum(value: Int): this.type = {
    require(value > 0, "行最小词数必须大于0")
    this.minTermNum = value
    this
  }
 
  def getInputCol: String = this.inputCol
 
  def getOutputCol: String = this.outputCol
 
  def getSegmentType: String = this.segmentType
 
  def this() = this(Identifiable.randomUID("segment"))
 
  def transform(dataset: DataFrame): DataFrame = {
    val spark = dataset.sparkSession
 
    var segment: Segment = null
    segmentType match {
      case "NShortSegment" =>
        segment = new MyNShortSegment()
      case "CRFSegment" =>
        segment = new MyCRFSegment()
      case _ =>
    }
 
    val tokens = dataset.select(inputCol).rdd.flatMap { case Row(line: String) =>
      var terms: Seq[Term] = Seq()
      segmentType match {
        case "StandardSegment" =>
          terms = StandardTokenizer.segment(line)
        case "NLPSegment" =>
          terms = NLPTokenizer.segment(line)
        case "IndexSegment" =>
          terms = IndexTokenizer.segment(line)
        case "SpeedSegment" =>
          terms = SpeedTokenizer.segment(line)
        case "NShortSegment" =>
          terms = segment.seg(line)
        case "CRFSegment" =>
          terms = segment.seg(line)
        case _ =>
          println("分词类型错误!")
          System.exit(1)
      }
 
 
      val termSeq = terms.flatMap { term =>
        val word = term.word
        val nature = term.nature
 
        if (this.delNum && word.matches(numExpr)) None //去除数字
        else if (this.delEn && word.matches(enExpr)) None //去除英文
        else if (word.length < minTermLen) None //去除过短的词
        else if (this.addNature) Some(word + "/" + nature)
        else Some(word)
      }
 
      if (termSeq.nonEmpty && termSeq.size >= minTermNum) Some((line, termSeq)) else None //去除词数过少的行
    }
 
    import spark.implicits._
    val tokensSet = tokens.toDF(inputCol + "#1", outputCol)
 
    dataset.join(tokensSet, dataset(inputCol) === tokensSet(inputCol + "#1")).drop(inputCol + "#1")
  }
}

主要在transform方法中调用了HanLP相关的分词方法。注意,如果使用NShortSegment和CRFSegment,需要new相应的对象,这里我自己创建了MyNShortSegment和MyCRFSegment类(代码如下),继承了HanLP中对应的类,并继承了Serializable特质(其实并没有做什么操作)。主要是因为HanLP没有对它们实现序列化,直接在RDD中使用它们会报错。(当然你也可以对HanLP的源码进行修改,再重新打包。个人觉得比较麻烦,并且不易跟进HanLP发布进度,所以没去弄


package hnbian.sparkml.classnews.utils.segment
 
import com.hankcs.hanlp.seg.CRF.CRFSegment
 
 
/**
  * @author hnbian
  * @ Description 自定义CRF分词类,继承CRFSegment,并实现序列化
  * @ Date 2023/05/16 15:26
  **/
class MyCRFSegment extends CRFSegment with Serializable{
 
}

package hnbian.sparkml.classnews.utils.segment
 
import com.hankcs.hanlp.seg.NShort.NShortSegment
 
 
/**
  * @author hnbian
  * @ Description
  * @ Date 2023/05/16 15:27
  **/
class MyNShortSegment extends NShortSegment with Serializable {
 
}

2.4 停用词移除

分词之后,我们需要对一些常用的无意义词,如:“的”、“我们”、“是”等进行去除(通常去除的是语气词、连词等,统称为“停用词”)。因为这些词没有多大的意义,但是在自然语言中又经常使用,这些词不去掉会强烈的干扰我们对特征的抽取效果。(比如:在体育分类语料中,“的”共出现500次,“足球”共出现300次,那么谁更能代表体育这个分类呢?谁更应该作为特征被保留下来呢?)

去除停用词的操作我们直接调用ml包中的StopWordsRemover类:

//=== 去除停用词
    val stopwordArray = spark.sparkContext.textFile(params.stopwordFilePath).collect()
    val remover = new StopWordsRemover()
      .setStopWords(stopwordArray) //这是停用词集合
      .setInputCol(segmenter.getOutputCol)
      .setOutputCol("removed")
    val removedDF = remover.transform(segDF)

由于spark的StopWordsRemover类中内置的停用词都是一些英文停用词,而我们在这里处理的是中文语料,故需要加载自己的停用词。这里我使用了HanLPdictionary目录下的stopwords.txt文件提供的停用词。(这里面都是一些基本停用词,如果对停用词要求比较高,可以在网上找几份停用词表进行合并,效果会更好一点)

有兴趣的同学可以进到transform方法中看一看,spark官方的去除停用词方法跟我们常用处理一样,将停用词转换成set,然后调用contains进行判断,然后过滤:

@Since("2.0.0")
  override def transform(dataset: Dataset[_]): DataFrame = {
    val outputSchema = transformSchema(dataset.schema)
    val t = if ($(caseSensitive)) {
      val stopWordsSet = $(stopWords).toSet
      udf { terms: Seq[String] =>
        terms.filter(s => !stopWordsSet.contains(s))
      }
    } else {
      val lc = new Locale($(locale))
      val toLower = (s: String) => if (s != null) s.toLowerCase(lc) else s
      val lowerStopWords = $(stopWords).map(toLower(_)).toSet
      udf { terms: Seq[String] =>
        terms.filter(s => !lowerStopWords.contains(toLower(s)))
      }
    }
    val metadata = outputSchema($(outputCol)).metadata
    dataset.select(col("*"), t(col($(inputCol))).as($(outputCol), metadata))
  }

2.5 向量化

由于目前常用的分类、聚类等算法都是基于向量空间模型VSM(即将对象向量化为一个N维向量,映射成N维超空间中的一个点),VSM将数据转换为向量形式,便于对大规模数据进行矩阵操作等,也可以通过计算超空间中两个点之间的距离(一般是余弦距离)来计算两个向量之间的相似度。因此,我们需要将经过处理的语料转换为向量形式,这个过程叫做向量化

这里我们也调用spark提供的向量化类CountVectorizer类进行向量化操作:

/**
    * 向量化过程, 包括词汇表过滤
    *
    * @param data   输入数据
    * @param params 配置参数
    * @return 向量模型
    */
  def vectorize(data: DataFrame, params: PreprocessParam): CountVectorizerModel = {
    //=== 向量化
    val vectorizer = new CountVectorizer()
      .setVocabSize(params.vocabSize) //特征词汇表大小
      .setInputCol("removed")
      .setOutputCol("features")
    val parentVecModel = vectorizer.fit(data)
 
    //过滤词汇表
    val numPattern = "[0-9]+".r
    val vocabulary = parentVecModel.vocabulary.flatMap { term =>
      if (term.length == 1 || term.matches(numPattern.regex)) None else Some(term)
    }
 
    val vecModel = new CountVectorizerModel(Identifiable.randomUID("cntVec"), vocabulary)
      .setInputCol("removed")
      .setOutputCol("features")
 
    //返回转换器模型
    vecModel
  }

这里的 vocabSize 是词汇表大小,即转换为向量之后的向量维度。通过阅读fit方法(训练向量化模型,主要是计算vocabulary词汇表的过程),我们可以看到其逻辑也比较简单:wordcount计算词频 –> 计算文档频率 –> 按文档频率过滤–>取词频最大的vocabSize个词

@Since("2.0.0")
  override def fit(dataset: Dataset[_]): CountVectorizerModel = {
    transformSchema(dataset.schema, logging = true)
    val vocSize = $(vocabSize)
    val input = dataset.select($(inputCol)).rdd.map(_.getAs[Seq[String]](0))
    val countingRequired = $(minDF) < 1.0 || $(maxDF) < 1.0
    val maybeInputSize = if (countingRequired) {
      Some(input.cache().count())
    } else {
      None
    }
    val minDf = if ($(minDF) >= 1.0) {
      $(minDF)
    } else {
      $(minDF) * maybeInputSize.get
    }
    val maxDf = if ($(maxDF) >= 1.0) {
      $(maxDF)
    } else {
      $(maxDF) * maybeInputSize.get
    }
    require(maxDf >= minDf, "maxDF must be >= minDF.")
    // 从这里开始 -----------------------------------
    val allWordCounts = input.flatMap { case (tokens) =>
      val wc = new OpenHashMap[String, Long]
      tokens.foreach { w =>
        wc.changeValue(w, 1L, _ + 1L) // 计算词频
      }
      wc.map { case (word, count) => (word, (count, 1)) }
    }.reduceByKey { case ((wc1, df1), (wc2, df2)) =>
      (wc1 + wc2, df1 + df2) //将每个词与在每篇文档中的词频相加,并将词的文档频率相加
    }
 
    val filteringRequired = isSet(minDF) || isSet(maxDF)
    val maybeFilteredWordCounts = if (filteringRequired) {
      //过滤出符合规则的词语(大于最小次数,小于等于最大次数)
      allWordCounts.filter { case (_, (_, df)) => df >= minDf && df <= maxDf }
    } else {
      allWordCounts
    }
 
    val wordCounts = maybeFilteredWordCounts
      .map { case (word, (count, _)) => (word, count) }
      .cache()
   // 到这里 -------------------------------------------------
    if (countingRequired) {
      input.unpersist()
    }
 
    val fullVocabSize = wordCounts.count()
 
   // 获取词频最多的 vocSize 个词,(不足的情况下按真实个数计算)然后按词频进行排序,保留排序后的词
    val vocab = wordCounts
      .top(math.min(fullVocabSize, vocSize).toInt)(Ordering.by(_._2))
      .map(_._1)
 
    require(vocab.length > 0, "The vocabulary size should be > 0. Lower minDF as necessary.")
    copyValues(new CountVectorizerModel(uid, vocab).setParent(this))
  }

从这里可以看出,所谓的训练CountVectorizer模型仅仅是对词频进行统计,计算出词频最大的 vocabSize 个词作为词汇表。下面我们继续看看transform方法:

@Since("2.0.0")
  override def transform(dataset: Dataset[_]): DataFrame = {
    transformSchema(dataset.schema, logging = true)
    // 将词汇表添加索引,转换为map,进行广播
    if (broadcastDict.isEmpty) {
      val dict = vocabulary.zipWithIndex.toMap
      broadcastDict = Some(dataset.sparkSession.sparkContext.broadcast(dict))
    }
    val dictBr = broadcastDict.get
    val minTf = $(minTF)
   // 向量稀疏化,将向量转为稀疏向量形式
    val vectorizer = udf { (document: Seq[String]) =>
      val termCounts = new OpenHashMap[Int, Double]
      var tokenCount = 0L
      document.foreach { term =>
        dictBr.value.get(term) match {
          // 遍历文档中的每个词,如果在该词词汇表中,则将其在词汇表 map 中的位置索引、词频总数做映射到termCounts 这个 map中
          case Some(index) => termCounts.changeValue(index, 1.0, _ + 1.0)
          case None => // ignore terms not in the vocabulary
        }
        tokenCount += 1
      }
      val effectiveMinTF = if (minTf >= 1.0) minTf else tokenCount * minTf
      // 按最小词频过滤,如果特征设置取二值特征,则将索引--> 词频 映射中的词频设置为1
      val effectiveCounts = if ($(binary)) {
        termCounts.filter(_._2 >= effectiveMinTF).map(p => (p._1, 1.0)).toSeq
      } else {
        termCounts.filter(_._2 >= effectiveMinTF).toSeq
      }
 
      Vectors.sparse(dictBr.value.size, effectiveCounts)
    }
    val attrs = vocabulary.map(_ => new NumericAttribute).asInstanceOf[Array[Attribute]]
    val metadata = new AttributeGroup($(outputCol), attrs).toMetadata()
    dataset.withColumn($(outputCol), vectorizer(col($(inputCol))), metadata)
  }

transform方法也比较简单,将词汇表建立索引并转换为Map –>遍历并保留在词汇表中的词,及其词频 –>转换为稀疏向量形式

我们可以将向量化后的数据打印出来看看长什么样儿:

/**
    * 向量化过程, 包括词汇表过滤
    *
    * @param data   输入数据
    * @param params 配置参数
    * @return 向量模型
    */
  def vectorize(data: DataFrame, params: PreprocessParam): CountVectorizerModel = {
    //=== 向量化
    val vectorizer = new CountVectorizer()
      .setVocabSize(params.vocabSize) //特征词汇表大小
      .setInputCol("removed")
      .setOutputCol("features")
    val parentVecModel = vectorizer.fit(data)
 
    //过滤词汇表
    val numPattern = "[0-9]+".r
    val vocabulary = parentVecModel.vocabulary.flatMap { term =>
      if (term.length == 1 || term.matches(numPattern.regex)) None else Some(term)
    }
 
    val vecModel = new CountVectorizerModel(Identifiable.randomUID("cntVec"), vocabulary)
      .setInputCol("removed")
      .setOutputCol("features")
 
    //返回转换器模型
    vecModel.transform(data).select("label","features").show(false)
    vecModel
  } 
 
+-----+------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|label|indexedLabel|features                                                                                                                                                                                                                                                                                                                                                                                                                    |
+-----+------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|文化 |1.0         |(15000,[0,8,50,132,192,302,583,719,742,770,899,1274,2555,2677,2678,3396,4242,5172,5469,8100,8373,8546,9599,14688],[3.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,5.0,1.0,1.0,1.0,1.0,1.0])                                                                                                                                                                                                        |
|体育 |0.0         |(15000,[5,15,19,31,90,248,323,362,468,718,861,979,1098,1232,1233,1380,1507,1603,1852,2664,2958,3587,3689,4511,5354,5594,5775,7584,13147],[2.0,1.0,2.0,1.0,1.0,1.0,1.0,1.0,1.0,3.0,1.0,1.0,2.0,1.0,1.0,1.0,1.0,1.0,2.0,1.0,1.0,1.0,1.0,2.0,3.0,1.0,1.0,1.0,1.0])                                                                                                                                                             |
|体育 |0.0         |(15000,[7,14,15,56,71,129,174,191,222,258,368,385,419,446,447,523,643,703,752,786,923,967,1378,1588,3029,4159,5269,5642,5912,6645,8876],[1.0,1.0,1.0,1.0,1.0,1.0,1.0,5.0,1.0,2.0,1.0,1.0,1.0,5.0,3.0,1.0,1.0,1.0,1.0,1.0,6.0,2.0,1.0,3.0,1.0,1.0,4.0,1.0,1.0,1.0,2.0])                                                                                                                                                      |
|财经 |3.0         |(15000,[1,38,70,78,97,99,136,142,166,198,250,329,372,404,488,518,543,579,715,910,922,944,1045,1111,1162,1798,1825,1880,2559,3526,3848,4182,5462,7444,8564,8995,9222,10652,10703,10894,11646],[1.0,2.0,1.0,1.0,1.0,2.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,2.0,1.0,1.0,1.0,1.0,1.0,2.0,1.0,1.0,1.0,2.0,1.0,1.0,1.0,1.0,3.0,1.0,1.0,1.0,2.0,1.0,1.0,1.0,2.0,1.0,1.0,1.0,1.0])                                                         |
|体育 |0.0         |(15000,[1,43,51,98,104,139,153,158,180,184,212,247,313,338,403,475,523,525,546,643,645,841,860,1126,1231,1249,1370,1696,1900,2325,3195,3382,3710,4111,4605,4975,5518,6932,7309,8587,8601,11573,12257,12616,12806],[1.0,1.0,1.0,1.0,2.0,1.0,1.0,1.0,2.0,1.0,1.0,1.0,1.0,1.0,2.0,1.0,1.0,1.0,1.0,2.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,3.0,1.0,2.0,1.0,2.0,1.0,1.0,2.0,1.0,1.0,1.0,1.0,1.0,4.0,1.0,1.0])                    |
|财经 |3.0         |(15000,[1,19,33,46,59,114,126,131,137,175,233,247,271,287,324,332,474,523,550,698,704,789,794,828,836,1067,1190,1288,1565,1684,1889,1998,2338,3698,4028,4286,4837,5720,7011,7454,10655,11410,12263,12296,13151,14011,14703],[2.0,1.0,2.0,1.0,1.0,1.0,2.0,1.0,2.0,2.0,2.0,2.0,1.0,1.0,1.0,1.0,1.0,3.0,2.0,3.0,2.0,1.0,2.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,2.0,3.0,6.0,3.0,5.0,2.0,3.0,1.0,1.0,3.0,3.0,1.0,1.0,5.0,3.0,2.0,2.0])  |
+-----+------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+

可以看到前面是中文标签,接着是我们经过转换的标签向量,即类别序号,后面是一个稀疏向量,其元素分别代表:向量维度(15000)、特征索引数组(即词汇表中哪些索引号的词出现在该文档中)、词频数组(词汇表中索引词在该文档中出现的次数),例:第一行最后一个元素14688表示词汇表中第14688个词出现在该文档中,出现的次数是 1

三、模型训练与评估

3.1 模型训练

在经过向量化后,数据就可以用来进行分类模型的训练了!这里我们先使用最常用的分类模型——逻辑回归LogisticRegression。spark上提供的LR模型可以实现多分类,正好适用于我们的语料。

下面是分类模型训练的代码:

/**
  * LR模型训练处理过程, 包括: "模型训练"
  *
  * @param data   训练集
  * @return (向量模型, LR模型)
  */
  def train(data: DataFrame): LogisticRegressionModel = {
    val params = new ClassParam
 
    //=== LR分类模型训练
    data.persist()
    val lrModel = new LogisticRegression()
      .setMaxIter(params.maxIteration)  //模型最大迭代次数
      .setRegParam(params.regParam) //正则化项参数
      .setElasticNetParam(params.elasticNetParam) //L1范式比例, L1/(L1 + L2)
      .setTol(params.converTol) //模型收敛阈值
      .setLabelCol("indexedLabel")
      .setFeaturesCol("features")
      .fit(data)
    data.unpersist()
    this.saveModel(lrModel, params)
    lrModel.transform(data).select("label","prediction","probability").show(false)
    lrModel
  }
 
+------------+----------+---------------------------------------------------------------------------------+
|indexedLabel|prediction|probability                                                                      |
+------------+----------+---------------------------------------------------------------------------------+
|3.0         |3.0       |[0.16441041666862294,0.21278600323541705,0.19673717281009295,0.42606640728586703]|
|0.0         |0.0       |[0.6044368640741875,0.08082194053619135,0.06652880841860079,0.24821238697102038] |
|0.0         |3.0       |[0.24607915964277016,0.2656097748102712,0.20262095533491245,0.28569011021204616] |
|2.0         |2.0       |[0.10992902040055669,0.10823056085109142,0.6500273389120252,0.1318130798363267]  |
|1.0         |1.0       |[0.12108670218712453,0.6847084868394621,0.08512534822967509,0.10907946274373846] |
+------------+----------+---------------------------------------------------------------------------------+

在实例化一个LogisticRegression时,可以对其参数进行设置,这里大概跟大家说一下:

setMaxIter 设置最大迭代次数(默认100),具体迭代过程可能会在不足最大迭代次数时停止(参照下一条)
setTol 设置容错(默认1E-6),每次迭代会计算一个误差值,误差值会随着迭代次数的增加逐渐减小,如果误差值小于设置的容错值,则停止迭代优化
setRegParam 设置正则化项系数(默认0.0),正则化项主要用于防止过拟合现象,因此,如果你的数据集比较小,特征维数又比较多时,易出现过拟合,此时可以考虑增大正则化项系数
setElasticNetParam 正则化范式比(默认0.0),正则化一般有两种范式:L1(Lasso)和L2(Ridge)。L1一般用于特征的稀疏化,L2一般用于防止过拟合。这里的参数即设置L1范式的占比,默认0.0即只使用L2范式
setLabelCol 设置标签列(默认读取“label”列)
setFeaturesCol 设置特征列(默认读取“features”列)
setWeightCol 设置各个特征的权重(默认1.0),这里我们使用默认值就好了,如果对特征有特殊要求,可考虑重新设置对应的权重(如将标题作为一项特征,并且标题重要性更高,可将标题这一特征的权重设置大一点)

注意:由于我们的数据稀疏性本来就很高了(15000维的向量只有少数维度有值),因此切记不要把setElasticNetParam设置得过大!!因为setElasticNetParam越大表示L1正则所占比例越高,对向量稀疏化效果越好,而我们的向量本来就很稀疏了,再稀疏化特征基本都为0了,得到的分类效果跟随机分类没什么区别(不信的话可以把这个值设置大一点,然后把后面说到的预测结果的probability打印出来,可以看到在各类别上的概率差别不大)

关于参数的设置,一般根据语料特点和业务场景的不同有所不同,这是一个经验性的东西,没有一个固定的计算公式(所以对数据挖掘和算法工程师来说,调参是一件相当耗时并且头疼的问题)。我们这里暂时使用spark官方example里面的设置,后面再进行调优。

23/05/16 11:50:04 INFO BlockManagerInfo: Removed broadcast_298_piece0 on DESKTOP-ROCPQ1G:7019 in memory (size: 28.6 KB, free: 799.7 MB)
23/05/16 11:50:04 INFO OWLQN: Step Size: 1.000
23/05/16 11:50:04 INFO OWLQN: Val and Grad Norm: 0.701753 (rel: 1.48e-13) 6.87906e-07
23/05/16 11:50:04 INFO OWLQN: Converged because gradient converged
23/05/16 11:50:04 INFO TorrentBroadcast: Destroying Broadcast(18) (from destroy at LogisticRegression.scala:817)
......

这里打印了每次迭代的步长(由算法内部自动设置),以及每次迭代完后计算出的误差值,可以看到我们经过一定次数迭代后达到迭代次数上线,就停止迭代优化过程了。

刚才我们在代码中设置了打印前5个结果,可以看到console中有预测结果的输出:

+------------+----------+---------------------------------------------------------------------------------+
|indexedLabel|prediction|probability                                                                      |
+------------+----------+---------------------------------------------------------------------------------+
|3.0         |3.0       |[0.16441041666862294,0.21278600323541705,0.19673717281009295,0.42606640728586703]|
|0.0         |0.0       |[0.6044368640741875,0.08082194053619135,0.06652880841860079,0.24821238697102038] |
|0.0         |3.0       |[0.24607915964277016,0.2656097748102712,0.20262095533491245,0.28569011021204616] | // ---->分类错误
|2.0         |2.0       |[0.10992902040055669,0.10823056085109142,0.6500273389120252,0.1318130798363267]  |
|1.0         |1.0       |[0.12108670218712453,0.6847084868394621,0.08512534822967509,0.10907946274373846] |
+------------+----------+---------------------------------------------------------------------------------+

我们可以看到其中还是有一些分类预测错误的数据。

3.2 模型评估

虽说模型看上去效果不错,但是我们也需要一个量化指标来衡量其效果:这个模型的准确率、召回率和F1值(这3个指标是评判模型预测能力常用的一组指标,没听过的可以先去【模型评价指标:精确率、准确率、召回率、F1值】文章查看一下)。spark提供了用于多分类模型评估的类MulticlassClassificationEvaluator,我们就使用这个来测一测这个模型到底怎么样

具体代码如下:

package hnbian.sparkml.classnews.utils
 
import hnbian.sparkml.classnews.LRClassPredictDemo.predictions
import org.apache.spark.ml.evaluation.MulticlassClassificationEvaluator
import org.apache.spark.mllib.evaluation.MulticlassMetrics
import org.apache.spark.sql.{DataFrame, Row}
 
/**
  * @author hnbian
  *         @ Description 多分类结果评估
  *         @ Date 2023/05/16 16:14
  **/
object Evaluations {
  /**
    * 评估结果
    *
    * @param data 分类结果
    * @return (准确率, 召回率, F1)
    */
  def multiClassEvaluate(data: DataFrame): (Double,Double, Double, Double) = {
    
    val evaluator = new MulticlassClassificationEvaluator()
      .setLabelCol("label")
      .setPredictionCol("prediction")
      .setMetricName("accuracy")
 
    val predictionRDD = predictions.select("prediction", "indexedLabel")
      .rdd.map { case Row(prediction: Double, label: Double) => (prediction, label) }
 
    val metrics = new MulticlassMetrics(predictionRDD)
 
    /** 准确率 */
    val accuracy = evaluator.evaluate(data)
    /** 精确率 */
    val weightedPrecision = metrics.weightedPrecision
    /** 召回率 */
    val weightedRecall = metrics.weightedRecall
    /** F1 */
    val f1 = metrics.weightedFMeasure
 
    (accuracy, weightedPrecision, weightedRecall, f1)
  }
}

运行结果如下:

========= 评估结果 ==========

准确率:0.9775172943889316
加权精确率:0.9777068320965895
加权召回率:0.9775172943889316
F1值:0.9775422468868562

看来效果确实还可以。准确率基本能达到97左右,这也是因为我们数据的类别区分度本身就比较好,如果选择的数据类别比较相近,分错的概率相对来说就比较大了。

整个测试的流程到这里基本结束了,一般数据挖掘的整体流程不外乎上面这些:

数据清洗转换 特征选择 → 向量化 模型选择与训练 模型测试 模型评估

四、调优

上面讲的是常规的处理流程,在使用算法的过程中,最初得到的结果往往并没有这么理想,这时我们需要对处理过程进行调优,接下来讲讲调优的事情。

4.1 调整训练集大小

训练集的大小将直接决定我们模型的好坏。一般情况下,用于模型训练的训练集应当越大越好(打个比方,如果让你猜一个东西是什么,是不是给的提示越多,越容易才出来?),如果训练集过小,极易导致过拟合,因此有时需要根据结果适当的将训练数据集调大,在我们的例子中对训练数据集的设置是10000,对于训练结果来说已经比较不错了。

过拟合:即模型在训练数据上准确率特别高,几乎都可以分对,但是对于新数据,其预测的准确率并不是很高,这时可以称这个模型的泛化能力差。导致过拟合的原因是数据量太少,训练时模型把个别数据的局部特征当成了全局特征来处理,比如说:如果我们就给模型几片带锯齿边缘的树叶,它可能得到的结果是树叶都带锯齿边缘,那么如果给他一片光滑边缘的树叶,模型可能就把它识别成不是树叶

4.2 特征选择

排除了过拟合,通过查看类别概率分布,发现每个文档在每个类别的概率相差不大,这意味着什么?可以猜测应该是我们的特征对样本数据的区分度不够,也就是说,使用目前选择的特征,无法很好地区分出哪些文档是属于哪个类别的!针对这种情况,我们先做以下两步操作:

过滤有效特征
增大特征维数
更改向量化方式
过滤有效特征
一般做特征选择时,都会尽量选择区分度大的特征,也就是容易从特征识别出是属于哪个类别(如从“足球”很容易看出应该是体育相关的文章)下面代码所示我们对向量做了哪些过滤与优化

//过滤词汇表过
    val numPattern = "[0-9]+".r
    val vocabulary = parentVecModel.vocabulary.flatMap { term =>
      //  1. 滤长度为1的词           2. 过滤数字
      if (term.length == 1 || term.matches(numPattern.regex)) None else Some(term)
    }

4.3 增加特征维数

特征区分效果不好,与特征数量也会有一定的影响,毕竟我们的语料是新闻长文本,每篇文档按200词计算,2000 * 4 *200 = 1600000,总共大于160万词,就算去除重复词、停用词,还应该有10W+的词量。这里我们将维度设置为 15000 对当前的数据预测基本上是恰当的,如果我们将维度设置过高预测准确度反而会下降。降低就是因为产生了过拟合,特征数量太多,模型的泛化能力下降,此时就可以确定准确率最高时的特征维数比较合适。这里由于篇幅问题,15000~50000中间的就省略就不继续测试了。(注意,模型训练的时间会随着特征维度的增加大幅增加,这是因为中间进行向量计算时,其计算量会因为向量长度增加成几何增长,这也就是我们常说的“维度灾难”)

4.4 改进向量化方式

在上面流程中,我们使用了根据词频来选择向量的特征,这是一种常用的方法,但是还有另一种更常用的方法——TF-IDF,中文叫做“文档-逆文档频率”,这里的文档频率其实就是我们上面用到的词频,逆文档频率其实就是预料中文档的总数除以包含该词的文档数,然后再取对数,具体公式如下:

![image-20210309172212404](/Users/hnbian/Library/Application Support/typora-user-images/image-20210309172212404.png)

这里词频除以出现次数最多的词的词频是为了做标准化处理,消除不同文档长短带来的影响(也可以除以当前文档词的总数),而求逆文档频率的时候将包含词的文档数+1是为了做平滑处理,防止出现除零的情况。

使用TF-IDF与直接使用词频做特征选择最大的不同是TF-IDF选出来的词的区分度更高,因为TF-IDF越高的词,代表这个词更加为当前文档所独有,因此更能代表这篇文档的属性。

由于我们这里的各项指标都已经很高了,将TF改成TF-IDF效果不是很大,故不做此步骤的优化!如果你的分类准确率并不是很高,可以替换成TF-IDF做特征选择,效果应该会有所提升,TF-IDF在spark中也已经提供,具体使用可参考这里(TF-IDF)(注意,该示例中使用了HashingTF来提取词频,但是该过程没有生成我们上述的词汇表,也就是说我们不能针对词频提取的特征进行过滤等操作,推荐把此部分更换为我们上述提到的使用CountVectorizer来做词频计算,然后再使用IDF方法提取IDF值)

4.5模型调参

我们这里使用到的LogisticRegression可以设置的参数在上面已经介绍过了,下面我们将针对这些参数进行调整,看看能否提高模型性能。

setMaxItersetTol
这两个参数我们在上面也介绍过了,主要是用来控制模型迭代的次数。不知各位是否还记得,上面我们发现一个问题:使用40次迭代时,多次测试发现结果波动比较大,其实这个原因很明显:因为迭代次数不够,模型还没有收敛到最优,还处于波动状态,因此才会导致这个问题。如果我们设置迭代次数比较多,误差阈值比较小,这样虽然会延长模型训练的时间,但是训练处的模型会更加稳定,性能也会更优!

所以我们设置setMaxIter=100,setTol=1E-7

大家还可以对setRegParam和setElasticNetParam进行测试,这两个参数是控制正则化的,用于减小过拟合现象,这里我们就不进行测试了(如果数据本来就稀疏的情况下,增大setElasticNetParam可能会导致准确率下降!原因我们在上面参数说明的时候已经解释过了)


文章作者: hnbian
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 hnbian !
评论
  目录