Skip to content

Commit 03e4f98

Browse files
committed
feat: implement FLMM starting weights
1 parent d238edc commit 03e4f98

File tree

4 files changed

+317
-117
lines changed

4 files changed

+317
-117
lines changed

docs/references.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ References
2222
Computer Methods in Applied Mechanics and Engineering, Vol. 194, pp. 743-773, 2005,
2323
`DOI <https://doi.org/10.1016/j.cma.2004.06.006>`__.
2424
25+
.. [Diethelm2006] K. Diethelm, J. M. Ford, N. J. Ford, M. Weilbeer,
26+
*Pitfalls in Fast Numerical Solvers for Fractional Differential Equations*,
27+
Journal of Computational and Applied Mathematics, Vol. 186, pp. 482--503, 2006,
28+
`DOI <https://doi.org/10.1016/j.cam.2005.03.023>`__.
29+
2530
.. [Shen2011] J. Shen, T. Tang, L.-L. Wang,
2631
*Spectral Methods - Algorithms, Analysis and Applications*,
2732
Springer, 2011.

src/pycaputo/generating_functions.py

Lines changed: 242 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,160 @@
99

1010
from pycaputo.utils import Array
1111

12-
# {{{ Lubich1986 weights
12+
# {{{ FLMM: Starting Weights
1313

1414

