第07章 数据清洗和准备--Python for Data Analysis 2nd

在数据分析和建模的过程中,相当多的时间要用在数据准备上:加载、清理、转换以及重塑。这些工作会占到分析师时间的80%或更多。有时,存储在文件和数据库中的数据的格式不适合某个特定的任务。许多研究者都选择使用通用编程语言(如Python、Perl、R或Java)或UNIX文本处理工具(如sed或awk)对数据格式进行专门处理。幸运的是,pandas和内置的Python标准库提供了一组高级的、灵活的、快速的工具,可以让你轻松地将数据规整为想要的格式。

如果你发现了一种本书或pandas库中没有的数据操作方式,请在邮件列表或GitHub网站上提出。实际上,pandas的许多设计和实现都是由真实应用的需求所驱动的。

在本章中,我会讨论处理缺失数据、重复数据、字符串操作和其它分析数据转换的工具。下一章,我会关注于用多种方法合并、重塑数据集。

处理缺失数据

在许多数据分析工作中,缺失数据是经常发生的。pandas的目标之一就是尽量轻松地处理缺失数据。例如,pandas对象的所有描述性统计默认都不包括缺失数据。
缺失数据在pandas中呈现的方式有些不完美,但对于大多数用户可以保证功能正常。对于数值数据,pandas使用浮点值NaN(Not a Number)表示缺失数据。我们称其为哨兵值,可以方便的检测出来:

import pandas as pd
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中,我们采用了R语言中的惯用法,即将缺失值表示为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列出了一些关于缺失数据处理的函数。

方法说明
dropna根据各标签的值是否存在缺失数据对轴标签进行过滤,可通过阈值调节对缺失值的容忍度
fillna用指定值或插值方法(如ffill或vfill)填充缺失数据
isnull返回一个含有布尔值的对象,这些布尔值表示哪些值是缺失值/NA,该对象的类型与源类型一样
notnullisnull的否定式

滤除缺失数据

过滤掉缺失数据的办法有很多种。你可以通过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

012
01.06.53.0
11.0NaNNaN
2NaNNaNNaN
3NaN6.53.0
cleaned
012
01.06.53.0

传入how='all’将只丢弃全为NA的那些行:

data.dropna(how='all')
012
01.06.53.0
11.0NaNNaN
3NaN6.53.0

用这种方式丢弃列,只需传入axis=1即可:

data.loc[4] = NA
data
012
01.06.53.0
11.0NaNNaN
2NaNNaNNaN
3NaN6.53.0
4NaNNaNNaN
data.drop(4,axis=0,inplace=True)
data
012
01.06.53.0
11.0NaNNaN
2NaNNaNNaN
3NaN6.53.0
data[4] = NA
data
0124
01.06.53.0NaN
11.0NaNNaNNaN
2NaNNaNNaNNaN
3NaN6.53.0NaN
data.dropna(axis=1, how='all')
012
01.06.53.0
11.0NaNNaN
2NaNNaNNaN
3NaN6.53.0

另一个滤除DataFrame行的问题涉及时间序列数据。假设你只想留下一部分观测数据,可以用thresh参数实现此目的:

df = pd.DataFrame(np.random.randn(7, 3))

df.iloc[:4, 1] = NA
df.iloc[:2, 2] = NA
df
012
00.246405NaNNaN
1-0.435733NaNNaN
2-0.417859NaN3.696555
3-0.515305NaN0.592398
42.0308340.339192-1.113542
51.679019-0.0231580.565316
6-2.3530460.2207050.823561
df.dropna()
012
42.0308340.339192-1.113542
51.679019-0.0231580.565316
6-2.3530460.2207050.823561
df.dropna(thresh=2)
012
2-0.417859NaN3.696555
3-0.515305NaN0.592398
42.0308340.339192-1.113542
51.679019-0.0231580.565316
6-2.3530460.2207050.823561

填充缺失数据

你可能不想滤除缺失数据(有可能会丢弃跟它有关的其他数据),而是希望通过其他方式填补那些“空洞”。对于大多数情况而言,fillna方法是最主要的函数。通过一个常数调用fillna就会将缺失值替换为那个常数值:

df.fillna(0)
012
00.2464050.0000000.000000
1-0.4357330.0000000.000000
2-0.4178590.0000003.696555
3-0.5153050.0000000.592398
42.0308340.339192-1.113542
51.679019-0.0231580.565316
6-2.3530460.2207050.823561

若是通过一个字典调用fillna,就可以实现对不同的列填充不同的值:

df.fillna({1: 0.5, 2: 0})
012
00.2464050.5000000.000000
1-0.4357330.5000000.000000
2-0.4178590.5000003.696555
3-0.5153050.5000000.592398
42.0308340.339192-1.113542
51.679019-0.0231580.565316
6-2.3530460.2207050.823561

fillna默认会返回新对象,但也可以对现有对象进行就地修改:

_ = df.fillna(0, inplace=True)
df
012
00.2464050.0000000.000000
1-0.4357330.0000000.000000
2-0.4178590.0000003.696555
3-0.5153050.0000000.592398
42.0308340.339192-1.113542
51.679019-0.0231580.565316
6-2.3530460.2207050.823561

对reindexing有效的那些插值方法也可用于fillna

df = pd.DataFrame(np.random.randn(6, 3))

df.iloc[2:, 1] = NA
df.iloc[4:, 2] = NA

df
012
00.940596-1.4262220.590986
1-1.8746980.6660010.476362
2-0.578111NaN-1.141313
3-0.095899NaN0.030459
41.546775NaNNaN
5-0.272365NaNNaN
df.fillna(method='ffill')
012
00.940596-1.4262220.590986
1-1.8746980.6660010.476362
2-0.5781110.666001-1.141313
3-0.0958990.6660010.030459
41.5467750.6660010.030459
5-0.2723650.6660010.030459
df.fillna(method='ffill', limit=2)
012
00.940596-1.4262220.590986
1-1.8746980.6660010.476362
2-0.5781110.666001-1.141313
3-0.0958990.6660010.030459
41.546775NaN0.030459
5-0.272365NaN0.030459

只要有些创新,你就可以利用fillna实现许多别的功能。比如说,你可以传入Series的平均值或中位数:

data = pd.Series([1., NA, 3.5, NA, 7])

data.fillna(data.mean())
0    1.000000
1    3.833333
2    3.500000
3    3.833333
4    7.000000
dtype: float64

fillna的参数

参数说明
value用于填充缺失值的标量值或字典对象
method插值方式。如果函数调用时未指定其他参数的话,默认为‘ffill’
axis待填充的轴,默认axis=0
inplace修改调用者对象而不产生副本
limit(对于向前和向后填充)可以连续填充的最大数量

数据转换

本章到目前为止介绍的都是数据的重排。另一类重要操作则是过滤、清理以及其他的转换工作。

移除重复数据

DataFrame中出现重复行有多种原因。下面就是一个例子:

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

data
k1k2
0one1
1two1
2one2
3two3
4one3
5two4
6two4

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()
k1k2
0one1
1two1
2one2
3two3
4one3
5two4

这两个方法默认会判断全部列,你也可以指定部分列进行重复项判断。假设我们还有一列值,且只希望根据k1列过滤重复项:

data['v1'] = range(7)

data
k1k2v1
0one10
1two11
2one22
3two33
4one34
5two45
6two46
data.drop_duplicates(['k1'])
k1k2v1
0one10
1two11

duplicated和drop_duplicates默认保留的是第一个出现的值组合。传入keep='last’则保留最后一个:

data.drop_duplicates(['k1', 'k2'], keep='last')
k1k2v1
0one10
1two11
2one22
3two33
4one34
6two46

利用函数或映射进行数据转换

对于许多数据集,你可能希望根据数组、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
foodounces
0bacon4.0
1pulled pork3.0
2bacon12.0
3Pastrami6.0
4corned beef7.5
5Bacon8.0
6pastrami3.0
7honey ham5.0
8nova lox6.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方法,将各个值转换为小写:

