6.2. 特徵提取#

sklearn.feature_extraction 模組可用於從文字和圖像等格式的數據集中提取機器學習演算法所支援的格式的特徵。

注意

特徵提取與特徵選擇非常不同:前者包括將任意數據(例如文字或圖像)轉換為可用於機器學習的數值特徵。後者是一種應用於這些特徵的機器學習技術。

6.2.1. 從字典載入特徵#

DictVectorizer 類別可用於將表示為標準 Python dict 物件清單的特徵陣列轉換為 scikit-learn 估計器使用的 NumPy/SciPy 表示法。

雖然處理速度不是特別快,但 Python 的 dict 的優點是使用方便、稀疏(不需要儲存不存在的特徵)以及儲存特徵名稱和值。

DictVectorizer 針對類別(又稱名義、離散)特徵實作所謂的 one-of-K 或「獨熱」編碼。類別特徵是「屬性-值」對,其中值被限制在一個沒有排序的離散可能性列表(例如主題識別碼、物件類型、標籤、名稱...)。

在以下範例中,「城市」是一個類別屬性,而「溫度」是一個傳統的數值特徵

>>> 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
<Compressed Sparse...dtype 'float64'
  with 6 stored elements and shape (1, 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 的 dictcollections 模組中的變體)、(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 到 1,000 個獨特詞彙。

為了能夠將這樣的矩陣儲存在記憶體中,同時加速矩陣/向量的代數運算,實作方式通常會使用稀疏表示法,例如 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
<Compressed Sparse...dtype 'int64'
  with 19 stored elements and shape (4, 9)>

預設配置通過提取至少 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

因此,在訓練語料庫中未看到的詞彙在未來呼叫轉換方法時將被完全忽略

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

請注意,在先前的語料庫中,第一個和最後一個文件具有完全相同的詞彙,因此以相等的向量編碼。特別是,我們失去了最後一個文件是疑問形式的資訊。為了保留一些局部排序資訊,我們可以提取 2 個詞彙的 n 元語法,除了 1 個詞彙的 n 元語法(個別詞彙)

>>> 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

您也應該確保停用詞列表已套用與向量化器相同的預處理和斷詞方法。CountVectorizer 的預設斷詞器會將單詞 we’ve 分割成 weve,因此如果 we’vestop_words 中,但 ve 不在其中,則 ve 將會從轉換後的文字中的 we’ve 保留。我們的向量化器會嘗試識別並警告某些不一致的情況。

參考文獻

[NQY18]

J. Nothman, H. Qin 和 R. Yurchak (2018)。“自由開源軟體套件中的停用詞列表”。在 Proc. Workshop for NLP Open Source Software 中。

6.2.3.5. Tf–idf 詞彙權重#

在大型文字語料庫中,某些詞彙會非常頻繁地出現(例如英文中的 “the”、“a”、“is”),因此對於文件的實際內容而言,它們所攜帶的資訊量非常少。如果我們將直接計數數據直接輸入分類器,這些非常頻繁出現的詞彙將會掩蓋較罕見但更有意義的詞彙的頻率。

為了將計數特徵重新加權為適合分類器使用的浮點數值,通常會使用 tf–idf 轉換。

Tf 代表詞頻 (term-frequency),而 tf–idf 代表詞頻乘以逆向文件頻率 (inverse document-frequency)\(\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 分量,而 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,則會在 idf 中加上 “1” 的計數,而不是加在 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
<Compressed Sparse...dtype 'float64'
  with 9 stored elements and shape (6, 3)>

>>> 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)
<Compressed Sparse...dtype 'float64'
  with 19 stored elements and shape (4, 9)>

雖然 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))。

疑難排解文字解碼問題#

