Skip to content

Comments

Deprecate estimate method in favor of class constructor#7771

Merged
lagru merged 89 commits intoscikit-image:mainfrom
matthew-brett:deprecate-estimate-method
Jul 7, 2025
Merged

Deprecate estimate method in favor of class constructor#7771
lagru merged 89 commits intoscikit-image:mainfrom
matthew-brett:deprecate-estimate-method

Conversation

@matthew-brett
Copy link
Contributor

@matthew-brett matthew-brett commented Apr 7, 2025

Description

This is the promised follow-on PR from the discussion more or less starting at #7754 (comment).

To recap briefly:

There is a wart in the current API, in that the estimate method of Transforms ignores any stored state of the initialized Transform, and re-estimates. So, for example:

tf = SimilarityTransform(rotation=(0.2, 0.3))
tf.estimate(src, dst)

wil discard the initial state with the specified rotations. Another problem is that one generally has to first initialize the transform, most sensibly as an identity transform, and then estimate, thus:

tf = SimilarityTransform.identity(3)
tf.estimate(src, dst)

However, the argument to the identity transform is redundant, because we can infer the dimensionality of the points (3) from src and dst point arrays. So, this PR deprecates the estimate method, in favor of a class constructor, like this:

tf = SimilarityTransform.from_estimate(src, pts)

For discussion

Release note

For maintainers and optionally contributors, please refer to the instructions on how to document this PR for the release notes.

In `skimage.measure`, add a new class method and constructor `from_estimate` for 
`LineModelND`, `CircleModel`, and `EllipseModel`. This replaces the old
API—the now deprecated `estimate` method—which required initalizing a model
with undefined parameters before calling `estimate`. 
In `skimage.transform`, add a new class method and constructor `from_estimate` for
`AffineTransform`, `EssentialMatrixTransform`, `EuclideanTransform`,
`FundamentalMatrixTransform`, `PiecewiseAffineTransform`, `PolynomialTransform`, 
`ProjectiveTransform`, `SimilarityTransform`, and `ThinPlateSplineTransform`. This
replaces the old API—the now deprecated `estimate` method—which required initializing an undefined transform before calling `estimate`.

@matthew-brett matthew-brett added the 📜 type: API Involves API change(s) label Apr 7, 2025
@matthew-brett matthew-brett force-pushed the deprecate-estimate-method branch from 7ba0259 to c163539 Compare April 7, 2025 21:25
@matthew-brett matthew-brett marked this pull request as draft April 8, 2025 09:30
@matthew-brett
Copy link
Contributor Author

This one is ready for a first review - but we need to make some decisions, listed above, so I would be very glad of some initial feedback.

@lagru lagru self-requested a review April 8, 2025 12:24
@matthew-brett
Copy link
Contributor Author

I'll proceed with these (provisional) answers to the points for discussion above:

  • from_estimate is a reasonable name for the class constructor method. Conversely, using estimate will commit us to using a odd trick that allows the class method to differ from the instance method - contrary to the normal interface in Python - and for the long term (because we will have to keep an error marker for the instance method).
  • I believe my check for the degenerate transform is (now) correct (directly evaluate the determinant of the generated transform matrix).
  • I guess that the use-case is rare, of investigating the various affines in a PiecewiseAffineTransform, and that rare user that needs to investigate, can read the code for themselves to reproduce the steps. So None is OK as a return value from from_estimate. If it proves necessary to get the component affines, we can add that as another class method, I guess. But not in this PR, as likely YAGNI.
  • I will implement from_estimate and estimate deprecations for LineModelND, CircleModel, EllipseModel andThinPlateSpline.

When I've done the last step, I'll unmark this PR as draft.

If anyone disagrees with the above - let me know soon?

@stefanv
Copy link
Member

stefanv commented Apr 10, 2025

That all sounds good to me. I'm fine with from_estimate. I do think we need a way to tell the user whether the transform succeeded or failed in all cases. Raising may be a fine option, unless there is some reason to proceed with a non-fitted model.

@matthew-brett
Copy link
Contributor Author

I do think we need a way to tell the user whether the transform succeeded or failed in all cases

Could you say more about what you were getting at here?

@stefanv
Copy link
Member

