지도 데이터 시각화 : 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


카일스쿨 유튜브 채널을 만들었습니다. 데이터 사이언스, 성장, 리더십, BigQuery 등을 이야기할 예정이니, 관심 있으시면 구독 부탁드립니다 :)

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

이 글이 도움이 되셨거나 다양한 의견이 있다면 댓글 부탁드립니다 :)

Buy me a coffeeBuy me a coffee





© 2017. by Seongyun Byeon

Powered by zzsza