Skip to content

ENH: special: Extend Riemann Zeta function to complex inputs#21744

Merged
mdhaber merged 46 commits into
scipy:mainfrom
steppi:complex-zeta
Oct 28, 2024
Merged

ENH: special: Extend Riemann Zeta function to complex inputs#21744
mdhaber merged 46 commits into
scipy:mainfrom
steppi:complex-zeta

Conversation

@steppi

@steppi steppi commented Oct 23, 2024

Copy link
Copy Markdown
Member

Reference issue

Closes #14073

What does this implement/fix?

This PR extends the Riemann Zeta function to complex inputs. It follows a Python reference implementation written by @mdhaber and posted in a Colab notebook here. There are a couple of minor tweaks I'm aware of which could improve accuracy slightly, but I will leave those for a follow-up to make this easier to review. As can be seen in the linked notebook, results are sufficiently accurate for this to get in as is.

The primary ways the implementation here differs from the reference implementation are that

  1. Coefficients for expansions are stored in lookup tables.
  2. Some factors (well logs of factors) in the Euler-Maclaurin sum are updated incrementally in each iteration, rather than recomputed from scratch.
  3. A slight algebraic manipulation as been made to the Borwein series. It is now able to handle z with arbitrarily large real part, so there is no need to have a special case for z with large real part.
  4. The logsumexp calculation for the log of sinpi must be done manually, since logsumexp is not something that's available currently in xsf. I added simple (private) implementation of complex exppi ($exp(\pi z)$) for use here.

I've added test cases comparing with the mpmath reference implementation.

Additional information

Some things that may need attention:

The Riemann zeta function and the Hurwitz Zeta function are currently combined into a single function zeta which dispatches to the ufunc _riemann_zeta when an argument q=None, and otherwise dispatches to a ufunc _zeta for the Hurwitz zeta function. I've only added complex support for _riemann_zeta, and zeta will error out when called on complex input when q is not None. Having accepted input types depend on a keyword argument isn't ideal, but I think it's acceptable here, and complex support for Hurwitz Zeta is on the way (@mdhaber has written a reference implementation for this too). We could However change this so that complex inputs are still accepted when q is not None, but NaN is returned when off the real line. This is similar to what currently happens for inputs x < 1. They are only supported for q = None, but not for any other q.

Is there a better place for the Python reference implementation than a colab notebook? Perhaps we could add reference implementations to the xsf repo? I think it's fine not to worry about this now though.

@steppi steppi requested a review from person142 as a code owner October 23, 2024 18:30
@github-actions github-actions Bot added scipy.special C/C++ Items related to the internal C/C++ code base enhancement A new feature or improvement labels Oct 23, 2024
@steppi steppi requested review from mdhaber and removed request for person142 October 23, 2024 18:38

@mdhaber mdhaber left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Great! High level comments to start; I'll take a look at the details this afternoon.

Comment thread scipy/special/_basic.py Outdated
Comment thread scipy/special/tests/test_zeta.py Outdated
Comment thread scipy/special/tests/test_zeta.py Outdated
Comment thread scipy/special/tests/test_zeta.py Outdated
Comment thread scipy/special/xsf/zeta.h Outdated
Comment thread scipy/special/xsf/zeta.h
Comment thread scipy/special/xsf/zeta.h
Comment thread scipy/special/xsf/zeta.h Outdated

@mdhaber mdhaber left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Math looks pretty good except that the $\log \sin$ part, asymptotic approximation of $\frac{|B_{2n}|}{(2k)!}$), the last term of the EM formula, and the EM estimate are much harder to follow than the rest. It's probably best if I ask for your help there, since I imagine that future readers would have trouble, too.

Comment thread scipy/special/xsf/zeta.h
Comment thread scipy/special/xsf/zeta.h
Comment thread scipy/special/xsf/zeta.h Outdated
Comment thread scipy/special/xsf/zeta.h Outdated
Comment thread scipy/special/xsf/zeta.h Outdated
Comment thread scipy/special/xsf/zeta.h
Comment thread scipy/special/xsf/zeta.h
Comment thread scipy/special/xsf/zeta.h Outdated
Comment thread scipy/special/xsf/zeta.h Outdated
Comment thread scipy/special/tests/test_zeta.py
# reference = complex(mp.zeta(t))
# cases.append((complex(t), reference, default_rtol))

