Files
tianxuan/analysis/tools/clustering.py
T
PM-pinou 9508cc8d58 perf: optimize for 25M rows on 8GB RAM
- globe.py: add streaming=True to all lf.head().collect() calls (3 places)
- entities.py: chunked aggregation (100K batches) for datasets > 1M rows
- constants.py: reduce MAX_SAMPLE_ROWS 50000->30000 for 8GB RAM
- tools/__init__.py: add __all__ for '*' re-export (fix tool_registry import)
- clustering.py: fix misplaced logger/import traceback syntax errors
- analysis.py: fix indentation of pass statement
- anomalies.py: fix misplaced import traceback syntax error
2026-07-23 22:54:45 +08:00

459 lines
18 KiB
Python

"""Clustering handlers — HDBSCAN, KMeans, AgglomerativeClustering.
Contains:
- _handle_run_clustering: cluster selected columns
- _handle_filter_and_cluster: filter then auto-cluster
- _cluster_core: shared clustering pipeline extracted from both handlers
"""
import logging
import traceback
from analysis.constants import LOW_MEMORY_THRESHOLD_GB, LOW_MEMORY_THRESHOLD_ROWS, MAX_CLUSTER_FEATURES, MAX_SAMPLE_ROWS, RANDOM_SEED
from typing import Optional
import numpy as np
import polars as pl
from ..session_store import SessionStore, _generate_id
from ._helpers import (
NUMERIC_DTYPES,
_build_filter_expr,
_resolve_dataset,
_count_rows,
)
logger = logging.getLogger(__name__)
_clust_logger = logging.getLogger(__name__)
def _cluster_core(
data: np.ndarray,
feature_cols: list[str],
algorithm: str,
params: dict,
random_state: int = RANDOM_SEED,
):
"""Shared clustering pipeline: scale, filter, reduce, fit, score.
Args:
data: Numpy array (n_samples, n_features).
feature_cols: Column names matching data columns.
algorithm: 'hdbscan', 'kmeans', or 'agglomerative'.
params: Algorithm-specific parameters dict.
random_state: Random seed.
Returns:
(labels_array, n_clusters_found, noise_count, quality_metrics, _ds_idx)
"""
p = dict(params)
# ── Low memory check ───────────────────────────────────────────────
low_memory = False
try:
import psutil
mem = psutil.virtual_memory()
low_memory = mem.available < LOW_MEMORY_THRESHOLD_GB * 1024 ** 3 # 2 GB
except ImportError:
pass
if low_memory and len(data) > LOW_MEMORY_THRESHOLD_ROWS:
rng = np.random.default_rng(RANDOM_SEED)
idx = rng.choice(len(data), MAX_SAMPLE_ROWS, replace=False)
data = data[idx]
# ── Downsampling to 50K max ────────────────────────────────────────
_ds_idx = None
if len(data) > MAX_SAMPLE_ROWS:
rng = np.random.default_rng(RANDOM_SEED)
_ds_idx = rng.choice(len(data), MAX_SAMPLE_ROWS, replace=False)
data = data[_ds_idx]
# ── Variance filtering ─────────────────────────────────────────────
if data.shape[1] > 1:
variances = np.var(data, axis=0)
keep_var = np.where(variances >= 1e-10)[0]
if len(keep_var) == 0:
keep_var = np.arange(data.shape[1])
if len(keep_var) < data.shape[1]:
data = data[:, keep_var]
feature_cols = [feature_cols[i] for i in keep_var]
# Log clustering input
_clust_logger.info(
'[CLUSTER_INPUT] rows=%d cols=%d feature_cols=%s',
len(data), data.shape[1] if len(data.shape) > 1 else 1, feature_cols,
)
# Standard scale
from sklearn.preprocessing import StandardScaler
_clust_logger.info('[CLUSTER_SCALE] Starting StandardScaler...')
data_scaled = StandardScaler().fit_transform(data)
_clust_logger.info('[CLUSTER_SCALE] Done. shape=%s', data_scaled.shape)
# ── Correlation filtering ──────────────────────────────────────────
if data_scaled.shape[1] > 1:
corr = np.corrcoef(data_scaled.T)
upper = np.triu(np.abs(corr), 1)
to_drop = [j for j in range(upper.shape[1]) if any(upper[:, j] > 0.95)]
if to_drop:
keep = [j for j in range(data_scaled.shape[1]) if j not in to_drop]
data_scaled = data_scaled[:, keep]
feature_cols = [feature_cols[j] for j in keep]
# ── SVD dimensionality reduction (if >50 features) ─────────────────
n_features = data_scaled.shape[1]
if n_features > MAX_CLUSTER_FEATURES:
from sklearn.decomposition import TruncatedSVD
svd = TruncatedSVD(n_components=MAX_CLUSTER_FEATURES, random_state=random_state)
data_scaled = svd.fit_transform(data_scaled)
# ── Hyperparameter auto-tuning ─────────────────────────────────────
n = len(data_scaled)
if algorithm == 'hdbscan':
min_cluster = p.get('min_cluster_size', max(3, min(MAX_CLUSTER_FEATURES, n // 20)))
p['min_cluster_size'] = min_cluster
p['min_samples'] = p.get('min_samples', min_cluster)
p['cluster_selection_epsilon'] = p.get('cluster_selection_epsilon', 0.0)
else:
p['min_cluster_size'] = min(
p.get('min_cluster_size', 5),
max(n // 2, 2),
)
# ── Model fit ──────────────────────────────────────────────────────
from sklearn.cluster import HDBSCAN, MiniBatchKMeans, AgglomerativeClustering
if algorithm == 'kmeans':
n_clusters_param = p.get('n_clusters', 3)
model = MiniBatchKMeans(
n_clusters=n_clusters_param, random_state=random_state,
batch_size=1024, n_init='auto',
)
elif algorithm in ('agglomerative', 'ward'):
n_clusters_param = p.get('n_clusters', 5)
model = AgglomerativeClustering(
n_clusters=n_clusters_param,
metric=p.get('metric', 'euclidean'),
linkage=p.get('linkage', 'ward'),
distance_threshold=None,
)
else:
model = HDBSCAN(
min_cluster_size=p['min_cluster_size'],
min_samples=p.get('min_samples', None),
metric=p.get('metric', 'euclidean'),
cluster_selection_epsilon=p.get('cluster_selection_epsilon', 0.0),
)
labels_array = model.fit_predict(data_scaled)
n_clusters_found = int(len(set(labels_array)) - (1 if -1 in labels_array else 0))
noise_count = int((labels_array == -1).sum()) if algorithm == 'hdbscan' else 0
noise_ratio = round(noise_count / len(labels_array), 4) if algorithm == 'hdbscan' else 0.0
# ── Quality metrics ────────────────────────────────────────────────
quality = {
'n_clusters': n_clusters_found,
'n_noise': noise_count,
'noise_ratio': noise_ratio,
'algorithm': algorithm,
}
if n_clusters_found > 1:
try:
from sklearn.metrics import silhouette_score
score = silhouette_score(data_scaled, labels_array, random_state=random_state)
quality['silhouette_score'] = round(float(score), 4)
except Exception:
logger.error("unknown failed: {}".format(traceback.format_exc()))
quality['silhouette_score'] = None
try:
from sklearn.metrics import davies_bouldin_score
dbs = davies_bouldin_score(data_scaled, labels_array)
quality['davies_bouldin_score'] = round(float(dbs), 4)
except Exception:
logger.error("unknown failed: {}".format(traceback.format_exc()))
quality['davies_bouldin_score'] = None
try:
from sklearn.metrics import calinski_harabasz_score
chs = calinski_harabasz_score(data_scaled, labels_array)
quality['calinski_harabasz_score'] = round(float(chs), 4)
except Exception:
logger.error("unknown failed: {}".format(traceback.format_exc()))
quality['calinski_harabasz_score'] = None
# Cluster sizes
cluster_sizes = {}
for label in np.unique(labels_array):
cluster_sizes[int(label)] = int((labels_array == label).sum())
quality['cluster_sizes'] = cluster_sizes
return labels_array, n_clusters_found, noise_count, quality, _ds_idx
# ═══════════════════════════════════════════════════════════════════════
# _handle_run_clustering
# ═══════════════════════════════════════════════════════════════════════
async def _handle_run_clustering(
dataset_id: str,
cluster_columns: list[str],
algorithm: str = 'agglomerative',
params: Optional[dict] = None,
random_state: int = RANDOM_SEED,
) -> dict:
"""Run HDBSCAN, KMeans, or Agglomerative clustering on selected columns."""
entry, err = _resolve_dataset(dataset_id)
if err:
return err
lf: pl.LazyFrame = entry['lazyframe']
p = params or {}
# Collect cluster features - numeric only
available = lf.collect_schema()
available_names = available.names()
available_dtypes = available.dtypes()
feature_cols = [
c for c in cluster_columns
if c in available_names
and available_dtypes[available_names.index(c)] in NUMERIC_DTYPES
]
if not feature_cols:
# Fall back: auto-select all numeric columns (exclude internal ones)
feature_cols = [
available_names[i] for i, dt in enumerate(available_dtypes)
if dt in NUMERIC_DTYPES and not available_names[i].startswith('_')
][:10]
if not feature_cols:
# Fallback: try coercing Utf8 columns to Float64
utf8_cols = [
available_names[i] for i, dt in enumerate(available_dtypes)
if dt == pl.Utf8 and not available_names[i].startswith('_')
][:10]
if utf8_cols:
for col in utf8_cols:
try:
casted = lf.select(
pl.col(col).cast(pl.Float64, strict=False).alias(col)
).collect(streaming=True)
null_ratio = casted[col].is_null().sum() / max(len(casted), 1)
if null_ratio < 0.9:
lf = lf.with_columns(
pl.col(col).cast(pl.Float64, strict=False)
)
feature_cols.append(col)
except Exception:
logger.error("unknown failed: {}".format(traceback.format_exc()))
pass
if not feature_cols:
all_numeric = [f"{available_names[i]}" for i, dt in enumerate(available_dtypes)
if dt in NUMERIC_DTYPES]
return {
'error': f'No numeric cluster columns found in dataset. Available: {all_numeric}',
'truncated': False
}
# Downsample before collect
row_count = _count_rows(lf)
if row_count > LOW_MEMORY_THRESHOLD_ROWS:
lf = lf.collect(streaming=True).sample(n=LOW_MEMORY_THRESHOLD_ROWS, seed=RANDOM_SEED).lazy()
df_features = lf.select(feature_cols).collect(streaming=True)
# Drop all-NaN columns
for col in df_features.columns:
if df_features[col].is_null().all():
df_features = df_features.drop(col)
feature_cols = [c for c in feature_cols if c != col]
if df_features.width == 0:
return {'error': 'No valid feature columns after dropping all-NaN columns',
'truncated': False}
data = df_features.to_numpy()
# Fill remaining NaN with column mean
if data.dtype.kind == 'f':
col_mean = np.nanmean(data, axis=0)
col_mean = np.nan_to_num(col_mean, nan=0.0)
data = np.where(np.isnan(data), col_mean, data)
# Zero-sample guard
if len(data) < 3:
return {
'cluster_result_id': _generate_id('clust'),
'n_clusters': 0,
'n_noise': 0,
'quality_metrics': {'note': 'too few samples'},
'truncated': False,
}
labels_array, n_found, noise_count, quality, _ds_idx = _cluster_core(
data, feature_cols, algorithm, p, random_state,
)
result_id = _generate_id('clust')
store = SessionStore()
store.store_cluster_result(
result_id=result_id,
labels=labels_array.tolist(),
n_clusters=n_found,
params={
'algorithm': algorithm,
'params': p,
'random_state': random_state,
'feature_columns': feature_cols,
'_ds_idx': _ds_idx.tolist() if _ds_idx is not None else None,
},
parent_dataset_id=dataset_id,
)
return {
'cluster_result_id': result_id,
'n_clusters': n_found,
'n_noise': noise_count,
'labels_sample': labels_array[:50].tolist(),
'quality_metrics': quality,
}
# ═══════════════════════════════════════════════════════════════════════
# _handle_filter_and_cluster
# ═══════════════════════════════════════════════════════════════════════
async def _handle_filter_and_cluster(
dataset_id: str,
filters: list[dict],
logic: str = 'and',
algorithm: str = 'hdbscan',
params: dict | None = None,
) -> dict:
"""Apply column-level filters then run clustering on the filtered result."""
entry, err = _resolve_dataset(dataset_id)
if err:
return err
lf: pl.LazyFrame = entry['lazyframe']
full_schema = entry.get('schema', {})
# ── Step 1: Apply filters ─────────────────────────────────────────────
exprs = [_build_filter_expr(
f['column'], f.get('op', 'eq'), f.get('value'),
) for f in filters]
if exprs:
if logic == 'and':
combined = exprs[0]
for e in exprs[1:]:
combined = combined & e
else:
combined = exprs[0]
for e in exprs[1:]:
combined = combined | e
filtered_lf = lf.filter(combined)
else:
filtered_lf = lf
try:
row_count = _count_rows(filtered_lf)
except Exception:
logger.error("unknown failed: {}".format(traceback.format_exc()))
row_count = None
# ── Step 2: Auto-detect numeric feature columns ───────────────────────
available = filtered_lf.collect_schema()
available_names = available.names()
available_dtypes = available.dtypes()
feature_cols = [
available_names[i] for i, dt in enumerate(available_dtypes)
if dt in NUMERIC_DTYPES and not available_names[i].startswith('_')
][:10]
if not feature_cols:
return {
'error': 'No numeric columns found for clustering after filtering',
'filtered_dataset_id': None,
'truncated': False,
}
# ── Step 3: Store filtered dataset ────────────────────────────────────
filtered_dataset_id = _generate_id('flt')
store = SessionStore()
store.store_dataset(
dataset_id=filtered_dataset_id,
lazyframe=filtered_lf,
schema=full_schema,
metadata={
'row_count': row_count,
'parent_dataset_id': dataset_id,
'filter_count': len(filters),
'logic': logic,
},
parent_id=dataset_id,
)
# ── Step 4: Run clustering ────────────────────────────────────────────
p = params or {}
total_count = _count_rows(filtered_lf)
clamp_lf = filtered_lf
if total_count > LOW_MEMORY_THRESHOLD_ROWS:
clamp_lf = filtered_lf.collect(streaming=True).sample(n=LOW_MEMORY_THRESHOLD_ROWS, seed=RANDOM_SEED).lazy()
df_features = clamp_lf.select(feature_cols).collect(streaming=True)
for col in df_features.columns:
if df_features[col].is_null().all():
df_features = df_features.drop(col)
feature_cols = [c for c in feature_cols if c != col]
if df_features.width == 0:
return {'error': 'No valid feature columns after dropping all-NaN columns',
'filtered_dataset_id': filtered_dataset_id, 'truncated': False}
data = df_features.to_numpy()
if data.dtype.kind == 'f':
col_mean = np.nanmean(data, axis=0)
col_mean = np.nan_to_num(col_mean, nan=0.0)
data = np.where(np.isnan(data), col_mean, data)
if len(data) < 3:
return {
'cluster_result_id': _generate_id('clust'),
'n_clusters': 0,
'n_noise': 0,
'quality_metrics': {'note': 'too few samples'},
'filtered_dataset_id': filtered_dataset_id,
'truncated': False,
}
labels_array, n_found, noise_count, quality, _ds_idx = _cluster_core(
data, feature_cols, algorithm, p, RANDOM_SEED,
)
result_id = _generate_id('clust')
store.store_cluster_result(
result_id=result_id,
labels=labels_array.tolist(),
n_clusters=n_found,
params={
'algorithm': algorithm,
'params': p,
'random_state': RANDOM_SEED,
'feature_columns': feature_cols,
'_ds_idx': _ds_idx.tolist() if _ds_idx is not None else None,
},
parent_dataset_id=filtered_dataset_id,
)
return {
'cluster_result_id': result_id,
'filtered_dataset_id': filtered_dataset_id,
'n_clusters': n_found,
'n_noise': noise_count,
'labels_sample': labels_array[:50].tolist(),
'quality_metrics': quality,
'feature_columns': feature_cols,
}