hossam 0.4.13__py3-none-any.whl → 0.4.15__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.
- hossam/__init__.py +23 -16
- hossam/hs_cluster.py +777 -40
- hossam/hs_plot.py +322 -218
- hossam/hs_study.py +76 -0
- {hossam-0.4.13.dist-info → hossam-0.4.15.dist-info}/METADATA +1 -1
- {hossam-0.4.13.dist-info → hossam-0.4.15.dist-info}/RECORD +9 -8
- {hossam-0.4.13.dist-info → hossam-0.4.15.dist-info}/WHEEL +1 -1
- {hossam-0.4.13.dist-info → hossam-0.4.15.dist-info}/licenses/LICENSE +0 -0
- {hossam-0.4.13.dist-info → hossam-0.4.15.dist-info}/top_level.txt +0 -0
hossam/hs_cluster.py
CHANGED
|
@@ -1,29 +1,299 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# ===================================================================
|
|
3
|
+
# 패키지 참조
|
|
4
|
+
# ===================================================================
|
|
5
|
+
import numpy as np
|
|
6
|
+
import concurrent.futures as futures
|
|
5
7
|
|
|
6
8
|
from . import hs_plot
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
from tqdm.auto import tqdm
|
|
11
|
+
from itertools import combinations
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
13
|
+
from typing import Literal, Callable
|
|
14
|
+
from kneed import KneeLocator
|
|
15
|
+
from pandas import Series, DataFrame, MultiIndex, concat
|
|
16
|
+
from matplotlib.pyplot import Axes # type: ignore
|
|
17
|
+
|
|
18
|
+
from sklearn.cluster import KMeans, DBSCAN
|
|
19
|
+
from sklearn.neighbors import NearestNeighbors
|
|
20
|
+
from sklearn.metrics import silhouette_score, adjusted_rand_score
|
|
21
|
+
|
|
22
|
+
from scipy.stats import normaltest
|
|
23
|
+
|
|
24
|
+
RANDOM_STATE = 52
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ===================================================================
|
|
28
|
+
# K-평균 군집화 모델을 적합하는 함수.
|
|
29
|
+
# ===================================================================
|
|
30
|
+
def kmeans_fit(
|
|
31
|
+
data: DataFrame, n_clusters: int, random_state: int = RANDOM_STATE, plot: bool = False,
|
|
32
|
+
fields: list[list[str]] | None = None,
|
|
33
|
+
**params
|
|
34
|
+
) -> tuple[KMeans, DataFrame]:
|
|
35
|
+
"""
|
|
36
|
+
K-평균 군집화 모델을 적합하는 함수.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
data (DataFrame): 군집화할 데이터프레임.
|
|
40
|
+
n_clusters (int): 군집 개수.
|
|
41
|
+
random_state (int, optional): 랜덤 시드. 기본값은 RANDOM_STATE.
|
|
42
|
+
plot (bool, optional): True면 결과를 시각화함. 기본값 False.
|
|
43
|
+
fields (list[list[str]] | None, optional): 시각화할 필드 쌍 리스트. 기본값 None이면 수치형 컬럼의 모든 조합 사용.
|
|
44
|
+
**params: KMeans에 전달할 추가 파라미터.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
KMeans: 적합된 KMeans 모델.
|
|
48
|
+
DataFrame: 클러스터 결과가 포함된 데이터 프레임
|
|
49
|
+
"""
|
|
50
|
+
df = data.copy()
|
|
51
|
+
kmeans = KMeans(n_clusters=n_clusters, random_state=random_state, **params)
|
|
52
|
+
kmeans.fit(data)
|
|
53
|
+
df["cluster"] = kmeans.predict(df)
|
|
54
|
+
|
|
55
|
+
if plot:
|
|
56
|
+
cluster_plot(
|
|
57
|
+
estimator=kmeans,
|
|
58
|
+
data=data,
|
|
59
|
+
fields=fields,
|
|
60
|
+
title=f"K-Means Clustering (k={n_clusters})"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return kmeans, df
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ===================================================================
|
|
67
|
+
# K-평균 군집화에서 엘보우(Elbow) 기법을 활용해 최적의 K값을 탐지하는 함수.
|
|
68
|
+
# ===================================================================
|
|
69
|
+
def kmeans_elbow(
|
|
70
|
+
data: DataFrame,
|
|
71
|
+
k_range: list | tuple = [2, 11],
|
|
72
|
+
S: float = 0.1,
|
|
73
|
+
random_state: int = RANDOM_STATE,
|
|
74
|
+
plot: bool = True,
|
|
75
|
+
title: str = None,
|
|
76
|
+
marker: str = None,
|
|
77
|
+
width: int = hs_plot.config.width,
|
|
78
|
+
height: int = hs_plot.config.height,
|
|
79
|
+
dpi: int = hs_plot.config.dpi,
|
|
80
|
+
linewidth: int = hs_plot.config.line_width,
|
|
81
|
+
save_path: str | None = None,
|
|
82
|
+
ax: Axes | None = None,
|
|
83
|
+
callback: Callable | None = None,
|
|
84
|
+
**params,
|
|
25
85
|
) -> tuple:
|
|
86
|
+
"""
|
|
87
|
+
K-평균 군집화에서 엘보우(Elbow) 기법을 활용해 최적의 K값을 탐지하는 함수.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
data (DataFrame): 군집화할 데이터프레임.
|
|
91
|
+
k_range (list | tuple, optional): K값의 범위 지정. 기본값은 [2, 11].
|
|
92
|
+
S (float, optional): KneeLocator의 민감도 파라미터. 기본값 0.1.
|
|
93
|
+
random_state (int, optional): 랜덤 시드. 기본값은 RANDOM_STATE.
|
|
94
|
+
plot (bool, optional): True면 결과를 시각화함. 기본값 True.
|
|
95
|
+
title (str, optional): 플롯 제목.
|
|
96
|
+
marker (str, optional): 마커 스타일.
|
|
97
|
+
width (int, optional): 플롯 가로 크기.
|
|
98
|
+
height (int, optional): 플롯 세로 크기.
|
|
99
|
+
dpi (int, optional): 플롯 해상도.
|
|
100
|
+
linewidth (int, optional): 선 두께.
|
|
101
|
+
save_path (str | None, optional): 저장 경로 지정시 파일로 저장.
|
|
102
|
+
ax (Axes | None, optional): 기존 matplotlib Axes 객체. None이면 새로 생성.
|
|
103
|
+
callback (Callable | None, optional): 플롯 후 호출할 콜백 함수.
|
|
104
|
+
**params: lineplot에 전달할 추가 파라미터.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
tuple: (best_k, inertia_list)
|
|
108
|
+
- best_k: 최적의 K값
|
|
109
|
+
- inertia_list: 각 K값에 대한 inertia 리스트
|
|
110
|
+
|
|
111
|
+
Examples:
|
|
112
|
+
```python
|
|
113
|
+
from hossam import *
|
|
114
|
+
|
|
115
|
+
data = hs_util.load_data('iris')
|
|
116
|
+
best_k, inertia_list = hs_cluster.kmeans_elbow(data.iloc[:, :-1])
|
|
117
|
+
```
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
inertia_list = []
|
|
121
|
+
|
|
122
|
+
r = range(k_range[0], k_range[1])
|
|
123
|
+
|
|
124
|
+
for k in r:
|
|
125
|
+
kmeans, _ = kmeans_fit(data=data, n_clusters=k, random_state=random_state)
|
|
126
|
+
inertia_list.append(kmeans.inertia_)
|
|
127
|
+
|
|
128
|
+
best_k, _ = elbow_point(
|
|
129
|
+
x=list(r),
|
|
130
|
+
y=inertia_list,
|
|
131
|
+
dir="left,down",
|
|
132
|
+
S=S,
|
|
133
|
+
plot=plot,
|
|
134
|
+
title=title,
|
|
135
|
+
marker=marker,
|
|
136
|
+
width=width,
|
|
137
|
+
height=height,
|
|
138
|
+
dpi=dpi,
|
|
139
|
+
linewidth=linewidth,
|
|
140
|
+
save_path=save_path,
|
|
141
|
+
ax=ax,
|
|
142
|
+
callback=callback,
|
|
143
|
+
**params,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return best_k, inertia_list
|
|
147
|
+
|
|
26
148
|
|
|
149
|
+
# ===================================================================
|
|
150
|
+
# K-평균 군집화에서 실루엣 점수를 계산하는 함수.
|
|
151
|
+
# ===================================================================
|
|
152
|
+
def kmeans_silhouette(
|
|
153
|
+
data: DataFrame,
|
|
154
|
+
k_range: list | tuple = [2, 11],
|
|
155
|
+
random_state: int = RANDOM_STATE,
|
|
156
|
+
plot: Literal[False, "silhouette", "cluster", "both"] = "both",
|
|
157
|
+
title: str = None,
|
|
158
|
+
xname: str = None,
|
|
159
|
+
yname: str = None,
|
|
160
|
+
width: int = hs_plot.config.width,
|
|
161
|
+
height: int = hs_plot.config.height,
|
|
162
|
+
linewidth: float = hs_plot.config.line_width,
|
|
163
|
+
dpi: int = hs_plot.config.dpi,
|
|
164
|
+
save_path: str | None = None,
|
|
165
|
+
**params,
|
|
166
|
+
) -> DataFrame:
|
|
167
|
+
"""
|
|
168
|
+
K-평균 군집화에서 실루엣 점수를 계산하는 함수.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
data (DataFrame): 군집화할 데이터프레임.
|
|
172
|
+
k_range (list | tuple, optional): K값의 범위 지정. 기본값은 [2, 11].
|
|
173
|
+
random_state (int, optional): 랜덤 시드. 기본값은 RANDOM_STATE.
|
|
174
|
+
plot (Literal[False, "silhouette", "cluster", "both"], optional):
|
|
175
|
+
플롯 옵션 지정. 기본값 "both".
|
|
176
|
+
title (str, optional): 플롯 제목.
|
|
177
|
+
xname (str, optional): 군집 산점도의 x축 컬럼명.
|
|
178
|
+
yname (str, optional): 군집 산점도의 y축 컬럼명.
|
|
179
|
+
width (int, optional): 플롯 가로 크기.
|
|
180
|
+
height (int, optional): 플롯 세로 크기.
|
|
181
|
+
linewidth (float, optional): 선 두께.
|
|
182
|
+
dpi (int, optional): 플롯 해상도.
|
|
183
|
+
save_path (str | None, optional): 저장 경로 지정시 파일로 저장.
|
|
184
|
+
**params: silhouette_plot에 전달할 추가 파라미터.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
DataFrame: 각 K값에 대한 실루엣 점수 데이터프레임.
|
|
188
|
+
|
|
189
|
+
Examples:
|
|
190
|
+
```python
|
|
191
|
+
from hossam import *
|
|
192
|
+
|
|
193
|
+
data = hs_util.load_data('iris')
|
|
194
|
+
silhouette_scores = hs_cluster.kmeans_silhouette(data.iloc[:, :-1], k=3)
|
|
195
|
+
```
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
klist = list(range(k_range[0], k_range[1]))
|
|
199
|
+
total = len(klist)
|
|
200
|
+
|
|
201
|
+
if plot is not False:
|
|
202
|
+
total *= 2
|
|
203
|
+
|
|
204
|
+
with tqdm(total=total) as pbar:
|
|
205
|
+
silhouettes = []
|
|
206
|
+
estimators = []
|
|
207
|
+
|
|
208
|
+
def __process_k(k):
|
|
209
|
+
estimator, cdf = kmeans_fit(
|
|
210
|
+
data=data, n_clusters=k, random_state=random_state
|
|
211
|
+
)
|
|
212
|
+
s_score = silhouette_score(X=data, labels=cdf["cluster"])
|
|
213
|
+
return s_score, estimator
|
|
214
|
+
|
|
215
|
+
with futures.ThreadPoolExecutor() as executor:
|
|
216
|
+
for k in klist:
|
|
217
|
+
pbar.set_description(f"K-Means Silhouette: k={k}")
|
|
218
|
+
executed = executor.submit(__process_k, k)
|
|
219
|
+
s_score, estimator = executed.result()
|
|
220
|
+
silhouettes.append(s_score)
|
|
221
|
+
estimators.append(estimator)
|
|
222
|
+
pbar.update(1)
|
|
223
|
+
|
|
224
|
+
if plot is not False:
|
|
225
|
+
for estimator in estimators:
|
|
226
|
+
pbar.set_description(f"K-Means Plotting: k={estimator.n_clusters}")
|
|
227
|
+
|
|
228
|
+
if plot == "silhouette":
|
|
229
|
+
hs_plot.silhouette_plot(
|
|
230
|
+
estimator=estimator,
|
|
231
|
+
data=data,
|
|
232
|
+
title=title,
|
|
233
|
+
width=width,
|
|
234
|
+
height=height,
|
|
235
|
+
dpi=dpi,
|
|
236
|
+
linewidth=linewidth,
|
|
237
|
+
save_path=save_path,
|
|
238
|
+
**params,
|
|
239
|
+
)
|
|
240
|
+
elif plot == "cluster":
|
|
241
|
+
hs_plot.cluster_plot(
|
|
242
|
+
estimator=estimator,
|
|
243
|
+
data=data,
|
|
244
|
+
xname=xname,
|
|
245
|
+
yname=yname,
|
|
246
|
+
outline=True,
|
|
247
|
+
palette=None,
|
|
248
|
+
width=width,
|
|
249
|
+
height=height,
|
|
250
|
+
dpi=dpi,
|
|
251
|
+
title=title,
|
|
252
|
+
save_path=save_path,
|
|
253
|
+
)
|
|
254
|
+
elif plot == "both":
|
|
255
|
+
hs_plot.visualize_silhouette(
|
|
256
|
+
estimator=estimator,
|
|
257
|
+
data=data,
|
|
258
|
+
xname=xname,
|
|
259
|
+
yname=yname,
|
|
260
|
+
outline=True,
|
|
261
|
+
palette=None,
|
|
262
|
+
width=width,
|
|
263
|
+
height=height,
|
|
264
|
+
dpi=dpi,
|
|
265
|
+
title=title,
|
|
266
|
+
linewidth=linewidth,
|
|
267
|
+
save_path=save_path,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
pbar.update(1)
|
|
271
|
+
|
|
272
|
+
silhouette_df = DataFrame({"k": klist, "silhouette_score": silhouettes})
|
|
273
|
+
silhouette_df.sort_values(by="silhouette_score", ascending=False, inplace=True)
|
|
274
|
+
return silhouette_df
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ===================================================================
|
|
278
|
+
# 엘보우(Elbow) 포인트를 자동으로 탐지하는 함수.
|
|
279
|
+
# ===================================================================
|
|
280
|
+
def elbow_point(
|
|
281
|
+
x: Series | np.ndarray | list,
|
|
282
|
+
y: Series | np.ndarray | list,
|
|
283
|
+
dir: Literal["left,down", "left,up", "right,down", "right,up"] = "left,down",
|
|
284
|
+
S: float = 0.1,
|
|
285
|
+
plot: bool = True,
|
|
286
|
+
title: str = None,
|
|
287
|
+
marker: str = None,
|
|
288
|
+
width: int = hs_plot.config.width,
|
|
289
|
+
height: int = hs_plot.config.height,
|
|
290
|
+
dpi: int = hs_plot.config.dpi,
|
|
291
|
+
linewidth: int = hs_plot.config.line_width,
|
|
292
|
+
save_path: str | None = None,
|
|
293
|
+
ax: Axes | None = None,
|
|
294
|
+
callback: Callable | None = None,
|
|
295
|
+
**params,
|
|
296
|
+
) -> tuple:
|
|
27
297
|
"""
|
|
28
298
|
엘보우(Elbow) 포인트를 자동으로 탐지하는 함수.
|
|
29
299
|
|
|
@@ -48,6 +318,7 @@ def elbow_point(
|
|
|
48
318
|
linewidth (int, optional): 선 두께.
|
|
49
319
|
save_path (str | None, optional): 저장 경로 지정시 파일로 저장.
|
|
50
320
|
ax (Axes | None, optional): 기존 matplotlib Axes 객체. None이면 새로 생성.
|
|
321
|
+
callback (Callable | None, optional): 플롯 후 호출할 콜백 함수.
|
|
51
322
|
**params: lineplot에 전달할 추가 파라미터.
|
|
52
323
|
|
|
53
324
|
Returns:
|
|
@@ -91,29 +362,495 @@ def elbow_point(
|
|
|
91
362
|
ax.axvline(best_x, color="red", linestyle="--", linewidth=0.7)
|
|
92
363
|
ax.axhline(best_y, color="red", linestyle="--", linewidth=0.7)
|
|
93
364
|
ax.text(
|
|
94
|
-
best_x
|
|
95
|
-
best_y + 0.
|
|
96
|
-
"
|
|
97
|
-
fontsize=
|
|
98
|
-
ha="
|
|
365
|
+
best_x,
|
|
366
|
+
best_y + (best_y * 0.01),
|
|
367
|
+
"x=%.2f, y=%.2f" % (best_x, best_y),
|
|
368
|
+
fontsize=6,
|
|
369
|
+
ha="center",
|
|
99
370
|
va="bottom",
|
|
100
|
-
color="
|
|
371
|
+
color="black",
|
|
372
|
+
fontweight="bold"
|
|
101
373
|
)
|
|
102
374
|
|
|
375
|
+
if callback is not None:
|
|
376
|
+
callback(ax)
|
|
377
|
+
|
|
103
378
|
hs_plot.lineplot(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
379
|
+
df=None,
|
|
380
|
+
xname=x,
|
|
381
|
+
yname=y,
|
|
382
|
+
title=title,
|
|
383
|
+
marker=marker,
|
|
384
|
+
width=width,
|
|
385
|
+
height=height,
|
|
386
|
+
linewidth=linewidth,
|
|
387
|
+
dpi=dpi,
|
|
388
|
+
save_path=save_path,
|
|
389
|
+
callback=hvline,
|
|
390
|
+
ax=ax,
|
|
391
|
+
**params,
|
|
117
392
|
)
|
|
118
393
|
|
|
119
|
-
return best_x, best_y
|
|
394
|
+
return best_x, best_y
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# ===================================================================
|
|
398
|
+
# 데이터프레임의 여러 필드 쌍에 대해 군집 산점도를 그리는 함수.
|
|
399
|
+
# ===================================================================
|
|
400
|
+
def cluster_plot(
|
|
401
|
+
estimator: KMeans,
|
|
402
|
+
data: DataFrame,
|
|
403
|
+
hue: str | None = None,
|
|
404
|
+
vector: str | None = None,
|
|
405
|
+
fields: list[list] = None,
|
|
406
|
+
title: str | None = None,
|
|
407
|
+
palette: str | None = None,
|
|
408
|
+
outline: bool = True,
|
|
409
|
+
width: int = hs_plot.config.width,
|
|
410
|
+
height: int = hs_plot.config.height,
|
|
411
|
+
linewidth: float = hs_plot.config.line_width,
|
|
412
|
+
dpi: int = hs_plot.config.dpi,
|
|
413
|
+
save_path: str | None = None,
|
|
414
|
+
ax: Axes | None = None,
|
|
415
|
+
):
|
|
416
|
+
"""
|
|
417
|
+
데이터프레임의 여러 필드 쌍에 대해 군집 산점도를 그리는 함수.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
estimator (KMeans): KMeans 군집화 모델.
|
|
421
|
+
data (DataFrame): 군집화할 데이터프레임.
|
|
422
|
+
hue (str | None, optional): 군집 레이블 컬럼명. 지정되지 않으면 estimator의 레이블 사용.
|
|
423
|
+
vector (str | None, optional): 벡터 종류를 의미하는 컬럼명(for DBSCAN)
|
|
424
|
+
fields (list[list], optional): 시각화할 필드 쌍 리스트. 기본값 None이면 수치형 컬럼의 모든 조합 사용.
|
|
425
|
+
title (str | None, optional): 플롯 제목.
|
|
426
|
+
palette (str | None, optional): 색상 팔레트 이름.
|
|
427
|
+
outline (bool, optional): True면 데이터 포인트 외곽선 표시. 기본값 False.
|
|
428
|
+
width (int, optional): 플롯 가로 크기.
|
|
429
|
+
height (int, optional): 플롯 세로 크기.
|
|
430
|
+
linewidth (float, optional): 선 두께.
|
|
431
|
+
dpi (int, optional): 플롯 해상도.
|
|
432
|
+
save_path (str | None, optional): 저장 경로 지정시 파일로 저장.
|
|
433
|
+
ax (Axes | None, optional): 기존 matplotlib Axes 객체. None이면 새로 생성.
|
|
434
|
+
|
|
435
|
+
Examples:
|
|
436
|
+
```python
|
|
437
|
+
from hossam import *
|
|
438
|
+
|
|
439
|
+
data = hs_util.load_data('iris')
|
|
440
|
+
estimator, cdf = hs_cluster.kmeans_fit(data.iloc[:, :-1], n_clusters=3)
|
|
441
|
+
hs_cluster.cluster_plot(cdf, hue='cluster')
|
|
442
|
+
```
|
|
443
|
+
"""
|
|
444
|
+
|
|
445
|
+
if fields is None:
|
|
446
|
+
numeric_cols = data.select_dtypes(include=["number"]).columns.tolist()
|
|
447
|
+
if len(numeric_cols) < 2:
|
|
448
|
+
raise ValueError("데이터프레임에 수치형 컬럼이 2개 이상 필요합니다.")
|
|
449
|
+
|
|
450
|
+
# fields의 모든 조합 생성
|
|
451
|
+
fields = [list(pair) for pair in combinations(numeric_cols, 2)]
|
|
452
|
+
|
|
453
|
+
for field_pair in fields:
|
|
454
|
+
xname, yname = field_pair
|
|
455
|
+
|
|
456
|
+
hs_plot.cluster_plot(
|
|
457
|
+
estimator=estimator,
|
|
458
|
+
data=data,
|
|
459
|
+
xname=xname,
|
|
460
|
+
yname=yname,
|
|
461
|
+
hue=hue,
|
|
462
|
+
title=title,
|
|
463
|
+
vector=vector,
|
|
464
|
+
palette=palette,
|
|
465
|
+
outline=outline,
|
|
466
|
+
width=width,
|
|
467
|
+
height=height,
|
|
468
|
+
linewidth=linewidth,
|
|
469
|
+
dpi=dpi,
|
|
470
|
+
save_path=save_path,
|
|
471
|
+
ax=ax,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
# ===================================================================
|
|
476
|
+
# 군집화된 데이터프레임에서 각 군집의 페르소나(특성 요약)를 생성하는 함수.
|
|
477
|
+
# ===================================================================
|
|
478
|
+
def persona(
|
|
479
|
+
data: DataFrame,
|
|
480
|
+
cluster: str | Series | np.ndarray | list | dict,
|
|
481
|
+
fields: list[str] | None = None,
|
|
482
|
+
full: bool = False
|
|
483
|
+
) -> DataFrame:
|
|
484
|
+
"""
|
|
485
|
+
군집화된 데이터프레임에서 각 군집의 페르소나(특성 요약)를 생성하는 함수.
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
data (DataFrame): 군집화된 데이터프레임.
|
|
489
|
+
cluster (str | Series | ndarray | list | dict): 군집 레이블 컬럼명 또는 배열.
|
|
490
|
+
fields (list[str] | None, optional): 페르소나 생성에 사용할 필드 리스트. 기본값 None이면 수치형 컬럼 전체 사용.
|
|
491
|
+
full (bool, optional): True면 모든 통계량을 포함. 기본값 False.
|
|
492
|
+
Returns:
|
|
493
|
+
DataFrame: 각 군집의 페르소나 요약 데이터프레임.
|
|
494
|
+
|
|
495
|
+
Examples:
|
|
496
|
+
```python
|
|
497
|
+
from hossam import *
|
|
498
|
+
|
|
499
|
+
data = hs_util.load_data('iris')
|
|
500
|
+
data['cluster'] = hs_cluster.kmeans_fit(data.iloc[:, :-1], n_clusters=3)[1]
|
|
501
|
+
persona_df = hs_cluster.persona(data, hue='cluster')
|
|
502
|
+
print(persona_df)
|
|
503
|
+
```
|
|
504
|
+
"""
|
|
505
|
+
df = data.copy()
|
|
506
|
+
|
|
507
|
+
if fields is None:
|
|
508
|
+
fields = df.select_dtypes(include=["number"]).columns.tolist()
|
|
509
|
+
|
|
510
|
+
if isinstance(cluster, str):
|
|
511
|
+
if cluster not in df.columns:
|
|
512
|
+
raise ValueError(f"cluster로 지정된 컬럼 '{cluster}'이(가) 데이터프레임에 존재하지 않습니다.")
|
|
513
|
+
else:
|
|
514
|
+
df["cluster"] = cluster
|
|
515
|
+
cluster = "cluster"
|
|
516
|
+
fields.remove(cluster) if cluster in fields else None
|
|
517
|
+
|
|
518
|
+
persona_list = []
|
|
519
|
+
|
|
520
|
+
grouped = df.groupby(cluster)
|
|
521
|
+
for cluster_label, group in grouped:
|
|
522
|
+
persona_dict = {}
|
|
523
|
+
# 군집 레이블 및 카운트는 단일 인덱스 유지
|
|
524
|
+
persona_dict[(cluster, "")] = cluster_label
|
|
525
|
+
persona_dict[("", f"count")] = len(group)
|
|
526
|
+
|
|
527
|
+
for field in fields:
|
|
528
|
+
# 명목형일 경우 최빈값 사용
|
|
529
|
+
if df[field].dtype == "object" or df[field].dtype.name == "category":
|
|
530
|
+
persona_dict[(field, "mode")] = group[field].mode()[0]
|
|
531
|
+
else:
|
|
532
|
+
if full:
|
|
533
|
+
persona_dict[(field, "mean")] = group[field].mean()
|
|
534
|
+
persona_dict[(field, "median")] = group[field].median()
|
|
535
|
+
persona_dict[(field, "std")] = group[field].std()
|
|
536
|
+
persona_dict[(field, "min")] = group[field].min()
|
|
537
|
+
persona_dict[(field, "max")] = group[field].max()
|
|
538
|
+
persona_dict[(field, "25%")] = group[field].quantile(0.25)
|
|
539
|
+
persona_dict[(field, "50%")] = group[field].quantile(0.50)
|
|
540
|
+
persona_dict[(field, "75%")] = group[field].quantile(0.75)
|
|
541
|
+
else:
|
|
542
|
+
# normaltest를 사용해서 정규분포일 경우 평균/표준편차, 비정규분포일 경우 중앙값/IQR 사용
|
|
543
|
+
stat, p = normaltest(df[field])
|
|
544
|
+
alpha = 0.05
|
|
545
|
+
|
|
546
|
+
if p > alpha:
|
|
547
|
+
# 정규분포
|
|
548
|
+
persona_dict[(field, "mean")] = group[field].mean()
|
|
549
|
+
persona_dict[(field, "std")] = group[field].std()
|
|
550
|
+
else:
|
|
551
|
+
# 비정규분포
|
|
552
|
+
persona_dict[(field, "median")] = group[field].median()
|
|
553
|
+
persona_dict[(field, "IQR")] = group[field].quantile(
|
|
554
|
+
0.75
|
|
555
|
+
) - group[field].quantile(0.25)
|
|
556
|
+
|
|
557
|
+
persona_list.append(persona_dict)
|
|
558
|
+
|
|
559
|
+
persona_df = DataFrame(persona_list)
|
|
560
|
+
# 멀티인덱스로 변환 (단일 인덱스는 그대로)
|
|
561
|
+
persona_df.columns = MultiIndex.from_tuples(persona_df.columns) # type: ignore
|
|
562
|
+
# 군집 레이블(cluster)을 인덱스로 설정
|
|
563
|
+
persona_df.set_index((cluster, ""), inplace=True)
|
|
564
|
+
persona_df.index.name = cluster
|
|
565
|
+
return persona_df
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
# ===================================================================
|
|
569
|
+
# 엘보우 포인트와 실루엣 점수를 통해 최적의 K값을 결정하는 함수.
|
|
570
|
+
# ===================================================================
|
|
571
|
+
def kmeans_best_k(
|
|
572
|
+
data: DataFrame,
|
|
573
|
+
k_range: list | tuple = [2, 11],
|
|
574
|
+
S: float = 0.1,
|
|
575
|
+
random_state: int = RANDOM_STATE,
|
|
576
|
+
plot: bool = True
|
|
577
|
+
) -> int:
|
|
578
|
+
"""
|
|
579
|
+
엘보우 포인트와 실루엣 점수를 통해 최적의 K값을 결정하는 함수.
|
|
580
|
+
Args:
|
|
581
|
+
data (DataFrame): 군집화할 데이터프레임.
|
|
582
|
+
k_range (list | tuple, optional): K값의 범위 지정. 기본값은 [2, 11].
|
|
583
|
+
S (float, optional): KneeLocator의 민감도 파라미터. 기본값 0.1.
|
|
584
|
+
random_state (int, optional): 랜덤 시드. 기본값은 RANDOM_STATE.
|
|
585
|
+
plot (bool, optional): True면 결과를 시각화함. 기본값 True.
|
|
586
|
+
|
|
587
|
+
Returns:
|
|
588
|
+
int: 최적의 K값.
|
|
589
|
+
|
|
590
|
+
Examples:
|
|
591
|
+
```python
|
|
592
|
+
from hossam import *
|
|
593
|
+
data = hs_util.load_data('iris')
|
|
594
|
+
best_k = hs_cluster.kmeans_best_k(data.iloc[:, :-1])
|
|
595
|
+
```
|
|
596
|
+
"""
|
|
597
|
+
|
|
598
|
+
elbow_k, _ = kmeans_elbow(
|
|
599
|
+
data=data,
|
|
600
|
+
k_range=k_range,
|
|
601
|
+
S=S,
|
|
602
|
+
random_state=random_state,
|
|
603
|
+
plot=True if plot else False
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
silhouette_df = kmeans_silhouette(
|
|
607
|
+
data=data,
|
|
608
|
+
k_range=k_range,
|
|
609
|
+
random_state=random_state,
|
|
610
|
+
plot="both" if plot else False
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
silhouette_k = silhouette_df.sort_values(by="silhouette_score", ascending=False).iloc[0]["k"]
|
|
614
|
+
|
|
615
|
+
if elbow_k == silhouette_k:
|
|
616
|
+
best_k = elbow_k
|
|
617
|
+
else:
|
|
618
|
+
best_k = min(elbow_k, silhouette_k)
|
|
619
|
+
|
|
620
|
+
print(f"Elbow K: {elbow_k}, Silhouette K: {silhouette_k} => Best K: {best_k}")
|
|
621
|
+
return best_k
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
# ===================================================================
|
|
625
|
+
# DBSCAN 군집화 모델을 적합하는 함수.
|
|
626
|
+
# ===================================================================
|
|
627
|
+
def __dbscan_fit(
|
|
628
|
+
data: DataFrame,
|
|
629
|
+
eps: float = 0.5,
|
|
630
|
+
min_samples: int = 5,
|
|
631
|
+
**params
|
|
632
|
+
) -> tuple[DBSCAN, DataFrame, DataFrame]:
|
|
633
|
+
"""
|
|
634
|
+
DBSCAN 군집화 모델을 적합하는 함수.
|
|
635
|
+
|
|
636
|
+
Args:
|
|
637
|
+
data (DataFrame): 군집화할 데이터프레임.
|
|
638
|
+
eps (float, optional): 두 샘플이 같은 군집에 속하기 위한 최대 거리. 기본값 0.5.
|
|
639
|
+
min_samples (int, optional): 핵심점이 되기 위한 최소 샘플 수. 기본값 5.
|
|
640
|
+
**params: DBSCAN에 전달할 추가 파라미터.
|
|
641
|
+
|
|
642
|
+
Returns:
|
|
643
|
+
tuple: (estimator, df)
|
|
644
|
+
- estimator: 적합된 DBSCAN 모델.
|
|
645
|
+
- df: 클러스터 및 벡터 유형이 포함된 데이터 프레임.
|
|
646
|
+
- result_df: 군집화 요약 통계 데이터 프레임.
|
|
647
|
+
|
|
648
|
+
"""
|
|
649
|
+
df = data.copy()
|
|
650
|
+
estimator = DBSCAN(eps=eps, min_samples=min_samples, n_jobs=-1, **params)
|
|
651
|
+
estimator.fit(df)
|
|
652
|
+
df["cluster"] = estimator.labels_
|
|
653
|
+
|
|
654
|
+
# 기본적으로 모두 외곽 벡터로 지정
|
|
655
|
+
df["vector"] = "border"
|
|
656
|
+
|
|
657
|
+
# 핵심 벡터인 경우 'core'로 지정
|
|
658
|
+
df.loc[estimator.core_sample_indices_, "vector"] = "core"
|
|
659
|
+
|
|
660
|
+
# 노이즈 분류
|
|
661
|
+
df.loc[df["cluster"] == -1, "vector"] = "noise"
|
|
662
|
+
|
|
663
|
+
labels = estimator.labels_
|
|
664
|
+
n_clusters = len(set(labels)) - (1 if -1 in labels else 0)
|
|
665
|
+
noise_ratio = np.mean(labels == -1)
|
|
666
|
+
|
|
667
|
+
result_df = DataFrame({
|
|
668
|
+
"eps": [eps],
|
|
669
|
+
"min_samples": [min_samples],
|
|
670
|
+
"n_clusters": [n_clusters],
|
|
671
|
+
"noise_ratio": [noise_ratio]
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
return estimator, df, result_df
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
# ===================================================================
|
|
678
|
+
# DBSCAN 군집화에서 최적의 eps 값을 탐지하는 함수.
|
|
679
|
+
# ===================================================================
|
|
680
|
+
def dbscan_eps(
|
|
681
|
+
data: DataFrame,
|
|
682
|
+
min_samples: int = 5,
|
|
683
|
+
delta_ratio: float = 0.3,
|
|
684
|
+
step_ratio: float = 0.05,
|
|
685
|
+
S: float = 0.1,
|
|
686
|
+
plot: bool = True,
|
|
687
|
+
title: str | None = None,
|
|
688
|
+
palette: str | None = None,
|
|
689
|
+
width: int = hs_plot.config.width,
|
|
690
|
+
height: int = hs_plot.config.height,
|
|
691
|
+
linewidth: int = hs_plot.config.line_width,
|
|
692
|
+
dpi: int = hs_plot.config.dpi,
|
|
693
|
+
save_path: str | None = None,
|
|
694
|
+
ax: Axes | None = None
|
|
695
|
+
) -> tuple[float, np.ndarray]:
|
|
696
|
+
"""
|
|
697
|
+
DBSCAN 군집화에서 최적의 eps 값을 탐지하는 함수.
|
|
698
|
+
|
|
699
|
+
Args:
|
|
700
|
+
data (DataFrame): 군집화할 데이터프레임.
|
|
701
|
+
min_samples (int, optional): 핵심점이 되기 위한 최소 샘플 수. 기본값 5.
|
|
702
|
+
delta_ratio (float, optional): eps 탐색 범위 비율. 기본값 0.3.
|
|
703
|
+
step_ratio (float, optional): eps 탐색 스텝 비율. 기본값 0.05.
|
|
704
|
+
S (float, optional): KneeLocator의 민감도 파라미터. 기본값 0.1.
|
|
705
|
+
plot (bool, optional): True면 결과를 시각화함. 기본값 True.
|
|
706
|
+
title (str | None, optional): 플롯 제목.
|
|
707
|
+
palette (str | None, optional): 색상 팔레트 이름.
|
|
708
|
+
width (int, optional): 플롯 가로 크기.
|
|
709
|
+
height (int, optional): 플롯 세로 크기.
|
|
710
|
+
linewidth (float, optional): 선 두께.
|
|
711
|
+
dpi (int, optional): 플롯 해상도.
|
|
712
|
+
save_path (str | None, optional): 저장 경로 지정시 파일로 저장.
|
|
713
|
+
ax (Axes | None, optional): 기존 matplotlib Axes 객체. None이면 새로 생성.
|
|
714
|
+
|
|
715
|
+
Returns:
|
|
716
|
+
tuple: (best_eps, eps_grid)
|
|
717
|
+
- best_eps: 최적의 eps 값
|
|
718
|
+
- eps_grid: 탐색할 eps 값의 그리드 배열
|
|
719
|
+
|
|
720
|
+
Examples:
|
|
721
|
+
```python
|
|
722
|
+
from hossam import *
|
|
723
|
+
data = hs_util.load_data('iris')
|
|
724
|
+
best_eps, eps_grid = hs_cluster.dbscan_eps(data, plot=True)
|
|
725
|
+
```
|
|
726
|
+
"""
|
|
727
|
+
|
|
728
|
+
neigh = NearestNeighbors(n_neighbors=min_samples)
|
|
729
|
+
nbrs = neigh.fit(data)
|
|
730
|
+
distances, indices = nbrs.kneighbors(data)
|
|
731
|
+
|
|
732
|
+
# 각 포인트에 대해 k번째 최근접 이웃까지의 거리 추출
|
|
733
|
+
k_distances = distances[:, -1]
|
|
734
|
+
k_distances.sort()
|
|
735
|
+
|
|
736
|
+
# 엘보우 포인트 탐지
|
|
737
|
+
_, best_eps = elbow_point(
|
|
738
|
+
x=list(range(1, len(k_distances) + 1)),
|
|
739
|
+
y=k_distances,
|
|
740
|
+
dir="right,down",
|
|
741
|
+
S=S,
|
|
742
|
+
plot=plot,
|
|
743
|
+
title=title,
|
|
744
|
+
marker=None,
|
|
745
|
+
width=width,
|
|
746
|
+
height=height,
|
|
747
|
+
dpi=dpi,
|
|
748
|
+
linewidth=linewidth,
|
|
749
|
+
palette=palette,
|
|
750
|
+
save_path=save_path,
|
|
751
|
+
ax=ax,
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
eps_min = best_eps * (1 - delta_ratio)
|
|
755
|
+
eps_max = best_eps * (1 + delta_ratio)
|
|
756
|
+
step = best_eps * step_ratio
|
|
757
|
+
|
|
758
|
+
eps_grid = np.arange(eps_min, eps_max + step, step)
|
|
759
|
+
|
|
760
|
+
return best_eps, eps_grid
|
|
761
|
+
|
|
762
|
+
def dbscan_fit(
|
|
763
|
+
data: DataFrame,
|
|
764
|
+
eps: float | list | np.ndarray | None = None,
|
|
765
|
+
min_samples: int = 5,
|
|
766
|
+
ari_threshold: float = 0.9,
|
|
767
|
+
noise_diff_threshold: float = 0.05,
|
|
768
|
+
plot : bool = True,
|
|
769
|
+
**params
|
|
770
|
+
) -> tuple[DBSCAN, DataFrame, DataFrame]:
|
|
771
|
+
|
|
772
|
+
# eps 값이 지정되지 않은 경우 최적의 eps 탐지
|
|
773
|
+
if eps is None:
|
|
774
|
+
_, eps_grid = dbscan_eps(data=data, min_samples=min_samples, plot=plot)
|
|
775
|
+
eps = eps_grid
|
|
776
|
+
|
|
777
|
+
# eps가 단일 값인 경우 리스트로 변환
|
|
778
|
+
if not isinstance(eps, (list, np.ndarray)):
|
|
779
|
+
eps = [eps]
|
|
780
|
+
|
|
781
|
+
estimators = []
|
|
782
|
+
cluster_dfs = []
|
|
783
|
+
result_dfs: DataFrame | None = None
|
|
784
|
+
|
|
785
|
+
with tqdm(total=len(eps)+2) as pbar:
|
|
786
|
+
with futures.ThreadPoolExecutor() as executor:
|
|
787
|
+
for i, e in enumerate(eps):
|
|
788
|
+
pbar.set_description(f"DBSCAN Fit: eps={e:.4f}")
|
|
789
|
+
executed = executor.submit(__dbscan_fit, data=data, eps=e, min_samples=min_samples, **params)
|
|
790
|
+
estimator, cluster_df, result_df = executed.result()
|
|
791
|
+
estimators.append(estimator)
|
|
792
|
+
cluster_dfs.append(cluster_df)
|
|
793
|
+
|
|
794
|
+
if result_dfs is None:
|
|
795
|
+
result_df['ARI'] = np.nan
|
|
796
|
+
result_dfs = result_df
|
|
797
|
+
else:
|
|
798
|
+
result_df['ARI'] = adjusted_rand_score(cluster_dfs[i-1]['cluster'], cluster_df['cluster']) # type: ignore
|
|
799
|
+
result_dfs = concat([result_dfs, result_df], ignore_index=True)
|
|
800
|
+
|
|
801
|
+
pbar.update(1)
|
|
802
|
+
|
|
803
|
+
pbar.set_description(f"DBSCAN Stability Analysis")
|
|
804
|
+
result_dfs['cluster_diff'] = result_dfs['n_clusters'].diff().abs() # type: ignore
|
|
805
|
+
result_dfs['noise_ratio_diff'] = result_dfs['noise_ratio'].diff().abs() # type: ignore
|
|
806
|
+
result_dfs['stable'] = ( # type: ignore
|
|
807
|
+
(result_dfs['ARI'] >= ari_threshold) & # type: ignore
|
|
808
|
+
(result_dfs['cluster_diff'] <= 0) & # type: ignore
|
|
809
|
+
(result_dfs['noise_ratio_diff'] <= noise_diff_threshold) # type: ignore
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
# 첫 행은 비교 불가
|
|
813
|
+
result_dfs.loc[0, 'stable'] = False # type: ignore
|
|
814
|
+
pbar.update(1)
|
|
815
|
+
|
|
816
|
+
if len(eps) == 1:
|
|
817
|
+
result_dfs['group_id'] = 1 # type: ignore
|
|
818
|
+
result_dfs['recommand'] = 'unknown' # type: ignore
|
|
819
|
+
else:
|
|
820
|
+
# 안정구간 도출하기
|
|
821
|
+
# stable 여부를 0/1로 변환
|
|
822
|
+
stable_flag = result_dfs['stable'].astype(int).values # type: ignore
|
|
823
|
+
|
|
824
|
+
# 연속 구간 구분용 그룹 id 생성
|
|
825
|
+
group_id = (stable_flag != np.roll(stable_flag, 1)).cumsum() # type: ignore
|
|
826
|
+
result_dfs['group_id'] = group_id # type: ignore
|
|
827
|
+
|
|
828
|
+
# 안정구간 중 가장 긴 구간 선택
|
|
829
|
+
stable_groups = result_dfs[result_dfs['stable']].groupby('group_id') # type: ignore
|
|
830
|
+
|
|
831
|
+
# 각 구간의 길이 계산
|
|
832
|
+
group_sizes = stable_groups.size()
|
|
833
|
+
|
|
834
|
+
# 가장 긴 안정 구간 선택
|
|
835
|
+
best_group_id = group_sizes.idxmax()
|
|
836
|
+
|
|
837
|
+
result_dfs['recommand'] = 'bad' # type: ignore
|
|
838
|
+
|
|
839
|
+
# 가장 긴 안정 구간에 해당하는 recommand 컬럼을 `best`로 변경
|
|
840
|
+
result_dfs.loc[result_dfs["group_id"] == best_group_id, 'recommand'] = 'best' # type: ignore
|
|
841
|
+
|
|
842
|
+
# result_dfs에서 recommand가 best에 해당하는 인덱스와 같은 위치의 추정기만 추출
|
|
843
|
+
best_indexes = list(result_dfs[result_dfs['recommand'] == 'best'].index) # type: ignore
|
|
844
|
+
|
|
845
|
+
for i in range(len(estimators)-1, -1, -1):
|
|
846
|
+
if i not in best_indexes:
|
|
847
|
+
del(estimators[i])
|
|
848
|
+
del(cluster_dfs[i])
|
|
849
|
+
|
|
850
|
+
pbar.update(1)
|
|
851
|
+
|
|
852
|
+
return (
|
|
853
|
+
estimators[0] if len(estimators) == 1 else estimators, # type: ignore
|
|
854
|
+
cluster_dfs[0] if len(cluster_dfs) == 1 else cluster_dfs,
|
|
855
|
+
result_dfs # type: ignore
|
|
856
|
+
)
|