文本分类任务#

如前所述,我们将专注于基于 AG_NEWS 数据集的简单文本分类任务,该任务是将新闻标题分类为以下四个类别之一:国际、体育、商业和科技。

数据集#

此数据集已集成到 torchtext 模块中,因此我们可以轻松访问它。

In [1]:
import torch
import torchtext
import os
import collections
os.makedirs('./data',exist_ok=True)
train_dataset, test_dataset = torchtext.datasets.AG_NEWS(root='./data')
classes = ['World', 'Sports', 'Business', 'Sci/Tech']

在这里,train_datasettest_dataset 包含返回标签(类别编号)和文本对的集合,例如:

In [2]:
list(train_dataset)[0]
(3,
 "Wall St. Bears Claw Back Into the Black (Reuters) Reuters - Short-sellers, Wall Street's dwindling\\band of ultra-cynics, are seeing green again.")

那么,让我们打印出数据集中前10条新标题:

In [5]:
for i,x in zip(range(5),train_dataset):
    print(f"**{classes[x[0]]}** -> {x[1]}")
**Sci/Tech** -> Wall St. Bears Claw Back Into the Black (Reuters) Reuters - Short-sellers, Wall Street's dwindling\band of ultra-cynics, are seeing green again.
**Sci/Tech** -> Carlyle Looks Toward Commercial Aerospace (Reuters) Reuters - Private investment firm Carlyle Group,\which has a reputation for making well-timed and occasionally\controversial plays in the defense industry, has quietly placed\its bets on another part of the market.
**Sci/Tech** -> Oil and Economy Cloud Stocks' Outlook (Reuters) Reuters - Soaring crude prices plus worries\about the economy and the outlook for earnings are expected to\hang over the stock market next week during the depth of the\summer doldrums.
**Sci/Tech** -> Iraq Halts Oil Exports from Main Southern Pipeline (Reuters) Reuters - Authorities have halted oil export\flows from the main pipeline in southern Iraq after\intelligence showed a rebel militia could strike\infrastructure, an oil official said on Saturday.
**Sci/Tech** -> Oil prices soar to all-time record, posing new menace to US economy (AFP) AFP - Tearaway world oil prices, toppling records and straining wallets, present a new economic menace barely three months before the US presidential elections.

因为数据集是迭代器,如果我们想多次使用数据,则需要将其转换为列表:

In [3]:
train_dataset, test_dataset = torchtext.datasets.AG_NEWS(root='./data')
train_dataset = list(train_dataset)
test_dataset = list(test_dataset)

分词#

现在我们需要将文本转换为可以表示为张量的数字。如果我们想要基于单词的表示,需要完成两件事:

  • 使用分词器将文本拆分为标记
  • 构建这些标记的词汇表
In [4]:
tokenizer = torchtext.data.utils.get_tokenizer('basic_english')
tokenizer('He said: hello')
['he', 'said', 'hello']
In [5]:
counter = collections.Counter()
for (label, line) in train_dataset:
    counter.update(tokenizer(line))
vocab = torchtext.vocab.vocab(counter, min_freq=1)

使用词汇表,我们可以轻松地将标记化的字符串编码为一组数字:

In [19]:
vocab_size = len(vocab)
print(f"Vocab size if {vocab_size}")

stoi = vocab.get_stoi() # dict to convert tokens to indices

def encode(x):
    return [stoi[s] for s in tokenizer(x)]

encode('I love to play with my words')
Vocab size if 95810
[599, 3279, 97, 1220, 329, 225, 7368]

词袋文本表示法#

由于单词代表了意义,有时我们可以通过仅仅查看单个单词,而不考虑它们在句子中的顺序,就能理解一段文本的含义。例如,在分类新闻时,像 weather(天气)、snow(雪)这样的单词可能表明是 天气预报,而像 stocks(股票)、dollar(美元)这样的单词则可能属于 财经新闻

词袋(Bag of Words, BoW)向量表示法是最常用的传统向量表示法。每个单词都与一个向量索引相关联,向量的元素包含某个单词在给定文档中出现的次数。

展示词袋向量表示在内存中如何表示的图片。

Note: 你也可以将 BoW 理解为文本中每个单词的独热编码向量的总和。

下面是一个使用 Scikit Learn Python 库生成词袋表示的示例:

In [7]:
from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer()
corpus = [
        'I like hot dogs.',
        'The dog ran fast.',
        'Its hot outside.',
    ]
vectorizer.fit_transform(corpus)
vectorizer.transform(['My dog likes hot dogs on a hot day.']).toarray()
array([[1, 1, 0, 2, 0, 0, 0, 0, 0]], dtype=int64)

要从我们的 AG_NEWS 数据集的向量表示计算词袋向量,可以使用以下函数:

In [20]:
vocab_size = len(vocab)

def to_bow(text,bow_vocab_size=vocab_size):
    res = torch.zeros(bow_vocab_size,dtype=torch.float32)
    for i in encode(text):
        if i<bow_vocab_size:
            res[i] += 1
    return res

print(to_bow(train_dataset[0][1]))
tensor([2., 1., 2.,  ..., 0., 0., 0.])

注意: 这里我们使用全局变量 vocab_size 来指定默认的词汇表大小。由于词汇表的大小通常非常大,我们可以将词汇表的大小限制为最常用的词汇。尝试降低 vocab_size 的值并运行下面的代码,看看它如何影响准确性。你应该预期会有一些准确性的下降,但不会太剧烈,以换取更高的性能。

训练 BoW 分类器#

现在我们已经学习了如何构建文本的词袋表示,让我们在其基础上训练一个分类器。首先,我们需要将数据集转换为适合训练的形式,即将所有位置向量表示转换为词袋表示。这可以通过将 bowify 函数作为标准 torch DataLoadercollate_fn 参数传递来实现:

In [21]:
from torch.utils.data import DataLoader
import numpy as np 

# this collate function gets list of batch_size tuples, and needs to 
# return a pair of label-feature tensors for the whole minibatch
def bowify(b):
    return (
            torch.LongTensor([t[0]-1 for t in b]),
            torch.stack([to_bow(t[1]) for t in b])
    )

train_loader = DataLoader(train_dataset, batch_size=16, collate_fn=bowify, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=16, collate_fn=bowify, shuffle=True)

现在让我们定义一个简单的分类器神经网络,它包含一个线性层。输入向量的大小等于 vocab_size,输出大小对应于类别的数量(4)。由于我们正在解决分类任务,最终的激活函数是 LogSoftmax()

In [22]:
net = torch.nn.Sequential(torch.nn.Linear(vocab_size,4),torch.nn.LogSoftmax(dim=1))

现在我们将定义标准的PyTorch训练循环。由于我们的数据集相当大,为了教学目的,我们将只训练一个周期,有时甚至少于一个周期(通过指定epoch_size参数可以限制训练)。我们还将在训练过程中报告累计的训练准确率;报告的频率通过report_freq参数指定。

In [24]:
def train_epoch(net,dataloader,lr=0.01,optimizer=None,loss_fn = torch.nn.NLLLoss(),epoch_size=None, report_freq=200):
    optimizer = optimizer or torch.optim.Adam(net.parameters(),lr=lr)
    net.train()
    total_loss,acc,count,i = 0,0,0,0
    for labels,features in dataloader:
        optimizer.zero_grad()
        out = net(features)
        loss = loss_fn(out,labels) #cross_entropy(out,labels)
        loss.backward()
        optimizer.step()
        total_loss+=loss
        _,predicted = torch.max(out,1)
        acc+=(predicted==labels).sum()
        count+=len(labels)
        i+=1
        if i%report_freq==0:
            print(f"{count}: acc={acc.item()/count}")
        if epoch_size and count>epoch_size:
            break
    return total_loss.item()/count, acc.item()/count
In [25]:
train_epoch(net,train_loader,epoch_size=15000)
3200: acc=0.8028125
6400: acc=0.8371875
9600: acc=0.8534375
12800: acc=0.85765625
(0.026090790722161722, 0.8620069296375267)

二元组、三元组和N元组#

词袋方法的一个局限性是,有些词是多词表达的一部分。例如,“热狗”这个词的含义与“热”和“狗”在其他语境中的含义完全不同。如果我们总是用相同的向量表示“热”和“狗”,可能会让模型感到困惑。

为了解决这个问题,N元组表示通常用于文档分类方法中,其中每个单词、双词或三词的频率是训练分类器的有用特征。例如,在二元组表示中,我们会将所有的词对添加到词汇表中,除了原始单词之外。

下面是一个使用Scikit Learn生成二元组词袋表示的示例:

In [26]:
bigram_vectorizer = CountVectorizer(ngram_range=(1, 2), token_pattern=r'\b\w+\b', min_df=1)
corpus = [
        'I like hot dogs.',
        'The dog ran fast.',
        'Its hot outside.',
    ]
bigram_vectorizer.fit_transform(corpus)
print("Vocabulary:\n",bigram_vectorizer.vocabulary_)
bigram_vectorizer.transform(['My dog likes hot dogs on a hot day.']).toarray()
Vocabulary:
 {'i': 7, 'like': 11, 'hot': 4, 'dogs': 2, 'i like': 8, 'like hot': 12, 'hot dogs': 5, 'the': 16, 'dog': 0, 'ran': 14, 'fast': 3, 'the dog': 17, 'dog ran': 1, 'ran fast': 15, 'its': 9, 'outside': 13, 'its hot': 10, 'hot outside': 6}
array([[1, 0, 1, 0, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]],
      dtype=int64)

N-gram方法的主要缺点是词汇量会迅速增长。在实际应用中,我们需要将N-gram表示与一些降维技术结合使用,例如嵌入,我们将在下一单元中讨论。

为了在我们的AG News数据集中使用N-gram表示,我们需要构建一个特殊的N-gram词汇:

In [27]:
counter = collections.Counter()
for (label, line) in train_dataset:
    l = tokenizer(line)
    counter.update(torchtext.data.utils.ngrams_iterator(l,ngrams=2))
    
bi_vocab = torchtext.vocab.vocab(counter, min_freq=1)

print("Bigram vocabulary length = ",len(bi_vocab))
Bigram vocabulary length =  1308842

我们可以使用上述相同的代码来训练分类器,但这样会非常占用内存。在下一节中,我们将使用嵌入来训练二元分类器。

注意: 你只能保留那些在文本中出现次数超过指定数量的n元组。这将确保不常见的二元组被省略,并显著减少维度。为此,可以将min_freq参数设置为更高的值,并观察词汇表长度的变化。

词频-逆文档频率 TF-IDF#

在 BoW 表示法中,单词的出现被均等对待,无论单词本身如何。然而,很明显,像 ain 这样的常见词对于分类的作用远不如一些专业术语重要。实际上,在大多数 NLP 任务中,有些词比其他词更相关。

TF-IDF词频-逆文档频率 的缩写。它是袋子模型的一种变体,其中不是用二进制的 0/1 值来表示单词是否出现在文档中,而是使用一个浮点值,该值与单词在语料库中的出现频率相关。

更正式地说,单词 $i$ 在文档 $j$ 中的权重 $w_{ij}$ 定义为: $$ w_{ij} = tf_{ij}\times\log({N\over df_i}) $$ 其中:

  • $tf_{ij}$ 是单词 $i$ 在文档 $j$ 中出现的次数,即我们之前看到的 BoW 值
  • $N$ 是语料库中的文档总数
  • $df_i$ 是整个语料库中包含单词 $i$ 的文档数量

TF-IDF 值 $w_{ij}$ 随着单词在文档中出现的次数成比例增加,同时会受到语料库中包含该单词的文档数量的影响,从而调整某些单词出现频率较高的情况。例如,如果某个单词出现在语料库中的每一篇文档中,那么 $df_i=N$,且 $w_{ij}=0$,这些词将被完全忽略。

你可以使用 Scikit Learn 轻松实现文本的 TF-IDF 向量化:

In [28]:
from sklearn.feature_extraction.text import TfidfVectorizer
vectorizer = TfidfVectorizer(ngram_range=(1,2))
vectorizer.fit_transform(corpus)
vectorizer.transform(['My dog likes hot dogs on a hot day.']).toarray()
array([[0.43381609, 0.        , 0.43381609, 0.        , 0.65985664,
        0.43381609, 0.        , 0.        , 0.        , 0.        ,
        0.        , 0.        , 0.        , 0.        , 0.        ,
        0.        ]])

结论#

尽管TF-IDF表示法为不同的单词提供了频率权重,但它无法表达意义或顺序。正如著名语言学家J.R. Firth在1935年所说:“一个词的完整意义总是与上下文相关,任何脱离上下文的意义研究都不应被认真对待。” 在课程的后续部分,我们将学习如何通过语言建模从文本中捕捉上下文信息。


免责声明
本文档使用AI翻译服务Co-op Translator进行翻译。尽管我们努力确保翻译的准确性,但请注意,自动翻译可能包含错误或不准确之处。应以原始语言的文档作为权威来源。对于关键信息,建议使用专业人工翻译。我们对因使用本翻译而引起的任何误解或误读不承担责任。