Data Analysis Study

RNN, LSTM, GRU 모델 성능 비교 | 하이퍼파라미터 구조 튜닝 및 피처 엔지니어링 (기상청 데이터)

Solbi Lee 2025. 7. 30. 23:45

1. 코드의 목적과 역할 분석 

과거 30일치 서울의 일평균 기온 시계열 데이터를 입력 (2015.01.01 ~ 2025.06.27)

SimpleRNN, LSTM, GRU 세 가지 순환 신경망 셀을 각각 학습

테스트 구간에 대한 다음 날 기온을 예측

RMSE(Root Mean Squared Error)로 모델 성능을 비교 후 시각화 

 

2. 필수 라이브러리 

sklearn.metrics.mean_squared_error : MSE 계산, RMSE 산출 

tensorflow.keras.models.Sequential : 순차 모델 템플릿 

tensorflow.keras.layers.SimpleRNN : 기본 순환 셀 구현 

tensorflow.keras.layers.LSTM : 게이트 구조로 장기 의존성 학습 개선 

tensorflow.keras.layers.GRU : LSTM 보다 경량화된 게이트 구조 

tensorflow.keras.layers.Dense : 출력층 (회귀 예측값 1개) 

 

3. 주요 기능 및 이론적 배경 

1. 결측치 전처리

- fillna(method='ffill')로 앞선 값으로 결측 보간 → 시계열 누락 방지

- ffill : forward fill의 줄임말. 결측치가 있을 때 그 바로 이전 값을 그대로 채워넣는 방식 (시계열에서 주로 사용)

 

2. 슬라이딩 윈도우(make_dataset)

- 과거 30일(window) 데이터 → 다음 날 값 매핑

- 시계열 예측의 표준 기법 

 

3. 훈련/테스트 분리 

- 80%:20% 비율로 분할 → 과적합 방지, 일반화 성능 평가

 

4. RNN vs LSTM vs GRU

- SimpleRNN: 단순 순환 구조, 장기 기억 어려움(소실 기울기)

- LSTM: 입력/포겟/출력 게이트로 정보 흐름 제어 → 장기 의존성 보존

- GRU: 업데이트/리셋 게이트, 파라미터 절반 수준으로 경량화

 

5. 모델 학습/검증

- validation_split=0.2 → 내부 검증으로 과적합 모니터링

- loss='mse' → 회귀 특성에 맞는 손실함수

 

6. 성능 평가 

RMSE = sqrt(MSE) → 원 단위 온도 오차 직관적 해석

- 오차를 제곱한 후 평균을 낸 MSE에 루트를 씌운 값이므로 RMSE의 단위는 실제 예측하려는 값과 동일한 단위를 갖는다. 

- 주로 기온, 에너지 소비량, 주가 등 단위를 명확히 알고 있어 직관적 이해가 필요할 때 사용 

( + MSE는 상 Gradient Descent 같은 최적화 알고리즘과 호환이 좋아, 주로 모델 내부 학습에 쓰임, 평가지표로는 직관성이 떨어져 잘 안씀 ) 

 

4. 코드 해석 

# recurrent_ex.py (완료)
# 순환신경망 실습

# 주제 : 기상청 데이터를 활용한 일일 평균기온 예측
# 데이터 : 기상자료개방포털에서 서울의 일최고기온/일평균기온 CSV파일 다운로드
# 예측목표 : 과거 30일간의 기온데이터를 이용해 다음 날의 기온을 예측
# 날짜, 평균기온 CSV파일을 사용

# 라이브러리
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import mean_squared_error
from tensorflow.keras.models import Sequential # 순차 모델 템플릿
from tensorflow.keras.layers import SimpleRNN, LSTM, GRU, Dense  # RNN/LSTM/GRU 셀, 출력층(Dense)

# 1. 기상자료개방포털에 있는 서울_일평균기온.csv 로딩
df = pd.read_csv('assets/seoul_temp.csv', encoding='utf-8')
df['date'] = df['date'].astype(str).str.strip()            # 문자열 앞뒤 공백 제거
df = df[df['date'] != '']                                   # 빈 문자열 제거
df = df.dropna(subset=['date'])                             # 결측값 제거
df['date'] = pd.to_datetime(df['date'], errors='coerce')    # 에러가 나면 NaT로 처리
df = df.dropna(subset=['date'])                             # NaT 제거
df = df.sort_values('date') # 날짜 순으로 정렬

# 2. 기온 데이터 전처리
temp = df['avg_temp'].fillna(method='ffill').values  # 결측치 앞 값으로 채움

# 3. 시계열 입력/출력 만들기
def make_dataset(data, window=30):
    X, y = [], []
    for i in range(len(data) - window):
        X.append(data[i:i+window]) # 과거 window 일치 입력
        y.append(data[i+window]) # 바로 다음 날 출력
    return np.array(X), np.array(y)

X, y = make_dataset(temp, window=30)
X = X.reshape((X.shape[0], X.shape[1], 1)) # (샘플 수, 30, 1)

# 4. 훈련/테스트 나누기
split = int(len(X) * 0.8)
X_train, X_test = X[:split], X[split:]
y_train, y_test = y[:split], y[split:]

# 5. 모델 빌딩 (구성) 함수
# 아무런 하이퍼파라미터 튜닝 없이 베이스라인 모델로 구성 (모델 성능 비교, 검증할 때 기준점으로 삼음)
# 튜닝 가능한 하이퍼파라미터 : 유닛수, 레이어 깊이, 드롭아웃/정규화, 학습률, 배치 사이즈/에포크 수, 할성화 함수 변경, 조기종료, 모델 체크포인트
def build_model(cell_type='RNN'):
    model = Sequential()
    if cell_type == 'RNN':
        model.add(SimpleRNN(32, input_shape=(30, 1))) # 셀 유닛수 32, 레이어 1개
    elif cell_type == 'LSTM':
        model.add(LSTM(32, input_shape=(30, 1)))
    elif cell_type == 'GRU':
        model.add(GRU(32, input_shape=(30, 1)))
    model.add(Dense(1))
    model.compile(optimizer='adam', loss='mse')
    return model

# 6. 세 가지 모델 학습 및 예측
models, preds, rmses = {}, {}, {} #변수 3개를 빈 딕셔너리로 초기화
# 왜 딕셔너리? :각각의 모델·예측·RMSE 값을
# models['LSTM'], preds['GRU'], rmses['RNN'] 처럼
# 문자열 키로 바로 꺼내 쓰기 위해서.
# 리스트로 받으면 숫자 인덱스를 써야 해서 가독성이 떨어지기 때문
for cell in ['RNN', 'LSTM', 'GRU']:
    print(f"▶ {cell} 모델 학습 시작...")
    m = build_model(cell)
    m.fit(X_train, y_train, epochs=20, batch_size=32, validation_split=0.2, verbose=0)
    pred = m.predict(X_test).flatten()
    models[cell] = m
    preds[cell] = pred
    rmses[cell] = np.sqrt(mean_squared_error(y_test, pred))
    print(f"{cell} 모델 RMSE: {rmses[cell]:.3f}")

# 7. 결과 시각화 (x축을 날짜로 지정)
plt.rc("font", family="Malgun Gothic")
plt.figure(figsize=(14, 6))

# 테스트 구간에 해당하는 날짜 생성
test_dates = df['date'].values[-len(y_test):]

plt.plot(test_dates, y_test, label='실제 기온', color='black')  # x축에 날짜 사용
for cell in preds:
    plt.plot(test_dates, preds[cell], label=f'{cell} 예측')

plt.title('기상청 기온 예측: RNN vs LSTM vs GRU')
plt.xlabel('날짜')
plt.ylabel('평균기온 (℃)')
plt.xticks(rotation=45)  # 날짜 라벨 회전
plt.legend()
plt.grid()
plt.tight_layout()
plt.show()

# 8. RMSE 출력
print("\n 모델별 RMSE (Root Mean Squared Error)")
for cell in rmses:
    print(f"{cell}: {rmses[cell]:.3f}")

24/24 ━━━━━━━━━━━━━━━━━━━━ 0s 4ms/step
RNN 모델 RMSE: 2.972

24/24 ━━━━━━━━━━━━━━━━━━━━ 0s 6ms/step
LSTM 모델 RMSE: 2.493

24/24 ━━━━━━━━━━━━━━━━━━━━ 0s 7ms/step
GRU 모델 RMSE: 2.513

