Skip to content

Commit 0e6bbbc

Browse files
FEAT make Narx support missing values (#25)
* FEAT make Narx support missing values
1 parent 8e4a01f commit 0e6bbbc

File tree

2 files changed

+74
-26
lines changed

2 files changed

+74
-26
lines changed

fastcan/_narx.py

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@
44

55
import math
66
from itertools import combinations_with_replacement
7-
from numbers import Integral, Real
7+
from numbers import Integral
88

99
import numpy as np
1010
from scipy.optimize import least_squares
1111
from scipy.stats import rankdata
1212
from sklearn.base import BaseEstimator, RegressorMixin
1313
from sklearn.linear_model import LinearRegression
14-
from sklearn.utils import check_array, check_X_y
14+
from sklearn.utils import check_array, check_consistent_length, column_or_1d
1515
from sklearn.utils._param_validation import Interval, StrOptions, validate_params
1616
from sklearn.utils.validation import check_is_fitted
1717

@@ -52,7 +52,7 @@ def make_time_shift_features(X, ids):
5252
[5., 3., 4.],
5353
[7., 5., 6.]])
5454
"""
55-
X = check_array(X, ensure_2d=True, dtype=float)
55+
X = check_array(X, ensure_2d=True, dtype=float, force_all_finite="allow-nan")
5656
ids = check_array(ids, ensure_2d=True, dtype=int)
5757
n_samples = X.shape[0]
5858
n_outputs = ids.shape[0]
@@ -177,7 +177,7 @@ def make_poly_features(X, ids):
177177
[ 1., 5., 25., 6.],
178178
[ 1., 7., 49., 8.]])
179179
"""
180-
X = check_array(X, ensure_2d=True, dtype=float)
180+
X = check_array(X, ensure_2d=True, dtype=float, force_all_finite="allow-nan")
181181
ids = check_array(ids, ensure_2d=True, dtype=int)
182182
n_samples = X.shape[0]
183183
n_outputs, degree = ids.shape
@@ -263,6 +263,12 @@ def make_poly_ids(
263263
return np.delete(ids, const_id, 0) # remove the constant featrue
264264

265265

266+
def _mask_missing_value(*arr):
267+
"""Remove missing value for all arrays."""
268+
mask_nomissing = np.all(np.isfinite(np.c_[arr]), axis=1)
269+
return tuple([x[mask_nomissing] for x in arr])
270+
271+
266272
class Narx(BaseEstimator, RegressorMixin):
267273
"""Nonlinear Autoregressive eXogenous model.
268274
For example, a (polynomial) Narx model is like
@@ -392,6 +398,9 @@ def fit(self, X, y, coef_init=None, **params):
392398
When `coef_init` is an array, the model will be a Multi-Step-Ahead
393399
Narx whose coefficients and intercept are initialized by the array.
394400
401+
.. note::
402+
When coef_init is None, missing values (i.e., np.nan) are allowed.
403+
395404
**params : dict
396405
Keyword arguments passed to
397406
`scipy.optimize.least_squares`.
@@ -401,7 +410,9 @@ def fit(self, X, y, coef_init=None, **params):
401410
self : object
402411
Fitted Estimator.
403412
"""
404-
X, y = self._validate_data(X, y, y_numeric=True, multi_output=False)
413+
X = self._validate_data(X, dtype=float, force_all_finite="allow-nan")
414+
y = column_or_1d(y, dtype=float, warn=True)
415+
check_consistent_length(X, y)
405416

406417
if self.time_shift_ids is None:
407418
self.time_shift_ids_ = make_time_shift_ids(
@@ -455,8 +466,10 @@ def fit(self, X, y, coef_init=None, **params):
455466
osa_narx = LinearRegression()
456467
time_shift_vars = make_time_shift_features(xy_hstack, self.time_shift_ids_)
457468
poly_terms = make_poly_features(time_shift_vars, self.poly_ids_)
469+
# Remove missing values
470+
poly_terms_masked, y_masked = _mask_missing_value(poly_terms, y)
458471

459-
osa_narx.fit(poly_terms, y)
472+
osa_narx.fit(poly_terms_masked, y_masked)
460473
if coef_init is None:
461474
self.coef_ = osa_narx.coef_
462475
self.intercept_ = osa_narx.intercept_
@@ -516,9 +529,21 @@ def _expression(self, X, y_hat, coef, intercept, k):
516529
def _predict(expression, X, y_init, coef, intercept, max_delay):
517530
n_samples = X.shape[0]
518531
y_hat = np.zeros(n_samples)
519-
y_hat[:max_delay] = y_init
520-
for k in range(max_delay, n_samples):
521-
y_hat[k] = expression(X, y_hat, coef, intercept, k)
532+
at_init = True
533+
init_k = 0
534+
for k in range(n_samples):
535+
if ~np.all(np.isfinite(X[k])):
536+
at_init = True
537+
init_k = k + 1
538+
y_hat[k] = np.nan
539+
continue
540+
if k - init_k == max_delay:
541+
at_init = False
542+
543+
if at_init:
544+
y_hat[k] = y_init[k - init_k]
545+
else:
546+
y_hat[k] = expression(X, y_hat, coef, intercept, k)
522547
if np.any(y_hat[k] > 1e20):
523548
y_hat[k:] = np.inf
524549
return y_hat
@@ -535,9 +560,11 @@ def _residual(
535560
coef = coef_intercept[:-1]
536561
intercept = coef_intercept[-1]
537562

538-
return (
539-
y - Narx._predict(expression, X, y[:max_delay], coef, intercept, max_delay)
540-
).flatten()
563+
y_hat = Narx._predict(expression, X, y[:max_delay], coef, intercept, max_delay)
564+
565+
y_masked, y_hat_masked = _mask_missing_value(y, y_hat)
566+
567+
return (y_masked - y_hat_masked).flatten()
541568

542569
@validate_params(
543570
{
@@ -564,11 +591,11 @@ def predict(self, X, y_init=None):
564591
"""
565592
check_is_fitted(self)
566593

567-
X = self._validate_data(X, reset=False)
594+
X = self._validate_data(X, reset=False, force_all_finite="allow-nan")
568595
if y_init is None:
569596
y_init = np.zeros(self.max_delay_)
570597
else:
571-
y_init = check_array(y_init, ensure_2d=False, dtype=np.float64)
598+
y_init = column_or_1d(y_init, dtype=float)
572599
if y_init.shape[0] != self.max_delay_:
573600
raise ValueError(
574601
"`y_init` should have the shape of "
@@ -586,6 +613,9 @@ def predict(self, X, y_init=None):
586613
self.max_delay_,
587614
)
588615

616+
def _more_tags(self):
617+
return {"allow_nan": True}
618+
589619

590620
@validate_params(
591621
{
@@ -721,10 +751,10 @@ def make_narx(
721751
Parameters
722752
----------
723753
X : array-like of shape (n_samples, n_features)
724-
Feature matrix.
754+
Feature matrix.
725755
726756
y : array-like of shape (n_samples,)
727-
Target matrix.
757+
Target vector.
728758
729759
n_features_to_select : int
730760
The parameter is the absolute number of features to select.
@@ -800,14 +830,15 @@ def make_narx(
800830
| X[k-1,0]*X[k-3,0] | 1.999 |
801831
| X[k-2,0]*X[k-0,1] | 1.527 |
802832
"""
803-
X, y = check_X_y(X, y, dtype=Real, multi_output=False)
833+
X = check_array(X, dtype=float, ensure_2d=True, force_all_finite="allow-nan")
834+
y = column_or_1d(y, dtype=float)
835+
check_consistent_length(X, y)
804836

805837
xy_hstack = np.c_[X, y]
806838
n_features = X.shape[1]
807-
n_outputs = xy_hstack.shape[1] - n_features
808839

809840
if include_zero_delay is None:
810-
include_zero_delay = [True] * n_features + [False] * n_outputs
841+
include_zero_delay = [True] * n_features + [False]
811842

812843
time_shift_ids_all = make_time_shift_ids(
813844
n_features=xy_hstack.shape[1],
@@ -831,11 +862,14 @@ def make_narx(
831862
)
832863
poly_terms = make_poly_features(time_shift_vars, poly_ids_all)
833864

865+
# Remove missing values
866+
poly_terms_masked, y_masked = _mask_missing_value(poly_terms, y)
867+
834868
csf = FastCan(
835869
n_features_to_select,
836870
eta=eta,
837871
verbose=0,
838-
).fit(poly_terms, y)
872+
).fit(poly_terms_masked, y_masked)
839873
if drop is not None:
840874
indices, _ = refine(csf, drop=drop, max_iter=max_iter, verbose=verbose)
841875
support = np.zeros(shape=csf.n_features_in_, dtype=bool)

tests/test_narx.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ def test_time_ids():
1919
with pytest.raises(ValueError, match=r"The length of `include_zero_delay`.*"):
2020
make_time_shift_ids(3, 2, [False, True, False, True])
2121

22-
def test_narx():
22+
@pytest.mark.parametrize("nan", [False, True])
23+
def test_narx(nan):
2324
rng = np.random.default_rng(12345)
2425
n_samples = 1000
2526
max_delay = 3
@@ -32,8 +33,14 @@ def test_narx():
3233
y = y[max_delay:]+e
3334
X = np.c_[u0[max_delay:], u1]
3435

36+
if nan:
37+
X_nan_ids = rng.choice(n_samples, 20, replace=False)
38+
y_nan_ids = rng.choice(n_samples, 10, replace=False)
39+
X[X_nan_ids] = np.nan
40+
y[y_nan_ids] = np.nan
41+
3542
params = {
36-
"n_features_to_select": rng.integers(low=1, high=4),
43+
"n_features_to_select": rng.integers(low=2, high=4),
3744
"max_delay": rng.integers(low=0, high=10),
3845
"poly_degree": rng.integers(low=2, high=5),
3946
}
@@ -46,14 +53,19 @@ def test_narx():
4653
params["include_zero_delay"] = [False, True, False]
4754
narx_0_delay = make_narx(X=X, y=y, **params)
4855
time_shift_ids = narx_0_delay.time_shift_ids
49-
assert ~np.isin(0, time_shift_ids[time_shift_ids[:, 0] == 0][:, 1])
50-
assert np.isin(0, time_shift_ids[time_shift_ids[:, 0] == 1][:, 1])
51-
assert ~np.isin(0, time_shift_ids[time_shift_ids[:, 0] == 2][:, 1])
56+
time_ids_u0 = time_shift_ids[time_shift_ids[:, 0] == 0]
57+
time_ids_u1 = time_shift_ids[time_shift_ids[:, 0] == 1]
58+
time_ids_y = time_shift_ids[time_shift_ids[:, 0] == 2]
59+
assert ~np.isin(0, time_ids_u0[:, 1]) or (time_ids_u0.size == 0)
60+
assert np.isin(0, time_ids_u1[:, 1]) or (time_ids_u1.size == 0)
61+
assert ~np.isin(0, time_ids_y[:, 1]) or (time_ids_y.size == 0)
5262

5363
params["static_indices"] = [1]
5464
narx_static = make_narx(X=X, y=y, **params)
5565
time_shift_ids = narx_static.time_shift_ids
56-
assert time_shift_ids[time_shift_ids[:, 0] == 1][0, 1] == 0
66+
time_ids_u1 = time_shift_ids[time_shift_ids[:, 0] == 1]
67+
if time_ids_u1.size != 0:
68+
assert time_ids_u1[0, 1] == 0
5769

5870
params["drop"] = 1
5971
params["max_iter"] = 10
@@ -112,3 +124,5 @@ def test_narx():
112124
poly_ids = make_poly_ids(time_shift_ids.shape[0]+1, 2)
113125
with pytest.raises(ValueError, match=r"The element x of poly_ids should .*"):
114126
narx_osa = Narx(time_shift_ids=time_shift_ids, poly_ids=poly_ids).fit(X, y)
127+
128+
test_narx(True)

0 commit comments

Comments
 (0)