본문 바로가기

AIFFLE/PROJECT

[kaggle] Bike Sharing Demand (자전거 수요 예측)

728x90
학습 내용
  • 데이터 분석 및 모델링의 전 과정을 알 수 있다
  • 시각화 기법들을 알 수 있다

 

자세한 코드보기 :

https://github.com/seongyeon1/aiffel/blob/main/Exploration/Quest2/project2_sykim.ipynb

https://github.com/seongyeon1/aiffel/blob/main/Exploration/Quest2/Bike_sharing.ipynb

  • 첫 번째 링크는 두 번째 링크 이후에 작성한 코드입니다.

 

Bike sharing demand

대회 관련 링크

데이터 및 문제 설명

  • 시계열과 수치형, 범주형으로 구성된 다양한 피처들로 자전거 대여량을 예측하는 회귀문제이다.

데이터 필드 구성

  • datetime : 기록 일시(1시간 간격)
  • season : 계절(1봄, 2여름, 3가을, 4겨울)
  • holiday : 공휴일 여부(0공휴일 아님, 1공휴일)
  • workingday : 근무일 여부(0근무일 아님, 1근무일_주말과 공휴일이 아니면 근무일로 간주)
  • weather : 날씨(1맑음, 2옅은 안개 및 약간 흐림, 3약간의 눈이나 비 천둥번개와 흐림, 4폭우와 천둥번개 및 눈 짙은 안개)
  • temp : 실제 온도
  • atemp : 체감 온도
  • humidity : 상대 습도
  • windspeed : 풍속

train 데이터에만 존재하는 feature

  • casual : 등록되지 않은 사용자(비회원) 대여 수량
  • registered : 등록된 사용자(회원) 대여 수량

예측해야 할 타깃값

  • count : 자전거 대여 수량 (회원 + 비회원)

 

데이터 분석 방향성

 

1. 시계열 데이터 (datetime) : 시계열 데이터 분석을 통해 시간적 특성에 따른 대여량의 변화 및 차이를 확인한다.

  • datetime 으로 타입을 변환한 뒤 파생변수를 생성한다 (day, month, dayofweek 등)

2. 날씨 관련 연속형 데이터 (temp, atemp, humidity, windspeed)

  • 이상치 파악

3. 범주형 데이터 (season, holiday, workingday, weather) : 명목형과 순서형을 구분한다

  • 명목형 : holiday, workingday
  • 순서형 : season, weather

4. target : train 데이터에만 있는 casual과 registered feature에 대해서 어떻게 다뤄야 할 지 고민해본다.

  • 예측해야 하는 타겟값이 두 피처의 합으로 보여진다.
  • 각각을 예측한 뒤에 합해서 결과를 낼지 혹은 한번에 예측해야 할 지 두 가지 예측 방향성을 생각해 볼 수 있다.

 

 

Datetime 컬럼을 datatime 형식으로 변환 후 파생변수 추출

  • datetime 컬럼은
# train['datetime'] = pd.to_datetime(train['datetime'])
# train['year'] = train['datetime'].dt.year
# train['month'] = train['datetime'].dt.month
# train['day'] = train['datetime'].dt.day
# train['hour'] = train['datetime'].dt.hour
# train['minute'] = train['datetime'].dt.minute
# train['second'] = train['datetime'].dt.second

 

위 코드를 좀 더 간결하게 작성하는 방법을 소개하고자 한다.

  • getattr()를 통해 간결하게 하였다.
# datetime 컬럼을 datetime 자료형으로 변환
train['datetime'] = pd.to_datetime(train['datetime'])

# 추출할 시간 관련 속성들
time_attrs = ['year', 'month', 'day', 'hour', 'minute', 'second']

# for 루프를 사용하여 각 속성을 추출하고 저장
for attr in time_attrs:
    train[attr] = getattr(train['datetime'].dt, attr)

 

날씨 관련 수치형(연속형) 데이터 (temp, atemp, humidity, windspeed)

  • 이상치 파악

 

numerical = ['temp','atemp', 'humidity', 'windspeed']
train[numerical].describe()

  • humidity와 windspeed의 데이터에서 최솟값으로 0이 있는데, 이것이 실제로 가능한 수치인지 생각해 볼 필요가 있다.
  • 체감온도와 실제온도는 관련성이 높은 feature로 보이기에 두 데이터 간의 차이가 너무 크다면 이 또한 이상치로 보아야할 것이다.

 

