-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathstreamlit_clustering_mixed_data.py
More file actions
1012 lines (857 loc) · 44.4 KB
/
streamlit_clustering_mixed_data.py
File metadata and controls
1012 lines (857 loc) · 44.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import pandas as pd
import numpy as np
import time
import random
import streamlit as st
# plots
import seaborn as sns
import matplotlib.pyplot as plt
# HAC
from scipy.spatial.distance import squareform
from scipy.cluster.hierarchy import linkage, fcluster
from sklearn.metrics import silhouette_score
# HDBSCAN
import hdbscan
from hdbscan.validity import validity_index
# FeatureEncode
from sklearn.preprocessing import OneHotEncoder, RobustScaler
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.feature_extraction import FeatureHasher
from sklearn.preprocessing import LabelEncoder
#from category_encoders import CatBoostEncoder
# Feature weights
from scipy.stats import skew
from sklearn.feature_selection import mutual_info_classif, mutual_info_regression
# Cluster Explanation
from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor, plot_tree
from tempfile import NamedTemporaryFile
from supertree import SuperTree
from streamlit.components.v1 import html
import faiss
# Расчет ренжей для расстояния Говера
def compute_numeric_ranges(df, num_cols, method='minmax'):
"""
Возвращает словарь {col: range} для нормализации при расчете расстояния Говера
- 'minmax' : range = max - min
- 'iqr' : range = (Q3-Q1)
"""
ranges = {}
for c in num_cols:
col = df[c].dropna().to_numpy(dtype=float)
if col.size == 0:
ranges[c] = 1.0
continue
# Для скошенных данных берем логарифм признака для расчета ренжа
sk = skew(col)
if sk > 2:
shift = 1 - np.min(col)
col = np.log1p(col + max(shift, 0))
if method == 'minmax':
r = float(np.max(col) - np.min(col))
elif method == 'iqr':
q1 = np.percentile(col, 25)
q3 = np.percentile(col, 75)
r = float((q3 - q1) * 1.5)
else:
raise ValueError("unknown method")
# Если range слишком мал, то ставим его 1.0
if not np.isfinite(r) or r <= 1e-8:
r = 1.0
ranges[c] = r
return ranges
# Расчет весов для расстояния Говера
def compute_feature_weights(df, target, num_cols, cat_cols):
"""
Считает веса признаков по взаимной информации (MI) с таргетом
df : Входные данные
target : pd.Series или np.array таргет (числовой или категориальный)
output:
weights : dict
{column_name: weight}, веса нормированы (сумма = число признаков).
"""
y = target.values
# Кодируем категориальные
df_enc = df.copy()
discrete_features = []
for c in cat_cols:
le = LabelEncoder()
df_enc[c] = le.fit_transform(df_enc[c].astype(str))
discrete_features.append(df.columns.get_loc(c)) # индексы категориальных
X = df_enc.to_numpy()
if not discrete_features:
discrete_features = False
# Вычисляем MI
if pd.api.types.is_numeric_dtype(target) == True:
mi = mutual_info_regression(X, y, discrete_features=discrete_features, random_state=42)
elif pd.api.types.is_object_dtype(target) == True:
mi = mutual_info_classif(X, y, discrete_features=discrete_features, random_state=42)
else:
raise ValueError("Тип целевой переменной должен быть числовым или категориальным")
# Нормируем веса
mi = np.maximum(mi, 1e-9) # чтобы не было нулевых
mi_normalized = mi / mi.sum() * len(mi)
weights = {col: w for col, w in zip(df.columns, mi_normalized)}
return weights
# Расчет расстояния Говера
def compute_gower_matrix(df, num_cols, cat_cols, num_ranges_method='iqr', weights=None):
"""
- numeric_ranges: dict col->range. Если None, computed by minmax on df.
- weights: dict col->weight. Если None - все 1
"""
X = df.reset_index(drop=True)
n = len(X)
if n == 0:
return np.zeros((0,0), dtype=np.float32)
numeric_ranges = compute_numeric_ranges(X, num_cols, method=num_ranges_method)
D = np.zeros((n, n), dtype=np.float64) # accumulate in float64 for numeric stability
# weights
if weights is None:
# equal weight per feature
w_num = {c: 1.0 for c in num_cols}
w_cat = {c: 1.0 for c in cat_cols}
else:
# user-provided; missing keys default to 1.0
w_num = {c: float(weights.get(c, 1.0)) for c in num_cols}
w_cat = {c: float(weights.get(c, 1.0)) for c in cat_cols}
# high-cardinality weighting
for c in cat_cols:
if X[c].nunique() > 100:
w_cat[c] = w_cat.get(c, 1.0) / np.sqrt(X[c].nunique())
# numeric part
for c in num_cols:
col = X[c].to_numpy(dtype=float)
# Для скошенных данных берем логарифм признака для расчета Gower distance
# и соответственно для кластеризации
sk = skew(col)
if sk > 2:
shift = 1 - np.min(col)
col = np.log1p(col + max(shift, 0))
rng = numeric_ranges.get(c, 1.0)
# normalized differences (broadcast)
mat = np.abs(col[:, None] - col[None, :]) / rng
mat = np.clip(mat, 0, 1)
D += w_num.get(c, 1.0) * mat
# categorical part
for c in cat_cols:
col = X[c].astype(str).to_numpy()
neq = (col[:, None] != col[None, :]).astype(np.float64)
D += w_cat.get(c, 1.0) * neq
# normalize by total weights sum
total_weight = sum(w_num.values()) + sum(w_cat.values())
if total_weight <= 0:
total_weight = 1.0
D = (D / float(total_weight))
return D.astype(np.float64)
### Кластеризация
def clusterize(df, D, method="HDBSCAN", max_k=20):
"""
Кластеризация на подвыборке.
method = "hdbscan" или "hac"
"""
if method == "HDBSCAN":
best_score = -1
best_params = None
best_labels = None
d = df.shape[1]
# specify parameters and distributions to sample from
param_grid = [
{"min_cluster_size": mcs, "min_samples": ms}
for mcs in [10, 20, 30, 50, 100]
for ms in [5, 10, 20, 30]
if ms <= mcs
]
for params in param_grid:
cl = hdbscan.HDBSCAN(metric="precomputed", **params)
labels = cl.fit_predict(D)
n_clusters = len(set(labels)) - (1 if -1 in labels else 0)
if n_clusters < 2 or np.all(labels == -1):
continue
score = validity_index(
D,
labels,
metric="precomputed",
d=d
)
if not np.isfinite(score):
continue
if score > best_score:
best_score = score
best_params = params
best_labels = labels
elif method == "HAC":
# преобразуем матрицу в condensed form
condensed = squareform(D, checks=False)
# linkage (average linkage лучше для Gower)
Z = linkage(condensed, method="complete")
# поиск оптимального k по silhouette
best_k, best_score, best_labels = None, -1, None
for k in range(2, min(max_k, len(df)//10)):
labels = fcluster(Z, k, criterion="maxclust")
if len(np.unique(labels)) < 2:
continue
try:
score = silhouette_score(D, labels, metric="precomputed")
except Exception:
continue
if score > best_score:
best_score, best_k, best_labels = score, k, labels
if best_labels is None:
# fallback: всё в один кластер
best_labels = np.ones(len(df), dtype=int)
labels = best_labels
else:
raise ValueError("method must be 'HDBSCAN' or 'HAC'")
return labels
### Векторизация данных для FAISS
def vectorize(df, num_cols, cat_cols, ohe_thresh=20, hash_dim=32):
"""
Преобразует таблицу в числовой массив для кластеризации / поиска соседей.
Parameters:
df : pd.DataFrame
numeric_cols : list[str] - числовые колонки
cat_cols : list[str] - категориальные колонки
ohe_thresh : int - макс число уникальных категорий для OHE
hash_dim : int - размер выходного вектора для FeatureHasher
output:
X : np.ndarray, float32
"""
# --- числовые ---
scaler = RobustScaler()
X_num = scaler.fit_transform(df[num_cols]) if num_cols else None
# --- категориальные ---
X_cat_list = []
encoders = {}
for c in cat_cols:
n_unique = df[c].nunique()
col_data = df[[c]].astype(str)
if n_unique <= ohe_thresh:
# OHE
enc = OneHotEncoder(sparse_output=False, handle_unknown='ignore')
X_c = enc.fit_transform(col_data)
encoders[c] = enc
else:
# FeatureHasher
enc = FeatureHasher(n_features=hash_dim, input_type='string')
# FeatureHasher принимает список словарей: [{cat_val: 1}, ...]
X_c = enc.transform([{val: 1} for val in col_data[c]]).toarray()
encoders[c] = enc
X_cat_list.append(X_c)
if X_cat_list:
X_cat = np.hstack(X_cat_list)
else:
X_cat = None
# --- объединение ---
if X_num is not None and X_cat is not None:
X = np.hstack([X_num, X_cat])
elif X_num is not None:
X = X_num
else:
X = X_cat
return X.astype(np.float32)
#### FAISS на сэпмлированной выборке
def build_faiss_hnsw(X_S, M=32, efSearch=64):
"""
Создаёт HNSW индекс FAISS для подвыборки X_S
X_S : сэмплированный массив от кластерзиации
M : число соседей в графе HNSW
efSearch : ширина поиска
output:
index : faiss.IndexHNSWFlat
"""
dim = X_S.shape[1]
index = faiss.IndexHNSWFlat(dim, M)
index.hnsw.efSearch = efSearch
index.add(X_S)
return index
###### Присваивание лейблом через FAISS HSNW
def assign_faiss(index, X_rest, labels_S, k=3, ood_threshold=None, weighted=True):
"""
Приписывает новые точки к кластерам из labels_S через HNSW
index : faiss.IndexHNSWFlat, построенный на кластеризованном сэмпле X_S
X_rest : новые точки из некластеризованной выборки
labels_S : метки кластеров
k : число ближайших соседей
ood_threshold : float или None, порог для OOD; если None - вычисляется
как 99-й перцентиль расстояний в S
weighted : bool, использовать взвешенное голосование по расстоянию
output:
assigned_labels : присвоенные лейбл
assign_distance : расстояние до выбранного кластера
is_OOD : True если объект OOD
"""
n_S = labels_S.shape[0]
k = min(k, n_S)
D, I = index.search(X_rest, k) # distances & indices
n_rest = X_rest.shape[0]
assigned_labels = np.empty(n_rest, dtype=int)
assign_distance = np.empty(n_rest, dtype=float)
is_OOD = np.zeros(n_rest, dtype=bool)
# OOD threshold если не задан
if ood_threshold is None:
# эмпирический 99-й перцентиль всех расстояний внутри S
# D_self: расстояния до 1-го ближайшего соседа в S (exclude self)
D_self, _ = index.search(index.reconstruct_n(0, n_S), 2)
D_self = D_self[:, 1] # первый сосед = сам объект, берем 2-й
ood_threshold = np.quantile(D_self, 0.99)
for i in range(n_rest):
neigh_idxs = I[i]
neigh_dists = D[i]
neigh_labels = labels_S[neigh_idxs]
if weighted:
# взвешенное голосование: 1/(dist+1e-9)
weights = 1.0 / (neigh_dists + 1e-9)
label_score = {}
for lbl, w in zip(neigh_labels, weights):
label_score[lbl] = label_score.get(lbl, 0.0) + w
# выбираем label с максимальной суммы весов
assigned_label = max(label_score.items(), key=lambda x: x[1])[0]
else:
# через ArgMax
vals, counts = np.unique(neigh_labels, return_counts=True)
assigned_label = vals[np.argmax(counts)]
assigned_labels[i] = assigned_label
# расстояние до ближайшего соседа с выбранным label
mask = neigh_labels == assigned_label
assign_distance[i] = neigh_dists[mask].min()
# OOD
is_OOD[i] = assign_distance[i] > ood_threshold
return assigned_labels, assign_distance, is_OOD
#################### Анализ кластеризации ##################################
### Общий анализ кластеров
def analyze_all_clusters(df,
target_col,
num_cols,
cat_cols,
cluster_col="cluster",
max_depth=6,
show_outlier=False):
results = {}
df.reset_index(inplace=True, drop=True)
if target_col!="No Target":
# Распределение таргета по кластерам
if pd.api.types.is_numeric_dtype(df[target_col]):
summary = df.groupby(cluster_col)[target_col].agg(
["count", "mean", "std", "min", "max", "median"]
).sort_values("mean", ascending=False)
summary.columns = [
"Количество объектов",
"Среднее значение таргета",
"Стд. откл.",
"Минимальное значение",
"Максимальное",
"Медиана"
]
else:
counts = (
df.groupby([cluster_col, target_col])
.size()
.reset_index(name="count")
)
total = counts.groupby(cluster_col)["count"].transform("sum")
counts["share"] = (counts["count"] / total * 100).round(2)
# Наиболее частая категория в каждом кластере
mode_df = (
counts.sort_values(["cluster", "count"], ascending=[True, False])
.drop_duplicates(subset=[cluster_col])
.rename(columns={target_col: "most_frequent"})
.loc[:, [cluster_col, "most_frequent"]]
)
summary = (
counts.pivot(index=cluster_col, columns=target_col, values="share")
.fillna(0)
)
summary.columns = [f"{col} (% в кластере)" for col in summary.columns]
summary["Самая частая категория"] = mode_df.set_index(cluster_col)["most_frequent"]
summary["Всего объектов"] = counts.groupby(cluster_col)["count"].sum().values
else:
summary = None
# Объяснение кластеров через DecisionTreeClassifier
if show_outlier==True:
X = df[num_cols + cat_cols]
y = df[cluster_col].astype(str)
else:
X = df.query('cluster > -1')[num_cols + cat_cols]
y = df.query('cluster > -1')[cluster_col].astype(str)
preproc = ColumnTransformer(
transformers=[
("num", "passthrough", num_cols),
("cat", OneHotEncoder(sparse_output=False, handle_unknown="ignore"), cat_cols)
]
)
clf = Pipeline([
("prep", preproc),
("tree", DecisionTreeClassifier(
max_depth=max_depth,
class_weight="balanced",
min_impurity_decrease=0.01,
criterion='entropy',
random_state=42))
])
clf.fit(X, y)
#### Строим дерево объяснющее кластеры
super_tree = SuperTree(
clf.named_steps["tree"],
clf.named_steps["prep"].transform(X),
y,
clf.named_steps["prep"].get_feature_names_out(),
np.unique(y))
importances = pd.Series(
clf.named_steps["tree"].feature_importances_,
index=clf.named_steps["prep"].get_feature_names_out()
).sort_values(ascending=False)
importances.name = "Важность признака"
results["cluster_importances"] = importances
return summary, super_tree, importances
### Общий анализ внутри кластеров
def analyze_within_clusters(df, target_col, num_cols, cat_cols, cluster_col="cluster", cluster=0, max_depth=5):
results = {}
df.reset_index(inplace=True, drop=True)
preproc = ColumnTransformer(
transformers=[
("num", "passthrough", num_cols),
("cat", OneHotEncoder(sparse_output=False, handle_unknown="ignore"), cat_cols)
]
)
# Анализ таргета внутри каждого кластера
target_results = {}
subset = df[df[cluster_col] == cluster]
X_sub = subset[num_cols + cat_cols]
y_sub = subset[target_col]
# Если целевой признак числовой DecisionTreeRegresor
if pd.api.types.is_numeric_dtype(subset[target_col]) == True:
model = Pipeline([
("prep", preproc),
("tree", DecisionTreeRegressor(max_depth=max_depth, random_state=42))
])
model.fit(X_sub, y_sub)
class_names = None
# Если целевой признак категориальный DecisionTreeClassifier
elif pd.api.types.is_object_dtype(subset[target_col]) == True:
le = LabelEncoder()
y_enc = le.fit_transform(y_sub)
model = Pipeline([
("prep", preproc),
("tree", DecisionTreeClassifier(max_depth=max_depth, random_state=42))
])
model.fit(X_sub, y_enc)
class_names = le.classes_
else:
raise ValueError("Тип целевой переменной должен быть числовым или категориальным")
importances = pd.Series(
model.named_steps["tree"].feature_importances_,
index=model.named_steps["prep"].get_feature_names_out()
).sort_values(ascending=False)
importances.name = "Важность признака"
target_results[cluster] = importances
super_tree = SuperTree(
model.named_steps["tree"],
model.named_steps["prep"].transform(X_sub),
y_sub.reset_index(drop=True),
model.named_steps["prep"].get_feature_names_out(),
target_names=class_names)
return super_tree, importances
######################## Streamlit layout ################################
st.set_page_config(page_title="Кластеризация смешанных данных", layout="wide")
st.title("Кластеризация смешанных данных")
st.write('''Данное приложение позволяет кластеризовать смешанные табличные данные
(числовые и категориальные) с целевым признаком (или без него) с использованием HDBSCAN или
Hierarchical agglomerative clustering, где используется предрассчитанная
метрика расстояния на основе Gower Distance. После кластеризации есть возможность
провести анализ построенных кластеров с помощью решающих деревьев и оценить статистику.
Обратите внимание, почти у всех пунктов есть ❔ при наведении на который всплывет описание
и даст более детальное пояснение по функции''')
st.header("1. Анализ и фильтрация данных")
# === 1. Загрузка файла ===
uploaded_file = st.file_uploader("Загрузите CSV или Excel файл", type=["csv", "xlsx"])
with st.expander('''Если при импорте возникли проблемы с форматом признаков
попробуйте выбрать другой CSV или числовой разделитель'''):
option_decimal = st.radio(
"Выберите десятичный разделитель",
(".", ","),
horizontal=True)
option_sep = st.text_input("Введите свой тип разделителя для CSV", value=",")
if uploaded_file is not None:
# Определяем формат и читаем файл
if uploaded_file.name.endswith(".csv"):
if option_decimal == ".":
option_thousand = ','
else:
option_thousand = None
try:
df = pd.read_csv(
uploaded_file,
sep=option_sep,
decimal=option_decimal,
thousands=option_thousand)
except (ValueError, TypeError):
st.error('Проблема в чтении файла', icon="🚨")
else:
df = pd.read_excel(uploaded_file)
# === 2. Преобразуем даты автоматически ===
for col in df.select_dtypes(exclude=[np.number]).columns:
try:
df[col] = pd.to_datetime(df[col])
except (ValueError, TypeError):
pass # не дата — пропускаем
st.subheader("Предпросмотр данных и их типы")
st.dataframe(df.head())
st.warning('''Внимательно проверьте типы признаков для успешной кластеризации.
Далее будет возможность привести признаки к числовому типу.''',
icon="⚠️")
st.dataframe(df.dtypes.to_frame().T)
# Подготовка данных
st.subheader("Подготовка данных")
# === 3. Выбор столбцов для приведения к числовому типу ===
help_to_num = '''Выберите признаки, которые по вашему мнению должны быть числовыми,
но они определяются как object скорее всего из-за наличия текстовых
случайных значений или ошибок'''
to_num_cols = st.multiselect("Выберите признаки для приведения к числовому типу",
df.columns.tolist(),
help=help_to_num
)
df[to_num_cols] = df[to_num_cols].apply(pd.to_numeric, errors='coerce')
# === 4. Выбор столбцов для удаления ===
help_drop = '''Выбрасывает ненужные для анализа и кластеризации признаки'''
drop_cols = st.multiselect('''Выберите признаки для удаления, и не забудьте
про индексные признаки''',
df.columns.tolist(),
help=help_drop
)
if drop_cols:
df = df.drop(columns=drop_cols)
# === 3. Выбор таргета ===
help_target = '''Для кластеризации наличие целевого признака необязательно,
но для статистического анализа он необходим. Если вы не хотите
использовать целевую переменную выберите в конце списка No Target'''
target_cols = df.columns.tolist()
target_cols.append("No Target")
target = st.selectbox(
"Выберите целевой признак",
target_cols,
help=help_target
)
if target!="No Target":
if df[target].nunique() > 1000 and pd.api.types.is_object_dtype(df[target]):
st.error("Целевой признак с очень высокой кардинальностью, выберите другой признак")
df.dropna(subset=[target], inplace=True)
# === 5. Фильтрация ===
help_filter = '''Отбирает признаки, по которым можно будет отфильтровать данные,
в том числе и по датам'''
filter_cols = st.multiselect(
"Выберите признаки для фильтра",
df.columns.tolist(),
help=help_filter
)
if filter_cols:
with st.expander("Открыть фильтры"):
for col in filter_cols:
col_type = df[col].dtype
if pd.api.types.is_datetime64_any_dtype(col_type):
# фильтр по дате
min_date, max_date = df[col].min(), df[col].max()
start_date, end_date = st.date_input(
f"{col}: диапазон дат",
[min_date, max_date],
key=col
)
mask = (df[col] >= pd.to_datetime(start_date)) & (df[col] <= pd.to_datetime(end_date))
df = df.loc[mask]
elif pd.api.types.is_numeric_dtype(col_type):
# фильтр по числовому диапазону
min_val, max_val = float(df[col].min()), float(df[col].max())
val_range = st.slider(
f"{col}: диапазон значений",
min_val, max_val, (min_val, max_val),
key=col
)
mask = (df[col] >= val_range[0]) & (df[col] <= val_range[1])
df = df.loc[mask]
else:
# фильтр по категориям/тексту
unique_vals = df[col].dropna().unique().tolist()
# ограничим слишком большие списки
if len(unique_vals) > 1 and len(unique_vals) <= 100:
selected_vals = st.multiselect(
f"{col}: выберите значения",
unique_vals,
default=unique_vals,
key=col
)
df = df[df[col].isin(selected_vals)]
# === 5.1 Снижение кардинальности категориальных признаков ========
if target!="No Target":
cat_cols = (df
.drop(target, axis=1)
.select_dtypes(exclude=[np.number, np.datetime64])
.columns.tolist())
else:
cat_cols = (df
.select_dtypes(exclude=[np.number, np.datetime64])
.columns.tolist())
help_card_cat = '''Для повышения качества кластеризации в категориальных признаках
можно оставить топ-10 категорий, а остальные заменить на 'Other'''
cat_cols_to_reduce = st.multiselect(
"Выберите категориальные признаки для снижения кардинальности",
cat_cols,
help=help_card_cat
)
for col in cat_cols_to_reduce:
top_cat = df[col].value_counts().head(10).index.tolist()
df[col] = np.where(df[col].isin(top_cat), df[col], col+'_Other')
# === 6. Работа с пропусками ===
missing_option = st.radio("Что делать с пропусками в данных?",
("Заполнить медианой и MISSING", "Выбросить объекты с пропусками"),
horizontal=True)
if target!="No Target":
num_cols = (df
.drop(target, axis=1)
.select_dtypes(include=[np.number])
.columns.tolist())
cat_cols = (df
.drop(target, axis=1)
.select_dtypes(exclude=[np.number, np.datetime64])
.columns.tolist())
else:
num_cols = (df
.select_dtypes(include=[np.number])
.columns.tolist())
cat_cols = (df
.select_dtypes(exclude=[np.number, np.datetime64])
.columns.tolist())
if missing_option == "Заполнить медианой и MISSING":
# Заполняем пропуски медианой, либо __MISSING__
for col in num_cols:
df[col] = df[col].fillna(df[col].median())
for col in cat_cols:
df[col] = df[col].fillna("__MISSING__").astype(str)
else:
# Выбрасываем все объекты с пропусками
df = df.dropna()
# === 7. Результат ===
st.subheader("Отфильтрованные данные")
st.warning('''У признаков с кардинальностью больше 100 редкие категории
автоматически переназначаются на 'Other'!''')
for col in cat_cols:
if df[col].nunique() > 100:
top_cat = df[col].value_counts().head(20).index.tolist()
df[col] = np.where(df[col].isin(top_cat), df[col], col+'_Other')
df = df.reset_index(drop=True)
st.write(f"Отображается {len(df.head(500))} из {len(df)} строк")
st.dataframe(df.head(500))
st.info("Еще раз внимательно проверьте типы признаков")
st.dataframe(df.dtypes.to_frame().T)
# Сохраняем данные для кластеризации
datetime_columns = df.select_dtypes(include=[np.datetime64]).columns
if target!="No Target":
y = df[target]
X = df.drop(target, axis=1)
else:
X = df.copy()
y = None
X = X.drop(datetime_columns, axis=1)
# === 8. Кластеризация ===
st.header("2. Кластеризация")
st.write('''Данная часть разбита на 2 последовательных тяжелых вычислительных этапа.
Сначала рассчитываем метрики расстояний, далее используем ее в
кластеризации данных.
При смене метода кластеризации, метрику расстояния пересчитывать необязательно.''')
### Считаем расстояние Говера
help_gower = '''Расстояние Гауэра — это мера сходства, используемая для кластеризации
наборов данных смешанного типа и вычисления степени сходства между
точками данных на основе комбинации числовых, категориальных и порядковых
атрибутов. Метод работает путём стандартизации степени сходства каждого
признака в диапазоне от 0 до 1 с последующим вычислением средневзвешенного
значения этих значений.'''
st.subheader("Расчет метрики расстояния (Gower Distance)", help=help_gower)
st.write('''Для дальнейшей кластеризации необходимо рассчитать метрику расстояний
между объектами. Сначала попробуйте произвести расчет без учета весов.''')
help_weights = '''Алгоритм может автоматически рассчитывать веса на основе важности
признаков. Это достигается с помощью метода взаимной информации,
который помогает сбалансировать вклад различных типов признаков
(непрерывных и категориальных). Он корректирует дисбаланс
переменных: устраняет недостаток невзвешенной формулы, которая
может быть обусловлена большим количеством непрерывных или
категориальных переменных.'''
option_weights = st.radio("Расчитываем веса на основе взаимной информации с целевым признаком?",
("Нет", "Да"),
horizontal=True,
help=help_weights)
if st.button("Рассчитать метрику"):
####################### FAISS INDEX ############################
# Готовим индексы для FAISS, на случай, несли выборка будет больше 8000
N = X.shape[0]
S = min(8000, N)
idx_all = np.arange(N)
idx_S = np.random.choice(idx_all, size=S, replace=False)
idx_rest = np.setdiff1d(idx_all, idx_S)
st.session_state.idx_S = idx_S
st.session_state.idx_rest = idx_rest
# Рассчитываем веса для расстояния Говера
if option_weights == "Да" and target!="No Target":
weights = compute_feature_weights(X.iloc[idx_S], y.iloc[idx_S], num_cols, cat_cols)
elif option_weights == "Да" and target=="No Target":
st.error('''Вы не используете целевую переменную в расчетах! Веса не будут
учитываться при расчете метрики!''')
weights = None
else:
weights = None
# Рассчитываем расстояния Говера
st.session_state.D = compute_gower_matrix(
X.iloc[idx_S],
num_cols,
cat_cols,
weights=weights
)
st.success("Матрица рассчитана")
#### Кластеризуем
st.subheader("Кластеризация на основе рассчитанной метрики")
help_cluster = '''1.HDBSCAN (медленный вариант) — это алгоритм кластеризации,
основанный на плотности данных. Он идентифицирует кластеры,
строя минимальное остовное дерево на основе взвешенного графа
точек данных, преобразуя его в иерархию кластеров, а затем
выбирая из неё стабильные кластеры на основе их устойчивости
на разных уровнях плотности. Лучшие гиперпарматры подбираются
автоматически с оценкой через Validity Index. Важно ⚠️:
отдельные объекты, не попадающие ни в один кластер, будут иметь
метку кластера -1.
2.HAC (быстрый вариант) - иерархическая агломеративная
кластеризация. Метод кластеризации «снизу вверх», который
начинается с того, что каждая точка данных представляет собой
отдельный кластер, и итеративно объединяет ближайшие пары
кластеров, пока не останется только один кластер. В результате
получается древовидная структура, называемая дендрограммой,
которая визуализирует иерархию слияний. Лучшее количество
кластеров подбирается автоматически с оценкой через Silhoutte score.
Важно ⚠️: отдельно стоящие объекты попадут в кластеры, не смотря на то,
что могут сильно выбиваться по значениям.'''
option_method = st.radio("Выберите метод кластеризации",
("HDBSCAN", "HAC"),
horizontal=True,
help=help_cluster)
if st.button("Начать кластеризацию"):
if hasattr(st.session_state, "D"):
st.session_state.labels = clusterize(
X.iloc[st.session_state.idx_S],
st.session_state.D,
method=option_method
)
else:
st.error("Рассчитайте расстонния Говера")
# --- FAISS HNSW index если размер выборки > 8000 ---
if X.shape[0] > 8000:
st.subheader("FAISS HNSW разметка лейблов")
X_vectorized = vectorize(X, num_cols, cat_cols)
X_S = X_vectorized[st.session_state.idx_S]
X_rest = X_vectorized[st.session_state.idx_rest]
index = build_faiss_hnsw(X_S, M=32, efSearch=64)
# --- assign остальных ---
labels_rest, dist_rest, is_ood = assign_faiss(index,
X_rest,
st.session_state.labels,
k=3
)
# --- собрать результат ---
result = df.copy()
result['cluster'] = np.nan
result['assign_distance'] = np.nan
result['is_OOD'] = False
# метки для подвыборки
result.loc[st.session_state.idx_S, 'cluster'] = st.session_state.labels
result.loc[st.session_state.idx_S, 'assign_distance'] = 0.0
result.loc[st.session_state.idx_S, 'is_OOD'] = False
# метки для остальных
result.loc[st.session_state.idx_rest, 'cluster'] = labels_rest
result.loc[st.session_state.idx_rest, 'assign_distance'] = dist_rest
result.loc[st.session_state.idx_rest, 'is_OOD'] = is_ood
st.session_state.result = result
st.success(f"Кластеризация методом {option_method} с доразметкой FAISS выполнена успешно")
else:
result = df.copy()
result.loc[st.session_state.idx_S, 'cluster'] = st.session_state.labels
st.session_state.result = result
st.success(f"Кластеризация методом {option_method} выполнена успешно")
# === 9. Анализ кластеризации ===
if hasattr(st.session_state, "result"):
st.subheader("Результат")
st.write(f"Отображается {len(st.session_state.result.head(500))}",
f"из {len(st.session_state.result)} строк")
st.dataframe(st.session_state.result.head(500))
st.download_button(
label="Скачать результаты как CSV",
data=st.session_state.result.to_csv(index=False).encode('utf-8'),
file_name="result.csv",
mime="text/csv",
)
help_analyze = '''Анализ построенных кластеров с помощью решающих деревьев
и статистические/количественные оценки'''
st.header("3. Анализ кластеризации", help=help_analyze)
help_overlclust = '''Смотрим как целевой признак распределен по кластерам,
какие признаки формируют кластер'''
st.subheader("Общекластерный анализ", help=help_overlclust)
option_tree_depth = st.radio(
"Выберите глубину объясняющего дерева для анализа",
list(range(3, 7)),
horizontal=True
)
if option_method=="HDBSCAN":
option_outliers = st.checkbox("Учитывать выбросы при HDBSCAN для " +
"построения (для лучшего разбиения лучше не учитывать)")
else:
option_outliers = False
if st.button("Начать анализ", key="1"):
(st.session_state.summary,
st.session_state.super_tree,
st.session_state.importances
) = analyze_all_clusters(
st.session_state.result,
target,
num_cols,
cat_cols,
max_depth=option_tree_depth,
show_outlier=option_outliers
)
with NamedTemporaryFile(suffix=".html", delete=False) as f:
st.session_state.super_tree.save_html(f.name)
st.session_state.super_tree_html = open(f.name, "r", encoding="utf-8").read()
if hasattr(st.session_state, "super_tree"):
st.text("Правила, объясняющие кластеры")
if "super_tree_html" in st.session_state:
html(st.session_state.super_tree_html, height=650)
st.text(f"Распределение целевого признака {target} по кластерам:")
st.dataframe(st.session_state.summary)
st.text("\n Главные признаки, формирующие кластеры:")
st.dataframe(st.session_state.importances.head(10))
if target!="No Target":
help_intraclust = '''Смотрим какие признаки влияют на целевой признак
внутри каждого кластера'''
st.subheader("Внутрикластерный анализ", help=help_intraclust)
option_cluster = st.selectbox(
"Выберите кластер для внутрикластерного анализа",
st.session_state.result['cluster'].unique()
)
option_cluster_tree_depth = st.radio(
"Выберите глубину дерева для внутрикластерного анализа",
list(range(3, 6)),
horizontal=True
)
if st.button("Начать анализ", key="2"):
(st.session_state.cluster_tree,
st.session_state.importances
) = analyze_within_clusters(
st.session_state.result,
target,
num_cols,
cat_cols,
cluster=option_cluster,