
这两年我见过太多从 Excel 转过来的分析需求大家上手 Pandas 的速度其实都很快但用着用着就发现自己写的代码越来越像在“用 Python 翻译 SQL 和 Excel 操作”。明明数据量不大跑起来却越来越慢明明改了某个子集的值回头一看原表没动明明只是按条件筛个数据控制台却飘出一大串看不懂的警告。这套《精通 Pandas》第二版系列的第一篇我想先把最容易被忽略、但决定后期效率的东西讲透数据结构怎么理解、数据怎么读进来、怎么在动手清洗之前摸清底细以及最常见的几个坑到底是怎么踩上去的。适合已经会写df[df[列名] 10]这类基础代码、但还没有形成自己方法论的同学也适合想系统查漏补缺的从业者。这一篇讲完你再看后面的分组聚合、合并拼接、时间序列这些进阶主题会顺畅很多。1. 为什么“精通”两个字值得较真而不是靠背接口1.1 大多数 Pandas 代码只停留在“翻译 SQL”阶段很多拆解过业务的人都有这种体验用惯了 SQL 的SELECT、WHERE、GROUP BY上手 Pandas 时会觉得哪哪都很像于是很自然地开始逐行对应WHERE写成df[df[列] 值]GROUP BY写成df.groupby(列).sum()JOIN写成pd.merge()。语法层面确实没问题但这只是把一件事从一种工具搬到了另一种工具里并没有真正发挥 Pandas 的价值。Pandas 的核心价值是“内存里的向量化操作和索引对齐”而不是“把 SQL 语法换个外壳”。这两者的差别在数据量小的时候完全看不出来一旦数据量上了百万行或者要把多个文件拼在一起做分析写法的差异会被无限放大。比如遍历每一行去改值、频繁重置索引、反复切片又赋值这些操作在老手手里基本都不会出现。1.2 进阶学习最该先想清的三件事不少人在学习第二版资料时习惯把所有函数从头到尾背一遍。背完applymap忘记map背完pivot_table忘记groupby。我的建议是在啃接口之前先想清楚三件事我的数据是几维的用 Series 够不够还是必须 DataFrame我现在最需要的是筛选、清洗、聚合还是拼接这次操作是产生了一个新对象还是直接改掉了原对象这三件事想明白了函数只是工具索引。df.groupby(日期).agg({金额: sum, 单号: nunique})这种写法本质是“按日期分组然后对不同列执行不同聚合”理解了agg接字典的机制再复杂的需求也能推导出来而不是靠死记。1.3 环境与版本认知先到位第二版资料默认读者已经能跑通import pandas as pd但我还是想多说一句版本问题。Pandas 2.x 相比 1.x最大的变化在于对复制写入机制Copy-on-Write的推进以及一些历史遗留的inplace行为逐步收紧。我现在的习惯是能用链式写法就用链式写法能不写inplaceTrue就不写全部重新赋值给新变量。这样代码更清晰也能避开很多隐式副作用。提示如果还在用 1.x 的老代码直接迁移到 2.x建议先跑一遍测试数据重点看fillna(methodffill)这类被弃用的写法有没有提示。旧写法在新版里还能跑但警告已经说明它活不长了。2. 先把 Series 和 DataFrame 在脑子里画出来2.1 Series 就是“带标签的一列数据”很多人提到 Series 会直接把它想象成“一维数组”这个说法对但漏了最重要的关键词标签。Series 的每一个数值都挂在一个 index 上就像 Excel 里某一列数据带着左侧的行号。区别在于Pandas 的 index 不一定是 0、1、2、3它可以是字符串可以是时间甚至可以是不连续的整数。这个设计带来的直接好处是索引对齐。两个 Series 做加法时Pandas 不是按位置逐行相加而是按 index 自动匹配。举个最简单的例子import pandas as pd s1 pd.Series([10, 20, 30], index[a, b, c]) s2 pd.Series([1, 2, 3], index[b, c, d]) print(s1 s2)结果里a行是缺失值因为s2里没有a这个标签b、c正常相加d行也是缺失值因为s1里没有d。这一行为对身处 Excel 世界的人来讲非常反直觉但它是深入理解后面merge、join、groupby的基础。索引就像人的身份证号对上了才能操作。2.2 DataFrame 是多个 Series 共享一个索引DataFrame 可以理解为“一组共享行索引的 Series 的集合”。每一列都是一个 Series列名由columns管理行索引由index管理。很多人在初期会把注意力全放在列上忽略index的存在结果到后面一groupby、一排序index 就变得乱七八糟。我自己的习惯是加载数据后第一件事就是确认df.index是什么。如果它只是 0 到 N-1 的整数说明数据没有额外索引如果它是某个业务单号或者日期那就要小心后续操作会不会因为索引对齐而出问题。有一个非常常见的翻车场景df1的索引是乱序的df2的索引是reset_index()过的然后做df1[列] df2[列]结果 Pandas 会把df2里的值按索引重新对齐后再塞到df1里而不是“按位置逐行塞进去”。一旦索引不是排序有序的数据就会错位得一塌糊涂而且很难发现。遇到这种情况请在赋值前先reset_index(dropTrue)或者确认两边索引一致。2.3 视图和副本新手最容易在这个地方翻车Pandas 给人一种错觉df2 df1[df1[金额] 100]之后df2就是“取出来的一个独立表”。严格来说它可能是一个视图view也可能是副本copy到底取决于底层内存是否发生了复制。更麻烦的是不同版本、不同操作的结果还不完全一样。所以在正式分析里我的原则只有一条如果要修改df2先.copy()。df2 df[df[金额] 100].copy() df2[备注] 大额这样你就彻底摆脱了对视图/副本机制的猜测。你修改df2时永远不用担心回头改掉了原始数据也不用担心df2的修改不生效。这个习惯多花的时间约等于零但能省下大量排查问题的时间。3. 数据加载read_csv 参数一次配到位3.1 从最常用的 read_csv 开始但别忽略数据类型真实的业务数据很少是干干净净的 CSV常见的问题包括空值多种多样空字符串、NULL、\N、日期列是字符串、金额列被读成对象、列名带 BOM 等。处理这些问题的正确位置是读取阶段而不是读取后再慢慢擦屁股。import pandas as pd df pd.read_csv( orders.csv, dtype{ order_id: string, amount: float32, }, parse_dates[order_date], usecols[order_id, order_date, customer_id, amount, status], na_values[, NULL, \\N], )这里的关键参数usecols只读取需要的列。如果源文件有上百列但分析只需要五列别把全部数据搬进内存那不是分析是浪费内存。dtype提前指定列类型避免order_id被读成int64后面又因为出现字母而炸掉。parse_dates把日期字符串解析成时间类型后续才能方便做月份聚合、时间差计算。na_values把业务里常见的“假空值”提前映射成NaN统一清洗口径。我在实际项目中还经常用到infer_datetime_format相关设置不过在 Pandas 2.x 里直接传format给pd.to_datetime更靠谱。读取阶段就指定parse_dates[order_date]是不够快的如果日期格式是标准格式Pandas 会自动推断如果格式花哨最好读取后再用pd.to_datetime加format参数处理。3.2 大文件的读取思路能不读全量就不读全量当单个 CSV 有几百 MB 甚至几个 GB第一原则是减少内存占用。第二原则是分段处理。chunk_iter pd.read_csv( big_orders.csv, chunksize200_000, dtype{amount: float32}, parse_dates[order_date], ) total_amount 0.0 for chunk in chunk_iter: total_amount chunk[amount].sum() print(total_amount)chunksize会返回一个按行分块的迭代器每块是一个独立的 DataFrame逐块处理再汇总结果。这种方式特别适合“只需要聚合值不需要保留全量明细”的场景。内存峰值被压住代码也不复杂实测下来跑得很稳。如果数据本身需要反复筛选还可以考虑直接使用pyarrow引擎读取前提是安装对应的扩展库。在 Pandas 2.x 里enginepyarrow对大型 CSV 的读取速度有肉眼可见的改善但要注意它支持的数据类型和默认引擎不完全一致迁移前先用一小块数据做验证。3.3 其他数据源的读取提示Excelpd.read_excel(数据.xlsx, sheet_name明细, dtype{金额: float32})。注意如果一张表里混合了文本和数字Pandas 会退回对象类型后面还得再转一次。JSONpd.read_json对嵌套结构支持一般更复杂的嵌套最好先用 Python 的json库解析到能拍平成表的状态再塞给 DataFrame。数据库pd.read_sql(sql, engine)数据库查询本来就能做聚合的尽量在 SQL 里完成聚合别把全表拉到内存里再groupby。我接过好几次因为“图方便”把几千万行历史数据拉进 DataFrame 的项目机器当场卡死。注意读取任何外部数据后先df.info()看一眼。这一步能暴露大部分类型解析问题比你自己逐列print快得多。4. 数据体检动手之前先花五分钟看清楚4.1 info、describe、dtypes 的组合体检拿到一个 DataFrame我第一轮必做的检查有三个df.info(verboseTrue) df.describe(includeall) df.dtypesinfo能告诉我每一列的非空数量、类型和总行数一眼就能看出哪列空值多哪列类型不对。describe则会给出数值列的均值、分位数、标准差以及分类列的频次信息。includeall让它在有非数值列时也不报错。有人觉得print(df.head())就够了尤其在 Jupyter 里能直接看到表格。但数据显示的是“前几行”往往不能代表全貌。某个客户 ID 列前面看都是数字后面混进几个文本值的场景我见过太多次只有dtypes会诚实地告诉你它已经变成对象列了。4.2 空值、重复值、唯一值的一次性盘点清洗之前先做一次系统性盘点能避免“东一榔头西一棒槌”。print(df.isna().sum()) print(df.duplicated().sum()) print(df[status].value_counts(dropnaFalse))isna().sum()给出每一列缺失值的总数duplicated().sum()给出完全重复的行数value_counts(dropnaFalse)让你看清某个分类字段的分部情况特别是把NaN单独作为一个类别列出来这比直接dropna要有信息量得多。对于数值列我还会额外看df[金额].describe()以及df[金额].value_counts(bins10)后者能快速发现是否有异常大头。比如明明是客单价数据却出现了一个 9999999 的测试单这种问题在聚合之后才爆出来排查成本就高太多了。4.3 内存占用大部分人都没有检查的习惯df.memory_usage(deepTrue)能告诉你每一列实际占用多少字节以及整个 DataFrame 的总内存。很多人只知道“我的电脑内存不够”但不知道瓶颈到底在哪一列。最常见的内存杀手是对象列也就是被切成 Python 字符串的文本数据。如果一个分类列可以被转成category类型内存占用会有指数级的下降。df[status] df[status].astype(category)这个操作在你数据量大、重复值多时效果尤其明显。对于业务上已经明确只有几个取值的列如订单状态、渠道来源、是否退款大胆转掉。5. 数据清洗第一课类型、缺失值、列名5.1 列名处理rename 和 columns 的取舍列名里带空格、中文和大小写混乱是 Excel 时代留下的老毛病。处理列名的场景无非两种系统性重命名直接给df.columns赋值适合明显的一一对应。局部改名用df.rename(columns{旧名: 新名})清晰且不会越界。df df.rename(columns{order_date: date, cust_id: customer_id})这里我特别强调要重新赋值。rename默认返回新对象如果你漏掉改名是不会生效的。虽然它也支持inplaceTrue但我前面说过新代码里我已经全面转向重新赋值。5.2 类型转换astype 与 to_datetime 的实战边界astype虽然便捷但它的容错能力很弱。如果一列里混进了几个无法解析的字符串astype(float)会直接抛异常。相比之下pd.to_numeric配合errorscoerce更为稳健df[amount] pd.to_numeric(df[amount], errorscoerce)被coerce掉的值会变成NaN之后你再统一处理缺失值即可。遇到日期字符串同样用pd.to_datetime加errorscoercedf[date] pd.to_datetime(df[date], errorscoerce)如果日期格式相对固定强烈建议加上format参数比如format%Y-%m-%d。这样解析速度会快很多而且只要有一个值对不上格式就会被转成NaN反而能帮你发现脏数据。errorscoerce不是用来掩盖问题的它是把“报错中断”变成“统一暴露”方便后面统计到底有多少解析失败。5.3 缺失值处理先分类再动手缺失值的处理没有银弹关键是先想明白每一类缺失值到底意味着什么。我把常见的处理方式分成三种删除整行缺失比例过高、或关键字段如订单号为空直接dropna。填充业务上存在明确含义的比如“备注为空”可以填成无备注。插值/前向填充时序数据里缺失值往往可以沿用最近的观测值。df df.dropna(subset[order_id, date]) # 关键字段留空就删 df[notes] df[notes].fillna(无备注) # 分类字段填业务值 df[amount] df[amount].fillna(0) # 金额缺失按 0 处理但要谨慎fillna(methodffill)在时序数据里有奇效但前提是数据确实有序。如果没排序就做前向填充等于用后面的值补前面的洞结果错得离谱。5.4 筛选与排序的常见姿势清洗完后df.loc是筛选的主力。big_orders df.loc[ (df[amount] 1000) (df[status].isin([paid, refund])) ].sort_values(amount, ascendingFalse)这里提醒一句多个条件必须用而不是and每个条件都要加括号。不要问为什么这是 Pandas 运算符重载的规则忘了括号就会得到一个意识流报错。排序时我会顺手决定是否需要重置索引如果是中间步骤保持原索引问题不大如果要导出或者后续合并提前reset_index(dropTrue)能省不少麻烦。6. 踩坑实录这些问题我基本每次都遇到6.1 SettingWithCopyWarning 到底在说什么这是 Pandas 新手最经典的一道坎。出现这个警告的本质是你在一行代码里同时做了“链式取数”和“赋值”而 Pandas 不确定你是在改原表还是改副本。# 这样写很容易触发警告 df[df[status] paid][remark] 已支付这种链式写法的执行路径是先筛出符合条件的行再从中取一列然后把值赋进去。问题在于第一步筛出来的对象可能是副本于是“改值”改到了一个不知道会不会写回原对象的临时副本上。正确做法是改用df.loc或者干脆提前.copy()。df.loc[df[status] paid, remark] 已支付loc的语义是明确的同时定位“哪一行”和“哪一列”然后统一赋新值。如果你确实只是想维护一个独立子表就先.copy()再去改。这个警告不是小问题它是 Pandas 提示你“你可能正在做一件自己都没意识到的事”。6.2 索引变成了一个奇怪的列groupby之后立刻to_csv你会看到第一列是分组列但没有列名或者列名是index。这个现象的本质是groupby默认把分组列放进了索引。monthly df.groupby(month, as_indexTrue).agg({amount: sum}).reset_index()如果忘记reset_index导出时要么把索引保留成第一列要么在to_csv时用indexFalse丢掉。业务上拿 CSV 去做报表的人看到莫名其妙的第一列会很困惑。我的经验是如果最终要导出给同事或 Excel在导出前统一reset_index(dropTrue)或as_indexFalse然后把to_csv的indexFalse写死永远不要让索引泄漏到文件里。6.3 时间数据出了幺蛾子最常见的坑有两个。第一个是时间列被读成了字符串手写df[date] 2024-01-01时能匹配上但想按月份聚合就抓瞎。第二个是 Excel 里的日期在 Python 里被读成了序列号比如 45000 这种数字用pd.to_datetime直接解析也会得到奇怪的结果。我现在的流程是所有时间列统一pd.to_datetime统一成datetime64类型然后立刻验证一下df[date].dt.year的取值范围。如果出现了 1969 或 2030 这种明显不在业务范围内的年份马上回头查数据源多半是序列号或者时区问题。6.4 编码问题中文字段名和 BOMread_csv读带中文的文件最常见的问题是列名第一个字前面多了一个看不见的\ufeff这是 UTF-8 BOM 在作怪。直接解决办法是读取时用encodingutf-8-sigpd.read_csv(中文数据.csv, encodingutf-8-sig)导出时如果目标受众用的是 Excel同样用utf-8-sig否则 Excel 打开 CSV 中文会乱码。df.to_csv(结果.csv, indexFalse, encodingutf-8-sig)如果你的系统里到处是 GBK 编码的旧文件那就按源文件的编码来读不要硬着头皮猜。拿open打开看前几行或者用工具检查编码再进read_csv。7. 串联起来跑一遍从原始文件到月度汇总7.1 一个可以复制的完整流程把前面所有知识点串起来以一个订单明细文件为例import pandas as pd df pd.read_csv( orders.csv, usecols[order_id, order_date, customer_id, amount, status], dtype{order_id: string, amount: float32}, parse_dates[order_date], na_values[, NULL, \\N], ) df df.dropna(subset[order_id, order_date]) df df.drop_duplicates(subset[order_id]) df[amount] pd.to_numeric(df[amount], errorscoerce) df[amount] df[amount].fillna(0) df[month] df[order_date].dt.to_period(M) monthly ( df.groupby(month, as_indexFalse) .agg( total_amount(amount, sum), order_count(order_id, nunique), ) .sort_values(month) ) monthly.to_csv(monthly_orders.csv, indexFalse, encodingutf-8-sig)流程就是四个字读入、清洗、聚合、导出。每一步做完都可以穿插df.shape或者df[列名].isna().sum()看一下中间结果。检查这一步不是可选项它是后面排查问题时的抓手。你自己写完代码一天之后再看能让你快速定位“数据是在哪一步开始变化的”就是这些print和shape。7.2 输出结果时的最后检查导出之前我还会再看一遍最终表的规模和列名print(monthly.shape) print(monthly.head())如果出现month列全是空、行数莫名翻倍、金额为负数等异常现在发现总比发出去之后发现好。对于这种小规模结果表to_csv是首选因为它是通用格式大家都能打开。如果要做继续分析也可以保留为parquet但那是后话。7.3 这篇之外的延伸这一篇没有展开讲merge、concat、pivot、resample这些高级话题但它们都建立在同一个基础上你理解了索引理解了 DataFrame 是按标签对齐的理解了你随时可能操作到一个副本。后面的所有复杂操作本质都是这套心智模型的组合。我个人在实际使用中的一个习惯是写完每条操作链优先保证“每一步重新赋值给新变量”。看起来多写了几行但排查问题时可以随时print任意一步的结果。数据工作最贵的不是代码行数而是排错的时间成本。把流程拆得足够透明你的数据鲁棒性会高一个台阶。