基于Spark的学生成绩分析系统

本文首发于我的个人博客QIMING.INFO,转载请带上链接及署名。

本文是本人硕士期间云计算课程的一次大作业,所以可能部分内容有充字数的嫌疑,还望各位看官无视。。。但是也正因为此,本文对一些基础概念描述的也挺详细,包括但不限于Spark简介、Spark与Hadoop对比、Spark架构介绍、Pearson相关系数简介、Spark中的combineByKey函数简介、Spark中提交并运行作业的方法等。

问题说明

提出问题及目标

学生成绩是评价学生学习效果和老师教学效果的重要指标,如何充分利用已有的学生成绩数据,发现其中的规律,提高教学质量是广大教育工作者普遍关注的问题。

一般而言,学生各科成绩之间或多或少都存在联系,例如一个学习好的学生各科成绩普遍都比较高。研究者们对此进行了大量的数据采集、统计工作,从他们的研究结果来看,学生各科成绩之间的确存在一定的相关性,只是不同课程之间相关性的强弱不同而已。

通过对学生成绩进行统计分析,可以发现学生成绩中隐藏的课程关联规则和模式,这些知识可以帮助老师更加合理地安排教学内容,从而对教学起到促进作用。

现有西工大某学院2006级至2015级学生的全部成绩,我们想利用这些数据统计出:①各学科的整体情况;②各课程之间的相关性

目标细化及难点

经过讨论,我们决定用每个年级的各科平均成绩来反映该学科的整体情况,并分年级计算各个课程之间的Pearson相关系数以反映各课程间的相关性。并最后将分析的结果保存在HDFS上。

要做到这些工作,我们需要解决以下难点:
① 2006至2015学生人数众多,且课程种类多,为分析带来了极大困难
② 用单机运行处理大量数据需要大量的时间
所以我们便考虑使用分布式计算引擎Spark来克服这些难点。

背景

Spark简介

Spark是专为大规模数据处理而设计的快速通用的计算引擎,最初在2009年由加州大学伯克利分校的AMP实验室用Scala语言开发,并于2010年成为Apache的开源项目。

Spark是基于MapReduce算法实现的分布式计算,拥有Hadoop MapReduce所具有的优点;但不同于MapReduce的是中间过程的输出和结果可以保存在内存中,从而不再需要读写HDFS,大大提高了速度。

Spark和Hadoop的关系图如下:

官方资料介绍,和Hadoop相比,Spark可以让你的程序在内存中运行时速度提升100倍,就算在硬盘上,运行速度也能提升10倍。

Spark架构

Spark的架构示意图如下:

如图可见,Spark主要有以下模块:
① Spark Core:包含Spark的基本功能;尤其是定义RDD的API、操作以及这两者上的动作。其他Spark的库都是构建在RDD和Spark Core之上的。
② Spark SQL:提供通过Apache Hive的SQL变体Hive查询语言(HiveQL)与Spark进行交互的API。每个数据库表被当做一个RDD,Spark SQL查询被转换为Spark操作。
③ Spark Streaming:对实时数据流进行处理和控制。Spark Streaming允许程序能够像普通RDD一样处理实时数据
④ MLlib:一个常用机器学习算法库,算法被实现为对RDD的Spark操作。这个库包含可扩展的学习算法,比如分类、回归等需要对大量数据集进行迭代的操作。
⑤ GraphX:控制图、并行图操作和计算的一组算法和工具的集合。GraphX扩展了RDD API,包含控制图、创建子图、访问路径上所有顶点的操作。

Spark RDD简介

RDD(Resilient Distributed Dataset)即弹性分布式数据集。

RDD是Spark的核心,在Spark中,对数据的所有操作不外乎创建RDD、转化已有RDD以及调用RDD操作进行求值。每个RDD都被分为多个分区,这些分区运行在集群中的不同节点上。RDD可以包含Python、Java、Scala中任意类型的对象,甚至可以包含用户自定义的对象。

