简介:smog-formula 是一个面向 JavaScript 开发者的可读性检测工具,基于 1969 年的 SMOG(Gobbledygook 的简单度量)公式,通过音节数、多音节词数与句子数计算文本阅读难度等级,适合内容平台、教育测评或文本分析场景使用。该 npm 包仅支持 ESM 导入,需在 Node 12+ 环境中运行,核心 API 为 smogFormula 函数,例如传入 sentence:1、polysillabicWord:4 即可得到约 14.55 的等级值。压缩包共 12 个文件,以 js 源码、json 配置、yml 工作流、md 文档及许可证文件为主,整体仅 6KB,结构清晰。已有 368 人浏览学习。通过阅读源码与工程配置,可以掌握 SMOG 公式的具体实现、可读性算法的参数定义,以及 npm 包的标准发布与测试流程,便于二次开发或直接集成到自有文本分析流程中。
1. 为什么一个1969年的可读性公式,现在还被内容团队当基线
1969年出现的SMOG公式,算年龄比不少做内容质检的从业者还大,但到今天,医疗健康传播、技术文档评审、教育软件里但凡要给英文文本标一个可读性分数,SMOG依然是默认基线之一。它的全称是 Simple Measure of Gobbledygook——Gobbledygook 指的就是那些堆满术语、绕来绕去的废话行话。它只数两样东西:句子个数,以及句子里三个音节以上的长词个数,然后套一个 3 + √P 的公式,映射出读者需要读到哪个年级才能顺畅理解。反直觉的点在于:这个 1969 年的老式子,不做句法分析、不做词向量,却比很多机器学习可读性模型更容易解释、更稳定地反映「长词密度」这一核心难度。
它解决的是文本难易度量化问题:这篇文章到底是给谁读的、要不要把术语换掉、评审时用什么口径跟作者沟通。适合三类人来做:内容平台做质检的开发者、给普通读者写材料的作者、以及搭 NLP 工具链但缺一个可解释基线的人。接下来的写法偏实操:先把手算流程走通,再给出一个能直接复用的 Python 实现,最后把最常见的那几个坑逐个拆掉。
2. 先把SMOG手算跑通:30句抽样、多音节词计数与 3+√P 的由来
2.1 Gobbledygook与阅读难易度:长词率为什么和年级正相关
可读性研究里有一个朴素的共识:读者理解文本的负担,主要来自「认词」和「理句」两步。SMOG 选择盯住认词这一步,并且只盯最难的那一类词——三个音节以上的长词。一个词要三个音节,意味着它大概率带前缀后缀、大概率是拉丁或希腊词根、大概率在口语里不常见,比如 utilization 而不是 use,implementation 而不是 doing。这类词在心理词典里激活慢,拼读负担高,读者扫一眼就得停下来。文本里这种词越多,读得越慢,理解越容易断。
1969年提出这个公式的研究者,当时用一批教育材料做回归,发现「30句样本里多音节词数量的平方根」和「读者所需教育年级」之间近似线性。为什么取平方根而不是直接看比例?因为多音节词数量从10涨到100时,阅读难度不是线性翻十倍,而是从大约6年级涨到13年级,涨幅被明显压缩。平方根这个非线性变换正好把长尾压平,让分数在常见文本区间里和年级大致对齐。这个操作在当年没有机器学习,就是一个经验拟合,但它拟合的是「难度感知」而不是「文本长度」,所以到现在依然能打。
这里要特别说清 SMOG 的性格:它对词汇难度极度敏感,对句法复杂度基本不敏感。两句话各20个词,一句全是短词,一句塞满三音节术语,SMOG 分数会差出好几个年级;但一篇全是简单词却绕了五层从句的文本,SMOG 反而会给出偏低的分数。选它做基线,就等于明确告诉团队:我们先把词汇门槛管住,句法问题交给别的手段。
2.2 手算SMOG的七个步骤:从取段到开根号
完整的手算流程不复杂,但每一步都有细节,先按原版规则走一遍:
- 从文本开头取前10句,中间取10句,结尾取10句,凑成30句样本。文本总句子数不足30句时,全部纳入。
- 逐句识别包含3个及以上音节的单词,缩写、数字、专有名词都算数。
- 统计这30句里多音节词的总数,记为 P。
- 计算 SMOG = 3 + √P。
- 结果直接对应年级:6代表读者大约要读到六年级,12代表十二年级,数字越高越难。
- 如果分数超过12,说明文本基本只适合高等教育以上读者,普通大众材料通常要求压到8以下。
- 记录时注明采用了哪个口径,比如连字符是否拆分、未知词音节怎么判,否则分数不可比。
举个例子,假设某一段文本里有这样一句:
Nevertheless, the implementation of additional automation within our organization will substantially facilitate comprehensive documentation procedures.
逐词数音节:nevertheless 4音节,implementation 5,additional 4,automation 4,organization 5,substantially 4,facilitate 3,comprehensive 4,documentation 5,procedures 3。这10个都是多音节词。30句抽下来如果总数是49个,SMOG 就是 3 + √49 = 10,对应十年级。手算时最容易吵起来的两个点:连字符词算一个词还是拆开算,以及 e.g.、Dr. 这类缩写会不会干扰数句。这两个问题后面第4章会专门给结论,手算阶段先记录自己的处理方式就行。
注意:SMOG 原版是为英语母语读者设计的,分数含义依赖英语的拼读规则和年级体系,中文文本不能直接用,这点在第4章展开。
2.3 SMOG、Flesch-Kincaid、Gunning Fog:三个公式怎么选
做文本可读性的人一定绕不开另外两个老牌公式,Flesch-Kincaid 和 Gunning Fog。三者的核心差异在「拿什么衡量难」:SMOG 只看多音节词密度,Flesch-Kincaid 把平均句长和平均音节数揉在一起,Gunning Fog 看句子长度加复杂词占比。选型不是越新越好,而是看你要拦什么。
| 公式 | 核心输入 | 输出 | 强项 | 弱项 |
|---|---|---|---|---|
| SMOG | 30句样本中的多音节词数 | 年级(约3–13+) | 对术语密度敏感,适合健康传播、公共文档 | 短文本不稳定,句法复杂度盲区 |
| Flesch-Kincaid Grade | 平均句长 + 平均音节数 | 年级 | 数值平滑,适合教材分级 | 长词占比被平均句长稀释 |
| Gunning Fog | 平均句长 + 复杂词占比 | 年级 | 计算简单,结果直观 | 复杂词定义粗糙,波动大 |
| Flesch Reading Ease | 平均句长 + 平均音节数 | 0–100分 | 大众传播常用 | 分数与年级非线性,不直观 |
我一般这样选:给大众读者做健康科普、说明书、政策解读,用 SMOG,因为它直接暴露术语密度;给教材或分级阅读做连续标定,用 Flesch-Kincaid,因为它对难度的变化更平滑;做内部快速筛查时用 Gunning Fog,手算快,但别拿它做对外结论。还需要记住一个经验:同一篇文本,SMOG 给出的年级通常比 Flesch-Kincaid 高 1 到 2 个年级,这是两个公式的基准不同,不是谁算错了,做横向对比时千万不能混用两套公式的数字。
3. 用Python把smog-formula落地成函数:分词、句式切分与音节判定
3.1 第一版:用元音群近似音节,先跑通主流程
先给一个不依赖外部词典的版本,逻辑最简单,也最容易调试。核心思路是把连续的元音字母当成一个音节组,数元音组的数量来近似音节数。
import math import re def count_syllables_vowel_group(word: str) -> int: # 用连续元音字母组的数量近似音节数,silent e 场景会偏高 word = word.lower() groups = re.findall(r"[aeiouy]+", word) return max(1, len(groups)) def smog_v1(text: str) -> float: # 第一版只数全文词,不做30句抽样,先把计算主流程跑通 words = re.findall(r"[a-zA-Z]+(?:['-][a-zA-Z]+)*", text) polys = sum(1 for w in words if count_syllables_vowel_group(w) >= 3) return 3 + math.sqrt(max(0, polys))这段代码的逻辑分三层:正则把所有字母串提取成词,数字和符号被直接忽略;每个词通过元音组数量判定是否达到 3 音节;最后套原版公式。max(1, ...)是为了防止 magic 这种词被算成 0 音节。连字符词如 well-known 被正则里的(?:['-][a-zA-Z]+)*保留为整体,不拆开。
这个版本的优点是零依赖,缺点是 silent e 会让音节数普遍虚高,比如 made 会被算成 2 音节。作为主流程验证够用,但如果直接拿去对外输出分数,第4章会告诉你为什么容易翻车。
3.2 第二版:接入cmudict发音词典,让多音节判定更稳
要提升多音节判定的准确度,常见做法是接入发音词典。NLTK 自带的 cmudict 数据把每个词拆成音素并按重音标了数字,数字的个数就是音节数。
from nltk.corpus import cmudict _d = cmudict.dict() def count_syllables_cmu(word: str) -> int: # cmudict 每个发音里的数字个数即为音节数 word = word.lower() try: prons = _d[word] counts = [len([p for p in pron if p[-1].isdigit()]) for pron in prons] # 同一个词有多种读音时取最大音节数,宁可高估难度 return max(counts) except KeyError: # 词典未收录的词,回退到元音群近似 return count_syllables_vowel_group(word)逻辑说明:p[-1].isdigit()是在每个音素串的末尾找重音标记,CMU 数据里重音标记就是数字字符。max(counts)用的是保守策略:像 either 这种存在两种读音的词,按音节多的那个计入。SMOG 的定位本来就是「宁可高估难度」,用 max 符合这个取向。回退分支很重要,专有名词、新造词、拼写变体在词典里查不到,不兜底的话整个函数会直接抛异常。
注意第一次跑之前需要下载数据,在环境里执行nltk.download('cmudict')。这一步卡住的话,多半是网络没到位,也可以手动把数据文件拷到 NLTK 数据目录下。
3.3 按规则抽样:头10句、中10句、尾10句各取10句
原版 SMOG 要求从开头、中间、结尾各取 10 句,目的是覆盖文本不同位置的难度分布。很多现成实现直接随机抽 30 句,结构强的文本里会漏掉结论段落。
def split_sentences(text: str) -> list[str]: # 先保护常见缩写尾点,避免把一句话切成两半 text = re.sub(r"\b(Dr|Mr|Mrs|Ms|Inc|e\.g|i\.e)\.", r"\1<DOT>", text) raw = re.split(r"[.!?]+", text) sentences = [s.strip().replace("<DOT>", ".") for s in raw if len(s.strip()) > 0] return sentences def sample_30(sentences: list[str]) -> list[str]: # 头10、中10、尾10;不足30句时全量返回 if len(sentences) <= 30: return sentences head, tail = sentences[:10], sentences[-10:] mid_start = len(sentences) // 2 - 5 mid = sentences[mid_start:mid_start + 10] return head + mid + tail参数说明:mid_start取len(sentences) // 2 - 5,当总句数为奇数时中间段会稍微偏前,这是刻意选的,宁可偏前也不要越界。split_sentences里的缩写保护正则覆盖了最常见的几个词,但 U.S.A.、Ph.D. 这类更复杂的缩写仍会漏,后面第4章会讲怎么补强。抽样数量固定 30 是原公式的经验参数,不建议放大,放大后年级映射就不准了。
3.4 组装成命令行工具:输入文本路径,输出SMOG分数
把前面几节拼成一个完整的命令行工具,输入文件路径,输出 SMOG 年级分数,顺便支持抽样量和置信度标记。
import argparse from pathlib import Path def smog_formula_score(text: str) -> float: # 完整流程:分句 -> 抽样 -> 统计多音节词 -> 原版公式 sents = sample_30(split_sentences(text)) polys = 0 for sent in sents: words = re.findall(r"[a-zA-Z]+(?:['-][a-zA-Z]+)*", sent) for w in words: if count_syllables_cmu(w) >= 3: polys += 1 return 3 + math.sqrt(polys) def main(): parser = argparse.ArgumentParser(description="smog-formula 可读性计算") parser.add_argument("file", type=Path, help="输入文本文件路径") parser.add_argument("--sample", type=int, default=30, help="抽样句子数,默认30,通常不建议修改") args = parser.parse_args() text = args.file.read_text(encoding="utf-8", errors="ignore") score = smog_formula_score(text) sents = sample_30(split_sentences(text)) print(f"SMOG Grade: {score:.2f}") print(f"样本句子数: {len(sents)}") if __name__ == "__main__": main()逻辑说明:--sample参数保留出来不是为了让你日常改它,而是方便做灵敏度测试——把抽样量从 30 改成 20、15,观察分数波动,就能判断一个文本的分数是否稳定。errors="ignore"处理非 UTF-8 编码文件时避免整个程序崩掉,代价是丢弃少量乱码字符,对统计多音节词影响很小。跑通这个工具后,前面手算的那些规则就有了一个可重复执行的落点。
4. smog-formula落地避坑:预处理、短文本偏差与多语言误用
4.1 缩写词尾点把文本切成碎片:先保护再分句
现象:一段本来 40 句的文本,跑完分句逻辑变成 90 句,抽样位置全乱,SMOG 分数明显偏低。
原因:句切分正则[.!?]+把 Dr.、e.g.、Inc. 这类缩写词后面的英文句点当成了句子结束符。缩写被拦腰截断后,多音节词计数被稀释到更多「假句子」里,30句抽样落在毫无意义的位置。
解决:分句前先做缩写保护。第3章代码里用的<DOT>替换方案是常见做法,但覆盖面有限。我一般会额外维护一份带点缩写的列表,把 Ph.D.、U.S.A.、etc. 这些也加进去,替换逻辑统一:先用正则把所有[A-Za-z]\.后面紧跟小写字母或空格缩写词的情况保护起来,再切分。切完再还原句点。这一步做完,句数量基本能回到和人眼判断一致的水平。
4.2 多音节计数和在线工具对不上:口径不一致
现象:同一份文本,用自己实现的工具算出来是 7.5 年级,丢进某个在线可读性检测网站却是 6.1,两边差出一个半年级。
原因:不同工具对「多音节词」的判定口径差异很大。有的用cmudict发音词典,有的用元音群近似,有的把连字符词拆开算两个词,有的把数字串也当成单词。这些差异在长词密度高的文本里会被放大,最终体现在根号后的年级数上。
解决:选定一套口径并在报告里写明:连字符词按整体计,未知词按元音群回退,多音读法按最大音节数计。对外对比时,只用同一套算法跑所有文本,不要拿 A 工具的 SMOG 和 B 工具的 SMOG 直接对比。做基准测试时,建议用一小段公开的谚语或百科文本作为标准样例,把自己的输出和已知参考值对齐一次。
4.3 短文本直接翻车:小样本下平方根放大误差
现象:一段只有 8 句话的说明文字,数出 6 个多音节词,SMOG 分数 5.45;删掉其中两个长词后,分数直接掉到 4.83。内容只改了一行,分数却跳了半个年级。
原因:公式里的平方根对小样本极度敏感。多音节词总数 P 从 4 变到 8,分数从 5 涨到 5.83,而样本句子数不足时这个波动没有任何统计缓冲。SMOG 本来就是按 30 句样本设计的,短文本完全不在它的适用区间内。
解决:总句子数低于 30 句时,输出分数旁要带置信度标记,比如加一行「警告:样本不足30句,分数仅供参考」。如果这个文本会进入自动决策流程,比如内容平台拿它决定要不要人工复审,那就直接用全量文本计算并调低决策阈值,别套用 30 句样本的年级阈值。
4.4 中文、混合语料直接套英文公式:结果没有意义
现象:有人拿一封中文邮件或者中英混杂的聊天记录跑 SMOG,得到一个看似合理的年级数,比如 7.2,但中文读者看了完全无法对应这个难度。
原因:SMOG 的多音节判定依赖英语音节的拼读规则,中文没有音节这个概念,分词边界也不一样。混合语料里只要英文单词占比变化,分数就会剧烈波动,但这个波动反映的不是难度变化,而是语言比例变化。
解决:SMOG 只适用于英文文本,这是底线。中文文本的可读性需要另走一条路径:先分词,再统计词长分布、低频词占比、句子平均长度,必要时引入中文词汇频率表做加权。如果平台里必须处理多语言内容,就给每种语言单独配一个可读性模块,绝不共用一个 SMOG 实现。
4.5 长词结构不同,SMOG分数失真:术语密度和常见长词被等权处理
现象:一篇学术摘要和一篇新闻报道,SMOG 分数都是 9.5,但前者让普通读者读得满头问号,后者基本能顺下来。分数没变,实际难度感受完全不同。
原因:SMOG 把所有三音节以上单词一律等权计入,information 这种高频长词和 environmental sustainability 这种术语组合在公式里贡献相同。它不区分「长但常见」和「长且生僻」,而真正卡住读者的往往是后者。
解决:落地时不要只输出一个 SMOG 分数,加一个「生僻长词占比」的辅助指标。实现上可以接一个词汇频率表,对多音节词按频率分层,低频词单独加权。不做加权的话,至少按语料类型分别设阈值:故事类内容 8 年级可能没问题,法律条款类内容压到 6 年级以下才算达标。
5. 锁死实现:回归测试与SMOG分数的可信区间
5.1 维护一组黄金样例,跑通回归测试
公式实现最怕的不是写错,而是改完一处代码后分数悄悄漂移。解法是维护一组固定文本和对应期望值,每次改动后用这组样例跑一遍自动化回归。
def load_golden_samples(): # 每项是(文本, 期望分数),期望分数来自人工复核过的基准值 return [ ("A simple sentence has few long words.", 3.0), ("Nevertheless, the implementation of additional automation substantially facilitates comprehensive documentation procedures.", 5.57), ] def test_smog_regression(): for text, expected in load_golden_samples(): score = smog_formula_score(text) assert abs(score - expected) < 0.1, f"期望 {expected},实际 {score}"这段测试的意义在于把「我感觉这个公式没问题」变成「跑得过才叫没问题」。expected的值建议用你自己代码跑通后人工复核过的数字,不要从网上抄参考值,因为音节判定口径不同,期望值很难完全一致。只要口径不变,回归测试就能在后续重构分词正则、换音节表时第一时间暴露偏差。
5.2 给分数标一个可信区间,而不是一个单点数字
SMOG 的输出不适合拿来做精确定位。我的习惯是:对超过 100 句的长文档,用滑动窗口重采样多次,取分数的均值和波动范围。方法是把文本按句子切成列表,从不同起点各取 30 句算一遍分数,看最大最小差。常见结果在 ±0.5 年级以内,如果某个文本波动超过 1 个年级,说明它的难度分布不均匀,一个单点分数会误导决策。
汇报时写成「SMOG 8.2 ± 0.4」比写「SMOG 8.2」诚实得多。配合 Flesch-Kincaid 做交叉验证也很有用:一个句子长、词汇短的文本,SMOG 会给出低分而 Flesch-Kincaid 会偏高,两者差值本身就是文本结构偏词汇难度还是偏句法难度的信号。我看完两个分数再决定是建议作者换词还是拆句。
我现在的习惯是,接任何可读性需求先问三件事:给谁读、拿来做什么决策、能不能接受跑多个公式。口径没定就跑 SMOG,后面全得返工。这也算这几年踩坑踩出来的血泪经验,希望帮到你。
本文还有配套的精品资源,点击获取