Spaces:
Sleeping
Sleeping
Download tools.py from mahir123456/topic_modelling_bert: direct link, hf CLI and curl.
- Browser
- Download file 15.6 kB
-
https://huggingface.co/spaces/mahir123456/topic_modelling_bert/resolve/main/tools.py
- Command line
-
hf download hf://spaces/mahir123456/topic_modelling_bert/tools.py
-
curl -L -o tools.py https://huggingface.co/spaces/mahir123456/topic_modelling_bert/resolve/main/tools.py
15.6 kB
| """ | |
| tools.py - BERTopic Agentic AI Tools | |
| Stateless, functional tools with zero try/except blocks. | |
| All errors propagate to LLM via @tool. | |
| """ | |
| import pandas as pd | |
| import numpy as np | |
| import re | |
| import json | |
| from pathlib import Path | |
| from typing import Dict, Any, List | |
| import nltk | |
| from nltk.tokenize import sent_tokenize | |
| from sentence_transformers import SentenceTransformer | |
| from sklearn.cluster import AgglomerativeClustering | |
| from sklearn.metrics.pairwise import cosine_similarity | |
| from bertopic import BERTopic | |
| from bertopic.vectorizers import ClassTfidfTransformer | |
| from bertopic.representation import KeyBERTInspired | |
| import plotly.graph_objects as go | |
| from plotly.subplots import make_subplots | |
| import plotly.express as px | |
| from langchain_core.tools import tool | |
| from langchain_mistralai import ChatMistralAI | |
| from langchain_core.prompts import PromptTemplate | |
| from langchain_core.output_parsers import JsonOutputParser | |
| # Download NLTK data | |
| nltk.download('punkt', quiet=True) | |
| nltk.download('punkt_tab', quiet=True) | |
| # Global paths | |
| DATA_DIR = Path("./data") | |
| DATA_DIR.mkdir(exist_ok=True) | |
| def clean_abstract_text(text: str) -> str: | |
| """Remove publisher boilerplate noise from abstracts.""" | |
| patterns = [ | |
| r'©\s*\d{4}.*?rights reserved', | |
| r'All rights reserved', | |
| r'Elsevier.*?Ltd', | |
| r'Springer.*?Verlag', | |
| r'Published by.*?$', | |
| r'\[ABSTRACT FROM AUTHOR\]', | |
| r'\[ABSTRACT FROM PUBLISHER\]', | |
| ] | |
| cleaned = text | |
| cleaned = re.sub('|'.join(patterns), '', cleaned, flags=re.IGNORECASE) | |
| cleaned = re.sub(r'\s+', ' ', cleaned).strip() | |
| return cleaned | |
| def load_scopus_csv(filepath: str) -> str: | |
| """ | |
| Load Scopus CSV, count papers, split abstracts into sentences, remove boilerplate. | |
| Returns stats string with paper count and sentence count. | |
| """ | |
| # FIX: Use encoding_errors='replace' to prevent Scopus invisible character crashes | |
| df = pd.read_csv(filepath, encoding='utf-8-sig', encoding_errors='replace') | |
| paper_count = len(df) | |
| df['Abstract'] = df['Abstract'].fillna('').astype(str) | |
| df['cleaned_abstract'] = df['Abstract'].apply(clean_abstract_text) | |
| sentences = [] | |
| paper_ids = [] | |
| for idx, row in df.iterrows(): | |
| sents = sent_tokenize(row['cleaned_abstract']) | |
| sentences.extend(sents) | |
| paper_ids.extend([idx] * len(sents)) | |
| sentence_df = pd.DataFrame({ | |
| 'sentence': sentences, | |
| 'paper_id': paper_ids | |
| }) | |
| sentence_df.to_csv(DATA_DIR / 'sentences.csv', index=False) | |
| df.to_csv(DATA_DIR / 'papers.csv', index=False) | |
| return f"Loaded {paper_count} papers, extracted {len(sentences)} sentences. Saved to data/sentences.csv and data/papers.csv" | |
| def run_bertopic_discovery(run_key: str, threshold: float = 0.7) -> str: | |
| """ | |
| Embed sentences with all-MiniLM-L6-v2, cluster in 384d space using AgglomerativeClustering. | |
| NO UMAP dimensionality reduction. Generate Plotly charts and save outputs. | |
| """ | |
| sentences_df = pd.read_csv(DATA_DIR / 'sentences.csv') | |
| sentences = sentences_df['sentence'].tolist() | |
| # Embed sentences | |
| embedder = SentenceTransformer('all-MiniLM-L6-v2') | |
| embeddings = embedder.encode(sentences, normalize_embeddings=True, show_progress_bar=True) | |
| # Cluster directly in 384d space | |
| clusterer = AgglomerativeClustering( | |
| n_clusters=None, | |
| metric='cosine', | |
| linkage='average', | |
| distance_threshold=threshold | |
| ) | |
| cluster_labels = clusterer.fit_predict(embeddings) | |
| # Build topic summaries | |
| unique_labels = sorted(set(cluster_labels)) | |
| topic_summaries = [] | |
| for label in unique_labels: | |
| cluster_mask = cluster_labels == label | |
| cluster_embeddings = embeddings[cluster_mask] | |
| cluster_sentences_idx = np.where(cluster_mask)[0] | |
| centroid = cluster_embeddings.mean(axis=0).reshape(1, -1) | |
| similarities = cosine_similarity(cluster_embeddings, centroid).flatten() | |
| top_5_idx = similarities.argsort()[-5:][::-1] | |
| top_sentences = [sentences[cluster_sentences_idx[i]] for i in top_5_idx] | |
| paper_ids = sentences_df.iloc[cluster_sentences_idx]['paper_id'].unique() | |
| topic_summaries.append({ | |
| 'topic_id': int(label), | |
| 'sentence_count': int(cluster_mask.sum()), | |
| 'paper_count': int(len(paper_ids)), | |
| 'top_sentences': top_sentences, | |
| 'centroid': centroid.tolist() | |
| }) | |
| # Save outputs | |
| run_dir = DATA_DIR / run_key | |
| run_dir.mkdir(parents=True, exist_ok=True) | |
| with open(run_dir / 'summaries.json', 'w') as f: | |
| json.dump(topic_summaries, f, indent=2) | |
| np.save(run_dir / 'emb.npy', embeddings) | |
| np.save(run_dir / 'labels.npy', cluster_labels) | |
| # Generate Plotly charts | |
| embeddings_2d = embeddings[:, :2] # Use first 2 dimensions for visualization | |
| # Chart 1: Intertopic Distance Map | |
| fig1 = px.scatter( | |
| x=embeddings_2d[:, 0], | |
| y=embeddings_2d[:, 1], | |
| color=cluster_labels, | |
| title='Intertopic Distance Map', | |
| labels={'x': 'Dimension 1', 'y': 'Dimension 2', 'color': 'Topic'} | |
| ) | |
| fig1.write_html(run_dir / 'intertopic_map.html', include_plotlyjs='cdn') | |
| # Chart 2: Topic Bars | |
| topic_sizes = pd.Series(cluster_labels).value_counts().sort_index() | |
| fig2 = go.Figure(data=[go.Bar(x=topic_sizes.index, y=topic_sizes.values)]) | |
| fig2.update_layout(title='Topic Sizes', xaxis_title='Topic ID', yaxis_title='Sentence Count') | |
| fig2.write_html(run_dir / 'topic_bars.html', include_plotlyjs='cdn') | |
| # Chart 3: Hierarchy (dendrogram simulation) | |
| fig3 = go.Figure(data=[go.Scatter( | |
| x=list(range(len(unique_labels))), | |
| y=[len([c for c in cluster_labels if c == l]) for l in unique_labels], | |
| mode='markers+lines', | |
| marker=dict(size=10) | |
| )]) | |
| fig3.update_layout(title='Topic Hierarchy', xaxis_title='Topic ID', yaxis_title='Size') | |
| fig3.write_html(run_dir / 'hierarchy.html', include_plotlyjs='cdn') | |
| # Chart 4: Heatmap (topic similarity) | |
| topic_centroids = np.array([np.mean(embeddings[cluster_labels == l], axis=0) for l in unique_labels]) | |
| similarity_matrix = cosine_similarity(topic_centroids) | |
| fig4 = go.Figure(data=go.Heatmap(z=similarity_matrix, x=unique_labels, y=unique_labels)) | |
| fig4.update_layout(title='Topic Similarity Heatmap') | |
| fig4.write_html(run_dir / 'heatmap.html', include_plotlyjs='cdn') | |
| return f"Discovered {len(unique_labels)} topics. Saved summaries, embeddings, and 4 charts to {run_dir}" | |
| def label_topics_with_llm(run_key: str) -> str: | |
| """ | |
| Send top 100 topics to Mistral API for labeling. | |
| Parse JSON output: label, category, confidence, reasoning, niche. | |
| """ | |
| run_dir = DATA_DIR / run_key | |
| with open(run_dir / 'summaries.json', 'r') as f: | |
| summaries = json.load(f) | |
| top_100 = sorted(summaries, key=lambda x: x['sentence_count'], reverse=True)[:100] | |
| llm = ChatMistralAI(model='mistral-small-latest', temperature=0) | |
| parser = JsonOutputParser() | |
| prompt = PromptTemplate( | |
| template="""You are a research domain expert. Analyze these topic clusters from academic papers and provide structured labels. | |
| For each topic, return JSON with: | |
| - label: Short research area name (2-5 words) | |
| - category: Broader domain (e.g., "Machine Learning", "Healthcare", "Finance") | |
| - confidence: 0-1 score | |
| - reasoning: Brief explanation | |
| - niche: true if highly specialized, false if mainstream | |
| Topics: | |
| {topics} | |
| Return a JSON array with one object per topic. {format_instructions}""", | |
| input_variables=['topics'], | |
| partial_variables={'format_instructions': parser.get_format_instructions()} | |
| ) | |
| topics_text = json.dumps([{ | |
| 'topic_id': t['topic_id'], | |
| 'top_sentences': t['top_sentences'][:3] | |
| } for t in top_100], indent=2) | |
| chain = prompt | llm | parser | |
| labels = chain.invoke({'topics': topics_text}) | |
| with open(run_dir / 'labels.json', 'w') as f: | |
| json.dump(labels, f, indent=2) | |
| return f"Labeled {len(labels)} topics. Saved to {run_dir / 'labels.json'}" | |
| def consolidate_into_themes(run_key: str, theme_map: Dict[str, List[int]]) -> str: | |
| """ | |
| Group topics into researcher-approved themes. | |
| Recompute centroids, recount sentences/papers. | |
| """ | |
| run_dir = DATA_DIR / run_key | |
| with open(run_dir / 'summaries.json', 'r') as f: | |
| summaries = json.load(f) | |
| embeddings = np.load(run_dir / 'emb.npy') | |
| cluster_labels = np.load(run_dir / 'labels.npy') | |
| summaries_dict = {s['topic_id']: s for s in summaries} | |
| themes = [] | |
| for theme_name, topic_ids in theme_map.items(): | |
| topic_ids = [int(tid) for tid in topic_ids] | |
| theme_mask = np.isin(cluster_labels, topic_ids) | |
| theme_embeddings = embeddings[theme_mask] | |
| centroid = theme_embeddings.mean(axis=0) | |
| sentence_count = int(theme_mask.sum()) | |
| paper_ids = set() | |
| for tid in topic_ids: | |
| sentences_df = pd.read_csv(DATA_DIR / 'sentences.csv') | |
| topic_sentences = sentences_df[cluster_labels == tid] | |
| paper_ids.update(topic_sentences['paper_id'].unique()) | |
| themes.append({ | |
| 'theme_name': theme_name, | |
| 'topic_ids': topic_ids, | |
| 'sentence_count': sentence_count, | |
| 'paper_count': len(paper_ids), | |
| 'centroid': centroid.tolist() | |
| }) | |
| with open(run_dir / 'themes.json', 'w') as f: | |
| json.dump(themes, f, indent=2) | |
| return f"Consolidated {len(themes)} themes. Saved to {run_dir / 'themes.json'}" | |
| def compare_with_taxonomy(run_key: str) -> str: | |
| """ | |
| Map themes to PAJAIS 25-category taxonomy using Mistral. | |
| Return pajais_match, confidence, reasoning, is_novel. | |
| """ | |
| run_dir = DATA_DIR / run_key | |
| with open(run_dir / 'themes.json', 'r') as f: | |
| themes = json.load(f) | |
| pajais_categories = [ | |
| "Artificial Intelligence", "Blockchain", "Business Analytics", "Cloud Computing", | |
| "Cybersecurity", "Data Science", "Digital Innovation", "E-Commerce", "Enterprise Systems", | |
| "Ethics and Privacy", "Healthcare IT", "Human-Computer Interaction", "Internet of Things", | |
| "IT Governance", "Knowledge Management", "Mobile Computing", "Social Media", | |
| "Software Development", "Strategic IS", "Supply Chain", "Technology Adoption", | |
| "Telecommunications", "Virtual Reality", "Web Technologies", "NOVEL" | |
| ] | |
| llm = ChatMistralAI(model='mistral-small-latest', temperature=0) | |
| parser = JsonOutputParser() | |
| prompt = PromptTemplate( | |
| template="""Map each research theme to the closest PAJAIS category. | |
| PAJAIS Categories: {categories} | |
| Themes: | |
| {themes} | |
| For each theme, return JSON with: | |
| - theme_name: Original theme name | |
| - pajais_match: Best matching category (or NOVEL if none fit) | |
| - confidence: 0-1 score | |
| - reasoning: Explanation | |
| - is_novel: true if NOVEL, false otherwise | |
| Return a JSON array. {format_instructions}""", | |
| input_variables=['themes', 'categories'], | |
| partial_variables={'format_instructions': parser.get_format_instructions()} | |
| ) | |
| themes_text = json.dumps([{ | |
| 'theme_name': t['theme_name'], | |
| 'sentence_count': t['sentence_count'], | |
| 'paper_count': t['paper_count'] | |
| } for t in themes], indent=2) | |
| chain = prompt | llm | parser | |
| mappings = chain.invoke({ | |
| 'themes': themes_text, | |
| 'categories': ', '.join(pajais_categories) | |
| }) | |
| with open(run_dir / 'taxonomy_map.json', 'w') as f: | |
| json.dump(mappings, f, indent=2) | |
| return f"Mapped {len(mappings)} themes to taxonomy. Saved to {run_dir / 'taxonomy_map.json'}" | |
| def generate_comparison_csv() -> str: | |
| """ | |
| Load themes from abstract and title runs. | |
| Create side-by-side comparison DataFrame. | |
| """ | |
| abstract_dir = DATA_DIR / 'abstract_run' | |
| title_dir = DATA_DIR / 'title_run' | |
| with open(abstract_dir / 'themes.json', 'r') as f: | |
| abstract_themes = json.load(f) | |
| with open(title_dir / 'themes.json', 'r') as f: | |
| title_themes = json.load(f) | |
| rows = [] | |
| max_len = max(len(abstract_themes), len(title_themes)) | |
| for i in range(max_len): | |
| row = {} | |
| if i < len(abstract_themes): | |
| at = abstract_themes[i] | |
| row['abstract_theme'] = at['theme_name'] | |
| row['abstract_sentences'] = at['sentence_count'] | |
| row['abstract_papers'] = at['paper_count'] | |
| else: | |
| row['abstract_theme'] = '' | |
| row['abstract_sentences'] = 0 | |
| row['abstract_papers'] = 0 | |
| if i < len(title_themes): | |
| tt = title_themes[i] | |
| row['title_theme'] = tt['theme_name'] | |
| row['title_sentences'] = tt['sentence_count'] | |
| row['title_papers'] = tt['paper_count'] | |
| else: | |
| row['title_theme'] = '' | |
| row['title_sentences'] = 0 | |
| row['title_papers'] = 0 | |
| rows.append(row) | |
| comparison_df = pd.DataFrame(rows) | |
| comparison_df.to_csv(DATA_DIR / 'comparison.csv', index=False) | |
| return f"Generated comparison CSV with {len(rows)} rows. Saved to {DATA_DIR / 'comparison.csv'}" | |
| def export_narrative(run_key: str) -> str: | |
| """ | |
| Send themes and taxonomy to Mistral to draft a 500-word Section 7 literature review. | |
| References B&C phases. | |
| """ | |
| run_dir = DATA_DIR / run_key | |
| with open(run_dir / 'themes.json', 'r') as f: | |
| themes = json.load(f) | |
| with open(run_dir / 'taxonomy_map.json', 'r') as f: | |
| taxonomy = json.load(f) | |
| llm = ChatMistralAI(model='mistral-small-latest', temperature=0.7) | |
| prompt = PromptTemplate( | |
| template="""You are writing Section 7 (Literature Review) for an academic paper on IS research trends. | |
| Themes discovered: | |
| {themes} | |
| Taxonomy mapping: | |
| {taxonomy} | |
| Write a 500-word narrative that: | |
| 1. References the Bandara & Cresswell (B&C) framework phases | |
| 2. Highlights dominant research themes | |
| 3. Notes novel or emerging areas | |
| 4. Connects to PAJAIS categories | |
| 5. Uses academic tone | |
| Output only the prose, no preamble.""", | |
| input_variables=['themes', 'taxonomy'] | |
| ) | |
| themes_text = json.dumps([{ | |
| 'theme_name': t['theme_name'], | |
| 'paper_count': t['paper_count'] | |
| } for t in themes], indent=2) | |
| taxonomy_text = json.dumps([{ | |
| 'theme_name': m['theme_name'], | |
| 'pajais_match': m['pajais_match'], | |
| 'is_novel': m['is_novel'] | |
| } for m in taxonomy], indent=2) | |
| chain = prompt | llm | |
| narrative = chain.invoke({'themes': themes_text, 'taxonomy': taxonomy_text}) | |
| narrative_text = narrative.content | |
| with open(run_dir / 'narrative.txt', 'w') as f: | |
| f.write(narrative_text) | |
| return f"Generated 500-word narrative. Saved to {run_dir / 'narrative.txt'}" |