sklearn
MLPRegressor
optuna
hyperparameter tuning
machine learning

How to set hidden_layer_sizes in sklearn MLPRegressor using optuna trial

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

The hidden_layer_sizes parameter in scikit-learn's MLPRegressor defines the neural network architecture — the number of hidden layers and neurons per layer. Tuning this parameter with Optuna requires special handling because it is a tuple of variable length, not a simple scalar. This article shows how to use Optuna's trial suggestions to search over both the number of layers and the number of neurons per layer.

The Challenge

hidden_layer_sizes accepts a tuple like (100,) (one hidden layer with 100 neurons) or (64, 32, 16) (three hidden layers). Optuna's trial.suggest_int() returns a single value, so you need to build the tuple dynamically.

Basic Approach

python
1import optuna
2from sklearn.neural_network import MLPRegressor
3from sklearn.model_selection import cross_val_score
4from sklearn.datasets import make_regression
5
6X, y = make_regression(n_samples=1000, n_features=20, random_state=42)
7
8def objective(trial):
9    # Number of hidden layers (1 to 4)
10    n_layers = trial.suggest_int('n_layers', 1, 4)
11
12    # Neurons per layer
13    layers = []
14    for i in range(n_layers):
15        n_neurons = trial.suggest_int(f'n_neurons_layer_{i}', 16, 256)
16        layers.append(n_neurons)
17
18    hidden_layer_sizes = tuple(layers)
19
20    model = MLPRegressor(
21        hidden_layer_sizes=hidden_layer_sizes,
22        max_iter=500,
23        random_state=42,
24    )
25
26    scores = cross_val_score(model, X, y, cv=3, scoring='neg_mean_squared_error')
27    return scores.mean()
28
29study = optuna.create_study(direction='maximize')
30study.optimize(objective, n_trials=50)
31
32print(f"Best params: {study.best_params}")
33print(f"Best score: {study.best_value:.4f}")

Full Example with All Hyperparameters

Tune the architecture alongside learning rate, activation, and solver:

python
1import optuna
2from sklearn.neural_network import MLPRegressor
3from sklearn.model_selection import cross_val_score
4from sklearn.preprocessing import StandardScaler
5from sklearn.pipeline import Pipeline
6from sklearn.datasets import make_regression
7
8X, y = make_regression(n_samples=1000, n_features=20, noise=10, random_state=42)
9
10def objective(trial):
11    # Architecture
12    n_layers = trial.suggest_int('n_layers', 1, 3)
13    layers = tuple(
14        trial.suggest_int(f'n_neurons_{i}', 32, 256, log=True)
15        for i in range(n_layers)
16    )
17
18    # Other hyperparameters
19    activation = trial.suggest_categorical('activation', ['relu', 'tanh', 'logistic'])
20    solver = trial.suggest_categorical('solver', ['adam', 'sgd', 'lbfgs'])
21    alpha = trial.suggest_float('alpha', 1e-5, 1e-1, log=True)
22    learning_rate_init = trial.suggest_float('learning_rate_init', 1e-4, 1e-1, log=True)
23
24    model = Pipeline([
25        ('scaler', StandardScaler()),
26        ('mlp', MLPRegressor(
27            hidden_layer_sizes=layers,
28            activation=activation,
29            solver=solver,
30            alpha=alpha,
31            learning_rate_init=learning_rate_init,
32            max_iter=1000,
33            early_stopping=True,
34            random_state=42,
35        ))
36    ])
37
38    scores = cross_val_score(model, X, y, cv=5, scoring='neg_mean_squared_error')
39    return scores.mean()
40
41study = optuna.create_study(direction='maximize')
42study.optimize(objective, n_trials=100, show_progress_bar=True)
43
44# Print results
45print(f"\nBest score: {study.best_value:.4f}")
46for key, value in study.best_params.items():
47    print(f"  {key}: {value}")
48
49# Reconstruct the best architecture
50best = study.best_params
51n_layers = best['n_layers']
52best_architecture = tuple(best[f'n_neurons_{i}'] for i in range(n_layers))
53print(f"\nBest architecture: {best_architecture}")

Using Logarithmic Sampling

For neuron counts, logarithmic sampling often works better because the difference between 32 and 64 neurons is more significant than between 224 and 256:

python
1# Linear sampling (uniform between 16 and 256)
2n_neurons = trial.suggest_int(f'n_neurons_{i}', 16, 256)
3
4# Logarithmic sampling (favors powers of 2 range)
5n_neurons = trial.suggest_int(f'n_neurons_{i}', 16, 256, log=True)

Constraining Architecture Shape

You may want architectures that taper (shrink from input to output) or maintain width:

Tapering Network

python
1def objective(trial):
2    n_layers = trial.suggest_int('n_layers', 1, 4)
3    max_neurons = trial.suggest_int('max_neurons', 64, 512, log=True)
4
5    # Each layer is smaller than the previous
6    layers = []
7    for i in range(n_layers):
8        neurons = max(16, max_neurons // (2 ** i))
9        layers.append(neurons)
10
11    model = MLPRegressor(hidden_layer_sizes=tuple(layers), max_iter=500)
12    return cross_val_score(model, X, y, cv=3, scoring='neg_mean_squared_error').mean()

Fixed-Width Network

python
1def objective(trial):
2    n_layers = trial.suggest_int('n_layers', 1, 4)
3    n_neurons = trial.suggest_int('n_neurons', 32, 256, log=True)
4
5    # All layers have the same width
6    layers = tuple([n_neurons] * n_layers)
7
8    model = MLPRegressor(hidden_layer_sizes=layers, max_iter=500)
9    return cross_val_score(model, X, y, cv=3, scoring='neg_mean_squared_error').mean()

Optuna Features for Better Results

Pruning Unpromising Trials

python
1from optuna.integration import OptunaSearchCV
2
3# Or use pruning with manual callback
4study = optuna.create_study(
5    direction='maximize',
6    pruner=optuna.pruners.MedianPruner(n_startup_trials=10)
7)

Saving and Resuming Studies

python
1# Save to SQLite
2study = optuna.create_study(
3    study_name='mlp-tuning',
4    storage='sqlite:///optuna_study.db',
5    direction='maximize',
6    load_if_exists=True
7)

Common Pitfalls

  • Conditional parameters: When n_layers=2, parameters n_neurons_2 and n_neurons_3 are not used. Optuna handles this via conditional sampling, but be aware that the best_params dictionary will contain entries for all suggested parameters across all trials. Reconstruct the architecture using n_layers.
  • Scaling features: MLPRegressor is sensitive to feature scaling. Always use StandardScaler or MinMaxScaler in a pipeline, otherwise hyperparameter tuning results will be unreliable.
  • max_iter convergence: Low max_iter causes convergence warnings that make tuning unreliable. Set it high enough (500-1000) or enable early_stopping=True.
  • Reproducibility: Set random_state on both the model and the study sampler for reproducible results.
  • Efficient Sampling: Optuna uses Tree-structured Parzen Estimator (TPE) by default, which is effective for this type of mixed search space. Avoid switching to random sampling unless debugging.

Summary

  • Build hidden_layer_sizes as a tuple by suggesting n_layers first, then n_neurons per layer
  • Use log=True for neuron count suggestions to better explore the search space
  • Always scale features with StandardScaler in a pipeline
  • Reconstruct the best architecture from study.best_params using the n_layers value
  • Enable early_stopping=True and set sufficient max_iter to avoid convergence issues

Course illustration
Course illustration

All Rights Reserved.