pandas03:深入认识索引

Pandas索引机制

pandas 相比 numpy ,一个很重要的特点就在于它引入了显式的索引机制。显式的索引在方便数据获取的同时,也可能造成学习上的困惑。接下来首先详细介绍 pandas 的索引机制。

Series与索引

之前说过,Series 对象可以看作一种字典,它提供了索引与值对的映射,因此可以使用字典一样的方式获取值:

s01 = pd.Series(np.arange(4), index=['a', 'b', 'c', 'd'])
s01['b']
1

Series 的许多操作都和 Python 字典很像,例如可以通过 item assignment 增加新的索引-值对,这等价于向 Series 添加新的一项:

s01['e'] = 10

这种自定义的索引是显式的,它是真实存在的,因此可以向字典一样获取所有的索引-值对:

list(s01.items())
[('a', 0), ('b', 1), ('c', 2), ('d', 3), ('e', 10)]

Series 不仅有着和字典一样的索引操作,还具备和 numpy 数组一样的数组数据选择功能,例如数组索引:

s01[['b', 'd']]
b 1 d 3 dtype: int32

这么看来,Series 的索引除了允许自定义外,和 ndarray 的索引好像没什么区别。不过注意,Series 的索引是允许重复的,这可能会导致一次性获取到多个值:

s02 = pd.Series(np.arange(5), index=['a', 'b', 'b', 'd', 'b'])
s02['b']
b 1 b 2 b 4 dtype: int32

此外,这种索引在用作切片时,得到的结果将包含后端的值:

s01['b':'d']
b 1 c 2 d 3 dtype: int32

这样做的好处是不用明白它后一项的索引是什么。但如果索引有重复的话,将不能用于切片操作:

try:
    s02['a':'b']
except Exception as e:
    print(type(e), e)
<class 'KeyError'> "Cannot get right slice bound for non-unique label: 'b'"

这种索引机制可能会导致数据获取的不便。但实际上,Series 依然保留了 numpy 数组从零开始、切片时前闭后开的隐式索引:

s01[1]
1
s01[1:3]
b 1 c 2 dtype: int32

这两种索引方式很容易造成混淆,尤其是使用自定义整数作索引时,它可能会覆盖隐式索引,使得某些操作失效:

s03 = pd.Series(np.arange(4), index=np.arange(1, 5))
try:
    s03[s03.argmin()]
except Exception as e:
    print(type(e), e)
<class 'KeyError'> 0

因此 pandas 提供了一些索引器作为取值的方法,它们是 Series 对象暴露取值与切片接口的属性。

第一种索引器是 .loc 属性,表示用的是自定义、可重复、类型不限、切片时包含两端的显式索引:

s01.loc['a':'b']
a 0 b 1 dtype: int32
try:
    s01.loc[1]
except KeyError as e:
    print(e)
1

第二种索引器是 .iloc 属性,表示用的是从 0 开始、切片前闭后开的整数隐式(implicit)索引:

s03.iloc[s03.argmin()]
0

这两种索引器独立工作,不能混用,因此可以各自用于需要的场景中。

DataFrame与索引

之前说过 DataFrame 也可以看作一种字典,它提供了列索引与 Series 对的映射,因此可以使用字典一样的方式由列索引获取一个 Series

df01 = pd.DataFrame(
    {'units': {'pencil': 95, 'binder': 30, 'paperclip': 81},
     'unitcost': {'pencil': 1.99, 'binder': 19.99, 'paperclip': 4.99}})
df01
unitsunitcost
pencil951.99
binder3019.99
paperclip814.99
df01['unitcost']
pencil 1.99 binder 19.99 paperclip 4.99 Name: unitcost, dtype: float64

和前面介绍的 Series 对象一样,也可以用 item assignment 增加一列:

df01['total'] = df01['units'] * df01['unitcost']
df01
unitsunitcosttotal
pencil951.99189.05
binder3019.99599.70
paperclip814.99404.19

因此对列索引而言,它和 Series 的索引机制比较像。但是由于 DataFrame 行列都有索引,因此单级的显式索引只能作用于列,否则操作很容易引起歧义。

除此之外,直接对行或列应用隐式索引会引起错误。从概念上来说,对行和列的隐式索引容易存在误解:如果将 DataFrame 看作结构数组,那么一列就代表一个结构成员,列与列之间并没有严格的先后关系,直接取第几列这种操作无法让人明白其意图。而行虽然没有这种误解,但是会产生一个更关键的问题:直接取某一行使得行索引不再被用到而丢弃,返回一个 Series ,但是 Series 要求所有元素的类型一致,而一个结构各成员间往往有着各自各样的类型,强行统一它们的类型会造成类型提升,为后续操作带来更多问题。