# # Very large imaginary part

@mdhaber mdhaber Oct 25, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

How long do these take for you to run? (I know it doesn't really matter since it's all offline, but it is taking forever.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Extremely fast

In [2]: %timeit run zeta_cases.py
23.1 ms ± 136 μs per loop (mean ± std. dev. of 7 runs, 1 loop each)

Note sure what's going on. My mpmath version is 1.3.0 and I just use the default dps.

@steppi

steppi commented Oct 25, 2024

Copy link
Copy Markdown
Member Author

For the failing test cases, I think it's fine to punt on the Intel oneAPI failures, and I've attempted to xfail those. I also loosened the rtol for a couple test cases.

Comment thread scipy/special/xsf/zeta.h
Comment thread scipy/special/xsf/zeta.h Outdated
Comment thread scipy/special/xsf/zeta.h Outdated

@mdhaber mdhaber left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

OK, I wrote before:

Math looks pretty good except that the $\log \sin$ part, asymptotic approximation of $\frac{|B_{2n}|}{(2k)!}$), the last term of the EM formula, and the EM estimate are much harder to follow than the rest.

I've checked the $\log \sin$ part (easier as a separate function), the asymptotic approximation of $\frac{|B_{2n}|}{(2k)!}$) (much easier now that I know the equation you were starting with), and the error estimate (easier with the indexing adjusted to match the reference - but I'll want to take another look after the corrections).

I tried checking the $\sum T_{k, n}$ part but it is still challenging to cross the i's and dot the t's. If there's anything else you're willing to do to make it look more like the reference, I'd appreciate it. Every little bit would help, like using -z * log(n) in log_factor instead of log(b) and changing the name of the loop variable. Whatever happens, next time I sit down I'm sure I'll be able to verify it. I also need to verify some of the new mpmath test cases at my home computer, since they were running too slowly on Colab.

Then I'll probably play with this locally a bit and do one more superficial pass, and I'll probably be happy with it.

Do you also want someone more familiar with the special/C++-specific stuff to review from that perspective, or are you comfortable with that as long as I've checked the algorithmic stuff?

@steppi

steppi commented Oct 25, 2024

Copy link
Copy Markdown
Member Author

I tried checking the ∑ T k , n part but it is still challenging to cross the i's and dot the t's. If there's anything else you're willing to do to make it look more like the reference, I'd appreciate it. Every little bit would help, like using -z * log(n) in log_factor instead of log(b) and changing the name of the loop variable.

Noted. Things should be more clear now.

Do you also want someone more familiar with the special/C++-specific stuff to review from that perspective, or are you comfortable with that as long as I've checked the algorithmic stuff?

Feel free to merge based on your algorithmic review. This is all pretty straightforward from a C++ perspective.

Comment thread scipy/special/xsf/zeta.h Outdated
@mdhaber

mdhaber commented Oct 28, 2024

Copy link
Copy Markdown
Contributor

Playing around with this, I think it's pretty good. I think you already knew that 0 + 0j is a problem spot.

(The positive axis labels are something like 16 + np.log10(diff), where diff is the distance from the center point.)

{B1576EF7-F254-4E12-B9AD-C9CEC61A22FF}

z = (1e-15 + 1e-15j)
res = complex(scipy_zeta(z))
ref = complex(mpmath_zeta(z))
print(res, ref, abs(res-ref)/abs(ref))
# (-0.5097353193036964+0.010130110463593661j) (-0.5000000000000009-9.189385332046748e-16j) 0.02809950746540121

Similar story at 1 + 0j.

{B9ABDAAF-0E2C-48FB-8DCC-B1A7B7AE0E32}