15-
def lubich_bdf_starting_weights_count(order: int, alpha: float) -> int:
16-
"""An estimate for the number of starting weights from [Lubich1986]_.
15+
def lmm_starting_weights(
16+
w: Array, sigma: Array, alpha: float, *, atol: float | None = None
17+
) -> Iterator[tuple[int, Array]]:
18+
r"""Constructs starting weights for a given set of weights *w* of a
19+
fractional-order linear multistep method (FLMM).
1720
18-
:arg order: order of the BDF method.
19-
:arg alpha: order of the fractional derivative.
21+
The starting weights are introduced to handle a lack of smoothness of the
22+
function :math:`f(x)` being integrated. They are constructed in such a way
23+
that they are exact for a series of monomials, which results in an accurate
24+
quadrature for functions of the form :math:`f(x) = f_i x^{\sigma_i} +
25+
x^{\beta} g(x)`, where :math:`g` is smooth and :math:`\beta < p`
26+
(see Remark 3.6 in [Li2020]_). This ensures that the Taylor expansion near
27+
the origin is sufficiently accurate.
28+
29+
Therefore, they are obtained by solving
30+
31+
.. math::
32+
33+
\sum_{k = 0}^s w_{mk} k^{\sigma_q} =
34+
\frac{\Gamma(\sigma_q +1)}{\Gamma(\sigma_q + \alpha + 1)}
35+
m^{\sigma_q + \alpha} -
36+
\sum_{k = 0}^s w_{n - k} k^{\sigma_q}
37+
38+
for a set of :math:`\sigma_q` powers. In the simplest case, we can just set
39+
:math:`\sigma \in \{0, 1, \dots, p - 1\}` and obtain integer powers. Other
40+
values can be chosen depending on the behaviour of :math:`f(x)` near the origin.
41+
42+
Note that the starting weights are only computed for :math:`m \ge s`.
43+
The initial :math:`s` steps are expected to be computed in some other
44+
fashion.
45+
46+
:arg w: convolution weights of an FLMM.
47+
:arg sigma: an array of monomial powers for which the starting weights are exact.
48+
:arg alpha: order of the fractional derivative to approximate.
49+
:arg atol: absolute tolerance used for the GMRES solver. If *None*, an
50+
exact LU-based solver is used instead (see Section 3.2 in [Diethelm2006]_
51+
for a discussion of these methods).
52+
53+
:returns: the index and starting weights for every point :math:`m \ge s`.
54+
"""
55+
56+
from scipy.linalg import lu_factor, lu_solve
57+
from scipy.sparse.linalg import gmres
58+
from scipy.special import gamma
59+
60+
s = sigma.size
61+
j = np.arange(1, s + 1).reshape(-1, 1)
62+
63+
A = j**sigma
64+
assert A.shape == (s, s)
65+
assert np.all(np.isfinite(A))
66+
67+
if atol is None:
68+
lu_p = lu_factor(A)
69+
70+
for k in range(s, w.size):
71+
b = (
72+
gamma(sigma + 1) / gamma(sigma + alpha + 1) * k ** (sigma + alpha)
73+
- A @ w[k - s : k][::-1]
74+
)
75+
76+
if atol is None:
77+
omega = lu_solve(lu_p, b)
78+
else:
79+
omega, _ = gmres(A, b, atol=atol)
80+
81+
assert np.all(np.isfinite(omega))
82+
yield k, omega
83+
84+
85+
def diethelm_starting_powers(order: int, alpha: float) -> Array:
86+
r"""Generate monomial powers for the starting weights from [Diethelm2006]_.
87+
88+
The proposed starting weights are given in Section 3.1 from [Diethelm2006]_ as
89+
90+
.. math::
91+
92+
\sigma \in \{i + \alpha j \mid i, j \ge 0, i + \alpha j \le p - 1 \},
93+
94+
where :math:`p` is the desired order. For certain values of :math:`\alpha`,
95+
these monomials can repeat, e.g. for :math:`\alpha = 0.1` we get the same
96+
value for :math:`(i, j) = (1, 0)` and :math:`(i, j) = (0, 10)`. This function
97+
returns only unique powers.
98+
99+
:arg order: order of the LMM method.
100+
:arg alpha: order of the fractional operator.
101+
"""
102+
from itertools import product
103+
104+
result = np.array([
105+
gamma
106+
for i, j in product(range(order), repeat=2)
107+
if (gamma := i + abs(alpha) * j) <= order - 1
108+
])
109+
110+
return np.array(np.unique(result))
111+
112+
113+
def lubich_starting_powers(order: int, alpha: float, *, beta: float = 1.0) -> Array:
114+
r"""Generate monomial powers for the starting weights from [Lubich1986]_.
115+
116+
The proposed starting weights are given in Section 4.2 of [Lubich1986]_ as
117+
118+
.. math::
119+
120+
\sigma \in \{i + \beta - 1 \mid q \le p - 1\},
121+
122+
where :math:`\beta` is chosen such that :math:`q + \beta - 1 \le p - 1`
123+
and :math:`q + \alpha + \beta - 1 < p`, according to Theorem 2.4 from
124+
[Lubich1986]_. In general multiple such :math:`\beta` exist and choosing
125+
more can prove beneficial.
126+
127+
:arg order: order of the LMM method.
128+
:arg alpha: order of the fractional operator.
20129
"""
21130
from math import floor
22131

23-
return floor(1 / abs(alpha)) - 1
132+
# NOTE: trying to satisfy
133+
# 0 <= q <= p - \beta and 0 <= q < p + 1 - alpha - beta
134+
qmax = floor(max(0, min(order - beta, order - alpha - beta + 1))) + 1
135+
136+
return np.array([q + beta - 1 for q in range(qmax)])
137+
138+
139+
def garrappa_starting_powers(order: int, alpha: float) -> Array:
140+
r"""Generate monomial powers for the starting weights from
141+
`FLMM2 <https://www.mathworks.com/matlabcentral/fileexchange/47081-flmm2>`__.
142+
143+
:arg order: order of the LMM method.
144+
:arg alpha: order of the fractional operator.
145+
"""
146+
from math import ceil, floor
147+
148+
if order == 2 and 0 < alpha < 1:
149+
# NOTE: this is the estimate from the FLMM2 MATLAB code
150+
k = floor(1 / abs(alpha))
151+
else:
152+
# NOTE: this should be vaguely the cardinality of `diethelm_bdf_starting_powers`
153+
k = ceil(order * (order - 1 + 2 * abs(alpha)) / (2 * abs(alpha)))
154+
155+
return np.arange(k) * abs(alpha)
156+
157+
158+
# }}}
159+
160+
161+
# {{{ backward differentiation formulas
24162

25163

26164
def lubich_bdf_weights(alpha: float, order: int, n: int) -> Array:
27-
r"""This function generates the weights for the p-BDF methods of [Lubich1986]_.
165+
r"""This function generates the weights for the BDF methods of [Lubich1986]_.
28166
29167
Table 1 from [Lubich1986]_ gives the generating functions. The weights
30168
constructed here are the coefficients of the Taylor expansion of the
@@ -37,7 +175,7 @@ def lubich_bdf_weights(alpha: float, order: int, n: int) -> Array:
37175
\left(\sum_{k = 1}^p \frac{1}{k} (1 - \zeta)^k\right)^\alpha.
38176
39177
The corresponding weights also have an explicit formulation based on the
40-
recursion formula
178+
recursion formula (see Theorem 4 in [Garrappa2015b]_)
41179
42180
.. math::
43181
@@ -49,9 +187,11 @@ def lubich_bdf_weights(alpha: float, order: int, n: int) -> Array:
49187
we restrict here to a maximum order of 6, as the classical BDF methods of
50188
larger orders are not stable.
51189
52-
:arg alpha: power of the generating function.
190+
:arg alpha: power of the generating function, where a positive value is
191+
used to approximate the integral and a negative value is used to
192+
approximate the derivative.
53193
:arg order: order of the method, only :math:`1 \le p \le 6` is supported.
54-
:arg n: number of weights.
194+
:arg n: number of truncated terms in the power series of :math:`w^\alpha_k(\zeta)`.
55195
"""
56196
if order <= 0:
57197
raise ValueError(f"Negative orders are not supported: {order}")
@@ -84,93 +224,123 @@ def lubich_bdf_weights(alpha: float, order: int, n: int) -> Array:
84224
else:
85225
raise ValueError(f"Unsupported order '{order}'")
86226

