Spaces:
Sleeping
Sleeping
| 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() |