绝对词频 vs 加权词频:用Python defaultdict 做业务导向的文本价值分析

📅 2026/7/6 10:48:07
绝对词频 vs 加权词频:用Python defaultdict 做业务导向的文本价值分析
1. 项目概述为什么“绝对词频”和“加权词频”不是一回事在文本分析的日常工作中我几乎每天都要处理客户给过来的成千上万条标题、评论或广告文案。最常被问到的问题是“我们最常提什么词”——这听起来简单但答案往往藏着陷阱。如果你只看绝对词频Absolute Frequency也就是某个词在所有文档里出现了多少次那结果可能完全误导决策。比如一个电商团队发现“免费”在商品标题里出现频率排前三就立刻要求所有新品都加上这个词结果转化率不升反降。后来我们一查加权词频才发现带“免费”的商品平均客单价只有行业均值的62%而“旗舰”“Pro”“大师版”这些词虽然出现次数少却贡献了全站41%的GMV。这就是典型的“数得对但看得错”。这个项目讲的就是如何用两套数字讲清一个真相词出现的次数绝对频次 vs. 这个词背后承载的真实价值加权频次。它不依赖任何黑箱模型核心就靠 Python 原生的collections.defaultdict和pandas但逻辑非常扎实。你不需要懂机器学习只要会写循环、会算除法就能复现整套分析。关键词里没写“电影”但原文用 Box Office Mojo 的15,500部电影数据做演示恰恰因为它够真实、够嘈杂、够有代表性——电影标题短、歧义多、套路重比如满屏的“Part 2”“Vol. 2”比新闻标题或论文摘要更能暴露纯词频统计的缺陷。适合谁来读第一类是刚入门的数据分析师或运营同学想摆脱“Excel 求和排序”的初级分析第二类是内容策略师或 SEO 从业者需要量化验证“到底该主推哪个关键词”第三类是技术产品经理要设计内容推荐或标签系统必须理解“高频词≠高价值词”。我带过的实习生里有人用这套方法帮一家母婴社区把“辅食”相关标题的点击率提升了27%关键不是改了词而是把原来混在“宝宝”“婴儿”里的“辅食”单独拎出来按加权频次重新排序优先展示那些既高频又高转化的组合比如“高铁肉泥辅食”比“苹果泥辅食”权重高3.8倍。所以这不是一个“炫技”的教程而是一把能立刻插进业务流程里的小刀——锋利、直接、不花哨。2. 核心思路拆解为什么非得用 defaultdict 而不是 Counter 或字典推导式很多人看到词频统计第一反应是from collections import Counter或者更简单的{word: texts.count(word) for word in set(texts)}。我在实际项目中试过所有主流方案最终锁定defaultdict原因很实在它天然适配“加权”场景且内存效率碾压其他方案。下面我用真实对比数据说话。先说Counter。它的设计初衷是统计离散事件比如“用户点击了几次按钮A”。但当你需要同时记录“词出现次数”和“对应文档的数值总和”时Counter就得绕弯子。常见写法是from collections import Counter abs_counter Counter() wtd_sum {} # 单独建个字典存加权和 for text, value in zip(texts, nums): for word in text.split(): abs_counter[word] 1 wtd_sum[word] wtd_sum.get(word, 0) value问题在哪第一你要维护两个独立结构代码冗余第二wtd_sum.get(word, 0)每次都要哈希查找当词表超10万时性能明显下降第三无法保证abs_counter和wtd_sum的键完全同步debug 时容易漏掉 key 不一致的 bug。再看字典推导式。有人会写# 错误示范别这么干 word_stats {word: [0, 0] for word in set(all_words)} # 预分配所有词 for text, value in zip(texts, nums): for word in text.split(): word_stats[word][0] 1 word_stats[word][1] value表面看没问题但致命伤是你得先遍历所有文本拿到all_words内存直接翻倍。处理100万条微博时光是set(all_words)就可能吃掉8GB内存而defaultdict是“用到才建”实测内存占用低47%。defaultdict的精妙在于它的“懒加载”机制。看原文代码from collections import defaultdict word_freq defaultdict(lambda: [0, 0]) # 默认值是[0,0] for text, num in zip(text_list, num_list): for word in text.split(): word_freq[word][0] 1 # 绝对频次 word_freq[word][1] num # 加权总和这里lambda: [0, 0]是关键。当word_freq[star]第一次被访问时defaultdict自动调用 lambda生成[0, 0]并存入后续再访问直接取值。没有预分配没有重复哈希没有状态不同步风险。我拿15,500部电影数据实测用defaultdict耗时1.2秒Counterdict组合耗时2.8秒字典推导式在内存溢出前就卡死了。还有人问“为啥不用 pandas 的value_counts()” 答案是value_counts()只能算单列频次无法关联另一列数值做加权聚合。你得先explode分词再groupby最后agg代码量翻三倍且explode对长文本极其不友好一个标题分出20个词数据量瞬间膨胀。defaultdict一行初始化三行核心逻辑干净利落。最后强调一个易错点defaultdict(int)只适用于绝对频次加权必须用defaultdict(lambda: [0, 0])或类似结构。因为int()返回0你没法同时存两个数。见过太多人在这里栽跟头写成defaultdict(int)后拼命调试word_freq[word] num报错其实根本不是语法问题而是数据结构选错了。3. 实操细节解析从原始数据到可交付报告的完整链路真正落地时90%的坑不在算法而在数据清洗和边界处理。原文给的电影数据看似规整但实际爬下来全是“惊喜”$936,662,225里的逗号、2009^里的脱字符、N/A年份、甚至标题里带括号的(2017)。我带团队做过37个类似项目总结出一套必须执行的“五步清洗法”缺一不可。3.1 步骤一数值字段的鲁棒清洗比正则更稳的方案原文用re.sub(r[\$,^], , x[3])处理票房这在小数据集上没问题但遇到$1,234,567,890 (est.)就会失效。我的方案是先用str.replace()做安全替换再用pd.to_numeric()强制转换并处理错误。import pandas as pd # 安全替换逐个字符处理避免正则失控 def clean_currency(text): if pd.isna(text): return 0 # 先去空格再去符号 cleaned str(text).strip().replace($, ).replace(,, ).replace(^, ) # 移除所有非数字字符但保留负号虽然票房不会是负的 cleaned .join(c for c in cleaned if c.isdigit() or c -) return int(cleaned) if cleaned else 0 # 应用到DataFrame boxoffice_df[lifetime_gross] boxoffice_df[3].apply(clean_currency) # 强制转数值错误值设为0 boxoffice_df[lifetime_gross] pd.to_numeric( boxoffice_df[lifetime_gross], errorscoerce ).fillna(0).astype(int)为什么比正则强因为str.replace()是确定性操作不会因正则引擎回溯导致性能暴跌pd.to_numeric(errorscoerce)能自动把abc、这类异常转为NaN比手动try/except更简洁。3.2 步骤二年份字段的智能修复不是所有N/A都该填原文发现4个n/a年份手动查网页填了[1998, 1999, 1960, 1973]。这在小样本可行但如果是10万条新闻数据你不可能人工查。我的经验是用上下文信息做概率填充。比如电影数据中n/a年份往往集中在老片1960s-1980s或新片2020s后可按工作室和票房区间估算# 按工作室分组计算各工作室年份的众数 studio_mode_year boxoffice_df.groupby(studio)[year].agg(pd.Series.mode).to_dict() # 对n/a行优先用工作室众数年份填充 boxoffice_df.loc[boxoffice_df[year].isna(), year] \ boxoffice_df.loc[boxoffice_df[year].isna(), studio].map(studio_mode_year) # 剩余未填的用全局中位数兜底 global_median int(boxoffice_df[year].median()) boxoffice_df[year].fillna(global_median, inplaceTrue)这样既保留了数据分布特征又避免了人工干预偏差。3.3 步骤三标题分词的语义保真别让“Star Wars”变成“Star”和“Wars”原文直接用text.split()这对英文标题是灾难。Star Wars: The Force Awakens会被切成[Star, Wars:, The, Force, Awakens]Wars:带冒号后续统计会漏掉。更糟的是Iron Man 3变成[Iron, Man, 3]但3和ii、Part 2是同一类概念应该归一化。我的方案是先做基础清洗再用正则精准切分。import re def clean_and_split_title(title): if not isinstance(title, str): return [] # 1. 移除括号及内容如 (2017), (Re-issue) title re.sub(r\([^)]*\), , title) # 2. 替换标点为空格: ; - _ 等 title re.sub(r[^\w\s], , title) # 3. 合并多余空格 title re.sub(r\s, , title).strip() # 4. 拆分但保留数字和字母组合如 Vol.2 - Vol 2 words re.findall(r\b\w\b, title.lower()) # 5. 归一化常见序数词 norm_map {ii: 2, iii: 3, iv: 4, v: 5} words [norm_map.get(w, w) for w in words] return words # 测试 print(clean_and_split_title(Star Wars: The Force Awakens (2015))) # 输出: [star, wars, the, force, awakens] print(clean_and_split_title(Guardians of the Galaxy Vol. 2)) # 输出: [guardians, of, the, galaxy, vol, 2]这样Vol. 2和Part 2都能映射到2加权统计时自然聚类。3.4 步骤四停用词的动态管理别一刀切删掉“love”原文把the,and等列为停用词这没错。但电影标题里love出现211次绝对频次第一如果按常规停用词表删掉就彻底错过“爱情片是票房基本盘”这个洞察。我的做法是分层停用词表 业务白名单。# 基础停用词必删 base_stopwords {the, a, an, and, or, but, in, on, at, to, for, of, with, by, is, are, was, were} # 业务白名单即使在停用词里也保留 business_whitelist {love, man, star, night, day, life} # 电影核心主题词 # 动态判断 def should_keep_word(word): word_lower word.lower() return word_lower not in base_stopwords or word_lower in business_whitelist # 在分词循环中使用 for text, num in zip(text_list, num_list): words clean_and_split_title(text) for word in words: if not should_keep_word(word): continue # ... 统计逻辑这样既清掉了无意义虚词又保留了业务关键实体词。我们曾用此法帮一家影视公司发现queen在历史片标题中绝对频次不高但加权频次排第7因《The Queen》《Bohemian Rhapsody》等片票房极高于是他们针对性开发了“女王题材”内容矩阵半年内相关栏目播放量涨了3.2倍。3.5 步骤五结果交付的实用增强让老板一眼看懂统计完只是开始交付才是关键。原文用style.bar()画图但业务方更关心“这个词值不值得投资源”。我的标准交付包包含三张表Top 20 绝对频次表按abs_freq降序突出显示rel_value加权/绝对比值比值150说明“每次出现都带来高价值”Top 20 加权频次表按wtd_freq降序增加wtd_freq_perc_cum累计占比快速定位“前5个词是否贡献了70%以上营收”异常词诊断表筛选rel_value 200且abs_freq 50的词高价值低曝光这类词是优化重点。# 生成诊断表 diagnosis_df abs_wtd_df[ (abs_wtd_df[rel_value] 200) (abs_wtd_df[abs_freq] 50) (abs_wtd_df[word].str.len() 2) # 排除单字母 ].sort_values(rel_value, ascendingFalse) # 输出示例真实项目数据 print(diagnosis_df[[word, abs_freq, wtd_freq, rel_value]].head(5)) # word abs_freq wtd_freq rel_value # 0 queen 12 1824567890 152 # 1 legend 18 2156789012 119 # 2 empire 22 2456789012 111 # 3 galaxy 25 2756789012 110 # 4 marvel 33 3056789012 92这张表直接告诉产品“queen这个词只在12个标题里出现但带来了18亿票房建议立即策划女王主题专题”。4. 核心环节实现手把手复现电影数据全流程含避坑指南现在我们把前面所有细节串起来用15,500部电影数据走一遍完整流程。我会标注每一行代码的真实意图和踩过的坑而不是照抄原文。4.1 数据获取与结构化别信爬虫返回的HTML结构原文用soup.select(tr td)[11:511]硬切这极不稳定。Box Office Mojo 页面结构稍一改整个脚本就废。我的方案是用 CSS 选择器精准定位表格主体再用 pandas 自动解析。import requests from bs4 import BeautifulSoup import pandas as pd def scrape_boxoffice_page(page_num): url fhttp://www.boxofficemojo.com/alltime/domestic.htm?page{page_num}p.htm headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } try: resp requests.get(url, headersheaders, timeout10) resp.raise_for_status() soup BeautifulSoup(resp.text, lxml) # 关键不硬切索引找table标签 table soup.find(table, {class: mojo-body-table}) # 真实class名 if not table: # 备用方案找所有table取最大的那个 tables soup.find_all(table) table max(tables, keylambda t: len(t.find_all(tr))) # 用pandas直接读取HTML表格比手动解析稳定10倍 dfs pd.read_html(str(table), header0) return dfs[0] if dfs else pd.DataFrame() except Exception as e: print(fPage {page_num} failed: {e}) return pd.DataFrame() # 实际采集示例取前3页避免请求过频 all_dfs [] for i in range(1, 4): # 生产环境用range(1,156) print(fScraping page {i}...) df scrape_boxoffice_page(i) if not df.empty: all_dfs.append(df) boxoffice_df pd.concat(all_dfs, ignore_indexTrue)提示原文没提headers但很多网站会封无UA的请求。timeout10防止卡死try/except确保单页失败不影响整体。4.2 字段清洗与类型转换用pd.to_numeric的正确姿势原文用列表推导式转换数值这在数据量大时会慢且易错。我的方案是用 pandas 内置方法批量处理一步到位。# 假设原始df列名为Rank, Title, Studio, Lifetime Gross, Year # 1. 清洗Lifetime Gross列 boxoffice_df[Lifetime Gross] ( boxoffice_df[Lifetime Gross] .astype(str) # 统一转str避免float转str出现nan .str.replace(r[^\d.], , regexTrue) # 只留数字和点 .str.replace(r^\.|\.$, , regexTrue) # 去首尾点 .replace(, 0) # 空字符串变0 .astype(float) # 转float .astype(int) # 转int ) # 2. 清洗Year列处理n/a和异常值 boxoffice_df[Year] pd.to_numeric( boxoffice_df[Year].astype(str).str.replace(r[^0-9], , regexTrue), errorscoerce ).fillna(boxoffice_df[Year].median()).astype(int) # 3. 重命名列统一小写 boxoffice_df.columns [rank, title, studio, lifetime_gross, year]注意pd.to_numeric(errorscoerce)会把所有异常转为NaN比try/except快5倍且不会中断流程。4.3 分词与加权统计整合所有优化点这是核心函数我把前面所有经验都塞进来了from collections import defaultdict import re def word_frequency_enhanced( text_series, num_series, sepNone, min_word_len2, # 过滤太短的词 base_stopwordsNone, business_whitelistNone ): 增强版词频统计支持停用词、白名单、长度过滤、大小写归一 if base_stopwords is None: base_stopwords {the, a, an, and, or, but, in, on, at, to, for, of, with, by, is, are, was, were} if business_whitelist is None: business_whitelist {love, man, star, night, day, life, god, king, queen, war, story} word_freq defaultdict(lambda: [0, 0]) # 预编译正则提升性能 bracket_pattern re.compile(r\([^)]*\)) punct_pattern re.compile(r[^\w\s]) for text, num in zip(text_series, num_series): if not isinstance(text, str) or not text.strip(): continue # 清洗标题 clean_text bracket_pattern.sub(, text) clean_text punct_pattern.sub( , clean_text) clean_text re.sub(r\s, , clean_text).strip() if not clean_text: continue # 分词 words re.findall(r\b\w\b, clean_text.lower()) # 过滤和归一化 for word in words: # 长度过滤 if len(word) min_word_len: continue # 停用词检查白名单优先 if word in business_whitelist: pass elif word in base_stopwords: continue # 数字归一化 if word.isdigit(): word word elif word in [ii, iii, iv, v]: word {ii:2, iii:3, iv:4, v:5}[word] word_freq[word][0] 1 word_freq[word][1] int(num) if num else 0 # 转DataFrame df pd.DataFrame.from_dict(word_freq, orientindex, columns[abs_freq, wtd_freq]) df.index.name word # 计算衍生指标 total_abs df[abs_freq].sum() total_wtd df[wtd_freq].sum() df[abs_perc] df[abs_freq] / total_abs df[wtd_freq_perc] df[wtd_freq] / total_wtd df[rel_value] (df[wtd_freq] / df[abs_freq]).round(0) df[abs_perc_cum] df[abs_perc].cumsum() df[wtd_freq_perc_cum] df[wtd_freq_perc].cumsum() return df.sort_values(wtd_freq, ascendingFalse) # 执行统计 result_df word_frequency_enhanced( boxoffice_df[title], boxoffice_df[lifetime_gross], min_word_len2, business_whitelist{love, man, star, 2, 3, ii, part} )实测处理15,500条标题此函数耗时1.8秒含清洗比原文方案快1.2秒且结果更准。4.4 结果可视化与业务解读不只是画图原文用matplotlib画了两张图但业务方需要的是“下一步动作”。我的标准输出是三张图一张诊断表import matplotlib.pyplot as plt import seaborn as sns # 设置中文字体如需中文 plt.rcParams[font.sans-serif] [SimHei, Arial Unicode MS] fig, axes plt.subplots(1, 3, figsize(24, 8)) # 图1Top 20 绝对频次突出rel_value top_abs result_df.sort_values(abs_freq, ascendingFalse).head(20) axes[0].barh(range(len(top_abs)), top_abs[abs_freq], color#288FB7) for i, (word, freq) in enumerate(zip(top_abs.index, top_abs[abs_freq])): axes[0].text(freq 5, i, f{word}: {freq}, vacenter, fontsize10) axes[0].set_title(Top 20 Words by Absolute Frequency, fontsize14, fontweightbold) axes[0].set_xlabel(Count) # 图2Top 20 加权频次用美元单位 top_wtd result_df.head(20) axes[1].barh(range(len(top_wtd)), top_wtd[wtd_freq]/1e9, color#FF6B6B) for i, (word, wtd) in enumerate(zip(top_wtd.index, top_wtd[wtd_freq])): axes[1].text(wtd/1e9 0.1, i, f{word}: ${wtd/1e9:.1f}B, vacenter, fontsize10) axes[1].set_title(Top 20 Words by Weighted Frequency ($B), fontsize14, fontweightbold) axes[1].set_xlabel(Revenue (Billions $)) # 图3rel_value 散点图识别高价值低曝光词 high_value_mask (result_df[rel_value] 150) (result_df[abs_freq] 100) axes[2].scatter(result_df[abs_freq], result_df[rel_value], alpha0.6, s20, color#4ECDC4) axes[2].scatter( result_df[high_value_mask][abs_freq], result_df[high_value_mask][rel_value], s60, colorred, labelHigh Value Targets ) axes[2].set_xlabel(Absolute Frequency) axes[2].set_ylabel(Relative Value (Wtd/Abs)) axes[2].set_title(Value vs. Exposure: Find Your Hidden Gems, fontsize14, fontweightbold) axes[2].legend() plt.tight_layout() plt.show() # 输出诊断表高价值低曝光词 high_value_targets result_df[ (result_df[rel_value] 150) (result_df[abs_freq] 100) (result_df[word].str.len() 2) ].sort_values(rel_value, ascendingFalse).head(10) print(\n HIGH-VALUE TARGET WORDS (Low Exposure, High ROI) ) print(high_value_targets[[abs_freq, wtd_freq, rel_value]].to_string())这张散点图直接标出“红点”——那些rel_value 150且abs_freq 100的词就是你应该立刻优化的标题关键词。比如queen12次出现18亿票房、legend18次21亿它们不是高频词却是印钞机。5. 常见问题与排查技巧实录我在37个项目里踩过的坑这套方法看着简单但实际落地时90%的问题都出在数据细节上。以下是我在真实项目中整理的“避坑清单”每一条都来自血泪教训。5.1 问题一分词后词频暴涨内存爆掉“The”和“the”算两个词现象统计完发现the有3055次The有1310次明明是同一个词却占了近一半结果。根因没做大小写归一。英文标题首字母大写是惯例The Dark Knight和the dark knight是同一部电影但分词后The和the被当不同键。解决方案在分词后强制.lower()且在defaultdict存储时就归一。# 错误先存再转 word_freq[word][0] 1 # word可能是The # 正确存之前就转 clean_word word.lower() word_freq[clean_word][0] 1实测15,500部电影数据大小写归一后词表从28,456个词降到14,201个内存占用直降52%。5.2 问题二rel_value出现inf或nan除零错误现象rel_value列里有inf无穷大或nan非数字排序时全乱。根因某个词的abs_freq是0但wtd_freq不是0比如数据清洗时把标题清空了但票房数值还在。解决方案计算rel_value时加安全除法。# 原始危险写法 df[rel_value] df[wtd_freq] / df[abs_freq] # 安全写法 df[rel_value] df.apply( lambda row: row[wtd_freq] / row[abs_freq] if row[abs_freq] 0 else 0, axis1 ).round(0)提示pandas的div()方法也支持fill_value参数但apply更直观可控。5.3 问题三2成为Top 1但业务方说“这不合理”现象加权频次第一是290亿但老板说“我们不可能主推数字2啊”。根因没做业务语义归一。2代表续集但ii、Part 2、Vol. 2没合并导致2单独称王。解决方案在分词清洗阶段把所有序数表达归一为数字。# 在clean_and_split_title函数中加入 norm_map { ii: 2, iii: 3, iv: 4, v: 5, part2: 2, part3: 3, vol2: 2, vol3: 3, second: 2, third: 3 } words [norm_map.get(w, w) for w in words]效果归一后2的wtd_freq从90亿升到127亿合并了ii、Part 2等结论更坚实。5.4 问题四爬虫被封requests.get()一直超时现象跑着跑着程序卡住日志显示TimeoutError。根因Box Office Mojo 有反爬机制连续请求会触发验证码或IP封禁。解决方案三重防护——随机延迟 请求头轮换 失败重试。import time import random from urllib.parse import urlparse def robust_request(url, max_retries3): user_agents [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36, Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ] for attempt in range(max_retries): try: headers {User-Agent: random.choice(user_agents)} # 随机延迟1-3秒 time.sleep(random.uniform(1, 3)) resp requests.get(url, headersheaders, timeout15) resp.raise_for_status() return resp except Exception as e: print(fAttempt {attempt1} failed: {e}) if attempt max_retries - 1: raise return None经验timeout15比10更稳妥网络抖动时不至于误判失败。5.5 问题五结果表里出现、-等符号词现象word列里有、-、:显然不是有效词。根因分词时没过滤标点。Star Wars: The Force Awakens的:被当成了独立词。解决方案分词后过滤非字母数字词。# 在分词循环中 words re.findall(r\b\w\b, clean_text.lower()) # \b\w\b 只匹配单词字符 # 或更严格只留长度2以上的纯字母词 words [w for w in words if w.isalpha() and len(w) 2]注意isalpha()会过滤掉2所以数字要单独处理如前面的norm_map。5.6 问题六pandas内存爆炸read_html卡死现象pd.read_html()读大表格时内存飙升到20GB。根因read_html()默认解析所有table而网页里可能有隐藏广告表格。解决方案指定attrs参数精准定位。# 不要这样 dfs pd.read_html(str(soup)) # 要这样用CSS class精准定位 dfs pd.read_html( str(soup