RDD是Spark中的抽象数据结构类型,任何数据在Spark中都被表示为RDD。从编程的角度来看,RDD可以简单看成是一个数组。和普通数组的区别是,RDD中的数据是分区存储的,这样不同分区的数据就可以分布在不同的机器上,同时可以被并行处理。因此,Spark应用程序所做的无非是把需要处理的数据转换为RDD,然后对RDD进行一系列的变换和操作从而得到结果。

更多内容可参考本站的这篇文章Spark RDD的简单使用

Pearson相关系数简介

Pearson相关系数 (Pearson Correlation Coefficient)是用来衡量两个数据集合是否在一条线上面,它用来衡量定距变量间的线性关系。Pearson是一个介于-1和1之间的值,用来描述两组线性的数据一同变化移动的趋势。

Pearson系数的计算公式如下:

注:Cov(X,Y)表示X和Y的协方差,E(X ) 表示X的平均值。

Pearson 相关系数大小的含义如下表所示:

相关性 负相关 正相关
-0.09 至 0.0 0.0 至 0.09
-0.3 至 -0.1 0.1 至 0.3
-0.5 至 -0.3 0.3 至 0.5
-1.0 至 -0.5 0.5 至 1.0

实现步骤

源数据是在MySQL数据库中存储的,所以在进行统计分析工作前要先将数据从MySQL数据库中读取出来。

平均成绩分析

取数据

根据课程id取出每个年级的各个学生此门课程的成绩,SQL语句如下:

def sqlStr(courseId: Int): String = {
  s"""SELECT c.gradeId,sc.score FROM tb_student st
      LEFT JOIN tb_score sc ON st.studentId = sc.studentId
      LEFT JOIN tb_class c  ON st.classId = c.classId
      WHERE sc.courseId = $courseId
      ORDER BY sc.studentId"""
}

转化数据

从MySQL中取得的数据在Spark中是DataFrame类型的,为了方便计算,我们需要将其转化为一个键值对RDD,键为“科目+年级”,值为每个学生成绩,如( C语言2006级,89.0),代码如下:

val scoreRdd = scoreDataFrame.map(x => (courseStr(courseId) + gradeStr(x.getLong(0).toInt), x.getDecimal(1).doubleValue)).rdd

计算平均成绩

调用RDD的combineByKey()方法来计算平均数,此方法先将相同键的值加起来,之后除以这个键的个数,得到这个键对应的平均成绩,代码如下:

val averageScoreRdd = scoreRdd.combineByKey(
  createCombiner = (v: Double) => (v: Double, 1),
  mergeValue = (c: (Double, Int), v: Double) => (c._1 + v, c._2 + 1),
  mergeCombiners = (c1: (Double, Int), c2: (Double, Int)) => (c1._1 + c2._1, c1._2 + c2._2),
  numPartitions = 3
).map { case (k, v) => (k, v._1 / v._2) }

代码说明:

  • createCombiner即当遇到一个新键时,创建一个此键对应的累加器,如第一次遇到(C语言2006级,89.0)时,创建(C语言2006级,(89.0,1));
  • mergeValue即当遇到之前已遇到过的键时,将该键的累加器与当前值合并,如再遇到(C语言2006级,90.5)时,合并为(C语言2006级,(179.5,2));
  • mergeCombiners即将各个分区的结果进行合并,得到每门课程对应不同年级的总成绩和总人数,如(C语言2006级,(19173.7,241));
  • numPartitions即表示Spark分了3个分区来并行处理数据;

代码最后一行的map即为将每个键的累加器的总成绩除以总人数,得到一个值为平均成绩的RDD,如(C语言2006级,79.559)。

保存结果

调用saveAsTextFile()方法将计算出的平均成绩存储在HDFS中,每个科目的平均成绩保存在用该科目名命名的文件夹中,代码如下:

averageScoreRdd.sortByKey().saveAsTextFile("hdfs://localhost:9000/user/hadoop/output/score-avg/" + courseStr(courseId))

相关性分析

取数据

