Like most people, I've had a lot of free time recently, and I've spent some of it watching various YouTube videos about the Riemann Hypothesis. I've collected the videos I've watched into YouTube playlist. The playlist is sorted with the most mathematically approachable videos first, so even if you haven't studied complex analysis before, you can watch the first few. If you have studied complex analysis, all the videos will be within your reach (none of them are highly technical with proofs). Each video contains parts that aren't in any of the other videos, so you will get something out of watching each of them.
One of the videos near the end of the playlist is a lecture by Keith Conrad. In it, he mentioned a method by which one could go about verifying the Riemann Hypothesis with a computer. I wanted to see if I could do this with SymPy and mpmath. It turns out you can.
Before we get to the computations, let's go over some mathematical background. As you may know, the Riemann Hypothesis is one of the 7 Millennium Prize Problems outlined by the Clay Mathematics Institute in 2000. The problems have gained some fame because each problem comes with a $1,000,000 prize if solved. One problem, the Poincaré conjecture, has already been solved (Grigori Perelman who solved it turned down the 1 million dollar prize). The remainder remain unsolved.
The Riemann Hypothesis is one of the most famous of these problems. The reason for this is that the problem is central many open questions in number theory. There are hundreds of theorems which are only known to be true contingent on the Riemann Hypothesis, meaning that if the Riemann Hypothesis were proven, immediately hundreds of theorems would be proven as well. Also, unlike some other Millennium Prize problems, like P=NP, the Riemann Hypothesis is almost universally believed to be true by mathematicians. So it's not a question of whether or not it is true, just one of how to actually prove it. The problem has been open for over 160 years, and while many advances have been made, no one has yet come up with a proof of it (crackpot proofs aside).
To understand the statement of the hypothesis, we must first define the zeta function. Let
(that squiggle
The product ranges over all prime numbers, i.e., it is
Let's take a closer look at what this is. It is
where
So to expand the infinite product, we do the same thing. We take every
combination of picking
where each prime power combination is picked exactly once. However, we know by the Fundamental Theorem of Arithmetic that when you take all combinations of products of primes that you get each positive integer exactly once. So the above sum is just
In other words,
$$\zeta(s) = \sum_{n=1}^\infty \frac{1}{n^s} = \prod_{\text{$p$
prime}}\frac{1}{1 - \frac{1}{p^s}},$$ for
In 1859, Bernhard Riemann wrote a short 9 page paper on number theory and the zeta function. It was the only paper Riemann ever wrote on the subject of number theory, but it is undoubtedly one of the most important papers every written on the subject.
In the paper, Riemann considered that the zeta function summation,
makes sense not just for integers
Riemann wanted to extend this function to the entire complex plane, not just
Riemann used the following approach. Consider what we might call the "completed zeta function"
Using Fourier analysis, Riemann gave a formula for
-
$Z(s)$ is defined everywhere in the complex plane, except for simple poles at 0 and 1. -
$Z(s) = Z(1 - s).$ This means if we have a value for$s$ that is right of the line$\mathrm{Re}(z) = \frac{1}{2},$ we can get a value to the left of it by reflecting it over the real-axis and the line at$\frac{1}{2}$ (to see this, note that the average of$s$ and$1 - s$ is$1/2$ , so the midpoint of a line connecting the two should always go through the point$1/2$ ).
(Reflection of
Looking at
So taking this, along with the fact that
-
$Z(s)$ has a simple pole at 1, which means that$\zeta(s)$ does as well. This is not surprising, since we already know the summation formula from above diverges as$s$ approaches 1. -
$Z(s)$ has a simple pole at 0. Since$\Gamma\left(\frac{s}{2}\right)$ also has a simple pole at 0, they must cancel and$\zeta(s)$ must have neither a zero nor a pole at 0 (in fact,$\zeta(0) = -1/2$ ). - Since
$\Gamma\left(\frac{s}{2}\right)$ has no zeros, there are no further poles of$\zeta(s)$ . Thus,$\zeta(s)$ is entire everywhere except for a simple pole at$s=1$ . -
$\Gamma\left(\frac{s}{2}\right)$ has poles at the remaining negative even integers. Since$Z(s)$ has no poles there, these must correspond to zeros of$\zeta(s)$ . These are the so-called "trivial" zeros of the zeta function, at$s=-2, -4, -6, \ldots$ . The term "trivial" here is a relative one. They are trivial to see from the above formula, whereas other zeros of$\zeta(s)$ are much harder to find. -
$\zeta(s) \neq 0$ if$\mathrm{Re}(s) > 1$ . One way to see this is from the Euler product formula. Since each term in the product is not zero, the function itself cannot be zero (this is a bit hand-wavy, but it can be made rigorous). This implies that$Z(s) \neq 0$ in this region as well. We can reflect$\mathrm{Re}(s) > 1$ over the line at$\frac{1}{2}$ by considering$\zeta(1 - s)$ . Using the above formula and the fact that$Z(s) = Z(1 - s)$ , we see that$\zeta(s)$ cannot be zero for$\mathrm{Re}(s) < 0$ either, with the exception of the aforementioned trivial zeros at$s=-2, -4, -6, \ldots$ .
Thus, any non-trivial zeros of
Whenever you have a mathematical hypothesis, it is good to check if it is true
numerically. Riemann himself used some methods (not the same ones we use here)
to numerically estimate the first few non-trivial zeros of
If we verified that all the zeros in the critical strip from, say,
How would we verify that the zeros are all on the line
We want to find rigorous answers to these two questions:
-
How can we count the number of zeros between
$\mathrm{Im}(s) = 0$ and$\mathrm{Im}(s) = N$ of$\zeta(s)$ ? -
How can we verify that all those zeros lie on the critical line, that is, they have real part equal to exactly
$1/2$ ?
To answer the first question, we can make use of a powerful theorem from
complex analysis called the argument
principle.
The argument principle says that if
$$\frac{1}{2\pi i}\oint_C \frac{f'(z)}{f(z)}\,dz = \#\left\{\text{zeros of
In other words, the integral on the left-hand side counts the number of zeros
of
One should take a moment to appreciate the beauty of this result. The left-hand side is an integral, something we generally think of as being a continuous quantity. But it is always exactly equal to an integer. Results such as these give us a further glimpse at how analytic functions and complex analysis can produce theorems about number theory, a field which one would naively think can only be studied via discrete means. In fact, these methods are far more powerful than discrete methods. For many results in number theory, we only know how to prove them using complex analytic means. So-called "elementary" proofs for these results, or proofs that only use discrete methods and do not use complex analysis, have not yet been found.
Practically speaking, the fact that the above integral is exactly an integer means that if we compute it numerically and it comes out to something like 0.9999999, we know that it must in fact equal exactly 1. So as long as we get a result that is near an integer, we can round it to the exact answer.
We can integrate a contour along the critical strip up to some
So using the argument principle, we can count the number of zeros in a region.
Now how can we verify that they all lie on the critical line? The answer lies
in the
This helps us because
However,
which means that
This simplifies things a lot, because it is much easier to find zeros of a real
function. In fact, we don't even care about finding the zeros, only counting
them. Since
So our approach to verifying the Riemann Hypothesis is as such:
-
Integrate
$\frac{1}{2\pi i}\oint_C Z'(s)/Z(s)\,ds$ along a contour$C$ that runs along the critical strip up to some$\mathrm{Im}(s) = N$ . The integral will tell us there are exactly$n$ zeros in the contour, counting multiplicity. -
Try to find
$n$ sign changes of$Z(1/2 + it)$ for$t\in [0, N]$ . If we can find$n$ of them, we are done. We have confirmed all the zeros are on the critical line.
Step 2 would fail if the Riemann Hypothesis is false, in which case a zero wouldn't be on the critical line. But it would also fail if a zero has a multiplicity greater than 1, since the integral would count it more times than the sign changes. Fortunately, as it turns out, the Riemann Hypothesis has been verified up to N = 10000000000000, and no one has yet found a zero of the zeta function yet that has a multiplicity greater than 1, so we should not expect that to happen (no one has yet found a counterexample to the Riemann Hypothesis either).
We now use SymPy and mpmath to compute the above quantities. We use
SymPy to do symbolic manipulation for us, but the
heavy work is done by mpmath.
mpmath is a pure Python library for arbitrary precision numerics. It is used
by SymPy under the hood, but it will be easier for us to use it directly. It
can do, among other things, numeric integration. When I first tried to do
this, I tried using the scipy.special
zeta
function,
but unfortunately, it does not support complex arguments.
First we do some basic imports
>>> from sympy import *
>>> import mpmath
>>> import numpy as np
>>> import matplotlib.pyplot as plt
>>> s = symbols('s')
Define the completed zeta function
>>> Z = pi**(-s/2)*gamma(s/2)*zeta(s)
We can verify that Z is indeed real for
>>> Z.subs(s, 1/2 + 0.5j).evalf()
-1.97702795164031 + 5.49690501450151e-17*I
We get a small imaginary part due to the way floating point arithmetic works.
Since it is below 1e-15
, we can safely ignore it.
D
will be the logarithmic derivative of Z
.
>>> D = simplify(Z.diff(s)/Z)
>>> D
polygamma(0, s/2)/2 - log(pi)/2 + Derivative(zeta(s), s)/zeta(s)
This is
Note that logarithmic derivatives behave similar to logarithms. The
logarithmic derivative of a product is the sum of logarithmic derivatives (the
We now use
lambdify
to convert the SymPy expressions Z
and D
into functions that are evaluated
using mpmath. A technical difficulty here is that the derivative of the zeta
function zeta
can evaluate
$\zeta'$
but it doesn't yet work with sympy.lambdify
(see SymPy issue
11802). So we have to manually
define "Derivative"
in lambdify, knowing that it will be the derivative of
zeta
when it is called. Beware that this is only correct for this specific
expression where we know that Derivative
will be Derivative(zeta(s), s)
.
>>> Z_func = lambdify(s, Z, 'mpmath')
>>> D_func = lambdify(s, D, modules=['mpmath',
... {'Derivative': lambda expr, z: mpmath.zeta(z, derivative=1)}])
Now define a function to use the argument principle to count the number of
zeros up to
We have to be careful about the poles of
It has also been shown that there are no zeros on the lines
(Our contour with
mpmath.quad
can take a list of points to compute a contour. The maxdegree
parameter
allows us to increase the degree of the quadrature if it becomes necessary to
get an accurate result.
>>> def argument_count(func, N, maxdegree=6):
... return 1/(2*mpmath.pi*1j)*(mpmath.quad(func,
... [1 + 0.1j, 1 + N*1j, 0 + N*1j, 0 + 0.1j, 1 + 0.1j],
... maxdegree=maxdegree))
Now let's test it. Lets count the zeros of
>>> expr = s**2 - s + S(1)/2
>>> argument_count(lambdify(s, expr.diff(s)/expr), 10)
mpc(real='1.0', imag='3.4287545414000525e-24')
The integral is 1. We can confirm there is indeed one
zero in this box, at
>>> solve(s**2 - s + S(1)/2)
[1/2 - I/2, 1/2 + I/2]
Now compute points of dps
is the number of digits of precision
the values are computed to. The default is 15, but mpmath can compute values
to any number of digits.
mpmath.chop
zeros out
values that are close to 0
, which removes any numerically insignificant
imaginary parts that arise from the floating point evaluation.
>>> def compute_points(Z_func, N, npoints=10000, dps=15):
... import warnings
... old_dps = mpmath.mp.dps
... points = np.linspace(0, N, npoints)
... try:
... mpmath.mp.dps = dps
... L = [mpmath.chop(Z_func(i)) for i in 1/2 + points*1j]
... finally:
... mpmath.mp.dps = old_dps
... if L[-1] == 0:
... # mpmath will give 0 if the precision is not high enough, since Z
... # decays rapidly on the critical line.
... warnings.warn("You may need to increase the precision")
... return L
Next define a function to count the number of sign changes in a list of real values.
>>> def sign_changes(L):
... """
... Count the number of sign changes in L
...
... Values of L should all be real.
... """
... changes = 0
... assert im(L[0]) == 0, L[0]
... s = sign(L[0])
... for i in L[1:]:
... assert im(i) == 0, i
... s_ = sign(i)
... if s_ == 0:
... # Assume these got chopped to 0
... continue
... if s_ != s:
... changes += 1
... s = s_
... return changes
For example, for
>>> sign_changes(lambdify(s, sin(s))(np.linspace(-10, 10)))
7
Now we can check how many zeros of
First try up to
>>> argument_count(D_func, 20)
mpc(real='0.99999931531867581', imag='-3.2332902529067346e-24')
Mathematically, the above value must be an integer, so we know it is 1.
Now check the number of sign changes of
>>> L = compute_points(Z_func, 20)
>>> sign_changes(L)
1
So it checks out. There is one zero between
Now let's verify the other two zeros from Wikipedia.
>>> argument_count(D_func, 25)
mpc(real='1.9961479945577916', imag='-3.2332902529067346e-24')
>>> L = compute_points(Z_func, 25)
>>> sign_changes(L)
2
>>> argument_count(D_func, 30)
mpc(real='2.9997317058520916', imag='-3.2332902529067346e-24')
>>> L = compute_points(Z_func, 30)
>>> sign_changes(L)
3
Both check out as well.
Since we are computing the points, we can go ahead and make a plot as well.
However, there is a technical difficulty. If you naively try to plot
>>> def plot_points_bad(L, N):
... npoints = len(L)
... points = np.linspace(0, N, npoints)
... plt.figure()
... plt.plot(points, L)
... plt.plot(points, [0]*npoints, linestyle=':')
>>> plot_points_bad(L, 30)
So instead of plotting
>>> def plot_points(L, N):
... npoints = len(L)
... points = np.linspace(0, N, npoints)
... p = [mpmath.log(abs(i)) for i in L]
... plt.figure()
... plt.plot(points, p)
... plt.plot(points, [0]*npoints, linestyle=':')
>>> plot_points(L, 30)
The spikes downward are the zeros.
Finally, let's check up to N=100. OEIS A072080
gives the number of zeros of
>>> argument_count(D_func, 100)
mpc(real='28.248036536895913', imag='-3.2332902529067346e-24')
This is not near an integer. This means we need to increase the precision of
the quadrature (the maxdegree
argument).
>>> argument_count(D_func, 100, maxdegree=9)
mpc(real='29.000000005970151', imag='-3.2332902529067346e-24')
And the sign changes...
>>> L = compute_points(Z_func, 100)
__main__:11: UserWarning: You may need to increase the precision
Our guard against the precision being too low was triggered. Try raising it (the default dps is 15).
>>> L = compute_points(Z_func, 100, dps=50)
>>> sign_changes(L)
29
They both give 29. So we have verified the Riemann Hypothesis up to
Here is a plot of these 29 zeros.
>>> plot_points(L, 100)
(remember that the spikes downward are the zeros)
This was just me playing around with SymPy and mpmath, but if I wanted to
actually verify the Riemann Hypothesis, I would try to find a more efficient
method of computing the above quantities. For the sake of simplicity, I used
Furthermore, for the argument principle integral, I would like to see precise
error estimates for the integral. We saw above with
One would also probably want to use a faster integrator than mpmath (like one
written in C), and perhaps also find a faster to evaluate expression than the
one I used for
In this post I described the Riemann zeta function and the Riemann Hypothesis,
and showed how to computationally verify it. But I didn't really go over the
details of why the Riemann Hypothesis matters. I encourage you to watch the
videos in my YouTube
playlist
if you want to know this. Among other things, the truth of the Riemann
Hypothesis would give a very precise bound on the distribution of prime
numbers. Also, the non-trivial zeros of