Repository: scikit-image/scikit-image

Issue:
Numerical inaccuracies with the random_walker
## Description

The random walker segmentation code is very useful but behaves sometimes very strangely with respect to numerical results.

See following code (sorry this is slow):

result:

Should be between 0 and 1: [0.000000 ; 1.103701]
## Way to reproduce
```
import numpy as np
from skimage.filters import gaussian
from skimage.segmentation import random_walker

np.random.seed(42)
blob3d = np.random.randn(101,101,101) # 3D image of random noise
blob3ds = gaussian(blob3d,sigma=10.) # makes a Gaussian random field
p05=np.percentile(blob3ds,5.) # Normalize the GRF between 0 and 1
p95=np.percentile(blob3ds,95.)
blob3ds = (blob3ds-p05)/(p95-p05)
blob3ds = np.clip(blob3ds,0,1)

markers3d = np.zeros((101,101,101)) 
markers3d[45:55,45:55,45:55] = 1  # central box
markers3d[:,:,0]=markers3d[:,0,:]=markers3d[0,:,:] = 2 # border of the box
markers3d[100,:,:]=markers3d[:,100,:] = markers3d[:,:,100] = 2 # border of the box cont.

grow3d = random_walker(blob3ds, markers3d, beta=5e4, mode='cg_mg', tol=1e-5, return_full_prob=True)

print("Should be between 0 and 1: [%f ; %f]" % (np.min(grow3d[1]), np.max(grow3d[1])))
```


Discussion:
@JDWarner any ideas about this? Pretty funny to see a probability of 1.1. =D

This code doesn't work for me... I get 

```
Traceback (most recent call last):
  File "issue_2360.py", line 18, in <module>
    grow3d = random_walker(blob3ds, markers3d, beta=5e4, mode='mg_cg', tol=1e-5, return_full_prob=True)
  File ".../skimage/segmentation/random_walker_segmentation.py", line 459, in random_walker
    for Xline in X])
UnboundLocalError: local variable 'X' referenced before assignment
```

This should probably be added as a bug also?!?

@jmetz LOL #2361 #2362 

The correct mode is `'cg_mg'`. 

Thanks for that @jni - I got it to work and can confirm a similar result using the `"cg"` method also:

```
Method cg
Should be between 0 and 1: [0.000000 ; 1.256141]
Method cg_mg
Should be between 0 and 1: [0.000000 ; 1.103701]
```

I also generated output images (maximum projections along the first dimension as the data is 3d) for the two probabilities, for each method: 

