在进行数据分析和建模的过程中,大量时间花费在数据准备上:包括加载、清洗、转换和重新排列。这些任务通常占据分析师80%或更多的时间。有时,数据存储在文件或数据库中的方式对特定任务而言并不是正确的格式。许多研究人员选择使用通用编程语言,如Python、Perl、R或Java或Unix文本处理工具(如sed或awk)对从一种形式到另一种形式的数据进行临时处理。幸运的是,pandas以及内置的Python语言特性,为我们提供了一组高级、灵活且快速的工具,它使我们可以将苏剧操作成正确的形式。
如果你发现某种类型的数据操作在本书或pandas库的其他地方都没有,请随时在Python邮件列表之一或pandas的GitHub网站上分享你的用例。事实上,pandas的大部分设计和实现都是由现实世界应用的需求推动的。
在本章中,将讨论用于缺失数据、重复数据、字符串操作和一些其他分析数据转换的工具。在下一章,将重点介绍如何以各种方式合并和重排数据集。

7.1处理缺失数据

许多数据分析应用程序中经常会出现数据缺失的情况。pandas的目标之一是尽可能轻松地处理缺失的数据。例如,默认情况下,pandas对象上的宿友描述性统计信息都直接排除缺失数据。
在pandas对象中表示缺失数据的方式有些不完美,但对大多数现实世界的使用来说已经足够了。对于float64类型的数据,pandas使用浮点值NaN(不是数字)来表示缺失值。
我们称之为"标记值":当存在时,它表示缺失值(或null值)

float_data = pd.Series([1.2, -3.5, np.nan, 0])
float_data.isna() # isna方法返回一个布尔Series,元素为缺失值则对应为True

在pandas中,我们采用了R编程语言中使用的约定,用NA指代缺失数据,表示不可用。在统计应用程序中,NA数据可以是不存在的数据,也可以存在但未被观察到的数据(例如,通过问题收集数据)。在清洗数据进行分析时,通常重要的是对缺失数据本身进行分析,以识别数据收集问题或由缺失数据引起的数据中的潜在偏差。

# Python内置的None值也被视为NA
string_data = pd.Series(['aardvard', np.nan, None, 'avocado'])
string_data.isna() # np.nan和None在isna方法中都返回True
float_data = pd.Series([1, 2, None], dtype='float64')
float_data.isna()

pandas项目试图使缺失值在不同数据类型之间保持一致。像pandas.isna函数抽象出很多烦人的细节。下表是有关缺失值处理的一些函数的列表。

[表]缺失值处理对象方法

过滤缺失值

有一些方法来过滤缺失值。虽然你始终可以选择手动使用pandas.isna方法 和布尔索引来完成它,但dropna方法可能会有所帮助。

# 在Series上,dropna方法会返回只有非null数据及其索引的Series
data = pd.Series([1, np.nan, 3.5, np.nan, 7])
data.dropna() # 返回Series,只有值1、3.5、7及它们的索引
# 等效的方式是:
data[data.notna()]

# 对DataFrame对象,有不同的方式移除缺失值。你可能希望删除全为NA的行或列,或者仅删除包含任何NA的行或列。
# 默认情况下,dropna方法可以删除包含缺失值的任何行
# 注意:默认情况下,dropna方法都会返回新对象,并且不会修改原对象的内容
data = pd.DataFrame([[1., 6.5, 3.], [1., np.nan, np.nan], [np.nan, np.nan, np.nan], [np.nan, 6.5, 3.]])
data.dropna() # 返回不包含缺失值的行组成的DataFrame
data.dropna(how='all') # 传递how='all'将仅删除所有值都为NA的行
# 传递axis='columns'能对列进行操作
data[4] = np.nan # 添加列名为'4'的列,其值为np.nan
data.drop(axis='columns', how='all') # 仅删除所有值都为NA的列

# 假如你想要保留最多包含一定数量缺失观测值的行。你可以使用thresh参数来指示它
df = pd.DataFrame(np.random.standard_normal((7, 3)))
df.iloc[:4, 1] = np.nan
df.iloc[:2, 2] = np.nan
df.dropna() # 返回不包含任何缺失值的行组成的DataFrame
df.dropna(thresh=2) # 返回少于2个缺失值(不包含2个)的行组成的DataFrame

填充缺失值

与其过滤掉缺失值(并可能丢掉其他数据),你也许希望用其他数据填充缺失值。

# fillna方法是填充缺失值的主力函数。调用带常量的fillna方法可以填充缺失值:
df.fillna(0) # 用0填充缺失值

# 调用带字典的fillna方法,则可以对不同的列使用不同的填充值
df.fillna({1: 0.5, 2: 0}) # 对列名为'1'的列用0.5填充,对列名为'2'的列用0来填充

