【特征工程】特征選擇及mRMR算法解析

一、 特征選擇的幾個常見問題

  • 為什么?
    (1)降低維度,選擇重要的特征,避免維度災難,降低計算成本
    (2)去除不相關的冗余特征(噪聲)來降低學習的難度,去除噪聲的干擾,留下關鍵因素,提高預測精度
    (3)獲得更多有物理意義的,有價值的特征

  • 不同模型有不同的特征適用類型?
    (1)lr模型適用于擬合離散特征(見附錄)
    (2)gbdt模型適用于擬合連續數值特征
    (3)一般說來,特征具有較大的方差說明蘊含較多信息,也是比較有價值的特征

  • 特征子集的搜索:
    (1)子集搜索問題。
    比如逐漸添加相關特征(前向forward搜索)或逐漸去掉無關特征(后向backward搜索),還有雙向搜索。
    缺點是,該策略為貪心算法,本輪最優并不一定是全局最優,若不能窮舉搜索,則無法避免該問題。
    該子集搜索策略屬于最大相關(maximum-relevance)的選擇策略。
    (2)特征子集評價與度量。
    信息增益,交叉熵,相關性,余弦相似度等評級準則。

  • 典型的特征選擇方法


二、從決策樹模型的特征重要性說起

決策樹可以看成是前向搜索與信息熵相結合的算法,樹節點的劃分屬性所組成的集合就是選擇出來的特征子集。

(1)決策樹劃分屬性的依據

(2)通過gini不純度計算特征重要性

不管是scikit-learn還是mllib,其中的隨機森林和gbdt算法都是基于決策樹算法,一般的,都是使用了cart樹算法,通過gini指數來計算特征的重要性的。
比如scikit-learn的sklearn.feature_selection.SelectFromModel可以實現根據特征重要性分支進行特征的轉換。

>>> from sklearn.ensemble import ExtraTreesClassifier
>>> from sklearn.datasets import load_iris
>>> from sklearn.feature_selection import SelectFromModel
>>> iris = load_iris()
>>> X, y = iris.data, iris.target
>>> X.shape
(150, 4)
>>> clf = ExtraTreesClassifier()
>>> clf = clf.fit(X, y)
>>> clf.feature_importances_ 
array([ 0.04...,  0.05...,  0.4...,  0.4...])
>>> model = SelectFromModel(clf, prefit=True)
>>> X_new = model.transform(X)
>>> X_new.shape              
(150, 2)

(3)mllib中集成學習算法計算特征重要性的源碼

在spark 2.0之后,mllib的決策樹算法都引入了計算特征重要性的方法featureImportances,而隨機森林算法(RandomForestRegressionModel和RandomForestClassificationModel類)和gbdt算法(GBTClassificationModel和GBTRegressionModel類)均利用決策樹算法中計算特征不純度和特征重要性的方法來得到所使用模型的特征重要性。
而這些集成方法的實現類都集成了TreeEnsembleModel[M <: DecisionTreeModel]這個特質(trait),即featureImportances是在該特質中實現的。
featureImportances方法的基本計算思路是:

  • 針對每一棵決策樹而言,特征j的重要性指標為所有通過特征j進行劃分的樹結點的增益的和
  • 將一棵樹的特征重要性歸一化到1
  • 將集成模型的特征重要性向量歸一化到1

以下是源碼分析:

