| 111 |
111 |
|
}
|
| 112 |
112 |
|
}
|
| 113 |
113 |
|
|
|
114 |
+ |
/// Midpoint of the normalized `[0, 1]` feature range, used to impute a missing
|
|
115 |
+ |
/// dimension so every vector maps to a complete point in the same space.
|
|
116 |
+ |
const FEATURE_IMPUTE: f64 = 0.5;
|
|
117 |
+ |
|
| 114 |
118 |
|
/// Compute weighted Euclidean distance between two feature vectors.
|
| 115 |
|
- |
/// Skips dimensions where either vector has `None`.
|
|
119 |
+ |
///
|
|
120 |
+ |
/// Missing dimensions (`None` on either side) are imputed to the neutral
|
|
121 |
+ |
/// normalized midpoint and the sum is divided by the constant total weight, so
|
|
122 |
+ |
/// the function is a true weighted-Euclidean metric over a fixed 12-dimensional
|
|
123 |
+ |
/// basis. This matters because the VP-tree index prunes using the triangle
|
|
124 |
+ |
/// inequality: the previous form divided by the *count of overlapping dims*, a
|
|
125 |
+ |
/// per-pair denominator that varied with which dimensions happened to be
|
|
126 |
+ |
/// present, broke the triangle inequality, and could prune a genuine nearest
|
|
127 |
+ |
/// neighbour. Analysis populates all DSP columns together, so imputation is
|
|
128 |
+ |
/// rare in practice; it exists to keep the metric well-defined when it isn't.
|
| 116 |
129 |
|
pub fn feature_distance(a: &FeatureVector, b: &FeatureVector, weights: &FeatureWeights) -> f64 {
|
| 117 |
|
- |
let mut sum = 0.0;
|
| 118 |
|
- |
let mut dims = 0.0;
|
| 119 |
|
- |
|
| 120 |
130 |
|
let pairs: [(Option<f64>, Option<f64>, f64); 12] = [
|
| 121 |
131 |
|
(a.bpm, b.bpm, weights.bpm),
|
| 122 |
132 |
|
(a.duration, b.duration, weights.duration),
|
| 132 |
142 |
|
(a.attack_time, b.attack_time, weights.attack_time),
|
| 133 |
143 |
|
];
|
| 134 |
144 |
|
|
|
145 |
+ |
let mut sum = 0.0;
|
|
146 |
+ |
let mut total_weight = 0.0;
|
| 135 |
147 |
|
for (va, vb, w) in &pairs {
|
| 136 |
|
- |
if let (Some(va), Some(vb)) = (va, vb) {
|
| 137 |
|
- |
let diff = va - vb;
|
| 138 |
|
- |
sum += w * diff * diff;
|
| 139 |
|
- |
dims += 1.0;
|
| 140 |
|
- |
}
|
|
148 |
+ |
let va = va.unwrap_or(FEATURE_IMPUTE);
|
|
149 |
+ |
let vb = vb.unwrap_or(FEATURE_IMPUTE);
|
|
150 |
+ |
let diff = va - vb;
|
|
151 |
+ |
sum += w * diff * diff;
|
|
152 |
+ |
total_weight += w;
|
| 141 |
153 |
|
}
|
| 142 |
154 |
|
|
| 143 |
|
- |
// Return a large finite distance when no dimensions overlap, preserving
|
| 144 |
|
- |
// the VP-tree triangle inequality contract (INFINITY violates it).
|
| 145 |
|
- |
if dims == 0.0 { 1e10 } else { (sum / dims).sqrt() }
|
|
155 |
+ |
// Constant scale factor (sum of all weights) keeps this a metric. Only zero
|
|
156 |
+ |
// when every weight is zero, in which case all distances are zero.
|
|
157 |
+ |
if total_weight == 0.0 { 0.0 } else { (sum / total_weight).sqrt() }
|
| 146 |
158 |
|
}
|
| 147 |
159 |
|
|
| 148 |
160 |
|
/// Load the feature vector for a sample by hash.
|
| 551 |
563 |
|
}
|
| 552 |
564 |
|
|
| 553 |
565 |
|
#[test]
|
|
566 |
+ |
fn distance_satisfies_triangle_inequality_with_missing_dims() {
|
|
567 |
+ |
// The VP-tree index prunes on the triangle inequality, so the distance
|
|
568 |
+ |
// must satisfy d(a,c) <= d(a,b) + d(b,c) even when vectors have
|
|
569 |
+ |
// different sets of missing dimensions (the case the old per-pair
|
|
570 |
+ |
// denominator broke).
|
|
571 |
+ |
let w = FeatureWeights::default();
|
|
572 |
+ |
let a = FeatureVector { bpm: Some(0.1), duration: Some(0.9), ..Default::default() };
|
|
573 |
+ |
let b = FeatureVector { bpm: Some(0.8), lufs: Some(0.2), ..Default::default() };
|
|
574 |
+ |
let c = FeatureVector { duration: Some(0.1), spectral_centroid: Some(0.7), ..Default::default() };
|
|
575 |
+ |
|
|
576 |
+ |
let ab = feature_distance(&a, &b, &w);
|
|
577 |
+ |
let bc = feature_distance(&b, &c, &w);
|
|
578 |
+ |
let ac = feature_distance(&a, &c, &w);
|
|
579 |
+ |
assert!(
|
|
580 |
+ |
ac <= ab + bc + 1e-9,
|
|
581 |
+ |
"triangle inequality violated: d(a,c)={ac} > d(a,b)+d(b,c)={}",
|
|
582 |
+ |
ab + bc
|
|
583 |
+ |
);
|
|
584 |
+ |
}
|
|
585 |
+ |
|
|
586 |
+ |
#[test]
|
| 554 |
587 |
|
fn ranking_correctness() {
|
| 555 |
588 |
|
let db = Database::open_in_memory().unwrap();
|
| 556 |
589 |
|
insert_with_features(&db, "ref", 120.0, 1.0);
|