构建文本分类器
利用Transformer进行文本分类应该是比较常用的应用,本文梳理了文本分类的基本流程以及容易忽视的一些细节。
利用集成Huggingface进行模型搭建
首先我们必须了解HuggingFace的三个核心库,那就是Datasets,Tokenizers,Transformers,接下来我们分别回归下这三个核心库。
Datasets
平时在学习科研的过程中可以通过现有的经过数据清洗过的数据集进行训练以及实验:
1、查看Hub上有哪些数据集
from datasets import list_datasets
all_datasets = list_datasets()
print(f"There are {len(all_datasets)} datasets currently available on the Hub")
print(f"The first 10 are: {all_datasets[:10]}")
2、加载数据集
from datasets import load_dataset
emotions = load_dataset("emotion")
数据集类似于 Python 字典,每个键值对应一个不同的数据集划分,我们可以按照字典查询的方式访问具体数据集,比如下面是获取训练集。
然而在现实中的大多数情况是需要属于自己项目的数据集,针对不同数据格式我们可以进行加载。
| Data format | Loading script | Example
| CSV | `csv` | `load_dataset("csv", data_files="my_file.csv")`
| Text | `text` | `load_dataset("text", data_files="my_file.txt")`
| JSON | `json` | `load_dataset("json", data_files="my_file.jsonl")`
emotions_local = load_dataset("csv", data_files="data/train.txt", sep=";",
names=["text", "label"])
将Datasets对象转为DataFrame
!!!!!!!!!!!!!!
我们经常会遇到的问题是集成好的Dataset或者DataLoader可视化较麻烦,因此考虑将Dataset转换为Dataframe,访问高级用于数据可视化的级别API。
import pandas as pd
emotions.set_format(type="pandas")
df = emotions["train"][:]
df.head()
def label_int2str(row):
return emotions["train"].features["label"].int2str(row)
df["label_name"] = df["label"].apply(label_int2str)
df.head()
查看数据分布!!!!!!!!!
进行数据分析往往容易被忽视,查看数据分布,进行数据分析才能使得模型与数据相适应相融合。
import matplotlib.pyplot as plt
df["label_name"].value_counts(ascending=True).plot.barh()
plt.title("Frequency of Classes")
plt.show()
对于数据集分布不平衡的问题,我们可以通过以下几种方式进行处理:、
- 随机过采样少数类。
- 随机对多数类进行欠采样。
- 从代表性不足的类别中收集更多标记数据。
查看输入序列长度的分布
df["Words Per Tweet"] = df["text"].str.split().apply(len) # 按空格切分,获取雷彪长度
df.boxplot("Words Per Tweet", by="label_name", grid=False, showfliers=False,
color="black")
plt.suptitle("")
plt.xlabel("")
plt.show()
根据长短不同可以考虑补全序列或者截断pad、truction
Tokenizer
先分词再进行词嵌入是基础的手段,最常见的字符级和词级。
Character Tokenization
text = "Tokenizing text is a core task of NLP."
tokenized_text = list(text)
print(tokenized_text)
token2idx = {ch: idx for idx, ch in enumerate(sorted(set(tokenized_text)))}
print(token2idx)
input_ids = [token2idx[token] for token in tokenized_text]
print(input_ids)
现在每个标记都被映射到一个唯一的数字标识符(因此名称为 input_ids)。最后一步是将 input_ids 转换为 one-hot 向量的 2D 张量。One-hot 向量在机器学习中经常用于对分类数据进行编码,这些数据可以是有序的也可以是名义的。
import torch
import torch.nn.functional as F
input_ids = torch.tensor(input_ids)
one_hot_encodings = F.one_hot(input_ids, num_classes=len(token2idx))
one_hot_encodings.shape
注意: 始终在 one_hot() 函数中设置 num_classes 很重要,否则 one-hot 向量最终可能会比词汇表的长度短(并且需要手动填充零)。在 TensorFlow 中,等效函数是 tf.one_hot(),其中 depth 参数扮演 num_classes 的角色。
虽然然而字符集分词优点是有助于处理拼写错误和稀有单词,但是容易破坏文本的语义结构,需要大量的计算。
Word Tokenization
tokenized_text = text.split()
print(tokenized_text)
注意:一些词标记器对标点符号有额外的规则。也可以应用词干化或词形还原,将词标准化为词干(例如,“great”、“greater”和“greatest”都变成“great”),但会丢失文本中的一些信息。
拥有大量词汇是一个问题,因为它需要神经网络具有大量参数.
- 一种常见的方法是通过考虑语料库中最常见的 100,000 个词来限制词汇并丢弃稀有词。不属于词汇表的单词被归类为“未知”并映射到共享的 UNK 标记。这意味着我们在词标记化过程中丢失了一些潜在的重要信息,因为该模型没有关于与 UNK 相关的词的信息。
Subword Tokenization
Subword分词背后的基本思想是结合字符和词标记化的最佳应用。一方面,我们希望将稀有词拆分成更小的单元,以使模型能够处理复杂的词和拼写错误。另一方面,我们希望将常用词保留为唯一实体,以便我们可以将输入的长度保持在可管理的大小。Subword分词(以及词标记化)的主要区别特征是它是使用统计规则和算法的组合从预训练语料库中学习而来的。
Transformers 提供了一个方便的 AutoTokenizer 类,允许我们快速加载与预训练模型关联的标记器——我们只需调用它的 from_pretrained() 方法,提供 分词器的模型或本地文件路径。让我们从为 DistilBERT 加载分词器开始:
# 加载distilbert模型
from transformers import AutoTokenizer
model_ckpt = "distilbert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(model_ckpt)
AutoTokenizer 类属于一组更大的“自动”类,其工作是从checkpoint的名称中自动检索模型的配置、预训练的权重或词汇表。这允许我们在模型之间灵活切换,但如果您希望手动加载特定类,您也可以这样做。例如,我们可以按如下方式加载 DistilBERT 分词器:
from transformers import DistilBertTokenizer
distilbert_tokenizer = DistilBertTokenizer.from_pretrained(model_ckpt)
```-->
> 注意:当我们第一次运行 `AutoTokenizer.from_pretrained()` 方法时,我们将看到一个进度条,显示从 Hugging Face Hub 加载的预训练标记器的哪些参数。 当你第二次运行代码时,它会从缓存中加载分词器,通常位于_~/.cache/huggingface/_,windows系统在我们用户目录下
让我们通过简单的“文本分词是 NLP 的核心任务”来检查这个分词模块是如何工作的。 示例文本:
```python
encoded_text = tokenizer(text)
print(encoded_text)
{‘input_ids’: [101, 19204, 6026, 3793, 2003, 1037, 4563, 4708, 1997, 17953,
2361, 1012, 102], ‘attention_mask’: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]}
就像我们在字符分词中看到的那样,我们可以看到单词已经映射到 input_ids 字段中的唯一整数。我们将在下一节讨论 attention_mask 字段的作用。现在我们有了 input_ids,我们可以使用分词器的 convert_ids_to_tokens() 方法将它们转换回原始字符:
tokens = tokenizer.convert_ids_to_tokens(encoded_text.input_ids)
print(tokens)
['[CLS]', 'token', '##izing', 'text', 'is', 'a', 'core', 'task', 'of', 'nl',
'##p', '.', '[SEP]']
print(tokenizer.convert_tokens_to_string(tokens))
[CLS] tokenizing text is a core task of nlp. [SEP]
tokenizer.vocab_size
tokenizer.model_max_length
okenizer.model_input_names
警告:使用预训练模型时,确保使用与训练模型相同的分词器(tokenizer)非常重要。从模型的角度来看,切换分词器就像打乱词汇表一样。如果您周围的每个人都开始将“房子”之类的随机词替换为“猫”,那么你也很难理解发生了什么!
对整个数据集进行分词
首先,我们需要一个处理函数来分词我们的文本:
def tokenize(batch):
return tokenizer(batch["text"], padding=True, truncation=True)
该函数将分词器应用于一批文本数据;padding=True 会将示例用零填充到批次中最长的大小,而 truncation=True 会将示例截断为模型的最大上下文大小。要查看 tokenize() 的实际效果,让我们从训练集中传递含有两条数据的batch:
print(tokenize(emotions["train"][:2]))
tokens2ids = list(zip(tokenizer.all_special_tokens, tokenizer.all_special_ids))
data = sorted(tokens2ids, key=lambda x : x[-1])
df = pd.DataFrame(data, columns=["Special Token", "Special Token ID"])
df.T
另请注意,除了将编码的推文作为“input_ids”返回之外,标记器还返回一个“attention_mask”数组的列表。这是因为我们不希望模型被额外的填充标记混淆:注意掩码允许模型忽略输入的填充部分。下图a提供了如何填充输入 ID 和attention-mask的可视化解释。
# hide_output
emotions_encoded = emotions.map(tokenize, batched=True, batch_size=None)
print(emotions_encoded["train"].column_names)
默认情况下,map() 方法对语料库中的每个示例单独运行,因此设置 batched=True 将对推文进行批量编码。因为我们设置了 batch_size=None,所以我们的 tokenize() 函数将作为单个批次应用于整个数据集。这确保了输入张量和注意力掩码在全局范围内具有相同的形状,我们可以看到这个操作在数据集中添加了新的 input_ids 和 attention_mask 列
训练一个分类器
注意:在实践中,PyTorch 跳过了为令牌编码创建 one-hot 向量的步骤,因为将矩阵与 one-hot 向量相乘与从矩阵中选择一列相同。这可以通过从矩阵中获取具有标记 ID 的列来直接完成。
- 特征提取:: 我们使用隐藏状态作为特征,只在它们上训练一个分类器,而不修改预训练模型。
- Fine-tuning:: 我们端到端训练整个模型,这也更新了预训练模型的参数。
使用预训练模型
我们将使用 Transformers 中另一个方便的自动类,称为“AutoModel”。类似于 AutoTokenizer 类,AutoModel 有一个 from_pretrained() 方法来加载预训练模型的权重。让我们使用这个方法来加载 DistilBERT 检查点:
# hide_output
from transformers import AutoModel
model_ckpt = "distilbert-base-uncased"
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = AutoModel.from_pretrained(model_ckpt).to(device)
提取最后的隐藏状态
text = "this is a test"
inputs = tokenizer(text, return_tensors="pt")
print(f"Input tensor shape: {inputs['input_ids'].size()}")
inputs = {k:v.to(device) for k,v in inputs.items()}
with torch.no_grad():
outputs = model(**inputs)
print(outputs)
查看隐藏状态张量,我们看到它的形状为“[batch_size, n_tokens, hidden_dim]”。换句话说,为 6 个输入标记中的每一个返回一个 768 维向量。对于分类任务,通常的做法是仅使用与“[CLS]”标记关联的隐藏状态作为输入特征。由于这个标记出现在每个序列的开头,我们可以通过简单地索引到 outputs.last_hidden_state 来提取它,如下所示:
outputs.last_hidden_state[:,0].size()
对于分类任务,通常的做法是仅使用与“[CLS]”标记关联的隐藏状态作为输入特征。由于这个标记出现在每个序列的开头,我们可以通过简单地索引到 outputs.last_hidden_state 来提取它,如下所示:
def extract_hidden_states(batch):
# Place model inputs on the GPU
inputs = {k:v.to(device) for k,v in batch.items()
if k in tokenizer.model_input_names}
# Extract last hidden states
with torch.no_grad():
last_hidden_state = model(**inputs).last_hidden_state
# Return vector for [CLS] token
return {"hidden_state": last_hidden_state[:,0].cpu().numpy()}
emotions_encoded.set_format("torch",
columns=["input_ids", "attention_mask", "label"])
#hide_output
emotions_hidden = emotions_encoded.map(extract_hidden_states, batched=True)
创建特征矩阵
import numpy as np
X_train = np.array(emotions_hidden["train"]["hidden_state"])
X_valid = np.array(emotions_hidden["validation"]["hidden_state"])
y_train = np.array(emotions_hidden["train"]["label"])
y_valid = np.array(emotions_hidden["validation"]["label"])
X_train.shape, X_valid.shape
可视化训练集
from umap import UMAP
from sklearn.preprocessing import MinMaxScaler
# Scale features to [0,1] range
X_scaled = MinMaxScaler().fit_transform(X_train)
# Initialize and fit UMAP
mapper = UMAP(n_components=2, metric="cosine").fit(X_scaled)
# Create a DataFrame of 2D embeddings
df_emb = pd.DataFrame(mapper.embedding_, columns=["X", "Y"])
df_emb["label"] = y_train
df_emb.head()
fig, axes = plt.subplots(2, 3, figsize=(7,5))
axes = axes.flatten()
cmaps = ["Greys", "Blues", "Oranges", "Reds", "Purples", "Greens"]
labels = emotions["train"].features["label"].names
for i, (label, cmap) in enumerate(zip(labels, cmaps)):
df_emb_sub = df_emb.query(f"label == {i}")
axes[i].hexbin(df_emb_sub["X"], df_emb_sub["Y"], cmap=cmap,
gridsize=20, linewidths=(0,))
axes[i].set_title(label)
axes[i].set_xticks([]), axes[i].set_yticks([])
plt.tight_layout()
plt.show()
训练一个简单的分类器
#hide_output
# We increase `max_iter` to guarantee convergence
from sklearn.linear_model import LogisticRegression
lr_clf = LogisticRegression(max_iter=3000)
lr_clf.fit(X_train, y_train)
lr_clf.score(X_valid, y_valid)
0.6335
from sklearn.dummy import DummyClassifier
dummy_clf = DummyClassifier(strategy="most_frequent")
dummy_clf.fit(X_train, y_train)
dummy_clf.score(X_valid, y_valid)
0.352
from sklearn.metrics import ConfusionMatrixDisplay, confusion_matrix
def plot_confusion_matrix(y_preds, y_true, labels):
cm = confusion_matrix(y_true, y_preds, normalize="true")
fig, ax = plt.subplots(figsize=(6, 6))
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=labels)
disp.plot(cmap="Blues", values_format=".2f", ax=ax, colorbar=False)
plt.title("Normalized confusion matrix")
plt.show()
y_preds = lr_clf.predict(X_valid)
plot_confusion_matrix(y_preds, y_valid, labels)
Transformers微调
from transformers import AutoModelForSequenceClassification
num_labels = 6
model = (AutoModelForSequenceClassification
.from_pretrained(model_ckpt, num_labels=num_labels)
.to(device))
from sklearn.metrics import accuracy_score, f1_score
def compute_metrics(pred):
labels = pred.label_ids
preds = pred.predictions.argmax(-1)
f1 = f1_score(labels, preds, average="weighted")
acc = accuracy_score(labels, preds)
return {"accuracy": acc, "f1": f1}
- 在 Hugging Face Hub 上登录我们的帐户。这将使我们能够将微调后的模型推送到我们在 Hub 上的帐户并与社区共享。
- 定义训练运行的所有超参数。
from huggingface_hub import notebook_login
notebook_login()
$ huggingface-cli login
from transformers import Trainer, TrainingArguments
batch_size = 64
logging_steps = len(emotions_encoded["train"]) // batch_size
model_name = f"{model_ckpt}-finetuned-emotion"
training_args = TrainingArguments(output_dir=model_name,
num_train_epochs=2,
learning_rate=2e-5,
per_device_train_batch_size=batch_size,
per_device_eval_batch_size=batch_size,
weight_decay=0.01,
evaluation_strategy="epoch",
disable_tqdm=False,
logging_steps=logging_steps,
push_to_hub=True,
log_level="error")
from transformers import Trainer
trainer = Trainer(model=model, args=training_args,
compute_metrics=compute_metrics,
train_dataset=emotions_encoded["train"],
eval_dataset=emotions_encoded["validation"],
tokenizer=tokenizer)
trainer.train();
preds_output = trainer.predict(emotions_encoded["validation"])
y_preds = np.argmax(preds_output.predictions, axis=1)
plot_confusion_matrix(y_preds, y_valid, labels)
误差分析
from torch.nn.functional import cross_entropy
def forward_pass_with_label(batch):
# Place all input tensors on the same device as the model
inputs = {k:v.to(device) for k,v in batch.items()
if k in tokenizer.model_input_names}
with torch.no_grad():
output = model(**inputs)
pred_label = torch.argmax(output.logits, axis=-1)
loss = cross_entropy(output.logits, batch["label"].to(device),
reduction="none")
# Place outputs on CPU for compatibility with other dataset columns
return {"loss": loss.cpu().numpy(),
"predicted_label": pred_label.cpu().numpy()}
#hide_output
# Convert our dataset back to PyTorch tensors
emotions_encoded.set_format("torch",
columns=["input_ids", "attention_mask", "label"])
# Compute loss values
emotions_encoded["validation"] = emotions_encoded["validation"].map(
forward_pass_with_label, batched=True, batch_size=16)
emotions_encoded.set_format("pandas")
cols = ["text", "label", "predicted_label", "loss"]
df_test = emotions_encoded["validation"][:][cols]
df_test["label"] = df_test["label"].apply(label_int2str)
df_test["predicted_label"] = (df_test["predicted_label"]
.apply(label_int2str))
df_test.sort_values("loss", ascending=False).head(10)
text label predicted_label loss
882 i feel badly about reneging on my commitment t… love sadness 5.349359
1500 i guess we would naturally feel a sense of lon… anger sadness 5.317410
1950 i as representative of everything thats wrong … surprise sadness 5.078965
1963 i called myself pro life and voted for perry w… joy sadness 4.999394
1111 im lazy my characters fall into categories of … joy fear 4.933125
1801 i feel that he was being overshadowed by the s… love sadness 4.914969
1870 i guess i feel betrayed because i admired him … joy sadness 4.697159
318 i felt ashamed of these feelings and was scare… fear sadness 4.511000
1840 id let you kill it now but as a matter of fact… joy fear 4.437477
1581 i feel stronger clearer but a little annoyed n… anger joy 4.385489
df_test.sort_values("loss", ascending=True).head(10)
trainer.push_to_hub(commit_message="Training completed!")
from transformers import pipeline
# Change `transformersbook` to your Hub username
model_id = "transformersbook/distilbert-base-uncased-finetuned-emotion"
classifier = pipeline("text-classification", model=model_id)
custom_tweet = "I saw a movie today and it was really good."
preds = classifier(custom_tweet, return_all_scores=True)
custom_tweet = "I saw a movie today and it was really good."
preds = classifier(custom_tweet, return_all_scores=True)
https://mp.weixin.qq.com/s/hd6ZSufhu74AGYLqqvIuDA