분포 파악

distplot을 활용한 데이터 분포 확인

# 4개의 feature에 대한 distplot 그리기
plt.figure(figsize=(12, 4))
for i, feature in enumerate(numerical):
    plt.subplot(1, 4, i + 1)
    sns.distplot(train[feature], kde=True)
    plt.title(feature)

 

 

qq plot을 활용한 데이터 분포 확인

from scipy import stats

# 4개의 feature에 대한 qqplot 그리기
plt.figure(figsize=(12, 4))
for i, feature in enumerate(numerical):
    plt.subplot(1, 4, i + 1)
    stats.probplot(train[feature], dist="norm", plot=plt)
    plt.title(feature)

 

- 풍속(windspeed)의 경우 왼쪽으로 치우쳐져 있는 것을 볼 수 있다.
- 로그 변환을 고려해볼 필요가 있다.
- 풍속(windspeed)은 0이 될 수 없으므로 사실상 이상치라 볼 수 있고 이를 다른 값으로 대체해 주는 것이 바람직 해 보인다.
 

 

수치형 데이터의 상관관계 파악

온도와 체감온도의 산점도

# 온도와 체감 온도에 대한 산점도 그리기
plt.scatter(train['temp'], train['atemp'])
plt.xlabel('Temperature')
plt.ylabel('Apparent Temperature')
plt.show()

- 온도와 체감온도가 상당히 차이가 많이 나는 데이터 확인 후 이상치 처리를 해볼 것이다.
- 유독 차이가 많이 나는 부분이 체감온도가 하나의 값인 것으로 보아 기록시 나타난 이상치로 보인다

이상치 처리 (체감온도)

- 실제온도 / 체감온도의 비율로 이상치를 파악해보았다.
- 이 비율이 2를 넘어가면 이상치로 처리해주었다

train.loc[train['temp'] / train['atemp'] > 2,'atemp_out'] = 1
train.atemp_out.fillna(0, inplace=True)

sns.scatterplot(data=train, x='temp', y='atemp', hue='atemp_out')
plt.xlabel('Temperature')
plt.ylabel('Apparent Temperature')
plt.show()

 

이상치 시각화

잘 분류해낸 것을 볼 수 있다

이상치 확인

- 데이터를 확인해보니 2012년 08월 17일의 체감온도가 12.12로 일정하게 잘 못 기록된 것으로 보인다.
- 랜덤 포레스트를 활용하여 이상치를 보간해 주었다.

이상치 보간 (imputation)

from sklearn.ensemble import RandomForestRegressor
# atemp_out이 1인 데이터의 인덱스를 추출
index_outlier = train[train['atemp_out'] == 1].index

# atemp_out이 0인 데이터를 학습 데이터로 사용
X_train = train[train['atemp_out'] == 0][['temp', 'humidity', 'windspeed','month','hour']]
y_train = train[train['atemp_out'] == 0]['atemp']

# 랜덤 포레스트 모델 생성
model = RandomForestRegressor()

# 모델 학습
model.fit(X_train, y_train)

# atemp_out이 1인 데이터의 온도, 습도, 풍속을 사용하여 체감온도 예측
y_pred = model.predict(train[train['atemp_out'] == 1][['temp', 'humidity', 'windspeed','month','hour']])

# 예측한 체감온도를 train 데이터에 업데이트
train.loc[index_outlier, 'atemp'] = y_pred
sns.scatterplot(data=train, x='temp', y='atemp', hue='atemp_out')
plt.xlabel('Temperature')
plt.ylabel('Apparent Temperature')
plt.show()

이렇게 잘 변환된 것을 볼 수 있다

 

풍속 0인 데이터 처리

- 풍속이 0인 데이터는 1313개로 상당히 많아서 제거하기에는 너무 큰 숫자로 보인다
- 좀 더 자세한 분석을 통해 처리 방법을 고려해보자

windspeed가 상당히 이산적인 형태를 띄는 것을 볼 수 있다

'풍속'이 unique 값이 28개 밖에 안되는 것으로 보아 이산적으로 어떤 기준에 따라 나눠진 것 임을 예측할 수 있고, 0은 단순히 0이 아니라 6 미만 정도로 해석이 가능하다

 

 