87-
w = np.empty(n)
88-
indices = np.arange(n)
227+
import scipy.special as ss
228+
229+
w = np.empty(n + 1)
230+
indices = np.arange(n + 1)
89231

90-
w[0] = c[0] ** alpha
91-
for k in range(1, n):
92-
min_j = max(0, k - c.size + 1)
93-
max_j = k
232+
if order == 1:
233+
return np.array(ss.poch(indices, alpha - 1) / ss.gamma(alpha))
234+
else:
235+
w[0] = c[0] ** -alpha
236+
for k in range(1, n + 1):
237+
min_j = max(0, k - c.size + 1)
238+
max_j = k
94239

95-
j = indices[min_j:max_j]
96-
omega = (alpha * (k - j) - j) * c[1 : k + 1][::-1]
240+
j = indices[min_j:max_j]
241+
omega = -(alpha * (k - j) + j) * c[1 : k + 1][::-1]
97242

98-
w[k] = np.sum(omega * w[min_j:max_j]) / (k * c[0])
243+
w[k] = np.sum(omega * w[min_j:max_j]) / (k * c[0])
99244

100245
return w
101246

102247

103-
def lubich_bdf_starting_weights(
104-
w: Array, s: int, alpha: float, *, beta: float = 1.0, atol: float | None = None
105-
) -> Iterator[Array]:
106-
r"""Constructs starting weights for a given set of weights *w* from [Lubich1986]_.
248+
# }}}
107249

108-
The starting weights are introduced to handle a lack of smoothness of the
109-
functions being integrated. They are constructed in such a way that they
110-
are exact for a series of monomials, which results in an accurate
111-
quadrature for functions of the form :math:`f(x) = x^{\beta - 1} g(x)`,
112-
where :math:`g` is smooth.
113250

114-
Therefore, they are obtained by solving
251+
# {{{ trapezoidal formulas
115252

116-
.. math::
117253

118-
\sum_{k = 0}^s w_{mk} k^{q + \beta - 1} =
119-
\frac{\Gamma(q + \beta)}{\Gamma(q + \beta + \alpha)}
120-
m^{q + \beta + \alpha - 1} -
121-
\sum_{k = 0}^s w_{n - k} j^{q + \beta - 1}
254+
def trapezoidal_weights(alpha: float, n: int) -> Array:
255+
r"""This function constructions the weights for the trapezoidal method
256+
from Section 4.1 in [Garrappa2015b]_.
122257
123-
where :math:`q \in \{0, 1, \dots, s - 1\}` and :math:`\beta \ne \{0, -1, \dots\}`.
124-
In the simplest case, we can take :math:`\beta = 1` and obtain integer
125-
powers. Other values can be chosen depending on the behaviour of :math:`f(x)`
126-
near the origin.
258+
The trapezoidal method has the generating function
127259
128-
Note that the starting weights are only computed for :math:`m \ge s`.
129-
The initial :math:`s` steps are expected to be computed in some other
130-
fashion.
260+
.. math::
131261
132-
:arg w: convolution weights defined by [Lubich1986]_.
133-
:arg s: number of starting weights.
134-
:arg alpha: order of the fractional derivative to approximate.
135-
:arg beta: order of the singularity at the origin.
136-
:arg atol: absolute tolerance used for the GMRES solver. If *None*, an
137-
exact LU-based solver is used instead.
262+
w^\alpha(\zeta) \triangleq \left(
263+
\frac{1}{2} \frac{1 + \zeta}{1 - \zeta}
264+
\right)^\alpha,
138265
139-
:returns: the starting weights for every point :math:`x_m` starting with
140-
:math:`m \ge s`.
266+
which expands into an infinite series. This function truncates that series
267+
to the first *n* terms, which give the desired weights.
268+
269+
:arg alpha: power of the generating function, where a positive value is
270+
used to approximate the integral and a negative value is used to
271+
approximate the derivative.
272+
:arg n: number of truncated terms in the power series of :math:`w^\alpha_k(\zeta)`.
141273
"""
142274

143-
if s <= 0:
144-
raise ValueError(f"Negative s is not allowed: {s}")
275+
try:
276+
from scipy.fft import fft, ifft
277+
except ImportError:
278+
from numpy.fft import fft, ifft
145279

146-
if beta.is_integer() and beta <= 0:
147-
raise ValueError(f"Values of beta in 0, -1, ... are not supported: {beta}")
280+
omega_1 = np.empty(n + 1)
281+
omega_2 = np.empty(n + 1)
148282

149-
from scipy.linalg import lu_factor, lu_solve
150-
from scipy.sparse.linalg import gmres
151-
from scipy.special import gamma
283+
omega_1[0] = omega_2[0] = 1.0
284+
for k in range(1, n + 1):
285+
omega_1[k] = ((alpha + 1) / k - 1) * omega_1[k - 1]
286+
omega_2[k] = (1 + (alpha - 1) / k) * omega_2[k - 1]
152287

153-
q = np.arange(s) + beta - 1
154-
j = np.arange(1, s + 1).reshape(-1, 1)
288+
# FIXME: this looks like it can be done faster by using rfft?
289+
omega_1_hat = fft(omega_1, 2 * omega_1.size)
290+
omega_2_hat = fft(omega_2, 2 * omega_2.size)
291+
omega = ifft(omega_1_hat * omega_2_hat)[: omega_1.size].real
155292

156-
A = j**q
157-
assert A.shape == (s, s)
293+
return np.array(omega / 2**alpha)
158294

159-
if atol is None:
160-
lu, p = lu_factor(A)
161295

162-
for k in range(s, w.size):
163-
b = (
164-
gamma(q + 1) / gamma(q + alpha + 1) * k ** (q + alpha)
165-
- A @ w[k - s : k][::-1]
166-
)
296+
# }}}
167297

168-
if atol is None:
169-
omega = lu_solve((lu, p), b)
170-
else:
171-
omega, _ = gmres(A, b, atol=atol)
172298

173-
yield omega
299+
# {{{ Newton-Gregory formulas
300+
301+
302+
def newton_gregory_weights(alpha: float, k: int, n: int) -> Array:
303+
r"""This function constructions the weights for the Newton-Gregory method
304+
from Section 4.2 in [Garrappa2015b]_.
305+
306+
The Newton-Gregory family of methods have the generating functions
307+
308+
.. math::
309+
310+
w^\alpha_k(\zeta) \triangleq
311+
\frac{G^\alpha_k(\zeta)}{(1 - \zeta)^\alpha},
312+
313+
where :math:`G^\alpha_k(\zeta)` is the :math:`k`-term truncated power
314+
series expansion of
315+
316+
.. math::
317+
318+
G^\alpha(\zeta) = \left(\frac{1 - \zeta}{\log \zeta}\right)^\alpha.
319+
320+
Note that this is not a classic expansion in the sense of [Lubich1986]_
321+
because the :math:`G^\alpha_k(\zeta)` function is first raised to the
322+
:math:`\alpha` power and then truncated to :math:`k` terms. In a standard
323+
scheme, the generating function :math:`w^\alpha_k(\zeta)` is the one that
324+
is truncated.
325+
326+
:arg alpha: power of the generating function, where a positive value is
327+
used to approximate the integral and a negative value is used to
328+
approximate the derivative.
329+
:arg k: number of truncated terms in the power series of :math:`G^\alpha_k(\zeta)`.
330+
:arg n: number of truncated terms in the power series of :math:`w^\alpha_k(\zeta)`.
331+
"""
332+
if k != 1:
333+
raise ValueError(f"Only 2-term truncations are supported: '{k + 1}'")
334+
335+
omega = np.empty(n + 1)
336+
omega[0] = 1.0
337+
for i in range(1, n + 1):
338+
omega[i] = (1 + (alpha - 1) / i) * omega[i - 1]
339+
340+
omega[1:] = (1 - alpha / 2) * omega[1:] + alpha / 2 * omega[:-1]
341+
omega[0] = 1 - alpha / 2.0
342+
343+
return omega
174344

175345

176346
# }}}

0 commit comments

Comments
 (0)