From 598813cc53c2a79b618abe95e6a14d1b1f914e69 Mon Sep 17 00:00:00 2001
From: shuo-zhou~
Date: Mon, 3 Aug 2026 23:17:53 +0100
Subject: [PATCH 01/11] init mpca pipeline
---
kalelinear/pipeline/__init__.py | 0
kalelinear/pipeline/mpca_trainer.py | 199 ++++++++++++++++++++++++++++
kalelinear/transformer/_mpca.py | 26 ++--
3 files changed, 217 insertions(+), 8 deletions(-)
create mode 100644 kalelinear/pipeline/__init__.py
create mode 100644 kalelinear/pipeline/mpca_trainer.py
diff --git a/kalelinear/pipeline/__init__.py b/kalelinear/pipeline/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/kalelinear/pipeline/mpca_trainer.py b/kalelinear/pipeline/mpca_trainer.py
new file mode 100644
index 0000000..dc6869d
--- /dev/null
+++ b/kalelinear/pipeline/mpca_trainer.py
@@ -0,0 +1,199 @@
+# =============================================================================
+# Author: Shuo Zhou, shuo.zhou@sheffield.ac.uk
+# Haiping Lu, h.lu@sheffield.ac.uk or hplu@ieee.org
+# =============================================================================
+
+"""Implementation of MPCA->Feature Selection->Linear SVM/LogisticRegression Pipeline
+
+References:
+ [1] Swift, A. J., Lu, H., Uthoff, J., Garg, P., Cogliano, M., Taylor, J., ... & Kiely, D. G. (2020). A machine
+ learning cardiac magnetic resonance approach to extract disease features and automate pulmonary arterial
+ hypertension diagnosis. European Heart Journal-Cardiovascular Imaging.
+ [2] Song, X., Meng, L., Shi, Q., & Lu, H. (2015, October). Learning tensor-based features for whole-brain fMRI
+ classification. In International Conference on Medical Image Computing and Computer-Assisted Intervention
+ (pp. 613-620). Springer, Cham.
+ [3] Lu, H., Plataniotis, K. N., & Venetsanopoulos, A. N. (2008). MPCA: Multilinear principal component analysis of
+ tensor objects. IEEE Transactions on Neural Networks, 19(1), 18-39.
+"""
+
+import logging
+
+import numpy as np
+from sklearn.base import BaseEstimator, ClassifierMixin
+from sklearn.feature_selection import f_classif
+from sklearn.linear_model import LogisticRegression
+from sklearn.model_selection import GridSearchCV
+from sklearn.svm import LinearSVC, SVC
+from sklearn.utils.validation import check_is_fitted
+
+from kalelinear.transformer import MPCA
+
+param_c_grids = list(np.logspace(-4, 2, 7))
+classifiers = {
+ "svc": [SVC, {"kernel": ["linear"], "C": param_c_grids, "max_iter": [50000]}],
+ "linear_svc": [LinearSVC, {"C": param_c_grids}],
+ "lr": [LogisticRegression, {"C": param_c_grids}],
+}
+
+# k-fold cross-validation used for grid search, i.e. searching for optimal value of C
+default_search_params = {"cv": 5}
+default_mpca_params = {"var_ratio": 0.97, "vectorize": True}
+
+
+class MPCATrainer(BaseEstimator, ClassifierMixin):
+ """Trainer of pipeline: MPCA->Feature selection->Classifier
+
+ Args:
+ classifier (str, optional): Available classifier options: {"svc", "linear_svc", "lr"}, where "svc" trains a
+ support vector classifier, supports both linear and non-linear kernels, optimizes with library "libsvm";
+ "linear_svc" trains a support vector classifier with linear kernel only, and optimizes with library
+ "liblinear", which suppose to be faster and better in handling large number of samples; and "lr" trains
+ a classifier with logistic regression. Defaults to "svc".
+ classifier_params (dict, optional): Parameters of classifier. Defaults to 'auto'.
+ classifier_param_grid (dict, optional): Grids for searching the optimal hyper-parameters. Works only when
+ classifier_params == "auto". Defaults to None by searching from the following hyper-parameter values:
+ 1. svc, {"kernel": ["linear"], "C": [0.0001, 0.001, 0.01, 0.1, 1, 10, 100], "max_iter": [50000]},
+ 2. linear_svc, {"C": [0.0001, 0.001, 0.01, 0.1, 1, 10, 100]},
+ 3. lr, {"C": [0.0001, 0.001, 0.01, 0.1, 1, 10, 100]}
+ mpca_params (dict, optional): Parameters of MPCA, e.g., {"var_ratio": 0.8}. Defaults to None, i.e., using the
+ default parameters (https://pykale.readthedocs.io/en/latest/kale.embed.html#module-kale.embed.mpca).
+ n_features (int, optional): Number of features for feature selection. Defaults to None, i.e., all features
+ after dimension reduction will be used.
+ search_params (dict, optional): Parameters of grid search, for more detail please see
+ https://scikit-learn.org/stable/modules/grid_search.html#grid-search . Defaults to None, i.e., using the
+ default params: {"cv": 5}.
+ """
+
+ def __init__(
+ self,
+ classifier="svc",
+ classifier_params="auto",
+ classifier_param_grid=None,
+ mpca_params=None,
+ n_features=None,
+ search_params=None,
+ ):
+ if classifier not in ["svc", "linear_svc", "lr"]:
+ error_msg = "Valid classifier should be 'svc', 'linear_svc', or 'lr', but given %s" % classifier
+ logging.error(error_msg)
+ raise ValueError(error_msg)
+
+ self.classifier = classifier
+ # init mpca object
+ if mpca_params is None:
+ self.mpca_params = default_mpca_params
+ else:
+ self.mpca_params = mpca_params
+ self.mpca = MPCA(**self.mpca_params)
+ # init feature selection parameters
+ self.n_features = n_features
+ self.feature_order = None
+ # init classifier object
+ if search_params is None:
+ self.search_params = default_search_params
+ else:
+ self.search_params = search_params
+ self.classifier_param_grid = classifier_param_grid
+
+ self.auto_classifier_param = False
+ if classifier_params == "auto":
+ self.auto_classifier_param = True
+ if self.classifier_param_grid is None:
+ self.classifier_param_grid = classifiers[classifier][1]
+ self.grid_search = GridSearchCV(
+ classifiers[classifier][0](), param_grid=self.classifier_param_grid, **self.search_params
+ )
+ self.clf = None
+ elif isinstance(classifier_params, dict):
+ self.clf = classifiers[classifier][0](**classifier_params)
+ else:
+ error_msg = "Invalid classifier parameter type"
+ logging.error(error_msg)
+ raise ValueError(error_msg)
+
+ self.classifier_params = classifier_params
+
+ def fit(self, x, y):
+ """Fit a pipeline with the given data x and labels y
+
+ Args:
+ x (array-like tensor): input data, shape (n_samples, I_1, I_2, ..., I_N)
+ y (array-like): data labels, shape (n_samples, )
+
+ Returns:
+ self
+ """
+ # fit mpca
+ self.mpca.fit(x)
+ self.mpca.set_params(**{"vectorize": True})
+ x_transformed = self.mpca.transform(x)
+
+ # feature selection
+ if self.n_features is None:
+ self.n_features = x_transformed.shape[1]
+ self.feature_order = self.mpca.idx_order
+ else:
+ f_score, p_val = f_classif(x_transformed, y)
+ self.feature_order = (-1 * f_score).argsort()
+ x_transformed = x_transformed[:, self.feature_order][:, : self.n_features]
+
+ # fit classifier
+ if self.auto_classifier_param:
+ self.grid_search.param_grid["C"].append(1 / x.shape[0])
+ self.grid_search.fit(x_transformed, y)
+ self.clf = self.grid_search.best_estimator_
+ if self.classifier == "svc":
+ self.clf.set_params(**{"probability": True})
+
+ self.clf.fit(x_transformed, y)
+
+ def predict(self, x):
+ """Predict the labels for the given data x
+
+ Args:
+ x (array-like tensor): input data, shape (n_samples, I_1, I_2, ..., I_N)
+
+ Returns:
+ array-like: Predicted labels, shape (n_samples, )
+ """
+ return self.clf.predict(self._extract_feature(x))
+
+ def decision_function(self, x):
+ """Decision scores of each class for the given data x
+
+ Args:
+ x (array-like tensor): input data, shape (n_samples, I_1, I_2, ..., I_N)
+
+ Returns:
+ array-like: decision scores, shape (n_samples,) for binary case, else (n_samples, n_class)
+ """
+ return self.clf.decision_function(self._extract_feature(x))
+
+ def predict_proba(self, x):
+ """Probability of each class for the given data x. Not supported by "linear_svc".
+
+ Args:
+ x (array-like tensor): input data, shape (n_samples, I_1, I_2, ..., I_N)
+
+ Returns:
+ array-like: probabilities, shape (n_samples, n_class)
+ """
+ if self.classifier == "linear_svc":
+ error_msg = "Linear SVC does not support computing probability."
+ logging.error(error_msg)
+ raise ValueError(error_msg)
+ return self.clf.predict_proba(self._extract_feature(x))
+
+ def _extract_feature(self, x):
+ """Extracting features for the given data x with MPCA->Feature selection
+
+ Args:
+ x (array-like tensor): input data, shape (n_samples, I_1, I_2, ..., I_N)
+
+ Returns:
+ array-like: n_new, shape (n_samples, n_features)
+ """
+ check_is_fitted(self.clf)
+ x_transformed = self.mpca.transform(x)
+
+ return x_transformed[:, self.feature_order][:, : self.n_features]
diff --git a/kalelinear/transformer/_mpca.py b/kalelinear/transformer/_mpca.py
index e75befe..265931e 100644
--- a/kalelinear/transformer/_mpca.py
+++ b/kalelinear/transformer/_mpca.py
@@ -176,6 +176,7 @@ def _fit(self, X):
"""
shape_ = X.shape # shape of input data
+ n_samples = shape_[0]
n_dims = X.ndim
self.shape_in = shape_[1:]
@@ -184,12 +185,21 @@ def _fit(self, X):
# init
shape_out = ()
- proj_mats = []
+ proj_matrices = []
+ covariance_matrices = dict()
+
+ for i in range(1, n_dims):
+ for j in range(n_samples):
+ sample_data_unfold = unfold(X[j], mode=(i - 1))
+ covariance_ij = sample_data_unfold @ sample_data_unfold.T
+ if i not in covariance_matrices.keys():
+ covariance_matrices[i] = covariance_ij
+ else:
+ covariance_matrices[i] = covariance_matrices[i] + covariance_ij
# get the output tensor shape based on the cumulative distribution of eigen values for each mode
for i in range(1, n_dims):
- mode_data_mat = unfold(X, mode=i)
- singular_vec_left, singular_val, singular_vec_right = la.svd(mode_data_mat, full_matrices=False)
+ singular_vec_left, singular_val, singular_vec_right = la.svd(covariance_matrices[i])
eig_values = np.square(singular_val)
idx_sorted = (-1 * eig_values).argsort()
cum = eig_values[idx_sorted]
@@ -199,13 +209,13 @@ def _fit(self, X):
if np.sum(cum[:j]) / tot_var > self.var_ratio:
shape_out += (j,)
break
- proj_mats.append(singular_vec_left[:, idx_sorted][:, : shape_out[i - 1]].T)
+ proj_matrices.append(singular_vec_left[:, idx_sorted][:, : shape_out[i - 1]].T)
for i_iter in range(self.max_iter):
for i in range(1, n_dims): # ith mode
x_projected = multi_mode_dot(
X,
- [proj_mats[m] for m in range(n_dims - 1) if m != i - 1],
+ [proj_matrices[m] for m in range(n_dims - 1) if m != i - 1],
modes=[m for m in range(1, n_dims) if m != i],
)
mode_data_mat = unfold(x_projected, i)
@@ -213,15 +223,15 @@ def _fit(self, X):
singular_vec_left, singular_val, singular_vec_right = la.svd(mode_data_mat, full_matrices=False)
eig_values = np.square(singular_val)
idx_sorted = (-1 * eig_values).argsort()
- proj_mats[i - 1] = (singular_vec_left[:, idx_sorted][:, : shape_out[i - 1]]).T
+ proj_matrices[i - 1] = (singular_vec_left[:, idx_sorted][:, : shape_out[i - 1]]).T
- x_projected = multi_mode_dot(X, proj_mats, modes=[m for m in range(1, n_dims)])
+ x_projected = multi_mode_dot(X, proj_matrices, modes=[m for m in range(1, n_dims)])
x_proj_unfold = unfold(x_projected, mode=0) # unfold the tensor projection to shape (n_samples, n_features)
# x_proj_cov = np.diag(np.dot(x_proj_unfold.T, x_proj_unfold)) # covariance of unfolded features
x_proj_cov = np.sum(np.multiply(x_proj_unfold.T, x_proj_unfold.T), axis=1) # memory saving computing covariance
idx_order = (-1 * x_proj_cov).argsort()
- self.proj_mats = proj_mats
+ self.proj_mats = proj_matrices
self.idx_order = idx_order
self.shape_out = shape_out
self.n_dims = n_dims
From 530e95823c9cbf6faf41cddac7e460a3c69aaaad Mon Sep 17 00:00:00 2001
From: shuo-zhou~
Date: Tue, 4 Aug 2026 19:56:47 +0100
Subject: [PATCH 02/11] update mpca mem efficiency
---
kalelinear/transformer/_mpca.py | 154 +++++++++++++++++++++-----------
1 file changed, 101 insertions(+), 53 deletions(-)
diff --git a/kalelinear/transformer/_mpca.py b/kalelinear/transformer/_mpca.py
index 265931e..16a30f6 100644
--- a/kalelinear/transformer/_mpca.py
+++ b/kalelinear/transformer/_mpca.py
@@ -12,11 +12,37 @@
import warnings
import numpy as np
-import scipy.linalg as la
+from numpy.linalg import eigvalsh
+from scipy.linalg import eigh
from sklearn.base import BaseEstimator, TransformerMixin
from tensorly.base import fold, unfold
from tensorly.tenalg import multi_mode_dot
+_CHUNK_ELEMS = 4000000 # target number of float64 elements per processed chunk (~32 MB)
+
+
+def _chunk_size(n_samples, elems_per_sample, chunk_elems=_CHUNK_ELEMS):
+ """Return the largest chunk keeping per-chunk element count under ``chunk_elems``.
+
+ Parameters
+ ----------
+ n_samples : int
+ Number of samples in the data.
+ elems_per_sample : int
+ Number of array elements contributed by a single sample (e.g. the
+ number of features of the input or unfolded tensor).
+ chunk_elems : int, default=4000000
+ Memory budget in number of elements for one processed chunk.
+
+ Returns
+ -------
+ chunk_size : int
+ Number of samples to process at a time.
+ """
+ if elems_per_sample <= 0:
+ return n_samples
+ return min(n_samples, max(1, chunk_elems // elems_per_sample))
+
def _check_n_dim(X, n_dims):
"""Validate the number of dimensions.
@@ -98,9 +124,9 @@ class MPCA(BaseEstimator, TransformerMixin):
Feature ranking indices by descending projected variance.
mean_ : ndarray
Per-feature empirical mean of the training data.
- shape_in : tuple
+ sample_shape : tuple
Input per-sample tensor shape.
- shape_out : tuple
+ modewise_n_components : tuple
Output per-sample tensor shape after projection.
References
@@ -179,61 +205,83 @@ def _fit(self, X):
n_samples = shape_[0]
n_dims = X.ndim
- self.shape_in = shape_[1:]
- self.mean_ = np.mean(X, axis=0)
- X = X - self.mean_
+ self.sample_shape = shape_[1:]
- # init
- shape_out = ()
- proj_matrices = []
- covariance_matrices = dict()
+ # Samples are processed in chunks so that a centered copy of the full
+ # data never has to be materialized and disk-backed inputs (e.g. a
+ # memory-mapped array or a loader over file paths) are read one chunk
+ # at a time.
+ n_features_in = int(np.prod(shape_[1:]))
+ chunk_size = _chunk_size(n_samples, n_features_in)
+ mean_acc = np.zeros(shape_[1:], dtype=np.float64)
+ for start in range(0, n_samples, chunk_size):
+ mean_acc += X[start : start + chunk_size].sum(axis=0, dtype=np.float64)
+ self.mean_ = mean_acc / n_samples
+
+ # init: accumulate per-mode covariance over chunked unfoldings
+ covariance_matrices = {}
for i in range(1, n_dims):
- for j in range(n_samples):
- sample_data_unfold = unfold(X[j], mode=(i - 1))
- covariance_ij = sample_data_unfold @ sample_data_unfold.T
- if i not in covariance_matrices.keys():
- covariance_matrices[i] = covariance_ij
- else:
- covariance_matrices[i] = covariance_matrices[i] + covariance_ij
+ mode_cov = np.zeros((shape_[i], shape_[i]))
+ for start in range(0, n_samples, chunk_size):
+ batch = X[start : start + chunk_size] - self.mean_
+ batch_unfold = unfold(batch, mode=i)
+ mode_cov += batch_unfold @ batch_unfold.T
+ covariance_matrices[i] = mode_cov
# get the output tensor shape based on the cumulative distribution of eigen values for each mode
- for i in range(1, n_dims):
- singular_vec_left, singular_val, singular_vec_right = la.svd(covariance_matrices[i])
- eig_values = np.square(singular_val)
- idx_sorted = (-1 * eig_values).argsort()
- cum = eig_values[idx_sorted]
+ modewise_n_components = ()
+ proj_matrices = []
+ for mode_i in range(1, n_dims):
+ eigenvalues = eigvalsh(covariance_matrices[mode_i])
+ idx_sorted = np.argsort(eigenvalues)[::-1]
+ cum = eigenvalues[idx_sorted]
tot_var = np.sum(cum)
- for j in range(1, cum.shape[0] + 1):
- if np.sum(cum[:j]) / tot_var > self.var_ratio:
- shape_out += (j,)
- break
- proj_matrices.append(singular_vec_left[:, idx_sorted][:, : shape_out[i - 1]].T)
-
- for i_iter in range(self.max_iter):
- for i in range(1, n_dims): # ith mode
- x_projected = multi_mode_dot(
- X,
- [proj_matrices[m] for m in range(n_dims - 1) if m != i - 1],
- modes=[m for m in range(1, n_dims) if m != i],
+ cum_var = np.cumsum(cum)
+ mode_n_components = min(
+ int(np.searchsorted(cum_var, self.var_ratio * tot_var, side="right")) + 1, shape_[mode_i]
+ )
+ modewise_n_components += (mode_n_components,)
+
+ # Only the j largest eigenvectors are needed; scipy eigh returns them in
+ # ascending eigenvalue order for the requested index range.
+ _, eigenvectors = eigh(
+ covariance_matrices[mode_i], subset_by_index=[shape_[mode_i] - mode_n_components, shape_[mode_i] - 1]
+ )
+ proj_matrices.append(eigenvectors[:, ::-1].T)
+
+ for _iter in range(self.max_iter):
+ for mode_i in range(1, n_dims): # ith mode
+ mode_cov_mat = np.zeros((shape_[mode_i], shape_[mode_i]))
+ proj_other = [proj_matrices[m] for m in range(n_dims - 1) if m != mode_i - 1]
+ modes_other = [m for m in range(1, n_dims) if m != mode_i]
+ for start in range(0, n_samples, chunk_size):
+ batch = X[start : start + chunk_size] - self.mean_
+ batch_proj = multi_mode_dot(batch, proj_other, modes=modes_other)
+ batch_unfold = unfold(batch_proj, mode=mode_i)
+ mode_cov_mat += batch_unfold @ batch_unfold.T
+
+ _, eigenvectors = eigh(
+ mode_cov_mat,
+ subset_by_index=[shape_[mode_i] - modewise_n_components[mode_i - 1], shape_[mode_i] - 1],
)
- mode_data_mat = unfold(x_projected, i)
-
- singular_vec_left, singular_val, singular_vec_right = la.svd(mode_data_mat, full_matrices=False)
- eig_values = np.square(singular_val)
- idx_sorted = (-1 * eig_values).argsort()
- proj_matrices[i - 1] = (singular_vec_left[:, idx_sorted][:, : shape_out[i - 1]]).T
-
- x_projected = multi_mode_dot(X, proj_matrices, modes=[m for m in range(1, n_dims)])
- x_proj_unfold = unfold(x_projected, mode=0) # unfold the tensor projection to shape (n_samples, n_features)
- # x_proj_cov = np.diag(np.dot(x_proj_unfold.T, x_proj_unfold)) # covariance of unfolded features
- x_proj_cov = np.sum(np.multiply(x_proj_unfold.T, x_proj_unfold.T), axis=1) # memory saving computing covariance
- idx_order = (-1 * x_proj_cov).argsort()
+ proj_matrices[mode_i - 1] = eigenvectors[:, ::-1].T
+
+ # variance of the projected features, accumulated per chunk so the full
+ # unfolded projection never has to be materialized
+ x_proj_var = np.zeros(int(np.prod(modewise_n_components)))
+ modes_all = [m for m in range(1, n_dims)]
+ for start in range(0, n_samples, chunk_size):
+ batch = X[start : start + chunk_size] - self.mean_
+ batch_proj = multi_mode_dot(batch, proj_matrices, modes=modes_all)
+ batch_unfold = unfold(batch_proj, mode=0) # unfold the chunked projection to shape (n_chunk, n_features)
+ x_proj_var += np.einsum("ij,ij->j", batch_unfold, batch_unfold)
+ idx_order = np.argsort(-x_proj_var)
self.proj_mats = proj_matrices
self.idx_order = idx_order
- self.shape_out = shape_out
+ self.modewise_n_components = modewise_n_components
self.n_dims = n_dims
return self
@@ -256,7 +304,7 @@ def transform(self, X):
# reshape X to shape (1, I_1, I_2, ..., I_N) if X in shape (I_1, I_2, ..., I_N), i.e. n_samples = 1
if X.ndim == self.n_dims - 1:
X = X.reshape((1,) + X.shape)
- _check_tensor_dim_shape(X, self.n_dims, self.shape_in)
+ _check_tensor_dim_shape(X, self.n_dims, self.sample_shape)
X = X - self.mean_
# projected tensor in lower dimensions
@@ -267,7 +315,7 @@ def transform(self, X):
x_projected = unfold(x_projected, mode=0)
x_projected = x_projected[:, self.idx_order]
if isinstance(n_components, int):
- n_features = int(np.prod(self.shape_out))
+ n_features = int(np.prod(self.modewise_n_components))
if n_components > n_features:
warn_msg = (
"n_components %d exceeds the maximum number, all features will be returned." % n_components
@@ -292,22 +340,22 @@ def inverse_transform(self, X):
x_rec : ndarray of shape (n_samples, I_1, ..., I_N)
Reconstructed tensor data in the original shape.
"""
- # reshape X to tensor in shape (n_samples, self.shape_out) if X has been unfolded
+ # reshape X to tensor in shape (n_samples, self.modewise_n_components) if X has been unfolded
if X.ndim <= 2:
if X.ndim == 1:
# reshape X to a 2D matrix (1, n_components) if X in shape (n_components,)
X = X.reshape((1, -1))
n_samples = X.shape[0]
n_features = X.shape[1]
- if n_features <= np.prod(self.shape_out):
- x_ = np.zeros((n_samples, np.prod(self.shape_out)))
+ if n_features <= np.prod(self.modewise_n_components):
+ x_ = np.zeros((n_samples, np.prod(self.modewise_n_components)))
x_[:, self.idx_order[:n_features]] = X[:]
else:
msg = "Feature dimension exceeds the shape upper limit."
logging.error(msg)
raise ValueError(msg)
- X = fold(x_, mode=0, shape=((n_samples,) + self.shape_out))
+ X = fold(x_, mode=0, shape=((n_samples,) + self.modewise_n_components))
x_rec = multi_mode_dot(X, self.proj_mats, modes=[m for m in range(1, self.n_dims)], transpose=True)
From 87bab8070583875c7dce961b8a643db92fc8e2c2 Mon Sep 17 00:00:00 2001
From: shuo-zhou~
Date: Wed, 5 Aug 2026 19:11:28 +0100
Subject: [PATCH 03/11] update mpca and readme tutorials
---
README.md | 100 ++--------------------
TUTORIALS.md | 101 ++++++++++++++++++++++
kalelinear/pipeline/mpca_trainer.py | 7 +-
kalelinear/transformer/_mpca.py | 126 ++++++++++++++++++++--------
tests/transformer/test_mpca.py | 8 +-
5 files changed, 204 insertions(+), 138 deletions(-)
create mode 100644 TUTORIALS.md
diff --git a/README.md b/README.md
index f32c024..d3a49cc 100644
--- a/README.md
+++ b/README.md
@@ -58,101 +58,13 @@ pip install -e ".[dev]"
## Quick Start
-### Learn a Domain-Invariant Embedding
+Worked examples for the main transformers and estimators are collected in
+[Tutorials](TUTORIALS.md):
-```python
-import numpy as np
-from kalelinear.transformer import TCA
-
-X = np.array(
- [
- [-2.0, -1.8],
- [-1.8, -2.1],
- [1.9, 1.7],
- [2.1, 2.0],
- [-1.4, -1.2],
- [-1.2, -1.1],
- [1.2, 1.1],
- [1.4, 1.3],
- ]
-)
-domain_labels = np.array([0, 0, 0, 0, 1, 1, 1, 1])
-
-transformer = TCA(n_components=2)
-z = transformer.fit_transform(X, covariates=domain_labels, target_covariate=1)
-
-z_source = z[domain_labels == 0]
-z_target = z[domain_labels == 1]
-```
-
-TCA, JDA, and BDA take domain labels through `covariates`. They do not accept
-separate source and target arrays; stack samples into one array and use
-`target_covariate` to identify the target domain.
-
-### Use MIDA with Categorical Covariates
-
-```python
-import numpy as np
-from kalelinear.transformer import MIDA
-
-x = np.random.default_rng(0).normal(size=(8, 4))
-y = np.array([0, 0, 1, 1, 0, 0, 1, 1])
-domains = np.array(["source", "source", "source", "source", "target", "target", "target", "target"])
-
-transformer = MIDA(n_components=2, covariate_encoder="onehot")
-z = transformer.fit_transform(x, y=y, covariates=domains)
-```
-
-### Train a Domain Adaptation Classifier
-
-For ARSVM and ARRLS, pass all source and target samples in `x`, labels for the
-source samples in `y`, and a covariate vector identifying the target domain.
-
-```python
-import numpy as np
-from kalelinear.estimator import ARSVM
-
-x = np.array(
- [
- [-2.2, -1.9],
- [-1.9, -2.1],
- [1.8, 2.1],
- [2.0, 1.9],
- [-1.4, -1.2],
- [-1.1, -1.3],
- [1.3, 1.1],
- [1.5, 1.2],
- ]
-)
-
-source_labels = np.array([0, 0, 1, 1])
-domains = np.array([0, 0, 0, 0, 1, 1, 1, 1])
-x_target = x[domains == 1]
-
-clf = ARSVM()
-clf.fit(x, source_labels, covariates=domains, target_covariate=1)
-y_pred = clf.predict(x_target)
-```
-
-### Train a Manifold-Regularized Classifier
-
-LapSVM and LapRLS can use labeled source samples together with unlabeled target
-samples. The labels array may contain only the labeled source examples.
-
-```python
-import numpy as np
-from kalelinear.estimator import LapSVM
-
-x_source = np.array([[-2.0, -1.8], [-1.8, -2.1], [1.9, 1.7], [2.1, 2.0]])
-ys = np.array([0, 0, 1, 1])
-x_target = np.array([[-1.4, -1.2], [-1.2, -1.1], [1.2, 1.1], [1.4, 1.3]])
-
-x_train = np.vstack((x_source, x_target))
-
-clf = LapSVM(kernel="linear")
-clf.fit(x_train, ys)
-y_pred = clf.predict(x_target)
-```
+- Learn a domain-invariant embedding with TCA
+- Use MIDA with categorical covariates
+- Train a domain adaptation classifier (ARSVM, ARRLS)
+- Train a manifold-regularized classifier (LapSVM, LapRLS)
## Public API
diff --git a/TUTORIALS.md b/TUTORIALS.md
new file mode 100644
index 0000000..2f0a58e
--- /dev/null
+++ b/TUTORIALS.md
@@ -0,0 +1,101 @@
+# Tutorials
+
+Worked examples for the `kalelinear` transformers and estimators. For
+installation instructions and an API overview, see the
+[README](README.md).
+
+## Learn a Domain-Invariant Embedding
+
+```python
+import numpy as np
+from kalelinear.transformer import TCA
+
+X = np.array(
+ [
+ [-2.0, -1.8],
+ [-1.8, -2.1],
+ [1.9, 1.7],
+ [2.1, 2.0],
+ [-1.4, -1.2],
+ [-1.2, -1.1],
+ [1.2, 1.1],
+ [1.4, 1.3],
+ ]
+)
+domain_labels = np.array([0, 0, 0, 0, 1, 1, 1, 1])
+
+transformer = TCA(n_components=2)
+z = transformer.fit_transform(X, covariates=domain_labels, target_covariate=1)
+
+z_source = z[domain_labels == 0]
+z_target = z[domain_labels == 1]
+```
+
+TCA, JDA, and BDA take domain labels through `covariates`. They do not accept
+separate source and target arrays; stack samples into one array and use
+`target_covariate` to identify the target domain.
+
+## Use MIDA with Categorical Covariates
+
+```python
+import numpy as np
+from kalelinear.transformer import MIDA
+
+x = np.random.default_rng(0).normal(size=(8, 4))
+y = np.array([0, 0, 1, 1, 0, 0, 1, 1])
+domains = np.array(["source", "source", "source", "source", "target", "target", "target", "target"])
+
+transformer = MIDA(n_components=2, covariate_encoder="onehot")
+z = transformer.fit_transform(x, y=y, covariates=domains)
+```
+
+## Train a Domain Adaptation Classifier
+
+For ARSVM and ARRLS, pass all source and target samples in `x`, labels for the
+source samples in `y`, and a covariate vector identifying the target domain.
+
+```python
+import numpy as np
+from kalelinear.estimator import ARSVM
+
+x = np.array(
+ [
+ [-2.2, -1.9],
+ [-1.9, -2.1],
+ [1.8, 2.1],
+ [2.0, 1.9],
+ [-1.4, -1.2],
+ [-1.1, -1.3],
+ [1.3, 1.1],
+ [1.5, 1.2],
+ ]
+)
+
+source_labels = np.array([0, 0, 1, 1])
+domains = np.array([0, 0, 0, 0, 1, 1, 1, 1])
+x_target = x[domains == 1]
+
+clf = ARSVM()
+clf.fit(x, source_labels, covariates=domains, target_covariate=1)
+y_pred = clf.predict(x_target)
+```
+
+## Train a Manifold-Regularized Classifier
+
+LapSVM and LapRLS can use labeled source samples together with unlabeled target
+samples. The labels array may contain only the labeled source examples.
+
+```python
+import numpy as np
+from kalelinear.estimator import LapSVM
+
+x_source = np.array([[-2.0, -1.8], [-1.8, -2.1], [1.9, 1.7], [2.1, 2.0]])
+ys = np.array([0, 0, 1, 1])
+x_target = np.array([[-1.4, -1.2], [-1.2, -1.1], [1.2, 1.1], [1.4, 1.3]])
+
+x_train = np.vstack((x_source, x_target))
+
+clf = LapSVM(kernel="linear")
+clf.fit(x_train, ys)
+y_pred = clf.predict(x_target)
+```
diff --git a/kalelinear/pipeline/mpca_trainer.py b/kalelinear/pipeline/mpca_trainer.py
index dc6869d..5816a5c 100644
--- a/kalelinear/pipeline/mpca_trainer.py
+++ b/kalelinear/pipeline/mpca_trainer.py
@@ -37,7 +37,7 @@
# k-fold cross-validation used for grid search, i.e. searching for optimal value of C
default_search_params = {"cv": 5}
-default_mpca_params = {"var_ratio": 0.97, "vectorize": True}
+default_mpca_params = {"explained_variance_ratio": 0.97, "vectorize": True}
class MPCATrainer(BaseEstimator, ClassifierMixin):
@@ -55,8 +55,9 @@ class MPCATrainer(BaseEstimator, ClassifierMixin):
1. svc, {"kernel": ["linear"], "C": [0.0001, 0.001, 0.01, 0.1, 1, 10, 100], "max_iter": [50000]},
2. linear_svc, {"C": [0.0001, 0.001, 0.01, 0.1, 1, 10, 100]},
3. lr, {"C": [0.0001, 0.001, 0.01, 0.1, 1, 10, 100]}
- mpca_params (dict, optional): Parameters of MPCA, e.g., {"var_ratio": 0.8}. Defaults to None, i.e., using the
- default parameters (https://pykale.readthedocs.io/en/latest/kale.embed.html#module-kale.embed.mpca).
+ mpca_params (dict, optional): Parameters of MPCA, e.g., {"explained_variance_ratio": 0.8}. Defaults to None,
+ i.e., using the default parameters
+ (https://pykale.readthedocs.io/en/latest/kale.embed.html#module-kale.embed.mpca).
n_features (int, optional): Number of features for feature selection. Defaults to None, i.e., all features
after dimension reduction will be used.
search_params (dict, optional): Parameters of grid search, for more detail please see
diff --git a/kalelinear/transformer/_mpca.py b/kalelinear/transformer/_mpca.py
index 16a30f6..df92572 100644
--- a/kalelinear/transformer/_mpca.py
+++ b/kalelinear/transformer/_mpca.py
@@ -107,7 +107,7 @@ class MPCA(BaseEstimator, TransformerMixin):
Parameters
----------
- var_ratio : float, default=0.97
+ explained_variance_ratio : float, default=0.97
Target cumulative explained variance ratio per mode.
max_iter : int, default=1
Maximum number of alternating optimization iterations.
@@ -115,6 +115,11 @@ class MPCA(BaseEstimator, TransformerMixin):
If ``True``, output projected tensors as vectors.
n_components : int, optional
Number of output features when ``vectorize=True``.
+ output_shape : tuple of int, optional
+ Number of components to keep per mode. If given, it overrides
+ ``explained_variance_ratio`` and the output dimensions are set exactly.
+ Default is None, i.e. the output shape is derived from
+ ``explained_variance_ratio``.
Attributes
----------
@@ -124,10 +129,14 @@ class MPCA(BaseEstimator, TransformerMixin):
Feature ranking indices by descending projected variance.
mean_ : ndarray
Per-feature empirical mean of the training data.
- sample_shape : tuple
+ input_shape : tuple
Input per-sample tensor shape.
- modewise_n_components : tuple
- Output per-sample tensor shape after projection.
+ output_shape_ : tuple
+ Output per-sample tensor shape after projection. Equals
+ ``output_shape`` when given, otherwise determined by
+ ``explained_variance_ratio``.
+ explained_variance_ratio_ : tuple of float
+ Achieved cumulative explained variance ratio per mode after fitting.
References
----------
@@ -152,13 +161,15 @@ class MPCA(BaseEstimator, TransformerMixin):
>>> x_projected = mpca.transform(x)
>>> x_projected.shape
(40, 50)
- >>> x_rec = mpca.inverse_transform(x_projected)
- >>> x_rec.shape
+ >>> x_reconstructed = mpca.inverse_transform(x_projected)
+ >>> x_reconstructed.shape
(40, 20, 25, 20)
"""
- def __init__(self, var_ratio=0.97, max_iter=1, vectorize=False, n_components=None):
- self.var_ratio = var_ratio
+ def __init__(
+ self, explained_variance_ratio=0.97, max_iter=1, vectorize=False, n_components=None, output_shape=None
+ ):
+ self.explained_variance_ratio = explained_variance_ratio
if max_iter > 0 and isinstance(max_iter, int):
self.max_iter = max_iter
else:
@@ -168,6 +179,16 @@ def __init__(self, var_ratio=0.97, max_iter=1, vectorize=False, n_components=Non
self.proj_mats = []
self.vectorize = vectorize
self.n_components = n_components
+ if output_shape is None:
+ self.output_shape = None
+ elif isinstance(output_shape, (tuple, list)) and all(
+ isinstance(v, (int, np.integer)) and v > 0 for v in output_shape
+ ):
+ self.output_shape = tuple(int(v) for v in output_shape)
+ else:
+ msg = "output_shape must be None or a sequence of positive integers but given %s" % (output_shape,)
+ logging.error(msg)
+ raise ValueError(msg)
def fit(self, X, y=None):
"""Fit MPCA to tensor data.
@@ -205,7 +226,7 @@ def _fit(self, X):
n_samples = shape_[0]
n_dims = X.ndim
- self.sample_shape = shape_[1:]
+ self.input_shape = shape_[1:]
# Samples are processed in chunks so that a centered copy of the full
# data never has to be materialized and disk-backed inputs (e.g. a
@@ -229,26 +250,56 @@ def _fit(self, X):
mode_cov += batch_unfold @ batch_unfold.T
covariance_matrices[i] = mode_cov
- # get the output tensor shape based on the cumulative distribution of eigen values for each mode
- modewise_n_components = ()
+ # get the output tensor shape: either user-specified or derived from the
+ # cumulative distribution of eigen values for each mode
+ if self.output_shape is not None:
+ output_shape = self.output_shape
+ if len(output_shape) != n_dims - 1:
+ error_msg = "output_shape must have length %s (one entry per mode) but has %s" % (
+ n_dims - 1,
+ len(output_shape),
+ )
+ logging.error(error_msg)
+ raise ValueError(error_msg)
+ for mode_i, n_comp in enumerate(output_shape, start=1):
+ if n_comp > shape_[mode_i]:
+ error_msg = "output_shape entry %s must not exceed the input size %s of mode %s but is %s" % (
+ mode_i - 1,
+ shape_[mode_i],
+ mode_i,
+ n_comp,
+ )
+ logging.error(error_msg)
+ raise ValueError(error_msg)
+ else:
+ output_shape = ()
+
proj_matrices = []
+ explained_variance_ratios = []
for mode_i in range(1, n_dims):
- eigenvalues = eigvalsh(covariance_matrices[mode_i])
- idx_sorted = np.argsort(eigenvalues)[::-1]
- cum = eigenvalues[idx_sorted]
- tot_var = np.sum(cum)
-
- cum_var = np.cumsum(cum)
- mode_n_components = min(
- int(np.searchsorted(cum_var, self.var_ratio * tot_var, side="right")) + 1, shape_[mode_i]
- )
- modewise_n_components += (mode_n_components,)
+ if self.output_shape is None:
+ eigenvalues = eigvalsh(covariance_matrices[mode_i])
+ idx_sorted = np.argsort(eigenvalues)[::-1]
+ cum = eigenvalues[idx_sorted]
+ tot_var = np.sum(cum)
+
+ cum_var = np.cumsum(cum)
+ mode_n_components = min(
+ int(np.searchsorted(cum_var, self.explained_variance_ratio * tot_var, side="right")) + 1,
+ shape_[mode_i],
+ )
+ output_shape += (mode_n_components,)
+ else:
+ mode_n_components = output_shape[mode_i - 1]
- # Only the j largest eigenvectors are needed; scipy eigh returns them in
- # ascending eigenvalue order for the requested index range.
- _, eigenvectors = eigh(
+ # Only the top mode_n_components eigenvectors are needed; scipy eigh
+ # returns them in ascending eigenvalue order for the requested range.
+ subset_eigenvalues, eigenvectors = eigh(
covariance_matrices[mode_i], subset_by_index=[shape_[mode_i] - mode_n_components, shape_[mode_i] - 1]
)
+ tot_var = np.trace(covariance_matrices[mode_i])
+ explained_variance_ratio = subset_eigenvalues.sum() / tot_var if tot_var > 0 else 0.0
+ explained_variance_ratios.append(explained_variance_ratio)
proj_matrices.append(eigenvectors[:, ::-1].T)
for _iter in range(self.max_iter):
@@ -264,13 +315,13 @@ def _fit(self, X):
_, eigenvectors = eigh(
mode_cov_mat,
- subset_by_index=[shape_[mode_i] - modewise_n_components[mode_i - 1], shape_[mode_i] - 1],
+ subset_by_index=[shape_[mode_i] - output_shape[mode_i - 1], shape_[mode_i] - 1],
)
proj_matrices[mode_i - 1] = eigenvectors[:, ::-1].T
# variance of the projected features, accumulated per chunk so the full
# unfolded projection never has to be materialized
- x_proj_var = np.zeros(int(np.prod(modewise_n_components)))
+ x_proj_var = np.zeros(int(np.prod(output_shape)))
modes_all = [m for m in range(1, n_dims)]
for start in range(0, n_samples, chunk_size):
batch = X[start : start + chunk_size] - self.mean_
@@ -281,7 +332,8 @@ def _fit(self, X):
self.proj_mats = proj_matrices
self.idx_order = idx_order
- self.modewise_n_components = modewise_n_components
+ self.output_shape_ = output_shape
+ self.explained_variance_ratio_ = tuple(explained_variance_ratios)
self.n_dims = n_dims
return self
@@ -304,7 +356,7 @@ def transform(self, X):
# reshape X to shape (1, I_1, I_2, ..., I_N) if X in shape (I_1, I_2, ..., I_N), i.e. n_samples = 1
if X.ndim == self.n_dims - 1:
X = X.reshape((1,) + X.shape)
- _check_tensor_dim_shape(X, self.n_dims, self.sample_shape)
+ _check_tensor_dim_shape(X, self.n_dims, self.input_shape)
X = X - self.mean_
# projected tensor in lower dimensions
@@ -315,7 +367,7 @@ def transform(self, X):
x_projected = unfold(x_projected, mode=0)
x_projected = x_projected[:, self.idx_order]
if isinstance(n_components, int):
- n_features = int(np.prod(self.modewise_n_components))
+ n_features = int(np.prod(self.output_shape_))
if n_components > n_features:
warn_msg = (
"n_components %d exceeds the maximum number, all features will be returned." % n_components
@@ -337,28 +389,28 @@ def inverse_transform(self, X):
Returns
-------
- x_rec : ndarray of shape (n_samples, I_1, ..., I_N)
+ x_reconstructed : ndarray of shape (n_samples, I_1, ..., I_N)
Reconstructed tensor data in the original shape.
"""
- # reshape X to tensor in shape (n_samples, self.modewise_n_components) if X has been unfolded
+ # reshape X to tensor in shape (n_samples, self.output_shape_) if X has been unfolded
if X.ndim <= 2:
if X.ndim == 1:
# reshape X to a 2D matrix (1, n_components) if X in shape (n_components,)
X = X.reshape((1, -1))
n_samples = X.shape[0]
n_features = X.shape[1]
- if n_features <= np.prod(self.modewise_n_components):
- x_ = np.zeros((n_samples, np.prod(self.modewise_n_components)))
+ if n_features <= np.prod(self.output_shape_):
+ x_ = np.zeros((n_samples, np.prod(self.output_shape_)))
x_[:, self.idx_order[:n_features]] = X[:]
else:
msg = "Feature dimension exceeds the shape upper limit."
logging.error(msg)
raise ValueError(msg)
- X = fold(x_, mode=0, shape=((n_samples,) + self.modewise_n_components))
+ X = fold(x_, mode=0, shape=((n_samples,) + self.output_shape_))
- x_rec = multi_mode_dot(X, self.proj_mats, modes=[m for m in range(1, self.n_dims)], transpose=True)
+ x_reconstructed = multi_mode_dot(X, self.proj_mats, modes=[m for m in range(1, self.n_dims)], transpose=True)
- x_rec = x_rec + self.mean_
+ x_reconstructed = x_reconstructed + self.mean_
- return x_rec
+ return x_reconstructed
diff --git a/tests/transformer/test_mpca.py b/tests/transformer/test_mpca.py
index 4b1ce1e..50c9849 100644
--- a/tests/transformer/test_mpca.py
+++ b/tests/transformer/test_mpca.py
@@ -30,11 +30,11 @@ def baseline_model(download_path):
@pytest.mark.parametrize("n_components", N_COMPS)
-@pytest.mark.parametrize("var_ratio", VAR_RATIOS)
-def test_mpca(var_ratio, n_components, gait):
+@pytest.mark.parametrize("explained_variance_ratio", VAR_RATIOS)
+def test_mpca(explained_variance_ratio, n_components, gait):
# basic mpca test, return tensor
x = gait["fea3D"].transpose((3, 0, 1, 2))
- mpca = MPCA(var_ratio=var_ratio, vectorize=False)
+ mpca = MPCA(explained_variance_ratio=explained_variance_ratio, vectorize=False)
x_proj = mpca.fit(x).transform(x)
testing.assert_equal(x_proj.ndim, x.ndim)
@@ -74,7 +74,7 @@ def test_mpca_against_baseline(gait, baseline_model):
x = gait["fea3D"].transpose((3, 0, 1, 2))
baseline_proj_mats = [baseline_model["tUs"][i][0] for i in range(baseline_model["tUs"].size)]
baseline_mean = baseline_model["TXmean"]
- mpca = MPCA(var_ratio=0.97)
+ mpca = MPCA(explained_variance_ratio=0.97)
x_proj = mpca.fit(x).transform(x)
testing.assert_allclose(baseline_mean, mpca.mean_)
baseline_proj_x = multi_mode_dot(x - baseline_mean, baseline_proj_mats, modes=[1, 2, 3])
From 39608db0b8e5f750c5b6b55dae244ae5542d40bd Mon Sep 17 00:00:00 2001
From: shuo-zhou~
Date: Thu, 6 Aug 2026 19:12:59 +0100
Subject: [PATCH 04/11] fix optimization issue and init github workflow
---
.github/CHANGELOG.md | 5 ++
.github/workflows/changelog.yml | 31 +++++++++
.github/workflows/codeql-analysis.yml | 70 ++++++++++++++++++++
.github/workflows/pre-commit.yml | 28 ++++++++
.github/workflows/project.yml | 39 +++++++++++
.github/workflows/release.yml | 44 +++++++++++++
.github/workflows/test.yml | 95 +++++++++++++++++++++++++++
README.md | 55 ++++++++--------
TUTORIALS.md | 6 +-
kalelinear/estimator/base.py | 9 +++
kalelinear/transformer/_base.py | 11 ++++
11 files changed, 363 insertions(+), 30 deletions(-)
create mode 100644 .github/CHANGELOG.md
create mode 100644 .github/workflows/changelog.yml
create mode 100644 .github/workflows/codeql-analysis.yml
create mode 100644 .github/workflows/pre-commit.yml
create mode 100644 .github/workflows/project.yml
create mode 100644 .github/workflows/release.yml
create mode 100644 .github/workflows/test.yml
diff --git a/.github/CHANGELOG.md b/.github/CHANGELOG.md
new file mode 100644
index 0000000..6a348f9
--- /dev/null
+++ b/.github/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Version 0.1.0a1
+
+#### New Features
+
+* Initial release of `kalelinear`: knowledge-aware linear and kernel methods for multi-source/multi-view learning, including transfer learning, domain adaptation, manifold regularization, and group-aware estimators and transformers.
diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml
new file mode 100644
index 0000000..e006387
--- /dev/null
+++ b/.github/workflows/changelog.yml
@@ -0,0 +1,31 @@
+# This workflow will generate a log of changes automatically upon a new release.
+# See https://github.com/marketplace/actions/changelog-ci
+
+name: changelog
+
+on:
+ pull_request:
+ types: [opened]
+
+jobs:
+ log-changes:
+ name: Log changes
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ repository: ${{ github.event.pull_request.head.repo.full_name }}
+ ref: ${{ github.event.pull_request.head.ref }}
+ # Keep checkout shallow; changelog-ci handles unshallow internally.
+ # Using fetch-depth: 0 causes changelog-ci to fail with:
+ # "fatal: --unshallow on a complete repository does not make sense"
+ fetch-depth: 1
+ token: ${{ secrets.GITHUB_TOKEN }}
+ - name: Run changelog
+ uses: saadmk11/changelog-ci@v1.2.0
+ with:
+ changelog_filename: .github/CHANGELOG.md
+ config_file: .github/changelog-ci-config.json
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 0000000..95e4c21
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,70 @@
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+#
+# ******** NOTE ********
+# We have attempted to detect the languages in your repository. Please check
+# the `language` matrix defined below to confirm you have the correct set of
+# supported CodeQL languages.
+#
+name: codeql-analysis
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ # The branches below must be a subset of the branches above
+ branches: [ main ]
+ schedule:
+ - cron: '15 18 * * 5'
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [ 'python' ]
+ # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
+ # Learn more about CodeQL language support at https://git.io/codeql-language-support
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v3
+ with:
+ languages: ${{ matrix.language }}
+ # If you wish to specify custom queries, you can do so here or in a config file.
+ # By default, queries listed here will override any specified in a config file.
+ # Prefix the list here with "+" to use these queries and those in the config file.
+ # queries: ./path/to/local/query, your-org/your-repo/queries@main
+
+ # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
+ # If this step fails, then you should remove it and run the build manually (see below)
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v3
+
+ # âšī¸ Command-line programs to run using the OS shell.
+ # đ https://git.io/JvXDl
+
+ # âī¸ If the Autobuild fails above, remove it and uncomment the following three lines
+ # and modify them (or add more) to build your code if your project
+ # uses a compiled language
+
+ #- run: |
+ # make bootstrap
+ # make release
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v3
diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml
new file mode 100644
index 0000000..5ba5112
--- /dev/null
+++ b/.github/workflows/pre-commit.yml
@@ -0,0 +1,28 @@
+# This workflow will run lint and many other pre-commit hooks.
+# https://pre-commit.com/
+
+name: pre-commit-check
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ pre-commit-check:
+ name: Pre-commit checks including linting
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+ - name: Install dependencies
+ run: |
+ pip install pre-commit isort
+ pre-commit install
+ - name: Run pre-commit checks including linting
+ run: |
+ pre-commit run --all-files
diff --git a/.github/workflows/project.yml b/.github/workflows/project.yml
new file mode 100644
index 0000000..6553228
--- /dev/null
+++ b/.github/workflows/project.yml
@@ -0,0 +1,39 @@
+# This workflow will automatically add an issue or pull request to the project defined on lines 25 and 38.
+# The project needs to be updated when we move to a new project.
+# https://github.com/marketplace/actions/add-to-github-projects
+# The encrypted secret key "ADD_TO_PROJECT_PAT" has been created in accordance with the guidelines provided in https://docs.github.com/en/actions/security-guides/encrypted-secrets
+# In PRs, the action will only run if the PR is from pykale repository to avoid requesting secrets for forks.
+
+name: assign-project
+
+on:
+ issues:
+ types: [opened, labeled]
+ pull_request:
+ types: [opened, labeled]
+
+jobs:
+ add-issue-to-project:
+ name: Add issue to project
+ if: |
+ github.event_name == 'issues' &&
+ github.event.action == 'opened'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/add-to-project@v1.0.2
+ with:
+ project-url: https://github.com/orgs/pykale/projects/4
+ github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
+
+ add-pull-request-to-project:
+ name: Add pull request to project
+ if: |
+ github.event_name == 'pull_request' &&
+ github.event.action == 'opened' &&
+ github.event.pull_request.head.repo.full_name == github.repository
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/add-to-project@v1.0.2
+ with:
+ project-url: https://github.com/orgs/pykale/projects/4
+ github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..ebcc404
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,44 @@
+# This workflow will release a package on PyPI automatically when it is tagged/released.
+# https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/
+
+name: release
+
+on:
+ release:
+ types: [created]
+
+jobs:
+ build-n-publish:
+ name: Release on PyPI
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python 3.11
+ uses: actions/setup-python@v5
+ with:
+ python-version: 3.11
+ - name: Install pypa/build
+ run: >-
+ python -m
+ pip install
+ build
+ --user
+ - name: Build a binary wheel and a source tarball
+ run: >-
+ python -m
+ build
+ --sdist
+ --wheel
+ --outdir dist/
+ .
+ - name: Publish distribution to Test PyPI
+ if: github.event.release.prerelease
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ password: ${{ secrets.TEST_PYPI_API_TOKEN }}
+ repository-url: https://test.pypi.org/legacy/
+ - name: Publish distribution to PyPI
+ if: ${{ !github.event.release.prerelease }}
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ password: ${{ secrets.PYPI_API_TOKEN }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..1967143
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,95 @@
+# This workflow will install Python dependencies, run tests, and report the coverage with a variety of Python versions and OSs.
+# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
+
+name: test
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+ schedule:
+ # * is a special character in YAML, so you have to quote this string
+ - cron: "0 0 * * *" # every midnight
+
+jobs:
+ test:
+ name: Test (${{ matrix.os }}, python version ${{ matrix.python-version }})
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+# os: [ubuntu-latest, windows-latest]
+ os: [ubuntu-latest]
+ python-version: ["3.10", "3.11", "3.12"] # list of Python versions to test
+# exclude:
+# - os: windows-latest
+# python-version: "3.10"
+ include:
+ - os: ubuntu-latest
+ path: ~/.cache/pip
+# - os: windows-latest
+# path: ~\AppData\Local\pip\Cache
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python using Miniconda
+ uses: conda-incubator/setup-miniconda@v3
+ with:
+ auto-update-conda: true
+ python-version: ${{ matrix.python-version }}
+ miniconda-version: latest
+
+ - name: Cache pip dependencies
+ id: cache_pip
+ uses: actions/cache@v4
+ with:
+ path: ${{ matrix.path }}
+ key: ${{ runner.os }}-python${{ matrix.python-version }}-pip-20250302-${{ hashFiles('**/setup.py') }}
+ restore-keys: |
+ ${{ runner.os }}-python${{ matrix.python-version }}-pip-20250302-
+ # We have used a softer matching strategy for the full hash of setup.py, as recommended by GitHub.
+ # See: https://github.com/davronaliyev/Cache-dependencies-in-GitHub-Actions/blob/main/examples.md#python---pip
+ # This restores the cache first and then downloads any changed packages to avoid updating the cache with
+ # every change to the setup.py file, thus reducing the storage requirements of GitHub Action.
+ # We set a date tag to the cache key to show the updated date of the cache. We can update this date tag to
+ # generate new cache after every major changes in setup.py.
+
+ - name: Install project and dev dependencies
+ run: |
+ pip install --no-build-isolation -e .[dev]
+ shell: bash -l {0}
+
+ - name: Cache downloaded test data
+ id: cache_data
+ uses: actions/cache@v4
+ with:
+ path: tests/test_data
+ key: ${{ runner.os }}-python${{ matrix.python-version }}-data-${{ hashFiles('tests/download_test_data.py') }}
+ restore-keys: |
+ ${{ runner.os }}-python${{ matrix.python-version }}-data-${{ hashFiles('tests/download_test_data.py') }}
+ # Use strict matching for the hash of download_test_data.py, as we want to update the cache whenever the file changes.
+
+ - name: Download test data
+ if: steps.cache_data.outputs.cache-hit != 'true'
+ run: |
+ python tests/download_test_data.py
+ shell: bash -l {0}
+
+ - name: Run tests with thread limits
+ id: run_tests
+ run: |
+ export OMP_NUM_THREADS=1
+ export MKL_NUM_THREADS=1
+ export NUMEXPR_NUM_THREADS=1
+ pytest --nbmake --nbmake-timeout=3000 --cov=kalelinear
+ shell: bash -l {0}
+
+ - name: Determine coverage
+ run: |
+ coverage xml
+ shell: bash -l {0}
+
+ - name: Report coverage
+ uses: codecov/codecov-action@v4
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/README.md b/README.md
index d3a49cc..28ca283 100644
--- a/README.md
+++ b/README.md
@@ -9,12 +9,11 @@
[](https://pypi.org/project/kalelinear/)
[](https://pepy.tech/project/kalelinear)
-
`kalelinear` is a Python library for learning harmonized or individualized models from multi-source/multi-view data in linear or reproducing kernel Hilbert spaces (RKHS). It provides NumPy-based methods for leveraging related data distributions and structural assumptions, including transfer learning, domain adaptation, manifold regularization, and group-aware learning, through a [`scikit-learn`](https://github.com/scikit-learn/scikit-learn) style API.
The package is part of the [PyKale](https://github.com/pykale/pykale) ecosystem and focuses on linear and kernel methods for data characterized by covariates (e.g., domain labels, group labels, side information), unlabeled target samples, or tensor structures.
-## Methods and features
+## What's included
- Transformer models for learning feature embeddings:
- Multilinear Principal Component Analysis (MPCA) [[1](#references)]
@@ -32,20 +31,6 @@ The package is part of the [PyKale](https://github.com/pykale/pykale) ecosystem
`fit_predict` workflows where applicable.
- Optional covariate encoding for categorical domain or group labels.
-## Installation
-
-Install the released package from PyPI:
-
-```bash
-pip install kalelinear
-```
-
-Install from a local checkout for development:
-
-```bash
-pip install -e ".[dev]"
-```
-
`kalelinear` requires Python 3.10 or later. Core dependencies include:
- [NumPy](http://www.numpy.org/)
@@ -56,24 +41,23 @@ pip install -e ".[dev]"
- [cvxopt](http://cvxopt.org/)
- [osqp](https://osqp.org/)
-## Quick Start
+## Getting started
-Worked examples for the main transformers and estimators are collected in
-[Tutorials](TUTORIALS.md):
+### Installation
-- Learn a domain-invariant embedding with TCA
-- Use MIDA with categorical covariates
-- Train a domain adaptation classifier (ARSVM, ARRLS)
-- Train a manifold-regularized classifier (LapSVM, LapRLS)
+Install the released package from PyPI:
-## Public API
+```bash
+pip install kalelinear
+```
-```python
-from kalelinear.transformer import BDA, JDA, MIDA, MPCA, TCA
-from kalelinear.estimator import ARRLS, ARSVM, CoIRLS, CoIRSVM, GSDA, LapRLS, LapSVM
+Install from a local checkout for development:
+
+```bash
+pip install -e ".[dev]"
```
-## Development
+### Development
From the root of the repository, run the following commands in your terminal:
@@ -102,6 +86,21 @@ From the root of the repository, run the following commands in your terminal:
sphinx-build -b html docs/source docs/build/html
```
+### Public API
+
+```python
+from kalelinear.transformer import BDA, JDA, MIDA, MPCA, TCA
+from kalelinear.estimator import ARRLS, ARSVM, CoIRLS, CoIRSVM, GSDA, LapRLS, LapSVM
+```
+
+Worked examples for the main transformers and estimators are collected in
+[Tutorials](TUTORIALS.md):
+
+- Learn a domain-invariant embedding with TCA
+- Use MIDA with categorical covariates
+- Train a domain adaptation classifier (ARSVM, ARRLS)
+- Train a manifold-regularized classifier (LapSVM, LapRLS)
+
# References
[1] Lu, H., Plataniotis, K.N. and Venetsanopoulos, A.N., 2008. [MPCA: Multilinear principal component analysis of tensor objects](https://ieeexplore.ieee.org/abstract/document/4359192/). _IEEE Transactions on Neural Networks_, 19(1), pp.18-39.
diff --git a/TUTORIALS.md b/TUTORIALS.md
index 2f0a58e..d35a82b 100644
--- a/TUTORIALS.md
+++ b/TUTORIALS.md
@@ -6,6 +6,8 @@ installation instructions and an API overview, see the
## Learn a Domain-Invariant Embedding
+### Use TCA for Two-Domain Adaptation
+
```python
import numpy as np
from kalelinear.transformer import TCA
@@ -35,7 +37,7 @@ TCA, JDA, and BDA take domain labels through `covariates`. They do not accept
separate source and target arrays; stack samples into one array and use
`target_covariate` to identify the target domain.
-## Use MIDA with Categorical Covariates
+### Use MIDA with Categorical Domain Covariates
```python
import numpy as np
@@ -43,7 +45,7 @@ from kalelinear.transformer import MIDA
x = np.random.default_rng(0).normal(size=(8, 4))
y = np.array([0, 0, 1, 1, 0, 0, 1, 1])
-domains = np.array(["source", "source", "source", "source", "target", "target", "target", "target"])
+domains = np.array(["source1", "source1", "source2", "source2", "target", "target", "target", "target"])
transformer = MIDA(n_components=2, covariate_encoder="onehot")
z = transformer.fit_transform(x, y=y, covariates=domains)
diff --git a/kalelinear/estimator/base.py b/kalelinear/estimator/base.py
index c0efaa5..364198e 100644
--- a/kalelinear/estimator/base.py
+++ b/kalelinear/estimator/base.py
@@ -82,6 +82,15 @@ def _quadprog(cls, P, y, C, solver="osqp"):
q = -1 * np.ones(n_labeled)
upper_bound = C / n_labeled
+ # The semi-dual Hessian is only positive-semidefinite up to rounding
+ # (manifold/MMD terms such as ``L @ K`` are not PSD in general), which
+ # makes convex QP solvers such as osqp fail on some datasets. Project
+ # ``P`` onto the PSD cone so the QP is always convex and solvable.
+ P = P.astype(np.float64)
+ P = 0.5 * (P + P.T)
+ eigenvalues, eigenvectors = np.linalg.eigh(P)
+ P = (eigenvectors * np.clip(eigenvalues, 0, None)) @ eigenvectors.T
+
if solver == "cvxopt":
G = np.zeros((2 * n_labeled, n_labeled))
G[:n_labeled, :] = -1 * np.eye(n_labeled)
diff --git a/kalelinear/transformer/_base.py b/kalelinear/transformer/_base.py
index 2867804..562edce 100644
--- a/kalelinear/transformer/_base.py
+++ b/kalelinear/transformer/_base.py
@@ -106,6 +106,17 @@ def _eigendecompose(
"""Compute eigenpairs for a kernel matrix or a generalized eigenproblem."""
a, b = _get_eigenproblem_matrices(eigenproblem)
+ # Generalized eigenproblems require a positive-definite ``b``. Constraint
+ # matrices such as centered kernel matrices are only positive-semidefinite
+ # up to floating-point rounding, which makes scipy's ``eigh(a, b)`` fail
+ # with a non-positive-definite error. Regularize ``b`` with a small ridge
+ # relative to its scale; the shift is negligible for the retained
+ # components.
+ if b is not None:
+ b = 0.5 * (b + b.T)
+ ridge = 10 * np.finfo(b.dtype).eps * max(1.0, np.max(np.abs(b))) * b.shape[0]
+ b = b + ridge * np.eye(b.shape[0], dtype=b.dtype)
+
n_components = _check_n_components((a, b), n_components)
solver = _check_solver((a, b), n_components, solver, eigenvalue_order)
From a43f761e49af56865947a1b2c09eda963bd9317c Mon Sep 17 00:00:00 2001
From: Shuo Zhou
Date: Fri, 7 Aug 2026 16:48:38 +0100
Subject: [PATCH 05/11] update for varaible naming consistency
---
TUTORIALS.md | 24 ++---
docs/source/tutorial.rst | 28 +++---
docs/source/usage.rst | 14 +--
kalelinear/estimator/_artl.py | 16 ++--
kalelinear/estimator/_coir.py | 10 +-
kalelinear/estimator/_gsda.py | 16 ++--
kalelinear/estimator/_manifold_learn.py | 16 ++--
kalelinear/estimator/base.py | 4 +-
kalelinear/pipeline/mpca_trainer.py | 66 ++++++-------
kalelinear/transformer/_base.py | 34 +++----
kalelinear/transformer/_mpca.py | 64 ++++++-------
kalelinear/transformer/_tca.py | 2 +-
kalelinear/utils/_base.py | 10 +-
tests/estimator/test_estimator.py | 122 ++++++++++++------------
tests/transformer/test_mida.py | 92 +++++++++---------
tests/transformer/test_mpca.py | 66 ++++++-------
tests/transformer/test_transformer.py | 66 ++++++-------
tests/utils/test_utils.py | 16 ++--
18 files changed, 334 insertions(+), 332 deletions(-)
diff --git a/TUTORIALS.md b/TUTORIALS.md
index d35a82b..320e125 100644
--- a/TUTORIALS.md
+++ b/TUTORIALS.md
@@ -43,24 +43,24 @@ separate source and target arrays; stack samples into one array and use
import numpy as np
from kalelinear.transformer import MIDA
-x = np.random.default_rng(0).normal(size=(8, 4))
+X = np.random.default_rng(0).normal(size=(8, 4))
y = np.array([0, 0, 1, 1, 0, 0, 1, 1])
domains = np.array(["source1", "source1", "source2", "source2", "target", "target", "target", "target"])
transformer = MIDA(n_components=2, covariate_encoder="onehot")
-z = transformer.fit_transform(x, y=y, covariates=domains)
+z = transformer.fit_transform(X, y=y, covariates=domains)
```
## Train a Domain Adaptation Classifier
-For ARSVM and ARRLS, pass all source and target samples in `x`, labels for the
+For ARSVM and ARRLS, pass all source and target samples in `X`, labels for the
source samples in `y`, and a covariate vector identifying the target domain.
```python
import numpy as np
from kalelinear.estimator import ARSVM
-x = np.array(
+X = np.array(
[
[-2.2, -1.9],
[-1.9, -2.1],
@@ -75,11 +75,11 @@ x = np.array(
source_labels = np.array([0, 0, 1, 1])
domains = np.array([0, 0, 0, 0, 1, 1, 1, 1])
-x_target = x[domains == 1]
+X_target = X[domains == 1]
clf = ARSVM()
-clf.fit(x, source_labels, covariates=domains, target_covariate=1)
-y_pred = clf.predict(x_target)
+clf.fit(X, source_labels, covariates=domains, target_covariate=1)
+y_pred = clf.predict(X_target)
```
## Train a Manifold-Regularized Classifier
@@ -91,13 +91,13 @@ samples. The labels array may contain only the labeled source examples.
import numpy as np
from kalelinear.estimator import LapSVM
-x_source = np.array([[-2.0, -1.8], [-1.8, -2.1], [1.9, 1.7], [2.1, 2.0]])
+X_source = np.array([[-2.0, -1.8], [-1.8, -2.1], [1.9, 1.7], [2.1, 2.0]])
ys = np.array([0, 0, 1, 1])
-x_target = np.array([[-1.4, -1.2], [-1.2, -1.1], [1.2, 1.1], [1.4, 1.3]])
+X_target = np.array([[-1.4, -1.2], [-1.2, -1.1], [1.2, 1.1], [1.4, 1.3]])
-x_train = np.vstack((x_source, x_target))
+X_train = np.vstack((X_source, X_target))
clf = LapSVM(kernel="linear")
-clf.fit(x_train, ys)
-y_pred = clf.predict(x_target)
+clf.fit(X_train, ys)
+y_pred = clf.predict(X_target)
```
diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst
index 2ac9c36..a83442d 100644
--- a/docs/source/tutorial.rst
+++ b/docs/source/tutorial.rst
@@ -13,7 +13,7 @@ label is the target domain.
import numpy as np
from kalelinear.transformer import TCA
- x = np.array(
+ X = np.array(
[
[-2.0, -1.8],
[-1.8, -2.1],
@@ -28,7 +28,7 @@ label is the target domain.
domain_labels = np.array([0, 0, 0, 0, 1, 1, 1, 1])
transformer = TCA(n_components=2)
- z = transformer.fit_transform(x, covariates=domain_labels, target_covariate=1)
+ z = transformer.fit_transform(X, covariates=domain_labels, target_covariate=1)
z_source = z[domain_labels == 0]
z_target = z[domain_labels == 1]
@@ -41,19 +41,19 @@ Use MIDA with Categorical Covariates
import numpy as np
from kalelinear.transformer import MIDA
- x = np.random.default_rng(0).normal(size=(8, 4))
+ X = np.random.default_rng(0).normal(size=(8, 4))
y = np.array([0, 0, 1, 1, 0, 0, 1, 1])
domains = np.array(
["source", "source", "source", "source", "target", "target", "target", "target"]
)
transformer = MIDA(n_components=2, covariate_encoder="onehot")
- z = transformer.fit_transform(x, y=y, covariates=domains)
+ z = transformer.fit_transform(X, y=y, covariates=domains)
Train a Domain Adaptation Classifier
------------------------------------
-For ARSVM and ARRLS, pass all source and target samples in ``x``, labels for the
+For ARSVM and ARRLS, pass all source and target samples in ``X``, labels for the
source samples in ``y``, and a covariate vector identifying the target domain.
.. code-block:: python
@@ -61,7 +61,7 @@ source samples in ``y``, and a covariate vector identifying the target domain.
import numpy as np
from kalelinear.estimator import ARSVM
- x = np.array(
+ X = np.array(
[
[-2.2, -1.9],
[-1.9, -2.1],
@@ -76,11 +76,11 @@ source samples in ``y``, and a covariate vector identifying the target domain.
source_labels = np.array([0, 0, 1, 1])
domains = np.array([0, 0, 0, 0, 1, 1, 1, 1])
- x_target = x[domains == 1]
+ X_target = X[domains == 1]
clf = ARSVM()
- clf.fit(x, source_labels, covariates=domains, target_covariate=1)
- y_pred = clf.predict(x_target)
+ clf.fit(X, source_labels, covariates=domains, target_covariate=1)
+ y_pred = clf.predict(X_target)
Train a Manifold-Regularized Classifier
---------------------------------------
@@ -93,12 +93,12 @@ samples. The labels array may contain only the labeled source examples.
import numpy as np
from kalelinear.estimator import LapSVM
- x_source = np.array([[-2.0, -1.8], [-1.8, -2.1], [1.9, 1.7], [2.1, 2.0]])
+ X_source = np.array([[-2.0, -1.8], [-1.8, -2.1], [1.9, 1.7], [2.1, 2.0]])
ys = np.array([0, 0, 1, 1])
- x_target = np.array([[-1.4, -1.2], [-1.2, -1.1], [1.2, 1.1], [1.4, 1.3]])
+ X_target = np.array([[-1.4, -1.2], [-1.2, -1.1], [1.2, 1.1], [1.4, 1.3]])
- x_train = np.vstack((x_source, x_target))
+ X_train = np.vstack((X_source, X_target))
clf = LapSVM(kernel="linear")
- clf.fit(x_train, ys)
- y_pred = clf.predict(x_target)
+ clf.fit(X_train, ys)
+ y_pred = clf.predict(X_target)
diff --git a/docs/source/usage.rst b/docs/source/usage.rst
index 10e0566..29f1116 100644
--- a/docs/source/usage.rst
+++ b/docs/source/usage.rst
@@ -15,7 +15,7 @@ label is the target domain.
import numpy as np
from kalelinear.transformer import TCA
- x = np.array(
+ X = np.array(
[
[-2.0, -1.8],
[-1.8, -2.1],
@@ -30,7 +30,7 @@ label is the target domain.
domain_labels = np.array([0, 0, 0, 0, 1, 1, 1, 1])
transformer = TCA(n_components=2)
- z = transformer.fit_transform(x, covariates=domain_labels, target_covariate=1)
+ z = transformer.fit_transform(X, covariates=domain_labels, target_covariate=1)
z_source = z[domain_labels == 0]
z_target = z[domain_labels == 1]
@@ -38,7 +38,7 @@ label is the target domain.
Domain Adaptation Estimators
----------------------------
-ARSVM and ARRLS use all source and target samples in ``x``, labels for the
+ARSVM and ARRLS use all source and target samples in ``X``, labels for the
source samples, and covariates that mark each sample's domain.
.. code-block:: python
@@ -46,7 +46,7 @@ source samples, and covariates that mark each sample's domain.
import numpy as np
from kalelinear.estimator import ARSVM
- x = np.array(
+ X = np.array(
[
[-2.2, -1.9],
[-1.9, -2.1],
@@ -60,8 +60,8 @@ source samples, and covariates that mark each sample's domain.
)
source_labels = np.array([0, 0, 1, 1])
domains = np.array([0, 0, 0, 0, 1, 1, 1, 1])
- x_target = x[domains == 1]
+ X_target = X[domains == 1]
clf = ARSVM()
- clf.fit(x, source_labels, covariates=domains, target_covariate=1)
- y_pred = clf.predict(x_target)
+ clf.fit(X, source_labels, covariates=domains, target_covariate=1)
+ y_pred = clf.predict(X_target)
diff --git a/kalelinear/estimator/_artl.py b/kalelinear/estimator/_artl.py
index f066d45..23c532b 100644
--- a/kalelinear/estimator/_artl.py
+++ b/kalelinear/estimator/_artl.py
@@ -107,7 +107,7 @@ def __init__(
kernel="linear",
lambda_=1.0,
gamma_=0.0,
- k_neighbour=5,
+ k_neighbors=5,
solver="osqp",
manifold_metric="cosine",
knn_mode="distance",
@@ -125,7 +125,7 @@ def __init__(
MMD regulisation param, by default 1.0
gamma_ : float, optional
manifold regulisation param, by default 0.0
- k_neighbour : int, optional
+ k_neighbors : int, optional
number of nearest numbers for each sample in manifold regularisation,
by default 5
solver : str, optional
@@ -148,7 +148,7 @@ def __init__(
self.C = C
self.gamma_ = gamma_
self.solver = solver
- self.k_neighbour = k_neighbour
+ self.k_neighbors = k_neighbors
# self.alpha = None
self.knn_mode = knn_mode
self.manifold_metric = manifold_metric
@@ -191,7 +191,7 @@ def fit(self, X, y, covariates=None, target_covariate=None, unlabeled_value=None
y_ = self._lb.fit_transform(y)
if self.gamma_ != 0:
- lap_mat = lap_norm(X, n_neighbour=self.k_neighbour, mode=self.knn_mode)
+ lap_mat = lap_norm(X, n_neighbors=self.k_neighbors, mode=self.knn_mode)
Q_ = unit_matrix + multi_dot([(self.lambda_ * M + self.gamma_ * lap_mat), x_kernel_matrix])
else:
Q_ = unit_matrix + multi_dot([(self.lambda_ * M), x_kernel_matrix])
@@ -281,7 +281,7 @@ def __init__(
lambda_=1.0,
gamma_=0.0,
sigma_=1.0,
- k_neighbour=5,
+ k_neighbors=5,
manifold_metric="cosine",
knn_mode="distance",
**kwargs,
@@ -298,7 +298,7 @@ def __init__(
manifold regularisation param, by default 0.0
sigma_ : float, optional
l2 regularisation param, by default 1.0
- k_neighbour : int, optional
+ k_neighbors : int, optional
number of nearest numbers for each sample in manifold regularisation,
by default 5
manifold_metric : str, optional
@@ -318,7 +318,7 @@ def __init__(
self.lambda_ = lambda_
self.gamma_ = gamma_
self.sigma_ = sigma_
- self.k_neighbour = k_neighbour
+ self.k_neighbors = k_neighbors
# self.coef_ = None
self.knn_mode = knn_mode
self.manifold_metric = manifold_metric
@@ -360,7 +360,7 @@ def fit(self, X, y, covariates=None, target_covariate=None, unlabeled_value=None
J[:nl, :nl] = np.eye(nl)
if self.gamma_ != 0:
- lap_mat = lap_norm(X, n_neighbour=self.k_neighbour, metric=self.manifold_metric, mode=self.knn_mode)
+ lap_mat = lap_norm(X, n_neighbors=self.k_neighbors, metric=self.manifold_metric, mode=self.knn_mode)
Q_ = np.dot((J + self.lambda_ * M + self.gamma_ * lap_mat), x_kernel_matrix) + self.sigma_ * unit_matrix
else:
Q_ = np.dot((J + self.lambda_ * M), x_kernel_matrix) + self.sigma_ * unit_matrix
diff --git a/kalelinear/estimator/_coir.py b/kalelinear/estimator/_coir.py
index eb399fa..b6af2c4 100644
--- a/kalelinear/estimator/_coir.py
+++ b/kalelinear/estimator/_coir.py
@@ -46,7 +46,7 @@ def __init__(
kernel="linear",
lambda_=1.0,
mu=0.0,
- k_neighbour=3,
+ k_neighbors=3,
manifold_metric="cosine",
knn_mode="distance",
solver="osqp",
@@ -65,7 +65,7 @@ def __init__(
param for covariate (side information) independence regularisation, by default 1
mu : float, optional
param for manifold regularisation, by default 0
- k_neighbour : int, optional
+ k_neighbors : int, optional
number of nearest numbers for each sample in manifold regularisation,
by default 3
manifold_metric : str, optional
@@ -94,7 +94,7 @@ def __init__(
# self.support_vectors_ = None
# self.n_support_ = None
self.manifold_metric = manifold_metric
- self.k_neighbour = k_neighbour
+ self.k_neighbors = k_neighbors
self.knn_mode = knn_mode
self.covariate_encoder = covariate_encoder
self._lb = LabelBinarizer(pos_label=1, neg_label=-1)
@@ -130,7 +130,7 @@ def fit(self, X, y, covariates=None):
Q_ = unit_matrix.copy()
if self.mu != 0:
- lap_mat = lap_norm(X, n_neighbour=self.k_neighbour, metric=self.manifold_metric, mode=self.knn_mode)
+ lap_mat = lap_norm(X, n_neighbors=self.k_neighbors, metric=self.manifold_metric, mode=self.knn_mode)
Q_ += np.dot(
self.lambda_ / np.square(n - 1) * covariate_hsic_matrix + self.mu / np.square(n) * lap_mat,
x_kernel_matrix,
@@ -316,7 +316,7 @@ def fit(self, X, y, covariates=None):
J[:nl, :nl] = np.eye(nl)
if self.mu != 0:
- lap_mat = lap_norm(X, n_neighbour=self.k, mode=self.knn_mode, metric=self.manifold_metric)
+ lap_mat = lap_norm(X, n_neighbors=self.k, mode=self.knn_mode, metric=self.manifold_metric)
Q_ = self.sigma_ * unit_matrix + np.dot(
J + self.lambda_ / np.square(n - 1) * covariate_hsic_matrix + self.mu / np.square(n) * lap_mat,
x_kernel_matrix,
diff --git a/kalelinear/estimator/_gsda.py b/kalelinear/estimator/_gsda.py
index 293588f..5337e7a 100644
--- a/kalelinear/estimator/_gsda.py
+++ b/kalelinear/estimator/_gsda.py
@@ -307,7 +307,7 @@ def _lbfgs_solver(self, X, y, groups, target_idx=None):
z += delta_theta[i] * (alphas[len(alphas) - 1 - i] - beta_i)
else:
z = np.eye(self.theta_.shape[0]) @ q
- # Line search and update x
+ # Line search and update X
# Implement a line search algorithm to find an appropriate step size
# step_size = 1 # Placeholder
# Update memory
@@ -379,20 +379,20 @@ def _gd_solver(self, X, y, groups, target_idx=None):
return self
def compute_gsda_gradient(self, X, y, groups, target_idx=None):
- n_sample = X.shape[0]
+ n_samples = X.shape[0]
n_tgt = y.shape[0]
if target_idx is None:
- x_tgt = X[:n_tgt]
+ X_tgt = X[:n_tgt]
else:
- x_tgt = X[target_idx]
+ X_tgt = X[target_idx]
- y_hat = expit(x_tgt @ self.theta_)
+ y_hat = expit(X_tgt @ self.theta_)
# n_feature = X.shape[1]
_simple_hsic = simple_hsic_grad_term(self.theta_, X, groups)
- hsic_proba = expit(np.dot(self.theta_, _simple_hsic) / np.square(n_sample - 1))
- grad_hsic = (hsic_proba - 1) * _simple_hsic / np.square(n_sample - 1)
+ hsic_proba = expit(np.dot(self.theta_, _simple_hsic) / np.square(n_samples - 1))
+ grad_hsic = (hsic_proba - 1) * _simple_hsic / np.square(n_samples - 1)
- delta_grad = (x_tgt.T @ (y_hat - y)) / n_tgt
+ delta_grad = (X_tgt.T @ (y_hat - y)) / n_tgt
if self.regularization is not None:
delta_grad += self.theta_ * self.alpha
delta_grad += self.lambda_ * grad_hsic
diff --git a/kalelinear/estimator/_manifold_learn.py b/kalelinear/estimator/_manifold_learn.py
index 62bf880..1410148 100644
--- a/kalelinear/estimator/_manifold_learn.py
+++ b/kalelinear/estimator/_manifold_learn.py
@@ -24,7 +24,7 @@ def __init__(
kernel="linear",
gamma_=1.0,
solver="osqp",
- k_neighbour=3,
+ k_neighbors=3,
manifold_metric="cosine",
knn_mode="distance",
**kwargs,
@@ -41,7 +41,7 @@ def __init__(
param for manifold regularisation, by default 1.0
solver : str, optional
quadratic programming solver, [cvxopt, osqp], by default 'osqp'
- k_neighbour : int, optional
+ k_neighbors : int, optional
number of nearest numbers for each sample in manifold regularisation,
by default 3
manifold_metric : str, optional
@@ -62,7 +62,7 @@ def __init__(
self.solver = solver
self.kwargs = kwargs
self.manifold_metric = manifold_metric
- self.k_neighbour = k_neighbour
+ self.k_neighbors = k_neighbors
self.knn_mode = knn_mode
self._lb = LabelBinarizer(pos_label=1, neg_label=-1)
@@ -87,7 +87,7 @@ def fit(self, X, y):
if self.gamma_ == 0:
Q_ = centering_matrix
else:
- lap_mat = lap_norm(X, n_neighbour=self.k_neighbour, mode=self.knn_mode)
+ lap_mat = lap_norm(X, n_neighbors=self.k_neighbors, mode=self.knn_mode)
Q_ = centering_matrix + self.gamma_ * np.dot(lap_mat, x_kernel_matrix)
y_ = self._lb.fit_transform(y)
@@ -171,7 +171,7 @@ def __init__(
kernel="linear",
gamma_=1.0,
sigma_=1.0,
- k_neighbour=5,
+ k_neighbors=5,
manifold_metric="cosine",
knn_mode="distance",
**kwargs,
@@ -186,7 +186,7 @@ def __init__(
manifold regularisation param, by default 1.0
sigma_ : float, optional
l2 regularisation param, by default 1.0
- k_neighbour : int, optional
+ k_neighbors : int, optional
number of nearest numbers for each sample in manifold regularisation,
by default 5
manifold_metric : str, optional
@@ -205,7 +205,7 @@ def __init__(
self.kernel = kernel
self.gamma_ = gamma_
self.sigma_ = sigma_
- self.k_neighbour = k_neighbour
+ self.k_neighbors = k_neighbors
# self.coef_ = None
self.knn_mode = knn_mode
self.manifold_metric = manifold_metric
@@ -235,7 +235,7 @@ def fit(self, X, y):
J[:nl, :nl] = np.eye(nl)
if self.gamma_ != 0:
- lap_mat = lap_norm(X, n_neighbour=self.k_neighbour, metric=self.manifold_metric, mode=self.knn_mode)
+ lap_mat = lap_norm(X, n_neighbors=self.k_neighbors, metric=self.manifold_metric, mode=self.knn_mode)
Q_ = np.dot((J + self.gamma_ * lap_mat), x_kernel_matrix) + self.sigma_ * unit_matrix
else:
Q_ = np.dot(J, x_kernel_matrix) + self.sigma_ * centering_matrix
diff --git a/kalelinear/estimator/base.py b/kalelinear/estimator/base.py
index 364198e..f27c321 100644
--- a/kalelinear/estimator/base.py
+++ b/kalelinear/estimator/base.py
@@ -20,7 +20,7 @@ class BaseKaleEstimator(BaseEstimator, ClassifierMixin):
def __init__(
self,
kernel="linear",
- k_neighbour=5,
+ k_neighbors=5,
manifold_metric="cosine",
knn_mode="distance",
pos_label=1,
@@ -29,7 +29,7 @@ def __init__(
):
super().__init__()
self.kernel = kernel
- self.k_neighbour = k_neighbour
+ self.k_neighbors = k_neighbors
self.manifold_metric = manifold_metric
self.knn_mode = knn_mode
self.coef_ = None
diff --git a/kalelinear/pipeline/mpca_trainer.py b/kalelinear/pipeline/mpca_trainer.py
index 5816a5c..08d34d1 100644
--- a/kalelinear/pipeline/mpca_trainer.py
+++ b/kalelinear/pipeline/mpca_trainer.py
@@ -100,7 +100,9 @@ def __init__(
if classifier_params == "auto":
self.auto_classifier_param = True
if self.classifier_param_grid is None:
- self.classifier_param_grid = classifiers[classifier][1]
+ self.classifier_param_grid = {
+ param_name: list(values) for param_name, values in classifiers[classifier][1].items()
+ }
self.grid_search = GridSearchCV(
classifiers[classifier][0](), param_grid=self.classifier_param_grid, **self.search_params
)
@@ -114,87 +116,87 @@ def __init__(
self.classifier_params = classifier_params
- def fit(self, x, y):
- """Fit a pipeline with the given data x and labels y
+ def fit(self, X, y):
+ """Fit a pipeline with the given data X and labels y
Args:
- x (array-like tensor): input data, shape (n_samples, I_1, I_2, ..., I_N)
+ X (array-like tensor): input data, shape (n_samples, I_1, I_2, ..., I_N)
y (array-like): data labels, shape (n_samples, )
Returns:
self
"""
# fit mpca
- self.mpca.fit(x)
+ self.mpca.fit(X)
self.mpca.set_params(**{"vectorize": True})
- x_transformed = self.mpca.transform(x)
+ X_transformed = self.mpca.transform(X)
# feature selection
if self.n_features is None:
- self.n_features = x_transformed.shape[1]
- self.feature_order = self.mpca.idx_order
+ self.n_features = X_transformed.shape[1]
+ self.feature_order = self.mpca.idx_order_
else:
- f_score, p_val = f_classif(x_transformed, y)
+ f_score, p_val = f_classif(X_transformed, y)
self.feature_order = (-1 * f_score).argsort()
- x_transformed = x_transformed[:, self.feature_order][:, : self.n_features]
+ X_transformed = X_transformed[:, self.feature_order][:, : self.n_features]
# fit classifier
if self.auto_classifier_param:
- self.grid_search.param_grid["C"].append(1 / x.shape[0])
- self.grid_search.fit(x_transformed, y)
+ self.grid_search.param_grid["C"].append(1 / X.shape[0])
+ self.grid_search.fit(X_transformed, y)
self.clf = self.grid_search.best_estimator_
if self.classifier == "svc":
self.clf.set_params(**{"probability": True})
- self.clf.fit(x_transformed, y)
+ self.clf.fit(X_transformed, y)
- def predict(self, x):
- """Predict the labels for the given data x
+ def predict(self, X):
+ """Predict the labels for the given data X
Args:
- x (array-like tensor): input data, shape (n_samples, I_1, I_2, ..., I_N)
+ X (array-like tensor): input data, shape (n_samples, I_1, I_2, ..., I_N)
Returns:
array-like: Predicted labels, shape (n_samples, )
"""
- return self.clf.predict(self._extract_feature(x))
+ return self.clf.predict(self._extract_feature(X))
- def decision_function(self, x):
- """Decision scores of each class for the given data x
+ def decision_function(self, X):
+ """Decision scores of each class for the given data X
Args:
- x (array-like tensor): input data, shape (n_samples, I_1, I_2, ..., I_N)
+ X (array-like tensor): input data, shape (n_samples, I_1, I_2, ..., I_N)
Returns:
- array-like: decision scores, shape (n_samples,) for binary case, else (n_samples, n_class)
+ array-like: decision scores, shape (n_samples,) for binary case, else (n_samples, n_classes)
"""
- return self.clf.decision_function(self._extract_feature(x))
+ return self.clf.decision_function(self._extract_feature(X))
- def predict_proba(self, x):
- """Probability of each class for the given data x. Not supported by "linear_svc".
+ def predict_proba(self, X):
+ """Probability of each class for the given data X. Not supported by "linear_svc".
Args:
- x (array-like tensor): input data, shape (n_samples, I_1, I_2, ..., I_N)
+ X (array-like tensor): input data, shape (n_samples, I_1, I_2, ..., I_N)
Returns:
- array-like: probabilities, shape (n_samples, n_class)
+ array-like: probabilities, shape (n_samples, n_classes)
"""
if self.classifier == "linear_svc":
error_msg = "Linear SVC does not support computing probability."
logging.error(error_msg)
raise ValueError(error_msg)
- return self.clf.predict_proba(self._extract_feature(x))
+ return self.clf.predict_proba(self._extract_feature(X))
- def _extract_feature(self, x):
- """Extracting features for the given data x with MPCA->Feature selection
+ def _extract_feature(self, X):
+ """Extracting features for the given data X with MPCA->Feature selection
Args:
- x (array-like tensor): input data, shape (n_samples, I_1, I_2, ..., I_N)
+ X (array-like tensor): input data, shape (n_samples, I_1, I_2, ..., I_N)
Returns:
array-like: n_new, shape (n_samples, n_features)
"""
check_is_fitted(self.clf)
- x_transformed = self.mpca.transform(x)
+ X_transformed = self.mpca.transform(X)
- return x_transformed[:, self.feature_order][:, : self.n_features]
+ return X_transformed[:, self.feature_order][:, : self.n_features]
diff --git a/kalelinear/transformer/_base.py b/kalelinear/transformer/_base.py
index 562edce..f5de7d7 100644
--- a/kalelinear/transformer/_base.py
+++ b/kalelinear/transformer/_base.py
@@ -318,15 +318,15 @@ def __init__(
self.fit_inverse_transform = fit_inverse_transform
self.augment = augment
- def _fit_inverse_transform(self, x_transformed, X):
+ def _fit_inverse_transform(self, X_transformed, X):
if hasattr(X, "tocsr"):
raise NotImplementedError("Inverse transform not implemented for sparse matrices!")
- n_samples = x_transformed.shape[0]
- x_transformed_kernel_matrix = self._get_kernel(x_transformed)
+ n_samples = X_transformed.shape[0]
+ x_transformed_kernel_matrix = self._get_kernel(X_transformed)
x_transformed_kernel_matrix.flat[:: n_samples + 1] += self.alpha
self.dual_coef_ = la.solve(x_transformed_kernel_matrix, X, assume_a="pos", overwrite_a=True)
- self.x_transformed_fit_ = x_transformed
+ self.X_transformed_fit_ = X_transformed
@property
def _n_features_out(self):
@@ -361,7 +361,7 @@ def orig_coef_(self):
w = self.eigenvectors_
if self.scale_components:
w = _scale_eigenvectors(self.eigenvalues_, w)
- return safe_sparse_dot(w.T, self.x_fit_)
+ return safe_sparse_dot(w.T, self.X_fit_)
def _requires_covariates(self):
return False
@@ -557,8 +557,8 @@ def fit(self, X, y=None, covariates=None, **fit_params):
elif hasattr(self, "_factor_validator"):
delattr(self, "_factor_validator")
- self.x_fit_ = self._augment_data(context.X_fit, context.covariates_fit)
- self.x_fit_raw_ = context.X_fit
+ self.X_fit_ = self._augment_data(context.X_fit, context.covariates_fit)
+ self.X_fit_raw_ = context.X_fit
self.covariates_input_ = raw_covariates
self.covariates_fit_ = context.covariates_fit
self.fit_context_ = context
@@ -567,15 +567,15 @@ def fit(self, X, y=None, covariates=None, **fit_params):
self.gamma_ = 1 / _num_features(X) if self.gamma is None else self.gamma
self._centerer = KernelCenterer()
- x_fit_kernel_matrix = self._get_kernel(self.x_fit_)
- x_fit_kernel_matrix = self._centerer.fit_transform(x_fit_kernel_matrix)
+ X_fit_kernel_matrix = self._get_kernel(self.X_fit_)
+ X_fit_kernel_matrix = self._centerer.fit_transform(X_fit_kernel_matrix)
- eigenproblem = self._make_eigenproblem(x_fit_kernel_matrix, context)
+ eigenproblem = self._make_eigenproblem(X_fit_kernel_matrix, context)
self._fit_transform_in_place(eigenproblem)
if self.fit_inverse_transform:
- x_transformed = self.transform(context.X_input, covariates=context.covariates_input)
- self._fit_inverse_transform(x_transformed, context.X_input)
+ X_transformed = self.transform(context.X_input, covariates=context.covariates_input)
+ self._fit_inverse_transform(X_transformed, context.X_input)
return self
@@ -604,21 +604,21 @@ def transform(self, X, covariates=None):
covariates = self._factor_validator.transform(covariates)
X_query = self._augment_data(X, covariates)
- x_fit_kernel_matrix = self._get_kernel(X_query, self.x_fit_)
- x_fit_kernel_matrix = self._centerer.transform(x_fit_kernel_matrix)
+ X_fit_kernel_matrix = self._get_kernel(X_query, self.X_fit_)
+ X_fit_kernel_matrix = self._centerer.transform(X_fit_kernel_matrix)
w = self.eigenvectors_
if self.scale_components:
w = _scale_eigenvectors(self.eigenvalues_, w)
- z = safe_sparse_dot(x_fit_kernel_matrix, w)
+ z = safe_sparse_dot(X_fit_kernel_matrix, w)
if self.augment == "post":
z = np.hstack((z, covariates))
return z
- def inverse_transform(self, z):
+ def inverse_transform(self, X):
check_is_fitted(self)
if not self.fit_inverse_transform:
raise NotFittedError(
@@ -627,7 +627,7 @@ def inverse_transform(self, z):
"the inverse transform is not available."
)
- k_z = self._get_kernel(z, self.x_transformed_fit_)
+ k_z = self._get_kernel(X, self.X_transformed_fit_)
return safe_sparse_dot(k_z, self.dual_coef_)
def fit_transform(self, X, y=None, covariates=None, **fit_params):
diff --git a/kalelinear/transformer/_mpca.py b/kalelinear/transformer/_mpca.py
index df92572..cf5d7f8 100644
--- a/kalelinear/transformer/_mpca.py
+++ b/kalelinear/transformer/_mpca.py
@@ -123,13 +123,13 @@ class MPCA(BaseEstimator, TransformerMixin):
Attributes
----------
- proj_mats : list of ndarray
+ proj_mats_ : list of ndarray
Transposed projection matrices with shapes ``(P_i, I_i)``.
- idx_order : ndarray
+ idx_order_ : ndarray
Feature ranking indices by descending projected variance.
mean_ : ndarray
Per-feature empirical mean of the training data.
- input_shape : tuple
+ input_shape_ : tuple
Input per-sample tensor shape.
output_shape_ : tuple
Output per-sample tensor shape after projection. Equals
@@ -148,21 +148,21 @@ class MPCA(BaseEstimator, TransformerMixin):
--------
>>> import numpy as np
>>> from kalelinear.transformer import MPCA
- >>> x = np.random.random((40, 20, 25, 20))
- >>> x.shape
+ >>> X = np.random.random((40, 20, 25, 20))
+ >>> X.shape
(40, 20, 25, 20)
>>> mpca = MPCA()
- >>> x_projected = mpca.fit_transform(x)
- >>> x_projected.shape
+ >>> X_projected = mpca.fit_transform(X)
+ >>> X_projected.shape
(40, 18, 23, 18)
- >>> x_projected = mpca.transform(x)
- >>> x_projected.shape
+ >>> X_projected = mpca.transform(X)
+ >>> X_projected.shape
(40, 7452)
- >>> x_projected = mpca.transform(x)
- >>> x_projected.shape
+ >>> X_projected = mpca.transform(X)
+ >>> X_projected.shape
(40, 50)
- >>> x_reconstructed = mpca.inverse_transform(x_projected)
- >>> x_reconstructed.shape
+ >>> X_reconstructed = mpca.inverse_transform(X_projected)
+ >>> X_reconstructed.shape
(40, 20, 25, 20)
"""
@@ -176,7 +176,7 @@ def __init__(
msg = "Number of max iterations must be a positive integer but given %s" % max_iter
logging.error(msg)
raise ValueError(msg)
- self.proj_mats = []
+ self.proj_mats_ = []
self.vectorize = vectorize
self.n_components = n_components
if output_shape is None:
@@ -226,7 +226,7 @@ def _fit(self, X):
n_samples = shape_[0]
n_dims = X.ndim
- self.input_shape = shape_[1:]
+ self.input_shape_ = shape_[1:]
# Samples are processed in chunks so that a centered copy of the full
# data never has to be materialized and disk-backed inputs (e.g. a
@@ -328,13 +328,13 @@ def _fit(self, X):
batch_proj = multi_mode_dot(batch, proj_matrices, modes=modes_all)
batch_unfold = unfold(batch_proj, mode=0) # unfold the chunked projection to shape (n_chunk, n_features)
x_proj_var += np.einsum("ij,ij->j", batch_unfold, batch_unfold)
- idx_order = np.argsort(-x_proj_var)
+ idx_order_ = np.argsort(-x_proj_var)
- self.proj_mats = proj_matrices
- self.idx_order = idx_order
+ self.proj_mats_ = proj_matrices
+ self.idx_order_ = idx_order_
self.output_shape_ = output_shape
self.explained_variance_ratio_ = tuple(explained_variance_ratios)
- self.n_dims = n_dims
+ self.n_dims_ = n_dims
return self
@@ -348,24 +348,24 @@ def transform(self, X):
Returns
-------
- x_projected : ndarray
+ X_projected : ndarray
Projected data. Shape is ``(n_samples, P_1, ..., P_N)`` when
``vectorize=False``. Otherwise returns vectorized features with
optional truncation to ``n_components``.
"""
# reshape X to shape (1, I_1, I_2, ..., I_N) if X in shape (I_1, I_2, ..., I_N), i.e. n_samples = 1
- if X.ndim == self.n_dims - 1:
+ if X.ndim == self.n_dims_ - 1:
X = X.reshape((1,) + X.shape)
- _check_tensor_dim_shape(X, self.n_dims, self.input_shape)
+ _check_tensor_dim_shape(X, self.n_dims_, self.input_shape_)
X = X - self.mean_
# projected tensor in lower dimensions
- x_projected = multi_mode_dot(X, self.proj_mats, modes=[m for m in range(1, self.n_dims)])
+ X_projected = multi_mode_dot(X, self.proj_mats_, modes=[m for m in range(1, self.n_dims_)])
n_components = self.n_components
if self.vectorize:
- x_projected = unfold(x_projected, mode=0)
- x_projected = x_projected[:, self.idx_order]
+ X_projected = unfold(X_projected, mode=0)
+ X_projected = X_projected[:, self.idx_order_]
if isinstance(n_components, int):
n_features = int(np.prod(self.output_shape_))
if n_components > n_features:
@@ -375,9 +375,9 @@ def transform(self, X):
logging.warning(warn_msg)
warnings.warn(warn_msg)
n_components = n_features
- x_projected = x_projected[:, :n_components]
+ X_projected = X_projected[:, :n_components]
- return x_projected
+ return X_projected
def inverse_transform(self, X):
"""Reconstruct original-space tensors from projected data.
@@ -389,7 +389,7 @@ def inverse_transform(self, X):
Returns
-------
- x_reconstructed : ndarray of shape (n_samples, I_1, ..., I_N)
+ X_reconstructed : ndarray of shape (n_samples, I_1, ..., I_N)
Reconstructed tensor data in the original shape.
"""
# reshape X to tensor in shape (n_samples, self.output_shape_) if X has been unfolded
@@ -401,7 +401,7 @@ def inverse_transform(self, X):
n_features = X.shape[1]
if n_features <= np.prod(self.output_shape_):
x_ = np.zeros((n_samples, np.prod(self.output_shape_)))
- x_[:, self.idx_order[:n_features]] = X[:]
+ x_[:, self.idx_order_[:n_features]] = X[:]
else:
msg = "Feature dimension exceeds the shape upper limit."
logging.error(msg)
@@ -409,8 +409,8 @@ def inverse_transform(self, X):
X = fold(x_, mode=0, shape=((n_samples,) + self.output_shape_))
- x_reconstructed = multi_mode_dot(X, self.proj_mats, modes=[m for m in range(1, self.n_dims)], transpose=True)
+ X_reconstructed = multi_mode_dot(X, self.proj_mats_, modes=[m for m in range(1, self.n_dims_)], transpose=True)
- x_reconstructed = x_reconstructed + self.mean_
+ X_reconstructed = X_reconstructed + self.mean_
- return x_reconstructed
+ return X_reconstructed
diff --git a/kalelinear/transformer/_tca.py b/kalelinear/transformer/_tca.py
index 4f36413..5e946a6 100644
--- a/kalelinear/transformer/_tca.py
+++ b/kalelinear/transformer/_tca.py
@@ -73,7 +73,7 @@ def _make_eigenproblem(self, x_kernel_matrix, context):
return obj, st
y_kernel_matrix = self.gamma_ * np.dot(context.y_encoded, context.y_encoded.T) + (1 - self.gamma_) * identity
- lap_mat = lap_norm(context.X_fit, n_neighbour=self.k, mode="connectivity")
+ lap_mat = lap_norm(context.X_fit, n_neighbors=self.k, mode="connectivity")
obj += multi_dot([x_kernel_matrix, (mmd_matrix + self.mu * lap_mat), x_kernel_matrix])
st += multi_dot([x_kernel_matrix, h, y_kernel_matrix, h, x_kernel_matrix])
diff --git a/kalelinear/utils/_base.py b/kalelinear/utils/_base.py
index 8069dab..a376195 100644
--- a/kalelinear/utils/_base.py
+++ b/kalelinear/utils/_base.py
@@ -8,14 +8,14 @@
from kalelinear.utils._backend import to_numpy
-def lap_norm(X, n_neighbour=3, metric="cosine", mode="distance", normalise=True):
+def lap_norm(X, n_neighbors=3, metric="cosine", mode="distance", normalize=True):
"""[summary]
Parameters
----------
X : [type]
[description]
- n_neighbour : int, optional
+ n_neighbors : int, optional
[description], by default 3
metric : str, optional
[description], by default 'cosine'
@@ -24,7 +24,7 @@ def lap_norm(X, n_neighbour=3, metric="cosine", mode="distance", normalise=True)
returned matrix: 'connectivity' will return the connectivity
matrix with ones and zeros, and 'distance' will return the
distances between neighbors according to the given metric.
- normalise : bool, optional
+ normalize : bool, optional
[description], by default True
Returns
@@ -34,7 +34,7 @@ def lap_norm(X, n_neighbour=3, metric="cosine", mode="distance", normalise=True)
"""
x_np = to_numpy(X)
n = x_np.shape[0]
- knn_graph = kneighbors_graph(x_np, n_neighbour, metric=metric, mode=mode).toarray()
+ knn_graph = kneighbors_graph(x_np, n_neighbors, metric=metric, mode=mode).toarray()
W = np.zeros((n, n))
knn_idx = np.logical_or(knn_graph, knn_graph.T)
if mode == "distance":
@@ -44,7 +44,7 @@ def lap_norm(X, n_neighbour=3, metric="cosine", mode="distance", normalise=True)
W[knn_idx] = 1
D = np.diag(np.sum(W, axis=1))
- if normalise:
+ if normalize:
D_ = inv(sqrtm(D))
lap_mat = np.eye(n) - multi_dot([D_, W, D_])
else:
diff --git a/tests/estimator/test_estimator.py b/tests/estimator/test_estimator.py
index cd994dd..342667a 100644
--- a/tests/estimator/test_estimator.py
+++ b/tests/estimator/test_estimator.py
@@ -7,7 +7,7 @@
@pytest.fixture
def office_test_data():
- x = np.array(
+ X = np.array(
[
[-2.2, -1.9],
[-1.9, -2.1],
@@ -24,62 +24,62 @@ def office_test_data():
y = np.array([0, 0, 0, 1, 1, 1, 0, 0, 1, 1])
z = np.array([1, 1, 1, 1, 1, 1, 0, 0, 0, 0])
covariate_mat = np.eye(2)[z]
- return x, y, z, covariate_mat
+ return X, y, z, covariate_mat
def _split_source_target(office_test_data, target_domain=0):
- x, y, z, covariate_mat = office_test_data
+ X, y, z, covariate_mat = office_test_data
tgt_idx = np.where(z == target_domain)
src_idx = np.where(z != target_domain)
- x_train = np.concatenate((x[src_idx], x[tgt_idx]))
+ X_train = np.concatenate((X[src_idx], X[tgt_idx]))
c_train = np.concatenate((covariate_mat[src_idx], covariate_mat[tgt_idx]))
y_train = y[src_idx]
- return x, y, tgt_idx, src_idx, x_train, c_train, y_train
+ return X, y, tgt_idx, src_idx, X_train, c_train, y_train
def test_coir_svm_solvers_fit_consistently(office_test_data):
- x, y, tgt_idx, src_idx, x_train, c_train, y_train = _split_source_target(office_test_data)
+ X, y, tgt_idx, src_idx, X_train, c_train, y_train = _split_source_target(office_test_data)
clf1 = estimator.CoIRSVM()
clf2 = estimator.CoIRSVM(solver="cvxopt")
- clf1.fit(x_train, y_train, c_train)
- clf2.fit(x_train, y_train, c_train)
+ clf1.fit(X_train, y_train, c_train)
+ clf2.fit(X_train, y_train, c_train)
for clf in (clf1, clf2):
- y_pred = clf.predict(x[tgt_idx])
- assert clf.coef_.shape[0] == x_train.shape[0]
+ y_pred = clf.predict(X[tgt_idx])
+ assert clf.coef_.shape[0] == X_train.shape[0]
assert np.isfinite(clf.coef_).all()
assert set(np.unique(y_pred)).issubset(set(np.unique(y)))
def test_coir_svm_none_covariates_matches_zero_covariates(office_test_data):
- x, _, tgt_idx, _, x_train, _, y_train = _split_source_target(office_test_data)
- zero_covariates = np.zeros((x_train.shape[0], 1))
+ X, _, tgt_idx, _, X_train, _, y_train = _split_source_target(office_test_data)
+ zero_covariates = np.zeros((X_train.shape[0], 1))
- clf_none = estimator.CoIRSVM().fit(x_train, y_train, covariates=None)
- clf_zero = estimator.CoIRSVM().fit(x_train, y_train, covariates=zero_covariates)
+ clf_none = estimator.CoIRSVM().fit(X_train, y_train, covariates=None)
+ clf_zero = estimator.CoIRSVM().fit(X_train, y_train, covariates=zero_covariates)
- dec_none = clf_none.decision_function(x[tgt_idx])
- dec_zero = clf_zero.decision_function(x[tgt_idx])
- pred_none = clf_none.predict(x[tgt_idx])
- pred_zero = clf_zero.predict(x[tgt_idx])
+ dec_none = clf_none.decision_function(X[tgt_idx])
+ dec_zero = clf_zero.decision_function(X[tgt_idx])
+ pred_none = clf_none.predict(X[tgt_idx])
+ pred_zero = clf_zero.predict(X[tgt_idx])
assert np.allclose(dec_none, dec_zero)
assert np.array_equal(pred_none, pred_zero)
def test_coir_ls_none_covariates_matches_zero_covariates(office_test_data):
- x, _, tgt_idx, _, x_train, _, y_train = _split_source_target(office_test_data)
- zero_covariates = np.zeros((x_train.shape[0], 1))
+ X, _, tgt_idx, _, X_train, _, y_train = _split_source_target(office_test_data)
+ zero_covariates = np.zeros((X_train.shape[0], 1))
- clf_none = estimator.CoIRLS().fit(x_train, y_train, covariates=None)
- clf_zero = estimator.CoIRLS().fit(x_train, y_train, covariates=zero_covariates)
+ clf_none = estimator.CoIRLS().fit(X_train, y_train, covariates=None)
+ clf_zero = estimator.CoIRLS().fit(X_train, y_train, covariates=zero_covariates)
- dec_none = clf_none.decision_function(x[tgt_idx])
- dec_zero = clf_zero.decision_function(x[tgt_idx])
- pred_none = clf_none.predict(x[tgt_idx])
- pred_zero = clf_zero.predict(x[tgt_idx])
+ dec_none = clf_none.decision_function(X[tgt_idx])
+ dec_zero = clf_zero.decision_function(X[tgt_idx])
+ pred_none = clf_none.predict(X[tgt_idx])
+ pred_zero = clf_zero.predict(X[tgt_idx])
assert np.allclose(dec_none, dec_zero)
assert np.array_equal(pred_none, pred_zero)
@@ -87,13 +87,13 @@ def test_coir_ls_none_covariates_matches_zero_covariates(office_test_data):
@pytest.mark.parametrize("estimator_cls", [estimator.CoIRSVM, estimator.CoIRLS])
def test_coir_estimators_predict_labels(estimator_cls, office_test_data):
- x, y, tgt_idx, src_idx, x_train, c_train, y_train = _split_source_target(office_test_data)
+ X, y, tgt_idx, src_idx, X_train, c_train, y_train = _split_source_target(office_test_data)
clf = estimator_cls()
- clf.fit(x_train, y_train, c_train)
+ clf.fit(X_train, y_train, c_train)
- decision = clf.decision_function(x[tgt_idx])
- y_pred = clf.predict(x[tgt_idx])
+ decision = clf.decision_function(X[tgt_idx])
+ y_pred = clf.predict(X[tgt_idx])
acc = accuracy_score(y[tgt_idx], y_pred)
assert decision.shape[0] == len(tgt_idx[0])
@@ -103,33 +103,33 @@ def test_coir_estimators_predict_labels(estimator_cls, office_test_data):
@pytest.mark.parametrize("estimator_cls", [estimator.CoIRSVM, estimator.CoIRLS])
def test_coir_covariate_encoder_accepts_string_covariates(estimator_cls, office_test_data):
- x, y, z, _ = office_test_data
+ X, y, z, _ = office_test_data
tgt_idx = np.where(z == 0)
src_idx = np.where(z != 0)
- x_train = np.concatenate((x[src_idx], x[tgt_idx]))
+ X_train = np.concatenate((X[src_idx], X[tgt_idx]))
z_train = np.concatenate((z[src_idx], z[tgt_idx]))
y_train = y[src_idx]
string_covariates = np.where(z_train == 0, "target", "source")
numeric_covariates = np.eye(2)[z_train]
- string_clf = estimator_cls(covariate_encoder="onehot").fit(x_train, y_train, string_covariates)
- numeric_clf = estimator_cls().fit(x_train, y_train, numeric_covariates)
+ string_clf = estimator_cls(covariate_encoder="onehot").fit(X_train, y_train, string_covariates)
+ numeric_clf = estimator_cls().fit(X_train, y_train, numeric_covariates)
assert string_clf.covariate_encoder_ is not None
- assert np.allclose(string_clf.decision_function(x[tgt_idx]), numeric_clf.decision_function(x[tgt_idx]))
+ assert np.allclose(string_clf.decision_function(X[tgt_idx]), numeric_clf.decision_function(X[tgt_idx]))
@pytest.mark.parametrize("estimator_cls", [estimator.ARSVM, estimator.ARRLS])
def test_artl_estimators_predict_labels(estimator_cls, office_test_data):
- x, y, z, _ = office_test_data
+ X, y, z, _ = office_test_data
tgt_idx = np.where(z == 0)
src_idx = np.where(z != 0)
clf = estimator_cls()
- clf.fit(x, y[src_idx], covariates=z, target_covariate=0)
+ clf.fit(X, y[src_idx], covariates=z, target_covariate=0)
- decision = clf.decision_function(x[tgt_idx])
- y_pred = clf.predict(x[tgt_idx])
+ decision = clf.decision_function(X[tgt_idx])
+ y_pred = clf.predict(X[tgt_idx])
acc = accuracy_score(y[tgt_idx], y_pred)
assert decision.shape[0] == len(tgt_idx[0])
@@ -139,36 +139,36 @@ def test_artl_estimators_predict_labels(estimator_cls, office_test_data):
@pytest.mark.parametrize("estimator_cls", [estimator.ARSVM, estimator.ARRLS])
def test_artl_covariate_api_accepts_source_only_or_full_labels(estimator_cls, office_test_data):
- x, y, z, _ = office_test_data
+ X, y, z, _ = office_test_data
tgt_idx = np.where(z == 0)
src_idx = np.where(z != 0)
- covariate = estimator_cls().fit(x, y[src_idx], covariates=z, target_covariate=0)
+ covariate = estimator_cls().fit(X, y[src_idx], covariates=z, target_covariate=0)
full_y = y.copy()
full_y[tgt_idx] = -1
- covariate_full_y = estimator_cls().fit(x, full_y, covariates=z, target_covariate=0, unlabeled_value=-1)
+ covariate_full_y = estimator_cls().fit(X, full_y, covariates=z, target_covariate=0, unlabeled_value=-1)
- assert np.allclose(covariate.decision_function(x[tgt_idx]), covariate_full_y.decision_function(x[tgt_idx]))
- assert np.array_equal(covariate.predict(x[tgt_idx]), covariate_full_y.predict(x[tgt_idx]))
+ assert np.allclose(covariate.decision_function(X[tgt_idx]), covariate_full_y.decision_function(X[tgt_idx]))
+ assert np.array_equal(covariate.predict(X[tgt_idx]), covariate_full_y.predict(X[tgt_idx]))
@pytest.mark.parametrize("estimator_cls", [estimator.ARSVM, estimator.ARRLS])
def test_artl_rejects_legacy_target_keyword(estimator_cls, office_test_data):
- x, y, z, _ = office_test_data
+ X, y, z, _ = office_test_data
tgt_idx = np.where(z == 0)
src_idx = np.where(z != 0)
with pytest.raises(TypeError, match="unexpected keyword argument 'Xt'"):
- estimator_cls().fit(x[src_idx], y[src_idx], Xt=x[tgt_idx])
+ estimator_cls().fit(X[src_idx], y[src_idx], Xt=X[tgt_idx])
@pytest.mark.parametrize("estimator_cls", [estimator.ARSVM, estimator.ARRLS])
def test_artl_covariate_fit_predict_returns_target_labels(estimator_cls, office_test_data):
- x, y, z, _ = office_test_data
+ X, y, z, _ = office_test_data
tgt_idx = np.where(z == 0)
src_idx = np.where(z != 0)
- y_pred = estimator_cls().fit_predict(x, y[src_idx], covariates=z, target_covariate=0)
+ y_pred = estimator_cls().fit_predict(X, y[src_idx], covariates=z, target_covariate=0)
assert y_pred.shape == y[tgt_idx].shape
assert set(np.unique(y_pred)).issubset(set(np.unique(y)))
@@ -176,13 +176,13 @@ def test_artl_covariate_fit_predict_returns_target_labels(estimator_cls, office_
@pytest.mark.parametrize("estimator_cls", [estimator.LapSVM, estimator.LapRLS])
def test_manifold_estimators_predict_labels(estimator_cls, office_test_data):
- x, y, tgt_idx, src_idx, x_train, _, y_train = _split_source_target(office_test_data)
+ X, y, tgt_idx, src_idx, X_train, _, y_train = _split_source_target(office_test_data)
clf = estimator_cls()
- clf.fit(x_train, y_train)
+ clf.fit(X_train, y_train)
- decision = clf.decision_function(x[tgt_idx])
- y_pred = clf.predict(x[tgt_idx])
+ decision = clf.decision_function(X[tgt_idx])
+ y_pred = clf.predict(X[tgt_idx])
acc = accuracy_score(y[tgt_idx], y_pred)
assert decision.shape[0] == len(tgt_idx[0])
@@ -191,18 +191,18 @@ def test_manifold_estimators_predict_labels(estimator_cls, office_test_data):
def test_gsda_fit_predicts_target_labels(office_test_data):
- x, y, z, covariate_mat = office_test_data
+ X, y, z, covariate_mat = office_test_data
target_idx = np.where(z == 0)[0]
clf = estimator.GSDA(max_iter=25, random_state=0)
- clf.fit(x, y[target_idx], covariate_mat, target_idx=target_idx)
+ clf.fit(X, y[target_idx], covariate_mat, target_idx=target_idx)
- y_proba = clf.predict_proba(x[target_idx])
- y_pred = clf.predict(x[target_idx])
+ y_proba = clf.predict_proba(X[target_idx])
+ y_pred = clf.predict(X[target_idx])
params = clf.get_fitted_params()
- assert clf.coef_.shape == (x.shape[1],)
- assert params["coef"].shape == (x.shape[1],)
+ assert clf.coef_.shape == (X.shape[1],)
+ assert params["coef"].shape == (X.shape[1],)
assert np.isfinite(clf.coef_).all()
assert np.isfinite(clf.intercept_)
assert y_proba.shape == y[target_idx].shape
@@ -214,14 +214,14 @@ def test_gsda_fit_predicts_target_labels(office_test_data):
def test_gsda_covariate_encoder_accepts_string_groups(office_test_data):
- x, y, z, _ = office_test_data
+ X, y, z, _ = office_test_data
target_idx = np.where(z == 0)[0]
string_groups = np.where(z == 0, "target", "source")
clf = estimator.GSDA(max_iter=25, random_state=0, covariate_encoder="onehot")
- clf.fit(x, y[target_idx], string_groups, target_idx=target_idx)
+ clf.fit(X, y[target_idx], string_groups, target_idx=target_idx)
- y_pred = clf.predict(x[target_idx])
+ y_pred = clf.predict(X[target_idx])
assert clf.covariate_encoder_ is not None
assert y_pred.shape == y[target_idx].shape
diff --git a/tests/transformer/test_mida.py b/tests/transformer/test_mida.py
index 276c429..4da2cd3 100644
--- a/tests/transformer/test_mida.py
+++ b/tests/transformer/test_mida.py
@@ -13,7 +13,7 @@
def sample_data():
# Test an extreme case of domain shift
# yet the data's manifold is linearly separable
- x, y, domains = make_domain_shifted_dataset(
+ X, y, domains = make_domain_shifted_dataset(
num_domains=10,
num_samples_per_class=2,
num_features=20,
@@ -23,63 +23,63 @@ def sample_data():
covariates = OneHotEncoder(handle_unknown="ignore").fit_transform(domains.reshape(-1, 1)).toarray()
- return x, y, domains, covariates
+ return X, y, domains, covariates
@pytest.mark.parametrize("num_components", [2, None])
def test_mida_shape_consistency(sample_data, num_components):
- x, y, domains, covariates = sample_data
+ X, y, domains, covariates = sample_data
mida = MIDA(n_components=num_components)
mida.set_params(**mida.get_params())
- mida.fit(x, covariates=covariates)
+ mida.fit(X, covariates=covariates)
# Transform the whole data
- z = mida.transform(x, covariates=covariates)
+ z = mida.transform(X, covariates=covariates)
# If num_components is not None, check the shape of the transformed data
if num_components is not None:
- testing.assert_equal(z.shape, (len(x), num_components))
+ testing.assert_equal(z.shape, (len(X), num_components))
# Transform the source and target domain data separately
source_mask = domains != 0 # return True/false mask instead of indices
- z_src = mida.transform(x[source_mask], covariates=covariates[source_mask])
- z_tgt = mida.transform(x[~source_mask], covariates=covariates[~source_mask])
+ z_src = mida.transform(X[source_mask], covariates=covariates[source_mask])
+ z_tgt = mida.transform(X[~source_mask], covariates=covariates[~source_mask])
# Check if transformations are consistent with separate domains
testing.assert_allclose(z_src, z[source_mask])
testing.assert_allclose(z_tgt, z[~source_mask])
orig_coef_dim = mida.orig_coef_.shape[1]
- feature_dim = x.shape[1]
+ feature_dim = X.shape[1]
assert mida.orig_coef_ is not None, "MIDA must have `orig_coef_` after fitting when kernel='linear'"
assert orig_coef_dim == feature_dim, f"orig_coef_ shape mismatch: {orig_coef_dim} != {feature_dim}"
def test_mida_inverse_transform(sample_data):
- x, y, domains, covariates = sample_data
+ X, y, domains, covariates = sample_data
mida = MIDA(fit_inverse_transform=True)
- mida.fit(x, covariates=covariates)
+ mida.fit(X, covariates=covariates)
# Transform the whole data
- z = mida.transform(x, covariates=covariates)
+ z = mida.transform(X, covariates=covariates)
# Inverse transform the data
- x_rec = mida.inverse_transform(z)
+ X_rec = mida.inverse_transform(z)
# We don't check whether the inverse transform is exactly equal to the original data
# in terms of value since it is expected to be different due to the domain adaptation effect.
# We only check the shape and dimensionality.
- assert len(x_rec) == len(x), f"Inverse transform failed: {len(x_rec)} != {len(x)}"
- assert x_rec.ndim == x.ndim, f"Inverse transform failed: {x_rec.ndim} != {x.ndim}"
- testing.assert_equal(x_rec.shape, x.shape)
+ assert len(X_rec) == len(X), f"Inverse transform failed: {len(X_rec)} != {len(X)}"
+ assert X_rec.ndim == X.ndim, f"Inverse transform failed: {X_rec.ndim} != {X.ndim}"
+ testing.assert_equal(X_rec.shape, X.shape)
@pytest.mark.parametrize("kernel", ["linear", "rbf", "cosine"])
def test_mida_support_kernel(sample_data, kernel):
- x, y, domains, covariates = sample_data
+ X, y, domains, covariates = sample_data
mida = MIDA(n_components=N_COMP_CONSTANT, kernel=kernel)
- mida.fit(x, covariates=covariates)
+ mida.fit(X, covariates=covariates)
is_linear = kernel == "linear"
try:
@@ -89,7 +89,7 @@ def test_mida_support_kernel(sample_data, kernel):
assert not is_linear, "MIDA must not have `orig_coef_` after fitting when kernel!='linear'"
# Transform the whole data
- z = mida.transform(x, covariates=covariates)
+ z = mida.transform(X, covariates=covariates)
# expect to allow multiple kernels supported
assert mida._n_features_out == N_COMP_CONSTANT, f"Expected {N_COMP_CONSTANT} components, got {mida._n_features_out}"
@@ -98,10 +98,10 @@ def test_mida_support_kernel(sample_data, kernel):
@pytest.mark.parametrize("augment", ["pre", "post", None])
def test_mida_augment(sample_data, augment):
- x, y, domains, covariates = sample_data
+ X, y, domains, covariates = sample_data
mida = MIDA(n_components=N_COMP_CONSTANT, kernel="linear", augment=augment, fit_inverse_transform=True)
- mida.fit(x, covariates=covariates)
+ mida.fit(X, covariates=covariates)
# expect validator for domain covariates if augment=True
has_validator = hasattr(mida, "_factor_validator")
@@ -111,18 +111,18 @@ def test_mida_augment(sample_data, augment):
assert not has_validator, "MIDA must not have `_factor_validator` after fitting when augment=False"
if augment == "post":
- z = mida.transform(x, covariates=covariates)
+ z = mida.transform(X, covariates=covariates)
assert (
z.shape[1] == N_COMP_CONSTANT + covariates.shape[-1]
- ), f"Expected {x.shape[1]} features, got {N_COMP_CONSTANT + covariates.shape[-1]}"
+ ), f"Expected {X.shape[1]} features, got {N_COMP_CONSTANT + covariates.shape[-1]}"
@pytest.mark.parametrize("ignore_y", [True, False])
def test_mida_ignore_y(sample_data, ignore_y):
- x, y, domains, covariates = sample_data
+ X, y, domains, covariates = sample_data
mida = MIDA(n_components=N_COMP_CONSTANT, kernel="linear", ignore_y=ignore_y)
- mida.fit(x, y, covariates=covariates)
+ mida.fit(X, y, covariates=covariates)
# expect classes_ to be set if ignore_y=False
has_classes = hasattr(mida, "classes_")
@@ -134,48 +134,48 @@ def test_mida_ignore_y(sample_data, ignore_y):
def test_mida_covariate_encoder_onehot(sample_data):
- x, y, domains, _ = sample_data
+ X, y, domains, _ = sample_data
mida = MIDA(n_components=2, covariate_encoder="onehot")
- z = mida.fit_transform(x, y=y, covariates=domains)
+ z = mida.fit_transform(X, y=y, covariates=domains)
- assert z.shape == (len(x), 2)
+ assert z.shape == (len(X), 2)
assert hasattr(mida, "covariate_encoder_")
assert mida.covariate_encoder_ is not None
- testing.assert_equal(mida.covariates_fit_.shape[0], len(x))
+ testing.assert_equal(mida.covariates_fit_.shape[0], len(X))
def test_mida_covariate_encoder_onehot_accepts_strings(sample_data):
- x, y, domains, _ = sample_data
+ X, y, domains, _ = sample_data
string_domains = np.asarray([f"domain-{domain}" for domain in domains])
mida = MIDA(n_components=2, covariate_encoder="onehot")
- z = mida.fit_transform(x, y=y, covariates=string_domains)
+ z = mida.fit_transform(X, y=y, covariates=string_domains)
- assert z.shape == (len(x), 2)
+ assert z.shape == (len(X), 2)
assert np.issubdtype(mida.covariates_fit_.dtype, np.number)
def test_mida_requires_numeric_covariates_without_encoder(sample_data):
- x, y, domains, _ = sample_data
+ X, y, domains, _ = sample_data
with pytest.raises(ValueError, match="covariate_encoder"):
- MIDA(n_components=2).fit(x, y=y, covariates=domains.astype(str))
+ MIDA(n_components=2).fit(X, y=y, covariates=domains.astype(str))
def test_mida_transform_rejects_mismatched_covariate_dimension(sample_data):
- x, _, _, covariates = sample_data
+ X, _, _, covariates = sample_data
- mida = MIDA(n_components=2, augment="pre").fit(x, covariates=covariates)
+ mida = MIDA(n_components=2, augment="pre").fit(X, covariates=covariates)
- mismatched_covariates = np.hstack((covariates, np.zeros((len(x), 1))))
+ mismatched_covariates = np.hstack((covariates, np.zeros((len(X), 1))))
with pytest.raises(ValueError, match="feature dimension"):
- mida.transform(x, covariates=mismatched_covariates)
+ mida.transform(X, covariates=mismatched_covariates)
@pytest.mark.parametrize("eigen_solver", ["auto", "dense", "arpack", "randomized"])
def test_mida_eigen_solver(sample_data, eigen_solver):
- x, y, domains, covariates = sample_data
+ X, y, domains, covariates = sample_data
mida = MIDA(
n_components=N_COMP_CONSTANT,
@@ -183,27 +183,27 @@ def test_mida_eigen_solver(sample_data, eigen_solver):
eigen_solver=eigen_solver,
max_iter=200 if eigen_solver == "arpack" else None,
)
- mida.fit(x, covariates=covariates)
+ mida.fit(X, covariates=covariates)
# Transform the whole data
- z = mida.transform(x, covariates=covariates)
+ z = mida.transform(X, covariates=covariates)
# expect the solver to have a consistent number of components
assert mida._n_features_out == N_COMP_CONSTANT, f"Expected {N_COMP_CONSTANT} components, got {mida._n_features_out}"
- assert z.shape[1] == mida._n_features_out, f"Expected {x.shape[1]} features, got {mida._n_features_out}"
+ assert z.shape[1] == mida._n_features_out, f"Expected {X.shape[1]} features, got {mida._n_features_out}"
@pytest.mark.parametrize("scale_components", [True, False])
def test_mida_scale_components(sample_data, scale_components):
- x, y, domains, covariates = sample_data
+ X, y, domains, covariates = sample_data
mida = MIDA(n_components=N_COMP_CONSTANT, kernel="linear", scale_components=scale_components)
- mida.fit(x, covariates=covariates)
+ mida.fit(X, covariates=covariates)
# Transform the whole data
- z = mida.transform(x, covariates=covariates)
+ z = mida.transform(X, covariates=covariates)
# Expect the scale_components to have a consistent number of components
# the behavior expected is the zero eigenvalues component is masked, not indexed
assert mida._n_features_out == N_COMP_CONSTANT, f"Expected {N_COMP_CONSTANT} components, got {mida._n_features_out}"
- assert z.shape[1] == mida._n_features_out, f"Expected {x.shape[1]} features, got {mida._n_features_out}"
+ assert z.shape[1] == mida._n_features_out, f"Expected {X.shape[1]} features, got {mida._n_features_out}"
diff --git a/tests/transformer/test_mpca.py b/tests/transformer/test_mpca.py
index 50c9849..57ee32e 100644
--- a/tests/transformer/test_mpca.py
+++ b/tests/transformer/test_mpca.py
@@ -10,7 +10,7 @@
N_COMPS = [1, 50, 100]
VAR_RATIOS = [0.7, 0.95]
-relative_tol = 0.00001
+RELATIVE_TOL = 0.00001
@pytest.fixture(scope="module")
@@ -33,56 +33,56 @@ def baseline_model(download_path):
@pytest.mark.parametrize("explained_variance_ratio", VAR_RATIOS)
def test_mpca(explained_variance_ratio, n_components, gait):
# basic mpca test, return tensor
- x = gait["fea3D"].transpose((3, 0, 1, 2))
+ X = gait["fea3D"].transpose((3, 0, 1, 2))
mpca = MPCA(explained_variance_ratio=explained_variance_ratio, vectorize=False)
- x_proj = mpca.fit(x).transform(x)
+ X_proj = mpca.fit(X).transform(X)
- testing.assert_equal(x_proj.ndim, x.ndim)
- testing.assert_equal(x_proj.shape[0], x.shape[0])
- for i in range(1, x.ndim):
- assert x_proj.shape[i] <= x.shape[i]
- testing.assert_equal(mpca.proj_mats[i - 1].shape[1], x.shape[i])
+ testing.assert_equal(X_proj.ndim, X.ndim)
+ testing.assert_equal(X_proj.shape[0], X.shape[0])
+ for i in range(1, X.ndim):
+ assert X_proj.shape[i] <= X.shape[i]
+ testing.assert_equal(mpca.proj_mats_[i - 1].shape[1], X.shape[i])
- x_rec = mpca.inverse_transform(x_proj)
- testing.assert_equal(x_rec.shape, x.shape)
+ X_rec = mpca.inverse_transform(X_proj)
+ testing.assert_equal(X_rec.shape, X.shape)
# test return vector
mpca.set_params(**{"vectorize": True, "n_components": n_components})
- x_proj = mpca.transform(x)
- testing.assert_equal(x_proj.ndim, 2)
- testing.assert_equal(x_proj.shape[0], x.shape[0])
- testing.assert_equal(x_proj.shape[1], n_components)
- x_rec = mpca.inverse_transform(x_proj)
- testing.assert_equal(x_rec.shape, x.shape)
+ X_proj = mpca.transform(X)
+ testing.assert_equal(X_proj.ndim, 2)
+ testing.assert_equal(X_proj.shape[0], X.shape[0])
+ testing.assert_equal(X_proj.shape[1], n_components)
+ X_rec = mpca.inverse_transform(X_proj)
+ testing.assert_equal(X_rec.shape, X.shape)
# test n_samples = 1
- x0_proj = mpca.transform(x[0])
- testing.assert_equal(x0_proj.ndim, 2)
- testing.assert_equal(x0_proj.shape[0], 1)
- testing.assert_equal(x0_proj.shape[1], n_components)
- x0_rec = mpca.inverse_transform(x0_proj.reshape(-1))
- testing.assert_equal(x0_rec.shape[1:], x[0].shape)
+ X0_proj = mpca.transform(X[0])
+ testing.assert_equal(X0_proj.ndim, 2)
+ testing.assert_equal(X0_proj.shape[0], 1)
+ testing.assert_equal(X0_proj.shape[1], n_components)
+ X0_rec = mpca.inverse_transform(X0_proj.reshape(-1))
+ testing.assert_equal(X0_rec.shape[1:], X[0].shape)
# test n_components exceeds upper limit
- mpca.set_params(**{"vectorize": True, "n_components": np.prod(x.shape[1:]) + 1})
- x_proj = mpca.transform(x)
- testing.assert_equal(x_proj.shape[1], np.prod(mpca.shape_out))
+ mpca.set_params(**{"vectorize": True, "n_components": np.prod(X.shape[1:]) + 1})
+ X_proj = mpca.transform(X)
+ testing.assert_equal(X_proj.shape[1], np.prod(mpca.output_shape_))
def test_mpca_against_baseline(gait, baseline_model):
- x = gait["fea3D"].transpose((3, 0, 1, 2))
+ X = gait["fea3D"].transpose((3, 0, 1, 2))
baseline_proj_mats = [baseline_model["tUs"][i][0] for i in range(baseline_model["tUs"].size)]
baseline_mean = baseline_model["TXmean"]
mpca = MPCA(explained_variance_ratio=0.97)
- x_proj = mpca.fit(x).transform(x)
+ X_proj = mpca.fit(X).transform(X)
testing.assert_allclose(baseline_mean, mpca.mean_)
- baseline_proj_x = multi_mode_dot(x - baseline_mean, baseline_proj_mats, modes=[1, 2, 3])
+ baseline_proj_X = multi_mode_dot(X - baseline_mean, baseline_proj_mats, modes=[1, 2, 3])
# check whether the output embeddings is close to the baseline output by keeping the same variance ratio 97%
- testing.assert_allclose(x_proj**2, baseline_proj_x**2, rtol=relative_tol)
- # testing.assert_equal(x_proj.shape, baseline_proj_x.shape)
+ testing.assert_allclose(X_proj**2, baseline_proj_X**2, rtol=RELATIVE_TOL)
+ # testing.assert_equal(X_proj.shape, baseline_proj_X.shape)
- for i in range(x.ndim - 1):
+ for i in range(X.ndim - 1):
# check whether each eigen-vector column is equal to/opposite of corresponding baseline eigen-vector column
- # testing.assert_allclose(abs(mpca.proj_mats[i]), abs(baseline_proj_mats[i]))
- testing.assert_allclose(mpca.proj_mats[i] ** 2, baseline_proj_mats[i] ** 2, rtol=relative_tol)
+ # testing.assert_allclose(abs(mpca.proj_mats_[i]), abs(baseline_proj_mats[i]))
+ testing.assert_allclose(mpca.proj_mats_[i] ** 2, baseline_proj_mats[i] ** 2, rtol=RELATIVE_TOL)
diff --git a/tests/transformer/test_transformer.py b/tests/transformer/test_transformer.py
index 73accb2..4cb33be 100644
--- a/tests/transformer/test_transformer.py
+++ b/tests/transformer/test_transformer.py
@@ -7,7 +7,7 @@
@pytest.fixture
def domain_adaptation_data():
- xs = np.array(
+ Xs = np.array(
[
[-2.0, -1.8],
[-1.8, -2.1],
@@ -16,7 +16,7 @@ def domain_adaptation_data():
]
)
ys = np.array([0, 0, 1, 1])
- xt = np.array(
+ Xt = np.array(
[
[-1.4, -1.2],
[-1.2, -1.1],
@@ -25,7 +25,7 @@ def domain_adaptation_data():
]
)
yt = np.array([0, 0, 1, 1])
- x = np.vstack((xs, xt))
+ X = np.vstack((Xs, Xt))
y = np.concatenate((ys, yt))
binary_covariates = np.array([0, 0, 0, 0, 1, 1, 1, 1])
covariates = np.array(
@@ -40,54 +40,54 @@ def domain_adaptation_data():
[0.0, 1.0],
]
)
- return xs, ys, xt, yt, x, y, binary_covariates, covariates
+ return Xs, ys, Xt, yt, X, y, binary_covariates, covariates
@pytest.mark.parametrize("transformer_cls", [TCA, JDA, BDA])
def test_domain_transformers_fit_transform_shapes(transformer_cls, domain_adaptation_data):
- xs, ys, xt, yt, x, y, binary_covariates, _ = domain_adaptation_data
+ Xs, ys, Xt, yt, X, y, binary_covariates, _ = domain_adaptation_data
transformer = transformer_cls(n_components=2)
- x_transformed = transformer.fit_transform(x, y=y, covariates=binary_covariates, target_covariate=1)
- xs_transformed = x_transformed[binary_covariates == 0]
- xt_transformed = x_transformed[binary_covariates == 1]
+ X_transformed = transformer.fit_transform(X, y=y, covariates=binary_covariates, target_covariate=1)
+ Xs_transformed = X_transformed[binary_covariates == 0]
+ Xt_transformed = X_transformed[binary_covariates == 1]
- assert xs_transformed.shape == (xs.shape[0], 2)
- assert xt_transformed.shape == (xt.shape[0], 2)
- assert np.isfinite(xs_transformed).all()
- assert np.isfinite(xt_transformed).all()
+ assert Xs_transformed.shape == (Xs.shape[0], 2)
+ assert Xt_transformed.shape == (Xt.shape[0], 2)
+ assert np.isfinite(Xs_transformed).all()
+ assert np.isfinite(Xt_transformed).all()
def test_mida_fit_transform_shapes_with_covariates(domain_adaptation_data):
- _, _, _, _, x, y, _, covariates = domain_adaptation_data
+ _, _, _, _, X, y, _, covariates = domain_adaptation_data
transformer = MIDA(n_components=2)
- x_transformed = transformer.fit_transform(x, y=y, covariates=covariates)
+ X_transformed = transformer.fit_transform(X, y=y, covariates=covariates)
- assert x_transformed.shape == (x.shape[0], 2)
- assert np.isfinite(x_transformed).all()
+ assert X_transformed.shape == (X.shape[0], 2)
+ assert np.isfinite(X_transformed).all()
def test_mida_transform_requires_fit(domain_adaptation_data):
- xs, _, _, _, _, _, _, _ = domain_adaptation_data
+ Xs, _, _, _, _, _, _, _ = domain_adaptation_data
transformer = MIDA(n_components=2)
with pytest.raises(NotFittedError):
- transformer.transform(xs)
+ transformer.transform(Xs)
def test_transformers_store_training_projection(domain_adaptation_data):
- xs, _, xt, _, x, y, binary_covariates, covariates = domain_adaptation_data
+ Xs, _, Xt, _, X, y, binary_covariates, covariates = domain_adaptation_data
- tca = TCA(n_components=2).fit(x, y=y, covariates=binary_covariates, target_covariate=1)
- jda = JDA(n_components=2).fit(x, y=y, covariates=binary_covariates, target_covariate=1)
- bda = BDA(n_components=2, mu=0.25).fit(x, y=y, covariates=binary_covariates, target_covariate=1)
- mida = MIDA(n_components=2).fit(x, y=y, covariates=covariates)
+ tca = TCA(n_components=2).fit(X, y=y, covariates=binary_covariates, target_covariate=1)
+ jda = JDA(n_components=2).fit(X, y=y, covariates=binary_covariates, target_covariate=1)
+ bda = BDA(n_components=2, mu=0.25).fit(X, y=y, covariates=binary_covariates, target_covariate=1)
+ mida = MIDA(n_components=2).fit(X, y=y, covariates=covariates)
- assert tca.U.shape[0] == xs.shape[0] + xt.shape[0]
- assert jda.U.shape[0] == xs.shape[0] + xt.shape[0]
- assert bda.U.shape[0] == xs.shape[0] + xt.shape[0]
- assert mida.U.shape[0] == xs.shape[0] + xt.shape[0]
+ assert tca.U.shape[0] == Xs.shape[0] + Xt.shape[0]
+ assert jda.U.shape[0] == Xs.shape[0] + Xt.shape[0]
+ assert bda.U.shape[0] == Xs.shape[0] + Xt.shape[0]
+ assert mida.U.shape[0] == Xs.shape[0] + Xt.shape[0]
@pytest.mark.parametrize("transformer_cls", [TCA, JDA, BDA])
@@ -100,18 +100,18 @@ def test_mmd_transformers_reject_covariate_encoder(transformer_cls, domain_adapt
@pytest.mark.parametrize("transformer_cls", [TCA, JDA, BDA])
def test_mmd_transformers_require_both_domains(transformer_cls, domain_adaptation_data):
- _, _, _, _, x, _, _, _ = domain_adaptation_data
+ _, _, _, _, X, _, _, _ = domain_adaptation_data
with pytest.raises(ValueError, match="both source and target"):
- transformer_cls(n_components=2).fit(x, covariates=np.zeros(x.shape[0], dtype=int))
+ transformer_cls(n_components=2).fit(X, covariates=np.zeros(X.shape[0], dtype=int))
@pytest.mark.parametrize("transformer_cls", [TCA, JDA, BDA])
def test_mmd_transformers_validate_target_covariate(transformer_cls, domain_adaptation_data):
- _, _, _, _, x, _, binary_covariates, _ = domain_adaptation_data
+ _, _, _, _, X, _, binary_covariates, _ = domain_adaptation_data
with pytest.raises(ValueError, match="target_covariate"):
- transformer_cls(n_components=2).fit(x, covariates=binary_covariates, target_covariate=2)
+ transformer_cls(n_components=2).fit(X, covariates=binary_covariates, target_covariate=2)
def test_jda_does_not_accept_mu():
@@ -121,7 +121,7 @@ def test_jda_does_not_accept_mu():
@pytest.mark.parametrize("mu", [-0.1, 1.1])
def test_bda_validates_mu(mu, domain_adaptation_data):
- _, _, _, _, x, y, binary_covariates, _ = domain_adaptation_data
+ _, _, _, _, X, y, binary_covariates, _ = domain_adaptation_data
with pytest.raises(ValueError, match="mu"):
- BDA(n_components=2, mu=mu).fit(x, y=y, covariates=binary_covariates, target_covariate=1)
+ BDA(n_components=2, mu=mu).fit(X, y=y, covariates=binary_covariates, target_covariate=1)
diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py
index c78318f..c1776a7 100644
--- a/tests/utils/test_utils.py
+++ b/tests/utils/test_utils.py
@@ -29,7 +29,7 @@ def test_base_init_returns_expected_shapes(sample_data):
@pytest.mark.parametrize("mode", ["distance", "connectivity"])
def test_lap_norm_returns_square_matrix(sample_data, mode):
- lap = lap_norm(sample_data, n_neighbour=2, mode=mode, normalise=False)
+ lap = lap_norm(sample_data, n_neighbors=2, mode=mode, normalize=False)
assert lap.shape == (4, 4)
assert np.allclose(lap, lap.T)
@@ -80,7 +80,7 @@ def make_domain_shifted_dataset(
w = random_state.randn(num_features)
w = w / np.linalg.norm(w)
- x_all = []
+ X_all = []
y_all = []
domain_all = []
@@ -90,21 +90,21 @@ def make_domain_shifted_dataset(
for label in [0, 1]:
class_mean = (label - 0.5) * class_sep * w + domain_shift
cov = np.eye(num_features)
- x_class = random_state.multivariate_normal(class_mean, cov, num_samples_per_class)
+ X_class = random_state.multivariate_normal(class_mean, cov, num_samples_per_class)
y_class = np.full(num_samples_per_class, label)
domain_class = np.full(num_samples_per_class, i_domain)
- x_all.append(x_class)
+ X_all.append(X_class)
y_all.append(y_class)
domain_all.append(domain_class)
- x = np.vstack(x_all)
+ X = np.vstack(X_all)
y = np.concatenate(y_all)
domains = np.concatenate(domain_all)
- idx = random_state.permutation(len(x))
- x = x[idx]
+ idx = random_state.permutation(len(X))
+ X = X[idx]
y = y[idx]
domains = domains[idx]
- return x, y, domains
+ return X, y, domains
From 32420bc86a22145e95a5c6ba42e3d554dd421410 Mon Sep 17 00:00:00 2001
From: shuo-zhou~
Date: Sun, 9 Aug 2026 17:27:47 +0100
Subject: [PATCH 06/11] update MPCA and MPCATrainer
---
.github/workflows/project.yml | 39 -----------------------------
docs/source/conf.py | 4 +--
docs/source/index.rst | 6 ++---
kalelinear/pipeline/mpca_trainer.py | 7 ++++--
kalelinear/transformer/_mpca.py | 17 ++++++++++---
tests/transformer/test_mpca.py | 31 +++++++++++++++++++++++
6 files changed, 55 insertions(+), 49 deletions(-)
delete mode 100644 .github/workflows/project.yml
diff --git a/.github/workflows/project.yml b/.github/workflows/project.yml
deleted file mode 100644
index 6553228..0000000
--- a/.github/workflows/project.yml
+++ /dev/null
@@ -1,39 +0,0 @@
-# This workflow will automatically add an issue or pull request to the project defined on lines 25 and 38.
-# The project needs to be updated when we move to a new project.
-# https://github.com/marketplace/actions/add-to-github-projects
-# The encrypted secret key "ADD_TO_PROJECT_PAT" has been created in accordance with the guidelines provided in https://docs.github.com/en/actions/security-guides/encrypted-secrets
-# In PRs, the action will only run if the PR is from pykale repository to avoid requesting secrets for forks.
-
-name: assign-project
-
-on:
- issues:
- types: [opened, labeled]
- pull_request:
- types: [opened, labeled]
-
-jobs:
- add-issue-to-project:
- name: Add issue to project
- if: |
- github.event_name == 'issues' &&
- github.event.action == 'opened'
- runs-on: ubuntu-latest
- steps:
- - uses: actions/add-to-project@v1.0.2
- with:
- project-url: https://github.com/orgs/pykale/projects/4
- github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
-
- add-pull-request-to-project:
- name: Add pull request to project
- if: |
- github.event_name == 'pull_request' &&
- github.event.action == 'opened' &&
- github.event.pull_request.head.repo.full_name == github.repository
- runs-on: ubuntu-latest
- steps:
- - uses: actions/add-to-project@v1.0.2
- with:
- project-url: https://github.com/orgs/pykale/projects/4
- github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 4abcaa7..0932ac4 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -1,4 +1,4 @@
-"""Sphinx configuration for Kale-Linear documentation."""
+"""Sphinx configuration for kalelinear documentation."""
from __future__ import annotations
@@ -8,7 +8,7 @@
sys.path.insert(0, os.path.abspath("../.."))
-project = "Kale-Linear"
+project = "kalelinear"
author = "The PyKale team"
copyright = f"{datetime.now().year}, {author}"
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 90ee5fd..343bc78 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -1,4 +1,4 @@
-Kale-Linear Documentation
+kalelinear Documentation
=========================
Getting Started
@@ -11,7 +11,7 @@ Getting Started
installation
tutorial
-Kale-Linear API
+kalelinear API
---------------
.. toctree::
@@ -23,7 +23,7 @@ Kale-Linear API
api_estimators
api_utilities
-Kale-Linear APIs above are ordered following the machine learning pipeline,
+kalelinear APIs above are ordered following the machine learning pipeline,
i.e., feature embedding transformers, predictive estimators, and reusable
utilities, rather than alphabetically.
diff --git a/kalelinear/pipeline/mpca_trainer.py b/kalelinear/pipeline/mpca_trainer.py
index 08d34d1..85882e5 100644
--- a/kalelinear/pipeline/mpca_trainer.py
+++ b/kalelinear/pipeline/mpca_trainer.py
@@ -57,7 +57,7 @@ class MPCATrainer(BaseEstimator, ClassifierMixin):
3. lr, {"C": [0.0001, 0.001, 0.01, 0.1, 1, 10, 100]}
mpca_params (dict, optional): Parameters of MPCA, e.g., {"explained_variance_ratio": 0.8}. Defaults to None,
i.e., using the default parameters
- (https://pykale.readthedocs.io/en/latest/kale.embed.html#module-kale.embed.mpca).
+ (https://kalelinear.readthedocs.io/en/latest/kalelinear.transformer.html#module-kalelinear.transformer.mpca).
n_features (int, optional): Number of features for feature selection. Defaults to None, i.e., all features
after dimension reduction will be used.
search_params (dict, optional): Parameters of grid search, for more detail please see
@@ -142,13 +142,16 @@ def fit(self, X, y):
# fit classifier
if self.auto_classifier_param:
- self.grid_search.param_grid["C"].append(1 / X.shape[0])
+ extra_c = 1 / X.shape[0]
+ if extra_c not in self.grid_search.param_grid["C"]:
+ self.grid_search.param_grid["C"].append(extra_c)
self.grid_search.fit(X_transformed, y)
self.clf = self.grid_search.best_estimator_
if self.classifier == "svc":
self.clf.set_params(**{"probability": True})
self.clf.fit(X_transformed, y)
+ return self
def predict(self, X):
"""Predict the labels for the given data X
diff --git a/kalelinear/transformer/_mpca.py b/kalelinear/transformer/_mpca.py
index cf5d7f8..1c1f8a2 100644
--- a/kalelinear/transformer/_mpca.py
+++ b/kalelinear/transformer/_mpca.py
@@ -158,7 +158,7 @@ class MPCA(BaseEstimator, TransformerMixin):
>>> X_projected = mpca.transform(X)
>>> X_projected.shape
(40, 7452)
- >>> X_projected = mpca.transform(X)
+ >>> X_projected = mpca.transform(X, vectorize=True)
>>> X_projected.shape
(40, 50)
>>> X_reconstructed = mpca.inverse_transform(X_projected)
@@ -226,6 +226,11 @@ def _fit(self, X):
n_samples = shape_[0]
n_dims = X.ndim
+ if n_samples <= 0:
+ error_msg = "MPCA requires at least 1 sample to fit."
+ logging.error(error_msg)
+ raise ValueError(error_msg)
+
self.input_shape_ = shape_[1:]
# Samples are processed in chunks so that a centered copy of the full
@@ -338,13 +343,17 @@ def _fit(self, X):
return self
- def transform(self, X):
+ def transform(self, X, vectorize=None):
"""Project data to the MPCA subspace.
Parameters
----------
X : ndarray of shape (n_samples, I_1, ..., I_N) or (I_1, ..., I_N)
Input tensor data.
+ vectorize : bool, default=None
+ Whether to return the projected data as vectors. If ``None``
+ (default), the value set in ``__init__`` (``self.vectorize``) is
+ used.
Returns
-------
@@ -353,6 +362,8 @@ def transform(self, X):
``vectorize=False``. Otherwise returns vectorized features with
optional truncation to ``n_components``.
"""
+ if vectorize is None:
+ vectorize = self.vectorize
# reshape X to shape (1, I_1, I_2, ..., I_N) if X in shape (I_1, I_2, ..., I_N), i.e. n_samples = 1
if X.ndim == self.n_dims_ - 1:
X = X.reshape((1,) + X.shape)
@@ -363,7 +374,7 @@ def transform(self, X):
X_projected = multi_mode_dot(X, self.proj_mats_, modes=[m for m in range(1, self.n_dims_)])
n_components = self.n_components
- if self.vectorize:
+ if vectorize:
X_projected = unfold(X_projected, mode=0)
X_projected = X_projected[:, self.idx_order_]
if isinstance(n_components, int):
diff --git a/tests/transformer/test_mpca.py b/tests/transformer/test_mpca.py
index 57ee32e..6c74474 100644
--- a/tests/transformer/test_mpca.py
+++ b/tests/transformer/test_mpca.py
@@ -86,3 +86,34 @@ def test_mpca_against_baseline(gait, baseline_model):
# check whether each eigen-vector column is equal to/opposite of corresponding baseline eigen-vector column
# testing.assert_allclose(abs(mpca.proj_mats_[i]), abs(baseline_proj_mats[i]))
testing.assert_allclose(mpca.proj_mats_[i] ** 2, baseline_proj_mats[i] ** 2, rtol=RELATIVE_TOL)
+
+
+def test_transform_vectorize_override(gait):
+ X = gait["fea3D"].transpose((3, 0, 1, 2))
+ n_components = 50
+
+ # init-level default and transform-level override to True
+ mpca = MPCA(vectorize=False).fit(X)
+ X_proj_tensor = mpca.transform(X)
+ X_proj_vec = mpca.transform(X, vectorize=True)
+ testing.assert_equal(X_proj_tensor.ndim, X.ndim)
+ testing.assert_equal(X_proj_vec.ndim, 2)
+
+ # init-level vectorize=True and transform-level override to False
+ mpca = MPCA(vectorize=True, n_components=n_components).fit(X)
+ X_proj_vec = mpca.transform(X)
+ X_proj_tensor = mpca.transform(X, vectorize=False)
+ testing.assert_equal(X_proj_vec.ndim, 2)
+ testing.assert_equal(X_proj_vec.shape[1], n_components)
+ testing.assert_equal(X_proj_tensor.ndim, X.ndim)
+
+ # explicit None keeps the init-level setting
+ X_proj_tensor_default = mpca.transform(X, vectorize=None)
+ testing.assert_equal(X_proj_tensor_default.ndim, 2)
+
+
+def test_fit_empty_input_raises():
+ X = np.empty((0, 4, 5, 6))
+ mpca = MPCA()
+ with pytest.raises(ValueError, match="0 sample"):
+ mpca.fit(X)
From 2766de7d871113168849b0e7c266f93e52cc6499 Mon Sep 17 00:00:00 2001
From: shuo-zhou~
Date: Mon, 10 Aug 2026 23:18:23 +0100
Subject: [PATCH 07/11] add MPCATrainer test
---
kalelinear/transformer/_mpca.py | 4 +-
requirements.txt | 1 +
setup.py | 10 +----
tests/conftest.py | 9 +++++
tests/pipeline/test_mpca_trainer.py | 60 +++++++++++++++++++++++++++++
tests/transformer/test_mpca.py | 10 +----
6 files changed, 74 insertions(+), 20 deletions(-)
create mode 100644 tests/pipeline/test_mpca_trainer.py
diff --git a/kalelinear/transformer/_mpca.py b/kalelinear/transformer/_mpca.py
index 1c1f8a2..a41588d 100644
--- a/kalelinear/transformer/_mpca.py
+++ b/kalelinear/transformer/_mpca.py
@@ -226,8 +226,8 @@ def _fit(self, X):
n_samples = shape_[0]
n_dims = X.ndim
- if n_samples <= 0:
- error_msg = "MPCA requires at least 1 sample to fit."
+ if n_samples < 2:
+ error_msg = "MPCA requires at least 2 samples to fit."
logging.error(error_msg)
raise ValueError(error_msg)
diff --git a/requirements.txt b/requirements.txt
index 4298f49..b763601 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,6 +2,7 @@ cvxopt
numpy
osqp
pandas
+pykale
scikit-learn
scipy
tensorly
diff --git a/setup.py b/setup.py
index 1c42d1b..2391d7a 100644
--- a/setup.py
+++ b/setup.py
@@ -11,15 +11,7 @@
# Core dependencies frequently used in the kalelinear API
-install_requires = [
- "cvxopt",
- "numpy",
- "osqp",
- "pandas",
- "scikit-learn>=1.6.0",
- "scipy",
- "tensorly",
-]
+install_requires = ["cvxopt", "numpy", "osqp", "pandas", "scikit-learn>=1.6.0", "scipy", "tensorly", "pykale"]
# Dependencies for all examples and tutorials
example_requires = [
diff --git a/tests/conftest.py b/tests/conftest.py
index 3a6dc8d..a03745f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -2,6 +2,7 @@
import os
import pytest
+from scipy.io import loadmat
@pytest.fixture(scope="session")
@@ -9,3 +10,11 @@ def download_path():
path = os.path.join("tests", "test_data")
os.makedirs(path, exist_ok=True)
return path
+
+
+@pytest.fixture(scope="module")
+def gait(download_path):
+ gait_data_path = os.path.join(download_path, "gait.mat")
+ if not os.path.exists(gait_data_path):
+ pytest.skip("Gait data not found, skipping tests.")
+ return loadmat(gait_data_path)
diff --git a/tests/pipeline/test_mpca_trainer.py b/tests/pipeline/test_mpca_trainer.py
new file mode 100644
index 0000000..628263e
--- /dev/null
+++ b/tests/pipeline/test_mpca_trainer.py
@@ -0,0 +1,60 @@
+import numpy as np
+import pytest
+from numpy import testing
+from sklearn.metrics import accuracy_score, roc_auc_score
+
+from kalelinear.pipeline.mpca_trainer import MPCATrainer
+
+CLASSIFIERS = ["svc", "linear_svc", "lr"]
+PARAMS = [
+ {"classifier_params": "auto", "mpca_params": None, "n_features": None, "search_params": None},
+ {
+ "classifier_params": {"C": 1},
+ "mpca_params": {"explained_variance_ratio": 0.9, "vectorize": True},
+ "n_features": 100,
+ "search_params": {"cv": 3},
+ },
+]
+
+
+@pytest.mark.parametrize("classifier", CLASSIFIERS)
+@pytest.mark.parametrize("params", PARAMS)
+def test_mpca_trainer(classifier, params, gait):
+ x = gait["fea3D"].transpose((3, 0, 1, 2))
+ x = x[:20, :]
+ y = gait["gnd"][:20].reshape(-1)
+ trainer = MPCATrainer(classifier=classifier, **params)
+ trainer.fit(x, y)
+ y_pred = trainer.predict(x)
+ testing.assert_equal(np.unique(y), np.unique(y_pred))
+ assert accuracy_score(y, y_pred) >= 0.8
+
+ if classifier == "linear_svc":
+ with pytest.raises(Exception):
+ y_proba = trainer.predict_proba(x)
+ else:
+ y_proba = trainer.predict_proba(x)
+ assert np.max(y_proba) <= 1.0
+ assert np.min(y_proba) >= 0.0
+ y_ = np.zeros(y.shape)
+ y_[np.where(y == 1)] = 1
+ assert roc_auc_score(y_, y_proba[:, 0]) >= 0.8
+
+ y_dec_score = trainer.decision_function(x)
+ assert roc_auc_score(y, y_dec_score) >= 0.8
+
+ if classifier == "svc" and trainer.clf.kernel == "rbf":
+ with pytest.raises(Exception):
+ trainer.mpca.inverse_transform(trainer.clf.coef_)
+ else:
+ # interpret utilities (select_top_weight/plot_weights) are not ported
+ # to kalelinear yet, so only check the inverse-transform path here.
+ weights = trainer.mpca.inverse_transform(trainer.clf.coef_) - trainer.mpca.mean_
+ testing.assert_equal(weights.shape[1:], x.shape[1:])
+
+
+def test_invalid_init():
+ with pytest.raises(Exception):
+ MPCATrainer(classifier="Ridge")
+ with pytest.raises(Exception):
+ MPCATrainer(classifier_params=False)
diff --git a/tests/transformer/test_mpca.py b/tests/transformer/test_mpca.py
index 6c74474..2a324df 100644
--- a/tests/transformer/test_mpca.py
+++ b/tests/transformer/test_mpca.py
@@ -13,14 +13,6 @@
RELATIVE_TOL = 0.00001
-@pytest.fixture(scope="module")
-def gait(download_path):
- gait_data_path = os.path.join(download_path, "gait.mat")
- if not os.path.exists(gait_data_path):
- pytest.skip("Gait data not found, skipping tests.")
- return loadmat(gait_data_path)
-
-
@pytest.fixture(scope="module")
def baseline_model(download_path):
baseline_data_path = os.path.join(download_path, "baseline.mat")
@@ -115,5 +107,5 @@ def test_transform_vectorize_override(gait):
def test_fit_empty_input_raises():
X = np.empty((0, 4, 5, 6))
mpca = MPCA()
- with pytest.raises(ValueError, match="0 sample"):
+ with pytest.raises(ValueError, match="MPCA requires at least 2 samples to fit."):
mpca.fit(X)
From 63fcbab9a230c56a4abbf199ca19b4be6afbd4fe Mon Sep 17 00:00:00 2001
From: shuo-zhou~
Date: Tue, 11 Aug 2026 16:08:15 +0100
Subject: [PATCH 08/11] rm pykale from dependency
---
requirements.txt | 1 -
setup.py | 2 +-
2 files changed, 1 insertion(+), 2 deletions(-)
diff --git a/requirements.txt b/requirements.txt
index b763601..4298f49 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,7 +2,6 @@ cvxopt
numpy
osqp
pandas
-pykale
scikit-learn
scipy
tensorly
diff --git a/setup.py b/setup.py
index 2391d7a..fb32d8d 100644
--- a/setup.py
+++ b/setup.py
@@ -11,7 +11,7 @@
# Core dependencies frequently used in the kalelinear API
-install_requires = ["cvxopt", "numpy", "osqp", "pandas", "scikit-learn>=1.6.0", "scipy", "tensorly", "pykale"]
+install_requires = ["cvxopt", "numpy", "osqp", "pandas", "scikit-learn>=1.6.0", "scipy", "tensorly"]
# Dependencies for all examples and tutorials
example_requires = [
From ffdfb6c3e97055ccb93a5f5a3f755789294fe9d0 Mon Sep 17 00:00:00 2001
From: Shuo Zhou
Date: Wed, 12 Aug 2026 23:18:34 +0100
Subject: [PATCH 09/11] update mpcatrainer and test
---
README.md | 7 ++--
kalelinear/pipeline/mpca_trainer.py | 54 +++++++++++++++++++----------
tests/pipeline/test_mpca_trainer.py | 16 ++++-----
3 files changed, 46 insertions(+), 31 deletions(-)
diff --git a/README.md b/README.md
index 28ca283..d972956 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,8 @@
-
-
+[](https://github.com/pykale/linear/actions/workflows/test.yml)
+[](https://codecov.io/gh/pykale/linear)
[](https://github.com/pykale/linear/blob/main/LICENSE)
[](https://www.python.org)
[](https://pypi.org/project/kalelinear/)
@@ -27,8 +27,7 @@ The package is part of the [PyKale](https://github.com/pykale/pykale) ecosystem
- Covariate Independence Regularized Learning Framework (CoIRSVM, CoIRLS) [[8](#references)][[9](#references)]
- Group-specific Discriminant Analysis (GSDA) [[9](#references)][[10](#references)]
- NumPy-compatible inputs and outputs.
-- scikit-learn style `fit`, `transform`, `predict`, `fit_transform`, and
- `fit_predict` workflows where applicable.
+- scikit-learn style `fit`, `transform`, `predict`, `fit_transform`, and `fit_predict` workflows where applicable.
- Optional covariate encoding for categorical domain or group labels.
`kalelinear` requires Python 3.10 or later. Core dependencies include:
diff --git a/kalelinear/pipeline/mpca_trainer.py b/kalelinear/pipeline/mpca_trainer.py
index 85882e5..8ae123d 100644
--- a/kalelinear/pipeline/mpca_trainer.py
+++ b/kalelinear/pipeline/mpca_trainer.py
@@ -57,11 +57,11 @@ class MPCATrainer(BaseEstimator, ClassifierMixin):
3. lr, {"C": [0.0001, 0.001, 0.01, 0.1, 1, 10, 100]}
mpca_params (dict, optional): Parameters of MPCA, e.g., {"explained_variance_ratio": 0.8}. Defaults to None,
i.e., using the default parameters
- (https://kalelinear.readthedocs.io/en/latest/kalelinear.transformer.html#module-kalelinear.transformer.mpca).
+ (https://kalelinear.readthedocs.io/en/latest/api_transformers.html#kalelinear.transformer.MPCA).
n_features (int, optional): Number of features for feature selection. Defaults to None, i.e., all features
after dimension reduction will be used.
search_params (dict, optional): Parameters of grid search, for more detail please see
- https://scikit-learn.org/stable/modules/grid_search.html#grid-search . Defaults to None, i.e., using the
+ https://scikit-learn.org/stable/modules/grid_search.html#grid-search. Defaults to None, i.e., using the
default params: {"cv": 5}.
"""
@@ -82,18 +82,17 @@ def __init__(
self.classifier = classifier
# init mpca object
if mpca_params is None:
- self.mpca_params = default_mpca_params
+ self.mpca_params = dict(default_mpca_params)
else:
- self.mpca_params = mpca_params
+ self.mpca_params = dict(mpca_params)
self.mpca = MPCA(**self.mpca_params)
# init feature selection parameters
self.n_features = n_features
- self.feature_order = None
# init classifier object
if search_params is None:
- self.search_params = default_search_params
+ self.search_params = dict(default_search_params)
else:
- self.search_params = search_params
+ self.search_params = dict(search_params)
self.classifier_param_grid = classifier_param_grid
self.auto_classifier_param = False
@@ -114,7 +113,16 @@ def __init__(
logging.error(error_msg)
raise ValueError(error_msg)
- self.classifier_params = classifier_params
+ if isinstance(classifier_params, dict):
+ self.classifier_params = dict(classifier_params)
+ self.clf = classifiers[classifier][0](**classifier_params)
+ elif classifier_params == "auto":
+ self.auto_classifier_param = True
+ self.classifier_params = "auto"
+ else:
+ error_msg = "Invalid classifier parameter type"
+ logging.error(error_msg)
+ raise ValueError(error_msg)
def fit(self, X, y):
"""Fit a pipeline with the given data X and labels y
@@ -133,18 +141,23 @@ def fit(self, X, y):
# feature selection
if self.n_features is None:
- self.n_features = X_transformed.shape[1]
- self.feature_order = self.mpca.idx_order_
+ self.n_features_ = X_transformed.shape[1]
+ self.feature_order_ = self.mpca.idx_order_
else:
f_score, p_val = f_classif(X_transformed, y)
- self.feature_order = (-1 * f_score).argsort()
- X_transformed = X_transformed[:, self.feature_order][:, : self.n_features]
+ self.feature_order_ = (-1 * f_score).argsort()
+ self.n_features_ = self.n_features
+ X_transformed = X_transformed[:, self.feature_order_][:, : self.n_features_]
# fit classifier
if self.auto_classifier_param:
+ param_grid = {name: list(values) for name, values in self.classifier_param_grid.items()}
extra_c = 1 / X.shape[0]
- if extra_c not in self.grid_search.param_grid["C"]:
- self.grid_search.param_grid["C"].append(extra_c)
+ if extra_c not in param_grid["C"]:
+ param_grid["C"].append(extra_c)
+ self.grid_search = GridSearchCV(
+ classifiers[self.classifier][0](), param_grid=param_grid, **self.search_params
+ )
self.grid_search.fit(X_transformed, y)
self.clf = self.grid_search.best_estimator_
if self.classifier == "svc":
@@ -162,7 +175,8 @@ def predict(self, X):
Returns:
array-like: Predicted labels, shape (n_samples, )
"""
- return self.clf.predict(self._extract_feature(X))
+ features = self._extract_feature(X)
+ return self.clf.predict(features)
def decision_function(self, X):
"""Decision scores of each class for the given data X
@@ -173,7 +187,8 @@ def decision_function(self, X):
Returns:
array-like: decision scores, shape (n_samples,) for binary case, else (n_samples, n_classes)
"""
- return self.clf.decision_function(self._extract_feature(X))
+ features = self._extract_feature(X)
+ return self.clf.decision_function(features)
def predict_proba(self, X):
"""Probability of each class for the given data X. Not supported by "linear_svc".
@@ -188,7 +203,8 @@ def predict_proba(self, X):
error_msg = "Linear SVC does not support computing probability."
logging.error(error_msg)
raise ValueError(error_msg)
- return self.clf.predict_proba(self._extract_feature(X))
+ features = self._extract_feature(X)
+ return self.clf.predict_proba(features)
def _extract_feature(self, X):
"""Extracting features for the given data X with MPCA->Feature selection
@@ -199,7 +215,7 @@ def _extract_feature(self, X):
Returns:
array-like: n_new, shape (n_samples, n_features)
"""
- check_is_fitted(self.clf)
+ check_is_fitted(self)
X_transformed = self.mpca.transform(X)
- return X_transformed[:, self.feature_order][:, : self.n_features]
+ return X_transformed[:, self.feature_order_][:, : self.n_features_]
diff --git a/tests/pipeline/test_mpca_trainer.py b/tests/pipeline/test_mpca_trainer.py
index 628263e..1433663 100644
--- a/tests/pipeline/test_mpca_trainer.py
+++ b/tests/pipeline/test_mpca_trainer.py
@@ -20,27 +20,27 @@
@pytest.mark.parametrize("classifier", CLASSIFIERS)
@pytest.mark.parametrize("params", PARAMS)
def test_mpca_trainer(classifier, params, gait):
- x = gait["fea3D"].transpose((3, 0, 1, 2))
- x = x[:20, :]
+ X = gait["fea3D"].transpose((3, 0, 1, 2))
+ X = X[:20, :]
y = gait["gnd"][:20].reshape(-1)
trainer = MPCATrainer(classifier=classifier, **params)
- trainer.fit(x, y)
- y_pred = trainer.predict(x)
+ trainer.fit(X, y)
+ y_pred = trainer.predict(X)
testing.assert_equal(np.unique(y), np.unique(y_pred))
assert accuracy_score(y, y_pred) >= 0.8
if classifier == "linear_svc":
with pytest.raises(Exception):
- y_proba = trainer.predict_proba(x)
+ y_proba = trainer.predict_proba(X)
else:
- y_proba = trainer.predict_proba(x)
+ y_proba = trainer.predict_proba(X)
assert np.max(y_proba) <= 1.0
assert np.min(y_proba) >= 0.0
y_ = np.zeros(y.shape)
y_[np.where(y == 1)] = 1
assert roc_auc_score(y_, y_proba[:, 0]) >= 0.8
- y_dec_score = trainer.decision_function(x)
+ y_dec_score = trainer.decision_function(X)
assert roc_auc_score(y, y_dec_score) >= 0.8
if classifier == "svc" and trainer.clf.kernel == "rbf":
@@ -50,7 +50,7 @@ def test_mpca_trainer(classifier, params, gait):
# interpret utilities (select_top_weight/plot_weights) are not ported
# to kalelinear yet, so only check the inverse-transform path here.
weights = trainer.mpca.inverse_transform(trainer.clf.coef_) - trainer.mpca.mean_
- testing.assert_equal(weights.shape[1:], x.shape[1:])
+ testing.assert_equal(weights.shape[1:], X.shape[1:])
def test_invalid_init():
From f8a9e5b8966897e1f8baf2dab60bf4f6ab7fd6a7 Mon Sep 17 00:00:00 2001
From: Shuo Zhou
Date: Thu, 13 Aug 2026 12:06:26 +0100
Subject: [PATCH 10/11] update mpca __init__
---
kalelinear/transformer/_mpca.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/kalelinear/transformer/_mpca.py b/kalelinear/transformer/_mpca.py
index a41588d..07373eb 100644
--- a/kalelinear/transformer/_mpca.py
+++ b/kalelinear/transformer/_mpca.py
@@ -176,7 +176,6 @@ def __init__(
msg = "Number of max iterations must be a positive integer but given %s" % max_iter
logging.error(msg)
raise ValueError(msg)
- self.proj_mats_ = []
self.vectorize = vectorize
self.n_components = n_components
if output_shape is None:
From 57d9c8d3ab11e7cdd816b9e430814e3c149587ba Mon Sep 17 00:00:00 2001
From: Shuo Zhou
Date: Thu, 13 Aug 2026 18:36:34 +0100
Subject: [PATCH 11/11] Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
kalelinear/pipeline/mpca_trainer.py | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/kalelinear/pipeline/mpca_trainer.py b/kalelinear/pipeline/mpca_trainer.py
index 8ae123d..cd1ce2d 100644
--- a/kalelinear/pipeline/mpca_trainer.py
+++ b/kalelinear/pipeline/mpca_trainer.py
@@ -141,12 +141,13 @@ def fit(self, X, y):
# feature selection
if self.n_features is None:
+ # MPCA.transform(vectorize=True) already returns features ordered by variance.
+ self.feature_order_ = np.arange(X_transformed.shape[1])
self.n_features_ = X_transformed.shape[1]
- self.feature_order_ = self.mpca.idx_order_
else:
- f_score, p_val = f_classif(X_transformed, y)
- self.feature_order_ = (-1 * f_score).argsort()
- self.n_features_ = self.n_features
+ f_score, _ = f_classif(X_transformed, y)
+ self.feature_order_ = np.argsort(-f_score)
+ self.n_features_ = min(self.n_features, X_transformed.shape[1])
X_transformed = X_transformed[:, self.feature_order_][:, : self.n_features_]
# fit classifier