범주형 데이터 분석

  • season 데이터는 범주형 데이터이지만 날씨의 특성상 여름, 봄, 가을, 겨울 순으로 온도가 낮아지기에 이를 순서형으로도 볼 수 있다.
  • 데이터 분석을 통해 단순 명목형으로 볼 것인지 혹은 순서형으로 변환해 볼지 고민해 볼 계정이다.
    • weather 데이터는 1은 맑은 날씨고 4로 갈수록 자전거 타기 어려운 날씨로 보여진다.
      • 따라서 순서형 데이터로 생각하고 데이터를 분석할 예정이다.
      • 또한 4는 1개밖에 없으므로 학습하는 데 방해가 될 것으로 예상되어 3으로 변환한다


시각화 시 의미가 잘 드러나도록 문자열로 바꾸고 진행한다.

fig, axes = plt.subplots(1, 3, figsize=(15, 5))

# holiday
sns.countplot(x='holiday', data=train, ax=axes[0])
axes[0].set_title('Holiday')
axes[0].set_xticklabels(['No Holiday', 'Holiday'])
for p in axes[0].patches:
    axes[0].annotate(format(p.get_height(), '.0f'), (p.get_x() + p.get_width() / 2., p.get_height()), ha = 'center', va = 'center', xytext = (0, 10), textcoords = 'offset points')

# workingday
sns.countplot(x='workingday', data=train, ax=axes[1])
axes[1].set_title('Working Day')
axes[1].set_xticklabels(['No Workingday', 'Workingday'])
for p in axes[1].patches:
    axes[1].annotate(format(p.get_height(), '.0f'), (p.get_x() + p.get_width() / 2., p.get_height()), ha = 'center', va = 'center', xytext = (0, 10), textcoords = 'offset points')

# weather
sns.countplot(x='weather', data=train, ax=axes[2])
axes[2].set_title('Weather')
axes[2].set_xticklabels(['Clear', 'Mist, Few clouds', 'Light Snow, Rain, Thunderstorm', 'Heavy Rain, Thunderstorm, Snow, Fog'])
axes[2].set_xticklabels(['Clear', 'Mist, Few clouds', 'Light Snow, Rain, Thunderstorm', 'Heavy Rain, Thunderstorm, Snow, Fog'], rotation=15)
for p in axes[2].patches:
    axes[2].annotate(format(p.get_height(), '.0f'), (p.get_x() + p.get_width() / 2., p.get_height()), ha = 'center', va = 'center', xytext = (0, 10), textcoords = 'offset points')

plt.tight_layout()
plt.show()

타겟 값 분포 확인

stats.probplot(train['count'], dist="norm", plot=plt)

- 분포가 왼쪽으로 많이 편향되어있는 것을 볼 수 있음.
- 회귀 모델이 좋은 성능을 내려면 데이터가 정규분포를 따라야 함
- 현재 타깃값인 count는 정규분포를 따르지 않아 현재 타깃값을 그대로 사용해 모델링하면 좋은 성능 기대 어려움
- 이에 따라 데이터 분포를 정규분포에 가깝게 만들기 위해 로그변환을 하고자 한다

범주형 데이터 분석

타겟 값과 범주형 변수 간의 관계

fig, axes = plt.subplots(nrows=2,ncols=2)
fig.set_size_inches(12, 10)
sns.boxplot(data=train, y="count", orient="v",ax=axes[0][0])
sns.boxplot(data=train, y="count", x="season",orient="v",ax=axes[0][1])
sns.boxplot(data=train, y="count", x="hour",orient="v",ax=axes[1][0])
sns.boxplot(data=train, y="count", x="workingday",orient="v",ax=axes[1][1])

axes[0][0].set(ylabel='Count',title="Box Plot On Count")
axes[0][1].set(xlabel='Season', ylabel='Count',title="Box Plot On Count Across Season")
axes[1][0].set(xlabel='Hour Of The Day', ylabel='Count',title="Box Plot On Count Across Hour Of The Day")
axes[1][1].set(xlabel='Working Day', ylabel='Count',title="Box Plot On Count Across Working Day")

- 계절(Season) : 대여량의 중앙값을 비교해보면, 계절의 따른 대여량 차이에는 봄이 다른 계절에 비해 대여량이 낮은 것으로 보인다.
- 시간대(Hour of the Day) : 대여량의 중앙값을 비교해보면, 아침 7-9시, 저녁 5-6시 시간대에 대여량이 다른 시간대에 비해 상당히 많음을 볼 수 있다. 이는 출퇴근 시간대에 자전거를 이용하여 대여량이 많은 것으로 유추할 수 있다.
- 이에 대한 증거로, 대부분의 이상치 값(대여량이 높은 데이터)들은 평일에 분포되어 있음을 볼 수 있다.

 

