Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
547c4a5
Use the safetensors package as default for model saving and loading
wenh06 Feb 23, 2025
c79269a
run pre-commit
wenh06 Sep 14, 2025
52c123e
replace .cpu().detach() operations with .detach().cpu()
wenh06 Sep 23, 2025
e35d552
Merge remote-tracking branch 'origin/dev' into dev
wenh06 Sep 23, 2025
accffd2
merge dev branch
wenh06 Sep 25, 2025
94ee32a
update CkptMixin: now uses safetensors by default
wenh06 Sep 25, 2025
a886c43
update model save in legacy models
wenh06 Sep 25, 2025
03ca4a9
Update torch_ecg/utils/utils_nn.py
wenh06 Sep 25, 2025
b30e765
Update torch_ecg/utils/utils_nn.py
wenh06 Sep 25, 2025
851d6ff
fix potential bugs related to timeout contextmanager
wenh06 Sep 26, 2025
fd700ed
Merge remote-tracking branch 'origin/safetensors' into safetensors
wenh06 Sep 26, 2025
15a76bf
enhance the utility function make_serializable
wenh06 Sep 27, 2025
fb90a87
enhance exception handling of SHHS tests
wenh06 Sep 27, 2025
f6ea7ce
enhance the pytest action
wenh06 Sep 27, 2025
a383feb
add pyrightconfig.json; fix docstring indentation issues
wenh06 Sep 28, 2025
ac5181f
fix bugs causing TypeError: isinstance() argument 2 cannot be a param…
wenh06 Sep 28, 2025
84d9e36
fix remaining bugs causing TypeError when calling isinstance function
wenh06 Sep 28, 2025
f3351c8
add configs for pytest and converage in the pyproject.toml file
wenh06 Oct 1, 2025
3114a1a
enhance pytest code coverage reports
wenh06 Oct 6, 2025
6575e98
update test and coverage setups
wenh06 Oct 6, 2025
dcb0849
fix errors in coverage settings in pyproject.toml
wenh06 Oct 6, 2025
52307e4
update docs
wenh06 Nov 11, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 49 additions & 27 deletions .github/workflows/run-pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ on:

env:
PYTHON_PRIMARY_VERSION: '3.10'
SHHS_DATA_AVAILABLE: 'false'

