In the cells below, I will generate a forecast of the junior suites with city views based on the numbers provided for the Plaza Hotel. There are 80 junior suites. The forecast shows a weekly period with a mild trend upward indicating the start of the high touristic season, as well as peaks on the weekends. The green line shows the capacity of that particular room class.
Note that the forecast below is based on the assumption that each room is priced $120 per night, which is typically what the Plaza Hotel charges for that room class. The forecast is based on historical data and should be updated frequently based on new reservation and cancellation data.
Based on this baseline forecast at a price of $120 per night, my goal is to find optimal prices to maximize revenue.
# Let's define a function that will simulate an occupancy level (demand)
# for the next "nb_days" days.
def simulate_demand(nb_days, period=7.0, dc_level=35.0, noise_level=5.0,
amplitude=3.0, phase=2.0*mt.pi/7.0, trend=0.06):
"""Simulate a demand curve over a forecast period.
Parameters

nb_days (integer):
Length of the forecast period (in days)
period (float):
Length of the seasonal component. Typically 7 days for weekly
fluctuations.
dc_level (float):
Baseline occupancy level
noise_level (float):
Noise level (additive) of the occupancy numbers. This number
corresponds to the standard deviation of the normal distribution.
amplitude (float):
Amplitude of the periodic signal
phase (float):
phase shift of the signal (in radians)
trend (float):
slope of the linear trend term. In units of rooms/day. Set to 0 if you
do not want to include a linear trend.
Returns

A numpy.ndarray (1D) containing the forecast numbers.
"""
days = np.linspace(1,nb_days,nb_days)
# noise level:
noise = noise_level * normal(size=len(days))
# Demand signal:
demand = dc_level + trend * days + amplitude * \
np.cos(2.0 * mt.pi * days / period + phase) + noise
return demand
# Let's simulate the occupancy for a period of 90 days:
date_start = '20170731'
date_end = '20171029'
nb_days = (pd.to_datetime(date_end)  pd.to_datetime(date_start)).days
forecasted_demand = simulate_demand(nb_days, dc_level=68,
noise_level=1.0, amplitude=5)
forecasted_demand = map(round, forecasted_demand)
forecasted_demand = pd.DataFrame(forecasted_demand,
index=pd.date_range(start=date_start,
end=date_end,
closed='left'),
columns=['occupancy'])
Below, I show the outcome of a simple occupancy forecast simulation with plotly
.
# Let's plot the occupancy forecast using plotly:
occupancy = [go.Scatter(x=forecasted_demand.index, y=forecasted_demand['occupancy'],
name='Forecasted Junior Suites with City View Occupancy for the next 90 days')]
layout_occ = go.Layout(title='Forecasted Junior Suites with City View Occupancy  Each room is priced $120/night',
xaxis={'title':'Day'},
yaxis={'title':'Number of Rooms Occupied'},
shapes=[{'type':'line',
'x0':'20170731',
'x1':'20171031',
'y0':80.0, 'y1':80.0,
'line': {
'color': 'rgb(50, 171, 96)',
'width': 4, 'dash':'dashdot'}
}]
)
fig = go.Figure(data=occupancy, layout=layout_occ)
iplot(fig, filename='occ_ts')
The next step is to assume some relationship between price and demand. I have adopted the relationship described in the Mathematical Formulation of this piece and set the elasticity between price and demand to e = − 2.
# Let's assume a demand price elasticity function:
def demand_price_elasticity(price, nominal_demand, elasticity=2.0, nominal_price=120.0):
"""Returns demand given a value for the elasticity, nominal demand and nominal price.
Parameters

price (numpy.ndarray):
onedimensional price array. The length of that array should correspond to the
length of the forecast period.
nominal_demand (numpy.ndarray):
onedimensional forecasted occupancy array. The length of that array should
correspond to the length of the forecast period.
elasticity (float):
value of the elasticity between price and demand. A value of e=2 is reasonable.
nominal_price (float):
room rate for which the forecast was computed.
Returns

A numpy.ndarray of expected demand.
"""
return nominal_demand * ( price / nominal_price ) ** (elasticity)
Finally, l'll bring the forecasted demand and demand elasticity together to define the constrained optimization problem. This will allow me to identify the optimal set of prices for different capacity levels. To minimize the objective function, I will use the scipy.optimize
optimization library. Other libraries that can perform optimization tasks in Python include pyOpt and CVXOPT, to name a few.
import scipy.optimize as optimize
# definition of the objective function:
def objective(p_t, nominal_demand=np.array([50,40,30,20]),
elasticity=2.0, nominal_price=120.0):
"""
Definition of the objective function. This is the function that want to minimize.
(minus sign in front)
Parameters

p_t (numpy.ndarray):
onedimensional price array. The length of that array should correspond to the
length of the forecast period.
nominal_demand (numpy.ndarray):
onedimensional forecasted occupancy array. The length of that array should
correspond to the length of the forecast period.
elasticity (float):
value of the elasticity between price and demand. A value of e=2 is
reasonable.
nominal price (float):
room rate for which the forecast was computed.
Returns

Value of the objective function (float).
Note: here we're trying to minimize the objective function. That's where the
minus sign comes_in.
"""
return (1.0 * np.sum( p_t * demand_price_elasticity(p_t, nominal_demand=nominal_demand,
elasticity=elasticity,
nominal_price=nominal_price) )) / 100
In the cell below, I explicitly list all the constraints the objective function is subject to. In this case, two constraints are defined:
 Prices have strictly positive values
 Demand can't exceed capacity