5. 결과 해석 

1. RMSE 비교

최저 RMSE: LSTM (2.493℃)

그 다음이 GRU (2.513℃), SimpleRNN이 가장 높음 (2.972℃)

=> LSTM이 세 모델 중 테스트 구간에서 오차가 가장 작아 다음날 기온 예측 과제에선 LSTM이 최적 선택임. 

 

2. 예측 곡선 패턴 분석

 

(1) 추세 추종력

- 세 모델 모두 장기적인 계절 변화(봄→여름→가을→겨울) 패턴은 잘 따라가지만,

- SimpleRNN(파란선)은 급격한 기온 등락 구간에서 반응이 느리고 평탄화 되는 경향이 강해,

- LSTM(주황선)과 GRU(녹색선)이 훨씬 빠르게 변동 폭을 좁히며 실제 곡선(검은선)에 더 밀착

 

(2) 극단값 처리

- 겨울철 영하권 일변동이나, 한여름 고온(30℃ 이상) 구간에서

 

  • SimpleRNN은 과소/과대 예측 폭이 크고
  • LSTM이 가장 정확하게 피크(고온)와 트로프(저온)를 재현
  • GRU는 LSTM보다 약간 과소평가되는 경향이 있다, 

Q. 7월 ~ 10월까지 실제 기온을 모든 모델이 거의 추종하지 못한 이유? 

 

원인분석 1. MSE 손실함수의 평탄화 경향

- MSE를 최소화할 때 모델은 극단값(30℃ 이상)보다 평범한 값(20~25℃) 쪽으로 손실을 줄이는 방향으로 “안전하게” 예측하려고 한다.

- 여름 피크 구간에서는 고온을 과소 예측하고, 가을 초입의 서서히 떨어지는 패턴도 잘 따라가지 못하는 “평균 회귀(regression to the mean)” 현상이 나타남.

 

원인분석 2. 외부 입력 부재 

- 단순히 온도만 사용하면, 예컨대 장마철 습도 급등·폭염·찬 대륙 고기압 등 기상 환경 변화를 반영하기 어려움 

- 습도·바람·일조량·강수량 같은 외부 피처를 넣거나, 달력 변수(월, 요일)·Fourier 계절성 항을 추가하면 7~10월 변화가 더 잘 잡힐 것으로 예상됨. 

 

(3) 시계열 의존성 학습 

- SimpleRNN은 긴 시퀀스(30일) 정보를 학습할수록 소실 기울기(vanishing gradient) 문제로 과거 정보를 충분히 활용하지 못해,

- LSTM/GRU의 게이트 구조는 과거 30일치 히스토리를 더 잘 보존해 “갑작스러운 일교차”를 포착하는 데 유리하다. 

 

3. 실무적 시사점 

1. 모델 선택 

- 비교적 짧은 윈도우라도 장기 의존성을 요구하는 기온 예측에는 LSTM이 최적 

- GRU는 파라미터가 더 적으므로 딥러닝 리소스가 제한적일 때 대안으로 활용해보기 

 

2. 더 긴 윈도우 (년 주기 히스토리) 월, 일과 같은 계절성 인덱스를 추가 입력해보기 

 

3. MSE 대신 극단값에 강한 Huber Loss 사용해보기 

 

6. 코드 개선 및 발전 아아디어 

< 주요 개선 포인트 > 

1. 윈도우 연장 : 30일 -> 365일 

2. 계절성 인코딩 : dayofyear → sin/cos 피처 추가

3. Loss 함수: MSE 대신 극단값에 강한 Huber Loss

- Huber Loss : MSE와 MAE의 장점을 동시에 취하면서 극단값에 강건하게 설계된 손실함수 

     - 작은 오차 영역에선 이차함수로 부드러운 기울기 제공, 큰 오차 영역에선 일차함수로 완만한 기울기 제공 

4. 모델 깊이: 2개 LSTM 레이어 + Dropout

5. EarlyStopping: 검증 손실 기반 자동 학습 중단

 

* Google Colaboratory의 T4 GPU 가속 환경을 활용해 실행함. 

gpus = tf.config.list_physical_devices('GPU')
for gpu in gpus:
    tf.config.experimental.set_memory_growth(gpu, True)