# 可用于重排索引的插值方法(ffill, bfill)也可以用于fillna:
df = pd.DataFrame(np.random.standard_normal((6, 3)))
df.iloc[2:, 1] = np.nan
df.iloc[4:, 2] = np.nan
df.fillna(method='ffill') # 向前值填充
df.fillna(method='ffill', limit=2) # 向前值填充,并限制填充的最大连续周期数为2

# 也可以利用fillna使用中位数或均值统计量来进行简单的数据插补:
data = pd.Series([1., np.nan, 3.5, np.nan, 7])
data.fillna(data.mean()) # 用数据列的均值(计算时不计入缺失值)填充缺失值

下表是关于fillna函数参数的参考说明。

[表]fillna函数的参数

7.2数据转换

前面我们关注的是处理缺失值。过滤、清洗和其他转换是另一类重要操作。

删除重复项

由于许多原因,可能在DataFrame中发现很多重复行。

data = pd.DataFrame({'k1': ['one', 'two'] * 3 + ['two'], 
                     'k2': [1, 1, 2, 3, 3, 4, 4]})

# DataFrame的duplicated方法会返回一个布尔值组成的Series,指示是否每个行是重复项(它的值与前面的某一行完全相同)
data.duplicated()

# 相关地,drop_duplicates方法返回一个DataFrame,其中包含从中筛选出来的duplicated数组中值为False的行(即非重复行)
data.drop_duplicates()

# 默认情况下,上面的两种方法都考虑所有的列。但你也可以指定它们的任何子集来检查重复项。
# 假设有一个额外的列值,如'v1'列,并希望根据'k1'列来过滤重复项
data['v1'] = range(7)
data.drop_duplicates(subset=['k1']) # 根据'k1'列来过滤重复项

# duplicated和drop_duplicates方法默认从前往后过滤重复项,但可以传递传递keep='last'来从后往前过滤重复项
data.drop_duplicates(['k1', 'k2'], keep='last')

使用函数或映射转换数据

对于许多数据集,你可能希望基于DataFrame中的数组、Series或列中的值执行一些转换。

# 考虑以下收集的有关各种肉类的假设数据
data = pd.DataFrame({"food": ["bacon", "pulled pork", "bacon",
                              "pastrami", "corned beef", "bacon",
                              "pastrami", "honey ham", "nova lox"],
                     "ounces": [4, 3, 12, 6, 7.5, 8, 3, 5, 6]})

# 假设你希望添加一列,指示每种食物来自的动物类型。
# 先写下每种不同肉类类型和动物类型的映射:
meat_to_animal = {
  "bacon": "pig",
  "pulled pork": "pig",
  "pastrami": "cow",
  "corned beef": "cow",
  "honey ham": "pig",
  "nova lox": "salmon"
}
# Series上的map方法(在'5.2.5节函数应用和映射'中讨论过)接受一个类字典对象,该对象包含一个用于执行值转换的映射:
data['animal'] = data['food'].map(meat_to_animal) # 对'food'列的每个元素应用映射meat_to_animal,返回的Series赋值给'animal'列
# 也可以通过在map方法中传递映射对应的函数
def get_animal(x):
    return meat_to_animal[x]
data['animal'] = data['food'].map(get_animal)

替换值

用fillna方法填充缺失值是更通用的值替换的特殊情况。如你所见,map方法可以被用于修改对象中的子集的值,但是replace方法提供了一种更简单、更灵活的方法来执行此操作。

data = pd.Series([1., -999., 2., -999., -1000., 3.])
# -999也许是缺失值的标记值,为了用pandas能理解的NA值代替它们,可以用replace函数产生一个新的Series
data.replace(-999, np.nan) # 将data中的所有-999替换为np.nan
# 如果你想要一次性替换多个值,可以先传递一个列表,然后传递替换值:
data.replace([-999, -1000], np.nan) # 将data中的所有-999和-1000替换为np.nan
# 要对每个值使用不同的替换值,则在第二个位置参数处需要传递替换值列表
data.replace([-999, -1000], [np.nan, 0])
# 传递的参数也可以是字典
data.repalce({-999: np.nan, -1000: 0})

注意:data.replace方法和data.str.replace方法的不同之处在于,后者执行逐元素字符串替换。我们将在本章后续部分介绍这些在Series上的字符串方法。

重命名轴索引

与Series中的值一样,轴标签也可以通过某种形式的函数或映射进行类似的转换,以生成新的、标签不同的对象。你还可以就地修改轴,而无需创建新的数据结构。

