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("""
""", 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)
""")