Building a Yield Rule Pipeline with Polars

Pricing one cell at a time in a Python loop is fine for a demo and hopeless for a portfolio: a 40-property estate with a year of dates across a handful of room types and rate plans is millions of (property_id, room_type_code, rate_plan_code, date) cells, and a per-row loop that resolves rules for each one turns a nightly repricing into an hours-long job. The fix is to express the whole ordered rule set as a sequence of vectorized Polars expressions over one dataframe, so every cell is lifted, undercut, clamped, and rounded in a handful of column operations. This page is the batch companion to the dynamic pricing rule engine, which defines the convert → adjust → clamp → round order this pipeline applies at portfolio scale.

The goal is one authoritative final_rate column produced deterministically from the input facts, with the same phase order as the single-cell engine, so a rate computed in the batch pass is bit-for-bit the rate the row-wise engine would have produced. Get the expression order right once and the portfolio prices in a single lazy query the planner can optimize.

Prerequisites & environment

The pipeline runs wherever your repricing job runs — a nightly worker, an on-demand recompute triggered by a base-rate change, or a backtest harness. Pin these versions so the vectorized math and the rounding behave identically across environments:

Every input column must already be normalized to the property’s own currency and tax basis; the yield pipeline adjusts and clamps numbers, it does not convert them, so a gross-vs-net mistake upstream becomes a portfolio-wide mispricing here.

A cell dataframe flowing through four vectorized Polars phases to one authoritative rate column A dataframe of pricing cells keyed by property, room type, rate plan and date enters on the left. It passes through four vectorized column stages. First, an occupancy-tier lift adds a working_rate column from the base. Second, a competitor undercut takes the elementwise minimum of the lifted rate and a target derived from the benchmark. Third, a guardrail clamp holds the working rate between floor and ceiling. Fourth, a single round snaps to two decimals, producing the final_rate column. A quarantine branch drops off rows where floor exceeds ceiling. One dataframe, four vectorized phases, one authoritative rate column Each phase is a with_columns expression; the whole portfolio prices in a single lazy pass. cell frame property · room plan · date base · occ · comp 1 · occ lift tier → working_rate when/then/otherwise 2 · comp undercut min(rate, target) elementwise 3 · clamp floor .. ceiling clip() 4 · round once round(2) → final_rate final_rate authoritative floor > ceiling → quarantine
Figure 1: the cell dataframe passes through four vectorized column phases in the same convert-adjust-clamp-round order as the row-wise engine, emitting one authoritative rate column while contradictory guardrail rows branch to quarantine.

Step-by-step implementation

Build the pipeline as four with_columns stages on a lazy frame, each stage adding or overwriting exactly one working column, then materialize once. Keeping the phases as separate expression blocks — rather than one giant chained expression — is what keeps the order readable and lets you assert on the intermediate columns in tests.

Step 1 — Build the cell frame and split out contradictory guardrails

Start from a typed frame of cells. Before any pricing, partition off rows where a floor exceeds its ceiling: those inputs are contradictory and must be quarantined, never priced, because a clamp cannot satisfy an empty range.

python
import polars as pl
import structlog

log = structlog.get_logger()

cells = pl.DataFrame({
    "property_id":    ["LON-STJ-01", "LON-STJ-01", "PAR-OPR-02"],
    "room_type_code": ["DLX_KING", "DLX_KING", "STD_TWIN"],
    "rate_plan_code": ["bar_flex", "bar_flex", "bar_flex"],
    "stay_date":      ["2026-08-14", "2026-08-15", "2026-08-14"],
    "base_rate":      [180.0, 180.0, 120.0],
    "occupancy":      [0.82, 0.55, 0.91],
    "competitor_avg": [171.0, 175.0, None],       # None = benchmark missing/stale
    "floor":          [150.0, 150.0, 100.0],
    "ceiling":        [340.0, 340.0, 260.0],
}).lazy()

# Contradictory guardrails can never be clamped into a valid range — quarantine them.
priceable = cells.filter(pl.col("floor") <= pl.col("ceiling"))
quarantine = cells.filter(pl.col("floor") > pl.col("ceiling"))

Splitting the frame with filter before pricing rather than handling the bad rows inside the clamp keeps the pricing expressions total — every row that reaches the clamp has a satisfiable range — so the vectorized math never has to encode an error branch.

Step 2 — Apply occupancy-tier lifts as a vectorized when/then ladder