data = pd.DataFrame(np.arange(12).reshape((3, 4)), index=['Ohio', 'Colorado', 'New York'], columns=['one', 'two', 'three', 'four'])
# 与Series类似,轴标签也有map方法
def transform(x):
    return x[:4].upper() # 取前4个字符并大写它们
    
data.index.map(transform) # 返回Index(['OHIO', 'COLO', 'NEW '], dtype='object')
data.index = data.index.map(transform) # 将映射后的结果赋值给index属性,则就地修改了DataFrame

# 如果你想在不修改原始数据集的情况下创建数据集的转换版本,则有一种有用的方法是rename方法:
data.rename(index=str.title, columns=str.upper) #str.title方法是将字符串首字母大写(其他字母小写)的方法,str.upper方法是将字符串全部大写的方法
# rename方法也可以与类字典对象结合使用,为轴标签的子集提供新值
data.rename(index={'OHIO': 'INDIANA'}, column{'three': 'peekaboo'})
# rename使你免于手动复制DataFrame并将新值赋值给index和columns属性

离散化和分档

连续型数据常常被离散化或以其它方式分离到’箱’中以进行分析。

# 假设你在一项研究中有关于一组人的数据,并且你想将它们分组到离散的年龄桶中
ages = [20, 22, 25, 27, 21, 23, 37, 31, 61, 45, 41, 32]
# 利用pandas.cut可以将它们分到18-25, 26-35, 36-60以及61+的箱子中。
bins = [18, 25, 35, 60, 100]
age_categories = pd.cut(ages, bins) # 返回由ages的每个元素对应所在的前开后闭的区间(bins构成的4个区间之一)组成的分类对象
age_categories.codes # 有ages的每个元素对应的区间编号(从0开始计)组成的数组
age_categories.categories # 由bins构成的4个区间组成的对象
age_categories.categories[0] # 第0个区间
pd.value_counts(age_categories) # 返回区间和对应区间内的值计数组成的Series
# 默认区间是左开右闭的,但可以通过设置right=False来使区间成为左闭右开的
pd.cut(ages, bins, right=False) # 返回由左闭右开区间组成的分类对象
# 可以通过传递一个数组列表到labels参数来重命名基于bin的默认区间
group_names = ['Youth', 'YoungAdult', 'MiddleAged', 'Senior']
pd.cut(ages, bins, labels=group_name)
# 如果传递bins的整数到pandas.cut方法而不是确切的bins边界,则会根据数据中的最大值和最小值计算等长的bins
data = np.random.uniform(size=20)
pd.cut(data, 4, precision=2) # 传递整数到bins,会讲最小值到最大值之间的区间四分等,这里precision=2限制区间端点为二位小数

# 使用pandas.cut方法并不总能使每个bin有相同数量的数据点。但pandas.qcut根据样本分位数对数据分档,它能保证每个bin包含相同的数据点。
data = np.random.standard_normal(1000)
quartiles = pd.qcut(data, 4, precision=2) # 将数据分为4个区间,每个区间有相同的数量点
pd.value_counts(quartiles) # 可以验证每个区间都是250个数据

# 与pandas.cut类似,你可以传递自己设定的分位数(0到1之间的书,包括0和1):
pd.qcut(data, [0, 0.1, 0.5, 0.9, 1.]).value_counts() # 根据给定的分位数将数据划分为4个区间

检测和过滤极端值

过滤或转化极端值是应用数组操作中的一个大问题。

# 考虑一个由正态分布数据组成的DataFrame
data = pd.DataFrame(np.random.standard_normal((1000, 4))) # 创建元素为正太分布值的1000行4列的DataFrame
data.describe() # 对每列计算描述性统计量

# 假设你希望在某一列(如'2'列)中找到绝对值超过3的值
col = data[2]
col[col.abs() > 3]

# 在布尔类型的DataFrame上使用any方法,获得有值的绝对值大于3的行
data[(data.abs() > 3).any(axis='columns')] # 跨列查看(即逐行查看),如果行内有一个True,则该行的布尔值为True,最后所有布尔值为True的行

# 极端值替换
data[data.abs() > 3] = np.sign(data) * 3 # 将大于3的转化为3,小于-3的转化为-3
data.describe()
# np.sign(data)会根据根据数据是正还是负来返回1或者-1
np.sign(data).head()

排列和随机采样

使用numpy.random.permutation函数使排列(随机重新排序)一个Series或一个DataFrame中的行成为可能。