根据年级id取出当前年级每个学生所有科目的成绩,SQL语句如下:

def allScoreSqlStr(gradeId: Int): String = {
  s"""SELECT
        MAX(case cou.courseId when '1' THEN IFNULL(sc.score, 0) END) '高等数学',
        MAX(case cou.courseId when '2' THEN IFNULL(sc.score, 0) END) '外语',
        MAX(case cou.courseId when '3' THEN IFNULL(sc.score, 0) END) '离散数学',
        MAX(case cou.courseId when '4' THEN IFNULL(sc.score, 0) END) 'C语言' ,
        MAX(case cou.courseId when '5' THEN IFNULL(sc.score, 0) END) '数据结构',
        MAX(case cou.courseId when '6' THEN IFNULL(sc.score, 0) END) '组成原理',
        MAX(case cou.courseId when '7' THEN IFNULL(sc.score, 0) END) '操作系统'
      FROM  tb_student st
      LEFT JOIN tb_score sc on st.studentId = sc.studentId
      LEFT JOIN tb_course cou on cou.courseId = sc.courseId
      LEFT JOIN tb_class cla ON  st.classId = cla.classId
      WHERE cla.gradeId = $gradeId
      GROUP BY st.studentId
      ORDER BY st.studentId"""
}

转化数据

先将此DataFrame类型的数据转化成数值类型的RDD:

val data = scoreDataFrame.map(x => x.toString).rdd.
  map(x => x.substring(1, x.length - 1).split(",")).
  map(x => x.map(x => x.toDouble))

计算Pearson相关系数可以调用Spark MLlib库中的方法,但是该方法要求RDD为向量类型的RDD,所以继续转化:

val data1 = data.map(x => Vectors.dense(x))

计算Pearson相关系数

直接调用Spark MLlib库中的Statistics.corr方法进行计算:

val corr = Statistics.corr(data1, "pearson")

保存结果

经过上述计算,得到的corr是一个矩阵,我们想将结果保存成CSV以便于查看,所以应先将corr转化成一个数值类型的RDD,再将RDD转化成DataFrame以便于保存为CSV文件,最后将结果保存到HDFS上,每个年级的各科相关系数保存在以该年级命名的文件夹中,代码如下:

val tmpRdd = sc.parallelize(corr.rowIter.toArray.map(x => x.toArray)).map(x => (x(0), x(1), x(2), x(3), x(4), x(5), x(6)))
val tmpDF = tmpRdd.toDF("高等数学", "外语", "离散数学", "C语言", "数据结构", "组成原理", "操作系统")
tmpDF.write.format("csv").save("hdfs://localhost:9000/user/hadoop/output/score-pearson/" + gradeStr(gradeId))

提交运行

代码编写完成后,我们需要将代码打包成jar文件(打包过程略),然后提交到spark集群上运行,有以下三步:

启动master

进入spark的安装目录,输入:

./sbin/start-master.sh

启动worker

./bin/spark-class org.apache.spark.deploy.worker.Worker spark://localhost:7077  

这里的地址为:启动master后,在浏览器输入localhost:8080,查看到的master地址:

启动成功后,用jps查看进程:

因本实验中还用到了HDFS,所以也必须启动它(使用start-dfs.sh),启动后再用jps查看一下进程:

提交作业

./bin/spark-submit --master spark://localhost:7077 --class ScoreAnalysis /home/hadoop/scoreanalysis.jar

注:这里将打包好的jar包放在了/home/hadoop/目录下

可以在4040端口查看作业进度:

结果展示

上文说过,我们将结果保存在了HDFS中,所以当程序运行完成后,要查看结果,必须用Hadoop HDFS提供的命令或者进入namenode管理页面进行查看。

在控制台上输入以下命令:

$ hadoop fs –ls /user/hadoop/output/

结果如下图:

为了便于查看,我们也可以进入namenode管理页面。

查看平均成绩

进入score-avg文件夹,可以看到每个科目创建了一个文件夹。

进入外语文件夹,可以看到有四个文件,_SUCCESS表示文件存储成功,其他三个文件即Spark保存时有三个分区,分别进行了保存。

为了方便查看,我们使用cat命令,将所有文件内容整合到一个本地文件中,如下图所示:

查看Pearson相关系数

进入score-pearson文件夹,可以看到,我们将不同课程的相关性按年级分成了不同的文件夹。

以2006级为例说明,进入2006级文件夹,看到5个文件,_SUCCSESS说明保存成功,其余四个文件即Spark保存时有四个分区,分别进行了保存。

同样,我们用cat命令将其合并到一个文件中,即在控制台中输入:

hadoop fs -cat /user/hadoop/output/score-pearson/2006级/part-* >  //home/hadoop/score-pearson/2006级.csv

之后打开2006级.csv,便能清晰的看到2006级学生成绩各科的pearson相关系数了:

从图中可以看出,组成原理和操作系统的Pearson相关系数最高,达到了0.62,说明组成原理和操作系统的相关性较强。

总结

在本次实验中,我们小组共同合作,完成了用Spark进行对西工大某学院学生2006级至2015级各课程的成绩统计与分析。

在这个过程中,我们学习到了Hadoop、Spark环境的搭建,Spark RDD的使用,Scala语言的用法,以及分布式开发的思想,并成功得出了各课程的平均成绩与相关性。

这次实践我们收获到了很多,但由于能力与时间有限,本实验还有很多可以改进的地方。以后还有很多值得学习与研究的地方,我们会再接再厉,努力做得更好。

附完整代码

import java.util.Properties

import org.apache.spark.mllib.linalg.Vectors
import org.apache.spark.mllib.stat.Statistics
import org.apache.spark.sql.SparkSession

/**
  * xu qi ming
  * 2018.06.15
  */
object ScoreAnalysis {

  /**
    * 根据课程id查找每个学生的成绩和所在年级的SQL语句
    *
    */
  def sqlStr(courseId: Int): String = {
    s"""SELECT c.gradeId,sc.score FROM tb_student st
          LEFT JOIN tb_score sc ON st.studentId = sc.studentId
          LEFT JOIN tb_class c ON  st.classId = c.classId
          WHERE sc.courseId = $courseId
          ORDER BY sc.studentId"""
  }

  /**
    * 查找当前年级每个学生所有科目的成绩的SQL语句
    *
    */
  def allScoreSqlStr(gradeId: Int): String = {
    s"""SELECT
          MAX(case cou.courseId when '1' THEN IFNULL(sc.score, 0) END) '高等数学',
          MAX(case cou.courseId when '2' THEN IFNULL(sc.score, 0) END) '外语',
          MAX(case cou.courseId when '3' THEN IFNULL(sc.score, 0) END) '离散数学',
          MAX(case cou.courseId when '4' THEN IFNULL(sc.score, 0) END) 'C语言' ,
          MAX(case cou.courseId when '5' THEN IFNULL(sc.score, 0) END) '数据结构',
          MAX(case cou.courseId when '6' THEN IFNULL(sc.score, 0) END) '组成原理',
          MAX(case cou.courseId when '7' THEN IFNULL(sc.score, 0) END) '操作系统'
        FROM  tb_student st
        LEFT JOIN tb_score sc on st.studentId = sc.studentId
        LEFT JOIN tb_course cou on cou.courseId = sc.courseId
        LEFT JOIN tb_class cla ON  st.classId = cla.classId
        WHERE cla.gradeId = $gradeId
        GROUP BY st.studentId
        ORDER BY st.studentId"""
  }


  /**
    * 将年级id转换成字符串
    *
    */
  def gradeStr(gradeId: Int): String = {
    gradeId match {
      case 1 => "2006级"
      case 2 => "2007级"
      case 3 => "2008级"
      case 4 => "2009级"
      case 5 => "2010级"
      case 6 => "2011级"
      case 7 => "2012级"
      case 8 => "2013级"
      case 9 => "2014级"
      case 10 => "2015级"
    }
  }

