Skip to content
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

Implement integration and Taylor series for lazy series #36233

Merged
merged 3 commits into from
Jan 22, 2024

Conversation

tscrim
Copy link
Collaborator

@tscrim tscrim commented Sep 10, 2023

We implement an integral() method for lazy Laurent and power series. If we raise an error if we perform $\int t^{-1} dt$.

We also prove a way to construct a lazy (power) series as the Taylor series of a function.

📝 Checklist

  • The title is concise, informative, and self-explanatory.
  • The description explains in detail what this PR is about.
  • I have linked a relevant issue or discussion.
  • I have created tests covering the changes.
  • I have updated the documentation accordingly.

⌛ Dependencies

@mantepse
Copy link
Collaborator

This needs a rebase, but it's not urgent for me.

@tscrim tscrim force-pushed the lazy_series/integration branch from adc4538 to 98a354e Compare September 21, 2023 06:49
@tscrim
Copy link
Collaborator Author

tscrim commented Sep 21, 2023

Here is the rebased version.

No problem. This was something that was useful for solving differential equations as FPS.

Actually, that makes me wonder if we should add a method to the lazy (Laurent) series parent to return the Taylor expansion of a function as a lazy series.

@mantepse
Copy link
Collaborator

Here is the rebased version.

No problem. This was something that was useful for solving differential equations as FPS.

Actually, that makes me wonder if we should add a method to the lazy (Laurent) series parent to return the Taylor expansion of a function as a lazy series.

Yes, that would certainly be useful. Another method I had in FriCAS which is extremely useful is a very general (and therefore possibly slow) facility to compute the solution of a functional equation, given initial values. For example:

sage: L.<z> = LazyPowerSeriesRing(QQ)
sage: y = function("y")
sage: L(diff(y(x)) == y(x), [1])
1 + z + 1/2*z^2 + 1/6*z^3 + 1/24*z^4 + 1/120*z^5 + 1/720*z^6 + O(z^7)

It might be even nicer if one could replace z with x in the first line, but I guess that this is too much to ask for.

My implementation simply created a power series with undetermined coefficients and complained whenever it could not compute the next coefficient by either solving a linear equation or using an initial value. Possibly one could even relax "linear equation" to "equation with unique solution".

@mantepse
Copy link
Collaborator

keyword: LazyPowerSeries

@tscrim tscrim force-pushed the lazy_series/integration branch from 98a354e to 419701c Compare October 10, 2023 03:21
@tscrim
Copy link
Collaborator Author

tscrim commented Oct 10, 2023

Here is an initial version of the functional equation. It has not been well tested, but I got a version that uses the undefined series as the variable. It might not be the most robust, but I don't have good examples to really test it on.

I also added the Taylor series method.

@mantepse
Copy link
Collaborator

mantepse commented Oct 11, 2023

This is extremely cool! I have lots of examples, but I should be doing other work this week. If you want to, here are examples to try:

  • 2*z*f(z^3) + z*f(z)^3 - 3*f(z) + 3 == 0 (alcohol)
  • diff(G(x), x) - exp(-G(-x)) == 0, initial value log(2)
  • f(z) * (1-q*z) - f(q*z)*(y*q*z+y)- f(q*z)*q*z*f(z) - y*q*z == 0, initial value 0. (Brak and Prellberg, Critical exponents from nonlinear functional equations for partially directed cluster models)

@tscrim
Copy link
Collaborator Author

tscrim commented Oct 12, 2023

Thank you. Those were good tests, which uncovered some bugs. I think the overall effect is that it is now slower since I have to substitute what I know on each coefficient (rather than directly plugging it in beforehand), but it is now more robust. I also added a test for a Laurent series function equation solution.

The next thing to consider is what the best API is. Right now this is a bit clunky, but I don't know how to make it better.

This also won't be able to do things with general input (such as using generic functions), but it should cover most reasonable cases...

@mantepse
Copy link
Collaborator

mantepse commented Oct 12, 2023

I just started to play with it. It is wonderful, I am impressed! Here are some very vague ideas concerning the user interface:

  • the method name is not ideal, and initially I was surprised that None is returned.
  • might it be possible to merge define and functional_equation? It seems to me that you had something like this in mind anyway. Is it bad luck that $f := s(f)$ is done via f.define(s) rather than L.define(f, s)? I am not completely sure, but maybe it would be more intuitive to make it a method of the element rather than the parent, or possibly both.
  • Having to specify right and initial_values is annoying. I would get rid of right altogether and make initial_values optional with default [].

So maybe, as a method of LazyModuleElement:

def define_implicitly(self, s, initial_values=[]):
...

Other things:

no solution in degree 5 as -384 == 0

should possibly be no solution in degree 5 as -384 != 0.

Here is a bug that I do not understand:

sage: L.<z> = LazyPowerSeriesRing(QQ)
sage: f = L.undefined();
sage: z1 = z*diff(f, z); z2 = z1 + z^2*diff(f, z, 2); z3 = z1 + 3*z^2*diff(f, z, 2) + z^3*diff(f, z, 3)
sage: e1 = f^2*z3 - 15*f*z1*z2+30*z1^3; e2 = f*z2-3*z1^2; e3 = f*z2 - 3*z1^2; e = e1^2+32*e2^3-f^10*e3^2
sage: L.functional_equation(e, 0, f, [1, 2])
sage: f
<repr(<sage.rings.lazy_series_ring.LazyPowerSeriesRing_with_category.element_class at 0x7fa303299cc0>) failed: ValueError: no solution in degree 5 as -384 == 0>

There should be a solution, however (this is Jacobi, see Stanley EC2, page 282, exercise 6.63b):

sage: f = L(lambda n: 1 if not n else (2 if is_square(n) else 0)); f
1 + 2*z + 2*z^4 + O(z^7)
sage: z1 = z*diff(f, z); z2 = z1 + z^2*diff(f, z, 2); z3 = z1 + 3*z^2*diff(f, z, 2) + z^3*diff(f, z, 3)
sage: e1 = f^2*z3 - 15*f*z1*z2+30*z1^3; e2 = f*z2-3*z1^2; e3 = f*z2 - 3*z1^2; e = e1^2+32*e2^3-f^10*e3^2
sage: e[:100]
[]

@mantepse
Copy link
Collaborator

PS: I tested all the other examples I had, without any further bugs. I am impressed.

@mantepse
Copy link
Collaborator

Another bug, from a current collaboration with M. Ludwig and A. Freyer:

sage: L.<z> = LazyPowerSeriesRing(QQ["x,y,f1,f2"].fraction_field())
sage: x,y,f1,f2 = QQ["x,y,f1,f2"].gens()
sage: F = L.undefined()
sage: L.functional_equation(F(2*z) - (1+exp(x*z)+exp(y*z))*F - exp((x+y)*z)*F(-z), 0, F, [0, f1]); F
<repr(<sage.rings.lazy_series_ring.LazyPowerSeriesRing_with_category.element_class at 0x7f6434d0b340>) failed: ValueError: no solution in degree 3 as (x*y*f1) == 0>

However, here is a solution:

sage: F = 1/(x-y)*((2*f2-y*f1)*(exp(x*z)-1)/x - (2*f2-x*f1)*(exp(y*z)-1)/y)
sage: F
f1*z + f2*z^2 + ((-1/6*x*y*f1+1/3*x*f2+1/3*y*f2)*z^3) + ((-1/24*x^2*y*f1-1/24*x*y^2*f1+1/12*x^2*f2+1/12*x*y*f2+1/12*y^2*f2)*z^4) + ((-1/120*x^3*y*f1-1/120*x^2*y^2*f1-1/120*x*y^3*f1+1/60*x^3*f2+1/60*x^2*y*f2+1/60*x*y^2*f2+1/60*y^3*f2)*z^5) + ((-1/720*x^4*y*f1-1/720*x^3*y^2*f1-1/720*x^2*y^3*f1-1/720*x*y^4*f1+1/360*x^4*f2+1/360*x^3*y*f2+1/360*x^2*y^2*f2+1/360*x*y^3*f2+1/360*y^4*f2)*z^6) + O(z^7)
sage: F(2*z) - (1+exp(x*z)+exp(y*z))*F - exp((x+y)*z)*F(-z)
O(z^8)

Moreover, the following might also be a bug:

sage: F = L.undefined(); L.functional_equation(F(2*z) - (1+exp(x*z)+exp(y*z))*F - exp((x+y)*z)*F(-z), 0, F, []); F
O(z^7)

@mantepse
Copy link
Collaborator

I looked a little at the code.

  • I do not quite understand what replace does precisely.
  • I think that Stream_functional_equation.iterate_coefficients code misses the possibility that the variables of sf appear in bad order. For example:
sage: R.<x,y,f1,f2> = QQ[]
sage: S = R.fraction_field()
sage: P = InfinitePolynomialRing(S, 'x')
sage: L.<z> = LazyPowerSeriesRing(P)
sage: F = L(lambda n: X[n])
sage: eq = F(2*z) - (1+exp(x*z)+exp(y*z))*F - exp((x+y)*z)*F(-z)

Now eq[0], eq[1] and eq[2] contain only x_0, but eq[3] contains x_0, x_1, x_2 and x_3. Therefore, there cannot be a unique solution.

@mantepse
Copy link
Collaborator

It seems to me that the following should be possible:

Think of the desired series f as a lazy power series over the InfinitePolynomialRing, bearing in mind that Stream_uninitialized is not aware of the base ring. Then we can think of the functional equation eq also as a series over the InfinitePolynomialRing. I do not see why the replace method would be necessary - it looks a bit difficult to understand for me. Here is a proof of concept:

sage: R.<x,y,f1,f2> = QQ[]
sage: S = R.fraction_field()
sage: P = InfinitePolynomialRing(S, 'x')
sage: L.<z> = LazyPowerSeriesRing(P)
sage: F = L.undefined()
sage: eq = F(2*z) - (1+exp(x*z)+exp(y*z))*F - exp((x+y)*z)*F(-z)
sage: F._coeff_stream._target = Stream_function(lambda n: X[n], False, 0, False)
sage: eq[0]
-3*x_0

Hm, I just see that modifying the caches of F._coeff_stream and eq._coeff_stream does not quite work, because the cache of intermediate streams (e.g. eq._coeff_stream._left) will contain the intermediate values. We would have to modify the cache of all these. But maybe this is worth it? So to say, a recursive substitution method?

Then, computing the next term of eq should never involve variables that are already known. This should also give us a significant speedup if the dependence on previous coefficients is complicated.