Express the demand lift as a single when/then/otherwise ladder that maps an occupancy band to a multiplier, computing working_rate from base_rate for every row at once. Ordering the branches from the highest tier down makes the ladder read as a rate card.

python
priced = priceable.with_columns(
    working_rate=pl.col("base_rate") * (
        pl.when(pl.col("occupancy") >= 0.90).then(1.20)   # peak demand
          .when(pl.col("occupancy") >= 0.80).then(1.12)   # filling
          .when(pl.col("occupancy") >= 0.60).then(1.04)   # steady
          .otherwise(1.00)                                 # soft: hold base
    )
)

Because the branches are evaluated top-down and are mutually exclusive by construction, a cell at 0.91 occupancy matches only the peak tier — there is no double-counting of lifts, which is the failure a naive sequence of additive += adjustments would introduce.

Step 3 — Undercut the competitor benchmark, skipping stale rows

Pull the lifted rate down toward the competitor set with an elementwise minimum, but only where a benchmark exists. A None benchmark must leave the demand-priced rate untouched rather than collapsing it toward zero — a missing rate shop is not a signal to give the room away.

python
priced = priced.with_columns(
    working_rate=pl.when(pl.col("competitor_avg").is_not_null())
      .then(
          pl.min_horizontal(                       # never sit above 2% under the comp set
              pl.col("working_rate"),
              pl.col("competitor_avg") * 0.98,
          )
      )
      .otherwise(pl.col("working_rate"))           # no benchmark → demand price stands
).with_columns(
    competitor_applied=pl.col("competitor_avg").is_not_null()
)

Guarding the undercut with is_not_null inside the when — instead of filling nulls with a sentinel earlier — means a stale benchmark degrades gracefully to demand-only pricing and is flagged per row in competitor_applied, so the batch summary can report exactly how many cells priced without a competitor signal.

Step 4 — Clamp to guardrails, then round exactly once

Clamp the working rate inside the per-cell floor and ceiling with clip, then round to the minor unit in a single final step. Rounding last, once, is what keeps the batch output identical to the row-wise engine and safe for the parity comparator.

python
result = priced.with_columns(
    pl.col("working_rate").clip(pl.col("floor"), pl.col("ceiling")).alias("working_rate")
).with_columns(
    final_rate=pl.col("working_rate").round(2)     # single rounding, at the very end
).select(
    "property_id", "room_type_code", "rate_plan_code", "stay_date",
    "base_rate", "final_rate", "competitor_applied",
).collect()                                        # materialize the whole portfolio once

log.info("yield_pipeline_complete", rows_priced=result.height,
         no_competitor=int(result["competitor_applied"].not_().sum()))

Calling collect() only after all four phases are declared lets Polars fold the entire pipeline into one optimized query plan and price the whole portfolio in a single pass, instead of materializing an intermediate frame between every rule.

Gotchas & production notes

Verification snippet

Confirm the two behaviours that make the batch pipeline trustworthy: that it agrees with the row-wise engine on a shared cell, and that a stale benchmark and a contradictory guardrail are both handled without poisoning the output.

python
def test_batch_matches_expected_authoritative_rate():
    df = result.filter(
        (pl.col("property_id") == "LON-STJ-01") & (pl.col("stay_date") == "2026-08-14")
    )
    # base 180 * 1.12 (occ 0.82) = 201.60; comp 171*0.98=167.58 undercuts;
    # clamp keeps it above floor 150; round → 167.58
    assert df["final_rate"][0] == 167.58

def test_missing_benchmark_holds_demand_price():
    par = result.filter(pl.col("property_id") == "PAR-OPR-02")
    # occ 0.91 → 120 * 1.20 = 144.0; no competitor_avg → undercut skipped; round → 144.0
    assert par["final_rate"][0] == 144.0
    assert par["competitor_applied"][0] is False

def test_contradictory_guardrails_are_quarantined():
    bad = pl.DataFrame({"floor": [200.0], "ceiling": [150.0]}).lazy()
    assert bad.filter(pl.col("floor") > pl.col("ceiling")).collect().height == 1

test_batch_matches_expected_authoritative_rate()
test_missing_benchmark_holds_demand_price()
test_contradictory_guardrails_are_quarantined()

The first assertion is the load-bearing one: it pins the batch output to a hand-computed authoritative rate, so a refactor of the expression order that silently changes a price fails the test instead of shipping a mispriced portfolio. In production, also assert that quarantine.collect().height plus result.height equals the input row count, so no cell is ever dropped on the floor between the two branches.

← Back to Dynamic Pricing Rule Engines