【Python】Pandas

第一部分:Pandas 核心基石 —— Series 与 DataFrame 深度剖析

Pandas 是 Python 数据分析生态系统的核心库,它构建在 NumPy 之上,提供了高性能、易用的数据结构和数据分析工具。理解 Pandas 的核心数据结构——SeriesDataFrame——的内部机制、创建方式、基本操作以及它们与 NumPy 的关系,是掌握 Pandas 的第一步,也是至关重要的一步。

1.1 Series:一维带标签数组的威力

Series 是 Pandas 中最基本的一维数据结构,可以看作是一个带标签的 NumPy 数组。它由两部分组成:

数据 (values):通常是一个 NumPy 数组,存储实际的数据。
索引 (index):一个与数据相关联的标签序列,用于访问和标识数据。索引可以是整数、字符串、日期时间等。

1.1.1 Series 的创建与基本属性
a. 从不同数据源创建 Series

Pandas 提供了多种创建 Series 对象的方式:

import pandas as pd
import numpy as np

# 1. 从 Python 列表创建 Series
#    默认情况下,索引是 RangeIndex(0, 1, 2, ...)
data_list = [10, 20, 30, 40, 50] # 定义一个Python列表
s_from_list = pd.Series(data_list) # 从列表创建Series
print("--- Series from Python List (default index) ---")
print(s_from_list)
# 输出:
# 0    10
# 1    20
# 2    30
# 3    40
# 4    50
# dtype: int64

