← Voltar pro projeto

Previsão de Demanda: Quando o Modelo Simples Ganha do Sofisticado

Os quatro cenários originais (atraso, nota, frete, RFM) nunca tocaram série temporal de verdade, só usaram mês como uma feature entre outras. Esse capítulo fecha essa lacuna: previsão de quantos pedidos chegam por mês. Notebook completo, executado, público no Colab.

A série, e por que eu corto as pontas

O capítulo 1 já mostrou pedidos por mês, mas com pontas quase vazias, setembro/outubro/dezembro de 2016 (4, 324 e 1 pedido) e setembro/outubro de 2018 (16 e 4). Isso não é queda de demanda, é o dataset começando e terminando no meio de um mês. Pra previsão de verdade eu uso só janeiro de 2017 a agosto de 2018, 20 meses cheios.

serie_completa = orders.groupby(orders['order_purchase_timestamp'].dt.to_period('M')).size()
serie = serie_completa['2017-01':'2018-08']

df = serie.reset_index()
df.columns = ['mes', 'pedidos']
df['t'] = np.arange(len(df))
df['mes_num'] = df['mes'].dt.month
df['sin'] = np.sin(2 * np.pi * df['mes_num'] / 12)
df['cos'] = np.cos(2 * np.pi * df['mes_num'] / 12)

groupby(...dt.to_period('M')).size() conta pedidos por mês calendário na série inteira, e o corte ['2017-01':'2018-08'] fica só com os 20 meses cheios. t é um contador simples (0, 1, 2...) que vira o eixo de tendência da regressão linear mais adiante. sin/cos do mês são a forma de dar sazonalidade cíclica pro modelo sem criar uma dummy por mês (o que exigiria 11 parâmetros extras pra só 20 pontos de treino, receita pra overfitar): janeiro e dezembro ficam próximos nessa representação circular, do jeito que meses vizinhos deveriam ficar, o que uma dummy comum não capturaria.

Três candidatos, testados nos últimos 4 meses reais

Em vez de extrapolar às cegas, separei maio a agosto de 2018 como teste e comparei três jeitos de prever: ingênuo (repete o último valor conhecido), sazonal ingênuo (repete o mesmo mês do ano anterior), e uma regressão linear com tendência mais sazonalidade cíclica (seno e cosseno do mês, não uma dummy por mês, que com só 20 pontos ia estourar em parâmetro pra pouco dado).

n_holdout = 4
treino = df.iloc[:-n_holdout]
teste = df.iloc[-n_holdout:]

pred_ingenuo = np.array([treino['pedidos'].iloc[-1]] * n_holdout)

pred_sazonal = []
for _, row in teste.iterrows():
    mes_ano_anterior = row['mes'] - 12
    pred_sazonal.append(serie_completa.get(mes_ano_anterior, treino['pedidos'].mean()))
pred_sazonal = np.array(pred_sazonal)

modelo_linear = LinearRegression()
modelo_linear.fit(treino[['t', 'sin', 'cos']], treino['pedidos'])
pred_linear = modelo_linear.predict(teste[['t', 'sin', 'cos']])

y_real = teste['pedidos'].values
comparacao = []
for nome, pred in [
    ('Ingênuo (último mês)', pred_ingenuo),
    ('Sazonal ingênuo (mesmo mês, ano anterior)', pred_sazonal),
    ('Linear + sazonalidade cíclica', pred_linear),
]:
    mae = mean_absolute_error(y_real, pred)
    mape = mean_absolute_percentage_error(y_real, pred)
    comparacao.append({'model': nome, 'mae': round(float(mae), 1), 'mape': round(float(mape), 4)})

iloc[:-n_holdout]/iloc[-n_holdout:] separam os últimos 4 meses (maio a agosto de 2018) como teste, o resto vira treino. pred_ingenuo é literalmente uma constante, o último valor de treino repetido 4 vezes. pred_sazonal busca, pra cada mês de teste, o mesmo mês um ano antes (row['mes'] - 12 em período mensal já faz essa subtração certa), com a média de treino como fallback se aquele mês não existir na série completa. modelo_linear.fit(treino[['t', 'sin', 'cos']], treino['pedidos']) treina só nas 16 linhas de treino, e predict nas 4 de teste, o mesmo par fit/predict que já apareceu em todo cenário de regressão do capítulo 4, só que agora com features de tempo em vez das features tabulares de antes. mean_absolute_error e mean_absolute_percentage_error comparam cada previsão contra o valor real dos 4 meses de teste.

