지도 데이터 시각화 : Uber의 pydeck 사용하기


  • Uber의 대규모 WebGL 기반 데이터 시각화 도구인 Deck.gl를 파이썬에서 사용할 수 있도록 만든 pydeck 사용 방법에 대해 작성한 글입니다
    • 제가 연습하며 사용한 코드는 Nbviewer로 보실 수 있습니다 :)

Deck.gl

  • Homepage
  • Uber에서 만든 WebGL 기반 대용량 데이터 시각화 도구
    • WebGL : 웹 기반의 그래픽 라이브러리로 웹 브라우저에서 3D 그래픽을 사용할 수 있도록 해줌. 참고 : 위키백과
  • 주요 특징
    • 데이터 시각화에 Layer적 접근(계층적 접근)
      • Deck.gl을 사용하면 존재하는 레이어를 활용해 복잡한 시각화를 구성할 수 있으며, 재사용 가능한 레이어로 쉽개 패키징하고 공유할 수 있음
      • 이미 입증된 레이어 종류를 제공하고 있음
    • GPU의 고정밀 계산
      • Deck.gl은 GPU에서 64비트 부동 소수점 계산을 emulating해서 비교할 수 없는 정확성과 성능으로 데이터 세트를 렌더링함
    • React와 Mapbox GL 통합
      • Deck.gl은 React와 잘 맞고, 리액트 프로그래밍 패러다임에서 효율적인 WebGL 렌더링을 지원함
      • Maxbox GL과 함께 사용하면 mapbox 카메라 시스템에 매핑되서 맵박스 지도에서 2D, 3D 시각화를 할 수 있게 됨
  • Introduction
    • deck.gl은 대규모 데이터 시각화를 단순히 할 수 있도록 설계됨
    • 사용자는 기존 레이어 구성을 통해 적은 노력으로 인상적인 시각화 결과를 얻을 수 있고, 고급 WebGL 기반으로 만든 자바스크립트 레이어로 패키징할 수 있는 아키텍처를 제공함




pydeck

  • pydeck Github
  • pydeck을 deck.gl을 파이썬에서 사용할 수 있도록 만든 라이브러리
  • pydeck의 목표 : Python 사용자가 많은 Javascript를 몰라도 deck.gl 맵을 만들 수 있도록 하는 것
  • Jupyter Notebook에서 가장 잘 작동하고, 노트북에서 결과를 보거나 HTML로 추출할 수 있음
  • pydeck의 고유한 기능
    • Folium, Ipyleaflet 등의 지도 라이브러리와 대비한 기능
    • Python에서 deck.gl의 전체 레이어 사용 가능
    • 대규모 데이터에서 색상 변경, 데이터 수정 지원
    • 시각화에서 선택한 데이터를 Jupyter Notebook의 커널로 다시 전달할 수 있는 양방향 통신
    • Python API를 통해 수십만 개의 데이터를 2D / 3D로 매핑하는 기능
  • 단, 아직 공식 Release는 아니고 Beta임
    • 글 작성 기준 최신 버전은 pydeck-0.1.dev5




Install

  • 추후에 바뀔 수 있으니 pydeck Github 참고

  • 설치

      pip3 install pydeck
    
  • Extension 설정

      jupyter nbextension install --sys-prefix --symlink --overwrite --py pydeck
      jupyter nbextension enable --sys-prefix --py pydeck
    




Mapbox API 등록

  • deck.gl처럼 pydeck 라이브러리도 Mapbox의 베이스맵 타일이 필요함
    • Mapbox API는 특정 사용량까진 무료이지만, 그 이상을 사용하고 싶을 경우 비용을 내야함. API pricing 참고
  • Mapbox API access token으로 이동해 회원 가입
  • Access tokens의 Token을 복사
  • 노트북에서 매번 정의하지 않고 환경 변수로 추가. 터미널에서 vi ~/.zshrc(bash를 사용하면 vi ~/.bashrc)을 한 후 아래 내용 추가

      export MAPBOX_API_KEY="pk로 시작하는 여러분들의 Token 값"
      # ESC :wq 로 저장하고 빠져나오기
    
  • 터미널에서 수정 사항 적용

      source ~/.bashrc
      # 만약 zsh을 사용하면 source ~/.zshrc
    
  • 터미널에서 MAPBOX_API_KEY가 제대로 나오는지 확인

      echo $MAPBOX_API_KEY
    
  • 만약 노트북에서 Mapbox API key is not set 에러가 발생하면 터미널을 아예 끄고, Jupyter notebook 재실행


Example Code

  • 공식 홈페이지에서 제공하는 예시
    • 단 r.to_html()을 r.show()로 바꿈(노트북에서 바로 보이도록 하기 위해)
import pydeck

# 2014 locations of car accidents in the UK
UK_ACCIDENTS_DATA = ('https://raw.githubusercontent.com/uber-common/'
                     'deck.gl-data/master/examples/3d-heatmap/heatmap-data.csv')

# Define a layer to display on a map
layer = pydeck.Layer(
    'HexagonLayer',
    UK_ACCIDENTS_DATA,
    get_position='[lng, lat]',
    auto_highlight=True,
    elevation_scale=50,
    pickable=True,
    elevation_range=[0, 3000],
    extruded=True,                 
    coverage=1)

# Set the viewport location
view_state = pydeck.ViewState(
    longitude=-1.415,
    latitude=52.2323,
    zoom=6,
    min_zoom=5,
    max_zoom=15,
    pitch=40.5,
    bearing=-27.36)

# Render
r = pydeck.Deck(layers=[layer], initial_view_state=view_state)
# r.to_html('demo.html')
r.show() # html 저장하지 않고 바로 보고싶은 경우 사용




Layer 종류

  • deck.gl에서 지원하는 Layer를 잘 파악해둬야 좋음
  • 기본적으로 Layer나 CompositeLayer 클래스를 상속함
  • Core Layers : 일반적인 용도의 레이어
    • ArcLayer
      • 위도 / 경도 좌표로 지정된 소스와 대상을 연결
    • BitmapLayer
      • 지정된 경계에 비트맵 이미지를 렌더링
    • ColumnLayer
      • HexagonLayer에 의해 렌더링되는 기본 레이어
    • GeoJsonLayer
      • GeoJson 형식의 데이터를 가져와 다각형, 선, 점으로 렌더링
    • GridCellLayer
      • 집계 후 CPUGridLayer에 렌더링됨
      • ColumnLayer의 변형
    • IconLayer
      • 지정된 좌표에서 래스터 아이콘을 렌더링
    • LineLayer
      • 위도 / 경도 좌표로 지정된 소스와 타겟점을 flat line으로 렌더링
    • PathLayer
      • 좌표 점 리스트를 가져와 돌출 선으로 렌더링
    • PointCloudLayer
      • 3D 위치, 색 등을 가져와 특정 반지름을 가진 구를 렌더링
    • PolygonLayer
      • 채워졌거나 스트로크된 다각형을 렌더링
      • PolygnLayer는 CompsiteLayer
    • ScatterplotLayer
      • 위도 경도 쌍으로 이루어진 점을 가져와 특정 반지름의 원으로 렌더링
    • SolidPolygonLayer
      • 채워진 다각형을 렌더링
    • TextLayer
      • 텍스처 레이블을 맵에 렌더링함
      • IconLayer의 확장판
  • Aggregation Layers : 입력 데이터를 집계 및 육각형, 컨투어 히트맵 등으로 시각화하는 레이어
    • ContourLayer
      • 주어진 임계값과 셀 크기에 대해 Isoline, Isband를 시각화
      • Isoband : 주어진 임계값 범위 값을 포함하는 다각형
      • Isoline : 등치선
    • GridLayer
      • point의 배열에 기반해 렌더링
      • 일정한 셀의 크기를 취하고 입력 포인트를 셀로 집계함
    • GPUGridLayer
      • GridLayer가 GPU에서 렌더링(WebGL2가 지원되는 브라우저 한정)
    • CPUGridLayer
      • GridLayer가 CPU에서 렌더링
    • HexagonLayer
      • pint의 배열에 기반으로 육각형 히트맵 렌더링
      • 육각형의 반지름을 사용해 그림
    • ScreenGridLayer
      • 위도 및 경도 좌표 포인트의 배열을 가져와 히스토그램 빈으로 집계해 그리드로 렌더링
      • 셀 사이즈를 조정하면 다시 집계해 렌더링함
    • HeatmapLayer
  • Geo Layers : 지도 타일, 지리 공간 색인 시스템, GIS 형식을 지원하는 지리 공간 레이어
    • GreatCircleLayer
    • H3ClusterLayer
      • H3으로 나타난 Cluster 렌더링
    • H3HexagonLayer
      • H3으로 렌더링
    • S2Layer
    • TileLayer
      • getTileData로 타입을 가져와 GeJsonLayer에서 렌더링
    • TripsLayer
      • 차량 Trip을 나타내는 path를 렌더링
      • currentTime이 바뀌며 이동하는 모습을 시각화할 수 있음
  • Mesh Layers : glTF 형식의 그래프에 대한 실험 지원. 3D 모델 지원
    • SimpleMeshLayer
    • ScenegraphLayer