# 指定自定义索引
custom_index_list = ['a', 'b', 'c', 'd', 'e'] # 定义自定义索引列表
s_from_list_custom_index = pd.Series(data_list, index=custom_index_list) # 创建带有自定义索引的Series
print("
--- Series from Python List (custom index) ---")
print(s_from_list_custom_index)
# 输出:
# a    10
# b    20
# c    30
# d    40
# e    50
# dtype: int64

# 2. 从 NumPy 数组创建 Series
#    这是非常常见的方式,因为 Pandas 底层大量依赖 NumPy
np_array = np.array([1.1, 2.2, 3.3, 4.4, 5.5]) # 定义一个NumPy数组
s_from_numpy = pd.Series(np_array) # 从NumPy数组创建Series (默认索引)
print("
--- Series from NumPy Array (default index) ---")
print(s_from_numpy)
# 输出:
# 0    1.1
# 1    2.2
# 2    3.3
# 3    4.4
# 4    5.5
# dtype: float64

# 指定索引和名称 (name 属性用于标识 Series 本身)
s_from_numpy_named = pd.Series(np_array, index=['row1', 'row2', 'row3', 'row4', 'row5'], name='MyFloatSeries') # 创建带有索引和名称的Series
print("
--- Series from NumPy Array (custom index and name) ---")
print(s_from_numpy_named)
# 输出:
# row1    1.1
# row2    2.2
# row3    3.3
# row4    4.4
# row5    5.5
# Name: MyFloatSeries, dtype: float64

# 3. 从 Python 字典创建 Series
#    字典的键 (keys) 默认成为 Series 的索引。
#    字典的值 (values) 成为 Series 的数据。
#    Series 中元素的顺序将遵循字典键的插入顺序 (Python 3.7+,或排序后的顺序)。
data_dict = {
            'apple': 100, 'banana': 200, 'cherry': 150, 'date': 300} # 定义一个Python字典
s_from_dict = pd.Series(data_dict) # 从字典创建Series
print("
--- Series from Python Dictionary ---")
print(s_from_dict)
# 输出 (顺序可能因Python版本而异,但通常是插入顺序或排序后):
# apple     100
# banana    200
# cherry    150
# date      300
# dtype: int64

# 如果在创建时额外指定了 index 参数,Pandas 会根据这个指定的 index 来构建 Series:
# - 如果指定 index 中的标签在字典的键中存在,则取对应的值。
# - 如果指定 index 中的标签在字典的键中不存在,则对应的值为 NaN (Not a Number)。
# - 字典中存在但未在指定 index 中出现的键将被忽略。
explicit_index_for_dict = ['banana', 'date', 'elderberry', 'apple'] # 定义显式索引
s_from_dict_explicit_index = pd.Series(data_dict, index=explicit_index_for_dict) # 使用显式索引从字典创建Series
print("
--- Series from Python Dictionary (with explicit index) ---")
print(s_from_dict_explicit_index)
# 输出:
# banana        200.0  (存在于字典,取值)
# date          300.0  (存在于字典,取值)
# elderberry      NaN  (不在字典中,值为NaN)
# apple         100.0  (存在于字典,取值)
# dtype: float64  (因为引入了NaN,整数类型会自动提升为浮点数)

# 4. 从标量值创建 Series
#    如果数据是一个标量值,则必须提供索引。该标量值会被重复填充到与索引长度相同。
scalar_value = 7 # 定义一个标量值
scalar_index = ['x', 'y', 'z'] # 定义索引
s_from_scalar = pd.Series(scalar_value, index=scalar_index) # 从标量创建Series
print("
--- Series from Scalar Value ---")
print(s_from_scalar)
# 输出:
# x    7
# y    7
# z    7
# dtype: int64

# 也可以不提供数据,只提供索引和 dtype,创建一个空的或特定类型的 Series
empty_s_with_index = pd.Series(index=['id1', 'id2'], dtype='object') # 创建一个指定索引和类型的空Series
print("
--- Empty Series with specified index and dtype ---")
print(empty_s_with_index)
# 输出:
# id1    NaN
# id2    NaN
# dtype: object

这段代码全面展示了创建Pandas Series的各种常用方法:

从Python列表创建:可以直接将列表传递给 pd.Series()。如果不指定 index 参数,Pandas会自动创建一个从0开始的整数范围索引 (RangeIndex)。如果提供了 index 参数(一个与数据等长的列表或类数组对象),则会使用这个自定义索引。
从NumPy数组创建:这是非常自然和高效的方式,因为 Series 的底层数据存储通常就是NumPy数组。同样可以指定自定义索引和 name 属性。name 属性可以给 Series 本身一个描述性名称,这在后续将 Series 组合成 DataFrame 或进行绘图时很有用。
从Python字典创建

当直接将字典传递给 pd.Series() 时,字典的键会成为 Series 的索引,字典的值成为 Series 的数据。在Python 3.7+版本中,Series 中元素的顺序会保持字典键的插入顺序;在更早的版本中,或者如果字典本身是无序的,Series 的索引可能会被排序。
如果在从字典创建 Series 时显式地提供了 index 参数,Pandas的行为会有所不同:它会严格按照提供的 index 来构建 Series。如果 index 中的某个标签存在于字典的键中,则取该键对应的值;如果不存在,则对应的值被设为 NaN (Not a Number)。任何存在于字典中但未出现在显式 index 里的键值对都将被忽略。值得注意的是,如果因为引入 NaN 而导致原始数据类型(如整数)无法表示 NaN,Pandas会自动将该 Series 的数据类型(dtype)提升为可以容纳 NaN 的类型(通常是 float64)。

从标量值创建:如果传递给 pd.Series() 的数据是一个单一的标量值(如一个数字或字符串),那么必须同时提供 index 参数。Pandas会将这个标量值重复广播,以匹配所提供索引的长度。
创建空或特定类型的Series:可以不传递数据参数(或传递 None),只提供 index 和可选的 dtype 来创建一个所有值都为 NaN (或相应类型的“空”值) 的 Series。这在预先定义数据结构框架时可能有用。

理解这些创建方式是灵活使用 Series 的基础。根据数据来源和期望的结构选择合适的创建方法非常重要。

b. Series 的核心属性

Series 对象有很多有用的属性,可以帮助我们了解其结构和内容:

import pandas as pd
import numpy as np

# 创建一个示例 Series 用于演示属性
data = np.array([10., 20., np.nan, 40., 50.]) # 包含NaN的浮点数数据
idx = pd.Index(['alpha', 'beta', 'gamma', 'delta', 'epsilon'], name='MyIndex') # 创建一个带名称的Index对象
s_example = pd.Series(data, index=idx, name='SampleSeries') # 创建示例Series

print("--- Example Series for Attribute Demonstration ---")
print(s_example)

# 1. values: 返回 Series 中的实际数据,通常是一个 NumPy 数组。
series_values = s_example.values # 获取Series的值 (NumPy数组)
print(f"
1. s_example.values: {
              series_values}, 类型: {
              type(series_values)}")
# 输出: [10. 20. nan 40. 50.], 类型: <class 'numpy.ndarray'>
# 注意:从 Pandas 1.0 开始,对于特定扩展类型 (如可空整数、分类类型等),
# .values 可能返回一个 ExtensionArray 而不是 NumPy 数组。
# 更推荐使用 .array 或 .to_numpy() 来获取底层数组。

# 2. array: 返回存储在 Series 中的底层数组对象。
#    对于标准数据类型,这通常是 NumPy ndarray。
#    对于扩展数据类型,这是 ExtensionArray。
series_array = s_example.array # 获取Series的底层数组对象
print(f"2. s_example.array: {
              series_array}, 类型: {
              type(series_array)}")
# 输出: <PandasArray>
# [10.0, 20.0, nan, 40.0, 50.0]
# Length: 5, dtype: float64
# 对于标准 float64,.array 返回的是一个 PandasArray 包装器,其底层仍是 NumPy 数组。

# 3. to_numpy(): 明确返回一个 NumPy ndarray。
#    可以指定 dtype 和 na_value。
series_numpy_array = s_example.to_numpy(dtype=float, na_value=np.nan) # 转换为NumPy数组
print(f"3. s_example.to_numpy(): {
              series_numpy_array}, 类型: {
              type(series_numpy_array)}")
# 输出: [10. 20. nan 40. 50.], 类型: <class 'numpy.ndarray'>

# 4. index: 返回 Series 的索引对象 (pd.Index)。
series_index = s_example.index # 获取Series的索引对象
print(f"
4. s_example.index: {
              series_index}")
# 输出: Index(['alpha', 'beta', 'gamma', 'delta', 'epsilon'], dtype='object', name='MyIndex')
print(f"   索引的类型: {
              type(series_index)}") # <class 'pandas.core.indexes.base.Index'>
print(f"   索引的名称: {
              series_index.name}") # MyIndex

# 5. dtype: 返回 Series 中数据值的 NumPy 数据类型 (dtype)。
series_dtype = s_example.dtype # 获取Series的数据类型
print(f"
5. s_example.dtype: {
              series_dtype}") # float64

# 6. shape: 返回一个表示 Series 形状的元组 (只有一个元素,即其长度)。
series_shape = s_example.shape # 获取Series的形状
print(f"
6. s_example.shape: {
              series_shape}") # (5,)

# 7. ndim: 返回 Series 的维度数量 (总是1)。
series_ndim = s_example.ndim # 获取Series的维度数量
print(f"
7. s_example.ndim: {
              series_ndim}") # 1

# 8. size: 返回 Series 中元素的总数 (包括 NaN)。
series_size = s_example.size # 获取Series的元素总数
print(f"
8. s_example.size: {
              series_size}") # 5

# 9. name: 返回 Series 的名称 (如果已设置)。
series_name = s_example.name # 获取Series的名称
print(f"
9. s_example.name: {
              series_name}") # SampleSeries
# 可以修改 name 属性
s_example.name = 'UpdatedSampleSeries' # 修改Series的名称
print(f"   修改后的 s_example.name: {
              s_example.name}") # UpdatedSampleSeries

# 10. hasnans: 返回一个布尔值,指示 Series 是否包含任何 NaN 值。
series_hasnans = s_example.hasnans # 检查Series是否包含NaN
print(f"
10. s_example.hasnans: {
              series_hasnans}") # True (因为我们包含了一个np.nan)

# 11. empty: 返回一个布尔值,指示 Series 是否为空 (即长度为0)。
s_empty = pd.Series(dtype=float) # 创建一个空的Series
print(f"
11. s_empty.empty: {
              s_empty.empty}") # True
print(f"    s_example.empty: {
              s_example.empty}") # False

# 12. T / transpose(): 对于 Series,转置操作返回其自身 (因为是一维的)。
series_transposed = s_example.T # 获取Series的转置 (返回自身)
print(f"
12. s_example.T (转置,对于Series返回自身):")
print(series_transposed)
# (与 s_example 相同)

# 13. memory_usage(deep=False, index=True): 返回 Series 对象占用的内存字节数。
#     deep=True 会深入检查 'object' 类型数据内部元素的内存占用。
#     index=True (默认) 会包含索引的内存占用。
memory_bytes = s_example.memory_usage(deep=True, index=True) # 计算Series的内存占用
print(f"
13. s_example.memory_usage(deep=True, index=True): {
              memory_bytes} 字节")
# 输出可能类似: 400 (具体值取决于索引类型和数据)
# 解释:
#   - 索引 ('alpha'...'epsilon') 是对象类型,deep=True会计算字符串实际占用的内存。
#   - 数据 (5个float64) 是 5 * 8 = 40 字节。
#   - 索引对象本身也有开销。

# 14. is_unique: 检查Series中的所有值是否唯一。
s_unique_check = pd.Series([1, 2, 3, 2, 4]) # 包含重复值的Series
print(f"
14. s_unique_check.is_unique: {
              s_unique_check.is_unique}") # False
s_all_unique = pd.Series([10, 20, 30]) # 值全部唯一的Series
print(f"    s_all_unique.is_unique: {
              s_all_unique.is_unique}") # True

# 15. is_monotonic_increasing / is_monotonic_decreasing: 检查值是否单调。
#     在Pandas 1.2.0中,is_monotonic 被弃用,拆分为 is_monotonic_increasing 和 is_monotonic_decreasing
s_monotonic = pd.Series([1, 2, 2, 3, 5]) # 单调递增的Series
print(f"
15. Monotonicity checks for s_monotonic = {
              s_monotonic.to_list()}:")
print(f"    is_monotonic_increasing: {
              s_monotonic.is_monotonic_increasing}") # True
print(f"    is_monotonic_decreasing: {
              s_monotonic.is_monotonic_decreasing}") # False

s_monotonic_strict = pd.Series([1, 2, 3, 5]) # 严格单调递增的Series
print(f"    s_monotonic_strict ({
              s_monotonic_strict.to_list()}) is_monotonic_increasing: {
              s_monotonic_strict.is_monotonic_increasing}") # True

s_non_monotonic = pd.Series([1, 3, 2, 4]) # 非单调的Series
print(f"    s_non_monotonic ({
              s_non_monotonic.to_list()}) is_monotonic_increasing: {
              s_non_monotonic.is_monotonic_increasing}") # False

# 对于索引也有这些单调性检查
print(f"    s_example.index.is_monotonic_increasing: {
              s_example.index.is_monotonic_increasing}") # True (因为 'alpha'...'epsilon' 是有序的)

这段代码详细地演示和解释了 Series 对象的一些核心属性:

.values: 返回 Series 中的数据,通常是NumPy ndarray。但对于Pandas的扩展数据类型(如可空整数 Int64Dtype,分类 CategoricalDtype),它可能返回一个 ExtensionArray
.array: 这是更现代的访问底层数据的方式,它总是返回一个 ExtensionArray 实例(对于标准NumPy dtypes,返回的是 PandasArray,它是对NumPy数组的轻量级包装;对于Pandas特定的扩展类型,返回的是相应的 ExtensionArray)。这个属性旨在提供一个更一致的接口来处理不同类型的数据存储。
.to_numpy(): 这是一个方法,明确地将 Series 的数据转换为NumPy ndarray。你可以指定转换后的 dtype 以及如何处理 NaN 值(通过 na_value 参数)。这是获取纯NumPy数组的推荐方式,特别是当你需要与期望NumPy数组的库交互时。
.index: 返回 Series 的索引对象,它本身是一个 pd.Index (或其子类,如 RangeIndex, DatetimeIndex, CategoricalIndex 等)的实例。索引对象有很多自己的属性和方法,例如 .name (索引的名称), .is_unique (索引值是否唯一), .is_monotonic_increasing (索引是否单调递增) 等。
.dtype: 返回 Series 中数据值的NumPy数据类型 (numpy.dtype 对象)。这告诉你数据是如何存储的(例如,int64, float64, object, bool, datetime64[ns], category 等)。
.shape: 对于 Series(一维),它返回一个只包含一个元素的元组 (length,),表示 Series 的长度。
.ndim: Series 的维度数量,总是1。
.size: Series 中元素的总数,等同于其长度 len(series)
.name: Series 的名称,是一个字符串。可以在创建时指定,也可以后续赋值修改。当 Series 作为 DataFrame 的一列时,其 name 通常就是列名。
.hasnans: 一个布尔值,如果 Series 中至少包含一个 NaN (Not a Number) 或 NaT (Not a Time,用于日期时间类型) 或 None (在对象类型中),则为 True,否则为 False。这是一个快速检查是否存在缺失值的方法。
.empty: 一个布尔值,如果 Series 的长度为0,则为 True
.T (或方法 .transpose())**: 对于 Series,转置操作返回其自身,因为一维数组的转置没有改变其结构。这与 DataFrame 的转置行为不同。
.memory_usage(deep=False, index=True): 这是一个方法,用于估算 Series 对象在内存中占用的字节数。

deep=False (默认):只计算数据缓冲区本身(如NumPy数组)和索引对象的基本内存占用,不深入检查 object 类型数据内部元素(如字符串)的实际内存。
deep=True:如果 Seriesdtypeobject,它会尝试递归地计算对象内部元素(如每个字符串)的真实内存占用,这会更准确但计算也更慢。
index=True (默认):计算结果中包含索引对象的内存占用。设为 False 则不包含。
这个方法对于理解和优化大规模数据的内存消耗非常有用。

.is_unique: 布尔属性,如果 Series 中的所有值都是唯一的(没有重复项),则为 True
.is_monotonic_increasing / .is_monotonic_decreasing: 布尔属性,检查 Series 中的值是否是单调递增(允许相等)或单调递减的。对于已排序或期望排序的数据,这是一个有用的验证。索引对象 (series.index) 也有这些单调性检查属性。

这些属性为我们提供了关于 Series 结构、内容、类型和状态的丰富信息,是在进行数据探索、清洗和转换时的重要参考。

1.1.2 Series 的核心功能:索引与选择

Series 强大的功能很大程度上源于其灵活的索引和选择机制。它结合了NumPy数组的位置(整数)索引和类似Python字典的标签索引。

a. 基于标签的索引 (.loc)

.loc 属性主要用于基于标签的索引和切片。

s.loc[label]: 选择单个标签对应的值。
s.loc[[label1, label2, ...]]: 选择多个标签对应的值,返回一个新的 Series
s.loc[start_label:end_label]: 进行基于标签的切片。重要的是,对于标签切片,结束标签 end_label 是包含在内的 (inclusive),这与Python列表或NumPy数组的位置切片(不包含结束位置)不同。
s.loc[boolean_series]: 使用一个与 s 索引对齐的布尔 Series 进行选择,返回 s 中对应 True 的元素。

b. 基于整数位置的索引 (.iloc)

.iloc 属性主要用于基于整数位置 (0-based) 的索引和切片,其行为非常类似于NumPy数组和Python列表。

s.iloc[position]: 选择单个整数位置对应的值。
s.iloc[[pos1, pos2, ...]]: 选择多个整数位置对应的值。
s.iloc[start_pos:end_pos]: 进行基于整数位置的切片。结束位置 end_pos 是不包含在内的 (exclusive)
s.iloc[boolean_numpy_array]: 可以使用一个与 s 等长的NumPy布尔数组进行选择。

c.直接索引 ([]) 的行为与歧义

直接使用方括号 [] 进行索引(如 s[key])时,Pandas会尝试“智能地”判断 key 是标签还是整数位置,这有时会导致行为不明确或意外。

如果 Series 的索引是整数类型:

如果 key 是一个整数,Pandas 通常会将其解释为标签索引(如果该整数标签存在于索引中)。如果该整数标签不存在,但它在有效的整数位置范围内 (0 到 len(s)-1),则可能会引发 KeyError (如果想用标签但标签不存在) 或在某些旧版本/情况下回退到位置索引(不推荐依赖这种回退)。
如果 key 是一个整数切片 (如 s[0:3]),Pandas 通常会将其解释为位置切片。

如果 Series 的索引是非整数类型(如字符串、日期时间):

如果 key 是索引的标签类型 (如字符串),则执行标签索引。
如果 key 是一个整数或整数切片,则执行位置索引。

最佳实践:为了代码的清晰性和避免歧义,强烈建议在需要明确基于标签索引时使用 .loc,在需要明确基于整数位置索引时使用 .iloc。直接使用 [] 应该谨慎,尤其是在索引类型可能变化或包含整数标签的情况下。

d. 条件选择 (Boolean Indexing)

这是 Series (和 DataFrame) 中非常强大的一种选择方式。可以通过一个布尔 Series (通常由对原 Series 进行条件比较运算得到) 来选择满足条件的元素。

import pandas as pd
import numpy as np

# 创建一个更复杂的 Series 用于索引和选择演示
# 包含整数标签、字符串标签、以及重复的索引
data_values = np.arange(10, 80, 10) # [10, 20, 30, 40, 50, 60, 70]
complex_index_labels = ['a', 'b', 'c', 'a', 'd', 'e', 'c'] # 包含重复标签
s_complex = pd.Series(data_values, index=complex_index_labels, name='ComplexSeries')

print("--- Series for Indexing/Selection Demonstration (s_complex) ---")
print(s_complex)
# 输出:
# a    10
# b    20
# c    30
# a    40  (注意:标签'a'和'c'是重复的)
# d    50
# e    60
# c    70
# Name: ComplexSeries, dtype: int64

# 1. 基于标签的索引 (.loc)
print("
--- 1. Label-based Indexing (.loc) ---")
# a) 选择单个标签
print(f"s_complex.loc['b']: {
              s_complex.loc['b']}") # 输出: 20

# b) 选择单个重复标签 (如 'a')
#    如果标签是唯一的,s.loc[label] 返回标量值。
#    如果标签是重复的,s.loc[label] 返回一个新的 Series,包含所有匹配该标签的元素。
print(f"s_complex.loc['a']:
{
              s_complex.loc['a']}")
# 输出:
# a    10
# a    40
# Name: ComplexSeries, dtype: int64

# c) 选择多个标签 (使用列表)
selected_labels = ['e', 'c', 'missing_label'] # 包含一个不存在的标签
try:
    print(f"s_complex.loc[['e', 'c']]:
{
              s_complex.loc[['e', 'c']]}")
    # 输出 (顺序与列表一致,'c'的结果会包含所有匹配项):
    # e    60
    # c    30
    # c    70
    # Name: ComplexSeries, dtype: int64
    
    # 如果选择的标签列表中包含不存在的标签,.loc 会引发 KeyError
    # print(s_complex.loc[selected_labels]) # 这会引发 KeyError: "['missing_label'] not found in axis"
except KeyError as e: # 捕获KeyError
    print(f"Error with s_complex.loc[{
              selected_labels}]: {
              e}")

# d) 标签切片 (结束标签包含在内)
#    注意:标签切片要求索引是已排序的,或者至少对于切片范围内的标签是明确有序的。
#    如果索引未排序且有重复,标签切片的行为可能不直观或引发错误,取决于Pandas版本。
#    对于 s_complex,其索引不是严格单调的。
#    如果索引是单调的,例如:
s_sorted_index = pd.Series([1,2,3,4], index=['w','x','y','z']) # 创建一个索引排序的Series
print(f"s_sorted_index:
{
              s_sorted_index}")
print(f"s_sorted_index.loc['x':'z'] (inclusive end):
{
              s_sorted_index.loc['x':'z']}")
# 输出:
# x    2
# y    3
# z    4
# dtype: int64

# 对于 s_complex,如果尝试对其未排序的重复索引进行标签切片,行为可能依赖于Pandas版本
# 并且通常不推荐。例如 s_complex.loc['b':'d'] 可能会基于标签出现的第一个位置进行切片。
# 为了安全和可预测性,最好在标签切片前确保索引是有意义的(如已排序或唯一)。
try:
    print(f"s_complex.loc['b':'d'] (on non-monotonic index with duplicates):")
    # 行为可能取决于Pandas版本。通常它会尝试找到'b'的第一个出现和'd'的第一个出现之间的所有元素
    #(包括边界)。
    print(s_complex.loc['b':'d'])
    # 可能输出:
    # b    20
    # c    30
    # a    40
    # d    50
    # Name: ComplexSeries, dtype: int64
except Exception as e: # 捕获可能的异常
    print(f"Error with s_complex.loc['b':'d']: {
              e}")


# 2. 基于整数位置的索引 (.iloc)
print("
--- 2. Integer Position-based Indexing (.iloc) ---")
# a) 选择单个位置
print(f"s_complex.iloc[0]: {
              s_complex.iloc[0]}") # 第一个元素 (对应标签 'a', 值为 10)
print(f"s_complex.iloc[-1]: {
              s_complex.iloc[-1]}") # 最后一个元素 (对应标签 'c', 值为 70)

# b) 选择多个位置 (使用列表)
print(f"s_complex.iloc[[1, 3, 5]]:
{
              s_complex.iloc[[1, 3, 5]]}")
# 输出 (对应位置1,3,5的元素):
# b    20
# a    40
# e    60
# Name: ComplexSeries, dtype: int64

# c) 位置切片 (结束位置不包含在内)
print(f"s_complex.iloc[1:4] (exclusive end, positions 1, 2, 3):
{
              s_complex.iloc[1:4]}")
# 输出:
# b    20
# c    30
# a    40
# Name: ComplexSeries, dtype: int64

print(f"s_complex.iloc[:3] (first 3 elements):
{
              s_complex.iloc[:3]}") # 前3个元素
print(f"s_complex.iloc[-3:] (last 3 elements):
{
              s_complex.iloc[-3:]}") # 后3个元素


# 3. 直接索引 `[]` 的行为
print("
--- 3. Direct Indexing `[]` ---")
# 对于 s_complex,其索引是字符串。
# a) 使用标签 (如果标签存在)
print(f"s_complex['b']: {
              s_complex['b']}") # 标签索引,输出 20
print(f"s_complex['a'] (direct, duplicate label):
{
              s_complex['a']}") # 标签索引,返回Series

# b) 使用整数 (因为索引非整数,所以整数被解释为位置)
print(f"s_complex[0] (direct, int on non-int index -> positional): {
              s_complex[0]}") # 位置索引,输出 10
# 但是,如果索引本身就是整数,直接用整数索引会优先尝试匹配标签!
s_int_index = pd.Series([100, 200, 300], index=[1, 5, 10]) # 创建一个整数索引的Series
print(f"
Series with integer index (s_int_index):
{
              s_int_index}")
print(f"s_int_index[1] (direct, int on int index -> label): {
              s_int_index[1]}") # 匹配标签1,输出100
# print(s_int_index[0]) # 这会引发 KeyError,因为标签0不存在,且Pandas不会回退到位置0
try:
    print(s_int_index[0]) # 尝试用整数0进行直接索引
except KeyError as e: # 捕获KeyError
    print(f"Error with s_int_index[0]: {
              e} (label 0 not found)")

# c) 使用切片 (通常被解释为位置切片,即使索引是标签)
print(f"s_complex[1:4] (direct slice on non-int index -> positional slice):
{
              s_complex[1:4]}")
# 输出 (与 s_complex.iloc[1:4] 相同):
# b    20
# c    30
# a    40
# Name: ComplexSeries, dtype: int64

# 对于整数索引的 Series,整数切片也是位置切片
print(f"s_int_index[0:2] (direct slice on int index -> positional slice):
{
              s_int_index[0:2]}")
# 输出 (位置0和1的元素):
# 1     100
# 5     200
# dtype: int64

# **强烈建议使用 .loc 和 .iloc 来避免 `[]` 的歧义!**


# 4. 条件选择 (Boolean Indexing)
print("
--- 4. Conditional Selection (Boolean Indexing) ---")
# a) 创建一个布尔 Series 作为掩码
mask_gt_30 = s_complex > 30 # 创建一个布尔Series,标记s_complex中值大于30的元素
print(f"Boolean mask (s_complex > 30):
{
              mask_gt_30}")
# 输出:
# a    False
# b    False
# c    False
# a     True
# d     True
# e     True
# c     True
# Name: ComplexSeries, dtype: bool

# b) 使用布尔 Series (掩码) 进行选择
selected_by_mask = s_complex[mask_gt_30] # 使用布尔掩码进行索引
# 这等效于 s_complex.loc[mask_gt_30] (通常更推荐 .loc)
print(f"s_complex[s_complex > 30]:
{
              selected_by_mask}")
# 输出 (所有值大于30的元素):
# a    40
# d    50
# e    60
# c    70
# Name: ComplexSeries, dtype: int64

# c) 组合条件
# 选择值大于20且其标签为 'a' 或 'c' 的元素
mask_combined = (s_complex > 20) & (s_complex.index.isin(['a', 'c'])) # 创建组合条件的布尔掩码
# s_complex.index.isin(['a', 'c']) 返回一个与 s_complex 等长的布尔数组,
# 标记索引是否在 ['a', 'c'] 中。
print(f"
Combined mask ((s_complex > 20) & (index is 'a' or 'c')):
{
              mask_combined}")
print(f"s_complex with combined condition:
{
              s_complex[mask_combined]}")
# 输出:
# c    30  (value=30, index='c')
# a    40  (value=40, index='a')
# c    70  (value=70, index='c')
# Name: ComplexSeries, dtype: int64

# d) 条件选择与赋值
s_copy_for_assignment = s_complex.copy() # 创建一个副本用于赋值操作
print(f"
Original s_copy_for_assignment (first 'a'): {
              s_copy_for_assignment.loc['a'].iloc[0]}") # 打印第一个'a'的值
# 将所有标签为 'a' 且值小于 20 的元素的值更新为 -1
s_copy_for_assignment.loc[(s_copy_for_assignment.index == 'a') & (s_copy_for_assignment < 20)] = -1 # 使用组合条件进行赋值
print(f"After assigning -1 to ('a' & <20), s_copy_for_assignment['a']:
{
              s_copy_for_assignment.loc['a']}")
# 输出:
# a    -1  (原值为10,被修改)
# a    40  (原值为40,未被修改)
# Name: ComplexSeries, dtype: int64

# 5. `.get()` 方法:类似字典的访问,带默认值
print("
--- 5. .get() method for label access ---")
print(f"s_complex.get('b'): {
              s_complex.get('b')}") # 输出: 20
print(f"s_complex.get('x'): {
              s_complex.get('x')}") # 标签'x'不存在,默认返回 None
print(f"s_complex.get('x', default=-999): {
              s_complex.get('x', default=-999)}") # 指定默认值 -999

# 如果标签重复,.get() 的行为与 .loc[] 类似,会返回一个 Series
print(f"s_complex.get('a'):
{
              s_complex.get('a')}")
# 输出 (与 s_complex.loc['a'] 相同):
# a    10
# a    40
# Name: ComplexSeries, dtype: int64

这段代码详细演示了 Series 对象的各种索引和选择方法:

.loc (基于标签)

选择单个标签:如果标签唯一,返回标量值;如果标签重复(如示例中的 'a''c'),则返回一个新的 Series,包含所有具有该标签的条目。
选择多个标签(通过列表):返回一个新的 Series。如果列表中包含不存在于索引中的标签,.loc 会引发 KeyError
标签切片 (start_label:end_label):结束标签是包含在内的。这要求索引至少在切片范围内是有序的。如果索引完全无序或有复杂的重复模式,标签切片的行为可能不直观或引发错误,因此建议在进行标签切片前确保索引的有序性或唯一性(如果适用)。

.iloc (基于整数位置)

其行为与Python列表和NumPy数组的整数索引/切片完全一致。
选择单个位置、多个位置(通过整数列表)、或进行位置切片(如 1:4,不包含结束位置 4)。支持负数索引(如 -1 表示最后一个元素)。

直接索引 []

其行为具有“智能”判断的特性,试图根据键的类型和索引的类型来决定是执行标签索引还是位置索引。
主要规则:如果索引是非整数类型(如字符串),则整数键被视为位置,非整数键被视为标签。如果索引是整数类型,则整数键优先被视为标签;如果该整数标签不存在,通常会引发 KeyError(现代Pandas版本一般不会轻易回退到位置索引来避免歧义)。
切片:无论索引类型如何,直接使用 [] 进行切片(如 s[0:3])通常被解释为位置切片
强烈建议:为了代码的清晰性和可维护性,应优先使用 .loc 进行显式的标签索引,使用 .iloc 进行显式的位置索引,以避免由 [] 的歧义行为可能导致的错误或困惑。

条件选择 (Boolean Indexing)

这是Pandas中一种极其强大的数据选择方式。
首先,创建一个与原 Series 索引对齐的布尔 Series(通常通过对原 Series 应用比较运算符如 ><==,或逻辑运算符如 & (AND), | (OR), ~ (NOT) 来组合多个条件)。
然后,将这个布尔 Series 用作索引(通常通过 .loc 或直接 [])来从原 Series 中选择所有对应布尔值为 True 的元素。
示例中演示了如何创建简单条件掩码、组合条件掩码(例如,值大于20 索引标签是 ‘a’ 或 ‘c’),以及如何使用布尔掩码进行条件赋值。

.get() 方法

提供了类似Python字典的 .get(key, default=None) 访问方式。
如果 key (标签) 存在于索引中,返回对应的值(如果标签重复,返回一个 Series)。
如果 key 不存在,它不会引发 KeyError,而是返回 None(或者通过 default 参数指定的默认值)。这在不确定某个标签是否一定存在时非常有用。

掌握这些索引和选择技术是高效使用Pandas Series 的关键。它们允许我们以各种灵活的方式精确地访问、过滤和修改数据,是进行数据清洗、转换和分析的基础操作。特别地,条件选择(布尔索引)在实际数据处理中应用极为广泛。

1.1.3 Series 的基本运算与对齐

Series 对象支持广泛的算术运算、逻辑运算和函数应用。一个核心特性是,当对两个 Series 对象进行运算时,Pandas 会自动按索引对齐数据。

a. 算术运算与数据对齐

当你对两个 Series 执行算术运算(如 +, -, *, /)时:

Pandas会查找两个 Series 中共同的索引标签。
对于共同的标签,执行相应的运算。
对于只存在于一个 Series 中的标签,结果中该标签对应的值将是 NaN(因为无法找到匹配项进行运算)。
结果 Series 的索引将是两个原始 Series 索引的并集,并排序。

import pandas as pd
import numpy as np

s1_data = {
            'a': 10, 'b': 20, 'c': 30, 'd': 40} # 第一个Series的数据
s1 = pd.Series(s1_data, name='Series1') # 创建第一个Series

s2_data = {
            'c': 300, 'd': 400, 'e': 500, 'f': 600} # 第二个Series的数据,索引部分重叠
s2 = pd.Series(s2_data, name='Series2') # 创建第二个Series

print("--- Series for Arithmetic Operations ---")
print("s1:
", s1)
# a    10
# b    20
# c    30
# d    40
# Name: Series1, dtype: int64
print("
s2:
", s2)
# c    300
# d    400
# e    500
# f    600
# Name: Series2, dtype: int64

# 1. Series 相加 (自动按索引对齐)
s_add = s1 + s2 # 对两个Series进行加法运算
print("
--- s1 + s2 (Addition with Alignment) ---")
print(s_add)
# 预期结果:
# a      NaN  (s2中无'a')
# b      NaN  (s2中无'b')
# c    330.0  (30 + 300)
# d    440.0  (40 + 400)
# e      NaN  (s1中无'e')
# f      NaN  (s1中无'f')
# dtype: float64 (因为引入了NaN,可能从int提升为float)

# 2. 使用算术方法并提供 fill_value
#    当你希望对于不匹配的索引使用一个默认值而不是NaN时,可以使用算术运算的方法版本
#    (如 `.add()`, `.sub()`, `.mul()`, `.div()`) 并指定 `fill_value` 参数。
#    `fill_value` 会在对齐过程中替换缺失的索引对应的值。

# s1.add(s2, fill_value=0):
#   - 对于 s1 中有但 s2 中没有的索引 (a, b),s2 中对应位置被视为0。
#   - 对于 s2 中有但 s1 中没有的索引 (e, f),s1 中对应位置被视为0。
s_add_fill_0 = s1.add(s2, fill_value=0) # 使用add方法并指定fill_value为0
print("
--- s1.add(s2, fill_value=0) ---")
print(s_add_fill_0)
# 预期结果:
# a     10.0  (10 + 0)
# b     20.0  (20 + 0)
# c    330.0  (30 + 300)
# d    440.0  (40 + 400)
# e    500.0  (0 + 500)
# f    600.0  (0 + 600)
# dtype: float64 (即使填充0,如果原始Series是整数,结果通常也会是float以保持一致性)

# 3. Series 与标量运算
#    当 Series 与一个标量进行运算时,该运算会广播到 Series 的每个元素。
s_times_10 = s1 * 10 # Series与标量相乘
print("
--- s1 * 10 (Series with Scalar) ---")
print(s_times_10)
# 输出:
# a    100
# b    200
# c    300
# d    400
# Name: Series1, dtype: int64

s_power_2 = s1 ** 2 # Series中每个元素求平方
print("
--- s1 ** 2 (Element-wise Power) ---")
print(s_power_2)
# 输出:
# a     100
# b     400
# c     900
# d    1600
# Name: Series1, dtype: int64

# 4. 应用 NumPy ufuncs (通用函数)
#    NumPy 的通用函数 (如 np.exp, np.log, np.sqrt, np.sin等) 可以直接应用于 Pandas Series。
#    它们会逐元素地作用于 Series 的值,并保留索引。
s_exp = np.exp(s1 / 10.0) # 对s1的每个元素除以10后取指数 (避免数值过大)
print("
--- np.exp(s1 / 10.0) (Applying NumPy ufunc) ---")
print(s_exp)
# 输出 (保留索引,值为exp(value/10)):
# a     2.718282  (exp(1))
# b     7.389056  (exp(2))
# c    20.085537  (exp(3))
# d    54.598150  (exp(4))
# Name: Series1, dtype: float64

# 5. 缺失值 (NaN) 在运算中的传播
#    大多数算术运算中,任何涉及 NaN 的运算结果通常也是 NaN。
s_with_nan = pd.Series([1, 2, np.nan, 4], index=['x','y','z','w']) # 创建一个包含NaN的Series
print("
s_with_nan:
", s_with_nan)
s_nan_add_5 = s_with_nan + 5 # NaN与标量相加
print("s_with_nan + 5:
", s_nan_add_5)
# 输出:
# x    6.0
# y    7.0
# z    NaN  (NaN + 5 = NaN)
# w    9.0
# dtype: float64

s_another_with_nan = pd.Series([10, np.nan, 30, np.nan], index=['x','y','z','v']) # 创建另一个包含NaN的Series
print("
s_another_with_nan:
", s_another_with_nan)
s_nan_add_nan = s_with_nan + s_another_with_nan # 两个包含NaN的Series相加
print("s_with_nan + s_another_with_nan:
", s_nan_add_nan)
# 输出:
# v      NaN  (s_with_nan中无'v')
# w      NaN  (s_another_with_nan中无'w')
# x     11.0  (1 + 10)
# y      NaN  (2 + NaN)
# z      NaN  (NaN + 30)
# dtype: float64

# 注意:某些聚合函数如 .sum(), .mean() 默认会跳过 NaN 值。
# 例如,s_with_nan.sum() 会是 1+2+4 = 7。
# 如果想让 NaN 参与并传播,可以使用 skipna=False 参数。
print(f"s_with_nan.sum(skipna=True) (default): {
              s_with_nan.sum()}") # 7.0
print(f"s_with_nan.sum(skipna=False): {
              s_with_nan.sum(skipna=False)}") # nan

这段代码清晰地展示了 Series 运算中的核心概念——索引对齐

SeriesSeries 的运算 (s1 + s2):

Pandas会首先找到 s1s2 索引中的交集(共同的标签,如本例中的 ‘c’ 和 ‘d’)。对于这些共同标签,它会取出各自 Series 中对应的值进行指定的算术运算(如加法)。
然后,Pandas会找到只存在于 s1 中的标签(‘a’, ‘b’)和只存在于 s2 中的标签(‘e’, ‘f’)。由于在另一个 Series 中找不到匹配的标签来进行运算,这些标签在结果 Series 中对应的值将被设为 NaN (Not a Number)。
最终结果 Series 的索引是 s1s2 索引的并集,并且通常是排序的。
如果运算中引入了 NaN,并且原始 Series 的数据类型(如 int64)不能表示 NaN,则结果 Series 的数据类型会自动提升为可以容纳 NaN 的类型(通常是 float64)。

使用算术方法并提供 fill_value (s1.add(s2, fill_value=0)):

Pandas Series 对象提供了一系列与标准算术运算符(+, -, *, /, //, %, **)对应的方法版本(如 .add(), .sub(), .mul(), .div(), .floordiv(), .mod(), .pow())。
这些方法版本的一个重要优点是它们接受一个 fill_value 参数。在进行索引对齐时,如果某个标签只存在于一个 Series 中,Pandas 会在另一个 Series 中“虚拟地”用 fill_value 来代替缺失值进行运算,而不是直接产生 NaN
在示例中,s1.add(s2, fill_value=0) 对于标签 ‘a’ 和 ‘b’(只在 s1 中),s2 中对应的值被视为0,所以结果是 s1 的原始值(如 10+0=10)。对于标签 ‘e’ 和 ‘f’(只在 s2 中),s1 中对应的值被视为0,所以结果是 s2 的原始值(如 0+500=500)。对于共同标签 ‘c’ 和 ‘d’,则正常相加。
这在处理可能存在缺失匹配的数据时非常有用,可以避免不必要的 NaN 传播。

Series 与标量运算 (s1 * 10, s1 ** 2):

当一个 Series 与一个标量值(单个数字或字符串,取决于运算)进行运算时,该标量值会被广播Series 的每一个元素上,然后逐元素执行运算。索引保持不变。

应用NumPy通用函数 (ufuncs) (np.exp(s1 / 10.0)):

NumPy 的大量数学函数(如 np.abs, np.sqrt, np.log, np.exp, np.sin, np.cos, 三角函数,统计函数的部分版本等)可以直接应用于Pandas Series 对象。
这些ufuncs会逐元素地作用于 Series 的底层数据(通常是NumPy数组),并且运算结果会自动包装回一个新的 Series,其索引与原始 Series 相同。

缺失值 (NaN) 在运算中的传播

在大多数标准的逐元素算术运算中,如果参与运算的任何一方是 NaN,则结果通常也是 NaN。这是一种“病毒式”传播,确保了缺失信息的影响被正确反映。
示例中 s_with_nan + 5s_with_nan + s_another_with_nan 都清晰地展示了这一点。
需要注意的是,Pandas内置的许多聚合函数(如 .sum(), .mean(), .median(), .std(), .var() 等)默认具有 skipna=True 的行为,即它们在计算时会自动忽略(跳过)NaN 值。如果想让 NaN 参与计算并导致结果也为 NaN(除非所有值都是 NaN,某些函数如 sum 对全 NaN 序列返回0),可以将这些函数的 skipna 参数设为 False

理解Pandas Series的自动索引对齐和 NaN 的处理方式是进行有效数据分析的关键。它使得我们可以非常直观地对来自不同来源、可能具有不同索引或包含缺失值的数据进行组合和运算,而Pandas会负责处理底层的匹配和对齐逻辑。fill_value 选项则提供了在对齐不匹配时控制默认行为的灵活性。

b. 逻辑运算与布尔 Series

Series 也支持逻辑运算符(& for AND, | for OR, ~ for NOT, ^ for XOR)以及比较运算符(>, <, ==, !=, >=, <=)。这些运算通常返回一个布尔型的 Series

import pandas as pd
import numpy as np

s_bool_1 = pd.Series([True, True, False, False], index=['a', 'b', 'c', 'd']) # 第一个布尔Series
s_bool_2 = pd.Series([True, False, True, False], index=['a', 'b', 'c', 'e']) # 第二个布尔Series,索引不同

print("--- Series for Logical Operations ---")
print("s_bool_1:
", s_bool_1)
print("
s_bool_2:
", s_bool_2)

# 1. 逻辑与 (&) - 自动对齐,不匹配为 False (对于布尔上下文) 或 NaN (如果结果非布尔)
#    当Pandas对齐布尔Series进行逻辑运算时,如果一个索引只存在于一方,
#    其行为可能取决于Pandas版本和具体操作。
#    通常,为了保持一致性,未对齐的部分在逻辑运算上下文中可能被视为False,
#    或者如果结果允许NaN,则为NaN。
#    对于标准的 & | ^,Pandas通常会进行外连接并用False填充缺失的布尔值。
s_and = s_bool_1 & s_bool_2 # 对两个布尔Series进行逻辑与运算
print("
--- s_bool_1 & s_bool_2 (Logical AND with Alignment) ---")
print(s_and)
# 预期结果 (Pandas通常将缺失的布尔值视为False进行对齐):
# a     True  (True & True)
# b    False  (True & False)
# c    False  (False & True)
# d    False  (False & (missing in s2 -> treated as False for &))
# e    False  ((missing in s1 -> treated as False for &) & False)
# dtype: bool

# 2. 逻辑或 (|)
s_or = s_bool_1 | s_bool_2 # 对两个布尔Series进行逻辑或运算
print("
--- s_bool_1 | s_bool_2 (Logical OR with Alignment) ---")
print(s_or)
# 预期结果:
# a     True  (True | True)
# b     True  (True | False)
# c     True  (False | True)
# d     True  (False | (missing -> True if other is True, else False if both missing or False))
#            -> Actually: (s1['d']=False | s2_missing_at_d -> False) => False
#            -> Correction: Pandas may fill with False for missing, so False | False = False.
#            -> Wait, if s1['d'] is False, and s2['d'] is missing (effectively False for OR if the other is False), then result is False.
#            -> If s1 had True at 'd', then True | (missing->False) = True.
#            Let's re-check Pandas behavior for OR with unaligned False:
#            It appears to treat unaligned as False.
#            a: True | True -> True
#            b: True | False -> True
#            c: False | True -> True
#            d: False (s1) | (missing s2 -> False) -> False
#            e: (missing s1 -> False) | False (s2) -> False

# 实际Pandas行为 (经过测试,对于 & | ^,未对齐的索引的另一方被视为False):
# s_and:
# a     True
# b    False
# c    False
# d    False
# e    False
# dtype: bool
# s_or:
# a     True
# b     True
# c     True
# d    False  <-- (False | effectively False)
# e    False  <-- (effectively False | False)
# dtype: bool
# 这个行为可能与期望的“如果任一方为True则为True”有所不同当索引不完全匹配时。
# 为了更明确的行为,通常先确保索引一致或使用带fill_value的逻辑方法(如果有)。

# 更好的方式可能是先 reindex 并填充:
s_bool_1_reindexed, s_bool_2_reindexed = s_bool_1.align(s_bool_2, join='outer', fill_value=False) # 对齐并用False填充缺失值
print("
--- Reindexed and Filled Boolean Series for Safer Logical Ops ---")
print("s_bool_1_reindexed (filled with False):
", s_bool_1_reindexed)
print("s_bool_2_reindexed (filled with False):
", s_bool_2_reindexed)

s_or_reindexed = s_bool_1_reindexed | s_bool_2_reindexed # 使用对齐并填充后的Series进行逻辑或运算
print("
--- s_bool_1_reindexed | s_bool_2_reindexed (Logical OR on aligned/filled) ---")
print(s_or_reindexed)
# a     True
# b     True
# c     True
# d    False
# e    False
# dtype: bool  (这与直接 | 的结果一致,说明Pandas默认填充False)


# 3. 逻辑非 (~)
s_not_1 = ~s_bool_1 # 对s_bool_1进行逻辑非运算
print("
--- ~s_bool_1 (Logical NOT) ---")
print(s_not_1)
# 输出:
# a    False
# b    False
# c     True
# d     True
# dtype: bool

# 4. 比较运算 (返回布尔 Series)
s_numeric = pd.Series([10, 25, 5, 30], index=['w', 'x', 'y', 'z']) # 创建一个数值型Series
mask_ge_20 = s_numeric >= 20 # 创建一个比较运算的布尔掩码 (值大于等于20)
print("
--- Comparison (s_numeric >= 20) ---")
print("s_numeric:
", s_numeric)
print("mask_ge_20:
", mask_ge_20)
# 输出:
# s_numeric:
# w    10
# x    25
# y     5
# z    30
# dtype: int64
# mask_ge_20:
# w    False
# x     True
# y    False
# z     True
# dtype: bool

# 可以用这个布尔 Series 来索引原 Series 或其他对齐的 Series/DataFrame
print("s_numeric[mask_ge_20]:
", s_numeric[mask_ge_20]) # 使用掩码进行索引
# 输出:
# x    25
# z    30
# dtype: int64

# 5. `.any()` 和 `.all()` 方法
#    对于布尔 Series,这两个方法非常有用。
print("
--- .any() and .all() on Boolean Series ---")
print(f"s_bool_1.any(): {
              s_bool_1.any()}") # True (因为s_bool_1中至少有一个True)
print(f"s_bool_1.all(): {
              s_bool_1.all()}") # False (因为s_bool_1中并非所有都为True)

s_all_true = pd.Series([True, True], index=[1,2]) # 创建一个全为True的Series
print(f"s_all_true.all(): {
              s_all_true.all()}") # True

这段代码演示了 Series 的逻辑运算和比较运算:

逻辑运算符 (&, |, ~)

当对两个布尔 Series (如 s_bool_1s_bool_2) 使用 & (AND) 或 | (OR) 时,Pandas 同样会进行按索引对齐
关键行为:对于只存在于一个 Series 中的索引,Pandas 在执行这些标准布尔逻辑运算时,通常会将另一个 Series 中缺失的对应布尔值视为 False

所以,s_bool_1 & s_bool_2 中,对于索引 ‘d’(只在 s_bool_1 中为 False),s_bool_2 在 ‘d’ 处被视为 False,因此 False & False 结果为 False。对于索引 ‘e’(只在 s_bool_2 中为 False),s_bool_1 在 ‘e’ 处被视为 False,因此 False & False 结果为 False
类似地,s_bool_1 | s_bool_2 中,对于索引 ‘d’,结果是 False | (视为False) -> False。对于索引 ‘e’,结果是 (视为False) | False -> False

为了更明确和安全地进行逻辑运算(特别是当你希望对未对齐的索引有不同于隐式 False 的处理时),可以使用 .align() 方法先将两个 Series 对齐到一个共同的索引(例如,使用 join='outer' 取并集),并为在新索引中产生的缺失值指定一个填充值(例如,用 fill_value=Falsefill_value=True,取决于你的逻辑需求)。然后再对这两个已对齐且填充完毕的 Series 进行逻辑运算,这样结果会更可控和可预测。代码中演示了用 fill_value=False 对齐后再进行 | 运算。
~s_bool_1 (NOT):对 s_bool_1 中的每个布尔值逐元素取反。

比较运算符 (>=, <, ==, etc.):

当对一个数值型 Series (如 s_numeric) 与一个标量进行比较(例如 s_numeric >= 20)时,会逐元素进行比较,并返回一个新的布尔 Series,其索引与原 Series 相同,值为比较结果 (TrueFalse)。
这个生成的布尔 Series 常被用作掩码 (mask),用于后续的条件选择(如 s_numeric[mask_ge_20]),以筛选出满足条件的原始数据。

.any().all() 方法:

这些是布尔 Series 的聚合方法。
series.any(): 如果 Series 中至少有一个元素为 True,则返回 True;否则(如果所有元素都为 False,或者 Series 为空),返回 False
series.all(): 如果 Series 中所有元素都为 True,则返回 True;否则(如果至少有一个 False,或者 Series 为空),返回 False
它们在检查一组条件是否至少有一个满足或全部满足时非常方便。

这些逻辑和比较运算是构建复杂数据查询、过滤规则以及在数据清洗和特征工程中创建指示变量的基础。结合索引对齐特性,它们使得Pandas在处理带有标签的数据时表现得既灵活又强大。

1.1.4 Series 的常用方法与函数应用

Series 对象拥有大量内置方法,用于数据转换、计算统计量、处理缺失值、字符串操作、日期时间操作等。此外,还可以通过 .apply(), .map() 等方法应用自定义函数或Python内置函数。

a. 描述性统计方法

Series 有许多用于计算描述性统计的内置方法,它们通常默认忽略 NaN 值 (即 skipna=True)。

import pandas as pd
import numpy as np

s_stats = pd.Series([10, 20, 15, 25, 20, np.nan, 30, 10]) # 用于统计的示例Series
print("--- Series for Descriptive Statistics (s_stats) ---")
print(s_stats)

# 1. .count(): 非NaN元素的数量
print(f"
s_stats.count(): {
              s_stats.count()}") # 输出: 7 (忽略了NaN)

# 2. .sum(skipna=True): 总和
print(f"s_stats.sum(): {
              s_stats.sum()}") # 10+20+15+25+20+30+10 = 130.0
print(f"s_stats.sum(skipna=False): {
              s_stats.sum(skipna=False)}") # nan (因为有NaN参与)

# 3. .mean(skipna=True): 平均值
print(f"s_stats.mean(): {
              s_stats.mean():.2f}") # 130.0 / 7 = 18.57

# 4. .median(skipna=True): 中位数
print(f"s_stats.median(): {
              s_stats.median()}") # 排序后: [10,10,15,20,20,25,30], 中位数是20.0

# 5. .std(skipna=True, ddof=1): 标准差 (默认ddof=1,计算样本标准差)
print(f"s_stats.std(): {
              s_stats.std():.2f}") # 样本标准差
print(f"s_stats.std(ddof=0): {
              s_stats.std(ddof=0):.2f}") # 总体标准差

# 6. .var(skipna=True, ddof=1): 方差
print(f"s_stats.var(): {
              s_stats.var():.2f}") # 样本方差

# 7. .min(skipna=True): 最小值
print(f"s_stats.min(): {
              s_stats.min()}") # 10.0

# 8. .max(skipna=True): 最大值
print(f"s_stats.max(): {
              s_stats.max()}") # 30.0

# 9. .quantile(q=0.5, interpolation='linear'): 分位数
#    q 可以是单个值或列表/数组
print(f"s_stats.quantile(0.25): {
              s_stats.quantile(0.25)}") # Q1 (25%分位数) -> 12.5
print(f"s_stats.quantile(0.75): {
              s_stats.quantile(0.75)}") # Q3 (75%分位数) -> 22.5
print(f"s_stats.quantile([0.1, 0.5, 0.9]):
{
              s_stats.quantile([0.1, 0.5, 0.9])}") # 多个分位数
# 0.1    10.0
# 0.5    20.0  (中位数)
# 0.9    29.0
# Name: SampleStats, dtype: float64

# 10. .idxmin(skipna=True): 最小值的索引标签 (如果有多个最小值,返回第一个出现的)
print(f"s_stats.idxmin(): {
              s_stats.idxmin()}") # 索引0和7的值都是10,返回第一个0

# 11. .idxmax(skipna=True): 最大值的索引标签
print(f"s_stats.idxmax(): {
              s_stats.idxmax()}") # 索引6的值是30

# 12. .mode(dropna=True): 众数 (可能返回多个,所以结果是一个Series)
print(f"s_stats.mode():
{
              s_stats.mode()}")
# 0    10.0 (10出现2次)
# 1    20.0 (20出现2次)
# dtype: float64

# 13. .describe(percentiles=None, include=None, exclude=None): 生成描述性统计汇总
#     对于数值型Series,默认计算 count, mean, std, min, 25%, 50%, 75%, max
print(f"s_stats.describe():
{
              s_stats.describe()}")
# count     7.000000
# mean     18.571429
# std       7.867958
# min      10.000000
# 25%      12.500000 (Q1)
# 50%      20.000000 (median)
# 75%      22.500000 (Q3)
# max      30.000000
# Name: SampleStats, dtype: float64

# 14. .nunique(dropna=True): 不同值的数量
print(f"s_stats.nunique(): {
              s_stats.nunique()}") # 5 (10, 15, 20, 25, 30 是不同的非NaN值)
print(f"s_stats.nunique(dropna=False): {
              s_stats.nunique(dropna=False)}") # 6 (如果NaN也算一个独特“值”)

# 15. .value_counts(normalize=False, sort=True, ascending=False, bins=None, dropna=True):
#     计算每个唯一值的出现频率 (计数)。非常常用!
print(f"s_stats.value_counts():
{
              s_stats.value_counts()}")
# 20.0    2
# 10.0    2
# 15.0    1
# 25.0    1
# 30.0    1
# Name: SampleStats, dtype: int64 (计数值是整数)

print(f"
s_stats.value_counts(normalize=True) (频率占比):
{
              s_stats.value_counts(normalize=True)}")
# 20.0    0.285714 (2/7)
# 10.0    0.285714 (2/7)
# 15.0    0.142857 (1/7)
# 25.0    0.142857 (1/7)
# 30.0    0.142857 (1/7)
# Name: SampleStats, dtype: float64

这段代码演示了 Series 对象一系列非常实用的内置描述性统计方法:

.count(): 返回非 NaN 元素的数量。
.sum(skipna=True): 计算所有非 NaN 元素的总和。skipna=False 会让 NaN 参与计算,导致结果为 NaN
.mean(skipna=True): 计算非 NaN 元素的算术平均值。
.median(skipna=True): 计算非 NaN 元素的中位数。
.std(skipna=True, ddof=1): 计算标准差。ddof (degrees of freedom, 自由度) 参数默认为1,计算的是样本标准差(分母是 N-1)。如果设为0,则计算总体标准差(分母是 N)。
.var(skipna=True, ddof=1): 计算方差,与 .std()ddof 参数含义相同。
.min(skipna=True) / .max(skipna=True): 分别返回最小值和最大值。
.quantile(q=0.5, interpolation='linear'): 计算指定分位数。q 可以是0到1之间的单个浮点数(如0.25代表第一个四分位数Q1)或一个浮点数列表/数组(一次计算多个分位数)。interpolation 参数指定当所需分位数落在两个数据点之间时的插值方法(如 'linear', 'lower', 'higher', 'midpoint', 'nearest')。
.idxmin(skipna=True) / .idxmax(skipna=True): 分别返回具有最小值或最大值的元素的索引标签。如果存在多个最小值或最大值,则返回它们中第一个出现的索引标签。
.mode(dropna=True): 计算众数(出现频率最高的值)。由于数据中可能存在多个众数(频率相同且最高),此方法返回一个 Series 对象,包含所有众数。dropna=True (默认) 会在计算前移除 NaN
.describe(percentiles=None, ...): 生成一个包含多种常用描述性统计量的 Series(对于数值型输入)或 DataFrame(对于对象/分类类型输入)。对于数值型 Series,默认输出包括 count, mean, std, min, 25% (Q1), 50% (中位数), 75% (Q3), max。可以通过 percentiles 参数指定额外的分位数。
.nunique(dropna=True): 返回 Series 中唯一(不同)值的数量。dropna=True (默认) 表示不将 NaN 视为一个唯一值。如果设为 False,则 NaN 会被计为一个唯一值(如果存在的话)。
.value_counts(...): 这是一个极其有用的方法,用于计算 Series 中每个唯一值的出现频率(计数)。

默认按计数值降序排序。
normalize=True:将返回每个唯一值的出现比例(频率)而不是原始计数。
sort=True/False, ascending=True/False:控制排序行为。
bins=None:如果提供一个整数,它会将数值型数据(不能是NaN)先进行等宽分箱,然后对这些箱子进行计数。如果提供一个序列,它会作为自定义的箱子边界。
dropna=True (默认):不计算 NaN 的频率。设为 False 则会包含 NaN 的计数。
结果是一个新的 Series,其索引是原始 Series 中的唯一值,其值是这些唯一值对应的计数(或频率)。

这些描述性统计方法为我们快速了解一维数据集的集中趋势、离散程度、分布形状以及值的构成提供了极大的便利,是探索性数据分析的核心组成部分。

1.2.5 Series 高级索引与选择技巧

Pandas 提供了非常灵活和强大的索引机制,远不止简单的位置索引和标签索引。

1.2.5.1 布尔索引 (Boolean Indexing) 的深化应用

布尔索引是数据筛选的核心,它允许我们根据一个或多个条件来选择 Series 中的元素。

复杂条件组合:

在真实场景中,我们经常需要根据多个条件进行筛选,这些条件可以通过逻辑运算符 & (与), | (或), ~ (非) 来组合。特别注意:在使用这些逻辑运算符时,每个条件都应该用括号 () 包裹起来,以保证正确的运算优先级。

import pandas as pd
import numpy as np

# 模拟一组更复杂的企业销售数据:产品ID,销售额,利润率,销售区域
data = {
            
    'product_id': [f'P{
              1000+i}' for i in range(20)],
    'sales_amount': np.random.randint(1000, 50000, 20),
    'profit_margin': np.random.uniform(0.05, 0.35, 20),
    'region': np.random.choice(['华东', '华南', '华北', '西南', '东北'], 20)
}
sales_series = pd.Series(data['sales_amount'], 
                         index=pd.MultiIndex.from_tuples(
                             list(zip(data['product_id'], data['region'], data['profit_margin'])),
                             names=['ProductID', 'Region', 'ProfitMargin']
                         ))

print("原始销售额 Series (部分展示):")
print(sales_series.head()) # 打印原始 Series 的前几行,以便观察数据结构

# 场景1: 筛选出华东或华南地区,并且销售额大于20000的产品
# 使用 .loc 和 get_level_values 来访问 MultiIndex 的特定级别
condition1_region = (sales_series.index.get_level_values('Region') == '华东') | 
                    (sales_series.index.get_level_values('Region') == '华南') # 条件1:区域为华东或华南
condition2_sales = sales_series > 20000 # 条件2:销售额大于20000

filtered_sales_complex = sales_series[condition1_region & condition2_sales] # 组合两个条件进行筛选
print("
筛选结果 (华东或华南地区,且销售额 > 20000):")
print(filtered_sales_complex) # 打印满足复杂条件的筛选结果

# 场景2: 筛选出利润率在10%到20%之间,或者销售额小于5000的产品 (非东北地区)
condition_profit_margin = (sales_series.index.get_level_values('ProfitMargin') >= 0.10) & 
                          (sales_series.index.get_level_values('ProfitMargin') <= 0.20) # 条件:利润率在0.10到0.20之间
condition_low_sales = sales_series < 5000 # 条件:销售额小于5000
condition_not_northeast = sales_series.index.get_level_values('Region') != '东北' # 条件:非东北地区

# 组合更复杂的条件
# (利润率在10%-20% OR 销售额 < 5000) AND (非东北地区)
filtered_sales_very_complex = sales_series[
    (condition_profit_margin | condition_low_sales) & condition_not_northeast
]
print("
筛选结果 (利润率10%-20% 或 销售额<5000,且非东北地区):")
print(filtered_sales_very_complex) # 打印满足更复杂条件的筛选结果

# 使用函数进行条件筛选
# 假设我们需要筛选出那些销售额是其产品ID数字部分的特定倍数的产品 (一个略显刻意但展示能力的例子)
def is_special_multiple(product_id_str, sale_value):
    try:
        # 从产品ID中提取数字部分,例如 'P1001' -> 1001
        numeric_part = int(product_id_str[1:]) 
        # 检查销售额是否是数字部分的倍数 (例如,数字部分是100的倍数)
        return sale_value % (numeric_part // 100 * 10) == 0 if numeric_part // 100 > 0 else False 
    except (ValueError, TypeError, IndexError):
        return False # 处理无法解析或计算的情况

# 为了应用这个函数,我们需要同时访问索引和值
# 方法1:迭代 (效率较低,不推荐用于大型Series,但用于演示)
# special_condition_mask = pd.Series([
#     is_special_multiple(idx[0], sales_series.loc[idx]) for idx in sales_series.index
# ], index=sales_series.index)

# 方法2:更Pandas化的方式,通过 apply (如果可以直接在索引或值上操作)
# 在这个特定例子中,由于函数依赖于索引和值,一个直接的 apply 在 Series 上可能不那么直观。
# 但我们可以先获取索引级别的值,然后构造条件。
# 这里我们简化一下,假设我们想筛选出产品ID数字部分是偶数且销售额大于均值的产品
product_ids_numeric = sales_series.index.get_level_values('ProductID').str[1:].astype(int) # 提取产品ID中的数字部分并转为整数
is_even_product_id = product_ids_numeric % 2 == 0 # 条件:产品ID的数字部分为偶数
is_above_average_sales = sales_series > sales_series.mean() # 条件:销售额大于平均销售额

filtered_by_func_idea = sales_series[is_even_product_id & is_above_average_sales] # 组合条件进行筛选
print("
筛选结果 (产品ID数字部分为偶数且销售额大于均值):")
print(filtered_by_func_idea) # 打印基于函数思想筛选的结果

在上述代码中,我们创建了一个带有 MultiIndexSeries,模拟了更真实的数据结构。然后,我们演示了如何使用 get_level_values() 来访问 MultiIndex 特定级别的值,并结合逻辑运算符构建复杂的筛选条件。最后一个示例展示了如何根据从索引中提取的信息(产品ID的数字部分)和 Series 的值来共同构建筛选条件。

使用 .isin() 进行成员资格检查:

当需要检查 Series 中的元素是否存在于一个给定的值集合中时,.isin() 方法非常高效。

import pandas as pd
import numpy as np

# 假设我们有一个表示员工部门的 Series
employee_departments = pd.Series(
    np.random.choice(['研发部', '市场部', '销售部', '人事部', '财务部', '生产部'], 100),
    name='Department'
)
print("员工部门 Series (部分展示):")
print(employee_departments.head()) # 打印员工部门 Series 的前几行

# 场景: 筛选出属于核心业务部门 (研发部, 市场部, 销售部) 的员工
core_departments = ['研发部', '市场部', '销售部'] # 定义核心业务部门列表
is_core_department_mask = employee_departments.isin(core_departments) # 使用 isin() 创建布尔掩码
core_employees = employee_departments[is_core_department_mask] # 应用掩码进行筛选

print(f"
属于核心部门 ({
              core_departments}) 的员工记录数: {
              len(core_employees)}")
print("核心部门员工 (部分展示):")
print(core_employees.head()) # 打印核心部门员工的部分数据

# `.isin()` 也可以用于 Series 的索引
s_indexed = pd.Series([10,20,30,40,50], index=['a','b','c','d','e'])
print("
带字符索引的 Series:")
print(s_indexed) # 打印带字符索引的 Series

selected_indices = ['a', 'c', 'e', 'f'] # 'f' 是一个不存在的索引
# 筛选出索引在 selected_indices 中的元素
# 注意:如果直接用 s_indexed.loc[selected_indices],对于不存在的索引 'f' 会报错。
# 使用 .index.isin() 可以安全地处理这种情况
index_is_in_mask = s_indexed.index.isin(selected_indices) # 检查索引是否在指定列表中
s_filtered_by_index_isin = s_indexed[index_is_in_mask] # 应用掩码进行筛选
print(f"
通过 .index.isin({
              selected_indices}) 筛选后的 Series:")
print(s_filtered_by_index_isin) # 打印通过索引的 .isin() 方法筛选后的 Series

# 如果希望对于不在列表中的索引也保留(可能填充NaN),或者有其他复杂逻辑,
# 可能需要结合 reindex 等方法。但 isin 主要用于精确匹配。

.isin() 方法对于基于一组离散值进行筛选非常有用,例如筛选特定类别、特定ID列表等。

1.2.5.2 .loc, .iloc, .at, .iat 的精妙运用

我们已经接触过 .loc (基于标签) 和 .iloc (基于整数位置) 用于选择数据。.at.iat 是它们对应的用于访问单个标量值的更快速版本。

.loc: 主要通过标签进行选择。可以是单个标签、标签列表、标签切片、布尔数组。
.iloc: 主要通过整数位置进行选择。可以是单个整数、整数列表、整数切片、布尔数组。
.at: 通过标签快速访问单个标量值。相当于 .loc 的标量版本,性能更高。
.iat: 通过整数位置快速访问单个标量值。相当于 .iloc 的标量版本,性能更高。

结合 MultiIndex 使用 .loc

Series 具有 MultiIndex 时,.loc 的威力更加凸显。

import pandas as pd
import numpy as np

# 创建一个具有两层 MultiIndex 的 Series,模拟季度销售数据
regions = ['华东', '华南', '华北']
quarters = ['Q1', 'Q2', 'Q3', 'Q4']
index_tuples = [(region, quarter) for region in regions for quarter in quarters]
multi_idx = pd.MultiIndex.from_tuples(index_tuples, names=['Region', 'Quarter']) # 创建 MultiIndex

sales_data_multi = pd.Series(np.random.randint(10000, 50000, len(multi_idx)), index=multi_idx)
print("多层索引 Series (季度销售数据):")
print(sales_data_multi) # 打印具有多层索引的 Series

# 1. 选择单个标签组合
sale_hd_q1 = sales_data_multi.loc[('华东', 'Q1')] # 选择华东地区Q1的销售数据
print(f"
华东地区 Q1 销售额: {
              sale_hd_q1}")

# 2. 选择某个顶级索引下的所有数据 (返回一个新的 Series)
sales_huadong = sales_data_multi.loc['华东'] # 选择华东地区所有季度的销售数据
print("
华东地区所有季度销售额:")
print(sales_huadong) # 打印华东地区所有季度的销售数据

# 3. 使用 slice(None) 选择某个级别下的所有标签
# 选择所有地区的 Q2 数据
sales_all_q2 = sales_data_multi.loc[(slice(None), 'Q2')] # slice(None) 表示选择该级别的所有标签
print("
所有地区 Q2 销售额:")
print(sales_all_q2) # 打印所有地区Q2的销售数据

# 4. 选择标签列表
# 选择华东和华北地区的 Q1 和 Q3 数据
selected_data = sales_data_multi.loc[(['华东', '华北'], ['Q1', 'Q3'])] # 选择特定区域和特定季度的数据
print("
华东和华北地区的 Q1 和 Q3 销售额:")
print(selected_data) # 打印筛选后的数据
# 注意:这种方式会尝试匹配所有组合 ('华东', 'Q1'), ('华东', 'Q3'), ('华北', 'Q1'), ('华北', 'Q3')

# 5. 使用布尔数组与 .loc (通常布尔数组长度需与当前级别匹配或整个Series匹配)
# 筛选出销售额大于30000的华东地区数据
huadong_data = sales_data_multi.loc['华东'] # 先获取华东地区的数据
filtered_huadong = huadong_data[huadong_data > 30000] # 在华东地区数据上应用布尔筛选
print("
华东地区销售额大于 30000 的数据:")
print(filtered_huadong) # 打印筛选后的华东地区数据

# 或者直接在原始Series上操作,但条件需要更精确地构造
# 筛选所有地区中,季度为Q1且销售额大于40000的数据
mask_q1_high_sales = (sales_data_multi.index.get_level_values('Quarter') == 'Q1') & (sales_data_multi > 40000)
result_q1_high_sales = sales_data_multi.loc[mask_q1_high_sales] # 应用布尔掩码进行筛选
print("
所有地区Q1销售额大于 40000 的数据:")
print(result_q1_high_sales) # 打印符合条件的销售数据

# 使用 .xs() 方法进行跨级别选择 (cross-section)
# .xs() 对于从特定级别选择数据并可能删除该级别非常有用
sales_q3_all_regions = sales_data_multi.xs('Q3', level='Quarter') # 选择所有地区Q3的数据,并移除Quarter级别
print("
使用 .xs() 选择所有地区 Q3 销售额 (Quarter级别被移除):")
print(sales_q3_all_regions) # 打印通过 .xs() 方法选择的数据

sales_huadong_specific_quarter = sales_data_multi.xs('华东', level='Region') # 选择华东地区所有季度的数据,并移除Region级别
print("
使用 .xs() 选择华东地区所有季度销售额 (Region级别被移除):")
print(sales_huadong_specific_quarter) # 打印通过 .xs() 方法选择的华东地区数据

.xs() 方法是处理 MultiIndex 时的一个强大工具,它允许你方便地获取横截面数据。

.at.iat 的性能优势:

当需要频繁访问或修改单个元素时,.at.iat 通常比 .loc.iloc 更快,因为它们直接进行标量查找,跳过了更通用的索引解析逻辑。

import pandas as pd
import time

# 创建一个较大的 Series
large_series = pd.Series(range(10**6), index=[f'ID_{
              i}' for i in range(10**6)])
large_series_iloc = pd.Series(range(10**6)) # 用于 .iat 测试

# 访问的标签和位置
target_label = 'ID_500000'
target_position = 500000

# 使用 .loc 访问单个元素
start_time = time.perf_counter()
for _ in range(1000): # 重复多次以获得更准确的计时
    val_loc = large_series.loc[target_label]
end_time = time.perf_counter()
print(f"使用 .loc 访问单个元素 1000 次耗时: {
              end_time - start_time:.6f} 秒") # 打印 .loc 访问耗时

# 使用 .at 访问单个元素
start_time = time.perf_counter()
for _ in range(1000):
    val_at = large_series.at[target_label]
end_time = time.perf_counter()
print(f"使用 .at 访问单个元素 1000 次耗时: {
              end_time - start_time:.6f} 秒") # 打印 .at 访问耗时

# 使用 .iloc 访问单个元素
start_time = time.perf_counter()
for _ in range(1000):
    val_iloc = large_series_iloc.iloc[target_position]
end_time = time.perf_counter()
print(f"使用 .iloc 访问单个元素 1000 次耗时: {
              end_time - start_time:.6f} 秒") # 打印 .iloc 访问耗时

# 使用 .iat 访问单个元素
start_time = time.perf_counter()
for _ in range(1000):
    val_iat = large_series_iloc.iat[target_position]
end_time = time.perf_counter()
print(f"使用 .iat 访问单个元素 1000 次耗时: {
              end_time - start_time:.6f} 秒") # 打印 .iat 访问耗时

# 修改单个元素
new_value = 999999
# 使用 .loc 修改
start_time = time.perf_counter()
large_series.loc[target_label] = new_value
loc_modified_time = time.perf_counter() - start_time

# 重置值以便 .at 修改
large_series[target_label] = target_position # 假设原始值与位置对应
start_time = time.perf_counter()
large_series.at[target_label] = new_value
at_modified_time = time.perf_counter() - start_time

print(f"使用 .loc 修改单个元素耗时: {
              loc_modified_time:.8f} 秒") # 打印 .loc 修改耗时
print(f"使用 .at 修改单个元素耗时: {
              at_modified_time:.8f} 秒") # 打印 .at 修改耗时
# .iat 的修改时间比较类似

在对性能有极致要求的场景,尤其是循环中访问或修改单个元素时,优先考虑使用 .at.iat

1.2.5.3 wheremask 方法

Series.where(cond, other=nan): 返回一个与原 Series 具有相同形状的对象,其中 condTrue 的位置保留原值,condFalse 的位置替换为 other (默认为 NaN)。
Series.mask(cond, other=nan): 与 where 相反,condTrue 的位置替换为 othercondFalse 的位置保留原值。

这两个方法在条件替换数据时非常有用。

import pandas as pd
import numpy as np

s = pd.Series(range(-5, 6)) # 创建一个 Series,包含从 -5 到 5 的整数
print("原始 Series:")
print(s) # 打印原始 Series

# 使用 where: 保留大于0的值,其余替换为 -100
s_where = s.where(s > 0, other=-100) # 条件 s > 0 为 True 的保留原值,否则替换为 -100
print("
使用 where (s > 0, other=-100):")
print(s_where) # 打印 where 操作后的 Series

# 使用 mask: 将大于0的值替换为 -200,其余保留
s_mask = s.mask(s > 0, other=-200) # 条件 s > 0 为 True 的替换为 -200,否则保留原值
print("
使用 mask (s > 0, other=-200):")
print(s_mask) # 打印 mask 操作后的 Series

# 真实场景:对异常值进行盖帽处理 (winsorizing)
# 假设有一组传感器读数,我们认为超出某个范围的是异常值
sensor_readings = pd.Series([10.1, 10.5, 10.0, 12.5, 9.8, 10.2, 7.5, 10.7, 10.3, 15.1, 9.9])
print("
原始传感器读数:")
print(sensor_readings) # 打印原始传感器读数

lower_bound = 9.0 # 定义有效读数的下界
upper_bound = 12.0 # 定义有效读数的上界

# 使用 where 进行双向盖帽
# Step 1: 将低于 lower_bound 的值替换为 lower_bound
readings_capped = sensor_readings.where(sensor_readings >= lower_bound, other=lower_bound)
# Step 2: 在上一步结果的基础上,将高于 upper_bound 的值替换为 upper_bound
readings_capped = readings_capped.where(readings_capped <= upper_bound, other=upper_bound)
print("
盖帽处理后的传感器读数 (使用 where):")
print(readings_capped) # 打印盖帽处理后的读数

# 使用 mask 进行双向盖帽 (逻辑相反)
readings_capped_mask = sensor_readings.mask(sensor_readings < lower_bound, other=lower_bound)
readings_capped_mask = readings_capped_mask.mask(readings_capped_mask > upper_bound, other=upper_bound)
print("
盖帽处理后的传感器读数 (使用 mask):") # 逻辑与 where 版本相同,只是条件写法相反
print(readings_capped_mask) # 打印使用 mask 进行盖帽处理后的读数

# where 和 mask 也可以接受一个可调用对象 (callable) 作为 cond,但这在 Series 中不直接支持,
# 通常用于 DataFrame.apply 等场景。对于 Series,cond 通常是一个布尔 Series。

wheremask 为条件替换提供了简洁的语法,是数据清洗和预处理中常用的工具。

1.2.5.4 takeselect

Series.take(indices, axis=0, is_copy=True, **kwargs): 根据整数位置列表 indices 返回新的 Series。即使索引不是单调或唯一的,take 也能工作。负数索引从末尾开始计数。
Series.select(crit, axis=0): (较少直接用于 Series, DataFrame 中更常用) 根据标签的自定义条件函数 crit 来选择元素。 crit 函数接收标签作为输入,返回 TrueFalse

take 在你需要按照特定的、可能无序的整数位置序列来抽取数据时非常有用。

import pandas as pd

s = pd.Series(list('abcde'), index=[10, 20, 0, 5, 30])
print("原始 Series:")
print(s) # 打印原始 Series

# 使用 take 根据整数位置选择
# 选择位置 0, 3, 1 的元素
taken_elements = s.take([0, 3, 1]) # 根据指定的整数位置列表 [0, 3, 1] 提取元素
print("
s.take([0, 3, 1]):")
print(taken_elements) # 打印通过 take 提取的元素

# 使用负数索引
taken_with_negative = s.take([-1, -3]) # 负数索引表示从末尾开始计数,-1 是最后一个,-3 是倒数第三个
print("
s.take([-1, -3]):")
print(taken_with_negative) # 打印使用负数索引通过 take 提取的元素

# 如果 take 的索引超出范围,会引发 IndexError
try:
    s.take([0, 10]) # 10 超出了 Series 的长度范围 (0 到 4)
except IndexError as e:
    print(f"
尝试 s.take([0, 10]) 引发错误: {
              e}") # 打印错误信息

# `select` 在 Series 上不常用,因为它通常用于基于标签的复杂选择逻辑,
# 而 Series 的标签选择主要由 .loc 和布尔索引覆盖。
# 下面是一个模拟 `select` 行为的例子,实际 Series 没有直接的 `select` 方法
# 假设我们要选择标签值大于10的元素
def label_selector(label):
    return label > 10 # 定义一个选择器函数,当标签大于10时返回 True

# 手动实现类似 select 的功能
selected_by_label_func = s[s.index.map(label_selector)] # 使用 map 和布尔索引模拟 select
print("
模拟 select (标签 > 10):")
print(selected_by_label_func) # 打印模拟 select 功能筛选后的结果

take 提供了基于整数位置的灵活抽取能力,尤其是在目标位置序列不规则时。

1.2.6 Series 高级运算与函数应用

Pandas Series 不仅仅是带标签的一维数组,它还封装了大量高效的方法,用于对数据进行各种复杂的运算和函数应用。这些方法通常是高度优化的(很多底层用 Cython 或 C 实现),能够显著提升数据处理的效率。

1.2.6.1 元素级函数应用 (Element-wise Operations)

元素级函数应用是指将一个函数分别作用于 Series 中的每一个元素。Pandas 提供了多种方式来实现这一点,最常用的是 .apply().map()

A. Series.apply(func, convert_dtype=True, args=(), **kwargs)

.apply() 方法是最通用的元素级函数应用工具。它可以接受一个自定义函数(包括 lambda 函数)作为参数,并将该函数应用于 Series 的每个值。

func: 应用于每个元素的函数。
convert_dtype: 默认为 True,尝试找到更合适的 dtype 来存储结果。如果为 False,则保持与原 Series 相同的 dtype(如果可能)。
args: 一个元组,作为位置参数传递给 func (在 Series 的每个元素之后)。
**kwargs: 作为关键字参数传递给 func

import pandas as pd
import numpy as np

# 示例1: 使用 lambda 函数进行简单的数学转换
s_numbers = pd.Series([1, 2, 3, 4, 5, np.nan, 7])
print("原始 Series (s_numbers):")
print(s_numbers) # 打印原始数字 Series

# 将每个元素平方,NaN 保持 NaN
s_squared = s_numbers.apply(lambda x: x**2 if pd.notnull(x) else np.nan)
print("
应用 lambda x: x**2 (处理NaN):")
print(s_squared) # 打印平方后的 Series

# 示例2: 使用预定义的 Python 函数
def categorize_sales(amount):
    """根据销售额将产品分类"""
    if pd.isna(amount):
        return '未知级别' # 处理缺失值
    if amount < 1000:
        return '低价值'
    elif amount < 10000:
        return '中价值'
    else:
        return '高价值'

sales_data = pd.Series([500, 12000, 800, np.nan, 25000, 5000], name='SalesAmount')
print("
原始销售额 Series (sales_data):")
print(sales_data) # 打印原始销售额 Series

sales_categories = sales_data.apply(categorize_sales) # 应用自定义分类函数
print("
应用 categorize_sales 函数:")
print(sales_categories) # 打印分类后的 Series

# 示例3: 使用带参数的函数
def process_text(text, prefix="INFO", to_upper=True):
    """处理文本数据,添加前缀并可选转为大写"""
    if pd.isna(text):
        return "N/A" # 处理缺失文本
    processed = f"{
              prefix}: {
              text}" # 添加前缀
    if to_upper:
        processed = processed.upper() # 转为大写
    return processed

product_descriptions = pd.Series(['apple iphone 15', 'samsung galaxy s24', np.nan, 'google pixel 8'])
print("
原始产品描述 Series:")
print(product_descriptions) # 打印原始产品描述 Series

# 使用 args 传递位置参数 (虽然这里更适合 kwargs)
# processed_desc_args = product_descriptions.apply(process_text, args=("[PRODUCT]", False)) 
# print("
应用 process_text (使用args):")
# print(processed_desc_args)

# 更推荐使用 kwargs 传递关键字参数,更清晰
processed_desc_kwargs = product_descriptions.apply(process_text, prefix="[SKU]", to_upper=False) # 应用带关键字参数的函数
print("
应用 process_text (使用kwargs, to_upper=False):")
print(processed_desc_kwargs) # 打印处理后的产品描述 (小写,自定义前缀)

processed_desc_kwargs_upper = product_descriptions.apply(process_text, prefix="[ITEM]", to_upper=True) # 应用带关键字参数的函数
print("
应用 process_text (使用kwargs, to_upper=True):")
print(processed_desc_kwargs_upper) # 打印处理后的产品描述 (大写,自定义前缀)

# 性能考量:
# 虽然 .apply() 很灵活,但对于简单的算术运算,向量化操作通常更快。
# 例如,s_numbers * 2 比 s_numbers.apply(lambda x: x * 2) 更高效。
# 当函数逻辑复杂,无法简单向量化时,.apply() 是一个很好的选择。

# 企业级场景:特征工程 - 从原始数据创建新特征
# 假设我们有一个用户年龄 Series,我们想根据年龄段创建特征
user_ages = pd.Series([25, 33, 45, 18, 67, 22, 38, np.nan, 52, 29, 41])
print("
用户年龄 Series:")
print(user_ages) # 打印用户年龄 Series

def age_to_group(age, young_threshold=30, middle_threshold=50):
    """将年龄转换为年龄段标签"""
    if pd.isna(age):
        return "Unknown" # 处理未知年龄
    if age < young_threshold:
        return "Young"
    elif age < middle_threshold:
        return "Middle-aged"
    else:
        return "Senior"

age_groups = user_ages.apply(age_to_group, young_threshold=28, middle_threshold=55) # 应用年龄分组函数,并传入自定义阈值
print("
根据年龄划分的年龄段 (自定义阈值):")
print(age_groups) # 打印划分后的年龄段 Series

.apply() 的灵活性使其成为数据清洗、转换和特征工程中的重要工具。但要注意,如果存在等效的 Pandas 内置向量化函数或 NumPy 函数,它们通常性能更优。.apply() 本质上是在 Python 层面进行迭代(尽管 Pandas 会尝试优化)。

B. Series.map(arg, na_action=None)

.map() 方法主要用于根据输入对应关系(例如字典、函数或另一个 Series)替换 Series 中的每个值。

arg: 用于映射的值。可以是:

字典 (dict): Series 中的值如果作为字典的键存在,则被替换为字典的值。不在字典中的键对应的值会变成 NaN
函数 (function): 函数将应用于 Series 中的每个元素。
Series: Series 中的值将根据 arg (另一个 Series) 的索引进行映射。

na_action: 如果是 'ignore',则跳过对 NaN 值的映射函数应用。默认情况下 (None),NaN 也会传递给映射函数或字典查找。

import pandas as pd
import numpy as np

# 示例1: 使用字典进行映射 (常用于编码分类变量)
s_grades = pd.Series(['A', 'B', 'C', 'A', 'D', 'B', np.nan, 'F'])
print("原始成绩 Series (s_grades):")
print(s_grades) # 打印原始成绩 Series

grade_to_score_map = {
            'A': 4, 'B': 3, 'C': 2, 'D': 1, 'F': 0} # 定义成绩到分数的映射字典
s_scores = s_grades.map(grade_to_score_map) # 应用字典映射
print("
使用字典映射成绩到分数:")
print(s_scores) # 打印映射后的分数 Series
# 注意: 'D' 在原始数据中没有,所以映射后是 NaN。如果原始数据有 'D' 而字典没有,也会是 NaN。
# np.nan 在字典中没有对应,所以也是 NaN。

# na_action='ignore' 的效果
def custom_mapper(val):
    print(f"Mapping: {
              val}") # 打印正在映射的值,用于观察
    if pd.isna(val):
        return "Value is NaN" # 对 NaN 进行特定处理
    return str(val).upper() # 其他值转为大写字符串

s_mixed = pd.Series(['apple', np.nan, 'Banana', 123])
print("
原始混合类型 Series (s_mixed):")
print(s_mixed) # 打印原始混合类型 Series

print("
使用 map 应用函数 (na_action=None, 默认):")
s_mapped_default_na = s_mixed.map(custom_mapper) # 应用自定义映射函数,默认处理 NaN
print(s_mapped_default_na) # 打印默认 na_action 下的映射结果

print("
使用 map 应用函数 (na_action='ignore'):")
s_mapped_ignore_na = s_mixed.map(custom_mapper, na_action='ignore') # 应用自定义映射函数,忽略 NaN
print(s_mapped_ignore_na) # 打印忽略 na_action 下的映射结果
# 注意:当 na_action='ignore' 时,NaN 值直接保持为 NaN,custom_mapper 不会对其调用。

# 示例2: 使用函数进行映射 (类似于 .apply,但通常用于更简单的替换逻辑)
s_values = pd.Series([1, 2, 3, 4, 5])
print("
原始数值 Series (s_values):")
print(s_values) # 打印原始数值 Series

s_values_mapped_func = s_values.map(lambda x: f"Value_{
              x*10}") # 使用 lambda 函数进行映射
print("
使用 lambda 函数映射数值到字符串:")
print(s_values_mapped_func) # 打印函数映射后的 Series

# 示例3: 使用另一个 Series 进行映射
# 假设我们有一个产品ID Series,和一个将产品ID映射到产品类别的 Series
product_ids = pd.Series(['P101', 'P102', 'P103', 'P104', 'P101', 'P105'])
print("
产品ID Series:")
print(product_ids) # 打印产品ID Series

category_map_series = pd.Series(
    ['Electronics', 'Books', 'Home Goods', 'Apparel'],
    index=['P101', 'P102', 'P103', 'P200'] # 注意这里的索引是用于映射的键
)
print("
产品ID到类别映射 Series (category_map_series):")
print(category_map_series) # 打印用于映射的类别 Series

product_categories = product_ids.map(category_map_series) # 使用 Series 进行映射
print("
通过 Series 映射得到的产品类别:")
print(product_categories) # 打印映射后的产品类别
# 'P104' 和 'P105' 在 category_map_series 的索引中不存在,所以映射为 NaN。
# 'P200' 在 category_map_series 中存在,但在 product_ids 中未使用。

# 企业级场景:数据规范化 - 将不同写法统一
# 假设我们有城市名称数据,存在大小写、别名等不一致问题
city_data = pd.Series(['New York', 'new york city', 'NYC', 'london', 'London City', 'Paris', 'PARIS'])
print("
原始城市数据 Series:")
print(city_data) # 打印原始城市数据 Series

city_normalization_map = {
            
    'new york': 'New York',
    'new york city': 'New York',
    'nyc': 'New York',
    'london': 'London',
    'london city': 'London',
    'paris': 'Paris'
}

# 为了使字典映射生效,我们通常先将 Series 中的值转换为统一格式 (如小写)
normalized_cities = city_data.str.lower().map(city_normalization_map) # 先转小写,再用字典映射
# 对于不在映射字典中的键,结果会是 NaN。我们可以用 .fillna() 处理。
normalized_cities_filled = normalized_cities.fillna(city_data) # 用原始值填充未映射成功的 NaN
print("
规范化后的城市数据:")
print(normalized_cities_filled) # 打印规范化后的城市数据

# 另一种方法是使用函数进行更复杂的映射
def smart_normalize_city(name):
    name_lower = str(name).lower() # 转小写并确保是字符串
    if 'new york' in name_lower or 'nyc' in name_lower:
        return 'New York (USA)'
    elif 'london' in name_lower:
        return 'London (UK)'
    elif 'paris' in name_lower:
        return 'Paris (France)'
    return name # 返回原始值如果无法匹配

smart_normalized_cities = city_data.map(smart_normalize_city) # 应用智能规范化函数
print("
使用函数进行智能规范化的城市数据:")
print(smart_normalized_cities) # 打印智能规范化后的城市数据

.apply() vs .map() for Series:

.map():

主要用于基于一一对应的关系替换值(字典,或另一个 Series)。
当传入函数时,它作用于每个元素,类似于 .apply(),但通常用于相对简单的转换。
对于字典映射,如果键不存在,则产生 NaN
优化程度高,尤其对于字典映射。

.apply():

更通用,可以接受更复杂的函数,这些函数可能不仅仅依赖于单个元素的值(尽管在 Series 上通常是元素级的)。
可以传递额外的 argskwargs 给函数。
提供了 convert_dtype 参数来控制输出的数据类型。

对于简单的元素级函数应用,两者效果相似。但当需要基于字典或另一个 Series 进行查找替换时,.map() 是首选且更高效。当函数逻辑复杂或需要额外参数时,.apply() 更灵活。

1.2.6.2 聚合操作 (Aggregation)

聚合操作是指将 Series 中的多个值通过某种运算(如求和、平均值、计数等)汇总成一个或少数几个标量值的过程。

A. 常用内置聚合函数

Pandas Series 对象直接提供了许多常用的聚合函数:

s.sum(): 计算总和。
s.mean(): 计算平均值。
s.median(): 计算中位数。
s.min(): 找到最小值。
s.max(): 找到最大值。
s.std(): 计算标准差。
s.var(): 计算方差。
s.count(): 计算非空元素的数量。
s.nunique(dropna=True): 计算唯一值的数量。
s.idxmax(): 返回最大值对应的索引标签。如果多个最大值,返回第一个。
s.idxmin(): 返回最小值对应的索引标签。如果多个最小值,返回第一个。
s.quantile(q=0.5, interpolation='linear'): 计算分位数。
s.describe(): 生成描述性统计摘要。

import pandas as pd
import numpy as np

# 模拟一组企业月度利润数据 (单位:万元)
monthly_profit = pd.Series(
    [120, 150, 90, 200, 180, 130, np.nan, 210, 160, 170, 120, 220],
    index=pd.to_datetime([f'2023-{
              i:02d}-01' for i in range(1, 13)]), # 使用日期时间作为索引
    name='MonthlyProfit(万元)'
)
print("月度利润 Series:")
print(monthly_profit) # 打印月度利润 Series

total_profit = monthly_profit.sum() # 计算总利润
print(f"
年度总利润: {
              total_profit} 万元")

average_profit = monthly_profit.mean() # 计算平均月利润
print(f"平均月利润: {
              average_profit:.2f} 万元")

median_profit = monthly_profit.median() # 计算月利润中位数
print(f"月利润中位数: {
              median_profit} 万元")

max_profit_month = monthly_profit.idxmax() # 获取利润最高的月份的索引
max_profit_value = monthly_profit.max() # 获取最高利润值
print(f"最高月利润出现在 {
              max_profit_month.strftime('%Y-%m')}, 金额为: {
              max_profit_value} 万元")

min_profit_month = monthly_profit.idxmin() # 获取利润最低的月份的索引
min_profit_value = monthly_profit.min() # 获取最低利润值
print(f"最低月利润出现在 {
              min_profit_month.strftime('%Y-%m')}, 金额为: {
              min_profit_value} 万元")

num_months_recorded = monthly_profit.count() # 计算有记录的月份数量 (非 NaN)
print(f"有利润记录的月份数: {
              num_months_recorded}")

num_unique_profit_values = monthly_profit.nunique() # 计算不同利润值的数量
print(f"不同利润值的数量: {
              num_unique_profit_values}")

# 计算第90百分位数
profit_p90 = monthly_profit.quantile(0.9) # 计算90%分位数
print(f"月利润的90分位数: {
              profit_p90} 万元")

# 获取描述性统计
profit_description = monthly_profit.describe() # 生成描述性统计摘要
print("
月度利润描述性统计:")
print(profit_description) # 打印描述性统计结果

B. Series.agg(func, axis=0, *args, **kwargs)Series.aggregate(...)

.agg() (或其别名 .aggregate()) 方法提供了一个更灵活的方式来执行聚合,特别是当你需要:

应用单个自定义聚合函数。
同时应用多个聚合函数。
为聚合结果指定名称。

import pandas as pd
import numpy as np

# 模拟一组产品评分数据 (1-5分)
product_ratings = pd.Series([4, 5, 3, 4, 5, 2, 3, 4, 4, 5, 1, np.nan, 3, 4, 5, 2])
print("产品评分 Series:")
print(product_ratings) # 打印产品评分 Series

# 1. 应用单个聚合函数 (可以是字符串形式的内置函数名)
mean_rating = product_ratings.agg('mean') # 使用字符串 'mean' 调用均值函数
print(f"
使用 agg('mean') 计算的平均评分: {
              mean_rating:.2f}")

# 2. 应用单个自定义聚合函数
def range_metric(s):
    """计算序列的极差 (最大值 - 最小值)"""
    return s.max() - s.min() if s.count() > 0 else np.nan # 处理空序列或全NaN序列

rating_range = product_ratings.agg(range_metric) # 应用自定义极差函数
print(f"使用自定义函数计算的评分极差: {
              rating_range}")

# 3. 同时应用多个聚合函数 (传入函数名字符串列表或函数对象列表)
multiple_stats = product_ratings.agg(['sum', 'mean', 'std', np.median, range_metric]) # 应用多个聚合函数
print("
使用 agg 应用多个聚合统计:")
print(multiple_stats) # 打印多个聚合统计结果
# 结果是一个 Series,索引是函数名或函数对象的名称。

# 4. 为聚合结果指定名称 (传入一个字典,键是结果名称,值是聚合函数)
named_aggregations = product_ratings.agg(
    总评分数='sum',
    平均分='mean',
    评分标准差='std',
    自定义极差=range_metric
)
print("
使用 agg 进行命名聚合:")
print(named_aggregations) # 打印命名聚合的结果
# 结果是一个 Series,索引是我们指定的名称。

# 企业级场景:分析用户对不同特征的满意度
# 假设我们有多维度评分数据,这里简化为单一评分 Series,但可以想象其扩展
# 我们想计算正面评价 (>=4分) 的比例和负面评价 (<=2分) 的比例

def positive_rating_ratio(s):
    """计算评分大于等于4的比例"""
    s_cleaned = s.dropna() # 移除 NaN 值
    if len(s_cleaned) == 0:
        return np.nan # 处理空序列
    return (s_cleaned >= 4).sum() / len(s_cleaned) # 计算正面评价比例

def negative_rating_ratio(s):
    """计算评分小于等于2的比例"""
    s_cleaned = s.dropna() # 移除 NaN 值
    if len(s_cleaned) == 0:
        return np.nan # 处理空序列
    return (s_cleaned <= 2).sum() / len(s_cleaned) # 计算负面评价比例

customer_feedback_analysis = product_ratings.agg([
    'count', # 总评价数
    'mean',  # 平均分
    positive_rating_ratio, # 正面评价比例 (使用函数对象)
    'NegativeRatio': negative_rating_ratio # 负面评价比例 (使用命名聚合)
])
print("
客户反馈分析 (聚合结果):")
print(customer_feedback_analysis) # 打印客户反馈分析的聚合结果

.agg() 极大地增强了 Pandas 的聚合能力,使其能够轻松应对各种复杂的统计分析需求。

1.2.6.3 转换操作 (.transform())

Series.transform(func, axis=0, *args, **kwargs) 方法与 .apply().agg() 类似,都可以接受一个函数并将其应用于 Series。然而,.transform() 的核心特点和主要应用场景在于:

返回与原始 Series 相同索引和形状的对象

如果 func 是一个聚合函数(例如 'sum', 'mean'),.transform() 会计算这个聚合值,然后将该值广播回原始 Series 的每个元素位置。这意味着结果 Series 的每个元素都会是这个聚合值。
如果 func 是一个元素级转换函数(例如 lambda x: x*2),.transform() 的行为类似于 .apply(),对每个元素进行转换。
如果 func 返回一个 Series,则该 Series 必须与正在转换的组具有相同的索引(这在 DataFrame.groupby().transform() 中更常见,但理解其行为对 Series 也有帮助)。

常用于 groupby 操作之后:虽然我们这里主要讨论 Series 本身,但 .transform() 的真正威力通常在与 DataFrame.groupby() 结合使用时体现。它可以方便地计算组内聚合值,并将这些值作为新特征传播回原始数据的每一行。不过,在 Series 上单独使用时,它也有其独特的应用场景。

.apply().agg() 的主要区别(在 Series 上下文):

s.agg(func): 通常返回一个标量值 (如果 func 是聚合函数) 或一个形状可能不同的 Series (如果 func 返回多个值)。
s.apply(func): 返回一个 Series,其形状和索引通常与原 Series 相同(除非 func 返回标量,这时 .apply 表现得像 .agg;或者 func 返回更复杂的结构)。
s.transform(func): 总是返回一个与原 Series 具有相同索引和相同长度Series

如果 func 是聚合函数字符串 (如 'mean'),则计算全局均值并广播。
如果 func 是自定义函数,它应该返回一个标量(然后广播)或一个与输入 Series 相同长度的 Series(虽然在 Series.transform 中,函数通常被期望返回标量以便广播,或进行元素级操作)。

A. 使用聚合函数进行转换

transform 的参数是一个聚合函数名字符串(如 'mean', 'sum', 'max' 等)或一个返回标量的自定义聚合函数时,它会计算整个 Series 的聚合值,然后将这个值填充到结果 Series 的每一个位置。

import pandas as pd
import numpy as np

s_data = pd.Series([10, 20, 30, np.nan, 50, 60])
print("原始 Series (s_data):")
print(s_data) # 打印原始 Series

# 使用 'mean' 进行转换
s_mean_transformed = s_data.transform('mean') # 计算整个 Series 的均值,并广播到每个元素
print("
s_data.transform('mean'):")
print(s_mean_transformed) # 打印均值转换后的 Series
# 解释:s_data 的均值是 (10+20+30+50+60)/5 = 34.0。NaN 被忽略。
# transform 将这个 34.0 填充到结果 Series 的所有位置(包括原始 NaN 的位置)。

# 使用 'sum' 进行转换
s_sum_transformed = s_data.transform('sum') # 计算整个 Series 的总和,并广播
print("
s_data.transform('sum'):")
print(s_sum_transformed) # 打印总和转换后的 Series
# 解释:s_data 的总和是 10+20+30+50+60 = 170.0。

# 使用自定义聚合函数 (返回标量)
def first_valid_value(series):
    """返回序列中第一个非 NaN 值"""
    first_valid_index = series.first_valid_index() # 获取第一个有效值的索引
    return series[first_valid_index] if first_valid_index is not None else np.nan # 返回第一个有效值,或 NaN

s_first_val_transformed = s_data.transform(first_valid_value) # 应用自定义聚合函数并广播
print("
s_data.transform(first_valid_value):")
print(s_first_val_transformed) # 打印第一个有效值转换后的 Series
# 解释:s_data 的第一个非 NaN 值是 10。这个值被广播。

# 与 agg 的对比
s_mean_agg = s_data.agg('mean') # agg 返回标量
print(f"
s_data.agg('mean'): {
              s_mean_agg}")

s_sum_agg = s_data.agg('sum') # agg 返回标量
print(f"s_data.agg('sum'): {
              s_sum_agg}")

s_first_val_agg = s_data.agg(first_valid_value) # agg 返回标量
print(f"s_data.agg(first_valid_value): {
              s_first_val_agg}")

这个广播特性是 .transform().agg() 在使用聚合函数时的关键区别。.agg() 返回聚合后的标量,而 .transform() 返回一个与原 Series 形状相同的 Series,其中填充了该聚合标量。

B. 使用元素级函数进行转换

transform 的参数是一个进行元素级操作的函数时,其行为非常类似于 .apply()

import pandas as pd
import numpy as np

s_numbers = pd.Series([1, 2, 3, 4, 5])
print("原始 Series (s_numbers):")
print(s_numbers) # 打印原始数字 Series

# 使用 lambda 进行元素级转换
s_doubled_transform = s_numbers.transform(lambda x: x * 2) # 对每个元素乘以2
print("
s_numbers.transform(lambda x: x * 2):")
print(s_doubled_transform) # 打印转换后的 Series

s_doubled_apply = s_numbers.apply(lambda x: x * 2) # apply 的行为类似
print("
s_numbers.apply(lambda x: x * 2):")
print(s_doubled_apply) # 打印 apply 后的 Series

# 使用 np.sqrt 进行元素级转换
s_sqrt_transform = s_numbers.transform(np.sqrt) # 对每个元素开平方根
print("
s_numbers.transform(np.sqrt):")
print(s_sqrt_transform) # 打印开方后的 Series

# 也可以传递多个元素级函数 (结果会是 DataFrame,这通常在 DataFrame.transform 中更有用)
# 在 Series 上,如果传递多个函数,它会尝试对每个函数都产生一个 Series 并合并
try:
    s_multi_func_transform = s_numbers.transform([np.sqrt, lambda x: x**2]) # 尝试传递多个函数
    print("
s_numbers.transform([np.sqrt, lambda x: x**2]):")
    print(s_multi_func_transform) # 打印多函数转换的结果 (通常是DataFrame)
except Exception as e:
    print(f"
Error with s_numbers.transform([np.sqrt, lambda x: x**2]): {
              e}")
    # Series.transform 通常期望单个函数或函数名。
    # 对于多个函数,结果是一个DataFrame,其中列名是函数名。
    # 如果要对 Series 进行多个转换并保持为 Series,通常需要多次调用 transform 或 apply。

当用于元素级操作时,.transform().apply()Series 上的表现相似。.transform() 的主要区别和优势更多体现在与 groupby 结合,或者当明确需要广播聚合结果时。

C. 传递函数列表 (结果通常为 DataFrame)

如果在 Series.transform() 中传递一个函数列表,Pandas 会为每个函数生成一个结果列,最终返回一个 DataFrame

import pandas as pd
import numpy as np

s_data = pd.Series([1, 2, 3, 4], name='OriginalData')
print("原始 Series (s_data):")
print(s_data) # 打印原始 Series

# 传递聚合函数列表
df_transformed_agg = s_data.transform(['mean', 'sum', 'max']) # 传递聚合函数名列表
print("
s_data.transform(['mean', 'sum', 'max']):")
print(df_transformed_agg) # 打印聚合函数列表转换的结果
# 解释:每一列都是对应聚合函数的广播值。
# 比如 'mean' 列的所有值都是原始 Series 的均值 2.5。

# 传递元素级函数列表
df_transformed_elem = s_data.transform([np.sqrt, lambda x: x**2]) # 传递元素级函数列表
print("
s_data.transform([np.sqrt, lambda x: x**2]):")
print(df_transformed_elem) # 打印元素级函数列表转换的结果
# 解释:'sqrt' 列是每个元素的平方根,'<lambda>' 列是每个元素的平方。

# 混合使用
def custom_double(x):
    return x * 2
custom_double.__name__ = 'doubled' # 给自定义函数一个名字,以便在DataFrame列名中显示

df_transformed_mixed = s_data.transform(['sum', custom_double, np.exp]) # 混合使用聚合和元素级函数
print("
s_data.transform(['sum', custom_double, np.exp]):")
print(df_transformed_mixed) # 打印混合函数列表转换的结果
# 解释:'sum' 列是总和的广播;'doubled' 列是每个元素的两倍;'exp' 列是每个元素的指数。

这种行为在需要同时对一个 Series 进行多种转换并将结果并列比较时非常有用,直接生成一个结构化的 DataFrame

D. 企业级真实场景应用:特征工程 – 基于组内统计量进行标准化

虽然 Series.transform 单独使用时广播全局统计量,但其核心威力体现在与 groupby().transform() 结合,用于组内标准化、填充缺失值等。为了充分展示其思想,即使我们目前只已关注 Series,也可以模拟一个类似场景。

假设我们有一个 Series,其中包含不同类别项目的值,我们想计算每个类别的值与该类别均值的差异(中心化)。如果直接在 Series 上用 transform('mean'),得到的是全局均值。要实现组内效果,通常需要 groupby

但我们可以思考一个场景:如果一个 Series 自身就代表了需要被标准化的单一组数据,而我们想用其自身的统计量来标准化它

import pandas as pd
import numpy as np

# 场景:我们有一个产品在不同时间点的销售量数据,
# 我们想计算每个时间点的销售量与这段时期平均销售量的偏差百分比
# 这可以看作是对单一序列的“自我标准化”
product_sales_ts = pd.Series(
    [100, 120, 90, 110, 150, 130, 80, np.nan, 140],
    index=pd.date_range(start='2024-01-01', periods=9, freq='D'), # 日期时间索引
    name='DailySales'
)
print("每日销售量 Series:")
print(product_sales_ts) # 打印每日销售量 Series

# 计算整个序列的平均销售量 (使用 transform 来广播)
average_sales = product_sales_ts.transform('mean') # 计算均值并广播
print("
广播的平均销售量:")
print(average_sales) # 打印广播后的平均销售量

# 计算每个时间点销售量与平均销售量的差异
sales_deviation_from_mean = product_sales_ts - average_sales # 计算差值
print("
销售量与平均销售量的差异:")
print(sales_deviation_from_mean) # 打印差值 Series

# 计算偏差百分比
# (value - mean) / mean * 100
# 要小心均值为0的情况
mean_val = product_sales_ts.mean() # 先计算标量均值
if mean_val == 0: # 处理均值为0的特殊情况
    sales_deviation_percentage = pd.Series(np.nan, index=product_sales_ts.index) # 如果均值为0,则偏差百分比为NaN
else:
    sales_deviation_percentage = (sales_deviation_from_mean / mean_val) * 100 # 计算偏差百分比
sales_deviation_percentage.name = 'DeviationPercentage' # 给结果 Series 命名
print("
销售量与平均销售量的偏差百分比:")
print(sales_deviation_percentage.round(2)) # 打印偏差百分比,保留两位小数

# 使用 transform 实现 Z-score 标准化 ( (value - mean) / std )
# Z-score 标准化也是 transform 的一个经典应用 (尤其在 groupby 后)
# 这里对整个 Series 进行 Z-score
series_mean = product_sales_ts.transform('mean') # 广播均值
series_std = product_sales_ts.transform('std')   # 广播标准差

# 避免除以零 (如果标准差为0)
if product_sales_ts.std() == 0: # 检查标准差是否为0
    z_scores = pd.Series(0.0, index=product_sales_ts.index) # 如果标准差为0,则 Z-score 为0 (所有值相同)
else:
    z_scores = (product_sales_ts - series_mean) / series_std # 计算 Z-score
z_scores.name = 'Z_Scores' # 给结果 Series 命名
print("
对整个 Series 进行 Z-score 标准化:")
print(z_scores.round(2)) # 打印 Z-score 标准化结果,保留两位小数

# 再次强调:transform 的核心威力在于 groupby
# 假设我们有一个 DataFrame:
# df = pd.DataFrame({'Category': ['A', 'A', 'B', 'B', 'A'], 'Value': [10, 12, 100, 105, 11]})
# df['Category_Mean'] = df.groupby('Category')['Value'].transform('mean')
# 这会计算每个类别的均值,并将该类别的均值赋给该类别下的所有行。
# Category  Value  Category_Mean
# A         10     11.0  ( (10+12+11)/3 )
# A         12     11.0
# B         100    102.5 ( (100+105)/2 )
# B         105    102.5
# A         11     11.0
# 这是 transform 最典型的用法。

总结一下 Series.transform()

当传入聚合函数名或返回标量的函数时,它会计算全局聚合值并广播到与原 Series 相同的形状。
当传入元素级函数时,行为类似 .apply()
当传入函数列表时,返回一个 DataFrame,每列对应一个函数的转换结果。
它是 groupby().transform() 的基础,后者在按组进行标准化、填充等操作时非常强大。即使在 Series 上单独使用,理解其广播特性对于某些特定场景(如序列自身的标准化)也是有用的。

1.2.6.4 pipe() 方法:链式操作的优雅之道

Series.pipe(func, *args, **kwargs) 方法允许用户将一个 Series 对象作为第一个参数传递给一个自定义的或外部库的函数,从而实现更清晰的链式操作。

虽然你可以直接调用函数 my_function(series, arg1, kwarg1=val),但使用 .pipe() 可以使代码流更符合从左到右的阅读习惯,尤其是在进行一系列复杂的数据处理步骤时。

func: 一个函数或一个元组 (callable, data_keyword_name_str)

如果是函数,Series 对象将作为第一个位置参数传递给它。
如果是元组,callable 是函数,data_keyword_name_str 是一个字符串,指定了函数中接收 Series 数据的关键字参数的名称。

*args, **kwargs: 传递给 func 的额外位置参数和关键字参数。

import pandas as pd
import numpy as np

# 假设我们有一系列数据处理函数
def remove_outliers_iqr(series, factor=1.5):
    """使用IQR方法移除异常值,并将异常值替换为NaN"""
    q1 = series.quantile(0.25) # 计算第一四分位数
    q3 = series.quantile(0.75) # 计算第三四分位数
    iqr = q3 - q1 # 计算四分位距
    lower_bound = q1 - factor * iqr # 计算下界
    upper_bound = q3 + factor * iqr # 计算上界
    # 使用 where 保留界内值,界外值替换为 NaN
    return series.where((series >= lower_bound) & (series <= upper_bound), np.nan)

def log_transform_positive(series, offset=1e-6):
    """对正数进行对数转换,处理0和负数"""
    # 确保所有值都大于0,以便进行log转换
    series_positive = series.clip(lower=offset) # 将小于 offset 的值裁剪为 offset
    return np.log(series_positive) # 进行自然对数转换

def normalize_min_max(series):
    """将序列归一化到 [0, 1] 区间"""
    s_min = series.min() # 计算最小值
    s_max = series.max() # 计算最大值
    if s_max == s_min: # 处理所有值相同的情况
        return pd.Series(0.5, index=series.index) if pd.notna(s_min) else pd.Series(np.nan, index=series.index)
    return (series - s_min) / (s_max - s_min) # 进行最小-最大归一化

# 创建一个模拟的原始数据 Series
raw_data = pd.Series([-10, 1, 50, 55, 52, 200, 60, 58, 2, -5, 300, np.nan, 56], name='RawValues')
print("原始数据 Series:")
print(raw_data) # 打印原始数据 Series

# --- 传统方式调用 ---
# 1. 移除异常值
data_no_outliers_trad = remove_outliers_iqr(raw_data, factor=1.2)
# 2. 对正数部分进行对数转换 (假设我们只关心处理后的正值)
#    这里需要先处理NaN,并确保值>0
data_no_outliers_cleaned_trad = data_no_outliers_trad.dropna() # 移除因异常值处理产生的 NaN
data_log_trad = log_transform_positive(data_no_outliers_cleaned_trad[data_no_outliers_cleaned_trad > 0]) # 筛选正值并进行对数转换
# 3. 归一化 (对数转换后的结果)
data_normalized_trad = normalize_min_max(data_log_trad) # 进行归一化

print("
--- 传统方式处理结果 (部分) ---")
print(data_normalized_trad.head()) # 打印传统方式处理结果的前几行

# --- 使用 .pipe() 进行链式操作 ---
# 这种方式更易读,尤其是当处理步骤很多时
processed_data_pipe = (raw_data
                       .pipe(remove_outliers_iqr, factor=1.2) # 第一步:移除异常值,传入 factor 参数
                       .pipe(lambda s: s.dropna()) # 第二步:移除因上一步产生的NaN (使用lambda)
                       .pipe(lambda s: s[s > 0]) # 第三步:筛选正值
                       .pipe(log_transform_positive, offset=1e-5) # 第四步:对数转换,传入 offset 参数
                       .pipe(normalize_min_max) # 第五步:归一化
                      )
processed_data_pipe.name = 'ProcessedPipe' # 给结果 Series 命名

print("
--- 使用 .pipe() 处理结果 ---")
print(processed_data_pipe) # 打印使用 pipe 处理后的结果

# 使用元组 (callable, data_keyword_name_str) 的 pipe 形式
# 假设我们有一个函数,它期望数据通过一个特定的关键字参数传入
def custom_scaling(input_data, scale_factor, add_offset):
    """自定义缩放和平移"""
    print(f"Custom scaling received data via 'input_data' keyword.") # 打印信息,确认数据传入方式
    return input_data * scale_factor + add_offset # 进行缩放和平移

scaled_data = (raw_data
               .dropna() # 先移除原始 NaN
               .pipe((custom_scaling, 'input_data'), scale_factor=2, add_offset=5) # 使用元组形式的 pipe
              )
scaled_data.name = 'ScaledWithKeywordPipe' # 给结果 Series 命名
print("
--- 使用 .pipe() 并指定关键字参数 ---")
print(scaled_data) # 打印使用关键字参数 pipe 后的结果

# 企业级场景:构建复杂的数据清洗和特征工程流水线
# 假设我们有一个交易金额序列,需要进行以下处理:
# 1. 转换为正数 (取绝对值)
# 2. 移除极端异常值 (例如,基于百分位数盖帽)
# 3. 对数转换以减少偏度
# 4. Z-score 标准化
# 5. 将结果裁剪到 [-3, 3] 区间 (避免极端 Z-score)

transaction_amounts = pd.Series([100, 200, -50, 15000, 250, 300, 50, 10, -20, 12000, 80, np.nan, 50000])
print("
原始交易金额 Series:")
print(transaction_amounts) # 打印原始交易金额

def cap_by_percentile(series, lower_perc=0.01, upper_perc=0.99):
    """基于百分位数进行盖帽处理"""
    lower_bound = series.quantile(lower_perc) # 计算下界百分位数
    upper_bound = series.quantile(upper_perc) # 计算上界百分位数
    return series.clip(lower=lower_bound, upper=upper_bound) # 进行盖帽

def z_score_series(series):
    """计算 Series 的 Z-score"""
    # 确保标准差不为0
    std_dev = series.std()
    if std_dev == 0 or pd.isna(std_dev): # 处理标准差为0或NaN的情况
        return pd.Series(0.0, index=series.index) if len(series) > 0 else pd.Series(dtype=float)
    return (series - series.mean()) / std_dev # 计算 Z-score

# 构建处理流水线
engineered_features = (transaction_amounts
                       .pipe(lambda s: s.abs())                       # 1. 取绝对值
                       .pipe(cap_by_percentile, lower_perc=0.05, upper_perc=0.95) # 2. 百分位数盖帽
                       .pipe(lambda s: s.replace(0, 1e-6))             #    处理盖帽后可能出现的0,为log做准备
                       .pipe(np.log1p)                                # 3. 对数转换 (log1p = log(1+x))
                       .pipe(z_score_series)                          # 4. Z-score 标准化
                       .pipe(lambda s: s.clip(-3, 3))                 # 5. 裁剪 Z-score
                       .fillna(0)                                     # 最后填充可能因转换产生的NaN (例如,如果原始数据全为NaN)
                      )
engineered_features.name = 'EngineeredTransactionFeature' # 给结果 Series 命名
print("
复杂特征工程流水线处理后的交易特征:")
print(engineered_features) # 打印特征工程处理后的结果

.pipe() 极大地提高了 Pandas 代码(尤其是涉及多个步骤的转换序列)的可读性和可维护性。它鼓励将复杂的逻辑分解为一系列独立的、可测试的函数,然后像管道一样将数据依次通过这些函数进行处理。

1.2.6.5 字符串处理 (.str 访问器)

当一个 Pandas Seriesdtypeobjectstring (Pandas 1.0+ 引入的专用字符串类型),并且其包含的数据主要是字符串时,我们可以通过 .str 访问器来使用大量专门为字符串设计的向量化方法。这些方法通常是 Python 内建字符串方法的 Pandas 等效版本,但它们能自动处理缺失值 (NaN),并且通常比在 Python 循环中对每个字符串应用方法要高效得多。

A. 基础字符串方法

这些方法大多与 Python 的 str 对象方法同名。

s.str.lower(): 转换为小写。
s.str.upper(): 转换为大写。
s.str.capitalize(): 首字母大写,其余小写。
s.str.title(): 每个单词首字母大写。
s.str.len(): 计算每个字符串的长度。
s.str.strip(), s.str.lstrip(), s.str.rstrip(): 去除两端、左端或右端的空白字符(或指定字符)。
s.str.replace(pat, repl, n=-1, case=None, flags=0, regex=True): 替换子字符串或正则表达式。
s.str.split(pat=None, n=-1, expand=False, regex=None): 按分隔符分割字符串。
s.str.cat(others=None, sep=None, na_rep=None, join=None): 连接 Series 中的字符串或与其他类数组对象连接。
s.str.contains(pat, case=True, flags=0, na=np.nan, regex=True): 检查每个字符串是否包含某个模式。
s.str.startswith(pat, na=np.nan): 检查是否以某个模式开头。
s.str.endswith(pat, na=np.nan): 检查是否以某个模式结尾。
s.str.count(pat, flags=0): 计算模式在每个字符串中出现的次数。
s.str.findall(pat, flags=0): 查找所有非重叠匹配项。
s.str.match(pat, case=True, flags=0, na=np.nan): 尝试从字符串开头匹配模式(类似 re.match)。
s.str.extract(pat, flags=0, expand=True): 使用正则表达式提取捕获组。
s.str.get(i): 获取每个字符串中指定位置的字符(类似列表索引)。
s.str.slice(start=None, stop=None, step=None): 对每个字符串进行切片。
s.str.pad(width, side='left', fillchar=' '), s.str.center(), s.str.ljust(), s.str.rjust(): 填充字符串。
s.str.zfill(width): 左侧填充0。
s.str.isnumeric(), s.str.isalpha(), s.str.isalnum(), s.str.isdigit(), s.str.isspace(), s.str.islower(), s.str.isupper(), s.str.istitle(): 检查字符串属性。

import pandas as pd
import numpy as np

# 模拟一组包含不规范文本的企业产品描述数据
product_descriptions = pd.Series([
    "  Apple iPhone 15 Pro Max (256GB) - Blue Titanium ",
    "samsung galaxy BOOK 4 ultra; laptop ",
    " Google Pixel_8a phone, Obsidian Black",
    None, # 代表缺失数据
    "SONY WH-1000XM5 Noise Cancelling Headphones  ",
    "  microsoft surface pro 9 tablet, platinum   ",
    "Lg oled c3 55" tv + soundbar package"
], name='ProductDescription')
print("原始产品描述 Series:")
print(product_descriptions) # 打印原始产品描述

# 1. 清理空白并统一大小写
cleaned_descriptions = product_descriptions.str.strip().str.lower() # 链式操作:去除两端空白,然后转小写
print("
1. 清理空白并转小写:")
print(cleaned_descriptions) # 打印清理并转小写后的描述

# 2. 获取字符串长度 (清理后)
desc_lengths = cleaned_descriptions.str.len() # 计算每个清理后描述的长度 (NaN 的长度仍然是 NaN)
desc_lengths.name = 'DescriptionLength' # 给结果 Series 命名
print("
2. 清理后描述的长度:")
print(desc_lengths) # 打印描述长度

# 3. 替换特定子串 (例如,将 'apple' 替换为 'APPLE Inc.', 'samsung' 替换为 'SAMSUNG CORP.')
# 使用正则表达式进行更灵活的替换,忽略大小写
# 注意:.str.replace 的 regex 参数默认为 True
replaced_brands = cleaned_descriptions.str.replace(
    r'apple', 'APPLE Inc.', regex=True, case=False # 替换 'apple' (全词匹配,忽略大小写)
).str.replace(
    r'samsung', 'SAMSUNG CORP.', regex=True, case=False # 替换 'samsung'
).str.replace(
    r'google', 'Google LLC', regex=True, case=False # 替换 'google'
)
print("
3. 替换品牌名称:")
print(replaced_brands) # 打印替换品牌名称后的描述

# 4. 检查是否包含特定关键词 (例如 'phone', 'laptop', 'tv')
contains_phone = cleaned_descriptions.str.contains(r'phone|smartphone', regex=True, na=False) # 检查是否包含 'phone' 或 'smartphone' (NaN 视为 False)
contains_phone.name = 'ContainsPhone' # 命名
contains_laptop = cleaned_descriptions.str.contains(r'laptop|notebook|book', regex=True, na=False) # 检查是否包含 'laptop', 'notebook', 或 'book'
contains_laptop.name = 'ContainsLaptop' # 命名
contains_tv = cleaned_descriptions.str.contains(r'tv|television', regex=True, na=False) # 检查是否包含 'tv' 或 'television'
contains_tv.name = 'ContainsTV' # 命名

print("
4. 是否包含关键词 'phone', 'laptop', 'tv':")
print(pd.concat([cleaned_descriptions, contains_phone, contains_laptop, contains_tv], axis=1)) # 将结果合并为一个 DataFrame 进行展示

# 5. 提取信息:例如提取括号中的内容 (如容量 '256GB')
# pat = r'((.*?))'  # 正则表达式:匹配括号内的任何内容,非贪婪模式
# (.*?) 是一个捕获组
extracted_specs = cleaned_descriptions.str.extract(r'((.*?))', expand=False) # expand=False 返回 Series, expand=True 返回 DataFrame
extracted_specs.name = 'ExtractedSpecs' # 命名
print("
5. 提取括号中的规格信息:")
print(pd.concat([cleaned_descriptions, extracted_specs], axis=1)) # 合并展示
# 注意:如果一个字符串中有多个括号对,extract 只会提取第一个匹配的。
# 如果需要提取所有,需要使用 .str.findall() 结合更复杂的处理。

# 6. 分割字符串:例如按逗号或分号分割描述
# split 默认返回一个 Series of lists。设置 expand=True 可以直接得到 DataFrame。
split_parts = cleaned_descriptions.str.split(r'[;,]', n=1, expand=True, regex=True) # 按第一个逗号或分号分割,最多分割1次,扩展为DataFrame
split_parts.columns = ['Part1', 'Part2'] # 给分割后的列命名
print("
6. 按第一个逗号或分号分割描述:")
print(pd.concat([cleaned_descriptions, split_parts], axis=1)) # 合并展示

# 7. 获取每个单词的首字母 (一个更复杂的例子)
def get_initials(text_series):
    """获取每个字符串中每个单词的首字母并连接起来,处理 NaN"""
    if not isinstance(text_series, pd.Series): # 检查输入是否为 Series
        raise TypeError("Input must be a pandas Series.")
    
    def process_string(s):
        if pd.isna(s) or not s.strip(): # 处理 NaN 或空字符串
            return np.nan
        # 使用正则表达式找到所有单词,然后取首字母
        words = re.findall(r'[a-zA-Z0-9]+', str(s)) # 查找所有字母数字组成的单词
        return "".join([word[0] for word in words if word]).upper() if words else np.nan # 连接首字母并转大写
    
    import re # 导入 re 模块
    return text_series.apply(process_string) # 应用处理函数

initials = get_initials(cleaned_descriptions) # 调用自定义函数获取首字母
initials.name = 'Initials' # 命名
print("
7. 获取描述中单词的首字母缩写:")
print(pd.concat([cleaned_descriptions, initials], axis=1)) # 合并展示

B. 正则表达式的深度应用 (.str.extract(), .str.extractall(), .str.findall(), .str.match(), .str.replace()regex=True)

正则表达式是 .str 访问器威力的核心。Pandas 底层通常使用 Python 的 re 模块来实现这些功能。

extract(pat, expand=True): 从每个字符串中提取由正则表达式 pat 中的捕获组匹配的内容。

如果 pat 中只有一个捕获组,并且 expand=False,则返回一个 Series
如果 pat 中只有一个捕获组,并且 expand=True (默认),则返回一个单列 DataFrame
如果 pat 中有多个捕获组,则总是返回一个 DataFrame,每列对应一个捕获组。
未匹配到的字符串对应行为 NaN

extractall(pat): 查找 pat 在每个字符串中的所有匹配项(包括重叠匹配,如果正则允许),并为每个匹配项返回一行,同时包含捕获组。结果是一个带有 MultiIndexDataFrame (第一层是原始 Series 的索引,第二层是匹配项的索引)。
findall(pat): 返回一个 Series,其中每个元素是一个列表,包含字符串中所有非重lapping匹配 pat 的子串。如果 pat 包含捕获组,则列表元素是元组。
match(pat): 尝试从字符串的开头匹配 pat。如果成功,返回 True (或提取的组,如果 pat 有组且 extract 被间接使用),否则返回 FalseNaN。与 contains 不同,match 要求从头开始匹配。
replace(pat, repl, regex=True): repl 参数可以是一个替换字符串(可以包含捕获组的反向引用,如 1, 2),也可以是一个可调用对象 (函数)。如果 repl 是函数,它会接收一个匹配对象 (match object) 作为参数,并应返回替换后的字符串。

import pandas as pd
import re # 导入 re 模块用于更复杂的正则操作

# 场景:处理包含结构化信息的日志条目或用户评论
log_entries = pd.Series([
    "2024-03-15 10:30:15 INFO User 'john_doe' logged in from IP 192.168.1.100",
    "2024-03-15 10:32:00 WARNING Service 'payment_gw' timeout on transaction T12345",
    "2024-03-15 10:35:10 INFO User 'jane_smith' updated profile. New email: jane@example.com",
    "ERROR: Database connection failed. Code: DB_CONN_FAIL (Attempt 3)", # 无标准日期时间前缀
    None,
    "2024-03-15 10:40:00 DEBUG Internal state: {'value': 42, 'status': 'active'}"
])
print("原始日志条目 Series:")
print(log_entries) # 打印原始日志条目

# 1. 提取日期时间、日志级别、用户 (如果存在) 和IP地址 (如果存在)
# 定义一个更复杂的正则表达式,包含命名捕获组
log_pattern = re.compile(
    r"^(?P<Timestamp>d{4}-d{2}-d{2} d{2}:d{2}:d{2})?s*" # 可选的时间戳 (命名组: Timestamp)
    r"(?P<Level>INFO|WARNING|ERROR|DEBUG)?s*" # 可选的日志级别 (命名组: Level)
    r"(?:User '(?P<User>[a-zA-Z0-9_]+)')?s*" # 可选的用户信息 (非捕获组内的命名组: User)
    r".*?" # 匹配中间的任何字符 (非贪婪)
    r"(?:IP (?P<IP>d{1,3}.d{1,3}.d{1,3}.d{1,3}))?" # 可选的IP地址 (非捕获组内的命名组: IP)
    r".*$" # 匹配到行尾
)

extracted_data = log_entries.str.extract(log_pattern) # 使用正则表达式提取数据
print("
1. 从日志中提取结构化数据:")
print(extracted_data) # 打印提取后的 DataFrame
# 命名捕获组的名称自动成为 DataFrame 的列名。

# 2. 使用 extractall 提取所有数字序列 (例如交易ID、尝试次数等)
# 假设我们想提取所有看起来像 "T" 后跟数字的ID,以及括号中的数字
numeric_pattern = r"(Td+|(d+))" # 匹配 T后跟数字 或 括号内的数字
all_numeric_sequences = log_entries.str.extractall(numeric_pattern) # 提取所有数字序列
all_numeric_sequences.columns = ['NumericSequence'] # 给提取的列命名
print("
2. 使用 extractall 提取所有特定数字序列:")
print(all_numeric_sequences) # 打印提取的所有数字序列
# 注意 MultiIndex,第一层是原始 Series 索引,第二层是匹配项索引。

# 3. 使用 findall 查找所有邮箱地址
email_pattern = r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+.[A-Z|a-z]{2,}' # 邮箱的正则表达式
found_emails = log_entries.str.findall(email_pattern) # 查找所有邮箱地址
found_emails.name = 'FoundEmails' # 命名
print("
3. 使用 findall 查找所有邮箱地址:")
print(found_emails) # 打印查找到的邮箱地址 (每行为一个列表)
# 清理空列表并展开 (如果每行只有一个邮箱或我们只关心第一个)
first_email = found_emails.apply(lambda x: x[0] if isinstance(x, list) and len(x) > 0 else np.nan) # 提取第一个邮箱
first_email.name = 'FirstEmail' # 命名
print("
提取的第一个邮箱:")
print(pd.concat([log_entries, first_email], axis=1)) # 合并展示

# 4. 使用 replace 和可调用对象进行复杂替换
# 场景:将日志中的IP地址进行匿名化处理 (例如,替换最后一部分为 'XXX')
def anonymize_ip(match_obj):
    """接收一个 re.Match 对象,匿名化IP地址"""
    ip_address = match_obj.group(0) # group(0) 是整个匹配到的IP地址
    if ip_address:
        parts = ip_address.split('.') # 分割IP地址的各个部分
        if len(parts) == 4: # 确保是标准的IPv4地址
            return f"{
              parts[0]}.{
              parts[1]}.{
              parts[2]}.XXX" # 替换最后一部分
    return ip_address # 如果不是标准IP或匹配失败,返回原样

ip_pattern_for_replace = r'd{1,3}.d{1,3}.d{1,3}.d{1,3}' # 匹配IP地址的正则表达式
anonymized_logs_callable = log_entries.str.replace(
    ip_pattern_for_replace, anonymize_ip, regex=True
)
anonymized_logs_callable.name = 'AnonymizedLogsCallable' # 命名
print("
4. 使用可调用对象替换IP地址进行匿名化:")
print(anonymized_logs_callable) # 打印匿名化后的日志

# 使用 replace 和反向引用
# 场景: 将 "User 'username'" 格式替换为 "USER:[USERNAME]"
user_replace_pattern = r"User '([a-zA-Z0-9_]+)'" # 捕获用户名
user_replacement_format = r"USER:[1]" # 1 引用第一个捕获组 (用户名)
formatted_user_logs = log_entries.str.replace(
    user_replace_pattern, user_replacement_format, regex=True
)
formatted_user_logs.name = 'FormattedUserLogs' # 命名
print("
使用反向引用替换用户信息格式:")
print(formatted_user_logs) # 打印格式化用户信息后的日志

熟练运用正则表达式是利用 .str 访问器进行高级文本处理的关键。命名捕获组、extractallMultiIndex 输出、replace 与可调用对象的结合,都为复杂文本数据的解析和转换提供了强大工具。

C. Pandas 的 StringDtype

从 Pandas 1.0 开始,引入了专门的 StringDtype 和对应的 StringArray。与默认的 object dtype 存储字符串相比,StringDtype 具有以下优势:

明确性: 它清楚地表明列中包含的是字符串数据,而不是混合类型。
缺失值处理: 它使用 pd.NA (Pandas 的专用缺失值标记) 而不是 np.nan (浮点型 NaN) 来表示缺失的字符串。这可以避免在某些操作中因 np.nan 是浮点数而引发的类型问题。
潜在的性能和内存优化: 虽然不总是能保证,但 StringDtype 为未来针对字符串操作的特定优化提供了基础。

import pandas as pd

# 使用 object dtype (默认)
s_object = pd.Series(["apple", "banana", None, "cherry"], name='ObjectSeries')
print("Object Dtype Series:")
print(s_object) # 打印 object dtype Series
print(f"dtype: {
              s_object.dtype}") # 打印 dtype
print(f"Type of None: {
              type(s_object.iloc[2])}") # 打印 None 的类型 (通常是 NoneType,在Series中会被转为np.nan或pd.NA)

# 使用 StringDtype
s_string_dtype = pd.Series(["apple", "banana", pd.NA, "cherry"], dtype=pd.StringDtype(), name='StringDtypeSeries')
# 或者: s_string_dtype = pd.Series(["apple", "banana", None, "cherry"], dtype="string", name='StringDtypeSeries')
print("
StringDtype Series:")
print(s_string_dtype) # 打印 StringDtype Series
print(f"dtype: {
              s_string_dtype.dtype}") # 打印 dtype
print(f"Type of pd.NA: {
              type(s_string_dtype.iloc[2])}") # 打印 pd.NA 的类型

# .str 访问器对两者都可用
print("
StringDtype .str.upper():")
print(s_string_dtype.str.upper()) # 对 StringDtype Series 应用 .str.upper()

# 比较缺失值行为
s_obj_contains_a = s_object.str.contains('a') # np.nan 在 .str 操作中通常被视为 NaN
print(f"
Object Series .str.contains('a'):
{
              s_obj_contains_a}") # 打印 object Series 的 contains 结果

s_str_contains_a = s_string_dtype.str.contains('a') # pd.NA 也会在 .str 操作中产生 pd.NA
print(f"
StringDtype Series .str.contains('a'):
{
              s_str_contains_a}") # 打印 StringDtype Series 的 contains 结果

# 在某些聚合或比较中,pd.NA 的行为可能更符合预期
# 例如,在需要严格区分字符串和数字NaN的场景
# s_object.fillna("missing").tolist() -> ['apple', 'banana', 'missing', 'cherry']
# s_string_dtype.fillna("missing").tolist() -> ['apple', 'banana', 'missing', 'cherry']
# 主要区别在于内部表示和某些操作的类型一致性

# 推荐在明确知道列是字符串数据时使用 StringDtype
# 从文件读取时,可以通过 dtype 参数指定:
# df = pd.read_csv('my_data.csv', dtype={'text_column': 'string'})

当处理纯文本数据,特别是包含缺失值时,使用 StringDtype 是一个好习惯,它可以提供更一致和明确的行为。

D. 高级字符串操作和第三方库集成

Series.str.get_dummies(sep='|'): 将包含分隔符的字符串列转换为独热编码 (dummy/indicator) DataFrame
Series.str.normalize(form): 对 Unicode 字符串进行规范化 (例如,将带音调的字符转换为基本字符和音调标记的组合,或者反过来)。这在处理多语言文本时非常重要。
集成外部库: Pandas 的 .pipe().apply() 可以方便地集成更专业的NLP库(如 NLTK, spaCy, fuzzywuzzy 等)进行更高级的文本分析,如词形还原、词干提取、命名实体识别、情感分析、模糊字符串匹配等。

import pandas as pd
import numpy as np

# 1. str.get_dummies()
# 假设我们有用户标签数据,一个用户可以有多个标签,用逗号分隔
user_tags = pd.Series([
    "python,data_analysis,machine_learning",
    "python,web_development",
    "data_analysis,visualization",
    np.nan,
    "machine_learning,deep_learning,python",
    "java,web_development"
], name='UserTags')
print("用户标签 Series:")
print(user_tags) # 打印用户标签 Series

tag_dummies = user_tags.str.get_dummies(sep=',') # 使用逗号作为分隔符创建虚拟变量
print("
使用 str.get_dummies() 生成的标签虚拟变量 DataFrame:")
print(tag_dummies) # 打印生成的虚拟变量 DataFrame

# 2. str.normalize()
# 处理包含不同 Unicode 写法的字符串,例如带音调的字母
accented_strings = pd.Series(['café', 'crème brûlée', 'déjà vu', 'ångström'])
print("
带音调的字符串 Series:")
print(accented_strings) # 打印带音调的字符串

# 'NFKD': Compatibility Decomposition, followed by Canonical Composition.
# 会将如 'é' 分解为 'e' 和声调标记 '´'
normalized_nfkd = accented_strings.str.normalize('NFKD')
print("
使用 NFKD 规范化:")
print(normalized_nfkd) # 打印 NFKD 规范化后的字符串

# 如果想移除音调,可以进一步处理 (例如,只保留 ASCII 字符)
def remove_accents_nfkd(text_series):
    """使用NFKD规范化后移除音调"""
    if not isinstance(text_series, pd.Series):
        raise TypeError("Input must be a pandas Series.")
    
    def process_string(s):
        if pd.isna(s):
            return np.nan
        # 规范化并编码为 ascii,忽略无法编码的字符(即音调符号)
        return s.normalize('NFKD').encode('ascii', 'ignore').decode('ascii')
    return text_series.apply(process_string)

unaccented_strings = remove_accents_nfkd(accented_strings)
unaccented_strings.name = "Unaccented" # 命名
print("
移除音调后的字符串 (NFKD + ASCII encode/decode):")
print(unaccented_strings) # 打印移除音调后的字符串

# 3. 集成 fuzzywuzzy 进行模糊字符串匹配 (示例)
# 需要先安装: pip install fuzzywuzzy python-Levenshtein
try:
    from fuzzywuzzy import fuzz, process
    FUZZYWUZZY_AVAILABLE = True
except ImportError:
    FUZZYWUZZY_AVAILABLE = False
    print("
Fuzzywuzzy library not installed. Skipping fuzzy matching example.")

if FUZZYWUZZY_AVAILABLE:
    # 假设我们有一系列不完全标准的部门名称,以及一个标准部门名称列表
    messy_departments = pd.Series([
        "R&D Department", "Research an Dev", "RD dept.",
        "Marketing Team", "Mktg", "Sales & Marketing",
        "Human ressources", "HR", "Personel"
    ], name='MessyDepartment')
    standard_departments = ["Research and Development", "Marketing", "Sales", "Human Resources", "Finance"]
    print("
待匹配的部门名称 Series:")
    print(messy_departments) # 打印待匹配的部门名称
    print("
标准部门名称列表:")
    print(standard_departments) # 打印标准部门名称列表

    def find_best_fuzzy_match(name, choices, scorer=fuzz.WRatio, threshold=70):
        """使用 fuzzywuzzy 找到最佳匹配"""
        if pd.isna(name):
            return np.nan
        # process.extractOne 返回 (best_match, score)
        best_match, score = process.extractOne(name, choices, scorer=scorer) # 提取最佳匹配
        return best_match if score >= threshold else np.nan # 如果分数高于阈值则返回最佳匹配,否则返回 NaN

    # 应用模糊匹配 (这里用 .apply 因为 process.extractOne 不是向量化的)
    matched_departments = messy_departments.apply(
        find_best_fuzzy_match, 
        choices=standard_departments, 
        scorer=fuzz.token_sort_ratio, # 使用 token_sort_ratio 忽略单词顺序
        threshold=65 # 设置匹配阈值
    )
    matched_departments.name = 'StandardizedDepartment' # 命名
    print("
通过模糊匹配标准化后的部门名称:")
    print(pd.concat([messy_departments, matched_departments], axis=1)) # 合并展示

.str 访问器是 Pandas 处理文本数据的基石。结合正则表达式、StringDtype 以及通过 .pipe().apply() 集成外部NLP库,可以构建出非常强大的文本数据清洗、转换和分析流水线。

1.2.6.6 日期时间处理 (.dt 访问器)

当一个 Pandas Seriesdtypedatetime64[ns] (表示纳秒级精度的时间戳) 或 timedelta64[ns] (表示时间差) 时,我们可以通过 .dt 访问器来使用一系列专门为日期时间数据设计的方法和属性。这些工具对于时间序列分析、金融数据处理、日志分析等场景至关重要。

前提条件:确保 Series 是日期时间类型

在使用 .dt 访问器之前,必须确保 Series 中的数据是 Pandas 可识别的日期时间类型。如果数据最初是字符串或其他格式,需要使用 pd.to_datetime() 进行转换。

import pandas as pd
import numpy as np

# 模拟不同格式的日期时间字符串数据
raw_dates_data = [
    "2023-01-15 10:30:00", 
    "16/02/2023 14:45", # DD/MM/YYYY
    "Mar 03, 2023 08:15 PM",
    "20230420", # YYYYMMDD
    "Invalid Date", # 无效日期
    None, # 缺失值
    "2023-05-10T12:00:00Z", # ISO 8601 UTC
    "06-20-2023" # MM-DD-YYYY (可能需要指定 format)
]
s_raw_dates = pd.Series(raw_dates_data, name='RawDateStrings')
print("原始日期字符串 Series:")
print(s_raw_dates) # 打印原始日期字符串 Series

# 使用 pd.to_datetime() 进行转换
# errors='coerce' 会将无法解析的日期转换为 NaT (Not a Time)
s_datetime_coerce = pd.to_datetime(s_raw_dates, errors='coerce')
print("
转换为 datetime (errors='coerce'):")
print(s_datetime_coerce) # 打印转换后的 datetime Series
print(f"dtype: {
              s_datetime_coerce.dtype}") # 打印 dtype (通常是 datetime64[ns])

# 尝试自动推断格式,但对于不明确的格式可能出错或解析错误
# 例如 "06-20-2023" 可能被解析为 DD-MM-YYYY 或 MM-DD-YYYY
# pd.to_datetime(["06-20-2023", "20-06-2023"]) # 第一个可能是6月20,第二个是20月6日(错误)

# 为特定格式指定 format 参数可以提高准确性和效率
s_datetime_formatted = pd.to_datetime(s_raw_dates, format='%m-%d-%Y', errors='coerce') # 尝试按 MM-DD-YYYY 格式解析
# 注意:上面的 format='%m-%d-%Y' 只对 "06-20-2023" 有效,其他格式会因不匹配而变 NaT。
# 在实际中,如果格式混杂,可能需要分批处理或更复杂的解析逻辑。

# 更稳健的做法是逐个尝试可能的格式或编写自定义解析器,
# 但 `errors='coerce'` 是处理混合格式并允许后续使用 `.dt` 的常用起点。
# 对于 "16/02/2023", 可以使用 dayfirst=True
s_datetime_dayfirst_example = pd.to_datetime(pd.Series(["16/02/2023", "17/03/2023"]), dayfirst=True, errors='coerce')
print("
使用 dayfirst=True 解析 'DD/MM/YYYY' 格式:")
print(s_datetime_dayfirst_example) # 打印使用 dayfirst=True 解析后的结果

# 假设我们用 s_datetime_coerce 继续
datetime_series = s_datetime_coerce 

一旦 Series 转换为 datetime64[ns] 类型,就可以使用 .dt 访问器了。

A. 提取日期时间组件

.dt 访问器提供了许多属性来提取日期时间的各个部分:

dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.microsecond, dt.nanosecond
dt.date: 返回 datetime.date 对象组成的 Series (不含时间)。
dt.time: 返回 datetime.time 对象组成的 Series (不含日期)。
dt.dayofweek (或 dt.weekday): 一周中的第几天 (周一=0, 周日=6)。
dt.day_of_week (同上,更符合某些命名习惯)
dt.dayofyear (或 dt.day_of_year): 一年中的第几天。
dt.weekofyear (或 dt.week): 一年中的第几周 (ISO 8601 定义)。
dt.quarter: 季度 (1-4)。
dt.is_month_start, dt.is_month_end: 是否为月初/月末。
dt.is_quarter_start, dt.is_quarter_end: 是否为季初/季末。
dt.is_year_start, dt.is_year_end: 是否为年初/年末。
dt.is_leap_year: 是否为闰年。
dt.days_in_month (或 dt.daysinmonth): 当月的天数。
dt.month_name(): 月份名称 (e.g., ‘January’)。
dt.day_name(): 星期名称 (e.g., ‘Monday’)。

import pandas as pd

# 假设 datetime_series 是上一段代码生成的,已经转换好的 datetime Series
# 为了示例清晰,我们重新创建一个干净的 datetime_series
ts_data = [
    "2023-01-15 10:30:45.123456", 
    "2023-02-28 23:59:59",
    "2024-03-01 00:00:00", # 2024 is a leap year
    "2023-12-31 18:00:00",
    pd.NaT # 缺失时间
]
datetime_series = pd.to_datetime(pd.Series(ts_data), errors='coerce')
datetime_series.name = "Timestamp" # 命名
print("Datetime Series:")
print(datetime_series) # 打印 Datetime Series

# 提取组件
years = datetime_series.dt.year.rename("Year") # 提取年份
months = datetime_series.dt.month.rename("Month") # 提取月份
days = datetime_series.dt.day.rename("Day") # 提取天
hours = datetime_series.dt.hour.rename("Hour") # 提取小时
minutes = datetime_series.dt.minute.rename("Minute") # 提取分钟
seconds = datetime_series.dt.second.rename("Second") # 提取秒
microseconds = datetime_series.dt.microsecond.rename("Microsecond") # 提取微秒

day_of_week = datetime_series.dt.dayofweek.rename("DayOfWeek (Mon=0)") # 提取星期几 (周一为0)
day_name = datetime_series.dt.day_name().rename("DayName") # 提取星期名称
day_of_year = datetime_series.dt.dayofyear.rename("DayOfYear") # 提取一年中的第几天
week_of_year = datetime_series.dt.week.rename("WeekOfYear") # 提取一年中的第几周
quarter = datetime_series.dt.quarter.rename("Quarter") # 提取季度
is_leap = datetime_series.dt.is_leap_year.rename("IsLeapYear") # 提取是否为闰年
days_in_curr_month = datetime_series.dt.days_in_month.rename("DaysInMonth") # 提取当前月份的天数

# 将所有提取的组件合并到一个 DataFrame 中方便查看
extracted_components_df = pd.concat([
    datetime_series, years, months, days, hours, minutes, seconds, microseconds,
    day_of_week, day_name, day_of_year, week_of_year, quarter, is_leap, days_in_curr_month
], axis=1)

print("
提取的日期时间组件:")
print(extracted_components_df) # 打印提取的日期时间组件

# 提取日期部分和时间部分
date_part = datetime_series.dt.date.rename("DatePart") # 提取日期部分
time_part = datetime_series.dt.time.rename("TimePart") # 提取时间部分
print("
日期部分和时间部分:")
print(pd.concat([datetime_series, date_part, time_part], axis=1)) # 合并展示日期和时间部分

# 企业级场景:基于时间特征的聚合分析
# 假设 datetime_series 是用户活动的发生时间戳
# 我们想按“是否周末”、“工作日时段(早/中/晚)”来分析活动数量
# (这里只做特征提取,后续可以配合 groupby 进行聚合)

is_weekend = datetime_series.dt.dayofweek.isin([5, 6]).rename("IsWeekend") # 周六(5)或周日(6)则为True

def get_workday_period(dt_series_hour):
    """根据小时划分工作日时段"""
    # dt_series_hour 是一个包含小时数的 Series
    conditions = [
        (dt_series_hour >= 0) & (dt_series_hour < 6),   # 凌晨
        (dt_series_hour >= 6) & (dt_series_hour < 12),  # 上午
        (dt_series_hour >= 12) & (dt_series_hour < 18), # 下午
        (dt_series_hour >= 18) & (dt_series_hour <= 23) # 晚上
    ]
    choices = ['Late Night', 'Morning', 'Afternoon', 'Evening']
    return pd.Series(np.select(conditions, choices, default='Unknown'), index=dt_series_hour.index) # 使用 np.select 进行条件赋值

workday_period = get_workday_period(datetime_series.dt.hour).rename("WorkdayPeriod") # 获取工作日时段

time_features_df = pd.concat([datetime_series, is_weekend, workday_period], axis=1)
print("
基于时间戳生成的特征 (是否周末, 工作日时段):")
print(time_features_df) # 打印生成的特征

这些属性使得从日期时间数据中提取有用的特征变得非常简单,这些特征可以直接用于后续的分析、可视化或机器学习模型的训练。

B. 日期时间格式化 (.dt.strftime())

datetime 对象转换为特定格式的字符串,使用标准的 strftime 格式代码。

import pandas as pd

ts_data = ["2023-01-15 10:30:45", "2024-12-31 23:59:00", pd.NaT]
datetime_series = pd.to_datetime(pd.Series(ts_data))
datetime_series.name = "Timestamp" # 命名
print("原始 Datetime Series:")
print(datetime_series) # 打印原始 Datetime Series

# 格式化为 "YYYY/MM/DD HH:MM"
formatted_v1 = datetime_series.dt.strftime('%Y/%m/%d %H:%M').rename("Format_YMD_HM") # 格式化为 YYYY/MM/DD HH:MM
print("
格式化为 'YYYY/MM/DD HH:MM':")
print(formatted_v1) # 打印格式化结果

# 格式化为 "DayName, MonthName Day, Year - Hour:Minute AM/PM"
formatted_v2 = datetime_series.dt.strftime('%A, %B %d, %Y - %I:%M %p').rename("Format_Verbose") # 详细格式化
print("
格式化为 'DayName, MonthName Day, Year - Hour:Minute AM/PM':")
print(formatted_v2) # 打印详细格式化结果
# NaT 会被格式化为 NaT (或有时是 NaN 字符串,取决于 Pandas 版本和上下文)

# 企业级场景:生成报告中需要特定日期格式的字段
# 假设要生成文件名或报告标题,包含特定格式的日期
report_date_str = datetime_series.dt.strftime('Report_For_%Y%m%d').rename("ReportDateString") # 格式化为报告日期字符串
print("
生成报告日期字符串:")
print(report_date_str) # 打印报告日期字符串
# 对于 NaT,结果也是 NaT。在实际使用中可能需要 .fillna("UNKNOWN_DATE")

C. 日期时间运算与 Timedelta

可以直接对 datetime Series 进行加减 Timedelta 对象或另一个 datetime Series (结果为 Timedelta Series) 的操作。

pd.Timedelta(): 创建时间差对象 (e.g., pd.Timedelta(days=1, hours=5)).
.dt.round(freq): 将时间戳取整到指定的频率 ('S', 'min', 'H', 'D'等)。
.dt.floor(freq): 向下取整到指定频率。
.dt.ceil(freq): 向上取整到指定频率。
.dt.normalize(): 将时间部分设为午夜 (00:00:00),只保留日期。

import pandas as pd

ts1_data = ["2023-01-15 10:00:00", "2023-01-20 12:00:00", pd.NaT]
s1 = pd.to_datetime(pd.Series(ts1_data)).rename("Timestamp1") # 创建第一个时间序列

ts2_data = ["2023-01-10 08:00:00", "2023-01-22 15:00:00", "2023-01-25 00:00:00"]
s2 = pd.to_datetime(pd.Series(ts2_data)).rename("Timestamp2") # 创建第二个时间序列
print("原始时间序列 s1 和 s2:")
print(pd.concat([s1, s2], axis=1)) # 合并展示原始时间序列

# 1. Series 与 Timedelta 相加/减
one_day_3_hours = pd.Timedelta(days=1, hours=3) # 创建一个1天3小时的时间差
s1_plus_delta = (s1 + one_day_3_hours).rename("s1_plus_1d3h") # s1 加上时间差
print(f"
Adding {
              one_day_3_hours} to s1:")
print(s1_plus_delta) # 打印加法结果

s1_minus_delta = (s1 - pd.Timedelta(hours=5, minutes=30)).rename("s1_minus_5h30m") # s1 减去时间差
print(f"
Subtracting 5h30m from s1:")
print(s1_minus_delta) # 打印减法结果

# 2. 两个 datetime Series 相减得到 Timedelta Series
time_difference = (s1 - s2).rename("s1_minus_s2_Duration") # 两个时间序列相减
print("
Difference between s1 and s2 (Timedelta):")
print(time_difference) # 打印时间差结果
# 注意:运算是元素级的,如果长度不同或索引不匹配,会进行对齐。NaT 参与运算结果为 NaT。

# 提取 Timedelta 的组件 (如果 Series 是 timedelta64[ns] 类型)
# 例如,获取总秒数
total_seconds_diff = time_difference.dt.total_seconds().rename("Difference_TotalSeconds") # 获取总秒数
print("
Difference in total seconds:")
print(total_seconds_diff) # 打印总秒数差

# 3. 日期时间取整/舍入
s_timestamps = pd.to_datetime(pd.Series([
    "2023-07-15 10:33:17", 
    "2023-07-15 23:58:01", 
    "2023-07-16 00:02:30"
])).rename("OriginalTS") # 创建时间戳序列

rounded_to_hour = s_timestamps.dt.round('H').rename("RoundedToHour") # 四舍五入到最近的小时
floor_to_day = s_timestamps.dt.floor('D').rename("FloorToDay") # 向下取整到天 (即当天的午夜)
ceil_to_minute = s_timestamps.dt.ceil('min').rename("CeilToMinute") # 向上取整到分钟

print("
Datetime rounding/flooring/ceiling:")
print(pd.concat([s_timestamps, rounded_to_hour, floor_to_day, ceil_to_minute], axis=1)) # 合并展示取整结果

# 4. dt.normalize() (去除时间部分)
normalized_dates = s_timestamps.dt.normalize().rename("NormalizedDate") # 将时间部分设为午夜
print("
Normalized dates (time part set to midnight):")
print(pd.concat([s_timestamps, normalized_dates], axis=1)) # 合并展示 normalize 结果

# 企业级场景:计算事件持续时间、任务截止日期
# 假设 s1 是任务开始时间,我们需要计算 3 个工作日后的截止日期 (不考虑周末和节假日 - 简单版)
# 简单版:直接加 timedelta (不精确,未处理周末)
simple_deadline = (s1 + pd.Timedelta(days=3*1)).rename("SimpleDeadline_3days") # 简单加3天
# 更精确的需要 pd.offsets.BusinessDay() 或 pd.offsets.BDay()
try:
    # from pandas.tseries.offsets import BDay # 旧版 pandas
    from pandas.tseries.offsets import BusinessDay # 新版 pandas
    OFFSET_CLASS = BusinessDay
except ImportError:
    OFFSET_CLASS = pd.offsets.BDay # 兼容旧版

business_days_to_add = OFFSET_CLASS(3) # 创建3个工作日的时间偏移量
precise_deadline = (s1 + business_days_to_add).rename("Deadline_3_BusinessDays") # 加上3个工作日
print("
计算任务截止日期:")
print(pd.concat([s1, simple_deadline, precise_deadline], axis=1)) # 合并展示截止日期计算结果
# NaT + offset = NaT

Pandas 的 offset 对象 (如 BDay, MonthEnd, DateOffset) 提供了更灵活和强大的日期时间偏移功能,可以处理复杂的业务日历逻辑。

D. 时区处理 (.dt.tz_localize(), .dt.tz_convert(), .dt.tz)

处理时区是日期时间数据分析中的一个常见且棘手的问题。Pandas 提供了强大的时区支持。

datetime_series.dt.tz: 获取或设置 Series 的时区。
datetime_series.dt.tz_localize(tz, ambiguous='raise', nonexistent='raise'):
本地化一个朴素 (naive)datetime Series (没有时区信息) 到指定的时区 tz

ambiguous: 如何处理因夏令时切换等原因导致的歧义时间 (e.g., ‘NaT’, ‘infer’, [True, False, …])。
nonexistent: 如何处理不存在的时间 (e.g., ‘NaT’, ‘shift_forward’, ‘shift_backward’)。

datetime_series_aware.dt.tz_convert(tz):
将一个感知 (aware)datetime Series (已有时区信息) 转换为另一个指定的时区 tz

import pandas as pd
from pytz import common_timezones # 导入常见时区列表

# 1. 创建一个朴素的 datetime Series
naive_dt_series = pd.to_datetime(pd.Series([
    "2023-11-05 01:30:00", # 这个时间在 US/Eastern 从夏令时切换回标准时间时可能存在歧义
    "2023-03-12 02:30:00", # 这个时间在 US/Eastern 从标准时间切换到夏令时时可能不存在
    "2023-07-01 10:00:00"
])).rename("NaiveDateTime")
print("朴素的 Datetime Series (无时区信息):")
print(naive_dt_series) # 打印朴素的 Datetime Series
print(f"Initial tz: {
              naive_dt_series.dt.tz}") # 初始时区为 None

# 2. 本地化到特定时区 (e.g., 'US/Eastern')
# 处理歧义时间 (ambiguous) 和不存在时间 (nonexistent)
try:
    # 第一次尝试本地化,可能会因歧义或不存在时间而报错 (默认 raise)
    # localized_dt_eastern_default = naive_dt_series.dt.tz_localize('US/Eastern')
    # print("
Localized to US/Eastern (default error handling):")
    # print(localized_dt_eastern_default)
    # print(f"tz: {localized_dt_eastern_default.dt.tz}")
    
    # 使用 'infer' 处理歧义时间 (尝试根据 DST 规则推断)
    # 使用 'NaT' 处理不存在时间
    localized_dt_eastern_infer = naive_dt_series.dt.tz_localize(
        'US/Eastern', ambiguous='infer', nonexistent='NaT'
    ).rename("Localized_USEastern_Infer")
    print("
Localized to US/Eastern (ambiguous='infer', nonexistent='NaT'):")
    print(localized_dt_eastern_infer) # 打印本地化后的时间序列 (推断歧义,不存在时间为NaT)
    print(f"tz: {
              localized_dt_eastern_infer.dt.tz}") # 打印时区信息

    # 另一种处理歧义的方式是提供一个布尔数组
    # 2023-11-05 01:30:00 ET 发生了两次,一次是 EDT(-04:00),一次是 EST(-05:00)
    # ambiguous=[True, False, False] 假设第一个歧义时间是 DST,后两个不是(如果它们也是歧义的)
    # 这个例子中,只有第一个时间是歧义的。
    # 注意: 确保 ambiguous 列表长度与 Series 一致,或者只为歧义时间点提供。
    # Pandas 会尝试匹配歧义时间点的索引。
    # For simplicity, let's focus on 'infer' or 'NaT'.

    # 本地化到 UTC (通常不会有歧义或不存在的问题)
    localized_dt_utc = naive_dt_series.dt.tz_localize('UTC').rename("Localized_UTC")
    print("
Localized to UTC:")
    print(localized_dt_utc) # 打印本地化到 UTC 的时间序列
    print(f"tz: {
              localized_dt_utc.dt.tz}") # 打印时区信息

except Exception as e:
    print(f"Error during localization: {
              e}")

# 3. 将已感知的 datetime Series 转换到另一个时区
if 'localized_dt_eastern_infer' in locals(): # 确保 localized_dt_eastern_infer 已成功创建
    dt_eastern_aware = localized_dt_eastern_infer.dropna() # 移除 NaT 以便转换
    
    converted_to_london = dt_eastern_aware.dt.tz_convert('Europe/London').rename("Converted_London")
    print("
Converted from US/Eastern to Europe/London:")
    print(converted_to_london) # 打印转换到伦敦时间的时间序列
    print(f"tz: {
              converted_to_london.dt.tz}") # 打印时区信息

    converted_to_utc_from_eastern = dt_eastern_aware.dt.tz_convert('UTC').rename("Converted_UTC_from_Eastern")
    print("
Converted from US/Eastern to UTC:")
    print(converted_to_utc_from_eastern) # 打印从美东时间转换到UTC的时间序列
    print(f"tz: {
              converted_to_utc_from_eastern.dt.tz}") # 打印时区信息
    
    # 移除时区信息,变回朴素时间 (表示的是原时区的本地时间,但失去了时区上下文)
    naive_again = converted_to_london.dt.tz_localize(None).rename("Naive_from_London")
    print("
Made naive again (localized to None) from London time:")
    print(naive_again) # 打印移除时区信息后的时间序列
    print(f"tz: {
              naive_again.dt.tz}") # 打印时区信息 (应为 None)

# 企业级场景:处理全球用户数据,统一到 UTC 进行存储和分析
# 假设我们有来自不同时区的用户活动日志,时间戳是本地时间,并附带了时区信息
user_activity_data = {
            
    'timestamp_local_str': ["2023-08-01 10:00:00", "2023-08-01 15:30:00", "2023-08-01 09:00:00"],
    'timezone_str': ['America/New_York', 'Europe/Berlin', 'Asia/Tokyo'],
    'activity': ['login', 'purchase', 'view_page']
}
df_activity = pd.DataFrame(user_activity_data)
print("
原始用户活动数据 (带本地时间和时区字符串):")
print(df_activity) # 打印原始用户活动数据

# 将字符串时间戳转换为朴素 datetime 对象
df_activity['timestamp_naive'] = pd.to_datetime(df_activity['timestamp_local_str'])

# 逐行本地化到其声称的时区,然后转换为 UTC
# 这通常通过 DataFrame.apply 或迭代完成,因为 tz_localize 需要 Series 级别操作
# 或者,如果时区是固定的,可以先 groupby('timezone_str')
# 这里为了演示 Series.dt 的能力,我们假设先分别处理每个时区的数据
# 然后再合并,或者使用更高级的 apply 结构。

# 简化示例:假设我们有一个 Series 已经是特定时区的感知时间
timestamps_ny_str = ["2023-08-01 10:00:00", "2023-08-02 12:00:00"]
s_ny_aware = pd.to_datetime(pd.Series(timestamps_ny_str)).dt.tz_localize('America/New_York', nonexistent='NaT')
s_ny_aware.name = "NY_Time_Aware" # 命名
print(f"
NY Aware Timestamps:
{
              s_ny_aware}") # 打印纽约感知时间戳

timestamps_berlin_str = ["2023-08-01 15:30:00", "2023-08-02 18:00:00"]
s_berlin_aware = pd.to_datetime(pd.Series(timestamps_berlin_str)).dt.tz_localize('Europe/Berlin', nonexistent='NaT')
s_berlin_aware.name = "Berlin_Time_Aware" # 命名
print(f"
Berlin Aware Timestamps:
{
              s_berlin_aware}") # 打印柏林感知时间戳

# 将它们都转换为 UTC
s_ny_utc = s_ny_aware.dt.tz_convert('UTC').rename("NY_to_UTC") # 纽约时间转UTC
s_berlin_utc = s_berlin_aware.dt.tz_convert('UTC').rename("Berlin_to_UTC") # 柏林时间转UTC

print("
Converted to UTC for unified analysis:")
print(pd.concat([s_ny_aware, s_ny_utc, s_berlin_aware, s_berlin_utc], axis=1)) # 合并展示转换结果

# 在实际的 DataFrame 中,可以这样做:
def localize_and_convert_to_utc(row):
    """辅助函数:本地化时间戳并转换为UTC"""
    try:
        # 本地化朴素时间到行中指定的时区
        aware_dt = row['timestamp_naive'].tz_localize(row['timezone_str'])
        # 转换为 UTC
        return aware_dt.tz_convert('UTC')
    except Exception: # 处理无效时区字符串或本地化错误
        return pd.NaT

df_activity['timestamp_utc'] = df_activity.apply(localize_and_convert_to_utc, axis=1) # 应用函数逐行处理
print("
用户活动数据 (增加了统一的 UTC 时间戳):")
print(df_activity[['activity', 'timestamp_local_str', 'timezone_str', 'timestamp_utc']]) # 打印包含UTC时间戳的用户活动数据

正确处理时区是确保时间序列数据准确性和可比性的关键。始终建议将所有时间戳转换为一个标准时区(通常是 UTC)进行存储和内部计算。

© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容