NLP 最广泛的实际应用可能是分类,即自动将文本归入某个类别。
分类模型还可用于解决一些起初并不明显合适的问题。例如 Kaggle 美国专利短语匹配[1]竞赛。在这项比赛中,我们的任务是比较两个单词或短语,并根据它们是否相似、在哪个专利类别中使用等因素进行评分。如果得分为 1,则认为这两个输入词具有相同的含义;如果得分为 0,则表示它们具有完全不同的含义。例如,abatement 和 eliminating process 得分为 0.5,表示它们有些相似,但不完全相同。
原来,这可以表示为一个分类问题。怎么表示?可以这样表示问题:对于下面的文字....."TEXT1: abatement; TEXT2: eliminating process"
......选择一个意义相似类别:"不同 相似 相同"。
本文专利短语匹配问题作为分类任务来解决。
对于任何希望提高机器学习技能的人来说,Kaggle 是一个非常棒的资源。没有什么比亲身实践和接收实时反馈更能帮助你提高技能了。它提供:
我们在此使用的数据集是从 Kaggle 获取。因此,你需要在网站上注册,然后进入比赛页面[2]。在该页面点击 "Rules",然后点击 "I Understand and Accept."。(虽然比赛已经结束,你也不会参加比赛,但仍然必须同意规则才能下载数据)。
如果是在 Kaggle.com 上运行,可以跳过下一部分。只需确保在 Kaggle 上选择了在会话中使用 GPU,方法是点击菜单(右上角的 3 个点)并点击 "Accelerator" -- 应该是这样的:
根据是否在 Kaggle 上运行,我们需要的代码会略有不同,因此将使用这个变量来跟踪位置:
import os
iskaggle = os.environ.get('KAGGLE_KERNEL_RUN_TYPE', '')
下载 Kaggle 数据集的最简单方法是使用 Kaggle API。你可以在 notebook cell 中运行 pip 来安装:
!pip install kaggle
你需要一个 API 密钥才能使用 Kaggle API;要获得一个 API 密钥,在 Kaggle 网站上点击你的个人资料图片,选择 "My Accoun",然后点击 "Create New API Token"。这将在你的电脑上保存一个名为 kaggle.json 的文件。需要将此密钥复制到 GPU 服务器上。为此,请打开下载的文件,复制文件内容并粘贴到以下单元格中(例如,creds = '{"username": "xxx", "key": "xxx"}'
):
creds = ''
然后执行该单元格(只需运行一次):
# 在 Python 中处理路径时,我推荐使用 `pathlib.Path` 。
from pathlib import Path
cred_path = Path('~/.kaggle/kaggle.json').expanduser()
if not cred_path.exists():
cred_path.parent.mkdir(exist_ok=True)
cred_path.write_text(creds)
cred_path.chmod(0o600)
现在就可以从 Kaggle 下载数据集。
path = Path('us-patent-phrase-to-phrase-matching')
然后使用 Kaggle API 将数据集下载到该路径并提取出来:
if not iskaggle and not path.exists():
import zipfile,kaggle
kaggle.api.competition_download_cli(str(path))
zipfile.ZipFile(f'{path}.zip').extractall(path)
if iskaggle:
path = Path('../input/us-patent-phrase-to-phrase-matching')
! pip install -q datasets
NLP 数据集中的文档通常有两种主要形式:
在 Jupyter 中,你可以使用任何 bash/shell 命令,以 ! 开头,并使用 {} 包含 python 变量,就像这样:
!ls {path}
sample_submission.csv
test.csv train.csv
看来这次比赛使用的是 CSV 文件。要打开、操作和查看 CSV 文件,一般最好使用 Pandas
库,一般情况下,导入时使用缩写 pd
。
import pandas as pd
为数据设置一个路径:
df = pd.read_csv(path/'train.csv')
这将创建一个 DataFrame,它是一个列名表,有点像数据库表。要查看 DataFrame 的首行、末行和行数,只需键入其名称即可:
df
DataFrame 最有用的功能之一是 describe()
方法:
df.describe(include='object')
我们可以看到,在 36473 行中,有 733 个独特的anchor、106 个上下文和近 30000 个目标。有些anchor非常常见,例如 "component composite coating" 就出现了 152 次。
早些时候,我建议我们可以用类似 "TEXT1:abatement;TEXT2:eliminating process"
的方式来表示模型的输入。我们还需要添加上下文。在 Pandas 中,我们只需使用 + 来连接,就像这样:
df['input'] = 'TEXT1: ' + df.context + '; TEXT2: ' + df.target + '; ANC1: ' + df.anchor
我们可以使用普通的 python "dotted"
符号来引用列(也称序列),也可以像访问字典一样访问列。要获取前几行,请使用 head()
:
df.input.head()
0 TEXT1: A47; TEXT2: abatement of pollution; ANC...
1 TEXT1: A47; TEXT2: act of abating; ANC1: abate...
2 TEXT1: A47; TEXT2: active catalyst; ANC1: abat...
3 TEXT1: A47; TEXT2: eliminating process; ANC1: ...
4 TEXT1: A47; TEXT2: forest region; ANC1: abatement
Name: input, dtype: object
Transformers 使用 Dataset 对象来存储......当然是数据集!我们可以这样创建一个
from datasets import Dataset,DatasetDict
ds = Dataset.from_pandas(df)
ds
Dataset({
features: ['id', 'anchor', 'target',
'context', 'score', 'input'],
num_rows: 36473
})
但我们不能将文本直接输入模型。深度学习模型需要输入数字,而不是英文句子!因此,我们需要做两件事:
具体方法取决于我们使用的特定模型。因此,首先需要选择一个模型。有成千上万的模型可供选择,但几乎所有 NLP 问题中,一般都是从使用 small 模型开始(在完成探索后,将 "small
" 替换为 "large
",以获得更慢但更精确的模型):
model_nm = 'microsoft/deberta-v3-small'
AutoTokenizer
将为给定模型创建一个合适的标记符号:
from transformers import AutoModelForSequenceClassification,AutoTokenizer
tokz = AutoTokenizer.from_pretrained(model_nm)
Downloading: 0%| | 0.00/52.0 [00:00<?, ?B/s]
Downloading: 0%| | 0.00/578 [00:00<?, ?B/s]
Downloading: 0%| | 0.00/2.35M [00:00<?, ?B/s]
在词汇表中添加特殊 token
后,确保对相关的词嵌入进行了微调或训练。
下面是一个例子,说明标记符如何将文本分割成 tohen
(类似于单词,但也可以是子单词片段,如下所示):
tokz.tokenize("G'day folks, I'm Jeremy from fast.ai!")
['▁G',
"'",
'day',
'▁folks',
',',
'▁I',
"'",
'm',
'▁Jeremy',
'▁from',
'▁fast',
'.',
'ai',
'!']
不常见的单词将被分割成片段。新词的开头用 ▁ 表示:
tokz.tokenize("A platypus is an ornithorhynchus anatinus.")
['▁A',
'▁platypus',
'▁is',
'▁an',
'▁or',
'ni',
'tho',
'rhynch',
'us',
'▁an',
'at',
'inus',
'.']
下面是一个简单的函数,用于标记我们的输入:
def tok_func(x): return tokz(x["input"])
要在数据集的每一行上并行快速运行,这里推荐使用 map
函数:
tok_ds = ds.map(tok_func, batched=True)
0%| | 0/37 [00:00<?, ?ba/s]
这将为我们的数据集添加一个名为 input_ids
的新项目。例如,下面是第一行数据的输入和 ID:
row = tok_ds[0]
row['input'], row['input_ids']
('TEXT1: A47; TEXT2: abatement of pollution; ANC1: abatement',
[1,
54453,
435,
294,
...
294,
47284,
2])
那么,这些 ID 是什么,它们从何而来?秘密在于tokenizer
中有一个名为 vocab
的列表,其中包含每个可能 token
字符串的唯一整数。可以像这样查找它们,例如查找单词 of
的token
:
tokz.vocab['▁of']
265
查看上面的输入 ID,我们确实看到 265 如预期出现。
最后需要准备标签。Transformers 总是假定标签的列名是 labels,但在数据集中,目前的列名是 score 需要重新命名它:
tok_ds = tok_ds.rename_columns({'score':'labels'})
现在已经准备好了token
和 labels
,需要创建验证集。
可能已经注意到,还有另一个文件:
eval_df = pd.read_csv(path/'test.csv')
eval_df.describe()
这就是测试集。机器学习中最重要的理念可能就是拥有独立的训练、验证和测试数据集。
想象一下拟合一个真实关系为二次方的模型:
def f(x): return -3*x**2 + 2*x + 20
而 matplotlib(Python 中最常用的绘图库)并不提供可视化函数的方法,因此要自己写一些东西来实现这一点:
import numpy as np, matplotlib.pyplot as plt
def plot_function(f, min=-2.1, max=2.1, color='r'):
x = np.linspace(min,max, 100)[:,None]
plt.plot(x, f(x), color)
plot_function(f)
例如,可能在某个事件发生前后测量了某个物体离地面的高度。测量结果会有一些随机误差。可以使用 numpy 的随机数生成器来模拟这种情况。
from numpy.random import normal,seed,uniform
np.random.seed(42)
这里有一个函数 add_noise
,可以为数组添加一些随机变化:
def noise(x, scale): return normal(scale=scale, size=x.shape)
def add_noise(x, mult, add): return x * (1+noise(x,mult)) + noise(x,add)
用它来模拟一些随时间均匀分布的测量值:
x = np.linspace(-2, 2, num=20)[:,None]
y = add_noise(f(x), 0.2, 1.3)
plt.scatter(x,y);
现在,看看对这些预测结果欠拟合或过拟合会发生什么。为此创建一个函数来拟合某个度数的多项式。
from sklearn.linear_model import LinearRegression
from sklearn.preprocessing import PolynomialFeatures
from sklearn.pipeline import make_pipeline
def plot_poly(degree):
model = make_pipeline(PolynomialFeatures(degree), LinearRegression())
model.fit(x, y)
plt.scatter(x,y)
plot_function(model.predict)
那么,如果用一条线来拟合我们的测量结果会怎样呢?
plot_poly(1)
如图所示,红线(拟合的直线)上的s点并不十分接近。这就是欠拟合,函数没有足够的细节来匹配数据。
如果我们在测量结果上拟合一个十项式,会发生什么情况?
plot_poly(10)
现在它与数据拟合得更好了,但看起来它并不能很好地预测除测量的点以外的其他点,尤其是较早或较晚时间段的点。这就是过拟合 -- 模型中的细节太多,以至于模型只拟合了点,却没有拟合真正关心的基本过程。
现在试试 二项式(二次函数),并与 "真实" 函数(蓝色)进行比较:
plot_poly(2)
plot_function(f, color='b')
那么,如何识别模型是欠拟合、过拟合还是 "恰到好处" 呢?我们使用验证集。这是一组从训练中 "保留" 下来的数据。如果使用 fastai 库,如果没有验证集,它会自动创建一个验证集,并始终使用验证集报告指标(模型准确性的测量)。
验证集仅用于了解模拟情况。它从来不会被用作训练模型的输入。
Transformers 使用 DatasetDict 来保存训练集和验证集。要创建一个包含 25% 验证集数据和 75% 训练集数据的数据集,可使用 train_test_split
:
dds = tok_ds.train_test_split(0.25, seed=42)
dds
DatasetDict({
train: Dataset({
features: ['id', 'anchor', 'target', 'context', 'labels', 'input', 'input_ids', 'token_type_ids', 'attention_mask'],
num_rows: 27354
})
test: Dataset({
features: ['id', 'anchor', 'target', 'context', 'labels', 'input', 'input_ids', 'token_type_ids', 'attention_mask'],
num_rows: 9119
})
})
如上图所示,这里的验证设置名为 test
,而不是 validate
,所以要小心!
在实践中,像我们这里使用的随机拆分可能并不是一个好主意--下面是雷切尔-托马斯博士对此的看法:
造成开发结果与生产结果脱节的罪魁祸首之一是验证集选择不当(或者更糟糕的是根本没有验证集)。根据数据的性质,选择验证集可能是最重要的一步。虽然 sklearn 提供了
train_test_split
方法,但这种方法采用的是数据的随机子集,对于许多实际问题来说,这是一种糟糕的选择。
这就是验证集的解释和创建。那么 "测试集" 呢?
测试集是另一个与训练无关的数据集。只有在完成整个训练过程(包括尝试不同的模型、训练方法、数据处理等)后,才能检查测试集上模型的准确性。
有时,当我们训练完模型后,查看在验证集上指标的时,可能会意外地发现一些,它们完全巧合地改善了验证集指标,但在实践中并没有真正改善。只要有足够的时间和实验,就会发现很多这样的巧合改进。这个实际上是过度拟合了验证集!
这就是我们需要测试集的原因。Kaggle 的公开排行榜就像是一个测试集,你可以时不时地查看一下。但不要检查得太频繁,否则你甚至会过度拟合测试集!
Kaggle 还有第二个测试集,这是另一个不公开的数据集,只在比赛结束时用于评估你的预测。这就是 "私人排行榜"。
我们将使用 eval 作为测试集的名称,以避免与上文创建的测试数据集混淆。
eval_df['input'] = 'TEXT1: ' + eval_df.context + '; TEXT2: ' + eval_df.target + '; ANC1: ' + eval_df.anchor
eval_ds = Dataset.from_pandas(eval_df).map(tok_func, batched=True)
0%| | 0/1 [00:00<?, ?ba/s]
当训练一个模型时,会有一个或多个我们希望最大化或最小化的指标。希望这些指标能代表模型对我们的工作有多大帮助。
在现实生活中,在 Kaggle 之外,事情并不容易......
在 Kaggle 中,要知道使用什么指标非常简单:Kaggle 会告诉你!根据该竞赛的评估页面,如根据预测和实际相似性得分之间的皮尔逊相关系数进行评估,该系数通常用单个字母 r
缩写,是衡量两个变量之间关系程度最广泛使用的指标。
r
可以在 -1
和 +1
之间变化,前者表示完全反相关,后者表示完全正相关。数学公式并不重要,重要的是对不同值的直观感受。首先尝试使用加利福尼亚州住房[3]数据集来看一些例子,该数据集显示 "加利福尼亚州各区房屋价值的中位数,单位为十万美元"。这个数据集由优秀的 scikit-learn[4] 库提供,它是深度学习之外使用最广泛的机器学习库。
from sklearn.datasets import fetch_california_housing
housing = fetch_california_housing(as_frame=True)
housing = housing['data'].join(housing['target']).sample(1000, random_state=52)
housing.head()
我们可以通过调用 np.corrcoef
查看该数据集中各列组合的所有相关系数:
np.set_printoptions(precision=2, suppress=True)
np.corrcoef(housing, rowvar=False)
array([[ 1. , -0.12, 0.43, -0.08, 0.01, -0.07, -0.12, 0.04, 0.68],
[-0.12, 1. , -0.17, -0.06, -0.31, 0. , 0.03, -0.13, 0.12],
[ 0.43, -0.17, 1. , 0.76, -0.09, -0.07, 0.12, -0.03, 0.21],
[-0.08, -0.06, 0.76, 1. , -0.08, -0.07, 0.09, 0. , -0.04],
[ 0.01, -0.31, -0.09, -0.08, 1. , 0.16, -0.15, 0.13, 0. ],
[-0.07, 0. , -0.07, -0.07, 0.16, 1. , -0.16, 0.17, -0.27],
[-0.12, 0.03, 0.12, 0.09, -0.15, -0.16, 1. , -0.93, -0.16],
[ 0.04, -0.13, -0.03, 0. , 0.13, 0.17, -0.93, 1. , -0.03],
[ 0.68, 0.12, 0.21, -0.04, 0. , -0.27, -0.16, -0.03, 1. ]])
当我们要同时获取大量数值时,这种方法很有效,但当我们要获取单一系数时,这种方法就显得多余了:
np.corrcoef(housing.MedInc, housing.MedHouseVal)
array([[1. , 0.68],
[0.68, 1. ]])
因此,我们将创建这个小函数,在给定一对变量的情况下,只返回我们需要的一个数字:
def corr(x,y): return np.corrcoef(x,y)[0][1]
corr(housing.MedInc, housing.MedHouseVal)
0.6760250732906
现在,我们用这个函数(函数的细节并不重要)来看几个相关性的例子:
def show_corr(df, a, b):
x,y = df[a],df[b]
plt.scatter(x,y, alpha=0.5, s=4)
plt.title(f'{a} vs {b}; r: {corr(x, y):.2f}')
好了,来看看收入与房屋价值之间的相关性:
show_corr(housing, 'MedInc', 'MedHouseVal')
这就是 0.68 的相关性。这是一个相当接近的关系,但仍然存在很大的差异。(顺便提一下,这也说明了为什么查看数据如此重要--我们可以从图中清楚地看到,50 万美元以上的房价似乎被截断到了最大值)。
我们再来看看另一对:
show_corr(housing, 'MedInc', 'AveRooms')
这种关系看起来与前一个例子相似,但 r
比收入与估值的关系要低得多。为什么会这样呢?原因在于有很多离群值,即 AveRooms
值远远超出平均值。
r
对异常值非常敏感。如果你的数据中有异常值,那么它们之间的关系就会主导指标。在这种情况下,房间数非常多的房子往往并不那么有价值,因此会降低 R 值。
现在剔除异常值,再试一次:
subset = housing[housing.AveRooms<15]
show_corr(subset, 'MedInc', 'AveRooms')
现在的相关性与第一次比较非常相似。
下面是在子集上使用 AveRooms 的另一种关系:
show_corr(subset, 'MedHouseVal', 'AveRooms')
在这一水平上,r
值为 0.34,关系变得相当微弱。
show_corr(subset, 'HouseAge', 'AveRooms')
如图所示,-0.2
的相关性显示出非常微弱的负趋势。
我们现在已经看到了各种相关系数水平的例子,希望你已经对这个指标的含义有了很好的了解。
Transformers 希望度量值以 dict
的形式返回,因为这样训练器才知道要使用什么标签,所以创建一个函数:
def corr_d(eval_pred): return {'pearson': corr(*eval_pred)}
要训练Transformers中的模型,我们需要这个:
from transformers import TrainingArguments,Trainer
我们选择了适合 GPU 的批次大小和较少的历元数,以便快速运行实验:
bs = 128
epochs = 4
最重要的超参数是学习率。Fastai 提供了一个学习率搜索器来帮助我们找出学习率,但 Transformers 没有,所以你只能通过不断尝试来找出答案。我们的想法是找到一个最大值,但不会导致训练失败。
lr = 8e-5
Transformers 使用 TrainingArguments 类来设置参数。不用太在意在这里使用的参数值,它们在大多数情况下都能正常工作。只是上面的 3 个参数可能需要根据不同的模型进行更改。
args = TrainingArguments('outputs',
learning_rate=lr,
warmup_ratio=0.1,
lr_scheduler_type='cosine',
fp16=True, evaluation_strategy="epoch",
per_device_train_batch_size=bs,
per_device_eval_batch_size=bs*2,
num_train_epochs=epochs,
weight_decay=0.01,
report_to='none')
现在,可以创建模型和 Trainer
,后者是一个将数据和模型结合在一起的类(就像 fastai 中的 Learner
一样):
model = AutoModelForSequenceClassification.from_pretrained(model_nm, num_labels=1)
trainer = Trainer(model, args,
train_dataset=dds['train'],
eval_dataset=dds['test'],
tokenizer=tokz,
compute_metrics=corr_d)
Downloading: 0%| |
0.00/273M [00:00<?, ?B/s]
trainer.train();
关键是要看上表中的 "Pearson"
值。如你所见,它在不断增加,已经超过了 0.8。这是个好消息!如果想在官方排行榜上获得分数,现在就可以向 Kaggle 提交预测了。
在测试集上预测结果:
preds = trainer.predict(eval_ds).predictions.astype(float)
preds
array([[ 0.51],
[ 0.65],
...
[-0. ],
[-0.03],
...
[ 0.46],
[ 0.21]])
注意--我们的一些预测结果<0
或>1
!这再一次说明了切记查看数据的重要性。
修正这些超出范围的预测:
preds = np.clip(preds, 0, 1)
preds
array([[0.51],
[0.65],
...
[0. ],
[0.03],
...
[0.46],
[0.21]])
好了,现在可以创建提交文件了。如果将 CSV 保存在notebook中,就可以选择稍后提交。
import datasets
submission = datasets.Dataset.from_dict({
'id': eval_ds['id'],
'score': preds
})
submission.to_csv('submission.csv', index=False)
Creating CSV from Arrow format:
0%| | 0/1 [00:00<?, ?ba/s]
857
不幸的是,这是一场code competition,互联网访问被禁用。如果你想提交到 Kaggle,就不能使用上面的 pip install datasets。要解决这个问题,你需要先下载 pip 安装程序到 Kaggle,如这里所述[5]。下载完成后,在笔记本中禁用互联网,进入 Kaggle 排行榜页面,点击 "Submission" 按钮。
如果你有任何问题或想法,请随时发表评论。
[1]
美国专利短语匹配: https://www.kaggle.com/competitions/us-patent-phrase-to-phrase-matching/
[2]
比赛页面: https://www.kaggle.com/c/us-patent-phrase-to-phrase-matching
[3]
加利福尼亚州住房: https://scikit-learn.org/stable/datasets/real_world.html#california-housing-dataset
[4]
scikit-learn: https://scikit-learn.org/stable/
[5]
https://www.kaggle.com/c/severstal-steel-defect-detection/discussion/113195