PandasのSeriesに、なんと文字列の型が6種類もあるとは...

## update

- 2024-03-17: 文字列操作の実践ケース（mapメソッドにdictを与えた変換）を追加
- 2024-03-20: 文字列操作のベンチマークにいくつかのメソッドを追加


## 背景

Pandas は現在 major version が2系だが、2024年4月（以降）に **[3.0のリリースが計画](https://github.com/pandas-dev/pandas/milestone/102)** されているようである（おそらく遅延するだろうと予測するが）。執筆時点での最新版は2.2.1であるが、2.2.0のリリースノートにて、**[pandas 3.0でのstring date typeの変更](https://pandas.pydata.org/docs/whatsnew/v2.2.0.html#upcoming-changes-in-pandas-3-0)** についての記載を見つけたため、自分の調査した結果を、この記事でまとめることとした。

なお、pandas における pyarrow型の取り扱いはexperimentalであるため、2.2より将来で変更される可能性には留意が必要だ。

## Prerequisites

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

## 今、何を使うべきかの個人的結論

1. 互換性を大切にしたいか？ Yes -> **`str`**. No -> 次へ
1. experimentalを承知の上、ともかく内部メモリを削減したいか？ Yes ->  **`pd.ArrowDtype(pa.string())`**,  No -> 次へ
1. 特に深く考えず `pd.NA` を統一的に扱いたいか -> **`pd.StringDtype("python")`** (ie, "string" 指定、csv読み込み時は `pd.read_csv(dtype_backend="numpy_nullable")` で簡単指定)
1. 何もせずしばらく様子見



## コードでの挙動

まずは型指定とその実データで確認することにしてみる。何度も強調するが、v2.2.1 時点で experimental なため、将来の挙動について何も保証はない。

### 確認のためのデータ構成

- `pd.Series()` には `dtype=` の指定が可能であるのでここで型を指定する
- s0~s3は文字列でも指定が可能であるが、ここでは型クラスで指定する
-  missing value として `None`, `pd.NA`, `float("nan")` を指定してみる

```python
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` で相互変換可能である。

```python
s0.astype(pd.ArrowDtype(pa.large_string())).dtype
# large_string[pyarrow]
```

### データの確認

s0~s5 の dtype や dtypeのtypeを確認してみる。



```python
ss = [s0, s1, s2, s3, s4, s5]
```

#### 各Seriesの要素について

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

<details>
<summary>各要素のtype</summary>

```python
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'>]}
```

</details>

- s0: オブジェクト型なので入力値通り
- s3: missing value は全て `float` 型（すなわち `float("nan")`）として扱われる
- s1,s2,s4,s5: missing value は全て `pd.NA` として扱われる

 
#### dtypeについて

```python
 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](https://pandas.pydata.org/docs/whatsnew/v2.2.0.html#dedicated-string-data-type-backed-by-arrow-by-default) では、3.0でデフォルトになるとの記述がある（本当にそうするのか？）。2.2.1でも下記のように `pd.options.future.infer_string = True` を設定すると、挙動を変更できる。

```python
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 について

```python
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 について

```python
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 表示が一番楽かもしれない

```python
[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 から各種情報を確認することがきる。

```python
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` のどちらかが明瞭である。

```python
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` の真偽値変換 

```python
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型` となるので注意
    ```python
    >>> 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](https://github.com/pandas-dev/pandas/issues/54805) でも報告されている。

### `str -> int` の変換について

```python
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 を含んだものとする。

```python
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となる。

```python
[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系データの操作全般遅いのは少し予想外だった。

```python
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全て共通。

```python
ss0.map({"male":0}).head()
0    NaN
1    0.0
2    NaN
3    0.0
4    NaN
dtype: float64
```

defaultdictを与えれば、keyに含まれない値の変換後の値を指定ができる。s0~s5全て共通。

```python
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` が使われているのでエラーとなる。

```python
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は当然エラーとなる。
```python
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倍程度効率的が異なる。ただし人工データとしての参考値でしかない。

```python
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倍ほど効率的

```python

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倍ほど効率

```python
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メソッド以外では必ずしも高速にはならないことに注意が必要である。

<details>
<summary>str.len()</summary>

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

</details>

<details>
<summary>str.startswith()</summary>

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

</details>

<details>
<summary>str.slice</summary>


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

</details>

<details>
<summary>str.replace()</summary>


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

</details>

<details>
<summary>str.lower()</summary>


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

</details>

## pd.read_csv()の挙動

このサンプルデータを読み込む。
```python
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` となることに注意。

```python
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 として読み込まれる。

```python
pd.read_csv(StringIO(s), dtype_backend="numpy_nullable").col1.array

<StringArray>
['row1', <NA>, <NA>, <NA>, <NA>]
Length: 5, dtype: string
```

以下、s4として読み込まれる。

```python
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なんもわからん。

https://numpy.org/neps/nep-0055-string_dtype.html

## References

https://pandas.pydata.org/pdeps/0010-required-pyarrow-dependency.html

https://github.com/pandas-dev/pandas/issues/54466

https://github.com/pandas-dev/pandas/issues/54792

https://github.com/pandas-dev/pandas/issues/35169

https://github.com/pandas-dev/pandas/issues/41856

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