본문 바로가기
  • plotly로 바로쓰는 동적시각화 in R & 파이썬
지도 시각화

정규 교원 1인당 학생수 - 지도에 표시하는 거품 차트(Bubble Chart) in R

by 아참형인간 2022. 7. 29.
bubble.knit

지도상에 표현되는 거품 차트(bubble chart)

데이터를 분석하는 분석가나 분석가가 되기를 원하는 사람들에게 필독서인 ‘팩트풀니스’(한스 로슬링, 김영사, 2019)에서는 데이터를 분석하는데 가장 유용하게 사용하는 시각화로 거품형 차트를 제시하고 있다. 거품형 차트는 X, Y축에 매핑되는 데카르트 좌표계에 원의 크기로 데이터의 크기를 표현하는 차트로 최소 3개의 변수를 표현할 수 있는 방법이다. 이에 원의 색을 추가하면 4개의 변수를 하나의 시각화에 표현한다는 점에서 매우 활용성이 높은 시각화 방법이다.

사실 이 거품 차트는 산점도와 종이 한 장 차이라고 볼 수 있다. 산점도를 ggplot2 패키지로 만드려면 geom_point() 하나의 레이어로 간단히 만들 수 있는데 X, Y 매핑에 size를 거품의 크기로 나타내고자 하는 변수에 매핑해주면 간단히 생성된다. 여기에 color를 변수에 매핑시켜 주면 4개의 변수가 표현되는 거품 차트가 만들어 진다.

그런데 이 거품 차트를 X, Y 축의 데카르트 2차원 좌표계가 아닌 지도상에 위치시키려면 어떻게 해아할까?

이 포스트에서는 각 시도별 정규 교원 1인당 학생수를 거품 차트로 지도위에 표현해보도록 하겠다.

데이터 import

지도위에 표현되는 거품 차트를 생성하기 위해 앞서 연도별 시도별 비정규 교원 1인당 학생수 in R - rank()에서 사용했던 ‘df_provinfo_21’ 데이터프레임을 사용한다.

지도를 표현하기 위해서는 Shape 데이터와 geojson 데이터를 사용한 지도의 시각화에서 shape 파일에서 읽어들인 ‘spdf_shp’ 데이터를 사용한다.

교원 1인당 학생수 처리

거품 차트를 그리기 위해서는 먼저 원 크기로 표현할 데이터를 생성해야 한다. 여기서는 각 시도별 교원 1인당 학생수를 원의 크기로 표현하는데 이를 위해 먼저 ‘df_provinfo_21’ 데이터프레임의 구조를 살펴본다.

library(tidyverse)

glimpse(df_provinfo_21)
## Rows: 239
## Columns: 9
## $ 연도               <chr> "2021", "2021", "2021", "2021", "2021", "2021", "2021~
## $ 시도               <chr> "전국", "전국", "전국", "전국", "전국", "전국", "전국~
## $ 학제               <chr> "유치원", "초등학교", "중학교", "고등학교", "(일반고)~
## $ 학급수             <dbl> 33381, 124047, 53053, 56245, 40063, 2865, 10352, 2965,~
## $ 학생수             <dbl> 582572, 2672340, 1350770, 1299965, 961275, 63181, 1986~
## $ 전체교원수         <dbl> 53457, 191224, 113238, 131120, 91448, 8001, 24816, 6855,~
## $ 기간제교원수       <dbl> 4652, 9566, 20089, 24929, 16879, 1181, 5734, 1135, 2410, ~
## $ 시간강사수         <dbl> 2, 1120, 2325, 1868, 1106, 291, 229, 242, 9, 0, 21, 211,~
## $ 비정규교원당학생수 <dbl> 125.17662, 250.07861, 60.26457, 48.51159, 53.44871, 42.92188~

