数据分析-pandas
Pandas 是 Wes McKinney 在2008年开发的一个强大的分析结构化数据的工具集。Pandas 以 NumPy 为基础(实现数据存储和运算),提供了专门用于数据分析的类型、方法和函数,对数据分析和数据挖掘提供了很好的支持;同时 pandas 还可以跟数据可视化工具 matplotlib 很好的整合在一起,非常轻松愉快的实现数据可视化呈现。
Pandas 核心的数据类型是Series
(数据系列)、DataFrame
(数据窗/数据框),分别用于处理一维和二维的数据,除此之外,还有一个名为Index
的类型及其子类型,它们为Series
和DataFrame
提供了索引功能。日常工作中DataFrame
使用得最为广泛,因为二维的数据结构刚好可以对应有行有列的表格。Series
和DataFrame
都提供了大量的处理数据的方法,数据分析师以此为基础,可以实现对数据的筛选、合并、拼接、清洗、预处理、聚合、透视和可视化等各种操作
Series
创建Series对象
方式1:
Pandas 库中的Series
对象可以用来表示一维数据结构,但是多了索引和一些额外的功能。Series
类型的内部结构包含了两个数组,其中一个用来保存数据,另一个用来保存数据的索引。我们可以通过列表或数组创建Series
对象,代码如下所示。
import pandas as pd
ser1 = pd.Series(data=[120, 380, 250, 360], index=['一季度', '二季度', '三季度', '四季度'])
print(ser1)
#Series构造器中的data参数表示数据,index参数表示数据的索引,相当于数据对应的标签。
输出
一季度 120
二季度 380
三季度 250
四季度 360
dtype: int64
方式2:
python
ser2 = pd.Series({'一季度': 320, '二季度': 180, '三季度': 300, '四季度': 405})
print(ser2)
# 通过字典创建Series对象时,字典的键就是数据的标签(索引),键对应的值就是数据。
series对象的运算
标量运算
我们尝试给刚才的ser1
每个季度加上10
,
ser2 = pd.Series({'一季度': 320, '二季度': 180, '三季度': 300, '四季度': 405})
print(f"series===={ser2}")
print("---------------")
ser2 += 10
print(ser2)
输出
series====一季度 320
二季度 180
三季度 300
四季度 405
dtype: int64
---------------
一季度 330
二季度 190
三季度 310
四季度 415
dtype: int64
矢量运算
我们尝试把ser1
和ser2
对应季度的数据加起来
ser1 = pd.Series(data=[120, 380, 250, 360], index=['一季度', '二季度', '三季度', '四季度'])
ser2 = pd.Series({'一季度': 320, '二季度': 180, '三季度': 300, '四季度': 405})
ser3 = pd.Series(data=[120, 380, 250, 360], index=['一季度', '二季度', '三季度', '五季度'])
print(ser1+ser2)
print(ser1 + ser3)
输出
一季度 440
二季度 560
三季度 550
四季度 765
dtype: int64
一季度 240.0
三季度 500.0
二季度 760.0
五季度 NaN
四季度 NaN
dtype: float64
索引运算
普通索引
跟数组一样,Series
对象也可以进行索引和切片操作,不同的是Series
对象因为内部维护了一个保存索引的数组,所以除了可以使用整数索引检索数据外,还可以通过自己设置的索引(标签)获取对应的数据。
使用整数索引或者自定义索引。
ser1 = pd.Series(data=[120, 380, 250, 360], index=['一季度', '二季度', '三季度', '四季度'])
print(ser1)
print(ser1[3])
print(ser1['四季度'])
输出
一季度 120
二季度 380
三季度 250
四季度 360
dtype: int64
360
360
切片索引
Series
对象的切片操作跟列表、数组类似,通过给出起始和结束索引,从原来的Series
对象中取出或修改部分数据,这里也可以使用整数索引和自定义的索引,代码如下所示。
ser1 = pd.Series(data=[120, 380, 250, 360], index=['一季度', '二季度', '三季度', '四季度'])
print(ser1)
print(ser1[1:3])
输出
一季度 120
二季度 380
三季度 250
四季度 360
dtype: int64
二季度 380
三季度 250
dtype: int64
花式索引
ser1 = pd.Series(data=[120, 380, 250, 360], index=['一季度', '二季度', '三季度', '四季度'])
print(ser1)
print(ser1[[0,2]])
print(ser1[['一季度', '三季度']])
输出
一季度 120
二季度 380
三季度 250
四季度 360
dtype: int64
一季度 120
三季度 250
dtype: int64
一季度 120
三季度 250
dtype: int64
布尔索引
ser1 = pd.Series(data=[120, 380, 250, 360], index=['一季度', '二季度', '三季度', '四季度'])
print(ser1[ser1>300])
属性与方法
Series
对象的属性和方法非常多,我们就捡着重要的跟大家讲吧。先看看下面的表格,它展示了Series
对象常用的属性。
属性 | 说明 |
---|---|
dtype / dtypes |
返回Series 对象的数据类型 |
hasnans |
判断Series 对象中有没有空值 |
at / iat |
通过索引访问Series 对象中的单个值 |
loc / iloc |
通过索引访问Series 对象中的单个值或一组值 |
index |
返回Series 对象的索引(Index 对象) |
is_monotonic |
判断Series 对象中的数据是否单调 |
is_monotonic_increasing |
判断Series 对象中的数据是否单调递增 |
is_monotonic_decreasing |
判断Series 对象中的数据是否单调递减 |
is_unique |
判断Series 对象中的数据是否独一无二 |
size |
返回Series 对象中元素的个数 |
values |
以ndarray 的方式返回Series 对象中的值(ndarray 对象) |
ser1 = pd.Series(data=[120, 380, 250, 360], index=['一季度', '二季度', '三季度', '四季度'])
print(ser1.dtype) # 数据类型
print(ser1.hasnans) # 有没有空值
print(ser1.index) # 索引
print(ser1.values) # 值
print(ser1.is_monotonic_increasing) # 是否单调递增
print(ser1.is_unique) # 是否每个值都独一无二
输出
int64
False
Index(['一季度', '二季度', '三季度', '四季度'], dtype='object')
[120 380 250 360]
False
True
统计相关的方法
ser1 = pd.Series(data=[120, 380, 250, 360], index=['一季度', '二季度', '三季度', '四季度'])
print(ser1.count()) # 计数
print(ser1.sum()) # 求和
print(ser1.mean()) # 求平均
print(ser1.median()) # 找中位数
print(ser1.max()) # 找最大
print(ser1.min()) # 找最小
print(ser1.std()) # 求标准差
print(ser1.var()) # 求方差
# print(ser1.describe())可以获得上述所有的描述性统计信息
因为describe()
返回的也是一个Series
对象,所以也可以用ser2.describe()['mean']
来获取平均值,用ser2.describe()[['max', 'min']]
来获取最大值和最小值。
如果Series
对象有重复的值,我们可以使用unique()
方法获得由独一无二的值构成的数组;可以使用nunique()
方法统计不重复值的数量;如果想要统计每个值重复的次数,可以使用value_counts()
方法,这个方法会返回一个Series
对象,它的索引就是原来的Series
对象中的值,而每个值出现的次数就是返回的Series
对象中的数据,在默认情况下会按照出现次数做降序排列,如下所示。
处理数据
Series
对象的isna()
和isnull()
方法可以用于空值的判断,notna()
和notnull()
方法可以用于非空值的判断
Series
对象的dropna()
和fillna()
方法分别用来删除空值和填充空值,具体的用法如下所示。
需要提醒大家注意的是,dropna()
和fillna()
方法都有一个名为inplace
的参数,它的默认值是False
,表示删除空值或填充空值不会修改原来的Series
对象,而是返回一个新的Series
对象。如果将inplace
参数的值修改为True
,那么删除或填充空值会就地操作,直接修改原来的Series
对象,此时方法的返回值是None
。后面我们会接触到的很多方法,包括DataFrame
对象的很多方法都会有这个参数,它们的意义跟这里是一样的。
Series
对象的mask()
和where()
方法可以将满足或不满足条件的值进行替换,如下所示。
ser1 = pd.Series(data=[120, 380, 250, 360], index=['一季度', '二季度', '三季度', '四季度'])
print(ser1)
print(ser1.where(ser1>300, 1))
输出
一季度 120
二季度 380
三季度 250
四季度 360
dtype: int64
一季度 1
二季度 380
三季度 1
四季度 360
dtype: int64
Series
对象的duplicated()
方法可以帮助我们找出重复的数据,而drop_duplicates()
方法可以帮我们删除重复数据
Series
对象的apply()
和map()
方法非常重要,它们可以通过字典或者指定的函数来处理数据,把数据映射或转换成我们想要的样子。这两个方法在数据准备阶段非常重要,我们先来试一试这个名为map
的方法。
ser1 = pd.Series(['cat', 'dog', np.nan, 'rabbit'])
print(ser1)
print(ser1.map('I am a {}'.format, na_action='ignore')) # 将指定字符串的format方法作用到数据系列的数据上,忽略掉所有的空值。
print(ser1.map({'cat': 'kitten', 'dog': 'puppy'})) # 通过字典给出的映射规则对数据进行处理。
输出
0 cat
1 dog
2 NaN
3 rabbit
dtype: object
0 I am a cat
1 I am a dog
2 NaN
3 I am a rabbit
dtype: object
0 kitten
1 puppy
2 NaN
3 NaN
dtype: object
apply作用如下
ser2 = pd.Series([20, 21, 12], index=['London', 'New York', 'Helsinki'])
print(ser2)
print(ser2.apply(np.square)) # :将求平方的函数作用到数据系列的数据上,也可以将参数np.square替换为lambda x: x ** 2。
print(ser2.apply(lambda x: x-20))
print(ser2.apply(lambda x, value: x-value, args=(5,)))
# 上面apply方法中的lambda函数有两个参数,第一个参数是数据系列中的数据,而第二个参数需要我们传入,所以我们给apply方法增加了args参数,用于给lambda函数的第二个参数传值。
输出
London 20
New York 21
Helsinki 12
dtype: int64
London 400
New York 441
Helsinki 144
dtype: int64
London 0
New York 1
Helsinki -8
dtype: int64
London 15
New York 16
Helsinki 7
dtype: int64
Process finished with exit code 0
排序
Series
对象的sort_index()
和sort_values()
方法可以用于对索引和数据的排序,排序方法有一个名为ascending
的布尔类型参数,该参数用于控制排序的结果是升序还是降序;而名为kind
的参数则用来控制排序使用的算法,默认使用了quicksort
,也可以选择mergesort
或heapsort
;如果存在空值,那么可以用na_position
参数空值放在最前还是最后,默认是last
,
ser1 = pd.Series(
data=[35, 96, 12, 57, 25, 89],
index=['grape', 'banana', 'pitaya', 'apple', 'peach', 'orange'])
print(ser1.sort_values()) # 按值从小到大排序
输出
pitaya 12
peach 25
grape 35
apple 57
orange 89
banana 96
dtype: int64
如果要从Series
对象中找出元素中最大或最小的"Top-N",我们不需要对所有的值进行排序的,可以使用nlargest()
和nsmallest()
方法来完成,如下所示。
ser1.nlargest(3) # 值最大的3个
ser1.nsmallest(2) # 值最小的2个
绘制图表
Series
对象有一个名为plot
的方法可以用来生成图表,如果选择生成折线图、饼图、柱状图等,默认会使用Series
对象的索引作为横坐标,使用Series
对象的数据作为纵坐标。下面我们创建一个Series
对象并基于它绘制柱状图,代码如下所示。
代码:
import matplotlib.pyplot as plt
ser9 = pd.Series({'Q1': 400, 'Q2': 520, 'Q3': 180, 'Q4': 380})
# 通过plot方法的kind指定图表类型为柱状图
ser9.plot(kind='bar')
# 定制纵轴的取值范围
plt.ylim(0, 600)
# 定制横轴刻度(旋转到0度)
plt.xticks(rotation=0)
# 为柱子增加数据标签
for i in range(ser9.size):
plt.text(i, ser9[i] + 5, ser9[i], ha='center')
plt.show()
dataFrame
创建dataFrame对象
方式1: 通过二维数组创建DataFrame对象
scores = np.random.randint(60, 101, (5, 3))
courses = ['语文', '数学', '英语']
stu_ids = np.arange(1001, 1006)
df1 = pd.DataFrame(data=scores, columns=courses, index=stu_ids)
print(df1)
输出
语文 数学 英语
1001 97 78 86
1002 87 78 92
1003 61 75 87
1004 95 83 93
1005 98 73 61
方式2: 通过字典创建DataFrame对象
scores = {
'语文': [62, 72, 93, 88, 93],
'数学': [95, 65, 86, 66, 87],
'英语': [66, 75, 82, 69, 82],
}
stu_ids = np.arange(1001, 1006)
df2 = pd.DataFrame(data=scores, index=stu_ids)
print(df2)
方式3: 读取csv文件
可以通过pandas
模块的read_csv
函数来读取 CSV 文件,read_csv
函数的参数非常多,下面介绍几个比较重要的参数。
-
sep
/delimiter
:分隔符,默认是,
。 -
header
:表头(列索引)的位置,默认值是infer
,用第一行的内容作为表头(列索引)。 -
index_col
:用作行索引(标签)的列。 -
usecols
:需要加载的列,可以使用序号或者列名。 -
true_values
/false_values
:哪些值被视为布尔值True
/False
。 -
skiprows
:通过行号、索引或函数指定需要跳过的行。 -
skipfooter
:要跳过的末尾行数。 -
nrows
:需要读取的行数。 -
na_values
:哪些值被视为空值。 -
iterator
:设置为True
,函数返回迭代器对象。 -
chunksize
:配合上面的参数,设置每次迭代获取的数据体量。df3 = pd.read_csv('data/2018年北京积分落户数据.csv', index_col='id') df3
方式4:读取Excel工作表
可以通过pandas
模块的read_excel
函数来读取 Excel 文件,该函数与上面的read_csv
非常类似,多了一个sheet_name
参数来指定数据表的名称,但是不同于 CSV 文件,没有sep
或delimiter
这样的参数。假设有名为"2022年股票数据.xlsx"的 Excel 文件,里面有用股票代码命名的五个表单,分别是阿里巴巴(BABA)、百度(BIDU)、京东(JD)、亚马逊(AMZN)、甲骨文(ORCL)这五个公司2022年的股票数据,如果想加载亚马逊的股票数据,代码如下所示。
df4 = pd.read_excel('data/2022年股票数据.xlsx', sheet_name='AMZN', index_col='Date')
df4
方式5: 读取关系数据库二维表创建DataFrame对象
pandas
模块的read_sql
函数可以通过 SQL 语句从数据库中读取数据创建DataFrame
对象,该函数的第二个参数代表了需要连接的数据库。对于 MySQL 数据库,我们可以通过pymysql
或mysqlclient
来创建数据库连接(需要提前安装好三方库),得到一个Connection
对象,而这个对象就是read_sql
函数需要的第二个参数,代码如下所示。
import pymysql
# 创建一个MySQL数据库的连接对象
conn = pymysql.connect(
host='xxxxxxxx', port=3306,
user='xxx', password='xxxx',
database='xxx', charset='utf8mb4'
)
# 通过SQL从数据库二维表读取数据创建DataFrame
df5 = pd.read_sql('select * from tb_emp', conn, index_col='eno')
df5
属性与方法
如下有三个这样的dataFrame
#dept_df 部门表(dept_df),其中dno是部门的编号,dname和dloc分别是部门的名称和所在地。
dname dloc
dno
10 会计部 北京
20 研发部 成都
30 销售部 重庆
40 运维部 深圳
# emp_df 员工表(emp_df),其中eno是员工编号,ename、job、mgr、sal、comm和dno分别代表员工的姓名、职位、主管编号、月薪、补贴和部门编号。
ename job mgr sal comm dno
eno
1359 胡一刀 销售员 3344.0 1800 200.0 30
2056 乔峰 分析师 7800.0 5000 1500.0 20
3088 李莫愁 设计师 2056.0 3500 800.0 20
3211 张无忌 程序员 2056.0 3200 NaN 20
3233 丘处机 程序员 2056.0 3400 NaN 20
3244 欧阳锋 程序员 3088.0 3200 NaN 20
3251 张翠山 程序员 2056.0 4000 NaN 20
3344 黄蓉 销售主管 7800.0 3000 800.0 30
3577 杨过 会计 5566.0 2200 NaN 10
3588 朱九真 会计 5566.0 2500 NaN 10
4466 苗人凤 销售员 3344.0 2500 NaN 30
5234 郭靖 出纳 5566.0 2000 NaN 10
5566 宋远桥 会计师 7800.0 4000 1000.0 10
7800 张三丰 总裁 NaN 9000 1200.0 20
在数据库中mgr
和comm
两个列的数据类型是int
,但是因为有缺失值(空值),读取到DataFrame
之后,列的数据类型变成了float
,因为我们通常会用float
类型的NaN
来表示空值。
# emp2_df 跟上面的员工表结构相同,但是保存了不同的员工数据。
ename job mgr sal comm dno
eno
9500 张三丰 总裁 NaN 50000 8000 20
9600 王大锤 程序员 9800.0 8000 600 20
9700 张三丰 总裁 NaN 60000 6000 20
9800 骆昊 架构师 7800.0 30000 5000 20
9900 陈小刀 分析师 9800.0 10000 1200 20
DataFrame
对象的属性如下表所示。
属性名 | 说明 |
---|---|
at / iat |
通过标签获取DataFrame 中的单个值。 |
columns |
DataFrame 对象列的索引 |
dtypes |
DataFrame 对象每一列的数据类型 |
empty |
DataFrame 对象是否为空 |
loc / iloc |
通过标签获取DataFrame 中的一组值。 |
ndim |
DataFrame 对象的维度 |
shape |
DataFrame 对象的形状(行数和列数) |
size |
DataFrame 对象中元素的个数 |
values |
DataFrame 对象的数据对应的二维数组 |
关于DataFrame
的方法,首先需要了解的是info()
方法,它可以帮助我们了解DataFrame
的相关信息,如下所示。
<class 'pandas.core.frame.DataFrame'>
Int64Index: 4 entries, 10 to 40
Data columns (total 2 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 dname 4 non-null object
1 dloc 4 non-null object
dtypes: object(2)
memory usage: 96.0+ bytes
None
如果需要查看DataFrame
的头部或尾部的数据,可以使用head()
或tail()
方法,这两个方法的默认参数是5
,表示获取DataFrame
最前面5行或最后面5行的数据,如下所示。
emp_df.head()
emp_df.tail()
操作数据
索引和切片
如果要获取DataFrame
的某一列,例如取出上面emp_df
的ename
列,可以使用下面的两种方式。
print(emp_df.ename)
print(emp_df['ename'])
执行上面的代码可以发现,我们获得的是一个Series
对象。事实上,DataFrame
对象就是将多个Series
对象组合到一起的结果。
如果要获取DataFrame
的某一行,可以使用整数索引或我们设置的索引,例如取出员工编号为2056
的员工数据,代码如下所示。
emp_df.iloc[1]
通过执行上面的代码我们发现,单独取DataFrame
的某一行或某一列得到的都是Series
对象。我们当然也可以通过花式索引来获取多个行或多个列的数据,花式索引的结果仍然是一个DataFrame
对象。
获取多个列:
emp_df[['ename', 'job']]
获取多个行:
emp_df.loc[[2056, 7800, 3344]]
如果要获取或修改DataFrame
对象某个单元格的数据,需要同时指定行和列的索引,例如要获取员工编号为2056
的员工的职位信息,代码如下所示。
emp_df['job'][2056]
或者
emp_df.loc[2056]['job']
或者
emp_df.loc[2056, 'job']
我们推荐大家使用第三种做法,因为它只做了一次索引运算。如果要将该员工的职位修改为"架构师",可以使用下面的代码。
emp_df.loc[2056, 'job'] = '架构师'
当然,我们也可以通过切片操作来获取多行多列,相信大家一定已经想到了这一点。
emp_df.loc[2056:3344]
数据筛选
上面我们提到了花式索引,相信大家已经联想到了布尔索引。跟ndarray
和Series
一样,我们可以通过布尔索引对DataFrame
对象进行数据筛选,例如我们要从emp_df
中筛选出月薪超过3500
的员工,代码如下所示。
print(emp_df[emp_df.sal > 3500])
当然,我们也可以组合多个条件来进行数据筛选,例如从emp_df
中筛选出月薪超过3500
且部门编号为20
的员工,代码如下所示。
print(emp_df[(emp_df.sal > 3500)&(emp_df.dno==20)])
除了使用布尔索引,DataFrame
对象的query
方法也可以实现数据筛选,query
方法的参数是一个字符串,它代表了筛选数据使用的表达式,而且更符合 Python 程序员的使用习惯。下面我们使用query
方法将上面的效果重新实现一遍,代码如下所示。
emp_df.query('sal > 3500 and dno == 20')
数据重塑
有的时候,我们做数据分析需要的原始数据可能并不是来自一个地方,就像上一章的例子中,我们从关系型数据库中读取了三张表,得到了三个DataFrame
对象,但实际工作可能需要我们把他们的数据整合到一起。例如:emp_df
和emp2_df
其实都是员工的数据,而且数据结构完全一致,我们可以使用pandas
提供的concat
函数实现两个或多个DataFrame
的数据拼接,代码如下所示。
all_emp_df = pd.concat([emp_df, emp2_df])
上面的代码将两个代表员工数据的DataFrame
拼接到了一起,接下来我们使用merge
函数将员工表和部门表的数据合并到一张表中,代码如下所示。
先使用reset_index
方法重新设置all_emp_df
的索引,这样eno
不再是索引而是一个普通列,reset_index
方法的inplace
参数设置为True
表示,重置索引的操作直接在all_emp_df
上执行,而不是返回修改后的新对象。
all_emp_df.reset_index(inplace=True)
通过merge
函数合并数据,当然,也可以调用DataFrame
对象的merge
方法来达到同样的效果。
pd.merge(all_emp_df, dept_df, how='inner', on='dno')
merge
函数的一个参数代表合并的左表、第二个参数代表合并的右表,有SQL编程经验的同学对这两个词是不是感觉到非常亲切。正如大家猜想的那样,DataFrame
对象的合并跟数据库中的表连接非常类似,所以上面代码中的how
代表了合并两张表的方式,有left
、right
、inner
、outer
四个选项;而on
则代表了基于哪个列实现表的合并,相当于 SQL 表连接中的连表条件,如果左右两表对应的列列名不同,可以用left_on
和right_on
参数取代on
参数分别进行指定。
如果对上面的代码稍作修改,将how
参数修改为'right'
,大家可以思考一下代码执行的结果。
数据清洗
通常,我们从 Excel、CSV 或数据库中获取到的数据并不是非常完美的,里面可能因为系统或人为的原因混入了重复值或异常值,也可能在某些字段上存在缺失值;再者,DataFrame
中的数据也可能存在格式不统一、量纲不统一等各种问题。因此,在开始数据分析之前,对数据进行清洗就显得特别重要。
可以使用DataFrame
对象的isnull
或isna
方法来找出数据表中的缺失值,如下所示。
emp_df.isnull()
或者
emp_df.isna()
相对应的,notnull
和notna
方法可以将非空的值标记为True
。如果想删除这些缺失值,可以使用DataFrame
对象的dropna
方法,该方法的axis
参数可以指定沿着0轴还是1轴删除,也就是说当遇到空值时,是删除整行还是删除整列,默认是沿0轴进行删除的,代码如下所示。
emp_df.dropna()
如果要沿着1轴进行删除,可以使用下面的代码。
emp_df.dropna(axis=1)
DataFrame
对象的很多方法都有一个名为inplace
的参数,该参数的默认值为False
,表示我们的操作不会修改原来的DataFrame
对象,而是将处理后的结果通过一个新的DataFrame
对象返回。如果将该参数的值设置为True
,那么我们的操作就会在原来的DataFrame
上面直接修改,方法的返回值为None
。简单的说,上面的操作并没有修改emp_df
,而是返回了一个新的DataFrame
对象。
在某些特定的场景下,我们可以对空值进行填充,对应的方法是fillna
,填充空值时可以使用指定的值(通过value
参数进行指定),也可以用表格中前一个单元格(通过设置参数method=ffill
)或后一个单元格(通过设置参数method=bfill
)的值进行填充,当代码如下所示。
emp_df.fillna(value=0)
注意:填充的值如何选择也是一个值得探讨的话题,实际工作中,可能会使用某种统计量(如:均值、众数等)进行填充,或者使用某种插值法(如:随机插值法、拉格朗日插值法等)进行填充,甚至有可能通过回归模型、贝叶斯模型等对缺失数据进行填充。
重复值
接下来,我们先给之前的部门表添加两行数据,让部门表中名为"研发部"和"销售部"的部门各有两个。
dept_df.loc[50] = {'dname': '研发部', 'dloc': '上海'}
dept_df.loc[60] = {'dname': '销售部', 'dloc': '长沙'}
现在,我们的数据表中有重复数据了,我们可以通过DataFrame
对象的duplicated
方法判断是否存在重复值,该方法在不指定参数时默认判断行索引是否重复,我们也可以指定根据部门名称dname
判断部门是否重复,代码如下所示。
dept_df.duplicated('dname')
从上面的输出可以看到,50
和60
两个部门从部门名称上来看是重复的,如果要删除重复值,可以使用drop_duplicates
方法,该方法的keep
参数可以控制在遇到重复值时,保留第一项还是保留最后一项,或者多个重复项一个都不用保留,全部删除掉。
使用同样的方式,我们也可以清除all_emp_df
中的重复数据,例如我们认定"ename"和"job"两个字段完全相同的就是重复数据,我们可以用下面的代码去除重复数据。
all_emp_df.drop_duplicates(['ename', 'job'], inplace=True)
面的drop_duplicates
方法添加了参数inplace=True
,该方法不会返回新的DataFrame
对象,而是在原来的DataFrame
对象上直接删除,大家可以查看all_emp_df
看看是不是已经移除了重复的员工数据。
异常值
异常值在统计学上的全称是疑似异常值,也称作离群点(outlier),异常值的分析也称作离群点分析。异常值是指样本中出现的"极端值",数据值看起来异常大或异常小,其分布明显偏离其余的观测值。实际工作中,有些异常值可能是由系统或人为原因造成的,但有些异常值却不是,它们能够重复且稳定的出现,属于正常的极端值,例如很多游戏产品中头部玩家的数据往往都是离群的极端值。所以,我们既不能忽视异常值的存在,也不能简单地把异常值从数据分析中剔除。重视异常值的出现,分析其产生的原因,常常成为发现问题进而改进决策的契机。
异常值的检测有Z-score 方法、IQR 方法、DBScan 聚类、孤立森林等,这里我们对前两种方法做一个简单的介绍。
Z-score方法检测异常值。
def detect_outliers_zscore(data, threshold=3):
avg_value = np.mean(data)
std_value = np.std(data)
z_score = np.abs((data - avg_value) / std_value)
return data[z_score > threshold]
IQR 方法检测异常值
IQR 方法中的IQR(Inter-Quartile Range)代表四分位距离,即上四分位数(Q3)和下四分位数(Q1)的差值。通常情况下,可以认为小于 $ Q1 - 1.5 \times IQR $ 或大于 $ Q3 + 1.5 \times IQR $ 的就是异常值,而这种检测异常值的方法也是箱线图(后面会讲到)默认使用的方法。下面的代码给出了如何通过 IQR 方法检测异常值。
def detect_outliers_iqr(data, whis=1.5):
q1, q3 = np.quantile(data, [0.25, 0.75])
iqr = q3 - q1
lower, upper = q1 - whis * iqr, q3 + whis * iqr
return data[(data < lower) | (data > upper)]
如果要删除异常值,可以使用DataFrame
对象的drop
方法,该方法可以根据行索引或列索引删除指定的行或列。例如我们认为月薪低于2000
或高于8000
的是员工表中的异常值,可以用下面的代码删除对应的记录。
emp_df.drop(emp_df[(emp_df.sal > 8000) | (emp_df.sal < 2000)].index)
如果要替换掉异常值,可以通过给单元格赋值的方式来实现,也可以使用replace
方法将指定的值替换掉。例如我们要将月薪为1800
和9000
的替换为月薪的平均值,补贴为800
的替换为1000
,代码如下所示。
avg_sal = np.mean(emp_df.sal).astype(int)
emp_df.replace({'sal': [1800, 9000], 'comm': 800}, {'sal': avg_sal, 'comm': 1000})
预处理
对数据进行预处理也是一个很大的话题,它包含了对数据的拆解、变换、归约、离散化等操作。我们先来看看数据的拆解。如果数据表中的数据是一个时间日期,我们通常都需要从年、季度、月、日、星期、小时、分钟等维度对其进行拆解,如果时间日期是用字符串表示的,可以先通过pandas
的to_datetime
函数将其处理成时间日期。
在下面的例子中,我们先读取 Excel 文件 2020年销售数据.xlsx ,获取到一组销售数据,其中第一列就是销售日期,我们将其拆解为"月份"、"季度"和"星期",代码如下所示。
sale_df = pd.read_excel(r"./dates/2020年销售数据.xlsx",usecols=['销售日期', '销售区域', '销售渠道', '品牌', '售价'])
print(sale_df.info())
输出
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1945 entries, 0 to 1944
Data columns (total 5 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 销售日期 1945 non-null datetime64[ns]
1 销售区域 1945 non-null object
2 销售渠道 1945 non-null object
3 品牌 1945 non-null object
4 售价 1945 non-null int64
dtypes: datetime64[ns](1), int64(1), object(3)
memory usage: 76.1+ KB
None
sale_df = pd.read_excel(r"./dates/2020年销售数据.xlsx",usecols=['销售日期', '销售区域', '销售渠道', '品牌', '售价'])
sale_df['月份'] = sale_df['销售日期'].dt.month
sale_df['季度'] = sale_df['销售日期'].dt.quarter
sale_df['星期'] = sale_df['销售日期'].dt.weekday
print(sale_df)
销售日期 销售区域 销售渠道 品牌 售价 月份 季度 星期
0 2020-01-01 上海 拼多多 八匹马 99 1 1 2
1 2020-01-01 上海 抖音 八匹马 219 1 1 2
2 2020-01-01 上海 天猫 八匹马 169 1 1 2
3 2020-01-01 上海 天猫 八匹马 169 1 1 2
4 2020-01-01 上海 天猫 皮皮虾 249 1 1 2
... ... ... ... ... ... .. .. ..
1940 2020-12-30 北京 京东 花花姑娘 269 12 4 2
1941 2020-12-30 福建 实体 八匹马 79 12 4 2
1942 2020-12-31 福建 实体 花花姑娘 269 12 4 3
1943 2020-12-31 福建 抖音 八匹马 59 12 4 3
1944 2020-12-31 福建 天猫 八匹马 99 12 4 3
[1945 rows x 8 columns]
在上面的代码中,通过日期时间类型的Series
对象的dt
属性,获得一个访问日期时间的对象,通过该对象的year
、month
、quarter
、hour
等属性,就可以获取到年、月、季度、小时等时间信息,获取到的仍然是一个Series
对象,它包含了一组时间信息,所以我们通常也将这个dt
属性称为"日期时间向量"。
我们再来说一说字符串类型的数据的处理,我们先从指定的 Excel 文件中读取某招聘网站的招聘数据。
jobs_df = pd.read_csv("./dates/某招聘网站招聘数据.csv", usecols=['city', 'companyFullName', 'positionName', 'salary'])
print(jobs_df.head()) #前五条信息
print(jobs_df.tail()) #后五条信息
city companyFullName positionName salary
0 北京 达疆网络科技(上海)有限公司 数据分析岗 15k-30k
1 北京 北京音娱时光科技有限公司 数据分析 10k-18k
2 北京 北京千喜鹤餐饮管理有限公司 数据分析 20k-30k
3 北京 吉林省海生电子商务有限公司 数据分析 33k-50k
4 北京 韦博网讯科技(北京)有限公司 数据分析 10k-15k
city companyFullName positionName salary
3135 天津 清博津商(天津)教育科技有限公司 审核实习生(春节短期) 1k-2k
3136 天津 上海礼紫股权投资基金管理有限公司 运营助理 6k-8k
3137 天津 北京达佳互联信息技术有限公司 运营编辑团队leader 8k-15k
3138 天津 北京河狸家信息技术有限公司 商家运营-天津 6k-8k
3139 天津 北京河狸家信息技术有限公司 运营实习生-天津 2k-4k
上面的数据表一共有3140
条数据,但并非所有的职位都是"数据分析"的岗位,如果要筛选出数据分析的岗位,可以通过检查positionName
字段是否包含"数据分析"这个关键词,这里需要模糊匹配,应该如何实现呢?我们可以先获取positionName
列,因为这个Series
对象的dtype
是字符串,所以可以通过str
属性获取对应的字符串向量,然后就可以利用我们熟悉的字符串的方法来对其进行操作,代码如下所示。
jobs_df = pd.read_csv("./dates/某招聘网站招聘数据.csv", usecols=['city', 'companyFullName', 'positionName', 'salary'])
print(jobs_df[jobs_df['positionName'].str.contains("数据分析岗")])
输出
city companyFullName positionName salary
0 北京 达疆网络科技(上海)有限公司 数据分析岗 15k-30k
14 北京 达疆网络科技(上海)有限公司 数据分析岗 15k-30k
279 北京 前锦网络信息技术(上海)有限公司北京分公司 数据分析岗 20k-40k
307 北京 前锦网络信息技术(上海)有限公司北京分公司 数据分析岗 20k-40k
386 北京 联想(北京)有限公司 数据库开发及数据分析岗 5k-9k
427 北京 联想(北京)有限公司 数据库开发及数据分析岗 5k-9k
611 上海 深圳平安综合金融服务有限公司 1221LT-数据分析岗 10k-15k
757 上海 平安国际融资租赁有限公司 26226B-数据分析岗位 15k-25k
908 深圳 中国平安人寿保险股份有限公司 022440-数据分析岗 10k-20k
933 深圳 平安国际智慧城市科技股份有限公司 4721EM-智慧法律-数据分析岗 15k-30k
960 深圳 民生易贷(珠海)互联网金融信息服务有限公司 运营数据分析岗 6k-10k
1145 广州 中邮消费金融有限公司 数据分析岗-风险建模专家(高级) 15k-30k
1159 广州 广发银行股份有限公司信用卡中心 数据分析岗(业务方向) 15k-25k
1163 广州 广州汶颢信息技术有限公司 产品运营-数据分析岗(线上精品棋牌手游) 8k-16k
1189 广州 中邮消费金融有限公司 数据分析岗 16k-26k
1205 广州 中邮消费金融有限公司 数据分析岗(高级BI开发) 15k-30k
1397 成都 四川锦程消费金融有限责任公司 数据分析岗 8k-12k
1508 成都 四川锦程消费金融有限责任公司 数据分析岗(2020年校招) 4k-8k
可以看出,筛选后的数据只有数据分析岗。接下来,我们还需要对salary
字段进行处理,如果我们希望统计所有岗位的平均工资或每个城市的平均工资,首先需要将用范围表示的工资处理成其中间值,代码如下所示。
jobs_df.salary.str.extract(r'(\d+)[kK]?-(\d+)[kK]?')
需要提醒大家的是,抽取出来的两列数据都是字符串类型的值,我们需要将其转换成int
类型,才能计算平均值,对应的方法是DataFrame
对象的applymap
方法,该方法的参数是一个函数,而该函数会作用于DataFrame
中的每个元素。完成这一步之后,我们就可以使用apply
方法将上面的DataFrame
处理成中间值,apply
方法的参数也是一个函数,可以通过指定axis
参数使其作用于DataFrame
对象的行或列,代码如下所示。
temp_df = jobs_df.salary.str.extract(r'(\d+)[kK]?-(\d+)[kK]?').applymap(int)
temp_df.apply(np.mean, axis=1)
接下来,我们可以用上面的结果替换掉原来的salary
列或者增加一个新的列来表示职位对应的工资,完整的代码如下所示。
emp_df = jobs_df.salary.str.extract(r'(\d+)[kK]?-(\d+)[kK]?').applymap(int)
jobs_df['salary'] = temp_df.apply(np.mean, axis=1)
jobs_df.head()
applymap
和apply
两个方法在数据预处理的时候经常用到,Series
对象也有apply
方法,也是用于数据的预处理,但是DataFrame
对象还有一个名为transform
的方法,也是通过传入的函数对数据进行变换,类似Series
对象的map
方法。需要强调的是,apply
方法具有归约效果的,简单的说就是能将较多的数据处理成较少的数据或一条数据;而transform
方法没有归约效果,只能对数据进行变换,原来有多少条数据,处理后还是有多少条数据。
如果要对数据进行深度的分析和挖掘,字符串、日期时间这样的非数值类型都需要处理成数值,因为非数值类型没有办法计算相关性,也没有办法进行 χ 2 \chi^2 χ2检验等操作。对于字符串类型,通常可以其分为以下三类,再进行对应的处理。
- 有序变量(Ordinal Variable):字符串表示的数据有顺序关系,那么可以对字符串进行序号化处理。
- 分类变量(Categorical Variable)/ 名义变量(Nominal Variable):字符串表示的数据没有大小关系和等级之分,那么就可以使用独热编码的方式处理成哑变量(虚拟变量)矩阵。
- 定距变量(Scale Variable):字符串本质上对应到一个有大小高低之分的数据,而且可以进行加减运算,那么只需要将字符串处理成对应的数值即可。
对于第1类和第3类,我们可以用上面提到的apply
或transform
方法来处理,也可以利用scikit-learn
中的OrdinalEncoder
处理第1类字符串,这个我们在后续的课程中会讲到。对于第2类字符串,可以使用pandas
的get_dummies()
函数来生成哑变量(虚拟变量)矩阵,代码如下所示。
persons_df = pd.DataFrame(
data={
'姓名': ['关羽', '张飞', '赵云', '马超', '黄忠'],
'职业': ['医生', '医生', '程序员', '画家', '教师'],
'学历': ['研究生', '大专', '研究生', '高中', '本科']
}
)
persons_df
输出:
姓名 职业 学历
0 关羽 医生 研究生
1 张飞 医生 大专
2 赵云 程序员 研究生
3 马超 画家 高中
4 黄忠 教师 本科
将职业处理成哑变量矩阵。
pd.get_dummies(persons_df['职业'])
输出:
医生 教师 画家 程序员
0 1 0 0 0
1 1 0 0 0
2 0 0 0 1
3 0 0 1 0
4 0 1 0 0
将学历处理成大小不同的值。
def handle_education(x):
edu_dict = {'高中': 1, '大专': 3, '本科': 5, '研究生': 10}
return edu_dict.get(x, 0)
persons_df['学历'].apply(handle_education)
输出:
0 10
1 3
2 10
3 1
4 5
Name: 学历, dtype: int64
离散化
离散化也叫分箱,如果变量的取值是连续值,那么它的取值有无数种可能,在进行数据分组的时候就会非常的不方便,这个时候将连续变量离散化就显得非常重要。之所以把离散化叫做分箱,是因为我们可以预先设置一些箱子,每个箱子代表了数据取值的范围,这样就可以将连续的值分配到不同的箱子中,从而实现离散化。下面的例子读取了2018年北京积分落户数据 2023年北京积分落户数据.csv ,我们可以根据落户积分对数据进行分组,具体的做法如下所示。
luohu_df = pd.read_csv('./dates/2018年北京积分落户数据.csv', index_col='id')
print(luohu_df.score.describe())
count 6019.000000
mean 95.654552
std 4.354445
min 90.750000
25% 92.330000
50% 94.460000
75% 97.750000
max 122.590000
Name: score, dtype: float64
可以看出,落户积分的最大值是122.59
,最小值是90.75
,那么我们可以构造一个从90
分到125
分,每5
分一组的7
个箱子,pandas
的cut
函数可以帮助我们首先数据分箱,代码如下所示。
ins = np.arange(90, 126, 5)
pd.cut(luohu_df.score, bins, right=False)
cut
函数的right
参数默认值为True
,表示箱子左开右闭;修改为False
可以让箱子的右边界为开区间,左边界为闭区间,我们可以根据分箱的结果对数据进行分组,然后使用聚合函数对每个组进行统计,这是数据分析中经常用到的操作,下一个章节会为大家介绍。除此之外,pandas
还提供了一个名为qcut
的函数,可以指定分位数对数据进行分箱,有兴趣的读者可以自行研究。
数据透视
经过前面的学习,我们已经将数据准备就绪而且变成了我们想要的样子,接下来就是最为重要的数据透视阶段了。当我们拿到一大堆数据的时候,如何从数据中迅速的解读出有价值的信息,把繁杂的数据变成容易解读的统计图表并再此基础上产生业务洞察,这就是数据分析要解决的核心问题。
首先,我们可以获取数据的描述性统计信息,通过描述性统计信息,我们可以了解数据的集中趋势和离散趋势。
例如,我们有如下所示的学生成绩表。
scores = np.random.randint(50, 101, (5, 3))
names = ('关羽', '张飞', '赵云', '马超', '黄忠')
courses = ('语文', '数学', '英语')
df = pd.DataFrame(data=scores, columns=courses, index=names)
print(df)
语文 数学 英语
关羽 69 56 52
张飞 59 90 76
赵云 68 87 58
马超 68 87 83
黄忠 55 61 56
我们可以通过DataFrame
对象的方法mean
、max
、min
、std
、var
等方法分别获取每个学生或每门课程的平均分、最高分、最低分、标准差、方差等信息,也可以直接通过describe
方法直接获取描述性统计信息,代码如下所示。
df.mean() # 每门课程成绩的平均分 按行处理
df.mean(axis=1) # 每个学生成绩的平均分。
df.var() # 每门课程成绩的方差。
df.describe() # 获取每门课程的描述性统计信息。
df.sort_values(by='语文', ascending=False) # 对数据进行排序,
# 如果DataFrame数据量很大,排序将是一个非常耗费时间的操作。有的时候我们只需要获得排前N名或后N名的数据,这个时候其实没有必要对整个数据进行排序,而是直接利用堆结构找出Top-N的数据。DataFrame的nlargest和nsmallest方法就提供对Top-N操作的支持,代码如下所示。
df.nlargest(3, '语文') # 语文成绩前3名的学生信息。
df.nsmallest(3, '数学') # 出数学成绩最低的3名学生的信息。
分组聚合
我们先从之前使用过的 Excel 文件中读取2020年销售数据,然后再为大家演示如何进行分组聚合操作。
如果我们要统计每个销售区域的销售总额,可以先通过"售价"和"销售数量"计算出销售额,为DataFrame
添加一个列,代码如下所示。
sale_df = pd.read_excel(r"./dates/2020年销售数据.xlsx")
sale_df['销售总额'] = sale_df['售价'] * sale_df['销售数量']
print(sale_df)
销售日期 销售区域 销售渠道 销售订单 品牌 售价 销售数量 销售总额
0 2020-01-01 上海 拼多多 182894-455 八匹马 99 83 8217
然后再根据"销售区域"列对数据进行分组,这里我们使用的是DataFrame
对象的groupby
方法。分组之后,我们取"销售额"这个列在分组内进行求和处理,代码和结果如下所示。
sale_df = pd.read_excel(r"./dates/2020年销售数据.xlsx")
sale_df['销售总额'] = sale_df['售价'] * sale_df['销售数量']
print(sale_df.groupby('销售区域')['销售总额'].sum())
输出
销售区域
上海 11610489
北京 12477717
安徽 895463
广东 1617949
江苏 2304380
浙江 687862
福建 10178227
Name: 销售总额, dtype: int64
如果我们要统计每个月的销售总额,我们可以将"销售日期"作为groupby`方法的参数,当然这里需要先将"销售日期"处理成月,代码和结果如下所示。
print(sale_df.groupby(sale_df['销售日期'].dt.month)['销售总额'].sum())
输出
销售日期
1 5409855
2 4608455
3 4164972
4 3996770
5 3239005
6 2817936
7 3501304
8 2948189
9 2632960
10 2375385
11 2385283
12 1691973
Name: 销售总额, dtype: int64
接下来我们将难度升级,统计每个销售区域每年的销售总额,这又该如何处理呢?事实上,groupby
方法的第一个参数可以是一个列表,列表中可以指定多个分组的依据,大家看看下面的代码和输出结果就明白了。
print(sale_df.groupby(['销售区域',sale_df['销售日期'].dt.year])['销售总额'].sum())
输出
销售区域 销售日期
上海 2020 11610489
北京 2020 12477717
安徽 2020 895463
广东 2020 1617949
江苏 2020 2304380
浙江 2020 687862
福建 2020 10178227
Name: 销售总额, dtype: int64
如果希望统计出每个区域的销售总额以及每个区域单笔金额的最高和最低,我们可以在DataFrame
或Series
对象上使用agg
方法并指定多个聚合函数,代码和结果如下所示。
print(sale_df.groupby('销售区域')['销售总额'].agg(['sum',"min","max"]))
# 如果希望自定义聚合后的列的名字,可以使用如下所示的方法。
sale_df.groupby('销售区域').销售总额.agg(销售总额='sum', 单笔最高='max', 单笔最低='min')
输出
sum min max
销售区域
上海 11610489 948 116303
北京 12477717 690 133411
安徽 895463 1683 68502
广东 1617949 990 120807
江苏 2304380 1089 114312
浙江 687862 3927 90909
福建 10178227 897 87527
如果需要对多个列使用不同的聚合函数,例如"统计每个销售区域销售额的总和以及销售数量的最低值和最高值",我们可以按照下面的方式来操作。
df.groupby('销售区域')[['销售额', '销售数量']].agg({
'销售额': 'sum', '销售数量': ['max', 'min']
})
透视表与交叉表
上面的例子中,"统计每个销售区域每个月的销售总额"会产生一个看起来很长的结果,在实际工作中我们通常把那些行很多列很少的表成为"窄表",如果我们不想得到这样的一个"窄表",可以使用DataFrame
的pivot_table
方法或者是pivot_table
函数来生成透视表。透视表的本质就是对数据进行分组聚合操作,根据 A 列对 B 列进行统计,如果大家有使用 Excel 的经验,相信对透视表这个概念一定不会陌生。例如,我们要"统计每个销售区域的销售总额
"销售区域"就是我们的 A 列,而"销售额"就是我们的 B 列,在pivot_table
函数中分别对应index
和values
参数,这两个参数都可以是单个列或者多个列。
pd.pivot_table(df, index='销售区域', values='销售额', aggfunc='sum')
上面的结果操作跟之前用groupby
的方式得到的结果有一些区别,groupby
操作后,如果对单个列进行聚合,得到的结果是一个Series
对象,而上面的结果是一个DataFrame
对象。
如果要统计每个销售区域每个月的销售总额,也可以使用pivot_table
函数,代码如下所示。
df['月份'] = df['销售日期'].dt.month
pd.pivot_table(df, index=['销售区域', '月份'], values='销售额', aggfunc='sum')
上面的操作结果是一个DataFrame
,但也是一个长长的"窄表",如果希望做成一个行比较少列比较多的"宽表",可以将index
参数中的列放到columns
参数中,代码如下所示。
pd.pivot_table(df, index='销售区域', columns='月份', values='销售额', aggfunc='sum', fill_value=0)
说明 :
pivot_table
函数的fill_value=0
会将空值处理为0
。
使用pivot_table
函数时,还可以通过添加margins
和margins_name
参数对分组聚合的结果做一个汇总,具体的操作和效果如下所示。
pd.pivot_table(df, index='销售区域', columns='月份', values='销售额', aggfunc='sum', fill_value=0, margins=True, margins_name='总计')
交叉表就是一种特殊的透视表,它不需要先构造一个DataFrame
对象,而是直接通过数组或Series
对象指定两个或多个因素进行运算得到统计结果。例如,我们要统计每个销售区域的销售总额,也可以按照如下所示的方式来完成,我们先准备三组数据。
sales_area, sales_month, sales_amount = df['销售区域'], df['月份'], df['销售额']
使用crosstab
函数生成交叉表。
pd.crosstab(index=sales_area, columns=sales_month, values=sales_amount, aggfunc='sum').fillna(0).astype('i8')
说明 :上面的代码使用了
DataFrame
对象的fillna
方法将空值处理为0,再使用astype
方法将数据类型处理成整数。
数据呈现
一图胜千言,我们对数据进行透视的结果,最终要通过图表的方式呈现出来,因为图表具有极强的表现力,能够让我们迅速的解读数据中隐藏的价值。和Series
一样,DataFrame
对象提供了plot
方法来支持绘图,底层仍然是通过matplotlib
库实现图表的渲染。关于matplotlib
的内容,我们在下一个章节进行详细的探讨,这里我们只简单的讲解plot
方法的用法。
例如,我们想通过一张柱状图来比较"每个销售区域的销售总额",可以直接在透视表上使用plot
方法生成柱状图。我们先导入matplotlib.pyplot
模块,通过修改绘图的参数使其支持中文显示。
import matplotlib.pyplot as plt
temp = pd.pivot_table(df, index='销售区域', values='销售额', aggfunc='sum')
temp.plot(figsize=(8, 4), kind='bar')
plt.xticks(rotation=0)
plt.show()