pydeck 사용 방법

  • pydeck은 geojson, Pandas Dataframe을 Input으로 사용 가능(URL도 사용 가능)
  • 큰 흐름
    • 1) 데이터 선택
    • 2) Layer 선택
    • 3) ViewState 정의
    • 4) 렌더링
  • 1) 데이터 선택

      import pandas as pd
    	
      UK_ACCIDENTS_DATA = 'https://raw.githubusercontent.com/uber-common/deck.gl-data/master/examples/3d-heatmap/heatmap-data.csv'
    	
      pd.read_csv(UK_ACCIDENTS_DATA).head()
    
  • 2) Layer 선택
    • pdk.Layer()에서 첫 인자에 레이어 이름을 String으로 작성
      • 참고 : pdk.Layer(type, data, id=None, get_position=’[lng, lat]’, **kwargs)
    • 그 후 데이터와 각종 인자를 넣어줌
    • 세부 인자는 Layer Document에서 찾아서 넣으면 됨
      import pydeck as pdk
    	
      layer = pdk.Layer(
          'HexagonLayer',
          UK_ACCIDENTS_DATA,
          get_position='[lng,lat]',
          elevation_scale=50,
          pickable=True,
          auto_highlight=True,
          elevation_range=[0, 3000],
          extruded=True,                 
          coverage=1)
    
  • 3) ViewState 정의
    • ViewState는 지도 데이터를 기준으로 카메라 각도를 지정함
    • 기본적으로 지도를 잡고 드래그, 회전할 수 있음
      view_state = pdk.ViewState(
          longitude=-1.415,
          latitude=52.2323,
          zoom=6,
          min_zoom=5,
          max_zoom=15,
          pitch=40.5,
          bearing=-27.36)
    
  • 4) 렌더링
    • pdk.Deck으로 레이어와 view state를 통합
    • layers 인자에 여러 레이어를 추가할 수 있음
    • r.show()로 시각화하고, 만약 저장하고 싶다면 r.to_html()사용
      r = pdk.Deck(layers=[layer], initial_view_state=view_state)
      r.show()
    
    • pdk.Deck Class 참고

        pdk.Deck(
            layers=[],
            views=[{"controller": true, "type": "MapView"}],
            map_style='mapbox://styles/mapbox/dark-v9',
            mapbox_key=None,
            initial_view_state={"bearing": 0, "latitude": 0.0, "longitude": 0.0, "maxZoom": 20, "minZoom": 0, "pitch": 0, "zoom": 1},
            width='100%',
            height=500,
            tooltip=True,
        )
      
  • 렌더링 후 업데이트
    • layer의 속성을 수정한 후, r.update()를 사용해 업데이트 가능
      layer.elevation_range = [0, 10000]
      r.update()
    
    • 시간이 지나며 업데이트하기

        import time
        r.show()
        for i in range(0, 10000, 1000):
            layer.elevation_range = [0, i]
            r.update()
            time.sleep(0.1)
      




Tooltip 추가하기

  • pdk.Deck()으로 객체를 생성할 때 tooltip을 설정하면 툴팁을 넣을 수 있음
  • 1) tooltip=True을 주면 가진 모든 내용을 툴팁으로 보여줌
  • 2) 특정 값만 HTML으로 스타일을 입힐 수도 있음

      import pydeck as pdk
    
      layer = pdk.Layer(
          'HexagonLayer',
          UK_ACCIDENTS_DATA,
          get_position='[lng, lat]',
          auto_highlight=True,
          elevation_scale=50,
          pickable=True,
          elevation_range=[0, 3000],
          extruded=True,
          coverage=1)
    	
      # Set the viewport location
      view_state = pdk.ViewState(
          longitude=-1.415,
          latitude=52.2323,
          zoom=6,
          min_zoom=5,
          max_zoom=15,
          pitch=40.5,
          bearing=-27.36)
    	
      # Combined all of it and render a viewport
      r = pdk.Deck(
          layers=[layer],
          initial_view_state=view_state,
          tooltip={
              'html': '<b>Elevation Value:</b> {elevationValue}',
              'style': {
                  'color': 'white'
              }
          }
      )
      r.show()
    
  • 3) 그냥 Text로 표현할 수도 있음

      import pydeck as pdk
    
      layer = pdk.Layer(
          'HexagonLayer',
          UK_ACCIDENTS_DATA,
          get_position='[lng, lat]',
          auto_highlight=True,
          elevation_scale=50,
          pickable=True,
          elevation_range=[0, 3000],
          extruded=True,
          coverage=1)
    	
      # Set the viewport location
      view_state = pdk.ViewState(
          longitude=-1.415,
          latitude=52.2323,
          zoom=6,
          min_zoom=5,
          max_zoom=15,
          pitch=40.5,
          bearing=-27.36)
    	
      # Combined all of it and render a viewport
      r = pdk.Deck(
          layers=[layer],
          initial_view_state=view_state,
          tooltip={
              "text": "Elevation: {elevationValue}"
          }
      )
      r.show()
    




ipywidgets을 사용해 Interactive 시각화

  • 사용 방식
    • 1) 우선 베이스가 되는 Deck을 만들어서 r.show()로 보여줌
    • 2) ipywidget 슬라이더 생성
    • 3) ipywidget에서 사용할 함수 정의 => 마지막에 r.update() 사용
    • 4) slider.observe로 Deck과 슬라이더를 연결
  • 예제 코드

      import pandas as pd
      import pydeck as pdk
    	
      LIGHTS_URL = 'https://raw.githubusercontent.com/ajduberstein/lights_at_night/master/chengdu_lights_at_night.csv'
      df = pd.read_csv(LIGHTS_URL)
      df['color'] = df['brightness'].apply(lambda val: [255, val * 4,  255, 255])
      plottable = df[df['year'] == 1993].to_dict(orient='records')
    	
      view_state = pdk.ViewState(
          latitude=31.0,
          longitude=104.5,
          zoom=8,
          max_zoom=8,
          min_zoom=8)
      scatterplot = pdk.Layer(
          'HeatmapLayer',
          data=plottable,
          get_position='[lng, lat]',
          get_weight='brightness',
          opacity=0.5,
          pickable=False,
          get_radius=800)
      r = pdk.Deck(
          layers=[scatterplot],
          initial_view_state=view_state,
          views=[pdk.View(type='MapView', controller=None)])
      r.show()
    	
      # Widget 슬라이더 생성
      import ipywidgets as widgets
      from IPython.display import display
      slider = widgets.IntSlider(1992, min=1993, max=2013, step=2)
    	
      # Widget에서 사용할 함수 정의 
      def on_change(v):
          results = df[df['year'] == slider.value].to_dict(orient='records')
          scatterplot.data = results
          r.update()
    	    
      # Deck과 슬라이더 연결
      slider.observe(on_change, names='value')
      display(slider)
    



뉴욕 택시 데이터 시각화

  • pickup ~ dropoff arc layer
agg_query = """
WITH base_data AS 
(
  SELECT 
    nyc_taxi.*, 
    pickup.zip_code as pickup_zip_code,
    pickup.internal_point_lat as pickup_zip_code_lat,
    pickup.internal_point_lon as pickup_zip_code_lon,
    dropoff.zip_code as dropoff_zip_code,
    dropoff.internal_point_lat as dropoff_zip_code_lat,
    dropoff.internal_point_lon as dropoff_zip_code_lon
  FROM (
    SELECT *
    FROM `bigquery-public-data.new_york_taxi_trips.tlc_yellow_trips_2015`
    WHERE 
        EXTRACT(MONTH from pickup_datetime) = 1
        and pickup_latitude <= 90 and pickup_latitude >= -90
        and dropoff_latitude <= 90 and dropoff_latitude >= -90
    ) AS nyc_taxi
  JOIN (
    SELECT zip_code, state_code, state_name, city, county, zip_code_geom, internal_point_lat, internal_point_lon 
    FROM `bigquery-public-data.geo_us_boundaries.zip_codes`
    WHERE state_code='NY'
    ) AS pickup 
  ON ST_CONTAINS(pickup.zip_code_geom, st_geogpoint(pickup_longitude, pickup_latitude))
  JOIN (
    SELECT zip_code, state_code, state_name, city, county, zip_code_geom, internal_point_lat, internal_point_lon 
    FROM `bigquery-public-data.geo_us_boundaries.zip_codes`
    WHERE state_code='NY' 
    ) AS dropoff
  ON ST_CONTAINS(dropoff.zip_code_geom, st_geogpoint(dropoff_longitude, dropoff_latitude))
  
)

SELECT 
  pickup_zip_code,
  pickup_zip_code_lat,
  pickup_zip_code_lon,
  dropoff_zip_code,
  dropoff_zip_code_lat,
  dropoff_zip_code_lon,
  COUNT(*) AS cnt
FROM base_data 
GROUP BY 1,2,3,4,5,6
limit 10000
"""

agg_df = pd.read_gbq(query=agg_query, dialect='standard', project_id='{여러분들의 프로젝트 id}')

# 100개만
agg_df = agg_df.sort_values('cnt', ascending=False)
agg_df = agg_df[:100]


arc_layer = pdk.Layer(
    'ArcLayer',
    agg_df,
    get_source_position='[pickup_zip_code_lon, pickup_zip_code_lat]',
    get_target_position='[dropoff_zip_code_lon, dropoff_zip_code_lat]',
    get_source_color='[255, 255, 120]', 
    get_target_color='[255, 0, 0]',
    width_units='meters',
    get_width="cnt/50",
    pickable=True, 
    auto_highlight=True,
)

nyc_center = [-73.9808, 40.7648] 
view_state = pdk.ViewState(longitude=nyc_center[0], latitude=nyc_center[1], zoom=9)

r = pdk.Deck(layers=[arc_layer], initial_view_state=view_state,
             tooltip={
                 'html': '<b>count:</b> {cnt}',
                 'style': {
                     'color': 'white'
                 }
             }
            )
r.show()

  • 여기서 width_unitsget_width 쪽이 문서나 참고 자료가 거의 없어서 이것 저것 시도함
  • ipywidgets을 사용한 요일별 Arc Layer
agg_query2 = """
WITH base_data AS 
(
  SELECT 
    nyc_taxi.*, 
    pickup.zip_code as pickup_zip_code,
    pickup.internal_point_lat as pickup_zip_code_lat,
    pickup.internal_point_lon as pickup_zip_code_lon,
    dropoff.zip_code as dropoff_zip_code,
    dropoff.internal_point_lat as dropoff_zip_code_lat,
    dropoff.internal_point_lon as dropoff_zip_code_lon
  FROM (
    SELECT *
    FROM `bigquery-public-data.new_york_taxi_trips.tlc_yellow_trips_2015`
    WHERE 
        EXTRACT(MONTH from pickup_datetime) = 1
        and pickup_latitude <= 90 and pickup_latitude >= -90
        and dropoff_latitude <= 90 and dropoff_latitude >= -90
    LIMIT 100000
    ) AS nyc_taxi
  JOIN (
    SELECT zip_code, state_code, state_name, city, county, zip_code_geom, internal_point_lat, internal_point_lon 
    FROM `bigquery-public-data.geo_us_boundaries.zip_codes`
    WHERE state_code='NY'
    ) AS pickup 
  ON ST_CONTAINS(pickup.zip_code_geom, st_geogpoint(pickup_longitude, pickup_latitude))
  JOIN (
    SELECT zip_code, state_code, state_name, city, county, zip_code_geom, internal_point_lat, internal_point_lon 
    FROM `bigquery-public-data.geo_us_boundaries.zip_codes`
    WHERE state_code='NY' 
    ) AS dropoff
  ON ST_CONTAINS(dropoff.zip_code_geom, st_geogpoint(dropoff_longitude, dropoff_latitude))
  
)

SELECT 
  CAST(format_datetime('%u', pickup_datetime) AS INT64) -1 AS weekday,
  pickup_zip_code,
  pickup_zip_code_lat,
  pickup_zip_code_lon,
  dropoff_zip_code,
  dropoff_zip_code_lat,
  dropoff_zip_code_lon,
  COUNT(*) AS cnt
FROM base_data 
GROUP BY 1,2,3,4,5,6,7
"""

agg_df2 = pd.read_gbq(query=agg_query2, dialect='standard', project_id='geultto')


default_data = agg_df2[agg_df2['weekday'] == 0].to_dict(orient='records')

arc_layer = pdk.Layer(
    'ArcLayer',
    default_data,
    get_source_position='[pickup_zip_code_lon, pickup_zip_code_lat]',
    get_target_position='[dropoff_zip_code_lon, dropoff_zip_code_lat]',
    get_source_color='[255, 255, 120]', 
    get_target_color='[255, 0, 0]',
    width_units='meters',
    get_width="cnt/50",
    pickable=True, 
    auto_highlight=True,
)

nyc_center = [-73.9808, 40.7648] 
view_state = pdk.ViewState(longitude=nyc_center[0], latitude=nyc_center[1], zoom=9)

r = pdk.Deck(layers=[arc_layer], initial_view_state=view_state,
             tooltip={
                 'html': '<b>count:</b> {cnt}',
                 'style': {
                     'color': 'white'
                 }
             }
            )
r.show()


# Widget 슬라이더 생성
import ipywidgets as widgets
from IPython.display import display
slider = widgets.IntSlider(0, min=0, max=6, step=1)

# Widget에서 사용할 함수 정의 
def on_change(v):
    results = agg_df2[agg_df2['weekday'] == slider.value].to_dict(orient='records')
    arc_layer.data = results
    r.update()

# Deck과 슬라이더 연결
slider.observe(on_change, names='value')
display(slider)


Kepler.gl vs Deck.gl

  • Kepler.gl은 블로그에 써둔 것처럼 매우 사용하기 쉬움
    • 단, 대용량 데이터 시각화 하면 크롬이 refresh
    • 결국 deck.gl 기반으로 만들어진 도구
    • 사용은 쉽지만 쿼리를 날려서 csv 저장하고 웹에 올려야되는 불편함
  • Deck.gl은 약간의 코딩이 필요(그래도 Python으로 가능하면 양호한 편이라 생각)
    • 대용량 데이터도 나름 잘 되는 편
    • 노트북에서 한번에 다 뽑아낼 수 있는 장점
    • 하지만 적응하기 까지 약간의 시간이 필요하고, 개발 진행중이라 계속 바뀔 가능성이 존재
  • 결국 목적에 맞도록 적절하게 사용하면 좋을 것 같아요 :)

Reference


카일스쿨 유튜브 채널을 만들었습니다. 데이터 분석, 커리어에 대한 내용을 공유드릴 예정입니다.

PM을 위한 데이터 리터러시 강의를 만들었습니다. 문제 정의, 지표, 실험 설계, 문화 만들기, 로그 설계, 회고 등을 담은 강의입니다

이 글이 도움이 되셨거나 의견이 있으시면 댓글 남겨주셔요.

Buy me a coffeeBuy me a coffee





© 2017. by Seongyun Byeon

Powered by zzsza