IVFSparse: documented `nfeatures` setting has no effect (feature selection is overwritten)
Describe the bug
The nfeatures option of the ivfsparse sparse ANN backend is documented as:
ivfsparse:
sample: percent of data to use for model training (0.0 - 1.0)
nfeatures: top n features to use for model training (int)
nlist: desired number of clusters (int)
nprobe: search probe setting (int)
minpoints: minimum number of points for a cluster (int)(see docs/embeddings/configuration/scoring.md)
It has no effect. In src/python/txtai/ann/sparse/ivfsparse.py, build() computes the
feature-selected matrix and then immediately overwrites it with the full matrix:
# Select top n most important features that contribute to L2 vector norm
indices = np.argsort(-norm(train, axis=0))[: self.setting("nfeatures", 25)]
data = train[:, indices]
data = train # <-- overwrites the feature selectionk-means is therefore always trained on all dimensions, regardless of nfeatures.
To Reproduce
Spy on MiniBatchKMeans.fit and record the width of the matrix that build() passes in.
nfeatures=2 with 8 dimensions should produce a (rows, 2) matrix.
import numpy as np
from scipy.sparse import csr_matrix
import sklearn.cluster as skc
from txtai.ann.sparse.ivfsparse import IVFSparse
seen = []
_orig = skc.MiniBatchKMeans.fit
def spy(self, X, *a, **k):
seen.append(tuple(X.shape))
return _orig(self, X, *a, **k)
skc.MiniBatchKMeans.fit = spy
rng = np.random.default_rng(7)
for nfeatures in (2, 4, 8):
seen.clear()
ann = IVFSparse({
"backend": "ivfsparse",
"dimensions": 8,
"ivfsparse": {"nfeatures": nfeatures, "nlist": 4, "minpoints": 1},
})
ann.build(csr_matrix(rng.random((300, 8))), 4)
print("nfeatures =", nfeatures, "-> fit received", seen[0])Output on master (9.14.0):
nfeatures = 2 -> fit received (300, 8)
nfeatures = 4 -> fit received (300, 8)
nfeatures = 8 -> fit received (300, 8)Expected (300, 2), (300, 4), (300, 8).
Expected behavior
MiniBatchKMeans.fit receives a matrix with nfeatures columns.
Removing the data = train line is enough — the selection itself is valid:
nfeatures = 2 -> fit received (300, 2)
nfeatures = 4 -> fit received (300, 4)
nfeatures = 8 -> fit received (300, 8)The rest of the IVFSparse lifecycle (index / append / delete / save-load) still behaves correctly with that line removed.
Fix
# Select top n most important features that contribute to L2 vector norm
indices = np.argsort(-norm(train, axis=0))[: self.setting("nfeatures", 25)]
data = train[:, indices]
- data = train
# Cluster data using k-means
kmeans = MiniBatchKMeans(n_clusters=clusters, random_state=0, n_init=5).fit(data)Happy to open a PR with this one-line change plus a regression test if that is the
preferred direction. If feature selection was disabled on purpose, the alternative would be
to drop the dead assignment and remove nfeatures from the docs so the configuration is not
misleading.
Secondary observation (related, same file)
IVFSparse.delete() guards only the upper bound, so a negative index is accepted:
size = self.size()
for x in ids:
if x < size and x not in self.deletes: # missing 0 <=
self.deletes.append(int(x))delete([-1]) appends -1 to self.deletes, but no indexed id is ever negative, so
count() (= size() - len(self.deletes)) is permanently reduced by one:
count 1249 -> 1248 after delete([-1]) # expected unchangedint(x) also truncates floats (delete([2.5]) deletes id 2). The same missing lower bound
appears in ann/dense/numpy.py:64 (x < self.backend.shape[0]) and
ann/dense/ggml.py:225 (x < rows and x not in self.deletes). Not reachable from
Embeddings.delete(), which passes non-negative index ids — only from direct ANN usage.
Worth tightening to 0 <= x < size while in the area.
Environment
- txtai 9.14.0 (master,
19ff237c) - Python 3.13.14
- numpy 2.5.3, scipy 1.18.1, scikit-learn, msgpack 1.2.2
- No GPU / model download required for this repro
Source: neuml/txtai