指南:從文本數(shù)值化到特征工程的完整鏈路)
剛?cè)胄凶鲎匀徽Z言處理的時候我總以為處理文本的第一步是上什么高大上的預(yù)訓(xùn)練模型。后來被各種任務(wù)反復(fù)教育之后才明白詞袋模型Bag of Words才是真正值得吃透的基本功。它干的事情很樸素把一段人話變成一堆數(shù)字讓機器學(xué)習(xí)模型能夠消化文本信息也就是標(biāo)題里說的文本數(shù)值化。這篇內(nèi)容不端著講理論就聊聊詞袋模型到底怎么用、為什么這么用、以及我在實際項目里踩過的那些坑。詞袋模型能解決的問題非常直接當(dāng)你手里有一批文本數(shù)據(jù)——無論是商品評論、新聞標(biāo)題、工單描述還是聊天記錄——想要做分類、聚類或者相似度計算第一步永遠是先讓這些文字變成矩陣。詞袋模型就是這個轉(zhuǎn)化過程中的第一性原理它簡單到甚至有點粗暴但恰恰是這份簡單讓它到現(xiàn)在依然是很多場景下的高效基線方案。這篇文章適合誰看如果你正在學(xué)習(xí)NLP入門知識或者剛接到一個文本處理的小任務(wù)又或者你想理解詞向量到底是從哪來的那這篇內(nèi)容可以幫你把地基打牢。我會從模型原理、手寫實現(xiàn)、sklearn實戰(zhàn)到避坑指南盡量講透。1. 機器眼中的文本為什么非要把文字掰碎成數(shù)字1.1 模型的胃只消化數(shù)字先想一個問題BERT、TextCNN、邏輯回歸……這些模型有一個共同的輸入要求——數(shù)字。更準(zhǔn)確地說是固定維度的向量或者矩陣。原因也不難理解模型內(nèi)部的數(shù)學(xué)運算都是基于矩陣乘法和梯度下降而矩陣里的每個元素必須是可微的數(shù)值。你扔進去一句這家餐廳的火鍋真好吃模型讀不懂好吃是個形容詞更不知道火鍋是名詞它只認得張量里那一個個float32。所以整個NLP流程的生命線就是從原始文本到數(shù)值表示的映射。這個映射的質(zhì)量直接決定了后面模型性能的上限。很多人上來就跑BERT卻連最基礎(chǔ)的文本是怎么變成向量的都說不清楚——這就是典型的上層建筑沒落地。1.2 文本數(shù)值化的幾條路線詞袋模型處在什么位置把文本變成數(shù)字目前主流有三條路線各自代表不同的信息取舍路線代表方法保留的信息計算成本離散符號化詞袋模型、TF-IDF、One-hot詞語是否出現(xiàn)、出現(xiàn)頻率極低稠密向量化Word2Vec、GloVe、FastText詞語之間的語義相似度中等上下文動態(tài)向量BERT、GPT等預(yù)訓(xùn)練模型詞語在不同語境下的含義很高詞袋模型屬于最底層的離散符號化路線。它做了一個非常大膽的假設(shè)把文本里詞語的順序全部丟光只保留每個詞出現(xiàn)了多少次。這個假設(shè)聽起來很荒謬——順序不是語言的核心嗎但在實際的分類任務(wù)里詞袋模型往往能打出一個讓人驚訝的基線分尤其是當(dāng)特征工程做得好的時候它甚至能和一些輕量級深度學(xué)習(xí)模型掰手腕。我見過不少團隊在生產(chǎn)環(huán)境里用詞袋 邏輯回歸做垃圾評論識別線上效果穩(wěn)得很這時候你不會去質(zhì)疑它丟掉了順序這件事——因為業(yè)務(wù)的線性特征已經(jīng)足夠區(qū)分了。2. 詞袋模型的完整構(gòu)建鏈路從原始句子到稀疏向量2.1 構(gòu)建詞匯表不在詞表里的詞就是不存在用一個最簡單的例子來拆解。假設(shè)我們現(xiàn)在只有三個句子句子1我喜歡貓 句子2我喜歡狗 句子3她喜歡貓和狗詞袋模型的構(gòu)建分兩步。第一步把這三句話的所有不重復(fù)詞語抽出來做成一張詞匯表Vocabulary[我, 喜歡, 貓, 狗, 她, 和]這張表就是整個向量空間的坐標(biāo)系。字典里有多少個詞向量就有多少維。這就是為什么詞袋模型產(chǎn)生的向量往往是高維且稀疏的——你的詞匯表可能有10萬個詞但對某一句話來說真正出現(xiàn)過的詞撐死也就幾十個其余維度全是0。第二步對每個句子統(tǒng)計詞表里的每個詞在這一句中出現(xiàn)了多少次得到一個計數(shù)向量。于是句子1 [1, 1, 1, 0, 0, 0] # 我1次喜歡1次貓1次 句子2 [1, 1, 0, 1, 0, 0] # 我1次喜歡1次狗1次 句子3 [0, 1, 1, 1, 1, 1] # 她1次喜歡1次貓1次狗1次和1次2.2 袋子的隱喻順序不重要出現(xiàn)才重要詞袋模型英文叫Bag of Words精髓就在Bag這個詞。一個袋子里的東西是無序堆放的你把句子扔進去攪一攪詞語的順序就亂了。但是沒關(guān)系這個模型根本不在乎順序它只在乎袋子里有沒有這個東西有多少個。這個特性帶來了一個著名的副作用我打你和你打我在詞袋模型眼里是同一個向量。因為它們包含的詞語集合完全相同只是排列順序不同。這個問題在短文本場景里往往可以容忍但在涉及語義角色判斷的任務(wù)比如誰對誰做了什么里就會出大問題。后面我會專門講怎么緩解。2.3 從出現(xiàn)次數(shù)到特征表達要不要用頻率上面的例子用的是詞頻計數(shù)Count每個位置的數(shù)字表示該詞在句子中出現(xiàn)了幾次。但實際場景里還有一個更常用的變體——詞頻-逆文檔頻率TF-IDF。它不是單純看出現(xiàn)次數(shù)而是給詞加了一個懲罰系數(shù)如果某個詞在大量文檔里都出現(xiàn)它就越不有信息量權(quán)重就會被壓低。舉個例子我們、的這類詞幾乎條條文本里都有用純詞頻的話它們會霸占最大的權(quán)重可它們對判斷文本類別幾乎沒有幫助。TF-IDF就是為了壓制這種高頻無意義詞而生的。我個人的習(xí)慣是做短文本相似度或者主題判斷優(yōu)先用TF-IDF做詞頻統(tǒng)計類的展示分析才用純Count。3. 五分鐘手寫一個詞袋模型建立直覺比調(diào)包重要3.1 純Python手寫版本我特別建議入門的人先手寫一遍不用任何NLP框架。這個過程的收獲是理解向量是怎么來的。下面是個極簡實現(xiàn)from collections import Counter def build_vocab(corpus): 構(gòu)建詞匯表所有去重后的詞 vocab set() for text in corpus: # 這里用split做演示中文實際要專門分詞 words text.split() vocab.update(words) return {word: idx for idx, word in enumerate(sorted(vocab))} def text_to_vector(text, vocab): 把單個句子轉(zhuǎn)成向量 vec [0] * len(vocab) word_counts Counter(text.split()) for word, count in word_counts.items(): if word in vocab: vec[vocab[word]] count return vec corpus [我 喜歡 貓, 我 喜歡 狗, 她 喜歡 貓 和 狗] vocab build_vocab(corpus) vectors [text_to_vector(text, vocab) for text in corpus] print(詞匯表:, vocab) print(向量矩陣:) for vec in vectors: print(vec)運行結(jié)果詞匯表: {我: 0, 貓: 1, 和: 2, 她: 3, 喜歡: 4, 狗: 5} 向量矩陣: [1, 1, 0, 0, 1, 0] [1, 0, 0, 0, 1, 1] [0, 1, 1, 1, 1, 1]你會發(fā)現(xiàn)一個細節(jié)詞匯表的排序順序不同同一個句子的向量輸出順序也會不同但表達的信息完全等價。所謂向量只是在這個坐標(biāo)系下的一個坐標(biāo)點而已。3.2 sklearn只需要三行代碼手寫一遍找完感覺之后就到生產(chǎn)工具登場了。scikit-learn里的CountVectorizer是詞袋模型最標(biāo)準(zhǔn)的實現(xiàn)三行搞定from sklearn.feature_extraction.text import CountVectorizer corpus [我喜歡貓, 我喜歡狗, 她喜歡貓和狗] vectorizer CountVectorizer() X vectorizer.fit_transform(corpus) # X是稀疏矩陣轉(zhuǎn)成稠密數(shù)組看內(nèi)容 print(vectorizer.get_feature_names_out()) print(X.toarray())輸出[喜歡 我 狗 貓 和 她] [[1 1 0 1 0 0] [1 1 1 0 0 0] [1 0 1 1 1 1]]注意這里的輸出順序是按字母/Unicode排的不代表詞的重要性。fit_transform這個函數(shù)名字里藏了兩個步驟fit是學(xué)習(xí)詞匯表transform是把文本映射成向量。預(yù)測新文本時只要vectorizer.transform([我喜歡貓])注意是transform而不是fit_transform否則會用新文本重建詞匯表維度就對不上了——這是個經(jīng)典新手坑。3.3 為什么要用稀疏矩陣不直接存二維數(shù)組你觀察上面的輸出會發(fā)現(xiàn)sklearn返回的是一個稀疏矩陣而不是普通的list。詞匯表一大的話比如10000個詞、10000篇文檔如果存成普通二維數(shù)組就是10000×10000的稠密矩陣光算一下就是8億個float內(nèi)存直接爆炸。稀疏矩陣只存儲非零元素的位置和值。10000維的向量里一篇文章可能只有幾十個非零稀疏存儲的壓縮率可以達到99%以上。所以詞袋模型天然就應(yīng)該搭配稀疏矩陣使用這也是sklearn的CountVectorizer默認返回稀疏矩陣的原因。這個設(shè)計不是刻意炫技是真的由數(shù)據(jù)特征決定的剛需。4. 進階調(diào)參實戰(zhàn)CountVectorizer的參數(shù)每天都在救命4.1 文本預(yù)處理參數(shù)lowercase、token_pattern與stop_words詞袋模型看起來簡單但參數(shù)坑一點都不少。先說最容易被忽視的lowercase。默認是True意思是所有字母轉(zhuǎn)小寫。英文場景下這個是好事避免Apple和apple被當(dāng)成兩個詞。但有些場景要格外小心比如代碼分析里變量名區(qū)分大小寫比如品牌名iPhone被轉(zhuǎn)成iphone后可能跟小寫品牌混在一起。我在做英文用戶反饋分析時就遇過US美國被轉(zhuǎn)成us我們直接污染了特征。token_pattern這個參數(shù)在中文場景下是個必須處理的問題。默認的正則表達式是匹配字母數(shù)字對中文可能直接失效或產(chǎn)生奇怪結(jié)果。中文NLP要用中文分詞器先分詞然后用空格分隔或者自己寫calback傳進去。默認的英文分詞器處理純中文時可能出現(xiàn)整個句子被當(dāng)成一個token或者每個字都被拆開兩種極端都不是你要的。最常見的處理方式是先分詞再把分詞結(jié)果用空格拼接成字符串交給CountVectorizer還有一種做法是寫自定義tokenizerimport jieba vectorizer CountVectorizer(tokenizerlambda text: jieba.lcut(text))但直接傳tokenizer有個坑preprocessor和token_pattern同時生效時可能會出問題需要把token_pattern顯式設(shè)成空或使用analyzerword配合自行處理。由于CountVectorizer內(nèi)部對tokenizer返回結(jié)果的類型有要求建議返回list而不是生成器。stop_words停用詞表也是個經(jīng)常需要手動干預(yù)的東西。sklearn內(nèi)置的英文停用詞只是最基礎(chǔ)的業(yè)務(wù)場景里的無效詞往往比通用停用詞更隱形。比如做租房評論分析時房子這個詞可能到處都是但它沒區(qū)分度。我在實際項目里習(xí)慣的做法是先跑一遍詞袋模型觀察高頻詞里哪些同時出現(xiàn)在所有類別中然后手動把這部分詞加進停用詞表迭代兩三輪效果會明顯變好。4.2 n-gram與min_df/max_df控制特征維度的生死線ngram_range是詞袋模型里最值得花時間的參數(shù)。默認是(1, 1)只看單個詞。如果把范圍設(shè)成(1, 2)向量里就會出現(xiàn)喜歡貓這種雙詞組合。這么做的好處是把部分語序信息通過滑動窗口的方式給找補回來了?!拔掖蚰恪焙汀澳愦蛭摇痹诩円辉~袋下向量相同但如果是二元詞袋前者會有我打和打你后者會有你打和打我特征完全不同。所以ngram是詞袋模型對抗丟順序問題最簡單實用的手段。不過代價也明顯特征維度會爆炸式增長一份幾千詞的文本用(1,3)可能產(chǎn)生幾十萬維特征必須配合下面的參數(shù)使用。min_df和max_df的本質(zhì)是低頻詞/高頻詞清洗。min_df5表示至少在5篇文檔里出現(xiàn)過的詞才會進入詞表max_df0.8表示在80%以上的文檔里都出現(xiàn)的詞會被排除。這兩個參數(shù)的作用是去掉那些只出現(xiàn)一次沒統(tǒng)計意義的噪音詞和到處都在出現(xiàn)沒區(qū)分度的平庸詞。剛開始調(diào)參的時候把min_df設(shè)小一點、max_df設(shè)大一點寧可讓特征多一點也別把有效信息誤殺。4.3 sublinear_tf與binary兩個容易被低估的小參數(shù)CountVectorizer里有個binary參數(shù)設(shè)為True時只要詞出現(xiàn)就記為1不看出現(xiàn)次數(shù)。這個在某些短文本分類場景里效果出奇的好原因是短文本里詞頻大部分都是1詞頻的區(qū)分度本來就低還容易讓高頻詞獲得不恰當(dāng)?shù)剡^高權(quán)重。TfidfVectorizer里還有個sublinear_tf參數(shù)設(shè)為True后詞頻用1 log(tf)替代原始計數(shù)。這個處理能壓縮高頻詞和低頻詞之間的差距比如一個詞出現(xiàn)1000次和出現(xiàn)10次原始計數(shù)差100倍取對數(shù)后差別就小得多了。在很多長文本場景里詞是否出現(xiàn)和詞的低頻差異比高頻上的線性差異更有意義這個參數(shù)就是處理這個問題的。5. 詞袋模型在真實項目中踩過的坑完整排查鏈路5.1 全角半角與亂碼文本清洗的隱形炸彈我第一次在中文數(shù)據(jù)集上跑詞袋模型出來的特征里有大量看起來一模一樣但實際是不同 token 的詞Android和全角版本café和cafe。原因是源數(shù)據(jù)來自不同平臺有的用了全角字符有的用了半角。如果不做統(tǒng)一同一個詞會被拆成兩個特征白白增加維度。排查過程其實是有脈絡(luò)的發(fā)現(xiàn)特征數(shù)量異常多先打印詞表前100個詞肉眼檢查然后果斷上正則做歸一化處理。我的標(biāo)準(zhǔn)清洗流程是先做全角轉(zhuǎn)半角再統(tǒng)一Unicode規(guī)范化最后按業(yè)務(wù)需要決定是否轉(zhuǎn)小寫import re import unicodedata def clean_text(text): # 全角轉(zhuǎn)半角 text unicodedata.normalize(NFKC, text) # 去除特殊符號保留中文、英文、數(shù)字、空格 text re.sub(r[^\w\u4e00-\u9fa5\s], , text) # 連續(xù)空格壓縮 text re.sub(r\s, , text) return text.strip()unicodedata.normalize(NFKC)這一步很關(guān)鍵它會統(tǒng)一字符寬度。清洗完再去做分詞和詞袋編碼特征維度立刻降下來了模型效果也會更穩(wěn)定。5.2 訓(xùn)練集和測試集向量空間不一致重建詞匯表的災(zāi)難這個坑我見得最多也最隱蔽。假設(shè)你先對訓(xùn)練集做了fit_transform然后在推理階段對單條新文本錯誤地調(diào)用了fit_transform而不是transform。新文本里如果有個詞是訓(xùn)練集詞表里沒有的fit會用新文本重建一個完全不同的詞匯表新向量維度和訓(xùn)練時的向量維度對不上模型直接報錯或靜默出錯。這個坑的排查鏈路比較搞笑因為報錯信息往往不明確??吹絛imension mismatch這類錯誤時第一反應(yīng)不是去查數(shù)據(jù)而是檢查是不是在transform階段不小心多做了一個fit。解決方案很簡單用pickle或joblib單獨保存fit好的vectorizer對象推理時每次都load同一個對象來transform。還有更隱蔽的情況有些同學(xué)把詞表保存成一個list推理時自己手動查表映射。如果新文本里遇到?jīng)]見過的詞可能直接跳過也可能用默認值占位——不同選擇結(jié)果完全不一樣。我在代碼評審時看到過直接把OOV詞丟棄的寫法這可能在損失關(guān)鍵信息。通常我會推薦用min_df來控制特征空間復(fù)雜度而不是在推理階段丟棄OOV詞。5.3 特征維度過高導(dǎo)致的內(nèi)存和性能問題曾有一個工單分類任務(wù)我只把ngram_range設(shè)為(1,2)就得到了300多萬維的特征空間。300萬維 × 幾萬條樣本的稀疏矩陣雖然勉強能存但在訓(xùn)練線性模型時迭代速度慢得讓人懷疑人生。排查后發(fā)現(xiàn)主要原因是沒有合理設(shè)置max_df也沒限制特征總量。CountVectorizer有個max_features參數(shù)可以限制詞表最大數(shù)量保留的規(guī)則是按詞頻從高到低截斷。直覺上我們會保留高頻詞但高頻詞往往沒有區(qū)分度所以max_features要結(jié)合業(yè)務(wù)理解來設(shè)置或者配合max_df排除那些在絕大多數(shù)文檔都出現(xiàn)的詞然后再設(shè)定max_features作為兜底。5.4 詞袋模型向量化前的分詞一致性問題中文場景比英文多一道工序分詞。分詞器不同詞表就不同。我在實際項目里發(fā)現(xiàn)完全相同的兩套數(shù)據(jù)用jieba分詞和用pkuseg分詞詞袋模型的AUC可以差好幾個點。這不是玄學(xué)是分詞粒度決定了特征的基本單元。比如機器學(xué)習(xí)這個詞一個大粒度分詞器會把它當(dāng)成一個詞小粒度可能拆成機器和學(xué)習(xí)。兩種分法在具體分類任務(wù)上的表現(xiàn)差異很大。我的建議是同一個項目里固定使用同一個分詞器不要混用。而且要關(guān)注詞典更新在垂直領(lǐng)域比如醫(yī)療、法律用通用分詞器往往效果一般要加載領(lǐng)域自定義詞典把專業(yè)術(shù)語切出來讓詞袋的特征更有業(yè)務(wù)含義。6. 從詞袋到語義理解什么時候該換賽道什么時候繼續(xù)用6.1 詞袋模型打不過深度學(xué)習(xí)不代表它沒用很多人以為詞袋模型早該被淘汰了。這個觀點在實習(xí)的同學(xué)里尤其常見——天天用ChatGPT和預(yù)訓(xùn)練模型回頭看詞袋模型覺得太簡陋了。但在真實工業(yè)場景里簡單模型的穩(wěn)定性和可解釋性是深度學(xué)習(xí)模型很難替代的。舉個例子做電商評論的情感二分類詞袋 邏輯回歸的AUC可以到0.92BERT微調(diào)可以到0.95。為了這3個點的提升你要付出的是GPU資源、推理延遲、線上穩(wěn)定性、可解釋性的代價。如果業(yè)務(wù)對準(zhǔn)確率不是極度敏感詞袋模型反而是更務(wù)實的選擇。6.2 詞袋模型的最佳搭檔線性模型和類別特征拼接詞袋模型的向量有一個特點特征之間高度稀疏且正交。這種數(shù)據(jù)形態(tài)特別適合邏輯回歸、線性SVM這類不需要復(fù)雜非線性變換的模型。你拿它直接喂給LightGBM也不是不行但通常效果反而不如線性模型——樹模型在稀疏高維特征上容易過擬合同時還沒法有效利用這種一個詞一個維度的正交特性。還有個實用技巧詞袋向量可以和結(jié)構(gòu)化特征直接拼接。比如做用戶反饋分類時把反饋長度是否含數(shù)字工單來源渠道這些數(shù)值變量拼在詞袋向量的尾部喂給線性模型效果往往比單獨用文本特征好不少。這是詞袋模型在工程上一個很舒服的落地方式。6.3 詞袋模型的升級路徑TF-IDF與詞向量的銜接如果你覺得詞袋模型的效果差不多到瓶頸了建議按這個順序升級第一級把CountVectorizer換成TfidfVectorizer先用權(quán)重替代頻次第二級加ngram_range(1,2)或(1,3)把局部語序信息引進來第三級如果還不行再考慮Word2Vec/FastText這類靜態(tài)詞向量最后一檔才是BERT之類的預(yù)訓(xùn)練模型。每一級升級都意味著更高的計算成本和更復(fù)雜的部署方案不建議一開始就越級。我在實際項目中做過一個對比純詞袋F10.81詞袋TF-IDF F10.84bigram后F10.86Word2Vec avg pooling F10.85BERT F10.88。可以看到從詞袋到TF-IDF和bigram的性價比最高花了最少成本拿下了大部分收益BERT的絕對收益最高但對應(yīng)的時間成本和工程投入也最大。6.4 詞袋模型仍然發(fā)光發(fā)熱的場景說一下我最近幾年還在用詞袋模型的幾個場景。首先是構(gòu)建baseline新接到一個NLP任務(wù)無論計劃用多復(fù)雜的模型我都會先跑一個詞袋 邏輯回歸以此確定任務(wù)的下限也用來驗證數(shù)據(jù)質(zhì)量有沒有問題。如果數(shù)據(jù)標(biāo)簽都亂套了詞袋baseline會很低這時候先花時間清洗數(shù)據(jù)比優(yōu)化模型更值得。其次是冷啟動階段一個新項目還沒積累足夠標(biāo)注數(shù)據(jù)預(yù)訓(xùn)練模型很難發(fā)揮威力詞袋模型對數(shù)據(jù)量的要求很低幾百條樣本就能跑出相對穩(wěn)定的結(jié)果。最后是關(guān)鍵詞提取和文本畫像詞袋模型的詞表 權(quán)重本身就是一份可解釋性極強的業(yè)務(wù)洞察可以直接導(dǎo)出詞云、高頻詞表、類別分布對產(chǎn)品同學(xué)和運營同學(xué)來說比一個黑盒模型有價值得多。7. 實踐建議詞袋模型的項目落地清單根據(jù)我的個人經(jīng)驗如果現(xiàn)在讓你在一個真實業(yè)務(wù)里落地詞袋模型可以按這個清單來走文本清洗統(tǒng)一全角半角、處理Unicode規(guī)范化、清理HTML標(biāo)簽和特殊符號把噪聲降到最低。按語言選分詞方案英文用CountVectorizer默認的token_pattern即可中文要接入統(tǒng)一的分詞器領(lǐng)域內(nèi)要加自定義詞典。設(shè)置合理的特征范圍先用min_df2和max_df0.9做初步清洗再根據(jù)內(nèi)存和模型情況疊加上限比如max_features50000。先跑默認參數(shù)建立baseline拿到初始指標(biāo)后再做針對性的調(diào)參不要一上來就搜索參數(shù)??紤]TF-IDF和ngram大部分場景下TfidfVectorizer(ngram_range(1,2), min_df2)比裸的CountVectorizer表現(xiàn)更好。搭配線性模型邏輯回歸或線性SVM是詞袋的最佳搭檔訓(xùn)練快、可解釋、不容易過擬合。對比基線再談升級詞袋模型跑完如果效果離業(yè)務(wù)目標(biāo)還差得遠再考慮上詞向量或預(yù)訓(xùn)練模型不要盲目追求最先進。詞袋模型是我入行NLP后掌握的第一個模型也是我現(xiàn)在教新同事的第一個知識點因為它真的能幫人快速建立文本 - 向量的完整直覺。有一次線上服務(wù)出現(xiàn)過一次OOV問題排查到最后發(fā)現(xiàn)是業(yè)務(wù)上線了一個新品牌詞詞表里根本沒有。當(dāng)時應(yīng)急預(yù)案就是停了線上模型重新訓(xùn)練詞袋模型幾分鐘就搞定了。換成BERT回滾和重新微調(diào)的周期恐怕就不是按分鐘算了。所以別小看這個老古董它就像是工具箱里的扳手——未必是最酷的工具但你總會需要它。真到了用的時候希望這篇文章能幫你少走點彎路。