Projetos
Passo a passo · Deep Learning · Tarefa 03

Previsão de Demanda em Mobilidade Urbana com RNNs

Vamos prever quantas bicicletas serão alugadas na próxima hora usando três redes com "memória": SimpleRNN, LSTM e GRU. Entenda por que séries temporais precisam de arquiteturas especiais.

Visão geral

Por que RNN para séries temporais?

Imagina que você quer prever o movimento na ciclovia às 17h. Você precisaria saber: é segunda ou sexta? Está chovendo? Mas também: como esteve o movimento nas últimas 24 horas?

Uma rede MLP analisa cada ponto de forma isolada. Uma RNN tem memória — ela leva em conta o contexto das horas anteriores. É como se dissesse: "as últimas 24h tiveram esse padrão, então a próxima hora provavelmente será assim."

Vamos comparar 3 arquiteturas recorrentes para o mesmo problema e medir qual performa melhor.

Ferramentas

O que você vai precisar

  • Python 3.10+
  • TensorFlow / Keras — para construir as RNNs.
  • Pandas / NumPy — para manipular a série temporal.
  • Scikit-learn — para normalização (MinMaxScaler) e métricas.
  • Matplotlib — para o gráfico real vs. previsto.

Dataset: Bike Sharing Dataset (UCI) — hour.csv com 17.379 registros horários do Capital Bikeshare, Washington D.C. (2011–2012).

Passo 1

Configurar e carregar os dados

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.preprocessing import MinMaxScaler
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers

np.random.seed(42)
tf.random.set_seed(42)

# Hiperparâmetros — centralizados para fácil ajuste
WINDOW_SIZE = 24    # horas do passado que o modelo vai "ver"
FEATURES    = ['hr', 'temp', 'hum', 'windspeed',
               'season', 'weekday', 'workingday', 'weathersit', 'cnt']
TARGET_COL  = 'cnt'
TARGET_IDX  = FEATURES.index(TARGET_COL)   # posição de cnt na lista
N_FEATURES  = len(FEATURES)   # 9 variáveis

UNITS      = 64     # neurônios na camada recorrente
EPOCHS     = 30
BATCH_SIZE = 64
PATIENCE   = 5      # épocas sem melhoria antes do early stopping
df = pd.read_csv('dataset/hour.csv', parse_dates=['dteday'])

print(f'Shape: {df.shape[0]} linhas x {df.shape[1]} colunas')
print(f'Periodo: {df["dteday"].min().date()} -> {df["dteday"].max().date()}')
# Shape: 17379 linhas x 17 colunas
# Periodo: 2011-01-01 -> 2012-12-31

O que descobrimos? A série cobre 2 anos completos (17.379 horas). WINDOW_SIZE=24 significa que para prever as 17h, o modelo vai olhar de 17h do dia anterior até as 16h de hoje.

Passo 2

Normalizar e criar janelas deslizantes

Redes neurais funcionam melhor com valores entre 0 e 1. E precisamos transformar a série em pares (janela de 24h → próximo valor).

dados_brutos = df[FEATURES].values.astype(np.float32)

# Dividir cronologicamente — não misturar futuro com passado!
n_janelas    = len(dados_brutos) - WINDOW_SIZE
n_treino     = int(n_janelas * 0.80)
indice_corte = WINDOW_SIZE + n_treino

# Ajusta o scaler APENAS no treino (evita data leakage — ver o futuro)
scaler = MinMaxScaler(feature_range=(0, 1))
scaler.fit(dados_brutos[:indice_corte])
dados_norm = scaler.transform(dados_brutos)
def criar_janelas(dados, tamanho_janela, indice_alvo):
    """
    Janela deslizante:
    X = últimas `tamanho_janela` horas (shape: n, 24, 9)
    y = próximo valor de cnt (shape: n,)
    """
    X_list, y_list = [], []
    for i in range(len(dados) - tamanho_janela):
        X_list.append(dados[i : i + tamanho_janela])
        y_list.append(dados[i + tamanho_janela, indice_alvo])
    return np.array(X_list), np.array(y_list)

X_all, y_all = criar_janelas(dados_norm, WINDOW_SIZE, TARGET_IDX)
X_train, X_test = X_all[:n_treino], X_all[n_treino:]
y_train, y_test = y_all[:n_treino], y_all[n_treino:]

print(f'X_train: {X_train.shape}')  # (n, 24, 9)

O que descobrimos? Cada amostra de X tem shape (24, 9) — 24 horas, 9 variáveis. A divisão cronológica é fundamental: não podemos usar dados do futuro para treinar.