lowercased = data['food'].str.lower()

lowercased
0          bacon
1    pulled pork
2          bacon
3       pastrami
4    corned beef
5          bacon
6       pastrami
7      honey ham
8       nova lox
Name: food, dtype: object
data['animal'] = lowercased.map(meat_to_animal)
data
foodouncesanimal
0bacon4.0pig
1pulled pork3.0pig
2bacon12.0pig
3Pastrami6.0cow
4corned beef7.5cow
5Bacon8.0pig
6pastrami3.0cow
7honey ham5.0pig
8nova lox6.0salmon

我们也可以传入一个能够完成全部这些工作的函数

data['food'].map(lambda x: meat_to_animal[x.lower()])
0       pig
1       pig
2       pig
3       cow
4       cow
5       pig
6       cow
7       pig
8    salmon
Name: food, dtype: object

使用map是一种实现元素级转换以及其他数据清理工作的便捷方式。

替换值

利用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不同,后者做的是字符串的元素级替换。我们会在后面学习Series的字符串方法。

重命名轴索引

跟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
onetwothreefour
OHIO0123
COLO4567
NEW891011

如果想要创建数据集的转换版(而不是修改原始数据),比较实用的方法是rename:

data.rename(index=str.title, columns=str.upper)
ONETWOTHREEFOUR
Ohio0123
Colo4567
New891011

特别说明一下,rename可以结合字典型对象实现对部分轴标签的更新:

data.rename(index={'OHIO': 'INDIANA'},
             columns={'three': 'peekaboo'})
onetwopeekaboofour
INDIANA0123
COLO4567
NEW891011

rename可以实现复制DataFrame并对其索引和列标签进行赋值。如果希望就地修改某个数据集,传入inplace=True即可:

data.rename(index={'OHIO': 'INDIANA'}, inplace=True)
data
onetwothreefour
INDIANA0123
COLO4567
NEW891011

离散化和面元划分

为了便于分析,连续数据常常被离散化或拆分为“面元”(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

跟“区间”的数学符号一样,圆括号表示开端,而方括号则表示闭端(包括)。哪边是闭端可以通过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)
[(0.011, 0.25], (0.48, 0.71], (0.48, 0.71], (0.71, 0.95], (0.48, 0.71], ..., (0.011, 0.25], (0.71, 0.95], (0.48, 0.71], (0.25, 0.48], (0.25, 0.48]]
Length: 20
Categories (4, interval[float64]): [(0.011, 0.25] < (0.25, 0.48] < (0.48, 0.71] < (0.71, 0.95]]

选项precision=2,限定小数只有两位。

qcut是一个非常类似于cut的函数,它可以根据样本分位数对数据进行面元划分。根据数据的分布情况,cut可能无法使各个面元中含有相同数量的数据点。而qcut由于使用的是样本分位数,因此可以得到大小基本相等的面元:

data = np.random.randn(1000)  # Normally distributed

cats = pd.qcut(data, 4)  # Cut into quartiles

cats
[(-0.66, 0.00891], (-3.32, -0.66], (-3.32, -0.66], (0.685, 2.816], (0.00891, 0.685], ..., (-3.32, -0.66], (0.00891, 0.685], (0.685, 2.816], (0.685, 2.816], (-0.66, 0.00891]]
Length: 1000
Categories (4, interval[float64]): [(-3.32, -0.66] < (-0.66, 0.00891] < (0.00891, 0.685] < (0.685, 2.816]]
pd.value_counts(cats)
(0.685, 2.816]      250
(0.00891, 0.685]    250
(-0.66, 0.00891]    250
(-3.32, -0.66]      250
dtype: int64

与cut类似,你也可以传递自定义的分位数(0到1之间的数值,包含端点):

