Séries Temporais e Previsão com Prophet e statsmodels

Séries Temporais e Previsão com Prophet e statsmodels

Séries temporais com pandas 3, statsmodels e Prophet: resample, decomposição, ADF e KPSS, SARIMA e comparação com linha de base — e os erros que enganam a avaliação, como a média móvel que enxerga o mês previsto e os feriados que somem numa série mensal.
Python

• • 23 min de leitura

Séries temporais estão em toda parte — preços de ações, temperatura diária, vendas mensais, tráfego de um site, consumo de energia elétrica. Diferente de dados tabulares convencionais, séries temporais têm uma dimensão fundamental: o tempo. A ordem das observações importa, e padrões como tendência, sazonalidade e ciclos precisam ser identificados e modelados. Python tem ferramentas excelentes para isso — desde a análise exploratória até modelos de previsão de produção.

Instalação

pip install statsmodels prophet pandas numpy matplotlib plotly scikit-learn

O scikit-learn entra pelas métricas de erro (mean_absolute_error e mean_squared_error) usadas para comparar os modelos. Os exemplos foram conferidos com pandas 3.0, statsmodels 0.15 e Prophet 1.4. Os apelidos de frequência mudaram no pandas 2.2 e endureceram no 3.0: "M" e "H" hoje levantam ValueError, e o certo é "ME" (fim de mês) e "h" (hora). Código antigo que usa as formas velhas quebra na atualização.

Criando e Manipulando Séries Temporais com pandas

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from datetime import datetime


# Criando série temporal sintética
np.random.seed(42)
datas  = pd.date_range(start="2021-01-01", end="2024-12-31", freq="D")
n      = len(datas)

# Componentes da série
tendencia     = np.linspace(100, 200, n)
sazonalidade  = 20 * np.sin(2 * np.pi * np.arange(n) / 365.25)
sazon_semanal = 10 * np.sin(2 * np.pi * np.arange(n) / 7)
ruido         = np.random.normal(0, 5, n)

valores = tendencia + sazonalidade + sazon_semanal + ruido

# Criando DataFrame com DatetimeIndex
df = pd.DataFrame({"valor": valores}, index=datas)
df.index.name = "data"

print(df.head())
print(f"\nPeríodo: {df.index.min()} → {df.index.max()}")
print(f"Observações: {len(df)}")
print(f"\nEstatísticas:\n{df.describe().round(2)}")

Operações com DatetimeIndex

# Resample — agregação por período
mensal   = df.resample("ME").mean()       # média mensal
semanal  = df.resample("W").sum()         # soma semanal
trimestral = df.resample("QE").agg({
    "valor": ["mean", "min", "max", "std"]
})

print("=== Médias Mensais ===")
print(mensal.head(6).round(2))

# Frequências úteis de resample
# D   — diário
# W   — semanal
# ME  — fim de mês
# MS  — início de mês
# QE  — fim de trimestre
# YE  — fim de ano
# h   — horário (minúsculo: "H" dá erro no pandas 3)
# 15min — 15 minutos

# Seleção por período
# Com .loc: df["2024-01"] procura uma COLUNA com esse nome
# e levanta KeyError desde o pandas 2.0
jan_2024   = df.loc["2024-01"]
primeiro_t = df.loc["2024-01":"2024-03"]
ultimo_ano = df.loc["2024"]

# Atributos de data no índice
df["ano"]       = df.index.year
df["mes"]       = df.index.month
df["dia"]       = df.index.day
df["dia_semana"] = df.index.dayofweek   # 0=segunda, 6=domingo
df["semana"]    = df.index.isocalendar().week.astype(int)
df["trimestre"] = df.index.quarter

# Estatísticas por componente temporal
media_por_mes    = df.groupby("mes")["valor"].mean()
media_por_dia_sem = df.groupby("dia_semana")["valor"].mean()

print("\n=== Média por Mês ===")
nomes_meses = ["Jan","Fev","Mar","Abr","Mai","Jun",
               "Jul","Ago","Set","Out","Nov","Dez"]
for mes, media in media_por_mes.items():
    print(f"  {nomes_meses[mes-1]:4}: {media:.2f}")

Análise Exploratória de Séries Temporais

import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec


def plot_serie_completa(df: pd.DataFrame, coluna: str = "valor"):
    fig = plt.figure(figsize=(16, 10))
    gs  = gridspec.GridSpec(3, 2, figure=fig)

    # Série completa
    ax1 = fig.add_subplot(gs[0, :])
    ax1.plot(df.index, df[coluna], linewidth=0.8, color="#2E75B6", alpha=0.8)
    ax1.set_title("Série Temporal Completa")
    ax1.set_ylabel("Valor")
    ax1.grid(alpha=0.3)

    # Média móvel
    mm_30  = df[coluna].rolling(30).mean()
    mm_90  = df[coluna].rolling(90).mean()
    ax1.plot(df.index, mm_30, color="orange", linewidth=1.5, label="MM 30d")
    ax1.plot(df.index, mm_90, color="red",    linewidth=1.5, label="MM 90d")
    ax1.legend()

    # Sazonalidade anual
    ax2 = fig.add_subplot(gs[1, 0])
    media_mensal = df.groupby("mes")[coluna].mean()
    ax2.bar(nomes_meses, media_mensal.values, color="#2E75B6", alpha=0.8)
    ax2.set_title("Padrão Mensal Médio")
    ax2.set_ylabel("Valor Médio")

    # Sazonalidade semanal
    ax3 = fig.add_subplot(gs[1, 1])
    nomes_dias = ["Seg", "Ter", "Qua", "Qui", "Sex", "Sáb", "Dom"]
    media_diaria = df.groupby("dia_semana")[coluna].mean()
    ax3.bar(nomes_dias, media_diaria.values, color="#375623", alpha=0.8)
    ax3.set_title("Padrão Semanal Médio")

    # Distribuição dos valores
    ax4 = fig.add_subplot(gs[2, 0])
    df[coluna].hist(bins=50, ax=ax4, color="#2E75B6", alpha=0.8)
    ax4.set_title("Distribuição dos Valores")

    # Autocorrelação
    ax5 = fig.add_subplot(gs[2, 1])
    pd.plotting.autocorrelation_plot(df[coluna].dropna(), ax=ax5)
    ax5.set_title("Autocorrelação")
    ax5.set_xlim(0, 60)

    plt.suptitle("Análise Exploratória da Série Temporal",
                 fontsize=14, fontweight="bold")
    plt.tight_layout()
    plt.savefig("eda_serie_temporal.png", dpi=120)
    plt.show()


plot_serie_completa(df)

Decomposição da Série Temporal

from statsmodels.tsa.seasonal import seasonal_decompose, STL


def decompor_serie(serie: pd.Series, modelo: str = "additive", periodo: int = 365):
    """
    Decompõe a série em tendência, sazonalidade e resíduo.
    modelo='additive': Y = T + S + R
    modelo='multiplicative': Y = T × S × R
    """
    decomposicao = seasonal_decompose(
        serie.dropna(),
        model=modelo,
        period=periodo,
        extrapolate_trend="period"   # "freq" está obsoleto no statsmodels 0.15
    )

    fig, axes = plt.subplots(4, 1, figsize=(14, 10), sharex=True)

    componentes = [
        (serie,                      "Original",    "#2E75B6"),
        (decomposicao.trend,         "Tendência",   "#C00000"),
        (decomposicao.seasonal,      "Sazonalidade","#375623"),
        (decomposicao.resid,         "Resíduo",     "#888888"),
    ]

    for ax, (comp, titulo, cor) in zip(axes, componentes):
        ax.plot(comp, color=cor, linewidth=0.8)
        ax.set_ylabel(titulo)
        ax.grid(alpha=0.3)

    axes[0].set_title(f"Decomposição {modelo.capitalize()} — Período={periodo}d")
    plt.tight_layout()
    plt.savefig("decomposicao.png", dpi=120)
    plt.show()

    return decomposicao


# Usando dados mensais para decomposição mais clara
serie_mensal = df["valor"].resample("ME").mean()
decomp = decompor_serie(serie_mensal, modelo="additive", periodo=12)


# STL — mais robusto que seasonal_decompose
stl    = STL(serie_mensal, period=12, robust=True)
res_stl = stl.fit()

fig = res_stl.plot()
fig.set_size_inches(14, 8)
plt.savefig("stl_decomposicao.png", dpi=120)
plt.show()

Teste de Estacionariedade

from statsmodels.tsa.stattools import adfuller, kpss


def testar_estacionariedade(serie: pd.Series, nome: str = "Série"):
    """
    ADF — H0: série tem raiz unitária (não estacionária)
    KPSS — H0: série é estacionária
    """
    print(f"\n{'='*50}")
    print(f"Testes de Estacionariedade — {nome}")
    print('='*50)

    # Augmented Dickey-Fuller
    adf_result = adfuller(serie.dropna(), autolag="AIC")
    print(f"\nAugmented Dickey-Fuller:")
    print(f"  Estatística:  {adf_result[0]:.4f}")
    print(f"  p-valor:      {adf_result[1]:.4f}")
    print(f"  Lag:          {adf_result[2]}")
    print(f"  Estacionária: {'Sim' if adf_result[1] < 0.05 else 'Não'} "
          f"(α=0.05)")

    # KPSS
    kpss_result = kpss(serie.dropna(), regression="c")
    print(f"\nKPSS:")
    print(f"  Estatística:  {kpss_result[0]:.4f}")
    print(f"  p-valor:      {kpss_result[1]:.4f}")
    print(f"  Estacionária: {'Sim' if kpss_result[1] > 0.05 else 'Não'} "
          f"(α=0.05)")

    return adf_result[1] < 0.05


# Testando a série original e diferenciada
eh_estacionaria = testar_estacionariedade(serie_mensal, "Série Original")

if not eh_estacionaria:
    serie_diff = serie_mensal.diff().dropna()
    testar_estacionariedade(serie_diff, "Série Diferenciada (d=1)")

ARIMA / SARIMA com statsmodels

from statsmodels.tsa.arima.model import ARIMA
from statsmodels.tsa.statespace.sarimax import SARIMAX
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
from sklearn.metrics import mean_absolute_error, mean_squared_error
import warnings
warnings.filterwarnings("ignore")


def plot_acf_pacf(serie: pd.Series, lags: int = 40):
    """Plota ACF e PACF para identificar parâmetros p e q."""
    fig, axes = plt.subplots(1, 2, figsize=(14, 4))
    plot_acf(serie.dropna(),  lags=lags, ax=axes[0], title="ACF")
    plot_pacf(serie.dropna(), lags=lags, ax=axes[1], title="PACF")
    plt.tight_layout()
    plt.savefig("acf_pacf.png", dpi=120)
    plt.show()


# 47 meses diferenciados: a PACF aceita no máximo metade da amostra
# (lags=40 levanta ValueError; o limite aqui é 23)
plot_acf_pacf(serie_mensal.diff().dropna(), lags=18)


def treinar_sarima(serie: pd.Series, ordem=(1,1,1), ordem_sazonal=(1,1,1,12)):
    """
    SARIMA(p,d,q)(P,D,Q,s)
    p,d,q: parâmetros AR, diferenciação, MA
    P,D,Q: parâmetros sazonais
    s:     período sazonal
    """
    # Divisão treino/teste
    n_teste  = 12   # últimos 12 meses para teste
    treino   = serie[:-n_teste]
    teste    = serie[-n_teste:]

    # Treinamento
    modelo = SARIMAX(
        treino,
        order=ordem,
        seasonal_order=ordem_sazonal,
        enforce_stationarity=False,
        enforce_invertibility=False
    )
    resultado = modelo.fit(disp=False)

    print(resultado.summary())

    # Previsão no período de teste
    previsoes = resultado.forecast(steps=n_teste)

    # Métricas
    mae  = mean_absolute_error(teste, previsoes)
    rmse = np.sqrt(mean_squared_error(teste, previsoes))
    mape = np.mean(np.abs((teste - previsoes) / teste)) * 100

    print(f"\nMétricas no conjunto de teste:")
    print(f"  MAE:  {mae:.2f}")
    print(f"  RMSE: {rmse:.2f}")
    print(f"  MAPE: {mape:.2f}%")

    # Visualização
    fig, ax = plt.subplots(figsize=(14, 5))
    treino.plot(ax=ax, label="Treino",   color="#2E75B6")
    teste.plot(ax=ax,  label="Real",     color="#375623")
    previsoes.plot(ax=ax, label="Previsão", color="#C00000",
                   linestyle="--", marker="o")

    # Intervalo de confiança para previsão futura
    prev_futuro = resultado.get_forecast(steps=n_teste + 12)
    ic = prev_futuro.conf_int()
    ax.fill_between(ic.index, ic.iloc[:, 0], ic.iloc[:, 1],
                    alpha=0.2, color="#C00000", label="IC 95%")

    ax.set_title(f"SARIMA{ordem}{ordem_sazonal}")
    ax.legend()
    ax.grid(alpha=0.3)
    plt.tight_layout()
    plt.savefig("sarima_previsao.png", dpi=120)
    plt.show()

    return resultado, previsoes