위의 결과를 보면 행의 수는 239개, 열은 ‘연도’, ‘시도’ 등 총 9개로 구성되어 있다. 이 중 교원 1인당 학생수를 구하기 위해서는 각 지역의 학생수를 교원수로 나누어주어야 한다. 이미 비정규 교원당 학생수는 산출되었으므로 이번에는 전체 교원당 학생수와 정규 교원당 학생수를 산출하겠다.

df_provinfo_21 |>
  mutate(`전체교원당학생수` = 학생수 / 전체교원수, 
         `정규교원당학생수` = 학생수 / (전체교원수 - 기간제교원수 - 시간강사수))
## # A tibble: 239 x 11
##    연도  시도  학제         학급수  학생수 전체교원수 기간제교원수 시간강사수
##    <chr> <chr> <chr>         <dbl>   <dbl>      <dbl>        <dbl>      <dbl>
##  1 2021  전국  유치원        33381  582572      53457         4652          2
##  2 2021  전국  초등학교     124047 2672340     191224         9566       1120
##  3 2021  전국  중학교        53053 1350770     113238        20089       2325
##  4 2021  전국  고등학교      56245 1299965     131120        24929       1868
##  5 2021  전국  (일반고)      40063  961275      91448        16879       1106
##  6 2021  전국  (특목고)       2865   63181       8001         1181        291
##  7 2021  전국  (특성화고)    10352  198663      24816         5734        229
##  8 2021  전국  (자율고)       2965   76846       6855         1135        242
##  9 2021  전국  특수학교       5105   26967      10269         2410          9
## 10 2021  전국  고등공민학교      5      43          5            0          0
## # ... with 229 more rows, and 3 more variables: 비정규교원당학생수 <dbl>,
## #   전체교원당학생수 <dbl>, 정규교원당학생수 <dbl>

이 중 시도가 ’전체’인 데이터를 제외하여야 하기 떄문에 최종 데이터는 다음과 같이 산출한다.

df_stu_per_tech <- df_provinfo_21 |>
  mutate(`전체교원당학생수` = 학생수 / 전체교원수, 
         `정규교원당학생수` = 학생수 / (전체교원수 - 기간제교원수 - 시간강사수)) |>
  filter(시도 != '전국')

교원 1인당 학생와 지리 데이터의 조인

기초 데이터가 완성되었으니 이제 지리 데이터와 조인을 해야한다. 우선 지리 데이터에서 사용하는 시도 코드를 행정안전부의 행정표준코드를 사용하여 생성해준다.

df_stu_per_tech <- df_stu_per_tech |>
  mutate(CTPRVN_CD = case_when(
    시도 == '강원' ~ '42', 
    시도 == '경기' ~ '41', 
    시도 == '경남' ~ '48', 
    시도 == '경북' ~ '47', 
    시도 == '광주' ~ '29', 
    시도 == '대구' ~ '27', 
    시도 == '대전' ~ '30', 
    시도 == '부산' ~ '26', 
    시도 == '서울' ~ '11', 
    시도 == '세종' ~ '36', 
    시도 == '울산' ~ '31', 
    시도 == '인천' ~ '28', 
    시도 == '전남' ~ '46', 
    시도 == '전북' ~ '45', 
    시도 == '제주' ~ '50', 
    시도 == '충남' ~ '44', 
    시도 == '충북' ~ '43'
  ))

이제 shape 파일에서 생성한 sf 객체인 ’spdf_shp’와 ’df_stu_per_tech’를 ’CTPRVN_CD’을 사용하여 다음과 같이 조인한다.

df_joined <- left_join(spdf_shp, df_stu_per_tech, by = 'CTPRVN_CD')

지형 데이터의 시각화

이제 조인된 데이터를 사용하여 지형 데이터를 시각화한다.

library(ggspatial)

df_joined |> 
  ggplot() + 
  geom_sf(color = 'gray80') +
  labs(x = '위도', y = '경도') +
  annotation_scale(location = "br") +
  annotation_north_arrow(location = "br", pad_y = unit(0.05, 'npc'),
                         style = north_arrow_nautical) +
  theme_bw()

이 데이터를 사용하여 앞서 살펴본 단계 구분도를 그리면 다음과 같다. 전체 학교급 중 초등학교에 대해 정규교원 1인당 학생수에 대한 단계구분도는 다음과 같이 그릴 수 있다.

df_joined |> 
  filter(학제 == '초등학교') |>
  ggplot() + 
  geom_sf(aes(fill = 정규교원당학생수), color = 'gray80') +
  labs(x = '위도', y = '경도') +
  annotation_scale(location = "br") +
  annotation_north_arrow(location = "br", pad_y = unit(0.05, 'npc'),
                         style = north_arrow_nautical) +
  theme_bw()

지형 데이터의 거품 차트 시각화

이번에는 이 단계구분도를 거품 차트로 변경한다. 우선 거품 차트에 사용해야 할 원의 위치를 각 시도별로 설정해야 한다. 이를 위해서 st_centroid()를 사용하여 각 지역별 중심점을 계산한다. 이와 관련해서는 지도에 지역 이름 넣기 in R를 참조하라.

centroid_shp <- as.data.frame(st_centroid(spdf_shp))

centroid_shp |> head()
##   CTPRVN_CD       CTP_ENG_NM CTP_KOR_NM                 geometry
## 1        42       Gangwon-do     강원도  POINT (1070808 1969047)
## 2        41      Gyeonggi-do     경기도 POINT (971834.4 1948256)
## 3        48 Gyeongsangnam-do   경상남도  POINT (1069218 1703449)
## 4        47 Gyeongsangbuk-do   경상북도  POINT (1112027 1817405)
## 5        29          Gwangju 광주광역시   POINT (939472 1684701)
## 6        27            Daegu 대구광역시  POINT (1096215 1759774)

이 데이터를 거품의 중심점으로 사용해야하기 떄문에 이 중심점 데이터도 ’CTPRVN_CD’를 사용하여 ’df_stu_per_tech’와 조인한다.

df_joined_centroid <- left_join(df_stu_per_tech, centroid_shp, by = 'CTPRVN_CD')

glimpse(df_joined_centroid)
## Rows: 224
## Columns: 15
## $ 연도               <chr> "2021", "2021", "2021", "2021", "2021", "2021", "2021~
## $ 시도               <chr> "서울", "서울", "서울", "서울", "서울", "서울", "서울~
## $ 학제               <chr> "유치원", "초등학교", "중학교", "고등학교", "(일반고)~
## $ 학급수             <dbl> 3704, 18396, 8563, 9194, 6303, 484, 1758, 649, 858, 3,~
## $ 학생수             <dbl> 69958, 399435, 209749, 216319, 149174, 12169, 35349, 1~
## $ 전체교원수         <dbl> 6391, 28219, 17234, 21039, 14099, 1246, 4264, 1430, 1685~
## $ 기간제교원수       <dbl> 186, 716, 3166, 4839, 3044, 229, 1184, 382, 365, 0, 125, ~
## $ 시간강사수         <dbl> 0, 307, 1057, 613, 418, 71, 45, 79, 3, 0, 78, 0, 0, 2058~
## $ 비정규교원당학생수 <dbl> 376.118280, 390.454545, 49.668245, 39.676999, 43.088966, 40.~
## $ 전체교원당학생수   <dbl> 10.9463308, 14.1548248, 12.1706510, 10.2818100, 10.5804667,~
## $ 정규교원당학생수   <dbl> 11.274456, 14.687270, 16.120898, 13.878168, 14.024067, 12.8~
## $ CTPRVN_CD          <chr> "11", "11", "11", "11", "11", "11", "11", "11", "11~
## $ CTP_ENG_NM         <chr> "Seoul", "Seoul", "Seoul", "Seoul", "Seoul", "Seoul~
## $ CTP_KOR_NM         <chr> "서울특별시", "서울특별시", "서울특별시", "서울특별~
## $ geometry           <POINT [m]> POINT (955111.6 1950406), POINT (955111.6 195~

