Python新能源汽车销售数据分析与可视化系统实战

1. 系统整体设计与技术选型思路

新能源车销售数据这套东西,我前前后后折腾过三版:第一版是几份 Excel 加几十张 Matplotlib 图,第二版硬堆成 Django 后台,第三版才收敛成现在这套「Python 分析脚本 + Streamlit 交互看板」的组合。之所以拿它当案例来讲,是因为它刚好踩在几个交叉点上——数据源零散、指标口径混乱、业务方要的东西又特别具体,属于典型的"看起来简单、做起来处处是坑"的项目。所谓基于 Python 的新能源汽车销售数据的分析与可视化系统,本质上就干三件事:把散落在各门店台账、订单系统导出表、车型配置表里的数据收拢到一张干净的事实表里,用 Pandas 把销量趋势、车型结构、区域分布、客户分群这些指标算出来,最后用可视化把结论塞到业务方一眼能看懂的地方。它解决的核心痛点是"数出多门"——同一款车,销售部说月销三千,供应链那边说交付两千四,老板开会时两边吵起来,谁都说自己没错。

适合谁看?如果你手头有一堆车辆销售明细表,想做一个能自己跑、能交付、能持续更新的分析系统,那这套东西基本可以照着抄;如果你只是想做几张漂亮的图交作业,也能从里面挑到清洗和图表配置的现成方案。我会把结构、字段、清洗规则、指标算法、图表踩的坑全摊开讲,代码部分给的是可以直接改字段名就跑的写法,不讲虚的。整套系统我建议按"数据层—分析层—展示层"三段来切,不要一上来就想着搞前后端分离,那玩意儿在数据量不到百万行的时候纯属给自己加戏。下面我从设计思路开始拆。

1.1 先想清楚数据从哪来、要回答哪些问题

动手写第一行代码之前,我建议你拿张纸,把"数据从哪来"和"要回答什么问题"这两列写清楚。这一步省下来的时间,后面会以十倍的效率还给你。我见过太多人上来就pd.read_excel,读到第三张表发现字段名五花八门——"车型"在一张表里叫model,另一张叫车型名称,第三张叫车系,然后整个下午都在写 rename。

按我的经验,这类系统的数据源通常就四类,我整理成下面这张表,你可以对照自己的实际情况看:

数据源典型形态关键字段获取难点
门店销售台账每月一张 Excel,人工填报日期、车型、门店、数量、成交价字段名不统一、手写错别字多
订单系统导出CSV,一行一个订单订单号、客户ID、下单时间、交付时间存在取消单、重复单
车型配置表静态维表车型、级别、续航、指导价版本迭代后新旧车型混在一起
区域维表静态维表省份、城市、大区归属行政区划名称写法不一致

问题清单我一般会列这么几条,基本覆盖了业务方 80% 的追问:月度销量走势与同比环比是多少、各价格带的销量结构怎么变、哪些城市是空白市场、哪些车型在拖后腿、门店之间的效率差距有多大、下个月大概能卖多少。这六个问题对应六组指标,后面所有分析模块都是为它们服务的。这里有个我踩过的坑要提醒:不要试图一开始就把所有指标做全,先把销量、同比、环比、结构占比这四个跑通,业务方看到可信的数字之后,自然会提出更多需求,那时候再加不迟。

另外,明确"分析粒度"这件事比想象中重要。同一份数据,按天聚合和按月聚合得到的结论可能完全相反——按天看,某款车月初冲量月末躺平;按月看,它就是一条平稳的线。我的做法是主表保留到订单级明细,分析层按需聚合,绝不提前把数据压成月表,否则后面想算"平均交付周期"这种指标时就抓瞎了。

1.2 技术栈为什么这么选:Pandas 打底,DuckDB 兜底,Plotly 出图

技术选型这块,我的原则是"能用一行解决的,绝不引入一个框架"。核心三件套是Pandas + DuckDB + Plotly/Streamlit,理由掰开说:

