Skip to content

XYZ Segmentation

Classify items by how predictable their demand is, from a scenario's demand history. Steady sellers come back X, variable ones Y, erratic ones Z — and anything the history cannot support a class for comes back named, with the reason, rather than guessed at. The answer is added to the scenario, so the history, the classes and the rule that drew them are saved together.

When to use this

  • You want to know which products are stable enough to plan around, before committing to any siting or routing work built on top of them
  • You are sizing safety stock and need to treat predictable items differently from unpredictable ones
  • You have a sales or shipment history export and the list of places it names, and want a useful answer without assembling costs or capacities

What you need

Three files every company can export without a project: a shipment or sales history, the list of the places it names, and the list of the products it names. Map Demand History maps five columns off the history, and all five are required by that record type:

Column What it is
Product the item key you plan by
Location where the demand was observed
Date the observation date. Common date formats and Excel date numbers are converted; set Read as under the Date field when a date like 01/02/2026 could be read either way
Quantity the observed quantity, negatives included
Unit the unit that quantity is in

All five fields are required. The mapper refuses a row without a Location or a Unit, so a three-column file is rejected before it reaches this node. If your export has no unit column, map a constant.

Every Location the history names must be one of the scenario's locations. Map the list of places with Map Locations and wire it into the same Create Scenario as the history. A row naming a place that is not in the list stops Create Scenario and names the row, rather than the place being made up.

Every Product the history names must be one of the scenario's products. List them with Map Products and wire it into the same Create Scenario. A row naming a product that is not in the list stops Create Scenario and names the row, so a misspelled item is caught rather than segmented as an item of its own.

No costs. Wire the three mappers into a Create Scenario, and that into this node: a scenario built from the history, its locations and its products is enough.

One row it cannot read refuses the whole run. A date it cannot parse, wherever the row sits, and inside the window a blank quantity or a missing item key — nothing is classified until they are fixed. A row dated outside the window is left out, and the method row counts it. That is deliberate: a row silently dropped changes an item's mean without telling anybody. The refusal is written to be repairable without opening the file — for each kind of fault it gives the count, the row numbers, the offending values and what the field wanted instead — so a real export usually needs one pass to find its bad rows and one to run.

Nothing has a default, and that is deliberate

Every parameter below is required. There is no default on any of them, and the node refuses to run until each one is answered — you will see a configuration finding against the node naming what is still unset.

The reason is that these are business choices, not properties of your data. Where the class boundaries fall, what time bucket demand is measured in, what a period with no sales means — each has more than one defensible answer, and the right one depends on your portfolio and what you are going to do with the result. A default would be a decision nobody made, silently inherited, quietly changing every number downstream. So the tool asks instead, and the result states back every answer it was given.

Settings

The panel asks for each setting with a question, shown here beside the setting's name.