stefanv commented Apr 10, 2025

Nevermind, I had misunderstood what you wrote. I guess the question is: do we return None on failure, or error out? It may be unexpected to get a non-Transform-class-compliant result without obvious failure.

@lagru
Copy link
Member

lagru commented Apr 10, 2025

From reading through this, I concur with the general direction.

Re failed estimations, if feel like returning None isn't a bad choice on failed estimation, but it might be unexpected for users. What do you think about using try_estimate instead of from_estimate? try_ would clearly indicate, hey this may fail and there's behavior for it.

Exceptions might work too, but a try except block forces users to check what kind of exception is raised. We could provide that optionally but I wouldn't make it the default.

@matthew-brett
Copy link
Contributor Author

I like that from_estimate conveys that we are making a transform from an estimate - it has a classic class constructor feel. Whereas try_estimate feels like it will trigger some confusion - is this like a try ... except?

I have a vague positivity about the current API - in that there is no way that someone could accidentally use a None returned Transform. For the option of raising an error - we really aren't saving them that much code. I mean comparing:

tf = AffineTransform.from_estimate(src, dst, raise_on_failure=True)

to:

tf = AffineTransform(src, dst):
if tf is None: raise ValueError('Estimation failed')

Copy link
Member

@lagru lagru left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I gave it a first look. A lot of the stuff is just nitpicks – please tell me if it doesn't feel constructive and I should tone it down. 😉 But I'd like to get your input on a few items. Thanks!

@stefanv
Copy link
Member

stefanv commented Apr 10, 2025

Matthew, re: the None return: it's not that it's hard to handle, more that it hides failures in a way that may not be obvious to developers. E.g., if you run a program and it works fine the first ten times, but then the 11th time breaks because None doesn't have some attribute, it's going to be confusing. If the error is "we couldn't fit the model", you immediately know what went wrong.

Re: naming, I prefer from_estimate, since that's already a paradigm in pandas etc.

@lagru
Copy link
Member

lagru commented Apr 10, 2025

Re #7771 (comment), I can live happily with from_estimate. 😄

Regarding raising on failure, this would be a way to tell users why an estimation failed. Just returning None has a "computer says no" vibe. 😅 But we could also work with warnings instead. Or add optional and more specific exceptions later. So I'm happy to keep this out of the scope of this PR.

@matthew-brett
Copy link
Contributor Author

Matthew, re: the None return: it's not that it's hard to handle, more that it hides failures in a way that may not be obvious to developers. E.g., if you run a program and it works fine the first ten times, but then the 11th time breaks because None doesn't have some attribute, it's going to be confusing. If the error is "we couldn't fit the model", you immediately know what went wrong.

Re: naming, I prefer from_estimate, since that's already a paradigm in pandas etc.

I'm happy with raising an error in the class method instead - that was my original implementation. Would you prefer that?

@stefanv
Copy link
Member

stefanv commented Apr 10, 2025

As always with API design, I just have a vague feeling, so I'll have to rely on y'all to help me figure out if the concern is legit or not.

@lagru
Copy link
Member

lagru commented Apr 10, 2025

Could we perhaps use a nice sentinel value other than None? That should result in nice error messages like "<FailedEstimation> has no attribute...". I would also provide a nice container for a string giving a reason for the failure. Though, we would need a good to check this maybe with something like

class FailedEstimation:
    def __bool__(self):
        return False

that would still allow a simple if not tform: check or assert tform.

@matthew-brett
Copy link
Contributor Author

Could we perhaps use a nice sentinel value other than None? That should result in nice error messages like " has no attribute...". I would also provide a nice container for a string giving a reason for the failure. Though, we would need a good to check this maybe with something like

class FailedEstimation:
    def __bool__(self):
        return False

that would still allow a simple if not tform: check or assert tform.

What would be the advantage over raising an error?

@lagru
Copy link
Member

lagru commented Apr 10, 2025

What would be the advantage over raising an error?

As stated above, it's easier to handle. try...except forces users to lookup and match the correct exception. But perhaps still better than a weird custom object that is "falsy".

@matthew-brett
Copy link
Contributor Author

What would be the advantage over raising an error?

As stated above, it's easier to handle. try...except forces users to lookup and match the correct exception. But perhaps still better than a weird custom object that is "falsy".

Ah yes - sorry - I see the point. I guess at the moment, my order of preference is:

  • falsey return value (@lagru's suggestion). I guess we could add an error message there, perhaps returned by __str__.
  • raise an error in class constructor.
  • None return value.

but I don't feel very strongly. What do y'all think?

Copy link
Contributor Author

@matthew-brett matthew-brett left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some comments on comments. Nitpickey is fine ...

@matthew-brett matthew-brett force-pushed the deprecate-estimate-method branch from d474042 to f038659 Compare April 11, 2025 11:17
Copy link
Contributor Author

@matthew-brett matthew-brett left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple of comments - thanks.

@matthew-brett
Copy link
Contributor Author

Returning to the API question ... it seems to me that one will often want to do this, as @lagru points out:

tf = Transform.from_estimate(src, dst)
if not tf:
    "Respond in some way"

So far, the None and FailedEstimation ideas both serve. The FailedEstimation improves on None in allowing - say - a .message to give more information, at the expense of being a bit more fancy and less obvious.

Otherwise (the raise option), one will nearly always have to do something like:

try:
    tf = Transform.from_estimate(src, dst)
except TransformEstimationError:
    "Respond in some way"

Of course, we could give the user a choice with:

 tf = Transform.from_estimate(src, dst, raise=True)

where the user then passes on the problem to their user. But this seems like an example where "There should be one-- and preferably only one --obvious way to do it." And we aren't saving the user much compared to:

tf = Transform.from_estimate(src, dst)
if not tf:
    "Respond in some way"

The advantage of Lars' FailedEstimation is that the user can even do:

tf = Transform.from_estimate(src, dst)
if not tf:
    raise ErrorOfYourChoice(str(tf))

So I'm still vaguely in favor of Lars' FailedEstimation idea. Any more thoughts?

@matthew-brett
Copy link
Contributor Author

As a side-point, and returning to this item:

  • I guess that the use-case is rare, of investigating the various affines in a PiecewiseAffineTransform, and that rare user that needs to investigate, can read the code for themselves to reproduce the steps. So None is OK as a return value from from_estimate. If it proves necessary to get the component affines, we can add that as another class method, I guess. But not in this PR, as likely YAGNI.

Lar's FailedEstimate allows us a simple way to return the intermediate results of the failed estimation, if we want to do that.

@matthew-brett matthew-brett force-pushed the deprecate-estimate-method branch 3 times, most recently from 14f3eab to 5dc6b03 Compare April 11, 2025 17:35
@matthew-brett
Copy link
Contributor Author

@lagru - you mentioned resolving the stacklevel for the warnings. I wonder whether you could help me with some confusing stacklevel test errors? Example at https://github.com/matthew-brett/scikit-image/actions/runs/14408547193/job/40410880036#step:9:445:

        warning_message = (
            "Standard deviation of data is too small to estimate "
            "circle with meaningful precision."
        )
        with pytest.warns(RuntimeWarning, match=warning_message) as _warnings:
            assert CircleModel.from_estimate(np.ones((6, 2))) is None
>       assert_stacklevel(_warnings)
E       AssertionError: Warning with wrong stacklevel:
E         Expected: /opt/hostedtoolcache/Python/3.13.2/x64/lib/python3.13/site-packages/skimage/measure/tests/test_fit.py:190
E         Actual: /opt/hostedtoolcache/Python/3.13.2/x64/lib/python3.13/site-packages/skimage/_shared/utils.py:572
E         RuntimeWarning: Standard deviation of data is too small to estimate circle with meaningful precision.

This appears to be because I'm using the now-deprecated estimate method from the from_estimate class method, and ignoring the warning on the way:

    @classmethod
    def from_estimate(cls, data):
        instance = cls()
        with catch_warnings():
            filterwarnings("ignore", message="`estimate` is deprecated")
            success = instance.estimate(data)
        return instance if success else None

but I must admit I didn't understand the stacklevel code in _shared/utils.py. Do you see what the problem is?

@matthew-brett
Copy link
Contributor Author

OK - work now basically done, waiting for a decision on the return value from the from_estimate class methods. Any final words? Otherwise I'll implement @lagru's suggestion.

Comment on lines +30 to +32
def from_estimate(cls, *data): ...

def residuals(self, *data): ...
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this protocol actually need to support multiple arguments via *data? The test suite only seems to check for the more restrictive case from_estimate(cls, data)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that is to match the signature of the ransac function - that allows you to pass multiple arrays as input data. These data then reach the model_class via model = model_class.from_estimate(*samples) (currently L 1153 in fit.py) - where *samples are the data arrays suitably indexed (samples = [d[spl_idxs] for d in data]). Then the data reaches residuals via residuals = np.abs(model.residuals(*data)). In both cases the model has to handle multiple input arrays.

@lagru's suggestions.

Co-authored-by: Lars Grüter <[email protected]>
@matthew-brett
Copy link
Contributor Author

Failures appear unrelated. OK to pass this ball back to you @lagru?

lagru added 3 commits July 1, 2025 16:57
I hope this makes it a bit more concise and faster to grok. I think
especially the `note` directive should help with that.
If we recommend the raise RuntimeError pattern in the geometric example
we can also directly recommend it in the documentation. It's not
particularly complex. In all cases, it is preceded by "e.g." which makes
it clear that this is one way to handle failure.
Copy link
Member

@lagru lagru left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finally done. Assuming the CI failures are unrelated, I think we can merge this.

Thanks @matthew-brett for the patience!

@lagru
Copy link
Member

lagru commented Jul 3, 2025

I added the missing TODO.txt notes and updated the release notes in the PR summary, adding a bit more details and justification. This should be good to go.

@lagru lagru merged commit 26b0696 into scikit-image:main Jul 7, 2025
20 of 24 checks passed
@lagru
Copy link
Member

lagru commented Jul 7, 2025

🎉

@stefanv stefanv added this to the 0.26 milestone Jul 7, 2025
matthew-brett added a commit to matthew-brett/scikit-image that referenced this pull request Jul 7, 2025
* origin/main:
  Deprecate estimate method in favor of class constructor (scikit-image#7771)
  Temporary fix for Visual Studio & Clang incompatibility in Windows image (scikit-image#7835)
  Address deprecations in Pillow 11.3 (scikit-image#7828)
  Remove unused & obsolete `legacy_datasets`, `legacy_registry` vars (scikit-image#7677)
  Draft migration guide for skimage2 (scikit-image#7785)
  Do not report failure in wheels sub-recipe (scikit-image#7806)
  Use consistent wording in property description. (scikit-image#7804)
  Add intensity_median to regionprops (scikit-image#7745)
  CI: Update pypa/gh-action-pypi-publish to v1.12.4 for attestations on PyPI (scikit-image#7793)
  Document output dtype for transform.resize. (scikit-image#7792)
  Use `cibuildwheel` to build WASM/Pyodide wheels for `scikit-image`, push nightlies to Anaconda.org (scikit-image#7440)
  DOC: Include missing gain parameter in adjust_gamma equation (scikit-image#7763)
  Temporarily pin to `pyodide-build==0.30.0`, and ensure that the correct xbuildenvs are used (scikit-image#7788)
  Deprecate old names & attributes in RegionProperties (scikit-image#7778)
  Pin JasonEtco/create-an-issue action to SHA for v2.9.2 (scikit-image#7787)
  Make doctest-plus work with spin (scikit-image#7786)
  Report failures on main via issue (scikit-image#7752)
  Use `workers` instead of alternate parameter names (scikit-image#7302)
  Fix f-string in otsu plot (scikit-image#7780)
  Further document use of regionprops function and fix terms. (scikit-image#7518)
matthew-brett added a commit to matthew-brett/scikit-image that referenced this pull request Jul 14, 2025
* origin/main: (31 commits)
  Update import convention in certain gallery examples (scikit-image#7764)
  Refactor fundamental matrix scaling (scikit-image#7767)
  Add unit test for cval unequal to zero
  Forward  in _generic_edge_filter
  Remove superfluous mask argument from _generic_edge_filter
  Only report failure on main branch once
  Deprecate estimate method in favor of class constructor (scikit-image#7771)
  Temporary fix for Visual Studio & Clang incompatibility in Windows image (scikit-image#7835)
  Address deprecations in Pillow 11.3 (scikit-image#7828)
  Remove unused & obsolete `legacy_datasets`, `legacy_registry` vars (scikit-image#7677)
  Draft migration guide for skimage2 (scikit-image#7785)
  Do not report failure in wheels sub-recipe (scikit-image#7806)
  Use consistent wording in property description. (scikit-image#7804)
  Add intensity_median to regionprops (scikit-image#7745)
  CI: Update pypa/gh-action-pypi-publish to v1.12.4 for attestations on PyPI (scikit-image#7793)
  Document output dtype for transform.resize. (scikit-image#7792)
  Use `cibuildwheel` to build WASM/Pyodide wheels for `scikit-image`, push nightlies to Anaconda.org (scikit-image#7440)
  DOC: Include missing gain parameter in adjust_gamma equation (scikit-image#7763)
  Temporarily pin to `pyodide-build==0.30.0`, and ensure that the correct xbuildenvs are used (scikit-image#7788)
  Deprecate old names & attributes in RegionProperties (scikit-image#7778)
  ...
lagru added a commit to lagru/scikit-image that referenced this pull request Jul 15, 2025
This avoids random failures when the number of max_trials is not enough
to successfully estimate a EuclideanTransform.

This was also the case previously, but recent updates to our Transform
API in scikit-image#7771 made these failures visible in the CI.
stefanv pushed a commit that referenced this pull request Jul 16, 2025
This avoids random failures when the number of max_trials is not enough
to successfully estimate a EuclideanTransform.

This was also the case previously, but recent updates to our Transform
API in #7771 made these failures visible in the CI.

Closes #7849.
matthew-brett added a commit that referenced this pull request Jul 16, 2025
This PR follows up on [PR 7771](#7771 (comment)) in continuing the logic of avoiding the creation of uninitialized Model objects, for later completion with `.estimate`.   Therefore a) deprecate creation of uninitialized Model objects and b) deprecate use of `params` arguments available in various `predict_*` methods that serve the purpose of initializing uninitialized objects.

* Deprecates no-argument constructor calls to the model classes such as
`LineModelND`;
* Deprecate `.params` attributes of models. Instead set model-specific
attributes: `origin, direction` for `LineModelND`; `center, radius` for
`CircleModel`, `center, axis_lengths, theta` for `EllipseModel`.
* Deprecates use of model-initiating extra parameters (`params`) in
`predict*` calls.
* Deprecates use of `BaseModel` (it serves no great purpose as a public class, and we will not need it after expiring the other deprecations).

We now raise a `ValueError` instead of a `TypeError` when failing  to pass a value for `params` (or passing `params=None`) to `predict` methods of an uninitialized transform.

## Release note

For maintainers and optionally contributors, please refer to the
[instructions](https://scikit-image.org/docs/stable/development/contribute.html#documenting-changes)
on how to document this PR for the release notes.

```release-note
Deprecate use of model constructor calls without arguments (e.g. `CircleModel()`), leaving an uninitialized instance.  Instead prefer input arguments to define instance (e.g. `CircleModel(center, radius)`).  This follows on from prior deprecation of the `estimate` method, which had implied the need for the no-argument constructor, of form `cm = CircleMoldel(); cm.estimate(data)`.

Deprecate `.params` attributes of models.   Instead set model-specific attributes: `origin, direction` for `LineModelND`; `center, radius` for `CircleModel`, `center, ax_lens, theta` for `EllipseModel`.

Deprecate use of `params` arguments to `predict*` calls of model objects, because we now ask instead that the user provide initialization equivalent to the `params` content in the class construction (i.e. prefer `cm = CircleModel((2, 3), 4); x = cm.predict_x(t)` to `cm = CircleMoldel(); x = cm.predict_x(t, params=(2, 3, 4))`).

Deprecate `skimage.measure.fit.BaseModel`; after we expire the other `*Model*` deprecations, there is no work for an ancestor class to do.

In a small cleanup, we now raise a `ValueError` instead of a `TypeError` when failing  to pass a value for `params` (or passing `params=None`) to `predict` methods of an uninitialized transform.
```
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🥾 Path to skimage2 A step towards the new "API 2.0" 📜 type: API Involves API change(s)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants