from __future__ import annotations
import warnings
import numpy as np
from sklearn.decomposition import PCA
from ..embed import resolve_embeddings
from .types import MeasureResult
### Distribution-Based Diversity Measure
[docs]
def bins_entropy(
data,
n_bins_x: int = 5,
n_bins_y: int = 5,
normalize: bool = True,
normalization: str = "effective", # "effective" -> log(min(n,B)), "bins" -> log(B)
projection: str = "umap",
pca_kwargs=None,
umap_kwargs=None,
*,
diversity_axis: str = "semantic",
embedding_model: str | None = None,
chunking_kwargs: dict | None = None,
) -> MeasureResult:
"""**Interpretation of values:** larger value = more diverse.
**Range:** [0, 1] with the default ``"effective"`` normalization; with ``"bins"`` it may be < 1.
Compute bins-based entropy diversity from a 2D projection of a vector set.
1) Project the input vectors to 2D with UMAP or PCA.
2) Bin points into a n_bins_x × n_bins_y grid.
3) Compute Shannon entropy over bin occupancies.
4) Optionally normalize.
References:
Cox, Samuel Rhys, Yunlong Wang, Ashraf Abdul, Christian von der Weth, and Brian Y. Lim. “Directed Diversity: Leveraging Language Embedding Distances for Collective Creativity in Crowd Ideation.” Proceedings of the 2021 CHI Conference on Human Factors in Computing Systems, May 6, 2021, 1–35. https://doi.org/10.1145/3411764.3445782.
Yang, Yuming, Yang Nan, Junjie Ye, Shihan Dou, Xiao Wang, Shuo Li, Huijie Lv, Tao Gui, Qi Zhang, and Xuan-Jing Huang. "Measuring data diversity for instruction tuning: A systematic analysis and a reliable metric." In Proceedings of the 63rd Annual Meeting of the Association for Computational Linguistics (Volume 1: Long Papers), pp. 18530-18549. 2025.
Args:
data:
Iterable/array-like of (embedding) vectors with shape (n, d).
Must contain at least 2 samples.
Accepts numpy arrays and (optionally) torch tensors.
n_bins_x:
Number of bins along x-axis. Must be > 0.
n_bins_y:
Number of bins along y-axis. Must be > 0.
normalize:
If True, normalize entropy by a log factor.
normalization:
Normalization denominator:
- "effective": (default) divide by log(min(n, B)); ensures result in [0, 1].
- "bins": divide by log(B) (paper-style; max < 1 when n < B).
projection:
"umap" or "pca". Defaults to "umap".
pca_kwargs:
Extra kwargs passed to PCA(...).
Defaults to None (treated as {}).
PCA is deterministic for full SVD solver.
umap_kwargs:
Extra kwargs passed to UMAP(...).
Defaults to None (treated as {}).
If random_state is not provided, random_state=42 is used.
diversity_axis: Registered axis used to embed text input (default "semantic").
embedding_model: Explicit embedding model id; overrides *diversity_axis*.
Returns:
A dict ``{"value": float, "parameters": {...}}`` where ``value`` is the
bins-based entropy (normalized to [0, 1] if normalize=True with the
default effective normalization) and
``parameters`` records the configuration used.
Raises:
ImportError: If projection is "umap" but UMAP is not installed.
ValueError: If input shape, bins, projection, or normalization is invalid.
"""
data, embedding_model = resolve_embeddings(data, diversity_axis, embedding_model, measure="bins_entropy", chunking_kwargs=chunking_kwargs)
# Normalize input to numpy array
X = np.asarray(data, dtype=float)
if X.size == 0:
raise ValueError("Cannot compute bins_based_entropy for fewer than 2 datapoints")
if X.ndim != 2:
raise ValueError(f"Expected 2D array of shape (n, d), got shape {X.shape}")
n, d = X.shape
if n < 2:
raise ValueError("Cannot compute bins_based_entropy for fewer than 2 datapoints")
if n_bins_x <= 0 or n_bins_y <= 0:
raise ValueError("n_bins_x and n_bins_y must be positive integers")
total_bins = int(n_bins_x) * int(n_bins_y)
if projection not in {"umap", "pca"}:
raise ValueError('projection must be either "umap" or "pca"')
# UMAP's spectral initialization needs more points than its output
# dimensionality and fails with cryptic internal errors below this.
if projection == "umap" and n < 4:
raise ValueError(
f"bins_entropy with projection='umap' requires at least 4 "
f"datapoints (got {n}); use projection='pca' for very small "
"inputs or provide more data."
)
# Projection kwargs
if pca_kwargs is None:
pca_kwargs = {}
else:
pca_kwargs = dict(pca_kwargs) # copy
if umap_kwargs is None:
umap_kwargs = {}
else:
umap_kwargs = dict(umap_kwargs) # copy
parameters = {
"n_bins_x": n_bins_x,
"n_bins_y": n_bins_y,
"normalize": normalize,
"normalization": normalization,
"projection": projection,
"pca_kwargs": pca_kwargs,
"umap_kwargs": umap_kwargs,
"embedding_model": embedding_model,
}
# 2D projection
# (User can still override solver/whiten/etc via kwargs)
if projection == "umap":
# umap is slow to import, so it is loaded only when actually
# projecting with it (PCA calls never pay for it).
try:
from umap import UMAP
except ImportError:
raise ImportError("UMAP is not installed.")
from ._umap import fit_transform_umap
umap_kwargs.setdefault("random_state", 42)
reducer = UMAP(n_components=2, **umap_kwargs)
Y = fit_transform_umap(reducer, X)
else:
reducer = PCA(n_components=2, **pca_kwargs)
Y = reducer.fit_transform(X) # shape (n, 2)
# Compute bounds and ranges for binning
min_x, min_y = Y.min(axis=0)
max_x, max_y = Y.max(axis=0)
range_x = max_x - min_x
range_y = max_y - min_y
# Assign each point to a bin
eps = 1e-10
if range_x <= 0:
bin_x = np.zeros(n, dtype=int)
else:
bin_x = np.floor((Y[:, 0] - min_x) / (range_x + eps) * n_bins_x).astype(int)
if range_y <= 0:
bin_y = np.zeros(n, dtype=int)
else:
bin_y = np.floor((Y[:, 1] - min_y) / (range_y + eps) * n_bins_y).astype(int)
bin_x = np.clip(bin_x, 0, n_bins_x - 1)
bin_y = np.clip(bin_y, 0, n_bins_y - 1)
# Map 2D bins to 1D labels
bin_labels = bin_x * n_bins_y + bin_y
# Count occurrences in each occupied bin
_, counts = np.unique(bin_labels, return_counts=True)
# Empirical distribution over occupied bins
f_b = counts / n
# Shannon entropy over occupied bins (empty bins contribute 0)
entropy = -np.sum(f_b * np.log(f_b))
# Optional normalization
if normalize:
if total_bins <= 1:
# With a 1x1 grid, entropy is always 0; avoid division by zero
return {"value": 0.0, "parameters": parameters}
if normalization not in {"effective", "bins"}:
raise ValueError('normalization must be either "effective" or "bins"')
denom_bins = min(n, total_bins) if normalization == "effective" else total_bins
denom = np.log(denom_bins)
if denom <= 0:
return {"value": 0.0, "parameters": parameters}
entropy = entropy / denom
# Numerical safety: keep in [0,1] for effective normalization
if normalization == "effective":
entropy = float(np.clip(entropy, 0.0, 1.0))
return {"value": float(entropy), "parameters": parameters}