끄적끄적
CH 2. 기초 프로젝트 - 데이터 전처리 (1) 본문
사용하는 데이터
더보기
데이터 출처:
https://www.kaggle.com/datasets/gauravmalik26/food-delivery-dataset/data?select=train.csv
테이블 설명:
| 컬럼명 | 설명 | 데이터 타입 |
| ID | 주문 건 ID | object |
| Delivery_person_ID | Delivery Person 고유 ID | object |
| Delivery_person_Age | Delivery Person 나이 | object |
| Delivery_person_Ratings | Delivery Person 평점 (1 to 5) | object |
| Restaurant_latitude | Restaurant 위도 | float64 |
| Restaurant_latitude | Restaurant 경도 | float64 |
| Delivery_location_latitude | 배달 목적지 위도 | float64 |
| Delivery_location_longitude | 배달 목적지 경도 | float64 |
| Order_Date | 주문 날짜 | object |
| Time_Ordered | 주문 시간 | object |
| Time_Order_picked | 배달원이 음식 픽업한 시간 | object |
| Weather conditions | 기상상태 (Windy, Sunny, Cloudy, Stormy, Fog, Sandstoms 등) | object |
| Road_traffic_density | 배달 당시 도로 교통 상황 (Jam, High, Medium, Low) | object |
| Vehicle_condition | 배달 차량의 상태 (Smooth, good, average) | int64 |
| Type_of_order | 배달음식 종류 (Snack, Meal, Buffet, Drinks 등) | object |
| Type_of_vehicle | 배달 차량 종류 (motorbike, bicycle 등) | object |
| multiple_deliveries | 한 번에 배달원이 배당받은 배달 건수 | object |
| Festival | 당일에 축제가 있는지 여부 | object |
| City | 배달지역 도시화 정도 (Metropolitian, Urban 등) | object |
| Time_taken(min) | 배달에 걸린 시간 (분 단위) | object |
1. Deliverey_person_ID 기준 Deliverey_person_Age, Deliverey_person_Ratings 처리
- Deliverey_person_ID가 동일해도 age,ratings이 다른 경우가 있다.
- 따라서 동일한 Deliverey_person_ID에 기준 평균값으로 age,ratings 값을 교체한다.
- 이때, 아래 2가지 경우의 데이터를 제거하고 Deliverey_person_ID 기준 groupby
- age나 ratings가 NaN인 데이터
- ratings가 범위를 넘어가는 데이터
# age나 rating이 nan인 데이터 제거
nan_age_rating_idx = list(df[df[age_rating].isna().any(axis=1)].index)
df_drop_age = df.drop(index = nan_age_rating_idx)
# age와 rating 데이터 float 변경 후 rating이 5 초과인 데이터 제거
df_drop_age.drop(index = list(df_drop_age[df_drop_age['Delivery_person_Ratings']>5].index), inplace=True)
df_drop_age = df_drop_age.reset_index(drop=True)
# 정상 데이터 기준 ID별 평균 age, rating 구하기
df_idgroup = df_drop_age.groupby(['Delivery_person_ID']).agg({"Delivery_person_Age":"mean","Delivery_person_Ratings":"mean"}).reset_index()

