二十三、核外学习 - 用于语义分析的大规模文本分类

优质
小牛编辑
128浏览
2023-12-01

可扩展性问题

sklearn.feature_extraction.text.CountVectorizersklearn.feature_extraction.text.TfidfVectorizer类受到许多可伸缩性问题的困扰,这些问题都源于vocabulary_属性(Python 字典)的内部使用,它用于将 unicode 字符串特征名称映射为整数特征索引。

主要的可扩展性问题是:

  • 文本向量化程序的内存使用情况:所有特征的字符串表示形式都加载到内存中
  • 文本特征提取的并行化问题:vocabulary_是一个共享状态:复杂的同步和开销
  • 不可能进行在线或核外/流式学习:vocabulary_需要从数据中学习:在遍历一次整个数据集之前无法知道其大小

为了更好地理解这个问题,让我们看一下vocabulary_属性的工作原理。 在fit的时候,语料库的标记由整数索引唯一标识,并且该映射存储在词汇表中:

from sklearn.feature_extraction.text import CountVectorizer

vectorizer = CountVectorizer(min_df=1)

vectorizer.fit([
    "The cat sat on the mat.",
])
vectorizer.vocabulary_

transform的时候,使用词汇表来构建出现矩阵:

X = vectorizer.transform([
    "The cat sat on the mat.",
    "This cat is a nice cat.",
]).toarray()

print(len(vectorizer.vocabulary_))
print(vectorizer.get_feature_names())
print(X)

让我们用稍大的语料库重新拟合:

vectorizer = CountVectorizer(min_df=1)

vectorizer.fit([
    "The cat sat on the mat.",
    "The quick brown fox jumps over the lazy dog.",
])
vectorizer.vocabulary_

vocabulary_随着训练语料库的大小而(以对数方式)增长。 请注意,我们无法在 2 个文本文档上并行构建词汇表,因为它们共享一些单词,因此需要某种共享数据结构或同步障碍,这对于设定来说很复杂,特别是如果我们想要将处理过程分发给集群的时候。

有了这个新的词汇表,输出空间的维度现在变大了:

X = vectorizer.transform([
    "The cat sat on the mat.",
    "This cat is a nice cat.",
]).toarray()

print(len(vectorizer.vocabulary_))
print(vectorizer.get_feature_names())
print(X)

IMDB 电影数据集

为了说明基于词汇的向量化器的可扩展性问题,让我们为经典文本分类任务加载更真实的数据集:文本文档的情感分析。目标是从互联网电影数据库(IMDb)中区分出积极的电影评论。

在接下来的章节中,使用了 Maas 等人收集的来自 IMDb 的电影评论的大型子集

A. L. Maas, R. E. Daly, P. T. Pham, D. Huang, A. Y. Ng, and C. Potts. Learning Word Vectors for Sentiment Analysis. In the proceedings of the 49th Annual Meeting of the Association for Computational Linguistics: Human Language Technologies, pages 142–150, Portland, Oregon, USA, June 2011. Association for Computational Linguistics.

该数据集包含 50,000 个电影评论,分为 25,000 个培训样本和 25,000 个测试样本。评论标记为负面(neg)或正面(pos)。此外,正面意味着电影在 IMDb 上收到> 6星;负面意味着电影收到<5星。

假设../fetch_data.py脚本成功运行,以下文件应该可用:

import os

train_path = os.path.join('datasets', 'IMDb', 'aclImdb', 'train')
test_path = os.path.join('datasets', 'IMDb', 'aclImdb', 'test')

现在,让我们通过 scikit-learn 的load_files函数,将它们加载到我们的活动会话中:

from sklearn.datasets import load_files

train = load_files(container_path=(train_path),
                   categories=['pos', 'neg'])

test = load_files(container_path=(test_path),
                  categories=['pos', 'neg'])

由于电影数据集由 50,000 个单独的文本文件组成,因此执行上面的代码片段可能需要约 20 秒或更长时间。

load_files函数将数据集加载到sklearn.datasets.base.Bunch对象中,这些对象是 Python 字典:

train.keys()

特别是,我们只对datatarget数组感兴趣。

import numpy as np

for label, data in zip(('TRAINING', 'TEST'), (train, test)):
    print('\n\n%s' % label)
    print('Number of documents:', len(data['data']))
    print('\n1st document:\n', data['data'][0])
    print('\n1st label:', data['target'][0])
    print('\nClass names:', data['target_names'])
    print('Class count:', 
          np.unique(data['target']), ' -> ',
          np.bincount(data['target']))

正如我们在上面所看到的,target数组由整数 0 和 1 组成,其中 0 代表负面,1 代表正面。

哈希技巧

回忆一下,使用基于词汇表的向量化器的词袋表示:

要解决基于词汇表的向量化器的局限性,可以使用散列技巧。 我们可以使用散列函数和模运算,而不是在 Python 字典中构建和存储特征名称到特征索引的显式映射:

对于哈希技巧的原始论文的更多信息和参考,请见以下网站,以及特定于语言的描述请见这里

from sklearn.utils.murmurhash import murmurhash3_bytes_u32

# encode for python 3 compatibility
for word in "the cat sat on the mat".encode("utf-8").split():
    print("{0} => {1}".format(
        word, murmurhash3_bytes_u32(word, 0) % 2 ** 20))

这种映射完全是无状态的,并且输出空间的维度预先明确固定(这里我们使用2 ** 20的模,这意味着大约 1M 的维度)。 这使得有可能解决基于词汇表的向量化器的局限性,既可用于并行化,也可用于在线/核外学习。

HashingVectorizer类是CountVectorizer(或use_idf=FalseTfidfVectorizer类)的替代品,它在内部使用 murmurhash 哈希函数:

from sklearn.feature_extraction.text import HashingVectorizer

h_vectorizer = HashingVectorizer(encoding='latin-1')
h_vectorizer

它共享相同的“预处理器”,“分词器”和“分析器”基础结构:

analyzer = h_vectorizer.build_analyzer()
analyzer('This is a test sentence.')

我们可以将数据集向量化为scipy稀疏矩阵,就像我们使用CountVectorizerTfidfVectorizer一样,除了我们可以直接调用transform方法:没有必要拟合,因为HashingVectorizer是无状态变换器:

docs_train, y_train = train['data'], train['target']
docs_valid, y_valid = test['data'][:12500], test['target'][:12500]
docs_test, y_test = test['data'][12500:], test['target'][12500:]

默认情况下,输出的维度事先固定为n_features = 2 ** 20(接近 1M 个特征),来最大限度地减少大多数分类问题的碰撞率,同时具有合理大小的线性模型(coef_属性中的 1M 权重):

h_vectorizer.transform(docs_train)

现在,让我们将HashingVectorizer的计算效率与CountVectorizer进行比较:

h_vec = HashingVectorizer(encoding='latin-1')
%timeit -n 1 -r 3 h_vec.fit(docs_train, y_train)

count_vec =  CountVectorizer(encoding='latin-1')
%timeit -n 1 -r 3 count_vec.fit(docs_train, y_train)

我们可以看到,在这种情况下,HashingVectorizerCountvectorizer快得多。

最后,让我们在 IMDb 训练子集上训练一个LogisticRegression分类器:

from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline

h_pipeline = Pipeline([
    ('vec', HashingVectorizer(encoding='latin-1')),
    ('clf', LogisticRegression(random_state=1)),
])

h_pipeline.fit(docs_train, y_train)

print('Train accuracy', h_pipeline.score(docs_train, y_train))
print('Validation accuracy', h_pipeline.score(docs_valid, y_valid))

import gc

del count_vec
del h_pipeline

gc.collect()

核外学习

核外学习是在不放不进内存或 RAM 的数据集上训练机器学习模型的任务。 这需要以下条件:

具有固定输出维度的特征提取层 提前知道所有类别的列表(在这种情况下,我们只有正面和负面的评论) 支持增量学习的机器学习算法(scikit-learn 中的partial_fit方法)。

在以下部分中,我们将建立一个简单的批量训练函数来迭代地训练SGDClassifier

但首先,让我们将文件名加载到 Python 列表中:

train_path = os.path.join('datasets', 'IMDb', 'aclImdb', 'train')
train_pos = os.path.join(train_path, 'pos')
train_neg = os.path.join(train_path, 'neg')

fnames = [os.path.join(train_pos, f) for f in os.listdir(train_pos)] +\
         [os.path.join(train_neg, f) for f in os.listdir(train_neg)]

fnames[:3]

接下来,让我们创建目标标签数组:

y_train = np.zeros((len(fnames), ), dtype=int)
y_train[:12500] = 1
np.bincount(y_train)

现在,我们实现batch_train函数,如下所示:

from sklearn.base import clone

def batch_train(clf, fnames, labels, iterations=25, batchsize=1000, random_seed=1):
    vec = HashingVectorizer(encoding='latin-1')
    idx = np.arange(labels.shape[0])
    c_clf = clone(clf)
    rng = np.random.RandomState(seed=random_seed)

    for i in range(iterations):
        rnd_idx = rng.choice(idx, size=batchsize)
        documents = []
        for i in rnd_idx:
            with open(fnames[i], 'r', encoding='latin-1') as f:
                documents.append(f.read())
        X_batch = vec.transform(documents)
        batch_labels = labels[rnd_idx]
        c_clf.partial_fit(X=X_batch, 
                          y=batch_labels, 
                          classes=[0, 1])

    return c_clf

请注意,我们没有像上一节中那样使用LogisticRegression,但我们将使用具有 logistic 成本函数的SGDClassifierSGD代表随机梯度下降,这是一种优化算法,它逐样本迭代地优化权重系数,这允许我们一块一块地将数据馈送给分类器。

我们训练SGDClassifier;使用batch_train函数的默认设置,它将在25 * 1000 = 25000个文档上训练分类器。 (根据你的机器,这可能需要>2分钟)

from sklearn.linear_model import SGDClassifier

sgd = SGDClassifier(loss='log', random_state=1, max_iter=1000)

sgd = batch_train(clf=sgd,
                  fnames=fnames,
                  labels=y_train)

最后,让我们评估一下它的表现:

vec = HashingVectorizer(encoding='latin-1')
sgd.score(vec.transform(docs_test), y_test)

哈希向量化器的限制

使用Hashing Vectorizer可以实现流式和并行文本分类,但也可能会引入一些问题:

  • 碰撞会在数据中引入太多噪声并降低预测质量,
  • HashingVectorizer不提供“反向文档频率”重新加权(缺少use_idf=True选项)。
  • 没有反转映射,和从特征索引中查找特征名称的简单方法。
  • 可以通过增加n_features参数来控制冲突问题。

可以通过在向量化器的输出上附加TfidfTransformer实例来重新引入 IDF 加权。然而,用于特征重新加权的idf_统计量的计算,需要在能够开始训练分类器之前,额外遍历训练集至少一次:这打破了在线学习方案。

缺少逆映射(TfidfVectorizerget_feature_names()方法)更难以解决。这将需要扩展HashingVectorizer类来添加“跟踪”模式,来记录最重要特征的映射,来提供统计调试信息。

在调试特征提取问题的同时,建议在数据集的小型子集上使用TfidfVectorizer(use_idf=False),来模拟具有get_feature_names()方法且没有冲突问题的HashingVectorizer()实例。

练习

在我们上面的batch_train函数的实现中,我们在每次迭代中随机抽取k个训练样本作为批量,这可以被视为带放回的随机子采样。 你可以修改batch_train函数,使它无放回地迭代文档,即它在每次迭代中使用每个文档一次。

# %load solutions/23_batchtrain.py