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 +7 -0
- reppi-0.1.0/README.md +0 -0
- reppi-0.1.0/pyproject.toml +22 -0
- reppi-0.1.0/setup.cfg +4 -0
- reppi-0.1.0/src/reppi/__init__.py +20 -0
- reppi-0.1.0/src/reppi/base.py +70 -0
- reppi-0.1.0/src/reppi/dictionary/__init__.py +6 -0
- reppi-0.1.0/src/reppi/dictionary/ksvd.py +292 -0
- reppi-0.1.0/src/reppi/dictionary/lc-ksvd.py +0 -0
- reppi-0.1.0/src/reppi/dictionary/lc_ksvd.py +542 -0
- reppi-0.1.0/src/reppi/exceptions.py +23 -0
- reppi-0.1.0/src/reppi/linear/__init__.py +0 -0
- reppi-0.1.0/src/reppi/linear/crc.py +0 -0
- reppi-0.1.0/src/reppi/linear/lrc.py +0 -0
- reppi-0.1.0/src/reppi/linear/lsr.py +0 -0
- reppi-0.1.0/src/reppi/sparse/__init__.py +5 -0
- reppi-0.1.0/src/reppi/sparse/omp.py +218 -0
- reppi-0.1.0/src/reppi/sparse/src.py +67 -0
- reppi-0.1.0/src/reppi.egg-info/PKG-INFO +7 -0
- reppi-0.1.0/src/reppi.egg-info/SOURCES.txt +21 -0
- reppi-0.1.0/src/reppi.egg-info/dependency_links.txt +1 -0
- reppi-0.1.0/src/reppi.egg-info/requires.txt +2 -0
- reppi-0.1.0/src/reppi.egg-info/top_level.txt +1 -0
reppi-0.1.0/PKG-INFO
ADDED
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,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,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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
reppi
|