From f6f9e817892090f6c1647f60f51fbc5ed80b74f1 Mon Sep 17 00:00:00 2001 From: YunLiu <55491388+KumoLiu@users.noreply.github.com> Date: Sat, 24 Feb 2024 00:49:00 +0800 Subject: [PATCH 01/35] Use int16 instead of int8 in `LabelStats` (#7489) Use uint8 instead of int8 in `LabelStats`. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. --------- Signed-off-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> --- monai/auto3dseg/analyzer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/monai/auto3dseg/analyzer.py b/monai/auto3dseg/analyzer.py index 56419da4cb..37f3faea21 100644 --- a/monai/auto3dseg/analyzer.py +++ b/monai/auto3dseg/analyzer.py @@ -460,7 +460,7 @@ def __call__(self, data: Mapping[Hashable, MetaTensor]) -> dict[Hashable, MetaTe torch.set_grad_enabled(False) ndas: list[MetaTensor] = [d[self.image_key][i] for i in range(d[self.image_key].shape[0])] # type: ignore - ndas_label: MetaTensor = d[self.label_key].astype(torch.int8) # (H,W,D) + ndas_label: MetaTensor = d[self.label_key].astype(torch.int16) # (H,W,D) if ndas_label.shape != ndas[0].shape: raise ValueError(f"Label shape {ndas_label.shape} is different from image shape {ndas[0].shape}") @@ -472,7 +472,7 @@ def __call__(self, data: Mapping[Hashable, MetaTensor]) -> dict[Hashable, MetaTe if isinstance(ndas_label, (MetaTensor, torch.Tensor)): unique_label = unique_label.data.cpu().numpy() - unique_label = unique_label.astype(np.int8).tolist() + unique_label = unique_label.astype(np.int16).tolist() label_substats = [] # each element is one label pixel_sum = 0 From 20512d30e40182f8f68a9784405cfe499e76c884 Mon Sep 17 00:00:00 2001 From: monai-bot <64792179+monai-bot@users.noreply.github.com> Date: Mon, 26 Feb 2024 07:01:44 +0000 Subject: [PATCH 02/35] auto updates (#7495) Signed-off-by: monai-bot Signed-off-by: monai-bot --- tests/test_resize.py | 1 + tests/test_resized.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/test_resize.py b/tests/test_resize.py index 65b33ea649..d4c57e2742 100644 --- a/tests/test_resize.py +++ b/tests/test_resize.py @@ -46,6 +46,7 @@ class TestResize(NumpyImageTestCase2D): + def test_invalid_inputs(self): with self.assertRaises(ValueError): resize = Resize(spatial_size=(128, 128, 3), mode="order") diff --git a/tests/test_resized.py b/tests/test_resized.py index d62f29ab5c..243a4e6622 100644 --- a/tests/test_resized.py +++ b/tests/test_resized.py @@ -66,6 +66,7 @@ @SkipIfAtLeastPyTorchVersion((2, 2, 0)) # https://github.com/Project-MONAI/MONAI/issues/7445 class TestResized(NumpyImageTestCase2D): + def test_invalid_inputs(self): with self.assertRaises(ValueError): resize = Resized(keys="img", spatial_size=(128, 128, 3), mode="order") From 7cfa2c9e281da51c78d823c7fd88e0e888fff3e1 Mon Sep 17 00:00:00 2001 From: "Timothy J. Baker" <62781117+tim-the-baker@users.noreply.github.com> Date: Mon, 26 Feb 2024 11:32:23 -0500 Subject: [PATCH 03/35] Add sample_std parameter to RandGaussianNoise. (#7492) Fixes issue #7425 ### Description Add a `sample_std` parameter to `RandGaussianNoise` and `RandGaussianNoised`. When True, the Gaussian's standard deviation is sampled uniformly from 0 to std (i.e., what is currently done). When False, the noise's standard deviation is non-random and set to std. The default for sample_std would be True for backwards compatibility. Changes were based on RandRicianNoise which already has a `sample_std` parameter and is similar to RandGaussianNoise in concept and implementation. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [x] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [x] In-line docstrings updated. - [x] Documentation updated, tested `make html` command in the `docs/` folder. --------- Signed-off-by: Timothy Baker --- monai/transforms/intensity/array.py | 15 ++++++++++++--- monai/transforms/intensity/dictionary.py | 6 ++++-- tests/test_rand_gaussian_noise.py | 12 +++++++----- tests/test_rand_gaussian_noised.py | 14 +++++++++----- 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index f9667402c9..a2f63a7482 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -91,24 +91,33 @@ class RandGaussianNoise(RandomizableTransform): mean: Mean or “centre” of the distribution. std: Standard deviation (spread) of distribution. dtype: output data type, if None, same as input image. defaults to float32. + sample_std: If True, sample the spread of the Gaussian distribution uniformly from 0 to std. """ backend = [TransformBackends.TORCH, TransformBackends.NUMPY] - def __init__(self, prob: float = 0.1, mean: float = 0.0, std: float = 0.1, dtype: DtypeLike = np.float32) -> None: + def __init__( + self, + prob: float = 0.1, + mean: float = 0.0, + std: float = 0.1, + dtype: DtypeLike = np.float32, + sample_std: bool = True, + ) -> None: RandomizableTransform.__init__(self, prob) self.mean = mean self.std = std self.dtype = dtype self.noise: np.ndarray | None = None + self.sample_std = sample_std def randomize(self, img: NdarrayOrTensor, mean: float | None = None) -> None: super().randomize(None) if not self._do_transform: return None - rand_std = self.R.uniform(0, self.std) - noise = self.R.normal(self.mean if mean is None else mean, rand_std, size=img.shape) + std = self.R.uniform(0, self.std) if self.sample_std else self.std + noise = self.R.normal(self.mean if mean is None else mean, std, size=img.shape) # noise is float64 array, convert to the output dtype to save memory self.noise, *_ = convert_data_type(noise, dtype=self.dtype) diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 058ef87b95..7e93464e64 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -172,7 +172,7 @@ class RandGaussianNoised(RandomizableTransform, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandGaussianNoise`. - Add Gaussian noise to image. This transform assumes all the expected fields have same shape, if want to add + Add Gaussian noise to image. This transform assumes all the expected fields have same shape, if you want to add different noise for every field, please use this transform separately. Args: @@ -183,6 +183,7 @@ class RandGaussianNoised(RandomizableTransform, MapTransform): std: Standard deviation (spread) of distribution. dtype: output data type, if None, same as input image. defaults to float32. allow_missing_keys: don't raise exception if key is missing. + sample_std: If True, sample the spread of the Gaussian distribution uniformly from 0 to std. """ backend = RandGaussianNoise.backend @@ -195,10 +196,11 @@ def __init__( std: float = 0.1, dtype: DtypeLike = np.float32, allow_missing_keys: bool = False, + sample_std: bool = True, ) -> None: MapTransform.__init__(self, keys, allow_missing_keys) RandomizableTransform.__init__(self, prob) - self.rand_gaussian_noise = RandGaussianNoise(mean=mean, std=std, prob=1.0, dtype=dtype) + self.rand_gaussian_noise = RandGaussianNoise(mean=mean, std=std, prob=1.0, dtype=dtype, sample_std=sample_std) def set_random_state( self, seed: int | None = None, state: np.random.RandomState | None = None diff --git a/tests/test_rand_gaussian_noise.py b/tests/test_rand_gaussian_noise.py index a56e54fe31..233b4dd1b6 100644 --- a/tests/test_rand_gaussian_noise.py +++ b/tests/test_rand_gaussian_noise.py @@ -22,22 +22,24 @@ TESTS = [] for p in TEST_NDARRAYS: - TESTS.append(("test_zero_mean", p, 0, 0.1)) - TESTS.append(("test_non_zero_mean", p, 1, 0.5)) + TESTS.append(("test_zero_mean", p, 0, 0.1, True)) + TESTS.append(("test_non_zero_mean", p, 1, 0.5, True)) + TESTS.append(("test_no_sample_std", p, 1, 0.5, False)) class TestRandGaussianNoise(NumpyImageTestCase2D): @parameterized.expand(TESTS) - def test_correct_results(self, _, im_type, mean, std): + def test_correct_results(self, _, im_type, mean, std, sample_std): seed = 0 - gaussian_fn = RandGaussianNoise(prob=1.0, mean=mean, std=std) + gaussian_fn = RandGaussianNoise(prob=1.0, mean=mean, std=std, sample_std=sample_std) gaussian_fn.set_random_state(seed) im = im_type(self.imt) noised = gaussian_fn(im) np.random.seed(seed) np.random.random() - expected = self.imt + np.random.normal(mean, np.random.uniform(0, std), size=self.imt.shape) + _std = np.random.uniform(0, std) if sample_std else std + expected = self.imt + np.random.normal(mean, _std, size=self.imt.shape) if isinstance(noised, torch.Tensor): noised = noised.cpu() np.testing.assert_allclose(expected, noised, atol=1e-5) diff --git a/tests/test_rand_gaussian_noised.py b/tests/test_rand_gaussian_noised.py index bcbed98b5a..e3df196be2 100644 --- a/tests/test_rand_gaussian_noised.py +++ b/tests/test_rand_gaussian_noised.py @@ -22,8 +22,9 @@ TESTS = [] for p in TEST_NDARRAYS: - TESTS.append(["test_zero_mean", p, ["img1", "img2"], 0, 0.1]) - TESTS.append(["test_non_zero_mean", p, ["img1", "img2"], 1, 0.5]) + TESTS.append(["test_zero_mean", p, ["img1", "img2"], 0, 0.1, True]) + TESTS.append(["test_non_zero_mean", p, ["img1", "img2"], 1, 0.5, True]) + TESTS.append(["test_no_sample_std", p, ["img1", "img2"], 1, 0.5, False]) seed = 0 @@ -31,15 +32,18 @@ class TestRandGaussianNoised(NumpyImageTestCase2D): @parameterized.expand(TESTS) - def test_correct_results(self, _, im_type, keys, mean, std): - gaussian_fn = RandGaussianNoised(keys=keys, prob=1.0, mean=mean, std=std, dtype=np.float64) + def test_correct_results(self, _, im_type, keys, mean, std, sample_std): + gaussian_fn = RandGaussianNoised( + keys=keys, prob=1.0, mean=mean, std=std, dtype=np.float64, sample_std=sample_std + ) gaussian_fn.set_random_state(seed) im = im_type(self.imt) noised = gaussian_fn({k: im for k in keys}) np.random.seed(seed) # simulate the randomize() of transform np.random.random() - noise = np.random.normal(mean, np.random.uniform(0, std), size=self.imt.shape) + _std = np.random.uniform(0, std) if sample_std else std + noise = np.random.normal(mean, _std, size=self.imt.shape) for k in keys: expected = self.imt + noise if isinstance(noised[k], torch.Tensor): From 98305257b376aaeff82679967d4e639b346b9b14 Mon Sep 17 00:00:00 2001 From: Mathijs de Boer <8137653+MathijsdeBoer@users.noreply.github.com> Date: Wed, 28 Feb 2024 06:10:40 +0100 Subject: [PATCH 04/35] Add __repr__ and __str__ to Metrics baseclass (#7487) ### Description When training a model using MONAI metrics for experiment tracking, I tend to log which metrics I am using. Unfortunately, just sending the metrics objects to Tensorboard will result in a list like [CustomMetric1, CustomMetric2, , etc.] Adding `__repr__` and `__str__` methods to the base class will solve this small annoyance. The current implementation will only return the class name, but if a certain metric would wish to report more data for its `__repr__` string, this can be easily overridden in any subclass. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. --------- Signed-off-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> Signed-off-by: Mathijs de Boer Co-authored-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> Co-authored-by: Mathijs de Boer --- monai/metrics/metric.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/monai/metrics/metric.py b/monai/metrics/metric.py index a6dc1a49a2..249b2dc951 100644 --- a/monai/metrics/metric.py +++ b/monai/metrics/metric.py @@ -37,6 +37,9 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any: """ raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") + def __str__(self): + return self.__class__.__name__ + class IterationMetric(Metric): """ From 02c7f53a2ea5835623df6ad12943d58202a42e04 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Mar 2024 16:23:09 +0800 Subject: [PATCH 05/35] Bump al-cheb/configure-pagefile-action from 1.3 to 1.4 (#7510) Bumps [al-cheb/configure-pagefile-action](https://github.com/al-cheb/configure-pagefile-action) from 1.3 to 1.4.
Release notes

Sourced from al-cheb/configure-pagefile-action's releases.

v1.4: Update task node version to 20

configure-pagefile-action

This action is intended to configure Pagefile size and location for Windows images in GitHub Actions.

Available parameters

Argument Description Format Default value
minimum-size Set minimum size of Pagefile 2048MB, 4GB, 8GB and etc 8GB
maximum-size Set maximum size of Pagefile The same like minimum-size minimum-size
disk-root Set disk root where Pagefile will be located C: or D: D:

Usage

name: CI
on: [push]
jobs:
  build:
    runs-on: windows-latest
    steps:
    - name: configure Pagefile
      uses: al-cheb/configure-pagefile-action@v1.4
      with:
        minimum-size: 8
- name: configure Pagefile
  uses: al-cheb/configure-pagefile-action@v1.4
  with:
    minimum-size: 8
    maximum-size: 16
    disk-root: &quot;D:&quot;

License

The scripts and documentation in this project are released under the MIT License

Commits
  • a3b6ebd Merge pull request #20 from mikehardy/mikehardy-patch-1
  • 850626f build(deps): bump javascript dependencies / forward-port as needed
  • e7aac1b fix: use node 20
  • d940d24 build(deps): use v4 of setup-node action, use node 20
  • dfdc038 build(deps): bump actions/checkout from 3 to 4
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=al-cheb/configure-pagefile-action&package-manager=github_actions&previous-version=1.3&new-version=1.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/conda.yml | 2 +- .github/workflows/pythonapp.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/conda.yml b/.github/workflows/conda.yml index a387c77ebd..367a24cbde 100644 --- a/.github/workflows/conda.yml +++ b/.github/workflows/conda.yml @@ -26,7 +26,7 @@ jobs: steps: - if: runner.os == 'windows' name: Config pagefile (Windows only) - uses: al-cheb/configure-pagefile-action@v1.3 + uses: al-cheb/configure-pagefile-action@v1.4 with: minimum-size: 8GB maximum-size: 16GB diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index b011e65cf1..b7f2cfb9db 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -62,7 +62,7 @@ jobs: steps: - if: runner.os == 'windows' name: Config pagefile (Windows only) - uses: al-cheb/configure-pagefile-action@v1.3 + uses: al-cheb/configure-pagefile-action@v1.4 with: minimum-size: 8GB maximum-size: 16GB From e9e273858b6904659b0cd581d357662dc5c782f6 Mon Sep 17 00:00:00 2001 From: YunLiu <55491388+KumoLiu@users.noreply.github.com> Date: Sun, 3 Mar 2024 23:09:21 +0800 Subject: [PATCH 06/35] Add arm support (#7500) Fixes # . ### Description Add arm support ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. --------- Signed-off-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> Co-authored-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> --- Dockerfile | 4 ++++ requirements-dev.txt | 4 ++-- setup.cfg | 4 ++-- tests/test_convert_to_onnx.py | 19 +++++++++++++------ tests/test_dynunet.py | 9 ++++++++- tests/test_rand_affine.py | 2 +- tests/test_rand_affined.py | 4 +++- tests/test_spatial_resampled.py | 9 ++++++++- 8 files changed, 41 insertions(+), 14 deletions(-) diff --git a/Dockerfile b/Dockerfile index cb1300ea90..7383837585 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,10 @@ FROM ${PYTORCH_IMAGE} LABEL maintainer="monai.contact@gmail.com" +# TODO: remark for issue [revise the dockerfile](https://github.com/zarr-developers/numcodecs/issues/431) +WORKDIR /opt +RUN git clone --recursive https://github.com/zarr-developers/numcodecs.git && pip wheel numcodecs + WORKDIR /opt/monai # install full deps diff --git a/requirements-dev.txt b/requirements-dev.txt index b08fef874b..af1b8b89d5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -26,7 +26,7 @@ mypy>=1.5.0 ninja torchvision psutil -cucim>=23.2.0; platform_system == "Linux" +cucim-cu12; platform_system == "Linux" and python_version >= "3.9" and python_version <= "3.10" openslide-python imagecodecs; platform_system == "Linux" or platform_system == "Darwin" tifffile; platform_system == "Linux" or platform_system == "Darwin" @@ -46,7 +46,7 @@ pynrrd pre-commit pydicom h5py -nni; platform_system == "Linux" +nni; platform_system == "Linux" and "arm" not in platform_machine and "aarch" not in platform_machine optuna git+https://github.com/Project-MONAI/MetricsReloaded@monai-support#egg=MetricsReloaded onnx>=1.13.0 diff --git a/setup.cfg b/setup.cfg index 229e2ace56..d7cb703d25 100644 --- a/setup.cfg +++ b/setup.cfg @@ -59,7 +59,7 @@ all = tqdm>=4.47.0 lmdb psutil - cucim>=23.2.0 + cucim-cu12; python_version >= '3.9' and python_version <= '3.10' openslide-python tifffile imagecodecs @@ -111,7 +111,7 @@ lmdb = psutil = psutil cucim = - cucim>=23.2.0 + cucim-cu12 openslide = openslide-python tifffile = diff --git a/tests/test_convert_to_onnx.py b/tests/test_convert_to_onnx.py index 398d260c52..798c510800 100644 --- a/tests/test_convert_to_onnx.py +++ b/tests/test_convert_to_onnx.py @@ -12,6 +12,7 @@ from __future__ import annotations import itertools +import platform import unittest import torch @@ -29,6 +30,12 @@ TESTS = list(itertools.product(TORCH_DEVICE_OPTIONS, [True, False], [True, False])) TESTS_ORT = list(itertools.product(TORCH_DEVICE_OPTIONS, [True])) +ON_AARCH64 = platform.machine() == "aarch64" +if ON_AARCH64: + rtol, atol = 1e-1, 1e-2 +else: + rtol, atol = 1e-3, 1e-4 + onnx, _ = optional_import("onnx") @@ -56,8 +63,8 @@ def test_unet(self, device, use_trace, use_ort): device=device, use_ort=use_ort, use_trace=use_trace, - rtol=1e-3, - atol=1e-4, + rtol=rtol, + atol=atol, ) else: # https://github.com/pytorch/pytorch/blob/release/1.9/torch/onnx/__init__.py#L182 @@ -72,8 +79,8 @@ def test_unet(self, device, use_trace, use_ort): device=device, use_ort=use_ort, use_trace=use_trace, - rtol=1e-3, - atol=1e-4, + rtol=rtol, + atol=atol, ) self.assertTrue(isinstance(onnx_model, onnx.ModelProto)) @@ -107,8 +114,8 @@ def test_seg_res_net(self, device, use_ort): device=device, use_ort=use_ort, use_trace=True, - rtol=1e-3, - atol=1e-4, + rtol=rtol, + atol=atol, ) self.assertTrue(isinstance(onnx_model, onnx.ModelProto)) diff --git a/tests/test_dynunet.py b/tests/test_dynunet.py index b0137ae245..f3c982056c 100644 --- a/tests/test_dynunet.py +++ b/tests/test_dynunet.py @@ -11,6 +11,7 @@ from __future__ import annotations +import platform import unittest from typing import Any, Sequence @@ -24,6 +25,12 @@ InstanceNorm3dNVFuser, _ = optional_import("apex.normalization", name="InstanceNorm3dNVFuser") +ON_AARCH64 = platform.machine() == "aarch64" +if ON_AARCH64: + rtol, atol = 1e-2, 1e-2 +else: + rtol, atol = 1e-4, 1e-4 + device = "cuda" if torch.cuda.is_available() else "cpu" strides: Sequence[Sequence[int] | int] @@ -159,7 +166,7 @@ def test_consistency(self, input_param, input_shape, _): with eval_mode(net_fuser): result_fuser = net_fuser(input_tensor) - assert_allclose(result, result_fuser, rtol=1e-4, atol=1e-4) + assert_allclose(result, result_fuser, rtol=rtol, atol=atol) class TestDynUNetDeepSupervision(unittest.TestCase): diff --git a/tests/test_rand_affine.py b/tests/test_rand_affine.py index f37f7827bb..23e3fd148c 100644 --- a/tests/test_rand_affine.py +++ b/tests/test_rand_affine.py @@ -147,7 +147,7 @@ def test_rand_affine(self, input_param, input_data, expected_val): g.set_random_state(123) result = g(**input_data) g.rand_affine_grid.affine = torch.eye(4, dtype=torch.float64) # reset affine - test_resampler_lazy(g, result, input_param, input_data, seed=123) + test_resampler_lazy(g, result, input_param, input_data, seed=123, rtol=_rtol) if input_param.get("cache_grid", False): self.assertTrue(g._cached_grid is not None) assert_allclose(result, expected_val, rtol=_rtol, atol=1e-4, type_test="tensor") diff --git a/tests/test_rand_affined.py b/tests/test_rand_affined.py index 20c50954e2..32fde8dc0f 100644 --- a/tests/test_rand_affined.py +++ b/tests/test_rand_affined.py @@ -234,7 +234,9 @@ def test_rand_affined(self, input_param, input_data, expected_val, track_meta): lazy_init_param["keys"], lazy_init_param["mode"] = key, mode resampler = RandAffined(**lazy_init_param).set_random_state(123) expected_output = resampler(**call_param) - test_resampler_lazy(resampler, expected_output, lazy_init_param, call_param, seed=123, output_key=key) + test_resampler_lazy( + resampler, expected_output, lazy_init_param, call_param, seed=123, output_key=key, rtol=_rtol + ) resampler.lazy = False if input_param.get("cache_grid", False): diff --git a/tests/test_spatial_resampled.py b/tests/test_spatial_resampled.py index 541015cc34..d5c86258d7 100644 --- a/tests/test_spatial_resampled.py +++ b/tests/test_spatial_resampled.py @@ -11,6 +11,7 @@ from __future__ import annotations +import platform import unittest import numpy as np @@ -23,6 +24,12 @@ from tests.lazy_transforms_utils import test_resampler_lazy from tests.utils import TEST_DEVICES, assert_allclose +ON_AARCH64 = platform.machine() == "aarch64" +if ON_AARCH64: + rtol, atol = 1e-1, 1e-2 +else: + rtol, atol = 1e-3, 1e-4 + TESTS = [] destinations_3d = [ @@ -104,7 +111,7 @@ def test_flips_inverse(self, img, device, dst_affine, kwargs, expected_output): # check lazy lazy_xform = SpatialResampled(**init_param) - test_resampler_lazy(lazy_xform, output_data, init_param, call_param, output_key="img") + test_resampler_lazy(lazy_xform, output_data, init_param, call_param, output_key="img", rtol=rtol, atol=atol) # check inverse inverted = xform.inverse(output_data)["img"] From 95f69dea3d2ff9fb3d0695d922213aefaf5f0c39 Mon Sep 17 00:00:00 2001 From: YunLiu <55491388+KumoLiu@users.noreply.github.com> Date: Sun, 10 Mar 2024 23:21:28 +0800 Subject: [PATCH 07/35] Fix error in "test_bundle_trt_export" (#7524) Fixes #7523 ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> --- monai/networks/utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/monai/networks/utils.py b/monai/networks/utils.py index 42e537648a..4e6699f16b 100644 --- a/monai/networks/utils.py +++ b/monai/networks/utils.py @@ -850,7 +850,10 @@ def _onnx_trt_compile( # wrap the serialized TensorRT engine back to a TorchScript module. trt_model = torch_tensorrt.ts.embed_engine_in_new_module( - f.getvalue(), torch.device(f"cuda:{device}"), input_names, output_names + f.getvalue(), + device=torch.device(f"cuda:{device}"), + input_binding_names=input_names, + output_binding_names=output_names, ) return trt_model From 6b7568d02f1b5d58e28e18285bbf6139c00e28ef Mon Sep 17 00:00:00 2001 From: Fabian Klopfer Date: Fri, 15 Mar 2024 03:19:57 +0100 Subject: [PATCH 08/35] Fix typo in the PerceptualNetworkType Enum (#7548) Fixes #7547 ### Description Previously it was 'medical_resnet50_23datasets' for both identifier and string, which doesn't correspond to the name in the hubconf.py of Warvito's repo. Now it is the correct version (according to Warvitos repo) 'medicalnet_resnet50_23datasets'. The docs state it correctly already. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ x] New tests added to cover the changes. - [ x] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Fabian Klopfer --- monai/losses/perceptual.py | 2 +- tests/test_perceptual_loss.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/monai/losses/perceptual.py b/monai/losses/perceptual.py index 2207de5e64..fd61603b03 100644 --- a/monai/losses/perceptual.py +++ b/monai/losses/perceptual.py @@ -29,7 +29,7 @@ class PercetualNetworkType(StrEnum): squeeze = "squeeze" radimagenet_resnet50 = "radimagenet_resnet50" medicalnet_resnet10_23datasets = "medicalnet_resnet10_23datasets" - medical_resnet50_23datasets = "medical_resnet50_23datasets" + medicalnet_resnet50_23datasets = "medicalnet_resnet50_23datasets" resnet50 = "resnet50" diff --git a/tests/test_perceptual_loss.py b/tests/test_perceptual_loss.py index ba204af697..02232e6f8d 100644 --- a/tests/test_perceptual_loss.py +++ b/tests/test_perceptual_loss.py @@ -40,6 +40,11 @@ (2, 1, 64, 64, 64), (2, 1, 64, 64, 64), ], + [ + {"spatial_dims": 3, "network_type": "medicalnet_resnet50_23datasets", "is_fake_3d": False}, + (2, 1, 64, 64, 64), + (2, 1, 64, 64, 64), + ], [ {"spatial_dims": 3, "network_type": "resnet50", "is_fake_3d": True, "pretrained": True, "fake_3d_ratio": 0.2}, (2, 1, 64, 64, 64), From ec63e068fec85dff5f043d73f2c029b359332707 Mon Sep 17 00:00:00 2001 From: YunLiu <55491388+KumoLiu@users.noreply.github.com> Date: Mon, 18 Mar 2024 11:56:45 +0800 Subject: [PATCH 09/35] Update to use `log_sigmoid` in `FocalLoss` (#7534) Fixes #7533 ### Description A few sentences describing the changes proposed in this pull request. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. --------- Signed-off-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> --- monai/losses/focal_loss.py | 5 ++--- tests/test_focal_loss.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/monai/losses/focal_loss.py b/monai/losses/focal_loss.py index 98c1a071b6..28d1c0cdc9 100644 --- a/monai/losses/focal_loss.py +++ b/monai/losses/focal_loss.py @@ -234,9 +234,8 @@ def sigmoid_focal_loss( """ # computing binary cross entropy with logits # equivalent to F.binary_cross_entropy_with_logits(input, target, reduction='none') - # see also https://github.com/pytorch/pytorch/blob/v1.9.0/aten/src/ATen/native/Loss.cpp#L231 - max_val = (-input).clamp(min=0) - loss: torch.Tensor = input - input * target + max_val + ((-max_val).exp() + (-input - max_val).exp()).log() + # see also https://github.com/pytorch/pytorch/blob/main/aten/src/ATen/native/Loss.cpp#L363 + loss: torch.Tensor = input - input * target - F.logsigmoid(input) # sigmoid(-i) if t==1; sigmoid(i) if t==0 <=> # 1-sigmoid(i) if t==1; sigmoid(i) if t==0 <=> diff --git a/tests/test_focal_loss.py b/tests/test_focal_loss.py index de8d625058..0bb8a078ae 100644 --- a/tests/test_focal_loss.py +++ b/tests/test_focal_loss.py @@ -132,7 +132,7 @@ def test_consistency_with_cross_entropy_2d_no_reduction(self): error = np.abs(a - b) max_error = np.maximum(error, max_error) - assert np.allclose(max_error, 0) + assert np.allclose(max_error, 0, atol=1e-6) def test_consistency_with_cross_entropy_2d_onehot_label(self): """For gamma=0 the focal loss reduces to the cross entropy loss""" From 35c93fd08669ee41a70c297c509c7ff88f06c307 Mon Sep 17 00:00:00 2001 From: YunLiu <55491388+KumoLiu@users.noreply.github.com> Date: Fri, 22 Mar 2024 16:03:00 +0800 Subject: [PATCH 10/35] Update integration_segmentation_3d result for PyTorch2403 (#7551) Fixes #7550 ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> Co-authored-by: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> --- runtests.sh | 2 + tests/testing_data/integration_answers.py | 56 +++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/runtests.sh b/runtests.sh index 0c60bc0f58..0b3e20ce49 100755 --- a/runtests.sh +++ b/runtests.sh @@ -738,12 +738,14 @@ fi # network training/inference/eval integration tests if [ $doNetTests = true ] then + set +e # disable exit on failure so that diagnostics can be given on failure echo "${separator}${blue}integration${noColor}" for i in tests/*integration_*.py do echo "$i" ${cmdPrefix}${cmd} "$i" done + set -e # enable exit on failure fi # run model zoo tests diff --git a/tests/testing_data/integration_answers.py b/tests/testing_data/integration_answers.py index c0dd973418..e02b9ae995 100644 --- a/tests/testing_data/integration_answers.py +++ b/tests/testing_data/integration_answers.py @@ -600,6 +600,62 @@ ], } }, + { # test answers for 24.03 + "integration_segmentation_3d": { + "losses": [ + 0.5442982316017151, + 0.4741817444562912, + 0.4535954713821411, + 0.44163046181201937, + 0.4307525992393494, + 0.428487154841423, + ], + "best_metric": 0.9314384460449219, + "infer_metric": 0.9315622448921204, + "output_sums": [ + 0.14268704426414708, + 0.1528672845845743, + 0.1521782248125706, + 0.14028769128068194, + 0.1889830671664784, + 0.16999075690664475, + 0.14736282992708227, + 0.16877952654821815, + 0.15779597155181269, + 0.17987829927082263, + 0.16320253928314676, + 0.16854299322173155, + 0.14497470986956967, + 0.11437140546369519, + 0.1624117412960871, + 0.20156009294443875, + 0.1764654154256958, + 0.0982348259217418, + 0.1942436068604293, + 0.20359421536407518, + 0.19661953116976483, + 0.2088326101468625, + 0.16273043545239807, + 0.1326107887439663, + 0.1489245275752285, + 0.143107476635514, + 0.23189027677929547, + 0.1613818424566088, + 0.14889532196775188, + 0.10332622984492143, + 0.11940054688302351, + 0.13040496302762658, + 0.11472123087193181, + 0.15307044007394474, + 0.16371989575844717, + 0.1942898223272055, + 0.2230120930471398, + 0.1814679187634795, + 0.19069496508164732, + 0.07537197031940022, + ], + } + }, ] From c64993431198774ef896f525bad0fd6cc932e788 Mon Sep 17 00:00:00 2001 From: Lucas Robinet <67736918+Lucas-rbnt@users.noreply.github.com> Date: Fri, 22 Mar 2024 11:58:39 +0100 Subject: [PATCH 11/35] Add Barlow Twins loss for representation learning (#7530) ### Description Addition of the BarlowTwinsLoss class. This cost function is introduced in the http://proceedings.mlr.press/v139/zbontar21a/zbontar21a.pdf paper with the aim of disentangling the representations learned on two views of the same sample, making it a powerful tool for multimodal and unsupervised learning. This cost function is similar to the InfoNCE Loss function already implemented in MONAI (https://docs.monai.io/en/latest/_modules/monai/losses/contrastive.html#ContrastiveLoss). However, it differs in several respects: there is no l2-normalisation, but rather a z-normalisation. In addition, rather than working between pairs of embeddings, Barlow Twins seeks to decorrelate the components of the representations. ```math \mathcal{L}_{BT} := \sum_i (1 - \mathcal{C}_{ii})^2 + \lambda \sum_i \sum_{i\neq j} \mathcal{C}_{ij}^2 ``` with $\lambda$ a positive hyperparameters and $\mathcal{C}$ the cross-correlation matrix ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [x] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [x] In-line docstrings updated. - [x] Documentation updated, tested `make html` command in the `docs/` folder. --------- Signed-off-by: Lucas Robinet Signed-off-by: Lucas Robinet <67736918+Lucas-rbnt@users.noreply.github.com> Co-authored-by: Lucas Robinet Co-authored-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Co-authored-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- docs/source/losses.rst | 5 ++ monai/losses/__init__.py | 1 + monai/losses/barlow_twins.py | 84 ++++++++++++++++++++++++ tests/test_barlow_twins_loss.py | 109 ++++++++++++++++++++++++++++++++ 4 files changed, 199 insertions(+) create mode 100644 monai/losses/barlow_twins.py create mode 100644 tests/test_barlow_twins_loss.py diff --git a/docs/source/losses.rst b/docs/source/losses.rst index e929e9d605..61dd959807 100644 --- a/docs/source/losses.rst +++ b/docs/source/losses.rst @@ -73,6 +73,11 @@ Segmentation Losses .. autoclass:: ContrastiveLoss :members: +`BarlowTwinsLoss` +~~~~~~~~~~~~~~~~~ +.. autoclass:: BarlowTwinsLoss + :members: + `HausdorffDTLoss` ~~~~~~~~~~~~~~~~~ .. autoclass:: HausdorffDTLoss diff --git a/monai/losses/__init__.py b/monai/losses/__init__.py index 92898c81ca..4ebedb2084 100644 --- a/monai/losses/__init__.py +++ b/monai/losses/__init__.py @@ -12,6 +12,7 @@ from __future__ import annotations from .adversarial_loss import PatchAdversarialLoss +from .barlow_twins import BarlowTwinsLoss from .cldice import SoftclDiceLoss, SoftDiceclDiceLoss from .contrastive import ContrastiveLoss from .deform import BendingEnergyLoss, DiffusionLoss diff --git a/monai/losses/barlow_twins.py b/monai/losses/barlow_twins.py new file mode 100644 index 0000000000..a61acca66e --- /dev/null +++ b/monai/losses/barlow_twins.py @@ -0,0 +1,84 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import torch +from torch.nn.modules.loss import _Loss + + +class BarlowTwinsLoss(_Loss): + """ + The Barlow Twins cost function takes the representations extracted by a neural network from two + distorted views and seeks to make the cross-correlation matrix of the two representations tend + towards identity. This encourages the neural network to learn similar representations with the least + amount of redundancy. This cost function can be used in particular in multimodal learning to work on + representations from two modalities. The most common use case is for unsupervised learning, where data + augmentations are used to generate 2 distorted views of the same sample to force the encoder to + extract useful features for downstream tasks. + + Zbontar, Jure, et al. "Barlow Twins: Self-Supervised Learning via Redundancy Reduction" International + conference on machine learning. PMLR, 2020. (http://proceedings.mlr.press/v139/zbontar21a/zbontar21a.pdf) + + Adapted from: + https://github.com/facebookresearch/barlowtwins + + """ + + def __init__(self, lambd: float = 5e-3) -> None: + """ + Args: + lamb: Can be any float to handle the informativeness and invariance trade-off. Ideally set to 5e-3. + + Raises: + ValueError: When an input of dimension length > 2 is passed + ValueError: When input and target are of different shapes + ValueError: When batch size is less than or equal to 1 + + """ + super().__init__() + self.lambd = lambd + + def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: + """ + Args: + input: the shape should be B[F]. + target: the shape should be B[F]. + """ + if len(target.shape) > 2 or len(input.shape) > 2: + raise ValueError( + f"Either target or input has dimensions greater than 2 where target " + f"shape is ({target.shape}) and input shape is ({input.shape})" + ) + + if target.shape != input.shape: + raise ValueError(f"ground truth has differing shape ({target.shape}) from input ({input.shape})") + + if target.size(0) <= 1: + raise ValueError( + f"Batch size must be greater than 1 to compute Barlow Twins Loss, but got {target.size(0)}" + ) + + lambd_tensor = torch.as_tensor(self.lambd).to(input.device) + batch_size = input.shape[0] + + # normalize input and target + input_norm = (input - input.mean(0)) / input.std(0).add(1e-6) + target_norm = (target - target.mean(0)) / target.std(0).add(1e-6) + + # cross-correlation matrix + c = torch.mm(input_norm.t(), target_norm) / batch_size # input_norm.t() is FxB, target_norm is BxF so c is FxF + + # loss + c_diff = (c - torch.eye(c.size(0), device=c.device)).pow_(2) # FxF + c_diff[~torch.eye(c.size(0), device=c.device).bool()] *= lambd_tensor + + return c_diff.sum() diff --git a/tests/test_barlow_twins_loss.py b/tests/test_barlow_twins_loss.py new file mode 100644 index 0000000000..81f4032e0c --- /dev/null +++ b/tests/test_barlow_twins_loss.py @@ -0,0 +1,109 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import numpy as np +import torch +from parameterized import parameterized + +from monai.losses import BarlowTwinsLoss + +TEST_CASES = [ + [ # shape: (2, 4), (2, 4) + {"lambd": 5e-3}, + { + "input": torch.tensor([[1.0, 1.0, 0.0, 0.0], [1.0, 1.0, 0.0, 0.0]]), + "target": torch.tensor([[1.0, 1.0, 0.0, 0.0], [1.0, 1.0, 0.0, 0.0]]), + }, + 4.0, + ], + [ # shape: (2, 4), (2, 4) + {"lambd": 5e-3}, + { + "input": torch.tensor([[0.0, 0.0, 1.0, 1.0], [0.0, 0.0, 1.0, 1.0]]), + "target": torch.tensor([[1.0, 1.0, 0.0, 0.0], [1.0, 1.0, 0.0, 0.0]]), + }, + 4.0, + ], + [ # shape: (2, 4), (2, 4) + {"lambd": 5e-3}, + { + "input": torch.tensor([[1.0, 0.0, 1.0, 1.0], [0.0, 1.0, 1.0, 0.0]]), + "target": torch.tensor([[1.0, 1.0, 1.0, 0.0], [1.0, 1.0, 0.0, 1.0]]), + }, + 5.2562, + ], + [ # shape: (2, 4), (2, 4) + {"lambd": 5e-4}, + { + "input": torch.tensor([[2.0, 3.0, 1.0, 2.0], [0.0, 1.0, 2.0, 5.0]]), + "target": torch.tensor([[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]]), + }, + 5.0015, + ], + [ # shape: (4, 4), (4, 4) + {"lambd": 5e-3}, + { + "input": torch.tensor( + [[1.0, 2.0, 1.0, 1.0], [3.0, 1.0, 1.0, 2.0], [1.0, 1.0, 1.0, 1.0], [2.0, 1.0, 1.0, 0.0]] + ), + "target": torch.tensor( + [ + [0.0, 1.0, -1.0, 0.0], + [1 / 3, 0.0, -2 / 3, 1 / 3], + [-2 / 3, -1.0, 7 / 3, 1 / 3], + [1 / 3, 0.0, 1 / 3, -2 / 3], + ] + ), + }, + 1.4736, + ], +] + + +class TestBarlowTwinsLoss(unittest.TestCase): + + @parameterized.expand(TEST_CASES) + def test_result(self, input_param, input_data, expected_val): + barlowtwinsloss = BarlowTwinsLoss(**input_param) + result = barlowtwinsloss(**input_data) + np.testing.assert_allclose(result.detach().cpu().numpy(), expected_val, atol=1e-4, rtol=1e-4) + + def test_ill_shape(self): + loss = BarlowTwinsLoss(lambd=5e-3) + with self.assertRaises(ValueError): + loss(torch.ones((1, 2, 3)), torch.ones((1, 1, 2, 3))) + + def test_ill_batch_size(self): + loss = BarlowTwinsLoss(lambd=5e-3) + with self.assertRaises(ValueError): + loss(torch.ones((1, 2)), torch.ones((1, 2))) + + def test_with_cuda(self): + loss = BarlowTwinsLoss(lambd=5e-3) + i = torch.ones((2, 10)) + j = torch.ones((2, 10)) + if torch.cuda.is_available(): + i = i.cuda() + j = j.cuda() + output = loss(i, j) + np.testing.assert_allclose(output.detach().cpu().numpy(), 10.0, atol=1e-4, rtol=1e-4) + + def check_warning_raised(self): + with self.assertWarns(Warning): + BarlowTwinsLoss(lambd=5e-3, batch_size=1) + + +if __name__ == "__main__": + unittest.main() From c3a7383494b35f82bc3193797a3b07629e94c0c0 Mon Sep 17 00:00:00 2001 From: cxlcl Date: Fri, 22 Mar 2024 09:54:40 -0700 Subject: [PATCH 12/35] Stein's Unbiased Risk Estimator (SURE) loss and Conjugate Gradient (#7308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Description Based on the discussion topic [here](https://github.com/Project-MONAI/MONAI/discussions/7161#discussion-5773293), we implemented the Conjugate-Gradient algorithm for linear operator inversion, and Stein's Unbiased Risk Estimator (SURE) [1] loss for ground-truth-date free diffusion process guidance that is proposed in [2] and illustrated in the algorithm below: Screenshot 2023-12-10 at 10 19 25 PM The Conjugate-Gradient (CG) algorithm is used to solve for the inversion of the linear operator in Line-4 in the algorithm above, where the linear operator is too large to store explicitly as a matrix (such as FFT/IFFT of an image) and invert directly. Instead, we can solve for the linear inversion iteratively as in CG. The SURE loss is applied for Line-6 above. This is a differentiable loss function that can be used to train/giude an operator (e.g. neural network), where the pseudo ground truth is available but the reference ground truth is not. For example, in the MRI reconstruction, the pseudo ground truth is the zero-filled reconstruction and the reference ground truth is the fully sampled reconstruction. The reference ground truth is not available due to the lack of fully sampled. **Reference** [1] Stein, C.M.: Estimation of the mean of a multivariate normal distribution. Annals of Statistics 1981 [[paper link](https://projecteuclid.org/journals/annals-of-statistics/volume-9/issue-6/Estimation-of-the-Mean-of-a-Multivariate-Normal-Distribution/10.1214/aos/1176345632.full)] [2] B. Ozturkler et al. SMRD: SURE-based Robust MRI Reconstruction with Diffusion Models. MICCAI 2023 [[paper link](https://arxiv.org/pdf/2310.01799.pdf)] ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [x] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [x] In-line docstrings updated. - [x] Documentation updated, tested `make html` command in the `docs/` folder. --------- Signed-off-by: chaoliu Signed-off-by: cxlcl Signed-off-by: chaoliu Signed-off-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> Co-authored-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> --- docs/source/losses.rst | 5 + docs/source/networks.rst | 5 + monai/losses/__init__.py | 1 + monai/losses/sure_loss.py | 200 ++++++++++++++++++++ monai/networks/layers/__init__.py | 1 + monai/networks/layers/conjugate_gradient.py | 112 +++++++++++ tests/test_conjugate_gradient.py | 55 ++++++ tests/test_sure_loss.py | 71 +++++++ 8 files changed, 450 insertions(+) create mode 100644 monai/losses/sure_loss.py create mode 100644 monai/networks/layers/conjugate_gradient.py create mode 100644 tests/test_conjugate_gradient.py create mode 100644 tests/test_sure_loss.py diff --git a/docs/source/losses.rst b/docs/source/losses.rst index 61dd959807..ba794af3eb 100644 --- a/docs/source/losses.rst +++ b/docs/source/losses.rst @@ -139,6 +139,11 @@ Reconstruction Losses .. autoclass:: JukeboxLoss :members: +`SURELoss` +~~~~~~~~~~ +.. autoclass:: SURELoss + :members: + Loss Wrappers ------------- diff --git a/docs/source/networks.rst b/docs/source/networks.rst index 8eada7933f..b59c8af5fc 100644 --- a/docs/source/networks.rst +++ b/docs/source/networks.rst @@ -408,6 +408,11 @@ Layers .. autoclass:: LLTM :members: +`ConjugateGradient` +~~~~~~~~~~~~~~~~~~~ +.. autoclass:: ConjugateGradient + :members: + `Utilities` ~~~~~~~~~~~ .. automodule:: monai.networks.layers.convutils diff --git a/monai/losses/__init__.py b/monai/losses/__init__.py index 4ebedb2084..e937b53fa4 100644 --- a/monai/losses/__init__.py +++ b/monai/losses/__init__.py @@ -41,5 +41,6 @@ from .spatial_mask import MaskedLoss from .spectral_loss import JukeboxLoss from .ssim_loss import SSIMLoss +from .sure_loss import SURELoss from .tversky import TverskyLoss from .unified_focal_loss import AsymmetricUnifiedFocalLoss diff --git a/monai/losses/sure_loss.py b/monai/losses/sure_loss.py new file mode 100644 index 0000000000..ebf25613a6 --- /dev/null +++ b/monai/losses/sure_loss.py @@ -0,0 +1,200 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Callable, Optional + +import torch +import torch.nn as nn +from torch.nn.modules.loss import _Loss + + +def complex_diff_abs_loss(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor: + """ + First compute the difference in the complex domain, + then get the absolute value and take the mse + + Args: + x, y - B, 2, H, W real valued tensors representing complex numbers + or B,1,H,W complex valued tensors + Returns: + l2_loss - scalar + """ + if not x.is_complex(): + x = torch.view_as_complex(x.permute(0, 2, 3, 1).contiguous()) + if not y.is_complex(): + y = torch.view_as_complex(y.permute(0, 2, 3, 1).contiguous()) + + diff = torch.abs(x - y) + return nn.functional.mse_loss(diff, torch.zeros_like(diff), reduction="mean") + + +def sure_loss_function( + operator: Callable, + x: torch.Tensor, + y_pseudo_gt: torch.Tensor, + y_ref: Optional[torch.Tensor] = None, + eps: Optional[float] = -1.0, + perturb_noise: Optional[torch.Tensor] = None, + complex_input: Optional[bool] = False, +) -> torch.Tensor: + """ + Args: + operator (function): The operator function that takes in an input + tensor x and returns an output tensor y. We will use this to compute + the divergence. More specifically, we will perturb the input x by a + small amount and compute the divergence between the perturbed output + and the reference output + + x (torch.Tensor): The input tensor of shape (B, C, H, W) to the + operator. For complex input, the shape is (B, 2, H, W) aka C=2 real. + For real input, the shape is (B, 1, H, W) real. + + y_pseudo_gt (torch.Tensor): The pseudo ground truth tensor of shape + (B, C, H, W) used to compute the L2 loss. For complex input, the shape is + (B, 2, H, W) aka C=2 real. For real input, the shape is (B, 1, H, W) + real. + + y_ref (torch.Tensor, optional): The reference output tensor of shape + (B, C, H, W) used to compute the divergence. Defaults to None. For + complex input, the shape is (B, 2, H, W) aka C=2 real. For real input, + the shape is (B, 1, H, W) real. + + eps (float, optional): The perturbation scalar. Set to -1 to set it + automatically estimated based on y_pseudo_gtk + + perturb_noise (torch.Tensor, optional): The noise vector of shape (B, C, H, W). + Defaults to None. For complex input, the shape is (B, 2, H, W) aka C=2 real. + For real input, the shape is (B, 1, H, W) real. + + complex_input(bool, optional): Whether the input is complex or not. + Defaults to False. + + Returns: + sure_loss (torch.Tensor): The SURE loss scalar. + """ + # perturb input + if perturb_noise is None: + perturb_noise = torch.randn_like(x) + if eps == -1.0: + eps = float(torch.abs(y_pseudo_gt.max())) / 1000 + # get y_ref if not provided + if y_ref is None: + y_ref = operator(x) + + # get perturbed output + x_perturbed = x + eps * perturb_noise + y_perturbed = operator(x_perturbed) + # divergence + divergence = torch.sum(1.0 / eps * torch.matmul(perturb_noise.permute(0, 1, 3, 2), y_perturbed - y_ref)) # type: ignore + # l2 loss between y_ref, y_pseudo_gt + if complex_input: + l2_loss = complex_diff_abs_loss(y_ref, y_pseudo_gt) + else: + # real input + l2_loss = nn.functional.mse_loss(y_ref, y_pseudo_gt, reduction="mean") + + # sure loss + sure_loss = l2_loss * divergence / (x.shape[0] * x.shape[2] * x.shape[3]) + return sure_loss + + +class SURELoss(_Loss): + """ + Calculate the Stein's Unbiased Risk Estimator (SURE) loss for a given operator. + + This is a differentiable loss function that can be used to train/guide an + operator (e.g. neural network), where the pseudo ground truth is available + but the reference ground truth is not. For example, in the MRI + reconstruction, the pseudo ground truth is the zero-filled reconstruction + and the reference ground truth is the fully sampled reconstruction. Often, + the reference ground truth is not available due to the lack of fully sampled + data. + + The original SURE loss is proposed in [1]. The SURE loss used for guiding + the diffusion model based MRI reconstruction is proposed in [2]. + + Reference + + [1] Stein, C.M.: Estimation of the mean of a multivariate normal distribution. Annals of Statistics + + [2] B. Ozturkler et al. SMRD: SURE-based Robust MRI Reconstruction with Diffusion Models. + (https://arxiv.org/pdf/2310.01799.pdf) + """ + + def __init__(self, perturb_noise: Optional[torch.Tensor] = None, eps: Optional[float] = None) -> None: + """ + Args: + perturb_noise (torch.Tensor, optional): The noise vector of shape + (B, C, H, W). Defaults to None. For complex input, the shape is (B, 2, H, W) aka C=2 real. + For real input, the shape is (B, 1, H, W) real. + + eps (float, optional): The perturbation scalar. Defaults to None. + """ + super().__init__() + self.perturb_noise = perturb_noise + self.eps = eps + + def forward( + self, + operator: Callable, + x: torch.Tensor, + y_pseudo_gt: torch.Tensor, + y_ref: Optional[torch.Tensor] = None, + complex_input: Optional[bool] = False, + ) -> torch.Tensor: + """ + Args: + operator (function): The operator function that takes in an input + tensor x and returns an output tensor y. We will use this to compute + the divergence. More specifically, we will perturb the input x by a + small amount and compute the divergence between the perturbed output + and the reference output + + x (torch.Tensor): The input tensor of shape (B, C, H, W) to the + operator. C=1 or 2: For complex input, the shape is (B, 2, H, W) aka + C=2 real. For real input, the shape is (B, 1, H, W) real. + + y_pseudo_gt (torch.Tensor): The pseudo ground truth tensor of shape + (B, C, H, W) used to compute the L2 loss. C=1 or 2: For complex + input, the shape is (B, 2, H, W) aka C=2 real. For real input, the + shape is (B, 1, H, W) real. + + y_ref (torch.Tensor, optional): The reference output tensor of the + same shape as y_pseudo_gt + + Returns: + sure_loss (torch.Tensor): The SURE loss scalar. + """ + + # check inputs shapes + if x.dim() != 4: + raise ValueError(f"Input tensor x should be 4D, got {x.dim()}.") + if y_pseudo_gt.dim() != 4: + raise ValueError(f"Input tensor y_pseudo_gt should be 4D, but got {y_pseudo_gt.dim()}.") + if y_ref is not None and y_ref.dim() != 4: + raise ValueError(f"Input tensor y_ref should be 4D, but got {y_ref.dim()}.") + if x.shape != y_pseudo_gt.shape: + raise ValueError( + f"Input tensor x and y_pseudo_gt should have the same shape, but got x shape {x.shape}, " + f"y_pseudo_gt shape {y_pseudo_gt.shape}." + ) + if y_ref is not None and y_pseudo_gt.shape != y_ref.shape: + raise ValueError( + f"Input tensor y_pseudo_gt and y_ref should have the same shape, but got y_pseudo_gt shape {y_pseudo_gt.shape}, " + f"y_ref shape {y_ref.shape}." + ) + + # compute loss + loss = sure_loss_function(operator, x, y_pseudo_gt, y_ref, self.eps, self.perturb_noise, complex_input) + + return loss diff --git a/monai/networks/layers/__init__.py b/monai/networks/layers/__init__.py index d61ed57f7f..3a6e4aa554 100644 --- a/monai/networks/layers/__init__.py +++ b/monai/networks/layers/__init__.py @@ -11,6 +11,7 @@ from __future__ import annotations +from .conjugate_gradient import ConjugateGradient from .convutils import calculate_out_shape, gaussian_1d, polyval, same_padding, stride_minus_kernel_padding from .drop_path import DropPath from .factories import Act, Conv, Dropout, LayerFactory, Norm, Pad, Pool, split_args diff --git a/monai/networks/layers/conjugate_gradient.py b/monai/networks/layers/conjugate_gradient.py new file mode 100644 index 0000000000..93a45930d7 --- /dev/null +++ b/monai/networks/layers/conjugate_gradient.py @@ -0,0 +1,112 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Callable + +import torch +from torch import nn + + +def _zdot(x1: torch.Tensor, x2: torch.Tensor) -> torch.Tensor: + """ + Complex dot product between tensors x1 and x2: sum(x1.*x2) + """ + if torch.is_complex(x1): + assert torch.is_complex(x2), "x1 and x2 must both be complex" + return torch.sum(x1.conj() * x2) + else: + return torch.sum(x1 * x2) + + +def _zdot_single(x: torch.Tensor) -> torch.Tensor: + """ + Complex dot product between tensor x and itself + """ + res = _zdot(x, x) + if torch.is_complex(res): + return res.real + else: + return res + + +class ConjugateGradient(nn.Module): + """ + Congugate Gradient (CG) solver for linear systems Ax = y. + + For linear_op that is positive definite and self-adjoint, CG is + guaranteed to converge CG is often used to solve linear systems of the form + Ax = y, where A is too large to store explicitly, but can be computed via a + linear operator. + + As a result, here we won't set A explicitly as a matrix, but rather as a + linear operator. For example, A could be a FFT/IFFT operation + """ + + def __init__(self, linear_op: Callable, num_iter: int): + """ + Args: + linear_op: Linear operator + num_iter: Number of iterations to run CG + """ + super().__init__() + + self.linear_op = linear_op + self.num_iter = num_iter + + def update( + self, x: torch.Tensor, p: torch.Tensor, r: torch.Tensor, rsold: torch.Tensor + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + """ + perform one iteration of the CG method. It takes the current solution x, + the current search direction p, the current residual r, and the old + residual norm rsold as inputs. Then it computes the new solution, search + direction, residual, and residual norm, and returns them. + """ + + dy = self.linear_op(p) + p_dot_dy = _zdot(p, dy) + alpha = rsold / p_dot_dy + x = x + alpha * p + r = r - alpha * dy + rsnew = _zdot_single(r) + beta = rsnew / rsold + rsold = rsnew + p = beta * p + r + return x, p, r, rsold + + def forward(self, x: torch.Tensor, y: torch.Tensor) -> torch.Tensor: + """ + run conjugate gradient for num_iter iterations to solve Ax = y + + Args: + x: tensor (real or complex); Initial guess for linear system Ax = y. + The size of x should be applicable to the linear operator. For + example, if the linear operator is FFT, then x is HCHW; if the + linear operator is a matrix multiplication, then x is a vector + + y: tensor (real or complex); Measurement. Same size as x + + Returns: + x: Solution to Ax = y + """ + # Compute residual + r = y - self.linear_op(x) + rsold = _zdot_single(r) + p = r + + # Update + for _i in range(self.num_iter): + x, p, r, rsold = self.update(x, p, r, rsold) + if rsold < 1e-10: + break + return x diff --git a/tests/test_conjugate_gradient.py b/tests/test_conjugate_gradient.py new file mode 100644 index 0000000000..239dbe3ecd --- /dev/null +++ b/tests/test_conjugate_gradient.py @@ -0,0 +1,55 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import torch + +from monai.networks.layers import ConjugateGradient + + +class TestConjugateGradient(unittest.TestCase): + def test_real_valued_inverse(self): + """Test ConjugateGradient with real-valued input: when the input is real + value, the output should be the inverse of the matrix.""" + a_dim = 3 + a_mat = torch.tensor([[1, 2, 3], [2, 1, 2], [3, 2, 1]], dtype=torch.float) + + def a_op(x): + return a_mat @ x + + cg_solver = ConjugateGradient(a_op, num_iter=100) + # define the measurement + y = torch.tensor([1, 2, 3], dtype=torch.float) + # solve for x + x = cg_solver(torch.zeros(a_dim), y) + x_ref = torch.linalg.solve(a_mat, y) + # assert torch.allclose(x, x_ref, atol=1e-6), 'CG solver failed to converge to reference solution' + self.assertTrue(torch.allclose(x, x_ref, atol=1e-6)) + + def test_complex_valued_inverse(self): + a_dim = 3 + a_mat = torch.tensor([[1, 2, 3], [2, 1, 2], [3, 2, 1]], dtype=torch.complex64) + + def a_op(x): + return a_mat @ x + + cg_solver = ConjugateGradient(a_op, num_iter=100) + y = torch.tensor([1, 2, 3], dtype=torch.complex64) + x = cg_solver(torch.zeros(a_dim, dtype=torch.complex64), y) + x_ref = torch.linalg.solve(a_mat, y) + self.assertTrue(torch.allclose(x, x_ref, atol=1e-6)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sure_loss.py b/tests/test_sure_loss.py new file mode 100644 index 0000000000..945da657bf --- /dev/null +++ b/tests/test_sure_loss.py @@ -0,0 +1,71 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import torch + +from monai.losses import SURELoss + + +class TestSURELoss(unittest.TestCase): + def test_real_value(self): + """Test SURELoss with real-valued input: when the input is real value, the loss should be 0.0.""" + sure_loss_real = SURELoss(perturb_noise=torch.zeros(2, 1, 128, 128), eps=0.1) + + def operator(x): + return x + + y_pseudo_gt = torch.randn(2, 1, 128, 128) + x = torch.randn(2, 1, 128, 128) + loss = sure_loss_real(operator, x, y_pseudo_gt, complex_input=False) + self.assertAlmostEqual(loss.item(), 0.0) + + def test_complex_value(self): + """Test SURELoss with complex-valued input: when the input is complex value, the loss should be 0.0.""" + + def operator(x): + return x + + sure_loss_complex = SURELoss(perturb_noise=torch.zeros(2, 2, 128, 128), eps=0.1) + y_pseudo_gt = torch.randn(2, 2, 128, 128) + x = torch.randn(2, 2, 128, 128) + loss = sure_loss_complex(operator, x, y_pseudo_gt, complex_input=True) + self.assertAlmostEqual(loss.item(), 0.0) + + def test_complex_general_input(self): + """Test SURELoss with complex-valued input: when the input is general complex value, the loss should be 0.0.""" + + def operator(x): + return x + + perturb_noise_real = torch.randn(2, 1, 128, 128) + perturb_noise_complex = torch.zeros(2, 2, 128, 128) + perturb_noise_complex[:, 0, :, :] = perturb_noise_real.squeeze() + y_pseudo_gt_real = torch.randn(2, 1, 128, 128) + y_pseudo_gt_complex = torch.zeros(2, 2, 128, 128) + y_pseudo_gt_complex[:, 0, :, :] = y_pseudo_gt_real.squeeze() + x_real = torch.randn(2, 1, 128, 128) + x_complex = torch.zeros(2, 2, 128, 128) + x_complex[:, 0, :, :] = x_real.squeeze() + + sure_loss_real = SURELoss(perturb_noise=perturb_noise_real, eps=0.1) + sure_loss_complex = SURELoss(perturb_noise=perturb_noise_complex, eps=0.1) + + loss_real = sure_loss_real(operator, x_real, y_pseudo_gt_real, complex_input=False) + loss_complex = sure_loss_complex(operator, x_complex, y_pseudo_gt_complex, complex_input=True) + self.assertAlmostEqual(loss_real.item(), loss_complex.abs().item(), places=6) + + +if __name__ == "__main__": + unittest.main() From c86e790d56f3a5ad07db6e642c6a65e7779b7b21 Mon Sep 17 00:00:00 2001 From: monai-bot <64792179+monai-bot@users.noreply.github.com> Date: Mon, 25 Mar 2024 07:26:43 +0000 Subject: [PATCH 13/35] auto updates (#7577) Signed-off-by: monai-bot Signed-off-by: monai-bot --- tests/test_conjugate_gradient.py | 1 + tests/test_sure_loss.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/test_conjugate_gradient.py b/tests/test_conjugate_gradient.py index 239dbe3ecd..64efe3b168 100644 --- a/tests/test_conjugate_gradient.py +++ b/tests/test_conjugate_gradient.py @@ -19,6 +19,7 @@ class TestConjugateGradient(unittest.TestCase): + def test_real_valued_inverse(self): """Test ConjugateGradient with real-valued input: when the input is real value, the output should be the inverse of the matrix.""" diff --git a/tests/test_sure_loss.py b/tests/test_sure_loss.py index 945da657bf..903f9bd2ca 100644 --- a/tests/test_sure_loss.py +++ b/tests/test_sure_loss.py @@ -19,6 +19,7 @@ class TestSURELoss(unittest.TestCase): + def test_real_value(self): """Test SURELoss with real-valued input: when the input is real value, the loss should be 0.0.""" sure_loss_real = SURELoss(perturb_noise=torch.zeros(2, 1, 128, 128), eps=0.1) From 97678fa75a851c411c713658059bac3b2ce5cf70 Mon Sep 17 00:00:00 2001 From: Suraj Pai Date: Mon, 25 Mar 2024 22:13:56 -0400 Subject: [PATCH 14/35] Remove nested error propagation on `ConfigComponent` instantiate (#7569) Fixes #7451 ### Description Reduces the length of error messages and error messages being propagated twice. This helps debug better when long `ConfigComponent`s are being instantiated. Refer to issue #7451 for more details ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Suraj Pai Co-authored-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> --- monai/bundle/config_item.py | 5 +---- monai/utils/module.py | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/monai/bundle/config_item.py b/monai/bundle/config_item.py index 844d5b30bf..e5122bf3de 100644 --- a/monai/bundle/config_item.py +++ b/monai/bundle/config_item.py @@ -289,10 +289,7 @@ def instantiate(self, **kwargs: Any) -> object: mode = self.get_config().get("_mode_", CompInitMode.DEFAULT) args = self.resolve_args() args.update(kwargs) - try: - return instantiate(modname, mode, **args) - except Exception as e: - raise RuntimeError(f"Failed to instantiate {self}") from e + return instantiate(modname, mode, **args) class ConfigExpression(ConfigItem): diff --git a/monai/utils/module.py b/monai/utils/module.py index 5e058c105b..6f301d8067 100644 --- a/monai/utils/module.py +++ b/monai/utils/module.py @@ -272,7 +272,7 @@ def instantiate(__path: str, __mode: str, **kwargs: Any) -> Any: return pdb.runcall(component, **kwargs) except Exception as e: raise RuntimeError( - f"Failed to instantiate component '{__path}' with kwargs: {kwargs}" + f"Failed to instantiate component '{__path}' with keywords: {','.join(kwargs.keys())}" f"\n set '_mode_={CompInitMode.DEBUG}' to enter the debugging mode." ) from e From e5bebfc7cd2e48499159743365230115d1a7f460 Mon Sep 17 00:00:00 2001 From: Juampa <1523654+juampatronics@users.noreply.github.com> Date: Tue, 26 Mar 2024 03:57:36 +0100 Subject: [PATCH 15/35] 2872 implementation of mixup, cutmix and cutout (#7198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2872 ### Description Implementation of mixup, cutmix and cutout as described in the original papers. Current implementation support both, the dictionary-based batches and tuples of tensors. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [x] In-line docstrings updated. - [x] Documentation updated, tested `make html` command in the `docs/` folder. --------- Signed-off-by: Juan Pablo de la Cruz Gutiérrez Signed-off-by: monai-bot Signed-off-by: elitap Signed-off-by: Felix Schnabel Signed-off-by: YanxuanLiu Signed-off-by: ytl0623 Signed-off-by: Dženan Zukić Signed-off-by: KumoLiu Signed-off-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> Signed-off-by: Ishan Dutta Signed-off-by: dependabot[bot] Signed-off-by: kaibo Signed-off-by: heyufan1995 Signed-off-by: binliu Signed-off-by: axel.vlaminck Signed-off-by: Ibrahim Hadzic Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> Signed-off-by: Timothy Baker Signed-off-by: Mathijs de Boer Signed-off-by: Fabian Klopfer Signed-off-by: Lucas Robinet Signed-off-by: Lucas Robinet <67736918+Lucas-rbnt@users.noreply.github.com> Signed-off-by: chaoliu Signed-off-by: cxlcl Signed-off-by: chaoliu Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: monai-bot <64792179+monai-bot@users.noreply.github.com> Co-authored-by: elitap Co-authored-by: Felix Schnabel Co-authored-by: YanxuanLiu <104543031+YanxuanLiu@users.noreply.github.com> Co-authored-by: ytl0623 Co-authored-by: Dženan Zukić Co-authored-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Co-authored-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> Co-authored-by: Ishan Dutta Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Kaibo Tang Co-authored-by: Yufan He <59374597+heyufan1995@users.noreply.github.com> Co-authored-by: binliunls <107988372+binliunls@users.noreply.github.com> Co-authored-by: Ben Murray Co-authored-by: axel.vlaminck Co-authored-by: Mingxin Zheng <18563433+mingxin-zheng@users.noreply.github.com> Co-authored-by: Ibrahim Hadzic Co-authored-by: Dr. Behrooz Hashemian <3968947+drbeh@users.noreply.github.com> Co-authored-by: Timothy J. Baker <62781117+tim-the-baker@users.noreply.github.com> Co-authored-by: Mathijs de Boer <8137653+MathijsdeBoer@users.noreply.github.com> Co-authored-by: Mathijs de Boer Co-authored-by: Fabian Klopfer Co-authored-by: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Co-authored-by: Lucas Robinet <67736918+Lucas-rbnt@users.noreply.github.com> Co-authored-by: Lucas Robinet Co-authored-by: cxlcl --- docs/source/transforms.rst | 42 +++++ docs/source/transforms_idx.rst | 10 + monai/transforms/__init__.py | 12 ++ monai/transforms/regularization/__init__.py | 10 + monai/transforms/regularization/array.py | 173 ++++++++++++++++++ monai/transforms/regularization/dictionary.py | 97 ++++++++++ tests/test_regularization.py | 90 +++++++++ 7 files changed, 434 insertions(+) create mode 100644 monai/transforms/regularization/__init__.py create mode 100644 monai/transforms/regularization/array.py create mode 100644 monai/transforms/regularization/dictionary.py create mode 100644 tests/test_regularization.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index 8990e7991d..bd3feb3497 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -661,6 +661,27 @@ Post-processing :members: :special-members: __call__ +Regularization +^^^^^^^^^^^^^^ + +`CutMix` +"""""""" +.. autoclass:: CutMix + :members: + :special-members: __call__ + +`CutOut` +"""""""" +.. autoclass:: CutOut + :members: + :special-members: __call__ + +`MixUp` +""""""" +.. autoclass:: MixUp + :members: + :special-members: __call__ + Signal ^^^^^^^ @@ -1707,6 +1728,27 @@ Post-processing (Dict) :members: :special-members: __call__ +Regularization (Dict) +^^^^^^^^^^^^^^^^^^^^^ + +`CutMixd` +""""""""" +.. autoclass:: CutMixd + :members: + :special-members: __call__ + +`CutOutd` +""""""""" +.. autoclass:: CutOutd + :members: + :special-members: __call__ + +`MixUpd` +"""""""" +.. autoclass:: MixUpd + :members: + :special-members: __call__ + Signal (Dict) ^^^^^^^^^^^^^ diff --git a/docs/source/transforms_idx.rst b/docs/source/transforms_idx.rst index f4d02a483f..650d45db71 100644 --- a/docs/source/transforms_idx.rst +++ b/docs/source/transforms_idx.rst @@ -74,6 +74,16 @@ Post-processing post.array post.dictionary +Regularization +^^^^^^^^^^^^^^ + +.. autosummary:: + :toctree: _gen + :nosignatures: + + regularization.array + regularization.dictionary + Signal ^^^^^^ diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 2aa8fbf8a1..349533fb3e 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -336,6 +336,18 @@ VoteEnsembled, VoteEnsembleDict, ) +from .regularization.array import CutMix, CutOut, MixUp +from .regularization.dictionary import ( + CutMixd, + CutMixD, + CutMixDict, + CutOutd, + CutOutD, + CutOutDict, + MixUpd, + MixUpD, + MixUpDict, +) from .signal.array import ( SignalContinuousWavelet, SignalFillEmpty, diff --git a/monai/transforms/regularization/__init__.py b/monai/transforms/regularization/__init__.py new file mode 100644 index 0000000000..1e97f89407 --- /dev/null +++ b/monai/transforms/regularization/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/monai/transforms/regularization/array.py b/monai/transforms/regularization/array.py new file mode 100644 index 0000000000..6c9022d647 --- /dev/null +++ b/monai/transforms/regularization/array.py @@ -0,0 +1,173 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from abc import abstractmethod +from math import ceil, sqrt + +import torch + +from ..transform import RandomizableTransform + +__all__ = ["MixUp", "CutMix", "CutOut", "Mixer"] + + +class Mixer(RandomizableTransform): + def __init__(self, batch_size: int, alpha: float = 1.0) -> None: + """ + Mixer is a base class providing the basic logic for the mixup-class of + augmentations. In all cases, we need to sample the mixing weights for each + sample (lambda in the notation used in the papers). Also, pairs of samples + being mixed are picked by randomly shuffling the batch samples. + + Args: + batch_size (int): number of samples per batch. That is, samples are expected tp + be of size batchsize x channels [x depth] x height x width. + alpha (float, optional): mixing weights are sampled from the Beta(alpha, alpha) + distribution. Defaults to 1.0, the uniform distribution. + """ + super().__init__() + if alpha <= 0: + raise ValueError(f"Expected positive number, but got {alpha = }") + self.alpha = alpha + self.batch_size = batch_size + + @abstractmethod + def apply(self, data: torch.Tensor): + raise NotImplementedError() + + def randomize(self, data=None) -> None: + """ + Sometimes you need may to apply the same transform to different tensors. + The idea is to get a sample and then apply it with apply() as often + as needed. You need to call this method everytime you apply the transform to a new + batch. + """ + self._params = ( + torch.from_numpy(self.R.beta(self.alpha, self.alpha, self.batch_size)).type(torch.float32), + self.R.permutation(self.batch_size), + ) + + +class MixUp(Mixer): + """MixUp as described in: + Hongyi Zhang, Moustapha Cisse, Yann N. Dauphin, David Lopez-Paz. + mixup: Beyond Empirical Risk Minimization, ICLR 2018 + + Class derived from :py:class:`monai.transforms.Mixer`. See corresponding + documentation for details on the constructor parameters. + """ + + def apply(self, data: torch.Tensor): + weight, perm = self._params + nsamples, *dims = data.shape + if len(weight) != nsamples: + raise ValueError(f"Expected batch of size: {len(weight)}, but got {nsamples}") + + if len(dims) not in [3, 4]: + raise ValueError("Unexpected number of dimensions") + + mixweight = weight[(Ellipsis,) + (None,) * len(dims)] + return mixweight * data + (1 - mixweight) * data[perm, ...] + + def __call__(self, data: torch.Tensor, labels: torch.Tensor | None = None): + self.randomize() + if labels is None: + return self.apply(data) + return self.apply(data), self.apply(labels) + + +class CutMix(Mixer): + """CutMix augmentation as described in: + Sangdoo Yun, Dongyoon Han, Seong Joon Oh, Sanghyuk Chun, Junsuk Choe, Youngjoon Yoo. + CutMix: Regularization Strategy to Train Strong Classifiers with Localizable Features, + ICCV 2019 + + Class derived from :py:class:`monai.transforms.Mixer`. See corresponding + documentation for details on the constructor parameters. Here, alpha not only determines + the mixing weight but also the size of the random rectangles used during for mixing. + Please refer to the paper for details. + + The most common use case is something close to: + + .. code-block:: python + + cm = CutMix(batch_size=8, alpha=0.5) + for batch in loader: + images, labels = batch + augimg, auglabels = cm(images, labels) + output = model(augimg) + loss = loss_function(output, auglabels) + ... + + """ + + def apply(self, data: torch.Tensor): + weights, perm = self._params + nsamples, _, *dims = data.shape + if len(weights) != nsamples: + raise ValueError(f"Expected batch of size: {len(weights)}, but got {nsamples}") + + mask = torch.ones_like(data) + for s, weight in enumerate(weights): + coords = [torch.randint(0, d, size=(1,)) for d in dims] + lengths = [d * sqrt(1 - weight) for d in dims] + idx = [slice(None)] + [slice(c, min(ceil(c + ln), d)) for c, ln, d in zip(coords, lengths, dims)] + mask[s][idx] = 0 + + return mask * data + (1 - mask) * data[perm, ...] + + def apply_on_labels(self, labels: torch.Tensor): + weights, perm = self._params + nsamples, *dims = labels.shape + if len(weights) != nsamples: + raise ValueError(f"Expected batch of size: {len(weights)}, but got {nsamples}") + + mixweight = weights[(Ellipsis,) + (None,) * len(dims)] + return mixweight * labels + (1 - mixweight) * labels[perm, ...] + + def __call__(self, data: torch.Tensor, labels: torch.Tensor | None = None): + self.randomize() + augmented = self.apply(data) + return (augmented, self.apply_on_labels(labels)) if labels is not None else augmented + + +class CutOut(Mixer): + """Cutout as described in the paper: + Terrance DeVries, Graham W. Taylor. + Improved Regularization of Convolutional Neural Networks with Cutout, + arXiv:1708.04552 + + Class derived from :py:class:`monai.transforms.Mixer`. See corresponding + documentation for details on the constructor parameters. Here, alpha not only determines + the mixing weight but also the size of the random rectangles being cut put. + Please refer to the paper for details. + """ + + def apply(self, data: torch.Tensor): + weights, _ = self._params + nsamples, _, *dims = data.shape + if len(weights) != nsamples: + raise ValueError(f"Expected batch of size: {len(weights)}, but got {nsamples}") + + mask = torch.ones_like(data) + for s, weight in enumerate(weights): + coords = [torch.randint(0, d, size=(1,)) for d in dims] + lengths = [d * sqrt(1 - weight) for d in dims] + idx = [slice(None)] + [slice(c, min(ceil(c + ln), d)) for c, ln, d in zip(coords, lengths, dims)] + mask[s][idx] = 0 + + return mask * data + + def __call__(self, data: torch.Tensor): + self.randomize() + return self.apply(data) diff --git a/monai/transforms/regularization/dictionary.py b/monai/transforms/regularization/dictionary.py new file mode 100644 index 0000000000..373913da99 --- /dev/null +++ b/monai/transforms/regularization/dictionary.py @@ -0,0 +1,97 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from monai.config import KeysCollection +from monai.utils.misc import ensure_tuple + +from ..transform import MapTransform +from .array import CutMix, CutOut, MixUp + +__all__ = ["MixUpd", "MixUpD", "MixUpDict", "CutMixd", "CutMixD", "CutMixDict", "CutOutd", "CutOutD", "CutOutDict"] + + +class MixUpd(MapTransform): + """ + Dictionary-based version :py:class:`monai.transforms.MixUp`. + + Notice that the mixup transformation will be the same for all entries + for consistency, i.e. images and labels must be applied the same augmenation. + """ + + def __init__( + self, keys: KeysCollection, batch_size: int, alpha: float = 1.0, allow_missing_keys: bool = False + ) -> None: + super().__init__(keys, allow_missing_keys) + self.mixup = MixUp(batch_size, alpha) + + def __call__(self, data): + self.mixup.randomize() + result = dict(data) + for k in self.keys: + result[k] = self.mixup.apply(data[k]) + return result + + +class CutMixd(MapTransform): + """ + Dictionary-based version :py:class:`monai.transforms.CutMix`. + + Notice that the mixture weights will be the same for all entries + for consistency, i.e. images and labels must be aggregated with the same weights, + but the random crops are not. + """ + + def __init__( + self, + keys: KeysCollection, + batch_size: int, + label_keys: KeysCollection | None = None, + alpha: float = 1.0, + allow_missing_keys: bool = False, + ) -> None: + super().__init__(keys, allow_missing_keys) + self.mixer = CutMix(batch_size, alpha) + self.label_keys = ensure_tuple(label_keys) if label_keys is not None else [] + + def __call__(self, data): + self.mixer.randomize() + result = dict(data) + for k in self.keys: + result[k] = self.mixer.apply(data[k]) + for k in self.label_keys: + result[k] = self.mixer.apply_on_labels(data[k]) + return result + + +class CutOutd(MapTransform): + """ + Dictionary-based version :py:class:`monai.transforms.CutOut`. + + Notice that the cutout is different for every entry in the dictionary. + """ + + def __init__(self, keys: KeysCollection, batch_size: int, allow_missing_keys: bool = False) -> None: + super().__init__(keys, allow_missing_keys) + self.cutout = CutOut(batch_size) + + def __call__(self, data): + result = dict(data) + self.cutout.randomize() + for k in self.keys: + result[k] = self.cutout(data[k]) + return result + + +MixUpD = MixUpDict = MixUpd +CutMixD = CutMixDict = CutMixd +CutOutD = CutOutDict = CutOutd diff --git a/tests/test_regularization.py b/tests/test_regularization.py new file mode 100644 index 0000000000..d381ea72ca --- /dev/null +++ b/tests/test_regularization.py @@ -0,0 +1,90 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import torch + +from monai.transforms import CutMix, CutMixd, CutOut, MixUp, MixUpd + + +class TestMixup(unittest.TestCase): + def test_mixup(self): + for dims in [2, 3]: + shape = (6, 3) + (32,) * dims + sample = torch.rand(*shape, dtype=torch.float32) + mixup = MixUp(6, 1.0) + output = mixup(sample) + self.assertEqual(output.shape, sample.shape) + self.assertTrue(any(not torch.allclose(sample, mixup(sample)) for _ in range(10))) + + with self.assertRaises(ValueError): + MixUp(6, -0.5) + + mixup = MixUp(6, 0.5) + for dims in [2, 3]: + with self.assertRaises(ValueError): + shape = (5, 3) + (32,) * dims + sample = torch.rand(*shape, dtype=torch.float32) + mixup(sample) + + def test_mixupd(self): + for dims in [2, 3]: + shape = (6, 3) + (32,) * dims + t = torch.rand(*shape, dtype=torch.float32) + sample = {"a": t, "b": t} + mixup = MixUpd(["a", "b"], 6) + output = mixup(sample) + self.assertTrue(torch.allclose(output["a"], output["b"])) + + with self.assertRaises(ValueError): + MixUpd(["k1", "k2"], 6, -0.5) + + +class TestCutMix(unittest.TestCase): + def test_cutmix(self): + for dims in [2, 3]: + shape = (6, 3) + (32,) * dims + sample = torch.rand(*shape, dtype=torch.float32) + cutmix = CutMix(6, 1.0) + output = cutmix(sample) + self.assertEqual(output.shape, sample.shape) + self.assertTrue(any(not torch.allclose(sample, cutmix(sample)) for _ in range(10))) + + def test_cutmixd(self): + for dims in [2, 3]: + shape = (6, 3) + (32,) * dims + t = torch.rand(*shape, dtype=torch.float32) + label = torch.randint(0, 1, shape) + sample = {"a": t, "b": t, "lbl1": label, "lbl2": label} + cutmix = CutMixd(["a", "b"], 6, label_keys=("lbl1", "lbl2")) + output = cutmix(sample) + # croppings are different on each application + self.assertTrue(not torch.allclose(output["a"], output["b"])) + # but mixing of labels is not affected by it + self.assertTrue(torch.allclose(output["lbl1"], output["lbl2"])) + + +class TestCutOut(unittest.TestCase): + def test_cutout(self): + for dims in [2, 3]: + shape = (6, 3) + (32,) * dims + sample = torch.rand(*shape, dtype=torch.float32) + cutout = CutOut(6, 1.0) + output = cutout(sample) + self.assertEqual(output.shape, sample.shape) + self.assertTrue(any(not torch.allclose(sample, cutout(sample)) for _ in range(10))) + + +if __name__ == "__main__": + unittest.main() From 2716b6aec1d3e26e030fae3a57869ece06992148 Mon Sep 17 00:00:00 2001 From: YunLiu <55491388+KumoLiu@users.noreply.github.com> Date: Wed, 27 Mar 2024 12:16:19 +0800 Subject: [PATCH 16/35] Remove device count cache when import monai (#7581) workaround for #7575 ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. --------- Signed-off-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> Co-authored-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> --- monai/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/monai/__init__.py b/monai/__init__.py index 638220f6df..eb05ac993d 100644 --- a/monai/__init__.py +++ b/monai/__init__.py @@ -83,6 +83,11 @@ from .utils.tf32 import detect_default_tf32 detect_default_tf32() + import torch + + # workaround related to https://github.com/Project-MONAI/MONAI/issues/7575 + if hasattr(torch.cuda.device_count, "cache_clear"): + torch.cuda.device_count.cache_clear() except BaseException: from .utils.misc import MONAIEnvVars From c9fed96f8f09302de98bf1b5ff73a81fe85158a7 Mon Sep 17 00:00:00 2001 From: Lucas Robinet Date: Wed, 27 Mar 2024 06:10:45 +0100 Subject: [PATCH 17/35] Fixing gradient in sincos positional encoding in monai/networks/blocks/patchembedding.py (#7564) ### Description When you choose to put the argument `pos_embed_type='sincos'` in the `PatchEmbeddingBlock` class, it still return a learnable positional encoding To reproduce: ```python from monai.networks.blocks import PatchEmbeddingBlock patcher = PatchEmbeddingBlock( in_channels=1, img_size=(32, 32, 32), patch_size=(8, 8, 8), hidden_size=96, num_heads=8, pos_embed_type="sincos", dropout_rate=0.5, ) print(patcher.position_embeddings.requires_grad) >>> True ``` In the literature, we sometimes use either positional encoding in sincos which are fixed and non-trainable as in the original Attention Is All You Need [paper](https://arxiv.org/abs/1706.03762) or a learnable positional embedding as in the ViT [paper](https://arxiv.org/abs/2010.11929). If you choose to use a sincos, then it seems that is must be fixed which is not the case here. I'm not completely sure of the desired result in MONAI since there's already a learnable possibility, so if we choose sincos we'd like gradient-free parameters. However the documentation of `build_sincos_position_embedding`in the `pos_embed_utils.py`files stipulate: "The sin-cos position embedding as a learnable parameter" which seems a bit confusing. Especially as the encoding construction function seems to aim to set the require gradient to False (see below) ```python pos_embed = nn.Parameter(pos_emb) pos_embed.requires_grad = False return pos_embed ``` But these changes are not maintained by torch's `copy_` function, which does not copy gradient parameters (see the cpp code https://github.com/pytorch/pytorch/blob/148a8de6397be6e4b4ca1508b03b82d117bfb03c/torch/csrc/lazy/ts_backend/tensor_aten_ops.cpp#L51). This `copy_`is used in the `PatchEmbeddingBlock` class to instantiate the positional embedding. I propose a small fix to overcome this problem as well as test cases to ensure that positional embedding behaves correctly. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [x] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [x] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Lucas Robinet Co-authored-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> --- monai/networks/blocks/patchembedding.py | 1 + monai/networks/blocks/pos_embed_utils.py | 2 +- tests/test_patchembedding.py | 26 ++++++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/monai/networks/blocks/patchembedding.py b/monai/networks/blocks/patchembedding.py index 7d56045814..44774ce5da 100644 --- a/monai/networks/blocks/patchembedding.py +++ b/monai/networks/blocks/patchembedding.py @@ -123,6 +123,7 @@ def __init__( with torch.no_grad(): pos_embeddings = build_sincos_position_embedding(grid_size, hidden_size, spatial_dims) self.position_embeddings.data.copy_(pos_embeddings.float()) + self.position_embeddings.requires_grad = False else: raise ValueError(f"pos_embed_type {self.pos_embed_type} not supported.") diff --git a/monai/networks/blocks/pos_embed_utils.py b/monai/networks/blocks/pos_embed_utils.py index e03553307e..21586e56da 100644 --- a/monai/networks/blocks/pos_embed_utils.py +++ b/monai/networks/blocks/pos_embed_utils.py @@ -46,7 +46,7 @@ def build_sincos_position_embedding( temperature (float): The temperature for the sin-cos position embedding. Returns: - pos_embed (nn.Parameter): The sin-cos position embedding as a learnable parameter. + pos_embed (nn.Parameter): The sin-cos position embedding as a fixed parameter. """ if spatial_dims == 2: diff --git a/tests/test_patchembedding.py b/tests/test_patchembedding.py index f8610d9214..d059145033 100644 --- a/tests/test_patchembedding.py +++ b/tests/test_patchembedding.py @@ -93,6 +93,32 @@ def test_shape(self, input_param, input_shape, expected_shape): result = net(torch.randn(input_shape)) self.assertEqual(result.shape, expected_shape) + def test_sincos_pos_embed(self): + net = PatchEmbeddingBlock( + in_channels=1, + img_size=(32, 32, 32), + patch_size=(8, 8, 8), + hidden_size=96, + num_heads=8, + pos_embed_type="sincos", + dropout_rate=0.5, + ) + + self.assertEqual(net.position_embeddings.requires_grad, False) + + def test_learnable_pos_embed(self): + net = PatchEmbeddingBlock( + in_channels=1, + img_size=(32, 32, 32), + patch_size=(8, 8, 8), + hidden_size=96, + num_heads=8, + pos_embed_type="learnable", + dropout_rate=0.5, + ) + + self.assertEqual(net.position_embeddings.requires_grad, True) + def test_ill_arg(self): with self.assertRaises(ValueError): PatchEmbeddingBlock( From ba3c72cc784fcad9c3745b3a46af5bb8d1f4024b Mon Sep 17 00:00:00 2001 From: johnzielke Date: Wed, 27 Mar 2024 02:50:20 -0400 Subject: [PATCH 18/35] Fix inconsistent alpha parameter/docs for RandGibbsNoise/RandGibbsNoised (#7584) Fixes inconsistent alpha parameter/docs for RandGibbsNoise/RandGibbsNoised ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [x] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. --------- Signed-off-by: John Zielke Co-authored-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> --- monai/transforms/intensity/array.py | 8 ++++++-- monai/transforms/intensity/dictionary.py | 5 +++-- tests/test_rand_gibbs_noise.py | 9 +++++++++ tests/test_rand_gibbs_noised.py | 8 ++++++++ 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index a2f63a7482..0085050ee3 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -1840,15 +1840,19 @@ class RandGibbsNoise(RandomizableTransform): Args: prob (float): probability of applying the transform. - alpha (Sequence(float)): Parametrizes the intensity of the Gibbs noise filter applied. Takes + alpha (float, Sequence(float)): Parametrizes the intensity of the Gibbs noise filter applied. Takes values in the interval [0,1] with alpha = 0 acting as the identity mapping. If a length-2 list is given as [a,b] then the value of alpha will be sampled uniformly from the interval [a,b]. 0 <= a <= b <= 1. + If a float is given, then the value of alpha will be sampled uniformly from the interval [0, alpha]. """ backend = GibbsNoise.backend - def __init__(self, prob: float = 0.1, alpha: Sequence[float] = (0.0, 1.0)) -> None: + def __init__(self, prob: float = 0.1, alpha: float | Sequence[float] = (0.0, 1.0)) -> None: + if isinstance(alpha, float): + alpha = (0, alpha) + alpha = ensure_tuple(alpha) if len(alpha) != 2: raise ValueError("alpha length must be 2.") if alpha[1] > 1 or alpha[0] < 0: diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 7e93464e64..5b911904b0 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -1423,10 +1423,11 @@ class RandGibbsNoised(RandomizableTransform, MapTransform): keys: 'image', 'label', or ['image', 'label'] depending on which data you need to transform. prob (float): probability of applying the transform. - alpha (float, List[float]): Parametrizes the intensity of the Gibbs noise filter applied. Takes + alpha (float, Sequence[float]): Parametrizes the intensity of the Gibbs noise filter applied. Takes values in the interval [0,1] with alpha = 0 acting as the identity mapping. If a length-2 list is given as [a,b] then the value of alpha will be sampled uniformly from the interval [a,b]. + If a float is given, then the value of alpha will be sampled uniformly from the interval [0, alpha]. allow_missing_keys: do not raise exception if key is missing. """ @@ -1436,7 +1437,7 @@ def __init__( self, keys: KeysCollection, prob: float = 0.1, - alpha: Sequence[float] = (0.0, 1.0), + alpha: float | Sequence[float] = (0.0, 1.0), allow_missing_keys: bool = False, ) -> None: MapTransform.__init__(self, keys, allow_missing_keys) diff --git a/tests/test_rand_gibbs_noise.py b/tests/test_rand_gibbs_noise.py index 4befeffbe2..5ef249a1f4 100644 --- a/tests/test_rand_gibbs_noise.py +++ b/tests/test_rand_gibbs_noise.py @@ -90,6 +90,15 @@ def test_alpha(self, im_shape, input_type): self.assertGreaterEqual(t.sampled_alpha, 0.5) self.assertLessEqual(t.sampled_alpha, 0.51) + @parameterized.expand(TEST_CASES) + def test_alpha_single_value(self, im_shape, input_type): + im = self.get_data(im_shape, input_type) + alpha = 0.01 + t = RandGibbsNoise(1.0, alpha) + _ = t(deepcopy(im)) + self.assertGreaterEqual(t.sampled_alpha, 0) + self.assertLessEqual(t.sampled_alpha, 0.01) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_rand_gibbs_noised.py b/tests/test_rand_gibbs_noised.py index 6580189af6..382290dd39 100644 --- a/tests/test_rand_gibbs_noised.py +++ b/tests/test_rand_gibbs_noised.py @@ -105,6 +105,14 @@ def test_alpha(self, im_shape, input_type): _ = t(deepcopy(data)) self.assertTrue(0.5 <= t.rand_gibbs_noise.sampled_alpha <= 0.51) + @parameterized.expand(TEST_CASES) + def test_alpha_single_value(self, im_shape, input_type): + data = self.get_data(im_shape, input_type) + alpha = 0.01 + t = RandGibbsNoised(KEYS, 1.0, alpha) + _ = t(deepcopy(data)) + self.assertTrue(0 <= t.rand_gibbs_noise.sampled_alpha <= 0.01) + if __name__ == "__main__": unittest.main() From 7c0b10e8fd6529188779663b8a2ccbcfafbc8f70 Mon Sep 17 00:00:00 2001 From: Mingxin Zheng <18563433+mingxin-zheng@users.noreply.github.com> Date: Wed, 27 Mar 2024 17:32:24 +0800 Subject: [PATCH 19/35] Fix bundle_root for NNIGen (#7586) Fixes #7585 . ### Description Because the NNI test takes too much time, the previous behavior did not get caught with the dry-run mode of HPO Gen ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). --------- Signed-off-by: Mingxin Zheng Co-authored-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> --- monai/apps/auto3dseg/hpo_gen.py | 4 +++- tests/test_auto3dseg_hpo.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/monai/apps/auto3dseg/hpo_gen.py b/monai/apps/auto3dseg/hpo_gen.py index 688bf2b916..b755b99feb 100644 --- a/monai/apps/auto3dseg/hpo_gen.py +++ b/monai/apps/auto3dseg/hpo_gen.py @@ -193,7 +193,9 @@ def generate(self, output_folder: str = ".") -> None: self.obj_filename = os.path.join(write_path, "algo_object.pkl") if isinstance(self.algo, BundleAlgo): - self.algo.export_to_disk(output_folder, task_prefix + task_id, fill_with_datastats=False) + self.algo.export_to_disk( + output_folder, task_prefix + task_id, bundle_root=write_path, fill_with_datastats=False + ) else: ConfigParser.export_config_file(self.params, write_path) logger.info(write_path) diff --git a/tests/test_auto3dseg_hpo.py b/tests/test_auto3dseg_hpo.py index 34d00336ec..53d09defa0 100644 --- a/tests/test_auto3dseg_hpo.py +++ b/tests/test_auto3dseg_hpo.py @@ -181,7 +181,7 @@ def test_get_history(self) -> None: NNIGen().run_algo(obj_filename, self.work_dir) history = import_bundle_algo_history(self.work_dir, only_trained=True) - assert len(history) == 3 + assert len(history) == 1 def tearDown(self) -> None: self.test_dir.cleanup() From 2d463a7d19166cff6a83a313f339228bc812912d Mon Sep 17 00:00:00 2001 From: monai-bot <64792179+monai-bot@users.noreply.github.com> Date: Wed, 27 Mar 2024 22:11:13 +0000 Subject: [PATCH 20/35] Auto3DSeg algo_template hash update (#7589) Signed-off-by: monai-bot Signed-off-by: monai-bot --- monai/utils/misc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monai/utils/misc.py b/monai/utils/misc.py index caa7c067df..c30eb0904d 100644 --- a/monai/utils/misc.py +++ b/monai/utils/misc.py @@ -527,7 +527,7 @@ def doc_images() -> str | None: @staticmethod def algo_hash() -> str | None: - return os.environ.get("MONAI_ALGO_HASH", "249bf4b") + return os.environ.get("MONAI_ALGO_HASH", "c51bc6a") @staticmethod def trace_transform() -> str | None: From 15d2abf93eed3cacc16ccab58da092d011c59afa Mon Sep 17 00:00:00 2001 From: YunLiu <55491388+KumoLiu@users.noreply.github.com> Date: Mon, 1 Apr 2024 11:41:00 +0800 Subject: [PATCH 21/35] Utilizing subprocess for nnUNet training. (#7576) Workaround for #7575 ### Description - Due to the impact of #7575, the operation to set the device within nnUNetV2Runner will become ineffective. This PR is intended to resolve this issue. - Add a version check for #7575, will revisit after the update from pytorch team. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. --------- Signed-off-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> --- monai/apps/nnunet/nnunetv2_runner.py | 94 +++++++++++++--------------- tests/test_set_visible_devices.py | 3 +- 2 files changed, 45 insertions(+), 52 deletions(-) diff --git a/monai/apps/nnunet/nnunetv2_runner.py b/monai/apps/nnunet/nnunetv2_runner.py index 44b3c24256..8a10849904 100644 --- a/monai/apps/nnunet/nnunetv2_runner.py +++ b/monai/apps/nnunet/nnunetv2_runner.py @@ -22,6 +22,7 @@ from monai.apps.nnunet.utils import analyze_data, create_new_data_copy, create_new_dataset_json from monai.bundle import ConfigParser from monai.utils import ensure_tuple, optional_import +from monai.utils.misc import run_cmd load_pickle, _ = optional_import("batchgenerators.utilities.file_and_folder_operations", name="load_pickle") join, _ = optional_import("batchgenerators.utilities.file_and_folder_operations", name="join") @@ -495,65 +496,64 @@ def train_single_model(self, config: Any, fold: int, gpu_id: tuple | list | int fold: fold of the 5-fold cross-validation. Should be an int between 0 and 4. gpu_id: an integer to select the device to use, or a tuple/list of GPU device indices used for multi-GPU training (e.g., (0,1)). Default: 0. - from nnunetv2.run.run_training import run_training kwargs: this optional parameter allows you to specify additional arguments in - ``nnunetv2.run.run_training.run_training``. Currently supported args are - - plans_identifier: custom plans identifier. Default: "nnUNetPlans". - - pretrained_weights: path to nnU-Net checkpoint file to be used as pretrained model. Will only be - used when actually training. Beta. Use with caution. Default: False. - - use_compressed_data: True to use compressed data for training. Reading compressed data is much - more CPU and (potentially) RAM intensive and should only be used if you know what you are - doing. Default: False. - - continue_training: continue training from latest checkpoint. Default: False. - - only_run_validation: True to run the validation only. Requires training to have finished. - Default: False. - - disable_checkpointing: True to disable checkpointing. Ideal for testing things out and you - don't want to flood your hard drive with checkpoints. Default: False. + ``nnunetv2.run.run_training.run_training_entry``. + + Currently supported args are: + + - p: custom plans identifier. Default: "nnUNetPlans". + - pretrained_weights: path to nnU-Net checkpoint file to be used as pretrained model. Will only be + used when actually training. Beta. Use with caution. Default: False. + - use_compressed: True to use compressed data for training. Reading compressed data is much + more CPU and (potentially) RAM intensive and should only be used if you know what you are + doing. Default: False. + - c: continue training from latest checkpoint. Default: False. + - val: True to run the validation only. Requires training to have finished. + Default: False. + - disable_checkpointing: True to disable checkpointing. Ideal for testing things out and you + don't want to flood your hard drive with checkpoints. Default: False. """ if "num_gpus" in kwargs: kwargs.pop("num_gpus") logger.warning("please use gpu_id to set the GPUs to use") - if "trainer_class_name" in kwargs: - kwargs.pop("trainer_class_name") + if "tr" in kwargs: + kwargs.pop("tr") logger.warning("please specify the `trainer_class_name` in the __init__ of `nnUNetV2Runner`.") - if "export_validation_probabilities" in kwargs: - kwargs.pop("export_validation_probabilities") + if "npz" in kwargs: + kwargs.pop("npz") logger.warning("please specify the `export_validation_probabilities` in the __init__ of `nnUNetV2Runner`.") + cmd = self.train_single_model_command(config, fold, gpu_id, kwargs) + run_cmd(cmd, shell=True) + + def train_single_model_command(self, config, fold, gpu_id, kwargs): if isinstance(gpu_id, (tuple, list)): if len(gpu_id) > 1: gpu_ids_str = "" for _i in range(len(gpu_id)): gpu_ids_str += f"{gpu_id[_i]}," - os.environ["CUDA_VISIBLE_DEVICES"] = gpu_ids_str[:-1] + device_setting = f"CUDA_VISIBLE_DEVICES={gpu_ids_str[:-1]}" else: - os.environ["CUDA_VISIBLE_DEVICES"] = f"{gpu_id[0]}" - else: - os.environ["CUDA_VISIBLE_DEVICES"] = f"{gpu_id}" - - from nnunetv2.run.run_training import run_training - - if isinstance(gpu_id, int) or len(gpu_id) == 1: - run_training( - dataset_name_or_id=self.dataset_name_or_id, - configuration=config, - fold=fold, - trainer_class_name=self.trainer_class_name, - export_validation_probabilities=self.export_validation_probabilities, - **kwargs, - ) + device_setting = f"CUDA_VISIBLE_DEVICES={gpu_id[0]}" else: - run_training( - dataset_name_or_id=self.dataset_name_or_id, - configuration=config, - fold=fold, - num_gpus=len(gpu_id), - trainer_class_name=self.trainer_class_name, - export_validation_probabilities=self.export_validation_probabilities, - **kwargs, - ) + device_setting = f"CUDA_VISIBLE_DEVICES={gpu_id}" + num_gpus = 1 if isinstance(gpu_id, int) or len(gpu_id) == 1 else len(gpu_id) + + cmd = ( + f"{device_setting} nnUNetv2_train " + + f"{self.dataset_name_or_id} {config} {fold} " + + f"-tr {self.trainer_class_name} -num_gpus {num_gpus}" + ) + if self.export_validation_probabilities: + cmd += " --npz" + for _key, _value in kwargs.items(): + if _key == "p" or _key == "pretrained_weights": + cmd += f" -{_key} {_value}" + else: + cmd += f" --{_key} {_value}" + return cmd def train( self, @@ -637,15 +637,7 @@ def train_parallel_cmd( if _config in ensure_tuple(configs): for _i in range(self.num_folds): the_device = gpu_id_for_all[_index % n_devices] # type: ignore - cmd = ( - "python -m monai.apps.nnunet nnUNetV2Runner train_single_model " - + f"--input_config '{self.input_config_or_dict}' --work_dir '{self.work_dir}' " - + f"--config '{_config}' --fold {_i} --gpu_id {the_device} " - + f"--trainer_class_name {self.trainer_class_name} " - + f"--export_validation_probabilities {self.export_validation_probabilities}" - ) - for _key, _value in kwargs.items(): - cmd += f" --{_key} {_value}" + cmd = self.train_single_model_command(_config, _i, the_device, kwargs) all_cmds[-1][the_device].append(cmd) _index += 1 return all_cmds diff --git a/tests/test_set_visible_devices.py b/tests/test_set_visible_devices.py index 7860656b3d..b4f44957a2 100644 --- a/tests/test_set_visible_devices.py +++ b/tests/test_set_visible_devices.py @@ -14,7 +14,7 @@ import os import unittest -from tests.utils import skip_if_no_cuda +from tests.utils import SkipIfAtLeastPyTorchVersion, skip_if_no_cuda class TestVisibleDevices(unittest.TestCase): @@ -25,6 +25,7 @@ def run_process_and_get_exit_code(code_to_execute): return int(bin(value).replace("0b", "").rjust(16, "0")[:8], 2) @skip_if_no_cuda + @SkipIfAtLeastPyTorchVersion((2, 2, 1)) def test_visible_devices(self): num_gpus_before = self.run_process_and_get_exit_code( 'python -c "import os; import torch; ' From ec4d946571937ad162b415b1ea5977efc5adcfcd Mon Sep 17 00:00:00 2001 From: Vladimir Chernyi <57420464+scalyvladimir@users.noreply.github.com> Date: Mon, 1 Apr 2024 07:28:56 +0300 Subject: [PATCH 22/35] typo fix (#7595) Fixes # 1. ### Description Fixed typo. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [x] In-line docstrings updated. Signed-off-by: Vladimir Chernyi <57420464+scalyvladimir@users.noreply.github.com> Co-authored-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> --- monai/data/dataset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monai/data/dataset.py b/monai/data/dataset.py index 531893d768..79e066303e 100644 --- a/monai/data/dataset.py +++ b/monai/data/dataset.py @@ -427,7 +427,7 @@ def _transform(self, index: int): class CacheNTransDataset(PersistentDataset): """ - Extension of `PersistentDataset`, tt can also cache the result of first N transforms, no matter it's random or not. + Extension of `PersistentDataset`, it can also cache the result of first N transforms, no matter it's random or not. """ From a7c2589133ab01afd75a762885dce69a73abfe1a Mon Sep 17 00:00:00 2001 From: monai-bot <64792179+monai-bot@users.noreply.github.com> Date: Mon, 1 Apr 2024 08:13:31 +0100 Subject: [PATCH 23/35] auto updates (#7599) Signed-off-by: monai-bot Signed-off-by: monai-bot --- monai/transforms/regularization/array.py | 1 + tests/test_regularization.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/monai/transforms/regularization/array.py b/monai/transforms/regularization/array.py index 6c9022d647..0b495c8623 100644 --- a/monai/transforms/regularization/array.py +++ b/monai/transforms/regularization/array.py @@ -22,6 +22,7 @@ class Mixer(RandomizableTransform): + def __init__(self, batch_size: int, alpha: float = 1.0) -> None: """ Mixer is a base class providing the basic logic for the mixup-class of diff --git a/tests/test_regularization.py b/tests/test_regularization.py index d381ea72ca..22faf1027d 100644 --- a/tests/test_regularization.py +++ b/tests/test_regularization.py @@ -19,6 +19,7 @@ class TestMixup(unittest.TestCase): + def test_mixup(self): for dims in [2, 3]: shape = (6, 3) + (32,) * dims @@ -52,6 +53,7 @@ def test_mixupd(self): class TestCutMix(unittest.TestCase): + def test_cutmix(self): for dims in [2, 3]: shape = (6, 3) + (32,) * dims @@ -76,6 +78,7 @@ def test_cutmixd(self): class TestCutOut(unittest.TestCase): + def test_cutout(self): for dims in [2, 3]: shape = (6, 3) + (32,) * dims From c885100e951e5b7b1fedf5e9bc965201592e8541 Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Mon, 1 Apr 2024 15:56:38 +0800 Subject: [PATCH 24/35] 7540 change bundle workflow args (#7549) Fixes #7540 . ### Description This PR: 1. add logging file and meta file into BundleWorkflow 2. add the sequence form of meta files check for ConfigWorkflow ### Types of changes - [x] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. --------- Signed-off-by: Yiheng Wang Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> --- monai/bundle/workflows.py | 70 ++++++++++++++++++++++++++--------- tests/nonconfig_workflow.py | 4 +- tests/test_bundle_workflow.py | 8 ++++ 3 files changed, 63 insertions(+), 19 deletions(-) diff --git a/monai/bundle/workflows.py b/monai/bundle/workflows.py index da3aa30141..804b3b06f0 100644 --- a/monai/bundle/workflows.py +++ b/monai/bundle/workflows.py @@ -46,6 +46,9 @@ class BundleWorkflow(ABC): or "infer", "inference", "eval", "evaluation" for a inference workflow, other unsupported string will raise a ValueError. default to `None` for common workflow. + meta_file: filepath of the metadata file, if this is a list of file paths, their contents will be merged in order. + logging_file: config file for `logging` module in the program. for more details: + https://docs.python.org/3/library/logging.config.html#logging.config.fileConfig. """ @@ -59,11 +62,40 @@ class BundleWorkflow(ABC): new_name="workflow_type", msg_suffix="please use `workflow_type` instead.", ) - def __init__(self, workflow_type: str | None = None, workflow: str | None = None): + def __init__( + self, + workflow_type: str | None = None, + workflow: str | None = None, + meta_file: str | Sequence[str] | None = None, + logging_file: str | None = None, + ): + if logging_file is not None: + if not os.path.isfile(logging_file): + raise FileNotFoundError(f"Cannot find the logging config file: {logging_file}.") + logger.info(f"Setting logging properties based on config: {logging_file}.") + fileConfig(logging_file, disable_existing_loggers=False) + + if meta_file is not None: + if isinstance(meta_file, str) and not os.path.isfile(meta_file): + logger.error( + f"Cannot find the metadata config file: {meta_file}. " + "Please see: https://docs.monai.io/en/stable/mb_specification.html" + ) + meta_file = None + if isinstance(meta_file, list): + for f in meta_file: + if not os.path.isfile(f): + logger.error( + f"Cannot find the metadata config file: {f}. " + "Please see: https://docs.monai.io/en/stable/mb_specification.html" + ) + meta_file = None + workflow_type = workflow if workflow is not None else workflow_type if workflow_type is None: self.properties = copy(MetaProperties) self.workflow_type = None + self.meta_file = meta_file return if workflow_type.lower() in self.supported_train_type: self.properties = {**TrainProperties, **MetaProperties} @@ -74,6 +106,8 @@ def __init__(self, workflow_type: str | None = None, workflow: str | None = None else: raise ValueError(f"Unsupported workflow type: '{workflow_type}'.") + self.meta_file = meta_file + @abstractmethod def initialize(self, *args: Any, **kwargs: Any) -> Any: """ @@ -142,6 +176,13 @@ def get_workflow_type(self): """ return self.workflow_type + def get_meta_file(self): + """ + Get the meta file. + + """ + return self.meta_file + def add_property(self, name: str, required: str, desc: str | None = None) -> None: """ Besides the default predefined properties, some 3rd party applications may need the bundle @@ -233,25 +274,26 @@ def __init__( **override: Any, ) -> None: workflow_type = workflow if workflow is not None else workflow_type - super().__init__(workflow_type=workflow_type) if config_file is not None: _config_files = ensure_tuple(config_file) - self.config_root_path = Path(_config_files[0]).parent + config_root_path = Path(_config_files[0]).parent for _config_file in _config_files: _config_file = Path(_config_file) - if _config_file.parent != self.config_root_path: + if _config_file.parent != config_root_path: logger.warn( - f"Not all config files are in {self.config_root_path}. If logging_file and meta_file are" - f"not specified, {self.config_root_path} will be used as the default config root directory." + f"Not all config files are in {config_root_path}. If logging_file and meta_file are" + f"not specified, {config_root_path} will be used as the default config root directory." ) if not _config_file.is_file(): raise FileNotFoundError(f"Cannot find the config file: {_config_file}.") else: - self.config_root_path = Path("configs") - + config_root_path = Path("configs") + meta_file = str(config_root_path / "metadata.json") if meta_file is None else meta_file + super().__init__(workflow_type=workflow_type, meta_file=meta_file) + self.config_root_path = config_root_path logging_file = str(self.config_root_path / "logging.conf") if logging_file is None else logging_file if logging_file is not None: - if not os.path.exists(logging_file): + if not os.path.isfile(logging_file): if logging_file == str(self.config_root_path / "logging.conf"): logger.warn(f"Default logging file in {logging_file} does not exist, skipping logging.") else: @@ -262,14 +304,8 @@ def __init__( self.parser = ConfigParser() self.parser.read_config(f=config_file) - meta_file = str(self.config_root_path / "metadata.json") if meta_file is None else meta_file - if isinstance(meta_file, str) and not os.path.exists(meta_file): - logger.error( - f"Cannot find the metadata config file: {meta_file}. " - "Please see: https://docs.monai.io/en/stable/mb_specification.html" - ) - else: - self.parser.read_meta(f=meta_file) + if self.meta_file is not None: + self.parser.read_meta(f=self.meta_file) # the rest key-values in the _args are to override config content self.parser.update(pairs=override) diff --git a/tests/nonconfig_workflow.py b/tests/nonconfig_workflow.py index 7b5328bf72..b2c44c12c6 100644 --- a/tests/nonconfig_workflow.py +++ b/tests/nonconfig_workflow.py @@ -36,8 +36,8 @@ class NonConfigWorkflow(BundleWorkflow): """ - def __init__(self, filename, output_dir): - super().__init__(workflow_type="inference") + def __init__(self, filename, output_dir, meta_file=None, logging_file=None): + super().__init__(workflow_type="inference", meta_file=meta_file, logging_file=logging_file) self.filename = filename self.output_dir = output_dir self._bundle_root = "will override" diff --git a/tests/test_bundle_workflow.py b/tests/test_bundle_workflow.py index f7da37acef..0b0d51cbfb 100644 --- a/tests/test_bundle_workflow.py +++ b/tests/test_bundle_workflow.py @@ -35,6 +35,8 @@ TEST_CASE_3 = [os.path.join(os.path.dirname(__file__), "testing_data", "config_fl_train.json")] +TEST_CASE_NON_CONFIG_WRONG_LOG = [None, "logging.conf", "Cannot find the logging config file: logging.conf."] + class TestBundleWorkflow(unittest.TestCase): @@ -144,8 +146,14 @@ def test_train_config(self, config_file): def test_non_config(self): # test user defined python style workflow inferer = NonConfigWorkflow(self.filename, self.data_dir) + self.assertEqual(inferer.meta_file, None) self._test_inferer(inferer) + @parameterized.expand([TEST_CASE_NON_CONFIG_WRONG_LOG]) + def test_non_config_wrong_log_cases(self, meta_file, logging_file, expected_error): + with self.assertRaisesRegex(FileNotFoundError, expected_error): + NonConfigWorkflow(self.filename, self.data_dir, meta_file, logging_file) + if __name__ == "__main__": unittest.main() From 264b9e4700667e895c272f8da6a5a43d0c50d865 Mon Sep 17 00:00:00 2001 From: YunLiu <55491388+KumoLiu@users.noreply.github.com> Date: Mon, 1 Apr 2024 18:09:35 +0800 Subject: [PATCH 25/35] Add "properties_path" in BundleWorkflow (#7542) Fixes #7541 ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. --------- Signed-off-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- monai/bundle/workflows.py | 23 +++++-- tests/test_bundle_workflow.py | 10 +++ tests/test_regularization.py | 16 +++++ tests/testing_data/fl_infer_properties.json | 67 +++++++++++++++++++++ 4 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 tests/testing_data/fl_infer_properties.json diff --git a/monai/bundle/workflows.py b/monai/bundle/workflows.py index 804b3b06f0..d876f6d7ae 100644 --- a/monai/bundle/workflows.py +++ b/monai/bundle/workflows.py @@ -11,6 +11,7 @@ from __future__ import annotations +import json import os import sys import time @@ -24,6 +25,7 @@ from monai.bundle.config_parser import ConfigParser from monai.bundle.properties import InferProperties, MetaProperties, TrainProperties from monai.bundle.utils import DEFAULT_EXP_MGMT_SETTINGS, EXPR_KEY, ID_REF_KEY, ID_SEP_KEY +from monai.config import PathLike from monai.utils import BundleProperty, BundlePropertyConfig, deprecated_arg, deprecated_arg_default, ensure_tuple __all__ = ["BundleWorkflow", "ConfigWorkflow"] @@ -46,6 +48,7 @@ class BundleWorkflow(ABC): or "infer", "inference", "eval", "evaluation" for a inference workflow, other unsupported string will raise a ValueError. default to `None` for common workflow. + properties_path: the path to the JSON file of properties. meta_file: filepath of the metadata file, if this is a list of file paths, their contents will be merged in order. logging_file: config file for `logging` module in the program. for more details: https://docs.python.org/3/library/logging.config.html#logging.config.fileConfig. @@ -66,6 +69,7 @@ def __init__( self, workflow_type: str | None = None, workflow: str | None = None, + properties_path: PathLike | None = None, meta_file: str | Sequence[str] | None = None, logging_file: str | None = None, ): @@ -92,15 +96,24 @@ def __init__( meta_file = None workflow_type = workflow if workflow is not None else workflow_type - if workflow_type is None: + if workflow_type is None and properties_path is None: self.properties = copy(MetaProperties) self.workflow_type = None self.meta_file = meta_file return - if workflow_type.lower() in self.supported_train_type: + if properties_path is not None: + properties_path = Path(properties_path) + if not properties_path.is_file(): + raise ValueError(f"Property file {properties_path} does not exist.") + with open(properties_path) as json_file: + self.properties = json.load(json_file) + self.workflow_type = None + self.meta_file = meta_file + return + if workflow_type.lower() in self.supported_train_type: # type: ignore[union-attr] self.properties = {**TrainProperties, **MetaProperties} self.workflow_type = "train" - elif workflow_type.lower() in self.supported_infer_type: + elif workflow_type.lower() in self.supported_infer_type: # type: ignore[union-attr] self.properties = {**InferProperties, **MetaProperties} self.workflow_type = "infer" else: @@ -247,6 +260,7 @@ class ConfigWorkflow(BundleWorkflow): or "infer", "inference", "eval", "evaluation" for a inference workflow, other unsupported string will raise a ValueError. default to `None` for common workflow. + properties_path: the path to the JSON file of properties. override: id-value pairs to override or add the corresponding config content. e.g. ``--net#input_chns 42``, ``--net %/data/other.json#net_arg`` @@ -271,6 +285,7 @@ def __init__( tracking: str | dict | None = None, workflow_type: str | None = None, workflow: str | None = None, + properties_path: PathLike | None = None, **override: Any, ) -> None: workflow_type = workflow if workflow is not None else workflow_type @@ -289,7 +304,7 @@ def __init__( else: config_root_path = Path("configs") meta_file = str(config_root_path / "metadata.json") if meta_file is None else meta_file - super().__init__(workflow_type=workflow_type, meta_file=meta_file) + super().__init__(workflow_type=workflow_type, meta_file=meta_file, properties_path=properties_path) self.config_root_path = config_root_path logging_file = str(self.config_root_path / "logging.conf") if logging_file is None else logging_file if logging_file is not None: diff --git a/tests/test_bundle_workflow.py b/tests/test_bundle_workflow.py index 0b0d51cbfb..9a276b577f 100644 --- a/tests/test_bundle_workflow.py +++ b/tests/test_bundle_workflow.py @@ -105,6 +105,16 @@ def test_inference_config(self, config_file): ) self._test_inferer(inferer) + # test property path + inferer = ConfigWorkflow( + config_file=config_file, + properties_path=os.path.join(os.path.dirname(__file__), "testing_data", "fl_infer_properties.json"), + logging_file=os.path.join(os.path.dirname(__file__), "testing_data", "logging.conf"), + **override, + ) + self._test_inferer(inferer) + self.assertEqual(inferer.workflow_type, None) + @parameterized.expand([TEST_CASE_3]) def test_train_config(self, config_file): # test standard MONAI model-zoo config workflow diff --git a/tests/test_regularization.py b/tests/test_regularization.py index 22faf1027d..c6f727cb54 100644 --- a/tests/test_regularization.py +++ b/tests/test_regularization.py @@ -16,9 +16,15 @@ import torch from monai.transforms import CutMix, CutMixd, CutOut, MixUp, MixUpd +from monai.utils import set_determinism class TestMixup(unittest.TestCase): + def setUp(self) -> None: + set_determinism(seed=0) + + def tearDown(self) -> None: + set_determinism(None) def test_mixup(self): for dims in [2, 3]: @@ -53,6 +59,11 @@ def test_mixupd(self): class TestCutMix(unittest.TestCase): + def setUp(self) -> None: + set_determinism(seed=0) + + def tearDown(self) -> None: + set_determinism(None) def test_cutmix(self): for dims in [2, 3]: @@ -78,6 +89,11 @@ def test_cutmixd(self): class TestCutOut(unittest.TestCase): + def setUp(self) -> None: + set_determinism(seed=0) + + def tearDown(self) -> None: + set_determinism(None) def test_cutout(self): for dims in [2, 3]: diff --git a/tests/testing_data/fl_infer_properties.json b/tests/testing_data/fl_infer_properties.json new file mode 100644 index 0000000000..72e97cd2c6 --- /dev/null +++ b/tests/testing_data/fl_infer_properties.json @@ -0,0 +1,67 @@ +{ + "bundle_root": { + "description": "root path of the bundle.", + "required": true, + "id": "bundle_root" + }, + "device": { + "description": "target device to execute the bundle workflow.", + "required": true, + "id": "device" + }, + "dataset_dir": { + "description": "directory path of the dataset.", + "required": true, + "id": "dataset_dir" + }, + "dataset": { + "description": "PyTorch dataset object for the inference / evaluation logic.", + "required": true, + "id": "dataset" + }, + "evaluator": { + "description": "inference / evaluation workflow engine.", + "required": true, + "id": "evaluator" + }, + "network_def": { + "description": "network module for the inference.", + "required": true, + "id": "network_def" + }, + "inferer": { + "description": "MONAI Inferer object to execute the model computation in inference.", + "required": true, + "id": "inferer" + }, + "dataset_data": { + "description": "data source for the inference / evaluation dataset.", + "required": false, + "id": "dataset::data", + "refer_id": null + }, + "handlers": { + "description": "event-handlers for the inference / evaluation logic.", + "required": false, + "id": "handlers", + "refer_id": "evaluator::val_handlers" + }, + "preprocessing": { + "description": "preprocessing for the input data.", + "required": false, + "id": "preprocessing", + "refer_id": "dataset::transform" + }, + "postprocessing": { + "description": "postprocessing for the model output data.", + "required": false, + "id": "postprocessing", + "refer_id": "evaluator::postprocessing" + }, + "key_metric": { + "description": "the key metric during evaluation.", + "required": false, + "id": "key_metric", + "refer_id": "evaluator::key_val_metric" + } +} From bbaaf4c7d75976b0289152541afa9fa03fa44f7a Mon Sep 17 00:00:00 2001 From: monai-bot <64792179+monai-bot@users.noreply.github.com> Date: Mon, 1 Apr 2024 15:39:10 +0100 Subject: [PATCH 26/35] Auto3DSeg algo_template hash update (#7603) Signed-off-by: monai-bot Signed-off-by: monai-bot --- monai/utils/misc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monai/utils/misc.py b/monai/utils/misc.py index c30eb0904d..dd0ccada3d 100644 --- a/monai/utils/misc.py +++ b/monai/utils/misc.py @@ -527,7 +527,7 @@ def doc_images() -> str | None: @staticmethod def algo_hash() -> str | None: - return os.environ.get("MONAI_ALGO_HASH", "c51bc6a") + return os.environ.get("MONAI_ALGO_HASH", "b910ab8") @staticmethod def trace_transform() -> str | None: From 5ec7305abfa406fec7f013953ec4da7cfcae15ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C5=91rincz-Moln=C3=A1r=20Szabolcs-Botond?= Date: Tue, 2 Apr 2024 04:13:28 +0200 Subject: [PATCH 27/35] ENH: generate_label_classes_crop_centers: warn only if ratio of missing class is not set to 0 (#7602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #7594 ### Description Only warn users that the ratio of a missing class is set to 0, when it wasn't already set to 0 by the user, in `generate_label_classes_crop_centers`, function being used by `RandCropByLabelClasses` transform. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [x] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. ⚠️ See notes - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. ⚠️ See notes - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. ### Notes regarding tests Some tests were failing, see details:
====================================================================== ERROR: test_cuda_0_2_batches_1_dimensions_1_channels_2_classes_2_mixtures (tests.test_gmm.GMMTestCase) ---------------------------------------------------------------------- Traceback (most recent call last): File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 2096, in _run_ninja_build subprocess.run( File "/usr/lib/python3.10/subprocess.py", line 526, in run raise CalledProcessError(retcode, process.args, subprocess.CalledProcessError: Command '['ninja', '-v']' returned non-zero exit status 1. The above exception was the direct cause of the following exception: Traceback (most recent call last): File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/parameterized/parameterized.py", line 620, in standalone_func return func(*(a + p.args), **p.kwargs, **kw) File "/home/szabolcslorincz/MONAI/tests/test_gmm.py", line 288, in test_cuda gmm = GaussianMixtureModel(features_tensor.size(1), mixture_count, class_count, verbose_build=True) File "/home/szabolcslorincz/MONAI/monai/networks/layers/gmm.py", line 44, in __init__ self.compiled_extension = load_module( File "/home/szabolcslorincz/MONAI/monai/_extensions/loader.py", line 89, in load_module module = load( File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 1306, in load return _jit_compile( File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 1710, in _jit_compile _write_ninja_file_and_build_library( File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 1823, in _write_ninja_file_and_build_library _run_ninja_build( File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 2112, in _run_ninja_build raise RuntimeError(message) from e RuntimeError: Error building extension 'gmm_1_2_1_Linux_3_10_12_22_12_1' ====================================================================== ERROR: test_cuda_1_1_batches_1_dimensions_5_channels_2_classes_1_mixtures (tests.test_gmm.GMMTestCase) ---------------------------------------------------------------------- Traceback (most recent call last): File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 2096, in _run_ninja_build subprocess.run( File "/usr/lib/python3.10/subprocess.py", line 526, in run raise CalledProcessError(retcode, process.args, subprocess.CalledProcessError: Command '['ninja', '-v']' returned non-zero exit status 1. The above exception was the direct cause of the following exception: Traceback (most recent call last): File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/parameterized/parameterized.py", line 620, in standalone_func return func(*(a + p.args), **p.kwargs, **kw) File "/home/szabolcslorincz/MONAI/tests/test_gmm.py", line 288, in test_cuda gmm = GaussianMixtureModel(features_tensor.size(1), mixture_count, class_count, verbose_build=True) File "/home/szabolcslorincz/MONAI/monai/networks/layers/gmm.py", line 44, in __init__ self.compiled_extension = load_module( File "/home/szabolcslorincz/MONAI/monai/_extensions/loader.py", line 89, in load_module module = load( File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 1306, in load return _jit_compile( File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 1710, in _jit_compile _write_ninja_file_and_build_library( File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 1823, in _write_ninja_file_and_build_library _run_ninja_build( File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 2112, in _run_ninja_build raise RuntimeError(message) from e RuntimeError: Error building extension 'gmm_5_2_1_Linux_3_10_12_22_12_1' ====================================================================== ERROR: test_cuda_2_1_batches_2_dimensions_2_channels_4_classes_4_mixtures (tests.test_gmm.GMMTestCase) ---------------------------------------------------------------------- Traceback (most recent call last): File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 2096, in _run_ninja_build subprocess.run( File "/usr/lib/python3.10/subprocess.py", line 526, in run raise CalledProcessError(retcode, process.args, subprocess.CalledProcessError: Command '['ninja', '-v']' returned non-zero exit status 1. The above exception was the direct cause of the following exception: Traceback (most recent call last): File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/parameterized/parameterized.py", line 620, in standalone_func return func(*(a + p.args), **p.kwargs, **kw) File "/home/szabolcslorincz/MONAI/tests/test_gmm.py", line 288, in test_cuda gmm = GaussianMixtureModel(features_tensor.size(1), mixture_count, class_count, verbose_build=True) File "/home/szabolcslorincz/MONAI/monai/networks/layers/gmm.py", line 44, in __init__ self.compiled_extension = load_module( File "/home/szabolcslorincz/MONAI/monai/_extensions/loader.py", line 89, in load_module module = load( File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 1306, in load return _jit_compile( File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 1710, in _jit_compile _write_ninja_file_and_build_library( File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 1823, in _write_ninja_file_and_build_library _run_ninja_build( File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 2112, in _run_ninja_build raise RuntimeError(message) from e RuntimeError: Error building extension 'gmm_2_4_1_Linux_3_10_12_22_12_1' ====================================================================== ERROR: test_cuda_3_1_batches_3_dimensions_1_channels_2_classes_1_mixtures (tests.test_gmm.GMMTestCase) ---------------------------------------------------------------------- Traceback (most recent call last): File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 2096, in _run_ninja_build subprocess.run( File "/usr/lib/python3.10/subprocess.py", line 526, in run raise CalledProcessError(retcode, process.args, subprocess.CalledProcessError: Command '['ninja', '-v']' returned non-zero exit status 1. The above exception was the direct cause of the following exception: Traceback (most recent call last): File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/parameterized/parameterized.py", line 620, in standalone_func return func(*(a + p.args), **p.kwargs, **kw) File "/home/szabolcslorincz/MONAI/tests/test_gmm.py", line 288, in test_cuda gmm = GaussianMixtureModel(features_tensor.size(1), mixture_count, class_count, verbose_build=True) File "/home/szabolcslorincz/MONAI/monai/networks/layers/gmm.py", line 44, in __init__ self.compiled_extension = load_module( File "/home/szabolcslorincz/MONAI/monai/_extensions/loader.py", line 89, in load_module module = load( File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 1306, in load return _jit_compile( File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 1710, in _jit_compile _write_ninja_file_and_build_library( File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 1823, in _write_ninja_file_and_build_library _run_ninja_build( File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 2112, in _run_ninja_build raise RuntimeError(message) from e RuntimeError: Error building extension 'gmm_1_2_1_Linux_3_10_12_22_12_1_v1' ====================================================================== ERROR: test_load (tests.test_gmm.GMMTestCase) ---------------------------------------------------------------------- Traceback (most recent call last): File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 2096, in _run_ninja_build subprocess.run( File "/usr/lib/python3.10/subprocess.py", line 526, in run raise CalledProcessError(retcode, process.args, subprocess.CalledProcessError: Command '['ninja', '-v']' returned non-zero exit status 1. The above exception was the direct cause of the following exception: Traceback (most recent call last): File "/home/szabolcslorincz/MONAI/tests/test_gmm.py", line 311, in test_load load_module("gmm", {"CHANNEL_COUNT": 2, "MIXTURE_COUNT": 2, "MIXTURE_SIZE": 3}, verbose_build=True) File "/home/szabolcslorincz/MONAI/monai/_extensions/loader.py", line 89, in load_module module = load( File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 1306, in load return _jit_compile( File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 1710, in _jit_compile _write_ninja_file_and_build_library( File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 1823, in _write_ninja_file_and_build_library _run_ninja_build( File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/utils/cpp_extension.py", line 2112, in _run_ninja_build raise RuntimeError(message) from e RuntimeError: Error building extension 'gmm_2_2_3_Linux_3_10_12_22_12_1' ====================================================================== ERROR: test_spacing_35 (tests.test_spacing.TestSpacingCase) ---------------------------------------------------------------------- Traceback (most recent call last): File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/parameterized/parameterized.py", line 620, in standalone_func return func(*(a + p.args), **p.kwargs, **kw) File "/home/szabolcslorincz/MONAI/tests/test_spacing.py", line 289, in test_spacing res: MetaTensor = tr(**call_param) # type: ignore File "/home/szabolcslorincz/MONAI/monai/transforms/spatial/array.py", line 525, in __call__ data_array = self.sp_resample( File "/home/szabolcslorincz/MONAI/monai/transforms/spatial/array.py", line 223, in __call__ return spatial_resample( File "/home/szabolcslorincz/MONAI/monai/transforms/spatial/functional.py", line 178, in spatial_resample img = affine_xform(img.unsqueeze(0), theta=xform.to(img), spatial_size=spatial_size).squeeze(0) # type: ignore File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1511, in _wrapped_call_impl return self._call_impl(*args, **kwargs) File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1520, in _call_impl return forward_call(*args, **kwargs) File "/home/szabolcslorincz/MONAI/monai/networks/layers/spatial_transforms.py", line 584, in forward grid = nn.functional.affine_grid(theta=theta[:, :sr], size=list(dst_size), align_corners=self.align_corners) File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/nn/functional.py", line 4418, in affine_grid return torch.affine_grid_generator(theta, size, align_corners) torch.cuda.OutOfMemoryError: CUDA out of memory. Tried to allocate 840.00 MiB. GPU 0 has a total capacity of 8.00 GiB of which 0 bytes is free. Including non-PyTorch memory, this process has 17179869184.00 GiB memory in use. Of the allocated memory 6.47 GiB is allocated by PyTorch, and 156.63 MiB is reserved by PyTorch but unallocated. If reserved but unallocated memory is large try setting PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True to avoid fragmentation. See documentation for Memory Management (https://pytorch.org/docs/stable/notes/cuda.html#environment-variables) ====================================================================== FAIL: test_seg_res_net_1_cuda (tests.test_convert_to_onnx.TestConvertToOnnx) ---------------------------------------------------------------------- Traceback (most recent call last): File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/parameterized/parameterized.py", line 620, in standalone_func return func(*(a + p.args), **p.kwargs, **kw) File "/home/szabolcslorincz/MONAI/tests/test_convert_to_onnx.py", line 108, in test_seg_res_net onnx_model = convert_to_onnx( File "/home/szabolcslorincz/MONAI/monai/networks/utils.py", line 709, in convert_to_onnx assert_fn(r1.cpu(), convert_to_tensor(r2, dtype=r1.dtype), rtol=rtol, atol=atol) # type: ignore File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/testing/_comparison.py", line 1520, in assert_close raise error_metas[0].to_error(msg) AssertionError: Tensor-likes are not close! Mismatched elements: 233543 / 1451520 (16.1%) Greatest absolute difference: 0.0014852285385131836 at index (0, 19, 12, 13, 22) (up to 0.0001 allowed) Greatest relative difference: 589.0405883789062 at index (0, 32, 21, 16, 15) (up to 0.001 allowed) ====================================================================== FAIL: test_unet_4_cuda (tests.test_convert_to_onnx.TestConvertToOnnx) ---------------------------------------------------------------------- Traceback (most recent call last): File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/parameterized/parameterized.py", line 620, in standalone_func return func(*(a + p.args), **p.kwargs, **kw) File "/home/szabolcslorincz/MONAI/tests/test_convert_to_onnx.py", line 57, in test_unet onnx_model = convert_to_onnx( File "/home/szabolcslorincz/MONAI/monai/networks/utils.py", line 709, in convert_to_onnx assert_fn(r1.cpu(), convert_to_tensor(r2, dtype=r1.dtype), rtol=rtol, atol=atol) # type: ignore File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/testing/_comparison.py", line 1520, in assert_close raise error_metas[0].to_error(msg) AssertionError: Tensor-likes are not close! Mismatched elements: 6705 / 49152 (13.6%) Greatest absolute difference: 0.0015408992767333984 at index (11, 0, 21, 27) (up to 0.0001 allowed) Greatest relative difference: 51.80112838745117 at index (14, 0, 25, 19) (up to 0.001 allowed) ====================================================================== FAIL: test_unet_5_cuda (tests.test_convert_to_onnx.TestConvertToOnnx) ---------------------------------------------------------------------- Traceback (most recent call last): File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/parameterized/parameterized.py", line 620, in standalone_func return func(*(a + p.args), **p.kwargs, **kw) File "/home/szabolcslorincz/MONAI/tests/test_convert_to_onnx.py", line 57, in test_unet onnx_model = convert_to_onnx( File "/home/szabolcslorincz/MONAI/monai/networks/utils.py", line 709, in convert_to_onnx assert_fn(r1.cpu(), convert_to_tensor(r2, dtype=r1.dtype), rtol=rtol, atol=atol) # type: ignore File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/testing/_comparison.py", line 1520, in assert_close raise error_metas[0].to_error(msg) AssertionError: Tensor-likes are not close! Mismatched elements: 6218 / 49152 (12.7%) Greatest absolute difference: 0.0015670061111450195 at index (2, 0, 23, 14) (up to 0.0001 allowed) Greatest relative difference: 7.987473964691162 at index (8, 0, 27, 8) (up to 0.001 allowed) ====================================================================== FAIL: test_unet_6_cuda (tests.test_convert_to_onnx.TestConvertToOnnx) ---------------------------------------------------------------------- Traceback (most recent call last): File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/parameterized/parameterized.py", line 620, in standalone_func return func(*(a + p.args), **p.kwargs, **kw) File "/home/szabolcslorincz/MONAI/tests/test_convert_to_onnx.py", line 57, in test_unet onnx_model = convert_to_onnx( File "/home/szabolcslorincz/MONAI/monai/networks/utils.py", line 709, in convert_to_onnx assert_fn(r1.cpu(), convert_to_tensor(r2, dtype=r1.dtype), rtol=rtol, atol=atol) # type: ignore File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/testing/_comparison.py", line 1520, in assert_close raise error_metas[0].to_error(msg) AssertionError: Tensor-likes are not close! Mismatched elements: 6743 / 49152 (13.7%) Greatest absolute difference: 0.0015552043914794922 at index (1, 1, 9, 11) (up to 0.0001 allowed) Greatest relative difference: 2.0317020416259766 at index (11, 0, 19, 21) (up to 0.001 allowed) ====================================================================== FAIL: test_unet_7_cuda (tests.test_convert_to_onnx.TestConvertToOnnx) ---------------------------------------------------------------------- Traceback (most recent call last): File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/parameterized/parameterized.py", line 620, in standalone_func return func(*(a + p.args), **p.kwargs, **kw) File "/home/szabolcslorincz/MONAI/tests/test_convert_to_onnx.py", line 57, in test_unet onnx_model = convert_to_onnx( File "/home/szabolcslorincz/MONAI/monai/networks/utils.py", line 709, in convert_to_onnx assert_fn(r1.cpu(), convert_to_tensor(r2, dtype=r1.dtype), rtol=rtol, atol=atol) # type: ignore File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/torch/testing/_comparison.py", line 1520, in assert_close raise error_metas[0].to_error(msg) AssertionError: Tensor-likes are not close! Mismatched elements: 6823 / 49152 (13.9%) Greatest absolute difference: 0.0018431544303894043 at index (10, 0, 9, 19) (up to 0.0001 allowed) Greatest relative difference: 4.297887325286865 at index (13, 0, 12, 13) (up to 0.001 allowed) ====================================================================== FAIL: test_shape_2 (tests.test_multi_scale.TestMultiScale) ---------------------------------------------------------------------- Traceback (most recent call last): File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/parameterized/parameterized.py", line 620, in standalone_func return func(*(a + p.args), **p.kwargs, **kw) File "/home/szabolcslorincz/MONAI/tests/test_multi_scale.py", line 59, in test_shape np.testing.assert_allclose(result.detach().cpu().numpy(), expected_val, rtol=1e-5) File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/numpy/testing/_private/utils.py", line 1504, in assert_allclose assert_array_compare(compare, actual, desired, err_msg=str(err_msg), File "/usr/lib/python3.10/contextlib.py", line 79, in inner return func(*args, **kwds) File "/home/szabolcslorincz/MONAI/venv/lib/python3.10/site-packages/numpy/testing/_private/utils.py", line 797, in assert_array_compare raise AssertionError(msg) AssertionError: Not equal to tolerance rtol=1e-05, atol=0 Mismatched elements: 1 / 1 (100%) Max absolute difference: 0. Max relative difference: 0. x: array(0.715212, dtype=float32) y: array(0.715228) ---------------------------------------------------------------------- Ran 14392 tests in 2252.916s FAILED (failures=6, errors=6, skipped=450) --------- Signed-off-by: Szabolcs Botond Lorincz Molnar Co-authored-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> --- monai/transforms/utils.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index e282ecff24..455b0cfb7d 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -625,9 +625,12 @@ def generate_label_classes_crop_centers( for i, array in enumerate(indices): if len(array) == 0: - ratios_[i] = 0 - if warn: - warnings.warn(f"no available indices of class {i} to crop, set the crop ratio of this class to zero.") + if ratios_[i] != 0: + ratios_[i] = 0 + if warn: + warnings.warn( + f"no available indices of class {i} to crop, setting the crop ratio of this class to zero." + ) centers = [] classes = rand_state.choice(len(ratios_), size=num_samples, p=np.asarray(ratios_) / np.sum(ratios_)) From 763347d0c945d5ea87916b86240c4368d818a286 Mon Sep 17 00:00:00 2001 From: YunLiu <55491388+KumoLiu@users.noreply.github.com> Date: Wed, 3 Apr 2024 21:12:58 +0800 Subject: [PATCH 28/35] Update base image to 2403 (#7600) ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. --------- Signed-off-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> Co-authored-by: Nic Ma --- .github/workflows/cron.yml | 16 ++++++++-------- .github/workflows/pythonapp-gpu.yml | 8 ++++---- Dockerfile | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 792fda5279..0f9e6cd480 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -19,18 +19,18 @@ jobs: - "PTLATEST+CUDA121" include: # https://docs.nvidia.com/deeplearning/frameworks/pytorch-release-notes - - environment: PT191+CUDA113 - pytorch: "torch==1.9.1 torchvision==0.10.1 --extra-index-url https://download.pytorch.org/whl/cu113" - base: "nvcr.io/nvidia/pytorch:21.06-py3" # CUDA 11.3 - environment: PT110+CUDA113 pytorch: "torch==1.10.2 torchvision==0.11.3 --extra-index-url https://download.pytorch.org/whl/cu113" base: "nvcr.io/nvidia/pytorch:21.06-py3" # CUDA 11.3 - environment: PT113+CUDA113 pytorch: "torch==1.13.1 torchvision==0.14.1 --extra-index-url https://download.pytorch.org/whl/cu113" base: "nvcr.io/nvidia/pytorch:21.06-py3" # CUDA 11.3 - - environment: PTLATEST+CUDA121 - pytorch: "-U torch torchvision --extra-index-url https://download.pytorch.org/whl/cu118" + - environment: PT113+CUDA122 + pytorch: "torch==1.13.1 torchvision==0.14.1 --extra-index-url https://download.pytorch.org/whl/cu121" base: "nvcr.io/nvidia/pytorch:23.08-py3" # CUDA 12.2 + - environment: PTLATEST+CUDA124 + pytorch: "-U torch torchvision --extra-index-url https://download.pytorch.org/whl/cu121" + base: "nvcr.io/nvidia/pytorch:24.03-py3" # CUDA 12.4 container: image: ${{ matrix.base }} options: "--gpus all" @@ -76,7 +76,7 @@ jobs: if: github.repository == 'Project-MONAI/MONAI' strategy: matrix: - container: ["pytorch:22.10", "pytorch:23.08"] + container: ["pytorch:23.08", "pytorch:24.03"] container: image: nvcr.io/nvidia/${{ matrix.container }}-py3 # testing with the latest pytorch base image options: "--gpus all" @@ -121,7 +121,7 @@ jobs: if: github.repository == 'Project-MONAI/MONAI' strategy: matrix: - container: ["pytorch:23.08"] + container: ["pytorch:24.03"] container: image: nvcr.io/nvidia/${{ matrix.container }}-py3 # testing with the latest pytorch base image options: "--gpus all" @@ -221,7 +221,7 @@ jobs: if: github.repository == 'Project-MONAI/MONAI' needs: cron-gpu # so that monai itself is verified first container: - image: nvcr.io/nvidia/pytorch:23.08-py3 # testing with the latest pytorch base image + image: nvcr.io/nvidia/pytorch:24.03-py3 # testing with the latest pytorch base image options: "--gpus all --ipc=host" runs-on: [self-hosted, linux, x64, integration] steps: diff --git a/.github/workflows/pythonapp-gpu.yml b/.github/workflows/pythonapp-gpu.yml index a6d7981814..f83d52f8e3 100644 --- a/.github/workflows/pythonapp-gpu.yml +++ b/.github/workflows/pythonapp-gpu.yml @@ -29,10 +29,6 @@ jobs: - "PT210+CUDA121DOCKER" include: # https://docs.nvidia.com/deeplearning/frameworks/pytorch-release-notes - - environment: PT19+CUDA114DOCKER - # 21.10: 1.10.0a0+0aef44c - pytorch: "-h" # we explicitly set pytorch to -h to avoid pip install error - base: "nvcr.io/nvidia/pytorch:21.10-py3" - environment: PT110+CUDA111 pytorch: "torch==1.10.2 torchvision==0.11.3 --extra-index-url https://download.pytorch.org/whl/cu111" base: "nvcr.io/nvidia/cuda:11.1.1-devel-ubuntu18.04" @@ -47,6 +43,10 @@ jobs: # 23.08: 2.1.0a0+29c30b1 pytorch: "-h" # we explicitly set pytorch to -h to avoid pip install error base: "nvcr.io/nvidia/pytorch:23.08-py3" + - environment: PT210+CUDA121DOCKER + # 24.03: 2.3.0a0+40ec155e58.nv24.3 + pytorch: "-h" # we explicitly set pytorch to -h to avoid pip install error + base: "nvcr.io/nvidia/pytorch:24.03-py3" container: image: ${{ matrix.base }} options: --gpus all --env NVIDIA_DISABLE_REQUIRE=true # workaround for unsatisfied condition: cuda>=11.6 diff --git a/Dockerfile b/Dockerfile index 7383837585..d5777104c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ # To build with a different base image # please run `docker build` using the `--build-arg PYTORCH_IMAGE=...` flag. -ARG PYTORCH_IMAGE=nvcr.io/nvidia/pytorch:23.08-py3 +ARG PYTORCH_IMAGE=nvcr.io/nvidia/pytorch:24.03-py3 FROM ${PYTORCH_IMAGE} LABEL maintainer="monai.contact@gmail.com" From 195d7ddc3669426e39567d90d083684cd948e388 Mon Sep 17 00:00:00 2001 From: Lucas Robinet Date: Thu, 4 Apr 2024 09:16:17 +0200 Subject: [PATCH 29/35] simplification of the sincos positional encoding in patchembedding.py (#7605) Fixes #7564 . ### Description As discussed, a small simplification for the creation of sincos positional encoding where we don't need to use the `torch.no_grad()` context or copy the tensor with `copy_` from torch which doesn't preserve the `requires_grad` attribute here. The changes are simple and are linked to the corresponding comment #7564, the output is already in float32 so it doesn't seem particularly necessary to apply the conversion previously done. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [x] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Lucas Robinet Co-authored-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> --- monai/networks/blocks/patchembedding.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/monai/networks/blocks/patchembedding.py b/monai/networks/blocks/patchembedding.py index 44774ce5da..91bd73ebbb 100644 --- a/monai/networks/blocks/patchembedding.py +++ b/monai/networks/blocks/patchembedding.py @@ -120,10 +120,7 @@ def __init__( for in_size, pa_size in zip(img_size, patch_size): grid_size.append(in_size // pa_size) - with torch.no_grad(): - pos_embeddings = build_sincos_position_embedding(grid_size, hidden_size, spatial_dims) - self.position_embeddings.data.copy_(pos_embeddings.float()) - self.position_embeddings.requires_grad = False + self.position_embeddings = build_sincos_position_embedding(grid_size, hidden_size, spatial_dims) else: raise ValueError(f"pos_embed_type {self.pos_embed_type} not supported.") From 625967c483e6ad98c2e41d7d046040c91329cd8c Mon Sep 17 00:00:00 2001 From: Lucas Robinet Date: Fri, 5 Apr 2024 11:21:24 +0200 Subject: [PATCH 30/35] harmonization and clarification of dice losses variants docs and associated tests (#7587) ### Description This PR aims to clarify and harmonise the code for the DiceLoss variants in the `monai/losses/dice.py` file. With the `to_onehot_y` `softmax` and `sigmoid` arguments, I didn't necessarily understand the ValueError that occurred when I passed a target of size NH[WD]. I had a bit of trouble reading the documentation and understanding it. I thought that they had to be the same shape as they are displayed, unlike the number of dimensions in the input, so I added that. Besides, in the documentation is written: ```python """ raises: ValueError: When number of channels for target is neither 1 nor the same as input. """ ``` Trying to reproduce this, we give an input with a number of channels $N$ and target a number of channels of $M$, with $M \neq N$ and $M > 1$. ```python loss = DiceCELoss() input = torch.rand(1, 4, 3, 3) target = torch.randn(1, 2, 3, 3) loss(input, target) >: AssertionError: ground truth has different shape (torch.Size([1, 2, 3, 3])) from input (torch.Size([1, 4, 3, 3])) ``` This error in the Dice is an `AssertionError` and not a `ValueError` as expected and the explanation can be confusing and doesn't give a clear idea of the error here. The classes concerned and harmonised are `DiceFocalLoss`, `DiceCELoss` and `GeneralizedDiceFocalLoss` with the addition of tests that behave correctly and handle this harmonisation. Also, feel free to modify or make suggestions regarding the changes made in the docstring to make them more understandable (in my opinion, but other readers and users will probably have a different view). ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [x] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. --------- Signed-off-by: Lucas Robinet Signed-off-by: Lucas Robinet Co-authored-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- monai/losses/dice.py | 42 +++++++++++++++++++---- tests/test_dice_ce_loss.py | 18 +++++++--- tests/test_dice_focal_loss.py | 14 ++++++-- tests/test_generalized_dice_focal_loss.py | 14 ++++++-- 4 files changed, 73 insertions(+), 15 deletions(-) diff --git a/monai/losses/dice.py b/monai/losses/dice.py index b3c0f57c6e..f1c357d31f 100644 --- a/monai/losses/dice.py +++ b/monai/losses/dice.py @@ -778,12 +778,22 @@ def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: Raises: ValueError: When number of dimensions for input and target are different. - ValueError: When number of channels for target is neither 1 nor the same as input. + ValueError: When number of channels for target is neither 1 (without one-hot encoding) nor the same as input. + + Returns: + torch.Tensor: value of the loss. """ - if len(input.shape) != len(target.shape): + if input.dim() != target.dim(): raise ValueError( "the number of dimensions for input and target should be the same, " + f"got shape {input.shape} (nb dims: {len(input.shape)}) and {target.shape} (nb dims: {len(target.shape)}). " + "if target is not one-hot encoded, please provide a tensor with shape B1H[WD]." + ) + + if target.shape[1] != 1 and target.shape[1] != input.shape[1]: + raise ValueError( + "number of channels for target is neither 1 (without one-hot encoding) nor the same as input, " f"got shape {input.shape} and {target.shape}." ) @@ -899,14 +909,24 @@ def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: Raises: ValueError: When number of dimensions for input and target are different. - ValueError: When number of channels for target is neither 1 nor the same as input. + ValueError: When number of channels for target is neither 1 (without one-hot encoding) nor the same as input. + Returns: + torch.Tensor: value of the loss. """ - if len(input.shape) != len(target.shape): + if input.dim() != target.dim(): raise ValueError( "the number of dimensions for input and target should be the same, " + f"got shape {input.shape} (nb dims: {len(input.shape)}) and {target.shape} (nb dims: {len(target.shape)}). " + "if target is not one-hot encoded, please provide a tensor with shape B1H[WD]." + ) + + if target.shape[1] != 1 and target.shape[1] != input.shape[1]: + raise ValueError( + "number of channels for target is neither 1 (without one-hot encoding) nor the same as input, " f"got shape {input.shape} and {target.shape}." ) + if self.to_onehot_y: n_pred_ch = input.shape[1] if n_pred_ch == 1: @@ -1015,15 +1035,23 @@ def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: target (torch.Tensor): the shape should be BNH[WD] or B1H[WD]. Raises: - ValueError: When the input and target tensors have different numbers of dimensions, or the target - channel isn't either one-hot encoded or categorical with the same shape of the input. + ValueError: When number of dimensions for input and target are different. + ValueError: When number of channels for target is neither 1 (without one-hot encoding) nor the same as input. Returns: torch.Tensor: value of the loss. """ if input.dim() != target.dim(): raise ValueError( - f"Input - {input.shape} - and target - {target.shape} - must have the same number of dimensions." + "the number of dimensions for input and target should be the same, " + f"got shape {input.shape} (nb dims: {len(input.shape)}) and {target.shape} (nb dims: {len(target.shape)}). " + "if target is not one-hot encoded, please provide a tensor with shape B1H[WD]." + ) + + if target.shape[1] != 1 and target.shape[1] != input.shape[1]: + raise ValueError( + "number of channels for target is neither 1 (without one-hot encoding) nor the same as input, " + f"got shape {input.shape} and {target.shape}." ) gdl_loss = self.generalized_dice(input, target) diff --git a/tests/test_dice_ce_loss.py b/tests/test_dice_ce_loss.py index 225618ed2c..97c7ae5050 100644 --- a/tests/test_dice_ce_loss.py +++ b/tests/test_dice_ce_loss.py @@ -93,10 +93,20 @@ def test_result(self, input_param, input_data, expected_val): result = diceceloss(**input_data) np.testing.assert_allclose(result.detach().cpu().numpy(), expected_val, atol=1e-4, rtol=1e-4) - # def test_ill_shape(self): - # loss = DiceCELoss() - # with self.assertRaisesRegex(ValueError, ""): - # loss(torch.ones((1, 2, 3)), torch.ones((1, 1, 2, 3))) + def test_ill_shape(self): + loss = DiceCELoss() + with self.assertRaises(AssertionError): + loss.forward(torch.ones((1, 2, 3)), torch.ones((1, 2, 5))) + + def test_ill_shape2(self): + loss = DiceCELoss() + with self.assertRaises(ValueError): + loss.forward(torch.ones((1, 2, 3)), torch.ones((1, 1, 2, 3))) + + def test_ill_shape3(self): + loss = DiceCELoss() + with self.assertRaises(ValueError): + loss.forward(torch.ones((1, 3, 4, 4)), torch.ones((1, 2, 4, 4))) # def test_ill_reduction(self): # with self.assertRaisesRegex(ValueError, ""): diff --git a/tests/test_dice_focal_loss.py b/tests/test_dice_focal_loss.py index 13899da003..814a174762 100644 --- a/tests/test_dice_focal_loss.py +++ b/tests/test_dice_focal_loss.py @@ -69,8 +69,18 @@ def test_result_no_onehot_no_bg(self, size, onehot): def test_ill_shape(self): loss = DiceFocalLoss() - with self.assertRaisesRegex(ValueError, ""): - loss(torch.ones((1, 2, 3)), torch.ones((1, 1, 2, 3))) + with self.assertRaises(AssertionError): + loss.forward(torch.ones((1, 2, 3)), torch.ones((1, 2, 5))) + + def test_ill_shape2(self): + loss = DiceFocalLoss() + with self.assertRaises(ValueError): + loss.forward(torch.ones((1, 2, 3)), torch.ones((1, 1, 2, 3))) + + def test_ill_shape3(self): + loss = DiceFocalLoss() + with self.assertRaises(ValueError): + loss.forward(torch.ones((1, 3, 4, 4)), torch.ones((1, 2, 4, 4))) def test_ill_lambda(self): with self.assertRaisesRegex(ValueError, ""): diff --git a/tests/test_generalized_dice_focal_loss.py b/tests/test_generalized_dice_focal_loss.py index 8a0a80865e..65252611ca 100644 --- a/tests/test_generalized_dice_focal_loss.py +++ b/tests/test_generalized_dice_focal_loss.py @@ -59,8 +59,18 @@ def test_result_no_onehot_no_bg(self): def test_ill_shape(self): loss = GeneralizedDiceFocalLoss() - with self.assertRaisesRegex(ValueError, ""): - loss(torch.ones((1, 2, 3)), torch.ones((1, 1, 2, 3))) + with self.assertRaises(AssertionError): + loss.forward(torch.ones((1, 2, 3)), torch.ones((1, 2, 5))) + + def test_ill_shape2(self): + loss = GeneralizedDiceFocalLoss() + with self.assertRaises(ValueError): + loss.forward(torch.ones((1, 2, 3)), torch.ones((1, 1, 2, 3))) + + def test_ill_shape3(self): + loss = GeneralizedDiceFocalLoss() + with self.assertRaises(ValueError): + loss.forward(torch.ones((1, 3, 4, 4)), torch.ones((1, 2, 4, 4))) def test_ill_lambda(self): with self.assertRaisesRegex(ValueError, ""): From c0b9cc0b00459563196b9ce1d430f5d86406c5e6 Mon Sep 17 00:00:00 2001 From: Lucas Robinet Date: Fri, 5 Apr 2024 14:56:25 +0200 Subject: [PATCH 31/35] Implementation of intensity clipping transform: bot hard and soft approaches (#7535) Fixes Issue #7512. ### Description Addition of a transformation allowing values above or below a certain percentile to be clipped. Clipping can be hard or soft. With soft clipping, the function remains derivable and the order of the values is respected, with smoother corners. The soft clipping function is based on this medium article https://medium.com/life-at-hopper/clip-it-clip-it-good-1f1bf711b291 It's important to note that I've chosen to switch from Nones values to percentiles to take account of the fact that soft clipping can be one-sided or two-sided. In fact, providing percentiles of 100 or 0 doesn't change anything in the case of hard clipping, but it does in the case of soft clipping because the function is smoothed. Hence the interest in introducing the possibility of putting None to avoid smoothing the function on one side or the other. To implement this we had to define a `softplus` function in `monai.transforms.utils_pytorch_numpy_unification.py`. One of the problems is that `np.logaddexp` do not exactly yields same outputs as `torch.logaddexp`. I've left it as is and lowered the tolerance of the tests slightly, but it's possible to force the conversion to numpy and then switch back to torch to ensure better unification between the frameworks. I've also added the `soft_clip` function in `monai.transforms.utils.py` with the associated unit tests to ensure that the transformation works properly. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [x] In-line docstrings updated. - [x] Documentation updated, tested `make html` command in the `docs/` folder. --------- Signed-off-by: Lucas Robinet Co-authored-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- docs/source/transforms.rst | 12 ++ monai/transforms/__init__.py | 4 + monai/transforms/intensity/array.py | 148 ++++++++++++- monai/transforms/intensity/dictionary.py | 35 +++ monai/transforms/utils.py | 37 ++++ .../utils_pytorch_numpy_unification.py | 15 ++ tests/test_clip_intensity_percentiles.py | 185 ++++++++++++++++ tests/test_clip_intensity_percentilesd.py | 203 ++++++++++++++++++ tests/test_soft_clip.py | 125 +++++++++++ 9 files changed, 763 insertions(+), 1 deletion(-) create mode 100644 tests/test_clip_intensity_percentiles.py create mode 100644 tests/test_clip_intensity_percentilesd.py create mode 100644 tests/test_soft_clip.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index bd3feb3497..8bd5bfd99f 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -309,6 +309,12 @@ Intensity :members: :special-members: __call__ +`ClipIntensityPercentiles` +"""""""""""""""""""""""""" +.. autoclass:: ClipIntensityPercentiles + :members: + :special-members: __call__ + `RandScaleIntensity` """""""""""""""""""" .. image:: https://raw.githubusercontent.com/Project-MONAI/DocImages/main/transforms/RandScaleIntensity.png @@ -1405,6 +1411,12 @@ Intensity (Dict) :members: :special-members: __call__ +`ClipIntensityPercentilesd` +""""""""""""""""""""""""""" +.. autoclass:: ClipIntensityPercentilesd + :members: + :special-members: __call__ + `RandScaleIntensityd` """"""""""""""""""""" .. image:: https://raw.githubusercontent.com/Project-MONAI/DocImages/main/transforms/RandScaleIntensityd.png diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 349533fb3e..ab9adb6a99 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -92,6 +92,7 @@ from .croppad.functional import crop_func, crop_or_pad_nd, pad_func, pad_nd from .intensity.array import ( AdjustContrast, + ClipIntensityPercentiles, ComputeHoVerMaps, DetectEnvelope, ForegroundMask, @@ -135,6 +136,9 @@ AdjustContrastd, AdjustContrastD, AdjustContrastDict, + ClipIntensityPercentilesd, + ClipIntensityPercentilesD, + ClipIntensityPercentilesDict, ComputeHoVerMapsd, ComputeHoVerMapsD, ComputeHoVerMapsDict, diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 0085050ee3..f656475a36 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -30,7 +30,7 @@ from monai.data.utils import get_random_patch, get_valid_patch_size from monai.networks.layers import GaussianFilter, HilbertTransform, MedianFilter, SavitzkyGolayFilter from monai.transforms.transform import RandomizableTransform, Transform -from monai.transforms.utils import Fourier, equalize_hist, is_positive, rescale_array +from monai.transforms.utils import Fourier, equalize_hist, is_positive, rescale_array, soft_clip from monai.transforms.utils_pytorch_numpy_unification import clip, percentile, where from monai.utils.enums import TransformBackends from monai.utils.misc import ensure_tuple, ensure_tuple_rep, ensure_tuple_size, fall_back_tuple @@ -54,6 +54,7 @@ "NormalizeIntensity", "ThresholdIntensity", "ScaleIntensityRange", + "ClipIntensityPercentiles", "AdjustContrast", "RandAdjustContrast", "ScaleIntensityRangePercentiles", @@ -1007,6 +1008,151 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: return ret +class ClipIntensityPercentiles(Transform): + """ + Apply clip based on the intensity distribution of input image. + If `sharpness_factor` is provided, the intensity values will be soft clipped according to + f(x) = x + (1/sharpness_factor)*softplus(- c(x - minv)) - (1/sharpness_factor)*softplus(c(x - maxv)) + From https://medium.com/life-at-hopper/clip-it-clip-it-good-1f1bf711b291 + + Soft clipping preserves the order of the values and maintains the gradient everywhere. + For example: + + .. code-block:: python + :emphasize-lines: 11, 22 + + image = torch.Tensor( + [[[1, 2, 3, 4, 5], + [1, 2, 3, 4, 5], + [1, 2, 3, 4, 5], + [1, 2, 3, 4, 5], + [1, 2, 3, 4, 5], + [1, 2, 3, 4, 5]]]) + + # Hard clipping from lower and upper image intensity percentiles + hard_clipper = ClipIntensityPercentiles(30, 70) + print(hard_clipper(image)) + metatensor([[[2., 2., 3., 4., 4.], + [2., 2., 3., 4., 4.], + [2., 2., 3., 4., 4.], + [2., 2., 3., 4., 4.], + [2., 2., 3., 4., 4.], + [2., 2., 3., 4., 4.]]]) + + + # Soft clipping from lower and upper image intensity percentiles + soft_clipper = ClipIntensityPercentiles(30, 70, 10.) + print(soft_clipper(image)) + metatensor([[[2.0000, 2.0693, 3.0000, 3.9307, 4.0000], + [2.0000, 2.0693, 3.0000, 3.9307, 4.0000], + [2.0000, 2.0693, 3.0000, 3.9307, 4.0000], + [2.0000, 2.0693, 3.0000, 3.9307, 4.0000], + [2.0000, 2.0693, 3.0000, 3.9307, 4.0000], + [2.0000, 2.0693, 3.0000, 3.9307, 4.0000]]]) + + See Also: + + - :py:class:`monai.transforms.ScaleIntensityRangePercentiles` + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __init__( + self, + lower: float | None, + upper: float | None, + sharpness_factor: float | None = None, + channel_wise: bool = False, + return_clipping_values: bool = False, + dtype: DtypeLike = np.float32, + ) -> None: + """ + Args: + lower: lower intensity percentile. In the case of hard clipping, None will have the same effect as 0 by + not clipping the lowest input values. However, in the case of soft clipping, None and zero will have + two different effects: None will not apply clipping to low values, whereas zero will still transform + the lower values according to the soft clipping transformation. Please check for more details: + https://medium.com/life-at-hopper/clip-it-clip-it-good-1f1bf711b291. + upper: upper intensity percentile. The same as for lower, but this time with the highest values. If we + are looking to perform soft clipping, if None then there will be no effect on this side whereas if set + to 100, the values will be passed via the corresponding clipping equation. + sharpness_factor: if not None, the intensity values will be soft clipped according to + f(x) = x + (1/sharpness_factor)*softplus(- c(x - minv)) - (1/sharpness_factor)*softplus(c(x - maxv)). + defaults to None. + channel_wise: if True, compute intensity percentile and normalize every channel separately. + default to False. + return_clipping_values: whether to return the calculated percentiles in tensor meta information. + If soft clipping and requested percentile is None, return None as the corresponding clipping + values in meta information. Clipping values are stored in a list with each element corresponding + to a channel if channel_wise is set to True. defaults to False. + dtype: output data type, if None, same as input image. defaults to float32. + """ + if lower is None and upper is None: + raise ValueError("lower or upper percentiles must be provided") + if lower is not None and (lower < 0.0 or lower > 100.0): + raise ValueError("Percentiles must be in the range [0, 100]") + if upper is not None and (upper < 0.0 or upper > 100.0): + raise ValueError("Percentiles must be in the range [0, 100]") + if upper is not None and lower is not None and upper < lower: + raise ValueError("upper must be greater than or equal to lower") + if sharpness_factor is not None and sharpness_factor <= 0: + raise ValueError("sharpness_factor must be greater than 0") + + self.lower = lower + self.upper = upper + self.sharpness_factor = sharpness_factor + self.channel_wise = channel_wise + if return_clipping_values: + self.clipping_values: list[tuple[float | None, float | None]] = [] + self.return_clipping_values = return_clipping_values + self.dtype = dtype + + def _clip(self, img: NdarrayOrTensor) -> NdarrayOrTensor: + if self.sharpness_factor is not None: + lower_percentile = percentile(img, self.lower) if self.lower is not None else None + upper_percentile = percentile(img, self.upper) if self.upper is not None else None + img = soft_clip(img, self.sharpness_factor, lower_percentile, upper_percentile, self.dtype) + else: + lower_percentile = percentile(img, self.lower) if self.lower is not None else percentile(img, 0) + upper_percentile = percentile(img, self.upper) if self.upper is not None else percentile(img, 100) + img = clip(img, lower_percentile, upper_percentile) + + if self.return_clipping_values: + self.clipping_values.append( + ( + ( + lower_percentile + if lower_percentile is None + else lower_percentile.item() if hasattr(lower_percentile, "item") else lower_percentile + ), + ( + upper_percentile + if upper_percentile is None + else upper_percentile.item() if hasattr(upper_percentile, "item") else upper_percentile + ), + ) + ) + img = convert_to_tensor(img, track_meta=False) + return img + + def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: + """ + Apply the transform to `img`. + """ + img = convert_to_tensor(img, track_meta=get_track_meta()) + img_t = convert_to_tensor(img, track_meta=False) + if self.channel_wise: + img_t = torch.stack([self._clip(img=d) for d in img_t]) # type: ignore + else: + img_t = self._clip(img=img_t) + + img = convert_to_dst_type(img_t, dst=img)[0] + if self.return_clipping_values: + img.meta["clipping_values"] = self.clipping_values # type: ignore + + return img + + class AdjustContrast(Transform): """ Changes image intensity with gamma transform. Each pixel/voxel intensity is updated as:: diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 5b911904b0..5dbac485fe 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -26,6 +26,7 @@ from monai.data.meta_obj import get_track_meta from monai.transforms.intensity.array import ( AdjustContrast, + ClipIntensityPercentiles, ComputeHoVerMaps, ForegroundMask, GaussianSharpen, @@ -77,6 +78,7 @@ "NormalizeIntensityd", "ThresholdIntensityd", "ScaleIntensityRanged", + "ClipIntensityPercentilesd", "AdjustContrastd", "RandAdjustContrastd", "ScaleIntensityRangePercentilesd", @@ -122,6 +124,8 @@ "ThresholdIntensityDict", "ScaleIntensityRangeD", "ScaleIntensityRangeDict", + "ClipIntensityPercentilesD", + "ClipIntensityPercentilesDict", "AdjustContrastD", "AdjustContrastDict", "RandAdjustContrastD", @@ -886,6 +890,36 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N return d +class ClipIntensityPercentilesd(MapTransform): + """ + Dictionary-based wrapper of :py:class:`monai.transforms.ClipIntensityPercentiles`. + Clip the intensity values of input image to a specific range based on the intensity distribution of the input. + If `sharpness_factor` is provided, the intensity values will be soft clipped according to + f(x) = x + (1/sharpness_factor) * softplus(- c(x - minv)) - (1/sharpness_factor)*softplus(c(x - maxv)) + """ + + def __init__( + self, + keys: KeysCollection, + lower: float | None, + upper: float | None, + sharpness_factor: float | None = None, + channel_wise: bool = False, + dtype: DtypeLike = np.float32, + allow_missing_keys: bool = False, + ) -> None: + super().__init__(keys, allow_missing_keys) + self.scaler = ClipIntensityPercentiles( + lower=lower, upper=upper, sharpness_factor=sharpness_factor, channel_wise=channel_wise, dtype=dtype + ) + + def __call__(self, data: dict) -> dict: + d = dict(data) + for key in self.key_iterator(d): + d[key] = self.scaler(d[key]) + return d + + class AdjustContrastd(MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.AdjustContrast`. @@ -1929,6 +1963,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N NormalizeIntensityD = NormalizeIntensityDict = NormalizeIntensityd ThresholdIntensityD = ThresholdIntensityDict = ThresholdIntensityd ScaleIntensityRangeD = ScaleIntensityRangeDict = ScaleIntensityRanged +ClipIntensityPercentilesD = ClipIntensityPercentilesDict = ClipIntensityPercentilesd AdjustContrastD = AdjustContrastDict = AdjustContrastd RandAdjustContrastD = RandAdjustContrastDict = RandAdjustContrastd ScaleIntensityRangePercentilesD = ScaleIntensityRangePercentilesDict = ScaleIntensityRangePercentilesd diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index 455b0cfb7d..14f35e1219 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -38,6 +38,7 @@ nonzero, ravel, searchsorted, + softplus, unique, unravel_index, where, @@ -131,9 +132,45 @@ "resolves_modes", "has_status_keys", "distance_transform_edt", + "soft_clip", ] +def soft_clip( + arr: NdarrayOrTensor, + sharpness_factor: float = 1.0, + minv: NdarrayOrTensor | float | int | None = None, + maxv: NdarrayOrTensor | float | int | None = None, + dtype: DtypeLike | torch.dtype = np.float32, +) -> NdarrayOrTensor: + """ + Apply soft clip to the input array or tensor. + The intensity values will be soft clipped according to + f(x) = x + (1/sharpness_factor)*softplus(- c(x - minv)) - (1/sharpness_factor)*softplus(c(x - maxv)) + From https://medium.com/life-at-hopper/clip-it-clip-it-good-1f1bf711b291 + + To perform one-sided clipping, set either minv or maxv to None. + Args: + arr: input array to clip. + sharpness_factor: the sharpness of the soft clip function, default to 1. + minv: minimum value of target clipped array. + maxv: maximum value of target clipped array. + dtype: if not None, convert input array to dtype before computation. + + """ + + if dtype is not None: + arr, *_ = convert_data_type(arr, dtype=dtype) + + v = arr + if minv is not None: + v = v + softplus(-sharpness_factor * (arr - minv)) / sharpness_factor + if maxv is not None: + v = v - softplus(sharpness_factor * (arr - maxv)) / sharpness_factor + + return v + + def rand_choice(prob: float = 0.5) -> bool: """ Returns True if a randomly chosen number is less than or equal to `prob`, by default this is a 50/50 chance. diff --git a/monai/transforms/utils_pytorch_numpy_unification.py b/monai/transforms/utils_pytorch_numpy_unification.py index 0774d50314..020d99af16 100644 --- a/monai/transforms/utils_pytorch_numpy_unification.py +++ b/monai/transforms/utils_pytorch_numpy_unification.py @@ -52,9 +52,24 @@ "median", "mean", "std", + "softplus", ] +def softplus(x: NdarrayOrTensor) -> NdarrayOrTensor: + """stable softplus through `np.logaddexp` with equivalent implementation for torch. + + Args: + x: array/tensor. + + Returns: + Softplus of the input. + """ + if isinstance(x, np.ndarray): + return np.logaddexp(np.zeros_like(x), x) + return torch.logaddexp(torch.zeros_like(x), x) + + def allclose(a: NdarrayTensor, b: NdarrayOrTensor, rtol=1e-5, atol=1e-8, equal_nan=False) -> bool: """`np.allclose` with equivalent implementation for torch.""" b, *_ = convert_to_dst_type(b, a, wrap_sequence=True) diff --git a/tests/test_clip_intensity_percentiles.py b/tests/test_clip_intensity_percentiles.py new file mode 100644 index 0000000000..f5fe07a323 --- /dev/null +++ b/tests/test_clip_intensity_percentiles.py @@ -0,0 +1,185 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms import ClipIntensityPercentiles +from monai.transforms.utils import soft_clip +from monai.transforms.utils_pytorch_numpy_unification import clip +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, NumpyImageTestCase3D, assert_allclose + + +class TestClipIntensityPercentiles2D(NumpyImageTestCase2D): + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_hard_clipping_two_sided(self, p): + hard_clipper = ClipIntensityPercentiles(upper=95, lower=5) + im = p(self.imt) + result = hard_clipper(im) + lower, upper = np.percentile(self.imt, (5, 95)) + expected = clip(self.imt, lower, upper) + assert_allclose(result, p(expected), type_test="tensor", rtol=1e-7, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_hard_clipping_one_sided_high(self, p): + hard_clipper = ClipIntensityPercentiles(upper=95, lower=None) + im = p(self.imt) + result = hard_clipper(im) + lower, upper = np.percentile(self.imt, (0, 95)) + expected = clip(self.imt, lower, upper) + assert_allclose(result, p(expected), type_test="tensor", rtol=1e-7, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_hard_clipping_one_sided_low(self, p): + hard_clipper = ClipIntensityPercentiles(upper=None, lower=5) + im = p(self.imt) + result = hard_clipper(im) + lower, upper = np.percentile(self.imt, (5, 100)) + expected = clip(self.imt, lower, upper) + assert_allclose(result, p(expected), type_test="tensor", rtol=1e-7, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_soft_clipping_two_sided(self, p): + soft_clipper = ClipIntensityPercentiles(upper=95, lower=5, sharpness_factor=1.0) + im = p(self.imt) + result = soft_clipper(im) + lower, upper = np.percentile(self.imt, (5, 95)) + expected = soft_clip(self.imt, sharpness_factor=1.0, minv=lower, maxv=upper) + # the rtol is set to 1e-6 because the logaddexp function used in softplus is not stable accross torch and numpy + assert_allclose(result, p(expected), type_test="tensor", rtol=1e-6, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_soft_clipping_one_sided_high(self, p): + soft_clipper = ClipIntensityPercentiles(upper=95, lower=None, sharpness_factor=1.0) + im = p(self.imt) + result = soft_clipper(im) + upper = np.percentile(self.imt, 95) + expected = soft_clip(self.imt, sharpness_factor=1.0, minv=None, maxv=upper) + # the rtol is set to 5e-5 because the logaddexp function used in softplus is not stable accross torch and numpy + assert_allclose(result, p(expected), type_test="tensor", rtol=5e-5, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_soft_clipping_one_sided_low(self, p): + soft_clipper = ClipIntensityPercentiles(upper=None, lower=5, sharpness_factor=1.0) + im = p(self.imt) + result = soft_clipper(im) + lower = np.percentile(self.imt, 5) + expected = soft_clip(self.imt, sharpness_factor=1.0, minv=lower, maxv=None) + # the rtol is set to 1e-6 because the logaddexp function used in softplus is not stable accross torch and numpy + assert_allclose(result, p(expected), type_test="tensor", rtol=1e-6, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_channel_wise(self, p): + clipper = ClipIntensityPercentiles(upper=95, lower=5, channel_wise=True) + im = p(self.imt) + result = clipper(im) + for i, c in enumerate(self.imt): + lower, upper = np.percentile(c, (5, 95)) + expected = clip(c, lower, upper) + assert_allclose(result[i], p(expected), type_test="tensor", rtol=1e-7, atol=0) + + def test_ill_sharpness_factor(self): + with self.assertRaises(ValueError): + ClipIntensityPercentiles(upper=95, lower=5, sharpness_factor=0.0) + + def test_ill_lower_percentile(self): + with self.assertRaises(ValueError): + ClipIntensityPercentiles(upper=None, lower=-1) + + def test_ill_upper_percentile(self): + with self.assertRaises(ValueError): + ClipIntensityPercentiles(upper=101, lower=None) + + def test_ill_percentiles(self): + with self.assertRaises(ValueError): + ClipIntensityPercentiles(upper=95, lower=96) + + def test_ill_both_none(self): + with self.assertRaises(ValueError): + ClipIntensityPercentiles(upper=None, lower=None) + + +class TestClipIntensityPercentiles3D(NumpyImageTestCase3D): + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_hard_clipping_two_sided(self, p): + hard_clipper = ClipIntensityPercentiles(upper=95, lower=5) + im = p(self.imt) + result = hard_clipper(im) + lower, upper = np.percentile(self.imt, (5, 95)) + expected = clip(self.imt, lower, upper) + assert_allclose(result, p(expected), type_test="tensor", rtol=1e-7, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_hard_clipping_one_sided_high(self, p): + hard_clipper = ClipIntensityPercentiles(upper=95, lower=None) + im = p(self.imt) + result = hard_clipper(im) + lower, upper = np.percentile(self.imt, (0, 95)) + expected = clip(self.imt, lower, upper) + assert_allclose(result, p(expected), type_test="tensor", rtol=1e-7, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_hard_clipping_one_sided_low(self, p): + hard_clipper = ClipIntensityPercentiles(upper=None, lower=5) + im = p(self.imt) + result = hard_clipper(im) + lower, upper = np.percentile(self.imt, (5, 100)) + expected = clip(self.imt, lower, upper) + assert_allclose(result, p(expected), type_test="tensor", rtol=1e-7, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_soft_clipping_two_sided(self, p): + soft_clipper = ClipIntensityPercentiles(upper=95, lower=5, sharpness_factor=1.0) + im = p(self.imt) + result = soft_clipper(im) + lower, upper = np.percentile(self.imt, (5, 95)) + expected = soft_clip(self.imt, sharpness_factor=1.0, minv=lower, maxv=upper) + # the rtol is set to 1e-6 because the logaddexp function used in softplus is not stable accross torch and numpy + assert_allclose(result, p(expected), type_test="tensor", rtol=1e-6, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_soft_clipping_one_sided_high(self, p): + soft_clipper = ClipIntensityPercentiles(upper=95, lower=None, sharpness_factor=1.0) + im = p(self.imt) + result = soft_clipper(im) + upper = np.percentile(self.imt, 95) + expected = soft_clip(self.imt, sharpness_factor=1.0, minv=None, maxv=upper) + # the rtol is set to 5e-5 because the logaddexp function used in softplus is not stable accross torch and numpy + assert_allclose(result, p(expected), type_test="tensor", rtol=5e-5, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_soft_clipping_one_sided_low(self, p): + soft_clipper = ClipIntensityPercentiles(upper=None, lower=5, sharpness_factor=1.0) + im = p(self.imt) + result = soft_clipper(im) + lower = np.percentile(self.imt, 5) + expected = soft_clip(self.imt, sharpness_factor=1.0, minv=lower, maxv=None) + # the rtol is set to 1e-6 because the logaddexp function used in softplus is not stable accross torch and numpy + assert_allclose(result, p(expected), type_test="tensor", rtol=1e-6, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_channel_wise(self, p): + clipper = ClipIntensityPercentiles(upper=95, lower=5, channel_wise=True) + im = p(self.imt) + result = clipper(im) + for i, c in enumerate(self.imt): + lower, upper = np.percentile(c, (5, 95)) + expected = clip(c, lower, upper) + assert_allclose(result[i], p(expected), type_test="tensor", rtol=1e-7, atol=0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_clip_intensity_percentilesd.py b/tests/test_clip_intensity_percentilesd.py new file mode 100644 index 0000000000..193fa8b487 --- /dev/null +++ b/tests/test_clip_intensity_percentilesd.py @@ -0,0 +1,203 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms import ClipIntensityPercentilesd +from monai.transforms.utils import soft_clip +from monai.transforms.utils_pytorch_numpy_unification import clip +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, NumpyImageTestCase3D, assert_allclose + + +class TestClipIntensityPercentilesd2D(NumpyImageTestCase2D): + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_hard_clipping_two_sided(self, p): + key = "img" + hard_clipper = ClipIntensityPercentilesd(keys=[key], upper=95, lower=5) + im = p(self.imt) + result = hard_clipper({key: im}) + lower, upper = np.percentile(self.imt, (5, 95)) + expected = clip(self.imt, lower, upper) + assert_allclose(result[key], p(expected), type_test="tensor", rtol=1e-7, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_hard_clipping_one_sided_high(self, p): + key = "img" + hard_clipper = ClipIntensityPercentilesd(keys=[key], upper=95, lower=None) + im = p(self.imt) + result = hard_clipper({key: im}) + lower, upper = np.percentile(self.imt, (0, 95)) + expected = clip(self.imt, lower, upper) + assert_allclose(result[key], p(expected), type_test="tensor", rtol=1e-7, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_hard_clipping_one_sided_low(self, p): + key = "img" + hard_clipper = ClipIntensityPercentilesd(keys=[key], upper=None, lower=5) + im = p(self.imt) + result = hard_clipper({key: im}) + lower, upper = np.percentile(self.imt, (5, 100)) + expected = clip(self.imt, lower, upper) + assert_allclose(result[key], p(expected), type_test="tensor", rtol=1e-7, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_soft_clipping_two_sided(self, p): + key = "img" + soft_clipper = ClipIntensityPercentilesd(keys=[key], upper=95, lower=5, sharpness_factor=1.0) + im = p(self.imt) + result = soft_clipper({key: im}) + lower, upper = np.percentile(self.imt, (5, 95)) + expected = soft_clip(self.imt, sharpness_factor=1.0, minv=lower, maxv=upper) + # the rtol is set to 1e-6 because the logaddexp function used in softplus is not stable accross torch and numpy + assert_allclose(result[key], p(expected), type_test="tensor", rtol=1e-6, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_soft_clipping_one_sided_high(self, p): + key = "img" + soft_clipper = ClipIntensityPercentilesd(keys=[key], upper=95, lower=None, sharpness_factor=1.0) + im = p(self.imt) + result = soft_clipper({key: im}) + upper = np.percentile(self.imt, 95) + expected = soft_clip(self.imt, sharpness_factor=1.0, minv=None, maxv=upper) + # the rtol is set to 5e-5 because the logaddexp function used in softplus is not stable accross torch and numpy + assert_allclose(result[key], p(expected), type_test="tensor", rtol=5e-5, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_soft_clipping_one_sided_low(self, p): + key = "img" + soft_clipper = ClipIntensityPercentilesd(keys=[key], upper=None, lower=5, sharpness_factor=1.0) + im = p(self.imt) + result = soft_clipper({key: im}) + lower = np.percentile(self.imt, 5) + expected = soft_clip(self.imt, sharpness_factor=1.0, minv=lower, maxv=None) + # the rtol is set to 1e-6 because the logaddexp function used in softplus is not stable accross torch and numpy + assert_allclose(result[key], p(expected), type_test="tensor", rtol=1e-6, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_channel_wise(self, p): + key = "img" + clipper = ClipIntensityPercentilesd(keys=[key], upper=95, lower=5, channel_wise=True) + im = p(self.imt) + result = clipper({key: im}) + for i, c in enumerate(self.imt): + lower, upper = np.percentile(c, (5, 95)) + expected = clip(c, lower, upper) + assert_allclose(result[key][i], p(expected), type_test="tensor", rtol=1e-7, atol=0) + + def test_ill_sharpness_factor(self): + key = "img" + with self.assertRaises(ValueError): + ClipIntensityPercentilesd(keys=[key], upper=95, lower=5, sharpness_factor=0.0) + + def test_ill_lower_percentile(self): + key = "img" + with self.assertRaises(ValueError): + ClipIntensityPercentilesd(keys=[key], upper=None, lower=-1) + + def test_ill_upper_percentile(self): + key = "img" + with self.assertRaises(ValueError): + ClipIntensityPercentilesd(keys=[key], upper=101, lower=None) + + def test_ill_percentiles(self): + key = "img" + with self.assertRaises(ValueError): + ClipIntensityPercentilesd(keys=[key], upper=95, lower=96) + + def test_ill_both_none(self): + key = "img" + with self.assertRaises(ValueError): + ClipIntensityPercentilesd(keys=[key], upper=None, lower=None) + + +class TestClipIntensityPercentilesd3D(NumpyImageTestCase3D): + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_hard_clipping_two_sided(self, p): + key = "img" + hard_clipper = ClipIntensityPercentilesd(keys=[key], upper=95, lower=5) + im = p(self.imt) + result = hard_clipper({key: im}) + lower, upper = np.percentile(self.imt, (5, 95)) + expected = clip(self.imt, lower, upper) + assert_allclose(result[key], p(expected), type_test="tensor", rtol=1e-7, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_hard_clipping_one_sided_high(self, p): + key = "img" + hard_clipper = ClipIntensityPercentilesd(keys=[key], upper=95, lower=None) + im = p(self.imt) + result = hard_clipper({key: im}) + lower, upper = np.percentile(self.imt, (0, 95)) + expected = clip(self.imt, lower, upper) + assert_allclose(result[key], p(expected), type_test="tensor", rtol=1e-7, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_hard_clipping_one_sided_low(self, p): + key = "img" + hard_clipper = ClipIntensityPercentilesd(keys=[key], upper=None, lower=5) + im = p(self.imt) + result = hard_clipper({key: im}) + lower, upper = np.percentile(self.imt, (5, 100)) + expected = clip(self.imt, lower, upper) + assert_allclose(result[key], p(expected), type_test="tensor", rtol=1e-7, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_soft_clipping_two_sided(self, p): + key = "img" + soft_clipper = ClipIntensityPercentilesd(keys=[key], upper=95, lower=5, sharpness_factor=1.0) + im = p(self.imt) + result = soft_clipper({key: im}) + lower, upper = np.percentile(self.imt, (5, 95)) + expected = soft_clip(self.imt, sharpness_factor=1.0, minv=lower, maxv=upper) + # the rtol is set to 1e-6 because the logaddexp function used in softplus is not stable accross torch and numpy + assert_allclose(result[key], p(expected), type_test="tensor", rtol=1e-6, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_soft_clipping_one_sided_high(self, p): + key = "img" + soft_clipper = ClipIntensityPercentilesd(keys=[key], upper=95, lower=None, sharpness_factor=1.0) + im = p(self.imt) + result = soft_clipper({key: im}) + upper = np.percentile(self.imt, 95) + expected = soft_clip(self.imt, sharpness_factor=1.0, minv=None, maxv=upper) + # the rtol is set to 5e-5 because the logaddexp function used in softplus is not stable accross torch and numpy + assert_allclose(result[key], p(expected), type_test="tensor", rtol=5e-5, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_soft_clipping_one_sided_low(self, p): + key = "img" + soft_clipper = ClipIntensityPercentilesd(keys=[key], upper=None, lower=5, sharpness_factor=1.0) + im = p(self.imt) + result = soft_clipper({key: im}) + lower = np.percentile(self.imt, 5) + expected = soft_clip(self.imt, sharpness_factor=1.0, minv=lower, maxv=None) + # the rtol is set to 1e-6 because the logaddexp function used in softplus is not stable accross torch and numpy + assert_allclose(result[key], p(expected), type_test="tensor", rtol=1e-6, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_channel_wise(self, p): + key = "img" + clipper = ClipIntensityPercentilesd(keys=[key], upper=95, lower=5, channel_wise=True) + im = p(self.imt) + result = clipper({key: im}) + for i, c in enumerate(self.imt): + lower, upper = np.percentile(c, (5, 95)) + expected = clip(c, lower, upper) + assert_allclose(result[key][i], p(expected), type_test="tensor", rtol=1e-7, atol=0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_soft_clip.py b/tests/test_soft_clip.py new file mode 100644 index 0000000000..de5122e982 --- /dev/null +++ b/tests/test_soft_clip.py @@ -0,0 +1,125 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import numpy as np +import torch +from parameterized import parameterized + +from monai.transforms.utils import soft_clip + +TEST_CASES = [ + [ + {"minv": 2, "maxv": 8, "sharpness_factor": 10}, + { + "input": torch.arange(10).float(), + "clipped": torch.tensor([2.0000, 2.0000, 2.0693, 3.0000, 4.0000, 5.0000, 6.0000, 7.0000, 7.9307, 8.0000]), + }, + ], + [ + {"minv": 2, "maxv": None, "sharpness_factor": 10}, + { + "input": torch.arange(10).float(), + "clipped": torch.tensor([2.0000, 2.0000, 2.0693, 3.0000, 4.0000, 5.0000, 6.0000, 7.0000, 8.0000, 9.0000]), + }, + ], + [ + {"minv": None, "maxv": 7, "sharpness_factor": 10}, + { + "input": torch.arange(10).float(), + "clipped": torch.tensor([0.0000, 1.0000, 2.0000, 3.0000, 4.0000, 5.0000, 6.0000, 6.9307, 7.0000, 7.0000]), + }, + ], + [ + {"minv": 2, "maxv": 8, "sharpness_factor": 1.0}, + { + "input": torch.arange(10).float(), + "clipped": torch.tensor([2.1266, 2.3124, 2.6907, 3.3065, 4.1088, 5.0000, 5.8912, 6.6935, 7.3093, 7.6877]), + }, + ], + [ + {"minv": 2, "maxv": 8, "sharpness_factor": 3.0}, + { + "input": torch.arange(10).float(), + "clipped": torch.tensor([2.0008, 2.0162, 2.2310, 3.0162, 4.0008, 5.0000, 5.9992, 6.9838, 7.7690, 7.9838]), + }, + ], + [ + {"minv": 2, "maxv": 8, "sharpness_factor": 5.0}, + { + "input": torch.arange(10).float(), + "clipped": torch.tensor([2.0000, 2.0013, 2.1386, 3.0013, 4.0000, 5.0000, 6.0000, 6.9987, 7.8614, 7.9987]), + }, + ], + [ + {"minv": 2, "maxv": 8, "sharpness_factor": 10}, + { + "input": np.arange(10).astype(np.float32), + "clipped": np.array([2.0000, 2.0000, 2.0693, 3.0000, 4.0000, 5.0000, 6.0000, 7.0000, 7.9307, 8.0000]), + }, + ], + [ + {"minv": 2, "maxv": None, "sharpness_factor": 10}, + { + "input": np.arange(10).astype(float), + "clipped": np.array([2.0000, 2.0000, 2.0693, 3.0000, 4.0000, 5.0000, 6.0000, 7.0000, 8.0000, 9.0000]), + }, + ], + [ + {"minv": None, "maxv": 7, "sharpness_factor": 10}, + { + "input": np.arange(10).astype(float), + "clipped": np.array([0.0000, 1.0000, 2.0000, 3.0000, 4.0000, 5.0000, 6.0000, 6.9307, 7.0000, 7.0000]), + }, + ], + [ + {"minv": 2, "maxv": 8, "sharpness_factor": 1.0}, + { + "input": np.arange(10).astype(float), + "clipped": np.array([2.1266, 2.3124, 2.6907, 3.3065, 4.1088, 5.0000, 5.8912, 6.6935, 7.3093, 7.6877]), + }, + ], + [ + {"minv": 2, "maxv": 8, "sharpness_factor": 3.0}, + { + "input": np.arange(10).astype(float), + "clipped": np.array([2.0008, 2.0162, 2.2310, 3.0162, 4.0008, 5.0000, 5.9992, 6.9838, 7.7690, 7.9838]), + }, + ], + [ + {"minv": 2, "maxv": 8, "sharpness_factor": 5.0}, + { + "input": np.arange(10).astype(float), + "clipped": np.array([2.0000, 2.0013, 2.1386, 3.0013, 4.0000, 5.0000, 6.0000, 6.9987, 7.8614, 7.9987]), + }, + ], +] + + +class TestSoftClip(unittest.TestCase): + + @parameterized.expand(TEST_CASES) + def test_result(self, input_param, input_data): + outputs = soft_clip(input_data["input"], **input_param) + expected_val = input_data["clipped"] + if isinstance(outputs, torch.Tensor): + np.testing.assert_allclose( + outputs.detach().cpu().numpy(), expected_val.detach().cpu().numpy(), atol=1e-4, rtol=1e-4 + ) + else: + np.testing.assert_allclose(outputs, expected_val, atol=1e-4, rtol=1e-4) + + +if __name__ == "__main__": + unittest.main() From 87152d106557bdc37de0ee923be4e9a25c4c003c Mon Sep 17 00:00:00 2001 From: YunLiu <55491388+KumoLiu@users.noreply.github.com> Date: Mon, 8 Apr 2024 12:51:23 +0800 Subject: [PATCH 32/35] Fix typo in `SSIMMetric` (#7612) Fixes #7610 ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> --- monai/metrics/regression.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monai/metrics/regression.py b/monai/metrics/regression.py index 9d29654ee3..4c8b8aa71b 100644 --- a/monai/metrics/regression.py +++ b/monai/metrics/regression.py @@ -303,7 +303,7 @@ def _compute_metric(self, y_pred: torch.Tensor, y: torch.Tensor) -> torch.Tensor if self.spatial_dims == 3 and dims != 5: raise ValueError( - f"y_pred should have 4 dimensions (batch, channel, height, width, depth) when using {self.spatial_dims}" + f"y_pred should have 5 dimensions (batch, channel, height, width, depth) when using {self.spatial_dims}" f" spatial dimensions, got {dims}." ) From e9a5bfe4693ce490c75616a97eb0812e1828539b Mon Sep 17 00:00:00 2001 From: monai-bot <64792179+monai-bot@users.noreply.github.com> Date: Wed, 10 Apr 2024 04:03:54 +0100 Subject: [PATCH 33/35] auto updates (#7614) Signed-off-by: monai-bot Signed-off-by: monai-bot --- tests/test_clip_intensity_percentilesd.py | 2 ++ tests/test_regularization.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/tests/test_clip_intensity_percentilesd.py b/tests/test_clip_intensity_percentilesd.py index 193fa8b487..7e00ef09de 100644 --- a/tests/test_clip_intensity_percentilesd.py +++ b/tests/test_clip_intensity_percentilesd.py @@ -23,6 +23,7 @@ class TestClipIntensityPercentilesd2D(NumpyImageTestCase2D): + @parameterized.expand([[p] for p in TEST_NDARRAYS]) def test_hard_clipping_two_sided(self, p): key = "img" @@ -124,6 +125,7 @@ def test_ill_both_none(self): class TestClipIntensityPercentilesd3D(NumpyImageTestCase3D): + @parameterized.expand([[p] for p in TEST_NDARRAYS]) def test_hard_clipping_two_sided(self, p): key = "img" diff --git a/tests/test_regularization.py b/tests/test_regularization.py index c6f727cb54..4df60b9808 100644 --- a/tests/test_regularization.py +++ b/tests/test_regularization.py @@ -20,6 +20,7 @@ class TestMixup(unittest.TestCase): + def setUp(self) -> None: set_determinism(seed=0) @@ -59,6 +60,7 @@ def test_mixupd(self): class TestCutMix(unittest.TestCase): + def setUp(self) -> None: set_determinism(seed=0) @@ -89,6 +91,7 @@ def test_cutmixd(self): class TestCutOut(unittest.TestCase): + def setUp(self) -> None: set_determinism(seed=0) From 54a6991c7e319c30751d017da670391eb8551179 Mon Sep 17 00:00:00 2001 From: YunLiu <55491388+KumoLiu@users.noreply.github.com> Date: Thu, 11 Apr 2024 18:26:50 +0800 Subject: [PATCH 34/35] Fix test error in `test_soft_clipping_one_sided_high` (#7624) Fixes #7616 ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. --------- Signed-off-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- tests/test_clip_intensity_percentiles.py | 60 +++++++++++------------ tests/test_clip_intensity_percentilesd.py | 60 +++++++++++------------ 2 files changed, 60 insertions(+), 60 deletions(-) diff --git a/tests/test_clip_intensity_percentiles.py b/tests/test_clip_intensity_percentiles.py index f5fe07a323..82471e25ce 100644 --- a/tests/test_clip_intensity_percentiles.py +++ b/tests/test_clip_intensity_percentiles.py @@ -12,12 +12,12 @@ import unittest -import numpy as np +import torch from parameterized import parameterized from monai.transforms import ClipIntensityPercentiles from monai.transforms.utils import soft_clip -from monai.transforms.utils_pytorch_numpy_unification import clip +from monai.transforms.utils_pytorch_numpy_unification import clip, percentile from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, NumpyImageTestCase3D, assert_allclose @@ -28,8 +28,8 @@ def test_hard_clipping_two_sided(self, p): hard_clipper = ClipIntensityPercentiles(upper=95, lower=5) im = p(self.imt) result = hard_clipper(im) - lower, upper = np.percentile(self.imt, (5, 95)) - expected = clip(self.imt, lower, upper) + lower, upper = percentile(im, (5, 95)) + expected = clip(im, lower, upper) assert_allclose(result, p(expected), type_test="tensor", rtol=1e-7, atol=0) @parameterized.expand([[p] for p in TEST_NDARRAYS]) @@ -37,8 +37,8 @@ def test_hard_clipping_one_sided_high(self, p): hard_clipper = ClipIntensityPercentiles(upper=95, lower=None) im = p(self.imt) result = hard_clipper(im) - lower, upper = np.percentile(self.imt, (0, 95)) - expected = clip(self.imt, lower, upper) + lower, upper = percentile(im, (0, 95)) + expected = clip(im, lower, upper) assert_allclose(result, p(expected), type_test="tensor", rtol=1e-7, atol=0) @parameterized.expand([[p] for p in TEST_NDARRAYS]) @@ -46,8 +46,8 @@ def test_hard_clipping_one_sided_low(self, p): hard_clipper = ClipIntensityPercentiles(upper=None, lower=5) im = p(self.imt) result = hard_clipper(im) - lower, upper = np.percentile(self.imt, (5, 100)) - expected = clip(self.imt, lower, upper) + lower, upper = percentile(im, (5, 100)) + expected = clip(im, lower, upper) assert_allclose(result, p(expected), type_test="tensor", rtol=1e-7, atol=0) @parameterized.expand([[p] for p in TEST_NDARRAYS]) @@ -55,8 +55,8 @@ def test_soft_clipping_two_sided(self, p): soft_clipper = ClipIntensityPercentiles(upper=95, lower=5, sharpness_factor=1.0) im = p(self.imt) result = soft_clipper(im) - lower, upper = np.percentile(self.imt, (5, 95)) - expected = soft_clip(self.imt, sharpness_factor=1.0, minv=lower, maxv=upper) + lower, upper = percentile(im, (5, 95)) + expected = soft_clip(im, sharpness_factor=1.0, minv=lower, maxv=upper, dtype=torch.float32) # the rtol is set to 1e-6 because the logaddexp function used in softplus is not stable accross torch and numpy assert_allclose(result, p(expected), type_test="tensor", rtol=1e-6, atol=0) @@ -65,8 +65,8 @@ def test_soft_clipping_one_sided_high(self, p): soft_clipper = ClipIntensityPercentiles(upper=95, lower=None, sharpness_factor=1.0) im = p(self.imt) result = soft_clipper(im) - upper = np.percentile(self.imt, 95) - expected = soft_clip(self.imt, sharpness_factor=1.0, minv=None, maxv=upper) + upper = percentile(im, 95) + expected = soft_clip(im, sharpness_factor=1.0, minv=None, maxv=upper, dtype=torch.float32) # the rtol is set to 5e-5 because the logaddexp function used in softplus is not stable accross torch and numpy assert_allclose(result, p(expected), type_test="tensor", rtol=5e-5, atol=0) @@ -75,8 +75,8 @@ def test_soft_clipping_one_sided_low(self, p): soft_clipper = ClipIntensityPercentiles(upper=None, lower=5, sharpness_factor=1.0) im = p(self.imt) result = soft_clipper(im) - lower = np.percentile(self.imt, 5) - expected = soft_clip(self.imt, sharpness_factor=1.0, minv=lower, maxv=None) + lower = percentile(im, 5) + expected = soft_clip(im, sharpness_factor=1.0, minv=lower, maxv=None, dtype=torch.float32) # the rtol is set to 1e-6 because the logaddexp function used in softplus is not stable accross torch and numpy assert_allclose(result, p(expected), type_test="tensor", rtol=1e-6, atol=0) @@ -85,8 +85,8 @@ def test_channel_wise(self, p): clipper = ClipIntensityPercentiles(upper=95, lower=5, channel_wise=True) im = p(self.imt) result = clipper(im) - for i, c in enumerate(self.imt): - lower, upper = np.percentile(c, (5, 95)) + for i, c in enumerate(im): + lower, upper = percentile(c, (5, 95)) expected = clip(c, lower, upper) assert_allclose(result[i], p(expected), type_test="tensor", rtol=1e-7, atol=0) @@ -118,8 +118,8 @@ def test_hard_clipping_two_sided(self, p): hard_clipper = ClipIntensityPercentiles(upper=95, lower=5) im = p(self.imt) result = hard_clipper(im) - lower, upper = np.percentile(self.imt, (5, 95)) - expected = clip(self.imt, lower, upper) + lower, upper = percentile(im, (5, 95)) + expected = clip(im, lower, upper) assert_allclose(result, p(expected), type_test="tensor", rtol=1e-7, atol=0) @parameterized.expand([[p] for p in TEST_NDARRAYS]) @@ -127,8 +127,8 @@ def test_hard_clipping_one_sided_high(self, p): hard_clipper = ClipIntensityPercentiles(upper=95, lower=None) im = p(self.imt) result = hard_clipper(im) - lower, upper = np.percentile(self.imt, (0, 95)) - expected = clip(self.imt, lower, upper) + lower, upper = percentile(im, (0, 95)) + expected = clip(im, lower, upper) assert_allclose(result, p(expected), type_test="tensor", rtol=1e-7, atol=0) @parameterized.expand([[p] for p in TEST_NDARRAYS]) @@ -136,8 +136,8 @@ def test_hard_clipping_one_sided_low(self, p): hard_clipper = ClipIntensityPercentiles(upper=None, lower=5) im = p(self.imt) result = hard_clipper(im) - lower, upper = np.percentile(self.imt, (5, 100)) - expected = clip(self.imt, lower, upper) + lower, upper = percentile(im, (5, 100)) + expected = clip(im, lower, upper) assert_allclose(result, p(expected), type_test="tensor", rtol=1e-7, atol=0) @parameterized.expand([[p] for p in TEST_NDARRAYS]) @@ -145,8 +145,8 @@ def test_soft_clipping_two_sided(self, p): soft_clipper = ClipIntensityPercentiles(upper=95, lower=5, sharpness_factor=1.0) im = p(self.imt) result = soft_clipper(im) - lower, upper = np.percentile(self.imt, (5, 95)) - expected = soft_clip(self.imt, sharpness_factor=1.0, minv=lower, maxv=upper) + lower, upper = percentile(im, (5, 95)) + expected = soft_clip(im, sharpness_factor=1.0, minv=lower, maxv=upper, dtype=torch.float32) # the rtol is set to 1e-6 because the logaddexp function used in softplus is not stable accross torch and numpy assert_allclose(result, p(expected), type_test="tensor", rtol=1e-6, atol=0) @@ -155,8 +155,8 @@ def test_soft_clipping_one_sided_high(self, p): soft_clipper = ClipIntensityPercentiles(upper=95, lower=None, sharpness_factor=1.0) im = p(self.imt) result = soft_clipper(im) - upper = np.percentile(self.imt, 95) - expected = soft_clip(self.imt, sharpness_factor=1.0, minv=None, maxv=upper) + upper = percentile(im, 95) + expected = soft_clip(im, sharpness_factor=1.0, minv=None, maxv=upper, dtype=torch.float32) # the rtol is set to 5e-5 because the logaddexp function used in softplus is not stable accross torch and numpy assert_allclose(result, p(expected), type_test="tensor", rtol=5e-5, atol=0) @@ -165,8 +165,8 @@ def test_soft_clipping_one_sided_low(self, p): soft_clipper = ClipIntensityPercentiles(upper=None, lower=5, sharpness_factor=1.0) im = p(self.imt) result = soft_clipper(im) - lower = np.percentile(self.imt, 5) - expected = soft_clip(self.imt, sharpness_factor=1.0, minv=lower, maxv=None) + lower = percentile(im, 5) + expected = soft_clip(im, sharpness_factor=1.0, minv=lower, maxv=None, dtype=torch.float32) # the rtol is set to 1e-6 because the logaddexp function used in softplus is not stable accross torch and numpy assert_allclose(result, p(expected), type_test="tensor", rtol=1e-6, atol=0) @@ -175,8 +175,8 @@ def test_channel_wise(self, p): clipper = ClipIntensityPercentiles(upper=95, lower=5, channel_wise=True) im = p(self.imt) result = clipper(im) - for i, c in enumerate(self.imt): - lower, upper = np.percentile(c, (5, 95)) + for i, c in enumerate(im): + lower, upper = percentile(c, (5, 95)) expected = clip(c, lower, upper) assert_allclose(result[i], p(expected), type_test="tensor", rtol=1e-7, atol=0) diff --git a/tests/test_clip_intensity_percentilesd.py b/tests/test_clip_intensity_percentilesd.py index 7e00ef09de..2b49383182 100644 --- a/tests/test_clip_intensity_percentilesd.py +++ b/tests/test_clip_intensity_percentilesd.py @@ -13,12 +13,12 @@ import unittest -import numpy as np +import torch from parameterized import parameterized from monai.transforms import ClipIntensityPercentilesd from monai.transforms.utils import soft_clip -from monai.transforms.utils_pytorch_numpy_unification import clip +from monai.transforms.utils_pytorch_numpy_unification import clip, percentile from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, NumpyImageTestCase3D, assert_allclose @@ -30,8 +30,8 @@ def test_hard_clipping_two_sided(self, p): hard_clipper = ClipIntensityPercentilesd(keys=[key], upper=95, lower=5) im = p(self.imt) result = hard_clipper({key: im}) - lower, upper = np.percentile(self.imt, (5, 95)) - expected = clip(self.imt, lower, upper) + lower, upper = percentile(im, (5, 95)) + expected = clip(im, lower, upper) assert_allclose(result[key], p(expected), type_test="tensor", rtol=1e-7, atol=0) @parameterized.expand([[p] for p in TEST_NDARRAYS]) @@ -40,8 +40,8 @@ def test_hard_clipping_one_sided_high(self, p): hard_clipper = ClipIntensityPercentilesd(keys=[key], upper=95, lower=None) im = p(self.imt) result = hard_clipper({key: im}) - lower, upper = np.percentile(self.imt, (0, 95)) - expected = clip(self.imt, lower, upper) + lower, upper = percentile(im, (0, 95)) + expected = clip(im, lower, upper) assert_allclose(result[key], p(expected), type_test="tensor", rtol=1e-7, atol=0) @parameterized.expand([[p] for p in TEST_NDARRAYS]) @@ -50,8 +50,8 @@ def test_hard_clipping_one_sided_low(self, p): hard_clipper = ClipIntensityPercentilesd(keys=[key], upper=None, lower=5) im = p(self.imt) result = hard_clipper({key: im}) - lower, upper = np.percentile(self.imt, (5, 100)) - expected = clip(self.imt, lower, upper) + lower, upper = percentile(im, (5, 100)) + expected = clip(im, lower, upper) assert_allclose(result[key], p(expected), type_test="tensor", rtol=1e-7, atol=0) @parameterized.expand([[p] for p in TEST_NDARRAYS]) @@ -60,8 +60,8 @@ def test_soft_clipping_two_sided(self, p): soft_clipper = ClipIntensityPercentilesd(keys=[key], upper=95, lower=5, sharpness_factor=1.0) im = p(self.imt) result = soft_clipper({key: im}) - lower, upper = np.percentile(self.imt, (5, 95)) - expected = soft_clip(self.imt, sharpness_factor=1.0, minv=lower, maxv=upper) + lower, upper = percentile(im, (5, 95)) + expected = soft_clip(im, sharpness_factor=1.0, minv=lower, maxv=upper, dtype=torch.float32) # the rtol is set to 1e-6 because the logaddexp function used in softplus is not stable accross torch and numpy assert_allclose(result[key], p(expected), type_test="tensor", rtol=1e-6, atol=0) @@ -71,8 +71,8 @@ def test_soft_clipping_one_sided_high(self, p): soft_clipper = ClipIntensityPercentilesd(keys=[key], upper=95, lower=None, sharpness_factor=1.0) im = p(self.imt) result = soft_clipper({key: im}) - upper = np.percentile(self.imt, 95) - expected = soft_clip(self.imt, sharpness_factor=1.0, minv=None, maxv=upper) + upper = percentile(im, 95) + expected = soft_clip(im, sharpness_factor=1.0, minv=None, maxv=upper, dtype=torch.float32) # the rtol is set to 5e-5 because the logaddexp function used in softplus is not stable accross torch and numpy assert_allclose(result[key], p(expected), type_test="tensor", rtol=5e-5, atol=0) @@ -82,8 +82,8 @@ def test_soft_clipping_one_sided_low(self, p): soft_clipper = ClipIntensityPercentilesd(keys=[key], upper=None, lower=5, sharpness_factor=1.0) im = p(self.imt) result = soft_clipper({key: im}) - lower = np.percentile(self.imt, 5) - expected = soft_clip(self.imt, sharpness_factor=1.0, minv=lower, maxv=None) + lower = percentile(im, 5) + expected = soft_clip(im, sharpness_factor=1.0, minv=lower, maxv=None, dtype=torch.float32) # the rtol is set to 1e-6 because the logaddexp function used in softplus is not stable accross torch and numpy assert_allclose(result[key], p(expected), type_test="tensor", rtol=1e-6, atol=0) @@ -93,8 +93,8 @@ def test_channel_wise(self, p): clipper = ClipIntensityPercentilesd(keys=[key], upper=95, lower=5, channel_wise=True) im = p(self.imt) result = clipper({key: im}) - for i, c in enumerate(self.imt): - lower, upper = np.percentile(c, (5, 95)) + for i, c in enumerate(im): + lower, upper = percentile(c, (5, 95)) expected = clip(c, lower, upper) assert_allclose(result[key][i], p(expected), type_test="tensor", rtol=1e-7, atol=0) @@ -132,8 +132,8 @@ def test_hard_clipping_two_sided(self, p): hard_clipper = ClipIntensityPercentilesd(keys=[key], upper=95, lower=5) im = p(self.imt) result = hard_clipper({key: im}) - lower, upper = np.percentile(self.imt, (5, 95)) - expected = clip(self.imt, lower, upper) + lower, upper = percentile(im, (5, 95)) + expected = clip(im, lower, upper) assert_allclose(result[key], p(expected), type_test="tensor", rtol=1e-7, atol=0) @parameterized.expand([[p] for p in TEST_NDARRAYS]) @@ -142,8 +142,8 @@ def test_hard_clipping_one_sided_high(self, p): hard_clipper = ClipIntensityPercentilesd(keys=[key], upper=95, lower=None) im = p(self.imt) result = hard_clipper({key: im}) - lower, upper = np.percentile(self.imt, (0, 95)) - expected = clip(self.imt, lower, upper) + lower, upper = percentile(im, (0, 95)) + expected = clip(im, lower, upper) assert_allclose(result[key], p(expected), type_test="tensor", rtol=1e-7, atol=0) @parameterized.expand([[p] for p in TEST_NDARRAYS]) @@ -152,8 +152,8 @@ def test_hard_clipping_one_sided_low(self, p): hard_clipper = ClipIntensityPercentilesd(keys=[key], upper=None, lower=5) im = p(self.imt) result = hard_clipper({key: im}) - lower, upper = np.percentile(self.imt, (5, 100)) - expected = clip(self.imt, lower, upper) + lower, upper = percentile(im, (5, 100)) + expected = clip(im, lower, upper) assert_allclose(result[key], p(expected), type_test="tensor", rtol=1e-7, atol=0) @parameterized.expand([[p] for p in TEST_NDARRAYS]) @@ -162,8 +162,8 @@ def test_soft_clipping_two_sided(self, p): soft_clipper = ClipIntensityPercentilesd(keys=[key], upper=95, lower=5, sharpness_factor=1.0) im = p(self.imt) result = soft_clipper({key: im}) - lower, upper = np.percentile(self.imt, (5, 95)) - expected = soft_clip(self.imt, sharpness_factor=1.0, minv=lower, maxv=upper) + lower, upper = percentile(im, (5, 95)) + expected = soft_clip(im, sharpness_factor=1.0, minv=lower, maxv=upper, dtype=torch.float32) # the rtol is set to 1e-6 because the logaddexp function used in softplus is not stable accross torch and numpy assert_allclose(result[key], p(expected), type_test="tensor", rtol=1e-6, atol=0) @@ -173,8 +173,8 @@ def test_soft_clipping_one_sided_high(self, p): soft_clipper = ClipIntensityPercentilesd(keys=[key], upper=95, lower=None, sharpness_factor=1.0) im = p(self.imt) result = soft_clipper({key: im}) - upper = np.percentile(self.imt, 95) - expected = soft_clip(self.imt, sharpness_factor=1.0, minv=None, maxv=upper) + upper = percentile(im, 95) + expected = soft_clip(im, sharpness_factor=1.0, minv=None, maxv=upper, dtype=torch.float32) # the rtol is set to 5e-5 because the logaddexp function used in softplus is not stable accross torch and numpy assert_allclose(result[key], p(expected), type_test="tensor", rtol=5e-5, atol=0) @@ -184,8 +184,8 @@ def test_soft_clipping_one_sided_low(self, p): soft_clipper = ClipIntensityPercentilesd(keys=[key], upper=None, lower=5, sharpness_factor=1.0) im = p(self.imt) result = soft_clipper({key: im}) - lower = np.percentile(self.imt, 5) - expected = soft_clip(self.imt, sharpness_factor=1.0, minv=lower, maxv=None) + lower = percentile(im, 5) + expected = soft_clip(im, sharpness_factor=1.0, minv=lower, maxv=None, dtype=torch.float32) # the rtol is set to 1e-6 because the logaddexp function used in softplus is not stable accross torch and numpy assert_allclose(result[key], p(expected), type_test="tensor", rtol=1e-6, atol=0) @@ -195,8 +195,8 @@ def test_channel_wise(self, p): clipper = ClipIntensityPercentilesd(keys=[key], upper=95, lower=5, channel_wise=True) im = p(self.imt) result = clipper({key: im}) - for i, c in enumerate(self.imt): - lower, upper = np.percentile(c, (5, 95)) + for i, c in enumerate(im): + lower, upper = percentile(c, (5, 95)) expected = clip(c, lower, upper) assert_allclose(result[key][i], p(expected), type_test="tensor", rtol=1e-7, atol=0) From ac7007431a8ff1334222ecdc8023c07c4e93068b Mon Sep 17 00:00:00 2001 From: Ben Murray Date: Thu, 11 Apr 2024 13:59:06 +0100 Subject: [PATCH 35/35] flip refactor for geometry Signed-off-by: Ben Murray --- monai/transforms/lazy/utils.py | 43 +++++++++++++++++++++++++- monai/transforms/spatial/functional.py | 31 +++++++++++++++++-- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/monai/transforms/lazy/utils.py b/monai/transforms/lazy/utils.py index 359559e319..3819e6bb10 100644 --- a/monai/transforms/lazy/utils.py +++ b/monai/transforms/lazy/utils.py @@ -18,9 +18,11 @@ import monai from monai.config import NdarrayOrTensor +from monai.data.meta_obj import get_track_meta, MetaObj +from monai.data.meta_tensor import MetaTensor from monai.data.utils import AFFINE_TOL from monai.transforms.utils_pytorch_numpy_unification import allclose -from monai.utils import LazyAttr, convert_to_numpy, convert_to_tensor, look_up_option +from monai.utils import LazyAttr, MetaKeys, convert_to_numpy, convert_to_tensor, look_up_option __all__ = ["resample", "combine_transforms"] @@ -227,3 +229,42 @@ def resample(data: torch.Tensor, matrix: NdarrayOrTensor, kwargs: dict | None = resampler.lazy = False # resampler is a lazytransform with resampler.trace_transform(False): # don't track this transform in `img` return resampler(img=img, **call_kwargs) + + +def apply_to_geometry( + data: torch.Tensor, + meta_info: dict | MetaObj, +): + """ + Apply an affine geometric transform or deformation field to geometry. + At present this is limited to the transformation of points. + + The points must be provided as a tensor and must be compatible with a homogeneous + transform. This means that: + - 2D points are of the form (x, y, 1) + - 3D points are of the form (x, y, z, 1) + + The affine transform or deformation field is applied to the the points and a tensor of + the same shape as the input tensor is returned. + + Args: + data: the tensor of points to be transformed. + meta_info: the metadata containing the affine transformation + """ + + if not isinstance(data, (torch.Tensor, MetaTensor)): + raise TypeError(f"data {type(data)} must be a torch.Tensor or MetaTensor") + + data = convert_to_tensor(data, track_meta=get_track_meta()) + + transform = meta_info.meta[MetaKeys.AFFINE] + + if transform.dtype != data.dtype: + transform = transform.to(data.dtype) + + if data.shape[1] != transform.shape[0]: + raise ValueError(f"second element of data.shape {data.shape} must match transform shape {transform.shape}") + + result = torch.matmul(data, transform.T) + + return result diff --git a/monai/transforms/spatial/functional.py b/monai/transforms/spatial/functional.py index add4e7f5ea..bbf970d30f 100644 --- a/monai/transforms/spatial/functional.py +++ b/monai/transforms/spatial/functional.py @@ -24,13 +24,14 @@ import monai from monai.config import USE_COMPILED from monai.config.type_definitions import NdarrayOrTensor -from monai.data.meta_obj import get_track_meta +from monai.data.meta_obj import get_track_meta, MetaObj from monai.data.meta_tensor import MetaTensor from monai.data.utils import AFFINE_TOL, compute_shape_offset, to_affine_nd from monai.networks.layers import AffineTransform from monai.transforms.croppad.array import ResizeWithPadOrCrop from monai.transforms.intensity.array import GaussianSmooth from monai.transforms.inverse import TraceableTransform +from monai.transforms.lazy.utils import apply_to_geometry from monai.transforms.utils import create_rotate, create_translate, resolves_modes, scale_affine from monai.transforms.utils_pytorch_numpy_unification import allclose from monai.utils import ( @@ -229,7 +230,7 @@ def orientation(img, original_affine, spatial_ornt, lazy, transform_info) -> tor return out.copy_meta_from(meta_info) if isinstance(out, MetaTensor) else out # type: ignore -def flip(img, sp_axes, lazy, transform_info): +def flip_impl(img, sp_axes, lazy, transform_info): """ Functional implementation of flip. This function operates eagerly or lazily according to @@ -258,6 +259,11 @@ def flip(img, sp_axes, lazy, transform_info): meta_info = TraceableTransform.track_transform_meta( img, sp_size=sp_size, affine=xform, extra_info=extra_info, transform_info=transform_info, lazy=lazy ) + return axes, meta_info + + +def flip_raster(img, sp_axes, lazy, transform_info): + axes, meta_info = flip_impl(img, sp_axes, lazy, transform_info) out = _maybe_new_metatensor(img) if lazy: return out.copy_meta_from(meta_info) if isinstance(out, MetaTensor) else meta_info @@ -265,6 +271,27 @@ def flip(img, sp_axes, lazy, transform_info): return out.copy_meta_from(meta_info) if isinstance(out, MetaTensor) else out +def flip_geom(img, sp_axes, lazy, transform_info): + _, meta_info = flip_impl(img, sp_axes, lazy, transform_info) + out = _maybe_new_metatensor(img) + if lazy: + return out.copy_meta_from(meta_info) if isinstance(out, MetaTensor) else meta_info + out = apply_to_geometry(out, meta_info) + return out.copy_meta_from(meta_info) if isinstance(out, MetaTensor) else out + + +def flip(image, sp_axes, lazy, transform_info): + """ + Flip the tensor / MetaTensor according to `sp_axes`. + """ + + if isinstance(image.MetaTensor): + if image.kind == Kind.RASTER: + return flip_raster(image, sp_axes, lazy, transform_info) + elif image.kind == Kind.GEOM: + return flip_geom(image, sp_axes, lazy, transform_info) + + def resize( img, out_size, mode, align_corners, dtype, input_ndim, anti_aliasing, anti_aliasing_sigma, lazy, transform_info ):