接上篇解密 parquet 文件,以及如何用 Python 去处理它(一):https://developer.aliyun.com/article/1617387?spm=a2c6h.13148508.setting.21.72964f0eREsVbB
记录的边界要如何确定
了解完 Parquet 文件的结构之后,再来回顾之前遗留的问题,那就是记录的边界该怎么确定呢?
以里面的 contacts.name 为例,前两个属于第一条 record,后两个属于第二条,那么问题来了,Parquet 是如何区分的呢?答案是通过 repetition level。
数组的每个元素都有一个 repetition level,如果只考虑每个数组的第一个元素,那么它们的 repetition level 就是从 0 开始依次递增的。至于其它元素的 repetition level 则直接和数组的个数保持一致,可能用文字描述的话不太好理解,我们画一张图就清晰了。
因为有 5 条记录,所以数组的个数就是 5。每个数组的第一个元素的 repetition level 从 0 开始依次递增,所以 a1、b1、c1、d1、e1 的 repetition level 分别对应 0、1、2、3、4,至于其它元素的 repetition level 则直接和数组的个数保持一致,因为这里有 5 条记录,所以就都是 5。
说完了 repetition level,再来看看 definition level,后者主要是表示 null 值。因为 Parquet 文件不会显式地存储 null,所以需要用 definition level 来判断某个值是否为 null。
message TestDefinitionLevel { optional group a { optional group b { optional string c; } } }
该结构如果转换成列式,那么它只有一列 a.b.c,由于所有 field 都是 optional 的,都可能是 null。如果 c 有定义,那么 a 和 b 作为它的上层,也一定是有定义的。但当 c 为 null 时,可能是因为它的某一级 parent 为 null 才导致 c 是 null 的。
这时为了记录嵌套结构的状况,我们就需要保存最先出现 null 的那一层的深度了。这里一共嵌套三层,所以 definition level 最大是 3。
这里 definition level 不会大于3,等于 3 的时候,表示所有层级都有定义;等于 0、1、2 的时候,指明了 null 出现的层级.
需要说明的是,对于 required 字段是不需要 definition level 的,只有那些 optional 字段才需要,举个例子:
message TestDefinitionLevel { optional group a { required group b { optional string c; } } }
如果将 b 改成 required,那么 definition level 最大就是 2,因为 b 不需要 definition level。
以上就是 repetition level 和 definition level,还是不难理解的。但可能有人觉得每个值都保存这俩玩意,是不是太浪费空间了。所以 Parquet 又做了优化,对非数组类型的字段不保存 repetition level,对 required 字段不保存 definition level。
并且实际存储这两个字段时,还会通过 bit-packing + RLE 来进行压缩。
Python 操作 Parquet 文件
Parquet 文件的原理我们稍微了解一下就好,重点是如何操作它。需要说明的是,不光是嵌套结构,二维表结构也一样可以用 Parquet 文件存储。
import pandas as pd df = pd.DataFrame({ "name": ["satori", "koishi", "marisa", "cirno"], "age": [17, 16, 18, 40], "gender": ["female"] * 4 }) df.to_parquet( "girl.parquet.gz", # 需要 pip install pyarrow engine="pyarrow", # 压缩方式,可选择:'snappy', 'gzip', 'brotli', None # 默认是 'snappy' compression="gzip", # 是否把 DataFrame 自带的索引写进去,默认写入 # 但要注意的是,索引会以 range 对象的形式写入到元数据中 # 因此不会占用太多空间,并且速度还更快 index=False )
写完之后,在我本地就会生成一个 gz 文件,那么要如何读取它呢?
import pandas as pd df = pd.read_parquet("girl.parquet.gz", engine="pyarrow") print(df) """ name age gender 0 satori 17 female 1 koishi 16 female 2 marisa 18 female 3 cirno 40 female """
结果没有问题,如果你只想要部分字段,那么通过 columns 参数指定想要的字段即可。
根据列进行分区
然后我们还可以根据指定的列进行分区,举个例子:
import pandas as pd df = pd.DataFrame({ "name": ["satori", "koishi", "marisa", "cirno"] * 2, "age": [17, 16, 18, 40] * 2, "gender": ["female"] * 8 }) df.to_parquet( "girl.parquet.gz", engine="pyarrow", compression="gzip", # 按照 "name" 字段分区 partition_cols=["name"] )
执行之前先将刚才生成的文件删掉,执行之后会发现生成了一个目录:
此时的 girl.parquet.gz 就不再是文件了,而是一个目录,然后目录里面会出现 4 个子目录。因为我们是按照 name 分区的,而 name 有 4 个不同的值。
所以从这里可以看出,只有那些专门用于分类、元素重复率非常高的字段,才适合做分区字段,最典型的就是日期。
然后我们来读取它。
import pandas as pd df = pd.read_parquet("girl.parquet.gz", engine="pyarrow") print(df) """ age gender name 0 40 female cirno 1 40 female cirno 2 16 female koishi 3 16 female koishi 4 18 female marisa 5 18 female marisa 6 17 female satori 7 17 female satori """ # 默认全部读出来了 # 但也可以选择读取部分记录,比如只读取 name='satori' 的记录 df = pd.read_parquet("girl.parquet.gz/name=satori", engine="pyarrow") print(df) """ age gender 0 17 female 1 17 female """
在读取 name='satori' 的记录时,我们看到居然没有 name 字段,原因也很简单,我们是按照 name 字段分区的。那么每个分区的 name 字段的值一定是相同的,所以读取出来之后手动添加即可。
df = pd.read_parquet("girl.parquet.gz/name=satori", engine="pyarrow") df["name"] = "satori" print(df) """ age gender name 0 17 female satori 1 17 female satori """
当然啦,如果你觉得一个分区字段不够,那么也可以指定多个分区字段。
import pandas as pd import numpy as np df = pd.DataFrame({ "p1": ["a"] * 4 + ["b"] * 4 + ["c"] * 4, "p2": ["X", "X", "Y", "Y"] * 3, "p3": np.random.randint(1, 100, size=(12,)) }) df.to_parquet( "test.parquet.gz", engine="pyarrow", compression="gzip", # 按照 "p1" 和 "p2" 字段分区 partition_cols=["p1", "p2"] )
先按照 p1 分区,在 p1 内部再按照 p2 分区,执行之后目录结构如下:
非常清晰,所以 pandas 已经封装的非常好了,你根本不需要理解 Parquet 文件的原理,直接用就完事了。
然后我们来读取:
import pandas as pd df = pd.read_parquet("test.parquet.gz", engine="pyarrow") print(df) """ p3 p1 p2 0 86 a X 1 72 a X 2 64 a Y 3 53 a Y 4 90 b X 5 60 b X 6 58 b Y 7 30 b Y 8 17 c X 9 74 c X 10 81 c Y 11 24 c Y """ df = pd.read_parquet("test.parquet.gz/p1=b", engine="pyarrow") print(df) """ p3 p2 0 90 X 1 60 X 2 58 Y 3 30 Y """ df = pd.read_parquet("test.parquet.gz/p1=b/p2=X", engine="pyarrow") print(df) """ p3 0 90 1 60 """
结果也没有问题,然后 pandas 的一个强大之处就在于,它不仅可以读取本地的 Parquet 文件,还可以读 s3 上面的,甚至是 http、ftp 也支持。
小结
以上我们就介绍了 Parquet 文件的原理以及如何用 Python 去操作它,这里需要再补充的一点是 pyarrow,它是 Apache Arrow 的 Python 实现。Apache Arrow 是一个高效的列式数据格式,用于加快数据的处理速度,并且是跨语言的。
而 pandas 在 2.0 的时候,可以采用 pyarrow 作为后端。在此之前,Pandas 的数据在内存中基本都是以 Numpy 数组的形式存在,每一列数据都以向量的形式存储,内部用 BlockManager 去管理这些向量。
但 Numpy 本身并不是为 DataFrame 而设计,对于一些数据类型的支持并不是很好。最尴尬就是缺失值,结果就搞出来 NaN,NaT,pd.NA 等等,让人头皮发麻,甚至一些公司在某些场景下都禁用 pandas。
但 Arrow 的引入可以完美地解决问题,不需要 Pandas 内部对每一种类型都设计一套实现,更契合的内存数据结构省了很多麻烦。不仅速度更快,也更省内存。
import pandas as pd import pyarrow # Pyarrow 是 Apache Arrow 的 Python 实现 # 通过 pyarrow.array() 即可生成 Arrow 格式的数据 arr1 = pyarrow.array([1, 2, 3]) print(arr1.__class__) """ <class 'pyarrow.lib.Int64Array'> """ arr2 = pyarrow.array([1, 2, 3], type=pyarrow.int32()) print(arr2.__class__) """ <class 'pyarrow.lib.Int32Array'> """ # pandas 在存储数据时默认使用 Numpy Array s1 = pd.Series([1, 2, 3], dtype="int64") print(s1) """ 0 1 1 2 2 3 dtype: int64 """ # 指定 Pyarrow,表示使用 Arrow 格式来存储数据 s2 = pd.Series([1, 2, 3], dtype="int64[pyarrow]") print(s2) """ 0 1 1 2 2 3 dtype: int64[pyarrow] """ # 在 2.0 的时候,通过设置 dtype_backend # 可以让 pandas 默认选择 pyarrow 作为后端 # pd.options.mode.dtype_backend = 'pyarrow'
pandas 还有一个让人诟病的地方,就是它的字符串处理效率不高,因为 Numpy 主要是用于数值计算的,字符串并不擅长。所以在 pandas 里面存储字符串的时候,本质上存储的还是泛型指针,那这样就和 Python 动态处理字符串没什么两样了。
而使用 Arrow 之后,字符串就不再需要通过指针来查找了,它们是连续的一段内存,这样在内存占用和处理速度上都有非常大的提升。
所以我们可以得出如下结论:
1)Apache Arrow 是一种跨语言的高效的列式数据格式,用于加速数据处理;
2)pyarrow 库是 Apache Arrow 的 Python 实现,调用 pyarrow 的 array 函数,即可创建 Arrow 格式的数据。至于它的一些 API 以后有机会再说,不过有 pandas,这些 API 你完全可以不用了解,在使用 pandas 的时候,让它在背后为你默默工作就行;
3)pandas 在创建 Series 或 DataFrame 的时候,在类型后面加上 [pyarrow],即可将数据格式从 Numpy 的数组换成 Arrow,然后在处理数据的时候会以 pyarrow 作为后端;
但 pyarrow 除了可以创建 Arrow 格式的数据之外,它还提供了一系列读取文件的方法,比如读取 ORC 文件、Parquet 文件、CSV 文件、本地文件、HDFS 文件等等。但这些方法你同样不需要了解,因为我们有 pandas,只需要在使用 pandas 读取文件的时候,将参数 engine 指定为 pyarrow 即可。
非常方便,并且速度快了不止一点半点。
注:将 engine 指定为 pyarrow,表示用 pyarrow 去读取 CSV 文件,但数据格式仍然使用 Numpy Array。而在 pandas2.0 的时候新增一个参数 dtype_backend,也要指定为 pyarrow,表示数据读取进来之后使用 Arrow 格式。
以上就是本文的内容,我们聊了 Parquet 文件,然后聊到了如何用 Python 去处理它,最后又介绍了 pyarrow。
本文参考自: