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:

From per-cell deviation to a volume-weighted worst-offender ranking Normalized observations enter a scoring stage that computes a signed percentage deviation per cell. That deviation is multiplied by a booking-volume weight to produce a weighted severity per cell. The weighted severities are aggregated over a trailing seven-day rolling window and grouped by channel to produce a ranked worst-offender list, shown with booking_com at the top, then expedia, then agoda. Per-cell deviation to volume-weighted ranking Signed deviation is scaled by booking volume, then rolled up per channel over a trailing window. Normalized observations Signed deviation % per cell × volume weight weighted severity Rolling 7-day per channel group + window Ranking 1 booking_com 2 expedia 3 agoda
Figure 1: the scoring pipeline — a signed per-cell deviation is scaled by a booking-volume weight into a severity, then aggregated over a trailing rolling window and grouped by channel to rank the worst offenders by revenue impact rather than raw percentage.

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.

python
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.

python
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.

python
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.

python
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

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.

python
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.

← Back to Parity Monitoring & Alerting