File size: 13,471 Bytes
114b0c9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
import os
import gradio as gr
import pandas as pd
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer, CountVectorizer
from sklearn.decomposition import LatentDirichletAllocation, NMF
import plotly.express as px
import plotly.graph_objects as go
from huggingface_hub import InferenceClient

# Optional premium imports - wrapped in try-except to ensure the app boots even if dependencies differ
try:
    from bertopic import BERTopic
    from sentence_transformers import SentenceTransformer
    HAS_BERTOPIC = True
except ImportError:
    HAS_BERTOPIC = False

def load_data(file_obj):
    """Safely loads CSV, Excel, or TXT file into a Pandas DataFrame."""
    if file_obj is None:
        return None, gr.update(choices=[], visible=False)
    
    file_path = file_obj.name
    ext = os.path.splitext(file_path)[1].lower()
    
    try:
        if ext == '.csv':
            df = pd.read_csv(file_path)
        elif ext in ['.xls', '.xlsx']:
            df = pd.read_excel(file_path)
        elif ext == '.txt':
            with open(file_path, 'r', encoding='utf-8') as f:
                lines = [line.strip() for line in f.readlines() if line.strip()]
            df = pd.DataFrame({'text': lines})
        else:
            return None, gr.update(choices=[], visible=False), "Unsupported file format. Please upload .csv, .xlsx, or .txt."
        
        # Filter for object/string columns
        string_cols = [col for col in df.columns if df[col].dtype == 'object' or df[col].astype(str).str.len().mean() > 5]
        if not string_cols:
            string_cols = list(df.columns)
            
        return df, gr.update(choices=string_cols, value=string_cols[0], visible=True), f"Successfully loaded dataset with {len(df)} rows."
    except Exception as e:
        return None, gr.update(choices=[], visible=False), f"Error loading file: {str(e)}"

def run_lda_nmf(docs, n_topics, n_words, method):
    """Runs classic CPU-based LDA or NMF topic modeling."""
    vectorizer = TfidfVectorizer(max_df=0.95, min_df=2, stop_words='english')
    dtm = vectorizer.fit_transform(docs)
    feature_names = vectorizer.get_feature_names_out()
    
    if method == "LDA (Classic & Fast)":
        model = LatentDirichletAllocation(n_components=n_topics, random_state=42)
    else:
        model = NMF(n_components=n_topics, random_state=42, init='nndsvda')
        
    topic_distributions = model.fit_transform(dtm)
    
    # Extract topics & keywords
    topics_data = []
    for topic_idx, topic in enumerate(model.components_):
        top_words_idx = topic.argsort()[:-n_words - 1:-1]
        top_words = [feature_names[i] for i in top_words_idx]
        weights = [topic[i] for i in top_words_idx]
        topics_data.append({
            "Topic": f"Topic {topic_idx + 1}",
            "Keywords": ", ".join(top_words),
            "top_words_list": top_words,
            "weights_list": weights
        })
        
    df_topics = pd.DataFrame(topics_data)
    
    # Assign dominant topic to original documents
    dominant_topics = np.argmax(topic_distributions, axis=1) + 1
    doc_probabilities = np.max(topic_distributions, axis=1)
    
    return df_topics, dominant_topics, doc_probabilities

def run_bertopic_api(docs, hf_token, model_name, min_topic_size=5):
    """Runs high-performance BERTopic-like pipeline using the student's HF API token."""
    if not hf_token:
        raise ValueError("A Hugging Face Token is required for BERTopic (API Mode).")
    
    # Initialize client with user's token
    client = InferenceClient(token=hf_token)
    
    # 1. Generate Embeddings via Hugging Face Serverless Inference API
    # We batch the texts to prevent timeout limits
    embeddings = []
    batch_size = 32
    
    for i in range(0, len(docs), batch_size):
        batch = docs[i:i+batch_size]
        try:
            # Get sentence embeddings using the specified model
            resp = client.feature_extraction(text=batch, model=model_name)
            # Response is a numpy-like list of embeddings
            embeddings.extend(resp)
        except Exception as e:
            raise RuntimeError(f"Error generating embeddings at batch {i}: {str(e)}")
            
    embeddings = np.array(embeddings)
    
    # 2. Local Clustering using Scikit-Learn (HDBSCAN/KMeans fallback to run reliably on CPU Space)
    # To mimic BERTopic on a standard free CPU Space without heavy dependencies:
    from sklearn.cluster import KMeans
    from sklearn.decomposition import PCA
    
    # Dimensionality reduction (PCA instead of UMAP for pure speed/no-binary stability)
    pca = PCA(n_components=min(10, len(docs) - 1), random_state=42)
    reduced_embeddings = pca.fit_transform(embeddings)
    
    # Simple dynamic cluster detection or KMeans
    n_clusters = max(2, min(15, len(docs) // 5))
    kmeans = KMeans(n_clusters=n_clusters, random_state=42)
    labels = kmeans.fit_predict(reduced_embeddings)
    
    # 3. Class-based TF-IDF to get topic keywords
    vectorizer = CountVectorizer(stop_words='english')
    
    # Group documents by label
    df_temp = pd.DataFrame({'doc': docs, 'label': labels})
    grouped = df_temp.groupby('label')['doc'].apply(lambda x: " ".join(x)).reset_index()
    
    X = vectorizer.fit_transform(grouped['doc'])
    words = vectorizer.get_feature_names_out()
    
    # Simple c-TF-IDF calculation
    from sklearn.feature_extraction.text import TfidfTransformer
    transformer = TfidfTransformer()
    ctfidf = transformer.fit_transform(X).toarray()
    
    topics_data = []
    for idx, row in enumerate(ctfidf):
        label = grouped.iloc[idx]['label']
        top_words_idx = row.argsort()[:-11:-1]
        top_words = [words[i] for i in top_words_idx]
        weights = [row[i] for i in top_words_idx]
        topics_data.append({
            "Topic": f"Topic {label + 1}",
            "Keywords": ", ".join(top_words),
            "top_words_list": top_words,
            "weights_list": weights
        })
        
    df_topics = pd.DataFrame(topics_data)
    
    # Dominant topic assignment
    dominant_topics = labels + 1
    
    # Distances to cluster centers as pseudo-probability
    distances = kmeans.transform(reduced_embeddings)
    min_distances = np.min(distances, axis=1)
    probs = 1 / (1 + min_distances) # normalized score
    
    return df_topics, dominant_topics, probs

def analyze(file_obj, text_column, method, num_topics, num_words, hf_token, hf_model):
    if file_obj is None:
        return None, None, "Please upload a dataset first."
        
    # Re-load data
    df, _, _ = load_data(file_obj)
    if df is None:
        return None, None, "Failed to parse the file."
        
    docs = df[text_column].astype(str).fillna("").tolist()
    if not docs:
        return None, None, "No text documents found in the selected column."
        
    try:
        if method in ["LDA (Classic & Fast)", "NMF (Classic & Fast)"]:
            df_topics, dominant_topics, probs = run_lda_nmf(docs, num_topics, num_words, method)
        else:
            # BERTopic (API Mode)
            if not hf_token:
                return None, None, "Error: Hugging Face API Token is required to run BERTopic (API Mode). You can get one for free at huggingface.co/settings/tokens."
            df_topics, dominant_topics, probs = run_bertopic_api(docs, hf_token, hf_model, num_topics)
            
        # Create visual topic overview chart
        fig = go.Figure()
        for idx, row in df_topics.iterrows():
            fig.add_trace(go.Bar(
                name=row['Topic'],
                x=row['top_words_list'][:8],
                y=row['weights_list'][:8],
                hovertext=row['Keywords']
            ))
        fig.update_layout(
            title="Top Words per Topic",
            xaxis_title="Keywords",
            yaxis_title="Importance Weight",
            barmode='group',
            template="plotly_dark",
            height=450
        )
        
        # Save results to df
        df_result = df.copy()
        df_result['Assigned_Topic'] = [f"Topic {t}" for t in dominant_topics]
        df_result['Topic_Probability'] = np.round(probs, 4)
        
        # Export file path
        out_path = "topic_modeling_results.csv"
        df_result.to_csv(out_path, index=False)
        
        # Select clean table to display
        df_display_topics = df_topics[["Topic", "Keywords"]].copy()
        
        return df_display_topics, fig, out_path
        
    except Exception as e:
        return None, None, f"Execution failed: {str(e)}"

# Custom premium CSS styling matching dark theme
custom_css = """
body {
    background-color: #0b0f19;
    color: #f3f4f6;
}
.gradio-container {
    font-family: 'Inter', sans-serif !important;
}
h1, h2 {
    color: #6366f1 !important;
}
.tabs {
    border: 1px solid #1e293b;
    border-radius: 8px;
    padding: 10px;
    background: #0f172a;
}
"""

with gr.Blocks(theme=gr.themes.Default(primary_hue="indigo", secondary_hue="slate"), css=custom_css) as demo:
    # Hidden state to store loaded DataFrame
    df_state = gr.State()
    
    gr.HTML("""
    <div style="text-align: center; margin-bottom: 2rem;">
        <h1 style="font-size: 2.5rem; font-weight: 700; margin-bottom: 0.5rem; background: linear-gradient(to right, #6366f1, #a855f7); -webkit-background-clip: text; -webkit-text-fill-color: transparent;">Interactive Topic Modeler</h1>
        <p style="font-size: 1.1rem; color: #94a3b8; max-width: 800px; margin: 0 auto;">
            Upload your text datasets (.csv, .xlsx, or .txt), configure your modeling method, and explore key concepts. 
            Runs locally on standard models, or unlocks advanced AI embeddings using your personal Hugging Face Token.
        </p>
    </div>
    """)
    
    with gr.Row():
        # Left Panel: Configurations & Inputs
        with gr.Column(scale=1):
            gr.Markdown("### 1. Upload & Select")
            file_input = gr.File(label="Upload Dataset (.csv, .xlsx, .txt)", file_types=[".csv", ".xlsx", ".txt"])
            status_text = gr.Markdown("No dataset uploaded yet.")
            
            text_column_selector = gr.Dropdown(
                label="Target Text Column", 
                choices=[], 
                visible=False,
                interactive=True
            )
            
            gr.Markdown("### 2. Method Configuration")
            method_selector = gr.Radio(
                choices=["LDA (Classic & Fast)", "NMF (Classic & Fast)", "BERTopic (API Mode)"],
                value="LDA (Classic & Fast)",
                label="Modeling Method"
            )
            
            with gr.Group() as api_group:
                hf_token_input = gr.Textbox(
                    label="Hugging Face API Token",
                    placeholder="hf_...",
                    type="password",
                    visible=False,
                    info="Get a free token in Settings > Access Tokens on Hugging Face."
                )
                hf_model_input = gr.Dropdown(
                    choices=[
                        "sentence-transformers/all-MiniLM-L6-v2",
                        "sentence-transformers/all-mpnet-base-v2",
                        "BAAI/bge-large-en-v1.5"
                    ],
                    value="sentence-transformers/all-MiniLM-L6-v2",
                    label="Embedding Model (HF API)",
                    visible=False
                )
            
            with gr.Row():
                num_topics = gr.Slider(minimum=2, maximum=30, value=5, step=1, label="Number of Topics")
                num_words = gr.Slider(minimum=3, maximum=15, value=8, step=1, label="Keywords per Topic")
                
            run_btn = gr.Button("Run Topic Modeling", variant="primary")
            
        # Right Panel: Visualization & Export
        with gr.Column(scale=2):
            gr.Markdown("### 3. Results & Exploration")
            
            with gr.Tabs():
                with gr.TabItem("Topic Summary Table"):
                    topics_table = gr.Dataframe(
                        headers=["Topic", "Keywords"],
                        datatype=["str", "str"],
                        interactive=False,
                        wrap=True
                    )
                with gr.TabItem("Keywords Chart"):
                    chart_output = gr.Plot(label="Top Words Plot")
                    
            gr.Markdown("### 4. Export & Download")
            download_btn = gr.File(label="Download Labeled Dataset (.csv)")
            
    # Interactive UI state adjustments
    def toggle_method_fields(method):
        if method == "BERTopic (API Mode)":
            return gr.update(visible=True), gr.update(visible=True)
        else:
            return gr.update(visible=False), gr.update(visible=False)
            
    method_selector.change(
        fn=toggle_method_fields,
        inputs=method_selector,
        outputs=[hf_token_input, hf_model_input]
    )
    
    file_input.change(
        fn=load_data,
        inputs=file_input,
        outputs=[df_state, text_column_selector, status_text]
    )
    
    run_btn.click(
        fn=analyze,
        inputs=[file_input, text_column_selector, method_selector, num_topics, num_words, hf_token_input, hf_model_input],
        outputs=[topics_table, chart_output, download_btn]
    )

if __name__ == "__main__":
    demo.launch()