AI 工具

Hugging Face:用 datasets 前先检查数据集许可证与来源

这篇讲用 datasets 库处理数据,但先做一件常被忽略的事:用之前先体检数据集。 licensecheck.py 只读取 Hub 的公开 API,检查数据集卡片上的许可证、是否依赖已被淘汰的加载脚本,以及数据行里 source 列指向的上游数据。 然后用自己编写的评价句子,按规范构建一个数据集:清洗、去重、ClassLabel、分层切分,输出 parqu

BINBHO · 图文教程

这篇讲用 datasets 库处理数据,但先做一件常被忽略的事:用之前先体检数据集。 license_check.py 只读取 Hub 的公开 API,检查数据集卡片上的许可证、是否依赖已被淘汰的加载脚本,以及数据行里 source 列指向的上游数据。 然后用自己编写的评价句子,按规范构建一个数据集:清洗、去重、ClassLabel、分层切分,输出 parquet 和数据卡。最后用 A 篇的模型做评测,得到准确率、混淆矩阵和错例。

先看清成品(box 上真实运行)

  • 体检 tyqiangz/multilingual-sentiments(也就是 A 篇模型的训练数据):许可证标注是 Apache-2.0,但检查出两个问题:依赖加载脚本;中文部分的 source 是 amazon_reviews_multi。
  • 自建数据集:原始 37 行,丢弃 1 行空行、1 行重复,切分成 train 23 行、test 12 行(每类各 4 条)。
  • A 篇模型在 35 条上的准确率是 77.1%:12 条中性句里有 7 条被判成了 positive。
  • pytest 4 项通过。

合规提醒:Hub 上每个模型和数据集都有自己的许可证,下载前先看卡片。本文用的模型 lxyuan/distilbert-base-multilingual-cased-sentiments-student 是 Apache-2.0,但它的卡片写明是在 tyqiangz/multilingual-sentiments 上蒸馏的,而这个数据集的中文部分来自 amazon_reviews_multi。后者的原始条款是“仅限非商业研究”,并且已经被数据提供方下架。所以商用前请自行评估训练数据的风险,本文只做本地学习演示。Token 不要写进代码或仓库。本文不列 Hugging Face 付费方案的价格,以官网为准。

运行说明:全部在 box 上真实运行。原计划直接用 tyqiangz/multilingual-sentiments 的中文部分做评测,但体检发现上游数据是“仅限非商业研究”的 Amazon 评论,而且已经被下架,所以改用自己编写的句子。没有执行 push_to_hub,因为那是对外发布操作。

做完之后你会得到什么?

成果 说明
license_check.py 数据集体检:许可证、gated、加载脚本、parquet 转换、上游来源
raw_reviews.csv 37 行自写评价,故意放了 1 行空行和 1 行重复
build_dataset.py 构建数据集,输出 data/train.parquet、data/test.parquet 和 data/README.md
eval_model.py 用 A 篇模型评测,输出准确率、混淆矩阵和错例
test_b.py 4 项测试(体检部分用替身 Hub,不联网)

需要准备哪些环境?

A 篇的环境,另外 pip install datasets pyarrow(box 上的版本:datasets 5.1.0、pyarrow 26.0.0)。评测需要 A 篇下载好的模型,用 MODEL_DIR 指定路径。

文件结构

56-huggingface-B-src/
├── license_check.py
├── raw_reviews.csv
├── build_dataset.py
├── eval_model.py
├── test_b.py
└── data/                 # build_dataset.py 生成
    ├── README.md
    ├── train.parquet
    └── test.parquet

第 1 步:体检

"""数据集“体检”:用之前先看许可证、来源数据集、是否依赖加载脚本、是否有自动转换的 parquet。
只读 Hub 公开 API,不下载数据。用法:python license_check.py <dataset_id> [...]
"""
import json, sys, urllib.parse, urllib.request

UA = {"User-Agent": "binbho-tutorial/1.0"}
RISKY = ("nc", "nd", "other", "unknown")          # 需要人工读条款的许可证关键字

def get(url):
    try:
        return json.load(urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=30))
    except urllib.error.HTTPError as e:
        return {"_error": e.code}

def check(ds_id):
    info = get(f"https://huggingface.co/api/datasets/{ds_id}")
    if "_error" in info:
        return [("ERROR", f"无法读取(HTTP {info['_error']}),可能是私有、需要同意条款或不存在")]
    card = info.get("cardData") or {}
    lic = card.get("license") or [t.split(":", 1)[1] for t in info.get("tags", []) if t.startswith("license:")]
    lic = [lic] if isinstance(lic, str) else (lic or [])
    out = []
    if not lic:
        out.append(("WARN", "卡片没有声明许可证"))
    for l in lic:
        lvl = "WARN" if any(k in l.lower() for k in RISKY) else "OK"
        out.append((lvl, f"许可证 {l}" + (":含非商用/禁演绎/自定义条款,先读原文" if lvl == "WARN" else "")))
    if l_sa := [l for l in lic if "sa" in l.lower().split("-")]:
        out.append(("INFO", f"{l_sa}:相同方式共享,衍生数据也要用同样许可证发布"))
    if info.get("gated"):
        out.append(("WARN", f"gated={info['gated']}:需要在网页上同意条款后才能访问"))
    files = [s["rfilename"] for s in info.get("siblings", [])]
    scripts = [f for f in files if f.endswith(".py") and "/" not in f]   # 只有根目录的脚本会被当作加载脚本
    if scripts:
        pq = get(f"https://huggingface.co/api/datasets/{ds_id}/parquet")
        out.append(("WARN", f"依赖加载脚本 {scripts}:新版 datasets 不再支持;"
                    + ("Hub 有自动转换的 parquet(refs/convert/parquet)" if "_error" not in pq else "也没有 parquet 转换")))
    # 来源:卡片字段 source_datasets,以及实际数据里常见的 source 列
    if card.get("source_datasets"):
        out.append(("INFO", f"source_datasets: {card['source_datasets']}"))
    srcs = set()
    for cfg in _configs(ds_id)[:MAX_CONFIGS]:                # 每个 config 抽前几行,来源可能因 config 而异
        first = get(f"https://datasets-server.huggingface.co/first-rows?dataset={urllib.parse.quote(ds_id)}"
                    f"&config={urllib.parse.quote(cfg)}&split={_split(ds_id, cfg)}")
        srcs |= {f"{cfg}:{r['row']['source']}" for r in first.get("rows", []) if "source" in r.get("row", {})}
    if srcs:
        out.append(("WARN", f"数据行里的 source 列:{sorted(srcs)}。上游数据有自己的条款,外层许可证不能覆盖它"))
    return out

MAX_CONFIGS = 12
_splits = {}
def _load_splits(ds_id):
    if ds_id not in _splits:
        sp = get(f"https://datasets-server.huggingface.co/splits?dataset={urllib.parse.quote(ds_id)}")
        _splits[ds_id] = [(x["config"], x["split"]) for x in sp.get("splits", [])]
    return _splits[ds_id]

def _configs(ds_id):
    seen = []
    for c, _ in _load_splits(ds_id):
        if c not in seen:
            seen.append(c)
    return seen

def _split(ds_id, cfg):
    names = [s for c, s in _load_splits(ds_id) if c == cfg]
    return "train" if "train" in names else names[0]

if __name__ == "__main__":
    worst = 0
    for ds in sys.argv[1:]:
        print(f"== {ds}")
        for lvl, msg in check(ds):
            print(f"  [{lvl}] {msg}")
            worst = max(worst, {"OK": 0, "INFO": 0, "WARN": 1, "ERROR": 2}[lvl])
    sys.exit(worst)
$ python license_check.py tyqiangz/multilingual-sentiments facebook/belebele stanfordnlp/imdb
== tyqiangz/multilingual-sentiments
  [OK] 许可证 apache-2.0
  [WARN] 依赖加载脚本 ['multilingual-sentiments.py']:新版 datasets 不再支持;Hub 有自动转换的 parquet(refs/convert/parquet)
  [WARN] 数据行里的 source 列:['all:malaya', 'arabic:sem_eval_2017', 'chinese:amazon_reviews_multi', 'english:sem_eval_2017', 'french:deft_2017', 'german:sb_10k', 'hindi:sail_2015', 'indonesian:indonlue/smsa', 'italian:sentipolc_2016', 'japanese:amazon_reviews_multi', 'malay:malaya', 'portuguese:sentiBR_2017']。上游数据有自己的条款,外层许可证不能覆盖它
== facebook/belebele
  [OK] 许可证 cc-by-sa-4.0
  [INFO] ['cc-by-sa-4.0']:相同方式共享,衍生数据也要用同样许可证发布
== stanfordnlp/imdb
  [WARN] 许可证 other:含非商用/禁演绎/自定义条款,先读原文
  [INFO] source_datasets: ['original']

看到 amazon_reviews_multi 之后,我去读了 Hub 上 defunct-datasets/amazon_reviews_multi 的卡片。原文写着 “Amazon has licensed this dataset under its own agreement for non-commercial research usage only”,并且说明该数据集“is defunct and no longer accessible due to the decision of data providers”。外层仓库标注的 Apache-2.0 不能改变上游数据的条款,所以本文不使用它。

第 2 步:构建数据集

text,label
物流很快,第二天就到了,包装也结实。,positive
质量比想象中好,做工很细。,positive
客服回复及时,问题很快解决了。,positive
已经回购三次了,家里人都喜欢。,positive
尺码标准,穿着很舒服。,positive
性价比很高,推荐给朋友了。,positive
电池很耐用,充一次能用两天。,positive
声音清晰,降噪效果超出预期。,positive
安装简单,说明书写得很清楚。,positive
颜色和图片一模一样,很漂亮。,positive
味道很好,孩子很爱吃。,positive
屏幕显示细腻,看视频很舒服。,positive
收到了,还没开始用。,neutral
和描述基本一致,没什么特别的。,neutral
价格中规中矩,质量也一般。,neutral
快递是周三到的。,neutral
包装是普通纸箱。,neutral
用了几天,暂时看不出好坏。,neutral
颜色是深灰色,和别的店差不多。,neutral
尺寸是标准尺寸。,neutral
这款是去年的型号。,neutral
说明书是英文的。,neutral
还可以,符合这个价位。,neutral
发货地是杭州。,neutral
用了两天就坏了,非常失望。,negative
客服一直不回消息,体验很差。,negative
和图片差别太大,明显是旧货。,negative
味道很刺鼻,不敢给孩子用。,negative
物流慢了一个星期,还被压坏了。,negative
尺码偏小两号,退货还要自己付运费。,negative
充电器发烫,担心安全问题。,negative
做工粗糙,边缘还有毛刺。,negative
声音有杂音,耳机戴着耳朵疼。,negative
屏幕有坏点,换货流程很麻烦。,negative
电池半天就没电了,完全不能用。,negative
用了两天就坏了,非常失望。,negative
  ,neutral
"""把自己标注的 CSV 做成规范的 Hugging Face 数据集:清洗 → 去重 → ClassLabel → 分层切分 → parquet + 数据卡。"""
import json, pathlib, sys
from datasets import ClassLabel, load_dataset

LABELS = ["positive", "neutral", "negative"]   # 与 A 篇模型的标签顺序和名称一致
OUT = pathlib.Path("data")

def build(src="raw_reviews.csv", seed=42, test_size=0.34):
    ds = load_dataset("csv", data_files=src, split="train")
    n0 = len(ds)
    ds = ds.map(lambda r: {"text": (r["text"] or "").strip()})
    ds = ds.filter(lambda r: len(r["text"]) >= 2)
    n_empty = n0 - len(ds)
    seen = set()
    def first_time(r):
        if r["text"] in seen:
            return False
        seen.add(r["text"]); return True
    ds = ds.filter(first_time)               # 单进程运行,seen 集合才可靠
    n_dup = n0 - n_empty - len(ds)
    cl = ClassLabel(names=LABELS)
    # 字符串标签不能直接 cast 成 ClassLabel(见调试示例),先用 str2int 转成整数;未知标签会抛 ValueError
    ds = ds.map(lambda r: {"label": cl.str2int(r["label"].strip())})
    ds = ds.cast_column("label", cl)
    sp = ds.train_test_split(test_size=test_size, seed=seed, stratify_by_column="label")
    stats = {"raw": n0, "dropped_empty": n_empty, "dropped_dup": n_dup,
             **{k: {"rows": len(v), "per_label": {LABELS[i]: v["label"].count(i) for i in range(3)}} for k, v in sp.items()}}
    return sp, stats

CARD = """---
license: cc0-1.0
language: [zh]
task_categories: [text-classification]
size_categories: [n<1K]
configs:
- config_name: default
  data_files:
  - split: train
    path: train.parquet
  - split: test
    path: test.parquet
---
# 自写商品评价情感样例

- 来源:作者自己编写的示例句子,不来自任何平台的真实评价。
- 标签:positive / neutral / negative(ClassLabel,顺序固定)。
- 处理:去首尾空白、丢弃空行、按文本去重、按标签分层切分(seed=42)。
- 统计:{stats}
- 用途:教学演示。样本量很小,不能用来下结论或训练生产模型。
"""

