EDA란 무엇인가
EXPLORATORY DATA ANALYSIS
여기까지 왔으면 DuckDB의 기초는 어지간히 손에 잡혔을 것이다. CSV와 Parquet 같은 파일 형식에 담긴 데이터로 DuckDB 데이터베이스를 채우는 법을 보았고, MySQL 같은 데이터베이스 서버에서 채우는 법도 익혔다. 제5장은 DuckDB를 실제 상황에 적용해 탐색적 데이터 분석을 수행한다.
EDA는 데이터셋의 주요 특성을 요약하기 위해 그것을 분석하고 시각화하는 접근이다. 핵심 목표는 데이터 안의 패턴과 추세와 관계를 이해하는 것이다. EDA에서는 데이터에 다음 기법들을 자주 쓴다.
데이터 요약
data summarization평균, 중앙값, 표준편차 같은 기술 통계로 데이터셋의 분포를 이해한다.
데이터 시각화
data visualizationMatplotlib과 Seaborn 같은 라이브러리로 막대 차트, 원 차트 등 여러 유형의 도표를 그려, 데이터의 분포와 서로 다른 데이터 유형 사이의 관계를 눈으로 살핀다.
추세 식별
trends identification데이터 안의 패턴과 추세와 이상을 식별하고, 이 관찰에 영향을 주는 잠재적 요인에 관한 통찰을 제공한다.
이 장에서 다루는 것은 2015 Flight Delays 데이터셋의 탐색과 시각화이며, 두 갈래로 나뉜다.
| 갈래 | 익히는 것 |
|---|---|
| 지리공간 분석 | 지도 표시 · 지도에 모든 공항 표시 · DuckDB의 spatial 확장 사용 · 위도와 경도를 Point 데이터 유형으로 변환 · 근처 공항 찾기 |
| 기술 분석 | 주와 도시별 공항 찾기 · 주별 공항 총수 집계 · 출발·도착 공항 쌍별 운항 편수 · 항공사의 결항편 · 요일별 운항 편수 · 지연이 가장 잦은 시간대 · 지연이 가장 많은 항공사와 가장 적은 항공사 |
제5장은 앞의 네 장과 성격이 다르다. 결과가 대부분 지도와 도표 이미지로 실려 있어, 텍스트로 확인되는 수치가 적다. 그래서 이 페이지는 다음 원칙을 지켰다.
- 원서 본문에 명기된 수치만 인용한다. 이 장에서 그런 값은 열두 개뿐이며, 각각 등장하는 자리에 굵은 글자로 표시했다.
- 도판 이미지에서만 읽히는 표와 차트의 값은 옮기지 않는다. 대신 그 도판이 무엇을 보이는지, 본문이 그것을 어떻게 해석했는지를 적는다.
- 지도 이미지는 재현하지 않는다. 대신 질의가 무엇을 세고 무엇을 정렬하는지를 도해로 옮긴다.
코드의 지면 잘림 표기는 앞 장들과 같다. 복원한 부분은 점선 밑줄, 복원하지 않은 부분은 …다.
세 개의 표를 올린다
OUR DATASET: THE 2015 FLIGHT DELAYS DATASET
일관성을 위해 이 장의 모든 예제에는 제2장에서 본 2015 Flight Delays and Cancellations 데이터셋을 쓴다. 세 개의 CSV 파일로 이루어진다는 것을 기억한다. airlines.csv는 미국 항공사 목록, airports.csv는 미국 공항 목록, flights.csv는 2015년 여러 항공사의 항공편 상세 내역이다.
import duckdb conn = duckdb.connect() conn.execute(''' CREATE TABLE flights as SELECT * FROM read_csv_auto('flights.csv') ''') conn.execute(''' CREATE TABLE airports as SELECT * FROM read_csv_auto('airports.csv') ''') conn.execute(''' CREATE TABLE airlines as SELECT * FROM read_csv('airlines.csv') ''') display(conn.execute('SHOW TABLES').df())
| name |
|---|
| airlines |
| airports |
| flights |
세 문장이 나란히 놓이니 한 가지가 눈에 띈다. flights와 airports는 read_csv_auto()로 읽고, airlines만 read_csv()로 읽는다. 제2장에서 airlines.csv의 첫 행이 헤더로 인식되지 않던 그 문제가 남긴 흔적이다. 함수 하나의 차이가 앞 장의 기록을 증언한다.
지도를 펴다 · folium
DISPLAYING A MAP
지리공간 분석은 공간 분석 또는 지리정보체계(GIS) 분석이라고도 하며, 공간 데이터를 살피고 해석하고 시각화해 지리적 영역 안의 패턴과 관계와 추세를 이해하는 연구 분야다. 흔한 사용 사례는 도시 계획과 교통이다. 예컨대 어느 도시의 대중교통 부서가 효율을 높이고 이동 시간을 줄이기 위해 버스 노선을 최적화하려 한다면, 지리공간 분석으로 도시의 버스 정류장과 교통 패턴과 인구 밀도를 분석한다.
airports 테이블에 공항의 위치가 담겨 있으므로 지리공간 분석을 해 볼 좋은 때다. 각 공항의 위치를 지도에 그린다. 이를 위해 folium 라이브러리를 쓴다. 대화형 지도를 그리는 자바스크립트 라이브러리 leaflet.js를 감싼 파이썬 래퍼다. folium을 쓰면 Jupyter Notebook 안에서 곧바로 지리공간 시각화를 파이썬 프로젝트에 넣을 수 있다.
!pip install folium먼저 folium으로 지도를 표시한다. 미국을 지도 중앙에 놓기 위해 위도 47.116386과 경도 -101.299591을 골라 folium의 Map 클래스에 넘긴다.
import folium mymap = folium.Map(location = [47.116386, -101.299591], width = 950, height = 550, zoom_start = 3, tiles = 'openstreetmap') mymap
이 코드에서 눈여겨볼 것은 넷이다.
- location 위도와 경도를 목록으로 넘겨 위치를 지정한다.
- width · height 지도의 크기를 지정한다.
- zoom_start 지도의 초기 확대 수준을 정한다. 숫자가 클수록 더 확대된다.
- tiles 쓸 타일셋을 지정한다. 타일셋은 연속된 지도 화면을 만드는 데 쓰이는 지도 타일의 모음이다. 기본값은 openstreetmap이다.
tiles 매개변수에는 openstreetmap 외에 내장 타일셋 cartodbpositron과 cartodbdark_matter를 쓸 수 있다. 그런데 tiles 매개변수로 특정 타일셋에 지도를 못박는 대신, TileLayer 클래스로 여러 타일셋을 지도에 얹을 수도 있다.
import folium mymap = folium.Map(location = [47.116386, -101.299591], width = 950, height = 550, zoom_start = 3, tiles = 'openstreetmap') folium.TileLayer('cartodbpositron', attr = 'cartodbpositron', show = False).add_to(mymap) folium.TileLayer('cartodbdark_matter', attr = 'cartodbdark_matter', show = False).add_to(mymap) folium.LayerControl().add_to(mymap) mymap
같은 땅을 세 가지 종이에 인쇄해 두고 필요할 때 바꿔 보는 것과 같다. 밑그림을 하나로 고정하지 않는다는 이 선택이, 뒤에 마커가 수백 개 얹힐 때 유용해진다. 밝은 배경에서 잘 보이는 색과 어두운 배경에서 잘 보이는 색이 다르기 때문이다.
공항마다 표를 꽂다
DISPLAYING ALL AIRPORTS ON THE MAP
먼저 모든 공항을 뽑아 df 변수에 담는다.
df = conn.execute(''' SELECT latitude as lat, longitude as lng, airport as airport FROM airports WHERE (lat is not null) or (lng is not null) ''').df()
이 질의의 WHERE 절은 or로 이어져 있다. 위도와 경도 둘 중 하나만 값이 있어도 통과한다는 뜻이므로, 한쪽이 NULL인 행이 남을 수 있다. 지도에 점을 찍는 일에는 두 값이 모두 필요하므로 and가 뜻에 더 맞는다. 원서를 그대로 옮기되 이 점은 적어 둔다. 뒤의 코드가 math 모듈을 가져오는 것도 이와 무관하지 않아 보인다.
그다음 DataFrame의 모든 행을 순회해 위도와 경도, 공항 이름을 뽑아 CircleMarker 클래스로 지도에 마커를 얹는다.
import math for lat, lng, airport in zip(df['lat'], df['lng'], df['airport']): airport = folium.CircleMarker( location = [lat, lng], # 마커의 위치 radius = 4, # 마커의 크기 color = 'red', # 테두리 색 fill = True, # 내부를 채운다 fill_color = 'yellow', # 채움 색 fill_opacity = 0.5, # 반투명하게 popup = airport) # 말풍선에 넣을 공항 이름 # 원 마커를 지도에 얹는다 airport.add_to(mymap) mymap
원 마커 대신 아이콘이 든 기본 Leaflet 마커를 쓸 수도 있다.
for lat, lng, airport in zip(df['lat'], df['lng'], df['airport']): airport = folium.Marker( location = [lat, lng], popup = airport, icon = folium.Icon(color = 'lightgray', icon = 'plane-arrival', prefix = 'fa'), ) airport.add_to(mymap) mymap
마커 안에 표시할 수 있는 아이콘의 목록은 Font Awesome에서 볼 수 있다. prefix = 'fa'가 그 글꼴 집합을 가리킨다.
좌표를 점으로 바꾸다
USING THE SPATIAL EXTENSION · POINT · GEODATAFRAME
공항의 위치를 지도에 그리는 일은 쉽고 즐거웠다. 그런데 특정 지점에 가장 가까운 공항을 찾는 일은 어떨까. DuckDB에는 이 일을 산책처럼 만들어 주는 spatial 확장이 있다. 이 확장으로 다음 같은 공간 처리 과제를 쉽게 수행한다.
- 거리 점 사이의 거리를 구한다.
- 범위 두 위치가 지정한 거리 안에 있는지 판정한다.
먼저 airports.csv를 pandas DataFrame으로 읽는다. 그다음 spatial 확장이 다룰 수 있도록 위도와 경도에 얼마간의 변환을 해야 한다.
Point 유형과 WKT
airports 테이블에는 위치에 관한 두 열, latitude와 longitude가 있다. spatial 확장을 쓰려면 DataFrame에 새 열을 더해야 한다. 이 열이 Point 데이터 유형으로 표현된 위치 값을 담는다.
Point 데이터 유형은 데카르트 좌표계, 지리 좌표(경도·위도), 극좌표 등 여러 좌표계로 표현되는 공간의 한 점을 나타낸다.
위도와 경도를 Point 유형으로 바꾸는 길은 두 가지다. Shapely 라이브러리를 쓰는 길과 DuckDB의 spatial 확장을 쓰는 길이다. 먼저 Shapely 쪽을 본다. 기하 객체의 조작과 분석을 위한 파이썬 꾸러미다.
!pip install shapely import pandas as pd df = pd.read_csv('airports.csv') from shapely.geometry import Point df['geometry'] = df.apply( lambda row: Point(row['LONGITUDE'], row['LATITUDE'])… df
WKT는 기하 객체를 사람이 읽을 수 있는 형태로 표현하는 텍스트 기반 형식이다. 이 예의 위치는 EPSG:4326 형식으로 저장된다.
EPSG:4326은 지리공간 응용과 GIS에서 흔히 쓰이는 특정 좌표 참조 체계(CRS)다. EPSG(European Petroleum Survey Group, 현재는 국제 석유·가스 생산자 협회의 지구정보위원회)가 좌표 참조 체계와 관련 매개변수의 데이터베이스를 관리한다. EPSG:4326 형식의 예는 POINT (-75.4404 40.65236)이다. 이 체계는 WGS 84(World Geodetic System 1984)라고도 불린다.
이 예시 좌표가 낯익다면 제2장을 떠올려도 좋다. airports_location.csv의 첫 행에 있던 ABE, 곧 Lehigh Valley 국제공항의 좌표와 같은 값이다. 경도가 앞, 위도가 뒤에 오는 순서를 눈여겨보아야 한다. 일상의 어법과 뒤바뀌어 있다.
DataFrame을 고쳤으므로 이를 DuckDB 데이터베이스에 airports_2라는 이름으로 올린다. 두 번째 길은 spatial 확장으로 DuckDB 안에서 곧바로 변환하는 것이다.
# ① Shapely로 만든 DataFrame을 올리는 경우 conn.execute("CREATE TABLE airports_2 AS SELECT * FROM df") # ② DuckDB의 spatial 확장으로 변환하는 경우 conn.execute('INSTALL spatial;') conn.execute('LOAD spatial;') conn.execute(''' DROP TABLE IF EXISTS airports_2 ; CREATE TABLE airports_2 as SELECT *, ST_AsText(ST_Point(LONGITUDE,LATITUDE)) as geometry FROM airports ''')
본문은 이 함수를 ST_AsPoint()라 부르지만, 실제 코드는 ST_Point()를 ST_AsText()로 감싼 형태다. 본문의 표기와 코드가 어긋나므로, 실제로 동작하는 쪽인 코드를 기준으로 읽는 것이 맞다.
pandas DataFrame을 GeoDataFrame으로
geometry 열에 공간 분석을 수행하려면 pandas DataFrame을 GeoPandas의 GeoDataFrame으로 바꿔야 한다.
GeoDataFrame은 지리공간 라이브러리 GeoPandas의 표 형식 데이터 구조로, 일반 pandas DataFrame의 능력을 확장해 공간 데이터를 다룬다. GeoPandas는 pandas와 Shapely 위에 세워져, pandas의 표 데이터 조작 능력과 Shapely가 제공하는 기하 연산을 결합한다.
!pip install leafmap !pip install mapclassify !pip install geopandas import leafmap df_airports_gdf = leafmap.df_to_gdf( conn.execute('SELECT * FROM airports_2').df(), geometry = 'geometry', src_crs="EPSG:4326", dst_crs="EPSG:4326")
이 문장에서는 원본 위치 형식을 EPSG:4326으로, 변환할 목적 형식도 EPSG:4326으로 지정했다. 변환할 열(geometry)은 geometry 매개변수로 알린다. 결국 이 연산은 geometry 열의 데이터 유형을 object에서 geometry로 바꾼다.
원본과 목적 좌표계가 같은데도 변환을 거치는 까닭이 여기 있다. 좌표계를 옮기는 일이 아니라 유형을 옮기는 일이기 때문이다. 글자로 적힌 좌표를 계산 가능한 기하 객체로 승격시키는 절차다.
변환을 마쳤으면 GeoDataFrame의 explore() 메서드를 불러 folium과 leaflet.js에 기반한 대화형 지도를 만든다.
df_airports_gdf.explore()한 줄이다. GeoDataFrame을 쓰면 지도를 만드는 법을 알 필요조차 없이 모든 일이 자동으로 처리된다. 앞 절에서 Map을 만들고 CircleMarker를 순회하며 얹던 그 일이 메서드 하나로 접힌다. 도구를 갈아 쥐면 손이 하던 일이 사라진다는 것, 답사에서 흔히 겪는 일이다.
가까운 공항을 찾다 · 반경과 거리
FINDING NEARBY AIRPORTS
마이애미의 한 지점, 위도 25.7824017, 경도 -80.2706578을 놓고 이 지점에 가장 가까운 공항들을 찾는다. DuckDB의 spatial 확장에는 이 일에 쓸 함수가 여럿 있으며, 원서는 둘을 쓴다.
| 함수 | 하는 일 | 물음의 성격 |
|---|---|---|
| ST_DWithin() | 두 기하가 지정한 거리 안에 있는지 판정한다. | 범위를 정한다. 걸리는 개수는 결과로 알게 된다. |
| ST_Distance() | 두 기하 사이의 거리를 계산한다. | 순위를 정한다. 개수를 직접 정할 수 있다. |
먼저 ST_DWithin()으로 마이애미 지점에서 3도 안에 있는 공항을 찾는다.
conn.execute('INSTALL spatial;') conn.execute('LOAD spatial;') # 마이애미 LOCATION_LNGLAT = (-80.2706578, 25.7824017) # 3도 안 df_airports_near_miami = conn.sql(f""" SELECT * FROM airports_2 WHERE ST_DWithin( ST_GeomFromText(geometry), ST_GeomFromText('POINT ({LOCATION_LNGLAT… 3); """).df() df_airports_near_miami
이 경우 마이애미의 지점에 가장 가까운 공항이 여덟 곳이다. 더 좁은 범위를 원하면 3도를 2도로 바꾼다. 그러면 다섯 곳만 얻는다.
여기서 반경 방식의 한계가 드러난다. 몇 곳이 걸릴지 미리 알 수 없다. 세 곳을 얻고 싶다면 반경을 손으로 더듬어야 한다. 그럴 때는 ST_Distance()가 낫다.
df_airports_near_miami = conn.sql(f""" SELECT *, ST_Distance(ST_GeomFromText(geometry), ST_GeomFromText('POINT ( {LOCATION_LNGLAT[0]} {LOCATION_LNGLAT[1]}))') as distance FROM airports_2; """).df()
가장 가까운 세 곳을 원하면 distance 열을 오름차순으로 정렬해 위에서 세 개를 취한다.
df_airports_near_miami = conn.sql(f""" SELECT *, ST_Distance(ST_GeomFromText(geometry), ST_GeomFromText('POINT ( {LOCATION_LNGLAT[0]} {LOCATION_LNGLAT[1]}))') as distance FROM airports_2 ORDER BY distance LIMIT 3 """).df()
결과를 지도에 그리려면 GeoDataFrame으로 바꾸고 explore()를 부른다. 그리고 그 함수가 돌려준 folium 지도의 참조를 써서, 마이애미의 그 지점에도 마커를 하나 얹는다.
import leafmap df_airports_near_miami_gdf = leafmap.df_to_gdf(df_airports_near_miami, …) folium_map = df_airports_near_miami_gdf.explore() folium_map import folium # 마이애미 지점에 말풍선을 얹는다 folium.Marker(location = [LOCATION_LNGLAT[1],LOCATION_LNGLAT[0]], popup='Miami').add_to(folium_map) folium_map
같은 좌표가 두 곳에서 서로 다른 순서로 쓰인다. WKT의 POINT에는 (경도 위도) 순으로, folium의 location에는 [위도, 경도] 순으로 넣는다. 위 코드에서 LOCATION_LNGLAT[1]이 앞에 오는 것이 그 때문이다. 지리공간 코드에서 가장 흔한 오류가 이 순서를 혼동하는 데서 나온다.
여기까지 익힌 것을 원서는 일곱 항목으로 정리한다. folium으로 지도 표시, 여러 타일셋 표시, 마커로 모든 공항 표시, DuckDB의 spatial 확장 사용, Shapely와 spatial 확장으로 위도·경도를 Point 유형으로 변환, GeoDataFrame으로 공항 위치 표시, 도(度)와 거리로 근처 공항 찾기.
주와 도시로 공항을 갈라 보다
DESCRIPTIVE ANALYTICS — AIRPORTS BY STATE AND CITY
기술 분석은 과거 사건에 관한 패턴과 추세와 통찰을 이해하기 위해 역사적 데이터를 해석하고 요약하는 일이다. Flight Delays 데이터셋으로 기술 분석을 수행하면 다음 물음들에 답을 얻는다. 주마다 공항이 몇 개인가. 한 공항에서 다른 공항으로 가는 항공편은 몇 편인가. 어느 요일에 그 노선의 항공편이 가장 적은가. 지연을 피하려면 하루 중 언제 떠나는 것이 가장 좋은가. 결항이 가장 많은 항공사는 어디인가. 지연이 가장 많은 항공사는 어디인가.
쉬운 것부터 시작한다. 모든 공항을 주와 도시로 묶는다.
df_city_state = conn.execute(''' SELECT * FROM airports ORDER BY STATE, CITY ''').df() # 다중 인덱스 DataFrame으로 만든다 df_city_state.set_index(['STATE','CITY'], inplace=True) df_city_state # 캘리포니아의 모든 공항 df_city_state.loc['CA'] # 캘리포니아 샌프란시스코의 공항 df_city_state.loc[('CA','San Francisco')]
SQL로 정렬하고 pandas로 색인한다. 이 절의 구조가 그렇다. ORDER BY가 순서를 만들고 set_index()가 그 순서를 주소로 바꾼다. 그러면 loc['CA']처럼 주 이름 하나로, 또는 loc[('CA','San Francisco')]처럼 튜플 하나로 원하는 자리를 곧바로 집는다. 지명을 색인으로 쓰는 이 방식은 답사기의 목차와 다르지 않다.
주별 공항 수와 도표의 윤리
이제 주마다 공항 수를 세고 그 수를 내림차순으로 정렬한다.
df_airports_state = conn.execute(''' SELECT STATE, count(*) as COUNT FROM airports GROUP BY STATE ORDER BY Count DESC ''').df() df_airports_state.head()
이를 원 차트로 그려 보면 흥미롭다. 그런데 주의 목록이 상당히 길어 원 차트가 어수선해진다. 원서는 이 지점에서 도표를 세 번 고친다. 도표 하나를 놓고 무엇이 문제인지 짚어 가며 다듬는 이 과정이, 이 절에서 실제로 배울 것이다.
- 첫째 판 · 도판 5-21 모든 주를 넣은 원 차트. 조각이 너무 많아 읽을 수 없다.
- 둘째 판 · 도판 5-22 LIMIT 10으로 공항이 가장 많은 열 개 주로 줄인다. 읽을 만해졌다.
- 셋째 판 · 도판 5-23 그래도 개선의 여지가 있다. 조각마다 백분율을 보이는 것은 뜻이 크지 않다. 알고 싶은 것은 주마다 공항이 몇 개인가다. 그래서 fmt() 함수를 정의해 autopct 매개변수에 넘긴다.
- 넷째 판 · 도판 5-24 원 차트가 보기에 좋아 보이더라도, 원 차트가 문제라는 주장이 있다. 원서는 “Why Pie Charts Are Evil”을 그 논거로 가리키며 막대 차트를 대안으로 제시한다.
import matplotlib.pyplot as plt import seaborn # 공항의 총수 total = df_airports_state['COUNT'].sum() def fmt(x): return '{:.1f}%\n({:.0f} airports)'.format(x, total*x/100) palette_color = seaborn.color_palette('pastel') plt.figure(figsize = (7, 7)) plt.pie(df_airports_state['COUNT'], labels = df_airports_state['STATE'], colors = palette_color, autopct = fmt) plt.legend(df_airports_state['STATE'], loc = "best")
원 차트를 그릴 때 Matplotlib이 조각마다 fmt() 함수를 부른다. x의 값은 각 조각의 백분율이다. 그러므로 백분율에 공항 총수를 곱하면 조각마다 실제 공항 수가 나온다. 백분율로 넘어온 것을 다시 개수로 환산해 되돌리는 셈이다.
# 막대 차트를 만든다 plt.bar(df_airports_state['STATE'], df_airports_state['COUNT'], color='skyblue') plt.xlabel('State') plt.ylabel('Number of Airports') plt.title('Top 10 States with Most Airports') plt.xticks(rotation = 45)
노선의 무게를 재다
FLIGHT COUNTS FOR EACH PAIR OF AIRPORTS
가장 간단한 것부터 시작한다. 출발 공항별 운항 편수다.
conn.execute(''' SELECT ORIGIN_AIRPORT, COUNT(ORIGIN_AIRPORT) as COUNT FROM flights GROUP BY ORIGIN_AIRPORT ORDER BY COUNT DESC ''').df()
| ORIGIN_AIRPORT | COUNT | 공항 |
|---|---|---|
| ATL | 346,846 | Hartsfield–Jackson Atlanta International |
| ORD | 285,884 | O'Hare International |
| ⋮ | ⋮ | ⋮ |
더 흥미로운 과제는 출발 공항과 도착 공항의 쌍마다 운항 편수를 얻는 일이다.
conn.execute(''' SELECT ORIGIN_AIRPORT, DESTINATION_AIRPORT, COUNT(*) as COUNT FROM flights GROUP BY ORIGIN_AIRPORT, DESTINATION_AIRPORT ORDER BY COUNT DESC ''').df()
선택을 델타 항공(DL)이 제공하는 항공편으로만 한정하면 어떨까.
conn.execute(''' SELECT ORIGIN_AIRPORT, DESTINATION_AIRPORT, COUNT(*) as COUNT FROM flights WHERE AIRLINE='DL' GROUP BY ORIGIN_AIRPORT, DESTINATION_AIRPORT ORDER BY COUNT DESC ''').df()
같은 SQL 문에서 항공사 코드만 바꾸면 어느 항공사의 항공편이든 얻는다. 질의 하나를 잘 세워 두면 열네 번을 다시 쓸 필요가 없다는 것, 이것이 SQL로 분석하는 일의 실질적인 이득이다.
결항을 세는 법과 견주는 법
GETTING THE CANCELED FLIGHTS FROM AIRLINES
flights 테이블에서 결항된 모든 항공편은 CANCELLED 필드가 1로 설정되어 있다. 이를 써서 2015년 델타 항공의 결항 편수를 알아낸다. 그런데 절대 수를 아는 것보다 결항 비율을 이해하는 편이 더 뜻이 있다. 원서는 세 단계로 물음을 정교하게 만든다.
-- ① 노선별 결항 편수 conn.execute(''' SELECT ORIGIN_AIRPORT, DESTINATION_AIRPORT, COUNT(*) as COUNT FROM flights WHERE AIRLINE='DL' AND CANCELLED = 1 GROUP BY ORIGIN_AIRPORT, DESTINATION_AIRPORT ORDER BY COUNT DESC ''').df() -- ② 노선별 결항 비율 conn.execute(''' SELECT ORIGIN_AIRPORT, DESTINATION_AIRPORT, (SUM(CANCELLED) * 100.0) / COUNT(*) as CANCELLED_PERCENT FROM flights WHERE AIRLINE = 'DL' GROUP BY ORIGIN_AIRPORT, DESTINATION_AIRPORT ORDER BY CANCELLED_PERCENT DESC ''').df() -- ③ 항공사 전체의 결항 비율 conn.execute(''' SELECT (SUM(CANCELLED) * 100.0) / COUNT(*) as CANCELLED_PERCENT FROM flights WHERE AIRLINE = 'DL' ''').df()
델타 항공은 2015년 전체 항공편의 0.44%를 결항했다. 그런데 한 항공사의 결항 비율만 보여서는 그 비율이 좋은지 나쁜지 알 수 없다. 모든 항공사에 걸쳐 견줄 수 있어야 유용하다.
import matplotlib.pyplot as plt df = conn.execute(''' SELECT AIRLINE, (SUM(CANCELLED) * 100.0) / COUNT(*) as CANCELLED_PERCENT FROM flights -- WHERE AIRLINE = 'DL' GROUP BY AIRLINE ORDER BY CANCELLED_PERCENT DESC ''').df() display(df)
이 대목의 기법이 소박하지만 실용적이다. WHERE 절을 지우지 않고 --로 가려 둔다. 한 항공사만 보는 질의와 전체를 보는 질의를 한 문장에 담아 두고 한 글자로 갈아타는 것이다. 원서는 이렇게 얻은 결과로 델타 항공이 그리 나쁘지 않다고 적는다. 결항 순위에서 뒤에서 세 번째다.
다음은 막대 차트로 옮기는 일이다. 그런데 x축 눈금이 항공사 코드로 나오므로 이름을 온전히 보이는 편이 낫다. 그러려면 airlines 테이블과 JOIN 연산을 해야 한다.
df = conn.execute(''' SELECT a.AIRLINE, (SUM(f.CANCELLED) * 100.0) / COUNT(*) as CANCELLED_PERCENT FROM flights f JOIN airlines a ON f.AIRLINE = a.IATA_CODE GROUP BY a.AIRLINE ORDER BY CANCELLED_PERCENT DESC ''').df() df.plot(kind='bar', x='AIRLINE', y='CANCELLED_PERCENT') plt.xlabel('Airlines') plt.ylabel('Cancellation Percentage') plt.title('Cancellation Percentage for Different Airlines')
제3장에서 익힌 조인이 여기서 쓰인다. 코드 DL을 이름 Delta Air Lines Inc.로 바꾸는 일, 곧 사람이 읽을 수 있게 만드는 일이 조인의 몫이다. 분석의 마지막 한 걸음은 늘 이런 종류의 번역이다.
요일을 세다 · 그리고 10월의 이상
FLIGHT COUNT FOR EACH DAY OF THE WEEK
노선마다 요일별 운항 편수를 알면 특정 요일에 항공편이 적은지 알 수 있다. 모든 노선 조합을 요일로 묶는 DataFrame을 만든다.
df_flights_day_of_week = conn.execute(''' SELECT day_of_week, origin_airport, destination_airport, COUNT(*) AS flight_count FROM flights WHERE CANCELLED = 0 -- 결항편은 제외한다 GROUP BY day_of_week, origin_airport, destination_airport ORDER BY day_of_week, origin_airport, destination_airport; ''').df() df_flights_day_of_week
결과를 살펴보면 첫 다섯 행의 ORIGIN_AIRPORT와 DESTINATION_AIRPORT 필드가 알파벳이 아니라 숫자 값으로 공항 코드를 담고 있음을 알게 된다. 실제로 이 양상은 2015년 10월 한 달 전체에 걸쳐 일관되게 나타난다. 두 열에서 알파벳이 아닌 값을 찾는 질의로 이를 확인할 수 있다.
conn.execute(''' SELECT YEAR, MONTH, DAY, ORIGIN_AIRPORT, DESTINATION_AIRPORT FROM flights WHERE ORIGIN_AIRPORT NOT SIMILAR TO '[A-Za-z… OR DESTINATION_AIRPORT NOT SIMILAR TO '[A… ''').df()
도판 5-36이 보이는 대로, 2015년 10월에 두 열이 숫자 값을 담고 있음이 확인된다. 이 두 필드의 숫자 값은 통상 특수한 경우를 나타낸다. 작은 공항이나 화물 전용 거점, 미지정 위치에 대해 체계가 배정한 코드다. 회항이나 운영상 예외로 특정 공항 코드를 쓸 수 없었던 경우에도 나타난다. 여기서는 간결함을 위해 이 숫자 값들을 별개의 공항 코드로 간주한다.
이 박스가 이 장에서 가장 값진 대목이다. 원서는 결과를 그대로 받아들이지 않고, 이상한 것이 눈에 띄자 그것을 확인하는 질의를 따로 세웠다. 그리고 그 이상이 한 달에 국한된 체계적 결손임을 밝혔다. 탐색적 분석의 본령은 답을 얻는 데 있지 않고 데이터가 어디서 어긋나 있는지를 찾아내는 데 있다. 답사자가 비석의 마멸된 자리를 먼저 살피는 일과 같다.
본문으로 돌아온다. 도판 5-35의 결과에는 모든 노선 조합이 담겨 있으므로, SFO에서 LAX로 가는 항공편의 운항 편수만 골라 막대 차트로 그린다.
from_airport = 'SFO' to_airport = 'LAX' df_flights_result = df_flights_day_of_week.query( f'ORIGIN_AIRPORT=="{from_airport}" & DESTINATION_AIRPORT=="{to_airport}"') df_flights_result.plot(kind='bar', x = 'DAY_OF_WEEK', y = 'flight_count', legend = False) plt.xlabel('Day of Week') plt.ylabel('Number of Flights') plt.title(f'Number of Flights from {from_airport} …') plt.xticks(df_flights_result['DAY_OF_WEEK'] - 1, ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']) plt.show()
운항 편수를 세는 데 그치지 않고 백분율을 계산할 수도 있다. 다음 질의는 결항편을 제외하고 주어진 출발·도착 공항에 대해 요일별 항공편의 백분율을 계산한다. 두 개의 공통 테이블 표현식(CTE)으로, 먼저 요일별 운항 편수를 모으고 그다음 출발·도착 쌍별 총 운항 편수를 모아 이를 이룬다.
df_flights_weekday = conn.execute(''' with t1 as ( SELECT day_of_week, origin_airport, destination_airport, COUNT(*) AS flight_count_per_weekday FROM flights WHERE CANCELLED = 0 -- 결항편 제외 GROUP BY day_of_week, origin_airport, destination_airport ORDER BY day_of_week, origin_airport, destination_airport ), t2 as ( SELECT origin_airport, destination_airport, count(*) as total_flight_count FROM flights WHERE CANCELLED = 0 -- 결항편 제외 GROUP BY origin_airport, destination_airport ) SELECT t1.origin_airport, t1.destination_airport, t1.day_of_week, t2.total_flight_count, 100. * (t1.flight_count_per_weekday / t2.total_flight_count) as percent_flights_on_weekday FROM t1 JOIN t2 ON t1.origin_airport = t2.origin_airport AND t1.destination_airport = t2.destination_airport ''').df()
CTE 두 개의 역할이 분명하다. t1은 분자를, t2는 분모를 만든다. 그리고 둘을 노선으로 조인해 나눈다. 백분율을 구하는 일이 결국 같은 데이터를 두 가지 굵기로 묶어 견주는 일임을 이 구조가 보여 준다. 절대 수만 보면 큰 노선이 언제나 이기지만, 비율로 보면 노선의 크기와 무관한 요일의 성질이 드러난다.
언제 떠나야 늦지 않는가
FINDING THE MOST COMMON TIMESLOT FOR FLIGHT DELAYS
지연이 가장 잦은 시간을 찾기 위해 하루를 네 개의 시간대로 나눈다. 0시~6시, 6시~12시, 12시~18시, 18시~24시다. 이 시간대로 시간대별 항공편 지연을 찾는 SQL 질의를 만든다.
df_delays_by_week = conn.execute(''' SELECT DAY_OF_WEEK, CASE WHEN SCHEDULED_DEPARTURE BETWEEN '0000' A… WHEN SCHEDULED_DEPARTURE BETWEEN '0600' A… WHEN SCHEDULED_DEPARTURE BETWEEN '1200' A… WHEN SCHEDULED_DEPARTURE BETWEEN '1800' A… ELSE 'Other' END AS DEPARTURE_TIME_INTERVAL, AVG(ARRIVAL_DELAY) AS AVG_ARRIVAL_DELAY FROM flights WHERE ARRIVAL_DELAY > 0 GROUP BY DAY_OF_WEEK, CASE -- SELECT 절과 같은 CASE 식을 되풀이한다 ELSE 'Other' END ORDER BY DAY_OF_WEEK, DEPARTURE_TIME_INTERVAL; ''').df()
GROUP BY 절에 SELECT 절과 똑같은 CASE 식을 다시 적는다. 별칭 DEPARTURE_TIME_INTERVAL을 GROUP BY에서 쓸 수 없는 표준 SQL의 제약 때문이다. 원한다면 하루를 더 많은 시간대로 나눌 수도 있다.
DataFrame만 보아서는 그다지 쓸모가 없다. 그래서 시간대별 지연을 보이는 막대 차트를 그린다. 그러기 전에 DataFrame의 색인이 DAY_OF_WEEK가 되고 열이 시간대가 되도록 피벗해야 한다.
df_delays_by_week_pivot = df_delays_by_week.pivot( index = 'DAY_OF_WEEK', columns = 'DEPARTURE_TIME_INTERVAL', values = 'AVG_ARRIVAL_DELAY') df_delays_by_week_pivot # 막대 차트를 그린다 df_delays_by_week_pivot.plot(kind='bar', stacked=False, figsize=(10, 6)) # x축 눈금을 요일로 바꾼다 days_of_week = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] plt.xticks(ticks=range(len(days_of_week)), labels=days_of_week) plt.title('Average Arrival Delay by Departure Time …') plt.xlabel('Day of Week') plt.ylabel('Average Arrival Delay (minutes)') plt.legend(title='Departure Time Interval', bbox_… plt.tight_layout() # 잘림을 피하도록 배치를 조정한다 plt.show()
도판 5-42는 지연이 있는 모든 항공편에 대해 세 가지를 보인다. 아래 격자는 그 세 문장을 옮긴 것이다. 수치가 아니라 본문이 내린 판단을 칸에 표시했다.
| 시간대 | 월 | 화 | 수 | 목 | 금 | 토 | 일 |
|---|---|---|---|---|---|---|---|
| 0시 ~ 6시 | · | · | · | 최소 | 최소 | · | · |
| 6시 ~ 12시 | 짧음 | 짧음 | 짧음 | · | · | 짧음 | · |
| 12시 ~ 18시 | · | · | · | · | · | · | · |
| 18시 ~ 24시 | 가장 김 | 가장 김 | 가장 김 | 가장 김 | 가장 김 | 가장 김 | 가장 김 |
이 격자는 도판 5-42의 수치를 옮긴 것이 아니다. 원서 본문이 그 도판에서 읽어 낸 세 문장을 칸에 배치한 것이며, 개별 평균 지연 값은 도판 이미지에만 있어 인용하지 않았다.
결론은 실용적이다. 지연을 최소로 하려면 목요일이나 금요일, 그리고 아주 이른 새벽(0시~6시)에 떠난다. 저녁 시간대는 어느 요일이든 가장 길다. 하루 동안 쌓인 지연이 저녁으로 밀려 내려간다는, 항공 운항의 상식이 데이터로 확인되는 자리다.
건수의 함정, 비율의 정직함
AIRLINES WITH THE MOST AND FEWEST DELAYS
먼저 항공사별 지연 건수를 합산한다.
df_most_delays = conn.execute(''' SELECT count(airlines.AIRLINE) as Count, airlines.AIRLINE FROM flights, airlines WHERE airlines.IATA_CODE = flights.AIRLINE AN… GROUP BY airlines.AIRLINE ORDER BY COUNT DESC ''').df() df_most_delays
그런데 어느 항공사의 지연 항공편 수만 세어 그 수로 지연이 가장 많은 항공사를 정하는 것은 공정하지 않을 수 있다. 어떤 항공사가 하루에 두 편만 운항하고 그중 한 편이 지연되었다고 상상해 본다. 지연 항공편은 한 편뿐이지만 지연 비율은 50%다.
지연이 가장 많은 항공사를 훨씬 정확하게 정하는 방법은 항공사별 지연 백분율을 계산하는 것이다.
df_percent_delay = conn.execute(''' WITH flight_delays AS ( SELECT AIRLINE, 1.0 * count(*) as TotalFlights, 1.0 * sum(case when ARRIVAL_DELAY > 0… (1.0 * sum(case when ARRIVAL_DELAY >… count(*)) * 100 as Percentage FROM flights GROUP BY AIRLINE ) SELECT flight_delays.Percentage, airlines.IATA_CODE, airlines.AIRLINE FROM flight_delays JOIN airlines ON airlines.IATA_CODE = flight_delays.AIRLINE ORDER BY flight_delays.Percentage DESC; ''').df()
결과의 첫 줄이 이 절의 답이다. 지연에 관해서는 Spirit Air Lines가 선두이며, 전체 항공편의 48.46%가 정시에 도착하지 못했다.
그리고 늘 정시에 도착하는, 심지어 더 일찍 도착하는 항공사를 찾고 싶다면 어떻게 하는가. 쉽다. 부호를 >에서 <=로 바꾸기만 하면 된다.
df_percent_on_time = conn.execute(''' WITH flight_delays AS ( SELECT AIRLINE, 1.0 * count(*) as TotalFlights, 1.0 * sum(case when ARRIVAL_DELAY <=… OnTimeFlights, (1.0 * sum(case when ARRIVAL_DELAY <=… count(*)) * 100 as Percentage FROM flights GROUP BY AIRLINE ) SELECT flight_delays.Percentage, airlines.IATA_CODE, airlines.AIRLINE FROM flight_delays JOIN airlines ON airlines.IATA_CODE = flight_delays.AIRLINE ORDER BY flight_delays.Percentage DESC; ''').df()
| 물음 | 선두 | 값 |
|---|---|---|
| 지연 비율이 가장 높은 항공사 | Spirit Air Lines | 48.46% |
| 정시 도착 비율이 가장 높은 항공사 | Delta Air Lines | 70.72% |
| 결항 비율 · 델타 항공 단독 | Delta Air Lines | 0.44% |
이 절의 교훈은 부호 하나가 아니라 분모의 선택이다. 건수로 세면 큰 항공사가 늘 지연이 많은 항공사가 된다. 비율로 세면 규모가 지워지고 성질이 드러난다. 앞의 요일 백분율 절에서 이미 같은 원리가 나왔고, 결항 비율 절에서도 그랬다. 이 장은 같은 교훈을 세 번 되풀어 가르친다. 무엇을 세는가보다 무엇으로 나누는가가 결론을 정한다는 것이다.