Setting Question in the panel Options Default What it decides
Group By What counts as one item Product, Location, or both none — choose What counts as one item. Product alone segments the portfolio; Product and Location together segment each stocking point on its own. An item that looks stable nationally is routinely erratic at three of its sites, so if you are going to set stock levels from this, you want the second.
Period Grain The bucket demand is measured in Day, Week (Monday start), Month none — choose The bucket demand is measured in. A coefficient of variation of 0.8 means stable daily and erratic monthly, so the thresholds below are uninterpretable without it. Monthly is what most demand reviews use.
First day The stretch of history to read a date, YYYY-MM-DD none — choose The first day of history to read. It must sit on a bucket boundary — the 1st of a month for a monthly grain, a Monday for a weekly one — because a partial first bucket reads as a demand dip and inflates every item's variability.
Last day The stretch of history to read a date, YYYY-MM-DD none — choose The last day of history to read; the last day of a month, or a Sunday, on the same rule. Two years of monthly history lets a seasonal cycle repeat twice. Give explicit dates rather than thinking in the last N months: a relative window silently reclassifies the portfolio on every re-run.
Period range Which periods should count? Between first and last sale, Whole analysis window; Recorded periods only (legacy) on an older node none — choose Missing periods count as zero sales within the chosen range. Between first and last sale ignores periods outside each item's first and last sale; use it when these dates approximate when the item was available. Whole analysis window includes every period in the window; use it when items were available throughout, including months with no sales. First and last sale do not necessarily mean launch and discontinuation. Recorded periods only (legacy) is deprecated: it counts only periods with a recorded row and ignores the gaps, which makes occasional sales look steady. It still runs on a node saved with it; choose one of the other two to replace it.
X boundary Where the class boundaries fall a number, 0 or above none — choose The highest coefficient of variation still called X. The textbook split is 0.5 with the Y boundary at 1.0; tight fast-moving portfolios often run 0.25 and 0.5. The honest way to pick is to run it once and look at where your own items fall — every classified row carries its coefficient of variation, which is what the first run is really for.
Y boundary Where the class boundaries fall a number, at or above the X boundary none — choose The highest coefficient of variation still called Y. Anything above it is Z.
Min Periods How much history an item needs to get a class a whole number, 2 or more none — choose The fewest periods an item needs before it gets a class at all. The window has to be wide enough to hold this many buckets — a three-month window against a minimum of six is refused when the node is configured, because it would classify nothing whatever the file contained. Below it the item is reported unclassified with that reason rather than given one. Six monthly buckets is about where a coefficient of variation stops being noise; seasonal work wants twelve.

Every one of these is on the node's config panel. Nothing here can only be set by editing JSON.

What is fixed, and reported rather than asked

Some things are not choices, so they are not parameters. The result states each of them so nothing about the method is implicit:

  • The measure is the coefficient of variation — the sample standard deviation of bucketed demand, the n-1 form your spreadsheet calls STDEV, divided by the mean. There is only one measure in this version, so there is nothing to choose; the result names it anyway.
  • An item needs at least two periods with demand. One observation has no standard deviation. That is arithmetic, not policy, so it is enforced rather than asked, and an item below it is reported as single_observation.
  • Demand is read in the scenario's unit. Every row is converted into the Unit stated on Create Scenario with its product's conversion, as the optimizer converts it, so an item recorded in cases and in pallets is one series. A scenario with no unit, or a row no conversion reaches, is refused with the product and the units named.
  • An item whose demand is zero in every period has no class. Rows with a quantity of 0 put an item in the history without any demand to measure, and it is reported as all_zero.
  • Negative quantities are kept. Returns net off, because net demand is what gets planned. An item whose quantities net to zero or less has no mean to divide by and is reported as nets_to_zero_or_less.
  • The average demand interval is reported for every classified item — periods divided by periods with demand — even though nothing classifies on it yet. It is what tells a slow mover that sells four times a year apart from a volatile item that sells every week, and those two want opposite stocking policies. Classifying on it is a later release; the number is here now.

What you get

The scenario it was given, with two tables added. A Create Scenario after it saves all of it — the demand history, and the answer drawn from it — as one scenario you can open, query and put on a dashboard.

  • item-segments — one row per item, classified or not: the product (and the location, when you grouped by it), its class — X, Y, Z, or unclassified — and for an unclassified item the reason and a sentence of detail. Every row also carries the number of periods, the number with demand, the net total_quantity in the scenario's unit, and the first and last period the item was seen in; a classified row adds the mean, the standard deviation, the cv and the adi. Nothing is given a class it has not earned.
  • segmentation-method — one row: every setting you gave, the measure, the class rule, and how many rows were read, used and left outside the window. This is what makes the answer checkable a year later.

An unclassified item's reason in item-segments is one of:

  • mixed_units — the item's rows still carry more than one unit, so they cannot be added into one series
  • too_few_periods — fewer periods than Min Periods
  • all_zero — demand is zero in every period
  • single_observation — demand in only one period
  • nets_to_zero_or_less — returns cancel the sales, so there is no mean

