[{"data":1,"prerenderedAt":596},["ShallowReactive",2],{"lang-switch-post-\u002Fen\u002Fprojects\u002Folist-ecommerce\u002F06-previsao-demanda":3,"chapter-en-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\u002Fen\u002Fprojects\u002Folist-ecommerce\u002F06-previsao-demanda.md","Demand Forecasting: When the Simple Model Beats the Sophisticated One",{"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",{},"The four original scenarios (delay, review, freight, RFM) never touched real time series, they only used month as one feature among others. This chapter closes that gap: predicting how many orders arrive per month. Full executed notebook, ",[15,16,20],"a",{"href":17,"rel":18},"https:\u002F\u002Fcolab.research.google.com\u002Fdrive\u002F1fPSvpTQcOn4XEDAxHKEYSLlOw6kFaW6n?usp=sharing",[19],"nofollow","public on Colab",".",[23,24,26],"h2",{"id":25},"the-series-and-why-i-trim-the-edges","The series, and why I trim the edges",[11,28,29],{},"Chapter 1 already showed orders per month, but with nearly empty edges, September\u002FOctober\u002FDecember 2016 (4, 324 and 1 order) and September\u002FOctober 2018 (16 and 4). That's not a demand drop, it's the dataset starting and ending mid-month. For real forecasting I only use January 2017 through August 2018, 20 full months.",[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()"," counts orders per calendar month across the whole series, and the slice ",[38,104,105],{},"['2017-01':'2018-08']"," keeps only the 20 full months. ",[38,108,109],{},"t"," is a simple counter (0, 1, 2...) that becomes the linear regression's trend axis further down. ",[38,112,113],{},"sin","\u002F",[38,116,117],{},"cos"," of the month is how I give the model cyclical seasonality without creating a per-month dummy (which would need 11 extra parameters for only 20 training points, a recipe for overfitting): January and December end up close together in this circular representation, the way neighboring months should, something a regular dummy wouldn't capture.",[23,120,122],{"id":121},"three-candidates-tested-on-the-last-4-real-months","Three candidates, tested on the last 4 real months",[11,124,125],{},"Instead of blindly extrapolating, I set aside May through August 2018 as a test set and compared three approaches: naive (repeats the last known value), seasonal naive (repeats the same month from the previous year), and a linear regression with trend plus cyclical seasonality (sine and cosine of the month, not a per-month dummy, which with only 20 points would blow up in parameters for too little data).",[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:]"," split off the last 4 months (May through August 2018) as test, the rest becomes training. ",[38,284,285],{},"pred_ingenuo"," is literally a constant, the last training value repeated 4 times. ",[38,288,289],{},"pred_sazonal"," looks up, for each test month, the same month one year earlier (",[38,292,293],{},"row['mes'] - 12"," on a monthly period already does that subtraction correctly), falling back to the training mean if that month isn't in the full series. ",[38,296,297],{},"modelo_linear.fit(treino[['t', 'sin', 'cos']], treino['pedidos'])"," trains only on the 16 training rows, and ",[38,300,301],{},"predict"," on the 4 test rows, the same fit\u002Fpredict pair that already showed up in every regression scenario back in Chapter 4, just with time features instead of the earlier tabular ones. ",[38,304,305],{},"mean_absolute_error"," and ",[38,308,309],{},"mean_absolute_percentage_error"," compare each forecast against the real value across the 4 test months.",[312,313,314,332],"table",{},[315,316,317],"thead",{},[318,319,320,325,329],"tr",{},[321,322,324],"th",{"align":323},"left","Model",[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},"Naive (last month)",[338,342,343],{"align":327},"478.0",[338,345,346],{"align":327},"7.58%",[318,348,349,352,355],{},[338,350,351],{"align":323},"Seasonal naive (same month, previous year)",[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 + cyclical seasonality",[338,364,365],{"align":327},"2,166.5",[338,367,368],{"align":327},"33.87%",[23,370,372],{"id":371},"the-simplest-model-won-and-thats-this-chapters-finding","The simplest model won, and that's this chapter's finding",[11,374,375],{},"The naive model is off by 478 orders on average. The linear model with trend and seasonality, the \"serious\" candidate, is off by 2,166.5, almost 5 times worse. The explanation is in the series' shape: 2017 had strong growth (November is Black Friday), and the linear model learned that slope and extrapolated continued growth into May-August 2018. Except the base had already leveled off well before that (6,873 in May, 6,167 in June, 6,292 in July, 6,512 in August, an almost flat line). The \"sophisticated\" model predicts growth where there's no more growth. The naive one, which assumes nothing about trend, simply doesn't make that bet.",[377,378],"olist-demand-forecast-chart",{"band-label":379,"forecast-label":380,"history-label":381,"y-label":382},"uncertainty","forecast","history","orders",[23,384,386],{"id":385},"the-final-forecast-and-why-its-a-flat-line","The final forecast, and why it's a flat line",[11,388,389],{},"Since the naive model genuinely won on the test, that's the one I use to extrapolate the 3 months following the dataset's end, September through November 2018. The forecast is a flat line at 6,512 orders (the last known real value), with an uncertainty band that grows with the square root of the horizon (the further out, the more uncertain), computed from the model's own real error on the test set.",[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"," is the naive model's real error on the 4 test months (predicted minus real), and ",[38,475,476],{},"std_holdout"," is those errors' standard deviation, the ruler for \"how wrong\" the model usually is. The ",[38,479,480],{},"for"," loop walks the 3 future months (",[38,483,484],{},"i"," from 1 to 3), and ",[38,487,488],{},"largura = std_holdout * np.sqrt(i) * 1.96"," is the uncertainty band: multiplying by the square root of ",[38,491,484],{}," makes the band grow slower than linearly (the error 3 months out isn't 3 times the 1-month error, it's ",[38,494,495],{},"√3"," times), and ",[38,498,499],{},"1.96"," is the standard multiplier for a roughly 95% interval assuming normally distributed error. ",[38,502,503],{},"max(0, ...)"," on the lower bound just keeps the band from turning into a negative order count, which wouldn't make sense.",[312,506,507,520],{},[315,508,509],{},[318,510,511,514,517],{},[321,512,513],{"align":323},"Month",[321,515,516],{"align":327},"Predicted",[321,518,519],{"align":327},"Range",[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 to 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 to 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 to 7,421.9",[11,554,555],{},"A flat line as an \"AI\" forecast sounds anticlimactic, but it's the honest answer 20 months of data give. Predicting continued growth with no evidence it continues is the kind of mistake that looks good on a trend chart and bad in practice.",[23,557,559],{"id":558},"wrapping-up-the-chapter-and-the-project","Wrapping up the chapter (and the project)",[11,561,562],{},"This chapter's lesson generalizes to the other five: a more sophisticated model isn't better by default, it's only better if its assumptions match what the data is actually doing. The linear model assumed continued growth, the base stopped growing, the linear model lost. It's the same lesson as Chapter 4 (good AUC isn't a good threshold) and Chapter 5 (statistically optimal K isn't rich segmentation), told a new way: a good training-fit metric doesn't guarantee a good decision going forward.",[11,564,565,566,570],{},"With that, the Olist project's six technical chapters are closed. If you haven't seen it yet, ",[15,567,569],{"href":568},"\u002Fen\u002Fprojects\u002Folist-ecommerce\u002F00-convite","Chapter 0"," is the interactive showcase of all of this, no prose at all, just for playing around.",[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","Sixth chapter of the Olist case study, the project's only time-series angle: I compare three ways of predicting monthly orders on a real holdout, and the most naive model wins by a wide margin over a regression with trend and seasonality.","md",{},"\u002Fen\u002Fprojects\u002Folist-ecommerce\u002F06-previsao-demanda","olist-ecommerce",{"title":6,"description":583},"published","en\u002Fprojects\u002Folist-ecommerce\u002F06-previsao-demanda",[592,593,594],"forecasting","scikit-learn","time-series","6RKu9waeTtV3Bfr0VzwzUz32-trTvIlUnO2qpKo7f3I",1788965102116]