A beginner-friendly random-seed audit for a scikit-learn neural network, using fixed data splits, repeated fits, score distributions, row-level probability ranges, and an honest ensemble comparison.
The same neural-network settings produced sharply different validation scores in this lesson. Seed 19 reached 0.505 balanced accuracy, while seed 11 reached 0.608, even though both runs used the same rows, features, and settings. A report containing only seed 11 would hide that variation.
To test neural network stability across random seeds, freeze the data split and preprocessing, fit a fresh copy of the same model configuration with each of several explicit random_state values, and record both metric variation and per-row probability variation. Report the minimum, mean, maximum, and standard deviation across runs; do not treat the seed with the highest score as a model setting.
We will apply that procedure to a fictional data-import review system. The model estimates whether a new CSV import needs manual review. This is a controlled teaching example, not an automated rule for accepting real data.
You need Python 3.11 or newer. You should know basic Python variables and pandas tables. You also need to know that a classifier assigns a row to a category. If model fitting and test data are new ideas, begin with How to Build Your First Machine Learning Model.
Create and activate a virtual environment, then install the four packages used by the executed build:
python -m venv .venv
source .venv/bin/activate
python -m pip install numpy pandas scikit-learn matplotlib
On Windows PowerShell, activate it with .venv\Scripts\Activate.ps1. The published run used Python 3.13.2, NumPy 2.5.1, pandas 3.0.3, scikit-learn 1.9.0, and Matplotlib 3.11.0.
Download data_import_batches.csv and save it beside your Python file or notebook. This original synthetic dataset was generated with NumPy seed 20260722 and is released under CC0-1.0. Synthetic means code created the rows. All batch IDs, quality measurements, and review labels are fictional.
Load the file and inspect three rows before modeling. This small check reveals the column names, units, table size, and class counts:
import pandas as pd
imports = pd.read_csv("data_import_batches.csv")
print(imports.shape)
print(imports.head(3).to_string(index=False))
print(imports["needs_manual_review"].value_counts().sort_index())(1200, 10)
batch_id rows_received columns_detected missing_cell_pct duplicate_row_pct parse_error_pct mixed_type_columns encoding_warnings source_age_days needs_manual_review
IMPORT-0001 170058 37 6.89 3.60 0.02 1 0 104 0
IMPORT-0002 468162 65 1.30 0.09 0.39 3 0 28 0
IMPORT-0003 92008 15 4.06 0.82 0.57 0 1 162 0
needs_manual_review
0 832
1 368There are 1,200 batches. Eight numeric feature columns describe file size, shape, missing cells, duplicates, parsing problems, mixed data types, encoding warnings, and source age. The target is needs_manual_review: 1 means review and 0 means no review. About 31% of rows have label 1, so plain accuracy could give too much influence to the larger class.
A random seed is an integer that starts a repeatable sequence of pseudorandom choices. An algorithm produces these choices. They look random, but the same code, data, software versions, and integer can reproduce the sequence.
Neural-network training needs such choices. The starting weights are initialized before learning. The Adam solver also trains with small shuffled batches rather than the complete table at once. With early stopping enabled, scikit-learn reserves an internal validation sample from the supplied training rows.
The current MLPClassifier documentation states that random_state controls weight and bias initialization, the early-stopping split, and batch sampling for Adam or stochastic gradient descent. Changing the integer can therefore lead training through a different path.
This does not mean the model is defective. One seeded fit is one possible outcome of the training procedure. A useful stability audit separates two kinds of variation:
fixed in this audit changed in this audit
------------------- ---------------------
dataset rows model random_state
train/validation/test split
feature scaling
network shape and training settings
metric definitionsIf you change the split and model seed together, you cannot tell whether a score changed because of different data or different training choices. Both questions matter, but they need separate experiments. This lesson measures training variation first.
An integer seed gives repeatability. It does not prove stability. A model can reproduce one unusually strong or weak outcome perfectly.
Use one fixed, stratified split for all twelve runs. Stratified means each part keeps approximately the same class proportions. We will assign 40% of the data to training, 30% to validation, and 30% to final testing:
from sklearn.model_selection import train_test_split
feature_columns = [
"rows_received",
"columns_detected",
"missing_cell_pct",
"duplicate_row_pct",
"parse_error_pct",
"mixed_type_columns",
"encoding_warnings",
"source_age_days",
]
X = imports[feature_columns]
y = imports["needs_manual_review"]
X_train, X_later, y_train, y_later = train_test_split(
X,
y,
train_size=0.40,
stratify=y,
random_state=41027,
)
X_validation, X_test, y_validation, y_test = train_test_split(
X_later,
y_later,
test_size=0.50,
stratify=y_later,
random_state=41027,
)
print(len(X_train), len(X_validation), len(X_test))480 360 360The 480 training rows update model weights. The 360 validation rows measure seed-to-seed variation. The 360 test rows remain unused until the audit procedure and comparison methods are fixed.
The small training set is intentional. It makes variation easy to observe in a quick CPU lesson. A larger representative dataset may reduce variation, but size alone does not guarantee stability.
The input columns use very different scales. rows_received can be in the hundreds of thousands, while parse_error_pct is usually a small number. StandardScaler centers each feature and scales its variation. A scikit-learn Pipeline fits the scaler only on training rows and applies the stored transformation during validation and testing.
The official pipeline guide recommends this structure because it keeps preprocessing with the estimator and helps prevent test statistics from entering training.
Define a function that accepts one seed. Every other setting stays constant:
from sklearn.neural_network import MLPClassifier
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
def make_model(seed):
return Pipeline([
("scale", StandardScaler()),
("network", MLPClassifier(
hidden_layer_sizes=(24, 12),
activation="relu",
solver="adam",
alpha=0.002,
batch_size=32,
learning_rate_init=0.002,
max_iter=350,
early_stopping=True,
validation_fraction=0.18,
n_iter_no_change=18,
random_state=seed,
)),
])The two hidden layers contain 24 and 12 units, also called neurons. alpha adds a penalty for large weights. Early stopping ends training after the internal validation score fails to improve enough for 18 epochs. These values are fixed lesson choices, not recommended settings for every dataset.
Notice that the internal early-stopping sample comes only from X_train. It is separate from our 360-row outer validation set. The outer set gives every completed run the same comparison data.
We need metrics that show different behavior. Balanced accuracy averages recall across both classes, giving manual-review and no-review rows equal weight. Higher is better. Log loss measures predicted probabilities and penalizes confident wrong answers. Lower is better.
The current scikit-learn references define balanced accuracy as average class recall and log loss as probability-based cross-entropy loss.
Fit one fresh pipeline per integer. Save the probability vector as well as the run-level metrics. Creating a new pipeline makes each run independent and prevents state from carrying over if you later enable options such as warm_start:
import numpy as np
from sklearn.metrics import balanced_accuracy_score, log_loss
seeds = [3, 7, 11, 19, 23, 31, 43, 47, 59, 61, 73, 89]
run_rows = []
validation_probabilities = []
models = []
for seed in seeds:
model = make_model(seed)
model.fit(X_train, y_train)
probability = model.predict_proba(X_validation)[:, 1]
prediction = (probability >= 0.5).astype(int)
run_rows.append({
"seed": seed,
"epochs": model.named_steps["network"].n_iter_,
"validation_balanced_accuracy": balanced_accuracy_score(
y_validation, prediction
),
"validation_log_loss": log_loss(y_validation, probability),
})
validation_probabilities.append(probability)
models.append(model)
runs = pd.DataFrame(run_rows)
print(runs.round(3).to_string(index=False)) seed epochs validation_balanced_accuracy validation_log_loss
3 46 0.575 0.589
7 24 0.549 0.602
11 30 0.608 0.582
19 22 0.505 0.604
23 31 0.516 0.596
31 41 0.570 0.572
43 45 0.590 0.592
47 36 0.606 0.559
59 32 0.582 0.569
61 24 0.505 0.609
73 27 0.554 0.581
89 28 0.570 0.573The complete executed table is available as seed_run_metrics.csv.
Now summarize balanced accuracy across runs. Use sample standard deviation, written as std(ddof=1), to describe how much the twelve observed scores vary around their mean:
score = runs["validation_balanced_accuracy"]
print(f"minimum: {score.min():.3f}")
print(f"mean: {score.mean():.3f}")
print(f"maximum: {score.max():.3f}")
print(f"std: {score.std(ddof=1):.3f}")minimum: 0.505
mean: 0.561
maximum: 0.608
std: 0.036The 0.103 gap between minimum and maximum is large relative to the mean. In this audit, the model configuration is sensitive to training randomness on the given 480 rows. The mean describes these twelve runs better than the maximum does, but it is not a promise for new data.
Do not tune random_state by keeping seed 11 because it has the highest balanced accuracy. A seed does not describe a meaningful property of future import batches. Choosing it after reading validation results turns random variation into hidden model selection.
An average metric can hide local disagreement. Stack the twelve probability arrays into a matrix. Each row of this matrix represents a model seed, and each column represents one validation batch:
probability_matrix = np.vstack(validation_probabilities)
label_matrix = (probability_matrix >= 0.5).astype(int)
changed_label = label_matrix.min(axis=0) != label_matrix.max(axis=0)
print(f"changed labels: {changed_label.sum()} / {len(y_validation)}")changed labels: 124 / 360For 124 validation batches, at least one model predicted manual review and at least one predicted no review. That is more operationally concrete than the score standard deviation: about one-third of the rows did not receive a consistent class across these twelve runs.
The next calculation finds rows with the widest probability range. It reports the minimum, mean, and maximum probability plus the number of models that crossed the 0.50 review cutoff:
details = pd.DataFrame({
"batch_id": imports.loc[X_validation.index, "batch_id"].to_numpy(),
"actual_review": y_validation.to_numpy(),
"minimum_probability": probability_matrix.min(axis=0),
"mean_probability": probability_matrix.mean(axis=0),
"maximum_probability": probability_matrix.max(axis=0),
"review_votes_out_of_12": label_matrix.sum(axis=0),
})
details["probability_range"] = (
details["maximum_probability"] - details["minimum_probability"]
)
shown = details.nlargest(4, "probability_range")
print(shown.drop(columns="probability_range").round(3).to_string(index=False)) batch_id actual_review minimum_probability mean_probability maximum_probability review_votes_out_of_12
IMPORT-0770 1 0.270 0.499 0.809 4
IMPORT-0367 1 0.196 0.440 0.719 5
IMPORT-0923 0 0.123 0.347 0.645 2
IMPORT-0702 0 0.286 0.519 0.790 7For IMPORT-0770, probabilities range from 0.270 to 0.809. Only 4 of 12 models cross the review cutoff, even though the mean probability is almost exactly 0.5. Reporting one seed would conceal this disagreement.
The generated chart combines the run-level and row-level views:
Read the left panel vertically: every dot is the same recipe with a different seed, and the orange line is the mean. In the right panel, each gray segment spans the smallest to largest probability assigned to one batch. Crossing the dashed 0.50 line means the final label depends on the seed.
You can inspect all eight displayed rows in seed_disagreement_examples.csv. These rows are candidates for error analysis. They may reveal limited training coverage, overlapping classes, weak features, noisy labels, or a decision cutoff that needs separate evaluation.
One way to combine repeated models is a probability ensemble: average their class-1 probabilities, then apply the decision cutoff once. We will compare that predefined method with seed 3, the first seed in the list. Neither choice is based on the validation winner.
Only now generate probabilities for the untouched test rows:
test_probability_matrix = np.vstack([
model.predict_proba(X_test)[:, 1] for model in models
])
seed_3_probability = test_probability_matrix[0]
ensemble_probability = test_probability_matrix.mean(axis=0)
seed_3_prediction = (seed_3_probability >= 0.5).astype(int)
ensemble_prediction = (ensemble_probability >= 0.5).astype(int)
print(
"seed 3 balanced accuracy:",
round(balanced_accuracy_score(y_test, seed_3_prediction), 3),
)
print(
"ensemble balanced accuracy:",
round(balanced_accuracy_score(y_test, ensemble_prediction), 3),
)
print("seed 3 log loss:", round(log_loss(y_test, seed_3_probability), 3))
print("ensemble log loss:", round(log_loss(y_test, ensemble_probability), 3))seed 3 balanced accuracy: 0.605
ensemble balanced accuracy: 0.573
seed 3 log loss: 0.583
ensemble log loss: 0.578The ensemble has slightly lower log loss, so it scored better on this test set under that metric. Its balanced accuracy is lower because averaging changes which probabilities sit above 0.50. This is why “use an ensemble” is not a complete conclusion. Evaluate the metric tied to the real decision, and do not assume averaging improves every measure.
These scores also do not make the synthetic classifier suitable for real import approval. A real system needs representative files, reviewed labels, subgroup checks, cost-based thresholds, monitoring, and a manual fallback.
Changing the data split for every model seed. This mixes sampling variation with training variation. Keep the outer split fixed for this audit. Run a separate repeated-split or cross-validation study when you want both sources of uncertainty.
Reporting only the best seed. The largest score is an extreme from the measured distribution. Report the seed list, all run metrics, and a summary. Treat the seed as an experimental control, not a hyperparameter to optimize.
Leaving random_state=None. Repeated fits may differ, but the exact experiment cannot be reproduced. Use an explicit list of integers and store it with the results. Scikit-learn’s randomness guidance explains the difference between integers, RandomState instances, and None.
Fitting the scaler before splitting. A scaler fitted on all rows learns validation and test statistics. Split first and keep StandardScaler inside the pipeline.
Reusing a warm-started model. With warm_start=True, a later call to fit() can reuse the previous solution instead of starting an independent run. Construct a fresh pipeline inside the loop.
Comparing rounded probabilities. Calculate labels and metrics from full-precision arrays. Round only the printed table. A displayed 0.500 may be slightly above or below the cutoff internally.
Measuring only a single score. Balanced accuracy describes class decisions, while log loss describes probability quality. Add row-level ranges to find cases where operational labels change.
Assuming a small standard deviation proves safety. Stable models can be consistently wrong because of biased data, leakage, label errors, or distribution shift. Stability is one diagnostic, not complete validation.
Expecting exact numbers with different versions. Integer seeds make the tested environment repeatable, but numerical libraries and implementation details can change. Record Python and package versions with serious experiments.
A fixed seed answers “Can I reproduce this run?” A seed audit answers the more useful question: “Does the training procedure behave similarly when its random choices change?”
For this synthetic import-review model, twelve runs produced validation balanced accuracy from 0.505 to 0.608, with a mean of 0.561 and a sample standard deviation of 0.036. Predicted labels differed across runs on 124 of 360 validation rows. The probability ensemble slightly reduced test log loss but also reduced test balanced accuracy, so it was not a universal improvement.
Keep this short audit for your next stochastic model:
The next useful experiment is a separate repeated-split audit. Keep the model-seed list fixed, vary the outer split seed, and compare variation between splits with variation between training runs. That shows whether the larger uncertainty comes from which rows were sampled or how the network learned from them.