Passo 3

Construir os três modelos recorrentes

def construir_modelo(camada_rnn, unidades, n_features):
    return keras.Sequential([
        keras.Input(shape=(WINDOW_SIZE, n_features)),
        camada_rnn(unidades, return_sequences=False),
        layers.Dense(32, activation='relu'),
        layers.Dense(1)    # saída: 1 valor (cnt da próxima hora)
    ])

# SimpleRNN: recorrência básica
# Problema: gradientes somem em sequências longas (vanishing gradient)
modelo_srnn = construir_modelo(layers.SimpleRNN, UNITS, N_FEATURES)

# LSTM: 3 portas — entrada, saída e esquecimento
# Permite lembrar padrões de longo prazo (ex: padrão semanal)
modelo_lstm = construir_modelo(layers.LSTM, UNITS, N_FEATURES)

# GRU: 2 portas — mais simples que LSTM, mas igualmente eficaz na prática
modelo_gru  = construir_modelo(layers.GRU, UNITS, N_FEATURES)
early_stop = keras.callbacks.EarlyStopping(
    monitor='val_loss', patience=PATIENCE, restore_best_weights=True
)

def treinar(modelo, nome):
    modelo.compile(optimizer='adam', loss='mse', metrics=['mae'])
    hist = modelo.fit(
        X_train, y_train,
        epochs=EPOCHS,
        batch_size=BATCH_SIZE,
        validation_split=0.15,
        callbacks=[early_stop],
        verbose=0
    )
    print(f'{nome}: {len(hist.history["loss"])} épocas')
    return hist

hist_srnn = treinar(modelo_srnn, 'SimpleRNN')
hist_lstm = treinar(modelo_lstm, 'LSTM')
hist_gru  = treinar(modelo_gru,  'GRU')

O que descobrimos? LSTM e GRU convergiram mais rápido. As "portas" de memória evitam o problema de gradiente que some no SimpleRNN — o modelo consegue lembrar padrões de dias atrás.

Passo 4

Avaliar e comparar

def avaliar(modelo, nome, X_test, y_test):
    y_pred_norm = modelo.predict(X_test, verbose=0).flatten()

    # Desnormalizamos: voltamos para escala real (número de bicicletas)
    dummy = np.zeros((len(y_pred_norm), N_FEATURES))
    dummy[:, TARGET_IDX] = y_pred_norm
    y_pred_real = scaler.inverse_transform(dummy)[:, TARGET_IDX]

    dummy[:, TARGET_IDX] = y_test
    y_true_real = scaler.inverse_transform(dummy)[:, TARGET_IDX]

    mae  = mean_absolute_error(y_true_real, y_pred_real)
    rmse = np.sqrt(mean_squared_error(y_true_real, y_pred_real))
    r2   = r2_score(y_true_real, y_pred_real)
    print(f'{nome:10} → MAE={mae:.1f}  RMSE={rmse:.1f}  R²={r2:.3f}')
    return y_true_real, y_pred_real

y_true, y_srnn = avaliar(modelo_srnn, 'SimpleRNN', X_test, y_test)
_,      y_lstm = avaliar(modelo_lstm, 'LSTM',      X_test, y_test)
_,      y_gru  = avaliar(modelo_gru,  'GRU',       X_test, y_test)
# Gráfico: Real vs. Previsto para os 3 modelos
n_plot = 200   # primeiras 200 horas do teste

fig, axes = plt.subplots(3, 1, figsize=(14, 10))
for ax, y_pred, nome in zip(axes,
                             [y_srnn, y_lstm, y_gru],
                             ['SimpleRNN', 'LSTM', 'GRU']):
    ax.plot(y_true[:n_plot], label='Real',   color='steelblue')
    ax.plot(y_pred[:n_plot], label=nome,     color='tomato', alpha=0.8)
    ax.legend()
    ax.set_title(f'Real vs. Previsto — {nome}')

plt.tight_layout()
plt.savefig('real_vs_previsto_todos.png', dpi=150)
plt.show()

O que descobrimos? LSTM e GRU tiveram MAE e RMSE menores que o SimpleRNN. O GRU chegou a desempenho similar ao LSTM com menos parâmetros e treino mais rápido — um trade-off relevante para produção.

Recursos

Código completo e referências

github.com/Jair-pc/puc-minas-arquiteturas_de_deep_learning

Dataset: Bike Sharing Dataset (UCI Machine Learning Repository) — Capital Bikeshare, Washington D.C.

Anterior: CNN Próximo: Transformers