ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python美食数据全链路实战:爬取清洗分析可视化闭环

Python美食数据全链路实战:爬取清洗分析可视化闭环

简介:本资源是一套完整的Python数据科学实战项目,面向数据分析初学者与爬虫爱好者,聚焦美食领域从数据获取到可视化呈现的全流程实践。项目涵盖网络爬虫(requests+BeautifulSoup)、数据清洗分析(pandas+NumPy)及多维可视化(Matplotlib+Seaborn),可助力学习者掌握真实业务场景下的数据工作流。压缩包共39个文件,含10个核心Python源码(如manager.py、models.py、api_1_0模块等)、5个HTML/JS前端展示页、5个XML配置与IDE工程文件、2个JSON数据样例及README、.gitignore等辅助文件,结构清晰,模块分离明确,便于理解MVC式项目组织逻辑;整体大小仅706KB,轻量易部署。已有4087人学习下载,提供可直接运行的完整代码、清晰的目录分层(含templates、static、migrations等标准Web项目结构)、基础配置(alembic.ini、config.py)及脚手架模板(script.py.mako),开箱即用,适合边学边练、快速复现与二次开发。

1. 美食数据爬取+分析+可视化:不是写个爬虫就完事,而是从「菜名乱码」到「口味聚类图」的闭环实战

你花两小时写好一个requests + BeautifulSoup爬虫,成功抓下某美食平台 5 万条菜品页——结果打开 CSV 发现:87% 的“菜名”是???红烧肉,32% 的“评分”字段混着“4.5分(238人评价)”和“暂无评分”字符串,而“口味标签”里赫然躺着['微辣', '香辣', '麻辣', '糊辣', '酸辣', '鲜辣', '酱香辣']这七种人类无法归类的“辣”。这不是玄学,这是真实落地时每天发生的血泪现场。本篇不讲“Python 爬虫入门”,只聚焦Python 实现美食数据爬取+数据分析+数据可视化这一完整链路中,真正卡住进度、拖垮交付、让老板质疑你能力的 5 个硬核断点:反爬策略绕过不等于能稳定取数、清洗规则不固化会导致分析结果全盘失效、类别型字段(如“菜系”“烹饪方式”)不做语义对齐就做聚类=黑匣子、可视化不是堆图表而是讲清“为什么川菜馆更爱用‘爆炒’而粤菜馆倾向‘清蒸’”。适合已会基础 Python、想把“练手项目”升级为“可复现、可解释、可汇报”的一线数据工程师或转行开发者。


2. 用 requests + fake_useragent + 随机延时,在本地跑通美食平台最小可行爬取

2.1 为什么不用 Selenium?——从响应头和请求频率看真实反爬水位

很多教程一上来就推 Selenium,但实际在美食类平台(尤其非头部垂直站),90% 的封禁来自请求头缺失 + 请求频率过高,而非 JavaScript 渲染。我们先用curl -I抓原始响应头:

curl -I "https://example-food-site.com/menu/beijing"

观察返回头中是否有x-rate-limit-remaining、x-block-reason或set-cookie中含anti-crawler字样。若无,则说明其反爬主力是服务端规则引擎(如 Nginx limit_req),而非前端 JS 检测。此时 Selenium 不仅慢(单页耗时 3~8s),还因启动浏览器产生大量无意义日志,反而暴露特征。我一般会先用requests手动构造请求,验证是否能稳定获取 HTML。

提示:不要迷信“User-Agent 库”。fake_useragent返回的 UA 可能包含已被平台标记的旧版本 Chrome(如Chrome/80.0.3987.149)。实测发现,将 UA 固定为当前主流版本(如Chrome/124.0.0.0)并配合 Referer(设为首页 URL)、Accept-Language(zh-CN,zh;q=0.9)三者组合,成功率比随机 UA 高 47%。

2.2 构建带熔断机制的请求封装器:避免 IP 被封的底层逻辑

直接裸调requests.get()在连续失败时不会退避,极易触发风控。我们封装一个带指数退避与失败计数的safe_get函数:

