quickseries
generates Python functions that perform fast vectorized power
series approximations of mathematical functions. It can provide performance
improvements ranging from ~3x (simple functions, no fiddling around with
parameters) to ~100x (complicated functions, some parameter tuning).
quickseries
is in beta; bug reports are appreciated.
quickseries
is available on PyPi; install with pip install quickseries
.
It is also available on conda-forge; install with conda install quickseries --channel conda-forge
.
The minimum supported version of Python is 3.11.
>>> import numpy as np
>>> from quickseries import quickseries
>>> bounds = (-np.pi, np.pi)
>>> approx = quickseries("sin(x)*cos(x)", point=0, nterms=12, bounds=bounds)
>>> x = np.linspace(*bounds, 100000)
>>> print(f"max error: {max(abs(np.sin(x) * np.cos(x) - approx(x)))}")
>>> print("original runtime:")
>>> %timeit np.sin(x) * np.cos(x)
>>> print("approx runtime:")
>>> %timeit approx(x)
max error: 0.0003270875375037813
original runtime:
968 µs ± 2.17 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
approx runtime:
325 µs ± 3.89 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
- The most important keyword arguments to
quickseries
arebounds
,nterms
, andpoint
.bounds
specifies the range (or ranges, for multivariate functions) of values across which to approximate the function.nterms
specifies how many terms to use in the series expansion.point
specifies the value (or values, for multivariate functions) about which to generate the series expansion. See "limitations" and "tips" below for examples and discussion. quickseries()
is capable of auto-jitting the functions it generates withnumba
. Pass thejit=True
argument.numba
is an optional dependency; install it with your preferred package manager.quickseries.benchmark()
offers an easy way to test the accuracy and efficiency ofquickseries.quickseries()
-generated functions.- By default,
quickseries()
caches the code it generates. If you wish to turn this behavior off, passcache=False
.- If you call
quickseries()
with the same arguments from separate modules, it will write separate caches for each module. - ipython/Jupyter shells/kernels all share one cache within the same user account.
quickseries()
treats stdin or similar 'anonymous' invocation contexts like modules named"__quickseries_anonymous_caller_cache__"
in the current working directory.- In this mode,
quickseries()
also caches any results ofnumba
JIT compilation. - Caching is turned off by default for
benchmark()
.
- If you call
- If you pass the
precision
argument toquickseries()
, it will attempt to guarantee that the function it returns will not cast input values to bit widths greater than the value ofprecision
. Legal values ofprecision
are 16, 32, and 64. The returned function will not, however, attempt to reduce the precision of its arguments. For instance,quickseries("sin(x) + exp(x)", precision=32)
will return a Pythonfloat
if passed anfloat
, and anp.float64
ndarray
if passed anp.float64
ndarray
. However, it will return anp.float32
ndarray
if passed anp.float32
ndarray
, which is not guaranteed without theprecision=32
argument.
- Multivariate
quickseries()
-generated functions always map positional arguments to variables in the string representation of the input function in alphanumeric order. This is in order to maintain consistency between slightly different forms of the same expression.- Examples:
quickseries("cos(x) * sin(y)")(1, 2)
approximatescos(1) * sin(2)
quickseries("sin(y) * cos(x)")(1, 2)
also approximatescos(1) * sin(2)
quickseries("sin(x) * cos(y)")(1, 2)
approximatessin(1) * cos(2)
- Note that you can always determine the argument order of a
quickseries()
- generated function by using thehelp()
builtin orinspect.getfullargspec()
, examining the function's docstring, etc.
- Examples:
- Most legal Python variable names are allowable names for free variables.
Named mathematical functions and constants are the major exceptions.
- Examples:
"ln(_)"
,"ln(One_kitty)"
,"ln(x0)"
, and"ln(ă)"
will all work fine."ln(if)"
and"ln(🔥)"
will both fail, becauseif
and🔥
are not legal Python variable names."ln(gamma)"
will fail, becausequickseries()
will interpret "gamma" as the gamma function."cos(x) * cos(pi * 2)"
will succeed, butquickseries()
will interpret it as "the cosine of a variable named 'x' times the cosine of two times the mathematical constant pi" -- in other words, as"cos(x)"
.
- Examples:
quickseries
only works for functions ℝn🡒ℝ for finite n. In programming terms, this means it will only produce functions that accept a fixed number of floating-point or integer arguments (which may be 'arraylike' objects such as pandasSeries
or numpyndarrays
) and return a single floating-point value (or a 1-D floating-point array if passed arraylike arguments).quickseries
only works consistently on functions that are continuous and infinitely differentiable within the domain of interest. Specifically, they should not have singularities, discontinuities, or infinite / undefined values atpoint
or withinbounds
. Failure cases differ:quickseries
will always fail on functions that are infinite/undefined atpoint
, likequickseries("ln(x)", point=-1)
.- It will almost always fail on functions with a largeish interval of
infinite/undefined values within
bounds
, such asquickseries("gamma(x + y)", bounds=((-1.1, 0), (0, 1)), point=(-0.5, 0))
. - It will usually succeed but produce bad results on functions with
singularities or point discontinuities within
bounds
or nearpoint
but not atpoint
, such asquickseries("tan(x)", bounds=(1, 2))
. - It will often succeed, but usually produce bad results, on univariate
functions that are continuous but not differentiable at
point
, such asquickseries("abs(sin(x))", point=0)
. It will always fail on multivariate functions of this kind.
- Functions given to
quickseries
must be expressed in strict closed form and include only finite terms. They cannot contain limits, integrals, derivatives, summations, continued fractions, etc. quickseries
is not guaranteed to work for all such functions.
- Narrowing
bounds
will tend to make the approximation more accurate within those bounds. In the example at the top of this README, settingbounds
to(-1, 1)
provides ~20x greater accuracy within the (-1, 1) interval (with the downside that the resulting approximation will get pretty bad past about +/-pi/2). - Like many optimizers,
quickseries()
tends to be much more effective closer to 0 and when its input arguments have similar orders of magnitude. If it is practical to shift/squeeze your data towards 0, you may be able to get more use out ofquickseries
. One of the biggest reasons for this is that high-order polynomials are more numerically stable with smaller input values.- Functions with a pole at 0 can of course present an exception to this rule. It will still generally be better to keep their input values small.
- Increasing
nterms
will tend to make the approximation slower but more accurate. In the example above, increasingnterms
to 14 provides ~20x greater accuracy but makes the approximation ~20% slower.- This tends to have diminishing returns. In the example above, increasing
nterms
to 30 provides no meaningful increase in accuracy overnterms=14
, but makes the approximation slower thannp.sin(x) * np.cos(x)
. - Setting
nterms
too high can also cause the approximation algorithm to fail entirely.
- This tends to have diminishing returns. In the example above, increasing
- For most functions, placing
point
in the middle ofbounds
will produce the best results, and if you don't passpoint
at all,quickseries
defaults to placing it in the middle ofbounds
. - The location of accuracy/performance "sweet spots" in the parameter space depends on the function and the approximation bounds. If you want to seriously optimize a particular function in a particular interval, you will need to play around with these parameters.
- The speedup (or lack thereof) that a
quickseries()
-generated approximation provides can vary greatly in different operating environments and on different processors.- It can also vary depending on the length of the input arguments. It generally provides most benefit on arrays with tens or hundreds of thousands of elements, although this again varies depending on operating environment, the particular approximated function, etc.
- In general,
quickseries
provides more performance benefits for more 'complicated' input functions. This is due to the implicit 'simplification' offered by the power series expansion. - It is often difficult to generate a polynomial approximation that remains good across a wide range of input values. In some cases, it may be useful to generate different functions for different parts of your code, or even to perform piecewise operations with multiple functions (although this of course adds complexity and overhead).
- By default, if you pass a simple polynomial expression to
quickseries()
(e.g."x**4 + 2 * x**3"
), it does not actually generate an approximation, but instead simply attempts to rewrite it in a more efficient form.nterms
,bounds
, andpoint
are ignored in this "rewrite" mode.- This type of
quickseries()
-generated function should produce the same results as any other Python function that straightforwardly implements a form of the input polynomial (down to floating-point error). - This can produce surprising speedups even in simple cases -- for example,
quickseries("x**4")
is ~20x faster thanlambda x: x ** 4
on somenumpy
arrays. - If you want
quickseries()
to actually create an approximation of a simple polynomial, passapprox_poly=True
.- When approximating a polynomial, there is generally no good reason to
set
nterms
> that polynomial's order. If you do, the functionquickseries()
generates will typically be very similar to a simple rewrite of the input polynomial, but with slightly worse performance and accuracy. quickseries
may fail to approximate some low-order polynomials.- The results of polynomial approximation will often be boring with
bounds
near 0, because most polynomials are approximated well near 0 by their lower-order terms.- For instance,
quickseries("x**8 + 2 * x ** 3 + 5 * x + 10" nterms=4, bounds=(-0.5, 0.5), approx_poly=True)
will produce a function that almost exactly just computes2 * x ** 3 + 5 * x + 10
.
- For instance,
- When approximating a polynomial, there is generally no good reason to
set
- In many, but not all, cases,
jit=True
will provide a significant performance improvement, sometimes by an order of magnitude. It also permits callingquickseries
-generated functions from within othernumba
-compiled functions.- Note that some functions may not be compatible with
numba
.
- Note that some functions may not be compatible with
quickseries
tends to be most effective on univariate functions, mostly because the number of terms in a function's power expansion increases geometrically with its number of free parameters.- Functions generated by
quickseries()
may in some cases be less space/memory-efficient even if they are more time/compute-efficient. - By default,
quickseries
takes the analytic series expansion of the input function as a strong suggestion rather than the last word on the topic, and performs a numerical optimization step to improve its goodness of fit acrossbounds
. There are good reasons you might not want it to do this, though -- for instance, if your input arguments are always going to be quite close topoint
, messing with the analytic series expansion may be wasteful or even counterproductive. If you don't want it to do this, passfit_series_expansion=False
. In this case,quickseries
ignores thebounds
argument, except to infer a value forpoint
if you do not specify one.- In some cases, this optimization step can become numerically unstable. In
these cases, you may wish to experiment with constraining it rather than
turning it off completely. You can do this by passing
bound_series_fit=True
.
- In some cases, this optimization step can become numerically unstable. In
these cases, you may wish to experiment with constraining it rather than
turning it off completely. You can do this by passing
- By default, the functions that
quickseries
generates precompute all repeated exponents in the generated polynomial. This is a space-for-time trade, and may not always be desirable (or even effective). You can turn this off by passingprefactor=False
.- If
jit=True
,quickseries
does not do this by default. Thenumba
compiler implicitly performs a similar optimization, and computing these terms explicitly tends to be counterproductive. If you wantquickseries
to do it anyway, you can passprefactor=True
.
- If
- Specifying
precision
can lead to significant speedups and memory usage improvements. - Many libraries and formats do not support the "half-float" values generated
by
quickseries
when passedprecision=16
.
quickseries
has a few simple tests. You can run them by executing pytest
in the repository's root directory. More comprehensive test coverage is
planned.