AI powered demand forecasting for eCommerce

TL;DR

Difficulty: Intermediate

You do not need IKEA's budget to forecast demand well, you need a method.

  • Benchmark first: every model must beat the seasonal naive forecast, the same week last year.
  • Three models: SARIMA for steady staples, Prophet for strong seasons and gaps, gradient boosted trees once you have outside features known in advance.
  • Test honestly: rolling forecasts made only from earlier weeks, never fitted values.
  • Combine: a simple or error weighted average is hard to beat.
  • Decide: set the reorder point from the forecast's interval, not a fixed 15% buffer.

IKEA reported a 5% gain in forecast accuracy from AI demand sensing in Portugal, and the techniques underneath are open source. I compare SARIMA, Prophet and gradient boosted trees on an invented Galway outdoor shop's weekly sales, show how to test them on weeks they have never seen, combine them, and turn the forecast into a reorder point in Rails and Solidus.

What IKEA's demand sensing shows, and what it does not

When a retailer the size of IKEA gets its forecast wrong, the result is warehouses full of bookcases nobody wants and empty shelves where the popular ones should be. Its answer was demand sensing, an AI tool built in a three year collaboration between IKEA Retail (Ingka Group), Inter IKEA Systems, Blue Yonder and others. Ingka says it analyses short term demand patterns from inputs that include customer insights, market events, pricing, campaigns, localised weather reports and external economic factors, and it reports that at IKEA Portugal forecast accuracy improved by 5% overall.1

The earlier version of this article said the tool used "up to 200 data sources per product" and cut manual overrides "from 8% to 2%". I could not find either figure in a source from IKEA or Ingka that still resolves, so both are gone. The 5% is enough to make the point.

The point is this: the techniques underneath are not secret. The models are open source, and most of the data a smaller shop needs is already in its database. What separates gut feel stock buying from a forecast you can defend is not budget. It is method: a benchmark, an honest test, and a link from the forecast to a purchase order.

This article compares three approaches I use, SARIMA, Prophet and gradient boosted trees such as XGBoost, explains when each one earns its place, and shows how to wire them into a Rails and Solidus shop. The wider toolkit, from moving averages to judgemental methods, is in the complete guide to forecasting. The methods follow Hyndman and Athanasopoulos, Forecasting: Principles and Practice, which is free to read online.2

Throughout I use one invented business. Inagh Outdoor is an outdoor gear shop in Galway at inaghoutdoor.example, a short spin from the Connemara hills. It sells waterproof jackets, walking boots and camping gas, has three years of weekly sales history, and buys from suppliers with a two week lead time. Nobody real, and every number below is made up so that every step can be shown.

Three lessons that transfer to a smaller shop

The data you already have is worth more than you think. A Solidus database holds order history, and with consented tracking also product views and searches, plus your own promotion calendar. That is enough for a useful forecast.

Outside signals matter, but only some. Weather, bank holidays, paydays, your own promotions and your own ad spend all move demand. The question is not how many you can feed in, but which ones you will actually know in advance for the weeks you are forecasting.

Forecast at the level of the decision. IKEA forecasts per product per store. A shop that reorders per product from one warehouse needs a forecast per product per week, not per day per Eircode.

An example: Inagh reorders jackets every Monday, and the supplier delivers two weeks later. So the forecast that matters is weekly jacket sales for the next three weeks. A daily forecast would be noisier and answer a question nobody is asking.

Start with the benchmark everyone skips

Before anything clever, every forecast should be compared with the simplest possible ones. Hyndman and Athanasopoulos list four simple methods. For a seasonal shop the one that matters most is the seasonal naive method: next week's forecast is the same week last year.3 If a model cannot beat that, it has not earned its keep.

An example: in the first week of October last year Inagh sold 118 jackets. The seasonal naive forecast for the first week of October this year is 118. It ignores growth, weather and promotions, and it is still surprisingly hard to beat in a business that runs on the calendar.

Three models, three different questions

SARIMA, seasonal autoregressive integrated moving average, models each week from its own past: the recent values (autoregressive), the differences that remove a trend (integrated), the recent forecast errors (moving average), and the same terms a season back.4 It is the statistical workhorse. It suits staples with steady, repeating patterns, and with the SARIMAX implementation in statsmodels it can also take outside variables, called exogenous regressors.5

Prophet, released by Facebook's Core Data Science team and now maintained under Meta, fits an additive model with a trend, yearly and weekly seasonality and holiday effects, and is robust to missing data and shifts in the trend.6 Holidays are passed in as a table, and extra regressors such as a promotion flag can be added, as long as their values are known for both the history and the future dates.7 It suits products with strong seasons, holiday spikes and gaps in the history, such as weeks when a product was out of stock.

Gradient boosted trees, XGBoost or LightGBM, are general machine learning models. They do not know what time is. You give them features, such as last week's sales, the same week last year, price, a promotion flag, the weather forecast and the week of the year, and they learn how those combine, including effects that are not straight lines.

The earlier version of this article said XGBoost was "the only approach that naturally handles multiple input features". That was wrong. SARIMAX and Prophet both take outside variables.57 What trees do better is find interactions nobody specified, for example that a promotion only works when it is also raining.

The evidence is worth knowing. In the M5 forecasting competition, built on tens of thousands of retail sales series, the top ranks in both the accuracy and the uncertainty tracks were dominated by entries using gradient boosted trees, LightGBM above all.8 That is a result for large sets of related series with rich features. For one product line with three years of weekly data, it says very little.

Worked example: Inagh's jackets through each lens

SARIMA sees a series that repeats every 52 weeks, with a slow upward trend that disappears after one difference. Its forecast for October follows last October, nudged up by the trend.

Prophet sees a trend rising about 6% a year, a yearly season that peaks in October and November and bottoms out in June, and the extra lift in promotion weeks and bank holiday weeks. Its forecast for October is the trend plus the October season.

Gradient boosting sees features. Jacket sales rise with the number of rainy days in the coming week's forecast, jump when there is a promotion, and rise more when both happen at once. Its forecast for October depends on what the weather forecast and the promotion calendar say.

All three give a number. Which number to trust is the next question.

Choose the model on weeks it has never seen

The model is chosen by its errors on data it did not train on. The simplest measure is the mean absolute error:

MAE=1n∑t=1n∣yt−y^t∣\text{MAE} = \frac{1}{n}\sum_{t=1}^{n} \left| y_t - \hat{y}_t \right|

Here MAE\text{MAE} is the mean absolute error, nn the number of weeks tested, yty_t the actual sales in week tt, y^t\hat{y}_t the forecast for that week, and the vertical bars take the size of each miss whatever its sign. It is in the same units as the sales, here jackets. Foundations: the mean

Hyndman and Athanasopoulos describe the other common measures and their traps, among them that percentage errors break when actual sales are zero or close to it.9 For one product, MAE is easy to read and hard to misuse.

The important part is how the test is run. The earlier version of this article fitted each model on the whole history and then compared its fitted values with the last 20% of that same history. The models had already seen those weeks, so the test flattered them. The honest method is time series cross validation: a rolling forecasting origin, where each forecast is made using only the weeks before it.10 The TimeSeriesSplit class in the scikit-learn library produces exactly those splits.11

Worked example: four test weeks for the jackets

Four test weeks, each forecast made only from the weeks before it. Actual sales were 100, 120, 90 and 110. Prophet forecast 110, 115, 100 and 100.

The misses are ∣100−110∣=10|100 - 110| = 10, ∣120−115∣=5|120 - 115| = 5, ∣90−100∣=10|90 - 100| = 10 and ∣110−100∣=10|110 - 100| = 10. So MAE=(10+5+10+10)/4=8.75\text{MAE} = (10 + 5 + 10 + 10) / 4 = 8.75 jackets a week.

