Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
274 changes: 256 additions & 18 deletions rust/lance/src/index/vector/ivf.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The Lance Authors

Expand Down Expand Up @@ -4993,6 +4993,85 @@
))
}

/// Splits `cluster` in two by L2 around the row farthest from its centroid and
/// the row farthest from that one, or returns `None` when all its rows are
/// identical. Each seed row is nearest to itself, so both halves are non-empty
/// whenever the rows differ.
fn bisect_weighted_cluster<T: StreamingKMeansArrowType>(
cluster: &WeightedCluster<T>,
data_values: &[T::Native],
weights: &[f64],
losses: &[f64],
dimension: usize,
metric_policy: &dyn StreamingFloatKMeansMetricPolicy<T>,
next_cluster_id: &mut usize,
) -> Result<Option<[WeightedCluster<T>; 2]>> {
let row = |idx: usize| &data_values[idx * dimension..(idx + 1) * dimension];
let farthest_from = |anchor: &[T::Native]| {
cluster
.indices
.iter()
.copied()
.max_by(|&a, &b| squared_l2(row(a), anchor).total_cmp(&squared_l2(row(b), anchor)))
};
let Some(first) = farthest_from(&cluster.centroid) else {
return Ok(None);
};
let Some(second) = farthest_from(row(first)) else {
return Ok(None);
};
if squared_l2(row(first), row(second)) == 0.0 {
return Ok(None);
}
let (near_first, near_second): (Vec<usize>, Vec<usize>) = cluster
.indices
.iter()
.partition(|&&idx| squared_l2(row(idx), row(first)) <= squared_l2(row(idx), row(second)));
let mut make_half = |indices: Vec<usize>| -> Result<WeightedCluster<T>> {
let weight = indices.iter().map(|&idx| weights[idx]).sum::<f64>();
let mut centroid = vec![T::Native::default(); dimension];
for &idx in &indices {
let row_weight = cast_weight::<T>(weights[idx], format_args!("bisected row {idx}"))?;
for (sum, value) in centroid.iter_mut().zip(row(idx)) {
*sum += *value * row_weight;
}
}
if weight > 0.0 {
let weight = cast_weight::<T>(weight, "bisected cluster")?;
centroid
.iter_mut()
.for_each(|value| *value = *value / weight);
} else {
centroid.copy_from_slice(row(indices[0]));
}
let loss = indices
.iter()
.map(|&idx| losses[idx] + weights[idx] * squared_l2(row(idx), &centroid))
.sum();
let split_priority = metric_policy.split_priority(
data_values,
weights,
&indices,
&centroid,
dimension,
loss,
);
let id = *next_cluster_id;
*next_cluster_id += 1;
Ok(WeightedCluster {
id,
indices,
centroid,
weight,
split_priority,
finalized: true,
})
};
let first = make_half(near_first)?;
let second = make_half(near_second)?;
Ok(Some([first, second]))
}

fn train_weighted_hierarchical_kmeans<T: StreamingKMeansArrowType>(
data: &FixedSizeListArray,
weights: &[f64],
Expand Down Expand Up @@ -5139,25 +5218,70 @@
}
}