modelo_sarima, prev_sarima = treinar_sarima(
    serie_mensal,
    ordem=(1, 1, 1),
    ordem_sazonal=(1, 1, 0, 12)
)

Prophet: Previsão Moderna do Meta

Prophet é a biblioteca de previsão do Meta. Ele modela tendência e sazonalidades como curvas somadas, tolera datas faltando e aceita feriados como regressores — desde que a frequência dos dados permita enxergá-los, o que o exemplo abaixo mostra com números:

from prophet import Prophet
from prophet.plot import plot_plotly, plot_components_plotly
import pandas as pd
import numpy as np


# Prophet exige colunas 'ds' (datetime) e 'y' (valor)
df_prophet = serie_mensal.reset_index()
df_prophet.columns = ["ds", "y"]

print(df_prophet.head())
print(f"Shape: {df_prophet.shape}")


def treinar_prophet(
    df: pd.DataFrame,
    periodos: int = 12,
    intervalo_confianca: float = 0.95,
    sazonalidade_anual: bool = True,
    sazonalidade_semanal: bool = False
) -> tuple:
    """Treina modelo Prophet e gera previsão."""

    # Divisão treino/teste
    n_teste = 12
    treino  = df[:-n_teste]
    teste   = df[-n_teste:]

    # Instanciando e configurando
    modelo = Prophet(
        interval_width=intervalo_confianca,
        yearly_seasonality=sazonalidade_anual,
        weekly_seasonality=sazonalidade_semanal,
        daily_seasonality=False,
        seasonality_mode="additive",
        changepoint_prior_scale=0.05,    # flexibilidade da tendência
        seasonality_prior_scale=10.0,    # força da sazonalidade
    )

    # Sem feriados e sem sazonalidade "mensal": com um ponto por mês,
    # nenhum dos dois pode ser aprendido (ver o texto depois do bloco)

    # Treinamento
    modelo.fit(treino)

    # DataFrame futuro
    futuro = modelo.make_future_dataframe(
        periods=periodos + n_teste,
        freq="ME"
    )

    # Previsão
    previsao = modelo.predict(futuro)

    # Métricas no período de teste
    prev_teste = previsao[-n_teste - periodos:-periodos][["ds", "yhat",
                                                           "yhat_lower",
                                                           "yhat_upper"]]
    valores_reais = teste["y"].values

    mae  = mean_absolute_error(valores_reais, prev_teste["yhat"])
    rmse = np.sqrt(mean_squared_error(valores_reais, prev_teste["yhat"]))
    mape = np.mean(np.abs((valores_reais - prev_teste["yhat"]) /
                           valores_reais)) * 100

    print(f"\nMétricas Prophet no teste:")
    print(f"  MAE:  {mae:.2f}")
    print(f"  RMSE: {rmse:.2f}")
    print(f"  MAPE: {mape:.2f}%")

    return modelo, previsao, prev_teste


modelo_prophet, previsao, prev_teste = treinar_prophet(
    df_prophet,
    periodos=24
)

# Visualizações do Prophet
fig1 = modelo_prophet.plot(previsao, figsize=(14, 5))
fig1.suptitle("Previsão Prophet", fontsize=14)
plt.savefig("prophet_previsao.png", dpi=120)
plt.show()

# Componentes da previsão
fig2 = modelo_prophet.plot_components(previsao, figsize=(14, 8))
plt.savefig("prophet_componentes.png", dpi=120)
plt.show()

