Skip to content

Commit 60382a3

Browse files
authored
Add Wasserstein distance (aka Earth Mover's distance) to linfa-nn (#440)
* feat(linfa-nn): Added Earth Mover's Distance * Changed EarthMoverDist as WassersteinDist and use tests from SciPy * Added SciPy version that was used for comparison to Wasserstein distance documentation
1 parent ad45826 commit 60382a3

1 file changed

Lines changed: 121 additions & 0 deletions

File tree

algorithms/linfa-nn/src/distance.rs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,32 @@ impl<F: Float> Distance<F> for LpDist<F> {
118118
}
119119
}
120120

121+
/// [Wasserstein](https://en.wikipedia.org/wiki/Wasserstein_metric) or
122+
/// [Earth Mover's](https://en.wikipedia.org/wiki/Earth_mover%27s_distance) distance.
123+
///
124+
/// The function accepts histograms where each array element is the probability mass at that index.
125+
/// This differs from SciPy's (v1.17.0) `wasserstein_distance` which instead accepts support values and weights,
126+
/// then builds the histograms internally.
127+
#[cfg_attr(
128+
feature = "serde",
129+
derive(Serialize, Deserialize),
130+
serde(crate = "serde_crate")
131+
)]
132+
#[derive(Debug, Clone, PartialEq, Eq)]
133+
pub struct WassersteinDist;
134+
impl<F: Float> Distance<F> for WassersteinDist {
135+
#[inline]
136+
fn distance<D: Dimension>(&self, a: ArrayView<F, D>, b: ArrayView<F, D>) -> F {
137+
let mut cumulative_diff = F::zero();
138+
let mut dist = F::zero();
139+
Zip::from(&a).and(&b).for_each(|&a, &b| {
140+
cumulative_diff += a - b;
141+
dist += cumulative_diff.abs()
142+
});
143+
dist
144+
}
145+
}
146+
121147
/// Computes a similarity matrix with gaussian kernel and scaling parameter `eps`
122148
///
123149
/// The generated matrix is a upper triangular matrix with dimension NxN (number of observations) and contains the similarity between all permutations of observations
@@ -157,6 +183,7 @@ mod test {
157183
has_autotraits::<L2Dist>();
158184
has_autotraits::<LInfDist>();
159185
has_autotraits::<LpDist<f64>>();
186+
has_autotraits::<WassersteinDist>();
160187
}
161188

162189
fn dist_test<D: Distance<f64>>(dist: D, result: f64) {
@@ -204,4 +231,98 @@ mod test {
204231
fn lp_dist() {
205232
dist_test(LpDist(3.3), 4.635);
206233
}
234+
235+
#[test]
236+
fn wasserstein_dist() {
237+
dist_test(WassersteinDist, 4.2);
238+
}
239+
240+
// The following Wasserstein tests are from SciPy (v1.17.0).
241+
// However, since SciPy Wasserstein distance has different API as ours,
242+
// we need to first transform the SciPy parameters into histograms that our API accepts.
243+
//
244+
// For example, SciPy values `[1, 3]` with weights `[1, 9]`:
245+
// At index 1 we have weight 1 out of total weight 10 => 0.1.
246+
// At index 3 we have weight 9 out of total weight 10 => 0.9.
247+
// Thus we get a histogram [0.0, 0.1, 0.0, 0.9]
248+
249+
#[test]
250+
/// For basic distributions, the value of the Wasserstein distance is straightforward.
251+
fn wasserstein_simple() {
252+
let dist = WassersteinDist;
253+
254+
// SciPy: u_values=[0, 1], v_values=[0], u_weights=[1, 1], v_weights=[1]
255+
let u = arr1(&[0.5, 0.5]);
256+
let v = arr1(&[1.0, 0.0]);
257+
assert_abs_diff_eq!(dist.distance(u.view(), v.view()), 0.5);
258+
259+
// SciPy: u_values=[0, 1], v_values=[0], u_weights=[3, 1], v_weights=[1]
260+
let u = arr1(&[0.75, 0.25]);
261+
let v = arr1(&[1.0, 0.0]);
262+
assert_abs_diff_eq!(dist.distance(u.view(), v.view()), 0.25);
263+
264+
// SciPy: u_values=[0, 2], v_values=[0], u_weights=[1, 1], v_weights=[1]
265+
let u = arr1(&[0.5, 0.0, 0.5]);
266+
let v = arr1(&[1.0, 0.0, 0.0]);
267+
assert_abs_diff_eq!(dist.distance(u.view(), v.view()), 1.0);
268+
269+
// SciPy: u_values=[0, 1, 2], v_values=[1, 2, 3]
270+
let u = arr1(&[1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0, 0.0]);
271+
let v = arr1(&[0.0, 1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0]);
272+
assert_abs_diff_eq!(dist.distance(u.view(), v.view()), 1.0);
273+
}
274+
275+
#[test]
276+
/// Any distribution moved to itself should have a Wasserstein distance of zero.
277+
fn wasserstein_same_distribution() {
278+
let dist = WassersteinDist;
279+
280+
// SciPy: u_values=[1, 2, 3], v_values=[2, 1, 3]
281+
let u = arr1(&[0.0, 1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0]);
282+
let v = arr1(&[0.0, 1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0]);
283+
assert_abs_diff_eq!(dist.distance(u.view(), v.view()), 0.0);
284+
285+
// SciPy: u_values=[1, 1, 1, 4], v_values=[4, 1], u_weights=[1, 1, 1, 1], v_weights=[1, 3]
286+
let u = arr1(&[0.0, 0.75, 0.0, 0.0, 0.25]);
287+
let v = arr1(&[0.0, 0.75, 0.0, 0.0, 0.25]);
288+
assert_abs_diff_eq!(dist.distance(u.view(), v.view()), 0.0);
289+
}
290+
291+
#[test]
292+
/// If the whole distribution is shifted by x, then the Wasserstein distance should be the norm of x.
293+
fn wasserstein_shift() {
294+
let dist = WassersteinDist;
295+
296+
// SciPy: u_values=[0], v_values=[1]
297+
let u = arr1(&[1.0, 0.0]);
298+
let v = arr1(&[0.0, 1.0]);
299+
assert_abs_diff_eq!(dist.distance(u.view(), v.view()), 1.0);
300+
301+
// SciPy: u_values=[-5], v_values=[5]
302+
let u = arr1(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
303+
let v = arr1(&[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]);
304+
assert_abs_diff_eq!(dist.distance(u.view(), v.view()), 10.0);
305+
306+
// SciPy: u_values=[1, 2, 3, 4, 5], v_values=[11, 12, 13, 14, 15]
307+
let u = arr1(&[
308+
0.2, 0.2, 0.2, 0.2, 0.2, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
309+
]);
310+
let v = arr1(&[
311+
0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.2, 0.2, 0.2, 0.2, 0.2,
312+
]);
313+
assert_abs_diff_eq!(dist.distance(u.view(), v.view()), 10.0);
314+
}
315+
316+
#[test]
317+
fn wasserstein_inf_values() {
318+
let dist = WassersteinDist;
319+
320+
let u = arr1(&[1.0, f64::INFINITY]);
321+
let v = arr1(&[1.0, 0.0]);
322+
assert!(dist.distance(u.view(), v.view()).is_infinite());
323+
324+
let u = arr1(&[1.0, f64::INFINITY]);
325+
let v = arr1(&[1.0, f64::INFINITY]);
326+
assert!(dist.distance(u.view(), v.view()).is_nan());
327+
}
207328
}

0 commit comments

Comments
 (0)