reppi 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
reppi-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: reppi
3
+ Version: 0.1.0
4
+ Summary: A Python toolkit for representation-based learning/classification algorithms
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: numpy>=2.4.4
7
+ Requires-Dist: scipy>=1.17.1
reppi-0.1.0/README.md ADDED
Binary file
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "reppi"
3
+ version = "0.1.0"
4
+ description = "A Python toolkit for representation-based learning/classification algorithms"
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "numpy>=2.4.4",
8
+ "scipy>=1.17.1",
9
+ ]
10
+
11
+ [build-system]
12
+ requires = [
13
+ "setuptools>=61.0",
14
+ "wheel"
15
+ ]
16
+ build-backend = "setuptools.build_meta"
17
+
18
+ [tool.setuptools]
19
+ package-dir = {"" = "src"}
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
reppi-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,20 @@
1
+ """
2
+ reppi — Representation Learning Algorithms
3
+ ==========================================
4
+
5
+ A Python library implementing classical sparse representation and
6
+ dictionary learning algorithms.
7
+
8
+ Modules
9
+ -------
10
+ sparse
11
+ Sparse coding (OMP, Batch-OMP).
12
+ dictionary
13
+ Dictionary learning (K-SVD, LC-KSVD1, LC-KSVD2).
14
+ """
15
+
16
+ from reppi.sparse import OMP
17
+ from reppi.dictionary import KSVD, LCKSVD
18
+
19
+ __all__ = ["OMP", "KSVD", "LCKSVD"]
20
+ __version__ = "0.1.0"
@@ -0,0 +1,70 @@
1
+ """
2
+ Base classes for representation learning algorithms.
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+ import numpy as np
7
+
8
+
9
+ class BaseSparseCoder(ABC):
10
+ """Abstract base class for sparse coding algorithms."""
11
+
12
+ @abstractmethod
13
+ def encode(self, X: np.ndarray, D: np.ndarray) -> np.ndarray:
14
+ """
15
+ Compute sparse codes for signals X given dictionary D.
16
+
17
+ Parameters
18
+ ----------
19
+ X : np.ndarray, shape (n_features, n_samples)
20
+ Input signals as columns.
21
+ D : np.ndarray, shape (n_features, n_atoms)
22
+ Dictionary with (approximately) unit-norm columns.
23
+
24
+ Returns
25
+ -------
26
+ Gamma : np.ndarray, shape (n_atoms, n_samples)
27
+ Sparse representation matrix.
28
+ """
29
+ raise NotImplementedError
30
+
31
+
32
+ class BaseDictionaryLearner(ABC):
33
+ """Abstract base class for dictionary learning algorithms."""
34
+
35
+ @abstractmethod
36
+ def fit(self, X: np.ndarray) -> "BaseDictionaryLearner":
37
+ """
38
+ Learn a dictionary from training data.
39
+
40
+ Parameters
41
+ ----------
42
+ X : np.ndarray, shape (n_features, n_samples)
43
+ Training signals as columns.
44
+
45
+ Returns
46
+ -------
47
+ self
48
+ """
49
+ raise NotImplementedError
50
+
51
+ @abstractmethod
52
+ def transform(self, X: np.ndarray) -> np.ndarray:
53
+ """
54
+ Encode signals using the learned dictionary.
55
+
56
+ Parameters
57
+ ----------
58
+ X : np.ndarray, shape (n_features, n_samples)
59
+ Signals to encode.
60
+
61
+ Returns
62
+ -------
63
+ Gamma : np.ndarray, shape (n_atoms, n_samples)
64
+ Sparse representations.
65
+ """
66
+ raise NotImplementedError
67
+
68
+ def fit_transform(self, X: np.ndarray) -> np.ndarray:
69
+ """Fit and return sparse codes on the training data."""
70
+ return self.fit(X).transform(X)
@@ -0,0 +1,6 @@
1
+ """Dictionary learning algorithms."""
2
+
3
+ from reppi.dictionary.ksvd import KSVD
4
+ from reppi.dictionary.lc_ksvd import LCKSVD, initialization4lcksvd
5
+
6
+ __all__ = ["KSVD", "LCKSVD", "initialization4lcksvd"]
@@ -0,0 +1,292 @@
1
+ """
2
+ K-SVD dictionary learning.
3
+
4
+ Implements the K-SVD algorithm described in:
5
+ Aharon, Elad, Bruckstein. "The K-SVD: An Algorithm for Designing
6
+ Overcomplete Dictionaries for Sparse Representation".
7
+ IEEE Trans. Signal Processing, 54(11), 2006.
8
+
9
+ Batch-OMP integration follows:
10
+ Elad, Rubinstein, Zibulevsky. "Efficient Implementation of the K-SVD
11
+ Algorithm using Batch Orthogonal Matching Pursuit". Technion TR, 2008.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import numpy as np
17
+ from scipy import linalg
18
+
19
+ from reppi.base import BaseDictionaryLearner
20
+ from reppi.exceptions import DictionaryLearningError
21
+ from reppi.sparse.omp import OMP, batch_omp
22
+ from reppi.sparse.src import col_norms_squared, normalize_columns, rep_error_squared
23
+
24
+
25
+ class KSVD(BaseDictionaryLearner):
26
+ """
27
+ K-SVD dictionary learner.
28
+
29
+ Alternates between:
30
+ 1. Sparse coding — encode each training signal over the current D.
31
+ 2. Dictionary update — update each atom (and its coefficients) via a
32
+ rank-1 approximation of the residual matrix.
33
+
34
+ Parameters
35
+ ----------
36
+ n_components : int
37
+ Number of dictionary atoms to learn.
38
+ n_nonzero_coefs : int
39
+ Sparsity target T: each signal is represented with at most T atoms.
40
+ n_iter : int
41
+ Number of K-SVD iterations (default 10).
42
+ exact_svd : bool
43
+ If True, use full SVD for the atom update (exact K-SVD).
44
+ If False (default), use the faster approximate update.
45
+ mu_thresh : float
46
+ Mutual-incoherence threshold in (0, 1]. Atoms whose pairwise
47
+ correlation exceeds this value are replaced. Set to 1.0 to
48
+ disable (default 0.99).
49
+ mem_usage : str
50
+ One of 'high', 'normal' (default), 'low'.
51
+ Controls whether G = D'D (and DtX = D'X) are precomputed.
52
+ random_state : int or None
53
+ Seed for reproducible atom initialisation.
54
+ verbose : bool
55
+ Print iteration progress (default False).
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ n_components: int,
61
+ n_nonzero_coefs: int,
62
+ n_iter: int = 10,
63
+ exact_svd: bool = False,
64
+ mu_thresh: float = 0.99,
65
+ mem_usage: str = "normal",
66
+ random_state: int | None = None,
67
+ verbose: bool = False,
68
+ ) -> None:
69
+ if mem_usage not in ("high", "normal", "low"):
70
+ raise ValueError("mem_usage must be 'high', 'normal', or 'low'.")
71
+ self.n_components = n_components
72
+ self.n_nonzero_coefs = n_nonzero_coefs
73
+ self.n_iter = n_iter
74
+ self.exact_svd = exact_svd
75
+ self.mu_thresh = mu_thresh
76
+ self.mem_usage = mem_usage
77
+ self.random_state = random_state
78
+ self.verbose = verbose
79
+
80
+ # Set after fit
81
+ self.D_: np.ndarray | None = None
82
+ self.errors_: list[float] = []
83
+
84
+ # ------------------------------------------------------------------
85
+ # Public API
86
+ # ------------------------------------------------------------------
87
+
88
+ def fit(self, X: np.ndarray, D_init: np.ndarray | None = None) -> "KSVD":
89
+ """
90
+ Learn a dictionary from training signals.
91
+
92
+ Parameters
93
+ ----------
94
+ X : np.ndarray, shape (n_features, n_samples)
95
+ D_init : np.ndarray or None, shape (n_features, n_components)
96
+ Optional initial dictionary. If None, random training signals
97
+ are chosen as initial atoms.
98
+
99
+ Returns
100
+ -------
101
+ self
102
+ """
103
+ X = np.asarray(X, dtype=float)
104
+ rng = np.random.default_rng(self.random_state)
105
+
106
+ D = self._init_dict(X, D_init, rng)
107
+ self.errors_ = []
108
+
109
+ for it in range(self.n_iter):
110
+ G = D.T @ D if self.mem_usage in ("high", "normal") else None
111
+ Gamma = self._sparse_code(X, D, G)
112
+
113
+ unused = np.arange(X.shape[1])
114
+ replaced = np.zeros(self.n_components, dtype=bool)
115
+
116
+ for j in range(self.n_components):
117
+ D[:, j], gamma_j, idx, unused, replaced = _optimize_atom(
118
+ X, D, j, Gamma, unused, replaced, self.exact_svd
119
+ )
120
+ Gamma[j, idx] = gamma_j
121
+
122
+ err = float(np.sqrt(rep_error_squared(X, D, Gamma).sum() / X.size))
123
+ self.errors_.append(err)
124
+
125
+ D, _ = _clear_dict(D, Gamma, X, self.mu_thresh, unused, replaced)
126
+
127
+ if self.verbose:
128
+ print(f"Iter {it + 1}/{self.n_iter} RMSE={err:.6f}")
129
+
130
+ self.D_ = D
131
+ return self
132
+
133
+ def transform(self, X: np.ndarray) -> np.ndarray:
134
+ """Encode X using the learned dictionary."""
135
+ if self.D_ is None:
136
+ raise DictionaryLearningError("Call fit() before transform().")
137
+ coder = OMP(self.n_nonzero_coefs, mode="batch", check_dict=False)
138
+ return coder.encode(X, self.D_)
139
+
140
+ # ------------------------------------------------------------------
141
+ # Internal helpers
142
+ # ------------------------------------------------------------------
143
+
144
+ def _init_dict(
145
+ self,
146
+ X: np.ndarray,
147
+ D_init: np.ndarray | None,
148
+ rng: np.random.Generator,
149
+ ) -> np.ndarray:
150
+ n_features, n_samples = X.shape
151
+ k = self.n_components
152
+
153
+ if D_init is not None:
154
+ D = np.asarray(D_init, dtype=float)
155
+ if D.shape != (n_features, k):
156
+ raise DictionaryLearningError(
157
+ f"D_init shape {D.shape} does not match "
158
+ f"(n_features={n_features}, n_components={k})."
159
+ )
160
+ else:
161
+ valid = np.where(col_norms_squared(X) > 1e-6)[0]
162
+ if len(valid) < k:
163
+ raise DictionaryLearningError(
164
+ "Not enough non-zero training signals to initialise the dictionary."
165
+ )
166
+ chosen = rng.choice(valid, size=k, replace=False)
167
+ D = X[:, chosen].copy()
168
+
169
+ return normalize_columns(D)
170
+
171
+ def _sparse_code(
172
+ self,
173
+ X: np.ndarray,
174
+ D: np.ndarray,
175
+ G: np.ndarray | None,
176
+ ) -> np.ndarray:
177
+ if self.mem_usage == "high" and G is not None:
178
+ return batch_omp(D.T @ X, G, self.n_nonzero_coefs)
179
+ coder = OMP(self.n_nonzero_coefs, mode="batch", check_dict=False)
180
+ return coder.encode(X, D, G=G)
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # Module-level helpers (shared with LC-KSVD)
185
+ # ---------------------------------------------------------------------------
186
+
187
+
188
+ def _optimize_atom(
189
+ X: np.ndarray,
190
+ D: np.ndarray,
191
+ j: int,
192
+ Gamma: np.ndarray,
193
+ unused_sigs: np.ndarray,
194
+ replaced: np.ndarray,
195
+ exact_svd: bool,
196
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
197
+ """
198
+ Update the j-th dictionary atom and the corresponding sparse codes.
199
+
200
+ Mirrors the MATLAB ``optimize_atom`` function.
201
+
202
+ Returns
203
+ -------
204
+ atom : np.ndarray, shape (n_features,)
205
+ gamma_j : np.ndarray, non-zero coefficients for atom j
206
+ data_indices : np.ndarray, signal indices that use atom j
207
+ unused_sigs : np.ndarray (updated)
208
+ replaced : np.ndarray (updated)
209
+ """
210
+ # Signals that actively use atom j
211
+ data_indices = np.where(np.abs(Gamma[j, :]) > 1e-10)[0]
212
+
213
+ # --- Dead atom: replace with the worst-reconstructed unused signal ---
214
+ if len(data_indices) == 0:
215
+ max_signals = 5000
216
+ perm = np.random.permutation(len(unused_sigs))[:min(max_signals, len(unused_sigs))]
217
+ candidates = unused_sigs[perm]
218
+ E = rep_error_squared(X, D, Gamma, block_size=len(candidates) + 1)
219
+ best = int(np.argmax(E[candidates]))
220
+ atom = X[:, candidates[best]]
221
+ atom = atom / max(np.linalg.norm(atom), 1e-14)
222
+ gamma_j = np.zeros(len(data_indices))
223
+ # Remove used signal from the pool
224
+ mask = np.ones(len(unused_sigs), dtype=bool)
225
+ mask[perm[best]] = False
226
+ unused_sigs = unused_sigs[mask]
227
+ replaced[j] = True
228
+ return atom, gamma_j, data_indices, unused_sigs, replaced
229
+
230
+ # --- Normal update ---
231
+ small_gamma = Gamma[:, data_indices] # (n_atoms, |support|)
232
+ g_j = Gamma[j, data_indices] # (|support|,)
233
+
234
+ # Residual matrix: remove atom j's contribution then add it back
235
+ # E = X[:,support] - D*small_gamma + d_j * g_j
236
+ E = X[:, data_indices] - D @ small_gamma + np.outer(D[:, j], g_j)
237
+
238
+ if exact_svd:
239
+ # Exact update via rank-1 SVD
240
+ U, s, Vt = np.linalg.svd(E, full_matrices=False)
241
+ atom = U[:, 0]
242
+ gamma_j = s[0] * Vt[0, :]
243
+ else:
244
+ # Approximate update (alternating optimisation)
245
+ atom = E @ g_j
246
+ atom_norm = np.linalg.norm(atom)
247
+ atom = atom / max(atom_norm, 1e-14)
248
+ gamma_j = atom @ E # (|support|,)
249
+
250
+ return atom, gamma_j, data_indices, unused_sigs, replaced
251
+
252
+
253
+ def _clear_dict(
254
+ D: np.ndarray,
255
+ Gamma: np.ndarray,
256
+ X: np.ndarray,
257
+ mu_thresh: float,
258
+ unused_sigs: np.ndarray,
259
+ replaced: np.ndarray,
260
+ use_thresh: int = 4,
261
+ ) -> tuple[np.ndarray, int]:
262
+ """
263
+ Replace rarely-used or highly-correlated atoms with high-error signals.
264
+
265
+ Mirrors the MATLAB ``cleardict`` function.
266
+
267
+ Returns
268
+ -------
269
+ D : np.ndarray (possibly modified)
270
+ cleared : int number of atoms replaced
271
+ """
272
+ n_atoms = D.shape[1]
273
+ err = rep_error_squared(X, D, Gamma)
274
+ use_count = (np.abs(Gamma) > 1e-7).sum(axis=1) # (n_atoms,)
275
+ cleared = 0
276
+
277
+ for j in range(n_atoms):
278
+ if len(unused_sigs) == 0:
279
+ break
280
+ Gj = D.T @ D[:, j]
281
+ Gj[j] = 0.0
282
+ bad_coherence = np.max(Gj ** 2) > mu_thresh ** 2
283
+ bad_usage = use_count[j] < use_thresh
284
+
285
+ if (bad_coherence or bad_usage) and not replaced[j]:
286
+ best = int(np.argmax(err[unused_sigs]))
287
+ atom = X[:, unused_sigs[best]]
288
+ D[:, j] = atom / max(np.linalg.norm(atom), 1e-14)
289
+ unused_sigs = np.delete(unused_sigs, best)
290
+ cleared += 1
291
+
292
+ return D, cleared
File without changes
@@ -0,0 +1,542 @@
1
+ """
2
+ Label Consistent K-SVD (LC-KSVD) dictionary learning.
3
+
4
+ Implements LC-KSVD1 and LC-KSVD2 as described in:
5
+ Zhuolin Jiang, Zhe Lin, Larry S. Davis.
6
+ "Learning A Discriminative Dictionary for Sparse Coding via Label
7
+ Consistent K-SVD", CVPR 2011.
8
+
9
+ LC-KSVD augments the standard K-SVD objective with:
10
+ - A label-consistency term (LC-KSVD1) that encourages atoms associated
11
+ with the same class to produce similar sparse codes.
12
+ - An additional linear classifier term (LC-KSVD2) that jointly trains a
13
+ classifier W alongside the dictionary.
14
+
15
+ Optimization problems
16
+ ---------------------
17
+ LC-KSVD1:
18
+ min_{D, A, X} ||Y - DX||_F^2 + alpha * ||Q - AX||_F^2
19
+ s.t. ||x_i||_0 <= T
20
+
21
+ LC-KSVD2:
22
+ min_{D, A, W, X} ||Y - DX||_F^2 + alpha * ||Q - AX||_F^2
23
+ + beta * ||H - WX||_F^2
24
+ s.t. ||x_i||_0 <= T
25
+
26
+ where:
27
+ Y = training signals
28
+ D = dictionary
29
+ X = sparse codes
30
+ Q = label-consistent sparse code targets (binary atom-class assignments)
31
+ A = linear mapping for Q-consistency
32
+ W = linear classifier
33
+ H = class label matrix (one-hot per column)
34
+ alpha, beta = trade-off weights
35
+
36
+ Usage
37
+ -----
38
+ For LC-KSVD1::
39
+
40
+ model = LCKSVD(
41
+ n_components=570,
42
+ n_nonzero_coefs=30,
43
+ alpha=4.0,
44
+ variant="lcksvd1",
45
+ )
46
+ model.fit(X_train, H_train)
47
+ predictions = model.predict(X_test)
48
+
49
+ For LC-KSVD2::
50
+
51
+ model = LCKSVD(
52
+ n_components=570,
53
+ n_nonzero_coefs=30,
54
+ alpha=4.0,
55
+ beta=2.0,
56
+ variant="lcksvd2",
57
+ )
58
+ model.fit(X_train, H_train)
59
+ predictions = model.predict(X_test)
60
+ """
61
+
62
+ from __future__ import annotations
63
+
64
+ import numpy as np
65
+
66
+ from reppi.base import BaseDictionaryLearner
67
+ from reppi.exceptions import DictionaryLearningError
68
+ from reppi.sparse.omp import OMP, batch_omp
69
+ from reppi.sparse.src import col_norms_squared, normalize_columns, rep_error_squared
70
+ from reppi.dictionary.ksvd import KSVD, _optimize_atom, _clear_dict
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Initialisation helper
75
+ # ---------------------------------------------------------------------------
76
+
77
+
78
+ def _build_label_consistent_target(
79
+ H: np.ndarray,
80
+ n_components: int,
81
+ n_nonzero_coefs: int,
82
+ sparse_codes: np.ndarray,
83
+ ) -> np.ndarray:
84
+ """
85
+ Build the label-consistent target matrix Q.
86
+
87
+ Each dictionary atom is associated with exactly one class. Q[:,i] is a
88
+ binary vector that is 1 in the positions of atoms belonging to the same
89
+ class as training sample i, and 0 elsewhere.
90
+
91
+ Parameters
92
+ ----------
93
+ H : np.ndarray, shape (n_classes, n_samples)
94
+ One-hot class label matrix.
95
+ n_components : int
96
+ Total number of dictionary atoms.
97
+ n_nonzero_coefs : int
98
+ Sparsity level T.
99
+ sparse_codes : np.ndarray, shape (n_components, n_samples)
100
+ Current sparse codes (used to determine per-class atom assignment).
101
+
102
+ Returns
103
+ -------
104
+ Q : np.ndarray, shape (n_components, n_samples)
105
+ """
106
+ n_classes, n_samples = H.shape
107
+
108
+ # Distribute atoms evenly across classes
109
+ atoms_per_class = n_components // n_classes
110
+
111
+ # Assign atoms to classes in order
112
+ atom_class = np.zeros(n_components, dtype=int)
113
+ for c in range(n_classes):
114
+ start = c * atoms_per_class
115
+ end = start + atoms_per_class if c < n_classes - 1 else n_components
116
+ atom_class[start:end] = c
117
+
118
+ Q = np.zeros((n_components, n_samples))
119
+ for i in range(n_samples):
120
+ cls = int(np.argmax(H[:, i]))
121
+ Q[atom_class == cls, i] = 1.0
122
+
123
+ return Q
124
+
125
+
126
+ def initialization4lcksvd(
127
+ X: np.ndarray,
128
+ H: np.ndarray,
129
+ n_components: int,
130
+ n_iter_init: int,
131
+ n_nonzero_coefs: int,
132
+ random_state: int | None = None,
133
+ verbose: bool = False,
134
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
135
+ """
136
+ Initialise D, A (label-consistency transform), W (classifier), and Q.
137
+
138
+ This mirrors the MATLAB ``initialization4LCKSVD`` step. A plain K-SVD is
139
+ run first, then a linear classifier W and label-consistent target Q are
140
+ estimated from the resulting sparse codes.
141
+
142
+ Parameters
143
+ ----------
144
+ X : np.ndarray, shape (n_features, n_samples)
145
+ Training signals.
146
+ H : np.ndarray, shape (n_classes, n_samples)
147
+ One-hot label matrix.
148
+ n_components : int
149
+ Dictionary size.
150
+ n_iter_init : int
151
+ K-SVD iterations for the initialisation run.
152
+ n_nonzero_coefs : int
153
+ Sparsity level T.
154
+ random_state : int or None
155
+ verbose : bool
156
+
157
+ Returns
158
+ -------
159
+ D_init : np.ndarray, shape (n_features, n_components)
160
+ A_init : np.ndarray, shape (n_components, n_components)
161
+ Initial label-consistency transform.
162
+ W_init : np.ndarray, shape (n_classes, n_components)
163
+ Initial linear classifier weights.
164
+ Q : np.ndarray, shape (n_components, n_samples)
165
+ Label-consistent sparse code targets.
166
+ """
167
+ # Step 1: standard K-SVD initialisation
168
+ ksvd = KSVD(
169
+ n_components=n_components,
170
+ n_nonzero_coefs=n_nonzero_coefs,
171
+ n_iter=n_iter_init,
172
+ random_state=random_state,
173
+ verbose=verbose,
174
+ )
175
+ ksvd.fit(X)
176
+ D_init = ksvd.D_
177
+
178
+ # Step 2: sparse-code the training data with the initial dictionary
179
+ coder = OMP(n_nonzero_coefs, mode="batch", check_dict=False)
180
+ Gamma = coder.encode(X, D_init)
181
+
182
+ # Step 3: build Q
183
+ Q = _build_label_consistent_target(H, n_components, n_nonzero_coefs, Gamma)
184
+
185
+ # Step 4: fit W (classifier) via least squares: W * Gamma ≈ H
186
+ # W = H @ Gamma.T @ pinv(Gamma @ Gamma.T)
187
+ W_init = H @ np.linalg.pinv(Gamma)
188
+
189
+ # Step 5: fit A (label-consistency map) via least squares: A * Gamma ≈ Q
190
+ A_init = Q @ np.linalg.pinv(Gamma)
191
+
192
+ return D_init, A_init, W_init, Q
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # LC-KSVD training
197
+ # ---------------------------------------------------------------------------
198
+
199
+
200
+ def _augment_data(
201
+ X: np.ndarray,
202
+ Q: np.ndarray,
203
+ H: np.ndarray | None,
204
+ sqrt_alpha: float,
205
+ sqrt_beta: float,
206
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
207
+ """
208
+ Build the augmented signal/dictionary system for LC-KSVD.
209
+
210
+ The combined objective is minimised by stacking the data matrices:
211
+
212
+ Y_aug = [ X ] D_aug = [ D ]
213
+ [ α * Q ] [ α A]
214
+ [ β * H ] [ β W] (LC-KSVD2 only)
215
+
216
+ This augmentation lets the standard K-SVD atom-update step
217
+ simultaneously minimise reconstruction, label-consistency, and
218
+ (optionally) classification error.
219
+
220
+ Returns
221
+ -------
222
+ X_aug : np.ndarray
223
+ alpha_scale : float (for constructing D_aug at each iteration)
224
+ beta_scale : float
225
+ """
226
+ alpha_scale = sqrt_alpha
227
+ beta_scale = sqrt_beta
228
+
229
+ parts = [X, sqrt_alpha * Q]
230
+ if H is not None:
231
+ parts.append(sqrt_beta * H)
232
+
233
+ X_aug = np.vstack(parts)
234
+ return X_aug, alpha_scale, beta_scale
235
+
236
+
237
+ class LCKSVD(BaseDictionaryLearner):
238
+ """
239
+ Label Consistent K-SVD dictionary learner (LC-KSVD1 and LC-KSVD2).
240
+
241
+ Parameters
242
+ ----------
243
+ n_components : int
244
+ Number of dictionary atoms.
245
+ n_nonzero_coefs : int
246
+ Sparsity level T.
247
+ alpha : float
248
+ Weight for the label-consistency term (sqrt_alpha in the paper).
249
+ beta : float
250
+ Weight for the classifier term (sqrt_beta; LC-KSVD2 only).
251
+ variant : {'lcksvd1', 'lcksvd2'}
252
+ Which variant to train.
253
+ n_iter : int
254
+ Number of LC-KSVD iterations (default 50).
255
+ n_iter_init : int
256
+ K-SVD iterations for the initialisation phase (default 20).
257
+ exact_svd : bool
258
+ Use exact SVD in the atom-update step (slower but slightly better).
259
+ mu_thresh : float
260
+ Mutual-incoherence threshold (default 0.99).
261
+ random_state : int or None
262
+ verbose : bool
263
+
264
+ Attributes
265
+ ----------
266
+ D_ : np.ndarray, shape (n_features, n_components)
267
+ Learned dictionary.
268
+ W_ : np.ndarray, shape (n_classes, n_components)
269
+ Learned linear classifier weights.
270
+ A_ : np.ndarray, shape (n_components, n_components)
271
+ Learned label-consistency transform.
272
+ errors_ : list of float
273
+ Per-iteration RMSE on training data.
274
+ """
275
+
276
+ def __init__(
277
+ self,
278
+ n_components: int,
279
+ n_nonzero_coefs: int,
280
+ alpha: float = 4.0,
281
+ beta: float = 2.0,
282
+ variant: str = "lcksvd2",
283
+ n_iter: int = 50,
284
+ n_iter_init: int = 20,
285
+ exact_svd: bool = False,
286
+ mu_thresh: float = 0.99,
287
+ random_state: int | None = None,
288
+ verbose: bool = False,
289
+ ) -> None:
290
+ if variant not in ("lcksvd1", "lcksvd2"):
291
+ raise ValueError("variant must be 'lcksvd1' or 'lcksvd2'.")
292
+ self.n_components = n_components
293
+ self.n_nonzero_coefs = n_nonzero_coefs
294
+ self.alpha = alpha
295
+ self.beta = beta
296
+ self.variant = variant
297
+ self.n_iter = n_iter
298
+ self.n_iter_init = n_iter_init
299
+ self.exact_svd = exact_svd
300
+ self.mu_thresh = mu_thresh
301
+ self.random_state = random_state
302
+ self.verbose = verbose
303
+
304
+ self.D_: np.ndarray | None = None
305
+ self.W_: np.ndarray | None = None
306
+ self.A_: np.ndarray | None = None
307
+ self.errors_: list[float] = []
308
+
309
+ # ------------------------------------------------------------------
310
+ # Public API
311
+ # ------------------------------------------------------------------
312
+
313
+ def fit(
314
+ self,
315
+ X: np.ndarray,
316
+ H: np.ndarray,
317
+ D_init: np.ndarray | None = None,
318
+ A_init: np.ndarray | None = None,
319
+ W_init: np.ndarray | None = None,
320
+ Q: np.ndarray | None = None,
321
+ ) -> "LCKSVD":
322
+ """
323
+ Learn a discriminative dictionary from labelled training data.
324
+
325
+ Parameters
326
+ ----------
327
+ X : np.ndarray, shape (n_features, n_samples)
328
+ Training signals.
329
+ H : np.ndarray, shape (n_classes, n_samples)
330
+ One-hot label matrix.
331
+ D_init : np.ndarray or None
332
+ Initial dictionary. If None, a K-SVD initialisation is run.
333
+ A_init : np.ndarray or None
334
+ Initial label-consistency transform.
335
+ W_init : np.ndarray or None
336
+ Initial classifier weights (required / used for LC-KSVD2).
337
+ Q : np.ndarray or None
338
+ Label-consistent target matrix. Computed from H if None.
339
+
340
+ Returns
341
+ -------
342
+ self
343
+ """
344
+ X = np.asarray(X, dtype=float)
345
+ H = np.asarray(H, dtype=float)
346
+ n_features, n_samples = X.shape
347
+ n_classes = H.shape[0]
348
+
349
+ # ---- Initialisation ----
350
+ if D_init is None or A_init is None or W_init is None or Q is None:
351
+ if self.verbose:
352
+ print("Running initialisation K-SVD...")
353
+ D_init, A_init, W_init, Q = initialization4lcksvd(
354
+ X, H,
355
+ self.n_components,
356
+ self.n_iter_init,
357
+ self.n_nonzero_coefs,
358
+ random_state=self.random_state,
359
+ verbose=self.verbose,
360
+ )
361
+
362
+ D = normalize_columns(D_init.copy())
363
+ A = A_init.copy()
364
+ W = W_init.copy()
365
+
366
+ sqrt_alpha = self.alpha
367
+ sqrt_beta = self.beta
368
+
369
+ use_classifier_term = (self.variant == "lcksvd2")
370
+
371
+ # ---- Build augmented training data ----
372
+ # Y_aug = [X ; sqrt_alpha*Q ; sqrt_beta*H] (LC-KSVD2)
373
+ # Y_aug = [X ; sqrt_alpha*Q] (LC-KSVD1)
374
+ H_aug = H if use_classifier_term else None
375
+ X_aug, _, _ = _augment_data(X, Q, H_aug, sqrt_alpha, sqrt_beta)
376
+
377
+ self.errors_ = []
378
+
379
+ for it in range(self.n_iter):
380
+
381
+ # ---- Build augmented dictionary ----
382
+ # D_aug = [D ; sqrt_alpha*A ; sqrt_beta*W]
383
+ D_aug = self._build_aug_dict(D, A, W, sqrt_alpha, sqrt_beta, use_classifier_term)
384
+ D_aug_norm = normalize_columns(D_aug)
385
+
386
+ # ---- Sparse coding on augmented system ----
387
+ G_aug = D_aug_norm.T @ D_aug_norm
388
+ Gamma = batch_omp(D_aug_norm.T @ X_aug, G_aug, self.n_nonzero_coefs)
389
+
390
+ # ---- Dictionary update (on original data only) ----
391
+ # We update D, A (and W for LC-KSVD2) jointly via the
392
+ # augmented residual, but evaluate coherence/usage on original X.
393
+ unused = np.arange(n_samples)
394
+ replaced = np.zeros(self.n_components, dtype=bool)
395
+
396
+ for j in range(self.n_components):
397
+ D_aug_norm[:, j], gamma_j, idx, unused, replaced = _optimize_atom(
398
+ X_aug, D_aug_norm, j, Gamma, unused, replaced, self.exact_svd
399
+ )
400
+ Gamma[j, idx] = gamma_j
401
+
402
+ # De-augment: extract D, A, W from D_aug_norm
403
+ D, A, W = self._split_aug_dict(
404
+ D_aug_norm, n_features, n_classes, sqrt_alpha, sqrt_beta, use_classifier_term
405
+ )
406
+ D = normalize_columns(D)
407
+
408
+ # ---- Update classifier W (LC-KSVD2) via least squares ----
409
+ if use_classifier_term:
410
+ W = H @ np.linalg.pinv(Gamma)
411
+
412
+ # ---- Update A via least squares ----
413
+ A = Q @ np.linalg.pinv(Gamma)
414
+
415
+ # ---- Clear incoherent / rarely-used atoms ----
416
+ # Rebuild normalised augmented dict for coherence checking
417
+ D_aug_rebuilt = self._build_aug_dict(D, A, W, sqrt_alpha, sqrt_beta, use_classifier_term)
418
+ D_aug_rebuilt = normalize_columns(D_aug_rebuilt)
419
+ D_aug_rebuilt, _ = _clear_dict(
420
+ D_aug_rebuilt, Gamma, X_aug, self.mu_thresh,
421
+ unused, replaced
422
+ )
423
+ D, A, W = self._split_aug_dict(
424
+ D_aug_rebuilt, n_features, n_classes, sqrt_alpha, sqrt_beta, use_classifier_term
425
+ )
426
+ D = normalize_columns(D)
427
+
428
+ # ---- Track RMSE on original X ----
429
+ err = float(np.sqrt(rep_error_squared(X, D, Gamma).sum() / X.size))
430
+ self.errors_.append(err)
431
+
432
+ if self.verbose:
433
+ print(f"[{self.variant.upper()}] Iter {it + 1}/{self.n_iter} RMSE={err:.6f}")
434
+
435
+ self.D_ = D
436
+ self.A_ = A
437
+ self.W_ = W
438
+ return self
439
+
440
+ def transform(self, X: np.ndarray) -> np.ndarray:
441
+ """
442
+ Encode X using the learned dictionary D.
443
+
444
+ Parameters
445
+ ----------
446
+ X : np.ndarray, shape (n_features, n_samples)
447
+
448
+ Returns
449
+ -------
450
+ Gamma : np.ndarray, shape (n_components, n_samples)
451
+ """
452
+ self._check_fitted()
453
+ coder = OMP(self.n_nonzero_coefs, mode="batch", check_dict=False)
454
+ return coder.encode(X, self.D_)
455
+
456
+ def predict(self, X: np.ndarray) -> np.ndarray:
457
+ """
458
+ Classify test signals using the learned classifier W.
459
+
460
+ The predicted class for each signal is the argmax of W @ gamma.
461
+
462
+ Parameters
463
+ ----------
464
+ X : np.ndarray, shape (n_features, n_samples)
465
+
466
+ Returns
467
+ -------
468
+ labels : np.ndarray, shape (n_samples,) integer class indices
469
+ """
470
+ self._check_fitted()
471
+ if self.W_ is None:
472
+ raise DictionaryLearningError(
473
+ "Classifier W is not available. "
474
+ "Use variant='lcksvd2' or access sparse codes via transform()."
475
+ )
476
+ Gamma = self.transform(X)
477
+ scores = self.W_ @ Gamma # (n_classes, n_samples)
478
+ return np.argmax(scores, axis=0)
479
+
480
+ def score(self, X: np.ndarray, H: np.ndarray) -> float:
481
+ """
482
+ Classification accuracy on (X, H).
483
+
484
+ Parameters
485
+ ----------
486
+ X : np.ndarray, shape (n_features, n_samples)
487
+ H : np.ndarray, shape (n_classes, n_samples) — one-hot labels
488
+
489
+ Returns
490
+ -------
491
+ accuracy : float in [0, 1]
492
+ """
493
+ true_labels = np.argmax(H, axis=0)
494
+ pred_labels = self.predict(X)
495
+ return float(np.mean(pred_labels == true_labels))
496
+
497
+ # ------------------------------------------------------------------
498
+ # Internal helpers
499
+ # ------------------------------------------------------------------
500
+
501
+ def _check_fitted(self) -> None:
502
+ if self.D_ is None:
503
+ raise DictionaryLearningError("Call fit() before transform() / predict().")
504
+
505
+ @staticmethod
506
+ def _build_aug_dict(
507
+ D: np.ndarray,
508
+ A: np.ndarray,
509
+ W: np.ndarray,
510
+ sqrt_alpha: float,
511
+ sqrt_beta: float,
512
+ use_classifier: bool,
513
+ ) -> np.ndarray:
514
+ """Stack [D ; sqrt_alpha*A ; (sqrt_beta*W)]."""
515
+ parts = [D, sqrt_alpha * A]
516
+ if use_classifier:
517
+ parts.append(sqrt_beta * W)
518
+ return np.vstack(parts)
519
+
520
+ @staticmethod
521
+ def _split_aug_dict(
522
+ D_aug: np.ndarray,
523
+ n_features: int,
524
+ n_classes: int,
525
+ sqrt_alpha: float,
526
+ sqrt_beta: float,
527
+ use_classifier: bool,
528
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
529
+ """
530
+ Recover (D, A, W) from the augmented dictionary D_aug.
531
+
532
+ D_aug rows are: n_features | n_components | (n_classes if lcksvd2).
533
+ """
534
+ n_components = D_aug.shape[1]
535
+ D = D_aug[:n_features, :]
536
+ A_rows = n_components
537
+ A = D_aug[n_features: n_features + A_rows, :] / max(sqrt_alpha, 1e-14)
538
+ if use_classifier:
539
+ W = D_aug[n_features + A_rows:, :] / max(sqrt_beta, 1e-14)
540
+ else:
541
+ W = np.zeros((n_classes, n_components))
542
+ return D, A, W
@@ -0,0 +1,23 @@
1
+ """
2
+ Custom exceptions for the reppi library.
3
+ """
4
+
5
+
6
+ class ReppiError(Exception):
7
+ """Base exception for all reppi errors."""
8
+
9
+
10
+ class DictionaryNormalizationError(ReppiError):
11
+ """Raised when dictionary atoms are not unit-norm."""
12
+
13
+
14
+ class SparseCodingError(ReppiError):
15
+ """Raised when sparse coding fails or receives invalid inputs."""
16
+
17
+
18
+ class DictionaryLearningError(ReppiError):
19
+ """Raised when dictionary learning encounters an unrecoverable error."""
20
+
21
+
22
+ class InvalidParameterError(ReppiError):
23
+ """Raised when an invalid parameter value is supplied."""
File without changes
File without changes
File without changes
File without changes
@@ -0,0 +1,5 @@
1
+ """Sparse coding algorithms."""
2
+
3
+ from reppi.sparse.omp import OMP, batch_omp, omp_cholesky
4
+
5
+ __all__ = ["OMP", "batch_omp", "omp_cholesky"]
@@ -0,0 +1,218 @@
1
+ """
2
+ Orthogonal Matching Pursuit (OMP) sparse coding.
3
+
4
+ Implements Batch-OMP as described in:
5
+ Elad, Rubinstein, Zibulevsky. "Efficient Implementation of the K-SVD
6
+ Algorithm using Batch Orthogonal Matching Pursuit". Technion TR, 2008.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+ from scipy import linalg
13
+
14
+ from reppi.base import BaseSparseCoder
15
+ from reppi.exceptions import DictionaryNormalizationError, SparseCodingError
16
+
17
+
18
+ def _check_dict_normalized(D: np.ndarray, tol: float = 1e-2) -> None:
19
+ """Raise if any atom of D deviates from unit L2-norm by more than tol."""
20
+ norms = np.sqrt((D * D).sum(axis=0))
21
+ if np.any(np.abs(norms - 1.0) > tol):
22
+ raise DictionaryNormalizationError(
23
+ "Dictionary columns must be normalized to unit L2-norm. "
24
+ f"Got norms in range [{norms.min():.4f}, {norms.max():.4f}]."
25
+ )
26
+
27
+
28
+ def omp_cholesky(
29
+ D: np.ndarray,
30
+ x: np.ndarray,
31
+ n_nonzero: int,
32
+ ) -> np.ndarray:
33
+ """
34
+ Single-signal OMP via Cholesky updates (OMP-Cholesky).
35
+
36
+ Parameters
37
+ ----------
38
+ D : np.ndarray, shape (n_features, n_atoms)
39
+ Normalized dictionary.
40
+ x : np.ndarray, shape (n_features,)
41
+ Single input signal.
42
+ n_nonzero : int
43
+ Maximum number of non-zero coefficients (sparsity).
44
+
45
+ Returns
46
+ -------
47
+ gamma : np.ndarray, shape (n_atoms,)
48
+ Sparse representation of x.
49
+ """
50
+ n_atoms = D.shape[1]
51
+ residual = x.copy().astype(float)
52
+ support: list[int] = []
53
+ gamma = np.zeros(n_atoms)
54
+
55
+ # Cholesky factor of D[:,support].T @ D[:,support]
56
+ L = np.zeros((n_nonzero, n_nonzero))
57
+
58
+ for k in range(n_nonzero):
59
+ correlations = D.T @ residual
60
+ j = int(np.argmax(np.abs(correlations)))
61
+ support.append(j)
62
+
63
+ # --- Cholesky update ---
64
+ Ds = D[:, support]
65
+ if k == 0:
66
+ L[0, 0] = 1.0
67
+ else:
68
+ w = Ds[:, :-1].T @ D[:, j] # (k,)
69
+ # Solve L[:k,:k] * v = w
70
+ v = linalg.solve_triangular(L[:k, :k], w, lower=True)
71
+ l_new = np.sqrt(max(1.0 - float(v @ v), 1e-14))
72
+ L[k, :k] = v
73
+ L[k, k] = l_new
74
+
75
+ # Solve (L L.T) c = Ds.T x
76
+ rhs = Ds.T @ x
77
+ c = linalg.cho_solve(
78
+ (L[: k + 1, : k + 1], True), rhs
79
+ )
80
+ residual = x - Ds @ c
81
+
82
+ gamma[support] = c
83
+ return gamma
84
+
85
+
86
+ def batch_omp(
87
+ DtX: np.ndarray,
88
+ G: np.ndarray,
89
+ n_nonzero: int,
90
+ ) -> np.ndarray:
91
+ """
92
+ Batch OMP — fastest variant; requires precomputed G = D'D and DtX = D'X.
93
+
94
+ Parameters
95
+ ----------
96
+ DtX : np.ndarray, shape (n_atoms, n_samples)
97
+ Precomputed projections D.T @ X.
98
+ G : np.ndarray, shape (n_atoms, n_atoms)
99
+ Precomputed Gram matrix D.T @ D.
100
+ n_nonzero : int
101
+ Sparsity level.
102
+
103
+ Returns
104
+ -------
105
+ Gamma : np.ndarray, shape (n_atoms, n_samples)
106
+ Sparse representations (dense).
107
+ """
108
+ n_atoms, n_samples = DtX.shape
109
+ Gamma = np.zeros((n_atoms, n_samples))
110
+
111
+ for i in range(n_samples):
112
+ dtx = DtX[:, i]
113
+ residual_proj = dtx.copy()
114
+ support: list[int] = []
115
+ L = np.zeros((n_nonzero, n_nonzero))
116
+
117
+ for k in range(n_nonzero):
118
+ j = int(np.argmax(np.abs(residual_proj)))
119
+ support.append(j)
120
+
121
+ # Cholesky update using Gram matrix
122
+ if k == 0:
123
+ L[0, 0] = 1.0
124
+ else:
125
+ w = G[support[:-1], j] # (k,)
126
+ v = linalg.solve_triangular(L[:k, :k], w, lower=True)
127
+ l_new = np.sqrt(max(1.0 - float(v @ v), 1e-14))
128
+ L[k, :k] = v
129
+ L[k, k] = l_new
130
+
131
+ # Solve (L L.T) c = DtX[support, i]
132
+ rhs = dtx[support]
133
+ c = linalg.cho_solve((L[: k + 1, : k + 1], True), rhs)
134
+
135
+ # Update residual in projection space
136
+ residual_proj = dtx - G[:, support] @ c
137
+
138
+ Gamma[support, i] = c
139
+
140
+ return Gamma
141
+
142
+
143
+ class OMP(BaseSparseCoder):
144
+ """
145
+ Orthogonal Matching Pursuit sparse coder.
146
+
147
+ Parameters
148
+ ----------
149
+ n_nonzero_coefs : int
150
+ Target sparsity — maximum number of non-zero coefficients per signal.
151
+ mode : {'batch', 'cholesky'}
152
+ Implementation variant.
153
+ 'batch' — Batch-OMP; requires the full Gram matrix G = D'D.
154
+ Fastest when encoding many signals at once.
155
+ 'cholesky' — Single-signal OMP-Cholesky; lower memory footprint.
156
+ check_dict : bool
157
+ Whether to verify that dictionary atoms are unit-norm (default True).
158
+ """
159
+
160
+ def __init__(
161
+ self,
162
+ n_nonzero_coefs: int,
163
+ mode: str = "batch",
164
+ check_dict: bool = True,
165
+ ) -> None:
166
+ if n_nonzero_coefs < 1:
167
+ raise ValueError("n_nonzero_coefs must be >= 1.")
168
+ if mode not in ("batch", "cholesky"):
169
+ raise ValueError("mode must be 'batch' or 'cholesky'.")
170
+ self.n_nonzero_coefs = n_nonzero_coefs
171
+ self.mode = mode
172
+ self.check_dict = check_dict
173
+
174
+ def encode(
175
+ self,
176
+ X: np.ndarray,
177
+ D: np.ndarray,
178
+ G: np.ndarray | None = None,
179
+ ) -> np.ndarray:
180
+ """
181
+ Compute sparse codes for each column of X.
182
+
183
+ Parameters
184
+ ----------
185
+ X : np.ndarray, shape (n_features, n_samples)
186
+ D : np.ndarray, shape (n_features, n_atoms)
187
+ G : np.ndarray or None, shape (n_atoms, n_atoms)
188
+ Precomputed Gram matrix D.T @ D. Required for 'batch' mode;
189
+ computed internally if not supplied.
190
+
191
+ Returns
192
+ -------
193
+ Gamma : np.ndarray, shape (n_atoms, n_samples)
194
+ """
195
+ X = np.asarray(X, dtype=float)
196
+ D = np.asarray(D, dtype=float)
197
+
198
+ if X.ndim == 1:
199
+ X = X[:, np.newaxis]
200
+
201
+ if self.check_dict:
202
+ _check_dict_normalized(D)
203
+
204
+ T = self.n_nonzero_coefs
205
+
206
+ if self.mode == "batch":
207
+ if G is None:
208
+ G = D.T @ D
209
+ DtX = D.T @ X
210
+ return batch_omp(DtX, G, T)
211
+
212
+ # cholesky mode — signal by signal
213
+ n_atoms = D.shape[1]
214
+ n_samples = X.shape[1]
215
+ Gamma = np.zeros((n_atoms, n_samples))
216
+ for i in range(n_samples):
217
+ Gamma[:, i] = omp_cholesky(D, X[:, i], T)
218
+ return Gamma
@@ -0,0 +1,67 @@
1
+ """
2
+ Utility functions shared across sparse coding algorithms.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import numpy as np
8
+
9
+
10
+ def normalize_columns(D: np.ndarray) -> np.ndarray:
11
+ """Return D with each column scaled to unit L2-norm.
12
+
13
+ Columns whose norm is below 1e-10 are left unchanged to avoid division
14
+ by zero.
15
+ """
16
+ norms = np.sqrt((D * D).sum(axis=0))
17
+ norms = np.where(norms < 1e-10, 1.0, norms)
18
+ return D / norms
19
+
20
+
21
+ def col_norms_squared(X: np.ndarray, block_size: int = 2000) -> np.ndarray:
22
+ """Compute squared L2-norm of each column of X in blocks (memory-safe).
23
+
24
+ Parameters
25
+ ----------
26
+ X : np.ndarray, shape (n_features, n_samples)
27
+ block_size : int
28
+ Number of columns to process at a time.
29
+
30
+ Returns
31
+ -------
32
+ norms2 : np.ndarray, shape (n_samples,)
33
+ """
34
+ n_samples = X.shape[1]
35
+ norms2 = np.zeros(n_samples)
36
+ for start in range(0, n_samples, block_size):
37
+ end = min(start + block_size, n_samples)
38
+ norms2[start:end] = (X[:, start:end] ** 2).sum(axis=0)
39
+ return norms2
40
+
41
+
42
+ def rep_error_squared(
43
+ X: np.ndarray,
44
+ D: np.ndarray,
45
+ Gamma: np.ndarray,
46
+ block_size: int = 2000,
47
+ ) -> np.ndarray:
48
+ """Per-signal squared reconstruction error |x_i - D gamma_i|^2.
49
+
50
+ Parameters
51
+ ----------
52
+ X : np.ndarray, shape (n_features, n_samples)
53
+ D : np.ndarray, shape (n_features, n_atoms)
54
+ Gamma : np.ndarray, shape (n_atoms, n_samples)
55
+ block_size : int
56
+
57
+ Returns
58
+ -------
59
+ err2 : np.ndarray, shape (n_samples,)
60
+ """
61
+ n_samples = X.shape[1]
62
+ err2 = np.zeros(n_samples)
63
+ for start in range(0, n_samples, block_size):
64
+ end = min(start + block_size, n_samples)
65
+ diff = X[:, start:end] - D @ Gamma[:, start:end]
66
+ err2[start:end] = (diff ** 2).sum(axis=0)
67
+ return err2
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: reppi
3
+ Version: 0.1.0
4
+ Summary: A Python toolkit for representation-based learning/classification algorithms
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: numpy>=2.4.4
7
+ Requires-Dist: scipy>=1.17.1
@@ -0,0 +1,21 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/reppi/__init__.py
4
+ src/reppi/base.py
5
+ src/reppi/exceptions.py
6
+ src/reppi.egg-info/PKG-INFO
7
+ src/reppi.egg-info/SOURCES.txt
8
+ src/reppi.egg-info/dependency_links.txt
9
+ src/reppi.egg-info/requires.txt
10
+ src/reppi.egg-info/top_level.txt
11
+ src/reppi/dictionary/__init__.py
12
+ src/reppi/dictionary/ksvd.py
13
+ src/reppi/dictionary/lc-ksvd.py
14
+ src/reppi/dictionary/lc_ksvd.py
15
+ src/reppi/linear/__init__.py
16
+ src/reppi/linear/crc.py
17
+ src/reppi/linear/lrc.py
18
+ src/reppi/linear/lsr.py
19
+ src/reppi/sparse/__init__.py
20
+ src/reppi/sparse/omp.py
21
+ src/reppi/sparse/src.py
@@ -0,0 +1,2 @@
1
+ numpy>=2.4.4
2
+ scipy>=1.17.1
@@ -0,0 +1 @@
1
+ reppi