Introduction to Manifold Diffusion Geometry¶
This notebook follows the computation in Manifold Diffusion Geometry: Curvature, Tangent Spaces, and Dimension. The aim is to take a point cloud and compute familiar manifold geometry: tangent spaces, dimension, and scalar curvature.
We assume the data behave like points sampled from a low-dimensional manifold embedded in Euclidean space. We use diffusion geometry to estimate the intrinsic geometry from the heat flow on the data. The heat flow gives a Laplacian, and the Laplacian gives the carré du champ
$$ \Gamma(f,h)=g(\nabla f,\nabla h). $$
Once $\Gamma$ is known, Riemannian geometry becomes linear algebra.
The plots in this notebook are interactive
from pathlib import Path
import site
import sys
import numpy as np
from opt_einsum import contract
import plotly.io as pio
# Keep imports on this repo/conda env when running the notebook directly.
sys.path = [path for path in sys.path if not path.startswith(site.getusersitepackages())]
if Path.cwd().name == "intro_notebooks":
sys.path.insert(0, "..")
from diffusion_geometry import DiffusionGeometry
from diffusion_geometry.visualisation import plot_scatter_3d, plot_2form_3d
pio.renderers.default = "notebook_connected"
def torus(minor_radius=1.0, major_radius=2.0, n=1000, noise=0.0, seed=0):
rng = np.random.default_rng(seed)
theta = rng.uniform(0, 2*np.pi, n)
phi = rng.uniform(0, 2*np.pi, n)
data = np.column_stack(((major_radius + minor_radius*np.cos(phi))*np.cos(theta),
(major_radius + minor_radius*np.cos(phi))*np.sin(theta),
minor_radius*np.sin(phi)))
data += noise*rng.standard_normal(data.shape)
return data, phi
def torus_scalar_curvature(phi, minor_radius=1.0, major_radius=2.0):
return 2*np.cos(phi)/(minor_radius*(major_radius + minor_radius*np.cos(phi)))
def two_forms_from_normals(normals):
"""Ambient 2-form matrices whose Hodge duals are the given normal vectors."""
forms = np.zeros((len(normals), 3, 3))
# In 3D, plot_2form_3d draws disks orthogonal to the Hodge-dual vector.
forms[:, 1, 2], forms[:, 2, 1] = -normals[:, 0], normals[:, 0]
forms[:, 2, 0], forms[:, 0, 2] = -normals[:, 1], normals[:, 1]
forms[:, 0, 1], forms[:, 1, 0] = -normals[:, 2], normals[:, 2]
return forms
camera = dict(eye=dict(x=0.9, y=-1.15, z=0.85),
center=dict(x=0, y=0, z=-0.05),
up=dict(x=0, y=0, z=1))
torus_camera = dict(eye=dict(x=1.6, y=-1.7, z=1.1),
center=dict(x=0, y=0, z=0),
up=dict(x=0, y=0, z=1))
Sample some data¶
We start with a Swiss roll: a two-dimensional manifold embedded in three dimensions.
rng = np.random.default_rng(1)
n_samples = 1200
swiss_roll_parameter = 1.5*np.pi*(1 + 2*rng.random(n_samples))
height = 21*rng.random(n_samples)
data = np.column_stack((swiss_roll_parameter*np.cos(swiss_roll_parameter),
height,
swiss_roll_parameter*np.sin(swiss_roll_parameter)))
data = (data - data.mean(axis=0))/data.std(axis=0).max()
plot_scatter_3d(data, color=swiss_roll_parameter - swiss_roll_parameter.mean(), size=2, camera=camera).show()
First fundamental form¶
The gradients of the ambient coordinate functions span the tangent space. We use these to form the pointwise Gram matrix
$$ \Gamma_p(x_i,x_j). $$
For a clean $d$-dimensional manifold embedded isometrically, this matrix has $d$ eigenvalues equal to $1$ and the remaining eigenvalues equal to $0$. In data, the same pattern appears approximately. The large eigenvectors give a tangent frame and the small eigenvectors give a normal frame.
Diffusion maps recover the Laplacian only up to a constant scale. We correct that scale using the observation that the top intrinsic eigenvalues should average to one.
dg = DiffusionGeometry.from_point_cloud(data, n_function_basis=100)
# First fundamental form in ambient coordinates: gamma[p, i, j] = Gamma_p(x_i, x_j).
gamma = dg.cache.gamma_coords
eigenvalues, ambient_frame = np.linalg.eigh(gamma)
eigenvalues = eigenvalues[:, ::-1]
ambient_frame = ambient_frame[:, :, ::-1]
# The leading tangent eigenvalues should average to one.
metric_scale = np.median(eigenvalues[:, :2].mean(axis=1))
eigenvalues = eigenvalues/metric_scale
# The dimension is the position of the largest eigenvalue drop.
eigenvalue_gaps = -np.diff(eigenvalues, prepend=1.0, append=0.0, axis=1)
pointwise_dimension = dg._regularise(np.argmax(eigenvalue_gaps, axis=1).astype(float))
intrinsic_dimension = int(np.round(np.median(pointwise_dimension)))
# Split the ambient eigenframe into tangent and normal directions.
tangent_frame = ambient_frame[:, :, :intrinsic_dimension]
normal_frame = ambient_frame[:, :, intrinsic_dimension:]
print("global dimension:", intrinsic_dimension)
print("gamma shape:", gamma.shape)
print("median eigenvalues:", np.median(eigenvalues, axis=0))
plot_scatter_3d(data, color=pointwise_dimension.round(0) - 1, size=5, amax=2, camera=camera).show()
global dimension: 2 gamma shape: (1200, 3, 3) median eigenvalues: [1.23463209 0.76275985 0.00861364]
Tangent planes¶
A tangent plane in three dimensions can be shown as an oriented 2-form: the Hodge dual of that 2-form is the normal vector. Here the normal frame from the previous cell becomes a small disk at each point, so the picture shows the estimated tangent bundle directly.
# The tangent plane is represented by its normal vector, converted to an ambient 2-form.
fig = plot_2form_3d(data,
two_forms_from_normals(normal_frame[:, :, 0]),
radius=0.1,
n_circle=18,
magnitude_scaling=False)
fig.show()
Second fundamental form and curvature¶
We can write the Hessian in terms of the iterated carré du champ:
$$ H(f)(\nabla h_1,\nabla h_2)=\frac12\big(\Gamma(h_1,\Gamma(h_2,f)) + \Gamma(h_2,\Gamma(h_1,f)) - \Gamma(f,\Gamma(h_1,h_2))\big). $$
The coordinate Hessians are already cached as dg.cache.hessian_coords. Projecting them onto the normal and tangent frames gives the second fundamental form
$$ \alpha^\ell_{ij}=H(n^\ell)(e_i,e_j). $$
The Gauss equation then gives the Riemann curvature tensor, and tracing gives Ricci and scalar curvature.
data_torus, phi = torus(n=1200, seed=0)
true_scalar = torus_scalar_curvature(phi)
dg = DiffusionGeometry.from_point_cloud(data_torus)
# Tangent and normal frames from the first fundamental form.
gamma = dg.cache.gamma_coords
eigenvalues, ambient_frame = np.linalg.eigh(gamma)
eigenvalues = eigenvalues[:, ::-1]
ambient_frame = ambient_frame[:, :, ::-1]
metric_scale = np.median(eigenvalues[:, :2].mean(axis=1))
tangent_frame = ambient_frame[:, :, :2]
normal_frame = ambient_frame[:, :, 2:]
# hessian[p, l, i, j] = Hess(x_l)(grad x_i, grad x_j), after metric-scale correction.
hessian = dg.cache.hessian_coords.transpose(0, 3, 1, 2)/metric_scale**2
# Project coordinate Hessians onto one normal direction and two tangent directions:
# alpha[p, normal_l, tangent_i, tangent_j] is the second fundamental form.
second_fundamental_form = contract("pstu,psl,pti,puj->plij",
hessian,
normal_frame,
tangent_frame,
tangent_frame)
# Gauss equation: R_ijkl = <alpha_ik, alpha_jl> - <alpha_jk, alpha_il>.
riemann = contract("pLik,pLjl->pijkl", second_fundamental_form, second_fundamental_form)
riemann -= contract("pLjk,pLil->pijkl", second_fundamental_form, second_fundamental_form)
# Trace the Riemann tensor to get Ricci, then trace Ricci to get scalar curvature.
ricci = contract("pkikj->pij", riemann)
scalar = dg._regularise(contract("pii->p", ricci))
print("riemann tensor shape:", riemann.shape)
print("ricci tensor shape:", ricci.shape)
print("scalar curvature shape:", scalar.shape)
print("\nTrue scalar curvature:")
plot_scatter_3d(data_torus, color=true_scalar, size=2, camera=torus_camera).show()
print("Estimated scalar curvature:")
plot_scatter_3d(data_torus, color=scalar, size=2, camera=torus_camera).show()
riemann tensor shape: (1200, 2, 2, 2, 2) ricci tensor shape: (1200, 2, 2) scalar curvature shape: (1200,) True scalar curvature:
Estimated scalar curvature: