:用正則處理撇號連字符與縮寫)
簡介在自然語言處理中文本預(yù)處理階段經(jīng)常遇到所有字母連在一起的情況需要將連續(xù)英文序列正確拆分為獨立單詞。這份PDF教程以Python的symspellpy庫為主線講解如何使用對稱刪除拼寫糾正算法完成無空格文本的單詞分割從環(huán)境安裝、詞典準(zhǔn)備到SymSpell對象參數(shù)配置再到調(diào)用分割函數(shù)輸出結(jié)果步驟完整且?guī)纠a適合NLP初學(xué)者、數(shù)據(jù)清洗人員和需要批量處理畸形英文文本的開發(fā)者參考。資源包含1個PDF文檔資源包僅29KB內(nèi)容緊湊便于離線閱讀。目前已有4805人參與學(xué)習(xí)下載。除了單詞分割教程還結(jié)合詞頻和編輯距離介紹基礎(chǔ)拼寫糾錯并解釋分割結(jié)果字符串、距離總和、對數(shù)概率總和等返回參數(shù)的含義有助于讀者理解分詞原理直接用于文本清洗、用戶輸入糾錯等真實場景。1. 為什么說英文單詞分割不是英文分詞拿到一段英文原始文本第一反應(yīng)是text.split( )但對做過一次真實語料處理的人來說這個直覺立刻會撞上三種碎片句尾的world,、所有格的Neils、連字符組成的state-of-the-art。英文單詞分割English Word Tokenization要解決的是把連續(xù)字符串切成“后續(xù)分析里最小的有意義單元”它和中文分詞的區(qū)別在于英文天然有空格問題不在“哪里是詞”而在“哪些字符應(yīng)該被拼回一個詞、哪些標(biāo)點應(yīng)該被剝離”。這個任務(wù)貫穿幾乎所有 Python 文本處理場景詞頻統(tǒng)計、文本分類前的特征提取、聊天機(jī)器人的意圖識別、搜索引擎倒排索引的構(gòu)建。判定的核心圍繞三個問題撇號怎么拆、連字符怎么處理、縮寫和數(shù)字混合串要不要完整保留。文中會給出可直接落地的正則方案、邊界策略、批處理管道和驗證手段適合正在寫爬蟲清洗邏輯、NLP 預(yù)處理腳本或日志分析的工程師。2. 用 re.findall 做英文單詞分割的基礎(chǔ)實現(xiàn)2.1 為什么 replace 掉標(biāo)點再 split 不是單詞分割很多初學(xué)者會把分割做成兩步先用replace刪掉所有標(biāo)點再split()。這個方案的直接后果是信息丟失——C變成了兩個 C3.14被截斷state-of-the-art這類攜帶語義的信息被強(qiáng)行拆散。而更隱蔽的問題在于replace依賴你枚舉所有標(biāo)點符號漏掉一個特殊符號整個統(tǒng)計結(jié)果就帶上污染。單詞分割本質(zhì)上是逐個字符做“詞內(nèi)/詞外”的二元判斷而不是做替換。詞內(nèi)字符包括字母、數(shù)字、撇號、連字符和部分 Unicode 擴(kuò)展字母詞外字符是空白、標(biāo)點、符號。用一遍遍歷完成這個判斷最省事的工具就是正則表達(dá)式。2.2 一條能跑的最小正則代碼import re text Hello, world! This is a test: its not that hard. words re.findall(r[\w], text) print(words) # 輸出: [Hello, world, This, is, a, test, its, not, that, hard][\w]的逐段含義是\w匹配 Unicode 字母、數(shù)字和下劃線將撇號顯式放入字符類把相鄰字符拼成一個完整的單詞單元。re.findall只返回匹配到的內(nèi)容不返回分隔符正好滿足“只提取詞”的需求。這個模式能覆蓋約八成常見英文語料。但它有兩個明顯缺口連字符state-of-the-art會被拆成三段縮寫U.S.會被點號截斷。需要考慮到底層邏輯再決定是否用更復(fù)雜的模式。2.3 正則模式中 \w 和邊界 \b 的實際行為\b是單詞邊界它匹配一個位置而不是字符。直接使用re.findall(r\b\w\b, text)同樣是有效的分割方法它依賴邊界的零寬匹配來切分詞。模式匹配示例適用場景[\w]its,hello保留撇號的基礎(chǔ)場景速度快\b\w\bit,s會把 its 拆開只需要純字母詞時[a-zA-Z](?:[-][a-zA-Z])*state-of-the-art,dont需要保留連字符和撇號作為詞內(nèi)字符時\b的邊界判斷基于\w的字符集\b的一側(cè)是\w字符而另一側(cè)不是。dont中間的撇號不是\w所以\b\w\b會以撇號為界拆出don和t這是很多人初次嘗試時踩到的第一道坑。不帶\b、直接用字符類拼接的模式更適合做英文單詞分割因為它把“什么字符屬于詞內(nèi)部”的決定權(quán)完全握在自己手里。3. 英文單詞分割的邊界情況撇號、連字符和縮寫3.1 撇號的三種處理策略撇號是英文單詞分割里最值得細(xì)摳的字符。它同時承擔(dān)所有格Johns、縮寫dont、its、復(fù)數(shù)縮寫90s三種角色沒有統(tǒng)一的語法信號。策略模式對dont的結(jié)果適用任務(wù)保留撇號[a-zA-Z](?:[a-zA-Z])*dont詞頻統(tǒng)計、模糊匹配拆開撇號[a-zA-Z]再做切分don,t拼寫糾錯、語音合成丟棄撇號[a-zA-Z]dont搜索引擎索引我的默認(rèn)選擇是保留。原因在于后續(xù)做單詞歸一化時保留下來的dont能通過規(guī)則還原成do not而拆開的t很難拼回原意。如果下游是向量化或詞嵌入拆開的碎片基本是噪聲。3.2 縮寫后處理U.S. 不該被切成 U 和 SU.S.、U.K.、e.g.這類帶點縮寫在一段正常英文里出現(xiàn)頻率不低任何基于字符類的正則都會把它們拆成單字母詞。簡單做法是分兩步走先用正則切再按白名單合并。我常用的合并邏輯如下。import re PATTERN re.compile(r[a-zA-Z](?:[-][a-zA-Z])*) ABBR_MAP { (u, s): u.s., (u, k): u.k., (e, g): e.g., (i, e): i.e., } def split_with_abbreviations(text): tokens PATTERN.findall(text.lower()) merged [] i 0 while i len(tokens): if i 1 len(tokens) and (tokens[i], tokens[i1]) in ABBR_MAP: merged.append(ABBR_MAP[(tokens[i], tokens[i1])]) i 2 else: merged.append(tokens[i]) i 1 return merged這個實現(xiàn)的關(guān)鍵是合并邏輯永遠(yuǎn)在分割之后進(jìn)行避免正則本身把e.g.當(dāng)成整體去匹配而干擾其他單詞。注意白名單是有限的落到具體業(yè)務(wù)時應(yīng)該從語料里統(tǒng)計高頻單字母對再決定要不要納入映射。3.3 連字符拆開是三個詞留著是一個詞state-of-the-art在技術(shù)文檔里大量出現(xiàn)。拆開得到state、of、the、art四個高頻詞會稀釋統(tǒng)計的區(qū)分度留著作為整體則能保留復(fù)合詞含義。兩者沒有絕對對錯取決于任務(wù)。做詞頻統(tǒng)計或 TF-IDF 時我傾向于保留因為state-of-the-art作為一個整體比四個獨立詞更能代表文檔主題。做機(jī)器翻譯或語法分析時反而應(yīng)該拆開因為句法樹需要獨立的state和of。保留連字符的正則寫法是# 匹配字母開頭 中間允許出現(xiàn)連字符或撇號 字母結(jié)尾 re.findall(r[a-zA-Z](?:[-][a-zA-Z])*, text)這里有個細(xì)節(jié)連字符若出現(xiàn)在單詞末尾如well-這種斷行標(biāo)記上述模式不會吞入結(jié)尾的-從而避免尾部噪聲。這是我喜歡用這個模式而不是[a-zA-Z-]的原因后者會把word--next里的雙連符一起吞進(jìn)去。3.4 數(shù)字和 Unicode 字符的取舍英文語料里?;烊?984、3.14、COVID-19這類數(shù)字相關(guān)詞。\w默認(rèn)包含數(shù)字但純數(shù)字串對詞頻統(tǒng)計沒有語義價值。如果任務(wù)是文本分類通常只保留字母開頭的 token。遇到café、na?ve這類帶重音符號的單詞時[a-zA-Z]會直接丟棄重音字母\w在 Python 3 的默認(rèn) Unicode 模式下能完整保留。取舍的原則是語料面向搜索引擎或統(tǒng)計任務(wù)時應(yīng)顯式加入re.ASCII標(biāo)志讓\w只匹配 ASCII避免帶重音的變體被拆成兩個不完整詞。# 只保留 ASCII 字母和內(nèi)部連字符/撇號 re.findall(r[a-zA-Z](?:[-][a-zA-Z])*, text) # 保留 Unicode 字母適合多語言混合語料 re.findall(r[^\W\d_](?:[-][^\W\d_])*, text, flagsre.UNICODE)[^\W\d_]是\w減去數(shù)字和下劃線的寫法匹配所有的 Unicode 字母。在英文為主、偶爾夾雜法文或德文的語料里這個模式比[a-zA-Z]更穩(wěn)。4. 實戰(zhàn)英文單詞分割在詞頻統(tǒng)計里的完整管道4.1 從文件讀取到分割的最小管道把分割邏輯接到真實文件處理上我通常一次性完成讀取、分割和統(tǒng)計而不是中間保存中間結(jié)果。下面是一個完整的詞頻統(tǒng)計管道。import re from collections import Counter PATTERN re.compile(r[a-zA-Z](?:[-][a-zA-Z])*) def tokenize_file(filepath, min_len2): with open(filepath, encodingutf-8) as f: text f.read() tokens PATTERN.findall(text.lower()) return [t for t in tokens if len(t) min_len] filepath article.txt tokens tokenize_file(filepath, min_len2) counter Counter(tokens) print(counter.most_common(20))這段代碼維持了單向數(shù)據(jù)流讀入原始文本 → 小寫歸一化 → 正則分割 → 長度過濾 → 頻次統(tǒng)計。min_len2能過濾掉單字母詞但也會誤傷I和a所以這個參數(shù)必須結(jié)合停用詞表使用不能單獨依賴長度過濾。4.2 大小寫歸一化與詞形歸并的邊界Python和python在統(tǒng)計時應(yīng)算同一個詞所以lower()是分割后必做的一步。但 lower 不能徹底解決問題running、runs、ran屬于同一個詞根詞頻統(tǒng)計里被當(dāng)成三個獨立詞。正則只能做表層切割做不了詞形歸并Lemmaization。如果下游做的是文檔相似度或主題聚類建議引入詞干化工具常見做法是加一層 SnowballStemmer。但對于單詞分割本身的職責(zé)邊界分詞只負(fù)責(zé)把 token 切對詞形歸并不是它的工作。把職責(zé)分開后續(xù)如果換用深度學(xué)習(xí)模型做向量化只要重寫歸并層不需要動分割層。4.3 停用詞表與最小詞長過濾的組合只按詞頻排序結(jié)果前列永遠(yuǎn)是the、and、of。停用詞表能把這些常見詞從結(jié)果中剔除但要避免一個隱患停用詞過濾必須放在大小寫歸一化之后否則The和the會被當(dāng)成兩個詞分別判斷。STOP_WORDS { the, and, of, to, in, for, on, with, at, by, is, are, was, were, be, been, have, has, had, } def filter_stopwords(tokens): return [t for t in tokens if t not in STOP_WORDS]對于沒有現(xiàn)成停用詞表的場景可以先用 Counter 統(tǒng)計全量詞頻把出現(xiàn)次數(shù)超過語料 1% 的高頻虛詞提取出來作為停用詞初稿。這樣生成的停用詞表與業(yè)務(wù)語料強(qiáng)相關(guān)比直接套用通用表更貼合場景。min_len和停用詞表是互補(bǔ)關(guān)系前者過濾結(jié)構(gòu)上的短噪聲后者過濾語義上的噪聲。4.4 三個必調(diào)參數(shù)及調(diào)參路徑參數(shù)位置推薦初始值調(diào)整依據(jù)正則模式findall第一個參數(shù)[a-zA-Z](?:[-][a-zA-Z])*語料含法語/德語詞時改[^\W\d_]min_len篩選 token2統(tǒng)計到單字母術(shù)語如C語言時降為 1是否納入縮寫合并分割后處理關(guān)語料縮寫密度高時開啟白名單調(diào)參的順序是先看分割質(zhì)量再做長度過濾。輸出里出現(xiàn)don和t這種碎片時先修正則出現(xiàn)整段的https或文件名時再考慮加前綴過濾。一次性調(diào)整多個參數(shù)會讓結(jié)果難以定位真正原因。5. 性能對比與結(jié)果驗證的進(jìn)階技巧5.1 先 split 后清洗 vs 直接 findall一個常被提起的老問題re.findall要逐字符掃描文本會不會比先split再用str.translate清洗標(biāo)點慢得多我做一個約 20 萬詞的新聞?wù)Z料測試結(jié)論是findall與splittranslate耗時基本持平差異在 10% 以內(nèi)。原因在于split雖然快但后續(xù)還要用translate遍歷每個 token 做第二次掃描findall是在一次遍歷中完成字符分類和輸出。后者多花的成本僅在于正則引擎的狀態(tài)機(jī)切換而 Python 層省下了列表二次遍歷的開銷。因此性能不該成為選擇 split 的理由正確性才是。5.2 用 re.compile 緩存模式和進(jìn)程池加速批處理處理上百個文件時反復(fù)調(diào)用re.findall(r..., text)會導(dǎo)致正則模式被反復(fù)編譯。把模式提前用re.compile編譯findall方法直接在編譯后的模式對象上調(diào)用能減少這一層開銷。配合進(jìn)程池能達(dá)到接近線性加速。import re from concurrent.futures import ProcessPoolExecutor PATTERN re.compile(r[a-zA-Z](?:[-][a-zA-Z])*) def tokenize_file(filepath): with open(filepath, encodingutf-8) as f: text f.read() return PATTERN.findall(text.lower()) file_list [a.txt, b.txt, c.txt, d.txt] with ProcessPoolExecutor(max_workers4) as ex: results list(ex.map(tokenize_file, file_list))注意進(jìn)程池的回調(diào)函數(shù)里不需要再寫一次re.compile因為全局變量PATTERN會被每個 worker 進(jìn)程繼承。這比每次調(diào)用tokenize_file時臨時編譯要快。5.3 一個驗證方法把分割結(jié)果重新拼回原文分割邏輯改過幾次后最怕的問題不是切得不準(zhǔn)而是切丟了字符。一個有效的自查方式是把分割后的單詞重新連接成字符串與原始文本去掉所有空白后的字符串做比較。如果兩者完全相等說明沒有字符丟失。def verify_no_char_loss(text, words): joined .join(words) reference re.sub(r\s, , text) return joined reference, len(joined), len(reference) text Dont stop believing, hold on to that feelin. words PATTERN.findall(text.lower()) ok, joined_len, ref_len verify_no_char_loss(text, words) print(ok, joined_len, ref_len)這個驗證的局限在于它只保證“沒丟字符”不保證“切分正確”比如把dont拆成don和t再拼回去長度仍然相等。所以把它作為冒煙測試跑真正判斷切分質(zhì)量要把輸出目測一遍或者和權(quán)威分詞器的結(jié)果抽樣對比。兩種手段結(jié)合才能放心把分割結(jié)果送進(jìn)下游統(tǒng)計。本文還有配套的精品資源點擊獲取