A size curve says what share of a style each size should get. Here is one for a
shirt: S 8%, M 22%, L 35%, XL 25%, XXL 10%.
The supplier does not take percentages. They take a whole number per size, and the
whole numbers have to add up to the buy. For 60 units the arithmetic is kind: the
exact figures are 4.8, 13.2, 21, 15 and 6, they round to 5, 13, 21, 15 and 6, and
that is 60.
Change the buy to 61 and it stops being kind.
Plain rounding drifts
| Size | Exact | Rounded |
|---|---|---|
| S | 4.88 | 5 |
| M | 13.42 | 13 |
| L | 21.35 | 21 |
| XL | 15.25 | 15 |
| XXL | 6.10 | 6 |
| Total | 61 | 60 |
Every size rounds down except S, and the order comes out one short. At 71 it goes
the other way: 5.68, 15.62, 24.85, 17.75 and 7.10 round to 6, 16, 25, 18 and 7,
which is 72.
One unit is not much, but it is not the buy you planned, and nothing says which
size should absorb it.
Small buys have a worse failure. Split 6 units for a test run and the exact
figures are 0.48, 1.32, 2.10, 1.50 and 0.60. They round to 0, 1, 2, 2 and 1. The
total is right and S is gone.
The largest-remainder fix
The method is old; some countries share out parliamentary seats this way.
- Round every size down.
- Count how many units that leaves short of the total.
- Give one each to the sizes with the largest fractions, in that order, until the total is met.
For 61, rounding down gives 4, 13, 21, 15 and 6, which is 59. Two units are left.
The fractions are S 0.88, M 0.42, L 0.35, XL 0.25 and XXL 0.10, so S and M get
them: 5, 14, 21, 15 and 6, which is 61.
For 71, rounding down gives 68, three short, and the three go to L (0.85), XL (0.75)
and S (0.68): 6, 15, 25, 18 and 7. The total cannot drift, because it is counted
out, not summed afterwards, and no size moves a whole unit from its exact figure.
In Python:
from math import floor
def split(total, curve):
"""curve: {size: percent}. Returns whole units that add up to total."""
exact = {s: total * p / 100 for s, p in curve.items()}
units = {s: floor(x) for s, x in exact.items()}
short = total - sum(units.values())
by_remainder = sorted(exact, key=lambda s: exact[s] - units[s], reverse=True)
for s in by_remainder[:short]:
units[s] += 1
return units
curve = {"S": 8, "M": 22, "L": 35, "XL": 25, "XXL": 10}
for total in (60, 61, 71):
print(total, split(total, curve))
60 {'S': 5, 'M': 13, 'L': 21, 'XL': 15, 'XXL': 6}
61 {'S': 5, 'M': 14, 'L': 21, 'XL': 15, 'XXL': 6}
71 {'S': 6, 'M': 15, 'L': 25, 'XL': 18, 'XXL': 7}
At 6 units largest remainder still gives S nothing: 0, 1, 2, 2 and 1, because
0.48 is the weakest claim on the spare units.
A floor of one, and what it costs
A size with no stock is not a small bet. It is no bet: the page shows it sold
out from day one, and you learn nothing about whether anyone wanted it. So many
buyers set a floor of one per size.
To keep the total, lift any size at zero to one and take the unit from the size
furthest above its exact figure. At 6 units that is XL, 2 against an exact 1.5:
| Size | Exact | Largest remainder | Floor of 1 |
|---|---|---|---|
| S | 0.48 | 0 | 1 |
| M | 1.32 | 1 | 1 |
| L | 2.10 | 2 | 2 |
| XL | 1.50 | 2 | 1 |
| XXL | 0.60 | 1 | 1 |
The cost is plain. S is 8% of the curve and now 16.7% of the buy, and XL, a quarter
of the curve, is down to one unit. On a test run that is usually worth it. At 60,
61 or 71 units no size is near zero, and the floor changes nothing.
Case packs
If the supplier ships in packs of 6, split packs, not units. A buy of 72 is 12
packs. The exact packs are 0.96, 2.64, 4.2, 3 and 1.2; rounding down gives 10, and
the two spare packs go to S (0.96) and M (0.64). That is 1, 3, 4, 3 and 1 packs,
or 6, 18, 24, 18 and 6 units, against exact figures of 5.76, 15.84, 25.20, 18 and
7.20.
Each size can now sit up to a whole pack from its exact figure, not one unit. M is
2.16 over and L 1.2 short. The bigger the pack against the buy, the less of the
curve survives it.
The size split calculator
starts from what each size sold, what came back and what is on hand, and works out
the order. Left alone, it rounds each size on its own, to the nearest unit and never below
zero. Open "My buy is fixed", type the total, and it counts the buy out by the method
above: rounded down, the spare units to the largest fractions, in whole packs if you
give a pack size, and at least one of every size if you tick it. The total is always
the buy.
I build it, as part of a Shopify app
that does the same for every style from your orders and refunds. It is $29 a month,
with a 14-day free trial.
Top comments (0)