Over a full year of rolling tests, the example's results are: seasonal naive 14.0, SARIMA 11.2, Prophet 9.6 and gradient boosting 8.8 jackets a week. All three models beat the benchmark, so all three earn a place. For camping gas, a steady staple with no weather effect, the same test might show SARIMA winning and the trees adding nothing.

Combining forecasts is hard to beat

In practice I do not always pick one model. Combining forecasts has a long record. Hyndman and Athanasopoulos quote Clemen's review, which found the results "virtually unanimous: combining multiple forecasts leads to increased forecast accuracy", and note that a simple average has proven hard to beat.12

One common way to weight a combination is by the inverse of each model's error:

wm=1/MAEm∑k=1K1/MAEkw_m = \frac{1 / \text{MAE}_m}{\sum_{k=1}^{K} 1 / \text{MAE}_k}

Here wmw_m is the weight of model mm, MAEm\text{MAE}_m its mean absolute error in the rolling tests, KK the number of models, and the sum in the denominator makes the weights add up to 1. A model with half the error gets twice the weight. Foundations: ratios and rates

Worked example: one combined forecast for October

The inverse errors are 1/11.2=0.08931 / 11.2 = 0.0893 for SARIMA, 1/9.6=0.10421 / 9.6 = 0.1042 for Prophet and 1/8.8=0.11361 / 8.8 = 0.1136 for gradient boosting. They sum to 0.30710.3071. The weights are 0.0893/0.3071=0.290.0893 / 0.3071 = 0.29, 0.1042/0.3071=0.340.1042 / 0.3071 = 0.34 and 0.1136/0.3071=0.370.1136 / 0.3071 = 0.37.

For the first week of October the three forecast 120, 132 and 140 jackets. The weighted combination is 0.29×120+0.34×132+0.37×140=34.8+44.9+51.8=131.50.29 \times 120 + 0.34 \times 132 + 0.37 \times 140 = 34.8 + 44.9 + 51.8 = 131.5. The simple average is (120+132+140)/3=130.7(120 + 132 + 140) / 3 = 130.7. Less than one jacket apart, which is exactly why the simple average is so often good enough. Whether the combination beats the best single model is something the rolling test answers, not an assumption.

Wiring it into Rails and Solidus

The models run in Python, where the libraries are. Rails gathers the history, hands it over as JSON and stores the result. Sales come from completed orders in Solidus:

module Forecasting
  class History
    def self.weekly_units(variant)
      Spree::LineItem
        .joins(:order)
        .merge(Spree::Order.complete)
        .where(variant_id: variant.id)
        .group(Arel.sql("DATE_TRUNC('week', spree_orders.completed_at)"))
        .sum(:quantity)
        .sort
        .map { |week, units| { ds: week.to_date.iso8601, y: units } }
    end
  end

  class Runner
    def forecast(variant, horizon_weeks: 3)
      payload = {
        history: History.weekly_units(variant),
        calendar: WeeklyCalendar.flags(variant, past_and_next: horizon_weeks), # the shop's promotions and Irish bank holiday weeks
        horizon: horizon_weeks
      }
      output, status = Open3.capture2("python3", "forecast/run.py", stdin_data: payload.to_json)
      raise "forecast failed for #{variant.sku}" unless status.success?

      Forecast.create!(variant: variant, result: JSON.parse(output), generated_at: Time.current)
    end
  end
end

The data goes in on standard input, not pasted into the Python source as the earlier version did, so a product name with a quote in it cannot break the script. The Prophet part of run.py adds the promotion flag and a bank holiday flag as regressors, the way the documentation asks:7

import json, sys
import pandas as pd
from prophet import Prophet

data = json.load(sys.stdin)
history = pd.DataFrame(data["history"])
history["ds"] = pd.to_datetime(history["ds"])
promos = pd.DataFrame(data["calendar"])  # ds, promotion, bank_holiday_week (0 or 1), past and future weeks
promos["ds"] = pd.to_datetime(promos["ds"])
history = history.merge(promos, on="ds", how="left").fillna(0)

