File size: 6,741 Bytes
d52dd8c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
embedder.py β€” Semantic Embedding Vector Generator & Visualizer.
Features:
  - Generates per-word and per-sentence embeddings using sentence-transformers
  - PCA / t-SNE dimensionality reduction for 2D visualization
  - UMAP reduction for cluster visualization (if umap-learn installed)
  - Cosine semantic similarity heatmap between sentences
  - Word cluster labeling
"""

import numpy as np
import streamlit as st
from typing import List, Dict, Any, Optional, Tuple


@st.cache_resource(show_spinner="πŸ”’ Loading embedding model (one-time)...")
def get_embedding_model(model_name: str = "all-MiniLM-L6-v2"):
    """Load and cache the sentence-transformer embedding model."""
    from sentence_transformers import SentenceTransformer
    return SentenceTransformer(model_name)


def embed_texts(
    texts: List[str],
    model_name: str = "all-MiniLM-L6-v2",
    batch_size: int = 32
) -> np.ndarray:
    """
    Embed a list of texts into dense vectors.
    Returns ndarray of shape (N, embedding_dim).
    """
    if not texts:
        return np.array([])
    model = get_embedding_model(model_name)
    embeddings = model.encode(
        texts,
        batch_size=batch_size,
        show_progress_bar=False,
        normalize_embeddings=True,
    )
    return embeddings


def reduce_pca(embeddings: np.ndarray, n_components: int = 2) -> np.ndarray:
    """Reduce embedding dimensions using PCA."""
    from sklearn.decomposition import PCA
    if len(embeddings) < n_components:
        return embeddings
    pca = PCA(n_components=n_components, random_state=42)
    return pca.fit_transform(embeddings)


def reduce_tsne(embeddings: np.ndarray, n_components: int = 2, perplexity: int = None) -> np.ndarray:
    """Reduce embedding dimensions using t-SNE."""
    from sklearn.manifold import TSNE
    n = len(embeddings)
    if n < 4:
        return reduce_pca(embeddings, n_components)
    perp = min(perplexity or 30, n - 1)
    tsne = TSNE(
        n_components=n_components,
        perplexity=perp,
        random_state=42,
        n_iter=500,
        learning_rate="auto",
        init="pca"
    )
    return tsne.fit_transform(embeddings)


def reduce_umap(embeddings: np.ndarray, n_components: int = 2) -> Optional[np.ndarray]:
    """Reduce embedding dimensions using UMAP (if available)."""
    try:
        import umap
        reducer = umap.UMAP(n_components=n_components, random_state=42)
        return reducer.fit_transform(embeddings)
    except ImportError:
        return None


def cosine_similarity_matrix(embeddings: np.ndarray) -> np.ndarray:
    """Compute pairwise cosine similarity matrix (assumes normalized embeddings)."""
    norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
    normalized = embeddings / np.clip(norms, 1e-8, None)
    return normalized @ normalized.T


def cluster_words(embeddings: np.ndarray, n_clusters: int = 5) -> np.ndarray:
    """K-Means cluster the embeddings, return cluster labels."""
    from sklearn.cluster import KMeans
    n = len(embeddings)
    k = min(n_clusters, max(1, n // 3))
    kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
    return kmeans.fit_predict(embeddings)


def build_embedding_data(
    ocr_results: List[Dict[str, Any]],
    text_unit: str = "word",  # "word" | "sentence" | "line"
    reduction: str = "PCA",    # "PCA" | "t-SNE" | "UMAP"
    n_clusters: int = 5,
) -> Dict[str, Any]:
    """
    Full embedding pipeline:
    1. Extract text units from OCR results
    2. Generate embeddings
    3. Reduce to 2D
    4. Cluster
    5. Return structured data for plotting

    Returns dict with keys:
        texts, embeddings, coords_2d, cluster_labels, similarity_matrix
    """
    if not ocr_results:
        return {}

    # ── Step 1: Extract text units ──
    if text_unit == "word":
        texts = [r["text"] for r in ocr_results if r["text"].strip()]
    elif text_unit == "sentence":
        full = " ".join(r["text"] for r in ocr_results)
        # Simple sentence split
        import re
        sentences = re.split(r"(?<=[.!?])\s+", full)
        texts = [s.strip() for s in sentences if len(s.strip()) > 3]
    else:  # line
        # Group words into lines by y-proximity
        sorted_results = sorted(ocr_results, key=lambda r: r["y_min"])
        lines = []
        current_line_y = None
        current_line_texts = []
        for r in sorted_results:
            if current_line_y is None or abs(r["y_min"] - current_line_y) < 20:
                current_line_texts.append(r["text"])
                current_line_y = r["y_min"]
            else:
                if current_line_texts:
                    lines.append(" ".join(current_line_texts))
                current_line_texts = [r["text"]]
                current_line_y = r["y_min"]
        if current_line_texts:
            lines.append(" ".join(current_line_texts))
        texts = [l for l in lines if l.strip()]

    if not texts:
        return {}

    # ── Step 2: Embed ──
    embeddings = embed_texts(texts)
    if embeddings.size == 0:
        return {}

    # ── Step 3: Reduce to 2D ──
    if reduction == "t-SNE" and len(texts) >= 4:
        coords_2d = reduce_tsne(embeddings)
    elif reduction == "UMAP":
        coords_2d = reduce_umap(embeddings)
        if coords_2d is None:
            coords_2d = reduce_pca(embeddings)
    else:
        coords_2d = reduce_pca(embeddings)

    # ── Step 4: Cluster ──
    if len(texts) >= 2:
        cluster_labels = cluster_words(embeddings, n_clusters)
    else:
        cluster_labels = np.zeros(len(texts), dtype=int)

    # ── Step 5: Similarity matrix ──
    if len(texts) <= 60:
        sim_matrix = cosine_similarity_matrix(embeddings)
    else:
        sim_matrix = None

    # ── Step 6: Top-N similar pairs ──
    similar_pairs = []
    if sim_matrix is not None and len(texts) > 1:
        for i in range(len(texts)):
            for j in range(i + 1, len(texts)):
                similar_pairs.append({
                    "i": i,
                    "j": j,
                    "text_i": texts[i],
                    "text_j": texts[j],
                    "similarity": float(sim_matrix[i, j]),
                })
        similar_pairs.sort(key=lambda x: x["similarity"], reverse=True)
        similar_pairs = similar_pairs[:10]

    return {
        "texts": texts,
        "embeddings": embeddings,
        "coords_2d": coords_2d,
        "cluster_labels": cluster_labels.tolist(),
        "similarity_matrix": sim_matrix,
        "similar_pairs": similar_pairs,
        "embedding_dim": embeddings.shape[1] if len(embeddings.shape) > 1 else 0,
        "n_texts": len(texts),
        "reduction_method": reduction,
    }