  /**
    * 将课程id转换成相应课程名字符串
    *
    */
  def courseStr(corseId: Int): String = {
    corseId match {
      case 1 => "高等数学"
      case 2 => "外语"
      case 3 => "离散数学"
      case 4 => "C语言"
      case 5 => "数据结构"
      case 6 => "组成原理"
      case 7 => "操作系统"
    }
  }

  def main(args: Array[String]): Unit = {

    // 生成SparkSession对象
    val spark = new SparkSession.Builder().appName("ScoreAnalysis").getOrCreate()
    //生成SparkContext对象
    val sc = spark.sparkContext

    //建立与mysql数据库的连接
    val connProperties = new Properties()
    connProperties.put("driver", "com.mysql.jdbc.Driver")
    connProperties.put("user", "root")
    connProperties.put("password", "Root_1234")
    connProperties.put("fetchsize", "100")

    import spark.implicits._

    /**
      * 计算指定课程的每个年级的平均成绩
      *
      */
    def averageScore(courseId: Int): Unit = {

      //连接数据库,将取得的成绩保存成DataFrame
      val scoreDataFrame = spark.read.jdbc(
        "jdbc:mysql://localhost:3306/db_score",
        s"(${sqlStr(courseId)}) as table01",
        connProperties)

      //将DataFrame转化成一个键值对RDD,键是科目+年级,值是每个学生的成绩
      val scoreRdd = scoreDataFrame.map(x => (courseStr(courseId) + gradeStr(x.getLong(0).toInt), x.getDecimal(1).doubleValue)).rdd

      //计算每个年级的平均成绩
      val averageScoreRdd = scoreRdd.combineByKey(
        createCombiner = (v: Double) => (v: Double, 1),
        mergeValue = (c: (Double, Int), v: Double) => (c._1 + v, c._2 + 1),
        mergeCombiners = (c1: (Double, Int), c2: (Double, Int)) => (c1._1 + c2._1, c1._2 + c2._2),
        numPartitions = 3
      ).map { case (k, v) => (k, v._1 / v._2) }

      //保存到HDFS中
      averageScoreRdd.sortByKey().saveAsTextFile("hdfs://localhost:9000/user/hadoop/output/score-avg/" + courseStr(courseId))
    }

    //循环这7门课,求每个课程每个年级的平均成绩
    for (i <- 1 to 7) {
      averageScore(i)
    }

    /**
      * 计算指定年级的所有科目的pearson相关系数
      *
      */
    def pearsonCorr(gradeId: Int): Unit = {

      //连接数据库,将取得的成绩保存成DataFrame
      val scoreDataFrame = spark.read.jdbc(
        "jdbc:mysql://localhost:3306/db_score",
        s"(${allScoreSqlStr(gradeId)}) as table01",
        connProperties)

      //将DataFrame转成数值类型的RDD
      val data = scoreDataFrame.map(x => x.toString).rdd.
        map(x => x.substring(1, x.length - 1).split(",")).
        map(x => x.map(x => x.toDouble))

      //将数值RDD转为Vector类型的RDD
      val data1 = data.map(x => Vectors.dense(x))

      //调用机器学习库的统计学习中的算法计算pearson相关系数
      //将结果返回成一个矩阵
      val corr = Statistics.corr(data1, "pearson")

      //保存计算的结果到HDFS
      val tmpRdd = sc.parallelize(corr.rowIter.toArray.map(x => x.toArray)).map(x => (x(0), x(1), x(2), x(3), x(4), x(5), x(6)))
      val tmpDF = tmpRdd.toDF("高等数学", "外语", "离散数学", "C语言", "数据结构", "组成原理", "操作系统")
      tmpDF.write.format("csv").save("hdfs://localhost:9000/user/hadoop/output/score-pearson/" + gradeStr(gradeId))
    }

    //循环这10个年级,求每个年级的各科相关系数情况
    for (i <- 1 to 10) {
      pearsonCorr(i)
    }
  }

}

你可能感兴趣的:(大数据)