Repository: scikit-image/scikit-image

Issue:
RANSAC returns an invalid model
## RANSAC returns an invalid model

In some cases that I cannot exactly pinpoint, RANSAC returns a model that does not pass the `is_model_valid` test.

## Way to reproduce
```python
import numpy as np
from skimage.measure import LineModelND, ransac

def is_model_valid(model, *random_data) -> bool:
    """Only allow models with a maximum of 10 degree tilt from the vertical"""
    tilt = abs(np.arccos(np.dot(model.params[1], [0, 0, 1])))
    return tilt <= (10 / 180 * np.pi)

np.random.seed(0)
model, inliers = ransac(
    np.linspace([0,0,0], [0.3,0,1], 1000) + np.random.rand(1000, 3) - 0.5,
    LineModelND,
    min_samples=2,
    residual_threshold=0.3,
    max_trials=50,
    random_state=0,
    is_model_valid=is_model_valid
)
print(is_model_valid(model) if model else "No model")
```


## Version information
```python
3.8.8 | packaged by conda-forge | (default, Feb 20 2021, 16:22:27) 
[GCC 9.3.0]
Linux-4.14.198-152.320.amzn2.x86_64-x86_64-with-glibc2.10
scikit-image version: 0.17.2
numpy version: 1.20.3
```

```python
print(is_model_valid(model) if model else "No model")
# False
```



Discussion:
Hello @RenaudLN and thank you for your interest.
When I set the random seed to 1 in your example, the output model is in fact not valid:
```python
In [15]: import numpy as np 
    ...: from skimage.measure import LineModelND, ransac 
    ...:  
    ...: def is_model_valid(model, *random_data) -> bool: 
    ...:     """Only allow models with a maximum of 10 degree tilt from the vertical""" 
    ...:     tilt = abs(np.arccos(np.dot(model.params[1], [0, 0, 1]))) 
    ...:     return tilt <= (10 / 180 * np.pi) 
    ...:  
    ...: np.random.seed(1) 
    ...: model, inliers = ransac( 
    ...:     np.linspace([0,0,0], [0.3,0,1], 1000) + np.random.rand(1000, 3) - 0.5, 
    ...:     LineModelND, 
    ...:     min_samples=2, 
    ...:     residual_threshold=0.3, 
    ...:     max_trials=50, 
    ...:     random_state=0, 
    ...:     is_model_valid=is_model_valid 
    ...: ) 
    ...: print(is_model_valid(model) if model else "No model")                                          
False
```
But when I set `max_trials` to 1000, I obtain a valid model.
I agree with you that outputting an invalid model without any warning is a strange behavior! Thank you for raising this.

Resolving patch:
diff --git a/skimage/measure/fit.py b/skimage/measure/fit.py
--- a/skimage/measure/fit.py
+++ b/skimage/measure/fit.py
@@ -16,11 +16,6 @@ def _check_data_atleast_2D(data):
         raise ValueError('Input data must be at least 2D.')
 
 
-def _norm_along_axis(x, axis):
-    """NumPy < 1.8 does not support the `axis` argument for `np.linalg.norm`."""
-    return np.sqrt(np.einsum('ij,ij->i', x, x))
-
-
 class BaseModel(object):
 
     def __init__(self):
@@ -129,7 +124,7 @@ def residuals(self, data, params=None):
         origin, direction = params
         res = (data - origin) - \
               ((data - origin) @ direction)[..., np.newaxis] * direction
