Agora que temos um agente conversacional que compreende linguagem natural e consegue fornecer uma recomendação simples de preços hoteleiros, baseada em heurísticas, precisamos de levar a solução um passo mais além: queremos que a recomendação se baseie em dados reais do hotel, que analise os preços da concorrência, a estação do ano, a taxa de ocupação, etc., e que forneça uma recomendação de preços precisa. Para isso, precisamos de dados. E de um modelo preditivo.
O conjunto de dados para o Hotel Lemon Lagos
A granularidade dos dados é diária e por tipo de quarto. Este hotel é composto por três tipos de quarto diferentes: standard, com vista para o mar e suite. Temos dados extraídos de várias fontes no seguinte dicionário:
Dimensão de Tempo e Calendário
| Coluna | Tipo | Exemplo | Descrição |
|---|---|---|---|
| data | Data | 2025-07-15 | Data de referência do registo |
| Dia da semana | Inteiro (0-6) | 1 | Dia da semana (0 = Segunda-feira, 6 = Domingo) |
| Época | Categórico | Alta | Época: baixa, intermédia, alta |
| É_fim_de_semana | Binário (0/1) | 1 | Indica se o dia é um fim de semana de lazer (Sex/Sáb) |
| É feriado | Binário (0/1) | 0 | Indicador de feriado nacional |
| Indicador de evento | Binário (0/1) | 1 | Indicador de evento local que afeta a procura |
Dimensão do Produto (Quartos)
| Coluna | Tipo | Exemplo | Descrição |
|---|---|---|---|
| Tipo_de_quarto | Categórico | vista_mar | Categoria de quarto (standard, vista para o mar, suite) |
| capacidade_dos_quartos | Inteiro | 20 | Número total de quartos desse tipo |
| quartos_ocupados | Inteiro | 15 | Número de quartos ocupados nesse dia |
Operações e Procura
| Coluna | Tipo | Exemplo | Descrição |
|---|---|---|---|
| Taxa_de_ocupação | Valor decimal (0–1) | 0.75 | Taxa de ocupação por tipo de quarto |
| Índice de procura | Valor decimal (0.05-1.0) | 0.82 | Índice de procura normalizado |
| Tempo_médio_de_antecedência | Valor decimal (dias) | 18.4 | Tempo médio de antecedência da reserva |
Preços e Mercado
| Coluna | Tipo | Example (€) | Descrição |
|---|---|---|---|
| Preço médio | Valor decimal | 168.40 | Preço médio de venda do hotel |
| Preço médio da concorrência | Valor decimal | 162.30 | Preço médio estimado da concorrência |
Análise Exploratória de Dados
Vamos explorar estes dados visualmente e tentar perceber como o hotel funciona. Importei os dados de um ficheiro CSV do meu repositório do Github aqui e criei um relatório Power BI de página única para analisar os dados aqui. Abaixo, poderá ver o relatório incorporado:
Como pode ver, a procura é estruturalmente sazonal e existem picos no verão e alguma volatilidade nas reservas devido à concorrência feroz, cancelamentos e assim por diante (coisas que não conseguimos explicar).
Na época baixa, a taxa de ocupação é de cerca de 40 a 50%, enquanto na época alta atinge 85 a 98%. Na época intermédia (ou de transição), totaliza 65 a 75%. Isto é normal para este setor.
Ao analisar os preços dos concorrentes versus os nossos próprios preços, notamos mais uma vez a concorrência feroz que existe. Os preços são quase idênticos durante todo o ano. Isto significa que o hotel está a seguir uma estratégia de preços alinhada com o mercado, sem cortes agressivos. A fixação de preços premium (mais elevados) só é aplicada quando a procura é estruturalmente alta e o risco de capacidade é mínimo.
Portanto, a esta altura, podemos assumir que a implicação para o agente (de revenue management) é que ele deve aprender a liderança controlada de preços, e não a fixação de preços agressivamente mais altos (overpricing).
Arquitetura do Modelo e Pipeline de Treino
Como o nosso objetivo não é prever a procura, não vamos usar previsão de séries temporais desta vez (como fizemos num artigo anterior). Desta vez, o objetivo é modelar a relação causal entre preço e ocupação sob diferentes condições de mercado e, em seguida, otimizar o preço para maximizar a receita e a ocupação. Isto reformula a definição de preços como um problema de otimização controlada, e não como uma tarefa passiva de previsão.
Aqui está a arquitetura de alto nível. Caso deseje analisar ou descarregar o código, pode encontrá-lo no meu repositório do GitHub aqui.
- Variável Alvo (Label) y: Taxa de Ocupação (occupancy_rate) (contínua, entre 0 e 1)
- Tarefa de Aprendizagem: Regressão Supervisionada
- Função do Modelo: Aprender a elasticidade da procura em relação ao preço no contexto do mercado.
Variáveis de Entrada Principais
- Preço médio
- Preço médio da concorrência
- Época
- É_fim_de_semana
- É feriado
- Indicador de evento
- Tempo_médio_de_antecedência
- Tipo_de_quarto
- capacidade_dos_quartos
Engenharia de Variáveis
- diferença_de_preço (ou price_gap) = preço_médio - preço_médio_dos_concorrentes
- rácio_de_preço (ou price_ratio) = preço_médio / preço_médio_dos_concorrentes
- mês = mês(data)
- capacidade_dos_quartos
Codificação e Pré-processamento
- epoca (season): one-hot encoding
- tipo_de_quarto (room_type): one-hot encoding
- variáveis numéricas: podemos usar normalização para modelos lineares ou mantê-las "como estão" para modelos baseados em árvores (decidiremos isso mais tarde)
- Variáveis Categóricas: utilizaremos um valor explícito "desconhecido" (unknown). Variáveis Numéricas: utilizaremos a imputação pela mediana (median imputation).
Para Respeitar a Estrutura Temporal e Evitar o Viés de Antecipação (Look-Ahead Bias):
- Conjunto de Treino (Training Set): 2023–2024
- Conjunto de Validação (Validation Set): início de 2025
- Conjunto de Teste (Test Set): final de 2025
Treino do Modelo de Regressão
Iremos treinar um HistGradientBoostingRegressor, que é um modelo baseado em árvores que pertence à família das Árvores de Decisão de Gradient Boosting. Este algoritmo constrói muitas árvores de decisão, uma após a outra, onde cada nova árvore se concentra em corrigir os erros das árvias anteriores. Assim, é como se usássemos muitos modelos pequenos que, combinados, se tornam muito eficientes e "inteligentes".
As Árvores de Decisão são particularmente eficazes na aprendizagem de padrões baseados em regras, como "se a estação for baixa, a taxa de ocupação tende a ser mais baixa", e na captura de relações não lineares entre as variáveis de entrada e a variável alvo. Isto torna-as especialmente adequadas para problemas de otimização de preços, onde o impacto do preço na procura raramente é linear e depende fortemente do contexto.
Código Python Completo
# train_pricing_model.py
import os
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
import joblib
# ---------------------------------------------------
# 1. Configuration
# ---------------------------------------------------
CSV_PATH = r"C:\Projetos\hotel_pricing\data\hotel_lagos_daily_rooms.csv"
# By default we model occupancy as a function of price and context.
# If you really want to estimate price instead, set this to "avg_price".
TARGET_COLUMN = "occupancy_rate"
MODEL_OUTPUT_PATH = r"C:\Projetos\hotel_pricing\regression_model\pricing_elasticity_model.pkl"
# ---------------------------------------------------
# 2. Load data
# ---------------------------------------------------
df = pd.read_csv(CSV_PATH, parse_dates=["date"])
# Basic sanity checks and type normalization
required_cols = [
"date",
"season",
"room_type",
"avg_price",
"competitor_avg_price",
"occupancy_rate",
"lead_time_avg",
"rooms_capacity",
"is_weekend",
"is_holiday",
"event_flag",
]
missing_required = [c for c in required_cols if c not in df.columns]
if missing_required:
raise ValueError(f"Missing required columns in CSV: {missing_required}")
# ---------------------------------------------------
# 3. Feature engineering
# ---------------------------------------------------
# Clip occupancy and lead time to realistic ranges
df["occupancy_rate"] = df["occupancy_rate"].clip(0.0, 1.0)
df["lead_time_avg"] = df["lead_time_avg"].clip(1, 40)
# Relative price features
df["price_gap"] = df["avg_price"] - df["competitor_avg_price"]
df["price_ratio"] = df["avg_price"] / df["competitor_avg_price"]
# Calendar features
df["month"] = df["date"].dt.month.astype(int)
# Ensure binary flags are integers (0/1)
for col in ["is_weekend", "is_holiday", "event_flag"]:
df[col] = df[col].astype(int)
# ---------------------------------------------------
# 4. Train / validation / test split (time-based)
# ---------------------------------------------------
# Example: train on 2023–2024, validate on 2025-H1, test on 2025-H2
train_end = pd.Timestamp("2024-12-31")
val_end = pd.Timestamp("2025-06-30")
train_mask = df["date"] <= train_end
val_mask = (df["date"] > train_end) & (df["date"] <= val_end)
test_mask = df["date"] > val_end
df_train = df[train_mask].copy()
df_val = df[val_mask].copy()
df_test = df[test_mask].copy()
print(f"Train rows: {len(df_train)}, Val rows: {len(df_val)}, Test rows: {len(df_test)}")
# ---------------------------------------------------
# 5. Define features and target
# ---------------------------------------------------
feature_cols_numeric = [
"avg_price",
"competitor_avg_price",
"price_gap",
"price_ratio",
"lead_time_avg",
"month",
"rooms_capacity",
"is_weekend",
"is_holiday",
"event_flag",
]
feature_cols_categorical = [
"season",
"room_type",
]
X_train = df_train[feature_cols_numeric + feature_cols_categorical]
y_train = df_train[TARGET_COLUMN]
X_val = df_val[feature_cols_numeric + feature_cols_categorical]
y_val = df_val[TARGET_COLUMN]
X_test = df_test[feature_cols_numeric + feature_cols_categorical]
y_test = df_test[TARGET_COLUMN]
# ---------------------------------------------------
# 6. Preprocessing pipeline
# ---------------------------------------------------
numeric_transformer = Pipeline(
steps=[
("imputer", SimpleImputer(strategy="median")),
# No scaler needed for tree-based models; add StandardScaler if using linear models
]
)
categorical_transformer = Pipeline(
steps=[
("imputer", SimpleImputer(strategy="most_frequent")),
("onehot", OneHotEncoder(handle_unknown="ignore")),
]
)
preprocess = ColumnTransformer(
transformers=[
("num", numeric_transformer, feature_cols_numeric),
("cat", categorical_transformer, feature_cols_categorical),
]
)
# ---------------------------------------------------
# 7. Regression model
# ---------------------------------------------------
regressor = HistGradientBoostingRegressor(
random_state=42,
max_depth=None,
learning_rate=0.1,
max_iter=300,
)
model = Pipeline(
steps=[
("preprocess", preprocess),
("regressor", regressor),
]
)
# ---------------------------------------------------
# 8. Train model
# ---------------------------------------------------
print("Training model...")
model.fit(X_train, y_train)
# ---------------------------------------------------
# 9. Evaluation helper
# ---------------------------------------------------
def evaluate(split_name: str, y_true, y_pred):
# If we are modeling occupancy, clip to [0, 1] for interpretability
if TARGET_COLUMN == "occupancy_rate":
y_pred = np.clip(y_pred, 0.0, 1.0)
mae = mean_absolute_error(y_true, y_pred)
rmse = np.sqrt(mean_squared_error(y_true, y_pred))
r2 = r2_score(y_true, y_pred)
print(f"\n[{split_name}]")
print(f"MAE : {mae:.4f}")
print(f"RMSE: {rmse:.4f}")
print(f"R² : {r2:.4f}")
# ---------------------------------------------------
# 10. Evaluate on validation and test
# ---------------------------------------------------
y_val_pred = model.predict(X_val)
evaluate("Validation", y_val, y_val_pred)
y_test_pred = model.predict(X_test)
evaluate("Test", y_test, y_test_pred)
# ---------------------------------------------------
# 11. Save trained model
# ---------------------------------------------------
os.makedirs(os.path.dirname(MODEL_OUTPUT_PATH), exist_ok=True)
joblib.dump(model, MODEL_OUTPUT_PATH)
print(f"\nModel saved to: {MODEL_OUTPUT_PATH}")
Treinamos o modelo e testámo-lo executando o script acima e obtemos o seguinte resultado:
Train rows: 2193, Val rows: 543, Test rows: 459
Training model...
[Validation]
MAE : 0.0268
RMSE: 0.0348
R² : 0.9801
[Test]
MAE : 0.0247
RMSE: 0.0333
R² : 0.9767
Model saved to: C:\Projetos\hotel_pricing\regression_model\pricing_elasticity_model.pkl
Após estender o conjunto de variáveis (features) para incluir explicitamente indicadores de fim de semana, feriados e eventos locais, o modelo manteve um desempenho consistentemente forte tanto no conjunto de validação como no conjunto de teste out-of-time (fora do período de treino). Atingiu um Erro Absoluto Médio (MAE) de aproximadamente 0.03 no conjunto de validação e 0.02 no conjunto de teste, o que corresponde a um erro médio de cerca de dois pontos percentuais na taxa de ocupação. Para capacidades típicas de quartos, isto traduz-se em um erro de previsão médio de muito menos do que um quarto por dia.
Os valores de R² (Coeficiente de Determinação) de 0.98 no conjunto de validação e 0.97 no conjunto de teste confirmam que o modelo explica a maior parte da variabilidade da ocupação utilizando apenas o preço e o contexto de mercado. O facto de o desempenho ter-se mantido estável após a adição de novas variáveis comportamentais indica que a estrutura do modelo é robusta e que capturou com sucesso as principais relações preço–procura necessárias para uma otimização de preços fiável.
Hora de Testar o Modelo
Ok, vamos pensar num cenário onde este modelo possa ser útil: período de época baixa (low-season timing), sem eventos especiais a acontecer, e precisamos de prever a taxa de ocupação.
Podemos dizer ao Python para testar o modelo localmente com:
{
"name": "Low season – standard room – no event",
"avg_price": 95,
"competitor_avg_price": 100,
"season": "low",
"room_type": "standard",
"lead_time_avg": 8,
"rooms_capacity": 30,
"is_weekend": 0,
"is_holiday": 0,
"event_flag": 0,
"month": 1,
}
E o resultado será:
============================================================
Scenario: Low season – standard room – no event
Price: €95.00
Competitor price: €100.00
Season: low | Room type: standard | Month: 1 | Weekend: 0 | Holiday: 0 | Event: 0
Lead time: 8 days
Predicted occupancy: 46.89%
Expected revenue: €1,336.39
Isto é o que esperaríamos de um cenário de época baixa. Com um dia de semana em Janeiro, sem eventos, e um preço ligeiramente descontado em comparação com o mercado, o modelo não "inventa" ocupação total. Em vez disso, prevê que cerca de 47% dos quartos standard serão vendidos, o que se traduz em aproximadamente 14 em 30 quartos e cerca de €1.300 em receita para essa noite.
O ponto importante aqui não é o número exato, mas o comportamento: o modelo reage ao preço e ao contexto de uma forma que é economicamente razoável e consistente com o padrão histórico que encontrámos nos dados.
Vamos testar o modelo com dois cenários adicionais:
{
"name": "High season – sea view – strong demand (weekend + event)",
"avg_price": 195,
"competitor_avg_price": 190,
"season": "high",
"room_type": "sea_view",
"lead_time_avg": 28,
"rooms_capacity": 20,
"is_weekend": 1,
"is_holiday": 0,
"event_flag": 1,
"month": 8,
},
{
"name": "Shoulder season – suite – weekend, no event",
"avg_price": 220,
"competitor_avg_price": 210,
"season": "shoulder",
"room_type": "suite",
"lead_time_avg": 18,
"rooms_capacity": 10,
"is_weekend": 1,
"is_holiday": 0,
"event_flag": 0,
"month": 5,
}
Qual a resposta do modelo?
============================================================
Scenario: High season – sea view – strong demand (weekend + event)
Price: €195.00
Competitor price: €190.00
Season: high | Room type: sea_view | Month: 8 | Weekend: 1 | Holiday: 0 | Event: 1
Lead time: 28 days
Predicted occupancy: 100.00%
Expected revenue: €3,900.00
============================================================
Scenario: Shoulder season – suite – weekend, no event
Price: €220.00
Competitor price: €210.00
Season: shoulder | Room type: suite | Month: 5 | Weekend: 1 | Holiday: 0 | Event: 0
Lead time: 18 days
Predicted occupancy: 68.59%
Expected revenue: €1,509.02
Estes resultados também são consistentes com a análise exploratória que fizemos anteriormente. O modelo reage à época do ano e ao preço da concorrência.
No próximo passo, vamos parar de testar preços únicos e começar a simular múltiplos níveis de preço para o mesmo cenário, de modo a que possamos procurar o preço que maximiza a receita esperada.
Testamos o modelo com os mesmos três cenários num intervalo de preços em torno dos concorrentes:
- Um mínimo de 50% do preço do concorrente
- Um máximo de 150% do preço do concorrente
E aqui estão os resultados:
==================================================================
Scenario: Low season – standard room – no event
Competitor price: €100.00 | Price range tested: €50.00 → €150.00
------------------------------------------------------
Top 5 price points by expected revenue:
price predicted_occupancy expected_revenue
€75.00 95.41% €2,146.76
€70.83 96.07% €2,041.50
€66.67 98.61% €1,972.23
€79.17 82.34% €1,955.66
€62.50 97.43% €1,826.88
Recommended price (argmax revenue):
Price: €75.00 | Predicted occupancy: 95.41% | Expected revenue: €2,146.76
==================================================================
Scenario: High season – sea view – strong demand (weekend + event)
Competitor price: €190.00 | Price range tested: €95.00 → €285.00
------------------------------------------------------
Top 5 price points by expected revenue:
price predicted_occupancy expected_revenue
€261.25 86.02% €4,494.47
€253.33 88.64% €4,490.93
€269.17 82.48% €4,440.23
€229.58 95.93% €4,404.56
€285.00 77.26% €4,403.54
Recommended price (argmax revenue):
Price: €261.25 | Predicted occupancy: 86.02% | Expected revenue: €4,494.47
==================================================================
Scenario: Shoulder season – suite – weekend, no event
Competitor price: €210.00 | Price range tested: €105.00 → €315.00
------------------------------------------------------
Top 5 price points by expected revenue:
price predicted_occupancy expected_revenue
€183.75 90.93% €1,670.78
€175.00 93.39% €1,634.27
€201.25 79.47% €1,599.36
€210.00 75.92% €1,594.35
€166.25 95.81% €1,592.89
Recommended price (argmax revenue):
Price: €183.75 | Predicted occupancy: 90.93% | Expected revenue: €1,670.78
Estes resultados são convincentes. O primeiro padrão claro nos três cenários é que revelam não só diferentes pontos de preço ótimos, mas também distintos níveis de elasticidade de preço. Em época baixa, a procura é altamente elástica: pequenas reduções de preço traduzem-se num aumento de ocupação desproporcionalmente maior, o que explica porque é que o preço ótimo desce para 75€, bem abaixo do preço de referência/concorrente. Em contraste, durante a época alta com procura forte, o mercado torna-se marcadamente inelástico. A ocupação diminui muito lentamente à medida que os preços sobem, permitindo um preço ótimo de 261.25€ — muito acima do preço do concorrente (190€) — enquanto continua a gerar a receita esperada mais alta. Este contraste mostra como o mesmo hotel pode operar sob regimes de procura completamente diferentes, dependendo do contexto.
O cenário da suíte em época média (shoulder-season) situa-se entre estes dois extremos, mostrando elasticidade moderada. Baixar o preço de 210€ para cerca de 183.75€ aumenta a ocupação de forma significativa, mas não tão acentuada como na época baixa. A otimização da receita aqui depende de equilibrar uma curva de procura mais suave com o valor intrínseco mais alto do quarto. Em conjunto, os cenários confirmam que a precificação ótima é fundamentalmente moldada pelo regime de elasticidade: elástica sob procura baixa, inelástica quando a procura é forte e mista durante períodos de transição. Isto reforça o argumento central do artigo: a intuição por si só é insuficiente porque a elasticidade é dinâmica e dependente do contexto, tornando a precificação algorítmica e orientada por dados essencial para a otimização consistente da receita.
Finalmente, para ir um passo mais além, podemos investigar a importância das variáveis. O que é que impulsiona estes resultados? Vou executar o script de importância das variáveis e examinar os resultados.
Computing permutation feature importance...
Feature importance (by mean decrease in R²):
price_gap 0.524692
is_weekend 0.365607
season 0.306367
price_ratio 0.221113
event_flag 0.140881
rooms_capacity 0.069459
competitor_avg_price 0.010325
room_type 0.009927
avg_price 0.009160
lead_time_avg 0.004647
is_holiday 0.004448
month 0.001936
Resultados da importância das variáveis por permutação.
A análise de importância das variáveis baseada em permutação confirma que o price\_gap (a diferença entre o nosso preço testado e o preço do concorrente) é o principal impulsionador, confirmando que o posicionamento competitivo desempenha um papel central na formação da procura. Este é seguido por is\_weekend, season, e price\_ratio, os quais influenciam materialmente a ocupação ao capturar padrões estruturais de intensidade da procura. A presença de um evento também contribui significativamente, embora em menor grau, reforçando que os picos de procura externos são importantes, mas não superam fatores de base como a dinâmica de preços e a sazonalidade.
Curiosamente, variáveis frequentemente assumidas como sendo grandes impulsionadoras, tais como o preço médio do concorrente, o tipo de quarto, o preço histórico médio, o lead time, feriados, ou até mesmo o mês, mostram muito baixa importância neste modelo.
Isto sugere que, para o conjunto de dados sintético e os cenários testados, o preço relativo e o tempo (fim de semana/época) dominam o sinal de procura, enquanto os atributos mais granulares contribuem marginalmente. Isto reforça a conclusão mais ampla: a otimização de preços é primariamente uma função da posição relativa do mercado e do contexto temporal, em vez de características estáticas dos quartos ou efeitos de calendário.
Quer uma estratégia de preços personalizada para o seu hotel?
Contacte o Swell AI Lab para agendar um diagnóstico rápido.





[…] you missed Part 1 and Part 2 of this series, the goal of this project is to help a hotel manager in Lagos, Algarve determine […]
[…] the recommendation is based on the trained model, using the hotel data described in part 2 of this […]