Pandas 不用多解释,清洗、透视、时间序列全靠它,生态成熟到几乎不会遇到"这个问题没法解"的绝境。但如果你的明细表超过两百万行,Pandas 的内存占用会开始难受,groupby一次要等十几秒。这时候我一般用DuckDB顶上——它可以先用 SQL 把大表聚合成小表,再把小结果交给 Pandas 做后续加工。为什么这么搭配?因为 DuckDB 能直接SELECT * FROM 'orders.parquet'读文件,不用建库不用起服务,对单机分析场景几乎是零成本。实测下来,两千万行的明细做一次分组聚合,DuckDB 大概两秒内出结果,Pandas 得跑到内存告警。

可视化这块我分成两档:静态报告用 Matplotlib(导出 PNG 塞进周报),交互看板用 Plotly + Streamlit。Plotly 的好处是 hover 提示、缩放、图例筛选都是白送的,业务方最喜欢在图上点来点去看某个月的具体数值。Streamlit 则是把"脚本变成网页"这件事的成本压到了最低——一个st.plotly_chart(fig)就能把图挂上去,改完代码页面自动刷新,不用碰 HTML、不用写路由。

至于为什么不上 Django 或 Flask 自己写接口,我的判断标准很直接:如果使用方不超过 20 个人、不需要权限体系、不需要写操作,Streamlit 就是最优解。上完整前后端分离,你会花 70% 的时间在写登录、鉴权、接口文档上,真正做分析的时间反而被挤压。这不是技术能力问题,是投入产出比问题。

1.3 三层架构落地:原始层、明细层、应用层

我把整个系统拆成三层,每层职责单一,这样出问题时能快速定位是哪一层的锅。

原始层就是原始文件的归档目录,按raw/2024/03/门店A_202403.xlsx这种结构存,一律不改原文件内容,进来什么样就存什么样。这样做的好处是数据对不上时永远有据可查,不用去翻聊天记录找"当时到底发的哪一版"。

明细层是清洗后的宽表,一行一个订单或一条销售记录,字段全部标准化,存成 Parquet 格式。选 Parquet 不选 CSV 的原因很实在:体积小、读取快、带数据类型,date列读进来就是 datetime,不用每次都pd.to_datetime。这套明细表是整个系统的地基,后面所有指标都从它算出来。

应用层就是指标宽表和看板。指标宽表按"月份 × 车型 × 城市"这种粒度预聚合好,看板直接读,省得每次刷新都重算一遍。预聚合的代价是灵活性降低——你想按周看就得重跑,但换来的是看板秒开,这个交换我认为非常值。

流程上我是这么串的:ingest.py负责把原始文件搬进明细表,clean.py做清洗和校验,metrics.py算指标,app.py做展示,用一个Makefile或者简单的run_all.sh串起来,每天早上定时跑一遍。这种"脚本流水线"的土办法,比任何调度框架都更容易维护——出错了直接看日志,哪一步失败跑哪一步,不用猜。

2. 数据清洗与字段标准化:脏数据才是真正的战场

说实话,这个项目里写分析代码的时间大概只占三成,剩下七成全在跟脏数据搏斗。新能源车销售数据的脏,有它自己的特点:车型名称的写法能玩出花来——同一个车系,有人写"XX 460 豪华版",有人写"XX460豪华",还有人把年款写在前面;日期列里混着2024/3/1、2024-03-01、2024年3月1日、甚至 Excel 的序列号45352;数量列里出现过"约3000"、"3000辆"、"3,000"这种写法,直接astype(int)会当场报错。

这一章我就把清洗环节的规则、代码和血泪教训全摆出来,这部分内容你换个行业、换个数据集,思路也是通用的。

2.1 字段设计:先定标准,再谈清洗

清洗的本质是"把各种野路子写法映射到一套标准字段上",所以第一步得先把标准字段定下来。我设计的明细表长这样,字段不多但每个都有讲究:

字段名类型说明常见问题
order_idstring订单唯一标识存在重复、部分为空
order_datedate下单日期格式多样,含空值
deliver_datedate交付日期大量为空(未交付)
model_namestring车型标准名写法五花八门
seriesstring车系(归一化后)需从车型名派生
levelstring级别(轿车/SUV/MPV)来源维表,需关联
price_bandstring价格带由成交价分箱得到
deal_pricefloat成交价(万元)含"万"字、千分位
city / provincestring城市、省份行政区划写法不一
store_idstring门店编码与门店名混用
qtyint数量含单位、含"约"字
statusstring订单状态口径需与业务确认