jobs:
build:
Expand Down Expand Up @@ -66,40 +67,61 @@ jobs:
- name: List installed Python packages
run: |
python -m pip list
- name: Install nsrr and download a samll part of SHHS to do test
- name: Install nsrr and download a small part of SHHS to do test
# ref. https://github.com/DeepPSP/nsrr-automate
uses: gacts/run-and-post-run@v1
with:
# if ~/tmp/nsrr-data/shhs is empty (no files downloaded),
# fail and terminate the workflow
run: |
gem install nsrr --no-document
nsrr download shhs/polysomnography/edfs/shhs1/ --file="^shhs1\-20010.*\.edf" --token=${{ secrets.NSRR_TOKEN }}
nsrr download shhs/polysomnography/annotations-events-nsrr/shhs1/ --file="^shhs1\-20010.*\-nsrr\.xml" --token=${{ secrets.NSRR_TOKEN }}
nsrr download shhs/polysomnography/annotations-events-profusion/shhs1/ --file="^shhs1\-20010.*\-profusion\.xml" --token=${{ secrets.NSRR_TOKEN }}
nsrr download shhs/polysomnography/annotations-rpoints/shhs1/ --file="^shhs1\-20010.*\-rpoint\.csv" --token=${{ secrets.NSRR_TOKEN }}
nsrr download shhs/datasets/ --shallow --token=${{ secrets.NSRR_TOKEN }}
nsrr download shhs/datasets/hrv-analysis/ --token=${{ secrets.NSRR_TOKEN }}
mkdir -p ~/tmp/nsrr-data/
mv shhs/ ~/tmp/nsrr-data/
du -sh ~/tmp/nsrr-data/*
if [ "$(find ~/tmp/nsrr-data/shhs -type f | wc -l)" -eq 0 ]; \
then (echo "No files downloaded. Exiting..." && exit 1); \
else echo "Found $(find ~/tmp/nsrr-data/shhs -type f | wc -l) files"; fi
post: |
cd ~/tmp/ && du -sh $(ls -A)
rm -rf ~/tmp/nsrr-data/
cd ~/tmp/ && du -sh $(ls -A)
# uses: gacts/run-and-post-run@v1
continue-on-error: true
run: |
set -u -o pipefail
gem install nsrr --no-document
nsrr download shhs/polysomnography/edfs/shhs1/ --file="^shhs1\-20010.*\.edf" --token=${{ secrets.NSRR_TOKEN }} 2>&1 | sed -E '/^[[:space:]]*[Ss]kipped([[:space:]]|$)/d' || true
nsrr download shhs/polysomnography/annotations-events-nsrr/shhs1/ --file="^shhs1\-20010.*\-nsrr\.xml" --token=${{ secrets.NSRR_TOKEN }} 2>&1 | sed -E '/^[[:space:]]*[Ss]kipped([[:space:]]|$)/d' || true
nsrr download shhs/polysomnography/annotations-events-profusion/shhs1/ --file="^shhs1\-20010.*\-profusion\.xml" --token=${{ secrets.NSRR_TOKEN }} 2>&1 | sed -E '/^[[:space:]]*[Ss]kipped([[:space:]]|$)/d' || true
nsrr download shhs/polysomnography/annotations-rpoints/shhs1/ --file="^shhs1\-20010.*\-rpoint\.csv" --token=${{ secrets.NSRR_TOKEN }} 2>&1 | sed -E '/^[[:space:]]*[Ss]kipped([[:space:]]|$)/d' || true
nsrr download shhs/datasets/ --shallow --token=${{ secrets.NSRR_TOKEN }} 2>&1 | sed -E '/^[[:space:]]*[Ss]kipped([[:space:]]|$)/d' || true
nsrr download shhs/datasets/hrv-analysis/ --token=${{ secrets.NSRR_TOKEN }} 2>&1 | sed -E '/^[[:space:]]*[Ss]kipped([[:space:]]|$)/d' || true

mkdir -p ~/tmp/nsrr-data/
mv shhs/ ~/tmp/nsrr-data/
du -sh ~/tmp/nsrr-data/* || true

EDF_COUNT=$(find ~/tmp/nsrr-data/shhs -type f -name "*.edf" 2>/dev/null | wc -l | tr -d ' ')
echo "Detected SHHS EDF file count: $EDF_COUNT"

if [ "$EDF_COUNT" -eq 0 ]; then
echo "::error title=No SHHS EDF files downloaded::No .edf files were downloaded (token may be invalid or pattern mismatch). SHHS tests will be skipped."
echo "No SHHS EDF files downloaded; setting SHHS_DATA_AVAILABLE=false"
echo "SHHS_DATA_AVAILABLE=false" >> $GITHUB_ENV
exit 1
else
echo "Found $EDF_COUNT SHHS EDF files; setting SHHS_DATA_AVAILABLE=true"
echo "SHHS_DATA_AVAILABLE=true" >> $GITHUB_ENV
fi
# post: |
# cd ~/tmp/ && du -sh $(ls -A)
# rm -rf ~/tmp/nsrr-data/
# cd ~/tmp/ && du -sh $(ls -A)
- name: Run test with pytest and collect coverage
run: |
pytest -vv -s \
--cov=torch_ecg \
--ignore=test/test_pipelines \
test
echo "SHHS_DATA_AVAILABLE at test step: $SHHS_DATA_AVAILABLE"
pytest --cov --junitxml=junit.xml -o junit_family=legacy
env:
SHHS_DATA_AVAILABLE: ${{ env.SHHS_DATA_AVAILABLE }}
- name: Upload coverage to Codecov
if: matrix.python-version == ${{ env.PYTHON_PRIMARY_VERSION }}
uses: codecov/codecov-action@v4
with:
fail_ci_if_error: true # optional (default = false)
verbose: true # optional (default = false)
token: ${{ secrets.CODECOV_TOKEN }} # required
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/test-results-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Cleanup SHHS temp data
if: always() && true
run: |
cd ~/tmp/ && du -sh $(ls -A)
rm -rf ~/tmp/nsrr-data/
cd ~/tmp/ && du -sh $(ls -A)
13 changes: 13 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,13 @@ Changed

- Make the function `remove_spikes_naive` in `torch_ecg.utils.utils_signal`
support 2D and 3D input signals.
- Use `save_file` and `load_file` from the `safetensors` package for saving
and loading files in place of `torch.save` and `torch.load` in the `CkptMixin`
class in `torch_ecg.utils.utils_nn`.
- Add retry mechanism to the `http_get` function in
`torch_ecg.utils.download` module.
- Add length verification in the `http_get` function in
`torch_ecg.utils.download` module.

Deprecated
~~~~~~~~~~
Expand All @@ -42,6 +47,14 @@ Fixed
- Fix potential errors when deepcopying a `torch_ecg.cfg.CFG` object:
previously, deepcopying such an object like `CFG({"a": {1: 0.1, 2: 0.2}})`
would result in an error.
- Fix potential bugs in contextmanager `torch_ecg.utils.timeout`: restore the previously
installed SIGALRM handler after use, cancel any pending alarm reliably in a finally block,
avoid installing a handler when duration <= 0 (preventing unintended global side-effects),
and thereby eliminate spurious `TimeoutError` exceptions that could be triggered later by
unrelated signal.alarm calls due to the old implementation not reinstating the original handler.
- Fix bugs in utility function `torch_ecg.utils.make_serializable`: the previous implementation
does not drop some types of unserializable items correctly. Two additional parameters
`drop_unserializable` and `drop_paths` are added.

Security
~~~~~~~~
Expand Down
4 changes: 2 additions & 2 deletions benchmarks/train_crnn_cinc2020/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ def inference(
_input = _input.unsqueeze(0) # add a batch dimension
prob = self.sigmoid(self.forward(_input))
pred = (prob >= bin_pred_thr).int()
prob = prob.cpu().detach().numpy()
pred = pred.cpu().detach().numpy()
prob = prob.detach().cpu().numpy()
pred = pred.detach().cpu().numpy()
for row_idx, row in enumerate(pred):
row_max_prob = prob[row_idx, ...].max()
if row_max_prob < ModelCfg.bin_pred_nsr_thr and nsr_cid is not None:
Expand Down
4 changes: 2 additions & 2 deletions benchmarks/train_crnn_cinc2021/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ def inference(
# batch_size, channels, seq_len = _input.shape
prob = self.sigmoid(self.forward(_input))
pred = (prob >= bin_pred_thr).int()
prob = prob.cpu().detach().numpy()
pred = pred.cpu().detach().numpy()
prob = prob.detach().cpu().numpy()
pred = pred.detach().cpu().numpy()
for row_idx, row in enumerate(pred):
row_max_prob = prob[row_idx, ...].max()
if row_max_prob < ModelCfg.bin_pred_nsr_thr and nsr_cid is not None:
Expand Down
10 changes: 5 additions & 5 deletions benchmarks/train_hybrid_cpsc2020/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ def inference(
_input = _input.unsqueeze(0) # add a batch dimension
prob = self.sigmoid(self.forward(_input))
pred = (prob >= bin_pred_thr).int()
prob = prob.cpu().detach().numpy()
pred = pred.cpu().detach().numpy()
prob = prob.detach().cpu().numpy()
pred = pred.detach().cpu().numpy()
for row_idx, row in enumerate(pred):
row_max_prob = prob[row_idx, ...].max()
if row.sum() == 0:
Expand Down Expand Up @@ -190,14 +190,14 @@ def inference(
if self.n_classes == 2:
prob = self.sigmoid(prob) # (batch_size, seq_len, 2)
pred = (prob >= bin_pred_thr).int()
prob = prob.cpu().detach().numpy()
pred = pred.cpu().detach().numpy()
prob = prob.detach().cpu().numpy()
pred = pred.detach().cpu().numpy()
# aux used to filter out potential simultaneous predictions of SPB and PVC
aux = (prob == np.max(prob, axis=2, keepdims=True)).astype(int)
pred = aux * pred
elif self.n_classes == 3:
prob = self.softmax(prob) # (batch_size, seq_len, 3)
prob = prob.cpu().detach().numpy()
prob = prob.detach().cpu().numpy()
pred = np.argmax(prob, axis=2)

if rpeak_inds is not None:
Expand Down
8 changes: 4 additions & 4 deletions benchmarks/train_hybrid_cpsc2021/entry_2021.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,12 +379,12 @@ def _detect_rpeaks(model, sig, siglen, overlap_len, config):
for idx in range(batch_size // _BATCH_SIZE):
pred = model.forward(sig[_BATCH_SIZE * idx : _BATCH_SIZE * (idx + 1), ...])
pred = model.sigmoid(pred)
pred = pred.cpu().detach().numpy().squeeze(-1)
pred = pred.detach().cpu().numpy().squeeze(-1)
l_pred.append(pred)
if batch_size % _BATCH_SIZE != 0:
pred = model.forward(sig[batch_size // _BATCH_SIZE * _BATCH_SIZE :, ...])
pred = model.sigmoid(pred)
pred = pred.cpu().detach().numpy().squeeze(-1)
pred = pred.detach().cpu().numpy().squeeze(-1)
l_pred.append(pred)
pred = np.concatenate(l_pred)

Expand Down Expand Up @@ -473,12 +473,12 @@ def _main_task(model, sig, siglen, overlap_len, rpeaks, config):
for idx in range(batch_size // _BATCH_SIZE):
pred = model.forward(sig[_BATCH_SIZE * idx : _BATCH_SIZE * (idx + 1), ...])
pred = model.sigmoid(pred)
pred = pred.cpu().detach().numpy().squeeze(-1)
pred = pred.detach().cpu().numpy().squeeze(-1)
l_pred.append(pred)
if batch_size % _BATCH_SIZE != 0:
pred = model.forward(sig[batch_size // _BATCH_SIZE * _BATCH_SIZE :, ...])
pred = model.sigmoid(pred)
pred = pred.cpu().detach().numpy().squeeze(-1)
pred = pred.detach().cpu().numpy().squeeze(-1)
l_pred.append(pred)
pred = np.concatenate(l_pred)

Expand Down
14 changes: 7 additions & 7 deletions benchmarks/train_hybrid_cpsc2021/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ def _inference_qrs_detection(
_input = _input.unsqueeze(0) # add a batch dimension
# batch_size, channels, seq_len = _input.shape
prob = self.sigmoid(self.forward(_input))
prob = prob.cpu().detach().numpy().squeeze(-1)
prob = prob.detach().cpu().numpy().squeeze(-1)

# prob --> qrs mask --> qrs intervals --> rpeaks
rpeaks = _qrs_detection_post_process(
Expand Down Expand Up @@ -226,7 +226,7 @@ def _inference_main_task(
_input = _input.unsqueeze(0) # add a batch dimension
batch_size, n_leads, seq_len = _input.shape
prob = self.sigmoid(self.forward(_input))
prob = prob.cpu().detach().numpy().squeeze(-1)
prob = prob.detach().cpu().numpy().squeeze(-1)

af_episodes, af_mask = _main_task_post_process(
prob=prob,
Expand Down Expand Up @@ -368,7 +368,7 @@ def _inference_qrs_detection(
_input = _input.unsqueeze(0) # add a batch dimension
# batch_size, channels, seq_len = _input.shape
prob = self.sigmoid(self.forward(_input))
prob = prob.cpu().detach().numpy().squeeze(-1)
prob = prob.detach().cpu().numpy().squeeze(-1)

# prob --> qrs mask --> qrs intervals --> rpeaks
rpeaks = _qrs_detection_post_process(
Expand Down Expand Up @@ -425,7 +425,7 @@ def _inference_main_task(
_input = _input.unsqueeze(0) # add a batch dimension
batch_size, n_leads, seq_len = _input.shape
prob = self.sigmoid(self.forward(_input))
prob = prob.cpu().detach().numpy().squeeze(-1)
prob = prob.detach().cpu().numpy().squeeze(-1)

af_episodes, af_mask = _main_task_post_process(
prob=prob,
Expand Down Expand Up @@ -567,7 +567,7 @@ def _inference_qrs_detection(
_input = _input.unsqueeze(0) # add a batch dimension
# batch_size, channels, seq_len = _input.shape
prob = self.sigmoid(self.forward(_input))
prob = prob.cpu().detach().numpy().squeeze(-1)
prob = prob.detach().cpu().numpy().squeeze(-1)

# prob --> qrs mask --> qrs intervals --> rpeaks
rpeaks = _qrs_detection_post_process(
Expand Down Expand Up @@ -624,7 +624,7 @@ def _inference_main_task(
_input = _input.unsqueeze(0) # add a batch dimension
batch_size, n_leads, seq_len = _input.shape
prob = self.sigmoid(self.forward(_input))
prob = prob.cpu().detach().numpy().squeeze(-1)
prob = prob.detach().cpu().numpy().squeeze(-1)

af_episodes, af_mask = _main_task_post_process(
prob=prob,
Expand Down Expand Up @@ -721,7 +721,7 @@ def inference(
prob = self.forward(_input)
if self.config.clf.name != "crf":
prob = self.sigmoid(prob)
prob = prob.cpu().detach().numpy().squeeze(-1)
prob = prob.detach().cpu().numpy().squeeze(-1)

af_episodes, af_mask = _main_task_post_process(
prob=prob,
Expand Down
22 changes: 11 additions & 11 deletions benchmarks/train_mtl_cinc2022/models/crnn.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,31 +200,31 @@ def inference(
prob = self.softmax(forward_output["murmur"])
pred = torch.argmax(prob, dim=-1)
bin_pred = (prob == prob.max(dim=-1, keepdim=True).values).to(int)
prob = prob.cpu().detach().numpy()
pred = pred.cpu().detach().numpy()
bin_pred = bin_pred.cpu().detach().numpy()
prob = prob.detach().cpu().numpy()
pred = pred.detach().cpu().numpy()
bin_pred = bin_pred.detach().cpu().numpy()

murmur_output = ClassificationOutput(
classes=self.classes,
prob=prob,
pred=pred,
bin_pred=bin_pred,
forward_output=forward_output["murmur"].cpu().detach().numpy(),
forward_output=forward_output["murmur"].detach().cpu().numpy(),
)

if forward_output.get("outcome", None) is not None:
prob = self.softmax(forward_output["outcome"])
pred = torch.argmax(prob, dim=-1)
bin_pred = (prob == prob.max(dim=-1, keepdim=True).values).to(int)
prob = prob.cpu().detach().numpy()
pred = pred.cpu().detach().numpy()
bin_pred = bin_pred.cpu().detach().numpy()
prob = prob.detach().cpu().numpy()
pred = pred.detach().cpu().numpy()
bin_pred = bin_pred.detach().cpu().numpy()
outcome_output = ClassificationOutput(
classes=self.outcomes,
prob=prob,
pred=pred,
bin_pred=bin_pred,
forward_output=forward_output["outcome"].cpu().detach().numpy(),
forward_output=forward_output["outcome"].detach().cpu().numpy(),
)
else:
outcome_output = None
Expand All @@ -238,13 +238,13 @@ def inference(
else:
prob = self.sigmoid(forward_output["segmentation"])
pred = (prob > seg_thr).int() * (prob == prob.max(dim=-1, keepdim=True).values).int()
prob = prob.cpu().detach().numpy()
pred = pred.cpu().detach().numpy()
prob = prob.detach().cpu().numpy()
pred = pred.detach().cpu().numpy()
segmentation_output = SequenceLabellingOutput(
classes=self.states,
prob=prob,
pred=pred,
forward_output=forward_output["segmentation"].cpu().detach().numpy(),
forward_output=forward_output["segmentation"].detach().cpu().numpy(),
)
else:
segmentation_output = None
Expand Down
9 changes: 7 additions & 2 deletions benchmarks/train_mtl_cinc2022/models/model_ml.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,10 @@ def get_model(self, model_name: str, params: Optional[dict] = None) -> BaseEstim

"""
model_cls = self.model_map[model_name]
params = params or {}
if model_cls in [GradientBoostingClassifier, SVC]:
params.pop("n_jobs", None)
return model_cls(**(params or {}))
return model_cls(**params)

def save_model(
self,
Expand All @@ -198,9 +199,13 @@ def save_model(
path to save the model.

"""
if isinstance(model_path, bytes):
model_path = model_path.decode()
model_path = Path(model_path).expanduser().resolve()
model_path.parent.mkdir(parents=True, exist_ok=True)
_config = deepcopy(config)
_config.pop("db_dir", None)
Path(model_path).write_bytes(
model_path.write_bytes(
pickle.dumps(
{
"config": _config,
Expand Down
8 changes: 4 additions & 4 deletions benchmarks/train_mtl_cinc2022/models/seg.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,8 @@ def inference(
else:
prob = self.sigmoid(self.forward(_input)["segmentation"])
pred = (prob > bin_pred_threshold).int() * (prob == prob.max(dim=-1, keepdim=True).values).int()
prob = prob.cpu().detach().numpy()
pred = pred.cpu().detach().numpy()
prob = prob.detach().cpu().numpy()
pred = pred.detach().cpu().numpy()

segmentation_output = SequenceLabellingOutput(
classes=self.classes,
Expand Down Expand Up @@ -264,8 +264,8 @@ def inference(
else:
prob = self.sigmoid(self.forward(_input)["segmentation"])
pred = (prob > bin_pred_threshold).int() * (prob == prob.max(dim=-1, keepdim=True).values).int()
prob = prob.cpu().detach().numpy()
pred = pred.cpu().detach().numpy()
prob = prob.detach().cpu().numpy()
pred = pred.detach().cpu().numpy()

segmentation_output = SequenceLabellingOutput(
classes=self.classes,
Expand Down
Loading
Loading