Holiday, Working Day 피처 분석

# Contingency Table 생성
contingency_table = pd.crosstab(train['holiday'], train['workingday'])

print("Contingency Table:")
# 시각화
import seaborn as sns
import matplotlib.pyplot as plt

plt.figure(figsize=(8, 6))
sns.heatmap(contingency_table, annot=True, cmap="YlGnBu", fmt='g')

plt.xlabel('Working Day')
plt.ylabel('Holiday')
plt.title('Contingency Table of Holiday vs Working Day')

plt.show()

두 피처를 합해 3개의 category로 만들수 있을 것 같다

 

피처 엔지니어링

# 0 : 휴일아닌 평일
# 1 : 휴일아닌 주말
# -1 : 휴일인 주말
train['day_off'] = train.holiday * (-1) + train.workingday + 1

 

날씨 변수 변경

train['weather'] = train['weather'].replace({4: 3})

 

원 핫 인코딩

# One-hot encoding for categorical variables
train = pd.get_dummies(train, columns=['season', 'day_off','year', 'month', 'day','hour']) # weather은 1,2,3이 순서가 있다고 생각해서 그대로사용

 

체감온도, 실제 온도 데이터는 상관관계 높으므로 PCA

from sklearn.decomposition import PCA

pca = PCA(n_components=1)
train['temperature'] = pca.fit_transform(train[['temp', 'atemp']])
train.drop(['temp','atemp'], axis=1, inplace=True)

 

 

테스트 데이터 셋과 학습데이터 셋으로 분리

from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(train.drop('count', axis=1), train['count'], test_size=0.2, random_state=123)

print(f"학습 데이터: {X_train.shape} 레이블: {y_train.shape}")
print(f"테스트 데이터: {X_test.shape} 레이블: {y_test.shape}")

 

Minmax scaling
# scikit-learn 패키지의 MinMaxScaler 클래스를 불러옵니다.
from sklearn.preprocessing import MinMaxScaler

# MinMaxScaler 객체를 생성합니다.
minmax_scaler = MinMaxScaler()

# fit_transform()을 사용해서 학습과 스케일링을 한 번에 적용합니다.
train_minmax = minmax_scaler.fit_transform(X_train)

# Min-Max 스케일링이 완료된 데이터를 데이터프레임 형태로 변환합니다.
train_minmax = pd.DataFrame(train_minmax, 
                            index=X_train.index, 
                            columns=X_train.columns)

 

로그 변환

y_train = np.log1p(y_train)

 

모델링

  • 모델링은 간단하게 선형 회귀 모델로만 진행하였다
from sklearn.linear_model import LinearRegression

# scikit-learn에서 제공하는 선형 회귀 모델
lr_model = LinearRegression()

# model을 x와 y 데이터셋을 이용하여 학습시킴
lr_model.fit(train_minmax, y_train)

 

학습된 모델로 X_test에 대한 예측값 출력 및 손실함수값 계산

test_minmax = minmax_scaler.fit_transform(X_test)

# model이 test 데이터에 대해 예측한 값을 array로 반환
y_pred = lr_model.predict(test_minmax)
y_pred = np.expm1(y_pred)

# 모델이 예측한 값과 정답 target 같의 손실함수 값 계산하기
mse = ((y_pred - y_test) ** 2).mean()
rmse = np.sqrt(mse)

print(f'mse: {mse}')
print(f'rmse: {rmse}')
mse: 9283.943270974496
rmse: 96.35322138348306

결론

  • 배운 점 : 자주 하던 데이터셋이라서 새로운 것이 없을 줄 알았는데, windspeed 관련해서 새로운 인사이트를 얻은 것 같아서 기분이 좋다
  • 아쉬운 점 : windspeed가 categorical 하다는 걸 알게 되었고, 이에 대한 고민을 더 해보지 못해서 아쉬웠다
  • 느낀 점 : modeling을 직접 하드코딩으로 하는 연습을 해야 겠다
  • 어려웠던 점 : windspeed 전처리에 대한 해결방안을 찾지못했다

 

참고자료

https://www.kaggle.com/c/bike-sharing-demand

728x90