)
做文本分析這些年我越來越覺得中文語料的坑比想象中多。前陣子接了個活兒把《紅樓夢》一百二十回按回目做核心關鍵詞提取用來快速瀏覽每回到底講了啥順便看看能不能發(fā)現(xiàn)一些隱藏的敘事線索。標題里寫的“基于 TF-IDF”聽起來是個老掉牙的方法但真拿它去處理古白話小說尤其是《紅樓夢》這種人物多、場景切換快、虛實交織的文本還是會遇到一堆預料之外的麻煩。這篇就復盤一下整個實戰(zhàn)過程包括我踩過的坑、調過的參數(shù)以及最后怎么讓結果看起來像人話而不是術語堆砌。1. 項目概述為什么選 TF-IDF 和《紅樓夢》分回文本1.1 核心需求從三十萬字的章回體里快速定位每回焦點《紅樓夢》全書一百二十回每回少則五六千字多則上萬字通讀一遍費時費力。文本挖掘的第一步通常是“降維”先把每回的主要內容壓縮成幾個關鍵詞讓人一眼看出這回的主線是什么。比如某回如果提到“鳳姐”“操辦”“喪事”大概率能猜到是協(xié)理寧國府的情節(jié)如果出現(xiàn)“黛玉”“焚稿”“斷癡情”那基本是高鶚續(xù)書里的名場面。這個需求在實際場景里很有用——做文學研究的人可以用它輔助標注做在線閱讀平臺的編輯可以用它生成章節(jié)摘要普通讀者用它當導讀也方便。但“提取核心關鍵詞”這件事難點不在算法本身而在“什么算核心”。在《紅樓夢》這種文本里高頻人名、地名、器物名會淹沒真正的動作和事件比如賈母、寶玉這兩個詞幾乎每回都會出現(xiàn)但對“這一回”的區(qū)分度極低。所以做這件事的核心思路不是單純統(tǒng)計詞頻而是找一個能平衡“詞頻”和“區(qū)分度”的指標TF-IDF就是這么個東西。1.2 方案選型為什么沒用 LDA 也沒用 TextRank在動手之前肯定要糾結一下方案。市面上常見的文本關鍵詞提取無外乎三種路線統(tǒng)計路線TF-IDF、詞頻、圖路線TextRank、主題模型路線LDA。我當時選 TF-IDF 而不是另外兩個原因有三條。第一LDA 適合做“主題歸納”而不是“關鍵詞提取”。LDA 輸出的是詞的概率分布擬合的是“這篇文章在談論什么主題”粒度太粗。比如某回模型算出“主題1家庭事務”你還是不知道具體是哪個事件。而且 LDA 需要人為指定主題數(shù) k調起來主觀性太強不同 k 值結果天差地別不太適合做批量自動化的章節(jié)導讀。第二TextRank 的效果受文本長度影響很大而且更吃圖構建時的共現(xiàn)窗口參數(shù)。分回文本長度不一從 3000 到 9000 字都有固定窗口會把短章和長章的連邊數(shù)量搞得不可比導致有些回目提取出的詞特別散亂。TF-IDF 對文本長度沒有那么敏感因為它本質上是在每個文檔內部做詞頻歸一化再乘逆文檔頻率。第三TF-IDF 在 sklearn 里實現(xiàn)成熟、參數(shù)透明跑批處理非常穩(wěn)。配合 jieba 分詞百來行代碼就能出一個基線結果后續(xù)調優(yōu)的空間也大。說白了先用一套可解釋性強的基線方法把流程打通后面再疊加 TextRank 或者詞向量做融合心里才踏實。最終我的技術棧定成了Python jieba 分詞 sklearn 的 TfidfVectorizer pandas 做結果整理。數(shù)據(jù)源用的是網上公開的《紅樓夢》全文 TXT按回目切分好。整個流程跑完一百二十回每個回目輸出 8-10 個核心關鍵詞大概花了不到二十分鐘效果比預想的好。2. TF-IDF 核心原理與中文文本預處理2.1 一個樸素但有效的基礎假設詞的重要性 在本文中多 在全書中少先從原理說起。TF-IDF 由兩部分組成TF 是詞頻IDF 是逆文檔頻率。前者衡量詞在當前文檔中出現(xiàn)的頻繁程度后者衡量詞在整個語料庫中出現(xiàn)文檔數(shù)量的倒數(shù)取對數(shù)。公式是TF-IDF(t, d) TF(t, d) × IDF(t)其中 IDF(t) log(N / (1 DF(t)))N 是總文檔數(shù)DF(t) 是包含詞 t 的文檔數(shù)。這里的關鍵思想用大白話講就是一個詞如果在某一回里反復出現(xiàn)但在其他回目里很少出現(xiàn)那它大概率是這一回的特征詞。反過來像“說道”“只見”“賈母”這種每回都有的詞DF 值很大IDF 接近 0權重被壓得很低自然排不上號。但《紅樓夢》的文風造成了一個現(xiàn)象很多高頻動詞在全書里分布均勻比如“笑道”“不敢”“起身”這些詞在 TF 那一步得分不低但 IDF 會把它拉下來。真正能體現(xiàn)“分回焦點”的往往是具體名詞——人名、物品、事件詞。比如“猜謎”“螃蟹”“詩社”這類在某幾回里高度集中IDF 會給它們很高的權重。2.2 中文分詞難點古白話、人名歧義和詞表構建中文和英文最大的不同是分詞。英文天然按空格切詞中文需要先分詞分詞結果直接決定了關鍵詞提取的上限。jieba 默認詞典是面向現(xiàn)代漢語的處理《紅樓夢》這種古白話會有幾個明顯問題。其一古白話專名詞在默認詞典里缺失比如“櫳翠庵”“妙玉”“蘅蕪苑”這類地名、人名jieba 可能會切成“櫳翠”“庵”“妙玉”甚至“蘅蕪”“苑”這種碎片導致詞頻統(tǒng)計失真。其二同一個人名可能有多種稱呼。賈寶玉在文里可以是“寶玉”“寶二爺”“怡紅公子”林黛玉可以是“黛玉”“林妹妹”“顰兒”。如果不去歸一化這些稱呼會被當成不同詞各自分散 TF 值導致真正的核心人物反而不突出。其三現(xiàn)代漢語詞匯和古白話詞匯混在一起。比如“丫鬟”在文里更多寫成“丫頭”“吃飯”更多寫成“用飯”如果不做針對性的詞表調整提取結果會偏向現(xiàn)代詞不夠貼合文本風格。針對這些問題我做了三件預處理先加載 jieba 自帶詞典再自定義追加一批《紅樓夢》專有名詞包括主要人物、字、號、居所、器物名。在分詞結果上做同義詞歸一化把“寶二爺”“怡紅公子”“寶玉”統(tǒng)一映射成“寶玉”把“林妹妹”“顰兒”“黛玉”統(tǒng)一映射成“黛玉”。這里要注意不是所有詞都能粗暴合并比如“鳳姐”和“王熙鳳”在不同章節(jié)語境里的實體指向是同一個可以合并但“寶玉”和“通靈寶玉”指向完全不同合并了會亂套所以映射表要人工盯一遍。自定義停用詞表。除了常見的“的”“了”“之”還要加入大量文言虛詞和敘事套話比如“話說”“且說”“卻說”“只見”“不知”等。這套停用詞表是我手動從結果里一遍遍挑出來的后面會細說。2.3 沒有絕對通用的停用詞表只有不斷迭代的停用詞表這里插一句我在項目里的真實感受。網上有很多公開的“中文停用詞表”但直接用往往效果很一般。因為停用詞是高度語料相關的對新聞語料有效的表放到古白話小說里就差勁了。我的做法是先用基礎停用詞表跑一遍基線結果然后把每回關鍵詞里那些明顯沒信息量的詞挑出來加入停用詞表再重跑。這個迭代我做了四輪。第一輪發(fā)現(xiàn)高頻干擾詞有“一個”“如何”“只見”第二輪發(fā)現(xiàn)“因”“便”“方”“且”等文言連詞混進了關鍵詞第三輪發(fā)現(xiàn)一些像“老太太”“太太”“姑娘”的稱謂詞雖然區(qū)分度低但偶爾進 Top第四輪又清了一批“話說”“不在話下”之類的過門套話。迭代大概能讓結果質量提升一個檔次。解決方法不復雜就是從輸出反推詞表但得有點耐心一遍全跑完再一起加不要看到一個改一次那樣效率太低。3. 實操過程數(shù)據(jù)清洗、分詞、TF-IDF 計算與結果整理3.1 數(shù)據(jù)準備從原始文本到一百二十個獨立文檔這一步看起來平平無奇其實最容易出鬼。我在網上找的《紅樓夢》全文 TXT 版本有各種殘次品有帶校注者注釋的有不帶注釋的有章節(jié)標題格式不統(tǒng)一的有的還混有脂評。我的清洗流程是這樣的把原文里的空格、全角空白、換行控制符統(tǒng)一清掉只保留中文字符和少數(shù)必要標點。用正則表達式按“第X回”切分文本。注意有些版本寫作“第一回”有些寫作“第一回 ”后面帶空格還有些寫作“卷 第X回 ”我統(tǒng)一用re.split(r第[一二三四五六七八九十百零]回, text)但不直接 split而是先找到所有回目標題位置再按位置切片。每回的內容里可能殘留回目標題和副題比如“賈雨村風塵懷閨秀”我把它們剔除了不然回目標題里的詞必然在關鍵詞結果里刷屏。最后一共得到 120 個文檔存成每回一個 txt再讀入 pandas DataFrame列名叫 “chapter” 和 “content”。這里我順手統(tǒng)計了一下每回字數(shù)最長的一回有 9640 字最短的 4260 字字數(shù)差距接近兩倍。這個統(tǒng)計對后面設置 TF-IDF 參數(shù)有幫助也讓我意識到長度差異確實存在后面考慮要不要用歸一化策略時心里有底。3.2 分詞和文本向量化代碼實現(xiàn)與參數(shù)取舍分詞和向量化我用的是 jieba 加 sklearn 的 TfidfVectorizer。核心代碼不長貼一段關鍵實現(xiàn)import jieba import pandas as pd from sklearn.feature_extraction.text import TfidfVectorizer # 假設 df 已經包含 chapter 和 content 兩列 def cut_words(text): return .join(jieba.lcut(text)) df[cut_content] df[content].apply(cut_words) vectorizer TfidfVectorizer( max_features3000, ngram_range(1, 2), stop_wordsstop_words_list, sublinear_tfTrue ) tfidf_matrix vectorizer.fit_transform(df[cut_content]) feature_names vectorizer.get_feature_names_out()幾個參數(shù)我當時斟酌了很久逐個說一下。max_features3000的意思是只考慮全部文檔中 DF 值最大的 3000 個詞。這能砍掉大量只出現(xiàn)一兩次的冷門詞和分詞碎片。實際上對一百二十回的小說來說去掉虛詞和超低頻詞之后特征詞總數(shù)大概八千左右3000 是個比較合適的截斷點。太小會漏掉重要事件詞太大會讓向量變得稀疏增加噪聲。ngram_range(1, 2)允許詞和相鄰詞的組合作為特征。為什么要開 bigram因為《紅樓夢》里很多關鍵事件是固定搭配比如“葬花”“焚稿”“螃蟹宴”單獨一個“花”或“稿”信息量太小bigram 能把這些組合詞抓出來。實測開 bigram 之后比如 第二十七回“滴翠亭楊妃戲彩蝶 埋香冢飛燕泣殘紅”核心詞里出現(xiàn)了“葬花”這個組合效果立刻比只看單個詞好。sublinear_tfTrue這個參數(shù)很多人忽略。它把 TF 做了1 log(TF)的對數(shù)放縮防止某些回里同一個詞反復出現(xiàn)導致 TF 值爆炸。比如某回里“酒”字出現(xiàn) 30 次不代表它比出現(xiàn) 15 次的詞重要兩倍對數(shù)放縮讓這種差距變緩更符合直覺。實測下來開了這個參數(shù)后高頻詞不霸榜了低頻但關鍵的事件詞能浮上來。最后是 IDF 的計算方式。sklearn 默認的 smooth_idfTrue分子分母都加平滑項避免除零。這個默認值我保留沒動因為文檔數(shù)固定 120平滑與否對排序影響很小。3.3 結果輸出哪些詞能成為真正的“回目之眼”計算完成后從 tfidf_matrix 里取每一行的 Top 詞輸出成表格。我把每回取前 12 個然后人工抽樣檢查了十幾個回目的效果。先看幾個示例結果回數(shù)前 8 個核心關鍵詞第二十七回葬花、黛玉、滴翠亭、寶釵、彩蝶、紅玉、鳳姐、帕子第四十回劉姥姥、賈母、行令、茄鲞、鴛鴦、笑話、杯子、酒令第六十六回尤三姐、柳湘蓮、劍、鴛鴦劍、退婚、尤二姐、賈璉、定禮第九十八回黛玉、寶釵、寶玉、病、淚、斷癡情、紫鵑、魂歸、出嫁、狠心第二十七回的結果尤其漂亮“滴翠亭”和“葬花”分列兩頭正好對應這一回的兩條主線撲蝶和葬花。第四十回“茄鲞”“行令”直接鎖定宴飲場面和那句著名的“老劉老劉食量大如?!?。第六十六回“尤三姐”“鴛鴦劍”“退婚”精準命中剛烈女子自刎的名場面。第九十八回也就是“苦絳珠魂歸離恨天”那一回“魂歸”“淚”“斷癡情”排得很靠前這說明只要預處理到位TF-IDF 完全有能力抓住敘事的情緒高潮。但也有失敗的回目。最典型的是第一回“甄士隱夢幻識通靈 賈雨村風塵懷閨秀”提取出的關鍵詞里全是“通靈”“頑石”“甄士隱”“賈雨村”“太虛幻境”這些超高頻概念和“懷閨秀”相關的“嬌杏”反而被淹沒了。原因也簡單第一回是全書的“楔子”信息密度極高概念詞一個接一個TF-IDF 在這種超長密集文本上會把每個詞都分配權重反而難以聚焦。這屬于方法本身的局限我用后處理做了修正后面細說。3.4 同義合并和實體歸一化的實現(xiàn)細節(jié)剛才提了一嘴同義詞歸一化這里展開說說。我的做法是在分詞之后、向量化之前對 cut_content 的 token 序列做一次映射替換。alias_map { 寶二爺: 寶玉, 怡紅公子: 寶玉, 絳洞花主: 寶玉, 林妹妹: 黛玉, 顰兒: 黛玉, 瀟湘妃子: 黛玉, 璉二爺: 賈璉, 鳳辣子: 王熙鳳, 老祖宗: 賈母 } def normalize_tokens(tokens): return [alias_map.get(tok, tok) for tok in tokens]這個映射表要非常謹慎。比如“瀟湘妃子”是黛玉的號合并是合理的“瀟湘館”是住所不能合并。再有“襲人”和“花襲人”可以合并但“花氣襲人知晝暖”里的“襲人”是動詞不能合并。雖然這句話在文中出現(xiàn)次數(shù)不多但映射表的構建原則就是“寧可遺漏不可錯并”錯了會引入新的混亂。另外一些人名在不同回里以姓氏稱謂出現(xiàn)比如“賈母”有時叫“史太君”有時叫“老太太”?!袄咸痹谫Z府語境里基本專指賈母但偶爾也指王夫人或邢夫人這種有歧義的我就不合并只保留“賈母”和“史太君”的同義映射。做完歸一化再跑 TF-IDF最直觀的變化是“寶玉”“黛玉”“王熙鳳”這些關鍵人物的排名普遍上升了因為它們的各個別名 TF 值合并在一起。但同時也要防一手歸一化后某些詞的整體 DF 變大IDF 變小如果某個詞本來是某回的別名集中出現(xiàn)的比如只有某回用了“瀟湘妃子”這個號合并成“黛玉”之后反而可能被稀釋掉。所以后來我做了一個變通保留原始別名作為獨立特征但也在結果表里標注其對應的人名這樣既不破壞 IDF 的區(qū)分度又方便人眼理解。4. 結果解讀技巧與常見問題排查實錄4.1 從關鍵詞序列反推敘事結構三個解讀維度提取出關鍵詞之后不是結束而是開始。單看一個回目里的關鍵詞列表容易迷失在詞海里。我總結了三個實用的解讀維度。第一是“回內對比”。把每回關鍵詞按權重從高到低排觀察前三個詞和后幾個詞的差異。如果前三個都是人物名說明這一回偏向“人物驅動”主線可能圍繞關系沖突展開如果前三個里有大量器物名或地點名那這一回更可能偏向“場景驅動”。比如第十七回“大觀園試才題對額”關鍵詞里“園”“匾”“對額”“賈政”“題詞”占了半席一下就能判斷這是空間與建筑描寫為主的回目。第二是“回間對比”。把相鄰幾回的關鍵詞放一起看能看出敘事節(jié)奏的推進。比如第六十三回到第六十五回關鍵詞從“寶玉”“夜宴”“群芳”逐步過渡到“尤二姐”“尤三姐”“賈璉”“偷娶”“退婚”這說明敘事線從大觀園日常轉移到寧國府風波節(jié)奏由輕松轉入緊張。這種視角對文學研究很有用可以用它來給全書做“敘事段”分割。第三是“人物關聯(lián)度”。我不止算關鍵詞還順手統(tǒng)計了在每一回里“關鍵詞與人物名是否共現(xiàn)”。具體做法很簡單就是在 TF-IDF 矩陣里挑出人物名所在的列和事件詞所在的列做相關性排序。比如第四十回里“劉姥姥”和“笑話”共現(xiàn)的權重很高可以推斷這一回的笑點主要由劉姥姥制造。這個方法有點糙但對于快速驗證敘事直覺已經夠用。4.2 常見坑位盤點五個幾乎必踩的問題整個項目從零到一跑了七八遍踩過的坑我按頻次排了序列成表格供后來人直接避雷。問題現(xiàn)象原因解決方案回目標題刷屏“通靈”“懷秀”跑進前 5切分時沒去掉回目標題行先識別回目標題并剔除再切正文同義詞分散權重“寶二爺”“怡紅公子”各自低頻人稱、號太多做別名映射但要防歧義文言虛詞殘留“便”“因”“方”進入Top詞基礎停用詞表不夠古白話特化四輪迭代擴充停用詞表bigram 組合太僵“了的”“之處”這類無效組合默認bigram不夠或參數(shù)開太大結合自定義詞典再開bigram地名與人物混淆“蘅蕪苑”被切成“蘅蕪”“苑”jieba 默認詞典缺古白話詞自定義追加專名維護詞典這當中最難的其實是第一個回目標題刷屏。我最初用正則直接按“第X回”切分沒處理標題行結果切出來的第一回正文開頭還是“甄士隱夢幻識通靈”這七個字直接霸占當回 TF 值前列。后來我把切分邏輯改成先定位所有回目標題的起止位置然后把標題從正文里替換為“”再分詞問題才消失。還有一個小坑是編碼問題。很多公開 TXT 是 GBK 編碼用 UTF-8 讀會直接亂碼。讀取時我統(tǒng)一encodinggb18030做兼容把錯誤處理設成errorsignore然后再轉存為 UTF-8 的干凈版本。這個坑雖然小但能讓人白忙活一小時。4.3 超長回目和信息密集回目的后處理方法剛才提到第一回效果不好這也是 TF-IDF 方法的典型痛點當一篇文檔里的各個信息點都重要時TF-IDF 不知道該怎么“側重”只能平均分配權重。我試過幾種補救方法效果不一這里分享一條我覺得可行的路線。先做“片段滑動窗口”把每個回目的文本按 200 字一個窗口切成段分別在窗口級做 TF-IDF再把窗口排名結果做聚類聚合。這樣能緩解長回目內部的語義漂移。不過 120 回的文檔量不大窗口滑動后文檔數(shù)膨脹到上萬我當時為了跑得快只對個別超長回目做了這個處理沒有全量啟用。再就是加一個后處理規(guī)則從候選詞里剔除“與全書所有回目共現(xiàn)頻率超過 70%”的詞。雖然這些詞的 TF 可能很高但它們不可能是某回的特征詞。比如“寶玉”在 120 回里出現(xiàn)次數(shù)為 113 回這個 DF 太高了即使它是某回的關鍵人物名也不具備區(qū)分度。但這種詞又不能一概剔除因為有些回目里“寶玉”確實是事件核心刪了就丟了主角。所以我的做法是把這些高頻詞降低權重而不是直接刪設置一個懲罰系數(shù)DF 100 的詞權重乘以 0.6DF 在 80-100 之間的乘以 0.8。實測懲罰后人物名與事件詞的平衡感更好例如第一回里名場面相關的“識通靈”“懷閨秀”這類事件詞排名上升了。4.4 如何選 Top N 關鍵詞不是越多越好Top N 的數(shù)量直接關系到可讀性。我最初輸出 Top 20結果每回的關鍵詞表格長得像天書第 15 個詞開始出現(xiàn)一堆沒有敘事意義的低權重詞比如“眾人”“一面”“一面又”看著枯燥也無助于導讀。后來我做了個實驗把 N 從 5 調到 20人工對比十個回目的結果發(fā)現(xiàn) 8-12 個是一個甜蜜區(qū)間。少于 8 個摘要粒度太粗比如某回只給了人物名事件動詞沒浮出來多于 12 個噪聲明顯上升。最終我統(tǒng)一輸出每回前 10 個詞再在權重超過平均值 1.2 倍的關鍵詞里額外選 2 個時報詞匯這個策略基本穩(wěn)定。這里也要提醒N 的選取不能完全拍腦袋最好基于人工抽樣看幾回再定。不同語料的甜蜜區(qū)間不一樣比如分析新聞標題可能 5 個就夠分析學術摘要15 個也合理。關鍵是每回結果的“可讀性一致”。5. 后續(xù)擴展從單純關鍵詞到章節(jié)“迷你摘要”5.1 關鍵詞串聯(lián)成句給閱讀器用的淺層摘要TF-IDF 提取的關鍵詞是碎片如果想做成讀者可讀的迷你摘要還有一個后處理步驟。我的想法是既然每回有 10 個核心關鍵詞那就用這些詞去原文里找片段把包含 3 個及以上關鍵詞的句子抽出來按出現(xiàn)順序拼接就能得到一段原始文本構成的迷你摘要。實操時我先按句號、問號、感嘆號把每回切成句子然后篩出含核心關鍵詞數(shù)量 3 的句子按在原文里的順序排好。這個操作在第二十七回的效果讓我很驚訝——拼接出來的句子幾乎就是“黛玉葬花”和“寶釵撲蝶”的原始描寫片段完全可以當導讀用。sentences re.split(r[。], chapter_text) key_sentence [] for sent in sentences: hit sum(1 for word in keyword_list if word in sent) if hit 3: key_sentence.append(sent) summary 。.join(key_sentence[:5])這一招有個附帶的好處它既保留了原文文風又能保證摘要內容不是模型瞎編的。對文學類文本來說保持原汁原味非常重要。后來我用這個方法給一百二十回都生成了迷你摘要人工抽查了十回基本都能覆蓋該回主要情節(jié)。除了極個別回目因為原文描寫分散、一句話里難湊三個關鍵詞摘要會拼出一些跳躍感但整體可用性已經遠超純關鍵詞列表。5.2 多方法融合TF-IDF 作為基線的意義最后說一點關于方法選型的思考。TF-IDF 這個算法 1972 年就提出來了快六十年了今天看來很簡單甚至有人覺得過時。拉一個詞向量模型出來可能顯得更“先進”但我做這個項目的體會是在文本分析里“可解釋的簡單方法”往往比“不可解釋的復雜模型”更值得先做。因為 TF-IDF 的每一步都能落到具體詞和具體權重上出了奇怪結果可以直接反查對于古代文本這種噪聲極高的語料尤其重要。我現(xiàn)在把整個流程沉淀成了一個復現(xiàn)性較強的代碼庫輸入是任意長文本集合輸出是每個文檔的 Top 詞表和句子級迷你摘要。后續(xù)要做的話可以在 TF-IDF 結果之上疊加預訓練語言模型做關鍵詞消歧比如把“葬花”和“黛玉”之間的語義關聯(lián)進一步強化。不過那是更靠后的優(yōu)化方向基線階段我仍然推薦老老實實用 TF-IDF 把數(shù)據(jù)和預處理打磨干凈畢竟后面所有花哨模型的效果都建立在這一步的地基上。按我個人的經驗跑這類項目最大的收獲反而不在算法本身而在對“文本差異”的敏感度。《紅樓夢》每一回的語言風格、敘事密度、詞場分布差異巨大而這些差異在 TF-IDF 的權重里體現(xiàn)得很分明??炊嗔私Y果表你對“什么詞才是區(qū)分性的”會形成直覺這直覺在做任何 NLP 項目時都好用。