6.2. 特征提取#

sklearn.feature_extraction 模块可用于从由文本和图像等格式组成的 dataset 中提取机器学习算法支持的格式的特征。

注意

特征提取与 特征选择 非常不同:前者包括将任意数据(如文本或图像)转换为可用于机器学习的数值特征。后者是一种应用于这些特征的机器学习技术。

6.2.1. 从字典加载特征#

该类 DictVectorizer 可用于将表示为标准 Python dict 对象列表的特征数组转换为 scikit-learn 估计器使用的 NumPy/SciPy 表示。

虽然处理速度不是特别快,但 Python 的 dict 具有易于使用、稀疏(不需要存储缺失的特征)以及除了值之外还存储特征名称的优点。

DictVectorizer 实现了所谓的 one-of-K 或“one-hot”编码,用于分类(也称为名义、离散)特征。分类特征是“属性-值”对,其中值限制为一组离散的可能性,没有排序(例如,主题标识符、对象类型、标签、名称……)。

在以下示例中,“城市”是一个分类属性,而“温度”是一个传统的数值特征

>>> measurements = [
...     {'city': 'Dubai', 'temperature': 33.},
...     {'city': 'London', 'temperature': 12.},
...     {'city': 'San Francisco', 'temperature': 18.},
... ]

>>> from sklearn.feature_extraction import DictVectorizer
>>> vec = DictVectorizer()

>>> vec.fit_transform(measurements).toarray()
array([[ 1.,  0.,  0., 33.],
       [ 0.,  1.,  0., 12.],
       [ 0.,  0.,  1., 18.]])

>>> vec.get_feature_names_out()
array(['city=Dubai', 'city=London', 'city=San Francisco', 'temperature'], ...)

DictVectorizer 接受一个特征的多个字符串值,例如,电影的多个类别。

假设一个数据库使用一些类别(非强制性)和它的发布年份对每部电影进行分类。

>>> movie_entry = [{'category': ['thriller', 'drama'], 'year': 2003},
...                {'category': ['animation', 'family'], 'year': 2011},
...                {'year': 1974}]
>>> vec.fit_transform(movie_entry).toarray()
array([[0.000e+00, 1.000e+00, 0.000e+00, 1.000e+00, 2.003e+03],
       [1.000e+00, 0.000e+00, 1.000e+00, 0.000e+00, 2.011e+03],
       [0.000e+00, 0.000e+00, 0.000e+00, 0.000e+00, 1.974e+03]])
>>> vec.get_feature_names_out()
array(['category=animation', 'category=drama', 'category=family',
       'category=thriller', 'year'], ...)
>>> vec.transform({'category': ['thriller'],
...                'unseen_feature': '3'}).toarray()
array([[0., 0., 0., 1., 0.]])

DictVectorizer 也是自然语言处理模型中训练序列分类器的一个有用的表示转换,这些模型通常通过提取围绕特定感兴趣词的特征窗口来工作。

例如,假设我们有一个第一个算法提取词性 (PoS) 标签,我们想将这些标签用作训练序列分类器(例如,分块器)的补充标签。以下字典可以是围绕句子“The cat sat on the mat.”中的词“sat”提取的这样一个特征窗口。

>>> pos_window = [
...     {
...         'word-2': 'the',
...         'pos-2': 'DT',
...         'word-1': 'cat',
...         'pos-1': 'NN',
...         'word+1': 'on',
...         'pos+1': 'PP',
...     },
...     # in a real application one would extract many such dictionaries
... ]

此描述可以被向量化为一个稀疏二维矩阵,适合馈送到分类器(可能在通过管道传输到 TfidfTransformer 进行归一化之后)。

>>> vec = DictVectorizer()
>>> pos_vectorized = vec.fit_transform(pos_window)
>>> pos_vectorized
<1x6 sparse matrix of type '<... 'numpy.float64'>'
    with 6 stored elements in Compressed Sparse ... format>
>>> pos_vectorized.toarray()
array([[1., 1., 1., 1., 1., 1.]])
>>> vec.get_feature_names_out()
array(['pos+1=PP', 'pos-1=NN', 'pos-2=DT', 'word+1=on', 'word-1=cat',
       'word-2=the'], ...)

可以想象,如果一个人在文档语料库的每个单词周围提取这样的上下文,那么生成的矩阵将非常宽(许多 one-hot 特征),其中大多数特征在大多数情况下都将被赋值为零。为了使生成的数据结构能够放入内存,DictVectorizer 类默认使用 scipy.sparse 矩阵而不是 numpy.ndarray

6.2.2. 特征哈希#

FeatureHasher 是一种高速、低内存的向量化器,它使用一种称为 特征哈希 或“哈希技巧”的技术。与向量化器构建训练中遇到的特征的哈希表不同,FeatureHasher 的实例将哈希函数应用于特征,以直接确定它们在样本矩阵中的列索引。结果是速度提高和内存使用量减少,但代价是可检查性;哈希器不记得输入特征是什么样子,也没有 inverse_transform 方法。

由于哈希函数可能会导致(不相关)特征之间的冲突,因此使用带符号的哈希函数,哈希值的符号决定了为特征存储在输出矩阵中的值的符号。这样,冲突更有可能抵消而不是累积错误,并且任何输出特征值的预期均值为零。此机制在默认情况下通过 alternate_sign=True 启用,对于小型哈希表大小(n_features < 10000)特别有用。对于大型哈希表大小,可以禁用它,以允许将输出传递给像 MultinomialNBchi2 这样的特征选择器,这些选择器需要非负输入。

FeatureHasher 接受映射(如 Python 的 dict 及其在 collections 模块中的变体)、(feature, value) 对或字符串,具体取决于构造函数参数 input_type。映射被视为 (feature, value) 对的列表,而单个字符串具有隐式值为 1,因此 ['feat1', 'feat2', 'feat3'] 被解释为 [('feat1', 1), ('feat2', 1), ('feat3', 1)]。如果单个特征在一个样本中出现多次,则关联的值将被求和(因此 ('feat', 2)('feat', 3.5) 变成 ('feat', 5.5))。FeatureHasher 的输出始终是 CSR 格式的 scipy.sparse 矩阵。

特征哈希可以用于文档分类,但与 CountVectorizer 不同,FeatureHasher 不进行词语分割或任何其他预处理,除了 Unicode 到 UTF-8 编码;有关组合的标记器/哈希器的更多信息,请参见下面的 使用哈希技巧向量化大型文本语料库

例如,考虑一个词级自然语言处理任务,该任务需要从 (token, part_of_speech) 对中提取特征。可以使用 Python 生成器函数来提取特征

def token_features(token, part_of_speech):
    if token.isdigit():
        yield "numeric"
    else:
        yield "token={}".format(token.lower())
        yield "token,pos={},{}".format(token, part_of_speech)
    if token[0].isupper():
        yield "uppercase_initial"
    if token.isupper():
        yield "all_uppercase"
    yield "pos={}".format(part_of_speech)

然后,要馈送到 FeatureHasher.transformraw_X 可以使用以下方法构建

raw_X = (token_features(tok, pos_tagger(tok)) for tok in corpus)

并馈送到哈希器中

hasher = FeatureHasher(input_type='string')
X = hasher.transform(raw_X)

以获得 scipy.sparse 矩阵 X

请注意使用生成器推导,这在特征提取中引入了惰性:只有在哈希器需要时才会处理标记。

实现细节#

FeatureHasher 使用 MurmurHash3 的带符号 32 位变体。因此(以及由于 scipy.sparse 的限制),当前支持的特征最大数量为 \(2^{31} - 1\)

Weinberger 等人提出的哈希技巧的原始公式使用两个独立的哈希函数 \(h\)\(\xi\) 来分别确定特征的列索引和符号。当前的实现是在 MurmurHash3 的符号位与其其他位无关的假设下工作的。

由于使用简单的模运算将哈希函数转换为列索引,因此建议使用 2 的幂作为 n_features 参数;否则,特征将不会均匀地映射到列。

参考文献

参考文献

6.2.3. 文本特征提取#

6.2.3.1. 词袋表示#

文本分析是机器学习算法的主要应用领域。然而,原始数据,即符号序列,不能直接馈送到算法本身,因为大多数算法期望具有固定大小的数值特征向量,而不是具有可变长度的原始文本文档。

为了解决这个问题,scikit-learn 提供了用于从文本内容中提取数值特征的最常见方法的实用程序,即

  • 标记化字符串并为每个可能的标记提供一个整数 ID,例如通过使用空格和标点符号作为标记分隔符。

  • 计算每个文档中标记的出现次数。

  • 规范化并使用递减的重要性对在大多数样本/文档中出现的标记进行加权。

在此方案中,特征和样本定义如下

  • 每个单个标记出现频率(是否规范化)都被视为一个特征

  • 给定文档的所有标记频率的向量被认为是一个多元样本

因此,文档语料库可以用一个矩阵来表示,该矩阵每行代表一个文档,每列代表语料库中出现的标记(例如单词)。

我们将向量化定义为将文本文档集合转换为数值特征向量的通用过程。这种特定策略(标记化、计数和规范化)称为词袋或“n 元词袋”表示。文档通过词语出现次数来描述,而完全忽略了词语在文档中的相对位置信息。

6.2.3.2. 稀疏性#

由于大多数文档通常会使用语料库中使用的一小部分单词,因此生成的矩阵将具有许多特征值为零(通常超过 99%)。

例如,一个包含 10,000 个简短文本文档(例如电子邮件)的集合将使用一个词汇表,该词汇表的大小约为 100,000 个唯一词,而每个文档将分别使用 100 到 1000 个唯一词。

