Skip to main content

Distances, nearest-neighbour search and clustering for mixed numeric/categorical tabular data.

Project description

mixdist

Distances, nearest-neighbour search and clustering for mixed numeric/categorical tables.

日本語 README

Real customer tables mix age and spend with gender and plan_tier. The standard answers — Gower's distance and k-prototypes — handle that, but with two well-known problems: you have to hand-pick the numeric/categorical balance, and high-cardinality categorical columns quietly dominate the distance regardless of whether they carry signal.

mixdist makes the weighting explicit and inspectable, scales past the n × n wall, and hands the resulting geometry to ordinary Euclidean tooling.

pip install mixdist

Requires Python ≥ 3.9, NumPy and pandas. Nothing else.


The problem, in one table

from mixdist import MixedMetric, make_mixed_blobs

# 3 informative numeric + 2 informative categorical + 2 pure-noise categorical
X, y_true = make_mixed_blobs(n_samples=800, random_state=0)

MixedMetric(weights="equal").fit(X).column_report()
            kind  n_levels  expected_dissimilarity  weight  share
column
noise_0  nominal      30.0                   0.965     1.0  0.246
noise_1  nominal      30.0                   0.965     1.0  0.246
cat_1    nominal       3.0                   0.666     1.0  0.170
cat_0    nominal       3.0                   0.665     1.0  0.170
num_1    numeric       NaN                   0.246     1.0  0.063
num_2    numeric       NaN                   0.210     1.0  0.054
num_0    numeric       NaN                   0.200     1.0  0.051

Two columns of pure noise command 49 % of the distance, while each informative numeric column gets ~5 %. Equal weights equalise the nominal weight, not the contribution — and contribution is what the clustering actually sees. The mechanism is arithmetic, not bad luck: a balanced nominal column with K levels has expected dissimilarity 1 − 1/K (0.97 at 30 levels), while a range-scaled numeric column tops out at 1/3 and is usually nearer 0.2.

weights="balanced" sets w_k ∝ 1 / E[d_k], so every column contributes the same expected distance:

MixedMetric(weights="balanced").fit(X).column_report()["share"]
# every column ≈ 0.143

Measured over 5 seeds (800 rows, 3 clusters):

weights kNN purity (k=10) ARI (KMeans on transform) ARI s.d.
"equal" (Gower 1971) 0.905 0.827 0.083
"type_balanced" 0.910 0.846 0.062
"balanced" 0.937 0.894 0.024

kNN purity — the fraction of a point's 10 nearest neighbours sharing its true cluster — is reported because it measures the metric with no clustering algorithm in the way. Note also the collapse in variance: balanced is not just better on average, it is three times more stable across seeds.

(Reproduce with python examples/weighting_matters.py.)


Quick start

from mixdist import MixedMetric

metric = MixedMetric(weights="balanced").fit(df)

D = metric.pairwise(df)                        # full distance matrix
dist, idx = metric.kneighbors(n_neighbors=10)  # exact kNN, never builds n×n
Z = metric.transform(df)                       # ||Z_i − Z_j||² == Gower distance

metric.column_report()      # which variables own the distance
metric.explain(df, 3, 17)   # why rows 3 and 17 are similar, column by column

Column typing

Types are inferred (object/category/bool → nominal, ordered Categorical → ordinal, low-cardinality integers → nominal), and you override what matters:

MixedMetric(
    categorical=["plan_tier", "region"],
    ordinal=["satisfaction"],
    numeric_range="robust",   # inner-quantile scaling; one outlier can't flatten a column
)

Missing values are handled the way Gower intended — the column drops out of that pair's denominator, not the whole dataset.


Scaling

Distances accumulate one column at a time into a bounded block, so no (n, m, p) intermediate is ever built. Measured on this machine (7 columns; python examples/scaling.py):

n kneighbors peak pairwise peak reference gower peak
2,000 0.09 s 164 MB 0.06 s 196 MB 11.4 s 18 MB
5,000 0.52 s 329 MB 0.33 s 528 MB 71.1 s 106 MB
10,000 2.04 s 330 MB 1.31 s 1,128 MB 285.6 s 412 MB
20,000 7.96 s 331 MB

Two things to read off it. Speed: ~200× faster than the reference implementation at n = 10,000. Memory: kneighbors is flat in n — the matrix is never materialised — while pairwise necessarily grows as because the matrix is the output. (mixdist computes in float64 and gower in float32, which accounts for the 2× in the pairwise column.)

So on anything large, don't ask for the matrix:

# Top-k directly — O(n·k) memory, exact, no approximation
dist, idx = metric.kneighbors(query_df, reference_df, n_neighbors=20)

# Or stream the matrix in blocks
for start, stop, block in metric.iter_pairwise(df):
    ...

Results agree with the reference gower package to 4e-8 (float32 precision) — see tests/test_reference_gower.py, which is run against it directly.


Euclidean embedding (the interesting part)

Gower's distance is a non-negatively weighted average of |u − v| on range-scaled numerics and 1[u ≠ v] on nominals. Both are metrics of negative type, and negative type is closed under non-negative combination — so √d_Gower embeds isometrically in ℓ₂, and the map can be written down explicitly rather than recovered by MDS.

transform() builds it: thermometer features for numeric columns, scaled one-hot for nominal ones, amplitudes chosen so that

‖transform(X)[i] − transform(X)[j]‖²  ==  pairwise(X)[i, j]

exactly, up to 1/n_bins quantisation per numeric column.

import faiss, numpy as np

Z = metric.transform(df, n_components=256)     # optional JL projection
index = faiss.IndexFlatL2(Z.shape[1])
index.add(np.ascontiguousarray(Z))
index.search(Z[:5], 10)                        # Gower nearest neighbours, at FAISS speed

The same trick makes sklearn.cluster.KMeans, HDBSCAN and UMAP operate on Gower geometry without modification.


Clustering

Two algorithms, chosen because they attack the weighting problem from opposite directions.

KPrototypes — Huang (1997)

from mixdist import KPrototypes

km = KPrototypes(n_clusters=4, gamma="modha-spangler", random_state=0).fit(df)
km.labels_, km.gamma_, km.cluster_centers_

gamma="auto" uses Huang's rule of thumb. gamma="modha-spangler" searches the balance by minimising the product of the numeric and categorical within-cluster distortions (Modha & Spangler, 2003) — no labels, no tuning, one run per grid point.

KAMILA — Foss et al. (2016)

from mixdist import KAMILA

km = KAMILA(n_clusters=4, random_state=0).fit(df)
km.cluster_centers_
km.level_probabilities_["plan_tier"]   # per-cluster level probabilities

KAMILA has no gamma. It models the continuous block as a spherical cluster with a KDE-estimated radial density and the categorical block as per-cluster multinomials, then compares log-densities — quantities already on a common scale. The balance comes from the data.

This is the first Python implementation; the reference implementation is the R package kamila. It is written from the published description, not ported, so behaviour agrees but numbers need not match bit for bit.

Explaining clusters

metric.explain_clusters(df, km.labels_)
          num_0   num_1   num_2   cat_0   cat_1  noise_0  noise_1
cluster
0        0.0110  0.0285  0.0219  0.0353  0.0338   0.0001   0.0001
1        0.0106  0.0258  0.0232  0.0314  0.0295   0.0002   0.0001
2        0.0135  0.0269  0.0241  0.0345  0.0307   0.0002   0.0002

Entry (g, k) is the weighted dispersion reduction of column k inside cluster g: how much tighter the column gets once you condition on the cluster, on the Gower scale. The noise columns score ~0.0002 against ~0.03 for the informative ones, and they do so even though they sit far from the global centre — which is the trap this measure is built to avoid. Negative entries mean the cluster is more heterogeneous than the data at large. No surrogate model is involved.


Design notes

  • No scikit-learn dependency, but estimators implement get_params/set_params, so they drop into Pipeline and GridSearchCV when scikit-learn is present.
  • gower_matrix(X) is provided as a one-line drop-in for the common gower.gower_matrix idiom, defaulting to weights="equal" for exact compatibility.
  • Constant columns are dropped with a warning. They contribute no distance but do enter Gower's denominator, silently rescaling everything.

Limitations

  • transform() cannot represent missing values exactly — Gower's pair-dependent denominator has no single feature map. The default imputes and warns; pass on_missing="error" to refuse instead.
  • KAMILA's continuous model assumes spherical clusters, as in the original paper.
  • gamma="modha-spangler" costs one full clustering per grid point.

References

  • Gower, J. C. (1971). A general coefficient of similarity and some of its properties. Biometrics 27(4).
  • Huang, Z. (1997). Clustering large data sets with mixed numeric and categorical values. PAKDD.
  • Modha, D. S. & Spangler, W. S. (2003). Feature weighting in k-means clustering. Machine Learning 52(3).
  • Foss, A., Markatou, M., Ray, B. & Heching, A. (2016). A semiparametric method for clustering mixed data. Machine Learning 105(3).
  • Schoenberg, I. J. (1938). Metric spaces and positive definite functions. TAMS 44(3) — the negative-type result behind transform().

License

BSD 3-Clause.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

mixdist-0.1.0.tar.gz (46.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

mixdist-0.1.0-py3-none-any.whl (39.2 kB view details)

Uploaded Python 3

File details

Details for the file mixdist-0.1.0.tar.gz.

File metadata

  • Download URL: mixdist-0.1.0.tar.gz
  • Upload date:
  • Size: 46.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.6

File hashes

Hashes for mixdist-0.1.0.tar.gz
Algorithm Hash digest
SHA256 4faa3ff9d18821f98d209746f34c8a49f8c0f6ffc3a098f5210551bdd028024a
MD5 a537357ee0c41d5510aedce2108404bc
BLAKE2b-256 8d5989d02c623728bf2d4b462c5c95d0cdd26c6c7a5fb8da5c1f1ae421e8cfb7

See more details on using hashes here.

File details

Details for the file mixdist-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: mixdist-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 39.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.6

File hashes

Hashes for mixdist-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c0385d25b29048b23273cd77ee4ce91ee733347ff136daa29f0895ab86c1672d
MD5 d63dc8b2712f5cba6098d4fafaf4afe7
BLAKE2b-256 f18cf5ecb67080f1280908b9229fe8bda6a3ea35f8fe124c31867c8db50ba7d3

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page