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 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