news 2026/9/20 18:20:53

PySpark 黄金文件测试解析:pa.RecordBatch.from_pandas 在不同 preserve_index 下的行为矩阵

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PySpark 黄金文件测试解析:pa.RecordBatch.from_pandas 在不同 preserve_index 下的行为矩阵

PySpark 黄金文件测试解析:pa.RecordBatch.from_pandas 在不同 preserve_index 下的行为矩阵

【免费下载链接】sparkApache Spark - A unified analytics engine for large-scale data processing项目地址: https://gitcode.com/gh_mirrors/sp/spark

导读

本文以 Apache Spark 仓库中 PySpark 上游 PyArrow 测试的黄金文件 golden_pyarrow_record_batch_from_pandas.md 为骨架,完整解读pa.RecordBatch.from_pandas()preserve_index=None / False / True三种取值下,对 0 列、单列、多列 pandas DataFrame(含多种索引形态与空值场景)的转换行为。文章同时结合其配套测试 test_pyarrow_dataframe_from_pandas.py 与 PySpark 真实生产调用点,说明该行为矩阵为何直接关系到createDataFrame的 0 列场景行数保留、Spark 与 Spark Connect 两侧 schema 推断差异等实际问题。读完本文,你将掌握 pandas 索引在 Arrow 转换中的语义、黄金文件测试机制(含SPARK_GENERATE_GOLDEN_FILES=1再生成方式),以及该矩阵与 PySpark 源码的对应关系。

一、这份黄金文件是什么

pa.RecordBatch.from_pandas()是 PyArrow 提供的"整体 DataFrame 输入"构造函数之一,它接收一个 pandas DataFrame,返回一个 Arrow RecordBatch。它接收的是整张表,与接收 Series 的pa.Array.from_pandas()形成对照。

这份 golden 文件是一个行为矩阵:行是精心构造的 8 个 pandas DataFrame 测试用例,列是preserve_index的三种取值(外加一列"输入 DataFrame 原始形态"作为参照),每个单元格固化该输入在该参数下的确定性输出字符串。测试运行时逐格对比实际输出与黄金文件,任何不符(例如 PyArrow 升级导致索引处理或类型推断漂移)都会使测试失败并报告具体差异。文件由 test_pyarrow_dataframe_from_pandas.py 中的PyArrowRecordBatchFromPandasTests.test_from_pandas驱动生成与校验,同目录下的golden_pyarrow_record_batch_from_pandas.csv是它的机器可读等价物(制表符分隔),Markdown 版本由df.to_markdown(tablefmt="github")导出。

该测试继承自GoldenFileTestMixin(goldenutils.py),它会将测试时区固定为America/Los_Angeles以确保时间戳输出确定,并通过compare_or_generate_golden_matrix实现"比较或生成"双模式。测试文件头部注释点明了这份矩阵的定位:

Golden-file tests for the PyArrowfrom_pandasconstructors that take a whole pandas DataFrame…… These take a DataFrame, unlikepa.Array.from_pandaswhich takes a Series (covered by test_pyarrow_array_from_pandas_*). Per-column type inference matches the Array tests, so these pin the DataFrame-level behavior instead: whole-frame assembly, the pandas index underpreserve_index, and -- for RecordBatch -- num_rows preservation for a 0-column DataFrame.

即:逐列类型推断已由 Array 系列测试覆盖,这份 DataFrame 级测试专门锁定整帧组装、索引在不同preserve_index下的去留、以及 0 列 DataFrame 的num_rows保持

二、矩阵单元格的记法约定