z = (1+1e-15 + 1e-15j)
res = complex(scipy_zeta(z))
ref = complex(mpmath_zeta(z))
print(res, ref, abs(res-ref)/abs(ref))
# (496745969635765.9-443048778321942.06j) (497279149540589.06-447909238514022.3j) 0.007306002718971802

At negative even integers (trivial zeros), there is only a problem on the real axis (at least imaginary offsets greater than 1e-16).

{AD8708C7-BDAB-405A-9340-F6E1C19A4C1E}

z = (-2+1e-15)
res = complex(scipy_zeta(z))
ref = complex(mpmath_zeta(z))
print(res, ref, abs(res-ref)/abs(ref))
# (-3.6806848447762256e-17+0j) (-3.3804578090538615e-17+0j) 0.08881253743746413

But the absolute error is OK, and this is a problem with the existing real implementation, not this PR. It might be worth a second look at whether we should just use the complex implementation even when the input is real. IIRC, there were problems at the trivial zeros in my implementation, so I replaced it on the entire real axis, but maybe we should just special-case the trivial zeros.

When we zoom out, though, the error is typically quite good. Consider white to mean esentially zero error: on the left, both mpmath and scipy return an infinite result, and on the right, the result of both is essentially just 1.0.

{880B8A01-EA32-486F-81F6-98A4FD405329}

Looking near the critical strip, we get some inaccuracy near the transition between algorithms.

{5F9F9BCC-5DD4-4617-B24F-F19F33F6662C}

But typically the error near the critical strip is not bad.

{8915C4CA-0C28-4DBC-931F-D57EC5C32D41}

It seems fine very close to the real axis.

{078E5969-0142-41F2-8201-0FA385712B0F}

There is a transition near 50 + 50j that looks fine.

{CA7BAFFA-4EB7-4736-A872-3D94385C3D21}

The transitions at 50 - 50j looks about the same, and the transitions at the negative reals are invisible, probably because the error is all due to use of the log-reflection (but it's on the order of 1e-13, so nothing to worry about).

In the critical strip with imaginary part close to 1e9, the error is around 1e-7. It takes quite a long time where EM is used here, but probably not so long that people would think it hangs. You've added some protection against long execution times for real part less than 2.5, but it can start taking a long time and get inaccurate when the real and imaginary parts are greater, e.g.

z = 2.51 + 1e13*1j
res = complex(scipy_zeta(z))
ref = complex(mpmath_zeta(z))
print(res, ref, abs(res-ref)/abs(ref))
# (0.9381747827300801-0.21060417060191572j) (0.938250825152661-0.2106505908517201j) 9.26485094426602e-05

But I think this PR is about the main algorithms, not the fine tuning or special cases. We know where some additional work may be needed, but let's merge the main implementation. Thanks @steppi! (I'll merge later tonight if you're happy, too.)

@mdhaber mdhaber merged commit c7835c8 into scipy:main Oct 28, 2024
@h-vetinari

Copy link
Copy Markdown
Member

Was just updating the release notes for the factorial changes, and noticed that this doesn't have an entry yet.

@lucascolley lucascolley added this to the 1.15.0 milestone Nov 13, 2024
@steppi

steppi commented Nov 13, 2024

Copy link
Copy Markdown
Member Author

Was just updating the release notes for the factorial changes, and noticed that this doesn't have an entry yet.

Thanks @h-vetinari. There's a few other things I need to add release notes for too. I'll try to get them all done by the end of the month.

@lucascolley lucascolley added the needs-release-note A maintainer should add a release note written by a reviewer/author to the wiki. label Nov 13, 2024
@mdhaber mdhaber removed the needs-release-note A maintainer should add a release note written by a reviewer/author to the wiki. label Nov 17, 2024
@steppi steppi deleted the complex-zeta branch March 30, 2026 18:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

C/C++ Items related to the internal C/C++ code base enhancement A new feature or improvement scipy.special

Projects

None yet

Development

Successfully merging this pull request may close these issues.

scipy.special.zeta and zetac don't take complex variables

4 participants