はじめに
こんにちは、事業会社で働いているデータサイエンティストです:
普段の業務ではRやSQL、git、Docker関連のlinuxコマンドなどを使っていて、Pythonを利用することはほとんどないですが、言語仕様自体は理解しています。
以前Pythonの観点からRのキモいところをいっぱい説明する記事を出したら、かなり良い反応があったので、
今回はR、特にtidyverseのモダンな観点から、Pythonのキモいところを説明しようと考えております。
具体的にはR言語(R6オブジェクト指向プログラミングシステムは除く)には存在しえないミュータブルなオブジェクトの挙動と、pandasの型のゆるさを中心に共有できればと思います!
結論を先に話すと、Python書くときはPythonの言語仕様にあった書き方をしましょう。これはプログラミングを超えた社会の真理だと思います。無理矢理よそ(今回の場合はR)の思想を持ち込んでPythonは使えないと主張するのは間違っています。Python側のお作法にきちんと従えば、良質なデータ分析と開発ができるはずです。
では、早速始めたいと思います!
キモいポイントその1:自由すぎるpandas
まずはRの動作から確認しましょう。
numeric型のカラムに、無理やりcharacter型のものを入れようとすると、エラーが出ます:
> df <- tibble::tibble(
a = 1:3,
b = 1:3
)
> df[1,1] <- "hello"
Error in `[<-`:
! Assigned data `"hello"` must be compatible with existing data.
ℹ Error occurred for column `a`.
Caused by error in `vec_assign()`:
! Can't convert <character> to <integer>.
Run `rlang::last_trace()` to see where the error occurred.
だからRのtibbleデータフレイムを利用する際は、同じカラムの要素は皆同じ型であることを信じて開発・分析できます。
一方で、Pythonのpandasがどうなっているのかというと
>>> import pandas as pd
>>> df = pd.DataFrame(
... {
... "a" : [1,2,3],
... "b" : [4,5,6]
... }
... )
>>> df.iloc[0,0] = "hello"
>>> df
a b
0 hello 4
1 2 5
2 3 6
え、、、str型のhelloが入りました、、、
念の為カラムの型を確認する処理も入れてみると
>>> import pandas as pd
>>> df = pd.DataFrame(
... {
... "a" : [1,2,3],
... "b" : [4,5,6]
... }
... )
>>> print(df.dtypes)
a int64
b int64
dtype: object
df.iloc[0,0] = "hello"
df
print(df.dtypes)
>>> df.iloc[0,0] = "hello"
>>> df
a b
0 hello 4
1 2 5
2 3 6
>>> print(df.dtypes)
a object
b int64
dtype: object
helloを入れる前は、aカラムもbカラムもint64型だったんですが、helloをaの0番目の要素に入れた途端、aカラムがobject型になりました。
このように、pandas単体を利用する際は、同じカラムは同じ型であると信じてはいけません。pandasはRのtibbleより自由だからです。
なので、個人的に分析でやむをえずPythonを使うことになったら、pandasのデータフレイムに厳格な型検証を行わない限り、信用して使いません。思わぬエラーが想定外のところから出るからです。
また、業務でPythonを利用する経験がなくて詳細はわからないですが、pandasが生成したデータブレイムをBigQueryなどのデータベースに入れたり、APIエンドポイントの出力として出したりする前も、型の検証処理を一回挟んだ方がいいと思います。
もしくは、Python使いらしく、クラスを作って許容するデータをしっかり定義しましょう。
キモいポイントその2:データフレイムじゃなくなった!
ここでもまずはRを見ましょう。
Rのtibbleデータフレイムは、どんなindexを渡しても、エラーを吐くか、tibbleデータフレイムを返します:
> df <- tibble::tibble(
a = 1:3,
b = 1:3
)
> df[0,]
# A tibble: 0 × 2
# ℹ 2 variables: a <int>, b <int>
> df[0]
# A tibble: 3 × 0
> df[,1]
# A tibble: 3 × 1
a
<int>
1 1
2 2
3 3
> df[10,10]
Error in `df[10, 10]`:
! Can't subset columns past the end.
ℹ Location 10 doesn't exist.
ℹ There are only 2 columns.
Run `rlang::last_trace()` to see where the error occurred.
> df[0,0]
# A tibble: 0 × 0
> df[1,1]
# A tibble: 1 × 1
a
<int>
1 1
> df[2,2]
# A tibble: 1 × 1
b
<int>
1 2
いいですね、どんなindexを渡されても型は変わりません。tibbleのままです。
Python側もきっと大丈夫かな?
import pandas as pd
df = pd.DataFrame(
{
"a" : ["hello",2,3],
"b" : [4,5,"apple"]
}
)
この設定でやってみましょう!
>>> df.iloc[0,:]
a hello
b 4
Name: 0, dtype: object
>>> df.iloc[0]
a hello
b 4
Name: 0, dtype: object
>>> df.iloc[:,1]
0 4
1 5
2 apple
Name: b, dtype: object
いいですね!
>>> df.iloc[10,10]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/Users/dongwenwu/Library/Python/3.8/lib/python/site-packages/pandas/core/indexing.py", line 1096, in __getitem__
return self.obj._get_value(*key, takeable=self._takeable)
File "/Users/dongwenwu/Library/Python/3.8/lib/python/site-packages/pandas/core/frame.py", line 3867, in _get_value
series = self._ixs(col, axis=1)
File "/Users/dongwenwu/Library/Python/3.8/lib/python/site-packages/pandas/core/frame.py", line 3664, in _ixs
label = self.columns[i]
File "/Users/dongwenwu/Library/Python/3.8/lib/python/site-packages/pandas/core/indexes/base.py", line 5175, in __getitem__
return getitem(key)
IndexError: index 10 is out of bounds for axis 0 with size 2
エラーも出ています!
きっと大丈夫だよなー
>>> df.iloc[0,0]
'hello'
おーい!データフレイムじゃなくなってやん!strさんやんお前!!!
なので、Pythonの処理、特にAPIとフロントエンドを作る際は、一個の要素しか選択されないようなリクエストに対する例外処理をきちんとしていきましょう。
もしくはデータフレイムに全てをお願いするのではなく、自分でクラスを作るやり方もあります。
R使いとしての感想ですが、だからPythonが大量にクラスを作りたがるよなと思います。
キモいポイントその3:勝手に書き換えるな!
さて、Pythonで関数を作る時間です!aカラムの全ての要素に2をかけて、新しいデータフレイムを返す処理をしましょう:
>>> import pandas as pd
>>> df = pd.DataFrame(
... {
... "a" : ["hello",2,3],
... "b" : [4,5,"apple"]
... }
... )
>>> def kakeru_ni(df):
... new_df = df
... new_df["a"] = new_df["a"] * 2
... return new_df
...
>>> kakeru_ni(df)
a b
0 hellohello 4
1 4 5
2 6 apple
ふむふむ、なるほど、Pythonのstr型も四則演算が一部できるから、掛け算のところでエラーが出ないですね、、、こうやってデータフレイム内の汚染された内容がずっと残って、どこかで深刻なバグを引き起こすんですね。
あれ?そういえばブレッド&バターワイナリーのワインを飲み過ぎたので、dfってどんな内容だったっけ?
見てみよ:
>>> df
a b
0 hellohello 4
1 4 5
2 6 apple
おーい!書き換えられたやないかい!!!!!!
これpandasのデータフレイムはRに存在していない・存在してはいけない(R6オブジェクト指向プログラミングシステム以外)、ミュータブルなオブジェクトだからです。
中の動作を可視化するため、変数の内容がどこに格納されているのかを可視化しましょう。Pythonではhex(id(x))で確認できます。
>>> import pandas as pd
>>> df = pd.DataFrame(
... {
... "a" : ["hello",2,3],
... "b" : [4,5,"apple"]
... }
... )
>>> hex(id(df))
'0x117303130'
>>> def kakeru_ni(df):
... new_df = df
... print(hex(id(new_df)))
... new_df["a"] = new_df["a"] * 2
... return new_df
...
>>> kakeru_ni(df)
0x117303130
a b
0 hellohello 4
1 4 5
2 6 apple
>>> df
a b
0 hellohello 4
1 4 5
2 6 apple
>>>
>>> hex(id(df))
'0x117303130'
なるほど、そもそも関数内でR使いが新規作成したと勘違いするnew_dfも、実は元のdfと同じ0x117303130
オブジェクトです。だから書き換え処理をしたら、元のデータフレイムにも影響が波及します。
ちなみにRはどうなのかというと、まずtibbleデータフレイムを作って、その変数の住所を確認しましょう:
> df <- tibble::tibble(
a = 1:3,
b = 1:3
)
> tracemem(df)
[1] "<0x13f451848>"
続いて、kakeru_niを定義します。ここでは、Rのcopy on modify(変更が加わったら、コピーしてオブジェクトを完全に新規作成する)の動作を説明するために、変更前後に住所をprintさせます。
> kakeru_ni <- function(df){
new_df <- df
print(tracemem(new_df))
new_df <- new_df |>
dplyr::mutate(
a = a * 2
)
print(tracemem(new_df))
return(new_df)
}
早速dfを関数に入れましょう!
> kakeru_ni(df)
[1] "<0x13f451848>"
tracemem[0x13f451848 -> 0x10ddec708]: initialize <Anonymous> mutate_cols mutate.data.frame <Anonymous> kakeru_ni
tracemem[0x10ddec708 -> 0x10ddecb08]: names<-.tbl_df names<- initialize <Anonymous> mutate_cols mutate.data.frame <Anonymous> kakeru_ni
tracemem[0x10ddecb08 -> 0x10ddecc88]: dplyr_new_list initialize <Anonymous> mutate_cols mutate.data.frame <Anonymous> kakeru_ni
tracemem[0x10ddecc88 -> 0x10ddece88]: dplyr_new_list initialize <Anonymous> mutate_cols mutate.data.frame <Anonymous> kakeru_ni
tracemem[0x13f451848 -> 0x11e576188]: new_data_frame vec_data as.list dplyr_col_modify.data.frame dplyr_col_modify mutate.data.frame <Anonymous> kakeru_ni
tracemem[0x11e576188 -> 0x10e5b1bc8]: as.list.data.frame as.list dplyr_col_modify.data.frame dplyr_col_modify mutate.data.frame <Anonymous> kakeru_ni
tracemem[0x10e5b1bc8 -> 0x10e5b2748]: new_data_frame dplyr_col_modify.data.frame dplyr_col_modify mutate.data.frame <Anonymous> kakeru_ni
tracemem[0x10e5b2748 -> 0x10e645688]: new_data_frame dplyr_new_data_frame dplyr_reconstruct dplyr_col_modify.data.frame dplyr_col_modify mutate.data.frame <Anonymous> kakeru_ni
tracemem[0x10e645688 -> 0x10e678748]: dplyr_reconstruct.data.frame dplyr_reconstruct_dispatch dplyr_reconstruct dplyr_col_modify.data.frame dplyr_col_modify mutate.data.frame <Anonymous> kakeru_ni
[1] "<0x14b5ee208>"
tracemem[0x14b5ee208 -> 0x139769a88]: as.data.frame.tbl_df as.data.frame vec_size vec_head df_head tbl_format_setup.tbl tbl_format_setup_dispatch tbl_format_setup format_tbl format.tbl format writeLines print_tbl print.tbl <Anonymous>
tracemem[0x139769a88 -> 0x139769988]: as.list.data.frame as.list vapply map_mold map_lgl which as.data.frame.tbl_df as.data.frame vec_size vec_head df_head tbl_format_setup.tbl tbl_format_setup_dispatch tbl_format_setup format_tbl format.tbl format writeLines print_tbl print.tbl <Anonymous>
tracemem[0x139769a88 -> 0x139769648]: tbl_subassign [<-.tbl_df [<- as.data.frame.tbl_df as.data.frame vec_size vec_head df_head tbl_format_setup.tbl tbl_format_setup_dispatch tbl_format_setup format_tbl format.tbl format writeLines print_tbl print.tbl <Anonymous>
tracemem[0x139769648 -> 0x139768d48]: tbl_subassign_col tbl_subassign [<-.tbl_df [<- as.data.frame.tbl_df as.data.frame vec_size vec_head df_head tbl_format_setup.tbl tbl_format_setup_dispatch tbl_format_setup format_tbl format.tbl format writeLines print_tbl print.tbl <Anonymous>
tracemem[0x139768d48 -> 0x139768cc8]: vectbl_restore tbl_subassign [<-.tbl_df [<- as.data.frame.tbl_df as.data.frame vec_size vec_head df_head tbl_format_setup.tbl tbl_format_setup_dispatch tbl_format_setup format_tbl format.tbl format writeLines print_tbl print.tbl <Anonymous>
# A tibble: 3 × 2
a b
<dbl> <int>
1 2 1
2 4 2
3 6 3
初回のnew_df住所printは変更がまだないため、元のdfと同じ0x13f451848
ですが、dplyrによる変更を加えたら、住所が0x14b5ee208
になってしまいました。ちなみにdf自体は元のままです。
> df
tracemem[0x13f451848 -> 0x139cdf308]: as.data.frame.tbl_df as.data.frame vec_size vec_head df_head tbl_format_setup.tbl tbl_format_setup_dispatch tbl_format_setup format_tbl format.tbl format writeLines print_tbl print.tbl <Anonymous>
tracemem[0x139cdf308 -> 0x139cdf2c8]: as.list.data.frame as.list vapply map_mold map_lgl which as.data.frame.tbl_df as.data.frame vec_size vec_head df_head tbl_format_setup.tbl tbl_format_setup_dispatch tbl_format_setup format_tbl format.tbl format writeLines print_tbl print.tbl <Anonymous>
tracemem[0x139cdf308 -> 0x139cdf088]: tbl_subassign [<-.tbl_df [<- as.data.frame.tbl_df as.data.frame vec_size vec_head df_head tbl_format_setup.tbl tbl_format_setup_dispatch tbl_format_setup format_tbl format.tbl format writeLines print_tbl print.tbl <Anonymous>
tracemem[0x139cdf088 -> 0x139cde948]: tbl_subassign_col tbl_subassign [<-.tbl_df [<- as.data.frame.tbl_df as.data.frame vec_size vec_head df_head tbl_format_setup.tbl tbl_format_setup_dispatch tbl_format_setup format_tbl format.tbl format writeLines print_tbl print.tbl <Anonymous>
tracemem[0x139cde948 -> 0x139cde8c8]: vectbl_restore tbl_subassign [<-.tbl_df [<- as.data.frame.tbl_df as.data.frame vec_size vec_head df_head tbl_format_setup.tbl tbl_format_setup_dispatch tbl_format_setup format_tbl format.tbl format writeLines print_tbl print.tbl <Anonymous>
# A tibble: 3 × 2
a b
<int> <int>
1 1 1
2 2 2
3 3 3
>
> tracemem(df)
[1] "<0x13f451848>"
なので、変更が行われた際、ミュータブルなpandasは、元の変数を書き換えるのに対して、ミュータブルでないRのtibbleは変数を本当の意味で新規作成します。
なぜPythonがこのような言語仕様を選んだのかを考えてみましょう。
データサイエンティストが多いR使いにとって、変数をいじってみて、問題なければ結果を保存し、問題があったら変更前の状態に切り戻したいです。
> untracemem(df) #うるさいから止めますね
> df |> dplyr::mutate(a = a * 2)
# A tibble: 3 × 2
a b
<dbl> <int>
1 2 1
2 4 2
3 6 3
なるほど、aに2を足したらこうなるよね、では3を足したらどうなるのかな?のような試行錯誤が繰り返されます。
Pythonのように全ての試行錯誤処理が元の変数を破壊すると、切り戻しが効かなくなり、結局データフレイムを作り直さないといけません。
>>> import pandas as pd
>>> df = pd.DataFrame(
... {
... "a" : ["hello",2,3],
... "b" : [4,5,"apple"]
... }
... )
>>> df["a"] = df["a"] * 2
>>> df
a b
0 hellohello 4
1 4 5
2 6 apple
{"a" : ["hello",2,3],"b" : [4,5,"apple"]}
のDictしか入っていないデータフレイムならどうでもいいですが、初期作成自体が重いデータフレイムの場合、無駄な再作成処理に時間がかかってしまいます。
ただ、この「色々試してダメだったら切り戻そう」という発想自体が非常にデータサイエンティスト的です。
全ての処理内容が決まったシステムを作るとき、やらないといけない処理が事前に100%決まっているはずだから、変数を人間が試行錯誤的にいじったりすることはあり得ないはずです。
このような状態で、変更が発生するたびに
- 変数の元の内容をコピー
- 変更を加える
のようなことをやると、場合によって膨大なメモリを消耗してしまいます。
10テラのデータフレイムを考えてください。df |> dplyr::mutate(a = a + 3)
を実行するたびにマシンが20テラ(そう、10テラではないです)のメモリを用意しないといけません。きついです。
だから、元の10テラのデータフレイムをそのまま変更しちゃった方が合理的です。
結論としては、Python的には、システムを作る処理を設計する際は、ミュータブル性が問題を起こすようなことはするなと個人的に理解しています。
ここで一つR使いの感想を話すと、APIやバッチ処理を作るときって、テラバイト級のものが入りそうだったらそもそもBigQueryで最初のwhere処理でデータを切ってから入れたり、もしくはMapReduceのように処理を分割したりするのが正しいと思います。
また、現代の計算スペックは有能なので、ギガバイト級のデータが一時的に2倍になっても問題はあまり起こらないはずです、経験上。
なので、社会科学出身で主にいわゆる「テーブルデータ」とテキストデータしか扱わないデータサイエンティストとしては、関数の影響範囲の予測可能性を犠牲にするまで、メモリを節約する必要があるのかというと、正直あまりないのではと思います。
(Rは3,099,254行のデータも簡単に扱えます)
逆にミュータブルな変数の切り戻しに伴う再作成に膨大な時間がかかる可能性があります。
もちろん、個人の経験で感じたことなので、一般的に言えることではないかもしれません。
結論
いかがでしたか?
RとPythonの違いは1始まり・ゼロ始まりと非標準評価(厳密にいうとtidy評価などで利用されるquoteの存在)の有無だけではありません。両者にもっと深いところで言語設計のフィロソフィーが違います。
他の言語をやるときは、しっかりとフィロソフィーの違いを押さえておくと、よりスムーズに上達できますよ!
皆さんもぜひしっかり言語仕様を理解してPythonを活用していきましょうー