理解矩阵前需要先掌握单元格字符串的编码规范,它来自 goldenutils 的 repr 工具(goldenutils.py 中repr_pandas_valuerepr_arrow_record_batch_value_index_desc等):

  • 输入列pandas dataframe:形如{列名: [值,...]}@Dataframe[列名 dtype,...][index=索引描述]@左侧是各列tolist()的结果(保证跨 pandas 2/3 稳定),@Dataframe[...]是 dtype 列表,[index=...]是测试专用扩展——因为 pandas 的repr默认不显示索引,而索引恰恰是本矩阵的核心变量。索引描述由_index_desc生成:
    • RangeIndex[0:3:1]start:stop:step三元组(测试基类_PyArrowFromPandasFrameTestBase._index_descf"RangeIndex[{index.start}:{index.stop}:{index.step}]");
    • 'idx':[100, 200, 300]:有名索引显示索引名:值列表
    • None:[10, 20, 30]:无名非 Range 索引显示None:值列表
  • 输出列:形如{列名: [值,...]}@RecordBatch[列名: 类型,...][num_rows=N]@RecordBatch[...]是该批次的 Arrow schema(类型经repr_type归一化,如doublefloat64),尾部[num_rows=N]是测试额外附加上去的,因为0 列批次没有任何列可以隐含行数num_rows是这份测试特意钉死的属性。若转换抛异常,单元格会记录ERR@<异常类名>

三、完整行为矩阵(黄金文件原文)

以下是 golden_pyarrow_record_batch_from_pandas.md 的完整内容,按测试用例族拆分展示,数据逐字继承、未做任何删减。

3.1 0 列 DataFrame(只有索引,索引是唯一的信息载体)

test casepandas dataframepreserve_index=Nonepreserve_index=Falsepreserve_index=True
0-columns:range-index{}@Dataframe[][index=RangeIndex[0:3:1]]{}@RecordBatch[][num_rows=3]{}@RecordBatch[][num_rows=0]{__index_level_0__: [0, 1, 2]}@RecordBatch[__index_level_0__: int64][num_rows=3]
0-columns:named-index{}@Dataframe[][index='idx':[100, 200, 300]]{idx: [100, 200, 300]}@RecordBatch[idx: int64][num_rows=3]{}@RecordBatch[][num_rows=0]{idx: [100, 200, 300]}@RecordBatch[idx: int64][num_rows=3]
0-columns:unnamed-index{}@Dataframe[][index=None:[10, 20, 30]]{__index_level_0__: [10, 20, 30]}@RecordBatch[__index_level_0__: int64][num_rows=3]{}@RecordBatch[][num_rows=0]{__index_level_0__: [10, 20, 30]}@RecordBatch[__index_level_0__: int64][num_rows=3]
0-columns:empty{}@Dataframe[][index=RangeIndex[0:0:1]]{}@RecordBatch[][num_rows=0]{}@RecordBatch[][num_rows=0]{__index_level_0__: []}@RecordBatch[__index_level_0__: int64][num_rows=0]

3.2 单列 DataFrame(非 Range 索引会变成额外一列)

test casepandas dataframepreserve_index=Nonepreserve_index=Falsepreserve_index=True
single-column:range-index{'a': [1, 2, 3]}@Dataframe[a int64][index=RangeIndex[0:3:1]]{a: [1, 2, 3]}@RecordBatch[a: int64][num_rows=3]{a: [1, 2, 3]}@RecordBatch[a: int64][num_rows=3]{a: [1, 2, 3], __index_level_0__: [0, 1, 2]}@RecordBatch[a: int64, __index_level_0__: int64][num_rows=3]
single-column:named-index{'a': [1, 2, 3]}@Dataframe[a int64][index='idx':[100, 200, 300]]{a: [1, 2, 3], idx: [100, 200, 300]}@RecordBatch[a: int64, idx: int64][num_rows=3]{a: [1, 2, 3]}@RecordBatch[a: int64][num_rows=3]{a: [1, 2, 3], idx: [100, 200, 300]}@RecordBatch[a: int64, idx: int64][num_rows=3]
single-column:unnamed-index{'a': [1, 2, 3]}@Dataframe[a int64][index=None:[10, 20, 30]]{a: [1, 2, 3], __index_level_0__: [10, 20, 30]}@RecordBatch[a: int64, __index_level_0__: int64][num_rows=3]{a: [1, 2, 3]}@RecordBatch[a: int64][num_rows=3]{a: [1, 2, 3], __index_level_0__: [10, 20, 30]}@RecordBatch[a: int64, __index_level_0__: int64][num_rows=3]

3.3 多列 DataFrame(标准、含空、零行三种形态)

