Separating Prep Error from Portioning Drift
This page shows a food-tech developer how to split the inferred portion of a food-cost variance into its two operational causes: prep error (loss during preparation — over-trimming, mis-batching) and portioning drift (systematic over- or under-plating at service). It is the implementation companion to cost variance attribution models; read that for the additive decomposition and residual contract, then follow the steps here to estimate these two causes so they stay additive and reconcile to the total.
The distinction matters operationally: prep error is a back-of-house training and yield problem, portioning drift is a service-line discipline and scale problem. Blaming one for the other sends the fix to the wrong station.
Prerequisites and Data Contract
- Python 3.11+,
pandas2.x. - The measured-cause-adjusted remainder from attribution (total variance minus waste, substitution, supplier price), per
(location_id, ingredient_sku). - A prep-stage signal: batch yield observations from the yield factor frameworks. A portioning signal: per-portion weight samples from the line.
- Output is
prep_error_costandportioning_cost, signed andDecimal, that sum to the inferred remainder.
Step-by-Step Implementation
Step 1 — Estimate prep error from yield deviation
Prep error is the cost of the gap between a batch’s observed prep yield and its standard yield, over the quantity prepped.
from decimal import Decimal
import pandas as pd
def prep_error_cost(batches: pd.DataFrame) -> pd.DataFrame:
"""batches: ingredient_sku, prepped_qty, observed_yield, standard_yield, unit_cost."""
df = batches.copy()
df["yield_gap"] = df["standard_yield"] - df["observed_yield"] # positive = extra loss
df["prep_error_cost"] = df.apply(
lambda r: (Decimal(str(r["prepped_qty"])) * Decimal(str(r["yield_gap"]))
* Decimal(str(r["unit_cost"]))),
axis=1,
)
return df.groupby("ingredient_sku", as_index=False)["prep_error_cost"].sum()
Step 2 — Estimate portioning drift from per-portion samples
Portioning drift is the cost of the mean deviation between actual plated weight and the standard portion, over the number of portions served.
from decimal import Decimal
import pandas as pd
def portioning_cost(samples: pd.DataFrame, served: pd.DataFrame,
unit_costs: pd.DataFrame) -> pd.DataFrame:
"""samples: ingredient_sku, sampled_portion_wt; standard in `served`."""
mean_wt = samples.groupby("ingredient_sku", as_index=False)["sampled_portion_wt"].mean()
df = mean_wt.merge(served, on="ingredient_sku").merge(unit_costs, on="ingredient_sku")
df["portion_drift"] = df["sampled_portion_wt"] - df["standard_portion_wt"]
df["portioning_cost"] = df.apply(
lambda r: (Decimal(str(r["portion_drift"])) * Decimal(str(r["portions_served"]))
* Decimal(str(r["unit_cost"]))),
axis=1,
)
return df[["ingredient_sku", "portioning_cost"]]
Step 3 — Reconcile the two to the remainder
The two estimates rarely sum to the remainder on the nose; the gap is scaled proportionally so the decomposition stays exact and additive.
from decimal import Decimal
import pandas as pd
def reconcile_split(remainder: pd.DataFrame, prep: pd.DataFrame,
portion: pd.DataFrame) -> pd.DataFrame:
df = remainder.merge(prep, on="ingredient_sku", how="left") \
.merge(portion, on="ingredient_sku", how="left").fillna(Decimal("0"))
est_total = df["prep_error_cost"] + df["portioning_cost"]
# Scale both estimates so they sum exactly to the inferred remainder.
scale = df.apply(
lambda r: (r["inferred_remainder"] / est_total.loc[r.name])
if est_total.loc[r.name] != 0 else Decimal("0"),
axis=1,
)
df["prep_error_cost"] *= scale
df["portioning_cost"] = df["inferred_remainder"] - df["prep_error_cost"]
return df
Verification and Validation
- Additivity. After reconciliation, assert
prep_error_cost + portioning_cost == inferred_remainderexactly per SKU. A drift means a float slipped in. - Signal separation. For a SKU with a known prep problem (low batch yield) and correct portioning, the split must load onto
prep_error_cost, notportioning_cost. - Direction. Confirm signs: extra prep loss and over-plating both increase cost (positive); under-portioning is negative.
- Roll-up. Aggregate both components to the dish and confirm they still sum into the dish-level inferred remainder.
Gotchas and Edge Cases
- Yield already applied upstream. If the BOM quantity already folds in standard yield, prep error is the deviation from that standard, not the whole loss. Double-applying yield inflates prep error and starves portioning.
- Sparse portion samples. A mean over two portions is noisy. Require a minimum sample count before trusting the portioning estimate; below it, leave the remainder in the residual rather than fabricating a split.
- Correlated causes. A station that both over-trims and over-plates entangles the two signals. The proportional reconciliation keeps the total honest, but flag high-entanglement SKUs so the operator knows the split is approximate.
- Float in the scale factor. Keep the scaling ratio and both components in
Decimal; a float scale reintroduces the drift the exact reconciliation is meant to remove.
Related
- Cost Variance Attribution Models — the additive decomposition this split feeds.
- Yield Factor Calculation Frameworks — the source of the prep-yield signal.
- Portion Size Standardization — the standard portions the drift is measured against.
- Theoretical vs Actual Food Cost Calculation — the wider variance domain.
For library specifics, see the official pandas documentation on group-by aggregation.