soojeongcrystal's picture
Update app.py
8409ec3 verified
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("""
<style>
.css-1adrfps {
padding: 0px;
}
.css-1kyxreq {
padding: 10px;
}
.topic-summary {
background-color: #f0f2f6;
border-left: 5px solid #4e8098;
padding: 10px;
margin-bottom: 10px;
}
.small-title {
font-size: 24px;
}
.header-text {
color: #707070;
text-align: right;
width: 100%;
padding: 10px;
}
</style>
<div class="header-text">
mySUNI Crystal.B
</div>
""", unsafe_allow_html=True)
st.markdown('<h1 class="small-title">📊토픽모델링 for SK</h1>', 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)
""")