写给开发者的机器学习指南(八)

2018-08-06 17:44:21 浏览数 (1)

Ranking emails based on their content(Recommendation system)

这个例子将会完全建立你自己的推荐系统。我们将根据以下特征对电子邮件进行排名:“发件人”,“主题”,“主题中的常用术语”和“电子邮件正文中的常用术语”。 稍后在示例中,我们将解释这些特征。 请注意,这些特征是在您制作自己的推荐系统时定义的。当建立自己的推荐系统时,这是最难的部分之一。 达到良好的特征并不简单,当您最终选择这些特征时,数据可能无法直接用于这些特征。

此示例背后的主要想法是向您展示如何执行特征选择,以及如何解决您在使用自己的数据时,开始执行此操作时会出现的问题。

我们将使用我们在电子邮件分类为垃圾邮件或ham的示例中使用的电子邮件数据的子集。这个子集可以在这里下载。此外,你需要停止词文件。 请注意,这个数据是一组接收到的电子邮件,因此我们缺少一半的数据,即此邮箱的外发电子邮件。然而,即使没有这些信息,我们也可以做一些相当不错的排名。

在我们操作排名系统之前,我们首先需要从我们的电子邮件集中提取尽可能多的数据。由于数据格式有点乏味,我们使用代码来解决这个。 内嵌的注释解释了为什么程序怎么完成的。 注意,应用程序是一个带有GUI的swing应用程序。我们这样做是因为我们将需要绘制数据以便稍后获得直观的了解。还要注意,我们直接对测试和训练数据进行分割,方便接下来可以测试我们的模型。

代码语言:javascript复制
import java.awt.{Rectangle}
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import smile.plot.BarPlot
import scala.swing.{MainFrame, SimpleSwingApplication}
import scala.util.Try
object RecommendationSystem extendsSimpleSwingApplication {
 case classEmailData(emailDate : Date, sender : String, subject : String, body : String)
  def top = newMainFrame {
    title ="Recommendation System Example"
    val basePath ="/Users/../data"
    val easyHamPath= basePath   "/easy_ham"
    val mails =getFilesFromDir(easyHamPath).map(x => getFullEmail(x))
    valtimeSortedMails = mails
      .map  (x => EmailData (   getDateFromEmail(x),
                               getSenderFromEmail(x), 
                               getSubjectFromEmail(x), 
                               getMessageBodyFromEmail(x)
                            )
            )
      .sortBy(x=> x.emailDate)
    val(trainingData, testingData) = timeSortedMails
           .splitAt(timeSortedMails.length / 2)
      }
  defgetFilesFromDir(path: String): List[File] = {
    val d = newFile(path)
    if (d.exists&& d.isDirectory) {
      //Remove themac os basic storage file, 
      //andalternatively for unix systems "cmds"
     d.listFiles.filter(x =>  x.isFile &&
                               !x.toString.contains(".DS_Store") &&
                                !x.toString.contains("cmds")).toList
    } else {
      List[File]()
    }
  }
  defgetFullEmail(file: File): String = {
    //Note that theencoding of the example files is latin1, 
    //thus thisshould be passed to the from file method.
    val source =scala.io.Source.fromFile(file)("latin1")
    val fullEmail =source.getLines mkString "n"
    source.close()
    fullEmail
  }
  defgetSubjectFromEmail(email: String): String = {
    //Find theindex of the end of the subject line
    val subjectIndex =email.indexOf("Subject:")
    valendOfSubjectIndex = email   
   .substring(subjectIndex)                        .indexOf('n')  subjectIndex
    //Extract thesubject: start of subject   7 
    // (length ofSubject:) until the end of the line.
    val subject =email
   .substring(subjectIndex   8, endOfSubjectIndex)
    .trim
    .toLowerCase
    //Additionally,we check whether the email was a response and 
    //remove the're: ' tag, to make grouping on topic easier:
   subject.replace("re: ", "")
  }
  defgetMessageBodyFromEmail(email: String): String = {
    valfirstLineBreak = email.indexOf("nn")
    //Return themessage body filtered by only text 
    //from a-z andto lower case
   email.substring(firstLineBreak)
   .replace("n", " ")
   .replaceAll("[^a-zA-Z ]", "")
    .toLowerCase
  }
  defgetSenderFromEmail(email: String): String = {
    //Find theindex of the From: line
    valfromLineIndex = email
   .indexOf("From:")
    val endOfLine =email
   .substring(fromLineIndex)
    .indexOf('n')  fromLineIndex
    //Search forthe <> tags in this line, as if they are there,
    // the emailaddress is contained inside these tags
    valmailAddressStartIndex = email
   .substring(fromLineIndex, endOfLine)
   .indexOf('<')   fromLineIndex   1
    valmailAddressEndIndex = email
   .substring(fromLineIndex, endOfLine)
   .indexOf('>')   fromLineIndex
    if(mailAddressStartIndex > mailAddressEndIndex) {
      //The emailaddress was not embedded in <> tags,
      // extractthe substring without extra spacing and to lower case
      varemailString = email
     .substring(fromLineIndex   5, endOfLine)
      .trim
      .toLowerCase
      //Remove apossible name embedded in () at the end of the line,
      //for examplein test@test.com (tester) the name would be removed here
      valadditionalNameStartIndex = emailString.indexOf('(')
      if(additionalNameStartIndex == -1) {
        emailString
       .toLowerCase
      }
      else {
        emailString
       .substring(0, additionalNameStartIndex)
        .trim
       .toLowerCase
      }
    }
    else {
      //Extract theemail address from the tags. 
      //If these<> tags are there, there is no () with a name in
      // the From:string in our data
      email
     .substring(mailAddressStartIndex, mailAddressEndIndex)
      .trim
      .toLowerCase
    }
  }
  defgetDateFromEmail(email: String): Date = {
    //Find theindex of the Date: line in the complete email
    valdateLineIndex = email
   .indexOf("Date:")
    valendOfDateLine = email
   .substring(dateLineIndex)
    .indexOf('n')  dateLineIndex
    //All possibledate patterns in the emails.
    valdatePatterns = Array(   "EEE MMM ddHH:mm:ss yyyy",
                                "EEE, ddMMM yyyy HH:mm",
                                "dd MMMyyyy HH:mm:ss",
                                "EEE MMMdd yyyy HH:mm")
   datePatterns.foreach { x =>
      //Try todirectly return a date from the formatting.
      //when itfails on a pattern it continues with the next one
      // until oneworks
      Try(returnnew SimpleDateFormat(x)
                   .parse(email
                           .substring(dateLineIndex  5, endOfDateLine)
                            .trim.substring(0,x.length)))
    }
    //Finally, ifall failed return null 
    //(this willnot happen with our example data but without 
    //this returnthe code will not compile)
    null
  }
}

