import streamlit as st import pandas as pd import numpy as np from anthropic import Anthropic, HUMAN_PROMPT, AI_PROMPT import json from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer from sklearn.decomposition import LatentDirichletAllocation from konlpy.tag import Okt import re import os import altair as alt import colorsys import matplotlib.pyplot as plt import matplotlib.font_manager as fm import networkx as nx import io # 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)) nouns = okt.nouns(text) processed = [word for word in nouns if word not in stop_words and len(word) > 1] return ' '.join(processed) 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 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" font_prop = fm.FontProperties(fname=font_path) plt.rcParams['font.family'] = font_prop.get_name() 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 perform_topic_modeling(df, text_column, num_topics, stop_words): 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 상위 단어를 점수 순으로 정렬 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 # TF-IDF 상위 단어도 점수 순으로 정렬 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 perform_conditional_analysis(df, condition_column, text_column, num_topics, stop_words, is_numeric, condition): if is_numeric: if isinstance(condition, tuple) and condition[0] == "이상": filtered_df = df[df[condition_column] >= condition[1]] elif isinstance(condition, tuple) and condition[0] == "이하": filtered_df = df[df[condition_column] <= condition[1]] else: # 범위 선택 filtered_df = df[(df[condition_column] >= condition[0]) & (df[condition_column] <= condition[1])] topic_results, _, _, _, _ = perform_topic_modeling(filtered_df, text_column, num_topics, stop_words) return {f"{condition_column} {condition}": topic_results} else: results = {} for value in condition: filtered_df = df[df[condition_column] == value] if len(filtered_df) > 0: topic_results, _, _, _, _ = perform_topic_modeling(filtered_df, text_column, num_topics, stop_words) results[value] = topic_results return results # 스타일 설정 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) # 분석 방법 선택 analysis_type = st.radio("분석 방법 선택", ["전체 분석", "조건부 분석"]) if analysis_type == "조건부 분석": 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() st.write(f"{condition_column}의 범위: {min_val:.2f} ~ {max_val:.2f}") analysis_method = st.radio("분석 방법 선택", ["범위 선택", "임계값 기준"]) if analysis_method == "범위 선택": condition = st.slider(f"{condition_column} 범위 선택", float(min_val), float(max_val), (float(min_val), float(max_val))) 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)) comparison = st.radio("비교 기준", ["이상", "이하"]) condition = (comparison, threshold) is_numeric = True else: unique_values = df[condition_column].unique() condition = st.multiselect(f"{condition_column} 값 선택", unique_values, default=unique_values) is_numeric = False if st.button("토픽 모델링 실행"): st.session_state.run_analysis = True st.session_state.text_column = text_column st.session_state.num_topics = num_topics st.session_state.analysis_type = analysis_type if analysis_type == "조건부 분석": st.session_state.condition_column = condition_column st.session_state.condition = condition st.session_state.is_numeric = is_numeric else: st.session_state.run_analysis = False 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 파일을 업로드해주세요.") # 메인 분석 로직 if 'run_analysis' in st.session_state and st.session_state.run_analysis: if 'text_column' in st.session_state and 'num_topics' in st.session_state and 'analysis_type' in st.session_state: try: if st.session_state.analysis_type == "전체 분석": st.header("전체 데이터 분석 결과") with st.spinner("토픽 모델링 실행 중..."): topic_results, lda, lda_output, tfidf_matrix, feature_names = perform_topic_modeling( df, st.session_state.text_column, st.session_state.num_topics, stop_words ) # 토픽 요약 표시 topic_summary = ", ".join([f"토픽{info['topic_num']}({info['topic_name']}, {info['weight']:.1f}%)" for info in topic_results]) st.markdown(f'
{topic_summary}
', unsafe_allow_html=True) # 토픽별 상세 정보 표시 for idx, topic_info in enumerate(topic_results): st.subheader(f"토픽 {idx + 1}") col1, col2 = st.columns(2) with col1: df_lda = pd.DataFrame(list(zip(topic_info['lda_words'], lda.components_[idx][np.argsort(lda.components_[idx])[::-1][:10]])), columns=['단어', 'LDA 점수']) st.subheader("LDA 상위 단어") st.table(df_lda.style.format({'LDA 점수': '{:.4f}'})) with col2: df_tfidf = pd.DataFrame(list(zip(topic_info['tfidf_words'], tfidf_matrix[lda_output[:, idx].argsort()[::-1][:100]].mean(axis=0).A1[np.argsort(tfidf_matrix[lda_output[:, idx].argsort()[::-1][:100]].mean(axis=0).A1)[::-1][:10]])), columns=['단어', 'TF-IDF']) st.subheader("TF-IDF 상위 단어") st.table(df_tfidf.style.format({'TF-IDF': '{:.4f}'})) # 토픽 비중 그래프 st.header("토픽 비중 그래프") df_weights = pd.DataFrame({ '토픽': [f'토픽 {i+1}' for i in range(st.session_state.num_topics)], '비중': [result['weight'] for result in topic_results] }) colors = generate_colors(st.session_state.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_column_width=True) st.download_button( label="네트워크 그래프 다운로드", data=img_bytes, file_name="topic_network_graph.png", mime="image/png", key="download_graph" ) # 토픽 요약 테이블 st.subheader("토픽 요약 테이블") 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) elif st.session_state.analysis_type == "조건부 분석": st.header("조건부 분석 결과") with st.spinner("조건부 토픽 모델링 실행 중..."): conditional_results = perform_conditional_analysis( df, st.session_state.condition_column, st.session_state.text_column, st.session_state.num_topics, stop_words, st.session_state.is_numeric, st.session_state.condition ) for value, topic_results in conditional_results.items(): st.subheader(f"{st.session_state.condition_column}: {value}") # 토픽 요약 표시 topic_summary = ", ".join([f"토픽{info['topic_num']}({info['topic_name']}, {info['weight']:.1f}%)" for info in topic_results]) st.markdown(f'
{topic_summary}
', unsafe_allow_html=True) # 토픽 요약 테이블 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) # 네트워크 그래프 st.subheader(f"{value} - 토픽 단어 네트워크 그래프") G = create_network_graph(topic_results, num_words=20) img_bytes = plot_network_graph(G) st.image(img_bytes, caption=f"{value} - 토픽별 상위 20개 단어 네트워크", use_column_width=True) st.download_button( label=f"{value} - 네트워크 그래프 다운로드", data=img_bytes, file_name=f"topic_network_graph_{value}.png", mime="image/png", key=f"download_graph_{value}" ) st.markdown("---") # 각 카테고리 결과 사이에 구분선 추가 # Claude API를 사용한 토픽 해석 부분 if api_key: st.header("토픽 종합 해석") def interpret_topics_full(api_key, topic_results): anthropic = 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: completion = anthropic.completions.create( model="claude-2.1", max_tokens_to_sample=4000, prompt=f"{HUMAN_PROMPT} {prompt} {AI_PROMPT}", ) return completion.completion except Exception as e: return f"Claude API 호출 중 오류가 발생했습니다: {str(e)}" def interpret_topics_conditional(api_key, topic_results, condition): anthropic = Anthropic(api_key=api_key) prompt = f"""다음은 LDA 토픽 모델링 결과로 나온 각 토픽의 정보입니다. 이를 바탕으로 각 토픽에 대해 간략히 요약해주세요: 조건: {condition} {", ".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. 토픽명은 "[구체적인 토픽명]" 형식으로 작성해주세요. 반드시 8어절 이상으로 구체적이고 설명적으로 작성해야 합니다. 2. 예시: "구성원의 전문성 향상을 위한 체계적인 학습과 역량 개발 방안 모색", "조직의 장기적 성과 향상을 위한 핵심 학습 역량 강화 전략", "현재 컬리지 멤버들의 역할 고민과 향후 발전 방향에 대한 논의" 등 3. 토픽명은 단순히 단어를 나열하는 것이 아니라, 토픽의 핵심 주제나 의미를 잘 나타내는 구체적이고 설명적인 문구로 만들어주세요. 위 형식에 맞춰 각 토픽에 대해 간략히 요약해주세요. 사용자가 쉽게 복사하여 사용할 수 있도록 간결하고 명확하게 작성해주세요. """ try: completion = anthropic.completions.create( model="claude-2.1", max_tokens_to_sample=2000, prompt=f"{HUMAN_PROMPT} {prompt} {AI_PROMPT}", ) return completion.completion except Exception as e: return f"Claude API 호출 중 오류가 발생했습니다: {str(e)}" if st.session_state.analysis_type == "전체 분석": col1, col2 = st.columns([3, 1]) with col2: if st.button("토픽 다시 해석하기", key="reinterpret"): st.session_state.topic_interpretation = None with col1: if 'topic_interpretation' not in st.session_state or st.session_state.topic_interpretation is None: with st.spinner("토픽 해석 중..."): st.session_state.topic_interpretation = interpret_topics_full(api_key, topic_results) st.subheader("토픽 모델링 종합 결과") st.text_area("결과를 복사하여 사용하세요:", value=st.session_state.topic_interpretation, height=500) else: for value, topic_results in conditional_results.items(): st.subheader(f"{st.session_state.condition_column}: {value} - 토픽 해석") if st.button(f"{value} - 토픽 다시 해석하기", key=f"reinterpret_{value}"): st.session_state[f'topic_interpretation_{value}'] = None if f'topic_interpretation_{value}' not in st.session_state or st.session_state[f'topic_interpretation_{value}'] is None: with st.spinner(f"{value} 토픽 해석 중..."): st.session_state[f'topic_interpretation_{value}'] = interpret_topics_conditional(api_key, topic_results, condition=f"{st.session_state.condition_column}: {value}") st.text_area(f"{value} - 결과를 복사하여 사용하세요:", value=st.session_state[f'topic_interpretation_{value}'], height=300) else: st.warning("Claude API 키가 설정되지 않았습니다. https://console.anthropic.com/settings/keys 에 접속하여 API 키를 발급받으시면 토픽명과 해석을 제공받으실 수 있습니다.") except Exception as e: st.error(f"분석 중 오류가 발생했습니다: {str(e)}") else: st.error("유효한 데이터가 없습니다. CSV 파일을 다시 업로드해주세요.") # 분석 초기화 버튼 if st.button("분석 초기화"): for key in st.session_state.keys(): if key.startswith('topic_interpretation') or key in ['run_analysis', 'run_conditional_analysis', 'conditional_topic_results']: del st.session_state[key] st.experimental_rerun() st.markdown(""" --- © 2024 SK mySUNI 행복 College. All rights reserved. 문의사항이 있으시면 연락주세요 (배수정RF, soojeong.bae@sk.com) """)