← 메인 화면프로젝트 자료·코드
← 자료 목록

sejong_area.py

프로그램·코드 · 11.7 KB원본 다운로드

원본 내용을 읽기 전용으로 표시합니다.

  1. # 세종시 전체 분석
  2. import os
  3. import numpy as np
  4. import pandas as pd
  5. import matplotlib.pyplot as plt
  6. from sklearn.preprocessing import StandardScaler
  7. from sklearn.cluster import KMeans
  8. from sklearn.metrics import silhouette_score
  9. from sklearn.decomposition import PCA
  10. from common import *
  11. # 탐색적 데이터 분석
  12. # 업종 분포, 행정동별 지표, 행정동, 업종 구성비, 원도심/신도심
  13. def 탐색분석(now, prev, CATS):
  14. # 세종 전체의 업종 구조를 숫자와 그림으로 정리
  15. log("\n" + "=" * 70)
  16. log("2. 탐색적 데이터 분석(EDA)")
  17. log("=" * 70)
  18. # 업종 대분류별 업소 수
  19. # 세종 전체에서 어떤 업종이 많은지 먼저 확인
  20. 업종수 = now["상권업종대분류명"].value_counts()
  21. 표 = pd.DataFrame({"업소수": 업종수})
  22. 표["비율"] = (표["업소수"] / 표["업소수"].sum()).round(3)
  23. 표.index.name = "상권업종대분류명"
  24. log("\n[업종 대분류 분포]")
  25. log(표.to_string())
  26. plt.figure(figsize=(7, 4))
  27. plt.barh(업종수.index[::-1], 업종수.values[::-1], color=COLORS[0])
  28. plt.title("세종시 업종 대분류별 업소 수 (2026.6)")
  29. plt.xlabel("업소 수")
  30. 그림저장("01_업종대분류_분포.png")
  31. # 행정동별 지표
  32. # 동마다 상권 크기(업소수), 활력(신규비율), 위험(소멸률), 성격(체인, 1층, 다양성)을 한 표로 만듦
  33. dong = pd.DataFrame()
  34. dong["업소수"] = now.groupby("행정동명").size()
  35. dong["권역"] = now.groupby("행정동명")["권역"].first()
  36. dong["신규비율"] = now.groupby("행정동명")["신규"].mean()
  37. dong["체인비율"] = now.groupby("행정동명")["체인"].mean()
  38. dong = dong.sort_values("업소수", ascending=False)
  39. dong["소멸률"] = prev.groupby("행정동명")["소멸"].mean()
  40. dong["순증가율"] = now.groupby("행정동명").size() / prev.groupby("행정동명").size() - 1
  41. 층있음 = now[now["1층여부"] >= 0] # 층정보 있는 업소만
  42. dong["1층비율"] = 층있음.groupby("행정동명")["1층여부"].mean()
  43. dong["업종다양성"] = now.groupby("행정동명")["상권업종중분류명"].apply(다양성)
  44. log("\n[행정동별 주요 지표]")
  45. log(dong.round(3).to_string())
  46. dong.round(4).to_csv(os.path.join(결과폴더, "table_행정동별_지표.csv"), encoding="utf-8-sig")
  47. # 행정동별 업소 수 그래프 (원도심은 주황, 신도심은 파랑)
  48. 색 = []
  49. for g in dong["권역"]:
  50. if g == "읍면(원도심)":
  51. 색.append(COLORS[1])
  52. else:
  53. 색.append(COLORS[0])
  54. plt.figure(figsize=(7, 6))
  55. plt.barh(dong.index[::-1], dong["업소수"][::-1], color=색[::-1])
  56. plt.title("행정동별 업소 수 (주황: 읍면 원도심, 파랑: 동 신도심)")
  57. 그림저장("02_행정동별_업소수.png")
  58. # 행정동별 히트맵
  59. # 동마다 업종 비율을 색으로 비교
  60. comp = pd.crosstab(now["행정동명"], now["상권업종대분류명"], normalize="index")
  61. comp = comp[CATS]
  62. comp = comp.loc[dong.index]
  63. plt.figure(figsize=(9, 8))
  64. plt.imshow(comp.values, cmap="Blues", aspect="auto")
  65. plt.xticks(range(len(CATS)), CATS, rotation=45, ha="right")
  66. plt.yticks(range(len(comp)), comp.index)
  67. for i in range(comp.shape[0]):
  68. for j in range(comp.shape[1]):
  69. 글자색 = "black"
  70. if comp.values[i, j] > 0.25: # 진한 칸은 글자 안 보여서 흰색으로
  71. 글자색 = "white"
  72. plt.text(j, i, round(comp.values[i, j] * 100), ha="center", va="center", fontsize=7, color=글자색)
  73. plt.title("행정동별 업종 대분류 구성비(%)")
  74. plt.colorbar()
  75. 그림저장("03_행정동_업종구성_히트맵.png")
  76. comp.round(4).to_csv(os.path.join(결과폴더, "table_행정동별_업종구성비.csv"), encoding="utf-8-sig")
  77. # 원도심 vs 신도심 업종 구성
  78. # 을 두 묶음으로 합쳐서 차이를 숫자로 확인
  79. grp = pd.crosstab(now["상권업종대분류명"], now["권역"], normalize="columns").loc[CATS]
  80. log("\n[권역별 업종 대분류 구성비]")
  81. log(grp.round(3).to_string())
  82. # 행정동별 소멸률, 신규비율 산점도
  83. # x = 얼마나 없어지나, y = 얼마나 새로 생기나
  84. plt.figure(figsize=(7, 5))
  85. 신도심 = dong[dong["권역"] == "동(신도심)"]
  86. 원도심 = dong[dong["권역"] == "읍면(원도심)"]
  87. plt.scatter(신도심["소멸률"], 신도심["신규비율"], s=np.sqrt(신도심["업소수"]) * 6, color=COLORS[0], label="동(신도심)")
  88. plt.scatter(원도심["소멸률"], 원도심["신규비율"], s=np.sqrt(원도심["업소수"]) * 6, color=COLORS[1], label="읍면(원도심)")
  89. for 이름 in dong.index:
  90. if dong.loc[이름, "업소수"] > 400: # 큰 동만 이름 표시
  91. plt.text(dong.loc[이름, "소멸률"], dong.loc[이름, "신규비율"], 이름, fontsize=8)
  92. plt.xlabel("1년 소멸률 (2025.6 업소 중 사라진 비율)")
  93. plt.ylabel("신규 비율 (2026.6 업소 중 새로 생긴 비율)")
  94. plt.title("행정동별 소멸률과 신규 비율 (원 크기 = 업소 수)")
  95. plt.legend()
  96. 그림저장("04_행정동_소멸률_신규비율.png")
  97. return dong, comp
  98. # 머신러닝 - K-Means 로 상권 유형 나누기
  99. # 세종 전체를 250m 격자로 나누고, 격자마다 업종 비율/업소 수/1층 비율/체인 비율을 구해서
  100. # 비슷한 격자끼리 묶기
  101. def 상권유형군집(now, prev, CATS):
  102. # 250m 격자 단위로 K-Means 군집화해서 상권 유형을 자동으로 나눔
  103. # 행정동은 너무 커서 한 동 안에 학원가, 먹자골목, 주택가가 섞임 -> 격자로 더 잘게 봄
  104. log("\n" + "=" * 70)
  105. log("3. 머신러닝 ①: 격자(250m) 단위 상권 유형 군집화 (K-Means)")
  106. log("=" * 70)
  107. # 격자 만들기
  108. now["gx"] = (now["x"] // GRID_M).astype(int)
  109. now["gy"] = (now["y"] // GRID_M).astype(int)
  110. prev["gx"] = (prev["x"] // GRID_M).astype(int)
  111. prev["gy"] = (prev["y"] // GRID_M).astype(int)
  112. # 격자별로 값 계산
  113. g = now.groupby(["gx", "gy"])
  114. cell = pd.DataFrame()
  115. cell["업소수"] = g.size()
  116. cell["체인비율"] = g["체인"].mean()
  117. cell["신규비율"] = g["신규"].mean()
  118. cell["위도"] = g["위도"].mean()
  119. cell["경도"] = g["경도"].mean()
  120. cell["대표동"] = g["행정동명"].agg(최빈값)
  121. 층있음 = now[now["1층여부"] >= 0]
  122. cell["1층비율"] = 층있음.groupby(["gx", "gy"])["1층여부"].mean()
  123. cell["1층비율"] = cell["1층비율"].fillna(cell["1층비율"].median()) # 층정보 없는 격자는 중앙값으로
  124. 업종비율 = pd.crosstab([now["gx"], now["gy"]], now["상권업종대분류명"], normalize="index")
  125. cell = cell.join(업종비율[CATS])
  126. cell = cell[cell["업소수"] >= MIN_STORES].copy() # 업소 적은 격자 빼기
  127. cell["log업소수"] = np.log1p(cell["업소수"]) # 업소 수 차이가 너무 커서 로그 씌움
  128. log(f"분석 대상 격자: {len(cell)}개 (업소 {MIN_STORES}개 이상), 포함 업소 {cell['업소수'].sum():,}개")
  129. # K-Means
  130. # 변수 단위가 다르니까 표준화한 뒤 군집
  131. # 군집 수 k는 엘보 + 실루엣으로 봄. 실루엣이 전체적으로 낮아서(0.1대) 경계가 뚜렷하진 않음
  132. FEATS = CATS + ["log업소수", "1층비율", "체인비율"]
  133. X = StandardScaler().fit_transform(cell[FEATS]) # 표준화 (단위 맞추기)
  134. # k 정하기 : k를 3~8까지 해보고 점수 확인
  135. ks = [3, 4, 5, 6, 7, 8]
  136. inertias = []
  137. sils = []
  138. for k in ks:
  139. km = KMeans(n_clusters=k, n_init=20, random_state=SEED)
  140. km.fit(X)
  141. inertias.append(km.inertia_)
  142. sils.append(silhouette_score(X, km.labels_))
  143. log("k별 실루엣 점수: " + ", ".join(f"k={k}:{s:.3f}" for k, s in zip(ks, sils)))
  144. # k=3은 유형이 너무 적어서 4 이상 중에 실루엣 제일 높은 걸로
  145. BEST_K = 4
  146. 최고점수 = -1
  147. for k, s in zip(ks, sils):
  148. if k >= 4 and s > 최고점수:
  149. 최고점수 = s
  150. BEST_K = k
  151. log(f"선택된 군집 수 K = {BEST_K} (k>=4 중 실루엣 최대)")
  152. plt.figure(figsize=(10, 3.6))
  153. plt.subplot(1, 2, 1)
  154. plt.plot(ks, inertias, marker="o")
  155. plt.title("엘보 곡선")
  156. plt.xlabel("군집 수 k")
  157. plt.subplot(1, 2, 2)
  158. plt.plot(ks, sils, marker="o")
  159. plt.axvline(BEST_K, color="gray", linestyle=":")
  160. plt.title("실루엣 점수")
  161. plt.xlabel("군집 수 k")
  162. 그림저장("05_군집수_선택.png")
  163. # 최종 K-Means
  164. km = KMeans(n_clusters=BEST_K, n_init=50, random_state=SEED)
  165. km.fit(X)
  166. cell["군집"] = km.labels_
  167. # 군집별 평균
  168. prof = cell.groupby("군집")[CATS + ["업소수", "1층비율", "체인비율", "신규비율"]].mean()
  169. prof["격자수"] = cell.groupby("군집").size()
  170. 전체평균 = cell[CATS].mean()
  171. # 군집 이름 붙이기
  172. # 손으로 붙이면 k나 데이터가 바뀔 때마다 고쳐야 하니까 규칙으로 함
  173. 상위25 = cell["업소수"].quantile(0.75)
  174. 중앙값 = cell["업소수"].quantile(0.5)
  175. 이름들 = []
  176. for c in prof.index:
  177. r = prof.loc[c]
  178. if r["업소수"] >= 상위25:
  179. 밀도 = "고밀"
  180. elif r["업소수"] <= 중앙값:
  181. 밀도 = "저밀"
  182. else:
  183. 밀도 = "중밀"
  184. 배율 = (r[CATS] / 전체평균).sort_values(ascending=False)
  185. if 배율.iloc[0] < 1.3:
  186. 이름 = 밀도 + " 업종혼합"
  187. elif 배율.iloc[1] >= 1.3:
  188. 이름 = f"{밀도} {배율.index[0]}·{배율.index[1]} 특화"
  189. else:
  190. 이름 = f"{밀도} {배율.index[0]} 특화"
  191. 이름들.append(이름)
  192. prof["유형명"] = 이름들
  193. # 같은 이름이 있으면 뒤에 번호 붙이기
  194. 겹침 = prof["유형명"].duplicated(keep=False)
  195. prof.loc[겹침, "유형명"] = prof.loc[겹침, "유형명"] + " " + prof.loc[겹침].index.astype(str)
  196. cell["유형명"] = cell["군집"].map(prof["유형명"])
  197. log("\n[군집(상권 유형) 프로파일]")
  198. log(prof.round(3).to_string())
  199. prof.round(4).to_csv(os.path.join(결과폴더, "table_상권유형_군집프로파일.csv"), encoding="utf-8-sig")
  200. # 동별로 어떤 유형 격자가 몇 개 있는지
  201. dong_type = pd.crosstab(cell["대표동"], cell["유형명"])
  202. log("\n[행정동별 상권 유형 격자 수]")
  203. log(dong_type.to_string())
  204. # 결과 그림
  205. # 2차원으로 줄여서 군집 그림 그리기
  206. pca = PCA(n_components=2, random_state=SEED)
  207. P = pca.fit_transform(X)
  208. plt.figure(figsize=(7, 5))
  209. for c in range(BEST_K):
  210. m = cell["군집"].values == c
  211. plt.scatter(P[m, 0], P[m, 1], s=20, color=COLORS[c % 8], label=prof.loc[c, "유형명"])
  212. plt.title(f"상권 유형 군집 결과 (PCA 2차원, 설명력 {pca.explained_variance_ratio_.sum():.0%})")
  213. plt.legend(fontsize=8)
  214. 그림저장("06_군집_PCA.png")
  215. # 군집별 업종 배율 히트맵 (1보다 크면 평균보다 많음)
  216. lift = prof[CATS] / 전체평균
  217. plt.figure(figsize=(9, 0.6 * BEST_K + 1.5))
  218. plt.imshow(lift.values, cmap="Blues", aspect="auto", vmin=0, vmax=3)
  219. plt.xticks(range(len(CATS)), CATS, rotation=45, ha="right")
  220. plt.yticks(range(BEST_K), prof["유형명"])
  221. for i in range(lift.shape[0]):
  222. for j in range(lift.shape[1]):
  223. 글자색 = "black"
  224. if lift.values[i, j] > 1.8:
  225. 글자색 = "white"
  226. plt.text(j, i, round(lift.values[i, j], 1), ha="center", va="center", fontsize=8, color=글자색)
  227. plt.title("상권 유형별 업종 특화 배율 (전체 평균=1.0)")
  228. plt.colorbar()
  229. 그림저장("07_상권유형_업종특화배율.png")
  230. return cell, prof, BEST_K
파일 확인 정보

SHA-256: f32ac91417bfa4440e8c7ac152477547bb5c0e79f22cf41f6c51460ab3dc7da8