Repository: geomstats/geomstats

Issue:
Implement scalar product of metric
We need a method to multiply a metric by a scalar. After discussion with @alebrigant we suggest the following. Thoughts from everyone very welcome!

In `RiemannianMetric` implement `__mul__` and `__rmul__` to allow operations of the type `new_metric = 2 * old_metric` which will return a `ScalarProductMetric`. This will give a simple and intuitive way to make scalar products.

Implement `ScalarProductMetric` which will simply wrap `NFoldMetric` with one product.

Then the key work should happen in `NFoldMetric`. This makes sense because `NFoldMetric` should already be little more than a wrapper for the underlying metric. It is already configured to take an argument `scales` which rescales the metric on the different factors. However, it only implements a couple of basic methods from `RiemannianMetric` and it is not set up to take advantage of any of the implementations in the various subclasses. I see a couple of options to remedy that.

1. Dynamic implementation: edit `NFoldMetric.__getattribute__` to obtain all attributes from the underlying metric and then pass it to a wrapper which rescales the output. The scaling factor is hard-coded into `NFoldMetric` (ie, is it linear, square-root, reciprocal) and if `NFoldMetric` doesn't know what to do it throws an `AttributeError` and you have to add your missing method to the list of known methods. It is possible to change the underlying metric on the fly.
2. Generate attributes at `__init__`: the same as above expect that instead of editing `__getattribute__`, at initialization we use the `__dir__` method of the underlying metric to gather all its attributes, we wrap them, and we put them into our `ScalarProductMetric` object.  Now if you change the underlying metric, it has no impact on the `ScalarProductMetric` object, because it has already collected all the attributes. 
3. Implement methods one by one: each method gets its own implementation in `ScalarProductMetric` which calls the underlying method and rescales it. `__init__` will have to collect all variables from the underlying metric.

Does using `NFoldMetric` seem reasonable? As for the implementation, my preference is to go with option 1 or 2. If you want to teach `ScalarProductMetric` a new method you just have to add it to one of the hard-coded lists so that it knows how to scale the result, rather than putting in a whole new method. That keeps the code a bit cleaner, I think. I'm not sure which I like better. I don't see any clear advantages to either. They have different functionality, obviously, but I do not see clearly which functionality is more likely to be useful. In all three cases, one risk to be aware of is the possibility that different child classes of `RiemannianMetric` could have different methods with the same name which have to be scaled differently. That seems unlikely, but it's something to consider.

Comments:

Patch:
diff --git a/geomstats/geometry/riemannian_metric.py b/geomstats/geometry/riemannian_metric.py
index c06b03d78..5eeacc1cd 100644
--- a/geomstats/geometry/riemannian_metric.py
+++ b/geomstats/geometry/riemannian_metric.py
@@ -46,6 +46,48 @@ class RiemannianMetric(Connection, ABC):
             signature = (dim, 0)
         self.signature = signature
 
+    def __mul__(self, scalar):
+        """Multiply the metric by a scalar.
+
+        This method multiplies the (0,2) metric tensor by a scalar. Note that this does
+        not scale distances by the scalar. That would require multiplication by the
+        square of the scalar.
+
+        Parameters
+        ----------
+        scalar : float
+            The number by which to multiply the metric.
+
+        Returns
+        -------
+        metric : ScalarProductMetric
+            The metric multiplied by the scalar
+        """
+        from geomstats.geometry.scalar_product_metric import ScalarProductMetric
+
+        if not isinstance(scalar, float):
+            raise NotImplementedError(f"Expected `float` instead of `{type(scalar)}`")
+        return ScalarProductMetric(self, scalar)
+
+    def __rmul__(self, scalar):
+        """Multiply the metric by a scalar.
+
+        This method multiplies the (0,2) metric tensor by a scalar. Note that this does
+        not scale distances by the scalar. That would require multiplication by the
+        square of the scalar.
+
+        Parameters
+        ----------
+        scalar : float
+            The number by which to multiply the metric.
+
+        Returns
+        -------
+        metric : ScalarProductMetric
+            The metric multiplied by the scalar.
+        """
+        return self * scalar
+
     def metric_matrix(self, base_point=None):
         """Metric matrix at the tangent space at a base point.
 
diff --git a/geomstats/geometry/scalar_product_metric.py b/geomstats/geometry/scalar_product_metric.py
new file mode 100644
index 000000000..ce661680a
--- /dev/null
+++ b/geomstats/geometry/scalar_product_metric.py
@@ -0,0 +1,116 @@
+"""Scalar product of a Riemannian metric.
+
+Define the product of a Riemannian metric with a scalar number.
+
+Lead author: John Harvey.
+"""
+from functools import wraps
+
+import geomstats.backend as gs
+
+SQRT_LIST = ["norm", "dist", "dist_broadcast", "dist_pairwise", "diameter"]
+LINEAR_LIST = [
+    "metric_matrix",
+    "inner_product",
+    "inner_product_derivative_matrix",
+    "squared_norm",
+    "squared_dist",
+    "covariant_riemann_tensor",
+]
+QUADRATIC_LIST = []
+INVERSE_LIST = [
+    "cometric_matrix",
+    "inner_coproduct",
+    "hamiltonian",
+    "sectional_curvature",
+    "scalar_curvature",
+]
+INVERSE_QUADRATIC_LIST = ["normalize", "random_unit_tangent_vec", "normal_basis"]
+
+
+def _wrap_attr(scaling_factor, func):
+    @wraps(func)
+    def response(*args, **kwargs):
+        res = scaling_factor * func(*args, **kwargs)
+        return res
+
+    return response
+
+
+def _get_scaling_factor(func_name, scale):
+    if func_name in SQRT_LIST:
+        return gs.sqrt(scale)
+
+    if func_name in LINEAR_LIST:
+        return scale
+
+    if func_name in QUADRATIC_LIST:
+        return gs.power(scale, 2)
+
+    if func_name in INVERSE_LIST:
+        return 1.0 / scale
+
+    if func_name in INVERSE_QUADRATIC_LIST:
+        return 1.0 / gs.power(scale, 2)
+    return None
+
+
+class ScalarProductMetric:
+    """Class for scalar products of Riemannian and pseudo-Riemannian metrics.
+
+    This class multiplies the (0,2) metric tensor 'underlying_metric' by a scalar
+    'scaling_factor'. Note that this does not scale distances by 'scaling_factor'. That
+    would require multiplication by the square of the scalar.
+
+    An object of this type can also be instantiated by the expression
+    scaling_factor * underlying_metric.
+
+    This class acts as a wrapper for the underlying Riemannian metric. All public
+    attributes apart from 'underlying_metric' and 'scaling_factor' are loaded from the
+    underlying metric at initialization and rescaled by the appropriate factor. Changes
+    to the underlying metric at runtime will not affect the attributes of this object.
+
+    One exception to this is when the 'underlying_metric' is itself of type
+    ScalarProductMetric. In this case, rather than wrapping the wrapper, the
+    'underlying_metric' of the first ScalarProductMetric object is wrapped a second
+    time with a new 'scaling_factor'.
+
+    Parameters
+    ----------
+    underlying_metric : RiemannianMetric
+        The original metric of the manifold which is being scaled.
+    scaling_factor : float
+        The value by which to scale the metric. Note that this rescales the (0,2)
+        metric tensor, so distances are rescaled by the square root of this.
+    """
+
+    def __init__(self, underlying_metric, scaling_factor):
+        """Load all attributes from the underlying metric."""
+        if hasattr(underlying_metric, "underlying_metric"):
+            self.underlying_metric = underlying_metric.underlying_metric
+            self.scaling_factor = scaling_factor * underlying_metric.scaling_factor
+        else:
+            self.underlying_metric = underlying_metric
+            self.scaling_factor = scaling_factor
+
+        reserved_names = ("underlying_metric", "scaling_factor")
+        for attr_name in dir(self.underlying_metric):
+            if attr_name.startswith("_") or attr_name in reserved_names:
+                continue
+
+            attr = getattr(self.underlying_metric, attr_name)
+            if not callable(attr):
+                try:
+                    setattr(self, attr_name, attr)
+                except AttributeError as ex:
+                    if not isinstance(
+                        getattr(type(self.underlying_metric), attr_name, None),
+                        property,
+                    ):
+                        raise ex
+            else:
+                scaling_factor = _get_scaling_factor(attr_name, self.scaling_factor)
+                method = (
+                    attr if scaling_factor is None else _wrap_attr(scaling_factor, attr)
+                )
+                setattr(self, attr_name, method)


Test patch:
diff --git a/tests/tests_geomstats/test_scalar_product_metric.py b/tests/tests_geomstats/test_scalar_product_metric.py
new file mode 100644
index 000000000..757da653c
--- /dev/null
+++ b/tests/tests_geomstats/test_scalar_product_metric.py
@@ -0,0 +1,76 @@
+import geomstats.backend as gs
+from geomstats.geometry.euclidean import Euclidean
+from geomstats.geometry.scalar_product_metric import (
+    ScalarProductMetric,
+    _get_scaling_factor,
+    _wrap_attr,
+)
+from tests.conftest import TestCase
+
+
+class TestWrapper(TestCase):
+    def test_wrap_attr(self):
+        def example_fnc():
+            return 2.0
+
+        scaled_func = _wrap_attr(3.0, example_fnc)
+
+        res = example_fnc()
+        scaled_res = scaled_func()
+
+        self.assertAllClose(res, scaled_res / 3.0)
+
+    def test_get_scaling_factor(self):
+        scale = 2.0
+
+        func_name = "dist"
+        scaling_factor = _get_scaling_factor(func_name, scale)
+        self.assertAllClose(gs.sqrt(scale), scaling_factor)
+
+        func_name = "metric_matrix"
+        scaling_factor = _get_scaling_factor(func_name, scale)
+        self.assertAllClose(scale, scaling_factor)
+
+        func_name = "cometric_matrix"
+        scaling_factor = _get_scaling_factor(func_name, scale)
+        self.assertAllClose(1.0 / scale, scaling_factor)
+
+        func_name = "normalize"
+        scaling_factor = _get_scaling_factor(func_name, scale)
+        self.assertAllClose(1.0 / gs.power(scale, 2), scaling_factor)
+
+        func_name = "not_scaled"
+        scaling_factor = _get_scaling_factor(func_name, scale)
+        assert scaling_factor is None
+
+
+class TestInstantiation(TestCase):
+    def test_scalar_metric_multiplication(self):
+        space = Euclidean(dim=3)
+        scale = 2.0
+
+        scaled_metric_1 = scale * space.metric
+        scaled_metric_2 = space.metric * scale
+
+        point_a, point_b = space.random_point(2)
+        dist = space.metric.squared_dist(point_a, point_b)
+        dist_1 = scaled_metric_1.squared_dist(point_a, point_b)
+        dist_2 = scaled_metric_2.squared_dist(point_a, point_b)
+
+        self.assertAllClose(scale * dist, dist_1)
+        self.assertAllClose(scale * dist, dist_2)
+
+    def test_scaling_scalar_metric(self):
+        space = Euclidean(dim=3)
+        scale = 2.0
+
+        scaled_metric_1 = ScalarProductMetric(space.metric, scale)
+        scaled_metric_2 = ScalarProductMetric(scaled_metric_1, scale)
+
+        point_a, point_b = space.random_point(2)
+        dist = space.metric.squared_dist(point_a, point_b)
+        dist_1 = scaled_metric_1.squared_dist(point_a, point_b)
+        dist_2 = scaled_metric_2.squared_dist(point_a, point_b)
+
+        self.assertAllClose(scale * dist, dist_1)
+        self.assertAllClose(scale**2 * dist, dist_2)