update
- 2024-03-17: 文字列操作の実践ケース(mapメソッドにdictを与えた変換)を追加
- 2024-03-20: 文字列操作のベンチマークにいくつかのメソッドを追加
背景
Pandas は現在 major version が2系だが、2024年4月(以降)に 3.0のリリースが計画 されているようである(おそらく遅延するだろうと予測するが)。執筆時点での最新版は2.2.1であるが、2.2.0のリリースノートにて、pandas 3.0でのstring date typeの変更 についての記載を見つけたため、自分の調査した結果を、この記事でまとめることとした。
なお、pandas における pyarrow型の取り扱いはexperimentalであるため、2.2より将来で変更される可能性には留意が必要だ。
Prerequisites
import pandas # repr表示をそのまま実行できるようにするため
import pandas as pd
import pyarrow as pa
from pprint import pprint
pd.set_option('display.max_colwidth', 511)
print(pd.__version__)
# 2.2.1
print(pa.__version__)
# 15.0.1
今、何を使うべきかの個人的結論
- 互換性を大切にしたいか? Yes ->
str
. No -> 次へ - experimentalを承知の上、ともかく内部メモリを削減したいか? Yes ->
pd.ArrowDtype(pa.string())
, No -> 次へ - 特に深く考えず
pd.NA
を統一的に扱いたいか ->pd.StringDtype("python")
(ie, "string" 指定、csv読み込み時はpd.read_csv(dtype_backend="numpy_nullable")
で簡単指定) - 何もせずしばらく様子見
コードでの挙動
まずは型指定とその実データで確認することにしてみる。何度も強調するが、v2.2.1 時点で experimental なため、将来の挙動について何も保証はない。
確認のためのデータ構成
-
pd.Series()
にはdtype=
の指定が可能であるのでここで型を指定する - s0~s3は文字列でも指定が可能であるが、ここでは型クラスで指定する
- missing value として
None
,pd.NA
,float("nan")
を指定してみる
s0 = pd.Series(["s0", None, pd.NA, float("nan")], name="s0", dtype=str) # dtype="str"
s1 = pd.Series(["s1", None, pd.NA, float("nan")], name="s1", dtype=pd.StringDtype("python")) # dtype="string[python]" or dtype="string"
s2 = pd.Series(["s2", None, pd.NA, float("nan")], name="s2", dtype=pd.StringDtype("pyarrow")) # dtype="string[pyarrow]"
s3 = pd.Series(["s3", None, pd.NA, float("nan")], name="s3", dtype=pd.StringDtype("pyarrow_numpy")) # dtype=string[pyarrow_numpy]"
s4 = pd.Series(["s4", None, pd.NA, float("nan")], name="s4", dtype=pd.ArrowDtype(pa.string()))
s5 = pd.Series(["s5", None, pd.NA, float("nan")], name="s5", dtype=pd.ArrowDtype(pa.large_string()))
なお、既存の文字型のSeriesは astype
で相互変換可能である。
s0.astype(pd.ArrowDtype(pa.large_string())).dtype
# large_string[pyarrow]
データの確認
s0~s5 の dtype や dtypeのtypeを確認してみる。
ss = [s0, s1, s2, s3, s4, s5]
各Seriesの要素について
pd.concat(ss, axis=1)
# s0 s1 s2 s3 s4 s5
# 0 s0 s1 s2 s3 s4 s5
# 1 None <NA> <NA> NaN <NA> <NA>
# 2 <NA> <NA> <NA> NaN <NA> <NA>
# 3 NaN <NA> <NA> NaN <NA> <NA>
各要素のtype
pprint({s.name: s.map(type).tolist() for s in ss})
{'s0': [<class 'str'>,
<class 'NoneType'>,
<class 'pandas._libs.missing.NAType'>,
<class 'float'>],
's1': [<class 'str'>,
<class 'pandas._libs.missing.NAType'>,
<class 'pandas._libs.missing.NAType'>,
<class 'pandas._libs.missing.NAType'>],
's2': [<class 'str'>,
<class 'pandas._libs.missing.NAType'>,
<class 'pandas._libs.missing.NAType'>,
<class 'pandas._libs.missing.NAType'>],
's3': [<class 'str'>, <class 'float'>, <class 'float'>, <class 'float'>],
's4': [<class 'str'>,
<class 'pandas._libs.missing.NAType'>,
<class 'pandas._libs.missing.NAType'>,
<class 'pandas._libs.missing.NAType'>],
's5': [<class 'str'>,
<class 'pandas._libs.missing.NAType'>,
<class 'pandas._libs.missing.NAType'>,
<class 'pandas._libs.missing.NAType'>]}
- s0: オブジェクト型なので入力値通り
- s3: missing value は全て
float
型(すなわちfloat("nan")
)として扱われる - s1,s2,s4,s5: missing value は全て
pd.NA
として扱われる
dtypeについて
pprint({s.name: s.dtypes for s in ss})
{'s0': dtype('O'),
's1': string[python],
's2': string[pyarrow],
's3': string[pyarrow_numpy],
's4': string[pyarrow],
's5': large_string[pyarrow]}
- s0は伝統的な
dtype('O')
のオブジェクト型である - S1~S4は
string[...]
のように型パラメータが表示されている - s2とs4の表示は
string[pyarrow]
同一だが、次述のtype(s.dtype) は異なるため、dtype指定と一対一ではない(要注意)
s3の string[pyarrow_numpy]
について、公式ドキュメント 2.2.0 Release 時の Upcoming Change in Pandas 3.0 では、3.0でデフォルトになるとの記述がある(本当にそうするのか?)。2.2.1でも下記のように pd.options.future.infer_string = True
を設定すると、挙動を変更できる。
with pd.option_context("future.infer_string", True):
ser = pd.Series(["a", "b"])
repr(ser.dtype)
# 'string[pyarrow_numpy]'
ser = pd.Series(["a", "b"])
repr(ser.dtype)
# "dtype('O')"
個人的な感想だが、この調査をするまでこの型の存在すら知らなかったし、後述の挙動の比較からもデフォルトとして扱うには他よりそこまで利点を感じない。
dtype の type について
pprint({s.name: type(s.dtypes) for s in ss})
{'s0': <class 'numpy.dtypes.ObjectDType'>,
's1': <class 'pandas.core.arrays.string_.StringDtype'>,
's2': <class 'pandas.core.arrays.string_.StringDtype'>,
's3': <class 'pandas.core.arrays.string_.StringDtype'>,
's4': <class 'pandas.core.dtypes.dtypes.ArrowDtype'>,
's5': <class 'pandas.core.dtypes.dtypes.ArrowDtype'>}
- s0は素のnumpy dtypeのオブジェクト型
- s1~s3は pandas.core.arrays.string_.StringDtype(いわゆる Extension Array)
- s4~s5は pandas.core.dtypes.dtypes.ArrowDtype (おそらく素の arrowベース)
dtype の array type について
pprint({s.name: s.dtypes.construct_array_type() if hasattr(s.dtypes, "construct_array_type") else "---" for s in ss})
{'s0': '---',
's1': <class 'pandas.core.arrays.string_.StringArray'>,
's2': <class 'pandas.core.arrays.string_arrow.ArrowStringArray'>,
's3': <class 'pandas.core.arrays.string_arrow.ArrowStringArrayNumpySemantics'>,
's4': <class 'pandas.core.arrays.arrow.array.ArrowExtensionArray'>,
's5': <class 'pandas.core.arrays.arrow.array.ArrowExtensionArray'>}
- s0は存在しない(素のnumpy dtypeなので)
- s1,s2,s3の
string[...]
の array type の実態はそれぞれ異なる - s4,s5は同一で、array typeは arrowベースの
ArrowExtensionArray
もっと簡単な確認方法
Series.array
の repr 表示が一番楽かもしれない
[repr(s.array) for s in ss]
["<NumpyExtensionArray>\n['s0', None, <NA>, nan]\nLength: 4, dtype: object",
"<StringArray>\n['s1', <NA>, <NA>, <NA>]\nLength: 4, dtype: string",
"<ArrowStringArray>\n['s2', <NA>, <NA>, <NA>]\nLength: 4, dtype: string",
"<ArrowStringArrayNumpySemantics>\n['s3', nan, nan, nan]\nLength: 4, dtype: string",
"<ArrowExtensionArray>\n['s4', <NA>, <NA>, <NA>]\nLength: 4, dtype: string[pyarrow]",
"<ArrowExtensionArray>\n['s5', <NA>, <NA>, <NA>]\nLength: 4, dtype: large_string[pyarrow]"]
型クラスそのものからの情報
実は上記 s1~s5で指定した型クラスそのもの method, property から各種情報を確認することがきる。
string_types = [
pd.StringDtype("python"),
pd.StringDtype("pyarrow"),
pd.StringDtype("pyarrow_numpy"),
pd.ArrowDtype(pa.string()),
pd.ArrowDtype(pa.large_string())
]
pprint([{
"variable": f"s{i}",
"name": d.name,
"storage": d.storage,
"na_value": str(d.na_value),
"type": str(d.type),
"construct_array_type": str(d.construct_array_type()),
"_metadata": str(d._metadata),
}
for i, d in enumerate(string_types, 1)],
sort_dicts=False, width=120)
[{'variable': 's1',
'name': 'string',
'storage': 'python',
'na_value': '<NA>',
'type': "<class 'str'>",
'construct_array_type': "<class 'pandas.core.arrays.string_.StringArray'>",
'_metadata': "('storage',)"},
{'variable': 's2',
'name': 'string',
'storage': 'pyarrow',
'na_value': '<NA>',
'type': "<class 'str'>",
'construct_array_type': "<class 'pandas.core.arrays.string_arrow.ArrowStringArray'>",
'_metadata': "('storage',)"},
{'variable': 's3',
'name': 'string',
'storage': 'pyarrow_numpy',
'na_value': 'nan',
'type': "<class 'str'>",
'construct_array_type': "<class 'pandas.core.arrays.string_arrow.ArrowStringArrayNumpySemantics'>",
'_metadata': "('storage',)"},
{'variable': 's4',
'name': 'string[pyarrow]',
'storage': 'pyarrow',
'na_value': '<NA>',
'type': "<class 'str'>",
'construct_array_type': "<class 'pandas.core.arrays.arrow.array.ArrowExtensionArray'>",
'_metadata': "('storage', 'pyarrow_dtype')"},
{'variable': 's5',
'name': 'large_string[pyarrow]',
'storage': 'pyarrow',
'na_value': '<NA>',
'type': "<class 'str'>",
'construct_array_type': "<class 'pandas.core.arrays.arrow.array.ArrowExtensionArray'>",
'_metadata': "('storage', 'pyarrow_dtype')"}]
コンストラクタからの直接の作成
以下のように、構成されているデータが np.array
か pa.array
のどちらかが明瞭である。
pd.arrays.NumpyExtensionArray(np.array(["s0",None,pd.NA,float("nan")], dtype="object")) # s0.array
pd.arrays.StringArray(np.array(["s1",None,None,None])) # s1.array
pd.arrays.ArrowStringArray(pa.array(["s2",None,None,None])) # s2.array
# pd.arrays には公開されていない
pd.core.arrays.string_arrow.ArrowStringArrayNumpySemantics(pa.array(["s3",None,None,None])) # s3
pd.arrays.ArrowExtensionArray(pa.array(["s4",None,None,None])) # s4.array
pd.arrays.ArrowExtensionArray(pa.array(["s5",None,None,None], type="large_string")) # s5.array
文字列操作後の型について
str -> bool
の真偽値変換
s0.str.contains("x").dtypes,\
s1.str.contains("x").dtypes,\
s2.str.contains("x").dtypes,\
s3.str.contains("x").dtypes,\
s4.str.contains("x").dtypes,\
s5.str.contains("x").dtypes
# (dtype('O'), BooleanDtype, BooleanDtype, dtype('O'), bool[pyarrow], bool[pyarrow])
- s0, s3 は 伝統的なObject型となる。s3は NaN を含んでいるからであるが、NaNを含まない場合は
bool型
となるので注意>>> s0.str.contains("x") 0 False 1 None 2 <NA> 3 NaN Name: s0, dtype: object >>> s3.str.contains("x") 0 False 1 NaN 2 NaN 3 NaN Name: s3, dtype: object >>> s3.fillna("x").dtypes string[pyarrow_numpy] >>> s3.fillna("x").str.contains("x") 0 False 1 True 2 True 3 True Name: s3, dtype: bool
- s1, s2は
boolean
(pd.NAを含むことができるExtension Type)となる - s4, s5は
bool[pyarrow]
となる
ここで、 s3 の挙動について nan
の有無により型が変わりうる問題について issue#54805 でも報告されている。
str -> int
の変換について
s0.str.len().dtypes,\
s1.str.len().dtypes,\
s2.str.len().dtypes,\
s3.str.len().dtypes,\
s4.str.len().dtypes,\
s5.str.len().dtypes
# (dtype('O'), Int64Dtype(), Int64Dtype(), dtype('float64'), int32[pyarrow], int64[pyarrow])
s0.fillna("x").str.len().dtypes,\
s1.fillna("x").str.len().dtypes,\
s2.fillna("x").str.len().dtypes,\
s3.fillna("x").str.len().dtypes,\
s4.fillna("x").str.len().dtypes,\
s5.fillna("x").str.len().dtypes
# (dtype('int64'), Int64Dtype(), Int64Dtype(), dtype('int64'), int32[pyarrow], int64[pyarrow])
真偽値変換と同様の傾向である。すなわち:
- s0, s3 は 伝統的な型(単純なnumpyベース)となる。s3は NaN を含んでいるためint系ではなく
float64
となっている、NaNを含まない場合はint64
となることに注意 - s1, s2は
Int64
型 (pd.NAを含むことができるExtension Type)となる - s4は
int32[pyarrow]
、s5はint64[pyarrow]
となる。large_string
2GB以上の文字を扱う型なのでは サイズint64 を返す型となるのは整合的であろう
もう少し実践的なケース
male
, female
を 0
, 1
の 数値に変換。適度に missing value を含んだものとする。
import random
random.seed(15)
s = random.choices(["male","female",None],weights=[49,49,2],k=1_000_000)
ss0 = pd.Series(s, dtype="str")
ss1 = pd.Series(s, dtype=pd.StringDtype("python"))
ss2 = pd.Series(s, dtype=pd.StringDtype("pyarrow"))
ss3 = pd.Series(s, dtype=pd.StringDtype("pyarrow_numpy"))
ss4 = pd.Series(s, dtype=pd.ArrowDtype(pa.string()))
ss5 = pd.Series(s, dtype=pd.ArrowDtype(pa.large_string()))
ケース1
pd.Series.map で dictionaryを引数に与える場合、全ての結果は nan
を含んだfloat64となる。
[s.map({"male":0, "female":1}).head() for s in [ss0, ss1, ss2, ss3, ss4, ss5]]
[0 1.0
1 0.0
2 1.0
3 0.0
4 NaN
dtype: float64,
0 1.0
1 0.0
2 1.0
3 0.0
4 NaN
dtype: float64,
0 1.0
1 0.0
2 1.0
3 0.0
4 NaN
dtype: float64,
0 1.0
1 0.0
2 1.0
3 0.0
4 NaN
dtype: float64,
0 1.0
1 0.0
2 1.0
3 0.0
4 NaN
dtype: float64,
0 1.0
1 0.0
2 1.0
3 0.0
4 NaN
dtype: float64]
このケース1での実行速度は以下の通り(ipython magic)。s0,s1が10倍程度早い。s2~s5のarrow系データの操作全般遅いのは少し予想外だった。
In [290]: %timeit ss0.map({"male":0, "female":1})
18.4 ms ± 472 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
In [291]: %timeit ss1.map({"male":0, "female":1})
12 ms ± 191 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
In [292]: %timeit ss2.map({"male":0, "female":1})
114 ms ± 3.65 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
In [293]: %timeit ss3.map({"male":0, "female":1})
120 ms ± 2.22 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
In [294]: %timeit ss4.map({"male":0, "female":1})
128 ms ± 4.44 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
In [295]: %timeit ss5.map({"male":0, "female":1})
124 ms ± 3.28 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
なお、mapの引数のdictのkeyに含まれない値は nan
となることに注意。s0~s5全て共通。
ss0.map({"male":0}).head()
0 NaN
1 0.0
2 NaN
3 0.0
4 NaN
dtype: float64
defaultdictを与えれば、keyに含まれない値の変換後の値を指定ができる。s0~s5全て共通。
from collections import defaultdict
ss0.map(defaultdict(lambda: -1, {"male": 0})).head()
0 -1
1 0
2 -1
3 0
4 -1
dtype: int64
ケース2
pd.Series.mask について、s1~25はそもそも 文字列 -> 数値
の型変換を伴う場合はエラーとなる。文字列- > 文字列|pd.NA
のみ受け付けるようだ。
なお Pandas 2.2.0 から導入された case_when
メソッドも、内部では mask
が使われているのでエラーとなる。
pd.Series(["male", "male"], dtype="string[python]").mask(lambda s: s=="male", 0)
ValueError: StringArray requires a sequence of strings or pandas.NA
mask実行後の型がmixするケースも(そもそも 文字列 -> 数値
の変換の時点で)、s1~s5は当然エラーとなる。
ss0.mask(lambda s: s=="male", 0).head()
# Out[230]:
# 0 female
# 1 0
# 2 female
# 3 0
# 4 None
# dtype: object
ss1.mask(lambda s: s=="male", 0).head()
# ValueError: StringArray requires a sequence of strings or pandas.NA
ss2.mask(lambda s: s=="male", 0).head()
# TypeError: Scalar must be NA or str
ss3.mask(lambda s: s=="male", 0).head()
# TypeError: Scalar must be NA or str
ss4.mask(lambda s: s=="male", 0).head()
# TypeError: Invalid value '0' for dtype string[pyarrow]
ss5.mask(lambda s: s=="male", 0).head()
# TypeError: Invalid value '0' for dtype large_string[pyarrow]
メモリ使用量について
結論:
-
str
,pd.StringDtype("python")
の使用量は同量で一番多い - その他は上記より少ない。おおよそ
str
の25%~50%程度 - その中でもさらに
pd.ArrowDtype(pa.string())
は 10~20%少なくなりうる
100万行、要素は1文字
要素が1文字は6~10倍程度効率的が異なる。ただし人工データとしての参考値でしかない。
df0 = (
pd.DataFrame({
"s0": ["x"] * n,
"s1": ["x"] * n,
"s2": ["x"] * n,
"s3": ["x"] * n,
"s4": ["x"] * n,
"s5": ["x"] * n,
})
.astype({
"s0": str,
"s1": pd.StringDtype("python"),
"s2": pd.StringDtype("pyarrow"),
"s3": pd.StringDtype("pyarrow_numpy"),
"s4": pd.ArrowDtype(pa.string()),
"s5": pd.ArrowDtype(pa.large_string()),
})
)
df0.memory_usage(deep=True)
# Index 132
# s0 58000000
# s1 58000000
# s2 9000000
# s3 9000000
# s4 5000000
# s5 9000000
# dtype: int64
- s0, s1 が58MBと突出
- s2, s3, s5 が 9MB
- s4 が5MB
100万行、要素長は36文字(ハイフン込みuuid文字列)
uuid文字列はシナリオとしてありうる。2倍ほど効率的
import uuid
def uuids(n: int) -> list[str]:
return [str(uuid.uuid4()) for _ in range(n)]
n = 1_000_000
df1 = (
pd.DataFrame({
"s0": uuids(n),
"s1": uuids(n),
"s2": uuids(n),
"s3": uuids(n),
"s4": uuids(n),
"s5": uuids(n),
})
.astype({
"s0": str,
"s1": pd.StringDtype("python"),
"s2": pd.StringDtype("pyarrow"),
"s3": pd.StringDtype("pyarrow_numpy"),
"s4": pd.ArrowDtype(pa.string()),
"s5": pd.ArrowDtype(pa.large_string()),
})
)
df1.memory_usage(deep=True)
# Index 132
# s0 93000000
# s1 93000000
# s2 44000000
# s3 44000000
# s4 40000000
# s5 44000000
- s0, s1 が93MB
- s2, s3, s5が40MB
- s4 が40MB
100万行、1~15文字ランダム
人工データだがありうるケース。4.0~5.4倍ほど効率
import random, string
def random_chars(n: int, k: int, seed: int = 1) -> list[str]:
random.seed(seed)
candi = string.ascii_letters + string.ascii_uppercase
r = range(1, k)
def k_word() -> str:
return "".join(random.choices(candi, k=random.choice(r)))
return [k_word() for n in range(n)]
n = 1_000_000
k = 16
df2 = (
pd.DataFrame({
"a": random_chars(n, k),
"b": random_chars(n, k),
"c": random_chars(n, k),
"d": random_chars(n, k),
"e": random_chars(n, k),
"f": random_chars(n, k),
})
.astype({
"a": str,
"b": pd.StringDtype("python"),
"c": pd.StringDtype("pyarrow"),
"d": pd.StringDtype("pyarrow_numpy"),
"e": pd.ArrowDtype(pa.string()),
"f": pd.ArrowDtype(pa.large_string()),
})
)
df2.memory_usage(deep=True)
# Index 132
# s0 65001139
# s1 65001139
# s2 16001139
# s3 16001139
# s4 12001139
# s5 16001139
# dtype: int64
- s0, s1 が65MB
- s2, s3, s5が16MB
- s4 が12MB
文字操作の実行速度について
実行速度について、内部的にarrowを使うs2, s3, s4, s5が 5(~20)倍 速くなる。データが多量かつstr method 操作を多用する場合はトータルの実行時間でも体感的に差を感じるであろう。ただし、前述のmapメソッドでは逆にarrow系が10倍も遅くなるなど、strメソッド以外では必ずしも高速にはならないことに注意が必要である。
str.len()
In [9]: %timeit df2.a.str.len()
139 ms ± 1.64 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [10]: %timeit df2.b.str.len()
41.1 ms ± 1.67 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [11]: %timeit df2.c.str.len()
11.4 ms ± 336 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
In [12]: %timeit df2.d.str.len()
9.84 ms ± 138 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
In [13]: %timeit df2.e.str.len()
9.81 ms ± 168 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
In [14]: %timeit df2.f.str.len()
9.98 ms ± 141 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
str.startswith()
In [15]: %timeit df2.a.str.startswith("xyz")
138 ms ± 1.96 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [16]: %timeit df2.b.str.startswith("xyz")
124 ms ± 1.03 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [17]: %timeit df2.c.str.startswith("xyz")
6.93 ms ± 132 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
In [18]: %timeit df2.d.str.startswith("xyz")
6.89 ms ± 108 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
In [19]: %timeit df2.e.str.startswith("xyz")
5.79 ms ± 97.9 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
In [20]: %timeit df2.f.str.startswith("xyz")
6.04 ms ± 92 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
str.slice
In [305]: %timeit df2.a.str[:4]
155 ms ± 15 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [306]: %timeit df2.b.str[:4]
188 ms ± 8.81 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [307]: %timeit df2.c.str[:4]
19.8 ms ± 491 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [308]: %timeit df2.d.str[:4]
20.7 ms ± 493 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [309]: %timeit df2.e.str[:4]
20.5 ms ± 768 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [310]: %timeit df2.f.str[:4]
21.2 ms ± 712 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
str.replace()
In [311]: %timeit df2.a.str.replace("xyz", "abc")
156 ms ± 1.47 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [312]: %timeit df2.b.str.replace("xyz", "abc")
168 ms ± 7.38 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [313]: %timeit df2.c.str.replace("xyz", "abc")
33.6 ms ± 1.06 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [314]: %timeit df2.d.str.replace("xyz", "abc")
31.6 ms ± 1.37 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [315]: %timeit df2.e.str.replace("xyz", "abc")
31.5 ms ± 1.06 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [316]: %timeit df2.f.str.replace("xyz", "abc")
31.4 ms ± 1.79 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
str.lower()
In [324]: %timeit df2.a.str.lower()
106 ms ± 2.92 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
In [325]: %timeit df2.b.str.lower()
113 ms ± 4.15 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [326]: %timeit df2.c.str.lower()
21.4 ms ± 609 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [327]: %timeit df2.d.str.lower()
22.5 ms ± 604 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [328]: %timeit df2.e.str.lower()
21.6 ms ± 1.12 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [329]: %timeit df2.f.str.lower()
22.8 ms ± 1.02 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
pd.read_csv()の挙動
このサンプルデータを読み込む。
from io import StringIO
s = """col0,col1
0,row1
1,None
2,
3,NA
4,NaN
"""
pd.read_csv(dtype=...)の指定
pd.read_csv() での dtype の指定も問題ない。str
での missing value については全て nan
となることに注意。
pprint(
[pd.read_csv(StringIO(s), dtype={"col1": t}).col1.array for t in [str] + string_types]
)
[<NumpyExtensionArray>
['row1', nan, nan, nan, nan]
Length: 5, dtype: object,
<StringArray>
['row1', <NA>, <NA>, <NA>, <NA>]
Length: 5, dtype: string,
<ArrowStringArray>
['row1', <NA>, <NA>, <NA>, <NA>]
Length: 5, dtype: string,
<ArrowStringArrayNumpySemantics>
['row1', nan, nan, nan, nan]
Length: 5, dtype: string,
<ArrowExtensionArray>
['row1', <NA>, <NA>, <NA>, <NA>]
Length: 5, dtype: string[pyarrow],
<ArrowExtensionArray>
['row1', <NA>, <NA>, <NA>, <NA>]
Length: 5, dtype: large_string[pyarrow]]
pd.read_csv(dtype_backend=...)の指定
以下、s1 として読み込まれる。
pd.read_csv(StringIO(s), dtype_backend="numpy_nullable").col1.array
<StringArray>
['row1', <NA>, <NA>, <NA>, <NA>]
Length: 5, dtype: string
以下、s4として読み込まれる。
pd.read_csv(StringIO(s), dtype_backend="pyarrow").col1.array
<ArrowExtensionArray>
['row1', <NA>, <NA>, <NA>, <NA>]
Length: 5, dtype: string[pyarrow]
なお、 pd.read_csv
には engine="pyarrow"
というengine指定もあるが、これは csv の文字の読み込み時のparseを何で行うかの指定であり、読み込んだ後の型には(理想的には)影響しないものである。(parse_datesの指定では一部想定外のケースあり)
その他
な、なんと numpy 2.0 が2024年春以降に公開予定だが、可変長UTF8の文字列型が StringDType
として新規導入予定のようだ。pandas 3.0でどのように連携されるかは、まったくの調査不足のため不明である。混沌としてきて、もはや Polars に移行してしまえば全く悩みはなくなるのでは、と個人的には思うばかりである。Pandasなんもわからん。
References
https://pandas.pydata.org/docs/user_guide/text.html
https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.StringDtype.html
https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.ArrowDtype.html