这里有两个字段值得单独说。deliver_date大量为空不是数据质量问题,而是业务事实——很多订单下了但还没交付。如果你直接dropna(),会把在途订单全删掉,导致当月销量严重低估。正确做法是保留空值,在算"交付口径销量"时过滤,算"订单口径销量"时不过滤,两个口径分开存。price_band我也不建议在明细层硬存,因为价格带的分箱规则经常被业务方要求调整("把 20 万到 30 万再拆细一点"),存死了就得重跑全表。我后来改成在指标层动态分箱,明细层只留deal_price,改规则只需改一个字典。

series字段是从model_name派生的,这个设计很关键。因为车型名带配置后缀,做"车系销量排行"时如果直接按model_name分组,会得到几百个细碎条目,图根本没法看。归一化到车系之后,排行就清爽了。

2.2 清洗规则清单与可复用的代码

清洗规则我是写成配置驱动的,不用一堆if堆在函数里,方便后面加规则。核心思路是:每个字段对应一个清洗函数,函数里先做类型转换,再做值映射,最后做校验。

先说数量字段,这是最容易出事的地方:

import re import pandas as pd def clean_qty(series: pd.Series) -> pd.Series: """把 '3000辆'、'约3,000'、'3000台' 统一成 int""" def _parse(x): if pd.isna(x): return 0 if isinstance(x, (int, float)): return int(x) s = str(x).strip() # 去掉中文单位、约字、千分位逗号、空格 s = re.sub(r'[辆台约,,\s]', '', s) m = re.search(r'-?\d+', s) return int(m.group()) if m else 0 return series.map(_parse).astype('int64')

这段代码看着朴素,但救过我好几次。注意re.search而不是re.match,因为有些写法前面带括号或备注,match会漏掉。另外负数要保留——退货单就是负数,不要用abs()把它抹平,那样会让当月销量虚高。

车型名称归一化,我用的是"关键词映射表 + 兜底"的策略:

SERIES_MAP = { 'A': ['A系', 'A系列', 'A-'], 'B': ['B系', 'B系列', 'B-'], 'C': ['C系', 'C系列', 'C-'], } def normalize_series(model: str) -> str: if not isinstance(model, str): return '未知' m = model.upper().replace(' ', '').replace('-', '-') for series, keys in SERIES_MAP.items(): if any(k.upper() in m for k in keys): return series return '其他'

实际用的时候,我会先跑一遍value_counts(),人工看一眼前 50 个高频写法,把映射补全,再跑第二遍看"其他"这一类的占比。如果"其他"超过 5%,说明映射表不够全,继续补。这个迭代过程大概要跑两三轮,别想着一把过。

日期字段我单独写了个兜底函数:

def parse_date(x): if pd.isna(x): return pd.NaT if isinstance(x, (int, float)) and 20000 < x < 60000: # Excel 序列号,1899-12-30 为基准 return pd.Timestamp('1899-12-30') + pd.Timedelta(days=int(x)) s = str(x).strip().replace('年', '-').replace('月', '-').replace('日', '') return pd.to_datetime(s, errors='coerce')

Excel 序列号这个坑必须提一句:很多人直接用pd.to_datetime,遇到序列号会得到 1970 年附近的鬼日期,然后整张表的时间筛选全乱套,还不报错,属于典型的"静默错误",比报错可怕得多。

2.3 指标口径统一:那些年我们对不上的数

清洗完了,真正的麻烦才开始——口径。我吃过最惨的一次亏,是给业务方做的月度销量报表,跟财务的数差了将近两成。查了大半天,原因是:我按order_date统计,财务按deliver_date统计,中间跨月的在途订单全成了差异。

后来我定了个规矩,所有指标必须写清楚口径,写在字段名里。具体分这么几种:

指标口径定义适用场景
订单销量按 order_date,含已取消单看市场热度、投放效果
有效订单量按 order_date,剔除取消单看真实需求
交付销量按 deliver_date,不含空值与财务、库存对齐
库存量期初 + 入库 - 交付看渠道压力

