Skip to content

Commit 30889b7

Browse files
FEAT add session_sizes to split time-series sessions (#203)
1 parent 0ad5875 commit 30889b7

File tree

9 files changed

+224
-34
lines changed

9 files changed

+224
-34
lines changed

.github/workflows/emscripten.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ jobs:
99
steps:
1010
- uses: actions/checkout@v5
1111
- name: Build WASM wheel
12-
uses: pypa/cibuildwheel@v3.2.1
12+
uses: pypa/cibuildwheel@v3.3.0
1313
env:
1414
CIBW_PLATFORM: pyodide
1515
- name: Upload package

.github/workflows/wheel.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
steps:
3434
- uses: actions/checkout@v5
3535
- name: Build wheels
36-
uses: pypa/cibuildwheel@v3.2.1
36+
uses: pypa/cibuildwheel@v3.3.0
3737
env:
3838
CIBW_SKIP: "*_i686 *_ppc64le *_s390x *_universal2 *-musllinux_* cp314t*"
3939
CIBW_PROJECT_REQUIRES_PYTHON: ">=3.9"

doc/narx.rst

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,20 +46,31 @@ The discontinuity can be caused by
4646
lasts for one hour at 1 Hz, and another measurement is carried out at Tuesday and lasts for two hours at 1 Hz
4747
#. Missing values
4848

49-
No matter how the discontinuity is caused, :class:`NARX` treats the discontinuous time series as multiple pieces of continuous time series in the same way.
49+
As a result, there are two ways to inform :class:`NARX` the training data from different measurement sessions.
50+
From version 0.5, the difference measurement sessions can be set by `session_sizes` in :meth:`NARX.fit` and :func:`make_narx`.
51+
Alternatively, different measurement sessions can also be notified by inserting `np.nan` to break the data.
52+
The following examples show how to notify :class:`NARX` the training data from different measurement sessions.
5053

51-
.. note::
52-
It is easy to notify :class:`NARX` that the time series data are from different measurement sessions by inserting np.nan to break the data. For example,
54+
.. code-block:: python
5355
5456
>>> import numpy as np
5557
>>> x0 = np.zeros((3, 2)) # First measurement session
5658
>>> x1 = np.ones((5, 2)) # Second measurement session
5759
>>> max_delay = 2 # Assume the maximum delay for NARX model is 2
5860
>>> # Insert (at least max_delay number of) np.nan to break the two measurement sessions
5961
>>> u = np.r_[x0, [[np.nan, np.nan]]*max_delay, x1]
62+
>>> # Or use session_sizes to break the two measurement sessions
63+
>>> session_sizes = [3, 5]
6064
61-
It is important to break the different measurement sessions by np.nan, because otherwise,
62-
the model will assume the time interval between the two measurement sessions is the same as the time interval within a session.
65+
.. note::
66+
It is important to separate different measurement sessions using either method.
67+
Otherwise, the model will incorrectly assume that the time interval between two measurement sessions
68+
is the same as the sampling interval within a session.
69+
70+
Actually, the two methods will result in the exact same models when `X` is not None or multiple-step-ahead prediction is not used for training.
71+
If `X` is None, i.e. Auto-Regression models, and multiple-step-ahead prediction is used for training, only `session_sizes` can
72+
separate the two measurement sessions.
73+
Because when using multiple-step-ahead prediction, the optimiser relies on the missing values in inputs `X` rather than output `y`, while Auto-Regression models do not have input `X`.
6374

6475

6576
One-step-ahead and multiple-step-ahead prediction

doc/unsupervised.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ features, which maximize the sum of the squared canonical correlation (SSC) with
1717
the principal components (PCs) acquired from PCA of the feature matrix :math:`X` [1]_.
1818
See the example below.
1919

20+
.. code-block:: python
21+
2022
>>> from sklearn.decomposition import PCA
2123
>>> from sklearn import datasets
2224
>>> from fastcan import FastCan

fastcan/narx/_base.py

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,14 @@ def __init__(
162162
{
163163
"X": [None, "array-like"],
164164
"coef_init": [None, StrOptions({"one_step_ahead"}), "array-like"],
165-
"sample_weight": ["array-like", None],
165+
"sample_weight": [None, "array-like"],
166+
"session_sizes": [None, "array-like"],
166167
},
167168
prefer_skip_nested_validation=True,
168169
)
169-
def fit(self, X, y, sample_weight=None, coef_init=None, **params):
170+
def fit(
171+
self, X, y, sample_weight=None, coef_init=None, session_sizes=None, **params
172+
):
170173
"""
171174
Fit narx model.
172175
@@ -194,6 +197,13 @@ def fit(self, X, y, sample_weight=None, coef_init=None, **params):
194197
When coef_init is `one_step_ahead`, the model will be trained as a
195198
Multi-Step-Ahead NARX, rather than a One-Step-Ahead NARX.
196199
200+
session_sizes : array-like of shape (n_sessions,), default=None
201+
The sizes of measurement sessions for time-series.
202+
The sum of session_sizes should be equal to n_samples.
203+
If None, the whole data is treated as one session.
204+
205+
.. versionadded:: 0.5
206+
197207
**params : dict
198208
Keyword arguments passed to
199209
`scipy.optimize.least_squares`.
@@ -220,9 +230,6 @@ def fit(self, X, y, sample_weight=None, coef_init=None, **params):
220230
X = np.empty((len(y), 0), dtype=float, order="C") # Create 0 feature input
221231
else:
222232
check_consistent_length(X, y)
223-
sample_weight = _check_sample_weight(
224-
sample_weight, X, dtype=X.dtype, ensure_non_negative=True
225-
)
226233
# store the number of dimension of the target to predict an array of
227234
# similar shape at predict
228235
self._y_ndim = y.ndim
@@ -231,6 +238,12 @@ def fit(self, X, y, sample_weight=None, coef_init=None, **params):
231238
self.n_outputs_ = y.shape[1]
232239
n_samples, n_features = X.shape
233240

241+
sample_weight = _check_sample_weight(
242+
sample_weight, X, dtype=X.dtype, ensure_non_negative=True
243+
)
244+
245+
session_sizes_cumsum = _validate_session_sizes(session_sizes, n_samples)
246+
234247
if self.feat_ids is None:
235248
if n_features == 0:
236249
feat_ids_ = make_poly_ids(self.n_outputs_, 1) - 1
@@ -295,8 +308,13 @@ def fit(self, X, y, sample_weight=None, coef_init=None, **params):
295308
time_shift_ids, poly_ids = fd2tp(self.feat_ids_, self.delay_ids_)
296309
xy_hstack = np.c_[X, y]
297310
osa_narx = LinearRegression(fit_intercept=self.fit_intercept)
298-
time_shift_vars = make_time_shift_features(xy_hstack, time_shift_ids)
299-
poly_terms = make_poly_features(time_shift_vars, poly_ids)
311+
poly_terms = _prepare_poly_terms(
312+
xy_hstack,
313+
time_shift_ids,
314+
poly_ids,
315+
session_sizes_cumsum,
316+
self.max_delay_,
317+
)
300318
# Remove missing values
301319
poly_terms_masked, y_masked, sample_weight_masked = mask_missing_values(
302320
poly_terms, y, sample_weight
@@ -359,6 +377,7 @@ def fit(self, X, y, sample_weight=None, coef_init=None, **params):
359377
self.output_ids_,
360378
self.fit_intercept,
361379
sample_weight_sqrt,
380+
session_sizes_cumsum,
362381
grad_yyd_ids,
363382
grad_delay_ids,
364383
grad_coef_ids,
@@ -422,6 +441,7 @@ def _loss(
422441
output_ids,
423442
fit_intercept,
424443
sample_weight_sqrt,
444+
session_sizes_cumsum,
425445
*args,
426446
):
427447
# Sum of squared errors
@@ -442,6 +462,7 @@ def _loss(
442462
feat_ids,
443463
delay_ids,
444464
output_ids,
465+
session_sizes_cumsum,
445466
y_hat,
446467
)
447468

@@ -461,6 +482,7 @@ def _grad(
461482
output_ids,
462483
fit_intercept,
463484
sample_weight_sqrt,
485+
session_sizes_cumsum,
464486
grad_yyd_ids,
465487
grad_delay_ids,
466488
grad_coef_ids,
@@ -484,6 +506,7 @@ def _grad(
484506
feat_ids,
485507
delay_ids,
486508
output_ids,
509+
session_sizes_cumsum,
487510
y_hat,
488511
)
489512

@@ -510,6 +533,7 @@ def _grad(
510533
grad_delay_ids,
511534
grad_coef_ids,
512535
grad_feat_ids,
536+
session_sizes_cumsum,
513537
dydx,
514538
dcf,
515539
)
@@ -587,7 +611,9 @@ def predict(self, X, y_init=None):
587611
f"`y_init` should have {self.n_outputs_} outputs "
588612
f"but got {y_init.shape[1]}."
589613
)
590-
y_hat = np.zeros((X.shape[0], self.n_outputs_), dtype=float)
614+
n_samples = X.shape[0]
615+
y_hat = np.zeros((n_samples, self.n_outputs_), dtype=float)
616+
session_sizes_cumsum = np.array([n_samples], dtype=np.int32)
591617
_predict(
592618
X,
593619
y_init,
@@ -596,6 +622,7 @@ def predict(self, X, y_init=None):
596622
self.feat_ids_,
597623
self.delay_ids_,
598624
self.output_ids_,
625+
session_sizes_cumsum,
599626
y_hat,
600627
)
601628
if self._y_ndim == 1:
@@ -606,3 +633,34 @@ def __sklearn_tags__(self):
606633
tags = super().__sklearn_tags__()
607634
tags.input_tags.allow_nan = True
608635
return tags
636+
637+
638+
def _validate_session_sizes(session_sizes, n_samples):
639+
if session_sizes is None:
640+
return np.array([n_samples], dtype=np.int32)
641+
session_sizes = column_or_1d(
642+
session_sizes,
643+
dtype=np.int32,
644+
warn=True,
645+
)
646+
if (session_sizes <= 0).any():
647+
raise ValueError(
648+
"All elements of session_sizes should be positive, "
649+
f"but got {session_sizes}."
650+
)
651+
if session_sizes.sum() != n_samples:
652+
raise ValueError(
653+
"The sum of session_sizes should be equal to n_samples, "
654+
f"but got {session_sizes.sum()} != {n_samples}."
655+
)
656+
return np.cumsum(session_sizes, dtype=np.int32)
657+
658+
659+
def _prepare_poly_terms(
660+
xy_hstack, time_shift_ids, poly_ids, session_sizes_cumsum, max_delay
661+
):
662+
time_shift_vars = make_time_shift_features(xy_hstack, time_shift_ids)
663+
for start in session_sizes_cumsum[:-1]:
664+
time_shift_vars[start : start + max_delay] = np.nan
665+
poly_terms = make_poly_features(time_shift_vars, poly_ids)
666+
return poly_terms

fastcan/narx/_narx_fast.pyx

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -122,14 +122,15 @@ cdef inline void _update_dcf(
122122

123123
@final
124124
cpdef void _predict(
125-
const double[:, ::1] X, # IN
126-
const double[:, ::1] y_ref, # IN
127-
const double[::1] coef, # IN
128-
const double[::1] intercept, # IN
129-
const int[:, ::1] feat_ids, # IN
130-
const int[:, ::1] delay_ids, # IN
131-
const int[::1] output_ids, # IN
132-
double[:, ::1] y_hat, # OUT
125+
const double[:, ::1] X, # IN
126+
const double[:, ::1] y_ref, # IN
127+
const double[::1] coef, # IN
128+
const double[::1] intercept, # IN
129+
const int[:, ::1] feat_ids, # IN
130+
const int[:, ::1] delay_ids, # IN
131+
const int[::1] output_ids, # IN
132+
const int[::1] session_sizes_cumsum, # IN
133+
double[:, ::1] y_hat, # OUT
133134
) noexcept nogil:
134135
"""
135136
Vectorized (Cython) variant of Python NARX._predict.
@@ -142,11 +143,15 @@ cpdef void _predict(
142143
cdef:
143144
Py_ssize_t n_samples = X.shape[0]
144145
Py_ssize_t n_ref = y_ref.shape[0]
145-
Py_ssize_t k
146+
Py_ssize_t k, s = 0
146147
Py_ssize_t init_k = 0
147148
bint at_init = True
148149

149150
for k in range(n_samples):
151+
if k == session_sizes_cumsum[s]:
152+
s += 1
153+
at_init = True
154+
init_k = k
150155
with gil:
151156
if not np.all(np.isfinite(X[k])):
152157
at_init = True
@@ -192,8 +197,9 @@ cpdef void _update_dydx(
192197
const int[:, ::1] grad_delay_ids,
193198
const int[::1] grad_coef_ids,
194199
const int[:, ::1] grad_feat_ids,
195-
double[:, :, ::1] dydx, # OUT
196-
double[:, :, ::1] dcf, # OUT
200+
const int[::1] session_sizes_cumsum,
201+
double[:, :, ::1] dydx, # OUT
202+
double[:, :, ::1] dcf, # OUT
197203
) noexcept nogil:
198204
"""
199205
Computation of the Jacobian matrix dydx.
@@ -211,7 +217,7 @@ cpdef void _update_dydx(
211217
cdef:
212218
Py_ssize_t n_samples = y_hat.shape[0]
213219
Py_ssize_t n_coefs = feat_ids.shape[0]
214-
Py_ssize_t k, i, d
220+
Py_ssize_t k, i, d, s = 0
215221
Py_ssize_t M = dcf.shape[1] # n_outputs
216222
Py_ssize_t N = dydx.shape[2] # n_x
217223
Py_ssize_t init_k = 0
@@ -224,6 +230,11 @@ cpdef void _update_dydx(
224230
terms_intercepts[i] = 1.0
225231

226232
for k in range(n_samples):
233+
if k == session_sizes_cumsum[s]:
234+
s += 1
235+
at_init = True
236+
init_k = k
237+
continue
227238
with gil:
228239
is_finite = np.all(np.isfinite(X[k])) and np.all(np.isfinite(y_hat[k]))
229240
if not is_finite:

fastcan/narx/_utils.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,9 @@
1818
from .._fastcan import FastCan
1919
from .._refine import refine
2020
from ..utils import mask_missing_values
21-
from ._base import NARX
21+
from ._base import NARX, _prepare_poly_terms, _validate_session_sizes
2222
from ._feature import (
23-
make_poly_features,
2423
make_poly_ids,
25-
make_time_shift_features,
2624
make_time_shift_ids,
2725
tp2fd,
2826
)
@@ -136,6 +134,7 @@ def _get_term_str(term_feat_ids, term_delay_ids):
136134
Interval(Integral, 1, None, closed="left"),
137135
],
138136
"fit_intercept": ["boolean"],
137+
"session_sizes": [None, "array-like"],
139138
"max_candidates": [None, Interval(Integral, 1, None, closed="left")],
140139
"random_state": ["random_state"],
141140
"include_zero_delay": [None, "array-like"],
@@ -161,6 +160,7 @@ def make_narx(
161160
poly_degree=1,
162161
*,
163162
fit_intercept=True,
163+
session_sizes=None,
164164
max_candidates=None,
165165
random_state=None,
166166
include_zero_delay=None,
@@ -194,6 +194,13 @@ def make_narx(
194194
fit_intercept : bool, default=True
195195
Whether to fit the intercept. If set to False, intercept will be zeros.
196196
197+
session_sizes : array-like of shape (n_sessions,), default=None
198+
The sizes of measurement sessions for time-series.
199+
The sum of session_sizes should be equal to n_samples.
200+
If None, the whole data is treated as one session.
201+
202+
.. versionadded:: 0.5
203+
197204
max_candidates : int, default=None
198205
Maximum number of candidate polynomial terms retained before selection.
199206
Randomly selected by reservoir sampling.
@@ -284,7 +291,7 @@ def make_narx(
284291
check_consistent_length(X, y)
285292
if y.ndim == 1:
286293
y = y.reshape(-1, 1)
287-
n_outputs = y.shape[1]
294+
n_samples, n_outputs = y.shape
288295
if isinstance(n_terms_to_select, Integral):
289296
n_terms_to_select = np.full(n_outputs, n_terms_to_select, dtype=int)
290297
else:
@@ -295,6 +302,7 @@ def make_narx(
295302
f"the number of outputs, {n_outputs}, but got "
296303
f"{len(n_terms_to_select)}."
297304
)
305+
session_sizes_cumsum = _validate_session_sizes(session_sizes, n_samples)
298306

299307
xy_hstack = np.c_[X, y]
300308
n_features = X.shape[1]
@@ -318,16 +326,20 @@ def make_narx(
318326
),
319327
0,
320328
)
321-
time_shift_vars = make_time_shift_features(xy_hstack, time_shift_ids_all)
322-
323329
poly_ids_all = make_poly_ids(
324330
time_shift_ids_all.shape[0],
325331
poly_degree,
326332
max_poly=max_candidates,
327333
random_state=random_state,
328334
)
329-
poly_terms = make_poly_features(time_shift_vars, poly_ids_all)
330335

336+
poly_terms = _prepare_poly_terms(
337+
xy_hstack,
338+
time_shift_ids_all,
339+
poly_ids_all,
340+
session_sizes_cumsum,
341+
max_delay,
342+
)
331343
# Remove missing values
332344
poly_terms_masked, y_masked = mask_missing_values(poly_terms, y)
333345

0 commit comments

Comments
 (0)