hossam 0.4.13__py3-none-any.whl → 0.4.14__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/hs_cluster.py CHANGED
@@ -1,29 +1,299 @@
1
- from typing import Literal
2
- from kneed import KneeLocator
3
- from pandas import Series
4
- from matplotlib.pyplot import Axes # type: ignore
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
- import numpy as np
10
+ from tqdm.auto import tqdm
11
+ from itertools import combinations
9
12
 
10
- def elbow_point(
11
- x: Series | np.ndarray | list,
12
- y: Series | np.ndarray | list,
13
- dir: Literal["left,down", "left,up", "right,down", "right,up"] = "left,down",
14
- S: float = 0.1,
15
- plot: bool = True,
16
- title: str = None,
17
- marker: str = None,
18
- width: int = hs_plot.config.width,
19
- height: int = hs_plot.config.height,
20
- dpi: int = hs_plot.config.dpi,
21
- linewidth: int = hs_plot.config.line_width,
22
- save_path: str | None = None,
23
- ax: Axes | None = None,
24
- **params,
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 + 0.1,
95
- best_y + 0.1,
96
- "Best K=%d" % best_x,
97
- fontsize=8,
98
- ha="left",
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="r",
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
- df = None,
105
- xname = x,
106
- yname = y,
107
- title = title,
108
- marker = marker,
109
- width = width,
110
- height = height,
111
- linewidth = linewidth,
112
- dpi = dpi,
113
- save_path = save_path,
114
- callback = hvline,
115
- ax = ax,
116
- **params
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
+ )