Skip to content

高阶pandas

分类数据

import numpy as np
import pandas as pd

背景和目标

一个列经常会包含重复值,这些重复值是一个小型的不同值的集合。 uniquevalue_counts这样的函数允许我们从一个数组中提取不同值并分别计算这些不同值的频率:

values = pd.Series(['apple', 'orange', 'apple', 'apple'] * 2)
print(values)
# 0     apple
# 1    orange
# 2     apple
# 3     apple
# 4     apple
# 5    orange
# 6     apple
# 7     apple
# dtype: object

print(pd.unique(values))
# ['apple' 'orange']

print(pd.value_counts(values))
# apple     6
# orange    2
# dtype: int64

在数据入库的操作中,使用维度表是一种最佳实践,维度表包含了不同值,并将主要观测值存储为引用维度表的整数键:

values = pd.Series([0, 1, 0, 0] * 2)
dim = pd.Series(['apple', 'oragne'])

使用take方法来恢复原来的字符串Series。(0对应到apple)。 这种按照整数展现的方式被称为分类或字典编码展现。不同值的数组可以被称为数据的类别、字典或层级。

print(dim.take(values))
# 0     apple
# 1    oragne
# 0     apple
# 0     apple
# 0     apple
# 1    oragne
# 0     apple
# 0     apple
# dtype: object

在做数据分析时,分类展示会产生显著的性能提升。可以在类别上进行转换同时不改变代码。

以下是一些相对低开销的转换示例:

  • 重命名类别
  • 在不改变已有的类别顺序的情况下添加一个新的类别

pandas中的Categorical类型

pandas拥有特殊的Categorical类型,用于承载基于整数的类别展示或编码的数据。

fruits = ['apple', 'orange', 'apple', 'apple'] * 2
N = len(fruits)
df = pd.DataFrame(
    {
        'fruit': fruits,
        'basket_id': np.arange(N),
        'count': np.random.randint(3, 15, size=N),
        'weight': np.random.uniform(0, 4, size=N)
    },
    columns=['basket_id', 'fruit', 'count', 'weight']
)
print(df)
#    basket_id   fruit  count    weight
# 0          0   apple      8  1.288867
# 1          1  orange      4  3.414430
# 2          2   apple      7  3.222160
# 3          3   apple     14  2.724804
# 4          4   apple      8  3.548828
# 5          5  orange     10  0.918739
# 6          6   apple      4  0.784816
# 7          7   apple     10  3.140607

df['fruit']是一个Python字符串对象组成的数组。可以通过调用函数将它转换为Categorical对象:

fruit_cat = df['fruit'].astype('category')
print(fruit_cat)
# 0     apple
# 1    orange
# 2     apple
# 3     apple
# 4     apple
# 5    orange
# 6     apple
# 7     apple
# Name: fruit, dtype: category
# Categories (2, object): ['apple', 'orange']

fruit_cat的值并不是NumPy数组,而是pandas.Categorical的实例:

c = fruit_cat.values
print(type(c))
# <class 'pandas.core.arrays.categorical.Categorical'>
print(c)
# ['apple', 'orange', 'apple', 'apple', 'apple', 'orange', 'apple', 'apple']
# Categories (2, object): ['apple', 'orange']

Categorical对象拥有categoriescodes属性:

print(c.categories)
# Index(['apple', 'orange'], dtype='object')
print(c.codes)
# [0 1 0 0 0 1 0 0]

通过分配已转换的结果将DataFrame的一列转换为Categorical对象:

print(df['fruit'])
# 0     apple
# 1    orange
# 2     apple
# 3     apple
# 4     apple
# 5    orange
# 6     apple
# 7     apple
# Name: fruit, dtype: object
df['fruit'] = df['fruit'].astype('category')
print(df['fruit'])
# 0     apple
# 1    orange
# 2     apple
# 3     apple
# 4     apple
# 5    orange
# 6     apple
# 7     apple
# Name: fruit, dtype: category
# Categories (2, object): ['apple', 'orange']

也可以从其他Python序列类型直接生成pandas.Categorical

my_categories = pd.Categorical(['foo', 'bar', 'baz', 'foo', 'bar'])
print(my_categories)
# ['foo', 'bar', 'baz', 'foo', 'bar']
# Categories (3, object): ['bar', 'baz', 'foo']

也可以使用from_codes构造函数来转换其他数据源的分类编码数据:

categories = ['foo', 'bar', 'baz']
codes = [0, 1, 2, 0, 0, 1]
my_cats_2 = pd.Categorical.from_codes(codes, categories)
print(my_cats_2)
# ['foo', 'bar', 'baz', 'foo', 'foo', 'bar']
# Categories (3, object): ['foo', 'bar', 'baz']