def featureImportances[M <: DecisionTreeModel](trees: Array[M], numFeatures: Int): Vector = {
  val totalImportances = new OpenHashMap[Int, Double]()
  // 針對每一棵決策樹模型進行遍歷
  trees.foreach { tree =>
    // Aggregate feature importance vector for this tree
    val importances = new OpenHashMap[Int, Double]()
    // 從根節點開始,遍歷整棵樹的中間節點,將同一特征的特征重要性累加起來
    computeFeatureImportance(tree.rootNode, importances)
    // Normalize importance vector for this tree, and add it to total.
    // TODO: In the future, also support normalizing by tree.rootNode.impurityStats.count?
    // 將一棵樹的特征重要性進行歸一化
    val treeNorm = importances.map(_._2).sum
    if (treeNorm != 0) {
      importances.foreach { case (idx, impt) =>
        val normImpt = impt / treeNorm
        totalImportances.changeValue(idx, normImpt, _ + normImpt)
      }
    }
  }
  // Normalize importances
  // 歸一化總體的特征重要性
  normalizeMapValues(totalImportances)
  // Construct vector
  // 構建最終輸出的特征重要性向量
  val d = if (numFeatures != -1) {
    numFeatures
  } else {
    // Find max feature index used in trees
    val maxFeatureIndex = trees.map(_.maxSplitFeatureIndex()).max
    maxFeatureIndex + 1
  }
  if (d == 0) {
    assert(totalImportances.size == 0, s"Unknown error in computing feature" +
      s" importance: No splits found, but some non-zero importances.")
  }
  val (indices, values) = totalImportances.iterator.toSeq.sortBy(_._1).unzip
  Vectors.sparse(d, indices.toArray, values.toArray)
}

其中computeFeatureImportance方法為:

// 這是計算一棵決策樹特征重要性的遞歸方法
def computeFeatureImportance(
    node: Node,
    importances: OpenHashMap[Int, Double]): Unit = {
  node match {
    // 如果是中間節點,即進行特征劃分的節點
    case n: InternalNode =>
      // 得到特征標記
      val feature = n.split.featureIndex
      // 計算得到比例化的特征增益值,信息增益乘上該節點使用的訓練數據數量
      val scaledGain = n.gain * n.impurityStats.count
      importances.changeValue(feature, scaledGain, _ + scaledGain)
      // 前序遍歷二叉決策樹
      computeFeatureImportance(n.leftChild, importances)
      computeFeatureImportance(n.rightChild, importances)
    case n: LeafNode =>
    // do nothing
  }
}

(4)mllib中決策樹算法計算特征不純度的源碼

InternalNode類使用ImpurityCalculator類的私有實例impurityStats來記錄不純度的信息和狀態,具體使用哪一種劃分方式通過getCalculator方法來進行選擇:

def getCalculator(impurity: String, stats: Array[Double]): ImpurityCalculator = {
  impurity match {
    case "gini" => new GiniCalculator(stats)
    case "entropy" => new EntropyCalculator(stats)
    case "variance" => new VarianceCalculator(stats)
    case _ =>
      throw new IllegalArgumentException(
        s"ImpurityCalculator builder did not recognize impurity type: $impurity")
  }
}

以gini指數為例,其信息計算的代碼如下:

@Since("1.1.0")
@DeveloperApi
override def calculate(counts: Array[Double], totalCount: Double): Double = {
  if (totalCount == 0) {
    return 0
  }
  val numClasses = counts.length
  var impurity = 1.0
  var classIndex = 0
  while (classIndex < numClasses) {
    val freq = counts(classIndex) / totalCount
    impurity -= freq * freq
    classIndex += 1
  }
  impurity
}

以上源碼解讀即是從集成方法來計算特征重要性到決策樹算法具體計算節點特征不純度方法的過程。

三、最大相關最小冗余(mRMR)算法

(1)互信息

互信息可以看成是一個隨機變量中包含的關于另一個隨機變量的信息量,或者說是一個隨機變量由于已知另一個隨機變量而減少的不確定性。互信息本來是信息論中的一個概念,用于表示信息之間的關系, 是兩個隨機變量統計相關性的測度


(2)mRMR

之所以出現mRMR算法來進行特征選擇,主要是為了解決通過最大化特征與目標變量的相關關系度量得到的最好的m個特征,并不一定會得到最好的預測精度的問題。
前面介紹的評價特征的方法基本都是基于是否與目標變量具有強相關性的特征,但是這些特征里還可能包含一些冗余特征(比如目標變量是立方體體積,特征為底面的長度、底面的寬度、底面的面積,其實底面的面積可以由長與寬得到,所以可被認為是一種冗余信息),mRMR算法就是用來在保證最大相關性的同時,又去除了冗余特征的方法,相當于得到了一組“最純凈”的特征子集(特征之間差異很大,而同目標變量的相關性也很大)。
作為一個特例,變量之間的相關性(correlation)可以用統計學的依賴關系(dependency)來替代,而互信息(mutual information)是一種評價該依賴關系的度量方法。
mRMR可認為是最大化特征子集的聯合分布與目標變量之間依賴關系的一種近似
mRMR本身還是屬于filter型特征選擇方法。


可以通過max(V-W)或max(V/W)來統籌考慮相關性和冗余性,作為特征評價的標準。

(3)mRMR的spark實現源碼

mRMR算法包含幾個步驟:

  • 將數據進行處理轉換的過程(注:為了計算兩個特征的聯合分布和邊緣分布,需要將數據歸一化到[0,255]之間,并且將每一維特征使用合理的數據結構進行存儲)
  • 計算特征之間、特征與響應變量之間的分布及互信息
  • 對特征進行mrmr得分,并進行排序
private[feature] def run(
    data: RDD[LabeledPoint],
    nToSelect: Int,
    numPartitions: Int) = {
   
  val nPart = if(numPartitions == 0) data.context.getConf.getInt(
      "spark.default.parallelism", 500) else numPartitions
     
  val requireByteValues = (l: Double, v: Vector) => {       
    val values = v match {
      case SparseVector(size, indices, values) =>
        values
      case DenseVector(values) =>
        values
    }
    val condition = (value: Double) => value <= 255 &&
      value >= 0
    if (!values.forall(condition(_)) || !condition(l)) {
      throw new SparkException(s"Info-Theoretic Framework requires positive values in range [0, 255]")
    }          
  }
       
  val nAllFeatures = data.first.features.size + 1
  // 將數據排列成欄狀,其實是為每個數據都編上號
  val columnarData: RDD[(Long, Short)] = data.zipWithIndex().flatMap ({
    case (LabeledPoint(label, values: SparseVector), r) =>
      requireByteValues(label, values)
      // Not implemented yet!
      throw new NotImplementedError()          
    case (LabeledPoint(label, values: DenseVector), r) =>
      requireByteValues(label, values)
      val rindex = r * nAllFeatures
      val inputs = for(i <- 0 until values.size) yield (rindex + i, values(i).toShort)
      val output = Array((rindex + values.size, label.toShort))
      inputs ++ output   
  }).sortByKey(numPartitions = nPart) // put numPartitions parameter       
  columnarData.persist(StorageLevel.MEMORY_AND_DISK_SER) 
       
  require(nToSelect < nAllFeatures)
  // 計算mrmr過程及對特征進行排序
  val selected = selectFeatures(columnarData, nToSelect, nAllFeatures)
         
  columnarData.unpersist()
 
  // Print best features according to the mRMR measure
  val out = selected.map{case F(feat, rel) => (feat + 1) + "\t" + "%.4f".format(rel)}.mkString("\n")
  println("\n*** mRMR features ***\nFeature\tScore\n" + out)
  // Features must be sorted
  new SelectorModel(selected.map{case F(feat, rel) => feat}.sorted.toArray)
}

下面是基于互信息及mrmr的特征選擇過程:

/**
 * Perform a info-theory selection process.
 *
 * @param data Columnar data (last element is the class attribute).
 * @param nToSelect Number of features to select.
 * @param nFeatures Number of total features in the dataset.
 * @return A list with the most relevant features and its scores.
 *
 */