# 调用带有permutation函数,并传递你想要排列的轴的数量,则会返回象征新排序的整数数组
df = pd.DataFrame(np.arange(5 * 7).reshape((5, 7))) # 创建5行7列的DataFrame
sampler = np.random.permutation(5) # 返回新排列组成的整数数组
# 调用基于iloc的索引或take函数获得新排列下的DataFrame
df.take(sampler)
df.iloc[sampler]
# 通过在take函数中传递axis='columns',我们可以获得列的排列:
column_sampler = np.random.permutation(7)
df.take(column_sampler, axis='columns') # 跨列排列

# 在Series和DataFrame上调用sample方法可以获得一个不重复(相同行不会被重复选择)的随机子集
df.sample(n=3) # 采样3行
# 要产生一个重复的采样,可以在sample方法中传递replace=True
choices = pd.Series([5, 7, -1, 6, 4])
choices.sample(n=10, replace=True)

计算指示/哑变量

另一种用于统计模型或机器学习应用的转换是将一个类别变量转换为虚拟或者指示矩阵。

# 如果DataFrame的一列有k个不同的值,你想推导只由0和1组成的k列矩阵或DataFrame。则pandas.get_dummies函数可以做到这一点
df = pd.DataFrame({'key': ['b', 'b', 'a', 'c', 'a', 'b'], 'data1': range(6)})
pd.get_dummies(df['key']) # 根据df的'key'列的值,生成一个指示矩阵(dataFrame),列名为df['key']的各值,每行只有df中该行的值所在的列的值为1

# pandas.get_dummies函数由一个prefix参数可以为指示DataFrame的列添加前缀
dummies = pd.get_dummies(df['key'], prefix='key') # 在指示DataFrame的列名前加上前缀'key_'
df_with_dummy = df[['data1']].join(dummies) # df[['data1']]是只含'data1'列的DataFrame,利用join方法拼接dummies

# 如果DataFrame的一行属于多个类,则不得不用不同的方法创建哑变量。
# 下面看MovieLens 1M数据集,在第13章:数据分析案例中将调查更多细节
mnames = ['movie_id', 'title', 'genres']
movies = pd.read_table('datasets/movielens/movies.dat', sep='::', header=None, names=mnames, engine='python')
movies[:10] # 查看DataFrame前10行
# pandas有一个特殊的Series方法str.get_dummies(将在后面的字符串操作中介绍的以str.开始的方法)用于处理这种用分隔符编码的多类成员
dummies = movies['genres'].str.get_dummies('|')
dummies.iloc[:10, :6]
# 可以用add_prefix方法在dummies这个DataFrame中为列名加入前缀'Genre_',然后通过join方法拼接到movies上
movies_windic = movies.join(dummies.add_prefix('Genre_'))
movies_windic.iloc[0] # 查看DataFrame的第0行

注意:对更大的数据集,这种构建多类成员的指示变量的方法不是很快速。更好的办法是写一个低水平函数来直接在NumPy数组上写入,然后将结果转换为DataFrame。

统计应用的一个有用方法是将pandas.get_dummies和离散化函数如pandas.cut组合在一起:

np.random.seed(12345)
values = np.random.uniform(size=10)
bins = [0, 0.2, 0.4, 0.6, 0.8, 1]
pd.get_dummies(pd.cut(values, bins))

我们将在后面的’创造哑变量用于建模’小节中再次看到pandas.get_dummies函数。

7.3扩展数据类型

这是一个新的且更加高级的主题,很多pandas使用者不需要知道很多,但为了完整性将它放在这里,因为在即将到来的章节中的很多地方都需要用到扩展数据类型。

pandas最初是基于NumPy的功能构建的,NumPy是一个数组计算库,主要用于处理数值数据。许多pandas概念,如缺失值,是使用NumPy中可用的实现的,同时试图最大化同时使用NumPy和pandas的库时的兼容性。
建立在NumPy上导致了一些缺点,如:

  • 对一些数值数据类型,如整数和布尔值,的缺失值处理是不完整的。因此,当缺失值被引入到这样的数据中时,pandas将数据类型转换为float64并使用np.nan表示空值。这在许多pandas算法中引入了微妙的问题,产生了复合效应。
  • 有很多字符串的数据集计算代价很大,且占用大量内存。
  • 一些数据类型,如时间间隔、时间增量和带时区的时间戳,如果不使用计算量大的Python对象数组,就无法有效地支持。

最近,pandas已经开发了一种新的扩展类型系统,它允许添加新的数据类型,即使NumPy本身不支持这些数据类型。这些新数据类型可以与来自NumPy数组的数据一起作为一类处理。

