Skip to main content

Module 5 — Hyperparameter tuning at scale

Module 4 trained one model at fixed hyperparameters. That model is a starting point, not a candidate for production: n_estimators=400, max_depth=6 was a guess. This module hands the search over to Vertex AI Vizier, Google's Bayesian optimisation service, and runs dozens of trials in parallel — at a total cost lower than a bad grid search on a laptop.

Grid, random, Bayesian: what Vizier chooses for you

Three families of search dominate:

  • Grid search samples every combination of a discrete lattice. Interpretable and wasteful — most of the lattice is far from the optimum.
  • Random search samples independently at each trial. Surprisingly strong for two or three dimensions, and easy to parallelise, but blind: it does not use past trials to pick the next one.
  • Bayesian optimisation fits a probabilistic model of the objective and picks the next point where it expects the largest improvement. This is what Vizier does.

The gain is not academic. On a 5-dimension search over the XGBoost hyperparameters, Vizier typically reaches within 1 % of the best random-search result in a third of the trials — because it stops sampling regions that already look bad.

What "the metric reported by the code" means, exactly

Vizier does not read your model. It reads a metric name and value the training script prints on standard output, one line per trial, using the cloudml-hypertune reporter:

from hypertune import HyperTune

# ...
ap = average_precision_score(y_val, clf.predict_proba(X_val)[:, 1])

HyperTune().report_hyperparameter_tuning_metric(
hyperparameter_metric_tag="aucpr",
metric_value=float(ap),
global_step=args.n_estimators,
)

The tag (aucpr) is the string Vizier looks up in the tuning spec. Getting the tag wrong is a silent failure — the trial "succeeds" from Vertex's point of view but returns no metric, so Vizier treats every trial as equal and search collapses to random.

The metric must be measured on a held-out split, not on the training set. This module's task.py splits the parquet snapshot 80/10/10 by transaction_id hash and reports the metric on the validation split.

Defining the search space

The HyperparameterTuningJob needs a base training job (which one worker in one trial), a metric spec, a parameter spec and the trial budget:

from google.cloud import aiplatform
from google.cloud.aiplatform import hyperparameter_tuning as hpt

worker_pool_specs = [{
"machine_spec": {"machine_type": "n1-highmem-8"},
"replica_count": 1,
"container_spec": {
"image_uri": "europe-docker.pkg.dev/fraud-detection-dev/vertex/fraud-trainer:v3",
"args": [
"--training_data=gs://fraud-detection-dev-vertex-eu/training/exp-042/2026-06-*.parquet",
],
},
}]

tuning_job = aiplatform.HyperparameterTuningJob(
display_name="fraud-xgb-vizier-v1",
custom_job=aiplatform.CustomJob(
display_name="fraud-xgb-trial",
worker_pool_specs=worker_pool_specs,
staging_bucket="gs://fraud-detection-dev-vertex-eu",
),
metric_spec={"aucpr": "maximize"},
parameter_spec={
"n_estimators": hpt.IntegerParameterSpec(100, 1500, scale="log"),
"max_depth": hpt.IntegerParameterSpec(3, 12, scale="linear"),
"learning_rate": hpt.DoubleParameterSpec(0.01, 0.3, scale="log"),
"subsample": hpt.DoubleParameterSpec(0.6, 1.0, scale="linear"),
"colsample_bytree": hpt.DoubleParameterSpec(0.6, 1.0, scale="linear"),
},
max_trial_count=60,
parallel_trial_count=6,
search_algorithm=None, # None means Bayesian (Vizier)
)

tuning_job.run(service_account="vertex-training@fraud-detection-dev.iam.gserviceaccount.com")

Three deliberate choices in that block are worth naming.

scale="log" on n_estimators and learning_rate. For a parameter that spans decades, sampling on a linear scale wastes trials at the top of the range. Log-scale sampling puts equal density on 100, 300, 1000, 1500, which is what actually matters.

