Skip to content

Commit 162f650

Browse files
authored
CSP compatibility with pandas 3.0 (#669)
Signed-off-by: Adam Glustein <adam.glustein@point72.com>
1 parent d38100d commit 162f650

8 files changed

Lines changed: 241 additions & 111 deletions

File tree

.github/workflows/build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,9 @@ jobs:
628628
package:
629629
- sqlalchemy<2
630630
- perspective-python<3
631+
# - pandas<3
632+
# uncomment the pandas pin once we move off Python 3.10 to ensure we maintain pandas 2.x compatibility
633+
# pandas 3.0 does not support 3.10 so we currently get free coverage, but won't after 3.10 goes EOL
631634
# Min supported version of pandas 2.2
632635
- numpy==1.22.4
633636

cpp/csp/cppnodes/statsimpl.h

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -776,7 +776,7 @@ class StandardError
776776

777777
double compute() const
778778
{
779-
return ( m_count > m_ddof ? sqrt( m_var.compute() / ( m_count - m_ddof ) ) : std::numeric_limits<double>::quiet_NaN() );
779+
return ( m_count > m_ddof ? sqrt( m_var.compute() / m_count ) : std::numeric_limits<double>::quiet_NaN() );
780780
}
781781

782782
private:
@@ -822,7 +822,7 @@ class WeightedStandardError
822822

823823
double compute() const
824824
{
825-
return ( m_wsum > m_ddof && m_wsum > EPSILON ? sqrt( m_var.compute() / ( m_wsum - m_ddof ) ) : std::numeric_limits<double>::quiet_NaN() );
825+
return ( m_wsum > m_ddof && m_wsum > EPSILON ? sqrt( m_var.compute() / m_wsum ) : std::numeric_limits<double>::quiet_NaN() );
826826
}
827827

828828
private:

csp/impl/pandas_ext_type.py

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ class _NumPyBackedExtensionArrayMixin(ExtensionArray, ExtensionScalarOpsMixin):
203203
# Code inspired by https://github.com/hgrecco/pint-pandas/blob/master/pint_pandas/pint_array.py
204204
# and https://github.com/ContinuumIO/cyberpandas/blob/master/cyberpandas/ip_array.py
205205
_data: np.ndarray
206+
_readonly: bool = False
206207

207208
@property
208209
def dtype(self) -> "TsDtype":
@@ -235,9 +236,14 @@ def __getitem__(self, item) -> Any:
235236

236237
item = check_array_indexer(self, item)
237238

238-
return self.__class__(self._data[item], self.dtype)
239+
cls = self.__class__(self._data[item], self.dtype)
240+
cls._readonly = self._readonly
241+
return cls
239242

240243
def __setitem__(self, key, value):
244+
if self._readonly:
245+
raise ValueError("Cannot modify read-only array")
246+
241247
# need to not use `not value` on numpy arrays
242248
if isinstance(value, (list, tuple)) and (not value):
243249
# doing nothing here seems to be ok
@@ -390,6 +396,9 @@ def __init__(self, values, dtype=None, copy=False):
390396
self._data = values._data
391397
else:
392398
self._data = np.atleast_1d(np.asarray(values, dtype="O"))
399+
# Ensure the array is writeable (may be read-only in pandas 3.x)
400+
if not self._data.flags.writeable:
401+
self._data = self._data.copy()
393402

394403
if dtype is None:
395404
try:
@@ -439,7 +448,7 @@ def formatting_function(x):
439448

440449
return formatting_function
441450

442-
def _reduce(self, name, skipna=True, **kwds):
451+
def _reduce(self, name, skipna=True, keepdims: bool = False, **kwds):
443452
"""
444453
Return a scalar result of performing the reduction operation.
445454
Parameters
@@ -450,12 +459,15 @@ def _reduce(self, name, skipna=True, **kwds):
450459
std, var, sem, kurt, skew }.
451460
skipna : bool, default True
452461
If True, skip NaN values (both missing Edges and nan's inside of Edges)
462+
keepdims : bool, default False
463+
If True, return a TsArray with shape (1,) instead of a scalar, keeping the dimensions.
464+
Used by DataFrame reductions. Required argument as of pandas 3.0: https://github.com/pandas-dev/pandas/pull/52788.
453465
**kwargs
454466
Additional keyword arguments passed to the reduction function.
455467
Currently, `ddof` is the only supported kwarg.
456468
Returns
457469
-------
458-
scalar
470+
scalar or TsArray
459471
Raises
460472
------
461473
TypeError : subclass does not define reductions
@@ -486,9 +498,24 @@ def _reduce(self, name, skipna=True, **kwds):
486498
quantity = self._data
487499

488500
if len(quantity) == 0:
489-
return None
501+
result = None
502+
else:
503+
result = functions[name](quantity.tolist(), self.dtype.subtype, kwargs=dict(skipna=skipna))
504+
505+
if keepdims:
506+
# Determine the output dtype based on the reduction operation
507+
if name in ("any", "all"):
508+
result_dtype = TsDtype(bool)
509+
elif name in ("mean", "median", "std", "var", "sem", "skew", "kurt"):
510+
result_dtype = TsDtype(float)
511+
else:
512+
result_dtype = self.dtype
513+
514+
if result is None:
515+
return TsArray([], dtype=result_dtype)
516+
return TsArray([result], dtype=result_dtype)
490517

491-
return functions[name](quantity.tolist(), self.dtype.subtype, kwargs=dict(skipna=skipna))
518+
return result
492519

493520
@classmethod
494521
def _create_comparison_method(cls, op):
@@ -537,6 +564,36 @@ def astype(self, dtype, copy=True):
537564
return TsArray(values, dtype)
538565
return super(TsArray, self).astype(dtype, copy=copy)
539566

567+
def to_numpy(self, dtype=None, copy=False, na_value=None):
568+
"""Convert to a NumPy ndarray.
569+
570+
Parameters
571+
----------
572+
dtype : str or numpy.dtype, optional
573+
The dtype to pass to numpy.asarray.
574+
copy : bool, default False
575+
Whether to ensure that the returned value is not a view on another array.
576+
na_value : Any, optional
577+
The value to use for missing values.
578+
579+
Returns
580+
-------
581+
numpy.ndarray
582+
"""
583+
result = np.asarray(self._data, dtype=dtype)
584+
585+
if copy:
586+
result = result.copy()
587+
elif self._readonly:
588+
# Need to make array not writeable if this TsArray is read-only
589+
if np.may_share_memory(result, self._data):
590+
result = result.view()
591+
result.flags.writeable = False
592+
593+
if na_value is not None:
594+
result[self.isna()] = na_value
595+
return result
596+
540597

541598
@node
542599
def _cast(x: ts[object], typ: "T") -> ts["T"]:

csp/tests/adapters/test_parquet.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,10 +205,11 @@ def graph():
205205
csp.run(graph, starttime=start_time)
206206
df = pandas.read_parquet(filename)
207207
expected_columns = {}
208+
# pandas 3.0+: need to explicitly force ns unit and UTC tz, no longer the default
208209
if timestamp_column_name:
209-
expected_columns[timestamp_column_name] = [
210-
start_time + timedelta(seconds=sec + 1) for sec in range(10)
211-
]
210+
expected_columns[timestamp_column_name] = pandas.date_range(
211+
start=start_time + timedelta(seconds=1), periods=10, unit="ns", freq="1s", tz="UTC"
212+
)
212213
expected_columns.update(
213214
{
214215
"x": list(range(1, 11)),
@@ -241,7 +242,10 @@ def graph():
241242

242243
expected_df = pandas.DataFrame.from_dict(
243244
{
244-
"timestamp": [start_time + timedelta(seconds=sec + 1) for sec in range(10)],
245+
# pandas 3.0+: need to explicitly force ns unit and UTC tz, no longer the default
246+
"timestamp": pandas.date_range(
247+
start=start_time + timedelta(seconds=1), periods=10, unit="ns", freq="1s", tz="UTC"
248+
),
245249
"x": list(range(1, 11)),
246250
"y": list(map(float, range(0, 100, 10))),
247251
}

csp/tests/impl/test_pandas.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import unittest
22
from datetime import datetime, timedelta
3+
from zoneinfo import ZoneInfo
34

45
import pandas as pd
56
from numpy import nan
@@ -27,7 +28,7 @@ def test_make_pandas_basic(self):
2728
self.assertEqual(out[2][0], start + 3 * dt2)
2829

2930
## First Tick
30-
idx = pd.DatetimeIndex([start + dt2])
31+
idx = pd.DatetimeIndex([start + dt2], dtype="datetime64[ns]")
3132
target = pd.DataFrame({"x": [2], "y": [1]}, index=idx)
3233
pd.testing.assert_frame_equal(out1[0][1], target)
3334
pd.testing.assert_frame_equal(out2[0][1], target)
@@ -37,7 +38,7 @@ def test_make_pandas_basic(self):
3738
# Notes:
3839
# - pandas is a bit inconsistent with whether or not it sets freq on the DateTimeIndex, so we drop for comparison.
3940
# - when there is missing data in an integer column, it uses float NaN and hence the column becomes float type
40-
idx = pd.DatetimeIndex([start + dt2, start + dt2 + dt1, start + dt2 + 2 * dt1])
41+
idx = pd.DatetimeIndex([start + dt2, start + dt2 + dt1, start + dt2 + 2 * dt1], dtype="datetime64[ns]")
4142
target = pd.DataFrame({"x": [2, 3, 4], "y": [1.0, nan, 2.0]}, index=idx)
4243
out1[1][1].index.freq = None
4344
pd.testing.assert_frame_equal(out1[1][1], target)
@@ -50,7 +51,8 @@ def test_make_pandas_basic(self):
5051

5152
## Third Tick
5253
idx = pd.DatetimeIndex(
53-
[start + dt2, start + dt2 + dt1, start + dt2 + 2 * dt1, start + dt2 + 3 * dt1, start + dt2 + 4 * dt1]
54+
[start + dt2, start + dt2 + dt1, start + dt2 + 2 * dt1, start + dt2 + 3 * dt1, start + dt2 + 4 * dt1],
55+
dtype="datetime64[ns]",
5456
)
5557
target = pd.DataFrame({"x": [2, 3, 4, 5, 6], "y": [1.0, nan, 2.0, nan, 3.0]}, index=idx)
5658
out1[2][1].index.freq = None
@@ -83,7 +85,7 @@ def test_make_pandas_window(self):
8385
self.assertEqual(out[1][0], start + 2 * dt2)
8486

8587
## First Tick
86-
idx = pd.DatetimeIndex([start + dt2])
88+
idx = pd.DatetimeIndex([start + dt2], dtype="datetime64[ns]")
8789
target = pd.DataFrame({"x": [2], "y": [1]}, index=idx)
8890
pd.testing.assert_frame_equal(out1[0][1], target)
8991
pd.testing.assert_frame_equal(out2[0][1], target)
@@ -93,17 +95,17 @@ def test_make_pandas_window(self):
9395
# Notes:
9496
# - pandas is a bit inconsistent with whether or not it sets freq on the DateTimeIndex, so we drop for comparison.
9597
# - when there is missing data in an integer column, it uses float NaN and hence the column becomes float type
96-
idx = pd.DatetimeIndex([start + dt2, start + dt2 + dt1, start + dt2 + 2 * dt1])
98+
idx = pd.DatetimeIndex([start + dt2, start + dt2 + dt1, start + dt2 + 2 * dt1], dtype="datetime64[ns]")
9799
target = pd.DataFrame({"x": [nan, 3.0, 4.0], "y": [1.0, nan, 2.0]}, index=idx)
98100
out1[1][1].index.freq = None
99101
pd.testing.assert_frame_equal(out1[1][1], target)
100102

101-
idx = pd.DatetimeIndex([start + dt2 + dt1, start + dt2 + 2 * dt1])
103+
idx = pd.DatetimeIndex([start + dt2 + dt1, start + dt2 + 2 * dt1], dtype="datetime64[ns]")
102104
target = pd.DataFrame({"x": [3, 4], "y": [nan, 2.0]}, index=idx)
103105
out2[1][1].index.freq = None
104106
pd.testing.assert_frame_equal(out2[1][1], target)
105107

106-
idx = pd.DatetimeIndex([start + dt2 + 2 * dt1])
108+
idx = pd.DatetimeIndex([start + dt2 + 2 * dt1], dtype="datetime64[ns]")
107109
target = pd.DataFrame({"x": [4], "y": [2]}, index=idx)
108110
out3[1][1].index.freq = None
109111
pd.testing.assert_frame_equal(out3[1][1], target)
@@ -135,19 +137,19 @@ def test_make_pandas_init(self):
135137
target = pd.DataFrame({"x": pd.Series(dtype="int64"), "y": pd.Series(dtype="int64")}, index=idx)
136138
pd.testing.assert_frame_equal(out1[0][1], target)
137139

138-
idx = pd.DatetimeIndex([start + dt1])
140+
idx = pd.DatetimeIndex([start + dt1], dtype="datetime64[ns]")
139141
target = pd.DataFrame({"x": [1], "y": [nan]}, index=idx)
140142
pd.testing.assert_frame_equal(out1[1][1], target)
141143
pd.testing.assert_frame_equal(out1[2][1], target)
142144

143-
idx = pd.DatetimeIndex([start + dt1, start + dt2])
145+
idx = pd.DatetimeIndex([start + dt1, start + dt2], dtype="datetime64[ns]")
144146
target = pd.DataFrame({"x": [1, 2], "y": [nan, 1.0]}, index=idx)
145147
pd.testing.assert_frame_equal(out1[3][1], target)
146148

147149
## out2
148150
self.assertEqual(len(out2), 1)
149151
self.assertEqual(out2[0][0], start + dt2)
150-
idx = pd.DatetimeIndex([start + dt2])
152+
idx = pd.DatetimeIndex([start + dt2], dtype="datetime64[ns]")
151153
target = pd.DataFrame({"x": [2], "y": [1]}, index=idx)
152154
out2[0][1].index.freq = None
153155
pd.testing.assert_frame_equal(out2[0][1], target)
@@ -201,9 +203,16 @@ def test_pandas_ts_types(self):
201203
df = csp.DataFrame({("A", "x"): x, ("B", "x"): csp.const("Test"), ("A", "y"): y, ("B", "y"): csp.const(start)})
202204
out = csp.run(lambda: df.to_pandas_ts(("A", "y"), 2, ("A", "x")), starttime=start, endtime=end)[0]
203205
self.assertEqual(len(out), 3)
204-
idx = pd.DatetimeIndex([start + dt2 + dt1, start + dt2 + 2 * dt1])
206+
idx = pd.DatetimeIndex([start + dt2 + dt1, start + dt2 + 2 * dt1], dtype="datetime64[ns]")
205207
target = pd.DataFrame(
206-
{("A", "x"): [3, 4], ("B", "x"): ["Test", "Test"], ("A", "y"): [1, 2], ("B", "y"): [start, start]},
208+
{
209+
("A", "x"): [3, 4],
210+
("B", "x"): ["Test", "Test"],
211+
("A", "y"): [1, 2],
212+
("B", "y"): [
213+
pd.Timestamp(start.replace(tzinfo=ZoneInfo("UTC")).timestamp() * 1e9, unit="ns") for i in range(2)
214+
],
215+
},
207216
index=idx,
208217
)
209218
self.assertEqual(out[1][0], start + dt2 + 2 * dt1)

0 commit comments

Comments
 (0)