ModeloMAEMAPE
Ingênuo (último mês)478,07,58%
Sazonal ingênuo (mesmo mês, ano anterior)2.635,540,76%
Linear + sazonalidade cíclica2.166,533,87%

O modelo mais simples ganhou, e esse é o achado do capítulo

O ingênuo erra em média 478 pedidos por mês. O linear com tendência e sazonalidade, o candidato "sério", erra 2.166,5, quase 5 vezes mais. A explicação está no formato da série: 2017 teve um crescimento forte (novembro é a Black Friday), e o modelo linear aprendeu essa inclinação e extrapolou continuação de crescimento pra maio-agosto de 2018. Só que a base já tinha estabilizado bem antes disso (6.873 em maio, 6.167 em junho, 6.292 em julho, 6.512 em agosto, uma linha quase reta). O modelo "sofisticado" prevê crescimento onde não tem mais crescimento. O ingênuo, que não assume nada sobre tendência, simplesmente não erra essa aposta.

Carregando dados reais...

A previsão final, e por que ela é uma linha reta

Como o ingênuo ganhou de verdade no teste, é ele que uso pra extrapolar os 3 meses seguintes ao fim do dataset, setembro a novembro de 2018. A previsão é uma linha reta em 6.512 pedidos (o último valor real conhecido), com uma faixa de incerteza que cresce com a raiz do horizonte (quanto mais longe no futuro, mais incerto), calculada a partir do erro real do próprio modelo no teste.

residuos_holdout = y_real - pred_ingenuo
std_holdout = residuos_holdout.std()

ultimo_valor = int(df['pedidos'].iloc[-1])
futuro_meses = pd.period_range(df['mes'].iloc[-1] + 1, periods=3, freq='M')

previsao_futura = []
for i, mes in enumerate(futuro_meses, start=1):
    largura = std_holdout * np.sqrt(i) * 1.96
    previsao_futura.append({
        'month': str(mes),
        'predicted': ultimo_valor,
        'lower': round(max(0, ultimo_valor - largura), 1),
        'upper': round(ultimo_valor + largura, 1),
    })

residuos_holdout é o erro real do ingênuo nos 4 meses de teste (previsto menos real), e std_holdout é o desvio padrão desses erros, a régua de "quão errado" o modelo costuma ser. O for varre os 3 meses futuros (i de 1 a 3) e largura = std_holdout * np.sqrt(i) * 1.96 é a faixa de incerteza: multiplicar pela raiz de i faz a faixa crescer mais devagar que linear (o erro de 3 meses à frente não é 3 vezes o de 1 mês, é √3 vezes), e o 1.96 é o multiplicador padrão de um intervalo de aproximadamente 95% assumindo erro com distribuição normal. max(0, ...) no limite inferior existe só pra não deixar a faixa virar número negativo de pedidos, o que não faria sentido.

MêsPrevistoFaixa
2018-096.5125.986,7 a 7.037,3
2018-106.5125.769,1 a 7.254,9
2018-116.5125.602,1 a 7.421,9

Uma linha reta como previsão de "IA" soa anticlimático, mas é a resposta honesta que 20 meses de dado dão. Prever crescimento continuado sem nenhuma evidência de que ele continua é o tipo de erro que fica bonito num gráfico de tendência e feio na prática.

Fechando o capítulo (e o projeto)

A lição desse capítulo generaliza pros outros cinco: um modelo mais sofisticado não é melhor por padrão, ele só é melhor se as suposições dele baterem com o que os dados realmente estão fazendo. O linear assumiu crescimento contínuo, a base parou de crescer, o linear perdeu. É a mesma lição do capítulo 4 (AUC boa não é threshold bom) e do capítulo 5 (K estatisticamente ótimo não é segmentação rica) contada de um jeito novo: métrica de ajuste no treino não garante decisão boa daqui pra frente.

Com isso, os seis capítulos técnicos do projeto Olist estão fechados. Se ainda não viu, o capítulo 0 é a vitrine interativa de tudo isso, sem prosa nenhuma, só pra mexer.