0001 — BigQuery ML — 01 — Prevendo preços de passagens aéreas
Contexto da operação
Neste artigo vamos criar modelos de Machine Learning diretamente no BigQuery usando BigQuery ML. A proposta é começarmos com um modelo inicial simples e ir explorando as ferramentas que o BigQuery ML oferece, do treino à interpretação do modelo final.
A tabela de origem contém dados de passagens aéreas com companhia, rota, horários, classe, duração, dias até o embarque e preço. Os dados vêm de um dataset público do Kaggle.
DOC: https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-create-glm
Para acompanhar o processo, você precisa de três coisas:
- Um dataset chamado flights criado no seu projeto BigQuery.
- Uma chave de API do Kaggle (gerada em Account → Create New API Token) salva no Google Cloud Secret Manager com o nome kaggle_api, e o seu username do Kaggle salvo em outra secret com o nome kaggle_user_name.
- Um ambiente Python autenticado no Google Cloud (Colab, Vertex AI Workbench ou máquina local com gcloud auth application-default login).
Com isso pronto, rode o notebook abaixo. Ele baixa o dataset do Kaggle e carrega na tabela flights.data_flights no BigQuery.
!pip install kagglehub[pandas-datasets] pandas-gbq google-cloud-secret-manager -qimport os
import logging
import kagglehub
import pandas as pd
import pandas_gbq
from kagglehub import KaggleDatasetAdapter
from google.cloud import secretmanager
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
force=True
)
class SecretManager:
def __init__(self, project_id: str = "<YOUR-PROJECT-ID>") -> None:
self.log = logging.getLogger(__name__)
self.project_id = project_id
def get_secret(self, secret_id: str) -> str:
client = secretmanager.SecretManagerServiceClient()
name = f"projects/{self.project_id}/secrets/{secret_id}/versions/latest"
try:
response = client.access_secret_version(request={"name": name})
secret_value = response.payload.data.decode("UTF-8")
self.log.info(f"Success to get secret - '{secret_id}'")
return secret_value
except Exception as e:
self.log.error(f"Error to get secret - '{secret_id}': {e}")
return None
def set_kaggle_credentials(self) -> bool:
username = self.get_secret("kaggle_user_name")
key = self.get_secret("kaggle_api")
if not username or not key:
self.log.error("Failed to retrieve Kaggle credentials.")
return False
os.environ["KAGGLE_USERNAME"] = username
os.environ["KAGGLE_KEY"] = key
self.log.info("Kaggle credentials set successfully.")
return True
class Extraction:
DATASET_HANDLE = "shubhambathwal/flight-price-prediction"
FILE_PATH = "Clean_Dataset.csv"
def __init__(self) -> None:
self.log = logging.getLogger(__name__)
def get_data(self) -> dict:
self.log.info(f"Fetching dataset '{self.DATASET_HANDLE}'")
try:
df = kagglehub.dataset_load(
KaggleDatasetAdapter.PANDAS,
self.DATASET_HANDLE,
self.FILE_PATH,
)
if "Unnamed: 0" in df.columns:
df = df.drop(columns=["Unnamed: 0"])
df.columns = (
df.columns
.str.strip()
.str.lower()
.str.replace(" ", "_", regex=False)
)
df["duration"] = pd.to_numeric(df["duration"], errors="coerce")
df["days_left"] = pd.to_numeric(df["days_left"], errors="coerce")
df["price"] = pd.to_numeric(df["price"], errors="coerce")
df = df.dropna(subset=["price"])
self.log.info(f"Dataset loaded: {len(df)} rows, {len(df.columns)} columns")
self.log.info(f"Columns: {df.columns.tolist()}")
return {"success": True, "data": df}
except Exception as e:
self.log.error(f"Extraction failed: {e}")
return {"success": False, "data": None}
class Load:
def __init__(self, df: pd.DataFrame, project_id: str, dataset_id: str, table_id: str) -> None:
self.df = df
self.project_id = project_id
self.dataset_id = dataset_id
self.table_id = table_id
self.log = logging.getLogger(__name__)
def upload_to_bigquery(self, if_exists: str = "replace") -> bool:
destination = f"{self.dataset_id}.{self.table_id}"
try:
self.log.info(f"Uploading data to BigQuery: {destination}")
pandas_gbq.to_gbq(
self.df,
destination_table=destination,
project_id=self.project_id,
if_exists=if_exists,
chunksize=100_000,
progress_bar=True,
)
self.log.info(f"Upload complete: {destination}")
return True
except Exception as e:
self.log.error(f"Upload failed for {destination}: {e}")
return False
if __name__ == "__main__":
log = logging.getLogger(__name__)
project_id = "<YOUR-PROJECT-ID>"
secret_manager = SecretManager(project_id=project_id)
if not secret_manager.set_kaggle_credentials():
log.error("Failed to set Kaggle credentials. Aborting.")
raise SystemExit(1)
extraction = Extraction()
result = extraction.get_data()
if not result["success"]:
log.error("Failed to retrieve data. Aborting.")
raise SystemExit(1)
df = result["data"]
log.info(f"Head:\n{df.head()}")
log.info(f"Tail:\n{df.tail()}")
loader = Load(
df=df,
project_id=project_id,
dataset_id="flights",
table_id="data_flights",
)
if not loader.upload_to_bigquery(if_exists="replace"):
log.error("BigQuery upload failed.")
raise SystemExit(1)
log.info("Pipeline completed successfully.")Depois que a tabela estiver carregada, podemos inspecionar uma amostra para entender a estrutura dos dados.
SELECT
airline,
flight,
source_city,
departure_time,
stops,
arrival_time,
destination_city,
class,
duration,
days_left,
price
FROM `flights.data_flights`
;Nosso objetivo é prever o preço da passagem (price) em rúpias indianas. Cada linha é uma observação independente, o que torna o problema naturalmente adequado para regressão linear como ponto de partida.
Baseline ingênuo
Antes de treinar qualquer modelo, vale estabelecer dois baselines triviais para termos uma régua de comparação. O primeiro prevê sempre o preço médio global. O segundo, já antecipando o que veremos na análise exploratória, prevê a mediana de preço por classe (Economy ou Business). Esses baselines não exigem treino e servem como piso de qualidade: qualquer modelo que treinarmos precisa ficar abaixo desses erros para justificar sua existência.
WITH stats AS (
SELECT
AVG(price) AS mean_price
FROM `flights.flight_prices`
WHERE price IS NOT NULL
)
SELECT
ROUND(AVG(ABS(price - mean_price)), 2) AS mae_baseline_mean,
ROUND(SQRT(AVG(POW(price - mean_price, 2))), 2) AS rmse_baseline_mean
FROM `flights.flight_prices`, stats
;O baseline da média global produz um MAE de 19.759,89 INR. Esse é o erro mínimo que qualquer modelo precisa superar para entregar valor: prever sempre a média já erra, em média, quase 20 mil rúpias por passagem. Note que esse número está muito próximo do MAE do nosso primeiro modelo linear (19.329,86), o que reforça que aquele primeiro modelo capturou pouquíssima informação além do que um chute na média já traz.
Mas a média global não é o baseline mais informado que conseguimos construir sem treinar nada. Se já souvermos que existem dois mercados claramente distintos (Economy e Business), prever a mediana dentro de cada classe é trivial e muito mais forte:
WITH medians AS (
SELECT
class,
APPROX_QUANTILES(price, 2)[OFFSET(1)] AS median_price
FROM `flights.flight_prices`
GROUP BY class
)
SELECT
ROUND(AVG(ABS(f.price - m.median_price)), 2) AS mae_baseline_class
FROM `flights.flight_prices` f
JOIN medians m
USING (class)
;Esse baseline mais cuidadoso desce o MAE de 19.759,89 para 4.801,27 INR, uma redução de cerca de 76% sem treinar nenhum modelo. É o número que vamos manter em mente como referencia: qualquer modelo que treinarmos só entrega valor real se ficar abaixo desses 4.801 INR de erro médio. Como veremos a seguir, o nosso primeiro modelo linear vai ficar bem acima dele e a virada virá quando incluirmos a variável class entre as features.
Modelo inicial: rota e horário
Vamos criar uma base muito simples. Ela usa apenas informações de rota e horário, sem considerar classe, duração, escalas ou tempo até o embarque.
CREATE OR REPLACE TABLE `flights.flight_prices_v1`
AS
WITH source_data as (
SELECT
airline,
source_city,
departure_time,
arrival_time,
destination_city,
class,
SAFE_CAST(duration AS FLOAT64) as duration,
SAFE_CAST(days_left AS INT64) as days_left,
SAFE_CAST(price AS FLOAT64) as price
FROM `flights.data_flights`
WHERE airline IS NOT NULL
AND source_city IS NOT NULL
AND destination_city IS NOT NULL
AND price IS NOT NULL
AND price > 0
)
,
get_features as (
SELECT
source_city,
destination_city,
departure_time,
arrival_time,
price
FROM source_data
)
SELECT
source_city,
destination_city,
departure_time,
arrival_time,
price
FROM get_features
;Treinando o modelo com CREATE MODEL
CREATE OR REPLACE MODEL `flights.flight_price_linear_model_v1`
OPTIONS(
model_type = 'LINEAR_REG',
input_label_cols = ['price'],
data_split_method = 'RANDOM',
data_split_eval_fraction = 0.2
)
AS
SELECT
source_city,
destination_city,
departure_time,
arrival_time,
price
FROM `flights.flight_prices_v1`
WHERE price IS NOT NULL
;Com o comando CREATE OR REPLACE MODEL ou CREATE MODEL project_name.dataset_name.model_name podemos criar nossos podelos que ficará hospedados diretamente no nosso dataset. Bem similar aos comando CREATE OR REPLACE TABLE para criação de tabelas.
A cláusula OPTIONS é onde definimos como o modelo vai ser treinado. Cada parâmetro tem um papel específico, e vale conhecer as alternativas disponíveis.
model_type define o algoritmo. Aqui usamos LINEAR_REG, que é regressão linear ordinária. O BigQuery ML aceita vários outros tipos para regressão, como BOOSTED_TREE_REGRESSOR, RANDOM_FOREST_REGRESSOR, DNN_REGRESSOR (rede neural) e AUTOML_REGRESSOR(que escolhe o algoritmo automaticamente). Para classificação existem versões equivalentes (LOGISTIC_REG, BOOSTED_TREE_CLASSIFIER, etc), além de modelos não-supervisionados como KMEANS (clustering) e MATRIX_FACTORIZATION (recomendação).
input_label_cols indica qual coluna o modelo deve prever. O parâmetro recebe um array (por isso a sintaxe [‘price’] com colchetes), mas na prática os modelos de regressão e classificação aceitam apenas um valor (até o momento de escrita desse artigo). Em modelos não-supervisionados como KMEANS, esse parâmetro não existe, porque não há label a prever.
data_split_method controla como o BigQuery separa os dados entre treino e avaliação. Existem cinco opções:
- AUTO_SPLIT (padrão): divide aleatoriamente quando há mais de 500 linhas.
- RANDOM: divisão aleatória explícita. Recomendado para dados cross-sectionais como o nosso.
- SEQ: separação sequencial baseada em uma coluna ordenável (geralmente date). Usar para séries temporais.
- CUSTOM: você define manualmente quais linhas vão para treino e quais para avaliação via uma coluna booleana.
- NO_SPLIT: usa todos os dados para treino. Indicado apenas quando você tem outra forma de validar o modelo.
data_split_eval_fraction define o percentual de dados reservados para avaliação. O padrão é 0,2 (20%). Subir para 0,3 dá uma avaliação mais robusta mas reduz dados de treino. Em datasets grandes (acima de 100k linhas), 0,2 costuma ser suficiente. Em datasets pequenos, pode valer reduzir para 0,1 para deixar mais dados para o modelo aprender.
Existem outros parâmetros que poderíamos passar mas não usamos no primeiro modelo, como l1_reg e l2_reg (regularização), learn_rate (taxa de aprendizado), max_iterations (limite de iterações) e early_stop (parada antecipada). Vamos explorar alguns deles no segundo modelo.
Avaliando com ML.EVALUATE
Depois de treinar, usamos ML.EVALUATE para medir a qualidade do modelo. O retorno depende do tipo de modelo. Para regressão, o BigQuery devolve seis métricas:
- mean_absolute_error: erro absoluto médio em rúpias.
- mean_squared_error: erro quadrático médio. Penaliza erros grandes com mais rigor.
- mean_squared_log_error: erro quadrático em escala logarítmica.
- median_absolute_error: erro absoluto mediano. Menos sensível a valores discrepantes.
- r2_score: porcentagem da variância do alvo explicada pelo modelo.
- explained_variance: variância explicada pelas previsões.
Para modelos de classificação as métricas são outras (accuracy, precision, recall, f1_score, log_loss, roc_auc). Para clustering, métricas como davies_bouldin_index e mean_squared_distance. Mantenha isso em mente quando trocar o model_type: a tabela retornada vai mudar de colunas.
Por padrão, ML.EVALUATE calcula as métricas no conjunto de avaliação (os 20% reservados pelo data_split_eval_fraction). Se você quiser avaliar em um conjunto diferente (por exemplo, dados novos chegando em produção), basta passar uma query como segundo argumento da função.
Uma observação importante sobre a query a seguir: os limites usados nos CASE WHEN para classificar cada métrica em very_good, good, regular ou bad são heurísticas que defini para este dataset específico, considerando que o preço médio das passagens está na casa das dezenas de milhares de rúpias. Esses thresholds não são universais. Em outro domínio (preços de imóveis, tempo de entrega, churn), recalibre essas faixas com base na ordem de grandeza do alvo e no desvio padrão do seu próprio dataset.
WITH model_evaluation as (
SELECT
mean_absolute_error,
mean_squared_error,
SQRT(mean_squared_error) as root_mean_squared_error,
mean_squared_log_error,
median_absolute_error,
r2_score,
explained_variance
FROM ML.EVALUATE(
MODEL `flights.flight_price_linear_model_v1`
)
)
SELECT
'mean_absolute_error' as metric,
ROUND(mean_absolute_error, 2) as value,
CASE
WHEN mean_absolute_error <= 3000 THEN 'very_good'
WHEN mean_absolute_error <= 6000 THEN 'good'
WHEN mean_absolute_error <= 10000 THEN 'regular'
ELSE 'bad'
END as status
FROM model_evaluation
UNION ALL
SELECT
'mean_squared_error' as metric,
ROUND(mean_squared_error, 2) as value,
'-' as status
FROM model_evaluation
UNION ALL
SELECT
'root_mean_squared_error' as metric,
ROUND(root_mean_squared_error, 2) as value,
CASE
WHEN root_mean_squared_error <= 4000 THEN 'very_good'
WHEN root_mean_squared_error <= 8000 THEN 'good'
WHEN root_mean_squared_error <= 13000 THEN 'regular'
ELSE 'bad'
END as status
FROM model_evaluation
UNION ALL
SELECT
'mean_squared_log_error' as metric,
ROUND(mean_squared_log_error, 4) as value,
'-' as status
FROM model_evaluation
UNION ALL
SELECT
'median_absolute_error' as metric,
ROUND(median_absolute_error, 2) as value,
'-' as status
FROM model_evaluation
UNION ALL
SELECT
'r2_score' as metric,
ROUND(r2_score, 4) as value,
CASE
WHEN r2_score >= 0.85 THEN 'very_good'
WHEN r2_score >= 0.65 THEN 'good'
WHEN r2_score >= 0.30 THEN 'regular'
ELSE 'bad'
END as status
FROM model_evaluation
UNION ALL
SELECT
'explained_variance' as metric,
ROUND(explained_variance, 4) as value,
CASE
WHEN explained_variance >= 0.85 THEN 'very_good'
WHEN explained_variance >= 0.65 THEN 'good'
WHEN explained_variance >= 0.30 THEN 'regular'
ELSE 'bad'
END as status
FROM model_evaluation
;EVALUATION do primeiro modelo
O mean_absolute_error (MAE) ficou em 19.329,86 INR. Em valor absoluto, esse erro está na mesma ordem de grandeza do preço médio das passagens, o que mostra que as previsões estão muito distantes dos valores reais.
O root_mean_squared_error (RMSE) de 22.339,98 ficou cerca de 15% acima do MAE. Quando RMSE e MAE estão próximos, isso indica que os erros são relativamente uniformes ao longo da base, em vez de concentrados em poucos casos extremos. O median_absolute_error de 16.661,88 reforça essa leitura.
O r2_score de 0,0298 é o resultado mais informativo. O R² mede quanto da variância do alvo o modelo consegue explicar em relação a um baseline que sempre prevê a média. Um valor de 0,03 significa que o modelo está apenas marginalmente melhor do que esse baseline. O explained_variance idêntico (0,0298) confirma que praticamente nenhuma estrutura está sendo capturada pelas features atuais.
Acompanhando o treino com ML.TRAINING_INFO
Além das métricas finais, podemos olhar como o modelo evoluiu durante o treinamento. ML.TRAINING_INFO retorna uma linha por iteração, com a loss de treino, a loss de avaliação, o tempo gasto e o learning_rate usado.
SELECT
iteration,
loss as training_loss,
eval_loss,
duration_ms,
learning_rate
FROM ML.TRAINING_INFO(MODEL `flights.flight_price_linear_model_v1`)
ORDER BY iteration
;Essa função é especialmente útil para detectar dois problemas.
- overfitting durante o treino: se a eval_loss começar a subir enquanto a loss continua descendo, o modelo está decorando o treino.
- estagnação: se as duas losses se mantêm constantes por muitas iterações, o modelo já convergiu e treinar mais não vai ajudar.
ML.TRAINING_INFO está disponível para LINEAR_REG, LOGISTIC_REG, DNN_REGRESSOR, DNN_CLASSIFIER, BOOSTED_TREE_REGRESSOR, BOOSTED_TREE_CLASSIFIER e KMEANS.
Para RANDOM_FOREST ela não existe, porque cada árvore é treinada independentemente sem o conceito de iteração sequencial.
Predizendo com ML.PREDICT
Uma vez que o modelo está treinado, usamos ML.PREDICT para gerar previsões. Aqui vale uma observação importante. A query passada para ML.PREDICT precisa fornecer as mesmas colunas que foram usadas no treino, com os mesmos nomes e tipos. Se faltar alguma, a função retorna erro. Se vier alguma coluna a mais, ela é simplesmente ignorada.
Por isso é comum reutilizar a mesma query de treino dentro do ML.PREDICT, principalmente para validar o modelo no próprio dataset de origem. Quando o objetivo é predizer dados novos (uma linha digitada manualmente, dados que chegaram depois do treino, dados de outra tabela), basta montar uma query que produza as mesmas colunas no mesmo formato.
WITH model_data as (
SELECT
source_city,
destination_city,
departure_time,
arrival_time,
price
FROM `flights.flight_prices_v1`
WHERE price IS NOT NULL
)
,
predictions as (
SELECT
source_city,
destination_city,
departure_time,
arrival_time,
price as real_price,
predicted_price
FROM ML.PREDICT(
MODEL `flights.flight_price_linear_model_v1`,
(
SELECT
source_city,
destination_city,
departure_time,
arrival_time,
price
FROM model_data
)
)
)
SELECT
source_city,
destination_city,
departure_time,
arrival_time,
real_price,
ROUND(predicted_price, 2) as predicted_price,
ROUND(real_price - predicted_price, 2) as price_prediction_error,
ROUND(ABS(real_price - predicted_price), 2) as absolute_price_error
FROM predictions
ORDER BY absolute_price_error DESC
;A coluna predicted_price é gerada automaticamente. Em modelos de regressão o BigQuery sempre retorna a coluna com o prefixo predicted_ seguido do nome da label. Em modelos de classificação retorna predicted_<label> mais predicted_<label>_probs (probabilidades por classe).
Visualizando as previsões
Para ver o comportamento do modelo de forma mais clara, plotamos as previsões em um notebook Python conectado ao BigQuery.
from google.cloud import bigquery
import matplotlib.pyplot as plt
client = bigquery.Client()
df = client.query("""
SELECT
price as real_price,
predicted_price
FROM ML.PREDICT(
MODEL `flights.flight_price_linear_model_v1`,
(
SELECT
source_city, destination_city,
departure_time, arrival_time,
price
FROM `flights.flight_prices_v1`
WHERE price IS NOT NULL
)
)
""").to_dataframe()
plt.figure(figsize=(10, 6))
plt.scatter(df['real_price'], df['predicted_price'], alpha=0.3, s=10)
plt.plot([0, 130000], [0, 130000], 'r--', label='y = x (modelo perfeito)')
plt.xlabel('Preço real (INR)')
plt.ylabel('Preço previsto (INR)')
plt.title('Modelo v1: preço real vs preço previsto')
plt.legend()
plt.show()Em um modelo perfeito, todos os pontos cairiam sobre a linha vermelha tracejada. O que vemos é diferente. As previsões formam uma faixa horizontal entre 5.000 e 30.000 INR, independentemente do preço real. A inclinação efetiva da nuvem é praticamente zero, ou seja, mudar o preço real quase não muda a previsão. Por isso o R² ficou em 0,03.
Por que o modelo inicial é tão limitado
Antes de partir para um próximo modelo, vale entender com clareza o que travou o primeiro.
A primeira limitação é a ausência das variáveis que mais afetam o preço. O dataset traz colunas como class, duration, days_left e stops, e nenhuma delas foi usada. O modelo trabalhou apenas com rota e horário, que são informações secundárias para precificação de passagens.
A segunda é a falta de qualquer transformação não-linear. Mesmo que tivéssemos incluído days_left, a relação entre antecedência e preço não é uma reta. A regressão linear precisa que essa não-linearidade seja explicitada via transformações como logaritmo, quadrado ou bucketização.
A terceira é o uso ingênuo das variáveis categóricas. Sem one-hot encoding, mesmo categorias importantes seriam tratadas de forma incorreta.
O problema do modelo inicial não foi o algoritmo. Foi a base de features. Antes de redesenhar essa base, no entanto, precisamos olhar para os dados de verdade.
Análise exploratória dos dados
Antes de redesenharmos o modelo, fiz um estudo exploratório local da base com Python conectado ao BigQuery. O objetivo era responder de forma objetiva a três perguntas. Quais variáveis realmente movem o preço. Qual a forma das relações entre elas e o alvo. Onde estão as não-linearidades que a regressão linear precisa receber prontas.
Em vez de descrever o estudo apenas em texto, vale apresentar os dois gráficos que resumem visualmente os achados que vão guiar as features do segundo modelo. O primeiro mostra a distribuição do preço, separada por classe, em escala logarítmica:
O primeiro achado é que a distribuição de price é bimodal, com dois grupos claramente separados que correspondem às classes Economy e Business. A diferença entre as duas medianas (Economy 5.772 INR, Business 53.164 INR) é maior que a variação produzida por qualquer outra variável da base. Isso transforma class em um sinal não negociável para o modelo.
O segundo achado é que a relação entre days_left e o preço não é igual nos dois mercados. Na Economy, a correlação é forte e negativa. Na Business, a curva é praticamente plana depois do décimo dia. Esse comportamento diferente por classe é a justificativa direta para criarmos features de interação como log_days_left_x_business_class.
O gráfico abaixo mostra o segundo achado: o comportamento de days_left vs price separado por classe deixa claro que as duas curvas são muito diferentes.
O terceiro achado é que cidade de origem, cidade de destino, horário de partida e horário de chegada quase não movem a mediana de preço. Faz sentido que o linear_v1, que usou apenas essas variáveis fracas, tenha ficado com R² próximo de zero.
Get Manuel Ventura’s stories in your inbox
Join Medium for free to get updates from this writer.
A partir desses achados, montamos a base do segundo modelo com três princípios. Primeiro, incluir as variáveis estruturais que separam o mercado. Segundo, criar transformações não-lineares que a regressão linear consegue absorver via TRANSFORM. Terceiro, criar interações entre class e as variáveis numéricas.
Segundo modelo: regressão linear com features ricas
A construção da flight_prices_v2 adiciona variáveis estruturais (class, duration, stops, days_left), transformações não-lineares (log_days_left, duration_squared, days_left_squared), flags binárias (is_business_class, is_direct_flight, is_last_minute) e interações entre features-chave.
CREATE OR REPLACE TABLE `flights.flight_prices_v2`
AS
WITH source_data as (
SELECT
airline,
source_city,
departure_time,
stops,
arrival_time,
destination_city,
class,
SAFE_CAST(duration AS FLOAT64) as duration,
SAFE_CAST(days_left AS INT64) as days_left,
SAFE_CAST(price AS FLOAT64) as price
FROM `flights.data_flights`
WHERE airline IS NOT NULL
AND source_city IS NOT NULL
AND destination_city IS NOT NULL
AND class IS NOT NULL
AND stops IS NOT NULL
AND price IS NOT NULL
AND price > 0
AND duration IS NOT NULL
AND duration > 0
AND days_left IS NOT NULL
AND days_left > 0
)
,
base_features as (
SELECT
airline, source_city, destination_city, departure_time, arrival_time,
stops, class, duration, days_left, price,
CONCAT(source_city, '_', destination_city) as route,
CONCAT(airline, '_', class) as airline_class
FROM source_data
)
,
engineered_features as (
SELECT
airline, source_city, destination_city, departure_time, arrival_time,
stops, class, duration, days_left, route, airline_class, price,
LN(days_left + 1) as log_days_left,
POW(duration, 2) as duration_squared,
POW(days_left, 2) as days_left_squared,
CASE WHEN class = 'Business' THEN 1 ELSE 0 END as is_business_class,
CASE WHEN stops = 'zero' THEN 1 ELSE 0 END as is_direct_flight,
CASE WHEN days_left <= 7 THEN 1 ELSE 0 END as is_last_minute,
CASE
WHEN departure_time IN ('Early_Morning', 'Late_Night') THEN 1
ELSE 0
END as is_off_peak_departure
FROM base_features
)
SELECT
airline, source_city, destination_city, departure_time, arrival_time,
stops, class, route, airline_class,
duration, days_left, log_days_left, duration_squared, days_left_squared,
is_business_class, is_direct_flight, is_last_minute, is_off_peak_departure,
duration * is_business_class as duration_x_business_class,
log_days_left * is_business_class as log_days_left_x_business_class,
duration * is_direct_flight as duration_x_direct_flight,
is_last_minute * is_business_class as last_minute_x_business_class,
price
FROM engineered_features
;Treinando com TRANSFORM e hyperparameter tuning
CREATE OR REPLACE MODEL `flights.flight_price_linear_model_v2`
TRANSFORM(
ML.STANDARD_SCALER(duration) OVER() as duration_scaled,
ML.STANDARD_SCALER(days_left) OVER() as days_left_scaled,
ML.STANDARD_SCALER(log_days_left) OVER() as log_days_left_scaled,
ML.STANDARD_SCALER(duration_squared) OVER() as duration_squared_scaled,
ML.STANDARD_SCALER(days_left_squared) OVER() as days_left_squared_scaled,
ML.QUANTILE_BUCKETIZE(days_left, 5) OVER() as days_left_bucket,
ML.QUANTILE_BUCKETIZE(duration, 4) OVER() as duration_bucket,
ML.STANDARD_SCALER(duration_x_business_class) OVER() as duration_x_business_class_scaled,
ML.STANDARD_SCALER(log_days_left_x_business_class) OVER() as log_days_left_x_business_class_scaled,
ML.STANDARD_SCALER(duration_x_direct_flight) OVER() as duration_x_direct_flight_scaled,
ML.STANDARD_SCALER(last_minute_x_business_class) OVER() as last_minute_x_business_class_scaled,
is_business_class, is_direct_flight, is_last_minute, is_off_peak_departure,
ML.ONE_HOT_ENCODER(airline, 'most_frequent', 6, 1) OVER() as airline_encoded,
ML.ONE_HOT_ENCODER(source_city, 'most_frequent', 6, 1) OVER() as source_city_encoded,
ML.ONE_HOT_ENCODER(destination_city, 'most_frequent', 6, 1) OVER() as destination_city_encoded,
ML.ONE_HOT_ENCODER(departure_time, 'most_frequent', 6, 1) OVER() as departure_time_encoded,
ML.ONE_HOT_ENCODER(arrival_time, 'most_frequent', 6, 1) OVER() as arrival_time_encoded,
ML.ONE_HOT_ENCODER(stops, 'most_frequent', 3, 1) OVER() as stops_encoded,
ML.ONE_HOT_ENCODER(class, 'most_frequent', 2, 1) OVER() as class_encoded,
ML.ONE_HOT_ENCODER(route, 'most_frequent', 30, 1) OVER() as route_encoded,
ML.ONE_HOT_ENCODER(airline_class, 'most_frequent', 12, 1) OVER() as airline_class_encoded,
price
)
OPTIONS(
model_type = 'LINEAR_REG',
input_label_cols = ['price'],
data_split_method = 'RANDOM',
data_split_eval_fraction = 0.2,
enable_global_explain = TRUE,
num_trials = 20,
max_parallel_trials = 4,
hparam_tuning_objectives = ['R2_SCORE'],
l1_reg = HPARAM_CANDIDATES([0.0, 0.01, 0.1, 1.0]),
l2_reg = HPARAM_CANDIDATES([0.01, 0.1, 1.0, 10.0])
)
AS
SELECT
duration,
days_left,
log_days_left,
duration_squared,
days_left_squared,
duration_x_business_class,
log_days_left_x_business_class,
duration_x_direct_flight,
last_minute_x_business_class,
is_business_class,
is_direct_flight,
is_last_minute,
is_off_peak_departure,
airline,
source_city,
destination_city,
departure_time,
arrival_time,
stops,
class,
route,
airline_class,
price
FROM `flights.flight_prices_v2`
WHERE price IS NOT NULL
;A cláusula TRANSFORM salva o pré-processamento dentro do próprio modelo. Quando usarmos ML.PREDICT, o BigQuery reaplicará automaticamente as mesmas transformações aprendidas no treino. Sem TRANSFORM, teríamos que reescrever todas as transformações manualmente em cada query de predição.
ML.STANDARD_SCALER padroniza variáveis numéricas para média 0 e desvio padrão 1. ML.ONE_HOT_ENCODER transforma categóricas em colunas binárias indicadoras. ML.QUANTILE_BUCKETIZE divide variáveis contínuas em N buckets baseados em quantis, capturando efeitos não-lineares que a regressão sozinha não consegue representar.
Os parâmetros novos no OPTIONS introduzem capacidades importantes do BigQuery ML.
enable_global_explain = TRUE habilita a função ML.GLOBAL_EXPLAIN, que mostra a importância média de cada feature no modelo final. Sem essa flag, a função não funciona depois do treino.
l1_reg e l2_reg aplicam regularização L1 e L2 respectivamente. L1 tende a zerar coeficientes (seleção de features), L2 tende a apenas reduzi-los (estabilização). Combinar as duas é o que se chama de Elastic Net. Sem regularização, modelos lineares com muitas features correlacionadas podem produzir coeficientes instáveis.
num_trials = 20 ativa o hyperparameter tuning automático. O BigQuery treina 20 versões do modelo com combinações diferentes dos parâmetros marcados como HPARAM_CANDIDATES e seleciona a melhor.
max_parallel_trials = 4 define quantos trials rodam simultaneamente. Mais paralelismo termina mais rápido mas custa mais recursos.
hparam_tuning_objectives = [‘R2_SCORE’] define qual métrica o tuning vai otimizar. Aceita também MEAN_ABSOLUTE_ERROR, MEAN_SQUARED_ERROR, MEAN_SQUARED_LOG_ERROR, MEDIAN_ABSOLUTE_ERROR e EXPLAINED_VARIANCE para regressão. Para classificação seria ACCURACY, PRECISION, RECALL, F1_SCORE, LOG_LOSS ou ROC_AUC.
HPARAM_CANDIDATES([…]) define a lista discreta de valores para o tuning testar. Existe também HPARAM_RANGE (intervalo contínuo) para parâmetros numéricos.
Identificando o melhor trial com ML.TRIAL_INFO
Quando ativamos hyperparameter tuning, o BigQuery cria internamente vários modelos candidatos. ML.TRIAL_INFO lista todos eles com seus hiperparâmetros e métricas, e marca qual foi o melhor.
SELECT
trial_id,
hyperparameters,
hparam_tuning_evaluation_metrics,
is_optimal
FROM ML.TRIAL_INFO(MODEL `flights.flight_price_linear_model_v2`)
ORDER BY is_optimal DESC, trial_id
;A coluna hyperparameters retorna um STRUCT com os valores usados naquele trial. hparam_tuning_evaluation_metrics traz as métricas que cada um obteve. Filtrando por is_optimal = TRUE, isolamos o trial vencedor.
WITH best_trial as (
SELECT trial_id
FROM ML.TRIAL_INFO(MODEL `flights.flight_price_linear_model_v2`)
WHERE is_optimal = TRUE
)
,
model_evaluation as (
SELECT
ev.trial_id,
ev.mean_absolute_error,
ev.mean_squared_error,
SQRT(ev.mean_squared_error) as root_mean_squared_error,
ev.mean_squared_log_error,
ev.median_absolute_error,
ev.r2_score,
ev.explained_variance
FROM ML.EVALUATE(MODEL `flights.flight_price_linear_model_v2`) ev
JOIN best_trial bt ON ev.trial_id = bt.trial_id
)
SELECT
'mean_absolute_error' as metric,
ROUND(mean_absolute_error, 2) as value,
CASE
WHEN mean_absolute_error <= 3000 THEN 'very_good'
WHEN mean_absolute_error <= 6000 THEN 'good'
WHEN mean_absolute_error <= 10000 THEN 'regular'
ELSE 'bad'
END as status
FROM model_evaluation
UNION ALL
SELECT
'mean_squared_error' as metric,
ROUND(mean_squared_error, 2) as value,
'-' as status
FROM model_evaluation
UNION ALL
SELECT
'root_mean_squared_error' as metric,
ROUND(root_mean_squared_error, 2) as value,
CASE
WHEN root_mean_squared_error <= 4000 THEN 'very_good'
WHEN root_mean_squared_error <= 8000 THEN 'good'
WHEN root_mean_squared_error <= 13000 THEN 'regular'
ELSE 'bad'
END as status
FROM model_evaluation
UNION ALL
SELECT
'mean_squared_log_error' as metric,
ROUND(mean_squared_log_error, 4) as value,
'-' as status
FROM model_evaluation
UNION ALL
SELECT
'median_absolute_error' as metric,
ROUND(median_absolute_error, 2) as value,
'-' as status
FROM model_evaluation
UNION ALL
SELECT
'r2_score' as metric,
ROUND(r2_score, 4) as value,
CASE
WHEN r2_score >= 0.85 THEN 'very_good'
WHEN r2_score >= 0.65 THEN 'good'
WHEN r2_score >= 0.30 THEN 'regular'
ELSE 'bad'
END as status
FROM model_evaluation
UNION ALL
SELECT
'explained_variance' as metric,
ROUND(explained_variance, 4) as value,
CASE
WHEN explained_variance >= 0.85 THEN 'very_good'
WHEN explained_variance >= 0.65 THEN 'good'
WHEN explained_variance >= 0.30 THEN 'regular'
ELSE 'bad'
END as status
FROM model_evaluation
;EVALUATION do segundo modelo
A diferença em relação ao primeiro modelo é gritante. O MAE caiu de 19.329,86 para 4.340,8 INR. O R² saltou de 0,0298 para 0,9205. Isso significa que o segundo modelo explica cerca de 92% da variância do preço, contra praticamente nada do primeiro. O salto não veio de uma mudança de algoritmo. Continuamos usando regressão linear. Veio inteiramente da inclusão das features estruturais e suas transformações.
Inspecionando o modelo com ML.WEIGHTS
Como estamos usando regressão linear, podemos olhar diretamente os coeficientes aprendidos. Essa interpretabilidade é uma das grandes vantagens dos modelos lineares.
SELECT
processed_input,
weight,
category_weights
FROM ML.WEIGHTS(
MODEL `flights.flight_price_linear_model_v2`,
STRUCT(TRUE AS standardize)
)
ORDER BY COALESCE(ABS(weight), 0) DESC
;Com STRUCT(TRUE AS standardize), os pesos são padronizados, o que torna a comparação entre magnitudes mais justa. Para features contínuas, o coeficiente vem na coluna weight. Para features categóricas (one-hot), os coeficientes vêm em category_weights como um array, com um peso por categoria.
ML.WEIGHTS está disponível para LINEAR_REG, LOGISTIC_REG e MATRIX_FACTORIZATION. Para modelos de árvore (RF, Boosted Tree), o conceito de “peso” não existe da mesma forma; você usa ML.FEATURE_IMPORTANCE no lugar.
Inspecionando com ML.FEATURE_INFO e ML.TRANSFORM
ML.FEATURE_INFO retorna estatísticas das colunas de entrada (min, max, mean, median, stddev, null_count). É útil para auditar a base original logo depois do treino.
SELECT
*
FROM ML.FEATURE_INFO(MODEL `flights.flight_price_linear_model_v2`)
ORDER BY input
;ML.TRANSFORM aplica as transformações salvas pelo TRANSFORM em qualquer query, sem fazer predição. Permite ver exatamente o que o modelo "enxerga" depois do pré-processamento.
SELECT
*
FROM ML.TRANSFORM(
MODEL `flights.flight_price_linear_model_v2`,
(
SELECT
*
FROM `flights.flight_prices_v2`
LIMIT 10
)
)
;Terceiro modelo: Random Forest
Antes de aceitar 92% de R² como teto, vale comparar com modelos baseados em árvores. Eles capturam não-linearidades sem precisarmos engenheirar features manualmente.
CREATE OR REPLACE MODEL `flights.flight_price_random_forest_v3`
TRANSFORM(
duration,
days_left,
is_business_class,
is_direct_flight,
is_last_minute,
is_off_peak_departure,
airline,
source_city,
destination_city,
departure_time,
arrival_time,
stops,
class,
route,
airline_class,
price
)
OPTIONS(
model_type = 'RANDOM_FOREST_REGRESSOR',
input_label_cols = ['price'],
data_split_method = 'RANDOM',
data_split_eval_fraction = 0.2,
num_parallel_tree = 100,
max_tree_depth = 12,
subsample = 0.85,
enable_global_explain = TRUE
)
AS
SELECT
duration,
days_left,
is_business_class,
is_direct_flight,
is_last_minute,
is_off_peak_departure,
airline,
source_city,
destination_city,
departure_time,
arrival_time,
stops,
class,
route,
airline_class,
price
FROM `flights.flight_prices_v2`
WHERE price IS NOT NULL
;Note que removemos as features de transformação não-linear (log_days_left, duration_squared, bucketizações) e as interações. Modelos baseados em árvores capturam essas relações sozinhos via splits sucessivos. Manter as features manuais não ajudaria, e em alguns casos pode atrapalhar criando ruído.
Os parâmetros específicos de RANDOM_FOREST_REGRESSOR:
num_parallel_tree define quantas árvores compõem o ensemble. Mais árvores reduzem a variância da previsão final, mas aumentam tempo de treino e memória. 100 é um valor razoável para começar.
max_tree_depth limita a profundidade de cada árvore individual. Árvores muito profundas decoram o treino e levam a overfitting. 12 é um valor moderado para 300k linhas.
subsample define qual fração dos dados cada árvore vê. Com 0.85, cada árvore é treinada em 85% das linhas (sorteadas com reposição). Isso introduz diversidade entre as árvores, que é a essência do bagging.
Outros parâmetros disponíveis incluem min_split_loss (ganho mínimo para um split), min_tree_child_weight (mínimo de exemplos por folha) e colsample_bytree (fração de colunas amostradas por árvore, equivalente ao Random Subspace).
O que muda nas funções de inspeção para Random Forest
ML.EVALUATE retorna as mesmas seis métricas de regressão (a função é agnóstica ao algoritmo dentro da mesma família).
ML.PREDICT funciona igual, retorna predicted_price.
ML.WEIGHTS não funciona para Random Forest. No lugar, usamos ML.FEATURE_IMPORTANCE:
SELECT
*
FROM ML.FEATURE_IMPORTANCE(MODEL `flights.flight_price_random_forest_v3`)
ORDER BY importance_gain DESC
;A função retorna três métricas de importância por feature: importance_weight (frequência de uso da feature em splits), importance_gain (ganho médio de cada split que usa a feature) e importance_cover (quantos exemplos passam por splits que usam a feature). Essas métricas estão disponíveis para RANDOM_FOREST_REGRESSOR, BOOSTED_TREE_REGRESSOR e suas versões de classificação.
ML.TRAINING_INFO não funciona para Random Forest. Como cada árvore é treinada independentemente sem iteração sequencial, não há “loss por iteração” para registrar.
ML.TRANSFORM e ML.FEATURE_INFO continuam funcionando normalmente.
Quarto modelo: Boosted Tree
Boosted Tree também é um ensemble de árvores, mas usa uma estratégia diferente. Em vez de treinar todas as árvores em paralelo e fazer média, ele treina sequencialmente. Cada nova árvore é treinada para corrigir os erros (resíduos) da anterior. O resultado costuma ser mais preciso que Random Forest, mas o custo computacional é maior e o modelo é mais sensível a hiperparâmetros.
CREATE OR REPLACE MODEL `flights.flight_price_boosted_tree_v4`
TRANSFORM(
duration,
days_left,
is_business_class,
is_direct_flight,
is_last_minute,
is_off_peak_departure,
airline,
source_city,
destination_city,
departure_time,
arrival_time,
stops,
class,
route,
airline_class,
price
)
OPTIONS(
model_type = 'BOOSTED_TREE_REGRESSOR',
input_label_cols = ['price'],
data_split_method = 'RANDOM',
data_split_eval_fraction = 0.2,
max_tree_depth = 5,
max_iterations = 50,
subsample = 0.85,
learn_rate = 0.1,
enable_global_explain = TRUE
)
AS
SELECT
duration,
days_left,
is_business_class,
is_direct_flight,
is_last_minute,
is_off_peak_departure,
airline,
source_city,
destination_city,
departure_time,
arrival_time,
stops,
class,
route,
airline_class,
price
FROM `flights.flight_prices_v2`
WHERE price IS NOT NULL
;Parâmetros específicos do boosting:
max_tree_depth = 5 mantém cada árvore rasa. Em boosting, profundidade alta tende a causar overfitting porque cada árvore foca em corrigir resíduos específicos. Valores entre 3 e 8 costumam ser o ideal.
learn_rate = 0.1 controla quanto cada árvore contribui para a previsão final. Valores menores treinam mais devagar mas costumam generalizar melhor. Existe também learn_rate_strategy = ‘CONSTANT’ ou ‘LINE_SEARCH’.
max_iterations = 50 define o número máximo de árvores. Em boosting, mais iterações nem sempre é melhor: depois de certo ponto, o modelo começa a overfit.
subsample = 0.85 amostra 85% das linhas em cada iteração, controlando overfitting via stochastic gradient boosting.
Outros parâmetros úteis incluem early_stop = TRUE (parar cedo se a métrica não melhorar por N iterações), min_rel_progress = 0.001 (limiar de melhora considerada) e booster_type = ‘GBTREE’ ou ‘DART’ (variação que aplica dropout entre árvores).
Para Boosted Tree, ML.TRAINING_INFO volta a funcionar, porque o treinamento é iterativo. Vale rodar para ver a curva de loss e verificar se o número de iterações foi suficiente ou excessivo.
SELECT
iteration,
loss,
eval_loss
FROM ML.TRAINING_INFO(MODEL `flights.flight_price_boosted_tree_v4`)
ORDER BY iteration
;ML.FEATURE_IMPORTANCE também funciona, igual ao Random Forest.
Comparação final entre os quatro modelos
Para comparar de forma justa, precisamos olhar tanto o conjunto de treino quanto o de teste. A diferença entre os dois é o que mostra se o modelo está apenas decorando a base ou se realmente aprendeu padrões generalizáveis.
ML.EVALUATE por padrão usa o conjunto de avaliação. Para obter as métricas de treino, podemos usar ML.PREDICT na base completa e calcular as métricas manualmente. Como exemplo, a query abaixo gera as métricas de treino para o random_forest.
WITH train_predictions as (
SELECT
price as real_price,
predicted_price
FROM ML.PREDICT(
MODEL `flights.flight_price_random_forest_v3`,
(
SELECT
*
FROM `flights.flight_prices_v2`
WHERE price IS NOT NULL
)
)
)
SELECT
'train' as split,
'random_forest' as model,
ROUND(AVG(ABS(real_price - predicted_price)), 2) as mae,
ROUND(SQRT(AVG(POW(real_price - predicted_price, 2))), 2) as rmse,
ROUND(1 - SAFE_DIVIDE(
SUM(POW(real_price - predicted_price, 2)),
SUM(POW(real_price - (SELECT AVG(real_price) FROM train_predictions), 2))
), 4) as r2_score
FROM train_predictions
;Repetimos a mesma lógica para cada um dos quatro modelos, em ambos os splits (treino e teste). Para consolidar tudo em uma única tabela, podemos juntar os resultados via UNION ALL.
WITH linear_v1_train as (
SELECT
'linear_v1' as model,
'train' as split,
price as real_price,
predicted_price
FROM ML.PREDICT(
MODEL `flights.flight_price_linear_model_v1`,
(SELECT * FROM `flights.flight_prices_v1` WHERE price IS NOT NULL)
)
)
,
linear_v2_train as (
SELECT
'linear_v2' as model,
'train' as split,
price as real_price,
predicted_price
FROM ML.PREDICT(
MODEL `flights.flight_price_linear_model_v2`,
(SELECT * FROM `flights.flight_prices_v2` WHERE price IS NOT NULL)
)
)
,
random_forest_train as (
SELECT
'random_forest' as model,
'train' as split,
price as real_price,
predicted_price
FROM ML.PREDICT(
MODEL `flights.flight_price_random_forest_v3`,
(SELECT * FROM `flights.flight_prices_v2` WHERE price IS NOT NULL)
)
)
,
boosted_tree_train as (
SELECT
'boosted_tree' as model,
'train' as split,
price as real_price,
predicted_price
FROM ML.PREDICT(
MODEL `flights.flight_price_boosted_tree_v4`,
(SELECT * FROM `flights.flight_prices_v2` WHERE price IS NOT NULL)
)
)
,
all_train as (
SELECT * FROM linear_v1_train UNION ALL
SELECT * FROM linear_v2_train UNION ALL
SELECT * FROM random_forest_train UNION ALL
SELECT * FROM boosted_tree_train
)
,
model_means as (
SELECT
model,
AVG(real_price) as mean_real_price
FROM all_train
GROUP BY model
)
,
train_metrics as (
SELECT
all_train.model,
all_train.split,
ROUND(AVG(ABS(all_train.real_price - all_train.predicted_price)), 2) as mae,
ROUND(SQRT(AVG(POW(all_train.real_price - all_train.predicted_price, 2))), 2) as rmse,
ROUND(
1 - SAFE_DIVIDE(
SUM(POW(all_train.real_price - all_train.predicted_price, 2)),
SUM(POW(all_train.real_price - model_means.mean_real_price, 2))
),
4) as r2_score
FROM all_train
JOIN model_means
ON all_train.model = model_means.model
GROUP BY all_train.model, all_train.split
)
,
test_metrics as (
SELECT
'linear_v1' as model,
'test' as split,
ROUND(mean_absolute_error, 2) as mae,
ROUND(SQRT(mean_squared_error), 2) as rmse,
ROUND(r2_score, 4) as r2_score
FROM ML.EVALUATE(MODEL `flights.flight_price_linear_model_v1`)
UNION ALL
SELECT
'linear_v2',
'test',
ROUND(ev.mean_absolute_error, 2),
ROUND(SQRT(ev.mean_squared_error), 2),
ROUND(ev.r2_score, 4)
FROM ML.EVALUATE(MODEL `flights.flight_price_linear_model_v2`) ev
JOIN (
SELECT
trial_id
FROM ML.TRIAL_INFO(MODEL `flights.flight_price_linear_model_v2`)
WHERE is_optimal = TRUE
) bt
ON ev.trial_id = bt.trial_id
UNION ALL
SELECT
'random_forest',
'test',
ROUND(mean_absolute_error, 2),
ROUND(SQRT(mean_squared_error), 2),
ROUND(r2_score, 4)
FROM ML.EVALUATE(MODEL `flights.flight_price_random_forest_v3`)
UNION ALL
SELECT
'boosted_tree',
'test',
ROUND(mean_absolute_error, 2),
ROUND(SQRT(mean_squared_error), 2),
ROUND(r2_score, 4)
FROM ML.EVALUATE(MODEL `flights.flight_price_boosted_tree_v4`)
)
SELECT
model,
split,
mae,
rmse,
r2_score
FROM (
SELECT * FROM train_metrics
UNION ALL
SELECT * FROM test_metrics
)
ORDER BY model, split
;Análise dos resultados
A primeira coisa que salta aos olhos é que nenhum dos quatro modelos apresenta overfitting. A diferença entre R² de treino e teste é praticamente nula em todos eles, sempre na quarta casa decimal. Isso é um bom sinal. Significa que o problema não é decorar a base, é entender a estrutura dela.
A segunda observação é a evolução clara da performance. Saímos de R² 0,03 com a regressão linear simples para 0,97 com Random Forest.
O linear_v1 ficou no chão porque usou apenas variáveis fracas. O linear_v2 capturou 92% da variância graças à inclusão das features estruturais e suas transformações. O boosted_tree ficou levemente acima da linear melhorada, em 0,93. O ganho foi modesto, indicando que para esse problema a engenharia de features manual já estava capturando quase tudo que árvores rasas conseguem capturar sozinhas.
O salto real veio com random_forest. R² de 0,97 e MAE de 2.302 INR no teste, praticamente metade do erro da regressão linear melhorada. Isso mostra que ainda existia espaço para extrair valor dos dados, mas exigia árvores mais profundas (12 níveis) e um ensemble grande (100 árvores) para chegar lá.
Conclusão
Começamos com um modelo de regressão linear que falhou em explicar o preço. Seguimos para um modelo de regressão linear bem engenheirado que explicou 92% da variância. Subimos a régua para Random Forest, que chegou a 97%. O ganho marginal entre cada degrau ficou cada vez menor, mas a evolução foi consistente.
O exercício deixa claro que existem três alavancas distintas para melhorar um modelo, e elas funcionam em ordem.
A primeira alavanca é a base de features. Foi o que separou o linear_v1 do linear_v2. Sem variáveis informativas, nenhum algoritmo, por mais sofisticado, vai produzir resultado.
A segunda alavanca é a representação do conhecimento, ou seja, como apresentamos as features ao modelo. Foi o que consolidou o linear_v2. Saber que days_left afeta Economy e não afeta Business permitiu construir interações específicas. Saber que a relação não é linear permitiu adicionar logaritmos, quadrados e bucketizações via ML.QUANTILE_BUCKETIZE.
A terceira alavanca é o algoritmo. É a última a ser explorada, e foi o que separou linear_v2 de random_forest. Quando as duas primeiras alavancas estão maximizadas, mudar de algoritmo permite capturar nuances que a regressão linear não alcança.
A escolha de qual alavanca usar depende do contexto. Se o objetivo é interpretabilidade total, a regressão linear bem engenheirada continua imbatível. Você consegue ler os coeficientes via ML.WEIGHTS e explicar como cada variável afeta o preço. Se o objetivo é máxima precisão, Random Forest e Boosted Tree entregam mais, ao custo de perder a leitura direta dos coeficientes (sobra apenas ML.FEATURE_IMPORTANCE, que é menos granular).
O BigQuery ML cobre todo esse espectro de modelagem dentro de SQL, sem precisar exportar dados para outro ambiente. Treinamento, avaliação, predição, inspeção de pesos, importância de features e diagnóstico de treino estão todos disponíveis como funções nativas. A diferença prática entre os modelos não é só de algoritmo, é também do conjunto de funções de inspeção que cada um habilita.
Referências
Documentação oficial do BigQuery ML:
https://docs.cloud.google.com/bigquery/docs/bqml-introductionCREATE MODEL para modelos lineares no BigQuery ML:
https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-create-glmFunção ML.EVALUATE:
https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-evaluateFunção ML.PREDICT:
https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-predictFunção ML.TRAINING_INFO:
https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-trainingFunção ML.WEIGHTS:
https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-weightsFunção ML.FEATURE_IMPORTANCE:
https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-importanceFunção ML.TRIAL_INFO:
https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-trial-infoDataset utilizado no Kaggle: Flight Price Prediction
https://www.kaggle.com/datasets/shubhambathwal/flight-price-prediction