这个未排序的分类实例可以使用as_ordered进行排序:

print(my_cats_2.as_ordered())
# ['foo', 'bar', 'baz', 'foo', 'foo', 'bar']
# Categories (3, object): ['foo' < 'bar' < 'baz']

除非显式地指定,分类转换是不会指定类别的顺序的。因此categories数组可能会与输入数据的顺序不同。 当使用from_codes或其他任意构造函数时,可以为类别指定一个有意义的顺序:输出的[foo<bar<baz]表明foo的顺序在bar之前,以此类推。

my_categories_ordered = pd.Categorical.from_codes(codes=codes, categories=categories, ordered=True)
print(my_categories_ordered)
# ['foo', 'bar', 'baz', 'foo', 'foo', 'bar']
# Categories (3, object): ['foo' < 'bar' < 'baz']

分类数据可以不是字符串,尽管举的例子都是字符串例子。一个分类数组可以包含任一不可变的值类型。

使用Categorical对象进行计算

在pandas中使用Categorical与非编码版本相比(例如字符串数组)整体上是一致的。 pandas中的某些部分,比如groupby函数,在与Categorical对象协同工作时性能更好。 还有一些函数可以利用ordered标识。 下面考虑一些随机数字数据,并使用pandas.qcut分箱函数。结果会返回pandas.Categorical; 在前面章节使用过pandas.cut,但当时没有分析分类是如何工作的细节。

np.random.seed(12345)
draws = np.random.randn(1000)
print(draws[:5])
# [-0.20470766  0.47894334 -0.51943872 -0.5557303   1.96578057]

计算上面数据的四分位分箱,并提取一些统计值:

bins = pd.qcut(draws, 4)
print(bins)
# [(-0.684, -0.0101], (-0.0101, 0.63], (-0.684, -0.0101], (-0.684, -0.0101], (0.63, 3.928], ..., (-0.0101, 0.63], (-0.684, -0.0101], (-2.9499999999999997, -0.684], (-0.0101, 0.63], (0.63, 3.928]]
# Length: 1000
# Categories (4, interval[float64, right]): [(-2.9499999999999997, -0.684] < (-0.684, -0.0101] < (-0.0101, 0.63] < (0.63, 3.928]]

通过在qcut函数中使用labels参数来四分位数名称:

bins = pd.qcut(draws, 4, labels=['Q1', 'Q2', 'Q3', 'Q4'])
print(bins)
# ['Q2', 'Q3', 'Q2', 'Q2', 'Q4', ..., 'Q3', 'Q2', 'Q1', 'Q3', 'Q4']
# Length: 1000
# Categories (4, object): ['Q1' < 'Q2' < 'Q3' < 'Q4']
print(bins.codes[:10])
# [1 2 1 1 3 3 2 2 3 3]

被标记的bins分类数据并不包含数据中箱体边界的相关信息,因此可以使用groupby来提取一些汇总统计值:

bins = pd.Series(bins, name='quartile')
result = (pd.Series(draws).groupby(bins).agg(['count', 'min', 'max']).reset_index())
print(result)
#   quartile  count       min       max
# 0       Q1    250 -2.949343 -0.685484
# 1       Q2    250 -0.683066 -0.010115
# 2       Q3    250 -0.010032  0.628894
# 3       Q4    250  0.634238  3.927528

结果中的quartile列保留了bins中原始的分类信息,包括顺序:

print(result['quartile'])
# 0    Q1
# 1    Q2
# 2    Q3
# 3    Q4
# Name: quartile, dtype: category
# Categories (4, object): ['Q1' < 'Q2' < 'Q3' < 'Q4']

使用分类获得更高性能

如果对特定的数据集上做了大量的分析,将数据转换为分类数据可以产生大幅的性能提升。DateFrame中一列的分类版本通常也会明显使用更少内存。

下面的例子含有一千万元素的Series以及少量的不同类别:

N = 10000000
draws = pd.Series(np.random.randn(N))
labels = pd.Series(['foo', 'bar', 'baz', 'qux'] * (N // 4))

现在将labels转换为Categorical对象:

categories = labels.astype('category')
print(labels.memory_usage())  # labels比categories使用了明显更多的内存
# 80000128
print(categories.memory_usage())
# 10000332

分类方法

Series包含的分类数据拥有一些特殊方法,这些方法类似于Series.str的特殊字符串方法。这些方法提供了快捷访问类别和代码的方式。

s = pd.Series(['a', 'b', 'c', 'd'] * 2)
cat_s = s.astype('category')
print(cat_s)
# 0    a
# 1    b
# 2    c
# 3    d
# 4    a
# 5    b
# 6    c
# 7    d
# dtype: category
# Categories (4, object): ['a', 'b', 'c', 'd']

特殊属性cat提供了对分类方法的访问:

print(cat_s.cat.codes)
# 0    0
# 1    1
# 2    2
# 3    3
# 4    0
# 5    1
# 6    2
# 7    3
# dtype: int8
print(cat_s.cat.categories)
# Index(['a', 'b', 'c', 'd'], dtype='object')

假设数据的实际类别集合超出了数据中观察到的四个值,可以使用set_categories方法来改变类别:

actual_categories = ['a', 'b', 'c', 'd', 'e']
cat_s2 = cat_s.cat.set_categories(actual_categories)
print(cat_s2)
# 0    a
# 1    b
# 2    c
# 3    d
# 4    a
# 5    b
# 6    c
# 7    d
# dtype: category
# Categories (5, object): ['a', 'b', 'c', 'd', 'e']

虽然看起来数据并未改变,但新类别将反映在使用它们的操作中。例如,value_counts将遵循新的类别(如果存在):

print(cat_s.value_counts())
# a    2
# b    2
# c    2
# d    2
# dtype: int64
print(cat_s2.value_counts())
# a    2
# b    2
# c    2
# d    2
# e    0
# dtype: int64

大型数据集中,分类数据经常被用于节省内存和更高性能的便捷工具。 在过滤了一个大型DataFrame或Series之后,很多类别将不会出现在数据中。 可以使用remove_unused_categories方法来去除未观察到的类别:

cat_s3 = cat_s[cat_s.isin(['a', 'b'])]
print(cat_s3)
# 0    a
# 1    b
# 4    a
# 5    b
# dtype: category
# Categories (4, object): ['a', 'b', 'c', 'd']

print(cat_s3.cat.remove_unused_categories())
# 0    a
# 1    b
# 4    a
# 5    b
# dtype: category
# Categories (2, object): ['a', 'b']

创建用于建模的虚拟变量

当使用统计数据或机器学习工具时,通常会将分类数据转换为虚拟变量,也称为one-hot编码。 这会产生一个DataFrame,每个不同的类别都是它的一列。这些列包含一个特定类别的出现次数,否则为0。

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

使用pandas.get_dummies函数将一维的分类数据转换为一个包含虚拟变量的DataFrame:

print(pd.get_dummies(cat_s))
#    a  b  c  d
# 0  1  0  0  0
# 1  0  1  0  0
# 2  0  0  1  0
# 3  0  0  0  1
# 4  1  0  0  0
# 5  0  1  0  0
# 6  0  0  1  0
# 7  0  0  0  1

高阶GroupBy应用

import numpy as np
import pandas as pd

分组转换和“展开”GroupBy

在分组操作中可以使用apply方法实现转换操作。还有另一个内建方法transform,与apply方法类似但是可以对使用的函数加上更多的限制:

  • transform可以产生一个标量值,并广播到各分组的尺寸数据中。
  • transform可以产生一个与输入分组尺寸相同的对象。
  • transform不可改变它的输入。
df = pd.DataFrame(
    {
        'key': ['a', 'b', 'c'] * 4,
        'value': np.arange(12)
    }
)
print(df)
#    key  value
# 0    a      0
# 1    b      1
# 2    c      2
# 3    a      3
# 4    b      4
# 5    c      5
# 6    a      6
# 7    b      7
# 8    c      8
# 9    a      9
# 10   b     10
# 11   c     11

key分组的均值:

g = df.groupby('key').value
print(g.mean())
# key
# a    4.5
# b    5.5
# c    6.5
# Name: value, dtype: float64

假设要产生一个Series,它的尺寸和df['value']一样,但值都被按key分组的均值替代。 可以向transfrom传递匿名函数lambda x: x.mean()

result = g.transform(lambda x: x.mean())
print(result)
# 0     4.5
# 1     5.5
# 2     6.5
# 3     4.5
# 4     5.5
# 5     6.5
# 6     4.5
# 7     5.5
# 8     6.5
# 9     4.5
# 10    5.5
# 11    6.5
# Name: value, dtype: float64

对于内建的聚合函数,可以像GroupByagg方法一样传递一个字符串别名:

result = g.transform('mean')
print(result)
# 0     4.5
# 1     5.5
# 2     6.5
# 3     4.5
# 4     5.5
# 5     6.5
# 6     4.5
# 7     5.5
# 8     6.5
# 9     4.5
# 10    5.5
# 11    6.5
# Name: value, dtype: float64

apply类似,transform可以与返回Series的函数一起使用,但结果必须和输入有相同的大小。

例如,可以使用lambda函数给每个组乘以2:

result = g.transform(lambda x: x * 2)
print(result)
# 0      0
# 1      2
# 2      4
# 3      6
# 4      8
# 5     10
# 6     12
# 7     14
# 8     16
# 9     18
# 10    20
# 11    22
# Name: value, dtype: int64

更复杂一些,可以按照每个组的降序计算排名:

result = g.transform(lambda x: x.rank(ascending=False))
print(result)
# 0     4.0
# 1     4.0
# 2     4.0
# 3     3.0
# 4     3.0
# 5     3.0
# 6     2.0
# 7     2.0
# 8     2.0
# 9     1.0
# 10    1.0
# 11    1.0
# Name: value, dtype: float64

考虑一个由简单聚合构成的分组转换函数:

def normalize(x):
    return (x - x.mean()) / x.std()

使用transformapply可以获得等价的结果:

result = g.transform(normalize)
print(result)
# 0    -1.161895
# 1    -1.161895
# 2    -1.161895
# 3    -0.387298
# 4    -0.387298
# 5    -0.387298
# 6     0.387298
# 7     0.387298
# 8     0.387298
# 9     1.161895
# 10    1.161895
# 11    1.161895
# Name: value, dtype: float64

result = g.apply(normalize)
print(result)
# 0    -1.161895
# 1    -1.161895
# 2    -1.161895
# 3    -0.387298
# 4    -0.387298
# 5    -0.387298
# 6     0.387298
# 7     0.387298
# 8     0.387298
# 9     1.161895
# 10    1.161895
# 11    1.161895
# Name: value, dtype: float64

内建的聚合函数如meansum通常会比apply函数更快。 这些函数在与transform一起使用时也会存在一个"快速通过"。 这允许我们执行一个所谓的展开分组操作。 一个展开分组操作可能会包含多个分组聚合,矢量化操作的整体优势往往超过了这一点。

result = g.transform('mean')
print(result)
# 0     4.5
# 1     5.5
# 2     6.5
# 3     4.5
# 4     5.5
# 5     6.5
# 6     4.5
# 7     5.5
# 8     6.5
# 9     4.5
# 10    5.5
# 11    6.5
# Name: value, dtype: float64

normalized = (df['value'] - g.transform('mean')) / g.transform('std')
print(normalized)
# 0    -1.161895
# 1    -1.161895
# 2    -1.161895
# 3    -0.387298
# 4    -0.387298
# 5    -0.387298
# 6     0.387298
# 7     0.387298
# 8     0.387298
# 9     1.161895
# 10    1.161895
# 11    1.161895
# Name: value, dtype: float64

分组的时间重新采样

对于时间序列数据,resample方法在语义上是一种基于时间分段的分组操作。

N = 15
times = pd.date_range('2020-5-20 00:00', freq='1min', periods=N)
df = pd.DataFrame(
    {
        'time': times,
        'value': np.arange(N)
    }
)

print(df)
#                   time  value
# 0  2020-05-20 00:00:00      0
# 1  2020-05-20 00:01:00      1
# 2  2020-05-20 00:02:00      2
# 3  2020-05-20 00:03:00      3
# 4  2020-05-20 00:04:00      4
# 5  2020-05-20 00:05:00      5
# 6  2020-05-20 00:06:00      6
# 7  2020-05-20 00:07:00      7
# 8  2020-05-20 00:08:00      8
# 9  2020-05-20 00:09:00      9
# 10 2020-05-20 00:10:00     10
# 11 2020-05-20 00:11:00     11
# 12 2020-05-20 00:12:00     12
# 13 2020-05-20 00:13:00     13
# 14 2020-05-20 00:14:00     14

这里,可以按time进行索引,然后重新采样:

result = df.set_index('time').resample('5min').count()
print(result)
#                      value
# time
# 2020-05-20 00:00:00      5
# 2020-05-20 00:05:00      5
# 2020-05-20 00:10:00      5

假设DataFrame包含多个时间序列,并按一个附加的分组键列进行了标记:

df2 = pd.DataFrame(
    {
        'time': times.repeat(3),
        'key': np.tile(['a', 'b', 'c'], N),
        'value': np.arange((N * 3))
    }
)
print(df2)
#                   time key  value
# 0  2020-05-20 00:00:00   a      0
# 1  2020-05-20 00:00:00   b      1
# 2  2020-05-20 00:00:00   c      2
# 3  2020-05-20 00:01:00   a      3
# ......
# 43 2020-05-20 00:14:00   b     43
# 44 2020-05-20 00:14:00   c     44

使用pandas.TimeGrouper对象,每个key的值进行相同的重新采样: pd.TimeGrouper() was formally deprecated in pandas v0.21.0 in favor of pd.Grouper().

方法链技术

import numpy as np
import pandas as pd
from numpy import nan as NA

df = pd.DataFrame(
    [[1., 2., 3.],
     [1., NA, NA],
     [NA, NA, NA],
     [NA, 2., 3.]]
)
v = ['a', 'b', 'c', 'd']
print(df)
#      0    1    2
# 0  1.0  2.0  3.0
# 1  1.0  NaN  NaN
# 2  NaN  NaN  NaN
# 3  NaN  2.0  3.0

非函数赋值的方式。

df2 = df.copy()
df2['k'] = v
print(df2)
#      0    1    2  k
# 0  1.0  2.0  3.0  a
# 1  1.0  NaN  NaN  b
# 2  NaN  NaN  NaN  c
# 3  NaN  2.0  3.0  d

函数赋值的方式。 DataFrame.assign方法是对df[k] = v的赋值方式的一种功能替代。它返回的是一个按指定修改的新的DataFrame,而不是在原对象上进行修改。

df2 = df.assign(k=v)
print(df2)
#      0    1    2  k
# 0  1.0  2.0  3.0  a
# 1  1.0  NaN  NaN  b
# 2  NaN  NaN  NaN  c
# 3  NaN  2.0  3.0  d

pipe方法

对数据连续操作形成方法链(多个方法连续调用对数据进行处理)。 Series.pipeDataFrame.pipe意味着 x.pipe(f, *args, **kwargs)f(x, *args, **kwargs) 效果相同。换句话说,该函数应用于整个数据。

以 DataFrame 为例:

  • 语法: DataFrame.pipe(func, *args, **kwargs)
  • 参数:
  • func:函数,应用于系列/数据帧的函数。args 和 kwargs 被传递到 func。或者是一个(callable,data_keyword)元组,其中 data_keyword 是一个字符串,表示需要Series/DataFrame 的 callable 关键字
  • args:可迭代对象, 可选,函数的位置参数
  • kwargs:mapping, 可选,传入 func 的关键字参数字典
  • 返回:object:func 处理后的任意数据类型

DataFrame示例

df = pd.DataFrame(
    [[1., 2., 3.],
     [1., NA, NA],
     [NA, NA, NA],
     [NA, 2., 3.]]
)

被传递的类型是调用的实例。

df.pipe(type)  # 传递的是type实例
# <class 'pandas.core.frame.DataFrame'>

df.pipe(len)  # 传递的是len实例
# 4

def fun(df):
    return df * 2

fun(df)
#      0    1    2
# 0  2.0  4.0  6.0
# 1  2.0  NaN  NaN
# 2  NaN  NaN  NaN
# 3  NaN  4.0  6.0
df.pipe(fun)  # 传递的是fun函数
#      0    1    2
# 0  2.0  4.0  6.0
# 1  2.0  NaN  NaN
# 2  NaN  NaN  NaN
# 3  NaN  4.0  6.0

def fun2(x, df):  # 数据是第二个参数
    return df * 3

df.pipe((fun2, 'df'), 2)  # 注意传值
#      0    1    2
# 0  3.0  6.0  9.0
# 1  3.0  NaN  NaN
# 2  NaN  NaN  NaN
# 3  NaN  6.0  9.0

Series 示例

s = pd.Series([1, 2, 3, 4, 5])

s.pipe(type)
# <class 'pandas.core.series.Series'>
s.pipe(len)
# 5

def fun3(x, ss):
    return ss * 3

s.pipe((fun3, 'ss'), 2)
# 0     3
# 1     6
# 2     9
# 3    12
# 4    15
# dtype: int64

GroupBy 示例

df = pd.DataFrame({'A': 'a b a b'.split(), 'B': [1, 2, 3, 4]})
print(df)
#    A  B
# 0  a  1
# 1  b  2
# 2  a  3
# 3  b  4

求每组最大值和最小值之间的差异。

df.groupby('A').pipe(lambda x: x.max() - x.min())
#    B
# A
# a  2
# b  2

def mean1(groupby):
    return groupby.mean()

df.groupby(['A']).pipe(mean1)
#      B
# A
# a  2.0
# b  3.0