如果您在解碼文字時遇到問題,以下是一些您可以嘗試的方法:

  • 找出文字的實際編碼。檔案可能隨附標頭或 README,告訴您編碼為何,或者您可以根據文字的來源假設某些標準編碼。

  • 您或許可以使用 UNIX 命令 file 來大致了解它的一般編碼類型。Python chardet 模組隨附一個名為 chardetect.py 的腳本,它會猜測特定的編碼,但您不能依賴它的猜測是正確的。

  • 您可以嘗試使用 UTF-8 並忽略錯誤。您可以使用 bytes.decode(errors='replace') 來解碼位元組字串,將所有解碼錯誤替換為無意義的字元,或在向量化器中設定 decode_error='replace'。這可能會損害您的特徵的實用性。

  • 實際文字可能來自各種來源,而這些來源可能使用了不同的編碼,甚至可能以與其編碼不同的編碼草率地解碼。這在從網路擷取的文字中很常見。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)                           

(根據 chardet 的版本,它可能第一個判斷錯誤。)

如需一般 Unicode 和字元編碼的介紹,請參閱 Joel Spolsky 的 每個軟體開發人員都必須了解的 Unicode 絕對最小值

6.2.3.7. 應用與範例#

詞袋表示法雖然非常簡單,但在實務上卻出乎意料地有用。

尤其是在監督式設定中,它可以成功地與快速且可擴展的線性模型結合,以訓練文件分類器,例如

非監督式設定中,可以使用集群演算法(例如 K-means)將相似的文件分組在一起

最後,可以通過放寬集群的硬性分配約束來發現語料庫的主要主題,例如使用非負矩陣分解 (NMF 或 NNMF)

6.2.3.8. 詞袋表示法的限制#

一組單詞 (詞袋的組成) 無法捕捉片語和多字詞組,有效地忽略了任何單字順序的依賴性。此外,詞袋模型不考慮潛在的拼寫錯誤或單字衍生。

N-gram 來救援!與其建立一個簡單的單詞集合 (n=1),不如選擇一個二元組集合 (n=2),其中計算連續單字對的出現次數。

或者,可以考慮一組字元 N-gram,這是一種對拼寫錯誤和衍生具有彈性的表示法。

例如,假設我們處理的是包含兩個文件的語料庫:['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-gram。char 分析器則會建立跨越單字的 N-gram。

>>> ngram_vectorizer = CountVectorizer(analyzer='char_wb', ngram_range=(5, 5))
>>> ngram_vectorizer.fit_transform(['jumpy fox'])
<Compressed Sparse...dtype 'int64'
  with 4 stored elements and shape (1, 4)>

>>> 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'])
<Compressed Sparse...dtype 'int64'
  with 5 stored elements and shape (1, 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)
<Compressed Sparse...dtype 'float64'
  with 16 stored elements and shape (4, 10)>

您可以看到向量輸出中提取了 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)
<Compressed Sparse...dtype 'float64'
  with 19 stored elements and shape (4, 1048576)>

我們不再發生碰撞,但這是以輸出空間的維度大得多為代價。當然,除了此處使用的 19 個詞之外,其他詞可能仍然會彼此碰撞。

HashingVectorizer 也具有以下限制

  • 由於執行映射的雜湊函式的單向性質,因此無法反轉模型(沒有 inverse_transform 方法),也無法存取特徵的原始字串表示。

  • 它不提供 IDF 加權,因為這會在模型中引入狀態。如果需要,可以在管道中附加 TfidfTransformer

使用 HashingVectorizer 執行核心外擴展#

使用 HashingVectorizer 一個有趣的發展是能夠執行核外 (out-of-core) 縮放。這表示我們可以從不適合電腦主記憶體的資料中學習。

實作核外縮放的一種策略是以小批次 (mini-batches) 的方式將資料串流到估計器。每個小批次都使用 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:一個可呼叫物件,它接收預處理器的輸出,並將其拆分為詞元 (tokens),然後返回這些詞元的列表。

  • analyzer:一個可呼叫物件,它取代預處理器和詞元化器。預設的分析器都會呼叫預處理器和詞元化器,但自訂分析器會跳過此步驟。n-gram 提取和停用詞篩選是在分析器層級進行的,因此自訂分析器可能必須重現這些步驟。

(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 叢集 (階層式叢集),但也可用於建立預先計算的核 (kernels) 或相似度矩陣。