File size: 3,599 Bytes
21bdc64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# S-BERT ์ž„๋ฒ ๋”ฉ โ†’ UMAP ์ฐจ์›์ถ•์†Œ โ†’ HDBSCAN ๋ฐ€๋„ ๊ตฐ์ง‘ํ™”๋กœ ํ‚ค์›Œ๋“œ๋ฅผ ์˜๋ฏธ ํด๋Ÿฌ์Šคํ„ฐ๋กœ ๋ฌถ๋Š” ๋ชจ๋“ˆ
import warnings
from functools import lru_cache

import hdbscan
import pandas as pd
import umap
from sentence_transformers import SentenceTransformer

warnings.filterwarnings("ignore")  # UMAP/HDBSCAN์˜ ๋น„์น˜๋ช… ๊ฒฝ๊ณ  ์–ต์ œ

# ๋‹ค๊ตญ์–ด ๊ฒฝ๋Ÿ‰ ๋ชจ๋ธ(ํ•œ๊ตญ์–ด ํฌํ•จ, ~120MB). ํ’ˆ์งˆ ์šฐ์„  ์‹œ jhgan/ko-sroberta-multitask๋กœ ๊ต์ฒด ๊ฐ€๋Šฅ
MODEL_NAME = "paraphrase-multilingual-MiniLM-L12-v2"
MIN_KEYWORDS = 20                  # ์ด๋ณด๋‹ค ์ ์œผ๋ฉด ๊ตฐ์ง‘์ด ๋ฌด์˜๋ฏธ
RELEVANCE_THRESHOLD = 0.35          # ํ‚ค์›Œ๋“œ๋ณ„ ์‹œ๋“œ ์œ ์‚ฌ๋„ ํ•˜ํ•œ (๊ณ„์‚ฐ๊ธฐยทํ‚ค๋ณด๋“œ ๋“ฑ ๋ฌด๊ด€์–ด ์ œ๊ฑฐ)
CLUSTER_RELEVANCE_THRESHOLD = 0.45  # ํด๋Ÿฌ์Šคํ„ฐ ํ‰๊ท  ์‹œ๋“œ ๊ด€๋ จ์„ฑ ํ•˜ํ•œ (๋ฐฑํŒฉยท์ƒŒ๋“ค ๋“ฑ ํŒจ์…˜ ์žกํ™” ๊ตฐ์ง‘ ์ œ๊ฑฐ)


@lru_cache(maxsize=1)
def _model() -> SentenceTransformer:
    # ๋ชจ๋ธ์€ ํ•œ ๋ฒˆ๋งŒ ๋กœ๋“œํ•ด ์žฌ์‚ฌ์šฉ (์ฝœ๋“œ ์Šคํƒ€ํŠธ ๋น„์šฉ ์ ˆ๊ฐ)
    return SentenceTransformer(MODEL_NAME)


def filter_by_relevance(df: pd.DataFrame, seed: str, threshold: float = RELEVANCE_THRESHOLD):
    # ์‹œ๋“œ์™€ ์œ ์‚ฌ๋„๊ฐ€ ๋‚ฎ์€ ๋ฌด๊ด€ ํ‚ค์›Œ๋“œ ์ œ๊ฑฐ. (ํ•„ํ„ฐ๋œ df, ์ •๊ทœํ™” ์ž„๋ฒ ๋”ฉ) ๋ฐ˜ํ™˜
    model = _model()
    seed_emb = model.encode([seed], normalize_embeddings=True)[0]
    kw_emb = model.encode(df["keyword"].tolist(), normalize_embeddings=True)
    sims = kw_emb @ seed_emb  # ์ •๊ทœํ™”๋ผ ์žˆ์–ด ๋‚ด์  = ์ฝ”์‚ฌ์ธ ์œ ์‚ฌ๋„

    df = df.copy()
    df["relevance"] = sims
    mask = sims >= threshold
    return df[mask].reset_index(drop=True), kw_emb[mask]


def cluster_keywords(df: pd.DataFrame, embeddings=None, min_cluster_size: int = 10) -> list[dict]:
    # ํ‚ค์›Œ๋“œ df๋ฅผ ์˜๋ฏธ ๊ตฐ์ง‘์œผ๋กœ ๋ฌถ์–ด ๊ฒ€์ƒ‰๋Ÿ‰์ˆœ ํด๋Ÿฌ์Šคํ„ฐ ์š”์•ฝ ๋ฐ˜ํ™˜ (๋…ธ์ด์ฆˆ -1 ์ œ์™ธ)
    # embeddings: filter_by_relevance๊ฐ€ ๋งŒ๋“  ์ž„๋ฒ ๋”ฉ์„ ๋„˜๊ธฐ๋ฉด ์žฌ๊ณ„์‚ฐ์„ ์ƒ๋žตํ•œ๋‹ค
    if len(df) < MIN_KEYWORDS:
        return []

    df = df.copy()
    df["total_volume"] = df["search_volume_pc"].fillna(0) + df["search_volume_mobile"].fillna(0)

    # 1) ์ •๊ทœํ™” ์ž„๋ฒ ๋”ฉ (์—†์œผ๋ฉด ์ƒˆ๋กœ ๊ณ„์‚ฐ)
    emb = embeddings if embeddings is not None else _model().encode(
        df["keyword"].tolist(), normalize_embeddings=True, show_progress_bar=False
    )
    # 2) UMAP์œผ๋กœ 5์ฐจ์› ์ถ•์†Œ (๊ณ ์ฐจ์› ๋ฉ”๊ฐ€ ํด๋Ÿฌ์Šคํ„ฐ ๋ฐฉ์ง€, ์ฝ”์‚ฌ์ธ ๊ธฐ๋ฐ˜)
    n_neighbors = min(10, len(df) - 1)
    reduced = umap.UMAP(
        n_neighbors=n_neighbors, n_components=5, min_dist=0.0,
        metric="cosine", random_state=42,
    ).fit_transform(emb)
    # 3) HDBSCAN leaf ๋ฐฉ์‹์œผ๋กœ ์„ธ๋ถ„ํ™”๋œ ๋™์งˆ ๊ตฐ์ง‘ ์ถ”์ถœ
    df["cluster_id"] = hdbscan.HDBSCAN(
        min_cluster_size=min_cluster_size, cluster_selection_method="leaf"
    ).fit_predict(reduced)

    clusters = []
    for cid, g in df[df["cluster_id"] != -1].groupby("cluster_id"):
        # ํด๋Ÿฌ์Šคํ„ฐ ์ „์ฒด๊ฐ€ ์‹œ๋“œ์™€ ๋А์Šจํ•œ ํŒจ์…˜ ์žกํ™”๋ฉด ๊ตฐ์ง‘์งธ ์ œ์™ธ
        if "relevance" in g.columns and g["relevance"].mean() < CLUSTER_RELEVANCE_THRESHOLD:
            continue
        g = g.sort_values("total_volume", ascending=False)
        clusters.append({
            "cluster_id": int(cid),
            "cluster_label": g.iloc[0]["keyword"],          # ๋Œ€ํ‘œ ํ‚ค์›Œ๋“œ(์ตœ๊ณ  ๊ฒ€์ƒ‰๋Ÿ‰)
            "keyword_count": int(len(g)),
            "total_search_volume": int(g["total_volume"].sum()),
            "top_keywords": g["keyword"].head(4).tolist(),  # ์ฃผ์š” ํฌํ•จ ํ‚ค์›Œ๋“œ
        })

    clusters.sort(key=lambda c: c["total_search_volume"], reverse=True)
    return clusters