-        return _norm_along_axis(res, axis=1)
+        return np.linalg.norm(res, axis=1)
 
     def predict(self, x, axis=0, params=None):
         """Predict intersection of the estimated line model with a hyperplane
@@ -618,21 +613,14 @@ def _dynamic_max_trials(n_inliers, n_samples, min_samples, probability):
     if n_inliers == 0:
         return np.inf
 
-    nom = 1 - probability
-    if nom == 0:
+    if probability == 1:
         return np.inf
 
-    inlier_ratio = n_inliers / float(n_samples)
-    denom = 1 - inlier_ratio ** min_samples
-    if denom == 0:
+    if n_inliers == n_samples:
         return 1
-    elif denom == 1:
-        return np.inf
 
-    nom = np.log(nom)
-    denom = np.log(denom)
-    if denom == 0:
-        return 0
+    nom = math.log(1 - probability)
+    denom = math.log(1 - (n_inliers / n_samples) ** min_samples)
 
     return int(np.ceil(nom / denom))
 
@@ -779,8 +767,9 @@ def ransac(data, model_class, min_samples, residual_threshold,
     >>> sum(inliers) > 40
     True
 
-    RANSAC can be used to robustly estimate a geometric transformation. In this section,
-    we also show how to use a proportion of the total samples, rather than an absolute number.
+    RANSAC can be used to robustly estimate a geometric
+    transformation. In this section, we also show how to use a
+    proportion of the total samples, rather than an absolute number.
 
     >>> from skimage.transform import SimilarityTransform
     >>> rng = np.random.default_rng()
@@ -806,10 +795,11 @@ def ransac(data, model_class, min_samples, residual_threshold,
 
     """
 
-    best_model = None
     best_inlier_num = 0
     best_inlier_residuals_sum = np.inf
-    best_inliers = None
+    best_inliers = []
+    validate_model = is_model_valid is not None
+    validate_data = is_data_valid is not None
 
     random_state = np.random.default_rng(random_state)
 
@@ -819,7 +809,7 @@ def ransac(data, model_class, min_samples, residual_threshold,
     num_samples = len(data[0])
 
     if not (0 < min_samples < num_samples):
-        raise ValueError("`min_samples` must be in range (0, <number-of-samples>)")
+        raise ValueError(f"`min_samples` must be in range (0, {num_samples})")
 
     if residual_threshold < 0:
         raise ValueError("`residual_threshold` must be greater than zero")
@@ -831,74 +821,78 @@ def ransac(data, model_class, min_samples, residual_threshold,
         raise ValueError("`stop_probability` must be in range [0, 1]")
 
     if initial_inliers is not None and len(initial_inliers) != num_samples:
-        raise ValueError("RANSAC received a vector of initial inliers (length %i)"
-                         " that didn't match the number of samples (%i)."
-                         " The vector of initial inliers should have the same length"
-                         " as the number of samples and contain only True (this sample"
-                         " is an initial inlier) and False (this one isn't) values."
-                         % (len(initial_inliers), num_samples))
+        raise ValueError(
+            f"RANSAC received a vector of initial inliers (length "
+            f"{len(initial_inliers)}) that didn't match the number of "
+            f"samples ({num_samples}). The vector of initial inliers should "
+            f"have the same length as the number of samples and contain only "
+            f"True (this sample is an initial inlier) and False (this one "
+            f"isn't) values.")
 
     # for the first run use initial guess of inliers
     spl_idxs = (initial_inliers if initial_inliers is not None
-                else random_state.choice(num_samples, min_samples, replace=False))
+                else random_state.choice(num_samples, min_samples,
+                                         replace=False))
+
+    # estimate model for current random sample set
+    model = model_class()
 
     for num_trials in range(max_trials):
         # do sample selection according data pairs
         samples = [d[spl_idxs] for d in data]
-        # for next iteration choose random sample set and be sure that no samples repeat
+
+        # for next iteration choose random sample set and be sure that
+        # no samples repeat
         spl_idxs = random_state.choice(num_samples, min_samples, replace=False)
 
         # optional check if random sample set is valid
-        if is_data_valid is not None and not is_data_valid(*samples):
+        if validate_data and not is_data_valid(*samples):
             continue
 
-        # estimate model for current random sample set
-        sample_model = model_class()
-
-        success = sample_model.estimate(*samples)
+        success = model.estimate(*samples)
         # backwards compatibility
         if success is not None and not success:
             continue
 
         # optional check if estimated model is valid
-        if is_model_valid is not None and not is_model_valid(sample_model, *samples):
+        if validate_model and not is_model_valid(model, *samples):
             continue
 
-        sample_model_residuals = np.abs(sample_model.residuals(*data))
+        residuals = np.abs(model.residuals(*data))
         # consensus set / inliers
-        sample_model_inliers = sample_model_residuals < residual_threshold
-        sample_model_residuals_sum = np.sum(sample_model_residuals ** 2)
+        inliers = residuals < residual_threshold
+        residuals_sum = residuals.dot(residuals)
 
         # choose as new best model if number of inliers is maximal
-        sample_inlier_num = np.sum(sample_model_inliers)
+        inliers_count = np.count_nonzero(inliers)
         if (
             # more inliers
-            sample_inlier_num > best_inlier_num
+            inliers_count > best_inlier_num
             # same number of inliers but less "error" in terms of residuals
-            or (sample_inlier_num == best_inlier_num
-                and sample_model_residuals_sum < best_inlier_residuals_sum)
-        ):
-            best_model = sample_model
-            best_inlier_num = sample_inlier_num
-            best_inlier_residuals_sum = sample_model_residuals_sum
-            best_inliers = sample_model_inliers
+            or (inliers_count == best_inlier_num
+                and residuals_sum < best_inlier_residuals_sum)):
+            best_inlier_num = inliers_count
+            best_inlier_residuals_sum = residuals_sum
+            best_inliers = inliers
             dynamic_max_trials = _dynamic_max_trials(best_inlier_num,
                                                      num_samples,
                                                      min_samples,
                                                      stop_probability)
             if (best_inlier_num >= stop_sample_num
-                or best_inlier_residuals_sum <= stop_residuals_sum
-                or num_trials >= dynamic_max_trials):
+                    or best_inlier_residuals_sum <= stop_residuals_sum
+                    or num_trials >= dynamic_max_trials):
                 break
 
     # estimate final model using all inliers
