Pular para conteúdo

16. Séries temporais

Uma série temporal é uma sequência de medições no tempo, em intervalos regulares: o consumo de energia de hora em hora, a temperatura de mês a mês, o tráfego de uma rede de minuto a minuto, as vendas de uma loja por dia. Ela tem uma particularidade que as tabelas das unidades anteriores não tinham: a ordem importa, e o valor de agora depende dos anteriores.

A pergunta mais comum sobre uma série é "e o próximo?". Quanta energia a cidade vai consumir na semana que vem; quanto tráfego o link vai ter no horário de pico. Este capítulo decompõe uma série nas suas partes (tendência, ciclos e ruído) com as ferramentas que você já tem, e usa essas partes para prever e para medir o quanto a previsão erra.

Uma série temporal

🌍 Contexto: o consumo de energia de uma cidade

O operador do sistema elétrico precisa prever o consumo com antecedência: as usinas levam horas para mudar a produção, e energia elétrica, em grande quantidade, não se guarda. Um erro de previsão para baixo pode causar apagão; para cima, desperdício. O arquivo consumo_energia.csv tem 8 semanas do consumo, em megawatts, de uma cidade pequena, de hora em hora.

# Consumo de energia de uma cidade pequena (MW), de hora em hora, por 8 semanas.
import numpy as np
import matplotlib.pyplot as plt

dados = np.loadtxt("consumo_energia.csv", delimiter=",", skiprows=1)
hora = dados[:, 0]
consumo = dados[:, 1]
print(f"{len(consumo)} horas = {len(consumo) / 24:.0f} dias")
print(f"de {np.min(consumo):.1f} a {np.max(consumo):.1f} MW, media {np.mean(consumo):.1f} MW")

plt.figure()
plt.plot(hora / 24, consumo)
plt.xlabel("dia")
plt.ylabel("consumo (MW)")
plt.title("8 semanas de consumo")
plt.grid()
plt.show()

plt.figure()
plt.plot(hora[:168] / 24, consumo[:168], ".-")
plt.xlabel("dia (0 = segunda-feira)")
plt.ylabel("consumo (MW)")
plt.title("A primeira semana")
plt.grid()
plt.show()
1344 horas = 56 dias
de 37.7 a 70.7 MW, media 54.3 MW

Oito semanas de consumo: oscilações diárias, cinco dias altos e dois baixos por semana, e uma leve subida

A primeira semana: um pico por dia no começo da noite e um vale de madrugada; o fim de semana é mais baixo

Olhando com atenção, a série tem quatro partes somadas:

  • uma tendência: o consumo cresce devagar ao longo das semanas;
  • um ciclo diário: vale de madrugada, pico no começo da noite;
  • um ciclo semanal: sábado e domingo consomem menos;
  • ruído: o que sobra, que não se prevê.

Separar essas partes é o primeiro passo de qualquer previsão.

Média móvel

A média móvel troca cada ponto pela média de uma janela de pontos em volta dele. Com uma janela do tamanho de um ciclo, o ciclo some: as horas altas e baixas se compensam dentro da janela, e sobra o que muda mais devagar.

# Media movel: cada ponto vira a media das horas da janela que termina nele.
# Com uma janela de uma semana (168 h), os ciclos do dia e da semana somem, e
# sobra o que muda mais devagar: a tendencia.
import numpy as np
import matplotlib.pyplot as plt

dados = np.loadtxt("consumo_energia.csv", delimiter=",", skiprows=1)
hora = dados[:, 0]
consumo = dados[:, 1]

janela = 168
media = np.zeros(len(consumo) - janela + 1)
for i in range(len(media)):
    media[i] = np.mean(consumo[i:i + janela])     # as 168 horas a partir de i

plt.figure()
plt.plot(hora / 24, consumo, label="consumo horário")
plt.plot(hora[janela - 1:] / 24, media, label="média móvel de uma semana")
plt.xlabel("dia")
plt.ylabel("consumo (MW)")
plt.grid()
plt.legend()
plt.show()
print(f"media movel: comeca em {media[0]:.1f} MW e termina em {media[-1]:.1f} MW")
media movel: comeca em 52.1 MW e termina em 56.6 MW

A média móvel de uma semana é uma linha que sobe devagar no meio das oscilações

