Skip to content

Parquet 导出

把 Potato 标注导出为 Apache Parquet,一种列式格式,可用 pandas、DuckDB、Spark、Polars 和 Hugging Face Datasets 做大规模分析。

v2.3.0 新增

Apache Parquet 是一种为分析型负载而生的列式存储格式。对于大型标注数据集,它的压缩率优于 JSON 或 CSV,读取部分列时不必碰文件的其余部分,并且在 pandas、DuckDB、PyArrow、Spark、Polars 和 Hugging Face Datasets 中都能原生加载。

Parquet 导出需要 PyArrow:

bash
pip install "pyarrow>=12.0.0"

运行导出

Parquet 是 Potato 已注册的导出格式之一,所以导出命令行工具可以直接写出它:

bash
python -m potato.export -c config.yaml -f parquet -o ./parquet_export/

Potato 会读取配置中 output_annotation_dir 下记录的标注,并把 Parquet 表写进你指定的输出目录。

定时导出

长期运行的研究通常希望拿到 Parquet 快照,而不必靠人记得去跑命令行。设置 export_annotation_format 可以让 Potato 在标注进行期间定期写出导出文件:

yaml
output_annotation_dir: "output/"
 
# A list is accepted, so Parquet can sit alongside another format
export_annotation_format:
  - parquet
  - jsonl
 
# Seconds between automatic exports (default: 60)
auto_export_interval: 300

自动导出会落在 <output_annotation_dir>/exports/<format>/,所以 Parquet 表会出现在 output/exports/parquet/ 下。无论这项设置如何,标注本身始终存放为 <output_annotation_dir>/<user>/user_state.json。

较旧的 output_annotation_format 键已废弃。Potato 会把它当作 export_annotation_format 读取并记录一条警告,所以还是改名为好。

输出文件

一次导出最多产生四张表,各自覆盖数据的不同层级。

annotations.parquet

每一行对应一个(实例,标注者)组合,各方案被展平成列。

列类型说明
instance_idstring被标注条目的 ID
user_idstring标注者的 ID
<schema_name>不定每个标注方案一列

列的类型跟随标注类型:radio 和 select 变成保存所选标签的字符串,likert、slider 和 number 变成 float64(如果刻度带命名标签则为字符串),multiselect 变成字符串列表,text 变成字符串。

一次存下多个答案的方案,每个子答案得到一列,命名为 <schema>.<key>。一个名为 handling 的 multirate 方案会产生 handling.Urgency 和 handling.Customer tone;constant_sum 和 soft_label 的展开方式相同。单选仍然只占一列,因为在数据框里真正好用的就是 severity 里存着 "Serious"。

每一列都会出现在每一行上,标注者没有回答的地方填 null,所以只有部分标注者做到的方案仍然存在于文件中。

spans.parquet

每一行对应一条 span 标注,当研究中含有 span 方案时写出。

列类型说明
instance_idstring被标注条目的 ID
user_idstring标注者的 ID
schema_namestringspan 方案的名称
startintspan 起始处的字符偏移量
endintspan 结束处的字符偏移量
labelstring该 span 的标签
textstring该 span 的文本内容
target_fieldstringspan 是在哪个显示字段中标出的,单字段页面上为 ""
titlestring该标签的显示标题
span_idstring所存 span 的 id,形如 schema_label_start_end
format_coordsstringJSON:PDF span 的页码和方框,电子表格 span 的行和列
additional_partsstringJSON:不连续 span 的其余区间
kb_id、kb_source、kb_labelstring已链接的 span 所指向的实体

只要一项研究有多个 span 目标,就要读 target_field。单个 span 方案会在页面上每个 span 目标字段中记录,所以同一个方案内部字段也会变化,而 span_id 无法还原它:同样的词在两个字段中被标出,产生的行在其余部分完全相同。

text 是导出时由偏移量推出来的,而不是存下来的,所以它永远不会与偏移量不一致。

items.parquet

每一行对应一个源条目,承载原始数据。

列类型说明
item_idstring条目的 ID
<field_name>不定原始数据中每个字段一列(嵌套的字典和列表会被 JSON 序列化)

phase_responses.parquet

当研究收集了阶段数据(例如知情同意书或研究后问卷),并且配置把这些数据纳入导出时才会写出。一项既收集知情同意又收集研究后问卷的研究,如果没有纳入阶段数据,这张表一行也不会有,所以在认定文件存在之前先检查 export_include_phase_data。

导出选项

通过 --option key=value 传入格式选项,每个选项重复一次该参数:

bash
python -m potato.export -c config.yaml -f parquet -o ./parquet_export/ \
    --option compression=zstd --option include_items=false
选项默认值说明
compressionsnappy压缩编码:snappy、gzip、zstd、lz4 或 none
include_itemstrue写出带源数据的 items.parquet
include_spanstrue当存在 span 标注时写出 spans.parquet
row_group_sizePyArrow 默认值annotations.parquet 的行组大小

对多数项目来说 snappy 是合适的默认值,因为它解压足够快,读取仍然是交互式的。当数据集大到文件体积变得重要时选 zstd,它在速度相近的情况下压缩效果好得多。

加载 Parquet 数据

pandas

python
import pandas as pd
 
annotations = pd.read_parquet("parquet_export/annotations.parquet")
spans = pd.read_parquet("parquet_export/spans.parquet")
items = pd.read_parquet("parquet_export/items.parquet")
 
# One column per schema, so a schema is selected by name
print(annotations["sentiment"].value_counts())
 
# Compute inter-annotator agreement on the sentiment schema
from sklearn.metrics import cohen_kappa_score
pivot = annotations.pivot(index="instance_id", columns="user_id", values="sentiment")
kappa = cohen_kappa_score(pivot.iloc[:, 0], pivot.iloc[:, 1])

DuckDB

sql
-- Direct query without loading into memory
SELECT instance_id, sentiment, COUNT(*) AS annotator_count
FROM 'parquet_export/annotations.parquet'
GROUP BY instance_id, sentiment
ORDER BY annotator_count DESC;
 
-- Join annotations with the source items
SELECT a.instance_id, i.text, a.sentiment, a.user_id
FROM 'parquet_export/annotations.parquet' a
JOIN 'parquet_export/items.parquet' i
  ON a.instance_id = i.item_id;

PyArrow

python
import pyarrow.parquet as pq
 
# Read specific columns only (fast for wide tables)
table = pq.read_table(
    "parquet_export/annotations.parquet",
    columns=["instance_id", "user_id", "sentiment"]
)
 
df = table.to_pandas()
 
parquet_file = pq.ParquetFile("parquet_export/annotations.parquet")
print(f"Row groups: {parquet_file.metadata.num_row_groups}")
print(f"Total rows: {parquet_file.metadata.num_rows}")

Hugging Face Datasets

python
from datasets import load_dataset
 
dataset = load_dataset("parquet", data_files={
    "annotations": "parquet_export/annotations.parquet",
    "spans": "parquet_export/spans.parquet",
    "items": "parquet_export/items.parquet",
})
 
print(dataset["annotations"][0])
 
dataset["annotations"].push_to_hub("my-org/my-annotations", split="train")

Potato 还自带一个 huggingface 导出器,可以直接构建同样的数据划分;当目的地就是 Hub 时,这样可以省掉中间文件。

Polars

python
import polars as pl
 
annotations = pl.read_parquet("parquet_export/annotations.parquet")
 
label_counts = (
    annotations
    .group_by("sentiment")
    .agg(pl.len().alias("count"))
    .sort("count", descending=True)
)
print(label_counts)

完整示例

一个 span 标注项目,把 JSONL 保留为工作格式,同时写出 Parquet 供分析使用:

yaml
annotation_task_name: "NER Annotation Project"
task_dir: "."
 
data_files:
  - "data/documents.jsonl"
 
item_properties:
  id_key: doc_id
  text_key: text
 
annotation_schemes:
  - annotation_type: span
    name: entities
    description: "Highlight named entities"
    labels:
      - name: PERSON
      - name: ORGANIZATION
      - name: LOCATION
 
output_annotation_dir: "output/"
 
# Both formats are written periodically to output/exports/<format>/
export_annotation_format:
  - jsonl
  - parquet
auto_export_interval: 300
 
user_config:
  allow_all_users: true

标注完成后,携带实体偏移量的是 span 表:

python
import pandas as pd
 
spans = pd.read_parquet("output/exports/parquet/spans.parquet")
 
# Entity type distribution
print(spans["label"].value_counts())
 
# Average span length by type
spans["length"] = spans["end"] - spans["start"]
print(spans.groupby("label")["length"].mean())

延伸阅读

有关实现详情,请参阅源文档。