From 0db3391cfd3d639f810e94b07a508d8c0af75c88 Mon Sep 17 00:00:00 2001 From: jbbqqf Date: Sun, 10 May 2026 00:29:23 +0200 Subject: [PATCH 1/2] feat(timeseries): add idxmin / idxmax to TimeSeries (#2696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TimeSeries.min(axis=0)` and `.max(axis=0)` return a single-row series whose timestamp is just the *first* entry of the original time index, not the timestamp of the actual min / max value. The original issue documents how this is misleading; the maintainer has agreed (#2696) that adding `idxmin` / `idxmax` is the cleanest fix. This commit: - Adds `TimeSeries.idxmin()` and `TimeSeries.idxmax()` returning a `pandas.Series` indexed by component name with values being the time-index entries (Timestamp or int) at which each component attains its min / max — mirroring `pandas.DataFrame.idxmin/idxmax` semantics. This sidesteps the multivariate dilemma flagged in the issue (each component can have a different argmin/argmax) cleanly and is purely additive (no breaking change). - For stochastic series we reduce samples with the median first so the returned index does not depend on `n_samples`. - Adds a one-line note to `min()` / `max()` docstrings pointing readers to the new helpers. - Adds 4 regression tests covering the univariate, multivariate, RangeIndex, and stochastic cases. The univariate test fails on master (`AttributeError: 'TimeSeries' object has no attribute 'idxmin'`) and passes on this branch. - CHANGELOG entry under Unreleased > Improved. Refs: https://github.com/unit8co/darts/issues/2696 Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 2 + darts/tests/test_timeseries.py | 59 ++++++++++++++++++++++ darts/timeseries.py | 92 ++++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a47a2faaaa..ea5aedef61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ but cannot always guarantee backwards compatibility. Changes that may **break co **Improved** +- Added `TimeSeries.idxmin()` and `TimeSeries.idxmax()`, returning a `pandas.Series` (indexed by component) of the time index value at which each component attains its minimum / maximum. Also clarified the docstrings of `min()` / `max()` to point users to these new helpers when they want the actual argmin/argmax timestamp. Closes [#2696](https://github.com/unit8co/darts/issues/2696). + **Fixed** **Dependencies** diff --git a/darts/tests/test_timeseries.py b/darts/tests/test_timeseries.py index 2fcf4dfdae..750d3354c4 100644 --- a/darts/tests/test_timeseries.py +++ b/darts/tests/test_timeseries.py @@ -3297,6 +3297,65 @@ def test_max(self): new_ts._values, self.values.max(axis=axis, keepdims=True) ).all() + def test_idxmin_idxmax_univariate_datetime(self): + # univariate, deterministic, datetime index — covers issue #2696 + # where TimeSeries.min(axis=0) returns the first timestamp instead of + # the timestamp of the actual minimum. + idx = pd.date_range("2020-01-01", periods=5, freq="D") + values = np.array([3.0, 1.0, 4.0, 0.0, 2.0]) + ts = TimeSeries(times=idx, values=values, components=["a"]) + + # idxmin / idxmax return a pd.Series indexed by component name. + idxmin = ts.idxmin() + idxmax = ts.idxmax() + assert list(idxmin.index) == ["a"] + assert list(idxmax.index) == ["a"] + # The actual minimum is at position 3 (2020-01-04) and the maximum at + # position 2 (2020-01-03). This is the load-bearing assertion: it + # fails on master where users had to fall back to pd_dataframe() + # because TimeSeries provided no idx{min,max}. + assert idxmin["a"] == pd.Timestamp("2020-01-04") + assert idxmax["a"] == pd.Timestamp("2020-01-03") + + def test_idxmin_idxmax_multivariate(self): + # different argmin/argmax per component + idx = pd.date_range("2020-01-01", periods=3, freq="D") + values = np.array([[1.0, 0.0], [0.0, 0.0], [0.0, 1.0]]) + ts = TimeSeries(times=idx, values=values, components=["a", "b"]) + + idxmin = ts.idxmin() + idxmax = ts.idxmax() + # argmin returns first occurrence of the minimum, mirroring numpy. + assert idxmin["a"] == pd.Timestamp("2020-01-02") + assert idxmin["b"] == pd.Timestamp("2020-01-01") + assert idxmax["a"] == pd.Timestamp("2020-01-01") + assert idxmax["b"] == pd.Timestamp("2020-01-03") + + def test_idxmin_idxmax_range_index(self): + # RangeIndex-based series should return integer indices. + values = np.array([5.0, 3.0, 8.0, 1.0]) + ts = TimeSeries( + times=pd.RangeIndex(start=10, stop=14, step=1), + values=values, + components=["x"], + ) + assert ts.idxmin()["x"] == 13 + assert ts.idxmax()["x"] == 12 + + def test_idxmin_idxmax_stochastic(self): + # For a stochastic series we reduce samples with the median first so + # the answer does not depend on how many samples were drawn. + rng = np.random.default_rng(0) + idx = pd.date_range("2020-01-01", periods=4, freq="D") + # Component "a" has its median minimum at t=2. + median_target = np.array([5.0, 3.0, 1.0, 2.0]) + values = np.stack( + [median_target + rng.normal(0, 0.01, size=4) for _ in range(50)], + axis=-1, + )[:, None, :] + ts = TimeSeries(times=idx, values=values, components=["a"]) + assert ts.idxmin()["a"] == pd.Timestamp("2020-01-03") + def test_sum(self): for axis in range(3): new_ts = self.ts.sum(axis=axis) diff --git a/darts/timeseries.py b/darts/timeseries.py index 8b3755b4f9..a855bbe7fd 100644 --- a/darts/timeseries.py +++ b/darts/timeseries.py @@ -4764,6 +4764,12 @@ def min(self, axis: int = 2) -> Self: If ``axis=1``, the static covariates and the hierarchy are discarded from the series. + .. note:: + With ``axis=0`` the returned timestamp is the first entry of the + original ``time_index`` and **does not** correspond to the + timestamp of the actual minimum value. Use :func:`idxmin` to get + the timestamp at which each component attains its minimum. + Parameters ---------- axis @@ -4793,6 +4799,12 @@ def max(self, axis: int = 2) -> Self: If ``axis=1``, the static covariates and the hierarchy are discarded from the series. + .. note:: + With ``axis=0`` the returned timestamp is the first entry of the + original ``time_index`` and **does not** correspond to the + timestamp of the actual maximum value. Use :func:`idxmax` to get + the timestamp at which each component attains its maximum. + Parameters ---------- axis @@ -4812,6 +4824,86 @@ def max(self, axis: int = 2) -> Self: **(self._attrs if axis != 1 else dict()), ) + def idxmin(self) -> pd.Series: + """Return the time index value of the minimum of each component. + + For a stochastic series the median over samples is taken before + finding the minimum, so the returned index is well-defined regardless + of ``n_samples``. + + Useful as a companion to :func:`min` because ``min(axis=0)`` returns a + single-row series whose timestamp is the *first* time index entry of + the original series, not the entry of the actual minimum (see + `issue #2696 `_). + + Returns + ------- + pandas.Series + A series indexed by component name. Each value is the timestamp + (or integer index, if the series uses an ``RangeIndex``) at which + that component attains its minimum. + + Examples + -------- + >>> import pandas as pd + >>> from darts import TimeSeries + >>> df = pd.DataFrame({"a": [1, 0, 0], "b": [0, 0, 1]}) + >>> series = TimeSeries.from_dataframe(df) + >>> series.idxmin() + a 1 + b 0 + dtype: int64 + """ + # Reduce samples first so the result is independent of stochasticity; + # using the median (rather than mean) keeps the returned index value + # an actual observed value when n_samples == 1. + deterministic = ( + self._values + if self.is_deterministic + else np.median(self._values, axis=2, keepdims=True) + ) + # argmin along time axis → shape (n_components,) + idxs = deterministic[:, :, 0].argmin(axis=0) + return pd.Series(self._time_index[idxs], index=self.components) + + def idxmax(self) -> pd.Series: + """Return the time index value of the maximum of each component. + + For a stochastic series the median over samples is taken before + finding the maximum, so the returned index is well-defined regardless + of ``n_samples``. + + Useful as a companion to :func:`max` because ``max(axis=0)`` returns a + single-row series whose timestamp is the *first* time index entry of + the original series, not the entry of the actual maximum (see + `issue #2696 `_). + + Returns + ------- + pandas.Series + A series indexed by component name. Each value is the timestamp + (or integer index, if the series uses an ``RangeIndex``) at which + that component attains its maximum. + + Examples + -------- + >>> import pandas as pd + >>> from darts import TimeSeries + >>> df = pd.DataFrame({"a": [1, 0, 0], "b": [0, 0, 1]}) + >>> series = TimeSeries.from_dataframe(df) + >>> series.idxmax() + a 0 + b 2 + dtype: int64 + """ + deterministic = ( + self._values + if self.is_deterministic + else np.median(self._values, axis=2, keepdims=True) + ) + idxs = deterministic[:, :, 0].argmax(axis=0) + return pd.Series(self._time_index[idxs], index=self.components) + def quantile(self, q: float | Sequence[float] = 0.5, **kwargs) -> Self: """Return a deterministic series with the desired quantile(s) `q` of each component computed over the samples of the stochastic series. From d2a06a91cc401ec5070f8ba86ff3e70caeae4970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ch=C5=82apek?= Date: Tue, 16 Jun 2026 11:24:52 +0200 Subject: [PATCH 2/2] feat: pr suggestions --- CHANGELOG.md | 2 +- darts/tests/test_timeseries.py | 26 +++++--------------------- darts/timeseries.py | 21 --------------------- 3 files changed, 6 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea5aedef61..25375e63be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ but cannot always guarantee backwards compatibility. Changes that may **break co **Improved** -- Added `TimeSeries.idxmin()` and `TimeSeries.idxmax()`, returning a `pandas.Series` (indexed by component) of the time index value at which each component attains its minimum / maximum. Also clarified the docstrings of `min()` / `max()` to point users to these new helpers when they want the actual argmin/argmax timestamp. Closes [#2696](https://github.com/unit8co/darts/issues/2696). +- Added `TimeSeries.idxmin()` and `TimeSeries.idxmax()`, returning a `pandas.Series` (indexed by component) of the time index value at which each component attains its minimum / maximum. Closes [#3115](https://github.com/unit8co/darts/pull/3115) by [Jean-Baptiste Braun](https://github.com/jbbqqf). **Fixed** diff --git a/darts/tests/test_timeseries.py b/darts/tests/test_timeseries.py index 750d3354c4..69bdf975a3 100644 --- a/darts/tests/test_timeseries.py +++ b/darts/tests/test_timeseries.py @@ -3298,41 +3298,32 @@ def test_max(self): ).all() def test_idxmin_idxmax_univariate_datetime(self): - # univariate, deterministic, datetime index — covers issue #2696 - # where TimeSeries.min(axis=0) returns the first timestamp instead of - # the timestamp of the actual minimum. idx = pd.date_range("2020-01-01", periods=5, freq="D") values = np.array([3.0, 1.0, 4.0, 0.0, 2.0]) ts = TimeSeries(times=idx, values=values, components=["a"]) - # idxmin / idxmax return a pd.Series indexed by component name. idxmin = ts.idxmin() idxmax = ts.idxmax() assert list(idxmin.index) == ["a"] assert list(idxmax.index) == ["a"] - # The actual minimum is at position 3 (2020-01-04) and the maximum at - # position 2 (2020-01-03). This is the load-bearing assertion: it - # fails on master where users had to fall back to pd_dataframe() - # because TimeSeries provided no idx{min,max}. + # minimum at position 3 (2020-01-04), maximum at position 2 (2020-01-03) assert idxmin["a"] == pd.Timestamp("2020-01-04") assert idxmax["a"] == pd.Timestamp("2020-01-03") def test_idxmin_idxmax_multivariate(self): - # different argmin/argmax per component idx = pd.date_range("2020-01-01", periods=3, freq="D") values = np.array([[1.0, 0.0], [0.0, 0.0], [0.0, 1.0]]) ts = TimeSeries(times=idx, values=values, components=["a", "b"]) idxmin = ts.idxmin() idxmax = ts.idxmax() - # argmin returns first occurrence of the minimum, mirroring numpy. + # first occurrence of the minimum, mirroring numpy assert idxmin["a"] == pd.Timestamp("2020-01-02") assert idxmin["b"] == pd.Timestamp("2020-01-01") assert idxmax["a"] == pd.Timestamp("2020-01-01") assert idxmax["b"] == pd.Timestamp("2020-01-03") def test_idxmin_idxmax_range_index(self): - # RangeIndex-based series should return integer indices. values = np.array([5.0, 3.0, 8.0, 1.0]) ts = TimeSeries( times=pd.RangeIndex(start=10, stop=14, step=1), @@ -3343,16 +3334,9 @@ def test_idxmin_idxmax_range_index(self): assert ts.idxmax()["x"] == 12 def test_idxmin_idxmax_stochastic(self): - # For a stochastic series we reduce samples with the median first so - # the answer does not depend on how many samples were drawn. - rng = np.random.default_rng(0) - idx = pd.date_range("2020-01-01", periods=4, freq="D") - # Component "a" has its median minimum at t=2. - median_target = np.array([5.0, 3.0, 1.0, 2.0]) - values = np.stack( - [median_target + rng.normal(0, 0.01, size=4) for _ in range(50)], - axis=-1, - )[:, None, :] + idx = pd.date_range("2020-01-01", periods=3, freq="D") + # sample 0 min at t=1, sample 1 min at t=2 — medians [3.0, 2.0, 1.5], min at t=2 + values = np.array([[[2.0, 4.0]], [[1.0, 3.0]], [[3.0, 0.0]]]) # (3, 1, 2) ts = TimeSeries(times=idx, values=values, components=["a"]) assert ts.idxmin()["a"] == pd.Timestamp("2020-01-03") diff --git a/darts/timeseries.py b/darts/timeseries.py index a855bbe7fd..dbb5f7f05d 100644 --- a/darts/timeseries.py +++ b/darts/timeseries.py @@ -4764,11 +4764,6 @@ def min(self, axis: int = 2) -> Self: If ``axis=1``, the static covariates and the hierarchy are discarded from the series. - .. note:: - With ``axis=0`` the returned timestamp is the first entry of the - original ``time_index`` and **does not** correspond to the - timestamp of the actual minimum value. Use :func:`idxmin` to get - the timestamp at which each component attains its minimum. Parameters ---------- @@ -4799,11 +4794,6 @@ def max(self, axis: int = 2) -> Self: If ``axis=1``, the static covariates and the hierarchy are discarded from the series. - .. note:: - With ``axis=0`` the returned timestamp is the first entry of the - original ``time_index`` and **does not** correspond to the - timestamp of the actual maximum value. Use :func:`idxmax` to get - the timestamp at which each component attains its maximum. Parameters ---------- @@ -4831,10 +4821,6 @@ def idxmin(self) -> pd.Series: finding the minimum, so the returned index is well-defined regardless of ``n_samples``. - Useful as a companion to :func:`min` because ``min(axis=0)`` returns a - single-row series whose timestamp is the *first* time index entry of - the original series, not the entry of the actual minimum (see - `issue #2696 `_). Returns ------- @@ -4854,9 +4840,6 @@ def idxmin(self) -> pd.Series: b 0 dtype: int64 """ - # Reduce samples first so the result is independent of stochasticity; - # using the median (rather than mean) keeps the returned index value - # an actual observed value when n_samples == 1. deterministic = ( self._values if self.is_deterministic @@ -4873,10 +4856,6 @@ def idxmax(self) -> pd.Series: finding the maximum, so the returned index is well-defined regardless of ``n_samples``. - Useful as a companion to :func:`max` because ``max(axis=0)`` returns a - single-row series whose timestamp is the *first* time index entry of - the original series, not the entry of the actual maximum (see - `issue #2696 `_). Returns -------