Skip to content

Linearized OT ​

We have many point clouds, each drawn from a member of a family of shapes. The aims are to find the family back from the clouds alone, and then to say which member a new cloud comes from, in the Wasserstein sense.

Point clouds are not vectors. Draw twice from the same shape, and the i-th point of the first cloud has nothing to do with the i-th point of the second: a PCA of the coordinates of the points, or of their displacements, means nothing. What the clouds do share is a reference measure, here the Lebesgue measure ρ of the unit square. Each cloud μk=1n∑iδyik is the image of ρ by its optimal transport map Tk, and these maps all live in the same space, L2(ρ):

μk=(Tk)#ρ,∥Tk−Tl∥L2(ρ)≥W2(μk,μl).

This is linearized optimal transport (Wang et al., 2013). With a semi-discrete transport, Tk is constant on each power cell: it sends the cell of yik to yik. Close to a reference measure that is smooth enough, the distance between the maps is close to W2 (Mérigot, Delalande and Chazal, Quantitative stability of optimal transport maps and linearization of the 2-Wasserstein space, 2020). Every linear tool then works on the maps: the mean, the PCA, least squares.

The map of a cloud ​

sdot.plan gives the weights wi of the power diagram, and a point x of the square goes to the dirac of its cell, arg⁡mini|x−yi|2−wi. The map is read at 4096 points drawn uniformly in the square, once and for all: the same points for every cloud, so that the maps can be compared. That gives one vector of R4096×2 per cloud, and the mean over these points stands for the integral against ρ.

python
import numpy as np
import sdot

x = np.random.default_rng( 123 ).random( ( 4096, 2 ) )   # where every map is read

def transport_map( y ):
    """T( x ): the dirac of the power cell of each reading point"""
    w = np.asarray( sdot.plan( sdot.SumOfDiracs( y ), sdot.Box() ).weights )
    return y[ ( ( y * y ).sum( axis = 1 ) - w - 2 * x @ y.T ).argmin( axis = 1 ) ]

The argmin over all the diracs is the power cell of each reading point. For many diracs, the cell complex finds it with a tree: cells, _ = sdot.PowerDiagram( y, w, boundaries = ... ).cell_complex().locate( x ).

The family of the example is a ring, centered in the square. Its inner and outer boundaries each go from a circle (s=0) to a square (s=1), by interpolating the polar radius: two parameters, sin and sout in [0,1]. Below are the power cells of four draws (of 1000 diracs), at the four corners of the family. Use the slider at the top of the viewer to go from one draw to the next.

Open in a separate page ↗

The star in the middle is where the map tears. The square has no hole and the ring has one, so no continuous map goes from one to the other. The center of the square is split between all the diracs of the inner boundary. This tear decides most of what follows.

Learning the family ​

200 draws of 104 diracs, with (sin,sout) uniform in [0,1]2, give 200 maps. The parameters are not used to learn anything. The PCA of the maps:

python
maps = np.stack( [ transport_map( y ).ravel() for y in draws ] )   # [ 200, 4096 * 2 ]
mean = maps.mean( axis = 0 )
_, sv, modes = np.linalg.svd( maps - mean, full_matrices = False )
coords = ( maps - mean ) @ modes[ :4 ].T
mode123456
share of the variance0.6160.0810.0660.0580.0160.012

The first mode is the outer boundary, sout: it moves the most mass, the farthest. The inner boundary moves much less mass, and shares modes 2 to 4 with the tear, which the sampling noise moves from one draw to the next.

A point of the learned space is a measure: (T¯+∑kckvk)#ρ, the image of the reading points by the reconstructed map. Below, the first two coordinates, the others at 0. Far from the training draws, the reconstruction extrapolates and folds onto itself: it is no longer a transport map. The sliders therefore span the range of the training draws, shrunk towards the mean until the reconstruction folds nowhere. The dashed lines are the circles and squares the family moves between. c1 turns the outside from a circle into a square, while c2 mostly pushes the hole aside.

Open in a separate page ↗

Identifying a new draw ​

A new cloud is identified by its map. Its 4 coordinates c=VT(T−T¯) give the closest member of the learned space, (T¯+Vc)#ρ. The distance from T to its projection is an upper bound of W2 between the cloud and this member. To check the result against the truth, and only for that, an affine least squares fit of the training coordinates onto (sin,sout) reads the coordinates as parameters.

On 10 new draws, the mean error is 0.039 on sin and 0.007 on sout, and the distance to the learned space ranges from 0.0079 to 0.0104. Each frame shows the diracs of a draw (grey), its reconstruction (red), and its true boundaries:

Open in a separate page ↗

Outside of the family ​

A ring whose outer square is turned by 45° is not a member of the family. Its distance to the learned space, 0.057, is more than five times that of the test draws (at most 0.0104), while its unturned twin is at 0.0097. Reading its coordinates as parameters would give (0.68,−0.03), a meaningless answer that the distance flags. Use the slider to compare the twin and the turned square:

Open in a separate page ↗

The script ​

docs/examples/lot_dictionary.py computes everything on this page. It is NumPy only, with one sdot.plan per draw, and takes about three minutes on a laptop.

bash
python docs/examples/lot_dictionary.py           # every page and table
python docs/examples/lot_dictionary.py 1d cells  # the quick ones

SDOT — H. Leclerc, Q. Mérigot, T. Gallouët · LMO / INRIA PARMA