07_Hugging Face生态使用

1 概述

Hugging Face 是一个专注于人工智能的开源生态平台,类似 AI 界的 GitHub + 应用商店,提供了丰富的模型、数据集、应用、工具等资源。

Hugging Face 生态核心包括:

  • Transformers:最核心的库,支持主流深度学习框架,包含大量预训练模型,比如 BERT、GPT、RoBERTa 等。
    • AutoModel:预训练模型,包含多种模型,比如 BERT、GPT、RoBERTa 等。
    • Tokenizers:每个模型都有自己搭配的分词器,支持分词、编解码、填充、截断、掩码等操作。
  • Datasets:数据集库,提供丰富的数据集,可以高效加载和处理数据,支持多种数据格式,如 CSV、JSON、Arrow 等。
  • Hugging Face Hub:模型仓库,提供模型、数据集、应用托管服务,可以方便地上传和下载。

使用 pip install transformers datasets 安装。

2 预训练模型

和 Transformers 这个库的名字一样,这个库提供了大量基于 Transformer 的预训练模型,以及训练和加载预训练模型所需的工具。

2.1 AutoModel

Transformers 包含大量的预训练模型,每个模型本质上都是一个基于 nn.Module 的类,如果加载每种模型都使用各自的加载方法,就十分麻烦。为了简化这一流程,Transformers 提供了统一的模型加载接口 AutoModel,用于自动下载和加载模型。

在 Hugging Face Hub 上找好所需的模型,只需要将模型的标识名称传给 AutoModel.from_pretrained() 方法即可,当然,HF 模型页面一般也会提供 Use this model 的加载代码。

1
2
3
4
5
6
from transformers import AutoModel

bert_model = AutoModel.from_pretrained("google-bert/bert-base-chinese")

# 也可以从本地目录加载
# model = AutoModel.from_pretrained("pretrained_model")

上述代码执行时,会去本地目录寻找模型文件,如果找不到,会去 Hugging Face Hub 下载所需的模型文件,这些文件会缓存在本地目录,之后再加载时会直接从本地目录加载。

执行下方代码可以查看模型缓存的位置:

1
2
from transformers import TRANSFORMERS_CACHE
print(TRANSFORMERS_CACHE)
/Users/zhubaoduo/.cache/huggingface/hub

存储结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
models--google-bert--bert-base-chinese <= models--组织--模型

├── blobs/ <-- 【仓库:实际存储区】
│ │ (文件名全是乱码般的 Hash 值)
│ ├── 56216b3f... <-- 这可能是真实的 model.safetensors
│ ├── a5b6c7d8... <-- 这可能是真实的 config.json
│ └── ... (其他大文件)

├── refs/ <-- 【指针:分支信息】
│ └── main <-- 记录最新的 Commit ID

└── snapshots/ <-- 【快照:实际加载区】

└── 32f4a1... (Commit ID ) <-- 对应某个特定版本的文件夹
│ (软链接文件,指向 blobs 中的文件)
├── config.json ──> ../../blobs/a5b6c7d8...
├── model.safetensors ──> ../../blobs/56216b3f...

如果不想缓存到默认目录,可以通过设置 cache_dir 参数临时指定缓存目录。

1
2
3
4
model = AutoModel.from_pretrained(
"google-bert/bert-base-chinese",
cache_dir="MyExternalDrive/hf_cache"
)

或者设置全局生效的环境变量,在 .zshrc 文件中添加:

1
export HF_HOME=/MyExternalDrive/hf_cache

Transformers 创建预训练模型的流程:

  1. 根据 config.json 文件中的配置信息,自动识别模型类型,并自动实例化对应的模型类(创建的对象本质就是一个标准的神经网络模型)。
  2. model.safetensors 文件中加载权重参数。
  3. 模型创建成功,已经可以直接用于推理或微调。

使用 type() 可以看到 BERT 的模型类型为 BertModel

1
type(bert_model)
transformers.models.bert.modeling_bert.BertModel

使用 config 可以查看模型的配置信息。

1
bert_model.config
BertConfig {
  "architectures": [
    "BertForMaskedLM"
  ],
  "attention_probs_dropout_prob": 0.1,
  "classifier_dropout": null,
  "directionality": "bidi",
  "dtype": "float32",
  "hidden_act": "gelu",
  "hidden_dropout_prob": 0.1,
  "hidden_size": 768,
  "initializer_range": 0.02,
  "intermediate_size": 3072,
  "layer_norm_eps": 1e-12,
  "max_position_embeddings": 512,
  "model_type": "bert",
  "num_attention_heads": 12,
  "num_hidden_layers": 12,
  "pad_token_id": 0,
  "pooler_fc_size": 768,
  "pooler_num_attention_heads": 12,
  "pooler_num_fc_layers": 3,
  "pooler_size_per_head": 128,
  "pooler_type": "first_token_transform",
  "position_embedding_type": "absolute",
  "transformers_version": "4.57.3",
  "type_vocab_size": 2,
  "use_cache": true,
  "vocab_size": 21128
}

2.2 AutoModelForXxx

AutoModel 只会加载模型的主干结构,不包含任何任务相关的输出层,也就是说没有特定任务,适合自定义模型结构,处理后续输出。

Transformers 还提供了很多有 任务头(Task Head) 的模型,也就是可以用于下游任务的模型AutoModelForXxx。在模型主干的基础上,添加了适配特定任务的输出层,使模型能够直接用于文本分类、实体识别等标准 NLP 任务。

常见任务对应的 Auto 模型:

  • AutoModelForSequenceClassification:序列分类任务,如情感分析。
  • AutoModelForTokenClassification:序列标注任务,如词性标注。
  • AutoModelForQuestionAnswering:抽取式问答任务,如阅读理解。
  • AutoModelForSeq2SeqLM:序列到序列任务,如机器翻译。

AutoModelForXxx 的用法和 AutoModel 类似,这里做一个简单的演示。下面直接使用 bert-base-chinese 进行演示,由于该模型并不是预训练好的分类模型,会提示缺少权重,Transformers 会自动初始化了输出层的权重,用于后续训练和微调。

1
2
3
4
5
6
from transformers import AutoModelForSequenceClassification

classifier_model = AutoModelForSequenceClassification.from_pretrained(
"google-bert/bert-base-chinese",
num_labels=10 # 可以指定模型分类数
)
Some weights of BertForSequenceClassification were not initialized from the model checkpoint at google-bert/bert-base-chinese and are newly initialized: ['classifier.bias', 'classifier.weight']
You should probably TRAIN this model on a down-stream task to be able to use it for predictions and inference.

使用type可以看出这次是一个BertForSequenceClassification类型。

1
type(classifier_model)
transformers.models.bert.modeling_bert.BertForSequenceClassification

直接使用 print(model) 可以输出模型的结构,分别查看上面两个模型,可以发现BertForSequenceClassification模型结构仅仅比BertModel多如下结构,核心就是一个全连接层,用于分类。

1
2
(dropout): Dropout(p=0.1, inplace=False)
(classifier): Linear(in_features=768, out_features=3, bias=True)

2.3 输入输出格式

由于 Transformers 的预训练模型都继承 nn.Module 类,推理过程通过forward前向传播。每个模型都有自己的输入输出格式,具体使用方法可以查看直接每个模型的 forward 方法,或者查阅官方文档 Hugging Face Transformers

这里以 BertModel 为例介绍基本使用方法。

BertModel 的标准输入是一个字典,通常包含以下三个核心 Tensor。假设 batch_size 为 B,seq_len 为 L。

键名 形状 描述
input_ids (B,L) 核心输入,词元对应的词表索引。
attention_mask (B,L) 掩码,告诉模型哪些是真词(1),哪些是补全的 Padding(0),模型不会去注意 0 的部分。
token_type_ids (B,L) 句段 ID,用于区分两句话。第一句全是 0,第二句全是 1。如果是单句任务,通常全是 0。

BertModel返回的是一个BaseModelOutputWithPoolingAndCrossAttentions对象,其中最重要的两个属性是:

  • last_hidden_state(最后一层隐藏状态)
    • 形状:(batch_size, seq_len, hidden_size)
    • 含义:模型对每一个 Token 理解后的上下文向量表示。
    • 用途:序列标注、词性识别,抽取式问答等需要每个 Token 上下文信息时。
  • pooler_output(池化输出)
    • 形状:(batch_size, hidden_size)
    • 含义:取序列第一个位置(即 [CLS])的输出向量,经过了一个线性层和 Tanh 激活函数处理后的结果,包含整句话的语义信息。
    • 用途:句子相似度计算等需要整句话的语义信息时。

上述用途并不绝对,由于 pooler 是预训练的线性层,有可能对我们的任务适配并不那么好。比如文本分类任务,直觉上应该直接使用pooler的输出,但实际使用output第一个位置的输出,再接入自定义的线性层,效果会更好。

pooler 层结构如下:

1
2
3
4
(pooler): BertPooler(
(dense): Linear(in_features=768, out_features=768, bias=True)
(activation): Tanh()
)

3 分词器

在 Transformers 库中,每一个预训练模型都有与之配套的 Tokenizer,可以将原始文本直接转换为模型所需的输入形式(如 input_idsattention_masktoken_type_ids),集成了分词、编码、填充、截断、掩码等操作,搭配起来非常方便。

3.1 加载 Tokenizer

加载分词器和加载预训练模型流程基本一致,由于它们通常是配套使用的,所以模型的标识名称也是一样的。

1
2
3
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained('google-bert/bert-base-chinese')

在模型的缓存目录中,在 snapshots 快照文件夹中,主要添加了如下 3 个软连接:

  • vocab.txt:词表文件,每行一个 token,保存了索引到 token 的映射关系。
  • tokenizer_config.json:分词器配置文件,记录了分词器的行为参数。比如 do_lower_case=True(是否转小写),以及 model_max_length=512(最大长度限制)。
  • tokenizer.json:非 Fast 版所需的 vocab.txt 和 special_tokens_map.json 的合并文件,Fast 版的分词器会优先使用这个文件。保存了所有词表映射关系和特殊字符。

同样的,这三个软链接指向 blobs 中真实的文件。

1
type(tokenizer)
transformers.models.bert.tokenization_bert_fast.BertTokenizerFast

使用type查看 tokenizer 的类型,可以看到是BertTokenizerFast,这里有一个 Fast,莫非还有有 Slow 版本?tokenizer的确有两个版本,通过参数use_fast控制,默认为True,使用 RUST 实现,这个版本加载速度更快,False则使用 Python 的实现。

3.2 Tokenizer 使用

  • tokenize():对文本进行分词,返回一个列表。
1
2
tokens = tokenizer.tokenize("自然语言处理非常有趣")
tokens
['自', '然', '语', '言', '处', '理', '非', '常', '有', '趣']
  • convert_tokens_to_ids(): 将 tokens 列表转为词表对应的索引列表。
1
tokenizer.convert_tokens_to_ids(tokens)
[5632, 4197, 6427, 6241, 1905, 4415, 7478, 2382, 3300, 6637]
  • convert_ids_to_tokens():将索引列表转换成对应的 tokens 列表。
1
2
ids = [5632, 4197, 6427, 6241, 1905, 4415, 7478, 2382, 3300, 6637]
tokenizer.convert_ids_to_tokens(ids)
['自', '然', '语', '言', '处', '理', '非', '常', '有', '趣']
  • encode():对文本进行编码,先分词再映射为索引,返回编码后的列表。一般会添加特殊标记,比如[CLS][SEP]
1
2
3
4
tokenizer.encode(
"自然语言处理非常有趣",
add_special_tokens=True # 添加特殊标记,默认为True
)
[101, 5632, 4197, 6427, 6241, 1905, 4415, 7478, 2382, 3300, 6637, 102]
  • decode():对索引列表进行解码,返回对应的原始文本。
1
2
ids = [101, 5632, 4197, 6427, 6241, 1905, 4415, 7478, 2382, 3300, 6637, 102]
tokenizer.decode(ids)
'[CLS] 自 然 语 言 处 理 非 常 有 趣 [SEP]'

将文本转换为输入的方法还有 encode_plus() 和批处理的 batch_encode_plus,这里更加推荐使用它们的集合体———tokenizer()

  • tokenizer():也就是__call__方法,允许将对象当做方法使用,这个方法进行了高度封装,自动完成了分词、转索引、加特殊符号,支持填充、截断,并且自动生成 mask 等,支持批处理,能够直接构造模型所需的所有输入,非常方便。
1
2
texts = ['自然语言处理', '非常有趣']
inputs = tokenizer(texts)

返回值是一个字典,包括模型所需的所有输入,可以通过inputs['键名']访问,每个值默认都是一个列表。

1
2
3
4
5
6
7
8
{
'input_ids': [[101, 5632, 4197, 6427, 6241, 1905, 4415, 102],
[101, 7478, 2382, 3300, 6637, 102]],
'token_type_ids': [[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0]],
'attention_mask': [[1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1]]
}

除了直接使用tokenizer(),该方法还支持非常多的参数,实现填充截断等功能,这里介绍几个常用的参数。更多参数请查阅官方文档

  • padding:填充
    • True:填充到当前批最大长度
    • False:不填充(默认)
    • max_length:填充到 max_length 参数的指定长度
  • truncation:截断
    • True:截断到 max_length 参数指定的长度
    • False:不截断(默认)
  • max_length:最大长度
  • return_tensors:字典中每个值的类型
1
2
3
4
5
6
7
8
9
inputs = tokenizer(
texts,
padding=True, # 填充
truncation=True, # 截断
max_length=10, # 最大长度
return_tensors="pt", # PyTorch张量
)

inputs
{'input_ids': tensor([[ 101, 5632, 4197, 6427, 6241, 1905, 4415,  102],
        [ 101, 7478, 2382, 3300, 6637,  102,    0,    0]]), 'token_type_ids': tensor([[0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0]]), 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1],
        [1, 1, 1, 1, 1, 1, 0, 0]])}

注意:这里的批次和 DataLoader 的批次是两码事,DataLoader 打乱数据后,每个批次内的长度可能不同,导致报错,具体解决方案在第 5 节工作流中介绍。

tokenizer 处理的字典直接解包传入模型,即可得到模型输出。

1
2
3
outputs = bert_model(**inputs) 

print(type(outputs))
<class 'transformers.modeling_outputs.BaseModelOutputWithPoolingAndCrossAttentions'>

模型输出是一个 ModelOutput 类型的对象 BaseModelOutputWithPoolingAndCrossAttentions,它是 Hugging Face 专门设计的一个混合容器,不仅拥有字典的功能,还拥有对象和元组的功能。

1
2
3
4
5
6
7
8
# 像对象一样的属性访问
print(f'last_hidden_state: {outputs.last_hidden_state.shape}')

# 像字典一样的键访问
print(f'last_hidden_state: {outputs['last_hidden_state'].shape}')

# 像元组一样的索引访问
print(f'pooler_output: {outputs[1].shape}')
last_hidden_state: torch.Size([2, 8, 768])
last_hidden_state: torch.Size([2, 8, 768])
pooler_output: torch.Size([2, 768])

4 Datasets

datasets 是 Hugging Face 生态系统中的另一块基石。如果说 transformers 是用来搞定模型和算法的,那么 datasets 就是专门用来搞定数据的。

datasets 可以从 Hugging Face Hub 中一键加载数据集,或者从本地加载各种类型的文件。加载后的结构类似字典,可以划分训练集、验证集、测试集,每个数据集都是一个清晰的类表格结构。datasets 还支持强大的数据处理功能,可以批量处理数据。

相较于 Pandas 一次性把数据集读入内存中,datasets 底层使用 Apache Arrow 格式和操作系统的 Memory Mapping (mmap) 技术,将数据存储在硬盘上,操作系统将其映射到虚拟内存,即使是普通的笔记本电脑,也能流畅地浏览和处理几百 GB 的数据集,而且读取速度极快。

4.1 加载 Datasets

在 Hugging Face Hub 找好需要的数据集,可以直接使用load_dataset联网下载数据集,下载好的数据集会缓存到和模型相同的根目录下,文件结构也是类似的,这里不再赘述。

1
2
3
4
from datasets import load_dataset

dataset_dict1 = load_dataset("lansinuote/ChnSentiCorp")
dataset_dict1
DatasetDict({
    train: Dataset({
        features: ['text', 'label'],
        num_rows: 9600
    })
    validation: Dataset({
        features: ['text', 'label'],
        num_rows: 1200
    })
    test: Dataset({
        features: ['text', 'label'],
        num_rows: 1200
    })
})

load_dataset返回的是DatasetDict对象,该对象是字典结构,可以包括多个数据集。

加载本地数据时需要声明文件类型,如 CSV、JSON、Parquet,并通过data_files参数指定文件路径。如果只有一个文件,那么默认是键为train的数据集。

1
2
3
4
5
6
7
8
dataset_dict2 = load_dataset(
'csv', # 数据集格式
data_files='data/train.txt', # 数据集路径,必须是字符串,不支持 Path
sep='\t', # 数据集分隔符
column_names=['text', 'label'] # 指定数据集列名(默认使用第一行为列名)
)

dataset_dict2
DatasetDict({
    train: Dataset({
        features: ['text', 'label'],
        num_rows: 16000
    })
})

如果有多个数据集,需要以字典形式传入data_files,键为数据集名称,值为数据集路径。返回值为包含多个DatasetDatasetDict对象,每个Dataset称为一个 split。

1
2
3
4
5
6
7
8
9
10
11
dataset_dict2 = load_dataset(
'csv',
data_files={
'train': 'data/train.txt',
'test': 'data/test.txt'
},
sep='\t',
names=['text', 'label']
)

dataset_dict2
DatasetDict({
    train: Dataset({
        features: ['text', 'label'],
        num_rows: 16000
    })
    test: Dataset({
        features: ['text', 'label'],
        num_rows: 4000
    })
})

4.2 Datasets 使用

4.2.1 数据格式

  • dataset_dict['键名']:获取数据集。
1
2
train_dataset = dataset_dict1['train']
train_dataset
Dataset({
    features: ['text', 'label'],
    num_rows: 9600
})
  • dataset[索引/列名]:获取数据集的样本。
1
2
3
4
5
6
7
8
9
10
11
# 通过索引访问
print(f'第 0 个样本:\n{train_dataset[0]}\n')

# 通过列名访问,默认取前 5 行
print(f'按列名访问:\n{train_dataset['text']}\n')

# 查看具体样本
print(f'查看具体样本:\n{train_dataset[0]['text']}\n')

# 使用切片访问
print(f'切片取前 3 项:\n{train_dataset[:3]}\n')
第 0 个样本:
{'text': '选择珠江花园的原因就是方便,有电动扶梯直接到达海边,周围餐馆、食廊、商场、超市、摊位一应俱全。酒店装修一般,但还算整洁。 泳池在大堂的屋顶,因此很小,不过女儿倒是喜欢。 包的早餐是西式的,还算丰富。 服务吗,一般', 'label': 1}

按列名访问:
Column(['选择珠江花园的原因就是方便,有电动扶梯直接到达海边,周围餐馆、食廊、商场、超市、摊位一应俱全。酒店装修一般,但还算整洁。 泳池在大堂的屋顶,因此很小,不过女儿倒是喜欢。 包的早餐是西式的,还算丰富。 服务吗,一般', '15.4寸笔记本的键盘确实爽,基本跟台式机差不多了,蛮喜欢数字小键盘,输数字特方便,样子也很美观,做工也相当不错', '房间太小。其他的都一般。。。。。。。。。', '1.接电源没有几分钟,电源适配器热的不行. 2.摄像头用不起来. 3.机盖的钢琴漆,手不能摸,一摸一个印. 4.硬盘分区不好办.', '今天才知道这书还有第6卷,真有点郁闷:为什么同一套书有两种版本呢?当当网是不是该跟出版社商量商量,单独出个第6卷,让我们的孩子不会有所遗憾。'])

查看具体样本:
选择珠江花园的原因就是方便,有电动扶梯直接到达海边,周围餐馆、食廊、商场、超市、摊位一应俱全。酒店装修一般,但还算整洁。 泳池在大堂的屋顶,因此很小,不过女儿倒是喜欢。 包的早餐是西式的,还算丰富。 服务吗,一般

切片取前 3 项:
{'text': ['选择珠江花园的原因就是方便,有电动扶梯直接到达海边,周围餐馆、食廊、商场、超市、摊位一应俱全。酒店装修一般,但还算整洁。 泳池在大堂的屋顶,因此很小,不过女儿倒是喜欢。 包的早餐是西式的,还算丰富。 服务吗,一般', '15.4寸笔记本的键盘确实爽,基本跟台式机差不多了,蛮喜欢数字小键盘,输数字特方便,样子也很美观,做工也相当不错', '房间太小。其他的都一般。。。。。。。。。'], 'label': [1, 1, 0]}

