Skip to content

Commit 8291a60

Browse files
authored
Switch to src layout (#219)
* Switch to src layout * Move tests * Fix mypy * Fix style * More style fixes * Try mypy exclude * Also fix in pre-commit * Add setuptools package-dir * Try automatic layout discovery * Fix extension build * Remove unnecessary mypy settings * Use setuptools-scm * Remove MANIFEST.in * Specify packages explicitly * Add additional known warning * Do not include tests * Reformat examples * Minor formatting changes * Remove examples from wheels
1 parent 01542fd commit 8291a60

39 files changed

Lines changed: 280 additions & 277 deletions

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ repos:
1010
rev: v1.10.0
1111
hooks:
1212
- id: mypy
13-
exclude: ^sleepecg/test/|^examples
13+
exclude: ^tests|^examples
1414
args: [--python-version=3.9]
1515
additional_dependencies:
1616
- types-PyYAML

CONTRIBUTING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ pytest -m "not c_extension"
9696
9797
## Releases
9898
Follow these steps to release a new version of SleepECG:
99-
- In `sleepecg/__init__.py` remove the `-dev` suffix in `__version__`.
99+
- In `src/sleepecg/__init__.py` remove the `-dev` suffix in `__version__`.
100100
- In case of a patch release, modify the version number accordingly.
101101
- In `CHANGELOG.md`, update `## [UNRELEASED] - YYYY-MM-DD` to contain the version number and current date.
102102
- Commit these changes as `Prepare vX.Y.Z release` and push.
@@ -107,6 +107,6 @@ Follow these steps to release a new version of SleepECG:
107107
- This triggers the [`release.yml`](https://github.com/cbrnr/sleepecg/blob/main/.github/workflows/release.yml) workflow, which builds the wheels and publishes the package on [PyPI](https://pypi.org/project/sleepecg).
108108
109109
This concludes the new release. Now prepare the source for the next planned release as follows:
110-
- Update `__version__` in `sleepecg/__init__.py` to the next planned version and append `-dev`.
110+
- Update `__version__` in `src/sleepecg/__init__.py` to the next planned version and append `-dev`.
111111
- Start a new section at the top of `CHANGELOG.md` titled `## [UNRELEASED] - YYYY-MM-DD`.
112112
- Commit these changes as `Prepare vX.Y.Z-dev` and push.

MANIFEST.in

Lines changed: 0 additions & 13 deletions
This file was deleted.

examples/benchmark/README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,36 @@
11
# Heartbeat detection benchmarks
2+
23
This example reproduces the benchmarks shown in the [docs](https://sleepecg.readthedocs.io/en/latest/heartbeat_detection.html).
34

45
## Usage
6+
57
To run the benchmark, create a virtual environment and install the requirements with:
8+
69
```
710
pip install -r requirements-benchmark.txt
811
```
912

1013
Then execute
14+
1115
```
1216
python benchmark_detectors.py [<benchmark>]
1317
```
18+
1419
where the optional `[<benchmark>]` argument is a top-level key in `config.yml` (see section [Configuration](#configuration)). Possible values are `runtime`, `metrics`, and `rri_similarity`. If not provided, the `runtime` benchmark is executed.
1520

1621
Plots can be created by executing
22+
1723
```
1824
python plot_benchmark_results.py <results.csv>
1925
```
26+
2027
which will save the plot to `<results>.svg` at the same location as `<results>.csv`. Plot types and labels for the provided benchmarks are selected based on the filename, so renaming may lead to errors.
2128

2229

2330
## Configuration
31+
2432
A benchmark configuration is specified below a unique top-level key in `config.yml`.
33+
2534
|Key|Type|Default|Description|
2635
|---|----|--------|-----------|
2736
|`data_dir`|`str`|`'~/.sleepecg/datasets'`|Path where all datasets are stored. Required files will be downloaded if they don't exist.|
@@ -34,7 +43,9 @@ A benchmark configuration is specified below a unique top-level key in `config.y
3443
|`suppress_warnings`|`bool`|`False`|Whether to suppress warnings during detector execution.|
3544
|`calc_rri_similarity`|`bool`|`False`|Whether to calculate similarity measures between detected and annotated RR intervals (computationally expensive for long signals).|
3645

46+
3747
## Known issues
48+
3849
- `heartpy` detection will fail for mitdb:105:V1, mitdb:115:V1, mitdb:200:V1, and mitdb:201:V1. It raises a `BadSignalWarning`, which is caught in `utils.evaluate_single`.
3950
- For signal lengths starting somewhere between 600 and 900 minutes, the `heartpy` detector takes at least several hours for ltdb:15814:ECG2.
40-
- For signal lengths starting somewhere between 300 and 600 minutes, `wfdb-xqrs` takes at least 20 times longer for ltdb:14134:ECG2 and ltdb:14184:ECG2 than for the other ltdb records.
51+
- For signal lengths starting somewhere between 300 and 600 minutes, `wfdb-xqrs` takes at least 20 times longer for ltdb:14134:ECG2 and ltdb:14184:ECG2 than for the other records.

examples/benchmark/benchmark_detectors.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
except KeyError:
3333
raise ValueError(f"Invalid benchmark: {benchmark!r}, available: {list(cfg)}.") from None
3434

35-
3635
if cfg.get("suppress_warnings", False):
3736
warnings.filterwarnings("ignore")
3837

@@ -70,16 +69,12 @@
7069
if cfg.get("calc_rri_similarity", False):
7170
fieldnames += ["pearsonr", "spearmanr", "rmse"]
7271

73-
# Trigger imports and jit compilation to make runtime benchmarks representative
72+
# trigger imports and jit compilation to make runtime benchmarks representative
7473
for detector in cfg["detectors"]:
7574
detector_dispatch(records[0].ecg[: 10 * records[0].fs], records[0].fs, detector)
7675

77-
7876
with open(csv_filepath, "w", newline="") as csv_file:
79-
writer = csv.DictWriter(
80-
csv_file,
81-
fieldnames=fieldnames,
82-
)
77+
writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
8378
writer.writeheader()
8479
for signal_len in cfg["signal_lengths"]:
8580
print(f"==== Signal length: {signal_len} minutes ====")

examples/benchmark/utils.py

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from typing import Any
1212

1313
import numpy as np
14-
1514
import sleepecg
1615
from sleepecg.io.ecg_readers import ECGRecord
1716

@@ -172,9 +171,7 @@ def evaluate_single(
172171

173172
except HeartpyWarning:
174173
runtime = np.nan
175-
TP = []
176-
FP = []
177-
FN = annotation
174+
TP, FP, FN = [], [], annotation
178175

179176
if calc_rri_similarity:
180177
pearsonr = np.nan
@@ -194,11 +191,5 @@ def evaluate_single(
194191
"FN": len(FN),
195192
}
196193
if calc_rri_similarity:
197-
result.update(
198-
{
199-
"pearsonr": pearsonr,
200-
"spearmanr": spearmanr,
201-
"rmse": rmse,
202-
}
203-
)
194+
result.update({"pearsonr": pearsonr, "spearmanr": spearmanr, "rmse": rmse})
204195
return result

examples/classifiers/wrn_gru_mesa.py

Lines changed: 85 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
# %%
2-
from tensorflow.keras import layers, models
3-
from tqdm import tqdm
1+
import warnings
42

53
from sleepecg import (
64
evaluate,
@@ -13,94 +11,107 @@
1311
save_classifier,
1412
set_nsrr_token,
1513
)
14+
from tensorflow.keras import layers, models
15+
from tqdm import tqdm
1616

17-
# %% Read data and extract features
1817
set_nsrr_token("your-token-here")
19-
records = list(read_mesa())
20-
21-
feature_extraction_params = {
22-
"lookback": 120,
23-
"lookforward": 150,
24-
"feature_selection": [
25-
"hrv-time",
26-
"hrv-frequency",
27-
"recording_start_time",
28-
"age",
29-
"gender",
30-
],
31-
"min_rri": 0.3,
32-
"max_rri": 2,
33-
"max_nans": 0.5,
34-
}
35-
36-
features_train, stages_train, feature_ids = extract_features(
37-
tqdm(records),
38-
**feature_extraction_params,
39-
n_jobs=-2,
40-
)
4118

42-
# %% Merge sleep stages, pad and mask data as preparation for keras NN
43-
stages_mode = "wake-rem-nrem"
19+
TRAIN = True # set to False to skip training and load classifier from disk
4420

45-
features_train_pad, stages_train_pad, _ = prepare_data_keras(
46-
features_train,
47-
stages_train,
48-
stages_mode,
49-
)
50-
print_class_balance(stages_train_pad, stages_mode)
51-
52-
# %% Define and train model
53-
model = models.Sequential(
54-
[
55-
layers.Input((None, features_train_pad.shape[2])),
56-
layers.Masking(-1),
57-
layers.BatchNormalization(),
58-
layers.Dense(64),
59-
layers.ReLU(),
60-
layers.Bidirectional(layers.GRU(8, return_sequences=True)),
61-
layers.Bidirectional(layers.GRU(8, return_sequences=True)),
62-
layers.Dense(stages_train_pad.shape[-1], activation="softmax"),
63-
]
21+
# silence warnings (which might pop up during feature extraction)
22+
warnings.filterwarnings(
23+
"ignore", category=RuntimeWarning, message="HR analysis window too short"
6424
)
6525

66-
model.compile(
67-
optimizer="rmsprop",
68-
loss="categorical_crossentropy",
69-
metrics=["accuracy"],
70-
)
71-
model.build()
72-
model.summary()
73-
74-
# %% Train model
75-
model.fit(
76-
features_train_pad,
77-
stages_train_pad,
78-
epochs=25,
79-
)
26+
if TRAIN:
27+
print("‣ Starting training...")
28+
print("‣‣ Extracting features...")
29+
records = list(read_mesa(offline=False))
8030

81-
# %% Store classifier
82-
save_classifier(
83-
name="wrn-gru-mesa",
84-
model=model,
85-
stages_mode=stages_mode,
86-
feature_extraction_params=feature_extraction_params,
87-
mask_value=-1,
88-
classifiers_dir="./classifiers",
89-
)
31+
feature_extraction_params = {
32+
"lookback": 120,
33+
"lookforward": 150,
34+
"feature_selection": [
35+
"hrv-time",
36+
"hrv-frequency",
37+
"recording_start_time",
38+
"age",
39+
"gender",
40+
],
41+
"min_rri": 0.3,
42+
"max_rri": 2,
43+
"max_nans": 0.5,
44+
}
45+
46+
features_train, stages_train, feature_ids = extract_features(
47+
tqdm(records),
48+
**feature_extraction_params,
49+
n_jobs=-1,
50+
)
51+
52+
print("‣‣ Preparing data for Keras...")
53+
stages_mode = "wake-rem-nrem"
54+
55+
features_train_pad, stages_train_pad, _ = prepare_data_keras(
56+
features_train,
57+
stages_train,
58+
stages_mode,
59+
)
60+
print_class_balance(stages_train_pad, stages_mode)
61+
62+
print("‣‣ Defining model...")
63+
model = models.Sequential(
64+
[
65+
layers.Input((None, features_train_pad.shape[2])),
66+
layers.Masking(-1),
67+
layers.BatchNormalization(),
68+
layers.Dense(64),
69+
layers.ReLU(),
70+
layers.Bidirectional(layers.GRU(8, return_sequences=True)),
71+
layers.Bidirectional(layers.GRU(8, return_sequences=True)),
72+
layers.Dense(stages_train_pad.shape[-1], activation="softmax"),
73+
]
74+
)
75+
76+
model.compile(
77+
optimizer="rmsprop",
78+
loss="categorical_crossentropy",
79+
metrics=["accuracy"],
80+
)
81+
model.build()
82+
model.summary()
83+
84+
print("‣‣ Training model...")
85+
model.fit(
86+
features_train_pad,
87+
stages_train_pad,
88+
epochs=25,
89+
)
90+
91+
print("‣‣ Saving classifier...")
92+
save_classifier(
93+
name="wrn-gru-mesa",
94+
model=model,
95+
stages_mode=stages_mode,
96+
feature_extraction_params=feature_extraction_params,
97+
mask_value=-1,
98+
classifiers_dir="./classifiers",
99+
)
90100

91-
# %% Load classifier from disk for validation
101+
print("‣ Starting testing...")
102+
print("‣‣ Loading classifier...")
92103
clf = load_classifier("wrn-gru-mesa", "./classifiers")
93104

94-
# %% Read data and extract features
95-
shhs = list(read_shhs())
105+
print("‣‣ Extracting features...")
106+
shhs = list(read_shhs(offline=False))
96107

97108
features_test, stages_test, feature_ids = extract_features(
98109
tqdm(shhs),
99110
**clf.feature_extraction_params,
100111
n_jobs=-2,
101112
)
102113

103-
# %% Predict & evaluate
114+
print("‣‣ Evaluating classifier...")
104115
features_test_pad, stages_test_pad, _ = prepare_data_keras(
105116
features_test,
106117
stages_test,

0 commit comments

Comments
 (0)