2026. 2. 21. 16:20ㆍData Analyst Project
학원명당 - 원스토어
학원명당: 데이터 기반 학원 입지 분석 서비스
m.onestore.co.kr
이번 포스틩은 저번 포스팅에 이은 분석지표 생성과 클러스터링을 통한 특성에 따른 학원 입지 군집화를 해보겠습니다.
저번과 마찬가지로 코드와 함께 밑에 설명을 첨부하는 식으로 진행하겠습니다.
1. 파생 지표 계산
- EPI (평균아파트가격 × 총학생수) — 시장 구매력 지수
- ROI_GAP (총학생수 / 평당가격 % 상가평당가격) — 입지 가성비 지수
- 과목별 학원경쟁도 계산 (학생 수 대비 학원 수)
- 각 지표의 상위 % 백분위 컬럼 추가
2. K-means 클러스터링
- 엘보우 + 실루엣 점수로 최적 k 탐색
- 6개 클러스터 확정 및 라벨링 (교육밀집지, 블루오션 등)
- 이상치 제거 (남양주 도농동 등 특정 동 수동 제외)
- 1% 클리핑 및 QuantileTransformer 정규화
3. GeoJSON 생성
- 서울/경기 SHP 파일 로드 → WGS84 좌표계 변환
- 시군구 코드 매핑으로 시도 + dong 생성
- 부천시 행정구역 개편 보정 (오정구·원미구·소사구 → 부천시)
- dong_data와 GeoJSON 키 매칭 후 dong_map.geojson 저장
4. 학원 카테고리 통합 및 최종 저장
- 134개 교습과정을 9개 대분류로 통합 (입시/보습, 음악/예술, 외국어 등)
- 최종 dong_map.geojson 재생성
- simplify로 지도 용량 최적화
1. 파생 지표 계산
① 총학원수 집계 및 수치 타입 변환
# 수치형 데이터 변환 및 총학원수 산출
final = pd.read_csv(f'{base_path}final_merged.csv')
academy_cols = [col for col in final.columns if col not in ['시도', 'dong', '노령화지수', '평균나이', '총인구', '총주택(거처)수', '10세미만', '10대', '20대', '30대', '40대', '50대', '60대', '70대', '80대', '90대', '100세이상', '평균아파트가격', '평당가격', '거래건수', '평균상가가격', '상가평당가격', '상가거래건수', '초등학생수', '중학생수', '고등학생수', '초등학교수', '중학교수', '고등학교수', '총학생수', '총학교수']]
final['총학원수'] = final[academy_cols].sum(axis=1)
final['총학생수'] = pd.to_numeric(final['총학생수'].astype(str).str.replace(',', ''), errors='coerce').fillna(0)
final['총학원수'] = pd.to_numeric(final['총학원수'].astype(str).str.replace(',', ''), errors='coerce').fillna(0)
final['평균아파트가격'] = pd.to_numeric(final['평균아파트가격'].astype(str).str.replace(',', ''), errors='coerce').fillna(0)
final['평당가격'] = pd.to_numeric(final['평당가격'].astype(str).str.replace(',', ''), errors='coerce').fillna(0)
final['상가평당가격'] = pd.to_numeric(final['상가평당가격'].astype(str).str.replace(',', ''), errors='coerce').fillna(0)
이전 단계까지 문자열로 저장된 수치 컬럼들을 지표 계산에 앞서 float으로 변환합니다. 앞서 거래금액과 동일하게 콤마를 제거한 뒤 pd.to_numeric으로 변환하고, 변환 실패 시 fillna(0)으로 처리합니다.
② EPI · ROI_GAP 지표 계산
# 핵심 입지 지표 계산
final['EPI'] = np.where(final['평균아파트가격'].notna() & final['총학생수'].notna(), final['평균아파트가격'] * final['총학생수'], np.nan)
final['ROI_GAP'] = np.where(final['평당가격'].notna() & final['상가평당가격'].notna(), (final['총학생수'] / final['평당가격']) % final['상가평당가격'], np.nan)
두 지표 모두 np.where로 양쪽 값이 모두 존재할 때만 계산하고, 한쪽이라도 NaN이면 NaN을 반환합니다. EPI는 평균 아파트 가격과 총학생 수를 곱해 해당 동의 교육 수요와 구매력을 동시에 반영하는 시장 구매력 지수입니다. 값이 높을수록 학원 수요가 높고 지불 여력도 높은 지역으로 해석합니다. ROI_GAP은 학생 수를 아파트 평당가격으로 나눈 뒤 상가 평당가격으로 나머지 연산(%)을 적용한 입지 가성비 지수입니다. 임대 비용 대비 학생 유입 효율을 측정합니다.
③ 과목별 학원경쟁도 계산
# 과목별 학원경쟁도 산출
academy_cols = [col for col in final.columns if col not in ['시도', 'dong', '노령화지수', '평균나이', '총인구', '총주택(거처)수', '10세미만', '10대', '20대', '30대', '40대', '50대', '60대', '70대', '80대', '90대', '100세이상', '평균아파트가격', '평당가격', '거래건수', '평균상가가격', '상가평당가격', '상가거래건수', '초등학생수', '중학생수', '고등학생수', '초등학교수', '중학교수', '고등학교수', '총학생수', '총학교수', '총학원수', 'EPI', 'EPI_상위%', 'ROI_GAP', 'ROI_GAP_상위%', '학원경쟁도', '학원경쟁도_상위%']]
for col in academy_cols:
final[f'{col}_학원경쟁도'] = np.where(final[col].notna() & final['총학생수'].notna() & (final['총학생수'] > 0), final[col] / final['총학생수'] * 1000, np.nan)
과목별 학원 수를 총학생 수로 나눠 학생 1,000명당 해당 과목 학원 수를 산출합니다. 값이 높을수록 경쟁이 치열한 과목·지역이며, 총학생 수가 0인 동은 분모 오류를 방지하기 위해 NaN으로 처리합니다.
④ 상위 % 백분위 컬럼 추가 및 저장
# 지표별 상위 퍼센트 산출 및 저장
final = final.replace(0, np.nan)
경쟁도_cols = [f'{col}_학원경쟁도' for col in academy_cols]
indicator_cols = ['EPI', 'ROI_GAP'] + 경쟁도_cols
for col in indicator_cols:
valid_count = final[col].notna().sum()
ranks = final[col].rank(ascending=False, method='min')
final[f'{col}_상위%'] = np.where(final[col].notna(), (ranks / valid_count * 100).round(1).astype(str) + '%', np.nan)
final.to_csv(f'{base_path}final_with_indicators.csv', index=False, encoding='utf-8-sig')
0을 NaN으로 대체해 데이터가 없는 동을 백분위 계산에서 제외합니다. 이후 각 지표별로 rank(ascending=False)로 내림차순 순위를 구한 뒤 전체 유효 개수로 나눠 상위 X% 문자열 컬럼을 생성합니다. 예를 들어 EPI가 전체 중 5번째로 높은 동이면 "상위 1.2%"와 같이 저장됩니다. 최종 결과는 final_with_indicators.csv로 저장합니다.
2. K-means 클러스터링
① 초기 피처 선정 및 탐색
# 초기 피처 정규화 및 최적 군집 탐색 준비
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
final = pd.read_csv(f'{base_path}final_with_indicators.csv')
feature_cols = ['EPI', 'ROI_GAP', '총학생수', '총학원수', '평균아파트가격']
df_cluster = final[feature_cols].dropna()
scaler = StandardScaler()
X = scaler.fit_transform(df_cluster)
초기에는 EPI와 ROI_GAP 등 파생 지표 중심으로 피처를 구성합니다. 결측치가 있는 행은 제거하고 StandardScaler로 정규화한 뒤 최적 k 탐색에 사용합니다.
② 엘보우 및 실루엣 점수로 최적 k 탐색
# 엘보우 및 실루엣 점수를 통한 최적 K 탐색
inertia = []
silhouette = []
for k in range(2, 11):
kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
kmeans.fit(X)
inertia.append(kmeans.inertia_)
silhouette.append(silhouette_score(X, kmeans.labels_))
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
axes[0].plot(range(2, 11), inertia, marker='o')
axes[0].set_title('Elbow Method')
axes[1].plot(range(2, 11), silhouette, marker='o', color='orange')
axes[1].set_title('Silhouette Score')
plt.tight_layout()
plt.show()
best_k = silhouette.index(max(silhouette)) + 2
print(f"최적 K: {best_k} (Silhouette: {max(silhouette):.3f})")
k=2부터 10까지 반복하며 두 가지 기준으로 최적 k를 탐색합니다. 군집 내 거리 합이 꺾이는 엘보우 지점과 각 데이터가 자기 군집에 얼마나 잘 속하는지 측정하는 실루엣 점수를 종합해 최종 k=6으로 확정합니다.
③ 파생 피처 추가
# 인구 대비 학원밀도 및 유소년 파생 피처 추가
final['학원밀도'] = final['총학원수'] / final['총인구_값'].replace(0, None)
final['유소년비율'] = (final['유아_값'] + final['초중고_값']) / final['총인구_값'].replace(0, None)
final['유소년수'] = final['유아_값'] + final['초중고_값']
단순 학원 수 대신 인구 대비 학원 수인 학원밀도와 유소년수를 피처로 추가합니다. 인구 규모 차이가 큰 동 간의 비교를 보정하기 위한 처리이며, 0인 경우 None으로 대체해 분모 오류를 방지합니다.
④ 이상치 수동 제거 및 최소 인구 필터링
# 분석 왜곡을 방지하기 위한 이상치 및 소규모 동 제거
final = final[~((final['시도'].str.contains('남양주시')) & (final['dong'] == '도농동'))].copy()
final = final[~((final['시도'].str.contains('남양주시')) & (final['dong'] == '지금동'))].copy()
exclude_list = [('과천시', '갈현동'), ('종로구', '서린동'), ('중구', '소공동')]
for 시도_keyword, dong in exclude_list:
final = final[~((final['시도'].str.contains(시도_keyword)) & (final['dong'] == dong))].copy()
final = final[final['총인구_값'] >= 1000].copy()
EDA 과정에서 발견된 이상치 동들을 수동으로 제거합니다. 개발 지구 특성상 인구 구조가 비정상적이거나 총인구가 1,000명 미만인 동은 분석 대상에서 제외하여 군집화 결과의 왜곡을 막습니다.
⑤ 1% 클리핑
# 상위 1% 극단값을 99분위수로 제한하는 클리핑 처리
for col in ['학원밀도', '총학원수', '상가거래건수', '청년_값', '노년_값', '평균상가가격', '상가평당가격']:
upper = final[col].quantile(0.99)
final[col] = final[col].clip(upper=upper)
상위 1% 극단값을 99퍼센타일 값으로 대체하는 이상치 처리 방법입니다. 데이터 손실 없이 분포를 균일하게 만들어 시각화 시 색상 분포가 특정 동에 쏠리는 현상을 방지합니다.
⑥ 최종 클러스터링 및 라벨링
# 최종 6개 군집 형성 및 직관적인 라벨링 부여
feature_cols = ['총인구_값', '초중고_값', '유소년수', '총학원수', '학원밀도', '평균아파트가격', '총학생수']
df_cluster = final[feature_cols].fillna(0)
X = StandardScaler().fit_transform(df_cluster)
kmeans = KMeans(n_clusters=6, random_state=42, n_init=10)
final['cluster'] = kmeans.fit_predict(X)
label_map = {0: '소규모주거지', 1: '학원밀집지', 2: '일반주거지', 3: '블루오션', 4: '교육저활성지', 5: '고급주거지'}
final['cluster_label'] = final['cluster'].map(label_map)
print(final.groupby('cluster')[['총인구_값', '총학원수', '학원밀도', '평균아파트가격']].mean().round(2))
학원밀도, 유소년수 등을 추가한 7개 피처로 최종 클러스터링을 수행합니다. 군집별 평균을 확인하여 각 군집의 특성에 맞는 직관적인 라벨을 부여합니다.
⑦ QuantileTransformer 정규화
# 시각화 색상 스펙트럼 최적화를 위한 분위수 정규화
from sklearn.preprocessing import QuantileTransformer
exclude_cols = ['시도', 'dong', 'cluster', 'cluster_label']
numeric_cols = [col for col in final.columns if col not in exclude_cols and not col.endswith('_상위%') and final[col].dtype in ['int64', 'float64']]
qt = QuantileTransformer(output_distribution='uniform', random_state=42)
final_normalized = final.copy()
final_normalized[numeric_cols] = qt.fit_transform(final[numeric_cols].fillna(0))
final_normalized.to_csv(f'{base_path}final_normalized.csv', index=False, encoding='utf-8-sig')
각 컬럼의 값을 분위수 기준으로 0~1 사이로 변환하여 균등하게 분포시킵니다. 이 정규화를 적용하면 지도 시각화에서 극단값에 의한 색상 편향 없이 스펙트럼 전체를 고르게 활용할 수 있습니다.
3. GeoJSON 생성
① SHP 파일 로드 및 WGS84 좌표계 변환
# SHP 파일 로드 및 WGS84 좌표계 변환
import geopandas as gpd
import pandas as pd
import json
gdf_seoul = gpd.read_file(f'{base_path}LSMD_ADM_SECT_UMD_11_202602.shp', encoding='cp949').to_crs(epsg=4326)
gdf_gg = gpd.read_file(f'{base_path}LSMD_ADM_SECT_UMD_41_202602.shp', encoding='cp949').to_crs(epsg=4326)
gdf = pd.concat([gdf_seoul, gdf_gg], ignore_index=True)
with open(f'{base_path}dong_data.json', 'r', encoding='utf-8') as f:
dong_data = json.load(f)
국토부에서 제공하는 행정동 경계 SHP 파일을 서울과 경기로 나눠 로드합니다. 원본 좌표계인 한국 표준 좌표계를 웹 지도에서 사용 가능한 WGS84로 변환한 뒤 하나로 병합하고, 앞서 생성한 json 데이터를 함께 불러옵니다.
② 시군구 코드 매핑으로 시도·동 컬럼 생성
# 시군구 코드 매핑 및 매칭 키 생성
import re
sigungu_code_map = {'11110': '종로구', '11140': '중구', '41191': '부천시 오정구', '41193': '부천시 원미구', '41195': '부천시 소사구', '41192': '부천시', '41194': '부천시', '41196': '부천시'}
sido_map = {'11': '서울특별시', '41': '경기도'}
gdf['시도'] = gdf['EMD_CD'].astype(str).str[:2].map(sido_map) + ' ' + gdf['EMD_CD'].astype(str).str[:5].map(sigungu_code_map)
gdf['dong'] = gdf['EMD_NM'].apply(lambda x: re.sub(r'[0-9\.·]+[가]*동$', '동', str(x)) if str(x).endswith(('동', '읍', '면')) else str(x))
gdf['key'] = gdf['시도'] + '_' + gdf['dong']
SHP 파일의 행정동 코드 앞자리를 활용해 시도를 식별합니다. 미리 정의한 코드 매핑 딕셔너리로 치환해 시도 컬럼을 생성하고, 행정동명에서 숫자나 특수문자를 정규화하여 dong 컬럼을 만듭니다. 마지막으로 데이터 매칭을 위한 key 컬럼을 생성합니다.
③ 부천시 행정구역 개편 보정
# 부천시 행정구역 개편에 따른 키 보정
new_dong_data = {}
for key, val in dong_data.items():
if any(gu in key for gu in ['부천시 오정구', '부천시 원미구', '부천시 소사구']):
new_key = key.replace('부천시 오정구', '부천시').replace('부천시 원미구', '부천시').replace('부천시 소사구', '부천시')
val['시도'] = new_key.split('_')[0]
new_dong_data[new_key] = val
else:
new_dong_data[key] = val
dong_data = new_dong_data
부천시는 행정구역 개편으로 하위 구가 폐지되어 부천시로 통합되었습니다. 기존 데이터는 구 단위로 기록되어 있고 SHP 파일은 통합 기준으로 제공되어 키가 불일치하므로, dong_data의 키를 SHP 기준으로 일괄 치환하여 보정합니다.
④ dong_data와 GeoJSON 키 매칭 및 저장
# GeoJSON 구조 생성 및 파일 저장
import os
features = []
for _, row in gdf.iterrows():
if row['key'] in dong_data:
data = dong_data[row['key']]
features.append({
'type': 'Feature',
'geometry': row['geometry'].__geo_interface__,
'properties': {
'key': row['key'], '시도': row['시도'], 'dong': row['dong'],
**{k: v for k, v in data.items() if k not in ['학원수별', '학원경쟁도별']},
'학원수별': data.get('학원수별', {}),
'학원경쟁도별': data.get('학원경쟁도별', {})
}
})
with open(f'{base_path}dong_map.geojson', 'w', encoding='utf-8') as f:
json.dump({'type': 'FeatureCollection', 'features': features}, f, ensure_ascii=False)
dong_data와 GDF 양쪽에 모두 존재하는 키를 확인하여, 매칭되는 동만 GeoJSON Feature로 변환합니다. 각 Feature에 행정동 경계 폴리곤과 분석 지표 전체를 담아 최종적으로 dong_map.geojson 파일로 저장하여 프론트엔드에서 사용할 수 있게 구성합니다.
4. 학원 카테고리 통합 및 최종 저장
① 134개 교습과정 → 9개 대분류 통합
피벗 테이블로 생성된 134개 교습과정 컬럼을 9개 대분류로 묶습니다. 각 대분류에 해당하는 원본 컬럼들을 행 방향으로 합산해 학원_입시/보습, 학원_외국어 등의 새 컬럼을 생성합니다. existing 필터를 통해 서울/경기 어느 한쪽에만 존재하는 교습과정 컬럼이 없어도 오류 없이 처리합니다. 이 통합 작업으로 프론트엔드에서 사용자에게 노출하는 학원 분류가 134개에서 9개로 줄어들어 UX가 크게 개선됩니다.
# 134개 교습과정을 9개 대분류로 통합
category_map = {'입시/보습': ['보습', '입시', '보습/논술', '입시/논술', '보통교과', '검정', '성인고시', '진학지도', '진학상담지도', '대학편입', '전문교과제외', '정보교과', '학교교과교습학원'], '음악/예술': ['음악', '미술', '피아노조율', '실용음악', '국악', '서예', '공예', '도예', '펜글씨', '방송', '영상', '영화', '연극', '연기(연극,뮤지컬,오페라)'], '외국어': ['외국어', '어학(성인)', '실용외국어(유아/초·중·고)', '통역(성인)', '번역(성인)', '국제'], '체육/무용': ['무용', '댄스', '무용(전통무용,현대무용)', '전통무용'], 'IT/컴퓨터': ['컴퓨터', '소프트웨어', '정보', '정보처리', '전자상거래', '게임', '로봇'], '예능/취미': ['바둑', '만화', '사진', '마술(매직)', '모델', '웅변', '속독', '속셈', '주산'], '직업/자격증': ['경영', '회계', '부동산', '자동차', '전기', '건축', '이/미용', '식음료품', '금융', '보험', '행정', '간호조무사', '항공', '관광'], '독서실': ['독서실', '독서실(유아/초·중·고)', '독서실(일반인)'], '기타': ['기타(소)', '기타(중)', '기예(중)', '예능(중)', '인문사회(중)']}
for cat, cols in category_map.items():
final[f'학원_{cat}'] = final[[c for c in cols if c in final.columns]].fillna(0).sum(axis=1)
② dong_data 재구성 및 GeoJSON 재생성
학원수별은 9개 대분류별 학원 수를 중첩 딕셔너리로 저장하되, 값이 0인 카테고리는 제외해 용량을 줄입니다. 학원경쟁도별은 과목별 경쟁도를 소수점 4자리로 반올림해 저장합니다. 부천시 키 보정은 dong_data 재구성 시마다 재적용합니다.
# 데이터 재구성 및 부천시 키 보정
경쟁도_cols = [col for col in final.columns if col.endswith('_학원경쟁도')]
academy_cols = [f'학원_{cat}' for cat in category_map.keys()]
dong_data = {}
for _, row in final.iterrows():
key = f"{row['시도']}_{row['dong']}"
entry = {col: (None if pd.isna(row.get(col)) else row.get(col)) for col in final.columns if col not in ['시도', 'dong']}
entry.update({'시도': row['시도'], 'dong': row['dong']})
entry['학원수별'] = {col: int(row[col]) for col in academy_cols if not pd.isna(row[col]) and row[col] > 0}
entry['학원경쟁도별'] = {col.replace('_학원경쟁도', ''): round(float(row[col]), 4) for col in 경쟁도_cols if not pd.isna(row[col])}
dong_data[key] = entry
new_dong_data = {}
for key, val in dong_data.items():
if any(x in key for x in ['부천시 오정구', '부천시 원미구', '부천시 소사구']):
new_key = key.replace('부천시 오정구', '부천시').replace('부천시 원미구', '부천시').replace('부천시 소사구', '부천시')
val['시도'] = new_key.split('_')[0]
new_dong_data[new_key] = val
else:
new_dong_data[key] = val
dong_data = new_dong_data
③ 최종 GeoJSON 저장
리스트 컴프리헨션으로 GDF를 순회하며 dong_data에 키가 존재하는 동만 Feature로 변환합니다. 학원수별과 학원경쟁도별은 중첩 객체로 분리해 properties 최상위에 배치합니다.
# 최종 GeoJSON 생성 및 저장
features = [{'type': 'Feature', 'geometry': row['geometry'].__geo_interface__, 'properties': {'key': row['key'], '시도': row['시도'], 'dong': row['dong'], **{k: v for k, v in dong_data[row['key']].items() if k not in ['학원수별', '학원경쟁도별']}, '학원수별': dong_data[row['key']].get('학원수별', {}), '학원경쟁도별': dong_data[row['key']].get('학원경쟁도별', {})}} for _, row in gdf.iterrows() if row['key'] in dong_data]
with open(f'{base_path}dong_map.geojson', 'w', encoding='utf-8') as f:
json.dump({'type': 'FeatureCollection', 'features': features}, f, ensure_ascii=False)
④ 지도 용량 최적화 (simplify)
행정동 경계는 수천 개의 좌표점으로 구성되어 있어 원본 GeoJSON 파일이 수십 MB에 달합니다. simplify는 경계선의 좌표점 수를 줄여 파일 크기를 낮추는 방법으로, tolerance 값이 클수록 더 많이 단순화됩니다. preserve_topology=True를 설정해 인접한 동 간 경계가 어긋나거나 폴리곤이 뒤집히는 위상 오류를 방지합니다. 단순화 후 buffer(0)을 추가로 적용해 자기교차 등 남은 위상 오류를 보정합니다. 두 단계로 나눠 tolerance를 조정한 것은 용량 감소폭과 경계 정밀도 사이의 균형을 실험적으로 탐색한 결과입니다.
# GeoJSON 용량 최적화 및 위상 보정
gdf = gpd.read_file(f'{base_path}dong_map.geojson')
gdf['geometry'] = gdf['geometry'].simplify(tolerance=0.0005, preserve_topology=True).buffer(0)
gdf.to_file(f'{base_path}dong_map_optimized.geojson', driver='GeoJSON')
'Data Analyst Project' 카테고리의 다른 글
| 인덱스 투자자에게 시장 분석 도구가 필요한 이유 — Passive 앱 개발지 ① (0) | 2026.03.16 |
|---|---|
| [어플리케이션 개발] 3. 학원 입지 분석 대시보드 앱 개발 - 아키텍처 및 결과물 (0) | 2026.02.21 |
| [어플리케이션 개발] 1. 학원 입지 분석 대시보드 앱 개발 - 수집 및 전처리 (0) | 2026.02.20 |
| [이커머스] 가중치 RFM 기반 고객 세분화 및 Cohort 분석을 통한 리텐션 증대 전략 (0) | 2026.01.07 |
| [대구 빅데이터 분석 경진대회] 클라우드 데이터베이스 구축과 데이터 적재 (2) | 2025.08.04 |