pd.qcut(data, [0, 0.1, 0.5, 0.9, 1.])
[(-1.244, 0.00891], (-3.32, -1.244], (-1.244, 0.00891], (1.268, 2.816], (0.00891, 1.268], ..., (-3.32, -1.244], (0.00891, 1.268], (0.00891, 1.268], (1.268, 2.816], (-1.244, 0.00891]]
Length: 1000
Categories (4, interval[float64]): [(-3.32, -1.244] < (-1.244, 0.00891] < (0.00891, 1.268] < (1.268, 2.816]]

本章稍后在讲解聚合和分组运算时会再次用到cut和qcut,因为这两个离散化函数对分位和分组分析非常重要。

检测和过滤异常值

过滤或变换异常值(outlier)在很大程度上就是运用数组运算。来看一个含有正态分布数据的DataFrame:

data = pd.DataFrame(np.random.randn(1000, 4))

data.describe()
0123
count1000.0000001000.0000001000.0000001000.000000
mean0.023170-0.0143570.0519740.022552
std1.0155730.9974651.0246841.028922
min-2.976564-3.961232-3.014750-2.971930
25%-0.658386-0.706815-0.670208-0.631982
50%0.001732-0.0124600.0580990.002906
75%0.7232290.6261820.7261290.674511
max3.2954243.2588193.2853113.576419

假设你想要找出某列中绝对值大小超过3的值:

col = data[2]
col[np.abs(col) > 3]
590   -3.014750
816    3.052614
960    3.285311
Name: 2, dtype: float64

要选出全部含有“超过3或-3的值”的行,你可以在布尔型DataFrame中使用any方法:

data[(np.abs(data) > 3).any(1)]
0123
700.369178-3.9612320.667674-0.548622
1323.295424-1.3839241.374598-0.071639
5900.002221-0.347278-3.0147501.036079
593-1.2793683.2588190.420626-1.620074
689-1.467970-0.935729-0.5202503.357273
7063.055261-0.0112940.0602052.065107
816-1.5797660.0769473.0526140.033548
8510.526193-1.3228600.6282193.576419
9600.7136590.9159033.2853110.108203
data[np.abs(data) > 3] = np.sign(data) * 3

data[70:71]
0123
700.369178-3.00.667674-0.548622
data.describe()
0123
count1000.0000001000.0000001000.0000001000.000000
mean0.022820-0.0136540.0516500.021619
std1.0144990.9932961.0236251.025989
min-2.976564-3.000000-3.000000-2.971930
25%-0.658386-0.706815-0.670208-0.631982
50%0.001732-0.0124600.0580990.002906
75%0.7232290.6261820.7261290.674511
max3.0000003.0000003.0000003.000000

根据数据的值是正还是负,np.sign(data)可以生成1和-1:

np.sign(data).head()
0123
01.0-1.01.0-1.0
1-1.01.0-1.0-1.0
21.0-1.01.01.0
31.01.01.01.0
41.0-1.01.0-1.0

排列和随机采样

利用numpy.random.permutation函数可以轻松实现对Series或DataFrame的列的排列工作(permuting,随机重排序)。通过需要排列的轴的长度调用permutation,可产生一个表示新顺序的整数数组:

df = pd.DataFrame(np.arange(5 * 4).reshape((5, 4)))
sampler = np.random.permutation(5)
sampler
array([4, 2, 3, 0, 1])

然后就可以在基于iloc的索引操作或take函数中使用该数组了:

df
0123
00123
14567
2891011
312131415
416171819
df.take(sampler)
0123
416171819
2891011
312131415
00123
14567

如果不想用替换的方式选取随机子集,可以在Series和DataFrame上使用sample方法:

df.sample(n=3)
0123
416171819
14567
312131415

要通过替换的方式产生样本(允许重复选择),可以传递replace=True到sample;replace=False报错:

choices = pd.Series([5, 7, -1, 6, 4])
draws = choices.sample(n=10, replace=True)
draws
0    5
1    7
4    4
4    4
2   -1
0    5
2   -1
4    4
1    7
2   -1
dtype: int64

计算指标/哑变量

另一种常用于统计建模或机器学习的转换方式是:将分类变量(categorical variable)转换为“哑变量”或“指标矩阵”。

在多重回归、Logistic回归模型中,自变量可以是连续型变量(interval variables),也可以是二项分类变量,和多分类变量。为了便于解释,对二项分类变量(如好坏、死活、发病不发病等)一般按0、1编码,一般0表示阴性或较轻情况,而1表示阳性或较严重情况。如果对二项分类变量按+1与-1编码,那么所得的logistic回归OR=exp(2beta),多重回归的beta同样增加一倍,容易造成错误的解释。因此建议尽量避免“+1”、“-1”编码形式。多分类变量又可分为有序(等级)或无序(也叫名义),如果是有序(ordinal)分类变量,一般可按对因变量影响由小到大的顺序编码为1、2、3、…,或按数据的自然大小,将它当作连续型变量处理。如果是无序的(nominal)分类变量,则需要采用哑变量(dummy variables)进行编码,下面以职业(J)为例加予以说明。 假如职业分类为工、农、商、学、兵5类,则可定义比分类数少1个,即5-1=4个哑变量

如果DataFrame的某一列中含有k个不同的值,则可以派生出一个k列矩阵或DataFrame(其值全为1和0)。pandas有一个get_dummies函数可以实现该功能(其实自己动手做一个也不难)。使用之前的一个DataFrame例子:

import pandas as pd
df = pd.DataFrame({'key': ['b', 'b', 'a', 'c', 'a', 'b'],
                     'data1': range(6)})
df
keydata1
0b0
1b1
2a2
3c3
4a4
5b5
pd.get_dummies(df)
data1key_akey_bkey_c
00010
11010
22100
33001
44100
55010
pd.get_dummies(df['key'])
abc
0010
1010
2100
3001
4100
5010

有时候,你可能想给指标DataFrame的列加上一个前缀,以便能够跟其他数据进行合并。get_dummies的prefix参数可以实现该功能:

dummies = pd.get_dummies(df['key'], prefix='key')
df_with_dummy = df[['data1']].join(dummies)
df_with_dummy
data1key_akey_bkey_c
00010
11010
22100
33001
44100
55010

如果DataFrame中的某行同属于多个分类,则事情就会有点复杂。看一下MovieLens 1M数据集,14章会更深入地研究它:

mnames = ['movie_id', 'title', 'genres']

movies = pd.read_table('datasets/movielens/movies.dat', sep='::',
                         header=None, names=mnames)


/root/miniconda3/lib/python3.6/site-packages/ipykernel_launcher.py:4: ParserWarning: Falling back to the 'python' engine because the 'c' engine does not support regex separators (separators > 1 char and different from '\s+' are interpreted as regex); you can avoid this warning by specifying engine='python'.
  after removing the cwd from sys.path.
movies.shape
(3883, 3)
 movies[:10]
movie_idtitlegenres
01Toy Story (1995)Animation|Children's|Comedy
12Jumanji (1995)Adventure|Children's|Fantasy
23Grumpier Old Men (1995)Comedy|Romance
34Waiting to Exhale (1995)Comedy|Drama
45Father of the Bride Part II (1995)Comedy
56Heat (1995)Action|Crime|Thriller
67Sabrina (1995)Comedy|Romance
78Tom and Huck (1995)Adventure|Children's
89Sudden Death (1995)Action
910GoldenEye (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)))

print(zero_matrix.shape)
dummies = pd.DataFrame(zero_matrix, columns=genres)
(3883, 18)
dummies.head()
AnimationChildren'sComedyAdventureFantasyRomanceDramaActionCrimeThrillerHorrorSci-FiDocumentaryWarMusicalMysteryFilm-NoirWestern
00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.0
10.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.0
20.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.0
30.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.0
40.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.0

现在,迭代每一部电影,并将dummies各行的条目设为1。要这么做,我们使用dummies.columns来计算每个类型的列索引:

gen = movies.genres[0]
print(gen.split('|'))
dummies.columns.get_indexer(gen.split('|'))
['Animation', "Children's", 'Comedy']





array([0, 1, 2])

然后,根据索引,使用.iloc设定值:

for i, gen in enumerate(movies.genres):
    indices = dummies.columns.get_indexer(gen.split('|'))
    dummies.iloc[i, indices] = 1
dummies.head()
AnimationChildren'sComedyAdventureFantasyRomanceDramaActionCrimeThrillerHorrorSci-FiDocumentaryWarMusicalMysteryFilm-NoirWestern
01.01.01.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.0
10.01.00.01.01.00.00.00.00.00.00.00.00.00.00.00.00.00.0
20.00.01.00.00.01.00.00.00.00.00.00.00.00.00.00.00.00.0
30.00.01.00.00.00.01.00.00.00.00.00.00.00.00.00.00.00.0
40.00.01.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.0

然后,和以前一样,再将其与movies合并起来:

movies_windic = movies.join(dummies.add_prefix('Genre_'))
movies_windic.head()
movie_idtitlegenresGenre_AnimationGenre_Children'sGenre_ComedyGenre_AdventureGenre_FantasyGenre_RomanceGenre_Drama...Genre_CrimeGenre_ThrillerGenre_HorrorGenre_Sci-FiGenre_DocumentaryGenre_WarGenre_MusicalGenre_MysteryGenre_Film-NoirGenre_Western
01Toy Story (1995)Animation|Children's|Comedy1.01.01.00.00.00.00.0...0.00.00.00.00.00.00.00.00.00.0
12Jumanji (1995)Adventure|Children's|Fantasy0.01.00.01.01.00.00.0...0.00.00.00.00.00.00.00.00.00.0
23Grumpier Old Men (1995)Comedy|Romance0.00.01.00.00.01.00.0...0.00.00.00.00.00.00.00.00.00.0
34Waiting to Exhale (1995)Comedy|Drama0.00.01.00.00.00.01.0...0.00.00.00.00.00.00.00.00.00.0
45Father of the Bride Part II (1995)Comedy0.00.01.00.00.00.00.0...0.00.00.00.00.00.00.00.00.00.0

5 rows × 21 columns

movies_windic.iloc[0]
movie_id                                       1
title                           Toy Story (1995)
genres               Animation|Children's|Comedy
Genre_Animation                                1
Genre_Children's                               1
Genre_Comedy                                   1
Genre_Adventure                                0
Genre_Fantasy                                  0
Genre_Romance                                  0
Genre_Drama                                    0
Genre_Action                                   0
Genre_Crime                                    0
Genre_Thriller                                 0
Genre_Horror                                   0
Genre_Sci-Fi                                   0
Genre_Documentary                              0
Genre_War                                      0
Genre_Musical                                  0
Genre_Mystery                                  0
Genre_Film-Noir                                0
Genre_Western                                  0
Name: 0, 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]
bins  = [i/10 for i in range(0, 11, 2)]
bins
[0.0, 0.2, 0.4, 0.6, 0.8, 1.0]
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]
000001
101000
210000
301000
400100
500100
600001
700010
800010
900010

我们用numpy.random.seed,使这个例子具有确定性。本书后面会介绍pandas.get_dummies。

字符串操作

Python能够成为流行的数据处理语言,部分原因是其简单易用的字符串和文本处理功能。大部分文本运算都直接做成了字符串对象的内置方法。对于更为复杂的模式匹配和文本操作,则可能需要用到正则表达式。pandas对此进行了加强,它使你能够对整组数据应用字符串表达式和正则表达式,而且能处理烦人的缺失数据。

字符串对象方法

对于许多字符串处理和脚本应用,内置的字符串方法已经能够满足要求了。例如,以逗号分隔的字符串可以用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                                Traceback (most recent call last)

<ipython-input-130-2c016e7367ac> in <module>
----> 1 val.index(':')


ValueError: substring not found

与此相关,count可以返回指定子串的出现次数:

 val.count(',')
2

replace用于将指定模式替换为另一个模式。通过传入空字符串,它也常常用于删除模式:

val.replace(',', '::')
'a::b::  guido'
val.replace(',', '')
'ab  guido'

这些运算大部分都能使用正则表达式实现(马上就会看到)

