PDEP-6: setitem-like操作でのアップキャストの禁止
- 作成日: 2022年12月23日
- ステータス: 実装済み
- 議論: #39584
- 著者: Marco Gorelli (元の問題はJoris Van den Bossche)
- 改訂: 1
概要
setitem-like操作は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-place操作であるかのように見える部分でデータをコピーしたりする可能性があります。
バグを隠す例としては
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-like操作に限定されます。例えば、以下から開始すると
df = DataFrame({"a": [1, 2, np.nan], "b": [4, 5, 6]})
ser = df["a"].copy()
以下のすべてはエラーを発生させます
-
setitem-like操作
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-like操作はそのdtypeを変更すべきではありません。setitem-like操作が以前に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.whereBlock.putmaskEABackedBlock.setitemEABackedBlock.whereEABackedBlock.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: 単にアップキャストする(pandasが現在null許容でないdtypeに対して行っているように)。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
そもそもそれを許可すべきかどうかについては、より大きな議論が必要であると言えます。この提案を焦点を絞るため、意図的に範囲から除外されています。
F.A.Q.
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日: 初稿
- 2024年7月4日: ステータスを「実装済み」に変更