# Constraints:
def constraint_1(p_t):
""" This constraint ensures that the prices are positive.
"""
return p_t
def constraint_2(p_t, capacity=20, forecasted_demand=35.0,
elasticity=2.0, nominal_price=120.0):
""" This constraint ensures that the demand does not exceed
capacity.
Parameters

p_t (float):
Room price
capacity (integer):
Capacity of the hotel (in rooms).
forecasted_demand (float):
Forecasted demand (in rooms) for that night
elasticity (float):
slope of the
nominal_price (float):
The price for which the forecasted_demand was computed.
Returns

Returns an array of excess capacity.
"""
return capacity  demand_price_elasticity(p_t, nominal_demand=forecasted_demand,
elasticity=elasticity,
nominal_price=nominal_price)
I have defined the objective function and the constraints. Now, I'll perform the minimization over four overlapping capacity segments. To do this, I'll use the Sequential Least Squares Programming (SLSQP) minimization technique.
# Let's run the optimization algorithm over four overlapping segments
# of 20, 40, 60, 80 room capacity.
# We look at four capacity segments: 20, 40, 60, and 80 (full capacity)
# rooms available.
capacities = [20.0, 40.0, 60.0, 80.0]
optimization_results = {}
for capacity in capacities:
# Nominal price associated with forecasted demand:
nominal_price = 120.0
# Forecasted demand:
nominal_demand = forecasted_demand['occupancy'].values
# Assumed price elasticity:
elasticity = 2.0
# Starting values:
p_start = 125.0 * np.ones(len(nominal_demand))
# bounds on the prices. Let's stick with reasonable values.
# One could be more sophisticated here and apply constraints
# that limit the prices to be in range of what competitors
# are charging, for example.
bounds = tuple((10.0, 400.0) for p in p_start)
# Constraints:
constraints = ({'type': 'ineq', 'fun': lambda x: constraint_1(x)},
{'type': 'ineq', 'fun': lambda x, capacity=capacity,
forecasted_demand=nominal_demand,
elasticity=elasticity,
nominal_price=nominal_price: constraint_2(x,capacity=capacity,
forecasted_demand=nominal_demand,
elasticity=elasticity,
nominal_price=nominal_price)})
opt_results = optimize.minimize(objective, p_start, args=(nominal_demand,
elasticity,
nominal_price),
method='SLSQP', bounds=bounds,
constraints=constraints)
optimization_results[capacity] = opt_results
# Plotting the resulting rates vs dates.
time_array = np.linspace(1,len(nominal_demand),len(nominal_demand))
rate_df = pd.DataFrame(index=time_array)
for capacity in optimization_results.keys():
rate_df = pd.concat([rate_df,
pd.DataFrame(optimization_results[capacity]['x'],
columns=['{}'.format(capacity)],
index=time_array)],
axis=1)
rate_df.index.name = 'Day'
datelist = pd.date_range(start=date_start, end=date_end, closed='left').tolist()
rate_df.index = [ x.date() for x in datelist]
In the table below, I show an example of the rates as a function of the number of rooms still available for booking. For example, column Capacity left: 80.0 corresponds to the case where there are 80 rooms left to book (no reservations that night yet) while the column Capacity left: 20.0 represents the case where there are only 20 rooms left to book. As you can see, the prices go up as capacity goes down — which is what we intuitively expect.
The table below shows the prices (in dollars) per night and per capacity levels. I also show the results in a chart highlighting fluctuations in price as a function of remaining capacity and day of week.
# Generate a pretty table for display purposes.
rate_df_to_show = rate_df.copy()
# Renaming the columns:
rate_df_to_show = rate_df_to_show[np.sort(np.asarray(rate_df_to_show.columns))]
rate_df_to_show.columns = ['Capacity left : {}'.format(x) for x in rate_df_to_show.columns]
# Rounding the numbers:
for col in rate_df_to_show.columns:
rate_df_to_show[col] = rate_df_to_show[col].apply(lambda x: round(x,2))
dow_map = { 6:'Sun', 0:'Mon', 1:'Tue', 2:'Wed', 3:'Thu', 4:'Fri', 5:'Sat'}
rate_df_to_show['date'] = rate_df_to_show.index
rate_df_to_show['dow'] = rate_df_to_show['date'].apply(lambda x: dow_map[x.weekday()])
rate_df_to_show['date'] = rate_df_to_show.apply(lambda row: row['dow']+" "+str(row['date']),
axis=1)
rate_df_to_show.index = rate_df_to_show['date'].values
rate_df_to_show.drop(['date','dow'],axis=1,inplace=True)
rate_df_to_show.head(10)

Capacity left: 20.0 
Capacity left: 40.0 
Capacity left: 60.0 
Capacity left: 80.0 
Mon 20170731 
216.33 
152.97 
124.90 
108.17 
Tue 20170801 
214.66 
151.79 
123.94 
107.33 
Wed 20170802 
214.66 
151.79 
123.94 
107.33 
Thu 20170803 
222.89 
157.61 
128.69 
111.45 
Fri 20170804 
230.82 
163.22 
133.27 
115.41 
Sat 20170805 
229.26 
162.11 
132.36 
114.63 
Sun 20170806 
227.68 
161.00 
131.45 
113.84 
Mon 20170807 
216.33 
152.97 
124.90 
108.17 
Tue 20170808 
216.33 
152.97 
124.90 
108.17 
Wed 20170809 
214.66 
151.79 
123.94 
107.33 
# Plotting the room rate time series.
# Let's focus on a single week cycle.
price_levels = [go.Scatter(x=rate_df_to_show.head(7).index,
y=rate_df_to_show.head(7)['Capacity left : 20.0'],
name='Capacity Remaining : 20 rooms'),
go.Scatter(x=rate_df_to_show.head(7).index,
y=rate_df_to_show.head(7)['Capacity left : 40.0'],
name='Capacity Remaining : 40 rooms'),
go.Scatter(x=rate_df_to_show.head(7).index,
y=rate_df_to_show.head(7)['Capacity left : 60.0'],
name='Capacity Remaining : 60 rooms'),
go.Scatter(x=rate_df_to_show.head(7).index,
y=rate_df_to_show.head(7)['Capacity left : 80.0'],
name='Capacity Remaining : 80 rooms')]
layout_prices = go.Layout(title='Rate vs Reservation Date and Current Capacity Levels',
xaxis={'title':'Day'}, yaxis={'title':'Rate ($)'})
fig = go.Figure(data=price_levels, layout=layout_prices)
iplot(fig, filename='price_levels_ts')
# Save rate dataframe to local folder:
rate_df.to_csv('data/rates.csv')
In order for this dynamic pricing system to work, you have to check the number of reservations made for a particular night and assign the correct price based on the four price buckets I outlined above.
This is the purpose of the rate_query()
function that I will deploy as a REST API on the DataScience.com Platform. What this function does is query the number of rooms booked in the central booking database of the hotel and assign the prices accordingly.
# deploy a model. Given a date and the capacity for that date,
# returns the rate.
# Reads current capacity from the heroku reservations db
# Read current rates from a csv file stored locally.
def rate_query(arrival_date, departure_date):
"""Given an arrival and a departure dates, this function
will look up the current number of reservations for those dates
and return the optimal price according to the rate_df dataframe.
Parameters

arrival_date (string):
format YYYYMMDD
departure_date (string):
format YYYYMMDD
Returns

A list containing the room rates for each night.
"""
# connection to the reservations database.
# In this case, we used a postgres DB hosted on heroku.
# The DataScience.com Platform allows you to easily store
# your access credentials as environment variables. You never
# have to copy and paste credentials directly in notebook!
conn = psycopg2.connect(database='my_db',
port=os.environ['HOTEL_BOOKINGS_DB_PORT'],
password=os.environ['HOTEL_BOOKINGS_DB_PASS'],
user=os.environ['HOTEL_BOOKINGS_DB_USER'],
host=os.environ['HOTEL_BOOKINGS_DB_HOST'])
current_bookings = pd.read_sql("SELECT * from bookings where date>=\'{}\' and date <\'{}\' ".format(arrival_date,
departure_date),conn)
current_bookings.index = pd.to_datetime(current_bookings['date'])
current_bookings.drop(['date'], axis=1, inplace=True)
# Read the rate dataframe:
rate_df = pd.read_csv('data/rates.csv',header=0, index_col=0)
# Check for no availability on any of those nights:
no_avail = current_bookings[current_bookings['rooms_available'] < 1.0 ]
if len(no_avail) > 0 :
raise ValueError("No Room available on {}".format(no_avail.index))
capacity_values = rate_df.columns
capacity_values = np.sort(capacity_values)[::1]
rates = []
# Look over each date in current_bookings:
for book_date in current_bookings.index:
id = 0
current_capacity = current_bookings.loc[book_date, 'rooms_available']
while id <= len(capacity_values)1 and current_capacity <= float(capacity_values[id]):
tmp = rate_df.loc[str(book_date.date()), capacity_values[id]]
id+=1
rates.append(tmp)
return [ round(rate,2) for rate in rates ]
Finally, a web reservation/booking system can make HTTP requests to the dynamic pricing API and fetch updated prices. You can deploy multiple versions of the algorithm, which allows for A/B testing or multiarm bandit tests of your pricing algorithm. Below is a snapshot of the web app, along with an example request made to the API.
In addition, we can schedule a job in the platform that updates the hotel's occupancy forecast on a daily basis based on new reservation and cancellation data.