字段名我就直接叫sales_delivered、sales_ordered,谁看都不会混。这个做法看着笨,但能省掉无数次"你这个数是怎么算的"的追问。

还有一个隐蔽的口径问题:去重。订单系统导出时,同一订单可能因为状态变更产生多条记录。我一开始用drop_duplicates(subset='order_id'),结果保留了哪条完全看运气,有时候留下的是"已取消"状态,导致销量莫名其妙少了。后来改成先排序再取最后一条:

df = (df.sort_values(['order_id', 'update_time']) .drop_duplicates(subset='order_id', keep='last'))

keep='last'配合时间排序,取的就是最新状态,这才是业务上正确的语义。这个细节在各种教程里几乎没人讲,但它直接决定了你的数可不可信。

3. 核心分析模块:六个指标撑起整个系统

指标层是整个系统的心脏。我不追求指标多,追求每个指标都能被业务方直接用上、且不会被质疑。下面这几个模块是我反复打磨过的,每个都带实现思路和参数选择依据。

3.1 销量趋势与同比环比:时间对齐比算法更重要

趋势分析听起来最简单,groupby一个月度求和就完事,但真正做起来,三个坑排着队等你。

第一个坑是月份缺失。某个月某车型一辆没卖,groupby之后那一行直接不存在,画折线图时线会从 1 月直接连到 3 月,看不出 2 月是零。正确做法是构造完整的月份索引再reindex:

months = pd.period_range(df['order_date'].min(), df['order_date'].max(), freq='M') trend = (df.groupby(df['order_date'].dt.to_period('M'))['qty'] .sum() .reindex(months, fill_value=0))

Period比Timestamp更好用,因为月份比较时不会有"3 月 1 日和 3 月 31 日是不是同一个月"这种歧义。

第二个坑是同比的基期对齐。今年 2 月有 29 天,去年 2 月只有 28 天,直接比会有一点误差,但更大的问题是春节错位——去年春节在 1 月,今年在 2 月,单看 2 月同比会失真。我的处理办法是同时给出"单月同比"和"累计同比",并在图注里标明春节位置。累计同比就是把 1 月到当月的量加起来比,能平滑掉节假日错位的影响。这个做法不需要复杂算法,但需要你把业务上"为什么这个数可信"讲清楚。

第三个坑是环比和同比别混着看。环比受季节影响极大,新能源车年底冲量、年初回落几乎是固定规律,单看环比会误判成"市场崩了"。我一般把环比和同比画在同一个图上,双轴或者并列柱,让读者自己判断。计算代码很简单,关键是数据结构要设计好:

def add_yoy_mom(trend: pd.Series) -> pd.DataFrame: out = pd.DataFrame({'sales': trend}) out['mom'] = out['sales'].pct_change() # 环比 out['yoy'] = out['sales'].pct_change(12) # 同比,需至少13个月数据 return out.round(4)

pct_change(12)这个 12 就是 12 个月,如果你的数据是按周聚合,那就得改成 52。这个参数一定要跟数据粒度绑定,写死一个"12"然后换个粒度就出错的代码,我在别人的脚本里见过好几次。

3.2 车型结构与价格带分层:分箱规则决定结论

结构分析解决的是"卖得好的是哪一类"。我一般做三个维度:车系、价格带、动力类型(纯电/插混)。前两个是必做,第三个看数据有没有。

价格带分箱这块,业务方最爱提意见。我最初用等宽分箱(每 10 万一段),结果 10 万以下全挤在一个箱子里,30 万以上也是一个箱子,中间 10-20 万塞了七成车型,图完全没信息量。后来改成业务自定义分箱,边界取在实际的价格敏感点上:

PRICE_BINS = [0, 10, 15, 20, 25, 30, 40, 1000] PRICE_LABELS = ['10万以下', '10-15万', '15-20万', '20-25万', '25-30万', '30-40万', '40万以上'] df['price_band'] = pd.cut(df['deal_price'], bins=PRICE_BINS, labels=PRICE_LABELS, right=False)

