在完成数据加载之后,我们可能需要对事实表和维度表进行连接,这是对数据进行多维度拆解的基础;我们可能从不同的数据源加载了结构相同的数据,我们需要将这些数据拼接起来;我们把这些操作统称为数据重塑。当然,由于企业的信息化水平以及数据中台建设水平的差异,我们拿到的数据未必是质量很好的,可能还需要对数据中的缺失值、重复值、异常值进行适当的处理。即便我们获取的数据在质量上是没有问题的,但也可能需要对数据进行一系列的预处理,才能满足我们做数据分析的需求。接下来,我们就为大家讲解和梳理这方面的知识。
数据重塑
有的时候,我们做数据分析需要的原始数据可能并不是来自一个地方,就像上一章的例子中,我们从关系型数据库中读取了三张表,得到了三个DataFrame
对象,但实际工作可能需要我们把他们的数据整合到一起。例如:emp_df
和emp2_df
其实都是员工的数据,而且数据结构完全一致,我们可以使用pandas
提供的concat
函数实现两个或多个DataFrame
的数据拼接,代码如下所示。
1 | all_emp_df = pd.concat([emp_df, emp2_df]) |
输出:
1 | ename job mgr sal comm dno |
上面的代码将两个代表员工数据的DataFrame
拼接到了一起,接下来我们使用merge
函数将员工表和部门表的数据合并到一张表中,代码如下所示。
先使用reset_index
方法重新设置all_emp_df
的索引,这样eno
不再是索引而是一个普通列,reset_index
方法的inplace
参数设置为True
表示,重置索引的操作直接在all_emp_df
上执行,而不是返回修改后的新对象。
1 | all_emp_df.reset_index(inplace=True) |
通过merge
函数合并数据,当然,也可以调用DataFrame
对象的merge
方法来达到同样的效果。
1 | pd.merge(all_emp_df, dept_df, how='inner', on='dno') |
输出:
1 | eno ename job mgr sal comm dno dname dloc |
merge
函数的一个参数代表合并的左表、第二个参数代表合并的右表,有SQL编程经验的同学对这两个词是不是感觉到非常亲切。正如大家猜想的那样,DataFrame
对象的合并跟数据库中的表连接非常类似,所以上面代码中的how
代表了合并两张表的方式,有left
、right
、inner
、outer
四个选项;而on
则代表了基于哪个列实现表的合并,相当于 SQL 表连接中的连表条件,如果左右两表对应的列列名不同,可以用left_on
和right_on
参数取代on
参数分别进行指定。
如果对上面的代码稍作修改,将how
参数修改为'right'
,大家可以思考一下代码执行的结果。
1 | pd.merge(all_emp_df, dept_df, how='right', on='dno') |
运行结果比之前的输出多出了如下所示的一行,这是因为how='right'
代表右外连接,也就意味着右表dept_df
中的数据会被完整的查出来,但是在all_emp_df
中又没有编号为40
部门的员工,所以对应的位置都被填入了空值。
1 | 19 NaN NaN NaN NaN NaN NaN 40 运维部 深圳 |
数据清洗
通常,我们从 Excel、CSV 或数据库中获取到的数据并不是非常完美的,里面可能因为系统或人为的原因混入了重复值或异常值,也可能在某些字段上存在缺失值;再者,DataFrame
中的数据也可能存在格式不统一、量纲不统一等各种问题。因此,在开始数据分析之前,对数据进行清洗就显得特别重要。
缺失值
可以使用DataFrame
对象的isnull
或isna
方法来找出数据表中的缺失值,如下所示。
1 | emp_df.isnull() |
或者
1 | emp_df.isna() |
输出:
1 | ename job mgr sal comm dno |
相对应的,notnull
和notna
方法可以将非空的值标记为True
。如果想删除这些缺失值,可以使用DataFrame
对象的dropna
方法,该方法的axis
参数可以指定沿着0轴还是1轴删除,也就是说当遇到空值时,是删除整行还是删除整列,默认是沿0轴进行删除的,代码如下所示。
1 | emp_df.dropna() |
输出:
1 | ename job mgr sal comm dno |
如果要沿着1轴进行删除,可以使用下面的代码。
1 | emp_df.dropna(axis=1) |
输出:
1 | ename job sal dno |
注意:
DataFrame
对象的很多方法都有一个名为inplace
的参数,该参数的默认值为False
,表示我们的操作不会修改原来的DataFrame
对象,而是将处理后的结果通过一个新的DataFrame
对象返回。如果将该参数的值设置为True
,那么我们的操作就会在原来的DataFrame
上面直接修改,方法的返回值为None
。简单的说,上面的操作并没有修改emp_df
,而是返回了一个新的DataFrame
对象。
在某些特定的场景下,我们可以对空值进行填充,对应的方法是fillna
,填充空值时可以使用指定的值(通过value
参数进行指定),也可以用表格中前一个单元格(通过设置参数method=ffill
)或后一个单元格(通过设置参数method=bfill
)的值进行填充,当代码如下所示。
1 | emp_df.fillna(value=0) |
注意:填充的值如何选择也是一个值得探讨的话题,实际工作中,可能会使用某种统计量(如:均值、众数等)进行填充,或者使用某种插值法(如:随机插值法、拉格朗日插值法等)进行填充,甚至有可能通过回归模型、贝叶斯模型等对缺失数据进行填充。
输出:
1 | ename job mgr sal comm dno |
重复值
接下来,我们先给之前的部门表添加两行数据,让部门表中名为“研发部”和“销售部”的部门各有两个。
1 | dept_df.loc[50] = {'dname': '研发部', 'dloc': '上海'} |
输出:
1 | dname dloc |
现在,我们的数据表中有重复数据了,我们可以通过DataFrame
对象的duplicated
方法判断是否存在重复值,该方法在不指定参数时默认判断行索引是否重复,我们也可以指定根据部门名称dname
判断部门是否重复,代码如下所示。
1 | dept_df.duplicated('dname') |
输出:
1 | dno |
从上面的输出可以看到,50
和60
两个部门从部门名称上来看是重复的,如果要删除重复值,可以使用drop_duplicates
方法,该方法的keep
参数可以控制在遇到重复值时,保留第一项还是保留最后一项,或者多个重复项一个都不用保留,全部删除掉。
1 | dept_df.drop_duplicates('dname') |
输出:
1 | dname dloc |
将keep
参数的值修改为last
。
1 | dept_df.drop_duplicates('dname', keep='last') |
输出:
1 | dname dloc |
使用同样的方式,我们也可以清除all_emp_df
中的重复数据,例如我们认定“ename”和“job”两个字段完全相同的就是重复数据,我们可以用下面的代码去除重复数据。
1 | all_emp_df.drop_duplicates(['ename', 'job'], inplace=True) |
说明:上面的
drop_duplicates
方法添加了参数inplace=True
,该方法不会返回新的DataFrame
对象,而是在原来的DataFrame
对象上直接删除,大家可以查看all_emp_df
看看是不是已经移除了重复的员工数据。
异常值
异常值在统计学上的全称是疑似异常值,也称作离群点(outlier),异常值的分析也称作离群点分析。异常值是指样本中出现的“极端值”,数据值看起来异常大或异常小,其分布明显偏离其余的观测值。实际工作中,有些异常值可能是由系统或人为原因造成的,但有些异常值却不是,它们能够重复且稳定的出现,属于正常的极端值,例如很多游戏产品中头部玩家的数据往往都是离群的极端值。所以,我们既不能忽视异常值的存在,也不能简单地把异常值从数据分析中剔除。重视异常值的出现,分析其产生的原因,常常成为发现问题进而改进决策的契机。
异常值的检测有Z-score 方法、IQR 方法、DBScan 聚类、孤立森林等,这里我们对前两种方法做一个简单的介绍。
如果数据服从正态分布,依据3σ法则,异常值被定义与平均值的偏差超过三倍标准差的值。在正态分布下,距离平均值3σ之外的值出现的概率为$ P(|x-\mu|>3\sigma)<0.003 $,属于小概率事件。如果数据不服从正态分布,那么可以用远离均值的多少倍的标准差来描述,这里的倍数就是Z-score。Z-score以标准差为单位去度量某一原始分数偏离平均值的距离,公式如下所示。
$$
z = \frac {X - \mu} {\sigma} \
|z| > 3
$$
Z-score需要根据经验和实际情况来决定,通常把远离标准差3
倍距离以上的数据点视为离群点,下面的代给出了如何通过Z-score方法检测异常值。
1 | def detect_outliers_zscore(data, threshold=3): |
IQR 方法中的IQR(Inter-Quartile Range)代表四分位距离,即上四分位数(Q3)和下四分位数(Q1)的差值。通常情况下,可以认为小于 $ Q1 - 1.5 \times IQR $ 或大于 $ Q3 + 1.5 \times IQR $ 的就是异常值,而这种检测异常值的方法也是箱线图(后面会讲到)默认使用的方法。下面的代码给出了如何通过 IQR 方法检测异常值。
1 | def detect_outliers_iqr(data, whis=1.5): |
如果要删除异常值,可以使用DataFrame
对象的drop
方法,该方法可以根据行索引或列索引删除指定的行或列。例如我们认为月薪低于2000
或高于8000
的是员工表中的异常值,可以用下面的代码删除对应的记录。
1 | emp_df.drop(emp_df[(emp_df.sal > 8000) | (emp_df.sal < 2000)].index) |
如果要替换掉异常值,可以通过给单元格赋值的方式来实现,也可以使用replace
方法将指定的值替换掉。例如我们要将月薪为1800
和9000
的替换为月薪的平均值,补贴为800
的替换为1000
,代码如下所示。
1 | avg_sal = np.mean(emp_df.sal).astype(int) |
预处理
对数据进行预处理也是一个很大的话题,它包含了对数据的拆解、变换、归约、离散化等操作。我们先来看看数据的拆解。如果数据表中的数据是一个时间日期,我们通常都需要从年、季度、月、日、星期、小时、分钟等维度对其进行拆解,如果时间日期是用字符串表示的,可以先通过pandas
的to_datetime
函数将其处理成时间日期。
在下面的例子中,我们先读取 Excel 文件,获取到一组销售数据,其中第一列就是销售日期,我们将其拆解为“月份”、“季度”和“星期”,代码如下所示。
1 | sales_df = pd.read_excel( |
说明:上面代码中使用了相对路径来获取 Excel 文件,也就是说 Excel 文件在当前工作路径下名为
data
的文件夹中。如果需要上面例子中的 Excel 文件,可以通过下面的百度云盘地址进行获取。链接:https://pan.baidu.com/s/1rQujl5RQn9R7PadB2Z5g_g,提取码:e7b4。
输出:
1 | <class 'pandas.core.frame.DataFrame'> |
1 | sales_df['月份'] = sales_df['销售日期'].dt.month |
输出:
1 | 销售日期 销售区域 销售渠道 品牌 销售额 月份 季度 星期 |
在上面的代码中,通过日期时间类型的Series
对象的dt
属性,获得一个访问日期时间的对象,通过该对象的year
、month
、quarter
、hour
等属性,就可以获取到年、月、季度、小时等时间信息,获取到的仍然是一个Series
对象,它包含了一组时间信息,所以我们通常也将这个dt
属性称为“日期时间向量”。
我们再来说一说字符串类型的数据的处理,我们先从指定的 Excel 文件中读取某招聘网站的招聘数据。
1 | jobs_df = pd.read_csv( |
说明:上面代码中使用了相对路径来获取 CSV 文件,也就是说 CSV 文件在当前工作路径下名为
data
的文件夹中。如果需要上面例子中的 CSV 文件,可以通过下面的百度云盘地址进行获取。链接:https://pan.baidu.com/s/1rQujl5RQn9R7PadB2Z5g_g,提取码:e7b4。
输出:
1 | <class 'pandas.core.frame.DataFrame'> |
查看前5
条数据。
1 | jobs_df.head() |
输出:
1 | city companyFullName positionName salary |
上面的数据表一共有3140
条数据,但并非所有的职位都是“数据分析”的岗位,如果要筛选出数据分析的岗位,可以通过检查positionName
字段是否包含“数据分析”这个关键词,这里需要模糊匹配,应该如何实现呢?我们可以先获取positionName
列,因为这个Series
对象的dtype
是字符串,所以可以通过str
属性获取对应的字符串向量,然后就可以利用我们熟悉的字符串的方法来对其进行操作,代码如下所示。
1 | jobs_df = jobs_df[jobs_df.positionName.str.contains('数据分析')] |
输出:
1 | (1515, 4) |
可以看出,筛选后的数据还有1515
条。接下来,我们还需要对salary
字段进行处理,如果我们希望统计所有岗位的平均工资或每个城市的平均工资,首先需要将用范围表示的工资处理成其中间值,代码如下所示。
1 | jobs_df.salary.str.extract(r'(\d+)[kK]?-(\d+)[kK]?') |
说明:上面的代码通过正则表达式捕获组从字符串中抽取出两组数字,分别对应工资的下限和上限,对正则表达式不熟悉的读者,可以阅读我的知乎专栏“从零开始学Python”中的《正则表达式的应用》一文。
输出:
1 | 0 1 |
需要提醒大家的是,抽取出来的两列数据都是字符串类型的值,我们需要将其转换成int
类型,才能计算平均值,对应的方法是DataFrame
对象的applymap
方法,该方法的参数是一个函数,而该函数会作用于DataFrame
中的每个元素。完成这一步之后,我们就可以使用apply
方法将上面的DataFrame
处理成中间值,apply
方法的参数也是一个函数,可以通过指定axis
参数使其作用于DataFrame
对象的行或列,代码如下所示。
1 | temp_df = jobs_df.salary.str.extract(r'(\d+)[kK]?-(\d+)[kK]?').applymap(int) |
输出:
1 | 0 22.5 |
接下来,我们可以用上面的结果替换掉原来的salary
列或者增加一个新的列来表示职位对应的工资,完整的代码如下所示。
1 | temp_df = jobs_df.salary.str.extract(r'(\d+)[kK]?-(\d+)[kK]?').applymap(int) |
输出:
1 | city companyFullName positionName salary |
applymap
和apply
两个方法在数据预处理的时候经常用到,Series
对象也有apply
方法,也是用于数据的预处理,但是DataFrame
对象还有一个名为transform
的方法,也是通过传入的函数对数据进行变换,类似Series
对象的map
方法。需要强调的是,apply
方法具有归约效果的,简单的说就是能将较多的数据处理成较少的数据或一条数据;而transform
方法没有归约效果,只能对数据进行变换,原来有多少条数据,处理后还是有多少条数据。
如果要对数据进行深度的分析和挖掘,字符串、日期时间这样的非数值类型都需要处理成数值,因为非数值类型没有办法计算相关性,也没有办法进行$\chi^2$检验等操作。对于字符串类型,通常可以其分为以下三类,再进行对应的处理。
- 有序变量(Ordinal Variable):字符串表示的数据有顺序关系,那么可以对字符串进行序号化处理。
- 分类变量(Categorical Variable)/ 名义变量(Nominal Variable):字符串表示的数据没有大小关系和等级之分,那么就可以使用独热编码的方式处理成哑变量(虚拟变量)矩阵。
- 定距变量(Scale Variable):字符串本质上对应到一个有大小高低之分的数据,而且可以进行加减运算,那么只需要将字符串处理成对应的数值即可。
对于第1类和第3类,我们可以用上面提到的apply
或transform
方法来处理,也可以利用scikit-learn
中的OrdinalEncoder
处理第1类字符串,这个我们在后续的课程中会讲到。对于第2类字符串,可以使用pandas
的get_dummies()
函数来生成哑变量(虚拟变量)矩阵,代码如下所示。
1 | persons_df = pd.DataFrame( |
输出:
1 | 姓名 职业 学历 |
将职业处理成哑变量矩阵。
1 | pd.get_dummies(persons_df['职业']) |
输出:
1 | 医生 教师 画家 程序员 |
将学历处理成大小不同的值。
1 | def handle_education(x): |
输出:
1 | 0 10 |
我们再来说说数据离散化。离散化也叫分箱,如果变量的取值是连续值,那么它的取值有无数种可能,在进行数据分组的时候就会非常的不方便,这个时候将连续变量离散化就显得非常重要。之所以把离散化叫做分箱,是因为我们可以预先设置一些箱子,每个箱子代表了数据取值的范围,这样就可以将连续的值分配到不同的箱子中,从而实现离散化。下面的例子读取了2018年北京积分落户数据,我们可以根据落户积分对数据进行分组,具体的做法如下所示。
1 | luohu_df = pd.read_csv('data/2018年北京积分落户数据.csv', index_col='id') |
输出:
1 | count 6019.000000 |
可以看出,落户积分的最大值是122.59
,最小值是90.75
,那么我们可以构造一个从90
分到125
分,每5
分一组的7
个箱子,pandas
的cut
函数可以帮助我们首先数据分箱,代码如下所示。
1 | bins = np.arange(90, 126, 5) |
说明:
cut
函数的right
参数默认值为True
,表示箱子左开右闭;修改为False
可以让箱子的右边界为开区间,左边界为闭区间,大家看看下面的输出就明白了。
输出:
1 | id |
我们可以根据分箱的结果对数据进行分组,然后使用聚合函数对每个组进行统计,这是数据分析中经常用到的操作,下一个章节会为大家介绍。除此之外,pandas
还提供了一个名为qcut
的函数,可以指定分位数对数据进行分箱,有兴趣的读者可以自行研究。