Com uma janela de uma semana (168 horas), somem os dois ciclos, o do dia e o da semana, e a média móvel mostra a tendência: de 52,1 para 56,6 MW em 7 semanas. É o mesmo remédio que os boletins de saúde usam para os casos novos de uma doença (a média móvel de 7 dias do capítulo 3): tirar o ciclo semanal para ver a tendência.

Tendência

Para medir a tendência em números, a média de cada dia apaga o ciclo diário, e uma reta de mínimos quadrados (capítulo 14) pelas médias diárias dá o crescimento.

# A tendencia: a media de cada dia, e uma reta por essas medias.
import numpy as np

dados = np.loadtxt("consumo_energia.csv", delimiter=",", skiprows=1)
consumo = dados[:, 1]
n_dias = len(consumo) // 24

dia = np.zeros(n_dias)
media_dia = np.zeros(n_dias)
for d in range(n_dias):
    dia[d] = d
    media_dia[d] = np.mean(consumo[24 * d:24 * d + 24])

coef = np.polyfit(dia, media_dia, 1)
print(f"tendencia: {coef[0]:.3f} MW por dia  ({coef[0] * 365:.0f} MW por ano, se continuar)")
print("media dos 7 primeiros dias:", np.round(media_dia[:7], 1))
tendencia: 0.079 MW por dia  (29 MW por ano, se continuar)
media dos 7 primeiros dias: [53.3 53.9 53.5 54.  53.7 47.9 48.2]

A cidade consome 0,079 MW a mais por dia, cerca de 29 MW a mais por ano se o ritmo continuar: mais da metade do consumo de hoje. É o tipo de número que faz uma distribuidora planejar uma subestação nova.

Sazonalidade

O ciclo diário e o semanal são a sazonalidade (a palavra vem das estações do ano, o ciclo mais antigo que se mediu). O "dia típico" é a média de cada hora do dia em todos os dias: a média de todas as 0 h, de todas as 1 h, e assim por diante. Com a fatia com passo do capítulo 12, consumo[h::24] pega a hora h de todos os dias.

# Sazonalidade: o "dia tipico" (a media de cada hora do dia, em todos os dias)
# e a "semana tipica" (a media de cada dia da semana).
import numpy as np
import matplotlib.pyplot as plt

dados = np.loadtxt("consumo_energia.csv", delimiter=",", skiprows=1)
consumo = dados[:, 1]

perfil_hora = np.zeros(24)
for h in range(24):
    perfil_hora[h] = np.mean(consumo[h::24])        # a hora h de todos os dias

perfil_semana = np.zeros(7)
n_dias = len(consumo) // 24
for s in range(7):
    medias = []
    for d in range(s, n_dias, 7):                   # todas as segundas, todas as tercas...
        medias.append(np.mean(consumo[24 * d:24 * d + 24]))
    perfil_semana[s] = np.mean(medias)

print(f"hora de maior consumo: {np.argmax(perfil_hora)} h ({np.max(perfil_hora):.1f} MW)")
print(f"hora de menor consumo: {np.argmin(perfil_hora)} h ({np.min(perfil_hora):.1f} MW)")
print("media por dia da semana (seg a dom):", np.round(perfil_semana, 1))

plt.figure()
plt.plot(range(24), perfil_hora, "o-")
plt.xlabel("hora do dia")
plt.ylabel("consumo médio (MW)")
plt.title("O dia típico")
plt.grid()
plt.show()
hora de maior consumo: 19 h (63.4 MW)
hora de menor consumo: 4 h (45.8 MW)
media por dia da semana (seg a dom): [55.6 55.8 56.  56.  56.2 50.  50.4]

O dia típico: vale às 4 h, subida de manhã, um patamar à tarde e pico às 19 h

O pico é às 19 h (chuveiros e iluminação, quando as pessoas chegam em casa), e o vale, às 4 h. Os dias úteis consomem cerca de 56 MW; sábado e domingo, uns 50 MW.

No quadro: a suavização exponencial

A média móvel dá o mesmo peso a todos os pontos da janela e peso zero aos de fora. Uma alternativa mais simples de calcular, muito usada em previsão, dá peso maior aos dados mais recentes.

🧑‍🏫 No quadro — a suavização exponencial simples

1. A previsão nova é uma mistura do valor que acabou de chegar com a previsão anterior, com um peso \(\alpha\) entre 0 e 1:

\[ \hat y_{t+1} = \alpha\,y_t + (1 - \alpha)\,\hat y_t. \]

2. Substituindo \(\hat y_t\) pela mesma fórmula, e de novo, e de novo:

\[ \hat y_{t+1} = \alpha\,y_t + \alpha(1-\alpha)\,y_{t-1} + \alpha(1-\alpha)^2\,y_{t-2} + \cdots \]

3. É uma média de todo o passado, com pesos que caem geometricamente: o dado de ontem pesa \(\alpha\), o de anteontem \(\alpha(1-\alpha)\), e assim por diante. Daí o nome "exponencial".

4. \(\alpha\) perto de 1: a previsão segue o último dado (reage rápido, mas treme com o ruído). \(\alpha\) perto de 0: lembra de muito tempo atrás (estável, mas lenta). E, para calcular, basta guardar um número.

# Suavizacao exponencial: a previsao nova e uma mistura da previsao anterior com
# o valor que acabou de chegar. alpha perto de 1: segue o ultimo dado; perto de 0:
# lembra de muito tempo atras.
import numpy as np

dados = np.loadtxt("consumo_energia.csv", delimiter=",", skiprows=1)
consumo = dados[:, 1]
n_dias = len(consumo) // 24
media_dia = np.zeros(n_dias)
for d in range(n_dias):
    media_dia[d] = np.mean(consumo[24 * d:24 * d + 24])

for alpha in [0.1, 0.5, 0.9]:
    previsao = media_dia[0]
    erros = []
    for d in range(1, n_dias):
        erros.append(abs(previsao - media_dia[d]))        # erro da previsao para o dia d
        previsao = alpha * media_dia[d] + (1 - alpha) * previsao
    print(f"alpha = {alpha}: erro medio {np.mean(erros):.2f} MW, previsao para amanha {previsao:.1f} MW")
alpha = 0.1: erro medio 2.71 MW, previsao para amanha 55.6 MW
alpha = 0.5: erro medio 2.49 MW, previsao para amanha 53.9 MW
alpha = 0.9: erro medio 1.91 MW, previsao para amanha 52.7 MW

Para as médias diárias desta cidade, \(\alpha = 0{,}9\) erra menos: o consumo de um dia se parece muito com o do dia anterior. Mas a queda do fim de semana engana qualquer \(\alpha\): a suavização simples não sabe nada de ciclos.

Prever e medir o erro

Para comparar métodos de previsão com honestidade, a série é cortada: as 7 primeiras semanas são o treino (o que o método pode ver) e a última é o teste (o que ele tenta prever, sem ver). Dois números resumem o erro:

  • MAE (erro absoluto médio): \(\frac{1}{n}\sum |\hat y_i - y_i|\), "quanto se erra, em média", na unidade dos dados;
  • RMSE (raiz do erro quadrático médio): \(\sqrt{\frac{1}{n}\sum (\hat y_i - y_i)^2}\), que pune mais os erros grandes.
# Prever a ultima semana (a de teste) usando so as 7 primeiras (as de treino),
# e medir o erro de cada metodo.
import numpy as np

dados = np.loadtxt("consumo_energia.csv", delimiter=",", skiprows=1)
consumo = dados[:, 1]
treino = consumo[:7 * 168]
teste = consumo[7 * 168:]


def mae(previsto, real):
    return np.mean(abs(previsto - real))


def rmse(previsto, real):
    return np.sqrt(np.mean((previsto - real)**2))


# 1) ingenuo: a ultima hora conhecida, repetida a semana toda
ingenuo = np.zeros(168) + treino[-1]
# 2) sazonal: a mesma hora da semana passada
sazonal = treino[-168:]
# 3) sazonal + tendencia: a semana passada, somando o crescimento de uma semana
coef = np.polyfit(np.linspace(0, len(treino) - 1, len(treino)), treino, 1)
com_tendencia = treino[-168:] + coef[0] * 168

print("metodo                 MAE (MW)   RMSE (MW)")
print(f"ingenuo              {mae(ingenuo, teste):9.2f}  {rmse(ingenuo, teste):9.2f}")
print(f"sazonal (semana)     {mae(sazonal, teste):9.2f}  {rmse(sazonal, teste):9.2f}")
print(f"sazonal + tendencia  {mae(com_tendencia, teste):9.2f}  {rmse(com_tendencia, teste):9.2f}")
metodo                 MAE (MW)   RMSE (MW)
ingenuo                   6.42       7.80
sazonal (semana)          1.84       2.31
sazonal + tendencia       1.77       2.19

💥 Quebre o método

