Problem
When you need this. A retrained classifier reports a different accuracy. Before changing its algorithm, check whether it saw the same data, split and preprocessing. Those inputs let you distinguish a changed experiment from an improved model.
You will make a supervised baseline reproducible using the public UCI Rice dataset. The trainer is intentionally simple. The engineering work is to bind data identity, stable split membership, training-only preprocessing and a result record. This is an introductory tabular experiment. The following data and feature lessons add time and availability rules using a synthetic user-event log.
Concepts
Supervised learning fits a mapping from labeled examples: here, seven grain measurements are the input features and the rice variety is the label. Training rows update the model; validation rows support development choices; final test rows are reserved for the declared final evaluation. A baseline is a simple reference against which a more complex method can be compared.
Preprocessing learns quantities such as feature means and scales. Fitting them on validation or test rows leaks information into training. A content fingerprint is a digest computed from file bytes; this lab uses SHA-256. A stable row identity controls split membership. These identities serve different purposes: unchanged membership does not make an expanded dataset the same experiment.
Architecture
The loader checks the bundled dataset and row schema. Row identities determine partitions, training rows supply preprocessing statistics, and the trainer fits its classifier. Evaluation applies that frozen preprocessing and model to the selected split. The run record binds these stages so a metric is not detached from the data and settings that produced it.
Working code
Bind the experiment to a dataset version
The bundled CSV contains 3810 grains from the UCI Rice (Cammeo and Osmancik) dataset. Each row has seven measurements and one variety label. The data is licensed under CC BY 4.0; the lab README gives attribution and describes the CSV conversion.
load_rows() checks the file’s SHA-256 before parsing it. It then checks the header, field count, finite numeric features and known labels. A changed file is refused until you deliberately record a new data version. A hash identifies bytes; it does not prove that the measurements or labels are correct.
Download the complete lab and open build-a-supervised-learning-baseline-with-reproducible-dataset-splits/:
python3.12 -m venv .venv
source .venv/bin/activate
python baseline.py --out development-run.json
python -m unittest -v test_baseline.pyThe dataset is already included. No acquisition script, package installation, GPU or model API is needed. development-run.json holds the result of this local run. The regression suite also reproduces the previously recorded final evaluation; do not use its test score as feedback for choosing new settings.
Assign splits from row identity rather than position
A fixed hash salt and a row key determine training, validation or test membership. The key is the raw feature text in this CSV. Reversing the file moves rows to different positions but leaves those keys unchanged. Appending rows also leaves existing keys unchanged. The split diagnostic checks that rule separately from the loader: training on an expanded file still requires selecting its new dataset fingerprint.
The split intervals target 70%, 15% and 15% of possible hash values. They do not force the finite dataset to have exactly those proportions:
| Split | Cammeo | Osmancik | Total |
|---|---|---|---|
| training | 1156 | 1508 | 2664 |
| validation | 248 | 342 | 590 |
| test | 226 | 330 | 556 |
The implementation compares the 64-bit digest position with integer ratios. Converting it to a float first can round the largest possible position to 1.0 and leave it outside every interval. test_largest_digest_still_belongs_to_the_test_split supplies that endpoint and requires a valid assignment.
Exact textual duplicates get the same key, but equivalent numbers with different formatting can have different keys. Related rows from the same person, device or time period also need a group or temporal split. Stable membership solves reordering; it is not a general leakage detector.
Inspect the complete loader, split rule and trainer
"""A reproducible supervised-learning baseline for the rice dataset.
Run with Python 3.11+ and no packages:
python3 baseline.py # development run: validation metrics only
python3 baseline.py --final # final run: also scores the test split once
The data is the UCI "Rice (Cammeo and Osmancik)" dataset, CC BY 4.0
(https://doi.org/10.24432/C5MW4Z), converted from ARFF to rice.csv.
"""
import argparse
import hashlib
import json
import math
import random
import tempfile
from pathlib import Path
HERE = Path(__file__).resolve().parent
DATA = HERE / 'rice.csv'
DATA_SHA256 = 'ac5b63d257bab6bf013453f29b99285baa664038d1023c02033135f6e320eaec'
FEATURES = ['area', 'perimeter', 'major_axis_length', 'minor_axis_length',
'eccentricity', 'convex_area', 'extent']
LABELS = ['Cammeo', 'Osmancik']
POSITIVE = 'Cammeo'
SALT = 'rice-split-v1'
SPLITS = (('training', 0.70), ('validation', 0.85), ('test', 1.00))
SPLIT_RATIOS = {'training': (7, 10), 'validation': (17, 20), 'test': (1, 1)}
LEARNING_RATE = 0.5
EPOCHS = 200
def file_sha256(path):
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def load_rows(path=DATA, expected_sha256=DATA_SHA256):
"""Read and validate the CSV. Refuse a file that differs from the recorded version."""
if expected_sha256 is not None and file_sha256(path) != expected_sha256:
raise ValueError(f'{Path(path).name} does not match the recorded SHA-256; '
'record a new data version before using it')
lines = Path(path).read_text().splitlines()
if not lines or lines[0].split(',') != FEATURES + ['class']:
raise ValueError(f'unexpected header: {lines[0] if lines else "empty file"}')
rows = []
for number, line in enumerate(lines[1:], start=2):
cells = line.split(',')
if len(cells) != len(FEATURES) + 1:
raise ValueError(f'line {number}: expected {len(FEATURES) + 1} values, found {len(cells)}')
try:
values = [float(cell) for cell in cells[:-1]]
except ValueError:
raise ValueError(f'line {number}: a feature value is missing or not a number') from None
if not all(math.isfinite(v) for v in values):
raise ValueError(f'line {number}: a feature value is not finite')
if cells[-1] not in LABELS:
raise ValueError(f'line {number}: unknown class {cells[-1]!r}')
# The row key is the raw feature text, so it does not depend on file order.
rows.append({'key': ','.join(cells[:-1]), 'x': values, 'label': cells[-1]})
return rows
def assign_split(key, salt=SALT):
"""Map a row key to a split with SHA-256: stable across runs, machines and row order."""
digest = hashlib.sha256(f'{salt}:{key}'.encode()).digest()
position = int.from_bytes(digest[:8], 'big')
# Keep the 64-bit position as an integer: dividing the largest digest by
# 2**64 as a float rounds it to 1.0 and can leave a valid key unassigned.
for name, _ in SPLITS:
numerator, denominator = SPLIT_RATIOS[name]
if position * denominator < numerator * 2 ** 64:
return name
raise AssertionError('every 64-bit digest must belong to a split')
def split_rows(rows, salt=SALT):
parts = {name: [] for name, _ in SPLITS}
for row in rows:
parts[assign_split(row['key'], salt)].append(row)
for name, part in parts.items():
missing = [label for label in LABELS if not any(r['label'] == label for r in part)]
if missing:
raise ValueError(f'{name} split has no rows of class {", ".join(missing)}')
return parts
def content(row):
"""What a row is, independent of where it sits in the file or how its key is built."""
return (tuple(row['x']), row['label'])
def membership(parts):
return {content(row): name for name, part in parts.items() for row in part}
def shuffle_split(rows, seed=8):
"""The common alternative: shuffle with a seed, then cut by position. Used only for comparison."""
order = list(range(len(rows)))
random.Random(seed).shuffle(order)
cut_a, cut_b = round(0.70 * len(rows)), round(0.85 * len(rows))
parts = {name: [] for name, _ in SPLITS}
for position, index in enumerate(order):
parts['training' if position < cut_a else 'validation' if position < cut_b else 'test'].append(rows[index])
return parts
def reload(lines, folder, name):
"""Write a variant of the data file and read it back through the normal loader."""
path = Path(folder) / name
path.write_text(''.join(line + '\n' for line in lines))
return load_rows(path, expected_sha256=None)
def moved(before, after):
"""Rows present in both runs whose split differs, compared by row content."""
return sum(before[c] != after[c] for c in before if c in after)
def order_experiment(path=DATA, new_rows=100, sorted_by='area', other_salt='rice-split-v2'):
"""Reload reordered, sorted and appended copies of the file and count rows that change split."""
header, *body = Path(path).read_text().splitlines()
column = FEATURES.index(sorted_by)
# 'older' is the file before the last new_rows rows arrived; 'newest_first' lists those rows at the top.
variants = {'original': body, 'reversed': body[::-1], 'older': body[:-new_rows],
'newest_first': body[-new_rows:] + body[:-new_rows],
'sorted': sorted(body, key=lambda line: float(line.split(',')[column]))}
with tempfile.TemporaryDirectory() as folder:
loaded = {name: reload([header] + lines, folder, f'{name}.csv') for name, lines in variants.items()}
hashed = {name: membership(split_rows(rows)) for name, rows in loaded.items()}
shuffled = {name: membership(shuffle_split(rows)) for name, rows in loaded.items()}
resalted = membership(split_rows(loaded['original'], other_salt))
return {
'reversed_order': {'seeded_shuffle': moved(shuffled['original'], shuffled['reversed']),
'hash_split': moved(hashed['original'], hashed['reversed'])},
'sorted_by_' + sorted_by: {'seeded_shuffle': moved(shuffled['original'], shuffled['sorted']),
'hash_split': moved(hashed['original'], hashed['sorted'])},
'added_rows': {'new_rows': new_rows,
'appended_seeded_shuffle': moved(shuffled['older'], shuffled['original']),
'appended_hash_split': moved(hashed['older'], hashed['original']),
'listed_first_seeded_shuffle': moved(shuffled['older'], shuffled['newest_first']),
'listed_first_hash_split': moved(hashed['older'], hashed['newest_first'])},
'salt_changed': {'salt': other_salt, 'rows': len(loaded['original']),
'kept_split': len(loaded['original']) - moved(hashed['original'], resalted),
'expected_share_kept': round(0.70 ** 2 + 0.15 ** 2 + 0.15 ** 2, 4)},
}
def fit_scaler(rows):
"""Mean and standard deviation per feature, computed from the rows given (training only)."""
n = len(rows)
means = [math.fsum(r['x'][j] for r in rows) / n for j in range(len(FEATURES))]
stds = [math.sqrt(math.fsum((r['x'][j] - means[j]) ** 2 for r in rows) / n) or 1.0
for j in range(len(FEATURES))]
return {'mean': means, 'std': stds}
def scale(row, scaler):
return [(v - m) / s for v, m, s in zip(row['x'], scaler['mean'], scaler['std'])]
def sigmoid(z):
if z >= 0:
return 1.0 / (1.0 + math.exp(-z))
e = math.exp(z)
return e / (1.0 + e)
def train_logistic(rows, scaler, learning_rate=LEARNING_RATE, epochs=EPOCHS):
"""Full-batch gradient descent on the mean cross-entropy loss. Starts from zero weights."""
xs = [scale(r, scaler) for r in rows]
ys = [1.0 if r['label'] == POSITIVE else 0.0 for r in rows]
weights, bias, n, losses = [0.0] * len(FEATURES), 0.0, len(rows), []
for _ in range(epochs):
ps = [sigmoid(math.fsum(w * v for w, v in zip(weights, x)) + bias) for x in xs]
errors = [p - y for p, y in zip(ps, ys)]
grads = [math.fsum(e * x[j] for e, x in zip(errors, xs)) / n for j in range(len(weights))]
weights = [w - learning_rate * g for w, g in zip(weights, grads)]
bias -= learning_rate * math.fsum(errors) / n
losses.append(-math.fsum(math.log(max(p, 1e-12)) if y else math.log(max(1 - p, 1e-12))
for p, y in zip(ps, ys)) / n)
return {'weights': weights, 'bias': bias, 'losses': losses}
def predict_logistic(model, scaler, row):
z = math.fsum(w * v for w, v in zip(model['weights'], scale(row, scaler))) + model['bias']
return POSITIVE if z >= 0 else next(label for label in LABELS if label != POSITIVE)
def majority_label(rows):
counts = {label: sum(r['label'] == label for r in rows) for label in LABELS}
return max(LABELS, key=lambda label: (counts[label], label))
def evaluate(predictions, rows):
matrix = {actual: {predicted: 0 for predicted in LABELS} for actual in LABELS}
for predicted, row in zip(predictions, rows):
matrix[row['label']][predicted] += 1
correct = sum(matrix[label][label] for label in LABELS)
return {'rows': len(rows), 'correct': correct, 'accuracy': round(correct / len(rows), 4),
'recall': {label: round(matrix[label][label] / sum(matrix[label].values()), 4) for label in LABELS},
'confusion': matrix}
def run(final=False, path=DATA, salt=SALT):
rows = load_rows(path)
parts = split_rows(rows, salt)
training = parts['training']
scaler = fit_scaler(training)
model = train_logistic(training, scaler)
majority = majority_label(training)
record = {
'data': {'file': Path(path).name, 'sha256': file_sha256(path), 'rows': len(rows),
'source': 'UCI Rice (Cammeo and Osmancik), CC BY 4.0, doi:10.24432/C5MW4Z'},
'split': {'salt': salt, 'method': 'sha256(salt:raw feature text)',
'counts': {name: {label: sum(r['label'] == label for r in part) for label in LABELS}
for name, part in parts.items()}},
'settings': {'learning_rate': LEARNING_RATE, 'epochs': EPOCHS, 'positive_class': POSITIVE,
'scaler_fitted_on': 'training'},
'scaler': {'mean': [round(v, 4) for v in scaler['mean']], 'std': [round(v, 4) for v in scaler['std']]},
'model': {'weights': [round(w, 4) for w in model['weights']], 'bias': round(model['bias'], 4),
'loss_first_epoch': round(model['losses'][0], 4), 'loss_last_epoch': round(model['losses'][-1], 4)},
'majority_class': majority,
'reproducibility': order_experiment(path),
'validation': {
'majority': evaluate([majority] * len(parts['validation']), parts['validation']),
'logistic': evaluate([predict_logistic(model, scaler, r) for r in parts['validation']], parts['validation']),
},
}
if final:
record['test'] = {
'majority': evaluate([majority] * len(parts['test']), parts['test']),
'logistic': evaluate([predict_logistic(model, scaler, r) for r in parts['test']], parts['test']),
}
accuracy, n = record['test']['logistic']['accuracy'], len(parts['test'])
# Normal approximation to the binomial: a rough guide to single-split noise, not an exact interval.
record['test']['logistic']['standard_error'] = round(math.sqrt(accuracy * (1 - accuracy) / n), 4)
return record
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument('--final', action='store_true', help='also score the test split (do this once)')
parser.add_argument('--out', help='write the run record to this JSON file')
args = parser.parse_args()
record = run(final=args.final)
text = json.dumps(record, indent=1)
if args.out:
Path(args.out).write_text(text + '\n')
v = record['validation']
print(f"rows {record['data']['rows']} split {json.dumps(record['split']['counts'])}")
print(f"validation accuracy majority {v['majority']['accuracy']:.4f} logistic {v['logistic']['accuracy']:.4f}")
if args.final:
t = record['test']
print(f"test accuracy majority {t['majority']['accuracy']:.4f} logistic {t['logistic']['accuracy']:.4f}")
if __name__ == '__main__':
main()Fit preprocessing using training data only
The scaler computes each feature’s mean and standard deviation from training rows. The same saved scaler transforms validation and test rows. Fitting it on the whole file would let evaluation data influence the representation used during training.
The trainer starts logistic regression from zero weights and uses fixed settings. The majority rule predicts the most common training label for every row. Both are evaluated on the same validation cases, so the learned model must demonstrate a benefit over a real simple alternative.
test_scaler_uses_training_rows_only compares the recorded means with both the training-only and whole-dataset calculations. Checking that a scaler file exists would miss this leakage. The implementation uses math.fsum for accurate accumulation, while the regression comparison permits a small floating-point tolerance across platforms.
This lesson does not rederive logistic regression or gradient descent. The operational requirement is to keep the trainer, preprocessing, data and configuration identifiable when results are compared.
Compare a frozen final run with its simple alternative
Use validation data to choose settings. Once they are fixed, --final adds test metrics to the record:
python baseline.py --final --out my-final-run.jsonrows 3810 split {"training": {"Cammeo": 1156, "Osmancik": 1508}, "validation": {"Cammeo": 248, "Osmancik": 342}, "test": {"Cammeo": 226, "Osmancik": 330}}
validation accuracy majority 0.5797 logistic 0.9271
test accuracy majority 0.5935 logistic 0.9299The learned classifier outperforms the majority rule on this split. Inspect per-class recall as well: a majority rule never predicts the minority class, even when its aggregate accuracy looks respectable.
| Model | Test accuracy | Cammeo recall | Osmancik recall |
|---|---|---|---|
| Majority rule | 0.5935 | 0 | 1 |
| Logistic regression | 0.9299 | 0.9248 | 0.9333 |
The flag is an explicit reporting boundary, not a lock that prevents repeated inspection of test data. If you repeatedly change settings after seeing this score, that set has become development feedback. A future candidate needs a declared comparison protocol and appropriate uncertainty analysis; a small score difference on one split is not enough by itself.
Diagnose changes with the run record
The JSON binds the dataset checksum, split salt and counts, training settings, scaler, learned parameters and metrics. Start an investigation by comparing these inputs before attributing a difference to the model algorithm.
Inspect the existing numerical run record
{
"data": {
"file": "rice.csv",
"sha256": "ac5b63d257bab6bf013453f29b99285baa664038d1023c02033135f6e320eaec",
"rows": 3810,
"source": "UCI Rice (Cammeo and Osmancik), CC BY 4.0, doi:10.24432/C5MW4Z"
},
"split": {
"salt": "rice-split-v1",
"method": "sha256(salt:raw feature text)",
"counts": {
"training": {
"Cammeo": 1156,
"Osmancik": 1508
},
"validation": {
"Cammeo": 248,
"Osmancik": 342
},
"test": {
"Cammeo": 226,
"Osmancik": 330
}
}
},
"settings": {
"learning_rate": 0.5,
"epochs": 200,
"positive_class": "Cammeo",
"scaler_fitted_on": "training"
},
"scaler": {
"mean": [
12687.9501,
454.8323,
189.0682,
86.3201,
0.8872,
12974.4981,
0.6617
],
"std": [
1741.9744,
35.8326,
17.565,
5.7355,
0.0208,
1787.521,
0.077
]
},
"model": {
"weights": [
0.7939,
1.0641,
1.2289,
-0.0949,
1.3097,
0.8908,
0.0971
],
"bias": -0.7315,
"loss_first_epoch": 0.6931,
"loss_last_epoch": 0.185
},
"majority_class": "Osmancik",
"reproducibility": {
"reversed_order": {
"seeded_shuffle": 1764,
"hash_split": 0
},
"sorted_by_area": {
"seeded_shuffle": 1764,
"hash_split": 0
},
"added_rows": {
"new_rows": 100,
"appended_seeded_shuffle": 288,
"appended_hash_split": 0,
"listed_first_seeded_shuffle": 1691,
"listed_first_hash_split": 0
},
"salt_changed": {
"salt": "rice-split-v2",
"rows": 3810,
"kept_split": 2060,
"expected_share_kept": 0.535
}
},
"validation": {
"majority": {
"rows": 590,
"correct": 342,
"accuracy": 0.5797,
"recall": {
"Cammeo": 0.0,
"Osmancik": 1.0
},
"confusion": {
"Cammeo": {
"Cammeo": 0,
"Osmancik": 248
},
"Osmancik": {
"Cammeo": 0,
"Osmancik": 342
}
}
},
"logistic": {
"rows": 590,
"correct": 547,
"accuracy": 0.9271,
"recall": {
"Cammeo": 0.8992,
"Osmancik": 0.9474
},
"confusion": {
"Cammeo": {
"Cammeo": 223,
"Osmancik": 25
},
"Osmancik": {
"Cammeo": 18,
"Osmancik": 324
}
}
}
},
"test": {
"majority": {
"rows": 556,
"correct": 330,
"accuracy": 0.5935,
"recall": {
"Cammeo": 0.0,
"Osmancik": 1.0
},
"confusion": {
"Cammeo": {
"Cammeo": 0,
"Osmancik": 226
},
"Osmancik": {
"Cammeo": 0,
"Osmancik": 330
}
}
},
"logistic": {
"rows": 556,
"correct": 517,
"accuracy": 0.9299,
"recall": {
"Cammeo": 0.9248,
"Osmancik": 0.9333
},
"confusion": {
"Cammeo": {
"Cammeo": 209,
"Osmancik": 17
},
"Osmancik": {
"Cammeo": 22,
"Osmancik": 308
}
},
"standard_error": 0.0108
}
}
}The record also compares reordered and extended versions of the data. Reversing the file moves 1764 rows under the seeded positional shuffle and 0 under the hash rule. The seed controls the permutation of positions, not the identity occupying each position.
test_changed_data_is_refused changes a label and checks the checksum guard. test_bad_value_names_the_line and test_non_finite_value_is_rejected exercise malformed measurements. test_split_missing_a_class_is_rejected prevents reporting per-class evaluation when a split lacks the required classes.
Design exercise: predict which fields change when you reorder the file, change the salt or fit the scaler on all rows. Use the existing tests to check your predictions. Then choose a split key for repeated measurements from one device and explain why the raw row text is insufficient.
Compare your reasoning
Reordering changes the file’s byte fingerprint and can move rows under the positional shuffle. It leaves the content-key hash memberships unchanged. Changing the salt can change those memberships, fitted statistics and model results. Fitting the scaler on all rows changes preprocessing using evaluation data. For repeated measurements from one device, group by device identity so that related readings remain together; distinct row text cannot provide that separation.
Read the explanation
Split by something the row carries with it, such as a salted hash of its values or of a group ID. Then file order, new rows and the Python version cannot move it.
Failure cases
Compare two changes before retraining: reorder the rows, then alter the data file. Reordering must preserve membership; changed bytes must fail the selected dataset check. The separate preprocessing test catches leakage even when the resulting feature values look numerically reasonable.
Check the input and failure boundaries
| Trigger | Required behavior |
|---|---|
| The data file differs from the recorded checksum | Refuse the changed dataset before reporting a comparable run. |
| A row has a missing or non-numeric value | Reject an incomplete or unparsable feature row. |
| A feature value is not finite | Reject nonfinite numeric values rather than propagating them into training. |
| An unknown class label | Reject labels outside the declared classification task. |
| Rows arrive in a different order | Retain each identified row’s partition membership. |
| New rows are added to the file | Keep existing row assignments stable; a larger dataset still has a new identity. |
| Scaling statistics computed on all rows | Use only training rows to fit means and scales. |
| A split has no rows of one class | Refuse a partition that cannot support the declared class comparison. |
| The largest digest rounds to an unassigned float endpoint | Use the integer hash boundary so every valid digest has an assigned partition. |
Deployment
This program trains and evaluates locally. Retain the dataset fingerprint, split rule, preprocessing settings and trainer revision with its report. The evaluation report is not a complete resumable checkpoint or a serving package; later MLOps lessons add setup and artifact identity around this existing trainer.
Scaling
Loading, splitting and feature preparation grow with the dataset, while training cost also depends on the optimizer and number of updates. Measure those stages separately before changing the algorithm. Hash-based membership avoids dependence on row order, but it does not address entity leakage or changing data distributions; the following split-design lesson owns those evaluation targets.
Key takeaways
- A metric needs identified data, membership and preprocessing to explain what was evaluated.
- Stable row membership and dataset identity are separate contracts.
- Training-only preprocessing prevents one source of evaluation leakage.
Best practices
- Inspect class coverage in every required partition.
- Fit preprocessing on training rows and reuse it unchanged for evaluation.
- Keep the final test decision separate from validation-driven development.
References
- UCI Rice dataset: source, feature definitions and CC BY 4.0 license.
- Python, hashlib: stable message digests for byte inputs.
- Python, math.fsum: accurate floating-point summation and its platform caveats.
Sources checked October 24, 2026. The original numerical record is retained; current local execution reproduces it within the bundled regression tolerances.