Work

Premium Upgrade Dynamic Pricing

Daily optimal-control pricing for premium seat upgrades at one of Europe's largest tour operators — delivered on their AWS estate in five sprints

Python
Optimization
Optimal Control
Machine Learning
Dynamic Pricing
AWS
Premium cabin seats on a holiday charter flight

Executive Summary

Problem

The client, one of Europe’s largest tour operators, sells premium seat upgrades on the flights that carry its package holidays. Upgrade prices were set manually and rarely revisited, so seats were routinely sold too cheap early on or left unsold at departure. The client wanted a daily, automated price recommendation for every flight — one that reacts to how bookings are actually coming in, and that their trading team could still overrule.

Solution

We built an optimisation engine that reprices premium upgrades for every flight, every day, from three connected models:

  1. A looks model that forecasts the market size — how many seats will be booked onto each flight on each remaining day before departure.
  2. A price sensitivity model that predicts the probability a booking converts to a premium upgrade at any given price.
  3. An optimal control optimiser that combines the two to compute a full price trajectory from today to departure, and publishes today’s price from it.

The whole thing runs as pipelines on the client’s own AWS environment, with trader-configurable price guardrails applied before anything is published.

Outcome

  • A semi-productionised optimisation model delivered on the customer’s AWS environment in five sprints, plus an extension sprint for validation and hardening.
  • Daily price recommendations per flight leg, each with a full forward price trajectory for explainability and for warm-starting the next day’s optimisation.
  • The engagement also produced the client’s first counterfactual validation framework for upgrade pricing, and a clear scoping of what Phase 2 should tackle.

The challenge

1. Pricing a seat that is really two seats

Package holiday flights don’t behave like scheduled airline flights. Capacity is managed at the flight pair level: 30 outbound seats to Cancun on the 3rd of December are paired to 30 inbound seats on the 10th. We discovered this mid-project — target booking profiles (and capacity!) were modelled per pair, while all our data gathering so far had been at the individual flight leg level.

Rather than rebuild everything, we made a deliberate approximation: keep modelling looks and conversion at the flight level, and aggregate the duration dimension out of the target profiles. Without cross-elasticities this is a fairly profound approximation, and we said so — moving to flight-pair-level modelling became the headline recommendation for Phase 2. The looks models were designed so that switch is mostly a new data source and new capacity numbers, not a rewrite.

The wrinkle it left in the optimiser was more interesting: the conversion decision happens at the flight pair level, but the prices we set are for the component legs of each pair. That means the optimiser can’t simply pick the revenue-optimal price of each pair independently — the legs couple the problem together, and the price search inside the optimiser had to account for it.

2. Forecasting demand from a target curve

The looks model answers one question: how many people will even see an upgrade price on this flight, each day to departure?

We implemented a family of models of increasing sophistication:

  • Straight line: X seats remaining, D days remaining — assume X/D sales per day. No external data needed.
  • Constant offset: if a flight is running Y% above or below its target load factor curve, assume it stays Y% off all the way to departure.
  • Decaying offset: the same, but the offset halves every configurable number of days — a reversion to the target profile. The halflife doubles as an operational lever for traders, since some routes claw their way back to target much faster than others.
  • Gaussian process offset: model the offset from target as a GP and extrapolate it forward. Scoped and half-implemented, then parked for a later phase.

The honest risk here was data dependence: everything beyond the straight line needs the client’s target load factor profiles, and those live in a legacy revenue system that aggregates them per package holiday, not per flight. We were upfront that if the flight-to-profile mapping couldn’t be supplied, we would have to approximate that system’s aggregation logic ourselves — and we planned sprint capacity accordingly.

3. A price sensitivity model the optimiser can differentiate

For sensitivity we had bookings data: each row a flight leg on a booking, with a 0 or 1 for whether it converted to a premium upgrade.

The model is a neural network logistic regression with structure, not a black box:

straight_line(price, features) = (reference_price(features) - price) / scale(features)
conversion(price, features)    = maximum(features) * sigmoid(straight_line(price, features))

The network learns the reference_price, scale and maximum functions simultaneously, and enforcing scale to be positive guarantees well-behaved price sensitivity — conversion can only go down as price goes up. It turned out a constant scale across all features gave quite a good fit, which simplified life considerably.

Features were kept deliberately modest: days to go, one-hot encoded UK and overseas airports, trig-encoded day of year, and holiday duration. Remaining premium capacity and time of day were considered and explicitly deferred — the former because the optimiser would then need its derivative too, and we were not going to buy that complexity in Phase 1.

Validation was against a held-out season of bookings: log-likelihood, ROC and precision-recall curves, and calibration curves, sliced by route, days-to-go and departure date, with a go/no-go decision gate agreed with the client.

4. The optimiser: repeated deterministic optimal control

The pricing engine itself is a deterministic optimal control problem, solved fresh every day:

  1. Initialise a Lagrangian multiplier — the cost of violating the “you can’t sell seats you don’t have” terminal constraint — and an initial price trajectory.
  2. Simulate the forward capacity dynamics for the current trajectory.
  3. Simulate the co-state backwards in time from the terminal condition.
  4. Update the trajectory by maximising the Hamiltonian at each time step, and repeat to convergence.
  5. Step the Lagrangian multiplier based on any remaining constraint violation, and repeat the whole thing to convergence.

The output is a full price trajectory to departure. Only the first price is published; the rest of the trajectory is kept for explainability — a trader can see where the model thinks this flight is going — and as a warm start for tomorrow’s run. See the bottom of the page for more details on the maths.

Guardrails (min and max allowed prices) are read from config and applied as post-processing. One known limitation we chose to live with: the optimiser respects min/max price levels but not min/max price deltas between days — getting delta constraints into the Hamiltonian maximisation step needs genuine extra thought, and we deferred it rather than hack it.

5. Delivering in someone else’s cloud

The pipelines — historical ingestion, daily ingestion, model training and optimisation — all had to run in the client’s AWS environment rather than our own, under a complex statement of work with demanding stakeholders.

graph TB
    subgraph SRC["Source Data"]
        HIST_DATA["Historical Bookings"]
        DAILY_DATA["Daily Bookings, Targets & Capacity"]
    end

    subgraph ING["Ingestion"]
        HIST_ING["Historical Ingestion Pipeline"]
        DAILY_ING["Daily Ingestion Pipeline"]
    end

    subgraph TRAIN["Model Training"]
        LOOKS["Looks Model"]
        SENS["Price Sensitivity Model"]
    end

    subgraph OPT["Optimisation Pipeline"]
        OC["Optimal Control Optimiser"]
        GR["Guardrails"]
    end

    CFG(["Trader-Configured Limits"])
    OUT[("Daily Price Recommendations")]

    HIST_DATA --> HIST_ING --> SENS
    DAILY_DATA --> DAILY_ING --> LOOKS
    DAILY_ING --> SENS

    LOOKS --> OC
    SENS --> OC
    CFG --> GR
    GR --> OC

    OC --> OUT

I was the engineering lead, working alongside a data science team that had never collaborated with our engineers before. Working in the client’s environment meant their rules and their platform quirks, so I negotiated a compromise with their stakeholders: some of the DevOps burden moved to their team, in exchange for us working inside their estate.

Towards the end of the project, team absences left me as the sole engineering contributor. The aim then was twofold: land a high-quality result, and hand over enough knowledge that the delivery didn’t depend on me. Throughout, I worked closely with the product owner and client director to keep delivering value without giving away the margin — mostly.

6. Validating a pricing model with no A/B test

You can’t A/B test prices you never charged, and this client had no experimentation framework to lean on. So validation was built from three layers, each with its own agreed go/no-go gate:

  1. Conversion: hold-out metrics against a full past season, as above.
  2. Looks: forecast this year’s load factor come-in, match each flight to its equivalent last year, and compare the curves side by side.
  3. Optimisation: a counterfactual backtest — what would we have charged on each historic day, and why do we believe it beats what was actually charged?

We flagged early that the full counterfactual depended on historic snapshots of remaining flight-pair capacity and their matched target profiles — data the client’s systems may simply never have kept. We scoped it honestly as “possibly not achievable”, and it was one of the reasons the extension sprint existed. The final open question we put to the client is a good one for any pricing team: should unsold seats be penalised beyond their foregone incremental revenue?

Extra material for the curious

The optimiser is an application of Pontryagin’s maximum principle to seat pricing. The state is remaining premium capacity; the control is the price; the dynamics are looks × conversion draining that capacity over time.

The co-state (adjoint) variable can be read as the marginal value of one more seat of remaining capacity on a given day — simulate it backwards from departure, and the Hamiltonian maximisation at each time step is then just “pick the price that best trades today’s revenue against the future value of the seats it consumes”. The terminal constraint — you cannot land with negative capacity — is enforced with a Lagrangian multiplier that is itself iterated in an outer loop: solve the control problem, measure the violation, raise the price of violating, repeat.

Re-running this whole procedure daily with fresh data is what turns an offline piece of control theory into a pricing system: each morning the model produces a new full trajectory, publishes only its first step, and uses the rest to warm-start tomorrow.