Repository: geomstats/geomstats

Issue:
Metrics for positive semi-definite matrices
LogEuclidean and affine invariant metrics indicated PSD `rank_k_psd_matrices` for PSD matrices are not applicable and could not be computed (thus raising numerical errors). Euclidean is fine and Bures-Wasserstein should work (more on that in a different issue).

I'll open a PR to remove these metrics

Here is some code to reproduce this issue.
```
from geomstats.geometry.rank_k_psd_matrices import PSDMatrices, PSDMetricEuclidean, PSDMetricAffine, PSDMetricLogEuclidean
from numpy.linalg import LinAlgError

dim, rank = 4, 3
space = PSDMatrices(n=dim, k=rank)
X = space.random_point(10)

euclidean_metric = PSDMetricEuclidean(dim)
affine_metric = PSDMetricAffine(dim)
logeuclidean_metric = PSDMetricLogEuclidean(dim)

for metric in [euclidean_metric, affine_metric, logeuclidean_metric]:
    try:
        print(metric.dist(X, X[::-1]))
    except LinAlgError:
        print("LinAlgError raised")
```

Discussion:

Patch:
diff --git a/geomstats/geometry/base.py b/geomstats/geometry/base.py
index 3ef8be2e1..b88c45b04 100644
--- a/geomstats/geometry/base.py
+++ b/geomstats/geometry/base.py
@@ -29,7 +29,7 @@ class VectorSpace(Manifold, abc.ABC):
             kwargs["dim"] = int(gs.prod(gs.array(shape)))
         super(VectorSpace, self).__init__(shape=shape, **kwargs)
         self.shape = shape
-        self.basis = None
+        self._basis = None
 
     def belongs(self, point, atol=gs.atol):
         """Evaluate if the point belongs to the vector space.
@@ -148,7 +148,7 @@ class VectorSpace(Manifold, abc.ABC):
 
     @basis.setter
     def basis(self, basis):
-        if basis is not None and len(basis) < self.dim:
+        if len(basis) < self.dim:
             raise ValueError(
                 "The basis should have length equal to the " "dimension of the space."
             )
diff --git a/geomstats/geometry/lie_algebra.py b/geomstats/geometry/lie_algebra.py
index 6a2bb35c1..04af3ed99 100644
--- a/geomstats/geometry/lie_algebra.py
+++ b/geomstats/geometry/lie_algebra.py
@@ -36,7 +36,6 @@ class MatrixLieAlgebra(VectorSpace, abc.ABC):
         geomstats.errors.check_integer(dim, "dim")
         geomstats.errors.check_integer(n, "n")
         self.dim = dim
-        self.basis = None
         self.n = n
 
     bracket = Matrices.bracket
diff --git a/geomstats/geometry/matrices.py b/geomstats/geometry/matrices.py
index 861956de8..94c308ba7 100644
--- a/geomstats/geometry/matrices.py
+++ b/geomstats/geometry/matrices.py
@@ -28,7 +28,6 @@ class Matrices(VectorSpace):
         geomstats.errors.check_integer(m, "m")
         self.m = m
         self.n = n
-        self.basis = None
 
     def _create_basis(self):
         """Create the canonical basis."""
diff --git a/geomstats/geometry/rank_k_psd_matrices.py b/geomstats/geometry/rank_k_psd_matrices.py
index 8ed761eb8..7818f5ed5 100644
--- a/geomstats/geometry/rank_k_psd_matrices.py
+++ b/geomstats/geometry/rank_k_psd_matrices.py
@@ -9,10 +9,8 @@ from geomstats.geometry.manifold import Manifold
 from geomstats.geometry.matrices import Matrices
 from geomstats.geometry.spd_matrices import (
     SPDMatrices,
-    SPDMetricAffine,
     SPDMetricBuresWasserstein,
     SPDMetricEuclidean,
-    SPDMetricLogEuclidean,
 )
 from geomstats.geometry.symmetric_matrices import SymmetricMatrices
 
@@ -185,10 +183,6 @@ PSDMetricBuresWasserstein = SPDMetricBuresWasserstein
 
 PSDMetricEuclidean = SPDMetricEuclidean
 
-PSDMetricLogEuclidean = SPDMetricLogEuclidean
-
-PSDMetricAffine = SPDMetricAffine
-
 
 class PSDMatrices(RankKPSDMatrices, SPDMatrices):
     r"""Class for the psd matrices.
