Skip to content
Open
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
4 changes: 4 additions & 0 deletions qlib/data/ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -1277,6 +1277,10 @@ def _load_internal(self, instrument, start_index, end_index, *args):
_series = self.feature.load(instrument, start_index, end_index, *args)
if self.N == 0:
series = pd.Series(expanding_rsquare(_series.values), index=_series.index)
# Mask degenerate windows (near-constant input) the same way the
# rolling branch does below; otherwise the kernel leaks inf /
# garbage values (see #2297).
series.loc[np.isclose(_series.expanding(min_periods=1).std(), 0, atol=2e-05)] = np.nan
else:
series = pd.Series(rolling_rsquare(_series.values, self.N), index=_series.index)
series.loc[np.isclose(_series.rolling(self.N, min_periods=1).std(), 0, atol=2e-05)] = np.nan
Expand Down
57 changes: 57 additions & 0 deletions tests/ops/test_rsquare.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Regression tests for Rsquare on degenerate (near-constant) windows.

See https://github.com/microsoft/qlib/issues/2297: the expanding (``N == 0``)
path leaked ``inf`` / garbage values on near-constant input because the NaN
guard existed only on the rolling path.
"""

import unittest
from unittest import mock

import numpy as np
import pandas as pd

from qlib.data.ops import Rsquare


def _fake_feature(values):
"""A feature whose load() returns a fixed series (no data infra needed)."""
feature = mock.Mock()
feature.load.return_value = pd.Series(values, dtype="float64")
return feature


class TestRsquareDegenerateWindows(unittest.TestCase):
def _assert_no_inf(self, series: pd.Series) -> None:
self.assertEqual(np.isinf(series.values).sum(), 0, "Rsquare leaked inf values")

def test_expanding_constant_run_is_masked(self):
# A long constant run makes var(y) == 0 for every expanding window
# fully inside it; those windows must be NaN, never inf/garbage.
values = [1.0] * 60 + [2.0, 3.0, 4.0, 5.0]
rs = Rsquare(_fake_feature(values), 0)
out = rs._load_internal("unit", 0, len(values) - 1)
self._assert_no_inf(out)
# expanding windows of size >= 2 fully inside the constant run -> NaN
self.assertTrue(bool(out.iloc[1:60].isna().all()), "degenerate expanding windows were not masked")
# windows that include the varying tail see variance -> not masked
self.assertFalse(bool(out.iloc[-3:].isna().all()))

def test_expanding_normal_series_has_no_inf(self):
# Sanity: ordinary varying input stays finite.
values = np.sin(np.linspace(0, 6 * np.pi, 120)) + np.random.RandomState(0).normal(0, 1e-3, 120)
rs = Rsquare(_fake_feature(values), 0)
out = rs._load_internal("unit", 0, len(values) - 1)
self._assert_no_inf(out)

def test_rolling_constant_run_is_masked(self):
# The rolling branch already had the guard; keep it green.
values = [1.0] * 60 + [2.0, 3.0, 4.0, 5.0]
rs = Rsquare(_fake_feature(values), 10)
out = rs._load_internal("unit", 0, len(values) - 1)
self._assert_no_inf(out)
self.assertTrue(bool(out.iloc[10:59].isna().all()))


if __name__ == "__main__":
unittest.main()