- 이렇게 얻은 groupby 데이터를 가지고, 원본 데이터프레임에 Deliverey_person_ID 기준으로 삽입
# 전체 데이터에 평균 age,rating ID에 맞춰 넣기
# 만약 ID가 없으면 NaN 반환
age_map = {}
ratings_map = {}
for idx,row in df_idgroup.iterrows():
age_map[row['Delivery_person_ID']] = round(row['Delivery_person_Age'],0)
ratings_map[row['Delivery_person_ID']] = round(row['Delivery_person_Ratings'],1)
df['Delivery_person_Age'] = df["Delivery_person_ID"].map(age_map)
df['Delivery_person_Ratings'] = df["Delivery_person_ID"].map(ratings_map)
전체 코드
더보기
import pandas as pd
import numpy as np
path = './Food_delivery_dataset/Food_delivery_dataset.csv'
df_origin = pd.read_csv(path)
df = df_origin.copy()
# 데이터의 NaN 값이 문자열'NaN "으로 되어있어 가 replace
age_rating = ["Delivery_person_Age","Delivery_person_Ratings"]
df.replace('NaN ',np.nan,inplace=True)
df[age_rating] = df[age_rating].astype(float)
# age나 rating이 nan인 데이터 제거
nan_age_rating_idx = list(df[df[age_rating].isna().any(axis=1)].index)
df_drop_age = df.drop(index = nan_age_rating_idx)
# age와 rating 데이터 float 변경 후 rating이 5 초과인 데이터 제거
df_drop_age.drop(index = list(df_drop_age[df_drop_age['Delivery_person_Ratings']>5].index), inplace=True)
df_drop_age = df_drop_age.reset_index(drop=True)
# 정상 데이터 기준 ID별 평균 age, rating 구하기
df_idgroup = df_drop_age.groupby(['Delivery_person_ID']).agg({"Delivery_person_Age":"mean","Delivery_person_Ratings":"mean"}).reset_index()
# 전체 데이터에 평균 age,rating ID에 맞춰 넣기
# 만약 ID가 없으면 NaN 반환
age_map = {}
ratings_map = {}
for idx,row in df_idgroup.iterrows():
age_map[row['Delivery_person_ID']] = round(row['Delivery_person_Age'],0)
ratings_map[row['Delivery_person_ID']] = round(row['Delivery_person_Ratings'],1)
df['Delivery_person_Age'] = df["Delivery_person_ID"].map(age_map)
df['Delivery_person_Ratings'] = df["Delivery_person_ID"].map(ratings_map)
2. 경도, 위도 1차 이상치 처리
- 일부 좌표 데이터에서 좌표가 바닷가로 찍히는 데이터가 존재하기에 이를 제거하기 쉽게 NaN 으로 바꿔줌
# 0으로 시작하는 값 NaN 변경
lat_lng = ['Restaurant_latitude','Restaurant_longitude','Delivery_location_latitude','Delivery_location_longitude']
df[lat_lng] = df[lat_lng].mask(
df[lat_lng]
.astype(str)
.apply(lambda col: col.str.startswith('0.', na=False))
.any(axis=1), np.nan)
- 경도가 아래 그림처럼 음수가 되는 경우가 있는데, 이는 인도 데이터와 맞지 않는 데이터다. 그래서 아예 제거를 할까 하다 음식점 경도에만 음수가 존재하고, 이를 양수로 바꾸니 올바른 좌표에 위치하는 듯 보여서 절댓값 처리를 하기로 결정했다.
df['Restaurant_latitude'] = df['Restaurant_latitude'].mask(df['Restaurant_latitude']<0, abs(df['Restaurant_latitude']))

- 이후에 결측치 제거를 수행한다.
df = df.dropna(axis=0).reset_index(drop=True)
Box-plot 코드
더보기
import matplotlib.pyplot as plt
lat_lng = ['Restaurant_latitude','Restaurant_longitude','Delivery_location_latitude','Delivery_location_longitude']
def make_box(data):
fig, ax = plt.subplots()
data.boxplot()
plt.xticks(rotation=45)
ax.set_xlabel('columns')
ax.set_ylabel('Value')
plt.show()
전체 코드
더보기
lat_lng = ['Restaurant_latitude','Restaurant_longitude','Delivery_location_latitude','Delivery_location_longitude']
df[lat_lng] = df[lat_lng].mask(
df[lat_lng]
.astype(str)
.apply(lambda col: col.str.startswith('0.', na=False))
.any(axis=1), np.nan)
df['Restaurant_latitude'] = df['Restaurant_latitude'].mask(df['Restaurant_latitude']<0, abs(df['Restaurant_latitude']))
3. 공백, 필요 없는 문자열 처리
- 일부 문자열 데이터에 쓸모 없는 공백이 존재했다.
- 일부 문자열에 필요 없는 문자열에 존재했다.
- 따라서, 해당 내용을 모두 제거했다
# 문자 공백 및 필요없는 문자열 제거
for col in df.columns:
if df[col].dtypes == 'object':
df[col] = df[col].astype(str).str.replace(' ', '', regex=False)
df['Weatherconditions'] = df['Weatherconditions'].str.replace('conditions', '')
df['Time_taken(min)'] = df['Time_taken(min)'].str.replace('(min)', '').astype(int)

4. 날짜 데이터 datetime으로 형식 변경 + 요일 + 주중/주말 추가하기
- Order_Date 데이터를 datetim 형식으로, Time_Orderd와 Time_Orderd_picked 데이터는 timedelta 형식으로 변경했다.
- timedelta는 시간 간격(기간) 처리할 때 사용하는 함수로 날짜 함수와 시간 함수가 분리된 경우에 이를 합치기 위해 사용했다.
- Time_Orderd와 Time_Orderd_picked 데이터를 날짜 + 시각 데이터로 변경하였다. (이때, 주문 시각이 픽업 시간보다 늦는 경우에는 픽업 날짜에 하루를 추가했다.
- 위의 Time_Orderd와 Time_Orderd_picked 데이터 사용해서 주문을 받고 픽업이 될 때까지의 시간을 측정한 food_making_time(min) 컬럼을 추가하였다.
df['Order_Date'] = pd.to_datetime(df['Order_Date'],format='%d-%m-%Y',errors='raise')
df['Time_Orderd'] = pd.to_timedelta(df['Time_Orderd'])
df['Time_Order_picked'] = pd.to_timedelta(df['Time_Order_picked'])
# 주문 시각이 도착 시각보다 늦은 경우 하루가 지난 것
df['Time_Order_picked'] = np.where(df['Time_Orderd']>=df['Time_Order_picked'], df['Order_Date'] + df['Time_Order_picked'] + pd.DateOffset(days=1) , df['Order_Date'] + df['Time_Order_picked'] )
df['Time_Orderd'] = df['Order_Date'] + df['Time_Orderd']
df['food_making_time(min)'] = (df['Time_Order_picked'] - df['Time_Orderd']).dt.total_seconds() / 60

- 주문한 요일을 나타내는 Order_day와 주문 날짜가 주중 or 주말인지를 나타내는 Is_Weekend 컬럼을 추가했다.
- 추가된 컬럼

day_weekname = df['Order_Date'].dt.weekday
df['Order_day'] = df['Order_Date'].dt.day_name()
# 평일은 0, 주말은 1
df['Is_Weekend'] = day_weekname.apply(lambda x: 'No' if x<5 else "Yes")
5. 식당과 배달 도착지 간 거리 구하기
- 이를 위해 geopy 라이브러리을 사용하였다.(참고 블로그)
- geopy의 distance 모듈은 두 지점에 대한 위도, 경도를 튜플 형태로 받아 계산한다.
- 만약 위도, 경도가 하나가 아닌 각각의 변수로 존재하면 zip()을 활용해 하나의 묶음으로 만들 수 있다.
- 이렇게 만들어진 묶음을 데이터프레임에 적용할 수 있다.
import geopy.distance
# 기본 형태
geopy.distance.distance((위도1, 경도1), (위도2, 경도2))
# 위도, 경도가 개별 변수일 때
# 식당 위도, 경도 합치기
distances['restaurant'] = pd.Series(zip(df['Restaurant_latitude'], df['Restaurant_longitude']))
# 배달지 위도, 경도 합치기
distances['delivery'] = pd.Series(zip(df['Delivery_location_latitude'], df['Delivery_location_longitude']))
# apply 함수를 사용해 전체 행에 적용하고 axis=1로 가로 방향 한 행씩 lambda 함수를 실행
distances['distance'] = distances.apply(lambda x: geopy.distance.distance(x['restaurant'],x['delivery']).km,axis=1)
전체 코드
더보기
import geopy.distance
distances = pd.DataFrame()
# 위도, 경도가 개별 변수일 때
# 식당 위도, 경도 합치기
distances['restaurant'] = pd.Series(zip(df['Restaurant_latitude'], df['Restaurant_longitude']))
# 배달지 위도, 경도 합치기
distances['delivery'] = pd.Series(zip(df['Delivery_location_latitude'], df['Delivery_location_longitude']))
# apply 함수를 사용해 전체 행에 적용하고 axis=1로 가로 방향 한 행씩 lambda 함수를 실행
distances['distance'] = distances.apply(lambda x: geopy.distance.distance(x['restaurant'],x['delivery']).km,axis=1)
# 원본 df에 추가
df['distance'] = distances['distance']
6. 범주형 데이터 카테고리화
- 범주형 데이터는 메모리 절약, 속도 향상, 분석 효율, 의미 명시 등등의 이유로 범주형 데이터를 카데고리화했다.
- 날씨, 교통 상활, 차종, 차량 상태 등 총 9개의 범주형 데이터를 카테고리화 했다.
category_col = ['Weatherconditions','Road_traffic_density','Vehicle_condition','Type_of_order','Type_of_vehicle','Festival','City','Order_day','Is_Weekend']
for c in category_col:
df[c] = df[c].astype('category')

7. 배달 거리 대비 배달 시간으로 속도 추정
- distance(km) / (minute/60) 으로 시간 당 속도를 계산했다.
df['Speed(km/h)'] = round(df['distance(km)'] / (df['Time_taken(min)']/60),1)
8.이상치
- 숫자형 데이터를 대상으로 Box plot을 사용해 이상치를 확인해보았다.
- 나이, 평점, 경도, 묶음 배달 수, 추정한 속도 이렇게 5가지 영역에서 이상치가 있다고 확인이 가능했다.
- 나이, 평점 그리고 묶음 배달 수의 이상치로 나오는 값은 큰 문제가 없을 것으로 판단했으며, 경도의 경우 해당 데이터로 QGIS로 지도에 투영시킬 때 튀는 점이 없었다.

- 좌표계에 투영했을 때

- 그러나 배달 속도의 경우, 비상식적인 속도를 보이는 데이터가 다소 보인다고 생각하였다.
- IQR 이상치 기준 58.9를 넘기는 데이터를 이상치로 판단하는 것이 옳으나, 일단 기준을 널널하게 잡아 80km/h 의 속도를 가진 데이터는 이상치로 판단하여 제거하였다.
import seaborn as sns
fig ,ax = plt.subplots(figsize = (12,5))
sns.histplot(df['Speed(km/h)'])

q1 = df['Speed(km/h)'].quantile(0.25)
q3 = df['Speed(km/h)'].quantile(0.75)
iqr = (q3-q1) * 1.5
# 원래 이상치 제거 방법 (1235개)
# df[(df['Speed(km/h)'] > (q3 + iqr)) | (df['Speed(km/h)'] < (q1 - iqr))]
# 내가 적용한 이상치 제거 (245개)
# df[(df['Speed(km/h)'] > 80) | (df['Speed(km/h)'] < (q1 - iqr))]
df = df.drop(index= list(df[(df['Speed(km/h)'] > 80) | (df['Speed(km/h)'] < (q1 - iqr))].index)).reset_index(drop=True)
'[스파르타]내일배움캠프 데이터 분석 트랙 > Project' 카테고리의 다른 글
| CH 2. 기초 프로젝트 - 데이터 전처리 (2) (5) | 2025.06.18 |
|---|---|
| CH 2. 기초 프로젝트 - 데이터 분석 (1) (3) | 2025.06.17 |
| 22일차 - 공모전 준비 (QGIS 격자 기반 분석) (0) | 2025.06.12 |
| 20일차 - 공모전 준비 (CSV 공간 데이터 DBeaver 연결) (1) | 2025.06.10 |
| 19일차 - 공모전 준비 (QGIS 공간 데이터 DBeaver 연결) (0) | 2025.06.09 |