These tables are the results, and your agent inspects and queries them like any other table of the scenario: how many items were segmented, how many landed in each class and how many are unclassified, and each class's share of the demand volume. The share is what stops a large unclassified count reading as a failure when it is a small part of the volume. A scenario no segmentation built holds no rows in either.

To put the class boundaries somewhere useful, run it once and look at where your own items' cv values fall — a query over item-segments grouped by class answers it, as does a table tile on a dashboard.

Workflow wiring

flowchart LR
    n1["Import Data"] --> n2["Map Demand History"]
    n2 --> n3["Create Scenario"]
    n6["Import Data (locations)"] --> n7["Map Locations"]
    n7 --> n3
    n3 --> n4["XYZ Segmentation"]
    n4 --> n5["Create Scenario"]
    n8["Import Data (products.csv)"] --> n9["Map Products"]
    n9 --> n3

Getting the item rows out as a file is one Scenario to Table and a Save Data, on a branch beside the Create Scenario that saves the answer. Scenario to Table takes the scenario and returns rows; Save Data writes them as a data source you can download.

flowchart LR
    n1["Load Scenario"] --> n2["XYZ Segmentation"]
    n2 --> n3["Create Scenario"]
    n2 --> n4["Scenario to Table (into Scenario Data)"]
    n4 --> n5["Save Data"]
def main(scenario_data):
    """One row per item, with its class or the reason it has none."""
    return scenario_data["item-segments"]

Common mistakes

  • Wiring Map Demand History straight into it. This node reads a scenario; put a Create Scenario between them. The canvas refuses the edge, and a run says so rather than guessing.
  • Leaving out the Create Scenario after it. The answer rides on the scenario, and without one downstream nothing saves it — the run is refused before it starts.
  • Giving a window that starts mid-month with a monthly grain. The first bucket would be a partial period, which reads as a slump. The run is refused with the window it wanted instead.
  • Choosing Whole analysis window when items launch or are discontinued inside the window. The periods before an item's first sale and after its last count as zero sales and push it toward Z; Between first and last sale leaves them out.
  • Comparing two runs whose Period Grain or Period range differ. The classes are not comparable; the segmentation-method row on each is where to check.

What it does not do

  • It does not save anything itself. The classes ride on the scenario it returns, and the Create Scenario downstream is what stores them.
  • It does not remove seasonality. A clean seasonal item has a high coefficient of variation and comes back Y or Z — correct arithmetic, wrong answer about the item. How much that costs depends on your portfolio and is not something this page will guess at: on one published two-year retail catalogue, dividing out a population-average month-of-year index moved 8% of the full-history items, about as many to a harsher class as to a calmer one. Your range may be far more seasonal than that. The method row carries seasonality_removed, so a downstream consumer does not have to have read this page. A deseasonalised measure is a later release.
  • It does not exclude periods you know are unrepresentative — a strike month, a system migration, a one-off tender. Later release, same reason: it is worth having once a first result has shown you which periods those are.
  • It does not do ABC. Value segmentation is a different question with a different threshold family; every classified row carries its total quantity, so the join is available to whatever does it.
  • It does not treat outliers, and a promotional spike counts, because a silent adjustment to a number you are trying to recognise is worse than no adjustment. The method row's outliers_treated says so.
  • It does not decide whether ABC1 and abc1 are one item. An ERP may distinguish them and this tool cannot know, so a file containing both is refused with the spellings named, and you settle it in the data. Silently keeping them apart produced, on a real catalogue, a 26-unit phantom classified as the most predictable line in the range.
  • It does not know what a coefficient of variation means at low volume. Below roughly thirty units a bucket the CV is dominated by counting noise rather than by demand behaviour, so small items drift toward Z whatever their pattern. The volume floor that addresses it is a later release.

Messages this node can show

A run gives this node 5 minutes. A node that takes longer stops with Time limit reached.

Like every node, it can also stop on the checks before a run and the run's limits.