对数据做这样的预处理是非常常见的,并且当您的数据非标准化时,例如这些电子邮件的日期和发件人。然而,执行完这个代码块,我们现在可以使用我们的示例数据的下面这些属性了:完整电子邮件,接收日期,发件人,主题和正文。 这允许我们可以在推荐系统中继续使用这些实际特征。

我们将根据电子邮件的发件人制作第一个推荐特征。那些收到更多电子邮件的人应该排名高于收到较少电子邮件的人。这是一个强假设,但是直觉上你会同意,但事实是垃圾邮件被遗漏了。让我们来看看发送者在整个电子邮件集中的分布情况。

代码语言:javascript复制
//Add to the top body:
//First we group the emails by Sender, then we extractonly the sender address 
//and amount of emails, and finally we sort them onamounts ascending
val mailsGroupedBySender = trainingData
.groupBy(x => x.sender)
.map(x => (x._1, x._2.length))
.toArray
.sortBy(x => x._2)
//In order to plot the data we split the values from theaddresses as 
//this is how the plotting library accepts the data.
val senderDescriptions = mailsGroupedBySender
    .map(x =>x._1)
val senderValues = mailsGroupedBySender
    .map(x =>x._2.toDouble)
val barPlot = BarPlot.plot("", senderValues,senderDescriptions)
//Rotate the email addresses by -80 degrees such that wecan read them
barPlot.getAxis(0).setRotation(-1.3962634)
barPlot.setAxisLabel(0, "")
barPlot.setAxisLabel(1, "Amount of emails received")
peer.setContentPane(barPlot)
bounds = new Rectangle(800, 600)