if __name__ == "__main__":
    sp, stats = build(*sys.argv[1:2])
    OUT.mkdir(exist_ok=True)
    for k, v in sp.items():
        v.to_parquet(OUT / f"{k}.parquet")
    (OUT / "README.md").write_text(CARD.format(stats=json.dumps(stats, ensure_ascii=False)), encoding="utf-8")
    print(json.dumps(stats, ensure_ascii=False, indent=1))
$ python build_dataset.py
{"raw": 37, "dropped_empty": 1, "dropped_dup": 1,
 "train": {"rows": 23, "per_label": {"positive": 8, "neutral": 8, "negative": 7}},
 "test": {"rows": 12, "per_label": {"positive": 4, "neutral": 4, "negative": 4}}}

第 3 步:评测

"""用 A 篇下载的本地模型评测自建数据集:准确率 + 混淆矩阵 + 错例。不训练,所以 train/test 都可以拿来评测。"""
import os, sys
os.environ.setdefault("HF_HUB_OFFLINE", "1")
from datasets import load_dataset, concatenate_datasets
from transformers import pipeline

MODEL_DIR = os.environ.get("MODEL_DIR", "../56-huggingface-A-src/models/sentiment")

def evaluate(model_dir=MODEL_DIR, data_dir="data"):
    dd = load_dataset(data_dir)                       # 按数据卡里的 configs 读取 parquet
    ds = concatenate_datasets([dd["train"], dd["test"]])
    names = ds.features["label"].names
    clf = pipeline("text-classification", model=model_dir, tokenizer=model_dir, device=-1)
    texts = list(ds["text"])                          # datasets 5.x 的 ds["text"] 是 Column 对象,不是 list
    preds = [p["label"] for p in clf(texts, batch_size=16, truncation=True)]
    gold = [names[i] for i in ds["label"]]
    cm = {g: {p: 0 for p in names} for g in names}
    for g, p in zip(gold, preds):
        cm[g][p] += 1
    wrong = [(t, g, p) for t, g, p in zip(texts, gold, preds) if g != p]
    return sum(g == p for g, p in zip(gold, preds)) / len(gold), cm, wrong, names

if __name__ == "__main__":
    acc, cm, wrong, names = evaluate(*sys.argv[1:2])
    print(f"准确率 {acc:.1%}  (n={sum(sum(r.values()) for r in cm.values())})")
    print("真实\\预测 " + " ".join(f"{n:>9}" for n in names))
    for g in names:
        print(f"{g:<10}" + " ".join(f"{cm[g][p]:>9}" for p in names))
    for t, g, p in wrong:
        print(f"  ✗ {t}  期望 {g}  实际 {p}")
$ MODEL_DIR=../56-huggingface-A-src/models/sentiment python eval_model.py
准确率 77.1%  (n=35)
真实\预测  positive   neutral  negative
positive         12         0         0
neutral           7         5         0
negative          0         1        10
  ✗ 包装是普通纸箱。  期望 neutral  实际 positive
  ✗ 尺码偏小两号,退货还要自己付运费。  期望 negative  实际 neutral
  ✗ 这款是去年的型号。  期望 neutral  实际 positive
  ✗ 尺寸是标准尺寸。  期望 neutral  实际 positive
  ✗ 发货地是杭州。  期望 neutral  实际 positive
  ✗ 收到了,还没开始用。  期望 neutral  实际 positive
  ✗ 快递是周三到的。  期望 neutral  实际 positive
  ✗ 还可以,符合这个价位。  期望 neutral  实际 positive

结论:这个模型容易把客观陈述判成正面。如果你的业务很看重“中性”这一类,要么在 A 篇的复核阈值上多放一些样本给人工,要么用自己的数据微调。35 条样本太少,只能说明这个现象存在,不能作为准确率指标。

第 4 步:测试

import pytest
import build_dataset, license_check

def test_build_cleans_and_stratifies(tmp_path):
    sp, st = build_dataset.build("raw_reviews.csv")
    assert st["dropped_empty"] == 1 and st["dropped_dup"] == 1
    assert st["test"]["per_label"] == {"positive": 4, "neutral": 4, "negative": 4}
    assert sp["train"].features["label"].names == ["positive", "neutral", "negative"]
    assert not set(sp["train"]["text"]) & set(sp["test"]["text"])      # 去重后切分,不会泄漏

def test_unknown_label_rejected(tmp_path):
    p = tmp_path / "bad.csv"
    p.write_text("text,label\n很好,postive\n", encoding="utf-8")
    with pytest.raises(ValueError):
        build_dataset.build(str(p))

def fake_hub(info, splits=(), rows=None):
    def get(url):
        if "/parquet" in url: return {"default": {}}
        if "/splits" in url: return {"splits": [{"config": c, "split": "train"} for c in splits]}
        if "first-rows" in url: return {"rows": [{"row": r} for r in (rows or [])]}
        return info
    return get