注意right=False,也就是左闭右开。这个参数必须显式写,因为pd.cut的默认行为是右闭,导致 20 万整会落进"15-20万"而不是"20-25万",业务方一看就问你"20 万的车算哪档",解释起来很费劲。左闭右开符合人的直觉:说"20 万级别的车"时,20 万本身属于这一档。

结构占比我用的是堆叠面积图 + 环比变化表的组合。面积图看趋势,表格看具体数值,两个放一起,业务方既能感受到"15 万以下占比在收缩"这个趋势,又能精确看到"从 42% 降到 37%"这个数。光给图不给数,或者光给数不给图,效果都差一半。

还有一个进阶做法我强烈推荐:结构变化贡献度拆解。总销量增长 10%,可能是 A 系列涨了 30% 但占比小,也可能是 B 系列涨了 5% 但基数大。算一下每个车系对总增长的贡献(增量 ÷ 总增量),能立刻看出"谁在拉动增长"。这个指标比单纯的"销量排行"有用得多,因为它包含了时间维度。

3.3 区域市场与地图可视化:省份名称是最大的坑

区域分析的价值在于找空白市场。做法上先用省份聚合,画地图看分布,再用城市级数据找具体机会点。地图这块,pyecharts的Map组件做中国地图非常方便,但有个几乎人人都会踩的坑:省份名称必须完全匹配。

pyecharts内置地图认的是"广东"、"广西"、"内蒙古"这种短名称,而你数据里很可能是"广东省"、"广西壮族自治区"、"内蒙古自治区"。不处理的话,地图上这些省份就是空白,而且不报错,你只能通过肉眼看颜色缺失发现。我写了个映射函数专门治这个:

PROVINCE_FIX = { '广西壮族自治区': '广西', '内蒙古自治区': '内蒙古', '西藏自治区': '西藏', '宁夏回族自治区': '宁夏', '新疆维吾尔自治区': '新疆', '香港特别行政区': '香港', '澳门特别行政区': '澳门', } def fix_province(name: str) -> str: if not isinstance(name, str): return '未知' n = name.strip() if n in PROVINCE_FIX: return PROVINCE_FIX[n] if n.endswith('省') or n.endswith('市'): return n[:-1] return n

这段代码里endswith('市')那一支要小心——直辖市"北京市"去掉"市"变成"北京"是对了,但如果是地级市"苏州市"去掉"市"也是对的,只有在做省级地图时才需要区分。我一般的做法是先判断是不是省级名称,是才做去掉后缀的处理,避免误伤。

除了地图,区域分析更实用的其实是渗透率、单店效率这类派生指标。渗透率是"该城市新能源车销量 ÷ 该城市乘用车总销量",如果拿不到全量数据,退一步用"该城市销量 ÷ 全国销量"看份额变化也行。单店效率则是"城市销量 ÷ 该城市门店数",这个指标能直接指导渠道下沉决策——两个城市销量差不多,但一个只有 3 家店,另一个有 12 家,前者明显还有开店空间。

这里我要提醒一句:小样本城市不要直接排名。某个城市一个月只卖了 2 台,因为基数小,环比增长率可能是 300%,排到第一去,看起来很唬人但毫无意义。我的做法是设一个最小样本量门槛(比如月均 30 台),低于门槛的归入"其他"或者单独标注,避免图被噪音主导。

3.4 门店与客户分群:用简单模型解决实际问题

分群这块,很多人一上来就想搞复杂的聚类,其实先用规则就能解决八成问题。我的做法是先规则分层,再聚类补充。

门店效率分层用两个指标就够:月均销量和单车毛利。画个散点图,用中位数切成四个象限:

象限特征建议方向
高销量高毛利明星店保持,输出经验
高销量低毛利走量店优化价格策略
低销量高毛利精品店扩客源
低销量低毛利问题店重点诊断

这个四象限法不需要任何算法,但业务方一看就懂,而且能直接对应到行动。相比之下,扔一个 K-Means 出来的簇编号过去,对方第一句话就是"这个 0 号簇是什么意思",解释成本极高。

真要做聚类,我会用在客户维度。RFM 模型在汽车销售场景里需要改造一下,因为买车是超低频行为,"最近一次购买"这个维度几乎没用。我改成R(最近到店时间)+ V(到店次数)+ M(成交金额),客户数据从留资和试驾记录里来:

