6.2. 特征提取#

sklearn.feature_extraction 模块可用于从包含文本和图像等格式的数据集中提取机器学习算法支持的特征格式。

Note

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

6.2.1. 从字典加载特征#

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

虽然处理速度不是特别快,但 Python 的 dict 具有使用方便、稀疏(不需要存储不存在的特征)以及存储特征名称和值的优点。

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

在下面的例子中,”city” 是一个分类属性,而 “temperature” 是一个传统的数值特征:

>>> 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',
...     },
...     # 在实际应用中,会提取许多这样的字典
... ]

这种描述可以被向量化成一个稀疏的二维矩阵

适合输入分类器(可能在通过 TfidfTransformer 进行归一化之后):

>>> vec = DictVectorizer()
>>> pos_vectorized = vec.fit_transform(pos_window)
>>> pos_vectorized
<1x6 稀疏矩阵,类型为 '<... 'numpy.float64'>'
    包含 6 个存储元素,采用压缩稀疏...格式>
>>> 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'], ...)

如你所想,如果在文档语料库中每个单词周围提取这样的上下文,结果矩阵将会非常宽(许多独热特征),其中大多数特征值在大多数时间都是零。为了使结果数据结构能够适应内存, 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 的输出始终是一个 scipy.sparse 矩阵,采用 CSR 格式。

特征哈希可用于文档分类,但与 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 的符号位与其其他位独立。

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

参考文献

参考文献

6.2.3. 文本特征提取#

6.2.3.1. 词袋表示#

文本分析是机器学习算法的主要应用领域。 然而,原始数据,一系列符号,不能直接输入

直接应用于算法本身,因为大多数算法期望的是具有固定大小的数值特征向量,而不是具有可变长度的原始文本文档。

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

  • 分词:将字符串分词并为每个可能的词元分配一个整数 ID,例如使用空格和标点符号作为词元分隔符。

  • 计数:统计每个文档中词元的出现次数。

  • 归一化:对在大多数样本/文档中出现的词元进行权重递减处理。

在这种方案中,特征和样本的定义如下:

  • 每个**单独词元的出现频率**(归一化或不归一化)被视为一个**特征**。

  • 给定**文档**中所有词元频率的向量被视为一个多元**样本**。

因此,文档集合可以表示为一个矩阵,其中每行代表一个文档,每列代表在语料库中出现的词元(例如单词)。

我们称**向量化**是将文本文档集合转换为数值特征向量的通用过程。这种特定的策略(分词、计数和归一化)被称为**词袋**或“n-gram 词袋”表示法。文档由单词的出现次数描述,同时完全忽略单词在文档中的相对位置信息。

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 稀疏矩阵,类型为'<... 'numpy.int64'>'
    包含19个存储元素,采用压缩稀疏...格式>

默认配置通过提取至少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” 等,被认为在表示文本内容时信息量不大,可能会在处理文本时被移除。 为了避免这些词被误解为预测的信号,有时会将其移除。然而,在某些情况下,类似的词对于预测是有用的,例如在分类写作风格或个性时。

我们提供的 ‘english’ 停用词列表存在几个已知问题。它并不旨在成为一个通用的、“一刀切”的解决方案,因为某些任务可能需要更定制化的解决方案。更多详情请参见 [NQY18]

请谨慎选择停用词列表。流行的停用词列表可能包含对某些任务非常有信息价值的词,例如 computer

您还应确保停用词列表经过了与向量化器中使用的相同的预处理和分词处理。单词 we’ve 被 CountVectorizer 的默认分词器拆分为 weve,因此如果 we’vestop_words 中,但 ve 不在,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)

词频(term frequency),即一个词在给定文档中出现的次数, 与逆文档频率(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 矩阵的数值示例

让我们以以下计数为例。第一个词在所有文档中都出现,因此不太有趣。 其他两个特征仅在某些文档中出现,因此可能更能代表文档的内容。

在不到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]])

每一行都归一化,使其具有单位欧几里得范数:

:math:`v_{norm} = \frac{v}{||v||_2} = \frac{v}{\sqrt{v{_1}^2 +
v{_2}^2 + \dots + v{_n}^2}}`

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

:math:`n = 6`

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

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

:math:`\text{tf-idf}_{\text{term1}} = \text{tf} \times \text{idf} = 3 \times 1 = 3`

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

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

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

以及原始tf-idf向量:

:math:`\text{tf-idf}_{\text{raw}} = [3, 0, 2.0986].`


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

