[{"data":1,"prerenderedAt":596},["ShallowReactive",2],{"lang-switch-post-\u002Fprojects\u002Folist-ecommerce\u002F06-previsao-demanda":3,"chapter-pt-olist-ecommerce-06-previsao-demanda":4},null,{"id":5,"title":6,"body":7,"cover":3,"date":582,"description":583,"extension":584,"meta":585,"navigation":59,"order":75,"path":586,"project":587,"seo":588,"status":589,"stem":590,"tags":591,"__hash__":595},"projectChapters\u002Fpt\u002Fprojects\u002Folist-ecommerce\u002F06-previsao-demanda.md","Previsão de Demanda: Quando o Modelo Simples Ganha do Sofisticado",{"type":8,"value":9,"toc":575},"minimark",[10,22,27,30,97,119,123,126,274,311,369,373,376,383,387,390,468,505,553,556,560,563,571],[11,12,13,14,21],"p",{},"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, ",[15,16,20],"a",{"href":17,"rel":18},"https:\u002F\u002Fcolab.research.google.com\u002Fdrive\u002F1fPSvpTQcOn4XEDAxHKEYSLlOw6kFaW6n?usp=sharing",[19],"nofollow","público no Colab",".",[23,24,26],"h2",{"id":25},"a-série-e-por-que-eu-corto-as-pontas","A série, e por que eu corto as pontas",[11,28,29],{},"O capítulo 1 já mostrou pedidos por mês, mas com pontas quase vazias, setembro\u002Foutubro\u002Fdezembro de 2016 (4, 324 e 1 pedido) e setembro\u002Foutubro 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.",[31,32,37],"pre",{"className":33,"code":34,"language":35,"meta":36,"style":36},"language-python shiki shiki-themes github-light github-dark","serie_completa = orders.groupby(orders['order_purchase_timestamp'].dt.to_period('M')).size()\nserie = serie_completa['2017-01':'2018-08']\n\ndf = serie.reset_index()\ndf.columns = ['mes', 'pedidos']\ndf['t'] = np.arange(len(df))\ndf['mes_num'] = df['mes'].dt.month\ndf['sin'] = np.sin(2 * np.pi * df['mes_num'] \u002F 12)\ndf['cos'] = np.cos(2 * np.pi * df['mes_num'] \u002F 12)\n","python","",[38,39,40,48,54,61,67,73,79,85,91],"code",{"__ignoreMap":36},[41,42,45],"span",{"class":43,"line":44},"line",1,[41,46,47],{},"serie_completa = orders.groupby(orders['order_purchase_timestamp'].dt.to_period('M')).size()\n",[41,49,51],{"class":43,"line":50},2,[41,52,53],{},"serie = serie_completa['2017-01':'2018-08']\n",[41,55,57],{"class":43,"line":56},3,[41,58,60],{"emptyLinePlaceholder":59},true,"\n",[41,62,64],{"class":43,"line":63},4,[41,65,66],{},"df = serie.reset_index()\n",[41,68,70],{"class":43,"line":69},5,[41,71,72],{},"df.columns = ['mes', 'pedidos']\n",[41,74,76],{"class":43,"line":75},6,[41,77,78],{},"df['t'] = np.arange(len(df))\n",[41,80,82],{"class":43,"line":81},7,[41,83,84],{},"df['mes_num'] = df['mes'].dt.month\n",[41,86,88],{"class":43,"line":87},8,[41,89,90],{},"df['sin'] = np.sin(2 * np.pi * df['mes_num'] \u002F 12)\n",[41,92,94],{"class":43,"line":93},9,[41,95,96],{},"df['cos'] = np.cos(2 * np.pi * df['mes_num'] \u002F 12)\n",[11,98,99,102,103,106,107,110,111,114,115,118],{},[38,100,101],{},"groupby(...dt.to_period('M')).size()"," conta pedidos por mês calendário na série inteira, e o corte ",[38,104,105],{},"['2017-01':'2018-08']"," fica só com os 20 meses cheios. ",[38,108,109],{},"t"," é um contador simples (0, 1, 2...) que vira o eixo de tendência da regressão linear mais adiante. ",[38,112,113],{},"sin","\u002F",[38,116,117],{},"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.",[23,120,122],{"id":121},"três-candidatos-testados-nos-últimos-4-meses-reais","Três candidatos, testados nos últimos 4 meses reais",[11,124,125],{},"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).",[31,127,129],{"className":33,"code":128,"language":35,"meta":36,"style":36},"n_holdout = 4\ntreino = df.iloc[:-n_holdout]\nteste = df.iloc[-n_holdout:]\n\npred_ingenuo = np.array([treino['pedidos'].iloc[-1]] * n_holdout)\n\npred_sazonal = []\nfor _, row in teste.iterrows():\n    mes_ano_anterior = row['mes'] - 12\n    pred_sazonal.append(serie_completa.get(mes_ano_anterior, treino['pedidos'].mean()))\npred_sazonal = np.array(pred_sazonal)\n\nmodelo_linear = LinearRegression()\nmodelo_linear.fit(treino[['t', 'sin', 'cos']], treino['pedidos'])\npred_linear = modelo_linear.predict(teste[['t', 'sin', 'cos']])\n\ny_real = teste['pedidos'].values\ncomparacao = []\nfor nome, pred in [\n    ('Ingênuo (último mês)', pred_ingenuo),\n    ('Sazonal ingênuo (mesmo mês, ano anterior)', pred_sazonal),\n    ('Linear + sazonalidade cíclica', pred_linear),\n]:\n    mae = mean_absolute_error(y_real, pred)\n    mape = mean_absolute_percentage_error(y_real, pred)\n    comparacao.append({'model': nome, 'mae': round(float(mae), 1), 'mape': round(float(mape), 4)})\n",[38,130,131,136,141,146,150,155,159,164,169,174,180,186,191,197,203,209,214,220,226,232,238,244,250,256,262,268],{"__ignoreMap":36},[41,132,133],{"class":43,"line":44},[41,134,135],{},"n_holdout = 4\n",[41,137,138],{"class":43,"line":50},[41,139,140],{},"treino = df.iloc[:-n_holdout]\n",[41,142,143],{"class":43,"line":56},[41,144,145],{},"teste = df.iloc[-n_holdout:]\n",[41,147,148],{"class":43,"line":63},[41,149,60],{"emptyLinePlaceholder":59},[41,151,152],{"class":43,"line":69},[41,153,154],{},"pred_ingenuo = np.array([treino['pedidos'].iloc[-1]] * n_holdout)\n",[41,156,157],{"class":43,"line":75},[41,158,60],{"emptyLinePlaceholder":59},[41,160,161],{"class":43,"line":81},[41,162,163],{},"pred_sazonal = []\n",[41,165,166],{"class":43,"line":87},[41,167,168],{},"for _, row in teste.iterrows():\n",[41,170,171],{"class":43,"line":93},[41,172,173],{},"    mes_ano_anterior = row['mes'] - 12\n",[41,175,177],{"class":43,"line":176},10,[41,178,179],{},"    pred_sazonal.append(serie_completa.get(mes_ano_anterior, treino['pedidos'].mean()))\n",[41,181,183],{"class":43,"line":182},11,[41,184,185],{},"pred_sazonal = np.array(pred_sazonal)\n",[41,187,189],{"class":43,"line":188},12,[41,190,60],{"emptyLinePlaceholder":59},[41,192,194],{"class":43,"line":193},13,[41,195,196],{},"modelo_linear = LinearRegression()\n",[41,198,200],{"class":43,"line":199},14,[41,201,202],{},"modelo_linear.fit(treino[['t', 'sin', 'cos']], treino['pedidos'])\n",[41,204,206],{"class":43,"line":205},15,[41,207,208],{},"pred_linear = modelo_linear.predict(teste[['t', 'sin', 'cos']])\n",[41,210,212],{"class":43,"line":211},16,[41,213,60],{"emptyLinePlaceholder":59},[41,215,217],{"class":43,"line":216},17,[41,218,219],{},"y_real = teste['pedidos'].values\n",[41,221,223],{"class":43,"line":222},18,[41,224,225],{},"comparacao = []\n",[41,227,229],{"class":43,"line":228},19,[41,230,231],{},"for nome, pred in [\n",[41,233,235],{"class":43,"line":234},20,[41,236,237],{},"    ('Ingênuo (último mês)', pred_ingenuo),\n",[41,239,241],{"class":43,"line":240},21,[41,242,243],{},"    ('Sazonal ingênuo (mesmo mês, ano anterior)', pred_sazonal),\n",[41,245,247],{"class":43,"line":246},22,[41,248,249],{},"    ('Linear + sazonalidade cíclica', pred_linear),\n",[41,251,253],{"class":43,"line":252},23,[41,254,255],{},"]:\n",[41,257,259],{"class":43,"line":258},24,[41,260,261],{},"    mae = mean_absolute_error(y_real, pred)\n",[41,263,265],{"class":43,"line":264},25,[41,266,267],{},"    mape = mean_absolute_percentage_error(y_real, pred)\n",[41,269,271],{"class":43,"line":270},26,[41,272,273],{},"    comparacao.append({'model': nome, 'mae': round(float(mae), 1), 'mape': round(float(mape), 4)})\n",[11,275,276,114,279,282,283,286,287,290,291,294,295,298,299,302,303,306,307,310],{},[38,277,278],{},"iloc[:-n_holdout]",[38,280,281],{},"iloc[-n_holdout:]"," separam os últimos 4 meses (maio a agosto de 2018) como teste, o resto vira treino. ",[38,284,285],{},"pred_ingenuo"," é literalmente uma constante, o último valor de treino repetido 4 vezes. ",[38,288,289],{},"pred_sazonal"," busca, pra cada mês de teste, o mesmo mês um ano antes (",[38,292,293],{},"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. ",[38,296,297],{},"modelo_linear.fit(treino[['t', 'sin', 'cos']], treino['pedidos'])"," treina só nas 16 linhas de treino, e ",[38,300,301],{},"predict"," nas 4 de teste, o mesmo par fit\u002Fpredict 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. ",[38,304,305],{},"mean_absolute_error"," e ",[38,308,309],{},"mean_absolute_percentage_error"," comparam cada previsão contra o valor real dos 4 meses de teste.",[312,313,314,332],"table",{},[315,316,317],"thead",{},[318,319,320,325,329],"tr",{},[321,322,324],"th",{"align":323},"left","Modelo",[321,326,328],{"align":327},"right","MAE",[321,330,331],{"align":327},"MAPE",[333,334,335,347,358],"tbody",{},[318,336,337,341,344],{},[338,339,340],"td",{"align":323},"Ingênuo (último mês)",[338,342,343],{"align":327},"478,0",[338,345,346],{"align":327},"7,58%",[318,348,349,352,355],{},[338,350,351],{"align":323},"Sazonal ingênuo (mesmo mês, ano anterior)",[338,353,354],{"align":327},"2.635,5",[338,356,357],{"align":327},"40,76%",[318,359,360,363,366],{},[338,361,362],{"align":323},"Linear + sazonalidade cíclica",[338,364,365],{"align":327},"2.166,5",[338,367,368],{"align":327},"33,87%",[23,370,372],{"id":371},"o-modelo-mais-simples-ganhou-e-esse-é-o-achado-do-capítulo","O modelo mais simples ganhou, e esse é o achado do capítulo",[11,374,375],{},"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.",[377,378],"olist-demand-forecast-chart",{"band-label":379,"forecast-label":380,"history-label":381,"y-label":382},"incerteza","previsão","história","pedidos",[23,384,386],{"id":385},"a-previsão-final-e-por-que-ela-é-uma-linha-reta","A previsão final, e por que ela é uma linha reta",[11,388,389],{},"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.",[31,391,393],{"className":33,"code":392,"language":35,"meta":36,"style":36},"residuos_holdout = y_real - pred_ingenuo\nstd_holdout = residuos_holdout.std()\n\nultimo_valor = int(df['pedidos'].iloc[-1])\nfuturo_meses = pd.period_range(df['mes'].iloc[-1] + 1, periods=3, freq='M')\n\nprevisao_futura = []\nfor i, mes in enumerate(futuro_meses, start=1):\n    largura = std_holdout * np.sqrt(i) * 1.96\n    previsao_futura.append({\n        'month': str(mes),\n        'predicted': ultimo_valor,\n        'lower': round(max(0, ultimo_valor - largura), 1),\n        'upper': round(ultimo_valor + largura, 1),\n    })\n",[38,394,395,400,405,409,414,419,423,428,433,438,443,448,453,458,463],{"__ignoreMap":36},[41,396,397],{"class":43,"line":44},[41,398,399],{},"residuos_holdout = y_real - pred_ingenuo\n",[41,401,402],{"class":43,"line":50},[41,403,404],{},"std_holdout = residuos_holdout.std()\n",[41,406,407],{"class":43,"line":56},[41,408,60],{"emptyLinePlaceholder":59},[41,410,411],{"class":43,"line":63},[41,412,413],{},"ultimo_valor = int(df['pedidos'].iloc[-1])\n",[41,415,416],{"class":43,"line":69},[41,417,418],{},"futuro_meses = pd.period_range(df['mes'].iloc[-1] + 1, periods=3, freq='M')\n",[41,420,421],{"class":43,"line":75},[41,422,60],{"emptyLinePlaceholder":59},[41,424,425],{"class":43,"line":81},[41,426,427],{},"previsao_futura = []\n",[41,429,430],{"class":43,"line":87},[41,431,432],{},"for i, mes in enumerate(futuro_meses, start=1):\n",[41,434,435],{"class":43,"line":93},[41,436,437],{},"    largura = std_holdout * np.sqrt(i) * 1.96\n",[41,439,440],{"class":43,"line":176},[41,441,442],{},"    previsao_futura.append({\n",[41,444,445],{"class":43,"line":182},[41,446,447],{},"        'month': str(mes),\n",[41,449,450],{"class":43,"line":188},[41,451,452],{},"        'predicted': ultimo_valor,\n",[41,454,455],{"class":43,"line":193},[41,456,457],{},"        'lower': round(max(0, ultimo_valor - largura), 1),\n",[41,459,460],{"class":43,"line":199},[41,461,462],{},"        'upper': round(ultimo_valor + largura, 1),\n",[41,464,465],{"class":43,"line":205},[41,466,467],{},"    })\n",[11,469,470,473,474,477,478,481,482,485,486,489,490,492,493,496,497,500,501,504],{},[38,471,472],{},"residuos_holdout"," é o erro real do ingênuo nos 4 meses de teste (previsto menos real), e ",[38,475,476],{},"std_holdout"," é o desvio padrão desses erros, a régua de \"quão errado\" o modelo costuma ser. O ",[38,479,480],{},"for"," varre os 3 meses futuros (",[38,483,484],{},"i"," de 1 a 3) e ",[38,487,488],{},"largura = std_holdout * np.sqrt(i) * 1.96"," é a faixa de incerteza: multiplicar pela raiz de ",[38,491,484],{}," faz a faixa crescer mais devagar que linear (o erro de 3 meses à frente não é 3 vezes o de 1 mês, é ",[38,494,495],{},"√3"," vezes), e o ",[38,498,499],{},"1.96"," é o multiplicador padrão de um intervalo de aproximadamente 95% assumindo erro com distribuição normal. ",[38,502,503],{},"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.",[312,506,507,520],{},[315,508,509],{},[318,510,511,514,517],{},[321,512,513],{"align":323},"Mês",[321,515,516],{"align":327},"Previsto",[321,518,519],{"align":327},"Faixa",[333,521,522,533,543],{},[318,523,524,527,530],{},[338,525,526],{"align":323},"2018-09",[338,528,529],{"align":327},"6.512",[338,531,532],{"align":327},"5.986,7 a 7.037,3",[318,534,535,538,540],{},[338,536,537],{"align":323},"2018-10",[338,539,529],{"align":327},[338,541,542],{"align":327},"5.769,1 a 7.254,9",[318,544,545,548,550],{},[338,546,547],{"align":323},"2018-11",[338,549,529],{"align":327},[338,551,552],{"align":327},"5.602,1 a 7.421,9",[11,554,555],{},"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.",[23,557,559],{"id":558},"fechando-o-capítulo-e-o-projeto","Fechando o capítulo (e o projeto)",[11,561,562],{},"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.",[11,564,565,566,570],{},"Com isso, os seis capítulos técnicos do projeto Olist estão fechados. Se ainda não viu, o ",[15,567,569],{"href":568},"\u002Fprojects\u002Folist-ecommerce\u002F00-convite","capítulo 0"," é a vitrine interativa de tudo isso, sem prosa nenhuma, só pra mexer.",[572,573,574],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":36,"searchDepth":50,"depth":50,"links":576},[577,578,579,580,581],{"id":25,"depth":50,"text":26},{"id":121,"depth":50,"text":122},{"id":371,"depth":50,"text":372},{"id":385,"depth":50,"text":386},{"id":558,"depth":50,"text":559},"2026-08-25","Sexto capítulo do case Olist, o único ângulo de série temporal do projeto: comparo três jeitos de prever pedidos por mês num holdout real, e o modelo mais ingênuo bate disparado uma regressão com tendência e sazonalidade.","md",{},"\u002Fpt\u002Fprojects\u002Folist-ecommerce\u002F06-previsao-demanda","olist-ecommerce",{"title":6,"description":583},"published","pt\u002Fprojects\u002Folist-ecommerce\u002F06-previsao-demanda",[592,593,594],"forecasting","scikit-learn","time-series","3JP6rdj0sIWjnq5pwem5jlN5ZtKxwD3qald6p09FnVs",1788965101051]