Python数据分析实战:Pandas分组统计与Matplotlib饼图可视化 1. 项目概述与核心价值最近在复盘一些老项目特别是数学建模竞赛的代码发现很多数据处理和分析的思路即便过了这么多年依然非常经典和实用。就拿2011年的数学建模A题来说题目本身是关于城市表层土壤重金属污染分析的但其中涉及的数据处理环节——比如对大量采样点按功能区进行分类统计、计算污染指数、可视化展示污染构成——这些操作几乎是任何数据分析项目的通用模板。很多朋友在学Python数据分析时常常陷入一个误区把pandas、matplotlib的每个函数都学一遍但遇到真实项目还是不知道从哪里下手代码写得又慢又乱。其实掌握几个核心的“组合拳”就能解决80%的问题。今天我就以这个老题目为背景不深究其复杂的数学模型而是聚焦于如何用Python高效、清晰地完成数据预处理、统计分析和结果可视化这一整套流程。你会看到如何对DataFrame进行安全的复制以避免连锁错误如何灵活地对每一行或每一列进行计算如何快速统计某个特征的不同类别数量以及如何用matplotlib画出既专业又美观的饼图来展示构成比例。无论你是正在准备数学建模竞赛的学生还是日常需要处理业务数据的分析师甚至是刚开始接触Python数据处理的新手这篇内容都能给你提供一套可直接“抄作业”的实战代码和避坑思路。我们用的工具无非就是pandas、numpy和matplotlib但重点在于理解每一步“为什么”要这么做以及有哪些细节决定了成败。2. 数据处理的核心思路与设计当我们拿到像2011年A题这样的数据集时通常是一个包含了多个采样点、每个采样点有地理位置如坐标、所属功能区类型如生活区、工业区、交通区等以及多种重金属元素浓度值的表格。我们的分析目标很明确首先要按功能区进行分类看看不同区域的污染状况有何不同其次可能需要计算每个采样点的综合污染指数这通常涉及行方向的计算然后我们还需要知道整个区域或某个功能区里究竟出现了哪几种重金属元素这涉及列方向的值唯一性统计最后将污染物的构成比例用饼图直观地展示出来。这个流程看似线性但在用代码实现时有几个关键的设计点需要提前想清楚否则很容易写出低效或容易出错的代码。2.1 为何要重视DataFrame的复制这是新手甚至是一些有经验的开发者最容易踩坑的地方。Pandas的DataFrame在进行赋值或切片操作时其行为并不总是符合直觉。import pandas as pd # 假设df是我们的原始数据 df pd.read_csv(soil_data.csv) # 方式一直接赋值这是危险的 df_new df # 方式二使用.copy()方法这是安全的 df_new_safe df.copy()如果你用df_new df那么df_new仅仅是df的一个视图或说另一个引用。你对df_new进行的任何修改例如df_new[pH] 7都会直接且同步地反映到原始的df上。这在进行探索性数据分析时是灾难性的因为你可能无意中污染了原始数据源导致后续步骤全部基于被修改的错误数据。而df.copy(deepTrue)deepTrue是默认值会创建数据的一个完全独立的副本两者在内存中是分开的互不影响。在开始任何数据清洗、转换或衍生计算之前先做一次安全的复制是一个必须养成的好习惯。这相当于在实验室里把原始样品妥善保存只在副本上进行各种试剂测试。2.2 分类统计的逻辑与实现选择“按功能区分类统计”通常意味着我们要计算每个功能区下各个重金属浓度的平均值、中位数、最大值、最小值等。这立刻让人想到groupby操作。但这里有一个细节我们的数据中一个功能区如“工业区”下可能有数十个采样点每个采样点有8种重金属的浓度。我们是想得到每个功能区下、每种重金属的一个统计值例如平均浓度这实际上是一个分组聚合操作。df.groupby(功能区)[铅, 镉, 铬].mean()这行代码就能生成一个新的DataFrame其行索引是各个功能区列是各种重金属值是平均值。这是后续画图比如分组柱状图和对比分析的基础。2.3 行、列计算与元素统计的场景辨析在分析中我们可能需要在两个维度上进行计算行方向计算每一行数据计算例如计算每个采样点的内梅罗综合污染指数。这需要用到该采样点所有重金属的浓度值。我们可以使用df.apply(lambda row: custom_function(row), axis1)或者更向量化地直接对多列进行运算df[综合指数] (df[[铅,镉]]**2).mean(axis1)**0.5。axis1这个参数指明了计算是沿着行方向进行的。列方向统计例如我们想知道“砷”这一列中到底出现了哪几种不同的浓度值或者更常见的想知道“功能区”这一列有哪几种类型。这时我们会用到df[功能区].unique()来获取唯一值数组或者用df[功能区].nunique()直接得到唯一值的个数。这对于了解数据的基本构成或者为后续的groupby分组提供依据非常有用。2.4 可视化载体为何选择饼图在本题中要展示不同重金属对污染的“贡献率”或构成比例饼图是一个直观的选择。它能清晰地显示各部分占总体的百分比。Matplotlib的plt.pie()函数是绘制饼图的核心。但默认生成的饼图可能比较简陋我们需要关注几个关键参数来提升其表现力autopct用于显示百分比startangle用于调整起始角度使图表更美观explode用于将某一部分“拉出来”以强调以及colors用于设置一套协调的颜色。同时为饼图的每一块添加清晰的图例legend是必不可少的否则读者无法将色块与数据对应起来。3. 核心工具链与数据准备工欲善其事必先利其器。我们这场数据分析“手术”需要一套标准且锋利的“手术刀”。3.1 环境与库的配置首先确保你的Python环境推荐使用Anaconda它集成了大部分科学计算库中已安装以下核心库pandas: 数据操作的基石用于数据读取、清洗、转换和分析。numpy: 提供高效的数组运算和数学函数支持pandas的底层依赖它。matplotlib: 绘图库用于生成各种静态图表。安装命令非常简单在终端或命令提示符中执行pip install pandas numpy matplotlib如果你使用Jupyter Notebook或JupyterLab进行交互式分析那将是更好的选择因为它允许你分段执行代码并立即看到结果和图表。3.2 模拟数据集的构建由于原始竞赛数据不易公开获取我们根据题目描述构建一个高度仿真的模拟数据集。这不仅能保护隐私也更有利于我们理解数据结构和分析目标。import pandas as pd import numpy as np import matplotlib.pyplot as plt # 设置随机种子以保证结果可复现 np.random.seed(2023) # 定义数据规模 n_samples 200 # 200个采样点 functional_areas [生活区, 工业区, 交通区, 公园绿地区, 山区] metals [铅(Pb), 镉(Cd), 铬(Cr), 汞(Hg), 砷(As), 铜(Cu), 锌(Zn), 镍(Ni)] # 生成模拟数据 data { 采样点ID: [fSP{i:03d} for i in range(1, n_samples1)], 经度: np.random.uniform(116.2, 116.6, n_samples).round(4), 纬度: np.random.uniform(39.8, 40.1, n_samples).round(4), 功能区: np.random.choice(functional_areas, n_samples, p[0.3, 0.25, 0.2, 0.15, 0.1]) # 各区出现概率不同 } # 为每种重金属生成浓度数据不同功能区设置不同的基准浓度和波动范围 # 模拟工业区污染更重 base_concentration {生活区: 10, 工业区: 50, 交通区: 30, 公园绿地区: 5, 山区: 2} for metal in metals: concentrations [] for area in data[功能区]: base base_concentration[area] # 添加随机波动和少量异常高值 conc base * np.random.lognormal(mean0, sigma0.3) if np.random.random() 0.02: # 2%的概率出现异常值 conc * np.random.uniform(5, 10) concentrations.append(round(conc, 2)) data[metal] concentrations # 创建DataFrame df_raw pd.DataFrame(data) print(原始数据前5行) print(df_raw.head()) print(f\n数据形状{df_raw.shape}) print(f\n各功能区采样点数量) print(df_raw[功能区].value_counts())运行这段代码我们会得到一个包含200行、12列ID、坐标、功能区8种金属的DataFrame。看一眼数据预览和功能区分布我们对数据集就有了初步的感知。注意在实际项目中你的数据可能来自CSV、Excel或数据库。使用pd.read_csv()或pd.read_excel()读取后第一步也应该是像这样查看数据概览df.head(),df.info(),df.describe()了解数据规模、类型和基本统计信息。4. 安全数据操作DataFrame的复制与子集选取现在我们有了原始的df_raw。按照最佳实践在开始任何分析之前我们先创建一个工作副本。# 关键步骤创建数据副本 df df_raw.copy(deepTrue) print(f原始df_raw的id: {id(df_raw)}) print(f副本df的id: {id(df)}) print(两者id不同说明是独立对象。)id()函数返回对象的内存地址两个地址不同证实了这是两个独立的对象。接下来我们可能只需要关注重金属浓度数据而暂时不需要坐标信息。这时我们需要选取数据的子集。# 选取需要分析的列构成新的工作DataFrame metal_columns [铅(Pb), 镉(Cd), 铬(Cr), 汞(Hg), 砷(As), 铜(Cu), 锌(Zn), 镍(Ni)] df_metals df[[功能区] metal_columns].copy() # 这里也做了copy确保df_metals独立 print(f分析用数据子集形状{df_metals.shape})这里有一个细节df_metals df[[功能区] metal_columns]这种切片操作返回的默认是一个视图。但紧接着我们调用了.copy()所以df_metals也是独立的。如果确定后续不会修改df且df_metals只是用于只读分析可以省略这个.copy()以节省内存。但在不确定的情况下加上.copy()是更稳妥的做法。实操心得我习惯在每一个可能产生新DataFrame的关键步骤后都检查一下其id或者通过简单的修改测试如df_test.iloc[0,0] 999再查看原数据是否变化来确认数据的独立性。尤其是在编写复杂的数据处理管道时这个习惯能帮你节省大量调试时间。5. 分类统计洞见不同功能区的污染特征我们的核心任务之一是比较不同功能区的污染状况。使用groupby可以轻松实现。# 按功能区进行分组并计算每种重金属的平均浓度 grouped_mean df_metals.groupby(功能区)[metal_columns].mean().round(2) print(各功能区重金属平均浓度) print(grouped_mean) # 除了平均值我们可能还想看中位数对异常值不敏感和标准差看数据波动 grouped_median df_metals.groupby(功能区)[metal_columns].median().round(2) grouped_std df_metals.groupby(功能区)[metal_columns].std().round(2) print(\n各功能区重金属浓度中位数) print(grouped_median.head()) print(\n各功能区重金属浓度标准差) print(grouped_std.head())观察grouped_mean这个DataFrame行是功能区列是重金属值就是平均浓度。一眼就能看出“工业区”的各项金属平均浓度远高于其他区域这符合我们的常识预期。“山区”的浓度则普遍最低。grouped_std则告诉我们同一功能区内的采样点其浓度波动大小。例如工业区的标准差可能也很大说明区内污染分布很不均匀有的点极重有的点相对较轻。为了更直观我们可以快速可视化这个结果# 绘制各功能区铅(Pb)平均浓度的柱状图 plt.figure(figsize(10, 6)) grouped_mean[铅(Pb)].sort_values(ascendingFalse).plot(kindbar, colorskyblue, edgecolorblack) plt.title(各功能区铅(Pb)平均浓度对比, fontsize15, fontweightbold) plt.xlabel(功能区) plt.ylabel(平均浓度 (mg/kg)) plt.xticks(rotation45) # 旋转x轴标签 plt.grid(axisy, linestyle--, alpha0.7) plt.tight_layout() plt.show()这张简单的柱状图已经能有力地支持“工业区污染最重”的结论。groupby是pandas中最强大的操作之一结合agg函数可以一次性计算多种统计量# 使用agg进行聚合统计 summary_stats df_metals.groupby(功能区)[metal_columns].agg([mean, median, std, count]).round(2) # 查看铅(Pb)的统计摘要 print(summary_stats[铅(Pb)])6. 行与列的计算从点到面的污染评估分类统计是从“面”功能区的视角看问题。接下来我们从“点”每个采样点的视角评估每个点的综合污染情况。这里就需要用到行方向的计算。6.1 计算每个采样点的综合污染指数假设我们采用一种简化的综合指数计算方法某点的综合指数 该点所有重金属浓度与各自背景值这里用所有点该金属的平均值模拟比值的平均值。这实际上是一个行方向的计算。# 步骤1计算每种重金属的背景值这里用全局平均值模拟 background_values df_metals[metal_columns].mean() print(背景值全局平均) print(background_values) # 步骤2计算每个采样点各金属的污染系数浓度/背景值 # 这里利用DataFrame的广播机制每一行数据都会除以background_values这个Series pollution_coefficient df_metals[metal_columns].div(background_values, axiscolumns) print(\n前5个采样点的污染系数) print(pollution_coefficient.head()) # 步骤3计算每个采样点的综合污染指数行方向求平均 # axis1 表示沿着每一行进行计算 df_metals[综合污染指数] pollution_coefficient.mean(axis1).round(3) print(\n添加了综合污染指数后的前5行数据) print(df_metals[[功能区, 综合污染指数]].head())df.div(background_values, axis‘columns’)是点睛之笔。axis‘columns’指定了除法操作是按列对齐的即df的每一列每种金属分别除以background_values中对应金属的值。pollution_coefficient.mean(axis1)则是对每一行每个采样点的所有金属污染系数求平均值得到了该点的综合指数。6.2 统计单列不同元素个数现在假设我们想单纯地了解一下“功能区”这一列到底有多少种类型以及每种类型的具体名称。这虽然简单但在数据探索阶段非常有用。# 方法1获取唯一值数组 unique_areas df_metals[功能区].unique() print(功能区唯一值数组, unique_areas) # 方法2获取唯一值个数 num_unique_areas df_metals[功能区].nunique() print(功能区唯一值个数, num_unique_areas) # 方法3获取各唯一值的计数频率分布 area_counts df_metals[功能区].value_counts() print(\n各功能区采样点数量分布) print(area_counts)value_counts()的结果是一个按计数降序排列的Series它比单纯看唯一值更能反映数据的分布情况。例如我们可以立刻知道哪个功能区的采样点最多数据是否均衡。7. 数据可视化用Matplotlib绘制专业饼图有了综合污染指数我们可以从整体上看看污染“贡献”主要来自哪些功能区。一种方法是将所有采样点按功能区分组计算各组综合指数的总和然后绘制饼图展示各区的“总污染负荷”占比。7.1 准备饼图数据# 计算各功能区综合污染指数的总和 pollution_by_area df_metals.groupby(功能区)[综合污染指数].sum().sort_values(ascendingFalse) print(各功能区总污染负荷) print(pollution_by_area) # 计算百分比 pollution_percentage (pollution_by_area / pollution_by_area.sum() * 100).round(1) print(\n各功能区污染负荷占比(%)) print(pollution_percentage)7.2 绘制基础饼图plt.figure(figsize(9, 9)) # 绘制饼图 # autopct: 显示百分比格式 # startangle: 起始角度90度表示从12点钟方向开始 wedges, texts, autotexts plt.pie(pollution_by_area.values, labelspollution_by_area.index, autopct%1.1f%%, startangle90, colorsplt.cm.Set3(np.arange(len(pollution_by_area))/len(pollution_by_area))) # 使用Set3色图 # 美化字体 for autotext in autotexts: autotext.set_color(black) autotext.set_fontsize(11) autotext.set_fontweight(bold) plt.title(各功能区总污染负荷占比, fontsize16, fontweightbold, pad20) plt.show()这张图已经能说明问题但我们可以做得更专业、更突出。7.3 绘制强调重点的爆炸式饼图假设我们想强调“工业区”的贡献可以将其扇形略微分离。# 创建“爆炸”距离数组对应每个扇形 explode [0, 0.1, 0, 0, 0] # 只“炸开”第二个扇形工业区 # 确保explode顺序与数据顺序一致 sorted_areas pollution_by_area.index.tolist() if 工业区 in sorted_areas: idx sorted_areas.index(工业区) explode [0] * len(sorted_areas) explode[idx] 0.1 plt.figure(figsize(10, 10)) wedges, texts, autotexts plt.pie(pollution_by_area.values, explodeexplode, labelspollution_by_area.index, autopctlambda pct: f{pct:.1f}%\n({pct*sum(pollution_by_area.values)/100:.0f}), startangle120, shadowTrue, # 添加阴影 colorsplt.cm.Paired(np.arange(len(pollution_by_area)))) # 使用Paired色图 # 进一步美化文本 plt.setp(autotexts, size10, weightbold, colordarkred) plt.setp(texts, size11) # 添加图例并放到图表外侧 plt.legend(wedges, pollution_by_area.index, title功能区, loccenter left, bbox_to_anchor(1, 0, 0.5, 1)) # 将图例放在图表右侧 plt.title(各功能区总污染负荷占比突出工业区, fontsize16, fontweightbold, pad20) plt.tight_layout() # 自动调整布局为图例留出空间 plt.show()在这段代码中我们做了几处优化explode参数使“工业区”的扇形分离视觉上得到强调。autopct使用了自定义函数同时显示百分比和实际数值信息更丰富。shadowTrue增加了立体感。使用了更美观的Paired颜色映射。将图例放置在图表右侧避免了与标签的重叠使图表更清晰。注意事项饼图适用于展示部分与整体的比例关系且部分数量不宜过多通常不超过6-7个。如果类别太多饼图会显得杂乱此时考虑使用条形图柱状图会更合适。另外当各部分比例相差不大时饼图是有效的如果某个部分占比超过50%饼图也能很好体现。Matplotlib的饼图默认不会自动显示百分比必须通过autopct参数设置。8. 完整流程串联与代码封装我们将上述所有步骤整合到一个连贯的、可复用的分析流程中并封装成函数方便应用于类似的数据集。def analyze_soil_pollution_data(df_raw, metal_list, area_col功能区): 对土壤重金属污染数据进行完整分析。 参数 df_raw: 原始DataFrame包含功能区列和多种金属浓度列。 metal_list: 重金属列名的列表。 area_col: 功能区列的名称。 返回 包含多个结果的字典。 results {} # 1. 数据安全复制与准备 df_work df_raw[[area_col] metal_list].copy() results[df_work] df_work # 2. 分类统计平均值 area_mean df_work.groupby(area_col)[metal_list].mean().round(3) results[area_mean] area_mean # 3. 计算综合污染指数行方向计算 background df_work[metal_list].mean() pollution_idx df_work[metal_list].div(background, axiscolumns).mean(axis1).round(3) df_work[综合污染指数] pollution_idx results[background] background results[df_with_index] df_work # 4. 按功能区汇总污染负荷 total_pollution_by_area df_work.groupby(area_col)[综合污染指数].sum().sort_values(ascendingFalse) results[total_pollution_by_area] total_pollution_by_area # 5. 统计功能区类别 area_stats { unique_areas: df_work[area_col].unique().tolist(), num_areas: df_work[area_col].nunique(), area_counts: df_work[area_col].value_counts().to_dict() } results[area_stats] area_stats return results # 使用函数进行分析 metal_cols [铅(Pb), 镉(Cd), 铬(Cr), 汞(Hg), 砷(As), 铜(Cu), 锌(Zn), 镍(Ni)] analysis_results analyze_soil_pollution_data(df_raw, metal_cols, 功能区) # 打印部分结果 print(各功能区平均浓度) print(analysis_results[area_mean]) print(\n背景浓度值) print(analysis_results[background]) print(\n各功能区总污染负荷) print(analysis_results[total_pollution_by_area]) print(\n功能区统计信息) print(f唯一值列表{analysis_results[area_stats][unique_areas]}) print(f个数{analysis_results[area_stats][num_areas]})这个函数将数据复制、分组统计、行计算、汇总和基本统计全部打包返回一个包含所有中间结果和最终结果的字典。这种结构化的输出非常有利于后续的进一步分析或报告生成。9. 常见问题与排查技巧实录在实际操作中你几乎一定会遇到下面这些问题。这里记录了我的排查思路和解决方法。9.1 数据读取与编码问题问题用pd.read_csv()读取数据时出现UnicodeDecodeError或者中文显示为乱码。排查这通常是因为文件保存的编码格式与read_csv默认的utf-8不一致。常见的编码还有gbk、gb2312、latin1等。解决# 尝试不同的编码 try: df pd.read_csv(data.csv, encodingutf-8) except UnicodeDecodeError: try: df pd.read_csv(data.csv, encodinggbk) except UnicodeDecodeError: df pd.read_csv(data.csv, encodinglatin1)更稳妥的方法是用文本编辑器如VS Code、Notepad打开CSV文件查看其编码格式然后在read_csv中明确指定。9.2 Groupby操作后数据结构混乱问题执行groupby().mean()后得到的DataFrame的列变成了多层索引MultiIndex不方便后续操作。排查当你对多列进行聚合且聚合函数不止一个时就会产生多层列索引。解决# 聚合前如果只需要单列 area_pb_mean df.groupby(功能区)[铅(Pb)].mean() # 聚合后如果列是多层索引可以将其压平 summary df.groupby(功能区)[metal_cols].agg([mean, std]) summary.columns [_.join(col).strip() for col in summary.columns.values] # 或者直接选取你需要的层级 pb_mean summary[(铅(Pb), mean)]9.3 饼图标签重叠或显示不全问题当饼图分区较多或百分比很小时标签和百分比文字会挤在一起难以辨认。排查默认的标签布局算法可能无法处理复杂情况。解决使用图例代替扇区标签这是最清晰的方法。设置labelsNone然后通过plt.legend()添加图例如7.3节所示。调整字体和位置通过textprops参数调整标签字体大小或手动调整autotexts和texts的位置较复杂。过滤小扇区将占比小于某个阈值如2%的扇区合并为“其他”类别。threshold 2.0 # 百分比阈值 pollution_by_area_filtered pollution_by_area[pollution_percentage threshold] other_sum pollution_by_area[pollution_percentage threshold].sum() if other_sum 0: pollution_by_area_filtered[其他] other_sum # 然后用过滤后的数据画图9.4 行计算axis1速度慢问题当DataFrame行数很大几十万以上时使用df.apply(func, axis1)进行行计算会异常缓慢。排查apply(axis1)是逐行操作的在Python层面循环效率低下。解决尽量使用向量化操作。例如计算综合指数时我们用的是df[metal_cols].div(background, axis‘columns’).mean(axis1)这完全是pandas/numpy的向量化计算比apply快几个数量级。如果计算逻辑确实复杂无法向量化可以考虑使用numba加速或者将数据转换为numpy数组后用np.apply_along_axis但后者提升有限。终极方案是重新审视计算逻辑看能否用矩阵运算表达。9.5 图表中文字体显示为方框问题在Matplotlib图表中中文字符显示为小方框。排查系统缺少中文字体或Matplotlib没有使用中文字体。解决最一劳永逸的方法是配置Matplotlib的字体。import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei, Microsoft YaHei, DejaVu Sans] # 指定默认字体 plt.rcParams[axes.unicode_minus] False # 解决负号‘-’显示为方块的问题确保你的系统中已安装这些字体如SimHei是黑体。在Jupyter中这段配置代码通常需要放在绘图代码之前或者写在单独的配置文件中。整个流程走下来从数据的安全复制、分类聚合、行列计算到最终的可视化我们不仅完成了一个具体的数据分析案例更掌握了一套应对类似问题的通用方法和避坑指南。数据处理的核心在于对工具pandas内在逻辑的理解和对数据本身结构的洞察而可视化则是将这种洞察转化为直观故事的最后一环。记住多动手试错多看文档遇到报错把错误信息完整地复制出来去搜索你解决问题的能力会增长得飞快。