model = Prophet(yearly_seasonality=True, weekly_seasonality=False)
model.add_regressor("promotion")
model.add_regressor("bank_holiday_week")
model.fit(history)

future = model.make_future_dataframe(periods=data["horizon"], freq="W-MON")
future = future.merge(promos, on="ds", how="left").fillna(0)
forecast = model.predict(future).tail(data["horizon"])
print(forecast[["ds", "yhat", "yhat_lower", "yhat_upper"]].to_json(orient="records", date_format="iso"))

Two fixes from the earlier version. It created the model, then created a second one when holidays existed, throwing the first one's settings away. And it added a promotion regressor without putting the promotion column into the history or the future table, which Prophet requires.7 Weekly seasonality is off because the data is already weekly. For the same reason the bank holidays arrive as a weekly flag: Prophet's holiday effects sit on exact dates, and a bank holiday Monday seldom lands on the date that labels a week of sales.

The same script runs SARIMA through statsmodels and the trees through XGBoost, scores all of them in rolling tests and returns each forecast with its MAE, so Rails can apply the weights.

A forecast becomes a reorder point

A forecast sitting in a dashboard is decoration. It earns its keep when it decides a purchase order. And a forecast is a range, not a number: Hyndman and Athanasopoulos treat the prediction interval as part of the forecast itself.13 Prophet returns one by default, 80% wide, covering uncertainty in the trend and the noise in the observations but not in the seasonality unless you run full sampling.14

The earlier version of this article set the reorder point at the forecast demand over the lead time times 1.15, a fixed 15% buffer. That buffer ignores how uncertain the forecast actually is: it is too much for camping gas and too little for jackets in a wet October. The better rule uses the interval. Reorder when stock falls to the upper end of the forecast range for the lead time.

Worked example: when to reorder jackets

The lead time is two weeks. The combined forecast for the next two weeks is 131 and 129 jackets, so expected demand over the lead time is 260. The upper end of the 80% interval over the same two weeks is 310. An 80% two sided interval leaves 10% above its upper end, so stock of 310 covers lead time demand in roughly nine weeks out of 10, if the interval is honest.

The reorder point is 310 jackets. The difference, 310−260=50310 - 260 = 50, is the safety stock the forecast's own uncertainty asks for. On Monday Inagh has 340 jackets in stock, so no order. The following Monday it has 296, below 310, so the order goes in.

The fixed buffer would have said 260×1.15=299260 \times 1.15 = 299. Close this time, but for camping gas, where the interval is narrow, it would hold far more stock than needed, and in a sudden cold snap for jackets, far less. Check the interval honestly too: if actual sales land above the upper end much more often than one week in 10, the interval is too narrow, and the reorder point with it.

module Inventory
  class ReorderPoint
    def self.for(variant, lead_time_weeks:)
      weeks = Forecasting::Runner.new.forecast(variant, horizon_weeks: lead_time_weeks).result
      expected = weeks.sum { |w| w["yhat"] }
      upper = weeks.sum { |w| w["yhat_upper"] } # summing weekly upper ends is a conservative shortcut
      { expected: expected.round, reorder_point: upper.ceil, safety_stock: (upper - expected).ceil }
    end
  end
end

Summing the weekly upper ends, as the code does, slightly overstates the two week range, because a bad week is rarely followed by an equally bad one. For a shop of Inagh's size that errs on the side of being in stock, which is usually the right side, and in a Galway October that is no bad thing. A precise version simulates the two weeks together.

A purchase decision driven by a model should also be explainable after the fact: which model, which inputs, which interval. Storing every forecast with its inputs, as the runner does, is what makes that possible when someone asks in January why the shop bought 600 jackets.

