Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test-package-no-mosek.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ['3.8', '3.9', '3.10', '3.11']
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{matrix.python-version}}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ['3.12']
python-version: ['3.13']
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{matrix.python-version}}
Expand Down
37 changes: 25 additions & 12 deletions pykoop/_sklearn_metaestimators/metaestimators.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class to do so. My compromise was to copy and adjust that code for my own uses,
"""

import abc
from contextlib import suppress
from typing import Any, Dict, List

import numpy as np
Expand All @@ -28,10 +29,18 @@ def _get_params(self, attr: str, deep: bool = True) -> Dict[str, Any]:
out = super().get_params(deep=deep)
if not deep:
return out

estimators = getattr(self, attr)
if not hasattr(estimators, '__iter__'):
try:
out.update(estimators)
except (TypeError, ValueError):
# Ignore TypeError for cases where estimators is not a list of
# (name, estimator) and ignore ValueError when the list is not
# formatted correctly. This is to prevent errors when calling
# `set_params`. `BaseEstimator.set_params` calls `get_params` which
# can error for invalid values for `estimators`.
return out
out.update(estimators)

for name, estimator in estimators:
if hasattr(estimator, "get_params"):
for key, value in estimator.get_params(deep=True).items():
Expand All @@ -43,14 +52,18 @@ def _set_params(self, attr: str, **params) -> '_BaseComposition':
# 1. All steps
if attr in params:
setattr(self, attr, params.pop(attr))
# 2. Step replacement
# 2. Replace items with estimators in params
items = getattr(self, attr)
names = []
if hasattr(items, '__iter__'):
names, _ = zip(*items)
for name in list(params.keys()):
if "__" not in name and name in names:
self._replace_estimator(attr, name, params.pop(name))
if isinstance(items, list) and items:
# Get item names used to identify valid names in params
# `zip` raises a TypeError when `items` does not contains
# elements of length 2
with suppress(TypeError):
item_names, _ = zip(*items)
for name in list(params.keys()):
if "__" not in name and name in item_names:
self._replace_estimator(attr, name, params.pop(name))

# 3. Step parameters and other initialisation arguments
super().set_params(**params)
return self
Expand All @@ -68,11 +81,11 @@ def _validate_names(self, names: List[str]) -> None:
if len(set(names)) != len(names):
raise ValueError("Names provided are not unique: {0!r}".format(
list(names)))
conflict_names = set(names).intersection(self.get_params(deep=False))
if conflict_names:
invalid_names = set(names).intersection(self.get_params(deep=False))
if invalid_names:
raise ValueError(
"Estimator names conflict with constructor arguments: {0!r}".
format(sorted(conflict_names)))
format(sorted(invalid_names)))
invalid_names = [name for name in names if "__" in name]
if invalid_names:
raise ValueError(
Expand Down
14 changes: 7 additions & 7 deletions pykoop/centers.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ class GridCenters(Centers):
>>> grid.fit(X_msd[:, 1:]) # Remove episode feature
GridCenters(n_points_per_feature=4)
>>> grid.centers_
array([...])
array(...)
"""

def __init__(
Expand Down Expand Up @@ -155,7 +155,7 @@ class UniformRandomCenters(Centers):
>>> rand.fit(X_msd[:, 1:]) # Remove episode feature
UniformRandomCenters(n_centers=10)
>>> rand.centers_
array([...])
array(...)
"""

def __init__(
Expand Down Expand Up @@ -235,7 +235,7 @@ class GaussianRandomCenters(Centers):
>>> rand.fit(X_msd[:, 1:]) # Remove episode feature
GaussianRandomCenters(n_centers=10)
>>> rand.centers_
array([...])
array(...)
"""

def __init__(
Expand Down Expand Up @@ -306,15 +306,15 @@ class QmcCenters(Centers):
>>> qmc.fit(X_msd[:, 1:]) # Remove episode feature
QmcCenters(n_centers=10)
>>> qmc.centers_
array([...])
array(...)

Generate centers using a Sobol sequence

>>> qmc = pykoop.QmcCenters(n_centers=8, qmc=scipy.stats.qmc.Sobol)
>>> qmc.fit(X_msd[:, 1:]) # Remove episode feature
QmcCenters(n_centers=8, qmc=<class 'scipy.stats._qmc.Sobol'>)
>>> qmc.centers_
array([...])
array(...)
"""

def __init__(
Expand Down Expand Up @@ -430,7 +430,7 @@ class ClusterCenters(Centers):
>>> kmeans.fit(X_msd[:, 1:]) # Remove episode feature
ClusterCenters(estimator=KMeans(n_clusters=3))
>>> kmeans.centers_
array([...])
array(...)
"""

def __init__(
Expand Down Expand Up @@ -507,7 +507,7 @@ class GaussianMixtureRandomCenters(Centers):
>>> gmm.fit(X_msd[:, 1:]) # Remove episode feature
GaussianMixtureRandomCenters(estimator=GaussianMixture(n_components=3))
>>> gmm.centers_
array([...])
array(...)
"""

def __init__(
Expand Down
12 changes: 10 additions & 2 deletions pykoop/kernel_approximation.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ class RandomFourierKernelApprox(KernelApproximation):
>>> ka.fit(X_msd[:, 1:]) # Remove episode feature
RandomFourierKernelApprox(n_components=10, random_state=1234)
>>> ka.transform(X_msd[:, 1:])
array([...])
array(...)
"""

_ft_lookup = {
Expand Down Expand Up @@ -267,6 +267,10 @@ def transform(self, X: np.ndarray) -> np.ndarray:
"""
sklearn.utils.validation.check_is_fitted(self)
X = sklearn.utils.validation.check_array(X)
if X.shape[1] != self.n_features_in_:
raise ValueError(
f"X has {X.shape[1]} features, but {self.__class__.__name__} "
f"is expecting {self.n_features_in_} features as input.")
X_scaled = np.sqrt(2 * self.shape) * X
products = X_scaled @ self.random_weights_ # (n_samples, n_components)
if self.method == 'weight_only':
Expand Down Expand Up @@ -314,7 +318,7 @@ class RandomBinningKernelApprox(KernelApproximation):
>>> ka.fit(X_msd[:, 1:]) # Remove episode feature
RandomBinningKernelApprox(n_components=10, random_state=1234)
>>> ka.transform(X_msd[:, 1:])
array([...])
array(...)
"""

_ddot_lookup = {
Expand Down Expand Up @@ -451,6 +455,10 @@ def transform(self, X: np.ndarray) -> np.ndarray:
"""
sklearn.utils.validation.check_is_fitted(self)
X = sklearn.utils.validation.check_array(X)
if X.shape[1] != self.n_features_in_:
raise ValueError(
f"X has {X.shape[1]} features, but {self.__class__.__name__} "
f"is expecting {self.n_features_in_} features as input.")
X_scaled = np.sqrt(2 * self.shape) * X
X_hashed = self._hash_samples(X_scaled)
Xt = self.encoder_.transform(X_hashed) / np.sqrt(self.n_components)
Expand Down
89 changes: 51 additions & 38 deletions pykoop/koopman_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -721,7 +721,7 @@ def fit(
}
# Validate data
X = sklearn.utils.validation.check_array(X, **self._check_array_params)
# Set numbre of input features (including episode feature)
# Set number of input features (including episode feature)
self.n_features_in_ = X.shape[1]
# Extract episode feature
if self.episode_feature_:
Expand Down Expand Up @@ -751,12 +751,11 @@ def transform(self, X: np.ndarray) -> np.ndarray:
X,
**self._check_array_params,
)
# Check input shape
# Check number of features
if X.shape[1] != self.n_features_in_:
raise ValueError(f'{self.__class__.__name__} `fit()` called '
f'with {self.n_features_in_} features, but '
f'`transform()` called with {X.shape[1]} '
'features.')
raise ValueError(
f"X has {X.shape[1]} features, but {self.__class__.__name__} "
f"is expecting {self.n_features_in_} features as input.")
return self._apply_transform_or_inverse(X, 'transform')

def inverse_transform(self, X: np.ndarray) -> np.ndarray:
Expand Down Expand Up @@ -996,12 +995,11 @@ def transform(self, X: np.ndarray) -> np.ndarray:
X,
**self._check_array_params,
)
# Check input shape
# Check number of features
if X.shape[1] != self.n_features_in_:
raise ValueError(f'{self.__class__.__name__} `fit()` called '
f'with {self.n_features_in_} features, but '
f'`transform()` called with {X.shape[1]} '
'features.')
raise ValueError(
f"X has {X.shape[1]} features, but {self.__class__.__name__} "
f"is expecting {self.n_features_in_} features as input.")
return self._apply_transform_or_inverse(X, 'transform')

def inverse_transform(self, X: np.ndarray) -> np.ndarray:
Expand All @@ -1014,12 +1012,11 @@ def inverse_transform(self, X: np.ndarray) -> np.ndarray:
X,
**self._check_array_params,
)
# Check input shape
# Check number of features
if X.shape[1] != self.n_features_out_:
raise ValueError(f'{self.__class__.__name__} `fit()` output '
f'{self.n_features_out_} features, but '
'`inverse_transform()` called with '
f'{X.shape[1]} features.')
raise ValueError(
f"X has {X.shape[1]} features, but {self.__class__.__name__} "
f"is expecting {self.n_features_out_} features as input.")
return self._apply_transform_or_inverse(X, 'inverse_transform')

def _apply_transform_or_inverse(self, X: np.ndarray,
Expand Down Expand Up @@ -1270,6 +1267,11 @@ def predict(self, X: np.ndarray) -> np.ndarray:
self._validate_feature_names(X)
# Validate array
X = sklearn.utils.validation.check_array(X, **self._check_array_params)
# Check number of features
if X.shape[1] != self.n_features_in_:
raise ValueError(
f"X has {X.shape[1]} features, but {self.__class__.__name__} "
f"is expecting {self.n_features_in_} features as input.")
# Split episodes
episodes = split_episodes(X, episode_feature=self.episode_feature_)
# Predict for each episode
Expand Down Expand Up @@ -1623,8 +1625,13 @@ def _validate_feature_names(self, X: np.ndarray) -> None:
if not np.all(_extract_feature_names(X) == self.feature_names_in_):
raise ValueError('Input features do not match fit features.')

# Extra estimator tags
# https://scikit-learn.org/stable/developers/develop.html#estimator-tags
def __sklearn_tags__(self):
tags = super().__sklearn_tags__()
tags.target_tags.required = False
tags.target_tags.single_output = False
tags.target_tags.multi_output = True
return tags

def _more_tags(self):
return {
'multioutput': True,
Expand Down Expand Up @@ -1812,12 +1819,11 @@ def transform(self, X: np.ndarray) -> np.ndarray:
X,
**self._check_array_params,
)
# Check input shape
# Check number of features
if X.shape[1] != self.n_features_in_:
raise ValueError(f'{self.__class__.__name__} `fit()` called '
f'with {self.n_features_in_} features, but '
f'`transform()` called with {X.shape[1]} '
'features.')
raise ValueError(
f"X has {X.shape[1]} features, but {self.__class__.__name__} "
f"is expecting {self.n_features_in_} features as input.")
# Split episodes
episodes = split_episodes(X, episode_feature=self.episode_feature_)
episodes_state = []
Expand Down Expand Up @@ -1873,12 +1879,11 @@ def inverse_transform(self, X: np.ndarray) -> np.ndarray:
sklearn.utils.validation.check_is_fitted(self)
X = sklearn.utils.validation.check_array(
X, **self._check_array_params)
# Check input shape
# Check number of features
if X.shape[1] != self.n_features_out_:
raise ValueError(f'{self.__class__.__name__} `fit()` output '
f'{self.n_features_out_} features, but '
'`inverse_transform()` called with '
f'{X.shape[1]} features.')
raise ValueError(
f"X has {X.shape[1]} features, but {self.__class__.__name__} "
f"is expecting {self.n_features_out_} features as input.")
# Split episodes
episodes = split_episodes(X, episode_feature=self.episode_feature_)
episodes_state = []
Expand Down Expand Up @@ -2278,12 +2283,11 @@ def transform(self, X: np.ndarray) -> np.ndarray:
X,
**self._check_array_params,
)
# Check input shape
# Check number of features
if X.shape[1] != self.n_features_in_:
raise ValueError(f'{self.__class__.__name__} `fit()` called '
f'with {self.n_features_in_} features, but '
f'`transform()` called with {X.shape[1]} '
'features.')
raise ValueError(
f"X has {X.shape[1]} features, but {self.__class__.__name__} "
f"is expecting {self.n_features_in_} features as input.")
# Apply lifting functions
X_out = X
for _, lf in self.lifting_functions_:
Expand All @@ -2309,12 +2313,11 @@ def inverse_transform(self, X: np.ndarray) -> np.ndarray:
X,
**self._check_array_params,
)
# Check input shape
# Check number of features
if X.shape[1] != self.n_features_out_:
raise ValueError(f'{self.__class__.__name__} `fit()` output '
f'{self.n_features_out_} features, but '
'`inverse_transform()` called with '
f'{X.shape[1]} features.')
raise ValueError(
f"X has {X.shape[1]} features, but {self.__class__.__name__} "
f"is expecting {self.n_features_out_} features as input.")
# Apply inverse lifting functions in reverse order
X_out = X
for _, lf in self.lifting_functions_[::-1]:
Expand Down Expand Up @@ -2360,6 +2363,11 @@ def predict(self, X: np.ndarray) -> np.ndarray:
X,
**self._check_array_params,
)
# Check number of features
if X.shape[1] != self.n_features_in_:
raise ValueError(
f"X has {X.shape[1]} features, but {self.__class__.__name__} "
f"is expecting {self.n_features_in_} features as input.")
# Lift data matrix
X_trans = self.transform(X)
# Predict in lifted space
Expand Down Expand Up @@ -2405,6 +2413,11 @@ def score(self, X: np.ndarray, y: Optional[np.ndarray] = None) -> float:
self._validate_feature_names(X)
# Validate input array
X = sklearn.utils.validation.check_array(X, **self._check_array_params)
# Check number of features
if X.shape[1] != self.n_features_in_:
raise ValueError(
f"X has {X.shape[1]} features, but {self.__class__.__name__} "
f"is expecting {self.n_features_in_} features as input.")
scorer = KoopmanPipeline.make_scorer()
score = scorer(self, X, None)
return score
Expand Down
4 changes: 2 additions & 2 deletions pykoop/lifting_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ class SkLearnLiftingFn(koopman_pipeline.EpisodeIndependentLiftingFn):
['ep', 'StandardScaler(x0)', 'StandardScaler(x1)', 'StandardScaler(u0)']
>>> X_msd_pp = std_scaler.transform(X_msd)
>>> np.mean(X_msd_pp[:, 1:], axis=0)
array([...])
array(...)
>>> np.std(X_msd_pp[:, 1:], axis=0)
array([...])
array(...)
"""

def __init__(
Expand Down
Loading
Loading