Theoretical Vs Actual Food Cost Calculation

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+, pandas 2.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_cost and portioning_cost, signed and Decimal, that sum to the inferred remainder.
Splitting inferred variance into prep error and portioning drift The inferred remainder of a cost variance is split two ways. A prep-stage yield signal estimates how much of the remainder is prep error. A per-portion weight dispersion signal estimates how much is portioning drift. The two signed estimates sum back to the inferred remainder exactly. Inferred remainder after measured causes Prep-yield signal → prep_error_cost Portion dispersion → portioning_cost Sum = 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_remainder exactly 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, not portioning_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.

For library specifics, see the official pandas documentation on group-by aggregation.