from sklearn.preprocessing import StandardScaler from sklearn.cluster import KMeans X = df[['recency_days', 'visit_cnt', 'amount']].fillna(0) # 金额分布极度右偏,先做对数变换再标准化 X['amount'] = np.log1p(X['amount']) X_scaled = StandardScaler().fit_transform(X) km = KMeans(n_clusters=4, random_state=42, n_init=10) df['segment'] = km.fit_predict(X_scaled)

np.log1p这步很关键,成交金额的分布是长尾的,不处理的话聚类结果会被少数大额客户主导,四个簇里三个都挤在低收入区间。n_init=10是显式指定初始化的次数,新版 sklearn 默认值改过,写死更保险。聚类完一定要回头做簇画像——算每个簇的均值、中位数、人数占比,再给簇起个业务化的名字(比如"高频试驾未成交"、"一次成交高价值"),不然结果没法用。

3.5 短期销量预测:别上深度学习,指数平滑够用了

预测这块我有明确态度:月粒度、几十个时间点的数据,不要碰 LSTM 和 Prophet 的复杂配置。数据量摆在那儿,复杂模型只会过拟合,而且解释不了。

我一般用 Holt-Winters 三次指数平滑,它对"趋势 + 季节性"的建模很直接,参数含义清晰:

from statsmodels.tsa.holtwinters import ExponentialSmoothing model = ExponentialSmoothing( trend_series, trend='add', # 有增长趋势用加法 seasonal='add', # 季节波动幅度稳定用加法 seasonal_periods=12, # 月度数据,周期12 initialization_method='estimated', ).fit() forecast = model.forecast(3)

seasonal='add'还是'mul'怎么选?看季节波动的绝对幅度是否随销量水平变化。如果淡季波动 500 台、旺季波动也是 500 台左右,用加法;如果旺季波动能到 2000 台,用乘法。判断方法很简单:画图看波峰波谷的垂直距离是否稳定。

预测结果我从来不单给一个数值,一定给区间 + 前提说明。因为月销预测受促销节奏影响极大,一个降价活动就能让预测失效。我的做法是给出基准预测,再标注"未考虑大型促销活动",让业务方知道这个数的适用边界。这比给一个精确到小数点的数字然后被打脸要体面得多。

另外提醒一句:至少要 24 个月的数据才做季节性预测,少于这个数,季节因子估不准,出来的结果不如直接用移动平均。数据不够时我会老实说"当前数据不支持季节性建模",然后给一个带趋势的简单预测。

4. 可视化与交互看板落地:从能看 to 好用

图和看板这一层,技术难度不高,但"看着舒服"和"看着难受"之间差距巨大。我见过太多分析做得很扎实、图却没法看的项目:中文全是方块、y 轴从 0 开始导致所有柱子一样高、饼图塞了 15 个分类。这一章讲具体怎么避开这些。

4.1 静态图表:中文字体与坐标轴的两个必踩坑

Matplotlib 的第一道坎永远是中文乱码。默认字体不含中文字形,标题全是方框。解决办法是显式指定字体,但不要用写死路径的方式,换个系统就崩:

import matplotlib.pyplot as plt import matplotlib # 按优先级尝试系统中可用的中文字体 for font in ['SimHei', 'Microsoft YaHei', 'WenQuanYi Zen Hei', 'Noto Sans CJK SC', 'PingFang SC']: if font in {f.name for f in matplotlib.font_manager.fontManager.ttflist}: plt.rcParams['font.sans-serif'] = [font] break plt.rcParams['axes.unicode_minus'] = False # 负号正常显示

axes.unicode_minus = False这行经常被忘,后果是坐标轴的负号变成方块。这个坑在画同比跌幅的时候百分百会遇到。

第二个坑是坐标轴范围。销量柱状图如果让 Matplotlib 自动定 y 轴,它经常从一个接近最小值的数开始,导致 3000 和 2800 看起来差三倍,视觉上严重夸大差异。柱状图必须从 0 开始,这是原则;折线图可以按需截断,但要在图注里说明。我的习惯是柱状图统一写ax.set_ylim(0, max_val * 1.15),留 15% 的顶部空间放标签。

DPI 也要注意,savefig默认 100,放到 PPT 里放大就糊。统一设成 200 或者 300,文件大一点无所谓:

plt.savefig('trend.png', dpi=200, bbox_inches='tight')

bbox_inches='tight'能自动裁掉多余白边,尤其是 x 轴标签被截断的问题,加上这个参数基本就治好了。

4.2 交互图表:Plotly 的几个实用配置

Plotly 的默认样式挺好看,但用在业务看板上有几个地方必须调。

第一个是hover 提示的格式化。默认显示qty=1234.0,业务方看着别扭。加hovertemplate就能自定义:

import plotly.express as px fig = px.line(trend_df, x='month', y='sales', color='series', markers=True) fig.update_traces( hovertemplate='%{x|%Y年%m月}<br>销量:%{y:,.0f} 台<extra></extra>' ) fig.update_layout( hovermode='x unified', # 同一x轴所有系列一起显示 legend=dict(orientation='h', y=1.05), margin=dict(l=40, r=20, t=60, b=40), )

hovermode='x unified'这个配置我强烈推荐,它会在鼠标位置把所有系列的值一次性列出来,比逐个 hover 高效太多,特别适合多车系对比的场景。%{y:,.0f}里的逗号是千分位,数值大时极大提升可读性。

第二个是颜色。默认配色在多系列时区分度不够,我一般用固定的品牌色板或者px.colors.qualitative.Set2这类离散色板。要提醒的是颜色不要超过 8 种,超过就分不清了,这时候应该改成"高亮重点 + 其余灰色"的画法——把要强调的那条线着色,其他全灰,视觉焦点立刻明确,这是我用得最多的技巧。

第三个是性能。Plotly 图里点数超过 5000 个,浏览器会开始卡。解决办法是先聚合再画,或者用px.line的render_mode='webgl'(散点图支持),不过最稳妥的还是减少点数。

4.3 Streamlit 看板:结构、缓存与刷新

Streamlit 写看板,核心就三件事:布局、缓存、交互控件。

布局上我一般用侧边栏放筛选器,主区域用st.columns分栏。侧边栏放月份范围、大区、车系多选,主区域上面一行放四个关键指标的st.metric,下面按主题分 tab:

import streamlit as st st.set_page_config(page_title='新能源车销售分析', layout='wide') with st.sidebar: st.header('筛选条件') date_range = st.date_input('时间范围', value=(start, end)) regions = st.multiselect('大区', REGION_LIST, default=REGION_LIST) series_list = st.multiselect('车系', SERIES_LIST, default=SERIES_LIST) c1, c2, c3, c4 = st.columns(4) c1.metric('总销量', f'{total:,} 台', f'{yoy:+.1%} 同比') tab1, tab2, tab3 = st.tabs(['趋势', '结构', '区域']) with tab1: st.plotly_chart(fig_trend, use_container_width=True)

layout='wide'一定要加,否则主区域被挤在中间一条窄带里,图都展不开。use_container_width=True让图自适应容器宽度,不加的话图是固定尺寸,宽屏上会留大片空白。

缓存是 Streamlit 的性能命门。数据加载和指标计算一定要加@st.cache_data,否则每次点筛选器都重新读文件、重新算,几秒的等待会让体验极差:

@st.cache_data(ttl=3600) def load_detail(path: str) -> pd.DataFrame: return pd.read_parquet(path)

ttl=3600是缓存一小时,配合每天更新的数据节奏刚好。要注意缓存函数的参数必须是可哈希的,传 DataFrame 进去会报错,只能传路径或简单类型。

还有一个容易忽略的点:筛选逻辑要写在缓存函数外面。我见过把筛选条件也塞进缓存函数的写法,结果每个筛选组合都缓存一份,内存很快吃满。正确的做法是缓存全量数据,筛选用 Pandas 在做图前做,这样缓存只有一份。

5. 常见问题与排查技巧实录

这一章是纯经验,都是我在实际跑这套系统时遇到的真问题,按数据层和展示层分开列,方便你出问题时直接查表。

5.1 数据层问题速查

现象可能原因排查与解决
销量比业务方少一截按交付口径统计,在途订单被过滤确认口径,改用订单口径或双口径并列
某月销量突然翻倍订单重复(状态变更多条记录)按 order_id 排序后 keep='last' 去重
地图上部分省份空白省份名称带"省""自治区"后缀统一做名称映射
同比算出来是 NaN数据不足 13 个月检查时间跨度,或改用累计同比
日期出现 1970 年Excel 序列号被当时间戳解析加序列号判断分支
某车型销量对不上车型名写法不一致归一化到车系后再聚合
价格带分箱结果怪异pd.cut 默认右闭显式设置 right=False

这张表里最值得展开说的是订单重复。我一开始不信邪,觉得订单号怎么会重复,结果duplicated('order_id').sum()一跑,发现有几百条。原因是订单状态更新时系统会追加一条新记录,而不是原地修改。这类"日志式"存储在很多业务系统里都存在,看到明细表行数比预期多,第一反应就该是查重复。

另一个高频问题是时区。如果数据里带时间戳,且来源系统用的不是本地时区,跨月统计会出偏差——月初 8 点前的订单可能被算到上个月。处理办法是统一转成本地时区再取月份:

df['order_date'] = (pd.to_datetime(df['order_time'], utc=True) .dt.tz_convert('Asia/Shanghai') .dt.date)

这个坑平时不显形,一到月末就冒出来,属于"一年坑你十二次"的类型。

5.2 展示与部署问题速查

现象可能原因解决方向
中文显示为方块字体未配置显式指定中文字体列表
负号变方块unicode_minus 未关设axes.unicode_minus=False
图上线条连成直线缺失月份未补零Period 索引 reindex
看板打开要等很久数据未缓存加@st.cache_data
图表导出模糊DPI 太低设 dpi=200 以上
多系列颜色分不清系列超过 8 个改高亮重点 + 其余灰色
图在宽屏上留白未设容器宽度use_container_width=True

部署上再补两条经验。一是Streamlit 的端口和访问,默认 8501,如果在服务器上跑,记得确认端口可访问;本地测试和服务器运行的环境差异(字体、中文字体包)经常导致"本地好好的,上去全是方块",容器化的时候要把字体一起装进去。二是数据文件路径用绝对路径或基于__file__的相对路径,别用./data/xxx.parquet这种写法,因为 Streamlit 的工作目录可能和你想的不一样,用相对路径经常报文件找不到,而且这个错报得很隐蔽。

5.3 几个让系统能活过三个月的工程习惯

一个分析系统能不能长期用,不取决于代码多优雅,取决于这几个习惯。

第一,指标定义写成配置文件。价格带边界、同比月数、最小样本门槛这些会变的东西,全部提到一个config.py里,别散落在各个脚本。业务方说"把价格带改成 5 万一档",你改一个字典重跑,五分钟搞定;散落各处的话,改一晚上还可能漏。

第二,每次跑批留日志。至少记录:跑了哪几个文件、多少行、清洗掉了多少行、各字段缺失率。我现在的习惯是每次跑完输出一份run_report.txt,里面写着"本次读取 12 个文件共 45,231 行,去重后 43,876 行,order_date 缺失 3 行已剔除"。数据异常时这份日志能帮你快速定位是哪一批文件出的问题,比翻代码猜快得多。

第三,图表配置抽成函数。同一套坐标轴样式、字体、色板,写成一个style()函数,所有图调用它。不抽的话,改个配色要改十几个地方,改到最后风格不统一,看起来像五个人拼的。

第四,给关键结论加校验断言。比如"各车系销量之和应等于总销量",写成assert abs(sum_by_series - total) < 1e-6。这类断言能第一时间抓出聚合逻辑错误,比图表上肉眼发现要早好几天。

最后分享一个小技巧:给每张图加一行"数据口径"脚注。比如"口径:订单口径,含已取消单;数据截至 2024-12-31"。这行字的成本几乎为零,但能挡掉大量"这个数怎么和我知道的不一样"的追问。我在实际使用中发现,业务方对数字的信任,很大程度上不来自于算法多精妙,而来自于你把口径讲清楚了、且每次都一致。系统跑得稳不稳,最后拼的就是这些不起眼的细节。