A primeira versão deste exemplo acrescentava os feriados brasileiros e uma sazonalidade "mensal" de 30,5 dias, e as duas coisas eram piores que inúteis com dados mensais. Os feriados só entram no modelo nas datas exatas em que caem, e a série tem um ponto por mês, sempre no último dia: dos 37 feriados nacionais de 2021 a 2024, nenhum cai num fim de mês, e o componente de feriados sai zero. Já uma sazonalidade de 30,5 dias, amostrada uma vez a cada 30 ou 31 dias, não tem como ser vista — o modelo ajusta ali um padrão fantasma. Medido: o componente "mensal" chegou a 78 numa série que vai de 100 a 200, e o erro médio no teste subiu de 1,15 para 6,85. A regra geral: só se modela um ciclo que a frequência dos dados consegue mostrar, com pelo menos dois pontos por ciclo. Feriados e sazonalidade semanal pedem dados diários.

Comparando Modelos

from sklearn.metrics import mean_absolute_error, mean_squared_error
import pandas as pd


def comparar_modelos(resultados: dict) -> pd.DataFrame:
    """Compara métricas de múltiplos modelos."""
    linhas = []
    for nome, (real, previsto) in resultados.items():
        mae  = mean_absolute_error(real, previsto)
        rmse = np.sqrt(mean_squared_error(real, previsto))
        mape = np.mean(np.abs((real - previsto) / real)) * 100
        linhas.append({
            "Modelo": nome,
            "MAE":    round(mae,  2),
            "RMSE":   round(rmse, 2),
            "MAPE %": round(mape, 2)
        })
    df_comp = pd.DataFrame(linhas).set_index("Modelo")
    return df_comp.sort_values("RMSE")


# Modelo Naive (baseline) — repete o último valor
n_teste   = 12
real_test = serie_mensal[-n_teste:].values
naive_prev = np.repeat(serie_mensal.iloc[-n_teste - 1], n_teste)   # .iloc: posição

# Média Móvel simples
# Média dos 6 últimos meses do TREINO, repetida: só usa o que se sabia
# no momento da previsão, como os outros modelos
mm_prev = np.repeat(serie_mensal.iloc[-n_teste - 6:-n_teste].mean(), n_teste)

# Coletando resultados
resultados_modelos = {
    "Naive (baseline)":    (real_test, naive_prev),
    "Média Móvel 6m":      (real_test, mm_prev),
    "SARIMA(1,1,1)(1,1,0,12)": (real_test, prev_sarima.values),
}

# Adicionando Prophet se disponível
prev_prophet_vals = prev_teste["yhat"].values[:n_teste]
resultados_modelos["Prophet"] = (real_test, prev_prophet_vals)

df_comparacao = comparar_modelos(resultados_modelos)
print("=== Comparação de Modelos ===")
print(df_comparacao.to_string())


# Visualização comparativa
fig, ax = plt.subplots(figsize=(14, 6))
datas_teste = serie_mensal[-n_teste:].index

ax.plot(datas_teste, real_test, label="Real", color="black",
        linewidth=2, marker="o")
ax.plot(datas_teste, naive_prev, label="Naive",
        color="gray", linestyle=":")
ax.plot(datas_teste, mm_prev, label="Média Móvel 6m",
        color="orange", linestyle="--")
ax.plot(datas_teste, prev_sarima.values, label="SARIMA",
        color="#2E75B6", linestyle="--", marker="s")
ax.plot(datas_teste, prev_prophet_vals, label="Prophet",
        color="#C00000", linestyle="--", marker="^")

ax.set_title("Comparação de Modelos de Previsão")
ax.legend()
ax.grid(alpha=0.3)
plt.tight_layout()
plt.savefig("comparacao_modelos.png", dpi=120)
plt.show()

Medido na série sintética, em erro absoluto médio: Prophet 1,15, SARIMA 1,56, naive 18,67 e média móvel 31,39. Na primeira versão do artigo, a média móvel era calculada com rolling(6).mean() sobre a série inteira e dava 13,75 — melhor que o naive — porque cada "previsão" incluía o próprio valor real do mês previsto. É o vazamento de dados mais comum em séries temporais: todo número usado para prever o mês t precisa estar disponível antes de t. Dois outros cuidados: serie[-13] numa série com índice de datas levanta KeyError no pandas 3 (o inteiro é tratado como rótulo, e a posição pede .iloc), e um único corte treino/teste é pouca evidência — a validação cruzada em janelas deslizantes, com TimeSeriesSplit do scikit-learn ou cross_validation do próprio Prophet, repete a comparação em vários pontos da história.

Exemplo Completo: Pipeline de Previsão de Demanda

import pandas as pd
import numpy as np
from prophet import Prophet
from sklearn.metrics import mean_absolute_error
import json
from pathlib import Path
from dataclasses import dataclass
from typing import Optional


@dataclass
class ResultadoPrevisao:
    modelo:        str
    mae:           float
    rmse:          float
    mape:          float
    previsoes:     pd.DataFrame
    modelo_obj:    object


class PipelinePrevisao:
    """Pipeline completo de previsão de séries temporais."""

    def __init__(self, nome: str):
        self.nome    = nome
        self._dados  = None
        self._treino = None
        self._teste  = None

    def carregar(self, df: pd.DataFrame, col_data: str, col_valor: str,
                 freq: str = "ME") -> "PipelinePrevisao":
        serie = df.set_index(col_data)[col_valor]
        serie.index = pd.to_datetime(serie.index)
        self._dados  = serie.resample(freq).mean().dropna()
        print(f"Série carregada: {len(self._dados)} observações "
              f"({self._dados.index.min().date()} → "
              f"{self._dados.index.max().date()})")
        return self

    def dividir(self, n_teste: int = 12) -> "PipelinePrevisao":
        self._treino = self._dados[:-n_teste]
        self._teste  = self._dados[-n_teste:]
        print(f"Treino: {len(self._treino)} obs | Teste: {len(self._teste)} obs")
        return self

    def prever_prophet(self, periodos_futuros: int = 12) -> ResultadoPrevisao:
        df_treino = self._treino.reset_index()
        df_treino.columns = ["ds", "y"]

        modelo = Prophet(
            yearly_seasonality=True,
            weekly_seasonality=False,
            daily_seasonality=False,
            changepoint_prior_scale=0.05,
            interval_width=0.95,    # o padrão é 0.80, e o gráfico diz "IC 95%"
        )
        modelo.fit(df_treino)

        n_teste = len(self._teste)
        futuro  = modelo.make_future_dataframe(
            periods=n_teste + periodos_futuros, freq="ME"
        )
        previsao = modelo.predict(futuro)

        # Métricas no teste
        prev_teste = previsao.set_index("ds")["yhat"].reindex(
            self._teste.index
        )
        real  = self._teste.values
        pred  = prev_teste.values
        mae   = mean_absolute_error(real, pred)
        rmse  = np.sqrt(np.mean((real - pred) ** 2))
        mape  = np.mean(np.abs((real - pred) / real)) * 100

        print(f"\n[Prophet] MAE={mae:.2f} | RMSE={rmse:.2f} | MAPE={mape:.2f}%")

        return ResultadoPrevisao(
            modelo="Prophet",
            mae=round(mae, 2),
            rmse=round(rmse, 2),
            mape=round(mape, 2),
            previsoes=previsao[["ds", "yhat", "yhat_lower", "yhat_upper"]],
            modelo_obj=modelo
        )

    def plotar_resultado(self, resultado: ResultadoPrevisao):
        fig, ax = plt.subplots(figsize=(14, 6))

        # Dados históricos
        self._treino.plot(ax=ax, label="Treino", color="#2E75B6", linewidth=1)
        self._teste.plot(ax=ax,  label="Real",   color="#375623",
                         linewidth=2, marker="o")

        # Previsão
        prev = resultado.previsoes.set_index("ds")
        prev_periodo = prev.loc[self._teste.index[0]:]

        ax.plot(prev_periodo.index, prev_periodo["yhat"],
                color="#C00000", linestyle="--",
                linewidth=2, marker="^", label=resultado.modelo)

        ax.fill_between(
            prev_periodo.index,
            prev_periodo["yhat_lower"],
            prev_periodo["yhat_upper"],
            alpha=0.2, color="#C00000", label="IC 95%"
        )

        ax.set_title(
            f"Pipeline de Previsão — {self.nome}\n"
            f"MAE={resultado.mae:.2f} | MAPE={resultado.mape:.2f}%"
        )
        ax.legend()
        ax.grid(alpha=0.3)
        plt.tight_layout()
        plt.savefig(f"pipeline_{self.nome.lower()}.png", dpi=120)
        plt.show()

    def exportar(self, resultado: ResultadoPrevisao, pasta: str = "previsoes"):
        Path(pasta).mkdir(exist_ok=True)
        caminho = Path(pasta) / f"{self.nome}_previsao.csv"
        resultado.previsoes.to_csv(caminho, index=False)

        meta = {
            "serie":    self.nome,
            "modelo":   resultado.modelo,
            "mae":      resultado.mae,
            "rmse":     resultado.rmse,
            "mape":     resultado.mape,
        }
        with open(Path(pasta) / f"{self.nome}_meta.json", "w") as f:
            json.dump(meta, f, indent=2)

        print(f"Exportado: {caminho}")