Getting started

  1. Export weekly sales per product from Solidus or your platform: date, product, quantity sold. Mark the weeks a product was out of stock, because zero sales then mean zero stock, not zero demand.
  2. Compute the seasonal naive forecast. That is the bar.
  3. Fit Prophet with Irish public holidays and your own promotion calendar, and test it with rolling origins.
  4. Add SARIMA. If it beats Prophet on a product, that product's pattern is regular enough that simpler is better.
  5. Add gradient boosting only once you have outside features you know in advance, such as price, promotions and planned ad spend.
  6. Combine the survivors, weighted or simply averaged, and recheck monthly.
  7. Connect the forecast to the decision: a reorder point from the interval, a reorder alert, or the week to raise ad spend.

The gap between gut feel and a forecast is not sophisticated technology. It is taking the data you already have and asking it better questions, then checking the answers on weeks the model has never seen.

Sources

Footnotes

  1. Ingka Group, IKEA uses demand sensing to improve the customer offering, Ingka newsroom, 22 December 2023, https://www.ingka.com/newsroom/ikea-uses-demand-sensing-to-improve-the-customer-offering/

  2. Rob J Hyndman and George Athanasopoulos, Forecasting: Principles and Practice, 3rd edition, OTexts, Melbourne, 2021, https://otexts.com/fpp3/

  3. Rob J Hyndman and George Athanasopoulos, Forecasting: Principles and Practice, 3rd edition, section 5.2, Some simple forecasting methods, OTexts, 2021, https://otexts.com/fpp3/simple-methods.html

  4. Rob J Hyndman and George Athanasopoulos, Forecasting: Principles and Practice, 3rd edition, chapter 9, ARIMA models, OTexts, 2021, https://otexts.com/fpp3/arima.html

  5. statsmodels, statsmodels.tsa.statespace.sarimax.SARIMAX, documentation, accessed November 2026, https://www.statsmodels.org/stable/generated/statsmodels.tsa.statespace.sarimax.SARIMAX.html

  6. Meta, Prophet: forecasting at scale, project documentation, accessed November 2026, https://meta.github.io/prophet/

  7. Meta, Seasonality, holiday effects and regressors, Prophet documentation, accessed November 2026, https://meta.github.io/prophet/docs/seasonality,_holiday_effects,_and_regressors.html

  8. Tim Januschowski, Yuyang Wang, Kari Torkkola, Timo Erkkilä, Hilaf Hasson and Jan Gasthaus, Forecasting with trees, International Journal of Forecasting, vol. 38, no. 4, 2022 (open access), https://cdn.amazon.science/17/31/0d99831d4add92e711a56abc75f8/forecasting-with-trees.pdf

  9. Rob J Hyndman and George Athanasopoulos, Forecasting: Principles and Practice, 3rd edition, section 5.8, Evaluating point forecast accuracy, OTexts, 2021, https://otexts.com/fpp3/accuracy.html

  10. Rob J Hyndman and George Athanasopoulos, Forecasting: Principles and Practice, 3rd edition, section 5.10, Time series cross validation, OTexts, 2021, https://otexts.com/fpp3/tscv.html

  11. scikit-learn, sklearn.model_selection.TimeSeriesSplit, documentation, accessed November 2026, https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.TimeSeriesSplit.html

  12. Rob J Hyndman and George Athanasopoulos, Forecasting: Principles and Practice, 3rd edition, section 13.4, Forecast combinations, OTexts, 2021, https://otexts.com/fpp3/combinations.html

  13. Rob J Hyndman and George Athanasopoulos, Forecasting: Principles and Practice, 3rd edition, section 5.5, Distributional forecasts and prediction intervals, OTexts, 2021, https://otexts.com/fpp3/prediction-intervals.html

  14. Meta, Uncertainty intervals, Prophet documentation, accessed November 2026, https://meta.github.io/prophet/docs/uncertainty_intervals.html

If your stock buying still runs on last year plus a feeling, bring a sales export to the free workshop and we will see which products a forecast would change first. The first two weeks of work are on me.