在数据分析和建模的过程中,要花很多时间在数据准备上:加载、清理、转换以及重塑。这些⼯作会占到分析师时间的80%或更多。有时,存储在⽂件和数据库中的数据的格式不适合某个特定的任务。pandas和内置的Python标准库提供了⼀组⾼级的、灵活的、快速的⼯具,可以让你轻松地将数据规变为想要的格式。接下来会讨论处理缺失数据、重复数据、字符串操作和其它分析数据转换的⼯具。
一、处理缺失数据
pandas的⽬标之⼀就是尽量轻松地处理缺失数据。例如,pandas对象的所有描述性统计默认都不包括缺失数据。
缺失数据在pandas中呈现的⽅式有些不完美,但对于⼤多数⽤户可以保证功能正常。对于数值数据,pandas使⽤浮点值NaN(Not a Number)表示缺失数据。我们称其为哨兵值,可以⽅便的检测出来:
string_data = pd.Series(['aardvark', 'artichoke', np.nan, 'avocado'])
string_data # 输出如下:
0 aardvark
1 artichoke
2 NaN
3 avocado
dtype: object
string_data.isnull() # 输出如下:
0 False
1 False
2 True
3 False
dtype: bool
在pandas中,将缺失值表示为NA,它表示不可⽤not available。在统计应⽤中,NA数据可能是不存在的数据或者虽然存在,但是没有观察到(例如,数据采集中发⽣了问题)。当进⾏数据清洗以进⾏分析时,最好直接对缺失数据进⾏分析,以判断数据采集的问题或缺失数据可能导致的偏差。Python内置的None值在对象数组中也可以作为NA:
string_data[0] = None
string_data.isnull() # 输出如下:
0 True
1 False
2 True
3 False
dtype: bool
pandas项⽬中还在不断优化内部细节以更好处理缺失数据,像⽤户API功能,例如pandas.isnull,去除了许多恼⼈的细节。表7-1列出了⼀些关于缺失数据处理的函数。
表7-1 NA处理⽅法
1、滤除缺失数据
过滤掉缺失数据的办法有很多种。可以通过pandas.isnull或布尔索引的⼿⼯⽅法,但dropna可能会更实⽤⼀些。对于⼀个Series,dropna返回⼀个仅含⾮空数据和索引值的Series:
from numpy import nan as NA
data = pd.Series([1, NA, 3.5, NA, 7])
data.dropna() # 输出如下:
0 1.0
2 3.5
4 7.0
dtype: float64
这等价于:
data[data.notnull()] # 输出如下:
0 1.0
2 3.5
4 7.0
dtype: float64
⽽对于DataFrame对象,事情就有点复杂了。你可能希望丢弃全NA或含有NA的⾏或列。
dropna默认丢弃任何含有缺失值的⾏:
data = pd.DataFrame([[1., 6.5, 3.], [1., NA, NA], [NA, NA, NA], [NA, 6.5, 3.]])
cleaned = data.dropna() # 删除有缺失值的行
data # data原始数据输出如下:
0 1 2
0 1.0 6.5 3.0
1 1.0 NaN NaN
2 NaN NaN NaN
3 NaN 6.5 3.0
cleaned # 删除全部有缺失值的行后输出如下:
0 1 2
0 1.0 6.5 3.0
传⼊how='all'将只丢弃全为NA的那些⾏:
data.dropna(how='all') # 删除全为NA的行
0 1 2
0 1.0 6.5 3.0
1 1.0 NaN NaN
3 NaN 6.5 3.0
⽤这种⽅式丢弃全为NA的列,只需传⼊axis=1即可:
data[4] = NA # 在data数组中新增第4列全为NA
data # 输出如下:
0 1 2 4
0 1.0 6.5 3.0 NaN
1 1.0 NaN NaN NaN
2 NaN NaN NaN NaN
3 NaN 6.5 3.0 NaN
data.dropna(axis=1, how='all') # 注意参数的组合使用,输出如下:
0 1 2
0 1.0 6.5 3.0
1 1.0 NaN NaN
2 NaN NaN NaN
3 NaN 6.5 3.0
另⼀个滤除DataFrame⾏的问题涉及时间序列数据。假设你只想留下⼀部分观测数据,可以⽤thresh参数实现此⽬的:
df = pd.DataFrame(np.random.randn(7, 3))
df.iloc[:4, 1] = NA # 前面4行的第2列设置为NA
df.iloc[:2, 2] = NA # 前面2行的第3列设置为NA
df # 输出如下:
0 1 2
0 -0.662826 NaN NaN
1 -0.020137 NaN NaN
2 -0.442894 NaN 0.732485
3 0.820674 NaN 0.292754
4 1.498702 0.643710 0.320940
5 -0.742214 -0.941152 0.895269
6 -1.220743 0.567435 0.490752
df.dropna() # 删除全部有NA值的行,输出如下:
0 1 2
4 1.498702 0.643710 0.320940
5 -0.742214 -0.941152 0.895269
6 -1.220743 0.567435 0.490752
df.dropna(thresh=2) # 删除2列都有NA值的行,输出如下:
0 1 2
2 -0.442894 NaN 0.732485
3 0.820674 NaN 0.292754
4 1.498702 0.643710 0.320940
5 -0.742214 -0.941152 0.895269
6 -1.220743 0.567435 0.490752
2、填充缺失数据
不删除缺失数据,因为可能丢弃跟它有关的其他数据。而是采用填充那缺失数据。fillna⽅法是最主要的函数。通过⼀个常数调⽤fillna就会将缺失值替换为那个常数值:
df.fillna(0) # 输出如下:缺失值填充0
0 1 2
0 -0.662826 0.000000 0.000000
1 -0.020137 0.000000 0.000000
2 -0.442894 0.000000 0.732485
3 0.820674 0.000000 0.292754
4 1.498702 0.643710 0.320940
5 -0.742214 -0.941152 0.895269
6 -1.220743 0.567435 0.490752
若是通过⼀个字典调⽤fillna,就可以实现对不同的列填充不同的值:
df.fillna({1: 0.5, 2: 0}) # 第2列的NA值填充为0.5,第3列的NA值填充为0。输出如下:
0 1 2
0 -0.662826 0.500000 0.000000
1 -0.020137 0.500000 0.000000
2 -0.442894 0.500000 0.732485
3 0.820674 0.500000 0.292754
4 1.498702 0.643710 0.320940
5 -0.742214 -0.941152 0.895269
6 -1.220743 0.567435 0.490752
fillna默认会返回新对象,但也可以对现有对象进⾏就地修改(参数inplace=True):
_ = df.fillna(0, inplace=True) # 就地修改,参数inplace=True
df # 输出如下:
0 1 2
0 -0.662826 0.500000 0.000000
1 -0.020137 0.500000 0.000000
2 -0.442894 0.500000 0.732485
3 0.820674 0.500000 0.292754
4 1.498702 0.643710 0.320940
5 -0.742214 -0.941152 0.895269
6 -1.220743 0.567435 0.490752
对reindexing有效的那些插值⽅法也可⽤于fillna:
df = pd.DataFrame(np.random.randn(6, 3))
df.iloc[2:, 1] = NA
df.iloc[4:, 2] = NA
df # 输出如下:
0 1 2
0 0.565130 0.430439 0.431819
1 -1.712032 -0.492103 -0.997918
2 0.241762 NaN 0.639279
3 -1.636311 NaN -0.106550
4 -0.892301 NaN NaN
5 0.116147 NaN NaN
df.fillna(method='ffill') # 输出如下:根据列中NA值前的一个数字填充后面的NA值
0 1 2
0 0.565130 0.430439 0.431819
1 -1.712032 -0.492103 -0.997918
2 0.241762 -0.492103 0.639279
3 -1.636311 -0.492103 -0.106550
4 -0.892301 -0.492103 -0.106550
5 0.116147 -0.492103 -0.106550
df.fillna(method='ffill', limit=2) # 限制填充2个NA值
0 1 2
0 0.565130 0.430439 0.431819
1 -1.712032 -0.492103 -0.997918
2 0.241762 -0.492103 0.639279
3 -1.636311 -0.492103 -0.106550
4 -0.892301 NaN -0.106550
5 0.116147 NaN -0.106550
只要做一些创新,你就可以利⽤fillna实现许多别的功能。⽐如,可以传⼊Series的平均值或中位数:
data.fillna(data.mean()) # 用平均值填充NA值,输出如下:
0 1.000000
1 3.833333
2 3.500000
3 3.833333
4 7.000000
dtype: float64
表7-2列出了fillna的参数
二、数据转换
数据的另一类重要操作是过滤、清理以及其他的转换⼯作。
1、移除重复数据
DataFrame中出现重复⾏有多种原因。下⾯就是⼀个例⼦:
data = pd.DataFrame({'k1': ['one', 'two'] * 3 + ['two'], 'k2': [1, 1, 2, 3, 3, 4, 4]})
data # 输出如下:
k1 k2
0 one 1
1 two 1
2 one 2
3 two 3
4 one 3
5 two 4
6 two 4
DataFrame的duplicated⽅法返回⼀个布尔型Series,表示各⾏是否是重复⾏(前⾯出现过的⾏):
data.duplicated() # 后面的一行与前面的一行相同则是重复行。输出如下:
0 False
1 False
2 False
3 False
4 False
5 False
6 True
dtype: bool
还有⼀个与此相关的drop_duplicates⽅法,它会返回⼀个DataFrame,重复的数组会标为False:
data.drop_duplicates() # 删除重复的行(后面的一行与前面的一行相同则是重复行)。输出如下:
k1 k2
0 one 1
1 two 1
2 one 2
3 two 3
4 one 3
5 two 4
这两个⽅法默认会判断全部列,你也可以指定部分列进⾏重复项判断。假设我们还有⼀列值,且只希望根据k1列过滤重复项:
data['v1'] = range(7) # 在data中添加v1列
data.drop_duplicates(['k1']) # 输出如下:
k1 k2 v1
0 one 1 0
1 two 1 1
duplicated和drop_duplicates默认保留的是第⼀个出现的值组合。传⼊keep='last'则保留最后⼀个:
data.drop_duplicates(['k1', 'k2'], keep='last') # 输出如下:(注意没有index=5的行)
k1 k2 v1
0 one 1 0
1 two 1 1
2 one 2 2
3 two 3 3
4 one 3 4
6 two 4 6
2、利⽤函数或映射进⾏数据转换
对于许多数据集,可能希望根据数组、Series或DataFrame列中的值来实现转换⼯作。我们来看看下⾯这组有关⾁类的数据:
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]})
data # 原始数据如下所示:
food ounces
0 bacon 4.0
1 pulled pork 3.0
2 bacon 12.0
3 Pastrami 6.0
4 corned beef 7.5
5 Bacon 8.0
6 pastrami 3.0
7 honey ham 5.0
8 nova lox 6.0
假设你想要添加⼀列表示该⾁类⻝物来源的动物类型。我们先编写⼀个不同⾁类到动物的映射:
meat_to_animal = {
'bacon': 'pig',
'pulled pork': 'pig',
'pastrami': 'cow',
'corned beef': 'cow',
'honey ham': 'pig',
'nova lox': 'salmon'
}
Series的map⽅法可以接受⼀个函数或含有映射关系的字典型对象,但是这⾥有⼀个⼩问题,即有些⾁类的⾸字⺟⼤写了,⽽另⼀些则没有。因此,我们还需要使⽤Series的str.lower⽅法,将各个值转换为⼩写:
data['animal'] = lowercased.map(meat_to_animal) # 注意映射使用方法
data # 输出如下:
food ounces animal
0 bacon 4.0 pig
1 pulled pork 3.0 pig
2 bacon 12.0 pig
3 Pastrami 6.0 cow
4 corned beef 7.5 cow
5 Bacon 8.0 pig
6 pastrami 3.0 cow
7 honey ham 5.0 pig
8 nova lox 6.0 salmon
我们也可以传⼊⼀个能够完成全部这些⼯作的函数:
data['food'].map(lambda x: meat_to_animal[x.lower()]) # 相当于:map(lambda x: meat_to_animal[x.lower()], data['food'])
0 pig
1 pig
2 pig
3 cow
4 cow
5 pig
6 cow
7 pig
8 salmon
Name: food, dtype: object
使⽤map是⼀种实现元素级转换以及其他数据清理⼯作的便捷⽅式。
3、替换值
利⽤fillna⽅法填充缺失数据可以看做值替换的⼀种特殊情况。前⾯已经看到,map可⽤于修改对象的数据⼦集,⽽replace则提供了⼀种实现该功能的更简单、更灵活的⽅式。我们来看看下⾯这个Series:
data = pd.Series([1., -999., 2., -999., -1000., 3.])
data # 输出如下:
0 1.0
1 -999.0
2 2.0
3 -999.0
4 -1000.0
5 3.0
dtype: float64
-999这个值可能是⼀个表示缺失数据的标记值。要将其替换为pandas能够理解的NA值,我们可以利⽤replace来产⽣⼀个新的Series(除⾮传⼊inplace=True):
data.replace(-999, np.nan) # 输出如下:
0 1.0
1 NaN
2 2.0
3 NaN
4 -1000.0
5 3.0
dtype: float64
如果你希望⼀次性替换多个值,可以传⼊⼀个由待替换值组成的列表以及⼀个替换值:
data.replace([-999, -1000], np.nan) # 输出如下:
0 1.0
1 NaN
2 2.0
3 NaN
4 NaN
5 3.0
dtype: float64
要让每个值有不同的替换值,可以传递⼀个替换列表:
data.replace([-999, -1000], [np.nan, 0]) # 输出如下:
0 1.0
1 NaN
2 2.0
3 NaN
4 0.0
5 3.0
dtype: float64
传⼊的参数也可以是字典:
data.replace({-999: np.nan, -1000: 0}) # 输出如下:
0 1.0
1 NaN
2 2.0
3 NaN
4 0.0
5 3.0
dtype: float64
注意:data.replace⽅法与data.str.replace不同,后者做的是字符串的元素级替换。
4、重命名轴索引
跟Series中的值⼀样,轴标签也可以通过函数或映射进⾏转换,从⽽得到⼀个新的不同标签的对象。轴还可以被就地修改,⽽⽆需新建⼀个数据结构。接下来看看下⾯这个简单的例⼦:
data = pd.DataFrame(np.arange(12).reshape((3, 4)),
index=['Ohio', 'Colorado', 'New York'],
columns=['one', 'two', 'three', 'four'])
跟Series⼀样,轴索引也有⼀个map⽅法:
transform = lambda x: x[:4].upper() # 定义规则
data.index.map(transform) # 输出:Index(['OHIO', 'COLO', 'NEW '], dtype='object')
你可以将其赋值给index,这样就可以对DataFrame进⾏就地修改:
data.index = data.index.map(transform)
data # 输出如下:
one two three four
OHIO 0 1 2 3
COLO 4 5 6 7
NEW 8 9 10 11
如果想要创建数据集的转换版(⽽不是修改原始数据),⽐较实⽤的⽅法是rename:
data.rename(index=str.title, columns=str.upper) # 输出如下:注意传的参数是函数名
ONE TWO THREE FOUR
Ohio 0 1 2 3
Colo 4 5 6 7
New 8 9 10 11
特别说明⼀下,rename可以结合字典型对象实现对部分轴标签的更新:
data.rename(index={'OHIO': 'INDIANA'}, columns={'three': 'peekaboo'}) # 输出如下:
one two peekaboo four
INDIANA 0 1 2 3
COLO 4 5 6 7
NEW 8 9 10 11
rename可以实现复制DataFrame并对其索引和列标签进⾏赋值。如果希望就地修改某个数据集,传⼊inplace=True即可:
data.rename(index={'COLO': 'CHENGDU'}, inplace=True)
data # 输出如下:
one two three four
OHIO 0 1 2 3
CHENGDU 4 5 6 7
NEW 8 9 10 11
5、离散化和⾯元划分
为了便于分析,连续数据常常被离散化或拆分为“⾯元”(bin)。假设有⼀组⼈员数据,⽽你希望将它们划分为不同的年龄组:
ages = [20, 22, 25, 27, 21, 23, 37, 31, 61, 45, 41, 32]
接下来将这些数据划分为“18到25”、“26到35”、“35到60”以及“60以上”⼏个⾯元。要实现该功能,你需要使⽤pandas的cut函数:
bins = [18, 25, 35, 60, 100] # 首先要指定范围
cats = pd.cut(ages, bins)
cats # 输出如下:将每个年龄进行区间划分
[(18, 25], (18, 25], (18, 25], (25, 35], (18, 25], ..., (25, 35], (60, 100], (35, 60], (35, 60], (25, 35]]
Length: 12
Categories (4, interval[int64]): [(18, 25] < (25, 35] < (35, 60] < (60, 100]]
pandas返回的是⼀个特殊的Categorical对象。结果展示了pandas.cut划分的⾯元。你可以将其看做⼀组表示⾯元名称的字符串。它的底层含有⼀个表示不同分类名称的类型数组,以及⼀个codes属性中的年龄数据的标签:
cats.codes # 输出:array([0, 0, 0, 1, 0, 0, 2, 1, 3, 2, 2, 1], dtype=int8)
cats.categories # 输出如下:(区间划分信息)
IntervalIndex([(18, 25], (25, 35], (35, 60], (60, 100]]
closed='right',
dtype='interval[int64]')
pd.value_counts(cats) # 输出如下:
(18, 25] 5
(35, 60] 3
(25, 35] 3
(60, 100] 1
dtype: int64
pd.value_counts(cats)是pandas.cut结果的⾯元计数。
跟“区间”的数学符号⼀样,圆括号表示开端,⽽⽅括号则表示闭端(包括)。哪边是闭端可以通过right=False进⾏修改:
pd.cut(ages, [18, 26, 36, 61, 100], right=False) # 输出如下:
[[18, 26), [18, 26), [18, 26), [26, 36), [18, 26), ..., [26, 36), [61, 100), [36, 61), [36, 61), [26, 36)]
Length: 12
Categories (4, interval[int64]): [[18, 26) < [26, 36) < [36, 61) < [61, 100)]
你可以通过传递⼀个列表或数组到labels,设置⾃⼰的⾯元名称:
group_names = ['Youth', 'YoungAdult', 'MiddleAged', 'Senior']
pd.cut(ages, bins, labels=group_names) # 输出如下:
[Youth, Youth, Youth, YoungAdult, Youth, ..., YoungAdult, Senior, MiddleAged, MiddleAged, YoungAdult]
Length: 12
Categories (4, object): [Youth < YoungAdult < MiddleAged < Senior]
如果向cut传⼊的是⾯元的数量⽽不是确切的⾯元边界,则它会根据数据的最⼩值和最⼤值计算等⻓⾯元。下⾯这个例⼦中,我们将⼀些均匀分布的数据分成四组:
data = np.random.rand(20)
pd.cut(data, 4, precision=2) # 输出如下:平均分为4组
[(0.74, 0.98], (0.0089, 0.25], (0.49, 0.74], (0.0089, 0.25], (0.74, 0.98], ..., (0.0089, 0.25], (0.49, 0.74], (0.25, 0.49], (0.49, 0.74], (0.74, 0.98]]
Length: 20
Categories (4, interval[float64]): [(0.0089, 0.25] < (0.25, 0.49] < (0.49, 0.74] < (0.74, 0.98]]
选项precision=2,限定⼩数只有两位。
qcut是⼀个⾮常类似于cut的函数,它可以根据样本分位数对数据进⾏⾯元划分。根据数据的分布情况,cut可能⽆法使各个⾯元中含有相同数量的数据点。⽽qcut由于使⽤的是样本分位数,因此可以得到⼤⼩基本相等的⾯元:
data = np.random.randn(1000) # Normally distributed(正态分布)
cats = pd.qcut(data, 4) # Cut into quartiles
cats # 输出如下:
[(0.031, 0.66], (0.031, 0.66], (-2.58, -0.604], (-0.604, 0.031], (-0.604, 0.031], ..., (-2.58, -0.604], (-2.58, -0.604], (-2.58, -0.604], (0.031, 0.66], (0.66, 2.965]]
Length: 1000
Categories (4, interval[float64]): [(-2.58, -0.604] < (-0.604, 0.031] < (0.031, 0.66] < (0.66, 2.965]]
pd.value_counts(cats) # 输出如下:
(0.66, 2.965] 250
(0.031, 0.66] 250
(-0.604, 0.031] 250
(-2.58, -0.604] 250
dtype: int64
与cut类似,你也可以传递⾃定义的分位数(0到1之间的数值,包含端点):
pd.qcut(data, [0, 0.1, 0.5, 0.9, 1.]) # 输出如下:
[(0.031, 1.352], (0.031, 1.352], (-2.58, -1.227], (-1.227, 0.031], (-1.227, 0.031], ..., (-2.58, -1.227], (-2.58, -1.227], (-2.58, -1.227], (0.031, 1.352], (0.031, 1.352]]
Length: 1000
Categories (4, interval[float64]): [(-2.58, -1.227] < (-1.227, 0.031] < (0.031, 1.352] < (1.352, 2.965]]
cut和qcut两个离散化函数对分位和分组分析⾮常重要。聚合和分组运算时会再次⽤到。
6、检测和过滤异常值
过滤或变换异常值(outlier)在很⼤程度上就是运⽤数组运算。来看⼀个含有正态分布数据的DataFrame:
data.describe() # 输出如下:
0 1 2 3
count 1000.000000 1000.000000 1000.000000 1000.000000
mean 0.016706 0.028248 0.036608 -0.005333
std 0.967088 1.016612 0.995248 1.026645
min -2.949776 -3.408071 -3.077886 -3.309142
25% -0.629107 -0.660412 -0.649365 -0.744179
50% 0.009834 0.058910 0.020661 0.018473
75% 0.668249 0.705827 0.739391 0.683511
max 2.650242 3.560653 3.053767 3.010718
假设想要找出某列中绝对值⼤⼩超过3的值:
col = data[2] # 第3列
col[np.abs(col) > 3] # 输出如下:(找出绝对值大于3的数)
52 -3.077886
277 3.012002
932 3.053767
Name: 2, dtype: float64
要选出全部含有“超过3或-3的值”的⾏,你可以在布尔型DataFrame中使⽤any⽅法:
data[(np.abs(data) > 3).any(1)] # 输出如下:
0 1 2 3
3 -0.842118 1.394323 0.310644 -3.309142
52 0.185749 -0.620026 -3.077886 0.148803
81 0.026080 -1.311505 -0.041084 -3.245929
146 0.977894 0.120866 1.006821 3.010718
277 -0.331066 -0.471784 3.012002 -1.350731
288 -0.656026 -3.408071 -0.955631 0.358787
564 -1.093666 3.560653 0.351178 1.185690
743 0.148074 -3.001641 0.815807 -1.031880
747 0.489386 -3.077059 -0.503121 -1.291354
800 -0.631084 3.461719 1.664846 0.999467
932 1.764187 0.683385 3.053767 -0.110174
根据这些条件,就可以对值进⾏设置。下⾯的代码可以将值限制在区间-3到3以内:
data[np.abs(data) > 3] = np.sign(data) * 3
data.describe() # 输出如下:
0 1 2 3
count 1000.000000 1000.000000 1000.000000 1000.000000
mean 0.016706 0.027712 0.036620 -0.004789
std 0.967088 1.011813 0.994809 1.024915
min -2.949776 -3.000000 -3.000000 -3.000000
25% -0.629107 -0.660412 -0.649365 -0.744179
50% 0.009834 0.058910 0.020661 0.018473
75% 0.668249 0.705827 0.739391 0.683511
max 2.650242 3.000000 3.000000 3.000000
根据数据的值是正还是负,np.sign(data)可以⽣成1和-1:
np.sign(data).head() # 输出如下:
0 1 2 3
0 -1.0 1.0 -1.0 1.0
1 -1.0 1.0 -1.0 1.0
2 -1.0 1.0 -1.0 -1.0
3 -1.0 1.0 1.0 -1.0
4 1.0 1.0 -1.0 1.0
7、排列和随机采样
利⽤numpy.random.permutation函数可以轻松实现对Series或DataFrame的列的排列⼯作(permuting,随机重排序)。通过需要排列的轴的⻓度调⽤permutation,可产⽣⼀个表示新顺序的整数数组:
df = pd.DataFrame(np.arange(5 * 4).reshape((5, 4)))
sampler = np.random.permutation(5)
sampler # 输出:array([1, 3, 2, 4, 0])
然后就可以在基于iloc的索引操作或take函数中使⽤该数组了:
df # 输出如下:
0 1 2 3
0 0 1 2 3
1 4 5 6 7
2 8 9 10 11
3 12 13 14 15
4 16 17 18 19
df.take(sampler) # 输出如下:
0 1 2 3
1 4 5 6 7
3 12 13 14 15
2 8 9 10 11
4 16 17 18 19
0 0 1 2 3
如果不想⽤替换的⽅式选取随机⼦集,可以在Series和DataFrame上使⽤sample⽅法:
df.sample(3) # 输出如下:(随机选取3行)
0 1 2 3
3 12 13 14 15
2 8 9 10 11
4 16 17 18 19
要通过替换的⽅式产⽣样本(允许重复选择),可以传递replace=True到sample:
choices = pd.Series([5, 7, -1, 6, 4])
draws = choices.sample(n=10, replace=True) # 对choices随机选取10个数据,并允许重复
draws # 输出如下:
1 7
0 5
2 -1
4 4
4 4
3 6
2 -1
3 6
3 6
4 4
dtype: int64
8、计算指标/哑变量
另⼀种常⽤于统计建模或机器学习的转换⽅式是:将分类变量(categorical variable)转换为“哑变量”或“指标矩阵”。
如果DataFrame的某⼀列中含有k个不同的值,则可以派⽣出⼀个k列矩阵或DataFrame(其值全为1和0)。pandas有⼀个get_dummies函数可以实现该功能(其实⾃⼰动⼿做⼀个也不难)。使⽤之前的⼀个DataFrame例⼦:
df = pd.DataFrame({'key': ['b', 'b', 'a', 'c', 'a', 'b'], 'data1': range(6)})
pd.get_dummies(df['key']) # 输出如下:注意0和1出现的规律,1表示新的DataFrame列标签在原DataFrame中的列出现位置
a b c
0 0 1 0
1 0 1 0
2 1 0 0
3 0 0 1
4 1 0 0
5 0 1 0
有时候,你可能想给指标DataFrame的列加上⼀个前缀,以便能够跟其他数据进⾏合并。get_dummies的prefix参数可以实现该功能:
dummies = pd.get_dummies(df['key'], prefix='key')
df_with_dummy = df[['data1']].join(dummies)
df_with_dummy # 输出如下:
data1 key_a key_b key_c
0 0 0 1 0
1 1 0 1 0
2 2 1 0 0
3 3 0 0 1
4 4 1 0 0
5 5 0 1 0
如果DataFrame中的某⾏同属于多个分类,则事情就会有点复杂。看⼀下MovieLens 1M数据集,
mnames = ['movie_id', 'title', 'genres']
movies = pd.read_table('datasets/movielens/movies.dat', sep='::', header=None, names=mnames)
movies[:10] # 输出如下:
movie_id title genres
0 1 Toy Story (1995) Animation|Children's|Comedy
1 2 Jumanji (1995) Adventure|Children's|Fantasy
2 3 Grumpier Old Men (1995) Comedy|Romance
3 4 Waiting to Exhale (1995) Comedy|Drama
4 5 Father of the Bride Part II (1995) Comedy
5 6 Heat (1995) Action|Crime|Thriller
6 7 Sabrina (1995) Comedy|Romance
7 8 Tom and Huck (1995) Adventure|Children's
8 9 Sudden Death (1995) Action
9 10 GoldenEye (1995) Action|Adventure|Thriller
要为每个genre添加指标变量就需要做⼀些数据规整操作。⾸先,我们从数据集中抽取出不同的genre值:
all_genres = []
for x in movies.genres:
all_genres.extend(x.split('|'))
genres = pd.unique(all_genres) # 去重
现在有:
genres # 输出如下:
array(['Animation', "Children's", 'Comedy', 'Adventure', 'Fantasy',
'Romance', 'Drama', 'Action', 'Crime', 'Thriller', 'Horror',
'Sci-Fi', 'Documentary', 'War', 'Musical', 'Mystery', 'Film-Noir',
'Western'], dtype=object)
构建指标DataFrame的⽅法之⼀是从⼀个全零DataFrame开始:
zero_matrix = np.zeros((len(movies), len(genres)))
dummies = pd.DataFrame(zero_matrix, columns=genres) # 把zero_matrix转换为DataFrame,列标签是genres列表
现在,迭代每⼀部电影,并将dummies各⾏的条⽬设为1。要这么做,我们使⽤dummies.columns来计算每个类型的列索引:
gen = movies.genres[0]
gen.split('|') # 输出:['Animation', "Children's", 'Comedy']
dummies.columns.get_indexer(gen.split('|')) # 输出:array([0, 1, 2], dtype=int64)
然后,根据索引,使⽤.iloc设定值:
for i, gen in enumerate(movies.genres):
indices = dummies.columns.get_indexer(gen.split('|'))
dummies.iloc[i, indices] = 1
然后,和以前⼀样,再将其与movies合并起来:
movies_windic = movies.join(dummies.add_prefix('Genre'))
movies_windic.iloc[0] # 输出如下:
movie_id 1
title Toy Story (1995)
genres Animation|Children's|Comedy
GenreAnimation 1
GenreChildren's 1
...
GenreWar 0
GenreMusical 0
GenreMystery 0
GenreFilm-Noir 0
GenreWestern 0
Name: 0, Length: 21, dtype: object
注意:对于很⼤的数据,⽤这种⽅式构建多成员指标变量就会变得⾮常慢。最好使⽤更低级的函数,将其写⼊NumPy数组,然后结果包装在DataFrame中。
⼀个对统计应⽤有⽤的秘诀是:结合get_dummies和诸如cut之类的离散化函数:
np.random.seed(12345)
values = np.random.rand(10)
values # 输出如下:
array([0.92961609, 0.31637555, 0.18391881, 0.20456028, 0.56772503,
0.5955447 , 0.96451452, 0.6531771 , 0.74890664, 0.65356987])
bins = [0, 0.2, 0.4, 0.6, 0.8, 1]
pd.get_dummies(pd.cut(values, bins)) # 输出如下:
(0.0, 0.2] (0.2, 0.4] (0.4, 0.6] (0.6, 0.8] (0.8, 1.0]
0 0 0 0 0 1
1 0 1 0 0 0
2 1 0 0 0 0
3 0 1 0 0 0
4 0 0 1 0 0
5 0 0 1 0 0
6 0 0 0 0 1
7 0 0 0 1 0
8 0 0 0 1 0
9 0 0 0 1 0
⽤numpy.random.seed,使这个例⼦具有确定性。后⾯会介绍pandas.get_dummies。
三、字符串操作
Python能够成为流⾏的数据处理语⾔,部分原因是其简单易⽤的字符串和⽂本处理功能。⼤部分⽂本运算都直接做成了字符串对象的内置⽅法。对于更为复杂的模式匹配和⽂本操作,则可能需要⽤到正则表达式。pandas对此进⾏了加强,它使你能够对整组数据应⽤字符串表达式和正则表达式,⽽且能处理烦⼈的缺失数据。
1、字符串对象⽅法
对于许多字符串处理和脚本应⽤,内置的字符串⽅法已经能够满⾜要求。例如,以逗号分隔的字符串可以⽤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'
但这种⽅式并不是很实⽤。⼀种更快更符合Python⻛格的⽅式是,向字符串"::"的join⽅法传⼊⼀个列表或元组:
'::'.join(pieces) # 输出:'a::b::guido'
其它⽅法关注的是⼦串定位。检测⼦串的最佳⽅式是利⽤Python的in关键字,还可以使⽤index和find:
'guido' in val # 输出:True
val.index(',') # 输出: 1
val.find(':') # 输出:-1
注意find和index的区别:如果找不到字符串,index将会引发⼀个异常(⽽不是返回-1):
val.index(':') # 引发一个异常:ValueError: substring not found
与此相关,count可以返回指定⼦串的出现次数:
val.count(',') # 输出:2
replace⽤于将指定模式替换为另⼀个模式。通过传⼊空字符串,它也常常⽤于删除模式:
val.replace(',', '::') # 输出:'a::b:: guido'
val.replace(',', '') # 输出:'ab guido'
表7-3列出了Python内置的字符串⽅法。这些运算⼤部分都能使⽤正则表达式实现(⻢上就会看到)。
2、正则表达式
正则表达式提供了⼀种灵活的在⽂本中搜索或匹配(通常⽐前者复杂)字符串模式的⽅式。正则表达式,常称作regex,是根据正则表达式语⾔编写的字符串。Python内置的re模块负责对字符串应⽤正则表达式。下面通过⼀些例⼦说明其使⽤⽅法。
re模块的函数可以分为三个⼤类:模式匹配、替换以及拆分。当然,它们之间是相辅相成的。⼀个regex描述了需要在⽂本中定位的⼀个模式,它可以⽤于许多⽬的。先来看⼀个简单的例⼦:假设我想要拆分⼀个字符串,分隔符为数量不定的⼀组空⽩符(制表符、空格、换⾏符等)。
描述⼀个或多个空⽩符的regex是\s+:
import re
text = "foo bar\t baz \tqux"
re.split('\s+', text) # 输出:['foo', 'bar', 'baz', 'qux']
调⽤re.split('\s+',text)时,正则表达式会先被编译,然后再在text上调⽤其split⽅法。
你可以⽤re.compile⾃⼰编译regex以得到⼀个可重⽤的regex对象:
regex = re.compile('\s+')
regex.split(text) # 输出:['foo', 'bar', 'baz', 'qux']
如果只希望得到匹配regex的所有模式,则可以使⽤findall⽅法:
regex.findall(text) # 输出:[' ', '\t ', ' \t']
注意:如果想避免正则表达式中不需要的转义(\),则可以使⽤原始字符串表示如r'C:\x'(也可以编写其等价式'C:\x')。
如果打算对许多字符串应⽤同⼀条正则表达式,强烈建议通过re.compile创建regex对象。这样将可以节省⼤量的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}'
# re.IGNORECASE makes the regex case-insensitive,忽略正则表达式的大小写
regex = re.compile(pattern, flags=re.IGNORECASE) # 编译regex得到⼀个可重⽤的regex对象
对text使⽤findall将得到⼀组电⼦邮件地址:
regex.findall(text) # 输出:['dave@google.com', 'steve@gmail.com', 'rob@gmail.com', 'ryan@yahoo.com']
search返回的是⽂本中第⼀个电⼦邮件地址(以特殊的匹配项对象形式返回)。对于上⾯那个regex,匹配项对象只能告诉我们模式在原字符串中的起始和结束位置:
m = regex.search(text)
m # 输出:<_sre.SRE_Match object; span=(5, 20), match='dave@google.com'>
text[m.start():m.end()] # 输出:'dave@google.com'
regex.match则将返回None,因为它只匹配出现在字符串开头的模式:
print(regex.match(text)) # 输出:None
相关的,sub⽅法可以将匹配到的模式替换为指定字符串,并返回所得到的新字符串:
print(regex.sub('REDACTED',text)) # 输出如下:
Dave REDACTED
Steve REDACTED
Rob REDACTED
Ryan REDACTED
假设你不仅想要找出电⼦邮件地址,还想将各个地址分成3个部分:⽤户名、域名以及域后缀。要实现此功能,只需将待分段的模式的各部分⽤圆括号包起来即可:
pattern = r'([A-Z0-9._%+-]+)@([A-Z0-9.-]+)\.([A-Z]{2,4})'
regex = re.compile(pattern, flags=re.IGNORECASE)
由这种修改过的正则表达式所产⽣的匹配项对象,可以通过其groups⽅法返回⼀个由模式各段组成的元组:
m = regex.match('michael@163.com')
m.groups() # 输出:('michael', '163', 'com')
对于带有分组功能的模式,findall会返回⼀个元组列表:
regex.findall(text) # 输出如下:
[('dave', 'google', 'com'),
('steve', 'gmail', 'com'),
('rob', 'gmail', 'com'),
('ryan', 'yahoo', 'com')]
sub还能通过诸如\1、\2之类的特殊符号访问各匹配项中的分组。符号\1对应第⼀个匹配的组,\2对应第⼆个匹配的组,以此类推:
print(regex.sub(r'Username: \1, Domain: \2, Suffix: \3', text)) # 输出如下:
Dave Username: dave, Domain: google, Suffix: com
Steve Username: steve, Domain: gmail, Suffix: com
Rob Username: rob, Domain: gmail, Suffix: com
Ryan Username: ryan, Domain: yahoo, Suffix: com
Python中还有许多的正则表达式,找与之相关的资料进一步学习。
表7-4:正则表达式方法(简要概括)
3、pandas的⽮量化字符串函数
清理待分析的散乱数据时,常常需要做⼀些字符串规整化⼯作。更为复杂的情况是,含有字符串的列有时还含有缺失数据:
data = {'Dave': 'dave@google.com', 'Steve': 'steve@gmail.com', 'Rob': 'rob@gmail.com', 'Wes': np.nan}
data = pd.Series(data)
data # 输出如下:
Dave dave@google.com
Steve steve@gmail.com
Rob rob@gmail.com
Wes NaN
dtype: object
data.isnull() # 输出如下:
Dave False
Steve False
Rob False
Wes True
dtype: bool
通过data.map,所有字符串和正则表达式⽅法都能被应⽤于(传⼊lambda表达式或其他函数)各个值,但是如果存在NA(null)就会报错。为了解决这个问题,Series有⼀些能够跳过NA值的⾯向数组⽅法,进⾏字符串操作。通过Series的str属性即可访问这些⽅法。例如,我们可以通过str.contains检查各个电⼦邮件地址是否含有"gmail":
data.str.contains('gmail') # 输出如下:
Dave False
Steve True
Rob True
Wes NaN
dtype: object
也可以使⽤正则表达式,还可以加上任意re选项(如IGNORECASE):
pattern # 有pattern表达式:'([A-Z0-9._%+-]+)@([A-Z0-9.-]+)\\.([A-Z]{2,4})'
data.str.findall(pattern, flags=re.IGNORECASE) # 输出如下:
Dave [(dave, google, com)]
Steve [(steve, gmail, com)]
Rob [(rob, gmail, com)]
Wes NaN
dtype: object
有两个办法可以实现⽮量化的元素获取操作:要么使⽤str.get,要么在str属性上使⽤索引:
matches = data.str.match(pattern, flags=re.IGNORECASE)
matches # 输出如下:
Dave True
Steve True
Rob True
Wes NaN
dtype: object
要访问嵌⼊列表中的元素,我们可以传递索引到这两个函数中:
matches.str.get(1) # 输出如下:使用str.get
Dave NaN
Steve NaN
Rob NaN
Wes NaN
dtype: float64
matches.str[0] # 输出如下:在str属性上使用索引
Dave NaN
Steve NaN
Rob NaN
Wes NaN
dtype: float64
可以利⽤这种⽅法对字符串进⾏截取:
data.str[:5] # 输出如下:
Dave dave@
Steve steve
Rob rob@g
Wes NaN
dtype: object
表7-5 部分⽮量化字符串⽅法(更多的pandas字符串⽅法)