一种特殊的情况是切片。切片将会保留行索引,得到的仍然是一个 DataFrame 。如果切片涉及的范围只有一行,那么就基本等价于获取 DataFrame 的某一行(虽然得到的仍然是一个二维数组):

df01[0:1]
unitsunitcosttotal
pencil951.99189.05

因此,除了对列应用显式索引外,其它形式的索引不仅应该使用索引器,而且应该使用 numpy 高维数组的索引方式。

例如,以下使用隐式索引器获取 DataFrame 的元素。这里在代表取值的方括号内传入了一个元组,第一个元素指代行的隐式索引,第二个元素指代列的隐式索引:

df01.iloc[2, 1]
4.99

根据隐式索引的规则,获取的应该是第 3 行第 2 列位置的元素。

再如,以下使用显式索引器得到指定几行的元素。这里对行应用数组索引,对列使用单个冒号 : 表示全部切片:

df01.loc[['pencil', 'paperclip'], :]
unitsunitcosttotal
pencil951.99189.05
paperclip814.99404.19

最后,下图总结了 DataFrame 的索引:

层级索引

层级索引的概念

通过之前的介绍可以认识到,DataFrame 是一种二维的结构。但有些时候,处理的数据可能不止两个维度。例如,在操作 Excel 时,经常可以看到这样的表格:

20212022
mid termend of term mid termend of term
grade 1 class 1 8688 8990
class 2 8887 9189
class 3 8486 8685
grade 2 class 1 8694 9091
class 2 8584 8791
class 3 8791 9090

这种数据可以从四个维度聚合:对列来说,可以得出每个年度的得分平均值,也可以得出历年期中和期末的得分平均值;对行也是同理。只凭借二维数据无法实现这样的关系,这时就需要使用层级索引。层级索引可以从多个角度来描述数据的分组。

pandas 中的索引类型不仅限于数值和字符串,甚至还能使用元组,例如:

i01 = [('A', 1), ('A', 2), ('A', 3),
       ('B', 1), ('B', 2), ('B', 3)]
s03 = pd.Series([1341, 1412, 1263, 643, 632, 685],
                index=i01)
s03
(A, 1) 1341 (A, 2) 1412 (A, 3) 1263 (B, 1) 643 (B, 2) 632 (B, 3) 685 dtype: int64

元组表示存储了多个值,是多级索引的基础。pandasMultiIndex 类提供了更丰富的操作方法。可以用它的类方法从元组创建一个多级索引:

i02 = pd.MultiIndex.from_tuples(i01, names=['class', 'group'])

通过 names 参数可以为这两个层级指定名称,方面区分各索引层。层级名称会保存到索引对象的 .names 属性中。

如果将前面创建的 Series 对象使用 .reindex() 方法将它的索引重置为 MultiIndex 对象,就会看到一个层级索引结构:

s03 = s03.reindex(index=i02)
s03
class group A 1 1341 2 1412 3 1263 B 1 643 2 632 3 685 dtype: int64

关于层级索引,需要记住的是:层级索引可以看作一个元素对应多个索引,或者说一个索引元组。如果检查层级索引的 .values 属性,会发现每个索引都使用多个值来描述:

s03.index.values
array([('A', 1), ('A', 2), ('A', 3), ('B', 1), ('B', 2), ('B', 3)], dtype=object)

因此在获取元素的时候,也需要通过多个值,或者说一个元组来获取:

s03[('A', 2)]
1412

多个值或一个元组构成的索引也可以用于切片。除了索引由一个值变成一个元组外,均遵循一维 Series 的切片规则,例如可以使用显式索引器 .loc

s03.loc[('A', 2):('B', 1)]
class group A 2 1412 3 1263 B 1 643 dtype: int64

显式索引器使切片包含两端的元素。返回检查层级索引的 .values 属性可以发现,包含两端的元素确实是 3 个。

这里需要注意,如果层级索引不是有序的,那么大多数切片操作都会失败。以下演示一种会导致错误的操作:

rand = np.random.RandomState(3)
s04 = pd.Series(rand.rand(6),
                index=pd.MultiIndex.from_product([['C', 'B', 'A'],
                                                  [1, 2]]))
try:
    s04.loc[('C', 2):('A', 1)]
