Theoretical Vs Actual Food Cost Calculation

Cost Variance Attribution Models

A single food-cost variance number tells an operator that money leaked; it does not tell them where the hole is. This guide, part of the theoretical vs actual food cost calculation domain, isolates the sub-problem of decomposing total variance into named, additive causes — prep error, portioning drift, supplier quality, logged waste, and sanctioned substitution — so that a 4% overage becomes “1.5 points portioning, 1 point supplier price, 1 point trim, 0.5 point unexplained.” The failure this eliminates is the undifferentiated variance bucket that operators either ignore because it has no owner or over-correct because they guess at its cause. It builds directly on the clean signed residual produced by inventory snapshot reconciliation.

The cost variance attribution model Total cost variance enters an additive decomposition. Known components are subtracted in turn: logged waste, sanctioned substitution, supplier price variance, portioning drift, and prep error. What remains is an unexplained residual. All named components plus the residual sum exactly back to the total variance, so the attribution reconciles to the cent. Total variance from reconciliation Additive decomposition subtract known causes in order Waste · substitution · supplier Portioning drift · prep error Unexplained residual Σ components = total

Concept Definition and Data Contract

Attribution is a decomposition: given a total cost variance per (location_id, ingredient_sku), produce a set of named component variances that sum exactly to it. The input contract is the signed depletion and cost variance from reconciling theoretical vs actual depletion, plus the supporting feeds each cause needs: logged waste from waste tracking and routing systems, the substitution audit from automating SKU substitution, and supplier price deltas from supplier price variance tracking. The output contract is one row per cause with a signed variance_cost, plus a residual, all in Decimal.

The defining invariant is reconciliation: sum(components) + residual == total, exactly. An attribution that does not sum back to the total is not an explanation, it is a guess, and the residual is the honest measure of what remains unexplained.

Architecture Decision Rationale

Additive, signed decomposition over classification. Each cause is a signed monetary contribution, not a category label. This lets causes offset (a favorable supplier price partly masking unfavorable portioning) and guarantees the parts sum to the whole. A classification model that buckets each dollar into one cause cannot represent offsetting effects and never reconciles cleanly.

Subtract known causes first, residual last. Waste, substitution, and supplier price are measured from their own feeds, so they are subtracted directly. Portioning drift and prep error are inferred from what remains after the measured causes are removed. The residual is whatever is still unexplained — the signal that a feed is missing or theft is occurring.

Attribute at the ingredient grain, roll up for reporting. Attribution is computed per ingredient where the causes are physically meaningful, then aggregated to dish, category, or location for reporting. Attributing at the dish level first would blur which ingredient drove the variance. The prep-versus-portioning split is worked in detail in separating prep error from portioning drift.

Phase 1 — Assemble the Attribution Contract

The first phase joins the total variance to each measured cause feed into one frame, all in canonical units and Decimal cost.

from __future__ import annotations

from decimal import Decimal

import pandas as pd


def assemble(total: pd.DataFrame, waste: pd.DataFrame, subs: pd.DataFrame,
             supplier: pd.DataFrame) -> pd.DataFrame:
    keys = ["location_id", "ingredient_sku"]
    df = (
        total.merge(waste, on=keys, how="left")
        .merge(subs, on=keys, how="left")
        .merge(supplier, on=keys, how="left")
    )
    for col in ["waste_cost", "substitution_cost", "supplier_price_cost"]:
        df[col] = df[col].fillna(Decimal("0"))
    return df

Phase 2 — Decompose and Compute the Residual

Each measured cause is subtracted from the total; the inferred causes are estimated from the remainder; the residual is what is left. The decomposition is exact because every term is Decimal.

from decimal import Decimal

import pandas as pd


def attribute(df: pd.DataFrame, portioning: pd.DataFrame,
              prep: pd.DataFrame) -> pd.DataFrame:
    keys = ["location_id", "ingredient_sku"]
    df = df.merge(portioning, on=keys, how="left").merge(prep, on=keys, how="left")
    for col in ["portioning_cost", "prep_error_cost"]:
        df[col] = df[col].fillna(Decimal("0"))

    components = ["waste_cost", "substitution_cost", "supplier_price_cost",
                  "portioning_cost", "prep_error_cost"]
    df["attributed_total"] = df[components].sum(axis=1)
    df["residual"] = df["total_variance_cost"] - df["attributed_total"]
    return df