test casepandas dataframepreserve_index=Nonepreserve_index=Falsepreserve_index=True
multi-column:standard{'i': [1, 2, 3], 'f': [1.5, 2.5, 3.5], 'b': [True, False, True], 's': ['a', 'b', 'c'], 't': [Timestamp('2020-01-01 05:30:00'), Timestamp('2020-01-01 05:30:00'), Timestamp('2020-01-01 05:30:00')]}@Dataframe[i int64, f float64, b bool, s object, t datetime64[ns]][index=RangeIndex[0:3:1]]{i: [1, 2, 3], f: [1.5, 2.5, 3.5], b: [True, False, True], s: [a, b, c], t: [2020-01-01 05:30:00, 2020-01-01 05:30:00, 2020-01-01 05:30:00]}@RecordBatch[i: int64, f: float64, b: bool, s: string, t: timestamp[ns]][num_rows=3]同左(num_rows=3{i: [1, 2, 3], f: [1.5, 2.5, 3.5], b: [True, False, True], s: [a, b, c], t: [2020-01-01 05:30:00, 2020-01-01 05:30:00, 2020-01-01 05:30:00], __index_level_0__: [0, 1, 2]}@RecordBatch[i: int64, f: float64, b: bool, s: string, t: timestamp[ns], __index_level_0__: int64][num_rows=3]
multi-column:nullable{'f': [1.5, nan, 3.5], 'b': [True, None, False], 's': ['a', None, 'c'], 't': [Timestamp('2020-01-01 05:30:00'), NaT, Timestamp('2020-01-01 05:30:00')]}@Dataframe[f float64, b object, s object, t datetime64[ns]][index=RangeIndex[0:3:1]]{f: [1.5, None, 3.5], b: [True, None, False], s: [a, None, c], t: [2020-01-01 05:30:00, None, 2020-01-01 05:30:00]}@RecordBatch[f: float64, b: bool, s: string, t: timestamp[ns]][num_rows=3]同左(num_rows=3{f: [1.5, None, 3.5], b: [True, None, False], s: [a, None, c], t: [2020-01-01 05:30:00, None, 2020-01-01 05:30:00], __index_level_0__: [0, 1, 2]}@RecordBatch[f: float64, b: bool, s: string, t: timestamp[ns], __index_level_0__: int64][num_rows=3]
multi-column:no-rows{'i': [], 'f': [], 'b': [], 't': []}@Dataframe[i int64, f float64, b bool, t datetime64[ns]][index=RangeIndex[0:0:1]]{i: [], f: [], b: [], t: []}@RecordBatch[i: int64, f: float64, b: bool, t: timestamp[ns]][num_rows=0]同左(num_rows=0{i: [], f: [], b: [], t: [], __index_level_0__: []}@RecordBatch[i: int64, f: float64, b: bool, t: timestamp[ns], __index_level_0__: int64][num_rows=0]

注:multi-column:standardmulti-column:nullablepreserve_index=None列输出与preserve_index=False列完全一致(该 DataFrame 的索引本就是默认 RangeIndex,None 语义下不额外生成索引列);表格中为节省篇幅以"同左"标注,其内容与 golden 文件逐字符一致。

四、逐条解读:preserve_index的三种语义

pa.RecordBatch.from_pandas(df, preserve_index=...)的三种取值行为,可以由上面的矩阵完整归纳:

4.1preserve_index=None(默认)

PyArrow 的默认策略是"尽力而为地保留索引",规则如下:

  • RangeIndex被视作"不携带信息"的默认索引,直接丢弃。证据:0-columns:range-index输出{}@RecordBatch[][num_rows=3](无索引列但行数保留),single-column:range-indexmulti-column:*(均使用默认 RangeIndex)输出中都不含索引列。
  • 有名索引保留为以其名字命名的列。证据:0-columns:named-indexsingle-column:named-index都输出了idx: int64列。
  • 无名非 Range 索引被保留,但列名使用 PyArrow 的固定占位名__index_level_0__。证据:0-columns:unnamed-indexsingle-column:unnamed-index均输出__index_level_0__: int64
  • 0 列且为 RangeIndex 的空帧:输出{}@RecordBatch[][num_rows=0]

这一列是PySpark 生产代码依赖的默认行为(详见第五节):__index_level_0__或命名索引列的存在,意味着 0 列 DataFrame 的行数可以通过索引元数据被带进 RecordBatch 的num_rows,这正是 0 列关系不丢行的关键。

4.2preserve_index=False

无条件丢弃索引:

  • 所有 0 列用例都得到{}@RecordBatch[][num_rows=0]——注意0-columns:range-index0-columns:named-index0-columns:unnamed-index三个本来有 3 行的 0 列帧,在False下全部退化为num_rows=0。这是本矩阵最重要的"陷阱":0 列 DataFrame 一旦丢弃索引,行数信息就彻底消失,这正是测试注释强调"0-column relation loses its rows"的原因。
  • 单列、多列用例的索引列全部不出现,数据列保持不变(num_rows仍由数据列正确推导)。

4.3preserve_index=True

强制保留索引为列:

  • RangeIndex 也变成__index_level_0__: int64列(如single-column:range-index输出{a: [1, 2, 3], __index_level_0__: [0, 1, 2]})。
  • 有名索引仍以原名列保留(idx),无名索引仍用__index_level_0__
  • 对 0 列帧,索引列成为唯一列,num_rows因此得以保留:0-columns:named-index输出{idx: [100, 200, 300]}@RecordBatch[idx: int64][num_rows=3]
  • 空帧场景:0-columns:empty输出{__index_level_0__: []}@RecordBatch[__index_level_0__: int64][num_rows=0]——索引列保留为空数组且类型仍为int64

关键结论:对"有数据列"的 DataFrame,三种取值的差异仅体现在索引列是否出现、以及用什么名字出现;对"0 列 DataFrame",preserve_index=False会丢失行数,而NoneTrue都通过索引元数据保住了num_rows。这正是测试注释中"Spark relies on the defaultpreserve_index=Noneto carry num_rows via the index metadata"的技术内涵。

五、测试用例的构造逻辑(源码级佐证)

矩阵中的每个用例都来自测试基类 test_pyarrow_dataframe_from_pandas.py 的_build_source_frames,其构造意图与 golden 单元格一一对应:

  • 0-columns:*四个用例range-index/named-index/unnamed-index/empty):刻意构造"没有任何列、只有索引"的 DataFrame,因为此时索引是唯一携带行数的载体。代码注释直白写道:"0-column frames -- only the index carries the row count"。
  • single-column:*三个用例:验证"非 RangeIndex 会变成额外一列"的规则(注释 "A non-RangeIndex becomes an extra column")。
  • multi-column:standard:五种代表性的漂移敏感 dtype 的组合——int64float64boolobject字符串、datetime64[ns],其中时间戳固定为datetime.datetime(2020, 1, 1, 5, 30)。注释说明 object 字符串与datetime64[ns]是跨 pandas 2/3 最容易漂移的列,必须钉死以保证 Arrow 输出稳定;而逐 dtype 的类型推断本身已由test_pyarrow_array_from_pandas_default覆盖,本测试聚焦"整帧组装"。
  • multi-column:nullable:在float64object(bool)、object(str)、datetime64[ns]列中混入None/nan/NaT,验证空值经 Arrow 转换后统一表现为None且列类型收窄为具体的float64/bool/string/timestamp[ns](而非 pandas 侧的objectdatetime64[ns])。
  • multi-column:no-rows:多列但零行,且刻意不包含空 object 列——注释说明空 object 列会推断成 Arrownull而非具体类型,因此被排除以保证输出类型明确。

矩阵化的执行路径在test_from_pandas中:preserve = {"preserve_index=None": None, "preserve_index=False": False, "preserve_index=True": True}定义了三个目标列,compute_cell在输入列渲染 DataFrame 原始形态、在输出列执行pa.RecordBatch.from_pandas(df, preserve_index=preserve[col_name])并附加[num_rows=...],随后交给compare_or_generate_golden_matrix与 golden 文件比对。测试还预留了overrides字典,用于记录已知的跨版本(如 PyArrow 18 vs 22)行为差异,使同一份 golden 文件可服务多个库版本。

六、矩阵背后的生产调用点:为什么这份 golden 文件重要

测试文件头部注释明确指出了三处依赖RecordBatch.from_pandas默认行为的 PySpark 生产调用点,这是本矩阵"实战价值"的落点。

6.1 经典 PySpark 的createDataFrame(pandas 输入)

pandas/conversion.py 的_create_from_pandas_with_arrow在分片处理 DataFrame 时,对 0 列场景做了专门分支L1000-L1003):

# Handle the 0-column case separately to preserve row count. # pa.RecordBatch.from_pandas preserves num_rows via pandas index metadata. if len(pdf.columns) == 0: arrow_batches = [pa.RecordBatch.from_pandas(pdf_slice) for pdf_slice in pdf_slices] else: arrow_batches = [ b for pdf_slice in pdf_slices for b in create_arrow_table_from_pandas( [(c, t) for (_, c), t in zip(pdf_slice.items(), spark_types)], timezone=timezone, safecheck=safecheck, prefers_large_types=prefers_large_var_types, ).to_batches() ]

这里from_pandas裸调用(不带preserve_index,即默认None)执行,注释与测试文档互为印证:正是依赖 golden 矩阵第一节验证的"索引元数据携带行数"行为,0 列关系才不会丢行。而create_arrow_table_from_pandaspyspark.sql.pandas.types)承担非 0 列路径的逐列类型协调。

6.2 Spark Connect 的createDataFrame(pandas 输入)

connect/session.py 的 pandas 分支同样为 0 列情况单列处理(L629-L632):

# Handle the 0-column case separately to preserve row count. # pa.RecordBatch.from_pandas preserves num_rows via pandas index metadata. if len(data.columns) == 0: _table = pa.Table.from_batches([pa.RecordBatch.from_pandas(data)]) else: _table = create_arrow_table_from_pandas(...)

注意两处调用点对preserve_index分歧,这是测试注释点名的关键差异:经典路径在 schema 推断时调用pa.Schema.from_pandas(pdf, preserve_index=False)(conversion.pyL948),而 Connect 路径在pa.Schema.from_pandas(data)时使用默认值(connect/session.pyL573)——这意味着同一个带命名索引的 DataFrame,两条路径推断出的字段集合可能不同(经典路径丢弃索引字段,Connect 路径把命名/非 Range 索引变成额外字段)。而Schema.from_pandas的行为由同目录的兄弟 golden 文件 golden_pyarrow_schema_from_pandas.md 单独钉死(它额外加入了MultiIndex用例,验证多级索引逐级变字段的命名规则)。

6.3 有状态流处理客户端的状态序列化

stateful_processor_api_client.py 的_send_arrow_state在把聚合状态列表发往 JVM 侧时,同样以裸调用完成转换:

