지도 데이터 시각화 : 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 시각화를 할 수 있게 됨
- 데이터 시각화에 Layer적 접근(계층적 접근)
- 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의 확장판
- ArcLayer
- Aggregation Layers : 입력 데이터를 집계 및 육각형, 컨투어 히트맵 등으로 시각화하는 레이어
- ContourLayer
- 주어진 임계값과 셀 크기에 대해 Isoline, Isband를 시각화
- Isoband : 주어진 임계값 범위 값을 포함하는 다각형
- Isoline : 등치선
- GridLayer
- point의 배열에 기반해 렌더링
- 일정한 셀의 크기를 취하고 입력 포인트를 셀로 집계함
- GPUGridLayer
- GridLayer가 GPU에서 렌더링(WebGL2가 지원되는 브라우저 한정)
- CPUGridLayer
- GridLayer가 CPU에서 렌더링
- HexagonLayer
- pint의 배열에 기반으로 육각형 히트맵 렌더링
- 육각형의 반지름을 사용해 그림
- ScreenGridLayer
- 위도 및 경도 좌표 포인트의 배열을 가져와 히스토그램 빈으로 집계해 그리드로 렌더링
- 셀 사이즈를 조정하면 다시 집계해 렌더링함
- HeatmapLayer
- 데이터의 Spatial 분포를 시각화할 때 사용
- 내부적으로 Gaussian Kernel Density Estimation을 구현해 히트맵 렌더링
- ContourLayer
- Geo Layers : 지도 타일, 지리 공간 색인 시스템, GIS 형식을 지원하는 지리 공간 레이어
- GreatCircleLayer
- ArcLayer의 변형
- Great Circle(대원)
- H3ClusterLayer
- H3으로 나타난 Cluster 렌더링
- H3HexagonLayer
- H3으로 렌더링
- S2Layer
- S2 토큰(지리 공간 인덱스)을 기반으로 지오메트리를 계산해 채워진 다각형 렌더링
- TileLayer
- getTileData로 타입을 가져와 GeJsonLayer에서 렌더링
- TripsLayer
- 차량 Trip을 나타내는 path를 렌더링
- currentTime이 바뀌며 이동하는 모습을 시각화할 수 있음
- GreatCircleLayer
- 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_units
와get_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
- Deck.gl Homepage
- Layer Document
- pydeck: Unlocking deck.gl for use in Python
- pydeck document
- pydeck example
- 전시흠님 블로그
카일스쿨 유튜브 채널을 만들었습니다. 데이터 사이언스, 성장, 리더십, BigQuery 등을 이야기할 예정이니, 관심 있으시면 구독 부탁드립니다 :)
PM을 위한 데이터 리터러시 강의를 만들었습니다. 문제 정의, 지표, 실험 설계, 문화 만들기, 로그 설계, 회고 등을 담은 강의입니다
이 글이 도움이 되셨거나 다양한 의견이 있다면 댓글 부탁드립니다 :)