File size: 7,882 Bytes
ce11d27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# clustering.py

import json
import logging
import numpy as np
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
from tracescope.analysis.metric_adapter import learn_metric, safe_transform


# ── Global logger setup ──
logging.basicConfig(level=logging.INFO,
                    format="%(asctime)s %(levelname)s:%(message)s",
                    datefmt="%Y-%m-%d %H:%M:%S")
logger = logging.getLogger(__name__)


def cluster_embeddings(embedding_list, n_clusters=None, user_clusters=None, min_k=3, random_state=42):
    # build & normalize
    matrix = np.array(embedding_list, dtype=float)
    matrix = matrix / np.linalg.norm(matrix, axis=1, keepdims=True)
    n_samples = matrix.shape[0]
    logger.info(f"cluster_embeddings β–Ά n_samples={n_samples}, requested n_clusters={n_clusters}, random_state={random_state}")

    if n_samples < 2:
        raise ValueError("Need at least 2 samples for clustering")

    # auto‑select k by silhouette
    if n_clusters is None:
        max_k = min(10, n_samples - 1)

        if user_clusters:
            # 2-stage grid: Ξ± ∈ {0,.25,.5,.75,1} and k ∈ [2…max_k]
            matrix_orig = matrix.copy()
            L = learn_metric(matrix_orig, user_clusters)

            best_score = -1
            best_k = 2
            best_alpha = 0
            best_labels = None
            best_centroids = None

            for alpha in (0, 0.25, 0.5, 0.75, 1):
                XΞ± = safe_transform(matrix_orig, L, alpha)
                for k in range(2, max_k + 1):
                    km = KMeans(n_clusters=k, init='k-means++', random_state=random_state)
                    lbl = km.fit_predict(XΞ±)
                    sil = silhouette_score(XΞ±, lbl)
                    if sil > best_score:
                        best_score, best_k = sil, k
                        best_alpha = alpha
                        best_labels = lbl
                        best_centroids = km.cluster_centers_

            logger.info(
                f"user feedback β†’ Ξ±={best_alpha}, k={best_k}, silhouette={best_score:.4f}"
            )
            n_clusters = best_k
            labels = best_labels
            centroids = best_centroids

        else:
            # no feedback β†’ original silhouette-only loop
            best_score = -1
            best_k = min_k
            best_labels = None
            best_centroids = None

            for k in range(min_k, max_k + 1):
                logger.info(f"Trying k={k}")
                km = KMeans(n_clusters=k, init='k-means++', random_state=random_state)
                lbl = km.fit_predict(matrix)
                sil = silhouette_score(matrix, lbl)
                logger.info(f" k={k} silhouette={sil:.4f}")
                if sil > best_score:
                    best_score, best_k = sil, k
                    best_labels = lbl
                    best_centroids = km.cluster_centers_

            # unbalanced 2-cluster guard as before
            counts = np.bincount(best_labels)
            if best_k == 2 and (counts.min() < 2 or counts.max()/counts.min() > 10):
                logger.info("Detected unbalanced 2-cluster; searching k=3…max_k")
                alt_best = -1
                alt_labels = None
                alt_k = None
                for k in range(3, min(max_k + 1, 6)):
                    lbl = KMeans(n_clusters=k, random_state=random_state).fit_predict(matrix)
                    s = silhouette_score(matrix, lbl)
                    logger.info(f"  alt_k={k} silhouette={s:.4f}")
                    if s > alt_best:
                        alt_best, alt_k, alt_labels = s, k, lbl
                if alt_k:
                    logger.info(f"Switching to k={alt_k} with silhouette={alt_best:.4f}")
                    best_k, best_labels = alt_k, alt_labels
                    best_centroids = KMeans(n_clusters=alt_k, random_state=random_state).fit(matrix).cluster_centers_

            n_clusters = best_k
            labels = best_labels
            centroids = best_centroids

    else:
        kmeans = KMeans(n_clusters=n_clusters, init='k-means++', random_state=random_state)
        labels = kmeans.fit_predict(matrix)
        centroids = kmeans.cluster_centers_

    # build output
    clusters = []
    for i in range(n_clusters):
        idxs = [int(idx) for idx, lbl in enumerate(labels) if lbl == i]
        clusters.append({
            "cluster_id": i,
            "indices": idxs,
            "centroid": centroids[i].tolist()
        })

    return {
        "n_clusters": n_clusters,
        "clusters": clusters,
        "labels": labels.tolist(),
    }

def main(embeddings_json, n_clusters=None, aspect=None, user_clusters=None, compute_moving=False, min_k=3, random_state=42):
    # Load all embeddings directly from JSON
    embedding_list = json.loads(embeddings_json)

      # 2) if user_clusters came in as a JSON string, decode it,
      # and make sure every index is an int

    # ────────────────────────────────────────────────────────────────────────
    # sanitize user_clusters: if it’s a JSON string, parse it; then
    # coerce absolutely every index to a Python int, or error.
    if user_clusters:
        # 1) parse JSON if we were handed a string
        if isinstance(user_clusters, str):
            try:
                user_clusters = json.loads(user_clusters)
            except Exception as e:
                raise ValueError(f"Could not parse user_clusters JSON: {e}")

        # 2) coerce every element to int
        try:
            user_clusters = [
                [int(idx) for idx in cluster]
                for cluster in user_clusters
            ]
        except Exception as e:
            raise ValueError(
                f"Invalid user_clusters format (must be List[List[int]]): {user_clusters}"
            ) from e
    # ────────────────────────────────────────────────────────────────────────

    # Cluster them (no deduplication or pruning)
    result = cluster_embeddings(
        embedding_list, n_clusters,
        user_clusters=user_clusters, min_k=min_k, random_state=random_state,
    )
    # print("RESULT:", result)

    # ── Optional moving‐average β€œcloseness” to each cluster ──
    if compute_moving:
        try:
            # rebuild & normalize matrix
            matrix = np.array(embedding_list, dtype=float)
            matrix = matrix / np.linalg.norm(matrix, axis=1, keepdims=True)
            n = matrix.shape[0]

            # weights decay with distance in list: w_{i,j} = 1 / (1 + |i-j|)
            idx = np.arange(n)
            W = 1 / (1 + np.abs(idx[:, None] - idx[None, :]))

            # compute weighted‐average embedding for each position
            weighted = (W[:, :, None] * matrix[None, :, :]).sum(axis=1) / W.sum(axis=1)[:, None]

            # extract centroids from the clustering result
            centroids = np.array([c["centroid"] for c in result["clusters"]])

            # for each position, compute similarity = 1/(1 + distance) to each centroid
            moving_sim = (
                1
                / (
                    1
                    + np.linalg.norm(
                        centroids[None, :, :] - weighted[:, None, :],
                        axis=2
                    )
                )
            ).tolist()

            result["moving_similarity"] = moving_sim

        except Exception as e:
            logger.error(f"Error computing moving‐average similarity: {e}")

    # Return the raw clustering result as JSON
    return json.dumps(result)