前言
文本词频分析(word frequency analysis)要解决的问题很直接:给定一段文本,统计其中每个词出现了多少次,并按次数从多到少排序。它是关键词提取、日志统计、舆情监控、文本分类特征工程的第一步,也是最基础的一步。
新手最常见的两个误解。第一个是以为"统计词频"就是"对每个词调用一次list.count()"——这样写的复杂度是 O(n²),文本一变长就慢得没法用。第二个是以为中文文本可以直接调用str.split()得到词,实际上split()只按空白字符切分,而中文句子内部没有空格,切出来往往是整句。
本文用纯标准库(re、collections)搭一套可以直接复制进.py文件运行的英文词频分析流程,并说清中文分词为什么绕不开第三方库,以及不装第三方库时的退路是什么。文中所有示例按 Python 3.8+ 编写。
一、词频分析的完整流程
一个能用的词频分析,通常要经过四步,缺一步结果就会走样:
- 读取文本。从字符串、文件或网页拿到底层文本。
- 归一化(normalization)。统一大小写、去掉标点、处理数字。
- 切词(tokenization)。把长文本切成一个个词元(token)。
- 计数与排序。统计频次,取 Top-N。
很多"我自己写了个词频统计"的代码之所以结果奇怪,就出在第 2、3 步:"Python"和"python"被算成两个词,"word,"带标点和"word"也被算成两个词。
计数这一步不要手写循环累加,标准库已经给了专门的数据结构。
| 做法 | 复杂度 | 推荐度 |
|---|
对每个词调用list.count() | O(n²) | 不推荐 |
手写dict累加d[w] = d.get(w, 0) + 1 | O(n) | 可用 |
collections.Counter | O(n) | 推荐 |
二、切词:英文用正则,中文要走分词
英文的词与词之间有空格,但直接用split()还是不够:"word,"会把逗号一起切进来。更稳的写法是用正则表达式re.findall(),只匹配"连续的字母",把标点当作分隔符扔掉。
# 适用于 Python 3.8+
import re
text = "The quick brown fox. The quick dog! The Fox."
words = re.findall(r"[A-Za-z']+", text.lower())
print(words)
# ['the', 'quick', 'brown', 'fox', 'the', 'quick', 'dog', 'the', 'fox']注意这里先.lower()做归一化,所以Fox和fox合并成了一个词,这是绝大多数场景想要的结果。如果你要区分大小写,去掉.lower()即可。
中文的情况完全不同。中文句子"今天天气很好"内部没有空格,re.findall(r"[A-Za-z']+", ...)什么都匹配不到,split()只会返回整句一个元素。中文正确切词的唯一实用办法是分词库,比如结巴分词(jieba)。jieba 是第三方库,需要单独安装,本机没有安装它,下面的调用请以 jieba 官方文档为准,我这里只写用法思路:
# 适用于 Python 3.8+;需要安装第三方库 jieba,此处仅示意用法
import jieba
words = list(jieba.cut("今天天气很好,我们出去走走"))
# 大致切分为 ['今天', '天气', '很好', '我们', '出去', '走走']如果不允许引入第三方库,可以用两种"降级"方案:一是按单字统计(把每个汉字当一个词,统计字频),二是按二元组(bigram)统计,也就是把相邻的两个字拼成一个词元。后者的写法是:
# 适用于 Python 3.8+:中文二元组切词,无需第三方库
import re
def bigrams(text):
chars = re.findall(r"[一-鿿]", text)
return [chars[i] + chars[i + 1] for i in range(len(chars) - 1)]
print(bigrams("今天天气很好"))
# ['今天', '天天', '天气', '气很', '很好']这里的一到鿿是常用汉字在 Unicode 中的码点区间。二元组切法噪声较大,但不需要任何依赖,适合做粗粒度统计。
三、用 Counter 计数
collections.Counter是dict的子类,专门用来数东西。它可以接收一个可迭代对象,直接把每个元素的出现次数统计好:Counter(words)之后,counter["the"]就是"the"的次数,取不存在的键返回0而不是抛KeyError。
# 适用于 Python 3.8+
from collections import Counter
words = ['the', 'quick', 'brown', 'fox', 'the', 'quick', 'the']
counter = Counter(words)
print(counter['the']) # 3
print(counter['missing']) # 0,不报错
# 取出现次数最多的 2 个
print(counter.most_common(2))
# [('the', 3), ('quick', 2)]most_common(n)返回的是(元素, 次数)元组的列表,按次数降序排列。不传参数时返回全部元素。它的排序在次数相同时顺序由元素本身的顺序决定,属于实现细节,不要依赖。
几个常用方法:
counter.update(iterable):把新一批词加进去,次数累加。counter.elements():按次数把每个元素重复展开成迭代器。counter.total():所有次数之和(需要 Python 3.10 及以上)。在 3.9 及更早版本用sum(counter.values())代替。
四、过滤停用词与取 Top-N
the、a、is这类词叫停用词(stop word),它们出现频率最高但几乎没有信息量。做法是先把它们塞进一个set,再生成计数前把它们过滤掉。
# 适用于 Python 3.8+
STOP_WORDS = {"the", "a", "an", "is", "are", "of", "to", "and"}
filtered = [w for w in words if w not in STOP_WORDS and len(w) > 1]
counter = Counter(filtered)判断"是不是停用词"用set而不是list:in操作在set上是平均 O(1),在list上是 O(n)。词表一大,差别就出来了。
完整的可运行示例:
# 适用于 Python 3.8+
import re
from collections import Counter
STOP_WORDS = {"the", "a", "an", "is", "are", "of", "to", "and", "in", "on"}
def word_freq(text, top_n=5):
tokens = re.findall(r"[A-Za-z']+", text.lower())
tokens = [t for t in tokens if t not in STOP_WORDS and len(t) > 1]
counter = Counter(tokens)
return counter.most_common(top_n)
sample = (
"Python is a programming language. Python is used for data analysis. "
"Data analysis with Python is popular. Python Python!"
)
for word, count in word_freq(sample):
print(f"{word}: {count}")程序推演:sample先转小写,再被正则切出所有字母词,过滤掉停用词和单字母词后,python出现 4 次、data出现 2 次、analysis出现 2 次、programming、language、used、popular各 1 次。most_common(5)输出前五名。
常见坑点
- 在循环里用
list.count()统计。这是最典型的新手写法,复杂度 O(n²),十万词的文本会卡住。
❌for w in set(words): freq[w] = words.count(w)✅freq = Counter(words),一次遍历统计完,O(n)。
- 忘了统一大小写和剥离标点。
"Python"与"python"、"fox"与"fox."会被拆成不同的键,Top-N 结果严重失真。
❌words = text.split()✅words = re.findall(r"[A-Za-z']+", text.lower())
- 以为中文能直接
split()或直接用英文正则。中文没有空格,split()返回整句,[A-Za-z']+一个都匹配不到,统计结果为空。
❌Counter(text.split())处理中文 ✅ 用 jieba 等分词库,或退而求其次按单字 / 二元组切分。
- 把
most_common()的返回当成字典。它返回的是(元素, 次数)元组的列表,直接["word"]下标取值会报TypeError。
❌top = counter.most_common(3)然后top["python"]✅top = counter.most_common(3)后遍历for word, count in top:。
- 在 3.9 及更早版本调用
Counter.total()。该方法是3.10 新增,旧版本会AttributeError。
❌ 在 3.8 环境写counter.total()✅ 用sum(counter.values()),它在所有 3.x 版本都可用。
- 统计时修改字典导致
RuntimeError。边遍历counter边删键(比如想顺手删掉低频词)会抛dictionary changed size during iteration。
❌for w in counter: if counter[w] < 2: del counter[w]✅ 先收集要删的键,再删除:for w in [k for k, c in counter.items() if c < 2]: del counter[w]。
- 读取文件不指定
encoding。在默认编码不是 UTF-8 的平台上(典型是 Windows 简体中文环境用 GBK),读含中文的文本会乱码或直接抛UnicodeDecodeError。
❌open("corpus.txt")✅open("corpus.txt", encoding="utf-8")。
- 把行数当词数。
len(open(path).readlines())得到的是行数,不是词数,两者完全不是一回事。
❌ 用文件行数冒充词频总数 ✅ 分词后再sum(counter.values())。
总结
| 环节 | 推荐做法 | 关键点 |
|---|
| 读取 | open(path, encoding="utf-8") | 显式指定编码,避免跨平台乱码 |
| 归一化 | .lower()+ 正则剥标点 | 否则同一个词被拆成多个键 |
| 英文切词 | re.findall(r"[A-Za-z']+", text.lower()) | 不要用split() |
| 中文切词 | jieba 等分词库;无依赖时用单字 / 二元组 | 中文无空格,正则匹配字母无效 |
| 计数 | collections.Counter | O(n),不要用list.count() |
| 排序 | counter.most_common(n) | 返回元组列表,不是字典 |
| 过滤 | set存停用词 | in判定平均 O(1) |
词频分析看着简单,真正的难点不在计数,而在"什么算一个词"。英文靠正则归一化,中文靠分词库;把切词这一层做对,后面的Counter和most_common几乎不会出错。反过来,切词一旦做错,再漂亮的统计代码给出的也只是一堆没有意义的字符串。