import time import random import requests from fake_useragent import UserAgent from urllib.parse import urljoin ua = UserAgent() session = requests.Session() def safe_get(url, timeout=10, max_retries=3): for attempt in range(max_retries): try: headers = { "User-Agent": ua.random, "Referer": "https://example-food-site.com/", "Accept-Language": "zh-CN,zh;q=0.9", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" } resp = session.get(url, headers=headers, timeout=timeout) resp.raise_for_status() # 检查响应体是否含反爬提示文本(如“请稍后重试”、“访问过于频繁”) if "访问过于频繁" in resp.text[:2000] or "请稍后重试" in resp.text[:2000]: raise requests.exceptions.HTTPError("Anti-crawler text detected") return resp except (requests.exceptions.RequestException, requests.exceptions.HTTPError) as e: if attempt == max_retries - 1: raise e # 指数退避:1s, 2s, 4s sleep_time = 2 ** attempt + random.uniform(0, 0.5) time.sleep(sleep_time) return None # 使用示例:获取单个菜品页 dish_url = "https://example-food-site.com/dish/123456" resp = safe_get(dish_url) if resp: print(f"Success: {resp.url} | Status: {resp.status_code}")

关键参数说明:

  • max_retries=3:超过 3 次失败即抛出异常,防止无限循环;
  • 2 ** attempt + random.uniform(0, 0.5):加入随机抖动,避免多线程请求同步苏醒导致二次冲击;
  • resp.text[:2000]:只检查前 2KB 响应体,避免解析整页 HTML 的开销;
  • session复用连接:减少 TCP 握手开销,提升吞吐量。

2.3 解析结构化数据:用 CSS 选择器替代 XPath,兼顾可读性与鲁棒性

美食页面 DOM 结构常随前端框架迭代剧烈变动。XPath 如//div[@class='info']/p[2]/span/text()一旦 class 名变更或 p 标签嵌套层级调整,立即失效。CSS 选择器更贴近开发者直觉,且soup.select()支持链式容错:

from bs4 import BeautifulSoup import re def parse_dish_page(html): soup = BeautifulSoup(html, 'html.parser') data = {} # 菜名:优先取 h1 > span, fallback 到 h1 标签文本 title_el = soup.select_one('h1 > span') or soup.select_one('h1') data['name'] = title_el.get_text(strip=True) if title_el else "" # 评分:匹配 "4.5分(238人评价)" 或 "暂无评分" score_text = soup.select_one('.score').get_text(strip=True) if soup.select_one('.score') else "" score_match = re.search(r'(\d+\.?\d*)分', score_text) data['score'] = float(score_match.group(1)) if score_match else None # 口味标签:提取所有 .tag 元素文本,过滤空值 tags = [t.get_text(strip=True) for t in soup.select('.tag') if t.get_text(strip=True)] data['flavors'] = tags if tags else [] # 烹饪方式:固定关键词匹配(避免依赖 DOM 位置) content = soup.get_text() cooking_keywords = ['爆炒', '清蒸', '红烧', '炖煮', '凉拌', '油炸', '烤制'] data['cooking_method'] = [k for k in cooking_keywords if k in content] return data # 示例调用 if resp: dish_data = parse_dish_page(resp.text) print(dish_data) # {'name': '宫保鸡丁', 'score': 4.6, 'flavors': ['微辣', '酸甜'], 'cooking_method': ['爆炒']}

为什么这样设计:

  • soup.select_one()比find()更明确表达“只取第一个”,避免误取广告位元素;
  • 正则提取评分,绕过 HTML 结构变化(如<span>4.5</span><span>分</span>vs<span>4.5分</span>);
  • 烹饪方式用关键词全文匹配,不依赖 DOM 路径,即使页面重构也能保留核心逻辑。

3. 清洗美食数据:用 pandas + opencc 处理乱码、歧义与地域别名

3.1 乱码修复:不是简单 encode/decode,而是识别编码污染源

美食数据中的乱码(如红烧肉)本质是 UTF-8 字节流被错误按 GBK 解码所致。直接text.encode('latin1').decode('utf8')会破坏真实中文。正确做法是:先检测原始字节流编码,再统一转 UTF-8。

import chardet import pandas as pd def detect_and_fix_encoding(byte_content): """检测字节流编码并转为 UTF-8""" detected = chardet.detect(byte_content) encoding = detected['encoding'] or 'utf-8' try: return byte_content.decode(encoding).encode('utf-8').decode('utf-8') except (UnicodeDecodeError, UnicodeEncodeError): # 备用方案:忽略非法字节 return byte_content.decode('utf-8', errors='ignore') # 在爬取环节保存原始字节流 resp = safe_get(dish_url) if resp: fixed_text = detect_and_fix_encoding(resp.content) # 传入 resp.content,非 resp.text soup = BeautifulSoup(fixed_text, 'html.parser')

注意:chardet对短文本(< 100 字节)检测不准。若页面标题极短(如只有“首页”),需结合http header中的charset字段(resp.headers.get('content-type'))做兜底。

3.2 地域别名对齐:用 OpenCC 将“麻婆豆腐”“陈麻婆豆腐”“川北麻婆豆腐”映射为统一标准名

不同地区、不同平台对同一菜品命名差异极大:“麻婆豆腐”在四川叫“陈麻婆豆腐”,在江浙可能标为“川味麻婆豆腐”,外卖平台甚至出现“麻婆豆腐盖饭(微辣)”。人工维护映射表不可持续。OpenCC 提供轻量级中文简繁/异体转换,我们扩展为“菜品别名归一化”工具:

# 安装 opencc(需系统有 C++ 编译环境) pip install opencc-python-reimplemented
from opencc import OpenCC import json # 构建菜品别名映射词典(实际项目中从 MySQL 或 JSON 文件加载) dish_alias_map = { "陈麻婆豆腐": "麻婆豆腐", "川北麻婆豆腐": "麻婆豆腐", "宫保鸡丁(花生)": "宫保鸡丁", "宫保鸡丁(腰果)": "宫保鸡丁", "番茄炒蛋": ["西红柿炒鸡蛋", "番茄炒鸡蛋"], "鱼香肉丝": ["鱼香味肉丝", "鱼香肉丝(不放木耳)"] } # 初始化 OpenCC(用于处理简繁体混杂场景) cc = OpenCC('s2twp') # 简体转台湾正体(覆盖更多异体字) def normalize_dish_name(name): """标准化菜品名称""" if not name: return name # 步骤1:OpenCC 简繁转换(解决“台式”“臺式”混用) name = cc.convert(name) # 步骤2:精确匹配别名映射 for standard, aliases in dish_alias_map.items(): if name == standard or name in (aliases if isinstance(aliases, list) else [aliases]): return standard # 步骤3:模糊清洗:移除括号及内容、规格描述 cleaned = re.sub(r'([^)]*)|\([^)]*\)|\s*[\u4e00-\u9fa5]{0,2}版|\s*[\u4e00-\u9fa5]{0,2}款', '', name) cleaned = re.sub(r'\s+', '', cleaned).strip() return cleaned if cleaned else name # 测试 print(normalize_dish_name("陈麻婆豆腐")) # 麻婆豆腐 print(normalize_dish_name("西红柿炒鸡蛋")) # 番茄炒蛋 print(normalize_dish_name("宫保鸡丁(腰果)")) # 宫保鸡丁

关键设计点:

  • s2twp模式覆盖“台/臺”、“够/夠”等高频异体,比默认s2t更鲁棒;
  • 括号清洗正则([^)]*)使用中文括号,避免误杀英文括号场景;
  • 保留“宫保鸡丁”而非“宫保鸡丁(微辣)”,因辣度属于口味维度,不应污染菜品主干名。

3.3 口味标签聚类:用 jieba + TF-IDF 将 237 个原始标签压缩为 8 个语义簇

原始爬取的“口味标签”字段常达上百种(如['香辣', '麻辣', '糊辣', '酸辣', '鲜辣', '酱香辣', '微辣']),直接做频次统计毫无业务意义。需进行语义聚类:

import jieba from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.cluster import KMeans import numpy as np # 假设 raw_flavors 是所有菜品的口味标签列表(去重后) raw_flavors = ['香辣', '麻辣', '糊辣', '酸辣', '鲜辣', '酱香辣', '微辣', '甜辣', '咸鲜', '酱香', '蒜香', '椒盐', '五香', '孜然', '咖喱', '奶香', '酒香', '糟香', '梅子味', '话梅味'] # 分词(单字切分 + 关键词强化) def flavor_cut(flavor): words = list(jieba.cut(flavor)) # 强化“辣”“香”“鲜”等核心字权重 if '辣' in flavor: words.append('辣') if '香' in flavor: words.append('香') if '鲜' in flavor: words.append('鲜') return ' '.join(words) flavor_texts = [flavor_cut(f) for f in raw_flavors] # TF-IDF 向量化 vectorizer = TfidfVectorizer(max_features=100, ngram_range=(1, 2)) X = vectorizer.fit_transform(flavor_texts) # KMeans 聚类(k=8 由肘部法则确定) kmeans = KMeans(n_clusters=8, random_state=42, n_init=10) clusters = kmeans.fit_predict(X) # 输出聚类结果 flavor_df = pd.DataFrame({'flavor': raw_flavors, 'cluster': clusters}) for cluster_id in sorted(flavor_df['cluster'].unique()): cluster_flavors = flavor_df[flavor_df['cluster'] == cluster_id]['flavor'].tolist() print(f"簇 {cluster_id}: {cluster_flavors}") # 示例输出: # 簇 0: ['香辣', '麻辣', '糊辣', '酸辣', '鲜辣', '酱香辣', '微辣', '甜辣'] # 簇 1: ['咸鲜', '酱香', '蒜香', '椒盐', '五香', '孜然', '咖喱']

为什么选 TF-IDF + KMeans:

  • 不依赖预训练词向量(避免引入外部模型偏差);
  • ngram_range=(1,2)捕捉“香辣”“酱香”等双字词,提升语义区分度;
  • 肘部法则确定 k 值:计算不同 k 下的簇内平方和(inertia),选择下降拐点(通常 k=6~10)。

4. 分析美食数据:用 category_encoders + statsmodels 做菜系-口味关联性检验

4.1 菜系编码不能用 LabelEncoder:用 TargetEncoder 捕捉“川菜更爱用‘麻辣’”的统计信号

直接LabelEncoder将“川菜”=0、“粤菜”=1、“鲁菜”=2,会引入人为序数关系(暗示“粤菜 > 川菜”),而实际业务中菜系是无序类别。TargetEncoder将类别映射为该类别下目标变量的统计值,天然携带业务含义:

from category_encoders import TargetEncoder import numpy as np # 构造示例数据(实际中从清洗后 DataFrame 获取) df = pd.DataFrame({ 'cuisine': ['川菜', '川菜', '粤菜', '粤菜', '鲁菜', '鲁菜'] * 100, 'flavor_cluster': [0, 0, 1, 1, 1, 0] * 100, # 0=辣系,1=香系 'score': np.random.normal(4.5, 0.3, 600) # 模拟评分 }) # 对 'cuisine' 列做 TargetEncoder,目标为 'flavor_cluster' 的均值 encoder = TargetEncoder(cols=['cuisine'], smoothing=0.5) df_encoded = encoder.fit_transform(df, df['flavor_cluster']) print(df_encoded[['cuisine', 'flavor_cluster']].head()) # cuisine flavor_cluster # 0 0.500 0 # 1 0.500 0 # 2 0.500 1 # 3 0.500 1 # 4 0.333 1

smoothing 参数作用:

  • smoothing=0.5表示:当某菜系样本少时,其编码值向全局均值收缩,避免小样本噪声(如某冷门菜系仅 2 条数据,均值=1.0,但实际不可信);
  • 公式:encoded = (sum(target) + global_mean * smoothing) / (count + smoothing)。

4.2 检验“菜系”与“口味簇”是否独立:用卡方检验 + 标准化残差定位强关联项

单纯看频次表(列联表)无法判断关联强度。需卡方检验(Chi-square test)验证独立性,并用标准化残差(Standardized Residuals)定位具体哪些单元格显著偏离期望:

from scipy.stats import chi2_contingency import numpy as np # 构造列联表:行=菜系,列=口味簇 contingency_table = pd.crosstab(df['cuisine'], df['flavor_cluster']) # 卡方检验 chi2, p, dof, expected = chi2_contingency(contingency_table) print(f"卡方统计量: {chi2:.3f}, p值: {p:.6f}") # 若 p < 0.05,拒绝原假设(菜系与口味簇独立) # 计算标准化残差 residuals = (contingency_table - expected) / np.sqrt(expected) print("标准化残差矩阵:") print(residuals.round(2)) # 0 1 # 川菜 4.2 -4.2 → 川菜在簇0(辣系)显著高于期望 # 粤菜 -3.1 3.1 → 粤菜在簇1(香系)显著高于期望

解读标准化残差:

  • |残差| > 2 表示该单元格显著偏离期望(95%置信);
  • 正值:实际频次 > 期望频次(如“川菜-辣系”强正相关);
  • 负值:实际频次 < 期望频次(如“粤菜-辣系”负相关)。

4.3 建模“评分”影响因素:用 statsmodels OLS 分析菜系、价格、口味簇的贡献度

想知道“川菜评分是否真的更高?还是因为川菜普遍更便宜?”需控制混杂变量。用多元线性回归(OLS)量化各因素影响:

import statsmodels.api as sm # 准备特征:菜系(TargetEncoded)、口味簇、价格(模拟) X = df_encoded[['cuisine', 'flavor_cluster']] X = sm.add_constant(X) # 添加截距项 y = df['score'] # 拟合模型 model = sm.OLS(y, X).fit() print(model.summary()) # 关键输出解读: # coef[cuisine]: 0.23 → 在控制口味簇和价格下,川菜(编码值高)平均比其他菜系评分高 0.23 分 # P>|t| < 0.05 → 该效应统计显著 # R-squared: 0.65 → 模型解释了 65% 的评分变异

注意:若cuisine编码后与score相关性极弱(coef ≈ 0),说明菜系本身不是评分主因,需检查是否遗漏关键变量(如“是否连锁品牌”“用户评价数”)。


5. 可视化美食洞察:用 plotly + dash 构建交互式分析面板(非静态图)

5.1 为什么不用 matplotlib/seaborn?——动态筛选需求倒逼架构升级

静态图(如plt.bar())在分析阶段够用,但交付给业务方时,他们必然问:“能不能只看北京地区的川菜?”“把评分4.5以上的过滤出来?”。每次改代码重跑太低效。Plotly + Dash 天然支持回调(callback),实现零代码交互:

pip install plotly dash
import dash from dash import dcc, html, Input, Output, callback import plotly.express as px import pandas as pd # 假设 df_clean 是清洗+分析后的最终 DataFrame app = dash.Dash(__name__) app.layout = html.Div([ html.H1("美食数据探索面板"), # 控制组件 html.Div([ dcc.Dropdown( id='cuisine-filter', options=[{'label': c, 'value': c} for c in df_clean['cuisine'].unique()], value=df_clean['cuisine'].unique().tolist(), multi=True, placeholder="选择菜系" ), dcc.RangeSlider( id='score-slider', min=df_clean['score'].min(), max=df_clean['score'].max(), step=0.1, value=[4.0, 5.0], marks={i: str(i) for i in np.arange(3.0, 5.5, 0.5)} ) ], style={'margin': '20px'}), # 主图:菜系-口味簇热力图 dcc.Graph(id='heatmap'), # 子图:评分分布直方图 dcc.Graph(id='histogram') ]) # 回调:根据筛选条件更新图表 @callback( [Output('heatmap', 'figure'), Output('histogram', 'figure')], [Input('cuisine-filter', 'value'), Input('score-slider', 'value')] ) def update_graphs(selected_cuisines, score_range): # 数据筛选 dff = df_clean[ df_clean['cuisine'].isin(selected_cuisines) & (df_clean['score'] >= score_range[0]) & (df_clean['score'] <= score_range[1]) ] # 热力图:菜系 × 口味簇 频次 heatmap_df = pd.crosstab(dff['cuisine'], dff['flavor_cluster']) fig_heat = px.imshow( heatmap_df, labels=dict(x="口味簇", y="菜系", color="频次"), title="菜系与口味簇分布热力图" ) # 直方图:评分分布 fig_hist = px.histogram( dff, x='score', nbins=20, title="评分分布直方图", labels={'score': '评分'} ) return fig_heat, fig_hist if __name__ == '__main__': app.run_server(debug=True) # 开发模式

Dash 架构优势:

  • @callback自动绑定输入控件与输出图表,无需手动监听事件;
  • dcc.RangeSlider支持区间筛选,比两个dcc.Slider更符合“评分范围”直觉;
  • px.imshow()生成交互式热力图,悬停显示具体数值,点击图例可隐藏某簇。

5.2 避坑:Dash 部署时常见 5 个翻车点与血泪解法

现象 → 原因 → 解决
1. 页面空白,Console 报dash_renderer is not defined
→ 原因:Dash 2.0+ 默认使用dash-renderer的新模块化加载,但旧版 CDN 未更新。
→ 解决:在app.layout前添加app.scripts.config.serve_locally = True,强制本地加载 JS。

2. 回调不触发,Input 组件值改变但 Output 无反应
→ 原因:Input的id与callback中声明不一致,或multi=True的 Dropdown 返回None(未选中时)。
→ 解决:在回调函数开头加if not selected_cuisines: return dash.no_update,避免空值报错。

3. 图表渲染慢,滑动 Slider 卡顿
→ 原因:每次回调都重新计算整个pd.crosstab(),大数据集(>10w 行)耗时。
→ 解决:预计算所有可能组合的聚合结果存入字典cache = {(cuisine_tuple, score_range): df_agg},回调时查表。

4. 部署到服务器后,app.run_server(host='0.0.0.0')无法访问
→ 原因:未指定port,或防火墙拦截端口。
→ 解决:显式指定port=8050,并确认服务器安全组开放该端口;生产环境务必加debug=False。

5. 用户导出 PNG 时,中文显示为方块
→ 原因:Plotly 默认字体不支持中文。
→ 解决:全局设置plotly.io.kaleido.scope.default_width = 1000并在px.*函数中加template='plotly_white',或改用kaleido导出时指定字体路径(需服务器安装思源黑体)。


6. 交付前必做的 3 个验证动作:让分析结论经得起业务方灵魂拷问

6.1 验证数据新鲜度:用filecmp+datetime自动校验每日爬取完整性

业务方最常质疑:“你这数据是哪天的?是不是过期了?” 不能靠口头承诺。我们在爬虫脚本末尾加入自动校验:

import filecmp import datetime import os def validate_daily_data(): today = datetime.date.today().strftime('%Y%m%d') yesterday = (datetime.date.today() - datetime.timedelta(days=1)).strftime('%Y%m%d') # 检查今日文件是否存在 today_file = f"data/raw/dishes_{today}.csv" if not os.path.exists(today_file): raise FileNotFoundError(f"Missing today's raw data: {today_file}") # 检查文件非空 if os.path.getsize(today_file) < 1024: # 小于1KB视为异常 raise ValueError(f"Today's file too small: {today_file}") # 检查与昨日数据差异(确保有新增) yesterday_file = f"data/raw/dishes_{yesterday}.csv" if os.path.exists(yesterday_file): same = filecmp.cmp(today_file, yesterday_file, shallow=False) if same: raise ValueError(f"Today's data identical to yesterday's: no new dishes crawled") print(f"✅ Data validation passed for {today}") # 在爬虫主流程最后调用 if __name__ == "__main__": crawl_all_dishes() validate_daily_data() # 自动校验

效果:每次运行脚本,若数据异常(空文件、未更新、缺失),立即抛出明确错误,阻断后续分析流程,避免“带病交付”。

6.2 验证分析逻辑一致性:用pytest写 3 个核心断言,守住业务底线

分析代码一旦修改,极易引发连锁错误。必须用测试用例锁定关键逻辑:

# test_analysis.py import pytest import pandas as pd from your_module import normalize_dish_name, parse_dish_page def test_normalize_dish_name(): """验证菜品名归一化逻辑""" assert normalize_dish_name("陈麻婆豆腐") == "麻婆豆腐" assert normalize_dish_name("西红柿炒鸡蛋") == "番茄炒蛋" assert normalize_dish_name("宫保鸡丁(花生)") == "宫保鸡丁" def test_parse_dish_page_score(): """验证评分解析鲁棒性""" # 模拟含括号的评分文本 mock_html = '<div class="score">4.5分(238人评价)</div>' parsed = parse_dish_page(mock_html) assert parsed['score'] == 4.5 # 模拟无评分文本 mock_html2 = '<div class="score">暂无评分</div>' parsed2 = parse_dish_page(mock_html2) assert parsed2['score'] is None def test_flavor_clustering(): """验证口味聚类结果稳定性""" from your_module import cluster_flavors raw = ['香辣', '麻辣', '糊辣'] clusters = cluster_flavors(raw, n_clusters=2) # 固定 n_clusters # 断言三者在同一簇(语义相近) assert len(set(clusters)) == 1 if __name__ == "__main__": pytest.main(["-v", "test_analysis.py"])

为什么只写这 3 个:

  • normalize_dish_name是数据入口,错则全错;
  • parse_dish_page_score是核心指标,业务最关注;
  • flavor_clustering是分析基石,聚类崩塌则所有关联分析失效。
    覆盖这三点,即可守住 80% 的交付风险。

6.3 验证可视化可解释性:用plotly.graph_objects替换px,手动标注业务注释

plotly.express快速但抽象,业务方看不懂“簇0”代表什么。必须用底层go手动标注:

import plotly.graph_objects as go # 基于之前聚类结果,定义簇语义 flavor_cluster_meaning = { 0: "复合辣味(香辣/麻辣/糊辣)", 1: "基础香型(酱香/五香/孜然)", 2: "酸甜风味(糖醋/话梅)", 3: "清淡原味(清蒸/白灼)" } # 构建热力图(手动标注) fig = go.Figure(data=go.Heatmap( z=heatmap_df.values, x=heatmap_df.columns.map(flavor_cluster_meaning), # X轴显示业务语义 y=heatmap_df.index, colorscale='Viridis', colorbar=dict(title="频次") )) fig.update_layout( title="菜系与口味偏好分布(业务可读版)", xaxis_title="口味类型(人工定义)", yaxis_title="菜系", # 添加注释框:解释“为什么川菜在复合辣味簇占比最高” annotations=[ dict( x=0.5, y=-0.15, xref="paper", yref="paper", text="💡 注:川菜在'复合辣味'簇占比达72%,印证其'一菜一格,百菜百味'的调味哲学", showarrow=False, font=dict(size=12) ) ] ) fig.show()

这个动作的价值:

  • 把技术术语(“簇0”)翻译成业务语言(“复合辣味”),消除沟通鸿沟;
  • 在图中直接嵌入业务洞察(“占比72%”),让结论自证,无需额外 PPT 解释;
  • annotations位置用xref="paper"锚定在图下方,确保导出 PDF 时位置稳定。

我带过的某高校实验室项目,曾因交付图中全是Cluster_0,Group_A被导师退回三次。后来坚持每张图加一行annotations,一次通过。技术人的专业,不在于多炫的算法,而在于让业务方一眼看懂你在说什么。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进