Computing Parity Deviation Scores Across Channels
A raw list of parity gaps tells you nothing about where to act first — a 6% undercut on a date that sells two rooms a year matters far less than a 2% undercut on your highest-volume channel during peak season. This page builds the scoring math that turns thousands of observed-versus-authoritative rate pairs into a single ranked signal per channel and date, so the parity monitoring and alerting loop can page on what actually costs money rather than on whatever breached most recently. It is the vectorized Polars companion to that guide’s scoring step.
The output is three things: a signed percentage deviation per (property_id, room_type_code, rate_plan_code, stay_date, channel) cell, a volume-weighted severity that scales the raw deviation by how much the cell earns, and a rolling worst-offender ranking that names the channels dragging parity down over a trailing window. Get the weighting right and the alert stream reorders itself around revenue impact instead of raw percentage.
Prerequisites & environment
The scorer runs as a batch pass over a normalized observation frame, downstream of the shopping layer and upstream of the alert gate. Pin these versions so the deviation math and the rolling window behave identically across workers:
- Python 3.11+ — for modern type hints and the
datetimehandling used in the rolling window. polars1.x — the entire scoring pass is expressed as vectorized expressions and a group-by rolling aggregation, not a per-row Python loop; on a portfolio-scale frame this is the difference between seconds and minutes.pydantic2.6+ — the input rows are already validatedParityObservationrecords from parity monitoring and alerting, so the scorer trusts types but re-checks the one invariant that breaks its math: a non-zero authoritative rate.structlog24.x — the worst-offender ranking is emitted as a key=value event keyed bychannel, so a revenue manager can grep the ranking history rather than screenshot a dashboard.- Normalized input — observed and authoritative rates must already be on the same gross-versus-net and currency basis, per normalizing competitor rates for gross versus net. The scorer assumes like-for-like; it cannot detect a basis mismatch.
- A booking-volume source — a per-cell weight, typically trailing bookings or forecast demand for that
(room_type_code, rate_plan_code, stay_date), so deviation can be scaled by revenue exposure.
Step-by-step implementation
The scorer is four expressions applied in order: a signed deviation, a volume weight, a weighted severity, and a rolling group-by ranking. Each is a Polars operation over the whole frame, so the cost is one pass regardless of portfolio size.
Step 1 — Compute the signed percentage deviation per cell
Deviation is (observed − authoritative) / authoritative × 100. Keep the sign: a negative value is an undercut (the parity breach), a positive value is an overcharge. Guard the zero-authoritative case even though the contract should have caught it, because a single divide-by-zero poisons the entire column with inf.
import polars as pl
def signed_deviation(obs: pl.DataFrame) -> pl.DataFrame:
# obs columns: property_id, room_type_code, rate_plan_code, stay_date,
# channel, authoritative_rate, observed_rate, bookings_trailing
return obs.with_columns(
pl.when(pl.col("authoritative_rate") > 0)
.then(
(pl.col("observed_rate") - pl.col("authoritative_rate"))
/ pl.col("authoritative_rate") * 100
)
.otherwise(None) # undefined deviation stays null, never inf
.round(3)
.alias("deviation_pct")
)
Mapping a zero authoritative rate to null rather than letting the division emit inf matters because Polars aggregations skip nulls by default but propagate inf — one bad cell would otherwise blow up every channel’s mean deviation into infinity and silently break the ranking.
Step 2 — Attach a bounded volume weight
Scale each deviation by how much the cell earns, but bound the weight so one enormous group-block booking cannot dominate the entire ranking. A logarithmic or clipped weight keeps a high-volume cell influential without letting it drown out a systematic small-volume breach across hundreds of dates.
def add_volume_weight(scored: pl.DataFrame, weight_cap: float = 20.0) -> pl.DataFrame:
return scored.with_columns(
# trailing bookings drive weight, but clip so a single 40-room block
# booking cannot outvote a channel-wide 0.5-room-per-cell pattern
pl.col("bookings_trailing").clip(0.0, weight_cap).alias("volume_weight")
).with_columns(
# weighted severity uses absolute deviation so undercuts and overcharges
# both rank; direction is preserved in deviation_pct for the alert router
(pl.col("deviation_pct").abs() * pl.col("volume_weight"))
.alias("weighted_severity")
)
Clipping the weight at a cap rather than using raw booking counts is the non-obvious choice: parity damage is usually systematic — a channel misconfigured across a whole season — so a scoring function that lets one blockbuster cell outweigh a broad low-volume pattern would rank the loud outlier above the expensive-but-diffuse problem.
Step 3 — Roll up per channel over a trailing window
A single day’s snapshot is too jittery to rank on. Aggregate the weighted severity per channel over a trailing window — seven days is a sensible default — so a channel that drifts consistently ranks above one that had a single bad afternoon. Use a group-by with a dynamic rolling window keyed on the observation date.
from datetime import date
def rolling_channel_rank(scored: pl.DataFrame, as_of: date,
window_days: int = 7) -> pl.DataFrame:
window_start = pl.lit(as_of) - pl.duration(days=window_days)
return (
scored
.filter(pl.col("stay_date") >= window_start)
.group_by("channel")
.agg(
pl.col("weighted_severity").sum().alias("severity_total"),
pl.col("deviation_pct").filter(pl.col("deviation_pct") < 0)
.mean().alias("mean_undercut_pct"),
pl.len().alias("cells_observed"),
)
.sort("severity_total", descending=True)
.with_row_index("rank", offset=1)
)
Computing mean_undercut_pct from only the negative deviations, separately from the severity total, gives the revenue manager two numbers that answer different questions: the severity total says which channel to fix first, while the mean undercut says how bad the typical breach on it is — averaging signed deviations together would let overcharges cancel undercuts and hide a real parity problem.
Step 4 — Emit the ranking as a structured event
Log the ranking so its history is queryable and so the alert router in parity monitoring and alerting can lift severity for a channel that has topped the worst-offender list several windows running. Emit one event per ranked channel, not one blob.
import structlog
log = structlog.get_logger()
def publish_ranking(ranking: pl.DataFrame, property_id: str) -> None:
for row in ranking.iter_rows(named=True):
log.info(
"parity_channel_rank",
property_id=property_id,
channel=row["channel"],
rank=row["rank"],
severity_total=round(row["severity_total"], 2),
mean_undercut_pct=round(row["mean_undercut_pct"] or 0.0, 3),
cells_observed=row["cells_observed"],
)
Emitting one event per channel rather than a single JSON blob keeps the ranking greppable by channel over time, so “has booking_com been our worst offender for three windows straight?” is a log query rather than a manual comparison of dashboard screenshots.
Gotchas & production notes
- Averaging signed deviations cancels real breaches. If you take a plain mean of
deviation_pct, a channel that undercuts on half its cells and overcharges on the other half averages to roughly zero and looks healthy. Always aggregate the absolute deviation for severity and filter to negatives separately for the undercut metric, exactly as Step 3 does. - Unweighted rankings chase the loud outlier. Ranking on raw deviation percentage surfaces the single most extreme cell, which is often a one-off on a date nobody books. Volume weighting is what makes the ranking track revenue exposure; without it the on-call chases cosmetic outliers while a diffuse channel-wide undercut goes unranked.
- Null-versus-zero deviation are different states. A
nulldeviation means the cell was unobserved this cycle; a zero means it was observed and in perfect parity. Collapsing them — for instance by filling nulls with zero before aggregating — inflatescells_observedand dilutes every channel’s severity with cells that were never actually shopped. - The rolling window must key on a real observation calendar. If cells are shopped irregularly, a fixed seven-row window is not a seven-day window. Filter on
stay_date(or the observation date) against an explicitas_ofbound as Step 3 does, rather than trusting row counts, or a sparsely observed channel gets an artificially short lookback.
Verification snippet
Confirm the two behaviours the ranking depends on: that undercuts and overcharges do not cancel, and that volume weighting reorders the ranking toward revenue impact. A green run proves the score means what the alert router assumes it means.
import polars as pl
from datetime import date
def test_undercut_and_overcharge_do_not_cancel() -> None:
obs = pl.DataFrame({
"property_id": ["LON-STJ-01"] * 2,
"room_type_code": ["DLX_KING"] * 2,
"rate_plan_code": ["BAR"] * 2,
"stay_date": [date(2026, 8, 1), date(2026, 8, 2)],
"channel": ["booking_com", "booking_com"],
"authoritative_rate": [100.0, 100.0],
"observed_rate": [94.0, 106.0], # -6% then +6%
"bookings_trailing": [5.0, 5.0],
})
ranked = rolling_channel_rank(add_volume_weight(signed_deviation(obs)),
as_of=date(2026, 8, 2))
# absolute severity accumulates; it must NOT net to zero
assert ranked["severity_total"][0] > 0
# only the -6% cell feeds the undercut metric
assert round(ranked["mean_undercut_pct"][0], 1) == -6.0
def test_volume_weight_reorders_ranking() -> None:
obs = pl.DataFrame({
"property_id": ["LON-STJ-01"] * 2,
"room_type_code": ["DLX_KING", "STD_TWIN"],
"rate_plan_code": ["BAR", "BAR"],
"stay_date": [date(2026, 8, 1), date(2026, 8, 1)],
"channel": ["agoda", "expedia"],
"authoritative_rate": [100.0, 100.0],
"observed_rate": [90.0, 97.0], # agoda -10% low-vol, expedia -3% high-vol
"bookings_trailing": [1.0, 18.0],
})
ranked = rolling_channel_rank(add_volume_weight(signed_deviation(obs)),
as_of=date(2026, 8, 1))
# expedia's smaller % but far higher volume must outrank agoda's louder outlier
assert ranked["channel"][0] == "expedia"
test_undercut_and_overcharge_do_not_cancel()
test_volume_weight_reorders_ranking()
The second test is the one that protects revenue: it proves a 3% undercut on a high-volume channel outranks a 10% undercut on a cell nobody books, which is exactly the prioritization the alert router depends on when it decides what to page. Run both before trusting the ranking to drive severity in parity monitoring and alerting.
Related
- Parity monitoring & alerting — the parent workflow: the sampling, gating, and routing loop this score feeds.
- Building parity drift alerts with Prometheus — exposing this deviation as a metric and alerting on sustained drift.
- Normalizing competitor rates (gross vs net) — the basis normalization the scorer assumes has already happened.
- Revenue KPI observability — where the channel ranking joins ADR and RevPAR trends.
- PMS & channel manager architecture foundations — the percentage-based parity tolerance this deviation score is measured against.
← Back to Parity Monitoring & Alerting