max_trial_count=60, parallel_trial_count=6. Sixty trials in ten waves of six is the sweet spot for Vizier: enough sequential feedback for the Bayesian model to steer, enough parallelism to finish overnight rather than in a week.

search_algorithm=None. In the SDK, None selects the default (Bayesian). Setting it to "RANDOM_SEARCH" or "GRID_SEARCH" is possible and useful as a baseline for benchmarking your search.

Parallel trials and the cost of parallelism

Six trials in parallel means six n1-highmem-8 VMs running at once. At $0.60 per hour each, a two-minute trial costs about $0.02 and the whole search costs roughly $1.20. That is not a typo — Vertex per-second billing plus a short training makes hyperparameter search on tabular data effectively free.

The trade-off with parallelism is feedback quality. Vizier chooses the next point using every completed trial's result. With 60 sequential trials the model uses 59 past results to pick trial 60; with 6 parallel trials it uses only 54. The rule of thumb: pick a parallelism between 4 and 8 for most workloads; go higher only when trials are long enough that sequential feedback wastes wall-clock.

Early stopping: cutting the obvious losers

Vizier supports early stopping, which halts a trial whose interim metric already looks hopeless. This works only when the training script reports the metric multiple times during training, not once at the end:

for round_ in range(0, args.n_estimators, 50):
clf.fit(X, y, xgb_model=clf if round_ else None,
iteration_range=(round_, round_ + 50))
ap = average_precision_score(y_val, clf.predict_proba(X_val)[:, 1])
HyperTune().report_hyperparameter_tuning_metric(
hyperparameter_metric_tag="aucpr",
metric_value=float(ap),
global_step=round_ + 50,
)

Then, on the tuning job, enable the built-in policy:

from google.cloud.aiplatform import hyperparameter_tuning as hpt

tuning_job = aiplatform.HyperparameterTuningJob(
# ...
trial_job_spec_early_stopping_spec=hpt.MedianAutomatedStoppingPolicy(),
)

The MedianAutomatedStoppingPolicy cancels a trial whose interim metric falls below the median of trials at the same step. On the fraud model, it typically kills 30 to 40 % of trials by their first checkpoint — cutting the total cost by the same fraction.

Reading the trials, not just the best one

The temptation is to grab the winning trial and move on. The value of a tuning run is in the shape of the results: which parameter matters, which range Vizier converged to, which trials were unusually good or bad.

job = aiplatform.HyperparameterTuningJob.get("projects/…/hyperparameterTuningJobs/…")

rows = []
for t in job.trials:
row = {"trial": t.id, "state": t.state.name,
"aucpr": next((m.value for m in t.final_measurement.metrics), None)}
row.update({p.parameter_id: p.value for p in t.parameters})
rows.append(row)

trials = pd.DataFrame(rows).sort_values("aucpr", ascending=False)

A quick trials.plot.scatter(x="learning_rate", y="aucpr") on log-x reveals whether the objective is flat over three decades (choose the fastest) or peaks sharply (the parameter is critical). This is worth 20 minutes; it saves an hour of the wrong retraining next time.

Pin the winning trial into the module 6 upload

Vizier gives you a best trial with its exact hyperparameters. Retrain one final model with those parameters on the full train+val set (no held-out fraction) before uploading to the registry. That last model, not any of the trial models, is the one module 6 registers and module 7 deploys.

In summary

  • Vizier is a Bayesian search: it reaches within 1 % of the best random-search result in roughly a third of the trials, at negligible cost thanks to Vertex per-second billing.
  • The training script reports its metric via cloudml-hypertune with a stable tag measured on a held-out split; a wrong tag is a silent failure.
  • Use log-scale for parameters that span decades, keep parallelism between 4 and 8 to preserve Vizier's feedback loop, and enable early stopping when the script reports interim metrics.
  • Do not read only the best trial — plot metric against each parameter to learn what matters — and retrain one final model on train+val with the winning hyperparameters before registering it.

Next module: taking that final model into the Model Registry, with versions, default aliases, attached evaluation and lineage back to the tuning job.