s = pd.Series([1, 2, 3, None])
s.dtype # 为了向后兼容,对于缺失值,Series自动转化为float64,并以np.Nan为缺失值
# pandas.Int64Dtype可以被代替用于创建这个Series
s = pd.Series([1, 2, 3, None], dtype=pd.Int64Dtype())
s[3] # 缺失值被标记为<NA>(pandas.NA),它表示这个值对于一个扩展类型的数组而言是缺失值
s.isna() # 缺失值对应的布尔值为True
s[3] is pd.NA # 返回True
s.dtype # 返回Int64Dtype()
# 也可以用简写'Int64'替代'pd.Int64Dtype()'来指定类型。大写是必要的,否则它将是一个基于NumPy的非扩展类型
s = pd.Series([1, 2, 3, None], dtype='Int64')

# pandas还有一个扩展类型专门用于不使用NumPy对象数组的字符串数据(它需要pyarrow库,你可能需要单独安装):
s = pd.Series(['one', 'two', None, 'three'], dtype=pd.StringDtype())
# 这些字符串数组用更少的内存生成并且在大型数据中进行操作时,可以更高效地进行频繁计算

# 另一种重要的扩展类型是Categorical,我们将在'Categorical Data'小节中讨论更多细节。

# 扩展类型可以传递给Series的astype方法,允许你在数据清洗过程中简单地转换数据
df = pd.DataFrame({'A': [1, 2, None, 4], 'B': ['one', 'two', 'three', None], 'C': [False, None, False, True]})
df['A'] = df['A'].astype('Int64')
df['B'] = df['B'].astype('string')
df['C'] = df['C'].astype('boolean')

下表是有关可用扩展类型的表格。

[表]pandas扩展数据类型

7.4字符串操作

Python长期以来一直是一种流行的原始数据操作语言,部分原因是它易于用于字符串和文本处理。通过string对象的内置方法,大多数文本操作都变得很简单。对于更复杂的模式匹配和文本操作,可能需要使用正则表达式。Pandas让你能够在整个数据数组上简洁地应用字符串和正则表达式,另外还可以处理数据丢失的烦恼。

Python内置的字符串对象方法

在许多字符串转换和脚本应用程序中,内置的字符串方法就足够了。

# 逗号分隔的字符串可以用split方法拆分为多个片段
val = 'a,b,  guido'
val.split(',') # 返回['a', 'b', ' guido']

# split方法经常和strip方法结合来去掉空白符(包括换行符)
pieces = [x.strip() for x in val.split(',')]
pieces # 返回['a', 'b', 'guido']

# 子字符串可以通过加法和双冒号连接起来
first, second, third = pieces
first + '::' + second + '::' + third # 返回'a::b::guido'
# 但这不是一个实用的通用方法。一种更快、更Pythonic的方法是在字符串"::"上向join方法传递一个列表或元组:
'::'.join(pieces) # 返回'a::b::guido'

# 使用Python的in关键字是检测子字符串的最好方法,尽管index和find方法也可使用
'guido' in val # 返回True
val.index(',') # 返回下标1
val.find(':') # 没找到,所以返回-1
# 注意:find和index的区别是,如果没找到,index会抛出错误,而find会返回-1

# count方法返回特定子字符串出现的次数
val.count(',') # 返回2

# replace方法会将一种模式转化为另一种模式。也经常用于通过传递空字符串来删除模式
val.replace(',', '::') # 将所有','替换为'::'
val.replace(',', '') # 删除所有的','

下表是一些Python的字符串方法,正则表达式也可以在其中的很多种操作中使用。

[表]Python内置字符串方法

正则表达式

正则表达式提供了一种灵活的方法来搜索或匹配文本中的字符串模式(通常更复杂)。单个表达式(通常称为正则表达式)是根据正则表达式语言形成的字符串。Python内置的re模块负责将正则表达式应用于字符串。
re模块函数分为三类:模式匹配、替换和拆分。当然,这些都是互相关联的。正则表达式描述了要在文本中找到的模式,然后可以将其用于许多目的。

# 假设我们要拆分一个包含可变数量的空格符(制表符、空格和换行符)的字符串
# 在正则表达式中描述一个或多个空格符的是'\s+'
import re
text = "foo    bar\t baz  \tqux"
re.split(r"\s+", text) # 将可变数量的空格符作为分隔符,拆分text字符串,返回列表:['foo', 'bar', 'baz', 'qux']
# 调用re.split(r"\s+", text)时,首先编译正则表达式,然后对传递的文本调用split方法

# 也可以先用re.complie自行编译正则表达式,形成一个可复用的正则表达式对象,然后再调用split方法
regex = re.compile(r'\s+') # 编译正则表达式
regex.split(text) # 按照编译的正则表达式使用split方法

# 可以用findall方法获得于正则表达式匹配的所有模式的列表
regex.findall(text) # 返回所有匹配的模式的列表,这里返回列表:['    ', '\t ', '  \t']

