fdnkit 1.0.0__py3-none-any.whl
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.
- fdnkit/__init__.py +94 -0
- fdnkit/classify.py +310 -0
- fdnkit/cli.py +175 -0
- fdnkit/dfa.py +88 -0
- fdnkit/features.py +212 -0
- fdnkit/fodn.py +346 -0
- fdnkit/io.py +163 -0
- fdnkit/mfdfa.py +233 -0
- fdnkit/preprocessing.py +125 -0
- fdnkit/synthetic.py +173 -0
- fdnkit/viz.py +146 -0
- fdnkit-1.0.0.dist-info/METADATA +192 -0
- fdnkit-1.0.0.dist-info/RECORD +16 -0
- fdnkit-1.0.0.dist-info/WHEEL +4 -0
- fdnkit-1.0.0.dist-info/entry_points.txt +2 -0
- fdnkit-1.0.0.dist-info/licenses/LICENSE +21 -0
fdnkit/features.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Assemble tidy per-trial / per-segment feature tables.
|
|
2
|
+
|
|
3
|
+
Combines the DFA, MFDFA, and FODN analyses into flat dictionaries and pandas
|
|
4
|
+
DataFrames (one row per trial or segment). This is the port and generalization
|
|
5
|
+
of the original feature extractor -- but computed directly from
|
|
6
|
+
arrays rather than by scraping a directory of CSVs.
|
|
7
|
+
|
|
8
|
+
The five "core" features reproduce the set used in Beeram et al. (2026):
|
|
9
|
+
``MeanAlpha``, ``VarAlpha``, ``LeadingEig``, ``MF_DFA_H``, ``MF_DFA_Hq_mean``.
|
|
10
|
+
:func:`extract_features` additionally returns a richer set (multifractal width,
|
|
11
|
+
sparseness, hub concentration, ...) that callers can opt into.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
from .dfa import dfa
|
|
20
|
+
from .fodn import fit_fodn
|
|
21
|
+
from .mfdfa import mfdfa
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"CORE_FEATURES",
|
|
25
|
+
"dfa_features",
|
|
26
|
+
"mfdfa_features",
|
|
27
|
+
"fodn_features",
|
|
28
|
+
"extract_features",
|
|
29
|
+
"feature_table",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
CORE_FEATURES = ["MeanAlpha", "VarAlpha", "LeadingEig", "MF_DFA_H", "MF_DFA_Hq_mean"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _clean_channels(signals):
|
|
36
|
+
x = np.asarray(signals, dtype=float)
|
|
37
|
+
if x.ndim == 1:
|
|
38
|
+
x = x[None, :]
|
|
39
|
+
if x.ndim != 2:
|
|
40
|
+
raise ValueError("signals must be 1-D or 2-D (n_channels, n_samples)")
|
|
41
|
+
return x
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def dfa_features(signals, scales=None, order: int = 1, prefix: str = "DFA") -> dict:
|
|
45
|
+
"""Per-channel DFA Hurst, summarized across channels.
|
|
46
|
+
|
|
47
|
+
Returns ``{<prefix>_H_mean, <prefix>_H_std, <prefix>_H_max, <prefix>_H_min}``.
|
|
48
|
+
"""
|
|
49
|
+
x = _clean_channels(signals)
|
|
50
|
+
h = np.array([dfa(x[i], scales=scales, order=order).hurst for i in range(x.shape[0])])
|
|
51
|
+
h = h[np.isfinite(h)]
|
|
52
|
+
if h.size == 0:
|
|
53
|
+
return {}
|
|
54
|
+
return {
|
|
55
|
+
f"{prefix}_H_mean": float(h.mean()),
|
|
56
|
+
f"{prefix}_H_std": float(h.std()),
|
|
57
|
+
f"{prefix}_H_max": float(h.max()),
|
|
58
|
+
f"{prefix}_H_min": float(h.min()),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def mfdfa_features(signals, scales=None, q=None, order: int = 1, prefix: str = "MFDFA") -> dict:
|
|
63
|
+
"""Per-channel MFDFA, summarized across channels.
|
|
64
|
+
|
|
65
|
+
Returns generalized-Hurst mean/std, mean multifractal width ``delta_h``, and
|
|
66
|
+
the mean ``h(q)`` over all channels and moments.
|
|
67
|
+
"""
|
|
68
|
+
x = _clean_channels(signals)
|
|
69
|
+
hq_means, deltas, hq_all = [], [], []
|
|
70
|
+
for i in range(x.shape[0]):
|
|
71
|
+
res = mfdfa(x[i], scales=scales, q=q, order=order)
|
|
72
|
+
finite = res.hq[np.isfinite(res.hq)]
|
|
73
|
+
if finite.size == 0:
|
|
74
|
+
continue
|
|
75
|
+
hq_means.append(float(finite.mean()))
|
|
76
|
+
deltas.append(res.delta_h)
|
|
77
|
+
hq_all.append(finite)
|
|
78
|
+
if not hq_means:
|
|
79
|
+
return {}
|
|
80
|
+
hq_all = np.concatenate(hq_all)
|
|
81
|
+
return {
|
|
82
|
+
f"{prefix}_Hq_mean": float(np.mean(hq_means)),
|
|
83
|
+
f"{prefix}_Hq_std": float(np.std(hq_means)),
|
|
84
|
+
f"{prefix}_delta_h_mean": float(np.nanmean(deltas)),
|
|
85
|
+
f"{prefix}_Hq_grand_mean": float(hq_all.mean()),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def fodn_features(signals, *, n_iter: int = 10, lambda_: float = 0.5, num_fract: int = 50,
|
|
90
|
+
top_k: int = 3, prefix: str = "FODN", **fodn_kwargs) -> dict:
|
|
91
|
+
"""FODN network features for a multi-channel segment.
|
|
92
|
+
|
|
93
|
+
Returns fractional-order (alpha) mean/std, leading eigenvalue, network
|
|
94
|
+
sparseness, and the summed hub score of the top-``k`` channels.
|
|
95
|
+
"""
|
|
96
|
+
x = _clean_channels(signals)
|
|
97
|
+
if x.shape[0] < 2:
|
|
98
|
+
return {}
|
|
99
|
+
res = fit_fodn(x, n_iter=n_iter, lambda_=lambda_, num_fract=num_fract, **fodn_kwargs)
|
|
100
|
+
alpha = res.alpha[np.isfinite(res.alpha)]
|
|
101
|
+
if alpha.size == 0:
|
|
102
|
+
return {}
|
|
103
|
+
hub = res.dominant_eigvec
|
|
104
|
+
hub = hub / (hub.sum() + 1e-12)
|
|
105
|
+
k = max(1, min(top_k, hub.size))
|
|
106
|
+
top_hub = float(np.sort(hub)[-k:].sum())
|
|
107
|
+
return {
|
|
108
|
+
f"{prefix}_alpha_mean": float(alpha.mean()),
|
|
109
|
+
f"{prefix}_alpha_std": float(alpha.std()),
|
|
110
|
+
f"{prefix}_alpha_var": float(alpha.var()),
|
|
111
|
+
f"{prefix}_leading_eig": float(res.leading_eig),
|
|
112
|
+
f"{prefix}_sparseness": float(res.sparseness),
|
|
113
|
+
f"{prefix}_hub_top{k}": top_hub,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def extract_features(
|
|
118
|
+
signals,
|
|
119
|
+
*,
|
|
120
|
+
do_dfa: bool = True,
|
|
121
|
+
do_mfdfa: bool = True,
|
|
122
|
+
do_fodn: bool = True,
|
|
123
|
+
scales=None,
|
|
124
|
+
q=None,
|
|
125
|
+
order: int = 1,
|
|
126
|
+
fodn_kwargs: dict | None = None,
|
|
127
|
+
include_core_aliases: bool = True,
|
|
128
|
+
) -> dict:
|
|
129
|
+
"""Compute a single tidy feature row for one multi-channel segment.
|
|
130
|
+
|
|
131
|
+
Parameters
|
|
132
|
+
----------
|
|
133
|
+
signals : array-like, shape (n_channels, n_samples)
|
|
134
|
+
do_dfa, do_mfdfa, do_fodn : bool
|
|
135
|
+
Toggle each analysis family.
|
|
136
|
+
scales, q, order : see the analysis modules.
|
|
137
|
+
fodn_kwargs : dict, optional
|
|
138
|
+
Extra keyword arguments for :func:`fdnkit.fodn.fit_fodn`.
|
|
139
|
+
include_core_aliases : bool
|
|
140
|
+
Also emit the five canonical column names in :data:`CORE_FEATURES`
|
|
141
|
+
(``MeanAlpha`` etc.) so results line up with the reference study.
|
|
142
|
+
|
|
143
|
+
Returns
|
|
144
|
+
-------
|
|
145
|
+
dict
|
|
146
|
+
Feature name -> value.
|
|
147
|
+
"""
|
|
148
|
+
feats: dict = {}
|
|
149
|
+
if do_dfa:
|
|
150
|
+
feats.update(dfa_features(signals, scales=scales, order=order))
|
|
151
|
+
if do_mfdfa:
|
|
152
|
+
feats.update(mfdfa_features(signals, scales=scales, q=q, order=order))
|
|
153
|
+
if do_fodn:
|
|
154
|
+
feats.update(fodn_features(signals, **(fodn_kwargs or {})))
|
|
155
|
+
|
|
156
|
+
if include_core_aliases:
|
|
157
|
+
alias = {}
|
|
158
|
+
if "FODN_alpha_mean" in feats:
|
|
159
|
+
alias["MeanAlpha"] = feats["FODN_alpha_mean"]
|
|
160
|
+
if "FODN_alpha_var" in feats:
|
|
161
|
+
alias["VarAlpha"] = feats["FODN_alpha_var"]
|
|
162
|
+
if "FODN_leading_eig" in feats:
|
|
163
|
+
alias["LeadingEig"] = feats["FODN_leading_eig"]
|
|
164
|
+
if "DFA_H_mean" in feats:
|
|
165
|
+
alias["MF_DFA_H"] = feats["DFA_H_mean"]
|
|
166
|
+
if "MFDFA_Hq_grand_mean" in feats:
|
|
167
|
+
alias["MF_DFA_Hq_mean"] = feats["MFDFA_Hq_grand_mean"]
|
|
168
|
+
feats.update(alias)
|
|
169
|
+
return feats
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def feature_table(trials, *, id_key="trial_id", group_key="group", label_key="label",
|
|
173
|
+
progress: bool = False, **extract_kwargs) -> pd.DataFrame:
|
|
174
|
+
"""Build a per-trial feature DataFrame from an iterable of trial records.
|
|
175
|
+
|
|
176
|
+
Parameters
|
|
177
|
+
----------
|
|
178
|
+
trials : iterable of dict
|
|
179
|
+
Each record must have a ``"signals"`` array of shape
|
|
180
|
+
``(n_channels, n_samples)`` and may carry ``trial_id``, ``group``
|
|
181
|
+
(e.g. subject id, for honest CV), and ``label``.
|
|
182
|
+
id_key, group_key, label_key : str
|
|
183
|
+
Keys copied through to identifier columns when present.
|
|
184
|
+
progress : bool
|
|
185
|
+
Print a short progress line per trial.
|
|
186
|
+
**extract_kwargs
|
|
187
|
+
Forwarded to :func:`extract_features`.
|
|
188
|
+
|
|
189
|
+
Returns
|
|
190
|
+
-------
|
|
191
|
+
pandas.DataFrame
|
|
192
|
+
One row per trial; identifier columns first, then features.
|
|
193
|
+
"""
|
|
194
|
+
rows = []
|
|
195
|
+
trials = list(trials)
|
|
196
|
+
for i, rec in enumerate(trials):
|
|
197
|
+
if "signals" not in rec:
|
|
198
|
+
raise KeyError("each trial record needs a 'signals' array")
|
|
199
|
+
if progress:
|
|
200
|
+
print(f"[fdnkit] features {i + 1}/{len(trials)}", flush=True)
|
|
201
|
+
feats = extract_features(rec["signals"], **extract_kwargs)
|
|
202
|
+
row = {}
|
|
203
|
+
for key, col in ((id_key, id_key), (group_key, group_key), (label_key, label_key)):
|
|
204
|
+
if key in rec:
|
|
205
|
+
row[col] = rec[key]
|
|
206
|
+
row.update(feats)
|
|
207
|
+
rows.append(row)
|
|
208
|
+
|
|
209
|
+
df = pd.DataFrame(rows)
|
|
210
|
+
id_cols = [c for c in (id_key, group_key, label_key) if c in df.columns]
|
|
211
|
+
other = [c for c in df.columns if c not in id_cols]
|
|
212
|
+
return df[id_cols + other]
|
fdnkit/fodn.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Fractional-Order Dynamical Network (FODN) model.
|
|
2
|
+
|
|
3
|
+
Ports the FODN estimator from the original reference implementation into a
|
|
4
|
+
clean, documented, sklearn-style estimator. The numerical procedure is preserved faithfully:
|
|
5
|
+
|
|
6
|
+
1. **Fractional order per channel** (:math:`\\alpha_i`) is estimated from the
|
|
7
|
+
variance decay of a Haar wavelet transform across dyadic scales.
|
|
8
|
+
2. **Grunwald-Letnikov fractional differencing** builds the fractional-derivative
|
|
9
|
+
signal ``z`` for each channel from its ``alpha``.
|
|
10
|
+
3. **Coupling matrix** ``A`` is fit by regularized least squares
|
|
11
|
+
(``z_k ≈ A x_{k-1}``), then refined by an ADMM-LASSO unknown-input step that
|
|
12
|
+
promotes a sparse directed network.
|
|
13
|
+
|
|
14
|
+
The model underlies the "fractional dynamical network" features in Beeram et al.
|
|
15
|
+
(2026, *Front. Netw. Physiol.* 6:1768476); the underlying method is due to
|
|
16
|
+
Gupta, Pequito & Bogdan (2018) and Xue & Bogdan (2017).
|
|
17
|
+
|
|
18
|
+
The heavy inner loop is unchanged from the validated source; the public surface
|
|
19
|
+
(:class:`FODN`, :func:`fit_fodn`, :class:`FODNResult`) is new.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
import scipy.linalg as LA
|
|
28
|
+
from scipy.special import gamma
|
|
29
|
+
|
|
30
|
+
__all__ = ["HaarWaveletTransform", "FODN", "FODNResult", "fit_fodn"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HaarWaveletTransform:
|
|
34
|
+
"""Fast in-place Haar wavelet transform of a 1-D signal.
|
|
35
|
+
|
|
36
|
+
Used by the FODN model to estimate a channel's fractional order from the
|
|
37
|
+
variance of detail coefficients across dyadic scales.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, x):
|
|
41
|
+
x = np.asarray(x, dtype=float)
|
|
42
|
+
if x.ndim > 1:
|
|
43
|
+
x = np.squeeze(x)
|
|
44
|
+
if x.ndim != 1:
|
|
45
|
+
raise ValueError("HaarWaveletTransform accepts only 1-D signals")
|
|
46
|
+
self.x = x
|
|
47
|
+
self._n = x.size
|
|
48
|
+
|
|
49
|
+
def normalize(self):
|
|
50
|
+
"""Subtract the mean in place."""
|
|
51
|
+
self.x = self.x - np.mean(self.x)
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def _dwt_haar(signal):
|
|
55
|
+
n_use = int(np.floor(signal.size / 2))
|
|
56
|
+
c = (signal[: 2 * n_use : 2] + signal[1 : 2 * n_use : 2]) / 2
|
|
57
|
+
s = signal[: 2 * n_use : 2] - c
|
|
58
|
+
c = 2 * c / np.sqrt(2)
|
|
59
|
+
s = -2 * s / np.sqrt(2)
|
|
60
|
+
return c, s
|
|
61
|
+
|
|
62
|
+
def transform(self):
|
|
63
|
+
"""Return approximation (``W``) and detail (``D``) coefficient tables."""
|
|
64
|
+
n_by2 = int(np.floor(self._n / 2))
|
|
65
|
+
approx = np.zeros((n_by2, n_by2))
|
|
66
|
+
detail = np.zeros((n_by2, n_by2))
|
|
67
|
+
j = self._n
|
|
68
|
+
signal = self.x
|
|
69
|
+
for i in range(int(np.floor(np.log2(self._n)))):
|
|
70
|
+
j = int(np.floor(j / 2))
|
|
71
|
+
w, d = self._dwt_haar(signal)
|
|
72
|
+
approx[i, :j] = w
|
|
73
|
+
detail[i, :j] = d
|
|
74
|
+
signal = w
|
|
75
|
+
return approx, detail
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class FODNResult:
|
|
80
|
+
"""Output of a fitted :class:`FODN` model.
|
|
81
|
+
|
|
82
|
+
Attributes
|
|
83
|
+
----------
|
|
84
|
+
alpha : numpy.ndarray
|
|
85
|
+
Per-channel fractional orders (length ``n_channels``).
|
|
86
|
+
coupling : numpy.ndarray
|
|
87
|
+
Estimated directed coupling matrix ``A`` (``n_channels x n_channels``).
|
|
88
|
+
eigenvalues : numpy.ndarray
|
|
89
|
+
Eigenvalues of ``A`` (complex).
|
|
90
|
+
leading_eig : float
|
|
91
|
+
Spectral radius: ``max |eigenvalue|`` (network gain / stability proxy).
|
|
92
|
+
dominant_eigvec : numpy.ndarray
|
|
93
|
+
Magnitude of the eigenvector for the largest-real-part eigenvalue;
|
|
94
|
+
a per-channel "hub" score.
|
|
95
|
+
sparseness : float
|
|
96
|
+
Fraction of ``|A|`` entries exceeding ``1e-2`` (network density).
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
alpha: np.ndarray
|
|
100
|
+
coupling: np.ndarray
|
|
101
|
+
eigenvalues: np.ndarray
|
|
102
|
+
leading_eig: float
|
|
103
|
+
dominant_eigvec: np.ndarray
|
|
104
|
+
sparseness: float
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class FODN:
|
|
108
|
+
"""Fractional-Order Dynamical Network estimator.
|
|
109
|
+
|
|
110
|
+
Parameters
|
|
111
|
+
----------
|
|
112
|
+
num_inputs : int, optional
|
|
113
|
+
Number of unknown inputs for the LASSO step. Defaults to
|
|
114
|
+
``floor(n_channels / 2)``.
|
|
115
|
+
num_fract : int
|
|
116
|
+
Truncation length of the Grunwald-Letnikov fractional-difference kernel.
|
|
117
|
+
n_iter : int
|
|
118
|
+
Number of ADMM refinement iterations.
|
|
119
|
+
lambda_ : float
|
|
120
|
+
LASSO sparsity weight for the unknown-input estimate.
|
|
121
|
+
verbose : bool
|
|
122
|
+
Print per-iteration MSE and timing.
|
|
123
|
+
|
|
124
|
+
Attributes (after :meth:`fit`)
|
|
125
|
+
------------------------------
|
|
126
|
+
alpha_ : numpy.ndarray
|
|
127
|
+
Per-channel fractional orders.
|
|
128
|
+
coupling_ : numpy.ndarray
|
|
129
|
+
Final coupling matrix ``A``.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(self, num_inputs=None, num_fract=50, n_iter=10, lambda_=0.5, verbose=False):
|
|
133
|
+
self.num_inputs = num_inputs
|
|
134
|
+
self.num_fract = num_fract
|
|
135
|
+
self.n_iter = n_iter
|
|
136
|
+
self.lambda_ = lambda_
|
|
137
|
+
self.verbose = verbose
|
|
138
|
+
|
|
139
|
+
# populated during fit
|
|
140
|
+
self._n_ch = None
|
|
141
|
+
self._k = None
|
|
142
|
+
self._order = None
|
|
143
|
+
self._z = None
|
|
144
|
+
self._b = None
|
|
145
|
+
self._a_hist = None
|
|
146
|
+
self._u = None
|
|
147
|
+
self._pre = None
|
|
148
|
+
self.alpha_ = None
|
|
149
|
+
self.coupling_ = None
|
|
150
|
+
|
|
151
|
+
# ---- fractional order estimation ---------------------------------------
|
|
152
|
+
def _fractional_order(self, x):
|
|
153
|
+
num_scales = int(np.floor(np.log2(self._k)))
|
|
154
|
+
log_scales = np.zeros(num_scales)
|
|
155
|
+
scale = np.arange(1, num_scales + 1)
|
|
156
|
+
|
|
157
|
+
wt = HaarWaveletTransform(x)
|
|
158
|
+
wt.normalize()
|
|
159
|
+
_, detail = wt.transform()
|
|
160
|
+
j = int(np.floor(self._k / 2))
|
|
161
|
+
for i in range(num_scales - 1):
|
|
162
|
+
y = detail[i, :j]
|
|
163
|
+
variance = np.var(y, ddof=1)
|
|
164
|
+
if variance <= 0: # guard log2(0)
|
|
165
|
+
variance = 1e-10
|
|
166
|
+
log_scales[i] = np.log2(variance)
|
|
167
|
+
j = int(np.floor(j / 2))
|
|
168
|
+
p = np.polyfit(scale[: num_scales - 1], log_scales[: num_scales - 1], 1)
|
|
169
|
+
return p[0] / 2
|
|
170
|
+
|
|
171
|
+
def _estimate_order(self, x):
|
|
172
|
+
self._order = np.array([self._fractional_order(x[i, :]) for i in range(self._n_ch)])
|
|
173
|
+
|
|
174
|
+
def _update_z(self, x):
|
|
175
|
+
self._z = np.empty((self._n_ch, self._k))
|
|
176
|
+
j = np.arange(0, self.num_fract + 1)
|
|
177
|
+
for i in range(self._n_ch):
|
|
178
|
+
prefactor = gamma(-self._order[i] + j) / gamma(-self._order[i]) / gamma(j + 1)
|
|
179
|
+
y = np.convolve(x[i, :], prefactor)
|
|
180
|
+
self._z[i, :] = y[: self._k]
|
|
181
|
+
|
|
182
|
+
# ---- coupling matrix ----------------------------------------------------
|
|
183
|
+
def _heuristic_b(self, a):
|
|
184
|
+
b = np.zeros((self._n_ch, self._n_ch))
|
|
185
|
+
b[np.abs(a) > 0.01] = a[np.abs(a) > 0.01]
|
|
186
|
+
_, r = LA.qr(b)
|
|
187
|
+
col_ind = np.where(np.abs(np.diag(r)) > 1e-7)
|
|
188
|
+
if np.size(col_ind[0]) < self.num_inputs:
|
|
189
|
+
self._b = np.vstack(
|
|
190
|
+
(np.eye(self.num_inputs), np.zeros((self._n_ch - self.num_inputs, self.num_inputs)))
|
|
191
|
+
)
|
|
192
|
+
else:
|
|
193
|
+
col_ind = col_ind[0][: self.num_inputs]
|
|
194
|
+
self._b = b[:, col_ind]
|
|
195
|
+
if np.linalg.matrix_rank(b) < self.num_inputs:
|
|
196
|
+
# fall back to a well-conditioned selector instead of failing
|
|
197
|
+
self._b = np.vstack(
|
|
198
|
+
(np.eye(self.num_inputs), np.zeros((self._n_ch - self.num_inputs, self.num_inputs)))
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def _least_squares(self, y, x):
|
|
202
|
+
x_use = np.vstack((np.zeros((1, self._n_ch)), x[:-1, :]))
|
|
203
|
+
reg = 1e-8 * np.eye(x_use.shape[1]) # avoid singular normal equations
|
|
204
|
+
a = np.matmul(np.matmul(y.T, x_use), LA.inv(np.matmul(x_use.T, x_use) + reg))
|
|
205
|
+
mse = LA.norm(y - np.matmul(x_use, a.T), axis=0) ** 2 / self._k
|
|
206
|
+
return a, np.mean(mse)
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def _factor(a, rho):
|
|
210
|
+
m, n = np.shape(a)
|
|
211
|
+
if m >= n:
|
|
212
|
+
lower = LA.cholesky(np.matmul(a.T, a) + rho * np.eye(n), lower=True)
|
|
213
|
+
else:
|
|
214
|
+
lower = LA.cholesky(np.eye(m) + 1 / rho * np.matmul(a, a.T), lower=True)
|
|
215
|
+
return lower, lower.T
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def _shrinkage(x, kappa):
|
|
219
|
+
return np.maximum(0, x - kappa) - np.maximum(0, -x - kappa)
|
|
220
|
+
|
|
221
|
+
class _PreComputed:
|
|
222
|
+
def __init__(self, b, rho):
|
|
223
|
+
self.l, self.u = FODN._factor(b, rho)
|
|
224
|
+
self.l_inv = LA.inv(self.l)
|
|
225
|
+
self.u_inv = LA.inv(self.u)
|
|
226
|
+
|
|
227
|
+
def _lasso(self, b_vec, lambda_):
|
|
228
|
+
a = self._b
|
|
229
|
+
b_vec = np.reshape(b_vec, (np.size(b_vec), 1))
|
|
230
|
+
max_iter, abstol, reltol = 100, 1e-4, 1e-2
|
|
231
|
+
m, n = np.shape(a)
|
|
232
|
+
atb = np.matmul(a.T, b_vec)
|
|
233
|
+
rho = 1 / lambda_
|
|
234
|
+
alpha = 1.0
|
|
235
|
+
|
|
236
|
+
z = np.zeros((n, 1))
|
|
237
|
+
u = np.zeros((n, 1))
|
|
238
|
+
l_inv, u_inv = self._pre.l_inv, self._pre.u_inv
|
|
239
|
+
|
|
240
|
+
for _ in range(max_iter):
|
|
241
|
+
q = atb + rho * (z - u)
|
|
242
|
+
if m >= n:
|
|
243
|
+
x = np.matmul(u_inv, np.matmul(l_inv, q))
|
|
244
|
+
else:
|
|
245
|
+
x = q / rho - np.matmul(
|
|
246
|
+
a.T, np.matmul(LA.inv(u_inv), np.matmul(LA.inv(l_inv), np.matmul(a, q)))
|
|
247
|
+
) / rho**2
|
|
248
|
+
|
|
249
|
+
z_old = np.array(z)
|
|
250
|
+
x_hat = alpha * x + (1 - alpha) * z_old
|
|
251
|
+
z = self._shrinkage(x_hat + u, lambda_ / rho)
|
|
252
|
+
u += x_hat - z
|
|
253
|
+
|
|
254
|
+
r_norm = LA.norm(x - z)
|
|
255
|
+
s_norm = LA.norm(-rho * (z - z_old))
|
|
256
|
+
eps_pri = np.sqrt(n) * abstol + reltol * np.max((LA.norm(x), LA.norm(-z)))
|
|
257
|
+
eps_dual = np.sqrt(n) * abstol + reltol * LA.norm(rho * u)
|
|
258
|
+
if r_norm < eps_pri and s_norm < eps_dual:
|
|
259
|
+
break
|
|
260
|
+
return np.squeeze(z)
|
|
261
|
+
|
|
262
|
+
# ---- public API ---------------------------------------------------------
|
|
263
|
+
def fit(self, x):
|
|
264
|
+
"""Fit the FODN model to a ``(n_channels, n_timepoints)`` array.
|
|
265
|
+
|
|
266
|
+
Returns ``self``; populates :attr:`alpha_` and :attr:`coupling_`.
|
|
267
|
+
"""
|
|
268
|
+
import time
|
|
269
|
+
|
|
270
|
+
x = np.asarray(x, dtype=float)
|
|
271
|
+
if x.ndim != 2:
|
|
272
|
+
raise ValueError("x must be 2-D (n_channels, n_timepoints)")
|
|
273
|
+
self._n_ch, self._k = x.shape
|
|
274
|
+
if self._n_ch < 2:
|
|
275
|
+
raise ValueError("FODN needs more than one channel")
|
|
276
|
+
if self._k < self._n_ch:
|
|
277
|
+
raise ValueError("number of timepoints must be >= number of channels")
|
|
278
|
+
if self.num_inputs is None:
|
|
279
|
+
self.num_inputs = int(np.floor(self._n_ch / 2))
|
|
280
|
+
self.num_inputs = max(1, int(self.num_inputs))
|
|
281
|
+
|
|
282
|
+
self._a_hist = np.empty((self.n_iter + 1, self._n_ch, self._n_ch))
|
|
283
|
+
self._u = np.zeros((self.num_inputs, self._k))
|
|
284
|
+
|
|
285
|
+
self._estimate_order(x)
|
|
286
|
+
self._update_z(x)
|
|
287
|
+
self._a_hist[0], mse = self._least_squares(self._z.T, x.T)
|
|
288
|
+
self._heuristic_b(self._a_hist[0])
|
|
289
|
+
self._pre = self._PreComputed(self._b, 1 / self.lambda_)
|
|
290
|
+
|
|
291
|
+
t0 = time.time()
|
|
292
|
+
if self.verbose:
|
|
293
|
+
print(f"beginning mse = {mse:.6f}")
|
|
294
|
+
for it in range(self.n_iter):
|
|
295
|
+
for k in range(1, self._k):
|
|
296
|
+
residual = self._z[:, k] - np.matmul(self._a_hist[it], x[:, k - 1])
|
|
297
|
+
self._u[:, k] = self._lasso(residual, self.lambda_)
|
|
298
|
+
self._a_hist[it + 1], mse = self._least_squares(
|
|
299
|
+
(self._z - np.matmul(self._b, self._u)).T, x.T
|
|
300
|
+
)
|
|
301
|
+
if self.verbose:
|
|
302
|
+
print(f"iter {it}: mse = {mse:.6f}")
|
|
303
|
+
if self.verbose:
|
|
304
|
+
print(f"time taken = {time.time() - t0:.3f}s")
|
|
305
|
+
|
|
306
|
+
self.alpha_ = self._order
|
|
307
|
+
self.coupling_ = self._a_hist[-1]
|
|
308
|
+
return self
|
|
309
|
+
|
|
310
|
+
def result(self) -> FODNResult:
|
|
311
|
+
"""Assemble a :class:`FODNResult` (eigen-decomposition + summaries)."""
|
|
312
|
+
if self.coupling_ is None:
|
|
313
|
+
raise RuntimeError("call fit() before result()")
|
|
314
|
+
a = self.coupling_
|
|
315
|
+
w, v = np.linalg.eig(a)
|
|
316
|
+
leading = float(np.max(np.abs(w)))
|
|
317
|
+
dom = np.abs(v[:, int(np.argmax(w.real))])
|
|
318
|
+
sparseness = float(np.count_nonzero(np.abs(a) > 0.01) / a.size)
|
|
319
|
+
return FODNResult(
|
|
320
|
+
alpha=self.alpha_,
|
|
321
|
+
coupling=a,
|
|
322
|
+
eigenvalues=w,
|
|
323
|
+
leading_eig=leading,
|
|
324
|
+
dominant_eigvec=dom,
|
|
325
|
+
sparseness=sparseness,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def fit_fodn(x, *, num_inputs=None, num_fract=50, n_iter=10, lambda_=0.5, verbose=False) -> FODNResult:
|
|
330
|
+
"""Functional wrapper: fit a :class:`FODN` and return its :class:`FODNResult`.
|
|
331
|
+
|
|
332
|
+
Parameters
|
|
333
|
+
----------
|
|
334
|
+
x : array-like, shape (n_channels, n_timepoints)
|
|
335
|
+
Multi-channel signal segment.
|
|
336
|
+
num_inputs, num_fract, n_iter, lambda_, verbose
|
|
337
|
+
Passed through to :class:`FODN`.
|
|
338
|
+
"""
|
|
339
|
+
model = FODN(
|
|
340
|
+
num_inputs=num_inputs,
|
|
341
|
+
num_fract=num_fract,
|
|
342
|
+
n_iter=n_iter,
|
|
343
|
+
lambda_=lambda_,
|
|
344
|
+
verbose=verbose,
|
|
345
|
+
).fit(x)
|
|
346
|
+
return model.result()
|