将标注导出到 Hugging Face Datasets
用一条导出命令把 Potato 标注推送到 Hugging Face Hub,再把导出的各个 split 整理成分类、NER、音频和图像训练所需的形状。
一条命令就能把 Potato 标注作为数据集推送到 Hugging Face Hub,其中包含一个 annotations split、可选的 spans 和 items split,以及一份自动生成的数据集卡片。
训练代码读取的是 Hugging Face 数据集,而研究进行期间 Potato 并不按这种形状保存标注。标注过程中它保存的是每个用户的状态,所以磁盘上的文件是围绕标注者组织的,而不是围绕训练样本。导出这一步就是两种形状相遇的地方,而 Potato 自带一个 Hub 导出器,你不必自己写这个转换。
标注过程中 Potato 写了什么
Potato 把每位标注者的工作保存为 output_annotation_dir 下的一个 user_state.json 文件,每个用户一个目录:
annotation_output/
├── user1/
│ └── user_state.json
├── user2/
│ └── user_state.json
└── ...
这些文件是实时记录,不是导出格式。下游的一切都通过导出 CLI 来读取它们,而这也正是把 schema 名称、span 偏移量和原始条目字段统一对齐起来的地方。
用内置导出器推送到 Hub
Hub 导出器需要可选依赖,通过 huggingface extra 安装:
pip install potato-annotation[huggingface]一条命令即可构建数据集并推送。--output 的值是 Hub 上的仓库 id,token 需要写权限:
python -m potato.export \
--config config.yaml \
--format huggingface \
--output your-org/my-annotations \
--option token=hf_xxx如果你不想在命令行里传 token,可以在环境变量中设置 HF_TOKEN,或者运行 huggingface-cli login。另有四个选项控制上传内容:private 创建私有数据集,commit_message 替换默认的提交信息,而 include_items 和 include_spans(默认都是 true)决定源条目和 span 标注是否随标签一起上传。
最终落在 Hub 上的数据集最多包含三个 split。annotations split 中每个 (instance_id, user_id) 组合一行,每个标注 schema 一列。spans split 中每条 span 标注一行,带有 instance_id、user_id、schema_name、start、end、label 和 text。items split 中每个原始数据条目一行,以 item_id 为键,你的其余源字段与之并列。Potato 还会生成一份数据集卡片,说明各个 schema 及其标签,统计标注记录数,并附上用法片段和指回项目的链接。
把它读回来就是普通的 load_dataset 调用:
from datasets import load_dataset
ds = load_dataset("your-org/my-annotations")
for row in ds["annotations"]:
print(row["instance_id"], row["sentiment"])如果你想在自己动手之前先看看数据的形状,有两个公开的导出示例:Blablablab/potato-demo-single-choice-annotations 和 Blablablab/potato-demo-agent-trace-annotations。
为训练任务重整导出结果
Hub 导出保留的是每个标注者对每个条目各一行,这对研究分歧是合适的形状,对 Trainer 则是错误的形状。当你需要训练样本时,改为导出 JSONL 再在本地重整。JSONL 导出器会在输出目录里写入 annotations.jsonl,每个 (instance_id, user_id) 组合一条记录:
python -m potato.export --config config.yaml --format jsonl --output ./export/每条记录带有 instance_id、user_id、labels、spans 和 links。span 在导出时已经把它所覆盖的文本切好了,所以你不必再拿偏移量去对源文件重新求解。
import json
records = []
with open("export/annotations.jsonl") as f:
for line in f:
records.append(json.loads(line))
print(records[0]["instance_id"], records[0]["labels"])每个条目一个训练样本
如果每个条目有三位标注者,分类器需要的是每个条目一个标签,所以得先把按标注者分开的行折叠起来。多数投票是常用做法,而把标注者数量一并留着,可以让你丢掉投票人数太少的条目:
from collections import defaultdict
from statistics import mode
from datasets import Dataset
by_item = defaultdict(list)
for rec in records:
by_item[rec["instance_id"]].append(rec["labels"]["sentiment"])
rows = [
{"id": item_id, "label": mode(labels), "num_annotators": len(labels)}
for item_id, labels in by_item.items()
]
dataset = Dataset.from_list([r for r in rows if r["num_annotators"] >= 2])按标注者分开的行也要留着。标注者产生分歧的条目才是值得细读的,而当任务确实带有主观性时,用软标签训练出的模型往往胜过用多数投票训练的模型。
附上条目文本
标签行是按 id 引用条目的,所以文本必须来自 items split 或你自己的数据文件:
items = {row["item_id"]: row for row in load_dataset("your-org/my-annotations")["items"]}
dataset = Dataset.from_list([
{"text": items[r["id"]]["text"], "label": r["label"]}
for r in rows
if r["id"] in items
])修正标签类型
字符串标签列也能训练,但它丢掉了类别顺序,也丢掉了模型配置里的类别名。转成 ClassLabel 可以把两者都补回来,而这些名称必须和你的 Potato schema 输出的标签一致:
from datasets import ClassLabel
dataset = dataset.cast_column(
"label",
ClassLabel(names=["Positive", "Negative", "Neutral"])
)训练、验证和测试划分
按条目划分,而不是按标注行划分。按行划分会把两位标注者对同一条目的判断分到不同的划分里,等于泄露了答案:
from datasets import DatasetDict
split = dataset.train_test_split(test_size=0.2, seed=42)
held_out = split["test"].train_test_split(test_size=0.5, seed=42)
dataset = DatasetDict({
"train": split["train"],
"validation": held_out["train"],
"test": held_out["test"],
})把 span 标注变成 token 标签
序列标注模型读的是每个 token 一个标签,而 spans split 存的是字符偏移量。无论 span 来自 span schema 还是来自导入的语料,转换方式都一样,只是需要一个能把偏移量映射回字符的分词器:
def to_iob(text, spans, tokenizer):
encoding = tokenizer(text, return_offsets_mapping=True)
tags = ["O"] * len(encoding["input_ids"])
for span in spans:
for i, (start, end) in enumerate(encoding["offset_mapping"]):
if start == end:
continue
if start >= span["start"] and end <= span["end"]:
prefix = "B" if start == span["start"] else "I"
tags[i] = f"{prefix}-{span['label']}"
return encoding["input_ids"], tags先按 instance_id 把 span 分好组,因为导出结果是每条 span 一行,而不是每个条目一行。
音频列和图像列
音频和图像任务在条目字段里存的是一个路径或一个 URL,当这一列被转成对应的特征类型时,datasets 就会加载相应的媒体文件:
from datasets import Audio, Image
audio_ds = audio_ds.cast_column("audio", Audio(sampling_rate=16000))
image_ds = image_ds.cast_column("image", Image())采样率必须和模型的预期一致,所以选定之前先查一下模型卡片。转换是在读取时重采样,而不是重写文件。
数据集卡片里该记录什么
自动生成的卡片涵盖了各个 schema、标签集合和记录数。其余部分是复用这份数据集的人会问到的内容,而趁研究还新鲜时写下来要容易得多:
---
license: cc-by-4.0
task_categories:
- text-classification
language:
- en
---
## Annotation process
Who annotated, how many annotators per item, how they were recruited and paid,
and what the guidelines said. Link the guidelines if they are public.
## Agreement
Which coefficient you computed, over which items, and what it came out at.
## Label definitions
One sentence per label, in the words the annotators saw.许可证要写在 frontmatter 里,而不是留给别人去猜,因为没有许可证的数据集别人无法放心使用。标签发生变化时给仓库打上版本,这样论文才能引用它所用数据的那个状态。
把结果载入训练代码
现在这个数据集已经是 Trainer 期望的形状,从导出到训练循环之间没有任何环节需要再动它:
from transformers import Trainer
trainer = Trainer(
model=model,
train_dataset=dataset["train"],
eval_dataset=dataset["validation"],
)导出器的完整选项列表,以及从 Hub 回到一个新 Potato 任务的往返流程,见 Hugging Face 导出文档。Potato 写出的其他格式见数据格式。