Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.5.4] - 2026-06-23

### Fixed

- `split_by_date()` no longer raises `OutOfBoundsDatetime` ("Cannot cast 0001-01-01 ... to
unit='ns' without overflow") on `datetime64[ns]` time columns. The internal ordinal
conversion (`normalize_sequence_col`) anchored on `0001-01-01`, which is outside the
nanosecond-resolution range; subtracting it from an `ns` series forced a resolution alignment
that overflowed. The origin is now the Unix epoch (`1970-01-01`) plus its ordinal offset,
yielding identical results without overflow. Surfaced with nanosecond datetimes
(e.g. `split_by_date(df, "2015-07-06", observation_days=6, gt_days=1)`).

## [0.5.3] - 2026-06-22

### Changed
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "rfscorer"
version = "0.5.3"
version = "0.5.4"
description = "Recency-Frequency based recommendation scoring"
readme = "README.md"
license = { file = "LICENSE" }
Expand Down
16 changes: 11 additions & 5 deletions src/rfscorer/_time_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,15 @@
)

# Origin for the vectorized ordinal computation in normalize_sequence_col.
# (series - _ORDINAL_ORIGIN).dt.days + 1 yields the same proleptic Gregorian
# ordinal as scalar .toordinal() used in normalize_ref.
_ORDINAL_ORIGIN = pd.Timestamp("0001-01-01")
# (series - _ORDINAL_ORIGIN).dt.days + _ORDINAL_ORIGIN_OFFSET yields the same
# proleptic Gregorian ordinal as scalar .toordinal() used in normalize_ref.
#
# The origin must stay inside the datetime64[ns] range (about 1677-2262);
# using year 1 (0001-01-01) triggers an OutOfBoundsDatetime overflow when
# pandas aligns resolutions against a nanosecond series. We therefore anchor
# on the Unix epoch and add its ordinal offset back.
_ORDINAL_ORIGIN = pd.Timestamp("1970-01-01")
_ORDINAL_ORIGIN_OFFSET = _ORDINAL_ORIGIN.toordinal() # 719163


def normalize_ref(value) -> int:
Expand All @@ -43,9 +49,9 @@ def normalize_ref(value) -> int:
def normalize_sequence_col(series: pd.Series) -> pd.Series:
"""Normalize a time column (datetime, string, or integer) to an integer Series."""
if is_datetime64_any_dtype(series):
return (series - _ORDINAL_ORIGIN).dt.days + 1
return (series - _ORDINAL_ORIGIN).dt.days + _ORDINAL_ORIGIN_OFFSET
elif is_string_dtype(series):
return (pd.to_datetime(series) - _ORDINAL_ORIGIN).dt.days + 1
return (pd.to_datetime(series) - _ORDINAL_ORIGIN).dt.days + _ORDINAL_ORIGIN_OFFSET
elif is_integer_dtype(series) or is_float_dtype(series):
return series.astype(int)
else:
Expand Down
31 changes: 31 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,20 @@ def test_invalid_dtype_raises(self):
with pytest.raises(ValueError, match="time_col must be datetime or integer type"):
normalize_sequence_col(s)

def test_matches_scalar_toordinal_for_datetime64_ns(self):
# ベクトル化した ordinal がスカラ .toordinal() と一致すること。
dates = pd.to_datetime(["2015-07-06", "2024-01-07", "1999-12-31"])
series = pd.Series(dates).astype("datetime64[ns]")
result = normalize_sequence_col(series)
expected = [pd.Timestamp(d).toordinal() for d in dates]
assert list(result) == expected

def test_string_and_datetime64_agree(self):
# 文字列列と datetime64 列で同じ ordinal を返すこと。
str_series = pd.Series(["2015-07-06", "2024-01-07"])
dt_series = pd.to_datetime(str_series)
assert list(normalize_sequence_col(str_series)) == list(normalize_sequence_col(dt_series))


# ---------------------------------------------------------------------------
# split_by_date
Expand Down Expand Up @@ -266,3 +280,20 @@ def test_chained_with_fit(self):
scorer.fit(df_obs, df_gt, recency_limit=7, frequency_limit=3)
# 例外なく fit が完了し、属性が設定されている
assert scorer.emp_probability_dict_ is not None

def test_datetime64_ns_does_not_overflow(self):
# 回帰テスト: datetime64[ns] 列で解像度合わせの OutOfBoundsDatetime が
# 発生しないこと(原点を年1にしていた際のバグ)。
df = _make_df().copy()
# 環境の既定解像度に依らず ns を強制し、解像度合わせの経路を確実に通す。
df["datetime"] = pd.to_datetime(df["datetime"]).astype("datetime64[ns]")
assert df["datetime"].dtype == "datetime64[ns]"
df_obs, df_gt = split_by_date(df, "2024-01-07", observation_days=6, gt_days=1)
# target - 6 + 1 = Jan02 ... Jan07 が観測期間
assert set(df_obs["datetime"]) == {
pd.Timestamp("2024-01-03"),
pd.Timestamp("2024-01-05"),
pd.Timestamp("2024-01-07"),
}
# 正解期間は Jan08 のみ → 該当データなし
assert df_gt.empty
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading