PandasをRDBと同じように扱うと詰まりやすいところまとめ

この記事は「Unagi-Network Advent Calendar 2023 9日目」の記事です。
Unagi-Network Advent Calendar 2023 - Adventar

前書き

Pandasって皆さんご存じでしょうか。
Pythonでデータ分析をしているそこのあなたであればもちろんご存じでしょう。
おそらくPythonでデータ分析をする際に一番最初に出会うライブラリだと思います。
様々な機械学習フレームワークやデータ分析系のライブラリなどにも利用されているので
そのまま汎用的に使えそうなのも良いポイントです。

このようにもてはやされているPandasですが、実は気を付けないと
ハマりかねないポイントがいくつも用意されています。
今回はそんなポイントを思いつく箇所だけでも記載してみようと思います。

pandasとは

公式には以下のようにPandasが紹介されています。

pandas: powerful Python data analysis toolkit
pandas is a Python package that provides fast, flexible, and expressive data structures designed to make working with "relational" or "labeled" data both easy and intuitive. It aims to be the fundamental high-level building block for doing practical, real world data analysis in Python. Additionally, it has the broader goal of becoming the most powerful and flexible open source data analysis / manipulation tool available in any language. It is already well on its way towards this goal.

pandas · PyPI

日本語に訳すと以下のようになります。

pandas: 強力な Python データ分析ツールキット
pandas は、「リレーショナル」データまたは「ラベル付き」データの操作を簡単かつ直観的に行えるように設計された、高速かつ柔軟で表現力豊かなデータ構造を提供する Python パッケージです。これは、Python で実践的な現実世界のデータ分析を行うための基本的な高レベルの構成要素となることを目指しています。さらに、あらゆる言語で利用できる最も強力で柔軟なオープンソース データ分析/操作ツールになるという幅広い目標もあります。すでにこの目標に向けて順調に進んでいます。

まあPythonで表形式のデータを扱うのを楽にしたライブラリという感じで覚えてもらえればよいです。
DB見たいな処理ができます。

Pandasの最低限の知識

最低限の知識は以下がわかっていれば大丈夫です。
DataFrame:Pandasにおけるテーブルを扱う型のようなもの。ざっくり2次元配列だと理解しておいてください。
Series:Pandasにおけるベクトルを扱う型のようなもの。ざっくり1次元配列だと理解しておいてください。
index:Pandasにおいて、行を一意に指定する添え字。

今回はPandasの初心者向けの使い方については解説はしません。
詳しく知りたい方は以下サイトとか見てみてください。
Pandas 入門 — ディープラーニング入門:Chainer チュートリアル

ただ、Pandas特有の事項で合ったり分かっていないといけない部分に関しては適宜補足をします。

では、Pandasで詰まりやすいところを以下よりまとめてみたいと思います。

Pandasは列指向であること

pandasにてDataFrameを操作したりする時点で少し難しい感覚を覚えると思います。
普段csvから読み込んでいたりするとこの辺意識しないのであれ?となります。

とりあえず表形式なので2次元配列を突っ込んでみましょう。

import pandas as pd

table = [
    [1,2,3,4,5],
    [1,2,1,2,1],
    ['a','b','c','a','b']
]

df = pd.DataFrame(table)
print(df)

上記のプログラムですが実行結果は以下になります。

   0  1  2  3  4
0  1  2  3  4  5
1  1  2  1  2  1
2  a  b  c  a  b

まあそのまま出てきますね。
では、以下の入力はどうなるか想像してみてください。

import pandas as pd

value = {
'a':[1,2,3,4,5],
'b':[1,2,1,2,1],
'c':['a','b','c','a','b']
}
df = pd.DataFrame(value)
print(df)

答えは以下のようになります。

   a  b  c
0  1  1  a
1  2  2  b
2  3  1  c
3  4  2  a
4  5  1  b

あれ?っと思った方も多いのではないでしょうか。
そうなんです、dictを入れるとその列名が指定された状態で入るのです。
普段RDBなどでテーブルを扱う際には行単位で扱うことが多いと思いますが
pandasは列単位で扱う思想が強いです。なので、こういった通常のテーブルを作る際にも
RDBとは違った感覚で実施しないといけません。

