[026] 인구 분석 / EDA·웹 크롤링·파이썬 프로그래밍

이연희·2023년 9월 12일

Chapter
1. 인구현황 데이터 정리하기
2. 인구현황 데이터 지도 ID 만들기
3. 인구현황 시각화하기

이번 시간에는 '인구 소멸 위기 지역'을 파악해 보았다. 우선 그 용어를 알 필요가 있는데, 인구 소멸 위기 지역은 65세 이상 노인 인구와 20-39세 여성 인구를 비교해 젊은 여성 인구가 노인 인구의 절반에 미달할 경우를 말한다.

데이터를 불러와 정리한 후, 원데이터를 분석에 맞게 가공한 후, 각 지역의 인구현황을 카르토그램으로 시각화화하는 과정까지 진행해보겠다.

  • 참고1: 카르토그램(cartogram)이란, '의석수나 선거인단수, 인구 등의 특정한 데이터 값의 변화에 따라 지도의 면적이 왜곡되는 그림'을 말하는데(출처: 위키백과) 이번 분석에서는 인구 수로 이를 활용하려고 한다.
  • 참고2: 원데이터는 '국가통계포털'에서 얻은 '2016 행정구역별 인구수'이다.
    (https://kosis.kr/index/index.do)

1. 인구현황 데이터 정리하기

원본데이터는 다음과 같다.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import set_matplotlib_hangul
import warnings

warnings.filterwarnings(action="ignore")
%matplotlib inline

population = pd.read_excel("../data/07_population_raw_data.xlsx", header=1)
population.fillna(method="pad", inplace=True) 
population.head(3)

데이터프레임을 가공하는 과정을 거쳐서 좀 더 식별하기 쉽게 하였다.
먼저, 컬럼명을 변경하고, '시도'컬럼의 '소계'값은 제거해주었다. 또한 변경된 이름의 '구분' 컬럼의 value값까지 변경해주었다.

# 컬럼명 변경
population.rename(
    columns = {
        "행정구역(동읍면)별(1)":"광역시도",
        "행정구역(동읍면)별(2)":"시도",
        "계":"인구수"
    }, inplace=True
)


# '소계' 제거
population = population[population["시도"]!= "소계"]


# '항목' 컬럼명 -> '구분'
population.is_copy = False

population.rename(
    columns = {"항목":"구분"}, inplace=True
)


#'구분' 컬럼의 value 변경
population.loc[population["구분"]=="총인구수 (명)", "구분"]  = "합계"
population.loc[population["구분"]=="남자인구수 (명)", "구분"]  = "남자"
population.loc[population["구분"]=="여자인구수 (명)", "구분"]  = "여자"

poulation.tail(3)

다음으로, 소멸지역을 찾아내기 위해서 20-39세와 65세 이상의 인구 수가 필요하므로 이들의 컬럼을 만들어 주었다.

population["20-39세"] = (
    population["20 - 24세"] + population["25 - 29세"] + population["30 - 34세"] + population["35 - 39세"]
)

population["65세 이상"] = (
    population["65 - 69세"] + population["70 - 74세"] + population["75 - 79세"] 
    + population["80 - 84세"] + population["85 - 89세"] + population["90 - 94세"]+ population["95 - 99세"]+ population["100+"] )

population.tail()

우리는 지역별 20-39세 여성 인구 수와 65세 이상 인구의 합계 수가 필요하므로 원하는 데이터를 쉽게 얻기 위해서 pivot table형식으로 만들어주었다.


pop = pd.pivot_table(
    data=population,
    index = ["광역시도", "시도"],
    columns=["구분"],
    values=["인구수", "20-39세", "65세 이상"]
)

pop

또한 소멸비율 = (20-39세 여성 인구 수) / (65세 이상 인구 수의 절반) 으로 새로운 컬럼을 만든 후, 비율이 1.0 이하인 지역을 boolean type으로 '소멸위기지역'인가를 확인할 컬럼을 만들어 주었다.

pop["소멸비율"] = pop["20-39세", "여자"] / (pop["65세 이상", "합계"] / 2)
pop["소멸위기지역"] = pop["소멸비율"] < 1.0
pop

마지막으로 두 줄로 되어 있는 칼럼을 깔끔하게 정리한다.
먼저 인덱스를 재설정한 다음, 컬럼의 첫번째 줄과 두번째 줄을 합쳐서 한 줄의 컬럼으로 만들어 준다.

# 인덱스 재설정
pop.reset_index(inplace=True)

# 컬럼명 정리
tmp_columns = [
    pop.columns.get_level_values(0)[n] + pop.columns.get_level_values(1)[n]
    for n in range( 0, len(pop.columns.get_level_values(0)) )   
]

pop.columns = tmp_columns
pop.head()

.
.
.
.

2. 인구현황 데이터 지도 ID 만들기

여기까지 데이터프레임을 정리한 다음, 지도에 지명을 표시하기 위해 'ID'컬럼을 만들어 주었다.

지도에 지명ID를 표시하기 위해 일정한 규칙을 두었다.

  • ID는 '특별시', '도', '시' 등과 같은 행정 구역 표시는 빼고 저장함.
  • 광역자치단체가 자치구를 포함한다면 구 이름까지 포함해서 ID를 출력함. 이때, 구 이름이 2글자라면 행정 구역 표시까지 포함.
    (ex. 서울 서초, 서울 중구, 인천 남동 등)
  • 광역자치단체가 아닌 시/군 또한 행정구를 포함한하면 위와 같은 규칙을 따름.
    (ex. 수원 팔달, 성남 분당 등)
  • 이외의 시/군명은 이름만 저장함.
    (ex. 남양주, 통영 등)
  • 만약 동일한 시/군명이 있다면 광역자치단체를 표시해줌.
    (ex. 고성(강원), 고성(경남))

자치구는 '시도'컬럼에 이미 저장되어 있으므로, 행정구만 따로 만들어주었다.

'ID' 컬럼으로 저장될 value를 먼저 저장하기 위에서 임의의 변수 si_name을 만들어 주었다.

si_name = [None] * len(pop)

이제 ID를 정리해보자.
먼저 광역자치단체와 시군명을 정리해주었다.

for idx, row in pop.iterrows():
    if row["광역시도"][-3:] not in ["광역시", "특별시","자치시"]:      
        si_name[idx] = row["시도"][:-1]      # 남양주시 > 남양주, 강릉시 > 강릉

    elif row["광역시도"] == "세종특별자치시":
        si_name[idx] = "세종"
        
    else:
        if len(row["시도"])==2:   # 중구, 서구
            si_name[idx] = row["광역시도"][:2] + " " + row["시도"]   # 서울특별시 중구 > 서울 중구
        else:
            si_name[idx] = row["광역시도"][:2] + " " + row["시도"][:-1]

앞에서 말한 규칙대로 먼저, 자치구를 포함하지 않은 광역자치단체라면 시군명만 저장. 특자시는 '세종' 한 군데 뿐이므로 따로 조건문을 생성, 마지막으로 자치구를 저장할 때, 2글자라면 '구'까지 행정구역 표시를 포함해서 변수에 저장해 주었다.

다음으로 tmp_gu_dict변수에 저장한 행정구를 정리한다.

for idx, row in pop.iterrows():
    
    if row["광역시도"][-3:] not in ["광역시", "특별시", "자치시"]:
        
        for keys, values in tmp_gu_dict.items():
            if row["시도"] in values:
                
                if len(row["시도"])==2:
                    si_name[idx] = keys + " " + row["시도"]
                    
                elif row["시도"] in ["마산합포구", "마산회원구"]:
                    si_name[idx] = keys + " " + row["시도"][2:-1]  
                    
                else:
                    si_name[idx] = keys + " " + row["시도"][:-1]

마찬가지로 규칙을 따라 각자의 si_name의 인덱스에 저장해주었다.

마지막으로 동명의 '고성군'을 가지고 있는 강원과 경남은 ID에 각각의 광역자치단체명을 표시해주었다.

for idx, row in pop.iterrows():
    if row["광역시도"][-3:] not in ["광역시", "특별시", "자치시"]:
        if row["시도"][:-1] == "고성" and row["광역시도"] == "강원도":
            si_name[idx] = "고성(강원)"
        elif row["시도"][:-1] =="고성" and row["광역시도"] == "경상남도":
            si_name[idx] = "고성(경남)"

모든 ID명의 정리가 끝났다면, 데이터프레임에 ID컬럼을 만들어준다.

모든 데이터 정리가 끝나고, 이 상태로 시각화를 해도 무리는 없지만 우리가 분석을 하기에는 필요없는 데이터들이 있어서 보기에 깔끔하지는 않다. 그래서 필요없는 컬럼들을 정리해 주었다.

이제 정말! 데이터 가공이 끝났다.
이제 카르토그램과 지도를 이용해 시각화해보자!!
.
.
.
.

3. 인구현황 시각화하기

(1) cartogram으로 표현한 시각화

① 데이터 불러와 (x,y)좌표 구하기

이제 카르토그램으로 시각화를 해보려고 한다.
아래와 같은 형태를 만들려고 한다.
(ps. 아래 파일은 교수님께서 직접 엑셀로 만드신 카르토그램이다.... 우리는 파일을 제공 받았다... 감사합니다....)

먼저 지명을 지도에 위치시킨 엑셀파일을 불러온다. 지명이 위치하지 않은 셀(지도에서는 해역)은 NaN으로 채워져 있다.

우리는 카르토그램을 그래프를 이용해서 그릴 예정인데, 그래프에 지명을 위치시키기 위해 (x,y)좌표가 필요하다. 그래서 데이터프레임을 해제시켜서 행번호와 열번호를 좌표로 사용하려고 한다.

② 행정구역 경계선 그리기

이제 구한 좌표를 이용해서 행정구역 경계선을 그리고 지명까지 표시해보자. 그래프에 그릴 경계선 좌표이다.

border_lines = [
    [(5,1),(5,2),(7,2),(7,3),(11,3),(11,0)], # 인천
    [(5,4),(5,5),(2,5),(2,7),(4,7),(4,9),(7,9),(7,7),(9,7),(9,5),(10,5),(10,4),(5,4)], # 서울
    [(1,7),(1,8),(3,8),(3,10),(10,10),(10,7),(12,7),(12,6),(11,6),(11,5),(12,5),(12,4),(11,4),(11,3)], # 경기도
    [(8,10),(8,11),(6,11),(6,12)], # 강원도
    [(12,5),(13,5),(13,4),(14,4),(14,5),(15,5),(15,4),(16,4),(16,2)], # 충청북도
    [(16,4),(17,4),(17,5),(16,5),(16,6),(19,6),(19,5),(20,5),(20,4),(21,4),(21,3),(19,3),(19,1)], #전북
    [(13,5),(13,6),(16,6)],[(13,5),(14,5)], #대전 #세종
    [(21,2),(21,3),(22,3),(22,4),(24,4),(24,2),(21,2)], #광주
    [(20,5),(21,5),(21,6),(23,6)], #전남
    [(10,8),(12,8),(12,9),(14,9),(14,8),(16,8),(16,6)], #충북
    [(14,9),(14,11),(14,12),(13,12),(13,13)], #경북
    [(15,8),(17,8),(17,10),(16,10),(16,11),(14,11)], #대구
    [(17,9),(18,9),(18,8),(19,8),(19,9),(20,9),(20,10),(21,10)], #부산
    [(16,11),(16,13)],
    [(27,5),(27,6),(25,6)]
]

우선은 경계선이 옳게 그려졌는지 확인하기 위해서 간단한 테스트 함수로 확인해보려고 한다.

def plot_text_simple(draw_korea):
    for idx,row in draw_korea.iterrows():
        if len(row["ID"].split()) == 2:
            dispname = "{}\n{}".format(row["ID"].split()[0], row["ID"].split()[1])
        elif row["ID"][:2] == "고성":
            dispname = "고성"   #어차피 위치로 광역 나눌 수 있어서 상관 없음
        else:
            dispname = row["ID"]  #시흥, 화성

        if len(dispname.splitlines()[-1]) >= 3:
            fontsize, linespacing = 9.5, 1.5  # 세글자 이상이면 글자크기 줄이기
        else:
            fontsize, linespacing = 11, 1.2

    
    
        # 주석달기    
        plt.annotate(
            dispname,
            (row["x"] + 0.5, row["y"] + 0.5),
            weight = "bold",
            fontsize = fontsize,
            linespacing = linespacing,
            ha = "center",  #수평정렬
            va = "center"   #수직정렬
    )

먼저 지도에 지명을 표시할 함수이다. 그래프에 주석을 넣을 메서드(annotate)을 이용했다.
우선은 구 이름까지 표시하고 있는 ID명은 두줄로 나눠서 표시하려고 하며, 동일한 행정구역을 가진 '충남 고성군'과 '강원 고성군'은 어차피 지도에 표시하면 광역자치단체를 구분할 수 있으므로 '고성'만 써넣어준다.

이제 샘플 그래프를 그려줄 함수를 표현해주자.

def simple_draw(draw_korea):
    plt.figure(figsize=(8,11))
    
    plot_text_simple(draw_korea)
    
    for path in border_lines:
        ys,xs = zip(*path) #x좌표끼리, y좌표끼리
        plt.plot(xs,ys, c="black", lw=1.5)
        
    plt.gca().invert_yaxis()   #축 뒤집기
    plt.axis("off")           # x,y축 없애기
    plt.tight_layout()        
    plt.show()


위와 같이 행정구역의 경계선이 표시되었다!

③ ID 검증작업과 데이터프레임 병합

색을 이용한 시각화 작업에 들어가기 전에, 서로 다른 정보를 가진 pop, draw_korea 데이터프레임을 병합하려고 한다. 그 전에 index가 너무 많기 때문에 중복된 ID가 없는지 검증작업을 해보자. 집합함수(set)서 쉽게 해결할 수 있다.

다음과 같이 pop 데이터프레임에 중복된 값이 확인되었다(광역시가 아닌데 행정구를 가지고 있던 도시들로 ID컬럼에 행정구명을 포함한 상태로 이미 저장되어 있으니 필요없다.) 제거 해준다.

이제 ID를 기준으로 두 데이터프레임을 병합해준다.

④ 그림을 그리기 위한 함수 만들기

이제 앞에서 작성해본 테스트함수를 응용해서 원하는 c컬럼에 따라 카르토그램을 그려볼 함수를 만들어보자.
크게 세 과정의 함수로 표현하려고 한다.

  • 컬럼의 value가 양의 값만 있는가? 음의 값도 포함하고 있는가에 따라 영점을 맞춰줄 함수
  • 카르토그램에 그래프 주석으로 지명을 표시해줄 함수(위의 테스트함수로 확인함)
  • 지역의 경계선을 그리고, value에 따라 색을 칠해줄 함수 (위트 테스트 함수로 확인함)

먼저, value에 양수만 존재할 때 사용할 함수이다.
인자에 targetData는 카르토그램으로 시각화할 컬럼, blockedMap에는 인구현황(pop)을 넣어주려고 한다.

# value에 양수값만 존재할 때 사용할 함수
def get_data_info(targetData, blockedMap):
    whitelabelmin = (
        max(blockedMap[targetData]) - min(blockedMap[targetData])
    ) * 0.25 + min(blockedMap[targetData])  # 그래프 셀의 바탕색에 따라 글자색을 조정하기 위한 조치
    vmin = min(blockedMap[targetData])
    vmax = max(blockedMap[targetData])
    
    mapdata = blockedMap.pivot_table(index="y", columns="x", values=targetData) #다시 원데이터 모양으로 

    return mapdata, vmax, vmin, whitelabelmin

그래프의 셀의 색깔에 따라 지명의 글자수가 진해지거나 밝아져야할 필요성이 있기때문에 whitelabelmin으로 그 조치를 취하려고 한다. 또한 mapdata에 원하는 컬럼의 value로 행정구역에 데이터를 위치시킬 피벗테이블을 지정한다.

만약 value에 음수까지 포함되어 있다면 다음의 함수를 사용한다.

# value에 음수,양수 둘 다 존재할 때, 0의 위치를 센터로 두려고 만들 함수
def get_data_info_for_zero_center(targetData, blockedMap): 
    whitelabelmin = 5 
    
    # 절댓값이 가장 큰 수를 찾아서 그 음의 값과 양의 값을 범위로 삼아서 중앙을 흰색으로 한다.
    tmp_max = max(
        [np.abs(min(blockedMap[targetData])), np.abs(max(blockedMap[targetData]))]
    ) 
    vmin, vmax = -tmp_max, tmp_max  
    mapdata = blockedMap.pivot_table(index="y", columns="x", values=targetData)
    return mapdata, vmax, vmin, whitelabelmin

쉽게 영점을 맞춰줄 함수라고 생각하면 되는데, 절댓값이 큰 값을 찾아 tmp_max로 지정한다음 tmp_max의 음의 값과 양의 값으로 범위를 잡아서 중앙의 0의 값을 흰색으로 잡는다.

이제 앞에서 확인한 테스트 함수처럼 그래프의 주석으로 지명을 써넣어 주자.

def plot_text(targetData, blockedMap, whitelabelmin): 
    for idx, row in blockedMap.iterrows():
        if len(row["ID"].split()) == 2:
            dispname = "{}\n{}".format(row["ID"].split()[0], row["ID"].split()[1])
        elif row["ID"][:2] == "고성":
            dispname = "고성"
        else:
            dispname = row["ID"]
            
        if len(dispname.splitlines()[-1]) >= 3:
            fontsize, linespacing = 9.5, 1.5
        else:
            fontsize, linespacing = 11, 1.2


        annocolor = "white" if np.abs(row[targetData]) > whitelabelmin else "black"
        
        plt.annotate(
            dispname,
            (row["x"] + 0.5, row["y"] + 0.5),
            weight="bold",
            color=annocolor,
            fontsize=fontsize,
            linespacing=linespacing,
            ha="center", # 수평 정렬
            va="center", # 수직 정렬 
        )

annocolor는 주석(지명)의 색을 잡아줄 변수이다. 만약에 value의 절대값이 whitelabelmain보다 크면 셀의 색이 짙어지기 때문에 글자수를 흰색으로, 그보다 작아면 셀의 색이 밝아지므로 글자수를 검정으로 표현해준다.

마지막으로 이 과정을 모두 거칠 최종 함수를 만들어주자.

def drawKorea(targetData, blockedMap, cmapname, zeroCenter=False):
    if zeroCenter:  # 0이 센터로 위치하고 음수, 양수 둘 다 존재
        masked_mapdata, vmax, vmin, whitelabelmin = get_data_info_for_zero_center(targetData, blockedMap)
    
    if not zeroCenter:  # 값의 범위가 양수만 존재할 때 
        masked_mapdata, vmax, vmin, whitelabelmin = get_data_info(targetData, blockedMap)
        
    plt.figure(figsize=(8, 11))
    plt.pcolor(masked_mapdata, vmin=vmin, vmax=vmax, cmap=cmapname, edgecolor="#aaaaaa", linewidth=0.5)
    
    plot_text(targetData, blockedMap, whitelabelmin)
    
    for path in border_lines:
        ys, xs = zip(*path)
        plt.plot(xs, ys, c="black", lw=1.5)
    
    plt.gca().invert_yaxis()
    plt.axis("off")
    plt.tight_layout()
    cb = plt.colorbar(shrink=0.1, aspect=10)
    cb.set_label(targetData)
    plt.show()

인자로 zeroCenter를 False로 디폴트값을 주어서 value의 범위가 양수만 있는가 음수까지 있는가에 따라 get_data_info_for_zero()함수, get_data_info()함수를 사용할 구별 옵션을 넣어준다.

⑤ 카르토그램을 이용한 분석

이제 모든 준비가 끝났다. 카르토그램을 그려보자.

먼저, 지역별 인구수를 카르토그램으로 그려본다.

drawKorea("인구수합계", pop, "Blues")

색이 진할 수록 인구수가 많은 지역임을 확인할 수 있다. 육안으로는 남양주, 서울 송파, 서울 노원, 화성 등 역시 수도권에 많은 인구가 밀집되어 있으며, 지방으로 갈수록 인구가 적다는 것을 확인할 수 있다.

그렇다면 소멸위기지역도 확인해보자.

pop["소멸위기지역"] = [1 if con else 0 for con in pop["소멸위기지역"]]
drawKorea("소멸위기지역", pop, "Reds")

앞에서 소멸위기지역 컬럼은 boolean type으로 저장했기 때문에 이를 다시 1과 0으로 표현해서 카르토그램을 그려주었다.

수도권은 위기가 적어보이지만 강원과 충청, 전라 지역이 상대적으로 노인수 대비 2030여성수가 적다는 것을 확인할 수 있다. 그렇다면 2030여성비율도 비슷한 색의패턴의 카르토그램이 그려질까? 먼저 확인해볼 컬럼을 새로 만들어주었다. 이때 남녀비율이 같다면 0.5가 나올테니 0.5를 빼서 0점을 맞춰서 값을 계산해주었다.

pop["2030여성비"] = (pop["20-39세여자"] / pop["20-39세합계"] - 0.5) * 100
pop.head()

카르토그램을 그려보자. 이때, value에 음수가 섞여있을테니 zeroCenter옵션을 True로 넣어주는 것을 잊지 말아야 한다.

drawKorea("2030여성비", pop, "RdBu", zeroCenter=True)

역시 위의 소멸위기지역과 비교해보면 약간 비슷한 색의 패턴을 보이는 것을 확인할 수 있다.

.
.

(2) folium을 이용한 시각화

folium을 이용해서 간단한 시각화도 해보자.
라이브러리를 이용하기 위해 인덱스를 ID로 맞춰주었다.

import folium
import json

pop_folium = pop.set_index("ID")
pop_folium.head()

인구수합계를 확인해보자.

geo_path = "../data/07_skorea_municipalities_geo_simple.json"
geo_str = json.load(open(geo_path, encoding="utf-8"))

# 인구수합계
mymap = folium.Map(location=[36.2002, 127.054], zoom_start=7)
mymap.choropleth(
    geo_data=geo_str,
    data=pop_folium["인구수합계"],
    key_on = "feature.id",
    columns = [pop_folium.index, pop_folium["인구수합계"]],
    fill_color="YlGnBu"
)
mymap

카르토그램과 마찬가지로 수도권과 일부 지방의 대도시에 인구수가 몰려있는 것을 확인할 수 있다.

마지막으로 소멸위기지역도 확인해보자.

# 소멸위기지역 시각화
mymap = folium.Map(location=[36.2002, 127.054], zoom_start=7)
mymap.choropleth(
    geo_data=geo_str,
    data=pop_folium["소멸위기지역"],
    key_on = "feature.id",
    columns = [pop_folium.index, pop_folium["소멸위기지역"]],
    fill_color="PuRd"
)
mymap

역시 카르토그램의 결과과 마찬가지로 강원과 충청, 전라 일대에 안타까운 현황을 확인할 수 있다.

profile
안녕하세요, 데이터 공부를 하고 있습니다.

0개의 댓글