diff --git a/geomstats/information_geometry/dirichlet.py b/geomstats/information_geometry/dirichlet.py
index 3e28c0973..566a112f4 100644
--- a/geomstats/information_geometry/dirichlet.py
+++ b/geomstats/information_geometry/dirichlet.py
@@ -2,8 +2,11 @@
 
 Lead author: Alice Le Brigant.
 """
+import logging
 
+import numpy as np
 from scipy.integrate import odeint, solve_bvp
+from scipy.optimize import minimize
 from scipy.stats import dirichlet
 
 import geomstats.backend as gs
@@ -480,12 +483,157 @@ class DirichletMetric(RiemannianMetric):
 
         return exp
 
-    def _geodesic_bvp(
+    def _approx_geodesic_bvp(
         self,
         initial_point,
         end_point,
-        n_steps=N_STEPS,
-        jacobian=False,
+        degree=5,
+        method="BFGS",
+        n_times=200,
+        jac_on=True,
+    ):
+        """Solve approximation of the geodesic boundary value problem.
+
+        The space of solutions is restricted to curves whose coordinates are
+        polynomial functions of time. The boundary value problem is solved by
+        minimizing the energy among all such curves starting from initial_point
+        and ending at end_point, i.e. curves t -> (x_1(t),...,x_n(t)) where x_i
+        are polynomial functions of time t, such that (x_1(0),..., x_n(0)) is
+        initial_point and (x_1(1),..., x_n(1)) is end_point. The parameterized
+        curve is computed at n_times discrete times.
+
+        Parameters
+        ----------
+        initial_point : array-like, shape=(dim,)
+            Starting point of the geodesic.
+        end_point : array-like, shape=(dim,)
+            End point of the geodesic.
+        degree : int
+            Degree of the coordinates' polynomial functions of time.
+        method : str
+            Minimization method to use in scipy.optimize.minimize.
+        n_times : int
+            Number of sample times.
+        jac_on : bool
+            If jac_on=True, use the Jacobian of the energy cost function in
+            scipy.optimize.minimize.
+
+        Returns
+        -------
+        dist : float
+            Length of the polynomial approximation of the geodesic.
+        curve : array-like, shape=(n_times, dim)
+            Polynomial approximation of the geodesic.
+        velocity : array-like, shape=(n_times, dim)
+            Velocity of the polynomial approximation of the geodesic.
+        """
+
+        def cost_fun(param):
+            """Compute the energy of the polynomial curve defined by param.
+
+            Parameters
+            ----------
+            param : array-like, shape=(degree - 1, dim)
+                Parameters of the curve coordinates' polynomial functions of time.
+
+            Returns
+            -------
+            energy : float
+                Energy of the polynomial approximation of the geodesic.
+            length : float
+                Length of the polynomial approximation of the geodesic.
+            curve : array-like, shape=(n_times, dim)
+                Polynomial approximation of the geodesic.
+            velocity : array-like, shape=(n_times, dim)
+                Velocity of the polynomial approximation of the geodesic.
+            """
+            last_coef = end_point - initial_point - gs.sum(param, axis=0)
+            coef = gs.vstack((initial_point, param, last_coef))
+
+            t = gs.linspace(0.0, 1.0, n_times)
+            t_curve = [t**i for i in range(degree + 1)]
+            t_curve = gs.stack(t_curve)
+            curve = gs.einsum("ij,ik->kj", coef, t_curve)
+
+            t_velocity = [i * t ** (i - 1) for i in range(1, degree + 1)]
+            t_velocity = gs.stack(t_velocity)
+            velocity = gs.einsum("ij,ik->kj", coef[1:], t_velocity)
+
+            if curve.min() < 0:
+                return np.inf, np.inf, curve, np.nan
+
+            velocity_sqnorm = self.squared_norm(vector=velocity, base_point=curve)
+            length = gs.sum(velocity_sqnorm ** (1 / 2)) / n_times
+            energy = gs.sum(velocity_sqnorm) / n_times
+            return energy, length, curve, velocity
+
+        def cost_jacobian(param):
+            """Compute the jacobian of the cost function at polynomial curve.
+
+            Parameters
+            ----------
+            param : array-like, shape=(degree - 1, dim)
+                Parameters of the curve coordinates' polynomial functions of time.
+
+            Returns
+            -------
+            jac : array-like, shape=(dim * (degree - 1),)
+                Jacobian of the cost function at polynomial curve.
+            """
+            last_coef = end_point - initial_point - gs.sum(param, 0)
+            coef = gs.vstack((initial_point, param, last_coef))
+
+            t = gs.linspace(0.0, 1.0, n_times)
+            t_position = [t**i for i in range(degree + 1)]
+            t_position = gs.stack(t_position)
+            position = gs.einsum("ij,ik->kj", coef, t_position)
+
+            t_velocity = [i * t ** (i - 1) for i in range(1, degree + 1)]
+            t_velocity = gs.stack(t_velocity)
+            velocity = gs.einsum("ij,ik->kj", coef[1:], t_velocity)
+
+            fac1 = gs.stack(
+                [
+                    k * t ** (k - 1) - degree * t ** (degree - 1)
+                    for k in range(1, degree)
+                ]
+            )
+            fac2 = gs.stack([t**k - t**degree for k in range(1, degree)])
+            fac3 = (velocity * gs.polygamma(1, position)).T - gs.sum(
+                velocity, 1
+            ) * gs.polygamma(1, gs.sum(position, 1))
+            fac4 = (velocity**2 * gs.polygamma(2, position)).T - gs.sum(
+                velocity, 1
+            ) ** 2 * gs.polygamma(2, gs.sum(position, 1))
+
+            cost_jac = (
+                2 * gs.einsum("ij,kj->ik", fac1, fac3)
+                + gs.einsum("ij,kj->ik", fac2, fac4)
+            ) / n_times
+            return cost_jac.T.reshape(dim * (degree - 1))
+
+        def f2minimize(x):
+            """Compute function to minimize."""
+            param = gs.transpose(x.reshape((dim, degree - 1)))
+            res = cost_fun(param)
+            return res[0]
+
+        def jacobian(x):
+            """Compute jacobian of the function to minimize."""
+            param = gs.transpose(x.reshape((dim, degree - 1)))
+            return cost_jacobian(param)
+
+        dim = initial_point.shape[0]
+        x0 = gs.ones(dim * (degree - 1))
+        jac = jacobian if jac_on else None
+        sol = minimize(f2minimize, x0, method=method, jac=jac)
+        opt_param = sol.x.reshape((dim, degree - 1)).T
+        _, dist, curve, velocity = cost_fun(opt_param)
+
+        return dist, curve, velocity
+
+    def _geodesic_bvp(
+        self, initial_point, end_point, n_steps=N_STEPS, jacobian=False, init="linear"
     ):
         """Solve geodesic boundary problem.
 
@@ -616,6 +764,12 @@ class DirichletMetric(RiemannianMetric):
 
             def initialize(point_0, point_1):
                 """Initialize the solution of the boundary value problem."""
+                if init == "polynomial":
+                    _, curve, velocity = self._approx_geodesic_bvp(
+                        point_0, point_1, n_times=n_steps
+                    )
+                    return gs.vstack((curve.T, velocity.T))
+
                 lin_init = gs.zeros([2 * self.dim, n_steps])
                 lin_init[: self.dim, :] = gs.transpose(
                     gs.linspace(point_0, point_1, n_steps)
@@ -637,6 +791,12 @@ class DirichletMetric(RiemannianMetric):
                 solution = solve_bvp(
                     bvp, bc, t_int, initialize(ip, ep), fun_jac=fun_jac
                 )
+                if solution.status == 1:
+                    logging.warning(
+                        "The maximum number of mesh nodes for solving the  "
+                        "geodesic boundary value problem is exceeded. "
+                        "Result may be inaccurate."
+                    )
                 solution_at_t = solution.sol(t)
                 geodesic = solution_at_t[: self.dim, :]
                 geod.append(gs.squeeze(gs.transpose(geodesic)))
@@ -645,7 +805,7 @@ class DirichletMetric(RiemannianMetric):
 
         return path
 
-    def log(self, point, base_point, n_steps=N_STEPS, jacobian=False):
+    def log(self, point, base_point, n_steps=N_STEPS, jacobian=False, init="linear"):
         """Compute the logarithm map.
 
         Compute logarithm map associated to the Fisher information metric by
@@ -665,6 +825,11 @@ class DirichletMetric(RiemannianMetric):
             If True, the explicit value of the jacobian is used to solve
             the geodesic boundary value problem.
             Optional, default: False.
+        init : str, {'linear', 'polynomial}
+            Initialization used to solve the geodesic boundary value problem.
+            If 'linear', use the Euclidean straight line as initial guess.
+            If 'polynomial', use a curve with coordinates that are polynomial
+            functions of time.
 
         Returns
         -------
@@ -674,7 +839,7 @@ class DirichletMetric(RiemannianMetric):
         """
         t = gs.linspace(0.0, 1.0, n_steps)
         geodesic = self._geodesic_bvp(
-            initial_point=base_point, end_point=point, jacobian=jacobian
+            initial_point=base_point, end_point=point, jacobian=jacobian, init=init
         )
         geodesic_at_t = geodesic(t)
         log = n_steps * (geodesic_at_t[..., 1, :] - geodesic_at_t[..., 0, :])


Test patch:
diff --git a/tests/parametrizers.py b/tests/parametrizers.py
index b9e40bfb1..1620e59a9 100644
--- a/tests/parametrizers.py
+++ b/tests/parametrizers.py
@@ -50,19 +50,67 @@ def _is_isometry(
 
 
 class Parametrizer(type):
-    """Metaclass for test files.
-
-    Parametrizer decorates every function inside the class with pytest.mark.parametrizer
-    (except class methods and static methods). Two conventions need to be respected:
-
-    1.There should be a TestData object named 'testing_data'.
-    2.Every test function should have its corresponding data function inside
-    TestData object.
-
-    Ex. test_exp() should have method called exp_data() inside 'testing_data'.
+    """Metaclass for test classes.
+
+    Note: A test class is a class that inherits from TestCase.
+    For example, `class TestEuclidean(TestCase)` defines
+    a test class.
+
+    The Parametrizer helps its test class by pairing:
+    - the different test functions of the test class:
+      - e.g. the test function `test_belongs`,
+    - with different test data, generated by auxiliary test data functions
+      - e.g. the test data function `belongs_data` that generates data
+      to test the function `belongs`.
+
+    As such, Parametrizer acts as a "metaclass" of its test class:
+    `class TestEuclidean(TestCase, metaclass=Parametrizer)`.
+
+    Specifically, Parametrizer decorates every test function inside
+    its test class with pytest.mark.parametrizer, with the exception
+    of the test class' class methods and static methods.
+
+    Two conventions need to be respected:
+    1. The test class should contain an attribute named 'testing_data'.
+      - `testing_data` is an object inheriting from `TestData`.
+    2. Every test function should have its corresponding test data function created
+    inside the TestData object called `testing_data`.
+
+    A sample test class looks like this:
+
+    ```
+    class TestEuclidean(TestCase, metaclass=Parametrizer):
+        class TestDataEuclidean(TestData):
+            def belongs_data():
+                ...
+                return self.generate_tests(...)
+        testing_data = TestDataEuclidean()
+        def test_belongs():
+            ...
+    ```
+    Parameters
+    ----------
+    cls : child class of TestCase
+        Test class, i.e. a class inheriting from TestCase
+    name : str
+        Name of the test class
+    bases : TestCase
+        Parent class of the test class: TestCase.
+    attrs : dict
+        Attributes of the test class, for example its methods,
+        stored in a dictionnary as (key, value) when key gives the
+        name of the attribute (for example the name of the method),
+        and value gives the actual attribute (for example the method
+        itself.)
+
+    References
+    ----------
+    More on pytest's parametrizers can be found here:
+    https://docs.pytest.org/en/6.2.x/parametrize.html
     """
 
     def __new__(cls, name, bases, attrs):
+        """Decorate the test class' methods with pytest."""
         for attr_name, attr_value in attrs.copy().items():
             if isinstance(attr_value, types.FunctionType):
                 if ("skip_" + attr_name, True) not in locals()["attrs"].items():
diff --git a/tests/tests_geomstats/test_dirichlet.py b/tests/tests_geomstats/test_dirichlet.py
index ef77fda2a..d7a7b1b6a 100644
--- a/tests/tests_geomstats/test_dirichlet.py
+++ b/tests/tests_geomstats/test_dirichlet.py
@@ -389,3 +389,30 @@ class TestDirichletDistributions(geomstats.tests.TestCase):
         result = helper.test_projection_and_belongs(self.dirichlet, shape)
         for res in result:
             self.assertTrue(res)
+
+    @geomstats.tests.np_and_autograd_only
+    def test_approx_geodesic_bvp(self):
+        """Test approximate solution to geodesic boundary value problem.
+
+        Check that the distance given by the approximate solution is close
+        to the exact solution.
+        """
+        point_a = self.dirichlet.random_point()
+        point_b = self.dirichlet.random_point()
+        res = self.dirichlet.metric._approx_geodesic_bvp(point_a, point_b)
+        result = res[0]
+        expected = self.dirichlet.metric.dist(point_a, point_b)
+        self.assertAllClose(expected, result, atol=0, rtol=1e-1)
+
+    @geomstats.tests.np_and_autograd_only
+    def test_polynomial_init(self):
+        """Test polynomial initialization of the geodesic boundary value problem.
+
+        Check that in a particular case, where linear initialization fails,
+        polynomial initialization gives the desired result.
+        """
+        point_a = gs.array([100.0, 1.0, 1.0])
+        point_b = gs.array([1.0, 1.0, 100.0])
+        result = self.dirichlet.metric.dist(point_a, point_b, init="polynomial")
+        expected = 8.5
+        self.assertAllClose(expected, result, atol=0, rtol=1e-1)