Skip to content

Commit 9e47020

Browse files
Merge pull request #515 from flatironinstitute/development
Development
2 parents 51cdeef + a111f30 commit 9e47020

15 files changed

Lines changed: 393 additions & 413 deletions

src/nemos/basis/_basis.py

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -94,15 +94,18 @@ def min_max_rescale_samples(
9494
else:
9595
nanmin, nanmax, asarray, where = np.nanmin, np.nanmax, np.asarray, np.where
9696

97-
# if not normalize all array
98-
vmin = nanmin(sample_pts, axis=0) if bounds is None else bounds[0]
99-
vmax = nanmax(sample_pts, axis=0) if bounds is None else bounds[1]
97+
if bounds is None:
98+
vmin = nanmin(sample_pts, axis=0)
99+
vmax = nanmax(sample_pts, axis=0)
100+
elif isinstance(bounds[0], (tuple, list)):
101+
vmin = asarray([b[0] for b in bounds])
102+
vmax = asarray([b[1] for b in bounds])
103+
else:
104+
vmin, vmax = bounds[0], bounds[1]
105+
100106
scaling = asarray(vmax - vmin)
101-
# do not normalize if samples contain a single value (in which case vmax=vmin)
102107
scaling = where(scaling == 0, 1.0, scaling)
103-
sample_pts -= vmin
104-
sample_pts /= scaling
105-
108+
sample_pts = (sample_pts - vmin) / scaling
106109
return sample_pts, scaling
107110

108111

@@ -115,7 +118,7 @@ def _is_single_bound(bounds) -> bool:
115118
if len(bounds) != 2:
116119
return False
117120
# Single bound has numeric elements; multiple bounds have tuple/None elements
118-
return all(isinstance(b, (int, float, np.number)) for b in bounds)
121+
return all(isinstance(b, (int, float, np.number)) or b is None for b in bounds)
119122

120123

121124
def _fill_bounds(b):
@@ -184,7 +187,7 @@ class Basis(Base, abc.ABC, BasisTransformerMixin):
184187
def __init__(
185188
self,
186189
) -> None:
187-
self._n_input_dimensionality = getattr(self, "_n_input_dimensionality", 0)
190+
self._n_inputs = getattr(self, "_n_inputs", 0)
188191

189192
# specified only after inputs/input shapes are provided
190193
self._input_shape_product = getattr(self, "_input_shape_product", None)
@@ -635,8 +638,8 @@ def evaluate(self, *xi: ArrayLike | Tsd | TsdFrame | TsdTensor) -> FeatureMatrix
635638
"""
636639
X = np.hstack(
637640
(
638-
self.basis1.evaluate(*xi[: self.basis1._n_input_dimensionality]),
639-
self.basis2.evaluate(*xi[self.basis1._n_input_dimensionality :]),
641+
self.basis1.evaluate(*xi[: self.basis1._n_inputs]),
642+
self.basis2.evaluate(*xi[self.basis1._n_inputs :]),
640643
)
641644
)
642645
return X
@@ -691,8 +694,8 @@ def _compute_features(
691694
)
692695
X = hstack_pynapple(
693696
(
694-
comp_feature_1(*xi[: self.basis1._n_input_dimensionality]),
695-
comp_feature_2(*xi[self.basis1._n_input_dimensionality :]),
697+
comp_feature_1(*xi[: self.basis1._n_inputs]),
698+
comp_feature_2(*xi[self.basis1._n_inputs :]),
696699
),
697700
)
698701
return X
@@ -1048,8 +1051,8 @@ def evaluate(self, *xi: ArrayLike | Tsd | TsdFrame | TsdTensor) -> FeatureMatrix
10481051
"""
10491052
# evaluate preserves the shape of the input arrays
10501053
shape = xi[0].shape
1051-
x1 = self.basis1.evaluate(*xi[: self.basis1._n_input_dimensionality])
1052-
x2 = self.basis2.evaluate(*xi[self.basis1._n_input_dimensionality :])
1054+
x1 = self.basis1.evaluate(*xi[: self.basis1._n_inputs])
1055+
x2 = self.basis2.evaluate(*xi[self.basis1._n_inputs :])
10531056
# Required in case xi.shape[-1] == 0
10541057
# For example, in a multiplication with Zero basis
10551058
x1_shape = math.prod(x1.shape[:-1])
@@ -1098,8 +1101,8 @@ def _compute_features(
10981101
comp_feature_2 = getattr(
10991102
self.basis2, "_compute_features", self.basis2.compute_features
11001103
)
1101-
x1 = comp_feature_1(*xi[: self.basis1._n_input_dimensionality])
1102-
x2 = comp_feature_2(*xi[self.basis1._n_input_dimensionality :])
1104+
x1 = comp_feature_1(*xi[: self.basis1._n_inputs])
1105+
x2 = comp_feature_2(*xi[self.basis1._n_inputs :])
11031106
# multiplicative basis inputs are of the same shape, checked and
11041107
# set just before the call to this method
11051108
n_samples = x1.shape[0]

src/nemos/basis/_basis_mixin.py

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,15 @@
1010
from contextlib import contextmanager
1111
from functools import wraps
1212
from itertools import chain
13-
from typing import TYPE_CHECKING, Any, Generator, Literal, Optional, Tuple, Union
13+
from typing import (
14+
TYPE_CHECKING,
15+
Any,
16+
Generator,
17+
List,
18+
Literal,
19+
Optional,
20+
Tuple,
21+
)
1422

1523
import jax
1624
import jax.numpy as jnp
@@ -548,21 +556,19 @@ def _compute_features(self, *xi: ArrayLike | Tsd | TsdFrame | TsdTensor):
548556
@support_pynapple(conv_type="jax")
549557
def _apply_fill_value(self, *xi: ArrayLike, out: NDArray) -> jax.Array:
550558
"""Apply fill value to out-of-bounds samples."""
551-
# Use jnp.where for JAX compatibility
552559
to_fill = jnp.any(
553560
jnp.stack(
554561
[
555562
jnp.any(
556-
(jnp.reshape(x, (x.shape[0], -1)) < self.bounds[0])
557-
| (jnp.reshape(x, (x.shape[0], -1)) > self.bounds[1]),
563+
(jnp.reshape(x, (x.shape[0], -1)) < lo)
564+
| (jnp.reshape(x, (x.shape[0], -1)) > hi),
558565
axis=1,
559566
)
560-
for x in xi
567+
for x, (lo, hi) in zip(xi, self._get_bounds_per_dim(), strict=True)
561568
]
562569
),
563570
axis=0,
564571
)
565-
# Reshape to_fill to broadcast correctly: (n_samples,) -> (n_samples, 1, 1, ...)
566572
to_fill_broadcast = to_fill.reshape(to_fill.shape[0], *([1] * (out.ndim - 1)))
567573
return jnp.where(to_fill_broadcast, self.fill_value, out)
568574

@@ -605,10 +611,46 @@ def _set_input_independent_states(self) -> EvalBasisMixin:
605611
return self
606612

607613
@property
608-
def bounds(self):
609-
"""Range of values covered by the basis."""
614+
def bounds(self) -> List[Tuple[float, float]] | Tuple[float, float] | None:
615+
"""Returns bounds, as provided."""
610616
return self._bounds
611617

618+
def _get_bounds_per_dim(self) -> List[Tuple[float, float]]:
619+
"""Return bounds, broadcast to one pair per input dimension."""
620+
if self._bounds is None or isinstance(self._bounds[0], (int, float)):
621+
return [self._bounds] * self._n_inputs
622+
return list(self._bounds)
623+
624+
@bounds.setter
625+
def bounds(self, values):
626+
if values is None:
627+
self._bounds = None
628+
return
629+
630+
if isinstance(values, np.ndarray):
631+
values = values.tolist()
632+
633+
if not isinstance(values, (list, tuple)):
634+
raise TypeError(
635+
f"Invalid bounds ``{values}`` provided, "
636+
"bounds should be one or multiple tuples of 2 floats, "
637+
"matching the inputs of the basis.\n"
638+
)
639+
640+
# Validate: single (lo, hi) pair or one per dimension
641+
if len(values) == 2 and all(not isinstance(v, (list, tuple)) for v in values):
642+
# Single pair
643+
self._bounds = self._format_bounds(values)
644+
else:
645+
# One pair per dimension
646+
if len(values) != self._n_inputs:
647+
raise ValueError(
648+
f"Invalid bounds ``{values}`` provided, "
649+
"bounds should be one or multiple tuples of 2 floats, "
650+
"matching the inputs of the basis.\n"
651+
)
652+
self._bounds = tuple(self._format_bounds(v) for v in values)
653+
612654
@staticmethod
613655
def _format_bounds(values: Any) -> Tuple[Any, Exception | None]:
614656
"""Check bounds and cast to tuple."""
@@ -640,19 +682,6 @@ def _format_bounds(values: Any) -> Tuple[Any, Exception | None]:
640682

641683
return values
642684

643-
@bounds.setter
644-
def bounds(self, values: Union[None, Tuple[float, float]]):
645-
"""Setter for bounds."""
646-
if values is None:
647-
self._bounds = None
648-
return
649-
values = self._format_bounds(values)
650-
if values is not None and len(values) != 2:
651-
raise ValueError(
652-
f"The provided `bounds` must be of length two. Length {len(values)} provided instead!"
653-
)
654-
self._bounds = values
655-
656685

657686
class ConvBasisMixin:
658687
"""Mixin class for convolutional basis."""
@@ -883,7 +912,7 @@ def __init__(
883912
self, basis1: BasisMixin, basis2: BasisMixin, label: Optional[str] = None
884913
):
885914
# number of input arrays that the basis receives
886-
self._n_input_dimensionality = infer_input_dimensionality(
915+
self._n_inputs = infer_input_dimensionality(
887916
basis1
888917
) + infer_input_dimensionality(basis2)
889918

src/nemos/basis/_composition_utils.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,8 @@ def _composite_basis_setter_logic(new: BasisMixin, current: BasisMixin):
165165
# Carry-on input shape info if dimensions match
166166
for attr in ("_input_shape_product", "_input_shape_"):
167167
if getattr(new, attr, None) is None and getattr(
168-
new, "_n_input_dimensionality", None
169-
) == getattr(current, "_n_input_dimensionality", None):
168+
new, "_n_inputs", None
169+
) == getattr(current, "_n_inputs", None):
170170
setattr(new, attr, getattr(current, attr, None))
171171
return new
172172

@@ -226,10 +226,10 @@ def _atomic_basis_label_setter_logic(
226226
def infer_input_dimensionality(bas: BasisMixin) -> int:
227227
"""Infer input dimensionality from compute_features signature.
228228
229-
If `_n_input_dimensionality` return the attribute, otherwise return
229+
If `_n_inputs` return the attribute, otherwise return
230230
the number of fixed arguments in `compute_features`.
231231
"""
232-
n_input_dim = getattr(bas, "_n_input_dimensionality", None)
232+
n_input_dim = getattr(bas, "_n_inputs", None)
233233
if n_input_dim is None:
234234
# infer from compute_features (facilitate custom basis compatibility).
235235
# assume compute_features is always implemented.
@@ -398,13 +398,13 @@ def set_input_shape(bas, *xi):
398398
else 1
399399
)
400400
# get the attribute if available
401-
n_input_dim = getattr(bas, "_n_input_dimensionality", n_args)
401+
n_input_dim = getattr(bas, "_n_inputs", n_args)
402402

403403
if len(xi) == 1 and xi[0] is None:
404404
xi = (None,) * n_input_dim
405405

406406
elif len(xi) != n_input_dim:
407-
expected_inputs = getattr(bas, "_n_input_dimensionality", 1)
407+
expected_inputs = getattr(bas, "_n_inputs", 1)
408408
raise ValueError(
409409
f"set_input_shape expects {expected_inputs} input"
410410
f"{'s' if expected_inputs > 1 else ''}, but {len(xi)} were provided."
@@ -436,7 +436,7 @@ def set_input_shape(bas, *xi):
436436
if hasattr(bas.basis1, "compute_features")
437437
else 1
438438
)
439-
n_input_dim_1 = getattr(bas.basis1, "_n_input_dimensionality", n_args_1)
439+
n_input_dim_1 = getattr(bas.basis1, "_n_inputs", n_args_1)
440440

441441
out1 = set_input_shape(bas.basis1, *xi[:n_input_dim_1])
442442
out2 = set_input_shape(bas.basis2, *xi[n_input_dim_1:])

src/nemos/basis/_custom_basis.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ def __init__(
219219

220220
self._input_shape_product = None
221221

222-
self._n_input_dimensionality = infer_input_dimensionality(self)
222+
self._n_inputs = infer_input_dimensionality(self)
223223
self._n_basis_funcs = len(self.funcs)
224224

225225
self.basis_kwargs = basis_kwargs
@@ -253,9 +253,9 @@ def funcs(self, val: Iterable[Callable[[NDArray, ...], NDArray]]):
253253
if not all(isinstance(f, Callable) for f in val):
254254
raise ValueError("User must provide an iterable of callable.")
255255

256-
if hasattr(self, "_n_input_dimensionality"):
256+
if hasattr(self, "_n_inputs"):
257257
inp_dim = sum(count_positional_and_var_args(f)[0] for f in val)
258-
if inp_dim != self._n_input_dimensionality:
258+
if inp_dim != self._n_inputs:
259259
raise ValueError(
260260
"The number of input time series required by the CustomBasis must be consistent. "
261261
"Redefine a CustomBasis for a different number of inputs."
@@ -718,10 +718,10 @@ def input_shape(
718718
"""
719719
input_shape = self._input_shape_
720720
if input_shape is None:
721-
if self._n_input_dimensionality == 1:
721+
if self._n_inputs == 1:
722722
return None
723723
else:
724-
return [None] * self._n_input_dimensionality
725-
if self._n_input_dimensionality == 1:
724+
return [None] * self._n_inputs
725+
if self._n_inputs == 1:
726726
return input_shape[0]
727-
return input_shape * self._n_input_dimensionality
727+
return input_shape * self._n_inputs

src/nemos/basis/_decaying_exponential.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,11 @@ def __init__(
3939
decay_rates: NDArray[np.floating],
4040
label: Optional[str] = "OrthExponentialBasis",
4141
):
42+
self._n_inputs = 1
4243
AtomicBasisMixin.__init__(self, n_basis_funcs=n_basis_funcs, label=label)
43-
Basis.__init__(
44-
self,
45-
)
44+
Basis.__init__(self)
4645
self.decay_rates = decay_rates
4746
self._check_rates()
48-
self._n_input_dimensionality = 1
4947

5048
@property
5149
def decay_rates(self):

src/nemos/basis/_fourier_basis.py

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ def __init__(
353353
) = "no-intercept",
354354
label: Optional[str] = None,
355355
) -> None:
356-
self._n_input_dimensionality = self._check_ndim(ndim)
356+
self._n_inputs = self._check_ndim(ndim)
357357
self.frequencies = frequencies
358358
self.frequency_mask = frequency_mask
359359
Basis.__init__(
@@ -462,8 +462,8 @@ def frequency_mask(
462462

463463
values = values.astype(bool)
464464

465-
if not values.ndim == self._n_input_dimensionality:
466-
ndim = self._n_input_dimensionality
465+
if not values.ndim == self._n_inputs:
466+
ndim = self._n_inputs
467467
raise ValueError(
468468
f"The frequency mask for a {ndim}-dimensional Fourier basis "
469469
f"must be an {ndim}-dimensional array of 0s and 1s. "
@@ -513,21 +513,19 @@ def frequencies(
513513
self,
514514
frequencies: int | tuple[int, int] | list[int] | list[tuple[int, int]],
515515
) -> None:
516-
ndim = self._n_input_dimensionality
516+
ndim = self._n_inputs
517517

518518
if isinstance(frequencies, Number) and (frequencies == int(frequencies)):
519519
frequencies = [arange_constructor(frequencies)] * ndim
520520

521521
elif isinstance(frequencies, tuple):
522-
frequencies = _process_tuple_frequencies(
523-
frequencies, self._n_input_dimensionality
524-
)
522+
frequencies = _process_tuple_frequencies(frequencies, self._n_inputs)
525523

526524
elif is_at_least_1d_numpy_array_like(frequencies):
527525
frequencies = _check_and_sort_frequencies(*([frequencies] * ndim))
528526

529527
elif isinstance(frequencies, list):
530-
if len(frequencies) != self._n_input_dimensionality:
528+
if len(frequencies) != self._n_inputs:
531529
raise ValueError(
532530
"Length of frequencies list must match input dimensionality."
533531
)
@@ -589,7 +587,7 @@ def masked_frequencies(self) -> jnp.ndarray:
589587
@property
590588
def ndim(self):
591589
"""The dimensionality of the basis."""
592-
return self._n_input_dimensionality
590+
return self._n_inputs
593591

594592
@staticmethod
595593
def _check_ndim(ndim: int) -> int:
@@ -630,25 +628,24 @@ def evaluate( # call these _evaluate
630628
631629
"""
632630
shape = sample_pts[0].shape
633-
634-
bounds = getattr(self, "bounds", None)
635-
if bounds is None:
636-
bounds = (None,) * self._n_input_dimensionality
631+
bounds = self._get_bounds_per_dim()
637632

638633
# min/max rescale to [0,1]:
639634
# The function does so over the time axis (each extra dim is
640635
# normalized independently)
641636
def _flat_samples_to_angles(xs):
642637
scaled_samples = jax.tree_util.tree_map(
643-
lambda x, b: 2
644-
* jnp.pi
645-
* self._shift_angles(min_max_rescale_samples(x, b)[0].reshape(-1)),
638+
lambda x, b: (
639+
2
640+
* jnp.pi
641+
* self._shift_angles(min_max_rescale_samples(x, b)[0].reshape(-1))
642+
),
646643
xs,
647644
bounds,
648645
)
649646
return jnp.stack(scaled_samples, axis=-1)
650647

651-
sample_pts = _flat_samples_to_angles(sample_pts)
648+
sample_pts = _flat_samples_to_angles(list(sample_pts))
652649
angles = sample_pts @ self._freq_combinations
653650
out = jnp.concatenate(
654651
[jnp.cos(angles), jnp.sin(angles[..., self._has_zero_phase :])], axis=1

0 commit comments

Comments
 (0)