One-Step Predictions with LSTM: Forecasting Hotel Revenues | by Michael Grogan | Towards Data Science

Note: This is an update to my previous article Forecasting Average Daily Rate Trends for Hotels Using LSTM. I since recognised a couple of technical errors in the original analysis, and decided to write a new article to address these and expand my prior analysis.
The purpose of using an LSTM model in this instance is to forecast ADR (average daily rate) for a hotel.
ADR is calculated as follows:
ADR = Revenue ÷ sold rooms
In this example, the average ADR for customers per week is calculated and formulated into a time series. The LSTM model is then used to forecast this metric on a week-by-week basis.
The original study by Antonio, Almeida and Nunes (2019) can be found here.
Using pandas, the average ADR is calculated per week. Here is a plot of the weekly ADR trend.
Note that the Jupyter Notebook for this example is available at the end of this article.
As with any neural network, the data needs to be scaled for proper interpretation by the network, a process known as normalization. MinMaxScaler is used for this purpose.
However, this comes with a caveat. Scaling must be done after the data has been split into training, validation and test sets — with each being scaled separately. A common mistake when first using the LSTM (I made this mistake myself) is to first normalize the data before splitting the data.
The reason this is erroneous is that the normalization technique will use data from the validation and test sets as a reference point when scaling the data as a whole. This will inadvertently influence the values of the training data, essentially resulting in data leakage from the validation and test sets.
In this regard, 100 data points are split into training and validation sets, with the last 15 data points being held as test data for comparison with the LSTM predictions.
train_size = int(len(df) * 0.8)val_size = len(df) - train_sizetrain, val = df[0:train_size,:], df[train_size:len(df),:]
A dataset matrix is formed:
def create_dataset(df, previous=1):    dataX, dataY = [], []    for i in range(len(df)-previous-1):        a = df[i:(i+previous), 0]        dataX.append(a)        dataY.append(df[i + previous, 0])    return np.array(dataX), np.array(dataY)
At this point, the training data can be scaled as follows:
scaler = MinMaxScaler(feature_range=(0, 1))train = scaler.fit_transform(train)train
Here is a sample of the output:
array([[0.35915778],       [0.42256282],       [0.53159902],...       [0.0236608 ],       [0.11987636],       [0.48651694]])
Similarly, the validation data is scaled in the same way:
val = scaler.fit_transform(val)val
A “lookback period” defines how many previous timesteps are used in order to predict the subsequent timestep. In this regard, we are using a one-step prediction model.
The lookback period is set to 5 in this instance. This means that we are using the time steps at t-4, t-3, t-2, t-1, and t to predict the value at time t+1.
# Lookback periodlookback = 5X_train, Y_train = create_dataset(train, lookback)X_val, Y_val = create_dataset(val, lookback)
Note that the selection of the lookback period is quite an arbitrary process. In this instance, a lookback window of 5 was shown to demonstrate the best predictive performance on the test set. However, another option could be to use the number of lags as indicated by PACF to set the size of the lookback window, as described at Data Science Stack Exchange.
Let’s take a look at the normalized window for X_train.
array([[0.35915778, 0.42256282, 0.53159902, 0.6084246 , 0.63902841],       [0.42256282, 0.53159902, 0.6084246 , 0.63902841, 0.70858066],       [0.53159902, 0.6084246 , 0.63902841, 0.70858066, 0.75574219],...
Here are the first three entries. We can see that the five time steps immediately prior to the one we are trying to predict move in a stepwise motion.
For instance, the first entry shows 0.63902841 at time t. In the second entry, this value now moves backwards to time t-1.
Let’s give an example applicable to this situation. For a hotel that wishes to predict the ADR value in week 26 for instance, the hotel will use this model to make the prediction in the prior week using data for weeks 21, 22, 23, 24, and 25.
Now, the input is reshaped into a [samples, time steps, features] format.
# reshape input to be [samples, time steps, features]X_train = np.reshape(X_train, (X_train.shape[0], 1, X_train.shape[1]))X_val = np.reshape(X_val, (X_val.shape[0], 1, X_val.shape[1]))
In this case, the shape of the input is [74, 1, 1].
74 samples are present in the training data, the model is operating on a time step of 1, and 1 feature is being used in the model, i.e. a lagged version of the time series.
An LSTM model is defined as follows:
# Generate LSTM networkmodel = tf.keras.Sequential()model.add(LSTM(4, input_shape=(1, lookback)))model.add(Dense(1))model.compile(loss='mean_squared_error', optimizer='adam')history=model.fit(X_train, Y_train, validation_split=0.2, epochs=100, batch_size=1, verbose=2)
An LSTM model is created with 4 neurons. The mean squared error is being used as the loss function — given that we are dealing with a regression problem. Additionally, the adam optimizer is used, with training done over 100 epochs with a validation split of 20%.
Here is a visual overview of the training and validation loss:
# list all data in historyprint(history.history.keys())# summarize history for accuracyplt.plot(history.history['loss'])plt.plot(history.history['val_loss'])plt.title('model loss')plt.ylabel('loss')plt.xlabel('epoch')plt.legend(['train', 'val'], loc='upper left')plt.show()
We can see that after an initial increase in the validation loss, the loss starts to decrease after about 10 epochs.
Now, the predictions are converted back to the original scale:
# Convert predictions back to normal valuestrainpred = scaler.inverse_transform(trainpred)Y_train = scaler.inverse_transform([Y_train])valpred = scaler.inverse_transform(valpred)Y_val = scaler.inverse_transform([Y_val])predictions = valpred
The root mean squared error is calculated on the training and validation set:
# calculate RMSEtrainScore = math.sqrt(mean_squared_error(Y_train[0], trainpred[:,0]))print('Train Score: %.2f RMSE' % (trainScore))valScore = math.sqrt(mean_squared_error(Y_val[0], valpred[:,0]))print('Validation Score: %.2f RMSE' % (valScore))
The obtained RMSE values are as follows:
Train error: 3.88 RMSE
Validation error: 8.78 RMSE
With a mean ADR value of 69.99 across the validation set, the validation error is quite small in comparison (roughly 12% of the mean value), indicating that the model has done a good job at forecasting ADR values.
Here is a plot of the forecasted versus actual ADR values across the training and validation set.
# Plot all predictionsinversetransform, =plt.plot(scaler.inverse_transform(df))trainpred, =plt.plot(scaler.inverse_transform(trainpredPlot))valpred, =plt.plot(scaler.inverse_transform(valpredPlot))plt.xlabel('Number of weeks')plt.ylabel('Cancellations')plt.title("Predicted vs. Actual Weekly ADR")plt.show()
We can see that the LSTM model is generally capturing the directional oscillations of the time series. However, during periods of extreme spikes in ADR, e.g. week 60, the model seems to perform less well.
However, in order to fully determine whether the model has predictive power — it will now be used to predict the last 15 time steps in the series, i.e. the test data.
Xnew = np.array([tseries.iloc[95:100],tseries.iloc[96:101],tseries.iloc[97:102],tseries.iloc[98:103],tseries.iloc[99:104],tseries.iloc[100:105],tseries.iloc[101:106],tseries.iloc[102:107],tseries.iloc[103:108],tseries.iloc[104:109],tseries.iloc[105:110],tseries.iloc[106:111],tseries.iloc[107:112],tseries.iloc[108:113],tseries.iloc[109:114]])
In this example, Xnew uses the previous five time steps to predict at time t+1. For instance, weeks 95 to 100 are used to predict the ADR value for week 101, then weeks 96 to 101 are used to predict week 102, and so on.
The above graph illustrates the LSTM predictions versus the actual ADR values in the test set — the last 15 points in the series.
The obtained RMSE and MAE (mean absolute error) values are as follows:
MAE: -27.65
RMSE: 31.91
The RMSE error for the test set is significantly higher than that for the validation set — which would be expected since we are working with unseen data.
However, with a mean ADR value of 160 across the test set, the RMSE error is approximately 20% of the size of the mean value, indicating that the LSTM does still have reasonably strong predictive power in determining the value of the next timestep.
Ideally, one would like to use a significantly larger data sample to validate whether the LSTM would retain predictive power across new data. Additionally, as illustrated in this Reddit thread, LSTMs can be prone to overfitting depending on the size of the data sample.
In this regard, a larger data sample is needed to validate if this model would work in a real-world scenario. However, the preliminary results in this case look promising.
In this example, you have seen:
How to properly format data to work with an LSTM model
Building of a one-step LSTM predictive model
Interpretation of RMSE and MAE values to determine model accuracy
Many thanks for reading, and any feedback or questions are greatly appreciated. You can find the Jupyter Notebook for this example here.
Additionally, I also highly recommend this tutorial by Machine Learning Mastery, which was used as a guideline for designing the LSTM model used in this example.
Disclaimer: This article is written on an “as is” basis and without warranty. It was written with the intention of providing an overview of data science concepts, and should not be interpreted as professional advice in any way. The author has no relationship with any third-parties mentioned in this article.