Qatar Airline Reviews
2024 — A forecast of where Qatar Airways review ratings are heading
01 Problem
Every review an airline receives says what a passenger loved or hated, but thousands of them read one by one say very little about the airline as a whole.
The goal is to turn Qatar Airways reviews into answers about what passengers praise, what they complain about, and where the average rating is heading next.
02 Data
The dataset holds 2,369 Qatar Airways reviews, each with a rating from 1 to 10, the review text, the route, the seat type, the aircraft, and the date.
The first row is dropped, along with 99 reviews that have neither a rating nor any text, which leaves 2,269. Columns the analysis never uses, such as the author and whether the review is verified, are dropped too.
df <- read.csv("Datasets/Airline_Qatar_Reviews.csv")
df <- df %>% mutate(across(where(is.character), ~ na_if(., "")))
df <- df %>%
select(
-Unnamed..0,
-Date.Published,
-Max.Rating,
-Author,
-Date.Flown,
-Recommended,
-Verified
) %>%
slice(-1)
df <- df %>%
filter(!(is.na(Review.Body) & is.na(Rating)))03 Topics
Each review is cleaned, stemmed, and searched for keywords that tag it with up to four topics, which are customer service, time and delay, seat and comfort, and food and beverages. A review that mentions both the crew and the meal counts toward both.
Seat and comfort tags 1,804 of the 1,894 written reviews, far more than any other topic.
keywords_customer_service <- c("service", "support", "customer", "response", "staff", "help", "crew", "email")
keywords_time_delay <- c("delay", "wait", "time", "hour", "late", "boarding", "missed")
keywords_seat_comfort <- c("seat", "comfort", "space", "legroom", "comfortability", "recline", "pillow", "blanket", "ac", "temperature", "air", "conditioner", "hot")
keywords_food_beverage <- c("food", "meal", "beverage", "drink", "snack", "refreshment", "menu", "lunch", "dinner", "breakfast")
classify_review <- function(review, category_keywords) {
if (is.na(review)) {
return(0)
}
cleaned_review <- clean_text(review, F, rm.stopwords_bahasa = F)
if (any(sapply(category_keywords, function(keyword) {
str_detect(cleaned_review, regex(keyword, ignore_case = TRUE))
}))) {
return(1)
} else {
return(0)
}
}
df <- df %>%
mutate(
"Customer_Service" = ifelse(is.na(Review.Body), 0, sapply(Review.Body, classify_review, category_keywords = keywords_customer_service)),
"Time_and_Delay" = ifelse(is.na(Review.Body), 0, sapply(Review.Body, classify_review, category_keywords = keywords_time_delay)),
"Seat_and_Comfort" = ifelse(is.na(Review.Body), 0, sapply(Review.Body, classify_review, category_keywords = keywords_seat_comfort)),
"Food_and_Beverages" = ifelse(is.na(Review.Body), 0, sapply(Review.Body, classify_review, category_keywords = keywords_food_beverage))
)04 Missing ratings
376 reviews have text but no rating. Dropping them would throw away what those passengers said, so GPT-4o mini reads each one and rates it from 1 to 10, the same scale the passengers use.
Every rating then falls into a sentiment, where 8 to 10 is positive, 5 to 7 is neutral, and 1 to 4 is negative.
from openai import OpenAI
client = OpenAI()
def get_sentiment(text, model_completion="gpt-4o-mini"):
completion = client.chat.completions.create(
model=model_completion,
messages=[
{"role": "system", "content": "You are a sentiment analyzer assistant, skilled in rating Qatar Airways review from 1 to 10. You will get the review text as an input and your job is to rate them from 1 to 10. Only output the rating number"},
{"role": "user", "content": "Rate this review:" + text}
]
)
return completion.model_dump()["choices"][0]["message"]["content"]
df.loc[df["Rating"].isnull(), "Rating"] = df.loc[df["Rating"].isnull(), "Review.Body"].apply(get_sentiment).astype("float64")
df['Sentiment_Category'] = np.select(
[
(df['Rating'] >= 8) & (df['Rating'] <= 10),
(df['Rating'] >= 5) & (df['Rating'] <= 7),
(df['Rating'] >= 1) & (df['Rating'] <= 4)
],
[
"Positive",
"Neutral",
"Negative"
]
)05 Missing dates
456 reviews have no date. The reviews are ordered from newest to oldest, so each missing date is filled with the midpoint between the nearest dated review before it and the nearest one after it.
filled_NaT_date = []
for i in range(len(df["Date"])):
if pd.isnull(df["Date"].iloc[i]):
previous_date = df["Date"].iloc[:i].dropna().iloc[-1] if i > 0 else None
next_date = df["Date"].iloc[i+1:].dropna().iloc[0] if i < len(df) - 1 else None
if previous_date is not None and next_date is not None:
filled_NaT_date.append(previous_date + (next_date - previous_date) / 2)
else:
filled_NaT_date.append(np.nan)
else:
filled_NaT_date.append(df["Date"].iloc[i])
df["Date"] = filled_NaT_date06 Ratings
A rating of 10 is the most common, given in 660 reviews. 1,331 reviews are positive, 501 are neutral, and 437 are negative.
07 Trend
The average rating peaks at 8.30 in 2020 and stays at 8.23 in 2021, then falls to 6.66 in 2022 and has not recovered since.
The 2024 average of 6.14 is the lowest yet, though it comes from only 29 reviews in the first two months of the year.
ts_yearly <- df %>%
group_by(Year) %>%
summarize(AverageRating = mean(Rating))08 Complaints
Across the 437 negative reviews, the most frequent words after removing stop words and the airline's own name are seat, hour, service, and time.
By topic, 251 negative reviews mention time and delay and 205 mention customer service.
get_tokens_count <- function(texts, rm.specific_terms = "") {
tokens_count <- texts %>%
tokenize_words() %>%
unlist() %>%
data.frame(token = .) %>%
count(token, sort = TRUE) %>%
na.omit() %>%
filter(!token %in% (rm.specific_terms %>% stemDocument()))
return(tokens_count)
}
overall_negative_tokens_count <- df %>%
filter(Sentiment_Category == "Negative") %>%
select(Review.Body) %>%
clean_text(F) %>%
get_tokens_count(rm.specific_terms = c("flight", "qatar", "doha", "airway", "airline"))09 Praise
2020 and 2021 are the best years in the data, so the positive reviews from 2020 to 2022 show what passengers praised while the airline was at its best.
Service leads by a wide margin, followed by time, good, food, and crew. Service and time rank near the top of the complaints too, so the same parts of the trip decide whether a passenger leaves happy or angry.
overall_positive_tokens_count_2020_to_2022 <- df %>%
filter(Sentiment_Category == "Positive",
Date < as.Date("2023-01-01"),
Date >= as.Date("2020-01-01")) %>%
select(Review.Body) %>%
clean_text(F) %>%
get_tokens_count(rm.specific_terms = c("flight", "qatar", "doha", "airway", "airline"))10 Stationarity
The ratings are averaged per month and split into the first 103 months for training and the last 24 for testing.
A rating can never leave the 1 to 10 scale, but a forecasting model does not know that. Each monthly average is scaled to between 0 and 1 and passed through a logit, so the model works on an unbounded series and every forecast maps back inside the scale.
An augmented Dickey-Fuller test gives a p-value of 0.09, so the series is not stationary. After differencing it once, the p-value drops to 1.9e-11, so both the regular and the seasonal differencing are set to 1.
ts_monthly_avg_rating = df['Rating'].resample('M').mean()
def transform_to_unbounded(x, lower=0, upper=10):
scaled = (x - lower) / (upper - lower)
scaled = np.clip(scaled, 0.001, 0.999)
return logit(scaled)
ts_monthly_avg_rating_unbounded = transform_to_unbounded(ts_monthly_avg_rating)
ts_train = ts_monthly_avg_rating_unbounded[:-24]
ts_test = ts_monthly_avg_rating_unbounded[-24:]
adfuller(ts_train)
adfuller(ts_train.diff().dropna())11 Model
The ACF and PACF of the differenced series suggest candidates for the autoregressive and moving average orders. Every combination of them, with a seasonal order of 0 or 1 on a 12-month season, gives 64 seasonal ARIMA models.
Each is trained on the first 103 months and scored on the last 24. The best is a SARIMA(0,1,6)(0,1,1)12, which misses the real monthly average by 13.6%, or 0.91 points, on average.
p_values = [0, 1, 2, 5]
q_values = [0, 1, 5, 6]
P_values = [0, 1]
Q_values = [0, 1]
seasonal_period = 12
def transform_to_bounded(x, lower=0, upper=10):
bounded_unit = expit(x)
return bounded_unit * (upper - lower) + lower
results = []
for p in p_values:
for q in q_values:
for P in P_values:
for Q in Q_values:
model = SARIMAX(ts_train, order=(p, 1, q), seasonal_order=(P, 1, Q, seasonal_period))
model_fit = model.fit(disp=False)
forecast_mean = transform_to_bounded(model_fit.get_forecast(steps=24).predicted_mean)
mape = mean_absolute_percentage_error(ts_monthly_avg_rating[-24:], forecast_mean) * 100
results.append({'p': p, 'q': q, 'P': P, 'Q': Q, 'MAPE': mape})12 Forecast
Carried forward another 12 months, the model expects the slide to continue. From March 2024 to February 2025 it forecasts an average of 5.97, below the 6.55 of the last 12 months on record.
model_SARIMAX = SARIMAX(ts_train,
order=(0, 1, 6),
seasonal_order=(0, 1, 1, 12)).fit()
forecast = model_SARIMAX.get_forecast(steps=36)
forecast_mean = transform_to_bounded(forecast.predicted_mean)13 Limits
The keywords match anywhere inside a word, so air and ac also match airport and back. That is why seat and comfort tags 1,804 of 1,894 written reviews, and why its count says little on its own.
The best model was picked by its score on the same 24 test months it is judged on, so its 13.6% is optimistic. The final forecast also comes from the model trained only up to February 2022, without the 24 newest months, which leaves room for the next version.
Qatar Airline Reviews
Forecasts the monthly average rating within 13.6% of the real one