조인된 데이터를 확인하면 중심점을 표현하는 열이 geometry로 POINT 타입의 데이터로 조인되어 있다. 이 데이터를 바로 geom_point()의 X, Y 매핑으로 사용할수 없으니 이를 X, Y 형태의 데이터프레임으로 바꾸어야 한다. 이를 위한 함수가 st_coordinates()이다. 이 결과로 생성된 데이터는 matrix 형태이므로 이를 데이터프레임으로 변경하고 열로 붙여주기 위해 cbind()를 사용하였다.

df_joined_centroid <- cbind(df_joined_centroid, data.frame(st_coordinates(df_joined_centroid$geometry)))

이렇게 생성된 데이터는 다음과 같다.

df_joined_centroid |>
  filter(학제 == '초등학교') |>
  select(시도, 정규교원당학생수, X, Y)
시도 정규교원당학생수 X Y
2 서울 14.68727 955111.6 1950406
16 부산 15.62650 1142039.9 1690714
30 대구 14.05329 1096215.2 1759774
43 인천 16.30998 900984.4 1954430
57 광주 14.36748 939472.0 1684701
71 대전 13.26362 990489.4 1815820
84 울산 16.41378 1157493.1 1730077
97 세종 14.33784 978414.3 1840349
107 경기 17.57210 971834.4 1948256
121 강원 11.14116 1070808.5 1969047
134 충북 12.72663 1029502.4 1860032
147 충남 13.28535 941837.4 1836960
161 전북 11.41730 967659.5 1746665
174 전남 11.38512 945193.5 1653619
187 경북 13.03628 1112027.0 1817405
200 경남 14.43981 1069217.9 1703449
215 제주 15.09423 911983.8 1488782

이제 조인된 두개의 데이터를 사용하여 정규교원 1인당 학생수에 대한 지형 거품 차트를 그려보겠다. 우선 ’df_joined’를 사용하여 지도를 그려주고 중심점이 계산된 ’df_joined_centroid’의 X, Y 열과 정규교원당학생수 열을 geom_point() 에 사용하여 거품 차트를 그려준다.

df_joined |> 
  filter(학제 == '초등학교') |>
  ggplot() + 
  geom_sf(color = 'gray80') +
  geom_point(data = df_joined_centroid |> filter(학제 == '초등학교'), aes(x = X, y = Y, size = `정규교원당학생수`), color = 'steelblue', alpha = 0.5) +
#  scale_size(range = c(.1, 10)) +
  labs(x = '위도', y = '경도') +
  annotation_scale(location = "br") +
  annotation_north_arrow(location = "br", pad_y = unit(0.05, 'npc'),
                         style = north_arrow_nautical) +
  theme_bw()

만약 고등학교의 세부 분류인 일반고, 특목고, 특성화고, 자율고를 비교하고자 한다면 다음과 같이 facet_wrap()을 사용할 수 있다.

df_joined |> 
  filter(학제 %in% c('(일반고)', '(특목고)', '(특성화고)', '(자율고)')) |>
  ggplot() + 
  geom_sf(color = 'gray80') +
  geom_point(data = df_joined_centroid |> filter(학제 %in% c('(일반고)', '(특목고)', '(특성화고)', '(자율고)')), aes(x = X, y = Y, size = `정규교원당학생수`), color = 'steelblue', alpha = 0.5) +
#  scale_size(range = c(.1, 10)) +
  labs(x = '위도', y = '경도') +
  facet_wrap(~학제) +
  annotation_scale(location = "br") +
  annotation_north_arrow(location = "br", pad_y = unit(0.05, 'npc'),
                         style = north_arrow_nautical) +
  theme_bw() 

댓글