# 1) 나눔폰트 설치
!sudo apt-get update -qq
!sudo apt-get install -y fonts-nanum
# 필요한 라이브러리를 불러옵니다.
import pandas as pd                                 # CSV 로딩 및 데이터 전처리
import numpy as np                                  # 수치 연산 라이브러리
import matplotlib.pyplot as plt                     # 데이터 시각화 라이브러리
from sklearn.preprocessing import MinMaxScaler      # 데이터를 0~1 사이로 정규화
from sklearn.metrics import mean_squared_error      # 모델 성능 평가 (RMSE 계산)
from tensorflow.keras.models import Sequential      # 신경망 모델을 순차적으로 구성
from tensorflow.keras.layers import LSTM, Dropout, Dense  # LSTM 레이어, 과적합 방지 Dropout, 최종 출력층
from tensorflow.keras.callbacks import EarlyStopping       # 과적합 방지 콜백 함수

# 2. CSV파일 데이터 로드 및 정제
df = pd.read_csv('assets/seoul_temp.csv', encoding='utf-8')       # CSV 데이터 불러오기
df['date'] = df['date'].astype(str).str.strip()                   # 날짜 열의 문자열 앞뒤 공백 제거
df['date'] = pd.to_datetime(df['date'], errors='coerce')          # 문자열을 날짜 형식으로 변환 (실패하면 NaT)
df = df.dropna(subset=['date', 'avg_temp'])                       # 날짜나 평균기온이 없는 행 제거
df = df.sort_values('date').reset_index(drop=True)                # 날짜순으로 오름차순 정렬

# 3. 날짜의 계절적 주기를 인코딩 (sin, cos 변환)
df['dayofyear'] = df['date'].dt.dayofyear                         # 연중 날짜(1~365)를 얻음
df['sin_doy'] = np.sin(2 * np.pi * df['dayofyear'] / 365)         # 주기적 특성 표현을 위해 사인 변환
df['cos_doy'] = np.cos(2 * np.pi * df['dayofyear'] / 365)         # 주기적 특성 표현을 위해 코사인 변환

# 4. 결측된 기온 데이터를 보간하고 정규화
df['avg_temp'] = df['avg_temp'].interpolate()                     # 결측된 기온 데이터를 선형 보간하여 채움
scaler = MinMaxScaler()                                            # 데이터 정규화 도구 생성
df['temp_scaled'] = scaler.fit_transform(df[['avg_temp']])         # 평균기온을 0~1 사이로 정규화

# 5. 시계열 데이터 생성을 위한 슬라이딩 윈도우 (길이=365, 특징=3개)
window = 365                                                       # 입력으로 쓸 기간을 365일(1년)로 설정
features = ['temp_scaled', 'sin_doy', 'cos_doy']                   # 사용할 특징(피처)
data = df[features].values                                         # 지정된 피처를 numpy 배열로 변환
X, y = [], []
for i in range(window, len(data)):                                 # 슬라이딩 윈도우 방식으로 데이터 생성
    X.append(data[i-window:i])                                     # 과거 365일 데이터를 입력으로 추가
    y.append(data[i, 0])                                           # 366일째 날의 정규화된 기온을 예측 타깃으로 추가
X = np.array(X)                                                    # 리스트를 numpy 배열로 변환
y = np.array(y)                                                    # 리스트를 numpy 배열로 변환

# 6. 데이터를 훈련용과 테스트용으로 분할 (8:2 비율)
split = int(len(X) * 0.8)
X_train, X_test = X[:split], X[split:]                             # 입력 데이터를 훈련/테스트로 분리
y_train, y_test = y[:split], y[split:]                             # 출력 데이터를 훈련/테스트로 분리

# 7. LSTM 모델 구성 (LSTM 2개층 + Dropout + Huber Loss + EarlyStopping)
model = Sequential()
model.add(LSTM(64, return_sequences=True, input_shape=(window, len(features))))  # 첫번째 LSTM층 (시퀀스 출력)
model.add(Dropout(0.2))                                                           # 20% 노드를 무작위로 제거하여 과적합 방지
model.add(LSTM(32))                                                               # 두번째 LSTM층 (단일 출력)
model.add(Dropout(0.2))                                                           # 추가적 과적합 방지
model.add(Dense(1))                                                               # 최종 출력층 (기온 예측)
model.compile(optimizer='adam', loss='huber')                                     # 옵티마이저 adam, 손실함수 huber 설정