Dataset的使用类似 Pandas 和字典的结合。它的数据是按照列进行组织的,可以通过键名(列名)访问列数据。如果取一批样本,它们是字典形式,键对应列名,值为列数据的列表。这种组织方式有利于后续和Tokenizer联动。

1
2
3
4
{
'feature1': ['text1', 'text2', 'text3'],
'feature2': [label1, label2, label3]
}

  • features:查看数据集的列,以及列的数据类型。
1
train_dataset.features
{'text': Value('string'), 'label': Value('int64')}
  • ClassLabel():类标签数据类型,专门处理分类数据,维护索引到字符串标签的双向映射。
1
2
3
4
5
6
7
8
9
from datasets import ClassLabel

c_label = ClassLabel(names=['negative', 'positive'])

# 1. 模型训练用
print('字符串转整数: positive ->', c_label.str2int('positive'))

# 2. 查看结果用
print('整数转字符串: 0 ->', c_label.int2str(0))
字符串转整数: positive -> 1
整数转字符串: 0 -> negative
  • cast_column():转换数据集中某列的数据类型,返回新的数据集对象。
1
2
3
4
5
# cast_column(要转换的列名, 转换的列类型)
train_dataset = train_dataset.cast_column('label', c_label)

print('新的特征类型:', train_dataset.features)
print('1 代表标签:', train_dataset.features['label'].int2str(1))
新的特征类型: {'text': Value('string'), 'label': ClassLabel(names=['negative', 'positive'])}
1 代表标签: positive

如果获取的数据集类别列是 string 类型,转换为 ClassLabel 类型,会自动将字符串映射为整数标签,便于后续模型训练,并且这一操作会改变底层数据。

如果获取的数据集类别列是 int 类型,可以直接用于训练,那么转换为 ClassLabel 之后,并不会修改底层数据,而是通过元数据注入的方式建立双向映射,既方便人能读懂,又可以让 Trainer 自动生成带标签配置的模型

4.2.2 数据预处理

  • train_test_split():划分训练集和测试集,不需要再导入 sklearn 库。
1
2
3
4
5
6
7
8
# 为了方便演示这里直接使用 train_dataset 分割训练集和测试集
dataset_dict = train_dataset.train_test_split(
train_size=0.8,
stratify_by_column="label" # 按照 label 进行分层,必须是 ClassLabel 类型
)

train_dataset = dataset_dict["train"]
test_dataset = dataset_dict["test"]
  • remove_columns(): 删除数据集中的列,返回新的 dataset 对象。
1
2
rm_dataset = train_dataset.remove_columns(['label'])
rm_dataset
Dataset({
    features: ['text'],
    num_rows: 7680
})
  • filter():根据条件筛选数据,返回过滤后的新数据集对象。
1
2
# x 为每一个样本
train_dataset.filter(lambda x: x['label'] == 1)
Filter: 100%|██████████| 7680/7680 [00:00<00:00, 305430.76 examples/s]





Dataset({
    features: ['text', 'label'],
    num_rows: 3839
})
  • map():最核心的方法,功能非常强大,可以多进程、批量化的处理数据,返回一个新的数据集对象。比如常常搭配 tokenizer 使用,批量处理数据集为可以输入模型的格式。

Datasets 为了把数据放入硬盘上,存储的是 Arrow 格式的文件。Arrow 格式只认识基础类型(Integers, Floats, Lists, Strings),它不认识 PyTorch Tensor。所以即使在 map 中返回 tensor 类型,数据存储时也会变为基础类型,建议在放入 DataLoader 前使用 set_format 函数转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def tokenize(example):
# example 为 dataset 的一个样本
inputs = tokenizer(
example["text"],
padding='max_length', # 这里为了方便适配 DataLoader,设置固定长度
truncation=True,
max_length=32,
return_tensors="pt" # 其实这里是多余的,无法生效,应使用 set_format
)

example["input_ids"] = inputs['input_ids']
example["attention_mask"] = inputs['attention_mask']
example['labels'] = example['label'] # 任务头模型接收的是 labels

return example

# 应用 tokenize 函数,使用 dataset_dict['train'] 接收分词后的结果
dataset_dict['train'] = dataset_dict['train'].map(
function=tokenize, # 应用到每个样本的函数
batched=True, # 是否批量处理,默认为 1000 条,极大提升处理效率
remove_columns=['text', 'label'], # 删除列
num_proc=4 # 多进程,CPU 核数
)

# 经过 map 之后,会丢失元数据,这里重新转类型
dataset_dict['train'] = dataset_dict['train'].cast_column('labels', c_label)

print(dataset_dict['train'])
print('实际没有转为张量:', type(dataset_dict['train']['input_ids'][0]))
Map (num_proc=4): 100%|██████████| 7680/7680 [00:00<00:00, 18073.44 examples/s]
Casting the dataset: 100%|██████████| 7680/7680 [00:00<00:00, 2740768.72 examples/s]

Dataset({
    features: ['input_ids', 'attention_mask', 'labels'],
    num_rows: 7680
})
实际没有转为张量: <class 'list'>

注意:使用 map, filter, shuffle, select 等数据处理方法时,由于返回的是新对象,无论使用dataset还是dataset_dict接收这个新对象,它们都会因此独立,互不影响。也就是说,修改 dataset的数据,并不会影响dataset_dict的数据,反过来也是如此。希望改变谁的数据,就使用谁来接收,比如想要改变dataset_dict['train']的数据,需要使用dataset_dict['train']的方式接收。

map 方法会对数据集中的每个样本执行指定的 function。该函数的返回值是一个字典,它将以增量更新的方式合并到原样本中:

  • 新增字段:如果返回的键在原样本中不存在,则作为新列添加。
  • 更新字段:如果返回的键在原样本中已存在,则覆盖原有值。

注意:它不会直接丢弃原样本中未被返回的字段,除非显式指定 remove_columns

  • set_format(): 设置数据格式,喂给模型前转为 tensor。原地修改,不返回新对象。
1
2
3
4
5
6
dataset_dict['train'].set_format(
type='torch', # 数据格式
columns=['input_ids', 'attention_mask', 'labels'] # 指定列
)

dataset_dict['train'][0]
{'input_ids': tensor([ 101, 6848, 2885, 4638,  752,  891, 1922, 4895, 1936,  749, 8024, 1930,
         1920,  749, 2552, 4415, 1486, 6418, 4638, 4385, 2141, 2692,  721, 8024,
         6375,  782, 1927, 1343,  749,  928,  818,  102]),
 'attention_mask': tensor([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
         1, 1, 1, 1, 1, 1, 1, 1]),
 'labels': tensor(0)}

注意:set_format只是改变了通过__getitem__(访问dataset[i])获取数据的格式,而不会修改底层的数据存储格式。不过使用 Arrow 格式存储时,元数据 state.json 会记录数据的 _format_type

4.3 保存和加载

  • to_csv():将 Dataset 保存为 CSV 文件。
1
test_dataset.to_csv('data/processed/test.csv')
Creating CSV from Arrow format: 100%|██████████| 2/2 [00:00<00:00, 293.27ba/s]





595543
  • to_json():将 Dataset 保存为 JSON 文件。
1
test_dataset.to_json('data/processed/test.jsonl')
Creating json from Arrow format: 100%|██████████| 2/2 [00:00<00:00, 291.38ba/s]





1207798

上述文件的加载方式使用 4.1 介绍的 load_dataset 即可。

  • save_to_disk():保存 Arrow 格式,支持 DatasetDict 和 Dataset,是官方最推荐的保存方式。
1
dataset_dict.save_to_disk('data/processed')
Saving the dataset (1/1 shards): 100%|██████████| 7680/7680 [00:00<00:00, 2539397.30 examples/s]
Saving the dataset (1/1 shards): 100%|██████████| 1920/1920 [00:00<00:00, 528173.65 examples/s]

每个 split 都会生成一个文件,里面有 arrow 数据文件和相应的元数据文件。

1
2
3
4
5
6
7
8
9
10
processed/
├─ test/
│ ├─ data-00000-of-00001.arrow
│ ├─ dataset_info.json
│ └─ state.json
├─ train/
│ ├─ data-00000-of-00001.arrow
│ ├─ dataset_info.json
│ └─ state.json
└─ dataset_dict.json
  • load_from_disk():加载 Arrow 格式的数据文件。
1
2
3
from datasets import load_from_disk

dataset_dict = load_from_disk('data/processed')

5 工作流

5.1 搭配 DataLoader

Hugging Face 生态可以和 PyTorch 无缝集成,使用 Datasets 获取数据并进行预处理,搭配 Tokenizer 将源数据转换为可以输入到模型的格式,Datasets 结合 PyTorch 的 DataLoader 可以实现数据的批量处理,将批量数据送入 Transformers 中的各种预训练模型当中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from torch.utils.data import DataLoader

dataloader = DataLoader(
dataset=dataset_dict['train'],
batch_size=16,
shuffle=True
)

for batch in dataloader:
print('batch keys:', batch.keys())
print('input_ids shape:', batch['input_ids'].shape)
print('attention_mask shape:', batch['attention_mask'].shape)
print('labels shape:', batch['labels'].shape)

print('\n===============Bert分类模型===============')
outputs = classifier_model(**batch)
print('inputs keys:', batch.keys())
print('outputs keys:', outputs.keys())
print('loss:', outputs['loss'])
print('logits shape:', outputs['logits'].shape)

print('\n=================Bert模型================')
# 使用 pop 弹出模型不需要的 labels 列,并且存下来后续计算损失使用
labels = batch.pop('labels')
print('inputs keys:', batch.keys())

outputs = bert_model(**batch)
print('outputs keys:', outputs.keys())
print('last_hidden_state shape:', outputs['last_hidden_state'].shape)
print('pooler_output shape:', outputs['pooler_output'].shape)

break
batch keys: dict_keys(['input_ids', 'attention_mask', 'labels'])
input_ids shape: torch.Size([16, 32])
attention_mask shape: torch.Size([16, 32])
labels shape: torch.Size([16])

===============Bert分类模型===============
inputs keys: dict_keys(['input_ids', 'attention_mask', 'labels'])
outputs keys: odict_keys(['loss', 'logits'])
loss: tensor(2.3796, grad_fn=<NllLossBackward0>)
logits shape: torch.Size([16, 10])

=================Bert模型================
inputs keys: dict_keys(['input_ids', 'attention_mask'])
outputs keys: odict_keys(['last_hidden_state', 'pooler_output'])
last_hidden_state shape: torch.Size([16, 32, 768])
pooler_output shape: torch.Size([16, 768])

Dataset 并未继承 torch.utils.data.Dataset,但由于实现了 __len__()__getitem__() 这两个核心接口,因此能够被 DataLoader 正确识别批量迭代。

5.2 注意事项

上面也提到过,Tokenizer 填充时的批次和 DataLoader 加载时的批次是不同的,如果直接使用 DataLoader 去加载 Tokenizer 填充的数据,可能会由于批次不同导致内部数据长度不一,从而导致 DataLoader 抛出异常。

这里主要有两种解决方案:

方法一:使用动态填充(DataCollator),这是 Hugging Face 的标准做法,既省显存,又不会报错。

不要在预处理(map)阶段把所有数据死板地补齐,而是把补齐的工作推迟到数据加载(DataLoader)阶段。使用 DataCollator 在每个 Batch 形成时,动态地将这个批次补齐到当前 Batch 最长的长度。

1
2
3
4
5
6
7
8
9
10
11
12
from transformers import DataCollatorWithPadding

# 1. 实例化一个动态填充器
data_collator = DataCollatorWithPadding(tokenizer=tokenizer)

# 2. 在 DataLoader 中加入 collate_fn 参数
dataloader = DataLoader(
dataset=dataset_dict['train'],
batch_size=64,
shuffle=True,
collate_fn=data_collator
)

如果采用这种方式在数据加载阶段填充,那么在数据预处理时 tokenizerpadding 参数应该设置为 False,避免浪费空间。

方法二:静态填充到统一长度,在数据预处理时将 tokenizer 的填充参数设为 padding='max_length'

由于所有数据都是统一长度,即使 DataLoader 随意打乱,每一批的数据长度也都是一致的。

1
2
3
4
5
6
7
8
9
10
def tokenize(example):
inputs = tokenizer(
example["text"],
padding="max_length", # 强制填充到最大长度
truncation=True,
max_length=32, # 所有数据长度都变成 32
return_tensors="pt",
)

return inputs

07_Hugging Face生态使用
https://zhubaoduo.com/2024/08/26/大模型开发/06_自然语言处理/07_Hugging Face生态使用/
作者
baoduozhu
发布于
2024年8月26日
许可协议