private[feature] def selectFeatures(
    data: RDD[(Long, Short)],
    nToSelect: Int,
    nFeatures: Int) = {
 
  // 特征的下標
  val label = nFeatures - 1
  // 因為data是(編號,每個特征),所以這是數據數量
  val nInstances = data.count() / nFeatures
  // 將同一類特征放在一起,根據同一key進行分組,然后取出最大值加1(用于后續構建分布直方圖的參數)
  val counterByKey = data.map({ case (k, v) => (k % nFeatures).toInt -> v})
        .distinct().groupByKey().mapValues(_.max + 1).collectAsMap().toMap
   
  // calculate relevance
  val MiAndCmi = IT.computeMI(
      data, 0 until label, label, nInstances, nFeatures, counterByKey)
  // 互信息池,用于mrmr判定,pool是(feat, Mrmr)
  var pool = MiAndCmi.map{case (x, mi) => (x, new MrmrCriterion(mi))}
    .collectAsMap() 
  // Print most relevant features
  // Print most relevant features
  val strRels = MiAndCmi.collect().sortBy(-_._2)
    .take(nToSelect)
    .map({case (f, mi) => (f + 1) + "\t" + "%.4f" format mi})
    .mkString("\n")
  println("\n*** MaxRel features ***\nFeature\tScore\n" + strRels) 
  // get maximum and select it
  // 得到了分數最高的那個特征及其mrmr
  val firstMax = pool.maxBy(_._2.score)
  var selected = Seq(F(firstMax._1, firstMax._2.score))
  // 將firstMax對應的key從pool這個map中去掉
  pool = pool - firstMax._1
 
  while (selected.size < nToSelect) {
    // update pool
    val newMiAndCmi = IT.computeMI(data, pool.keys.toSeq,
        selected.head.feat, nInstances, nFeatures, counterByKey)
        .map({ case (x, crit) => (x, crit) })
        .collectAsMap()
       
    pool.foreach({ case (k, crit) =>
      // 從pool里拿出第k個特征,然后從newMiAndCmi中得到對應的mi
      newMiAndCmi.get(k) match {
        case Some(_) => crit.update(_)
        case None =>
      }
    })
 
    // get maximum and save it
    val max = pool.maxBy(_._2.score)
    // select the best feature and remove from the whole set of features
    selected = F(max._1, max._2.score) +: selected
    pool = pool - max._1
  }   
  selected.reverse
}

具體計算互信息的代碼如下:

/**
 * Method that calculates mutual information (MI) and conditional mutual information (CMI)
 * simultaneously for several variables. Indexes must be disjoint.
 *
 * @param rawData RDD of data (first element is the class attribute)
 * @param varX Indexes of primary variables (must be disjoint with Y and Z)
 * @param varY Indexes of secondary variable (must be disjoint with X and Z)
 * @param nInstances    Number of instances
 * @param nFeatures Number of features (including output ones)
 * @return  RDD of (primary var, (MI, CMI))
 *
 */
def computeMI(
    rawData: RDD[(Long, Short)],
    varX: Seq[Int],
    varY: Int,
    nInstances: Long,     
    nFeatures: Int,
    counter: Map[Int, Int]) = {
   
  // Pre-requisites
  require(varX.size > 0)
 
  // Broadcast variables
  val sc = rawData.context
  val label = nFeatures - 1
  // A boolean vector that indicates the variables involved on this computation
  // 對應每個數據不同維度的特征的一個boolean數組
  val fselected = Array.ofDim[Boolean](nFeatures)
  fselected(varY) = true // output feature
  varX.map(fselected(_) = true) // 將fselected置為true
  val bFeatSelected = sc.broadcast(fselected)
  val getFeat = (k: Long) => (k % nFeatures).toInt
  // Filter data by these variables
  // 根據bFeatSelected來過濾rawData
  val data = rawData.filter({ case (k, _) => bFeatSelected.value(getFeat(k))})
    
  // Broadcast Y vector
  val yCol: Array[Short] = if(varY == label){
   // classCol corresponds with output attribute, which is re-used in the iteration
    classCol = data.filter({ case (k, _) => getFeat(k) == varY}).values.collect()
    classCol
  }  else {
    data.filter({ case (k, _) => getFeat(k) == varY}).values.collect()
  }   
 
  // data是所有選擇維度的特征,(varY, yCol)是y所在的列和y值數組
  // 生成特征與y的對應關系的直方圖
  val histograms = computeHistograms(data, (varY, yCol), nFeatures, counter)
  // 這里只是對數據規約成占比的特征和目標變量的聯合分布
  val jointTable = histograms.mapValues(_.map(_.toFloat / nInstances))
  // sum(h(*, ::))計算每一行數據之和
  val marginalTable = jointTable.mapValues(h => sum(h(*, ::)).toDenseVector)
     
  // If y corresponds with output feature, we save for CMI computation
  if(varY == label) {
    marginalProb = marginalTable.cache()
    jointProb = jointTable.cache()
  }
   
  val yProb = marginalTable.lookup(varY)(0)
  // Remove output feature from the computations
  val fdata = histograms.filter{case (k, _) => k != label}
  // fdata是特征與y的聯合分布,yProb是一個值
  computeMutualInfo(fdata, yProb, nInstances)
}

