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

jochiwon_alley.py

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

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

  1. # 조치원 상권 분석용 독립 모듈!
  2. import os
  3. import numpy as np
  4. import pandas as pd
  5. import matplotlib.pyplot as plt
  6. from sklearn.cluster import DBSCAN
  7. from sklearn.neighbors import BallTree
  8. from sklearn.model_selection import train_test_split
  9. from sklearn.ensemble import RandomForestClassifier
  10. from sklearn.inspection import permutation_importance
  11. from sklearn.metrics import roc_auc_score, classification_report
  12. from common import *
  13. # 4단계 : 조치원읍 골목상권 분석
  14. # 입지계수(LQ), 골목 덩어리 찾기, 업종별 소멸률, 골목 vs 대로, 핵심 골목 2곳 비교
  15. def 조치원분석(now, prev):
  16. # 조치원읍 골목상권을 자세히 분석 (보고서 4장, 활성화 방안의 근거 대부분이 여기서 나옴)
  17. # 입지계수 : 조치원에 많은/부족한 업종
  18. # DBSCAN : 골목 단위 덩어리 찾고 덩어리별 지표 계산
  19. # 업종별 소멸률, 골목 vs 대로, 핵심 골목 2곳 비교
  20. # 반환 파라미터: T(조치원 올해), Tp(조치원 작년), alley(골목 덩어리별 지표 표)
  21. log("\n" + "=" * 70)
  22. log(f"5. 대상 지역 정밀 분석: {TARGET}")
  23. log("=" * 70)
  24. T = now[now["행정동명"] == TARGET].copy() # 조치원 올해
  25. Tp = prev[prev["행정동명"] == TARGET].copy() # 조치원 작년
  26. new_city = now[now["권역"] == "동(신도심)"]
  27. log(f"{TARGET} 업소 {len(T):,}개 (세종 전체의 {len(T)/len(now):.1%}), "
  28. f"1년 소멸률 {Tp['소멸'].mean():.1%}, 체인 비율 {T['체인'].mean():.1%} "
  29. f"(신도심 체인 비율 {new_city['체인'].mean():.1%})")
  30. # '철물, 주점, 세탁, 식료품은 많고 교육, 의원은 부족'이 나옴 -> 방안에 작성 예정
  31. # 입지계수(LQ) = 조치원에서 그 업종 비율 / 세종 전체에서 그 업종 비율
  32. # 1보다 크면 조치원에 많은 업종, 작으면 적은 업종
  33. 조치원비율 = T["상권업종중분류명"].value_counts(normalize=True)
  34. 세종비율 = now["상권업종중분류명"].value_counts(normalize=True)
  35. lq = pd.DataFrame({"LQ": (조치원비율 / 세종비율).dropna()})
  36. lq["조치원업소수"] = T["상권업종중분류명"].value_counts()
  37. lq["세종업소수"] = now["상권업종중분류명"].value_counts()
  38. lq.index.name = "상권업종중분류명"
  39. lq = lq[lq["세종업소수"] >= 50] # 업소 수 너무 적은 업종은 빼기
  40. lq = lq.sort_values("LQ", ascending=False)
  41. log("\n[입지계수 상위(조치원 특화 업종)]")
  42. log(lq.head(12).round(2).to_string())
  43. log("\n[입지계수 하위(조치원에 상대적으로 부족한 업종)]")
  44. log(lq.tail(12).round(2).to_string())
  45. lq.round(3).to_csv(os.path.join(결과폴더, "table_조치원_입지계수.csv"), encoding="utf-8-sig")
  46. 보기 = pd.concat([lq.head(10), lq.tail(10)])
  47. 색 = []
  48. for v in 보기["LQ"]:
  49. if v >= 1:
  50. 색.append(COLORS[1])
  51. else:
  52. 색.append(COLORS[0])
  53. plt.figure(figsize=(7, 7))
  54. plt.barh(보기.index[::-1], 보기["LQ"][::-1], color=색[::-1])
  55. plt.axvline(1, color="gray")
  56. plt.title(f"{TARGET} 업종별 입지계수(LQ) 상·하위 10\n(1보다 크면 세종 평균보다 많음)")
  57. 그림저장("08_조치원_입지계수.png")
  58. # DBSCAN으로 골목상권 덩어리 찾기
  59. # 행정동 단위로는 골목별 차이가 안 보여서, 가게들이 붙어 있는 구역을 좌표로 찾음
  60. # K-Means와 달리 군집 수를 미리 안 정해도 되고, 띄엄띄엄 있는 가게는 -1(산재)로 빠져서 골목 찾기에 맞음
  61. # eps = 60m (걸어서 1분 정도), 최소 15개
  62. # (40, 80, 100m 등도 해봤는데 60m가 제일 적당했음 그냥 이걸로 하셈)
  63. coords = T[["x", "y"]].values
  64. db = DBSCAN(eps=60, min_samples=15)
  65. db.fit(coords)
  66. T["골목군집"] = db.labels_ # -1은 어느 덩어리에도 안 들어간 업소
  67. 골목수 = len(set(db.labels_)) - (1 if -1 in db.labels_ else 0)
  68. log(f"\nDBSCAN 결과: 골목상권 덩어리 {골목수}개, 군집 외(산재) 업소 {(db.labels_ == -1).sum()}개")
  69. # 작년 업소는 군집 번호가 없어서 제일 가까운 올해 업소(60m 안)의 번호를 따라감
  70. tree = BallTree(coords)
  71. dist, idx = tree.query(Tp[["x", "y"]].values, k=1)
  72. Tp["골목군집"] = np.where(dist[:, 0] <= 60, T["골목군집"].values[idx[:, 0]], -1)
  73. # 골목 덩어리마다 지표 계산
  74. # 덩어리 하나씩 돌면서 크기, 업종 비율, 소멸률, 신규비율, 청년소비형/생활밀착형 비율을 구해서 표로 만듦
  75. # 여기서 '행복길 = 음식 54%, 소멸 17%', '새내로 = 안정적이지만 신규 11%'가 나옴 -> 방안에 활용ㄱㄱ
  76. YOUTH = ["비알코올", "주점", "유원지·오락"] # 카페, 주점, 오락
  77. DAILY = ["이용·미용", "식료품 소매", "섬유·의복·신발 소매", "세탁", "종합 소매"] # 단순 소매점들
  78. 골목목록 = sorted(T.loc[T["골목군집"] >= 0, "골목군집"].unique())
  79. 행들 = []
  80. for k in 골목목록:
  81. a = T[T["골목군집"] == k] # 올해 이 골목 업소
  82. b = Tp[Tp["골목군집"] == k] # 작년 이 골목 업소
  83. 행 = {}
  84. 행["골목군집"] = k
  85. 행["업소수"] = len(a)
  86. 행["대표도로"] = ", ".join(a["도로"].value_counts().index[:3])
  87. 행["음식비율"] = (a["상권업종대분류명"] == "음식").mean()
  88. 행["소매비율"] = (a["상권업종대분류명"] == "소매").mean()
  89. 행["수리개인비율"] = (a["상권업종대분류명"] == "수리·개인").mean()
  90. 행["체인비율"] = a["체인"].mean()
  91. 행["신규비율"] = a["신규"].mean()
  92. 행["위도"] = a["위도"].mean()
  93. 행["경도"] = a["경도"].mean()
  94. 행["소멸률"] = b["소멸"].mean()
  95. 행["업종다양성"] = 다양성(a["상권업종중분류명"])
  96. 행["주요업종"] = ", ".join(a["상권업종중분류명"].value_counts().index[:4])
  97. 행["업소수_2025"] = len(b)
  98. 행["순증가율"] = len(a) / len(b) - 1
  99. 행["청년소비형비율"] = a["상권업종중분류명"].isin(YOUTH).mean()
  100. 행["생활밀착형비율"] = a["상권업종중분류명"].isin(DAILY).mean()
  101. 행들.append(행)
  102. alley = pd.DataFrame(행들).set_index("골목군집")
  103. alley = alley.sort_values("업소수", ascending=False)
  104. log("\n[조치원 골목상권 덩어리별 지표]")
  105. log(alley.drop(columns=["위도", "경도"]).round(3).to_string())
  106. alley.round(4).to_csv(os.path.join(결과폴더, "table_조치원_골목군집.csv"), encoding="utf-8-sig")
  107. # 조치원 업종(중분류)별 소멸률
  108. # 어떤 업종이 잘 망하고 어떤 업종이 오래 가는지 알아보는 코드
  109. mid = pd.DataFrame()
  110. mid["업소수_2025"] = Tp.groupby("상권업종중분류명").size()
  111. mid["소멸률"] = Tp.groupby("상권업종중분류명")["소멸"].mean()
  112. mid = mid.join(T.groupby("상권업종중분류명").size().rename("업소수_2026"), how="outer")
  113. mid = mid.join(T.groupby("상권업종중분류명")["신규"].sum().rename("신규수"), how="outer")
  114. mid = mid.fillna(0)
  115. mid["순증감"] = mid["업소수_2026"] - mid["업소수_2025"]
  116. mid = mid[mid["업소수_2025"] >= 20] # 작년에 20개 이상 있던 업종만
  117. mid = mid.sort_values("소멸률", ascending=False)
  118. log("\n[조치원 업종별 1년 소멸률(2025년 업소 20개 이상 업종)]")
  119. log(mid.round(3).to_string())
  120. mid.round(4).to_csv(os.path.join(결과폴더, "table_조치원_업종별_소멸률.csv"), encoding="utf-8-sig")
  121. # 골목(~길로 되어있는거) vs 대로(~로 단, 다솜로 같이 작게 되어 있는 것도 있어서 주의하세용) 비교
  122. T["도로유형"] = np.where(T["도로"].str.endswith("길"), "길(골목)", "로(대로)")
  123. Tp["도로유형"] = np.where(Tp["도로"].str.endswith("길"), "길(골목)", "로(대로)")
  124. road = pd.DataFrame()
  125. road["업소수"] = T.groupby("도로유형").size()
  126. road["체인비율"] = T.groupby("도로유형")["체인"].mean()
  127. road["신규비율"] = T.groupby("도로유형")["신규"].mean()
  128. road["소멸률"] = Tp.groupby("도로유형")["소멸"].mean()
  129. road["음식비율"] = (T["상권업종대분류명"] == "음식").groupby(T["도로유형"]).mean()
  130. log("\n[조치원 도로유형별 비교]")
  131. log(road.round(3).to_string())
  132. # 제일 큰 골목 2개 자세히 보기 (어떤 업종이 없어지고 생겼는지)
  133. # 행복길에서 없어진 업종과 생긴 업종이 거의 똑같다는 걸 여기서 확인함
  134. core = alley.index[:2].tolist()
  135. for k in core:
  136. a = T[T["골목군집"] == k]
  137. b = Tp[Tp["골목군집"] == k]
  138. 일층 = a.loc[a["1층여부"] >= 0, "1층여부"].mean()
  139. log(f"\n[핵심 골목 {k}: {alley.loc[k, '대표도로']}] 업소 {len(a)}개, "
  140. f"1년 소멸 {b['소멸'].sum()}개 / 신규 {a['신규'].sum()}개, 1층 비율 {일층:.0%}")
  141. 없어진업종 = b[b["소멸"] == 1]["상권업종중분류명"].value_counts().head(6)
  142. 생긴업종 = a[a["신규"] == 1]["상권업종중분류명"].value_counts().head(6)
  143. log(" - 소멸 상위 업종: " + ", ".join(f"{i}({v})" for i, v in 없어진업종.items()))
  144. log(" - 신규 상위 업종: " + ", ".join(f"{i}({v})" for i, v in 생긴업종.items()))
  145. # 핵심 골목 2개vs조치원 전체vs동지역 전체 비교표
  146. nc_prev = prev[~prev["행정동명"].str.endswith(("읍", "면"))] # 작년 동지역
  147. cmp = pd.DataFrame(columns=["1년 소멸률", "신규 비율", "체인 비율", "청년소비형 비율"])
  148. for k in core:
  149. 이름 = f"골목{k}\n({alley.loc[k, '대표도로'].split(',')[0]})"
  150. cmp.loc[이름] = [alley.loc[k, "소멸률"], alley.loc[k, "신규비율"], alley.loc[k, "체인비율"], alley.loc[k, "청년소비형비율"]]
  151. cmp.loc[f"{TARGET} 전체"] = [Tp["소멸"].mean(), T["신규"].mean(), T["체인"].mean(),
  152. T["상권업종중분류명"].isin(YOUTH).mean()]
  153. cmp.loc["신도심(동) 전체"] = [nc_prev["소멸"].mean(), new_city["신규"].mean(), new_city["체인"].mean(),
  154. new_city["상권업종중분류명"].isin(YOUTH).mean()]
  155. cmp = cmp.astype(float)
  156. log("\n[핵심 골목 비교표]")
  157. log(cmp.round(3).to_string())
  158. plt.figure(figsize=(12, 3.4))
  159. for n, col in enumerate(cmp.columns):
  160. plt.subplot(1, 4, n + 1)
  161. plt.barh(cmp.index[::-1], cmp[col][::-1], color=[COLORS[0], COLORS[0], COLORS[1], COLORS[1]])
  162. for i, v in enumerate(cmp[col][::-1]):
  163. plt.text(v, i, f" {v:.0%}", va="center", fontsize=8)
  164. plt.title(col)
  165. plt.xticks([])
  166. if n > 0:
  167. plt.yticks([])
  168. 그림저장("10_핵심골목_비교.png")
  169. # 조치원 업종별 소멸률 그래프
  170. mm = mid.sort_values("소멸률")
  171. plt.figure(figsize=(7, 6))
  172. plt.barh(mm.index, mm["소멸률"], color=COLORS[0])
  173. plt.axvline(Tp["소멸"].mean(), color="gray", linestyle=":")
  174. plt.title(f"{TARGET} 업종별 1년 소멸률 (점선 = 조치원 평균)")
  175. 그림저장("11_조치원_업종별_소멸률.png")
  176. return T, Tp, alley
  177. # 머신러닝 - 랜덤포레스트로 폐업(소멸) 위험 보기
  178. # 작년(2025.6) 업소가 1년 뒤에 없어졌는지(1) 남아있는지(0) 예측
  179. # 매출 같은 정보가 없어서 정확도는 낮을 것 같음
  180. def 소멸예측(prev, Tp, alley):
  181. # 랜덤포레스트로 1년 뒤 소멸 여부를 예측하고, 어떤 조건이 소멸과 관련 있는지 봄
  182. # 출력: 그림 09, table_조치원_골목군집.csv
  183. log("\n" + "=" * 70)
  184. log("6. 머신러닝 ②: 업소 소멸 위험 분류 (Random Forest)")
  185. log("=" * 70)
  186. P_ = prev.copy()
  187. # 모델 입력 변수 만들기
  188. # 작년 업소 기준이라 전처리에서 now 에 만든 것들을 prev 에도 똑같이 만들어 줌
  189. # 1층 여부 (작년 데이터도 같음!)
  190. 일층 = []
  191. for 층 in P_["층정보"]:
  192. if pd.isna(층):
  193. 일층.append(-1)
  194. elif str(층).strip() == "1":
  195. 일층.append(1)
  196. else:
  197. 일층.append(0)
  198. P_["1층여부"] = 일층
  199. 이름개수2 = P_["상호명"].value_counts()
  200. P_["체인"] = ((P_["지점명"].notna()) | (P_["상호명"].map(이름개수2) >= 3)).astype(int)
  201. P_["원도심"] = P_["행정동명"].str.endswith(("읍", "면")).astype(int)
  202. P_["골목길"] = P_["도로"].astype(str).str.endswith("길").astype(int)
  203. # 주변 100m 안에 업소가 몇 개 있는지 (자기 자신은 빼려고 -1)
  204. tree = BallTree(P_[["x", "y"]].values)
  205. P_["주변100m_업소수"] = tree.query_radius(P_[["x", "y"]].values, r=100, count_only=True) - 1
  206. # 주변 100m 안에 같은 업종(중분류)이 몇 개 있는지 => 경쟁 보려고
  207. same = np.zeros(len(P_), dtype=int)
  208. for 업종, 그룹 in P_.groupby("상권업종중분류명"):
  209. t = BallTree(그룹[["x", "y"]].values)
  210. 개수 = t.query_radius(그룹[["x", "y"]].values, r=100, count_only=True) - 1
  211. same[P_.index.get_indexer(그룹.index)] = 개수
  212. P_["주변100m_동종업소수"] = same
  213. P_["동종비중"] = P_["주변100m_동종업소수"] / (P_["주변100m_업소수"] + 1)
  214. # 업종은 글자라서 0, 1로 바꿈
  215. 업종더미 = pd.get_dummies(P_["상권업종대분류명"], prefix="업종").astype(int)
  216. num_cols = ["1층여부", "체인", "원도심", "골목길", "주변100m_업소수", "주변100m_동종업소수", "동종비중"]
  217. Xm = pd.concat([P_[num_cols], 업종더미], axis=1)
  218. y = P_["소멸"].values
  219. # 학습이랑 평가
  220. # 소멸이 13.7%뿐이라 그냥 정확도는 의미가 없어서 ROC-AUC로 평가
  221. # 로지스틱 회귀도 비교해 봤는데 AUC 비슷했음
  222. # 학습 75%, 검증 25%로 나누기
  223. X_tr, X_te, y_tr, y_te = train_test_split(Xm, y, test_size=0.25, stratify=y, random_state=SEED)
  224. # 소멸이 13%밖에 안 돼서 class_weight="balanced" 넣음 힘들다....
  225. rf = RandomForestClassifier(n_estimators=400, max_depth=10, min_samples_leaf=20,
  226. class_weight="balanced", random_state=SEED, n_jobs=-1)
  227. rf.fit(X_tr, y_tr)
  228. proba = rf.predict_proba(X_te)[:, 1] # 소멸할 확률
  229. auc = roc_auc_score(y_te, proba)
  230. log(f"학습 {len(X_tr):,}건 / 검증 {len(X_te):,}건, 소멸 비율 {y.mean():.1%}")
  231. log(f"검증 ROC-AUC = {auc:.3f} (0.5=무작위)")
  232. 예측 = (proba >= 0.5).astype(int)
  233. log(classification_report(y_te, 예측, target_names=["유지", "소멸"], digits=3))
  234. # 해석
  235. # 순열 중요도로 중요한 변수를 찾고, 그 변수별 실제 소멸률을 직접 계산해서 맞는지 다시 확인
  236. # 변수 중요도 (순열 중요도: 변수 하나를 섞었을 때 성능이 얼마나 떨어지는지)
  237. pi = permutation_importance(rf, X_te, y_te, scoring="roc_auc", n_repeats=10, random_state=SEED, n_jobs=-1)
  238. imp = pd.Series(pi.importances_mean, index=Xm.columns).sort_values(ascending=False)
  239. log("[순열 중요도 상위 10]")
  240. log(imp.head(10).round(4).to_string())
  241. top = imp.head(10)
  242. plt.figure(figsize=(7, 4.5))
  243. plt.barh(top.index[::-1], top.values[::-1], color=COLORS[0])
  244. plt.title(f"소멸 위험 모델 변수 중요도 (순열 중요도, AUC={auc:.2f})")
  245. 그림저장("09_소멸위험_변수중요도.png")
  246. # 중요하다고 나온 변수들이 실제로도 소멸률 차이가 있는지 직접 확인
  247. P_["주변밀도구간"] = pd.cut(P_["주변100m_업소수"], [-1, 5, 20, 50, 1000], labels=["0~5", "6~20", "21~50", "51+"])
  248. P_["동종비중구간"] = pd.cut(P_["동종비중"], [-0.01, 0.05, 0.15, 1], labels=["5%이하", "5~15%", "15%초과"])
  249. log("\n[조건별 실제 1년 소멸률]")
  250. for c in ["상권업종대분류명", "체인", "1층여부", "원도심", "골목길", "주변밀도구간", "동종비중구간"]:
  251. t = P_.groupby(c, observed=True)["소멸"].agg(["mean", "size"])
  252. 글 = []
  253. for k, v in t.iterrows():
  254. 글.append(f"{k}={v['mean']:.1%}(n={int(v['size'])})")
  255. log(f"- {c}: " + ", ".join(글))
  256. # 완료!
  257. P_["예측위험"] = rf.predict_proba(Xm)[:, 1]
  258. Tp["예측위험"] = P_.loc[Tp.index, "예측위험"]
  259. alley["평균예측위험"] = Tp[Tp["골목군집"] >= 0].groupby("골목군집")["예측위험"].mean()
  260. log("\n[조치원 골목군집별 평균 예측 위험도]")
  261. log(alley[["대표도로", "업소수", "소멸률", "평균예측위험"]].round(3).to_string())
  262. alley.round(4).to_csv(os.path.join(결과폴더, "table_조치원_골목군집.csv"), encoding="utf-8-sig")
파일 확인 정보

SHA-256: 280c92ee8a9884908229d1e8e51c31d6ed80c2e5ee88e57e9d1b4487e017c8f9