pandas_df = convert_pandas_using_numpy_type( pd.DataFrame(state, columns=column_names), schema ) batch = pa.RecordBatch.from_pandas(pandas_df) self.serializer.dump_stream([batch], cast(IO[bytes], self.sockfile))

这里的 DataFrame 由状态元组构造、带列名,依赖默认preserve_index=None下"RangeIndex 被丢弃、命名列保留"的语义,保证发往 JVM 的 Arrow 流 schema 与 Spark StructType 严格对应。任何索引处理行为的漂移都会静默改变字段集合,这正是 golden 矩阵对这三个调用点形成回归保护的意义。

6.4 相关兄弟测试

同一 golden 测试体系还覆盖了另外两个"整体 DataFrame 输入"构造函数,可用于交叉对照:

  • golden_pyarrow_schema_from_pandas.md:pa.Schema.from_pandas,钉死字段的 name/type/nullability——因为 Spark 读取每个字段的类型与可空性来构建StructType(conversion.pyL989/ connect/session.pyL590),nullability 的悄然变化会静默改变推断出的 Spark schema。
  • golden_pyarrow_table_from_pandas.md:pa.Table.from_pandas,在仓库中没有生产调用点,仅为完整性而覆盖;由于 Table 的列是ChunkedArray,它接受 chunked 备份的列,而 RecordBatch 会拒绝(抛TypeError,即 SPARK-58625),该 golden 文件因此额外钉死 chunk 数。

七、如何运行与再生成黄金文件

PyArrowRecordBatchFromPandasTests通过@unittest.skipIf(not have_pyarrow or not have_pandas, ...)在缺依赖时跳过。运行测试需要已安装 pandas 与 pyarrow,普通模式直接跑:

python/run-tests -k pyarrow

或以 pytest 方式单独定位测试文件:

python -m pytest python/pyspark/tests/upstream/pyarrow/test_pyarrow_dataframe_from_pandas.py -k "record_batch"

再生成黄金文件:当 PyArrow 或 pandas 升级导致预期输出整体变化(而非回归 bug)时,可设置环境变量后运行测试,GoldenFileTestMixin.is_generating_golden会识别SPARK_GENERATE_GOLDEN_FILES=1compare_or_generate_golden_matrix将重新计算每个单元格并同时写出 CSV 与 Markdown 两个黄金文件(见 goldenutils.py 中save_golden:CSV 以制表符分隔,Markdown 依赖tabulate包):

SPARK_GENERATE_GOLDEN_FILES=1 python -m pytest \ python/pyspark/tests/upstream/pyarrow/test_pyarrow_dataframe_from_pandas.py

测试基类还会把环境TZ固定为America/Los_Angelessetup_timezone),确保时间戳类单元格在任意机器上输出一致;再生成后应审查 git diff,确认变化确实来自预期的库版本行为演进而非意外漂移。

八、总结:从矩阵到工程实践

把这份 golden 文件放到 PySpark 整体语境中,可以提炼出三条可直接指导开发的结论:

  1. 0 列 DataFrame 的行数只能靠索引承载preserve_index=False会使其num_rows归零,因此 PySpark 两条createDataFrame路径(经典与 Connect)都专门为 0 列分支选择裸调用RecordBatch.from_pandas,以默认None语义保住行数。若你手写类似转换,务必沿用这一约定。

  2. preserve_index语义是"三层"的False无条件丢弃;True无条件保留(RangeIndex 也变__index_level_0__列);None则智能处理——RangeIndex 丢弃、有名索引以原名保留、无名索引变为__index_level_0__。索引列名(idxvs__index_level_0__)会直接影响下游 schema 推断。

  3. 黄金文件是跨版本回归的"契约"。它把 PyArrow/pandas 升级时最容易静默漂移的整帧组装、索引去留、num_rows保持行为固化下来,并与Schema.from_pandas(schema 字段集合与可空性)、Table.from_pandas(chunk 数)两兄弟文件一起,覆盖 Spark 生产代码对"DataFrame 输入"构造函数的全部依赖面。理解这份矩阵,等于掌握了 PySpark 与 PyArrow 之间数据契约的最底层拼图。

【免费下载链接】sparkApache Spark - A unified analytics engine for large-scale data processing项目地址: https://gitcode.com/gh_mirrors/sp/spark

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 18:19:04

测试循环结构:边界值分析与自动化实践指南

做测试这行&#xff0c;天天跟循环结构打交道。不管你是写自动化用例、做接口测试、还是性能压测&#xff0c;凡是涉及到“把某个操作反复执行N遍再验证结果”的场景&#xff0c;就一定会碰到循环。我在这个行业摸爬滚打了十来年&#xff0c;见过太多新手在循环结构上栽跟头&am…

作者头像 李华
网站建设 2026/9/20 18:19:04

GitHub镜像站实测:clone与release下载加速全攻略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 18:17:20

昇腾910B部署Qwen3.5实战:vLLM Ascend性能调优与避坑指南

1. 为什么要在昇腾910B上折腾Qwen3.5把Qwen3.5这种量级的模型塞进昇腾910B跑起来&#xff0c;并且还要跑出接近GPU集群的吞吐&#xff0c;这件事在两年前基本属于"能跑就行"的阶段。现在情况变了——vLLM Ascend后端的成熟度已经足够支撑生产级推理&#xff0c;但真正…

作者头像 李华