注意:为了避免在正则表达式中使用’‘进行不必要的转义,请使用原始字符串文本r’C:\x’而不是等效的’C:\x’。

如果你打算将相同的表达式应用于许多字符串,则强烈建议用re.complie创建一个正则表达式对象,这样会节省CPU周期。

# match和search方法与findall方法密切相关。findall方法会返回字符串中的所有匹配项,而search方法只会返回第一个匹配项。更严格地,match方法只会在字符串的开口匹配。
# 考虑一个文本块和一个能够识别大多数电子邮件的正则表达式
text = '''Dave dave@google.com
Steve steve@gmail.com
Rob rob@gmail.com
Ryan ryan@yahoo.com'''
pattern = r'[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}' # @前面可以是可变长度的[A-Z0-9._%+-]字符
regex = re.compile(pattern, flags=re.IGNORECASE) # re.IGNORECASE使正则表达式不敏感(不区分大小写)

# 在文本上使用findall会产生电子邮件地址列表
regex.findall(text)

# search方法会为文本中的第一个电子邮件返回一个特殊的匹配对象,匹配对象只能通过.start()和.end()方法告诉我们字符串模式的开始和结束位置
m = regex.search(text)
text[m.start():m.end()] # 返回'dave@google.com'

# match方法只会在字符串的开口匹配
print(regex.match(text)) # regex.match方法返回None,因为在text开头无法匹配模式

# sub方法会返回一个新的字符串,其中出现的模式会被替换为新字符串
print(regex.sub('REDACTED', text)) # 所有匹配的模式都被替换为'REDACTED'
# 假设你要查找电子邮件地址,并同时将每个地址分为其三个组成部分:用户名、域名和域后缀。为此,请在要分隔的模式部分周围加上括号:
pattern = r'([A-Z0-9._%+-]+)@([A-Z0-9.-]+)\.([A-Z]{2,4})'
regex = re.compile(pattern, flags=re.IGNORECASE)

# 通过修改后的正则表达式生成的匹配对象可以通过groups方法返回模式组件的元组
m = regex.match('wesm@bright.net')
m.groups() # 返回元组:('wesm', 'bright', 'net')

# 当模式具有组的时候,findall方法会返回模式组件的元组组成的列表:
regex.findall(text)

# sub还可以使用特殊符号(如\1和\2)来访问每个匹配项中的组件。符号\1对应第一个匹配组件,\2对应第二个匹配组件,以此类推:
print(regex.sub(r'Username: \1, Domain: \2, Suffix \3', text)) # 所有匹配的模式都被替换为'Username: \1, Domain: \2, Suffix \3',其中\x是匹配模式中的组件

Python的正则表达式还有很多内容,其中大部分不在本书范围内。下表提供了一个简短的摘要。

[表]正则表达式方法

pandas中的字符串函数

清理混乱的数据集进行分析通常需要大量的字符串操作。更复杂的是,包含字符串的列有时会缺少数据:

data = {"Dave": "dave@google.com", "Steve": "steve@gmail.com",
        "Rob": "rob@gmail.com", "Wes": np.nan}
data = pd.Series(data)
data.isna()

字符串和正则表达式方法可以通过data.map函数应用(传递一个lambda或者别的函数)于每个值,但是这在NA(null)值上会失效。为了解决这个问题,Series提供了面向数组的方法,用于执行跳过和传播NA值的字符串操作。这些方法通过Series的str属性访问

# 我们可以用str.contains方法检查是否每个邮件地址包含'gmail'
data.str.contains('gmail') # 返回一个布尔数组,包含'gmail'则对应True,不包含则对应False,缺失值对应NaN

# 注意,该操作返回的结果有object类型。pandas具有扩展类型,这些类型提供了对字符串、整数和布尔数据的专门处理
data_as_string_ext = data.astype('string')
data_as_string_ext.str.contains('gmail')

# 带任何re参数(如IGNORECASE)的正则表达式也可以被使用:
pattern = r'([A-Z0-9._%+-]+)@([A-Z0-9.-]+)\.([A-Z]{2,4})'
data.str.findall(pattern, flags=re.IGNORECASE)

# 有几种方法可以进行矢量化元素检索。使用str.get方法或者在str属性上使用索引
matches = data.str.findall(pattern, flags=re.IGNORECASE).str[0] # 矢量化获得第0个元素
matches.str.get(1) # 矢量化获得第1个元素
data.str[:5] # 矢量化对每个字符串进行切片(获得前5个字符)

# str.extract方法会将捕获的正则表达式组作为一个DataFrame返回
data.str.extract(pattern, flags=re.IGNORECASE) # 返回DataFrame