@mantepse
Copy link
Collaborator

Here is a proof of concept. After applying the patch below, do:

sage: from sage.data_structures.stream import Stream_function
sage: R.<x,y> = QQ[]
sage: S = R.fraction_field()
sage: P.<X> = InfinitePolynomialRing(S)
sage: L.<z> = LazyPowerSeriesRing(P)
sage: F = L.undefined()
sage: eq = F(2*z) - (1+exp(x*z)+exp(y*z))*F - exp((x+y)*z)*F(-z)
sage: F._coeff_stream._target = Stream_function(lambda n: X[n], False, 0, False)

sage: eq[0]
-3*X_0
sage: eq._coeff_stream.map_cache(lambda c: c.subs({X[0]:0}))

sage: eq[0]
0
sage: eq[1]
0
sage: eq[2]
0
sage: eq[3]
6*X_3 + (-2*x - 2*y)*X_2 + (x*y)*X_1
sage: F[0]
0
sage: F[1]
X_1
diff --git a/src/sage/data_structures/stream.py b/src/sage/data_structures/stream.py
index e5de010fbad..0f023aee101 100644
--- a/src/sage/data_structures/stream.py
+++ b/src/sage/data_structures/stream.py
@@ -147,6 +147,12 @@ class Stream():
         """
         self._true_order = true_order
 
+    def map_cache(self, function):
+        r"""
+        Apply ``function`` to each element in the cache.
+        """
+        pass
+
     @lazy_attribute
     def _approximate_order(self):
         """
@@ -447,6 +453,15 @@ class Stream_inexact(Stream):
             yield self.get_coefficient(n)
             n += 1
 
+    def map_cache(self, function):
+        r"""
+        Apply ``function`` to each element in the cache.
+        """
+        if self._is_sparse:
+            self._cache = {i: function(v) for i, v in self._cache.items()}
+        else:
+            self._cache = [function(v) for v in self._cache]
+
     def order(self):
         r"""
         Return the order of ``self``, which is the minimum index ``n`` such
@@ -1277,6 +1292,14 @@ class Stream_uninitialized(Stream_inexact):
         self._initializing = False
         return result
 
+    def map_cache(self, function):
+        r"""
+        Apply ``function`` to each element in the cache.
+        """
+        super().map_cache(function)
+        if self._target is not None:
+            self._target.map_cache(function)
+
 
 class Stream_functional_equation(Stream_inexact):
     r"""
@@ -1439,6 +1462,13 @@ class Stream_unary(Stream_inexact):
         self._series = series
         super().__init__(is_sparse, true_order)
 
+    def map_cache(self, function):
+        r"""
+        Apply ``function`` to each element in the cache.
+        """
+        super().map_cache(function)
+        self._series.map_cache(function)
+
     def __hash__(self):
         """
         Return the hash of ``self``.
@@ -1554,6 +1584,14 @@ class Stream_binary(Stream_inexact):
         self._right = right
         super().__init__(is_sparse, False)
 
+    def map_cache(self, function):
+        r"""
+        Apply ``function`` to each element in the cache.
+        """
+        super().map_cache(function)
+        self._left.map_cache(function)
+        self._right.map_cache(function)
+
     def __hash__(self):
         """
         Return the hash of ``self``.
@@ -2370,6 +2408,13 @@ class Stream_plethysm(Stream_binary):
             f = Stream_map_coefficients(f, lambda x: p(x), is_sparse)
         super().__init__(f, g, is_sparse)
 
+    def map_cache(self, function):
+        r"""
+        Apply ``function`` to each element in the cache.
+        """
+        super().map_cache(function)
+        self._powers = [g.map_cache(function) for g in self._powers]
+
     @lazy_attribute
     def _approximate_order(self):
         """
@@ -3251,6 +3296,12 @@ class Stream_shift(Stream):
         self._shift = shift
         super().__init__(series._true_order)
 
+    def map_cache(self, function):
+        r"""
+        Apply ``function`` to each element in the cache.
+        """
+        self._series.map_cache(function)
+
     @lazy_attribute
     def _approximate_order(self):
         """

@tscrim
Copy link
Collaborator Author

tscrim commented Oct 13, 2023

replace(X, Y) simply replaces the stream X with the stream Y anywhere it is encountered in the stream-tree. I thought about updating the caches versus trying to make a copy of the entire formula structure tree and replacing the undefined series with the generic variable series. I ended up going with the replacement method because you need to scan over the entire cache of every object in the stream-tree every update, which can be a lot of checks and there are no shortcuts. If we simply make a copy, then we make maximum usage of the cache. The replacement option also has been easier for debugging, but that is a fringe benefit IMO.

TL;DR, I am not sure which would be best. I am not opposed to switching to the cache sub if you definitively think it is better.

In an ideal world, we would be able to solve things out of order, but knowing how many coefficient to compute in order to get a unique solution is an undecidable problem. However you are right that the code does not currently check that the next variable is the next in order., which is the only sensible thing to do for a computation to succeed.

However, from your examples, I was able to quash a number of bugs. I had to do some hacks for the brittleness of the (infinite) polynomial ring substitution, which took more time than almost anything...

I also removed the right input and initial_values now has a default of [].

@mantepse
Copy link
Collaborator

I don't know whether substituting elements in the cache is faster - I would think that it is if the dependence on previous coefficients is complicated, and particularly so if after substitution we have just rational numbers. But I guess it would be necessary to code it and try some examples. I would expect that the Jacobi example would be interesting. Currently it takes already seconds to compute the coefficient of 49, the number of monomials before substitution is about 150000, whereas after substitution it is (of course) just 1.

In any case, I am quite convinced meanwhile that it would be good to call the method define_implicitly and make it an element method additionally. Since we are already used to f.define, this is the most natural place to look for.

@mantepse
Copy link
Collaborator

I shouldn't have done this, but at least I have some success. Computing g[49] in the Jacobi example takes 1.7 seconds instead of 17 seconds.

This is really only a proof of concept. In particular, this only works if it is indeed enough to check only the last element of the cache (which I believe is true). Initially I thought that I would have to go through all of the cache, which would make things extremely slow. In any case, I dislike my implementation, there ought to be a better way.

Moreover, there is at least one bug which I do not understand yet - when you execute the cosine example on line 2599 of lazy_power_series_ring.py, an unsubstituted variable appears. I hope that this is not a fundamental problem.

Finally, there seems to be a performance bug in the InfinitePolynomialRing: running the code the first time takes a very long time, almost all of which is spent in method 'has_coerce_map_from' of 'sage.structure.parent.Parent' objects.

diff --git a/src/sage/data_structures/stream.py b/src/sage/data_structures/stream.py
index 88c765569b..87e5042eeb 100644
--- a/src/sage/data_structures/stream.py
+++ b/src/sage/data_structures/stream.py
@@ -147,6 +147,12 @@ class Stream():
         """
         self._true_order = true_order
 
+    def map_cache(self, function):
+        r"""
+        Apply ``function`` to each element in the cache.
+        """
+        pass
+
     @lazy_attribute
     def _approximate_order(self):
         """
@@ -447,6 +453,17 @@ class Stream_inexact(Stream):
             yield self.get_coefficient(n)
             n += 1
 
+    def map_cache(self, function):
+        r"""
+        Apply ``function`` to each element in the cache.
+        """
+        if self._cache:
+            if self._is_sparse:
+                i = max(self._cache)
+                self._cache[i] = function(self._cache[i])
+            else:
+                self._cache[-1] = function(self._cache[-1])
+
     def order(self):
         r"""
         Return the order of ``self``, which is the minimum index ``n`` such
@@ -1299,6 +1316,14 @@ class Stream_uninitialized(Stream_inexact):
                 ret._target = temp
         return ret
 
+    def map_cache(self, function):
+        r"""
+        Apply ``function`` to each element in the cache.
+        """
+        super().map_cache(function)
+        if self._target is not None:
+            self._target.map_cache(function)
+
 
 class Stream_functional_equation(Stream_inexact):
     r"""
@@ -1343,12 +1368,23 @@ class Stream_functional_equation(Stream_inexact):
             initial_values = []
         super().__init__(False, true_order)
         self._F = F
-        self._base = R
         self._initial_values = initial_values
         self._approximate_order = approximate_order
+
+        from sage.rings.polynomial.infinite_polynomial_ring import InfinitePolynomialRing
+        self._P = InfinitePolynomialRing(R, names=('FESDUMMY',))
+        x = self._P.gen()
+        def get_coeff(n):
+            if n < len(self._initial_values):
+                return self._initial_values[n]
+            return x[n]
+
         self._uninitialized = uninitialized
         self._uninitialized._approximate_order = approximate_order
-        self._uninitialized._target = self
+        self._uninitialized._target = Stream_function(get_coeff,
+                                                      is_sparse=False,
+                                                      approximate_order=approximate_order,
+                                                      true_order=True)
 
     def iterate_coefficients(self):
         """
@@ -1368,56 +1404,40 @@ class Stream_functional_equation(Stream_inexact):
         """
         yield from self._initial_values
 
-        from sage.rings.polynomial.infinite_polynomial_ring import InfinitePolynomialRing
-        P = InfinitePolynomialRing(self._base, names=('FESDUMMY',))
-        x = P.gen()
-        PFF = P.fraction_field()
-        offset = self._approximate_order
-
-        def get_coeff(n):
-            return x[n-offset]
-
-        sf = Stream_function(get_coeff, is_sparse=False, approximate_order=offset, true_order=True)
-        self._F = self._F.replace(self._uninitialized, sf)
+        x = self._P.gen()
+        PFF = self._P.fraction_field()
 
         n = self._F._approximate_order
-        data = list(self._initial_values)
         while True:
             coeff = self._F[n]
             if coeff.parent() is PFF:
                 coeff = coeff.numerator()
             else:
-                coeff = P(coeff)
+                coeff = self._P(coeff)
             V = coeff.variables()
 
-            # Substitute for known variables
-            if V:
-                # The infinite polynomial ring is very brittle with substitutions
-                #   and variable comparisons
-                sub = {str(x[i]): val for i, val in enumerate(data)
-                       if any(str(x[i]) == str(va) for va in V)}
-                if sub:
-                    coeff = coeff.subs(sub)
-                    V = P(coeff).variables()
-
-            if len(V) > 1:
-                raise ValueError(f"unable to determine a unique solution in degree {n}")
-
             if not V:
                 if coeff:
                     raise ValueError(f"no solution in degree {n} as {coeff} != 0")
                 n += 1
                 continue
 
+            if len(V) > 1:
+                raise ValueError(f"unable to determine a unique solution in degree {n}")
+
+            # the following test is wrong
+            if V[0] != x[n + len(self._initial_values)]:
+                print(f"the solutions to the coefficients must be computed in order")
+                # raise ValueError(f"the solutions to the coefficients must be computed in order")
+
             # single variable to solve for
             hc = coeff.homogeneous_components()
             if not set(hc).issubset([0,1]):
                 raise ValueError(f"unable to determine a unique solution in degree {n}")
-            if str(hc[1].lm()) != str(x[len(data)]):
-                raise ValueError(f"the solutions to the coefficients must be computed in order")
-            val = -hc.get(0, P.zero()).lc() / hc[1].lc()
+
+            val = -hc.get(0, self._P.zero()).lc() / hc[1].lc()
             # Update the cache
-            data.append(val)
+            self._F.map_cache(lambda c: c.subs({V[0]: val}))
             yield val
             n += 1
 
@@ -1461,6 +1481,13 @@ class Stream_unary(Stream_inexact):
         self._series = series
         super().__init__(is_sparse, true_order)
 
+    def map_cache(self, function):
+        r"""
+        Apply ``function`` to each element in the cache.
+        """
+        super().map_cache(function)
+        self._series.map_cache(function)
+
     def __hash__(self):
         """
         Return the hash of ``self``.
@@ -1576,6 +1603,14 @@ class Stream_binary(Stream_inexact):
         self._right = right
         super().__init__(is_sparse, False)
 
+    def map_cache(self, function):
+        r"""
+        Apply ``function`` to each element in the cache.
+        """
+        super().map_cache(function)
+        self._left.map_cache(function)
+        self._right.map_cache(function)
+
     def __hash__(self):
         """
         Return the hash of ``self``.
@@ -2392,6 +2427,13 @@ class Stream_plethysm(Stream_binary):
             f = Stream_map_coefficients(f, lambda x: p(x), is_sparse)
         super().__init__(f, g, is_sparse)
 
+    def map_cache(self, function):
+        r"""
+        Apply ``function`` to each element in the cache.
+        """
+        super().map_cache(function)
+        self._powers = [g.map_cache(function) for g in self._powers]
+
     @lazy_attribute
     def _approximate_order(self):
         """
@@ -3273,6 +3315,12 @@ class Stream_shift(Stream):
         self._shift = shift
         super().__init__(series._true_order)
 
+    def map_cache(self, function):
+        r"""
+        Apply ``function`` to each element in the cache.
+        """
+        self._series.map_cache(function)
+
     @lazy_attribute
     def _approximate_order(self):
         """

@mantepse
Copy link
Collaborator

Moreover, there is at least one bug which I do not understand yet - when you execute the cosine example on line 2599 of lazy_power_series_ring.py, an unsubstituted variable appears. I hope that this is not a fundamental problem.

I was thinking about this but could not find a solution other than using you replace mechanism additionally.

Suppose that we do f = L.undefined() and define the functional equation E in terms of f.

It seems to me that the fundamental problem is as follows:

  • on the one hand, we want that f returns the coefficients that solve the functional equation,
  • on the other hand, we need that f returns placeholders for the coefficients when computing the next therm of E.

Do you see another way to achieve this? Or do you think that the replace mechanism is optimal?

I am quite convinced that we want to specialize the placeholders in the cache whenever a coefficient is determined, because of when keeping them undetermined, the size of the equations may grow extremely fast, as for example in the Jacobi functional equation.

@tscrim
Copy link
Collaborator Author

tscrim commented Oct 17, 2023

Sorry for being slow to respond.

I think the issue you are seeing is because you cannot assume that only the largest degree in a series's cache will be the only one you need to substitute at any given time. At the end, that is true, but potentially not for the intermediaries. I cannot think of another way around it other than going through the entire case of everything in the stream-tree.

For what's optimal, I would say it likely depends on the situation. Yet, I suspect doing the replace will generally be faster to large coefficients. It also is easier to prove the code is correct IMO. The replace might also be easier to generalize to the multivariate case.

I was able to get a significant speedup: By simply substituting in the known values into the sf._cache, I was able to get the 49-th Jacobi degree coefficient in ~4s rather than ~20s in my previous branch. It now only has 19372 terms. I tried to this beforehand and ran into errors, but that was likely due to other errors I had to debug.

We might be able to get some other speedups by controlling ourselves the polynomial rings. It might make some of the code more robust too... However, I think that is best for a followup.

I also moved the L.functional_equation() to f.define_implicitly(). I thought about combining it with define(), but the semantics are different and introducing a boolean argument seemed stupid.

How fast is the Jacobi example with your fricas implementation? Perhaps this might be a good benchmark.

@mantepse
Copy link
Collaborator

I finally managed to adapt the idea of substituting the variables in all caches recursively. The result is quite extreme. Computing g[100], where g is the Jacobi example, then takes about 1s on my computer, whereas it doesn't finish in reasonable time with the current branch - g[64] takes already 35s with the current branch.

Moreover, I did not even do any obvious optimizations yet. One thing I do not really know is, whether it could happen that not only the last element of the cache contains the new variable. Thinking of van der Hoeven's algorithm, #34616, with respect to this question makes my head spin.

