如果你经常跑菜市场或者习惯手机买菜,应该会对价格的波动特别敏感——菠菜今天的报价可能还是5块钱一把,过两天突然蹿到8块;同一片区域的土豆,不同摊位间价格差出一块钱也不稀奇。价格波动背后,是产地、天气、运输成本、批发商库存等各种因素搅在一起。但普通消费者想看到的,其实是“这个东西现在多少钱、最近涨了还是跌了、大概会涨到什么程度”,而这类信息恰恰分散在各个批发市场网站、农业信息平台里,格式不一、单位不同、更新频率也乱。
我今天分享的,就是一个基于Python的农产品价格数据分析与可视化系统。它负责把分散的农产品价格数据抓下来,清洗成统一口径的数据表,再做价格走势、涨跌排名、波动统计等分析,最后通过静态图表和网页可视化大屏把结果展示出来。这个项目不算大,但把数据分析最常见的链路——采集、清洗、存储、分析、展示——完整地走了一遍。如果你正在学Python数据分析,想找一个能写在简历上的实操项目;或者你是做农业、供应链相关工作的,想自己搭一套价格监控看板,那这套系统的思路可以直接借鉴。
1. 项目概述与需求拆解
1.1 农产品价格数据的“散”和“乱”到底有多严重
农产品价格数据最大的问题,不是拿不到,而是拿到了不知道怎么用。国内有大量农业信息平台每天在发布批发市场的价格行情,但每个网站的发布格式完全不一样。有的用表格列出品名、批发价、单位、市场名称;有的直接是一段纯文本,夹杂着“西红柿 3.5/公斤”这样的碎片;甚至同一个省份的不同市场,计价单位都可能在“斤”“公斤”“吨”之间反复横跳。
数据乱还不只是格式问题。品名同样不统一,这边写“西红柿”,那边写“番茄”,还有人写“大红番茄”;产地信息有时有、有时没有;价格后面的单位有时是“元/斤”,有时写“元/kg”也不标注清楚。这些对人工浏览来说不算大事,但对程序分析来说,每一个不一致都是麻烦。更关键的是时间维度,有的平台每天更新,有的平台周末就停更,数据缺失和口径错位几乎是家常便饭。
我当初搭这个系统,核心出发点就是为了解决这种“散、乱、缺”的问题。通过爬虫将多个来源统一采集,再用Pandas做清洗标准化,最后把价格数据按照“菜品+日期+市场”三个维度组织起来。整个数据链路跑通之后,分析才真正有地基。否则前面数据是脏的,后面画出来的图表再好看,也是在错误结论上盖高楼。
1.2 系统的实际使用场景和最适合的人群
这个系统能做的事,说起来也简单:定期抓取农产品价格,自动生成三类结果——价格趋势折线图、涨跌榜、价格分布统计。如果你把爬虫做成定时任务,挂在服务器上跑,甚至能每天往Telegram、企业微信或者本地数据库里推一份行情摘要。
它最适合三类人。第一类是正在学Python数据分析的初学者,需要一个覆盖完整链条、不是只调调库就能交差的练习项目,这个系统能逼你处理真实世界里的脏数据;第二类是做农业信息化相关工作的朋友,比如批发市场的信息员、农产品电商平台的选品运营,他们需要快速看到哪些品类在涨价、最近供应端有什么异动;第三类是想做数据可视化作品集的人,用同一个数据集,折腾出多张图表和一个展示页面,比拿着网上下载的干净数据做十几个demo更有说服力。
在这个项目里,我不会用特别复杂的算法,也不会上一套重量级框架,核心就是用Python生态里的Requests、Pandas、Flask、ECharts,把一个实际问题的闭环跑通。下面我会按数据流顺序,把每一步的设计思路和代码细节都拆开讲。
2. 技术选型与整体架构设计
2.1 为什么最终选了Python这一套技术栈
刚开始规划的时候,我也考虑过直接用Excel或者商业BI工具来做这个分析。Excel天然适合做小规模的数据透视,Power BI和Tableau在可视化交互上也很成熟,理论上不需要写代码,把数据下载下来拖拽几步就能出图。但它们的共同问题是:整个流程里最关键的数据采集环节,光靠手动下载根本撑不住长期自动化的需求。你不可能每天打开七八个网站,把数据一个个复制粘贴到Excel里,再手动刷新透视表。
Python把采集、清洗、分析、可视化这四个环节统一到了一个生态里。爬虫用Requests加BeautifulSoup,解析整个网页的表格数据大概二十行代码;数据清洗交给Pandas,处理缺失值、统一单位、按日期排序这些操作都有现成方法;可视化端有Matplotlib和Seaborn出静态图表,再往Web上走,Flask配ECharts做交互式大屏也毫不费力。整个项目只需要一套开发环境就能跑通,不需要在两个工具之间来回切换、手动搬运数据。
另外一个原因是社区生态成熟。农产品价格分析虽然是一个比较垂直的领域,但核心操作方法——处理时序数据、计算环比同比、做滚动均值、聚类分组——这些都有大量现成的代码和踩坑经验可参考。路上遇到问题,搜索Python数据分析、可视化相关的资料,大概率能找到解决方案,这对一个需要长期维护的小系统来说很重要。
2.2 数据从采集到展示的三层流转结构
整个系统按数据流可以切分成三层:采集层、分析层、展示层。我用表格把这层结构梳理一下,每个层的任务边界清楚之后,代码写起来就不容易乱。
| 层级 | 核心任务 | 用到的库/工具 | 产出物 |
|---|---|---|---|
| 采集层 | 定时抓取多个行情页面,解析表格文本 | Requests、BeautifulSoup | 原始CSV文件 |
| 分析层 | 数据清洗、单位统一、指标计算 | Pandas、NumPy | 标准化数据库 |
| 展示层 | 生成静态图表、提供API数据、渲染交互页面 | Matplotlib、Flask、ECharts | 图表与可视化大屏 |
采集层追求的是“不阻塞、能容错”。每个网站请求之间要控制频率,捕抓失败要能跳过,不能因为某个页面短暂超时就把整套流程卡死。这一层产生的原始数据,我建议先落成CSV存档,保留采集原貌,尽量不要在采集阶段就做太激进的清洗。
分析层是整个系统的核心,也是工作量和坑最多的地方。它负责把不同来源的单位统一成“元/斤”,把“西红柿”和“番茄”归并成同一条品名记录,再处理缺失日期、重复采集等问题。清洗完成后,按“日期、品名、价格”的明细表结构存入SQLite,供后续查询。
展示层则是把分析结果翻译成人眼更容易看懂的形态。静态图表适合放在周报、PPT里,动态大屏适合放在办公室、直播间侧边。两层可以共用同一种标准化的数据接口,分析层算好指标,展示层只负责取数、绘图。
这种三层结构的好处,是每一层都能独立测试、独立重跑。采集出了问题,不用动分析逻辑;展示样式要改,不影响底层数据。对个人项目来说,这意味着维护成本断崖式下降。
3. 数据采集与清洗的实操细节
3.1 从行情网页中抓取表格数据:一段可复用代码
采集环节我选的目标是公开的农产品批发市场行情页面,这类网站的结构相对简单,价格信息基本都在一个HTML表格里,很适合初学者拿来练习。写爬虫之前,建议先打开目标网页,用浏览器开发者工具确认一下表格的HTML结构,看看数据是静态渲染的还是Ajax动态加载的。绝大多数农业信息平台的行情页是静态表格,用Requests拿到HTML,再用BeautifulSoup定位就可以。
下面这段代码展示了最基本的页面抓取和表格解析逻辑:
import requests from bs4 import BeautifulSoup import time HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" } def fetch_market_prices(url): resp = requests.get(url, headers=HEADERS, timeout=10) resp.encoding = resp.apparent_encoding soup = BeautifulSoup(resp.text, "html.parser") rows = [] for tr in soup.select("table tr"): cells = [td.get_text(strip=True) for td in tr.find_all("td")] if len(cells) >= 4: rows.append(cells) return rows if __name__ == "__main__": url = "https://example-market.example/price" data = fetch_market_prices(url) for row in data[:5]: print(row) time.sleep(2)有几个细节值得提醒。resp.encoding那一段不能省,很多政府信息平台用的是GBK或GB2312编码,Requests默认根据响应头猜测编码,猜错的话解析出来就是乱码。用resp.apparent_encoding可以根据页面内容重新判断,准确率会高很多。另外,time.sleep(2)不是走形式,对目标网站保持礼貌的请求频率,既是合规的做法,也是防止IP被临时限制的实操手段。
采集到的原始数据里,字段大概会有“品名、市场、单位、批发价、日期”这几列。不同页面顺序可能还不一样,所以不建议在爬虫里硬编码列位置,而是保存成原始CSV后,在分析阶段用Pandas统一重命名列名。
3.2 清洗和标准化:把脏数据变成能分析的样子
采集完成后,数据分析过程中最耗时的一步才刚刚开始。原始数据里常见几类问题:品名大小写混用、单位不统一、价格字段偶发缺失、同一菜品在同一个市场同一天可能采集了两条记录。这些问题不解决,后面算出来的涨跌幅没有任何意义。
我的清洗流程分四步走。第一步去除空行和纯重复记录;第二步做品名和单位的标准化;第三步用统一口径计算标准化价格;第四步处理缺失值。下面给出核心代码:
import pandas as pd import numpy as np df = pd.read_csv("price_raw.csv") df.columns = ["name", "market", "unit", "price", "date"] # 1. 去重:同一市场、同一品名、同一天只需要一条记录 df = df.drop_duplicates(subset=["name", "market", "date"]) # 2. 品名标准化:去空格、转小写、常见别名映射 name_map = { "西红柿": "番茄", "小番茄": "圣女果", "土豆": "马铃薯", } df["name"] = df["name"].str.strip().str.lower() df["name"] = df["name"].replace(name_map) # 3. 单位统一:所有价格折算为“元/斤” unit_factor = {"斤": 1.0, "公斤": 0.5, "kg": 0.5, "千克": 0.5, "吨": 1 / 2000} df["factor"] = df["unit"].map(unit_factor) df["price_std"] = df["price"] * df["factor"] # 4. 对缺失价格先做向前填充,再取同菜品的均价兜底 df["price_std"] = df.groupby("name")["price_std"].transform( lambda x: x.ffill().bfill() ) df = df.dropna(subset=["price_std"])注意,步骤4的缺失值处理不是万能的。如果一个菜品连续一周没数据,前面的记录也被丢光了,那向前填充出来的价格其实是“虚构”的,会让趋势线失真。所以我会在后面做一层兜底:如果某个菜品在近7天内有效数据少于3天,就把它从趋势分析中临时剔除,不画图。这是很多人会忽略的细节。
清洗完成后的数据建议转成长表格式——每条记录一个日期、一个品名、一个市场、一个价格。这种结构对Pandas的分组聚合最友好,也方便后续直接写入数据库。
4. 数据存储与价格指标的底层逻辑
4.1 SQLite和CSV,到底选哪种存储方案
初期数据量不大的时候,很多人会把清洗后的结果继续存成CSV,好处是直观、方便打开核对,Excel直接就能看。但跑了一段时间你就会发现,随着日期累积,每天抓几百行,一个月上万行,CSV的麻烦开始冒头:每次分析都要重复读整个文件,想按日期切片不够灵活,多个菜品同时筛选时代码也变得啰嗦。
我的建议是,原始数据留CSV做备份,清洗后的明细数据存SQLite。SQLite是单文件数据库,不需要安装服务器,Python原生支持sqlite3模块,非常适合这个量级的数据分析项目。用SQL查询“某菜品的最近30天均价”这类需求,一行SQL比在Pandas里反复切片要清晰得多。
建表和写入的代码很简单:
import sqlite3 conn = sqlite3.connect("agri_prices.db") df.to_sql("price_daily", conn, if_exists="append", index=False) # 建立索引,按品名和日期查询会快很多 conn.execute( "CREATE INDEX IF NOT EXISTS idx_name_date " "ON price_daily(name, date)" ) conn.commit() conn.close()关键一步是给name和date建立联合索引。虽然系统数据量不大,但每次可视化请求都要按品名过滤、按日期排序,没有索引的话,数据多了以后接口响应会肉眼可见地变慢。索引在个人项目里是性价比最高的优化手段。
4.2 价格分析里最有用的四个指标
数据进库之后,就进入分析层。农产品价格分析不一定要做机器学习预测,先把这些基础指标算扎实,远比糊一个玄学预测模型靠谱。
第一个是周环比变化率。(今日价格 - 7日前价格) / 7日前价格,它比日环比更能反映短期趋势,抹平了单日波动。计算时用Pandas的shift函数就可以:
df = df.sort_values(["name", "date"]) df["prev_price"] = df.groupby("name")["price_std"].shift(7) df["week_change"] = (df["price_std"] - df["prev_price"]) / df["prev_price"]第二个是7日滚动均线。这一招在处理农产品价格时特别有效。单日价格受供需情绪影响很大,走势图会像锯齿一样上下跳,加上7日均线之后,你才能真正看到“这东西最近整体是涨还是跌”。实现用的是rolling方法,Pandas里一行解决。
第三个是价格分位数和标准差。农产品价格有明显的季节性,西红柿2块钱的时候可能已经处于近期低位,4块钱的时候可能就是高位。通过计算近30天价格的分位数,可以把当前价格映射成“低于25%分位”“高于75%分位”,直接判断当前价格处在什么水平。这个比单纯看绝对值更有参考价值。
第四个是价格离散度。同一菜品同一天在不同市场的价格差异,反映了各地供需差异和信息不对称的程度。如果看到某个菜品在不同市场的报价差超过20%,那可能意味着产地集中、运输成本高,或者市场之间流通不畅,这也是分析里很有价值的信息。
这些指标计算看起来简单,但它们组合起来就能支撑起一个像样的价格监控系统。比如自动扫描全品类,找出周环比涨幅最大的前十个菜品生成“涨价榜”,或者找出价格波动率最高的菜品提示关注,背后核心用的就是这些基础指标。
5. 可视化方案与展示页面实现
5.1 用Matplotlib和Seaborn出一组可读性强的静态图表
数据分析结果最终要靠图表来呈现。我个人会把图表分成基础款和进阶款两种思路。基础款是价格走势折线图加7日均线、涨跌幅度柱状图;进阶款是用热力图看不同菜品在不同时间段的价格水平,用箱线图看价格分布。
用Matplotlib画折线图,有一个必须提前处理的坑:中文乱码。默认字体不支持中文,出现的就是一堆方框。解决办法是显式指定中文字体,代码里加一行就行:
import matplotlib matplotlib.rc("font", family="Microsoft YaHei") matplotlib.rc("axes", unicode_minus=False)第一行指定字体为微软雅黑,第二行是避免坐标轴负号显示成方块。Mac和Linux环境请换成系统自带的中文字体,比如“PingFang SC”或者“Noto Sans CJK SC”。
下面是一段画菠菜价格走势的完整代码,同时绘制了原始价格和7日均线:
import matplotlib.pyplot as plt import pandas as pd def plot_price_trend(df, item_name): item = df[df["name"] == item_name].sort_values("date") item = item.set_index("date") item["ma7"] = item["price_std"].rolling(7).mean() plt.figure(figsize=(12, 6)) plt.plot(item.index, item["price_std"], label="当日价格", linewidth=1.2) plt.plot(item.index, item["ma7"], label="7日均线", linewidth=2) plt.title(f"{item_name} 价格走势") plt.xlabel("日期") plt.ylabel("价格(元/斤)") plt.legend() plt.xticks(rotation=45) plt.tight_layout() plt.savefig(f"{item_name}_trend.png", dpi=150) plt.show()画完之后会发现,原始价格线毛刺很多,但均线能平滑地反映走势。当两根线交叉、均线开始掉头向下的时候,通常就是一个中期趋势的转折信号。这个规律放到农产品上同样适用,只是周期性比股票更强、更规律。
除了折线图,我推荐用热力图看“不同菜品在不同周的价格水平”。做法是先把日期归一化成“周”,然后按每个菜品的周均价做透视表,再用Seaborn画热力图。这种图放在周报里非常有信息量,一眼就能看出哪些菜在哪些周处于高位。
5.2 用ECharts把分析结果变成可视化大屏
静态图适合打印和贴文档,但如果要给同事、客户展示,一个能交互的网页大屏会更有冲击力。我这里用的是Flask加ECharts的组合。Flask负责提供数据API,前端ECharts负责画图。整个链路不复杂,但有一种“自己搭了个数据产品”的实感。
后端代码里,先准备一个接口从SQLite读数据并转成JSON:
from flask import Flask, jsonify, render_template import sqlite3 import pandas as pd app = Flask(__name__) def query_price(item_name, days=30): conn = sqlite3.connect("agri_prices.db") sql = """ SELECT date, name, price_std FROM price_daily WHERE name = ? ORDER BY date DESC LIMIT ? """ df = pd.read_sql_query(sql, conn, params=(item_name, days)) conn.close() return df.sort_values("date") @app.route("/api/price") def api_price(): df = query_price("菠菜") return jsonify({ "dates": df["date"].astype(str).tolist(), "prices": df["price_std"].tolist() }) @app.route("/") def index(): return render_template("index.html")前端页面里,通过Fetch请求这个接口,把拿到的数据填进ECharts的折线图配置里:
fetch('/api/price') .then(res => res.json()) .then(data => { const chart = echarts.init(document.getElementById('chart')); chart.setOption({ tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: data.dates }, yAxis: { type: 'value', name: '元/斤' }, series: [{ name: '菠菜价格', type: 'line', data: data.prices, smooth: true }] }); });这样一个最简单的可视化页面就跑起来了。如果想做大屏,思路是完全一致的——不同的图表区域配置不同的ECharts实例,每个实例从不同的API接口取数。比如左上角放全国主要市场均价对比,右上角放涨价榜,底部放一个价格热力日历图。数据时统一走API,前端只需要关心渲染。
做这个环节时,我最强烈的感受是:前面所有清洗和存储的功夫,都是为这一层服务的。如果数据结构设计得稀烂,写前端接口的时候会痛苦到怀疑人生;反过来,只要存储层规整,写一套JSON接口通常一两个小时就能搞定。
6. 常见问题与排查技巧实录
6.1 数据口径不一致:最容易颠覆结论的问题
做数据清洗的过程中我踩过最大的坑,不是代码报错,而是“数据都对,但结论错了”。第一次分析时,我直接用爬虫抓下来的price字段算涨幅,没注意有的市场报价是“元/公斤”,有的市场直接报“元/斤”,两类数据混在一起画图,画出来一个荒谬的曲线——西红柿价格直接翻倍,因为单位从公斤变成了斤。
从那之后,我在清洗流程里强制加了单位映射表,并且会做一轮校验:检查同一个菜品同一天的价差范围,如果不同市场之间的比值超过2倍,大概率是单位或者录入口径出了问题。这个校验逻辑帮我在后续数据中抓出了好几个不规范报价。
品名映射也是一个长期维护的活。可以把西红柿和番茄做归并,但还有更多别名,比如土豆与马铃薯、青椒与柿子椒、花菜与花椰菜。建议在项目开始就准备一张别名映射表,后续遇到新别名随时补充。不要指望一条正则能把所有问题解决,现实中就是靠一张表不断维护。
6.2 数据缺失与时间对齐:三个避免曲线断崖的技巧
农产品价格数据最讨厌的缺失模式,是节假日和周末。很多市场周末不更新,一旦你按自然日画日频折线,图上就会出现一个个缺口。如果你直接用上一日价格填充,趋势看起来会平,但拉长时间后,可能掩盖“周末之后价格跳涨”这个实际现象。
我目前的处理策略有三个层面。第一层:分析周期拉长到周级别,用周均价替代日频价,天然规避周末缺失。第二层:如果必须保留日频,就用ffill加bfill做有限填充,同时记录填充标记列,画图时单独用虚线标出填充区间。第三层:对于连续缺失超过一周的菜品,直接在展示层隐藏,防止用户被误导。
6.3 爬虫被反爬限制与系统性能优化
个人项目里,最容易被忽视的就是爬虫的合规和礼貌。即使目标网站没有明确封禁,每次抓取间隔太短,也会给对方服务器造成压力。我在代码里强制加了随机延时,每次请求之间等待1到3秒,并且在User-Agent里声明了真实的浏览器信息。如果采集任务要对多个市场跑,可以做串行加延时,单线程足够用了。最有用的排查工具是浏览器的开发者工具网络面板,看网页是静态还是动态加载,避免浪费时间去解析一堆JavaScript渲染后的空表格。
性能方面,这个量级的数据用不到分布式、用不到消息队列。我做过的优化就两类:一是数据库索引,上文已经提到过;二是把高频查询结果缓存起来。比如涨价榜这种按天计算的全局指标,不用每次打开页面都重算一遍,可以在每天采集完成后触发生成一份JSON缓存,Flask接口直接读缓存文件,响应速度从几百毫秒降到十几毫秒。这个思路在小型数据分析系统里特别实用——少做重复计算,比堆机器更有效。
最后说一点个人体会。做完这个系统之后,我对“数据分析项目”这件事的理解变了很多。以前我也跟着教程跑过Yelp评论情感分析、Kaggle房价预测,但数据是现成的、目标是清晰的,整个过程更像是在调包。而自己从零做一个农产品价格分析系统,你不得不处理数据采集、脏数据、口径不一致、缺失值这类真实世界的磨人问题,反而是在这些环节里收获最大。
如果你也想尝试,我建议不要一上来就追求大而全。先选一个市场、选十个你常买的菜品,把采集到展示的链路跑通,再去扩展多市场、多品类、定时任务这些花活。这个项目后续可以往哪个方向扩展呢,我个人觉得最值得做的是加一个价格预警功能——某个菜品突破历史分位数时自动推送通知,比单纯画图实用得多。从一个简单的起点开始,慢慢把一个真正能用的系统养起来,这大概就是数据项目最有成就感的地方了。