简介:在自然语言处理中,文本预处理阶段经常遇到所有字母连在一起的情况,需要将连续英文序列正确拆分为独立单词。这份PDF教程以Python的symspellpy库为主线,讲解如何使用对称删除拼写纠正算法完成无空格文本的单词分割,从环境安装、词典准备到SymSpell对象参数配置,再到调用分割函数输出结果,步骤完整且带示例代码,适合NLP初学者、数据清洗人员和需要批量处理畸形英文文本的开发者参考。资源包含1个PDF文档,资源包仅29KB,内容紧凑,便于离线阅读。目前已有4805人参与学习下载。除了单词分割,教程还结合词频和编辑距离介绍基础拼写纠错,并解释分割结果字符串、距离总和、对数概率总和等返回参数的含义,有助于读者理解分词原理,直接用于文本清洗、用户输入纠错等真实场景。
1. 为什么说英文单词分割不是英文分词
拿到一段英文原始文本,第一反应是text.split(" "),但对做过一次真实语料处理的人来说,这个直觉立刻会撞上三种碎片:句尾的world,、所有格的Neil's、连字符组成的state-of-the-art。英文单词分割(English Word Tokenization)要解决的是把连续字符串切成“后续分析里最小的有意义单元”,它和中文分词的区别在于:英文天然有空格,问题不在“哪里是词”,而在“哪些字符应该被拼回一个词、哪些标点应该被剥离”。
这个任务贯穿几乎所有 Python 文本处理场景:词频统计、文本分类前的特征提取、聊天机器人的意图识别、搜索引擎倒排索引的构建。判定的核心围绕三个问题:撇号怎么拆、连字符怎么处理、缩写和数字混合串要不要完整保留。文中会给出可直接落地的正则方案、边界策略、批处理管道和验证手段,适合正在写爬虫清洗逻辑、NLP 预处理脚本或日志分析的工程师。
2. 用 re.findall 做英文单词分割的基础实现
2.1 为什么 replace 掉标点再 split 不是单词分割
很多初学者会把分割做成两步:先用replace删掉所有标点,再split()。这个方案的直接后果是信息丢失——"C++"变成了两个 C,"3.14"被截断,"state-of-the-art"这类携带语义的信息被强行拆散。而更隐蔽的问题在于:replace依赖你枚举所有标点符号,漏掉一个特殊符号,整个统计结果就带上污染。
单词分割本质上是逐个字符做“词内/词外”的二元判断,而不是做替换。词内字符包括字母、数字、撇号、连字符和部分 Unicode 扩展字母;词外字符是空白、标点、符号。用一遍遍历完成这个判断,最省事的工具就是正则表达式。
2.2 一条能跑的最小正则代码
import re text = "Hello, world! This is a test: it's not that hard." words = re.findall(r"[\w']+", text) print(words) # 输出: ['Hello', 'world', 'This', 'is', 'a', 'test', "it's", 'not', 'that', 'hard'][\w']+的逐段含义是:\w匹配 Unicode 字母、数字和下划线;'将撇号显式放入字符类;+把相邻字符拼成一个完整的单词单元。re.findall只返回匹配到的内容,不返回分隔符,正好满足“只提取词”的需求。
这个模式能覆盖约八成常见英文语料。但它有两个明显缺口:连字符state-of-the-art会被拆成三段,缩写U.S.会被点号截断。需要考虑到底层逻辑再决定是否用更复杂的模式。
2.3 正则模式中 \w 和边界 \b 的实际行为
\b是单词边界,它匹配一个位置而不是字符。直接使用re.findall(r"\b\w+\b", text)同样是有效的分割方法,它依赖边界的零宽匹配来切分词。
| 模式 | 匹配示例 | 适用场景 |
|---|---|---|
[\w']+ | it's,hello | 保留撇号的基础场景,速度快 |
\b\w+\b | it,s(会把 it's 拆开) | 只需要纯字母词时 |
[a-zA-Z]+(?:['-][a-zA-Z]+)* | state-of-the-art,don't | 需要保留连字符和撇号作为词内字符时 |
\b的边界判断基于\w的字符集:\b的一侧是\w字符而另一侧不是。don't中间的撇号不是\w,所以\b\w+\b会以撇号为界拆出don和t,这是很多人初次尝试时踩到的第一道坑。不带\b、直接用字符类拼接的模式更适合做英文单词分割,因为它把“什么字符属于词内部”的决定权完全握在自己手里。
3. 英文单词分割的边界情况:撇号、连字符和缩写
3.1 撇号的三种处理策略
撇号是英文单词分割里最值得细抠的字符。它同时承担所有格(John's)、缩写(don't、it's)、复数缩写('90s)三种角色,没有统一的语法信号。
| 策略 | 模式 | 对don't的结果 | 适用任务 |
|---|---|---|---|
| 保留撇号 | [a-zA-Z]+(?:'[a-zA-Z]+)* | don't | 词频统计、模糊匹配 |
| 拆开撇号 | [a-zA-Z']+再做切分 | don,t | 拼写纠错、语音合成 |
| 丢弃撇号 | [a-zA-Z]+ | dont | 搜索引擎索引 |
我的默认选择是保留。原因在于后续做单词归一化时,保留下来的don't能通过规则还原成do not,而拆开的t很难拼回原意。如果下游是向量化或词嵌入,拆开的碎片基本是噪声。
3.2 缩写后处理:U.S. 不该被切成 U 和 S
U.S.、U.K.、e.g.这类带点缩写在一段正常英文里出现频率不低,任何基于字符类的正则都会把它们拆成单字母词。简单做法是分两步走:先用正则切,再按白名单合并。我常用的合并逻辑如下。
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[i+1]) in ABBR_MAP: merged.append(ABBR_MAP[(tokens[i], tokens[i+1])]) i += 2 else: merged.append(tokens[i]) i += 1 return merged这个实现的关键是合并逻辑永远在分割之后进行,避免正则本身把e.g.当成整体去匹配而干扰其他单词。注意白名单是有限的,落到具体业务时,应该从语料里统计高频单字母对,再决定要不要纳入映射。
3.3 连字符:拆开是三个词,留着是一个词
state-of-the-art在技术文档里大量出现。拆开得到state、of、the、art四个高频词,会稀释统计的区分度;留着作为整体则能保留复合词含义。两者没有绝对对错,取决于任务。
做词频统计或 TF-IDF 时我倾向于保留,因为state-of-the-art作为一个整体比四个独立词更能代表文档主题。做机器翻译或语法分析时反而应该拆开,因为句法树需要独立的state和of。保留连字符的正则写法是:
# 匹配:字母开头 + 中间允许出现连字符或撇号 + 字母结尾 re.findall(r"[a-zA-Z]+(?:['-][a-zA-Z]+)*", text)这里有个细节:连字符若出现在单词末尾(如well-这种断行标记),上述模式不会吞入结尾的-,从而避免尾部噪声。这是我喜欢用这个模式而不是[a-zA-Z'-]+的原因,后者会把word--next里的双连符一起吞进去。
3.4 数字和 Unicode 字符的取舍
英文语料里常混入1984、3.14、COVID-19这类数字相关词。\w默认包含数字,但纯数字串对词频统计没有语义价值。如果任务是文本分类,通常只保留字母开头的 token。
遇到café、naïve这类带重音符号的单词时,[a-zA-Z]会直接丢弃重音字母,\w在 Python 3 的默认 Unicode 模式下能完整保留。取舍的原则是:语料面向搜索引擎或统计任务时,应显式加入re.ASCII标志让\w只匹配 ASCII,避免带重音的变体被拆成两个不完整词。
# 只保留 ASCII 字母和内部连字符/撇号 re.findall(r"[a-zA-Z]+(?:['-][a-zA-Z]+)*", text) # 保留 Unicode 字母,适合多语言混合语料 re.findall(r"[^\W\d_]+(?:['-][^\W\d_]+)*", text, flags=re.UNICODE)[^\W\d_]是\w减去数字和下划线的写法,匹配所有的 Unicode 字母。在英文为主、偶尔夹杂法文或德文的语料里,这个模式比[a-zA-Z]更稳。
4. 实战:英文单词分割在词频统计里的完整管道
4.1 从文件读取到分割的最小管道
把分割逻辑接到真实文件处理上,我通常一次性完成读取、分割和统计,而不是中间保存中间结果。下面是一个完整的词频统计管道。
import re from collections import Counter PATTERN = re.compile(r"[a-zA-Z]+(?:['-][a-zA-Z]+)*") def tokenize_file(filepath, min_len=2): with open(filepath, encoding="utf-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_len=2) counter = Counter(tokens) print(counter.most_common(20))这段代码维持了单向数据流:读入原始文本 → 小写归一化 → 正则分割 → 长度过滤 → 频次统计。min_len=2能过滤掉单字母词,但也会误伤"I"和"a",所以这个参数必须结合停用词表使用,不能单独依赖长度过滤。
4.2 大小写归一化与词形归并的边界
Python和python在统计时应算同一个词,所以lower()是分割后必做的一步。但 lower 不能彻底解决问题:running、runs、ran属于同一个词根,词频统计里被当成三个独立词。正则只能做表层切割,做不了词形归并(Lemmaization)。
如果下游做的是文档相似度或主题聚类,建议引入词干化工具,常见做法是加一层 SnowballStemmer。但对于单词分割本身的职责边界,分词只负责把 token 切对,词形归并不是它的工作。把职责分开,后续如果换用深度学习模型做向量化,只要重写归并层,不需要动分割层。
4.3 停用词表与最小词长过滤的组合
只按词频排序,结果前列永远是the、and、of。停用词表能把这些常见词从结果中剔除,但要避免一个隐患:停用词过滤必须放在大小写归一化之后,否则The和the会被当成两个词分别判断。
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]对于没有现成停用词表的场景,可以先用 Counter 统计全量词频,把出现次数超过语料 1% 的高频虚词提取出来作为停用词初稿。这样生成的停用词表与业务语料强相关,比直接套用通用表更贴合场景。min_len和停用词表是互补关系:前者过滤结构上的短噪声,后者过滤语义上的噪声。
4.4 三个必调参数及调参路径
| 参数 | 位置 | 推荐初始值 | 调整依据 |
|---|---|---|---|
| 正则模式 | findall第一个参数 | [a-zA-Z]+(?:['-][a-zA-Z]+)* | 语料含法语/德语词时改[^\W\d_]+ |
min_len | 筛选 token | 2 | 统计到单字母术语(如C语言)时降为 1 |
| 是否纳入缩写合并 | 分割后处理 | 关 | 语料缩写密度高时开启白名单 |
调参的顺序是先看分割质量再做长度过滤。输出里出现don和t这种碎片时先修正则;出现整段的https或文件名时再考虑加前缀过滤。一次性调整多个参数会让结果难以定位真正原因。
5. 性能对比与结果验证的进阶技巧
5.1 先 split 后清洗 vs 直接 findall
一个常被提起的老问题:re.findall要逐字符扫描文本,会不会比先split再用str.translate清洗标点慢得多?我做一个约 20 万词的新闻语料测试,结论是findall与split+translate耗时基本持平,差异在 10% 以内。
原因在于split虽然快,但后续还要用translate遍历每个 token 做第二次扫描;findall是在一次遍历中完成字符分类和输出。后者多花的成本仅在于正则引擎的状态机切换,而 Python 层省下了列表二次遍历的开销。因此性能不该成为选择 split 的理由,正确性才是。
5.2 用 re.compile 缓存模式和进程池加速批处理
处理上百个文件时,反复调用re.findall(r"...", text)会导致正则模式被反复编译。把模式提前用re.compile编译,findall方法直接在编译后的模式对象上调用,能减少这一层开销。配合进程池能达到接近线性加速。
import re from concurrent.futures import ProcessPoolExecutor PATTERN = re.compile(r"[a-zA-Z]+(?:['-][a-zA-Z]+)*") def tokenize_file(filepath): with open(filepath, encoding="utf-8") as f: text = f.read() return PATTERN.findall(text.lower()) file_list = ["a.txt", "b.txt", "c.txt", "d.txt"] with ProcessPoolExecutor(max_workers=4) as ex: results = list(ex.map(tokenize_file, file_list))注意进程池的回调函数里不需要再写一次re.compile,因为全局变量PATTERN会被每个 worker 进程继承。这比每次调用tokenize_file时临时编译要快。
5.3 一个验证方法:把分割结果重新拼回原文
分割逻辑改过几次后,最怕的问题不是切得不准,而是切丢了字符。一个有效的自查方式是把分割后的单词重新连接成字符串,与原始文本去掉所有空白后的字符串做比较。如果两者完全相等,说明没有字符丢失。
def verify_no_char_loss(text, words): joined = "".join(words) reference = re.sub(r"\s+", "", text) return joined == reference, len(joined), len(reference) text = "Don't 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)这个验证的局限在于它只保证“没丢字符”,不保证“切分正确”,比如把don't拆成don和t再拼回去长度仍然相等。所以把它作为冒烟测试跑,真正判断切分质量要把输出目测一遍或者和权威分词器的结果抽样对比。两种手段结合,才能放心把分割结果送进下游统计。
本文还有配套的精品资源,点击获取