ちなみに、2次元配列と同じように入れるためには、以下のように指定をする必要があるみたいです。

import pandas as pd

value = {
'a':[1,2,3,4,5],
'b':[1,2,1,2,1],
'c':['a','b','c','a','b']
}
df = pd.DataFrame.from_dict(value,orient='index')
print(df)

ちょっと面倒ですね。

また、列指向のため特定の行を指定するためには少し工夫が必要です。
通常の2次元配列と同じように扱うと地獄を見ます。

2次元配列では、1行目が欲しければ[1]とかで配列の添え字をつければよいのですが
pandasだと特殊なメソッドを利用しないといけないです。
例えば1行目を取得するには以下です。

import pandas as pd

table = [
    [1,2,3,4,5],
    [1,2,1,2,1],
    ['a','b','c','a','b']
]

df = pd.DataFrame(table)
print(df.loc[0])

少し特殊な関数を使わないといけないです。
(後述しますが、たまたまindexが0なので0と指定していますが、indexが別であった場合はそのキーを指定する必要があります。)

他にも列試行のためデータの挿入や削除に違和感を感じる部分は多いと思います。

上書きがプロパティ依存の話

以下出力の値は何になるでしょうか。
a=5
a+1
print(a)

6と答えたくなりますが、これで出力されるのは5ですね。
なぜなら変数を更新していないからです。

テーブルを扱うpandasでも同じようなことがよく発生します。
dfをいじる関数で出力されるのは自分自身を更新せず、dfのコピーを返します。
例えば行を削除する関数のdropとかはよく引っかかります。

import pandas as pd

value = {
'a':[1,2,3,4,5],
'b':[1,2,1,2,1],
'c':['a','b','c','a','b']
}
df = pd.DataFrame(value)

#上書きされていないのでdfは元のまま
df.drop([1])
print('no inplace:\n',df)

# dfが上書きされる。
df = df.drop([1])
# df.drop([1],inplace=True) こちらでも同じ意味となる。上の方が上書きされたのがわかりやすいので推奨されている。
print('inplaced:\n',df)

del df['a']
# 列の削除は強制上書きされる
print('del:\n',df)

pandasの設計思想とかでしょうか。
書き方には色々あるのですが、代表的な例だと上記のようになります。
(この辺りのシンタックスシュガーがあるのも結構厳しいポイントですね。Pythonは書き方が統一されるのが利点なのに…)

indexが難関な話

RDB上では必須の概念として主キーがあります。
RDBでは特定のカラムを指定して主キーにしますが、pandasでは、主キーと呼ばれるような概念としてIndexが存在します。
Indexはカラムとは別にある存在で、各行を一意に指定するラベルとなります。
ちなみに、インデックス、カラム両方Indexオブジェクトです。
詳細はこの人の記事とか参考になります。
PandasのIndexの理解と使い方まとめ - DeepAge

上記に記載の通り、IndexはあくまでラベルであるのでDBの主キーのような働きはしません。
そもそもDataFrameはRDBのように主キー制約や非NULL制約はありません。
なのでこのようなことは自ら気にしながら実装をしないと盛大にバグります。
例えば以下のようなことが普通にできます。

import pandas as pd

value = {
'a':[1,2,3,4,5],
'b':[1,2,1,2,1],
'a':['a','b','c','a','b']
}
df = pd.DataFrame(value)
print(df)

ちなみに上記実行結果は以下になります。

    a  b
0  a  1
1  b  2
2  c  1
3  a  2
4  b  1

こんな風にRDBとは違って制約は無いので
RDBと同じように使うと意図しない箇所でバグを引き起こす可能性もあります。

RDBとの比較やTipsはこの人の記事が参考になります。
pandas初級者に送りたいTips

無いに型がある話

プログラミング言語において切っても切り離せない存在の型。
型というのはその変数がどのような中身かを表したり、裏側ではメモリの確保に使われたりとプログラミング言語において型というのは意識せざるを得ないものです。

では皆さんに問題です。
数字の6は何型でしょうか。
そうです。代表的なものといえば皆さんご存知int(Integer)型ですね。
実際には数字の6だからといって型が明確にわかるわけではないのですが、大体何の型に入れればよいかは決まっています。

また問題です。
数字の1.6は何型でしょうか。
こちらもfloat型ですね。doubleでも良いですが、少数を表現できる型が必要です。

では、Nullは何型でしょうか。
そうですね。型なんてあるはずがないですね。Nullなんですから。

さて、pandasにはNan(Not a number)という値があります。
ではこいつは何型でしょうか。
察しがいい皆さん流石です。そうですね。float型ですね。

は?と思った皆さん、念のためもう一度言っておきます。
pandasのnanはfloat型です。

では確かめるために実行してみましょう

import pandas as pd

table = [
    [1,2,3,4,5],
    [1,2,1,2,1],
    ['a','b','a','b']
]
df = pd.DataFrame(
    table,
    columns=['col_0','col_1','col_2','col_3','col_4']

)
print(df)
print('type:',type(df['col_4'][2]))

結果は以下となります。

  col_0 col_1 col_2 col_3  col_4
0     1     2     3     4    5.0
1     1     2     1     2    1.0
2     a     b     a     b    NaN
type: <class 'numpy.float64'>

記載の通りNanはNumpyのfloat64型です。
後々話しますが、こいつが結構な悪さをしたりします。

ちなみに、PandasにおけるNullのようなものには
NoneとNanとNaTがあります。
NaTは(Not a Time)の略で、型は「」だそうです。

もう少し詳しく知りたい方は以下とか見てみると良いと思います。
Pythonで値がNaN(Not a Number)、NaT(Not a Time)、None、unknownの違いって? - ts0818のブログ

型推論が邪悪なこと

Pandasは型推論が邪悪な場合があります。

ではここで問題です。
以下のコードで作られるテーブルはどうなるでしょうか。

table = [
    [1,2,3,4,5],
    [1,2,1,2],
]

df = pd.DataFrame(
    table,
    columns=['col_0','col_1','col_2','col_3','col_4']
)

前の実行結果を見ていた方はわかると思いますが、正解はこちらです。

   col_0  col_1  col_2  col_3  col_4
0      1      2      3      4    5.0
1      1      2      1      2    NaN

あれ?型が変わっている奴が一人いますね…
そうなのです。
pandasではデータを読み込むときに型推論が発生し、
最も大きな型に列の値がすべて引っ張られます。

で、先ほど紹介した通り、Nanはfloat型のため
intより型として範囲が大きいfloatとして統一されます…


もっと恐ろしい例を示すと、以下のような場合型がめちゃくちゃになります。

table = [
    ['1',2,3.0,4,5],
    [1,2,1,2],
]

df = pd.DataFrame(
    table,
    columns=['col_0','col_1','col_2','col_3','col_4']
)

print(df.dtypes)

上記を実行した結果が以下となります。

col_0     object
col_1      int64
col_2    float64
col_3      int64
col_4    float64
dtype: object

で、これをそのままCSVなどに出力してしまうと、どうなるかお判りでしょう…
上記の例であれば10個しかデータが無いのでまだ目視確認ができますが
データ分析で1000万行とかの分析をし始めると意図しない事が発生します。
例えばIDで0埋めされている5桁の数値を読み取って
Pandas君が勝手にintだと判断して0を消してしまい条件で一致しなくなるという事も容易に発生します。
対策としてはデータを読み込む際には型を指定したり、pandraというpandasの型チェック用ライブラリを用いたりとかだと思います。

まとめ

PandasはPythonで表形式のデータを扱う上ではとても便利なのですが
RDBっぽく使おうとしたりするとそれなりに困難が発生します。
ただ、この辺きっちりと理解しておけばとても心強い味方になるはずなので
ぜひ色々試してみて詰まってみてください。

背景や課題に興味がある人はさらに以下の記事を読んでみても良いと思います。
(翻訳)Apache Arrowと「pandasの10項目の課題」 #Python - Qiita