bitbullet 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bitbullet/__init__.py +1 -0
- bitbullet/cluster/__init__.py +34 -0
- bitbullet/cluster/algorithms/__init__.py +0 -0
- bitbullet/cluster/algorithms/density/__init__.py +0 -0
- bitbullet/cluster/algorithms/density/dbscan.py +252 -0
- bitbullet/cluster/algorithms/model_based/__init__.py +0 -0
- bitbullet/cluster/algorithms/model_based/gmm.py +283 -0
- bitbullet/cluster/algorithms/partitional/__init__.py +19 -0
- bitbullet/cluster/algorithms/partitional/kmeans.py +157 -0
- bitbullet/cluster/algorithms/partitional/kmodes.py +334 -0
- bitbullet/cluster/algorithms/partitional/kprototypes.py +281 -0
- bitbullet/cluster/core/__init__.py +74 -0
- bitbullet/cluster/core/autopipeline.py +355 -0
- bitbullet/cluster/core/base.py +348 -0
- bitbullet/cluster/core/registry.py +61 -0
- bitbullet/cluster/distance/__init__.py +23 -0
- bitbullet/cluster/distance/mixed.py +178 -0
- bitbullet/cluster/distances/__init__.py +0 -0
- bitbullet/cluster/distances/memory_efficient.py +297 -0
- bitbullet/cluster/evaluation/__init__.py +66 -0
- bitbullet/cluster/evaluation/metrics.py +537 -0
- bitbullet/cluster/evaluation/profiling.py +602 -0
- bitbullet/cluster/evaluation/selection.py +214 -0
- bitbullet/cluster/utils/__init__.py +13 -0
- bitbullet/cluster/utils/sample_size.py +655 -0
- bitbullet/cluster/visualization/__init__.py +54 -0
- bitbullet/cluster/visualization/decomposition.py +324 -0
- bitbullet/cluster/visualization/interactive.py +496 -0
- bitbullet/cluster/weights/__init__.py +0 -0
- bitbullet/cluster/weights/categorical_weights.py +295 -0
- bitbullet/cluster/weights/gamma_estimation.py +273 -0
- bitbullet/evaluate/__init__.py +41 -0
- bitbullet/evaluate/classification.py +181 -0
- bitbullet/evaluate/config.py +50 -0
- bitbullet/evaluate/evaluator.py +322 -0
- bitbullet/evaluate/regression.py +136 -0
- bitbullet/model/__init__.py +55 -0
- bitbullet/model/core/__init__.py +1 -0
- bitbullet/model/core/base.py +213 -0
- bitbullet/model/core/metadata.py +335 -0
- bitbullet/model/core/registry.py +127 -0
- bitbullet/model/serialization/__init__.py +1 -0
- bitbullet/model/serialization/model_serializer.py +378 -0
- bitbullet/model/wrappers/__init__.py +39 -0
- bitbullet/model/wrappers/lgbm_wrapper.py +311 -0
- bitbullet/model/wrappers/xgb_wrapper.py +181 -0
- bitbullet/train/__init__.py +57 -0
- bitbullet/train/callbacks/__init__.py +0 -0
- bitbullet/train/core/__init__.py +1 -0
- bitbullet/train/core/base.py +526 -0
- bitbullet/train/core/config.py +216 -0
- bitbullet/train/core/state.py +149 -0
- bitbullet/train/evaluation/__init__.py +0 -0
- bitbullet/train/evaluation/threshold_optimizer.py +153 -0
- bitbullet/train/explainability/__init__.py +19 -0
- bitbullet/train/explainability/shap_explainer.py +581 -0
- bitbullet/train/feature_selection/__init__.py +0 -0
- bitbullet/train/feature_selection/selector_factory.py +609 -0
- bitbullet/train/optimizers/__init__.py +0 -0
- bitbullet/train/optimizers/hyperparameter_grids.py +511 -0
- bitbullet/train/optimizers/optuna_optimizer.py +252 -0
- bitbullet/train/reports/__init__.py +19 -0
- bitbullet/train/reports/feature_report.py +365 -0
- bitbullet/train/reports/training_report.py +325 -0
- bitbullet/train/trainers/__init__.py +0 -0
- bitbullet/train/trainers/optuna_trainer.py +1044 -0
- bitbullet/train/utils/__init__.py +0 -0
- bitbullet/train/utils/sample_weights.py +174 -0
- bitbullet/transform/__init__.py +29 -0
- bitbullet/transform/core/__init__.py +7 -0
- bitbullet/transform/core/base.py +308 -0
- bitbullet/transform/core/pipeline.py +518 -0
- bitbullet/transform/core/registry.py +241 -0
- bitbullet/transform/eda.py +148 -0
- bitbullet/transform/transformers/__init__.py +13 -0
- bitbullet/transform/transformers/categorical.py +344 -0
- bitbullet/transform/transformers/datetime.py +191 -0
- bitbullet/transform/transformers/numerical.py +407 -0
- bitbullet/transform/transformers/target.py +487 -0
- bitbullet-0.1.0.dist-info/METADATA +296 -0
- bitbullet-0.1.0.dist-info/RECORD +84 -0
- bitbullet-0.1.0.dist-info/WHEEL +5 -0
- bitbullet-0.1.0.dist-info/licenses/LICENSE +21 -0
- bitbullet-0.1.0.dist-info/top_level.txt +1 -0
bitbullet/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BitBullet Cluster - Production-grade clustering engine for mixed data types.
|
|
3
|
+
|
|
4
|
+
This package provides a comprehensive clustering solution with:
|
|
5
|
+
- Support for mixed numerical and categorical data
|
|
6
|
+
- Multiple clustering algorithms (K-Modes, K-Prototypes, K-Means, DBSCAN, GMM)
|
|
7
|
+
- Data-driven feature weighting
|
|
8
|
+
- Memory-efficient distance computation
|
|
9
|
+
- Production-ready serialization
|
|
10
|
+
- Comprehensive evaluation metrics
|
|
11
|
+
- Beautiful visualizations
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from bitbullet.cluster.core.base import BaseClusterer, ClusterConfig, ClusterState
|
|
15
|
+
from bitbullet.cluster.weights.categorical_weights import CategoricalWeights
|
|
16
|
+
from bitbullet.cluster.weights.gamma_estimation import GammaEstimator
|
|
17
|
+
from bitbullet.cluster.utils.sample_size import (
|
|
18
|
+
SampleSizeEstimator,
|
|
19
|
+
SampleSizeEstimate,
|
|
20
|
+
estimate_sample_size,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"BaseClusterer",
|
|
27
|
+
"ClusterConfig",
|
|
28
|
+
"ClusterState",
|
|
29
|
+
"CategoricalWeights",
|
|
30
|
+
"GammaEstimator",
|
|
31
|
+
"SampleSizeEstimator",
|
|
32
|
+
"SampleSizeEstimate",
|
|
33
|
+
"estimate_sample_size",
|
|
34
|
+
]
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DBSCAN density-based clustering algorithm.
|
|
3
|
+
|
|
4
|
+
Finds arbitrarily-shaped clusters and identifies outliers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from sklearn.cluster import DBSCAN as SKLearnDBSCAN
|
|
12
|
+
|
|
13
|
+
from bitbullet.cluster.core.base import BaseClusterer
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DBSCANClusterer(BaseClusterer):
|
|
17
|
+
"""DBSCAN clustering for arbitrary-shaped clusters.
|
|
18
|
+
|
|
19
|
+
Features:
|
|
20
|
+
- Density-based clustering (no need to specify n_clusters)
|
|
21
|
+
- Automatic outlier detection (noise points labeled as -1)
|
|
22
|
+
- Works with arbitrary cluster shapes
|
|
23
|
+
- Supports custom distance metrics
|
|
24
|
+
|
|
25
|
+
Note:
|
|
26
|
+
DBSCAN does not support prediction on new data. Use fit_predict
|
|
27
|
+
on all data at once, or use a classifier trained on DBSCAN labels.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def _validate_config(self) -> None:
|
|
31
|
+
"""Validate DBSCAN specific configuration."""
|
|
32
|
+
# DBSCAN doesn't require n_clusters (it's determined automatically)
|
|
33
|
+
# So we don't validate n_clusters
|
|
34
|
+
|
|
35
|
+
# Must have eps and min_samples
|
|
36
|
+
eps = self._config.params.get("eps")
|
|
37
|
+
min_samples = self._config.params.get("min_samples")
|
|
38
|
+
|
|
39
|
+
if eps is None:
|
|
40
|
+
raise ValueError("DBSCAN requires 'eps' parameter in config.params")
|
|
41
|
+
|
|
42
|
+
if min_samples is None:
|
|
43
|
+
raise ValueError("DBSCAN requires 'min_samples' parameter in config.params")
|
|
44
|
+
|
|
45
|
+
if eps <= 0:
|
|
46
|
+
raise ValueError(f"eps must be positive, got {eps}")
|
|
47
|
+
|
|
48
|
+
if min_samples < 1:
|
|
49
|
+
raise ValueError(f"min_samples must be >= 1, got {min_samples}")
|
|
50
|
+
|
|
51
|
+
def _fit(self, data: pd.DataFrame, verbose: bool = False) -> None:
|
|
52
|
+
"""Fit DBSCAN to data.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
data: Input data
|
|
56
|
+
verbose: Print progress messages
|
|
57
|
+
"""
|
|
58
|
+
# Extract parameters
|
|
59
|
+
eps = self._config.params.get("eps", 0.5)
|
|
60
|
+
min_samples = self._config.params.get("min_samples", 5)
|
|
61
|
+
metric = self._config.params.get("metric", "euclidean")
|
|
62
|
+
algorithm = self._config.params.get("algorithm", "auto")
|
|
63
|
+
leaf_size = self._config.params.get("leaf_size", 30)
|
|
64
|
+
|
|
65
|
+
if verbose:
|
|
66
|
+
print(f"Fitting DBSCAN (eps={eps}, min_samples={min_samples}, metric={metric})...")
|
|
67
|
+
|
|
68
|
+
# ── Mixed-distance path ───────────────────────────────────────────────
|
|
69
|
+
# When metric == 'mixed', DBSCAN consumes a precomputed pairwise
|
|
70
|
+
# distance matrix built from the same k-prototypes hybrid distance
|
|
71
|
+
# formula (numerical Euclidean + γ · weighted-matching) — the same
|
|
72
|
+
# math the Clusterability Diagnostic γ slider previews. This is how
|
|
73
|
+
# DBSCAN supports mixed numerical+categorical data without dragging
|
|
74
|
+
# in Gower as an external dep.
|
|
75
|
+
if metric == "mixed":
|
|
76
|
+
from bitbullet.cluster.distance import compute_mixed_distance_matrix
|
|
77
|
+
from bitbullet.cluster.weights.categorical_weights import CategoricalWeights
|
|
78
|
+
from bitbullet.cluster.weights.gamma_estimation import GammaEstimator
|
|
79
|
+
|
|
80
|
+
numerical_cols = self._config.numerical_columns or []
|
|
81
|
+
categorical_cols = self._config.categorical_columns or []
|
|
82
|
+
if not numerical_cols and not categorical_cols:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
"DBSCAN with metric='mixed' requires at least one numerical "
|
|
85
|
+
"or categorical feature selection."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
num_df = data[numerical_cols] if numerical_cols else None
|
|
89
|
+
cat_df = data[categorical_cols] if categorical_cols else None
|
|
90
|
+
|
|
91
|
+
# Resolve γ: heuristic string ('huang' / 'variance_matching') or
|
|
92
|
+
# a concrete float (manual). Mirrors k-prototypes' contract so a
|
|
93
|
+
# user toggling between the two algorithms sees identical γ
|
|
94
|
+
# semantics.
|
|
95
|
+
gamma_raw = self._config.params.get("gamma", "huang")
|
|
96
|
+
gamma_mode = "fixed"
|
|
97
|
+
if isinstance(gamma_raw, str):
|
|
98
|
+
gamma_mode = gamma_raw
|
|
99
|
+
gamma_value = GammaEstimator.calculate(
|
|
100
|
+
data, numerical_cols, categorical_cols, method=gamma_raw
|
|
101
|
+
) if numerical_cols and categorical_cols else 1.0
|
|
102
|
+
else:
|
|
103
|
+
gamma_value = float(gamma_raw)
|
|
104
|
+
|
|
105
|
+
# Categorical weights — same factory k-prototypes uses.
|
|
106
|
+
cat_weights_raw = self._config.params.get("categorical_weights", "relevance")
|
|
107
|
+
cat_weights_method = "manual"
|
|
108
|
+
if isinstance(cat_weights_raw, str):
|
|
109
|
+
cat_weights_method = cat_weights_raw
|
|
110
|
+
if categorical_cols:
|
|
111
|
+
cat_weights = CategoricalWeights.calculate(
|
|
112
|
+
data, method=cat_weights_raw, categorical_columns=categorical_cols
|
|
113
|
+
)
|
|
114
|
+
else:
|
|
115
|
+
cat_weights = None
|
|
116
|
+
elif cat_weights_raw is None:
|
|
117
|
+
cat_weights = None
|
|
118
|
+
cat_weights_method = "none"
|
|
119
|
+
else:
|
|
120
|
+
cat_weights = CategoricalWeights.validate_manual_weights(
|
|
121
|
+
cat_weights_raw, categorical_cols
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
distance_matrix = compute_mixed_distance_matrix(
|
|
125
|
+
num_df=num_df,
|
|
126
|
+
cat_df=cat_df,
|
|
127
|
+
gamma=float(gamma_value),
|
|
128
|
+
cat_weights=cat_weights,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
sklearn_metric = "precomputed"
|
|
132
|
+
sklearn_input = distance_matrix
|
|
133
|
+
mixed_metadata = {
|
|
134
|
+
"gamma_value": float(gamma_value),
|
|
135
|
+
"gamma_mode": gamma_mode,
|
|
136
|
+
"categorical_weighting_method": cat_weights_method,
|
|
137
|
+
"categorical_weights": cat_weights,
|
|
138
|
+
}
|
|
139
|
+
else:
|
|
140
|
+
sklearn_metric = metric
|
|
141
|
+
sklearn_input = data
|
|
142
|
+
mixed_metadata = None
|
|
143
|
+
|
|
144
|
+
# Create DBSCAN instance
|
|
145
|
+
clusterer = SKLearnDBSCAN(
|
|
146
|
+
eps=eps,
|
|
147
|
+
min_samples=min_samples,
|
|
148
|
+
metric=sklearn_metric,
|
|
149
|
+
algorithm=algorithm,
|
|
150
|
+
leaf_size=leaf_size,
|
|
151
|
+
n_jobs=-1,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Fit the model
|
|
155
|
+
labels = clusterer.fit_predict(sklearn_input)
|
|
156
|
+
|
|
157
|
+
# Count clusters (excluding noise points labeled -1)
|
|
158
|
+
n_clusters = len(set(labels)) - (1 if -1 in labels else 0)
|
|
159
|
+
n_noise = np.sum(labels == -1)
|
|
160
|
+
|
|
161
|
+
# Store results
|
|
162
|
+
self._state.labels = labels
|
|
163
|
+
self._state.n_clusters_actual = n_clusters
|
|
164
|
+
|
|
165
|
+
# DBSCAN doesn't have explicit centroids, but we can calculate them
|
|
166
|
+
# as the mean of points in each cluster (excluding noise). Categorical
|
|
167
|
+
# columns get the mode; only numerical columns get a true mean.
|
|
168
|
+
centroids = []
|
|
169
|
+
numerical_only = data.select_dtypes(include="number")
|
|
170
|
+
for cluster_id in range(n_clusters):
|
|
171
|
+
mask = labels == cluster_id
|
|
172
|
+
if np.any(mask) and not numerical_only.empty:
|
|
173
|
+
centroid = numerical_only[mask].mean(axis=0).values
|
|
174
|
+
centroids.append(centroid)
|
|
175
|
+
|
|
176
|
+
if centroids:
|
|
177
|
+
self._state.cluster_centers = np.array(centroids)
|
|
178
|
+
else:
|
|
179
|
+
self._state.cluster_centers = None
|
|
180
|
+
|
|
181
|
+
# Store fitted parameters
|
|
182
|
+
self._state.fitted_params = {
|
|
183
|
+
"eps": eps,
|
|
184
|
+
"min_samples": min_samples,
|
|
185
|
+
"metric": metric,
|
|
186
|
+
"n_noise_points": int(n_noise),
|
|
187
|
+
"n_clusters_found": n_clusters,
|
|
188
|
+
"core_sample_indices": clusterer.core_sample_indices_.tolist() if len(clusterer.core_sample_indices_) > 0 else [],
|
|
189
|
+
**({"mixed_distance": mixed_metadata} if mixed_metadata else {}),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if verbose:
|
|
193
|
+
print(f"Found {n_clusters} clusters and {n_noise} noise points")
|
|
194
|
+
|
|
195
|
+
def _predict(self, data: pd.DataFrame) -> np.ndarray:
|
|
196
|
+
"""DBSCAN does not support prediction on new data.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
data: New data
|
|
200
|
+
|
|
201
|
+
Raises:
|
|
202
|
+
NotImplementedError: DBSCAN does not support prediction
|
|
203
|
+
"""
|
|
204
|
+
raise NotImplementedError(
|
|
205
|
+
"DBSCAN does not support prediction on new data. "
|
|
206
|
+
"Either:\n"
|
|
207
|
+
"1. Include all data in fit_predict()\n"
|
|
208
|
+
"2. Train a classifier (e.g., KNN) on DBSCAN labels to predict new data\n"
|
|
209
|
+
"3. Use a different algorithm that supports prediction (e.g., K-Means)"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def get_noise_points(self) -> np.ndarray:
|
|
213
|
+
"""Get indices of points classified as noise.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Array of indices where labels == -1
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
RuntimeError: If not fitted
|
|
220
|
+
"""
|
|
221
|
+
if not self.is_fitted:
|
|
222
|
+
raise RuntimeError("Must fit before getting noise points")
|
|
223
|
+
|
|
224
|
+
return np.where(self._state.labels == -1)[0]
|
|
225
|
+
|
|
226
|
+
def get_core_points(self) -> np.ndarray:
|
|
227
|
+
"""Get indices of core points (points with >= min_samples neighbors).
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Array of core point indices
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
RuntimeError: If not fitted
|
|
234
|
+
"""
|
|
235
|
+
if not self.is_fitted:
|
|
236
|
+
raise RuntimeError("Must fit before getting core points")
|
|
237
|
+
|
|
238
|
+
return np.array(self._state.fitted_params["core_sample_indices"])
|
|
239
|
+
|
|
240
|
+
def get_n_clusters(self) -> int:
|
|
241
|
+
"""Get number of clusters found (excluding noise).
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Number of clusters
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
RuntimeError: If not fitted
|
|
248
|
+
"""
|
|
249
|
+
if not self.is_fitted:
|
|
250
|
+
raise RuntimeError("Must fit before getting n_clusters")
|
|
251
|
+
|
|
252
|
+
return self._state.n_clusters_actual
|
|
File without changes
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Gaussian Mixture Model (GMM) clustering algorithm.
|
|
3
|
+
|
|
4
|
+
Probabilistic soft clustering assuming Gaussian distributions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from sklearn.mixture import GaussianMixture
|
|
12
|
+
|
|
13
|
+
from bitbullet.cluster.core.base import BaseClusterer
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GMMClusterer(BaseClusterer):
|
|
17
|
+
"""Gaussian Mixture Model for probabilistic clustering.
|
|
18
|
+
|
|
19
|
+
Features:
|
|
20
|
+
- Soft clustering (probabilities for each cluster)
|
|
21
|
+
- Models overlapping clusters
|
|
22
|
+
- Multiple covariance types (full, tied, diag, spherical)
|
|
23
|
+
- Supports convergence diagnostics
|
|
24
|
+
|
|
25
|
+
Note:
|
|
26
|
+
GMM only works with numerical features. For mixed data,
|
|
27
|
+
use K-Prototypes instead.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def _validate_config(self) -> None:
|
|
31
|
+
"""Validate GMM specific configuration."""
|
|
32
|
+
if self._config.n_clusters is None or self._config.n_clusters < 1:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
f"n_clusters must be >= 1 for GMM, got {self._config.n_clusters}"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# GMM doesn't support categorical columns
|
|
38
|
+
if self._config.categorical_columns:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
"GMM does not support categorical columns. "
|
|
41
|
+
"Use K-Prototypes for mixed data types."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def _fit(self, data: pd.DataFrame, verbose: bool = False) -> None:
|
|
45
|
+
"""Fit GMM to numerical data.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
data: Input data with only numerical features
|
|
49
|
+
verbose: Print progress messages
|
|
50
|
+
"""
|
|
51
|
+
# Extract parameters
|
|
52
|
+
covariance_type = self._config.params.get("covariance_type", "full")
|
|
53
|
+
n_init = self._config.params.get("n_init", 1)
|
|
54
|
+
max_iter = self._config.params.get("max_iter", 100)
|
|
55
|
+
tol = self._config.params.get("tol", 1e-3)
|
|
56
|
+
reg_covar = self._config.params.get("reg_covar", 1e-6)
|
|
57
|
+
init_params = self._config.params.get("init_params", "kmeans")
|
|
58
|
+
|
|
59
|
+
if verbose:
|
|
60
|
+
print(f"Fitting GMM (n_components={self._config.n_clusters}, "
|
|
61
|
+
f"covariance_type={covariance_type})...")
|
|
62
|
+
|
|
63
|
+
# Create GMM instance
|
|
64
|
+
gmm = GaussianMixture(
|
|
65
|
+
n_components=self._config.n_clusters,
|
|
66
|
+
covariance_type=covariance_type,
|
|
67
|
+
n_init=n_init,
|
|
68
|
+
max_iter=max_iter,
|
|
69
|
+
tol=tol,
|
|
70
|
+
reg_covar=reg_covar,
|
|
71
|
+
init_params=init_params,
|
|
72
|
+
random_state=self._config.random_state,
|
|
73
|
+
verbose=int(verbose),
|
|
74
|
+
verbose_interval=10,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Fit the model
|
|
78
|
+
gmm.fit(data)
|
|
79
|
+
labels = gmm.predict(data)
|
|
80
|
+
|
|
81
|
+
# Store results
|
|
82
|
+
self._state.labels = labels
|
|
83
|
+
self._state.n_clusters_actual = self._config.n_clusters
|
|
84
|
+
self._state.cluster_centers = gmm.means_
|
|
85
|
+
|
|
86
|
+
# Store fitted parameters (needed for prediction)
|
|
87
|
+
self._state.fitted_params = {
|
|
88
|
+
"converged": bool(gmm.converged_),
|
|
89
|
+
"n_iter": int(gmm.n_iter_),
|
|
90
|
+
"lower_bound": float(gmm.lower_bound_),
|
|
91
|
+
"covariances": gmm.covariances_.tolist(),
|
|
92
|
+
"weights": gmm.weights_.tolist(),
|
|
93
|
+
"precisions_cholesky": gmm.precisions_cholesky_.tolist(),
|
|
94
|
+
"covariance_type": covariance_type,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if verbose:
|
|
98
|
+
converged = "converged" if gmm.converged_ else "did NOT converge"
|
|
99
|
+
print(f"GMM {converged} after {gmm.n_iter_} iterations. "
|
|
100
|
+
f"Log-likelihood: {gmm.lower_bound_:.2f}")
|
|
101
|
+
|
|
102
|
+
def _predict(self, data: pd.DataFrame) -> np.ndarray:
|
|
103
|
+
"""Assign new data to clusters using hard assignment.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
data: New data to assign to clusters
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Cluster labels (hard assignment)
|
|
110
|
+
"""
|
|
111
|
+
# Recreate GMM from fitted parameters
|
|
112
|
+
gmm = GaussianMixture(n_components=self._config.n_clusters)
|
|
113
|
+
gmm.means_ = self._state.cluster_centers
|
|
114
|
+
gmm.covariances_ = np.array(self._state.fitted_params["covariances"])
|
|
115
|
+
gmm.weights_ = np.array(self._state.fitted_params["weights"])
|
|
116
|
+
gmm.precisions_cholesky_ = np.array(self._state.fitted_params["precisions_cholesky"])
|
|
117
|
+
gmm.covariance_type = self._state.fitted_params["covariance_type"]
|
|
118
|
+
|
|
119
|
+
# Predict
|
|
120
|
+
labels = gmm.predict(data)
|
|
121
|
+
|
|
122
|
+
return labels
|
|
123
|
+
|
|
124
|
+
def predict_proba(self, data: pd.DataFrame) -> np.ndarray:
|
|
125
|
+
"""Get probability of each cluster for each data point.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
data: Data to get probabilities for
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Array of shape (n_samples, n_clusters) with probabilities
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
RuntimeError: If not fitted
|
|
135
|
+
"""
|
|
136
|
+
if not self.is_fitted:
|
|
137
|
+
raise RuntimeError("Must fit before prediction")
|
|
138
|
+
|
|
139
|
+
# Recreate GMM from fitted parameters
|
|
140
|
+
gmm = GaussianMixture(n_components=self._config.n_clusters)
|
|
141
|
+
gmm.means_ = self._state.cluster_centers
|
|
142
|
+
gmm.covariances_ = np.array(self._state.fitted_params["covariances"])
|
|
143
|
+
gmm.weights_ = np.array(self._state.fitted_params["weights"])
|
|
144
|
+
gmm.precisions_cholesky_ = np.array(self._state.fitted_params["precisions_cholesky"])
|
|
145
|
+
gmm.covariance_type = self._state.fitted_params["covariance_type"]
|
|
146
|
+
|
|
147
|
+
# Get probabilities
|
|
148
|
+
probas = gmm.predict_proba(data)
|
|
149
|
+
|
|
150
|
+
return probas
|
|
151
|
+
|
|
152
|
+
def get_centroids(self) -> pd.DataFrame:
|
|
153
|
+
"""Get cluster centroids (means) as DataFrame.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
DataFrame with centroids
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
RuntimeError: If not fitted
|
|
160
|
+
"""
|
|
161
|
+
if not self.is_fitted:
|
|
162
|
+
raise RuntimeError("Must fit before getting centroids")
|
|
163
|
+
|
|
164
|
+
centroids_df = pd.DataFrame(
|
|
165
|
+
self._state.cluster_centers,
|
|
166
|
+
columns=self._state.feature_names,
|
|
167
|
+
index=[f"cluster_{i}" for i in range(self._state.n_clusters_actual)]
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return centroids_df
|
|
171
|
+
|
|
172
|
+
def get_covariances(self) -> np.ndarray:
|
|
173
|
+
"""Get covariance matrices for each cluster.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Covariance matrices (shape depends on covariance_type)
|
|
177
|
+
|
|
178
|
+
Raises:
|
|
179
|
+
RuntimeError: If not fitted
|
|
180
|
+
"""
|
|
181
|
+
if not self.is_fitted:
|
|
182
|
+
raise RuntimeError("Must fit before getting covariances")
|
|
183
|
+
|
|
184
|
+
return np.array(self._state.fitted_params["covariances"])
|
|
185
|
+
|
|
186
|
+
def get_weights(self) -> np.ndarray:
|
|
187
|
+
"""Get mixture weights (prior probabilities of each cluster).
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Array of weights summing to 1.0
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
RuntimeError: If not fitted
|
|
194
|
+
"""
|
|
195
|
+
if not self.is_fitted:
|
|
196
|
+
raise RuntimeError("Must fit before getting weights")
|
|
197
|
+
|
|
198
|
+
return np.array(self._state.fitted_params["weights"])
|
|
199
|
+
|
|
200
|
+
def score(self, data: pd.DataFrame) -> float:
|
|
201
|
+
"""Compute log-likelihood of data under the model.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
data: Data to score
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Log-likelihood score (higher is better)
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
RuntimeError: If not fitted
|
|
211
|
+
"""
|
|
212
|
+
if not self.is_fitted:
|
|
213
|
+
raise RuntimeError("Must fit before scoring")
|
|
214
|
+
|
|
215
|
+
# Recreate GMM from fitted parameters
|
|
216
|
+
gmm = GaussianMixture(n_components=self._config.n_clusters)
|
|
217
|
+
gmm.means_ = self._state.cluster_centers
|
|
218
|
+
gmm.covariances_ = np.array(self._state.fitted_params["covariances"])
|
|
219
|
+
gmm.weights_ = np.array(self._state.fitted_params["weights"])
|
|
220
|
+
gmm.precisions_cholesky_ = np.array(self._state.fitted_params["precisions_cholesky"])
|
|
221
|
+
gmm.covariance_type = self._state.fitted_params["covariance_type"]
|
|
222
|
+
|
|
223
|
+
return gmm.score(data)
|
|
224
|
+
|
|
225
|
+
def get_bic(self, data: pd.DataFrame) -> float:
|
|
226
|
+
"""Compute Bayesian Information Criterion (BIC) for model selection.
|
|
227
|
+
|
|
228
|
+
BIC balances model fit with complexity. Lower is better.
|
|
229
|
+
Use this for elbow-like plots when selecting optimal number of clusters.
|
|
230
|
+
|
|
231
|
+
BIC = -2 * log_likelihood + n_params * log(n_samples)
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
data: Data used for fitting (same data used in fit())
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
BIC score (lower is better)
|
|
238
|
+
|
|
239
|
+
Raises:
|
|
240
|
+
RuntimeError: If not fitted
|
|
241
|
+
"""
|
|
242
|
+
if not self.is_fitted:
|
|
243
|
+
raise RuntimeError("Must fit before computing BIC")
|
|
244
|
+
|
|
245
|
+
# Recreate GMM from fitted parameters
|
|
246
|
+
gmm = GaussianMixture(n_components=self._config.n_clusters)
|
|
247
|
+
gmm.means_ = self._state.cluster_centers
|
|
248
|
+
gmm.covariances_ = np.array(self._state.fitted_params["covariances"])
|
|
249
|
+
gmm.weights_ = np.array(self._state.fitted_params["weights"])
|
|
250
|
+
gmm.precisions_cholesky_ = np.array(self._state.fitted_params["precisions_cholesky"])
|
|
251
|
+
gmm.covariance_type = self._state.fitted_params["covariance_type"]
|
|
252
|
+
|
|
253
|
+
return gmm.bic(data)
|
|
254
|
+
|
|
255
|
+
def get_aic(self, data: pd.DataFrame) -> float:
|
|
256
|
+
"""Compute Akaike Information Criterion (AIC) for model selection.
|
|
257
|
+
|
|
258
|
+
AIC is similar to BIC but with less penalty for model complexity.
|
|
259
|
+
Lower is better.
|
|
260
|
+
|
|
261
|
+
AIC = -2 * log_likelihood + 2 * n_params
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
data: Data used for fitting (same data used in fit())
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
AIC score (lower is better)
|
|
268
|
+
|
|
269
|
+
Raises:
|
|
270
|
+
RuntimeError: If not fitted
|
|
271
|
+
"""
|
|
272
|
+
if not self.is_fitted:
|
|
273
|
+
raise RuntimeError("Must fit before computing AIC")
|
|
274
|
+
|
|
275
|
+
# Recreate GMM from fitted parameters
|
|
276
|
+
gmm = GaussianMixture(n_components=self._config.n_clusters)
|
|
277
|
+
gmm.means_ = self._state.cluster_centers
|
|
278
|
+
gmm.covariances_ = np.array(self._state.fitted_params["covariances"])
|
|
279
|
+
gmm.weights_ = np.array(self._state.fitted_params["weights"])
|
|
280
|
+
gmm.precisions_cholesky_ = np.array(self._state.fitted_params["precisions_cholesky"])
|
|
281
|
+
gmm.covariance_type = self._state.fitted_params["covariance_type"]
|
|
282
|
+
|
|
283
|
+
return gmm.aic(data)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Partitional clustering algorithms."""
|
|
2
|
+
|
|
3
|
+
__all__ = ["KMeansClusterer", "KModesClusterer", "KPrototypesClusterer"]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def __getattr__(name: str):
|
|
7
|
+
if name == "KMeansClusterer":
|
|
8
|
+
from bitbullet.cluster.algorithms.partitional.kmeans import KMeansClusterer
|
|
9
|
+
|
|
10
|
+
return KMeansClusterer
|
|
11
|
+
if name == "KModesClusterer":
|
|
12
|
+
from bitbullet.cluster.algorithms.partitional.kmodes import KModesClusterer
|
|
13
|
+
|
|
14
|
+
return KModesClusterer
|
|
15
|
+
if name == "KPrototypesClusterer":
|
|
16
|
+
from bitbullet.cluster.algorithms.partitional.kprototypes import KPrototypesClusterer
|
|
17
|
+
|
|
18
|
+
return KPrototypesClusterer
|
|
19
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|