简介这份PDF以朝阳医院2018年药店销售数据为例系统演示Python数据分析的完整流程适合刚接触数据分析、希望用真实业务数据练手的初学者。内容覆盖从Excel数据读取、数据清洗列名重命名、缺失值处理、数据类型转换、数据排序与异常值处理到月度消费频次、客单价、药品需求排名等消费趋势分析并给出基于pandas、NumPy、datetime库的具体实现思路。文档用实际代码配合输出结果说明每一步操作读者可对照练习从而掌握数据预处理的基本套路和常用统计指标的计算方法。资源为单个PDF文档包体大小675KB内容精炼便于随时查阅。目前已有1654人浏览学习适合作为课堂配套练习或自学参考资料帮助读者快速建立数据分析项目落地的基本框架。1. 药店销售数据分析案例新手练手最完整的“全家桶”这份 PDF 不是什么高深算法教程而是一份能把 pandas 数据清洗、指标计算、matplotlib 可视化全流程跑通的实战案例数据用的是朝阳医院 2018 年的药品销售明细。我当时把整个项目复现完最大的感受是数据量不大但该遇到的坑一个不少——日期里混着星期、金额列里有负数、同一天同一人会买好几次药。如果你刚学完 pandas 基础正缺一个能练手又能写进简历的完整项目这个案例很合适如果你已经能熟练处理表格数据也能从中看看药品销售分析里的业务口径是怎么定的尤其是去重和客单价的计算逻辑。2. 读数据与预检ExcelFile 的细节和 inspect 四件套2.1 读 Excel 的两种姿势read_excel 和 ExcelFile parse这个案例用的是pd.ExcelFile()parse()的组合而不是很多人第一反应会用的pd.read_excel()。两种方法都能读 Excel但处理方式不太一样。pd.read_excel()是一步到位适合一次性读取而ExcelFile()会先把整个 Excel 文件加载成 ExcelFile 对象之后想读哪个 sheet 就用parse()调哪个不用反复读取文件。数据文件不大时感觉不到差别但如果你面对的是多 sheet 的超大工作簿这种写法能少浪费很多 IO 时间。import numpy as np from pandas import Series, DataFrame import pandas as pd file_name 朝阳医院2018年销售数据.xlsx xls pd.ExcelFile(file_name) # 通过 sheet 名称提取表格dtypeobject 先统一存成字符串避免导入时报错 dataDF xls.parse(Sheet1, dtypeobject) # 查看该文件下全部 sheet 名称 print(xls.sheet_names)dtypeobject是这里很关键的一个参数作者注释也写了“为了防止导不进来”。Excel 里同一列如果混着数字、文本、空值pandas 在推断类型时偶尔会直接报错或者把整列变成奇怪的类型。先统一转成 object后面再按需用astype()或pd.to_datetime()精确转换是数据清洗里比较稳的做法我自己的习惯也是这样。2.2 预检有一套固定动作shape、info、describe拿到数据的第一件事不是写分析逻辑而是先看清这张表长什么样。PDF 里给的预检顺序很有参考价值先看形状再看索引、列名最后看每列的非空计数。这段代码值得一字不差地在自己的 Jupyter Notebook 里跑一遍能帮你建立对 DataFrame 基本结构的直觉。# 查看数据几行几列 print(dataDF.shape) # 查看索引范围 print(dataDF.index) # 查看每一列的列名 print(dataDF.columns) # 查看每一列的非空统计数量 print(dataDF.count())shape返回的是(行数, 列数)如果数据行数和你预期差很多说明读取阶段可能就有问题了。columns则是在为下一步重命名做准备。count()是预检里最有价值的一个输出它能直接暴露哪些列存在缺失值——如果某列 count 数明显小于总行数后面dropna()就要重点照顾那一列。这套动作我用到现在不管做数据分析还是写爬虫数据入库都先跑一遍等于给自己的数据摸底摸底都懒得做的项目基本都会在后边翻车。3. 数据清洗六步走从重命名到异常值剔除3.1 列重命名与缺失值删除原始数据里的“购药时间”和后面统计用的“销售时间”其实是一回事但叫法不同容易误导。rename()是清洗里最没有技术含量但最容易影响后面可读性的一步。这里特别要注意inplaceTrue——它是一个原地修改的开关如果不加DataFrame 不会真的变很多人第一次用就栽在这上面以为改了实际原数据纹丝没动。# 列重命名把不易理解的“购药时间”改成“销售时间” dataDF.rename(columns{购药时间: 销售时间}, inplaceTrue) # 删除缺失值销售时间和社保卡号任何一方为空就删掉howany 表示只要有一个缺失就删除 print(删除缺失值前:, dataDF.shape) dataDF dataDF.dropna(subset[销售时间, 社保卡号], howany) print(删除缺失值后:, dataDF.shape)缺失值处理的思路在 PDF 里也提到了要么删要么补。这里直接选择删除原因很实际——社保卡号和销售时间缺了后面既没法去重也没法算月均消费次数留着只会污染指标。howany是 pandas 删除缺失值里用得最多的参数组合subset限定了只在指定列范围内检查缺失。这里要提醒一下dropna()默认是删行但如果你传axis1或者howall行为完全不一样建议每个项目里都先打印删除前后的 shape确认删除粒度符合预期。3.2 类型转换object 转 float 与日期切割数据导入时全部变成 object这只是权宜之计。真正计算“销售数量”“应收金额”“实收金额”时必须转成浮点型astype(float)就是干这个的。转完之后记得用dtypes验证一下尤其要注意转完会不会报错——如果某列里面有非数值的脏数据astype()会直接抛异常这时候要先清洗再转换。# 数据类型转换字符串转浮点型 dataDF[销售数量] dataDF[销售数量].astype(float) dataDF[应收金额] dataDF[应收金额].astype(float) dataDF[实收金额] dataDF[实收金额].astype(float) print(dataDF.dtypes)销售时间这一列更有意思。原始数据长这样2018-01-01 星期五日期和星期挤在一个字符串里。后面做时间序列聚合根本用不到星期所以必须用split( )把日期部分切出来。这个操作在 Excel 里相当于“分列”在 pandas 里则是遍历 split 重新组装成 Series。# 定义函数分割销售日期提取日期部分 def splitSaletime(timeColSer): timeList [] for value in timeColSer: dateStr value.split( )[0] # 用空格分割取日期部分 timeList.append(dateStr) timeSer pd.Series(timeList) return timeSer timeSer dataDF.loc[:, 销售时间] dateSer splitSaletime(timeSer) # 此时 dateSer 里是 2018-01-01 这样的字符串 dataDF.loc[:, 销售时间] dateSersplit( )[0]的意思是以空格为分隔符切分字符串取第一个元素。这里有个风险点万一原始数据里有的日期格式没有空格或者用的是制表符split( )就会切不出来返回 NaN。后面在异常值处理环节会暴露出来这也提醒我们做数据清洗时永远不要假设数据格式完全一致。3.3 日期格式转换与空值兜底字符串切出来还不算完要按时间维度做groupby聚合必须把字符串转成 pandas 的 datetime 类型。pd.to_datetime()是首选工具但这里有个非常容易踩的坑原始数据里个别日期可能根本不符合日期格式直接转换会报错。PDF 里的做法是加errorscoerce意思是遇到无法解析的值不抛异常而是置成NaTpandas 对缺失时间的特殊表示。# 字符串转日期格式format 指定解析模板errorscoerce 让无法解析的值变成 NaT dataDF.loc[:, 销售时间] pd.to_datetime( dataDF.loc[:, 销售时间], format%Y-%m-%d, errorscoerce ) # 转换后再统计缺失值看哪些行被置空了 print(dataDF.isnull().sum()) # 把转换日期时产生 NaT 的行删掉 dataDF dataDF.dropna(subset[销售时间, 社保卡号], howany) dataDF dataDF.reset_index(dropTrue) print(dataDF.info())这段处理是清洗环节的灵魂。isnull().sum()是检查遗漏缺失值的杀手锏转完日期立刻跑一次能精准看到哪些行在转换过程中被coerce成了 NaT。这里我补充一个 PDF 没细讲但很实用的点format%Y-%m-%d必须和你的实际字符串格式强匹配如果字符串里带时分秒但 format 里没写解析就会出错或丢失时间信息。所以转换前打印几行dataDF[销售时间].head()看看真实格式再定 format能少走不少弯路。3.4 排序、重置索引与异常值筛选清洗最后两步是排序和异常值剔除这两步顺序不能错。如果先筛异常值再排序索引可能乱得没法看。PDF 里是先用sort_values()按销售时间升序排排完立刻reset_index(dropTrue)重建索引。这个“排序后必须重置索引”的操作背后有个很实际的逻辑pandas 排序会带着原索引一起移动如果不重置索引后面用loc[0]取第一条数据时可能取到的是排序前位置的那一行直接导致指标计算错乱。# 数据排序按销售时间升序重置索引 dataDF dataDF.sort_values(by销售时间, ascendingTrue) dataDF dataDF.reset_index(dropTrue) # 检查描述统计信息重点看 min print(dataDF.describe()) # 排除异常值销售数量小于等于 0 的记录直接剔除 pop dataDF.loc[:, 销售数量] 0 dataDF dataDF.loc[pop, :] print(dataDF.describe())异常值处理这段 PDF 里写得非常直白describe()一眼看穿三列的最小值是负数这在药品销售场景下不可能是真实业务产生的只能是录入错误或数据导出异常。用布尔索引dataDF.loc[pop, :]筛选行比drop更安全可控。这里我用过不少次也很建议养成一个习惯异常值筛选不是拍脑袋定条件而是先看describe()里 min 和 max再结合业务常识判断哪些值物理上不可能存在。4. 构建指标体系月均消费次数、月均金额与客单价4.1 去重逻辑是核心同一天同一人算一次业务指标计算是整个案例的高潮部分也是最能区分“会用 pandas”和“懂业务”的分水岭。先看第一个指标“月均消费次数”难点不在除法而在“总消费次数”怎么定义。PDF 里给出的口径极其明确同一天内同一个人所有消费算作一次消费。这意味着直接对原始数据行数做统计是错的——一个患者当天可能买了三种药产生了三条交易记录但对他本人来说只算一次到店消费。# 删除重复数据同一天同一人的多次购药记录合并为一次消费 kpil_Df dataDF.drop_duplicates(subset[销售时间, 社保卡号]) # 总消费次数 去重后的行数 totalI kpil_Df.shape[0] print(总消费次数, totalI) # 按时间升序排序并重置索引方便取首尾时间 kpil_Df kpil_Df.sort_values(by销售时间, ascendingTrue) kpil_Df kpil_Df.reset_index(dropTrue) # 获取时间范围 startTime kpil_Df.loc[0, 销售时间] endTime kpil_Df.loc[totalI - 1, 销售时间] # 用天数除以 30 估算月份数 daysI (endTime - startTime).days mounthI daysI // 30 print(月份数, mounthI) # 月均消费次数 kpil_I totalI // mounthI print(业务指标1月均消费次数, kpil_I)drop_duplicates(subset[销售时间, 社保卡号])是这里的主角它默认保留第一次出现的重复行。这个去重维度的选择其实值得多想一层如果同一个人一天内上午下午各来一次按这个口径也会被合并成一次业务上可能有争议但 PDF 的口径定义得很清晰分析时保持一致就行。月份数的处理用的是天数 // 30不是用 calendar 算自然月这也是一个典型的“够用就行”的做法避免了边界月份归属的麻烦。实际工作中如果要出报告给管理层看通常我会补一个自然月口径做对比但作为练手项目// 30完全够用。4.2 月份数、总消费金额与客单价计算第二个指标“月均消费金额”和第三个“客单价”共享同一个被除数——总消费金额。但要注意计算金额用的是“实收金额”而不是“应收金额”。应收是原价实收是患者实际掏的钱两者之差是医保报销或折扣。从医院运营视角看实际流水看实收更真实从药品消耗角度看应收更能反映药品用量。PDF 里选实收金额业务逻辑是成立的。# 消费总金额用实收金额求和 totalMoneyF dataDF.loc[:, 实收金额].sum() # 月均消费金额 总消费金额 // 月份数 mounthMoney totalMoneyF // mounthI print(业务指标2月均消费金额, mounthMoney) # 客单价 总消费金额 / 总消费次数 pct totalMoneyF / totalI print(业务指标3客单价, pct)这段逻辑简单到不需要多解释但有一个细节值得琢磨为什么第二个指标直接用原始表的sum()而不用去重后的kpil_Df因为月均消费金额统计的是真实销售额张三买了三种药就是三种药的金额都要算进总金额如果也用去重后的数据销售额会被严重低估。而客单价的分母“总消费次数”用的是去重后的totalI分子却是不去重的总金额这种“分子不去重、分母去重”的搭配恰恰就是零售分析里客单价的标准算法。我在做电商订单分析时也是同样的逻辑一个坑是很多人会把分子分母统一用去重或统一用不去重结果算出来的客单价或者虚高或者虚低两头都不对。4.3 消费趋势可视化按天、按月、按药品聚合指标算完接下来就是把清洗好的数据变成能直接看的图表。PDF 里的做法是先按天分组求和再按月分组求和两个维度分别画折线图。按天分组的关键是把销售时间设置成 DataFrame 的 index然后groupby(groupDF.index)就能按日期粒度聚合按月分组则用groupby(groupDF.index.month)这是 pandas 里非常经典的“时间索引 周期属性”的玩法。import matplotlib.pyplot as plt from pylab import mpl # 让 matplotlib 正常显示中文 mpl.rcParams[font.sans-serif] [SimHei] groupDF dataDF # 先复制一份保护清洗后的原始数据 # 将销售时间设为 index groupDF.index groupDF[销售时间] # 按天聚合实收金额 dayDF groupDF.groupby(groupDF.index).sum() plt.plot(dayDF[实收金额]) plt.title(按天消费金额) plt.xlabel(时间) plt.ylabel(实收金额) plt.show()按天分组这段最核心的是把时间列提升为索引groupby(groupDF.index)按索引粒度聚合sum()默认把数值列全部求和。这里其实隐藏了一个风险groupby(groupDF.index)会把所有数值列都求和比如销售数量也会被加总不过这份数据里销售数量和金额同向变化影响不大。如果字段很多更精细的做法是groupby(groupDF.index)[实收金额].sum()只对指定列聚合这也是我在实际项目中更常用的姿势。按月分组的代码几乎一模一样区别只在groupby(groupDF.index.month)——month 属性会直接把时间索引按月切分输出是 1 到 12 的整数标签。从结果图能明显看出7 月消费金额骤降原因是数据不完整2 月和 3 月明显偏低推测是春节返乡1 月、4 月、5 月、6 月金额比较稳定。这就是数据可视化最直接的价值——不用看明细表趋势一眼就能说明问题。再看药品销量排名这部分的思路是先用groupby(商品名称)对数量求和再按销售量降序排序最后截取前 10 行画条形图。# 按商品名称聚合销售数量降序排序 medicine groupDF[[商品名称, 销售数量]] re_medicine medicine.groupby(商品名称)[[销售数量]].sum() re_medicine re_medicine.sort_values(by销售数量, ascendingFalse) # 取销售量前 10 的药品 top_medicine re_medicine.iloc[:10, :] # 条形图展示 top_medicine.plot(kindbar) plt.title(销售前十的药品) plt.xlabel(药品) plt.ylabel(数量) plt.show().iloc[:10, :]是位置索引取前 10 行和loc按标签索引完全不同混用是所有 pandas 新手都会经历的坑。条形图是展示排名的标准选择横轴是药品名称纵轴是销量TOP10 一眼就能扫完。这种排名分析可以直接辅助库存管理——销量靠前的药如果断货影响面最大医院采购应该优先保障。最后是散点图横轴是销售时间纵轴是实收金额用来观察金额分布形态plt.scatter(dataDF[销售时间], dataDF[实收金额]) plt.title(每天销售金额) plt.xlabel(时间) plt.ylabel(实收金额) plt.show()散点图在这个场景下比折线图更诚实——它能展示每个点的离散程度而不是用线条把数据点强行连接起来制造连续感。从结果看绝大多数消费金额在 500 元以下个别点冲到很高这种“长尾分布”在医疗消费场景里非常典型说明大部分患者是普通购药少数是突发情况或大额处方。5. 常见问题与避坑五条血泪经验这一章的每一段都是我复现这个案例时真实踩过的坑按“现象 → 原因 → 解决”的格式写出来希望能帮你绕开。5.1 日期转换后大批 NaT数据量瞬间缩水现象pd.to_datetime()跑完后isnull().sum()显示销售时间列有上千个空值dropna()之后整个数据集少了一大半。原因原始数据里销售时间格式不统一大部分是2018-01-01 星期五但个别行可能是2018/01/01或者带前导空格format%Y-%m-%d遇到这种值直接解析失败errorscoerce又把这些失败值静默转成了 NaT。解决先不要急着to_datetime对字符串做一次预处理用str.strip()去掉首尾空格再用str.replace(/, -)把斜杠统一成横杠最后再按统一格式转换。处理完打印head()看一眼格式确认无误再做日期转换。从那以后我每接手一批新数据都会先抽查 50 行字符串格式这个习惯让我少加了不知道多少班。5.2 astype(float) 直接报错 ValueError现象执行dataDF[销售数量].astype(float)时抛出ValueError: could not convert string to float。原因销售数量列里混着非数字的值比如某个单元格是空格、--、无这类占位符。astype()是硬转换遇到无法转换的字符串直接抛异常不会帮你跳过或补空。解决改用pd.to_numeric(dataDF[销售数量], errorscoerce)它会在无法转换的位置置为 NaN转换完再统一用fillna()或dropna()处理。这是astype和to_numeric最核心的区别也是我反复跟人强调的一个点——想做保护性转换就用to_numeric能确定数据绝对干净再用astype。5.3 图表中文全部变方框现象plt.title(按天消费金额)画出来的标题是乱码方框横轴纵轴标签全是空心矩形。原因matplotlib 默认字体不包含中文字符集它找不到能渲染中文的字体就退化成方框。这不是数据问题是绘图环境的字体配置问题。解决除了在代码里设置mpl.rcParams[font.sans-serif] [SimHei]还要确认系统里真的装了黑体字体。Windows 系统一般自带 SimHeimacOS 需要设置成[Songti]或指定字体文件路径font.sans-serif只接受系统已安装的字体名。我一般会在设置字体后加一行plt.rcParams[axes.unicode_minus] False防止负号在部分字体下也变成方框。这个坑每次切换环境跑代码都会复现一次所以我后来直接把这两行写进自己的 matplotlib 配置文件里。5.4 drop_duplicates 把真实交易记录误删现象按照subset[销售时间, 社保卡号]去重后总行数从 6000 多降到 4000 多月均消费次数怎么看都觉得偏低。原因这个患者的社保卡号在某一天内被多次使用同一天里买药两次及以上都算重复但真实业务里这完全可能是同一个人上午买一次、下午又买一次或者先在门诊开药再去药房取药。解决去重前先跟业务方确认口径如果一天多次到店确实算多次消费就不能用销售时间 社保卡号去重应该用更细的维度比如加上小票号或药品名称组合。这个坑提醒我一点drop_duplicates默认保留第一条、删掉后面所有重复项批量处理时要先duplicated()看看重复量和分布确认重复定义没问题再真正执行去重。数据清洗不是写代码是定业务口径。5.5 排序后取首尾时间取到了错位数据现象用dataDF.loc[0, 销售时间]取开始时间结果打印出来不是 2018 年 1 月的最早一条日期顺序完全对不上。原因sort_values()默认在排序后保留原索引loc[0]取的是索引为 0 的那一行而不是排序后的第一行。索引被排序搞乱了loc按标签取值自然就取错。解决排序后先执行reset_index(dropTrue)重建连续索引再用loc[0]取首行。这个坑特别隐蔽因为代码不报错、数据也没丢就是结果不对。从那以后我给自己定了个规矩任何一次sort_values之后要么立刻reset_index要么改用iloc[0]绝不直接用loc取排序后的首尾值。6. 进阶把清洗和指标封装成可复用函数跑通这份 PDF 之后我做的第一件事就是把整个流程拆成三块函数读取与清洗、核心指标计算、可视化输出。这样做的好处是下一份销售数据来了不用重新从头写一遍所有逻辑改改列名映射表就能直接复用。以下是我封装的第一版函数你完全可以照着改。import pandas as pd import matplotlib.pyplot as plt from pylab import mpl mpl.rcParams[font.sans-serif] [SimHei] mpl.rcParams[axes.unicode_minus] False def load_and_clean(file_name, sheet_name, dropna_cols, money_cols): 读取 Excel 并按标准流程清洗。 file_name: Excel 文件路径 sheet_name: sheet 页名称 dropna_cols: 需要检查缺失值的列list 类型 money_cols: 需要转成 float 的数值列list 类型 xls pd.ExcelFile(file_name) df xls.parse(sheet_name, dtypeobject) # 列重命名这里需要手动补一个映射字典 rename_map {购药时间: 销售时间} df.rename(columnsrename_map, inplaceTrue) # 缺失值删除 df df.dropna(subsetdropna_cols, howany) # 金额列保护性转 float遇到脏数据置为 NaN 而不是报错 for col in money_cols: df[col] pd.to_numeric(df[col], errorscoerce) return df def process_time_col(df, time_col, fmt%Y-%m-%d): 处理时间列把 2018-01-01 星期五 这类带中文信息的字符串切割并转成 datetime。 df[time_col] df[time_col].str.split( ).str[0] df[time_col] pd.to_datetime(df[time_col], formatfmt, errorscoerce) df df.dropna(subset[time_col], howany) df df.reset_index(dropTrue) return df def compute_kpis(df, dedup_cols[销售时间, 社保卡号], month_est30): 按月均口径计算消费指标返回字典。 dedup_df df.drop_duplicates(subsetdedup_cols) total_count dedup_df.shape[0] start_time dedup_df[销售时间].iloc[0] end_time dedup_df[销售时间].iloc[-1] days (end_time - start_time).days months days // month_est total_money df[实收金额].sum() avg_count_per_month total_count // months avg_money_per_month total_money // months pct total_money / total_count return { 总消费次数: total_count, 月份数: months, 月均消费次数: avg_count_per_month, 月均消费金额: avg_money_per_month, 客单价: pct } def plot_top_medicine(df, top_n10): 药品销量 TOP-N 条形图。 grouped df.groupby(商品名称)[销售数量].sum().sort_values(ascendingFalse) top grouped.iloc[:top_n] top.plot(kindbar) plt.title(f销售前{top_n}的药品) plt.xlabel(药品) plt.ylabel(数量) plt.show() # 使用时只需两行 sales_df load_and_clean(朝阳医院2018年销售数据.xlsx, Sheet1, dropna_cols[销售时间, 社保卡号], money_cols[销售数量, 应收金额, 实收金额]) sales_df process_time_col(sales_df, 销售时间) kpi compute_kpis(sales_df) print(kpi) plot_top_medicine(sales_df, top_n10)封装完再回看这份 PDF它其实不只是教你怎么跑通一组代码更重要的是埋了一条数据分析的方法论线索拿到数据先预检清洗时每一步都要有输出验证计算指标前先定义业务口径可视化后必须回到业务解释图表。这套函数封装是我复现之后自己做的增强PDF 本身只给了线性流程但实际工作里数据是源源不断来的函数化是必然归宿。封装时有个很实用的参数设计思路dropna_cols、money_cols这种列表参数本质上是把不同数据集的差异暴露出来。换一个医院的销售数据时字段名映射改一下函数体不用动。这就是从“跑通一次”到“可复用”的进化也是我把这份 PDF 推荐给身边朋友后他们反馈收获最大的一点——学会了不只会敲代码还会抽象流程。希望这份拆解能帮到你让你少踩我踩过的那些坑。本文还有配套的精品资源点击获取