引言
第六章进入尾声,前七篇的容器知识(列表、元组、字典、集合、拷贝与引用、嵌套容器、collections 四件套)需要一个真正的项目来检验。本篇就是那场检验:用纯标准库实现一个完整的「词频统计与分析」工具。它把一件真实工程任务——拿到一份文本,搞清楚「哪些词出现得最多」——从头到尾走一遍:读取文本、清洗分词、统计计数、过滤噪声、可视化、保存结果。全部代码加起来不到一百行,却是文本分析类工具(日志关键词统计、文章标签推荐、热点词监控)最核心的骨架。
读本篇前应已掌握:第五章的正则(findall、字符类、re.ASCII 的语义)、第一篇的列表操作、第三篇的字典、第七篇的 Counter 与 defaultdict。语种问题先如实说明:英文单词天然以空白分隔,按空白切分即可;中文的词与词之间没有空格,需要分词库(如 jieba)做词典匹配,本篇会演示并交代局限。所有代码在本机 Python 3.12.10 上实测运行,输出逐字一致。
概念与原理
词频统计的四个阶段
任何词频统计工具都逃不出这条流水线。第一是分词(tokenization):把文本切成一个个词。英文按空白切就能得到绝大部分词,但标点会粘在词上(summer.),大小写不一致(Python 与 python)。第二是清洗(normalization):小写化统一词形,用正则把非字母的杂质剥掉。第三是计数(frequency):把词列表交给 Counter,一行统计完成。第四是过滤与呈现:停用词(stopwords,如 the、and、i)是语法 glue 而非内容词,滤掉后 TOP 榜才有信息量;再用字符串方法画一幅纯文本柱状图、把报告写进文件。
每一步都对应前八篇学过的东西:正则负责清洗,列表当中间产物,Counter 与 dict 推导式负责计数与过滤,f-string 与乘法运算符负责画图,pathlib 负责读写文件。这就是「容器 + 正则」能做出真实工具的原因:数据组织是管线的骨架,正则负责把输入收拾整齐。
中文:为什么不能照搬英文方案
英文分词简单是因为词间有空白:text.split() 已能切出九成词汇。中文的句子「我爱北京天安门」没有空格,按空白切分一整句只会得到一个「词」,必须借助词典与统计模型判断哪些字组合成词——这就是分词库(英文 segmentation library)的工作。jieba 是目前 Python 生态最常用的开源分词库,pip install jieba 即可安装;它用前缀词典 + 动态规划做切分,还带 HMM 处理未登录词。本篇如实演示它的用法与局限;若环境里装不了第三方库,「按字统计」是无库的简化方案,但必须明白「字频」不等于「词频」。
性能与内存的量级概念
Counter 统计是逐词插入字典,均摊 O(n)(n 为词数);most_common(n) 在指定条数时用堆,复杂度 O(n log k)。作为量级参考,本篇实测:10 万词的词表(1000 种不同词)在 Python 3.12 上统计耗时为几毫秒级、峰值内存(含词表与 Counter 本身)约 5 MB。也就是说——日常文档规模(几千到几万字)根本不需要优化;只有到千万词级别才需要考虑流式处理(一行行读、边读边 update Counter,把内存从存下全部词降到只存唯一词)。
操作与实现
第一步:让样例文本就位
真实工程的第一步永远是「把数据弄到程序能读的地方」。这里把一段英文随笔写入 sample_article.txt。为什么用文件而不是字符串变量?因为文件才是真实输入,也让后面的每一段代码都能独立运行、可以换成你自己的文本:
from pathlib import Path
SAMPLE = """Learning to code has been my favorite project this year. I started with Python because Python code is easy to read. My first code was a small script that printed hello one hundred times. Every day I write a little more code and read a little more code. Reading good code helps me write better code. After the basics, I learned about lists and dictionaries. Python lists keep items in order, and dictionaries map keys to values. Dictionaries are fast when I look up data by name. Next I studied strings and regular expressions. Regular expressions are powerful tools for cleaning text. I used them to find words inside text, to split text into lines, and to count numbers in log files. Then I discovered the collections module, and my code became shorter. Counter counts words in one line, and defaultdict removes missing key errors. My first real project reads a text file and counts every word in that text. The project prints the top ten words and saves a report. My test files are small, so each test runs fast. I run my tests after every change. Small steps add up: I keep writing code, keep reading code, and keep testing my code. If you are reading this article, you already know every part of this project. Go ahead, write your own version, run it on your favorite text, and enjoy the result."""
Path('sample_article.txt').write_text(SAMPLE, encoding='utf-8')
print('已写入 sample_article.txt')
print('字符数:', len(SAMPLE), '| 按空白切词数:', len(SAMPLE.split()))
已写入 sample_article.txt
字符数: 1299 | 按空白切词数: 234
注意两点:write_text/read_text 必须显式给 encoding=‘utf-8’——Windows 上默认编码可能是 GBK,中文环境写文件不指定编码是最早遇到的坑(第七章文件专题还会细讲);这里只是为了演示,你也可以把任何 .txt 放在同目录替换它。
第二步:朴素切分与清洗的对比
先看「直接 split」的问题,再做清洗。清洗方案是第五章的老朋友:小写化 + re.findall(r"[a-z']+", text.lower())——一条正则同时完成「去标点」与「切词」:它只保留小写字母与撇号(don’t 这类缩写保留词干),其他字符(句点、逗号、冒号)都作为分隔符被跳过:
import re
from pathlib import Path
text = Path('sample_article.txt').read_text(encoding='utf-8')
naive = text.split()
clean = re.findall(r"[a-z']+", text.lower())
print('朴素切分前 6 个:', naive[:6])
print('清洗后前 6 个:', clean[:6])
print('朴素词数:', len(naive), '| 清洗后词数:', len(clean))
朴素切分前 6 个: ['Learning', 'to', 'code', 'has', 'been', 'my']
清洗后前 6 个: ['learning', 'to', 'code', 'has', 'been', 'my']
朴素词数: 234 | 清洗后词数: 234
两版词数都是 234,但含义不同:朴素版里 Learning 与 learning 是两个词、summer. 带着句点;清洗版统一了小写、剥掉了句点,the 和 The 不再被当成两个词。注意:我们只做小写化,不做词形还原(stemming)——learn 与 learning、code 与 coding 仍算不同词。工程上这是简化,统计时心里要有数。
第三步:Counter 一眼看到全貌
清洗后的词列表交给 Counter,统计一步到位;most_common(10) 直接给出候选榜:
import re
from collections import Counter
from pathlib import Path
text = Path('sample_article.txt').read_text(encoding='utf-8')
words = re.findall(r"[a-z']+", text.lower())
counts = Counter(words)
print('总词数:', len(words))
print('不同词数:', len(counts))
for word, n in counts.most_common(10):
print(f' {word:<12}{n}')
总词数: 234
不同词数: 134
code 11
and 11
i 9
my 7
to 6
text 6
a 5
the 5
project 4
every 4
榜单已经看出门道:code 是内容核心(11 次),但 and、i、my、to、a、the 这些功能词(function words)挤占了半壁江山——它们几乎出现在任何英文文本里,含金量为零。下一步就是把它们滤掉。
第四步:停用词过滤
停用词表没有标准答案,是工程调参项。这里用一张真实可用的常见英文小表(50 个左右),包含冠词、介词、代词、系动词的小写形式,用 dict 推导式 + Counter 构造过滤后的新计数:
import re
from collections import Counter
from pathlib import Path
text = Path('sample_article.txt').read_text(encoding='utf-8')
words = re.findall(r"[a-z']+", text.lower())
STOPWORDS = {
'the', 'a', 'an', 'and', 'or', 'but', 'of', 'to', 'in', 'on', 'for',
'with', 'is', 'are', 'was', 'were', 'be', 'been', 'it', 'its', 'this',
'that', 'as', 'at', 'by', 'from', 'has', 'have', 'had', 'do', 'does',
'did', 'not', 'so', 'than', 'too', 'very', 'can', 'will', 'you', 'your',
'we', 'our', 'i', 'he', 'she', 'they', 'my', 'me',
}
counts = Counter(words)
filtered = Counter({w: n for w, n in counts.items() if w not in STOPWORDS})
print('过滤前 TOP10:', counts.most_common(10))
print('过滤后 TOP10:', filtered.most_common(10))
print('存活不同词:', len(filtered), '/', len(counts))
过滤前 TOP10: [('code', 11), ('and', 11), ('i', 9), ('my', 7), ('to', 6), ('text', 6), ('a', 5), ('the', 5), ('project', 4), ('every', 4)]
过滤后 TOP10: [('code', 11), ('text', 6), ('project', 4), ('every', 4), ('keep', 4), ('python', 3), ('small', 3), ('write', 3), ('reading', 3), ('dictionaries', 3)]
存活不同词: 110 / 134
过滤后榜单「含金量」明显上升:code、text、project、python、dictionaries 这些真正的主题词浮出水面,134 个不同词里 110 个存活。注意 every、keep、small 这类词没被滤掉——它们在很多语境是内容词,这就是停用词表「宁缺毋滥」的原因:表太长会把主题词误杀。
第五步:纯文本柱状图
可视化不需要图表库——字符串乘法就是柱状图。按比例缩放柱长(最长柱 30 个字符),用 f-string 的 <12、>3 对齐词名与次数:
import re
from collections import Counter
from pathlib import Path
text = Path('sample_article.txt').read_text(encoding='utf-8')
words = re.findall(r"[a-z']+", text.lower())
STOPWORDS = {
'the', 'a', 'an', 'and', 'or', 'but', 'of', 'to', 'in', 'on', 'for',
'with', 'is', 'are', 'was', 'were', 'be', 'been', 'it', 'its', 'this',
'that', 'as', 'at', 'by', 'from', 'has', 'have', 'had', 'do', 'does',
'did', 'not', 'so', 'than', 'too', 'very', 'can', 'will', 'you', 'your',
'we', 'our', 'i', 'he', 'she', 'they', 'my', 'me',
}
counts = Counter(words)
filtered = Counter({w: n for w, n in counts.items() if w not in STOPWORDS})
top = filtered.most_common(10)
peak = top[0][1]
print(f'最高频词 {top[0][0]} 出现 {peak} 次,柱长按比例缩放:')
for word, n in top:
bar = '#' * round(n / peak * 30)
print(f'{word:<12}{n:>3} {bar}')
最高频词 code 出现 11 次,柱长按比例缩放:
code 11 ##############################
text 6 ################
project 4 ###########
every 4 ###########
keep 4 ###########
python 3 ########
small 3 ########
write 3 ########
reading 3 ########
dictionaries 3 ########
比表格直观一截:code 一柱擎天,text 次之,其余词汇量级相当。换成你自己的文本时,把 '#' 换成 '█' 就是实心图表。
第六步:中文文本怎么办
英文方案对中文直接失效——「我爱北京天安门」按空白切只有一个「词」。真实做法是分词库。jieba 需要先 pip install jieba,装好后调用 cut 即可:
from collections import Counter
import jieba
text = '我爱北京天安门,天安门上太阳升。'
raw = [w for w in jieba.cut(text)]
print('jieba 原始切分:', raw)
words = [w for w in raw if w.strip() and w not in ',。!?、']
print('过滤标点后:', words)
print(Counter(words))
jieba 原始切分: ['我', '爱', '北京', '天安门', ',', '天安门', '上', '太阳升', '。']
过滤标点后: ['我', '爱', '北京', '天安门', '天安门', '上', '太阳升']
Counter({'天安门': 2, '我': 1, '爱': 1, '北京': 1, '上': 1, '太阳升': 1})
三个要点:一是标点(,。)会被切成独立「词条」,分词后要再过滤一层;二是分词结果不总是符合直觉(太阳升 被合成了一个词,还出现了单字 我、爱——jieba 是统计学分词,不是词典精确命中);三是分词之后,Counter、停用词过滤、柱状图这套管线完全照用,换的只是切词那一行。
若环境里装不了 jieba,退而求其次的「按字统计」方案——它只能回答「哪些字出现多」,不能回答「哪些词出现多」:
from collections import Counter
text = '我爱北京天安门,天安门上太阳升。'
print(Counter(c for c in text if c.isalpha()))
Counter({'天': 2, '安': 2, '门': 2, '我': 1, '爱': 1, '北': 1, '京': 1, '上': 1, '太': 1, '阳': 1, '升': 1})
天安门三个字各出现两次被准确数出,但「北京」「太阳」这种双字词统统被拆散——做热词榜时字频榜与词频榜的意义完全不同,务必分清。
第七步:性能与内存的量级实测
担心「统计 10 万词会不会很慢」?直接测。构造 10 万词的词表(1000 种不同词,模拟真实文本的高重复度),用 perf_counter 计时:
import time
from collections import Counter
words = [f'word{i % 1000}' for i in range(100000)]
t0 = time.perf_counter()
counts = Counter(words)
t1 = time.perf_counter()
print('总词数:', len(words), '| 不同词数:', len(counts))
print(f'统计耗时: {(t1 - t0):.4f} 秒({(t1 - t0) * 1000:.1f} 毫秒)')
总词数: 100000 | 不同词数: 1000
统计耗时: 0.0066 秒(6.6 毫秒)
再用 tracemalloc 量一下内存峰值(含词表本身与 Counter):
import tracemalloc
from collections import Counter
tracemalloc.start()
words = [f'word{i % 1000}' for i in range(100000)]
counts = Counter(words)
_, peak = tracemalloc.get_traced_memory()
print(f'统计峰值内存(含词表与 Counter): {peak / 1024 / 1024:.2f} MB')
统计峰值内存(含词表与 Counter): 5.37 MB
本机实测的量级结论:10 万词全套清洗 + 计数在几十毫秒以内、内存个位数 MB。耗时数字随机器浮动(同一台机器多次运行也会有毫秒级抖动),量级恒定。这背后的复杂度是:切词 O(n)、Counter 构建 O(n)、most_common 用堆 O(n log k)。到了千万词级别,把「一次读入全部词」改成「一行行读、逐行 update 同一个 Counter」,内存从 O(词数) 降到 O(唯一词数)——这是第七篇 update 方法的用武之地。
第八步:报告落盘
最后把结果写进文件。用列表收集行、‘\n’.join 一次写盘、再回读打印验证闭环:
import re
from collections import Counter
from pathlib import Path
text = Path('sample_article.txt').read_text(encoding='utf-8')
words = re.findall(r"[a-z']+", text.lower())
counts = Counter(words)
STOPWORDS = {
'the', 'a', 'an', 'and', 'or', 'but', 'of', 'to', 'in', 'on', 'for',
'with', 'is', 'are', 'was', 'were', 'be', 'been', 'it', 'its', 'this',
'that', 'as', 'at', 'by', 'from', 'has', 'have', 'had', 'do', 'does',
'did', 'not', 'so', 'than', 'too', 'very', 'can', 'will', 'you', 'your',
'we', 'our', 'i', 'he', 'she', 'they', 'my', 'me',
}
filtered = Counter({w: n for w, n in counts.items() if w not in STOPWORDS})
lines = ['词频统计报告', '=' * 20,
f'文本文件: sample_article.txt',
f'总词数: {len(words)}',
f'不同词数: {len(counts)}',
f'过滤停用词后剩余不同词: {len(filtered)}', '',
'TOP 10(过滤后):']
for i, (word, n) in enumerate(filtered.most_common(10), 1):
lines.append(f'{i:>2}. {word:<12}{n}')
Path('word_freq_report.txt').write_text('\n'.join(lines), encoding='utf-8')
print(Path('word_freq_report.txt').read_text(encoding='utf-8'))
词频统计报告
====================
文本文件: sample_article.txt
总词数: 234
不同词数: 134
过滤停用词后剩余不同词: 110
TOP 10(过滤后):
1. code 11
2. text 6
3. project 4
4. every 4
5. keep 4
6. python 3
7. small 3
8. write 3
9. reading 3
10. dictionaries3
至此一个完整的词频统计工具就绪:输入任意英文文本文件,输出一份带 TOP 榜的报告。把 5-8 步合并成一个函数、用命令行参数指定文件路径,就是第七篇预告过的「工具化」雏形——排序、聚合、报表化这些正式分析留到下一篇实战。
易错点与陷阱
1. 不洗大小写与标点,统计立刻失真
Python 与 python 会被计成两个词,summer. 与 summer 也是两个词(实测朴素切分版 Learning 与 learning 并存)。统计前必须先统一:小写化 + 正则剥标点。顺序有讲究:先 lower 再 findall,正则就只需写小写字符类;反过来先剥标点再 lower 也行,但要小心撇号(don’t 里的 ’ 不要当标点剥掉,字符类里显式带上 ')。
2. 停用词表不是标准答案
本章的小表对这篇纪实随笔效果很好,换一篇科技论文就可能误伤 data、model;every、keep 这类词在这个主题里是内容词,换个主题就成了噪声。停用词过滤本质是领域调参:先在不过滤的情况下观察 TOP 榜,再决定表里放什么。另外表必须与「清洗后」的词形一致——清洗已全小写,表里就别写 The。
3. 词频 ≠ 主题重要度,中文还有「字 ≠ 词」
频次高只说明「出现多」,不说明「重要」:code 出现 11 次是因为这篇文章通篇讲编程。真要衡量重要性需要逆文档频率(IDF)等加权手段,那是课程 06 的事了。中文场景再加一条:按字统计得到的是字频榜,「北京」被拆成北、京二字(实测),任何「词云、热词」结论都必须以分词为前提。
4. 性能数字是量级不是承诺
6.6 毫秒是「本机、1000 种不同词、10 万词」这一具体场景的实测值;词表大小、机器配置、是否开着 tracemalloc(追踪分配本身会让程序变慢)都会改变数字。工程上正确的姿势是:记住「10 万词 ≈ 毫秒级、个位数 MB」的量级,真到千万词再优化(流式 update),不要为几万字的文档做任何过早优化。
小结
本篇用八步完成了一个可复用的词频统计工具:样例文件落盘、正则清洗(小写化 + 去标点)、Counter 一次计数、停用词过滤让内容词浮出水面、字符串乘法画纯文本柱状图、jieba 处理中文(并如实说明「字 ≠ 词」的局限)、10 万词性能与内存实测(毫秒级、个位数 MB)、报告写盘并回读验证。整条管线只用了已学的容器与正则,验证了「容器 + 正则 = 文本分析骨架」的判断。抓住的要点:清洗先于计数、停用词是领域调参、中文必须分词、性能按量级评估。下一篇实战把本工具升级成「排序、筛选与聚合报表」,把所有容器技能拧成一台完整的机器。
练习与思考题
- 把本篇的 SAMPLE 换成你自己的文本(比如把本篇正文存成 my_article.txt,注意中文需要先分词),跑通八步管线,观察 TOP10 与本文示例的差异。
- 改造柱状图脚本:柱长旁边同时打印百分比(
round(n / len(words) * 100, 1)),输出形如code 4.7%,解释为什么「百分比」比「次数」更适合跨文本比较。 - 思考题:若输入是 5000 万词的语料,现在「一次读入全部词」的方案会占多少内存?改用「逐行 readline + Counter.update」后内存主要取决于什么?(提示:词表 vs 唯一词数)
- 安装 jieba 后,把所有出现「英文单词」的地方换成你的中文文本,跑通「分词 → 过滤标点 → Counter → 柱状图」四步,并检查停用词表是否需要为中文重新设计。