Edited at

僕のpandas.SeriesとDataFrameのイメージは間違っていた

Pandas の入門記事には、大抵こんなことが書かれています。




  • Series は一次元配列です。組み込み型のlistのようなものです。


  • DataFrame は二次元配列です。


私も「ふーん、なるほど」と理解したつもりになって Pandas を使い始めたのですが、あとでとんでもない思い違いをしていたの気づきました。

Series はリストではないし、DataFrame は二次元配列ではないのです


Series・DataFrameの間違ったイメージ

私の間違ったイメージはこうでした:


SeriesやDataFrameは配列のようなものだ、だから s[i], df[i] で i+1 番目の値・行にアクセスできる


図で書くとこんな感じです:

間違ったpandas (1).png

まぁ、このイメージでも、使い始めてしばらくは何とかなりました。

しかしインデックスが現れると、私の間違ったイメージは破綻しました。

s = Series(['val0', 'val1', 'val2', 'val3', 'val4'], index=['a', 'b', 'c', 'd', 'e'])

# `[]`の中が数値ではないのに、要素にアクセスできる!?
s['c'] # => 'val2'


Series・DataFrameの正しいイメージ

私の誤解は、「s[i], df[i] は i+1 番目の値・行にアクセスしている」ということでした。

実際には s[i]i は添字(位置)ではなくインデックスの値なのです。s[i]にアクセスするのに、添字は関係ないのです1

図で表すとこうです:

正しいpandas (1).png

SeriesDataFrameで明示的にインデックスを指定しないと、添字と同じ連番が使われるので、たまたま「Seriesはlistのようなもの」と思ってもうまく行きました。

しかし、本来インデックス≠添字なので、インデックスに連番数値以外のものも指定できるのです。

非数値インデックスによるアクセス (1).png


インデックスあれこれ

一見、非直感的な「インデックス≠添字」ですが、Pandasはインデックスの存在が前提の作りになっていて、それにより効率的なデータ操作ができるようになっています。

以下では、(昔の私のように)「インデックスは添字のこと」という理解だと、思っていたのと違う動作になってしまったり、無駄な書き方をしてしまうような例を紹介します。


DataFrame を作ると、Series のインデックスが自動的に使われる

DataFrameを作る時、各列のSeriesのインデックスが同じものであれば、index=を指定しなくても、DataFrameに同じインデックスが使われます。

from pandas import *

s1 = Series(['val1', 'val2', 'val3'], index=['a', 'b', 'c'])
s2 = Series(['val10', 'val20', 'val30'], index=['a', 'b', 'c'])

df = DataFrame({'Col1': s1, 'Col2': s2})
# df = DataFrame({'Col1': s1, 'Col2': s2}, index=['a', 'b', 'c']) # インデックスを明示しなくても同じ

print(df) # 添字(0, 1, 2) ではなく、'a', 'b', 'c' がインデックスになる
# Col1 Col2
# a val1 val10
# b val2 val20
# c val3 val30


異なるインデックスの Series から DataFrame を作ると、インデックスがマージされる

各列のSeriesが違うインデックスを持っているときは、マージされた新しいインデックスが作られます。このとき、インデックスに対応する値が無い部分は na になります。

from pandas import *

s1 = Series(['val1', 'val2', 'val3'], index=['a', 'b', 'c'])
s2 = Series(['val10', 'val20', 'val30'], index=['b', 'c', 'd'])

df = DataFrame({
'Col1': s1,
'Col2': s2,
})
print(df)

# Col1 Col2
# a val1 NaN
# b val2 NaN
# c val3 val10
# d NaN val20
# e NaN val30


インデックスに異なる型が混じっていてもエラーにはならない

listSeriesと同様に、インデックスには文字列と数値のような異なる型を混在させられます。そのため、DataFrameを作る時に、各列のSeriesのインデックスの型が異なっていてもエラーにはなりません。

from pandas import *

s1 = Series(['val1', 'val2', 'val3'], index=['a', 'b', 'c'])
s2 = Series(['val10', 'val20', 'val30'], index=[1, 2, 3])

