ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Python数据分析进阶:从数据清洗到pandas高效处理

2026/9/11 15:56:40 拓冰建站 浏览量
Python数据分析进阶:从数据清洗到pandas高效处理 1. 内容整体设计与思路拆解1.1 数据处理在数据分析流程中的真实位置接手过几个数据分析项目之后我对数据处理这件事的态度已经发生了彻底转变。刚开始那会儿总觉得跑模型、画图表才是数据分析的重头戏后来真正扎进项目里才发现一个任务中真正消耗时间的恰恰是那些看起来不起眼的整理、清洗、对齐工作。业界常说的“80%时间花在数据处理上”并不是夸张我自己的项目经历也差不多是这样数据从业务系统里导出来的时候字段名混乱、缺失值一堆、日期格式五花八门有的列明明是数值却被存成了字符串这些问题不解决后面任何分析都谈不上。所以这篇文章说的“Python 数据分析进阶数据处理”本质上是想帮大家补上从“会写代码”到“能干活”之间最关键的那一段。数据处理不只是在 pandas 里调用几个函数而是要建立起一套处理问题的框架拿到数据先看什么、遇到脏数据怎么判断、清洗到什么程度算达标、怎样让整个处理过程可复现。适合谁来看我建议这样定位你已经写过一些 Python 代码知道 list、dict、循环和函数的基本用法也大概了解 pandas 里有 DataFrame 这么个东西但真正拿到一份乱糟糟的数据时不知道从哪下手。又或者你自己在 Excel 里处理数据已经非常熟练但想切换到用代码来做因为数据量大了之后 Excel 真的力不从心且处理过程难以追溯。这篇文章不会去讲那些高大上的机器学习算法也不会给你堆一堆华而不实的图表它要解决的就是最接地气的问题一份原始数据摆在面前怎么把它变成你分析时真正需要的样子。1.2 “进阶”到底进阶在什么地方如果只是学习 pandas 的单个函数那不叫进阶。进阶和入门之间有一道分水岭就是你能不能把一个个孤立的操作串联成一条完整的处理流水线。入门阶段我们通常是学这个函数是干什么的、那个方法怎么用比如dropna()删缺失值、astype()改类型。但到了实际项目里问题从来不会按函数的边界来出现而是以“场景”的方式出现。比如运营给你一张订单表里面既有用户的基本信息又有下单时间和金额还有一些乱填的备注文本你要做月度复购分析那你的处理任务就是去重一个用户一天下了好几单算不算复购、时间字段规范化有人填的是时间戳有人填的是“20240101”这种字符串、金额异常判断0.01 元的订单要不要剔除、用户分层老客新客怎么定义。这些任务里每一个都可能涉及好几个函数而且它们之间存在先后依赖关系。因此“进阶”的核心在于建立一种面向任务的拆解思维拿到一个分析需求先把它转化成数据处理的任务清单再为每个任务选择合适的方法最后把方法串成一条可执行的流水线。代码能力只是基础真正的进阶是学会用数据处理的思维去抽象业务问题。这就是为什么这篇文章会用一个完整的问题拆解思路来组织内容而不是按照“pandas API 字典”的方式罗列函数。1.3 核心设计原则可复现、可追溯、防呆数据处理不是一次性行为。同一个报表可能每周都要跑同一个分析可能隔几个月就要重新验证如果每次都是用 Excel 手工点来点去一方面效率低另一方面特别容易出错。我自己就吃过这个亏有一回做渠道流量分析临时在 Excel 里手动筛了几个条件后面发现有个筛选条件写错了导致整周的数据结论都不可信前前后后返工了两三天。用 Python 做数据处理的核心价值在于三个词可复现、可追溯、防呆。可复现就是同样的脚本无论谁跑、什么时候跑只要输入不变输出就保持一致可追溯就是每一步做了什么清洗和变换代码里白纸黑字写得清清楚楚审计的时候能说清楚“这个字段为什么被删掉了”防呆就是通过代码把容易出现人为失误的地方固化下来比如类型转换失败了就主动报错而不是默默返回一个错得离谱的结果。这三个原则会贯穿整篇文章。后面的实操里我会刻意强调代码的写法如何支撑这些原则比如处理缺失值时先统计再决定策略而不是无脑删除类型转换时加了errors参数来控制异常行为分组聚合时把多个统计口径一次性算清楚避免反复跑同样的代码。2. 工具选型与运行环境2.1 为什么选 pandas 而不是其他方案讲数据处理工具选型绕不开。目前 Python 生态里做数据处理pandas 是绝对的主流这一点基本没有争议。有人可能会问为什么不用纯 Python 的列表和字典去处理因为纯 Python 处理表格型数据的代码量大、性能差、可读性也差。举个最简单的例子你想算一列数值的平均值用纯 Python 得写sum(col) / len(col)还得分情况处理空值用 pandas 一行df[col].mean()就解决了而且它对缺失值有默认的处理策略。也有人会问JSON 格式的数据能不能直接用 Python 的 json 库来处理当然可以但如果你要从 JSON 里抽出一批嵌套字段用 pandas 的json_normalize或pd.json_normalize要方便得多。还有人可能更习惯用 SQL觉得数据处理在数据库里做就完了。我的看法是SQL 在处理结构化数据时依然很强大但它做不到的事情也不少比如复杂的文本清洗、正则匹配、自定义函数的灵活调用这些在 pandas 里做起来更顺手。更关键的是在数据分析项目里数据往往来自多个源头CSV、Excel、数据库、API 接口pandas 可以一次性把它们读成统一的 DataFrame 结构这个跨源整合的能力是 SQL 单独做不到的。pandas 的两个核心数据结构是 Series 和 DataFrame它们的底层构建在 NumPy 数组之上因此做数值计算时有很高的效率。Series 可以理解成一个带标签的一维数组DataFrame 则是多个 Series 按列拼成的二维表格。只要掌握了这两个东西的操作方式你就掌握了 pandas 的大部分核心用法。2.2 环境准备别在这一步浪费太多时间很多新手在配置环境上花的时间比学数据处理本身还多这其实挺可惜的。我的建议很直接直接用 Anaconda 或 Miniconda 来装 Python 环境因为它会把 Python 解释器、pandas、NumPy、Jupyter Notebook 这些常用工具一次性安排明白省去很多环境依赖的麻烦。如果你已经有 Python 环境只需要在命令行里执行pip install pandas numpy如果是用 conda 管理环境执行conda install pandas numpy装完之后在 Python 里验证一下版本import pandas as pd import numpy as np print(pd.__version__) print(np.__version__)我这里用的是 pandas 2.x 版本后面所有代码示例都基于这个版本。如果你的版本还是 1.x大部分功能都是兼容的。编辑器方面我个人日常用得最多的是 VS Code 加 Jupyter 插件这样既能像写脚本一样管理代码文件又能在调试时一段一段地看中间结果。如果你习惯 PyCharm也可以核心操作是一样的创建一个项目把 Python 解释器指到你已经装好 pandas 的环境上。VS Code 里还有一个容易踩坑的地方你在命令行 pip install 成功了但编辑器里 import pandas 却报错原因往往是选择的解释器不是同一个环境里的 Python。解决方法是按 CtrlShiftP 打开命令面板输入 Python: Select Interpreter选择你装了 pandas 的那个环境。2.3 核心数据结构DataFrame 和 Series 的底层直觉在动手处理数据之前强烈建议先把 DataFrame 和 Series 的“心智模型”建立起来。Series 是一个带标签的一维数组它有两个核心部分组成索引index和数据values。当你在一个 DataFrame 上用df[列名]取出一列时得到的正是一个 Series。索引就是每一行的标签默认是 0 到 n-1 的整数但你也可以把它改成有意义的标签比如日期、用户 ID。DataFrame 则可以理解为一个由多个 Series 按列对齐组成的二维表格。它有行索引和列索引两层结构行索引标识每一行列名标识每一列。当我们说“处理一行”或“处理一列”时本质上就是在操作 DataFrame 的轴。为什么要理解这些东西因为 pandas 很多操作的底层逻辑都围绕索引对齐展开。比如你用两个 DataFrame 做算术运算它不是简单地把对应位置的数字相减而是会先按照索引和列名对齐再执行运算。如果索引对不上结果会出现 NaN。这个特性用好了很方便用不好就是一堆莫名其妙的缺失值。我在后面第 5 章讲常见问题时会专门提到索引对齐引发的坑。3. 核心细节解析与实操要点3.1 数据加载与初步探查拿到数据的第一步不是急着清洗而是先“摸底”。就好比你要整理一间屋子总得先看看屋里有什么东西、东西都放在哪儿才能谈得上收拾。假设我们有一份订单数据orders.csv字段包括order_id、user_id、order_date、amount、status。加载和探查的标准流程如下import pandas as pd # 读取数据 df pd.read_csv(orders.csv) # 看前几行感受一下数据结构 print(df.head()) # 看整体信息行数、列数、每列类型、非空数量 df.info() # 数值列的描述性统计 df.describe()head()默认显示前 5 行让你对长什么样有个直观感受。info()输出的价值非常大它能告诉你三件重要的事数据集的行数和列数、每一列的数据类型dtype、每一列有多少个非空值。比如order_date列如果类型是object那基本可以断定它是字符串后期要转成日期类型amount列如果非空数量明显小于总行数那这条列存在缺失值。describe()对数值列输出计数、均值、标准差、最小值、四分位数、最大值。做完这一步你对数据的数值分布就有了基本概念。比如amount的最大值是 999999但 75% 分位数只有 200那这个最大值大概率是异常值需要进一步核实。探查阶段还有一个很容易被忽略的动作看分类变量的分布。比如status字段有哪几种取值每种取值的数量是多少print(df[status].value_counts())这行代码能帮你发现数据里的脏问题比如 status 出现了已完成、已 完成、完成这些肉眼看着一样但实际不同的取值。这种问题在真实数据里非常常见后面清洗时就要专门处理。另一个探查动作是检查缺失值占比# 每一列缺失比例 missing_ratio df.isna().mean() print(missing_ratio)isna()会把 DataFrame 里每个元素映射为布尔值缺失为 Truemean()则算出每列缺失值的比例。如果某列缺失比例超过 50%你必须想清楚是继续用还是直接删掉这一列以及为什么。3.2 缺失值处理先判断原因再决定策略缺失值处理是数据清洗里最核心的环节之一。很多人习惯一上来就dropna()全删掉这是最容易偷懒但也最容易犯错的做法。正确姿势是先搞清楚缺失值是什么原因造成的再决定策略。缺失值一般分三种类型完全随机缺失样本是否缺失与其他变量无关纯粹是数据采集过程中的偶然情况随机缺失缺失与否与数据集里的其他变量有关系但和缺失变量本身无关非随机缺失缺失与否与变量自身有关比如低收入的用户更不愿意填收入字段。不同类型对应的处理策略不一样。不过对于一般的数据分析项目我们不太可能做严谨的缺失机制检验更多是靠业务经验来判断。我能给的建议是先看缺失比例再看缺失变量本身的重要性。举例来说user_id如果缺失了那这条订单记录基本没法做用户维度分析直接删掉是合理的但如果remarks备注字段大部分缺失这不能简单删除因为缺失可能本身就有信息量需要单独建一个“是否填写备注”的布尔特征。数值列如age的缺失可以考虑用中位数填充order_date的缺失则不应该用其他值填充因为时间信息是无法用统计值替代的。常见的填充代码如下# 删除 user_id 缺失的行 df df.dropna(subset[user_id]) # 用中位数填充 amount 的缺失值 df[amount] df[amount].fillna(df[amount].median()) # 数值列统一用该列的均值填充 df[age] df[age].fillna(df[age].mean())对于时间序列数据填充的方式会更讲究一些比如用前向填充methodffill或后向填充methodbfill。这意味着用上一行的值填当前行的缺失值这在传感器数据、股价数据里很常用df[value] df[value].fillna(methodffill)在 pandas 2.x 里fillna(methodffill)的写法还能用但官方更推荐新写法df[value] df[value].ffill()3.3 重复值与异常值处理学会辨别“脏”与“异常”重复值和异常值处理是两个容易混在一起的问题。重复值相对简单。订单数据里如果order_id是唯一标识那重复出现就属于数据采集或合并时导致的重复直接删除# 检测重复行 print(df.duplicated(subset[order_id]).sum()) # 删除重复订单保留第一次出现的记录 df df.drop_duplicates(subset[order_id], keepfirst)但要注意一种情况有些数据本身允许重复比如用户行为日志里一个用户一天可以有多条记录。如果只看某几个字段就判断重复很容易误删正常数据。所以subset参数一定要按业务逻辑来选不能全列默认去重。异常值的情况要复杂得多。数值分布里的极端值不一定就是错误有可能只是反映了真实业务里的长尾现象。比如电商订单里的千元订单相对于一般几十上百元的订单来说是异常值但它完全可能是真实的。我常用的异常值判断方法是四分位距法IQR。它的思路是先算出数据的第一四分位数Q125%和第三四分位数Q375%两者之差 Q3 - Q1 就是 IQR。然后设定一个合理区间一般下界是 Q1 - 1.5 * IQR上界是 Q3 1.5 * IQR落在区间外面的点视为异常。Q1 df[amount].quantile(0.25) Q3 df[amount].quantile(0.75) IQR Q3 - Q1 lower_bound Q1 - 1.5 * IQR upper_bound Q3 1.5 * IQR outliers df[(df[amount] lower_bound) | (df[amount] upper_bound)] print(f异常值数量: {len(outliers)}, 占比: {len(outliers) / len(df):.2%})找到异常值之后我一般先做一件事拿这些异常值对应的业务字段出来人工看一遍。如果发现订单里有amount0或极小负数这类数据通常是测试单或系统错误删除没有太大问题如果是价格极高但确实存在的订单那就不能随便删后续分析时单独讨论这类用户的行为。提示异常值处理的最高原则是“不能只看统计结果要结合业务判断”。统计工具只是帮你把可疑点找出来最终处理策略一定要有业务上的解释。3.4 类型转换与时间处理最常见的隐形坑类型问题非常隐蔽但破坏力极大。假设你从数据库导出数据user_id是以文本形式导出的如果你直接拿它做groupby或merge大概率不会报错因为 pandas 能对字符串做分组和对齐。但如果你把release_date这个日期列读成了字符串在info()里显示为 object然后你想算“下单到发货的间隔时间”就直接做减法结果就是报错。类型转换的三个常用操作# 数值转换遇到无法转换的设为 NaN df[amount] pd.to_numeric(df[amount], errorscoerce) # 字符串转日期 df[order_date] pd.to_datetime(df[order_date], errorscoerce) # 强制转换类型 df[user_id] df[user_id].astype(str)errorscoerce这个参数是一个很重要的保护机制遇到无法解析的内容时pandas 不会让程序崩溃而是把对应位置设为 NaN方便你后续统一排查。如果你不加这个参数好一点的版本会直接抛异常、中断整个程序而一旦加了你就能把异常值变成一个统一的“标记”后续可以单独检查哪些值转换失败了# 找出转换失败变成 NaN的行 failed df[df[order_date].isna()] print(failed.head())日期时间的处理是进阶阶段必须拿下的点。比如提取月份、计算间隔时间# 提取年份、月份 df[year] df[order_date].dt.year df[month] df[order_date].dt.month # 计算今天的间隔天数 now pd.Timestamp.now() df[days_since_order] (now - df[order_date]).dt.days注意df[order_date].dt这个访问器的使用方法它是 pandas 专门为 datetime 类型提供的接口。如果列不是 datetime 类型你根本没法访问.dt属性所以这一步在代码逻辑上也起到了类型校验的作用。4. 核心操作与变换实现4.1 apply、map、applymap 怎么选很多初学者分不清.apply()、.map()和.applymap()其实它们的定位很清楚.map()是 Series 的方法用于把一个 Series 里的每个元素映射成另一个值.apply()既能用在 Series 上也能用在 DataFrame 上接受一个函数可以对一行或一列做批量处理.applymap()是 DataFrame 的方法对 DataFrame 里的每个元素都应用一次函数。实际工作中用得最多的是.apply()。比如你要根据订单金额做用户分层def amount_level(amount): if amount 1000: return 高消费 elif amount 200: return 中消费 else: return 普通 df[amount_level] df[amount].apply(amount_level)再比如要对备注文本做清洗比如去除首尾空格、把全角逗号替换成半角def clean_text(text): if pd.isna(text): return text return str(text).strip().replace(, ,) df[remarks_clean] df[remarks].apply(clean_text).apply()在按行处理时要注意性能问题。如果数据量达到百万级尽量避免对每一行做特别复杂的操作可以考虑用向量化方式替代。比如上面的金额分层用np.select会更快import numpy as np conditions [ df[amount] 1000, df[amount] 200 ] choices [高消费, 中消费] df[amount_level] np.select(conditions, choices, default普通)np.select的思路是把条件和结果分别放进列表里它是一次性对整个数组做条件判断比 Python 层面的循环快得多。这也是我在处理大文件时反复使用的一个技巧。4.2 groupby 分组聚合从“拆分”到“组合”的完整逻辑groupby是 pandas 里最重要、也最能体现数据处理思维的操作。它的底层逻辑可以用三个词概括拆分split、应用apply、组合combine。拆分就是按某个或某几个列把数据分成多组应用就是对每一组做相同的操作比如求和、均值、计数组合就是把各组的结果汇集成一个新的 DataFrame。最基础的用法# 按状态分组计算每组订单数量和金额总和 result df.groupby(status).agg( order_count(order_id, count), total_amount(amount, sum), avg_amount(amount, mean) ) print(result)这里的agg接收一个字典或元组列表为每一列指定聚合函数。这个写法在 pandas 2.x 里已经是主流可读性很强order_count是新列名(order_id, count)表示对order_id列执行count聚合。如果想一次聚合多个函数可以传一个列表result df.groupby(status)[amount].agg([count, sum, mean, median])分组聚合有一个很容易操作失误的地方groupby之后分组列默认会变成结果索引。比如上面这个例子最终结果的索引是status。如果你希望把status当作普通列保留下来需要在groupby里设置as_indexFalseresult df.groupby(status, as_indexFalse)[amount].agg([count, sum, mean])多次实际操作之后我发现as_indexFalse通常更符合后续分析的需求因为它直接返回一张“平铺”的表格方便继续做筛选或与其他表合并。分组聚合也可以支持多个分组维度比如按月、按用户分层同时统计df[order_month] df[order_date].dt.to_period(M) monthly_stats df.groupby([order_month, amount_level], as_indexFalse)[amount].sum()这段代码先利用日期列生成一个“年-月”的周期类型列order_month再按月和消费层级两个维度组合分组求金额总和。最终结果是每个月中不同消费层级的金额贡献可以直接用于后续的月度趋势分析。4.3 数据重塑pivot_table 与 melt 的场景切换数据分析中最常见的两种表格形态是宽表wide format和长表long format。宽表里每一行是一个样本每个变量一列长表里每一行是一对“变量-取值”的组合。这两种形态各有用途很多机器学习模型需要宽表但做分组统计和画图比如 seaborn 或 matplotlib时长表往往更方便。pivot_table用于从长表变成宽表melt用于从宽表变成长表。假设我们有一张销售记录表sales字段为month、product、revenue这是长表结构# 变成宽表行是月份列是产品值是收入 wide sales.pivot_table( indexmonth, columnsproduct, valuesrevenue, aggfuncsum, fill_value0 )pivot_table里有一个容易被忽略但很重要的参数fill_value0。因为某些月份可能没有某个产品的销售记录透视后对应位置会是 NaN填成 0 在后面对比分析时更安全。反过来如果已经有宽表想把它还原成长表用来做多产品对比的可视化long wide.reset_index().melt( id_varsmonth, var_nameproduct, value_namerevenue )reset_index()把原先的行索引转成普通列monthmelt指定month作为标识变量把其余的产品列收纳成两列product和revenue。这两个操作在真实业务里非常常用尤其是当你从数据库导出好几张“列特别多”的宽表想统一整理成便于分析的长表格式时melt是绕不开的核心工具。4.4 多表合并与拼接merge 和 concat多表操作也是进阶数据处理绕不开的一环。你可能要关联用户表和订单表、把多个月份的销售文件拼在一起。merge对应的是数据库里的 JOIN 操作。它根据一个或多个共同的键把两张表横向拼接在一起。最基本的用法merged pd.merge( orders, users, onuser_id, howleft )这里的how参数控制连接方式常用取值有inner只保留两边匹配上的行left保留左表全部行右表匹配不上的填 NaNright保留右表全部行左表匹配不上的填 NaNouter保留两边的全部行。我日常工作里left用得最多。比如订单表有用户 ID用户表有用户的年龄和性别想给订单表补充这些字段就用左连接。但要警惕一个问题如果右表里一个用户 ID 对应多行合并后订单数量会成倍增长出现“数据爆炸”的情况。所以在merge前一定要确认你要关联的键在右表里是唯一的# 检查 user_id 是否唯一 print(users[user_id].is_unique)如果不唯一需要先做去重或者明确自己想要的是笛卡尔积的效果否则结果会非常怪异。concat则是做纵向拼接收用的。比如每个月的数据是单独一个 CSV要合成全年的数据df_list [] for month in [01, 02, 03]: temp pd.read_csv(fsales_{month}.csv) df_list.append(temp) all_sales pd.concat(df_list, ignore_indexTrue)ignore_indexTrue这个参数很关键它表示拼接后重新按顺序生成行索引而不是保留原文件里的索引。否则你可能会得到一张索引全部是 0 的混乱表。5. 常见问题与排查技巧实录5.1 日期列怎么读都变成字符串这个问题实在太常见了。最常见的原因是 CSV 里的日期格式不是标准格式比如2024/1/5、20240105、05-01-2024这些乱七八糟的写法导致 pandas 无法自动识别为日期类型。排查思路先用df[col].dtype看类型再用df[col].head()看具体值长什么样。如果确定是格式不统一先统一格式再转import pandas as pd df[date_raw] df[date_raw].astype(str).str.replace(/, -) df[date_parsed] pd.to_datetime(df[date_raw], format%Y-%m-%d, errorscoerce)format参数可以直接指定日期格式转换效率比让 pandas 自动推断更高。还有一个比较隐蔽的坑如果你的日期数据混入了看不见的空格astype(str).str.strip()先去空格再转换会更稳妥。5.2 SettingWithCopyWarning 警告从哪里来pandas 出现SettingWithCopyWarning是很常见的事它本质是在提醒你你操作的可能是原表的副本而不是原表本身修改可能不会生效。这个警告通常出现在先切片再赋值的写法里# 这种写法容易触发警告 df[df[amount] 100][is_high] 1上面这段代码的本意是给金额大于 100 的行加一个标记但df[df[amount] 100]得到的往往是一个视图或副本给它赋值可能不会真正写回原表。正确做法是使用.locdf.loc[df[amount] 100, is_high] 1避免这个问题的最好习惯是需要修改数据时每一步都单独赋值给一个新的变量并在一开始就显式地复制一份数据df_clean df.copy()很多老手遇到这个警告的第一反应是df df.copy()因为这能彻底断掉视图与副本的耦合问题。虽然每次复制会消耗一点内存但在大数据处理中这个开销通常是可以接受的它换来的安全性和可预测性更值钱。5.3 大文件读取卡死或者内存爆炸数据分析中处理上千万行的 CSV 并不奇怪。如果直接pd.read_csv(huge.csv)读取内存占用可能直接飙到几个 GB机器直接卡死。我的处理思路是分三步走。第一只读需要的列用usecols参数指定列名减少内存占用第二对文本列指定更紧凑的类型比如对“是/否”的列用category类型第三如果数据必须全量处理使用chunksize分块读取chunk_iter pd.read_csv(huge.csv, usecols[user_id, amount, order_date], chunksize100000) result_list [] for chunk in chunk_iter: # 对每一块做处理 chunk_result chunk.groupby(user_id, as_indexFalse)[amount].sum() result_list.append(chunk_result) final_result pd.concat(result_list, ignore_indexTrue)这种分块思路的巧妙之处在于每一块占用的内存是可控的处理完一块就把结果浓缩一次最后把各块的浓缩结果拼起来。它可以把一个原本“内存爆炸”的任务变成一个稳定可跑的任务唯一的代价是代码量多几行。提示如果分块处理之后发现结果依然很慢优先检查分组键的基数。groupby(user_id)的时候如果用户量极大且每个组的数据量也不小可以考虑先用筛选把无关数据剔除掉再分组。很多时候数据量下降一个数量级速度会快上好几倍。5.4 合并时数据量突然翻倍这个问题我在前面提过但值得单独再说一次。merge之后行数变多绝大多数情况下是因为关联键在右侧表里不唯一。曾经有一次我要给一个商品销售明细表补充商品类目信息和类目表合并以后我发现销售明细从 10 万行膨胀到了 80 万行当时第一反应是代码写错了后来排查发现是类目表里同一个商品 ID 对应了多条记录——因为不同仓库的商品属于不同类目导致一对多匹配。解决方法很简单合并之前检查关联键的唯一性如果确实存在一对多的情况先想清楚你要保留哪一条记录# 类目表按商品ID去重保留第一条 category_map category_table.drop_duplicates(subset[product_id], keepfirst) merged sales.merge(category_map, onproduct_id, howleft)在合并前先查看两个表的行数变化是排查这类问题最直接的手段。强烈建议在执行merge的前后打印一下行数print(f合并前: {len(sales)}, 合并后: {len(merged)})如果数据量变化不符合预期那就说明合并逻辑有问题不要急着继续下一步。写在最后做数据处理做久了我最大的感受是真正决定一个分析项目质量的往往不是模型的选型也不是图表的精美程度而是数据在进入分析之前有没有被认真地整理过。一个 Excel 公式拉出来、肉眼扫一遍就认为“数据没问题”的习惯曾经让我在项目里反复踩坑后来我彻底转向了脚本化、流程化的处理方式才慢慢把返工率降下来。最后再分享一个小技巧每次完成一个数据处理任务不要急着删代码把代码整理成一个带注释的脚本保存下来。下次遇到类似的需求直接复制改参数就能用效率和准确性都会翻倍。数据处理是一件很琐碎、很磨人的工作但它值得投入精力——你的分析结论靠不靠谱很大程度上都取决于这一步有没有做扎实。