在进入Pandas的“术”之前,我们必须先理解其“道”。任何伟大的工具,其设计背后都蕴含着深刻的哲学思想和对特定问题的终极解决方案。Pandas的诞生,并非为了取代NumPy,而是为了在其之上构建一个数据分析的“神殿”。
NumPy(Numerical Python)是Python科学计算的基石。它提供了一个核心对象:N维数组(ndarray
),这是一个高效的、存储同类型元素的多维容器。NumPy的强大在于其底层由C语言实现,使得向量化(vectorization)的数学和逻辑运算速度极快,避免了Python原生循环的性能瓶ăpadă。
然而,在真实世界的数据分析场景中,纯粹的数值计算只是冰山一角。我们面临的数据往往是“不完美”的:
ndarray
要求所有元素类型统一,虽然可以通过dtype=object
来存储不同类型的Python对象,但这会牺牲掉NumPy几乎所有的性能优势和内存效率。data[0, 4]
远不如data.loc['客户A', '销售额']
清晰。np.nan
(Not a Number)来表示浮点数数组中的缺失,但它无法存在于整数数组中。若要在整数数组中表示缺失,NumPy会将其强制转换为浮点数类型,这可能并非我们所愿。处理np.nan
的计算也需要专门的函数(如np.nansum
),常规的np.sum
会因为nan
的存在而返回nan
。Pandas正是为了解决以上所有痛点而生的。
Wes McKinney在创造Pandas时,其核心目标是为Python提供一个强大、灵活且直观的数据分析工具,尤其擅长处理结构化(表格化)数据。其设计哲学可以归结为以下几点:
标签是核心,而非元数据:在Pandas中,索引(Index)和列名(Columns)不再是可有可无的附属品,它们是数据结构不可分割的一部分。这个设计彻底改变了数据操作的方式。所有操作,尤其是算术运算和数据合并,都默认基于标签进行自动对齐。这是Pandas最强大、最核心的特性之一。
优雅地处理缺失数据:Pandas将np.nan
作为浮点数和对象类型数据的标准缺失值标记,并构建了一整套专门用于检测(isnull()
)、移除(dropna()
)和填充(fillna()
)缺失值的API。这使得数据清洗流程变得异常清晰和连贯。
性能与易用性的平衡:Pandas的底层大量依赖于NumPy。其核心数据存储在NumPy的ndarray
中,从而继承了NumPy卓越的计算性能。然而,Pandas在其上构建了一个丰富的、面向分析师的API层,将底层的复杂性封装起来,提供了极高的易用性。你不需要关心底层的C实现或内存布局,就能写出高效且可读性强的代码。
功能整合与生态系统:Pandas的目标是成为一个“瑞士军刀”,它将数据读取、清洗、转换、聚合、建模和可视化的整个流程整合在一个库中。从read_csv
到groupby
再到.plot()
,你可以在一个连贯的工作流中完成大部分数据分析任务。
为了理解Pandas为何能高效处理异构数据,我们需要窥探其内部的BlockManager
机制。一个DataFrame在用户看来是一个二维表格,但在Pandas内部,它并非一个单一的二维数组。
想象一个DataFrame,它有整数列、浮点数列和字符串列。如果强制用一个NumPy数组存储,只能使用dtype=object
,性能会很差。Pandas的解决方案是:按数据类型(dtype)对列进行分组,每个组是一个“块”(Block),每个块是一个NumPy数组。
IntBlock
: 存储所有int64
类型的列。FloatBlock
: 存储所有float64
类型的列。ObjectBlock
: 存储所有object
类型(通常是字符串)的列。BoolBlock
: 存储所有bool
类型的列。一个BlockManager
对象负责管理这些块。它知道哪些列属于哪个块,以及这些列在块中的位置。
# 导入pandas库,并通常约定俗成地将其简写为pd
import pandas as pd
# 导入numpy库,并通常约定俗成地将其简写为np
import numpy as np
# 创建一个包含多种数据类型的字典,用于构建DataFrame
# 'sensor_id' 是字符串,代表传感器ID
# 'timestamp' 是整数,代表Unix时间戳
# 'temperature' 是浮点数,代表温度读数
# 'is_active' 是布尔值,代表传感器是否激活
# 'reading_quality' 是整数,代表读数质量等级
synthetic_data = {
'sensor_id': ['A-01', 'B-02', 'A-01', 'C-03'], # 这是一个字符串(object)类型的列
'timestamp': [1672531200, 1672531201, 1672531202, 1672531203], # 这是一个整数(int)类型的列
'temperature': [25.5, np.nan, 26.1, 22.3], # 这是一个浮点数(float)类型的列,包含一个缺失值
'is_active': [True, False, True, True], # 这是一个布尔(bool)类型的列
'reading_quality': [95, 88, 97, 91] # 这是另一个整数(int)类型的列
}
# 使用字典创建DataFrame
# DataFrame是Pandas中最核心的二维数据结构
df_probe = pd.DataFrame(synthetic_data)
# 访问这个DataFrame的内部BlockManager(这是一个非公开API,仅用于教学和探索)
# `._data` 属性可以让我们接触到底层的BlockManager
manager = df_probe._data
# 打印出BlockManager管理的所有的块
# 这会显示Pandas是如何根据数据类型将列分组存储的
print("DataFrame内部的BlockManager管理的块:")
print(manager.blocks)
# 我们可以看到,Pandas自动将两列整数('timestamp', 'reading_quality')合并到一个IntBlock中进行管理
# 这极大地优化了内存使用和计算速度,因为对这两列的操作可以在一个统一的、类型化的NumPy数组上进行
# 其他类型的列,如float, object, bool,则各自存在于自己的Block中
这种设计的优势是什么?
int64
),而不需要像object
类型那样为每个数字都创建一个完整的Python对象。FloatBlock
上,直接在底层的NumPy数组上执行,速度极快。BlockManager
只需要调整其内部的块和映射关系,而不需要重建整个数据结构。理解BlockManager
的存在,有助于我们理解很多Pandas的行为。例如,为什么访问一行数据通常比访问一列数据要慢?因为一列数据很可能来自同一个块(同一个连续的内存区域),而一行数据则需要从多个不同的块中分别提取元素,然后重新组合起来。
在挥舞Pandas这把利器之前,我们需要确保工作环境已经准备就绪,并且了解如何对Pandas自身的行为进行微调,以适应不同的分析任务和展示需求。
Pandas库的安装通常有两种主流方式:通过Conda
(特别是Anaconda或Miniconda发行版)或Pip
(Python的官方包管理器)。
方式一:Conda(推荐用于数据科学)
Conda不仅仅是一个包管理器,它还是一个强大的环境管理器。对于数据科学而言,它的一大优势是能够处理非Python的依赖(例如,很多科学计算库底层依赖的C或Fortran库),确保整个分析环境的一致性和稳定性。
# 在终端或Anaconda Prompt中执行以下命令
# conda会负责下载Pandas以及其所有依赖的库,如NumPy, pytz等
# 它会分析当前环境中已有的包,解决复杂的版本冲突问题
conda install pandas
方式二:Pip
Pip是Python自带的包管理器,如果你没有使用Anaconda,或者在一个轻量级的虚拟环境中工作,Pip是标准的选择。
# 在终端或命令行中执行
# pip会从Python Package Index (PyPI)上下载Pandas的"wheel"文件并安装
pip install pandas
验证安装
安装完成后,必须验证其是否成功,并查看当前安装的版本。版本号在复现他人分析或排查问题时至关重要。
# 导入pandas库
import pandas as pd
# 使用__version__属性来检查已安装的Pandas版本号
# 这是一个非常重要的习惯,特别是在团队协作或部署代码时
# 确保所有人的环境版本一致,可以避免很多不必要的错误
version_number = pd.__version__
# 打印出版本信息
# f-string是一种现代且易读的字符串格式化方法
print(f"Pandas库已成功安装,当前版本为: {
version_number}")
# 我们可以进行一个最简单的操作来确认库的核心功能正常
# 创建一个最简单的Series对象,它是一维带标签的数组
# 这可以验证Pandas与底层NumPy的连接是否正常
s = pd.Series([1, 2, 3])
print("成功创建一个简单的Pandas Series,核心功能正常:")
print(s)
set_option
的妙用Pandas有很多全局配置项,可以控制其显示行为。这在Jupyter Notebook或交互式控制台中尤其有用,能极大地提升数据探索的体验。pd.set_option()
函数是控制这些设置的入口。
当DataFrame非常大时,Pandas默认只会显示开头和结尾的几行(以及中间的省略号...
),以避免刷屏。我们可以改变这个默认行为。
# 导入pandas和numpy库
import pandas as pd
import numpy as np
# 创建一个较大的DataFrame用于演示
# np.random.randn(120, 15) 会生成一个120行15列,符合标准正态分布的随机数矩阵
# 我们用range(120)作为行索引,用字符串'Col_X'作为列索引
large_df = pd.DataFrame(np.random.randn(120, 15),
columns=[f'Col_{
i}' for i in range(15)])
# 在默认设置下,打印这个大型DataFrame
print("--- 默认显示设置 ---")
print(large_df) # 你会看到中间大部分行和列都被省略了
# --- 自定义设置 ---
# 'display.max_rows' 控制最大显示的行数
# 设置为10,表示如果行数超过10,就进行折叠显示
pd.set_option('display.max_rows', 10)
# 'display.max_columns' 控制最大显示的列数
# 设置为8,表示如果列数超过8,就进行折叠显示
pd.set_option('display.max_columns', 8)
print("\n--- 自定义显示设置 (10行, 8列) ---")
print(large_df) # 现在你会看到一个更紧凑的预览
# 如果你想完整地查看所有行和列(请谨慎对非常大的数据集使用此选项)
# 可以将选项值设置为None
pd.set_option('display.max_rows', None)
pd.set_option('display.max_columns', None)
# print("\n--- 显示所有行和列 (请注意,这可能会产生大量输出) ---")
# print(large_df) # 取消此行注释来查看完整输出
# 恢复到Pandas的默认设置,这在完成特定查看任务后是一个好习惯
pd.reset_option('display.max_rows')
pd.reset_option('display.max_columns')
print(f"\n恢复默认设置后,max_rows: {
pd.get_option('display.max_rows')}") # 使用get_option获取当前设置
有时列中的内容(如长字符串)被截断,或者浮点数的小数位数太多,影响可读性。
# 导入pandas库
import pandas as pd
import numpy as np
# 创建一个包含长文本和高精度浮点数的数据
data_for_formatting = {
'Mission_Log': [
"System check nominal. All thrusters responding to minor adjustments.", # 长字符串
"Anomaly detected in sector Gamma-7. High energy particle flux observed.", # 长字符串
"Course correction executed. Now en route to exoplanet Kepler-186f." # 长字符串
],
'Fuel_Ratio': [np.pi, np.e, 1/7] # 高精度浮点数
}
df_format = pd.DataFrame(data_for_formatting)
print("--- 默认格式化输出 ---")
print(df_format) # 注意长文本可能被截断,浮点数小数位很多
# 'display.max_colwidth' 控制每列的最大宽度(单位:字符)
# 设置为30,超过30个字符的内容将被截断
pd.set_option('display.max_colwidth', 30)
# 'display.precision' 控制浮点数显示的小数位数
# 设置为3,所有浮点数将四舍五入到3位小数
pd.set_option('display.precision', 3)
print("\n--- 自定义格式化输出 (列宽30, 精度3) ---")
print(df_format)
# 恢复默认设置
pd.reset_option('all') # 'all'可以一次性重置所有修改过的选项
print("\n--- 已重置所有显示选项 ---")
熟练使用pd.set_option()
是专业数据分析师的标志之一。它允许你根据当前任务的需求,动态调整Pandas的输出,使得数据探索和报告呈现更加清晰、高效。
数据分析的起点,永远是获取数据。Pandas提供了一套强大且高度优化的I/O工具,能够轻松地从各种格式的文件中读取数据,并将处理后的结果写回磁盘。我们将以最常见、最重要的CSV文件作为起点,进行一次前所未有的深度探索。
pd.read_csv()
:不止于逗号分隔pd.read_csv()
是Pandas中使用频率最高的函数,没有之一。它的功能远比其名称所暗示的“读取逗号分隔值文件”要强大得多。它是一个能够解析各种结构化文本文件的瑞士军刀。我们将逐一剖析其核心参数,并为每个参数构建独特的、原创的场景。
想象我们正在分析一个来自深空探测器发回的、格式略显混乱的日志文件。这个文件记录了不同时间点、不同传感器的读数。我们将创建一个模拟的日志文件probe_log.txt
。
# 使用Python内置的文件操作来创建一个模拟的日志文件
# 'w'模式表示写入,如果文件已存在则会覆盖
# 使用with语句可以确保文件操作结束后自动关闭文件,是最佳实践
# 定义日志文件的内容
# 注意内容中包含了多种挑战:
# 1. 文件开头有注释行,需要跳过
# 2. 字段分隔符是'|',而不是逗号
# 3. 列名在文件的第3行
# 4. 存在缺失值,用'N/A'和'Error'表示
# 5. 日期格式是'YYYY-MM-DD',时间格式是'HH:MM:SS'
# 6. 文件末尾有总结性文字,需要忽略
log_content = """# Deep Space Probe "Odyssey" Log File
# Data transmitted from Sector 7G
# ============================================
Timestamp|SensorID|ReadingType|Value|Status
2023-10-27 10:00:00|TEMP-01|Temperature|35.2|OK
2023-10-27 10:00:05|PRES-01|Pressure|1.02|OK
2023-10-27 10:00:10|GRAV-01|Gravity|9.81|N/A
2023-10-27 10:01:00|TEMP-01|Temperature|35.3|OK
2023-10-27 10:01:05|PRES-01|Pressure|Error|Failure
2023-10-27 10:01:10|RAD-01|Radiation|0.57|OK
# ============================================
# End of transmission.
"""
# 将内容写入到名为'probe_log.txt'的文件中
with open('probe_log.txt', 'w', encoding='utf-8') as f:
f.write(log_content)
print("模拟日志文件 'probe_log.txt' 创建成功。")
现在,我们有了这个充满挑战的日志文件。让我们用read_csv
的各项参数来“驯服”它。
filepath_or_buffer
, sep
, header
, skiprows
filepath_or_buffer
: 指定文件路径。可以是本地路径、URL,或者任何带有read()
方法的对象。sep
(或 delimiter
): 指定字段之间的分隔符。skiprows
: 一个整数或列表,指定要跳过的行。header
: 指定哪一行作为列名。# 导入pandas
import pandas as pd
# 第一次尝试:使用默认设置读取,这将会失败
try:
# 尝试用默认参数读取,默认分隔符是逗号',',且没有跳过任何行
pd.read_csv('probe_log.txt')
except Exception as e:
# 捕获异常并打印,让我们知道哪里出了问题
print("--- 默认读取失败,错误信息: ---")
print(e)
# 失败是正常的,因为文件的格式和默认设置不匹配
# 第二次尝试:逐步加入正确的参数来解析文件
print("\n--- 使用核心参数进行正确解析 ---")
# filepath_or_buffer: 'probe_log.txt',我们的文件名
# sep: '|',因为我们的数据是用竖线分隔的
# skiprows: [0, 1, 2],我们想跳过开头的3行注释和装饰线
# header: 0,在跳过了3行之后,新的第0行(也就是原始文件的第4行)是我们的列名
df_log = pd.read_csv(
'probe_log.txt',
sep='|',
skiprows=[0, 1, 2],
header=0
)
# 打印读取到的DataFrame的前几行,以检查结果
# .head()方法默认显示前5行,是快速预览数据的常用方法
print(df_log.head())
我们看到数据的大致结构已经出来了,但底部还有一行不想要的# End of transmission.
。
skipfooter
, engine
skipfooter
: 从文件末尾跳过N行。engine
: 指定解析引擎。Pandas有两个主要引擎:'c'
(默认,速度快但功能略少)和'python'
(功能更全但速度较慢)。skipfooter
参数就需要python
引擎的支持。# 导入pandas
import pandas as pd
# 第三次尝试:加入skipfooter来清理文件末尾
print("\n--- 使用skipfooter清理文件末尾 ---")
# 我们需要添加 skipfooter=2 来跳过最后的装饰线和结束语
# 重要提示:使用skipfooter参数时,必须指定engine='python'
df_log_clean = pd.read_csv(
'probe_log.txt',
sep='|',
skiprows=[0, 1, 2],
header=0,
skipfooter=2, # 从文件末尾跳过2行
engine='python' # skipfooter需要python解析引擎
)
# 打印这次清洗后的结果
print(df_log_clean)
现在,我们得到了一个干净的、没有首尾无关信息的DataFrame。
dtype
, na_values
数据加载进来了,但它们的类型可能不正确,缺失值也没有被正确识别。
dtype
: 用字典指定每列的数据类型,可以极大地优化内存并防止后续计算出错。na_values
: 一个列表或字典,告诉Pandas哪些字符串应该被视作缺失值(NaN
)。# 导入pandas和numpy
import pandas as pd
import numpy as np
# 检查上一步骤df_log_clean的数据类型和缺失值情况
print("\n--- 清理后DataFrame的初始信息 ---")
# .info()方法提供了DataFrame的摘要信息,包括索引类型、列、非空值数量和内存使用情况
df_log_clean.info()
# 我们会发现'Value'列被识别为'object'类型,因为它里面混入了字符串'Error'
# 'Status'列也被识别为'object',我们希望将'N/A'识别为缺失值
# 第四次尝试:在读取时就处理好数据类型和缺失值
print("\n--- 在读取时指定数据类型和缺失值 ---")
df_final = pd.read_csv(
'probe_log.txt',
sep='|',
skiprows=[0, 1, 2],
header=0,
skipfooter=2,
engine='python',
# dtype参数可以精细控制每一列的数据类型
# 这对于内存优化和防止类型错误至关重要
dtype={
'SensorID': 'string', 'ReadingType': 'string', 'Value': float, 'Status': 'string'},
# na_values定义了哪些值在读取时应该被转换成标准的缺失值pd.NA或np.nan
na_values=['N/A', 'Error']
)
# 再次查看信息,确认我们的设置已生效
print("\n--- 最终版DataFrame的信息 ---")
df_final.info()
# 现在'Value'列是float64类型,并且原本'Error'的位置变成了非空值的减少
# 'Status'列中'N/A'的位置也被正确处理了
print("\n--- 最终版DataFrame内容 ---")
print(df_final)
# 查看数据,可以看到'Error'和'N/A'的位置都变成了NaN (Not a Number),这是Pandas标准的浮点数缺失值表示
通过这一系列操作,我们已经将一个格式混乱的文本文件,在读取的瞬间就转换成了一个类型正确、数据干净的DataFrame,为后续的分析打下了坚实的基础。
parse_dates
我们的Timestamp
列现在还是一个object
(字符串)。在数据分析中,时间序列数据需要被转换成专门的datetime
对象才能进行强大的时间相关的操作。
parse_dates
: 可以是一个列名列表,Pandas会自动尝试解析这些列为日期时间格式。# 导入pandas
import pandas as pd
# 第五次尝试:在读取时直接解析日期
print("\n--- 在读取时解析日期列 ---")
df_with_dates = pd.read_csv(
'probe_log.txt',
sep='|',
skiprows=[0, 1, 2],
header=0,
skipfooter=2,
engine='python',
dtype={
'SensorID': 'string', 'ReadingType': 'string', 'Value': float, 'Status': 'string'},
na_values=['N/A', 'Error'],
# parse_dates告诉Pandas,'Timestamp'列应该被当作日期时间来解析
parse_dates=['Timestamp']
)
print("\n--- 带日期解析的DataFrame信息 ---")
df_with_dates.info()
# 可以看到'Timestamp'列现在是'datetime64[ns]'类型了
print("\n--- 带日期解析的DataFrame内容 ---")
print(df_with_dates)
# 我们可以验证一下datetime类型的强大之处
# 例如,可以直接访问日期的特定部分,比如小时
print("\n提取'Timestamp'列的小时部分:")
print(df_with_dates['Timestamp'].dt.hour)
usecols
, names
有时,日志文件非常宽,包含几十上百个字段,但我们只对其中几个感兴趣。一次性把所有数据读入内存是巨大的浪费。
usecols
: 一个列表或可调用函数,指定需要读取哪些列。names
: 一个列表,用于在没有表头或想重命名表头时,直接提供列名。# 导入pandas
import pandas as pd
# 场景:我们只关心传感器的ID和它的读数,并且我们想自己定义列名
print("\n--- 只加载特定列并重命名 ---")
# skiprows=4 表示我们跳过前面所有元数据和原始表头
# names=['ID', 'Reading'] 我们提供新的列名
# usecols=['SensorID', 'Value'] 我们告诉Pandas,只需要原始文件中的这两列数据
# 注意:当同时使用names和usecols时,usecols引用的仍然是原始文件中的列名
df_selective = pd.read_csv(
'probe_log.txt',
sep='|',
skiprows=4, # 跳过所有注释和原始表头行
skipfooter=2,
engine='python',
header=None, # 文件中没有我们要用的header了,因为我们自己提供
names=['Probe_Identifier', 'Measurement_Value'], # 提供全新的列名
usecols=['SensorID', 'Value'], # 根据原始列名选择要加载的列
na_values=['N/A', 'Error']
)
print(df_selective)
这段代码展示了一个高级技巧:我们跳过了原始的头部,然后用names
提供了全新的列名,同时用usecols
(引用原始列名)来指定只加载我们感兴趣的数据。这是一个极其高效和灵活的数据提取方式。
chunksize
的威力当文件达到GB甚至TB级别时,一次性将其读入内存是不可能的。read_csv
的chunksize
参数是解决这个问题的终极武器。它不会一次性返回一个DataFrame,而是返回一个迭代器。每次迭代,你都会得到一个指定大小(chunksize
行)的DataFrame“块”。
场景:计算超大日志文件中每个传感器的平均读数
假设probe_log.txt
有数十亿行,我们无法将其全部加载。
# 我们先创建一个更大的模拟文件来进行演示
# 导入相关库
import pandas as pd
import numpy as np
# 定义文件名
large_log_filename = 'large_probe_log.txt'
# 定义文件头
large_log_header = "Timestamp|SensorID|Reading\n"
# 写入文件头
with open(large_log_filename, 'w') as f:
f.write(large_log_header)
# 定义一些传感器ID
sensor_ids = ['TEMP-A', 'TEMP-B', 'PRES-A', 'PRES-B']
# 我们要生成10万行数据来模拟一个“大”文件
num_rows_to_generate = 100000
# 定义每个数据块的大小
chunk_size_for_generation = 10000
print(f"开始生成模拟的大型日志文件: {
large_log_filename} ({
num_rows_to_generate}行)")
# 分块生成数据,避免一次性在内存中创建过大的列表
for i in range(0, num_rows_to_generate, chunk_size_for_generation):
# 生成随机数据
data_chunk = {
'Timestamp': pd.to_datetime(pd.Timestamp('2024-01-01') + pd.to_timedelta(np.arange(i, i + chunk_size_for_generation), unit='s')),
'SensorID': np.random.choice(sensor_ids, size=chunk_size_for_generation),
'Reading': np.random.uniform(10, 100, size=chunk_size_for_generation) + np.random.choice([0, 1], size=chunk_size_for_generation, p=[0.95, 0.05]) * np.nan # 随机加入5%的缺失值
}
# 创建临时的DataFrame
temp_df = pd.DataFrame(data_chunk)
# 将DataFrame追加到CSV文件中
# mode='a' 表示追加模式
# header=False 因为我们已经手动写入了头部
# index=False 我们不希望将DataFrame的索引写入文件
temp_df.to_csv(large_log_filename, mode='a', header=False, index=False, sep='|')
print("大型日志文件生成完毕。")
# --- 现在,我们使用 chunksize 来处理这个大文件 ---
print("\n开始分块处理大型日志文件...")
# 初始化一个空的Series来存储每个传感器的总读数
total_readings = pd.Series(dtype=float)
# 初始化一个空的Series来存储每个传感器的读数计数
reading_counts = pd.Series(dtype=int)
# 使用chunksize参数,read_csv会返回一个TextFileReader对象,这是一个迭代器
# 我们设置chunksize为5000,表示每次从文件中读取5000行数据进行处理
chunk_iterator = pd.read_csv(
large_log_filename,
sep='|',
chunksize=5000
)
# 遍历这个迭代器
# 在每次循环中,chunk_df都是一个包含5000行数据的DataFrame
for i, chunk_df in enumerate(chunk_iterator):
# 打印进度信息,让我们知道程序正在运行
print(f" 正在处理数据块 {
i+1}...")
# 对当前块进行分组计算
# groupby('SensorID')['Reading'] 按传感器ID分组,并选取'Reading'列
# .sum() 计算每个传感器在当前块中的读数总和
chunk_totals = chunk_df.groupby('SensorID')['Reading'].sum()
# .count() 计算每个传感器在当前块中的有效读数(非空值)数量
chunk_counts = chunk_df.groupby('SensorID')['Reading'].count()
# 将当前块的计算结果累加到总结果中
# .add() 方法在相加时会自动对齐索引(SensorID),并将缺失值视为0
total_readings = total_readings.add(chunk_totals, fill_value=0)
reading_counts = reading_counts.add(chunk_counts, fill_value=0)
# 所有块处理完毕后,计算最终的平均值
# 最终平均值 = 总读数之和 / 总读数计数
average_readings = total_readings / reading_counts
print("\n--- 分块计算完成 ---")
print("每个传感器的平均读数:")
print(average_readings)
这个例子完美地展示了chunksize
的强大之处。我们只用了非常少的内存,就完成了对一个远超内存容量的大文件的聚合计算。这是处理大数据集时不可或缺的核心技能。
df.to_csv()
:将智慧结晶存盘分析完成之后,我们需要将结果保存下来。to_csv()
是read_csv()
的逆操作,同样提供了丰富的选项来控制输出的格式。
场景:保存上述分析结果,并进行定制化输出
# 导入pandas
import pandas as pd
# 假设我们有之前处理好的df_with_dates这个DataFrame
# (为了代码独立性,我们在这里重新创建它)
log_data = {
'Timestamp': pd.to_datetime(['2023-10-27 10:00:00', '2023-10-27 10:00:05', '2023-10-27 10:00:10', '2023-10-27 10:01:00', '2023-10-27 10:01:05', '2023-10-27 10:01:10']),
'SensorID': ['TEMP-01', 'PRES-01', 'GRAV-01', 'TEMP-01', 'PRES-01', 'RAD-01'],
'ReadingType': ['Temperature', 'Pressure', 'Gravity', 'Temperature', 'Pressure', 'Radiation'],
'Value': [35.2, 1.02, 9.81, 35.3, None, 0.57],
'Status': ['OK', 'OK', None, 'OK', 'Failure', 'OK']
}
df_to_save = pd.DataFrame(log_data)
# --- 基础保存 ---
# 将DataFrame保存到名为'clean_log.csv'的文件中
# index=False 是一个极其重要的参数,通常我们不希望将Pandas自动生成的0,1,2...索引写入到CSV文件中
df_to_save.to_csv('clean_log.csv', index=False)
print("基础版'clean_log.csv'已保存。")
# --- 高级定制化保存 ---
# 场景:我们需要将文件保存为制表符分隔(TSV),浮点数保留4位小数,
# 缺失值表示为'--MISSING--',并且只输出特定的列。
print("\n开始定制化保存...")
df_to_save.to_csv(
'formatted_report.tsv', # 文件名,使用.tsv后缀以表明其为制表符分隔
sep='\t', # sep='\t' 指定分隔符为制表符
index=False, # 同样,不保存索引
columns=['Timestamp', 'SensorID', 'Value'], # columns参数指定只输出这三列
header=['Time_Of_Reading', 'Sensor_Code', 'Measured_Value'], # header参数可以用一个列表来重命名输出的列名
na_rep='--MISSING--', # na_rep指定如何表示文件中的缺失值
float_format='%.4f', # float_format使用C风格的格式化字符串来控制浮点数的输出格式,'%.4f'表示保留4位小数
encoding='utf-8-sig' # encoding='utf-8-sig'可以在文件头部写入一个BOM(Byte Order Mark),这有助于Excel等软件正确识别UTF-8编码
)
print("定制版'formatted_report.tsv'已保存。")
# 我们可以读取并打印一下刚刚保存的文件内容来验证
print("\n验证'formatted_report.tsv'的内容:")
with open('formatted_report.tsv', 'r') as f:
print(f.read())
虽然CSV是通用的,但它有其缺陷(无类型信息、存储效率低等)。Pandas支持多种更现代、更高效的格式。
.xlsx
): 使用pd.read_excel()
和df.to_excel()
。需要安装openpyxl
或xlrd
库。优点是可以读取/写入单个文件的多个工作表(sheet)。.json
): 使用pd.read_json()
和df.to_json()
。对于Web API返回的数据或嵌套结构的数据非常有用。pd.read_sql()
和df.to_sql()
。可以直接与SQLAlchemy等数据库引擎交互,从数据库表或查询结果中创建DataFrame,或将DataFrame写入数据库表。.parquet
): 使用pd.read_parquet()
和df.to_parquet()
。需要pyarrow
或fastparquet
库。这是一种列式存储格式,非常高效。它压缩率高、查询速度快(尤其是在只读取部分列时),并且会完整地保存数据类型信息。是目前大数据生态系统中最推荐的存储格式。.feather
): 使用pd.read_feather()
和df.to_feather()
。同样需要pyarrow
库。它是一种为Pandas(Python)和R之间快速数据交换而设计的二进制格式。它的读写速度极快,因为它基本就是DataFrame在内存中的样子的直接转储。# 导入pandas
import pandas as pd
# 假设我们有之前处理好的df_with_dates
log_data = {
'Timestamp': pd.to_datetime(['2023-10-27 10:00:00', '2023-10-27 10:00:05', '2023-10-27 10:00:10', '2023-10-27 10:01:00', '2023-10-27 10:01:05', '2023-10-27 10:01:10']),
'SensorID': ['TEMP-01', 'PRES-01', 'GRAV-01', 'TEMP-01', 'PRES-01', 'RAD-01'],
'ReadingType': ['Temperature', 'Pressure', 'Gravity', 'Temperature', 'Pressure', 'Radiation'],
'Value': [35.2, 1.02, 9.81, 35.3, None, 0.57],
'Status': ['OK', 'OK', None, 'OK', 'Failure', 'OK']
}
df_to_store = pd.DataFrame(log_data)
# --- 写入Parquet文件 ---
# Parquet是数据科学领域推荐的高性能存储格式
# 它会保留数据类型(如datetime),并且压缩效果好
# 需要先安装pyarrow: pip install pyarrow
print("\n正在将DataFrame保存为Parquet格式...")
df_to_store.to_parquet('log_data.parquet', index=False)
print("'log_data.parquet'已保存。")
# --- 从Parquet文件读取 ---
# 读取Parquet文件非常简单
df_from_parquet = pd.read_parquet('log_data.parquet')
print("\n从Parquet文件读取后的DataFrame信息:")
# 注意,读取回来后,Timestamp仍然是datetime64[ns]类型,数据类型被完美保留
df_from_parquet.info()
print("\n内容:")
print(df_from_parquet)
选择正确的文件格式,是优化数据分析工作流性能和可靠性的关键一步。对于大型、严肃的数据项目,强烈建议从CSV过渡到Parquet。
在掌握了二维的DataFrame之后,我们必须回过头来,深入理解构成它的基本组件:一维的Series。DataFrame的每一列,本质上就是一个Series。透彻理解Series,是精通Pandas所有操作的前提。
一个Series由两部分组成:
ndarray
存储。这个“标签”就是Series与NumPy数组最根本的区别。NumPy数组只有隐式的、从0开始的整数索引。而Series的索引是显式的、用户可定义的,并且是操作的核心。
# 导入pandas和numpy
import pandas as pd
import numpy as np
# 创建一个NumPy数组
# 它只有数据,没有显式的标签
numpy_array = np.array([10, 20, 30, 40])
print("这是一个NumPy数组:")
print(numpy_array)
# 访问第三个元素只能通过位置索引
print(f"访问NumPy数组的第3个元素 (索引为2): {
numpy_array[2]}")
# 创建一个Pandas Series
# 我们为同样的数据提供了一个自定义的、有意义的字符串索引
series_object = pd.Series(
data=[10, 20, 30, 40], # 数据部分
index=['alpha', 'beta', 'gamma', 'delta'] # 索引部分
)
print("\n这是一个Pandas Series:")
print(series_object)
# 访问'gamma'标签对应的数据,代码可读性极高
print(f"访问Series中标签为'gamma'的元素: {
series_object['gamma']}")
Series的索引不仅仅是标签,它本身也是一个强大的对象,我们稍后会深入探讨。
Pandas提供了极其灵活的方式来创建Series。
这是最基本的方式。如果你不提供索引,Pandas会自动创建一个从0开始的RangeIndex
。
# 导入pandas
import pandas as pd
# --- 从一个简单的Python列表创建Series ---
potion_ingredients = ['Dragon Scale', 'Phoenix Tear', 'Mandrake Root', 'Unicorn Horn'] # 这是一个成分列表
s_from_list = pd.Series(potion_ingredients) # 将列表转换为Series
print("--- 从列表创建的Series (默认索引) ---")
print(s_from_list)
# --- 从列表创建,并提供一个自定义的索引 ---
# 索引的数量必须和数据的数量完全一致
quantities = [2, 5, 1, 3] # 这是每种成分的数量
ingredient_codes = ['DS-01', 'PT-01', 'MR-01', 'UH-01'] # 这是每种成分的唯一编码
s_with_custom_index = pd.Series(quantities, index=ingredient_codes) # 使用编码作为索引
print("\n--- 从列表创建的Series (自定义索引) ---")
print(s_with_custom_index)
这在与已有的科学计算代码集成时非常有用。默认情况下,Series和NumPy数组会共享内存,这意味着修改其中一个可能会影响另一个(除非数据类型发生改变)。
# 导入pandas和numpy
import pandas as pd
import numpy as np
# 创建一个NumPy数组,代表星球的轨道参数
orbital_params_array = np.array([1.0, 1.52, 5.2, 9.58]) # 分别是地球、火星、木星、土星的轨道半长轴(AU)
# 从NumPy数组创建Series
planet_names = ['Earth', 'Mars', 'Jupiter', 'Saturn'] # 行星名称作为索引
s_from_numpy = pd.Series(orbital_params_array, index=planet_names) # 创建Series
print("--- 从NumPy数组创建的Series ---")
print(s_from_numpy)
# 验证内存共享(在某些情况下)
# 修改NumPy数组的第一个元素
orbital_params_array[0] = 1.01
print("\n--- 修改原始NumPy数组后 ---")
# 观察Series中的'Earth'值是否也发生了变化
# 注意:这种共享行为在某些操作后可能会中断(例如,当需要类型转换时),但理解其存在很重要
print(s_from_numpy)
这是一种非常直观和常用的创建方式。字典的键(keys)会自动成为Series的索引,字典的值(values)会成为Series的数据。
# 导入pandas
import pandas as pd
# 定义一个字典,记录了不同魔法学院的学生人数
student_counts = {
'Gryffindor': 450,
'Slytherin': 420,
'Hufflepuff': 480,
'Ravenclaw': 440
}
# 从字典创建Series
# 键'Gryffindor'等成为索引,值450等成为数据
s_from_dict = pd.Series(student_counts)
print("--- 从字典创建的Series (键成为索引) ---")
print(s_from_dict)
# 如果你提供了索引,Pandas会根据你提供的索引来构建Series
# 这是一种强大的筛选和重排功能
# 'Durmstrang'在原始字典中不存在,所以其值会是NaN(缺失值)
# 'Slytherin'在字典中存在,但我们没有在索引中提供它,所以它会被舍弃
specified_houses = ['Gryffindor', 'Ravenclaw', 'Durmstrang']
s_from_dict_with_index = pd.Series(student_counts, index=specified_houses)
print("\n--- 从字典创建,但提供了自定义索引 ---")
print(s_from_dict_with_index)
如果你只提供一个单独的值(标量),但提供了一个索引,Pandas会用这个标量值填充Series,使其长度与索引匹配。
# 导入pandas
import pandas as pd
# 我们需要为4个季度的财报创建一个占位符Series
# 索引是季度名称
quarter_index = ['Q1-2024', 'Q2-2024', 'Q3-2024', 'Q4-2024']
# 数据是一个标量0,表示初始值为0
# Pandas会自动将0广播到索引的每一个位置
s_from_scalar = pd.Series(0, index=quarter_index)
print("--- 从标量值创建的Series ---")
print(s_from_scalar)
一个创建好的Series对象,包含了一系列非常有用的属性,可以帮助我们快速了解其结构和内容。
# 导入pandas和numpy
import pandas as pd
import numpy as np
# 创建一个用于演示的、较为复杂的Series
# 代表一个游戏角色的属性
character_stats = {
'Name': 'Arion',
'Level': 50,
'HP': 4890.5,
'Is_Active': True,
'Last_Login': pd.Timestamp('2023-10-27 20:00:00')
}
# 注意,这个Series包含了字符串、整数、浮点数、布尔值和日期时间,因此其dtype会是'object'
# 因为Pandas需要一个能容纳所有这些不同Python对象的通用类型
s_char = pd.Series(character_stats, name='Player_Profile') # 使用name属性给Series命名
print("--- 用于演示的Series ---")
print(s_char)
# 1. .values: 返回一个包含Series数据的NumPy数组
# 这是连接Pandas和NumPy的桥梁
print(f"\n1. .values -> {
s_char.values}") # 获取底层的数据数组
print(f" .values的类型: {
type(s_char.values)}") # 确认其类型是numpy.ndarray
# 2. .index: 返回Series的索引对象
# 索引对象本身就是一个特殊的数据结构
print(f"\n2. .index -> {
s_char.index}") # 获取索引对象
print(f" .index的类型: {
type(s_char.index)}") # 确认其类型是pandas.Index
# 3. .dtype: 返回Series中数据的类型
print(f"\n3. .dtype -> {
s_char.dtype}") # 获取数据的类型
# 4. .name: 返回Series的名字
# 在将Series放入DataFrame时,这个名字会成为列名
print(f"\n4. .name -> '{
s_char.name}'") # 获取Series的名称
# 5. .size: 返回Series中元素的总数
print(f"\n5. .size -> {
s_char.size}") # 获取元素的数量
# 6. .shape: 返回一个表示Series维度的元组
# 对于Series,它总是一个一维的元组(n,)
print(f"\n6. .shape -> {
s_char.shape}") # 获取形状
# 7. .ndim: 返回Series的维度数量
# 对于Series,它总是1
print(f"\n7. .ndim -> {
s_char.ndim}") # 获取维度数
# 8. .hasnans: 如果Series中存在任何缺失值,则返回True
s_with_nan = pd.Series([1, 2, np.nan]) # 创建一个带缺失值的Series
print(f"\n8. .hasnans (无缺失值) -> {
s_char.hasnans}") # 检查演示Series
print(f" .hasnans (有缺失值) -> {
s_with_nan.hasnans}") # 检查带NaN的Series
如何从Series中提取数据,是使用Pandas的日常核心。Pandas提供了多种索引方式,但最重要、最清晰的是.loc
和.iloc
。
.loc
: 基于**标签(Label)**的索引。.iloc
: 基于**位置(Integer location)**的索引。强烈建议始终使用.loc
和.iloc
进行索引,这会让你的代码意图明确,避免混淆和潜在的错误。
场景设定:行星数据Series
我们创建一个关于太阳系行星的Series,用行星名称做索引。
# 导入pandas
import pandas as pd
# 数据是每个行星的卫星数量
# 注意,为了演示,索引中我们故意包含了一个整数2006,以展示可能的混淆
planet_moons = {
'Mercury': 0,
'Venus': 0,
'Earth': 1,
'Mars': 2,
'Jupiter': 95,
'Saturn': 146,
'Uranus': 27,
'Neptune': 14,
2006: 5 # 假设这是对冥王星在2006年的卫星统计
}
s_moons = pd.Series(planet_moons, name='Satellite_Count')
print("--- 用于索引演示的行星卫星数量Series ---")
print(s_moons)
.loc
: 按标签取值.loc
是你与Series的“语义化”交互方式。你使用的是数据的“名字”,而不是它的位置。
# 导入pandas
import pandas as pd
planet_moons = {
'Mercury': 0, 'Venus': 0, 'Earth': 1, 'Mars': 2, 'Jupiter': 95, 'Saturn': 146, 'Uranus': 27, 'Neptune': 14, 2006: 5}
s_moons = pd.Series(planet_moons, name='Satellite_Count')
print("--- 使用 .loc 进行基于标签的索引 ---")
# --- 1. 获取单个值 ---
# 通过标签'Jupiter'获取其卫星数量
jupiter_moons = s_moons.loc['Jupiter']
print(f"\n1. 获取单个值 (Jupiter): {
jupiter_moons}")
# --- 2. 获取多个值 ---
# 通过一个标签列表获取多个行星的卫星数
outer_planets_moons = s_moons.loc[['Jupiter', 'Saturn', 'Uranus', 'Neptune']]
print("\n2. 获取多个值 (气态巨行星):")
print(outer_planets_moons)
# --- 3. 标签切片 ---
# 使用.loc进行切片,是包含起始和结束标签的!这和Python常规切片不同。
# 获取从'Earth'到'Jupiter'(包括Jupiter)的所有行星
slice_of_planets = s_moons.loc['Earth':'Jupiter']
print("\n3. 进行标签切片 ('Earth' to 'Jupiter'):")
print(slice_of_planets)
# --- 4. 使用布尔Series进行筛选 (布尔掩码) ---
# 这是最强大的功能之一。创建一个布尔条件...
high_moon_count_mask = s_moons > 20
print("\n4. 用于筛选的布尔掩码 (卫星数 > 20):")
print(high_moon_count_mask)
# ...然后将这个布尔掩码传给.loc,它会返回所有对应位置为True的元素
planets_with_many_moons = s_moons.loc[high_moon_count_mask]
print("\n 应用掩码后的结果:")
print(planets_with_many_moons)
.iloc
: 按位置取值.iloc
完全忽略索引标签,只关心数据在Series中的整数位置(从0开始)。它的行为与Python列表和NumPy数组的索引非常相似。
# 导入pandas
import pandas as pd
planet_moons = {
'Mercury': 0, 'Venus': 0, 'Earth': 1, 'Mars': 2, 'Jupiter': 95, 'Saturn': 146, 'Uranus': 27, 'Neptune': 14, 2006: 5}
s_moons = pd.Series(planet_moons, name='Satellite_Count')
print("--- 使用 .iloc 进行基于位置的索引 ---")
# --- 1. 获取单个值 ---
# 获取第3个位置的元素(索引为2),也就是'Earth'
third_planet_moons = s_moons.iloc[2]
print(f"\n1. 获取单个值 (位置2): {
third_planet_moons}")
# --- 2. 获取多个值 ---
# 通过一个整数位置列表获取多个元素
# 获取第1, 3, 5个位置的元素
some_planets = s_moons.iloc[[0, 2, 4]]
print("\n2. 获取多个值 (位置 0, 2, 4):")
print(some_planets)
# --- 3. 位置切片 ---
# 使用.iloc进行切片,行为和Python常规切片一样,不包含结束位置。
# 获取从位置2到位置5(不包括5)的元素
slice_by_position = s_moons.iloc[2:5]
print("\n3. 进行位置切片 ([2:5]):")
print(slice_by_position)
[]
直接索引:便捷但需警惕的“捷径”你可以直接使用方括号[]
对Series进行索引,它会试图表现得“智能”,但这恰恰是混淆的来源。
s['label']
的行为和s.loc['label']
完全一样。s[key]
会优先尝试按标签查找。只有当key
这个标签不存在时,它才会退一步,尝试按位置查找。这就是为什么我们的行星Series中故意加入一个整数标签2006
。
# 导入pandas
import pandas as pd
planet_moons = {
'Mercury': 0, 'Venus': 0, 'Earth': 1, 'Mars': 2, 'Jupiter': 95, 'Saturn': 146, 'Uranus': 27, 'Neptune': 14, 2006: 5}
s_moons = pd.Series(planet_moons, name='Satellite_Count')
print("--- '[]' 直接索引的演示 ---")
# --- 行为清晰的情况:字符串索引 ---
# 这会明确地按标签查找,等同于 .loc['Earth']
print(f"\n访问 'Earth' -> {
s_moons['Earth']}")
# --- 产生混淆的情况:整数索引 ---
# Series的索引本身是整数
s_integer_index = pd.Series([100, 200, 300], index=[10, 20, 30])
print("\n一个只有整数索引的Series:")
print(s_integer_index)
# print(s_integer_index[0]) # 这会立即抛出KeyError,因为它找不到标签为0的索引,并且对于纯整数索引,它不会自动回退到位置索引。
# --- 混淆的顶点:混合索引 ---
print("\n回到我们的行星Series,它有混合索引:")
print(s_moons)
# 访问 s_moons[2]
# 因为索引中没有标签为 2 的值,Pandas会回退到位置索引,返回第3个元素'Earth'
print(f"\n访问 s_moons[2] -> {
s_moons[2]} (这是位置索引的回退行为!)")
# 访问 s_moons[2006]
# 因为索引中存在标签为 2006 的值,Pandas会执行标签索引
print(f"访问 s_moons[2006] -> {
s_moons[2006]} (这是标签索引!)")
核心准则: 为了写出可预测、可维护、无歧义的Pandas代码,请忘记[]
可以用于整数访问,并始终坚持使用.loc
(用于标签)和.iloc
(用于位置)。这个习惯会为你免去无数个小时的调试痛苦。
.get()
和直接赋值除了使用索引器进行访问,Series还提供了其他一些方法来获取和修改数据。
.get(key, default=None)
: 这是一种更“安全”的获取单个元素的方式。如果标签(key)存在,它会返回值;如果不存在,它不会像.loc
或[]
那样抛出KeyError
,而是会返回一个你指定的默认值(默认为None
)。这在不确定某个键是否存在时非常有用,可以避免编写繁琐的try-except
代码块。
直接赋值: 你可以使用.loc
或.iloc
来修改Series中的值。这是更改数据的标准做法。
# 导入pandas
import pandas as pd
# 我们继续使用行星卫星的Series
planet_moons = {
'Mercury': 0,
'Venus': 0,
'Earth': 1,
'Mars': 2,
'Jupiter': 95,
}
s_moons = pd.Series(planet_moons, name='Satellite_Count')
print("--- .get() 方法的演示 ---")
# --- 1. 获取一个存在的值 ---
# 和 s_moons.loc['Mars'] 效果一样
mars_moons = s_moons.get('Mars')
print(f"使用.get('Mars')获取的值: {
mars_moons}")
# --- 2. 尝试获取一个不存在的值 ---
# 'Pluto' 不在我们的Series索引中
# 使用.loc['Pluto']会报错,但.get()会返回默认值None
pluto_moons = s_moons.get('Pluto')
print(f"使用.get('Pluto')获取的值 (默认返回值): {
pluto_moons}")
# --- 3. 获取不存在的值并提供一个自定义的默认值 ---
# 我们可以指定如果键不存在,应该返回什么
pluto_moons_custom_default = s_moons.get('Pluto', default='Status Unknown')
print(f"使用.get('Pluto', default='Status Unknown')获取的值: {
pluto_moons_custom_default}")
print("\n--- 使用 .loc 进行数据修改 ---")
print("修改前的Series:")
print(s_moons)
# --- 1. 修改单个值 ---
# 我们发现木星的新卫星,更新其数量
# 使用.loc定位到'Jupiter'标签,然后用等号进行赋值
s_moons.loc['Jupiter'] = 98
print("\n修改'Jupiter'的卫星数后:")
print(s_moons)
# --- 2. 同时修改多个值 ---
# 假设内行星受到引力扰动,卫星数发生变化
# 我们可以一次性更新多个值
s_moons.loc[['Mercury', 'Venus']] = [1, 1] # 用一个列表来赋给对应的多个标签
print("\n同时修改'Mercury'和'Venus'后:")
print(s_moons)
# --- 3. 新增一个元素 ---
# 如果你赋给一个不存在的标签,Pandas会自动为你新增这个元素
# 这是一种动态扩展Series的方式
s_moons.loc['Pluto'] = 5
print("\n为不存在的标签'Pluto'赋值 (新增元素)后:")
print(s_moons)
Series之所以强大,不仅仅在于它能存储带标签的数据,更在于它在运算时表现出的智能行为,特别是矢量化运算和索引自动对齐。
矢量化(Vectorization)是指将操作应用于整个数组,而不是逐个元素进行操作。Pandas的Series构建于NumPy之上,因此完美继承了这一特性。这意味着你可以用简洁、可读性强的代码,实现底层由C语言执行的高性能计算。
当一个Series与一个单独的数值(标量)进行算术运算时,该运算会被“广播”(broadcast)到Series的每一个元素上。
场景:晶体生长实验数据分析
我们有一个Series,记录了不同批次晶体在特定时间点的尺寸(单位:毫米)。我们需要将所有尺寸转换为微米,并计算出每个晶体距离目标尺寸的差距。
# 导入pandas
import pandas as pd
# 记录了5个晶体批次的尺寸(毫米)
# 索引是批次编号
crystal_sizes_mm = pd.Series(
[2.31, 1.98, 2.54, 2.05, 2.22],
index=['Batch-A01', 'Batch-A02', 'Batch-B01', 'Batch-B02', 'Batch-C01'],
name='Crystal_Size_mm'
)
print("--- 原始晶体尺寸 (mm) ---")
print(crystal_sizes_mm)
# --- 1. 矢量化乘法:单位转换 ---
# 1毫米 = 1000微米。我们将整个Series乘以1000
# 这个操作会应用到Series的每一个元素上,无需写for循环
crystal_sizes_micron = crystal_sizes_mm * 1000
crystal_sizes_micron.name = 'Crystal_Size_micron' # 给新的Series命名
print("\n--- 转换为微米后的尺寸 ---")
print(crystal_sizes_micron)
# --- 2. 矢量化减法:计算与目标的差距 ---
# 我们的目标尺寸是2200微米
target_size_micron = 2200
size_difference = crystal_sizes_micron - target_size_micron
size_difference.name = 'Difference_from_Target'
print("\n--- 与目标尺寸(2200微米)的差距 ---")
print(size_difference)
# --- 3. 矢量化逻辑运算 ---
# 我们想知道哪些批次的尺寸是合格的(差距在+/- 100微米以内)
# 首先,计算差距的绝对值
abs_difference = abs(size_difference)
# 然后,进行逻辑比较
is_qualified = abs_difference <= 100
is_qualified.name = 'Is_Qualified'
print("\n--- 各批次是否合格 (布尔Series) ---")
print(is_qualified)
矢量化不仅代码简洁,其性能远非Python的原生循环所能比拟。对于大型数据集,性能差异可达百倍甚至千倍。
这是Pandas最核心、最强大的概念之一,也是初学者最容易困惑的地方。当两个Series进行运算时,Pandas会首先根据索引标签对齐数据,然后再对齐了的数据进行运算。
NaN
(Not a Number),表示缺失,因为无法完成对齐运算。场景:合并不同季度的产品销售额与退货成本
我们有两份数据:一份是第一季度各产品的销售额,另一份是第一季度部分产品的退货成本。注意,并非所有销售的产品都有退货,同时可能存在一些退货记录对应的是上个季度销售的产品。
# 导入pandas
import pandas as pd
# Q1产品销售额,索引为产品SKU
q1_sales = pd.Series(
{
'SKU-001': 15000, 'SKU-002': 22000, 'SKU-003': 8500, 'SKU-004': 31000},
name='Q1_Sales'
)
# Q1产品退货成本,索引也是产品SKU
# 注意:'SKU-003'没有退货记录,而'SKU-005'是一个历史遗留产品的退货
q1_returns = pd.Series(
{
'SKU-001': 350, 'SKU-002': 820, 'SKU-004': 1200, 'SKU-005': 200},
name='Q1_Returns'
)
print("--- Q1销售额 ---")
print(q1_sales)
print("\n--- Q1退货成本 ---")
print(q1_returns)
# --- 直接相减,观察索引对齐的效果 ---
# 我们想计算每个产品的净销售额
net_sales = q1_sales - q1_returns
net_sales.name = 'Net_Sales'
print("\n--- 计算净销售额 (直接相减) ---")
print(net_sales)
# 结果分析:
# SKU-001, SKU-002, SKU-004: 在两个Series中都存在,所以计算成功 (15000-350, 22000-820, 31000-1200)
# SKU-003: 只在q1_sales中存在,在q1_returns中不存在。Pandas找不到与之对齐的值,因此结果是NaN。
# SKU-005: 只在q1_returns中存在,在q1_sales中不存在。同样,找不到对齐的值,结果是NaN。
直接运算导致的NaN
可能不是我们想要的结果。对于没有退货记录的SKU-003
,其退货成本应该是0,净销售额应该就是其销售额。对于只有退货记录的SKU-005
,我们可能想将其销售额视为0。这时,算术方法(如.add()
, .sub()
, .mul()
, .div()
)及其fill_value
参数就派上了用场。
fill_value
处理未对齐数据fill_value
参数允许你在对齐过程中,为一个Series中缺失的标签指定一个临时的填充值。
# 导入pandas
import pandas as pd
q1_sales = pd.Series({
'SKU-001': 15000, 'SKU-002': 22000, 'SKU-003': 8500, 'SKU-004': 31000}, name='Q1_Sales')
q1_returns = pd.Series({
'SKU-001': 350, 'SKU-002': 820, 'SKU-004': 1200, 'SKU-005': 200}, name='Q1_Returns')
print("--- 使用 .sub() 和 fill_value 计算净销售额 ---")
# 我们使用 .sub() 方法代替 '-' 操作符
# 在调用 q1_sales.sub(q1_returns, ...) 时:
# fill_value=0 的意思是,在进行减法前,
# 如果 q1_returns 中有的标签 q1_sales 中没有,就用0来填充 q1_sales 的缺失位置
# (注意:这个例子里 q1_returns 有 SKU-005,q1_sales没有,所以 q1_sales 会临时出现一个 q1_sales['SKU-005'] = 0)
#
# 我们还需要处理 q1_sales 中有,而 q1_returns 中没有的情况,比如 SKU-003
# 这需要在 q1_returns 上也应用一个 fill_value。
# 最清晰的方式是分别调用:
sales_filled = q1_sales.fillna(0) # 尽管本例中q1_sales无缺失,但这是个好习惯
returns_filled = q1_returns.fillna(0) # 同样
# 然而,更优雅的方式是在运算中指定
# q1_sales.sub(q1_returns, fill_value=0) 的含义是:
# 在 q1_sales 中找到 q1_returns 的所有索引,如果 q1_sales 中没有,则该位置的值视为 fill_value
# 这个行为对于减法是不对称的。
#
# 正确的做法是:
# 在进行运算前,先将两个Series的索引进行合并,对不存在的标签填充0
all_skus = q1_sales.index.union(q1_returns.index) # 获取所有出现过的SKU
# 使用.reindex()方法,它可以将一个Series调整为新的索引,对于新索引中不存在的标签,用fill_value填充
aligned_sales = q1_sales.reindex(all_skus, fill_value=0)
aligned_returns = q1_returns.reindex(all_skus, fill_value=0)
print("\n--- 对齐并填充后的Series ---")
print("对齐后的销售额:")
print(aligned_sales)
print("\n对齐后的退货成本:")
print(aligned_returns)
# 现在,两个Series拥有完全相同的索引,可以直接进行运算
net_sales_correct = aligned_sales - aligned_returns
net_sales_correct.name = 'Net_Sales_Correct'
print("\n--- 计算得到的正确净销售额 ---")
print(net_sales_correct)
# 使用.add() .sub()的fill_value参数是更简洁的方法
print("\n--- 使用 .add() 和 fill_value 的简洁方法 ---")
# 假设我们是计算总成本
total_cost = q1_sales.add(q1_returns, fill_value=0)
# SKU-003: 8500 + 0 = 8500
# SKU-005: 0 + 200 = 200
print(total_cost)
索引对齐是Pandas数据操作的基石。理解它,你就能写出强大、灵活且不易出错的数据处理代码。它让你从“数据在第几行”的思维中解放出来,专注于“这份数据代表什么”的业务逻辑。
NumPy的通用函数(Universal Functions, ufuncs)可以直接作用于Pandas的Series对象,并且会完美地保留索引。
场景:分析周期性信号数据
我们有一系列在不同时间点采集的信号相位角(单位:弧度),需要计算这些相位对应的正弦和余弦值。
# 导入pandas和numpy
import pandas as pd
import numpy as np
# 创建一个Series,表示在不同时间点(作为索引)采集到的相位角
time_points = pd.to_datetime(['2023-11-01 10:00:00',
'2023-11-01 10:00:01',
'2023-11-01 10:00:02',
'2023-11-01 10:00:03'])
phase_angles = pd.Series(
[0, np.pi/2, np.pi, 3*np.pi/2],
index=time_points,
name='Phase_Angle_rad'
)
print("--- 原始信号相位角 (弧度) ---")
print(phase_angles)
# --- 应用NumPy的ufunc ---
# 直接将整个Series传入np.sin()和np.cos()
# NumPy会自动对每个元素执行计算,并返回一个保留了原始索引的新Series
sin_values = np.sin(phase_angles)
sin_values.name = 'Sine_Value'
cos_values = np.cos(phase_angles)
cos_values.name = 'Cosine_Value'
print("\n--- 计算出的正弦值 ---")
print(sin_values) # 注意索引被完整保留了
print("\n--- 计算出的余弦值 ---")
print(cos_values) # 索引也被完整保留了
# 结果中的6.12...e-17 是由于浮点数精度问题导致的,实际上它就是0
这个例子表明,你可以无缝地使用NumPy生态中海量的数学函数来处理Pandas数据,而无需担心会丢失Pandas提供的标签化索引这一巨大优势。
真实世界的数据是“肮脏”的,缺失值无处不在。如何优雅、正确地处理缺失数据,是数据清洗和预处理阶段最核心的任务。Pandas为此提供了一套完整且强大的工具集。
np.nan
, None
与 pd.NA
在Pandas中,缺失值有几种不同的表示形式,理解它们的区别至关重要。
np.nan
: “Not a Number”。这是Pandas中最主要的、传统的缺失值表示。它是一个特殊的浮点数值。
np.nan
的算术运算,结果都是np.nan
(e.g., 5 + np.nan
is nan
)。np.nan
不等于任何东西,甚至不等于它自己 (i.e., np.nan == np.nan
is False
)。np.nan
是浮点类型,如果一个原本是整数的Series中出现了一个缺失值,Pandas为了容纳np.nan
,会自动将整个Series的类型(dtype)强制转换为float64
。None
: Python原生的空对象。
dtype
是object
(通常用于存储字符串),那么None
也可以被识别为缺失值。如果将None
放入一个数值型(整数或浮点数)的Series,它通常会被自动转换为np.nan
,并可能导致整个Series的类型转换。pd.NA
(实验性特性): 这是Pandas在较新版本中引入的、更现代的缺失值标记。它的目标是提供一个可以跨数据类型(整数、字符串、布尔等)使用的、“真正”的缺失值指示器,而不会改变Series的dtype
。
pd.NA
,你需要使用Pandas提供的特殊的可空数据类型,如'Int64'
(注意大写I), 'boolean'
, 'string'
。# 导入pandas和numpy
import pandas as pd
import numpy as np
# --- 演示 np.nan 导致类型转换 ---
int_series_with_missing = pd.Series([10, 20, np.nan, 40])
print("--- 含有np.nan的整数Series ---")
print(int_series_with_missing)
print(f"数据类型 (dtype): {
int_series_with_missing.dtype}") # 注意,dtype变成了float64
# --- 演示 None 在不同类型中的行为 ---
object_series_with_none = pd.Series(['apple', 'banana', None, 'cherry'])
print("\n--- 含有None的object Series ---")
print(object_series_with_none)
print(f"数据类型 (dtype): {
object_series_with_none.dtype}") # dtype是object
numeric_series_with_none = pd.Series([10.5, 20.2, None, 40.1])
print("\n--- 含有None的浮点数Series ---")
print(numeric_series_with_none) # None被自动转成了NaN
print(f"数据类型 (dtype): {
numeric_series_with_none.dtype}") # dtype是float64
# --- 演示 pd.NA 和可空数据类型 ---
# 使用 'Int64' (大写I) nullable integer type
nullable_int_series = pd.Series([100, 200, None, 400], dtype='Int64')
print("\n--- 使用可空整数类型'Int64'的Series ---")
print(nullable_int_series)
# 注意输出中的,这就是pd.NA
print(f"数据类型 (dtype): {
nullable_int_series.dtype}") # dtype被保留为Int64,没有变成float
在新的项目中,如果你的Pandas版本支持,应优先考虑使用'Int64'
, 'string'
等可空数据类型,以获得更一致和可预测的行为。
在处理缺失值之前,我们首先要能准确地找到它们。
.isna()
或 .isnull()
: 这两个方法完全相同。它们会返回一个布尔型的Series,在所有缺失值(np.nan
, None
, pd.NA
)的位置为True
,否则为False
。.notna()
或 .notnull()
: 这两个方法也完全相同,作用与上面相反。# 导入pandas和numpy
import pandas as pd
import numpy as np
# 创建一个混合了各种缺失值的Series
# 代表一次问卷调查中,不同受访者对某个问题的评分(1-5分)
survey_scores = pd.Series(
[5, 4, np.nan, 3, 1, None, 2],
index=['Respondent_A', 'B', 'C', 'D', 'E', 'F', 'G'],
name='Satisfaction_Score'
)
print("--- 原始问卷评分 ---")
print(survey_scores)
# --- 使用 .isna() 检测缺失值 ---
missing_mask = survey_scores.isna()
print("\n--- 使用 .isna() 检测出的缺失值掩码 ---")
print(missing_mask)
# --- 结合布尔索引,筛选出所有缺失的条目 ---
missing_entries = survey_scores[missing_mask]
print("\n--- 所有缺失的条目 ---")
print(missing_entries)
# --- 使用 .notna() 筛选出所有有效的条目 ---
valid_entries = survey_scores[survey_scores.notna()] # 直接在索引中调用.notna()
print("\n--- 所有有效的条目 ---")
print(valid_entries)
# --- 统计缺失值的数量 ---
# 因为True等于1,False等于0,所以可以直接对布尔掩码求和
num_missing = survey_scores.isna().sum()
print(f"\n缺失值的总数: {
num_missing}")
检测到缺失值后,通常有三种主要处理策略:剔除、填充和插值。选择哪种策略,取决于数据的特性、缺失的原因以及分析的目标。
这是最简单直接的方法:直接将包含缺失值的条目删除。dropna()
方法用于执行此操作。
优点:简单,能确保后续分析的数据都是完整的。
缺点:如果缺失数据量较大,可能会丢失大量有价值的信息,甚至导致数据集产生偏见。
# 导入pandas和numpy
import pandas as pd
import numpy as np
# 使用之前的问卷评分数据
survey_scores = pd.Series(
[5, 4, np.nan, 3, 1, None, 2],
index=['Respondent_A', 'B', 'C', 'D', 'E', 'F', 'G'],
name='Satisfaction_Score'
)
print("--- 原始数据 ---")
print(survey_scores)
# --- 使用 dropna() ---
# .dropna()会返回一个不含任何缺失值的新Series
# 它不会修改原始的survey_scores Series
cleaned_scores = survey_scores.dropna()
print("\n--- 使用dropna()剔除缺失值后的数据 ---")
print(cleaned_scores)
print("\n--- 原始数据并未改变 ---")
print(survey_scores) # 验证原始Series不变
# 如果想在原始Series上直接修改,可以使用 inplace=True 参数
# 但这通常不被推荐,因为它会使得代码的追踪和调试变得困难
# survey_scores.dropna(inplace=True)
填充是用一个特定的值来替换缺失值。这是一种比剔除更保守的策略,因为它保留了数据的原始大小。fillna()
是实现这一功能的核心方法。
场景:处理每日网站访客数量,其中有几天数据缺失
# 导入pandas和numpy
import pandas as pd
import numpy as np
# 每日网站访客数量
daily_visits = pd.Series(
[1205, 1342, 1298, np.nan, 1450, 1502, np.nan],
index=pd.to_datetime(['2023-12-01', '2023-12-02', '2023-12-03', '2023-12-04', '2023-12-05', '2023-12-06', '2023-12-07']),
name='Daily_Visits'
)
print("--- 原始每日访客数据 ---")
print(daily_visits)
# --- 1. 用一个常量填充 ---
# 例如,用0填充,表示缺失当天没有访客(这可能不合理,但只是演示)
filled_with_zero = daily_visits.fillna(0)
print("\n--- 用0填充 ---")
print(filled_with_zero)
# --- 2. 用统计量填充 (更常见) ---
# 用所有已知日期的平均访客数来填充缺失值
mean_visits = daily_visits.mean() # 计算平均值
print(f"\n计算出的平均访客数: {
mean_visits:.2f}")
filled_with_mean = daily_visits.fillna(mean_visits)
print("\n--- 用平均值填充 ---")
print(filled_with_mean)
# --- 3. 前向填充 (Forward Fill) ---
# 使用前一个有效观测值来填充缺失值。这在时间序列数据中很常用。
# 假设缺失当天的数据和前一天最接近。
# method='ffill' (forward fill)
filled_forward = daily_visits.fillna(method='ffill')
print("\n--- 前向填充 ('ffill') ---")
# 12-04的NaN被12-03的1298填充
# 12-07的NaN被12-06的1502填充
print(filled_forward)
# --- 4. 后向填充 (Backward Fill) ---
# 使用后一个有效观测值来填充缺失值。
# method='bfill' (backward fill)
filled_backward = daily_visits.fillna(method='bfill')
print("\n--- 后向填充 ('bfill') ---")
# 12-04的NaN被12-05的1450填充
# 12-07的NaN因为是最后一个,后面没有值,所以仍然是NaN
print(filled_backward)
插值是一种比填充更高级的技术,它会根据缺失点前后的数据,通过某种数学方法“估计”出缺失值。这在数据具有某种趋势(如线性、二次)时非常有效。.interpolate()
是其对应的方法。
场景:记录一个匀速上升的温度传感器读数,中间有数据点丢失
# 导入pandas和numpy
import pandas as pd
import numpy as np
# 温度传感器读数,每秒一次
# 数据呈现清晰的线性增长趋势
temp_readings = pd.Series(
[20.0, 20.5, np.nan, 21.5, 22.0, np.nan, np.nan, 23.5, 24.0],
index=pd.arange(9) # 简单的整数索引代表时间秒数
)
print("--- 原始温度读数 ---")
print(temp_readings)
# --- 使用线性插值 ---
# method='linear' 是默认方法。它会认为缺失点均匀分布在前后两个有效点之间。
interpolated_linear = temp_readings.interpolate(method='linear')
print("\n--- 线性插值后的结果 ---")
# 位置2的NaN:在20.5和21.5之间,所以是21.0
# 位置5和6的NaN:在22.0和23.5之间,它们将这个1.5的差距三等分
# 22.0 + (23.5-22.0)/3 * 1 = 22.5
# 22.0 + (23.5-22.0)/3 * 2 = 23.0
print(interpolated_linear)
# --- 使用多项式插值 ---
# 当数据趋势更复杂时,可以使用更高阶的方法
# 'polynomial'或'spline'需要指定阶数(order)
# 注意:这需要安装scipy库 (pip install scipy)
try:
interpolated_poly = temp_readings.interpolate(method='polynomial', order=2)
print("\n--- 二阶多项式插值后的结果 ---")
print(interpolated_poly)
except ImportError:
print("\n要使用多项式插值,请先安装scipy库: `pip install scipy`")
选择何种缺失值处理策略没有绝对的黄金法则,它需要分析师结合对业务的理解、数据的分布特性以及后续的建模需求来综合判断。通常,填充和插值比直接剔除能保留更多的信息,是更受青睐的方法。|
limit
与limit_direction
在连续填充或插值时,有时我们不希望无限制地填充下去。例如,如果数据连续缺失了太多点,那么插值的结果可能已经非常不可靠了。limit
参数就是为此而生,它可以限制连续填充或插值的最大数量。
limit
: 一个整数,指定在一次fillna
或interpolate
操作中,可以连续填充的最大元素数量。limit_direction
: {‘forward’, ‘backward’, ‘both’}
,指定限制的方向。
forward
: 从前向后填充时,限制连续填充的数量。backward
: 从后向前填充时,限制连续填充的数量。both
: 在两个方向上都应用限制。场景:修复一个间歇性失灵的传感器信号
一个传感器每秒回传一个压力读数,但它有时会连续几秒失灵。我们认为,如果传感器连续失灵超过2秒,那么插值的结果就不可信了,我们宁愿保留缺失状态。
# 导入pandas和numpy
import pandas as pd
import numpy as np
# 传感器压力读数,其中有一段连续4个点的缺失
pressure_readings = pd.Series(
[101.2, 101.3, 101.4, np.nan, np.nan, np.nan, np.nan, 102.0, 102.1],
index=pd.arange(9), # 时间秒数
name='Pressure_kPa'
)
print("--- 原始压力读数 (有连续4个缺失) ---")
print(pressure_readings)
# --- 使用带limit的插值 ---
# 我们设置limit=2,表示最多只连续插值2个点
# 即使有4个连续的NaN,这个操作也只会填充前两个
interpolated_with_limit = pressure_readings.interpolate(method='linear', limit=2)
print("\n--- 线性插值 (limit=2) ---")
# 位置3和4被插值了:101.55, 101.7
# 位置5和6由于超出了limit=2的限制,仍然是NaN
print(interpolated_with_limit)
# --- limit_direction的演示 ---
# 我们使用前向填充(ffill)来演示方向限制
ffill_limited = pressure_readings.fillna(method='ffill', limit=2)
print("\n--- 前向填充 (limit=2) ---")
# 位置2的值是101.4
# 位置3被填充为101.4 (第1次连续填充)
# 位置4被填充为101.4 (第2次连续填充)
# 位置5和6因为超过了limit=2,所以未被填充
print(ffill_limited)
选择哪种缺失值处理策略,会对下游的分析结果产生直接甚至巨大的影响。让我们通过一个具体的场景来量化这种影响。
场景:分析一组候选药物的体外细胞抑制率
我们有10种候选药物(Drug-A
到Drug-J
)的体外细胞抑制率(Inhibition_Rate
)数据。抑制率越高越好。然而,由于实验误差或读数失败,其中一些数据点缺失了。我们需要计算这批药物的平均抑制率和标准差,以评估其整体潜力和一致性。我们将对比不同处理策略对最终评估指标的影响。
# 导入pandas和numpy
import pandas as pd
import numpy as np
# 10种候选药物的细胞抑制率 (%)
inhibition_data = pd.Series(
[85.2, 91.5, np.nan, 78.3, 88.4, 95.1, 75.6, np.nan, 89.9, 81.3],
index=[f'Drug-{
chr(65+i)}' for i in range(10)], # Drug-A, Drug-B, ...
name='Inhibition_Rate'
)
print("--- 原始药物抑制率数据 ---")
print(inhibition_data)
print("-" * 30)
# --- 策略零:不处理,直接计算 ---
# Pandas的.mean()和.std()等方法默认会跳过NaN值进行计算
mean_original = inhibition_data.mean() # 计算平均值
std_original = inhibition_data.std() # 计算标准差
count_original = inhibition_data.count() # 计算有效数据点数量
print(f"策略零 (忽略NaN):")
print(f" 基于 {
count_original} 个样本计算")
print(f" 平均抑制率: {
mean_original:.2f}%")
print(f" 标准差: {
std_original:.2f}%")
print("-" * 30)
# --- 策略一:剔除 (dropna) ---
# 这种策略的计算结果和策略零完全一样,因为统计函数默认就忽略了NaN
# 但它在逻辑上是“先清洗,再分析”
data_dropped = inhibition_data.dropna()
mean_dropped = data_dropped.mean()
std_dropped = data_dropped.std()
count_dropped = data_dropped.count()
print(f"策略一 (剔除NaN):")
print(f" 基于 {
count_dropped} 个样本计算")
print(f" 平均抑制率: {
mean_dropped:.2f}%")
print(f" 标准差: {
std_dropped:.2f}%")
print("-" * 30)
# --- 策略二:用一个悲观值填充 (比如0) ---
# 假设我们认为缺失代表实验完全失败,药物无效
data_filled_zero = inhibition_data.fillna(0)
mean_filled_zero = data_filled_zero.mean()
std_filled_zero = data_filled_zero.mean()
count_filled_zero = data_filled_zero.count()
print(f"策略二 (用0填充):")
print(f" 基于 {
count_filled_zero} 个样本计算")
print(f" 平均抑制率: {
mean_filled_zero:.2f}% <-- 显著拉低了平均值")
print(f" 标准差: {
std_filled_zero:.2f}% <-- 极大地增加了数据的离散度")
print("-" * 30)
# --- 策略三:用均值填充 ---
# 一种常见的、中庸的策略,假设缺失值与整体水平一致
mean_for_filling = inhibition_data.mean() # 先计算出原始均值
data_filled_mean = inhibition_data.fillna(mean_for_filling)
mean_filled_mean = data_filled_mean.mean()
std_filled_mean = data_filled_mean.std()
count_filled_mean = data_filled_mean.count()
print(f"策略三 (用均值填充):")
print(f" 基于 {
count_filled_mean} 个样本计算")
print(f" 平均抑制率: {
mean_filled_mean:.2f}% <-- 平均值与原始计算值相同")
print(f" 标准差: {
std_filled_mean:.2f}% <-- 人为地降低了数据的离散度!")
print("-" * 30)
# --- 策略四:用中位数填充 ---
# 当数据中可能存在极端值时,用中位数填充比均值更稳健
median_for_filling = inhibition_data.median() # 计算中位数
data_filled_median = inhibition_data.fillna(median_for_filling)
mean_filled_median = data_filled_median.mean()
std_filled_median = data_filled_median.std()
count_filled_median = data_filled_median.count()
print(f"策略四 (用中位数填充):"