-    if best_inliers is not None and any(best_inliers):
+    if any(best_inliers):
         # select inliers for each data array
         data_inliers = [d[best_inliers] for d in data]
-        best_model.estimate(*data_inliers)
+        model.estimate(*data_inliers)
+        if validate_model and not is_model_valid(model, *data_inliers):
+            warn("Estimated model is not valid. Try increasing max_trials.")
     else:
-        best_model = None
+        model = None
         best_inliers = None
         warn("No inliers found. Model not fitted")
 
-    return best_model, best_inliers
+    return model, best_inliers


Test patch:
diff --git a/skimage/measure/tests/test_fit.py b/skimage/measure/tests/test_fit.py
--- a/skimage/measure/tests/test_fit.py
+++ b/skimage/measure/tests/test_fit.py
@@ -426,3 +426,20 @@ def test_ransac_with_no_final_inliers():
                                 residual_threshold=0, random_state=1523427)
     assert inliers is None
     assert model is None
+
+
+def test_ransac_non_valid_best_model():
+    """Example from GH issue #5572"""
+    def is_model_valid(model, *random_data) -> bool:
+        """Allow models with a maximum of 10 degree tilt from the vertical
+
+        """
+        tilt = abs(np.arccos(np.dot(model.params[1], [0, 0, 1])))
+        return tilt <= (10 / 180 * np.pi)
+
+    rnd = np.random.RandomState(1)
+    data = np.linspace([0, 0, 0], [0.3, 0, 1], 1000) + rnd.rand(1000, 3) - 0.5
+    with expected_warnings(["Estimated model is not valid"]):
+        ransac(data, LineModelND, min_samples=2,
+               residual_threshold=0.3, max_trials=50, random_state=0,
+               is_model_valid=is_model_valid)