Since this is such a huge improvement, I'd rather not bury it in a separate pull request for which I won't really have the time for.

One idea to make what's misnamed map_cache below less misnamed might be to have a method that yields all the caches, recursively, something like dependent_caches_iterator.

diff --git a/src/sage/data_structures/stream.py b/src/sage/data_structures/stream.py
index d8ead2bd6b..b3f418a379 100644
--- a/src/sage/data_structures/stream.py
+++ b/src/sage/data_structures/stream.py
@@ -147,6 +147,9 @@ class Stream():
         """
         self._true_order = true_order
 
+    def map_cache(self, function):
+        pass
+
     @lazy_attribute
     def _approximate_order(self):
         """
@@ -454,6 +457,14 @@ class Stream_inexact(Stream):
             yield self.get_coefficient(n)
             n += 1
 
+    def map_cache(self, function):
+        if self._cache:
+            if self._is_sparse:
+                i = max(self._cache)
+                self._cache[i] = function(self._cache[i])
+            else:
+                self._cache[-1] = function(self._cache[-1])
+
     def order(self):
         r"""
         Return the order of ``self``, which is the minimum index ``n`` such
@@ -1360,6 +1371,11 @@ class Stream_uninitialized(Stream_inexact):
                 ret._target = temp
         return ret
 
+    def map_cache(self, function):
+        super().map_cache(function)
+        if self._target is not None:
+            self._target.map_cache(function)
+
 
 class Stream_functional_equation(Stream_inexact):
     r"""
@@ -1430,7 +1446,7 @@ class Stream_functional_equation(Stream_inexact):
         yield from self._initial_values
 
         from sage.rings.polynomial.infinite_polynomial_ring import InfinitePolynomialRing
-        P = InfinitePolynomialRing(self._base, names=('FESDUMMY',))
+        P = InfinitePolynomialRing(self._base, names=('FESDUMMY',), implementation='sparse')
         x = P.gen()
         PFF = P.fraction_field()
         offset = self._approximate_order
@@ -1481,7 +1497,16 @@ class Stream_functional_equation(Stream_inexact):
                 raise ValueError(f"the solutions to the coefficients must be computed in order")
             val = -hc.get(0, P.zero()).lc() / hc[1].lc()
             # Update the cache
-            sf._cache[len(data)] = val
+            def sub(c):
+                if c not in self._base:
+                    # print(c.polynomial().parent())
+                    return self._base(c.subs({V[0]: val}))
+                return c
+            # print("sf._cache", sf._cache)
+            sf.map_cache(sub)
+            # print("F._cache", self._F._cache)            
+            self._F.map_cache(sub)
+            # sf._cache[len(data)] = val
             data.append(val)
             yield val
             n += 1
@@ -1526,6 +1551,10 @@ class Stream_unary(Stream_inexact):
         self._series = series
         super().__init__(is_sparse, true_order)
 
+    def map_cache(self, function):
+        super().map_cache(function)
+        self._series.map_cache(function)
+
     def __hash__(self):
         """
         Return the hash of ``self``.
@@ -1663,6 +1692,11 @@ class Stream_binary(Stream_inexact):
         self._right = right
         super().__init__(is_sparse, False)
 
+    def map_cache(self, function):
+        super().map_cache(function)
+        self._left.map_cache(function)
+        self._right.map_cache(function)
+
     def __hash__(self):
         """
         Return the hash of ``self``.
@@ -2526,6 +2560,10 @@ class Stream_plethysm(Stream_binary):
             f = Stream_map_coefficients(f, lambda x: p(x), is_sparse)
         super().__init__(f, g, is_sparse)
 
+    def map_cache(self, function):
+        super().map_cache(function)
+        self._powers = [g.map_cache(function) for g in self._powers]
+
     @lazy_attribute
     def _approximate_order(self):
         """
@@ -3407,6 +3445,9 @@ class Stream_shift(Stream):
         self._shift = shift
         super().__init__(series._true_order)
 
+    def map_cache(self, function):
+        self._series.map_cache(function)
+
     @lazy_attribute
     def _approximate_order(self):
         """

@mkoeppe mkoeppe removed this from the sage-10.2 milestone Oct 23, 2023
@tscrim
Copy link
Collaborator Author

tscrim commented Oct 24, 2023

That is certainly quite a big improvement! Thank you for doing it. I have added the patch. For future reference, you could always just make a PR on my fork based off my branch (so it will be included in this PR).

Just to check this is to be taken in conjunction with my current approach of a substitution rather than a replacement (ahem...substitution...) for it? If it is meant to be a strict a replacement, then I am not sure how to prove correctness (and I suspect I could find an example where this breaks through some cancellation of higher order terms than the ones computed).

@mantepse
Copy link
Collaborator

That is certainly quite a big improvement! Thank you for doing it. I have added the patch. For future reference, you could always just make a PR on my fork based off my branch (so it will be included in this PR).

Thank you! Yes, it was just a proof of concept so far. Since you say you like it, I will improve on it (and make a pull request).

Just to check this is to be taken in conjunction with my current approach of a substitution rather than a replacement (ahem...substitution...) for it?

Yes, exactly. As mentioned above (#36233 (comment)) the replace mechanism seems to be necessary.

If it is meant to be a strict a replacement, then I am not sure how to prove correctness (and I suspect I could find an example where this breaks through some cancellation of higher order terms than the ones computed).

It would be wonderful if you could cook up an example where, when computing the next term of Stream_functional_equation via iterate_coefficients one of the child-caches of self._F grows by more than one element, both containing the new FESDUMMY variable. In this case, it would not be enough that map_cache (I'll rename it soon) only substitutes the very last element of the cache.

@tscrim
Copy link
Collaborator Author

tscrim commented Oct 24, 2023

I am in the process of adding doc and tests to your patch. Please wait a bit before making any changes. I will let you know when I leave for the night, at which time I won't work more on this for the day.

@tscrim
Copy link
Collaborator Author

tscrim commented Oct 24, 2023

@mantepse Okay, I finished putting on the polish and bringing stuff to full test coverage. I probably won't do any more work on this for today. I included a remaining of the method, but I am not sure it is much better. Anyways, feel free to decide on a name you are happy with.

For the example, I was thinking something about taking a derivative of something then evaluating the result at 0 and cancelling that with a rescaled shift also evaluated at 0. Thus the intermediate stream would need more terms than what appears in the final result. It will almost certainly be a very contrived example (if I care enough to construct one...).

@dimpase
Copy link
Member

dimpase commented Jan 11, 2024

is this ready?

@mantepse
Copy link
Collaborator

No, everything related to implicit definitions of power series should be removed, because this will be done in #37033, and it will be done differently.

@tscrim tscrim force-pushed the lazy_series/integration branch from 2688cb5 to 3ab190b Compare January 12, 2024 01:42
@tscrim
Copy link
Collaborator Author

tscrim commented Jan 12, 2024

@mantepse Sorry for taking a while to split the two. Here is the branch without the implicit definition stuff and all commits squashed down to avoid accidentally deleting stuff with your branch. If you remove all of the integration/Taylor series stuff from your branch and squash everything down, then we shouldn't get any conflicts.

@tscrim tscrim changed the title Implement integration for lazy series Implement integration and Taylor series for lazy series Jan 12, 2024
Copy link
Collaborator

@mantepse mantepse left a comment

Choose a reason for hiding this comment

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

otherwise looks good!

src/sage/data_structures/stream.py Outdated Show resolved Hide resolved
interprets it as the number of times to integrate the function.
If ``variable`` is not the variable of the Laurent series, then
the coefficients are integrated with respect to ``variable``.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm afraid that this conflicts with the conventions in https://doc.sagemath.org/html/en/reference/power_series/sage/rings/multi_power_series_ring_element.html#sage.rings.multi_power_series_ring_element.MPowerSeries.integral

I think it is good to have the possibility of providing integration constants, but it is more important to be consistent with multivariate power series. So maybe we could have a keyword only parameter constants?

desolve has other conventions, but from my experience (and I use it quite a bit because of my teaching) they are a nightmare.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I just realised that this is the integral for univariate series. I am not sure whether my comment still applies.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The multivariate power series is a clear outlier, and it also doesn't support the same functionality as other cases:

sage: R.<x,y> = QQ['t'][[]]
sage: t = R.base_ring().gen()
sage: x.integral(t)  # errors out instead of t*x

From this, I would say the multivariate power series needs to be brought into line.

I will address the rest of the comments later when I have a bit more time.

src/sage/rings/lazy_series.py Outdated Show resolved Hide resolved
src/sage/rings/lazy_series.py Show resolved Hide resolved
src/sage/rings/lazy_series_ring.py Outdated Show resolved Hide resolved
sage: L.taylor(f)
1 + z + z^2 + z^3 + z^4 + z^5 + z^6 + O(z^7)

For inputs as symbolic functions, this uses the generic implementation,
Copy link
Collaborator

Choose a reason for hiding this comment

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

maybe replace For inputs as symbolic functions with If f is in the symbolic ring

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

That actually changes things in a very subtle but meaningful way. In particular, polynomials (e.g., QQ['x,y']) are also in SR but are not symbolic functions. So I think it is better to not change this.

src/sage/rings/lazy_series_ring.py Outdated Show resolved Hide resolved
src/sage/rings/lazy_series_ring.py Outdated Show resolved Hide resolved
@tscrim
Copy link
Collaborator Author

tscrim commented Jan 15, 2024

I have addressed all of the things I agree with and commented on why I did not make those changes.

I also implemented the multivariate Taylor series by using Stream_function to avoid passing the parent in. This means we do not have as meaningful comparisons, but it does allow us to cover a fairly easy and natural case (mathematically speaking).

Copy link

Documentation preview for this PR (built with commit fe6bac8; changes) is ready! 🎉

@mantepse
Copy link
Collaborator

Thank you! (The one doctest failure is almost certainly unrelated.)

@tscrim
Copy link
Collaborator Author

tscrim commented Jan 15, 2024

I agree and I cannot reproduce locally. Thank you.

vbraun pushed a commit to vbraun/sage that referenced this pull request Jan 16, 2024
…ries

    
<!-- ^^^^^
Please provide a concise, informative and self-explanatory title.
Don't put issue numbers in there, do this in the PR body below.
For example, instead of "Fixes sagemath#1234" use "Introduce new method to
calculate 1+1"
-->
<!-- Describe your changes here in detail -->

We implement an `integral()` method for lazy Laurent and power series.
If we raise an error if we perform $\int t^{-1} dt$.

We also prove a way to construct a lazy (power) series as the Taylor
series of a function.

<!-- Why is this change required? What problem does it solve? -->
<!-- If this PR resolves an open issue, please link to it here. For
example "Fixes sagemath#12345". -->
<!-- If your change requires a documentation PR, please link it
appropriately. -->

### 📝 Checklist

<!-- Put an `x` in all the boxes that apply. -->
<!-- If your change requires a documentation PR, please link it
appropriately -->
<!-- If you're unsure about any of these, don't hesitate to ask. We're
here to help! -->
<!-- Feel free to remove irrelevant items. -->

- [x] The title is concise, informative, and self-explanatory.
- [x] The description explains in detail what this PR is about.
- [x] I have linked a relevant issue or discussion.
- [x] I have created tests covering the changes.
- [x] I have updated the documentation accordingly.

### ⌛ Dependencies

<!-- List all open PRs that this PR logically depends on
- sagemath#12345: short description why this is a dependency
- sagemath#34567: ...
-->

- sagemath#35485: Uses the `is_uninitialized()` checking.

<!-- If you're unsure about any of these, don't hesitate to ask. We're
here to help! -->
    
URL: sagemath#36233
Reported by: Travis Scrimshaw
Reviewer(s): Martin Rubey, Travis Scrimshaw
vbraun pushed a commit to vbraun/sage that referenced this pull request Jan 16, 2024
…ries

    
<!-- ^^^^^
Please provide a concise, informative and self-explanatory title.
Don't put issue numbers in there, do this in the PR body below.
For example, instead of "Fixes sagemath#1234" use "Introduce new method to
calculate 1+1"
-->
<!-- Describe your changes here in detail -->

We implement an `integral()` method for lazy Laurent and power series.
If we raise an error if we perform $\int t^{-1} dt$.

We also prove a way to construct a lazy (power) series as the Taylor
series of a function.

<!-- Why is this change required? What problem does it solve? -->
<!-- If this PR resolves an open issue, please link to it here. For
example "Fixes sagemath#12345". -->
<!-- If your change requires a documentation PR, please link it
appropriately. -->

### 📝 Checklist

<!-- Put an `x` in all the boxes that apply. -->
<!-- If your change requires a documentation PR, please link it
appropriately -->
<!-- If you're unsure about any of these, don't hesitate to ask. We're
here to help! -->
<!-- Feel free to remove irrelevant items. -->

- [x] The title is concise, informative, and self-explanatory.
- [x] The description explains in detail what this PR is about.
- [x] I have linked a relevant issue or discussion.
- [x] I have created tests covering the changes.
- [x] I have updated the documentation accordingly.

### ⌛ Dependencies

<!-- List all open PRs that this PR logically depends on
- sagemath#12345: short description why this is a dependency
- sagemath#34567: ...
-->

- sagemath#35485: Uses the `is_uninitialized()` checking.

<!-- If you're unsure about any of these, don't hesitate to ask. We're
here to help! -->
    
URL: sagemath#36233
Reported by: Travis Scrimshaw
Reviewer(s): Martin Rubey, Travis Scrimshaw
vbraun pushed a commit to vbraun/sage that referenced this pull request Jan 16, 2024
…ries

    
<!-- ^^^^^
Please provide a concise, informative and self-explanatory title.
Don't put issue numbers in there, do this in the PR body below.
For example, instead of "Fixes sagemath#1234" use "Introduce new method to
calculate 1+1"
-->
<!-- Describe your changes here in detail -->

We implement an `integral()` method for lazy Laurent and power series.
If we raise an error if we perform $\int t^{-1} dt$.

We also prove a way to construct a lazy (power) series as the Taylor
series of a function.

<!-- Why is this change required? What problem does it solve? -->
<!-- If this PR resolves an open issue, please link to it here. For
example "Fixes sagemath#12345". -->
<!-- If your change requires a documentation PR, please link it
appropriately. -->

### 📝 Checklist

<!-- Put an `x` in all the boxes that apply. -->
<!-- If your change requires a documentation PR, please link it
appropriately -->
<!-- If you're unsure about any of these, don't hesitate to ask. We're
here to help! -->
<!-- Feel free to remove irrelevant items. -->

- [x] The title is concise, informative, and self-explanatory.
- [x] The description explains in detail what this PR is about.
- [x] I have linked a relevant issue or discussion.
- [x] I have created tests covering the changes.
- [x] I have updated the documentation accordingly.

### ⌛ Dependencies

<!-- List all open PRs that this PR logically depends on
- sagemath#12345: short description why this is a dependency
- sagemath#34567: ...
-->

- sagemath#35485: Uses the `is_uninitialized()` checking.

<!-- If you're unsure about any of these, don't hesitate to ask. We're
here to help! -->
    
URL: sagemath#36233
Reported by: Travis Scrimshaw
Reviewer(s): Martin Rubey, Travis Scrimshaw
@vbraun vbraun merged commit 70af5c8 into sagemath:develop Jan 22, 2024
19 of 20 checks passed
@mkoeppe mkoeppe added this to the sage-10.3 milestone Mar 7, 2024
@tscrim tscrim deleted the lazy_series/integration branch March 28, 2024 11:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants