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

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

> - `Series` は一次元配列です。組み込み型の`list`のようなものです。
> - `DataFrame` は二次元配列です。

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

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

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

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

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

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

![間違ったpandas (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179154/12f7ff7e-55ea-f896-0634-e18e0c9fb039.png)

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

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

```python
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]。

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

図で表すとこうです：

![正しいpandas (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179154/d9644a9b-97d0-6709-5c82-e144fa720c4c.png)

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

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

![非数値インデックスによるアクセス (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179154/1877a65b-6868-1611-4cac-4cacd8e999cd.png)

# インデックスあれこれ

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

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

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

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

```python
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 になります。

```python
from pandas import *

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

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
```

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

`Series` には文字列と数値のような異なる型を混ぜて格納できますが、インデックスも同様です。そのため、DataFrameを作るときに各列のインデックスの型が異なっていてもエラーにはなりません。

```python
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 の特定の列をインデックスにできる

インデックスは添字ではなく「特別扱いされる列」のようなものです。DataFrameを作った後に、インデックスを変更したり、特定の列をインデックスに指定したりできます。

```python
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
```

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

インデックスには同じ値が２度出現してもエラーにはなりません。

```python
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を作るためにインデックスのマージが起きるときには、重複値があるとエラーになります。

```python
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` で添字アクセス可能です。

```python
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 を理解していないのか、「方便」を訂正せずに進んでしまうものもあるようです。

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

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

# まとめ

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