Spark-NET-入门指南-二-

76 阅读1小时+

Spark.NET 入门指南(二)

原文:Introducing .NET for Apache Spark

协议:CC BY-NC-SA 4.0

五、数据帧 API

在本章中,我们将了解 DataFrame API,这是我们将与一起使用的核心 API。NET for Apache Spark。Apache Spark 有几个不同的 API,弹性分布式数据集(RDD)和 DataFrame APIs,用于处理。我们将介绍什么是 API 以及为什么 RDD API 在中不可用。净,这是好的;DataFrame API 给了我们所需要的一切。

RDD API 与 DataFrame API

弹性分布式数据集(RDD) API 提供了对 RDD 的访问。rdd 是对海量数据文件的抽象,通过对文件进行分区并将处理分散到不同的计算节点上来实现。当 Apache Spark 第一次出现时,RDD API 是唯一可用的 API,使用 Apache Spark 就是使用 RDD API。

DataFrame API 是一个更高层次的抽象,基于分布在 rdd 之上的数据列。Column对象包括许多方法,我们可以用它们来更有效地编写数据处理代码。在清单 5-1 中,我们有一个 Scala RDD 示例,它解析 Apache web 服务器日志,按照用户列对数据进行分组,并计算字节数和请求数。完整的示例来自 Apache Spark 安装,位于 examples/src/main/Scala/org/Apache/Spark/examples/log query . Scala 文件中。该示例使用mapreduceByKeycollect将 UDF 应用于 RDD。在清单 5-2 中,我们有一个 C#版本的例子,不使用 RDD API,而是使用 DataFrame API 和类似GroupByAggSumCount的方法。

static void Main(string[] args)
{
    Console.WriteLine("Hello World!");
    var regex = @"^([\d.]+) (\S+) (\S+) \[([\w\d:/]+\s[+\-]\d{4})\] ""(.+?)"" (\d{3}) ([\d\-]+) ""([^""]+)"" ""([^""]+)"".*";
    var spark = SparkSession.Builder().AppName("LogReader").GetOrCreate();
    var dataFrame = spark.Read().Text("log.txt");

    dataFrame
        .WithColumn("user", RegexpExtract(dataFrame["value"], regex, 3))
        .WithColumn("bytes", RegexpExtract(dataFrame["value"], regex, 7).Cast("int"))
        .WithColumn("uri", RegexpExtract(dataFrame["value"], regex, 5))
        .Drop("value")
        .GroupBy("user", "uri")
        .Agg(Sum("bytes").Alias("TotalBytesPerUser"), Count("user").Alias("RequestsPerUser"))
        .Show();
}

Listing 5-2The same example rewritten for the DataFrame API in C#

object LogQuery {
  def main(args: Array[String]) {

    val sparkConf = new SparkConf().setAppName("Log Query")
    val sc = new SparkContext(sparkConf)

    val dataSet =
      if (args.length == 1) sc.textFile(args(0)) else sc.parallelize(exampleApacheLogs)
    // scalastyle:off

    val apacheLogRegex =
      """^([\d.]+) (\S+) (\S+) \[([\w\d:/]+\s[+\-]\d{4})\] "(.+?)" (\d{3}) ([\d\-]+) "([^"]+)" "([^"]+)".*""".r
    // scalastyle:on
    /** Tracks the total query count and number of aggregate bytes for a particular group. */
    class Stats(val count: Int, val numBytes: Int) extends Serializable {
      def merge(other: Stats): Stats = new Stats(count + other.count, numBytes + other.numBytes)
      override def toString: String = "bytes=%s\tn=%s".format(numBytes, count)
    }

    def extractKey(line: String): (String, String, String) = {
      apacheLogRegex.findFirstIn(line) match {
        case Some(apacheLogRegex(ip, _, user, dateTime, query, status, bytes, referer, ua)) =>
          if (user != "\"-\"") (ip, user, query)
          else (null, null, null)
        case _ => (null, null, null)
      }
    }

    def extractStats(line: String): Stats = {
      apacheLogRegex.findFirstIn(line) match {
        case Some(apacheLogRegex(ip, _, user, dateTime, query, status, bytes, referer, ua)) =>
          new Stats(1, bytes.toInt)
        case _ => new Stats(1, 0)
      }
    }

    dataSet.map(line => (extractKey(line), extractStats(line)))
      .reduceByKey((a, b) => a.merge(b))
      .collect().foreach{
        case (user, query) => println("%s\t%s".format(user, query))}

    sc.stop()
  }
}
// scalastyle:on println

Listing 5-1Example Scala program using the RDD API

列表 5-1 的输出:

(10.10.10.10,"FRED",GET http://images.com/2013/Generic.jpg HTTP/1.1)  bytes=621  n=2

清单 5-2 的输出显示相同的数据;然而,输出是一个 DataFrame,我称之为 Show on 来显示数据,而不是作为本地对象的数据(strings、int 等)。)在 Scala 中。

+------+--------------------+-----------------+---------------+
|  user|                 uri|TotalBytesPerUser|RequestsPerUser|
+------+--------------------+-----------------+---------------+
|"FRED"|GET http://images...|              621|              2|
+------+--------------------+-----------------+---------------+

处理时,有两件关于 RDD API 的重要事情需要了解。NET for Apache Spark。首先是 RDD API 无法从。NET,也没有计划让他们可用。RDD 有可能在年实现。NET,但使用 RDD API 从。NET 将意味着必须使用我们在上一章看到的酸洗 UDF 来编写,这将是缓慢的。第二件事是我们不能忽略 RDD API,因为 DataFrame API 和诸如GroupByAgg之类的方法是对 RDD API 的抽象。当您调用 DataFrame API 时,代码进行 RDD 调用,在集群上执行的是 RDD API。

在 Apache Spark 1.x 中,Python 和 Scala/Java 之间的性能差异很大,因为您想在 Python 中执行的每个操作都需要将每一行“腌制”或序列化/反序列化到 Python 中进行处理。在 Apache Spark 2.x 时间框架中,DataFrame API 意味着 Python 程序可以调用 Column 上的 Scala 方法,这些方法反过来调用 RDD 函数,并将数据留在 Java 虚拟机(JVM)端。将数据留在 JVM 端意味着 Python 和 Scala/Java 之间的性能差异非常相似。正是这个 DataFrame API 使得编写成为可能。NET 代码具有相似的性能,所以 RDD API 在。因为性能不会很好,开发人员的体验也不会很好。

行动和转变

在我们研究 DataFrame API 并深入研究我们可以用 DataFrame API 做的所有事情之前,我们必须理解动作和转换之间的区别。转换是可能应用于数据帧的东西,而动作将所有先前的转换应用于数据帧。在清单 5-3 中,我展示了一个 Spark SQL 语句,它将在运行时失败,但是因为没有动作,程序成功完成。

spark.Sql("select assert_true(false)")

Listing 5-3A successfully completing query that should fail

将 false 传递给assert_true应该会使程序崩溃,但是当我们运行它时,程序完成了。如果我们添加一个类似于ShowCollectTakeCountFirst的动作,那么当我们执行程序时,我们会得到一个失败。清单 5-4 显示了导致运行时评估和后续失败的相同语句。

spark.Sql("select assert_true(false)").Show()

Listing 5-4An action terminates the statement, which causes the application to crash

当操作执行时,会有一个失败。但是,异常显示出错的方法是"showString":

Unhandled exception. System.Exception: JVM method execution failed: Nonstatic method ' showString ' failed for class '6' when called with 3 arguments ([Index=1, Type=Int32, Value=20], [Index=2, Type=Int32, Value=20], [Index=3, Type=Boolean, Value=False], )

当您得到一个错误,并且异常的细节显示了一个方法时,它通常会分散您对错误的原始原因的注意力,因此重要的是要认识到当您调用一个转换时,它可能是正确的,也可能是不正确的。

在这一点上,人们很可能认为调试一个大型的失败的 Apache Spark 应用程序是不可能的。然而,也不全是坏事。有些操作是经过验证的,例如文件的架构或查询中的列。即使清单 5-5 没有动作,仍然会有一个失败,因为我们试图使用的列不存在。

spark.Sql("SELECT ID FROM Range(100)").Select("UnknownColumn")

Listing 5-5Failure will occur without an action under certain circumstances

在这种情况下,执行了足够多的 Spark SQL 语句,因此 Apache Spark 知道列 UnknownColumn 无效,因此它将失败并出现异常。

数据帧 API

在本节中,我们将开始进一步探索我们可以用 DataFrame API 做什么。有一些定义相当好的类应该研究,这样我们就可以有效地使用 DataFrame API。我们将从DataFrameReader开始,它是我们用来将数据读入 Apache Spark 的类,然后我们将看看如何在不读取数据的情况下创建DataFrames,然后是DataFrameWriter,这是我们如何将处理结果再次写出来,最后更详细地看一下Column对象,这在 Apache Spark 中处理数据时非常重要。

数据帧阅读器

DataFrameReader是允许我们读取文件和数据源的类,然后我们可以用 Apache Spark 处理这些文件和数据源。我们使用一个SparkSession到达DataFrameReader,清单 5-6 展示了如何从SparkSession中读取并创建一个DataFrame,在清单 5-7 中,我们展示了 F#中的一个DataFrameReader

let spark = SparkSession.Builder().GetOrCreate()
let reader = spark.Read()
            |> fun reader -> reader.Format("csv")
            |> fun reader -> reader.Option("header", true)
            |> fun reader -> reader.Option("sep", "|")

let dataFrame = reader.Load("./csv_file.csv")
dataFrame.Show()

Listing 5-7Using the DataFrameReader to read data in F#

var spark = SparkSession.Builder().GetOrCreate();

DataFrameReader reader =
    spark.Read().Format("csv").Option("header", true).Option("sep", ",");

var dataFrame = reader.Load("./csv_file.csv");

dataFrame.Show();

Listing 5-6Using the DataFrameReader to read data in C#

在 Apache Spark 中,理解我们创建的许多对象(如果不是全部的话,比如 DataFrameReader)是不可变的是很重要的,因此如果您做了类似清单 5-8 中的事情,我们将不会修改原始对象,也不会得到想要的效果。

var spark = SparkSession.Builder().GetOrCreate();
var reader = spark.Reader();
reader.Option("header", true);
reader.Option("sep", "|");
reader.Csv("path.csv).Show();

Listing 5-8Each object is immutable, so unless we use method chaining, we could reference the wrong object

如果我们运行这段代码,我们在阅读器上设置的选项就会丢失。如果我们想保留它们,那么我们应该使用清单 5-6 中的方法链接。

CSV、检察官、Orc 与负载

有两种方法可以让 Apache Spark 以物理方式读取文件;第一种是在DataFrameReader上使用Load方法,第二种是调用Format(),然后调用Load()。清单 5-9 展示了如何调用特定于格式的方法,清单 5-10 展示了如何指定格式并调用Load

Spark.Read().Format("csv").Load("/path/to/.csv")

Listing 5-10Specifying the format of the file and using Load

spark.Read().CSV("/path/to/CSV")

Listing 5-9Using the custom format methods on the DataFrameReader

使用 DataFrameReader 的每种方法最终都会得到相同的结果,因此您可以选择使用哪种方法。当我不知道在编写代码时将加载哪种数据格式,或者不知道是否将在运行时提供类型信息(可能是我们随文件一起接收的一些元数据)时,我通常使用 Format/Load 方法。

默认情况下,格式设置为 parquet,所以如果您想使用 Load 方法,那么您要么需要加载一个 parquet 文件,要么首先调用 format。Apache Spark 本身支持表 5-1 中列出的格式。不过,也可以为其他数据源添加 JAR 文件,并使用 Format/Load 方法加载文件。清单 5-11 给出了一个例子,它使用 Format 方法指定一个 avro 文件,清单 5-12 给出了一个例子,它使用 Format 方法指定一个 Excel XLSX 文件。

表 5-1

Apache Spark 中的本地文件类型支持

| 文件类型 | | 文本 | | 数据 | | 镶木地板 | | 妖魔 | | 数据库编程 |

有两点需要注意。首先,每种文件类型在DataFrameReader上都有一个方法,比如Text()JSON()Parquet()等等,这些方法允许加载文件。其次,尽管 Java 数据库连接(JDBC)是一种连接到类似于 ODBC 或 ADO.NET 的数据库的方式,但我们仍然认为它是 Apache Spark 中的一种文件类型,因为 DataFrameReader 对象使用它,并且使用它和使用 DataFrameReader 从文件中获取数据没有区别。

spark.Format("avro").Load("com.crealytics.spark.excel")

Listing 5-12Using Format to read from an Excel XLSX file

spark.Format("avro").Load("/path/to/avro.avro");

Listing 5-11Using Format to read from an avro file

因为 Apache Spark 没有提供这两种格式,所以我们需要向 Apache Spark 传递额外的参数,告诉它加载包含可以处理这两种格式的代码的 JAR 文件。

DataFrameReader 选项

在读取文件时,有许多考虑因素和选项可供我们用来加载文件。例如,在一个 CSV 文件中,文件分隔符是什么,是否有标题行?要查看哪些选项适用于哪种文件类型,可以访问DataFrameReader的 Apache Spark 文档,访问csvtextjsonparquet等文件类型方法,方法描述包含了可用选项的列表: https://spark.apache.org/docs/latest/api/java/org/apache/spark/sql/DataFrameReader.html#csv-scala.collection.Seq-

文档显示了可用的选项以及默认值。如果我们以 CSV 为例,我们可以看到今天有 28 个具体选项;默认分隔符(sep)是“”,默认编码是“UTF-8”。

设置选项时,您可以逐个指定它们,也可以传入一个您希望传入的所有选项的"Dictionary<string, string>"。当您需要传入一个不是字符串的值类型时,使用字符串表示,比如“true”,Apache Spark 会将它转换为正确的类型。

推断模式与手动指定的模式

有些格式,比如 avro 或 parquet,除了数据之外,还包含了作为定义良好的元数据的模式。JSON 或 CSV 等其他文件格式不包含模式定义,因此 Apache Spark 可以尝试推断模式。对于 CSV 文件,默认情况下不推断模式,但是您可以使用Option("inferSchema", "true")来推断模式。JSON 文件总是试图推断模式,除非手动指定了模式。不可能不传入模式,而让 Apache Spark 不推断模式。

有两种方法可以将模式传递给 DataFrameReader,第一种是在 SQL Server 或 Oracle 等传统数据库系统中传递我们称之为 DDL 的内容;我们在清单 5-13 中演示了这一点。第二种方法是传递所谓的StructType,它是模式定义。如果您做任何比 hello world 类型的 Apache Spark 应用程序更复杂的事情,那么您很可能会再次遇到StructType类型;清单 5-14 展示了如何在 C#中将 StructType 传递给 Apache Spark,清单 5-15 展示了如何在 F#中将 StructType 传递给 Apache Spark。

var spark = SparkSession.Builder().GetOrCreate();
            var dataFrame = spark.Read().Option("sep", ",").Option("header", "false")
                .Schema("greeting string, first_number int, second_number float")
                .CSV("csv_file.csv");

            dataFrame.PrintSchema();
            dataFrame.

Listing 5-13Passing a DDL string to Apache Spark to specify the schema

运行清单 5-13 产生以下输出:

let dataFrame = SparkSession.Builder().GetOrCreate()
                |> fun spark -> spark.Read()
                |> fun reader ->
                    reader.Schema
                        (StructType
                            ([| StructField("greeting", StringType())
                                StructField("first_number", IntegerType())
                                StructField("second_number", FloatType()) |]))

                |> fun reader -> reader.Option("sep", ",").Option("header", "false").Csv("csv_file.csv")

dataFrame.PrintSchema()
dataFrame.Show()

Listing 5-15Passing

a StructType to the DataFrameReader to manually specify the schema in F#

var spark = SparkSession.Builder().GetOrCreate();

var schema = new StructType(new List<StructField>()
{
    new StructField("greeting", new StringType()),
    new StructField("first_number", new IntegerType()),
    new StructField("second_number", new FloatType())
});

var dataFrame = spark.Read().Option("sep", ",").Option("header", "false")
    .Schema(schema)
    .Csv("csv_file.csv");

dataFrame.PrintSchema();
dataFrame.Show();

Listing 5-14Passing a StructType to the DataFrameReader to manually specify the schema in C#

root
 |-- greeting: string (nullable = true)
 |-- first_number: integer (nullable = true)
 |-- second_number: float (nullable = true)

+--------+------------+-------------+
|greeting|first_number|second_number|
+--------+------------+-------------+
|   hello|         123|        987.0|
|      hi|         456|        654.0|
+--------+------------+-------------+

创建数据帧

在一些罕见的情况下,您希望从代码中创建一个DataFrame,而不是将数据读入 Apache Spark。有几种创建数据帧的方法。我们可以调用CreateDataFrame,也可以使用SparkSession运行一些 Spark SQL 来创建一个数据帧,或者使用SparkSession创建一个DataFrame,其中包含一组使用Range方法的连续数字。

创建数据帧

第一种方法是使用CreateDataFrame,我们可以向它传递一个特定类型的列表或数组,这将创建一个由数组或列表中的值组成的单个列。或者,我们可以传入一个数组或一个列表GenericRow,这将允许我们创建多个列。清单 5-16 展示了传入单一类型的数组,这创建了一个具有单个列的 DataFrame,还传入了一个 GenericRows 的列表,这也需要将模式指定为 StructType,清单 5-17 展示了如何使用 F#创建 data frame。

let spark = SparkSession.Builder().GetOrCreate()

spark.CreateDataFrame([| "a"; "b"; "c" |]).Show()

spark.CreateDataFrame([| true; true; false |]).Show()

spark.CreateDataFrame([| GenericRow([| "hello"; 123; 543.0 |])
                         GenericRow([| "hi"; 987; 456.0 |]) |],
                      StructType
                          ([| StructField("greeting", StringType())
                              StructField("first_number", IntegerType())
                              StructField("second_number", DoubleType()) |]

                          )).Show()

Listing 5-17Creating DataFrames using CreateDataFrame in F#

var spark = SparkSession.Builder().GetOrCreate();

spark.CreateDataFrame(new [] {"a", "b", "c"}).Show();
spark.CreateDataFrame(new [] {true, true, false}).Show();

var schema = new StructType(new List<StructField>()
{
    new StructField("greeting", new StringType()),
    new StructField("first_number", new IntegerType()),
    new StructField("second_number", new DoubleType())
});

IEnumerable<GenericRow> rows = new List<GenericRow>()
{
    new GenericRow(new object[] {"hello", 123, 543D}),
    new GenericRow(new object[] {"hi", 987, 456D})
};

spark.CreateDataFrame(rows, schema).Show();

Listing 5-16Creating DataFrames in C#

运行清单 5-16 和 5-17 会产生以下输出:

+---+
| _1|
+---+
|  a|
|  b|
|  c|
+---+

+-----+
|   _1|
+-----+
| true|
| true|
|false|
+-----+

+--------+------------+-------------+
|greeting|first_number|second_number|
+--------+------------+-------------+
|   hello|         123|        543.0|
|      hi|         987|        456.0|
+--------+------------+-------------+

请注意,前两个数据帧有一个名为“_1”的列。英寸 NET for Apache Spark 中,CreateDataFrame的类型化版本将列表或数组转换成GenericRow并创建一个名为“_1”的StructType,在将它们传递给 Apache Spark 之前传递列表或数组的数据类型。

一旦有了DataFrame,就可以使用WithColumnRenamed重命名列,并用新名称替换现有的列名。这在清单 5-18 中有所展示。

spark.CreateDataFrame(new [] {"a", "b", "c"}).WithColumnRenamed("_1", "ColumnName").Show();

Listing 5-18Renaming a column using WithColumnRenamed

Spark SQL

创建数据帧的第二种方法是将一些 SQL 传递给 Apache Spark,如果可以的话,它将创建一个数据帧。清单 5-19 展示了一些可以传递给 Apache Spark 以生成数据帧的示例 SQL 语句。

var spark = SparkSession.Builder().GetOrCreate();
spark.Sql("SELECT ID FROM Range(100, 150)").Show();
spark.Sql("SELECT 'Hello' as Greeting, 123 as A_Number").Show();
spark.Sql("SELECT 'Hello' as Greeting, 123 as A_Number union SELECT 'Hi', 987").Show();

Listing 5-19Example Spark SQL statements that will generate DataFrames

运行时,输出显示了创建的所有行:

+---+
| ID|
+---+
|100|
|101|
|102|
|103|
|104|
|105|
|106|
|107|
|108|
|109|
|110|
|111|
|112|
|113|
|114|
|115|
|116|
|117|
|118|
|119|
+---+
only showing top 20 rows

+--------+--------+
|Greeting|A_Number|
+--------+--------+
|   Hello|     123|
+--------+--------+

+--------+--------+
|Greeting|A_Number|
+--------+--------+
|   Hello|     123|
|      Hi|     987|

范围方法

从创建数据帧的最后一个选项。NET 将对 SparkSession 对象使用 Range 方法。Range 方法接受一个整数值,Range 返回一个数据帧,其中包含从 0 到传入值之间的所有值,或者您可以给出一个起始值和结束值,您将返回一个包含这两个值之间的所有值的数据帧。清单 5-20 展示了范围的两种用法,然后我们展示了清单 5-20 的输出。

var spark = SparkSession.Builder().GetOrCreate();
spark.Range(5).Show();
spark.Range(10, 12).Show();

Listing 5-20Calling Range on the SparkSession to create a DataFrame

这样的输出是

+---+
| id|
+---+
|  0|
|  1|
|  2|
|  3|
|  4|
+---+

+---+
| id|
+---+
| 10|
| 11|
+---+

数据帧写入器

DataFrameWriter是我们用来再次写回数据的类。它与DataFrameReader的相似之处在于,你可以使用Csv()Parquet()等使用特定的格式来书写,或者指定格式并使用Format()Save()方法。我们直接从 DataFrame 进入 DataFrameWriter,并在清单 5-21 中展示了一个编写 DataFrame 的例子。

var spark = SparkSession.Builder().GetOrCreate();
var dataFrame = spark.Range(100);

dataFrame.Write().Csv("output.csv");
dataFrame.Write().Format("json").Save("output.json");

Listing 5-21The DataFrameWriter

DataFrameWriter 的工作方式与 DataFrameReader 非常相似。如果您想改变数据的写入方式,那么有一组选项可供您使用。例如,在编写 CSV 文件时,您可以控制分隔符、标题、编码等。在 DataFrameWriter 文档中可以找到编写文件时可以设置的所有可用选项,并查看每种编写方法,如csv()json()等:https://spark.apache.org/docs/latest/api/java/org/apache/spark/sql/DataFrameWriter.html#csv-java.lang.String-

数据帧写入器模式

当我们写数据时,我们可以选择如果有现有数据会发生什么。我们可以选择将数据添加到任何现有数据的末尾。我们可以选择覆盖任何现有数据。如果数据已经存在,我们可以选择什么都不做,最后,如果数据已经存在,我们可以选择引发错误。如果数据已经存在,最后一个出错的模式是默认模式。在清单 5-22 中,我们展示了所有写模式的一个例子。

var spark = SparkSession.Builder().GetOrCreate();
var dataFrame = spark.Range(100);

dataFrame.Write().Mode("overwrite").Csv("output.csv");
dataFrame.Write().Mode("ignore").Csv("output.csv");
dataFrame.Write().Mode("append").Csv("output.csv");
dataFrame.Write().Mode("error").Csv("output.csv");

Listing 5-22Apache Spark DataFrameWriter write modes

请注意,最后一行将导致异常,因为文件已经存在,如果文件已经存在,“error”将抛出异常。

分区依据

当我们写数据时,我们也可以选择一列或多列来划分数据。这意味着,如果我们有一个看起来像表 5-2 的数据帧,并且我们选择按年份和国家列进行分区,我们将最终得到每个国家每年一个文件。

表 5-2

抽样资料

|

国家

|

|

金额

| | --- | --- | --- | | 联合王国 | Two thousand and twenty | Five hundred | | 联合王国 | Two thousand and twenty | One thousand | | 法国 | Two thousand and twenty | Five hundred | | 法国 | One thousand nine hundred and ninety | One hundred | | 联合王国 | One thousand nine hundred and ninety | One hundred |

在清单 5-23 中,数据被写入,但是按照国家和年份进行分区,我们最终得到的是五个独立的文件,每个国家/年份组合一个文件。比如英国,2020 文件的路径是“output . CSV/Year = 2020/Country = UK/part-randomguid . CSV”。

var spark = SparkSession.Builder().GetOrCreate();
var dataFrame = spark.CreateDataFrame(new List<GenericRow>()
    {
        new GenericRow(new object[] {"UK", 2020, 500}),
        new GenericRow(new object[] {"UK", 2020, 1000}),
        new GenericRow(new object[] {"FRANCE", 2020, 500}),
        new GenericRow(new object[] {"FRANCE", 1990, 100}),
        new GenericRow(new object[] {"UK", 1990, 100})
    },
    new StructType(
        new List<StructField>()
        {
            new StructField("Country", new StringType()),
            new StructField("Year", new IntegerType()),
            new StructField("Amount", new IntegerType())
        }

    ));

dataFrame.Write().PartitionBy("Year", "Country").Csv("output.csv");

Listing 5-23Partitioning the data when writing it out

如果我们在读取数据时像这样对数据进行分区,并希望在 Apache Spark 中过滤数据,如果我们可以对分区的列进行过滤,那么读取效率会高得多。例如,如果我们使用"spark.Read().Csv("output.csv").Filter("Year = 2020 AND Country = 'UK'").Show();",那么分区将被使用,以便只有分区中匹配过滤器的数据将被读入。如果你有很多数据,但只需要其中的一小部分,那么这可以使阅读非常有效。

控制文件名

当我们使用 Apache Spark 写入数据并指定文件和文件名时,例如“c:\temp\output.csv”或“/tmp/output.csv”,我们将得到一个名为“output.csv”的文件夹,在该文件夹中有一个或多个遵循“part-part number-randomguid-jobid . format”命名过程的文件,例如“part-00003-de 71 ce 5c-63aa-4bd 9-863 c-9696 F9 f 86849 . c 0”

最终得到的单个文件的数量取决于您拥有的数据量以及这些数据的分区数量。如果你必须只有一个文件,你可以通过在调用DataFrameWriter之前在DataFrame 上做一个Coalesce()来控制你最终有多少个文件。Coalesce将允许您指定写出数据时使用多少分区。

控制文件名是不可能的,虽然这可能有点混乱和烦人,但这不是一个实际问题。我们写出数据,当我们读回数据时,我们传入文件夹的名称,如果使用分区,Apache Spark 将负责查找目录或任何子目录中的任何文件。

列和函数

我们将在本章介绍的 DataFrame API 的最后一部分是 Column 类。与 RDD API 的 map/reduce 类型操作相比,Column 类使得 DataFrame API 如此易于使用。Column是方法可用于实际处理数据的地方。请记住,DataFrame API 是基于数据列的,因此很自然地,Column类应该是我们处理数据的核心。

Column类是一个属于Microsoft.Spark.Sql.Functions的静态成员,你既可以使用Function类到达Column,比如Functions.Column,也可以在 C#中使用静态导入“使用静态Microsoft.Spark.Sql.Functions;”。Column 也有别名Col,所以如果你看到Column或者Col,它们是可以互换的。

清单 5-24 ,C#和 5-25,F#展示了我们如何使用Column来处理一个DataFrame中的数据。

let spark = SparkSession.Builder().GetOrCreate()
let dataFrame = spark.Range(100L)

dataFrame.Select(Functions.Column("ID")).Show()
dataFrame.Select(Functions.Col("ID")).Show()

dataFrame.Select(Functions.Column("ID").Name("Not ID")).Show()
dataFrame.Select(Functions.Col("ID").Name("Not ID")).Show()

dataFrame.Filter(Functions.Column("ID").Gt(100)).Show()
dataFrame.Filter(Functions.Col("ID").Gt(100)).Show()

Listing 5-25Using a Column or Col object in F#

using Microsoft.Spark.Sql;
using static Microsoft.Spark.Sql.Functions;

namespace Listing5_24
{
    class Program
    {
        static void Main(string[] args)
        {
            var spark = SparkSession.Builder().GetOrCreate();
            var dataFrame = spark.Range(100);

            dataFrame.Select(Column("ID")).Show();
            dataFrame.Select(Col("ID")).Show();

            dataFrame.Select(Column("ID").Name("Not ID")).Show();
            dataFrame.Select(Col("ID").Name("Not ID")).Show();

            dataFrame.Filter(Column("ID").Gt(100)).Show();
            dataFrame.Filter(Col("ID").Gt(100)).Show();
        }
    }

}

Listing 5-24Using a Column or Col object in C#, including the static using statement to bring the Functions into scope

当我们想要访问一个列时,我们可以使用Function.ColFunction.Column,或者数据帧本身可以使用列名来索引,比如dataFrame["ColumnName"]

要确切了解您可以对列执行什么操作,以及哪些功能在中可用。NET for Apache Spark,可以访问列( https://docs.microsoft.com/en-us/dotnet/api/microsoft.spark.sql.column?view=spark-dotnet )和函数( https://docs.microsoft.com/en-us/dotnet/api/microsoft.spark.sql.functions?view=spark-dotnet )的文档页面。

摘要

DataFrame API 是我们如何使用 Apache Spark 读取、处理和写入数据的核心。DataFrame API 是我们如何使用 Apache Spark 的核心。NET,理解什么是数据帧,如何读入数据,使用列和函数进行处理,并再次写回数据,是我们如何以编程方式使用 Apache Spark 的核心。

在下一章中,我们将看看如何通过使用配置单元表使用 SQL 查询来获得 Apache Spark 的强大功能。这种访问 Apache Spark 的不同方法是它吸引许多人的部分原因。想用 Scala/Python/R/编程的人。NET 可以做到这一点,想使用 SQL 的人也可以使用它。我发现自己主要编写代码,但使用 SQL 来探索数据或帮助迁移现有的遗留 SQL 解决方案。

六、Spark SQL 和 Hive 表

在这一章中,我们将看看 Apache Spark SQL API。SQL API 允许我们编写符合 ANSI SQL:2003 子集的查询,这是 SQL 数据库查询语言的标准。SQL API 意味着我们可以将数据存储在文件中,可能存储在数据湖中,并且我们可以编写访问数据的 SQL 查询。

在 Apache Spark 之前,Apache Hive 是由脸书创建的,作为一种对存储在 Hadoop 甚至 Hadoop 分布式文件系统(HDFS)中的数据运行 SQL 查询的方式。Apache Hive 由一个“metastore”和一个查询引擎组成,metastore 是一组关于文件的元数据,允许开发人员读取它们,就像它们是数据库中的表一样,查询引擎将 SQL 查询转换为可以对存储在 HDFS 中的文件执行的 map/reduce 作业。

当 Apache Spark 第一次发布时,它有 RDD API,没有 SQL 支持,但是当 Apache Spark 2.0 发布时,它包括了一个 SQL 解析器和到 Apache Hive metastore 的连接。这意味着 Apache Spark 能够使用自己的“catalyst”引擎运行 SQL 查询,同时使用 Apache Hive metastore 来存储读写文件所需的元数据。

什么是 SQL API

当我们使用。对于 Apache Spark API,我们通常有数据帧。我们要么从文件中读取它们,要么创建新的文件,但这些是我们工作的操作单元,将它们传递给 Apache Spark,转换并再次写回。

我们可以使用 SQL 来访问我们存储的任何数据,而不是直接从文件中读取或向文件中写入数据,我们已经在这些数据中创建了指向这些文件的元数据。

在清单 6-1 中,我们将看到如何获取一个 CSV 文件,将其注册为 Hive metastore 中的一个表,然后使用 SQL 查询读取该文件的内容。

var spark = SparkSession.Builder().GetOrCreate();
spark.Sql("CREATE TABLE Users USING csv OPTIONS (path './Names.csv')");

spark.Sql("SELECT * FROM Users").Show();

Listing 6-1Create a table in the Hive metastore, pointing to a file on disk

当我们执行这个程序时,我们看到文件的内容:

» ./RunListing.sh 6 01
+------+
|   _c0|
+------+
|    Ed|
|  Bert|
|  Mary|
|Martha|
+------+

Apache Spark DataFrame 和 SQL APIs 与大多数现代数据库系统有一个相似的特性,即它会生成一个计划,并且有一种方法可以查看为 Apache Spark 如何执行查询而生成的计划。在清单 6-2 中,我们将查看通过运行前面的 SQL 语句以及通过使用 DataFrame API 读取同一文件生成的计划,我们将看到生成的实际计划是相同的。

var spark = SparkSession.Builder().GetOrCreate();
spark.Sql("CREATE TABLE Users USING csv OPTIONS (path './Names.csv')");

spark.Sql("SELECT * FROM Users").Explain();
spark.Read().Format("csv").Load("./Names.csv").Explain();

Listing 6-2Comparing plans from the SQL and DataFrame API

当我们执行这个程序时,我们看到以下输出:

== Physical Plan ==
*(1) FileScan csv default.users[_c0#10] Batched: false, Format: CSV, Location: InMemoryFileIndex[file..., PartitionFilters: [], PushedFilters: [], ReadSchema: struct<_c0:string>
== Physical Plan ==
*(1) FileScan csv [_c0#22] Batched: false, Format: CSV, Location: InMemoryFileIndex[..., PartitionFilters: [], PushedFilters: [], ReadSchema: struct<_c0:string>

除了表名(包含在 SQL 版本的计划中)之外,它们都是相同的,这表明您是通过 DataFrame API 还是 SQL API 访问数据。您仍然会得到相同的执行路径。

在上下文之间传递数据

当我们调用SparkSession.Sql时,结果在一个数据帧中,所以将数据从 SQL API 传递到您的代码,在那里您可以运行您的标准数据帧调用,这是一个运行 select 语句的问题。如果我们想走另一条路,即获取一个数据帧并使其在 SQL 上下文中可用,那么我们需要执行一个步骤,以便将数据识别为 hive 目录中的一个表。

有许多方法可以让 SQL 使用数据帧。首先,我们可以在 Apache Hive 中创建一个托管表。DataFrameWriter对象有一个名为SaveAsTable的方法。当我们调用它时,DataFrame 被写成一组 parquet 文件,并被添加到 Apache Hive 目录中。清单 6-3 和 6-4 展示了如何获取一个数据帧并将其写成一个 Apache Hive 管理的表。

let spark = SparkSession.Builder().Config("spark.sql.legacy.allowCreatingManagedTableUsingNonemptyLocation", "true").GetOrCreate()

    spark.CreateDataFrame([|10;11;12;13;14;15|])
        |> fun dataFrame -> dataFrame.WithColumnRenamed("_1", "ID")
        |> fun dataFrame -> dataFrame.Write().SaveAsTable("saved_table")

    spark.Sql("select * from saved_table").Show()

Listing 6-4Writing a DataFrame as a managed Apache Hive table in F#

var spark = SparkSession.Builder().Config("spark.sql.legacy.allowCreatingManagedTableUsingNonemptyLocation", "true").GetOrCreate();

var dataFrame = spark.CreateDataFrame(new [] {10, 11, 12, 13, 14, 15}).WithColumnRenamed("_1", "ID");

dataFrame.Write().Mode("overwrite").SaveAsTable("saved_table");
spark.Sql("select * from saved_table").Show();

Listing 6-3Writing a DataFrame as a managed Apache Hive table in C#

当我们执行这些程序时,我们可以看到数据帧的内容:

+---+
| ID|
+---+
| 12|
| 15|
| 14|
| 13|
| 11|
| 10|
+---+

如果我们看一下输出,我们会发现即使我们创建的数据帧中的数字是以升序排列的,它们现在也是以随机顺序显示的。这是因为当我们写出数据帧时,它在物理上被保存到许多 Parquet 文件中,每个分区一个文件。正如我们在第五章中看到的,我们得到多个文件是因为工作在执行者之间被分割的方式。

如果我们看一下文件系统,我运行程序的文件夹有一个 spark-warehouse 目录,其中有一个与我们的表“saved_table”同名的文件夹,最后是一组五个 Parquet 文件。

还有第二件要注意的事情,当我创建SparkSession时,我必须传递一个选项,如果文件已经存在,该选项将允许SaveAsTable方法物理地覆盖目录中的文件;仅仅在DataFrameWriter上设置Mode("overwrite")是不够的。但是请注意,如果您使用的是 Apache Spark 3.0 或更高版本,那么您必须删除此选项,因为它会导致 Apache Spark 在配置设置不再有效时抛出异常。

我应该在这里指出,本地 spark-warehouse 来自运行 Apache Spark 的本地实例。在一个环境中,而不是在您的开发人员机器上,我们将正确配置 Apache Hive 仓库,或者用 Databricks 或 AWS Glue 包含一个 Apache Hive 仓库,或者您可以部署和管理自己的 metastore。当然,最终目标并不是在每个开发人员的机器上都有一组 Parquet 文件。

下面四种使数据帧可用于 SQL 查询的方法是数据帧上的以下方法:

  • CreateTempView–创建数据帧的临时视图。如果视图已经存在,这将失败。临时视图仅对当前 SparkSession 可用。

  • createorreplacetenview–创建数据帧的临时视图。如果视图已经存在,这不会失败。临时视图仅对当前 SparkSession 可用。

  • CreateGlobalTempView–创建数据帧的临时视图。如果视图已经存在,这将失败。临时视图可用于当前 SparkSession 和集群上的任何其他 SparkSession。

  • CreateOrReplaceGlobalTempView–创建数据帧的临时视图。如果视图已经存在,这不会失败。临时视图可用于当前 SparkSession 和集群上的任何其他 SparkSession。

  • 其中,DataFrameWriter.SaveAsTable方法被用来在 Apache Hive 中创建一个托管表,其中的数据被物理地写成一组 parquet 文件。这些方法在现有数据上创建视图,因此您不需要将数据写入磁盘作为中间步骤。

  • 不同方法的变化是为了允许视图被 Apache Spark 实例的其他用户读取,考虑一下 Databricks 工作区,其中许多用户连接,作业作为不同的用户运行,您可以在会话之间共享数据。如果视图是一个全局视图,那么当我们从中选择时,我们需要用全局视图数据库的名称“global_temp”作为它的前缀,所以如果我们创建一个名为“global_temp_view”的全局视图,我们可以在 SQL 上下文中运行这个查询来读取它:"select * from global_temp.global_temp_view"

  • Create 和 CreateOrReplace 之间的区别决定了您是否可以覆盖现有视图,或者如果视图已经存在,是否会引发异常。

在清单 6-5 和 6-6 中,我们展示了如何在 C#和 F#中使用这四个函数。

let spark = SparkSession.Builder().GetOrCreate()

let dataFrame = spark.CreateDataFrame([|10;11;12;13;14;15|]).WithColumnRenamed("_1", "ID")

dataFrame.CreateTempView("temp_view")
printfn "select * from temp_view:"
spark.Sql("select * from temp_view").Show()

dataFrame.CreateOrReplaceTempView("temp_view")
printfn "select * from temp_view:"
spark.Sql("select * from temp_view").Show()

dataFrame.CreateGlobalTempView("global_temp_view")
printfn "select * from global_temp.global_temp_view:"
spark.Sql("select * from global_temp.global_temp_view").Show()

dataFrame.CreateOrReplaceGlobalTempView("global_temp_view")
printfn "select * from global_temp.global_temp_view:"
spark.Sql("select * from global_temp.global_temp_view").Show()

0

Listing 6-6Using the Create View methods on a DataFrame in F#

var spark = SparkSession.Builder().GetOrCreate();

var dataFrame = spark.CreateDataFrame(new [] {10, 11, 12, 13, 14, 15}).WithColumnRenamed("_1", "ID");

dataFrame.CreateTempView("temp_view");
Console.WriteLine("select * from temp_view:");
spark.Sql("select * from temp_view").Show();

dataFrame.CreateOrReplaceTempView("temp_view");
Console.WriteLine("select * from temp_view:");
spark.Sql("select * from temp_view").Show();

dataFrame.CreateGlobalTempView("global_temp_view");
Console.WriteLine("select * from global_temp.global_temp_view:");
spark.Sql("select * from global_temp.global_temp_view").Show();

dataFrame.CreateOrReplaceGlobalTempView("global_temp_view");
Console.WriteLine("select * from global_temp.global_temp_view:");
spark.Sql("select * from global_temp.global_temp_view").Show();

Listing 6-5Using the Create View methods on a DataFrame in C#

运行这两个程序时,它们会显示以下输出:

select * from temp_view:
+---+
| ID|
+---+
| 10|
| 11|
| 12|
| 13|
| 14|
| 15|
+---+

select * from temp_view:
+---+
| ID|
+---+

| 10|
| 11|
| 12|
| 13|
| 14|
| 15|
+---+

select * from global_temp.global_temp_view:
+---+
| ID|
+---+
| 10|
| 11|
| 12|
| 13|
| 14|
| 15|
+---+

select * from global_temp.global_temp_view:
+---+

| ID|
+---+
| 10|
| 11|
| 12|
| 13|
| 14|
| 15|
+---+

Spark 会话目录

Spark 会议。Catalog 是一个对象,它允许我们检查和修改存储在 Hive metastore 中的元数据。我们可以创建表并列出数据库、表、视图和函数,还可以检查和删除这些相同的对象。在清单 6-7 和 6-8 中,我们将使用 SQL 查询创建一个新的数据库,然后查询数据库中的表列表,并使用 catalog 函数检查表上的列。清单使用了我已经生成的一个拼花文件;拼花文件包括三列。

let spark = SparkSession.Builder().GetOrCreate()
spark.Sql("CREATE DATABASE InputData")
spark.Catalog.SetCurrentDatabase "InputData"
spark.Catalog.CreateTable("id_list", "./ID.parquet")

let getTableDefinition =
    let getColumn(column:Row) =
       sprintf "%s\t%s" (column.[0].ToString()) (column.[2].ToString())

    let getColumns(dbName:string, tableName:string) =
        spark.Catalog.ListColumns(dbName, tableName)
                               |> fun c -> c.Collect()
                               |> Seq.map(fun column -> getColumn(column))
                               |> String.concat "\n"

    let getTable (table:Row) =
        let databaseName = table.[1].ToString()
        let tableName = table.[0].ToString()

        let tableHeader = sprintf "Database: %s, Table: %s" databaseName tableName
        let columnDefinition = getColumns(databaseName, tableName)

        sprintf "%s\n%s" tableHeader columnDefinition

    let tableDefinition =
        spark.Catalog.ListTables "InputData"
        |> fun t -> t.Collect()
        |> Seq.map (fun table -> getTable(table))

    tableDefinition

PrettyPrint.print getTableDefinition

0

Listing 6-8Working with Hive databases and tables in F#

var spark = SparkSession.Builder().GetOrCreate();
spark.Sql("CREATE DATABASE InputData");

spark.Catalog.SetCurrentDatabase("InputData");
spark.Catalog.CreateTable("id_list", "./ID.parquet");

var tables = spark.Catalog.ListTables("InputData");

foreach (var row in tables.Collect())
{
    var name = row[0].ToString();
    var database = row[1].ToString();

    Console.WriteLine($"Database: {database}, Table: {name}");
    var table = spark.Catalog.ListColumns(database, name);
    foreach (var column in table.Collect())
    {

        var columnName = column[0].ToString();
        var dataType = column[2].ToString();
        var nullable = (bool) column[3];
        var nullString = nullable ? "NULL" : "NOT NULL";

        Console.WriteLine($"{columnName}\t{dataType}\t{nullString}");
    }
}

Listing 6-7Working with Hive databases and tables in C#

这些程序的输出是

Database: inputdata, Table: id_list
Id        bigint
Age       bigint
halfAge   double

能够探索对象在实践中非常有用,这方面的一个真实例子是我参与的一个项目,该项目涉及从许多源系统接收数据文件,在这些源系统中,我们接收到的数据模式可能会在没有通知的情况下发生变化。我们开发的是一种比较现有模式和传入文件的模式的方法,并确定模式是否可以改进,或者我们是否必须手动修复模式以解决不兼容问题。

ListDatabasesListTablesListColumns方法对于探索哪些对象存在是有用的,但是 catalog 也有一些其他的函数来检查对象是否存在,比如DatabaseExistsFunctionExistsTableExists,如果对象存在则返回一个布尔值。

该目录还允许我们删除使用DropTempViewDropGlobalTempView创建的临时视图。

如果我们知道一个对象存在,我们可以使用GetDatabaseGetFunctionGetTable,它们不返回数据帧,而是一级对象,让我们访问它们的属性。

  • get Database–返回一个具有属性描述、名称和位置 Uri 的数据库对象

  • GetFunction–返回一个函数对象,它具有数据库、描述、名称、类名和 IsTemporary 属性

  • GetTable–返回一个 Table 对象,该对象具有属性 Database、Description、Name、IsTemporary 和 TableType

摘要

在本章中,我们了解了 Apache Spark 如何拥有运行 SQL 查询的接口,我们如何从这些 SQL 查询中访问数据帧,以及我们如何管理 SQL 查询引擎可用的表的元数据。

Apache Spark SQL 有一个非常好的特性——完整的 SQL 解析器和函数集;要查看最新的可用函数,请访问位于 https://spark.apache.org/docs/latest/api/sql/ 的 Apache Spark 文档,记住您可以从。NET 使用SparkSession.Sql

七、Spark 机器学习 API

在这一章中,我们将看看 Spark 的机器学习 API 或 MLLib API。MLLib API 由基于 RDD 的 API 和较新的 DataFrame API 组成。API 的 DataFrame 版本被称为 ML API,因为对象存在于 org.apache.spark.ml 名称空间中。从这里开始,我们将使用 ML API 这个术语来指代 MLLib API 的 DataFrame 版本。就像。NET for Apache Spark 项目支持 DataFrame API,但不支持 RDD API,迄今为止只有 Spark ML API 有任何实现。

ML API 最初发布时并不是核心项目的一部分,到目前为止只是通过外部贡献来实现,所以它不像其他 API 那样完整。随着时间的推移,ML API 将变得越来越完整,但截至今天,实现的 ML 对象屈指可数。这意味着我们在用编写机器学习应用程序时有一些不同的选择。NET for Apache Spark。

第一选择是我们使用。NET 并使用微软的 ML.NET 库,这意味着你可以使用 C#或 F#创建 ML 模型。为了从 Apache Spark 访问 ML.NET,我们将使用一个用户定义的函数(UDF)将数据传递给 ML.NET 模型。这种方法的缺点是所有数据都必须通过 UDF 传递,但是如果您想用. NET 编写所有代码,这可能是目前最好的选择。

第二个选择是,如果我们没有所需的一切。NET,但可以部分地创建或执行我们的模型,我们可以在。NET,然后保存我们的进度并调用 Scala 或 Python Apache Spark 程序来读取输出。NET 并完成处理。如果您已经拥有 Scala 或 Python 中的现有模型,并且希望将代码移植到. NET 中,那么这种选择会更好。

中实现机器学习应用程序的最后选择。NET 就是自己实现自己需要的对象。根据您要实现的内容,这可能很简单,也可能很难实现。在附录 B 中,我们展示了如何实现可以在项目中使用的对象,或者将这些对象贡献给。用于 Apache Spark 项目的. NET。

库命名

具体到命名,最初基于 RDD 的机器学习 API 被命名为 MLLib。在 Apache Spark 2.0 中,创建了“Spark ML”库,该库虽然不是官方名称,但用于指代 DataFrame API,Scala 中的对象是在 org.apache.spark.ml 包中创建的,其中 MLLib 对象以前位于 org.apache.spark.mllib 中。MLLib API 包括 RDD API 和 DataFrame API 的代码,但我们在中实现的对象。至少现在,Apache Spark . NET 将来自 org.apache.spark.ml 包。

当查看 Apache Spark 文档时,要小心的是;两个包中会有同名的对象,例如 org . Apache . spark . ml lib . feature .Word2Vec对象,它与 org . Apache . spark . ml . feature .Word2Vec对象是分开的,这可能会导致一些混淆,您期望看到一组在对象的 MLLib RDD 版本中不存在的参数,反之亦然。

实现的对象

ML API 中创建的第一组对象来自 org.spark.ml.feature API,实现的对象是

  • 斗式提升机

  • 计数器/计数器模块

  • 特征散列器

  • 哈希

  • IDF/IDFModel

  • Tokenizer

  • Word2Vec/Word2VecModel

  • SQL 转换器

  • 停用词去除器

对于更多的对象,有几个未决的拉请求,所以我希望这个列表将保持增长,虽然速度很慢,但速度稳定,直到我们之间的功能对等。NET 的 Apache Spark 和 Scala 和 Python。

要查看正在实现的 org.apache.spark.ml.feature 对象的进度,请参见本期 GitHub 跟踪进度: https://github.com/dotnet/spark/issues/381

参数

参数是构建机器学习应用程序的基础部分。了解如何控制模型以实现最佳可能结果,以及了解使用哪些参数来构建模型以便可以复制该模型,对于在生产中运行机器学习应用程序是必不可少的。如果不了解如何使用机器学习应用程序做出决策,可能会产生一些严重的后果,包括可能的监管措施。欧盟 GDPR 法律包括一个关于机器学习的特定部分,称为第 22 条,其中包括一个注释,说明必须有可能提供用于做出决策的逻辑。

当我们在 Spark 中使用 ML 对象时,每个对象通常带有许多参数,有两种方法可以访问这些参数。首先,对象本身通常有一组 getters 和 setters。例如,如果我们查看表 7-1 中的Word2Vec对象,我们可以看到每个参数的 Get*、Set*和参数名。

表 7-1

Word2Vec 上的 getter/setter 参数

|

得到

|

一组

|

参数名称

| | --- | --- | --- | | GetInputCol | SetInputCol | 输入控制 | | GetOutputCol | SetOutputCol | 输出控制 | | GetVectorSize | 服务规模 | 向量大小 | | getpincount | SetMinCount | minCount | | getmaxsentexcelength | setmaxsentexcelength | maxsentexcelength | | GetNumPartition | 集合分区 | 数字分区 | | 获取种子 | 集种子 | 种子 | | GetStepSize | SetStepSize | 步长 | | GetWindowSize | SetWindowSize | windows size(windows size) | | GetVectorSize | 服务规模 | 向量大小 | | GetMaxIter | SetMaxIter | 马克西特 |

实际上,这意味着我们可以使用提供的 getter 和 setter 方法来控制参数,或者我们可以使用方法 Set 并将参数传递给 object。在清单 7-1 中,我们展示了一个如何使用 getter 和 setter 方法或者一个Param对象来设置特定参数的例子。我们还引入了ExplainParams方法,它打印所有可用的参数,包括任何文档、当前值以及默认值(如果有的话)。

var word2Vec = new Word2Vec();
word2Vec.SetSeed(123);

Console.WriteLine(word2Vec.ExplainParams());

Listing 7-1Controlling an object’s parameters

运行此命令会产生以下输出:

inputCol: input column name (undefined)
maxIter: maximum number of iterations (>= 0) (default: 1)
maxSentenceLength: Maximum length (in words) of each sentence in the input data. Any sentence longer than this threshold will be divided into chunks up to the size (> 0) (default: 1000)
minCount: the minimum number of times a token must appear to be included in the word2vec model's vocabulary (>= 0) (default: 5)
numPartitions: number of partitions for sentences of words (> 0) (default: 1)
outputCol: output column name (default: w2v_cabb3eadcb81__output)
seed: random seed (default: -1961189076, current: 123)
stepSize: Step size to be used for each iteration of optimization (> 0) (default: 0.025)
vectorSize: the dimension of codes after transforming from words (> 0) (default: 100)
windowSize: the window size (context words from [-window, window]) (> 0) (default: 5)

我们可以看到种子的值被设置为 123。在清单 7-2 中,我们使用一个Param对象和Set方法来指定参数值。

var seedParam = new Param(word2Vec, "seed", "Setting the seed to 54321");
word2Vec.Set(seedParam, 54321L);

Console.WriteLine(word2Vec.ExplainParams());

Listing 7-2Using a Param object to set a parameter value

清单 7-2 产生以下输出,我们可以看到种子现在是 54321:

inputCol: input column name (undefined)
maxIter: maximum number of iterations (>= 0) (default: 1)
maxSentenceLength: Maximum length (in words) of each sentence in the input data. Any sentence longer than this threshold will be divided into chunks up to the size (> 0) (default: 1000)
minCount: the minimum number of times a token must appear to be included in the word2vec model's vocabulary (>= 0) (default: 5)
numPartitions: number of partitions for sentences of words (> 0) (default: 1)
outputCol: output column name (default: w2v_cabb3eadcb81__output)
seed: random seed (default: -1961189076, current: 54321)
stepSize: Step size to be used for each iteration of optimization (> 0) (default: 0.025)
vectorSize: the dimension of codes after transforming from words (> 0) (default: 100)
windowSize: the window size (context words from [-window, window]) (> 0) (default: 5)

最后,在清单 7-3 中,我们没有创建新的Param对象,而是要求Word2Vec对象给我们一个名为“seed”的参数,然后我们可以用它来设置参数。

var seed = word2Vec.GetParam("seed");
word2Vec.Set(seed, 12345L);
Console.WriteLine(word2Vec.ExplainParams());

Listing 7-3Using a Param object supplied by the Word2Vec object to set a parameter value

我们可以在输出中看到,参数值被设置为 12345:

inputCol: input column name (undefined)
maxIter: maximum number of iterations (>= 0) (default: 1)
maxSentenceLength: Maximum length (in words) of each sentence in the input data. Any sentence longer than this threshold will be divided into chunks up to the size (> 0) (default: 1000)
minCount: the minimum number of times a token must appear to be included in the word2vec model's vocabulary (>= 0) (default: 5)
numPartitions: number of partitions for sentences of words (> 0) (default: 1)
outputCol: output column name (default: w2v_cabb3eadcb81__output)
seed: random seed (default: -1961189076, current: 12345)
stepSize: Step size to be used for each iteration of optimization (> 0) (default: 0.025)
vectorSize: the dimension of codes after transforming from words (> 0) (default: 100)
windowSize: the window size (context words from [-window, window]) (> 0) (default: 5)

关键的一点是,当我们使用Param对象和Set方法时,数据类型没有被验证,因此有可能将参数设置为不正确的类型。除非你保存你的对象或者尝试使用它,否则你不会知道。这通常比在每个对象上使用提供的 getters 和 setters 更安全。Param对象的 Scala 版本有一种验证参数的方法,但是在。NET,我们只是需要小心。

如果你想把一个参数重置回它原来的默认值,你可以使用Clear方法,如清单 7-4 所示。

var seed = word2Vec.GetParam("seed");
word2Vec.Set(seed, 12345L);
Console.WriteLine(word2Vec.ExplainParams());

word2Vec.Clear(seed);
Console.WriteLine(word2Vec.ExplainParams());

Listing 7-4Clearing any parameters which have previously been set

保存/加载对象

Spark 中的每一个核心物体。ML 名称空间包括一个名为Save的方法和一个名为Load的静态方法。LoadSave方法保存对象的副本,包括任何运行时信息,然后允许它们被读回内存。这对于机器学习应用程序特别有用,因为我们可能希望在一组数据上创建和训练一个模型,然后保存这些对象,以便以后可以重用它们来使用该模型或运行预测。在清单 7-5 中,我们看到了正在使用的LoadSave方法。请注意,虽然它们在同一个进程中,但是对象可以保存在一个进程中,并加载到另一个进程中。语言是不相关的,所以您可以在。NET,保存它,然后从 Scala 加载并使用它。

bucketizer.SetInputCol("input_column");
bucketizer.Save("/tmp/bucketizer");

bucketizer.SetInputCol("something_else");

var loaded = Bucketizer.Load("/tmp/bucketizer");
Console.WriteLine(bucketizer.GetInputCol());
Console.WriteLine(loaded.GetInputCol());

Listing 7-5The Load and Save methods

当我们运行它时,我们可以看到原始的Bucketizer,它的 inputColumn 被设置为“其他”,仍然有效,但是新加载的Bucketizer具有原始的值“input_column”。

something_else
input_column

可辨认的

这些对象通常还会实现Identifiable,这意味着当您创建一个新的对象时,您可以选择指定一个惟一的字符串来标识该对象的特定实例。如果没有指定唯一的字符串,则会为您生成一个。您可以稍后使用这个唯一的字符串来标识对象的确切实例。当您创建Param对象时,您需要标识 param 将属于的对象,这可以通过传递对象本身或传递字符串标识符来完成。在清单 7-6 中,我们展示了如何将一个唯一的字符串传递给一个 Spark。ML 对象以及以后如何引用这个唯一的字符串。

var tokenizer = new Tokenizer();
Console.WriteLine(tokenizer.Uid());

tokenizer = new Tokenizer("a unique identifier");
Console.WriteLine(tokenizer.Uid());

Listing 7-6The uid of a Spark.ML object instance

它的输出是

tok_34a2ad14b80a
a unique identifier

TF-以色列国防军

中实现的 ML 对象。NET 与 Spark 在 Spark 中的对象数量相差甚远。ML;然而,已经有足够的功能来运行有用的机器学习应用程序。在本节中,我们将构建一个“术语频率,逆文档频率”或 TF-IDF 的工作示例,这是一种在一组文档中搜索某些文本并找到相关文档的方法。TF-IDF 基于这样一个事实:如果您只是对术语进行通配符搜索,那么您将会找到存在该术语但不太相关的文档。TF-IDF 衡量一个词在一个特定文档中的常见程度,与所有文档中有多少术语以及这些搜索术语的相关性。例如,一本书可能在每一页上都写有“第 xx 页”,但是这个术语与文档并不十分相关。然而,如果有一个文档讨论页面是如何布局的,那么这个文档中的单词 page 将会非常相关。

TF-IDF 在这篇维基百科文章中有详细讨论: https://en.wikipedia.org/wiki/Tf%E2%80%93idf *。*使用 TF-IDF 的高级流程是

  1. 获取一些文档作为来源。

  2. 将文件读入数据帧。

  3. 使用分词器将文档拆分成单词。

  4. 使用 HashingTF 构建一个向量,其中包含每个单词的哈希。

  5. 创建 IDF,通过“拟合”每个词的 hash 来创建 IDFModel,即给每个词或术语一个频数和相对重要性。

  6. 获取一些搜索词,并将它们转换成数据帧。

  7. 使用分词器将搜索词拆分成单词。

  8. 使用 HashingTF 构建一个向量,包含搜索词中每个单词的散列。

  9. 使用 IDFModel 转换搜索词,赋予它们与文档中相同的相对权重。

  10. 将数据集连接在一起,通过计算两者的余弦相似度并根据匹配程度对结果进行排序,计算出搜索词与文档的接近程度。要理解我们为什么用余弦相似度与 TF-IDF,看看这篇优秀的博文: https://janav.wordpress.com/2013/10/27/tf-idf-and-cosine-similarity/

对于这个例子,我将使用莎士比亚全集,然后找到与特定搜索词相关的文档。我们需要的数据有几个来源,但我下载了这个回购( https://github.com/severdia/PlayShakespeare.com-XML ),包括 XML 格式的所有作品的副本,这使得阅读每首诗或剧本的文本和标题以及许可许可证变得很简单。

完整的示例应用程序在清单 7-examples sharp 和清单 7-examples sharp 中。

在清单 7-7 (C#)和 7-8 (F#)中,我们以 XML 格式读取每个文档,并解析 XML 以检索作品的文本和标题。

let createXmlDoc(path: string) =
    let doc = XmlDocument()
    doc.Load(path)
    doc

let parseXml(doc: XmlDocument) =
    let selectSingleNode node =
        Option.ofObj (doc.SelectSingleNode(node))

    let documentTitle =
        match selectSingleNode "//title" with
        | Some node -> node.InnerText
        | None -> doc.SelectSingleNode("//personae[@playtitle]").Attributes.["playtitle"].Value

    match selectSingleNode "//play" with
    | Some node -> GenericRow([|documentTitle; node.InnerText|])
    | None -> GenericRow([|documentTitle; doc.SelectSingleNode("//poem").InnerText|])

let getDocuments path = System.IO.Directory.GetFiles(path, "*.xml")
                                      |> Seq.map (fun doc -> createXmlDoc doc)
                                      |> Seq.map (fun xml -> parseXml xml)

let main argv =

    let args = match argv with
                | [|documentPath; searchTerm|] -> {documentsPath = argv.[0]; searchTerm = argv.[1]; success = true}
                | _ -> {success = false; documentsPath = ""; searchTerm = ""}

    match args.success with
        | false ->
            printfn "Error, incorrect args. Expecting 'Path to documents' 'search term', got: %A" argv
            -1

        | true ->
            let spark = SparkSession.Builder().GetOrCreate()
            let documents = getDocuments args.documentsPath

Listing 7-8Reading the contents of each work as XML and retrieving the document and title in F#

private static List<GenericRow> GetDocuments(string path)
{
    var documents = new List<GenericRow>();

    foreach (var file in new DirectoryInfo(path).EnumerateFiles("*.xml", SearchOption.AllDirectories))
    {
        var doc = new XmlDocument();

        doc.Load(file.FullName);

        var playTitle = "";
        var title = doc.SelectSingleNode("//title");

        playTitle = title != null ? title.InnerText : doc.SelectSingleNode("//personae[@playtitle]").Attributes["playtitle"].Value;

        var play = doc.SelectSingleNode("//play");

        if (play != null)
        {
            documents.Add(new GenericRow(new[] {playTitle, play.InnerText}));
        }
        else
        {
            var poem = doc.SelectSingleNode("//poem");
            documents.Add(new GenericRow(new[] {playTitle, poem.InnerText}));
        }
    }

    return documents;
}

var spark = SparkSession
    .Builder()
    .GetOrCreate();

var documentPath = args[0];
var search = args[1];

var documentData = GetDocuments(documentPath);

Listing 7-7Reading the contents of each work as XML and retrieving the document and title in C#

既然我们已经将文档读入了。NET 应用程序,我们需要创建一个数据帧,以便 Apache Spark 可以处理这些文档。读取文件的替代方法。NET,然后创建一个 DataFrame,这将让 Apache Spark 读取 XML 文件,并用。NET,并以 Apache Spark 更友好的格式(如 Parquet 或 Avro)将它们写入磁盘。在这种情况下,因为大约有 50 个文档,所以我将创建一个 DataFrame 并将文档添加到其中,而不是再次写回文档。如果有成千上万的文档,那么我们需要考虑不同的方法。

在清单 7-9 和 7-10 中,我们创建了一个 DataFrame,它涉及到传递一个IEnumerable<GenericRow>和一个描述我们的行的模式。

let documents = spark.CreateDataFrame(documents, StructType([|StructField("title", StringType());StructField("content", StringType())|]))

Listing 7-10CreateDataFrame passing in our specific schema in F#

var documents = spark.CreateDataFrame(documentData, new StructType(new List<StructField>
{
    new StructField("title", new StringType()),
    new StructField("content", new StringType())
}));

Listing 7-9CreateDataFrame passing in our specific schema in C#

接下来我们要做的是产生 Spark。我们需要的 ML 对象。表 7-2 列出了对象以及我们将使用它们的目的。

表 7-2

Spark。机器学习应用程序所需的 ML 对象

|

目标

|

理由

|

培养

|

执行

| | --- | --- | --- | --- | | Tokenizer | 将文档拆分成数据帧中的单词数组 | 是 | 是 | | 哈希 | 将单词转换为每个单词的数字表示形式 | 是 | 是 | | 综合资料的文件(intergrated Data File) | 使用这些文档建立一个模型,该模型描述了所有文档中的术语使用频率 | 是 | 不一旦用样本数据集“训练”了模型,我们就使用它,而不是每次都重新训练模型 | | IDFModel | 这是与文档“匹配”的模型,包括每个术语在整个文档集中出现的频率 | 是 | 是 |

在清单 7-11 和 7-12 中,我们创建了在初始训练阶段和执行阶段使用的对象TokenizerHashingTFIDF。我们将使用实际的文档创建IDFModel

let tokenizer = Tokenizer().SetInputCol("content").SetOutputCol("words")
let hashingTF = HashingTF().SetInputCol("words").SetOutputCol("rawFeatures").SetNumFeatures(1000000)
let idf = IDF().SetInputCol("rawFeatures").SetOutputCol("features")

Listing 7-12Creating the Tokenizer, HashingTF, and IDF in F#

var tokenizer = new Tokenizer()
    .SetInputCol("content")
    .SetOutputCol("words");

var hashingTF = new HashingTF()
    .SetInputCol("words")
    .SetOutputCol("rawFeatures")
    .SetNumFeatures(1000000);

var idf = new IDF()
    .SetInputCol("rawFeatures")
    .SetOutputCol("features");

Listing 7-11Creating the Tokenizer, HashingTF, and IDF in C#

每个对象都使用一个DataFrame来处理,所以我们需要告诉对象使用哪个列。例如,要使用Tokenizer,我们告诉它将在“content”列中找到它的输入数据,它应该将它的输出数据写入到Tokenizer将创建的“words”列中。HashingTF将在“words”列中查找输入数据,并将数据输出到“rawFeatures”列。

在清单 7-13 和 7-14 中,我们将文档分成单个单词,然后分成向量,向量是每个单词的数字标识符。我们使用数字而不是字符串,因为我们需要运行一些计算,特别是计算每个文档与我们的搜索词相比的余弦相似性,而我们不能用字符串来做这些。

let featurized = tokenizer.Transform documents
                                |> hashingTF.Transform

Listing 7-14Transforming the documents into words and vectors in F#

var tokenizedDocuments = tokenizer.Transform(documents);
var featurizedDocuments = hashingTF.Transform(tokenizedDocuments);

Listing 7-13Transforming the documents into words and vectors in C#

如果我们在HashingTF返回的DataFrame上调用Show方法,那么它看起来会像这样

+---------+--------+---------+------------------+
|    title| content|  words|         rawFeatures|
+---------+--------+---------+------------------+
|The So...|The S...|the, ...|(1000000,[522, ...|
|The Tw...|The T...|[the, ...|(1000000,[130, ...|

内容被分成一组单词,每个单词都有一个数字标识符。

现在我们有了可以使用的文档格式。我们需要通过将文档“适应”IDF 来“训练”模型。我们在清单 [7-15 和 7-16 中展示了这一点。

let model = featurized
            |> idf.Fit

Listing 7-16“Fitting” the dataset to the IDF to create the model in F#

var idfModel = idf.Fit(featurizedDocuments);

Listing 7-15“Fitting” the dataset to the IDF to create the model in C#

现在我们有了我们需要的对象,我们有了已经在我们需要计算的文档数据集上训练过的模型,对于每个文档,它与所有其他文档相比有多大。为此,对于数据帧中的每一行,也就是每一个文档,我们遍历数据集中的每一个值,对数字求平方,然后求平方的平方根。在清单 7-17 和 7-18 中,我们遍历向量中的每个值,并计算归一化值,我们将在以后计算每个文档与我们的搜索词有多相似时使用该值。

let calcNormUDF = Functions.Udf<Row, double>(fun row -> row.Values.[3] :?> ArrayList
                                                     |> Seq.cast
                                                     |> Seq.map (fun item -> item * item)
                                                     |> Seq.sum
                                                     |> Math.Sqrt)

let normalizedDocuments = model.Transform featurized

                                            |> fun data -> data.Select(Functions.Col("features"), calcNormUDF.Invoke(Functions.Col("features")).Alias("norm"), Functions.Col("title"))

Listing 7-18Calculating the normalization number to use later on in F#

private static readonly Func<Column, Column> udfCalcNorm = Udf<Row, double>(row =>
    {
        var values = (ArrayList) row.Values[3];
        var norm = 0.0;

        foreach (var value in values)
        {
            var d = (double) value;
            norm += d * d;
        }

        return Math.Sqrt(norm);
    }
);

var transformedDocuments = idfModel.Transform(featurizedDocuments).Select("title", "features");
            var normalizedDocuments = transformedDocuments.Select(Col("features"), udfCalcNorm(transformedDocuments["features"]).Alias("norm"), Col("title"));

Listing 7-17Calculating the normalization number to use later on in C#

直到的 1.0 版。NET 中,不可能将向量从 JVM 转移到。尽管如此,在 1.0 版中,提供给 UDF 的数据是向量的内部表示。在 Apache Spark 中,有两种类型的向量,一种是 DenseVector,另一种是 SparseVector。如果您在 Scala 或 Python 中使用了一种 Vector 类型,那么您可以将它们作为 Vector 来使用。希望在未来的某个时候,你能够在。NET for Apache Spark,但在此之前,我们需要了解向量是如何在内部实现的。

DenseVector 是最容易使用的,因为它背后有一个 double 数组,即 double 数组。这里的 SparseVector 比较难处理,因为它不是一个包含所有值的数组,任何 0.0 的值都被排除在 SparseVector 之外,所以如果你想把 1.0,2.0,0.0,4.0 表示为一个 SparseVector,你会得到一个包含每个元素的索引列表的数组;如果值为 0.0,则省略索引。在我们的 SparseVector 示例中,我们有两个数组,一个包含以下索引 0、1、3,另一个包含值 1.0、2.0、3.0。当我们想要迭代 SparseVector 时,我们需要迭代每个索引。如果缺少索引值,我们知道该值是 0.0,但是如果该值在索引中,我们使用索引的位置来查找实际值。在我们的例子中,如果我们想知道 SparseVector 中第四个位置的值是什么,我们将进入索引并搜索值 3;记住这是一个从零开始的数组。值 3 位于数组或索引 2 的第三个位置,它指向值数组中的 3.0。

在表 7-3 中,我们可以看到单词是如何被拆分成记号的。

表 7-3

SparseVector 示例以及如何检索特定索引。

|

矢量

|

索引

|

价值观念

|

索引 5 处的值

| | --- | --- | --- | --- | | 0.0,0.0,0.1,0.0,0.0, 0.2 | 2、 5 | 0.1, 0.2 | 0.2 | | 0.1,0.2,0.3,0.4,0.0, 0.0 | 0, 1, 2, 3 | 0.1, 0.2, 0.3, 0.4 | 0.0 |

实际上,这意味着我们的 UDF 接收了一个包含四个对象的数组,如表 7-4 所示。

表 7-4

作为对象数组提供给 UDF 的 SparseVector 的详细信息

|

索引

|

类型

|

描述

| | --- | --- | --- | | Zero | (同 Internationalorganizations)国际组织 | 此应用程序的起始偏移量将始终为 0 | | one | (同 Internationalorganizations)国际组织 | 这个 SparseVector 表示的 Vector 中有多少项。SparseVector 可能包含 10 个值,但是 SparseVector 可以表示 Vector 中的数百万个项目 | | Two | (同 Internationalorganizations)国际组织 | 向量中指向非 0.0 值的索引 | | three | 两倍 | 向量中不为 0.0 的值 |

在清单 7-19 和 7-20 中,我们获取搜索词,创建一个数据帧,然后运行同样的过程,分割成单词,创建一个向量,并使用模型将向量转换成一组我们可以与原始文档进行比较的特征。我们唯一不需要做的是重建模型,因为我们有原始文档的模型,我们将不得不重用它;否则,我们的搜索词将与原始文档具有不同的权重。

let term = GenericRow([|"Montague and capulets"|])
let searchTerm = spark.CreateDataFrame([|term|], StructType([|StructField("content", StringType())|]) )

tokenizer.Transform searchTerm
    |> hashingTF.Transform
    |> model.Transform
    |> fun data -> data.WithColumnRenamed("features", "searchTermFeatures")
    |> fun data -> data.WithColumn("searchTermNorm", calcNormUDF.Invoke(Functions.Col("searchTermFeatures")))

Listing 7-20Converting the search term into a DataFrame that can be compared with the original documents in F#

var searchTerm = spark.CreateDataFrame(
    new List<GenericRow> {new GenericRow(new[] {search})},
    new StructType(new[] {new StructField("content", new StringType())}));

var tokenizedSearchTerm = tokenizer.Transform(searchTerm);

var featurizedSearchTerm = hashingTF.Transform(tokenizedSearchTerm);

var normalizedSearchTerm = idfModel
    .Transform(featurizedSearchTerm)
    .WithColumnRenamed("features", "searchTermFeatures")
    .WithColumn("searchTermNorm", udfCalcNorm(Column("searchTermFeatures")));

Listing 7-19Converting the search term into a DataFrame that can be compared with the original documents in C#

最后要做的事情是将原始文档和搜索词连接成一个数据帧,并计算两个向量的余弦相似度,如清单 7-21 和 7-22 所示。我们通过将向量中的每个值乘以数组中相同位置的第二个向量中的值来计算余弦相似度。然后,我们将结果除以我们之前计算的文档和搜索词的归一化乘积。还要注意,这是一个 SparseVector,所以我们需要做一些工作来识别特定偏移量处的值。

let cosineSimilarity (vectorA:Row, vectorB:Row, normA:double, normB:double):double =

    let indicesA = vectorA.Values.[2]  :?> ArrayList
    let valuesA = vectorA.Values.[3] :?> ArrayList

    let indicesB = vectorB.Values.[2] :?> ArrayList
    let valuesB = vectorB.Values.[3] :?> ArrayList

    let indexedA = indicesA |> Seq.cast |> Seq.indexed
    let indexedB = indicesB |> Seq.cast |> Seq.indexed |> Seq.map (fun item -> (snd item, fst item)) |> Map.ofSeq

    PrettyPrint.print indexedB

    let findIndex value = match indexedB.ContainsKey value with
                            | true -> indexedB.[value]
                            | false -> -1

    let findValue indexA =
                            let index =  findIndex indexA

                            match index with
                                | -1 -> 0.0
                                | _ -> unbox<double> (valuesB.Item(unbox<int> (index)))

    let dotProduct = indexedA
                       |> Seq.map (fun index -> (unbox<double>valuesA.[fst index]) * (findValue (unbox<int> indicesA.[fst index])))
                       |> Seq.sum

    normA * normB |> fun divisor -> match divisor with
                                                | 0.0 -> 0.0
                                                | _ -> dotProduct / divisor

let cosineSimilarityUDF = Functions.Udf<Row, Row, double, double, double>(fun vectorA vectorB normA normB -> cosineSimilarity(vectorA, vectorB, normA, normB))

Listing 7-22Calculating the cosine similarity using F#

private static readonly Func<Column, Column, Column, Column, Column> udfCosineSimilarity =
    Udf<Row, Row, double, double, double>(
        (vectorA, vectorB, normA, normB) =>
        {
            var indicesA = (ArrayList) vectorA.Values[2];
            var valuesA = (ArrayList) vectorA.Values[3];

            var indicesB = (ArrayList) vectorB.Values[2];
            var valuesB = (ArrayList) vectorB.Values[3];

            var dotProduct = 0.0;

            for (var i = 0; i < indicesA.Count; i++)
            {
                var valA = (double) valuesA[i];

                var indexB = findIndex(indicesB, 0, (int) indicesA[i]);

                double valB = 0;
                if (indexB != -1)
                {
                    valB = (double) valuesB[indexB];
                }
                else
                {
                    valB = 0;
                }

                dotProduct += valA * valB;
            }

            var divisor = normA * normB;

            return divisor == 0 ? 0 : dotProduct / divisor;
        });

Listing 7-21Calculating the cosine similarity using C#

在清单 7-23 和 7-24 中,我们有连接数据帧的最后一步,计算余弦相似度,按最相似到最不相似排序结果,然后打印出标题和相似度。

|> normalizedDocuments.CrossJoin
|> fun data -> data.WithColumn("similarity", cosineSimilarityUDF.Invoke(Functions.Col("features"), Functions.Col("searchTermFeatures"), Functions.Col("norm"), Functions.Col("searchTermNorm")))
|> fun matched -> matched.OrderBy(Functions.Desc("similarity")).Select("title", "similarity")
|> fun ordered -> ordered.Show(100, 1000)

Listing 7-24Joining the DataFrames and calculating the cosine similarity to generate our best matching results in F#

var results = normalizedDocuments.CrossJoin(normalizedSearchTerm);

results
    .WithColumn("similarity", udfCosineSimilarity(Column("features"), Column("searchTermFeatures"), Col("norm"), Col("searchTermNorm")))
    .OrderBy(Desc("similarity")).Select("title", "similarity")
    .Show(10000, 100);

Listing 7-23Joining the DataFrames and calculating the cosine similarity to generate our best matching results in C#

在表 7-5 中,我用各种搜索词运行程序,这些是结果,我认为这些结果惊人地准确。

表 7-5

与莎士比亚全集进行比对时的搜索词及其结果

|

搜索词

|

位置

|

标题

|

类似

| | --- | --- | --- | --- | | “树林里的恋人们毒死了自己” | one | 仲夏夜之梦 | 0.04105529867838565 | |   | Two | 如你所愿 | 0.02845396350570514 | |   | three | 《爱的徒劳》 | 0.014176769638970023 | | "女巫用匕首沾满鲜血" | one | 麦克白的悲剧 | 0.08824800070165366 | |   | Two | 错误的喜剧 | 0.025993297039907045 | |   | three | 亨利六世的第二部分 | 0.007198784643312808 |

摘要

在这一章中,我们研究了 Spark。ML API,尽管它远不如。NET 版本的 Apache Spark APIs 仍然有用,并且正在积极开发以增加覆盖率。

使用 Spark 有一些复杂之处。ML API in。NET,比如不得不与 raw SparseVector一起工作,但希望这些进入的障碍应该很快被消除。

八、批处理模式处理

在这一章中,我们将学习如何使用。NET for Apache Spark。我们将展示典型的数据处理作业如何读取源数据并解析数据,包括处理源文件中可能存在的任何异常,然后将文件写出为其他数据使用者可以使用的通用格式。

不完整的源数据

当我们处理数据源时,文件很少处于可以处理的完美状态;我们经常要做一些整理数据的工作,在我们本章将要用到的例子中,情况一如既往。我们将使用天然气和电力市场的政府监管机构 Ofgem 在英国发布的一些数据。我是通过浏览英国政府开放数据网站找到这些数据的。这些文件是一个有用的例子,因为它们有几个典型的问题,我们需要在处理数据时处理。

源数据文件

本例所需的数据文件可以从

如果我们检查文件,它们都是 CSV 文件,第一个文件的前几行在清单 8-1 中。

Over 25k Expenditure Report,,,,,,,,,
Date,Expense Type,Expense Area,Supplier,Reference, Amount ,,,,
March 2017,Building Rates,Corporate Services,CITY OF WESTMINSTER,58644," £1,807,657.66 ",,,,
March 2017,Building Rent,Corporate Services,CB RICHARD ELLIS,58332," £1,488,000.00 ",,,,
March 2017,Consultancy Fees, Ofgem ,PRICEWATERHOUSECOOPERS,58660," £187,870.80 ",,,,

Listing 8-1The first few lines of 12_mar_2017_over_25k_spend_report.csv

需要注意的是

  1. 第一行是多余的;“Over 25k Expenditure Report,,,,,,,”使它在电子表格中看起来很好,但我们需要先读取第二行的实际列名。

  2. 每行都有几个空列,每个文件都有不同数量的空列。

  3. 日期格式很奇怪,因为大多数文件都遵循“月+年”的模式,但是至少有一个文件的日期格式是“月-年”。

  4. Amount 列包含填充符、符号和逗号,不便于转换为数值。

  5. 在文件的底部,这里没有显示,有几个空行。

考虑到这些问题,当我们读取文件时,我们需要做一些额外的工作来使数据可供查询。

数据管道

我们将按照以下步骤创建一个数据管道,该管道将读入源文件,并一次一个地将它们处理到数据湖中:

  1. 读取每个 CSV 源文件。

  2. 删除空行。

  3. 使用第二行中的列标题为每一列指定正确的名称。

  4. 删除第一行,这是一个多余的标题。

  5. 将日期转换成可用的日期类型。

  6. 将金额转换成可用的数字类型。

  7. 将更多可用的数据写入数据湖的“结构化”区域。

  8. 使用结构化数据运行一些数据验证规则。

  9. 将经过验证的数据写入数据湖的“管理”区域,按月和年对数据进行分区。

  10. 最后,获取经过整理的数据,并将其写成 delta 格式,以便下游流程和报告可以使用这些数据。

获取源数据并将其转换为已知的结构,然后获取数据并进行管理,然后发布,这是使用数据湖的典型模式。您可能不会使用完全相同的术语“来源”、“结构化”、“策划”和“发布”,但可能会有一些变化。使用这些不同的区域可能看起来过于复杂,但是它允许我们确定我们在不同的区域中有什么。在表 8-1 中,我们看一下每个区域的用途,以及从中读取数据时我们可以期待什么。

表 8-1

数据湖的不同区域

|

面积

|

描述

| | --- | --- | | 来源 | 这是原始的源数据,无论源系统以何种格式提供数据。它通常不能直接使用,需要经过处理才能使用 | | 结构化的 | 原始数据已被解析为通用格式;该数据尚未经过验证,但将采用比原始数据更易于阅读的通用格式。该区域通常是数据最后一次以与接收时相同的方式存储时的位置,即列名和与接收时相同的文件集 | | 当(博物馆、美术馆、图书馆)馆长 | 在这方面,数据已经过验证,可以使用了。我们通常会将数据视为一个完整的数据集,包含所有日期、月份和年份的数据,而不是单独的文件 | | 出版 | 在这个领域,数据通常被转换成某种模型,要么是维度建模,要么是数据仓库建模。该区域中的数据将由报告工具或高级用户使用 |

在本章中,我们将浏览清单 8-1 和 8-2 ,它们是管道的完整 C#和 F#版本,但是我们将一步一步地解释每一个。首先,我们将通过 C#版本,然后是 F#版本,因为实际的实现是不同的。然而,两者都实现了相同的结果,尽管由于语言的差异而略有不同。要完成这些示例,您应该从已经给出的 URL 下载文件,并使用命令行参数将每个文件传递给您的应用程序。

编写数据管道时,有几条信息可以传递到应用程序中,其中最主要的是数据湖的路径。在我们的示例中,我们将引用一个本地文件夹,并使用它在本地测试管道。尽管如此,Apache Spark 的简单性意味着我们可以在开发和测试时写入本地文件系统,然后通过更改配置和数据存储的路径或 URL,在 Azure 存储帐户或 AWS S3 桶或 Hadoop 中传递数据湖的路径,而不必更改代码。

C#数据管道

我们现在将看看如何用 C#构建这个数据管道;因为 F#实现略有不同,你可以在本章的后面找到 F#实现。

在清单 8-2 中,我们验证了传入数据管道的参数,这些参数应该是数据湖的根路径、源文件以及文件所在的年份和月份。

if (args.Length != 4)
{
    Console.WriteLine($"Error, incorrect args. Expecting 'Data Lake Path' 'file path' 'year' 'month', got: {args}");
    return;
}

var spark = SparkSession.Builder()
    .Config("spark.sql.sources.partitionOverwriteMode", "dynamic")
    .Config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension")
    .GetOrCreate();

var dataLakePath = args[0];
var sourceFile = args[1];
var year = args[2];
var month = args[3];

const string sourceSystem = "ofgem";
const string entity = "over25kexpenses";

Listing 8-2Handling command-line arguments and getting setup

这里,我们对参数进行一些基本的验证,然后创建一个SparkSessionSparkSession上设置了一个选项。选项是"spark.sql.sources.partitionOverwriteMode",它允许我们覆盖一个特定的分区,而不是一个完整的表。这很有用,因为当我们写入数据湖的管理部分时,我们将按年和月对数据进行分区。因为每个档案都是一个月的,如果有更正,档案可以重新印发;我们希望处理一个月的数据,并覆盖该月已经存在的任何内容,但不是整个表。如果没有这个选项,我们要么不能覆盖一个分区,要么用单个分区覆盖整个表。

然后我们做一些简单的参数解析。您可能会在参数解析中添加额外的验证,或者在实际应用程序中使用一个库来解析参数。

在清单 8-3 中,我们创建了一个编排管道的方法;步骤是从源文件读取,写入结构化区域,然后写入管理区域,最后在发布区域创建最终结果。

private static void ProcessEntity(SparkSession spark, string sourceFile, string dataLakePath, string sourceSystem, string entity, string year, string month)
{
    var data = OfgemExpensesEntity.ReadFromSource(spark, sourceFile);

    OfgemExpensesEntity.WriteToStructured(data,$"{dataLakePath}/structured/{sourceSystem}/{entity}/{year}/{month}");
    OfgemExpensesEntity.WriteToCurated(data, $"{dataLakePath}/curated/{sourceSystem}/{entity}");

    OfgemExpensesEntity.WriteToPublish(spark, $"{dataLakePath}/curated/{sourceSystem}/{entity}", $"{dataLakePath}/publish/{sourceSystem}/{entity}");
}

Listing 8-3Orchestrate the pipeline

我们做的第一件事是读取源文件,并做一些处理以获得正确的列和数据类型,这样我们就可以在结构化区域中写入我们喜欢的格式。清单 8-4 显示了使用我们需要的选项读取源文件。

var dataFrame = spark.Read().Format("csv").Options(
    new Dictionary<string, string>()
    {
        {"inferSchema", "false"},
        {"header", "false" },
        {"encoding", "ISO-8859-1"},
        {"locale", "en-GB"},
        {"quote", "\""},
        {"ignoreLeadingWhiteSpace", "true"},
        {"ignoreTrailingWhiteSpace", "true"},
        {"dateFormat", "M y"}
    }
).Load(path);

Listing 8-4Reading the source file with the correct options

这组特殊的文件有一个额外的标题行,我们需要跳过。这里我们可以采用几种不同的方法,比如在使用 Apache Spark 之前,对文件进行预处理以删除多余的标题行。如果我用类似 SSIS 的语言编写这个数据管道,那么我可能会对文件进行预处理。在清单 8-5 中,我们展示了如何在不使用列标题的情况下读入整个文件,方法是给行添加一个索引,这样我们可以过滤掉前两行,但使用第二行作为列名的来源。此类文件的一个潜在问题是数据源提供者添加或更改列,因此我们需要小心依赖名称的列排序。使用提供的列名通常更可靠。

private static readonly List<string> ColumnsToKeep = new List<string>()
    {
        "Date","Expense Type","Expense Area","Supplier","Reference", "Amount"
    };

//Add an index column with an increasing id for each row
var dataFrameWithId = dataFrame.WithColumn("index", MonotonicallyIncreasingId());

//Pull out the column names
var header = dataFrameWithId.Filter(Col("index") == 1).Collect();

//filter out the header rows
var filtered = dataFrameWithId.Filter(Col("index") > 1).Drop("index");

var columnNames = new List<string>();
var headerRow = header.First();

for (int i = 0; i < headerRow.Values.Length; i++)
{
    if (headerRow[i] == null || !ColumnsToKeep.Contains(headerRow[i]))
    {
        Console.WriteLine($"DROPPING: _c{i}");
        filtered = filtered.Drop($"_c{i}");
    }
    else
    {

        columnNames.Add((headerRow[i] as string).Replace(" ", "_"));
    }
}

var output = filtered.ToDF(columnNames.ToArray());

Listing 8-5Using an index to skip over the additional header row and then using the column headers to name the columns and ignore any additional empty columns

这里的关键是我们使用了函数MonotonicallyIncreasingId(),它为每一行提供了一个索引号,我们可以用它来过滤。然后我们删除任何我们不需要的列,将实际的标题行作为一个字符串数组读取,并获取过滤后的数据,即没有两个标题行的实际数据,并调用filtered.ToDF,传入列名。这给了我们一个DataFrame,在这里我们可以使用列名来引用列。

因为源数据文件包括几行完全为空的数据,所以在清单 8-6 中,我们过滤掉没有供应商或参考的数据。

output = output.Filter(Col("Reference").IsNotNull() & Col("Supplier").IsNotNull());

Listing 8-6Filtering out rows that are empty

现在我们只有感兴趣的行。我们将修复金额列。在清单 8-7 中,我们展示了如何删除 Amount 列中多余的" "和" ",并将数据转换为实际的数值,在本例中是一个浮点数。

output = output.WithColumn("Amount", RegexpReplace(Col("Amount"), "[£,]", ""));
output = output.WithColumn("Amount", Col("Amount").Cast("float"));

Listing 8-7Turning the Amount string into a usable value

下一个要处理的列是“日期”列,他们通常使用一种日期格式,但是在一些文件中,他们使用不同的日期格式,所以我们需要能够满足两种可能性。为了处理这个问题,在清单 8-8 中,我们复制了一个现有的列,然后尝试转换日期列。如果转换的结果是日期列中的所有值都为空,那么我们将再次尝试使用备用日期格式。

output = output.WithColumn("OriginalDate", Col("Date"));
output = output.WithColumn("Date", ToDate(Col("Date"), "MMMM yyyy"));

if (output.Filter(Col("Date").IsNull()).Count() == output.Count())
{
    Console.WriteLine("Trying alternate date format...");
    output = output.WithColumn("Date", ToDate(Col("OriginalDate"), "MMM-yy"));
}

output = output.Drop("OriginalDate");

return output;

Listing 8-8Dealing with multiple date formats

最后,我们返回应该具有的数据帧

  • 正确的列标题

  • 删除任何空列/行

  • 正确的数据类型

因为我们已经做了相当多的工作才能够读取这些文件,所以用相同的原始数据保存这些文件,但以一种更容易读取的格式保存,通常是有用的。在清单 8-9 中,我们将把文件作为一个拼花文件写到数据湖的“结构化”区域。

    public static void WriteToStructured(DataFrame data, string path)
    {
        data.Write().Mode("overwrite").Format("parquet").Save(path);
    }

Listing 8-9Writing out the raw data in a format that can be easily consumed

我们传入的路径已经限定了年和月的范围,所以我们可以覆盖那里的任何内容;否则,我们希望确保不会覆盖其他月份的数据。

在下一阶段,我们将写入“管理的”区域,这意味着我们需要执行一些验证,以确保我们只引入有效的数据。

在清单 8-10 中,我们将展示第一次验证,以确保我们拥有的模式与预期的模式相匹配。这将验证是否存在正确的列以及数据类型是否正确。在这种情况下,我们只进行等式匹配,以确保模式是相同的。在一些系统中,我们可能想要迭代列,并检查我们至少有 x 个 y 类型的列。

StructType _expectedSchema = new StructType(new List<StructField>()
    {
        new StructField("Date", new DateType()),
        new StructField("Expense_Type", new StringType()),
        new StructField("Expense_Area", new StringType()),
        new StructField("Supplier", new StringType()),
        new StructField("Reference", new StringType()),
        new StructField("Amount", new FloatType())
    });

if (data.Schema().Json != _expectedSchema.Json)
        {
            Console.WriteLine("Expected Schema Does NOT Match");
            Console.WriteLine("Actual Schema: " + data.Schema().SimpleString);
            Console.WriteLine("Expected Schema: " + _expectedSchema.SimpleString);
            ret = false;
        }

Listing 8-10Validating the DataFrame schema

在清单 8-11 中,我们将展示接下来的两个检查,即验证我们在日期列的每一行中都有一个值,并检查我们是否有任何数据。

if (data.Filter(Col("Date").IsNotNull()).Count() == 0)
        {
            Console.WriteLine("Date Parsing resulted in all NULL's");
            ret = false;
        }

        if (data.Count() == 0)
        {
            Console.WriteLine("DataFrame is empty");
            ret = false;
        }

Listing 8-11Validating the date column, and we have at least one row

最终的检查更多的是一个业务规则,数据包含每个在一个月内收费超过 25,000 的供应商,所以我们检查每个金额是否超过 25,000。然而,有时一个供应商提供不同的服务。每项服务可能少于 25,000,因此我们需要聚合供应商列并合计金额,然后过滤以查看是否有低于 25,000 的服务。在清单 8-12 中,我们将展示如何使用 GroupBy 函数进行聚合。

var amountBySuppliers = data.GroupBy(Col("Supplier")).Sum("Amount")
    .Filter(Col("Sum(Amount)") < 25000);

if (amountBySuppliers.Count() > 0)
{
    Console.WriteLine("Amounts should only ever be over 25k");
    amountBySuppliers.Show();
    ret = false;
}

Listing 8-12Using GroupBy and Sum to get a total amount for each supplier

一旦数据得到验证,我们要么写出正确的数据,要么如果验证失败,就把它作为错误写出,以便以后调查。在清单 8-13 中,我们展示了如何写数据,但是我们写的不是单个文件,而是文件的其余数据,并按月和年对其进行分区。如果任何人需要读取数据,他们可以从一个地方读取,使用过滤只读取他们感兴趣的年份和月份。

if (ValidateEntity(data))
{
    data.WithColumn("year", Year(Col("Date")))
        .WithColumn("month", Month(Col("Date")))
        .Write()
        .PartitionBy("year", "month")
        .Mode("overwrite")
        .Parquet(path);
}
else
{
    Console.WriteLine("Validation Failed, writing failed file.");
    data.Write().Mode("overwrite").Parquet($"{path}-failed");
}

Listing 8-13Writing the data into a common area using partitioning to keep it isolated from other years and months

在这一点上,我们有原始数据,在“结构化”区域中我们有更直接的格式供其他人阅读的数据,在“策划”区域中我们有经过验证的数据。最后一步是写入数据湖的“发布”区域。

“发布”区域中的数据有两个特征。首先,我们将应用一些数据建模,而不是只有一个大表,我们将使用一个事实表和一个维度表,用于可以移动到维度中的每个属性。第二个特性是我们将使用 delta 格式来写文件。delta 格式允许我们合并更改,所以如果我们重新处理一个文件,那么任何更新都将被合并到数据中。delta 格式为我们提供了各种有用的有趣属性,比如我们通常与 RDBMS 联系在一起的 ACID 属性。这些酸性给了我们

  • 原子性—写操作完成或未完成,没有部分完成。

  • 一致性–无论何时有人试图读取数据,数据总是处于有效状态。

  • 隔离—多个并发写入不会损坏数据。

  • 耐久性—一旦写入操作完成,它将保持写入状态,无论系统是否出现故障。

知道了我们可以让多个 ETL 作业同时处理不同的文件后,编写数据管道就简单多了。

delta 格式使用它所写的事务日志文件来实现对 ACID 属性的支持,该事务日志进一步为我们提供了在某个时间点读取数据的能力,因此我们可以从表中读取数据,但需要上周出现的数据,这对生产故障排除很有用。

在清单 8-14 中,我们展示了发布过程的第一部分;我们从“策划”区域读入数据。虽然我们有一个单一的程序用于整个过程,但这通常被分成多个作业来处理每一步,所以我们显示了各部分之间的完全划分。

var data = spark.Read().Parquet(rootPath);

Listing 8-14Read the data back in from the “Curated” area

现在我们有了数据,我们将从数据中提取维属性,并将它们拆分到各自的增量表中。这样做的最终目标是拥有一个包含日期和金额等值的事实增量表,而“供应商”和“费用类型”等属性将位于它们自己的增量表中。在清单 8-15 中,我们将读取“Supplier”列并创建一个供应商名称的散列,这将是我们可以用来连接回主事实增量表的键。使用供应商名称的散列而不是递增键的原因是,如果我们愿意,我们可以在并行作业中加载任何维度和事实。如果数据必须在加载事实增量表之前存在于维度中,那么我们需要在处理顺序上更加严格。一旦我们向供应商添加了键列,如果这是我们第一次写入增量表,那么我们将创建一个新表。如果它不是我们正在处理的第一个文件,那么我们将使用“left_anti”连接来连接现有数据,这意味着只给出左边不存在的行。然后,我们将新行插入增量表。很明显,使用 delta 格式进行写入实际上开始使数据湖中的处理类似于 RDBMS 或 SQL 数据库中的处理。

var suppliers = data.Select(Col("Supplier")).Distinct()
                        .WithColumn("supplier_hash", Hash(Col("Supplier")));

var supplierPublishPath = $"{publishPath}-suppliers";

if (!Directory.Exists(supplierPublishPath))
{
    suppliers.Write().Format("delta").Save(supplierPublishPath);
}
else
{
    var existingSuppliers = spark.Read().Format("delta").Load(supplierPublishPath);
    var newSuppliers = suppliers.Join(existingSuppliers, existingSuppliers["Supplier"] == suppliers["Supplier"], "left_anti");
    newSuppliers.Write().Mode(SaveMode.Append).Format("delta").Save(supplierPublishPath);
}

Listing 8-15Storing each supplier in a dimension delta table

在清单 8-16 中,我们做了同样的事情,但是使用了“费用类型”列;我们将数据移动到它自己的维增量表中。

var expenseTypePublishPath = $"{publishPath}-expense-type";

var expenseType = data.Select(Col("Expense_Type")).Distinct().WithColumn("expense_type_hash", Hash(Col("Expense_Type")));

if (!Directory.Exists(expenseTypePublishPath))
{
    expenseType.Write().Format("delta").Save(expenseTypePublishPath);
}
else
{
    var existingExpenseType = spark.Read().Format("delta").Load(expenseTypePublishPath);
    var newExpenseType = expenseType.Join(existingExpenseType, existingExpenseType["Expense_Type"] == expenseType["Expense_Type"], "left_anti");
    newExpenseType.Write().Mode(SaveMode.Append).Format("delta").Save(expenseTypePublishPath);
}

data = data.WithColumn("Expense_Type", Hash(Col("Expense_Type"))).WithColumn("Supplier", Hash(Col("Supplier")));

Listing 8-16Move the “Expense Type” column into its own dimension delta table

在清单 8-17 中,这是发布阶段的最后一部分,如果这是我们处理的第一个文件,那么我们可以将数据写成 delta 格式;如果数据已经存在,那么我们将把数据合并在一起,如果有更新,这将更新任何现有的金额,或者插入新的行。

if (!Directory.Exists(publishPath))
{
    data.Write().Format("delta").Save(publishPath);
}
else
{
    var target = DeltaTable.ForPath(publishPath).Alias("target");
    target.Merge(
        data.Alias("source"), "source.Date = target.Date AND source.Expense_Type = target.Expense_Type AND source.Expense_Area = target.Expense_Area AND source.Supplier = target.supplier AND source.Reference = target.Reference"
        ).WhenMatched("source.Amount != target.Amount")
            .Update(new Dictionary<string, Column>(){{"Amount", data["Amount"]}}
    ).WhenNotMatched()
            .InsertAll()
    .Execute();
}

Listing 8-17Using a merge to write into the existing data

merge 语句本身很有趣。它允许我们通过指定哪些列应该匹配来合并源和目标数据帧;如果我们找到匹配,那么我们可以更新,或者可选地,提供一个额外的过滤器,然后像我们在这里所做的那样进行更新:WhenMatched("source.Amount != target.Amount")。如果合并条件确定一行不存在,我们可以选择做什么;这里,我们只想插入所有的行,但是我们可以更有选择地插入哪些列。最后,要运行 merge 语句,我们需要调用Execute

需要注意的一点是,就目前而言,Merge语句有点混合了代码和 SQL,为了使 SQL 明确地表明哪个是源和目标,我在DeltaTableDataFrame上都使用了 alias,以确保不会混淆我们正在比较的内容和时间。

也可以通过向 SparkSession 添加另一个选项“spark.sql.extensions”来使用 SQL 完整地编写 merge 语句,该选项应设置为“io . delta . SQL . deltasparksessionextension”。如果我们使用这个选项,我们可以用 SQL merge 语句替换我们的代码。

F#数据管道

在清单 8-18 中,我们验证了传入数据管道的参数,这些参数应该是数据湖的根路径、源文件以及文件所在的年份和月份。

let args = match argv with
            | [|dataLakePath; path; year; month|] -> {dataLakePath = argv.[0]; path = argv.[1]; year = argv.[2]; month = argv.[3]; success = true}
            | _ -> {success = false; dataLakePath= ""; path = ""; year = ""; month = "";}

match args.success with
    | false ->
        printfn "Error, incorrect args. Expecting 'Data Lake Path' 'file path' 'year' 'month', got: %A" argv
        -1

    | true ->
              let spark = SparkSession.Builder().Config("spark.sql.sources.partitionOverwriteMode", "dynamic").GetOrCreate()

Listing 8-18Handling command-line arguments and getting setup

这里,我们对参数进行一些基本的验证,然后创建一个SparkSessionSparkSession上设置了一个选项。选项是"spark.sql.sources.partitionOverwriteMode",它允许我们覆盖一个特定的分区,而不是一个完整的表。这是有用的,因为当我们写入数据湖的管理部分时,我们将按年和月对数据进行分区,并且因为每个文件都是一个月的,如果有更正,文件可以重新发布;我们希望处理一个月的数据,并覆盖该月已经存在的任何内容,但不是整个表。如果没有这个选项,我们要么不能覆盖一个分区,要么用单个分区覆盖整个表。

然后我们做一些简单的参数解析。您可能会在参数解析中添加额外的验证,或者在实际应用程序中使用一个库来解析参数。

在清单 8-19 中,我们创建了一个编排管道的方法;这些步骤从源文件中读取,写入结构化区域,然后写入管理区域,最后在发布区域创建最终结果。

let data = getData(spark, args.path)

              writeToStructured (data, (sprintf "%s/structured/%s/%s/%s/%s" args.dataLakePath "ofgem" "over25kexpenses" args.year args.month))
              match validateEntity data with
                | false -> writeToFailed(data, (sprintf "%s/failed/%s/%s" args.dataLakePath "ofgem" "over25kexpenses"))
                           -2
                | true -> writeToCurated(data, (sprintf "%s/curated/%s/%s" args.dataLakePath "ofgem" "over25kexpenses"))
                          writeToPublished(spark,  (sprintf "%s/curated/%s/%s" args.dataLakePath "ofgem" "over25kexpenses"), (sprintf "%s/publish/%s/%s" args.dataLakePath "ofgem" "over25kexpenses"))
                          0

Listing 8-19Orchestrate the pipeline

我们做的第一件事是读取源文件,并做一些处理以获得正确的列和数据类型,这样我们就可以在结构化区域中写入我们喜欢的格式。清单 8-20 显示了用我们需要的选项读取源文件。

let getData(spark:SparkSession, path:string) =
    let readOptions =
        let options = [
            ("inferSchema","true")
            ("header","false")
            ("encoding","ISO-8859-1")
            ("locale","en-GB")
            ("quote","\"")
            ("ignoreLeadingWhiteSpace","true")
            ("ignoreTrailingWhiteSpace","true")
            ("dateFormat","M y")
        ]
        System.Linq.Enumerable.ToDictionary(options, fst, snd)

    spark.Read().Format("csv").Options(readOptions).Load(path)
        |> fun data -> data.WithColumn("index", Functions.MonotonicallyIncreasingId())
        |> dropIgnoredColumns
        |> fixColumnHeaders
        |> filterOutEmptyRows
        |> fixDateColumn
        |> fixAmountColumn

Listing 8-20Reading the source file with the correct options

这组特殊的文件有一个额外的标题行,我们需要跳过。这里我们可以采用几种不同的方法,比如在使用 Apache Spark 之前,对文件进行预处理以删除多余的标题行。如果我用类似 SSIS 的语言编写这个数据管道,那么我可能会对文件进行预处理。在清单 8-21 中,我们展示了如何在不使用列标题的情况下读入整个文件,方法是给行添加一个索引,这样我们可以过滤掉前两行,但使用第二行作为列名的来源。此类文件的一个潜在问题是数据源提供者添加或更改列,因此我们需要小心依赖名称的列排序。使用提供的列名通常更可靠。

let dropIgnoredColumns (dataFrameToDropColumns:DataFrame) : DataFrame =

        let header = dataFrameToDropColumns.Filter(Functions.Col("index").EqualTo(1)).Collect()

        let shouldDropColumn (_:int, data:obj) =
            match data with
                | null -> true
                | _ -> match data.ToString() with
                            | "Date" -> false
                            | "Expense Type" -> false
                            | "Expense Area" -> false
                            | "Supplier" -> false
                            | "Reference" -> false
                            | "Amount" -> false
                            | "index" -> false
                            | null -> true
                            | _ -> true

        let dropColumns =
            let headerRow = header |> Seq.cast<Row> |> Seq.head
            headerRow

                |> fun row -> row.Values
                |> Seq.indexed
                |> Seq.filter shouldDropColumn
                |> Seq.map fst
                |> Seq.map(fun i -> "_c" + i.ToString())
                |> Seq.toArray

        dataFrameToDropColumns.Drop dropColumns

let fixColumnHeaders (dataFrame:DataFrame) : DataFrame =
    let header = getHeaderRow dataFrame
                    |> convertHeaderRowIntoArrayOfNames

    dataFrame.Filter(Functions.Col("index").Gt(1)).Drop("index").ToDF(header)

Listing 8-21Using an index to skip over the additional header row and then using the column headers to name the columns and ignore any additional empty columns

这里的关键是我们使用了函数MonotonicallyIncreasingId(),它为每一行提供了一个索引号,我们可以用它来过滤。然后,我们删除任何不需要的列,将实际的标题行作为一个字符串数组读取,并获取过滤后的数据,即没有两个标题行的实际数据,并调用ToDF(header),其中标题是新的一组列名。这给了我们一个DataFrame,在这里我们可以使用列名来引用列。

因为源数据文件包括几行完全为空的行,在清单 8-22 中,我们过滤掉了没有供应商或参考的行。

let filterOutEmptyRows (dataFrame:DataFrame) : DataFrame =
    dataFrame.Filter(Functions.Col("Reference").IsNotNull()).Filter(Functions.Col("Supplier").IsNotNull())

Listing 8-22Filtering out rows that are empty

现在我们只有感兴趣的行。我们将修复金额列。在清单 8-23 中,我们展示了如何删除 Amount 列中多余的" "和" ",并将数据转换为实际的数值,在本例中是一个浮点数。

let fixAmountColumn (dataFrame:DataFrame) : DataFrame =
    dataFrame.WithColumn("Amount", Functions.RegexpReplace(Functions.Col("Amount"), "[£,]", ""))
    |> fun d -> d.WithColumn("Amount", Functions.Col("Amount").Cast("float"))

Listing 8-23Turning the Amount string into a usable value

下一个要处理的列是“日期”列,他们通常使用一种日期格式,但是在一些文件中,他们使用不同的日期格式,所以我们需要能够满足两种可能性。为了处理这个问题,在清单 8-24 中,我们复制了一个现有的列,然后尝试转换日期列。如果转换的结果是日期列中的所有值都为空,那么我们将再次尝试使用备用日期格式。

let fixDateColumn (dataFrame:DataFrame) : DataFrame =
    dataFrame.WithColumn("__Date", Functions.Col("Date"))
     |> fun d -> d.WithColumn("Date", Functions.ToDate(Functions.Col("Date"), "MMMM yyyy"))
     |> fun d-> match d.Filter(Functions.Col("Date").IsNotNull()).Count() with
                    | 0L -> d.WithColumn("Date", Functions.ToDate(Functions.Col("__Date"), "MMM-yy"))
                    | _ -> d
     |> fun d -> d.Drop("__Date")

Listing 8-24Dealing with multiple date formats

最后,我们返回应该具有的数据帧

  • 正确的列标题

  • 删除任何空列/行

  • 正确的数据类型

因为我们已经做了相当多的工作才能够读取这些文件,所以用相同的原始数据保存这些文件,但以一种更容易读取的格式保存,通常是有用的。在清单 8-25 中,我们将把文件作为一个拼花文件写到数据湖的“结构化”区域。

let writeToStructured(dataFrame:DataFrame, path:string) : unit =
    dataFrame.Write().Mode("overwrite").Format("parquet").Save(path)

Listing 8-25Writing out the raw data in a format that can be easily consumed

我们传入的路径已经限定了年和月的范围,所以我们可以覆盖那里的任何内容;否则,我们希望确保不会覆盖其他月份的数据。

在下一阶段,我们将写入“管理的”区域,这意味着我们需要执行一些验证,以确保我们只引入有效的数据。

在清单 8-26 中,我们将展示第一次验证,以确保我们拥有的模式与预期的模式相匹配。这将验证是否存在正确的列以及数据类型是否正确。在这种情况下,我们只进行等式匹配,以确保模式是相同的。在一些系统中,我们可能想要迭代列,并检查我们至少有 x 个 y 类型的列。

let expectedSchema = StructType(
                                   [|
                                       StructField("Date", DateType())
                                       StructField("Expense_Type", StringType())
                                       StructField("Expense_Area", StringType())
                                       StructField("Supplier", StringType())
                                       StructField("Reference", StringType())
                                       StructField("Amount", FloatType())
                                   |]
                               )

let validateSchema (dataFrame:DataFrame) = dataFrame.Schema().Json = expectedSchema.Json

Listing 8-26Validating the DataFrame schema

在清单 8-27 中,我们将展示接下来的两个检查,即验证我们在日期列的每一行中都有一个值,并检查我们是否有任何数据。

let validateHaveSomeNonNulls (dataFrame:DataFrame) = dataFrame.Filter(Functions.Col("Date").IsNotNull()).Count() > 0L

Listing 8-27Validating the date column, and we have at least one row

最终检查更多的是一个业务规则;该数据包含每个在一个月内收费超过 25,000 英镑的供应商,因此我们检查每个金额是否超过 25,000 英镑。然而,有时,一个供应商提供不同的服务。每项服务可能少于 25,000,因此我们需要聚合供应商列并合计金额,然后过滤以查看是否有低于 25,000 的服务。在清单 8-28 中,我们将展示如何使用 GroupBy 函数进行聚合。

let validateAmountsPerSupplierGreater25K (dataFrame:DataFrame) = dataFrame.GroupBy(Functions.Col("Supplier")).Sum("Amount").Filter(Functions.Col("Sum(Amount)").Lt(25000)).Count() = 0L

Listing 8-28Using GroupBy and Sum to get a total amount for each supplier

一旦数据得到验证,我们要么写出正确的数据,要么如果验证失败,就把它作为错误写出,以便以后调查。在清单 8-29 中,我们展示了如何写数据,但是我们写的不是单个文件,而是文件的其余数据,并按月和年对其进行分区。如果任何人需要读取数据,他们可以从一个地方读取,使用过滤只读取他们感兴趣的年份和月份。

let writeToCurated (dataFrame:DataFrame, path:string) : unit =
    dataFrame.WithColumn("year", Functions.Year(Functions.Col("Date")))
    |> fun data -> data.WithColumn("month", Functions.Month(Functions.Col("Date")))
    |> fun data -> data.Write().PartitionBy("year", "month").Mode("overwrite").Parquet(path);

Listing 8-29Writing the data into a common area using partitioning to keep it isolated from other years and months

在这一点上,我们有原始数据,在“结构化”区域中我们有更直接的格式供其他人阅读的数据,在“策划”区域中我们有经过验证的数据。最后一步是写入数据湖的“发布”区域。

“发布”区域中的数据有两个特征。首先,我们将应用一些数据建模,而不是只有一个大表,我们将使用一个事实表和一个维度表,用于可以移动到维度中的每个属性。第二个特性是我们将使用 delta 格式来写文件。delta 格式允许我们合并更改,所以如果我们重新处理一个文件,那么任何更新都将被合并到数据中。delta 格式为我们提供了各种有用的有趣属性,比如我们通常与 RDBMS 联系在一起的 ACID 属性。这些酸性给了我们

  • 原子性—写操作完成或未完成,没有部分完成。

  • 一致性–无论何时有人试图读取数据,数据总是处于有效状态。

  • 隔离—多个并发写入不会损坏数据。

  • 耐久性—一旦写入操作完成,它将保持写入状态,无论系统是否出现故障。

知道了我们可以让多个 ETL 作业同时处理不同的文件后,编写数据管道就简单多了。

delta 格式使用它所写的事务日志文件来实现对 ACID 属性的支持,该事务日志进一步为我们提供了在某个时间点读取数据的能力,因此我们可以从表中读取数据,但需要上周出现的数据,这对生产故障排除很有用。

在清单 8-30 中,我们展示了发布过程的第一部分;我们从“策划”区域读入数据。虽然我们有一个单一的程序用于整个过程,但这通常被分成多个作业来处理每一步,所以我们显示了各部分之间的完全划分。

let data = spark.Read().Parquet(source)

Listing 8-30Read the data back in from the “Curated” area

现在我们有了数据,我们将从数据中提取维属性,并将它们拆分到各自的增量表中。这样做的最终目标是拥有一个包含日期和金额等值的事实增量表,而“供应商”和“费用类型”等属性将位于它们自己的增量表中。在清单 8-31 中,我们将读取“Supplier”列并创建一个供应商名称的散列,这将是我们可以用来连接回主事实增量表的键。使用供应商名称的散列而不是递增键的原因是,如果我们愿意,我们可以在并行作业中加载任何维度和事实。如果数据必须在加载事实增量表之前存在于维度中,那么我们需要在处理顺序上更加严格。一旦我们向供应商添加了键列,如果这是我们第一次写入增量表,那么我们将创建一个新表。如果它不是我们正在处理的第一个文件,那么我们将使用“left_anti”连接来连接现有数据,这意味着只给出左边不存在的行。然后,我们将新行插入增量表。很明显,使用 delta 格式进行写入实际上开始使数据湖中的处理类似于 RDBMS 或 SQL 数据库中的处理。

let saveSuppliers (spark: SparkSession, dataFrame:DataFrame, source:string, target:string) =

    let suppliers = dataFrame.Select(Functions.Col("Supplier")).Distinct()

    match Directory.Exists(sprintf "%s-suppliers" target) with
        | true -> let existingSuppliers = spark.Read().Format("delta").Load(sprintf "%s-suppliers" target)
                  existingSuppliers.Join(suppliers, existingSuppliers.Col("Supplier").EqualTo(suppliers.Col("Supplier")), "left_anti")
                    |> fun newSuppliers -> newSuppliers.WithColumn("Supplier_Hash", Functions.Hash(Functions.Col("Supplier"))).Write().Mode(SaveMode.Append).Format("delta").Save(sprintf "%s-suppliers" target)
         | false -> suppliers.WithColumn("Supplier_Hash", Functions.Hash(Functions.Col("Supplier"))).Write().Format("delta").Save(sprintf "%s-suppliers" target)

Listing 8-31Storing each supplier in a dimension delta table

在清单 8-32 中,我们做了同样的事情,但是使用了“费用类型”列;我们将数据移动到它自己的维增量表中。

let saveExpenseType (spark: SparkSession, dataFrame:DataFrame, source:string, target:string) =

    let expenseType = dataFrame.Select(Functions.Col("Expense_Type")).Distinct()

    match Directory.Exists(sprintf "%s-expense-type" target) with
        | true -> let existingExpenseType = spark.Read().Format("delta").Load(sprintf "%s-expense-type" target)
                  existingExpenseType.Join(expenseType, existingExpenseType.Col("Expense_Type").EqualTo(expenseType.Col("Expense_Type")), "left_anti")
                    |> fun newExpenseType -> newExpenseType.WithColumn("Expense_Type_Hash", Functions.Hash(Functions.Col("Expense_Type"))).Write().Mode(SaveMode.Append).Format("delta").Save(sprintf "%s-expense-type" target)
         | false -> expenseType.WithColumn("Expense_Type_Hash", Functions.Hash(Functions.Col("Expense_Type"))).Write().Mode("overwrite").Format("delta").Save(sprintf "%s-expense-type" target)

Listing 8-32Move the “Expense Type” column into its own dimension delta table

在清单 8-33 中,这是发布阶段的最后一部分,如果这是我们处理的第一个文件,那么我们可以将数据写成 delta 格式;如果数据已经存在,那么我们将把数据合并在一起,如果有更新,这将更新任何现有的金额,或者插入新的行。

let writeExpenses (dataFrame:DataFrame, target:string) =

    let data = dataFrame.WithColumn("Expense_Type_Hash", Functions.Hash(Functions.Col("Expense_Type"))).Drop("Expense_Type")
                |> fun data -> data.WithColumn("Supplier_Hash", Functions.Hash(Functions.Col("Supplier"))).Drop("Supplier").Alias("source")

    match Directory.Exists(target) with
        | false -> data.Write().Format("delta").Save(target)
        | true ->  DeltaTable.ForPath(target).Alias("target").Merge(data, "source.Date = target.Date AND source.Expense_Type_Hash = target.Expense_Type_Hash AND source.Expense_Area = target.Expense_Area AND source.Supplier_Hash = target.Supplier_Hash AND source.Reference = target.Reference")
                    |> fun merge -> let options = System.Linq.Enumerable.ToDictionary(["Amount", data.["Amount"]], fst, snd)
                                    merge.WhenMatched("source.Amount != target.Amount").Update(options)
                    |> fun merge -> merge.WhenNotMatched().InsertAll()
                    |> fun merge -> merge.Execute()

Listing 8-33Using a merge to write into the existing data

merge 语句本身很有趣。它允许我们通过指定哪些列应该匹配来合并源和目标数据帧;如果我们找到匹配,那么我们可以更新,或者可选地,提供一个额外的过滤器,然后像我们在这里所做的那样进行更新:WhenMatched("source.Amount != target.Amount")。如果合并条件确定一行不存在,我们可以选择做什么;这里,我们只想插入所有的行,但是我们可以更有选择地插入哪些列。最后,要运行 merge 语句,我们需要调用Execute

需要注意的一点是,就目前而言,Merge语句有点混合了代码和 SQL,为了使 SQL 明确地表明哪个是源和目标,我在DeltaTableDataFrame上都使用了 alias,以确保不会混淆我们正在比较的内容和时间。

也可以通过向 SparkSession 添加另一个选项“spark.sql.extensions”来使用 SQL 完整地编写 merge 语句,该选项应设置为“io . delta . SQL . deltasparksessionextension”。如果我们使用这个选项,我们可以用 SQL merge 语句替换我们的代码。

摘要

在 Apache Spark 中编写数据管道,要么使用。NET for Apache Spark 或 Python、Scala 等等,通常是将处理分成一系列更小的步骤,并在每个阶段验证数据。在接收数据时,几乎唯一不变的是数据在某一点上会是错误的,所以这是一个确保您能够有效地调试您的数据管道并了解它们何时何地失败的问题。

在本章中,我们展示了如何从包含大量挑战的数据文件中读取数据,并将这些单独的数据文件处理成一个完整的数据集,该数据集已经过验证,可供企业使用。