Skip to content

XYZ Segmentation

Classify items by how predictable their demand is, from demand history alone. 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.

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 one file — a sales or shipment history export — and want a useful answer out of it without assembling anything else

What you need

One file, and it is the one every company can export without a project — a shipment or sales history. Map Demand History maps five columns off it, 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, YYYY-MM-DD
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.

No costs, no master data, no scenario. Wire the mapper straight into this node: it reads rows rather than a scenario, so it runs before anything is persisted.

One row it cannot read refuses the whole run. A date it cannot parse, a blank quantity, a missing item key — nothing is classified until they are fixed. 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

Setting Options Default What it decides
Group By 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 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 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 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.
Gap Treatment Zero, within the item's own life, Zero, across the whole window, Skip — read only periods with rows none — choose What a period with no rows means. This moves the answer more than the thresholds do. The question that decides it: does your export carry a row for every item in every period, or only rows where something moved? If every period is present, choose Zero, across the whole window. If only movements are, choose Zero, within the item's own life: a gap counts as no demand, but only between the item's own first and last sale, so a product launched four months ago is judged on four months rather than on twenty fabricated zeros. Skip reads only the periods that have rows, which measures variability among sales and ignores intermittency entirely. Under it, the reported average demand interval is almost always 1.0 and tells you nothing. Skip also changes what counts as too little history: Min Periods is measured against the shorter of the series and the item's life, and under Skip the series is the periods with sales.
X boundary 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 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 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.
  • Rows in more than one unit are not totalled. Adding cases to pallets is arithmetic on different things, so such an item comes back mixed_units. Converting between them would need master data, which is the data project this tool exists to avoid.
  • 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

Four things, in one result:

  • method — every parameter 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.
  • segments — one row per classified item: its class, the number of periods, the number with demand, the mean, the standard deviation, the cv, the adi, the total quantity, its unit, and the first and last period it was seen in.
  • unclassified — one row per item that got no class, with the reason and a sentence of detail. Nothing is given a class it has not earned.
  • summary — the counts by class and by reason, the net quantity in each class, and the deciles of the coefficient of variation across the classified items. The last of those is what makes run it once and look at where your own items fall something you can do from the result rather than from a spreadsheet, and the quantity is what stops a large unclassified count reading as a failure when it is a small share of the volume.

Workflow wiring

Three nodes, and no scenario:

flowchart LR
    n1["Import Data"] --> n2["Map Demand History"]
    n2 --> n3["XYZ Segmentation"]

The output is one result object with four keys, not a bare table — segments is the rows, and method, unclassified and summary travel with them. A script node receives the whole object on its Auxiliary Table input, so read table_data["segments"] rather than iterating table_data:

flowchart LR
    n1["XYZ Segmentation"] --> n2["Scenario Script (into Auxiliary Table)"]

Common mistakes

  • Wiring a scenario into it. This node reads demand-history rows; wire the mapper into it directly. It will tell you so rather than guess.
  • 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.
  • Assuming Min Periods means the same thing under all three readings. Under both Zero readings it is measured against the item's own life, so a launch is short of history either way. Under Skip it is measured against the periods that carry sales, which is a stricter test — on a real catalogue that is a few hundred more items reported unclassified.
  • Reading a class off Skip and treating it as a statement about intermittency. Skip looks only at periods that have sales, so an item selling once a quarter and an item selling every week can both come back X.
  • Comparing two runs whose Period Grain or Gap Treatment differ. The classes are not comparable; the method block on each result is where to check.

What it does not do

  • It does not read or write a scenario. Nothing is persisted by this node; the classification is its return value.
  • 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 result carries method.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. method.outliers_treated says so in the result.
  • 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.