df = DataFrame({
'Col1': s1,
'Col2': s2,
})
print(df)
# Col1 Col2
# a val1 NaN
# b val2 NaN
# c val3 NaN
# 1 NaN val10
# 2 NaN val20
# 3 NaN val30


DataFrame の特定の列を、インデックスにできる

インデックスは、添字ではなく「特別な列」のようなものです。そのため、後からインデックスを変更したり、特定の列をインデックスに切り替えたりできます。

from pandas import *

df = DataFrame({
'amount': [1000, 2000, 10000],
'name': ['北里', '津田', '渋沢'],
'birthyear': [1853, 1864, 1840],
})

print(df.loc[0]) # デフォルトでは連番がインデックスになる
# amount 1000
# name 北里
# birthyear 1853
# Name: 0, dtype: object

df = df.set_index('amount') # 特定の列をインデックスに変えられる
print(df.loc[1000])
# name 北里
# birthyear 1853
# Name: 1000, dtype: object


インデックスには重複があってもよい

インデックスは特殊な列に過ぎないので、値が重複することも可能です。

from pandas import *

# インデックスに重複値があってもよい
s = Series(['val1', 'val2', 'val3', 'val4', 'val5'], index=['a', 'b', 'b', 'c', 'b'])

print(s['a']) # 重複していないインデックス値ではそのまま取り出せる
# val1

print(type(s['b'])) # 重複したインデックス値では、戻り値がSeriesになる
# <class 'pandas.core.series.Series'>

print(s['b'])
# b val2
# b val3
# b val5
# dtype: object

ただし、DataFrameを作るためにインデックスのマージが起きるときには、重複値があるとエラーになります。

from pandas import *

# インデックスに重複値があってもよい
s1 = Series(['val1', 'val2', 'val3', 'val4', 'val5'], index=['a', 'b', 'b', 'c', 'b'])
s2 = Series(['valA', 'valB', 'valC', 'valD', 'valE'], index=['a', 'b', 'b', 'c', 'b'])

s3 = Series(['val10', 'val20', 'val30'], index=['a', 'b', 'c'])

# 同じインデックス同士なら、重複値があってもエラーにならない
df = DataFrame({
'Col1': s1,
'Col2': s2,
})
print(df)
# Col1 Col2
# a val1 valA
# b val2 valB
# b val3 valC
# c val4 valD
# b val5 valE

# 異なるインデックス同時だと、重複値があるとエラーになる
df = DataFrame({
'Col1': s1,
'Col3': s3,
})
# => ValueError: cannot reindex from a duplicate axis


インデックスを無視して添字でアクセスできる

時にはインデックスを無視して添字(位置)でアクセスしたくなることもありますが、Series・DataFrameともに、.iloc で添字アクセス可能です。

from pandas import *

s = Series(['val1', 'val2', 'val3'], index=['a', 'b', 'c'])

print(s.iloc[1]) # 添字(位置)でアクセスできる

df = DataFrame({
'index': ['a', 'b', 'c'],
'Col1': Series(['val1', 'val2', 'val3']),
'Col2': Series(['valA', 'valB', 'valC']),
}).set_index('index')

print(df.iloc[2]) # 添字(位置)でアクセスできる
# Col1 val3
# Col2 valC
# Name: c, dtype: object


良心的な入門記事の見分け方

さて、冒頭で取り上げた、Series・DataFrameの間違った説明ですが、




  • Series は一次元配列です。組み込み型のlistのようなものです。


  • DataFrame は二次元配列です。


入門者にとりあえずイメージを掴んでもらう方便としては、そんなに的外れでもありません。インデックスが添字である間は矛盾は生じません。ただ、世の中には書き手が pandas を理解していないのか、「方便」を訂正せずに進んでしまうものもあるようです。

上記の説明の後にこんな但し書きがあるかどうかで、良心的な記事を判別できるでしょう。


ただし、連番以外の「インデックス」を持つことができるなど、リストや二次元配列より高機能です。インデックスについては第2章で説明します。



まとめ

Series はリストではない!DataFrame は二次元配列ではない!





  1. 内部的にはインデックス値→添字の変換をしていると思いますが、外からは添字を意識しなくてもよい作りになっています。