def test_flags_script_and_upstream_source(monkeypatch):
    info = {"cardData": {"license": "apache-2.0"}, "siblings": [{"rfilename": "x.py"}, {"rfilename": "unused/y.py"}]}
    monkeypatch.setattr(license_check, "get", fake_hub(info, ["zh"], [{"text": "a", "source": "amazon_reviews_multi"}]))
    license_check._splits.clear()
    msgs = license_check.check("demo/ds")
    text = " ".join(m for _, m in msgs)
    assert "['x.py']" in text and "y.py" not in text          # 子目录里的 .py 不算加载脚本
    assert "zh:amazon_reviews_multi" in text

def test_nc_license_warns(monkeypatch):
    monkeypatch.setattr(license_check, "get", fake_hub({"cardData": {"license": "cc-by-nc-4.0"}, "siblings": []}))
    license_check._splits.clear()
    assert ("WARN", "许可证 cc-by-nc-4.0:含非商用/禁演绎/自定义条款,先读原文") in license_check.check("demo/nc")
$ python -m pytest -q
4 passed in 0.59s

调试示例

1. 加载脚本已不受支持。 直接加载时报错:

>>> load_dataset("tyqiangz/multilingual-sentiments", "chinese")
RuntimeError: Dataset scripts are no longer supported, but found multilingual-sentiments.py

Hub 会把这类数据集自动转换成 parquet,放在 refs/convert/parquet 分支上。用 load_dataset("parquet", data_files="hf://datasets/<repo>@<该分支的 sha>/chinese/test/0000.parquet") 就能读到 3000 行,而且全部是 amazon_reviews_multi。这正是我放弃使用它的原因,体检脚本也因此加上了“加载脚本”和“source 列”两项检查。

2. 体检脚本第一版漏报,还误报了一次。 第一版只抽样了第一个 config(arabic),source 显示的是 sem_eval_2017,没有发现中文部分来自 Amazon;同时把 belebele 的 unused/belebele.py 当成了加载脚本。我改成遍历所有 config 各抽几行,并且只把根目录下的 .py 当作加载脚本。改完后的输出见第 1 步。

3. 字符串标签不能直接 cast 成 ClassLabel。 执行 cast_column("label", ClassLabel(names=...)) 时报错 ArrowInvalid: Failed to parse string: 'positive' as a scalar of type int64。解决办法是先用 ClassLabel.str2int 把标签 map 成整数,拼错的标签(比如 postive)会直接抛出 ValueError,测试里也覆盖了这种情况。

4. ds["text"] 不是 list。 把它传给 pipeline 时报错 ValueError: text input must be of type str ...。检查后发现,在 datasets 5.x 里,type(ds["text"]) 返回的是 datasets.arrow_dataset.Column。用 list() 包一层就解决了。

常见问题

现象 原因 处理
Dataset scripts are no longer supported 仓库依赖加载脚本 读取 refs/convert/parquet 分支,并固定 sha
外层许可证是 Apache,数据却不能商用 数据重新打包后,上游条款仍然有效 检查 source 列和 source_datasets,去读上游条款
切分后某一类没有样本 没有做分层切分 用 stratify_by_column,前提是标签列已经是 ClassLabel
去重结果不稳定 多进程 filter 时 seen 集合不共享 去重在单进程里做,或者改用 pandas 的 drop_duplicates
load_dataset("data") 读错了文件 数据卡里没有写 configs 在 README 的 YAML 头部写明 data_files

模板规则

  • 用一个数据集之前,先检查许可证、gated 状态和上游来源,结果记在数据卡里。
  • 标签用 ClassLabel 并固定顺序,切分用 seed 和分层。
  • 评测时一定要输出混淆矩阵和错例,只看准确率会漏掉问题。

项目资源

用途 地址
Hugging Face Hub 文档 https://huggingface.co/docs/hub/index
许可证说明 https://huggingface.co/docs/hub/repositories-licenses
datasets 加载指南 https://huggingface.co/docs/datasets/loading
Parquet 自动转换(Dataset Viewer) https://huggingface.co/docs/dataset-viewer/parquet
数据集卡片 https://huggingface.co/docs/hub/datasets-cards
amazon_reviews_multi(已下架,卡片里有条款说明) https://huggingface.co/datasets/defunct-datasets/amazon_reviews_multi

先体检数据再处理,可以避免把来源有问题的数据带进模型和产品里。