Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

milestones for natural continuation #6

Merged
merged 12 commits into from
Apr 19, 2019
Merged

Conversation

gdmcbain
Copy link
Contributor

Implemented using collections.deque, though the parameter can be passed in as any Iterable, e.g. numpy.ndarray (with ndim==1), as in test/test_bratu1d.py, modified to demonstrate.

As discussed in #5, if milestones have been specified, the natural parameter continuation terminates after the last has been reached.

The overall effect can be seen in the (λ, || u ||²) plot, with an × on each vertical grid line (Δ λ = 0.5) during the first, natural, pass.

Some other minor changes were made to the example to make it easier to run; e.g.

  • same newton_tol in natural as in euler_newton (for same reason)
  • raise RangeException to terminate subsequent euler_newton continuation after the region of interest has been left
  • a five-second pause after the RangeException, so that the plot can be inspected (previously there was plenty of time for this)

@codecov-io
Copy link

codecov-io commented Mar 20, 2019

Codecov Report

Merging #6 into master will decrease coverage by 3.6%.
The diff coverage is 100%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master       #6      +/-   ##
==========================================
- Coverage   87.93%   84.32%   -3.61%     
==========================================
  Files           5        5              
  Lines         174      185      +11     
==========================================
+ Hits          153      156       +3     
- Misses         21       29       +8
Impacted Files Coverage Δ
pacopy/natural.py 76.08% <100%> (-12.49%) ⬇️
pacopy/newton.py 95.83% <0%> (-4.17%) ⬇️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update b5ae74c...ec1b7fc. Read the comment docs.

@gdmcbain
Copy link
Contributor Author

The idea here is that one could check lmbda in milestones inside the callback to make something like the following (703b7f1):

bratu1d_profiles

@nschloe
Copy link
Collaborator

nschloe commented Mar 22, 2019

I'll check this out next week.

@gdmcbain
Copy link
Contributor Author

gdmcbain commented Apr 9, 2019

Thoughts? I've been using this a bit myself and am happy with the interface and behaviour.

@@ -76,7 +82,10 @@ def natural(
)

# Predictor
lmbda += lambda_stepsize
if milestones is None:
Copy link
Collaborator

Choose a reason for hiding this comment

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

Bit shorter:

lmbda += lambda_stepsize
if milestones:
    lmbda = min(lmbda, milestone)

"""
lmbda = lambda0
if milestones is not None:
Copy link
Collaborator

Choose a reason for hiding this comment

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

milestones = [] if milestones is None else milestones

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah. The motivation for wrapping milestones in iter is that a user will probably pass either a list or a numpy.ndarray, as in

https://github.com/nschloe/pacopy/blob/e2d972fcb80c3c43efdeb30e89f78bd83cfd1ad9/test/test_bratu1d.py#L83

and one can't call next on either of those.

Lists and NumPy ndarrarys are Iterable

isinstance(np.array([8e2]), typing.Iterable)

but not iterators.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There's a catch here in dispensing with the new name lambdas for the processed argument; see below.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note that iter is idempotent and therefore harmless in the case where a user does actually pass an iterator.

A reasonable use-case there is to generate an infinite sequence of milestones, e.g. passing milestones=itertools.count(), presumably terminating with max_steps #8 or by raising an Exception in the callback.

@@ -109,5 +118,10 @@ def natural(

callback(k, lmbda, u)
k += 1
if milestones is not None and lmbda == milestone:
Copy link
Collaborator

Choose a reason for hiding this comment

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

The float comparison is a bit iffy.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, interesting. Usually one avoids testing two floats for equality; here however I think it's O. K.

There are two dangers:

  • false negative, missing the milestone; and
  • false positive, matching something that isn't the milestone.

I don't think the false positive matters. If lmbda is close enough to the milestone to pass an equality test, it's good enough to be considered as it. I think this is fairly unlikely anyway; I have not been able to conceive a working example.

The false negative one is trickier. I think it's O. K., this won't happen because it's not really two floats, but, in the case that the milestone is reached, two (shallow) copies of the same float, being one of the specified milestones. The left term lmbda is assigned here (in the case that the milestone is reached) from the second argument of min.

https://github.com/nschloe/pacopy/blob/e2d972fcb80c3c43efdeb30e89f78bd83cfd1ad9/pacopy/natural.py#L88

So the question is whether min changes its second argument before assigning it to the left-hand side. It doesn't appear to; i.e.

milestone = 8e2
lmbda = min(1e3, milestone)
assert lmbda == milestone

In fact, one even has object identity

assert lmbda is milestone

while

assert lmbda is not 8e2

Perhaps using is here would be clearer?

I don't know. What do you think? Is it safe enough? (Could the behaviour of min differ?) Add a reassuring comment?

Or is there another way to implement this that avoids the issue?

I don't think the usual expedient of a tolerance as in numpy.allclose is appropriate as I can see that producing false negatives which would be messy and annoying. Especially as the other thing is that in the envisaged use-case, the callback will always have another such test; e.g.

https://github.com/nschloe/pacopy/blob/e2d972fcb80c3c43efdeb30e89f78bd83cfd1ad9/test/test_bratu1d.py#L106-L107

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thinking about this some more. (Thank you for raising it. I intend to make much use of this function and feature, so don't mind spending time now getting it right. I appreciate the rigour.)

Possible 'worst case scenario' (?). Say in

https://github.com/nschloe/pacopy/blob/98d4ccfa1867d6007c9b2262ccafc18f5fbd5573/pacopy/natural.py#L85-L87

that lmbda is numerically close to milestone but is judged by min to be the lesser. Then, say we successfully solve at this λ and get to

https://github.com/nschloe/pacopy/blob/98d4ccfa1867d6007c9b2262ccafc18f5fbd5573/pacopy/natural.py#L120

but then fail the second test, the floating-point equality. What will happen then is that we'll go through the next iteration of the while-loop with

https://github.com/nschloe/pacopy/blob/98d4ccfa1867d6007c9b2262ccafc18f5fbd5573/pacopy/natural.py#L85-L87

and this time lmbda will go well past milestone and so the latter will win the min and we'll be trying to solve for lmbda = milestone that was very close to the last one. This will be solved easily by pacopy.newton because the previous solution at the close lmbda will be an excellent initial guess and we'll be on our way again.

Side-effect: lambda_stepsize might be increased because newton_steps was small. Hmm. Is it worth catching or correcting for this? I don't think that we'd want to reset lambda_stepsize when we truncate a step to land on a milestone as the old lambda_stepsize is a better estimate of the right one thereafter.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The adjustment of lambda_stepsize is moved into the else clause in ec1b7fc ; i.e. it is not adjusted when the actual step was shortened to meet a milestone. That solves that last side-effect.

@gdmcbain
Copy link
Contributor Author

Oops, hang on, the last commit 457c5ec is wrong. In conflating the argument milestones and its processed version lambdas by reusing the name milestones, it's no longer possible to check whether the user asked for milestones. An iterator is always truthy, even if it's empty (unlike a list). The only way to test an iterator is to call next on and see whether it raises StopIteration.

@gdmcbain
Copy link
Contributor Author

Default no-milestones case fixed in d11f9f2.

@gdmcbain
Copy link
Contributor Author

This is ready for re-review.

@nschloe
Copy link
Collaborator

nschloe commented Apr 19, 2019

Alright, let's merge this. We might tune this or that in the future, but the fact that we have a test gives me some confidence that we won't mess up anyone's applications.

@nschloe nschloe merged commit 313647e into sigma-py:master Apr 19, 2019
@gdmcbain gdmcbain deleted the milestones branch April 19, 2019 19:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants