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("""
mySUNI Crystal.B
""", unsafe_allow_html=True) st.markdown('

📊토픽모델링 for SK

', unsafe_allow_html=True) # 사이드바 설정 with st.sidebar: st.header('설정하기') api_key = st.text_input("Claude API 키를 입력하세요", type="password") if not api_key: api_key = os.environ.get("ANTHROPIC_API_KEY") st.caption("Claude API가 있으면 토픽 종합 해석까지 가능합니다. 공백으로 두면 기본적인 결과만 나옵니다.") stop_words_input = st.text_area("불용어 목록 (쉼표로 구분)", ', '.join(default_stop_words)) stop_words = [word.strip() for word in stop_words_input.split(',') if word.strip()] st.caption("결과를 보고 업데이트해주세요.") uploaded_file = st.file_uploader("CSV 파일을 업로드하세요", type="csv") st.caption("csv-UTF 형식을 사용해주세요!") # 데이터 로드 및 초기 설정 if uploaded_file is not None: try: df = pd.read_csv(uploaded_file) if df.empty: st.error("CSV 파일에 데이터가 없습니다.") else: st.success("파일이 성공적으로 업로드되었습니다.") st.subheader("데이터 미리보기") st.write(df.head()) text_column = st.selectbox("텍스트 컬럼을 선택하세요", df.columns) num_topics = st.slider("토픽 수를 선택하세요", 2, 20, 5) # 탭 설정 tab1, tab2, tab3 = st.tabs(["토픽 수 결정", "전체 분석", "조건부 분석"]) # 탭 1: 토픽 수 결정 with tab1: st.header("토픽 수 결정") topic_range = st.slider("토픽 수 범위 선택", min_value=2, max_value=20, value=(2, 10), step=1) if st.button("Perplexity 및 Coherence 계산"): topic_range_list = list(range(topic_range[0], topic_range[1] + 1)) # ✅ 명시적 리스트 변환 perplexities, coherences, combined_scores, perplexity_diffs, coherence_diffs = calculate_perplexity_coherence(df, text_column, stop_words, topic_range_list) plot_perplexity_coherence(topic_range_list, perplexities, coherences) df_metrics_combined = pd.DataFrame({ '토픽 수': topic_range_list, 'Combined Score': combined_scores }) chart3 = alt.Chart(df_metrics_combined).mark_line(color='green').encode( x=alt.X('토픽 수:Q', title='토픽 수', axis=alt.Axis(tickCount=len(topic_range_list), values=topic_range_list)), y=alt.Y('Combined Score:Q', title='Combined Score'), tooltip=['토픽 수', 'Combined Score'] ).properties( width=600, height=400, title='Combined Score vs 토픽 수' ) st.altair_chart(chart3, use_container_width=True) best_combined_index = np.argmax(combined_scores) best_topic_count = topic_range[0] + best_combined_index st.markdown(f""" **추천 토픽 수**: Perplexity와 Coherence의 변화율을 종합적으로 고려하면 **{best_topic_count}개의 토픽**을 추천합니다. - Perplexity 감소율이 가장 큰 토픽 수: {topic_range[0] + np.argmax(perplexity_diffs)} - Coherence 증가율이 가장 큰 토픽 수: {topic_range[0] + np.argmax(coherence_diffs)} - Combined Score가 가장 높은 토픽 수: {best_topic_count} """) # 탭 2: 전체 분석 with tab2: st.header("전체 데이터 분석 결과") with st.spinner("토픽 모델링 실행 중..."): topic_results, lda, lda_output, tfidf_matrix, feature_names = perform_topic_modeling(df, text_column, num_topics, stop_words) display_topic_summary(topic_results) st.header("토픽 비중 그래프") df_weights = pd.DataFrame({ '토픽': [f'토픽 {i+1}' for i in range(num_topics)], '비중': [result['weight'] for result in topic_results] }) colors = generate_colors(num_topics) chart = alt.Chart(df_weights).mark_bar().encode( x=alt.X('토픽:N', axis=alt.Axis(labelAngle=0)), y=alt.Y('비중:Q', axis=alt.Axis(format=',.1f')), color=alt.Color('토픽:N', scale=alt.Scale(range=colors)) ).properties( width=600, height=400, title='문서 내 토픽 비중 (%)' ) text = chart.mark_text( align='center', baseline='bottom', dy=-5 ).encode( text=alt.Text('비중:Q', format='.1f') ) st.altair_chart(chart + text, use_container_width=True) st.header("토픽 단어 네트워크 그래프") G = create_network_graph(topic_results, num_words=20) img_bytes = plot_network_graph(G) st.image(img_bytes, caption="토픽별 상위 20개 단어 네트워크", use_container_width=True) # ✅ use_container_width st.download_button( label="네트워크 그래프 다운로드", data=img_bytes, file_name="topic_network_graph.png", mime="image/png", ) df_with_topic = df.copy() # ✅ 원본 보존 df_with_topic['topic'] = lda_output.argmax(axis=1) + 1 download_topic_assignment(df_with_topic, key_suffix='전체분석') if api_key: st.subheader("토픽 종합 해석") with st.spinner("Claude AI로 토픽 해석 중..."): interpretation = interpret_topics_full(api_key, topic_results) st.text_area("해석 결과", value=interpretation, height=300) # 탭 3: 조건부 분석 with tab3: st.header("조건부 분석 결과") condition_column = st.selectbox("조건부 분석에 사용할 변수를 선택하세요", df.columns) if pd.api.types.is_numeric_dtype(df[condition_column]): min_val, max_val = df[condition_column].min(), df[condition_column].max() analysis_method = st.radio("분석 방법 선택", ["범위 선택", "임계값 기준"]) if analysis_method == "범위 선택": ranges = st.slider(f"{condition_column} 범위 선택", float(min_val), float(max_val), (float(min_val), float(max_val)), step=0.1) conditions = [((df[condition_column] >= ranges[0]) & (df[condition_column] <= ranges[1]), f"{ranges[0]} ~ {ranges[1]}")] else: threshold = st.number_input(f"{condition_column} 임계값 설정", min_value=float(min_val), max_value=float(max_val), value=float((min_val + max_val) / 2)) conditions = [ (df[condition_column] < threshold, f"{condition_column} < {threshold}"), (df[condition_column] >= threshold, f"{condition_column} >= {threshold}") ] else: unique_values = df[condition_column].unique() selected_values = st.multiselect(f"{condition_column} 값 선택", unique_values, default=list(unique_values)) # ✅ list() 변환 conditions = [(df[condition_column] == value, str(value)) for value in selected_values] num_topics_cond = st.slider("토픽 수를 선택하세요", 2, 20, 5, key="조건부토픽수") if st.button("조건부 토픽 모델링 실행"): for condition, condition_name in conditions: st.subheader(f"조건: {condition_name}") filtered_df = df[condition].copy() # ✅ copy() 추가 if filtered_df.empty: st.warning(f"조건 '{condition_name}'에 해당하는 데이터가 없습니다.") continue topic_results, lda, lda_output, tfidf_matrix, feature_names = perform_topic_modeling(filtered_df, text_column, num_topics_cond, stop_words) display_topic_summary(topic_results) st.write("토픽 단어 네트워크 그래프") G = create_network_graph(topic_results, num_words=20) img_bytes = plot_network_graph(G) st.image(img_bytes, caption=f"토픽별 상위 20개 단어 네트워크 (조건: {condition_name})", use_container_width=True) # ✅ use_container_width st.download_button( label=f"네트워크 그래프 다운로드 (조건: {condition_name})", data=img_bytes, file_name=f"topic_network_graph_{condition_name}.png", mime="image/png", key=f"download_graph_{condition_name}" ) topic_column_name = f'topic_{condition_column}' filtered_df[topic_column_name] = lda_output.argmax(axis=1) + 1 download_topic_assignment(filtered_df, key_suffix=f'조건부분석_{condition_name}') if api_key: st.subheader(f"조건부 분석 종합 해석 (조건: {condition_name})") with st.spinner(f"Claude AI로 조건부 토픽 해석 중... (조건: {condition_name})"): interpretation = interpret_topics_full(api_key, topic_results) st.text_area(f"해석 결과 (조건: {condition_name})", value=interpretation, height=300, key=f"interpretation_{condition_name}") st.markdown("---") except pd.errors.EmptyDataError: st.error("업로드된 CSV 파일이 비어있습니다. 다시 확인해주세요.") except UnicodeDecodeError: st.error("파일 인코딩에 문제가 있습니다. UTF-8 인코딩으로 저장된 CSV 파일을 사용해주세요.") except Exception as e: st.error(f"파일을 읽는 중 오류가 발생했습니다: {str(e)}") else: st.info("CSV 파일을 업로드해주세요.") # 푸터 추가 st.markdown(""" --- © 2025 SK mySUNI 행복 College. All rights reserved. 문의사항이 있으시면 연락주세요 (배수정RF, soojeong.bae@sk.com) """)