列出了Python内置的字符串方法。

方法描述
string.capitalize()把字符串的第一个字符大写
string.casefold()包括英文和其它一些语言里面大写字母转换为小写
string.center(width)返回一个原字符串居中,并使用空格填充至长度 width 的新字符串
string.count(str, beg=0, end=len(string))返回 str 在 string 里面出现的次数,如果 beg 或者 end 指定则返回指定范围内 str 出现的次数
bytes.decode(encoding=‘UTF-8’, errors=‘strict’)以 encoding 指定的编码格式解码 string,如果出错默认报一个 ValueError 的 异 常 , 除非 errors 指 定 的 是 ‘ignore’ 或 者’replace’
string.encode(encoding=‘UTF-8’, errors=‘strict’)以 encoding 指定的编码格式编码 string,如果出错默认报一个ValueError 的异常,除非 errors 指定的是’ignore’或者’replace’
string.endswith(obj, beg=0, end=len(string))检查字符串是否以 obj 结束,如果beg 或者 end 指定则检查指定的范围内是否以 obj 结束,如果是,返回 True,否则返回 False.
string.expandtabs(tabsize=8)把字符串 string 中的 tab 符号转为空格,tab 符号默认的空格数是 8
string.find(str, beg=0, end=len(string))检测 str 是否包含在 string 中,如果 beg 和 end 指定范围,则检查是否包含在指定范围内,如果是返回开始的索引值,否则返回-1
string.format()格式化字符串
string.index(str, beg=0, end=len(string))跟find()方法一样,只不过如果str不在 string中会报一个异常
string.isalnum()如果 string 至少有一个字符并且所有字符都是字母或数字则返回 True,否则返回 False
string.isalpha()如果 string 至少有一个字符并且所有字符都是字母则返回 True,否则返回 False
string.isdecimal()如果 string 只包含十进制数字则返回 True 否则返回 False.
string.isdigit()如果 string 只包含数字则返回 True 否则返回 False.
string.isidentifier()判读是不是一个合法的标识符(是不是合法的变量名)
string.islower()如果 string 中包含至少一个区分大小写的字符,并且所有这些(区分大小写的)字符都是小写,则返回 True,否则返回 False
string.isnumeric()如果 string 中只包含数字字符,则返回 True,否则返回 False
string.isspace()如果 string 中只包含空格,则返回 True,否则返回 False.
string.istitle()如果 string 是标题化的(见 title())则返回 True,否则返回 False
string.isupper()如果 string 中包含至少一个区分大小写的字符,并且所有这些(区分大小写的)字符都是大写,则返回 True,否则返回 False
string.join(seq)以 string 作为分隔符,将 seq 中所有的元素(的字符串表示)合并为一个新的字符串
string.ljust(width)返回一个原字符串左对齐,并使用空格填充至长度 width 的新字符串
string.lower()转换 string 中所有大写字符为小写.
string.lstrip()截掉 string 左边的空格
string.maketrans(intab, outtab])maketrans() 方法用于创建字符映射的转换表,对于接受两个参数的最简单的调用方式,第一个参数是字符串,表示需要转换的字符,第二个参数也是字符串表示转换的目标
max(str)返回字符串 str 中最大的字母
min(str)返回字符串 str 中最小的字母
string.partition(str)有点像 find()和 split()的结合体,从 str 出现的第一个位置起,把 字 符 串 string 分 成 一 个 3 元 素 的 元 组 (string_pre_str,str,string_post_str),如果 string 中不包含str 则 string_pre_str == string.
string.replace(str1, str2, num=string.count(str1))把 string 中的 str1 替换成 str2,如果 num 指定,则替换不超过 num 次
string.rfind(str, beg=0,end=len(string))类似于 find()函数,不过是从右边开始查找.
string.rindex( str, beg=0,end=len(string))类似于 index(),不过是从右边开始.
string.rjust(width)返回一个原字符串右对齐,并使用空格填充至长度 width 的新字符串
string.rpartition(str)类似于 partition()函数,不过是从右边开始查找
string.rstrip()删除 string 字符串末尾的空格.
string.split(str="", num=string.count(str))以 str 为分隔符切片 string,如果 num有指定值,则仅分隔 num 个子字符串
string.splitlines([keepends])按照行(’\r’, ‘\r\n’, \n’)分隔,返回一个包含各行作为元素的列表,如果参数 keepends 为 False,不包含换行符,如果为 True,则保留换行符
string.startswith(obj, beg=0,end=len(string))检查字符串是否是以 obj 开头,是则返回 True,否则返回 False。如果beg 和 end 指定值,则在指定范围内检查
string.strip([obj])在 string 上执行 lstrip()和 rstrip()
string.swapcase()翻转 string 中的大小写
string.title()返回"标题化"的 string,就是说所有单词都是以大写开始,其余字母均为小写(见 istitle())
string.translate(str, del="")根据 str 给出的表(包含 256 个字符)转换 string 的字符,要过滤掉的字符放到 del 参数中
string.upper()转换 string 中的小写字母为大写
string.zfill(width)返回长度为 width 的字符串,原字符串 string 右对齐,前面填充0