在这里,您可以看到经常发送邮件的发件人发送了45封电子邮件,其次是37封电子邮件,然后迅速下降。 由于这些“巨大”异常值,直接使用这些数据将导致最高的1或2个发送者被评定为非常重要,而剩下的在推荐系统中将不被考虑。 为了防止这种事情,我们将通过取log1p重新缩放数据。 log1p函数取值的对数,但事先将值加1。 添加1是为了防止在发送仅发送1封电子邮件的发件人的对数值出现问题。在获取数据的对数后,数据看起来像这样。

代码语言:javascript复制
//Code changes:
val mailsGroupedBySender = trainingData
    .groupBy(x=> x.sender)
    .map(x =>(x._1, Math.log1p(x._2.length)))
    .toArray
    .sortBy(x =>x._2)
barPlot.setAxisLabel(1, "Amount of emails receivedon log Scale ")

一定程度上,数据仍然相同,但是却以不同的尺度表示。请注意,现在的数值范围在0.69和3.83之间。 这范围要小得多,使得异常值不会偏离剩下的数据。这种数据操作技巧在机器学习领域是非常常见的。 找到正确的尺度需要一些洞察力。

我们将要研究的下一个特征是主题发生的频率和时间范围。如果主题出现得更多,它可能具有更高的重要性。 此外,我们考虑线程的时间间隔。 因此,主题的频率将使用该主题的电子邮件的时间范围进行正则化。 这使得高度活跃的电子邮件线程会出现在顶部。同样,我们做的这个假设会决定哪些电子邮件应该排名较高。

代码语言:javascript复制
//Add to 'def top' 
val mailsGroupedByThread = trainingData
    .groupBy(x=> x.subject)
//Create a list of tuples with (subject, list of emails)
val threadBarPlotData = mailsGroupedByThread
    .map(x =>(x._1, x._2.length))
    .toArray
    .sortBy(x =>x._2)
val threadDescriptions = threadBarPlotData
    .map(x =>x._1)
val threadValues = threadBarPlotData
    .map(x =>x._2.toDouble)
//Code changes in 'def top'
val barPlot = BarPlot.plot(threadValues,threadDescriptions)
barPlot.setAxisLabel(1, "Amount of emails persubject")

再次使用log1p函数吧!

代码语言:javascript复制
//Code change:
val threadBarPlotData = mailsGroupedByThread
    .map(x =>(x._1, Math.log1p(x._2.length)))
    .toArray       
.sortBy(x => x._2)

现在值的范围在0.69和3.41之间了,这比之前推荐系统的1到29的范围好多了。 然而,我们没有纳入时间框架,因此我们回到正常频率,并应用接下来的转换。为了能够做到这一点,我们首先需要得到第一个和最后一个线程之间的时间:

代码语言:javascript复制
//Create a list of tuples with (subject, list of emails, 
//time difference between first and last email)
val mailGroupsWithMinMaxDates = mailsGroupedByThread
 .map(x =>(x._1, x._2,
             (x._2
               .maxBy(x => x.emailDate)
               .emailDate.getTime - 
             x._2
               .minBy(x => x.emailDate)
               .emailDate.getTime
                            ) / 1000
              )
      )
//turn into a list of tuples with (topic, list of emails,
// time difference, and weight) filtered that onlythreads occur
val threadGroupedWithWeights = mailGroupsWithMinMaxDates
 .filter(x =>x._3 != 0)
 .map(x =>(x._1, x._2, x._3, 10   
               Math.log10(x._2.length.toDouble / x._3)))
 .toArray
 .sortBy(x =>x._4)
val threadGroupValues = threadGroupedWithWeights
 .map(x => x._4)
val threadGroupDescriptions = threadGroupedWithWeights
 .map(x => x._1)
//Change the bar plot code to plot this data:
val barPlot = BarPlot.plot(threadGroupValues,threadGroupDescriptions)
barPlot.setAxisLabel(1, "Weighted amount of emailsper subject")

这个值非常小,我们想重新缩放一点,这是通过采取10log。 然而,单纯的log会导致我们的值变为负,这就是为什么我们添加一个基本值10,使每个值为正。该加权的最终结果如下:

可以看到我们的值大致在(4.4和8.6)之间,这表明异常值不会在很大程度上影响特征。

---未完待续

0 人点赞