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/__init__.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""FDNkit -- Fractional Dynamical Network & Multifractal toolkit for iEEG.
|
|
2
|
+
|
|
3
|
+
Turn intracranial-EEG recordings into fractal / fractional-dynamical-network
|
|
4
|
+
features and evaluate them honestly:
|
|
5
|
+
|
|
6
|
+
* :mod:`fdnkit.dfa` -- monofractal DFA Hurst exponent.
|
|
7
|
+
* :mod:`fdnkit.mfdfa` -- multifractal generalized Hurst ``h(q)`` and spectrum.
|
|
8
|
+
* :mod:`fdnkit.fodn` -- fractional-order dynamical network (alpha, coupling A,
|
|
9
|
+
eigenvector hubs).
|
|
10
|
+
* :mod:`fdnkit.features` -- tidy per-trial feature tables.
|
|
11
|
+
* :mod:`fdnkit.classify` -- classification with subject-wise CV by default.
|
|
12
|
+
* :mod:`fdnkit.io`, :mod:`fdnkit.preprocessing`, :mod:`fdnkit.viz`,
|
|
13
|
+
:mod:`fdnkit.synthetic` -- supporting IO, windowing, plots, and test signals.
|
|
14
|
+
|
|
15
|
+
Quickstart
|
|
16
|
+
----------
|
|
17
|
+
>>> from fdnkit.synthetic import synthetic_ieeg
|
|
18
|
+
>>> from fdnkit.features import extract_features
|
|
19
|
+
>>> sig, names = synthetic_ieeg(n_channels=6, n_samples=2000, seed=0)
|
|
20
|
+
>>> feats = extract_features(sig)
|
|
21
|
+
>>> sorted(feats)[:3]
|
|
22
|
+
['DFA_H_max', 'DFA_H_mean', 'DFA_H_min']
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
__version__ = "1.0.0"
|
|
28
|
+
|
|
29
|
+
from .dfa import DFAResult, dfa, hurst
|
|
30
|
+
from .features import (
|
|
31
|
+
CORE_FEATURES,
|
|
32
|
+
dfa_features,
|
|
33
|
+
extract_features,
|
|
34
|
+
feature_table,
|
|
35
|
+
fodn_features,
|
|
36
|
+
mfdfa_features,
|
|
37
|
+
)
|
|
38
|
+
from .fodn import FODN, FODNResult, fit_fodn
|
|
39
|
+
from .mfdfa import (
|
|
40
|
+
MFDFAResult,
|
|
41
|
+
delta_hq,
|
|
42
|
+
generalized_hurst,
|
|
43
|
+
mfdfa,
|
|
44
|
+
multifractal_spectrum,
|
|
45
|
+
)
|
|
46
|
+
from .preprocessing import flag_bad_channels, segment, sliding_windows, zscore
|
|
47
|
+
from .synthetic import binomial_cascade, fbm, fgn, synthetic_ieeg
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"__version__",
|
|
51
|
+
# dfa
|
|
52
|
+
"dfa",
|
|
53
|
+
"hurst",
|
|
54
|
+
"DFAResult",
|
|
55
|
+
# mfdfa
|
|
56
|
+
"mfdfa",
|
|
57
|
+
"generalized_hurst",
|
|
58
|
+
"delta_hq",
|
|
59
|
+
"multifractal_spectrum",
|
|
60
|
+
"MFDFAResult",
|
|
61
|
+
# fodn
|
|
62
|
+
"FODN",
|
|
63
|
+
"FODNResult",
|
|
64
|
+
"fit_fodn",
|
|
65
|
+
# features
|
|
66
|
+
"extract_features",
|
|
67
|
+
"feature_table",
|
|
68
|
+
"dfa_features",
|
|
69
|
+
"mfdfa_features",
|
|
70
|
+
"fodn_features",
|
|
71
|
+
"CORE_FEATURES",
|
|
72
|
+
# preprocessing
|
|
73
|
+
"zscore",
|
|
74
|
+
"flag_bad_channels",
|
|
75
|
+
"segment",
|
|
76
|
+
"sliding_windows",
|
|
77
|
+
# synthetic
|
|
78
|
+
"fgn",
|
|
79
|
+
"fbm",
|
|
80
|
+
"binomial_cascade",
|
|
81
|
+
"synthetic_ieeg",
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def __getattr__(name):
|
|
86
|
+
# Lazily expose the classification helpers to keep import light and
|
|
87
|
+
# optional-dependency-safe. Note: ``fdnkit.classify`` is the *module*
|
|
88
|
+
# (call ``fdnkit.classify.classify(...)`` or import the function directly);
|
|
89
|
+
# ``classify_dataframe`` and ``ClassificationResult`` are surfaced here.
|
|
90
|
+
if name in ("classify_dataframe", "ClassificationResult"):
|
|
91
|
+
from . import classify as _classify
|
|
92
|
+
|
|
93
|
+
return getattr(_classify, name)
|
|
94
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
fdnkit/classify.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Classification harness with honest, subject-wise cross-validation by default.
|
|
2
|
+
|
|
3
|
+
The reference study behind FDNkit found that evaluating an iEEG classifier with
|
|
4
|
+
row-wise splits leaked *patient identity* into the test set and inflated
|
|
5
|
+
accuracy. This module bakes the fix in:
|
|
6
|
+
|
|
7
|
+
* the default cross-validation is **leave-one-group-out** (subject-wise), and
|
|
8
|
+
``groups`` is *required* for it;
|
|
9
|
+
* trial-wise leave-one-out is available but must be requested explicitly and is
|
|
10
|
+
labeled *optimistic*;
|
|
11
|
+
* every run can attach a **subject-level permutation test** and bootstrap
|
|
12
|
+
confidence intervals.
|
|
13
|
+
|
|
14
|
+
Folds the honest-CV revalidation logic into a reusable API.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
import pandas as pd
|
|
23
|
+
from sklearn.linear_model import LogisticRegression
|
|
24
|
+
from sklearn.metrics import (
|
|
25
|
+
accuracy_score,
|
|
26
|
+
balanced_accuracy_score,
|
|
27
|
+
roc_auc_score,
|
|
28
|
+
)
|
|
29
|
+
from sklearn.model_selection import (
|
|
30
|
+
LeaveOneGroupOut,
|
|
31
|
+
LeaveOneOut,
|
|
32
|
+
StratifiedGroupKFold,
|
|
33
|
+
permutation_test_score,
|
|
34
|
+
)
|
|
35
|
+
from sklearn.pipeline import make_pipeline
|
|
36
|
+
from sklearn.preprocessing import StandardScaler
|
|
37
|
+
|
|
38
|
+
__all__ = ["ClassificationResult", "make_classifier", "classify", "classify_dataframe"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ClassificationResult:
|
|
43
|
+
"""Outcome of a cross-validated evaluation.
|
|
44
|
+
|
|
45
|
+
Attributes
|
|
46
|
+
----------
|
|
47
|
+
cv : str
|
|
48
|
+
Which scheme ran (``"loso"``, ``"loo"``, ``"group_kfold"``).
|
|
49
|
+
honest : bool
|
|
50
|
+
True when the scheme holds out whole subjects (no identity leakage).
|
|
51
|
+
n : int
|
|
52
|
+
Number of trials.
|
|
53
|
+
accuracy, balanced_accuracy, auc : float
|
|
54
|
+
Pooled out-of-fold metrics.
|
|
55
|
+
majority_baseline : float
|
|
56
|
+
Accuracy of always predicting the majority class.
|
|
57
|
+
permutation_p : float | None
|
|
58
|
+
p-value from the subject-level permutation test (if run).
|
|
59
|
+
permutation_chance : float | None
|
|
60
|
+
Mean permuted score (empirical chance level).
|
|
61
|
+
ci95 : tuple | None
|
|
62
|
+
Bootstrap 95% CI on balanced accuracy (if requested).
|
|
63
|
+
per_group : dict
|
|
64
|
+
Per-group balanced accuracy (subject-wise schemes only).
|
|
65
|
+
notes : str
|
|
66
|
+
Human-readable caveats.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
cv: str
|
|
70
|
+
honest: bool
|
|
71
|
+
n: int
|
|
72
|
+
accuracy: float
|
|
73
|
+
balanced_accuracy: float
|
|
74
|
+
auc: float
|
|
75
|
+
majority_baseline: float
|
|
76
|
+
permutation_p: float | None = None
|
|
77
|
+
permutation_chance: float | None = None
|
|
78
|
+
ci95: tuple | None = None
|
|
79
|
+
per_group: dict = field(default_factory=dict)
|
|
80
|
+
notes: str = ""
|
|
81
|
+
|
|
82
|
+
def summary(self) -> str:
|
|
83
|
+
"""A one-block textual report."""
|
|
84
|
+
lines = [
|
|
85
|
+
f"FDNkit classification ({self.cv}{'' if self.honest else ', OPTIMISTIC'})",
|
|
86
|
+
f" trials : {self.n}",
|
|
87
|
+
f" accuracy : {self.accuracy:.3f}",
|
|
88
|
+
f" balanced accuracy : {self.balanced_accuracy:.3f}",
|
|
89
|
+
f" ROC-AUC : {self.auc:.3f}",
|
|
90
|
+
f" majority baseline : {self.majority_baseline:.3f}",
|
|
91
|
+
]
|
|
92
|
+
if self.permutation_p is not None:
|
|
93
|
+
lines.append(
|
|
94
|
+
f" permutation test : chance={self.permutation_chance:.3f}, "
|
|
95
|
+
f"p={self.permutation_p:.4f}"
|
|
96
|
+
)
|
|
97
|
+
if self.ci95 is not None:
|
|
98
|
+
lines.append(f" bal-acc 95% CI : [{self.ci95[0]:.3f}, {self.ci95[1]:.3f}]")
|
|
99
|
+
if self.notes:
|
|
100
|
+
lines.append(f" note: {self.notes}")
|
|
101
|
+
return "\n".join(lines)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def make_classifier(C: float = 1.0, max_iter: int = 1000):
|
|
105
|
+
"""Standard-scaler + logistic-regression pipeline used throughout."""
|
|
106
|
+
return make_pipeline(StandardScaler(), LogisticRegression(C=C, max_iter=max_iter))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _bootstrap_ci(y_true, y_pred, n_boot=1000, seed=0):
|
|
110
|
+
rng = np.random.default_rng(seed)
|
|
111
|
+
n = len(y_true)
|
|
112
|
+
scores = []
|
|
113
|
+
for _ in range(n_boot):
|
|
114
|
+
idx = rng.integers(0, n, n)
|
|
115
|
+
yt, yp = y_true[idx], y_pred[idx]
|
|
116
|
+
if len(np.unique(yt)) < 2:
|
|
117
|
+
continue
|
|
118
|
+
scores.append(balanced_accuracy_score(yt, yp))
|
|
119
|
+
if not scores:
|
|
120
|
+
return None
|
|
121
|
+
return float(np.percentile(scores, 2.5)), float(np.percentile(scores, 97.5))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def classify(
|
|
125
|
+
X,
|
|
126
|
+
y,
|
|
127
|
+
groups=None,
|
|
128
|
+
*,
|
|
129
|
+
cv: str = "loso",
|
|
130
|
+
estimator=None,
|
|
131
|
+
n_splits: int = 5,
|
|
132
|
+
permutation: bool = True,
|
|
133
|
+
n_permutations: int = 1000,
|
|
134
|
+
bootstrap: bool = True,
|
|
135
|
+
random_state: int = 0,
|
|
136
|
+
) -> ClassificationResult:
|
|
137
|
+
"""Cross-validate a binary classifier with honest defaults.
|
|
138
|
+
|
|
139
|
+
Parameters
|
|
140
|
+
----------
|
|
141
|
+
X : array-like, shape (n_trials, n_features)
|
|
142
|
+
y : array-like, shape (n_trials,)
|
|
143
|
+
Binary labels.
|
|
144
|
+
groups : array-like, optional
|
|
145
|
+
Group (e.g. subject) id per trial. **Required** for ``cv in
|
|
146
|
+
{"loso", "group_kfold"}``.
|
|
147
|
+
cv : {"loso", "group_kfold", "loo"}
|
|
148
|
+
Cross-validation scheme. ``"loso"`` (leave-one-subject-out) is the
|
|
149
|
+
default and the only fully honest single-holdout option. ``"loo"`` is
|
|
150
|
+
trial-wise and *optimistic* (same subject can appear in train and test).
|
|
151
|
+
estimator : sklearn estimator, optional
|
|
152
|
+
Defaults to :func:`make_classifier`.
|
|
153
|
+
n_splits : int
|
|
154
|
+
Folds for ``cv="group_kfold"``.
|
|
155
|
+
permutation : bool
|
|
156
|
+
Run a (group-aware) permutation test.
|
|
157
|
+
n_permutations : int
|
|
158
|
+
Permutation count.
|
|
159
|
+
bootstrap : bool
|
|
160
|
+
Compute a bootstrap 95% CI on balanced accuracy.
|
|
161
|
+
random_state : int
|
|
162
|
+
Seed for permutation/bootstrap reproducibility.
|
|
163
|
+
|
|
164
|
+
Returns
|
|
165
|
+
-------
|
|
166
|
+
ClassificationResult
|
|
167
|
+
"""
|
|
168
|
+
X = np.asarray(X, dtype=float)
|
|
169
|
+
y = np.asarray(y).astype(int)
|
|
170
|
+
if X.ndim != 2:
|
|
171
|
+
raise ValueError("X must be 2-D (n_trials, n_features)")
|
|
172
|
+
if y.shape[0] != X.shape[0]:
|
|
173
|
+
raise ValueError("X and y length mismatch")
|
|
174
|
+
if len(np.unique(y)) < 2:
|
|
175
|
+
raise ValueError("need both classes present in y")
|
|
176
|
+
|
|
177
|
+
est = make_classifier() if estimator is None else estimator
|
|
178
|
+
cv = cv.lower()
|
|
179
|
+
honest = cv in ("loso", "group_kfold")
|
|
180
|
+
|
|
181
|
+
if cv in ("loso", "group_kfold") and groups is None:
|
|
182
|
+
raise ValueError(
|
|
183
|
+
f"cv='{cv}' is subject-wise and requires `groups` (one id per trial). "
|
|
184
|
+
"Pass groups=..., or use cv='loo' explicitly for an optimistic trial-wise estimate."
|
|
185
|
+
)
|
|
186
|
+
if groups is not None:
|
|
187
|
+
groups = np.asarray(groups)
|
|
188
|
+
|
|
189
|
+
if cv == "loso":
|
|
190
|
+
splitter = LeaveOneGroupOut()
|
|
191
|
+
split_iter = splitter.split(X, y, groups)
|
|
192
|
+
notes = ""
|
|
193
|
+
elif cv == "group_kfold":
|
|
194
|
+
n_groups = len(np.unique(groups))
|
|
195
|
+
k = min(n_splits, n_groups)
|
|
196
|
+
splitter = StratifiedGroupKFold(n_splits=k, shuffle=True, random_state=random_state)
|
|
197
|
+
split_iter = splitter.split(X, y, groups)
|
|
198
|
+
notes = f"stratified group {k}-fold"
|
|
199
|
+
elif cv == "loo":
|
|
200
|
+
splitter = LeaveOneOut()
|
|
201
|
+
split_iter = splitter.split(X)
|
|
202
|
+
notes = "trial-wise LOO ignores subject structure and is leakage-prone"
|
|
203
|
+
else:
|
|
204
|
+
raise ValueError(f"unknown cv scheme: {cv!r}")
|
|
205
|
+
|
|
206
|
+
preds = np.full(len(y), -1)
|
|
207
|
+
probs = np.full(len(y), np.nan)
|
|
208
|
+
for tr, te in split_iter:
|
|
209
|
+
if len(np.unique(y[tr])) < 2:
|
|
210
|
+
# a fold whose training set is single-class can't fit a classifier
|
|
211
|
+
preds[te] = int(round(np.mean(y[tr])))
|
|
212
|
+
probs[te] = np.mean(y[tr])
|
|
213
|
+
continue
|
|
214
|
+
model = est.fit(X[tr], y[tr])
|
|
215
|
+
preds[te] = model.predict(X[te])
|
|
216
|
+
if hasattr(model, "predict_proba"):
|
|
217
|
+
probs[te] = model.predict_proba(X[te])[:, 1]
|
|
218
|
+
else:
|
|
219
|
+
probs[te] = preds[te]
|
|
220
|
+
|
|
221
|
+
acc = accuracy_score(y, preds)
|
|
222
|
+
bal = balanced_accuracy_score(y, preds)
|
|
223
|
+
try:
|
|
224
|
+
auc = roc_auc_score(y, probs)
|
|
225
|
+
except ValueError:
|
|
226
|
+
auc = float("nan")
|
|
227
|
+
majority = max(np.mean(y), 1 - np.mean(y))
|
|
228
|
+
|
|
229
|
+
per_group: dict = {}
|
|
230
|
+
if honest and groups is not None:
|
|
231
|
+
for g in np.unique(groups):
|
|
232
|
+
m = groups == g
|
|
233
|
+
if m.sum() and len(np.unique(y[m])) >= 1:
|
|
234
|
+
per_group[str(g)] = float(balanced_accuracy_score(y[m], preds[m])) \
|
|
235
|
+
if len(np.unique(y[m])) >= 2 else float(accuracy_score(y[m], preds[m]))
|
|
236
|
+
|
|
237
|
+
perm_p = perm_chance = None
|
|
238
|
+
if permutation:
|
|
239
|
+
if cv == "loso":
|
|
240
|
+
cv_obj = LeaveOneGroupOut()
|
|
241
|
+
score, perm_scores, perm_p = permutation_test_score(
|
|
242
|
+
est, X, y, groups=groups, cv=cv_obj, scoring="accuracy",
|
|
243
|
+
n_permutations=n_permutations, random_state=random_state, n_jobs=1,
|
|
244
|
+
)
|
|
245
|
+
perm_chance = float(np.mean(perm_scores))
|
|
246
|
+
elif cv == "group_kfold":
|
|
247
|
+
cv_obj = StratifiedGroupKFold(n_splits=min(n_splits, len(np.unique(groups))),
|
|
248
|
+
shuffle=True, random_state=random_state)
|
|
249
|
+
score, perm_scores, perm_p = permutation_test_score(
|
|
250
|
+
est, X, y, groups=groups, cv=cv_obj, scoring="accuracy",
|
|
251
|
+
n_permutations=n_permutations, random_state=random_state, n_jobs=1,
|
|
252
|
+
)
|
|
253
|
+
perm_chance = float(np.mean(perm_scores))
|
|
254
|
+
# LOO permutation is not group-aware; skip to avoid a misleading p-value.
|
|
255
|
+
|
|
256
|
+
ci = _bootstrap_ci(y, preds, seed=random_state) if bootstrap else None
|
|
257
|
+
|
|
258
|
+
return ClassificationResult(
|
|
259
|
+
cv=cv, honest=honest, n=len(y), accuracy=float(acc),
|
|
260
|
+
balanced_accuracy=float(bal), auc=float(auc), majority_baseline=float(majority),
|
|
261
|
+
permutation_p=None if perm_p is None else float(perm_p),
|
|
262
|
+
permutation_chance=perm_chance, ci95=ci, per_group=per_group, notes=notes,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def classify_dataframe(
|
|
267
|
+
df: pd.DataFrame,
|
|
268
|
+
*,
|
|
269
|
+
feature_cols=None,
|
|
270
|
+
label_col: str = "label",
|
|
271
|
+
group_col: str = "group",
|
|
272
|
+
dropna: bool = True,
|
|
273
|
+
**kwargs,
|
|
274
|
+
) -> ClassificationResult:
|
|
275
|
+
"""Run :func:`classify` directly on a feature DataFrame.
|
|
276
|
+
|
|
277
|
+
Parameters
|
|
278
|
+
----------
|
|
279
|
+
df : pandas.DataFrame
|
|
280
|
+
Must contain ``label_col``; ``group_col`` is used when present (and is
|
|
281
|
+
required for the default subject-wise CV).
|
|
282
|
+
feature_cols : sequence of str, optional
|
|
283
|
+
Columns to use as features. Defaults to all numeric columns except the
|
|
284
|
+
label/group/identifier columns.
|
|
285
|
+
label_col, group_col : str
|
|
286
|
+
dropna : bool
|
|
287
|
+
Drop rows with missing features or label.
|
|
288
|
+
**kwargs
|
|
289
|
+
Forwarded to :func:`classify` (e.g. ``cv``, ``permutation``).
|
|
290
|
+
"""
|
|
291
|
+
if label_col not in df.columns:
|
|
292
|
+
raise KeyError(f"label column {label_col!r} not in DataFrame")
|
|
293
|
+
|
|
294
|
+
reserved = {label_col, group_col, "trial_id", "id"}
|
|
295
|
+
if feature_cols is None:
|
|
296
|
+
feature_cols = [
|
|
297
|
+
c for c in df.select_dtypes(include=[np.number]).columns if c not in reserved
|
|
298
|
+
]
|
|
299
|
+
if not feature_cols:
|
|
300
|
+
raise ValueError("no feature columns found")
|
|
301
|
+
|
|
302
|
+
use = df.copy()
|
|
303
|
+
subset = list(feature_cols) + [label_col]
|
|
304
|
+
if dropna:
|
|
305
|
+
use = use.dropna(subset=subset)
|
|
306
|
+
|
|
307
|
+
X = use[feature_cols].to_numpy()
|
|
308
|
+
y = use[label_col].to_numpy()
|
|
309
|
+
groups = use[group_col].to_numpy() if group_col in use.columns else None
|
|
310
|
+
return classify(X, y, groups=groups, **kwargs)
|
fdnkit/cli.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Command-line interface: ``fdnkit extract`` and ``fdnkit classify``.
|
|
2
|
+
|
|
3
|
+
Examples
|
|
4
|
+
--------
|
|
5
|
+
Extract features from an EDF or HDF5 recording into a one-row CSV::
|
|
6
|
+
|
|
7
|
+
fdnkit extract recording.edf --window 1.0 --out features.csv
|
|
8
|
+
|
|
9
|
+
Run a self-contained demo on synthetic data (no files needed)::
|
|
10
|
+
|
|
11
|
+
fdnkit demo --out demo_features.csv
|
|
12
|
+
|
|
13
|
+
Evaluate a feature CSV with honest subject-wise CV::
|
|
14
|
+
|
|
15
|
+
fdnkit classify features.csv --label label --group subject
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
import pandas as pd
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _cmd_extract(args):
|
|
28
|
+
from .features import feature_table
|
|
29
|
+
from .io import Recording, load_edf, load_h5, save_features
|
|
30
|
+
from .preprocessing import flag_bad_channels, segment, zscore
|
|
31
|
+
|
|
32
|
+
path = args.input
|
|
33
|
+
if path.lower().endswith((".edf", ".edf+", ".bdf")):
|
|
34
|
+
rec = load_edf(path)
|
|
35
|
+
elif path.lower().endswith((".h5", ".hdf5")):
|
|
36
|
+
rec = load_h5(path, fs=args.fs)
|
|
37
|
+
else:
|
|
38
|
+
raise SystemExit(f"unsupported input extension: {path}")
|
|
39
|
+
|
|
40
|
+
assert isinstance(rec, Recording)
|
|
41
|
+
signals = rec.signals
|
|
42
|
+
if args.drop_bad:
|
|
43
|
+
bad = set(flag_bad_channels(signals, rec.channel_names))
|
|
44
|
+
keep = [i for i in range(signals.shape[0]) if i not in bad]
|
|
45
|
+
signals = signals[keep]
|
|
46
|
+
print(f"[fdnkit] dropped {len(bad)} bad channel(s); kept {len(keep)}")
|
|
47
|
+
if args.zscore:
|
|
48
|
+
signals = zscore(signals)
|
|
49
|
+
|
|
50
|
+
win = int(args.window * rec.fs)
|
|
51
|
+
trials = []
|
|
52
|
+
for k, (_start, _stop, chunk) in enumerate(segment(signals, win)):
|
|
53
|
+
trials.append({
|
|
54
|
+
"trial_id": f"win{k}",
|
|
55
|
+
"group": args.group or "unknown",
|
|
56
|
+
"signals": chunk,
|
|
57
|
+
})
|
|
58
|
+
if not trials:
|
|
59
|
+
# whole-recording single window fallback
|
|
60
|
+
trials = [{"trial_id": "full", "group": args.group or "unknown", "signals": signals}]
|
|
61
|
+
|
|
62
|
+
df = feature_table(
|
|
63
|
+
trials,
|
|
64
|
+
do_fodn=not args.no_fodn,
|
|
65
|
+
progress=True,
|
|
66
|
+
fodn_kwargs={"n_iter": args.fodn_iter},
|
|
67
|
+
)
|
|
68
|
+
save_features(df, args.out)
|
|
69
|
+
print(f"[fdnkit] wrote {len(df)} row(s) x {df.shape[1]} cols -> {args.out}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _cmd_demo(args):
|
|
73
|
+
from .features import feature_table
|
|
74
|
+
from .io import save_features
|
|
75
|
+
from .synthetic import synthetic_ieeg
|
|
76
|
+
|
|
77
|
+
rng = np.random.default_rng(args.seed)
|
|
78
|
+
trials = []
|
|
79
|
+
for subj in range(args.subjects):
|
|
80
|
+
for t in range(args.trials_per_subject):
|
|
81
|
+
label = int(rng.random() < 0.5)
|
|
82
|
+
h = 0.7 + 0.06 * label # a faint, learnable class signal
|
|
83
|
+
sig, _ = synthetic_ieeg(
|
|
84
|
+
n_channels=args.channels, n_samples=args.samples,
|
|
85
|
+
hurst=h, seed=rng,
|
|
86
|
+
)
|
|
87
|
+
trials.append({
|
|
88
|
+
"trial_id": f"S{subj}_T{t}",
|
|
89
|
+
"group": f"S{subj}",
|
|
90
|
+
"label": label,
|
|
91
|
+
"signals": sig,
|
|
92
|
+
})
|
|
93
|
+
df = feature_table(trials, do_fodn=not args.no_fodn,
|
|
94
|
+
fodn_kwargs={"n_iter": args.fodn_iter}, progress=True)
|
|
95
|
+
save_features(df, args.out)
|
|
96
|
+
print(f"[fdnkit] demo wrote {len(df)} rows x {df.shape[1]} cols -> {args.out}")
|
|
97
|
+
print(f"[fdnkit] try: fdnkit classify {args.out} --label label --group group")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _cmd_classify(args):
|
|
101
|
+
from .classify import classify_dataframe
|
|
102
|
+
|
|
103
|
+
df = pd.read_csv(args.input)
|
|
104
|
+
res = classify_dataframe(
|
|
105
|
+
df,
|
|
106
|
+
label_col=args.label,
|
|
107
|
+
group_col=args.group,
|
|
108
|
+
cv=args.cv,
|
|
109
|
+
permutation=not args.no_permutation,
|
|
110
|
+
n_permutations=args.n_permutations,
|
|
111
|
+
bootstrap=not args.no_bootstrap,
|
|
112
|
+
)
|
|
113
|
+
print(res.summary())
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
117
|
+
p = argparse.ArgumentParser(prog="fdnkit", description=__doc__.splitlines()[0])
|
|
118
|
+
p.add_argument("--version", action="store_true", help="print version and exit")
|
|
119
|
+
sub = p.add_subparsers(dest="command")
|
|
120
|
+
|
|
121
|
+
pe = sub.add_parser("extract", help="extract features from a recording")
|
|
122
|
+
pe.add_argument("input", help="EDF/BDF or HDF5 file")
|
|
123
|
+
pe.add_argument("--out", default="features.csv", help="output CSV path")
|
|
124
|
+
pe.add_argument("--window", type=float, default=1.0, help="window length (seconds)")
|
|
125
|
+
pe.add_argument("--fs", type=float, default=1000.0, help="sampling rate for HDF5 without a time vector")
|
|
126
|
+
pe.add_argument("--group", default=None, help="group/subject id to tag rows with")
|
|
127
|
+
pe.add_argument("--zscore", action="store_true", help="z-score channels before analysis")
|
|
128
|
+
pe.add_argument("--drop-bad", action="store_true", help="auto-drop flat/EKG/DC channels")
|
|
129
|
+
pe.add_argument("--no-fodn", action="store_true", help="skip the (slow) FODN features")
|
|
130
|
+
pe.add_argument("--fodn-iter", type=int, default=5, help="FODN ADMM iterations")
|
|
131
|
+
pe.set_defaults(func=_cmd_extract)
|
|
132
|
+
|
|
133
|
+
pd_ = sub.add_parser("demo", help="generate a synthetic feature table (no data needed)")
|
|
134
|
+
pd_.add_argument("--out", default="demo_features.csv")
|
|
135
|
+
pd_.add_argument("--subjects", type=int, default=6)
|
|
136
|
+
pd_.add_argument("--trials-per-subject", type=int, default=8)
|
|
137
|
+
pd_.add_argument("--channels", type=int, default=6)
|
|
138
|
+
pd_.add_argument("--samples", type=int, default=2000)
|
|
139
|
+
pd_.add_argument("--fodn-iter", type=int, default=3)
|
|
140
|
+
pd_.add_argument("--no-fodn", action="store_true")
|
|
141
|
+
pd_.add_argument("--seed", type=int, default=0)
|
|
142
|
+
pd_.set_defaults(func=_cmd_demo)
|
|
143
|
+
|
|
144
|
+
pc = sub.add_parser("classify", help="honest cross-validated classification of a feature CSV")
|
|
145
|
+
pc.add_argument("input", help="feature CSV")
|
|
146
|
+
pc.add_argument("--label", default="label", help="label column")
|
|
147
|
+
pc.add_argument("--group", default="group", help="group/subject column")
|
|
148
|
+
pc.add_argument("--cv", default="loso", choices=["loso", "group_kfold", "loo"])
|
|
149
|
+
pc.add_argument("--no-permutation", action="store_true")
|
|
150
|
+
pc.add_argument("--n-permutations", type=int, default=1000)
|
|
151
|
+
pc.add_argument("--no-bootstrap", action="store_true")
|
|
152
|
+
pc.set_defaults(func=_cmd_classify)
|
|
153
|
+
|
|
154
|
+
return p
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main(argv=None):
|
|
158
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
159
|
+
parser = build_parser()
|
|
160
|
+
args = parser.parse_args(argv)
|
|
161
|
+
|
|
162
|
+
if getattr(args, "version", False):
|
|
163
|
+
from . import __version__
|
|
164
|
+
|
|
165
|
+
print(f"fdnkit {__version__}")
|
|
166
|
+
return 0
|
|
167
|
+
if not getattr(args, "command", None):
|
|
168
|
+
parser.print_help()
|
|
169
|
+
return 1
|
|
170
|
+
args.func(args)
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
if __name__ == "__main__":
|
|
175
|
+
raise SystemExit(main())
|
fdnkit/dfa.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Monofractal detrended fluctuation analysis (DFA).
|
|
2
|
+
|
|
3
|
+
DFA estimates the Hurst exponent ``H`` of a time series: the scaling exponent of
|
|
4
|
+
its detrended root-mean-square fluctuation against window size. It is the
|
|
5
|
+
``q = 2`` special case of :mod:`fdnkit.mfdfa`, exposed here as a lightweight,
|
|
6
|
+
single-purpose entry point.
|
|
7
|
+
|
|
8
|
+
Reference: Peng et al. (1994); the implementation matches the validated
|
|
9
|
+
reference and MATLAB code.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from .mfdfa import DEFAULT_SCALES, _fluctuations, _loglog_slope
|
|
19
|
+
|
|
20
|
+
__all__ = ["DFAResult", "dfa", "hurst"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class DFAResult:
|
|
25
|
+
"""Output of :func:`dfa`.
|
|
26
|
+
|
|
27
|
+
Attributes
|
|
28
|
+
----------
|
|
29
|
+
hurst : float
|
|
30
|
+
Hurst exponent (log-log slope of fluctuation vs scale).
|
|
31
|
+
scales : numpy.ndarray
|
|
32
|
+
Window sizes used.
|
|
33
|
+
fluct : numpy.ndarray
|
|
34
|
+
Fluctuation function ``F`` per scale.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
hurst: float
|
|
38
|
+
scales: np.ndarray
|
|
39
|
+
fluct: np.ndarray
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def dfa(signal, scales=None, order: int = 1, rel_floor: float = 1e-3) -> DFAResult:
|
|
43
|
+
"""Estimate the Hurst exponent of a 1-D signal by DFA.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
signal : array-like
|
|
48
|
+
1-D time series.
|
|
49
|
+
scales : array-like, optional
|
|
50
|
+
Window sizes in samples. Defaults to the standard FDNkit grid.
|
|
51
|
+
order : int
|
|
52
|
+
Detrending polynomial order (1 = linear).
|
|
53
|
+
rel_floor : float
|
|
54
|
+
Scale-relative floor on per-segment fluctuations (see
|
|
55
|
+
:func:`fdnkit.mfdfa.mfdfa`). Has negligible effect on the (positive-moment)
|
|
56
|
+
Hurst estimate but keeps behaviour consistent with MFDFA.
|
|
57
|
+
|
|
58
|
+
Returns
|
|
59
|
+
-------
|
|
60
|
+
DFAResult
|
|
61
|
+
|
|
62
|
+
Examples
|
|
63
|
+
--------
|
|
64
|
+
>>> from fdnkit.synthetic import fgn
|
|
65
|
+
>>> from fdnkit.dfa import dfa
|
|
66
|
+
>>> H = dfa(fgn(8000, 0.7, seed=0)).hurst # ~0.7
|
|
67
|
+
"""
|
|
68
|
+
eps = np.finfo(float).eps
|
|
69
|
+
scales = DEFAULT_SCALES if scales is None else np.asarray(scales, dtype=int)
|
|
70
|
+
x = np.asarray(signal, dtype=float).ravel()
|
|
71
|
+
if x.size < int(scales.min()) * 2:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"signal length {x.size} too short for smallest scale {int(scales.min())}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
rms_per_scale = _fluctuations(x, scales, order, rel_floor=rel_floor)
|
|
77
|
+
fluct = np.full(len(scales), np.nan)
|
|
78
|
+
for i, rms in enumerate(rms_per_scale):
|
|
79
|
+
if rms.size:
|
|
80
|
+
fluct[i] = np.sqrt(np.mean(rms**2))
|
|
81
|
+
|
|
82
|
+
H = _loglog_slope(scales, fluct + eps)
|
|
83
|
+
return DFAResult(hurst=H, scales=np.asarray(scales), fluct=fluct)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def hurst(signal, scales=None, order: int = 1) -> float:
|
|
87
|
+
"""Return just the Hurst exponent (shorthand for ``dfa(...).hurst``)."""
|
|
88
|
+
return dfa(signal, scales=scales, order=order).hurst
|