early_stop = EarlyStopping(
    monitor='val_loss',                                                            # 검증 손실이 더 이상 개선되지 않으면 멈춤
    patience=10,                                                                   # 10회까지 기다림
    restore_best_weights=True                                                      # 최적 가중치로 복원
)

history = model.fit(
    X_train, y_train, epochs=100, batch_size=32,
    validation_split=0.2, callbacks=[early_stop], verbose=1                        # 훈련 및 과적합 방지 조건 설정
)

# 8. 테스트 데이터 예측 후 역정규화
y_pred_scaled = model.predict(X_test).flatten()                                    # 모델이 예측한 스케일링된 기온값
y_pred = scaler.inverse_transform(y_pred_scaled.reshape(-1,1)).flatten()           # 실제 기온 단위로 역정규화
y_true = scaler.inverse_transform(y_test.reshape(-1,1)).flatten()                  # 실제 기온값도 역정규화

# 9. 모델 평가(RMSE) 및 결과 시각화
rmse = np.sqrt(mean_squared_error(y_true, y_pred))                                 # 모델 성능 RMSE 계산
print(f"개선 모델 RMSE: {rmse:.3f} ℃")                                              # RMSE 출력

# 예측값과 실제값을 그래프로 시각화
plt.figure(figsize=(12,5))
test_dates = df['date'].values[-len(y_true):]                                      # 테스트 기간의 날짜 얻기
plt.plot(test_dates, y_true, label='실제 기온', color='black')                      # 실제 기온 그리기
plt.plot(test_dates, y_pred, label='개선 모델 예측', color='tab:orange')             # 예측 기온 그리기
plt.title('계절성 인코딩 + 긴 윈도우 LSTM 예측 결과')
plt.xlabel('날짜'); plt.ylabel('평균기온 (℃)')
plt.xticks(rotation=45); plt.legend(); plt.grid(); plt.tight_layout()
plt.show()

 

 

개선 LSTM 모델 RMSE: 2.194℃

 

1. 성능 개선 확인 

기존 2.493 -> 개선 2.194

약 12% 수준의 오차 감소 

 

2. 그래프 패턴 분석 

(1) 여름철 고온(7~8월) 피크

기존 모델은 고온 구간에서 ±2℃ 이상 과소 예측 경향이 있었으나, 개선 모델은 실제 곡선(검은선)에 더 밀착되어 30℃를 상회하는 고온 피크가 뭉개지지 않고 재현됨 

 

(2) 가을(9~10월) 완만한 하강

과거 30일만 본 모델은 갑작스러운 하강에 민감하지 못했으나, 연간 주기(365일) 윈도우+사인·코사인 계절성 피처 덕분에 9~10월 온도 변동의 완급 조절을 더 자연스럽게 포착

 

3. 배운 점 및 핵심 포인트 정리 

 

계절성 처리: 연간 주기를 명시적으로 인코딩하면 기온 예측력 대폭 개선

윈도우 크기: 시계열 창 길이가 짧으면 장기 패턴을 놓치고, 너무 길면 노이즈가 많아지므로 도메인에 맞춰 조정

손실함수 선택: MSE vs Huber 등 목적에 맞는 손실 선택이 결과에 큰 영향

모델 구조: 단일 층보다 다층·정규화 조합이 복합 패턴 학습에 유리

자동 중단: 검증 성능 기반 EarlyStopping으로 과적합 방지 및 학습 효율화

 

+ ) Q. Epoch 수 차이 때문에 모델 성능이 좋아진 건 아닐까?

베이스라인 모델은 20, 개선 모델은 100 

개선 모델은 모델 복잡도 증가로 20 Epoch만으로는 충분히 수렴하지 못하고 과소적합 될 수 있으므로, 베이스라인 모델을 100 Epoch로 수정 후 재실행 

모델별 RMSE (Root Mean Squared Error)

RNN: 2.691
LSTM: 2.362
GRU: 2.351

 

 

진짜 구조 개선이 성능을 끌어올리는 것을 확인함.