// The split loop can stop with multi-row clusters left: a k-means split can
// leave every row on one side even when the rows differ, which happens
// under Dot, and a singleton at the top of the heap ends the loop. Bisect
// the remaining multi-row clusters by L2 until `target_k` is reached, so
// the count falls short only when the coreset holds fewer distinct rows.
let mut clusters = heap.into_vec();
let mut unsplittable = Vec::new();
while clusters.len() + unsplittable.len() < target_k {
let Some(position) = (0..clusters.len())
.filter(|&position| clusters[position].indices.len() > 1)
.max_by(|&a, &b| clusters[a].cmp(&clusters[b]))
else {
break;
};
let cluster = clusters.swap_remove(position);
match bisect_weighted_cluster(
&cluster,
data_values,
weights,
losses,
dimension,
metric_policy,
&mut next_cluster_id,
)? {
Some(halves) => clusters.extend(halves),
None => unsplittable.push(cluster),
}
}
clusters.append(&mut unsplittable);
clusters.sort_by_key(|cluster| cluster.id);
while clusters.len() < target_k {
let duplicate = clusters
.iter()
.max_by(|left, right| {
left.weight
.partial_cmp(&right.weight)
.unwrap_or(std::cmp::Ordering::Equal)
})
.cloned()
.ok_or_else(|| Error::index("No weighted clusters were trained"))?;
clusters.push(WeightedCluster {
id: next_cluster_id,
..duplicate
});
next_cluster_id += 1;
if clusters.len() < target_k {
// Padding the count with clones of the heaviest cluster left partitions
// whose centroid ties exactly with the original, which assignment keeps,
// so they started empty. Reject instead, like the flat hierarchical
// trainer: after the bisection above, the coreset has fewer distinct
// rows than `target_k`.
if data.len() < target_k {
// Not the data's fault: the coreset summarizing the training sample
// is smaller than the partition count, as Dot's can be when its only
// chunk samples exactly `num_partitions` rows.
return Err(Error::invalid_input(format!(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exhausted_splits regression inherited from merged #10270 still unwraps success for 33 partitions from 32 coreset rows, so this intentional rejection now makes the weighted-hierarchical suite fail. Please give that case an Error::InvalidInput assertion with the coreset-size advice, while retaining the 17- and 32-partition success/distinct-centroid checks. The rejection is correct; this is a non-blocking test-alignment issue, not a request to restore padding.

Reproducer

With the repository's Rust 1.98.1 toolchain, run:

cargo test -p lance --lib test_weighted_hierarchical_splits_until_target_k::case_3_exhausted_splits -- --nocapture

On this head it fails at the test's unwrap() (ivf.rs:8536), returning InvalidInput: Cannot create 33 IVF partitions from 32 weighted coreset rows: the coreset summarizing the training sample is smaller than the partition count. Increase sample_rate or reduce num_partitions. The exhausted case should assert that error instead of successful padded output.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3c70e99: the exhausted-split case now expects the coreset-size error rather than padded success, while the 17- and 32-partition success/distinct-centroid checks remain. All eight weighted-hierarchical cases pass.

"Cannot create {target_k} IVF partitions from {} weighted coreset rows: the \
coreset summarizing the training sample is smaller than the partition \
count. Increase sample_rate or reduce num_partitions.",
data.len()
)));
}
return Err(Error::invalid_input(format!(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This return can reject an attainable partition count. The loop stops as soon as the highest-loss heap item has one member, but inherited base_losses contribute to that ordering; a high-loss singleton can therefore stop training while other non-finalized clusters still contain distinct rows that can be split. At the production routing threshold this turns a valid 257-partition request into an error advising the user to reduce it to 16. Please continue past newly finalized singletons and return only once the heap has no splittable cluster left (the flat trainer's fill_missing_leaves follows that contract).

Reproducer

Add this test beside the new regression test:

#[test]
fn probe_weighted_hierarchical_production_threshold() {
    let dimension = 1;
    let num_points = 258;
    let target_k = 257;
    let mut values = Vec::with_capacity(num_points);
    values.push(1.0e9);
    values.extend((1..num_points).map(|i| i as f32 * 100.0));
    let data =
        FixedSizeListArray::try_new_from_values(Float32Array::from(values), dimension).unwrap();
    let params = WeightedHierarchicalKMeansParams {
        dimension: dimension as usize,
        target_k,
        metric_type: MetricType::L2,
        max_iters: 20,
        on_progress: Arc::new(|_, _| {}),
    };
    let mut losses = vec![0.0; num_points];
    losses[0] = 1.0e20;

    let result = train_weighted_hierarchical_f32_kmeans(
        &data,
        &vec![1.0; num_points],
        &losses,
        &params,
    );
    assert!(result.is_ok(), "unexpected shortfall: {result:?}");
}

Running cargo test -p lance probe_weighted_hierarchical_production_threshold -- --nocapture on this head fails with Cannot create 257 IVF partitions: ... could only form 16 non-empty clusters ... Reduce num_partitions to <= 16, even though all 258 coreset rows are distinct.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6488b60: a singleton is now finalized and requeued so the remaining non-finalized clusters can still split. The 257-of-258 production-threshold reproducer now passes.

"Cannot create {target_k} IVF partitions: weighted k-means could only form {} \
non-empty clusters from {} weighted coreset rows, and the rest could not be \
split further. Reduce num_partitions to <= {} or provide more diverse data.",
clusters.len(),
data.len(),
clusters.len()
)));
}
clusters.truncate(target_k);
// The split loop stops at `target_k`, and every `cluster_k` it takes is
// bounded by what is left, so the count lands exactly on `target_k`.
debug_assert_eq!(
clusters.len(),
target_k,
"weighted kmeans formed {} clusters for target_k {target_k}",
clusters.len()
);

let mut values = Vec::with_capacity(target_k * dimension);
for cluster in clusters {
Expand Down Expand Up @@ -7377,6 +7501,105 @@
);
}

/// Padding the partition count with clones of the heaviest cluster left
/// partitions that started empty; reject like the flat hierarchical
/// trainer. A coreset smaller than the partition count and a coreset whose
/// rows are identical both reach the shortfall, and each gets its own
/// advice.
#[rstest::rstest]
#[case::fewer_rows_than_partitions(6, 16, "Increase sample_rate")]
#[case::enough_rows_all_identical(32, 16, "provide more diverse data")]
#[test]
fn test_weighted_hierarchical_rejects_padding_with_duplicates(
#[case] num_points: usize,
#[case] target_k: usize,
#[case] advice: &str,
) {
let dimension = 8;
// Every row identical in the second case, a ramp in the first: either
// way the trainer cannot reach `target_k` non-empty clusters.
let values: Vec<f32> = if num_points < target_k {
(0..num_points * dimension)
.map(|i| i as f32 * 0.01)
.collect()
} else {
vec![0.5; num_points * dimension]
};
let data = streaming_kmeans_fsl_from_values::<Float32Type>(values, dimension).unwrap();
let params = WeightedHierarchicalKMeansParams {
dimension,
target_k,
metric_policy: &L2_METRIC_POLICY,
max_iters: 2,
on_progress: Arc::new(|_, _| {}),
};
let err = train_weighted_hierarchical_kmeans::<Float32Type>(
&data,
&vec![1.0; num_points],
&vec![0.1; num_points],
&params,
)
.unwrap_err();
assert!(matches!(err, Error::InvalidInput { .. }), "got: {err:?}");
let msg = err.to_string();
assert!(
msg.contains(&format!("Cannot create {target_k} IVF partitions")),
"got: {msg}"
);
// The row count belongs in the message: it is the coreset size, not
// the dataset's, so a reader can tell the two shortfalls apart.
assert!(
msg.contains(&format!("{num_points} weighted coreset rows")),
"got: {msg}"
);
assert!(msg.contains(advice), "got: {msg}");
}

/// Under Dot, positive 1-D rows all go to the largest centroid, so every
/// k-means split leaves its rows on one side. The rows are distinct, so the
/// trainer must still reach `target_k` distinct centroids rather than
/// reject the request, for f32 and f64 centroids alike.
fn bisects_clusters_kmeans_cannot_split<T: StreamingKMeansArrowType>() {
let num_points = 8;
let target_k = 4;
let values = (1..=num_points)
.map(|i| T::Native::from_f64(i as f64).unwrap())
.collect::<Vec<_>>();
let data = streaming_kmeans_fsl_from_values::<T>(values, 1).unwrap();
let params = WeightedHierarchicalKMeansParams {
dimension: 1,
target_k,
metric_policy: &DOT_METRIC_POLICY,
max_iters: 5,
on_progress: Arc::new(|_, _| {}),
};

let centroids = train_weighted_hierarchical_kmeans::<T>(
&data,
&vec![1.0; num_points],
&vec![0.0; num_points],
&params,
)
.unwrap();
let mut values = centroids
.values()
.as_primitive::<T>()
.values()
.iter()
.map(|value| value.as_())
.collect::<Vec<f64>>();
values.sort_by(f64::total_cmp);
values.dedup();
assert_eq!(values.len(), target_k, "centroids: {values:?}");
}

#[rstest::rstest]
#[case::f32(bisects_clusters_kmeans_cannot_split::<Float32Type> as fn())]
#[case::f64(bisects_clusters_kmeans_cannot_split::<Float64Type> as fn())]
fn test_weighted_hierarchical_bisects_clusters_kmeans_cannot_split(#[case] check: fn()) {
check();
}

#[tokio::test]
async fn test_build_ivf_model_streaming_dot_coreset_training() {
const DIMENSION: usize = 4;
Expand Down Expand Up @@ -8309,8 +8532,23 @@
&vec![1.0; 32],
&losses,
&params,
)
.unwrap();
);
if target_k > data.len() {
let Err(error) = result else {
panic!(
"a coreset of {} rows cannot serve {target_k} partitions",
data.len()
);
};
assert!(
error
.to_string()
.contains("smaller than the partition count"),
"unexpected error: {error}"
);
return;
}
let result = result.unwrap();
assert_eq!(result.len(), target_k);
let values = result.values().as_primitive::<Float32Type>().values();
let mut sorted = values.to_vec();
Expand Down
Loading