:math:`\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 的类,它在一个模型中结合了:class:CountVectorizer 和:class:TfidfTransformer 的所有选项:

>>> 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归一化通常非常有用,但在某些情况下,二进制出现标记可能提供更好的特征。这可以通过:class:CountVectorizerbinary 参数来实现。特别是,一些估计器如:ref:bernoulli_naive_bayes 明确地对离散布尔随机变量进行建模。此外,非常短的文本可能具有噪声tf-idf值,而二进制出现信息则更稳定。 通常,调整特征提取参数的最佳方法是使用交叉验证的网格搜索,例如通过将特征提取器与分类器流水线化:

6.2.3.6. 解码文本文件#

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

Note

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

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

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

#解码文本故障排除

如果你在解码文本时遇到问题,以下是一些尝试的方法:

  • 找出文本的实际编码是什么。文件可能带有一个标头或README文件告诉你编码,或者根据文本的来源,你可能可以假设某种标准的编码。

  • 你可能能够根据一般情况找出它是什么类型的编码。 使用 UNIX 命令 file 。Python 的 chardet 模块附带了一个名为 chardetect.py 的脚本,可以猜测具体的编码,尽管你不能依赖它的猜测是正确的。

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

  • 真实文本可能来自使用不同编码的各种来源,或者甚至被草率地以不同于其编码的编码解码。这在从 Web 检索的文本中很常见。Python 包 ftfy 可以自动解决一些类别的解码错误,因此你可以尝试将未知文本解码为 latin-1 ,然后使用 ftfy 修复错误。

  • 如果文本是混合了多种编码,以至于难以整理(例如 20 Newsgroups 数据集的情况),你可以回退到简单的单字节编码,如 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)                           

(Depending on the version of chardet , it might get the first one wrong.)

For an introduction to Unicode and character encodings in general, see Joel Spolsky’s Absolute Minimum Every Software Developer Must Know About Unicode .

6.2.3.7. Applications and examples#

The bag of words representation is quite simplistic but surprisingly useful in practice.

In particular in a supervised setting it can be successfully combined with fast and scalable linear models to train document classifiers, for instance:

In an unsupervised setting it can be used to group similar documents together by applying clustering algorithms such as K均值 :

Finally it is possible to discover the main topics of a corpus by relaxing the hard assignment constraint of clustering, for instance by using 非负矩阵分解(NMF 或 NNMF) :

6.2.3.8. Limitations of the Bag of Words representation#

A collection of unigrams (what bag of words is) cannot capture phrases and multi-word expressions, effectively disregarding any word order

依赖性。此外,词袋模型没有考虑到潜在的拼写错误或词形变化。

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

另一种选择是考虑字符 n-grams 集合,这种表示方法对拼写错误和词形变化具有较强的鲁棒性。

例如,假设我们处理一个包含两个文档的语料库: ['words', 'wprds'] 。第二个文档包含单词 ‘words’ 的拼写错误。 一个简单的词袋表示法会将这两个文档视为非常不同的文档,它们在两个可能的特征上都不同。 然而,字符 2-gram 表示法会发现这两个文档在 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-grams(每个边界两侧填充空格)。 另一种选择是 char 分析器,它创建跨越单词的 n-grams:

>>> 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 稀疏矩阵,类型为 ‘<… ‘numpy.int64’>’

包含 5 个存储元素,采用压缩稀疏…格式>

>>> ngram_vectorizer.get_feature_names_out()
array(['jumpy', 'mpy f', 'py fo', 'umpy ', 'y fox'], ...)

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

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

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

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

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

  • 语料库越大,词汇表增长越大,因此内存使用也越大,

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

  • 构建单词映射需要遍历整个数据集,因此不可能以严格在线的方式拟合文本分类器。

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

  • 很难将向量化工作拆分为并发的子任务,因为 vocabulary_ 属性必须是一个具有细粒度同步屏障的共享状态:从令牌字符串到特征索引的映射依赖于每个令牌首次出现的顺序,因此必须共享,这可能会损害并发工作者的性能,甚至使它们比顺序变体更慢。

通过结合由 FeatureHasher 类实现的“哈希技巧”(特征哈希 )和 CountVectorizer 的文本预处理和分词功能,可以克服这些限制。

这种组合在 HashingVectorizer 中实现,这是一个转换器类,其 API 与 CountVectorizer 基本兼容。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 ,以减少冲突的可能性,尽管这仍然可能导致一些冲突。 18 ``可能有助于在典型的文本分类任务中减少引入过多的额外冲突。

请注意,维度并不影响在 CSR 矩阵上操作的算法的 CPU 训练时间(例如`` LinearSVC(dual=True) Perceptron SGDClassifier PassiveAggressive ),但对于在 CSC 矩阵上工作的算法(例如 LinearSVC(dual=False) Lasso()``等)则有影响。

让我们用默认设置再试一次:

>>> hv = HashingVectorizer()
>>> hv.transform(corpus)
<4x1048576 稀疏矩阵,类型为 '<... 'numpy.float64'>'
    包含 19 个存储元素,采用压缩稀疏...格式>

我们不再遇到冲突,但这是以输出空间的维度大幅增加为代价的。当然,除了这里使用的 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-gram提取和停用词过滤发生在分析器级别,因此自定义分析器可能需要重现这些步骤。

(Lucene用户可能会认出这些名称,但请注意,scikit-learn的概念可能不会一对一地映射到Lucene概念上。)

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

或者对数值令牌进行归一化处理,后者在以下示例中进行了说明:

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

6.2.4. 图像特征提取#

6.2.4.1. 分块提取#

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

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

>>> one_image = np.arange(4 * 4 * 3).reshape((4, 4, 3))
>>> one_image[:, :, 0]  # 假 RGB 图片的 R 通道
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 聚类(层次聚类 ),但也可以用于构建预计算核或相似性矩阵。