-
Notifications
You must be signed in to change notification settings - Fork 176
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
Poor result in log1p(complex) #790
Comments
Thanks for a bugreport.
It was temporary increased in the mpmath/mpmath/ctx_mp_python.py Lines 1017 to 1026 in b5c0450
BTW, with diff --git a/mpmath/functions/functions.py b/mpmath/functions/functions.py
index 2bbb63d..178b375 100644
--- a/mpmath/functions/functions.py
+++ b/mpmath/functions/functions.py
@@ -168,7 +168,8 @@ def log1p(ctx, x):
return ctx.zero
if ctx.mag(x) < -ctx.prec:
return x - 0.5*x**2
- return ctx.log(ctx.fadd(1, x, prec=2*ctx.prec))
+ u = ctx.fadd(1, x, prec=2*ctx.prec)
+ return ctx.log(u)*(x/(u-1))
@defun_wrapped
def powm1(ctx, x, y): I got: >>> complex(log1p(c))
(2.93621063767769e-39-4.6885882517313053e-20j) PS: There is another issue, that could be more trivially fixed. Above function code is used in the fp context as well. We could change that to |
Thanks! It's at least cute that a well-intentioned small boost in precision to absorb rounding errors unintentionally convinced later code to do an entirely wrong thing 😉. Can't make time for it now, but suggest caution about: u = ctx.fadd(1, x, prec=2*ctx.prec)
return ctx.log(u)*(x/(u-1)) That trick works great for real-valued For example, this code isn't exactly the same, but does something similar in Python: def test(c):
c2 = mp.fadd(c, 1.0, prec=2*mp.prec)
L = mpmath.log(c2)
return c * (L / (c2 - 1.0)) and then >>> c = 7.395821761854832e+86-3.4983116269888314e+21j
>>> complex(mpmath.log1p(c))
(200.02323321145997-4.730118896363822e-66j)
>>> with workprec(500): complex(mpmath.log1p(c)) # that was _already_ as good as it gets
(200.02323321145997-4.730118896363822e-66j) # same result
>>> complex(test(c)) # but the imaginary part is damaged by the "-1 trick"
(200.02323321146-4.7301188963640004e-66j)
>>> import math # by about 339 ULP :-(
>>> (4.7301188963640004e-66 - 4.730118896363822e-66)/math.ulp(4.730118896363822e-66)
339.0 Note here that 1 is insignificantly tiny compared to either component of >>> c = 7.395821761854832e+86-3.4983116269888314e+21j
>>> r = mpmath.log1p(c)
>>> complex(r)
(200.02323321145997-4.730118896363822e-66j)
>>> c2 = mp.fadd(c, 1, prec=2*mp.prec)
>>> c == c2 # adding 1 is irrelevant for this `c`
True
>>> complex(c/c*r)
(200.02323321145997-4.730118896363822e-66j) # exactly the same
>>> _ == r
True
>>> complex(c*(r/c)) # but just reordering the operations introduces the 339 ULP error
(200.02323321146-4.7301188963640004e-66j) |
The surprisingly high ULP error in the last example seems primarily due to that the Python code rounds FYI, the function following takes a very different approach. Across about 10 million randomized inputs so far, it hasn't yet reached 2 1/2 total ULP error (summing the absolute values of the ULP errors in the def test(c):
if mp.mag(c) >= 3:
return mpmath.log(1.0 + c)
R = mpf(c.real)
C = mpf(c.imag)
imag_part = mpmath.atan2(C, R + 1.0)
# compute res = (R+1)**2 + C**2 - 1 exactly,
# = R**2 + 2*R + C**2.
Rsq = mp.fmul(R, R, exact=True)
twoR = mp.ldexp(R, 1)
res = mp.fadd(Rsq, twoR, exact=True)
Csq = mp.fmul(C, C, exact=True)
res = mp.fadd(res, Csq, exact=True)
# Now the real part is log(sqrt(1 + res)) = 0.5 * log(1 + res).
real_part = mpmath.log1p(res)
return mpc(real_part * 0.5, imag_part) |
Maybe it's safe to use just diff --git a/mpmath/functions/functions.py b/mpmath/functions/functions.py
index 2bbb63d..b770e05 100644
--- a/mpmath/functions/functions.py
+++ b/mpmath/functions/functions.py
@@ -168,7 +168,7 @@ def log1p(ctx, x):
return ctx.zero
if ctx.mag(x) < -ctx.prec:
return x - 0.5*x**2
- return ctx.log(ctx.fadd(1, x, prec=2*ctx.prec))
+ return ctx.log(ctx.fadd(1, x, exact=True))
@defun_wrapped
def powm1(ctx, x, y): One case, when mantissa of |
I think you're on to something there. I'm seeing excellent numerical results from the following Python function, which intends to do much the same. Haven't seen total 2 ULP error yet, and 99.99% of the time under 1 total, but that's after only 7 million trials. Alas, it seems to run significantly slower ovreall. Two tweaks:
def test(c):
c = mpc(c)
with extraprec(10):
if mp.mag(c) * 1.5 < -mp.prec:
result = c - 0.5 * c * c
else:
arg = c
if c.real + 1.0 != c.real:
arg = mp.fadd(1.0, c, exact=True)
result = mp.log(arg)
return result Surprise! At about the 11 millionth try, it hit a case pushing 4 ulp error: >>> c = mpc(-2.0476815825463086e-80 - 2.0235857941734692e-40j)
>>> with workprec(1000): right = mp.log1p(c)
>>> got = test(c)
>>> complex(right)
(-2.3184935597344513e-84-2.0235857941734692e-40j)
>>> complex(got) # small difference in real part
(-2.318493559734453e-84-2.0235857941734692e-40j)
>>> import math # compute ULP difference
>>> rgot = float(got.real)
>>> d = float(abs(rgot - right.real))
>>> d / math.ulp(abs(rgot))
3.7405120134353638 Don't know why. This would have taken the cheap 2-term series branch, and WOW! It's due to working precision being 1 bit too small for this case - sheesh. >>> with workprec(63): complex(c - c*c/2) # what we got
(-2.318493559734453e-84-2.0235857941734692e-40j)
>>> with workprec(64): complex(c - c*c/2) # increase by 1 bit and it's "perfect"
(-2.3184935597344513e-84-2.0235857941734692e-40j) |
And here's another stab, which has so far delivered a maximum total ULP error of 1.000254. Those cases are rare enough to check by eyeball, and they're "balanced". Meaning that there's an error very close to 0.5 ULP in each of the components. Deeper digging on half a dozen showed they were all cases where the infinitely precise result was very close to being halfway between representable floats. The twists here:
I suppose in the context of an mpmath implementation, where this code's My latest run just crossed 24 million cases. No change to mention: max total ULP error still 1.000254, and the number of cases with total error < 1 ULP is well over 99.99%; the most recent reachimg 1 ULP was over 7 million cases ago. def test(c):
c = mpc(c)
cmag = mp.mag(c)
with extraprec(10):
if cmag >= 3:
result = mp.log(1.0 + c)
elif cmag * 2 < -mp.prec:
with extraprec(5):
result = c - c * c * 0.5
else:
arg = mp.fadd(1.0, c, exact=True)
result = mp.log(arg)
return result
That was mostly an illusion. I had forgotten I boosted the reference calculation to use 1000 bits instead of 500. That made it significantly slower. |
Ack. After about 71 million trials, a nearly 2-ULP error popped up, at c=-4.382549590908223e-54-2.9606011943930447e-27j. Another "fast path" case that's always been there (mag is -87). And the error would go away if I used I seem lost as to how to out-think this. I always figured that since >>> c = mpc(-4.382549590908223e-54-2.9606011943930447e-27j)
>>> complex(c)
(-4.382549590908223e-54-2.9606011943930447e-27j)
>>> complex(c**2/2)
(-4.3825797161207615e-54+1.2974981553329635e-80j) So the leading 5+ decimal digits of the real part cancel out in the subtraction. That's about 15 bits, which is the total extra precision (10 + 5) I'm using. How bad can it get? Don't know. But if I ask for more & more precision on each ever-rarer bad case at a time,. it won't be long before the name "fast path" will be a joke 😉. BTW, I don't believe anything analogous can go wrong with this series for real-valued inputs. Despite that these have very different norms: >>> abs(c)
mpf('2.9606011943930447e-27')
>>> abs(c*c/2)
mpf('4.3825797161207615e-54') their real components are nevertheless nearly equal. That can't happen with |
Wouldn't be better using a Horner scheme |
In this specific case, yes. But cancellation brings it very close to where the 3rd-order term can matter, and there's no reason at all to hope that worse cancellation can't occur. So what I'm testing now does away with the elif cmag * 2 < -mp.prec:
result = c*c * (c/3.0 - 0.5) + c EDIT: which works better in "fully Hornerized" form: result = c*(c*(c/3.0 - 0.5) + 1.0) |
No surprises. After 300 million trials, max total ULP error seen is 1.0009, reached at 2.3352642762712193e+104-0.0377801491250023j. All but 47 values had total error less than 1 ULP. All inputs were built from native Python floats with input context prec 53, The real and imag components were generated in the same way: pick one of the 2**52 representable floats in |
Continued testing continued to deliver similar stellar results. Now that I'm (more than just) happy with accuracy, I can think about speed. For a start, the 2-term Taylor series is obviously much better-behaved in this context via Hornerizing, but I haven't tested that. So that's next. |
LOL! Testing with the 2-term Horner series quickly delivered the worst case of the last few days: total ULP error of 1.00224, at -3.8445809036335997e-16+1.754102014431826e-63j) (mag -50, so takes the fast path due to my widening of it). However, it delivers exactly the same (after rounding back to Python's 53-bit floats) with the 3-term series. To speed this up, I fiddled the random generator to heavily favor producing smaller outputs, to make it much more likely that the fast path will be taken. |
Damn. I added a long-ish comment on the actual source of errors here, but Github appears to have lost it. Short course: "Horner's method" is mostly a red herring here. It's just as prone to cancellation disasters. Random testing appears too feeble to catch the disasters in reasonable time - didn't see one over 1.002 total ULP error after 730 million tries. But now that I finally understand a cause, it's easy to construct randomish inputs >>> c = mpc(-6.4922176418510124e-21+1.1394926627101214e-10j)
>>> with workprec(10000): complex(mp.log1p(c)) # presumed correct
(-1.4201199664289643e-37+1.1394926627101214e-10j)
>>> complex(test(c)) # 2-term series only gets 4 correct digits
(-1.42033071087851e-37+1.1394926627101214e-10j)
# And cancellation is so bad the 3-term series isn't better
>>> with extraprec(10): complex(c*(c*(c/3.0 - 0.5) + 1.0))
(-1.42033071087851e-37+1.1394926627101214e-10j) No matter how it's spelled, the real part of the result is the mathematical >>> a = c.real
>>> b = c.imag
>>> with extraprec(100): a
mpf('-6.4922176418510124160942132081389312851943535838e-21')
>>> with extraprec(100): (a**2 - b**2)/2
mpf('-6.49221764185101227404006767533335278133713234072e-21') I note that |
A bit of better 😉 news. The imaginary part of the 2-term series is, mathematically, So disaster analysis can focus exclusively on what happens to the real part. |
Thinking out loud here: if you want to move to different
elif cmag < -mp.prec:
# Series expansion, c - c**2/2.
a, b = c.real, c.imag
# Compute (a**2 = b**2) / 2 very accurately.
a2b2 = mp.ldexp(mp.fmul(a+b, a-b, exact=True), -1)
# a-a2b2 can lose almost all the bits of `a` to
# cancellation. That's why we want lots of extra
# precision in a2b2.
result = mpc(a - a2b2, b - a*b) Then again, I found it very elegant that the same code was used for both. |
FYI. about this earlier report, of a bad fast-path case when under the widened test ( >>> c = mpc(-6.4922176418510124e-21+1.1394926627101214e-10j)
>>> with workprec(10000): complex(mp.log1p(c)) # presumed correct
(-1.4201199664289643e-37+1.1394926627101214e-10j)
>>> complex(test(c)) # 2-term series only gets 4 correct digits
(-1.42033071087851e-37+1.1394926627101214e-10j)
# And cancellation is so bad the 3-term series isn't better
>>> with extraprec(10): complex(c*(c*(c/3.0 - 0.5) + 1.0))
(-1.42033071087851e-37+1.1394926627101214e-10j)
Empirically, as I mentioned before I have a generator now that produces nothing but cases where cancellation can matter. Using the original But I've so far failed to find a provable bound on how bad it can get. Indeed, the more I've thought about it, the more confused I've gotten about which approaches might work for that 😆. (*) This run is using the "only one fat mpf multiply, mpc-free" code I showed in my last comment. |
Hmm, does cancellation ever occur in this branch with original code? Edit: it seems, flint uses same cutoff, not sure if this was justified somehow, but. |
Absolutely. I covered this in some detail in the comment GIthub lost 😢 Here's a pretty dramatic example: >>> c = mpc(-1.430796815051627e-72+1.691624553529315e-36j)
>>> mp.mag(c)
-117
# That's way less than -63, so the original will take its fast path.
>>> with workprec(10000): complex(mp.log1p(c))
(4.6709293580298264e-91+1.691624553529315e-36j)
# I presume that the 10-thousand bit result is correct.
# But the original function just manages to get the first digit right:
>>> complex(mp.log1p(c))
(4.909093465297727e-91+1.691624553529315e-36j)
# The Horner spelling repairs that, though:
>>> with extraprec(10): complex(c * (1.0 - c * 0.5))
(4.6709293580298264e-91+1.691624553529315e-36j) It's not that Horner is somehow magical, it's that the cancellation occurs inside the implementation of The original spelling fails because in |
I think it's easy to show that But I'm starting to suspect nobody actually thought about it for the complex case 😜 |
I know few libraries, that provide complex variant of log1p. IIRC, julia just uses Kahan's trick. The MPC has no log1p (it's in TODO). |
Ya, IMO >>> c = mpc(0.5, mp.sqrt(.75000000001))
>>> c
mpc(real='0.5', imag='0.8660254037902122')
>>> abs(c)
mpf('1.000000000005')
# So numerically challenging despite that c.real is far from 0.
# How does mpmath do on it?
>>> mp.log(c)
mpc(real='5.0000441239368832e-12', imag='1.0471975511994844')
>>> mp.log(abs(c))
mpf('5.0000004136893552e-12')
# Oops? The real part materially differs from mp.log(|c|).
# But that's because |c| was computed with inadequate
# precision in the last line.
>>> with extraprec(100): absofc = abs(c)
>>> mp.log(absofc)
mpf('5.0000441239368832e-12')
# And that matches mp.log(c).real.
|
I'm going to share my "bad case" generator here, so there's a record of it on the web. It required some effort to come up with, and it's been extremely effective at "breaking" half a dozen from math import sqrt, ldexp, ulp
from random import random, randrange
# Generate c such that there's significant cancellation
# in computing the real part of c - c*c/2.
def getc():
a = random() * 0.5 + 0.5
a = ldexp(a, randrange(-500, -126))
a = -a
b = sqrt(a * (a - 2.0))
u = ulp(b)
u = ldexp(u, randrange(40))
if random() < 0.5:
u = -u
u *= random()
b += u
return complex(a, b) The last code I talked about hasn't hit a total ULP error over 0.5005 after 650 million of these so far. |
I think I may 😉 understand this better now. Where the last code had: a2b2 = mp.ldexp(mp.fmul(a+b, a-b, exact=True), -1)
result = mpc(a - a2b2, b - a*b) I'm hopeful that this will do almost as well (and saves two mpf additions): b2 = mp.ldexp(mp.fmul(b, b, exact=True), -1)
result = mpc(a + b2, b - a*b) Unless I'm wrong, the high half of the full So This is fuzzy, though, because Preliminary testing is going fine, but it's only tried 10 million cases. Since this was derived by analyzing the snot 😉 out of cancelling cases, I widened the generator to probe many more non-cancelling fast-path cases too. The reasoning for always ignoring |
BTW, a similar but simpler argument shows why widening the fast path is fine for the mpf case. How can we ensure that So we want |
And another: in the mpf case, the original Same kind of argument: the leading bit of And all these arguments are pessimistic. Supposing Alas, this approach appears to be of no help in the complex case. Arguments based on |
Let me make that concrete via an earlier example: >>> c = mpc(-1.430796815051627e-72+1.691624553529315e-36j)
>>> mp.mag(c)
-117
>>> mp.mag(c*c/2)
-237
# In the real-valued case, such a large difference in mag guarantees that
# adding/subtracting the two will leave `c` unchanged to 53 sig bits.
# But not so in the complex case:
>> with extraprec(100): complex(c*c/2)
(-1.430796815051627e-72-2.4203710234528746e-108j)
# The .real part of the result is almost exactly c.real.
# Massive cancellation during `-` despite huge mag difference. Which, yes. contradicts my:
That was careless. I had in mind arguments about, say, whether a series converges. So some arguments carry over, but not others - couldn't be clearer 😉 . |
Observation: in the latest code, which continues to deliver excellent test results after over 900 million trials: b2 = mp.ldexp(mp.fmul(b, b, exact=True), -1)
result = mpc(a + b2, b - a*b) consider what happens when BTW, while the |
Ah! The light dawns!! In the complex version, there are really two series: one for the real part, and another for the imag part. They both have to converge, but may converge at different rates. Looking at the imag series alone, by minor variation of arguments already given, only one term should suffice: with So on to testing this code, which loses another multiply and add. We're down to just one multiply and one add now: b2 = mp.ldexp(mp.fmul(b, b, exact=True), -1)
result = mpc(a + b2, b) The last run (using |
And one more twist: cancellation can't occur in b2 = mp.ldexp(mp.fmul(b, b, exact=a<0), -1) The bound can be loosened to |
For clarity, I should have spelled this out earlier: what I called
|
I think I'm about done here. One more loose end: justifying the By the reverse triangle inequality, If Otherwise To ensure Some billions of randomized test cases have been run by now, with total ULP error no worse than 1.001, and in "fast path" cases no more than 0.501. These include a billion randomized fast-path cases contrived to suffer significant cancellation in It's been fun 😉. def test(c):
c = mpc(c)
cmag = mp.mag(c)
with extraprec(10):
if cmag >= -mp.prec:
arg = mp.fadd(1.0, c, exact=cmag < 4)
result = mp.log(arg)
else:
a, b = c.real, c.imag
b2 = mp.ldexp(mp.fmul(b, b, exact=a < 0.0), -1)
result = mpc(a + b2, b)
return result |
EDIT: OUCH! This code is no good. The regular tests still haven't found anything interesting, but my "bad case" generator broke it instantly. Quite some time ago I noted that cancellation was so bad in one case that it almost needed a third sequence term. Ha! This one needs four(!) terms. >>> c = mpc(-3.1061140011623543e-21+7.881768838480807e-11j)
>>> mp.mag(c)
-32
>>> with extraprec(10000): complex(mp.log1p(c)) # presumed correct
(4.3173401185459216e-38+7.881768838480807e-11j)
>>> with extraprec(1000): complex(c - c**2/2) # only 3+ digits right
(4.3163753241271e-38+7.881768838480807e-11j)
>>> with extraprec(1000): complex(c - c**2/2 + c**3/3) # not much better!
(4.3183049129647434e-38+7.881768838480807e-11j)
>>> with extraprec(1000): complex(c - c**2/2 + c**3/3 - c**4/4) # bingo
(4.3173401185459216e-38+7.881768838480807e-11j) So I can't cut the complex fast-path range to BTW, this isn't a problem for the code posted before this message, because the Original msg, which should be ignored: For completeness, another version that widens the fast-path range to If you like this better, tell me and I'll run tests on it for a lot longer. No surprises after a few dozen million trials (worst error below 0.501 ULP in each component): def test(c):
c = mpc(c)
cmag = mp.mag(c)
with extraprec(10):
if 2 * cmag >= -mp.prec:
arg = mp.fadd(1.0, c, exact=cmag < 4)
result = mp.log(arg)
else:
a, b = c.real, c.imag
if cmag < -mp.prec:
b2 = mp.fmul(b, b, exact=a < 0.0)
else:
b2 = mp.fmul(b+a, b-a, exact=a < 0.0)
b -= a*b
result = mpc(a + mp.ldexp(b2, -1), b)
return result |
@tim-one, would you like to create a PR? If you want to put in randomized tests, maybe we should depend on the hypothesis. |
Sorry, no, I don't want to try to create a PR. I already spent far more time on this than I planned for, and all my work has been at the Python level, outside mpmath's implementation. The internals of mpmath seem partly a different world, with lots of internal functions, data representations, and conventions I know nothing about. So I expect it would go far easier, better, and faster if, e.g., you 😄 translated the few lines of code to internal "mpmath-speak". The are also decisions for Those are all project-level decisions, though, and I'm not a project dev here. I'm just a happy |
I truly can't make more time for this now, but there's something else I think needs to be addressed to get production-quality code. This line: arg = mp.fadd(1.0, c, exact=cmag < 4) can make the code arbitrarily expensive the smaller But the smaller Empirically, "just" tripling the precision appears so far to work: arg = mp.fadd(1.0, c,
prec=(mp.prec if cmag >= 4 else 3 * mp.prec)) but But I don't have a handle on how to prove that, nor the time to dig into it now. It's an interesting puzzle, though 😄. |
No, this doesn't count as "digging" 😉. Trying out intuitions in Python goes very fast. This appears to work, and I think the comments are qualitatively right, but there's no real justification for why "3" is sufficient when def test(c):
c = mpc(c)
cmag = mp.mag(c)
a, b = c.real, c.imag
with extraprec(10):
wp = mp.prec
if cmag >= -wp:
if cmag < 4:
if mp.mag(a) > mp.mag(b):
# `a` already contributes the most to c's norm.
# After adding 1, it will utterly dominate it.
# We only need enough extra precision to avoid
# losing any of a's bits when addiog 1.
wp *= 2
else:
# `b` contributes the most to c's norm now. After
# adding 1 to `a`, in (a+1)**2 + b**2 =
# 1 + 2*a + a**2 + b**2, `b` may be so much larger
# than `a` now that b**2 is close to }2*a|, or
# even cancel 2*a out (in which case the high-
# order bits of the teensy a**2 become important).
wp *= 3
arg = mp.fadd(1.0, c, prec=wp)
result = mp.log(arg)
else:
b2 = mp.ldexp(mp.fmul(b, b, exact=a < 0.0), -1)
result = mpc(a + b2, b)
return result |
WRT this comment:
that's exactly what happens in the original case that was the subject of this report, c = 1.8370676479640493e-39 - 4.6885882517313053e-20j. b**2 is in the same ballpark as 2a, but adding 1 to It doesn't matter to this that the |
So I think this is "the answer" (just came to me while walking): in the hard cases, the magnitude of b aquared is about To get the base idea across more intuitively, the worst cases are when |
Dang. There's an error in the other path of the code (using the series expansion), at mpc(-1.999999873062092e-40+1.999999936531045e-20j) Random testing would probably never find one like this. It was contrived so that Computing the full 2nd-order real seriea, The "doesn't try to take any shortcuts" Horner trick, evaluating Unclear what to do. Using exact adds too is extremely unattractive 😢 . |
OK! Not so bad 😉. The code gets more long-winded, though: EXTRAPREC = 10
EXTRAPREC_MAG = mp.mag(EXTRAPREC)
def test(c):
c = mpc(c)
cmag = mp.mag(c)
a, b = c.real, c.imag
with extraprec(EXTRAPREC):
wp = mp.prec
if cmag >= -wp:
if cmag < 4:
if mp.mag(a) > mp.mag(b):
# `a` already contributes the most to c's norm.
# After adding 1, it will utterly dominate it.
# We only need enough extra precision to avoid
# losing any of a's significant bits when addiog,
wp *= 2
else:
# `b` contributes the most to c's norm now. After
# adding 1 to `a`, in (a+1)**2 + b**2 =
# 1 + 2*a + a**2 + b**2, `b` may be so much larger
# than `a` now that b**2 is close to }2*a|, or
# even cancel 2*a out (in which case the high-
# order bits of the teensy a**2 become important).
wp *= 3
arg = mp.fadd(1.0, c, prec=wp)
result = mp.log(arg)
else:
might_cancel = a < 0.0
b2 = mp.ldexp(mp.fmul(b, b, exact=might_cancel), -1)
apb2 = a + b2
if might_cancel:
if mp.mag(apb2) <= mp.mag(a) - EXTRAPREC_MAG:
# The guard bits were lost to cancellation. Rare.
# At the contrived
# -1.999999873062092e-40+1.999999936531045e-20j
# _all_ bits cancel out. Complete the full
# 2nd-order real series, esactly.
a2 = mp.ldexp(mp.fmul(a, a, exact=True), -1)
apb2 = a + mp.fsub(b2, a2, exact=True)
#print("X", mp.mag(apb2), mp.mag(a))
result = mpc(apb2, b)
return result |
Last for now rearranging the code for clarity and adding some comments. The comment justifying using LOG1P_EXTRAPREC = 10
LOG1P_EXTRAPREC_MAG = mp.mag(LOG1P_EXTRAPREC)
def test(c):
c = mpc(c)
cmag = mp.mag(c)
a, b = c.real, c.imag
with extraprec(LOG1P_EXTRAPREC):
wp = mp.prec
if cmag >= -wp:
if cmag < 4:
if mp.mag(a) > mp.mag(b):
# `a` already contributes the most to c's norm.
# After adding 1, it will utterly dominate it.
# We only need enough extra precision to avoid
# losing any of a's significant bits when addiog,
wp *= 2
else:
# `b` contributes the most to c's norm now. After
# adding 1 to `a`, in (a+1)**2 + b**2 =
# 1 + 2*a + a**2 + b**2, `b` may be so much larger
# than `a` now that b**2 is close to }2*a|, or
# even cancel 2*a out (in which case the high-
# order bits of the teensy a**2 become important).
wp *= 3
arg = mp.fadd(1.0, c, prec=wp)
result = mp.log(arg)
# Else c is "very small", and we use a series expansion.
# c - c**2/2
# The real part of that is a+(b*b-a*a)/2, and the
# imag part b-a*b.
# Given that cmag < -prec, it can bu shown that the "a*b"
# is numerically insignifcant in the imag part, and
# _usually_ the "a*a/2" in the real part. What remains
# is cheap to compute.
# In the real part, though, if `a` is negative, the
# remaining a+b**2/2 can suffer massive cancellation -
# even total.
elif a < 0.0: # cancellation is a potential problam
#b2 = mp.ldexp(mp.fmul(b, b, exact=True), -1)
apb2 = a + mp.ldexp(b*b, -1)
if mp.mag(apb2) <= mp.mag(a) - LOG1P_EXTRAPREC_MAG:
# The guard bits were lost to cancellation. Rare.
# At the contrived
# -1.999999873062092e-40+1.999999936531045e-20j
# _all_ bits cancel out. Complete the full
# 2nd-order real series, esactly.
a2 = mp.ldexp(mp.fmul(a, a, exact=True), -1)
b2 = mp.ldexp(mp.fmul(b, b, exact=True), -1)
apb2 = a + mp.fsub(b2, a2, exact=True)
result = mpc(apb2, b)
else: # cancellation not possible
result = mpc(a + mp.ldexp(b*b, -1), b)
return result |
Ya, I dug into it 😢 . There isn't real need for exact operations of any kind in the worst series-expansion cases. I'm uncomfortable with such large comment blocks, but don't want to write a book either 😉. I'm also uncomfortable with the relative lack of justifying comments in mpmath's internals. So how to deal with that is another mpmath-internal project decision. LOG1P_EXTRAPREC = 10
LOG1P_EXTRAPREC_MAG = mp.mag(LOG1P_EXTRAPREC)
def test(c):
c = mpc(c)
cmag = mp.mag(c)
a, b = c.real, c.imag
with extraprec(LOG1P_EXTRAPREC):
wp = mp.prec
if cmag >= -wp:
if cmag < 4:
if mp.mag(a) > mp.mag(b):
# `a` already contributes the most to c's norm.
# After adding 1, it will utterly dominate it.
# We only need enough extra precision to avoid
# losing any of a's significant bits when addiog,
wp *= 2
else:
# `b` contributes the most to c's norm now. After
# adding 1 to `a`, in (a+1)**2 + b**2 =
# 1 + 2*a + a**2 + b**2, `b` may be so much larger
# than `a` now that b**2 is close to }2*a|, or
# even cancel 2*a out (in which case the high-
# order bits of the teensy a**2 become important).
wp *= 3
arg = mp.fadd(1.0, c, prec=wp)
result = mp.log(arg)
# Else c is "very small", and we use a series expansion.
# c - c**2/2
# The real part of that is a+(b*b-a*a)/2, and the
# imag part b-a*b.
# Given that cmag < -prec, it can bu shown that the "a*b"
# is numerically insignifcant in the imag part, and
# _usually_ the "a*a/2" in the real part. What remains
# is cheap to compute.
# In the real part, though, if `a` is negative, the
# remaining a+b**2/2 can suffer massive cancellation -
# even total.
elif a < 0.0: # cancellation is a potential problam
apb2 = a + mp.ldexp(b*b, -1)
if mp.mag(apb2) <= mp.mag(a) - LOG1P_EXTRAPREC_MAG:
# The guard bits were lost to cancellation. Rare.
# At the contrived
# -1.999999873062092e-40+1.999999936531045e-20j
# _all_ bits cancel out.
# Since a ~= -b*b/2 in this case, and |b| is at
# largest (worst case) about 2**-prec, |a| is
# about 2**(-2*prec), and the true result may
# be as small as a**2/2, which is about
# 2**(-4*prec), of which we want the leading
# prec bits. To get the leading prec bits
# starting at 2**(-4**prec) from addends
# starting at 2**(-2*prec), we need the
# addition to handle 3*prec bits (the first
# 2*prec of which may cancel to exactly 0).
a2 = a*a*0.5 # only need at worst prec bits
b2 = mp.ldexp(mp.fmul(b, b, prec=2*wp), -1)
apb2 = a + mp.fsub(b2, a2, prec=3*wp)
result = mpc(apb2, b)
else: # cancellation not possible
result = mpc(a + mp.ldexp(b*b, -1), b)
return result |
Sorry about this LOG1P_EXTRAPREC = 10
def test(c):
c = mpc(c)
cmag = mp.mag(c)
a, b = c.real, c.imag
with extraprec(LOG1P_EXTRAPREC):
wp = mp.prec
if cmag >= -wp:
if cmag < 4:
if mp.mag(a) > mp.mag(b):
# `a` already contributes the most to c's norm.
# After adding 1, it will utterly dominate it.
# We only need enough extra precision to avoid
# losing any of a's significant bits when addiog,
wp *= 2
else:
# `b` contributes the most to c's norm now. After
# adding 1 to `a`, in (a+1)**2 + b**2 =
# 1 + 2*a + a**2 + b**2, `b` may be so much larger
# than `a` now that b**2 is close to }2*a|, or
# even cancel 2*a out (in which case the high-
# order bits of the teensy a**2 become important).
wp *= 3
arg = mp.fadd(1.0, c, prec=wp)
result = mp.log(arg)
# Else c is "very small", and we use a series expansion.
# c - c**2/2
# The real part of that is a+(b*b-a*a)/2, and the
# imag part b-a*b.
# Given that cmag < -prec, it can bu shown that the "a*b"
# is numerically insignifcant in the imag part, and
# _usually_ the "a*a/2" in the real part. What remains
# is cheap to compute.
# In the real part, though, if `a` is negative, the
# remaining a+b**2/2 can suffer massive cancellation -
# even total.
elif a < 0.0: # cancellation is a potential problam
apb2 = a + b*b*0.5
if mp.mag(apb2) <= mp.mag(a) - LOG1P_EXTRAPREC:
# The guard bits were lost to cancellation. Rare.
# At the contrived
# -1.999999873062092e-40+1.999999936531045e-20j
# _all_ bits cancel out.
# Since a ~= -b*b/2 in this case, and |b| is at
# largest (worst case) about 2**-prec, |a| is
# about 2**(-2*prec), and the true result may
# be as small as a**2/2, which is about
# 2**(-4*prec), of which we want the leading
# prec bits. To get the leading prec bits
# starting at 2**(-4**prec) from addends
# starting at 2**-(2*prec), we need the
# addition to handle 3*prec bits (the first
# 2*prec of which may cancel to exactly 0).
a2 = a*a*0.5 # only need at worst prec bits
b2 = mp.ldexp(mp.fmul(b, b, prec=2*wp), -1)
apb2 = a + mp.fsub(b2, a2, prec=3*wp)
result = mpc(apb2, b)
else: # cancellation not possible
result = mpc(a + b*b*0.5, b)
return result |
And another "final" version, fleshing out comments and rearranging the code more for clarity. I apologize for all the noise. I had no idea at the start how many subtle cancellation surprises were hiding in the details. If I had even an inkling of that, I would have set up a repository instead. I figured it would take a few hours at most - hahahaha 😝 If I ever need to work on a complex function again, at least there are days of "learning curve" I won't have to climb again (e.g., that rapid convergence of the norm says very little about the convergence rates of the derived real and imag series; and that component cancellation can be massive in a complex series even though the same series never gets anywhere near cancellation when applied to reals). LOG1P_EXTRAPREC = 10
def test(c):
# Note that all cases could by handled by log(1+c) provided the
# add is done exactly. Our aim here is be much faster than that,
# especially when |c| is small.
c = mpc(c)
cmag = mp.mag(c)
a, b = c.real, c.imag
with extraprec(LOG1P_EXTRAPREC):
wp = mp.prec
if cmag >= -wp:
# |c| isn't very small. We call log(1+c) instead, but
# are careful about the precision used by the add. The
# real part of the result is log(|c+1|). That's
# determined by 1 + 2*a + a**2 + b**2, and the add has
# to preserve enough info so that no important bits of
# that sum are lost. It doesn't matter to this that 2*a,
# a**2, etc, are not computed explicitly here: we're
# deducing how many bits have to be present in the sum
# for log() to "reverse engineer" the value of 2*a +
# a**2 + b**2 to `prec` good bits,
if cmag < 4:
# |c| isn't very small, or large.
if mp.mag(a) > mp.mag(b):
# `a` already contributes the most to c's norm.
# After adding 1, it will utterly dominate it.
# We only need enough extra precision to avoid
# losing any of a's `prec` most significant bits
# when addiog, `b**2` is too small to matter.
wp *= 2
else:
# b**2 is the larger of the square terms. The
# smallest b can be is about 2**-prec, so the
# smallest b**2 can be is about 2**(-2*prec). So
# for a bit to matter compared to b**2, it has
# to be at least about 2**(-3*prec). Bits of 2*a
# (if any) >= 2**(-3*prec) will be preserved if
# we use 3*prec bits for the add.
wp *= 3
# Else (cmag >= 4), |c+1| >= |c| - 1 is so large that
# working precision is fine (although that takes some
# careful analysis for cmag=4, given that .mag() _may_
# return a rexult too large by 2), So leave wp alone.
arg = mp.fadd(1.0, c, prec=wp)
result = mp.log(arg)
else:
# Else c is "very small", and we use a series expansion,
# c - c**2/2. The real part of that is a+(b*b-a*a)/2,
# and the imag part b-a*b. Given that cmag < -prec, it
# can be shown that "a*b" is numerically insignifcant in
# the imag part, and _usually_ the "a*a/2" in the real
# part. What remains is cheap to compute. In the real
# part, though, if `a` is negative, the remaining
# a+b**2/2 can suffer massive cancellation - even total.
real = a + b*b*0.5 # usually the real part of the result
if (a < 0.0
and mp.mag(real) <= mp.mag(a) - LOG1P_EXTRAPREC):
# The guard bits were lost to cancellation. Rare. At
# the contrived
# -1.999999873062092e-40+1.999999936531045e-20j
# _all_ bits cancel out. Since a ~= -b*b/2 in this
# case, and |b| is at largest (worst case) about
# 2**-prec, |a| is about 2**(-2*prec), and the true
# result may be as small as a**2/2, which is about
# 2**(-4*prec), of which we want the leading prec
# bits. To get the leading prec bits starting at
# 2**(-4**prec) from addends starting at
# 2**-(2*prec), we need the subtraction to handle
# 3*prec bits (the first 2*prec of which may cancel
# to exactly 0).
a2 = a*a # only need at worst prec bits
b2 = mp.fmul(b, b, prec=2*wp)
diff = mp.fsub(b2, a2, prec=3*wp)
real = a + mp.ldexp(diff, -1)
result = mpc(real, b)
return result |
Offline, it was suggested that the newer code to limit the precision used (instead of using But, alas, that reasoning only works for the real-valued function. For complex For example, in Given that While I'm doing my testing on native machine doubles, of course |
Patch, ported from Tim Peters version at: mpmath#790 (comment) And few tests from the issue thread Closes mpmath#790
A case where lop1p of a complex number gets a poor real part:
With the higher precision, the real part of the result is entirely different. I don't understand the code:
Because -63 < -53, I expected the code to take the branch that uses a 2-term expansion, which works fine:
But I must not have found the correct source code, since editing it myself has no visible effect.
The other branch in the code calls
log()
instead after adding 1 to c with double precision:It's not enough extra precision to help. In this case,
To be clear, "the code" I'm talking about is this, from functions/functions.py:
LATER: found the right source code.
ctx.mag(x)
and-ctx.prec
in the above both return -63 in this case, so that's why the "wrong" branch is taken. I have no idea howctx.prec
ended up at 63. As shown above, I never changed it from the default 53.The text was updated successfully, but these errors were encountered: