Schema 定义了 DataFrame 的列名和类型,我们可以让数据源定义 Schema(schema-on-read),也可以自己明确地进行定义。对于临时分析,schema-on-read 通常效果很好,但是这也可能导致精度问题,例如在读取文件时将 Long 型错误地设置为整型,在生产环境中手动定义 Schema 通常是更好的选择,尤其是在使用 CSV 和 JSON 等无类型数据源时。
Schema 是一种 structType,由很多 StructFields 组成,每个 StructField 具有名称、类型和布尔值标识(用于指示该列是否可以为 null),最后用户可以选择指定与该列关联的元数据,元数据是一种存储有关此列的信息的方式(Spark 在其机器学习库中使用此信息)。如果数据中的类型与 Schema 不匹配,Spark 将引发错误。
1 | import org.apache.spark.sql.types._ |
列只是表达式(Columns are just Expressions
):列以及列上的转换与经过解析的表达式拥有相同的逻辑计划。这是极为重要的一点,这意味着你可以将表达式编写为 DataFrame 代码或 SQL 表达式,并获得完全相同的性能特性。
对 Spark 而言,Columns 是一种逻辑构造,仅表示通过表达式在每条记录上所计算出来的值。这意味着要有一个列的实际值,我们就需要有一个行,要有一个行,我们就需要有一个 DataFrame,你不能在 DataFrame 上下文之外操作单个列,你必须在 DataFrame 中使用 Spark 转换来修改列的内容。
在 DataFrame 中引用列的方式有很多,以下几种语法形式是等价的:
1 | df.columns |
Expressions 是对 DataFrame 记录中一个或多个值的一组转换,可以将其视为一个函数,该函数将一个或多个列名作为输入,进行解析,然后可能应用更多表达式为数据集中每个记录创建单个值(可以是诸如 Map 或 Array 之类的复杂类型)。在最简单的情况下,通过 expr()
函数创建的表达式仅仅是 DataFrame 列引用,expr("col_name")
等价于 col("col_name")
。
列提供了表达式功能的子集,如果使用 col()
并想在该列上执行转换,则必须在该列引用上执行那些转换,使用表达式时, expr 函数实际上可以解析字符串中的转换和列引用,例如:expr("col_name - 5")
等价于 col("col_name") - 5
,甚至等价于 expr("col_name") - 5
。
1 | import org.apache.spark.sql.functions.expr |
DataFrame 中的每一行都是一条记录,Spark 将此记录表示为 Row 类型的对象,Spark 使用列表达式操纵 Row 对象,以产生可用的值。Row 对象在内部表示为字节数组,但是字节数组接口从未显示给用户,因为我们仅使用列表达式来操作它们。
可以通过手动实例化具有每个列中的值的 Row 对象来创建行,但是务必注意只有 DataFrame 有 Schema,Row 本身没有模式。
1 | import org.apache.spark.sql.Row |
访问行中的数据很容易,只需要指定位置或列名:
1 | df.collect().foreach(row=>{ |
DataFrame 转换不会改变原有的 DataFrame,而是生成一个新的 DataFrame。很多 DataFrame 转换/函数被包含在 org.apache.spark.sql.functions
模块,使用前推荐先导入相关模块:
1 | import org.apache.spark.sql.functions._ |
本文主要用到的示例数据:
1 | val data = Seq( |
1 | +--------------------+-----+------+------+ |
功能:select()
用于筛选/操作列;
语法:有两种语法形式,但是两种形式不能混用;
1 | // 传入列名字符串 |
1 | // 可以是列名字符串、*代表所有列、a.b 代表 struct 中的子域、不可用 as |
1 | selectExpr(exprs : scala.Predef.String*) : org.apache.spark.sql.DataFrame |
1 | df.selectExpr("name.firstname", "dob as f_dob", "*", "dob + salary as new_col").show() |
selectExpr 的灵活用法使其可以替代大部分的列操作算子,但是考虑到代码的简洁性,对于一些具体的操作,往往会有更简单直接的算子。事实上,DataFrame 操作使用最多的算子是 withColumn
,withColumn
算子将单列处理逻辑封装到独立的子句中,更具可读性,也方便了代码维护。
withColumn()
可以用来添加新列、改变已有列的值、改变列的类型; 1 | withColumn(colName: String, col: Column): DataFrame |
1 | // 添加新的列 |
1 | withColumnRenamed(existingName: String, newName: String): DataFrame |
1 | // 重命名单个列,withColumnRenamed(x, y) 将 y 列重名为 x |
1 | // drop 有三种不同的形式: |
1 | val df = spark.range(3) |
1 | // 有四种形式 |
1 | df.show() |
distinct()
方法可以移除 DataFrame 中重复的行,dropDuplicates()
方法用于移除 DataFrame 中在某几个字段上重复的行(默认保留重复行中的第一行)。1 | distinct(): Dataset[T] = dropDuplicates() |
1 | df.show() |
groupBy()
函数用于将 DataFrame/Dataset
按照指定字段分组,返回一个 RelationalGroupedDataset
对象语法:RelationalGroupedDataset
对象包含以下几种聚合方法:
示例:
1 | import spark.implicits._ |
1 | // sort |
1 | df.sort("department","state").show(false) |
1 | 1) map[U](func : scala.Function1[T, U])(implicit evidence$6 : org.apache.spark.sql.Encoder[U]) |
1 | // 示例数据 |
功能:foreach() 方法用于在 RDD/DataFrame/Dataset 的每个元素上应用函数,主要用于操作累积器共享变量,也可以用于将 RDD/DataFrame 结果写入数据库,生产消息到 kafka topic 等。foreachPartition() 方法用于在 RDD/DataFrame/Dataset 的每个分区上应用函数,主要用于在每个分区进行复杂的初始化操作(比如连接数据库),也可以用于操作累加器变量。foreach() 和 foreachPartition() 方法都是不会返回值的 action。
语法:
1 | foreachPartition(f : scala.Function1[scala.Iterator[T], scala.Unit]) : scala.Unit |
1 | // foreach 操作累加器 |
1 | // withReplacement: 是否是有放回抽样; fraction: 抽样比例; seed: 抽样算法初始值 |
1 | df.sample(0.2).show() |
1 | randomSplit(weights: Array[Double]) |
1 | val dfs = df.randomSplit(Array(0.8, 0.2)) |
df.limit(0)
来实现;1 | df.limit(n) |
1 | df.orderBy("dob").limit(3).show() |
功能:获取某列第一行/最后一行的值
语法:
1 | first(e: Column, ignoreNulls: Boolean) |
1 | df.select(first("name"), first("dob"), last("gender"), last("salary")).show() |
1 | df.union(df2) |
1 | // 没有什么好展示的 |
1 | 1) join(right: Dataset[_]): DataFrame |
import org.apache.spark.sql.catalyst.plans._
,以下示例将采用上面语句 6 的形式JoinType | Join String | Equivalent SQL Join |
---|---|---|
Inner.sql | inner | INNER JOIN |
FullOuter.sql | outer, full, fullouter, full_outer | FULL OUTER JOIN |
LeftOuter.sql | left, leftouter, left_outer | LEFT JOIN |
RightOuter.sql | right, rightouter, right_outer | RIGHT JOIN |
Cross.sql | cross | - |
LeftAnti.sql | anti, leftanti, left_anti | - |
LeftSemi.sql | semi, leftsemi, left_semi | - |
1 | val emp = Seq((1,"Smith",-1,"2018","10","M",3000), |
Inner Join 内连接,只返回匹配成功的行。
1 | empDF.join(deptDF,empDF("emp_dept_id") === deptDF("dept_id"),"inner").show(false) |
Outer/Full,/Fullouter Join 全外连接,匹配成功的 + 左表有右表没有 + 右表有左表没有
1 | empDF.join(deptDF,empDF("emp_dept_id") === deptDF("dept_id"),"outer").show(false) |
Left/Leftouter Join 左连接,匹配成功的 + 左表有右表没有的
1 | empDF.join(deptDF,empDF("emp_dept_id") === deptDF("dept_id"),"left").show(false) |
Right/Rightouter Join 右连接,匹配成功的 + 右表有左表没有的
1 | empDF.join(deptDF,empDF("emp_dept_id") === deptDF("dept_id"),"right").show(false) |
Left Semi Join 左半连接,匹配成功的,只保留左表字段。
1 | empDF.join(deptDF,empDF("emp_dept_id") === deptDF("dept_id"),"leftsemi").show(false) |
Left Anti Join 反左半连接,没有匹配成功的,只返回左表字段
1 | empDF.join(deptDF,empDF("emp_dept_id") === deptDF("dept_id"),"leftanti").show(false) |
虽然没有自连接类型,但是可以使用以上任意一种 join 类型与自己关联,但是要通过别名的方式。为DataFrame 起别名 "a"
后,原有字段名 "col"
就变成 "a.col"
,可以通过 "a.*"
把原有的列“释放”出来。
1 | empDF.as("emp1").join(empDF.as("emp2"), col("emp1.superior_emp_id") === col("emp2.emp_id"),"inner") |
Cross Join(笛卡尔连接、交叉连接)会将左侧 DataFrame 中的每一行与右侧 DataFrame 中的每一行进行连接,这将导致结果 DataFrame 中的行数发生绝对爆炸,仅在绝对必要时才应使用笛卡尔积,它们很危险!!!我们分几种场景来讨论和 Cross Join 相关的一些问题:
join
算子中如果指定了连接谓词,那么,即使将参数 joinType
设置为 “cross”,实际执行的仍然是 inner join
1 | empDF.join(deptDF, empDF("emp_dept_id") === deptDF("dept_id"), "cross").show() |
join
算子中,如果将连接谓词设置为恒等式,可以实现笛卡尔积(joinType
需同时设置为 “cross”)1 | empDF.join(deptDF, lit(1) === lit(1), "cross").show() |
join
算子中,如果省略了连接谓词,则会报 AnalysisException
错误,一种解决办法是设置 spark.conf.set("spark.sql.crossJoin.enabled",true)
,以允许笛卡尔积而不会发出警告或 Spark 不会尝试为您执行另一种连接1 | empDF.join(deptDF).show() |
spark-sql_2.11
2.1.0 之后的版本专门提供了 crossJoin
算子来实现笛卡尔积,使用 crossJoin
不用修改配置1 | empDF.crossJoin(deptDF).show() |
当同源 DataFrame(衍生于同一个 DataFrame )之间进行 Join 时,可能会导致一些意想不到的错误。
1 | var x = empDF.groupBy("superior_emp_id").agg(count("*").as("f_cnt")) |
有多种方式可以解决这个问题:
1 | empDF.createOrReplaceTempView("empDF") |
1 | empDF.as("a").join(x.as("b"), col("a.emp_id") === col("b.superior_emp_id")).show() |
withColumn
重命名列1 | val x = empDF.groupBy("superior_emp_id").agg(count("*").as("f_cnt")) |
toDF
重新定义其中一个 DataFrame 的 Schema:1 | x = x.toDF(x.columns:_*) |
usingColumn
语法得到的结果 DataFrame 中会自动去除被 join DataFrame 的关联键,只保留主调 DataFrame 中的关联键,所以不能通过 select
或 expr
选择被调 DataFrame 中的关联键,但是却可以在 filter
中引用被调 DataFrame 中的关联键:
1 | val x = deptDF.limit(2).select("dept_id").toDF("dept_id") |
如果参与 join 的两个 DataFrame 之间存在相同名称的字段,很容易在后续的转换操作中出现 Reference is ambiguous
的错误,整体上有两种解决思路:
在 join 前中后又可以有不同的处理方式:
usingColumn
语法,join 后只会保留左表关联字段;select(Expr)
明确指定需要的表字段;drop
删除不需要的表字段;withColumn
添加新的字段,此时 withColumn
如果用于修改已有同名字段的内容,将会同时修改所有同名字段,修改后的结果仍会保留同名字段; 示例:
1 | // 示例数据 |
DataFrame API 的 JOIN 操作有诸多需要注意的地方,除了正确使用 JOIN 类型和 JOIN 语法外,经常引起困惑的地方在于如何从 JOIN 结果中选择我们需要的字段,对此,我们总结了一些最佳实践:
"表别名.字段名"
来引用对应字段;"字段名"
来应用对应字段;usingColumn
语法将只会保留左表关联字段;select(Expr)
需要的字段,drop
不需要的字段,withColumn
添加新的字段;toDF()
转化其中一个 DataFrame;看过上面的示例,你可能会觉得 DataFrame 的 JOIN 太不方便了,还不如直接写 SQL 表达式呢!事实上,DataFrame API 更加紧凑,更便于编写结构化代码,能够帮助我们完成大部分的语法检查,如果要在 DataFrame 中穿插 SQL 表达式,就使用 expr() 或 selectExpr() 函数吧!
1 | // 指定所需的分区数 |
1 | df.repartition(3) |
1 | coalesce(numPartitions: Int) |
1 | df.repartition(5, col("dob")).coalesce(2) |
功能:虽然 Spark 提供的计算速度是传统 Map Reduce 作业的 100 倍,但是如果您没有将作业设计为重用重复计算,那么当您处理数十亿或数万亿数据时,性能会下降。使用 cache() 和 persist() 方法,每个节点将其分区的数据存储在内存/磁盘中,并在该数据集的其他操作中重用它们,真正缓存是在第一次被相关 action 调用后才缓存。Spark 在节点上的持久数据是容错的,这意味着如果数据集的任何分区丢失,它将使用创建它的原始转换自动重新计算。Spark 会自动监视您进行的每个 persist()和cache()调用,并检查每个节点上的使用情况,如果不再使用或通过 least-recently-used (LRU) 算法,删除持久化数据,也可以使用 unpersist()方法手动删除。unpersist()将数据集标记为非持久性,并立即从内存和磁盘中删除它的所有块。
语法:
1 | // StorageLevel |
1 | // cache |
级别 | 使用空间 | CPU时间 | 是否内存 | 是否磁盘 | 备注 |
---|---|---|---|---|---|
MEMORY_ONLY | 高 | 低 | 是 | 否 | - |
MEMORY_ONLY_2 | 高 | 低 | 是 | 否 | 数据存2份 |
MEMORY_ONLY_SER_2 | 低 | 高 | 是 | 否 | 数据序列化,数据存2份 |
MEMORY_AND_DISK | 高 | 中等 | 部分 | 部分 | 内存放不下,则溢写到磁盘 |
MEMORY_AND_DISK_2 | 高 | 中等 | 部分 | 部分 | 数据存2份 |
MEMORY_AND_DISK_SER | 低 | 高 | 部分 | 部分 | - |
MEMORY_AND_DISK_SER_2 | 低 | 高 | 部分 | 部分 | 数据存2份 |
DISK_ONLY | 低 | 高 | 否 | 是 | |
DISK_ONLY_2 | 低 | 高 | 否 | 是 | 数据存2份 |
NONE | - | - | - | - | - |
OFF_HEAP | - | - | - | - | - |
功能:collect() 和 collectAsList() 用于将 RDD/DataFrame/Dataset 中所有的数据拉取到 Driver 节点,然后你可以在 driver 节点使用 scala 进行进一步处理,通常用于较小的数据集,如果数据集过大可能会导致内存不足,很容易使 driver 节点崩溃并时区应用程序的状态,这也很昂贵,因为是逐条处理,而不是并行计算。
语法:
1 | collect() : scala.Array[T] |
1 | df.show() |
when otherwise
类似于 SQL 中的 case when 语句;1 | when(condition: Column, value: Any): Column |
1 | df.withColumn("new_gender", when(col("gender") === "M", "Male")).show() |
功能:在 Spark SQL 中,扁平化 DataFrame 的嵌套结构列对于一级嵌套很简单,而对于多级嵌套和存在数百个列的情况下则很复杂。
扁平化嵌套 struct: 如果哦列数有限,可以通过引用列名似乎很容易解决,但是请想象一下,如果您有100多个列并在一个select中引用所有列,那么会很麻烦。可以通过创建一个递归函数 flattenStructSchema()轻松地将数百个嵌套级别列展平。
1 | val structureData = Seq( |
1 | val arrayArrayData = Seq( |
语法:
示例:
1 | // 示例数据 |
功能:
语法:
1 | groupBy(x).pivot(y).sum(z) // x 列不同值作为行标签,y 列不同值作为列标签,z 列的聚合作为值 |
1 | // 创建一个 DataFrame |
1 | // stack(n, 列1显示名, 列1, ..., 列n显示名, 列n) |
在因果推断中,必须有干预(Intervention),没有干预就没有因果(Rubin,1974)。干预可以是一项政策、一项措施或一项活动等,比如实施 4 万亿财政刺激方案,服用某种新药等。本文主要讨论二值干预变量,两个值分别对应于积极的行动和被动的行动,分别称为干预和控制,受到对应干预的个体分别称为干预组和控制组。
干预和控制只是干预变量的两种状态的标签,具体哪个状态被称为干预,哪个状态称为控制并不重要,两种状态实际上是对称的,可以互换,取决于研究者的目的和偏好。比如,对于药物试验来说,干预是服用药物,控制是不服用药物。
在干预状态实现之前,有几个干预状态就有几个潜在结果(Potential outcome),而干预状态实现之后,只有一个潜在结果是可以观测到的。可以将潜在结果看作常数,对于每个特定的个体,他在两种干预状态下的潜在结果是给定的,不依赖于最终实现的干预状态,这一点对于理解 Rubin 因果模型很关键。
比如,考察大学教育对个人收入的影响,干预变量或原因变量是大学教育,那么对于任意个体 $i$ 有两种干预状态,用 $Di$ 来表示,$D_i=1$ 表示个体 $i$ 完成了大学教育,$D_i=0$ 表示个体 $i$ 完成高中教育。无论个体实际是完成大学教育还是高中教育,事前每个个体均有两种可能的状态:完成高中教育或完成大学教育。每一个状态下对应于一个潜在结果,$Y{1i}$ 表示个体 $i$ 在状态$Di=1$ 下的潜在结果,$Y{0i}$ 表示个体 $i$ 在状态 $Di=0$ 下的潜在结果。对个体而言,这两个潜在结果可以看作是确定性的变量,不因个体干预变量的实现状态而改变。比如个体 $i$ 完成大学教育状态下的收入为 $8000$ 元,即 $Y{1i}=8000$,仅完成高中教育状态下收入为 6000 元,即 $Y_{0i}=6000$。如果个体 $i$ 最后实际完成了大学教育,那么其两种干预状态下的潜在结果仍然是(8000,6000),如果个体 $i$ 最后实际完成的是高中教育,其两种干预状态下的潜在结果还是(8000,6000),不因个体最后实现的状态而改变。
当干预状态实现之后,我们仅能观测到实现状态下的潜在结果,称为观测结果(Observation outcome),没有实现状态下的潜在结果是无法观测的,通常称为反事实结果(Counterfactual outcome)。比如个体 $i$ 最终完成了大学教育,那么观测到的干预状态是 $Di=1$,我们可以观测到潜在结果 $Y{1i}$,即个体 $i$ 完成大学教育后的收入。他完成了大学教育,我们就不能观测到他没有完成大学教育时的潜在结果 $Y_{0i}$,即仅完成高中教育时的收入。一个人不可能同时踏入两条河流,不可能同时处于两种状态,因而,观测研究中,不可能同时看到个体所有的潜在结果。无法同时观测到个体所有潜在结果的现象称为因果推断的基本问题(Holland,1986)。
观测结果 $Y_i$ 与潜在结果之间的关系,可以用下面的公式表示:
潜在结果和观测结果的区分是现代统计学和现代计量经济学的重要标志,是经济学经验研究“可信性革命”的关键,也是区分描述性研究(descriptive study)和因果研究(causal study)的标志。
有了潜在结果的概念,个体因果效应的定义非常直观,不需要对分配机制进行任何内生性或外生性的假设,也不需要对结果变量的函数形式进行任何假设,对于个体 $i$,某项干预的因果效应是两种状态下的潜在结果的比较:
关于因果效应的定义有两点说明:
因果效应的定义仅依赖于不同潜在结果的比较,对于给定个体,研究者只能观察到该个体一个状态下的潜在结果,因而,如果仅有一个个体,我们是没有办法得到个体因果效应的。因果推断的核心内容,实际上是想办法将未观测到的潜在结果估计出来,即反事实结果估计。估计反事实结果必须要用到多个个体,多个个体的选择方式有两种:
RCM 的第二个要素是稳定个体干预值假(Stable Unit Treatment Value Assumption, SUTVA),简称稳定性假设(Rubin,1980),SUTVA 有两层含义:
分配机制是描述为什么有的人在干预组,有的人在控制组的机制。分配机制决定了个体哪个潜在结果会被实现,可以被观测到。在因果推断中,分配机制非常重要,来看一个“手术相对于药物的治疗效果”的例子:
在潜在结果列可以看出,对于病人 1 和病人 3 来说,手术治疗效果优于药物治疗,而对于病人 2 和病人 4 来说,药物治疗优于手术治疗。假设现实中医生具有很好的医术或鉴别力,可以让病人选择对他最有利的治疗方案,从而实现的分配机制如表中第 5 列所示,让 1 和 3 号病人接受手术治疗,让 2 和 4 号病人接受药物治疗,最终我们可以观测到 1、3 病人的 $Y{1i}$ 以及 2、4 病人的 $Y{0i}$,如观测结果列所示。如果不清楚分配机制,直接用两组观测结果进行比较,将会发现手术治疗平均寿命为 6 年,而药物治疗平均寿命为 7 年,从而得出药物治疗更有效的错误结论。而事实上,通过潜在结果计算出的平均因果效应,手术治疗要比药物治疗寿命长 2 年。
根据分配机制是否已知,可以将分配机制分成两类:
为了搞清楚分配机制,往往需要一些协变量(Covariates),也称混淆变量(Confusion variable),协变量的基本特征是这些变量不受干预变量的影响,但是却往往决定个体的干预状态,协变量包括两种:
非混杂性(Unconfoundedness),也称为条件独立性(Conditional independence),是指控制协变量 $X_i$ 后,个体干预状态的分配独立于潜在结果,非混杂性可以表示为:
根据分配机制是否满足条件独立性条件,可以将分配机制分成三类:
潜在结果的概念,对理清所要研究的因果问题、定义因果效应非常有帮助。有些因果问题的探讨,必须从潜在结果概念出发才能搞清楚因果效应是否有清晰的定义,从观测结果出发进行建模往往不能清晰地表述所研究的因果效应问题。
这一节介绍一个在统计学中很有名,但是在中文统计教科书中几乎从未介绍过的悖论 —— Lord 悖论(Lord’s Paradox)。这个悖论是由美国教育考试服务中心(EducationalTestingService, ETS)统计学家 FredericLord 于 1967 年提出来的,最终由同在 ETS 工作的另外两位统计学家 Paul Holland 和 Donald Rubin 于 1982 年圆满地找出了这个悖论的根源。
Lord(1967)构造了一个假想的案例,一所大学想考察其食堂膳食对于学生体重是否有差异性的影响,尤其关心食堂对于男女学生体重影响是否相同,为此,收集了学生 9 月份入学时的体重,然后次年 6 月份又获得了学生在校一学年后的体重。两个统计学家分别利用这个数据考察了学校食堂对学生体重的影响,但得到了完全不同的结论:
两个统计学家利用同一数据,采用不同的方法,得到几乎相反的结果,一个说无因果影响,一个说对男生的影响更大,这种矛盾的结果被称为 Lord 悖论。那么,这两个统计学家的分析,哪一个正确呢?
我们首先用 Rubin 因果模型的框架套用到该问题上:
表中的问号(?)是解决 Lord 悖论的关键,尽管积极干预是非常清晰的——学校食堂膳食,它对学生体重的影响是想要研究的问题,但没有清晰的控制干预,不在学校食堂吃饭时是在家吃饭还是在外面下馆子,我们并不清楚,这意味着潜在结果 $Y_0$ 的定义是模糊的,我们权且将 $Y_0$ 看做是假如期间学生没有在学校食堂吃饭时的体重。然而,没有学生在控制组,所有学生都在学校食堂吃饭,为了回答食堂对学生体重的影响,必然要引入一些有关 $Y_0$ 的不可检验的假设,这也正是两位统计学家产生分歧的地方。
食堂膳食对学生体重的个体影响可以写作 $Y_1 - Y_0$,对男女学生的平均影响可以写作:
平均因果影响的性别差异为:
第一位统计学家根据男女学生入学前和放假后平均体重的对比,得到学校膳食没有影响的结论。他所依据的假设是“假如学生不在学校食堂吃饭,他们的体重变化相同”,即 $Y_0 = X + C$,其中 $C$ 对男女学生都是相同的常量,基于该假设可以计算平均因果影响的性别差异:
第二位统计学家认为应该控制开学时的体重,比较相同体重的人放假时体重的变化,对于初始体重为 X 的个体,体重的增加为 $\delta_i(X) = E[Y_i-X|X,G=i],\ i=1,2$,增量的性别差异为 $\delta(X) = \delta_1(X)-\delta_2(X)$,为简单起见,Lord 假设条件期望函数均为线性且男女生斜率相同,即 $E[Y_i|X,G=i]=a_i+bX,\ i=1,2$,则 $\delta(X)=a_1-a_2$。$\delta(X)$ 与因果效应参数 $\Delta$ 没有直接关系,但是在一定的假设下二者等价,比如假设“如果学生不在学校食堂吃饭,他们的体重是初始体重的线性函数”,即 $Y_0 = a + bX$,并且对所有性别的学生都一样,在此假设下,有:
关于 Lord’s Paradox,我们有如下结论:
实证研究中,我们关心的往往不是某一特定个体的因果效应,而是干预的平均因果效应。假设有 N 个个体,用 i=1,……,N 表示,$D_i \in {0,1}$ 表示干预变量,个体因果效应为:
个体因果效应往往无法估计,因而,我们关注总体平均因果效应(Average Treatment Effect, ATE),它表示从总体中随机抽取一个个体进行干预的平均因果效应:
在政策评价中,我们更关心那些受到政策影响的个体的平均因果效应,称为干预组平均因果效应(Average Treatment Effect for the Treated,ATT):
有些时候,我们关注那些没有受到政策影响的个体如果接受政策干预的话,其平均因果效应是多少,称为控制组平均因果效应(Average Treatment Effect for the Control, ATC):
不同的因果效应参数回答不同的问题,比如考察大学教育对个体收入的影响,将大学教育看作一项积极干预,高中教育看作一项控制干预:
下面通过一个简单的例子来示范三个因果效应参数的计算,假设有四个个体,并且我们可以同时看到两种干预状态下的潜在结果(现实中只能看到一种状态下的结果):
理论上,我们可以根据表中的潜在结果数据分别计算 ATE、ATT、ATC:
实际上,我们仅能观测到每个个体在其中一种状态下的潜在结果。对于前两个个体,他们在干预组,我们可以观测到他们在积极干预状态下的潜在结果 $Y{1i}=Y_i$,但观测不到他们在控制状态下的潜在结果 $Y{0i}$;相反对于后两个个体,他们在控制组,我们可以观测到他们在被动控制状态下的潜在结果 $Y{0i}=Y_i$,但却观测不到他们在干预状态下的潜在结果 $Y{1i}$。从而,前面计算的三个因果效应参数也就没有办法计算出来了,现在我们再来看各个因果效应参数的定义:
其中,反事实结果 $E[Y{0i}|D_i=1]$ 和 $E[Y{1i}|D_i=0]$ 是观测不到的,必须通过一定的方法将其估计出来,才能得到以上干预效应。
学过回归分析的学生可能禁不住想用 $Y_i$ 对 $D_i$ 回归,这也是计量经济学的基本建模方式,但是这种回归并不能识别出任何因果效应参数。比如我们建立一个简单的双变量回归模型:
根据初等计量经济学的知识,用一个容量为 N 的随机样本去估计上述简单回归模型,$D_i$ 的回归系数为:
当干预变量是 $0-1$ 二值变量时,可以证明 $Y_i$ 对 $D_i$ 的回归系数 $\hat{\tau}^{ols}$ 等于干预组和控制组样本均值之差,在大样本的情况下:
$\tau^{ols}$ 是总体回归系数,一般不能反映因果效应参数,除非施加一定的假设。
首先,考察总体回归系数和干预组平均因果效应(ATT)之间的关系:
回归系数和因果效应参数 ATT 之间相差 $E[Y{0i}|D_i=1]-E[Y{0i}|Di=0]$,它表示干预组和控制组个体在控制状态下的潜在结果差异,也称为基线潜在结果差异(difference in baseline potential outcomes),这一偏差通常称为选择偏差(selection bias)。$E[Y{0i}|Di=1]$ 表示干预组个体在控制状态下的潜在结果,是观测不到的,但是在选择偏差为 0 的假设下,可以用控制组的观测结果 $E[Y{0i}|Di=0]$ 来代替干预组的反事实结果 $E[Y{0i}|D_i=1]$。比如教育收益率的例子,如果潜在收入高的人倾向于选择上大学,那么,上大学的人即使仅完成了高中教育,他们的收入也会比高中组高,那么大学组合高中组观测到的收入均值差就不能解释为大学教育对个人收入的因果影响,选择偏差为正,回归系数将高估教育对收入的影响。
类似地,总体回归系数也不是控制组平均因果效应(ATC),只有假设干预组和控制组的干预潜在结果相同,即 $E[Y{1i}|D_i=1]=E[Y{1i}|D_i=0]$,回归系数才等于 ATC:
最后,总体回归系数通常也不是平均因果效应,只有同时施加假设 $\Delta \tau0=0$ 和$\Delta \tau_1=0$ 时,总体回归系数才可解释为总体平均因果效应。将式 $(16)$ 和 $(17)$ 带入到 $\tau{ATE}=\tau{ATT}\cdot P_t+\tau{ATC}\cdot P_c$,易得:
我们可以得到分配机制、潜在结果、干预效应和回归系数之间的一般关系,如下图所示:
需要注意的是,潜在结果框架仅关注因果效应,不能说明变量之间的影响机制,因果效应是一个“黑箱”,只能给出因果效应的大小,不能给出产生这一因果效应的内在机制。
There are three kinds of lies: lies, damned lies, and statistics.
——Mark Twain
辛普森悖论(Simpson’s paradox)是概率统计中的一种现象:在变量 Z 的每一个分层上,变量 X 和变量 Y 都表现出一致的相关性,但是在 Z 的整体上,X 和 Y 却呈现出与之相反的相关性。该现象于 20 世纪初就有人讨论,但一直到 1951 年 E.H.辛普森在他发表的论文中阐述此一现象后,该现象才算正式地被描述解释,辛普森悖论这个名字是由柯林·布莱斯(Colin R. Blyth)在 1972 年提出的。
以 BBG 药物(Bad/Bad/Good Drug)之谜为例,假设有一种新药 D,这种新药似乎可以降低心脏病发作的风险,我们通过临床观测收集到了如下数据(数据来自观测实验而非随机化实验):
整体来看,服药组和未服药组各有 60 人,男性和女性各有 60 人,不同人群的心脏发病率表现如下:
这种药物似乎对女性有害,对男性也有害,但却对整个人类有益!一个表面的解决方案是,当我们知道病人的性别是男性或者是女性时,我们不采用这种药物疗法,但如果病人的性别是未知的,我们就应该采用这种疗法!但显然,这个结论是荒谬的。这三句话中一定有一句是错的,但错的是哪一句?为什么?这种令人迷惑不解的情况究竟是如何发生的呢?
任何声称能够解决悖论的方法都应该能够回答一些关于悖论的基本问题:
“辛普森逆转”是指在合并样本时,两个或多个不同样本关于某一特定事件的相对频率出现反转的现象。在上面的例子中,我们可以看到两组相对频率:1/20 < 3/40
,12/40 < 8/20
,然而 (1 + 12)/(20 + 40) > (3 + 8)/(40 + 20)
。
为了直观理解辛普森逆转机制,我们通过混合不同浓度的溶液来类比混合不同性别心脏发病率的场景,其中容器的形状代表性别,女性用圆形容器来表示,男性则用方形容器来表示,患者发病率用黑色阴影来表示,混合前圆形容器和方形容器干预组液体浓度都要大于对照组,混合后干预组液体浓度却高于对照组:
辛普森逆转通常满足两个前提:
辛普森逆转只是一个纯粹的数字事实,本身并无新奇之处,它最多只是纠正了人们对“平均表现”的错误概念。而悖论的含义不止于此,它应该能够引起两种为绝大部分人深信不疑的信念之间的冲突。在 BBG 药物悖论中,当“对男性有害”“对女性有害”“对人类有益”这三个陈述被简单理解为比例增减时,它们在数学上并不矛盾,但是你可能认为这种情况在现实世界中不可能存在,因为一种药物不可能既导致心脏病发作又防止心脏病发作。幸运的是,你的直觉是对的,BBG 药物确实不存在!
确凿性原则:假如无论事件 C 是否发生,某个行动都会增加某一结果的可能性,则该行动也将在我们不知道事件 C 是否发生的情况下增加这个结果的可能性,前提是该行动不会改变 C 的概率。
根据确凿性原则,以下三种陈述之一必定为假:
因为药物改变病人性别的事不太可能发生,所以前两句陈述中一定有一句为假。那么,哪句陈述是假的?要回答这个问题,我们必须在数据之外探寻数据生成的过程。我们可以通过以下因果图对 BBG 药物数据的产生过程建模,这张图对性别对心脏病发作风险的影响(男性患者的风险更大),以及性别对患者是否选择服用药物 D 的影响(女性更倾向于服用药物 D)进行了编码,性别因素构成了是否服用药物和心脏病发作的混淆因子:
为了客观估计药物对心脏病的影响,我们必须对混淆因子进行控制,或按照一般总体中性别分布对不同性别下药物效果进行加权:
5% × 0.5 + 30% × 0.5 = 17.5%
< 服药组的心脏病发病率 8% × 0.5 + 40% × 0.5 = 24%
;至此,我们找到了关于 BBG 药物最清晰、明确的答案:药物 D 不是 BBG 药物,而是 BBB 药物,对女性有害、对男性有害、对人类有害。
至此,我们回答了 BBG 药物悖论中的基本问题:
关于辛普森悖论,还应明确:
假设高血压是心脏病发作的可能原因,而药物 B 能降低血压,研究人员向看看这种药物是否也能降低心脏病发作的风险,因此他们在病人服药后测量了病人的血压,并观察病人是否会出现心脏病发作的情况:
这些数据看起来非常熟悉,其中的数字和 BBG 药物的统计数据是完全一致的。我们可以通过以下因果图对服用药物 B、血压、心脏病发作三者建模,与 BBG 因果图不同的是,血压不再是药物服用和心脏病发作的混淆因子,而是二者之间的中介物:
“服用药物 B -> 心脏病发作”这一因果关系中没有混杂因子,所以数据分层是不必要的。事实上,如果控制血压会使其中一条因果路径失效(而且可能是最重要的那条因果路径),导致药物无法通过这条路径发挥作用。鉴于此,我们得出的结论与在 BBG 药物的例子中得到的结论完全相反:药物 B 能有效预防心脏病发作。
1996 年发表的一篇观察性研究报告表明,对于摘除小型肾结石而言,开腹手术比内窥镜手术的恢复率高,对于摘除较大的肾结石而言,开腹手术也有更高的恢复率。然而就总体而言,开腹手术的恢复率反而较低。
小肾结石被认为是不太严重的病例,开腹手术比内窥镜手术更加激进,因此对于小肾结石,医生更有可能推荐保守内窥镜手术,因为病情不太严重,患者也更有可能首先成功恢复。对于严重的大肾结石,医生往往选择更激进的开腹手术,较大肾结石的病人本身的恢复率较低。
在 1995 年发表的一份关于甲状腺疾病的研究报告中,数据显示吸烟者的存活率(76%)比不吸烟者的存活率(69%)更高,寿命平均多出20年。然而,在样本的7个年龄组中,有6个年龄组中不吸烟者的存活率更高,而第7个年龄组中二者的差异微乎其微。年龄显然是吸烟和存活率的混杂因子:吸烟者的平均年龄比不吸烟者小(很可能是因为年老的吸烟者已经死了)。根据年龄来分割数据,我们就可以得出正确的结论:吸烟对存活率有负面影响。
逆转也可能发生在包含连续变量的情况,假设有一项关于各年龄段群体每周的运动时间与其体内胆固醇水平之关系的研究。如左图所示,我们以 x 轴表示运动时间,以 y 轴表示胆固醇水平。一方面,我们在每个年龄组中都看到了向下的趋势,表明运动可能的确有降低人体胆固醇水平的效果。另一方面,如果我们使用相同的散点图,但不按年龄对数据进行分层,如右图所示,那么我们就会看到一个明显向上的趋势,表明运动得越多,人体胆固醇水平就越高。看起来我们再次遇到了 BBG 药物的情况,其中运动就是那个药物:它似乎对每个年龄组都产生了有益的影响,却对整个总体有害。
像往常一样,要决定运动是有益的还是有害的,我们需要考察数据背后的故事。数据显示,总体中年龄越大的人运动得越多。因为更可能发生的是年龄影响运动,而不是反过来。同时,年龄可能对胆固醇水平也有因果效应。因此我们得出结论,年龄可能是运动时间和胆固醇水平的混杂因子,我们应该对年龄进行变量控制。换言之,我们应该看的是按照年龄组别进行分层后的数据,并据其得出结论:无论年龄大小,运动都是有益的。
我们生活在一个相信大数据能够解决所有问题的时代,然而数据远非万能,数据可以告诉你服药的病人比不服药的病人康复得快,却不能告诉你原因何在。也许,那些服药的人只是因为他们支付得起,即使不服用这种药,他们也能恢复得更快。正如 Kendall 和 Stuart 所说,统计关系无论有多强,有多紧密,也决不能建立起因果关系,因果关系的概念来自统计学之外的某个理论。
因果观念是人类认知事物的重要方式,我们相信,世界并非是由简单的事实堆砌而成,相反,这些事实是通过错综复杂的因果网络联系在一起的,科学正是建立在因果律的基础之上的。关于因果的讨论,已经持续了上千年,至今仍没有统一定论,在正式讨论“因果推断”之前,我们有必要搞清楚,当我们提到“因果”时,究竟是在谈论着什么。
在神话思维时代,人类对诸如雷电、地震等自然现象都会归结为某个神灵的意志。这种拟人化的目的归因,是人类试图捕捉现象背后本质因果思维的最初尝试,并发展出交感巫术、祈祷等手段与神灵沟通,从而对自然过程进行干预。
人类文明的轴心时代,是古希腊人最早发扬了理性精神。哲学和科学的诞生,不仅来自经验知识,更因为是有数学和几何。古希腊最早的哲学家,包括泰勒斯、毕达哥拉斯等,都同时也是数学家和自然科学家。数学对象之间的必然关系,放到经验世界,就产生了让哲学脱胎于神话的第一次天问:“世界是如何起源的?从此人类以理性思维探讨世界秩序成为了可能。
希腊哲学家对世界起源的回答,无论是水、气、火、数、逻各斯或无定形,最后都被亚里士多德总结为四种原因:
17 世纪,德国数学家和哲学家莱布尼茨,将自己的哲学建立在两个逻辑前提之上:矛盾律(在同一时刻,某个事物不可能在同一方面既是这样又不是这样)和充分理由律(任何事物都有其存在的充足理由)。这两个前提又都建立在一种“分析”命题的概念之上,而所谓的分析命题就是谓项被包含在主项之中的命题 —— 例如,“所有的白种人都是人”。矛盾律所陈述的是“所有分析命题都是真命题”,充分理由律所陈述的则是“所有的真命题都是分析命题”。这一点不仅适用于逻辑陈述,甚至对于那些我们必须当作关于实际问题的经验性陈述也适用。如果“我”做一次旅行,“我”的概念一定自永恒以来就将这次旅行的概念包括在内了,这次旅行就是“我”的谓项。
19 世纪德国哲学家、唯意志论创始人叔本华,在博士论文《充足理由律的四重根》中给出了莱布尼茨的充足理由律的四种表现形式:
- 因果关系(Becoming):生成/变化的充足理由律,适用于现实对象;
- 逻辑推论(Knowing):认识的充足理由律,适用于逻辑对象;
- 数学证明(Being):存在的充足理由律,解释时间和空间的必然性;
- 行为动机(Willing):行动的充足理由律,解释动机和行为之间的必然性。
18 世纪,英国经验主义哲学家休谟将因果关系限定在了经验世界的具体对象中,先后在《人性论》和《人类理智研究》中给出了因果关系两个定义:
我们无从得知因果之间的关系,只能得知某些事物总是会连结在一起,而这些事物在过去的经验里又是从不曾分开过的。我们并不能看透连结这些事物背后的理性为何,我们只能观察到这些事物的本身,并且发现这些事物总是透过一种恒常的连结而被我们在想像中归类。
—— 休谟.人性论.1739
我们可以给一个因下定义说,它是先行于、接近于另一个对象的一个对象,而且在这里,凡与前一个对象类似的一切对象都和与后一个对象类似的那些对象处在类似的先行关系和接近关系中。或者,换言之,假如没有前一个对象,那么后一个对象就不可能存在。
—— 休谟.人类理解研究.1748
在《人性论》中,休谟对因果关系的客观性提出了怀疑,认为我们只能观察到事物本身及其恒常相继发生,并不能观察到事物背后的因果链接。在《人类理解研究》中,休谟提到了反事实推理的必要因,也即“若非因”。
17 世纪,牛顿创立经典力学之后,决定论占据了所有学科领域的核心:万事万物都被包含在确定性的因果链条之中。法国数学家皮埃尔-西蒙·拉普拉斯在他的概率论导论中说:
我们可以把宇宙现在的状态视为其过去的果以及未来的因,假若一位智者知道在某一时刻所有促使自然运动的力和所有物体的位置,假若他也能够对这些数据进行分析,则在宇宙里,从最大的物体到最小的粒子,它们的运动都包含在一条简单公式里。对于这位智者来说,没有任何事物会是含糊的,并且未来只会像过去般出现在他眼前。
拉普拉斯这里所说的“智者”(intelligence)便是后人所称的拉普拉斯妖。
从赖欣巴哈和萨普斯开始,哲学家们开始使用“概率提高”的概念来定义因果关系:如果 X 提高了 Y 的概率,那么我们就说 X 导致了 Y,即 $P(Y|X) > P(X) => X \rightarrow Y$。这个概念也存在于我们的直觉中,并且根深蒂固。但是这种解释是错的,因为“提高”是一个因果概念,意味着 X 对 Y 的因果效应。但是,这种概率提高完全可能是其他因素造成的,比如 Y 是 X 的原因,或者其他变量是它们二者的原因。
18 世纪,一位英国长老会牧师和业余数学家托马斯·贝叶斯(Thomas Bayes),将概率现象解释为主观信念程度的变化和更新,让概率本身也失去了客观性。但自 19 世纪中叶起,随着频率学派(经典统计学派)的兴起,贝叶斯解释逐渐被统计学主流所拒绝。现代贝叶斯统计学的复兴肇始于 Jeffreys(1939),从 1950 年代开始,经过众多统计学家的努力,贝叶斯统计学才逐渐发展壮大。
在形式上,贝叶斯定理只是条件概率定义的一个初等推论,但在认识论上,它远远超出了初等的范畴。事实上,它作为一种规范性规则,能够用于根据证据更新信念这一重要操作。从许多层面来说,贝叶斯定理都是对科学方法的提炼:1. 提出一个假设 $h$;2. 推断假设的可检验结果;3. 进行实验并收集证据 $D$;4. 更新对假设的信念 $P(h|D)$。
贝叶斯定理所描述的仍然是“证据”和“假设”之间的相关性,证据所带来的“信念增强”并不意味着“证据”是“假设”的原因。
然而“除了物理学之外,都是集邮”(卢瑟福),纷纷效法物理学的其他自然和社会科学并没有取得想象中确定性的成功。到了19 世纪,统计学创始人高尔顿在研究“遗传均值回归”现象的过程中,以寻找因果关系为起点,最终却发现了相关性 —— 一种无视因果的关系。高尔顿的学生,作为统计学之父的卡尔·皮尔逊,则干脆用相关关系(Correlation)取代了因果关系,认为因果关系只是相关关系的一个特例。
我认为……高尔顿的本意是,存在一个比因果关系更广泛的范畴,即相关性,而因果关系只是被囊括于其中的一个有限的范畴。这种关于相关性的新概念在很大程度上将心理学、人类学、医学和社会学引向了数学处理的领域。
—— 皮尔逊.1934
一个特定的事件序列在过去已经发生并且重复发生,这只是一个经验问题,对此我们可以借助因果关系的概念给出其表达式……在任何情况下,科学都不能证明该特定事件序列中存在任何内在的必然性,也不能绝对肯定地证明它必定会重复发生。
—— 皮尔逊.科学语法.1892
皮尔逊将因果关系从统计学中剔除,取而代之的是相关关系。统计学告诉我们“相关关系不等于因果关系”,但并没有告诉我们因果关系是什么。在统计学教科书的索引里查找“因果”这个词是徒劳的。统计学不允许学生们说 X 是 Y 的原因,只允许他们说 X 与 Y “相关”或“存在关联”。统计学唯一关注的是如何总结数据,而不关注如何解释数据。
继高尔顿和皮尔逊之后,罗纳德·艾尔默·费舍尔成为当时统计学界无可争议的领袖,他简洁地描述了这种差异:
一旦你从统计学中删除因果关系,那么剩下的就只有数据约简了。
进入 20 世纪,就连在物理学中人们也发现了更多不确定性现象。量子力学对微观世界的描述,让很多人确信,世界在根基上就是不确定性的。混沌理论革命则让人们意识到,对复杂系统即使存在确定的关系,也会因为初始敏感导致计算不可约性。
在这些科学发展的背景下,不确定性完全占据了上风,大多数人认为可能只存在相关性,在科学实践和决策上也广泛采取统计学方法。科学反映客观实在的观念已一去不复返,物理定律也降格为基于某种观测数据拟合的理论模型。
2020 年 6 月 21 日,在第二届北京智源大会开幕式及全体会议上,图灵奖得主、贝叶斯网络奠基人Judea Pearl 做了名为《The New Science of Cause and Effect with reflections on data science and artificial intelligence》的主题演讲。
在演讲中,Judea Pearl 站在整个数据科学的视角,简单回顾了过去的“大数据革命”,指出数据科学正在从当前以数据为中心的范式向以科学为中心的范式偏移,现在正在发生一场席卷各个研究领域的“因果革命”。
To Build Truly Intelligent Machines, Teach Them Cause and Effect 。
——Judea Pearl
因果革命和以数据为中心的第一次数据科学革命,也就是大数据革命(涉及机器学习,深度学习机器应用,例如 Alpha-Go、语音识别、机器翻译、自动驾驶等等)的不同之处在于,它以科学为中心,涉及从数据到政策、可解释性、机制的泛化,再到一些社会科学中的基础概念信用、责备和公平性, 甚至哲学中的创造性和自由意志 。可以说, 因果革命彻底改变了科学家处理因果问题的方式。
Judea Pearl 认为,统计学的其他分支,以及那些依赖统计学工具的学科仍然停留在禁令时代,错误地相信所有科学问题的答案都藏于数据之中,有待巧妙的数据挖掘手段将其揭示出来。因果分析绝不只是针对数据的分析,在因果分析中,我们必须将我们对数据生成过程的理解体现出来,并据此得出初始数据不包含的内容。与相关性分析和大多数主流统计学不同,因果分析要求研究者做出主观判断。研究者必须绘制出一个因果图,其反映的是他对于某个研究课题所涉及的因果过程拓扑结构的定性判断,或者更理想的是,他所属的专业领域的研究者对于该研究课题的共识。为了确保客观性,他反而必须放弃传统的客观性教条。在因果关系方面,睿智的主观性比任何客观性都更能阐明我们所处的这个真实世界。
数据科学所研究的因果关系是经验世界中事件之间的因果关系,正如休谟所言,在经验世界中,我们实际所能观测到的只是事件本身,而无法观测到隐藏在事件背后的“因果机理”,事件间的因果关系本质上是对事件序列间特定关系的概括性称谓。目前,一个被广泛接受的因果关系的定义是由 Lazarsfeld(1959)给出的:
如果变量 A 和变量 B 满足以下三个条件,则称 A 和 B 之间存在因果关系“A 导致 B”,其中 A 被称为 B 原因,B 被称为 A 的结果:
- A 在时间上必须先于 B;
- A 和 B 应当在经验上相互关联;
- A 和 B 之间观测到的经验相关不能被第三个导致 A 和 B 两者的变量所解释;
相关性只是因果性的一个必要非充分条件,即“相关性不一定意味着因果性”,A 和 B 相关可能是以下情形的结果:
至此,我们已经查勘了因果观念的全景,现在可以对数据科学所涉及到的因果关系概括如下:
在经验世界中,我们所能观察到的只是事件(数据)本身,而如果仅凭数据间的关联,我们只能得到事件间的相关性,事件间的因果关系是对事件序列特定关系的概括:如果 A 和 B 同时满足以下条件 ① A 在时间上先于 B;② A 和 B 在经验上相关;③ A 和 B 间的相关性不能被其他变量所解释;则称 A 是 B 的原因,或称 A 导致了 B。
因果推断是研究变量间因果关系的学科,作为一门学科,因果推断目前仍然处于大众视野之外。朱迪亚·珀尔(Judea Pearl) 认为,一旦我们真正理解了因果思维背后的逻辑,就可以在现代计算机上模拟它,进而创造出一个“人工科学家”。这个智能机器人将会为我们发现未知的现象,解开悬而未决的科学之谜,设计新的实验,并不断从环境中提取更多的因果知识。
关于因果推断的讨论,可以有两个方向:
按照所能回答问题的类型,Judea Pearl 将因果信息划分成了三个层级,其中,高层级信息可以回答低层级问题,但是低层级信息无法回答高层级问题:
层级 | 任务 | 活动 | 符号 | 问题 | 例子 | 评价 |
---|---|---|---|---|---|---|
关联 | 基于被动观察做出预测 | 观察 | $P(Y\mid X)$ | 如果观察到X,如何预测Y? | 购买啤酒的用户多大可能会购买尿布? | 好的预测无需好的解释(因果) 当前机器学习/深度学习/统计学几乎完全是在关联层级下,由一系列观察数据拟合出一个函数 |
干预 | 基于主动干预做出评估 | 行动 | $P(Y\mid do(X))$ | 如果改变X,Y会怎样? | 如果价格提高两倍,销量会怎么变化? | 预测干预结果的方法是在严格控制的条件下进行实验 |
反事实 | 通过因果模型做出预测 | 想象 | $P(y_x \mid X’,Y’)$ | 假如观察到的不是X’,Y会怎样? | 假如过去没有抽烟,现在身体会更好吗? | 预测在尚未经历甚至未曾设想过的情况下会发生什么——这是所有科学的圣杯 |
Judea Pearl 在《The Book of Why》一书中对以上三种因果层级进行了详细描述,并将其称为“因果关系之梯”:
Judea Pearl 认为,人类的大脑拥有某种简洁的信息表示方式,同时还拥有某种十分有效的程序用以正确解释每个问题,并从存储的信息表示中提取正确答案,这就是因果图。Judea Pearl 通过一个被他称作“迷你图灵测试”的例子,借助因果图语言介绍了以上三种因果层级之间的差异。
如下图所示,假设一个犯人将要被执行枪决,这件事的发生必然会以一连串的事件发生为前提:首先,法院方面要下令处决犯人;命令下达到行刑队长后,他将指示行刑队的士兵(A 和 B)执行枪决;我们假设他们是服从命令的专业抢手,只听命令射击,并且只要其中任何一个抢手开了枪,囚犯都必死无疑。借助这个因果图,我们就可以回答来自因果关系之梯不同层级的因果问题了。
(1)首先,我们可以回答关联问题(一个事实告诉我们有关另一事实的什么信息)。一个可能的问题是,如果犯人死了,那么这是否意味着法院已下令处决犯人?我们(或一台计算机)可以通过核查因果图,追踪每个箭头背后的规则,并根据标准逻辑得出结论:如果没有行刑队队长的命令,两名士兵就不会射击。同样,如果行刑队队长没有接到法院的命令,他就不会发出执行枪决的命令。因此,这个问题的答案是肯定的。另一个可能的问题是,假设我们发现士兵 A 射击了,它告诉了我们关于 B 的什么信息?通过追踪箭头,计算机将断定B一定也射击了。(原因在于,如果行刑队队长没有发出射击命令,士兵A就不会射击,因此接收到同样命令的士兵B也一定射击了。)即使士兵 A 的行为不是士兵 B 做出某一行为的原因(因为从 A 到 B 没有箭头),该判断依然为真。
(2)沿着因果关系之梯向上攀登,我们可以提出有关干预的问题。如果士兵 A 决定按自己的意愿射击,而不等待队长的命令,情况会怎样?犯人会不会死?如果我们希望计算机能理解因果关系,我们就必须教会它如何打破规则,让它懂得“观察到某事件”和“使某事件发生”之间的区别。我们需要告诉计算机:“无论何时,如果你想使某事发生,那就删除指向该事的所有箭头,之后继续根据逻辑规则进行分析,就好像那些箭头从未出现过一样。”如此一来,对于这个问题,我们就需要删除所有指向被干预变量(A)的箭头,并且还要将该变量手动设置为规定值(真)。这种特殊的“外科手术”的基本原理很简单:使某事发生就意味着将它从所有其他影响因子中解放出来,并使它受限于唯一的影响因子——能强制其发生的那个因子。下图表示出了根据这个例子生成的因果图,显然,这种干预会不可避免地导致犯人的死亡,这就是箭头 A 到 D 背后的因果作用。同时,我们还能判断出:B(极有可能)没有开枪,A 的决定不会影响模型中任何不受 A 的行为的影响的其他变量。需要注意的是,仅凭收集大数据无助于我们登上因果关系之梯去回答上面的问题。假设你是一个记者,每天的工作就是记录行刑场中的处决情况,那么你的数据会由两种事件组成:要么所有 5 个变量都为真,要么所有都为假。在未掌握“谁听从于谁”的相关知识的情况下,这种数据根本无法让你(或任何机器学习算法)预测“说服枪手 A 不射击”的结果。
(3)最后,为了说明因果关系之梯的第三层级,我们提出一个反事实问题。假设犯人现在已倒地身亡,从这一点我们(借助第一层级的知识)可以得出结论:A射击了,B射击了,行刑队队长发出了指令,法院下了判决。但是,假如 A 决定不开枪,犯人是否还活着?这个问题需要我们将现实世界和一个与现实世界相矛盾的虚构世界进行比较。在虚构世界中,A 没有射击,指向 A 的箭头被去除,这进而又解除了 A 与 C 的听命关系。现在,我们将A的值设置为假,并让A行动之前的所有其他变量的水平与现实世界保持一致。如此一来,这一虚构世界就如下图所示。为通过迷你图灵测试,计算机一定会得出这样的结论:在虚构世界里犯人也会死,因为B会开枪击毙他。所以,A勇敢改变主意的做法也救不了犯人的命。
看起来,我们刚刚像是花了很大一番力气回答了一些答案显而易见的小问题。的确,因果推理对你来说很容易,其原因在于你是人类,在你还是三岁儿童时,你所拥有的功能神奇的大脑就比任何动物或计算机都更能理解因果关系。“迷你图灵问题”的重点就是要让计算机也能够进行因果推理,而我们能从人类进行因果推断的做法中得到启示。如上述三个例子所示,我们必须教会计算机如何有选择地打破逻辑规则。计算机不擅长打破规则,而这是儿童的强项。
用于因果推断的数据来源一般有三种:
本文转载自 One Data Science Job Doesn’t Fit All
在一家高速增长的公司里,当一名领导者的乐趣之一就是你不仅有机会去改变一些事情 —— 你还必须主动驱动变革以跟上步伐。而在数据科学(DS)这个新的、快速发展的领域工作,我们将同时置身于公司和行业的快速变化之中。
在 Airbnb,我们把数据看作是用户的声音,我们的目标是让数据科学家最大程度地发挥他们的影响,并对自己的工作充满期待。我们正在朝着这个方向努力,也一直在寻找改进的方法。作为这一演变的一部分,我们最近建立了一个角色定义框架,我希望我们在此过程中学到的知识可以对其他公司在定义数据科学角色方面具有参考意义。
我要分享的主要结论是:为了满足业务的需求,公司会考虑数据科学工作的三个通道 —— 分析、推断、算法。下面我将描述我们是如何发展到这三条工作通道上的,以及它是如何帮助我们的。
我们从“分析团队”开始,最初雇用的是“分析专家”。 2012年,我被聘为“数据科学家”。后来,我们聘请了“数据架构师”来处理数据质量,然后聘请了“数据分析专家”来帮助解决数据访问和工具方面的空白。然后,我们看到了机器学习方面的其他需求,因此我们聘请了“机器学习数据科学家”。这些头衔的演变既是对团队需求的反应,也是对竞争格局的反应。我们在2015年成为了“数据科学”部门,尽管我们仍然使用“ A-team”,因为它很有趣并且拥有我们重视的历史。
当我在2017年中担任数据科学职能部门的负责人时,我们大约有80位数据科学家分布在各个团队中。一些正在构建报表,一些正在构建NLP(自然语言处理)模型,另一些正在构建用于决策和设计实验的模型。
这种变化并不完全出乎意料,数据科学相对较新,而且发展迅速。我们在数据中看到了这一点。首先,从内部来看,我们发现 Airbnb 数据科学角色在2015-2018年间增长了4倍:
而且,根据谷歌趋势数据,对数据科学的查询也在增长:
数据科学不仅是一个新的领域,人们所说的“数据科学”的含义也千差万别,有时候,这纯粹是机器学习。有时是科技公司的商业智能。它是新的,而且在进化。
我们发现人们对数据科学的预期并不明确。在一个给定的公司中,这种多样性的缺点是,它可能导致组织混乱和人员流失,因为合作伙伴团队不知道从数据科学家那里得到什么,而数据科学家自己可能也不清楚他们的角色。那些来自 DS 只做建模的地方的人可能不认为数据科学技能能很好地用于更简单的分析。其他来自 DS 只做分析的地方的人可能会觉得最好让工程师做建模。
我们还有一个额外的挑战:从事分析工作的团队成员觉得他们的工作没有机器学习工作那么重要,但他们的工作对业务至关重要。业务合作伙伴渴望更具可操作性的见解,以推动决策,并扩展工具以了解数据本身。我们通过我们非常受欢迎的数据大学对数据教育进行了投资,但我们仍然需要专家。我们确定的一个原因是,虽然团队成员是“数据科学”职能的一部分,但我们使用的是“数据分析专家”的头衔,而且我们谈论“数据科学工作”的方式中有一些暗示,给人的印象是,分析工作并不同等重要。
我与同行公司的领导进行了交谈,以了解其他团队是如何处理这一问题的——有一次,我甚至创建了一个与不同组织结构共享的电子表格。我听说过新的分析团队从零开始创建,团队从机器学习中分离出来,工具团队被整合到数据科学中,等等。
很明显,没有一刀切的方法,但在定义我们是谁以及如何增加价值方面,具有战略性和有意识的态度将是至关重要的。我们知道我们的目标是“捍卫使命”,即完成公司最需要的工作。因此,我们需要符合当前业务需求的角色,同时也允许个性化和明确的期望。
我们决定沿着三个方向来重构数据科学,这三个方向描述了我们正在追寻的东西,也是我们想要吸引人才的领域:
The Algorithms track would be the home for those with expertise in machine learning, passionate about creating business value by infusing data in our product and processes. And the Inference track would be perfect for our statisticians, economists, and social scientists using statistics to improve our decision making and measure the impact of our work.
团队中的每一位数据科学家都应具备这些领域的专业知识,并根据业务需求和自身兴趣获得这些领域的技能。在每一个通道中都可以有进一步的专业化,但是每个人都有“数据科学家”的头衔,然后下面的描述提供了更清晰的描述。
如果我们看另一门学科,比如工程学,这里有“前端”和“后端”工程学的简写,它可以帮助你了解某人的技能或关注的领域。我意识到这是一个不完美的区别,但它比简单的“工程”更能让人感觉到某人的专业知识。数据科学离这一点还很远;这是我们正在朝着的方向发展。
我们还修改了我们的绩效评估标准,以反映我们的新结构。我们有多层次的数据科学家和管理者,我们通过观察对业务的影响来定义成功。对于那些在技术通道上的人,我们修改了我们的评估框架,使之与这些主要领域保持一致。
技术方面:
业务方面(适用于所有通道):
我们可以在这里写很多东西,但主要的收获是,我们也明确改变了我们评估绩效的方式,以反映工作的三个方面,并明确了期望。
Airbnb 足够大,拥有所有这些区别和细微差别是有意义的。当与那些想知道是否应该用专家组建团队的小公司交谈时,我建议他们从通用性开始。在早期,我们能够处理任何最紧迫的项目,而不是坐在一个僵硬的专业里,这真的很有帮助。随着时间的推移,专业化是有意义的,但最好是从通用开始,除非你能更早地看到它的商业案例。我们直到 2015 年左右才开始专攻,那时我们的团队只有 30 人。
我们还希望随着业务需求的变化,继续改变职能部门的角色。
即使是在我们的专业领域,每个领域的数据科学家也会从事其他类型的工作,我们鼓励团队成员也成为多面手(有时这是一个混乱的问题)。总体而言,进行此更改后,我们所听到的混乱少了很多。我也开始听到合作伙伴说诸如“我们需要具有推理和算法专业知识的人”之类的东西。因此,该语言对于传达业务需求非常有用。
这有助于我们找出差距。我最近联系了一位产品经理,她表示担心团队没有想出创新的方法来在她富有挑战性的产品领域进行实验。我立刻诊断出了这个问题:在那个特定的数据科学团队中,没有一个具备推理专业知识的人。这是我们下一次招聘时可以解决的问题,或者鼓励团队成员向其他推理专家学习。
我们很高兴听到从事分析工作的团队成员不再感到疏远或自卑。分析专家了解,如果他们尝试将机器学习应用于他们正在处理的业务问题,那么它们的影响将较小。
我希望与大家分享我们的故事,希望其他公司也能采用这个框架!当应聘者带着一个模糊的“数据科学”的名字,这可能意味着很多不同的东西,招聘就变得复杂起来。如果所有公司都使用类似的框架,这将使数据科学作为一个整体更容易传达我们的价值观。
如果您喜欢这个概念,请告诉您的数据科学领导者,或者如果您是数据科学的领导者,请自己进行更改。或者,如果你有一个更好的模型,我也很乐意听到这个-请伸出援手(data-science-org-ideas@airbnb.com). 考虑到数据科学领域是多么的新和快速发展,最好的命名约定将随着时间的推移而演变。在数据科学领域,我们越能联合起来制定规范,我们的行业就越快成熟,我们作为个人就越有能力驾驭它。
原文最初由 IBM developerWorks 中国网站发表,本文在此基础上进行了总结梳理,仅作为个人学习使用。
Spark 作为一个基于内存的分布式计算引擎,其内存管理模块至关重要。理解 Spark 内存管理的基本原理,有助于更好地开发 Spark 应用程序和性能调优。本文基于 Spark 2.1 版本,旨在梳理 Spark 内存管理的基本脉络。
在执行 Spark 应用程序时,Spark 集群会启动 Driver 和 Executor 两种 JVM 进程:
由于 Driver 的内存管理相对简单,本文主要对 Executor 的内存管理进行分析,下文中 Spark 内存均指 Executor 内存。
作为一个 JVM 进程,Executor 的内存管理建立在 JVM 的内存管理之上,Spark 对 JVM 的堆内(On-heap)空间进行了更为详细的分配,以充分利用内存。同时,Spark 引入了堆外(Off-heap)内存,使之可以直接在工作节点的系统内存中开辟空间,进一步优化了内存的使用。
Executor 内运行的并发任务共享 JVM 堆内内存,堆内内存的大小由 Spark 应用程序启动时的 –executor-memory
或 spark.executor.memory
参数配置(默认 1g),Spark 对堆内内存进行了详细的规划:
spark.memory.fraction
来设置(默认 0.6) spark.memory.storagefraction
来设置(默认 0.5);总结堆内内存的规划大小计算公式如下:
规划项 | 计算公式 | 默认值 |
---|---|---|
堆内内存(On-Heap) | spark.executor.memory | 1g |
统一内存(Unified) | spark.executor.memory * spark.memory.fraction | 1g * 0.6 = 600M |
存储内存(Storage) | spark.executor.memory * spark.memory.fraction * spark.memory.storagefraction | 1g 0.6 0.5 = 300M |
执行内存(Execution) | spark.executor.memory * spark.memory.fraction * (1 - spark.memory.storagefraction) | 1g 0.6 (1-0.5) = 300M |
剩余内存(Other) | spark.executor.memory * (1 - spark.memory.fraction) | 1g * (1-0.6) = 400M |
预留内存(Reserved) | 300M | 300M |
Spark 对堆内内存的管理只是一种”规划式“的管理,因为对象实例占用内存的申请和释放都由 JVM 完成,Spark 只能在申请和释放前记录这些内存,其具体流程为:
JVM 对象可以以序列化(将对象转化为二进制字节流)的方式存储,本质上可以理解为将非连续的链式存储转化为连续存储,在访问时则需要进行反序列化(将字节流转化为对象),这种方式节省了空间,但是增加了存储和读取的计算开销。对于序列化对象,由于是字节流的形式,其占用的内存大小可以直接计算,而对于非序列化对象,其占用的内存则通过周期采样近似估算,这种方式降低了时间开销但是可能误差较大,导致某一时刻的实际内存有可能远远超出预期。此外,在被 Spark 标记为释放的对象实例,很有可能在实际上并没有被 JVM 回收,导致实际可用的内存小于 Spark 记录的可用内存。所以 Spark 并不能准确记录实际可用的堆内内存,从而也就无法完全避免内存溢出(OOM, Out of Memory)的异常。
Spark 1.6 之后引入了统一内存管理机制,存储内存和执行内存共享同一块空间,可以动态占用对方的空闲区域,如图所示:
统一内存的动态占用机制:
凭借统一内存管理机制,Spark 在一定程度上提高了堆内和堆外内存资源的利用率,降低了开发者维护 Spark 内存的难度,但并不意味着开发者可以高枕无忧。譬如,所以如果存储内存的空间太大或者说缓存的数据过多,反而会导致频繁的全量垃圾回收,降低任务执行时的性能,因为缓存的 RDD 数据通常都是长期驻留内存的。所以要想充分发挥 Spark 的性能,需要开发者进一步了解存储内存和执行内存各自的管理方式和实现原理。
弹性分布式数据集(RDD)作为 Spark 最根本的数据抽象,是只读的分区记录(Partition)的集合,只能基于在稳定物理存储中的数据集上创建,或者在其他已有的 RDD 上执行转换(Transformation)操作产生一个新的 RDD。转换后的 RDD 与原始的 RDD 之间产生的依赖关系,构成了血统(Lineage)。凭借血统,Spark 保证了每一个 RDD 都可以被重新恢复。但 RDD 的所有转换都是惰性的,即只有当一个返回结果给 Driver 的行动(Action)发生时,Spark 才会创建任务读取 RDD,然后真正触发转换的执行。
Task 在启动之初读取一个分区时,会先判断这个分区是否已经被持久化,如果没有则需要检查 Checkpoint 或按照血统重新计算。所以如果一个 RDD 上要执行多次 Action,可以在第一次 Action 中使用 persist 或 cache 方法,在内存或磁盘中持久化或缓存这个 RDD,从而在后面的行动时提升计算速度。事实上,cache 方法是使用默认的 MEMORY_ONLY 的存储级别将 RDD 持久化到内存,故缓存是一种特殊的持久化。 堆内和堆外存储内存的设计,便可以对缓存 RDD 时使用的内存做统一的规划和管理。
RDD 的持久化由 Spark 的 Storage 模块负责,实现了 RDD 与物理存储的解耦合。Storage 模块负责管理 Spark 在计算过程中产生的数据,将那些在内存或磁盘、在本地或远程存取数据的功能封装了起来。在具体实现时 Driver 端和 Executor 端的 Storage 模块构成了主从式的架构,即 Driver 端的 BlockManager 为 Master,Executor 端的 BlockManager 为 Slave。Storage 模块在逻辑上以 Block 为基本存储单位,RDD 的每个 Partition 经过处理后唯一对应一个 Block(BlockId 的格式为 rdd_RDD-ID_PARTITION-ID )。Master 负责整个 Spark 应用程序的 Block 的元数据信息的管理和维护,而 Slave 需要将 Block 的更新等状态上报到 Master,同时接收 Master 的命令,例如新增或删除一个 RDD。
在对 RDD 持久化时,Spark 规定了 MEMORY_ONLY、MEMORY_AND_DISK 等 7 种不同的 存储级别 ,而存储级别是以下 5 个变量的组合:
1 | class StorageLevel private( |
通过对数据结构的分析,可以看出存储级别从三个维度定义了 RDD 的 Partition(同时也就是 Block)的存储方式:
RDD 缓存的过程是将对象从 other 内存区迁移至 Storage 区或 Disk 的过程:
因为不能保证存储空间可以一次容纳 Iterator 中的所有数据,当前的计算任务在 Unroll 时要向 MemoryManager 申请足够的 Unroll 空间来临时占位,空间不足则 Unroll 失败,空间足够时可以继续进行。对于序列化的 Partition,其所需的 Unroll 空间可以直接累加计算,一次申请。而非序列化的 Partition 则要在遍历 Record 的过程中依次申请,即每读取一条 Record,采样估算其所需的 Unroll 空间并进行申请,空间不足时可以中断,释放已占用的 Unroll 空间。如果最终 Unroll 成功,当前 Partition 所占用的 Unroll 空间被转换为正常的缓存 RDD 的存储空间,如下图所示:
由于同一个 Executor 的所有的计算任务共享有限的存储内存空间,当有新的 Block 需要缓存但是剩余内存空间不足且无法动态占用时,就要对 LinkedHashMap 中的旧 Block 进行淘汰(Eviction),而被淘汰的 Block 如果其存储级别中同时包含存储到磁盘的要求,则要对其进行落盘(Drop),否则直接删除该 Block。
存储内存的淘汰规则为:
存储内存的落盘规则为:如果其存储级别符合 _useDisk
为 true 的条件,再根据其_deserialized
判断是否是非序列化的形式,若是则对其进行序列化,最后将数据存储到磁盘,在 Storage 模块中更新其信息。
Executor 内运行的任务同样共享执行内存,Spark 用一个 HashMap 结构保存了任务到内存耗费的映射。每个任务可占用的执行内存大小的范围为 1/2N ~ 1/N,其中 N 为当前 Executor 内正在运行的任务的个数。每个任务在启动之时,要向 MemoryManager 请求申请最少为 1/2N 的执行内存,如果不能被满足要求则该任务被阻塞,直到有其他任务释放了足够的执行内存,该任务才可以被唤醒。
执行内存主要用来存储任务在执行 Shuffle 时占用的内存,Shuffle 是按照一定规则对 RDD 数据重新分区的过程,我们来看 Shuffle 的 Write 和 Read 两阶段对执行内存的使用:
在 ExternalSorter 和 Aggregator 中,Spark 会使用一种叫 AppendOnlyMap 的哈希表在堆内执行内存中存储数据,但在 Shuffle 过程中所有数据并不能都保存到该哈希表中,当这个哈希表占用的内存会进行周期性地采样估算,当其大到一定程度,无法再从 MemoryManager 申请到新的执行内存时,Spark 就会将其全部内容存储到磁盘文件中,这个过程被称为溢存(Spill),溢存到磁盘的文件最后会被归并(Merge)。
Shuffle Write 阶段中用到的 Tungsten 是 Databricks 公司提出的对 Spark 优化内存和 CPU 使用的计划,解决了一些 JVM 在性能上的限制和弊端。Spark 会根据 Shuffle 的情况来自动选择是否采用 Tungsten 排序。Tungsten 采用的页式内存管理机制建立在 MemoryManager 之上,即 Tungsten 对执行内存的使用进行了一步的抽象,这样在 Shuffle 过程中无需关心数据具体存储在堆内还是堆外。每个内存页用一个 MemoryBlock 来定义,并用 Object obj 和 long offset 这两个变量统一标识一个内存页在系统内存中的地址。堆内的 MemoryBlock 是以 long 型数组的形式分配的内存,其 obj 的值为是这个数组的对象引用,offset 是 long 型数组的在 JVM 中的初始偏移地址,两者配合使用可以定位这个数组在堆内的绝对地址;堆外的 MemoryBlock 是直接申请到的内存块,其 obj 为 null,offset 是这个内存块在系统内存中的 64 位绝对地址。Spark 用 MemoryBlock 巧妙地将堆内和堆外内存页统一抽象封装,并用页表(pageTable)管理每个 Task 申请到的内存页。Tungsten 页式管理下的所有内存用 64 位的逻辑地址表示,由页号和页内偏移量组成:
有了统一的寻址方式,Spark 可以用 64 位逻辑地址的指针定位到堆内或堆外的内存,整个 Shuffle Write 排序的过程只需要对指针进行排序,并且无需反序列化,整个过程非常高效,对于内存访问效率和 CPU 使用效率带来了明显的提升。
Spark 的存储内存和执行内存有着截然不同的管理方式:对于存储内存来说,Spark 用一个 LinkedHashMap 来集中管理所有的 Block,Block 由需要缓存的 RDD 的 Partition 转化而成;而对于执行内存,Spark 用 AppendOnlyMap 来存储 Shuffle 过程中的数据,在 Tungsten 排序中甚至抽象成为页式内存管理,开辟了全新的 JVM 内存管理机制。
Spark 3.0 堆外内存相关参数(详情参考Spark Configuration):
Spark 参数 | 默认值 | 说明 |
---|---|---|
spark.memory.offHeap.enabled | FALSE | If true, Spark will attempt to use off-heap memory for certain operations. If off-heap memory use is enabled, then spark.memory.offHeap.size must be positive. |
spark.memory.offHeap.size | 0 | The absolute amount of memory which can be used for off-heap allocation, in bytes unless otherwise specified. This setting has no impact on heap memory usage, so if your executors’ total memory consumption must fit within some hard limit then be sure to shrink your JVM heap size accordingly. This must be set to a positive value when spark.memory.offHeap.enabled=true. |
spark.executor.memoryOverhead | executorMemory * 0.10, with minimum of 384 | Amount of additional memory to be allocated per executor process in cluster mode, in MiB unless otherwise specified. This is memory that accounts for things like VM overheads, interned strings, other native overheads, etc. This tends to grow with the executor size (typically 6-10%). This option is currently supported on YARN and Kubernetes.Note: Additional memory includes PySpark executor memory (when spark.executor.pyspark.memory is not configured) and memory used by other non-executor processes running in the same container. The maximum memory size of container to running executor is determined by the sum of spark.executor.memoryOverhead, spark.executor.memory, spark.memory.offHeap.size and spark.executor.pyspark.memory. |
运行 executor 的最大内存取决于以下四者之和:
从 Spark 3.0 源码中也可看到:
1 | private[yarn] val resource: Resource = { |
为了进一步优化内存的使用以及提高 Shuffle 时排序的效率,Spark 引入了堆外(Off-heap)内存,使之可以直接在工作节点的系统内存中开辟空间,存储经过序列化的二进制数据。利用 JDK Unsafe API(从 Spark 2.0 开始,在管理堆外的存储内存时不再基于 Tachyon,而是与堆外的执行内存一样,基于 JDK Unsafe API 实现),Spark 可以直接操作系统堆外内存,减少了不必要的内存开销,以及频繁的 GC 扫描和回收,提升了处理性能。堆外内存可以被精确地申请和释放,而且序列化的数据占用的空间可以被精确计算,所以相比堆内内存来说降低了管理的难度,也降低了误差。
在默认情况下堆外内存并不启用,可通过配置 spark.memory.offHeap.enabled
参数启用,并由 spark.memory.offHeap.size
参数设定堆外空间的大小。除了没有 other 空间,堆外内存与堆内内存的划分方式相同,所有运行中的并发任务共享存储内存和执行内存。
待补充
假设 Spark 应用程序运行参数设置如下:
Spark 应用程序运行过程中,我们可以在 Web UI -> Executors 中查看 Excutor 内存实际使用大小/内存规划大小:
从该实例可以看出 Executor 的统一内存为 5.9G,与理论计算出来的值相近(10G * 0.6 = 6G),存储内存为 5.8 G,动态占用机制使得存储内存占用了绝大部分统一内存,导致只有很少的内存用于 Shuffle,这也是影响本任务执行效率的关键问题。此外,Driver 的内存基本没有被存储占用,有充足的内存可以用于执行 Spark 程序,可以适当减少 Driver 端内存分配。
进一步考察存储内存占用过高的原因,可以看到该程序缓存了非常大的中间结果,可以选择把缓存数据全部存储到磁盘,在这个场景下不会对缓存过程有太大影响,却可以保证充足的执行内存:
分区(Partition)是控制 RDD 在各节点上分布情况的高级特性,RDD 的存储和计算都是基于分区来进行的。为分布式数据集选择正确的分区方式和为本地数据选择合适的数据结构很相似 —— 数据分布都会极其明显地影响程序的性能。有时使用可控的分区方式把常被一起访问的数据放到同一个节点上,可以大大减少应用的通信开销,带来明显的性能提升。
RDD、分区、TASK、节点、核之间的关系:
RDD 创建方式不同,会产生不同的默认分区行为。比如,从 HDFS 中读取文件来创建 RDD 和通过一个 RDD 转换操作生成另一个新的 RDD 的分区行为是不同的。
调用 API | 默认分区数 | 分区器类 |
---|---|---|
sc.parallelize(...) | sc.defaultParallelism | 无 |
调用 API | 默认分区数 | 分区器类 |
---|---|---|
sc.textFile(...) | sc.defaultParallelism 和文件 block 数中较大值 | 无 |
调用 API | 默认分区数 | 分区器类 |
---|---|---|
filter,map,flatMap,distinct | 同父 RDD | filter同父 RDD,其他无分区器 |
mapValues, flatMapValues | 同父 RDD | 同父 RDD |
union | union 的两个 RDD 分区数之和 | 无 |
subtract | 同第一个RDD | 无 |
cartesian | 两个 RDD 分区数乘积 | 无 |
调用 API | 默认分区数 | 分区器类 |
---|---|---|
reduceByKey,foldByKey,combineByKey | 同父 RDD | HashPartitioner |
sortByKey | 同父 RDD | RangePartitioner |
cogroup,groupByKey,join,leftOuterJoin,rightOuterJoin | 取决于 RDD 的输入属性 | HashPartitioner |
Partitioner(分区器)定义了 RDD 的分区分布,决定了一个 RDD 可以被分成多少个分区,以及每个分区的数据量有多大,进而决定了每个 Task 将处理哪些数据。一般来说,分区器是针对 key-value 值 RDD 的,并通过对 key 的运算来划分分区,非 key-value 形式的 RDD 无法根据数据特征来进行分区,也就没有设置分区器,此时 Spark 会把数据均匀的分配到执行节点上。
目前的版本提供了三种分区器:
Object.hashCode
来实现的分区器,根据 Object.hashCode
来对 key 进行计算得到一个整数,再通过公式Object.hashCode % numPartitions
计算某个 key 该分到哪个分区,当 RDD 没有 Partitioner 时,会把 HashPartitioner 作为默认的 Partitioner;在 Scala 和 Java 中,你可以使用 RDD 的 partitioner 属性(Java 中使用 partitioner() 方法)来获取 RDD 的分区方式。它会返回一个 scala.Option 对象,这是 Scala 中用来存放可能存在的对象的容器类。你可以对这个 Option 对象调用 isDefined() 来检查其中是否有值,调用 get() 来获取其中的值。如果存在值的话,这个值会是一个 spark.Partitioner 对象。这本质上是一个告诉我们 RDD 中各个键分别属于哪个分区的函数。
1 | pairs.groupByKey().partitioner.get |
有三种方式可以用于设置 RDD 的分区数,但要注意,若改变分区数量或分区器通常会导致 Shuffle 操作,务必在调整分区后进行缓存:
partitionBy
方法:下面代码,我们自定义了一个分区器,并根据自定义的分区器对 RDD 进行重新分区,需要特别注意的是,在每次调用 partitionBy
之后,务必对结果进行缓存,否则后续每次惰性执行时都会重新执行分区动作,严重影响程序性能;1 | import org.apache.spark.Partitioner |
repartition
或 coalesec
方法:coalesce(numPartitions: Int, shuffle: Boolean = false)
:对 RDD 进行重分区,使用 HashPartitioner,第一个参数为重分区的数目,第二个为是否进行 shuffle,默认为false(此时是合并分区,父 RDD 和子 RDD 是窄依赖,不会产生 Shuffle);如果重分区的数目大于原来的分区数,那么必须指定 shuffle 参数为 true;repartition(numPartitions: Int, partitionExprs: Column*)
:repartition 是 coalesce shuffle 参数为 true 的简易实现,返回一个按 partitionExprs 将原 RDD 划分为 numPartitions 个分区的新 RDD,过程中会发生 Shuffle,父 RDD 和子 RDD 之间构成宽依赖;分区并不是对所有应用都有好处的,如果给定 RDD 只需要被扫描一次,我们完全没有必要对其预先进行分区处理,只有当数据集多次在诸如 JOIN 这种基于键的操作中使用时,分区才会有帮助。
你永远不会调用一个名为 shuffle 的方法,但是有很多方法会导致 shuffle 的发生,比如在 RDD 上调用 groupByKey()
方法时,会返回一个 ShuffledRDD
:
1 | val pairs = sc.parallelize(List((1, "one"), (2, "two"), (3, "three"))) |
要执行分布式 groupByKey 操作,我们通常必须在节点之间移动数据,以便数据可以按照它的 KEY 收集到单个机器上:
数据通过网络在节点之间移动的过程,称为 Shuffle(洗牌或混洗)。
以 Shuffle 为边界,Spark 将一个 Job 划分为不同的 Stage,这些 Stage 构成了一个大粒度的 DAG。Spark 的 Shuffle 过程分为 Write 和 Read 两个阶段,分属于两个不同的 Stage,前者是 Parent Stage 的最后一步,后者是 Child Stage 的第一步,如下图所示:
Shuffle 过程首先会将前置 Stage 的 Map Task 结果写入本地磁盘(Shuffle Write),然后后续 Stage 的 reduce Task 再从磁盘中读取这些文件(Shuffle Read)来执行计算,这有两点好处:
Spark 在 Shuffle 的实现上做了很多优化改进,Spark Shuffle 的演进过程如下(最早实现是 Hash Based Shuffle,2.0 以后就只有 Sort Based Shuffle 了):
Hash Based Shuffle 的基本流程:
Sort Based Shuffle 的基本流程:
和内存计算相比,网络通信和磁盘读写是非常耗时的过程,会严重影响程序执行效率,因此如非必要,应该尽可能避免数据 Shuffle。
为了更好地理解什么时候可能发生 Shuffle,我们需要先看看 RDD 是如何表示的:
RDD 由四部分组成:
事实上,RDD 之间的依赖关系定义了数据何时需要在网络中进行移动,根据父 RDD 和子 RDD 之间的依赖关系,可以将 Transformation 划分为两种:
总结 Spark 中常见的宽窄依赖 Transformation:
通过追踪分区间的依赖关系可以从血缘图中重新计算丢失的分区数据:
重新计算窄依赖中丢失的分区数据很快,但是要重新计算宽依赖中丢失的分区数据很慢:
有一些方法可以让你在使用宽依赖算子的同时尽量避免或减少 shuffle 的发生,其核心思想是通过重分区在集群中合理地组织数据。
在使用 groupByKey 之类的算子之前先对 RDD 进行预分区(预 Shuffle),之后所有工作都可以在工作节点上的本地分区上完成,无需将数据重新 shuffle 到另一个节点上,在这种情况下,必须移动数据的唯一时间是将最终的 reduce 值从工作节点发送会 Driver 节点:
可以通过 toDebugString 方法查看执行计划:
在执行 JOIN 前,使用相同的的分区器对连接的两个 RDD 进行预分区,可以避免 Shuffle,因为需要连接的两个 RDD 的数据已经被重新定位到同一分区中的相同节点上,不需要移动数据。
通过一个实际的例子来看,假设我们想统计有多少用户访问了他们没有订阅的主题,这可以通过用户订阅表和用户点击事件表进行 JOIN 得到:
1 | val sc = new SparkContext( ... ) |
“htt上面的 JOIN 操作会非常耗时,因为 JOIN 操作不知道任何关于数据的分区信息。JOIN 操作默认会 hash 两个数据集所有的 key,并将具有相同 hash 值的记录发送到同一个节点上进行 JOIN。解决办法很简单,就是在 JOIN 之前使用 partitionBy 对大表 RDD 进行重分区:
1 | val userData = sc.sequenceFile[UserID, Userlnfo]("hdfs:// ... ") |
我们在读入 userData 时调用了 partitionBy,Spark 会知道它被 hash 分区了,在后面调用 userData.join(events)
时会利用这一点,按照每个特定的 UserID 将 events RDD shuffle 到包含 userData 对应 hash 分区的节点上。
Spark 运行时架构包含以下三种基本组件:
执行模式使您能够在运行应用程序时确定上述资源的物理位置,有三种模式可供选择(在下面的部分中,带实心边框的矩形表示 driver 进程,而带虚线边框的矩形表示 executor 进程):
从 Spark 代码外部来看 Spark 应用程序的整个生命周期:
相比 Spark 的外部生命周期,Spark 内部(用户代码)生命周期更加重要:
任何 Spark 应用程序的第一步都是创建 SparkSession,在许多交互模式中,这是为您完成的,但在应用程序中,您必须手动完成。一些遗留代码可能使用新的 SparkContext 模式。应该避免这样做,因为 SparkSession 上的 builder 方法更能有力地实例化 Spark 和 SQL 上下文,并确保没有上下文冲突,因为可能有多个库试图在同一Spark应用程序中创建会话。
1 | // Creating a SparkSession in Scala |
在进行 SparkSession 之后,您应该能够运行 Spark 代码。通过 SparkSession,您还可以相应地访问所有低阶和遗留上下文和配置。请注意,SparkSession 类只添加在 Spark 2.x 中。您可能会发现,较旧的代码将直接为结构化API创建 SparkContext 和 sqlContext。
Spark 代码基本上由转换(transformation)和动作(action)组成,在 Spark 中,所有的 transformation 类型操作都是延迟计算的,Spark 只是记录了将要对数据集进行的操作,只有需要将数据返回到 Driver 程序时(即触发 Action 类型操作),所有已记录的 transformation 才会执行,这被称为“惰性计算”。通常,Spark 会按照动作(action)将 Spark 程序划分为不同的 Job。
transformation 种类繁多,我们只需要记住那些会将数据返回到 Driver 程序的那些操作即可:
函数名 | 目的 | 示例 | 结果 |
---|---|---|---|
collect() | 所有元素 | rdd.collect() | {1,2,3,3} |
count() | 元素个数 | rdd.count() | 4 |
countByValue() | 各元素在rdd中出现的次数 | rdd.countByValue() | {(1,1),(2,1),(3,2)} |
take(num) | 从rdd中返回num个元素 | rdd.take(2) | {1,2} |
top(num) | 从rdd中返回最前面的num个元素 | rdd.top(2) | {3,3} |
takeOrdered(num)(ordering) | 按提供的顺序,返回最前面的 num 个元素 | rdd.takeOrdered(2)(myOrdering) | {3,3} |
takeSample(withReplacement,num,[seed]) | 从rdd中返回任意一些元素 | rdd.takeSample(false,1) | 非确定的 |
reduce(func) | 整合RDD中的所有数据 | rdd.reduce((x,y)=>x+y) | 9 |
fold(zero)(func) | 和reduce一样,但是需要初始值 | rdd.fold(0)((x,y)=>x+y) | 9 |
aggregate(zeroValue)(seqOp,combOp) | 和reduce()相似,但是通常返回不同类型的函数 | rdd.aggregate((0,0))((x,y)=>(x,y)=>(x._1+y,x._2+1),(x,y)=>(x._1+y._1,x._2+y._2)) | (9,4) |
foreach(func) | 对RDd中的每个元素使用给定的元素 | rdd.foreach(func) | 无 |
Spark 中的阶段(stage)表示可以一起执行以在多台计算机上并行计算相同操作的任务(task)组。一般来说,Spark 会尝试将尽可能多的工作(即工作中尽可能多的转换)打包到同一个阶段(stage),但引擎会在称为洗牌(Shuffle)的操作后启动新的阶段(stage)。
在“Spark 指南:Spark 原理(一)—— Partition 和 Shuffle”一文中我们讲过宽依赖算子会导致 Shuffle,这里重温一下那些会导致 Shuffle 的算子:
Shuffle 过程首先会将前置 Stage 的 Map Task 结果写入本地磁盘(Shuffle Write),然后后续 Stage 的 reduce Task 会从磁盘中读取这些文件(Shuffle Read)来执行计算,这有两点好处:
每个任务(task)对应于将在单个执行器(executor)上运行的数据块(Partition)和一组转换的组合。Task 只是应用于数据单元(Partition)的计算单位,将数据划分为更多数量的分区意味着可以并行执行更多数据。如果我们的数据集中有一个大分区,我们将有一个任务;如果有1000个小分区,我们将有 1,000 个可以并行执行的任务。
使 Spark 成为“内存计算工具”的一个重要原因是,与之前的工具(如 MapReduce)不同,Spark 在将数据写入内存或磁盘前会尝试执行尽可能多的步骤。Spark 执行的关键优化之一是 pipelining,它发生在 RDD 及以下级别。使用流水线技术,任何可以将数据直接传递给彼此而无需在节点间移动的操作序列,都会被折叠成单个任务阶段,阶段内的所有操作会一起执行。例如,如果您编写一个基于 RDD 的程序,该程序执行一个 map,一个 filter,然后是另一个 map,则这些将导致单阶段任务,这些任务立即读取每个输入记录,将其传递给第一个 map,再将其传递给 filter,并在需要时将其传递给最后一个 map 函数。这种流水线式的计算比在每个步骤之后将中间结果写入内存或磁盘要快得多。
SQL(Structured Query Language) 是一种领域特定语言,用于表达对数据的关系型操作。SQL 无处不在,即使技术专家预言了它的消亡,它还是许多企业所依赖的及其灵活的数据工具。Spark 实现了 ANSI SQL:2003 的一个子集,该标准是大多数 SQL 数据库中可用的标准。Spark SQL 旨在用作联机分析处理(OLAP)数据库,而不是联机事务处理(OLTP)数据库,这意味着它不打算执行极低延迟的查询,即使将来肯定会支持原地修改,但是目前还不支持。
Spark SQL 的前身是 Shark。为了给熟悉 RDBMS 但又不理解 MapReduce 的技术人员提供快速上手的工具,hive 应运而生,它是当时唯一运行在 Hadoop 上的 SQL-on-hadoop 工具。但是MapReduce 计算过程中大量的中间磁盘落地过程消耗了大量的 I/O,降低的运行效率,为了提高 SQL-on-Hadoop 的效率,Shark 应运而生,但又因为 Shark 对于 Hive 的太多依赖(如采用 Hive 的语法解析器、查询优化器等等),2014 年 Spark 团队停止对 Shark 的开发,将所有资源放 Spark SQL 项目上。其中 Spark SQL 作为 Spark 生态的一员继续发展,而不再受限于 Hive,只是兼容 Hive;而 Hive on Spark 是一个 Hive 的发展计划,该计划将 Spark 作为 Hive 的底层引擎之一,也就是说,Hive 将不再受限于一个引擎,可以采用 Map-Reduce、Tez、Spark 等引擎。
Spark 提供了几个接口来执行 SQL 查询:
1 | ./bin/spark-sql |
1 | spark.sql(sql_statement) |
Catalog 是 Spark SQL 中最高级别的抽象,用于对数据库、表、视图、缓存、列、函数(UDF/UDAF)的元数据进行操作,其 API 可以在 org.apache.spark.sql.catalog
中查看。
示例数据:
1 | val data = Seq( |
获取 catalog 对象:
1 | val c = spark.catalog |
1 | // 返回当前使用的数据库,相当于select database() |
1 | c.listDatabases().show(false) |
1 | // 表/视图的属性 |
1 | c.listTables("default").show() |
1 | // 函数的属性 |
1 | c.listFunctions.show(10, false) |
1 | // 列的属性 |
1 | c.listColumns("df").show() |
要用 Spark SQL 做任何有用的事情,首先要定义表,表在逻辑上等效于 DataFrame,因为他们是运行命令所依据的数据结构,我们可以对表进行关联、过滤、汇总等操作,表和 DataFame 之间的核心区别在于:在编程语言范围内定义 DataFrame,在数据库中定义表。
Spark 相当独特的功能是可以在 SQL 中重用整个数据源 API:
1 | // 从数据源读取数据,创建表,定义了一个非托管表 |
1 | val sql = """ |
1 | spark.sql("describe df_copy").show() |
REFRESH TALE 刷新与该表的所有缓存条目(实质上是文件),如果该表先前已被缓存,则下次扫描时将被延迟缓存:
1 | spark.sql("refresh table df_copy") |
删除表会删除托管表中的数据,因此执行此操作时需要非常小心。
1 | spark.sql("drop table if exists df_copy") |
和 DataFrame 一样,你可以缓存表或者取消缓存表:
1 | spark.sql("uncache table flights") |
视图是保存的查询计划,可以方便地组织或重用查询逻辑。
Spark 有几种不同的视图概念,视图可以是全局视图、数据库视图或会话视图:
1 | // 常规/数据库视图:在所属数据库可见,不能基于视图再创建常规视图 |
定义好视图,就可以像访问表一样在 SQL 中访问视图了:
1 | spark.sql("select * from replace_temp_view_f").show() |
1 | spark.sql("drop view if exists replace_temp_view_f") |
数据库是用于组织表的工具,如果你没有定义数据库,Spark 将使用默认的数据库,在 Spark 中运行的所有 SQL 语句(包括 DataFrame 命令)都是在数据库的上下文中执行的,如果你更改数据库,则任何用户定义的表都将保留在先前的数据库中,并且要以其他方式查询。
1 | // 创建数据库 |
Spark 中的查询支持以下 ANSI SQL 要求(此处列出了 SELECT 表达式的布局):
1 | SELECT [ALL|DISTINCT] named_expression[, named_expression, ...] |
查看当前环境 SQL 参数的配置:
1 | spark.sql("SET -v").show(false) |
1 | #Job ID /Name |
可以在应用程序初始化时或在应用程序执行过程中进行设置:
1 | spark.conf.set("spark.sql.crossJoin.enabled", "true") |
functions
模块,要使用这些函数,需要先导入该模块:1 | import org.apache.spark.sql.functions._ |
Spark SQL 函数众多,最好的做法就是当需要某个具体功能时在以下列表中检索,或者直接百度谷歌:
在聚合中,您将指定一个分组和一个聚合函数,该函数必须为每个分组产生一个结果。Spark 的聚合功能是复杂巧妙且成熟的,具有各种不同的用例和可能性。通常,通过分组使用聚合函数去汇总数值型数据,也可以将任何类型的值聚合到 array、list 或 map 中。
Spark 支持以下分组类型,每个分组都会返回一个 RelationalGroupedDataset
,可以在上面指定聚合函数:
select
语句中执行聚合来汇总一个完整的 DataFrame;group by
允许指定一个或多个 key 以及一个或多个聚合函数来转换列值;window
可以指定一个或多个 key 以及一个或多个聚合函数来转换列值,但是输入到函数的行以某种方式与当前行有关;grouping set
可用于在多个不同级别进行聚合,grouping set
可以作为 SQL 原语或通过 DataFrame 中的 rollup
和 cube
使用;group by A, B grouping sets(A, B)
等价于 group by A union group by B
;rollup
可以指定一个或多个 key 以及一个或多个聚合函数来转换列值,这些列将按照层次进行聚合;group by A,B,C with rollup
首先会对 A,B,C
进行 group by,然后对 A,B
进行 group by,最后对 A
进行 group by,再对全表进行 group by,最后将结构进行 union,缺少字段补 null;cube
可以指定一个或多个 key 以及一个或多个聚合函数来转换列值,这些列将在所有列的组合中进行聚合;group by A,B,C with cube
,会对 A, B, C
的所有可能组合进行 group by,最后再将结果 union;除了可以在 DataFrame 上或通过 .stat
出现的特殊情况之外,所有聚合都可用作函数,你可以在 org.apache.spark.sql.functions
包中找到大多数聚合函数。
1 | // count("*") 会显示 count(1),但是直接写 count(1) 却会报错 |
1 | // 分组语法 |
grouping sets
:group by keys grouping sets(combine1(keys), ..., combinen(keys))
,其中,keys
包含了所有可能用于分组的字段,combine(keys)
是 keys 的一个子集,聚合函数会分别基于每组 combine(keys)
进行聚合,最后再把所有聚合结果按字段进行 union,不同类型的分组缺失字段补 null;可以通过 null 值在各列上的分布来判断各结果行所属的聚合类型,进一步地,我们可以用 grouping_id()
聚合函数值来标识每一结果行的聚合类型,grouping_id()
首先用二进制表示各个 key 是否为 null,如 (a, null, null)
对应二进制 011
,然后再将该二进制数转化为对应的十进制数(在这个例子中,十进制数为 3)得到 grouping_id()
的值;grouping sets
仅在 SQL 中可用,是 group by 子句的扩展,要在 DataFrame 中执行相同的操作,请使用 rollup 和 cube 算子;1 | val sql = """ |
rollup
:group by A,B,C with rollup
首先会对 A,B,C
进行 group by,然后对 A,B
进行 group by,最后对 A
进行 group by,再对全表进行 group by,最后将结构进行 union,缺少字段补 null;1 | val sql = """ |
cube
:group by A,B,C with cube
,会对 A, B, C
的所有可能组合进行 group by,最后再将结果 union;1 | val sql = """ |
可以通过 collect_list
和 collect_set
收集某列中的值,前者保留原始顺序,后者不保证顺序但会去重。
1 | val res = df.select(collect_list("Country"), collect_set("Country")) |
Spark 窗口函数对一组行(如frame、partition)进行操作,并为每个输入行返回一个值。窗口函数是一种特殊的聚合函数,但是输入到函数的行以某种方式与当前行有关,函数会为每一行返回一个值。Spark SQL支持三种窗口函数:
语法:
1 | // 定义窗口 |
示例数据:
1 | import spark.implicits._ |
用于排序的窗口定义:
1 | // 按照指定字段分组,在分组内按照另一字段排序,得到排序窗口,如果需要降序,可以使用col("salary").desc |
1 | df.withColumn("row_number",row_number.over(windowSpec)) |
1 | df.withColumn("rank",rank().over(windowSpec)) |
1 | df.withColumn("dense_rank",dense_rank().over(windowSpec)) |
1 | //percent_rank |
1 | df.withColumn("ntile",ntile(2).over(windowSpec)) |
1 | df.withColumn("cume_dist",cume_dist().over(windowSpec)).show() |
1 | df.withColumn("lag",lag("salary",2).over(windowSpec)).show() |
1 | df.withColumn("lead",lead("salary",2).over(windowSpec)).show() |
在本部分中,我将解释如何使用 Spark SQL Aggregate 窗口函数和 WindowSpec 计算每个分组的总和,最小值,最大值,使用聚合函数时,order by 子句特别重要,影响着最后聚合的具体范围。
1 | val windowSpec = Window.partitionBy("department").orderBy("salary") |
自定义函数是 Spark SQL 最有用的特性之一,它扩展了 Spark 的内置函数,允许用户实现更加复杂的计算逻辑。但是,自定义函数是 Spark 的黑匣子,无法利用 Spark SQL 的优化器,自定义函数将失去 Spark 在 Dataframe / Dataset 上所做的所有优化,通常性能和安全性较差。如果可能,应尽量选用 Spark SQL 内置函数,因为这些函数提供了优化。
根据自定义函数是作用于单行还是多行,可以将其划分为两类:
使用 UDF 的一般步骤:
null
值的处理,如果设计不当,UDF 很容易出错,最好的做法是在函数内部检查 null
,而不是在外部检查 null
;val 函数名 = org.apache.spark.sql.functions.udf(函数值)
;sparkSession.udf.register(函数名, 函数值)
;ArrayType
类型,实际传参时也要传入 ArrayType
类型的实参;1 | // 示例数据 |
1 | // convertCase 是一个函数值,将句子中每个单词首字母改为大写 |
1 | import org.apache.spark.sql.functions.udf |
1 | // 1. 注册 UDF |
在 “Spark SQL 数据类型”一文曾介绍过 Spark 类型和 Scala 类型之间的对应关系,当 UDF 在 Spark 和 Scala 之间传递参数和返回值时也遵循同样的对应关系,下面列出了 Spark 中复杂类型与 Scala 本地类型之间的对应关系:
Spark 类型 | udf 参数类型 | udf 返回值类型 |
---|---|---|
StructType | Row | Tuple/case class |
ArrayType | Seq | Seq/Array/List |
MapType | Map | Map |
本部分将使用如下示例数据来演示以上各种场景:
1 | val data = Seq( |
如果传给 udf 的是 StructType
类型,udf 参数类型应该定义为 Row
类型;如果需要 udf 返回 StructType
类型,udf 返回值类型应该定义为 Tuple
或 case class
;
Tuple
:Tuple
返回值会被转化为 struct
,Tuple
的各个元素分别对应 struct
的各个子域 _1
、_2
……1 | // 数据类型转化过程:Struct => Row => Tuple => Struct |
struct
,样例类的不同属性构成了 struct
的各个子域;1 | case class P(x:String, y:Int) |
1 | def myF(gender:String, a:Seq[Int]):Seq[String] = a.map(x => gender * x.toInt) |
1 | scala.collection.mutable.WrappedArray$ofRef cannot be cast to scala.collection.immutable.List` |
1 | def myF(gender:String, a:String *):Seq[String] = { |
1 | def myF(gender:String, m:Map[String, String]):Map[String, String] = { |
UDAF(User Defined Aggregate Function,即用户自定义的聚合函数)相比 UDF 要复杂很多,UDF 接收一行输入并产生一个输出,UDAF 则是接收一组(一般是多行)输入并产生一个输出,Spark 维护了一个 AggregationBuffer
来存储每组输入数据的中间结果。使用 UDAF 的一般步骤:
UserDefinedAggregateFunction
,对每个阶段方法做实现;我们通过一个计算平均值的 UDAF 实际例子来了解定义 UDAF 的过程:
1 | import org.apache.spark.sql.Row |
1 | import org.apache.spark.sql.SparkSession |
Spark SQL 具有大量内部类型表示形式,下表列出了 Scala 绑定的类型信息:
id | Data Type | Value type in Scala | API to create a data Type |
---|---|---|---|
1 | ByteType | Byte | ByteType |
2 | ShortType | Short | ShortType |
3 | IntegerType | Int | IntegerType |
4 | LongType | Long | LongType |
5 | FloatType | Float | FloatType |
6 | DoubleType | Double | DoubleType |
7 | DecimalType | java.math.BigDecimal | DecimalType |
8 | StringType | String | StringType |
9 | BinaryType | Array[Byte] | BinaryType |
10 | BooleanType | Boolean | BooleanType |
11 | TimestampType | java.Timestamp | TimestampType |
12 | DateType | java.sql.Date | DateType |
13 | ArrayType | scala.collection.Seq | ArrayType( elementType, [containsNull]) |
14 | MapType | scala.collection.Map | MapType( keyType, valueType, [valueContainsNull]) |
15 | StructType | org.apache.spark.sql.Row | tructType( fields: Array[StructField]) |
16 | StructField | Scala中此字段的数据类型的值类型 | StructField( name,dataType,[nullable]) |
在 Scala 中,要使用 Spark 类型,需要先导入 org.apache.spark.sql.types._
:
1 | import org.apache.spark.sql.types._ |
我们经常需要在本地类型和 Spark 类型之间进行转换,以利用各自在数据处理不同方面的优势,在转化过程中本地类型和 Spark 类型要符合上表中列出的对应关系,如果无法进行隐式转换就会报错:
toDF()
、createDataFrame()
;lit()
;collect()
;1 | import org.apache.spark.sql.functions.lit |
需要注意的是,如果传给 lit()
的参数本身就是 Column
对象,lit()
将原样返回该 Column
对象:
1 | /** |
将 DataFrame 列类型从一种类型转换到另一种类型有很多种方法:withColumn()
、cast()
、selectExpr
、SQL 表达式,需要注意的是目标类型必须是 DataType 的子类。
1 | // 示例数据 |
withColumn()
、cast()
:1 | val df2 = df |
select
:1 | val cast_df = df.select(df.columns.map { |
selectExpr
:1 | val df3 = df2.selectExpr("cast(age as int) age", |
布尔类型是所有过滤的基础:
1 | df.where(col("salary") < 4000).show() |
1 | df.describe().show() |
1 | val df2 = df.withColumn("f_diff", (col("dob") - col("salary"))/col("salary")) |
StatFunctions 程序包中提供了许多统计功能,可以通过 df.stat
访问。
1 | // 交叉表 |
monotonically_increasing_id 生成一个单调递增并且是唯一的 ID。
1 | df.withColumn("f_id", monotonically_increasing_id()).show() |
1 | // 语法:pos 从 1 开始 |
1 | // 语法:pattern 是一个正则表达式,返回一个 Array |
1 | // 语法 |
1 | // 语法 |
1 | df.withColumn("f_translate", translate(col("dob"), "36", "+-")).show() |
1 | // 语法,other 可以是 Column 对象,将逐行判断 |
正则详细规则参见这里。
1 | // 语法 |
1 | // 语法 |
在 Spark 中,有四种日期相关的数据类型:
本部分只介绍 Spark 内置的日期处理工具,更复杂的操作可以借助 java.text.SimpleDateFormat
和 java.util.{Calendar, Date}
使用 UDF 来解决。
1 | val df = spark.range(3) |
1 | val tmp = spark.range(1).select(lit("2020-11-07 19:45:12").as("date")) |
1 | val tmp = spark.range(1).select(lit("2020-11-07 19:45:12").as("date")) |
日期相关的四种数据类型之间的转换方法如下图所示,其中,格式串遵守 Java SimpleDateFormat 标准。
from_unixtime
函数可以将 Long 型时间戳转化为 String 类型的日期,unix_timestamp
函数可以将 String 类型的日期转化为 Long 型时间戳。
1 | // 默认返回当前秒级时间戳,在同一个查询中对 unix_timestamp 的所有调用都会返回相同值,unix_timestamp 会在查询开始时进行计算 |
1 | val tmp = df.withColumn("long_string", from_unixtime(col("timestampLong"))) |
to_date
函数可以将时间字符串转化为 date 类型,如果不指定具体的格式串,则等价于 cast("date")
;date_format
函数可以将 date/timestamp/string 类型的日期时间转化为指定格式的时间字符串,如果只是希望将他们按原样转化为字符串,也可直接通过 cast("string")
来实现。
1 | // 等价于 col(e: Column).cast("date") |
1 | val tmp = df.withColumn("date_string", date_format(col("date"), "yyyyMMdd")) |
和 string & date 之间的转换基本一致,不再赘述,这里只通过几个示例来做说明:
1 | val tmp = df.withColumn("timestamp_string", date_format(col("timestamp"), "yyyyMMdd")) |
date & timestamp 之间的转换直接通过 cast
即可实现,无需赘言:
1 | val tmp = df.withColumn("timestamp_date", col("timestamp").cast("date")) |
用到的时候搜索 API 即可,这里还是有必要列出最常用到的:
1 | // 原型,start 必须是date或者可以隐式地通过 cast("date") 转化为 date (timestamp 或 yyyy-MM-dd HH:ss 格式的字符串) |
1 | // 返回 end - start 的天数 |
1 | val tmp = df.withColumn("month_diff", months_between(col("date"), lit("2020-09-01"))) |
最佳实践是,你应该始终使用 null
来表示 DataFrame 中缺失或为空的数据,与使用空字符串或其他值相比,Spark 可以优化使用 null 的工作。对于空值的处理,要么删除要么填充,与 null 交互的主要方式是在 DataFrame 上调用 .na
子包。
ifnull(expr1, expr2)
:默认返回 expr1
,如果 expr1
值为 null 则返回 expr2
;只用于 SQL 表达式;nullif(expr1, expr2)
:如果条件为真则返回 null,否则返回 expr1
;只用于 SQL 表达式;nvl(expr1, expr2)
:同 ifnull;nvl2(expr1, expr2, expr3)
:如果 expr1
为 null 则返回 expr2
,否则返回 expr3
;1 | df.createOrReplaceTempView("df") |
coalesce(e: Column*)
:从左向右,返回第一个不为 null 的值;1 | df.select(coalesce(lit(null), lit(null), lit(1)).as("coalesce")).show(1) |
na.fill
:用法比较灵活:只有 value 的类型和所在列的原有类型可隐式转换时才会填充df.na.fill(value)
;df.na.fill(value, Seq(cols_name*))
;df.na.fill(Map(col->value))
1 | val df = spark.range(1).select( |
删除空值可以分为以下几种情况:
.where("col is not null")
即可完成;na.drop()
;na.drop("all")
仅当改行所有列均为 null 或 NaN 时,才会删除;1 | df.na.drop().show() |
复杂类型可以帮助你以对问题更有意义的方式组织和构造数据,Spark SQL 中复杂类型共有三种:
id | Data Type | Scala Type | API to create a data Type |
---|---|---|---|
1 | StructType | org.apache.spark.sql.Row | tructType( fields: Array[StructField]) |
2 | ArrayType | scala.collection.Seq | ArrayType( elementType, [containsNull]) |
3 | MapType | scala.collection.Map | MapType( keyType, valueType, [valueContainsNull]) |
示例数据:创建 DataFrame 时,显式定义 struct/array/map 类型
1 | val data = Seq( |
可以将 struct 视为 DataFrame 中的 DataFrame,struct 是一个拥有命名子域的结构体。
1 | df.select(struct(col("gender"), col("salary")), expr("(gender, salary)")).show() |
.*
可以提取 struct 中所有的子域;getField
方法也可以提取子域的值,但列名为完整带点号的名称1 | df.select(coldf.select(col("f_struct.firstname"), expr("f_struct.firstname"), col("f_struct").getField("firstname"), col("f_struct.*")).show() |
array
函数;split
、collect_list
等函数也会返回 array;1 | df.select(array(col("gender"), col("salary")), expr("array(gender, salary)")).show() |
[index]
按索引提取数组中的值;1 | df.select(col("f_array").getItem(0), expr("f_array[0]")).show() |
org.apache.spark.functions
1 | df.select( |
map(key1, value1, key2, value2, ...)
;其中,输入列必须可以被分组为 key-value
对,所有 key 列必须具有相同类型且不能为 null,value 列也必须具有相同类型(或者可以通过 cast 转化为相同类型);1 | val dfmap = df.select( |
1 | dfmap |
Spark 对 JSON 数据提供了一些独特的支持,可以直接在 Spark 中对 JSON 字符串进行处理,并从 JSON 字符串解析或提取 JSON 对象(返回字符串)。
1 | val df = spark.range(1).selectExpr(""" |
get_json_object
内联查询 JSON 对象,如果只有一层嵌套,也可以使用 json_tuple
1 | val res = df |
to_json
函数可以将 StructType
或 MapType
列转化为 JSON 字符串;1 | val dfjson = df.select("f_struct", "f_map") |
from_json
函数可以将 json 列解析回 struct/map 列,但是要求制定一个 Schema1 | val structSchema = new StructType() |
1 | import org.apache.spark.sql.SparkSession |
DataFrame 仅仅只是 Dataset[Row] 的一个类型别名,创建 Dataset 的方式和创建 DataFrame 基本相同。
spark.range
方法可以创建一个单列 DataFrame,其中列名为 id,列的类型为 LongType 类型,列中的值取 range 生成的值。
1 | // 语法 |
spark 提供了一系列隐式转换方法,可以将指定类型的对象序列 Seq[T]
或 RDD[T]
转化为 Dataset[T]
或 DataFrame
,使用前需要先导入隐式转换:
1 | // spark 为入口 SparkSession 对象 |
如果 T
是 Int
、Long
、String
或 T <: scala.Product
(Tuple 或 case class) 类型中的一种,则可以通过 toDs()
或 toDf()
方法转化为 Dataset[T]
或 DataFrame
。
toDF(): DataFrame
和 toDF(colNames: String*): DataFrame
方法提供了一种非常简洁的方式,将对象序列转化为一个 DataFrame;value
,如果结果有多列 _1, _2,...
会作为默认列名;1 | // 序列元素为简单类型 |
toDS(): Dataset[T]
提供了一种将指定类型的对象序列转化为 DataSet 的简易方法1 | // 序列元素为简单类型 |
toDF 方法对 null 类型处理的不好,不建议在生产环境中使用。
相比 toDF 和 toDS,createDataFrame 和 createDataSet 方法支持更多的数据类型,特别是 Seq[Row]
和 RDD[Row]
只能通过 create 方法来转化为 DataFrame。
createDataFrame[A <: Product : TypeTag](data: Seq[A]): DataFrame
: 通过 Product 序列创建 DataFrame,如 tuple、case classcreateDataFrame[A <: Product : TypeTag](rdd: RDD[A]): DataFrame
: 通过 Product RDD 创建 DataFrame,如 tuple、case classcreateDataFrame(rows: List[Row], schema: StructType): DataFrame
: 通过 java.util.List[Row] 并指定 Schema 创建 DataFramecreateDataFrame(rowRDD: RDD[Row], schema: StructType): DataFrame
: 通过 RDD[Row] 并指定 Schema 创建 DataFramecreateDataFrame(rdd: RDD[_], beanClass: Class[_]): DataFrame
: Applies a schema to an RDD of Java BeanscreateDataFrame(data: List[_], beanClass: Class[_]): DataFrame
: Applies a schema to a List of Java Beans1 | // 只传入 Seq[Tuple],列名为 "_1" "_2" |
createDataSet(x)
是 x.toDS()
的等价形式:1 | // 序列元素为简单类型 |
Spark 有六个核心数据源和社区编写的数百个外部数据源(Cassandra、HBase、MongoDB、XML):
读取数据源的通用 API 结构如下:
1 | DataFrameReader.format(...).option("key", "value").schema(...).load(path) |
读取数据的基本要素:
DataFrameReader
是 DataFrame 读取器,可以通过 SparkSession
的 read
属性来使用;format
是可选的,默认使用 Parquet 格式;option
允许设置键值配置,以参数化如何读取数据,也可以传入一个 Map;schema
如果数据源提供了 schema,或者你打算使用 schema 推断,则 schema 是可选的;每种格式都有一些必选项,我们将在讨论每种格式时进行详细讨论;Read modes 用于指定当 Spark 遇到格式错误的记录时如何处理:
permissive
:默认值,遇到损坏的记录时,将所有损坏记录放在名为called_corrupt_record
的字符串列中,将所有字段设置为 null;dropMalformed
:删除包含格式错误的行;failFast
:遇到格式错误的记录立即失败;写入数据的通用 API 结构如下:
1 | DataFrameWriter.format(...).option(...).partitionBy(...).bucketBy(...).sortBy(...).save() |
数据写入的基本要素:
DataFrameWriter
是 DataFrame 写入器,可以通过 DataFrame
的 write
属性来使用;format
是可选的,默认使用 Parquet 格式;option
允许设置键值配置,以参数化如何读取数据,也可以传入一个 Map;必须至少提供一个保存路径;Save modes 用于指定当 Spark 在指定位置找到数据将发生什么:
apppend
:将输出文件追加到该位置已存在的文件列表中;overwrite
:将完全覆盖那里已经存在的任何数据;errorIfExists
:默认值,如果指定位置已经存在数据或文件,则会引发错误并导致写入失败;ignore
:如果该位置存在数据或文件,则不执行任何操作;CSV 文件虽然看起来结构良好,但实际上是你将遇到的最棘手的文件格式之一,因为在生产方案中无法对其所包含的内容或结果进行很多假设,因此,CSV 读取器具有大量选项。
参数 | 解释 |
---|---|
sep | 默认是, 指定单个字符分割字段和值 |
encoding | 默认是uft-8通过给定的编码类型进行解码 |
quote | 默认是“,其中分隔符可以是值的一部分,设置用于转义带引号的值的单个字符。 如果您想关闭引号,则需要设置一个空字符串,而不是null。 |
escape | 默认(\)设置单个字符用于在引号里面转义引号 |
charToEscapeQuoteEscaping | 默认是转义字符(上面的escape)或者\0,当转义字符和引号(quote)字符 不同的时候,默认是转义字符(escape),否则为\0 |
comment | 默认是空值,设置用于跳过行的单个字符,以该字符开头。默认情况下,它是禁用的 |
header | 默认是false,将第一行作为列名 |
enforceSchema | 默认是true, 如果将其设置为true,则指定或推断的模式将强制应用于数据源文件, 而CSV文件中的标头将被忽略。 如果选项设置为false,则在header选项设置为true的情况下, 将针对CSV文件中的所有标题验证模式。模式中的字段名称和CSV标头 中的列名称是根据它们的位置检查的,并考虑了*spark.sql.caseSensitive。 虽然默认值为true,但是建议禁用 enforceSchema选项,以避免产生错误的结果 |
inferSchema | inferSchema(默认为false`):从数据自动推断输入模式。 *需要对数据进行一次额外的传递 |
samplingRatio | 默认为1.0,定义用于模式推断的行的分数 |
ignoreLeadingWhiteSpace | 默认为false,一个标志,指示是否应跳过正在读取的值中的前导空格 |
ignoreTrailingWhiteSpace | 默认为false一个标志,指示是否应跳过正在读取的值的结尾空格 |
nullValue | 默认是空的字符串,设置null值的字符串表示形式。从2.0.1开始, 这适用于所有支持的类型,包括字符串类型 |
emptyValue | 默认是空字符串,设置一个空值的字符串表示形式 |
nanValue | 默认是Nan,设置非数字的字符串表示形式 |
positiveInf | 默认是Inf |
negativeInf | 默认是-Inf 设置负无穷值的字符串表示形式 |
dateFormat | 默认是yyyy-MM-dd,设置指示日期格式的字符串。自定义日期格式遵循 java.text.SimpleDateFormat中的格式。这适用于日期类型 |
timestampFormat | 默认是yyyy-MM-dd’T’HH:mm:ss.SSSXXX,设置表示时间戳格式的字符串。 自定义日期格式遵循java.text.SimpleDateFormat中的格式。这适用于时间戳记类型 |
maxColumns | 默认是20480定义多少列数目的硬性设置 |
maxCharsPerColumn | 默认是-1定义读取的任何给定值允许的最大字符数。默认情况下为-1,表示长度不受限制 |
mode | 默认(允许)允许一种在解析过程中处理损坏记录的模式。它支持以下不区分大小写的模式。 请注意,Spark尝试在列修剪下仅解析CSV中必需的列。 因此,损坏的记录可以根据所需的字段集而有所不同。 可以通过spark.sql.csv.parser.columnPruning.enabled(默认启用)来控制此行为。 |
columnNameOfCorruptRecord | 默认值指定在spark.sql.columnNameOfCorruptRecord, 允许重命名由PERMISSIVE模式创建的格式错误的新字段。 这会覆盖spark.sql.columnNameOfCorruptRecord |
multiLine | 默认是false,解析一条记录,该记录可能跨越多行 |
1 | val mySchema = new StructType( |
job2.csv
实际上是一个目录,其中包含很多文件,文件数对应分区数;1 | df.write.format("csv") |
在 Spark 中,当我们谈到 JSON 文件时,指的的是 line-delimited
JSON 文件,这与每个文件具有较大 JSON 对象或数组的文件形成对比。line-delimited
和 multiline
由选项 multiLine
控制,当将此选项设置为 true 时,可以将整个文件作为一个 json 对象读取。line-delimited
的 JSON 实际上是一种更加稳定的格式,它允许你将具有新记录的文件追加到文件中,这也是建议你使用的格式。
属性名称 | 默认值 | 含义 |
---|---|---|
primitivesAsString | FALSE | 将所有原始类型推断为字符串类型 |
prefersDecimal | FALSE | 将所有浮点类型推断为 decimal 类型,如果不适合, 则推断为 double 类型 |
allowComments | FALSE | 忽略 JSON 记录中的 Java / C ++样式注释 |
allowUnquotedFieldNames | FALSE | 允许不带引号的 JSON 字段名称 |
allowSingleQuotes | TRUE | 除双引号外,还允许使用单引号 |
allowNumericLeadingZeros | FALSE | 允许数字前有零 |
allowBackslashEscapingAnyCharacter | FALSE | 允许反斜杠转义任何字符 |
allowUnquotedControlChars | FALSE | 允许JSON字符串包含不带引号的控制字符(值小于32的ASCII字符, 包括制表符和换行符)或不包含。 |
mode | PERMISSIVE | PERMISSIVE:允许在解析过程中处理损坏记录; DROPMALFORMED: 忽略整个损坏的记录;FAILFAST:遇到损坏的记录时抛出异常。 |
columnNameOfCorruptRecord | columnNameOfCorruptRecord(默认值是spark.sql.columnNameOfCorruptRecord中指定的值): 允许重命名由PERMISSIVE 模式创建的新字段(存储格式错误的字符串)。 这会覆盖spark.sql.columnNameOfCorruptRecord。 | |
dateFormat | dateFormat(默认yyyy-MM-dd):设置表示日期格式的字符串。 自定义日期格式遵循java.text.SimpleDateFormat中的格式。 | |
timestampFormat | timestampFormat(默认yyyy-MM-dd’T’HH:mm:ss.SSSXXX): 设置表示时间戳格式的字符串。 自定义日期格式遵循java.text.SimpleDateFormat中的格式。 | |
multiLine | FALSE | 解析可能跨越多行的一条记录 |
1 | spark.read.format("json") |
1 | df.write.format("json").mode("overwrite").save(path) |
Parquet 是 Spark 的默认文件格式(默认数据源可以通过 spark.sql.sources.default
进行设置),Parquet 是面向列的开源数据存储,可提供各种存储优化。它提供了列压缩,从而节省了存储空间,并允许读取单个列而不是整个文件。Parquet 支持复杂类型,如果你的列是 struct
、array
、map
类型,仍然可以正常读写该文件。
1 | spark.read.format("parquet").load(path) |
1 | df.write.format("parquet") |
ORC 是一种专为 Hadoop workloads 设计的自我描述、有类型的列式文件格式。它针对大型数据流进行了优化,但是集成了对快速查找所需行的支持。ORC 实际上没有读取数据的选项,因为 Spark 非常了解这种文件格式,一个经常会被问到的问题是:ORC 和 Parquet 有什么区别?在大多数情况下,他们非常相似,根本的区别在于 Parquet 专门为 Spark 做了优化,而 ORC 专门为 Hive 做了优化。
1 | spark.read.format("orc").load(path) |
1 | df.write.format("orc").mode("overwrite").save(path) |
Spark SQL 还支持读取和写入存储在Apache Hive中的数据。但是,由于Hive具有大量依赖项,因此这些依赖项不包含在默认的Spark发布包中。如果可以在类路径上找到Hive依赖项,Spark将自动加载它们。请注意,这些Hive依赖项也必须存在于所有工作节点(worker nodes)上,因为它们需要访问Hive序列化和反序列化库(SerDes)才能访问存储在Hive中的数据。
在使用Hive时,必须实例化一个支持Hive的SparkSession,包括连接到持久性Hive Metastore,支持Hive 的序列化、反序列化(serdes)和Hive用户定义函数。没有部署Hive的用户仍可以启用Hive支持。如果未配置hive-site.xml,则上下文(context)会在当前目录中自动创建metastore_db,并且会创建一个由spark.sql.warehouse.dir配置的目录,其默认目录为spark-warehouse,位于启动Spark应用程序的当前目录中。请注意,自Spark 2.0.0以来,该在hive-site.xml中的hive.metastore.warehouse.dir属性已被标记过时(deprecated)。使用spark.sql.warehouse.dir用于指定warehouse中的默认位置。可能需要向启动Spark应用程序的用户授予写入的权限。
下面的案例为在本地运行(为了方便查看打印的结果),运行结束之后会发现在项目的目录下 E:\IdeaProjects\myspark
创建了 spark-warehouse
和 metastore_db
的文件夹。可以看出没有部署Hive的用户仍可以启用Hive支持,同时也可以将代码打包,放在集群上运行。
1 | object SparkHiveExample { |
Spark SQL 还包括一个可以使用 JDBC 从其他数据库读取数据的数据源。与使用 JdbcRDD 相比,应优先使用此功能。这是因为结果作为 DataFrame 返回,它们可以在 Spark SQL 中轻松处理或与其他数据源连接。JDBC 数据源也更易于使用 Java 或 Python,因为它不需要用户提供 ClassTag。
可以使用 Data Sources API 将远程数据库中的表加载为 DataFrame 或 Spark SQL 临时视图。用户可以在数据源选项中指定JDBC连接属性。user并且password通常作为用于登录数据源的连接属性提供。除连接属性外,Spark还支持以下不区分大小写的选项:
属性名称 | 含义 |
---|---|
url | 要连接的JDBC URL,可以再URL中指定特定于源的连接属性 |
dbtable | 应该读取或写入的JDBC表 |
query | 将数据读入Spark的查询语句 |
driver | 用于连接到此URL的JDBC驱动程序的类名 |
numPartitions | 表读取和写入中可用于并行的最大分区数,同时确定了最大并发的JDBC连接数 |
partitionColumn, lowerBound, upperBound | 如果指定了任一选项,则必须指定全部选项。此外,还必须指定numPartitions。 partitionColumn必须是表中的数字,日期或时间戳列。 注意:lowerBound和upperBound(仅用于决定分区步幅,而不是用于过滤表中的行。 因此,表中的所有行都将被分区并返回,这些选项仅用于读操作。) |
queryTimeout | 超时时间(单位:秒),零意味着没有限制 |
fetchsize | 用于确定每次往返要获取的行数(例如Oracle是10行), 可以用于提升JDBC驱动程序的性能。此选项仅适用于读 |
batchsize | JDBC批处理大小,默认 1000,用于确定每次往返要插入的行数。 这可以用于提升 JDBC 驱动程序的性能。此选项仅适用于写。 |
isolationLevel | 事务隔离级别,适用于当前连接。它可以是 NONE,READ_COMMITTED, READ_UNCOMMITTED,REPEATABLE_READ 或 SERIALIZABLE 之一, 对应于 JDBC的Connection 对象定义的标准事务隔离级别, 默认值为 READ_UNCOMMITTED。此选项仅适用于写。 |
sessionInitStatement | 在向远程数据库打开每个数据库会话之后,在开始读取数据之前, 此选项将执行自定义SQL语句(或PL / SQL块)。 使用它来实现会话初始化,例如:option(“sessionInitStatement”, “”“BEGIN execute immediate ‘alter session set “_serial_direct_read”=true’; END;”””) |
truncate | 当启用SaveMode.Overwrite时,此选项会导致 Spark 截断现有表, 而不是删除并重新创建它。这样更高效,并且防止删除表元数据(例如,索引)。 但是,在某些情况下,例如新数据具有不同的 schema 时,它将无法工作。此选项仅适用于写。 |
cascadeTruncate | 如果JDBC数据库(目前为 PostgreSQL和Oracle)启用并支持, 则此选项允许执行TRUNCATE TABLE t CASCADE(在PostgreSQL的情况下, 仅执行TRUNCATE TABLE t CASCADE以防止无意中截断表)。 这将影响其他表,因此应谨慎使用。此选项仅适用于写。 |
createTableOptions | 此选项允许在创建表时设置特定于数据库的表和分区选项 (例如,CREATE TABLE t (name string) ENGINE=InnoDB)。此选项仅适用于写。 |
createTableColumnTypes | 创建表时要使用的数据库列数据类型而不是默认值。 (例如:name CHAR(64),comments VARCHAR(1024))。 指定的类型应该是有效的 spark sql 数据类型。 此选项仅适用于写。 |
customSchema | 用于从JDBC连接器读取数据的自定义 schema。 例如,id DECIMAL(38, 0), name STRING。 您还可以指定部分字段,其他字段使用默认类型映射。 例如,id DECIMAL(38,0)。列名应与JDBC表的相应列名相同。 用户可以指定Spark SQL的相应数据类型,而不是使用默认值。 此选项仅适用于读。 |
pushDownPredicate | 用于 启用或禁用 谓词下推 到 JDBC数据源的选项。 默认值为 true,在这种情况下,Spark会尽可能地将过滤器下推到JDBC数据源。 否则,如果设置为 false,则不会将过滤器下推到JDBC数据源, 此时所有过滤器都将由Spark处理。 |
1 | object JdbcDatasetExample { |
Spark SQL API 可以在模块 org.apache.spark.sql
下查看:
常用的 API 模块:
Spark SQL 的用法之一是执行 SQL 查询,它也可以从现有的 Hive 中读取数据,如果从其它编程语言内部运行 SQL,查询结果将作为一个 Dataset/DataFrame 返回。
表和视图与 DataFrame 基本相同,为我们只是针对它们执行 SQL 而不是 DataFrame 代码。
Spark 结构化 API 可以细分为两个 API:有类型的 Dataset 和无类型的 DataFrame。说 DataFrame 是无类型的并不准确,它们具有类型,但是 Spark 会完全维护它们,并且仅在运行时检查那些类型是否与模式中指定的类型一致。而 DataSet 在编译时检查类型是否符合规范,DataSet 仅适用于基于 Java 虚拟机(JVM)的语言(Scala 和 Java)。
Dataset 是一个分布式数据集,它是 Spark 1.6 版本中新增的一个接口, 它结合了 RDD(强类型,可以使用强大的 lambda 表达式函数) 和 Spark SQL 的优化执行引擎的好处。Dataset 可以从 JVM 对象构造得到,随后可以使用函数式的变换(map,flatMap,filter 等)进行操作。Dataset API 目前支持 Scala 和 Java 语言,还不支持 Python, 不过由于 Python 语言的动态性, Dataset API 的许多好处早就已经可用了,例如,你可以使用 row.columnName 来访问数据行的某个字段。
DataFrame 是按命名列方式组织的一个 Dataset。从概念上来讲,它等同于关系型数据库中的一张表或者 R 和 Python 中的一个 dataframe, 只不过在底层进行了更多的优化。DataFrame 可以从很多数据源构造得到,比如:结构化的数据文件,Hive 表,外部数据库或现有的 RDD。 DataFrame API 支持 Scala, Java, Python 以及 R 语言。在 Scala 和 Java 语言中, DataFrame 由 Row 的 Dataset 来 表示的。在 Scala API 中, DataFrame 仅仅只是 Dataset[Row] 的一个类型别名,而在 Java API 中, 开发人员需要使用 Dataset
下图对比了 SQL、DataFrame 和 DataSet 三种 Spark SQL 编程方式错误检查机制:
在大多数情况下,您可能会使用 DataFrame。对于 Scala-Spark,DataFrame 只是类型为 Row 的数据集,Row 类型是 Spark 内部优化表示的内部表示形式,这种格式可以进行高度专业化和高效的计算,而不是使用 JVM(可能导致高昂的垃圾处理和对象实例化成本)。对于 PySpark,一切都是 DataFrame。
DataFrame 和 RDD 都是可以并行处理的集合,但 DataFrame 更像是一个传统数据库里的表,除了数据之外还可以知道更多信息,比如列名、值、类型。从 API 角度来看 DataFrame 提供了更高级的 API,比 RDD 编程要方便很多,由于 R 语言和 Pandas 也有 DataFrame,这就降低了 Spark 的学习门槛,在编写 Spark 程序时根本不需要关心最后是运行在单机上还是分布式集群上,因为代码都是一样的。
假设 RDD 里面支持的是一个 Person 类型,那么每一条记录都相当于一个 Person,但是 Person 里面到底有什么我们并不知道。DataFrame 存储了各字段的列名、数据类型以及值,有了这些信息,Spark SQL 的查询优化器(Catalyst)在编译的时候就能够做更多的优化。
SQL、DataFrame 和 RDD 运行时性能对比:在大多数情况下 SQL 和 DataFrame 性能要好于 RDD
Spark SQL 的核心是 Catalyst 优化器,一种函数式的可扩展的查询优化器:
Catalyst 支持两种优化策略:
无论是直接使用 SQL 语句还是使用 DataFrame,都会经过如下环节转换成 DAG 对 RDD 的操作:
Spark2.x SQL 语句的解析采用的是 ANTLR4,ANTLR4 根据语法文件 SqlBase.g4 自动解析生成两个Java类:词法解析器 SqlBaseLexer 和语法解析器 SqlBaseParser。使用这两个解析器将SQL字符串语句解析成了ANTLR4 的 ParseTree 语法树结构。然后在 parsePlan 过程中,使用 AstBuilder.scala 将 ParseTree 转换成catalyst 表达式逻辑计划 Unresolved Logical Plan,ULP。
ULP 还只是一个语法树,系统需要通过元数据信息 Calalog 来获取表的 schema 信息(表名、列名、数据类型)和函数信息(类信息)。Analyzer 会再次遍历整个 AST,对树上的每个节点进行数据类型绑定以及函数绑定,比如people 词素会根据元数据表信息解析为包含 age、id 以及 name 三列的表,people.age会被解析为数据类型为 int 的变量,sum 会被解析为特定的聚合函数,解析后得到 Logical Plan,LP。
RBO 的优化策略就是对语法树进行一次遍历,模式匹配能够满足特定规则的节点,再进行相应的等价转换,即将一棵树等价地转换为另一棵树,最终得到优化后的逻辑计划 Optimized logical plan, OLP。
SQL 中经典的常见优化规则有:
100+80
优化为180
,避免每一条 record 都需要执行一次100+80
的操作OLP 只是逻辑上可行,实际上 spark 并不知道如何去执行这个OLP。一个逻辑计划(Logical Plan)经过一系列的策略(Strategy)处理之后,得到多个物理计划(Physical Plans),物理计划在 Spark 是由 SparkPlan 实现的。
RBO 属于 LogicalPlan 的优化,所有优化均基于 LogicalPlan 本身的特点,未考虑数据本身的特点,也未考虑算子本身的代价。CBO 充分考虑了数据本身的特点(如大小、分布)以及操作算子的特点(中间结果集的分布及大小)及代价,从而更好的选择执行代价最小的物理执行计划,即 SparkPlan。
比如 join 算子,Spark 根据不同场景为该算子制定了不同的算法策略,有 broadcastHashJoin、shuffleHashJoin 以及 sortMergeJoin。CBO 中常见的优化是 join 换位,以便尽量减少中间shuffle 数据集大小,达到最优输出。
选出的物理计划还是不能直接交给 Spark 执行,Spark 最后仍然会用一些 Rule 对 SparkPlan 进行处理:
JupyterLab 是 Jupyter 团队为 Jupyter 项目开发的下一代基于 Web 的交互式开发环境。相对于 Jupyter Notebook,它的集成性更强、更灵活并且更易扩展。它支持 100 种多种语言,支持多种文档相互集成,实现了交互式计算的新工作流程。如果说 Jupyter Notebook 像是一个交互式的笔记本,那么 Jupyter Lab 更像是一个交互式的 VSCode。另外,JupyterLab 非常强大的一点是,你可以将它部署在云服务器,不管是电脑、平板还是手机,都只需一个浏览器,即可远程访问使用。使用 JupyterLab,你可以进行数据分析相关的工作,可以进行交互式编程,可以学习社区中丰富的 Notebook 资料。
本文只是提供一个 Jupyter lab 的基本配置思路和索引,Jupyter lab 还在快速发展,文中提到的很多内容可能已经不再适用了,大家在配置时不要拘泥于文中细节,还是要去官网上查看具体安装细节,否则可能导致版本兼容的各种问题
建议先安装 Anaconda,Anaconda 自带 Jupyter 和常用的科学计算包,且方便通过 conda 进行环境管理。为了不污染本地 Python 环境,建议单独为 Jupyter lab 创建一个虚拟环境(在 base 环境下可能遇到各种奇怪的错误):
1 | # 创建虚拟环境,同时安装完整anaconda集合包(假设已经成功安装了 Anaconda) |
jupyter-lab 提供了两种方式来管理 Jupyter-lab 的插件:
1 | # jupyter-lab 运行插件需要先安装 nodejs |
安装插件时,通常需要先通过 pip/conda
安装相关依赖,再通过 jupyter labextension
来安装对应插件,部分插件在成功安装之后需要重启 jupyter-lab 才能生效。建议只安装必要的插件,插件过多会拖慢 jupyter-lab 的打开速度。
kite 是一个功能非常强大的代码补全工具,目前可用于 Python 与 javascript,为许多知名的编辑器譬如 Vs Code、Pycharm 提供对应的插件,详细的安装过程可以参考Jupyter lab 最强代码补全插件。
安装 kite 的一般步骤:
jupyter-lab
:需要注意的是 kite 只支持 2.2.0 以上版本的jupyter lab,但是目前jupyter lab的最新正式版本为2.1.5,因此我们需要使用pip来安装其提前发行版本,这里我选择2.2.0a1;1 | # 升级 jupyterlab 到 2.2.0 |
成功安装 kite 后,会自动跳转到 kite 使用说明文档 kite_tutorial.ipynb,这里简单介绍 kite 的几项核心功能:
Kite: Toggle Docs Panel
来关闭或打开完整说明文档jupyterlab_code_formatter 用于代码一键格式化。
1 | # 安装依赖 |
jupyterlab-go-to-definition 用于Lab笔记本和文件编辑器中跳转到变量或函数的定义
1 | # JuupyterLab 2.x |
默认快捷键 alt+click
:
jupyterlab-git 是 jupyter-lab 的 git 插件,可以方便地进行版本管理。
1 | $ conda install -c conda-forge jupyterlab jupyterlab-git |
qgrid 是一个可以用交互的方式操作 Pandas DataFrame 的插件,主要优点有:
1 | $ conda install qgrid |
1 | # 載入所需套件 |
1 | qgrid_widget.get_changed_df() |
jupyter_bokeh 该插件可以在 Lab 中展示bokeh 可视化效果。
1 | conda install -c bokeh jupyter_bokeh |
jupyterlab-dash 该插件可以在Lab中展示 plotly dash 交互式面板。
1 | $ conda install -c plotly -c defaults -c conda-forge "jupyterlab>=1.0" jupyterlab-dash=0.1.0a3 |
jupyterlab_variableinspector 可以在 Lab 中展示代码中的变量及其属性,类似RStudio中的变量检查器。你可以一边撸代码,一边看有哪些变量。对 Spark 和 Tensorflow 的支持需要解决依赖。
1 | $ jupyter labextension install @lckr/jupyterlab_variableinspector |
jupyterlab-system-monitor 用于监控 jupyter-lab 的资源使用情况。
1 | $ conda install -c conda-forge nbresuse |
默认只显示内存使用情况:
编辑配置文件 ~/.jupyter/jupyter_notebook_config.py
:添加一下内容,重启 jupyter-lab 就可以显示 CPU 利用率以及内存使用情况了。
1 | c = get_config() |
示例:
1 | # 示例:限制最大内存 4G,2 个 CPU,显示 CPU 利用率 |
jupyterlab-toc 用于在 jupyter-lab 中显示文档的目录。
1 | $ jupyter labextension install @jupyterlab/toc |
Collapsible_Headings 可实现标题的折叠。
1 | $ jupyter labextension install @aquirdturtle/collapsible_headings |
该插件允许你在Jupyter Lab内部呈现HTML文件,这在打开例如d3可视化效果时非常有用
1 | $ jupyter labextension install @mflevine/jupyterlab_html |
jupyterlab-drawio 可以在Lab中启用 drawio 绘图工具,drawio是一款非常棒的流程图工具。
1 | $ jupyter labextension install jupyterlab-drawio |
jupyterlab-tabular-data-editor 插件赋予我们高度的交互式操纵 csv 文件的自由,无需excel,就可以实现对csv表格数据的增删改查。
1 | $ jupyter labextension install jupyterlab-tabular-data-editor |
jupyterlab-themes 用于切换 jupyter 的主题。
1 | # 目前还只能一个一个安装 |
更多插件可以参考以下网站:
Jupyter kernel 可以用任何语言实现,只要它们遵循基于 ZeroMQ 的 Jupyter 通信协议。IPython 是最流行的内核,默认情况下包括在内。这并不奇怪,因为 Jupyter(Jupyter,Jupyter,Python,R)来自IPython项目。它是将独立于语言的部分从IPython内核中分离出来,使其能够与其他语言一起工作的结果,现在有超过100种编程语言的内核可用。
除了内核和前端之外,Jupyter 还包括与语言无关的后端部分,它管理内核、笔记本和与前端的通信。这个组件称为Jupyter服务器。笔记本存储在.ipynb文件中,在服务器上以Json格式编码。基于Json的格式允许以结构化的方式存储单元输入、输出和元数据。二进制输出数据采用base64编码。缺点是,与基于行的文本格式相比,json使diff和merge更困难。您可以将笔记本导出为其他格式,如Markdown、Scala(仅包含代码输入单元格)或类似本文的HTML。
1 | # 查看 kernel 列表 |
在Scala中对Jupyter的支持是怎样的?实际上有很多不同的内核。但是,如果仔细观察,它们中的许多在功能上有一定的局限性,存在可伸缩性问题,甚至已经被放弃。其他人只关注Spark而不是Scala和其他框架。
其中一个原因是,几乎所有现有内核都构建在默认REPL之上。由于其局限性,他们对其进行定制和扩展,以添加自己的特性,如依赖关系管理或框架支持。一些内核还使用sparkshell,它基本上是scalarepl的一个分支,专门为Spark支持而定制。这一切都会导致碎片化、重用困难和重复工作,使得创建一个与其他语言相当的内核变得更加困难。
关于一些原因的更详细讨论,请查看 Alexandre Archambault 在2017年 JupyterCon 上的演讲 Scala: Why hasn’t an Official Scala Kernel for Jupyter emerged yet?。
almond(之前叫jupyter-scala) 使得 jupyter 强大的功能向 Scala 开放,包括 Ammonite 的所有细节,尽管它还需要一些更多的集成和文档,但是它已经非常有用,并且非常有趣。
——Interactive Computing in Scala with Jupyter and almond
安装 almond 需要特别注意 almond 版本、Scala 版本以及 Spark版本之间的兼容性(almond 0.10.0 支持 scala 2.12.11 and 2.13.2 支持 park 2.4.x),almond 详细安装过程及版本对应关系请参考 almond 官方文档。
1 | # 查看可用的 Scala 版本 |
配置 Spark:
1 | # Or use any other 2.x version here |
由于我们是在 python 3 虚拟环境下安装了 jupyter lab,自带的是 python 3 kernel,现在需要添加 python 2 的 kernel:
1 | # 假设已经安装了名为 python2 的虚拟环境,切换到 python 2 环境 |
安装成功后,在 jupyter lab 新建文件页面会出现 python 2 的图标:
1 | (base) ➜ ~ jupyter labextension list |
jupyter\lab\settings\build_config.json,https://github.com/jupyterlab/jupyterlab/issues/8122
1 | (base) ➜ ~ jupyter lab build |
ModuleNotFoundError: No module named 'jupyter_nbextensions_configurator'
1 | (mylab) ➜ ilab jupyter-lab |
1 | (mylab) ➜ ~ which jupyter-nbextensions_configurator |
Byte、Short、Int、Long和Char类型统称整数类型,加上Float和Double称作数值类型。
以上列出的基本类型除了Java.lang.String外都是scala包的成员,Int的完整名称是scala.Int,不过scala包的所有成员在scala源文件中都已经自动引入,可以在任何地方使用简单名称。
以上列出的所有基础类型都可以使用字面值(literal)来书写,下图是指定字面值类型的记法:
示例:
1 | scala> val f = 1.234 |
一些常见的整数字面值:
1 | // 如果整数以非0开头,默认被视为十进制数 |
浮点数以十进制数字+可选的小数点+可选的E或e打头的指数组成:
1 | // 浮点数字面值默认为Double型 |
1 | scala> val c = 'we' |
\u
加上字符对应的四位十六进制数字,Unicode字符可以出现在Scala程序的任何位置1 | // 出现在字面值字符中 |
Scala 本身没有 String 类,字符串的类型实际上是 java.lang.String
,String 是一个不可变对象,对字符串的修改会生成一个新的字符串对象。
\
会被解析为转义符:1 | scala> val c1 = "hello world" |
1 | // 转义符会被当做普通字符 |
Scala默认提供了三种插值器来实现在字符串字面值中嵌入表达式,你也可以定义自己的插值器来满足不同的需求。
s"${expression}"
定位表达式 -> 表达式求值 -> 对值调用toString方法
raw"${expression}"
f"${expression}%.2f"
1 | scala> val x = 314 |
下表列出了 java.lang.String 中常用的方法,你可以在 Scala 中使用:
序号 | 方法 | 描述 |
---|---|---|
1 | char charAt(int index) | 返回指定位置的字符 |
2 | int compareTo(Object o) | 比较字符串与对象 |
3 | int compareTo(String anotherString) | 按字典顺序比较两个字符串 |
4 | int compareToIgnoreCase(String str) | 按字典顺序比较两个字符串,不考虑大小写 |
5 | String concat(String str) | 将指定字符串连接到此字符串的结尾,等价于 + |
6 | boolean contentEquals(StringBuffer sb) | 将此字符串与指定的 StringBuffer 比较。 |
7 | static String copyValueOf(char[] data) | 返回指定数组中表示该字符序列的 String |
8 | static String copyValueOf(char[] data, int offset, int count) | 返回指定数组中表示该字符序列的 String |
9 | boolean endsWith(String suffix) | 测试此字符串是否以指定的后缀结束 |
10 | boolean equals(Object anObject) | 将此字符串与指定的对象比较 |
11 | boolean equalsIgnoreCase(String anotherString) | 将此 String 与另一个 String 比较,不考虑大小写 |
12 | byte getBytes() | 使用平台的默认字符集将此 String 编码为 byte 序列,并将结果存储到一个新的 byte 数组中 |
13 | byte[] getBytes(String charsetName | 使用指定的字符集将此 String 编码为 byte 序列,并将结果存储到一个新的 byte 数组中 |
14 | void getChars(int srcBegin, int srcEnd, char[] dst, int dstBegin) | 将字符从此字符串复制到目标字符数组 |
15 | int hashCode() | 返回此字符串的哈希码 |
16 | int indexOf(int ch) | 返回指定字符在此字符串中第一次出现处的索引 |
17 | int indexOf(int ch, int fromIndex) | 返回在此字符串中第一次出现指定字符处的索引,从指定的索引开始搜索 |
18 | int indexOf(String str) | 返回指定子字符串在此字符串中第一次出现处的索引 |
19 | int indexOf(String str, int fromIndex) | 返回指定子字符串在此字符串中第一次出现处的索引,从指定的索引开始 |
20 | String intern() | 返回字符串对象的规范化表示形式 |
21 | int lastIndexOf(int ch) | 返回指定字符在此字符串中最后一次出现处的索引 |
22 | int lastIndexOf(int ch, int fromIndex) | 返回指定字符在此字符串中最后一次出现处的索引,从指定的索引处开始进行反向搜索 |
23 | int lastIndexOf(String str) | 返回指定子字符串在此字符串中最右边出现处的索引 |
24 | int lastIndexOf(String str, int fromIndex) | 返回指定子字符串在此字符串中最后一次出现处的索引,从指定的索引开始反向搜索 |
25 | int length() | 返回此字符串的长度 |
26 | boolean matches(String regex) | 告知此字符串是否匹配给定的正则表达式 |
27 | boolean regionMatches(boolean ignoreCase, int toffset, String other, int ooffset, int len) | 测试两个字符串区域是否相等 |
28 | boolean regionMatches(int toffset, String other, int ooffset, int len) | 测试两个字符串区域是否相等 |
29 | String replace(char oldChar, char newChar) | 返回一个新的字符串,它是通过用 newChar 替换此字符串中出现的所有 oldChar 得到的 |
30 | String replaceAll(String regex, String replacement | 使用给定的 replacement 替换此字符串所有匹配给定的正则表达式的子字符串 |
31 | String replaceFirst(String regex, String replacement) | 使用给定的 replacement 替换此字符串匹配给定的正则表达式的第一个子字符串 |
32 | String[] split(String regex) | 根据给定正则表达式的匹配拆分此字符串 |
33 | String[] split(String regex, int limit) | 根据匹配给定的正则表达式来拆分此字符串 |
34 | boolean startsWith(String prefix) | 测试此字符串是否以指定的前缀开始 |
35 | boolean startsWith(String prefix, int toffset) | 测试此字符串从指定索引开始的子字符串是否以指定前缀开始。 |
36 | CharSequence subSequence(int beginIndex, int endIndex) | 返回一个新的字符序列,它是此序列的一个子序列 |
37 | String substring(int beginIndex) | 返回一个新的字符串,它是此字符串的一个子字符串 |
38 | String substring(int beginIndex, int endIndex) | 返回一个新字符串,它是此字符串的一个子字符串 |
39 | char[] toCharArray() | 将此字符串转换为一个新的字符数组 |
40 | String toLowerCase() | 使用默认语言环境的规则将此 String 中的所有字符都转换为小写 |
41 | String toLowerCase(Locale locale) | 使用给定 Locale 的规则将此 String 中的所有字符都转换为小写 |
42 | String toString() | 返回此对象本身(它已经是一个字符串!) |
43 | String toUpperCase() | 使用默认语言环境的规则将此 String 中的所有字符都转换为大写 |
44 | String toUpperCase(Locale locale) | 使用给定 Locale 的规则将此 String 中的所有字符都转换为大写 |
45 | String trim() | 删除指定字符串的首尾空白符 |
46 | static String valueOf(primitive data type x) | 返回指定类型参数的字符串表示形式 |
Boolean类型有两个字面量,true
和false
:
1 | scala> val t = true |
和很多动态语言不同, Scala不支持其他类型到Boolean类型的隐式转换:
1 | scala> if(4>3) print("4>3") |
举例:
1 | scala> val a = 'a' |
1 | scala> "hello" + 2019 |
有几种方式:
1 | scala> val a = 97 |
1 | scala> a.asInstanceOf[Int] |
操作符即方法:操作符和方法只不过是操作的两种语法形式
一切操作符都只不过是方法调用的漂亮语法
一切方法都可以写作操作符表示法
==
的实现很用心,大部分场合都能返回给你需要的相等性比较的结果,其背后的规则是:首先检查左侧是否为null,如果不为Null,调用equals方法()
由于Scala并不是真的有操作符,操作符仅仅是用操作符表示法使用方法的一种方式,Scala通过操作符的首字符来决定操作符的优先级,通过操作符的尾字符决定操作符的结合性。
尽管你能够记住这些操作符的优先级,为了使得代码更加易于理解,你只应该在算术操作符合赋值操作符上利用操作符的优先级,其他情形还是老老实实加上括号吧。
Scala中操作符的优先级由操作符的首字符决定:举例来说,以*开始的操作符优先级比以+开始的操作符优先级更高,下图列出了Scala中不同首字母的操作度的优先级(自上而下,依次递减;同一行具有相同优先级):
上面红框分类不是很严谨,只是为了方便记忆,比较两个操作符的优先级的时候这样做:
:
在算术和关系之间,^
在 &
和 |
之间;1 | // + 的优先级在 < 之上,因而 + 先执行 |
当多个同等优先级的操作符并排在一起的时候,操作符的结合性由操作符的尾字符决定:任何以 :
结尾的操作符都是在它右侧的操作元调用,传入左侧操作元,以任何其他字符结尾的方法则相反。
1 | a ::: b ::: c 等价于 a ::: (b:::c) |
Scala中的操作符只是方法调用的漂亮语法,换句话说Scala中所有操作符都可以写作方法调用的形式。
<operator1> <operate> <operator2>
,如果是左结合性可以写作 <operator1>.<operate>(<operator2)
,如果是右结合性可以写作<operator2>.<operate>(<operator1)
;1 | // 1 + 2 |
+
、-
、!
、~
可以被用作前缀操作符,<operate> <operator1>
可以写作 <operator1>.unary_<operate>
1 | // -2.0 |
Scala中操作符并不是特殊的语法,任何方法都可以是操作符。
<operator1>.<operate>(<operator2>)
可以写作 <operator1> <operate> <operator2>
1 | scala> "hello world".indexOf("w") |
1 | scala> import scala.language.postfixOps |
1 | // 一个最简单的表达式 |
1 | // 表达式块 |
1 | scala> println("hello world") |
表达式为函数式编程提供了基础:表达式可以返回数据而不修改现有数据,这就允许使用不可变数据,函数也可以用来返回新的数据,在某种意义上这种函数是另一种类型的表达式。
]]>Scala中大多数控制结构都是表达式,有返回值
Scala 只有为数不多的几个内建的控制结构:if、match、for、while、try和函数调用,由于它们有返回值,可以很好地支持函数式编程。
语法:
if (<Boolean expression>) <expression>
:返回值是 Any 类型;if (<Boolean expression>) <expression> else <expression>
:返回值的类型是两种结果类型的最近公共父类型;if (<Boolean expression>) <expression> else if (<Boolean expression>) ... else <expression>
:本质上是 if ... else
表达式的嵌套,返回值的类型是所有可能返回结果类型的最近公共父类型;执行:如果布尔表达式成立则执行第一个表达式,否则执行另外一个表达式
示例:
1 | // if ... 返回值类型必为 Any |
模式匹配是检查某个值(value)是否匹配某一个模式的机制,它是Java中的switch语句的升级版,同样可以用于替代一系列的 if/else 语句。
语法:
1 | <expression> match { |
执行:获取输入表达式的值,逐一匹配备选模式,匹配成功则执行并返回对应模式后的表达式,匹配不成功则触发MatchError,返回值类型是各个备选结果表达式类型的最近公共父类型。
示例:
1 | // 对 if (x > y) x else y 的改写 |
变形:match 表达式的变形主要发生在
<pattern1> | <pattern2> ...
可以对多个模式重用 case 块1 | scala> "MON" match { |
_
可以匹配任意模式,但是不能在 => 右侧访问通配符1 | scala> "MON" match { |
1 | scala> "MON" match { |
模式变量: 类型
可以匹配输入表达式返回值的具体类型,需要注意的是备选模式的类型必须是输入表达式返回值类型的子类,否则会触发异常:error: scrutinee is incompatible with pattern type
1 | scala> val x: Int = 1 |
if <boolean expression>
,可以为匹配表达式添加匹配条件,只有条件满足时才算匹配成功1 | def showImportantNotification(notification: Notification, importantPeopleInfo: Seq[String]): String = { |
Scala 的for表达式是用于迭代的瑞士军刀,每次迭代会执行一个表达式,并返回所有表达式返回值的一个集合(可选)。
语法:enumerators 是一个枚举器,可以包含多个生成器(items <- items)和过滤器(if
1 | for (enumerators) [yield] <expression> |
执行:每次从枚举器中取出一个元素,执行表达式,返回所有返回值构成的一个集合(如果加了 yield 的话)。
示例:
1 | // 不带 yield,没有返回值 |
变形:
1 | scala> for (i <- 1 to 10 if i % 2 == 0) yield {2 * i} |
1 | scala> for {i <- 1 to 10 |
1 | scala> for { |
Scala 同样支持 while 和 do/while 循环语句,不过没有 for 表达式那么常用,因为它不是表达式,不能用来返回值。事实上,while 循环和 var通常是一起使用的,要想对程序产生任何效果,while循环通常要么更新一个var要么执行I/O。Scala 没有内建的 break 和 continue 语句,但可以通过 if 表达式来改写。
语法:
1 | // while |
执行:
while 和 do/while语句也有自己的用途,比如需要不断读取外部输入知道没有可读的内容为止,不过Scala提供了很多更有表述性且功能更强的方法来处理循环。
异常传播机制:方法除了正常返回某个值外,也可以通过抛出异常终止执行,方法调用方要么捕获并处理这个异常,要么自我终止,让异常传播到更上层的方法调用方,异常通过这种方式传播,逐个展开调用栈,直至某个方法处理该异常或再没有更多方法为止。
语法:
1 | throw new classException("something") |
执行:抛出对应类型的异常,返回值类型为Nothing
1 | scala> throw new IllegalArgumentException("ddfs") |
语法:
1 | try { |
执行:
返回值:
示例:
1 | import java.io.FileReader |
在Scala中,函数是命名的参数化表达式,而匿名函数实际上就是参数化表达式,函数可以出现在任何表达式可以出现的地方
在Scala中,函数是首类的,不仅可以得到声明和调用,还具有类型和值,函数类型和函数值可以出现在任何类型和值可以出现的地方
对于 Scala 和其他函数式编程语言来说,函数尤其重要。标准函数式编程方法论建议我们尽可能地构建纯(pure)函数,纯函数相对于非纯函数更加稳定,他们没有状态,且与外部数据正交,事实上它们是不可破坏的纯逻辑表达式:
Scala 函数可以像传统函数那样进行声明和调用,还可以进行嵌套和递归。
函数声明的一般格式:
1 | def <function_name>[[type_param]](<param1>: <param1_type> [,...]): <function_type> = <expression> |
def
:函数声明的关键字function_name
:函数名type_param
:类型参数,如果传入了类型参数,类型参数在函数定义的后续代码中就可以像普通类型一样使用param1
:值参数:
:每个参数后面都必须加上以冒号开始的类型标注,因为Scala并不会推断函数参数的类型param1_type
:值参数类型function_type
:函数的返回值类型是可选的,Scalade的类型推断会根据函数的实际返回值来推断函数的返回值类型,但在无法推断出函数返回值类型时必须显式提供函数返回值类型,比如递归函数必须显式给出函数的结果类型=
:等号也有特别的含义,表示在函数式的世界观里,函数定义的是一个可以获取到结果值的表达式expression
:函数体,由表达式或表达式块组成,最后一行将成为函数的返回值,如果需要在函数的表达式块结束前退出并返回一个值,可以使用return关键字显式指定函数的返回值,然后退出函数;如果函数只有一条语句,也可以选择不使用花括号没有参数的函数只是表达式的一个命名包装器:适用于通过一个函数来格式化当前数据或者返回一个固定的值
1 | scala> def hi() = "hi" |
没有返回值的函数被称作过程:以一个语句结尾的函数,如果函数没有显式的返回类型,且最后是一个语句,则Scala会推导出这个函数的返回类型为Unit
1 | scala> def log(d: Double) = println(f"Got Value $d%.2f") |
函数调用的通用语法:
1 | <function identifier>(<params>) |
调用无参函数时,空括号是可选的:如果在定义时加了空括号,在调用时可加可不加,但如果在定义时没有加,在调用时也不能加,这可以避免混淆调用无括号函数与调用函数返回值。
1 | scala> def hi() = "hi" |
当函数只有一个参数时,可以使用表达式块来发送参数:不必先计算一个量,然后把它保存在局部值中再传递给函数,完全可以在表达式块中完成计算,表达式块会在调用函数之前计算,将表达式块的返回值用作函数的参数
1 | scala> def len(s: String) = { |
Scala 中的参数默认按照参数顺序传递,也可以按照关键字传递:
1 | scala> def greet(prefix: String, name: String) = s"$prefix $name" |
Scala 可以为函数的任意参数指定默认值,使得调用者可以忽略这个参数:
1 | def <identifier>(<identifier>: <type> = <value> [,...]): <type> = <expression> |
如果默认参数后面还有非默认参数,那只能按照关键字传参,因为无法利用参数的顺序了;如果默认参数后面没有非默认参数,则可以按照顺序来传递前面的参数。
Scala 支持vararg参数,可以定义输入参数个数可变的函数,可变参数后面不可以有非可变参数,因为无法加以区分。
语法:在参数类型后面加上 *
来标识这是一个可变参数
1 | scala> def sum(items: Int*): Int = { |
Scala 函数不仅可以传入“值”参数,还可以传入“类型”参数,这可以提高函数的灵活性和可重用性,这样函数参数或返回值的类型不再是固定的,而是可以由函数调用者控制。
语法:在函数名后的[]
传入类型参数R之后,R就可以像一个具体的类型一样在后面使用了
1 | def <identifier>[type-param](<value-param>: <type-param>): <type> = <expression> |
示例:
1 | scala> def identity[R](r: R): R = r |
函数调用时,类型参数的类型推断:在调用包含类型参数的函数时,如果未明确指定类型参数的具体类型,scala会根据第一个参数列表的类型来推断类型参数的类型,如果第一个参数列表的类型也未知则会抛出异常。因此在设计柯里化函数时,往往将非函数参数放在第一个参数列表,将函数参数放在最后一个参数列表,这样函数的类型参数的具体类型可以通过第一个非函数入参的类型推断出来,而这个类型又能被继续用于对函数参数列表类型进行检查,使用者需要给出的类型信息更少,在编写函数字面量时可以更精简;
1 | // 类型参数未指定,且第一个参数函数字面值类型未指定,抛出异常 |
递归函数在函数式编程中很普遍,因为他们为迭代处理数据结构或计算提供了一种很好的方法,而且不必使用可变的数据,因为每个函数调用自己的栈来存储参数。
示例:
1 | // 计算正数次幂 |
使用递归函数可能会遇到”栈溢出“错误,为了避免这种情况,Scala编译器可以使用尾递归(tail-recursion)优化一些递归函数,使得递归调用不使用额外的栈空间,而只使用当前函数的栈空间。但是只有最后一个语句是递归调用的函数时(调用函数本身的结果作为直接返回值),Scala编译器才能完成尾递归优化。
示例:
1 | // 用尾递归的方式重写power |
函数是命名的参数化表达式,而表达式是可以嵌套的,所以函数本身也是可以嵌套的。当需要在一个方法中重复某个逻辑,但是把它作为外部方法有没有太大意义时,可以在函数中定义一个内部函数,这个内部函数只能在该函数内部使用。
示例:
1 | scala> def max(a: Int, b: Int, c: Int) = { |
函数式编程的一个关键是函数应当是首类的(first-class):函数不仅能得到声明和调用,还具有类型和值,函数类型和函数值可以出现在任何类型和值可以出现的地方
与函数返回值类型不同,函数类型是函数本身的类型,函数类型可以用 参数类型 => 返回值类型
来表示:
1 | ([<type>, ...]) => <type> |
函数类型可以出现在任何类型可以出现的地方:
1 | scala> def func(a: Int, b: Int): Int = if (a > b) a else b |
与函数返回值不同,函数值是函数本身的值,每个函数值都是某个扩展自scala包的FunctionN系列当中的一个特质的类的实例,比如Function0表示不带参数的函数,Function1表示带一个参数的函数,等等。每一个FunctionN特质都有一个apply方法用来调用该函数。
函数值可以出现在任何值可以出现的地方:
Scala 中有一些特殊的方法来创建或返回函数值,包括:
匿名函数是一个没有名字的函数值,匿名函数可以用 输入参数 => 返回值
来表示:
1 | ([<param1>: <type>...]) => <expression> |
示例:
1 | // 一个没有输入的函数字面值 |
匿名函数有很多名字:
函数字面量(function literal):由于匿名函数的创建不必指定标识符,且可以出现在一切函数值可以出现的地方,和一般类型中的字面值作用类似;
Lambda表达式:C#和Java8都采用这种说法,这是从原先数学中的lambda演算语法得来的;
functionN:Scala编译器对函数字面量的叫法,根据输入参数的个数而定;
当函数字面值满足以下两个条件时,甚至可以使用通配符语法把参数和箭头也给匿了:
1 | // 一个参数的情形 |
通配符语法在处理数据结构和集合事尤其有帮助,很多核心的排序、过滤和其他数据结构方法都会使用首类函数和占位符语法来减少调用这些方法所需的额外代码。
偏函数是只对满足某些特定模式的输入进行输出的函数字面值,如果输入匹配不到任何给定模式则会导致一个Scala错误(如果要避免这样的错误可以在末尾使用一个通配符):
1 | scala> val statusHandler: Int => String = { |
偏函数无法单独存在,必须要赋值给变量/参数。偏函数有点像 Sql 中的 case when
语句,在处理集合和模式匹配时更为有用。
函数名出现的时候会被默认视作一次函数调用,但是当将函数名赋值/传递给一个显式声明的变量/参数时,Scala会将其推断为一个函数值:
1 | scala> def double(x: Int): Int = x * 2 |
对于多参数函数,如果固定其中某些参数,剩余参数用通配符替换,将返回一个只接收剩余参数的函数值:
1 | def sum(a: Int, b: Int, c: Int) = a + b + c |
柯里化(Currying)是以逻辑学家 Haskell Curry 命名的一种将多参数函数转化为单参数函数链的技术。某些分析技术只能应用于具有单个参数的函数,在处理多参数函数时,柯里化通过逐一固定参数来得到关于剩余参数的新的函数,这样每次只需要处理单参数函数。
函数柯里化可以看做是部分调用函数的一种简洁语法:使用有多个参数表的函数,而不是将一个参数表分解为调用参数和非调用参数,每次调用一个函数表将返回一个函数而非函数值:
1 | // 定义一个多参数表的函数 |
如果一个函数不接收任何函数作为入参,就被称为初阶(first-order)函数,
高阶(high-order)函数则是包含了函数类型的参数或返回值的函数。
1 | scala> def safeStringOp(s: String, f: String => String) = { |
对于普通的传值参数(by-value)来说,如果向其传递一个函数调用,那么只会在参数传递的时候调用这个函数并将其返回值传递给传值参数,后面在使用这个参数的时候使用的都是它的值。而传名参数(by-name)不同,可以获取一个值,也可以获取最终返回一个值的函数,如果向这个函数传入一个值,和传值参数效果相同,但如果向它传入一个函数调用,那么每次使用这个参数时都会调用这个函数,整体上起到了“延迟调用”的效果。
传名参数的声明语法:仅仅是在参数和参数类型中间加了一个 =>
:
1 | <identifier>: => <type> |
示例:
1 | scala> def f(i: Int) = { |
Seq、Map、Set 是 Scala 最重要的三种集合类(容器),此外还有 Tuple、Option 等,这些会在后面小节逐一讲解,本节将按照自顶向下的层级结构来学习不同集合类的通用特性。
Scala 集合框架系统地区分了可变的(mutable)和不可变的(immutable)集合,并且可以很方便地在两者之间进行转换。你可以对可变集合中的元素进行增、删、改操作,你也可以对不可变类型模拟这些操作,但每个操作都会返回一个新的集合,原来的集合不会发生改变。
Scala 所有集合类都可以在以下包中找到:
scala.collection
:包中的集合既可以是可变的也可以是不可变的,下图展示了这个包中所有的集合类,这些都是高级抽象类或特质,它们通常有可变和不可变两种实现方式scala.collection.immutable
:包中的集合类是不可变的,Scala会默认导入这个包,这意味着Scala默认使用不可变集合类,当你写下 Set 而没有加任何前缀,你会得到一个不可变的 Set,下图展示了这个包中所有的集合类scala.collection.mutable
:包中的集合类是可变的,如果你想要使用可变的集合类,通用的做法是导入scala.collection.mutable
包即可,当你使用没有前缀的 Set 时仍然指的是一个不可变集合,当你使用 mutable.Set
时指的是可变的集合类,下图展示了这个包中所有的集合类scala.collection.generic
:包含了集合的构建块,集合类延迟了collection.generic
类中的部分操作实现Scala 中的集合类有以下通用方法:
1 | Traversable(1, 2, 3) |
可遍历(Traversable)是容器(collection)类的最高级别特质,它唯一的抽象操作是foreach
。foreach
是 Traversable
所有操作的基础,用于遍历容器中所有元素,并对每个元素进行指定的操作:
1 | // Elem 是容器中元素的类型,U是一个任意的返回值类型,对f的调用仅仅是容器遍历的副作用,实际上所有计算结果都被foreach抛弃(没有返回值) |
要实现 Traversable 的容器类仅需要定义与之相关的方法,其他所有方法都可以从 Traversable 中继承,Traversable 定义了许多方法:
Traversable对象的操作:
可以选择使用操作符记法,也可以选择点记法,这取决于个人喜好,但是没有参数的方法除外,这时必须使用点记法,为了一致性推荐使用点记法。
可迭代是容器类的另一个特质,这个特质里所有方法的定义都基于一个抽象方法iterator
,从Traversable Trait
中继承来的foreach方法在这里也是利用 iterator
来实现的:
1 | def foreach[U](f: Elem => U): Unit = { |
Iterator 有两个方法返回迭代器:grouped和sliding,这些迭代器返回的不是单个元素,而是原容器元素的全部子序列,grouped方法返回元素的增量分块,sliding方法生成一个滑动元素的窗口:
1 | scala> val xs = List(1, 2, 3, 4, 5) |
Iterator 在 Traversable 的基础上添加了一些其他方法:
集合的一般创建方式:
1 | // 创建空 Set |
集合的任何操作都可以使用以下三个基本操作来表达:
head
:返回集合第一个元素tail
:返回一个集合,包含除了第一元素之外的其他元素isEmpty
:在集合为空时返回 true1 | scala> val site = Set("Runoob", "Google", "Baidu") |
不可变 Set 的测、增、删、集合操作:
1 | scala> val setA = Set(1,2,3) |
可变 Set 支持不可变集合的所有操作,同时还支持对集合的原地修改操作:
1 | scala> import scala.collection.mutable |
对比 Set 和 mutable.Set:
+
、++
和 -
、--
来添加或删除元素,但很少使用,因为这些操作都需要通过集合拷贝来实现,可变集合提供了更有效的更新方法 +=
、++=
和 -=
、--=
,这些方法在集合中添加或删除元素,返回变化后的集合;+=
和 -=
操作,虽然效果相同,但它们在实现上是不同的,可变集合的+=
是在可变集合上调用+=
方法,它会改变s的内容,但不可变类型的+=
却是赋值操作的简写,它是在集合上应用方法+
,并把结果赋值给集合变量;这体现了一个重要的原则:我们通常能用一个非不可变集合的变量(var)来替换可变集合的常量(val);可变 Set 和不可变 Set 可以通过 Seq 作为中间桥梁进行相互转化:
1 | scala> val set_im = Set(1,2,3) |
选择一个 Set 比选择一个 Seq 要简单得多,可以直接使用可变与不可变的 Set。SoredSet 是按内容排序存储;LinkedHashSet 是按插入顺序存储;ListSet 可以像使用 List 一样使用,按插入顺序反序存储。
Immutable | Mutable | Description | |
---|---|---|---|
BitSet | ✓ | ✓ | A set of “non-negative integers represented as variable-size arrays of bits packed into 64-bit words.” Used to save memory when you have a set of integers. |
HashSet | ✓ | ✓ | The immutable version “implements sets using a hash trie”; the mutable version “implements sets using a hashtable.” |
LinkedHashSet | ✓ | A mutable set implemented using a hashtable. Returns elements in the order in which they were inserted. | |
ListSet | ✓ | A set implemented using a list structure. | |
TreeSet | ✓ | ✓ | The immutable version “implements immutable sets using a tree.” The mutable version is a mutable SortedSet with “an immutable AVL Tree as underlying data structure.” |
Set | ✓ | ✓ | Generic base traits, with both mutable and immutable implementations. |
SortedSet | ✓ | ✓ | A base trait. (Creating a variable as a SortedSet returns a TreeSet.) |
集合和映射类型常用操作的性能特点:
是否可变类型 | 具体类型 | lookup | add | remove | min |
---|---|---|---|---|---|
immutable | HashSet/HashMap | eC | eC | eC | L |
immutable | TreeSet/TreeMap | Log | Log | Log | Log |
immutable | BitSet | C | L | L | eC1 |
immutable | ListMap | L | L | L | L |
mutable | HashSet/HashMap | eC | eC | eC | L |
mutable | WeakHashMap | eC | eC | eC | L |
mutable | BitSet | C | aC | C | eC1 |
mutable | TreeSet | Log | Log | Log | Log |
操作说明:
操作 | 说明 |
---|---|
lookup | 测试一个元素是否被包含在集合中,或者找出一个键对应的值 |
add | 添加一个新的元素到一个集合中或者添加一个键值对到一个映射中。 |
remove | 移除一个集合中的一个元素或者移除一个映射中一个键。 |
min | 集合中的最小元素,或者映射中的最小键。 |