Unrestricted Networks¶
In the modern day market place, many carriers find themselves operating in much less restricted markets, where they cannot rely on imposing fare fences or restrictions to entice higher willingness-to-pay customers to purchase high price tickets.
import altair as alt
import numpy as np
import pandas as pd
import passengersim as pax
from passengersim.contrast import Contrast
pax.versions()
passengersim 0.59.dev9+ga3f6c1b98 passengersim.core 0.59.dev1+g671876c7d
cfg = pax.Config.from_yaml(pax.demo_network("3MKT/DEMO"))
In this example, we will work with a completely fenceless marketplace. To convert the typical 3MKT network into a restriction-free network, we could iterate through all the fares and remove restrictions, or we can can use some pre-packaged functions to strip the restrictions.
from passengersim.config.manipulate import strip_ap_restrictions, strip_fare_restrictions
cfg = strip_fare_restrictions(cfg)
cfg = strip_ap_restrictions(cfg)
Now we have completely unrestricted markets. We could run this network just like this, although we get what might be to some a surprising result...
sim = pax.MultiSimulation(cfg)
summary = sim.run()
summary.fig_carrier_revenues()
Looking at the carrier revenues, we can see that the average revenue for each carrier is now wildly less than the $91k we saw on the restricted network.
Looking at the fare class mix, the reason for this collapse becomes clear: both carriers are now selling exclusively Y5 tickets (the cheapest fare class). From the customers's perspective, this is perfectly reasonable -- without any restrictions, there's no reason to buy anything other than the cheapest fare available.
summary.fig_fare_class_mix()
From the carrier's perspective, clearly something is wrong. While customers will only purchase the lowest available fare, we would expect that carriers would want to optimize their networks by sometimes making the lowest available fare not actually the lowest possible fare.
The problem in this case is in the use of standard class-based forecasting. These forecasts assume demand for each fare class is independent, and that we can forecast the level of demand for each fare class based on the historical demand for that fare class. But in our simulation that gets us into a catch-22: the lack of sales in higher classes results in forecasting zero demand for those higher classes, and that zero forecast means the optimizer never closes the lowest fare class to try to save space for higher class customers (because it thinks there aren't any).
To solve this problem, we need to use a different kind of forecast, which accounts for the propensity for at least some customers to buy up to a higher fare class if the class they prefer is closed. The RM system "L" uses the same leg-based optimizer as "E", but instead of using standard forecasting it switches to conditional forecasting, which relaxes the independent demands by fare class assumption, and instead assumes that customers who purchase the lowest available fare are "priceable" customers, and that some fraction of those customers will still buy if the fare were higher. This fraction changes over the booking horizon, with generally lower fractions for early bookings and higher fractions for late bookings; how large that fraction is and how it changes over time is decribed by a "Frat5 curve", which we will ascribe to each carrier.
cfg.carriers.AL1.rm_system = "L"
cfg.carriers.AL2.rm_system = "L"
cfg.carriers.AL1.frat5 = "curve_C"
cfg.carriers.AL2.frat5 = "curve_C"
sim2 = pax.MultiSimulation(cfg)
summary2 = sim2.run()
comp2 = Contrast(E=summary, L=summary2)
comp2.fig_carrier_revenues()
comp2.fig_fare_class_mix()
comp2.fig_bookings_by_timeframe(by_carrier="AL1", by_class=True)
The use of the conditional forecaster introduces a much larger universe of possible options to chose from for simulated carriers. Some of these options are highly consequential, and others are less so.
For example, we can consider choosing among various Frat5 curves, which control the assumed levels of sellup across the booking curve. The available "standard" curves look like this:
# load all standard frat5 curves in a PassengerSim config
std_frat5 = pax.Config.from_yaml(pax.demo_network("standard-frat5.yaml"))
# convert to a dataframe for analysis
df = pd.concat(
{
curvename: pd.Series(std_frat5.frat5_curves[curvename].curve, name="frat5_value")
for curvename in std_frat5.frat5_curves
},
axis=0,
names=["curve_name", "days_prior"],
).reset_index()
# plot using Altair
alt.Chart(df).mark_line().encode(
x=alt.X("days_prior", sort="descending"),
y=alt.Y("frat5_value", scale=alt.Scale(domain=[1, 4])),
color="curve_name",
tooltip=["curve_name", "days_prior", "frat5_value"],
).properties(title="Standard Frat5 Curves")
We can assign to AL1 a different Frat5 curve, to change the rate at which the forecast predicts buy-up.
cfg.carriers.AL1.frat5 = "curve_E"
sim3 = pax.MultiSimulation(cfg)
summary3 = sim3.run()
comp3 = Contrast(curve_C=summary2, curve_E=summary3)
comp3.fig_carrier_revenues()
Apparently, we've made things substantially worse for AL1 with this change. Perhaps moving in the other direction would be more helpful
cfg.carriers.AL1.frat5 = "curve_A"
sim4 = pax.MultiSimulation(cfg)
summary4 = sim4.run()
comp4 = Contrast(curve_E=summary3, curve_C=summary2, curve_A=summary4)
comp4.fig_carrier_revenues()
Looks like using "curve_A" for AL1 gives the best revenue result. We can also look at how the fare class mix changes with the various curves.
comp4.fig_fare_class_mix()
It is interesting to note here how the results are also changing for AL2. This is expected; since PassengerSim is simulating the entire competitive environment, it is to be expected that other carriers can see changes in their results even if they don't change their own RM strategies.
The head-to-head revenue figure can show us some details about when the Frat5 curve matter most. Spoiler alert: it's the higher demand days.
summary4.fig_carrier_head_to_head_revenue("AL1", "AL2", mean_adjusted=False)