끄적끄적

CH 2. 기초 프로젝트 - 데이터 전처리 (1) 본문

[스파르타]내일배움캠프 데이터 분석 트랙/Project

CH 2. 기초 프로젝트 - 데이터 전처리 (1)

kminx 2025. 6. 13. 22:52

사용하는 데이터

더보기

데이터 출처: 

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
    1. age나 ratings가 NaN인 데이터
    2. 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)