casefold 将字符转换为小写,并将任何特定区域的变量字符组合转换成一个通用的可比较形式。

正则表达式

正则表达式提供了一种灵活的在文本中搜索或匹配(通常比前者复杂)字符串模式的方式。正则表达式,常称作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
"""
pattern1 = r'[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}'
pattern2 = r'[\w._%+-]+@[\w.-]+\.[A-Z]{2,4}'

# re.IGNORECASE makes the regex case-insensitive
regex = re.compile(pattern2, flags=re.IGNORECASE)

对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('wesm@bright.net')

m.groups()
('wesm', 'bright', 'net')

对于带有分组功能的模式,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中还有许多的正则表达式,但大部分都超出了本书的范围

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
'([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.findall(pattern, flags=re.IGNORECASE)
matches
Dave     [(dave, google, com)]
Steve    [(steve, gmail, com)]
Rob        [(rob, gmail, com)]
Wes                        NaN
dtype: object

要访问嵌入列表中的元素,我们可以传递索引到这两个函数中:

 matches.str.get(0)
Dave     (dave, google, com)
Steve    (steve, gmail, com)
Rob        (rob, gmail, com)
Wes                      NaN
dtype: object
matches.str[0]
Dave     (dave, google, com)
Steve    (steve, gmail, com)
Rob        (rob, gmail, com)
Wes                      NaN
dtype: object

你可以利用这种方法对字符串进行截取:

data.str[:5]
Dave     dave@
Steve    steve
Rob      rob@g
Wes        NaN
dtype: object

介绍了更多的pandas字符串方法。

方法说明
cat
count返回匹配模式出现的次数
contains返回一个是否包含匹配模式的布尔数组
extract使用带分组的正则表达式从字符串Series提取一个或多个字符串,结果是一个DataFrame,每组有一列
endswith相当于对每个元素执行x.endswith(pattern)
startswith相当于对每个元素执行x.startswith(pattern)
findall计算各字符串的模式列表
get获取各元素的第i个字符
isalnum相当于内置的str.alnum
isalpha相当于内置的str.isalpha
isdecimal相当于内置的str.decimal
isdigit相当于内置的str.digit
islower相当于内置的str.lower
isnumeric相当于内置的str.numeric
isupper相当于内置的str.upper
join根据指定的分隔符将Series中各元素的字符串连接起来
len计算各字符串长度
lower,upper转换大小写,相当于对各个元素执行
match根据指定的正则表达式对各个元素执行re.match
pad在字符串的左边和右边或两边添加空白符
center相当于pad(side=“both”)
repeat重复值,例如s.str.repeat(3)相当于对各个字符串执行x*3
replace用指定字符串替换找到的模式
slice对Series中的各个字符串进行子串截取
split根据分隔符或正则表达式对字符串进行拆分
strip去除两边的空白符
rstrip去除右边的空白符
lstrip去除左边的空白符
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值