Spaces:
Running
Running
import streamlit as st | |
import pandas as pd | |
import numpy as np | |
import matplotlib.pyplot as plt | |
import seaborn as sns | |
from scipy.stats import norm, skew | |
import platform | |
import os | |
import matplotlib.font_manager as fm | |
import warnings | |
warnings.filterwarnings('ignore') | |
# 심플한 한글 폰트 설정 - 앱 시작시 한번만 실행 | |
def setup_korean_font(): | |
"""한글 폰트를 간단하게 설정하는 함수""" | |
try: | |
# 1. 사용자 폰트 파일 확인 | |
script_dir = os.path.dirname(os.path.abspath(__file__)) | |
possible_fonts = ["NanumGothic.ttf"] | |
font_path = None | |
for font_file in possible_fonts: | |
candidate = os.path.join(script_dir, font_file) | |
if os.path.exists(candidate): | |
font_path = candidate | |
break | |
# 2. 폰트 적용 | |
if font_path: | |
# 폰트 파일이 있으면 직접 사용 | |
plt.rcParams['font.family'] = fm.FontProperties(fname=font_path).get_name() | |
st.sidebar.success(f"폰트 로딩 성공: {os.path.basename(font_path)}") | |
else: | |
# 시스템 기본 폰트 사용 | |
if platform.system() == 'Windows': | |
plt.rcParams['font.family'] = 'Malgun Gothic' | |
elif platform.system() == 'Darwin': # macOS | |
plt.rcParams['font.family'] = 'AppleGothic' | |
else: # Linux | |
plt.rcParams['font.family'] = 'DejaVu Sans' | |
st.sidebar.info(f"시스템 기본 폰트 사용: {plt.rcParams['font.family']}") | |
# 마이너스 기호 깨짐 방지 | |
plt.rcParams['axes.unicode_minus'] = False | |
return font_path | |
except Exception as e: | |
st.sidebar.warning(f"폰트 설정 오류: {e}") | |
plt.rcParams['font.family'] = 'DejaVu Sans' | |
plt.rcParams['axes.unicode_minus'] = False | |
return None | |
# 앱 시작시 폰트 설정 | |
FONT_PATH = setup_korean_font() | |
def analyze_scores(df): | |
"""데이터프레임을 받아 분석 결과를 표시하는 함수""" | |
st.subheader("📋 데이터 미리보기 (상위 5개)") | |
st.dataframe(df.head()) | |
# 숫자 형식의 열만 선택지로 제공 | |
numeric_columns = df.select_dtypes(include=np.number).columns.tolist() | |
if not numeric_columns: | |
st.error("❌ 데이터에서 분석 가능한 숫자 형식의 열을 찾을 수 없습니다.") | |
return | |
score_column = st.selectbox("📊 분석할 점수 열(column)을 선택하세요:", numeric_columns) | |
if score_column: | |
scores = df[score_column].dropna() | |
if len(scores) == 0: | |
st.error("❌ 선택한 열에 유효한 데이터가 없습니다.") | |
return | |
st.subheader(f"📈 '{score_column}' 점수 분포 분석 결과") | |
# 1. 기본 통계량 | |
st.write("#### 📊 기본 통계량") | |
col1, col2, col3, col4 = st.columns(4) | |
with col1: | |
st.metric("평균", f"{scores.mean():.2f}") | |
with col2: | |
st.metric("표준편차", f"{scores.std():.2f}") | |
with col3: | |
st.metric("최솟값", f"{scores.min():.2f}") | |
with col4: | |
st.metric("최댓값", f"{scores.max():.2f}") | |
# 상세 통계 | |
st.write("#### 📋 상세 통계량") | |
st.dataframe(scores.describe().to_frame().T) | |
# 2. 분포 시각화 | |
st.write("#### 🎨 점수 분포 시각화") | |
try: | |
# 한글 폰트 준비 | |
if FONT_PATH: | |
font_prop = fm.FontProperties(fname=FONT_PATH) | |
else: | |
font_prop = fm.FontProperties(family=plt.rcParams['font.family']) | |
fig, ax = plt.subplots(figsize=(12, 7)) | |
# 히스토그램과 KDE 곡선 | |
sns.histplot(scores, kde=True, stat='density', alpha=0.7, ax=ax, color='skyblue') | |
# 정규분포 곡선 추가 | |
mu, std = norm.fit(scores) | |
x = np.linspace(scores.min(), scores.max(), 100) | |
y = norm.pdf(x, mu, std) | |
ax.plot(x, y, 'r-', linewidth=2, label=f'정규분포 (μ={mu:.1f}, σ={std:.1f})') | |
# 평균선 | |
ax.axvline(mu, color='red', linestyle=':', linewidth=2, alpha=0.8, label=f'평균: {mu:.1f}') | |
# 제목과 라벨 - 한글 폰트 직접 지정 | |
ax.set_title(f'{score_column} 점수 분포 분석', fontproperties=font_prop, fontsize=16, pad=20) | |
ax.set_xlabel('점수', fontproperties=font_prop, fontsize=12) | |
ax.set_ylabel('밀도', fontproperties=font_prop, fontsize=12) | |
# 범례 - 한글 폰트 적용 | |
legend = ax.legend(prop=font_prop, fontsize=10) | |
ax.grid(True, alpha=0.3) | |
# 통계 정보 박스 - 한글 폰트 적용 | |
stats_text = f'샘플 수: {len(scores)}\n평균: {mu:.2f}\n표준편차: {std:.2f}\n최솟값: {scores.min():.1f}\n최댓값: {scores.max():.1f}' | |
ax.text(0.02, 0.98, stats_text, transform=ax.transAxes, | |
fontproperties=font_prop, fontsize=10, verticalalignment='top', | |
bbox=dict(boxstyle='round,pad=0.5', facecolor='lightblue', alpha=0.8)) | |
plt.tight_layout() | |
st.pyplot(fig) | |
except Exception as e: | |
st.error(f"❌ 그래프 생성 오류: {e}") | |
# 대체 그래프 (영어만 사용) | |
st.write("**Simple Chart (English):**") | |
fig2, ax2 = plt.subplots(figsize=(10, 6)) | |
ax2.hist(scores, bins=15, alpha=0.7, color='lightcoral', edgecolor='black') | |
ax2.set_title(f'Distribution of {score_column}', fontsize=14) | |
ax2.set_xlabel('Score') | |
ax2.set_ylabel('Frequency') | |
ax2.grid(True, alpha=0.3) | |
st.pyplot(fig2) | |
plt.close(fig2) | |
finally: | |
if 'fig' in locals(): | |
plt.close(fig) | |
# 3. 왜도 분석 | |
st.write("#### 📐 분포 형태 분석 (왜도)") | |
try: | |
skewness = skew(scores) | |
col1, col2 = st.columns([1, 2]) | |
with col1: | |
st.metric("왜도 (Skewness)", f"{skewness:.4f}") | |
with col2: | |
if skewness > 0.5: | |
st.success("🔴 **양의 왜도**: 대부분 학생이 낮은 점수대에 분포, 소수의 고득점자 존재") | |
elif skewness < -0.5: | |
st.success("🔵 **음의 왜도**: 대부분 학생이 높은 점수대에 분포, 소수의 저득점자 존재") | |
else: | |
st.success("🟢 **대칭 분포**: 점수가 평균을 중심으로 고르게 분포") | |
except Exception as e: | |
st.error(f"왜도 계산 오류: {e}") | |
# 4. 구간별 분포 | |
st.write("#### 📋 구간별 분포") | |
try: | |
if scores.max() <= 100: # 100점 만점 가정 | |
bins_labels = ['0-60', '61-70', '71-80', '81-90', '91-100'] | |
bins_edges = [0, 60, 70, 80, 90, 100] | |
else: | |
# 동적 구간 생성 | |
min_score, max_score = scores.min(), scores.max() | |
interval = (max_score - min_score) / 5 | |
bins_edges = [min_score + i * interval for i in range(6)] | |
bins_labels = [f'{bins_edges[i]:.0f}-{bins_edges[i+1]:.0f}' for i in range(5)] | |
score_counts = pd.cut(scores, bins=bins_edges, labels=bins_labels, include_lowest=True).value_counts().sort_index() | |
score_percentages = (score_counts / len(scores) * 100).round(1) | |
result_df = pd.DataFrame({ | |
'구간': score_counts.index, | |
'학생 수': score_counts.values, | |
'비율 (%)': score_percentages.values | |
}) | |
st.dataframe(result_df) | |
except Exception as e: | |
st.warning(f"구간 분석 오류: {e}") | |
def main(): | |
st.set_page_config( | |
page_title="학생 점수 분석 도구", | |
page_icon="📊", | |
layout="wide" | |
) | |
# 제목 | |
st.title("📊 학생 점수 분포 분석 도구") | |
st.markdown("**CSV 파일을 업로드하거나 Google Sheets URL을 입력하여 점수 분포를 분석하세요**") | |
# 폰트 정보 표시 | |
with st.expander("🔧 폰트 설정 정보"): | |
st.write(f"**현재 폰트**: {plt.rcParams['font.family']}") | |
st.write(f"**폰트 경로**: {FONT_PATH if FONT_PATH else '시스템 기본'}") | |
# 간단한 폰트 테스트 | |
if st.button("폰트 테스트"): | |
fig, ax = plt.subplots(figsize=(6, 2)) | |
ax.text(0.5, 0.5, '한글 폰트 테스트: 점수 분포 분석', | |
ha='center', va='center', fontsize=14) | |
ax.set_xlim(0, 1) | |
ax.set_ylim(0, 1) | |
ax.axis('off') | |
st.pyplot(fig) | |
plt.close(fig) | |
st.markdown("---") | |
# 사이드바 - 데이터 입력 | |
st.sidebar.title("📁 데이터 가져오기") | |
source_option = st.sidebar.radio( | |
"데이터 소스 선택:", | |
("📤 CSV 파일 업로드", "🔗 Google Sheets URL", "🎲 샘플 데이터") | |
) | |
df = None | |
if source_option == "📤 CSV 파일 업로드": | |
uploaded_file = st.sidebar.file_uploader( | |
"CSV 파일을 선택하세요", | |
type=["csv"], | |
help="UTF-8, CP949 등 다양한 인코딩을 자동으로 감지합니다" | |
) | |
if uploaded_file: | |
encodings = ['utf-8-sig', 'utf-8', 'cp949', 'euc-kr', 'latin1'] | |
for encoding in encodings: | |
try: | |
df = pd.read_csv(uploaded_file, encoding=encoding) | |
st.sidebar.success(f"✅ 파일 로딩 성공! (인코딩: {encoding})") | |
break | |
except UnicodeDecodeError: | |
continue | |
except Exception as e: | |
st.sidebar.error(f"파일 읽기 오류: {e}") | |
break | |
if df is None: | |
st.sidebar.error("❌ 파일 인코딩을 인식할 수 없습니다.") | |
elif source_option == "🔗 Google Sheets URL": | |
st.sidebar.info("💡 Google Sheets를 '웹에 게시'한 후 CSV URL을 입력하세요") | |
url = st.sidebar.text_input( | |
"Google Sheets CSV URL", | |
placeholder="https://docs.google.com/spreadsheets/d/..." | |
) | |
if url and "docs.google.com" in url: | |
try: | |
with st.spinner("📥 데이터 로딩 중..."): | |
df = pd.read_csv(url) | |
st.sidebar.success("✅ Google Sheets 로딩 성공!") | |
except Exception as e: | |
st.sidebar.error(f"❌ URL 로딩 실패: {e}") | |
elif url: | |
st.sidebar.warning("⚠️ 올바른 Google Sheets URL을 입력하세요") | |
elif source_option == "🎲 샘플 데이터": | |
if st.sidebar.button("샘플 데이터 생성"): | |
np.random.seed(42) | |
sample_size = st.sidebar.slider("샘플 크기", 50, 500, 100) | |
df = pd.DataFrame({ | |
'학생번호': range(1, sample_size + 1), | |
'수학점수': np.random.normal(75, 15, sample_size).clip(0, 100).round(1), | |
'영어점수': np.random.normal(80, 12, sample_size).clip(0, 100).round(1), | |
'과학점수': np.random.normal(70, 18, sample_size).clip(0, 100).round(1), | |
'국어점수': np.random.normal(77, 14, sample_size).clip(0, 100).round(1) | |
}) | |
st.sidebar.success(f"✅ {sample_size}명의 샘플 데이터 생성!") | |
# 메인 분석 | |
if df is not None and not df.empty: | |
st.success(f"🎉 데이터 로딩 완료! **{len(df)}개 행, {len(df.columns)}개 열**") | |
analyze_scores(df) | |
else: | |
st.info("👈 **사이드바에서 데이터를 선택하세요**") | |
# 기능 안내 | |
st.markdown(""" | |
### 🔍 주요 기능 | |
- **📊 기본 통계**: 평균, 표준편차, 최솟값, 최댓값 등 | |
- **📈 분포 시각화**: 히스토그램, KDE 곡선, 정규분포 비교 | |
- **📐 왜도 분석**: 분포의 비대칭성 측정 | |
- **📋 구간별 분포**: 점수 구간별 학생 수 및 비율 | |
### 📝 지원 형식 | |
- **CSV 파일**: UTF-8, CP949, EUC-KR 등 자동 인코딩 감지 | |
- **Google Sheets**: 웹에 게시된 시트의 CSV URL | |
- **샘플 데이터**: 테스트용 가상 점수 데이터 | |
""") | |
if __name__ == '__main__': | |
main() |