except Exception as e:
    print(type(e))
<class 'pandas.errors.UnsortedIndexError'>

问题出在切片和许多其它相似的操作都要求 MultiIndex 的各级索引是有序的。为此,pandas 提供了一些操作可以实现对索引的排序,最简单的方法是 .sort_index()

s04.sort_index(inplace=True)
s04.loc[('B', 2):('C', 2)]
B 2 0.510828 C 1 0.550798 2 0.708148 dtype: float64

经过索引排序后的切片结果就正常了。这里再次使用 inplace 参数来提醒默认情况下排序后得到的是一个新的对象,而不是在原有对象的基础上做修改。

层级索引相比普通的索引,索引类型由一个值变为多个值(或者说一个元组)。这看似多此一举,但是它允许从不同层面来处理一维的数据。如果访问层级索引的 .level 属性,可以得到:

s03.index.levels
FrozenList([['A', 'B'], [1, 2, 3]])

这说明该层级索引有两层:从索引的角度看,第一层有 2 种不同的索引,第二层有 3 种不同的索引;从数据的角度看,根据第一层索引可以将数据分为 2 类,根据第二层可以将数据分为 3 类。因此数据在聚合、变换时,可以根据不同的索引层级,从不同的角度处理。例如,对于以上具有层级索引的 Series ,可以统计每个 "class" 的数值平均值:

s03.sum(level='class')
class A 4016 B 1960 dtype: int64

新版本的 pandas 可能已经弃用了这种使用方式,或者抛出 FutureWarning ,提示说应该使用对表作分组计算后再合并,这就是以后介绍的内容了。

具有层级索引的 Series 很像一个 DataFrame 。事实上,使用对象的 .unstack() 方法可以将一个多级索引的 Series 转化为普通索引的 DataFrame

s03.unstack()
group123
class
A134114121263
B643632685

或者使用 .stack() 方法实现相反的效果,将一个 DataFrame 变成具有多级索引的 Series 。 既然可以用含多级索引的一维 Series 数据表示二维数据,那么就可以用 SeriesDataFrame 表示三维甚至更高维度的数据。借助多级索引,可以使三维及以上的数据以一种较为易读的形式表示出来。层级索引每增加一层,就表示数据增加一维,使得 DataFrame 可以表示任意维度的数据。因此 pandas 并没有提供三维及以上的数量类型。

DataFrame与层级索引

DataFrame 使用层级索引和在 Series 上使用层级索引是一致的,只不过列索引和行索引都可以设置为层级索引。

以下创建一个较为复杂的、行列都具有两级索引的 DataFrame 用于演示:

df03 = pd.DataFrame(rand.randint(50, 95, size=(4, 6)),
            index=pd.MultiIndex.from_product([[2020, 2021], [1, 2]],
                                             names=['year', 'term']),
            columns=pd.MultiIndex.from_product([['Tim', 'Mary', 'John'],
                                                ['math', 'physics']],
                                               names=['name', 'subject'])
            )
df03
nameTimMaryJohn
subjectmathphysicsmathphysicsmathphysics
yearterm
20201927453585071
2696093916071
20211888270947989
2647667767252

DataFrame 索引和 Series 基本一致,需要通过元组形式的索引来获取一个 Series ,并会保留行的层级索引:

df03[('John', 'math')]
year term 2020 1 50 2 60 2021 1 79 2 72 Name: (John, math), dtype: int32

索引器和切片的用法都是一致的:

df03.loc[(2020, 1):(2020, 2), [('John', 'math'), ('Mary', 'math')]]
nameJohnMary
subjectmathmath
yearterm
202015053
26093

不过这种索引元组的用法不是很方便,因为这个 DataFrame 实际上可以看作四维数据,但是只能在两个维度上切片。如果想获取所有人在第 1 学期的数学成绩,那么可能需要这样的索引:

df03.loc[(:, 1), (:, 'math')]
Cell In [35], line 1 df03.loc[(:, 1), (:, 'math')] ^ SyntaxError: invalid syntax

这是错误的用法,它会直接导致解释出错。为此,pandas 提供了 IndexSlice 对象,专门用来解决高维 DataFrame 的切片问题,例如:

Idx = pd.IndexSlice
df03.loc[Idx[:, 1], Idx[:, 'math']]
nameTimMaryJohn
subjectmathmathmath
yearterm
20201925350
20211887079

下图总结了 DataFrame 的层级索引:

京ICP备2021034974号
contact me by hello@frozencandles.fun