O método ingênuo (repetir o último valor conhecido) erra 6,4 MW em média: ele ignora que amanhã às 19 h vai ter pico. Só copiar a mesma hora da semana passada (o método sazonal) derruba o erro para 1,8 MW, sem conta nenhuma. Somar a tendência melhora mais um pouco. A lição é que um método simples que respeita os ciclos da série bate um método que os ignora. E a regra de sempre: um método só pode ser avaliado em dados que ele não viu (o teste), como no sobreajuste do capítulo 15.

Mesmo método, outra área

🌍 Contexto: a temperatura mês a mês

As normais climatológicas são as médias de temperatura de cada mês do ano, medidas ao longo de décadas. O ciclo é anual: 12 meses. Em João Pessoa, a variação é pequena (de 25 a 28 °C), com o inverno em julho.

# Mesmo metodo, outra area: clima. Temperatura media mensal (°C) em Joao Pessoa,
# tres anos (valores ilustrativos, perto das normais climatologicas).
import numpy as np

temperatura = np.array([27.6, 27.8, 27.6, 27.3, 26.6, 25.7, 25.1, 25.2, 25.9, 26.6, 27.0, 27.3,
                        27.8, 28.0, 27.9, 27.4, 26.8, 25.8, 25.3, 25.3, 26.0, 26.8, 27.2, 27.6,
                        28.0, 28.1, 28.0, 27.7, 26.9, 26.1, 25.4, 25.6, 26.2, 26.9, 27.4, 27.7])
treino = temperatura[:24]
teste = temperatura[24:]

sazonal = treino[12:]                               # o mesmo mes do ano passado
perfil = (treino[:12] + treino[12:]) / 2            # a media de cada mes nos dois anos
print(f"MAE sazonal (ano passado): {np.mean(abs(sazonal - teste)):.2f} °C")
print(f"MAE perfil medio:          {np.mean(abs(perfil - teste)):.2f} °C")
MAE sazonal (ano passado): 0.17 °C
MAE perfil medio:          0.27 °C

O mesmo método sazonal, com o ciclo de 12 meses no lugar do de 168 horas, prevê a temperatura do terceiro ano a partir do segundo com erro de 0,17 °C. O "perfil médio" dos dois anos anteriores erra mais (0,27 °C), porque mistura um ano mais frio: a série tem uma tendência de aquecimento, e o dado mais recente carrega mais dela.

Erros comuns deste capítulo

Sintoma Causa provável
a média móvel ainda oscila a janela não tem o tamanho de um ciclo inteiro (use 24 para o dia, 168 para a semana)
a média móvel tem menos pontos que a série é da natureza dela: cada ponto precisa de uma janela inteira
o método "perfeito" no treino erra muito no teste avaliou no treino, ou usou dados do futuro para prever o passado
o perfil diário fica deslocado a série não começa à 0 h, ou tem horas faltando: confira a posição de cada hora
comparar MAE com RMSE medem coisas diferentes; o RMSE é sempre maior ou igual ao MAE

Onde isso é usado de verdade

  • Setor elétrico: o Operador Nacional do Sistema (ONS) prevê a carga de hora em hora para decidir quais usinas ligar.
  • Telecomunicações: o dimensionamento de links e de servidores usa a previsão do tráfego no horário de pico; os ciclos diário e semanal são exatamente os deste capítulo.
  • Varejo e logística: previsão de vendas para montar estoques (com o ciclo semanal e o de datas como Natal e Black Friday).
  • Saúde pública: vigilância de epidemias com médias móveis e detecção de surtos quando a série foge do esperado.
  • Economia: inflação, desemprego e produção industrial são publicados "com ajuste sazonal", exatamente a remoção dos ciclos feita aqui.

Resumo

  • Uma série temporal se decompõe em tendência, sazonalidade (ciclos) e ruído.
  • A média móvel com janela de um ciclo inteiro apaga o ciclo e mostra a tendência.
  • A tendência se mede com uma reta pelas médias de cada período; a sazonalidade, com o perfil médio de cada posição do ciclo.
  • A suavização exponencial prevê com uma média do passado de pesos decrescentes, guardando um número só.
  • Previsões se comparam no teste, com MAE e RMSE. Um método que respeita os ciclos (o sazonal) costuma bater métodos que os ignoram.

Para praticar

A Lista 16 tem uma média móvel e uma suavização à mão (✏️), o MAE e o RMSE, o perfil de um ciclo e a previsão do tráfego de uma rede.