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:
- Python 3.11+ — for modern type hints and the
decimal-free arithmetic Polars handles natively. polars1.x — the whole pipeline is expressed aswith_columns/when/then/otherwisechains on a lazy frame; a per-row loop defeats the purpose.pydantic2.6+ — the input cell frame and the emitted decision rows are validated against the same data schema standardization contracts the rest of the pipeline uses (v2 API:model_validate,model_dump).structlog24.x — the batch summary (rows priced, rows clamped, rows quarantined) is logged as one key=value event, greppable byproperty_id.- Input facts — a base rate per cell resolved from the rate plan taxonomy, an occupancy signal per cell, a normalized competitor benchmark from competitor rate shopping ingestion, and a floor/ceiling guardrail per cell.
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.
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.
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.
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.
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.
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
- Nulls are not zeros, and Polars will not pretend they are. A missing
competitor_avgfilled with0earlier in the flow silently makesmin(rate, 0 * 0.98)collapse every affected rate to the floor. Keep the null and branch onis_not_nullinside the expression, so a rate-shop outage degrades to demand-only pricing instead of dumping inventory. - Round once, and only in the last stage. It is tempting to
round(2)after the occupancy lift “to keep numbers clean,” but rounding mid-pipeline compounds error and diverges from the row-wise engine, so a cell can price a cent apart from what the single-cell engine in the rule engine guide produces — enough to trip the parity comparator. There is exactly oneroundin the whole pipeline. - Clip needs a valid range or it silently inverts.
clip(floor, ceiling)with a floor above its ceiling does not raise — it clamps toward whichever bound it hits and yields a nonsense rate. That is why Step 1 partitions contradictory guardrails out before the pricing expressions run, rather than trustingclipto catch them. - Occupancy tiers must be mutually exclusive top-down. If you rewrite the ladder as separate additive
whencolumns and sum them, a 0.91-occupancy cell earns the peak and the filling and the steady lift stacked together. The singlewhen/then/otherwiseladder matches exactly one branch per row, which is the only form that encodes a rate card faithfully.
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.
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.
Related
- Dynamic pricing rule engines — the parent guide: the ordered convert-adjust-clamp-round model this pipeline vectorizes.
- Backtesting pricing rules against historical demand — replaying this same pipeline over historical cells to estimate revenue impact.
- Competitor rate shopping ingestion — the normalized benchmark column the undercut phase consumes.
- Rate plan taxonomy design — where each cell’s authoritative base rate is resolved.
- Data schema standardization — the payload contracts the input and output frames are validated against.
← Back to Dynamic Pricing Rule Engines