Description
skipna (added in 0.0.17/0.0.18 — it is absent throughout 0.0.16) works correctly in transform()
for every transform in coreforecast.lag_transforms. It is silently ignored by update() for five
of them, so a NaN that arrives through the incremental path poisons the accumulator permanently even
when skipna=True was requested.
Verified on coreforecast 0.0.18 (Linux, cp310). skipna=True on every transform; history
[1, 2, 3, 4, 5]; then a NaN observation arrives via update(), then a valid 9.0:
transform upd(+nan) upd(+nan,+9) expected
ExpandingMean nan nan 4.0000 <-- poisoned
ExpandingStd nan nan 2.8284 <-- poisoned
ExpandingMin nan nan 1.0000 <-- poisoned
ExpandingMax nan nan 9.0000 <-- poisoned
EWM(alpha=0.5) nan nan 6.5312 <-- poisoned
ExpandingQuantile 3.0000 3.5000 3.5000 ok
RollingMean(w=3) 4.5000 7.0000 7.0000 ok
RollingStd(w=3) 0.7071 2.8284 2.8284 ok
expected is transform() over the same full array read at the same position — i.e. the
transform/update pair disagree, and once the accumulator is NaN it never recovers.
For contrast, when the NaN is already in the history at transform() time, transform() and the
following update() are both correct for all of these — so the state built by transform is fine;
only the incremental step is broken:
history = [1., 2., nan, 4., 5., 6.], skipna=True
transform update(hist) expected ok
ExpandingMean 3.600000 3.600000 OK
ExpandingStd 2.073644 2.073644 OK
ExpandingMin 1.000000 1.000000 OK
ExpandingMax 6.000000 6.000000 OK
EWM(0.5) 4.937500 4.937500 OK
RollingMean3 5.000000 5.000000 OK
RollingStd3 1.000000 1.000000 OK
Root cause
There is an exact invariant: update() honors skipna if and only if it delegates to _lib.
_lib.grouped_array._GroupedArrayFloat64 exposes _rolling_{mean,std,min,max,quantile}_update,
_seasonal_rolling_{mean,std,min,max,quantile}_update and _expanding_quantile_update — and every
transform whose update() calls one of those is correct, because skipna is forwarded into C++:
# coreforecast/lag_transforms.py:120-123 (_RollingBase)
def update(self, ga: "GroupedArray") -> np.ndarray:
return getattr(ga, f"_rolling_{self.stat_name}_update")(
self.lag - 1, self.window_size, self.min_samples, self.skipna
)
There is no _expanding_mean_update, _expanding_std_update, _expanding_min_update,
_expanding_max_update or _exponentially_weighted_mean_update in _lib. Those five transforms
therefore carry their own NumPy accumulator update in Python, and none of them reads self.skipna:
# coreforecast/lag_transforms.py:391-394 (ExpandingMean.update)
self.stats_[:, 0] += 1.0 # count incremented unconditionally
self.stats_[:, 1] += ga._index_from_end(self.lag - 1) # NaN poisons the sum
return self.stats_[:, 1] / self.stats_[:, 0]
# coreforecast/lag_transforms.py:409-417 (ExpandingStd.update)
x = ga._index_from_end(self.lag - 1)
self.stats_[:, 0] += 1.0 # Welford update, no NaN guard;
n = self.stats_[:, 0] # mean and M2 both become NaN
prev_avg = self.stats_[:, 1].copy()
self.stats_[:, 1] = prev_avg + (x - prev_avg) / n
self.stats_[:, 2] += (x - prev_avg) * (x - self.stats_[:, 1])
# coreforecast/lag_transforms.py:429-431 (_ExpandingComp.update -> ExpandingMin/ExpandingMax)
self.stats_ = self._comp_fn(self.stats_, ga._index_from_end(self.lag - 1))
# _comp_fn is np.minimum / np.maximum (lines 443, 455), which propagate NaN.
# np.fmin / np.fmax ignore it.
# coreforecast/lag_transforms.py:500-503 (ExponentiallyWeightedMean.update)
x = ga._index_from_end(self.lag - 1)
self.stats_ = self.alpha * x + (1 - self.alpha) * self.stats_
The EWM case also contradicts its own documented contract — ExponentiallyWeightedMean's docstring
at coreforecast/lag_transforms.py:487-488 promises:
skipna (bool): If True, exclude NaN values from calculations using forward-fill behavior.
When False (default), NaN values propagate through the calculation.
and exponentially_weighted_mean's docstring in coreforecast/exponentially_weighted.py:16-20 says
the same. transform() does forward-fill; update() does not.
The ExpandingMean/ExpandingStd/ExpandingMin/ExpandingMax docstrings
(lag_transforms.py:382-383, 402-403, 439-440, 451-452) all state "If True, exclude NaN values
from calculations" without scoping it to transform, so the current behaviour reads as a bug rather
than a documented limitation.
Suggested fix
Guard the five Python accumulators on self.skipna. No C++ change is strictly required:
ExpandingMean.update / ExpandingStd.update — when skipna and x is NaN, skip the update
entirely (do not increment the count, do not touch the running mean/M2) and return the current
statistic.
_ExpandingComp.update — use np.fmin/np.fmax as _comp_fn when skipna=True.
ExponentiallyWeightedMean.update — when skipna and x is NaN, return self.stats_ unchanged
(that is the forward-fill the docstring describes).
All four are per-group vectorized, so a np.where(np.isnan(x), ...) form keeps them branch-free.
Alternatively, add the missing _expanding_*_update / _exponentially_weighted_mean_update kernels
to _lib so all transforms follow the single delegating code path and the invariant becomes
structural rather than something each Python update() has to remember.
A regression test shaped like a transform/update consistency property would catch the whole
class: for each transform and each skipna, update() applied step by step over a series must equal
transform() over the full series read at the corresponding positions.
Use case
This is related to mlforecast's issue 704. We use mlforecast for retail/product-level demand forecasting. Products have a life-cycle: for part of the history a SKU does not exist, or is delisted / out of range. mlforecast requires a gap-free time grid, so those periods have to be materialized as rows, and today the only allowed filler is a number — in practice 0. That makes "did not sell" indistinguishable from "did not exist", and a 12-week rolling mean spanning 8 weeks of non-existence is computed over 12 observations instead of the 4 real ones.
The fix on our side is NaN: keep the row so the grid stays dense and features stay positional, but
exclude it from the statistics. That is exactly what skipna=True gives us, and it already works —
in transform(). We have filed the corresponding request against mlforecast to allow NaN targets
at fit/preprocess and to pass skipna through to these transforms.
But mlforecast calls transform() once at preprocess time and then update() once per step
during recursive prediction, and per new batch of observations in MLForecast.update(). So with the
current update() behaviour, skipna=True would give correct training features and then silently
diverge at prediction time for ExpandingMean, ExpandingStd, ExpandingMin, ExpandingMax and
ExponentiallyWeightedMean — the worst possible failure mode, since nothing raises and the training
matrix looks right. A NaN arriving through MLForecast.update() (a SKU going out of range mid-stream)
would additionally poison the state permanently, for every future horizon.
Closing this gap is what makes skipna usable end-to-end for the "sparse / intermittent life-cycle"
case rather than only for one-shot feature construction.
Repro
import numpy as np
from coreforecast.grouped_array import GroupedArray
import coreforecast.lag_transforms as ct
def mk(y):
return GroupedArray(np.asarray(y, dtype=np.float64),
np.array([0, len(y)], dtype=np.int32))
hist = [1.0, 2.0, 3.0, 4.0, 5.0]
for make in [lambda: ct.ExpandingMean(1, skipna=True),
lambda: ct.ExpandingStd(1, skipna=True),
lambda: ct.ExpandingMin(1, skipna=True),
lambda: ct.ExpandingMax(1, skipna=True),
lambda: ct.ExponentiallyWeightedMean(1, 0.5, skipna=True),
lambda: ct.ExpandingQuantile(1, 0.5, skipna=True),
lambda: ct.RollingMean(1, 3, 1, skipna=True)]:
tfm = make()
tfm.transform(mk(hist))
tfm.update(mk(hist)) # incorporate the last real obs
tfm.update(mk(hist + [np.nan])) # a NaN observation arrives
got = float(tfm.update(mk(hist + [np.nan, 9.0]))[0])
exp = float(make().transform(mk(hist + [np.nan, 9.0, 0.0]))[-1])
print(f"{type(tfm).__name__:28s} update={got!r:>10} transform={exp:.4f}")
ExpandingMean update= nan transform=4.0000
ExpandingStd update= nan transform=2.8284
ExpandingMin update= nan transform=1.0000
ExpandingMax update= nan transform=9.0000
ExponentiallyWeightedMean update= nan transform=6.5312
ExpandingQuantile update= 3.5 transform=3.5000
RollingMean update= 7.0 transform=7.0000
Versions: coreforecast 0.0.18, numpy 2.x, Python 3.10, Linux x86_64.
Two smaller, related inconsistencies
Separable from the above; happy to split into their own issues if preferred.
-
LocalBoxCoxScaler is the only _BaseLocalScaler subclass without a skipna parameter.
LocalMinMaxScaler (coreforecast/scalers.py:187-188), LocalStandardScaler (:200-201) and
LocalRobustScaler (:214-219) all accept it and forward it via
_BaseLocalScaler.fit (:131: getattr(ga, f"_{self._scaler_type}_stats")(self.skipna)).
LocalBoxCoxScaler.__init__ (:234-245) does not, so it inherits the class-level default
skipna: bool = False (:121) and its overridden fit (:247-268) calls _boxcox_loglik /
_boxcox_guerrero with no skipna argument — a NaN anywhere in a group makes that group's lambda
NaN with no way to opt out. Related: the positivity guard at :255
(if self.method == "loglik" and any(ga.data < 0)) does not fire for NaN, since nan < 0 is
False, so NaN input reaches loglik silently.
Confirmed working today for the scalers that do have it:
x = [1., 2., nan, 4., 5.]
LocalStandardScaler(skipna=True) stats=[3.0, 1.58113883] -> [-1.265, -0.632, nan, 0.632, 1.265]
LocalStandardScaler(skipna=False) stats=[nan, nan] -> [nan, nan, nan, nan, nan]
LocalMinMaxScaler(skipna=True) stats=[1.0, 4.0] -> [0.0, 0.25, nan, 0.75, 1.0]
-
scalers.Difference / differences.diff have no skipna and no documented NaN contract.
Difference(1).fit_transform on [1, 2, nan, 4, 5] gives [nan, 1, nan, nan, 1], which is the
mathematically right answer — a difference against a missing value is undefined. We are not asking
for that to change. But Difference.tails_ (scalers.py:311, ga._tail(self.d)) can hold NaN,
and Difference.update / inverse_transform (:314-340) then propagate it into the inverted
series. A note in the docstring on what NaN tails mean for inverse_transform, or a skipna-style
option that carries the last valid value in the tail, would remove the ambiguity for callers doing
differencing on top of life-cycle-sparse series.
Note for downstream pinning
skipna does not exist in 0.0.16 (zero occurrences in rolling.py, expanding.py,
lag_transforms.py, scalers.py); 0.0.18 has it throughout. mlforecast currently declares
coreforecast>=0.0.15, so any mlforecast-side use of skipna needs that floor raised to whichever
release introduced it — worth stating explicitly in the release notes if it is not already.
Description
skipna(added in 0.0.17/0.0.18 — it is absent throughout 0.0.16) works correctly intransform()for every transform in
coreforecast.lag_transforms. It is silently ignored byupdate()for fiveof them, so a NaN that arrives through the incremental path poisons the accumulator permanently even
when
skipna=Truewas requested.Verified on coreforecast 0.0.18 (Linux, cp310).
skipna=Trueon every transform; history[1, 2, 3, 4, 5]; then a NaN observation arrives viaupdate(), then a valid9.0:expectedistransform()over the same full array read at the same position — i.e. thetransform/updatepair disagree, and once the accumulator is NaN it never recovers.For contrast, when the NaN is already in the history at
transform()time,transform()and thefollowing
update()are both correct for all of these — so the state built bytransformis fine;only the incremental step is broken:
Root cause
There is an exact invariant:
update()honorsskipnaif and only if it delegates to_lib._lib.grouped_array._GroupedArrayFloat64exposes_rolling_{mean,std,min,max,quantile}_update,_seasonal_rolling_{mean,std,min,max,quantile}_updateand_expanding_quantile_update— and everytransform whose
update()calls one of those is correct, becauseskipnais forwarded into C++:There is no
_expanding_mean_update,_expanding_std_update,_expanding_min_update,_expanding_max_updateor_exponentially_weighted_mean_updatein_lib. Those five transformstherefore carry their own NumPy accumulator update in Python, and none of them reads
self.skipna:The EWM case also contradicts its own documented contract —
ExponentiallyWeightedMean's docstringat
coreforecast/lag_transforms.py:487-488promises:and
exponentially_weighted_mean's docstring incoreforecast/exponentially_weighted.py:16-20saysthe same.
transform()does forward-fill;update()does not.The
ExpandingMean/ExpandingStd/ExpandingMin/ExpandingMaxdocstrings(
lag_transforms.py:382-383,402-403,439-440,451-452) all state "If True, exclude NaN valuesfrom calculations" without scoping it to
transform, so the current behaviour reads as a bug ratherthan a documented limitation.
Suggested fix
Guard the five Python accumulators on
self.skipna. No C++ change is strictly required:ExpandingMean.update/ExpandingStd.update— whenskipnaandxis NaN, skip the updateentirely (do not increment the count, do not touch the running mean/M2) and return the current
statistic.
_ExpandingComp.update— usenp.fmin/np.fmaxas_comp_fnwhenskipna=True.ExponentiallyWeightedMean.update— whenskipnaandxis NaN, returnself.stats_unchanged(that is the forward-fill the docstring describes).
All four are per-group vectorized, so a
np.where(np.isnan(x), ...)form keeps them branch-free.Alternatively, add the missing
_expanding_*_update/_exponentially_weighted_mean_updatekernelsto
_libso all transforms follow the single delegating code path and the invariant becomesstructural rather than something each Python
update()has to remember.A regression test shaped like a
transform/updateconsistency property would catch the wholeclass: for each transform and each
skipna,update()applied step by step over a series must equaltransform()over the full series read at the corresponding positions.Use case
This is related to mlforecast's issue 704. We use
mlforecastfor retail/product-level demand forecasting. Products have a life-cycle: for part of the history a SKU does not exist, or is delisted / out of range.mlforecastrequires a gap-free time grid, so those periods have to be materialized as rows, and today the only allowed filler is a number — in practice0. That makes "did not sell" indistinguishable from "did not exist", and a 12-week rolling mean spanning 8 weeks of non-existence is computed over 12 observations instead of the 4 real ones.The fix on our side is NaN: keep the row so the grid stays dense and features stay positional, but
exclude it from the statistics. That is exactly what
skipna=Truegives us, and it already works —in
transform(). We have filed the corresponding request againstmlforecastto allow NaN targetsat
fit/preprocessand to passskipnathrough to these transforms.But
mlforecastcallstransform()once atpreprocesstime and thenupdate()once per stepduring recursive prediction, and per new batch of observations in
MLForecast.update(). So with thecurrent
update()behaviour,skipna=Truewould give correct training features and then silentlydiverge at prediction time for
ExpandingMean,ExpandingStd,ExpandingMin,ExpandingMaxandExponentiallyWeightedMean— the worst possible failure mode, since nothing raises and the trainingmatrix looks right. A NaN arriving through
MLForecast.update()(a SKU going out of range mid-stream)would additionally poison the state permanently, for every future horizon.
Closing this gap is what makes
skipnausable end-to-end for the "sparse / intermittent life-cycle"case rather than only for one-shot feature construction.
Repro
Versions: coreforecast 0.0.18, numpy 2.x, Python 3.10, Linux x86_64.
Two smaller, related inconsistencies
Separable from the above; happy to split into their own issues if preferred.
LocalBoxCoxScaleris the only_BaseLocalScalersubclass without askipnaparameter.LocalMinMaxScaler(coreforecast/scalers.py:187-188),LocalStandardScaler(:200-201) andLocalRobustScaler(:214-219) all accept it and forward it via_BaseLocalScaler.fit(:131:getattr(ga, f"_{self._scaler_type}_stats")(self.skipna)).LocalBoxCoxScaler.__init__(:234-245) does not, so it inherits the class-level defaultskipna: bool = False(:121) and its overriddenfit(:247-268) calls_boxcox_loglik/_boxcox_guerrerowith noskipnaargument — a NaN anywhere in a group makes that group's lambdaNaN with no way to opt out. Related: the positivity guard at
:255(
if self.method == "loglik" and any(ga.data < 0)) does not fire for NaN, sincenan < 0isFalse, so NaN input reacheslogliksilently.Confirmed working today for the scalers that do have it:
scalers.Difference/differences.diffhave noskipnaand no documented NaN contract.Difference(1).fit_transformon[1, 2, nan, 4, 5]gives[nan, 1, nan, nan, 1], which is themathematically right answer — a difference against a missing value is undefined. We are not asking
for that to change. But
Difference.tails_(scalers.py:311,ga._tail(self.d)) can hold NaN,and
Difference.update/inverse_transform(:314-340) then propagate it into the invertedseries. A note in the docstring on what NaN tails mean for
inverse_transform, or askipna-styleoption that carries the last valid value in the tail, would remove the ambiguity for callers doing
differencing on top of life-cycle-sparse series.
Note for downstream pinning
skipnadoes not exist in 0.0.16 (zero occurrences inrolling.py,expanding.py,lag_transforms.py,scalers.py); 0.0.18 has it throughout.mlforecastcurrently declarescoreforecast>=0.0.15, so anymlforecast-side use ofskipnaneeds that floor raised to whicheverrelease introduced it — worth stating explicitly in the release notes if it is not already.