为了能够将这样的矩阵存储在内存中,同时还能加速矩阵/向量的代数运算,实现通常会使用稀疏表示,例如 scipy.sparse 包中提供的实现。

6.2.3.3. 常见的向量化器用法#

CountVectorizer 在一个类中实现了标记化和出现次数计数。

>>> from sklearn.feature_extraction.text import CountVectorizer

此模型具有许多参数,但是默认值相当合理(有关详细信息,请参阅 参考文档)。

>>> vectorizer = CountVectorizer()
>>> vectorizer
CountVectorizer()

让我们使用它来标记化并统计一个极简的文本文档语料库的词语出现次数。

>>> corpus = [
...     'This is the first document.',
...     'This is the second second document.',
...     'And the third one.',
...     'Is this the first document?',
... ]
>>> X = vectorizer.fit_transform(corpus)
>>> X
<4x9 sparse matrix of type '<... 'numpy.int64'>'
    with 19 stored elements in Compressed Sparse ... format>

默认配置通过提取至少包含 2 个字母的词语来标记化字符串。可以显式请求执行此步骤的特定函数。

>>> analyze = vectorizer.build_analyzer()
>>> analyze("This is a text document to analyze.") == (
...     ['this', 'is', 'text', 'document', 'to', 'analyze'])
True

分析器在拟合过程中找到的每个词语都会被分配一个唯一的整数索引,该索引对应于结果矩阵中的一列。可以按如下方式检索对列的这种解释。

>>> vectorizer.get_feature_names_out()
array(['and', 'document', 'first', 'is', 'one', 'second', 'the',
       'third', 'this'], ...)

>>> X.toarray()
array([[0, 1, 1, 1, 0, 0, 1, 0, 1],
       [0, 1, 0, 1, 0, 2, 1, 0, 1],
       [1, 0, 0, 0, 1, 0, 1, 1, 0],
       [0, 1, 1, 1, 0, 0, 1, 0, 1]]...)

从特征名称到列索引的逆映射存储在向量化的 vocabulary_ 属性中。

>>> vectorizer.vocabulary_.get('document')
1

因此,在训练语料库中未见过的词语将在以后对 transform 方法的调用中被完全忽略。

>>> vectorizer.transform(['Something completely new.']).toarray()
array([[0, 0, 0, 0, 0, 0, 0, 0, 0]]...)

请注意,在前面的语料库中,第一个和最后一个文档具有完全相同的词语,因此被编码为相等的向量。特别是,我们丢失了最后一个文档是疑问句形式的信息。为了保留一些局部排序信息,除了 1-gram(单个词语)之外,我们还可以提取 2-gram 的词语。

>>> bigram_vectorizer = CountVectorizer(ngram_range=(1, 2),
...                                     token_pattern=r'\b\w+\b', min_df=1)
>>> analyze = bigram_vectorizer.build_analyzer()
>>> analyze('Bi-grams are cool!') == (
...     ['bi', 'grams', 'are', 'cool', 'bi grams', 'grams are', 'are cool'])
True

因此,此向量化器提取的词汇表要大得多,现在可以解决局部定位模式中编码的歧义。

>>> X_2 = bigram_vectorizer.fit_transform(corpus).toarray()
>>> X_2
array([[0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0],
       [0, 0, 1, 0, 0, 1, 1, 0, 0, 2, 1, 1, 1, 0, 1, 0, 0, 0, 1, 1, 0],
       [1, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 0],
       [0, 0, 1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0, 1]]...)

特别是,疑问句形式“Is this”仅存在于最后一个文档中。

>>> feature_index = bigram_vectorizer.vocabulary_.get('is this')
>>> X_2[:, feature_index]
array([0, 0, 0, 1]...)

6.2.3.4. 使用停用词#

停用词是指诸如“and”、“the”、“him”之类的词语,这些词语被认为在表示文本内容方面没有信息量,并且可以将其删除以避免将其解释为预测信号。但是,有时类似的词语对于预测很有用,例如在对写作风格或个性进行分类时。

我们提供的“英语”停用词列表中存在一些已知问题。它并不旨在成为通用的“一刀切”解决方案,因为某些任务可能需要更自定义的解决方案。有关更多详细信息,请参阅 [NQY18]

请谨慎选择停用词列表。流行的停用词列表可能包含对某些任务具有高度信息量的词语,例如“computer”。

您还应确保停用词列表已应用与向量化器中使用的相同的预处理和标记化。词语“we’ve”被 CountVectorizer 的默认标记器拆分为“we”和“ve”,因此,如果“we’ve”在 stop_words 中,但“ve”不在,则“ve”将从已转换文本中的“we’ve”中保留。我们的向量化器将尝试识别并警告某些类型的不一致。

参考文献

[NQY18]

J. Nothman、H. Qin 和 R. Yurchak (2018)。“Stop Word Lists in Free Open-source Software Packages”。在Proc. Workshop for NLP Open Source Software 中。

6.2.3.5. Tf-idf 词语权重#

在一个大型文本语料库中,某些词语会非常频繁地出现(例如英语中的“the”、“a”、“is”),因此它们几乎没有关于文档实际内容的有意义的信息。如果我们将直接计数数据直接提供给分类器,那么这些非常频繁的词语会掩盖更稀有但更有趣的词语的频率。

为了将计数特征重新加权为适合分类器使用的浮点数,使用 tf-idf 变换非常普遍。

Tf 表示词语频率,而 tf-idf 表示词语频率乘以逆文档频率\(\text{tf-idf(t,d)}=\text{tf(t,d)} \times \text{idf(t)}\).

使用 TfidfTransformer 的默认设置,TfidfTransformer(norm='l2', use_idf=True, smooth_idf=True, sublinear_tf=False),词语频率(即词语在给定文档中出现的次数)乘以 idf 分量,该分量计算如下

\(\text{idf}(t) = \log{\frac{1 + n}{1+\text{df}(t)}} + 1\),

其中 \(n\) 是文档集中文档的总数,而 \(\text{df}(t)\) 是文档集中包含词语 \(t\) 的文档数量。然后,通过欧几里得范数对得到的 tf-idf 向量进行归一化

\(v_{norm} = \frac{v}{||v||_2} = \frac{v}{\sqrt{v{_1}^2 + v{_2}^2 + \dots + v{_n}^2}}\).

这最初是为信息检索而开发的一种词语权重方案(作为搜索引擎结果的排名函数),它在文档分类和聚类中也得到了很好的应用。

以下部分包含更详细的解释和示例,说明了 tf-idf 的确切计算方式以及 scikit-learn 的 TfidfTransformerTfidfVectorizer 中计算的 tf-idf 与定义 idf 为

\(\text{idf}(t) = \log{\frac{n}{1+\text{df}(t)}}.\)

TfidfTransformerTfidfVectorizer 中,如果 smooth_idf=False,则“1”计数将添加到 idf 中,而不是添加到 idf 的分母中

\(\text{idf}(t) = \log{\frac{n}{\text{df}(t)}} + 1\)

这种归一化由 TfidfTransformer 类实现。

>>> from sklearn.feature_extraction.text import TfidfTransformer
>>> transformer = TfidfTransformer(smooth_idf=False)
>>> transformer
TfidfTransformer(smooth_idf=False)

同样,请参阅 参考文档,了解所有参数的详细信息。

tf-idf 矩阵的数字示例#

让我们以以下计数为例。第一个词语在 100% 的时间内都存在,因此不是很有趣。另外两个特征仅在不到 50% 的时间内存在,因此可能更能代表文档的内容。

>>> counts = [[3, 0, 1],
...           [2, 0, 0],
...           [3, 0, 0],
...           [4, 0, 0],
...           [3, 2, 0],
...           [3, 0, 2]]
...
>>> tfidf = transformer.fit_transform(counts)
>>> tfidf
<6x3 sparse matrix of type '<... 'numpy.float64'>'
    with 9 stored elements in Compressed Sparse ... format>

>>> tfidf.toarray()
array([[0.81940995, 0.        , 0.57320793],
      [1.        , 0.        , 0.        ],
      [1.        , 0.        , 0.        ],
      [1.        , 0.        , 0.        ],
      [0.47330339, 0.88089948, 0.        ],
      [0.58149261, 0.        , 0.81355169]])

每行都归一化为具有单位欧几里得范数。

\(v_{norm} = \frac{v}{||v||_2} = \frac{v}{\sqrt{v{_1}^2 + v{_2}^2 + \dots + v{_n}^2}}\)

例如,我们可以计算 counts 数组中第一个文档中第一个词语的 tf-idf,如下所示

\(n = 6\)

\(\text{df}(t)_{\text{term1}} = 6\)

\(\text{idf}(t)_{\text{term1}} = \log \frac{n}{\text{df}(t)} + 1 = \log(1)+1 = 1\)

\(\text{tf-idf}_{\text{term1}} = \text{tf} \times \text{idf} = 3 \times 1 = 3\)

现在,如果我们对文档中的剩余 2 个词语重复此计算,我们将得到

\(\text{tf-idf}_{\text{term2}} = 0 \times (\log(6/1)+1) = 0\)

\(\text{tf-idf}_{\text{term3}} = 1 \times (\log(6/2)+1) \approx 2.0986\)

以及原始 tf-idf 向量

\(\text{tf-idf}_{\text{raw}} = [3, 0, 2.0986].\)

然后,应用欧几里得(L2)范数,我们得到文档 1 的以下 tf-idf

\(\frac{[3, 0, 2.0986]}{\sqrt{\big(3^2 + 0^2 + 2.0986^2\big)}} = [ 0.819, 0, 0.573].\)

此外,默认参数 smooth_idf=True 在分子和分母中添加“1”,就好像看到一个额外的文档,其中包含集合中每个词恰好出现一次,从而防止零除

\(\text{idf}(t) = \log{\frac{1 + n}{1+\text{df}(t)}} + 1\)

使用此修改,文档 1 中第三个词的 tf-idf 变为 1.8473

\(\text{tf-idf}_{\text{term3}} = 1 \times \log(7/3)+1 \approx 1.8473\)

L2 归一化后的 tf-idf 变为

\(\frac{[3, 0, 1.8473]}{\sqrt{\big(3^2 + 0^2 + 1.8473^2\big)}} = [0.8515, 0, 0.5243]\):

>>> transformer = TfidfTransformer()
>>> transformer.fit_transform(counts).toarray()
array([[0.85151335, 0.        , 0.52433293],
      [1.        , 0.        , 0.        ],
      [1.        , 0.        , 0.        ],
      [1.        , 0.        , 0.        ],
      [0.55422893, 0.83236428, 0.        ],
      [0.63035731, 0.        , 0.77630514]])

通过 fit 方法调用计算的每个特征的权重存储在模型属性中

>>> transformer.idf_
array([1. ..., 2.25..., 1.84...])

由于 tf-idf 经常用于文本特征,因此还有一个名为 TfidfVectorizer 的类,它将 CountVectorizerTfidfTransformer 的所有选项组合到一个模型中

>>> from sklearn.feature_extraction.text import TfidfVectorizer
>>> vectorizer = TfidfVectorizer()
>>> vectorizer.fit_transform(corpus)
<4x9 sparse matrix of type '<... 'numpy.float64'>'
    with 19 stored elements in Compressed Sparse ... format>

虽然 tf-idf 归一化通常非常有用,但可能存在二进制出现标记可能提供更好特征的情况。这可以通过使用 CountVectorizerbinary 参数来实现。特别是,一些估计器,如 伯努利朴素贝叶斯,明确地对离散布尔随机变量进行建模。此外,非常短的文本可能具有嘈杂的 tf-idf 值,而二进制出现信息更稳定。

像往常一样,调整特征提取参数的最佳方法是使用交叉验证网格搜索,例如通过将特征提取器与分类器进行管道化

6.2.3.6. 解码文本文件#

文本由字符组成,但文件由字节组成。这些字节根据某种编码来表示字符。要在 Python 中处理文本文件,必须将其字节解码为称为 Unicode 的字符集。常见的编码包括 ASCII、Latin-1(西欧)、KOI8-R(俄语)以及通用编码 UTF-8 和 UTF-16。还有许多其他编码。

注意

编码也可以称为“字符集”,但这个术语不太准确:一个字符集可以存在多个编码。

scikit-learn 中的文本特征提取器知道如何解码文本文件,但前提是您告诉它们文件采用什么编码。 CountVectorizer 接受一个 encoding 参数来实现此目的。对于现代文本文件,正确的编码可能是 UTF-8,因此它是默认值 (encoding="utf-8")。

但是,如果您加载的文本实际上不是用 UTF-8 编码的,您将收到一个 UnicodeDecodeError。可以通过将 decode_error 参数设置为 "ignore""replace" 来告诉向量化器对解码错误保持沉默。有关更多详细信息,请参阅 Python 函数 bytes.decode 的文档(在 Python 提示符处键入 help(bytes.decode))。

对解码文本进行故障排除#

如果您在解码文本时遇到问题,请尝试以下方法

  • 找出文本的实际编码。该文件可能附带一个标题或自述文件,其中说明了编码,或者您可能可以根据文本的来源假设某种标准编码。

  • 您可能可以使用 UNIX 命令 file 找出它的一般编码类型。Python chardet 模块附带一个名为 chardetect.py 的脚本,它将猜测特定的编码,尽管您不能依赖于它的猜测是正确的。

  • 您可以尝试使用 UTF-8 并忽略错误。您可以使用 bytes.decode(errors='replace') 解码字节字符串,将所有解码错误替换为无意义的字符,或者在向量化器中设置 decode_error='replace'。这可能会损害特征的有用性。

  • 真实的文本可能来自各种来源,这些来源可能使用了不同的编码,甚至可能以与编码不同的编码进行粗心解码。这在从 Web 中检索的文本中很常见。Python 包 ftfy 可以自动解决某些类型的解码错误,因此您可以尝试将未知文本解码为 latin-1,然后使用 ftfy 来修复错误。

  • 如果文本采用多种编码的混合形式,难以整理(20 个新闻组数据集就是这种情况),您可以回退到简单的单字节编码,例如 latin-1。某些文本可能显示不正确,但至少相同的字节序列将始终表示相同的特征。

例如,以下代码段使用 chardet(未与 scikit-learn 一起提供,必须单独安装)来找出三个文本的编码。然后它对文本进行向量化并打印学习到的词汇表。输出此处未显示。

>>> import chardet    
>>> text1 = b"Sei mir gegr\xc3\xbc\xc3\x9ft mein Sauerkraut"
>>> text2 = b"holdselig sind deine Ger\xfcche"
>>> text3 = b"\xff\xfeA\x00u\x00f\x00 \x00F\x00l\x00\xfc\x00g\x00e\x00l\x00n\x00 \x00d\x00e\x00s\x00 \x00G\x00e\x00s\x00a\x00n\x00g\x00e\x00s\x00,\x00 \x00H\x00e\x00r\x00z\x00l\x00i\x00e\x00b\x00c\x00h\x00e\x00n\x00,\x00 \x00t\x00r\x00a\x00g\x00 \x00i\x00c\x00h\x00 \x00d\x00i\x00c\x00h\x00 \x00f\x00o\x00r\x00t\x00"
>>> decoded = [x.decode(chardet.detect(x)['encoding'])
...            for x in (text1, text2, text3)]        
>>> v = CountVectorizer().fit(decoded).vocabulary_    
>>> for term in v: print(v)                           

(根据 chardet 的版本,它可能会将第一个文本弄错。)

有关 Unicode 和字符编码的总体介绍,请参阅 Joel Spolsky 的 每个软件开发人员必须了解的关于 Unicode 的绝对最小知识

6.2.3.7. 应用和示例#

词袋表示法非常简单,但在实践中却出奇地有用。

特别是在监督设置中,它可以与快速且可扩展的线性模型成功地结合起来,以训练文档分类器,例如

无监督设置中,它可以通过应用聚类算法(例如 K 均值)将相似的文档分组在一起

最后,可以通过放宽聚类的硬分配约束来发现语料库的主要主题,例如使用 非负矩阵分解 (NMF 或 NNMF)

6.2.3.8. 词袋表示法的局限性#

一组单字词(词袋是什么)无法捕获短语和多字词表达式,有效地忽略了任何词序依赖关系。此外,词袋模型没有考虑潜在的拼写错误或词语派生。

N 元语法来拯救!与其构建一个简单的单字词集合(n=1),不如构建一个二元语法集合(n=2),其中计算连续词对的出现次数。

或者,可以考虑一个字符 n 元语法集合,这是一种对拼写错误和派生具有弹性的表示形式。

例如,假设我们正在处理一个包含两个文档的语料库:['words', 'wprds']。第二个文档包含单词“words”的拼写错误。一个简单的词袋表示形式会将这两个文档视为截然不同的文档,在两个可能的特征中都不同。然而,一个字符 2 元语法表示形式会发现这两个文档在 8 个特征中的 4 个特征上匹配,这可能有助于首选分类器做出更好的决定

>>> ngram_vectorizer = CountVectorizer(analyzer='char_wb', ngram_range=(2, 2))
>>> counts = ngram_vectorizer.fit_transform(['words', 'wprds'])
>>> ngram_vectorizer.get_feature_names_out()
array([' w', 'ds', 'or', 'pr', 'rd', 's ', 'wo', 'wp'], ...)
>>> counts.toarray().astype(int)
array([[1, 1, 1, 0, 1, 1, 1, 0],
       [1, 1, 0, 1, 1, 1, 0, 1]])

在上面的示例中,使用了 char_wb 分析器,它仅从词边界内的字符创建 n 元语法(两侧用空格填充)。另一方面,char 分析器创建跨越单词的 n 元语法

>>> ngram_vectorizer = CountVectorizer(analyzer='char_wb', ngram_range=(5, 5))
>>> ngram_vectorizer.fit_transform(['jumpy fox'])
<1x4 sparse matrix of type '<... 'numpy.int64'>'
   with 4 stored elements in Compressed Sparse ... format>
>>> ngram_vectorizer.get_feature_names_out()
array([' fox ', ' jump', 'jumpy', 'umpy '], ...)

>>> ngram_vectorizer = CountVectorizer(analyzer='char', ngram_range=(5, 5))
>>> ngram_vectorizer.fit_transform(['jumpy fox'])
<1x5 sparse matrix of type '<... 'numpy.int64'>'
    with 5 stored elements in Compressed Sparse ... format>
>>> ngram_vectorizer.get_feature_names_out()
array(['jumpy', 'mpy f', 'py fo', 'umpy ', 'y fox'], ...)

词边界感知变体 char_wb 对使用空格进行词语分隔的语言特别有趣,因为它在这种情况下生成的噪声特征明显少于原始 char 变体。对于此类语言,它可以提高使用此类特征训练的分类器的预测精度和收敛速度,同时保持对拼写错误和词语派生的鲁棒性。

虽然可以通过提取 n 元语法而不是单个单词来保留一些局部位置信息,但词袋和词袋 n 元语法破坏了文档的大部分内部结构,因此也破坏了该内部结构所承载的大部分含义。

为了解决更广泛的自然语言理解任务,因此应考虑句子和段落的局部结构。因此,许多此类模型将被视为“结构化输出”问题,目前超出了 scikit-learn 的范围。

6.2.3.9. 使用哈希技巧对大型文本语料库进行向量化#

上述向量化方案很简单,但它持有一个从字符串标记到整数特征索引的内存内映射vocabulary_ 属性)会导致处理大型数据集时出现几个问题

  • 语料库越大,词汇表就越大,因此内存使用量也会越大,

  • 拟合需要分配大小与原始数据集成比例的中间数据结构。

  • 构建词映射需要对数据集进行完整遍历,因此无法以严格在线的方式拟合文本分类器。

  • 对具有大型 vocabulary_ 的向量化器进行序列化和反序列化可能非常慢(通常比序列化/反序列化相同大小的 NumPy 数组等扁平数据结构慢得多),

  • 由于 vocabulary_ 属性必须是具有细粒度同步屏障的共享状态,因此无法轻松地将向量化工作拆分为并发子任务:从标记字符串到特征索引的映射取决于每个标记首次出现的顺序,因此必须共享,这可能会损害并发工作者的性能,使其速度比顺序变体更慢。

可以通过结合 特征哈希(由 FeatureHasher 类实现)和 CountVectorizer 的文本预处理和标记化功能来克服这些限制。

这种组合在 HashingVectorizer 中实现,这是一个与 CountVectorizer 大部分 API 兼容的转换器类。 HashingVectorizer 是无状态的,这意味着您不必对其调用 fit

>>> from sklearn.feature_extraction.text import HashingVectorizer
>>> hv = HashingVectorizer(n_features=10)
>>> hv.transform(corpus)
<4x10 sparse matrix of type '<... 'numpy.float64'>'
    with 16 stored elements in Compressed Sparse ... format>

您可以看到在向量输出中提取了 16 个非零特征标记:这少于 CountVectorizer 在相同玩具语料库上之前提取的 19 个非零值。差异来自哈希函数冲突,因为 n_features 参数的值很低。

在现实世界中,n_features 参数可以保留其默认值 2 ** 20(大约一百万个可能的特征)。如果内存或下游模型大小是一个问题,选择较低的值,例如 2 ** 18,可能会在不引入太多额外冲突的情况下提供帮助,尤其是在典型的文本分类任务中。

请注意,维度不会影响在 CSR 矩阵上操作的算法的 CPU 训练时间(LinearSVC(dual=True)PerceptronSGDClassifierPassiveAggressive),但会影响在 CSC 矩阵上操作的算法(LinearSVC(dual=False)Lasso() 等)。

让我们再次尝试使用默认设置

>>> hv = HashingVectorizer()
>>> hv.transform(corpus)
<4x1048576 sparse matrix of type '<... 'numpy.float64'>'
    with 19 stored elements in Compressed Sparse ... format>

我们不再遇到冲突,但这是以输出空间的维度大幅增加为代价的。当然,除了这里使用的 19 个词之外,其他词可能仍然会相互冲突。

HashingVectorizer 还存在以下限制

  • 由于执行映射的哈希函数的单向性,无法反转模型(没有 inverse_transform 方法),也无法访问特征的原始字符串表示。

  • 它不提供 IDF 加权,因为这会在模型中引入状态。如果需要,可以将 TfidfTransformer 附加到管道中。

使用 HashingVectorizer 进行离线缩放#

使用 HashingVectorizer 的一个有趣发展是能够执行 离线 缩放。这意味着我们可以从不适合计算机主内存的数据中学习。

实现离线缩放的一种策略是将数据以小批量的方式流式传输到估计器。每个小批量使用 HashingVectorizer 进行向量化,以确保估计器的输入空间始终具有相同的维度。因此,任何时候使用的内存量都由小批量的尺寸限制。虽然这种方法可以摄取的数据量没有限制,但从实际的角度来看,学习时间通常受限于人们愿意在任务上花费的 CPU 时间。

有关文本分类任务中离线缩放的完整示例,请参见 文本文档的离线分类

6.2.3.10. 自定义向量化器类#

可以通过将可调用对象传递给向量化器构造函数来自定义行为

>>> def my_tokenizer(s):
...     return s.split()
...
>>> vectorizer = CountVectorizer(tokenizer=my_tokenizer)
>>> vectorizer.build_analyzer()(u"Some... punctuation!") == (
...     ['some...', 'punctuation!'])
True

特别是,我们命名

  • preprocessor:一个可调用对象,它以整个文档作为输入(作为单个字符串),并返回文档的可能转换版本,仍然作为整个字符串。这可用于删除 HTML 标签、将整个文档转换为小写等。

  • tokenizer:一个可调用对象,它接收来自预处理器的输出并将其拆分为标记,然后返回这些标记的列表。

  • analyzer:一个可调用对象,它替换预处理器和标记器。默认分析器都会调用预处理器和标记器,但自定义分析器会跳过此步骤。N 元语法提取和停用词过滤在分析器级别进行,因此自定义分析器可能需要重新执行这些步骤。

(Lucene 用户可能认识这些名称,但请注意,scikit-learn 概念可能不会与 Lucene 概念一一对应。)

为了使预处理器、标记器和分析器了解模型参数,可以从类派生并覆盖 build_preprocessorbuild_tokenizerbuild_analyzer 工厂方法,而不是传递自定义函数。

技巧#
  • 如果文档由外部包预先标记化,则将它们存储在以空格分隔标记的文件(或字符串)中,并传递 analyzer=str.split

  • scikit-learn 代码库中不包含诸如词干提取、词形还原、复合词拆分、基于词性的过滤等高级标记级别分析,但可以通过自定义标记器或分析器来添加。以下是一个使用 NLTK 的带有标记器和词形还原器的 CountVectorizer

    >>> from nltk import word_tokenize          
    >>> from nltk.stem import WordNetLemmatizer 
    >>> class LemmaTokenizer:
    ...     def __init__(self):
    ...         self.wnl = WordNetLemmatizer()
    ...     def __call__(self, doc):
    ...         return [self.wnl.lemmatize(t) for t in word_tokenize(doc)]
    ...
    >>> vect = CountVectorizer(tokenizer=LemmaTokenizer())  
    

    (请注意,这不会过滤掉标点符号。)

    例如,以下示例将转换一些英国拼写为美国拼写

    >>> import re
    >>> def to_british(tokens):
    ...     for t in tokens:
    ...         t = re.sub(r"(...)our$", r"\1or", t)
    ...         t = re.sub(r"([bt])re$", r"\1er", t)
    ...         t = re.sub(r"([iy])s(e$|ing|ation)", r"\1z\2", t)
    ...         t = re.sub(r"ogue$", "og", t)
    ...         yield t
    ...
    >>> class CustomVectorizer(CountVectorizer):
    ...     def build_tokenizer(self):
    ...         tokenize = super().build_tokenizer()
    ...         return lambda doc: list(to_british(tokenize(doc)))
    ...
    >>> print(CustomVectorizer().build_analyzer()(u"color colour"))
    [...'color', ...'color']
    

    对于其他预处理样式;示例包括词干提取、词形还原或规范化数值标记,后者在以下示例中进行了说明

在处理不使用空格等显式词分隔符的亚洲语言时,自定义向量化器也很有用。

6.2.4. 图像特征提取#

6.2.4.1. 补丁提取#

extract_patches_2d 函数从存储为二维数组的图像中提取补丁,或者存储为三维数组,其中颜色信息沿第三轴。要从所有补丁重建图像,请使用 reconstruct_from_patches_2d。例如,让我们生成一个具有 3 个颜色通道(例如,RGB 格式)的 4x4 像素图片

>>> import numpy as np
>>> from sklearn.feature_extraction import image

>>> one_image = np.arange(4 * 4 * 3).reshape((4, 4, 3))
>>> one_image[:, :, 0]  # R channel of a fake RGB picture
array([[ 0,  3,  6,  9],
       [12, 15, 18, 21],
       [24, 27, 30, 33],
       [36, 39, 42, 45]])

>>> patches = image.extract_patches_2d(one_image, (2, 2), max_patches=2,
...     random_state=0)
>>> patches.shape
(2, 2, 2, 3)
>>> patches[:, :, :, 0]
array([[[ 0,  3],
        [12, 15]],

       [[15, 18],
        [27, 30]]])
>>> patches = image.extract_patches_2d(one_image, (2, 2))
>>> patches.shape
(9, 2, 2, 3)
>>> patches[4, :, :, 0]
array([[15, 18],
       [27, 30]])

现在让我们尝试通过对重叠区域进行平均来从补丁重建原始图像

>>> reconstructed = image.reconstruct_from_patches_2d(patches, (4, 4, 3))
>>> np.testing.assert_array_equal(one_image, reconstructed)

PatchExtractor 类的工作方式与 extract_patches_2d 相同,只是它支持多个图像作为输入。它被实现为一个 scikit-learn 变换器,因此它可以在管道中使用。见

>>> five_images = np.arange(5 * 4 * 4 * 3).reshape(5, 4, 4, 3)
>>> patches = image.PatchExtractor(patch_size=(2, 2)).transform(five_images)
>>> patches.shape
(45, 2, 2, 3)

6.2.4.2. 图像的连通图#

scikit-learn 中的几个估计器可以使用特征或样本之间的连通性信息。例如,Ward 聚类 (层次聚类) 只能将图像的相邻像素聚类在一起,从而形成连续的块

../_images/sphx_glr_plot_coin_ward_segmentation_001.png

为此,估计器使用一个“连通性”矩阵,给出哪些样本是连接的。

函数 img_to_graph 从 2D 或 3D 图像返回这样的矩阵。类似地,grid_to_graph 为给定形状的图像构建一个连通性矩阵。

这些矩阵可用于在使用连通性信息的估计器中强制连通性,例如 Ward 聚类 (层次聚类),但也用于构建预先计算的内核或相似性矩阵。