![figure_1](https://cloud.githubusercontent.com/assets/1950756/19894160/e5255ee8-a043-11e6-8463-e886f5ad679d.png)
![figure_2](https://cloud.githubusercontent.com/assets/1950756/19894167/eedee90e-a043-11e6-9cae-252b2da148ff.png)
![figure_3](https://cloud.githubusercontent.com/assets/1950756/19894179/f906b952-a043-11e6-8795-ff2055f9c2e1.png)
![figure_4](https://cloud.githubusercontent.com/assets/1950756/19894180/f908b3e2-a043-11e6-9056-ee44d75535ca.png)

So a couple of things; 

a) This isn't due to `cg_mg` only as it also occurs in `cg`
b) It only seems to be affecting the second probability image

Don't know the algorithm well enough to fix it yet but will keep digging.

Looks like a convergence problem, try with tol=1e-7
@hugues-talbot sorry I didn't see your issue earlier, I must have been busy when you opened it ;-).

Yes, the problem seems related to a numerical convergence problem, since decreasing tol solves it, and decreasing the value of beta also solves it. ``tol`` is passed to the linear solver (``scipy.linalg.cg`` for ``cg`` mode, with a multigrid ruge_stuben_solver preconditioner for ``cg_mg``). It would be possible to test whether probabilities are between [0, 1] (with some tolerance) and issue a warning suggesting to change tol or beta if it is not. How would that be?
Yes that would be a perfectly acceptable solution.

On 2 September 2017 at 18:05, Emmanuelle Gouillart <notifications@github.com
> wrote:

> @hugues-talbot <https://github.com/hugues-talbot> sorry I didn't see your
> issue earlier, I must have been busy when you opened it ;-).
>
> Yes, the problem seems related to a numerical convergence problem, since
> decreasing tol solves it, and decreasing the value of beta also solves it.
> tol is passed to the linear solver (scipy.linalg.cg for cg mode, with a
> multigrid ruge_stuben_solver preconditioner for cg_mg). It would be
> possible to test whether probabilities are between [0, 1] (with some
> tolerance) and issue a warning suggesting to change tol or beta if it is
> not. How would that be?
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/scikit-image/scikit-image/issues/2360#issuecomment-326753181>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/ABfDULj3R2nURddZ5qwiJ9J4x7cjiJT0ks5seXyygaJpZM4KkYgU>
> .
>


Resolving patch:
diff --git a/skimage/segmentation/random_walker_segmentation.py b/skimage/segmentation/random_walker_segmentation.py
--- a/skimage/segmentation/random_walker_segmentation.py
+++ b/skimage/segmentation/random_walker_segmentation.py
@@ -264,7 +264,8 @@ def _preprocess(labels):
 
 
 def random_walker(data, labels, beta=130, mode='cg_j', tol=1.e-3, copy=True,
-                  multichannel=False, return_full_prob=False, spacing=None):
+                  multichannel=False, return_full_prob=False, spacing=None,
+                  *, prob_tol=1e-3):
     """Random walker algorithm for segmentation from markers.
 
     Random walker algorithm is implemented for gray-level or multichannel
@@ -310,7 +311,7 @@ def random_walker(data, labels, beta=130, mode='cg_j', tol=1.e-3, copy=True,
           requires that the pyamg module is installed.
 
     tol : float, optional
-        tolerance to achieve when solving the linear system using
+        Tolerance to achieve when solving the linear system using
         the conjugate gradient based modes ('cg', 'cg_j' and 'cg_mg').
     copy : bool, optional
         If copy is False, the `labels` array will be overwritten with
@@ -326,6 +327,9 @@ def random_walker(data, labels, beta=130, mode='cg_j', tol=1.e-3, copy=True,
     spacing : iterable of floats, optional
         Spacing between voxels in each spatial dimension. If `None`, then
         the spacing between pixels/voxels in each dimension is assumed 1.
+    prob_tol : float, optional
+        Tolerance on the resulting probability to be in the interval [0, 1].
+        If the tolerance is not satisfied, a warning is displayed.
 
     Returns
     -------
@@ -485,6 +489,11 @@ def random_walker(data, labels, beta=130, mode='cg_j', tol=1.e-3, copy=True,
     # first at pixel j by anisotropic diffusion.
     X = _solve_linear_system(lap_sparse, B, tol, mode)
 
+    if X.min() < -prob_tol or X.max() > 1 + prob_tol:
+        warn('The probability range is outside [0, 1] given the tolerance '
+             '`prob_tol`. Consider decreasing `beta` and/or decreasing '
+             '`tol`.')
+
     # Build the output according to return_full_prob value
     # Put back labels of isolated seeds
     labels[inds_isolated_seeds] = isolated_values


Test patch:
diff --git a/skimage/segmentation/tests/test_random_walker.py b/skimage/segmentation/tests/test_random_walker.py
--- a/skimage/segmentation/tests/test_random_walker.py
+++ b/skimage/segmentation/tests/test_random_walker.py
@@ -215,7 +215,8 @@ def test_multispectral_2d():
     data, labels = make_2d_syntheticdata(lx, ly)
     data = data[..., np.newaxis].repeat(2, axis=-1)  # Expect identical output
     with expected_warnings(['"cg" mode' + '|' + SCIPY_RANK_WARNING,
-                            NUMPY_MATRIX_WARNING]):
+                            NUMPY_MATRIX_WARNING,
+                            'The probability range is outside']):
         multi_labels = random_walker(data, labels, mode='cg',
                                      multichannel=True)
     assert data[..., 0].shape == labels.shape
@@ -430,10 +431,47 @@ def test_isolated_seeds():
     mask[6, 6] = 1
 
     # Test that no error is raised, and that labels of isolated seeds are OK
-    with expected_warnings([NUMPY_MATRIX_WARNING]):
+    with expected_warnings([NUMPY_MATRIX_WARNING,
+                            'The probability range is outside']):
         res = random_walker(a, mask)
     assert res[1, 1] == 1
-    with expected_warnings([NUMPY_MATRIX_WARNING]):
+    with expected_warnings([NUMPY_MATRIX_WARNING,
+                            'The probability range is outside']):
         res = random_walker(a, mask, return_full_prob=True)
     assert res[0, 1, 1] == 1
     assert res[1, 1, 1] == 0
+
+
+def test_prob_tol():
+    np.random.seed(0)
+    a = np.random.random((7, 7))
+    mask = - np.ones(a.shape)
+    # This pixel is an isolated seed
+    mask[1, 1] = 1
+    # Unlabeled pixels
+    mask[3:, 3:] = 0
+    # Seeds connected to unlabeled pixels
+    mask[4, 4] = 2
+    mask[6, 6] = 1
+
+    with expected_warnings([NUMPY_MATRIX_WARNING,
+                            'The probability range is outside']):
+        res = random_walker(a, mask, return_full_prob=True)
+
+    # Lower beta, no warning is expected.
+    with expected_warnings([NUMPY_MATRIX_WARNING]):
+        res = random_walker(a, mask, return_full_prob=True, beta=10)
+    assert res[0, 1, 1] == 1
+    assert res[1, 1, 1] == 0
+
+    # Being more prob_tol tolerant, no warning is expected.
+    with expected_warnings([NUMPY_MATRIX_WARNING]):
+        res = random_walker(a, mask, return_full_prob=True, prob_tol=1e-1)
+    assert res[0, 1, 1] == 1
+    assert res[1, 1, 1] == 0
+
+    # Reduced tol, no warning is expected.
+    with expected_warnings([NUMPY_MATRIX_WARNING]):
+        res = random_walker(a, mask, return_full_prob=True, tol=1e-9)
+    assert res[0, 1, 1] == 1
+    assert res[1, 1, 1] == 0