PDEP-6: setitemライクな操作におけるアップキャストの禁止
- 作成日: 2022年12月23日
- ステータス: 承認済み
- ディスカッション: #39584
- 作成者: Marco Gorelli (最初のissue は Joris Van den Bossche)
- 改訂版: 1
概要
提案では、setitemライクな操作によって`Series`のdtype(および`DataFrame`の列のdtype)が変更されないようにすることです。
現在の動作
In [1]: ser = pd.Series([1, 2, 3], dtype='int64')
In [2]: ser[2] = 'potage'
In [3]: ser # dtype changed to 'object'!
Out[3]:
0 1
1 2
2 potage
dtype: object
提案された動作
In [1]: ser = pd.Series([1, 2, 3])
In [2]: ser[2] = 'potage' # raises!
---------------------------------------------------------------------------
ValueError: Invalid value 'potage' for dtype int64
動機と範囲
現在、pandasは様々なdtypeを扱うことに非常に柔軟です。しかし、これによりバグが隠れたり、ユーザーの期待を裏切ったり、インプレース操作のように見える操作でデータがコピーされたりする可能性があります。
バグを隠してしまう例として
In[9]: ser = pd.Series(pd.date_range("2000", periods=3))
In[10]: ser[2] = "2000-01-04" # works, is converted to datetime64
In[11]: ser[2] = "2000-01-04x" # typo - but pandas does not error, it upcasts to object
このPDEPの範囲は、`Series`(および`DataFrame`の列)に対するsetitemライクな操作に限定されます。例えば、以下から始めると
df = DataFrame({"a": [1, 2, np.nan], "b": [4, 5, 6]})
ser = df["a"].copy()
以下のものは全て例外を発生します。
-
setitemライクな操作
ser.fillna('foo', inplace=True)
ser.where(ser.isna(), 'foo', inplace=True)
ser.fillna('foo', inplace=False)
ser.where(ser.isna(), 'foo', inplace=False)
-
setitemインデックス操作(`indexer`はスライス、マスク、単一値、値のリストまたは配列、またはその他の許可されたインデクサである可能性があります)
ser.iloc[indexer] = 'foo'
ser.loc[indexer] = 'foo'
df.iloc[indexer, 0] = 'foo'
df.loc[indexer, 'a'] = 'foo'
ser[indexer] = 'foo'
`Series.replace`と`Series.update`を上記のリストに追加することも考えられますが、PDEPの範囲を絞るために、ここでは除外します。
例外が発生しない操作の例
ser.diff()
pd.concat([ser, ser.astype(object)])
ser.mean()
ser[0] = 3
# 同じdtypeser[0] = 3.
# 3.0は丸められた浮動小数点数であり、'int64' dtypeと互換性がありますdf['a'] = pd.date_range(datetime(2020, 1, 1), periods=3)
df.index.intersection(ser.index)
詳細な説明
具体的には、提案は以下の通りです。
- `Series`が特定のdtypeの場合、setitemライクな操作によってそのdtypeが変更されるべきではありません。
- setitemライクな操作が以前は`Series`のdtypeを変更していた場合、現在は例外が発生します。
まず、これは以下を含みます。
-
Block.setitem
を変更して、中の`except`ブロックを削除する。value = extract_array(value, extract_numpy=True) try: casted = np_can_hold_element(values.dtype, value) except LossSetitiemError: # current dtype cannot store value, coerce to common dtype nb = self.coerce_to_target_dtype(value) return nb.setitem(index, value) else:
-
同様の変更を以下に行う。
Block.where
Block.putmask
EABackedBlock.setitem
EABackedBlock.where
EABackedBlock.putmask
上記だけでも、数百のテストを調整する必要があります。実装が開始されると、変更が必要な場所のリストがわずかに異なる可能性があることに注意してください。
アップキャストを完全に禁止するか、`object`へのアップキャストのみを禁止するか?
この提案で最も難しい点は、整数列に浮動小数点数を設定する場合の処理です。
In[1]: ser = pd.Series([1, 2, 3])
In [2]: ser
Out[2]:
0 1
1 2
2 3
dtype: int64
In[3]: ser[0] = 1.5 # what should this do?
現在の動作は'float64'にアップキャストすることです。
In [4]: ser
Out[4]:
0 1.5
1 2.0
2 3.0
dtype: float64
これは必ずしもバグの兆候ではありません。ユーザーは`Series`を数値型と考えているだけで(`int`と`float`をあまり気にせず)、`'int64'`はpandasが構築時に推測したものです。
考えられる選択肢は次のとおりです。
- 丸められた浮動小数点数(例:`1.0`)のみを受け入れ、それ以外のもの(例:`1.01`)では例外を発生させる。
- 設定する前に浮動小数点数を`int`に変換する(つまり、すべての浮動小数点数を暗黙的に丸める)。
- アップキャストされたdtypeが`object`の場合にのみ「アップキャストの禁止」を制限する(つまり、int64 Seriesをfloat64にアップキャストする現在の動作を維持する)。
他のライブラリの動作と比較してみましょう。
numpy
: オプション2cudf
: オプション2polars
: オプション2R data.frame
: 単にアップキャストする(null許容でないdtypeの場合、pandasが現在行っているように);pandas
(null許容dtype):オプション1datatable
: オプション1DataFrames.jl
: オプション1
オプション2
は、pandasにおける破壊的な動作変更となります。さらに、このPDEPの目的がバグの防止であるならば、これも望ましくありません。誰かが`1.5`を設定し、後で実際には`1`を設定したことを知って驚く可能性があります。
オプション3
にはいくつかの欠点があります。
- null許容dtypeの動作と矛盾します。
- コードベースとテストの複雑さも増します。
- シンプルなルールを教える代わりに、例外のあるルールを教える必要があり、教えにくくなります。
- 精度損失やオーバーフローのリスクがあります。
- `'int8'`を`'int16'`にアップキャストしないなど、他の例外の可能性も開きます。
オプション1
は、ユーザーをバグから保護する、null許容dtypeの現在の動作と一致する、そして教えやすいという点で、最も安全なオプションです。したがって、このPDEPで選択されたオプションはオプション1です。
使用方法と影響
これによりpandasはより厳格になるため、バグが発生するリスクはありません。むしろ、バグの防止に役立ちます。
残念ながら、意図的にアップキャストしていたユーザーをイライラさせる可能性もあります。
ユーザーは`Series`を最初に明示的にfloatにキャストすることで現在の動作を得ることができるため、厳格性の面でコミュニティ全体にとってより有益です。
範囲外
拡大。例えば
ser = pd.Series([1, 2, 3])
ser[len(ser)] = 4.5
それがそもそも許されるべきかどうかについて、より大きな議論があることは間違いありません。この提案に焦点を当てるために、意図的に範囲から除外されています。
よくある質問
Q: `int8` Seriesに`1.0`を設定するとどうなるか?
A: 現在の動作は、`1.0`を`1`として挿入し、dtypeを`int8`のままにすることです。したがって、これは変わりません。
Q: `int8` Seriesに`1_000_000.0`を設定するとどうなるか?
A: 現在の動作は`int32`にアップキャストすることです。したがって、このPDEPの下では、代わりに例外が発生します。
Q: `int8` Seriesに`16.000000000000001`を設定するとどうなるか?
A: Pythonの観点から見ると、`16.000000000000001`と`16.0`は同じ数です。したがって、`16`として挿入され、dtypeは変わりません(現在と同じように、ここでは変更はありません)。
Q: `int8` Seriesに`1.0000000001`を`1.0`として挿入したい場合。
A: 以下の様なヘルパー関数を定義すると良いでしょう。
def maybe_convert_to_int(x: int | float, tolerance: float):
if np.abs(x - round(x)) < tolerance:
return round(x)
return x
必要に応じて適応できます。
タイムライン
2.xリリース(2.0.0リリース後)のいつかで非推奨とし、3.0.0で強制する。
PDEP履歴
- 2022年12月23日: 初稿