Phase 3 — Reconcile and Route the Residual

Attribution is validated by reconciliation, and a residual beyond a tolerance is routed for investigation rather than absorbed.

from decimal import Decimal

import pandas as pd


def reconcile_and_route(df: pd.DataFrame,
                        residual_tol: Decimal = Decimal("0.02")) -> pd.DataFrame:
    df = df.copy()
    # Invariant: components + residual must equal total, exactly.
    check = (df["attributed_total"] + df["residual"] - df["total_variance_cost"]).abs()
    assert (check == Decimal("0")).all(), "attribution does not reconcile to total"

    denom = df["total_variance_cost"].abs().where(lambda s: s != 0, Decimal("1"))
    df["residual_flag"] = (df["residual"].abs() / denom) > residual_tol
    return df

A large residual is not noise to be swept into “miscellaneous” — it is the most important output of the model, because it says the measured causes do not explain the loss and something (an unlogged waste stream, a broken feed, or theft) is missing. Routing it for investigation is what turns attribution into an operational tool. This feeds the alerting logic in threshold tuning for alerts.

Production Hardening

  • Exact reconciliation. Compute every component in Decimal and assert components + residual == total on every run; a non-reconciling attribution is a bug, not a rounding artifact.
  • Signed components. Keep each cause signed so offsetting effects are represented and the decomposition stays additive.
  • Residual as a first-class signal. Flag and route large residuals for investigation; never fold them into a named cause to make the picture look complete.
  • Ingredient-grain attribution. Attribute where causes are physical, then roll up; do not attribute at the dish level and lose the ingredient signal.
  • Feed completeness. Track which cause feeds were present per run; a missing waste feed inflates the residual, and the report should say so rather than implying unexplained loss.

Failure Modes and Troubleshooting

Symptom Likely cause Detection / fix
Components do not sum to total Float arithmetic or a dropped cause Compute in Decimal; assert the reconciliation invariant each run.
Residual is always large A cause feed (waste, substitution) missing Track feed presence; a missing feed inflates residual — surface it.
Favorable and unfavorable causes cancel invisibly Causes stored as magnitudes Keep every component signed so offsets are visible, not hidden.
Variance blamed on the wrong ingredient Attribution done at dish grain Attribute per ingredient, then aggregate for reporting.
Residual quietly absorbed Residual folded into “other” Route residuals beyond tolerance to investigation as a distinct signal.

FAQ

Why must the components sum exactly to the total variance?

Because an attribution that does not reconcile is a guess, not an explanation. If the named causes plus the residual do not equal the measured total to the cent, some variance has been double-counted or dropped, and no operator can trust the breakdown. Computing every term in Decimal and asserting the invariant on each run makes reconciliation a guarantee rather than an aspiration.

What is the residual, and why keep it separate?

The residual is total variance minus every measured and inferred cause — the part the model cannot explain. Keeping it separate is the whole point: a large residual means a feed is missing or loss is occurring that the known causes do not cover, which is exactly the situation an operator needs flagged. Folding it into a named cause would hide the most actionable signal.

Why attribute at the ingredient level instead of per dish?

Because the physical causes — trim, portioning, supplier grade — act on ingredients, not plates. Attributing per ingredient keeps each cause meaningful and lets you say which ingredient drove a dish’s overage. Rolling up to dish, category, or location for reporting is a straightforward aggregation once the ingredient-grain attribution exists; going the other way loses the signal.

How are measured causes different from inferred ones?

Measured causes — logged waste, sanctioned substitution, supplier price change — come from their own feeds and are subtracted directly. Inferred causes — portioning drift, prep error — are estimated from what remains after the measured causes are removed. The order matters: subtract what you can measure first, infer the rest, and treat whatever is still unexplained as the residual.

For library specifics, see the official pandas documentation and Python decimal documentation.