# Usando o pipeline
df_input = df[["valor"]].reset_index()
df_input.columns = ["data", "valor"]

pipeline = PipelinePrevisao("vendas_mensais")
pipeline.carregar(df_input, "data", "valor")
pipeline.dividir(n_teste=12)

resultado = pipeline.prever_prophet(periodos_futuros=12)
pipeline.plotar_resultado(resultado)
pipeline.exportar(resultado)

Uma série temporal é uma tabela em que a ordem das linhas é informação. O pandas cuida da parte mecânica — índice de datas, resample, janelas móveis, seleção por período com .loc —, a decomposição separa tendência, sazonalidade e resíduo, os testes ADF e KPSS dizem se a série precisa ser diferenciada, e dois caminhos de modelagem ficam à mão: o SARIMA do statsmodels, com parâmetros escolhidos a partir da ACF e da PACF, e o Prophet, que ajusta tendência e sazonalidades como curvas somadas. As mesmas janelas e agrupamentos de Análise de Dados com Pandas Avançado e Plotly são a matéria-prima de tudo isso.

O que decide se uma previsão presta não é o modelo, é a disciplina da avaliação. Nenhum dado posterior ao momento da previsão pode entrar nela, e a média móvel deste artigo parecia melhor que o naive justamente porque enxergava o futuro. Nenhum componente deve ser modelado se a frequência da série não consegue mostrá-lo: feriados e ciclos de 30 dias numa série mensal só acrescentam ruído. E todo modelo precisa ser comparado com uma linha de base simples, porque é ela que diz se a complexidade está pagando o próprio custo.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um script de relatório, escrito em 2021, separa o mês de janeiro com jan = df["2024-01"], onde df tem um DatetimeIndex diário. Depois de uma atualização do ambiente, a linha passa a levantar KeyError: '2024-01'. Os dados são os mesmos. O que mudou?

Ver resposta

✓ Resposta: Mudou o significado dos colchetes. No DataFrame, df["texto"] seleciona uma coluna. O pandas antigo abria uma exceção: se o índice fosse de datas e o texto parecesse uma data, selecionava as linhas. O atalho foi descontinuado e removido no pandas 2.0, e hoje o pandas procura uma coluna chamada "2024-01", não acha e levanta KeyError. A forma certa é df.loc["2024-01"], que seleciona o mês inteiro pelo índice, assim como df.loc["2024"] seleciona o ano. O fatiamento df["2024-01":"2024-03"] continua funcionando, porque fatia com dois-pontos sempre foi sobre linhas — e é por isso que o erro pega o leitor de surpresa: das três linhas do exemplo, só duas quebram. Usar .loc nas três deixa a intenção explícita e não depende de regra especial.

Exercício 2

Na comparação de modelos da primeira versão do artigo, a "Média Móvel 6m" tinha erro médio de 13,75, contra 18,67 do naive. Alguém conclui que a média móvel é uma boa linha de base para a série. Quando a média é calculada só com os 6 últimos meses de treino, o erro vai para 31,39. Qual dos dois números vale, e por quê?

Ver resposta

✓ Resposta: Vale o 31,39. A versão original fazia serie.rolling(6).mean()[-12:] sobre a série inteira: a "previsão" de cada mês do teste era a média dos 6 meses terminando nele próprio, incluindo o valor real que devia prever e os meses de teste anteriores. É vazamento de dados, e o sinal típico dele é um modelo simples batendo modelos mais sofisticados. Mesmo com .shift(1), que tira o próprio mês, o erro fica em 17,40, porque a média continua usando os meses de teste já observados: é uma previsão de um passo à frente, enquanto SARIMA e Prophet preveem os 12 meses de uma vez, sem ver nenhum. A comparação justa exige o mesmo horizonte e a mesma informação para todos. A regra prática: antes de calcular qualquer métrica, pergunte, para cada número que entrou na previsão do mês t, se ele existia antes de t.

Exercício 3