下表展示了pandas字符串方法。

[表]Series字符串方法的部分列表

7.5分类数据

这一节介绍pandas的Categorical类型。

背景和动机

经常地,表中的一列也许包含重复的含不同值的更小的集合。我们已经见过unique和value_counts函数,它们分别帮助我们从数组中提取不同的值以及计算它们的频率

values = pd.Series(['apple', 'orange', 'apple', 'apple'] * 2)
pd.unique(values)
pd.value_counts(values)

# 许多数据系统(用于数据仓库、统计计算或其他用途)已经开发出专门的方法来表示具有重复值的数据,以实现更有效的存储和计算。
# 在数据仓库中,最佳做法是使用所谓的维度表,其中包含不同的值,并将主要观测值存储为引用维度表的整数键
values = pd.Series([0, 1, 0, 0]*2)
dim = pd.Series(['apple', 'orange'])
# 可以用take方法来恢复原始的字符串Series:
dim.take(values)

这种整数表示形式称为分类或字典编码表示形式。非重复值的数组可以称为数据的类型、字典或级别。在本书中中,我们将使用术语’categorical’和’categories’。引用类别的整数值称为类别编码或简称为编码。
在执行分析时,分类表示可以显著提高性能。你还可以对类别执行转换,同时保持代码不变。
可以以相对较低的成本进行一些转换的例子有:

  • 重命名类别
  • 在不更改现有类别的顺序或位置的情况下追加新类别

pandas中的分类扩展类型

pandas有一种特殊的类别扩展类型,它用于保存使用基于整数的分类表示或编码的数据。这是一种流行的数据压缩技术,适用于多次出现相似值的数据,并且可以以较低的内存使用率提供明显更快的性能,特别是对字符串数据。

fruits = ['apple', 'orange', 'apple', 'apple'] * 2
N = len(fruits)
rng = np.random.default_rng(seed=12345) # 创建生成器,后面用于生成各种分布
df = pd.DataFrame({'fruit': fruits,
                   'basket_id': np.arange(N),
                   'count': rng.integers(3, 15, size=N),
                   'weight': rng.uniform(0, 4, size=N)},
                   columns=['basket_id, 'fruit', 'count', 'weight'])

# 这里,df['fruit']是一个Python字符串对象的数组,我们可以调用astype()方法将它转化为分类数据类型
fruit_cat = df['fruit'].astype('category') # 返回元素类型是category的Series

# fruit_cat的值是pandas.Categorical的一个实例,你可以通过.array属性访问它
c = fruit_cat.array
type(c) # 返回pandas.core.arrays.categorical.Categorical

# Categorical对象有categories和codes属性
c.categories # 返回类别:Index(['apple', 'orange'], dtype='object')
c.codes # 返回编码:array([0, 1, 0, 0, 0, 1, 0, 0], dtype=int8)

这些内容通过使用cat访问器可以更方便地访问,这将很快在’Categorical方法’中被解释。

# 获得编码和类别之间的映射的一个有用的技巧是
dict(enumerate(c.categories)) # 会返回一个字典,它的键是编码,值是对应的类别

# 你可以通过赋值转化后的结果将DataFrame列转化为categorical列
df['fruit'] = df['fruit'].astype('category')

# 你也可以直接从其他类型的Python序列中创建pandas.Categorical
my_categories = pd.Categorical(['foo', 'bar', 'baz', 'foo', 'bar']) 

# 如果已经从其他来源获得分类编码数据,则可以使用from_codes构造函数创建pandas.Categorical(categorical数组)
categories = ['foo', 'bar', 'baz']
codes = [0, 1, 2, 0, 0, 1]
my_cats_2 = pd.Categorical.from_codes(codes, categories) # 第一个参数是编码,第二个参数是类别

# 一般分类转化默认类别之间没有顺序。在使用from_codes构造函数或其他构造函数时,可以通过ordered=True来指定类别有顺序意义
ordered_cat = pd.Categorical.from_codes(codes, categories, ordered=True) # 返回有顺序的pandas.Categorical(categorical数组)

# 无序的分类实例可以使用as_ordered方法创建排序
my_cats_2.as_ordered() # 返回有顺序的pandas.Categorical(categorical数组)

categorical数组可以是任何不可变的值类型组成,不是必须是字符串。

计算Categoricals

在pandas中使用Categorical与非编码版本(如字符串数组)相比,通常表现相同。pandas的某些部分,如groupby函数中,使用categoricals表现更好。还有一些可以使用ordered标签。
让我们考虑一些随机数值数据并使用pandas.qcut分箱函数。这将返回pandas.Categorical;我们在前面使用了pandas.cut,但是掩盖了类别如何工作的细节:

rng = np.random.default_rng(seed=12345)
draws = rng.standard_normal(1000)
draws[:5]

# 计算这个数据的四分位数分箱,并提取一些统计信息
bins = pd.qcut(draws, 4) # 返回pandas.Categorical

# 尽管确切的示例四分位数有用,但与四分位数名称相比作用可能较小。因此可以通过在qcut中传递labels参数来获得四分位数名称
bins = pd.qcut(draws, 4, labels=['Q1', 'Q2', 'Q3', 'Q4']) # 返回元素值为'Q1', 'Q2', 'Q3', 'Q4'之一的pandas.Categorical
bins.codes[:10] # 返回前10个元素的编码组成的数组

# 标签化(传递labels参数后)的bins不包含数据中箱边缘的信息,因此我们可以使用groupby来提取一些汇总统计信息:
bins = pd.Series(bins, name='quartile') # 将categorical数组转化为categorical Series
results = (pd.Series(draws) # 原始数组组成的Series
           .groupby(bins)   # 生成由元组(key, sub-iterator)构成的迭代器序列
           .agg(['count', 'min', 'max']) # 对每个分组应用count函数,min函数,max函数,注意必须要有''
           .reset_index())  # 重置DataFrame的索引,将索引'quartile'移到列上,并使用默认索引
           # 外面套括号表示这4行语句其实是在一行的
# 结果中的'quartile'列保留来自bins的categorical的信息,包括排序:
results['quartile'] # 返回pandas.Categorical
使用categoricals具有更好的性能

在本节开头,我们说过分类类型可以提高性能和内存使用率,因此让我们看一些示例。

# 考虑一些包含1000万个元素和少量不同类别的Series
N = 10_000_000
labels = pd.Series(['foo', 'bar', 'baz', 'qux'] * (N // 4))

# 将labels转化为categorical Series
categories = labels.astype('category')
# 我们注意到labels使用的内存明显多于categories
labels.memory_usage(deep=True) # 设置deep=True,则通过询问对象dtype来深入了解数据的系统级内存消耗,这里返回:600000128
categories.memory_usage(deep=True) # 返回:10000540
# 当然,转换为类别不是免费的,但这是一次性成本
%time _ = labels.astype('category') # 返回:CPU times: user 603 ms, sys: 22.8 ms, total: 626 ms Wall time: 629 ms

# 使用categoricals可以显著加快GroupBy操作的速度,因为基础算法使用基于整数的代码数组而不是字符串数组。在这里,我们比较了value_counts()的性能,它内部使用了GroupBy的性能
%timeit labels.value_counts()     # 返回:302 ms ± 8.12 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit categories.value_counts() # 返回:39.4 ms ± 1.06 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

Categorical方法

包含分类数据的Series有几个类似于Series.str专用字符串方法的特殊方法。这也提供了对类别和编码的便利访问。

s = pd.Series(['a', 'b', 'c', 'd'] * 2)
cat_s = s.astype('category') # 创建元素为category的Series

# 特殊访问属性cat提供了对pandas.Categorical的访问
cat_s.cat.codes
cat_s.cat.categories

# 假设我们知道该数据的实际类别集超出了在数据中观察到的四个值。我们可以使用set_categories方法来更改它们
actual_categories = ['a', 'b', 'c', 'd', 'e']
cat_s2 = cat_s.cat.set_categories(actual_categories)
# 虽然数据似乎没有变化,但新的类别将反映在使用它们的操作中。例如,如果类别存在,会在value_counts中反映:
cat_s.value_counts()
cat_s2.value_counts()

# 在大型数据集中,分类常常被用作节省内存和提高性能的方便工具。
# 在筛选大型DataFrame或Series后,许多类别可能不会出现在数据中。
# 为了帮助实现这一点,我们可以使用remove_unused_categories方法来修剪未观察到的类别
cat_s3 = cat_s[cat_s.isin(['a', 'b'])] # 只保留值为'a'和'b'的记录
cat_s3.cat.remove_unused_categories() # 将出现在数据中的类别删除

下表是可用categorical方法的列表。

[表]Pandas中用于Series的Categorical方法

为建模创建虚拟变量

在使用统计或机器学习工具时,通常会将分类数据转换为虚拟变量,也称为one-hot编码。这涉及到创建一个DataFrame,它为每个不同的类别设置一列;对应类别出现则显示为1,否则显示为0。

cat_s = pd.Series(['a', 'b', 'c', 'd'] * 2, dtype='category')

# pandas.get_dummies函数将这个一维分类数据转换为包含虚拟变量的DataFrame:
pd.get_dummies(cat_s)