import streamlit as st import pandas as pd import numpy as np from sklearn.decomposition import LatentDirichletAllocation from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer from konlpy.tag import Okt import re import altair as alt import networkx as nx import io import matplotlib.pyplot as plt import matplotlib.font_manager as fm import colorsys from gensim.models import CoherenceModel import gensim.corpora as corpora from anthropic import Anthropic # ✅ 레거시 HUMAN_PROMPT, AI_PROMPT 제거 import os # Streamlit 페이지 설정 st.set_page_config(layout="wide", page_title="📊 토픽모델링 for SK", page_icon="📊") # KoNLPy 형태소 분석기 초기화 @st.cache_resource def load_okt(): return Okt() okt = load_okt() # 기본 불용어 목록 default_stop_words = ['이', '그', '저', '것', '수', '등', '들', '및', '에서', '그리고', '그래서', '또는', '그런데', '의', '대한', '간의'] @st.cache_data def preprocess_text(text, stop_words): text = re.sub(r'[^가-힣\s]', '', str(text)) _okt = load_okt() # ✅ 캐시 안전하게 형태소 분석기 로드 nouns = _okt.nouns(text) processed = [word for word in nouns if word not in stop_words and len(word) > 1] return ' '.join(processed) # Perplexity와 Coherence 계산 함수 @st.cache_data def calculate_perplexity_coherence(df, text_column, stop_words, topic_range): df = df.copy() # ✅ 원본 DataFrame 변경 방지 df['processed_text'] = df[text_column].apply(lambda x: preprocess_text(x, stop_words)) vectorizer = CountVectorizer(max_df=0.95, min_df=2) doc_term_matrix = vectorizer.fit_transform(df['processed_text']) words = vectorizer.get_feature_names_out() perplexities = [] coherences = [] dictionary = corpora.Dictionary([text.split() for text in df['processed_text']]) corpus = [dictionary.doc2bow(text.split()) for text in df['processed_text']] for n_topics in topic_range: lda = LatentDirichletAllocation(n_components=n_topics, random_state=42) lda_output = lda.fit_transform(doc_term_matrix) # Perplexity 계산 perplexity = lda.perplexity(doc_term_matrix) perplexities.append(perplexity) # Coherence 계산 lda_topics = [[words[i] for i in topic.argsort()[:-10 - 1:-1]] for topic in lda.components_] coherence_model_lda = CoherenceModel(topics=lda_topics, texts=[text.split() for text in df['processed_text']], dictionary=dictionary, coherence='c_v') coherence = coherence_model_lda.get_coherence() coherences.append(coherence) # Perplexity 변화율 계산 (감소율) perplexity_diffs = [-((perplexities[i] - perplexities[i-1]) / perplexities[i-1]) if i > 0 else 0 for i in range(len(perplexities))] # Coherence 변화율 계산 (증가율) coherence_diffs = [(coherences[i] - coherences[i-1]) / coherences[i-1] if i > 0 else 0 for i in range(len(coherences))] # Combined Score 계산 combined_scores = [] for p_diff, c_diff in zip(perplexity_diffs, coherence_diffs): if p_diff > 0 and c_diff > 0: combined_score = 2 / ((1/p_diff) + (1/c_diff)) else: combined_score = 0 combined_scores.append(combined_score) return perplexities, coherences, combined_scores, perplexity_diffs, coherence_diffs # Perplexity 및 Coherence 그래프 그리기 def plot_perplexity_coherence(topic_range, perplexities, coherences): df_metrics = pd.DataFrame({ '토픽 수': list(topic_range), 'Perplexity': perplexities, 'Coherence': coherences }) chart1 = alt.Chart(df_metrics).mark_line().encode( x=alt.X('토픽 수:Q', title='토픽 수', axis=alt.Axis(tickCount=len(list(topic_range)), values=list(topic_range))), y=alt.Y('Perplexity:Q', title='Perplexity', scale=alt.Scale(type='log')), tooltip=['토픽 수', 'Perplexity'] ).properties( width=600, height=400, title='Perplexity vs 토픽 수' ) chart2 = alt.Chart(df_metrics).mark_line(color='orange').encode( x=alt.X('토픽 수:Q', title='토픽 수', axis=alt.Axis(tickCount=len(list(topic_range)), values=list(topic_range))), y=alt.Y('Coherence:Q', title='Coherence'), tooltip=['토픽 수', 'Coherence'] ).properties( width=600, height=400, title='Coherence vs 토픽 수' ) st.altair_chart(chart1, use_container_width=True) st.altair_chart(chart2, use_container_width=True) best_coherence_index = np.argmax(coherences) best_topic_count = list(topic_range)[best_coherence_index] st.markdown(f"**추천 토픽 수**: Coherence 값이 가장 높은 **{best_topic_count}개의 토픽**") # LDA 및 TF-IDF 상위 단어를 포함한 토픽 모델링 수행 def perform_topic_modeling(df, text_column, num_topics, stop_words): df = df.copy() # ✅ 원본 DataFrame 변경 방지 df['processed_text'] = df[text_column].apply(lambda x: preprocess_text(x, stop_words)) vectorizer = CountVectorizer(max_df=0.95, min_df=2) doc_term_matrix = vectorizer.fit_transform(df['processed_text']) lda = LatentDirichletAllocation(n_components=num_topics, random_state=42) lda_output = lda.fit_transform(doc_term_matrix) tfidf_vectorizer = TfidfVectorizer(max_df=0.95, min_df=2) tfidf_matrix = tfidf_vectorizer.fit_transform(df['processed_text']) feature_names = vectorizer.get_feature_names_out() topic_results = [] for idx, topic in enumerate(lda.components_): lda_top_words = sorted([(feature_names[i], topic[i]) for i in range(len(topic))], key=lambda x: x[1], reverse=True)[:10] topic_docs = lda_output[:, idx].argsort()[::-1][:100] topic_tfidf = tfidf_matrix[topic_docs].mean(axis=0).A1 tfidf_top_words = sorted([(feature_names[i], topic_tfidf[i]) for i in range(len(topic_tfidf))], key=lambda x: x[1], reverse=True)[:10] weight = lda_output[:, idx].mean() * 100 topic_name = ", ".join([word for word, _ in lda_top_words[:5]]) topic_results.append({ 'topic_num': idx + 1, 'topic_name': topic_name, 'lda_words': [word for word, _ in lda_top_words], 'tfidf_words': [word for word, _ in tfidf_top_words], 'weight': weight }) return topic_results, lda, lda_output, tfidf_matrix, feature_names # 토픽 단어 네트워크 그래프 생성 def create_network_graph(topic_results, num_words=10): G = nx.Graph() colors = generate_colors(len(topic_results)) for idx, topic in enumerate(topic_results): words = topic['lda_words'][:num_words] color = colors[idx] for word in words: if not G.has_node(word): G.add_node(word, color=color) for i in range(len(words)): for j in range(i+1, len(words)): if not G.has_edge(words[i], words[j]): G.add_edge(words[i], words[j]) return G # 네트워크 그래프 시각화 및 다운로드 def plot_network_graph(G): # ✅ 폰트 파일 경로 유연하게 처리 font_path = "./NanumBarunGothic.ttf" if not os.path.exists(font_path): # HuggingFace Spaces 대체 경로 시도 alt_paths = [ "/usr/share/fonts/truetype/nanum/NanumBarunGothic.ttf", "/usr/share/fonts/NanumBarunGothic.ttf", ] for alt_path in alt_paths: if os.path.exists(alt_path): font_path = alt_path break if os.path.exists(font_path): font_prop = fm.FontProperties(fname=font_path) plt.rcParams['font.family'] = font_prop.get_name() else: font_prop = fm.FontProperties() # 시스템 기본 폰트 사용 (한글 깨질 수 있음) st.warning("한글 폰트 파일을 찾을 수 없습니다. NanumBarunGothic.ttf를 앱 루트에 배치해주세요.") plt.figure(figsize=(12, 8)) pos = nx.spring_layout(G, k=0.5, iterations=50, seed=42) node_colors = [G.nodes[node]['color'] for node in G.nodes()] nx.draw(G, pos, node_color=node_colors, with_labels=False, node_size=1000, edge_color='gray', width=0.5) for node, (x, y) in pos.items(): plt.text(x, y, node, fontsize=8, ha='center', va='center', fontproperties=font_prop, fontweight='bold', bbox=dict(facecolor='white', edgecolor='none', alpha=0.7)) plt.title("토픽 단어 네트워크", fontsize=16, fontproperties=font_prop) plt.axis('off') img_bytes = io.BytesIO() plt.savefig(img_bytes, format='png', dpi=300, bbox_inches='tight') img_bytes.seek(0) plt.close() return img_bytes # 색상 생성 def generate_colors(n): HSV_tuples = [(x * 1.0 / n, 0.5, 0.9) for x in range(n)] return ['#%02x%02x%02x' % tuple(int(x*255) for x in colorsys.hsv_to_rgb(*hsv)) for hsv in HSV_tuples] # 토픽 요약 테이블 생성 def display_topic_summary(topic_results): topic_summary_df = pd.DataFrame([ { '토픽 번호': f"토픽{info['topic_num']}", '비중': f"{info['weight']:.1f}%", 'LDA 상위 단어': ", ".join(info['lda_words'][:10]), 'TF-IDF 상위 단어': ", ".join(info['tfidf_words'][:10]) } for info in topic_results ]) st.table(topic_summary_df) # ✅ Claude API — Messages API로 전면 교체 def interpret_topics_full(api_key, topic_results): client = Anthropic(api_key=api_key) prompt = f"""다음은 LDA 토픽 모델링 결과로 나온 각 토픽의 정보입니다. 이를 바탕으로 전체 토픽을 종합적으로 해석해주세요: {", ".join([f"토픽 {info['topic_num']} ({info['weight']:.1f}%)" for info in topic_results])} 각 토픽의 주요 단어: """ for info in topic_results: prompt += f""" 토픽 {info['topic_num']} (비중: {info['weight']:.1f}%): LDA 상위 단어: {', '.join(info['lda_words'][:10])} TF-IDF 상위 단어: {', '.join(info['tfidf_words'][:10])} """ prompt += """ 위 정보를 바탕으로 다음 형식에 맞춰 답변해주세요: 1. 전체 문서의 주제 요약 (3-4문장): [여기에 전체 문서의 주제를 종합적으로 설명해주세요. 각 토픽의 비중을 고려하여 중요도를 반영해주세요.] 2. 각 토픽 요약: [각 토픽에 대해 다음 형식으로 요약해주세요] 토픽[번호] "[토픽명]" [비중]% • LDA 상위 단어 10개: [LDA 상위 단어 10개를 쉼표로 구분하여 나열] • TF-IDF 상위 단어 10개: [TF-IDF 상위 단어 10개를 쉼표로 구분하여 나열] • 토픽명 설명: [토픽명이 이렇게 지어진 이유를 1-2문장으로 설명해주세요. LDA와 TF-IDF 상위 단어들이 어떻게 이 토픽명과 연관되는지 설명하세요.] • 토픽 설명: [2-3문장으로 토픽의 전반적인 내용을 설명해주세요.] 주의사항: 1. 토픽명은 "[구체적인 토픽명]" 형식으로 작성해주세요. 반드시 8어절 이상으로 구체적이고 설명적으로 작성해야 합니다. 2. 예시: "구성원의 전문성 향상을 위한 체계적인 학습과 역량 개발 방안 모색", "조직의 장기적 성과 향상을 위한 핵심 학습 역량 강화 전략", "현재 컬리지 멤버들의 역할 고민과 향후 발전 방향에 대한 논의" 등 3. 토픽명은 단순히 단어를 나열하는 것이 아니라, 토픽의 핵심 주제나 의미를 잘 나타내는 구체적이고 설명적인 문구로 만들어주세요. 4. 각 토픽의 LDA 상위 단어와 TF-IDF 상위 단어 10개를 반드시 포함해주세요. 위 형식에 맞춰 답변해주세요. 사용자가 쉽게 복사하여 사용할 수 있도록 간결하고 명확하게 작성해주세요. """ try: # ✅ Messages API 사용 (2024년 이후 표준) message = client.messages.create( model="claude-sonnet-4-5-20250929", # ✅ 최신 모델로 업데이트 max_tokens=4096, messages=[ {"role": "user", "content": prompt} ] ) return message.content[0].text except Exception as e: return f"Claude API 호출 중 오류가 발생했습니다: {str(e)}" # CSV 다운로드 버튼 추가 def download_topic_assignment(df, key_suffix=''): csv = df.to_csv(index=False) st.download_button( label="토픽 할당 데이터 다운로드", data=csv, file_name='topic_assignment.csv', mime='text/csv', key=f"download_button_{key_suffix}" ) # 스타일 설정 st.markdown("""