Para "capturar variações dentro do mês", um analista acrescenta ao Prophet add_seasonality(name="mensal", period=30.5, fourier_order=5) e os feriados brasileiros. A série tem um valor por mês, no último dia. O erro médio no teste sobe de 1,15 para 6,85, e o gráfico de componentes mostra um termo "mensal" oscilando com amplitude de 78, numa série que vai de 100 a 200. O que aconteceu?

Ver resposta

✓ Resposta: O modelo tentou aprender um ciclo que os dados não conseguem mostrar. Uma sazonalidade de 30,5 dias, observada uma vez a cada 28 a 31 dias, cai sempre num ponto quase igual do ciclo; o que sobra da variação da duração dos meses é pouco e irregular. Com 5 termos de Fourier livres, o Prophet usa esse espaço para ajustar ruído do treino, e o padrão fantasma cresce até 78 e estraga a previsão. Os feriados falham por outro motivo: o Prophet só aplica o efeito na data exata do feriado, e dos 37 feriados nacionais de 2021 a 2024 nenhum cai num último dia de mês — o componente sai zero. É o princípio de Nyquist aplicado a séries: um ciclo precisa de pelo menos dois pontos por período para ser visto. Para estudar efeitos dentro do mês, é preciso a série diária; com a mensal, basta a sazonalidade anual.

Exercício 4

Com a série mensal diferenciada, de 47 pontos, a chamada plot_pacf(serie, lags=40) falha com ValueError: Can only compute partial correlations for lags up to 50% of the sample size. The requested nlags 40 must be < 23. Por que existe esse limite, e quantos meses de dados seriam necessários para olhar 40 lags?

Ver resposta

✓ Resposta: A autocorrelação parcial no lag k é estimada ajustando uma regressão com k termos defasados, e cada lag a mais consome observações e graus de liberdade. Com metade da amostra ou mais, a estimativa é dominada pelo ruído, e o statsmodels recusa em vez de desenhar barras sem sentido. Com 47 pontos, o limite é 23 (o artigo usa 18). Para 40 lags, seriam precisos mais de 80 pontos diferenciados, ou seja, uns 7 anos de dados mensais. Na prática, para escolher o SARIMA de uma série mensal, interessam os lags até 24 ou 36: os primeiros, para p e q, e os múltiplos de 12, para os termos sazonais. Pedir 40 lags de uma série curta é sintoma de parâmetro copiado de um exemplo com dados diários.

Exercício 5

No teste de estacionariedade da série diferenciada, o KPSS imprime "p-valor: 0.1000" e o statsmodels emite InterpolationWarning: ... The actual p-value is greater than the p-value returned. Na série original, o KPSS imprime "p-valor: 0.0100", com o aviso de que o valor real é menor. O código do artigo esconde avisos com warnings.filterwarnings("ignore"). O que esses p-valores redondos querem dizer?

Ver resposta

✓ Resposta: Que são limites, não medidas. O KPSS não tem fórmula fechada para o p-valor: o statsmodels interpola numa tabela de valores críticos que vai de 0,01 a 0,10. Quando a estatística cai fora da tabela, a função devolve a borda e avisa em qual direção está o valor real. Na diferenciada, a estatística 0,0297 fica abaixo do menor valor crítico, e o p-valor real é maior que 0,10; na original, 0,9238 fica acima do maior, e o p-valor real é menor que 0,01. As conclusões ("estacionária" e "não estacionária") continuam certas, porque as duas bordas estão do lado certo de 0,05, mas nenhum dos dois números pode ser relatado como p-valor exato. Silenciar todos os avisos com filterwarnings("ignore") apaga justamente essa informação. Melhor filtrar só a categoria que incomoda, com warnings.filterwarnings("ignore", category=ConvergenceWarning), por exemplo.

Comentários

Mais em Python

Decoradores e Metaprogramação
Decoradores e Metaprogramação

Decoradores, closures e metaprogramação em Python, do açúcar sintático ao que…

WebSockets e Comunicação em Tempo Real em Python
WebSockets e Comunicação em Tempo Real em Python

WebSockets e SSE com FastAPI, do eco ao chat e às notificações, com as falhas…

Automação com Python: arquivos, pastas e sistema operacional
Automação com Python: arquivos, pastas e sistema operacional

Python para automatizar arquivos e o sistema: pathlib, shutil, subprocess…