1. 为什么非学Pandas不可:原生Python处理数据的真实痛点
先聊一个我经常遇到的场景。假设你手上有一份销售记录,只有三列:城市、日期、销售额。数据结构不难,就是一行一行的记录。用Python原生写法,你可能会写一个循环,按城市累加,最后再算一个平均值。这种写法本身没错,但一旦数据量从几十行涨到几十万行,循环的效率会立刻让你难受起来。更麻烦的是,当你需要按多列分组、再拼接、再补缺失值,原生列表和字典的组合就会迅速变得难以维护。
我第一次真正意识到这个问题,是在处理一份上百行的人员信息表时。当时的写法是两层嵌套循环加两个字典,代码写了一屏半,调试了差不多一个小时。后来用Pandas重写,同样的逻辑只花了五行,跑得还更快。从那以后,我的个人观点是:在Python生态里做数据分析,绕开Pandas几乎是不现实的。
Pandas是Python数据分析领域最基础也最核心的库,它的两个主力数据结构——Series和DataFrame,分别解决了一维数据和二维表格数据的建模问题。Series可以理解成一个带名字的序列,DataFrame则可以理解成一张带列名和行索引的表格。几乎所有后续的数据清洗、筛选、聚合、可视化,都是在这两个结构上展开的。这篇文章不做面面俱到的文档罗列,而是从创建到高频基本操作,把Series和DataFrame最常用、最容易被卡住的点一次讲清楚。
适合读这篇内容的人,是那种已经能写简单Python脚本、但对Pandas还处于"看过文档但用不溜"状态的初学者。如果你连Python基础都不太熟,我会尽量用生活化的说法解释每个概念,你可以边看边敲代码。准备好了就直接往下走,所有的代码示例都是在Python 3.8以上版本、Pandas 1.5以上版本的环境里实测过的。
2. Series底层逻辑:从创建到索引的每一个细节
2.1 Series的三种常规创建方式
Series是Pandas里的一维数据结构,它比Python自带的列表或者字典多了一层"标签"的概念。最直白的理解是:Series = 一列数据 + 每个数据对应的行号。这里的行号不一定是0、1、2这样的位置序号,它可以是任意字符串或者数字,统称为索引。
第一种创建方式最简单,直接传入列表:
import pandas as pd s1 = pd.Series([85, 92, 78]) print(s1)输出会是:
0 85 1 92 2 78 dtype: int64你没传索引,Pandas会默认给0到n-1的位置序号。这看起来和列表没区别,但Series真正的威力来自自定义索引。传入一个index参数:
s1 = pd.Series([85, 92, 78], index=["小明", "小红", "小刚"], name="数学成绩") print(s1)这次输出变成了:
小明 85 小红 92 小刚 78 Name: 数学成绩, dtype: int64这里的name参数是给整个序列起的名字,打印的时候会显示在最后一行,将来放进DataFrame时也会自动变成列名。很多初学者会忽略这个参数,等到后面要从Series构造DataFrame时才回头补,其实一开始就养成设置name的习惯会很省事。
第二种创建方式是从字典创建。Pandas会把字典的键作为索引,把值作为数据本身:
s2 = pd.Series({"杭州": 1500, "成都": 2200, "青岛": 1800}) print(s2)这里我强调的是:当索引顺序对你不重要时,字典创建是最省心的方式。但是要注意,在Python 3.7之前字典是无序的,版本升级后字典保持插入顺序,所以Series的顺序会对应字典的插入顺序而不是字母顺序。如果你对顺序有要求,还是老老实实把键列表传进index参数。
第三种创建方式是从标量创建。传入一个数字和一组索引,Pandas会把这个数字重复填充到每个索引位置上:
s3 = pd.Series(0, index=["一月", "二月", "三月"]) print(s3)这种写法通常用于预分配内存或者初始化默认值,比如准备一个全是0的月份序列,后面再逐月赋值。这三种方式里,列表加自定义索引、字典创建这两种用得最多,标量创建属于特定场景的小技巧。
2.2 索引对齐:Series最容易被忽略的特征
如果说Series有一个必须深刻理解的特性,那一定是索引对齐。什么叫对齐?就是两个Series在做加减乘除时,Pandas不按位置顺序运算,而是按索引标签配对运算。这个特性我第一次用的时候没有意识到,结果得到了很多NaN,排查了半天才明白过来。
看个例子:
s1 = pd.Series([100, 200], index=["A", "B"]) s2 = pd.Series([30, 40], index=["B", "A"]) print(s1 + s2)如果按位置来算,101+30和201+40才叫"按位置相加"。但Pandas的加法输出是:
A 140 B 230 dtype: int64它把索引A对应s1的100和s2的40相加,索引B对应s1的200和s2的30相加,而不是按位置顺序相加。在所有常规运算中,比如加法、减法、比较操作,Pandas默认都是这种按索引对齐的行为。
这个设计有好有坏。好的方面是,它能让来自不同顺序、不同来源的数据自动匹配,比如两个表都有一列"工号",但它们行顺序完全不同,你不需要手动重排,直接拿对应的列做运算就会匹配上。坏的方面是,如果两个Series的索引标签有交集但不完全一致,运算结果的索引会是两者的并集,不匹配的位置会被填成NaN。
s3 = pd.Series([100, 200], index=["A", "B"]) s4 = pd.Series([30, 40], index=["B", "C"]) print(s3 + s4)结果是:
A NaN B 230.0 C NaN dtype: float64索引B两边都有,正常相加;A和C只有一边有值,另一边缺失,结果是NaN。这就是对齐运算产生NaN最常见的场景之一。如果你在数据处理时发现多出一堆NaN,先别急着认为是数据本身脏,检查一下是不是索引没对齐。这个习惯能帮你省下大量的排查时间。
2.3 loc与iloc:两种索引方式的选择规则
Series的索引提取有两条路线,很多人一开始会被它们搞混。loc是标签索引,按索引的名字取值;iloc是位置索引,按排在第几位取值。两者用法在写法上非常相似,但含义完全不同。
s = pd.Series([10, 20, 30], index=["x", "y", "z"]) print(s.loc["y"]) # 输出20,按名字 print(s.iloc[1]) # 输出20,按位置只要理解了"标签"和"位置"这两个概念的区别,这一对用法就永远不会弄混。还有一点值得提一下:如果你给Series设置的是0、1、2这样的默认索引,那么s.loc[1]和s.iloc[1]在正常情况下都返回同一个值,这就更容易让人误以为两种写法等价。一旦你后来把索引改成了字符串或者其他不连续的数字,这种错觉就会立刻造成错误。所以我的建议是:日常养成明确区分两者的习惯,凡是想取"名字叫xx的那一行",就用loc;凡是想取"排在第x位的那一行",就用iloc。
Series还支持切片,比如s.iloc[1:3]表示取第1、2个位置的元素,s.loc["y":"z"]表示取y到z的范围。要注意的是,loc切片是闭区间,会包含结尾标签;而iloc切片和Python列表切片一致,是左闭右开,不会包含结束位置。这个差异也是新手经常踩的坑。如果你记不住,就在动手前先想清楚自己用的是哪种索引方式。
3. DataFrame的七种创建姿势与适用场景
3.1 从字典列表创建:数据科学家的默认姿势
DataFrame是Pandas里最常见的二维数据结构。如果非要用一句话描述,我会说:DataFrame = 多列Series拼成的表格。每一列其实就是一个Series,列名就是Series的name,行索引就是Series的索引。把这句话刻在脑子里,后面很多操作都会豁然开朗。
创建DataFrame的方式很多,但实际工作中大约七成情况都用同一种:从字典列表创建。所谓"字典列表",就是一个列表,里面每个元素都是一个字典,字典的键是列名,值是这一行的数据。
df = pd.DataFrame([ {"姓名": "张三", "城市": "杭州", "销售额": 1500}, {"姓名": "李四", "城市": "成都", "销售额": 2200}, {"姓名": "王五", "城市": "杭州", "销售额": 1800}, ]) print(df)输出是一张规整的表格,三行三列。Pandas会自动完成两件事:把字典的键汇总成列名,把每一行的值按字典的键放入对应位置。如果一个字典缺少其他字典有的键,那这个位置的取值就是NaN。
这种创建方式最贴近"一行一条记录"的日常认知,数据源如果是从接口或爬虫拿到的JSON格式,往往就是这种结构,可以直接塞进DataFrame,几乎不用做额外转换。我个人的习惯是:只要数据是逐条记录的格式,一律优先用字典列表,因为可读性好、出错时也好定位。
3.2 从Series字典创建与索引并集问题
第二种常见创建方式,是从Series字典创建。这里说的"字典"是:键是列名,值是一整个Series。
math_scores = pd.Series({"张三": 88, "李四": 92, "王五": 76}) english_scores = pd.Series({"张三": 71, "王五": 84, "李四": 90}) df = pd.DataFrame({ "数学": math_scores, "英语": english_scores, }) print(df)这个例子中,两个Series的索引顺序不一样。张三、李四、王五的出场顺序两边是不同的,但Pandas会自动按索引对齐,所以生成的DataFrame长这样:
数学 英语 张三 88 71 李四 92 90 王五 76 84行索引是张三、李四、王五的并集,列是数学、英语。这个特性在拼接不同来源的数据时特别方便,你不必手动统一顺序。但反过来也要小心:如果两个Series的索引不是同一套体系,比如一个是数字,一个是字符串,并集的结果就会包括所有索引,缺失值满天飞。比如:
s1 = pd.Series({"A": 1, "B": 2}) s2 = pd.Series({1: 3, 2: 4}) df = pd.DataFrame({"col1": s1, "col2": s2}) print(df)行索引会变成[A, B, 1, 2],col2只有1和2有值,col1只有A和B有值。这个现象新手看起来很奇怪,但本质就是索引对齐规则在DataFrame层面的体现。遇到这种情况,先停下来看看两个Series的索引类型是否统一,再决定是做索引映射还是重新构造数据。
3.3 从二维列表和文件中读入:快捷路径与隐藏陷阱
从二维列表创建DataFrame也很常见,尤其在临时处理一些手头数据时。写法是传入一个嵌套列表,再用columns参数指定列名:
data = [ ["张三", 88], ["李四", 92], ] df = pd.DataFrame(data, columns=["姓名", "分数"]) print(df)如果你不传columns,Pandas默认会用0、1、2这样的数字当列名。我强烈建议随手把columns参数写好,否则后面写df["0"]这种代码,可读性极差,而且很容易和索引位置混淆。从二维列表创建主要适合数据量小、列名明确、行顺序已经整理好的场景,比如手工录入的表格。
真正工作中最多的创建方式其实不是手工构造,而是从文件读取。其中CSV尤其常见:
df = pd.read_csv("sales.csv", encoding="utf-8")read_csv是整个Pandas里参数最丰富的函数之一,我挑几个高频参数说一下。sep用来指定分隔符,默认是逗号,如果是制表符就传sep="\t";encoding用来处理中文乱码,日常使用utf-8,如果读出来是乱码,试试gbk或者gb18030;parse_dates可以把某一列解析成日期类型,比如parse_dates=["日期"];index_col可以把某一列设为行索引,比如index_col="工号"。
Excel读取也是一样的思路:
df = pd.read_excel("data.xlsx", sheet_name="Sheet1")不过要注意,读Excel需要安装额外的库。Pandas本身不会自带操作Excel文件的能力,读xlsx需要openpyxl,读xls需要xlrd,缺了会直接报ImportError。很多初学者在这一步卡住,其实提示信息已经写得明明白白,装上相应的库就能解决。文件读取这块,我的建议是:能导成CSV就优先用CSV,文件格式简单、读取速度快、跨平台兼容性好。Excel适合给别人看,CSV适合给程序处理。
4. 日常使用频率最高的DataFrame操作:视图、筛选、增删列
4.1 先看懂DataFrame的三层结构
拿到一个DataFrame,我建议你先做三件事:看前五行、看行列数和看列名与数据类型。
df.head(5) # 看前5行 df.shape # 返回(行数, 列数) df.columns # 看所有列名 df.dtypes # 看每列的数据类型这套组合拳可以在几秒内让你了解数据的基本面貌。shape返回的是一个元组,第一个元素是行数,第二个是列数,这个顺序别搞反,因为下面用shape[0]和shape[1]时会直接影响到代码逻辑。dtypes尤其重要——某列看起来是数字但实际是字符串,很多时候就是从这里暴露出来的。字符串列在数据计算时不会报错,但会产生一堆诡异的拼接结果,比如"1"+"2"得到"12"。养成拿到数据先看dtypes的习惯,能避掉很多后面才出现的隐性错误。
还有一个常见需求是查看统计信息:df.describe()会对数值列给出计数、均值、标准差、最小值、四分位数、最大值。这对快速判断有没有异常值非常有用。比如某列的最大值和均值的差距大到离谱,就要怀疑是否存在极端异常点。我会把head、shape、dtypes、describe这四件套称为"数据体检四件套",每次拿到新数据先做一遍,再开始写业务逻辑。
4.2 筛选与切片:方括号什么时候能驾驭
DataFrame的筛选操作可能是新手最先接触、也最早产生混淆的环节。方括号直接选列是最容易理解的:
df["姓名"] # 返回一个Series df[["姓名", "销售额"]] # 返回一个DataFrame注意单列和双列的区别。单个列名用字符串,结果是一个Series;多个列名要用一个列表包起来,结果才是DataFrame。很多人刚学会,觉得"反正是选列,单双都行",然后写df[["姓名"]]时发现返回的是DataFrame,不自觉地开始compare几个方法,其实只需要记住这层包装关系就行。
行筛选则有三种主流方式。第一种是按行标签用loc:
df.loc[1] # 取行索引为1的那一行,返回Series df.loc[1:3] # 取行索引1到3(闭区间)第二种是按位置用iloc:
df.iloc[1] # 取第1行的数据 df.iloc[1:3] # 取第1、2行的数据(不包含3)再次强调那个闭区间和开区间的差异。loc是标签切片,1到3会把1、2、3全取出来;iloc是位置切片,1到3只取位置1和2。
第三种是布尔筛选,这是实际项目里用得最多的:
df[df["销售额"] > 1000]这个写法的执行逻辑是:先计算df["销售额"] > 1000,得到一个由True和False组成的布尔序列,然后把这个布尔序列传给DataFrame的方括号,只保留True对应的行。如果想加多条件,踩坑率最高的地方就来了——多个条件之间不能用Python的and或or,要用&和|,并且每个条件都要用括号包起来:
df[(df["销售额"] > 1000) & (df["城市"] == "杭州")]为什么不能用and?因为and在做布尔判断时要求两边的对象都能转换成True或False,而Series的布尔值是一个数组,没法直接转成单个布尔值,所以会报ValueError。这个报错信息我见过无数次,几乎每个初学Pandas的人都会碰到一次。&和|则是逐元素的逻辑运算,返回一个等长的布尔序列,符合筛选需求。如果你能把这个背后的原因理解了,以后再遇到类似报错就不会慌,而是想着"哦,这是个数组运算,不能用Python关键字"。
4.3 添加列与删除列的正确步骤
添加列的方法很直接,直接用一个新列名赋值即可:
df["利润率"] = df["销售额"] * 0.2这个操作会为DataFrame增加一列,右边可以是一个标量、一个列表或者一个Series。如果右边是Series,会按照索引对齐规则匹配行,而不是按位置匹配。我遇到过一种情况:想添加一列,右边Series的索引顺序和DataFrame不一致,但索引标签能对上有交集,结果数据被重新排列了,看起来"错位"其实是对的。如果你希望按位置强行赋值,方法是把Series转换成列表:df["新列"] = s.tolist()。
删除列有三种常见方式。第一种是drop方法:
df = df.drop("利润率", axis=1)这里的axis=1是关键。我当年学的时候老是忘axis到底是1还是0,后来是这样记的:axis=0代表行方向,往下走;axis=1代表列方向,往右走。要删列,就是沿着列方向做操作,所以是axis=1。如果你写axis=0,它会试图去删一个叫"利润率"的行,结果就是KeyError。
drop默认不修改原对象,而是返回一个新的DataFrame。所以写法上要接收返回值,或者用inplace=True。个人建议优先接收返回值,因为链式写法更清晰,也少一些不可预期的影响。第二种删除方式是del关键字:del df["利润率"],直接改原对象。第三种是pop:df.pop("利润率"),会把这一列取出来并删掉,适合你想保留删除列的数据继续用的场景。这三种方式的差别和适用场景,我用一个表整理了一下:
| 方式 | 是否修改原对象 | 是否能保留被删数据 | 典型场景 |
|---|---|---|---|
| df = df.drop(列, axis=1) | 否 | 否 | 最通用、可链式调用 |
| del df[列] | 是 | 否 | 简单粗暴删列 |
| df.pop(列) | 是 | 是 | 删列并取出该列复用 |
4.4 重命名列名与调整列顺序
重命名列名是清洗数据时常用的一步。当你从文件里读出来的标头是"Name"、"Age",而你的业务习惯是"姓名"、"年龄"时,rename就是最好的工具:
df = df.rename(columns={"Name": "姓名", "Age": "年龄"})rename也遵循不修改原对象的规则,记得接收返回值。columns参数接受一个字典,键是旧列名,值是新列名。如果你想把所有列名统一改成大写或者小写,可以用df.columns = [col.strip() for col in df.columns]这种列表推导式,直接赋值给columns属性也是一种修改方式,它会原地生效。
调整列顺序也是常见需求。DataFrame的列顺序就是columns这个列表的顺序,所以调整列顺序其实只是在重排columns:
df = df[["城市", "姓名", "销售额"]]用一个新的列名列表去切数据框,列就会按这个顺序排出来。如果你只想把某一列挪到最前面,可以写:
first_col = "姓名" df = df[[first_col] + [c for c in df.columns if c != first_col]]这种写法不需要依赖任何高级API,纯粹利用列表操作就能完成。有些教程会推荐sort_index之类的方法,但我觉得最简单的方括号重排反而最直观,也最容易理解和维护。
5. 数据清洗三板斧:缺失值、重复值、类型转换
5.1 缺失值的识别、统计与处理策略
数据清洗是Pandas的绝对主场。大多数真实数据拿到手里,第一件事不是分析,而是清理。清理的第一步永远是识别缺失值。
df.isnull().sum()isnull()会把整个DataFrame映射成True/False,True表示该位置是空值,sum()按列统计True的个数,也就是每列的缺失值数量。运行结果一目了然,哪列有多缺、哪列没有缺,全部暴露出来。与之配套的是notnull(),它是isnull的反向操作。
识别完之后,怎么处理缺失值主要看场景。两种最常用的方案:dropna和fillna。
df = df.dropna() # 删掉任何包含缺失值的行 df_clean = df.dropna(subset=["销售额"]) # 只根据某列判断,该列有缺失才删除dropna默认是把含有任何一个NaN的行整个删掉。这个行为有时过于激进,比如100列里只有1列某个单元格缺失,结果整行没了,数据损失太大。所以实际使用中我很少直接df.dropna()而是加subset参数,指定按哪些关键列判断是否删除。缺失率低的列可以容忍,缺失率高的列就可以考虑整列删除,比如df.dropna(axis=1, thresh=100)表示保留非空值数量超过100的列。这种阈值控制方式在处理宽表时非常实用。
fillna是另一种思路:把缺失值填充成某个值,而不是删除整行。
df["城市"] = df["城市"].fillna("未知") df["销售额"] = df["销售额"].fillna(df["销售额"].median())分类型列通常填充众数或一个明确的占位符,数值型列可以使用均值或中位数。我自己更偏向中位数而非均值,因为中位数对异常值不敏感。比如某城市销售额最大的一笔是10万,其他都是几十到几百,均值会被拉高很多,而中位数能反映一般水平。填充时要注意一点,fillna也是一次性返回新对象的行为,所以你赋值给新变量或者别忘接收。
什么时候该删、什么时候该填,没有绝对标准。我的经验是:如果缺失值占比极小(比如1%以内),删除是最省事的;如果关键字段缺失但记录本身有价值,优先填充;如果你要做模型训练,填充方式要谨慎,因为"未知"这个类别可能会影响模型的分布判断。处理缺失值波及的面很大,但它本质上就是一个取舍题,核心逻辑是尽量减少数据损失。
5.2 重复值的识别与去重技巧
重复值在真实数据里出现的频率比你想象的高得多,尤其是从多个人工录入的表格合并而来时。识别重复值的标准做法是duplicated:
df.duplicated().sum()这里的duplicated()判断的是整行是否与前面的行完全重复。如果只想按某几列判断,比如按姓名和城市判断是否重复,就传subset参数:
df.duplicated(subset=["姓名", "城市"]).sum()去重操作是drop_duplicates:
df = df.drop_duplicates(subset=["姓名", "城市"], keep="first")keep参数有三种取值:first表示保留第一条出现的记录,last表示保留最后一条,False表示把重复的记录全部删掉。日常业务中,keep="first"最常用。这里要特别提醒的是duplicated和drop_duplicates的默认行为都是整行完全重复,如果你传入了subset,它们的语义就变成了"按这几列的值判断重复"。两个函数在参数风格上保持一致,但很多人会忘了在duplicated里面写subset,导致识别结果对不上。
重复值的判断有一个隐蔽陷阱:NaN参与判断时,两个NaN也会被认为是重复的。比如两行数据姓名都是NaN,城市都是"杭州",按城市和姓名判断重复时,它们是重复的。这个问题见仁见智,但从业务角度讲,缺失值导致的"重复"往往不是真正的重复记录,可能是两条不同的人因为某个字段缺失而已。处理这种数据时,可以先填充再判断,或者加一个能区分的字段一起判断。
5.3 数据类型转换:astype、to_datetime与分类变量的处理
类型转换是数据清洗里最容易被忽略、却最影响后续分析的一环。最常见的需求是把一个数字型列转换成浮点数或整数:
df["销售额"] = df["销售额"].astype(float)如果把字符串"1500"转换成整数,astype(int)通常也能工作,但会遇到一个问题:字符串里一旦混入了"1500元"这种带单位的写法,astype就会直接抛异常。更稳健的做法是先用字符串替换把多余字符去掉,再astype。这就是为什么很多清洗代码里都会包含str.replace和astype连用的写法。
日期字符串转成日期类型也是一个高频操作。如果你从Excel或者爬虫里拿到的日期列实际上是"2024-01-15"这样的字符串,用to_datetime能直接解析:
df["日期"] = pd.to_datetime(df["日期"])转换之后,列的类型变为datetime64,你才能通过df["日期"].dt.year拿到年份、dt.month拿到月份,做时间范围筛选、按月聚合都会方便许多。to_datetime的errors参数也很常用:errors="coerce"表示把无法解析的日期转成NaT(时间缺失值),而不是直接报错。我处理脏数据时几乎总是开着coerce,因为这样一步到位,之后再结合isnull对解析失败的行单独处理。
分类变量也是值得一提的类型。Pandas里有专门的category类型,当你有一列重复度极高的字符串,比如"城市"列只有杭州、成都、青岛三个值,但数据有十万行,把它转成category类型能显著节省内存,某些聚合操作也会更快:
df["城市"] = df["城市"].astype("category")不过category类型在参与字符串操作时偶尔会遇到一些细微的不便,比如str访问器某些方法在category上表现不同。所以我的建议是:小数据量的场景没必要特地去转category,内存还没紧张到那个程度;大数据量或做机器学习特征工程时,再考虑这一步。
6. 新手最容易踩的五个坑:一次讲透
6.1 SettingWithCopyWarning:链式赋值的警告
这个警告应该是Pandas新手遇到频率最高的一个。典型触发场景是这样:
df2 = df[df["销售额"] > 1000] df2["标记"] = 1第二行代码运行后,你会看到类似"SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame"的提示。这个提示实际上是Pandas在告诉你:你正在修改的可能是一个副本,而不是原数据框,修改结果可能不生效,也可能影响原数据,行为具有不确定性。
我初学的时候直接把这个警告忽略了,后来发现标记列写了一堆,原数据却纹丝不动,彻底被坑了一次。正确做法是:不想改原数据,就用.copy()显式创建副本;想改原数据,就不要先筛选再赋值,而是直接用loc在筛选条件下设置:
df2 = df[df["销售额"] > 1000].copy() df2["标记"] = 1或者:
df.loc[df["销售额"] > 1000, "标记"] = 1第二种写法是Pandas官方强烈推荐的。它的含义是:在df中,找到销售额>1000的行,把标记列设为1。整个过程只动一次df,没有中间副本,也就没有模糊之处。以后看到SettingWithCopyWarning,不要想着怎么关闭警告,而是回头想想自己的赋值逻辑是不是应该换用loc。
6.2 行索引不唯一时,loc返回的不再是标量
默认情况下,DataFrame的行索引是0到n-1的唯一整数。但一旦你用index_col指定了某个业务字段作为索引,或者手工构造了带重复索引的DataFrame,索引可能就不唯一了。
比如这样一份数据:
df = pd.DataFrame({ "城市": ["杭州", "成都", "杭州", "青岛"], "销售额": [1500, 2200, 1800, 1600], }, index=["a", "b", "a", "c"])此时df.loc["a"]返回的是两行数据的DataFrame,而不是一个Series。很多人在这里会惊讶:明明是同一个写法,为什么之前返回Series,现在返回DataFrame?原因就是索引标签有重复。只要索引不唯一,Pandas就无法确定你要的是哪一行,只能把所有的a行全部返回。
这个行为在很多场景下反而是有用的,比如你想一次性取出所有杭州的数据,只要把城市列做成索引,df.loc["杭州"]就能拿到所有匹配行。但如果你习惯性地取行后接着做类似row["销售额"]这样的操作,就可能报错,因为row本身变成了DataFrame。遇到这种问题,检查索引是否唯一是第一位的:df.index.is_unique可以直接判断。如果确实需要唯一索引,用df.reset_index()重置,或者新增一个唯一的ID列。
6.3 drop、fillna、astype默认不修改原对象
我想强调的第三个坑,是所有Pandas方法调用时对原对象的态度。大部分Pandas方法,比如drop、fillna、rename、astype、sort_values,默认都返回新对象,不改变原DataFrame。这就导致了一种经典错误:
df.drop("销售额", axis=1) # 运行完发现df还是老样子你确实执行了drop,但返回值没有接收,原df没被修改,所以"删除"没有发生。Pandas这样设计是有意的,它能避免方法调用时意外破坏原数据,让代码更容易调试和链式书写。但代价就是新手极易忘记接收。
解决方案清晰的两种:一是用变量接收返回值:df = df.drop(...);二是设置inplace=True。我个人的风格是尽量不依赖inplace,因为inplace在Pandas内部某些方法上仍然有性能上的微小差别,而且链式写法中它很难用。如果你已经习惯接收新对象,那这套逻辑统一起来之后,代码会不容易出bug。
6.4 条件筛选的&与|陷阱
这个坑其实在4.2节已经详细说过,我这里再补一个实战提醒。多条件筛选时,不仅要用&和|,还要注意每个条件都必须用括号括起来:
df[(df["城市"] == "杭州") | (df["城市"] == "成都")]如果把括号去掉,写成df[df["城市"] == "杭州" | df["城市"] == "成都"],Python会因为运算符优先级问题报错。这个优先级问题很隐蔽:|的优先级低于==吗?实际上在Python中,比较运算符(==)的优先级高于位运算符(|),所以df["城市"] == "杭州" | df["城市"] == "成都"会先运算==右边的,结果整个表达式变成不可预测的东西。简单粗暴的解决就是永远加括号,不要试图去背优先级的细节。
另一种等价的写法是用isin:
df[df["城市"].isin(["杭州", "成都"])]当你要判断的候选值不止两三个时,isin比一长串|条件简洁得多,也不容易错。类似的取反操作可以用~:
df[~df["城市"].isin(["杭州", "成都"])]~就是按位取反,相当于"不在这些城市中"。这种写法在业务代码里非常常见,建议直接用。
6.5 read_csv读出来的列名带空格、中文乱码
从文件读取数据的坑相对冷门,但一旦遇到会很头痛。最常见的是CSV文件标头列名带空格,比如"姓名 "和"姓名"其实是两列。打印df.columns时你看到的是带空格的字符串,肉眼不容易发现,但代码里写df["姓名"]就会KeyError,因为真正列名是"姓名 "。解决办法有两个:读取时指定skipinitialspace=True,自动去除分隔符前后的空格;或者读取后用列表推导式清洗列名:
df.columns = [col.strip() for col in df.columns]中文乱码的通用解决思路是调整encoding参数。utf-8不行就试gbk,gbk不行试试gb18030,再不行可以把encoding="utf-8-sig"。utf-8-sig专门用来处理带BOM的文件,这种文件在某些Windows环境下导出CSV时很常见,直接utf-8解析会出来一个隐藏在第一个列名前的字符。每次遇到乱码,先看文件本身的编码,再看Pandas的encoding参数是否匹配,来回试几次绝大多数都能解决。
7. 用一个真实场景串起所有操作
讲了这么多基础操作,最后我想用一个连贯的小案例,把前面所有内容串起来看一遍。模拟场景:你拿到一个销售明细CSV,里面列有姓名、城市、日期、销售额,文件名sales.csv。你要做的事是:读入数据、体检数据、清洗缺失和重复、筛选出指定城市的记录、按城市聚合、最后输出一份整洁的汇总表。
第一步,读入并体检:
import pandas as pd df = pd.read_csv("sales.csv", encoding="utf-8") print(df.head(5)) print(df.shape) print(df.dtypes) print(df.isnull().sum())假设体检结果发现:日期的dtype是object,说明是字符串;销售额有3个缺失值;城市列没有缺失,但可能有的城市是"杭州 "这种带空格的写法。
第二步,清洗:
df["销售额"] = df["销售额"].astype(float) df["日期"] = pd.to_datetime(df["日期"], errors="coerce") df["城市"] = df["城市"].str.strip() df = df.dropna(subset=["销售额", "日期"]) df = df.drop_duplicates(subset=["姓名", "日期"], keep="first")第三步,筛选加聚合:
df_sub = df[df["城市"].isin(["杭州", "成都"])] result = df_sub.groupby("城市")["销售额"].agg(["sum", "mean", "count"]) print(result)这里的groupby是Pandas里最常用的分组聚合操作,它按城市分组,对销售额分别计算总和、均值、数量。输出就是一张按行索引为城市名、列为聚合指标的表格。
如果你想把结果保存成新的CSV:
result.to_csv("city_summary.csv", encoding="utf-8-sig")整个流程下来,你会看到:一开始原始数据可能有一堆问题,经过体检、清洗、筛选、聚合,最后得到一张可以直接给业务方看的干净汇总表。全程代码量大概二十多行,如果完全用原生Python实现同样逻辑,至少要多写两三倍,还要自己处理各种边界情况。这就是Pandas的价值所在。
我在实际项目里的体会是,学Pandas最忌讳死记API,最好的方式是带着数据问题去敲代码。一个命令敲不熟就反复敲,敲到条件反射为止。Series和DataFrame的基础操作看起来多,但常用的就那么几个,把loc、iloc、布尔筛选、dropna、fillna、groupby这六件事练熟,你已经能覆盖绝大多数日常分析工作。以后再遇到更高级的主题,比如merge、pivot_table、rolling窗口计算,也都是在这些基本功之上叠加而已。你可以把这篇内容当成自己的第一份速查手册,看完后马上找一份真实的表格跑一遍第七节的流程,遇到不理解的再翻回来。这样过一遍,比看十遍文档都管用。