計算數據分布直方圖的方法:

private def computeHistograms(
    data: RDD[(Long, Short)],
    yCol: (Int, Array[Short]),
    nFeatures: Long,
    counter: Map[Int, Int]) = {
   
  val maxSize = 256
  val byCol = data.context.broadcast(yCol._2)   
  val bCounter = data.context.broadcast(counter)
  // 得到y的最大值
  val ys = counter.getOrElse(yCol._1, maxSize).toInt
 
  // mapPartitions是對rdd每個分區進行操作,it為分區迭代器
  // map得到的是(feature, matrix)的Map
  data.mapPartitions({ it =>
    var result = Map.empty[Int, BDM[Long]]
    for((k, x) <- it) {
      val feat = (k % nFeatures).toInt; val inst = (k / nFeatures).toInt
      // 取得具體特征的最大值
      val xs = bCounter.value.getOrElse(feat, maxSize).toInt
      val m = result.getOrElse(feat, BDM.zeros[Long](xs, ys)) // 創建(xMax,yMax)的矩陣
      m(x, byCol.value(inst)) += 1
      result += feat -> m
    }
    result.toIterator
  }).reduceByKey(_ + _)
}

計算互信息的公式:

private def computeMutualInfo(
    data: RDD[(Int, BDM[Long])],
    yProb: BDV[Float],
    n: Long) = {   
   
  val byProb = data.context.broadcast(yProb)
  data.mapValues({ m =>
    var mi = 0.0d
    // Aggregate by row (x)
    val xProb = sum(m(*, ::)).map(_.toFloat / n)
    for(i <- 0 until m.rows){
      for(j <- 0 until m.cols){
        val pxy = m(i, j).toFloat / n
        val py = byProb.value(j); val px = xProb(i)
        if(pxy != 0 && px != 0 && py != 0) // To avoid NaNs
          // I(x,y) = sum[p(x,y)log(p(x,y)/(p(x)p(y)))]
          mi += pxy * (math.log(pxy / (px * py)) / math.log(2))
      }
    }
    mi       
  }) 
}

附錄

邏輯回歸模型與離散特征
將連續特征離散化(像是獨熱編碼之類的技巧)交給邏輯回歸模型,這樣做的優勢有以下幾點:

  1. 稀疏向量內積乘法運算速度快,計算結果方便存儲,容易擴展。
  2. 離散化后的特征對異常數據有很強的魯棒性:比如一個特征是年齡>30是1,否則0。如果特征沒有離散化,一個異常數據“年齡300歲”會給模型造成很大的干擾。
  3. 邏輯回歸屬于廣義線性模型,表達能力受限;單變量離散化為N個后,每個變量有單獨的權重,相當于為模型引入了非線性,能夠提升模型表達能力,加大擬合。
  4. 離散化后可以進行特征交叉,由M+N個變量變為M*N個變量,進一步引入非線性,提升表達能力。
  5. 特征離散化后,模型會更穩定,比如如果對用戶年齡離散化,20-30作為一個區間,不會因為一個用戶年齡長了一歲就變成一個完全不同的人。當然處于區間相鄰處的樣本會剛好相反,所以怎么劃分區間是門學問。
    李沐少帥指出,模型是使用離散特征還是連續特征,其實是一個“海量離散特征+簡單模型” 同 “少量連續特征+復雜模型”的權衡。既可以離散化用線性模型,也可以用連續特征加深度學習。

參考資料

轉載請注明作者Jason Ding及其出處
jasonding.top
Github博客主頁(http://blog.jasonding.top/)
CSDN博客(http://blog.csdn.net/jasonding1354)
簡書主頁(http://www.lxweimin.com/users/2bd9b48f6ea8/latest_articles)
Google搜索jasonding1354進入我的博客主頁

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容